fasttext源码解析

转载自知乎

作者:张晓辉
链接:https://zhuanlan.zhihu.com/p/64960839     https://zhuanlan.zhihu.com/p/65687490
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

 

最近花了一些时间通读了fb的fasttext的源码。远离了c++好长一段时间,猛地一看c++,太亲切了。简单的说,fasttextfasttext除了包含word2vec的cbow和skipgram算法,还有对文本进行分类算法,以及对模型进行量化压缩。

fasttext是用c++编写的,源文件都在src文件夹中,包含args、dictionary、fasttext、matrix、meter、model、productquantizer、qmatrix、utils、vector的cc源文件和h头文件,以及http://main.cc。Makefile中,对这些文件进行g++编译,生成一系列的.o文件,最后生成可执行文件fasttext。下面依次介绍这些文件的内容。

1. args

args.h和http://args.cc中定义了Args类,成员变量主要定义了一系列模型参数。比如input为输入的训练集文件,output为训练完成后输出的模型文件,lr学习率,lrUpdate学习率变更步数(默认100),dim词向量维数,ws(window size)窗口大小,epoch数据集训练迭代轮数,minCount词典截断数(出现次数小于这个数字词,将加入都unkown中),minCountLabel(出现次数小于这个数的分类标签,也不会被训练),neg为negative sample时负样本的选择个数,bucket为ngram的总的数量,minnmaxn为ngram的最小和最大程度,thread为训练的线程数,t为随机采样的超参数,初始值为e-4(在dictionary中详述),label的初始值为字符串__label__,在文本分类中,标记了每条训练样本属于哪个分类的字段,pretrainedVectors为预训练的词向量的文件名(训练时词向量初始化为这样的词向量)。以q开始的参数qout,qnorm,cutoff为模型量化的参数。

成员函数主要是为3部分,第一部分为paseArgs, 从命令行解析出来各个参数,赋值给成员变量。第二部分为帮助函数,打印输出这些参数的意义。第三部分是保存这些参数的函数save和读取文件加载这些参数的load。

2. vector

vector.h和http://vector.cc把stl的std::vector<float>封装成类Vector,除了构造函数外,包括获取指针函数data[];清零函数zero;数乘函数mul;求模函数norm;相加Vector函数addVector;相加矩阵某行函数addRow;矩阵向量相乘后,结果赋值函数给Vector的函数mul,以及求最大值下标函数argmax等。

3. matrix

matrix.h和http://matrix.cc把std::vector<float>封装成矩阵类Matrix,当然包含了矩阵行数m和列数n两个成员变量,除了构造函数外,有取地址函数data, 取i,j位置的元素值函数at,取行数列数的三个函数size rows cols,清零函数zero,随机复制函数uniform,某行与Vector内积函数dotRow,某行与Vector相加函数addRow,每行相乘函数multiplyRow,每行相除函数divideRow,某行取l2模函数l2NormRow,以及读取函数load和保存函数save

4. qmatrix和productquantizer

这两个文件分别定义了类QMatrix类ProductQuantizer,把Matrix对象进行量化压缩。

qmatrix.h和http://qmatrix.cc定义了类QMatrix,成员变量m_n_依然为待压缩矩阵mat的行数和列数,压缩成QMatrix矩阵,把每个向量压缩后的编码保存在成员变量codes_中,其行数依然为m_,列数为n_除以dsub(dsub应该是dimension subspace,即子空间的维数),codesize_为QMatrix的元素数量。从构造函数可以看出,接收一个Matrix对象mat,算出codesize_大小后,适配codes_的容量,生成productquantizer指针对象,然后成员函数quantize()调用pq的方法,对mat进行压缩,压缩结果写进codes_。其他QMatrix成员函数与Matrix的函数类似。

productquantizer.h和http://productquantizer.cc定义了乘积量化的具体操作。乘积量化简单的说,就是把较长的向量压缩成较短的向量,这样就节省了存储空间,减少了检索时间。比如我们有10million个向量,每条向量512维,那么就占有512*10m*4个字节(假设每个浮点数4个字节),大概占有20GB空间。

如果我们把每个向量的512维,平均划分成16份,即每一份有32个维度,那么我们将10m向量的起始32维拿出来,进行k-mean聚类(依然是10m个向量,但每个向量长度只有32了),聚成256簇。每一个向量都会属于其中的某一各簇。256个簇可以用8bit的数指示了(从0000 0000到1111 1111,即用0-255来表示),即可以用一个uint8来表示。那么每一个个向量的前32个维度就可以用一个uint8来指示。然后,第二个32维也做类似处理,10m个向量,每个向量大小为32,进行k-means聚类,聚成256簇,继续用所在的簇的标号来表示。这样总共进行16次,就把一个512维的float型向量,压缩成16维的uint8型向量。这16维的每一个维度,都是所在簇的标号。这样进行压缩的话,这10m个向量空间缩小了128倍。

如果理解了上面一段话,就可以看出来这种乘积量化属于有损压缩,但实际检索效果表现还不错。ProductQuantizer类实现了上面这个过程。该类的成员变量有:nbits_默认为8,就是用8bit来记录簇编号;ksub_是簇的大小256;niter_是k-means迭代步数;dim_是前面的n_(和前面例子的512),即原始向量的维数;dsub_就是每份的维度(就是示例中的32维,就构成了一个32维的子空间),nsubq_划分成多少份(多少个子空间)。lastdsub_记录了最后一个子空间的维度,上例中512维划分成16份,刚好可以划分成功,如果是300维划分成16份,就会有余数,这个余数就是最后一个子空间的维度;centroids_记录了所有子空间的聚类的簇的中心坐标点,大小为有dim_*ksub_大小。

ProductQuantizer类的主要成员函数为train(),即k-means的训练函数。train中进行nsubq_步循环,每个循环对一个子空间的向量进行聚类。如果向量的总数大于65536,则进行shuffle(用变量perm打乱次序),只聚类前65536个向量。这样,保证了聚类速度,剩余没有参与聚类的向量,则可以直接选择最近的聚类中心进行划分和标识。将相应的数据用mecpy快速复制到xslice中,用了较多的指针操作。

void ProductQuantizer::train(int32_t n, const real* x) {
//n为原始向量数目,x指向原始向量的起始位置。
  for (auto m = 0; m < nsubq_; m++) {
    if (n<65536)
      std::shuffle(perm.begin(), perm.end(), rng);
    for (auto j = 0; j < np; j++)
      memcpy(xslice.data() + j * d,  x + perm[j] * dim_ + m * dsub_,  d * sizeof(real));
    kmeans(xslice.data(), get_centroids(m, 0), np, d);
   
  }
}
const real* ProductQuantizer::get_centroids(int32_t m, uint8_t i) const {
//get_centroids(m,i) 返回第m个子空间的第i个聚类中心的位置
  if (m == nsubq_ - 1) //如果是最后一个子空间
    return &centroids_[m * ksub_ * dsub_ + i * lastdsub_];
  return &centroids_[(m * ksub_ + i) * dsub_];
}

k-means算法分两步,Estep和Mstep(本质上k-means属于em算法)

void ProductQuantizer::kmeans(const real* x, real* c, int32_t n, int32_t d) {
//x指向要聚类向量的起始位置,c为聚类中心位置,n为当前聚类向量的数量,d为当前每个向量的维度
  for (auto i = 0; i < ksub_; i++)    //训练开始时,将聚类中心随机设置成向量集中的某个向量
    memcpy(&c[i * d], x + perm[i] * d, d * sizeof(real));
  auto codes = std::vector<uint8_t>(n);//n个uint8个数,每个数代表了每个向量所在簇的标号
  for (auto i = 0; i < niter_; i++) {
    Estep(x, c, codes.data(), d, n);
    MStep(x, c, codes.data(), d, n);
  }
}

Estep为簇的划分,即每个向量依次与中心点centroids相比较,选择离其最近的centroid所在的簇。

real ProductQuantizer::assign_centroid(const real* x, const real* c0, uint8_t* code, int32_t d) const {
  const real* c = c0;
  real dis = distL2(x, c, d);
  code[0] = 0;
  for (auto j = 1; j < ksub_; j++) {//与每个聚类中心进行比较
    c += d;
    real disij = distL2(x, c, d);
    if (disij < dis) { code[0] = (uint8_t)j;  dis = disij; }
  }
  return dis;
}
void ProductQuantizer::Estep(const real* x, const real* centroids, uint8_t* codes, int32_t d, int32_t n) const {
//n为向量总数,d为每个向量的维度,x为n个向量的起始位置,centrids为聚类中心点,codes为向量的所在簇的编号(长度为n)
  for (auto i = 0; i < n; i++)//每个向量依次寻找最近的聚类中心
    assign_centroid(x + i * d, centroids, codes + i, d);
}

MStep为根据新划分的簇,重新估算centroids。当然,如果第k个簇没有节点,则选择另外一个较大的簇m,将簇m的中心点给k并随机加减一个常数。

