中科院的ICTCLAS,哈工大的ltp,东北大学的NIU Parser是学术界著名的分词器,我曾浅显读过一些ICTCLAS的代码,然而并不那么好读。jieba分词是python写成的一个算是工业界的分词开源库,其github地址为:https://github.com/fxsjy/jiebapython
jieba分词虽然效果上不如ICTCLAS和ltp,可是胜在python编写,代码清晰,扩展性好,对jieba有改进的想法能够很容易的本身写代码进行魔改。毕竟这样说好像本身就有能力改进jieba分词同样_(:з」∠)_git
网上诸多关于jieba分词的分析,多已过期,曾经分析jieba分词采用trie树的数据结构云云的文章都已通过时,如今的jieba分词已经放弃trie树,采用前缀数组字典的方式存储词典。github
本文分析的jieba分词基于2015年7月
左右的代码进行,往后jieba若更新,看缘分更新这一系列文章_(:з」∠)_算法
jieba分词对已收录词和未收录词都有相应的算法进行处理,其处理的思路很简单,固然,过于简单的算法也是制约其召回率的缘由之一。数组
其主要的处理思路以下:数据结构
加载词典dict.txtapp
从内存的词典中构建该句子的DAG(有向无环图)函数
对于词典中未收录词,使用HMM模型的viterbi算法尝试分词处理性能
已收录词和未收录词所有分词完毕后,使用dp寻找DAG的最大几率路径spa
输出分词结果
jieba分词默认的模型使用了一些语料来作训练集,在 https://github.com/fxsjy/jieba/issues/7 中,做者说
来源主要有两个,一个是网上能下载到的1998人民日报的切分语料还有一个msr的切分语料。另外一个是我本身收集的一些txt小说,用ictclas把他们切分(可能有必定偏差)。 而后用python脚本统计词频。
jieba分词的默认语料库选择看起来满随意的_(:з」∠)_,做者也吐槽高质量的语料库很差找,因此若是须要在生产环境使用jieba分词,尽可能本身寻找一些高质量的语料库来作训练集。
语料库中全部的词语被用来作两件事情:
对词语的频率进行统计,做为登陆词使用
对单字在词语中的出现位置进行统计,使用BMES模型进行统计,供后面套HMM模型Viterbi算法使用,这个后面说。
统计后的结果保存在dict.txt中,摘录其部分结构以下:
上访 212 v 上访事件 3 n 上访信 3 nt 上访户 3 n 上访者 5 n 上证 120 j 上证所 8 nt 上证指数 3 n 上证综指 3 n 上诉 187 v 上诉书 3 n 上诉人 3 n 上诉期 3 b 上诉状 4 n 上课 650 v
其中,第一列是中文词语
,第二列是词频
,第三列是词性,jieba分词如今的版本除了分词也提供词性标注等其余功能,这个不在本文讨论范围内,能够忽略第三列。jieba分词全部的统计来源,就是这个语料库产生的两个模型文件。
jieba分词为了快速地索引词典以加快分词性能,使用了前缀数组的方式构造了一个dict用于存储词典。
在旧版本的jieba分词中,jieba采用trie树的数据结构来存储,其实对于python来讲,使用trie树显得很是多余,我将对新老版本的字典加载分别进行分析。
trie树又叫字典树,是一种常见的数据结构,用于在一个字符串列表中进行快速的字符串匹配。其核心思想是将拥有公共前缀的单词归一到一棵树下以减小查询的时间复杂度,其主要缺点是占用内存太大了。
trie树按以下方法构造:
trie树的根节点是空,不表明任何含义
其余每一个节点只有一个字符,词典中全部词的第一个字的集合做为第一层叶子节点,以字符α开头的单词挂在以α为根节点的子树下,全部以α开头的单词的第二个字的集合做为α子树下的第一层叶子节点,以此类推
从根节点到某一节点,路径上通过的字符链接起来,为该节点对应的字符串
一个以and at as cn com
构造的trie树以下图:
查找过程以下:
从根结点开始一次搜索;
取得要查找关键词的第一个字母,并根据该字母选择对应的子树并转到该子树继续进行检索;
在相应的子树上,取得要查找关键词的第二个字母,并进一步选择对应的子树进行检索。
迭代过程……
在某个结点处,关键词的全部字母已被取出,则读取附在该结点上的信息,即完成查找。其余操做相似处理.
如查询at,能够找到路径root-a-t
的路径,对于单词av,从root找到a后,在a的叶子节点下面不能找到v结点,则查找失败。
trie树的查找时间复杂度为O(k),k = len(s),s为目标串。
二叉查找树的查找时间复杂度为O(lgn),比起二叉查找树,trie树的查找和结点数量无关,所以更加适合词汇量大的状况。
可是trie树对空间的消耗是很大的,是一个典型的空间换时间的数据结构。
旧版本jieba分词中关于trie树的生成代码以下:
def gen_trie(f_name): lfreq = {} trie = {} ltotal = 0.0 with open(f_name, 'rb') as f: lineno = 0 for line in f.read().rstrip().decode('utf-8').split('\n'): lineno += 1 try: word,freq,_ = line.split(' ') freq = float(freq) lfreq[word] = freq ltotal+=freq p = trie for c in word: if c not in p: p[c] ={} p = p[c] p['']='' #ending flag except ValueError, e: logger.debug('%s at line %s %s' % (f_name, lineno, line)) raise ValueError, e return trie, lfreq, ltotal
代码很简单,遍历每行文件,对于每一个单词的每一个字母,在trie树(trie和p变量)中查找是否存在,若是存在,则挂到下面,若是不存在,就创建新子树。
jieba分词采用python 的dict来存储树,这也是python对树的数据结构的通用作法。
我写了一个函数来直观输出其生成的trie树,代码以下:
def print_trie(tree, buff, level = 0, prefix=''): count = len(tree.items()) for k,v in tree.items(): count -= 1 buff.append('%s +- %s' % ( prefix , k if k!='' else 'NULL')) if v: if count == 0: print_trie(v, buff, level + 1, prefix + ' ') else: print_trie(v, buff, level + 1, prefix + ' | ') pass pass trie, list_freq, total = gen_trie('a.txt') buff = ['ROOT'] print_trie(trie, buff, 0) print('\n'.join(buff))
使用上面列举出的dict.txt的部分词典做为样例,输出结果以下
ROOT +- 上 +- 证 | +- NULL | +- 所 | | +- NULL | +- 综 | | +- 指 | | +- NULL | +- 指 | +- 数 | +- NULL +- 诉 | +- NULL | +- 人 | | +- NULL | +- 状 | | +- NULL | +- 期 | | +- NULL | +- 书 | +- NULL +- 访 | +- NULL | +- 信 | | +- NULL | +- 事 | | +- 件 | | +- NULL | +- 者 | | +- NULL | +- 户 | +- NULL +- 课 +- NULL
原本jieba采用trie树的出发点是能够的,利用空间换取时间,加快分词的查找速度,加速全切分操做。可是问题在于python的dict原生使用哈希表实现,在dict中获取单词是近乎O(1)的时间复杂度,因此使用trie树,实际上是一种拈轻怕重的作法。
因而2014年某位同窗的PR修正了这一状况。
在2014年的某次PR中(https://github.com/fxsjy/jieba/pull/187 ),提交者将trie树改为前缀数组,大大地减小了内存的使用,加快了查找的速度。
如今jieba分词对于词典的操做,改成了一层word:freq的结构,存于lfreq中,其具体操做以下:
对于每一个收录词,若是其在lfreq中,则词频累积,若是不在则加入lfreq
对于该收录词的全部前缀进行上一步操做,如单词'cat',则对c, ca, cat分别进行第一步操做。除了单词自己的全部前缀词频初始为0.
def gen_pfdict(self, f): lfreq = {} ltotal = 0 f_name = resolve_filename(f) for lineno, line in enumerate(f, 1): try: line = line.strip().decode('utf-8') word, freq = line.split(' ')[:2] freq = int(freq) lfreq[word] = freq ltotal += freq for ch in xrange(len(word)): wfrag = word[:ch + 1] if wfrag not in lfreq: lfreq[wfrag] = 0 except ValueError: raise ValueError( 'invalid dictionary entry in %s at Line %s: %s' % (f_name, lineno, line)) f.close() return lfreq, ltotal
很朴素的作法,然而充分利用了python的dict类型,效率提升了很多。