jieba源碼研讀筆記(六) - 分詞之精確模式(使用DAG有向無環圖+動態規劃)

jieba源碼研讀筆記(六) - 分詞之精確模式(使用DAG有向無環圖+動態規劃)

前言

本篇的主題是精確模式(不使用HMM,使用動態規劃),它是在Tokenizer這個類別中,以calc__cut_DAG_NO_HMM這兩個函數來實現。

jieba文檔上精確模式的介紹:

精确模式,试图将句子最精确地切开,适合文本分析;
采用了动态规划查找最大概率路径, 找出基于词频的最大切分组合

動態規劃的程序寫在calc函數中,但是因為它會把英數字都切成一個一個的字元,所以我們還需要__cut_DAG_NO_HMM這個wrapper,用於處理句中有英數字的情況。

calc函數

calc函數接受sentence,DAGroute三個參數。
其中sentece是要分詞的句子,DAG是由get_DAG函數得到的有向無環圖。
route在被傳入時是一個空的字典。
calc這個函數中會利用sentenceDAG來填充route,在函數結束後,route記錄的是句中每個詞的範圍,以及他們的機率對數。

def calc(self, sentence, DAG, route):
    N = len(sentence)
    route[N] = (0, 0)
    #self.total在gen_pfdict中計算,代表的是字典中所有詞出現次數的總和
    logtotal = log(self.total)
    """
    由後往前建構route
    """
    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])

route[idx]是一個tuple。其中第一個元素表示從句中第idx個字到句尾的切分方式的最大機率對數。第二個元素表示在該切分方式中與sentence[idx]組成詞的詞尾索引。

我們會使用動態規劃的方式,由後往前填充route
為什麼要由後往前填充呢?我們可以從代碼中看到,在計算route[idx]時會利用到route[x+1](x大於等於idx)的值。因為在計算前面的內容時會用到後面的內容,使用這種順序來填充,就能讓我們在一次循環裡完成整個過程。

接著開始來研讀代碼:

  • for x in DAG[idx]
    DAG這個字典記錄的是sentence中可以成詞的範圍。
    它的鍵代表詞首索引,值則代表以該鍵為詞首的所有詞的詞尾索引。
    在上面的代碼塊中,idx就是詞首索引,DAG[idx]是所有以sentence[idx]為詞首的詞的詞尾索引。x則是DAG[idx]中的單個元素。所以sentence[idx:x + 1]就表示DAG裡存的詞。

  • self.FREQ.get(sentence[idx:x + 1]) or 1
    這一句用到了dict.get(),它具有以下特性:

    D.get(k[,d]) -> D[k] if k in D, else d. d defaults to None.

    所以self.FREQ.get(sentence[idx:x + 1]) or 1表示如果sentence[idx:x + 1]這個字段不存在FREQ中,或者它的詞頻為0,則以1取代,這樣在經過log後的結果便是0。

  • logtotal:字典中所有詞出現次數的總和。

  • route[i]是一個tuple。tuple裡第一個元素代表的是sentence[i:](從當前字元到句子末尾的字段)最有可能出現的切分方式的機率對數。第二個元素則是在該切分方式中,包含sentence[i]的詞的詞尾索引。

  • log(self.FREQ.get(sentence[idx:x + 1]) or 1) - logtotal + route[x + 1][0]
    現在我們可以來看看這則算式代表的是什麼。第一項是句中第idx個字到第x個字的詞頻對數。將第一項與第二項相加,相當於把第一項正規化,得到的是機率對數。第三項是句中第x+1個字到句尾最大切分組合的機率對數。所以三項總和就是"如果新增x為切分點,這種組合的機率對數"。

  • 上述機率對數會與x(切分點)組成一個tuple。list comprehension結束後會得到一個list of tuple。

  • list of tuple作為max的參數。但是tuple要如何比較大小呢?根據How does tuple comparison work in Python?,tuple的比較方式是按元素順序比較,所以這裡主要是以詞組的機率對數作為比較對象。

  • max會找出sentence[idx:]的最大切分組合的機率對數以及對應的切分點。這也是route[idx]被賦予的值。

  • for idx in xrange(N - 1, -1, -1):
    由後往前填滿route,依序找出由sentence[idx]到句尾的最大切分組合的機率對數。

