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
裡的函數,下圖是幾個相關函數之間的呼叫關係。
![](https://i-blog.csdnimg.cn/blog_migrate/aa8acb3c8417a1d9573709c649e13eba.png)
可以從圖中看到,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
:
blk | re_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_word | buf | yield了什麼 |
---|---|---|
小张 | ‘小张’ | |
学 | 学 | |
了 | 学了 | |
c++ | [‘学了’, ‘c++’] | |
及 | 及 | |
p | 及p | |
y | 及py | |
t | 及pyt | |
h | 及pyth | |
o | 及pytho | |
n | 及python | (在while循環外yield) [‘及’, ‘python’] |
參考連結
jieba文檔
李航統計機器學習中的章節10.4 - 預測算法