这篇博客主要讲解word2vec源码(c语言)中的一些技巧,通过这些技巧从而更好的理解word2vec.
1 sigmoid的近似求解
先来看看sigmoid的公式和函数以及导数曲线:
g
(
z
)
=
1
1
+
e
−
z
\bm{ g(z)=\frac{1}{1+e^{-z}} }
g(z)=1+e−z1
从函数曲线中我可以看出,sigmoid的取值在(0,1)之间。
在源码中作者为了减少计算量,通过将[-6,6)进行划分(划分成1000份),来近似的取值。
接下来先看源码的划分和使用
划分代码:
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)
}
使用代码:这里的MAX_EXP=6,f是要传给sigmoid的数值。
if (f <= -MAX_EXP) continue;
else if (f >= MAX_EXP) continue;
//将f对应的sigmoid的值从数组中取出来,这里的操作可以看成之前对sigmoid操作的逆操作。
else f = expTable[(int)((f + MAX_EXP) * (EXP_TABLE_SIZE / MAX_EXP / 2))];
接下来开始分析,可以将第一段代码看成编码操作,那么第二段代码就是解码操作;
上图中的?就是f
,因为代码段1中的x和代码段2中的f都是要传给sigmoid的所以是相等的。从而可以求出i(更新f)。
关于代码段1中x的划分,我是这样理解的,i/1000*12 代表长度上的比例-6是为了区分正负。
2 window_size的说明
首先假设window=c,在源码中,context(w)其实不一定是2c,实际上是中心词的两边都是w-b
个词,
看一下源码:
b = next_random % window;
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;
。。。。。
}
这里b的取值就是 [0,c-1]. 可以取值代入试一下。
3 低频词和高频词的处理
-
首先是低频词的处理,在低频词的处理上,源码主要在两个点使用,
-
1 在构建词典时候判断词典大小是否大于预设值的0.7,若大于则开始去除低频词(这里min_count=5),同时min_count++。
-
2 在构建完成时,按照词频进行排序,再次去除低频词。
-
这两个区别在于,
1中是在构建过程中进行的,也就是说去除完之后还要继续添加,还会存在低频词,所以执行了2.
-
接下来是高频词的处理,首先需要说明的是,代码是以行为单位进行的,所以在读取一行单词的时候,可能会存在一些很高频的词,对我们的模型并不影响,例如,的,是。。。。。这个时候把他们顾虑掉并不影响模型整体。来看一下代码(其中sample是预设的阈值默认0.001):
还需要解释的是cn是词频,train_words所有词的词频的和。
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;
if (ran < (next_random & 0xFFFF) / (real)65536) continue;
}
上述代码公式是这样子的(tw代替train_words, s代表sample,ran是p)sample取值范围是【0,1e-5】越小对高频词打压越严重:
从上面的公式中f越大代表,cn越大,f越大,p越小
,然后随机生成一个(0,1)之间的数,若是p越小,则生成的这个数越容易比p大,也就是越容易去除。
4 参数的初始化
源码中词向量对应的是syn0,syn1对应的是非叶子节点的向量,syn1neg是采样的点对应的向量。
这里syn1和syn1neg的初始化都是0,而syn0 的初始化区间是
[
−
0.5
m
,
0.5
m
]
[-\frac{0.5}{m},\frac{0.5}{m}]
[−m0.5,m0.5],其中m是词向量的大小。
5 负采样
- 负采样也是根据词频来进行采样的,cn越高,被采样的概率就越大,借用百度百科的解释:判断两个单词是不是一对上下文词(context)与目标词(target),如果是一对,则是正样本,如果不是一对,则是负样本。
采样得到一个上下文词和一个目标词,生成一个正样本(positive example),生成一个负样本(negative example),则是用与正样本相同的上下文词,再在字典中随机选择一个单词,这就是负采样(negative sampling)。
- 这里有点类似我小时候玩的一个游戏转动指针指针停在不同的地方有不同的奖品,扇形区域越小得到的奖品越好,这里也是同样的到理。
接下来看源码:
void InitUnigramTable() {
int a, i;
double train_words_pow = 0;
double d1, power = 0.75;
//table_size是对[0,1]的等距离分割。这里的table_size是一个很大的数值1e8。
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;
//cn大的对应的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;
}
}
再来看一下使用代码(其中word是中心词的index):
if (negative > 0) for (d = 0; d < negative + 1; d++) {
if (d == 0) {
target = word;
label = 1;
} else {//采样一个w,这里因为有几率采样到中心词和</s>所以给过滤掉了
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;
}
源码中还有很多写的很不错的地方,都值得大家琢磨,例如Huffman树的生成代码等等。
接下来的这一段,需要对word2vec的原理有基本的了解之后再看,不然会一头雾水。
6 投影层到输出层的整体框架
先来看伪代码:
void *TrainModelThread(void *id) {
******每次读取一行************
if (sentence_length == 0) {
while (1) {
word = ReadWordIndex(fi, &eof);
。。。。。。
去除高频词。。。。
存储。。。。
}
sentence_position = 0;
}
-----------------------------------------
if (cbow) { //train the cbow architecture
依次取上下文词并求和
if (cw) {
将上下文向量的和求平均;接下来是cbow-hs
这里每次循环是 上下文向量的平均 和 从头节点到叶节点之间的非叶子节点对应的向量做内积,然后,接着计算梯度等
if (hs) for (d = 0; d < vocab[word].codelen; d++) {
计算梯度
}
其实从这里可以看出来,hs和ns并不是只能执行一个,我们把hs大于0,ns默认5,两个就都会执行。
NEGATIVE SAMPLING
if (negative > 0) for (d = 0; d < negative + 1; d++) {
计算梯度
}
cbow词向量的更新
}
}
***************************************************
else { //train skip-gram
for (a = b; a < window * 2 + 1 - b; a++) if (a != window) {
取词
// HIERARCHICAL SOFTMAX
if (hs) for (d = 0; d < vocab[word].codelen; d++) {
计算梯度
}
// NEGATIVE SAMPLING
if (negative > 0) for (d = 0; d < negative + 1; d++) {
采样
计算梯度
}
更新词向量
}
}
*****************************************
判断是否将这一行处理完毕
}
syn0:词向量;
syn1:非叶子节点对应的向量;
word:中心词;
syn1neg:采样词对应的向量;
6.1 cbow-hs
先来看最简单的cbow-hs,其中涉及到的Huffman树的构建就不详细说了。这里只是分析代码的流程。
这里每次循环是 上下文向量的平均 和 从头节点到叶节点之间的非叶子节点对应的向量做内积(这里的路径是由word也就是中心词决定的),然后,接着计算梯度等
,具体代码如下:
if (hs) for (d = 0; d < vocab[word].codelen; d++) {
f = 0;
//point存储的是从根节点到叶节点路径,
l2 = vocab[word].point[d] * layer1_size;
// Propagate hidden -> output
//线性运算,将context(w)平均之后的向量neu1(依次和从头节点到叶节点之间的非叶子节点对应的向量做内积)
//非叶子节点对应的向量存储在syn1中,point中存储的是从头到叶子节点对应的非叶子节点在syn1中对应的下标。
for (c = 0; c < layer1_size; c++) f += neu1[c] * syn1[c + l2];
//若f不在[-6,6)之间则跳过
if (f <= -MAX_EXP) continue;
else if (f >= MAX_EXP) continue;
//将f对应的sigmoid的值从数组中取出来,这里的操作可以看成之前对sigmoid操作的逆操作
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
//这里的neu1e可以理解为记录梯度
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];
}
6.2 cbow-ns
我们先来看一下似然函数:
L
=
∏
w
∈
c
{
σ
(
x
w
T
θ
w
)
∏
u
∈
N
E
G
(
W
)
[
1
−
σ
(
x
w
T
θ
u
)
]
}
L=\prod\limits_{w\in{c}} \{\sigma(x_w^{T}\theta^{w})\prod\limits_{u\in{NEG(W)}}[ 1-\sigma(x_w^{T}\theta^{u})]\}
L=w∈c∏{σ(xwTθw)u∈NEG(W)∏[1−σ(xwTθu)]}
其中
x
w
x_{w}
xw是上下文词向量的平均,
θ
w
\theta^w
θw代表正例,而
θ
u
\theta^u
θu代表反例(具体怎么采样前面有说过)
σ
(
x
w
T
θ
w
)
\sigma(x_w^{T}\theta^{w})
σ(xwTθw)可以理解为上下文词向量预测中心词正确的概率,那么,最大化似然函数相当于最大化
σ
(
x
w
T
θ
w
)
\sigma(x_w^{T}\theta^{w})
σ(xwTθw)和最小化
σ
(
x
w
T
θ
u
)
\sigma(x_w^{T}\theta^{u})
σ(xwTθu)。步骤:
1 取context(w)的词向量的平均;
2 对中心词进行负采样;
3 将上下文向量的均值向量依次和中心词以及负采样的词的向量 来计算梯度 ;
4 更新词向量。
再来看源代码:
// 上下文词向量的平均neu1和中心词以及采样出来的词向量预测中心词
//这里word是中心词的index
if (negative > 0) for (d = 0; d < negative + 1; d++) {
if (d == 0) {
target = word;
label = 1;
} else {//采样一个w,这里因为有几率采样到中心词和</s>所以给过滤掉了
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;
//这里需要注意的是synneg1是采样出来的词的词向量
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];
}
6.3 skip-gram-hs
从理解的角度,skip-gram应该是中心词预测两边的词,也就是构建
p
(
x
i
∣
x
w
)
,
i
=
1
,
2
,
.
.
.
.
,
2
c
p(x_i|x_w),i=1,2,....,2c
p(xi∣xw),i=1,2,....,2c,在代码中并不是这么做的,而是在构建
p
(
x
w
∣
x
i
)
p(x_w|x_i)
p(xw∣xi),可能是处于上下文的预测的等价性,这样使得一次更新的词向量更多吧。也可以说是另一种cbow。流程如下:
1 取context(w)的每一个向量;
2 每一个上下文向量都和 路径上的非叶子节点所对应的向量 进行计算梯度。
3 每一个上下文向量结束都更新一次上下文词向量
//代码层次理解:skip-gram的hs 是依次将2c个上下文向量和不同hs非叶子节点计算内积。。。。等。是双层循环
//从skip-gram原理上理解其实应该是一个中心词预测两边的词,因为最大化p(xi|xw)和p(xw|xi)是等价的,
//所以作者采取的方式是后者,这样子更新的词向量更多一点。(也可以说是变相的cbow)
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;
//取出每个2c对应的词向量在syn0里面的开始的下标
l1 = last_word * layer1_size;
for (c = 0; c < layer1_size; c++) neu1e[c] = 0;
// HIERARCHICAL SOFTMAX
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];
}
}
6.4 skip-gram-ns
在上面代码理解的基础上,来分析skip-gram-ns。
步骤:
1 对中心词进行负采样,(一共执行2c次,每次采样neg个加上正例就是neg+1)
2 将每一个context(w)分别和每次对中心词负采样的词的向量传个sigmoid进行梯度计算。
3 更新词向量
for i=1 to 2c :
e
=
0
e=0
e=0
for j=0 to (1+neg):
f
=
σ
(
x
w
i
T
θ
w
j
)
f=\sigma(x_{wi}^T\theta^{w_j})
f=σ(xwiTθwj)
g
=
(
y
i
−
f
)
η
g=(y_i-f)\eta
g=(yi−f)η
e
=
e
+
g
θ
w
j
e=e+g\theta^{wj}
e=e+gθwj
θ
w
j
=
θ
w
j
+
g
x
i
\theta{wj}=\theta{wj}+gx_i
θwj=θwj+gxi
源代码:
for (a = b; a < window * 2 + 1 - b; a++) if (a != window) {
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];
}
}
我注释的word2vec源码供参考:word2vec.c
参考
https://www.cnblogs.com/pinard/category/894695.html
https://www.cnblogs.com/peghoty/p/3857839.html