7

Word2vec 源码详解

 3 years ago
source link: https://mp.weixin.qq.com/s?__biz=MzIwMDIzNDI2Ng%3D%3D&%3Bmid=2247489308&%3Bidx=1&%3Bsn=77fcc7e91ddf06fdc5824886f425ed2d
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

已经看了很久的word2vec,但是发现了很多不同版本的解释,再加上原始论文没有提到太多的细节,所以打算直接看一遍源码,一方面可以加深自己理解;另一方面,以后也可以做适当的改进!

先给出源码中执行的流程图,按照流程图对代码进行详细的解读,流程图如下:

2E3qQjA.png!mobile

训练部分的流程图如下:

m2Yzuyy.png!mobile

讲解将会按照这个训练过程来!

一、训练参数

注意,这些参数都是 「全局参数」 ,包括以下参数:

  • 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, 表示不启用聚类。

以上参数都对应了代码中一些全局变量,全局变量具体含义,参考上述参数说明!

二、预生成expTable

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

在训练过程中需要用到大量的sigmoid值计算,如果每次都临时去算 exex的值,将会影响性能;当对精度的要求不是很严格的时候,我们可以采用近似的运算。在word2vec中,将区间 「[-MAX_EXP, MAX_EXP]」 (代码中MAX_EXP默认值为6)等距划分为 「EXP_TABLE_SIZE」 等份,并将每个区间的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++) {
    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)
}

三、构建词汇库

构建词汇库过程中,先判断是否已经有处理好现成的词汇库,有的话直接读取,没有的话再进行训练。

「词汇表训练过程」分为以下几个步骤: 「1.读取一个单词」「2.计算单词对应hash值」「3.通过hash值得到单词在词汇表中索引」「4.将单词加入到词汇表」「5.对词汇表根据词频进行降序排序」 , 「6.保存训练好的词汇表」 。依次介绍以上几个步骤。首先给出词汇表中每个词对应的 「结构体」

//词汇中每个word对应的结构体
struct vocab_word {
    long long cn;                     //词频
    int *point;                       //记录huffman树中父节点索引, 自顶向下
    char *word, *code, codelen;       //word表示该单词;code表示Huffman编码表,记录父节点是左节点还是右节点;codelen表示码值表长度
};

「1.读取一个单词对应代码」

// Reads a single word from a file, assuming space + tab + EOL to be word boundaries
//从文件中读取单个单词,假设单词之间通过空格或者tab键或者EOL键进行分割的
void ReadWord(char *word, FILE *fin) {
  int a = 0, ch;
  while (!feof(fin)) {
    ch = fgetc(fin);                                             //读一个词
    if (ch == 13) continue;                                      //如果是换行符                                  
    if ((ch == ' ') || (ch == '\t') || (ch == '\n')) {           //代表一个单词结束的边界
      if (a > 0) {                                               //如果读到了单词但是遇到了换行符,
        if (ch == '\n') ungetc(ch, fin);                         //退回到流中
        break;
      }
      if (ch == '\n') {                                          //仅仅读到了换行符
        strcpy(word, (char *)"</s>");                            //将</s>赋予给word
        return;
      } else continue;
    }
    word[a] = ch;
    a++;
    if (a >= MAX_STRING - 1) a--;   // Truncate too long words   //截断
  }
  word[a] = 0;                                                   //最后一个字符是'\0'
}

「2.计算单词对应的hash值」

// Returns hash value of a word
//返回一个词对应的hash值
int GetWordHash(char *word) {
  unsigned long long a, hash = 0;
  for (a = 0; a < strlen(word); a++) hash = hash * 257 + word[a];
  hash = hash % vocab_hash_size;
  return hash;
}

「3.通过hash值得到word在词汇表中索引」使用到了开放定址法,关于开放地址法,参考这里。

//开放地址发得到词的位置
int SearchVocab(char *word) {
  unsigned int hash = GetWordHash(word);                                     //获得索引
  while (1) {
    if (vocab_hash[hash] == -1) return -1;
    if (!strcmp(word, vocab[vocab_hash[hash]].word)) return vocab_hash[hash];
    hash = (hash + 1) % vocab_hash_size;                                     //开放定址法
  }
  return -1;
}

