/*自定义导航栏*/

【结巴分词资料汇编】结巴中文分词源码分析(2)

结巴中文分词源码分析(2)

作者:白宁超

2016年11月23日16:49:36

摘要:结巴中文分词的特点如下:支持三种分词模式:(精确模式,试图将句子最精确地切开,适合文本分析;全模式,把句子中所有的可以成词的词语都扫描出来, 速度非常快,但是不能解决歧义;搜索引擎模式,在精确模式的基础上,对长词再次切分,提高召回率,适合用于搜索引擎分词。)、支持繁体分词、支持自定义词典、MIT 授权协议。本文系列文章一是对官方文档的介绍,文章二是引用收集网友对结巴分词源码的分析,文章三是对基本操作代码示例演示。(本文原创汇编而成,转载请标明出处结巴中文分词源码分析(2)

目录:

【结巴分词资料汇编】结巴中文分词官方文档分析(1)

【结巴分词资料汇编】结巴中文分词源码分析(2)

【结巴分词资料汇编】结巴中文分词基本操作(3)

1 中文分词介绍


 中文分词特点:

  1. 词是最小的能够独立活动的有意义的语言成分
  2. 汉语是以字位单位,不像西方语言,词与词之间没有空格之类的标志指示词的边界
  3. 分词问题为中文文本处理的基础性工作,分词的好坏对后面的中文信息处理其关键作用

中文分词的难点 :

  1. 分词规范,词的定义还不明确 (《统计自然语言处理》宗成庆)
  2. 歧义切分问题,交集型切分问题,多义组合型切分歧义等
    结婚的和尚未结婚的 =>
    结婚/的/和/尚未/结婚/的
    结婚/的/和尚/未/结婚/的
  3. 未登录词问题有两种解释:一是已有的词表中没有收录的词,二是已有的训练语料中未曾出现过的词,第二种含义中未登录词又称OOV(Out of Vocabulary)。对于大规模真实文本来说,未登录词对于分词的精度的影响远超歧义切分。一些网络新词,自造词一般都属于这些词。

汉语分词方法:

  1. 基于字典、词库匹配的分词方法(基于规则)
    基于字符串匹配分词,机械分词算法。将待分的字符串与一个充分大的机器词典中的词条进行匹配。分为正向匹配和逆向匹配;最大长度匹配和最小长度匹配;单纯分词和分词与标注过程相结合的一体化方法。所以常用的有:正向最大匹配,逆向最大匹配,最少切分法。实际应用中,将机械分词作为初分手段,利用语言信息提高切分准确率。优先识别具有明显特征的词,以这些词为断点,将原字符串分为较小字符串再机械匹配,以减少匹配错误率,或将分词与词类标注结合。
  2. 基于词频度统计的分词方法(基于统计)
    相邻的字同时出现的次数越多,越有可能构成一个词语,对语料中的字组频度进行统计,基于词的频度统计的分词方法是一种全切分方法。jieba是基于统计的分词方法,jieba分词采用了动态规划查找最大概率路径, 找出基于词频的最大切分组合,对于未登录词,采用了基于汉字成词能力的HMM模型,使用了Viterbi算法。
  3. 基于知识理解的分词方法。
    该方法主要基于句法、语法分析,并结合语义分析,通过对上下文内容所提供信息的分析对词进行定界,它通常包括三个部分:分词子系统、句法语义子系统、总控部分。在总控部分的协调下,分词子系统可以获得有关词、句子等的句法和语义信息来对分词歧义进行判断。这类方法试图让机器具有人类的理解能力,需要使用大量的语言知识和信息。由于汉语语言知识的笼统、复杂性,难以将各种语言信息组织成机器可直接读取的形式。因此目前基于知识的分词系统还处在试验阶段。

分词工具下载:

2 结巴中文分词介绍(详见篇一)


结巴分词的算法策略 :

  1. 基于前缀词典实现高效的词图扫描,生成句子中汉字所有可能成词情况所构成的有向无环图 (DAG)
  2. 采用了动态规划查找最大概率路径, 找出基于词频的最大切分组合
  3. 对于未登录词,采用了基于汉字成词能力的 HMM 模型,使用了 Viterbi 算法

结巴源码组织形式 :

jieba
|-- Changelog
|-- extra_dict
|   |-- dict.txt.big
|   |-- dict.txt.small
|   |-- idf.txt.big
|   `-- stop_words.txt
|-- jieba
|   |-- analyse
|   |   |-- analyzer.py
|   |   |-- idf.txt
|   |   |-- __init__.py
|   |   |-- textrank.py
|   |   `-- tfidf.py
|   |-- _compat.py
|   |-- dict.txt
|   |-- finalseg
|   |   |-- __init__.py
|   |   |-- prob_emit.p
|   |   |-- prob_emit.py
|   |   |-- prob_start.p
|   |   |-- prob_start.py
|   |   |-- prob_trans.p
|   |   `-- prob_trans.py
|   |-- __init__.py
|   |-- __main__.py
|   `-- posseg
|       |-- char_state_tab.p
|       |-- char_state_tab.py
|       |-- __init__.py
|       |-- prob_emit.p
|       |-- prob_emit.py
|       |-- prob_start.p
|       |-- prob_start.py
|       |-- prob_trans.p
|       |-- prob_trans.py
|       `-- viterbi.py
|-- LICENSE
|-- setup.py
`-- test
    |-- *.py
    |-- parallel
    |   |-- extract_tags.py
    |   `-- test*.py
    `-- userdict.txt

代码行数统计(没有统计test文件夹下的代码):

    256 ./posseg/prob_start.py
   5307 ./posseg/prob_trans.py
    304 ./posseg/__init__.py
  89372 ./posseg/prob_emit.py
  61087 ./posseg/char_state_tab.py
     53 ./posseg/viterbi.py
    578 ./__init__.py
      4 ./finalseg/prob_start.py
      4 ./finalseg/prob_trans.py
    107 ./finalseg/__init__.py
  35226 ./finalseg/prob_emit.py
     31 ./_compat.py
     50 ./__main__.py
    111 ./analyse/tfidf.py
     37 ./analyse/analyzer.py
    104 ./analyse/textrank.py
     18 ./analyse/__init__.py
 192649 总用量

其中prob*.py的文件是作者事先训练好的模型参数(λ=(A,B,π)),如状态转移概率、发射概率等。真正的代码数也就:304+53+578+107+31+50+111+37+104+18=1393行(不包括test文件中的代码),当然作者的代码写的比较简洁。

  • jieba分词模型的参数数据(λ=(A,B,π))是如何生成的?
    即文件finalseg/prob_*.py,中初始化概率,状态转移概率,发射概率怎么算出来的?
    来源主要有两个: 一个是网上能下载到的1998人民日报的切分语料还有一个msr的切分语料; 另一个是作者自己收集的一些txt小说,用ictclas把他们切分(可能有一定误差)。 然后用python脚本统计词频 具体详情
    要统计的主要有三个概率表:
    1) 位置转换概率(状态转移概率),即B(开头),M(中间),E(结尾),S(独立成词)四种状态的转移概率;
    2) 位置到单字的发射概率,比如P(“和”|M)表示一个词的中间出现”和”这个字的概率;
    3) 词语以某种状态开头的概率,其实只有两种,要么是B,要么是S。

