Press "Enter" to skip to content

Word2Vec 算法推导 &实现

作者:郭必扬

本文主要根据cs224n的assignment2的计算题和编程题进行一个总结回顾。我发现这份作业设计太棒了,循序渐进,有理论有实践,前后呼应,难度适中,整个的编排我觉得更像是一份详细的教程。所以这里我就从这些题目出发,来复习、思考Word2Vec。

 

本文的主要内容:

 

使用 「朴素softmax」 损失函数的word2vec

 

使用 「负采样」 式损失函数的word2vec

 

编程实现的细节

 

一些Notations

 

skip-gram的目标就是学习由中心词预测其上下文中某特定词的概率,o就是outside的意思,c就是center的意思。o从哪里找呢?我们需要先确定一个窗口大小window size,例如下图中,我们使用唯window size=2,那幺中心词“banking”就可以找到4个上下文词:

 

采用窗口大小为2的word2vec skip-gram算法

 

朴素softmax损失函数(Naive-Softmax Loss)

 

对于,我们可以使用词向量的内积然后用softmax函数来构建:

 

这里我们对中心词c和上下文词o使用了两套词向量表示和,但注意二者形状完全一样,不是互补的关系。

 

这样,对于单个词对<c,o>,其损失函数可以写为:

 

其实,该损失函数还有另一种表示方法,那就是周围词真实分布与预测出来的概率分布的交叉熵。即:

 

1.证明概率的负对数损失函数等价于交叉熵损失函数

 

这就是作业2的第一小问。证明也十分简单:因为真实分布是一个one-hot向量,即只有当前真实的上下文词o的位置上,才是1。故

 

即证。

 

2.计算naive-softmax loss对中心词、上下文词的导数

 

求导这个就不用过多的解释,就是chain rule一把梭,下面展示一下求导过程:

 

对中心词向量求导:

 

对上下文词向量求导:

 

这个时候就要分情况了, 当w=o时:

 

而当w!=o时:

 

两种情况可以合并为:

 

从上面的推导结果,我们可以发现一些东西:我们 「对中心词求导时」 ,只计算了对当前位置c处的中心词的导数,为何呢?因为 「对其他位置的导数都是0」 !从J的表达式可以看出,J与除了c其他位置的中心词向量是无关的!而 「对上下文词求导」 时,我们发现,无论该词是不是在当前位置o,导数都存在,所以 「要把词汇表中所有词都计算一遍」 。

 

这告诉了我们什幺呢?

 

在参数更新时,更新向量是很容易的,更新向量却很艰难。

 

负采样(Negative Sampling)

 

上面对朴素softmax损失函数的求导过程中,我们发现了在更新U的时候,计算开销十分大。所以我们要想办法降低这样的计算开销。负采样技术,就是目前在word2vec中最实用的降低计算量的技巧。

 

假设当前中心词为c,我们从词汇库中选取K个负采样词,记为,其对应的词向量为,要注意选取这些负采样词的时候,要避开当前真实的上下文词o,o实际上是正样本。这样,我们便可以构建一个新的损失函数——负采样损失函数:

 

这个损失函数,一眼就可以看出比naive-softmax loss求导要更容易,因为,它在更新U矩阵时,只更新了K+1个向量,而naive-softmax需要更新U中的全部向量。而在负采样损失函数中,我们不再使用softmax激活函数了,而是使用sigmoid函数。所以,很多人也会说,负采样是把原本的一个softmax的|V|类分类变成了少数几个二分类问题。

 

我们对这个损失函数再仿照上一节的方法求个导。

 

在求导前,我们可以先计算一下sigmoid函数求导的特点,这样可以方便我们求导:

 

对中心词向量求导:

 

对上下文词向量求导:

 

对上下文词向量求导:

 

window loss

 

上面写的损失函数,都是针对单个词对<c,o>的损失,而skip-gram采用的是滑动窗口机制,所以我们需要进一步计算一整个上下文窗口(context window)中的损失:

 

等式后面的那个J可以替换成naive-softmax loss或者negative sampling loss,这里不再赘述。

 

Word2Vec的编程实现

 

这个就是cs224n作业2的编程部分了。

 

当然,我们肯定不是从零到一实现一个word2vec算法,这还是太复杂了,作业主要是填空的形式,让我们把算法的核心部分给填写了一下。至于很多系统的设计我们是不同操心的。

 

我们需要编写的,主要是这幺四部分:

 

A. naive softmax loss及其求导结果

 

B. negative sampling loss及其求导结果

 

C. 一个window的loss的计算

 

D. SGD优化算法中的参数更新

 

其实这些部分,就是根据我们上面计算的各种求导结果来写,而且我们的求导结果已经是矩阵运算了,十分方便。我写的代码已经上传到github上:

 

https://github.com/beyondguo/CS224n-notes-and-codes/tree/master/assignment 2

 

我这里想单独记录一下一些编程技巧:

 

numpy中各种矩阵运算的维度问题

 

如何用Vectorization来替换掉低效的for训练。

 

python乘法、np.multiply()和np.dot()

 

一图以蔽之:

 

总之,np.multiply()和普通的乘法没有区别,在形状相同的情况下,它们都是“对应元素相乘”,保留原形状。当形状不一的时候,小矩阵会利用“传播机制”,乘到大矩阵上。

 

而np.dot则是正经的矩阵相乘,需要符合维数的限制,维度不对就会报错。唯一的特例就是两个向量进行点积,这个时候不用使用转置。

 

关于Vectorization

 

np中所有的函数都可以作用于标量、向量、矩阵,因此,如果需要对一个矩阵中所有的元素采取相同的函数动作,那可以直接把整个句子丢进np函数中。另外,我们在进行计算的时候,如果最后要求的对象是矩阵,那最好在推导的时候,就写成矩阵的形式,这样避免了很多不必要的for循环。

 

例如,在作业中,我们需要求一个J对U的导数,在求导时,我们前面已经计算好了:

 

那J对U的导数,就是把U的每一列的导数求出来,再拼起来。但是,其实我们可以直接把这个过程写成矩阵的形式:

 

下面两张图展示了我们的变换过程:

 

因此,如果按照for循环的方式,代码就是分别对U的每一个向量求导之后再拼成一个矩阵:

 

gradU = np.array([v_c*(softmax(np.dot(U.T,v_c))[x]-int(x==o)) \

 

而如果使用我们前面算出来的矩阵的表示形式,那代码就是这样:

 

gradOutsideVecs = np.dot((y_hat-y).reshape(-1,1),v_c.reshape(1,-1))
# (V,),(d,)-->(V,d)

 

后者的执行效率会高很多。

 

numpy其他的一些有趣有用的功能

 

这里列举两个我觉得很使用的功能吧:

 

 

通过一个array直接取出ndarray对象中一系列指定序号的元素

 

 

下面的两个例子:

 

a = np.array([0,10,20,30,40,50,60,70])
indices = np.array([0,2,4,6])
a[indices]

 

输出: array([ 0, 20, 40, 60])

 

A = np.array([[1,1,1],
[2,2,2],
[3,3,3]])
indices = np.array([0,2])
A[indices]

 

输出: array([[1, 1, 1],[3, 3, 3]])

 

无论是数组还是矩阵,都可以直接取。需要注意的是,这里的indices必须是np.array的格式。

 

 

通过at函数往指定位置进行运算

 

 

比如我们有一个大矩阵,我们希望向其中的某一些列中加上一堆值。例如:

 

C = np.zeros((5,3))
temp = np.ones((2,3))
print('C:
',C)
print('temp:
',temp)

 

看一看C和temp是什幺:

 

C:
[[0. 0. 0.]
[0. 0. 0.]
[0. 0. 0.]
[0. 0. 0.]
[0. 0. 0.]]
temp:
[[1. 1. 1.]
[1. 1. 1.]]

 

接下来,我们想temp中的两个向量的值,加到C中的第0行,第2行,我们只用写:

 

np.add.at(C,[ 0 , 2 ],temp)

 

C

 

输出:

 

array([[1., 1., 1.],
[0., 0., 0.],
[1., 1., 1.],
[0., 0., 0.],
[0., 0., 0.]])

 

看!就是这幺方便!其中, add 可以替换成其他各种运算。

 

以上。

Be First to Comment

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注