jieba源碼研讀筆記(七) - 分詞之精確模式(使用HMM維特比算法發現新詞)

前言

jieba分詞的精確模式分為1. 不使用HMM(使用動態規劃算法) 或 2. 使用HMM(使用維特比算法發現新詞)兩種模式。

本篇介紹的是使用了HMM維特比算法的精確模式,對應的是jieba/__init__.py裡的__cut_DAG這個函數。在__cut_DAG中,仍然是以查字典為主,但是:

对于未登录词,采用了基于汉字成词能力的 HMM 模型,使用了 Viterbi 算法

__cut_DAG函數是由cut(sentence, cut_all=False, HMM=True)這個函數調用。
而它又會呼叫jieba/finalseg裡的函數,下圖是幾個相關函數之間的呼叫關係。

可以從圖中看到,jieba/__init__.py裡的__cut_DAG函數調用了finalseg/__init__.py裡的cut函數。而finalseg/__init__.py又接著調用了同一個檔案裡的__cut函數,__cut函數又接著調用了viterbi函數。

本篇將由內而外介紹,先從jieba/finalseg裡的函數開始,最後才介紹jieba/__init__.py裡的cut

以下在介紹維特比算法時會大量參考李航-統計機器學習的10.4節,在閱讀本篇前建議先看過該章節。

jieba/finalseg的目錄結構

首先看看jieba/finalseg的目錄結構:

jieba/finalseg:
    prob_emit.p
    prob_emit.py
    prob_start.p
    prob_start.py
    prob_trans.p
    prob_trans.py
    __init__.py

其中__init__.py實現了維特比算法。
其它的.p檔及.py檔則儲存了HMM的參數,從檔名可以看得出來,它們分別是HMM的初始概率、轉移概率及發射概率(皆以對數值表示),在__init__.py裡會被用到。

jieba/finalseg/init.py

載入HMM的參數

這個檔案裡事先定義了一些常數:

from __future__ import absolute_import, unicode_literals
import re
import os
import sys
import pickle
from .._compat import *

MIN_FLOAT = -3.14e100

PROB_START_P = "prob_start.p"
PROB_TRANS_P = "prob_trans.p"
PROB_EMIT_P = "prob_emit.p"

"""
參考http://www.52nlp.cn/%E4%B8%AD%E6%96%87%E5%88%86%E8%AF%8D%E5%85%A5%E9%97%A8%E4%B9%8B%E5%AD%97%E6%A0%87%E6%B3%A8%E6%B3%954
B: begin詞的首字
M: middle詞的中間字
E:end詞的尾字
S:single單字成詞
PrevStatus表示一個狀態之前可能是哪些狀態
"""
PrevStatus = {
    'B': 'ES', #在一個詞的首字之前,只可能是上一個詞的詞尾,或者是單字成詞
    'M': 'MB', #在一個詞的中間字之前,可能是當前詞的中間字,或是當前詞的首字
    'S': 'SE', #在單字成詞之前,可能是另外一個單字成詞,也可能是上一個詞的詞尾
    'E': 'BM' #在一個詞的詞尾之前,可能是詞首或是中間字
}

"""
從.p檔或.py檔載入start_P, trans_P, emit_P
"""
def load_model():
    start_p = pickle.load(get_module_res("finalseg", PROB_START_P))
    trans_p = pickle.load(get_module_res("finalseg", PROB_TRANS_P))
    emit_p = pickle.load(get_module_res("finalseg", PROB_EMIT_P))
    return start_p, trans_p, emit_p

if sys.platform.startswith("java"):
    start_P, trans_P, emit_P = load_model()
else:
    from .prob_start import P as start_P
    from .prob_trans import P as trans_P
    from .prob_emit import P as emit_P

來看看start_P, trans_P, emit_P這三個變數裡面是什麼:

import pprint
pprint.pprint(start_P, width=1)
"""
4個狀態的初始log機率
{'B': -0.26268660809250016, #使用e^x換算回去,機率約為0.76
 'E': -3.14e+100, #0
 'M': -3.14e+100, #0
 'S': -1.4652633398537678} #機率約為0.23
"""

pprint.pprint(trans_P, width=1)
"""
4個狀態間的轉移log機率
{'B': {'E': -0.51082562376599,
       'M': -0.916290731874155},
 'E': {'B': -0.5897149736854513,
       'S': -0.8085250474669937},
 'M': {'E': -0.33344856811948514,
       'M': -1.2603623820268226},
 'S': {'B': -0.7211965654669841,
       'S': -0.6658631448798212}}
"""

# 發射概率
# 每個狀態挑選5個字來看
pprint.pprint({k: dict(random.sample(v.items(), 5)) for k, v in emit_P.items()}, width=1)
"""
在4個狀態,觀察到不同字的機率
{'B': {'十': -6.007344000041945, #emit_P['B']有6857個item
       '囤': -12.607094089862704,
       '撷': -14.695424578959786,
       '柱': -9.88964863468285,
       '齏': -13.868746005775318},
 'E': {'僱': -12.20458319365197, # 7439
       '凜': -11.06728136003368,
       '妪': -13.50584051208595,
       '拟': -9.377654786484934,
       '焘': -11.21858978309201},
 'M': {'咦': -13.561365842482271, # 6409
       '疊': -9.63829300086754,
       '荑': -15.758590419818491,
       '趙': -10.120235750484746,
       '骛': -12.324603215333344},
 'S': {'妳': -13.847864212264698, # 14519
       '庝': -10.732052690793973,
       '弑': -12.34762559179559,
       '氜': -16.116547753583063,
       '筱': -13.957063504229689}}
"""

start_P, trans_P, emit_P這三個變數代表了HMM的參數,在viterbi函數中會用到。

viterbi函數

參考李航統計機器學習中的章節10.4 - 預測算法。
維特比算法是在給定模型參數及觀察序列的情況下,用於求解狀態序列的算法。
注意這個函數只適用於obs(也就是觀察序列)全是漢字的情況。

def viterbi(obs, states, start_p, trans_p, emit_p):
    #參考:李航 - 統計機器學習 10.4 預測算法
    """
    obs: 觀察序列
    states: 所有可能的狀態
    start_p: 李航書中的Pi,一開始在每個不同狀態的機率
    trans_p: 李航書中的A, 轉移矩陣
    emit_p: 李航書中的B, 每個狀態發射出不同觀察值的機率矩陣
    """

    """
    算法10.5 (1)初始化
    """
    V = [{}]  # tabular, 李航書中的delta, 表示在時刻t狀態為y的所有路徑的機率最大值,用一個字典表示一個時間點
    #V如果以矩陣表示的話會是len(obs)*len(states)=T*4的大小
    path = {} #李航書中的psi是一個矩陣。這裡採用不同的實現方式:使用path儲存各個狀態最有可能的路徑
    for y in states:  # init
        #y代表4種狀態中的一個
        #在時間點0有路徑{y}的機率是為:在狀態y的初始機率乘上狀態y發射出obs[0]觀察值的機率
        #因為這裡是機率對數,所以用加的
        V[0][y] = start_p[y] + emit_p[y].get(obs[0], MIN_FLOAT) 
        path[y] = [y] #在一開始時各狀態y的最大機率路徑都只包含它自己
    """
    算法10.5 (2)由t=0遞推到t=1...T-1(因為index是由0開始,所以最後一個是T-1)
    """
    for t in xrange(1, len(obs)): #遞推:由前一時刻的path算出當前時刻的path
        V.append({}) 
        newpath = {} #基於path(時刻t-1各個狀態機率最大的路徑)得到的,代表時刻t各個狀態的機率最大的路徑
        for y in states:
            #在狀態y發射觀察值obs[t]的機率對數
            em_p = emit_p[y].get(obs[t], MIN_FLOAT)
            
            """
            V[t - 1][y0] + trans_p[y0].get(y, MIN_FLOAT) + em_p:
              前一個時間點在y0的路徑的機率最大值*由y0轉移到y的機率*在狀態y0發射出y的機率
            for y0 in PrevStatus[y]:
              這裡使用PrevStatus這個字典獲取狀態y前一個時間點可能在什麼狀態,而不是使用所有的狀態
            max([(a1, b1), (a2, b2)]):
              會先比較a1與a2,如果一樣,繼續比較b1與b2
            state:
              李航書中的psi_t(y):如果時刻t時在狀態y,那麼在時刻t-1時最有可能在哪一個狀態?
            """
            (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 #時刻t在狀態y的路徑的機率最大值
            
            #時刻t狀態y機率最大的路徑為時刻t-1狀態為state機率最大的路徑 加上 當前狀態y
            #註:path是前一時刻(時刻t-1)在各個狀態機率最大的路徑
            newpath[y] = path[state] + [y]
        path = newpath #時刻t各個狀態機率最大的路徑
    
    """
    算法10.5 (3)終止
    """
    """
    for y in 'ES':限制最後一個狀態只能是這兩個
    len(obs) - 1:最後一個時間點,即T-1
    prob:時間T-1所有路徑的機率最大值
    state:時間T-1最有可能在哪一個狀態上
    """
    (prob, state) = max((V[len(obs) - 1][y], y) for y in 'ES')
    
    """
    李航書中本來還有一步最優路徑回溯,
    但是這裡因為path的實現方式跟書中不同,所以不必回溯。
    這裡的path直接記錄了len(states)條路徑
    我們只要從中選一條機率最大的即可
    """
    #path[state]:終點是state的路徑
    return (prob, path[state])

__cut函數

viterbi函數返回的是狀態序列及其機率值。在__cut函數中,調用了viterbi,並依據狀態序列來切分傳入的句子。
要注意的是:__cut函數只能接受全是漢字的句子當作輸入。
所以我們等一下會看到,它還會有一個wrapper,用來處理句子中包含英數字或其它符號的情況。

def __cut(sentence):
    global emit_P #為什麼只有emit_P是global?
    # 向viterbi函數傳入觀察序列,可能狀態值以及三個矩陣
    # 得到機率最大的狀態序列及其機率
    prob, pos_list = viterbi(sentence, 'BMES', start_P, trans_P, emit_P)
    begin, nexti = 0, 0
    # print pos_list, sentence
    # 利用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
    """
    如果sentence[-1]是'E'或'S',那麼句中的最後一個詞就會被yield出來,而nexti就派不上用場
    如果sentence[-1]是'B'或'M',那麼在迴圈中就不會yield出最後一個詞,
    所以到迴圈外後我們需要nexti(表示上個詞詞尾的下一個字的位置),然後用yield來產生句中的最後一個詞
    """
    if nexti < len(sentence):
        yield sentence[nexti:]

測試:

sentence = "生成对抗网络"
list(jieba.finalseg.__cut(sentence))
"""
V(在時刻t狀態為y的所有路徑的機率最大值)
[{'B': -5.699123518254582, #'生'最有可能是多字詞的詞首
  'E': -3.14e+100,
  'M': -3.14e+100,
  'S': -8.655647832361613},
 {'B': -14.990200384097042,
  'E': -11.698410473647112, #'成'最有可能是多字詞的詞尾
  'M': -12.773515893974773,
  'S': -15.824923479296029},
 {'B': -18.509323985742753,
  'E': -19.904243221342654,
  'M': -21.4505817263039,
  'S': -17.5957481759011},
 {'B': -25.66212844099389,
  'E': -27.56603808679005,
  'M': -28.650416299502055,
  'S': -27.33762268414803},
 {'B': -35.24592669320182,
  'E': -34.1387252653873,
  'M': -35.45662551050053,
  'S': -36.335560346874445},
 {'B': -46.11227959506007,
  'E': -43.794532687918625, #看到'络'的時候在各個狀態的機率,'络'最有可能是詞尾
  'M': -44.342662372475,
  'S': -46.01180758665919}]

prob:所有路徑的機率最大值
-43.794532687918625 #取log前的機率約為9.5559995e-20

state:最後一個時間點(看到"络"的時候),在哪個狀態上?
E

path隨時間的演變
{'B': ['B'], 'M': ['M'], 'E': ['E'], 'S': ['S']}
{'B': ['S', 'B'], 'M': ['B', 'M'], 'E': ['B', 'E'], 'S': ['S', 'S']}
{'B': ['B', 'E', 'B'], 'M': ['B', 'M', 'M'], 'E': ['B', 'M', 'E'], 'S': ['B', 'E', 'S']}
{'B': ['B', 'E', 'S', 'B'], 'M': ['B', 'E', 'B', 'M'], 'E': ['B', 'E', 'B', 'E'], 'S': ['B', 'E', 'S', 'S']}
{'B': ['B', 'E', 'S', 'S', 'B'], 'M': ['B', 'E', 'S', 'B', 'M'], 'E': ['B', 'E', 'S', 'B', 'E'], 'S': ['B', 'E', 'S', 'S', 'S']}
{'B': ['B', 'E', 'S', 'B', 'E', 'B'], 'M': ['B', 'E', 'S', 'S', 'B', 'M'], 'E': ['B', 'E', 'S', 'S', 'B', 'E'], 'S': ['B', 'E', 'S', 'B', 'E', 'S']}

path[state]:機率最大的路徑
['B', 'E', 'S', 'S', 'B', 'E']

輸出:['生成', '对', '抗', '网络']
"""

add_force_split函數

這個函數用於更新Force_Split_Words這個變數,而Force_Split_Words會被接下來介紹的cut函數所使用。

如果用戶有使用jieba/__init__.py裡的add_word(word, freq=None, tag=None)來新增詞彙,並且該詞彙原本不存在於字典裡,那麼在add_word執行的過程中,就會調用finalseg.add_force_split(word)

Force_Split_Words = set([])

def add_force_split(word):
    global Force_Split_Words
    Force_Split_Words.add(word)

可以看到add_force_split的作用是更新Force_Split_Words這個集合。
而如果用戶沒有使用jieba.add_word,那麼Force_Split_Words都會是一個空集合。

cut函數

這是__cut函數的wrapper,它會把句中的漢字/非漢字的部份分離。
如果某個字段是漢字,才會呼叫__cut函數來分詞。

def cut(sentence):
    sentence = strdecode(sentence)
    blocks = re_han.split(sentence)
    for blk in blocks:
        if re_han.match(blk):
            #呼叫__cut函數切分漢字
            for word in __cut(blk):
                if word not in Force_Split_Words:
                    yield word
                else:
                    #Force_Split_Words中的字會被強制切分
                    for c in word:
                        yield c
        else:
            #非漢字的部份
            tmp = re_skip.split(blk)
            for x in tmp:
                if x:
                    yield x

測試re_han.split

sentence = "Andy今年13歲了"
>>> re_han.split(sentence)
['Andy', '今年', '13', '歲了', '']

測試jieba.finalseg.cut

blkre_han.match(blk)list(__cut(blk))re_skip.split(blk)yield出的分詞結果
‘Andy’None[’’, ‘Andy’, ‘’]‘Andy’
‘今年’<_sre.SRE_Match object; span=(0, 2),
match=‘今年’>
[‘今年’]‘今年’
‘13’None[’’, ‘13’, ‘’]‘13’
‘歲了’<_sre.SRE_Match object; span=(0, 2),
match=‘歲了’>
[‘歲’, ‘了’]‘歲’, ‘了’
‘’None[’’]

jieba/init.py

Tokenizer類別裡的__cut_DAG函數:

__cut_DAG函數

finalseg.cut函數不查找字典,而是只依靠HMM維特比算法來分詞。
__cut_DAG函數則是在finalseg.cut外又包了一層,以查字典為主,維特比分詞為輔。

对于未登录词,采用了基于汉字成词能力的 HMM 模型,使用了 Viterbi 算法

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]
        # l_word長度為1,包括英文、數字及單字詞
        if y - x == 1:
            #如果碰到單字詞,就把它與前一個詞合併
            buf += l_word
        else:#看到一個長度大於等於2的詞
            #如果這時buf裡有東西,就先處理它
            if buf:
                if len(buf) == 1:
                    yield buf
                    buf = ''
                else: #多字的buf
                    #如果buf不存在於FREQ這個字典中
                    if not self.FREQ.get(buf):
                        #那就使用維特比算法發現新詞
                        recognized = finalseg.cut(buf)
                        for t in recognized:
                            yield t
                    else: #buf存在於FREQ這個字典中
                        #buf裡的東西是沒有被DAG給當成一個詞彙的
                        # 有可能出現DAG偵測不出來,FREQ裡卻存在的情況?
                        for elem in buf:
                            yield elem
                    buf = ''
            #buf處理完後yield當前詞:l_word
            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 = "小张学了c++及python"
jieba.lcut(sentence, cut_all=False, HMM=True)
# ['小张', '学了', 'c++', '及', 'python']
l_wordbufyield了什麼
小张‘小张’
学了
c++[‘学了’, ‘c++’]
p及p
y及py
t及pyt
h及pyth
o及pytho
n及python(在while循環外yield) [‘及’, ‘python’]

參考連結

jieba文檔
李航統計機器學習中的章節10.4 - 預測算法

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值