條件隨機場之CRF++源碼詳解-特征


  我在學習條件隨機場的時候經常有這樣的疑問,crf預測當前節點label如何利用其他節點的信息、crf的訓練樣本與其他的分類器有什么不同、crf的公式中特征函數是什么以及這些特征函數是如何表示的。在這一章中,我將在CRF++源碼中尋找答案。

輸入過程

  CRF++訓練的入口在crf_learn.cpp文件的main函數中,在該函數中調用了encoder.cpp的crfpp_learn(int argc, char **argv)函數。在CRF++中,訓練被稱為encoder,顯然預測就稱為decoder。crfpp_learn的源碼如下:

1 int crfpp_learn(int argc, char **argv) {
2   CRFPP::Param param; //存放輸入的參數
3   param.open(argc, argv, CRFPP::long_options); //處理命令行輸入的參數,存在param對象中
4   return CRFPP::crfpp_learn(param);
5 }

  Param對象主要存放輸入的參數,調用open方法處理命令行輸入的參數並存儲。最后調用crfpp_learn(const Param &param)函數,在該函數中將初始化Encoder對象encoder,並調用encoder的learn方法。

樣本的處理以及特征的構造

  本章的重點便是這個learn方法,該方法主要是根據輸入的樣本和特征模板構造特征。閱讀該函數源碼之前可以去CRF++官網了解一下CRF++輸入的參數,以及模板文件和訓練文件的格式。

  1 bool Encoder::learn(const char *templfile, //模板文件
  2                     const char *trainfile, //訓練樣本
  3                     const char *modelfile, //模型輸出文件
  4                     bool textmodelfile, 
  5                     size_t maxitr,
  6                     size_t freq,
  7                     double eta,
  8                     double C,
  9                     unsigned short thread_num,
 10                     unsigned short shrinking_size,
 11                     int algorithm) {
 12   std::cout << COPYRIGHT << std::endl;
 13 
 14   CHECK_FALSE(eta > 0.0) << "eta must be > 0.0"; //CHECK_FALSE是宏定義,如果傳入的條件是false,則輸出異常信息
 15   CHECK_FALSE(C >= 0.0) << "C must be >= 0.0";
 16   CHECK_FALSE(shrinking_size >= 1) << "shrinking-size must be >= 1";
 17   CHECK_FALSE(thread_num > 0) << "thread must be > 0";
 18 
 19 #ifndef CRFPP_USE_THREAD
 20   CHECK_FALSE(thread_num == 1)
 21       << "This architecture doesn't support multi-thrading";
 22 #endif
 23 
 24   if (algorithm == MIRA && thread_num > 1) {//MIRAS算法無法啟用多線程
 25     std::cerr <<  "MIRA doesn't support multi-thrading. use thread_num=1"
 26               << std::endl;
 27   }
 28 
 29   EncoderFeatureIndex feature_index; //所有的特征將存儲在feature_index中
 30   Allocator allocator(thread_num); //allocator對象主要用來做資源分配以及回收
 31   std::vector<TaggerImpl* > x; //x存放輸入的樣本,例如:如果做詞性標注的話,TaggerTmpl對象存放的是每句話,而x是所有句子
 32 
 33   std::cout.setf(std::ios::fixed, std::ios::floatfield);
 34   std::cout.precision(5);
 35 
 36 #define WHAT_ERROR(msg) do {                                    \
 37     for (std::vector<TaggerImpl *>::iterator it = x.begin();    \
 38          it != x.end(); ++it)                                   \
 39       delete *it;                                               \
 40     std::cerr << msg << std::endl;                              \
 41     return false; } while (0)
 42 
 43   CHECK_FALSE(feature_index.open(templfile, trainfile)) //打開“模板文件”和“訓練文件”
 44       << feature_index.what();
 45 
 46   {
 47     progress_timer pg;
 48 
 49     std::ifstream ifs(WPATH(trainfile));
 50     CHECK_FALSE(ifs) << "cannot open: " << trainfile;
 51 
 52     std::cout << "reading training data: " << std::flush;
 53     size_t line = 0;
 54     while (ifs) {      //開始讀取訓練樣本
 55       TaggerImpl *_x = new TaggerImpl(); //_x存放的是一句話的內容,CRF++官網中提到,用一個空白行將每個sentence隔開
 56       _x->open(&feature_index, &allocator); //做一些屬性賦值,所有的句子都對應相同的feature_index和allocator對象
 57       if (!_x->read(&ifs) || !_x->shrink()) {
 58         WHAT_ERROR(_x->what());
 59       }
 60 
 61       if (!_x->empty()) {
 62         x.push_back(_x);
 63       } else {
 64         delete _x;
 65         continue;
 66       }
 67 
 68       _x->set_thread_id(line % thread_num); //每個句子都會分配一個線程id,可以多線程並發處理不同的句子
 69 
 70       if (++line % 100 == 0) {
 71         std::cout << line << ".. " << std::flush;
 72       }
 73     }
 74 
 75     ifs.close();
 76     std::cout << "\nDone!";
 77   }
 78 
 79   feature_index.shrink(freq, &allocator); // 根據訓練是指定的-f參數,將特征出現的頻率小於freq的過濾掉
 80 
 81   std::vector <double> alpha(feature_index.size());           // feature_index.size()返回的是maxid_,即:特征函數的個數,alpha是每個特征函數的權重,便是CRF中要學習的參數
 82   std::fill(alpha.begin(), alpha.end(), 0.0);
 83   feature_index.set_alpha(&alpha[0]);
 84 
 85   std::cout << "Number of sentences: " << x.size() << std::endl;
 86   std::cout << "Number of features:  " << feature_index.size() << std::endl;
 87   std::cout << "Number of thread(s): " << thread_num << std::endl;
 88   std::cout << "Freq:                " << freq << std::endl;
 89   std::cout << "eta:                 " << eta << std::endl;
 90   std::cout << "C:                   " << C << std::endl;
 91   std::cout << "shrinking size:      " << shrinking_size
 92             << std::endl;
 93 
 94   ... //省略后續代碼 
95
}

我閱讀源碼是按照深度優先遍歷的方式,遇到一個函數會不斷地深入進去,直到理解了該函數的功能再返回。上述源碼需要重點介紹的部分,我也按照深度優先的方式記錄。對於比較容易理解的部分則直接在源碼中添加注釋。首先看下源碼第43行feature_index.open(templfile, trainfile),表面是理解是打開模板文件和訓練集文件,但具體做了什么事兒呢,進入這個函數發現分別調用了兩個函數。一個是EncoderFeatureIndex::openTemplate(const char *filename),這個函數主要是讀取模板文件中的unigram特征和bigram特征分別存儲,從官網文章中也可以知道,crf的特征分為兩種特征,unigram對應的是狀態特征,bigram對應的是轉移特征。另一個函數是EncoderFeatureIndex::openTagSet(const char *filename),該函數讀取訓練集文件,獲得訓練集特征的數量(feature_index.xsize_屬性)以及訓練集中label的集合(feature_index.y_屬性),以后可以用集合中label值得的索引代替label。

  learn函數的第57行,有兩個函數調用。一個是_x->read(&ifs),這個函數是對輸入的樣本做處理。解釋該函數之前,我先做一個約定,以詞性標注為例。我們輸入的訓練樣本每一行代表一個詞,每一列代表詞的特征,多個詞(多行)代表一個句子,句子與句子之間用空白行分隔。這個規則從CRF++文檔中也能看出,我們就統一用句子和詞表示,方便表達。那么,該函數會讀取一個句子。經過層層調用,會對_x對象中幾個重要的數據結構進行賦值,由於這個函數的處理邏輯不復雜,因此我直接給出最終賦值的結果。如下:

class TaggerImpl : public Tagger {
  FeatureIndex   *feature_index_;
  Allocator      *allocator_;
  std::vector<std::vector <const char *> > x_; //代表一個句子,外部vector代表多行(多個詞),內部vector代表每行的多列,具體的列用char*表示
  std::vector<std::vector <Node *> > node_;    //相當於二位數組,node_[i][j]表示一個節點,即:第i個詞是第j個label的點。如:“我”這個詞是“代詞”
  std::vector<unsigned short int>  answer_;    //每個詞對應的label
  std::vector<unsigned short int>  result_;    
};

 另一個調用是_x->shrink(),該函數的主要功能就是構造特征,具體來說是調用了feature_index的FeatureIndex::buildFeatures(TaggerImpl *tagger)方法,源碼如下:

#define ADD { const int id = getID(os.c_str()); \
  if (id != -1) feature.push_back(id); } while (0)
bool FeatureIndex::buildFeatures(TaggerImpl *tagger) const {
  string_buffer os;
  std::vector<int> feature;

  FeatureCache *feature_cache = tagger->allocator()->feature_cache(); //存放是每個節點或者邊對應的特征向量,節點便是node[i][j],邊的概念后續會接觸,暫時可以忽略
  tagger->set_feature_id(feature_cache->size()); //做個標記,以后要取該句子的特征,可以從該id的位置取

  for (size_t cur = 0; cur < tagger->size(); ++cur) {//遍歷每個詞,計算每個詞的特征
    for (std::vector<std::string>::const_iterator it
             = unigram_templs_.begin();
         it != unigram_templs_.end(); ++it) { //遍歷每個unigram特征
      if (!applyRule(&os, it->c_str(), cur, *tagger)) {applyRule函數根據當前詞(cur)以及當前的特征(如: %x[-2,0]),生成一個特征,存放在os中
        return false;
      }
      ADD; //將根據特征os,獲取該特征的id,如果不存在該特征,生成新的id,將該id添加到feature變量中
    }
    feature_cache->add(feature); //將該詞的特征添加到feature_cache中,add方法會將feature拷貝一份並將最后添加-1,方便后續讀取
    feature.clear();
  }

  for (size_t cur = 1; cur < tagger->size(); ++cur) {//遍歷每條邊,計算每條邊的特征
    for (std::vector<std::string>::const_iterator
             it = bigram_templs_.begin();
         it != bigram_templs_.end(); ++it) {//遍歷每個bigram特征
      if (!applyRule(&os, it->c_str(), cur, *tagger)) {//處理同上
        return false;
      }
      ADD;
    }
    feature_cache->add(feature);
    feature.clear();
  }

  return true;
}

 經過上面處理,最終會存儲節點(單詞)和邊(相鄰詞連接)的特征列表(函數中feature變量),並存儲在feature_cache中,由於在該函數中調用了set_feature_id方法,因此很容易拿到每個句子對應的特征列表。這里需要關注一下applyRule函數和ADD宏定義中的getID函數。下面我將舉個例子,來直觀感受下這兩個函數的功能。

tempfile:

  # Unigram
  U00:%x[-1,0]
  U01:%x[0,0]

 trainfile:

  0 - -1 -1 -1 -1 O
  0 submit 7 0 0 0 B
  1 submit 3 4 0 0 E

先看下CRF++中的特征模板,模板文件比較簡單,只有unigram特征,特征的表示形如 U00:%x[a,b],開頭的'U'代表unigram特征還是bigram特征,b代表的是哪列特征,a代表的是當前詞的行偏移量。樣本集文件更簡單,只有一個句子,該句子有3個單詞,每個單詞有6個特征。

1) 當cur=0,遍歷第一個unigram特征U00:%x[-1,0], 0代表第0個特征(第0列),-1代表前一個詞的第0個特征。由於第一個詞沒有前一個詞,所以CRF++中用_B-1代替,這部分可在源碼中找到。調用applyRule將會生成"U00:_B-1"特征,調用getID函數返回的maxid_並存儲在feature_index的dic_屬性中,maxid_初始值為0,如果當前特征是新的則返回maxid_並更新maxid_為新值,maxid更新代碼為maxid_ += (key[0] == 'U' ? y_.size() : y_.size() * y_.size()); 由於unigram是狀態特征label與當前節點有關,所以加y_.size()表示y_.size()個特征函數,而bigram表示轉移特征(邊),與當前狀態和前一個狀態有關,有y_.size() * y_.size()種情況,因此加上y_.size() * y_.size(),代表y_.size()*y_.size()個特征函數。以上述例子unigram來說,對於某個詞的特征,該詞的label可能有y_.size()種情況,最終生成的特征函數是 f(特征='U00:_B-1', y='O')=1,f(特征='U00:_B-1', y='B')=1,f(特征='U00:_B-1', y='E')=1。總結一下,對於這個例子來說,一個unigram特征對應3狀態特征函數,一個bigram特征對應9個轉移特征函數。

2) 當cur=0,遍歷第二個unigram特征U01:%x[0,0],調用applyRule生成特征"U01:0",調用getID函數,返回特征id為3,feature變量為[0,3]

3) 當cur=1,遍歷第一個unigram特征U00:%x[-1,0],調用applyRule生成特征"U00:0",調用getID函數,返回特征id為6

4) 當cur=1,遍歷第二個unigram特征U01:%x[0,0],調用applyRule生成特征"U01:0",調用getID函數,返回特征id為3, feature變量為[6,3]

5) 當cur=2,遍歷第一個unigram特征U00:%x[-1,0],調用applyRule生成特征"U00:0",調用getID函數,返回特征id為6

6) 當cur=2,遍歷第二個unigram特征U01:%x[0,0],調用applyRule生成特征"U01:1",調用getID函數,返回特征id為9, 此時maxid_更新為12,feature變量為[6,9]

因此,特征一共有4個,狀態特征有12個,轉移特征為0個,因此feature_index的maxid_為12,feature_cache的大小為5(3個節點+2條邊)。本例子中只有1句話並且只有一個特征的unigram特征函數,對於多句話和多個特征函數,計算邏輯是一樣的,並且都會更新到公共的變量feature_index中。

 至此,就_x->shrink()的核心邏輯便梳理完畢, 同時也是整個learn函數的核心邏輯,回到learn函數的源碼繼續往下看,while循環會對每個句子重復進行上述操作,並將表示句子的變量x_存儲到變量x中,代表整個訓練集。還有需要注意的是我們平時一般用w表示待學習的參數,但在CRF++中使用變量alpha表示w。

總結

  本章主要結合源碼和實際的例子,了解了CRF++如何處理輸入的樣本,如何生成特征以及特征函數。首先,通過本章可以清晰的找到開頭提到的幾個問題。其次,可以學習CRF++如何定義數據結構表示條件隨機場各個元素及其之間的關系,如果再仔細體會一下,就能發現CRF++里設計的數據結構和代碼實現還是非常巧妙的,值得學習。如對本章內容有疑問的歡迎在留言區交流,我會及時回復,同時如有表述不對的地方,煩請指正。

 

 

  


免責聲明!

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



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