1. 程式人生 > >【NLP】【二】jieba原始碼分析之分詞

【NLP】【二】jieba原始碼分析之分詞

【一】詞典載入

利用jieba進行分詞時,jieba會自動載入詞典,這裡jieba使用python中的字典資料結構進行字典資料的儲存,其中key為word,value為frequency即詞頻。

1. jieba中的詞典如下:

jieba/dict.txt

X光 3 n
X光線 3 n
X射線 3 n
γ射線 3 n
T恤衫 3 n
T型臺 3 n

該詞典每行一個詞,每行資料分別為:詞 詞頻 詞性

2. 詞典的載入

jieba/_init_.py,原始碼解析如下:

    # 載入詞典
    def gen_pfdict(self, f):
        # 定義字典,key = word, value = freq
        lfreq = {}
        # 記錄詞條總數
        ltotal = 0
        f_name = resolve_filename(f)
        for lineno, line in enumerate(f, 1):
            try:
                line = line.strip().decode('utf-8')
                # 取一行的詞語詞頻
                word, freq = line.split(' ')[:2]
                freq = int(freq)
                # 記錄詞頻與詞的關係,這裡直接採用dict儲存,沒有采用Trie樹結構
                lfreq[word] = freq
                ltotal += freq
                # 對多個字組成的詞進行查詢
                for ch in xrange(len(word)):
                    # 從頭逐步取詞,如 電風扇,則會一次掃描 電,電風,電風扇
                    wfrag = word[:ch + 1]
                    # 如果該詞不在詞頻表中,將該詞插入詞頻表,並設定詞頻為0
                    if wfrag not in lfreq:
                        lfreq[wfrag] = 0
            except ValueError:
                raise ValueError(
                    'invalid dictionary entry in %s at Line %s: %s' % (f_name, lineno, line))
        f.close()
        return lfreq, ltotal

在詞典初始化時,會呼叫該介面,將詞頻表儲存到變數FREQ中,後續進行分詞時,會直接進行該詞頻表的查詢。

呼叫點如下:

def initialize(self, dictionary=None):
    self.FREQ, self.total = self.gen_pfdict(self.get_dict_file())

【二】分詞

jieba進行分詞時,使用詞典的最大字首匹配的方式。當使用精確匹配模型且啟動HMM時,對於未登入詞(即詞典中不存在的詞),jieba會使用HMM模型對未登入詞進行分詞,具體的演算法是viterbi演算法。

jieba分詞的第一步是將待分配的文字,依據詞典,生成動態圖DAG。

1. 動態圖的生成。

    # 生成動態圖
    '''
    例如:sentence = '我愛北京天安門'
         id        = 0 1 2 3 4 5 6
         則 DAG = {
                    0:[0],
                    1:[1],
                    2:[2,3],
                    3:[3],
                    4:[4,5,6],
                    5:[5],
                    6:[6]
         }
    
    '''
    def get_DAG(self, sentence):
        # 檢視詞典是否已經初始化,若沒有,則載入詞典,初始化詞頻表。
        # 由此可以看出,jieba採用的是詞典懶載入模式
        self.check_initialized()
        # 使用字典結構儲存動態圖
        DAG = {}
        # 獲取待分詞的句子的長度
        N = len(sentence)
        # 從頭到尾,逐字掃描
        for k in xrange(N):
            tmplist = []
            i = k
            # 獲取句子中第k個位置的字
            frag = sentence[k]
            # 如果該字在詞頻表中,則繼續掃描,直到掃描出的詞不再詞頻表中為止
            # 即jieba採用的最大字首匹配法來搜尋詞
            # 例如: 句子為: 我愛北京天安門,frag = 北,則該while迴圈會一直搜尋到京,
            #        即搜尋到北京,而tmplist裡面會儲存:北,北京兩個詞
            while i < N and frag in self.FREQ:
                if self.FREQ[frag]:
                    tmplist.append(i)
                i += 1
                frag = sentence[k:i + 1]
            # 儲存一個字到動態圖中
            if not tmplist:
                tmplist.append(k)
            DAG[k] = tmplist
        return DAG

2. 分詞介面流程分析

# 分詞介面
    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.
        '''
        # 對待分詞的序列進行編解碼,解碼成utf-8的格式
        sentence = strdecode(sentence)

        # 依據是否使用全模式,確定待使用的正則表示式式,
        # 結巴先使用正則表示式對待分割內容進行預處理,主要是去除標點符號
        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
        # 依據分割模式,設定分詞介面,有點類似於函式指標的意思哈
        if cut_all:
            # 全模式,使用__cut_all
            cut_block = self.__cut_all
        elif HMM:
            # 精確匹配模式,且使用HMM對未登入詞進行分割,則使用__cut_DAG
            cut_block = self.__cut_DAG
        else:
            # 精確匹配模式,但不使用HMM對未登入詞進行分割,則使用__cut_DAG_NO_HMM
            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):
                # 呼叫分詞介面進行分詞
                for word in cut_block(blk):
                    yield word
            else:
                tmp = re_skip.split(blk)
                for x in tmp:
                    if re_skip.match(x):
                        yield x
                    elif not cut_all:
                        for xx in x:
                            yield xx
                    else:
                        yield x

3. 全模式分詞

    def __cut_all(self, sentence):
        # 生成動態圖
        '''
        例如:sentence = '我愛北京天安門'
             id        = 0 1 2 3 4 5 6
             則 DAG = {
                        0:[0],
                        1:[1],
                        2:[2,3],
                        3:[3],
                        4:[4,5,6],
                        5:[5],
                        6:[6]
             }
        
        '''
        dag = self.get_DAG(sentence)
        old_j = -1
        # 掃描動態圖
        for k, L in iteritems(dag):
            # 如果只有一個字,且該字未在前面的詞中出現過,則生成一個詞,否則跳過
            # 例如: ‘北京’已經成詞了,再次掃描到‘京’時,需要跳過
            if len(L) == 1 and k > old_j:
                yield sentence[k:L[0] + 1]
                old_j = L[0]
            else:
                #對於至少2個字的詞,如 4:[4,5,6], 則分割為 天安,天安門 兩個詞
                # 這符合jieba的全模式定義:儘量細粒度的分詞
                for j in L:
                    if j > k:
                        yield sentence[k:j + 1]
                        old_j = j

4. 精確模式分詞,且使用HMM對未登入詞進行分割

 def __cut_DAG(self, sentence):
        DAG = self.get_DAG(sentence)
        route = {}
        # 使用動態規劃演算法,選取概率最大的路徑進行分詞
        '''
        例如:sentence = '我愛北京天安門'
             id        = 0 1 2 3 4 5 6
             則 DAG = {
                        0:[0],
                        1:[1],
                        2:[2,3],
                        3:[3],
                        4:[4,5,6],
                        5:[5],
                        6:[6]
             }
        
        '''
        # 在進行 4:[4,5,6], 分詞時,計算出成詞的最大概率路徑為4~6,即‘天安門’的概率大於‘天安’
        self.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:
                if buf:
                    if len(buf) == 1:
                        yield buf
                        buf = ''
                    else:
                        # 對於未登入詞,使用HMM模型進行分詞
                        if not self.FREQ.get(buf):
                            recognized = finalseg.cut(buf)
                            for t in recognized:
                                yield t
                        else:
                            for elem in buf:
                                yield elem
                        buf = ''
                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

5. 動態規劃演算法求解最大概率路徑。

jieba在使用精確模式進行分詞時,會將‘天安門’分割成‘天安門’,而不是全模式下的‘天安’和‘天安門’兩個詞,jieba時如何做到的呢?

其實,求解的核心在於‘天安門’的成詞概率比‘天安’大。

5.1 先看看jieba的動態規劃後的結果

    '''
        例如:輸入的動態圖如下
        DAG = {
                    0:[0],
                    1:[1],
                    2:[2,3],
                    3:[3],
                    4:[4,5,6],
                    5:[5],
                    6:[6]
         }
        則,返回值為:
        R = {
            0:(f1,0),
            1:(f2,1),
            2:(f3,3),
            3:(f3,3),
            4:(f4,6),
            5:(f5,5),
            6:(f6,6)
        }

        這個返回值是什麼含義呢?
        例如:0:(f1,0)  ----> id從0到0成詞概率最大,最大概率為f1
             4:(f4,6), ----> id從4到6成詞概率最大,最大概率為f4
        依據返回值R,可以得到成詞下標,0->0,1->1,2->3,4->6,即:我/愛/北京/天安門
    '''
    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((log(self.FREQ.get(sentence[idx:x + 1]) or 1) -
                              logtotal + route[x + 1][0], x) for x in DAG[idx])

5.2 動態規劃演算法

jieba使用的是1-gram模型,即 P(S) = P(W1)*P(W2)*....P(W3). 要求得P(S)取得最大值時,P(W1)/P(W2)..../P(Wn)依次取得什麼值。

這裡採用動態規劃演算法來求解該問題。即:argmax P(S) = argmax ( P(Wn)*P(Sn-1)) <=> argmax (log(P(Wn))+log(P(Sn-1)))

當使用jieba中的Route來儲存最大概率路徑時,即得到 R[i] = argmax(log(Wi/V) + R[i-1]),jieba中使用了一個小技巧,即倒著掃描,這與DAG中的list儲存著後向節點有關係。即R[i] = argmax(log(Wi/V) + R[i+1]),於是便有了上述程式碼。

6. 關於未登入詞的HMM求解。

關於HMM介紹這裡不做贅述,這裡僅描述一下,jieba是怎麼依據HMM模型進行分詞的。

6.1 問題建模

jieba 使用 BMES對詞進行建模。比如:我愛北京天安門,用BMES表示為:SSBEBME。只要拿到了SSBEBME這個字串,就可以對“我愛北京天安門”進行分詞,按照該字串,分詞結果為: 我/愛/北京/天安門。

那麼問題來了,如何由“我愛北京天安門”得到“SSBEBME”這個字串呢?

我們可以將“我愛北京天安門”理解為觀察結果,“SSBEBME”理解為隱藏的詞的狀態的遷移結果,即HMM中的隱式狀態轉移。那麼問題就成了:如何求解:P(S|O)= P(隱式狀態遷移序列|觀測序列).

6.2 問題求解

依據貝葉斯公式: P(S|O) = P(S,O)/P(O) = P(O|S)P(S)/P(O)

結合HMM相關知識,進一步求解P(S|O) = P(St|Ot) = P(Ot|St)*P(St|St-1)/P(Ot),由於每一個t時刻,P(Ot)都一樣,可以去掉,因此:

P(St|Ot) = P(Ot|St)*P(St|St-1),其中 P(Ot|St)為發射概率,即HMM的引數Bij,P(St|St-1)是HMM的狀態轉移矩陣引數,即Aij。

另外還已知初始狀態向量引數,因此可以求解出P(S|O)的最大值時的概率路徑,也就是 “SSBEBME”。

6.3 jieba中的HMM引數值

初始狀態概率在 jieba/finalseg/prop_start.py裡面。

P={'B': -0.26268660809250016,
 'E': -3.14e+100,
 'M': -3.14e+100,
 'S': -1.4652633398537678}

狀態轉移矩陣引數在 jieba/finalseg/prop_trans.py

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}}

發射概率在 jieba/finalseg/prop_emit.py

6.4 jieba中這些HMM的引數是怎麼來的呢?

據jieba官方介紹,是採用人明日報的語料庫 和另外一個分詞工具訓練來的。總而言之,這些引數是依據特殊語料統計得到的。如果使用jieba的預設引數導致分詞不理想時,應該考慮到重新訓練自己的HMM引數。

6.5 jieba HMM原始碼解析

原始碼路徑在 jieba/finalseg/_init_.py

主體程式碼流程如下:

def cut(sentence):
    # 先解碼
    sentence = strdecode(sentence)
    # 再按照正則表示式進行初步分割
    blocks = re_han.split(sentence)
    for blk in blocks:
        if re_han.match(blk):
            # 依據HMM模型,對未登入詞進行分割
            for word in __cut(blk):
                if word not in Force_Split_Words:
                    yield word
                else:
                    for c in word:
                        yield c
        else:
            tmp = re_skip.split(blk)
            for x in tmp:
                if x:
                    yield x

HMM分詞如下:

def __cut(sentence):
    global emit_P
    # 使用viterbi演算法進行HMM求解,即生成 BMES的狀態遷移序列
    prob, pos_list = viterbi(sentence, 'BMES', start_P, trans_P, emit_P)
    begin, nexti = 0, 0
    # print pos_list, sentence
    # 依據 BMES的狀態遷移序列,進行分詞
    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演算法求解狀態轉移序列。關於viterbi演算法,注意兩點:該演算法的推導時自頂向下,但是最終的計算是自底向上,體現在分詞上,就是從前往後計算。求解的目的也是找到一條最大概率路徑,因此也是動態規劃演算法。這裡就不推導了。

【三】總結

jieba分詞,對於登入詞,使用最大字首匹配的方法,其中精確模式使用了動態規劃演算法來計算最大概率路徑,進而得到最佳分詞。對於未登入詞,jieba使用HMM模型來求解最佳分詞。

兩種方式,都用到了詞典與模型的HMM模型引數。因此,對於某些分詞場景,可以使用自己的詞典和自己的語料庫訓練出來的HMM模型引數進行中文分詞,進而提升分詞準確性。

jieba分詞的核心理論基礎為:統計和概率論。不知道基於深度學習的演算法,是否可以進行中文分詞。