void ProductQuantizer::MStep(const real* x0, real* centroids, const uint8_t* codes, int32_t d, int32_t n) {
  std::vector<int32_t> nelts(ksub_, 0);//记录每个簇有多少个元素,初始化为0
  memset(centroids, 0, sizeof(real) * d * ksub_);//将每个簇的中心点centroid清零
  const real* x = x0;
  for (auto i = 0; i < n; i++) {//簇中心centroid的坐标为该簇所有点的坐标的中心,即坐标和,然后取平均
    auto k = codes[i];  //第i个节点的簇号为k
    real* c = centroids + k * d; //第k个中心点的起始坐标
    for (auto j = 0; j < d; j++) //d个坐标,依次相加
      c[j] += x[j];
    nelts[k]++; //每个簇包含向量的个数增长1
    x += d; //下一个节点
  }

  real* c = centroids;
  for (auto k = 0; k < ksub_; k++) {//对每一个中心点(总共有ksub_个,即256)
    if ((real)nelts[k] != 0)
      for (auto j = 0; j < d; j++)    c[j] /= z;  //每个坐标值依次取平均
    c += d;  //下一个中心点
  }

这样,经过niter步Estep和Mstep迭代,我们就对nsubq_个子空间进行了一一聚类,每个子空间有ksub_个簇,并保存了所有簇的中心点。然后,我们可以用类ProductQuantizer的成员函数compute_codes来对所有的向量进行量化,得到每个向量的量化编码。

void ProductQuantizer::compute_code(const real* x, uint8_t* code) const {
  auto d = dsub_;
  for (auto m = 0; m < nsubq_; m++) {//总共有m个子空间
    if (m == nsubq_ - 1)  d = lastdsub_;//如果是最后一个子空间
    assign_centroid(x + m * dsub_, get_centroids(m, 0), code + m, d);
    //x的每个子空间的向量,与当前空间的所有簇的中心点centorids进行比较,选择最近的那个簇的标号为自己在这个空间的压缩编码
  }
}
void ProductQuantizer::compute_codes(const real* x, uint8_t* codes, int32_t n) const {
//n为向量个数,codes为压缩编码的起始位置,x为n个向量的起始位置
  for (auto i = 0; i < n; i++)//对第i个向量进行压缩编码,并将压缩编码保存到codes中
    compute_code(x + i * dim_, codes + i * nsubq_);
}

5. dictionary

dictionary.h和http://dictionary.cc定义了Dictionary类,该类包含了训练文件读取,字典和ngram构造等等。fasttext训练是多线程的,但读取数据是单线程的,在训练的开始阶段,要把训练集通读一遍,构造好字典后,再开始训练。字典保存在两个std::vector成员变量中,即在words_和word2int_中。word2int_初始化大小为MAX_VOCAB_SIZE=30000000, 且所有值初始化为-1。words_的的每个元素是struct entry型:

enum class entry_type : int8_t { word = 0, label = 1 };
struct entry {
  std::string word;  //单词的字符串
  int64_t count;     //该单词在训练集中出现的次数
  entry_type type;   //类型,word为0,文本分类的label为1
  std::vector<int32_t> subwords; //该词的ngram序列对应的下标
};

成员函数readFromFile依次读取文件中的每一个word(成员函数readWord)(每个word的分隔符是空格或者tab等),来加入到字典words_中(成员函数add)。每次加入时,先将word字符串hash映射映射成一个数h(成员函数hash),word2int_[h]标记了该word在words_中的位置,如果是第一次加入,word2int_[h]的初始值为-1,将word追加到words_的末尾,再将word2int_[h]改为相应的下标。如果words_中也存在该word,只需要将相应的count加一即可。hash函数如下:

uint32_t Dictionary::hash(const std::string& str) const {
  uint32_t h = 2166136261;
  for (size_t i = 0; i < str.size(); i++) {
    h = h ^ uint32_t(int8_t(str[i]));
    h = h * 16777619;
  }
  return h;
}

随着word不断的加入,当词典的容量大于0.75*MAX_VOCAB_SIZE时,就需要对词典words_进行精简。精简方式是有minThreshold控制的,初始为1,当超过阈值时,minThreshold就会增加1,然后出现次数少于minThreshold都会被删掉,这是有成员函数theshold实现的。

void Dictionary::threshold(int64_t t, int64_t tl) {
  sort(words_.begin(), words_.end(), [](const entry& e1, const entry& e2) { if (e1.type != e2.type) { return e1.type < e2.type; } return e1.count > e2.count;  });
  //将字典words_排序,排序的规则是把word拍在label的前面,然后在word和label类型中,都是从大到小排序。即第一关键字是entry_type(word在前,label在后),第二关键字是count
  words_.erase(remove_if(words_.begin(), words_.end(), [&](const entry& e) { return (e.type == entry_type::word && e.count < t) || (e.type == entry_type::label && e.count < tl);}), words_.end());
  //将出现次数小于阈值t的那些数remove到最后,然后删除。这样的话,依然是word在前(按出现次数从大到小排序),label在后,也是从大到小排序
  words_.shrink_to_fit(); //words_到合适大小
  size_ = 0;
  nwords_ = 0;
  nlabels_ = 0;
  std::fill(word2int_.begin(), word2int_.end(), -1); //重新初始化word2int_
  for (auto it = words_.begin(); it != words_.end(); ++it) { //重新将word2int_进行索引,每个word的hash值为h,word2int_的h下标的值,即为该word在words中的位置。这样,就可以非常快的检索到某个词
    int32_t h = find(it->word);
    word2int_[h] = size_++;
    if (it->type == entry_type::word)  nwords_++; 
    if (it->type == entry_type::label) nlabels_++;
  }
}
//size_为整个词典words_的大小,nwords_为词典words_中word的数量,nlabels_为词典words_的数量,而且size_ = nwords_ + nlabels_

读取整个语料后,还会重新执行一次threshold进行清理,这一次用的阈值是args的参数。类Dictionary的成员变量nwords_记录了词典中word的容量,词典中还包含了label标记,nlables_标记了文本分类的总的label数(由此可以看出,一条训练样本如果包含多个label,fasttext也是可以训练多标签分类的),ntokens_是训练预料中word和label的总数量(包含重复次数,而且清理字典时,并不改变这个值)。

然后,调用成员函数initTableDiscard来对成员变量std::vector<float> pdiscard_进行赋值,pdiscard_对应着字典中每个词word的被丢弃的概率。概率公式为:

[公式]

其中t为args的成员变量t,f(word)为word在训练预料中出现的频率。这个函数保证了出现次数多的单词被丢弃的概率更大,因为出现次数特别多的一些词,如the a is等等并没有实际意义,为训练噪音,可以适当丢弃加快训练。

然后调用成员函数initNgrams进行对字典words_的每一个词word进行ngrams初始化

void Dictionary::initNgrams() {
  for (size_t i = 0; i < size_; i++) { //对words_中的每一个词
    std::string word = BOW + words_[i].word + EOW; //词前后分别添加前缀BOW("<")和后缀EOW(">")
    words_[i].subwords.clear();
    words_[i].subwords.push_back(i); //将word自身的编号先加入到ngram中去
    if (words_[i].word != EOS) //读取训练预料构建字典时,每一行读完后,会将EOS(end of sentence)标识符"</s>"加入到字典中,所以字典中的EOS表示了训练预料的行数。
    //所以,如果不是标识符EOS,则进行ngram的计算
      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;
    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);
      }
    }
  }
}
void Dictionary::pushHash(std::vector<int32_t>& hashes, int32_t id) const {
  hashes.push_back(nwords_ + id);
}

computeSubwords中需要注意的是if((word[i] & 0xC0) == 0x80)这句代码。0xC0是1100 0000的十六进制表示,与某个字符进行"位与"操作后,就是提取这个字符的前两个bit位的数字,如果结果为0x80(0100 0000),即该if代码的意思是如果字符word[i]的起始两位是01的话。那怎么整体理解这几句代码呢?我的理解是,如果训练语料是全英文的,就没必要写这么复杂,但如果是其他语种的语料,这样写法就可以按照相应的utf-8编码来拆分该语种的每个词。

