結巴分詞2--基於前綴詞典及動態規划實現分詞


作者:zhbzz2007 出處:http://www.cnblogs.com/zhbzz2007 歡迎轉載,也請保留這段聲明。謝謝!

1 簡介

jieba分詞主要是基於統計詞典,構造一個前綴詞典;然后利用前綴詞典對輸入句子進行切分,得到所有的切分可能,根據切分位置,構造一個有向無環圖;通過動態規划算法,計算得到最大概率路徑,也就得到了最終的切分形式。

2 實例講解

以“去北京大學玩”為例,作為待分詞的輸入文本。

離線統計的詞典形式如下,每一行有三列,第一列是詞,第二列是詞頻,第三列是詞性。

...
北京大學 2053 nt
大學 20025 n
去 123402 v
玩 4207 v
北京 34488 ns
北 17860 ns
京 6583 ns
大 144099 a
學 17482 n
...

2.1 前綴詞典構建

首先是基於統計詞典構造前綴詞典,如統計詞典中的詞“北京大學”的前綴分別是“北”、“北京”、“北京大”;詞“大學”的前綴是“大”。統計詞典中所有的詞形成的前綴詞典如下所示,你也許會注意到“北京大”作為“北京大學”的前綴,但是它的詞頻卻為0,這是為了便於后面有向無環圖的構建。

...
北京大學 2053
北京大 0
大學 20025
去 123402
玩 4207
北京 34488
北 17860
京 6583
大 144099
學 17482
...

2.2 有向無環圖構建

然后基於前綴詞典,對輸入文本進行切分,對於“去”,沒有前綴,那么就只有一種划分方式;對於“北”,則有“北”、“北京”、“北京大學”三種划分方式;對於“京”,也只有一種划分方式;對於“大”,則有“大”、“大學”兩種划分方式,依次類推,可以得到每個字開始的前綴詞的划分方式。

在jieba分詞中,對每個字都是通過在文本中的位置來標記的,因此可以構建一個以位置為key,相應划分的末尾位置構成的列表為value的映射,如下所示,

0: [0]
1: [1,2,4]
2: [2]
3: [3,4]
4: [4]
5: [5]

對於0: [0],表示位置0對應的詞,就是0 ~ 0,就是“去”;對於1: [1,2,4],表示位置1開始,在1,2,4位置都是詞,就是1 ~ 1,1 ~ 2,1 ~ 4,即“北”,“北京”,“北京大學”這三個詞。

對於每一種划分,都將相應的首尾位置相連,例如,對於位置1,可以將它與位置1、位置2、位置4相連接,最終構成一個有向無環圖,如下所示,

2.3 最大概率路徑計算

在得到所有可能的切分方式構成的有向無環圖后,我們發現從起點到終點存在多條路徑,多條路徑也就意味着存在多種分詞結果,例如,

# 路徑1
0 -> 1 -> 2 -> 3 -> 4 -> 5
# 分詞結果1
去 / 北 / 京 / 大 / 學 / 玩
# 路徑2
0 -> 1 , 2 -> 3 -> 4 -> 5
# 分詞結果2
去 / 北京  /  大 / 學 / 玩
# 路徑3
0 -> 1 , 2 -> 3 , 4 -> 5
# 分詞結果3
去 / 北京  /  大學  /  玩
# 路徑4
0 -> 1 , 2 , 3 , 4 -> 5
# 分詞結果4
去 / 北京大學    /     玩
...

因此,我們需要計算最大概率路徑,也即按照這種方式切分后的分詞結果的概率最大。在計算最大概率路徑時,jieba分詞采用從后往前這種方式進行計算。為什么采用從后往前這種方式計算呢?因為,我們這個有向無環圖的方向是從前向后指向,對於一個節點,我們只知道這個節點會指向后面哪些節點,但是我們很難直接知道有哪些前面的節點會指向這個節點。

在采用動態規划計算最大概率路徑時,每到達一個節點,它前面的節點到終點的最大路徑概率已經計算出來。

3 源碼分析

3.1 算法流程

jieba.__init__.py中實現了jieba分詞接口函數cut(self, sentence, cut_all=False, HMM=True)。

jieba分詞接口主入口函數,會首先將輸入文本解碼為Unicode編碼,然后根據入參,選擇不同的切分方式,本文主要以精確模式進行講解,因此cut_all和HMM這兩個入參均為默認值;