3 结巴分词算法思想


如下算法实现分词:
1. 基于前缀词典实现高效的词图扫描,生成句子中汉字所有可能成词情况所构成的有向无环图 (DAG);

作者这个版本中使用前缀字典实现了词库的存储(即dict.txt文件中的内容),而弃用之前版本的trie树存储词库,想想也是,python中实现的trie树是基于dict类型的数据结构而且dict中又嵌套dict 类型,这样嵌套很深,导致内存耗费严重,详情见作者把trie树改成前缀词典的 缘由, 具体实现见 gen_pfdict(self, f_name)。接着说DAG有向无环图, 生成句子中汉字所有可能成词情况所构成的有向无环图。DAG根据我们生成的前缀字典来构造一个这样的DAG,对一个sentence DAG是以{key:list[i,j…], …}的字典结构存储,其中key是词的在sentence中的位置,list存放的是在sentence中以key开始且词sentence[key:i+1]在我们的前缀词典中 的以key开始i结尾的词的末位置i的列表,即list存放的是sentence中以位置key开始的可能的词语的结束位置,这样通过查字典得到词, 开始位置+结束位置列表。

例如:句子“抗日战争”生成的DAG中{0:[0,1,3]} 这样一个简单的DAG, 就是表示0位置开始, 在0,1,3位置都是词, 就是说0~0,0~1,0~3 即 “抗”,“抗日”,“抗日战争”这三个词 在dict.txt中是词。
2. 采用了动态规划查找最大概率路径, 找出基于词频的最大切分组合;
基于上面的DAG利用动态规划查找最大概率路径,这个理解DP算法的很容易就能明白了。根据动态规划查找最大概率路径的基本思路就是对句子从右往左反向计算最大概率,..依次类推, 最后得到最大概率路径, 得到最大概率的切分组合(这里满足最优子结构性质,可以利用反证法进行证明),这里代码实现中有个小trick,概率对数(可以让概率相乘的计算变成对数相加,防止相乘造成下溢,因为在语料、词库中每个词的出现概率平均下来还是很小的浮点数).
3. 对于未登录词,采用了基于汉字成词能力的 HMM 模型,使用了 Viterbi 算法;
未登录词(即jieba中文分词源码分析(一))中说的OOV, 其实就是词典 dict.txt 中没有记录的词。这里采用了HMM模型,HMM是个简单强大的模型,可以参考这个网络资源进行学习,HMM在实际应用中主要用来解决3类问题:

  1. a. 评估问题(概率计算问题) :即给定观测序列 O=O1,O2,O3…Ot和模型参数λ=(A,B,π),怎样有效计算这一观测序列出现的概率. (Forward-backward算法)
  2. b. 解码问题(预测问题) :即给定观测序列 O=O1,O2,O3…Ot和模型参数λ=(A,B,π),怎样寻找满足这种观察序列意义上最优的隐含状态序列S。 (viterbi算法,近似算法)
  3. c. 学习问题 :即HMM的模型参数λ=(A,B,π)未知,如何求出这3个参数以使观测序列O=O1,O2,O3…Ot的概率尽可能的大. (即用极大似然估计的方法估计参数,Baum-Welch,EM算法)

模型的关键相应参数λ=(A,B,π),经过作者对大量语料的训练, 得到了finalseg目录下的三个文件(初始化状态概率(π)即词语以某种状态开头的概率,其实只有两种,要么是B,要么是S。这个就是起始向量, 就是HMM系统的最初模型状态,对应文件prob_start.py;隐含状态概率转移矩A 即字的几种位置状态(BEMS四个状态来标记, B是开始begin位置, E是end, 是结束位置, M是middle, 是中间位置, S是single, 单独成词的位置)的转换概率,对应文件prob_trans.py;观测状态发射概率矩阵B 即位置状态到单字的发射概率,比如P(“狗”|M)表示一个词的中间出现”狗”这个字的概率,对应文件prob_emit.py)。这几个参数怎么得到的,具体方法见作者详述

4 结巴分词步骤

通过上面的举例即分析,想必大家对jieba分词应该有个大概的了解了。在上面的例子中我们注意到了,分词都是调用jieba.cut 这个函数,cut函数即是分词的入口,这个函数在文件jieba/__init__.py ,代码如下:

   #jieba分词的主函数,返回结果是一个可迭代的 generator
    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) # 解码为unicode
        # 不同模式下的正则
        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

         # 设置不同模式下的cut_block分词方法
        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): # re_han匹配的串
                for word in cut_block(blk):# 根据不同模式的方法进行分词
                    yield word
            else:# 按照re_skip正则表对blk进行重新切分
                tmp = re_skip.split(blk)# 返回list
                for x in tmp:
                    if re_skip.match(x):
                        yield x
                    elif not cut_all: # 精准模式下逐个字符输出
                        for xx in x:
                            yield xx
                    else: 
                        yield x

 其中参数sentence是需要分词的句子样本;cut_all是分词的模式,精确模式,全模式,默认使用HMM模型。下面根据cut函数来绘制出相应的流程图:



从图中可以看出,sentence先利用正则表达式切分,得到的词语列表blocks(re_han正则表达式使用了捕获括号,那么匹配的字符串也会被列入到list中返回),然后对切分后的每一个re_han匹配项blk词语利用cut_block方法进行具体的分词行为。

具体的分词流程:

  1.  给定待分词的句子, 使用正则(re_han)获取匹配的中文字符(和英文字符)切分成的短语列表;
  2.  利用get_DAG(sentence)函数获得待切分句子的DAG,首先检测(check_initialized)进程是否已经加载词库,若未初始化词库则调用initialize函数进行初始化,initialize中判断有无已经缓存的前缀词典cache_file文件,若有相应的cache文件则直接使用 marshal.load 方法加载前缀词典,若无则通过gen_pfdict对指定的词库dict.txt进行计算生成前缀词典,到jieba进程的初始化工作完成后就调用get_DAG获得句子的DAG;
  3. 根据cut_block指定具体的方法(__cut_all,__cut_DAG,__cut_DAG_NO_HMM)对每个短语使用DAG进行分词 ,如cut_block=__cut_DAG时则使用DAG(查字典)和动态规划, 得到最大概率路径, 对DAG中那些没有在字典中查到的字, 组合成一个新的片段短语, 使用HMM模型进行分词, 也就是作者说的识别新词, 即识别字典外的新词;
  4. 使用python的yield 语法生成一个词语生成器, 逐词语返回;

具体执行流程总结为下图:

这一节的具体源码注释见github jieba\__init__.py,接下来的几节将对源码进行进一步的说明。

5  前缀字典

作者这个版本(0.37)中使用前缀字典实现了词库的存储(即dict.txt文件中的内容),而弃用之前版本的trie树存储词库,Python中实现的trie树是基于dict类型的数据结构而且dict中又嵌套dict 类型,这样嵌套很深,导致内存耗费严重,具体点这里,下面是@gumblex commit的内容:

对于get_DAG()函数来说,用Trie数据结构,特别是在Python环境,内存使用量过大。经实验,可构造一个前缀集合解决问题。 
该集合储存词语及其前缀,如set([‘数’, ‘数据’, ‘数据结’, ‘数据结构’])。在句子中按字正向查找词语,在前缀列表中就继续查找,直到不在前缀列表中或超出句子范围。大约比原词库增加40%词条。 
该版本通过各项测试,与原版本分词结果相同。测试:一本5.7M的小说,用默认字典,64位Ubuntu,Python 2.7.6。 
Trie:第一次加载2.8秒,缓存加载1.1秒;内存277.4MB,平均速率724kB/s 
前缀字典:第一次加载2.1秒,缓存加载0.4秒;内存99.0MB,平均速率781kB/s 
此方法解决纯Python中Trie空间效率低下的问题。

jieba0.37版本中实际使用是前缀字典具体实现(对应代码中Tokenizer.FREQ字典),即就是利用python中的dict把dict.txt中出现的词作为key,出现频次作为value,比如sentece : “北京大学”,处理后的结果为:{u’北’:17860, u’北京’ :34488,u’北京大’: 0,u’北京大学’: 2053},具体详情见代码:def gen_pfdict(self, f_name):

def gen_pfdict(self, f_name):
        lfreq = {} # 字典存储  词条:出现次数
        ltotal = 0 # 所有词条的总的出现次数
        with open(f_name, 'rb') as f: # 打开文件 dict.txt 
            for lineno, line in enumerate(f, 1): # 行号,行
                try:
                    line = line.strip().decode('utf-8') # 解码为Unicode
                    word, freq = line.split(' ')[:2] # 获得词条 及其出现次数
                    freq = int(freq)
                    lfreq[word] = freq
                    ltotal += freq
                    for ch in xrange(len(word)):# 处理word的前缀
                        wfrag = word[:ch + 1]
                        if wfrag not in lfreq: # word前缀不在lfreq则其出现频次置0 
                            lfreq[wfrag] = 0
                except ValueError:
                    raise ValueError(
                        'invalid dictionary entry in %s at Line %s: %s' % (f_name, lineno, line))
        return lfreq, ltotal

 

6 DAG

DAG根据我们生成的前缀字典来构造一个这样的DAG,对一个sentence DAG是以{key:list[i,j…], …}的字典结构存储,其中key是词的在sentence中的位置,list存放的是在sentence中以key开始且词sentence[key:i+1]在我们的前缀词典中 的以key开始i结尾的词的末位置i的列表,即list存放的是sentence中以位置key开始的可能的词语的结束位置,这样通过查字典得到词, 开始位置+结束位置列表。 
例如句子”去北京大学玩“对应的DAG为: {0 : [0], 1 : [1, 2, 4], 2 : [2], 3 : [3, 4], 4 : [4], 5 : [5]} 
例如DAG中{0:[0]} 这样一个简单的DAG, 就是表示0位置对应的是词, 就是说0~0,即”去”这个词 在dict.txt中是词条。DAG中{1:[1,2,4]}, 就是表示1位置开始, 在1,2,4位置都是词, 就是说1~1,1~2,1~4 即 “北”,“北京”,“北京大学”这三个词 在dict.txt对应文件的词库中。

7 基于词频最大切分组合

通过上面两小节可以得知,我们已经有了词库(dict.txt)的前缀字典和待分词句子sentence的DAG,基于词频的最大切分 要在所有的路径中找出一条概率得分最大的路径,该怎么做呢? 
jieba中的思路就是使用动态规划方法,从后往前遍历,选择一个频度得分最大的一个切分组合。 
具体实现见代码,已给详细注释。

#动态规划,计算最大概率的切分组合
    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([ (概率对数,词语末字位置) for x in DAG[idx] ])
           # 以idx:(概率对数最大值,词语末字位置)键值对形式保存在route中
           # route[x+1][0] 表示 词路径[x+1,N-1]的最大概率对数,
           # [x+1][0]即表示取句子x+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])

从代码中可以看出calc是一个自底向上的动态规划(重叠子问题、最优子结构),它从sentence的最后一个字(N-1)开始倒序遍历sentence的字(idx)的方式,计算子句sentence[isdx~N-1]概率对数得分(这里利用DAG及历史计算结果route实现,同时赞下 作者的概率使用概率对数 这样有效防止 下溢问题)。然后将概率对数得分最高的情况以(概率对数,词语最后一个字的位置)这样的tuple保存在route中。 
根据上面的结束写了如下的测试:
输出结果为:

“去北京大学玩”的前缀字典: 
去 123402 
去北 0 
去北京 0 
去北京大 0 
去北京大学 0 
去北京大学玩 0 
“去北京大学玩”的DAG: 
0 : [0] 
1 : [1, 2, 4] 
2 : [2] 
3 : [3, 4] 
4 : [4] 
5 : [5] 
route: 
{0: (-26.039894284878688, 0), 1: (-19.851543754900984, 4), 2: (-26.6931716802707, 2), 3: (-17.573864399983357, 4), 4: (-17.709674112779485, 4), 5: (-9.567048044164698, 5), 6: (0, 0)} 
去/北京大学/玩

8 未登录词问题

在jieba中文分词的第一节曾提到未登录词问题

  • 中文分词的难点

    1. 分词规范,词的定义还不明确 (《统计自然语言处理》宗成庆)
    2. 歧义切分问题,交集型切分问题,多义组合型切分歧义等 结婚的和尚未结婚的 => 结婚/的/和/尚未/结婚/的 结婚/的/和尚/未/结婚/的
    3. 未登录词问题 有两种解释:一是已有的词表中没有收录的词,二是已有的训练语料中未曾出现过的词,第二种含义中未登录词又称OOV(Out of Vocabulary)。对于大规模真实文本来说,未登录词对于分词的精度的影响远超歧义切分。一些网络新词,自造词一般都属于这些词。

    因此可以看到,未登录词是分词中的一个重要问题,jieba分词中对于OOV的解决方法是:采用了基于汉字成词能力的 HMM 模型,使用了 Viterbi 算法

9 HMM

关于HMM的介绍网络上有很多资源,比如 52nlp HMM系列,在此不再具体介绍了,但一些基础知识要明确的:

  • HMM(Hidden Markov Model): 隐式马尔科夫模型。 HMM模型可以应用在很多领域,所以它的模型参数描述一般都比较抽象,以下篇幅针对HMM的模型参数介绍直接使用它在中文分词中的实际含义来讲:

  • HMM解决的三类问题: a. 评估问题(概率计算问题) 即给定观测序列 O=O1,O2,O3…Ot和模型参数λ=(A,B,π),怎样有效计算这一观测序列出现的概率. (Forward-backward算法) b. 解码问题(预测问题) 即给定观测序列 O=O1,O2,O3…Ot和模型参数λ=(A,B,π),怎样寻找满足这种观察序列意义上最优的隐含状态序列S。 (viterbi算法,近似算法) c. 学习问题 即HMM的模型参数λ=(A,B,π)未知,如何求出这3个参数以使观测序列O=O1,O2,O3…Ot的概率尽可能的大. (即用极大似然估计的方法估计参数,Baum-Welch,EM算法)

  • HMM 模型的五元组表示: { states,//状态空间 observations,//观察空间 start_probability,//状态的初始分布,即π  transition_probability,//状态的转移概率矩阵,即A emission_probability//状态产生观察的概率,发射概率矩阵,即B }

10 结巴 HMM 分析

使用jieba对句子:”到MI京研大厦”进行分词,若是使用非HMM模式则分词的结果为: 到/MI/京/研/大厦, 使用HMM分词则结果为:到/MI/京研/大厦。下面一段是利用上一节的程序的计算结果。

"到MI京研大厦"的前缀字典:
到 205341
到M 0
到MI 0
到MI京 0
到MI京研 0
到MI京研大 0
到MI京研大厦 0
"到MI京研大厦"的DAG:
0 : [0]
1 : [1]
2 : [2]
3 : [3]
4 : [4]
5 : [5, 6]
6 : [6]
route:
{0: (-73.28491710434629, 0), 1: (-67.60579126740393, 1), 2: (-49.69423813964871, 2), 3: (-31.78268501189349, 3), 4: (-22.663377731606147, 4), 5: (-11.256112777387571, 6), 6: (-12.298425021367148, 6), 7: (0, 0)}
到/MI/京/研/大厦
...
Loading model cost 0.696 seconds.
Prefix dict has been built succesfully.

# HMM切分结果:
到/MI/京研/大厦

 从句子”到MI京研大厦”对应的前缀字典可以看出“京研”并没有在字典中,但是也被Viterbi算法识别出来了,可以看出HMM的强大之处了,也正是 HMM 三大基本问题之一,即根据观察序列,求隐藏状态序列。 上一节中我们说明了HMM由五元组表示,那么这样的五元组参数在中文分词中的具体含义是:

  • states(状态空间) & observations(观察空间). 汉字按照BEMS四个状态来标记,分别代表 Begin End Middle 和 Single, {B:begin, M:middle, E:end, S:single}。分别代表每个状态代表的是该字在词语中的位置,B代表该字是词语中的起始字,M代表是词语中的中间字,E代表是词语中的结束字,S则代表是单字成词。 观察空间为就是所有汉字(我她…),甚至包括标点符号所组成的集合。 状态值也就是我们要求的值,在HMM模型中文分词中,我们的输入是一个句子(也就是观察值序列),输出是这个句子中每个字的状态值,用这四个状态符号依次标记输入句子中的字,可方便的得到分词方案。 如: 观察序列:我在北京 状态序列:SSBE 对于上面的状态序列,根据规则进行划分得到 S/S/BE/ 对应于观察序列:我/在/北京/ 分词任务就完成了。 同时我们可以注意到: B后面只可能接(M or E),不可能接(B or E)。而M后面也只可能接(M or E),不可能接(B, S)。

    上文只介绍了五元组中的两元 states & observations,下文介绍剩下的三元(start_probability,transition_probability,emission_probability).

  • start_probability(状态的初始分布). 初始状态概率分布是最好理解的,如下 P={ 'B': -0.26268660809250016, 'E': -3.14e+100, 'M': -3.14e+100, 'S': -1.4652633398537678 }

    示例数值是对概率值取对数之后的结果(trick, 让概率相乘变成对数相加),其中-3.14e+100作为负无穷,也就是对应的概率值是0。它表示了一个句子的第一个字属于{B,E,M,S}这四种状态的概率,如上可以看出,E和M的概率都是0,这和实际相符合,开头的第一个字只可能是词语的首字(B),或者是单字成词(S),这部分内容对应 jieba/finalseg/prob_start.py文件,具体源码

  • transition_probability(状态的转移概率矩阵) 转移概率是马尔科夫链很重要的一个知识点,马尔科夫链(一阶)最大的特点就是当前T=i时刻的状态state(i),只和T=i时刻之前的n个状态有关,即: {state(i-1), state(i-2), … state(i - n)} HMM模型有三个基本假设: a. 系统在时刻t的状态只与时刻t-1处的状态相关,(也称为无后效性); b. 状态转移概率与时间无关,(也称为齐次性或时齐性);  c. 假设任意时刻的观测只依赖于该时刻的马尔科夫链的状态,与其它观测及状态无关,(也称观测独立性假设)。 其中前两个假设为马尔科夫模型的假设。 模型的这几个假设能大大简化问题。 再看下transition_probability,其实就是一个嵌套的字典,数值是概率求对数后的值,示例: 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}} 如P[‘B’][‘E’]代表的含义就是从状态B转移到状态E的概率,由P[‘B’][‘E’] = -0.510825623765990,表示状态B的下一个状态是E的概率对数是-0.510825623765990。 这部分内容对应 jieba/finalseg/prob_trans.py文件,具体源码

  • emission_probability(状态产生观察的概率,发射概率) 根据HMM观测独立性假设发射概率,即观察值只取决于当前状态值,也就是: P(observed[i], states[j]) = P(states[j]) * P(observed[i]|states[j]),其中P(observed[i]|states[j])这个值就是从emission_probability中获取。 emission_probability示例如下: P={'B': {'\u4e00': -3.6544978750449433,    '\u4e01': -8.125041941842026,    '\u4e03': -7.817392401429855,    '\u4e07': -6.3096425804013165,     ...,     'S':{...},     ...   }

  • 比如P[‘B’][‘\u4e00’]代表的含义就是’B’状态下观测的字为’\u4e00’(对应的汉字为’一’)的概率对数P[‘B’][‘\u4e00’] = -3.6544978750449433。 这部分内容对应 jieba/finalseg/prob_emit.py文件,具体源码

到这里已经结合HMM模型把jieba的五元参数介绍完,这五元的关系是通过一个叫Viterbi的算法串接起来,observations序列值是Viterbi的输入,而states序列值是Viterbi的输出,输入和输出之间Viterbi算法还需要借助三个模型参数,分别是start_probability,transition_probability,emission_probability。对于未登录词(OOV)的问题,即已知观察序列S,初始状态概率prob_start,状态观察发射概率prob_emit,状态转换概率prob_trans。 求状态序列W,这是个解码问题,维特比算法可以解决。

  • Viterbi 维特比算法 HMM第二个问题又称为解码问题(预测问题)即给定观测序列 O=O1,O2,O3…Ot和模型参数λ=(A,B,π),怎样寻找满足这种观察序列意义上最优的隐含状态序列S。 (viterbi算法,近似算法),同样的,暴力算法是计算所有可能性的概率,然后找出拥有最大概率值的隐藏状态序列。与问题一的暴力解决方案类似,复杂度为O(N^T)。 那应该用什么方案呢?还是动态规划! 假设观察序列为O1,O2,O3,…,Ot. 在时刻i ∈ (1,t]时,定义D为观察O1,O2,…,Oi且Si=Sk时产生该观察序列的最大概率: vb 其中,S1,S2,….S(i-1),在此时也已经可以得到(子问题)。 vb2 它是一个是对子问题求最大值的最优解问题。 对于解码问题,因为需要求出的是使得观察序列概率最大的隐藏状态的序列,而不是最大概率,所以,在算法计算过程中,还需要记录前一个隐藏状态的值。
  • jieba Viterbi 的应用

    jieba中对于未登录词问题,通过__cut_DAG 函数我们可以看出这个函数前半部分用 calc 函数计算出了初步的分词,而后半部分就是就是针对上面例子中未出现在语料库的词语进行分词了。 由于基于频度打分的分词会倾向于把不能识别的词组一个字一个字地切割开,所以对这些字的合并就是识别OOV的一个方向,__cut_DAG定义了一个buf 变量收集了这些连续的单个字,最后把它们组合成字符串再交由 finalseg.cut 函数来进行下一步分词。

# 利用 viterbi算法得到句子分词的生成器
def __cut(sentence):
    global emit_P
    # viterbi算法得到sentence 的切分
    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]
        if pos == 'B':
            begin = i
        elif pos == 'E':
            yield sentence[begin:i + 1]
            nexti = i + 1
        elif pos == 'S':
            yield char
            nexti = i + 1
    if nexti < len(sentence):
        yield sentence[nexti:]

对应的viterbi算法:

#状态转移矩阵,比如B状态前只可能是E或S状态  
PrevStatus = {  
    'B':('E','S'),  
    'M':('M','B'),  
    'S':('S','E'),  
    'E':('B','M')  
}  
def viterbi(obs, states, start_p, trans_p, emit_p):
    V = [{}]  # 状态概率矩阵  
    path = {}
    for y in states:  # 初始化状态概率
        V[0][y] = start_p[y] + emit_p[y].get(obs[0], MIN_FLOAT)
        path[y] = [y] # 记录路径
    for t in xrange(1, len(obs)):
        V.append({})
        newpath = {}
        for y in states:
            em_p = emit_p[y].get(obs[t], MIN_FLOAT)
            # t时刻状态为y的最大概率(从t-1时刻中选择到达时刻t且状态为y的状态y0)
            (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 
    # 求出最后一个字哪一种状态的对应概率最大,最后一个字只可能是两种情况:E(结尾)和S(独立词)  
    (prob, state) = max((V[len(obs) - 1][y], y) for y in 'ES')

  

其实到这里思路很明确了,给定训练好的模型(如HMM)参数(λ=(A,B,π)), 然后对模型进行载入,再运行一遍Viterbi算法,就可以找出每个字对应的状态(B, M, E, S),这样就可以根据状态也就可以对句子进行分词。具体源码注释见: github/init.py

posted @ 2016-11-23 18:50 伏草惟存 阅读(...) 评论(...) 编辑 收藏