jieba源碼研讀筆記(十三) - 詞性標注(使用HMM維特比算法發現新詞)

jieba源碼研讀筆記(十三) - 詞性標注(使用HMM維特比算法發現新詞)

前言

jieba/posseg/__init__.py裡的__cut_DAG負責的是使用了HMM的詞性標注。
下圖顯示的是jieba/posseg/__init__.pyjieba/posseg/viterbi.py兩個檔案裡相關函數的呼叫關係。

__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_detailre_skip_detailre_numre_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(維特比算法)

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值