自然語言處理-中文分詞相關算法(MM、RMM、BMM、HMM)

一、前言

        關於中文分詞的介紹,之前已經詳細的介紹過了,此篇博文的重點是介紹一些具體的分詞方法。

二、分詞算法

        自中文自動分詞被提出以來,歷經將近30 年的探索,提出了很多方法,可主要歸納爲"規則分詞"、統計分詞"和"混合分詞(規則+統計)"這三個主要流派。規則分詞是最早興起的方法,主要是通過人工設立詞庫,按照一定方式進行匹配切分,其實現簡單高效,但對新詞很難進行處理。隨後統計機器學習技術的興起,應用於分詞任務上後,就有了統計分詞,能夠較好應對新詞發現等特殊場景。然而實踐中,單純的統計分詞也有缺陷,那就是太過於依賴語料的質量,因此實踐中多是採用這兩種方法的結合,即混合分詞。此博文主要介紹一些關於分詞的一些算法。

2.1 規則分詞

        基於規則的分詞是一種機械分詞方法,主要是通過維護詞典,在切分語句時,將語句的每個字符串與詞表中的詞進行逐一匹配,找到則切分,否則不切分。
        按照匹配切分的方式,主要有正向最大匹配法,逆向最大匹配法以及雙向最大匹配法三種方法。

2.1.1 正向最大匹配法

        正向最大匹配( Maximum Match Method , MM 法)的基本思想:假定分詞詞典中的最長詞有ii個漢字字符,則用被處理文檔的當前字串中的前ii個字作爲匹配字段,查找字典。若字典中存在這個的一個ii字詞,則匹配成功,匹配字段被作爲一個詞切分出來。如果字典中找不到這樣的一個ii字詞,則匹配失敗,將匹配字段中的最後一個字去掉,對剩下的字串重新進行匹配處理。如此進行下去,直到匹配成功,即切分處一個詞或剩餘字串的長度爲零爲止。這樣就完成了一輪匹配,然後去下一個ii字字串進行匹配處理,直到文檔被掃描完爲止。
其算法描述如下:
        1、從左向右取待切分漢語句的m個字符作爲匹配字段, m爲機器詞典中最長詞條的字符數。
        2、查找機器詞典並進行匹配。若匹配成功,則將這個匹配字段作爲一個詞切分出來。若匹配不成功,則將這個匹配字段的最後一個字去掉,剩下的字符串作爲新的匹配字段,進行再次匹配, 重複以上過程,直到切分出所有詞爲止。
具體算法描述如下所示:

正向即從前往後取詞,從7->1,每次減一個字,直到詞典命中或剩下1個單字。
第一輪掃描
第1次:“我們在野生動物”,掃描7字詞典,無
第2次:“我們在野生動”,掃描6字詞典,無
。。。。
第6次:“我們”,掃描2字詞典,有
掃描中止,輸出第1個詞爲“我們”,去除第1個詞後開始第2輪掃描,即:
第2輪掃描:
第1次:“在野生動物園玩”,掃描7字詞典,無
第2次:“在野生動物園”,掃描6字詞典,無
。。。。
第6次:“在野”,掃描2字詞典,有
掃描中止,輸出第2個詞爲“在野”,去除第2個詞後開始第3輪掃描,即:
第3輪掃描:
第1次:“生動物園玩”,掃描5字詞典,無
第2次:“生動物園”,掃描4字詞典,無
第3次:“生動物”,掃描3字詞典,無
第4次:“生動”,掃描2字詞典,有
掃描中止,輸出第3個詞爲“生動”,第4輪掃描,即:
第4輪掃描:
第1次:“物園玩”,掃描3字詞典,無
第2次:“物園”,掃描2字詞典,無
第3次:“物”,掃描1字詞典,無
掃描中止,輸出第4個詞爲“物”,非字典詞數加1,開始第5輪掃描,即:
第5輪掃描:
第1次:“園玩”,掃描2字詞典,無
第2次:“園”,掃描1字詞典,有
掃描中止,輸出第5個詞爲“園”,單字字典詞數加1,開始第6輪掃描,即:
第6輪掃描:
第1次:“玩”,掃描1字字典詞,有
掃描中止,輸出第6個詞爲“玩”,單字字典詞數加1,整體掃描結束。
正向最大匹配法,最終切分結果爲:“我們/在野/生動/物/園/玩”,其中,單字字典詞爲2,非詞典詞爲1。

程序實現(Python)

