FastText總結,fastText 原始碼分析

weixin_34037977發表於2017-07-13


文字分類單層網路就夠了。非線性的問題用多層的。

fasttext有一個有監督的模式,但是模型等同於cbow,只是target變成了label而不是word。

fastText有兩個可說的地方:1 在word2vec的基礎上, 把Ngrams也當做詞訓練word2vec模型, 最終每個詞的vector將由這個詞的Ngrams得出. 這個改進能提升模型對morphology的效果, 即"字面上"相似的詞語distance也會小一些. 有人在question-words資料集上跑過fastText和gensim-word2vec的對比, 結果在 Jupyter Notebook Viewer .可以看出fastText在"adjective-to-adverb", "opposite"之類的資料集上效果還是相當好的. 不過像"family"這樣的字面上不一樣的資料集, fastText效果反而不如gensim-word2vec.推廣到中文上, 結果也類似. "字面上"相似對vector的影響非常大. 一個簡單的例子是, gensim訓練的模型中與"交易"最相似的是"買賣", 而fastText的結果是"交易法".2 用CBOW的思路來做分類, 親測下來訓練速度和準確率都挺不錯(也許是我的資料比較適合). 尤其是訓練速度, 快得嚇人.

在比賽中用了fasttext,發現速度驚人,而且記憶體優化比較好,用tensorflow搭建3層模型,記憶體超52g了,但是fasttext訓練卻非常快,文字分類準確率都還可以,只是為什麼loss這麼高

 


分完詞,使用facebook開源工具fasttext試試,效果超讚。如果你自己做的話,tfidf其實對於兩三句話的短評可效果還是可以的。

要是資料量不夠的話 可以直接嵌入一些規則來做,這裡是我總結的一篇基於規則的情感分析;短文字情感分析 - Forever-守望 - 部落格頻道 - CSDN.NET要是資料量很大的話,可以參考word2vec的思路,使用更復雜的分類器,我用卷積神經網路實現了一個基於大規模短文字的分類問題CNN在中文文字分類的應用 - Forever-守望 - 部落格頻道 - CSDN.NET


不久前為某諮詢公司針對某行業做過一個在twitter上的情感分析專案。題主的資料比較好的一點是評論已經按維度劃分好,免去了自建分類器來劃分維度的步驟,而這一點對為客戶創造價值往往相當重要。情感分析一般是個分類或者預測問題,首先需要定義情感的scale,通常的做法是polarity,直接可以使用把問題簡化為分類模型,如果題主的資料不是簡單的兩極,而是類似於1-5分的評分模式,則可以考慮把問題建模成預測模型以儲存不同level之間的邏輯關係。分類模型需一定量的標註資料進行訓練,如果題主資料量比較小的話,像肖凱提到的,可以去尋找類似的標註好的文字資料,當然最好是酒店和汽車行業的。如果沒有現成標註,在預算之內可以使用像AMT這樣的服務進行標註。接著是特徵的抽取,對於短文字特徵確實比較少,可以參考像微博這種短文字的分析,用什麼方法提取特徵比較好呢? - 文字挖掘 劉知遠老師的回答,使用主題模型擴充特徵選擇。不過對於一個諮詢專案來講,情感分析的結論是對於某一維度評論集合的情感分析,本身已經很多工作要做,根據80/20原則,我覺得沒有必要花費大量時間熟悉並應用主題模型。可以考慮的特徵有1. 詞袋模型,固定使用詞典或者高頻詞加人工選擇一些作為特徵;2. 文字長度;3. 正面詞佔比;4. 負面詞佔比;5. 表強調或疑問語氣的標點等等,題主可以多閱讀一些評論,從中找到一些其他特徵。在選取完特徵之後,使用主成分分析重新選取出新的特徵組合,最好不要超過15個防止過擬合或者curse of dimensionality。在選取模型時,考慮使用對過擬合抵抗性強的模型,經驗來講,linear SVR或Random Forest Regression效果會好一些,但是題主可以把所有常用的預測模型都跑一邊看哪個模型比較好。以上是在假定只有文字資料的情況下的一個可行的方案,如果資料是社交網路資料,可以考慮使用網路模型中心度等對不同評論的重要性加權。結果的展示方面,最好能夠展示出正負情感的佔比,作為平均情感分數的補充。同時,按照不同維度顯示情感,並且顯示情感隨時間的變化也比較重要。


