jieba源码分析(二)

0、写在前面

jieba源码分析(一)里面已经jieba分词的一部分进行了分析,本文主要解决分词的另一块:未登陆词,也就是我们常说的新词。对于这些新词,我们前面所说的前缀词典中是不存在的,那么之前的分词方法自然就不能适用了。为了解决这一问题,jieba使用了隐马尔科夫(HMM)模型。关于HMM模型的具体细节,这里不会过多介绍,网上也已经有很多资源可以参考

54nlp网站HMM相关资源汇总

刘建平博客——隐马尔科夫模型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~

猜你喜欢

转载自blog.csdn.net/Kaiyuan_sjtu/article/details/83623732