HanLP分詞研究

這篇文章主要是記錄HanLP標準分詞算法整個實現流程。html

  • HanLP的核心詞典訓練自人民日報2014語料,語料不是完美的,總會存在一些錯誤。這些錯誤可能會致使分詞出現奇怪的結果,這時請打開調試模式排查問題:
HanLP.Config.enableDebug();

那什麼是語料呢?通俗的理解,就是HanLP裏面的二個核心詞典。假設收集了人民日報若干篇文檔,經過人工手工分詞,統計人工分詞後的詞頻:①統計分詞後的每一個詞出現的頻率,獲得一元核心詞典;②統計兩個詞兩兩相鄰出現的頻率,獲得二元核心詞典。根據貝葉斯公式:
\[ P(A|B)=\frac{P(A,B)}{P(B)}\qquad=\frac{count(A,B)}{count(B)}\qquad \]
其中\(count(A,B)\)表示詞A和詞B 在語料庫中共同出現的頻率;\(count(B)\)表示詞B 在語料庫中出現的頻率。有了這兩個頻率,就能夠計算在給定詞B的條件下,下一個詞是 A的機率。java

  • 關於HanLP的核心詞典、二元文法詞典出現錯誤時如何處理可參考這個連接:
  • 關於分詞算法的平滑問題,可參考這個連接

分詞流程

採用維特比分詞器:基於動態規劃的維特比算法。node

List<Term> termList = HanLP.segment(sentence);

在進入正式分詞流程前,可選擇是否進行歸一化,而後進入到正式的分詞流程。git

if (HanLP.Config.Normalization)
        {
            CharTable.normalization(text);
        }
        return segSentence(text);

第一步,構建詞網WordNet,參考:詞圖的生成github

詞網包含起始頂點和結束頂點,以及待分詞的文本內容,文本內容保存在charArray數組中。vertexes表示詞網中結點的個數:vertexes = new LinkedList[charArray.length + 2],加2的緣由是:起始頂點和結束頂點。算法

再將每一個結點初始化,每一個結點由一個LinkedList存儲,值爲空數組

for (int i = 0; i < vertexes.length; ++i)
        {
            vertexes[i] = new LinkedList<Vertex>();//待分詞的句子的每一個字符 對應的 LinkedList
        }

最後將起始結點和結束結點初始化,LinkedList中添加進相應的頂點。安全

vertexes[0].add(Vertex.newB());//添加起始頂點
        vertexes[vertexes.length - 1].add(Vertex.newE());//添加結束頂點
        size = 2;//結點size

在添加起始頂點和結束頂點的時候,會從核心詞典構建出一棵雙數組樹。好比,建立一個起始結點:函數

public static Vertex newB()
    {
        return new Vertex(Predefine.TAG_BIGIN, " ", new CoreDictionary.Attribute(Nature.begin, Predefine.MAX_FREQUENCY / 10), CoreDictionary.getWordID(Predefine.TAG_BIGIN));
    }

每一個頂點Vertex包括以下屬性:ui

/**
     * 節點對應的詞或等效詞(如未##數)
     */
    public String word;
    /**
     * 節點對應的真實詞,絕對不含##
     */
    public String realWord;
    /**
     * 詞的屬性,謹慎修改屬性內部的數據,由於會影響到字典<br>
     * 若是要修改,應當new一個Attribute
     */
    public CoreDictionary.Attribute attribute;
    /**
     * 等效詞ID,也是Attribute的下標
     */
    public int wordID;//CoreDictionary.txt 每一個詞的編號,從下標0開始

    /**
     * 在一維頂點數組中的下標,能夠視做這個頂點的id
     */
    public int index;//Vertex中 頂點的編號

下面來一 一解釋Vertex類中各個屬性的意義:

  1. 什麼是等效詞呢?可參考:[Bigram分詞中的等效詞串]。在PreDefine.java中就定義一些等效詞串:
