01
网络初始化参数
这一块内容主要涉及Huffman树和UnigramTable的的构建,分别为后续的Hierarchical Softmax和Negative Sampling的前置基础工作。1、InitNet
该部分主要初始化一个projection layer的网络结构,其中vocab_size为词表长度,layer1_size为输出的词向量长度,如果使用Hierarchical Softmax ,即hs=1,则会初始化一个vocab_size*layer1_size全0的辅助向量syn1,用来存储构建好的Haffman树中的非叶子节点。 若使用Negative Sampling,则会初始化一个vocab_size*layer1_size全0的辅助向量syn1neg。 syn0则为我们最后需要的词向量,初始化为[-0.5/layer1_size, 0.5/layer1_size]区间内vocab_size*layer1_size长度的随机向量。 具体的代码如下:void InitNet() { long long a, b; unsigned long long next_random = 1; 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) { 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; } 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; } 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; }}
2、CreateBinaryTree
该部分就是创建Haffman二叉树,主要是为Hierarchical Softmax方法的实现提供相对应的数据结构基础。 Haffman树建立的伪代码如下: 输入:权值为(?1,?2,...??)的?个节点 输出:对应的Haffman树 1)将(?1,?2,...??)看做是有?棵树的森林,每个树仅有一个节点。; 2)在森林中选择根节点权值最小的两棵树进行合并,得到一个新的树,这两颗树分布作为新树的左右子树。新树的根节点权重为左右子树的根节点权重之和; 3) 将之前的根节点权值最小的两棵树从森林删除,并把新树加入森林; 4)重复步骤2)和3)直到森林里只有一棵树为止。 根据Haffman树的特性,权值越高的词其二叉树上的路径越短,即二进制编码越短,且更靠近根节点,这也符合我们的业务认知,即越常用的词拥有更短的编码,word2vec里创建Haffman树的代码如下。void CreateBinaryTree() { long long a, b, i, min1i, min2i, pos1, pos2, point[MAX_CODE_LENGTH]; char code[MAX_CODE_LENGTH]; 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; for (a = 0; a < vocab_size - 1; a++) { 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; } for (a = 0; a < vocab_size; a++) { b = a; i = 0; while (1) { code[i] = binary[b]; point[i] = b; 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; } } free(count); free(binary); free(parent_node);}
首先我们需要定义point和code,其中point用来存储一个词到根节点的Haffman树路径,code用来存储一个词的Haffman编码。代码里同时也定义了count数组、binary数组以及parent_node数组,长度均为vocab_size*2+1, 其中count数组前vocab_size为Haffman树的叶子节点,
初始化为词表中全部单词的词频,它后vocab_size为Haffman树中即将生成的非叶子节点的词频,通过合并子节点的权值得到,初始化为1e15。binary数组记录的是各个节点相对于其父节点的二进制编码,parent_node则记录了每个节点的父节点。
接着就是构建Haffman树,注意下这里的输入的count数组的前vocab_size已经按照词频做了降序排序,初始的pos1,pos2分别为词表中词频最低词和第一个1e15的索引,后面通过对比当前的pos1和pos2对应单词的词频大小,来更新作为当前词频(包括合并后)最小和次小节点的索引min1i、min2i。
这边以原始序列{'u': 7, 'v': 8, 'w': 9, 'x': 10, 'y': 19}为例,对应的初始化词频为[19, 10, 9, 8, 7],对应的count数组为[19, 10, 9, 8, 7, 1e15, 1e15, 1e15, 1e15, 1e15]。
第一轮迭代,a为0,pos1为4,pos2为5,count[pos1]为7,count[pos2]为1e15,此时count[pos1] < count[pos2],min1i为4,pos1递减为3,count[pos1]为8,count[pos2]为1e15,count[pos1] < count[pos2],min2i=3, pos1递减为2,count[5+0]被赋值为count[3]+count[4]=15。
第二轮迭代,a为1,pos1为2,pos2为5,count[pos1]为9,count[pos2]为15,此时count[pos1] < count[pos2],min1i为2,pos1递减为1,count[pos1]为10,count[pos2]为1e15,count[pos1] < count[pos2],min2i=1, pos1递减为0,count[5+1]被赋值为count[1]+count[2]=19。
第三轮迭代,a为2,pos1为0,pos2为5,count[pos1]为19,count[pos2]为15,此时count[pos1] > count[pos2],min1i为5,pos2递增为6,count[pos1]为19,count[pos2]为19,count[pos1] = count[pos2],min2i=6, pos2递增为7,count[5+2]被赋值为count[5]+count[6]=34。
第四轮迭代,a为3,pos1为0,pos2为7,count[pos1]为19,count[pos2]为34,此时count[pos1] < count[pos2],min1i为0,pos1递减为-1,此时pos1小于0,min2i=7, pos2递增为8,count[5+3]被赋值为count[0]+count[7]=53。
设立临时变量b,主要是获取当前词在parent_node里的路径,该路径也直接对应了binary里的索引;
由于Haffman树一共有vocab_size*2-1个节点,所以vocab_size*2-2为根节点,代码里以此为循环的终止条件,即路径走到了根节点;
由于Haffman编码和路径是从根节点到叶子结点的,因此需要对之前得到的code和point进行reverse操作, 路径那边也要减去vocab_size。
3、InitUnigramTable
为词表中的每个词构建一个幂律分布(power law distribution)表,用于后续的负采样,这边对词频取0.75次幂实际上属于一种平滑策略,缩小词与词之间的词频差距,具体代码如下。void InitUnigramTable() { int a, i; double train_words_pow = 0; double d1, power = 0.75; table = (int *)malloc(table_size * sizeof(int)); 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; 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; }}
初始化的table_size为1e8,然后遍历词表, train_words_pow为每个词对应的词频cn^0.75的累加。接着计算单个词对于train_words_pow的比率d1,如果a/table_size一直不超过当前词的d1的话,i就不会递增,这就意味着当前词会一直占据a+n-a个位置, 否则会累加更新d1的值,累加的原因是不需要从头开始比较,a位置一直往后,对应的d1应该也在之前的基础上累加,保持相对位置不变,绝对位置的更新。这样每个词都会对应一段区间,在同一个区间的词都是一样的,所以在采样的时候,只需指定neg个索引位置,必然会取到neg个词。
![e60f62a81b7d9e726df8f5b7f9e11d43.png](https://img-blog.csdnimg.cn/img_convert/e60f62a81b7d9e726df8f5b7f9e11d43.png)
02
训练模块
无论是CBOW还是Skip-gram在代码里都使用了下面几个变量,在此先对他们做个介绍:neu1:输入的词向量【在CBOW是窗口中各个词的向量和,在skip-gram是中心词的词向量】,长度为layer1_size,初始化为0,用于更新syn1;
neu1e:长度为layer1_size,初始化为0,用于更新syn0;
alpha:学习率;
last_word:记录当前扫描到的上下文单词索引。
1、CBOW
CBOW需要我们根据上下文的窗口词来预测中心词,这里先计算窗口词向量的和并求均值,cw为窗口长度,建立起中心词和窗口词的关联。for (c = 0; c < layer1_size; c++) neu1[c] += syn0[c + last_word * layer1_size];for (c = 0; c < layer1_size; c++) neu1[c] /= cw;
1.1 使用Hierarchical Softmax进行训练
根据前文构建的Haffman树,遍历从根节点到当前词的叶子节点路径中所有经过的中间节点,l2为当前遍历到的中间节点的向量在syn1中的位置, f为syn1在l2处对应的neu1在layer1_size范围内的累加,其实本质就是neu1和 中间节点向量的内积。这边的codelen即为树深度,循环的时候可以直接过滤掉上述例子'w'对应的point中的-3。 接着就是对f做Sigmoid变换处理,这里是维护了一个expTable数组,我们可以根据索引直接查表得知变换的结果。然后再对f和之前得到的每个词的Haffman编码计算误差并进行梯度更新,需要注意的是这里编码为1的节点会被定义成负类,0为正类,即真实label为1-vocab[word].code[d],最后就是更新neu1e和syn1对应位置的向量,其中每一次迭代即为一次逻辑回归,整体其实就是一个利用随机梯度提升来最大化对数似然函数的过程。for (d = 0; d < vocab[word].codelen; d++) { f = 0; l2 = vocab[word].point[d] * layer1_size; for (c = 0; c < layer1_size; c++) f += neu1[c] * 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 = (1 - vocab[word].code[d] - f) * alpha; for (c = 0; c < layer1_size; c++) neu1e[c] += g * syn1[c + l2]; for (c = 0; c < layer1_size; c++) syn1[c + l2] += g * neu1[c]; }
1.2 使用Negative Sampling进行训练
其中negative为负样本的数量,当d为0即获取的是目标词,指定其为正样本,否则就从UnigramTable中随机抽取negative个负样本(不能和当前正样本一样)。这边的l2为syn1neg中目标单词的位置,后面的基本和Hierarchical Softmax一致,不再多做赘述。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]; }
最后我们再根据得到的neu1e来即时地更新syn0,从而获取最终训练得出的vocab_size*layer1_size的词向量。
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-gram
Skip-gram需要我们根据输入的中心词来预测该单词上下文窗口词,代码里的last_word为当前待预测的上下文单词,l1为当前单词的词向量在syn0的位置,初始化neu1e为0。 训练这部分大体和前面一致,区别就在于前面是用neu1[c]来和中间节点向量的内积,而这边直接用syn0[c + l1]替换neu1[c], syn0[c + l1]在这里为单个词的词向量,而neu1[c]则为窗口词向量的平均。另外需要注意的是Skip-gram虽然是给定中心词来预测上下文,但真正在训练的时候仍是用上下文预测中心词。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; l1 = last_word * layer1_size; for (c = 0; c < layer1_size; c++) neu1e[c] = 0; if (hs) for (d = 0; d < vocab[word].codelen; d++) { f = 0; l2 = vocab[word].point[d] * layer1_size; 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 = (1 - vocab[word].code[d] - f) * alpha; for (c = 0; c < layer1_size; c++) neu1e[c] += g * syn1[c + l2]; 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]; }
![255eaf6c358ef5e45a2bff46dcb4c36e.gif](https://img-blog.csdnimg.cn/img_convert/255eaf6c358ef5e45a2bff46dcb4c36e.gif)
![255eaf6c358ef5e45a2bff46dcb4c36e.gif](https://img-blog.csdnimg.cn/img_convert/255eaf6c358ef5e45a2bff46dcb4c36e.gif)
![255eaf6c358ef5e45a2bff46dcb4c36e.gif](https://img-blog.csdnimg.cn/img_convert/255eaf6c358ef5e45a2bff46dcb4c36e.gif)
代码里的其他一些tricks:
读取文件时,每行末尾加入了一个"",便于训练。
如果词表大小超过上限,则做一次词表删减操作,将当前词频小于min_reduce(初始为1)的进行删除,该删除操作在一个while(1)循环里,min_reduce一次循环累加1,保证最大的单词数量不超过vocab_hash_size * 0.7,代码设定的vocab_hash_size = 30000000,即词表最多只能有2100万个词,否则就会一直删除低频词,直至满足这个条件为止, 而expTable的大小为1e8, 还是远超过2100万的,所以不必担心Negative Sampling无法训练的问题。
在初始学习率的基础上,随着实际训练词数的上升,逐步降低当前学习率(自适应调整学习率),并保证学习率不低于starting_alpha * 0.0001。
对高频词进行随机下采样,丢弃掉一些高频词,类似平滑处理,这样也可以加快训练速度。
句子长度超过MAX_SENTENCE_LENGTH,则做截断处理。
https://github.com/tmikolov/word2vec/blob/master/word2vec.c
https://github.com/deborausujono/word2vecpy
https://blog.csdn.net/EnochX/article/details/52847696
https://blog.csdn.net/EnochX/article/details/52852271
http://www.hankcs.com/nlp/word2vec.html
https://www.cnblogs.com/pinard/p/7160330.html