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

源码分析请参考本人github地址:https://github.com/liuwei1206/word2vec

Pytorch实现参考: https://github.com/liuwei1206/CNN_CBOW

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

训练部分的流程图如下:

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

(一)训练参数

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

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

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

(二)预生成expTable

  word2vec计算过程中用上下文预测中心词或者用中心词预测上下文,都需要进行预测;而word2vec中采用的预测方式是逻辑回归分类,需要用到sigmoid函数,具体函数形式为:
σ(x)=11+e−x=ex1+ex\sigma(x)=\frac{1}{1+e^{-x}}=\frac{e^{x}}{1+e^{x}}σ(x)=1+e−x1​=1+exex​
在训练过程中需要用到大量的sigmoid值计算,如果每次都临时去算exe^{x}ex的值,将会影响性能;当对精度的要求不是很严格的时候,我们可以采用近似的运算。在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() tableexpTable[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>赋予给wordreturn;} 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;                                                     //词汇量加上1000vocab = (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 positionqsort(&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 constructionfor (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 actualhash = 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值默认为-1vocab_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值初始为-1fin = 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。

初始化代码如下:

//初始化网络
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][-\frac{0.5}{m}, \frac{0.5}{m}][−m0.5​,m0.5​],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仅仅用来记录上下文词对输入层的梯度。

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

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;
}

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

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

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

if (cbow) {  //train the cbow architecture// in -> hiddencw = 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,从根节点到中心词节点的总概率为:
P(w∣context(w))=∏j∈pointp(djw∣Xw,θjw)P(w|context(w))=\prod_{j\;\in\;point}p(d_j^w|X_w, \theta_j^w)P(w∣context(w))=j∈point∏​p(djw​∣Xw​,θjw​)
p(djw∣Xw,θjw)={σ(XwTθjw),djw=01−σ(XwTθjw),djw=1p(d_j^w|X_w, \theta_j^w)=\begin{cases} \sigma(X_w^T \theta_j^w), & \text { $d_j^w=0$} \\ 1-\sigma(X_w^T \theta_j^w), & \text{$d_j^w=1$} \end{cases}p(djw​∣Xw​,θjw​)={σ(XwT​θjw​),1−σ(XwT​θjw​),​ djw​=0djw​=1​
即p(djw∣Xw,θjw)=[σ(XwTθjw)]1−djw∗[1−σ(XwTθjw)]djw即\;\;p(d_j^w|X_w, \theta_j^w)=[\sigma(X_w^T \theta_j^w)]^{1-d_j^w}*[1-\sigma(X_w^T \theta_j^w)]^{d_j^w}即p(djw​∣Xw​,θjw​)=[σ(XwT​θjw​)]1−djw​∗[1−σ(XwT​θjw​)]djw​
其对数似然函数为:
L(w,j)=logP(w∣context(w))=((1−djw)log[σ(XwTθjw)]+djwlog[1−σ(XwTθjw)])L(w,j)=logP(w|context(w))= \left( (1-d_j^w)log[\sigma(X_w^T \theta_j^w)]+d_j^wlog[1-\sigma(X_w^T \theta_j^w)]\right)L(w,j)=logP(w∣context(w))=((1−djw​)log[σ(XwT​θjw​)]+djw​log[1−σ(XwT​θjw​)])
其中jjj表示的是从根节点到中心词w所经过的非叶子节点的索引值(huffman树是用一维数组存的,非叶子节点在数组中对应的索引),djwd_j^wdjw​表示的是该非叶子节点对应的huffman编码,作为左节点是0右节点是1。XwTX_w^TXwT​表示映射层的上下文向量,$ \theta_jw$表示非叶子节点向量。在这里$X_wT, \theta_j^w$都是变量,此时,对二者求偏导数:
∂L(w)∂θjw=∂L(w,j)∂θjw=[1−djw−σ(XwTθjw)]Xw\frac{\partial L(w)}{\partial \theta_j^w}=\frac{\partial L(w, j)}{\partial \theta_j^w}=[1-d_j^w-\sigma(X_w^T \theta_j^w)]X_w∂θjw​∂L(w)​=∂θjw​∂L(w,j)​=[1−djw​−σ(XwT​θjw​)]Xw​
∂L(w,j)∂Xw=[1−djw−σ(XwTθjw)]θjw\frac{\partial L(w, j)}{\partial X_w}=[1-d_j^w-\sigma(X_w^T \theta_j^w)]\theta_j^w∂Xw​∂L(w,j)​=[1−djw​−σ(XwT​θjw​)]θjw​
则∂L(w)∂Xw=∑j∈point∂L(w,j)∂Xw=∑j∈point[1−djw−σ(XwTθjw)]θjw则\frac{\partial L(w)}{\partial X_w}=\sum_{j\;\in\;point}\frac{\partial L(w, j)}{\partial X_w}=\sum_{j\;\in\;point}[1-d_j^w-\sigma(X_w^T \theta_j^w)]\theta_j^w则∂Xw​∂L(w)​=j∈point∑​∂Xw​∂L(w,j)​=j∈point∑​[1−djw​−σ(XwT​θjw​)]θjw​

再对应代码实现:

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 rateg = (1 - vocab[word].code[d] - f) * alpha;                                 //需要推导,得到这个梯度比例// Propagate errors output -> hiddenfor (c = 0; c < layer1_size; c++) neu1e[c] += g * syn1[c + l2];            //这个部分才是最终梯度值// Learn weights hidden -> outputfor (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模式
  负采样过程中,只有一个正样本也就是中心词,其他词都是负样本,将所有概率乘起来,使其最大。
对于单个样本uuu有:
p(u∣Context(w))={σ(XwTθu),Lw(u)=11−σ(XwTθu),Lw(u)=0p(u|Context(w))=\begin{cases} \sigma(X_w^T \theta^u), & \text { $L^w(u)=1$} \\ 1-\sigma(X_w^T \theta^u), & \text{$L^w(u)=0$} \end{cases}p(u∣Context(w))={σ(XwT​θu),1−σ(XwT​θu),​ Lw(u)=1Lw(u)=0​
则所有样本的概率之和为:
g(w)=σ(XwTθu)∏u∈NEG(w)[1−σ(XwTθu)]g(w)=\sigma(X_w^T \theta^u)\prod_{u\;\in\;NEG(w)}[1-\sigma(X_w^T \theta^u)]g(w)=σ(XwT​θu)u∈NEG(w)∏​[1−σ(XwT​θu)]
其对数似然函数为:
L(w,u)=logg(w)=log∏u∈{w}⋃NEG(w)([σ(XwTθu)]Lw(u)∗[1−σ(XwTθu)]1−Lw(u))L(w, u)=logg(w)=log\prod_{u\;\in\;\{w\} \bigcup NEG(w)} \left( [\sigma(X_w^T \theta^u)]^{L^w(u)}\;*\;[1-\sigma(X_w^T \theta^u)]^{1-L^w(u)}\right)L(w,u)=logg(w)=logu∈{w}⋃NEG(w)∏​([σ(XwT​θu)]Lw(u)∗[1−σ(XwT​θu)]1−Lw(u))
即为:
L(w,u)=Lw(u)log[σ(XwTθu)]+(1−Lw(u))log[1−σ(XwTθu)]L(w, u)=L^w(u)\;log[\sigma(X_w^T \theta^u)]+(1-L^w(u))\;log[1-\sigma(X_w^T \theta^u)]L(w,u)=Lw(u)log[σ(XwT​θu)]+(1−Lw(u))log[1−σ(XwT​θu)]
其中uuu表示随机选取的词样本, θu\theta^uθu是该词样本对应的向量,XwTX_w^TXwT​表示映射层的上下文向量,Lw(u)L^w(u)Lw(u)表示判断词uuu是不是当前窗口中心词www,1表示是,0表示不是。NEG(w)NEG(w)NEG(w)表示相对于中心词www进行的负采样集合。其中θu\theta^uθu和XwTX_w^TXwT​是变量,对二者求导:
∂L(w)∂θu=∂L(w,u)∂θu=[Lw(u)−σ(XwTθu)]Xw\frac{\partial L(w)}{\partial \theta^u}=\frac{\partial L(w, u)}{\partial \theta^u}=[L^w(u)-\sigma(X_w^T \theta^u)]X_w∂θu∂L(w)​=∂θu∂L(w,u)​=[Lw(u)−σ(XwT​θu)]Xw​
∂L(w,u)∂Xw=[Lw(u)−σ(XwTθu)]θu\frac{\partial L(w, u)}{\partial X_w}=[L^w(u)-\sigma(X_w^T \theta^u)]\theta^u∂Xw​∂L(w,u)​=[Lw(u)−σ(XwT​θu)]θu

∂L(w)∂Xw=∑u∈{w}⋃NEG(w)∂L(w,u)∂Xw=∑u∈{w}⋃NEG(w)[Lw(u)−σ(XwTθu)]θu\frac{\partial L(w)}{\partial X_w}=\sum_{u\;\in\;\{w\} \bigcup NEG(w)}\frac{\partial L(w,u)}{\partial X_w}=\sum_{u\;\in\;\{w\} \bigcup NEG(w)}[L^w(u)-\sigma(X_w^T \theta^u)]\theta^u∂Xw​∂L(w)​=u∈{w}⋃NEG(w)∑​∂Xw​∂L(w,u)​=u∈{w}⋃NEG(w)∑​[Lw(u)−σ(XwT​θu)]θu
导数就能够进行梯度上升求最大值。实现代码如下:

// 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,这个等式保证不为0if (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模型中,也是三层,输入层、映射层和输出层。结构如下:

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 -> outputfor (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 rateg = (1 - vocab[word].code[d] - f) * alpha;// Propagate errors output -> hiddenfor (c = 0; c < layer1_size; c++) neu1e[c] += g * syn1[c + l2];// Learn weights hidden -> outputfor (c = 0; c < layer1_size; c++) syn1[c + l2] += g * syn0[c + l1];}// NEGATIVE SAMPLINGif (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 vectorsfprintf(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;                                                                              //记录最近的类别idfor (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 classesfor (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

word2vec源码详解相关推荐

  1. 【Live555】live555源码详解(九):ServerMediaSession、ServerMediaSubsession、live555MediaServer

    [Live555]live555源码详解系列笔记 继承协作关系图 下面红色表示本博客将要介绍的三个类所在的位置: ServerMediaSession.ServerMediaSubsession.Dy ...

  2. 【Live555】live555源码详解系列笔记

    [Live555]liveMedia下载.配置.编译.安装.基本概念 [Live555]live555源码详解(一):BasicUsageEnvironment.UsageEnvironment [L ...

  3. 【Live555】live555源码详解(八):testRTSPClient

    [Live555]live555源码详解系列笔记 继承协作关系图 下面红色表示本博客将要介绍的testRTSPClient实现的三个类所在的位置: ourRTSPClient.StreamClient ...

  4. 【Live555】live555源码详解(七):GenericMediaServer、RTSPServer、RTSPClient

    [Live555]live555源码详解系列笔记 继承协作关系图 下面红色表示本博客将要介绍的三个类所在的位置: GenericMediaServer.RTSPServer.RTSPClient 14 ...

  5. 【Live555】live555源码详解(六):FramedSource、RTPSource、RTPSink

    [Live555]live555源码详解系列笔记 继承协作关系图 下面红色表示本博客将要介绍的三个类所在的位置: FramedSource.RTPSource.RTPSink 11.FramedSou ...

  6. 【Live555】live555源码详解(五):MediaSource、MediaSink、MediaSession、MediaSubsession

    [Live555]live555源码详解系列笔记 继承协作关系图 下面红色表示本博客将要介绍的四个类所在的位置: MediaSource.MediaSink.MediaSession.MediaSub ...

  7. 【Live555】live555源码详解(四):Medium媒体基础类

    [Live555]live555源码详解系列笔记 7.Media Medai所依赖关系图 依赖Medai关系图 Media和UsageEnvironment关联图

  8. 【Live555】live555源码详解(二):BasicHashTable、DelayQueue、HandlerSet

    [Live555]live555源码详解系列笔记 3.BasicHashTable 哈希表 协作图: 3.1 BasicHashTable BasicHashTable 继承自 HashTable 重 ...

  9. 【Live555】live555源码详解(一):BasicUsageEnvironment、UsageEnvironment

    [Live555]live555源码详解系列笔记 类关系图 1.UsageEnvironment 详解 1.1 BasicUsageEnvironment BasicUsageEnvironment ...

  10. udhcp源码详解(五) 之DHCP包--options字段

    中间有很长一段时间没有更新udhcp源码详解的博客,主要是源码里的函数太多,不知道要不要一个一个讲下去,要知道讲DHCP的实现理论的话一篇博文也就可以大致的讲完,但实现的源码却要关心很多的问题,比如说 ...

最新文章

  1. 【原创】Kakfa metrics包源代码分析
  2. 正则表达式 特殊符号系列 通配符系列
  3. 判断网页pdf还是html,html5 – 如何知道PDF.JS是否完成渲染?
  4. AjaxUploader使用
  5. [转载]sql(hive)中变量的使用
  6. 获取apk安装包sha1的值
  7. 关于学习session的一二
  8. 网站盗取html文件工具,一键获取仿站精灵
  9. html中怎样滚动图片,CSS如何实现滚动的图片栏(代码实例)
  10. Linux终端快捷键
  11. Few-Shot Object Detection with Attention-RPN and Multi-Relation Detector论文解读
  12. Verilog语言乒乓球机8段译码器
  13. Springboot AMR格式音频转换MP3
  14. H3C无线AP 瘦模式转胖模式 fit转fat
  15. 软件产品选型测试/POC测试
  16. leetcode-460:LFU 缓存
  17. 金山办公财报首秀:订阅业务、云办公激增,广告疲软
  18. 远离那些聪明,却不厚道的人
  19. FPGA(四):高级设计
  20. [ACNOI2022]我不会GF啊

热门文章

  1. tc7102路由器虚拟服务器,国内电信推首款WiFi 6+路由器TC7102,5G和WiFi 6成黄金搭档-贤集网...
  2. Oracle DB 移动数据
  3. 愿岁月流成花海,你我结伴而行
  4. Android系统中设置TextView的字符间距
  5. Win10系统导出证书私钥及公钥
  6. linux解压文件后重命名,tar 解压,重命名
  7. UE4:转换成VR项目
  8. C++读入用逗号隔开的数据
  9. 字符串拼接用逗号隔开的四种方法
  10. 零基础Matlab Note9--离散数据绘图set(gca, )