/**
     * 結束 end
     */
    public final static String TAG_END = "末##末";
    /**
     * 句子的開始 begin
     */
    public final static String TAG_BIGIN = "始##始";
    /**
     * 數詞 m
     */
    public final static String TAG_NUMBER = "未##數";

也即句子的開始用符號"始##始"來表示,結束用"末##末"表示。也即前面提到的起始頂點和結束頂點。

另外,在分詞過程當中,會產生一些數量詞,好比一人、兩人……而這些數量詞統一用"未##數"表示。爲何要這樣表示呢?因爲分詞是基於n-gram模型的(n=2),一人、兩人 這樣的詞統計出來的頻率不太靠譜,致使在二元核心詞典中找不到詞共現頻率,所以使用等效詞串來進行處理。

  1. 真實詞String realWord

    真實詞是待分詞的文本字符。好比:「商品和服務」,真實詞就是其中的每一個char,「真」、「品」、「和」……

  2. Attribute屬性

    記錄這個詞在一元核心詞典中的詞性、詞頻。因爲一個詞可能會有多個詞性和詞頻,所以詞性和詞頻都有一維數組來存儲。

  3. wordId

    該詞在一元核心詞典中的位置(行號)

  4. index

    這個詞在詞網(詞圖)中的頂點的編號

    構建雙數組樹過程

    若是有bin文件,則直接是以二進制流的形式構建了一顆雙數組樹,不然從CoreNatureDictionary.txt中讀取詞典構建雙數組中。關於雙數組樹的原理比較複雜,等之後完全弄清楚了再來解釋。

    基於核心一元詞典構建好了雙數組樹以後,就能夠用雙數組樹來查詢結點的wordId、詞頻、詞性……信息,

    return new Vertex(Predefine.TAG_BIGIN, " ", new CoreDictionary.Attribute(Nature.begin, Predefine.MAX_FREQUENCY / 10), CoreDictionary.getWordID(Predefine.TAG_BIGIN));

    CoreDictionary.getWordID(Predefine.TAG_BIGIN)//查詢獲得一元詞典中該結點所表明的字符對應的wordId

    至此,建立了一個Vertex對象。

    WordNet wordNetAll = new WordNet(sentence);只是(初始化了)生成了詞網中的頂點,爲各個頂點分配了存儲空間,並初始化了起始結點和結束結點。接下來,須要初始化待分詞的文本中的各個字符了。

    GenerateWordNet(wordNetAll);生成完整的詞網。

    生成完整詞網

    生成完整詞網的流程是:根據待分詞的文本 使用雙數組樹 對一元核心詞典進行 最大匹配查詢,將命中的詞建立一個Vertex對象,而後添加到詞網中。

    while (searcher.next())
            {
               wordNetStorage.add(searcher.begin + 1, new Vertex(new String(charArray, searcher.begin, searcher.length), searcher.value, searcher.index));
            }

    具體舉例來講:假設待分詞的文本是"商品和服務",首先將該文本分解成單個的字符。

而後,對每個單個的字符,在雙數組樹(基於一元核心詞典構建的)中進行最大匹配查找:

對於 '商' 而言,最大匹配查找獲得:'商'--->‘商品’

對於'品'而言,最大匹配查找獲得:'品'

對於'和’而言,最大匹配查找獲得:'和'--->'和服'

對於'服'而言,最大匹配查找獲得:'服'--->'服務'

對於'務'而言,最大匹配查找獲得:'務'

下面來具體分析 '商' 這個頂點是如何構建的:

因爲'商'這個字存在於一元核心詞典中

searcher.next()確定會命中了'商' ,因而查找到了雙數組樹中'商'這個頂點的全部信息:

wordID:32769

詞性vg,對應的詞頻是607;詞性v對應的詞頻是198

所以,將雙數組樹中的頂點信息提取出來,用來構建 '商' 這個Vertex對象,並將之加入到LinkedList中

