中科院的ICTCLAS,哈工大的ltp,东北大学的NIU Parser是学术界著名的分词器,我曾浅显读过一些ICTCLAS的代码,然而并不那么好读。jieba分词是python写成的一个算是工业界的分词开源库,其github地址为:https://github.com/fxsjy/jieba
jieba分词虽然效果上不如ICTCLAS和ltp,但是胜在python编写,代码清晰,扩展性好,对jieba有改进的想法可以很容易的自己写代码进行魔改。毕竟这样说好像自己就有能力改进jieba分词一样_(:з」∠)_
网上诸多关于jieba分词的分析,多已过时,曾经分析jieba分词采用trie树的数据结构云云的文章都已经过时,现在的jieba分词已经放弃trie树,采用前缀数组字典的方式存储词典。
本文分析的jieba分词基于2015年7月
左右的代码进行,日后jieba若更新,看缘分更新这一系列文章_(:з」∠)_
jieba分词的基本思路
jieba分词对已收录词和未收录词都有相应的算法进行处理,其处理的思路很简单,当然,过于简单的算法也是制约其召回率的原因之一。
其主要的处理思路如下:
-
加载词典dict.txt
-
从内存的词典中构建该句子的DAG(有向无环图)
-
对于词典中未收录词,使用HMM模型的viterbi算法尝试分词处理
-
已收录词和未收录词全部分词完毕后,使用dp寻找DAG的最大概率路径
-
输出分词结果
词典的加载
语料库和词典
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树又叫字典树,是一种常见的数据结构,用于在一个字符串列表中进行快速的字符串匹配。其核心思想是将拥有公共前缀的单词归一到一棵树下以减少查询的时间复杂度,其主要缺点是占用内存太大了。
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树
旧版本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
使用trie树的问题
本来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类型,效率提高了不少。
分词模式
jieba分词有多种模式可供选择。可选的模式包括:
-
全切分模式
-
精确模式
-
搜索引擎模式
同时也提供了HMM模型的开关。
其中全切分模式就是输出一个字串的所有分词,
精确模式是对句子的一个概率最佳分词,
而搜索引擎模式提供了精确模式的再分词,将长词再次拆分为短词。
效果大抵如下:
# encoding=utf-8
import jieba
seg_list = jieba.cut("我来到北京清华大学", cut_all=True)
print("Full Mode: " + "/ ".join(seg_list)) # 全模式
seg_list = jieba.cut("我来到北京清华大学", cut_all=False)
print("Default Mode: " + "/ ".join(seg_list)) # 精确模式
seg_list = jieba.cut("他来到了网易杭研大厦") # 默认是精确模式
print(", ".join(seg_list))
seg_list = jieba.cut_for_search("小明硕士毕业于中国科学院计算所,后在日本京都大学深造") # 搜索引擎模式
print(", ".join(seg_list))
的结果为
【全模式】: 我/ 来到/ 北京/ 清华/ 清华大学/ 华大/ 大学
【精确模式】: 我/ 来到/ 北京/ 清华大学
【新词识别】:他, 来到, 了, 网易, 杭研, 大厦 (此处,“杭研”并没有在词典中,但是也被Viterbi算法识别出来了)
【搜索引擎模式】: 小明, 硕士, 毕业, 于, 中国, 科学, 学院, 科学院, 中国科学院, 计算, 计算所, 后, 在, 日本, 京都, 大学, 日本京都大学, 深造
其中,新词识别即用HMM模型的Viterbi算法进行识别新词的结果。
值得详细研究的模式是精确模式,以及其用于识别新词的HMM模型和Viterbi算法。
jieba.cut()
在载入词典之后,jieba分词要进行分词操作,在代码中就是核心函数jieba.cut()
,代码如下:
def cut(self, sentence, cut_all=False, HMM=True):
'''
The main function that segments an entire sentence that contains
Chinese characters into seperated words.
Parameter:
- sentence: The str(unicode) to be segmented.
- cut_all: Model type. True for full pattern, False for accurate pattern.
- HMM: Whether to use the Hidden Markov Model.
'''
sentence = strdecode(sentence)
if cut_all:
re_han = re_han_cut_all
re_skip = re_skip_cut_all
else:
re_han = re_han_default
re_skip = re_skip_default
if cut_all:
cut_block = self.__cut_all
elif HMM:
cut_block = self.__cut_DAG
else:
cut_block = self.__cut_DAG_NO_HMM
blocks = re_han.split(sentence)
for blk in blocks:
if not blk:
continue
if re_han.match(blk):
for word in cut_block(blk):
yield word
else:
tmp = re_skip.split(blk)
for x in tmp:
if re_skip.match(x):
yield x
elif not cut_all:
for xx in x:
yield xx
else:
yield x
其中,
docstr中给出了默认的模式,精确分词 + HMM模型开启。
第12-23行进行了变量配置。
第24行做的事情是对句子进行中文的切分,把句子切分成一些只包含能处理的字符的块(block),丢弃掉特殊字符,因为一些词典中不包含的字符可能对分词产生影响。
24行中re_han默认值为re_han_default,是一个正则表达式,定义如下:
# \u4E00-\u9FD5a-zA-Z0-9+#&\._ : All non-space characters. Will be handled with re_han
re_han_default = re.compile("([\u4E00-\u9FD5a-zA-Z0-9+#&\._]+)", re.U)
可以看到诸如空格、制表符、换行符之类的特殊字符在这个正则表达式被过滤掉。
25-40行使用yield实现了返回结果是一个迭代器,即文档中所说:
jieba.cut 以及 jieba.cut_for_search 返回的结构都是一个可迭代的 generator,可以使用 for 循环来获得分词后得到的每一个词语(unicode)
其中,31-40行,如果遇到block是非常规字符,就正则验证一下直接输出这个块作为这个块的分词结果。如标点符号等等,在分词结果中都是单独一个词的形式出现的,就是这十行代码进行的。
关键在28-30行,如果是可分词的block,那么就调用函数cut_block
,默认是cut_block = self.__cut_DAG
,进行分词
jieba.__cut_DAG()
__cut_DAG
的作用是按照DAG,即有向无环图进行切分单词。其代码如下:
def __cut_DAG(self, sentence):
DAG = self.get_DAG(sentence)
route = {}
self.calc(sentence, DAG, route)
x = 0
buf = ''
N = len(sentence)
while x < N:
y = route[x][1] + 1
l_word = sentence[x:y]
if y - x == 1:
buf += l_word
else:
if buf:
if len(buf) == 1:
yield buf
buf = ''
else:
if not self.FREQ.get(buf):
recognized = finalseg.cut(buf)
for t in recognized:
yield t
else:
for elem in buf:
yield elem
buf = ''
yield l_word
x = y
if buf:
if len(buf) == 1:
yield buf
elif not self.FREQ.get(buf):
recognized = finalseg.cut(buf)
for t in recognized:
yield t
else:
for elem in buf:
yield elem
对于一个sentence,首先 获取到其有向无环图DAG,然后利用dp对该有向无环图进行最大概率路径的计算。
计算出最大概率路径后迭代,如果是登录词,则输出,如果是单字,将其中连在一起的单字找出来,这些可能是未登录词,使用HMM模型进行分词,分词结束之后输出。
至此,分词结束。
其中,值得跟进研究的是第2行获取DAG
,第4行计算最大概率路径
和第20和34行的使用HMM模型进行未登录词的分词
,在后面的文章中会进行解读。
DAG = self.get_DAG(sentence)
...
self.calc(sentence, DAG, route)
...
recognized = finalseg.cut(buf)
DAG(有向无环图)
有向无环图,directed acyclic graphs,简称DAG,是一种图的数据结构,其实很naive,就是没有环的有向图_(:з」∠)_
DAG在分词中的应用很广,无论是最大概率路径,还是后面套NN的做法,DAG都广泛存在于分词中。
因为DAG本身也是有向图,所以用邻接矩阵来表示是可行的,但是jieba采用了python的dict,更方便地表示DAG,其表示方法为:
{prior1:[next1,next2...,nextN],prior2:[next1',next2'...nextN']...}
以句子 "国庆节我在研究结巴分词"为例,其生成的DAG的dict表示为:
{0: [0, 1, 2], 1: [1], 2: [2], 3: [3], 4: [4], 5: [5, 6], 6: [6], 7: [7, 8], 8: [8], 9: [9, 10], 10: [10]}
其中,
国[0] 庆[1] 节[2] 我[3] 在[4] 研[5] 究[6] 结[7] 巴[8] 分[9] 词[10]
get_DAG()函数代码如下:
def get_DAG(self, sentence):
self.check_initialized()
DAG = {}
N = len(sentence)
for k in xrange(N):
tmplist = []
i = k
frag = sentence[k]
while i < N and frag in self.FREQ:
if self.FREQ[frag]:
tmplist.append(i)
i += 1
frag = sentence[k:i + 1]
if not tmplist:
tmplist.append(k)
DAG[k] = tmplist
return DAG
frag即fragment,可以看到代码循环切片句子,FREQ即是词典的{word:frequency}的dict
因为在载入词典的时候已经将word和word的所有前缀加入了词典,所以一旦frag not in FREQ,即可以断定frag和以frag为前缀的词不在词典里,可以跳出循环。
由此得到了DAG,下一步就是使用dp动态规划对最大概率路径进行求解。
最大概率路径
值得注意的是,DAG的每个结点,都是带权的,对于在词典里面的词语,其权重为其词频,即FREQ[word]。我们要求得route = (w1, w2, w3 ,.., wn),使得Σweight(wi)最大。
动态规划求解法
满足dp的条件有两个
-
重复子问题
-
最优子结构
我们来分析最大概率路径问题。
重复子问题
对于结点Wi和其可能存在的多个后继Wj和Wk,有:
任意通过Wi到达Wj的路径的权重为该路径通过Wi的路径权重加上Wj的权重{Ri->j} = {Ri + weight(j)} ;
任意通过Wi到达Wk的路径的权重为该路径通过Wi的路径权重加上Wk的权重{Ri->k} = {Ri + weight(k)} ;
即对于拥有公共前驱Wi的节点Wj和Wk,需要重复计算到达Wi的路径。
最优子结构
对于整个句子的最优路径Rmax和一个末端节点Wx,对于其可能存在的多个前驱Wi,Wj,Wk...,设到达Wi,Wj,Wk的最大路径分别为Rmaxi,Rmaxj,Rmaxk,有:
Rmax = max(Rmaxi,Rmaxj,Rmaxk...) + weight(Wx)
于是问题转化为
求Rmaxi, Rmaxj, Rmaxk...
组成了最优子结构,子结构里面的最优解是全局的最优解的一部分。
状态转移方程
由上一节,很容易写出其状态转移方程
Rmax = max{(Rmaxi,Rmaxj,Rmaxk...) + weight(Wx)}
代码
上面理解了,代码很简单,注意一点total的值在加载词典的时候求出来的,为词频之和,然后有一些诸如求对数的trick,代码是典型的dp求解代码。
def calc(self, sentence, DAG, route):
N = len(sentence)
route[N] = (0, 0)
logtotal = log(self.total)
for idx in xrange(N - 1, -1, -1):
route[idx] = max((log(self.FREQ.get(sentence[idx:x + 1]) or 1) -
logtotal + route[x + 1][0], x) for x in DAG[idx])