为什么要说分词呢?其实这个话题挺大的。所以准备分几篇来写,这次先写第一篇。
写给别人看,也写给自己。毕竟,自己在思特奇也做了好久了,写点有意思的东西,结交一些有兴趣的朋友。
一是确实最近的一些实践给了自己很大的启发,另一方面,所谓互联网下半场的来临,那么,如果从一个增量的系统到一个存量的系统,那么数据挖掘就显得尤为重要了。怎么挖掘?或者说,怎么让计算机去帮我们挖掘呢?
不可否认,网上的一些大数据学习的资料,是翻译过来的,因为个人水平的不同,有时候很容易把人带到沟里去。尤其在牵扯到了一些算法的解释,更是莫名其妙,让不少初学者往往觉得这门课太难了。实际上,我可以说,它非常简单。简单到你几乎可以从你的生活中去恍然大悟。
经过持续的沉淀,我觉得从分词来切入是非常合适的。(这里特别感谢billing的王金山,感谢你的帮助)
我并不是想重复造轮子,而是通过造轮子,来解释一些基本的现象,以及一些算法为什么要这么用。如果你想做的更好,不停留在被动程序员的层次,那么希望你有兴趣读一下,或许对你有帮助,当然,也希望拍砖。
最近和一个朋友聊天,聊到了机器学习,说到大数据的应用,他说,网上说的这些名词,大多数都太空泛了。真正做到实践中去的,也就是几个大公司而已,中小公司基本没有这个实力。个人看数据挖掘,除了一些工具套路,实际上你真的会大数据吗?实际上,就算在大公司,目前的数据挖掘,也往往是刚起步而已。
和我说话的这个人,是今日头条的算法专家。
确实,怎么说是刚起步呢?
其实机器学习并不那么高深,所谓的机器大脑,目前阶段说穿了就一句话,"用概率学解释当前的事件是否可以映射成为另一个事件。
那么,这句话怎么理解?无论是什么学习,80%都离不开两个算法(实际是一个,另一个是这个的扩展),马尔科夫模型和贝叶斯算法。只要你能够沉淀一些这两个算法的知识和实践,当今大多开源机器学习,归类算法,推荐算法你都能玩的游刃有余。因为这些大部分都是以这两个为基础的变种。
什么东西都必须实践才能有沉淀,我一直秉持的这个观念。
分词和自然语言语音识别,现在取得了巨大的成就,基本上能把误差缩小到5%以内。甚至口音,方言,对于自然语言识别已经不是问题。分词而言,模糊的错误分词,对于识别也不再是难事。靠的是什么,靠的就是马科莫夫和贝叶斯。所以,我想从分词的角度,来阐释概率是如何计算出来的,以及显示和隐式到底是什么意思。
这不是什么了不得的技术,你我一样能轻松掌握,让我们从底层一步步来看看它是怎么实现的。
首先,要分词,先要有词库(废话)。
我们先不管词库是怎么来的(下几个帖子我会详细的解释,如何通过训练材料获得词库)。反正不是你一个个词敲击进去得到的。这样的方法简直是不可想象的。当然,你也可以这么做,不过你要花费很长的时间,还未必能达到你要的结果。现在,每天都会发生新的词汇,比如说"十动然拒"。你手动录入肯定是跟不上的。
一个真正的词库,实际一个txt足矣。
我的字库的组织方式是
一万条 4 m
一万次 16 m
一万步 30 m
一万盏 4 m
一万种 2 m
一万贯 4 m
一万遍 9 m
一万里 4 m
一万间 5 m
一行包含了三个元素,第一个是词本身,第二个是词在训练资料里出现的频率(词频),最后一个是词性标注(名词,动词,形容词,介词等等)
后两个参数我们先不用管(以后我会详细解释,为什么词典里需要后面两个参数,这两个参数是用于提炼一个句子中的关键词而是用的,也就是抽象一个句子的含义,我们需要后面两个参数),先以最简单的,只是单词为准。
我们有了词典,这里面大概有38万个中文词组。(目前以2016年6月份为准的中文词汇统计,差不多就这么多)
那么我们如何加载到内存里去效率最高呢?
或许,你会想,最简单的办法,给定一个句子,
比如:
"哪里见过你呀,朋友"
我可以for 38万次,用字符串比较看看是否每个词在这个句子里是包含的,直接用strstr()搞定。
是的,这样的办法很暴力,在服务器上,或许你还行,在一些运行效能低一些的机器上,可能就不行了。
有没有更好的办法?做到最高的判断效率?
当然有的,这就是算法的魅力了,先看看,我们怎么组织词典数据?
我们可以以树的形式记录词典,那么,怎么把38万个词变成一棵树呢?
我们知道,每个词,无论长短,它都是由一个个中文字符组成,我们把这个字作为一个个单独的关联元素。
那么我可以这样的组织数据
b比如,这几个词,"你" "你们" "你们的" "你的" "我" "他"
我们可以用上述方法无限类推,把38万的词,转换为一棵树。
这棵树和一般的树有些不一样,为了提高检索效率,我们必须把树种的子节点,变换为一个hash数组,这样,在给定任何一个字的基础上,我们都能快速获得这个字在所在层级的子节点位置。
我们可以给出节点的设定
//永久节点模型
struct _RuneLinkNode
{
CHashTable m_hmapRuneNextMap;
_Rune m_objRune;
char m_pWord[MAX_WORD_LENGTH];
int m_nPoolIndex;
char m_cUsed; //0为未使用,1为使用
};
最后一个叶子节点,也就是一个词的末尾,必须记录整个链条上的完整词汇,也就是m_pWord,便于抽取。过程节点,如果不是一个完整的词汇,则不必在记录这个词汇。
那么,给定一个句子,如可快读的遍历这棵树呢?
其实很简单,就和小孩子的积木形状填空游戏毫无二致。
还是那句话 "哪里见过你呀,朋友"
我们可以循环从这个字的第一个字开始,在树上寻找,如果找到这个字(哪),再获取这个字的下一个字(里),在这个字(哪)的下一个hashmap数组里去寻找(里),如果这个字(里)还有子节点,那么继续在这个子节点去寻找下一个字(见),直到找不到为止。
看上去可能有点绕口,那么程序上怎么实现呢?每次需要记录上一个字的位置,很烦的,因为我根本不知道一个词会有多少个字,比如"南无阿弥佗佛妈咪妈咪哄"这个词,我要记录多少次呀?是的,看上去很烦,但是,记得程序中有一个叫做递归的东西不?我们可以用一个递归函数,34行解决这个问题。
_RuneLinkNode* CWordBase::Set_HashMap_Word_Tree(_RuneLinkNode* pRuneNode, _Rune* pRune, int nLayer)
{
int nOfficeSet = pRuneNode->m_hmapRuneNextMap.Get_Hash_Box_Data((char* )pRune->m_szRune);
if(nOfficeSet > 0)
{
_RuneLinkNode* pCurrRuneNode = m_objNodePool.Get_NodeOffset_Ptr(nOfficeSet);
if(pCurrRuneNode->m_objRune == (*pRune))
{
//如果找到了,则返回当前节点
return pCurrRuneNode;
}
}
//如果没找到,则创建新的
_RuneLinkNode* pNode = m_objNodePool.Create(nLayer);
//printf("[CWordBase::Set_HashMap_Word_Tree]pNode=0x%08x.\n", pNode);
if(NULL == pNode)
{
printf("[CWordBase::Set_HashMap_Word_Tree]node pool is empty.\n");
return NULL;
}
int nNodeOffset = m_objNodePool.Get_Node_Offset(pNode);
pNode->m_objRune = (*pRune);
int nPos = pRuneNode->m_hmapRuneNextMap.Add_Hash_Data((char* )pRune->m_szRune, nNodeOffset);
if(-1 == nPos)
{
printf("[CWordBase::Set_HashMap_Word_Tree]tree node child is full.\n");
m_objNodePool.Delete(pNode);
return NULL;
}
return pNode;
}
这样,我们只需要按照肉眼的方式,读一遍句子,所有的词就被切分出来了。因为每层树的子节点都是hashmap,所以词的命中几乎和你读一遍句子for循环的次数是一致的,也就是说,"哪里见过你呀,朋友" 9个字我只需要9次循环就得到了全部的分词,比38万次强多了吧。分词结果是
[Cut]/哪里/见过/你/呀/,/朋友/
你看,机械分词就这么简单。那么,你可能会问。
分词是简单了,但是加载38万的词库,要消耗不少时间吧。
是的,38万个词,形成树,在我的测试服务器上,要消耗40秒左右。
我不可能为没次加载都付出40秒,启动太慢了,尤其在程序core掉的时候,有没有办法达到0秒启动呢?
当然,借助共享内存,我们可以把一棵树完整的压到共享内存中去,这样,当程序重启的时候,我只要得到共享内存的首地址,整个树就会瞬间还原了。怎么做呢?
树的主节点,可以是共享内存的开始节点。后面的叶子节点中的数据,我们不在用Node指针记录,直接用相对主节点的偏移量记录即可。使用的时候,我根据主节点的内存地址+偏移长度,直接还原成当前指针就可以了。所有的节点,都使用pool内存池的方式存储,用到添加节点的时候,从pool里面取得就行。
整个机械分词法+共享内存,也就500来行代码就搞定了,有兴趣可以看看我的实现。
完整代码,请看我的