讓我們來模擬一次calc在看到人工智能這個句子時的計算過程:

  1. for循環的第一次迭代:
    idx是3,關注的是這個字。
    它的log(self.FREQ.get(sentence[idx:x + 1]) or 1)(以下簡稱第一項)是字典裡它的詞頻的對數。route[x + 1][0]=route[4][0](以下簡稱第三項)則被初始化為0。所以三項之和就是log(的詞頻*1/所有詞出現次數之和),這代表將sentence[3:]切成字的機率對數。

  2. 第二次迭代:
    idx是2,關注的是這個字。
    這時候字典中共有兩個相關的詞,分別是單字詞智能這個詞。

    首先看,也就是把智能切成兩個字的切分組合。它的三項總和可以重新表示成log(self.FREQ.get(sentence[2:2+1])) - logtatal + log(self.FREQ.get(sentence[3:3+1])) - logtotal + 0,也就是這個單字詞的機率對數乘上的機率對數。

    再來看智能,它代表的是不切分sentence[2:],而把它當成一個詞。它的三項總和可以重新表示成log(self.FREQ.get(sentence[2:3+1])) - logtatal + 0,即智能一詞的機率對數。

    從下表中我們可以看出,智能的三項總和大於的三項總和,所以最後會將route[2]更新為(智能的機率對數, 能的索引)。也就是說,比起將智能兩字分開,分詞算法更偏好將兩字當成一個詞。

  3. 第三次迭代:
    idx是3,關注的是這個字。這時字典裡只有這一個相關的詞。
    它的三項總和代表的是:的機率對數乘上智能的機率對數。

  4. 第四次迭代:
    idx為4,關注。字典裡有人工人工智能這幾個相關的詞。
    它們分別對應人|工|智能人工|智能人工智能這三種分詞方式。
    從他們的三項總和看來,算法最偏好將句子切成人工智能這一個詞。

注:這種方式會偏好切出長詞,具體情形可以參考词频高却没有被切出来這個issue。

詞首索引 idx詞尾索引 x
sentence
[詞首索引:
詞尾索引 + 1]
詞頻 self.FREQ.get(詞)詞頻對數:
log(詞頻 or 1)
負的總詞頻對數
-logtotal
從詞尾的
後一字到句尾(sentence[x+1:])
的機率對數,即route[x+1][0]
三項總和:
從該字到句尾(sentence[idx:])
的機率對數
route
[詞首索引]
339309611.44-17.910-6.47(-6.47, 3)
2233598.11-17.91-6.47-16.26
23智能7786.65-17.910-11.25(-11.25, 3)
1137718.23-17.91-11.25-20.93(-20.93, 1)
0031320912.65-17.91-20.93-26.18
01人工28167.94-17.91-11.25-21.22
03人工智能1515.01-17.910-12.89(-12.89, 3)

__cut_DAG_NO_HMM函數

這個函數利用calc函數算出route,因為英數字並不存在字典中,所以route裡的英數字會被切成一個一個的字元。

__cut_DAG_NO_HMM中,會由前往後掃描route裡的內容。使用re_eng來找出句中的英數字,如果碰到了,就把它們放到buf裡,碰到下個中文字時再輸出。

注:關於re_eng,可參考jieba源碼研讀筆記(四) - 正則表達式

def __cut_DAG_NO_HMM(self, sentence):
    DAG = self.get_DAG(sentence)
    route = {}
    #注意因為字典裡沒有記錄英數字的詞頻,所以會把它們切成一個一個的字元
    self.calc(sentence, DAG, route)
    x = 0
    N = len(sentence)
    buf = '' #用來暫存英數字
    # 由前往後
    while x < N:
        #route[x][1]表示的是"以sentence[x]開頭,擁有最大概率的詞的詞尾的索引"
        y = route[x][1] + 1
        #sentence[x:y]:從句子中擷取出該詞
        l_word = sentence[x:y]
        #如果是英數字且長度為1
        if re_eng.match(l_word) and len(l_word) == 1:
            buf += l_word
            #接下來從y(當前詞的下一個字元)開始搜尋
            x = y
        else:
            # 之前收集的字元被集中到buf裡,這裡輸出並清出buf
            if buf:
                yield buf
                buf = ''
            # 把buf清空後(如果有的話),才輸山l_word
            yield l_word
            #接下來從y(當前詞的下一個字元)開始搜尋
            x = y
    # 最後一次的清空
    if buf:
        yield buf
        buf = ''

測試:
首先看一下DAG的內容:

tokenizer = jieba.Tokenizer()
route = {}
sentence = "Andy今年13岁了"
DAG = tokenizer.get_DAG(sentence)
print(DAG)
"""
{0: [0],
 1: [1],
 2: [2],
 3: [3],
 4: [4, 5],
 5: [5],
 6: [6],
 7: [7],
 8: [8],
 9: [9]}
"""

可視化如下:
在这里插入图片描述
再來看看使用動態規劃分詞的結果:

sentence = "Andy今年13岁了" #N=10
jieba.lcut(sentence, cut_all=False, HMM=False)
xyl_wordbuf
01AA
12nAn
23dAnd
34yAndy
46今年
6711
78313
89
910

buf是儲存英文及數字的暫存空間,從上表可以看出,在Andy或13成詞以前,它們都被存在buf裡。

sentence = "Andy今年13岁了"
DAG = jieba.get_DAG(sentence)
route = {}
jieba.calc(sentence, DAG, route)
print(route)
"""
{10: (0, 0),
 9: (-4.219754899017666, 9),
 8: (-12.762341291514282, 8),
 7: (-30.6738944192695, 7),
 6: (-48.58544754702472, 6),
 5: (-54.073565153894585, 5),
 4: (-57.2907687308483, 5),
 3: (-75.20232185860351, 3),
 2: (-93.11387498635872, 2),
 1: (-111.02542811411394, 1),
 0: (-128.93698124186915, 0)}
"""

參考連結

jieba文檔
词频高却没有被切出来
How does tuple comparison work in Python?
jieba源碼研讀筆記(四) - 正則表達式

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值