切分方式選擇,

re_han = re_han_default
re_skip = re_skip_default

塊切分方式選擇,

cut_block = self.__cut_DAG

函數__cut_DAG(self, sentence)首先構建前綴詞典,其次構建有向無環圖,然后計算最大概率路徑,最后基於最大概率路徑進行分詞,如果遇到未登錄詞,則調用HMM模型進行切分。本文主要涉及前三個部分,基於HMM的分詞方法則在下一文章中詳細說明。

3.2 前綴詞典構建

get_DAG(self, sentence)函數會首先檢查系統是否初始化,如果沒有初始化,則進行初始化。在初始化的過程中,會構建前綴詞典。

構建前綴詞典的入口函數是gen_pfdict(self, f),解析離線統計詞典文本文件,每一行分別對應着詞、詞頻、詞性,將詞和詞頻提取出來,以詞為key,以詞頻為value,加入到前綴詞典中。對於每個詞,再分別獲取它的前綴詞,如果前綴詞已經存在於前綴詞典中,則不處理;如果該前綴詞不在前綴詞典中,則將其詞頻置為0,便於后續構建有向無環圖。

jieba分詞中gen_pfdict函數實現如下,

# f是離線統計的詞典文件句柄
def gen_pfdict(self, f):
    # 初始化前綴詞典
    lfreq = {}
    ltotal = 0
    f_name = resolve_filename(f)
    for lineno, line in enumerate(f, 1):
        try:
            # 解析離線詞典文本文件,離線詞典文件格式如第2章中所示
            line = line.strip().decode('utf-8')
            # 詞和對應的詞頻
            word, freq = line.split(' ')[:2]
            freq = int(freq)
            lfreq[word] = freq
            ltotal += freq
            # 獲取該詞所有的前綴詞
            for ch in xrange(len(word)):
                wfrag = word[:ch + 1]
                # 如果某前綴詞不在前綴詞典中,則將對應詞頻設置為0,
                # 如第2章中的例子“北京大”
                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

為什么jieba沒有使用trie樹作為前綴詞典存儲的數據結構?

參考jieba中的issue--不用Trie,減少內存加快速度;優化代碼細節 #187,本處直接引用該issue的comment,如下,

對於get_DAG()函數來說,用Trie數據結構,特別是在Python環境,內存使用量過大。經實驗,可構造一個前綴集合解決問題。

該集合儲存詞語及其前綴,如set(['數', '數據', '數據結', '數據結構'])。在句子中按字正向查找詞語,在前綴列表中就繼續查找,直到不在前綴列表中或超出句子范圍。大約比原詞庫增加40%詞條。

該版本通過各項測試,與原版本分詞結果相同。

測試:一本5.7M的小說,用默認字典,64位Ubuntu,Python 2.7.6。

Trie:第一次加載2.8秒,緩存加載1.1秒;內存277.4MB,平均速率724kB/s;

前綴字典:第一次加載2.1秒,緩存加載0.4秒;內存99.0MB,平均速率781kB/s;

此方法解決純Python中Trie空間效率低下的問題。

同時改善了一些代碼的細節,遵循PEP8的格式,優化了幾個邏輯判斷。

3.2 有向無環圖構建

有向無環圖,directed acyclic graphs,簡稱DAG,是一種圖的數據結構,顧名思義,就是沒有環的有向圖。

DAG在分詞中的應用很廣,無論是最大概率路徑,還是其它做法,DAG都廣泛存在於分詞中。因為DAG本身也是有向圖,所以用鄰接矩陣來表示是可行的,但是jieba采用了Python的dict結構,可以更方便的表示DAG。最終的DAG是以{k : [k , j , ..] , m : [m , p , q] , ...}的字典結構存儲,其中k和m為詞在文本sentence中的位置,k對應的列表存放的是文本中以k開始且詞sentence[k: j + 1]在前綴詞典中的 以k開始j結尾的詞的列表,即列表存放的是sentence中以k開始的可能的詞語的結束位置,這樣通過查找前綴詞典就可以得到詞。

get_DAG(self, sentence)函數進行對系統初始化完畢后,會構建有向無環圖。

從前往后依次遍歷文本的每個位置,對於位置k,首先形成一個片段,這個片段只包含位置k的字,然后就判斷該片段是否在前綴詞典中,

  1. 如果這個片段在前綴詞典中,

    1.1 如果詞頻大於0,就將這個位置i追加到以k為key的一個列表中;

    1.2 如果詞頻等於0,如同第2章中提到的“北京大”,則表明前綴詞典存在這個前綴,但是統計詞典並沒有這個詞,繼續循環;

  2. 如果這個片段不在前綴詞典中,則表明這個片段已經超出統計詞典中該詞的范圍,則終止循環;

  3. 然后該位置加1,然后就形成一個新的片段,該片段在文本的索引為[k:i+1],繼續判斷這個片段是否在前綴詞典中。

jieba分詞中get_DAG函數實現如下,

# 有向無環圖構建主函數
def get_DAG(self, sentence):
    # 檢查系統是否已經初始化
    self.check_initialized()
    # DAG存儲向無環圖的數據,數據結構是dict
    DAG = {}
    N = len(sentence)
    # 依次遍歷文本中的每個位置
    for k in xrange(N):
        tmplist = []
        i = k
        # 位置k形成的片段
        frag = sentence[k]
        # 判斷片段是否在前綴詞典中
        # 如果片段不在前綴詞典中,則跳出本循環
        # 也即該片段已經超出統計詞典中該詞的長度
        while i < N and frag in self.FREQ:
            # 如果該片段的詞頻大於0
            # 將該片段加入到有向無環圖中
            # 否則,繼續循環
            if self.FREQ[frag]:
                tmplist.append(i)
            # 片段末尾位置加1
            i += 1
            # 新的片段較舊的片段右邊新增一個字
            frag = sentence[k:i + 1]
        if not tmplist:
            tmplist.append(k)
        DAG[k] = tmplist
    return DAG

以“去北京大學玩”為例,最終形成的有向無環圖為,

{0: [0], 1: [1,2,4], 2: [2], 3: [3,4], 4: [4], 5: [5]}

3.3 最大概率路徑計算

3.2章節中構建出的有向無環圖DAG的每個節點,都是帶權的,對於在前綴詞典里面的詞語,其權重就是它的詞頻;我們想要求得route = (w1,w2,w3,...,wn),使得 \(\sum weight(w_{i})\) 最大。

如果需要使用動態規划求解,需要滿足兩個條件,

  • 重復子問題
  • 最優子結構

我們來分析一下最大概率路徑問題,是否滿足動態規划的兩個條件。

重復子問題

對於節點wi和其可能存在的多個后繼節點Wj和Wk,

任意通過Wi到達Wj的路徑的權重 = 該路徑通過Wi的路徑權重 + Wj的權重,也即{Ri -> j} = {Ri + weight(j)}
任意通過Wi到達Wk的路徑的權重 = 該路徑通過Wi的路徑權重 + Wk的權重,也即{Ri -> k} = {Ri + weight(k)}

即對於擁有公共前驅節點Wi的節點Wj和Wk,需要重復計算達到Wi的路徑的概率。

最優子結構

對於整個句子的最優路徑Rmax和一個末端節點Wx,對於其可能存在的多個前驅Wi,Wj,Wk...,設到達Wi,Wj,Wk的最大路徑分別是Rmaxi,Rmaxj,Rmaxk,有,

Rmax = max(Rmaxi,Rmaxj,Rmaxk,...) + weight(Wx)

於是,問題轉化為,求解Rmaxi,Rmaxj,Rmaxk,...等,

組成了最優子結構,子結構里面的最優解是全局的最優解的一部分。

狀態轉移方程為,

Rmax = max{(Rmaxi,Rmaxj,Rmaxk,...) + weight(Wx)}

jieba分詞中計算最大概率路徑的主函數是calc(self, sentence, DAG, route),函數根據已經構建好的有向無環圖計算最大概率路徑。

函數是一個自底向上的動態規划問題,它從sentence的最后一個字(N-1)開始倒序遍歷sentence的每個字(idx)的方式,計算子句sentence[idx ~ N-1]的概率對數得分。然后將概率對數得分最高的情況以(概率對數,詞語最后一個位置)這樣的元組保存在route中。

函數中,logtotal為構建前綴詞頻時所有的詞頻之和的對數值,這里的計算都是使用概率對數值,可以有效防止下溢問題。

jieba分詞中calc函數實現如下,

def calc(self, sentence, DAG, route):
    N = len(sentence)
    # 初始化末尾為0
    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])

4 Reference

jieba分詞學習筆記(三)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM