![b5a5ca2fe33a4fc0b31e9fb3d68651e6.png](https://i-blog.csdnimg.cn/blog_migrate/10c4b5abe9a40e96f7129a7a9e1f8e27.png)
Word2Vec
目录
- 1.前记
- 2.一些背景知识
- 2.1词向量简单介绍
- 2.2哈弗曼树简单介绍
- 3.基于层次softmax的模型
- 3.1COBW 层次softmax
- 3.1.1整体结构
- 3.1.2 前向传播和反向传播推导
- 3.2 Skip-gram 层次softmax
- 3.2.1 整体结构
- 3.2.2 前向传播和反向传播推导
- 3.1COBW 层次softmax
- 4.基于负采样的模型
- 4.1 负采样算法简单介绍
- 4.2 CBOW 负采样
- 4.2.1 前向传播
- 4.2.2 反向传播
- 4.3 Skip-gram 负采样
- 4.3.1 前向传播
- 4.3.2 反向传播
- 5. 后记
好像知乎并不支持LaTex公式显示以及部分markdown语法, 大家如果想看公式, 建议直接去我的博客看Word2Vec详解-公式推导以及代码, 最近好像知乎可以输入公式了, 就是稍微有点小麻烦, 我会慢慢把公式都改成正常的样子.
1.前记
这篇Word2Vec介绍,大量参考了word2vec中的数学
这份pdf,感谢peghoty大神的教程,我将这份教程的pdf版本放在了github上面,点击跳出.这里同时有一份我改写的Python版本的word2vec的代码,包含本次讲解里面的所有内容,大家可以参考一下.
除此之外,我也参考了java版本的和C语言版本的word2vec代码,最终才写出来了Python版本的,附上链接:
dav/word2vec
liuwei1206/word2vec
linshouyi/Word2VEC_java
word2vec C语言注释版本
我不建议大家直接看原作者的论文,因为原作者的论文写的太简练了,以至于很难读懂,大家直接看代码,会明白的更多,会对更多的细节有更多的理解.这里我不建议在你找了很多资料依旧看不懂的情况下再继续找更多的网上资料来看,因为网上说的大都是一些个人的理解,而且关于公式的推导偏少,大都浅尝辄止,我强烈建议大家在看完公式推导之后,直接就看源代码,这样你肯定会明白更多word2vec的内部原理.同时因为本人水平有限,有些地方不对的地方,还请指出.
2.一些背景知识
2.1词向量简单介绍
词向量,简单的来说,就是把我们习以为常的汉字,字母等转换成数字
,因为对于计算机而言,它只能读懂二进制数字
,但是对于人而言,十进制数字会比二进制数字更加容易理解一些,所以人们先将词转换成了十进制的数字
.
对于计算机而言,词向量的转换是nlp方向特有的一种数据处理方式,因为在cv领域,图像本身就是按照数字存储在计算机中的,而且这些数字本身就已经包含了某些信息,同时每组不同的数字之间已经包含一些关系了,例如两张都是大海的图片,那么两张图片里面蓝色偏多,然后两张图片的数字RGB
里面的B
的占比就会比较大,当然还会有别的特征联系,但是因为人本身对数字的不敏感,所以有些信息人们是直接发现不了.
词向量的质量直接影响了之后的nlp的处理,例如机器翻译,图片理解等等,没有一个好质量的词向量,机器翻译的质量肯定是没法很好的提升的.
当初,人们的做法非常简单,直接把词映射为独热编码,例如I like writing code
,那么转换成独热编码就是:
单词|独热编码 --|--| I|0001 like|0010 writing|0100 code|1000
这么看着感觉还行吧,成功的把单词转换成了编码,这样是不是就可以了呢?
答案是肯定不行的,因为这么做最明显的缺点就是,单词之间的联系没有了,比如说I
和like
之间的关系和like
和writing
之间的关系,通过0001和0010
和0010和0100
怎么表现,通过距离?通过1的位置?你会发现独热编码完全没法表现单词之间的任何关系.
除此之外,当你的词汇量达到千万甚至上亿级别的时候,你会遇到一个更加严重的问题,维度爆炸了.这里举例使用的是4个词,你会发现,我们使用了四个维度,当词数量达到1千万的时候,词向量的大小变成了1千万维,不说别的,光内存你都受不了这么大的词向量,假设你使用一个bit来表示每一维,那么一个单词大概需要0.12GB的内存,但是注意这只是一个词,一共会有上千万的词,这样内存爆炸了.当维度过度增长的时候,你还会发现一个问题,你会发现0特别多,这样造成的后果就是整个向量中,有用的信息特别少,几乎就没法做计算.并且在高维空间中,所有的点几乎都是均匀分布的,这样的话,你根本就没法对词进行划分.
综上,独热编码完全没法用的
所以我们需要做的是,用一个稠密的向量,来表示单词,还是上面例子,例如使用下面的方式进行表示(下面的只是举例随便写的向量):
单词|稠密向量 --|--| I|[0.112] like|[0.224] writing|[0.512] code|{0.912}
我们可以看到,以前使用4维才能描述的数据,这里使用1维就可以描述了,当然这里只是举例,实际使用过程中,我在代码中使用的数据集中的有效词汇量大概是7万多,总的词汇在接近2千万,使用的维度实际是200维度的,再压缩一点我感觉也是可以的.
如何生产稠密的向量,是一个难题,这个时候,Word2vec出来了,层次softmax的word2vec本质上应该更加接近是BP神经网络,因它的整体运行模式和神经网络的前向传播和反向传播非常类似.
2.2哈弗曼树简单介绍
哈弗曼树是指给定N个权值作为N个叶子结点,构造一棵二叉树,若该树的带权路径长度达到最小,称这样的二叉树为最优二叉树,也称为哈夫曼树(Huffman Tree)。哈夫曼树是带权路径长度最短的树,权值较大的结点离根较近。(来自百度百科)
下面的示意图表现了哈弗曼树的构建过程,实际上这个过程也是原作者在代码中构建哈弗曼树的过程,原代码作者在构建哈弗曼树的时候并没有使用指针之类的方式进行构建,而是采用了一种稍微有点抽象的方式,应该说是原作者存储的是数组的下标的位置,构建的一个比较"抽象"的哈弗曼树.大家有机会可以去阅读一下最初的C语言的代码,原作者写的是真的好. 下图中红色是叶子节点,也即是词汇,数字代表该单词出现的频率.
![1a49f7173db5f97fa2d203f80be91d5a.png](https://i-blog.csdnimg.cn/blog_migrate/272711cdf106c800768cc40b43fac890.jpeg)
为什么这里会使用到哈弗曼树呢?因为这里需要使用层次softmax,所以需要构建一个哈弗曼树.构建好一个哈弗曼树之后,我们可以有效的减少计算量,因为词频比较高的词都比较靠近树的根部,因为对词频比较高的词的更新会比较频繁,所以每次进行计算的时候,可以有效的减少对树的遍历深度,也就减少了计算量.
当然上面说的是一个方面,其次,还有别的好处,但是因为个人水平有限,这里就不再继续探讨.
3.基于层次softmax的模型
基于层次softmax的模型,主要包括输入层,投影层和输出层
,非常的类似神经网络结构.CBOW的方式是在知道词
![497faf7f69230793260f373a3c3652fe.png](https://i-blog.csdnimg.cn/blog_migrate/56dab62128235e4f077e79a90d83f99d.jpeg)
而基于层次softmax的CBOW方式,我们需要最终优化的目标函数是
简单的说可以认为这个是层次softmax的公式,其中$Context(w)$表示的是单词$w$的的上下文单词,而基于Skip-gram的方式的最终需要优化的目标函数是:
下面的讨论计算中,我们主要关注的是如何构造
看到这里,估计你看的也是云里雾里,而且网上大部分说的几乎都和这个差不多,然后网上还有很多说词向量只不过是这整个模型的副产物,从某些角度来说,说词向量是这些模型的副产物也对,因为实际上这些模型的目标是给定一个上下文,然后可以预测一个词,或者给定一个词,可以预测上下文.但是在我看来,这个模型实际上想要产生的就是词向量,只不过是通过预测词或者预测上下文的方式来构造词向量,因为这样构造出来的词可以很好的体现词之间的关系.不过这些其实都不重要,如果你真的想明白word2vec,你需要做的是继续阅读,然后尽量把下面的公式
自己推导一遍.
3.1COBW 层次softmax
3.1.1整体结构
下图给出了基于层次softmax的CBOW的整体结构,首先它包括输入层,投影层和输出层:
![1098c5e8045e00b3dfe74d6d6e750531.png](https://i-blog.csdnimg.cn/blog_migrate/2dea4f2b51ce0f6018714a4df9d6ca33.jpeg)
其中输入层是指
然后投影层这里指的是直接对
最后是输出层,输出层是一个哈弗曼树,然后其中叶子节点是N个,对应于N个单词(对应于红色节点),其中非叶子节点N-1个(对应于绿色节点)
.word2vec基于层次softmax的方式主要的精华部分都集中在了哈弗曼树这部分.下面慢慢介绍
3.1.2 前向传播和反向传播推导
为了便于下面的介绍和公式的推导,这里需要预先定义一些变量:
-
:从根节点出发,然后到达单词$w$对应叶子节点的路径
-
:路径
中包含的节点的个数
-
: 路径
中对应的各个节点,其中
代表根节点,而
代表的是单词
对应的节点
-
: 单词
对应的哈夫曼编码,一个词的哈夫曼编码是由
位构成的,
表示路径
中的第j个单词对应的哈夫曼编码,因为根节点不参与对应的编码
-
: 路径
中非叶子节点对应的向量,
表示路径
中第
个非叶子节点对应的向量.
这里之所以给非叶子节点定义词向量,是因为这里的非叶子节点的词向量会作为下面的一个辅助变量进行计算,下面的公式推导的时候就会发现它的作用
 既然已经引入了那么多符号,那么我们通过一个简单的例子来看一下实际的运行情况,我们考虑单词w="世界"
,然后下图中黄色线路就是我们的单词走过的路径,整个路径上的4个节点就构成了路径
下面先进行前向传播的公式推导:
![80f410885c7f182dc81eab9862a1481a.png](https://i-blog.csdnimg.cn/blog_migrate/c46f5b20ee921fcdc6980ba0a967afea.jpeg)
下面我们需要开始考虑如何构建概率函数
既然是二分类,那么我们可以定义一个为正类,一个为父类.我们还有"世界"的哈夫曼编码,为101,这个哈夫曼编码是不包含根节点的,因为根节点没法分为左还是右子树.那么根据哈夫曼编码,我们一般可以把正类就认为是哈夫曼编码里面的1,而负类认为是哈夫曼编码里面的0.不过这个只是一个约定而已,因为哈夫曼编码和正类负类之间并没有什么明确要求对应的关系.但是原作者看来并不喜欢一般,原作者在写的时候,将编码为1的认定为负类,而编码为0的认定为正类,也就是说如果分到了左子树,就是负类,分到了右子树,就是正类.那么我们可以定义一个正类和负类的公式:
公式中,刚好正类和负类是和编码相反的.
在进行二分类的时候,这里选择了sigmoid函数.虽然sigmoid函数存在梯度消失的问题,但是源代码中进行了一些处理,稍微避免了这个问题
![c4623f4f23fed4963839acda04819ec3.png](https://i-blog.csdnimg.cn/blog_migrate/180855512c44fb7efe141d312ae2715e.jpeg)
那么分为正类的概率就是
那么分为负类的概率就是
上面公式里面包含的有$theta$,这个就是非叶子对应的向量 对于从根节点出发到达“世界”这个叶子节点所经历的3次二分类,每次分类的概率写出来就是:
- 第一次分类:$p(d^w_2|x_w,theta^w_1)=1-sigma(x^T_wtheta^w_1)$
- 第二次分类:$p(d^w_3|x_w,theta^w_2)=sigma(x^T_wtheta^w_2)$
- 第三次分类:$p(d^w_4|x_w,theta^w_3)=sigma(x^T_wtheta^w_3)$
那么,我们就可以得到$p(w|Context(w))$为:
$$ p("世界"|Context(“世界”))=prod_{j=2}^{4}p(d^w_j|x_w,theta^w_{j-1}) $$
这里应该说是贝叶斯公式的思想,对于词典中的任意一个单词$w$,哈夫曼树中肯定存在一个通路,从根节点到单词$w$的路径$p^w$,而路径$p^w$这条路并不是一条直线,每经过一个非叶子节点,肯定需要进行一次二分类,每次分类就会产生一个概率,我们将这些所有的概率都乘起来,那么我们就可以得到我们需要的$p(w|Context(w))$。
条件概率$p(w|Context(w))$一般写为:
$$ p(w|Context(w))=prod_{j=2}^{l^w}p(d^w_j|x_w,theta^w_{j-1}) (3.2) $$
其中:
$$ p(d^w_j|x_w,theta^w_{j-1})=left{begin{matrix} sigma(x^T_wtheta^w_{j-1}), & d^w_j=0 1 - sigma(x^T_wtheta^w_j-1), & d^w_j=1 end{matrix}right. $$
将上面的两个公式合并到一起
$$ p(d^w_j|x_w,theta^w_{j-1})=[sigma(x^T_wtheta^w_{j-1})^{1-d^w_j}cdot [1-sigma(x^T_wtheta^w_{j-1})^{d^w_j}]] $$
将(3.2)带入(3.1)中,得到
$$ zeta =sum_{w in C} log prod_{j=2}^{l^w}{{[sigma(x^T_wtheta^w_{j-1})]^{1-d^w_j}cdot [1-sigma(x^T_wtheta^w_{j-1})]^{d^w_j}}} = sum_{w in C} sum_{j=2}^{l^w}{(1-d^w_j) cdot log [sigma(x^T_w theta ^w_{j-1})] + d^w_j cdot log [1-sigma(x^T_w theta ^w_{j-1})] } (3.3) $$
为了推导方便,我们直接把累加里面的部分提取出来:
$$ zeta(w,j)=(1-d^w_j) cdot log [sigma(x^T_w theta ^w_{j-1})] + d^w_j cdot log [1-sigma(x^T_w theta ^w_{j-1})] $$
至此,前向传播的公式已经全部推导完毕,下面开始反向传播的推导
Word2Vec中采用的是随机梯度上升法
,为什么采用随机梯度上升法呢?在一般的神经网络中,我们都是采用的随机梯度下降法,因为在那些优化的目标里面,是让损失值最小,所以采用让目标沿着梯度降低的方向进行计算。而在这里,我们想要让目标函数$zeta$最大,因为只有当$zeta$最大的时候,才说明了这个句子(单词)出现在语料库中的概率越大,其实就是说在强化一个词$w$和某些词(例如和$w$出现在一个句子中的词)的关系.
为了能够使用随机梯度上升法,我们需要先对相应的变量求梯度,观察公式$zeta(w,j)$,我们可以发现,其中的变量只有$x^T_w$和$theta^w_{j-1}$,其中$w in C, j=2, ..., l^w$.首先计算函数$zeta(w,j)$关于$theta^w_{j-1}$的导数:
在进行所有的推导之前,我们先对$sigmoid$函数进行求导,因为下面会用到:
$$ frac{Delta sigma(x)}{Delta x} = frac{e^x}{(e^x+1)^2}=sigma(x)(1-sigma(x)) $$
$$ begin {aligned} frac{Delta zeta(w,j)}{Delta theta ^w_{j-1}} &= (1-d^w_j)[1- sigma(x^T_w theta ^w_{j-1})]x_w - d^w_j sigma (x^T_w theta^w_{j-1})x_w &= [1-d^w_j- sigma(x^T_w theta^w_{j-1})]x_w end {aligned} $$
那么的话,我们可以写出$theta$的更新公式:
$$ theta ^ w_{j-1}= theta^w_{j-1}+ eta [1-d^w_j- sigma(x^T_w theta^w_{j-1})]x_w $$
其中$eta$是学习率,一般在设置学习率的时候,原作者在CBOW中将学习率设置为0.05,在Skip-gram中设置为了0.025.不过在代码中,学习率会根据学习的进行,不停的进行着衰减,用来满足自适应性,防止训练后期的动荡和加快收敛.
接下来可以考虑关于$x$的梯度了,观察$zeta(w,j)$可以发现,$x$和$theta$其实是对称的,那么在计算过程中,其实我们将最终结果的变量的位置进行交换就可以了
$$ frac{Delta zeta(w,j)}{Delta x_w} = [1-d^w_j- sigma(x^T_w theta^w_{j-1})] theta^w_{j-1} $$
到了这里,我们已经求出来了$x_w$的梯度,但是我们想要的其实是每次进行运算的每个单词的梯度,而$x_w$是$Context(w)$中所有单词累加的结果,那么我们怎么使用$x_w$来对$Context(w)$中的每个单词$v(u)$进行更新呢?这里原作者选择了一个简单粗暴的方式,直接使用$x_w$的梯度累加对$v(u)$进行更新:
$$ v(u) = v(u) + eta sum^{l^w}_{j=2} frac{Delta zeta(w,j)}{Delta x_w}, u in Context(w) $$
至于使用别的方式是不是更有效,我没有进行尝试,所以这里也就不在进行深入的探讨
虽然推导已经结束了,但是实际写代码和实际的推导还是有点差距的,下面是伪代码,你可以发现,这个和推导的计算过程还是稍有不同
![e1b7e55a88fd0f6ff4010af05eaa75fe.png](https://i-blog.csdnimg.cn/blog_migrate/07cfd657ec06150a69b7e5d41b4a85c5.png)
这里需要注意的是,(3.3)和(3.4)不可以电刀,因为每次进行反向传播更新$v(u)$,的时候,我们在进行反向传播的时候,需要使用的是前向传播的时候参与计算的$theta^w_{j-1}$,而不是更新之后的$theta^w_{j-1}$.
同时,上面的符合和实际代码中的符号不太一样,在word2vec最初的代码中(我写的代码也按照了原来的命名方式进行),$syn0$表示$v(u)$,而$syn1$表示$theta^w_{j-1}$,$neul$表示$x_w$,$neule$表示$e$
读到了这里,你可能对word2vec有了一些了解,也可能云里雾里.但是都没关系,大部分人在没有接触代码的时候,都会感觉到word2vec很神奇,不清楚它的运行方式,看到这里,我强烈建议你去看代码,原版代码中只看cbow相关的层次softmax
3.2 Skip-gram 层次softmax
3.2.1 整体结构
可以认为skip-gram模式的层次softmax的结构和3.1 cbow的很类似,可以说它也具有输入层,"投影层"和输出层,但是因为它输入的就是一个单词,所以投影层就可以不要了.可以得到类似的下面的结构:
![679fadb296f98d325808a1ce46b23cd0.png](https://i-blog.csdnimg.cn/blog_migrate/c34e72942f18556cbf24a02d184d2f5e.jpeg)
3.2.2 前向传播和反向传播推导
Skip-gram举例来看的话,可以看到类似下面的这样的示意图:
![e82e5e917a2c89b5b160973328369d6e.png](https://i-blog.csdnimg.cn/blog_migrate/a9dae8975fedac853e587ff6b6942d26.jpeg)
其中蓝色的路线是需要走的路线,完整的句子是I like writing code
,所以首先是先到I
,然后再到like
这条路线,最后到code
这条路线.每条路线都像上面cbow里面的类似,都是经过节点的时候类似于经过一个二分类.所以本节的符号和上一节类似,就不再重复列出.
首先我们先定义每个路线的概率函数为$p(u|w), u in Context(w)$,表示在给定单词$w$的情况下,找到单词$w$的$Context(w)$对应的词的概率(路线),记为:
$$ p(u|w)= prod^{l^u}{j=2}p(d^w_j|v(w), theta^u{j-1}) $$
之后,我们知道单词$w$对应的上下文单词$Context(w)$包含好几个单词,那么我们可以定义:
$$ p(Context(w)|w)= prod_{u in Context(w)}p(u|w) $$ 其中$p(d^u_j|v(w), theta^u_{j-1})$和cbow中的定义类似,为:
$$ p(d^u_j|v(w), theta^u_{j-1})=[sigma(v(w)^T theta^u_{j-1})]^{1-d^w_j} cdot [1- sigma(v(w)^T theta^u_{j-1})]^{d^u_j} $$
那么现在将上面的式子带回,然后可以得到:
$$ begin {aligned} zeta &= sum_{w in C} log prod_{u in Context(w)} prod_{j=2}^{l^u} { [sigma(v(w)^T theta^u_{j-1})]^{1-d^w_j} cdot [1- sigma(v(w)^T theta^u_{j-1})]^{d^u_j} } &= sum_{w in C} sum_{u in Context(w)} sum_{j=2}^{l^u}{ (1-d^u_j) cdot log [sigma(v(w)^Ttheta^u_{j-1})] + d^u_j log [1- sigma(v(w)^T theta^u_{j-1})] } end {aligned} $$
还和上次一样,为了推导方便,我们将需要求导的部分直接提取出来:
$$ zeta (w,u,j)=(1-d^u_j) cdot log [sigma(v(w)^Ttheta^u_{j-1})] + d^u_j log [1- sigma(v(w)^T theta^u_{j-1})] $$
依旧和上次一样,我们发现这里面只有两个变量,分别是$v(w)$和$theta^u_{j-1}$,那么我们依旧使用随机梯度上升法来对其进行优化,首先计算关于$theta^u_{j-1}$的梯度:
$$ begin {aligned} frac{ Delta zeta(w,u,j)}{Delta theta^u_{j-1}} &= (1-d^u_j)(1- sigma(v(w)^T theta^u_{j-1}))v(w)-d^u_j sigma(v(w)^T theta^u_{j-1})v(w) &= [1-d^u_j-sigma(v(w)^T theta^u_{j-1}]v(w) end {aligned} $$
于是,$theta^u_{j-1}$的更新公式可以写成:
$$ theta^u_{j-1}=theta^u_{j-1} + eta [1-d^u_j-sigma(v(w)^T theta^u_{j-1}]v(w) $$
同理,根据对称性,可以很容易得到$zeta(w,u,j)$关于$v(w)$的梯度:
$$ begin {aligned} frac{ Delta zeta(w,u,j)}{Delta v(w)} &= [1-d^u_j-sigma(v(w)^T theta^u_{j-1}] theta^u_{j-1} end {aligned} $$
我们也可以得到关于v(w)的更新公式:
$$ v(w)=v(w)+ eta sum_{u in Context(w)} sum^{l^w}_{j=2} frac{ Delta zeta(w,u,j)}{Delta v(w)} $$
那么我们可以到Skip-gram使用层次softmax方法的时候的伪代码:
![496731f5c9cc4db7f7476d783939d8b3.png](https://i-blog.csdnimg.cn/blog_migrate/db1ba377a30596b61e2733e5d5380a17.jpeg)
这里依旧需要注意的是,(3.3)和(3.4)不能交换位置,原因在上面已经解释过了
这里给出和源码的对应关系:$syn0$表示$v(u)$,而$syn1$表示$theta^w_{j-1}$,$neul$表示$x_w$,$neule$表示$e$. 其实看到这里,你会发现,只要搞懂了一个,剩下的那个就很简单了
4.基于负采样的模型
下面将介绍基于负采样的CBOW和Skip-gram模型.具体什么NCE,NGE,我也不是特别清楚他们的关系,大家都说负采样是NCE的简化版本,具体什么样,我没有深究,以后有机会了再去研究.使用负采样的时候,可以明显感觉到训练速度快于层次softmax,而且不需要构建复杂的哈弗曼树.再我实际训练的过程中,在使用C语言的时候,相对于层次softmax,训练速度可以获得好几倍的增长,即使使用Python,训练速度也至少增长了两倍.
4.1 负采样算法简单介绍
什么是负采样呢? 例如在CBOW中,我们是知道了$Context(w)$,然后来预测单词$w$,那么这个时候,相对于$Context(w)$,我们提供一组结果,这些结果中包含正确的解$w$,剩下的都是错误的解,那么$w$就是正样本,剩下的解就是负样本.Skip-gram类似,相当于给一组输入,然后预测正确的输出$Context(w)$,输入的一组数据里面,有一个是正确的输入,为$v(w)$,剩下的都是错误的输入,也就是负样本. 那么如何确定怎么选取负样本呢? 这里采用的是一种带权采样的方法,这里的权,在这里可以使用词的频率来表示,也就是说,词的频率越高,它的权重越大,被采集到的可能性就越大.例如设词典中每个单词$w$对应的权值为$len(w)$:
$$ len(w)=frac{counter(w)}{sum_{u in C}counter(u)} $$
这里$counter(w)$表示单词$w$出现的次数.
在word2vec中,它的做法很简单,在word2vec中,令
$$ l_0=0,..., l_k=sum^{k}_{j=1}len(w_j), k=1,2,...,N $$
这里$w_j$表示词典中的第$j$个单词,那么按照集合${l_i}^N_{j=0}$中每个元素的大小,可以按照一定的比例将$[0,1]$进行划分,这个划分是非等距的,并且将$[0,1]$划分成了N份(也就是说有N个单词).这个时候,再提供一个在$[0,1]$上的等距划分,划分为M份,这里要求$M>>N$,如下图所示:
![cd355501f97ee89349315897fe59809c.png](https://i-blog.csdnimg.cn/blog_migrate/b6a5963caba240ec6321eb9dd00c7e7c.jpeg)
这样就可以将非等距划分的${l_i}^N_{j=1}$映射到等距划分的$Table(i)$上,当然了,$l_i$实际上就代表的单词,那么在映射的时候,把$l_j$换成$w_j$:
$$ Table(i)=w_j, m_i in (l_j-l_{j-1}),i=1,2,...,M-1,j=1,2...,N $$
之后根据映射关系,每次对单词$w^k$进行负采样的时候,在$[1,M-1]$上生成一个随机数$i$,然后$Table(i)$就是那个被采样到的单词.如果这个时候不幸采样到了单词$w^k$自己,这个时候,word2vec源代码的处理方式是直接跳过去,忽略这次采样的结果就行了,毕竟这样的概率不太高. 不过在word2vec中,原作者实际上没有直接使用$counter(w)$,而是加上了一个$alpha$次方,在代码中,实际上是下面这样的:
$$ begin {aligned} len(w) &= frac{counter(w)^alpha}{sum_{u in C}[counter(u)]^alpha} &= frac{counter(w)^{0.75}}{sum_{u in C}[counter(u)]^{0.75}} end {aligned} $$
猜测作者这样写,是因为想提高一点低频词被采集到的概率.除此之外,作者在代码中取$M=10^8$,源代码中是变量table_size.
这里我在使用Python实现的时候,采用的是原作者的方式,但是实际在初始化Tabel(i)的时候,还是挺慢的,大概需要十几秒的时间,原作者使用的C语言,要快的多.我猜想的是numpy自带的有choice函数,这个函数可以根据所给的数据,从这些数据中随机抽取一个出来,同时可以设置每个数据被随机抽取到的概率.然后每次进行负采样的时候,直接使用这个函数生成负采样结果,不知道这样效率会不会提升.或者提前使用这个函数生成一组负采样结果,计算的时候就直接拿来用.我没有尝试,你要是感兴趣可以试试.
4.2 CBOW 负采样
4.2.1 前向传播
上面的负采样已经介绍完了,下面开始进行公式的推导.首先我们先选好一个关于$Context(w)$的负样本集$NEG(w)$,对于$forall u in NEG(w) cup {w}$,我们定义单词$u$的标签为:
$$ L^w(u)= left{begin{matrix} 1, & u=w 0, & u neq w
end{matrix}right. $$
其中1表示是正样本,0表示负样本. 对于一个给定的$Context(w)$的正样本$NEG(w)$,我们希望最大化的目标函数是:
$$ g(w)=prod_{u in {w} cup NEG(W)} p(u|Context(w)) $$
其中
$$ begin {aligned} p(u|Context(w)) &= left{begin{matrix} sigma(x^T_w theta^u), & L^w(u)=1 1-sigma(x^T_w theta^u), & L^w(u)=0
end{matrix}right. &= [sigma(x^T_wtheta^u)]^{L^w(u)} cdot [1-sigma(x^T_w)theta^u]^{1-L^w(u)} end {aligned} $$
这里需要注意的是,这里的$x_w$依旧还是上面CBOW-hs中定义的$Context(w)$中所有词的词向量之和,而$theta^u in R^m$在这里作为一个辅助向量,作为待训练的参数.
为什么最大化$g(w)$就可以了呢?我们可以改变一下g(w)的表达式:
$$ g(w)=sigma(x^T_wtheta^w) prod_{u in NEG(w)} [1- sigma(x^T_wtheta^u)] $$
我们可以看到,如果我们最大化$g(w)$的话,就可在最大化$sigma(x^T_w theta^w)$的同时,最大化$1- sigma(x^T_wtheta^u), u in NEG(w)$,也就是最小化$sigma(x^T_wtheta^u), u in NEG(w)$.这样就相当于最大化了正样本,最小化了负样本.既然明白了这个,那么对于整个语料库,有:
$$ G = prod_{w in C}g(w) $$
作为最终的优化目标,这里为了求导方便,其实就是为了把$prod$转换成$sum$,我们在$G$前面加上$log$,得到:
$$ begin {aligned} zeta &= log G &= sum_{w in C} log g(w) &= sum_{w in C} sum_{u in {w} cup NEG(w)} log { [sigma(x^T_wtheta^u)]^{L^w(u)} cdot [1-sigma(x^T_w)theta^u]^{1-L^w(u)} } &= sum_{w in C} sum_{u in {w} cup NEG(w)} { L^w(u) cdot log[sigma(x^T_w theta^u) + [1-L^w(u)] cdot log [1-sigma(x^T_w theta^u)]] } end {aligned} $$
同样,为了求导方便,我们还是取$zeta(w,u)$:
$$ zeta(w,u) = L^w(u) cdot log[sigma(x^T_w theta^u) + [1-L^w(u)] cdot log [1-sigma(x^T_w theta^u)]] $$
4.2.2 反向传播
于是乎,现在到了反向传播的时候了,和以前的都几乎一样啦,这还是使用随机梯度上升法,然后首先求关于$theta^u$的梯度:
$$ begin {aligned} frac{Delta zeta(w,u)}{Delta theta^u} &=L^w(u)[1- sigma(x^T_wtheta^u)]x_w-[1-L^w(u)] cdot sigma(x^T_w theta^u)x_w &=[L^w(u)-sigma(x^T_w theta^u)]x_w end {aligned} $$
那么$theta^u$的更新公式可以写成:
$$ theta^u=theta^u+eta [L^w(u)-sigma(x^T_w theta^u)]x_w $$
同时根据对称性,额可以得到$x_w$的梯度:
$$ begin {aligned} frac{Delta zeta(w,u)}{Delta x_w} &=[L^w(u)-sigma(x^T_w theta^u)] theta^u end {aligned} $$
那么$v(w)$的更新公式可以写成:
$$ v(tilde w) =v(tilde w)+ eta sum_{u in {w} cup NEG(w)} frac{Delta zeta(w,u)}{Delta x_w}, tilde w in Context(w) $$
最后这里给出基于负采样的CBOW的伪代码:
$$ begin {aligned} & 1. e=0 & 2. x_w = sum_{u in Context(w)}v(u) & 3. FOR u = {w} cup NEG(w): & { & 3.1 q = sigma(x^T_w theta^u) & 3.2 g = eta(L^u(w) -q) & 3.3 e = e + g theta^u & 3.4 theta^u = theta^u + g x_w & } & 4. FOR u in Context(w): & { & v(u) = v(u) + e & } end {aligned} $$ 依旧是3.3和3.4的位置不能对调,然后对应于代码的关系是:$syn0$对应$v(u)$, $syn1neg$对应$theta^u$(不过在Python中这里依旧使用的是syn1),$neul$对应是$x_w$,neule对应是$e$.
4.3 Skip-gram 负采样
4.3.1 前向传播
因为这里和前面的几乎都很类似,所以这里就不再多叙述,直接给出最终的优化目标
$$ begin {aligned} zeta&= log G G&=prod_{w in C}g(w) g(w)&= prod_{tilde w in Context(w)} prod_{u in {w} cup NEU^{tilde w}(w)}p(Context|u) p(Context|u) & = left{begin{matrix} sigma(v(tilde w)^T theta^u), & L^w(u)=1 1-sigma(v(tilde w)^T theta^u), & L^w(u)=0 end{matrix}right. &=[sigma(v(tilde w)^T]^{L^w(u)} cdot [1-sigma(v(tilde w)^T]^{1-L^w(u)} L^w(u)&= left{begin{matrix} 1, & u=w 0, & u neq w
end{matrix}right. end {aligned} $$
化简之后,可以得到$zeta$
$$ begin {aligned} zeta = & sum_{win C} sum_{tilde w in Context(w)} sum_{u in {w} cup NEU^{tilde w}(w)} &L^w(u)log[sigma(v(tilde w)^T theta^u)] + [1-L^w(u)]log[1-sigma(v(tilde w)^T theta^u)] end {aligned} $$
为了推导方便,我们依旧提取出来$zeta(w, tilde w, u)$
$$ zeta(w, tilde w, u) = L^w(u)log[sigma(v(tilde w)^T theta^u)] + [1-L^w(u)]log[1-sigma(v(tilde w)^T theta^u)] $$
下面进行梯度的求解.
4.3.2 反向传播
这里依旧首先对$theta^u$进行求导:
$$ begin {aligned} frac{Delta zeta(w, tilde w, u)}{Delta theta^u} &=L^w(u)[1- sigma(v(tilde w)^T_wtheta^u)]v(tilde w)-[1-L^w(u)] cdot sigma(v(tilde w)_w theta^u)v(tilde w)^T &=[L^w(u)-sigma(v(tilde w)^T theta^u)]v(tilde w) end {aligned} $$
然后得到$theta^u$的更新公式:
$$ theta^u = theta^u + eta =[L^w(u)-sigma(v(tilde w)^T theta^u)]v(tilde w) $$
同理根据对称性,得到:
$$ begin {aligned} frac{Delta zeta(w, tilde w, u)}{Delta v(tilde w)} &=[L^w(u)-sigma(v(tilde w)^T theta^u)]theta^u end {aligned} $$
然后得到$v(tilde w)$的更新公式:
$$ v(tilde w) = v(tilde w) + sum_{u in {w} cup NEU^{tilde w}(w)} frac{Delta zeta(w, tilde w, u)}{Delta v(tilde w)}, tilde w in Context(w) $$
最后依旧是伪代码,同时还是3.3和3.4不能颠倒.同时和代码对应关系是:$syn0$对应$v(u)$,$syn1neg$对应$theta^u$(python 代码中依旧是syn1),$neule$对应$e$.
$$ begin {aligned} & 1. FOR tilde w in Context(w): & { & 2. e = 0 & 3. FOR u = {w} cup NEG^{tilde w}(w): & { & 3.1 q = sigma(v(tilde w)^T theta^u) & 3.2 g = eta(L^w(u) - q) & 3.3 e = e + g theta^u & 3.4 theta^u = theta^u + g v(tilde w) & } & v(tilde w) = v(tilde w) + e & } end {aligned} $$
5. 后记
断断续续使用了4天写完了这篇博客,这篇博客几乎都参考了peghoty.虽然大神总结的很好了,根据大神的教程和github的一些代码,已经使用Python复写出word2vec的代码,并且成功训练出了还行的结果,虽然Python效率很低,而且对多线程的支持不好(使用了多进程),多进程数据交互时间较长,但是也是实现出来了.然后使用这篇博客记录一些自己的理解.本来认为理解的已经还不错了.但是在参考了peghoty大神的总结,然后写博客的过程中,对于公式的推导,和对于一些模糊的地方有了一个更加清晰的认识,也感觉到了自己学习的不足,日后需要更加努力!