# """
#  author:jjk
#  datetime:2019/5/3
#  coding:utf-8
#  project name:Pycharm_workstation
#  Program function: 正向最大匹配( Maximum Match Method , MM 法)分詞
# """
"""
 S1、導入分詞詞典input.txt,存儲爲字典形式dic、導入停用詞詞典stop_words.utf8 ,存儲爲字典形式stoplis、需要分詞的文本文件 fenci.txt,存儲爲字符串chars
      S2、遍歷分詞詞典,找出最長的詞,其長度爲此算法中的最大分詞長度max_chars 
      S3、創建空列表words存儲分詞結果
      S4、初始化字符串chars的分詞起點n=0
      S5、判斷分詞點n是否在字符串chars內,即n < len(chars)  如果成立,則進入下一步驟,否則進入S9
      S6、根據分詞長度i(初始值爲max_chars)截取相應的需分詞文本chars的字符串s 
      S7、判斷s是否存在於分詞詞典中,若存在,則分兩種情況討論,一是s是停用詞,那麼直接刪除,分詞起點n後移i位,轉到步驟5;
          二是s不是停用詞,那麼直接添加到分詞結果words中,分詞起點n後移i位,
        轉到步驟5;若不存在,則分兩種情況討論,一是s是停用詞,那麼直接刪除,分詞起點後移i位,
        轉到步驟5;二是s不是停用詞,分詞長度i>1時,分詞長度i減少1,
        轉到步驟6 ,若是此時s是單字,則轉入步驟8;
      S8、將s添加到分詞結果words中,分詞起點n後移1位,轉到步驟5
      S9、將需分詞文本chars的分詞結果words輸出到文本文件result.txt中
"""
import codecs

#分詞字典
f1 = codecs.open('input.txt', 'r', encoding='utf8')
dic = {}
while 1:
    line = f1.readline()
    if len(line) == 0:
        break
    term = line.strip() #去除字典兩側的換行符,避免最大分詞長度出錯
    dic[term] = 1
f1.close()

#獲得需要分詞的文本
f2 = codecs.open('fenci.txt', 'r', encoding='utf8')
chars = f2.read().strip()
f2.close()

#停用詞典,存儲爲字典形式
f3 = codecs.open('stop_words.utf8', 'r', encoding='utf8')
stoplist = {}
while 1:
    line = f3.readline()
    if len(line) == 0:
        break
    term = line.strip()
    stoplist[term] = 1
f3.close()

"""
正向匹配最大分詞算法
遍歷分詞詞典,獲得最大分詞長度
"""
max_chars = 0
for key in dic:
    if len(key) > max_chars:
        max_chars = len(key)

#定義一個空列表來存儲分詞結果
words = []
n = 0
while n < len(chars):
    matched = 0
    #range([start,] stop[, step]),根據start與stop指定的範圍以及step設定的步長 step=-1表示去掉最後一位
    for i in range(max_chars, 0, -1): #i等於max_chars到1
        s = chars[n : n + i] #截取文本字符串n到n+1位
        #判斷所截取字符串是否在分詞詞典和停用詞詞典內
        if s in dic:
            if s in stoplist: #判斷是否爲停用詞
                words.append(s)
                matched = 1
                n = n + i
                break
            else:
                words.append(s)
                matched = 1
                n = n + i
                break
        if s in stoplist:
            words.append(s)
            matched = 1
            n = n + i
            break
    if not matched: #等於 if matched == 0
        words.append(chars[n])
        n = n + 1
#分詞結果寫入文件
f3 = open('MMresult.txt','w', encoding='utf8') # 輸出結果寫入到MMresult.txt中
f3.write('/'.join('%s' %id for id in words))
print('/'.join('%s' %id for id in words)) # 打印到控制檯
f3.close() # 關閉文件指針

測試結果
在這裏插入圖片描述注:獲取源碼及相應文件看博文末尾

2.1.2 逆向最大匹配法

        逆向最大匹配( Reverse Maximum Match Method , RMM 法)的基本原理與MM 法相同,不同的是分詞切分的方向與MM 法相反。逆向最大匹配法從被處理文檔的末端開始匹配掃描,每次取最末端的i個字符( i 爲詞典中最長詞數)作爲匹配字段,若匹配失敗,則去掉匹配字段最前面的一個字,繼續匹配。相應地,它使用的分詞詞典是逆序詞典,其中的每個詞條都將按逆序方式存放。在實際處理時,先將文檔進行倒排處理,生成逆序文擋。然後,根據逆序詞典,對逆序文檔用正向最大匹配法處理即可。
        由於漢語中偏正結構較多, 若從後向前匹配,可以適當提高精確度。所以,逆向最大匹配法比正向最大匹配法的誤差要小。

具體算法描述如下所示:

逆向即從後往前取詞,其他邏輯和正向相同。即:
第1輪掃描:“在野生動物園玩”
第1次:“在野生動物園玩”,掃描7字詞典,無
第2次:“野生動物園玩”,掃描6字詞典,無
。。。。
第7次:“玩”,掃描1字詞典,有
掃描中止,輸出“玩”,單字字典詞加1,開始第2輪掃描
第2輪掃描:“們在野生動物園”
第1次:“們在野生動物園”,掃描7字詞典,無
第2次:“在野生動物園”,掃描6字詞典,無
第3次:“野生動物園”,掃描5字詞典,有
掃描中止,輸出“野生動物園”,開始第3輪掃描
第3輪掃描:“我們在”
第1次:“我們在”,掃描3字詞典,無
第2次:“們在”,掃描2字詞典,無
第3次:“在”,掃描1字詞典,有
掃描中止,輸出“在”,單字字典詞加1,開始第4輪掃描
第4輪掃描:“我們”
第1次:“我們”,掃描2字詞典,有
掃描中止,輸出“我們”,整體掃描結束。
逆向最大匹配法,最終切分結果爲:“我們/在/野生動物園/玩”,其中,單字字典詞爲2,非詞典詞爲0。

