Word2Vec源码解析

一、word2vec训练参数

先根据输入的train_file文件创建两个数组,vocab和vocab_hash,vocab是词库数组,一维数组,每一个对象都是vocab_word类型;vocab_hash是词库的hash表,将词按hash映射到词库,vocab_hash[word_hash] = 词在词库的位置,在从语料文件建立词库时,对读入的词快速定位到其在词库中的位置。

struct vocab_word { // 词的结构体,存储包括词本身、词频、哈夫曼编码、编码长度、哈夫曼路径                                                                    
    long long cn;// 词频                                                                                  
    int *point;// 哈夫曼树中从根节点到该词的路径,路径的索引要特别注意,在下面的构建哈夫曼树中会说明                                                                                     
    char *word, *code, codelen;// 分别是:词,哈夫曼编码,编码长度                                                                      
};
//训练参数格式
-train text8 -output vectors.bin -cbow 0 -size 200 -window 5 -negative 0 -hs 1 -sample 1e-3 -threads 12 -binary
//训练参数意义
size: 对应代码中layer1_size, 表示词向量的维度,默认值是100。
train: 对应代码中train_file, 表示语料库文件路径。
save-vocab: 对应代码中save_vocab_file, 词汇表保存路径。
read-vocab: 对应代码中read_vocab_file, 表示已有的词汇表文件路径,直接读取,不用从语料库学习得来。
debug: 对应代码中debug_mode, 表示是否选择debug模型,值大于1表示开启,默认是2。开启debug会打印一些信息。
binary: 对应代码中全局变量binary,表示文件保存方式,1表示按二进制保存,0表示按文本保存,默认是0.
cbow: 对应代码中cbow, 1表示按cbow模型训练, 0表示按skip模式训练,默认是1。
alpha: 对应代码中alpha,表示学习率。skip模式下默认为0.025, cbow模式下默认是0.05。
output: 对应代码中output_file, 表示词向量保存路径。
window: 对应代码中window,表示训练窗口大小。默认是5
sample: 对应代码中sample,表示下采样阀值。
hs: 对应代码中hs, 表示按huffman softmax模式训练。默认是0, 表示不使用hs。
negative: 对应代码中negative, 表示按负采样模式训练, 默认是5。值为0表示不采用负采样训练;如果使用,值一般为3到10。
threads: 对应代码中num_threads,训练线程数,一般为12。
iter: 对应代码中iter,训练迭代次数,默认是5.
min-count: 对应代码中min_count,表示最小出现频率,低于这个频率的词会被移除词汇表。默认值是5
classes: 对应代码中classes,表示聚类中心数, 默认是0, 表示不启用聚类。
min-count: read

二、总体流程

main函数的流程

训练部分的流程

2.1 TrainModel()函数

void TrainModel() {
  long a, b, c, d;
  FILE *fo;
  //创建多线程,num_threads 为
  pthread_t *pt = (pthread_t *)malloc(num_threads * sizeof(pthread_t));
  printf("Starting training using file %s\n", train_file);
  starting_alpha = alpha;//初始学习速率

  //词汇文件是否存在,存在则直接读取,不存在则学习,得到vocab和vocab_hash
  if (read_vocab_file[0] != 0){
            printf("ReadVocab\n");
            ReadVocab();
  }
  else{
            printf("LearnVocabFromTrainFile\n");//词汇文件不存在,read_vocab_file[0]=0
            LearnVocabFromTrainFile();
  }
 if (save_vocab_file[0] != 0) SaveVocab();//根据需要,可以将词表中的词和词频输出到文件
 if (output_file[0] == 0) return;//训练后词向量的输出文件,由binary和classes共同控制输出结果,没有的话直接返回
 InitNet(); // 网络结构初始化
 if (negative > 0) InitUnigramTable();//暂时略过
 start = clock();//开始计时
 //其实网络的实现都是在TrainModelThread中,神经网络分成多线程计算,
 //计算完成之后再进行k-mean聚类。TrainModel生成线程,配置线程。
//TrainModelThread的详细解析在第五部分。
 for (a = 0; a < num_threads; a++) pthread_create(&pt[a], NULL, TrainModelThread, (void *)a);
 //让主线程等待子线程执行结束后,主线程再结束。
 //这样,防止主线程很快执行完后,退出,上一行创建的子线程没有机会执行。  
 for (a = 0; a < num_threads; a++) pthread_join(pt[a], NULL);
 fo = fopen(output_file, "wb");//打开输出文件,
  if (classes == 0) {//不用聚类,输出词向量到文件中
    // 首先打印出词汇表的大小,再打印出词向量维度
    fprintf(fo, "%lld %lld\n", vocab_size, layer1_size);
    for (a = 0; a < vocab_size; a++) {
      fprintf(fo, "%s ", vocab[a].word);//按照词汇表输出词汇
     //binary: 对应代码中全局变量binary,表示文件保存方式,1表示按二进制保存,0表示按文本保存,默认是0.
      if (binary) for (b = 0; b < layer1_size; b++) fwrite(&syn0[a * layer1_size + b], sizeof(real), 1, fo);
      else for (b = 0; b < layer1_size; b++) fprintf(fo, "%lf ", syn0[a * layer1_size + b]);
      fprintf(fo, "\n");
    }
  } 
}

三、构建词库和hash

word2vec支持两种数据,一种是已经统计好词频的形式,如“中国 6 地球 8”;另外一种是只做了分词,并没有统计词频的形式,如“我 爱 北京 天安门”。在TrainModel()这个函数中我们可以看到

// 如果是带有频率的词表则读入词表和频率,否则读词表并统计频率 
if (read_vocab_file[0] != 0) ReadVocab(); else LearnVocabFromTrainFile();

3.1 LearnVocabFromTrainFile()这个函数是读取不带频率的文件所用的

/*相关变量说明*/
 const int vocab_hash_size = 30000000;  
 int *vocab_hash;//词库的hash表,将词按hash映射到词库,vocab_hash[word_hash] = 词在词库的位置,
//在从语料文件建立词库时,对读入的词快速定位到其在词库中的位置,训练时不会用到