UTF-8是一种变长字节编码方式。对于某一个字符的UTF-8编码,如果只有一个字节则其最高二进制位为0;如果是多字节,其第一个字节从最高位开始,连续的二进制位值为1的个数决定了其编码的位数,其余各字节均以10开头。UTF-8最多可用到6个字节。

上图列举了utf-8码转成一般的二进制码的规则,这里的a、b、c、d、e代表0和1。因此UTF-8中可以用来表示字符编码的实际位数最多有31位。除去那些控制位(每字节开头的10等),这些x表示的位与UNICODE编码是一一对应的,位高低顺序也相同。

如果理解了这些,上面的代码也就不难理解了,如果读到第i个字符的前两位为10,则表示这个字节为纯字符位,不是一个完整的词,则continue,继续读下一个字节。如果该字节前两位不是10,则表示该字节是word的起始位,则用while循环把后续以10开头的字节(当前word的后续部分)全部读入到变量ngram中;while结束后,表示一个完整的word已经被读入到变量ngram中了,变量n用来统计ngram中word数量,在for循环中增加1。如果变量ngram中的word已经超过minn了,则可以把当前的ngram的hash值,加上字典中词的数量,取余后加入到变量ngrams中,这样,就表示了word的一个ngram被计算得到了,其放入了相应entry的subwords中。如果n大于maxn,则当前位置i的的遍历结束,以下一个i为其实位置,重新执行这样的步骤,依次找到长度在[minn,maxn]的ngram串。 可以看出,minnmaxn来控制ngrams的范围,如果不需要ngram,则这两个值都可以设置成0,关闭ngram功能。同样,虽然每个word的ngram是顺序放入到相应entry的substring中的,但我们并不关心ngram的相对位置。还有一点值得注意的是,ngram的序号是在词典所有词nwords_的后面。为ngram预留的容量是bucket(bucket初始化为2000000)。如果两个ngram的hash值一样,则没有解决hash冲突,认为二者是一样的。

这样,遍历完整个输入文件,我们就将建成了整个字典。接下来多线程训练时,我们还需要重新打开文件,为每个线程平均分配一部分内容,然后一行一行的读取文件内容。这时,需要用到类Dictionary的成员函数getline,getline被重载成两个函数,一个为cbow和skipgram模型服务,一个为文本分类模型supervised服务。两个函数内容基本是一致的,区别在于第三个参数,前者的第三个参数是一个随机数生成器,和上面提到的pdiscard_结合,来随机放弃该行某个词,并且忽略每一行的label,不加入每个word的ngram信息(但在fasttext.c中,又加入到训练集中);而后者的第三个参数是label,用来返回分类标记lable(类型是std::<vector>,可以存储多个分类);该函数设计较为复杂,如果读到一个词是word,如果是未登录词,则该词的ngram加入到训练集中;如果在词典中,不用ngram机制的话,则只把这个word加入到训练集word中,用ngram机制的话,则将这个词的ngram加入的训练集中;读完该行内容后,则把该行的Ngram加入到训练集合中,该行的Ngram是什么呢?上面提到类args中的参数wordNgrams,这个数就控制了Ngram的取法。当Ngram为3时,那么该行的连续2个word,3个word都将会加入到训练集中。

int32_t Dictionary::getLine(std::istream& in, std::vector<int32_t>& words, std::minstd_rand& rng) const //cbow和skipgram模型训练的数据读入
int32_t Dictionary::getLine(std::istream& in, std::vector<int32_t>& words, std::vector<int32_t>& labels) const{ //文本分类模型supervised的数据读入
//in为文件输入流,words要保存该行所有的word,label保存该行的label
  std::vector<int32_t> word_hashes;
  std::string token;
  int32_t ntokens = 0;

  if (in.eof()) { in.clear(); in.seekg(std::streampos(0)); }//如果文件读取结束,则从头开始读

  words.clear();
  labels.clear();
  while (readWord(in, token)) { //每次读取一个word,word以空格或者tab为分隔符
    uint32_t h = hash(token); //获取当前word的hash值
    int32_t wid = getId(token, h); //得到当前word的在词典words_的下标
    entry_type type = wid < 0 ? getType(token) : getType(wid); 
    //如果wid为负,则为未登录词,那么如果其中包含字符串"label",则为label,否则为word。如果在词典中,直接查询其type
    ntokens++;
    if (type == entry_type::word) {  //如果是word
      addSubwords(words, token, wid);  //将这个word的ngram加入到变量words中
      word_hashes.push_back(h); //将这个word的hash值保存
    }
    else if (type == entry_type::label && wid >= 0) //如果是label且该label在词典中,则将该label在words_中的序号保存到labels中返回
      labels.push_back(wid - nwords_); 
    if (token == EOS)  break;  //如果读到该行末尾,退出
  }
  addWordNgrams(words, word_hashes, args_->wordNgrams);  //将该行的Ngram加入到words中
  return ntokens;
}
void Dictionary::addSubwords(std::vector<int32_t>& line, const std::string& token, int32_t wid) const { //将token代表的word的ngram加入到line中,wid为该word在词典的下标
  if (wid < 0) //未登录词
    if (token != EOS) //且不是句子末尾
      computeSubwords(BOW + token + EOW, line); //将该未登录词的ngram加入到word中
  else {
    if (args_->maxn <= 0)  line.push_back(wid); //如果不用ngram,则仅将该词在词典words_的序号保存到到line中
    else{  //使用ngram
      const std::vector<int32_t>& ngrams = getSubwords(wid); //直接查词典words_中,word的ngrams,并保存到line中
      line.insert(line.end(), ngrams.cbegin(), ngrams.cend());
    }
  }
}
void Dictionary::addWordNgrams(std::vector<int32_t>& line,  const std::vector<int32_t>& hashes,  int32_t n) const { 
//将一行预料的Ngram保存到line中,参数n为类Args的wordNgrams
  for (int32_t i = 0; i < hashes.size(); i++) {
    uint64_t h = hashes[i];
    for (int32_t j = i + 1; j < hashes.size() && j < i + n; j++) { //该行任意连续2个,连续3个,...,连续wordNgram个词都要hash映射,保存到line中
      h = h * 116049371 + hashes[j];
      pushHash(line, h % args_->bucket); //将hash值保存到line中
    }
  }
}

如果看到这里,还没有看晕,我只能说,你真是个人才!!!

下图详细解释了重要的几个参数的数据结构,std::vector<entry> words_保存了训练集的词典,每个元素为entry类型,words_的长度为size_,分两部分,前部分为word部分,有nwords_个,并且按出现次数降序排序;后一部分为label部分,nlables_个,也按出现次数降序排列。而虚线框起来的,长为bucket是每个word的ngram的hash值,也包括了训练集中每行数据的Ngram的hash值。假设一行文本为"dog chasing cat",那么words_的第x1个元素保存了“dog”(包含出现次数,类型,3gram分别为<do, dog, og>),第x3元素保存了“cat”,而这两个word的ngram分别做hash,hash值就为在bucket中的下标,而entry保存的其实是这些下标值;同时,这句话的Ngram为“dog chasing”,“chasing cat”(设N=2),也会hash到这个bucket中。而word2int_中则是保存了一个word在词典words_中的位置,这样就可以快速查找,例如,假设"dog"的hash值为h,而word2int_的h位置的值为x1,则表示"dog"在词典的位置是x1。例如我们想知道"dog"在训练集中出现了多少次,那么我们先求出"dog"的哈希值h,然后找到word2int_的h位置的值为x1,则知道了words_的x1位置保存了"dog"(当然,查找时要处理hash冲突问题,代码中使用的是简单的线性向后探测法);当然,如果word2int_的h位置x1为-1,则表示"dog"是未登录词。

6. models

model.h和http://model.cc定义了类Model,定义的重要成员有:

  1. args_:Args类的智能指针变量(std::shared_ptr<Args>)

2. wi_wo_:Matrix类的智能指针变量(std::shared_ptr<Matrix>),wi_和wo_即为(word2vec)模型的输入矩阵weight input和输出矩阵weight out。这两个矩阵也正是cbow、skipgram和supervised要学习的参数。cbow和skipgram模型的参数矩阵wi_和wo_的行数为字典中词的个数,列数为词向量维数,即矩阵的每一行代表着一个词的词向量,而supervised模型(文本分类模型)是类似的,只是矩阵wo_的行数为类别数量,代表每个类别的向量。

3. 成员变量hidden_word2vec中的 [公式],即wi_中某些行的和,代表了某个词 [公式] (及其ngram,在skipgram中)或者 [公式] (及其ngram,在cbow中)或者某一文档所有词(及其ngram和Ngram,在文本分类supervised中)的特征向量。hidden_与wo_的若干行(word2vec中的 [公式])(在negative sample中是选定的负样本对应的行,在hierarchy softmax中是Huffman树从根到 [公式] 的路径上的那些节点对应的行,在supervised是wo_全部的行)的内积,经过sigmoid(skigram、cbow,supervised中多分类)或者softmax(supervised的单分类)后,保存在成员变量output_中(在word2vec两个伪代码中循环体内的 [公式] )。而hidden_和output_(二者均为Vector类)的长度分别保存在成员变量hsz_成员变量osz_(int类型)。成员变量grad_相当于word2vec 两个伪代码中循环体的 [公式] ,保存了wo_反向传播的梯度值,用于更新 [公式](skipgram)或者 [公式] (cbow)或者某一文档的所有词(supervised)的词向量。

4. 三个std::vector成员变量tree保存了hierarchy softmax的二叉树结构,paths和codes保存了每个word的路径和编码。注意的是,tree的size为2*vocab_size-1,因为Huffman的叶节点为vocab_size,内部节点数为vocab_size-1。在训练supervised时,可使用negative sample和hierarchy softmax结构,但会存在准确地很低, 模型不收敛的情况,所以训练文本分类,要慎重使用ns和hs。

struct Node {
  int32_t parent;  //父节点的index
  int32_t left;    //左孩子的index
  int32_t right;   //右孩子的index
  int64_t count;   //该节点出现的次数
  bool binary;     //该节点编码,若该节点是父节点的左子树,则为false;否则,为true
};

构造Huffman树的代码非常精炼:

void Model::buildTree(const std::vector<int64_t>& counts) {
//构建Huffman树,counts保存了词典中所有词出现的次数(有序,从大到小)
  tree.resize(2 * osz_ - 1); //申请2*vocab_size-1的空间,就是Huffman树的所有节点(osz即为词典大小,即osz_=vocab_size)
  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];  //将每个词出现的次数依次赋值给tree的前vocab_size个节点

  int32_t leaf = osz_ - 1;
  int32_t node = osz_; 
  //leaf和node分别指向考察的两个节点,其中leaf指向叶节点,node指向内部节点。
  //因为从osz_-1反向到0,counts是逐渐递增的。而归并后的内部节点,从osz_到2*osz_-1也是递增的,所以leaf向左移动,node向右移动,不需要回退就能遍历完全部节点,从而完成构建。
  //而leaf和node中间的节点(不包含二者),则是已经归并好的节点。
  for (int32_t i = osz_; i < 2 * osz_ - 1; i++) { //起始有osz_个叶节点,归并osz_-1次,就能归并到最终的根节点。从tree的第osz_个元素开始,为树的内部节点
    int32_t mini[2]; //两个变量,最终指向最小的那两个待归并的节点
    for (int32_t j = 0; j < 2; j++)
    {
      if (leaf >= 0 && tree[leaf].count < tree[node].count) //如果还有叶子节点没有归并完,则比较叶子节点和内部节点的大小,如果叶子节点小,则表示leaf指向的叶子节点需要归并。
        mini[j] = leaf--;   //此时,该节点是待归并节点,min[j]记录下来其编号;然后将leaf左移一个元素,指向待考察的叶节点。
      else   //如果叶节点已经归并完成或者当前叶节点大于当前的内部节点,则直接用min[j]记录当前内部节点为待归并节点,然后将node右移一个元素,指向下一个待考察的内部节点
        mini[j] = node++;
    }  //从上面的过程可以看出,min[0]保存了待归并的最小节点,min[1]记录了次小节点,二者可能指向叶节点,也可能指向内部节点。
       //在for循环中,i的朝右移动速度要超过node的移动速度,即i总是在node的右侧,只有for循环结束后,node才追上i,二者才相等
    tree[i].left = mini[0];  //较小的那个节点为i的左子树
    tree[i].right = mini[1]; //较大的那个节点为i的右子树
    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;  //子节点的若为父节点的右子树,则code为true;否则,保持原本初始化值false
  }
  for (int32_t i = 0; i < osz_; i++) { //遍历所有叶节点,得到path和code,加入到paths和codes中。
    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_);//注意path和code记录的是反序,即从叶节点到跟节点的顺序。正序和反序不重要,只要path和code能够对应起来即可。
      code.push_back(tree[j].binary);
      j = tree[j].parent;
    }
    paths.push_back(path);
    codes.push_back(code);
  }
}

5. 成员变量negatives_保存negative sample的预选词,负样本就从negtives_中随机挑。为了保证出现语料中次数较多的词被挑中的概率大,出现次数较小的词被挑中的概率小,每个词在negatives_中保存的副本数要正比于其在词典中出现的数量。

void Model::initTableNegatives(const std::vector<int64_t>& counts) { //对negtives_进行初始化,参数coounts保存的每个词出现的次数,词典words_的第i个词出现次数为count[i]
  real z = 0.0;
  for (size_t i = 0; i < counts.size(); i++)
    z += pow(counts[i], 0.5);   //每个词出现次数的平方,和为z
  for (size_t i = 0; i < counts.size(); i++) {
    real c = pow(counts[i], 0.5);
    for (size_t j = 0; j < c * NEGATIVE_TABLE_SIZE / z; j++) //NEGATIVE_TABLE_SIZE为1000w,表示negatives_的容量. c/z表示此i在z中的权重,乘以1kw表示在negatives_中出现了多少次。
      negatives_.push_back(i);  //  c/z表示此i在z中的权重,乘以1kw表示在negatives_中出现了多少次,所以就往negatives_中加入多少次
  }
  std::shuffle(negatives_.begin(), negatives_.end(), rng);  //随机打乱顺序。选择负样本的时候,成员函数getNegative顺序挑选就可以了。
}

6. 为了加快训练,避免在训练的时候一次次计算sigmoid和log函数,就先计算出来,训练的时候,直接查表即可。成员变量std::vector<real> t_sigmoid_和t_log_分别保存了这两个表。成员函数initSigmoid和initLog用来初始化这两个变量,而成员函数sigmoid和log就是查表,获取相应的值。

void Model::initSigmoid() { //初始化t_sigmoid_, MAX_SIGMOID=8, SIGMOID_TABLE_SIZE=512, LOG_TABLE_SIZE=512
  for (int i = 0; i < SIGMOID_TABLE_SIZE + 1; i++)
    real x = real(i * 2 * MAX_SIGMOID) / SIGMOID_TABLE_SIZE - MAX_SIGMOID; //512次循环,得到[-8, 8]的sigmoid值,每两个间隔1/32(0.03125)
    t_sigmoid_.push_back(1.0 / (1.0 + std::exp(-x)));
}
real Model::sigmoid(real x) const {
  if (x < -MAX_SIGMOID)  return 0.0;  //如果x小于-8,直接返回0.0
  else if (x > MAX_SIGMOID) return 1.0; //如果大于8, 直接返回1.0
  else {
    int64_t i = int64_t((x + MAX_SIGMOID) * SIGMOID_TABLE_SIZE / MAX_SIGMOID / 2); 
    //因为t_sigmoid_的第i个元素保存了(i*2*MAX_SIGMOID)/SIGMOID_TABLE_SIZE-MAX_SIGMOID的sigmoid值,那么反过来,当求x的sigmoi值时,只需要反过来算即可,即上式
    return t_sigmoid_[i];
  }
}

void Model::initLog() { //初始化t_log_,计算(0,1]的log值,共有512个,其中每隔1.0/512,即第i个元素保存的是i/512的log值
  for (int i = 0; i < LOG_TABLE_SIZE + 1; i++) {
    real x = (real(i) + 1e-5) / LOG_TABLE_SIZE;
    t_log_.push_back(std::log(x));
  }
}
real Model::log(real x) const { //
  if (x > 1.0)  return 0.0; //如果大于1,直接返回0
  int64_t i = int64_t(x * LOG_TABLE_SIZE); //直接查询第x*512个值,即为x的log值
  return t_log_[i];
}

7. 成员函数update是最重要的函数,对应着word2vec 中伪代码的for循环,功能为梯度上升更新模型参数wi_和wo_。

void Model::update(const std::vector<int32_t>& input, const std::vector<int32_t>& targets, int32_t targetIndex, real lr) {
//模型更新函数。在cbow中,input词w的Context(w)集合及它们的ngrams,targets是语料中某一行数据,targets[targetIndex]为w;
//在skipgram中,input为w及其ngrams,targets是训练集中某一行数据,targets[targetIndex]为Context(w)的某一个词
//在supervised中,input为某一行训练数据,targets则为label的序列(可以多分类训练),若targetIndex为-1,则进行多分类训练;若大于等于0,则训练分类targets[targetIndex]
  if (input.size() == 0)  return;

  computeHidden(input, hidden_); //将input里面的词所代表的词向量相加,取均值后保存到hidden_中。伪代码中的第2步

  if (targetIndex == kAllLabelsAsTarget) //训练多分类,其中kAllLabelAsTarget为-1,训练文本多分类
    loss_ += computeLoss(targets, -1, lr);
  else {
    assert(targetIndex >= 0);
    assert(targetIndex < osz_);
    loss_ += computeLoss(targets, targetIndex, lr); //更新wo_,并记录grad_,伪代码中的for循环
  }

  nexamples_ += 1; //模型参数更新次数加1

  if (args_->model == model_name::sup)
    grad_.mul(1.0 / input.size()); //如果是分类,则将梯度gard_进行均值话

  for (auto it = input.cbegin(); it != input.cend(); ++it) //更新wi_,即伪代码第4步
    wi_->addRow(grad_, *it, 1.0);
}

real Model::computeLoss(const std::vector<int32_t>& targets, int32_t targetIndex, real lr) {
  real loss = 0.0;

  if (args_->loss == loss_name::ns)             loss = negativeSampling(targets[targetIndex], lr);    //negative sample
  else if (args_->loss == loss_name::hs)        loss = hierarchicalSoftmax(targets[targetIndex], lr); //hierarchy softmax
  else if (args_->loss == loss_name::softmax)   loss = softmax(targets[targetIndex], lr);             //文本单分类
  else if (args_->loss == loss_name::ova)       loss = oneVsAll(targets, lr);                         //文本多分类
  else                                          throw std::invalid_argument("Unhandled loss function for this model.");
  return loss;
}

real Model::binaryLogistic(int32_t target, bool label, real lr) {  //逻辑斯蒂回归(二分类),for循环中的前四行分别为伪代码中的for循环内的四行
  real score = sigmoid(wo_->dotRow(hidden_, target));  //计算hidden_和wo_[target]的内积,然后sigmoid
  real alpha = lr * (real(label) - score);             //伪代码第二步
  grad_.addRow(*wo_, target, alpha);                   //保存梯度值
  wo_->addRow(hidden_, target, alpha);                 //更新wo_的参数
  if (label)  return  -log(score);                     
  else  return  -log(1.0 - score);                      //返回loss值,即目标函数的大小
}

real Model::negativeSampling(int32_t target, real lr) {  //negative sample机制的梯度更新,伪代码中for循环的那行
  real loss = 0.0;
  grad_.zero();
  for (int32_t n = 0; n <= args_->neg; n++) {
    if (n == 0)  loss += binaryLogistic(target, true, lr);         //wo_[target]对应的为正样本
    else loss += binaryLogistic(getNegative(target), false, lr);   //随机选取args_->neg个负样本
  }
  return loss;
}

real Model::hierarchicalSoftmax(int32_t target, real lr) { //hierarchy softmax机制的梯度更新,伪代码中的for循环
  real loss = 0.0;
  grad_.zero();
  const std::vector<bool>& binaryCode = codes[target];
  const std::vector<int32_t>& pathToRoot = paths[target];
  for (int32_t i = 0; i < pathToRoot.size(); i++)    //依次从叶子节点走向根节点(伪代码中从根节点到叶子节点)
    loss += binaryLogistic(pathToRoot[i], binaryCode[i], lr);
  return loss;
}

void Model::computeOutput(Vector& hidden, Vector& output) const {
  if(quant_ && args_->qout)  output.mul(*qwo_, hidden);
  else  output.mul(*wo_, hidden);     //矩阵wo_(行数为分类类别数,列数为词向量维数)与向量hidden的乘积,结果保存在output中
  }
}

void Model::computeOutputSigmoid(Vector& hidden, Vector& output) const {
  computeOutput(hidden, output);  //wo_与hidden的乘积,结果保存在output中
  for (int32_t i = 0; i < osz_; i++)
    output[i] = sigmoid(output[i]);
}

void Model::computeOutputSoftmax(Vector& hidden, Vector& output) const { //计算output的softmax值。每个元素减去最大值,是为了更加精确的计算,减小误差
  computeOutput(hidden, output); //wo_与hidden的乘积,结果保存在output中
  real max = output[0], z = 0.0;
  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);
    z += output[i];   //求和
  }
  for (int32_t i = 0; i < osz_; i++) output[i] /= z; //计算softmax值
}

void Model::computeOutputSoftmax() {   //hidden_与output_
  computeOutputSoftmax(hidden_, output_);
}

real Model::softmax(int32_t target, real lr) { //文本单目标分类。此时的wo_的行数为类别数量,列数为词向量的维度
  grad_.zero();                   //梯度清0
  computeOutputSoftmax(); //计算output的sofmax值,output是wo_与hidden_的内积,其中第target个元素为正样本,其他的label为负样本
  for (int32_t i = 0; i < osz_; i++) {
    real label = (i == target) ? 1.0 : 0.0;   //正样本or负样本?
    real alpha = lr * (label - output_[i]);   //我们的目标是为了让相应分类的概率值尽可能的接近label,也就是最大化相应的softmax概率值。伪代码中的softmax
    grad_.addRow(*wo_, i, alpha);   //保存梯度值
    wo_->addRow(hidden_, i, alpha); //更新wo_
  }
  return  -log(output_[target]); //返回损失
}

real Model::oneVsAll(const std::vector<int32_t>& targets, real lr) {  //多目标分类,用sigmoid函数。正样本的话,为\sigma,负样本为1-\sigma
  real loss = 0.0;
  for (int32_t i = 0; i < osz_; i++) {
    bool isMatch = utils::contains(targets, i);
    loss += binaryLogistic(i, isMatch, lr);
  }
  return loss;
} 

8. 成员函数predict、dfs和findKBest,这几个函数主要是用于预测cbow、skipgram和supervised结果的。是的,三个模型都可以使用这个函数。

在训练模型时,初始时先随机化模型参数,然后使用一条条训练样本不断迭代模型,先前向传播得出结果,然后比对结果与真实label的差距,然后用梯度上升法更新模型参数,目的是把我们的模型训练得贴近于真实数据。而predict函数其实就是单独的预测(inference)过程,或者说是前向传播过程。

三个模型都选择概率最大的前k个预测值,都是先算出hidden,但接下来的处理方式就有所不同了。一、在supervised和negative sample机制的word2vec中,计算出wo_的每一行与所有hidden_的内积,经过softmax或者sigmoid后,将概率值保存到output中,之后使用最小堆在osz_个元素中选择最大的k个,具体入堆、出堆、调整堆的操作就不详述了,代码中直接使用了std::push_heap和std::pop_heap来操作堆的。二、对于hierarchy softmax机制的cbow和supervised来说,得到hidden_后,使用递归操作,从Huffman树的根节点递归地向叶子节点遍历,遇到叶子节点,就得到了从根到该叶子结点路径上的概率值,该概率值就是该叶子节点的预测值;递归地依次访问Huffman树上的每一个叶子节点,同样使用最小堆操作来选取前k大的叶子节点。

这些就是model的具体实现。

7. fasttext

fasttext.h和http://fasttext.cc定了FastText类和实现,成员变量包括了:指向Args类的对象args_、Dictionary类的对象dict_、Matrix类的对象input_和output_(即前文的wi_和wo_)、QMatrix类的对象qinput_和qoutput_(它们分别量化压缩了wi_和wo_)、Model类的对象model_等。这些对象的生成均在这里。上文所述的各种类成员和方法,均为这个类服务。

主要的成员函数包括

  1. 保存模型:(1). saveModel(保存模型的版本号,保存args_和dict_,保存input_和output_或者qinput_和qoutput_等),(2). saveVectors(保存词向量,首先保存词向量个数和维度,然后保存每个词本身,及其词向量,此时的词向量是其本身和ngrams的向量之和,然后各个维度取平均),(3). saveOutput(保存output_,如果是word2vec模型,就是输出矩阵wo_;如果是文本分类模型,就是保存各个label的向量)
  2. 加载模型:(1). loadModel(与saveModel完全对称,读入文件,依次初始化args_、dict_、input_和output_(或者qintput_和output_)等) (2). loadVectors(加载词向量,与saveVectors对称。读入词的个数和维度,然后依次读出每个词及其词向量。args_和dict_在外部已经生成,此时生成变量input_,并将这些词向量对应复制给input_)
  3. 训练模型:train()为训练模型cbow、skipgram和supervised的函数。首先实例Args类和Dictionary类的对象args_(来源于命令行)和dict_(读一遍训练语料,构建词典dict_),然后实例Matrix类的对象input_和output_(实例input_时,可以加载预训练词向量),然后startThreads()开始多线程训练,函数trainThread()实现了多线程训练:每个线程分别从文件的等分点开始读(比如8个线程,就将每个线程的读取起始点平均分散到训练集的8个位置),如果读到文件尾,则从头读,相当于有多个线程分别在文件的不同位置进行训练语料的读取。在每个线程有单独的lr(学习率),且localTokenCount记录当前线程的读到的词的个数,并不断更新给类变量tokenCount_(该变量记录了所有线程读取训练样本数量的和)。当tokenCount_的数目大于epoch倍的词典数,则停止每个线程的读取和训练。各个线程读取某一行数据后,根据训练任务,用成员函数cbow()训练cbow模型,用成员函数skipgram()训练skipgram,用成员函数supervised训练单目标或多目标分类。这三个函数相对较为简单,具体就是设置好训练样本的input和target后,调用Model类的update函数更新模型参数(见上文)。
  4. 文本分类预测:predictLine()是预测文本分类的。读取语料的一行,然后用Model类的predict()来预测label,并将预测值保存在变量predictions中。
  5. 文本分类test:test()是在语料(测试集上)测试文本分类模型的Precision、Recall和F1-Score的;和predictLine()都是调用类Model的predict()方法。
  6. 相似词汇:getNN()是输入一个word,寻找与其相关度最高的k个词。
  7. 类比:getAnalogies()是word2vec的类比,如进行词向量操作France-Paris+German,会得到Berlin。我们输入三个词wodA、wordB和wordC后,进行词向量加减wodA-wordB+wordC,寻找离这个结果语义最近的前k个词
  8. 量化模型:quantize()对input_和output_(可选)进行量化压缩,并保存成qinput_和qoutput_两个QMatrix类对象。对input_量化时,要选择input_中norm最大的前cutoff个词的向量进行量化(此时需要对字典进行缩剪),甚至可以选择重新训练。

8. http://main.cc

主函数中就是接收命令行参数,根据命令行第二个参数cbow、skipgram、supervised、test、quantize、nn、analogies和predict的不同,分别调用类FastText中的成员函数来执行执行上文提到的6类任务。

9. *sh demo

word-vector-example.sh提供了训练skipgram词向量的demo。先下载enwik9训练集和rw测试集,编译源文件后执行fasttext skipgram训练,并对模型结果进行评估。classification-examples.sh和classification-results.sh是文本分类demo,下载训练集,如dbpedia,然后执行fasttext supervised进行训练,只用用test评估结果,用predict命令预测。quantization-example.sh是将文本分类模型进行压缩(只支持文本分类模型,不支持word2vec的压缩),先supervised训练,然后用quantize命令压缩模型文件,最后用test来验证量化结果。

fasttext的训练很快,quantization-example.sh一分钟就能训练结束,P@1能到70%,压缩后能到95%左右。如果多加一些epoch,就能达到98%以上的准确率。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值