【手撕 - 自然語言處理】手撕 FastText 源碼(02)基於字母的 Ngram 實現

作者:LogM

本文原載於 https://segmentfault.com/u/logm/articles ,不允許轉載~

1. 源碼來源

FastText 源碼:https://github.com/facebookresearch/fastText

本文對應的源碼版本:Commits on Jun 27 2019, 979d8a9ac99c731d653843890c2364ade0f7d9d3

FastText 論文:

[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

2. 概述

之前的博客介紹了"分類器的預測"的源碼,裏面有一個重點沒有詳細展開,就是"基於字母的 Ngram 是怎麼實現的"。這塊論文裏面關於"字母Ngram的生成"講的比較清楚,但是對於"字母Ngram"如何加入到模型中,講的不太清楚,所以就求助於源碼,源碼裏面把這塊叫做 Subwords

看懂了源碼其實會發現 Subwords 加入到模型很簡單,就是把它和"詞語"一樣對待,一起求和取平均。

另外,我自己再看源碼的過程中還有個收穫,就是關於"中文詞怎麼算subwords",之前我一直覺得 Subwords 對中文無效,看了源碼才知道是有影響的。

最後是詞向量中怎麼把 Subwords 加到模型。這部分我估計大家也不怎麼關心,所以我就相當於寫給我自己看的,解答自己看論文的疑惑。以skipgram爲例,輸入的 vector 和所要預測的 vector 都是單個詞語subwords相加求和的結果。

3. 怎麼計算 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);
                }
            }
        }
    }
}

4. 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];
    }
  }
}

5. 詞向量中的 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;
  }
}
發佈了52 篇原創文章 · 獲贊 19 · 訪問量 5萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章