//从训练文件中获取所有词汇并构建词表和hash
void LearnVocabFromTrainFile() {
  char word[MAX_STRING];//MAX_STRING为一个word的最大长度
  FILE *fin;
  long long a, i;
  for (a = 0; a < vocab_hash_size; a++) vocab_hash[a] = -1;// 0~30000000
  fin = fopen(train_file, "rb");//打开训练文件 train_file(作为训练参数输入)
  if (fin == NULL) {//找不到该文件,直接退出
    printf("ERROR: training data file not found!\n");
    exit(1);
  }
  vocab_size = 0;//词库中实际的词个数,初始为0
  // 将</s>添加到词表最开始的位置
  AddWordToVocab((char *)"</s>");//添加字符</s>,该函数的定义请看4.2部分,添 
 //加一个单词到词表尾部,并返回该单词所在的位置
  while (1) {
    ReadWord(word, fin);//ReadWord()函数参看4.4部分,读取训练文件
    if (feof(fin)) break;//读取到文件末尾
    //读到每一个word
    train_words++;//要训练的词总数
    // debug_mode模式打印进度,暂时不管
    if ((debug_mode > 1) && (train_words % 100000 == 0)) {
      printf("%lldK%c", train_words / 1000, 13);
      fflush(stdout);
    }
    i = SearchVocab(word);//查找词在词库中的位置,词库中存在该词,返回词的位置,否则返回-1,参看4.5部分
    if (i == -1) {//不在词库中
      a = AddWordToVocab(word);//添加进去
      vocab[a].cn = 1;//词频唯一
    } else vocab[i].cn++;//不用添加,增加词频即可
      /**
         * 如果词表的规模大于哈希空间的70%,则移除一部分低频词
         * 每添加一个词,判断一次词库大小,若大于填充因子上限,删除一次低频词
         * 注意,这里有一个问题,在删除低频词时,语料文件可能是未处理完的,只是读取了一部分词
         * 所以词库当前状态下的词频信息是局部的,不是训练文件全局的
         * 这时删除低频词时,是把局部的低频词删除,但局部低频词未必是全局低频词,例如,一个词在训练文件的前一部分少量出现,但在后面部分,大量出现
         * 不过考虑到词库规模(千万级),这个问题也不太可能出现
         */
    if (vocab_size > vocab_hash_size * 0.7) ReduceVocab();//去除低频词,参看4.6部分
  }
SortVocab();// 把词表中的单词按词频排序 ,参看4.7部分
 if (debug_mode > 0) {
    printf("Vocab size: %lld\n", vocab_size);
    printf("Words in train file: %lld\n", train_words);
  }
  file_size = ftell(fin);//训练文件大小,ftell得到,多线程训练时会对文件进行分隔,
  //用于定位每个训练线程开始训练的文件位置
  printf("file_size: %lld\n", file_size);
  fclose(fin);
}

3.2 AddWordToVocab()是添加一个单词到词表尾部

//词表结构
struct vocab_word *vocab;
vocab = (struct vocab_word *)calloc(vocab_max_size,  sizeof(struct vocab_word));
struct vocab_word {
long long cn;//词频,从训练集中计数得到或直接提供词频文件
int *point;//Haffman树中从根节点到该词的路径,存放的是路径上每个节点的索引
//word为该词的字面值,code为该词的haffman编码,codelen为该词haffman编码的长 度
char *word, *code, codelen;
};
/*相关变量说明*/
long long vocab_max_size = 1000;//vocab_max_size词库规模(词库容量),在建立词库的过程中,
//当词库规模到达vocab_max_size时会对词库扩容,每次扩增vocab_max_size个容量
//for (a = 0; a < vocab_hash_size; a++) vocab_hash[a] = -1;// 0~30000000

//添加一个单词到词表尾部
int AddWordToVocab(char *word) {
  unsigned int hash, length = strlen(word) + 1;
  if (length > MAX_STRING) length = MAX_STRING;//保证单词的长度不超过MAX_STRING
  // vocab_size为词库中实际的词个数
  vocab[vocab_size].word = (char *)calloc(length, sizeof(char));
  strcpy(vocab[vocab_size].word, word);
  vocab[vocab_size].cn = 0;//词频暂时记为0
  vocab_size++;//词个数增加
 // 如果词表快到上限了,为词表重新申请空间
  if (vocab_size + 2 >= vocab_max_size) {
    vocab_max_size += 1000;
    vocab = (struct vocab_word *)realloc(vocab, vocab_max_size * sizeof(struct vocab_word));
  }
  hash = GetWordHash(word);//获取word的hash值,GetWordHash()请看4.3部分
  printf("%lld\n",hash);
  while (vocab_hash[hash] != -1) hash = (hash + 1) % vocab_hash_size;//30000000;开放定址法解决冲突,
 //哈希冲突时线性探测继续顺序往下查找空白位置;vocab_hash[hash] != -1,则说明已经有这个hash了
  vocab_hash[hash] = vocab_size - 1;// 记录在词汇表中的存储位置
  return vocab_size - 1;// 返回添加的单词在词汇表中的存储位置,末尾前一个
}

3.3 GetWordHash()是添加一个单词到词表尾部

//对一个单词进行哈希得到它的哈希值 
int GetWordHash(char *word) {
  unsigned long long a, hash = 0;//打印long long类型:printf("%lld\n",word[1]);
/*计算hash值*/
  for (a = 0; a < strlen(word); a++) hash = hash * 257 + word[a];
  hash = hash % vocab_hash_size;//词库大小取模
  return hash;
}

3.4 ReadWord()是从文件中读取单个单词

// 从文件中读取单个单词,假设单词之间通过空格或者tab键或者EOL键进行分割的
void ReadWord(char *word, FILE *fin) {
  int a = 0, ch;//a - 用于向word中插入字符的索引;ch - 从fin中读取的每个字符

  //整个循环就是为了读取一个单词
  while (!feof(fin)) {//如果fp文件指针没有到达文件尾
    ch = fgetc(fin);//读取一个词,准确地说是一个字符
    if (ch == 13) continue;//回车,开始新的一行,重新开始while循环读取下一个字符
    //当遇到space(' ') + tab(\t) + EOL(\n)时,认为word结束,UNIX/Linux中‘\n’为一行的结束符号,
    //windows中为:“<回车><换行>”,即“\r\n”;Mac系统里,每行结尾是“<回车>”,即“\r”
    if ((ch == ' ') || (ch == '\t') || (ch == '\n')) {//word结束
      if (a > 0) {
        if (ch == '\n') ungetc(ch, fin);//退回到流中
        break;//如果读到了单词就直接退出
      }
      if (ch == '\n') {//最后还是换行符,说明文件为空,直接退出
        strcpy(word, (char *)"</s>");//将</s>赋予给word
        return;
      } else continue;//此时a=0,且遇到的为\t or ' ',直接跳过取得下一个字符
    }
    word[a] = ch;
    a++;
    if (a >= MAX_STRING - 1) a--;   // MAX_STRING为一个word的最大长度,超过了就直接截断
  }
  word[a] = 0;//最后一个字符是'\0'
  printf(word);
  putchar('|');
}

3.5 SearchVocab()查找词在词库中的位置,词库中存在该词,返回词的位置,否则返回-1

// 查找词在词库中的位置,词库中存在该词,返回词的位置,否则返回-1
int SearchVocab(char *word) {
  unsigned int hash = GetWordHash(word);//获取hash值
  while (1) {
    if (vocab_hash[hash] == -1) return -1;//词库中不存在该词
    if (!strcmp(word, vocab[vocab_hash[hash]].word)) return vocab_hash[hash];//vocab_hash[hash] != -1且word一样,直接返回位置
    hash = (hash + 1) % vocab_hash_size;//vocab_hash[hash] != -1且word不一样,冲突策略
  }
  return -1;
}

3.6 ReduceVocab() 删除低频词

/*变量说明*/

// 从语料文件建立词库时,为了控制词库规模,会在词库的hash表达到填充因子上限时,调用该方法删除一些低频词
void ReduceVocab() {
  int a, b = 0;
  unsigned int hash;
 //vocab_size为词库里的实际词汇数
  for (a = 0; a < vocab_size; a++) if (vocab[a].cn > min_reduce) {
 //移位
    vocab[b].cn = vocab[a].cn;
    vocab[b].word = vocab[a].word;
    b++;
  } else free(vocab[a].word);
  vocab_size = b;//实际词汇数变了
  //处理hash映射
  for (a = 0; a < vocab_hash_size; a++) vocab_hash[a] = -1;//重新全部为-1
  for (a = 0; a < vocab_size; a++) {//实际词汇数
    // Hash will be re-computed, as it is not actual
    hash = GetWordHash(vocab[a].word);//重新计算hash
   //冲突了就重新寻址
    while (vocab_hash[hash] != -1) hash = (hash + 1) % vocab_hash_size;
    vocab_hash[hash] = a;//没冲突这就是位置
  }
  fflush(stdout);
  min_reduce++;//阈值变大
}

3.7 SortVocab() 把词表中的单词按词频排序

// 比较两个词的词频大小,用于对词库排序
int VocabCompare(const void *a, const void *b) {
    return ((struct vocab_word *)b)->cn - ((struct vocab_word *)a)->cn;
}

// 把词表中的单词按词频排序 
void SortVocab() {
  int a, size;
  unsigned int hash;
  // 快排排序,按照词频从大到小排
  qsort(&vocab[1], vocab_size - 1, sizeof(struct vocab_word), VocabCompare);
  for (a = 0; a < vocab_hash_size; a++) vocab_hash[a] = -1;//清楚原来的hash映射
  size = vocab_size;
  train_words = 0;
  for (a = 0; a < size; a++) {
     // 如果当前单词词频小于规定的阈值,则删除词表最后一个词 (因为已经从大到小排序了)
    if (vocab[a].cn < min_count) {
      vocab_size--;
      free(vocab[vocab_size].word);//删除最后一个词
    } else {
    // 排序以后词表的序改变了,需要重新计算哈希值 
      hash=GetWordHash(vocab[a].word);//计算hash值
     //说明hash冲突了,线性寻址
      while (vocab_hash[hash] != -1) hash = (hash + 1) % vocab_hash_size;
      vocab_hash[hash] = a;//找到位置
      train_words += vocab[a].cn;//重新计算train_words 
    }
  }
 // 因为删了一些词,所以重新定义词表大小 
  vocab = (struct vocab_word *)realloc(vocab, (vocab_size + 1) * sizeof(struct vocab_word));
  // Allocate memory for the binary tree construction
// 给每一个词汇的Huffman编码和路径申请空间 
  for (a = 0; a < vocab_size; a++) {
    vocab[a].code = (char *)calloc(MAX_CODE_LENGTH, sizeof(char));
    vocab[a].point = (int *)calloc(MAX_CODE_LENGTH, sizeof(int));
  }
}

3.8 ReadVocab() 读取词频文件,过程和LearnVocabFromTrainFile差不多

// 如果是带有频率的词表则读入词表和频率,否则读词表并统计频率 
if (read_vocab_file[0] != 0) ReadVocab(); else LearnVocabFromTrainFile();
//因此ReadVocab的过程和LearnVocabFromTrainFile差不多

//从词汇表文件中读词并构建词表和hash表
//由于词汇表中的词语不存在重复,因此与LearnVocabFromTrainFile相比没有做重复词汇的检测
void ReadVocab() {
  long long a, i = 0;
  char c;
  char word[MAX_STRING];
 //打开词汇表文件
  FILE *fin = fopen(read_vocab_file, "rb");
  if (fin == NULL) {
    printf("Vocabulary file not found\n");
    exit(1);
  }
 //初始化hash词表
  for (a = 0; a < vocab_hash_size; a++) vocab_hash[a] = -1;
  vocab_size = 0;//实际词汇数初始化
//开始处理词汇表文件
  while (1) {
    ReadWord(word, fin);//从文件中读入一个词
    if (feof(fin)) break;
    //将该词添加到词表中,创建其在hash表中的值,并通过输入的词汇表文件中的值 
    //来更新这个词的词频
    //不存在重复的问题,所以不用search
    a = AddWordToVocab(word);//返回在词汇表中的位置
    fscanf(fin, "%lld%c", &vocab[a].cn, &c);//读取词频,并设置词频
    i++;
  }
  SortVocab();//排序
  if (debug_mode > 0) {
    printf("Vocab size: %lld\n", vocab_size);
    printf("Words in train file: %lld\n", train_words);
  }
  fin = fopen(train_file, "rb");// 还得打开以下训练文件好知道文件大小是多少
  if (fin == NULL) {
    printf("ERROR: training data file not found!\n");
    exit(1);
  }
  fseek(fin, 0, SEEK_END);//将文件指针移至文件末尾
  file_size = ftell(fin);//得到训练文件大小
  fclose(fin);
}

3.9 SaveVocab() 输出单词和词频到文件(Vocab.txt)

void SaveVocab() {
  long long i;
  FILE *fo = fopen(save_vocab_file, "wb");
  for (i = 0; i < vocab_size; i++) fprintf(fo, "%s %lld\n", vocab[i].word, vocab[i].cn);
  fclose(fo);
}

四、初始化网络结构

4.1 初始化网络参数

void InitNet() {
  long long a, b;
  unsigned long long next_random = 1;
  //syn0存储的是词表中每个词的词向量,源码中使用一个real(float)类型的一维数组表 示,注意是一个一维数组!
  //容量大小为vocab_size * layer1_size(词向量的维度大小,也是隐藏层的大小),即 词汇量 * 词向量维度
  //调用posiz_memalign来为syn0分配内存,对齐的内存,大小为vocab_size * layer1_size * sizeof(real),
  //也就是每个词汇对应一个layer1_size的向量
  a = posix_memalign((void **)&syn0, 128, (long long)vocab_size * layer1_size * sizeof(real));
  if (syn0 == NULL) {printf("Memory allocation failed\n"); exit(1);}//没有分配到内存,退出程序
  if (hs) {//基于hierarchical softmax的模型
    //syn1存储的是Haffman树中每个非叶节点的向量
    a = posix_memalign((void **)&syn1, 128, (long long)vocab_size * layer1_size * sizeof(real));
    if (syn1 == NULL) {printf("Memory allocation failed\n"); exit(1);}//未分配到内存,退出程序
    for (a = 0; a < vocab_size; a++) for (b = 0; b < layer1_size; b++)
     syn1[a * layer1_size + b] = 0;////初始化syn1为0,初始化非叶节点的向量为0
  }
  if (negative>0) {//基于negative Sampling模型
    //负采样时,存储每个样本对应的词向量,一维数组,第i个词的词向量
   //为syn1neg[i * layer1_size, (i + 1) * layer1_size - 1]
    a = posix_memalign((void **)&syn1neg, 128, (long long)vocab_size * layer1_size * sizeof(real));
    if (syn1neg == NULL) {printf("Memory allocation failed\n"); exit(1);}//未分配到内 存,退出程序
    for (a = 0; a < vocab_size; a++) for (b = 0; b < layer1_size; b++)
     syn1neg[a * layer1_size + b] = 0;
  }
 //初始化词向量syn0,每一维的值为[-0.5, 0.5]/layer1_size范围内的随机数
  for (a = 0; a < vocab_size; a++) for (b = 0; b < layer1_size; b++) {
    next_random = next_random * (unsigned long long)25214903917 + 11;
    syn0[a * layer1_size + b] = (((next_random & 0xFFFF) / (real)65536) - 0.5) / layer1_size;
  }
  CreateBinaryTree(); //创建Haffman二叉树
}

4.2 构建哈夫曼树

  • 哈夫曼树
    一般得到霍夫曼树后我们会对叶子节点进行霍夫曼编码,由于权重高的叶子节点越靠近根节点,而权重低的叶子节点会远离根节点,这样我们的高权重节点编码值较短,而低权重值编码值较长。这保证的树的带权路径最短,也符合我们的信息论,即我们希望越常用的词拥有更短的编码。如何编码呢?在word2vec中,约定编码方式和上面的例子相反,即约定左子树编码为1,右子树编码为0,同时约定左子树的权重不小于右子树的权重。在有n个叶子节点的哈夫曼树中,其节点总数为2n-1
    Word2Vec中的Huffman树.png
  • 哈夫曼编码


    哈夫曼编码.png

    哈夫曼编码.png
 //利用统计到的词频构建Haffman二叉树
 //根据Haffman树的特性,出现频率越高的词其二叉树上的路径越短,即二进制编码越短