程序實現

# """
#  author:jjk
#  datetime:2019/5/3
#  coding:utf-8
#  project name:Pycharm_workstation
#  Program function: 逆向最大匹配(Reverse Maximum Match Method , RMM 法)分詞
# """

class IMM(object):
    def __init__(self,dic_path):
        self.dictionary=set()
        self.maximum = 0
        # 讀取字典
        with open(dic_path,'r',encoding='utf8') as f:
            for line in f:
                line = line.strip()
                if line:
                    self.dictionary.add(line)
            self.maximum = len(self.dictionary)

    def cut(self,text):
        # 用於存放切分出來的詞
        result = []
        index = len(text)
        # 記錄沒有在詞典中的詞,可以用於發現新詞
        no_word = ''
        while index>0:
            word = None
            # 從前往後匹配,以此實現最大匹配
            for first in range(index):
                if text[first:index] in self.dictionary:
                    word = text[first:index]
                    # 如果之前存放字典裏面沒有出現過的詞
                    if no_word != '':
                        result.append(no_word[::-1])
                        no_word = ''
                    result.append(text[first:index])
                    index = first
                    break
            if word == None:
                index = index - 1
                no_word += text[index]
        return  result[::-1]


def main():
    text = '南京市長江大橋'
    tokenizer = IMM('imm_dic.utf8') # 調用類函數
    print(tokenizer.cut(text)) # 輸出

if __name__ == '__main__':
    main()

在這裏插入圖片描述
注:獲取源碼及相應文件看博文末尾

2.1.3 雙向最大匹配法

        雙向最大匹配法( Bi-directction Matching method ) 是將正向最大匹配法得到的分詞結果和逆向最大匹配法得到的結果進行比較然後按照最大匹配原則,選取詞數切分最少的作爲結果。據SunM.S. 和Benjamin K.T. ( 1995 )的研究表明,中文中90.0% 左右的句子,正向最大匹配法和逆向最大匹配法完全重合且正確,只有大概9 .0% 的句子兩種切分方法得到的結果不一樣,但其中必有一個是正確的(歧義檢測成功) ,只有不到1.0%的句子,使用正向最大匹配法和逆向最大匹配法的切分雖重合卻是錯的,或者正向最大匹配法和逆向最大匹配法切分不同但兩個都不對(歧義檢測失敗) 。這正是雙向最大匹配法在實用中文信息處理系統中得以廣泛使用的原因。

具體算法描述如下所示:

正向最大匹配法和逆向最大匹配法,都有其侷限性,因此有人又提出了雙向最大匹配法。即,兩種算法都切一遍,然後根據大顆粒度詞越多越好,非詞典詞和單字詞越少越好的原則,選取其中一種分詞結果輸出。如:“我們在野生動物園玩”
正向最大匹配法,最終切分結果爲:“我們/在野/生動/物/園/玩”,其中,兩字詞3個,單字字典詞爲2,非詞典詞爲1。
逆向最大匹配法,最終切分結果爲:“我們/在/野生動物園/玩”,其中,五字詞1個,兩字詞1個,單字字典詞爲2,非詞典詞爲0。
非字典詞:正向(1)>逆向(0)(越少越好)
單字字典詞:正向(2)=逆向(2)(越少越好)
總詞數:正向(6)>逆向(4)(越少越好)
因此最終輸出爲逆向結果。

源碼實現

package test;

import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.HashSet;
import java.util.Set;
import java.util.Vector;


public class FBSegment {
    private static Set<String> seg_dict;

