1. 程式人生 > >系統學習機器學習之隨機場(四)--CRF++原始碼分析

系統學習機器學習之隨機場(四)--CRF++原始碼分析

1. 簡述

    最近要應用CRF模型,進行序列識別。選用了CRF++工具包,具體來說是在VS2008的C#環境下,使用CRF++的windows版本。本文總結一下了解到的和CRF++工具包相關的資訊。

    參考資料是CRF++的官方網站:CRF++: Yet Another CRF toolkit,網上的很多關於CRF++的博文就是這篇文章的全部或者部分的翻譯,本文也翻譯了一些。

2. 工具包下載

    第一,版本選擇,當前最新版本是2010-05-16日更新的CRF++ 0.54版本,不過這個版本以前我用過一次好像執行的時候存在一些問題,網上一些人也說有問題,所以這裡用的是2009-05-06: CRF++ 0.53版本。關於執行出錯的資訊有

http://ir.hit.edu.cn/bbs/viewthread.php?action=printable&tid=7945為證。

    第二,檔案下載,這個主頁上面只有最新的0.54版本的檔案,網上可以搜尋,不過不是資源不是很多,我在CSDN上面下載了一個CRF++0.53版本的,包含linux和windows版本,其要花掉10個積分。因為,我沒有找到比較穩定、長期、免費的連結,這裡上傳一份這個檔案:CRF++ 0.53 Linux和Windows版本

補充:

目前見到的版本,大概是CRF++ 0.58.

3. 工具包檔案

   

    doc資料夾:就是官方主頁的內容。
    example資料夾:有四個任務的訓練資料、測試資料和模板檔案。
    sdk資料夾:CRF++的標頭檔案和靜態連結庫。
    crf_learn.exe:CRF++的訓練程式。
    crf_test.exe:CRF++的預測程式
    libcrfpp.dll:訓練程式和預測程式需要使用的靜態連結庫。

    實際上,需要使用的就是crf_learn.exe,crf_test.exe和libcrfpp.dll,這三個檔案。

4. 命令列格式

4.1 訓練程式

    命令列:
    % crf_learn template_file train_file model_file
   
這個訓練過程的時間、迭代次數等資訊會輸出到控制檯上(感覺上是crf_learn程式的輸出資訊到標準輸出流上了),如果想儲存這些資訊,我們可以將這些標準輸出流到檔案上,命令格式如下:
    % crf_learn template_file train_file model_file >> train_info_file

    有四個主要的引數可以調整:
    -a CRF-L2 or CRF-L1     
    規範化演算法選擇。預設是CRF-L2。一般來說L2演算法效果要比L1演算法稍微好一點,雖然L1演算法中非零特徵的數值要比L2中大幅度的小。
    -c float
    這個引數設定CRF的hyper-parameter。c的數值越大,CRF擬合訓練資料的程度越高。這個引數可以調整過度擬合和不擬合之間的平衡度。這個引數可以通過交叉驗證等方法尋找較優的引數。
    -f NUM
    這個引數設定特徵的cut-off threshold。CRF++使用訓練資料中至少NUM次出現的特徵。預設值為1。當使用CRF++到大規模資料時,只出現一次的特徵可能會有幾百萬,這個選項就會在這樣的情況下起到作用。
    -p NUM
    如果電腦有多個CPU,那麼那麼可以通過多執行緒提升訓練速度。NUM是執行緒數量。

    帶兩個引數的命令列例子:
    % crf_learn -f  3 -c 1.5 template_file train_file model_file

4.2 測試程式 

     命令列:
     % crf_test -m model_file test_files
     有兩個引數-v和-n都是顯示一些資訊的,-v可以顯示預測標籤的概率值,-n可以顯示不同可能序列的概率值,對於準確率,召回率,執行效率,沒有影響,這裡不說明了。
      與crf_learn類似,輸出的結果放到了標準輸出流上,而這個輸出結果是最重要的預測結果資訊(測試檔案的內容+預測標註),同樣可以使用重定向,將結果儲存下來,命令列如下。
      % crf_test -m model_file test_files >> result_file

5. 檔案格式

5.1 訓練檔案

    下面是一個訓練檔案的例子:

    
    訓練檔案由若干個句子組成(可以理解為若干個訓練樣例),不同句子之間通過換行符分隔,上圖中顯示出的有兩個句子。每個句子可以有若干組標籤,最後一組標籤是標註,上圖中有三列,即第一列和第二列都是已知的資料,第三列是要預測的標註,以上面例子為例是,根據第一列的詞語和和第二列的詞性,預測第三列的標註。

   當然這裡有涉及到標註的問題,這個就是很多paper要研究的了,比如命名實體識別就有很多不同的標註集。這個超出本文範圍。

5.2 測試檔案

    測試檔案與訓練檔案格式自然是一樣的,用過機器學習工具包的這個一般都理解吧。

    與SVM不同,CRF++沒有單獨的結果檔案,預測結果通過標準輸出流輸出了,因此前面4.2節的命令列中,將結果重定向到檔案中了。結果檔案比測試檔案多了一列,即為預測的標籤,我們可以計算最後兩列,一列的標註的標籤,一列的預測的標籤,來得到標籤預測的準確率。

5.3 模板檔案

5.3.1 模板基礎

    模板檔案中的每一行是一個模板。每個模板都是由%x[row,col]來指定輸入資料中的一個token。row指定到當前token的行偏移,col指定列位置。
    
    由上圖可見,當前token是the這個單詞。%x[-2,1]就就是the的前兩行,1號列的元素(注意,列是從0號列開始的),即為PRP。

5.3.2 模板型別

    有兩種型別的模板,模板型別通過第一個字元指定。

    Unigram template: first character, 'U'
   
當給出一個"U01:%x[0,1]"的模板時,CRF++會產生如下的一些特徵函式集合(func1 ... funcN) 。
    
    這幾個函式我說明一下,%x[0,1]這個特徵到前面的例子就是說,根據詞語(第1列)的詞性(第2列)來預測其標註(第3列),這些函式就是反應了訓練樣例的情況,func1反映了“訓練樣例中,詞性是DT且標註是B-NP的情況”,func2反映了“訓練樣例中,詞性是DT且標註是I-NP的情況”。
    模板函式的數量是L*N,其中L是標註集中類別數量,N是從模板中擴充套件處理的字串種類。

    Bigram template: first character, 'B'
   
這個模板用來描述二元特徵。這個模板會自動產生當前output token和前一個output token的合併。注意,這種型別的模板會產生L * L * N種不同的特徵。

    Unigram feature 和 Bigram feature有什麼區別呢? 
    unigram/bigram很容易混淆,因為通過unigram-features也可以寫出類似%x[-1,0]%x[0,0]這樣的單詞級別的bigram(二元特徵)。而這裡的unigram和bigram features指定是uni/bigrams的輸出標籤。
    unigram: |output tag| x |all possible strings expanded with a macro|
    bigram: |output tag| x |output tag| x |all possible strings expanded with a macro|
    這裡的一元/二元指的就是輸出標籤的情況,這個具體的例子我還沒看到,example資料夾中四個例子,也都是隻用了Unigram,沒有用Bigarm,因此感覺一般Unigram feature就夠了。

5.3.3 模板例子

    這是CoNLL 2000的Base-NP chunking任務的模板例子。只使用了一個bigram template ('B')。這意味著只有前一個output token和當前token被當作bigram features。“#”開始的行是註釋,空行沒有意義。

   
6. 樣例資料

    example資料夾中有四個任務,basenp,chunking,JapaneseNE,seg。前兩個是英文資料,後兩個是日文資料。第一個應該是命名實體識別,第二個應該是分詞,第三個應該是日文命名實體識別,第四個不清楚。這裡主要跑了一下前兩個任務,後兩個是日文的搞不懂。

    根據任務下面的linux的腳步檔案,我寫了個簡單的windows批處理(其中用重定向儲存了資訊),比如命名為exec.bat,跑了一下。批處理檔案放在要跑的任務的路徑下就行,批處理檔案內容如下:
    ..\..\crf_learn -c 10.0 template train.data model >> train-info.txt
    ..\..\crf_test   -m model test.data >> test-info.txt

    這裡簡單解釋一下批處理,批處理檔案執行後的當前目錄就是該批處理檔案所在的目錄(至少我的是這樣,如果不是,可以使用cd %~dp0這句命令,~dp0表示了“當前碟符和路徑”),crf_learn和crf_test程式在當前目錄的前兩級目錄上,所以用了..\..\。