void CreateBinaryTree() {
 //用来暂存一个词到根节点的Haffman树路径
 //MAX_CODE_LENGTH是point域和code域大小 ,路径长度其实就是haffman编码 
的函数
 //int *point; 霍夫曼树中从根节点到该词的路径,存放路径上每个非叶结点的索引
  long long a, b, i, min1i, min2i, pos1, pos2, point[MAX_CODE_LENGTH];
  char code[MAX_CODE_LENGTH];//用来暂存一个词的Haffman编码
 //内存分配,Haffman二叉树中,若有n个叶子节点,则一共会有2n-1个节点 
 //count数组前vocab_size个元素为Haffman树的叶子节点,初始化为词表中所有词的词频, count数组后vocab_size个元素为Haffman书中即将生成的非叶子节点(合并节点)的词频,初始化为一个大值1e15
  long long *count = (long long *)calloc(vocab_size * 2 + 1, sizeof(long long));
 //binary数组中前vocab_size存储的是每一个词的对应的二进制编码,后面初始化的是0,用来存储生成节点的编码
  long long *binary = (long long *)calloc(vocab_size * 2 + 1, sizeof(long long));
 //parent_node数组中前vocab_size存储的是每一个词的对应的父节点,后面初始化的是0,用来存储生成节点的父节点
  long long *parent_node = (long long *)calloc(vocab_size * 2 + 1, sizeof(long long));
 //count数组的初始化
  for (a = 0; a < vocab_size; a++) count[a] = vocab[a].cn;
  for (a = vocab_size; a < vocab_size * 2; a++) count[a] = 1e15;
 //以下部分为创建Haffman树的算法,默认词表已经按词频由高到低排序
  //</s>词也包含在树内
  pos1 = vocab_size - 1;//末尾
  pos2 = vocab_size;//末尾后面一个
  //构建霍夫曼树,最多进行vocab_size-1次循环操作,每次添加一个节点,即可构成完整的树
  for (a = 0; a < vocab_size - 1; a++) {
    //  'min1, min2'分别用于存储最小和次小节点,注意vocab中的词是已经按照cn排好序的了,是按照降序排列的
    //pos1表示取最原始的词对应的词频,而pos2表示取合并最小值形成的词频
    //连续两次取,两次取的时候代码操作时一模一样的
   //第一个if查找最小的权重位置
    if (pos1 >= 0) {
      if (count[pos1] < count[pos2]) { //如果count[pos1]比较小,则pos1左移,反之pos2右移
        min1i = pos1;
        pos1--;
      } else {
        min1i = pos2;
        pos2++;
      }
    } else {//pos1<0,说明叶子节点已经被合并完了,只能往右边找非叶子节点进行合并
      min1i = pos2;
      pos2++;
    }
   //第二个if查找最小的权重位置
    if (pos1 >= 0) {
      if (count[pos1] < count[pos2]) { //如果count[pos1]比较小,则pos1左移,反之pos2右移
        min2i = pos1;
        pos1--;
      } else {
        min2i = pos2;
        pos2++;
      }
    } else {//pos1<0,说明叶子节点已经被合并完了,只能往右边找非叶子节点进行合并
      min2i = pos2;
      pos2++;
    }
   //在count数组的后半段存储合并节点的词频(即最小count[min1i]和次小 count[min2i]词频之和)
    count[vocab_size + a] = count[min1i] + count[min2i];//a
    parent_node[min1i] = vocab_size + a;//父节点的位置
    parent_node[min2i] = vocab_size + a;//父节点的位置
    binary[min2i] = 1;//词频较大的为1,左为1,右为0(因为有组合,所以有编码)
  }
 // 建好了hufuman树之后,就需要分配code了,注意这个hufuman树
//是用一个数组来存储的,并不是我们常用的指针式链表
// 顺着父子关系找回编码,vocab_size个词汇
  for (a = 0; a < vocab_size; a++) {
    b = a;//从第一个叶子节点开始
    i = 0;
   //找到一个word的huffman编码
    while (1) {
      code[i] = binary[b];//这个位置对应的编码
      point[i] = b;//在point数组中增加路径节点的编号
      i++;//Haffman编码的当前长度,从叶子结点到当前节点的深度
      b = parent_node[b];//找到父节点位置
      if (b == vocab_size * 2 - 2) break; //由于Haffman树一共有vocab_size*2-1个
      // 节点,所以vocab_size*2-2为根节点
    }
    // 以下要注意的是,同样的位置,point总比code深一层
    vocab[a].codelen = i;//编码长度为i,编码长度赋值,少1,没有算根节点
   //Haffman编码和路径都应该是从根节点到叶子结点的,因此需要对之前得到的code和point进行反向
    vocab[a].point[0] = vocab_size - 2;// // 逆序,把第一个赋值为root(即2*vocab_size - 2 - vocab_size)
    for (b = 0; b < i; b++) {
      vocab[a].code[i - b - 1] = code[b];//逆序,换成vocab[a].code[i - 1-b] = code[b]更 好理解
//路径逆序,point的长度比code长1,即根节点,point数组“最后”一个值是负的,point路径是为了定位节点位置,叶子节点即是词本身,不用定位,所以训练时这个负数是用不到的
      vocab[a].point[i - b] = point[b] - vocab_size;//注意这个索引对应的是huffman树中的非叶子节点,对应syn1中的索引, 因为非叶子节点都是在vocab_size * 2 + 1 的后(vocab_size + 1)个
    }
  }
//释放内存,point、code、codelen都已经得到
  free(count);
  free(binary);
  free(parent_node);
}
第一个for循环.png

五、模型训练

5.1 预生成expTable

word2vec计算过程中用上下文预测中心词或者用中心词预测上下文,都需要进行预测;而word2vec中采用的预测方式是逻辑回归分类,需要用到sigmoid函数,具体函数形式为:

sigmoid函数

sigmoid函数图像

在训练过程中需要用到大量的sigmoid值计算,如果每次都临时去算ex的值,将会影响性能;当对精度的要求不是很严格的时候,我们可以采用近似的运算。在word2vec中,将区间-MAX_EXP, MAX_EXP等距划分为EXP_TABLE_SIZE(默认值1000)等份,并将每个区间的sigmoid值计算好存入到expTable中。在需要使用时,只需要确定所属的区间,属于哪一份,然后直接去数组中查找。expTable初始化代码如下:

expTable = (real *)malloc((EXP_TABLE_SIZE + 1) * sizeof(real));       //初始化expTable,近似逼近sigmoid(x)值,x区间为[-MAX_EXP, MAX_EXP],分成EXP_TABLE_SIZE份
//将[-MAX_EXP, MAX_EXP]分成EXP_TABLE_SIZE份
for (i = 0; i < EXP_TABLE_SIZE; i++) {
  //注意:在代码中,作者使用的是小于EXP_TABLE_SIZE,实际的区间是[−6,6)[−6,6)
  expTable[i] = exp((i / (real)EXP_TABLE_SIZE * 2 - 1) * MAX_EXP);   // Precompute the exp() table
  expTable[i] = expTable[i] / (expTable[i] + 1);                     // Precompute f(x) = x / (x + 1)
}

5.2 CBOW模型

在CBOW模型中,总共有三层,分别是输入层,映射层和输出层。如下图所示:


CBOW模型.png

hs模式和negative模式中,输入层到映射层的处理是一样的,仅仅是映射层到输出层的处理不一致。输入层到映射层的具体操作是:将上下文窗口中的每个词向量求和,然后再平均,得到一个和词向量一样维度的向量,假设叫上下文向量,这个向量就是映射层的向量。


CBOW模型算法总体过程.png

CBOW模型算法总体过程.png

5.3 Skip-Gram模型


skip-gram模型总体算法.png

5.4 TrainModelThread()函数,实现神经网络,进行训练

//实现神经网络,进行训练
void *TrainModelThread(void *id) {
  // word: 在提取句子时用来表示当前词在词表中的索引,也就是说向sen中添加单词用,句子完成后表示句子中的当前单词
 // last_word 上一个单词,辅助扫描窗口,记录当前扫描到的上下文单词
 // sentence_length 当前处理的句子长度,当前句子的长度(单词数)
 // sentence_position 当前处理的单词在当前句子中的位置(index)
 //cw:窗口长度(中心词除外)
  long long a, b, d, cw, word, last_word, sentence_length = 0, sentence_position = 0;
 //word_count: 当前线程当前时刻已训练的语料的长度
 //last_word_count: 当前线程上一次记录时已训练的语料长度
 // last_word_count:保存值,以便在新训练语料长度超过某个值时输出信息
 // sen 单词数组,表示句子,//sen:当前从文件中读取的待处理句子,存放的是每个 词在词表中的索引
  long long word_count = 0, last_word_count = 0, sen[MAX_SENTENCE_LENGTH + 1];
 //l1:在skip-gram模型中,在syn0中定位当前词词向量的起始位置
 //l2:在syn1或syn1neg中定位中间节点向量或负采样向量的起始位置
  long long l1, l2, c, target, label, local_iter = iter;
  unsigned long long next_random = (long long)id;
  real f, g;// f e^x / (1/e^x),fs中指当前编码为是0(父亲的左子节点为0,右为1)的概率,ns中指label是1的概率
  // g 误差(f与真实值的偏离)与学习速率的乘积
  clock_t now;// 当前时间,和start比较计算算法效率
  //neu1:输入词向量,在CBOW模型中是Context(x)中各个词的向量和,在skip-gram模型中是中心词的词向量
  real *neu1 = (real *)calloc(layer1_size, sizeof(real));
  //neuele:累计误差项
  real *neu1e = (real *)calloc(layer1_size, sizeof(real));
  FILE *fi = fopen(train_file, "rb");//打开训练文件
 //多线程模型训练:利用多线程对训练文件划分,每个线程训练一部分的数据
 //每个进程对应一段文本,根据当前线程的id找到该线程对应文本的初始位置
 //file_size就是之前LearnVocabFromTrainFile和ReadVocab函数中获取的训练文件的大小
  fseek(fi, file_size / (long long)num_threads * (long long)id, SEEK_SET);// 将文件内容分配给各个线程
  while (1) {//对每一个词,应用四种模型进行训练
  //每训练10000个词时,打印已训练数占所有需要训练数比例,以及打印训练时间; 然后更新学习率
    if (word_count - last_word_count > 10000) {//word_count表示当前线程当前时刻已 训练的语料的长度
  //last_word_count当前线程上一次记录时已训练的语料长度
  //word_count_actual是全局变量
      word_count_actual += word_count - last_word_count;//word_count_actual是所有线程总共当前处理的词数
      last_word_count = word_count;//更新last_word_count的值
  // debug_mode 大于0,加载完毕后输出汇总信息,大于1,加载训练词汇的时候输出信息,训练过程中输出信息
      if ((debug_mode > 1)) {//debug模式下输出一些训练信息
        //输出信息包括:
        //当前的学习率alpha;
        //训练总进度(当前训练的总词数/(迭代次数*训练样本总词数)+1);
        //每个线程每秒处理的词数
        now=clock();
        printf("%cAlpha: %f  Progress: %.2f%%  Words/thread/sec: %.2fk  ", 13, alpha,
         word_count_actual / (real)(iter * train_words + 1) * 100,
         word_count_actual / ((real)(now - start + 1) / (real)CLOCKS_PER_SEC * 1000));
        fflush(stdout);
      }
      //在初始学习率的基础上,随着实际训练词数的上升,逐步降低当前学习率(自适应调整学习率)
      alpha = starting_alpha * (1 - word_count_actual / (real)(iter * train_words + 1));//自动调整学习率
      if (alpha < starting_alpha * 0.0001) alpha = starting_alpha * 0.0001;// 调整的过程中保证学习率不低于starting_alpha * 0.0001
    }//以上一整块都在基础配置阶段

    //从训练样本中取出一个句子,句子间以回车分割(只取出一个句子,因为外面还有一层循环)
    if (sentence_length == 0) {// 如果当前句子长度为0 ,从训练样本中取出一个句子,句子间以回车分割
    //sentence_length被初始值为0,所以一定进入该语句块
      while (1) {
        //word用来表示当前词在词表中的索引
        word = ReadWordIndex(fi);//从文件中读入一个词,并返回这个词在词汇表中的位置,关于该函数,请参看5.2部分。
        if (feof(fi)) break;//文件结束,退出最近的循环
        if (word == -1) continue;
        word_count++;//当前线程当前时刻已训练的语料的长度
        if (word == 0) break;//如果读到的时回车,表示句子结束,退出当前循环,读下一个句子,</s>的索引为0
         // 这里的亚采样是指 Sub-Sampling,Mikolov 在论文指出这种亚采样能够带来 2 到 10 倍的性能提升,并能够提升低频词的表示精度。
        // 低频词被丢弃概率低,高频词被丢弃概率高
        //对高频词进行随机下采样,丢弃掉一些高频词,能够使低频词向量更加准确,同时加快训练速度
        //可以看作是一种平滑方法
        if (sample > 0) {// sample 亚采样概率的参数,亚采样的目的是以一定概率拒绝高频词,使得低频词有更多出镜率,默认为0,即不进行亚采样
          real ran = (sqrt(vocab[word].cn / (sample * train_words)) + 1) * (sample * train_words) / vocab[word].cn;//词频高的词ran值低,容易被舍弃
          next_random = next_random * (unsigned long long)25214903917 + 11;
         //以1-ran的概率舍弃高频词
          if (ran < (next_random & 0xFFFF) / (real)65536) continue;
        }
        sen[sentence_length] = word;//句子里面存放的是词在词汇表里面的索引
        sentence_length++;//当前处理的单词在当前句子中的位置
        if (sentence_length >= MAX_SENTENCE_LENGTH) break;//如果句子长度超出最大长度则截断,超过1000个单词则截断。
      }
      sentence_position = 0;// 定位到句子头,表示当前单词在当前句中的index,起始值为0
    }
   //如果当前线程处理的词数超过了它应该处理的最大值,那么开始新一轮迭代
   //如果迭代数超过上限,则停止迭代
    if (feof(fi) || (word_count > train_words / num_threads)) {//读到末尾或者数据超过最大处理值
      word_count_actual += word_count - last_word_count;//更新word_count_actual,所有线程总共当前处理的词数
      local_iter--;//迭代总次数减少一次
      if (local_iter == 0) break;//迭代超过上限,停止迭代,只有这里才是跳出最外层循环的地方
      word_count = 0;
      last_word_count = 0;
      sentence_length = 0;
      fseek(fi, file_size / (long long)num_threads * (long long)id, SEEK_SET);//将文件读指针重新移到到此线程所处理词的开头
      continue;//重新开始循环
    }
    word = sen[sentence_position];// 取句子中的第一个单词,开始运行BP算法
    if (word == -1) continue;// 如果没有这个单词,则继续,有点疑问???
    for (c = 0; c < layer1_size; c++) neu1[c] = 0;//初始化输入词向量
    for (c = 0; c < layer1_size; c++) neu1e[c] = 0;//初始化累计误差项
//生成一个[0, window-1]的随机数,用来确定|context(w)|窗口的实际宽度
    next_random = next_random * (unsigned long long)25214903917 + 11;
    b = next_random % window;//b的大小在[0, window-1]之间
   /*然后就开始训练了,先初始化了neu1和neu1e的值。并且确定了窗口的起始位 置,
   通过b = next_random % window来确定,理论上,我们在中心词左右都是取大小为 window个上下文词,
   但是在代码中,并不是保证左右都是window个,而是左边为(window - b)个,
   右边为(window + b)个,总数仍然是2 * window个*/
/********如果使用的是CBOW模型:输入是某单词周围窗口单词的词向量,来预测该中心单词本身*********/
    if (cbow) {  //CBOW模型训练
     // 输入层 -> 隐藏层
      cw = 0;
     //这个循环主要是为了求neu1:输入词向量,在CBOW模型中是Context(x)中各个词的向量和
    //一个词的窗口为[setence_position - window + b, sentence_position + window - b]
      for (a = b; a < window * 2 + 1 - b; a++) if (a != window) {//去除窗口的中心词,这是我们要预测的内容,仅仅提取上下文
        c = sentence_position - window + a;//一个词的窗口为[setence_position - window + b, sentence_position + window - b]
        //不符合条件,则继续取
        if (c < 0) continue;
        if (c >= sentence_length) continue;
        last_word = sen[c]; //sen数组中存放的是句子中的每个词在词表中的索引
        if (last_word == -1) continue;//不存在该词,则继续,有点疑惑???
        //累加词对应的向量。双重循环下来就是窗口额定数量的词每一维对应的向量累加。  
        //累加后neu1的维度依然是layer1_size。  
        //从输入层过度到隐含层。
        //neu1:输入词向量,在CBOW模型中是Context(x)中各个词的向量和,在skip-gram模型中是中心词的词向量
       //real *neu1 = (real *)calloc(layer1_size, sizeof(real));
       //syn0 表示: 存储词典中每个词的词向量
       //real *syn0,syn0存储的是词表中每个词的词向量,源码中使用一个real(float)类型的一维数组表示,注意是一个一维数组!
        for (c = 0; c < layer1_size; c++) neu1[c] += syn0[c + last_word * layer1_size];//计算窗口中词向量的和
//注意syn0是一维数组,不是二维的,所以通过last_word * layer1_size来定位某个词对应的向量位置, last_word表示上下文中上一个词
        cw++;//进入隐含层的词个数。 
      }
      if (cw) {//有效次数大于1
      for (c = 0; c < layer1_size; c++) neu1[c] /= cw;//归一化处理,求平均值, neu1是投影层的向量和
      //注意hs模式下,syn1存的是非叶子节点对应的向量,并不是词汇表中的词对应的另一个向量;
      //而negative模型下,syn1neg存的是词的另一个向量,需要注意
      //如果采用分层softmax优化
      //根据Haffman树上从根节点到当前词的叶节点的路径,遍历所有经过的中间节点
        if (hs) for (d = 0; d < vocab[word].codelen; d++) {//codelen是编码长度,word是当前词的索引
          f = 0;
          // 霍夫曼树中从根节点到该词的路径,存放路径上每个非叶结点的索引
          l2 = vocab[word].point[d] * layer1_size;//l2为当前遍历到的中间节点的向量在syn1中的起始位置,syn1 用于表示hs(hierarchical softmax)算法中霍夫曼编码树非叶结点的权重,syn1也是一个一维向量
          // 隐藏层 -> 输出层
          for (c = 0; c < layer1_size; c++) f += neu1[c] * syn1[c + l2];//f为输入向量neu1与中间结点向量的内积
         //检测f有没有超出Sigmoid函数表的范围
          if (f <= -MAX_EXP) continue;
          else if (f >= MAX_EXP) continue;
        //如果没有超出范围则对f进行Sigmoid变换
        //sigmod函数, f=expTab[(int)((f+6)*1000/12)]  (计算出属于哪一份)
          else f = expTable[(int)((f + MAX_EXP) * (EXP_TABLE_SIZE / MAX_EXP / 2))];//f计算出来了
          //g是梯度和学习率的乘积
          //注意!word2vec中将Haffman编码为1的节点定义为负类,而将编码为0的节点定义为正类
         //即一个节点的label = 1 - d
          g = (1 - vocab[word].code[d] - f) * alpha;
          // 根据计算得到的修正量g和中间节点的向量更新累计误差
          for (c = 0; c < layer1_size; c++) neu1e[c] += g * syn1[c + l2];
          //根据计算得到的修正量g和输入向量更新中间节点的向量值
          for (c = 0; c < layer1_size; c++) syn1[c + l2] += g * neu1[c];
        }
        // 负采样,暂时略过
        if (negative > 0) for (d = 0; d < negative + 1; d++) {
          if (d == 0) {
            target = word;
            label = 1;
          } else {
            next_random = next_random * (unsigned long long)25214903917 + 11;
            target = table[(next_random >> 16) % table_size];
            if (target == 0) target = next_random % (vocab_size - 1) + 1;
            if (target == word) continue;
            label = 0;
          }
          l2 = target * layer1_size;
          f = 0;
          for (c = 0; c < layer1_size; c++) f += neu1[c] * syn1neg[c + l2];
          if (f > MAX_EXP) g = (label - 1) * alpha;
          else if (f < -MAX_EXP) g = (label - 0) * alpha;
          else g = (label - expTable[(int)((f + MAX_EXP) * (EXP_TABLE_SIZE / MAX_EXP / 2))]) * alpha;
          for (c = 0; c < layer1_size; c++) neu1e[c] += g * syn1neg[c + l2];
          for (c = 0; c < layer1_size; c++) syn1neg[c + l2] += g * neu1[c];
        }
        //根据获得的的累计误差,更新context(w)中每个词的词向量
        for (a = b; a < window * 2 + 1 - b; a++) if (a != window) {
          c = sentence_position - window + a;
          if (c < 0) continue;
          if (c >= sentence_length) continue;
          last_word = sen[c];//记录在词汇表中的位置
          if (last_word == -1) continue;//一般不存在这种情况
          //以上部分都与前面一致
          //neu1e存放的是累计误差向量,syn0存放的是词汇表中每个词汇的词向量
          for (c = 0; c < layer1_size; c++) syn0[c + last_word * layer1_size] += neu1e[c];
        }
      }
    } else {  //skip-gram 模型,skip-gram其实没有投影层
      //因为需要预测Context(w)中的每个词,因此需要循环2window - 2b + 1次遍历整个窗口
      //每个上下文都要有一次循环
      for (a = b; a < window * 2 + 1 - b; a++) if (a != window) {//遍历时,略过中心单词
        c = sentence_position - window + a;//last_word为当前待预测的上下文单词
       //不符合条件,则继续
        if (c < 0) continue;
        if (c >= sentence_length) continue;
        last_word = sen[c]; //取出该词在词汇表中的索引
        if(last_word ==-1)continue;//一般不会出现这种情况
        l1 = last_word * layer1_size; //l1为当前单词的词向量在syn0中的起始位置,syn中存的是词汇表中词的向量
        for (c = 0; c < layer1_size; c++) neu1e[c] = 0;//初始化累计误差
        // HIERARCHICAL SOFTMAX huffman树
        if (hs) for (d = 0; d < vocab[word].codelen; d++) {//d代表公式里的l
          f = 0;//初始化为0
        //l2为当前遍历到的中间节点的向量在syn1中的起始位置,syn1 用于表示hs(hierarchical softmax)算法
        //中霍夫曼编码树非叶结点的权重,syn1也是一个一维向量
          l2 = vocab[word].point[d] * layer1_size;
          // 从隐藏层到输出层
          for (c = 0; c < layer1_size; c++) f += syn0[c + l1] * syn1[c + l2];//f为与当前上下文向量与中间结点向量的内积
          //检测f有没有超出Sigmoid函数表的范围
          if (f <= -MAX_EXP) continue;
          else if (f >= MAX_EXP) continue;
         //如果没有超出范围则对f进行Sigmoid变换
          else f = expTable[(int)((f + MAX_EXP) * (EXP_TABLE_SIZE / MAX_EXP / 2))];//f计算出来了
         //注意!这里用到了模型对称:p(u|w) = p(w|u),其中w为中心词,u为context(w)中每个词
         //也就是skip-gram虽然是给中心词预测上下文,真正训练的时候还是用上下文预测中心词
         //与CBOW不同的是这里的u是单个词的词向量,而不是窗口向量之和
         //算法流程基本和CBOW的hs一样
          // g是梯度和学习率的乘积
          g = (1 - vocab[word].code[d] - f) * alpha;
          // 计算累计误差值
          for (c = 0; c < layer1_size; c++) neu1e[c] += g * syn1[c + l2];
          // 根据计算得到的修正量g和输入向量更新中间节点的向量值
          for (c = 0; c < layer1_size; c++) syn1[c + l2] += g * syn0[c + l1];
        }
        // 负采样,暂时略过
        if (negative > 0) for (d = 0; d < negative + 1; d++) {
          if (d == 0) {
            target = word;
            label = 1;
          } else {
            next_random = next_random * (unsigned long long)25214903917 + 11;
            target = table[(next_random >> 16) % table_size];
            if (target == 0) target = next_random % (vocab_size - 1) + 1;
            if (target == word) continue;
            label = 0;
          }
          l2 = target * layer1_size;
          f = 0;
          for (c = 0; c < layer1_size; c++) f += syn0[c + l1] * syn1neg[c + l2];
          if (f > MAX_EXP) g = (label - 1) * alpha;
          else if (f < -MAX_EXP) g = (label - 0) * alpha;
          else g = (label - expTable[(int)((f + MAX_EXP) * (EXP_TABLE_SIZE / MAX_EXP / 2))]) * alpha;
          for (c = 0; c < layer1_size; c++) neu1e[c] += g * syn1neg[c + l2];
          for (c = 0; c < layer1_size; c++) syn1neg[c + l2] += g * syn0[c + l1];
        }
        // 处理完一个词,及时去更新他的词向量,用累计误差来更新。
        for (c = 0; c < layer1_size; c++) syn0[c + l1] += neu1e[c];
       //每个窗口向量都要遍历一遍,遍历完成以后,读取下一个词。
      }
    }
     //都是为了跳到外面的循环进入下一步
    sentence_position++;//完成了一个词的训练,句子中位置往后移一个词
    if (sentence_position >= sentence_length) {//处理完一句句子后,将句子长度置为零,进入循环
    //重新读取句子并进行逐词计算,即读取下一个句子
      sentence_length = 0;//sentence_length 设置为0以后就
      continue;
    }
  }
  fclose(fi);//关闭文件
  free(neu1);//释放内存
  free(neu1e);//释放内存
  pthread_exit(NULL);//退出线程
}
//3.1.词向量的初始化:首先,生成一个很大的next_random的数,
  //通过与“0xFFFF”进行与运算截断,再除以65536得到[0,1]之间的数,
  //最终,得到的初始化的向量的范围为:[-0.5/layer1_size,0.5/layer1_size],其中layer1_size为词向量的长度
  for (a = 0; a < vocab_size; a++) for (b = 0; b < layer1_size; b++) {
    next_random = next_random * (unsigned long long)25214903917 + 11;
    syn0[a * layer1_size + b] = (((next_random & 0xFFFF) / (real)65536) - 0.5) / layer1_size; // 随机初始化word vectors
  }

5.4 ReadWordIndex()从文件流中读取一个词,并返回这个词在词汇表中的位置

//构建词库的过程:从文件流中读取一个词,并返回这个词在词汇表中的位置
int ReadWordIndex(FILE *fin) {
  char word[MAX_STRING];
  ReadWord(word, fin);//读取一个词
  if (feof(fin)) return -1;
  return SearchVocab(word);//返回该词在词汇表中的位置
}

推荐阅读更多精彩内容