    //加載詞典
    public static void Init(){

        seg_dict = new HashSet<String>();
        String dicpath = "F:\\IDEA\\IDEA_workstation\\src\\test\\input.txt";
        String line = null;


        try{
            BufferedReader br = new BufferedReader( new InputStreamReader(new FileInputStream(dicpath)));
            while((line = br.readLine()) != null){
                line = line.trim();
                if(line.isEmpty())
                    continue;
                seg_dict.add(line);
            }
            br.close();
        }catch(IOException e){
            e.printStackTrace();
        }

    }
    /**
     * 前向算法分詞
     * @param seg_dict 分詞詞典
     * @param phrase 待分詞句子
     * @return 前向分詞結果
     */
    private static Vector<String> FMM2( String  phrase){
        int maxlen = 16;
        Vector<String> fmm_list = new Vector<String>();
        int len_phrase = phrase.length();
        int i=0,j=0;

        while(i < len_phrase){
            int end = i+maxlen;
            if(end >= len_phrase)
                end = len_phrase;
            String phrase_sub = phrase.substring(i, end);
            for(j = phrase_sub.length(); j >=0; j--){
                if(j == 1)
                    break;
                String key =  phrase_sub.substring(0, j);
                if(seg_dict.contains(key)){
                    fmm_list.add(key);
                    i +=key.length() -1;
                    break;
                }
            }
            if(j == 1)
                fmm_list.add(""+phrase_sub.charAt(0));
            i+=1;
        }
        return fmm_list;
    }
    /**
     * 後向算法分詞
     * @param seg_dict 分詞詞典
     * @param phrase 待分詞句子
     * @return 後向分詞結果
     */
    private static Vector<String> BMM2( String  phrase){
        int maxlen = 16;
        Vector<String> bmm_list = new Vector<String>();
        int len_phrase = phrase.length();
        int i=len_phrase,j=0;

        while(i > 0){
            int start = i - maxlen;
            if(start < 0)
                start = 0;
            String phrase_sub = phrase.substring(start, i);
            for(j = 0; j < phrase_sub.length(); j++){
                if(j == phrase_sub.length()-1)
                    break;
                String key =  phrase_sub.substring(j);
                if(seg_dict.contains(key)){
                    bmm_list.insertElementAt(key, 0);
                    i -=key.length() -1;
                    break;
                }
            }
            if(j == phrase_sub.length() -1)
                bmm_list.insertElementAt(""+phrase_sub.charAt(j), 0);
            i -= 1;
        }
        return bmm_list;
    }

    /**
     * 該方法結合正向匹配和逆向匹配的結果,得到分詞的最終結果
     * @param FMM2 正向匹配的分詞結果
     * @param BMM2 逆向匹配的分詞結果
     * @param return 分詞的最終結果
     */
    public static Vector<String> segment( String phrase){
        Vector<String> fmm_list = FMM2(phrase);
        Vector<String> bmm_list = BMM2(phrase);
        //如果正反向分詞結果詞數不同,則取分詞數量較少的那個
        if(fmm_list.size() != bmm_list.size()){
            if(fmm_list.size() > bmm_list.size())
                return bmm_list;
            else return fmm_list;
        }
        //如果分詞結果詞數相同
        else{
            //如果正反向的分詞結果相同,就說明沒有歧義,可返回任意一個
            int i ,FSingle = 0, BSingle = 0;
            boolean isSame = true;
            for(i = 0; i < fmm_list.size();  i++){
                if(!fmm_list.get(i).equals(bmm_list.get(i)))
                    isSame = false;
                if(fmm_list.get(i).length() ==1)
                    FSingle +=1;
                if(bmm_list.get(i).length() ==1)
                    BSingle +=1;
            }
            if(isSame)
                return fmm_list;
            else{
                //分詞結果不同,返回其中單字較少的那個
                if(BSingle > FSingle)
                    return fmm_list;
                else return bmm_list;
            }
        }
    }
    public static void main(String [] args){
        String test = "南京市長江大橋";
        FBSegment.Init();
        System.out.println(FBSegment.segment(test));
    }
}

在這裏插入圖片描述
注:獲取源碼及相應文件看博文末尾

2.2 統計分詞

        隨着大規模語料庫的建立,統計機器學習方法的研究和發展,基於統計的中文分詞算法漸漸成爲主流。
       其主要思想是把每個詞看做是由詞的最小單位的各個字組成的,如果相連的字在不同的文本中出現的次數越多,就證明這相連的字很可能就是一個詞。因此我們就可以利用字與字相鄰出現的頻率來反應成詞的可靠度,統計語料中相鄰共現的各個字的組合的頻度,當組合頻度高於某一個臨界值時,我們便可認爲此字組可能會構成一個詞語。
       1 ) 建立統計語言模型。
       2 ) 對句子進行單詞劃分,然後對劃分結果進行概率計算,獲得概率最大的分詞方式。這裏就用到了統計學習算法,如隱含馬爾可夫(HMM) 、條件隨機場(CRF) 等。

2.2.1 語言模型

       語言模型在信息檢索、機器翻譯、語音識別中承擔着重要的任務。用概率論的專業術語描述語言模型就是:長度爲mm的字符串確定其概率分佈:
P(w1,w2,...,wm)=P(w1)P(w2w1)P(w3w1,w2)...P(wiw1,w2,...wi1),...wi)...P(wmw1,w2,...wm1)(1)P(w_1{},w_2{},...,w_m{})=P(w_1{})P(w_2{}|w_1{})P(w_3{}|w_1,w_2{})...P(w_i{}|w_1{},w_2{},...w_{_i-1}),...w_i{})...P(w_m{}|w_1{},w_2{},...w_{_m-1}) (1)
由上式可得,當文本過長時,公式右部從第三項起的每一項計算難度都很大。從而有人提出了nn元模型(ngrammodeln-gram model)降低該計算難度
       所謂nn元模型就是在估算條件概率時,忽略距離大於等於nn的上下文詞的影響,因此.P(wiw1,w2,...wi1).P(w_i{}|w_1{},w_2{},...w_{_i-1})的計算可簡化爲:P(wiw1,w2,...wi1)P(wiwi(n1),...,wi1)2P(w_i{}|w_1{},w_2{},...w_{_i-1})≈P(w_i|w_{i-(n-1),}...,w_{i-1})(2),
       n=1n=1,稱爲一元模型,此時整個句子的概率可表示爲:P(w1,w2,...,wm)=P(wi)P(w1)P(w2)...P(wm)P(w_1{},w_2{},...,w_m{})=P(w_i)P(w_1)P(w_2)...P(w_m)在一元語言模型中,整個句子的概率等於各個詞語概率的乘積。也就是說各個詞之間之間都是相互獨立的,這無疑是完全損失了句子中詞序信息。所以一元模型的效果固然不好。
       n=2n=2稱爲二元模型,將公式2變爲:P(wiw1,w2,...wi1)=P(wiwi1)P(w_i{}|w_1{},w_2{},...w_{_i-1})=P(w_i|w_{i-1})
       n=3n=3稱爲三元模型,將公式2變爲:P(wiw1,w2,...wi1)=P(wiwi2,wi1)P(w_i{}|w_1{},w_2{},...w_{_i-1})=P(w_i|w_{i-2},w_{i-1})
顯然當n2n≥2時,該模型是可以保留一定的詞序信息的,而且nn越大,保留的詞性信息越豐富,但要考慮計算成本奧。
一般使用頻率計數的比例來計算nn元條件概率,如:P(wiw1,w2,...wi1)=count(wi(n1),...,wi1,wi)count(wi(n1),...wi1)3P(w_i{}|w_1{},w_2{},...w_{_i-1})=\frac{count(w_{i-(n-1)},...,w_{i-1},w_i)}{count(w_{i-(n-1)},...w_{i-1})}(3) 公中count(wi(n1),...wi1){count(w_{i-(n-1)},...w_{i-1})}表示詞語wi(n1),...wi1w_{i-(n-1)},...w_{i-1}在語料庫中出現的總次數。
       由此可見,nn越大時,模型包含的詞序信息越豐富,同時計算量隨之增大。與此同時,長度越長的文本序列出現的次數也會越少,如公式3估計n元條件概率時,就會出現分子分母爲零的情況。因此,一般在n元模型中需要配合相應的平滑算法解決該方法,如拉普拉斯平滑算法等

2.2.2 HMM模型

       隱含馬爾可夫模型(HMM) 是將分詞作爲字在字串中的序列標註任務來實現的。其基本思路是: 每個字在構造一個特定的詞語時都佔據着一個確定的構詞位置(即詞位),現規定每個字最多隻有四個構詞位置: 即B (詞首)、M( 詞中)、E (詞尾)和s (單獨成詞) ,那麼下面句子1 )的分詞結果就可以直接表示成如2) 所示的逐字標註形式:

1 ) 中文/ 分詞/是1. 文本處理/不可或缺/的/ 一步!
2 ) 中/B 文/E 分/B 詞/E 是/S 文/B 本/B 處/M 理/E 不/B 可/M 或/M 缺/E 的/S一/B 步/E ! /S

用數學抽象表示如下:λ=λ1λ2λ3...λn\lambda=\lambda_1\lambda_2\lambda_3...\lambda_n代表輸入的句子,nn爲句子長度,λi\lambda_i表示字,o=o1o2...ono=o_1o_2...o_n代表輸出的標籤,那麼理想的輸出即爲:
                                                               max=maxP(o1o2...onλ1λ2λ3...λn)1max=maxP(o_1o_2...o_n|\lambda_1\lambda_2\lambda_3...\lambda_n) (1)
在分詞任務上,oo即爲B,M,E,S這四中標記,λ\lambda爲諸如“中” “文”等句子中的每個字(包括標點等非中文字符)。

需要注意的是,P(oλ)P(o|\lambda)均是關於2n 個變量的條件概率,且n 不固定。因此,幾乎無法對P(oλ)P(o|\lambda)進行精確計算。這裏引人觀測獨立性假設,即每個字的輸出僅僅與當前字有關,於是就能得到下式:
                                                   P(o1o2...onλ1λ2λ3...λn)=P(o1λ1)P(o2λ2)...P(onλn)2P(o_1o_2...o_n|\lambda_1\lambda_2\lambda_3...\lambda_n) =P(o_1|\lambda_1)P(o_2|\lambda_2)...P(o_n|\lambda_n) (2)

事實上, P(okλk)P(o_k|\lambda_k)的計算要容易得多。通過觀測獨立性假設,目標問題得到極大簡化。然而該方法完全沒有考慮上下文,且會出現不合理的情況。比如按照之前設定的B 、M、E 和S 標記,正常來說B後面只能是M或者E ,然而基於觀測獨立性假設,我們很可能得到諸如BBB 、BEM 等的輸出,顯然是不合理的。
HMM 就是用來解決該問題的一種方法。在上面的公式中,我們一直期望求解的是P(oλ)P(o|λ),通過貝葉斯公式能夠得到:
                                                                              P(oλ)=P(oλ)P(λ)=P(λo)P(o)P(λ)3P(o|λ)=\frac{P(o|λ)}{P(λ)}=\frac{P(λ|o)P(o)}{P(λ)} (3)
λλ爲給定的輸入,因此P(λ)P(λ)計算爲常數,可以忽略,因此最大化P(oλ)P(o|λ)等價於最大化P(λo)P(o)P(λ|o)P(o)
針對P(λo)P(o)P(λ|o)P(o)作馬爾科夫假設,得到:
                                                                          P(λo)=P(λ1o1)P(λ2O2)...P(λnon)4P(λ|o)=P(λ_1|o_1)P(λ_2|O_2)...P(λ_n|o_n) (4)
同時,對P(o)P(o)有:
                                                               P(o)=P(o1)P(o2o1)P(o3o1,o2)...P(ono1,o2,...,on1)5P(o) =P(o_1)P(o_2|o_1)P(o_3|o_1,o_2)...P(o_n|o_1,o_2,...,o_{n-1}) (5)
這裏HMM做了另外一個假設——齊次馬爾科夫假設,每個輸出僅僅與上一個輸出有關,那麼:
                                                           P(o)=P(o1)P(o2o1)P(o3o1,o2)...P(ono1,o2,...,on1)6P(o)=P(o_1)P(o_2|o_1)P(o_3|o_1,o_2)...P(o_n|o_1,o_2,...,o_{n-1}) (6)
於是:
                                                    P(λo)P(o)P(λ1o1)P(o2o1)P(λ2o2)P(o3o2)...P(on1P(λnon))(7)P(λ|o)P(o)—P(λ_1|o_1)P(o_2|o_1)P(λ_2|o_2)P(o_3|o_2)...P(o_{n-1}P(λ_n|o_n)) (7)
在HMM中,將P(λkok)P(λ_k|o_k)稱爲發射概率,P(okok1)P(o_k|o_{k-1})稱爲轉移概率。通過設置某些P(okk1)=0P(o_k|k_1)=0,可以排除類似BBB、EM等不合理的組合。
        事實上,式(6) 的馬爾可夫假設就是一個二元語言模型( bigram model ) , 當將齊次馬爾可夫假設改爲每個輸出與前兩個有關時,就變成了三元語言模型( trigram model ) 。當然在實際分詞應用中還是多采用二元模型,因爲相比三元模型,其計算複雜度要小不少。
         在HMM 中,求解maxP(λo)P(o)maxP(λ|o)P(o)的常用方法是Veterbi 算法。它是一種動態規劃方法,核心思想是: 如果最終的最優路徑經過某個oio_i, 那麼從初始節點到oi1o_{i-1}點的路徑必然也是一個最優路徑一一因爲每一個節點。i 只會影響前後兩個P(oi1P(oioi+1)P(o_{i-1}和P(o_i|o_{i+1})

         根據這個思想,可以通過遞推的方法,在考慮每個0; 時只需要求出所有經過各oi1o_{i-1}的候選點的最優路徑, 然後再與當前的oio_i 結合比較。這樣每步只需要算不超過l2l^{2}次,就可以逐步找出最優路徑。Viterbi 算法的效率是O(nl2)O(n·l^{2}),ll是候選數目最多的節點oio_i的候選數目,它正比於n, 這是非常高效率的。HMM 的狀態轉移圖如下圖 所示。
在這裏插入圖片描述

源碼實現

# -*- coding: utf-8 -*-
__author__ = 'tan'

import os
import logging
import codecs
import pickle
import numpy as np

class HMMModel:

    def __init__(self, N, M, PI, AA, BB):
        self.n = N
        self.m = M
        self.pi = PI
        self.B = BB
        self.A = AA


    def viterbi(self, T, O):
        '''
        下標都是從0開始的
        :param T:
        :param O:
        :return:
        '''
        # 初始化

        delta = np.zeros((T, self.n))
        psi = np.zeros((T, self.n))

        for i in range(self.n):
            delta[0][i] = self.pi[i]*self.B[i][O[1]]
            psi[0][i] = 0

        # 遞推
        for t in range(1, T):
            for i in range(self.n):
                maxDelta = 0.0
                index = 1
                for j in range(self.n):
                    if maxDelta < delta[t-1][j] * self.A[j][i]:
                        maxDelta = delta[t-1][j] * self.A[j][i]
                        index = j

                delta[t][i] = maxDelta * self.B[i][O[t]]
                psi[t][i] = index

        # 終止

        prob = 0
        path = [0 for _ in range(T)]
        path[T-1] = 1
        for i in range(self.n):
            if prob < delta[T-1][i]:
                prob = delta[T-1][i]
                path[T-1] = i

        # 最優路徑回溯
        for t in range(T-2, -1, -1):
            path[t] = psi[t+1][path[t+1]]
            return path, prob, delta, psi


class HMMSegment:

    def __init__(self, dictfile="dict.utf8.txt"):
        '''

        :param dictfile: 詞彙文件名
        :return:
        '''
        self.word2idx = {}
        self.idx2word = {}
        self.hmmmodel = None
        self.outfile = dictfile
        self.inited = False

    def build_dict_file(self, filename):
        f = codecs.open(filename, "rb", encoding="utf-8")
        idx = 1
        words = {}
        for line in f:
            line = line.strip()
            if len(line) == 0:
                continue

            idx += 1
            if idx % 100 == 0:
                print("read %d lines" % idx)

            ws = line.split()
            for word in ws:
                for _, w in enumerate(word):
                    if w not in words:
                        words[w] = 0
                    words[w] += 1

        f.close()

        dicts = sorted(words.items(), key=lambda d:d[1], reverse=True)

        print("writing the words in to file {}".format(self.outfile))
        dictfile = codecs.open(self.outfile, "wb", encoding="utf-8")
        for d in dicts:
            dictfile.write("%s\t%d\n" % (d[0], d[1]))
        dictfile.close()

    def init_paramater(self, load=False, save=False):
        '''
        '''
        if load == True:
            self.word2idx = pickle.load(open("word2idx.pkl", "rb"))
            self.idx2word = pickle.load(open("idx2word.pkl", "rb"))
        else:
            f = codecs.open(self.outfile, "rb", encoding="utf-8")
            for idx, line in enumerate(f):
                word, _ = line.strip().split("\t")
                self.word2idx[word] = idx + 1
                self.idx2word[idx+1] = word
            f.close()

            if save:
                pickle.dump(self.word2idx, open("word2idx.pkl", "wb"))
                pickle.dump(self.idx2word, open("idx2word.pkl", "wb"))

    def init_model(self, trainfile=None, load=False, save=False):

        self.init_paramater(load, save)

        if load:
            A = pickle.load(open("A.pkl", "rb"))
            B = pickle.load(open("B.pkl", "rb"))
            PI = pickle.load(open("PI.pkl", "rb"))

        else:
            f = codecs.open(trainfile, "rb", encoding="utf-8")
            lines = f.readlines()
            f.close()

            PI, A, B = self.init_A_B_PI(lines)

            if save:
                pickle.dump(A, open("A.pkl", "wb"))
                pickle.dump(B, open("B.pkl", "wb"))
                pickle.dump(PI, open("PI.pkl", "wb"))

        self.hmmmodel = HMMModel(4, len(self.word2idx), PI, A, B)

    def init_A_B_PI(self, lines):
        '''
         * count matrix:
         *   ALL B M E S
         * B *   * * * *
         * M *   * * * *
         * E *   * * * *
         * S *   * * * *
         *
         * NOTE:
         *  count[2][4] is the total number of complex words
         *  count[3][4] is the total number of single words
        :return:
        :param lines:
        :return:
        '''

        print("Init A B PI paramaters")
        last = ""
        countA = np.zeros((4, 5))
        numwords = len(self.word2idx)
        print(numwords)
        countB = np.zeros((4, numwords+1))

        for line in lines:
            line = line.strip()
            if len(line) == 0:
                continue
            phrase = line.split(" ")
            for word in phrase:
                # print(word)
                word = word.strip()
                num = len(word)
                #只有一個單詞 S 3
                if num == 1:
                    #countB
                    countB[3][self.word2idx[word]] += 1
                    countB[3][0] += 1

                    ########################
                    countA[3][4] += 1 # 單個詞的個數
                    #統計轉移值
                    if last != "":
                        #單獨詞轉移過來 S -> S
                        if len(last) == 1:
                            countA[3][3] += 1
                        #是從詞尾轉移過來 E-> S
                        else:
                            countA[2][3] += 1
                else:
                    countA[2][4] += 1 # 多個詞的個數
                    countA[0][4] += 1 # B->任意 統計
                    if num > 2:
                        countA[0][1] += 1 # B -> M
                        countA[1][4] += num - 2 # 統計M轉移的個數
                        if num > 3: # M-> M
                            countA[1][1] += num - 3
                        countA[1][2] += 1 # M->E
                    else:
                        countA[0][2] += 1 # B->E

                    if last != "":
                        if len(last) == 1:
                            countA[3][0] += 1 # S-> B
                        else:
                            countA[2][0] += 1 # E -> B

                    ###countB 用於計算B矩陣
                    for idx, w in enumerate(word):
                        if idx == 0:
                            countB[0][self.word2idx[word[idx]]] += 1
                            countB[0][0] += 1
                        elif idx == num - 1:
                            countB[2][self.word2idx[word[idx]]] += 1
                            countB[2][0] += 1
                        else:
                            countB[1][self.word2idx[word[idx]]] += 1
                            countB[1][0] += 1

                last = word

        countA[2][0] += 1 # 最後一個E 設爲E->B

        print("The count matrix is:")
        print(countA)
        # print(countB)

        A = np.zeros((4, 4))
        PI = np.array([0.0] * 4)

        B = np.zeros((4, numwords+1))

        allwords = countA[2][4] + countA[3][4]

        PI[0] = countA[0][4] / allwords
        PI[3] = countA[3][4] / allwords

        for i in range(4):
            for j in range(4):
                A[i][j] = countA[i][j] / countA[i][4]

            for j in range(1, numwords+1):
                B[i][j] = (countB[i][j] + 1) / countB[i][0]

        print("A and PI B is ")
        print(PI)
        print(A)
        # print(B)

        return PI, A, B

    def segment_sent(self, sentence):

        if not self.inited:
            self.init_model(load=True)

        O = []
        for w in sentence:
            w = w.strip()
            if len(w) == 0:
                continue
            if w not in self.word2idx:
                num = len(self.word2idx)+1
                self.word2idx[w] = num
                self.idx2word[num] = w
                #初始化未登錄詞的概率
                self.hmmmodel.B = np.column_stack((self.hmmmodel.B, np.array([0.3, 0.3, 0.3, 0.1])))
            O.append(self.word2idx[w])

        T = len(O)

        path, prob, delta, psi = self.hmmmodel.viterbi(T, O)
        result = ""
        for idx, p in enumerate(path):
            # print(self.idx2word[O[idx]], end="")
            result += self.idx2word[O[idx]]
            if p == 2 or p == 3:
                # print("/ ", end="")
                result +="/ "
        # print("")
        # print(path)
        # print(prob)
        return result

    def cut_sentence_new(self, content):
        start = 0
        i = 0
        sents = []
        punt_list = ',.!?:;~,。!?:;~'
        for word in content:
            if word in punt_list and token not in punt_list: #檢查標點符號下一個字符是否還是標點
                sents.append(content[start:i+1])
                start = i+1
                i += 1
            else:
                i += 1
                token = list(content[start:i+2]).pop()  # 取下一個字符
        if start < len(content):
            sents.append(content[start:])
        return sents

    def segment_sentences(self, content):
        result = ""
        sentences = self.cut_sentence_new(content)
        for sent in sentences:
            # print(sent)
            result  += self.segment_sent(sent)
            # print(tmp)
            # result += tmp

        return result



if __name__ == "__main__":
    traingfile = "trainCorpus.txt_utf8"
    hmmseg = HMMSegment()
    # hmmseg.init_model(traingfile, save=True)
    hmmseg.init_model(traingfile, load=True)
    sent = "我是在我在那,昆明理工大學"
    # hmmseg.segment_sent(sent)
    two = "辛辛苦苦做的敲個代碼還是錯的,我好難呀"
    # hmmseg.segment_sent(two)

    content = u'''我是誰,我在那,敲個代碼還是錯的,我好難那'''
    print(hmmseg.segment_sentences(content))
    # hmmseg.build_dict_file(traingfile)
    # lines = ["abc a ab", "abc a a ab abcd"]
    # hmmseg.init_A_B_PI(lines)
    # hmmseg.init_B(lines)

在這裏插入圖片描述
注:獲取源碼及相應文件看博文末尾

2.3 混合分詞

       事實上,目前不管是基於規則的算法、還是基於HMM 、CRF或者deep learning 等的方法,其分詞效果在具體任務中,其實差距並沒有那麼明顯。在實際工程應用中,多是基於一種分詞算法, 然後用其他分詞算法加以輔助。最常用的方式就是先基於詞典的方式進行分詞,然後再用統計分詞方法進行輔助。如此,能在保證詞典分詞準確率的基礎上,對未登錄詞和歧義詞有較好的識別。

三、中文分詞工具

       隨着NLP技術的發展,開源實現的分詞工具越來越多,比如:Jieba、Ansj、盤古分詞等,除此之外,由北大發布的開源分詞工具PKUSeg效果也是特別的不錯。網上分詞工具的案例也特別多,就不過多細節介紹了。

四、參考鏈接

1、https://blog.csdn.net/qxdoit/article/details/83063794

2、https://blog.csdn.net/lalalawxt/article/details/75458791

3、https://blog.csdn.net/lilong117194/article/details/81113171

4、https://kexue.fm/archives/3922

5、https://blog.csdn.net/sinat_33741547/article/details/78870575

五、源碼獲取

文中涉及的源碼及文件獲取鏈接:https://github.com/jiajikang1993/NLP_related_algorithm_learned/tree/master/NLP%20related%20algorithm%20learning

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章