wrod2vec中使用 「ReadWordIndex()函数」 直接整合了步骤1、步骤2和步骤3,代码如下:

// Reads a word and returns its index in the vocabulary
int ReadWordIndex(FILE *fin) {
  char word[MAX_STRING];                     
  ReadWord(word, fin);                                   //从文件流中读取一个单词
  if (feof(fin)) return -1;
  return SearchVocab(word);                              //返回对应的词汇表中索引
}

「4.将word加入到词汇表」

// Adds a word to the vocabulary
//将word加入到词汇表
int AddWordToVocab(char *word) {
  unsigned int hash, length = strlen(word) + 1;
  if (length > MAX_STRING) length = MAX_STRING;               //规定每个word不超过MAX_STRING个字符
  vocab[vocab_size].word = (char *)calloc(length, sizeof(char));
  strcpy(vocab[vocab_size].word, word);        //结构体的word词
  vocab[vocab_size].cn = 0;
  vocab_size++;
  // Reallocate memory if needed       //动态扩展内存
  if (vocab_size + 2 >= vocab_max_size) {
    vocab_max_size += 1000;              //词汇量加上1000
    vocab = (struct vocab_word *)realloc(vocab, vocab_max_size * sizeof(struct vocab_word));
  }
  hash = GetWordHash(word);
  while (vocab_hash[hash] != -1) hash = (hash + 1) % vocab_hash_size;   //得到word实际对应的hash值
  vocab_hash[hash] = vocab_size - 1;     //通过hash值获得word在vocab中索引
  return vocab_size - 1;       //返回单词对应索引
}

「5.对词汇表进行排序」 排序需要先尽力一个比较器,这里构造了一个降序排列的比较器,代码如下:

// Used later for sorting by word counts
//构造一个比较器,用来排序,降序
int VocabCompare(const void *a, const void *b) {
    return ((struct vocab_word *)b)->cn - ((struct vocab_word *)a)->cn;
}


// Sorts the vocabulary by frequency using word counts
void SortVocab() {
  int a, size;
  unsigned int hash;
  // Sort the vocabulary and keep </s> at the first position
  qsort(&vocab[1], vocab_size - 1, sizeof(struct vocab_word), VocabCompare);
  for (a = 0; a < vocab_hash_size; a++) vocab_hash[a] = -1;
  size = vocab_size;
  train_words = 0;
  for (a = 0; a < size; a++) {
    // Words occuring less than min_count times will be discarded from the vocab
    //频率低于一定程度的词会被抛弃掉
    if ((vocab[a].cn < min_count) && (a != 0)) {
      vocab_size--;
      free(vocab[a].word);
    } else {
      // Hash will be re-computed, as after the sorting it is not actual
      //因为排序之后顺序打乱,会重新计算一次hash值
      hash=GetWordHash(vocab[a].word);
      while (vocab_hash[hash] != -1) hash = (hash + 1) % vocab_hash_size;
      vocab_hash[hash] = a;
      train_words += vocab[a].cn;
    }
  }
  //重新规划内存大小
  vocab = (struct vocab_word *)realloc(vocab, (vocab_size + 1) * sizeof(struct vocab_word));
  // Allocate memory for the binary tree construction
  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));
  }
}

「6.保存训练好的词汇表」

//保存学习到的词汇文件表
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);
}

代码中还有一个词汇表裁剪函数, 当词汇表中词汇量大于一定值时,会进行裁剪,先裁掉频率低的词,然后再裁剪掉频率高的词,直到词汇量满足要求,代码如下:

// Reduces the vocabulary by removing infrequent tokens
//对于频率小于min_reduce的词将会被裁剪掉
void ReduceVocab() {
  int a, b = 0;
  unsigned int hash;
  //仅仅一个数组就实现了裁剪过程
  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;
  for (a = 0; a < vocab_size; a++) {
    // Hash will be re-computed, as it is not actual
    hash = GetWordHash(vocab[a].word);
    while (vocab_hash[hash] != -1) hash = (hash + 1) % vocab_hash_size;
    vocab_hash[hash] = a;
  }
  fflush(stdout);
  min_reduce++;     //每次裁剪之后都会提高最低频率数
}

如果已经有训练好的词汇表,可以直接读取,不需要通过语料库进行训练,代码如下:

//从已有的词汇文件中直接读取,不用临时去学习
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);
  }
  for (a = 0; a < vocab_hash_size; a++) vocab_hash[a] = -1;  //vocab_hash值默认为-1
  vocab_size = 0;
  while (1) {                        //不停读取,直到文件末尾
    ReadWord(word, fin);          //从文件流中读取一个单词到word中
    if (feof(fin)) break;
    a = AddWordToVocab(word);            //将单词加入到词汇表            
    fscanf(fin, "%lld%c", &vocab[a].cn, &c);     //读取词频到vocav.cn中,换行符                    
    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);
}

词汇库生成过程由 「LearnVocabFromTrainFile()函数」 组合以上步骤来完成,代码如下:

//整合上面的文件操作
void LearnVocabFromTrainFile() {
  char word[MAX_STRING];
  FILE *fin;
  long long a, i;
  for (a = 0; a < vocab_hash_size; a++) vocab_hash[a] = -1;    //hash值初始为-1
  fin = fopen(train_file, "rb");
  if (fin == NULL) {
    printf("ERROR: training data file not found!\n");
    exit(1);
  }
  vocab_size = 0;
  AddWordToVocab((char *)"</s>");                              //将'</s>'添加到词汇表,换行符就是用这个表示
  while (1) {
    ReadWord(word, fin);
    if (feof(fin)) break;
    train_words++;
    if ((debug_mode > 1) && (train_words % 100000 == 0)) {
      printf("%lldK%c", train_words / 1000, 13);
      fflush(stdout);
    }
    i = SearchVocab(word);                                     //查找该词的位置
    if (i == -1) {                                             //还未加入到词汇表                   
      a = AddWordToVocab(word);
      vocab[a].cn = 1;
    } else vocab[i].cn++;                                      //已经加入到词汇表
    if (vocab_size > vocab_hash_size * 0.7) ReduceVocab();     //裁剪词操作
  }
  SortVocab();                                                 //排序
  if (debug_mode > 0) {
    printf("Vocab size: %lld\n", vocab_size);
    printf("Words in train file: %lld\n", train_words);
  }
  file_size = ftell(fin);
  fclose(fin);
}

四、初始化网络

初始化网络包括以下几个过程: 「1.初始化网络参数」「2.构建哈夫曼树」「3,初始化负采样概率表」

1.初始化网络参数

网络中的参数主要包括 「syn0,syn1和syn1neg」

syn0: 我们需要得到的词向量,源码中使用一个real(float)类型的一维数组表示,注意是一个一维数组!
      容量大小为vocab_size * layer1_size,即 词汇量 * 词向量维度。

syn1: huffman树中,包括叶子节点和非叶子节点。叶子节点是对应的是词汇表中的单词,而非叶子节点是在构造huffman树过程中
      生成的路径节点。syn1表示的就是huffman树中的非叶子节点向量,其维度和词向量维度是一样的,共有(n-1)个非叶子节点,
      n表示词汇表中单词量。注意,syn1也是一个一维real(float)数组,容量为 vocab_size * layer1_size
    
syn1neg: 这是单词的另一个向量表示,之前看斯坦福自然语言处理视频中有提到过每个单词会训练出两个向量,现在看来的确是这    
         样,不过是通过negative方式训练才有。这个向量是用于负采样模式优化时需要的变量。也是一个一维的float数组,
         大小是 vocab_size * layer1_size。123456789

初始化代码如下:

