做者:LogMhtml
本文原載於 https://segmentfault.com/u/logm/articles ,不容許轉載~c++
FastText 源碼:https://github.com/facebookresearch/fastTextgit
本文對應的源碼版本:Commits on Jun 27 2019, 979d8a9ac99c731d653843890c2364ade0f7d9d3
github
FastText 論文:segmentfault
[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函數
以前的博客介紹了"分類器的預測"的源碼,裏面有一個重點沒有詳細展開,就是"基於字母的 Ngram 是怎麼實現的"。這塊論文裏面關於"字母Ngram的生成"講的比較清楚,可是對於"字母Ngram"如何加入到模型中,講的不太清楚,因此就求助於源碼,源碼裏面把這塊叫作 Subwords
。ui
看懂了源碼其實會發現 Subwords
加入到模型很簡單,就是把它和"詞語"同樣對待,一塊兒求和取平均。this
另外,我本身再看源碼的過程當中還有個收穫,就是關於"中文詞怎麼算subwords",以前我一直以爲 Subwords
對中文無效,看了源碼才知道是有影響的。編碼
最後是詞向量中怎麼把 Subwords
加到模型。這部分我估計你們也不怎麼關心,因此我就至關於寫給我本身看的,解答本身看論文的疑惑。以skipgram
爲例,輸入的 vector 和所要預測的 vector 都是單個詞語
與subwords
相加求和的結果。
Subwords
以前的博客有提到,Dictionary::getLine
這個函數的做用是從輸入文件中讀取一行,並將全部的Id(包括詞語的Id,SubWords的Id,WordNgram的Id)存入到數組 words
中。
首先咱們要來看看 Subwords
的 Id 是怎麼生成的,對應的函數是 Dictionary::addSubwords
。
// 文件:src/dictionary.cc // 行數:378 int32_t Dictionary::getLine( std::istream& in, // `in`是輸入的文件 std::vector<int32_t>& words, // `words`是全部的Id組成的數組(包括詞語的id,SubWords的Id,WordNgram的Id) std::vector<int32_t>& labels) const { // 由於FastText支持多標籤,因此這裏的`labels`也是數組 std::vector<int32_t> word_hashes; std::string token; int32_t ntokens = 0; reset(in); words.clear(); labels.clear(); while (readWord(in, token)) { // `token` 是讀到的一個詞語,若是讀到一行的行尾,則返回`EOF` uint32_t h = hash(token); // 找到這個詞語位於哪一個hash桶 int32_t wid = getId(token, h); // 在hash桶中找到這個詞語的Id,若是負數就是沒找到對應的Id entry_type type = wid < 0 ? getType(token) : getType(wid); // 若是沒找到對應Id,則有多是label,`getType`裏會處理 ntokens++; if (type == entry_type::word) { addSubwords(words, token, wid); // 這個函數是咱們要講的重點 word_hashes.push_back(h); } else if (type == entry_type::label && wid >= 0) { labels.push_back(wid - nwords_); } if (token == EOS) { break; } } addWordNgrams(words, word_hashes, args_->wordNgrams); return ntokens; }
來到 Dictionary::addSubwords
,能夠看到重點是 Dictionary::getSubwords
。
// 文件:src/dictionary.cc // 行數:325 void Dictionary::addSubwords( std::vector<int32_t>& line, // 咱們要把 `Subwords` 的 Id 插入到數組 `line` 中 const std::string& token, // `token` 是當前單詞的字符串 int32_t wid) const { // `wid` 是當前單詞的 Id if (wid < 0) { // out of vocab if (token != EOS) { computeSubwords(BOW + token + EOW, line); } } else { if (args_->maxn <= 0) { // in vocab w/o subwords line.push_back(wid); // 若是用戶關閉了 `Subwords` 功能,則不計算 } else { // in vocab w/ subwords const std::vector<int32_t>& ngrams = getSubwords(wid); // 這句是重點,獲取了 `Subwords` 對應的 Id line.insert(line.end(), ngrams.cbegin(), ngrams.cend()); } } }
來到 Dictionary::getSubwords
,看來每一個單詞的 subwords
是事先計算好的。
// 文件:src/dictionary.cc // 行數:85 const std::vector<int32_t>& Dictionary::getSubwords(int32_t i) const { assert(i >= 0); assert(i < nwords_); return words_[i].subwords; }
咱們找找是哪裏初始化了 Subwords
。在 Dictionary::initNgrams
中。
// 文件:src/dictionary.cc // 行數:197 void Dictionary::initNgrams() { for (size_t i = 0; i < size_; i++) { std::string word = BOW + words_[i].word + EOW; // 爲單詞增長開頭和結尾符號,好比`where`變爲`<where>` words_[i].subwords.clear(); words_[i].subwords.push_back(i); // 論文裏說了,這個單詞自己也算是`subwords`的一種 if (words_[i].word != EOS) { computeSubwords(word, words_[i].subwords); // 這個是重點 } } }
來到Dictionary::computeSubwords
。這邊涉及到UTF-8
的編碼。
看懂了UTF-8
的編碼,應該是比較容易能理解這段代碼的。這段代碼的計算方式,和論文裏給出的計算方式是一致的。
同時,這段代碼也解答了"中文詞怎麼算subwords"的問題。
// 文件:src/dictionary.cc // 行數:172 void Dictionary::computeSubwords( const std::string& word, std::vector<int32_t>& ngrams, std::vector<std::string>* substrings=nullptr) const { for (size_t i = 0; i < word.size(); i++) { std::string ngram; if ((word[i] & 0xC0) == 0x80) { // 和UTF-8的編碼形式有關,判斷是否是多字節編碼的中間字節 continue; } 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()))) { int32_t h = hash(ngram) % args_->bucket; pushHash(ngrams, h); if (substrings) { substrings->push_back(ngram); } } } } }
Subwords
是怎麼加入到模型的以前的博客有提到,Model::computeHidden
中的參數input
就是Id組成的數組(包括詞語的Id,SubWords的Id,WordNgram的Id)。如今主要看 Vector::addRow
是怎麼實現的。
// 文件:src/model.cc // 行數:43 void Model::computeHidden(const std::vector<int32_t>& input, State& state) const { Vector& hidden = state.hidden; hidden.zero(); for (auto it = input.cbegin(); it != input.cend(); ++it) { hidden.addRow(*wi_, *it); // 求和,`wi_`是輸入矩陣,要把`input`的每一項加到`wi_`上 } hidden.mul(1.0 / input.size()); // 而後取平均 }
來到Vector::addRow
,咱們找到了重點是 addRowToVector
函數。可是這邊用到了多態,須要分析下繼承關係才知道 addRowToVector
函數位於哪一個文件,我這邊略過繼承關係的分析過程,直接來到 QuantMatrix::addRowToVector
。
// 文件:src/vector.cc // 行數:62 void Vector::addRow(const Matrix& A, int64_t i) { assert(i >= 0); assert(i < A.size(0)); assert(size() == A.size(1)); A.addRowToVector(*this, i); // 這個是重點 }
來到 QuantMatrix::addRowToVector
,代碼有點涉及矩陣底層運算了,說實話,我沒看懂。但我爲何知道是求和呢?由於論文說了,對於單個詞語
,hidden 層的操做是把單詞的向量加和求平均,從這些代碼看,subwords
和單個詞語
是一致的,因此我才知道subwords
也是加和求平均。
// 文件:src/quantmatrix.cc // 行數:75 void QuantMatrix::addRowToVector(Vector& x, int32_t i) const { real norm = 1; if (qnorm_) { norm = npq_->get_centroids(0, norm_codes_[i])[0]; } pq_->addcode(x, codes_.data(), i, norm); }
// 文件:src/productquantizer.cc // 行數:197 void ProductQuantizer::addcode( Vector& x, const uint8_t* codes, int32_t t, real alpha) const { auto d = dsub_; const uint8_t* code = codes + nsubq_ * t; for (auto m = 0; m < nsubq_; m++) { const real* c = get_centroids(m, code[m]); if (m == nsubq_ - 1) { d = lastdsub_; } for (auto n = 0; n < d; n++) { x[m * dsub_ + n] += alpha * c[n]; } } }
Subwords
而後是詞向量中怎麼把 Subwords
加到模型。
這部分我估計你們也不怎麼關心,因此我就至關於寫給我本身看的,解答本身看論文的疑惑。以skipgram
爲例,輸入的 vector 和所要預測的 vector 都是單個詞語
與subwords
相加求和的結果。
// 文件:src/productquantizer.cc // 行數:393 void FastText::skipgram( Model::State& state, 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(state.rng); const std::vector<int32_t>& ngrams = dict_->getSubwords(line[w]); // 重點1 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, state); // 重點2 } } } }
// 文件:src/model.cc // 行數:70 void Model::update( const std::vector<int32_t>& input, const std::vector<int32_t>& targets, int32_t targetIndex, real lr, State& state) { if (input.size() == 0) { return; } computeHidden(input, state); Vector& grad = state.grad; grad.zero(); real lossValue = loss_->forward(targets, targetIndex, state, lr, true); // 重點 state.incrementNExamples(lossValue); if (normalizeGradient_) { grad.mul(1.0 / input.size()); } for (auto it = input.cbegin(); it != input.cend(); ++it) { wi_->addVectorToRow(grad, *it, 1.0); } }
softmax的計算多看幾遍應該仍是能看明白的。梯度的計算,建議結合論文的公式看,加入 subwords
後,梯度的求取再也不是softmax多分類的那種求梯度方式了。
// 文件:src/loss.cc // 行數:322 real SoftmaxLoss::forward( const std::vector<int32_t>& targets, int32_t targetIndex, Model::State& state, real lr, bool backprop) { computeOutput(state); // 這個是重點 assert(targetIndex >= 0); assert(targetIndex < targets.size()); int32_t target = targets[targetIndex]; if (backprop) { // 計算梯度的過程,結合論文公式看 int32_t osz = wo_->size(0); for (int32_t i = 0; i < osz; i++) { real label = (i == target) ? 1.0 : 0.0; real alpha = lr * (label - state.output[i]); state.grad.addRow(*wo_, i, alpha); wo_->addVectorToRow(state.hidden, i, alpha); } } return -log(state.output[target]); };
// 文件:src/loss.cc // 行數:305 void SoftmaxLoss::computeOutput(Model::State& state) const { Vector& output = state.output; output.mul(*wo_, state.hidden); // 結合論文公式看,和通常的softmax不同 real max = output[0], z = 0.0; int32_t osz = output.size(); for (int32_t i = 0; i < osz; i++) { max = std::max(output[i], max); } for (int32_t i = 0; i < osz; i++) { output[i] = exp(output[i] - max); // 應該是爲了防止數值計算溢出,計算結果和原始的softmax公式一致 z += output[i]; } for (int32_t i = 0; i < osz; i++) { output[i] /= z; } }