fastText 方法包含三部分:模型架構、Softmax 和 N-gram 特徵。下面我們一一介紹。
fastText 模型架構和 Word2Vec 中的 CBOW 模型很類似。不同之處在於,fastText 預測標籤,而 CBOW 模型預測中間詞。
Softmax建立在哈弗曼編碼的基礎上,對標籤進行編碼,能夠極大地縮小模型預測目標的數量。
常用的特徵是詞袋模型。但詞袋模型不能考慮詞之間的順序,因此 fastText 還加入了 N-gram 特徵。“我 愛 她” 這句話中的詞袋模型特徵是 “我”,“愛”, “她”。這些特徵和句子 “她 愛 我” 的特徵是一樣的。如果加入 2-Ngram,第一句話的特徵還有 “我-愛” 和 “愛-她”,這兩句話 “我 愛 她” 和 “她 愛 我” 就能區別開來了。當然啦,為了提高效率,我們需要過濾掉低頻的 N-gram。

fastText 的詞嵌入學習能夠考慮 english-born 和 british-born 之間有相同的字尾,但 word2vec 卻不能。

fastText還能在五分鐘內將50萬個句子分成超過30萬個類別。

支援多語言表達:利用其語言形態結構,fastText能夠被設計用來支援包括英語、德語、西班牙語、法語以及捷克語等多種語言。
FastText的效能要比時下流行的word2vec工具明顯好上不少,也比其他目前最先進的詞態詞彙表徵要好。

 

FastText= word2vec中 cbow + h-softmax的靈活使用

靈活體現在兩個方面:
1. 模型的輸出層:word2vec的輸出層,對應的是每一個term,計算某term的概率最大;而fasttext的輸出層對應的是 分類的label。不過不管輸出層對應的是什麼內容,起對應的vector都不會被保留和使用;
2. 模型的輸入層:word2vec的輸出層,是 context window 內的term;而fasttext 對應的整個sentence的內容,包括term,也包括 n-gram的內容;

兩者本質的不同,體現在 h-softmax的使用。
Wordvec的目的是得到詞向量,該詞向量 最終是在輸入層得到,輸出層對應的 h-softmax 也會生成一系列的向量,但最終都被拋棄,不會使用。
fasttext則充分利用了h-softmax的分類功能,遍歷分類樹的所有葉節點,找到概率最大的label(一個或者N個)


facebook公開了90種語言的Pre-trained word vectors
https://github.com/facebookresearch/fastText/blob/master/pretrained-vectors.md
可怕的facebook,用fasttext進行訓練,使用預設引數,300維度


與word2vec的區別
這個模型與word2vec有很多相似的地方,也有很多不相似的地方。相似地方讓這兩種演算法不同的地方讓這兩
相似的地方:
圖模型結構很像,都是採用embedding向量的形式,得到word的隱向量表達。
都採用很多相似的優化方法,比如使用Hierarchical softmax優化訓練和預測中的打分速度。
不同的地方:
word2vec是一個無監督演算法,而fasttext是一個有監督演算法。word2vec的學習目標是skip的word,而fasttext的學習目標是人工標註的分類結果。
word2vec要求訓練樣本帶有“序”的屬性,而fasttext使用的是bag of words的思想,使用的是n-gram的無序屬性。


fasttext只有1層神經網路,屬於所謂的shallow learning,但是fasttext的效果並不差,而且具備學習和預測速度快的優勢,在工業界這點非常重要。比一般的神經網路模型的精確度還要高。

 

 

Please cite 1 if using this code for learning word representations or 2 if using for text classification.
1. Enriching Word Vectors with Subword Information
2. Bag of Tricks for Efficient Text Classification
FastText其實包含兩部分。一個是word2vec優化版,用了Subword的資訊,速度是不會提升的,只是效果方面的改進,對於中文貌似完全沒用。另外一塊是文字分類的Trick,結論就是對這種簡單的任務,用簡單的模型效果就不錯了。具體方法就是把句子每個word的vec求平均,然後直接用簡單的LR分類就行。FastText的Fast指的是這個。https://www.zhihu.com/question/48345431/answer/111513229 這個知乎答案總結得挺好的,取平均其實算DL的average pooling,呵呵。

 
 
 
 

最近在一個專案裡使用了fasttext[1], 這是facebook今年開源的一個詞向量與文字分類工具,在學術上沒有什麼創新點,但是好處就是模型簡單,訓練速度又非常快。我在最近的一個專案裡嘗試了一下,發現用起來真的很順手,做出來的結果也可以達到上線使用的標準。

其實fasttext使用的模型與word2vec的模型在結構上是一樣的,拿cbow來說,不同的只是在於word2vec cbow的目標是通過當前詞的前後N個詞來預測當前詞,在使用層次softmax的時候,huffman樹葉子節點處是訓練語料裡所有詞的向量。

而fasttext在進行文字分類時,huffmax樹葉子節點處是每一個類別標籤的詞向量,在訓練的過程中,訓練語料的每一個詞也會得到對應的詞向量,輸入為一個window內的詞對應的詞向量,hidden layer為這幾個詞的線性相加,相加的結果作為該文件的向量,再通過層次softmax得到預測標籤,結合文件的真實標籤計算loss,梯度與迭代更新詞向量。

fasttext有別於word2vec的另一點是加了ngram切分這個trick,將長詞再通過ngram切分為幾個短詞,這樣對於未登入詞也可以通過切出來的ngram詞向量合併為一個詞。由於中文的詞大多比較短,這對英文語料的用處會比中文語料更大。

此外,fasttext相比deep learning模型的優點是訓練速度極快。我們目前使用fasttext來進行客戶填寫的訂單地址到鎮這一級別的分類。每一個省份建立一個模型,每個模型要分的類別都有1000多類,200萬左右的訓練資料,12個執行緒1分鐘不到就可以訓練完成,最終的分類準確率與模型魯棒性都比較高(區縣級別分類正確準確率高於99.5%, 鎮級別高於98%),尤其是對縮寫地名,或者漏寫了市級行政區、區縣級行政區的情況也都可以正確處理。

引數方面

  1. loss function選用hs(hierarchical softmax)要比ns(negative sampling) 訓練速度要快很多倍,並且準確率也更高。

  2. wordNgrams 預設為1,設定為2以上可以明顯提高準確率。

  3. 如果詞數不是很多,可以把bucket設定的小一點,否則預留會預留太多bucket使模型太大。

因為facebook提供的只是C++版本的程式碼,原本還以為要自己封裝一個Python介面,結果上github一搜已經有封裝的python介面了[2]。用起來特別方便,覺得還不能滿足自己的使用要求,修改原始碼也非常方便。

對於同樣的文字分類問題,後來還用單向LSTM做了一遍,輸入pre-trained的embedding詞向量,並且在訓練的時候fine-tune,與fasttext對比,即使使用了GTX 980的GPU,訓練速度還是要慢很多,並且,準確準確率和fasttext是差不多的。

所以對於文字分類,先用fasttext做一個簡單的baseline是很適合的。

https://github.com/salestock/fastText.py

 
 
 
 

fastText 原始碼分析

介紹

fastText 是 facebook 近期開源的一個詞向量計算以及文字分類工具,該工具的理論基礎是以下兩篇論文:

Enriching Word Vectors with Subword Information

這篇論文提出了用 word n-gram 的向量之和來代替簡單的詞向量的方法,以解決簡單 word2vec 無法處理同一詞的不同形態的問題。fastText 中提供了 maxn 這個引數來確定 word n-gram 的 n 的大小。

Bag of Tricks for Efficient Text Classification

這篇論文提出了 fastText 演算法,該演算法實際上是將目前用來算 word2vec 的網路架構做了個小修改,原先使用一個詞的上下文的所有詞向量之和來預測詞本身(CBOW 模型),現在改為用一段短文字的詞向量之和來對文字進行分類。

在我看來,fastText 的價值是提供了一個 更具可讀性,模組化程度較好 的 word2vec 的實現,附帶一些新的分類功能,本文詳細分析它的原始碼。

頂層結構

fastText 的程式碼結構以及各模組的功能如下圖所示:

fasttext-arch

分析各模組時,我只會解釋該模組的 主要呼叫路徑 下的原始碼,以 註釋 的方式說明,其它的功能性程式碼請大家自行閱讀。如果對 word2vec 的理論和相關術語不瞭解,請先閱讀這篇 word2vec 中的數學原理詳解

訓練資料格式

訓練資料格式為一行一個句子,每個詞用空格分割,如果一個詞帶有字首“__label__”,那麼它就作為一個類標籤,在文字分類時使用,這個字首可以通過-label引數自定義。訓練檔案支援 UTF-8 格式。

fasttext 模組

fasttext 是最頂層的模組,它的主要功能是訓練預測,首先是訓練功能的呼叫路徑,第一個函式是 train,它的主要作用是 初始化引數,啟動多執行緒訓練,請大家留意原始碼中的相關部分。

void FastText::train(std::shared_ptr<Args> args) {
  args_ = args;
  dict_ = std::make_shared<Dictionary>(args_);
  std::ifstream ifs(args_->input);
  if (!ifs.is_open()) {
    std::cerr << "Input file cannot be opened!" << std::endl;
    exit(EXIT_FAILURE);
  }
  // 根據輸入檔案初始化詞典
  dict_->readFromFile(ifs);
  ifs.close();

   // 初始化輸入層, 對於普通 word2vec,輸入層就是一個詞向量的查詢表,
   // 所以它的大小為 nwords 行,dim 列(dim 為詞向量的長度),但是 fastText 用了
   // word n-gram 作為輸入,所以輸入矩陣的大小為 (nwords + ngram 種類) * dim
   // 程式碼中,所有 word n-gram 都被 hash 到固定數目的 bucket 中,所以輸入矩陣的大小為
   // (nwords + bucket 個數) * dim
  input_ = std::make_shared<Matrix>(dict_->nwords()+args_->bucket, args_->dim);
  
  // 初始化輸出層,輸出層無論是用負取樣,層次 softmax,還是普通 softmax,
  // 對於每種可能的輸出,都有一個 dim 維的引數向量與之對應
  // 當 args_->model == model_name::sup 時,訓練分類器,
  // 所以輸出的種類是標籤總數 dict_->nlabels()
  if (args_->model == model_name::sup) {
    output_ = std::make_shared<Matrix>(dict_->nlabels(), args_->dim);
  } else {
  // 否則訓練的是詞向量,輸出種類就是詞的種類 dict_->nwords()
    output_ = std::make_shared<Matrix>(dict_->nwords(), args_->dim);
  }
  input_->uniform(1.0 / args_->dim);
  output_->zero();

  start = clock();
  tokenCount = 0;
  
  // 庫採用 C++ 標準庫的 thread 來實現多執行緒
  std::vector<std::thread> threads;
  for (int32_t i = 0; i < args_->thread; i++) {
    // 實際的訓練發生在 trainThread 中
    threads.push_back(std::thread([=]() { trainThread(i); }));
  }
  for (auto it = threads.begin(); it != threads.end(); ++it) {
    it->join();
  }
  
  // Model 的所有引數(input_, output_)是在初始化時由外界提供的,
  // 此時 input_ 和 output_ 已經處於訓練結束的狀態
  model_ = std::make_shared<Model>(input_, output_, args_, 0);

  saveModel();
  if (args_->model != model_name::sup) {
    saveVectors();
  }
}

下面,我們進入 trainThread函式,看看訓練的主體邏輯,該函式的主要工作是 實現了標準的隨機梯度下降,並隨著訓練的進行逐步降低學習率。

void FastText::trainThread(int32_t threadId) {

  std::ifstream ifs(args_->input);
  // 根據執行緒數,將訓練檔案按照總位元組數(utils::size)均分成多個部分
  // 這麼做的一個後果是,每一部分的第一個詞有可能從中間被切斷,
  // 這樣的"小噪音"對於整體的訓練結果無影響
  utils::seek(ifs, threadId * utils::size(ifs) / args_->thread);

  Model model(input_, output_, args_, threadId);
  if (args_->model == model_name::sup) {
    model.setTargetCounts(dict_->getCounts(entry_type::label));
  } else {
    model.setTargetCounts(dict_->getCounts(entry_type::word));
  }

  // 訓練檔案中的 token 總數
  const int64_t ntokens = dict_->ntokens();
  // 當前執行緒處理完畢的 token 總數
  int64_t localTokenCount = 0;
  std::vector<int32_t> line, labels;
  // tokenCount 為所有執行緒處理完畢的 token 總數
  // 當處理了 args_->epoch 遍所有 token 後,訓練結束 
  while (tokenCount < args_->epoch * ntokens) {
    // progress = 0 ~ 1,代表當前訓練程式,隨著訓練的進行逐漸增大
    real progress = real(tokenCount) / (args_->epoch * ntokens);
    // 學習率根據 progress 線性下降
    real lr = args_->lr * (1.0 - progress);
    localTokenCount += dict_->getLine(ifs, line, labels, model.rng);
    // 根據訓練需求的不同,這裡用的更新策略也不同,它們分別是:
    // 1. 有監督學習(分類)
    if (args_->model == model_name::sup) {
      dict_->addNgrams(line, args_->wordNgrams);
      supervised(model, lr, line, labels);
    // 2. word2vec (CBOW)
    } else if (args_->model == model_name::cbow) {
      cbow(model, lr, line);
    // 3. word2vec (SKIPGRAM)
    } else if (args_->model == model_name::sg) {
      skipgram(model, lr, line);
    }
    // args_->lrUpdateRate 是每個執行緒學習率的變化率,預設為 100,
    // 它的作用是,每處理一定的行數,再更新全域性的 tokenCount 變數,從而影響學習率
    if (localTokenCount > args_->lrUpdateRate) {
      tokenCount += localTokenCount;
      // 每次更新 tokenCount 後,重置計數
      localTokenCount = 0;
      // 0 號執行緒負責將訓練進度輸出到螢幕
      if (threadId == 0) {
        printInfo(progress, model.getLoss());
      }
    }
  }
  if (threadId == 0) {
    printInfo(1.0, model.getLoss());
    std::cout << std::endl;
  }
  ifs.close();
}