//初始化网络
void InitNet() {
  long long a, b;
  unsigned long long next_random = 1;
  //为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);}

  //如果采用huffman softmax构造,那么需要初始化syn1,大小为vocab_size * layer1_size * sizeof(real),每个词对应一个
  if (hs) {
    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;
  }

  //如果采用负采样进行训练,那么久初始化syn1neg,大小为vocab_size * layer1_size * sizeof(real),每个词对应一个
  if (negative>0) {
    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中每个词对应的词向量进行初始化
  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;//& 0xFFFF表示截断为[0, 65536]
  }

  //构建huffman softmax需要的哈夫曼树
  CreateBinaryTree();
}

syn0的每个值的范围为:[−0.5m,0.5m][−0.5m,0.5m],m表示向量维度;syn1初始化为0;syn1neg也初始化为0.

2.构建哈夫曼树

// Create binary Huffman tree using the word counts
// Frequent words will have short uniqe binary codes
void CreateBinaryTree() {
  long long a, b, i, min1i, min2i, pos1, pos2, point[MAX_CODE_LENGTH];
  char code[MAX_CODE_LENGTH];
  //分配的空间大小为,(vocab_size * 2 + 1) * sizeof(long long),因为hufuman树的特性,所以总结点数是2 * n + 1, 其中n是节点数, 此处应该有错误,是2 * n - 1才对
  long long *count = (long long *)calloc(vocab_size * 2 + 1, sizeof(long long));       //节点对应频率
  long long *binary = (long long *)calloc(vocab_size * 2 + 1, sizeof(long long));      //记录每个节点是左节点还是右节点
  long long *parent_node = (long long *)calloc(vocab_size * 2 + 1, sizeof(long long)); //父节点位置
  for (a = 0; a < vocab_size; a++) count[a] = vocab[a].cn;
  //后面的设为无穷
  for (a = vocab_size; a < vocab_size * 2; a++) count[a] = 1e15;
  pos1 = vocab_size - 1;
  pos2 = vocab_size;
  // Following algorithm constructs the Huffman tree by adding one node at a time
  //如同天才般的代码,一次遍历就构造好了huffuman树, ##注意,这个a还代表了一种顺序,所有count值由小到大的顺序##
  for (a = 0; a < vocab_size - 1; a++) {
    // First, find two smallest nodes 'min1, min2',注意vocab中的词是已经按照cn排好序的了,是按照降序排列的
    //pos1表示取最原始的词对应的词频,而pos2表示取合并最小值形成的词频
    //连续两次取,两次取的时候代码操作时一模一样的
    if (pos1 >= 0) {
      if (count[pos1] < count[pos2]) {
        min1i = pos1;
        pos1--;
      } else {
        min1i = pos2;
        pos2++;
      }
    } else {
      min1i = pos2;
      pos2++;
    }
    if (pos1 >= 0) {
      if (count[pos1] < count[pos2]) {
        min2i = pos1;
        pos1--;
      } else {
        min2i = pos2;
        pos2++;
      }
    } else {
      min2i = pos2;
      pos2++;
    }
    count[vocab_size + a] = count[min1i] + count[min2i];
    parent_node[min1i] = vocab_size + a;                   //记录好合并形成的父节点的位置
    parent_node[min2i] = vocab_size + a;
    binary[min2i] = 1;                                     //左为0,右为1
  }
  // Now assign binary code to each vocabulary word
  // 建好了hufuman树之后,就需要分配code了,注意这个hufuman树是用一个数组来存储的,并不是我们常用的指针式链表
  for (a = 0; a < vocab_size; a++) {
    b = a;
    i = 0;
    while (1) {
      code[i] = binary[b];                                 //对于每个节点,自底向上得到code值,通过每个节点的binary来实现
      point[i] = b;                                        //point记录节点到根节点经过的节点的路径
      i++;
      b = parent_node[b];
      if (b == vocab_size * 2 - 2) break;
    }
    vocab[a].codelen = i;                                  //记录词对应的码值的长度
    vocab[a].point[0] = vocab_size - 2;                    //最大值作为根节点
    for (b = 0; b < i; b++) {
      vocab[a].code[i - b - 1] = code[b];                  //倒序过来,自顶向下
      vocab[a].point[i - b] = point[b] - vocab_size;       //注意这个索引对应的是huffman树中的非叶子节点,对应syn1中的索引, 因为非叶子节点都是在vocab_size * 2 + 1 的后(vocab_size + 1)个
    }
  }
  free(count);
  free(binary);
  free(parent_node);
}

