0、写在前面
在jieba源码分析(一)里面已经jieba分词的一部分进行了分析,本文主要解决分词的另一块:未登陆词,也就是我们常说的新词。对于这些新词,我们前面所说的前缀词典中是不存在的,那么之前的分词方法自然就不能适用了。为了解决这一问题,jieba使用了隐马尔科夫(HMM)模型。关于HMM模型的具体细节,这里不会过多介绍,网上也已经有很多资源可以参考
1、算法简介及实例
利用HMM模型进行分词,主要就是将分词问题看作是一个序列标注(sequence labeling)问题。其中,句子为观测序列,分词结果为状态序列。现在我们的问题就转变成了:已知句子和HMM模型,求解最有可能的对于的状态序列(即分词结果)。熟悉HMM的同学应该知道,对应于这个问题,使用的算法就是Viterbi。
举个栗子
今天我们不“去北京大学玩”,我们“去上海交通大学玩”。我们可以很明显地看出分词结果是“去/上海交通大学/玩”。
对于分词状态,由于jieba分词中使用的是4-tag,因此我们以4-tag进行计算。4-tag,也就是每个字处在词语中的4种可能状态,B、M、E、S,分别表示Begin(这个字处于词的开始位置)、Middle(这个字处于词的中间位置)、End(这个字处于词的结束位置)、Single(这个字是单字成词)。其中,B后面只能接M或者E;而M后面只能接M或E;E后面只能接S或B;S后面只能接B或S。具体可以看下图。
注意,这里“去上海交通大学玩”是观测状态序列,而对应的“SBMMMMES”则是隐藏状态序列。
HMM模型有几个重要的参数:
- 初始状态概率π
- 状态转移概率A:比如上图中的S→B的概率
- 状态发射概率B:比如上图中的S→去的概率
初始状态概率
状态初始概率表示,每个词初始状态的概率;jieba分词训练出的状态初始概率模型如下所示。其中的概率值都是取对数之后的结果(可以让概率相乘转变为概率相加),其中-3.14e+100代表负无穷,对应的概率值就是0。这个概率表说明一个词中的第一个字属于{B、M、E、S}这四种状态的概率,如下可以看出,E和M的概率都是0,这也和实际相符合:开头的第一个字只可能是每个词的首字(B),或者单字成词(S)。这部分对应jieba/finaseg/prob_start.py,具体可以进入源码查看。
P={'B': -0.26268660809250016,
'E': -3.14e+100,
'M': -3.14e+100,
'S': -1.4652633398537678}
状态转移概率
再看jieba中的状态转移概率,其实就是一个嵌套的词典,数值是概率值求对数后的值,如下所示。这部分在jieba/finaseg/prob_trans.py,具体可以查看源码。
P={'B': {'E': -0.510825623765990, 'M': -0.916290731874155},
'E': {'B': -0.5897149736854513, 'S': -0.8085250474669937},
'M': {'E': -0.33344856811948514, 'M': -1.2603623820268226},
'S': {'B': -0.7211965654669841, 'S': -0.6658631448798212}}
状态发射概率
根据HMM模型中观测独立性假设,发射概率,即观测值只取决于当前状态值,这部分对应jieba/finaseg/prob_emit.py,具体可以查看源码。
2、源码分析
ieba分词中HMM模型识别未登录词的源码目录在jieba/finalseg/下,
__init__.py 实现了HMM模型识别未登录词;
prob_start.py 存储了已经训练好的HMM模型的状态初始概率表;
prob_trans.py 存储了已经训练好的HMM模型的状态转移概率表;
prob_emit.py 存储了已经训练好的HMM模型的状态发射概率表;
基于HMM的分词流程
jieba分词会首先调用函数cut(sentence),cut函数会先将输入句子进行解码,然后调用__cut函数进行处理。__cut函数就是jieba分词中实现HMM模型分词的主函数。__cut函数会首先调用viterbi算法,求出输入句子的隐藏状态,然后基于隐藏状态进行分词。
def __cut(sentence):
"""
jieba首先会调用函数cut(sentence),cut函数会先将输入的句子进行解码,
然后调用__cut()函数进行处理。该函数是实现HMM模型分词的主函数
__cut()函数首先调用viterbi算法, 求出输入句子的隐藏状态,然后基于隐藏状态分词
"""
global emit_P
# 通过viterbi算法求出隐藏状态序列
prob, pos_list = viterbi(sentence, 'BMES', start_P, trans_P, emit_P)
begin, nexti = 0, 0
# print pos_list, sentence
# 基于隐藏状态进行分词
for i, char in enumerate(sentence):
pos = pos_list[i]
# 如果字所处的位置是开始位置 Begin
if pos == 'B':
begin = i
# 如果字所处的位置是结束位置 END
elif pos == 'E':
# 这个子序列就一个分词
yield sentence[begin:i + 1]
nexti = i + 1
# 如果单独成字 Single
elif pos == 'S':
yield char
nexti = i + 1
# 剩余的直接作为一个分词,返回
if nexti < len(sentence):
yield sentence[nexti:]
Viterbi算法
关于Viterbi算法的具体细节参考李航老师的小蓝书,后面还有一个具体的例子。
def viterbi(obs, states, start_p, trans_p, emit_p):
"""
viterbi函数会先计算各个初始状态的对数概率值,
然后递推计算,每时刻某状态的对数概率值取决于
上一时刻的对数概率值、
上一时刻的状态到这一时刻的状态的转移概率、
这一时刻状态转移到当前的字的发射概率三部分组成。
"""
V = [{}] # tabular表示Viterbi变量,下标表示时间
path = {} # 记录从当前状态回退路径
# 时刻t=0,初始状态
for y in states: # init
V[0][y] = start_p[y] + emit_p[y].get(obs[0], MIN_FLOAT)
path[y] = [y]
# 时刻t = 1,...,len(obs) - 1
for t in xrange(1, len(obs)):
V.append({})
newpath = {}
# 当前时刻所处的各种可能的状态
for y in states:
# 获得发射概率对数
em_p = emit_p[y].get(obs[t], MIN_FLOAT)
# 分别获得上一时刻的状态的概率对数、该状态到本时刻的状态的转移概率对数
# 以及本时刻的状态的发射概率
(prob, state) = max(
[(V[t - 1][y0] + trans_p[y0].get(y, MIN_FLOAT) + em_p, y0) for y0 in PrevStatus[y]])
V[t][y] = prob
# 将上一时刻最有的状态 + 这一时刻的状态
newpath[y] = path[state] + [y]
path = newpath
# 最后一个时刻
(prob, state) = max((V[len(obs) - 1][y], y) for y in 'ES')
#返回最大概率对数和最有路径
return (prob, path[state])
以上就是jieba关于分词部分的大致内容,还有一些比如全模式分词cut_all()、搜索模式cut_for_search()等会补充到代码里上传至Github。
enjoy yourself~