public Vertex(String word, String realWord, CoreDictionary.Attribute attribute, int wordID)
    {
        if (attribute == null) attribute = new CoreDictionary.Attribute(Nature.n, 1);   //attribute爲null,就賦一個默認值 安全起見
        this.wordID = wordID;
        this.attribute = attribute;

        //初始時,全部詞的等效詞串word=null,當碰到數詞/地名...時, 會爲這些詞生成等效詞串.
        if (word == null) word = compileRealWord(realWord, attribute);//1人 或者 2人 這樣的詞,轉化爲:未##數@人
        assert realWord.length() > 0 : "構造空白節點會致使死循環!";
        this.word = word;
        this.realWord = realWord;
    }

compileRealWord()函數的做用是:當碰到數量詞……生成等效詞串

同理,下一步從雙數組樹中找到詞是'商品',相似地,構建'商品'這個Vertex對象,並將之添加到LinkedList下一個元素。

關於詞圖的生成,可參考:詞圖的生成。詞圖中創建各個節點之間的聯繫,是經過文章中提到的快速offset法實現的。其實就是經過快速offset法來尋找某個節點的下一個節點。由於後面會使用基於動態規劃的維特比算法來求解詞圖的最短路徑,而求解最短路徑就須要根據某個節點快速定位該節點的後繼節點。

下面,以 商品和服務爲例,詳細解釋一下快速offset法:

0 始##始
1 商 商品
2 品
3 和 和服
4 服 服務
5 務
6 末##末

上表可用一個LinkedList數組存儲。每一行表明一個LinkedList,存儲該字符最大匹配到的全部結果。這與圖:詞圖的連接表示 是一致的。使用這種方式存儲詞圖,不只簡單並且也易於查找下一個詞。

  1. 從詞網轉化成詞圖

    而後,接下來是:原子分詞,保證圖連通。這一部分,不是太理解

    // 原子分詞,保證圖連通
            LinkedList<Vertex>[] vertexes = wordNetStorage.getVertexes();
            for (int i = 1; i < vertexes.length; )
            {
                if (vertexes[i].isEmpty())
                {
                    int j = i + 1;
                    for (; j < vertexes.length - 1; ++j)
                    {
                        if (!vertexes[j].isEmpty()) break;
                    }
                    wordNetStorage.add(i, quickAtomSegment(charArray, i - 1, j - 1));
                    i = j;
                }
                else i += vertexes[i].getLast().realWord.length();
            }

    至此,獲得一個粗分詞網,以下:

    構建好了詞圖以後,有了詞圖中各條邊以及邊上的權值。接下來,來到了基於動態規劃的維特比分詞,使用維特比算法來求解最短路徑。

    動態規劃之維特比分詞算法

    List<Vertex> vertexList = viterbi(wordNetAll);

    計算頂點之間的邊的權值

    先把詞圖畫出來,以下:

    nodes數組就是用來存儲詞圖的鏈表數組:

    計算起始頂點的權值

    起始頂點"始##始"到第一層頂點"商"和"商品"的權值:

    //始##始 到 第一層頂點之間的 邊以及權值 構建
            for (Vertex node : nodes[1])
            {
                node.updateFrom(nodes[0].getFirst());
            }

    須要注意的是,每一個頂點Vertex的weight屬性,保存的是從起始頂點到該頂點的最短路徑。

    public void updateFrom(Vertex from)
        {
            double weight = from.weight + MathTools.calculateWeight(from, this);
            if (this.from == null || this.weight > weight)//this.weight>weight 代表尋找到了一條比原路徑更短的路徑
            {
                this.from = from;//記錄 更短路徑上 的前驅頂點,用來回溯獲得最短路徑上的節點
                this.weight = weight;//記錄 更短路徑 的權值
            }
        }

    updateFrom()方法實現了動態規劃自底向上計算最短路徑。當計算出的weight比當前頂點的路徑this.weight還要短時,就意味着找到一條更短的路徑。

    路徑上的權值的計算

    MathTools.calculateWeight()方法計算權值。這個權值是如何得出的呢?這就涉及到核心二元詞典CoreNatureDictionary.ngram.txt了。

    int nTwoWordsFreq = CoreBiGramTableDictionary.getBiFrequency(from.wordID, to.wordID);

    會開始加載核心二元詞典CoreNatureDictionary.ngram.txt到內存中,而後查找這兩個詞:from@to 的詞共現頻率。顯然,這裏的詞典採用了延遲加載的模式,也即當須要查詢詞共現頻率的時候,纔會去加載核心二元詞典,從詞典中找到對應的頻率。好比 始##始@商 即表示:語料中以第一個字'商'開頭的頻率是46

有了從一元核心詞典中查詢到的單個詞的詞頻,以及兩個詞之間的詞共現頻率,就能夠計算「機率」了。(背後的思想是貝葉斯機率,而且須要進行平滑)。關於平滑,可參考這個issue

double value = -Math.log(dSmoothingPara * frequency / (MAX_FREQUENCY) + (1 - dSmoothingPara) * ((1 - dTemp) * nTwoWordsFreq / frequency + dTemp));
計算其餘頂點的權值
for (int i = 1; i < nodes.length - 1; ++i)
        {
            LinkedList<Vertex> nodeArray = nodes[i];
            if (nodeArray == null) continue;
            for (Vertex node : nodeArray)//當前節點爲 node
            {
                if (node.from == null) continue;
                for (Vertex to : nodes[i + node.realWord.length()])//獲取當前節點 node的下一個節點 to
                {
                    to.updateFrom(node);
                }
            }
        }

updateFrom()方法,經過比較節點的權重(權重表明着機率),更新最優路徑上的節點(DP求解最優路徑)

public void updateFrom(Vertex from)
    {
        double weight = from.weight + MathTools.calculateWeight(from, this);
        if (this.from == null || this.weight > weight)//DP求解 2-gram 機率最大的路徑
        {
            this.from = from;
            this.weight = weight;
        }
    }

最終生成的詞圖以下:

以"商品"節點爲例,它的下一個節點有兩個:"和服" 、 "和"

0 始##始
1 商 商品
2 品
3 和 和服
4 服 服務
5 務
6 末##末

"商品"的行號爲1,長度爲2,那麼它的下一個節點存儲在行號爲 1+2 =3 的鏈表中。

同理,"品"的行號爲2,長度爲1,那麼它的下一個節點存儲在行號爲2+1=3的鏈表中。

從上面的詞圖中可驗證:節點"商品"的下一個節點是"和" 、"和服",正確無誤。

最終,計算出起始頂點到詞圖中各個頂點的最短路徑的權值。而後從結束頂點開始,回溯,找到最短路徑上的各個結點。

Vertex from = nodes[nodes.length - 1].getFirst();//最後一個頂點的from屬性記錄着到達該頂點的前綴頂點
        while (from != null)
        {
            vertexList.addFirst(from);
            from = from.from;//weight屬性是最短路徑的權值,因此直接回溯就能獲得最短路徑上的各個結點
        }
        return vertexList;

如今已經經過維特比算法求得最短路徑上的結點,可以使用用戶自定義的詞典合併結果。這裏的使用用戶自定義詞典合併結果的原理,有待進一步研究。(可參考:)

if (config.useCustomDictionary)
        {
            if (config.indexMode > 0)
                combineByCustomDictionary(vertexList, wordNetAll);
            else combineByCustomDictionary(vertexList);
        }

至此,粗分結果完畢。粗分結果以下:

粗分結果[商品/n, 和/cc, 服務/vn]

粗分完成以後,根據Config中的相應配置:是否開啓數字識別、NER命名識別、將最終的最短路徑上的分詞結果輸出。

return convert(vertexList, config.offset);

從而,最終的分詞結果以下:

[商品/n, 和/cc, 服務/vn]

原文:https://www.cnblogs.com/hapjin/p/11172299.html

相關文章
相關標籤/搜索