多么简洁而亮眼的代码。 「它主要利用了词汇表的有序性,是降序排列。所以刚开始 pos1 = vocab_size - 1 是原始词汇表中词频最小的那个单词。每次合并两个最小值,我们将新生成的节点放到后vocab-size + 1个位置,并且也是有序的往后填充,所以最终代表huffman数的count数组有一个特性,都是中心往两头在递增值。所以,我们每次取最小值,只需要比较两头中哪一头的当前值最小,就能取到两个最小值。」

3.初始化负采样概率表

如果是采用负采样的方法,此时还需要初始化每个词被选中的概率。在所有的词构成的词典中,每一个词出现的频率有高有低,我们希望, 「对于那些高频的词,被选中成为负样本的概率要大点,同时,对于那些出现频率比较低的词,我们希望其被选中成为负样本的频率低点」

//生成负采样的概率表
void InitUnigramTable() {
  int a, i;
  double train_words_pow = 0;
  double d1, power = 0.75;
  table = (int *)malloc(table_size * sizeof(int));
  //pow(x, y)计算x的y次方;train_words_pow表示总的词的概率,不是直接用每个词的频率,而是频率的0.75次方幂
  for (a = 0; a < vocab_size; a++) train_words_pow += pow(vocab[a].cn, power);  
  i = 0;
  d1 = pow(vocab[i].cn, power) / train_words_pow;
  //每个词在table中占的小格子数是不一样的,频率高的词,占的格子数显然多
  for (a = 0; a < table_size; a++) {
    table[a] = i;
    if (a / (double)table_size > d1) {
      i++;
      d1 += pow(vocab[i].cn, power) / train_words_pow;
    }
    if (i >= vocab_size) i = vocab_size - 1;
  }
}

五、模型训练

关于word2vec的CBOW和SKIP模型原理,强力推荐大神的博客讲解,虽然有错误细节,但是大体思想都是正确的。首先定义了几个重要的变量,变量解释如下:

last_word: 当前窗口正在训练的词的索引。
sentence_length: 当前训练的句子的长度
sentence_position: 当前中心词在句子中的位置
sen: 数组,存的是句子中每个词在词汇表中的索引
neu1: 是cbow模式下映射层对应的上下文向量表示,为上下文中所有词向量的平均值
neu1e: 因为skip模式下,映射层向量就是输入层向量的复制,所以neu1e仅仅用来记录上下文词对输入层的梯度。123456

每次读取一条句子,记录好句子中每个词在词汇表中对应的索引。如果启用了下采样,则会随机的跳过一些词,会随机的丢弃频繁的单词,同时保持顺序不变。代码如下:

if (sentence_length == 0) {
    while (1) {
      word = ReadWordIndex(fi);                                                   //得到词在词汇表中对应的索引
      if (feof(fi)) break;                                                        //
      if (word == -1) continue;
      word_count++;                                                               //句子总的次数
      if (word == 0) break;                                                       //遇到换行符,则直接跳出来,第一个词'</s>'代表换行符
      // The subsampling randomly discards frequent words while keeping the ranking same
      //下采样随机丢弃频繁的单词,同时保持排名相同,随机跳过一些词的训练
      if (sample > 0) {
        real ran = (sqrt(vocab[word].cn / (sample * train_words)) + 1) * (sample * train_words) / vocab[word].cn;
        next_random = next_random * (unsigned long long)25214903917 + 11;
        //频率越大的词,对应的ran就越小,越容易被抛弃,被跳过
        if (ran < (next_random & 0xFFFF) / (real)65536) continue;
      }
      sen[sentence_length] = word;                                                //当前句子包含的词,存的是索引
      sentence_length++;                                                          //句子实际长度,减去跳过的词
      if (sentence_length >= MAX_SENTENCE_LENGTH) break;
    }
    sentence_position = 0;
}123456789101112131415161718192021

