无所不能的Embedding2 - 词向量三巨头之FastText详解

Fasttext是FaceBook开源的文本分类和词向量训练库。最初看其他教程看的我十分迷惑,咋的一会ngram是字符一会ngram又变成了单词,最后发现其实是两个模型,一个是文本分类模型[Ref2],表现不是最好的但胜在结构简单高效,另一个用于词向量训练[Ref1],创新在于把单词分解成字符结构,可以infer训练集外的单词。这里拿quora的词分类数据集尝试了下Fasttext在文本分类的效果, 代码详见 https://github.com/DSXiangLi/Embedding

Fasttext 分类模型

Fasttext分类模型结构很直观是一个浅层的神经网络。先对文本的每个词做embedding得到\(w_i\), 然后所有词的embedding做平均得到文本向量\(w_{doc}\),然后经过1层神经网络对label进行预测

\[\begin{align} w_{doc} &= \frac{1}{n}\sum_{i=1}^n w_{i} \\ p &= \sigma {(\beta \cdot w_{doc})} \\ \end{align} \]

只说到这里,其实会发现和之前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看到这里第一个反应是英文可以这么搞,因为英文可以分解成字符,且一些前缀后缀是有特殊含义的,中文咋整,拆偏旁部首么?!来看代码答疑解惑

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<int32_t>& ngrams,
    std::vector<std::string>* 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

posted @ 2020-09-05 14:22  风雨中的小七  阅读(2970)  评论(0编辑  收藏  举报