jieba源碼研讀筆記(十三) - 詞性標注(使用HMM維特比算法發現新詞)
前言
jieba/posseg/__init__.py
裡的__cut_DAG
負責的是使用了HMM的詞性標注。
下圖顯示的是jieba/posseg/__init__.py
及jieba/posseg/viterbi.py
兩個檔案裡相關函數的呼叫關係。
![](https://i-blog.csdnimg.cn/blog_migrate/5ade0322dfdb35990e2f0f2ad813179d.png)
__cut_DAG
,__cut_detail
,__cut
負責詞性標注的核心算法,其中__cut
函數還會呼叫viterbi.py
裡的viterbi
這個函數。
而cut
及__cut_internal
則是__cut_DAG
的wrapper,隱藏了一些細節,讓它們變得更容易使用。
本篇將由內而外介紹,從viterbi
到__cut
,__cut_detail
直到__cut_DAG
函數。
但一開始還是先看看jieba/posseg/__init__.py
中HMM參數的載入。
載入HMM的參數
在jieba/posseg/__init__.py
的開頭便載入了HMM的參數,包括初始機率向量,狀態轉移機率矩陣,發射機率矩陣及CHAR_STATE_TAB_P
這個字典(它記錄各個漢字可能的狀態及詞性)。
如果是使用Jython的話,會需要用到load_model
這個函數,裡面使用pickle
這個模組來載入.p
檔。
如果是使用純Python的話,直接使用from ... import ...
即可。
PROB_START_P = "prob_start.p"
PROB_TRANS_P = "prob_trans.p"
PROB_EMIT_P = "prob_emit.p"
CHAR_STATE_TAB_P = "char_state_tab.p"
def load_model():
# For Jython
start_p = pickle.load(get_module_res("posseg", PROB_START_P))
trans_p = pickle.load(get_module_res("posseg", PROB_TRANS_P))
emit_p = pickle.load(get_module_res("posseg", PROB_EMIT_P))
state = pickle.load(get_module_res("posseg", CHAR_STATE_TAB_P))
return state, start_p, trans_p, emit_p
if sys.platform.startswith("java"):
char_state_tab_P, start_P, trans_P, emit_P = load_model()
else:
from .char_state_tab import P as char_state_tab_P
from .prob_start import P as start_P
from .prob_trans import P as trans_P
from .prob_emit import P as emit_P
測試:
print('char_state_tab_P', len(char_state_tab_P.keys()), random.sample(char_state_tab_P.items(), 1))
"""
記錄各個漢字可能的分詞標籤及詞性
char_state_tab_P 6648 [('漏', (('S', 'v'), ('E', 'v'), ('B', 'n'), ('B',
'v'), ('E', 'n'), ('B', 'nz'), ('E', 'i'), ('B', 'l'), ('E', 'vn'), ('B',
'i'), ('M', 'i'), ('B', 'vn'), ('M', 'j'), ('M', 'v'), ('B', 't'), ('M',
'n'), ('E', 'nz')))]
本例中'漏'這個漢字可能是單字成詞(S)且為動詞(v),也可能是詞尾(E)且為動詞,...
"""
print('start_P', len(start_P.keys()), random.sample(start_P.items(), 1))
"""
一開始是B(詞首)且詞性為人名的機率為0.10741562843
start_P 256 [(('B', 'nr'), -2.2310495913769506)]
"""
print('trans_P', len(trans_P.keys()), random.sample(trans_P.items(), 1))
"""
一開始在詞首(B)且詞性為副詞(d),則:
下一個詞是詞尾(E)且詞性為副詞(d)的機率為0.95430390914
下一個詞是詞中(M)且詞性為副詞(d)的機率為0.04569609085
trans_P 256 [(('B', 'd'), {('E', 'd'): -0.04677309521554972, ('M', 'd'):
-3.0857425240950174})]
"""
print('emit_P', len(emit_P.keys()), random.sample(emit_P.items(), 1))
"""
假設當前的字是處於詞首,且為方位詞(f)的情況下,發射出不同字的可能性
如下所示,發射出'以'的機率為0.22741048257,發射出'之'的機率為0.17872428527,...
emit_P 256 [(('B', 'f'), {'以': -1.4809986011977472, '之': -1.7219109663825019, ...})]
"""
jieba/posseg/viterbi.py檔
viterbi
函數接受句子,HMM的三個矩陣,以及char_state_tab_P
這個字典當作輸入。
它會計算出句中各字的分詞標籤及詞性,以及該組合的機率對數,將它們打包成一個tuple後回傳。
注意到因為char_state_tab_P
, start_P
, trans_P
, emit_P
這幾個字典裡都只包含漢字的資訊,所以viterbi
只能處理全是漢字的句子。
import sys
import operator
MIN_FLOAT = -3.14e100
MIN_INF = float("-inf")
# 處理Python2/3相容的問題
# 為何不復用_compat.py?
if sys.version_info[0] > 2:
xrange = range
# 沒有被用到
def get_top_states(t_state_v, K=4):
return sorted(t_state_v, key=t_state_v.__getitem__, reverse=True)[:K]
def viterbi(obs, states, start_p, trans_p, emit_p):
"""
請參考李航書中的算法10.5(維特比算法)
HMM共有五個參數,分別是觀察值集合(句子本身, obs),
狀態值集合(all_states, 即trans_p.keys()),
初始機率(start_p),狀態轉移機率矩陣(trans_p),發射機率矩陣(emit_p)
此處的states是為char_state_tab_P,
這是一個用來查詢漢字可能狀態的字典
此處沿用李航書中的符號,令T=len(obs),令N=len(trans_p.keys())
"""
"""
維特比算法第1步:初始化
"""
# tabular,李航書中的delta,在時刻t狀態為i的所有路徑中之機率最大值
V = [{}]
#李航書中的Psi,T乘N維的矩陣
#表示在時刻t狀態為i的所有單個路徑(i_1, i_2, ..., i_t-1, i)中概率最大的路徑的第t-1個結點
mem_path = [{}]
#共256種狀態,所謂"狀態"是:"分詞標籤(BMES)及詞性(v, n, nr, d, ...)的組合"
all_states = trans_p.keys()
#obs[0]表示句子的第一個字
#states.get(obs[0], all_states)表示該字可能是由哪些狀態發射出來的
for y in states.get(obs[0], all_states): # init
#在時間點0,狀態y的log機率為:
#一開始在y的log機率加上在狀態y發射obs[0]觀察值的log機率
V[0][y] = start_p[y] + emit_p[y].get(obs[0], MIN_FLOAT)
#時間點0在狀態y,則前一個時間點會在哪個狀態
mem_path[0][y] = ''
"""
維特比算法第2步:遞推
"""
for t in xrange(1, len(obs)): #觀察值序列
V.append({})
mem_path.append({})
#prev_states = get_top_states(V[t-1])
#mem_path[t - 1].keys(): 前一個時間點在什麼狀態,這裡以x代表
#只有在len(trans_p[x])>0(即x有可能轉移到其它狀態)的情況下,prev_states才保留x
prev_states = [
x for x in mem_path[t - 1].keys() if len(trans_p[x]) > 0]
#前一個狀態是x(prev_states中的各狀態),那麼現在可能在什麼狀態(y)
prev_states_expect_next = set(
(y for x in prev_states for y in trans_p[x].keys()))
#set(states.get(obs[t], all_states)):句子的第t個字可能在什麼狀態
#prev_states_expect_next:由前一個字推斷,當前的字可能在什麼狀態
#obs_states:以上兩者的交集
obs_states = set(
states.get(obs[t], all_states)) & prev_states_expect_next
#如果交集為空,則依次選取prev_states_expect_next或all_states
if not obs_states:
obs_states = prev_states_expect_next if prev_states_expect_next else all_states
for y in obs_states:
#李航書中的公式10.45
#y0表示前一個時間點的狀態
#max的參數是一個list of tuple: [(機率1,狀態1),(機率2,狀態2),...]
#V[t - 1][y0]:時刻t-1在狀態y0的機率對數
#trans_p[y0].get(y, MIN_INF):由狀態y0轉移到y的機率對數
#emit_p[y].get(obs[t], MIN_FLOAT):在狀態y發射出觀測值obs[t]的機率對數
#三項之和表示在時刻t由狀態y0到達狀態y的路徑的機率對數
prob, state = max((V[t - 1][y0] + trans_p[y0].get(y, MIN_INF) +
emit_p[y].get(obs[t], MIN_FLOAT), y0) for y0 in prev_states)
#挑選機率最大者將之記錄於V及mem_path
V[t][y] = prob
#時刻t在狀態y,則時刻t-1最有可能在state這個狀態
mem_path[t][y] = state
"""
維特比算法第3步:終止
"""
#mem_path[-1].keys():最後一個時間點可能在哪些狀態
#V[-1][y]:最後一個時間點在狀態y的機率
#把mem_path[-1]及V[-1]打包成一個list of tuple
last = [(V[-1][y], y) for y in mem_path[-1].keys()]
# if len(last)==0:
# print obs
#最後一個時間點最有可能在狀態state,其機率為prob
#在jieba/finalseg/__init__.py的viterbi函數中有限制句子末字的分詞標籤需為E或S
#這裡怎麼沒做這個限制?
prob, state = max(last)
"""
維特比算法第4步:最優路徑回溯
"""
route = [None] * len(obs)
i = len(obs) - 1
while i >= 0:
route[i] = state
#時間點i在狀態state,則前一個時間點最有可能在狀態mem_path[i][state]
state = mem_path[i][state]
i -= 1
return (prob, route)
使用HMM做詞性標注
__cut_DAG
這個函數是以查找字典為主,維特比算法為輔的方式來找出詞首詞尾。
它會呼叫__cut_detail
,而__cut_detail
又會呼叫__cut
。
這裡由內而外,以__cut
,__cut_detail
,__cut_DAG
的順序一一介紹。
__cut
__cut
會先呼叫viterbi
這個函數,得到句中各字的分詞標籤及詞性。
然後再將得到的結果打包成一對一對的(詞彙,詞性)pair
。
__cut
是一個生成器,每次被呼叫時,就生成一對(詞彙,詞性)pair
。
注意__cut
並未對英數字做特別處理,所以它跟viterbi
函數一樣,只能處理sentence
全是漢字的情況。
def __cut(self, sentence):
#使用維特比算法找出最有可能的狀態序列pos_list及其機率prob
#所謂狀態包含分詞標籤及詞性
prob, pos_list = viterbi(
sentence, char_state_tab_P, start_P, trans_P, emit_P)
begin, nexti = 0, 0
for i, char in enumerate(sentence):
pos = pos_list[i][0] #[0]表示分詞標籤('B','M','E','S')
if pos == 'B':
begin = i
elif pos == 'E':
#到詞尾時yield出該詞彙
#pos_list[i][1]表示該詞的詞性,這裡以詞尾的詞性代表全詞的詞性
yield pair(sentence[begin:i + 1], pos_list[i][1])
nexti = i + 1
elif pos == 'S':
#單字成詞的情況下直接yield
yield pair(char, pos_list[i][1])
nexti = i + 1
#nexti記錄上個詞彙詞尾的後一個位置
if nexti < len(sentence):
yield pair(sentence[nexti:], pos_list[nexti][1])
__cut_detail
__cut_detail
是__cut
的wrapper,它與__cut
同樣是一個會生成(詞彙,詞性)pair
的生成器。
但是它有對英數字做處理,並賦予它們詞性,所以它可以處理句中包含英數字的情況。
以下代碼中有用到正則表達式re_han_detail
,re_skip_detail
,re_num
及re_eng
,在jieba源碼研讀筆記(四) - 正則表達式己做過介紹。
def __cut_detail(self, sentence):
#re_han_detail:一個或多個漢字
blocks = re_han_detail.split(sentence)
for blk in blocks:
if re_han_detail.match(blk):
#如果該區段包含漢字,則直接使用__cut來切
for word in self.__cut(blk):
yield word
else:
#非漢字的區段
#re_num:小數點及數字
#re_eng:英數字
#re_skip_detail:re_num或re_eng
tmp = re_skip_detail.split(blk)
for x in tmp:
if x:
if re_num.match(x):
#'m':數詞
yield pair(x, 'm')
elif re_eng.match(x):
#'eng':外語
yield pair(x, 'eng')
else:
#'x':非語素字
yield pair(x, 'x')
__cut_DAG
此處的代碼邏輯與jieba/__init__.py
裡的__cut_DAG
函數類似。
它以查找字典self.word_tag_tab
為主,維特比算法(即呼叫__cut_detail
)為輔的方式來找出詞首詞尾。
它與之前的幾個函數一樣,都是生成器,每次生成一對(詞彙,詞性)pair
。
def __cut_DAG(self, sentence):
DAG = self.tokenizer.get_DAG(sentence)
route = {}
self.tokenizer.calc(sentence, DAG, route)
x = 0
buf = ''
N = len(sentence)
while x < N:
y = route[x][1] + 1
l_word = sentence[x:y]
if y - x == 1:
buf += l_word
else:
#碰到多字詞,先處理之前的buf
if buf:
if len(buf) == 1:
#單字詞
yield pair(buf, self.word_tag_tab.get(buf, 'x'))
elif not self.tokenizer.FREQ.get(buf):
#如果是未記錄於FREQ裡的buf,就使用維特比算法來找出詞首詞尾
recognized = self.__cut_detail(buf)
for t in recognized:
yield t
else:
#如果buf存在於FREQ裡,則把它拆成多個單字詞?
for elem in buf:
yield pair(elem, self.word_tag_tab.get(elem, 'x'))
buf = ''
#處理當前的多字詞
yield pair(l_word, self.word_tag_tab.get(l_word, 'x'))
x = y
#用一樣的方式來處理殘留的buf
if buf:
if len(buf) == 1:
yield pair(buf, self.word_tag_tab.get(buf, 'x'))
elif not self.tokenizer.FREQ.get(buf):
recognized = self.__cut_detail(buf)
for t in recognized:
yield t
else:
for elem in buf:
yield pair(elem, self.word_tag_tab.get(elem, 'x'))
參考連結
jieba源碼研讀筆記(四) - 正則表達式
李航統計機器學習中的算法10.5(維特比算法)