然后就开始训练了,先初始化了 neu1neu1e 的值。并且确定了窗口的起始位置,通过 b = next_random % window 来确定, 「理论上,我们在中心词左右都是取大小为window个上下文词,但是在代码中,并不是保证左右都是window个,而是左边为(window - b)个, 右边为(window + b)个,总数仍然是2 * window个。 「训练的时候,有两种训练模型,分别是」 CBOW模型和SKIP模型;对于每种模型,又有两种训练模式,分别为huffman softmax模式(hs)和negative模式(负采样)」 ,下面分别讲解。

1.CBOW模型

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

ArMFBnr.png!mobile

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

if (cbow) {  //train the cbow architecture
  // in -> hidden
  cw = 0;
  //随机取一个词word,然后计算该词上下文词对应的向量的各维度之和
  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];                                                         //获得senten中第c个词的索引
    if (last_word == -1) continue;
    //注意syn0是一维数组,不是二维的,所以通过last_word * layer1_size来定位某个词对应的向量位置, last_word表示上下文中上一个词
    for (c = 0; c < layer1_size; c++) neu1[c] += syn0[c + last_word * layer1_size];  //neu1表示映射层向量,上下文累加平均 
    cw++;
  }
  if (cw) {
  //上下文表示是所有词对应词向量的平均值
    for (c = 0; c < layer1_size; c++) neu1[c] /= cw;
    ......
  }
  ......
}

1.1 hs模式

huffman softmax中,计算上下文向量到中心词的概率,是一连串的二分类问题,因为从根节点到中心词对应的叶子节点,需要多次决定沿左节点还是右节点到叶子节点。详细介绍请参考word2vec数学原理详解。对于中心词w,从根节点到中心词节点的总概率为:

即:

其对数似然函数为:

中 j 表示的是从根节点到中心词w所经过的非叶子节点的索引值(huffman树是用一维数组存的,非叶子节点在数组中对应的索引),表示的是该非叶子节点对应的 huffman 编码,作为左节点是 0 右节点是 1。表示映射层的上下文向量,表示非叶子节点向量。在这里,都是变量,此时,对二者求偏导数:

则:

再对应代码实现:

if (hs) for (d = 0; d < vocab[word].codelen; d++) {
  f = 0;
  l2 = vocab[word].point[d] * layer1_size;                                     //索引到该词在数组偏移量
  // Propagate hidden -> output, 传播过程
  for (c = 0; c < layer1_size; c++) f += neu1[c] * syn1[c + l2];               //注意syn1也是一维数组,不同词对应的位置需要偏移量l2确定
    if (f <= -MAX_EXP) continue;                                               //当f值不属于[-MAX_EXP, MAX_EXP]
    else if (f >= MAX_EXP) continue;
    else f = expTable[(int)((f + MAX_EXP) * (EXP_TABLE_SIZE / MAX_EXP / 2))];  //查看f属于第几份,((f + MAX_EXP) / (2 * MAX_EXP)) * EXP_TABLE_SIZE
    // 'g' is the gradient multiplied by the learning rate
    g = (1 - vocab[word].code[d] - f) * alpha;                                 //需要推导,得到这个梯度比例
    // Propagate errors output -> hidden
    for (c = 0; c < layer1_size; c++) neu1e[c] += g * syn1[c + l2];            //这个部分才是最终梯度值
    // Learn weights hidden -> output
    for (c = 0; c < layer1_size; c++) syn1[c + l2] += g * neu1[c];             //加上梯度值,更新syn1
 }

更新词向量代码如下:

// hidden -> in
//更新输入层的词向量
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;
      for (c = 0; c < layer1_size; c++) 
     syn0[c + last_word * layer1_size] += neu1e[c];
}

1.2 negative模式

负采样过程中,只有一个正样本也就是中心词,其他词都是负样本,将所有概率乘起来,使其最大。对于单个样本 u 有:

则所有样本的概率之和为:

其对数似然函数为:

即为:

其中 u 表示随机选取的词样本,是该词样本对应的向量,表示映射层的上下文向量,表示判断词 u 是不是当前窗口中心词 w,1 表示是,0 表示不是。表示相对于中心词 w 进行的负采样集合。其中和是变量,对二者求导:

导数就能够进行梯度上升求最大值。实现代码如下:

// NEGATIVE SAMPLING
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;            //如果target为0,这个等式保证不为0
      if (target == word) continue;                                            //正样本则跳过
        label = 0;
      }
      l2 = target * layer1_size;                                               //syn1neg是一维数组,某个词需要先计算偏移量
      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];
 }

「更新词向量代码如下:」

// hidden -> in
//更新输入层的词向量
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;
      for (c = 0; c < layer1_size; c++) syn0[c + last_word * layer1_size] += neu1e[c];
}

2.SKIP模型

skip 模型中,也是三层,输入层、映射层和输出层。结构如下:

zaeUVn2.png!mobile

skip模型和cbow模型优化类似,主要是输入层到映射层之间不同, 「cbow中是上下文词向量平均求和,而skip模型中是直接复制中心词向量。skip模型中,优化过程是逐个计算中心词和上下文词之间的概率,使其最大化,所以和cbow中的优化计算基本类似」 ,代码如下:

else {  //train skip-gram
  //还是保证一个2 * window大小上下文,但是中心词左右并不一定刚好都是window个,根据b确定
  for (a = b; a < window * 2 + 1 - b; a++) if (a != window) {
    c = sentence_position - window + a;                          //c表示上下文的当前遍历位置
    if (c < 0) continue;
    if (c >= sentence_length) continue;
    last_word = sen[c];                                          //用来记录上一个词
    if (last_word == -1) continue;                               //如果词不在词汇表中,则直接跳过
    l1 = last_word * layer1_size;                                //偏移量,因为syn0是一维数组,每个词对应的向量需要先偏移前面的词对应向量
    for (c = 0; c < layer1_size; c++) neu1e[c] = 0;
    // HIERARCHICAL SOFTMAX 
    //不需要像cbow一样求平均
    if (hs) for (d = 0; d < vocab[word].codelen; d++) {
      f = 0;
      l2 = vocab[word].point[d] * layer1_size;                   //
      // Propagate hidden -> output
      for (c = 0; c < layer1_size; c++) f += syn0[c + l1] * syn1[c + l2];
      if (f <= -MAX_EXP) continue;
      else if (f >= MAX_EXP) continue;
      else f = expTable[(int)((f + MAX_EXP) * (EXP_TABLE_SIZE / MAX_EXP / 2))];
      // 'g' is the gradient multiplied by the learning rate
      g = (1 - vocab[word].code[d] - f) * alpha;
      // Propagate errors output -> hidden
      for (c = 0; c < layer1_size; c++) neu1e[c] += g * syn1[c + l2];
      // Learn weights hidden -> output
      for (c = 0; c < layer1_size; c++) syn1[c + l2] += g * syn0[c + l1];
    }
    // NEGATIVE SAMPLING
    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];//更新
    }
    // Learn weights input -> hidden
    //更新输入层权重
    for (c = 0; c < layer1_size; c++) syn0[c + l1] += neu1e[c];
  }
}

六、结果处理

可以直接保存结果或者用k-means聚类算法分析结果,代码如下:

//训练模型
void TrainModel() {
  long a, b, c, d;
  FILE *fo;
  pthread_t *pt = (pthread_t *)malloc(num_threads * sizeof(pthread_t));
  printf("Starting training using file %s\n", train_file);
  starting_alpha = alpha;                                                                         //设置学习率
  if (read_vocab_file[0] != 0) ReadVocab(); else LearnVocabFromTrainFile();                       //获得词汇表,如果已经有直接读,否则学
  if (save_vocab_file[0] != 0) SaveVocab();
  if (output_file[0] == 0) return;                                                                //必须有输出文件参数
  InitNet();                                                                                      //初始化网络参数
  if (negative > 0) InitUnigramTable();                                                           //如果是使用负采样,那么需要负采样概率表
  start = clock();                                                                                //计时器
  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) {                                                                             //classes判断是否使用kmean聚类,为0表示否
    // Save the word vectors
    fprintf(fo, "%lld %lld\n", vocab_size, layer1_size);
    for (a = 0; a < vocab_size; a++) {
      fprintf(fo, "%s ", vocab[a].word);
      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");
    }
  } else {
    // Run K-means on the word vectors
    //类别中心数,迭代次数,
    int clcn = classes, iter = 10, closeid;
    int *centcn = (int *)malloc(classes * sizeof(int));                                          //每个中心点拥有的词数量
    int *cl = (int *)calloc(vocab_size, sizeof(int));                                            //每个词所属类别标签
    real closev, x;
    real *cent = (real *)calloc(classes * layer1_size, sizeof(real));                            //聚类中心,注意是用一维数组表示,每个中心需要通过偏移量来定位
    for (a = 0; a < vocab_size; a++) cl[a] = a % clcn;                                           //初始化每个词所属类别
    for (a = 0; a < iter; a++) {                                                                 //开始训练
      for (b = 0; b < clcn * layer1_size; b++) cent[b] = 0;                                      //初始化中心点位置
      for (b = 0; b < clcn; b++) centcn[b] = 1;                                                  //初始化每个中心点拥有的词的数量
      //求每个中心点每个维度值的总和,等于所有属于这个类别的词向量的相应维度相加
      for (c = 0; c < vocab_size; c++) {
        for (d = 0; d < layer1_size; d++) cent[layer1_size * cl[c] + d] += syn0[c * layer1_size + d];
        centcn[cl[c]]++;                                                                         //所包含词的数量+1
      }
      //对于每一个类别,需要更新中心点各维度值,就是总和平均
      for (b = 0; b < clcn; b++) {                                                               
        closev = 0;
        for (c = 0; c < layer1_size; c++) {                                                       //遍历每个维度
          cent[layer1_size * b + c] /= centcn[b];                                                 //每个词每个维度平均
          closev += cent[layer1_size * b + c] * cent[layer1_size * b + c];                        //求每个中心点的模的平方
        }
        closev = sqrt(closev);                                                                    //每个中心点模
        for (c = 0; c < layer1_size; c++) cent[layer1_size * b + c] /= closev;                    //归一化处理
      }
      //更新每个词所属的类别,看离哪个中心点最近就归为相应的类别
      for (c = 0; c < vocab_size; c++) {
        closev = -10;                                                                             //记录离最近中心点距离
        closeid = 0;                                                                              //记录最近的类别id
        for (d = 0; d < clcn; d++) {
          x = 0;
          for (b = 0; b < layer1_size; b++) x += cent[layer1_size * d + b] * syn0[c * layer1_size + b];
          if (x > closev) {
            closev = x;
            closeid = d;
          }
        }
        cl[c] = closeid;
      }
    }
    // Save the K-means classes
    for (a = 0; a < vocab_size; a++) fprintf(fo, "%s %d\n", vocab[a].word, cl[a]);
    free(centcn);
    free(cent);
    free(cl);
  }
  fclose(fo);
}

int ArgPos(char *str, int argc, char **argv) {
  int a;
  for (a = 1; a < argc; a++) if (!strcmp(str, argv[a])) {
    if (a == argc - 1) {
      printf("Argument missing for %s\n", str);
      exit(1);
    }
    return a;
  }
  return -1;
}

完整的注释代码:https://github.com/liuwei1206/word2vec/blob/master/word2vec%E6%BA%90%E7%A0%81%E8%A7%A3%E6%9E%90/word2vec.c

参考博客:

https://blog.csdn.net/itplus/article/details/37969979

https://blog.csdn.net/google19890102/article/details/51887344

作者: 玩人

地址:https://blog.csdn.net/jeryjeryjery/article/details/80245924


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK