無所不能的Embedding2 - 詞向量三巨頭之FastText詳解

語言: CN / TW / HK

攜手創作,共同成長!這是我參與「掘金日新計劃 · 8 月更文挑戰」的第9天,點選檢視活動詳情

Fasttext是FaceBook開源的文字分類和詞向量訓練庫。最初看其他教程看的我十分迷惑,咋的一會ngram是字元一會ngram又變成了單詞,最後發現其實是兩個模型,一個是文字分類模型[Ref2],表現不是最好的但勝在結構簡單高效,另一個用於詞向量訓練[Ref1],創新在於把單詞分解成字元結構,可以infer訓練集外的單詞。這裡拿quora的詞分類資料集嘗試了下Fasttext在文字分類的效果, 程式碼詳見 https://github.com/DSXiangLi/Embedding

Fasttext 分類模型

Fasttext分類模型結構很直觀是一個淺層的神經網路。先對文字的每個詞做embedding得到$w_i$, 然後所有詞的embedding做平均得到文字向量$w_{doc}$,然後經過1層神經網路對label進行預測

$$ w_{doc} = \frac{1}{n}\sum_{i=1}^n w_{i} \ p = \sigma {(\beta \cdot w_{doc})} \ $$

只說到這裡,其實會發現和之前word2vec的CBOW基本是一樣的,區別在於CBOW預測的是center word, 而Fasttext預測的是label,例如新聞分類,情感分類等,同時CBOW只考慮window_size內的單詞,而Fasttext會使用變長文字內的所有單詞。

看到Fasttext對全文字的詞向量求平均, 第一反應是會丟失很多資訊,對於短文字可能還好,但對於長文字效果應該不咋地。畢竟不能考慮到詞序資訊,是詞袋模型的通病。Fasttext對此的解決辦法是加入n-gram特徵。這裡的n-gram是單詞級別的n-gram, 把相連的n個單詞當作1個單詞來做embedding,這樣就可以考慮到區域性的詞序資訊。當然副作用就是需要學習的embedding規模會大幅上升,只是2-gram就會比word要多得多。

Fasttext對此的解決方法是使用hashing把n-gram對映到bucket, 相同bucket的n-gram共享一個詞向量。

在Quora的文字資料集上我自己實現了一版fasttext分類模型, LeaderBoard的F1在0.71左右,因為要用Kernel提交太麻煩只在訓練集上跑了下在0.68左右,所以fasttext的分類模型確實是勝在一個快字。

Fasttext 詞向量模型

Fasttext另一個模型就是詞向量模型,是在Skip-gram的基礎上,創新加入了subword資訊。也就是把單詞分解成字串,模型學習的是字串embedding ,單詞的embedding由字元embedding求平均得到,這也是Fasttext詞向量可以infer樣本外單詞的原因。

關於模型和訓練細節,和前一章講到的word2vec是一樣的,感興趣的可以來這裡摟一眼 無所不能的Embedding 1 - Word2vec模型詳解&程式碼實現

這裡我們只細討論下和subword相關的原始碼。這裡n-gram不再指單詞而是字元,模型引數maxn,minn會設定n-gram的上界和下界。當設定minn = 2, maxn=3的時候,‘where’單詞對應的subwords是<'wh','her','ere',re'>,還有<'where'>本身。

當時paper看到這裡第一個反應是英文可以這麼搞,因為英文可以分解成字元,且一些字首字尾是有特殊含義的,中文咋整,拆偏旁部首麼?!來看程式碼答疑解惑

```c++ void Dictionary::initNgrams() { for (size_t i = 0; i < size_; i++) { std::string word = BOW + words_[i].word + EOW; // 詞前後加入<>用來區分單詞和字元 words_[i].subwords.clear(); words_[i].subwords.push_back(i); //先把單詞本身加入subword if (words_[i].word != EOS) { computeSubwords(word, words_[i].subwords); } } }

void Dictionary::computeSubwords( const std::string& word, std::vector& ngrams, std::vector* substrings) const { for (size_t i = 0; i < word.size(); i++) { std::string ngram; if ((word[i] & 0xC0) == 0x80) { continue; // 遇到10開頭位元組跳過,保證中文從第一個位元組開始讀 } for (size_t j = i, n = 1; j < word.size() && n <= args_->maxn; n++) { ngram.push_back(word[j++]); while (j < word.size() && (word[j] & 0xC0) == 0x80) { ngram.push_back(word[j++]); // 如果是中文,讀取該字元的所有位元組 } if (n >= args_->minn && !(n == 1 && (i == 0 || j == word.size()))) {// subword滿足長度,得到hash值加入到ngrams裡面 int32_t h = hash(ngram) % args_->bucket; pushHash(ngrams, h); if (substrings) { substrings->push_back(ngram); } } } } } ```

核心在computeSubwords部分, 乍一看十分迷幻的是(word[i] & 0xC0) == 0x80) 。這裡0xC0二進位制是11 00 00 00,和它做位運算是取位元組的前兩個bit,0x80二進位制是10 00 00 00,前兩位是10,其實也就是判斷word[i]前兩個bit是不是10,。。。10又是啥?

字元編碼筆記:ASCII,Unicode 和 UTF-8 這裡附上阮神的部落格,廣告費請結一下~

簡單來說就是Fasttext要求輸入為UTF-8編碼,這裡需要用到UTF-8的兩條編碼規則:

  • 單位元組的符號,位元組的第一位是0後面7位是unicode,英文的ASCII和utf-8是一樣滴
  • n位元組的符號,第一個位元組的前n位是1,後面位元組的前兩位一律是10,是的此10就是彼10。

因為所有的英文都是單位元組,而中文在utf-8中通常佔3個位元組,也就是隻有讀到中文字元中間位元組的時候(word[i] & 0xC0) == 0x80) 判斷成立。所以判斷本身是為了完整的讀取一個字元的全部位元組,也就是說中文的subword最小單位只能到單個漢字,而不會有更小的粒度了。而個人感覺漢字粒度並不能像英文單詞的構詞一樣帶來十分有效的資訊,所以Fasttext的這一創新感覺對中文並不會有太多增益。

不過說起拆偏旁部首,螞蟻金服人工智慧部在2018年還真發表過一個引入漢子偏旁部首資訊的詞向量模型cw2vec理論及其實現,感興趣的可以去看看喲~


REF 1. P. Bojanowski, E. Grave, A. Joulin, T. Mikolov, Enriching Word Vectors with Subword Information 2. A. Joulin, E. Grave, P. Bojanowski, T. Mikolov, Bag of Tricks for Efficient Text Classification 3. A. Joulin, E. Grave, P. Bojanowski, M. Douze, H. Jégou, T. Mikolov, FastText.zip: Compressing text classification models 4. https://zhuanlan.zhihu.com/p/64960839

我的部落格即將同步至 OSCHINA 社群,這是我的 OSCHINA ID:OSC_jHtwZy,邀請大家一同入駐:https://www.oschina.net/sharing-plan/apply