一哄而上的並行訓練:每個訓練執行緒在更新引數時並沒有加鎖,這會給引數更新帶來一些噪音,但是不會影響最終的結果。無論是 google 的 word2vec 實現,還是 fastText 庫,都沒有加鎖。

從 trainThread 函式中我們發現,實際的模型更新策略發生在 supervised,cbow,skipgram三個函式中,這三個函式都呼叫同一個 model.update 函式來更新引數,這個函式屬於 model 模組,但在這裡我先簡單介紹它,以方便大家理解程式碼。

update 函式的原型為

void Model::update(const std::vector<int32_t>& input, int32_t target, real lr)

該函式有三個引數,分別是“輸入”,“類標籤”,“學習率”。

  • 輸入是一個 int32_t陣列,每個元素代表一個詞在 dictionary 裡的 ID。對於分類問題,這個陣列代表輸入的短文字,對於 word2vec,這個陣列代表一個詞的上下文。
  • 類標籤是一個 int32_t 變數。對於 word2vec 來說,它就是帶預測的詞的 ID,對於分類問題,它就是類的 label 在 dictionary 裡的 ID。因為 label 和詞在詞表裡一起存放,所以有統一的 ID 體系。

下面,我們回到 fasttext 模組的三個更新函式:

void FastText::supervised(Model& model, real lr,
                          const std::vector<int32_t>& line,
                          const std::vector<int32_t>& labels) {
  if (labels.size() == 0 || line.size() == 0) return;
  // 因為一個句子可以打上多個 label,但是 fastText 的架構實際上只有支援一個 label
  // 所以這裡隨機選擇一個 label 來更新模型,這樣做會讓其它 label 被忽略
  // 所以 fastText 不太適合做多標籤的分類
  std::uniform_int_distribution<> uniform(0, labels.size() - 1);
  int32_t i = uniform(model.rng);
  model.update(line, labels[i], lr);
}

void FastText::cbow(Model& model, real lr,
                    const std::vector<int32_t>& line) {
  std::vector<int32_t> bow;
  std::uniform_int_distribution<> uniform(1, args_->ws);
  
  // 在一個句子中,每個詞可以進行一次 update
  for (int32_t w = 0; w < line.size(); w++) {
    // 一個詞的上下文長度是隨機產生的
    int32_t boundary = uniform(model.rng);
    bow.clear();
    // 以當前詞為中心,將左右 boundary 個詞加入 input
    for (int32_t c = -boundary; c <= boundary; c++) {
      // 當然,不能陣列越界
      if (c != 0 && w + c >= 0 && w + c < line.size()) {
        // 實際被加入 input 的不止是詞本身,還有詞的 word n-gram
        const std::vector<int32_t>& ngrams = dict_->getNgrams(line[w + c]);
        bow.insert(bow.end(), ngrams.cbegin(), ngrams.cend());
      }
    }
    // 完成一次 CBOW 更新
    model.update(bow, line[w], lr);
  }
}

void FastText::skipgram(Model& model, real lr,
                        const std::vector<int32_t>& line) {
  std::uniform_int_distribution<> uniform(1, args_->ws);
  for (int32_t w = 0; w < line.size(); w++) {
    // 一個詞的上下文長度是隨機產生的
    int32_t boundary = uniform(model.rng);
    // 採用詞+word n-gram 來預測這個詞的上下文的所有的詞
    const std::vector<int32_t>& ngrams = dict_->getNgrams(line[w]);
    // 在 skipgram 中,對上下文的每一個詞分別更新一次模型
    for (int32_t c = -boundary; c <= boundary; c++) {
      if (c != 0 && w + c >= 0 && w + c < line.size()) {
        model.update(ngrams, line[w + c], lr);
      }
    }
  }
}

訓練部分的程式碼已經分析完畢,預測部分的程式碼就簡單多了,它的主要邏輯都在 model.predict 函式裡。

void FastText::predict(const std::string& filename, int32_t k, bool print_prob) {
  std::vector<int32_t> line, labels;
  std::ifstream ifs(filename);
  if (!ifs.is_open()) {
    std::cerr << "Test file cannot be opened!" << std::endl;
    exit(EXIT_FAILURE);
  }
  while (ifs.peek() != EOF) {
    // 讀取輸入檔案的每一行
    dict_->getLine(ifs, line, labels, model_->rng);
    // 將一個詞的 n-gram 加入詞表,用於處理未登入詞。(即便一個詞不在詞表裡,我們也可以用它的 word n-gram 來預測一個結果)
    dict_->addNgrams(line, args_->wordNgrams);
    if (line.empty()) {
      std::cout << "n/a" << std::endl;
      continue;
    }
    std::vector<std::pair<real, int32_t>> predictions;
    // 呼叫 model 模組的預測介面,獲取 k 個最可能的分類
    model_->predict(line, k, predictions);
    // 輸出結果
    for (auto it = predictions.cbegin(); it != predictions.cend(); it++) {
      if (it != predictions.cbegin()) {
        std::cout << ' ';
      }
      std::cout << dict_->getLabel(it->second);
      if (print_prob) {
        std::cout << ' ' << exp(it->first);
      }
    }
    std::cout << std::endl;
  }
  ifs.close();
}

通過對 fasttext 模組的分析,我們發現它最核心的預測和更新邏輯都在 model 模組中,接下來,我們進入 model 模組一探究竟。

model 模組

model 模組對外提供的服務可以分為 update 和 predict 兩類,下面我們分別對它們進行分析。由於這裡的引數較多,我們先以圖示標明各個引數在模型中所處的位置,以免各位混淆。

fasttext-model-arch

圖中所有變數的名字全部與 model 模組中的名字保持一致,注意到 wo_ 矩陣在不同的輸出層結構中扮演著不同的角色。

update

update 函式的作用已經在前面介紹過,下面我們看一下它的實現:

void Model::update(const std::vector<int32_t>& input, int32_t target, real lr) {
  // target 必須在合法範圍內
  assert(target >= 0);
  assert(target < osz_);
  if (input.size() == 0) return;
  // 計算前向傳播:輸入層 -> 隱層
  hidden_.zero();
  for (auto it = input.cbegin(); it != input.cend(); ++it) {
    // hidden_ 向量儲存輸入詞向量的均值,
    // addRow 的作用是將 wi_ 矩陣的第 *it 列加到 hidden_ 上
    hidden_.addRow(*wi_, *it);
  }
  // 求和後除以輸入詞個數,得到均值向量
  hidden_.mul(1.0 / input.size());
  
  // 根據輸出層的不同結構,呼叫不同的函式,在各個函式中,
  // 不僅通過前向傳播算出了 loss_,還進行了反向傳播,計算出了 grad_,後面逐一分析。
  // 1. 負取樣
  if (args_->loss == loss_name::ns) {
    loss_ += negativeSampling(target, lr);
  } else if (args_->loss == loss_name::hs) {
  // 2. 層次 softmax
    loss_ += hierarchicalSoftmax(target, lr);
  } else {
  // 3. 普通 softmax
    loss_ += softmax(target, lr);
  }
  nexamples_ += 1;

  // 如果是在訓練分類器,就將 grad_ 除以 input_ 的大小
  // 原因不明
  if (args_->model == model_name::sup) {
    grad_.mul(1.0 / input.size());
  }
  // 反向傳播,將 hidden_ 上的梯度傳播到 wi_ 上的對應行
  for (auto it = input.cbegin(); it != input.cend(); ++it) {
    wi_->addRow(grad_, *it, 1.0);
  }
}

下面我們看看三種輸出層對應的更新函式:negativeSampling,hierarchicalSoftmax,softmax

model 模組中最有意思的部分就是將層次 softmax 和負取樣統一抽象成多個二元 logistic regression 計算。

如果使用負取樣,訓練時每次選擇一個正樣本,隨機取樣幾個負樣本,每種輸出都對應一個引數向量,儲存於 wo_ 的各行。對所有樣本的引數更新,都是一次獨立的 LR 引數更新。

如果使用層次 softmax,對於每個目標詞,都可以在構建好的霍夫曼樹上確定一條從根節點到葉節點的路徑,路徑上的每個非葉節點都是一個 LR,引數儲存在 wo_ 的各行上,訓練時,這條路徑上的 LR 各自獨立進行引數更新。

無論是負取樣還是層次 softmax,在神經網路的計算圖中,所有 LR 都會依賴於 hidden_的值,所以 hidden_ 的梯度 grad_ 是各個 LR 的反向傳播的梯度的累加。

LR 的程式碼如下:

real Model::binaryLogistic(int32_t target, bool label, real lr) {
  // 將 hidden_ 和引數矩陣的第 target 行做內積,並計算 sigmoid
  real score = utils::sigmoid(wo_->dotRow(hidden_, target));
  // 計算梯度時的中間變數
  real alpha = lr * (real(label) - score);
  // Loss 對於 hidden_ 的梯度累加到 grad_ 上
  grad_.addRow(*wo_, target, alpha);
  // Loss 對於 LR 引數的梯度累加到 wo_ 的對應行上
  wo_->addRow(hidden_, target, alpha);
  // LR 的 Loss
  if (label) {
    return -utils::log(score);
  } else {
    return -utils::log(1.0 - score);
  }
}

經過以上的分析,下面三種邏輯就比較容易理解了:

real Model::negativeSampling(int32_t target, real lr) {
  real loss = 0.0;
  grad_.zero();
  for (int32_t n = 0; n <= args_->neg; n++) {
    // 對於正樣本和負樣本,分別更新 LR
    if (n == 0) {
      loss += binaryLogistic(target, true, lr);
    } else {
      loss += binaryLogistic(getNegative(target), false, lr);
    }
  }
  return loss;
}

real Model::hierarchicalSoftmax(int32_t target, real lr) {
  real loss = 0.0;
  grad_.zero();
  // 先確定霍夫曼樹上的路徑
  const std::vector<bool>& binaryCode = codes[target];
  const std::vector<int32_t>& pathToRoot = paths[target];
  // 分別對路徑上的中間節點做 LR
  for (int32_t i = 0; i < pathToRoot.size(); i++) {
    loss += binaryLogistic(pathToRoot[i], binaryCode[i], lr);
  }
  return loss;
}

// 普通 softmax 的引數更新
real Model::softmax(int32_t target, real lr) {
  grad_.zero();
  computeOutputSoftmax();
  for (int32_t i = 0; i < osz_; i++) {
    real label = (i == target) ? 1.0 : 0.0;
    real alpha = lr * (label - output_[i]);
    grad_.addRow(*wo_, i, alpha);
    wo_->addRow(hidden_, i, alpha);
  }
  return -utils::log(output_[target]);
}

predict

predict 函式可以用於給輸入資料打上 1 ~ K 個類標籤,並輸出各個類標籤對應的概率值,對於層次 softmax,我們需要遍歷霍夫曼樹,找到 top-K 的結果,對於普通 softmax(包括負取樣和 softmax 的輸出),我們需要遍歷結果陣列,找到 top-K。

void Model::predict(const std::vector<int32_t>& input, int32_t k, std::vector<std::pair<real, int32_t>>& heap) {
  assert(k > 0);
  heap.reserve(k + 1);
  // 計算 hidden_
  computeHidden(input);
  
  // 如果是層次 softmax,使用 dfs 遍歷霍夫曼樹的所有葉子節點,找到 top-k 的概率
  if (args_->loss == loss_name::hs) {
    dfs(k, 2 * osz_ - 2, 0.0, heap);
  } else {
  // 如果是普通 softmax,在結果陣列裡找到 top-k
    findKBest(k, heap);
  }
  // 對結果進行排序後輸出
  // 因為 heap 中雖然一定是 top-k,但並沒有排好序
  std::sort_heap(heap.begin(), heap.end(), comparePairs);
}

void Model::findKBest(int32_t k, std::vector<std::pair<real, int32_t>>& heap) {
  // 計算結果陣列
  computeOutputSoftmax();
  for (int32_t i = 0; i < osz_; i++) {
    if (heap.size() == k && utils::log(output_[i]) < heap.front().first) {
      continue;
    }
    // 使用一個堆來儲存 top-k 的結果,這是算 top-k 的標準做法
    heap.push_back(std::make_pair(utils::log(output_[i]), i));
    std::push_heap(heap.begin(), heap.end(), comparePairs);
    if (heap.size() > k) {
      std::pop_heap(heap.begin(), heap.end(), comparePairs);
      heap.pop_back();
    }
  }
}

void Model::dfs(int32_t k, int32_t node, real score, std::vector<std::pair<real, int32_t>>& heap) {
  if (heap.size() == k && score < heap.front().first) {
    return;
  }

  if (tree[node].left == -1 && tree[node].right == -1) {
    // 只輸出葉子節點的結果
    heap.push_back(std::make_pair(score, node));
    std::push_heap(heap.begin(), heap.end(), comparePairs);
    if (heap.size() > k) {
      std::pop_heap(heap.begin(), heap.end(), comparePairs);
      heap.pop_back();
    }
    return;
  }
  
  // 將 score 累加後遞迴向下收集結果
  real f = utils::sigmoid(wo_->dotRow(hidden_, node - osz_));
  dfs(k, tree[node].left, score + utils::log(1.0 - f), heap);
  dfs(k, tree[node].right, score + utils::log(f), heap);
}

其它模組

除了以上兩個模組,dictionary 模組也相當重要,它完成了訓練檔案載入,雜湊表構建,word n-gram 計算等功能,但是並沒有太多演算法在裡面。

其它模組例如 Matrix, Vector 也只是封裝了簡單的矩陣向量操作,這裡不再做詳細分析。

附錄:構建霍夫曼樹演算法分析

在學資訊理論的時候接觸過構建 Huffman 樹的演算法,課本中的方法描述往往是:

找到當前權重最小的兩個子樹,將它們合併

演算法的效能取決於如何實現這個邏輯。網上的很多實現都是在新增節點都時遍歷一次當前所有的樹,這種演算法的複雜度是 O(n2)O(n2),效能很差。

聰明一點的方法是用一個優先順序佇列來儲存當前所有的樹,每次取 top 2,合併,加回佇列。這個演算法的複雜度是 O(nlogn)O(nlogn),缺點是必需使用額外的資料結構,而且進堆出堆的操作導致常數項較大。

word2vec 以及 fastText 都採用了一種更好的方法,時間複雜度是 O(nlogn)O(nlogn),只用了一次排序,一次遍歷,簡潔優美,但是要理解它需要進行一些推理。

演算法如下:

void Model::buildTree(const std::vector<int64_t>& counts) {
  // counts 陣列儲存每個葉子節點的詞頻,降序排列
  // 分配所有節點的空間
  tree.resize(2 * osz_ - 1);
  // 初始化節點屬性
  for (int32_t i = 0; i < 2 * osz_ - 1; i++) {
    tree[i].parent = -1;
    tree[i].left = -1;
    tree[i].right = -1;
    tree[i].count = 1e15;
    tree[i].binary = false;
  }
  for (int32_t i = 0; i < osz_; i++) {
    tree[i].count = counts[i];
  }
  // leaf 指向當前未處理的葉子節點的最後一個,也就是權值最小的葉子節點
  int32_t leaf = osz_ - 1;
  // node 指向當前未處理的非葉子節點的第一個,也是權值最小的非葉子節點
  int32_t node = osz_;
  // 逐個構造所有非葉子節點(i >= osz_, i < 2 * osz - 1)
  for (int32_t i = osz_; i < 2 * osz_ - 1; i++) {
    // 最小的兩個節點的下標
    int32_t mini[2];
    
    // 計算權值最小的兩個節點,候選只可能是 leaf, leaf - 1,
    // 以及 node, node + 1
    for (int32_t j = 0; j < 2; j++) {
      // 從這四個候選裡找到 top-2
      if (leaf >= 0 && tree[leaf].count < tree[node].count) {
        mini[j] = leaf--;
      } else {
        mini[j] = node++;
      }
    }
    // 更新非葉子節點的屬性
    tree[i].left = mini[0];
    tree[i].right = mini[1];
    tree[i].count = tree[mini[0]].count + tree[mini[1]].count;
    tree[mini[0]].parent = i;
    tree[mini[1]].parent = i;
    tree[mini[1]].binary = true;
  }
  // 計算霍夫曼編碼
  for (int32_t i = 0; i < osz_; i++) {
    std::vector<int32_t> path;
    std::vector<bool> code;
    int32_t j = i;
    while (tree[j].parent != -1) {
      path.push_back(tree[j].parent - osz_);
      code.push_back(tree[j].binary);
      j = tree[j].parent;
    }
    paths.push_back(path);
    codes.push_back(code);
  }
}

演算法首先對輸入的葉子節點進行一次排序(O(nlogn)O(nlogn) ),然後確定兩個下標 leaf 和 nodeleaf 總是指向當前最小的葉子節點,node 總是指向當前最小的非葉子節點,所以,最小的兩個節點可以從 leaf, leaf - 1, node, node + 1 四個位置中取得,時間複雜度 O(1)O(1),每個非葉子節點都進行一次,所以總複雜度為 O(n)O(n),演算法整體複雜度為 O(nlogn)O(nlogn)。

 

相關文章