下面這個轉自:http://www.hankcs.com/ml/crf-code-analysis.html,(我做了部分修改)

本文按照呼叫順序抽絲剝繭地分析了CRF++的程式碼,詳細註釋了主要函式,並指出了程式碼與理論公式的對應關係。內容包括擬牛頓法的目標函式、梯度、L2正則化、L-BFGS優化、概率圖構建、前向後向演算法、維特比演算法等。

訓練

先從訓練開始說起吧

  1. /**
  2.  * 命令列式訓練
  3.  * @param argc 命令個數
  4.  * @param argv 命令陣列
  5.  * @return 0表示正常執行,其他表示錯誤
  6.  */
  7. int crfpp_learn(int argc, char **argv)

該函式解析命令列之後呼叫:

  1. /**
  2.  * 訓練CRF模型
  3.  * @param param 引數
  4.  * @return
  5.  */
  6. int crfpp_learn(const Param &param)

該函式會呼叫:

  1.   /**
  2.    * 訓練
  3.    * @param templfile 模板檔案
  4.    * @param trainfile 訓練檔案
  5.    * @param modelfile 模型檔案
  6.    * @param textmodelfile 是否輸出文字形式的模型檔案
  7.    * @param maxitr 最大迭代次數
  8.    * @param freq 特徵最低頻次,也就是說,在某特徵出現的次數超過該值,才進入模型,預設為1,即只要出現就進入模型。
  9.    * @param eta 收斂閾值
  10.    * @param C cost-factor 實際定義的是權重共享係數
  11.    * @param thread_num 執行緒數
  12.    * @param shrinking_size 該引數在CRF演算法中沒用,在MIRA演算法中使用,也就是與CRF模型無關,可以不考慮。
  13.    * @param algorithm 訓練演算法
  14.    * @return
  15.    */
  16. bool learn(const char *templfile,
  17.            const char *trainfile,
  18.            const char *modelfile,
  19.            bool textmodelfile,
  20.            size_t maxitr,
  21.            size_t freq,
  22.            double eta,
  23.            double C,
  24.            unsigned short thread_num,
  25.            unsigned short shrinking_size,
  26.            int algorithm);

該函式先讀取特徵模板和訓練檔案

  1. /**
  2.  * 開啟配置檔案和訓練檔案
  3.  * @param template_filename
  4.  * @param train_filename
  5.  * @return
  6.  */
  7. bool open(const char *template_filename, const char *train_filename);

這個open方法並沒有構建訓練例項,而是簡單地解析特徵模板和統計標註集:

  1. /**
  2.  * 讀取特徵模板檔案
  3.  * @param filename
  4.  * @return
  5.  */
  6. bool openTemplate(const char *filename);
  7. /**
  8.  * 讀取訓練檔案中的標註集
  9.  * @param filename
  10.  * @return
  11.  */
  12. bool openTagSet(const char *filename);

這裡補充一下:

每個句子表示一個樣例,每個樣例中的單詞+標註,作為一個token。這裡在open結束後,系統開始針對每個樣例中的每個token,對映特徵函式。比如例子中,有一個特徵模板有19個特徵函式,那麼任何一個樣子(句子)中的任何一個單詞+標註就有19個特徵。所有這些特徵全部綜合儲存在feature_cache中,你可以把這個cache裡理解為一個二維快取,其中,水平方向為樣本類別,也就是Y集,例如該例子中Y有14個元素,也就是水平方向寬度為14,則ID每次針對一元模板增加14,也就是在相同水平位置對應的下一個垂直位置放置。最後整個cache就是一個水平為Y集,垂直為tokent特徵集X的快取。而該快取的元素值對應特徵出現的次數(因為要對所有樣例的所有token的所有特徵函式統計)。需要指出的是,特徵函式和ID之間的關係是預先確定好的,例如特徵函式作用後結果為U00:B-2,則直接找到相應編碼表中的B-2對應的index即可,這個表我也不是很清楚,因為是分詞標註領域的一個標準。

TaggerImpl儲存訓練樣例,x_儲存相應的output序列,result_儲存相應的狀態序列,answer_儲存模型算出來的狀態序列;為了實現多執行緒併發處理,另外儲存了處理該TaggerImpl的執行緒thread_id_;output序列中的每一個token都對應一個feature集合,整個output序列對應了feature集合的序列,系統將所有訓練樣例的feature集合順序儲存在一個feature_cache中,因此在每一個TaggerImpl中儲存了自己的feature序列在feature_cache中偏移量feature_id_,而這個feature_cache存在於FeatureIndex物件中。系統中所有的TaggerImpl都共享一個FeatureIndex物件;為了DP程式設計的方便,又包含一個Node二維陣列,橫軸對應output中的每一個token,縱軸代表系統狀態集合中的每一個狀態。Node儲存DP中的每一個狀態,包括alpha,beta,verterbi路徑前驅等。

回到learn方法中來,做完了這些諸如IO和引數解析之後,learn方法會根據演算法引數的不同而呼叫不同的訓練演算法。取最常用的說明如下:

  1. /**
  2.  * CRF訓練
  3.  * @param x 句子列表
  4.  * @param feature_index 特徵編號表
  5.  * @param alpha 特徵函式的代價
  6.  * @param maxitr 最大迭代次數
  7.  * @param C cost factor
  8.  * @param eta 收斂閾值
  9.  * @param shrinking_size 未使用
  10.  * @param thread_num 執行緒數
  11.  * @param orthant 是否使用L1範數
  12.  * @return 是否成功
  13.  */
  14. bool runCRF(const std::vector<TaggerImpl *> &x, EncoderFeatureIndex *feature_index, double *alpha, size_t maxitr,
  15.             float C, double eta, unsigned short shrinking_size, unsigned short thread_num, bool orthant)

計算梯度,

補充:

需要注意的是,CRF++實現的是線性鏈式CRF。主要區別在於勢函式的計算不同,其他相同。計算梯度主要的方式是,經驗分佈的數學期望與模型的條件概率的數學期望的差,再加上正則項,經驗分佈的數學期望為訓練資料集中隨機變數 (x,y)滿足特徵約束的個數,模型的條件概率的數學期望的計算實質上是計算條件概率p(y|x,alpha)。因此,演算法主要就是計算條件概率。

建立多個CRFEncoderThread,平均地將句子分給每個執行緒。每個執行緒的工作其實只是計算梯度:

  1. /**
  2.  * 計算梯度
  3.  * @param expected 梯度向量
  4.  * @return 損失函式的值
  5.  */
  6. double TaggerImpl::gradient(double *expected)

梯度計算時,先構建網格:

  1. void TaggerImpl::buildLattice()

由於CRF是概率圖模型,所以有一些圖的特有概念,如頂點和邊:

  1. /**
  2.  * 圖模型中的節點
  3.  */
  4. struct Node
  5. /**
  6.  * 邊
  7.  */
  8. struct Path

buildLattice方法呼叫rebuildFeatures對每個時刻的每個狀態分別構造邊和頂點,實際上是條件概率的矩陣計算:

  1. for (size_t cur = 0; cur < tagger->size(); ++cur)
  2. {
  3.     const int *f = (*feature_cache)[fid++];
  4.     for (size_t i = 0; i < y_.size(); ++i)
  5.     {
  6.         Node *n = allocator->newNode(thread_id);
  7.         n->clear();
  8.         n->x = cur;
  9.         n->y = i;
  10.         n->fvector = f;
  11.         tagger->set_node(n, cur, i);
  12.     }
  13. }
  14. for (size_t cur = 1; cur < tagger->size(); ++cur)
  15. {
  16.     const int *f = (*feature_cache)[fid++];
  17.     for (size_t j = 0; j < y_.size(); ++j)
  18.     {
  19.         for (size_t i = 0; i < y_.size(); ++i)
  20.         {
  21.             Path *p = allocator->newPath(thread_id);
  22.             p->clear();
  23.             p->add(tagger->node(cur - 1, j), tagger->node(cur, i));
  24.             p->fvector = f;
  25.         }
  26.     }
  27. }

這也就是大家經常看到的類似如下的圖:

補充下:

 這裡採用矩陣方式計算條件概率,對於一階線性鏈式CRF,在圖模型中增加起始Y0,和結束Yn+1,Yi-1 為Y',Yi為y,定義一組矩陣{Mi(x)|i = 1, 2, ......n+1},其中每個Mi(x)是一個y*y階隨機變數矩陣,矩陣中每個元素為:

圖示如下:

則條件概率為,其中歸一化:

然後計算每個節點和每條邊的代價(也就是特徵函式乘以相應的權值,簡稱代價):

  1. /**
  2.  * 計算狀態特徵函式的代價
  3.  * @param node 頂點
  4.  */
  5.     void FeatureIndex::calcCost(Node *n) const
  6.     {
  7.         n->cost = 0.0;
  8. #define ADD_COST(T, A)                                                  \
  9.   do { T c = 0;                                                               \
  10.     for (const int *f = n->fvector; *f != -1; ++f) { c += (A)[*f + n->y];  }  \
  11.     n->cost =cost_factor_ *(T)c; } while (0)
  12.         if (alpha_float_)
  13.         {
  14.             ADD_COST(float, alpha_float_);
  15.         }
  16.         else
  17.         {
  18.             ADD_COST(double, alpha_);
  19.         }
  20. #undef ADD_COST
  21.     }
  22. /**
  23.  * 計算轉移特徵函式的代價
  24.  * @param path 邊
  25.  */
  26.     void FeatureIndex::calcCost(Path *p) const
  27.     {
  28.         p->cost = 0.0;
  29. #define ADD_COST(T, A)                                          \
  30.   { T c = 0.0;                                                  \
  31.     for (const int *f = p->fvector; *f != -1; ++f) {            \
  32.       c += (A)[*f + p->lnode->y * y_.size() + p->rnode->y];     \
  33.     }                                                           \
  34.     p->cost =cost_factor_*(T)c; }
  35.         if (alpha_float_)
  36.         {
  37.             ADD_COST(float, alpha_float_);
  38.         }
  39.         else
  40.         {
  41.             ADD_COST(double, alpha_);
  42.         }
  43.     }

其中fvector是當前命中特徵函式的起始id集合,對於每個起始id,都有連續標籤個數種y值;n->y是當前時刻的標籤,由於每個特徵函式都必須同時接受x和y才能決定輸出1或0,所以要把兩者加起來才能確定最終特徵函式的id。用此id就能在alpha向量中取到最終的權值,將權值累加起來,乘以一個倍率(也就是所謂的代價引數cost_factor),得到最終的代價cost。

對於邊來說,也是類似的,只不過對每個起始id,都有連續標籤個數平方種y值組合。

這部分對應

需要強調的是:演算法內部對於exp沒有計算,實際上,所有關於exp{F}的計算,都是隻計算F,而在實際使用中,exp{F}參與計算時,直接採用log形式。例如下面的前向演算法中,要計算兩個expX1,expX2的乘積,則直接用log(X1+X2)表示,程式碼直接計算X1+X2

前向後向演算法

網格建完了,就可以在這個圖上面跑前向後向演算法了:

  1. /**
  2.  * 前向後向演算法
  3.  */
  4. void forwardbackward();

該方法依次計算前後向概率:

  1. for (int i = 0; i < static_cast<int>(x_.size()); ++i)
  2. {
  3.     for (size_t j = 0; j < ysize_; ++j)
  4.     {
  5.         node_[i][j]->calcAlpha();
  6.     }
  7. }
  8. for (int i = static_cast<int>(x_.size() - 1); i >= 0; --i)
  9. {
  10.     for (size_t j = 0; j < ysize_; ++j)
  11.     {
  12.         node_[i][j]->calcBeta();
  13.     }
  14. }

計算前向概率的具體實現是:

  1. void Node::calcAlpha()
  2. {
  3.     alpha = 0.0;
  4.     for (const_Path_iterator it = lpath.begin(); it != lpath.end(); ++it)
  5.     {
  6.         alpha = logsumexp(alpha, (*it)->cost + (*it)->lnode->alpha, (it == lpath.begin()));
  7.     }
  8.     alpha += cost;
  9. }

其中cost是我們剛剛計算的當前節點的M_i(x),而alpha則是當前節點的前向概率。lpath是入邊,如程式碼和圖片所示,一個頂點可能有多個入邊。

對應:

後向概率同理略過。

前後向概率都有了之後,計算規範化因子:

  1. Z_ = 0.0;
  2. for (size_t j = 0; j < ysize_; ++j)
  3. {
  4.     Z_ = logsumexp(Z_, node_[0][j]->beta, j == 0);
  5. }

對應著

關於函式logsumexp的意義,請參考《計算指數函式的和的對數》

於是完成整個前後向概率的計算。

期望值的計算

節點期望值

所謂的節點期望值指的是節點對應的狀態特徵函式關於條件分佈p(Y|X)的數學期望。

  1. for (size_t i = 0; i < x_.size(); ++i)
  2. {
  3.     for (size_t j = 0; j < ysize_; ++j)
  4.     {
  5.         node_[i][j]->calcExpectation(expected, Z_, ysize_);
  6.     }
  7. }

calcExpectation具體實現是:

  1. /**
  2.  * 計算節點期望
  3.  * @param expected 輸出期望
  4.  * @param Z 規範化因子
  5.  * @param size 標籤個數
  6.  */
  7. void Node::calcExpectation(double *expected, double Z, size_t size) const
  8. {
  9.     const double c = std::exp(alpha + beta - cost - Z);
  10.     for (const int *f = fvector; *f != -1; ++f)
  11.     {
  12.         expected[*f + y] += c;
  13.     }
  14.     for (const_Path_iterator it = lpath.begin(); it != lpath.end(); ++it)
  15.     {
  16.         (*it)->calcExpectation(expected, Z, size);
  17.     }
  18. }

第一個for對應下式的求和

概率求和意味著得到期望。

第二個for對應邊的期望值。

邊的期望值

所謂邊的期望指的是邊對應的轉移特徵函式關於條件分佈p(Y|X)的數學期望。

  1. /**
  2.  * 計算邊的期望
  3.  * @param expected 輸出期望
  4.  * @param Z 規範化因子
  5.  * @param size 標籤個數
  6.  */
  7. void Path::calcExpectation(double *expected, double Z, size_t size) const
  8. {
  9.     const double c = std::exp(lnode->alpha + cost + rnode->beta - Z);
  10.     for (const int *f = fvector; *f != -1; ++f)
  11.     {
  12.         expected[*f + lnode->y * size + rnode->y] += c;
  13.     }
  14. }

對應下式的求和

這樣就得到了條件分佈的數學期望:

梯度計算

  1. for (size_t i = 0; i < x_.size(); ++i)
  2. {
  3.     for (const int *f = node_[i][answer_[i]]->fvector; *f != -1; ++f)
  4.     {
  5.         --expected[*f + answer_[i]];
  6.     }
  7.     s += node_[i][answer_[i]]->cost;  // UNIGRAM cost
  8.     const std::vector<Path *> &lpath = node_[i][answer_[i]]->lpath;
  9.     for (const_Path_iterator it = lpath.begin(); it != lpath.end(); ++it)
  10.     {
  11.         if ((*it)->lnode->y == answer_[(*it)->lnode->x])
  12.         {
  13.             for (const int *f = (*it)->fvector; *f != -1; ++f)
  14.             {
  15.                 --expected[*f + (*it)->lnode->y * ysize_ + (*it)->rnode->y];
  16.             }
  17.             s += (*it)->cost;  // BIGRAM COST
  18.             break;
  19.         }
  20.     }
  21. }

–expected表示模型期望(條件分佈)減去觀測期望,得到目標函式的梯度:

有人可能要問了,expected的確存的是條件分佈的期望,但觀測期望還沒計算呢,把條件分佈的期望減一是幹什麼?

這是因為對觀測資料(訓練資料)來講,它一定是對的,也就是在y!=answer_[i]的時候概率為0,在y=answer_[i]的時候概率為1,乘以特徵函式的輸出1,就等於1,這就是觀測期望。也就是上面說的,訓練資料中(x,y)出現的次數。

維特比演算法

緊接著gradient函式還順便調了一下TaggerImpl::viterbi:

  1. void TaggerImpl::viterbi()
  2. {
  3.     for (size_t i = 0; i < x_.size(); ++i)
  4.     {
  5.         for (size_t j = 0; j < ysize_; ++j)
  6.         {
  7.             double bestc = -1e37;
  8.             Node *best = 0;
  9.             const std::vector<Path *> &lpath = node_[i][j]->lpath;
  10.             for (const_Path_iterator it = lpath.begin(); it != lpath.end(); ++it)
  11.             {
  12.                 double cost = (*it)->lnode->bestCost + (*it)->cost + node_[i][j]->cost;
  13.                 if (cost > bestc)
  14.                 {
  15.                     bestc = cost;
  16.                     best = (*it)->lnode;
  17.                 }
  18.             }
  19.             node_[i][j]->prev = best;
  20.             node_[i][j]->bestCost = best ? bestc : node_[i][j]->cost;
  21.         }
  22.     }
  23.     double bestc = -1e37;
  24.     Node *best = 0;
  25.     size_t s = x_.size() - 1;
  26.     for (size_t j = 0; j < ysize_; ++j)
  27.     {
  28.         if (bestc < node_[s][j]->bestCost)
  29.         {
  30.             best = node_[s][j];
  31.             bestc = node_[s][j]->bestCost;
  32.         }
  33.     }
  34.     for (Node *n = best; n; n = n->prev)
  35.     {
  36.         result_[n->x] = n->y;
  37.     }
  38.     cost_ = -node_[x_.size() - 1][result_[x_.size() - 1]]->bestCost;
  39. }

其中prev構成一個前驅陣列,在動態規劃結束後通過prev回溯最優路徑的標籤y,存放於result陣列中。

跑viterbi演算法的目的是為了評估當前模型的準確度,以輔助決定是否終止訓練。關於Viterbi演算法,可以參考:系統學習機器學習之馬爾科夫假設(一)--HMM

正則化

為了防止過擬合,CRF++採用了L1或L2正則化:

  1. if (orthant)
  2. {   // L1
  3.     for (size_t k = 0; k < feature_index->size(); ++k)
  4.     {
  5.         thread[0].obj += std::abs(alpha[k] / C);
  6.         if (alpha[k] != 0.0)
  7.         {
  8.             ++num_nonzero;
  9.         }
  10.     }
  11. }
  12. else
  13. {
  14.     num_nonzero = feature_index->size();
  15.     for (size_t k = 0; k < feature_index->size(); ++k)
  16.     {
  17.         thread[0].obj += (alpha[k] * alpha[k] / (2.0 * C));
  18.         thread[0].expected[k] += alpha[k] / C;
  19.     }
  20. }

以L2正則為例,L2正則在目標函式上加了一個正則項:

+

其中,σ是一個常數,在CRF++中其平方被稱作cost-factor,1/2*σ^2控制著懲罰因子的強度。可見要最小化目標函式,正則化項也必須儘量小才行。模型引數的平方和小,其複雜度就低,於是就不容易過擬合。關於L1、L2正則化推薦看Andrew Ng的ML公開課

目標函式加了正則項之後,梯度順理成章地也應加上正則項的導數:

+Wi/σ^2

這也就是程式碼中為什麼要自加這兩項的原因了:

  1.         thread[0].obj += (alpha[k] * alpha[k] / (2.0 * C));
  2.         thread[0].expected[k] += alpha[k] / C;

L-BFGS優化

梯度和損失函式有了,之後就是通用的凸函式LBFGS優化了。CRF++直接將這些引數送入一個LBFGS模組中:

  1. if (lbfgs.optimize(feature_index->size(), &alpha[0], thread[0].obj, &thread[0].expected[0], orthant, C) <=
  2.     0)
  3. {
  4.     return false;
  5. }

據說這個模組是用一個叫f2c的工具從FORTRAN程式碼轉成的C程式碼,可讀性並不好,也就不再深入了。

  1. //   lbfgs.c was ported from the FORTRAN code of lbfgs.m to C
  2. //   using f2c converter
  3. //
  4. //   http://www.ece.northwestern.edu/~nocedal/lbfgs.html

預測

預測就簡單多了,主要對應下列方法:

  1. bool TaggerImpl::parse()
  2. {
  3.     CHECK_FALSE(feature_index_->buildFeatures(this)) << feature_index_->what();
  4.     if (x_.empty())
  5.     {
  6.         return true;
  7.     }
  8.     buildLattice();
  9.     if (nbest_ || vlevel_ >= 1)
  10.     {
  11.         forwardbackward();
  12.     }
  13.     viterbi();
  14.     if (nbest_)
  15.     {
  16.         initNbest();
  17.     }
  18.     return true;
  19. }

主要的方法也就是建立網格和維特比這兩個,由於前面訓練的時候已經分析過,這裡就不再贅述了。

Reference