利用Python Numpy从零开始步步为营计算Word2Vec词向量
利用Python Numpy从零开始步步为营计算Word2Vec词向量
牛伯雨
词向量建模是自然语言处理当中的重要基础步骤。有了用向量表示的词汇,计算机就可以更好地处理文本数据了。
2013年,Mikolov et al. (2013)提出的Word2Vec是一个里程碑式的词向量建模方法。
最近看到一篇Derek Chia的关于徒手计算Word2Vec的博文An implementation guide to Word2Vec using NumPy and Google Sheets,作者利用表格软件表现这种模型训练过程中的矩阵和向量的变化,这对理解这种模型的原理大有裨益。另一篇GeeksforGeeks上题为Implement your own word2vec(skip-gram) model in Python的文章对这一模型也有比较详细的说明。本文是受此启发的产物,表格设计风格和部分代码有参考,旨在动手利用Python里的Numpy一步步地从零构建Word2Vec词向量。
1. CBOW模型和Skip-gram模型
Mikolov等人2013年的论文提出了两种模型,它们分别叫Continuous Bag of Words(CBOW)和Continuous Skip-gram,图示如下:
这两种模型都是预测模型,不过CBOW是已知某个词(中心词)周围的上下文,来预测这个词本身最有可能是什么,而Skip-gram则是已知一个词(中心词),来预测这个词周围最有可能是哪些词作为它的上下文。
这两种模型看上去相似,有相互对称的感觉,在具体的操作中也有一些区别。Mikolov 2013年的原文第4.3节对这两种模型进行了比较:
- 在模型训练时间方面,Skip-gram所需的时间基本上是CBOW的3倍。这一件比较符合我们的直观感受:已知上下文去预测其中的某一个词,是比只知道一个词去预测上下文要简单很多的。
- 在模型表现方面,Skip-gram在语义上的表现要优于CBOW,而CBOW则在句法上稍微更胜一筹。具体说来,如果我们用英文文本举例子,在单词相似性的任务里,Skip-gram更倾向于将"dog"和"cat"这类语义相近的单词视为相似词,而CBOW则可能会认为"dog"和其复数形式"dogs"更接近。
除了前面这两点,它们还有在对低频词的识别度方面的区别:Skip-gram一般比CBOW更敏感。其原因在于,在Skip-gram的训练过程中,虽然高频词比低频词出现的次数更多,但是高频词仍然是一个一个单独地出现的,而在CBOW中,低频词通常被裹在高频词之间作为上下文里不怎么有能见度的那一部分,而高频词经常会在上下文里连着一起出现,总是具有高能见度。
2. 动手编写Python代码
为了方便起见,我们就以句子“利用Python Numpy从零开始步步为营计算Word2Vec词向量”作为我们的(迷你)语料库,来计算词向量。
2.1. 文本预处理
处理中文需要进行分词。我们可以利用Python Jieba进行这一步。
import jieba
class Corpus(object):
def __init__(self, texts):
self.texts = texts
#texts: ["sentence1", "sentence2", ...]
self.tokenizedCorpus = []
#tokenizedCorpus: [["word1", "word2", ...], ["wordn", ...]]
def makeCorpus(self):
for sentence in self.texts:
self.tokenizedCorpus.append([])
for x in jieba.tokenize(sentence):
if x[0] == ' ':
#我们句子中的"Python"和"Numpy"之间有空格,而jieba认为这个空格也是一个词,但我们不需要空格作为单独的词
continue
self.tokenizedCorpus[-1].append(x[0])
def getTokenizedCorpus(self):
return self.tokenizedCorpus
具体用我们的迷你语料库作为实验:
s = ["利用Python Numpy从零开始步步为营计算Word2Vec词向量"]
corpusTest = Corpus(s)
corpusTest.makeCorpus()
corpusTest.getTokenizedCorpus()
我们得到的文本corpus是由词组成的列表的列表:
[['利用', 'Python', 'Numpy', '从零开始', '步步为营', '计算', 'Word2Vec', '词', '向量']]
2.2. 滑动窗口(Sliding windows)
上面我们说到,无论是CBOW还是Skip-gram,都会用到“中心词”和它周围的“上下文”的概念。
和文学意义的上下文不同的是,这里的上下文不是指真正的一个包含一定语义的段落整体,而是提前设置好长度的“窗口”。一个“窗口”所包含的词数总是一定的(除非在开头和结尾处会出现长度不够的情况),因此当它的中心词向前移动时,整个“窗口”都会随之移动,我们称这种窗口为“滑动窗口”。我们可以用表格软件来把这一过程变得成具体化:
我们以窗口大小为中心词的前后两个词(±2)为例进行说明。在这个例子中,标为绿色的代表当前中心词,即w(t),标为橘色的代表当前窗口所覆盖的范围,即从w(t-2)到w(t+2)。
2.3. One-hot编码向量化
在我们正式用Mikolov的思想计算词向量之前,我们先把每个词用One-hot的方式表达出来。
One-hot是最简单的词向量:向量的共有V个维度,V为文本中所出现的不同的单词数,每个词都对应有一个维度为1,其余维度均为0。比如对于我们的例子迷你文本来说,V=9。这种方法可以简单有效地区分不同的单词,但它的缺点也是显而易见的:所有词之间的距离均相等,不含任何语义信息,而且维度数过大。所以这种One-hot向量只是我们的一个参照物,重要的是根据文本和这些参照物将Word2Vec模型里面的参数不断更新,以得到维度数更少且反映语义信息的Word2Vec向量。
接下来,我们就用这种One-hot编码的方法把刚才我们制造出来的不同“窗口”表示出来:
等等,以此类推。图中的每一列即表示一个词的One-hot向量。
比如,在窗口3中,中心词是“Numpy”,One-hot向量为\((0, 0, 1, 0, 0, 0, 0, 0, 0)^T\),而它的上下文分别为:
“利用”:\((1, 0, 0, 0, 0, 0, 0, 0, 0)^T\);
“Python”:\((0, 1, 0, 0, 0, 0, 0, 0, 0)^T\);
“从零开始”:\((0, 0, 0, 1, 0, 0, 0, 0, 0)^T\);
“步步为营”:\((0, 0, 0, 0, 1, 0, 0, 0, 0)^T\);
下面我们编写实现One-hot编码和滑动窗口的代码。
import numpy as np
class TrainingData(object):
def __init__(self, tokenizedCorpus):
self.tokenizedCorpus = tokenizedCorpus.getTokenizedCorpus()
self.vocab = [] # 词汇表
self.vocabSize = 0 # 词汇量
self.oneHotVectors = {} # {word: one-hot_vector}
self.trainingData = [] # [[targetWordVector, contextWordsVectors]]
def makeVocab(self):
# 这里我们制作词汇表,每个文本里的词在这个表里只出现一次
# 我们选择列表而非集合的原因是,每个元素(词)的序号index之后会被用到
for sentence in self.tokenizedCorpus:
for word in sentence:
if word not in self.vocab:
self.vocab.append(word)
self.vocabSize = len(self.vocab) # 计算词汇量
def makeOneHotVectors(self):
# 制作与每个词一一对应的One-hot向量的字典
for word in self.vocab:
baseVector = np.zeros(self.vocabSize)
baseVector[self.vocab.index(word)] = 1
self.oneHotVectors[word] = baseVector
def makeTrainingData(self, windowSize = 2):
# 制作滑动窗口以便训练使用
for sentence in self.tokenizedCorpus:
for i in range(len(sentence)):
# 此时,该句中编号为i的词为中心词
targetWordVector = self.oneHotVectors[sentence[i]]
# 接下来计算上下文的词
# 在句子的开头和结尾,我们这里进行Padding,即用零向量填充没有词的地方,使得每个滑动窗口的大小都相同
contextWordsVectors = []
# 上文词
for j in range(i - windowSize, i):
if j < 0:
# 句子开头不足以填满窗口时跳过
continue
else:
contextWordsVectors.append(self.oneHotVectors[sentence[j]])
# 下文词
for j in range(i + 1, i + 1 + windowSize):
if j < len(sentence):
contextWordsVectors.append(self.oneHotVectors[sentence[j]])
else:
# 句子结尾不足以填满窗口时跳过
continue
self.trainingData.append([targetWordVector, contextWordsVectors])
用前面刚刚做好的Corpus对象进行测试:
trainingDataTest = TrainingData(corpusTest)
trainingDataTest.makeVocab()
trainingDataTest.makeOneHotVectors()
trainingDataTest.makeTrainingData()
至此,我们需要的用于训练的One-hot向量就做好了,比如前面提到的以“Numpy”为中心词的窗口:
trainingDataTest.trainingData[2]
返回
[array([0., 0., 1., 0., 0., 0., 0., 0., 0.]),
[array([1., 0., 0., 0., 0., 0., 0., 0., 0.]),
array([0., 1., 0., 0., 0., 0., 0., 0., 0.]),
array([0., 0., 0., 1., 0., 0., 0., 0., 0.]),
array([0., 0., 0., 0., 1., 0., 0., 0., 0.])]]
上面列表的第一项是目标词的向量表示,第二项是包含上下文词的向量表示的列表。
2.4. 搭建模型
有了One-hot向量后我们可以开始搭建Word2Vec模型了,为下一步训练模型做准备。
我们要搭建的Word2Vec模型是一个全连接的神经网络(fully connected neural network),有一层隐藏层,如下图(图片来源)所示:
输入层是一个原始词向量(上面说到的One-hot向量),维度数为V,即文本中的总词汇量。V的值对应上面TrainingData类的vocabSize属性。
经过第一个权重矩阵W(下文称W1)与该词向量相乘后,我们得到一个维度数为N的向量,这就是隐藏层,N对应的是我们希望在训练结果中的Word2Vec词向量的维度数。
经过第二个权重矩阵W'(下文称W2)与刚刚得到的隐藏层相乘后,我们又得到一个维度数为V的向量。把Softmax函数作用于这个新向量后,我们就可以预测输入词的旁边应该会有什么词了。
Softmax函数的定义如下,它的一个重要作用是把一个实数空间内的N维向量转化为N维的概率向量,即每个维度的数值都在0到1之间,且各个维度的数值之和为1(图片来源):
在这篇文章中,我们先来搭建Skip-gram模型。
根据上面的描述,我们可以先把正向传播的forward给写出来:
class Word2Vec(object):
def __init__(self, data, dimensions, lr, epochs):
"""
INPUT: 0. self
1. data, 一个TrainingData类的对象;
2. dimensions, 希望得到的Word2Vec向量的维度数, int
3. lr, Learning Rate, float
4. epochs, int
"""
self.vocab = data.vocab
self.vocabSize = data.vocabSize
self.trainingData = data.trainingData
self.n = dimensions
self.lr = lr
self.epochs = epochs
# 随机生成两个权重矩阵的初始值
self.W1 = np.random.uniform(-1, 1, (self.n, self.vocabSize))
self.W2 = np.random.uniform(-1, 1, (self.vocabSize, self.n))
def forward(self, x):
"""
INPUT: 1. self
2. x,一个vocabSize维的向量
"""
h_ = np.dot(self.W1, x) #得到n维隐藏层
u_ = np.dot(self.W2, h_) #得到vocabSize维的输出层
y_pred_ = self.softmax(u_) # 经过Softmax函数将输出层变成一个概率向量
return h_, u_, y_pred_
def softmax(self, x):
e_x = np.exp(x - np.max(x))
return e_x / e_x.sum(axis=0)
接下来,我们可以用一个具体的例子,来说明一个One-hot向量在被正向传播时都会经历些什么,以及它是如何到达终点、被变换成y_pred并被输出的。
比如“利用”这个词,它的One-hot向量为\((1, 0, 0, 0, 0, 0, 0, 0, 0)^T\),它的变换过程如下图所示:
这里我们得到的输出结果是\((0.147, 0.043, 0.037, 0.018 , 0.047, 0.143, 0.111, 0.104, 0.350)^T\)。
2.5. 计算错误,更新参数
刚才我们计算出了一个预测结果y_pred。在这个结果中,最大数值为0.350,出现在词汇表中的最后一个位置,也就是“向量”一词。也就是说,根据我们初始的模型,“利用”的旁边最可能出现的词是“向量”。这显然不符合我们的文本实际情况。
为了改善我们的模型的表现,我们需要对它进行修正和更新。
如何进行修正和更新呢?很关键的一步,就是计算我们预测出来的结果与实际情况的差别大小。定义这个差别的方法有无数种,这里我们就选用一个简单的:将预测结果减去真实结果(即中心词旁边的词的One-hot向量)。
继续用我们上面例子中的“利用”这个词,它在我们的文本中有两个邻居,因此,我们需要把这两个差值加起来作为我们的模型在“利用”这个词上的整体出错情况。
计算完预测与实际之间的差距之后,我们希望能够根据差距大小有针对性地更新我们模型里的各个参数(即权重矩阵里的各个权重)。为此,可以使用外积。
首先简单介绍一下外积。两个向量\(\vec{u}\)和\(\vec{v}\)的外积是矩阵\(\vec{u}\vec{v}^T\),具体用一个例子说明:假设向量\(\vec{u} = (u_1, u_2, u_3, u_4)^T\),向量\(\vec{v} = (v_1, v_2, v_3)^T\),则有
如图(图片来源)所示,向量\(\vec{u}\)中的每一个维度都会分别和\(\vec{v}\)中的每个维度相乘,其结果被分别摆在不同的位置,构成一个矩阵。
通过观察可以发现\((\vec{u}⊗\vec{v})^T = \vec{v}⊗\vec{u}\)。
外积在Python Numpy中可以使用outer实现,具体用法可以参考这里。
在我们的任务中,计算完总差值后,我们就可以利用外积计算W2的变化方向Delta W2了:
然后是W1的变化方向Delta W1。因为我们这里是反向传播的步骤,所以从反向的角度看,W1离输出层比W2更远,计算时的步骤也更多,分两小步进行。
第一小步:
第二小步(输入层仍然是前面的“利用”这个词的One-hot向量):
接下来就是更新参数了。
更新W1:
更新W2:
2.6. 计算损失
最后我们需要计算本轮训练的损失,以大概估计模型在每一轮的效能如何。
在前面介绍的正向传播当中,最后一步我们用到了Softmax函数,旨在将输出层的u转化成一个概率向量:
y = softmax(u)
y是一个向量,它的第j个元素为\(y_j = softmax(u)_j\),表示的意义是给定一个中心词\(w_i\),编号为j的词在该中心词的上下文当中的概率是多大:
在实际操作当中,\(w_i\)不会只有一个词作为它的上下文,而是有多个。因此,我们的目标就是使\(P(w_{j*}|w_i)\)的值尽可能大,\(w_{j*}\)表示\(w_i\)的所有正确的上下文词。
使\(P(w_{j*}|w_i)\)的值尽可能大,就是使
的值尽可能大。其中,\(j_{c}*\)表示\(w_i\)的上下文词在总词汇表里的编号,C表示\(w_i\)一共有多少个上下文词,即\(1 \leq c \leq C\),且\(c\)为整数。
要找到一个有这么多乘法的式子的最大值并不容易。不过,我们可以利用取对数把乘法化为加法。再对取了对数的结果取负值,经过变换,就得到了我们的损失函数loss function:我们需要尽量让损失接近零。
下面我们把刚才说明的内容变成代码:
class Word2Vec(object):
#前面的内容这里暂时删去以节省空间
def backprop(self, error, h, x):
#错误error相对W2求导(外积)
dError_dW2 = np.outer(error, h)
#错误error相对W1求导
dError_dW1 = np.outer(np.dot(self.W2.T, error), x)
#更新W1和W2
self.W1 -= self.lr * dError_dW1
self.W2 -= self.lr * dError_dW2
def train(self):
# 遍历每一轮训练
for i in range(self.epochs):
self.loss = 0 #每一轮训练开始时将此轮的损失loss归零
# 遍历训练数据
for vector_target, vectors_context in self.trainingData:
h, u, y_pred = self.forward(vector_target)
# 计算总差值(错误)向量
totalError = np.sum([np.subtract(y_pred, vector_context) for vector_context in vectors_context], axis=0)
#反向传播,更新W1和W2
self.backprop(totalError, h, vector_target)
#计算并打印损失
#vector_context.tolist().index(1)返回的是一个index,它使得vector_context[index] == 1成立
#因为vector_context是一个One-hot向量,所以使得vector_context[index] == 1成立的index应有且只有一个
#我们这里利用u[vector_context.tolist().index(1)]是因为我们需要Softmax以前的输出层结果来计算损失loss
self.loss += -np.sum([u[vector_context.tolist().index(1)] for vector_context in vectors_context]) + len(vectors_context) * np.log(np.sum(np.exp(u)))
print('Epoch:', i, "Loss:", self.loss)
2.7. 获取Word2Vec词向量
训练完成后,我们就可以获取训练文本中每一个词的Word2Vec向量表示了。这些向量就是训练后的W1的列。
也就是说,W1是m行n列的矩阵,训练后,词汇表里编号为i的词的Word2Vec向量就是W1中编号为i的那一列。
代码如下:
class Word2Vec(object):
#前面的内容这里暂时删去以节省空间
def getVector(self, word):
wordIndex = self.vocab.index(word)
#制作与所查询的词对应的One-hot向量
prepareOneHot = np.zeros(self.vocabSize)
prepareOneHot[wordIndex] = 1
OneHot = prepareOneHot
#利用W1与所查询的词对应的One-hot向量的乘积得到所查询的词对应的Word2Vec向量
return np.dot(self.W1, OneHot)
刚才我们已经准备好了训练文本trainingDataTest,现在我们可以来训练模型了:
word2VecTest = Word2Vec(trainingDataTest, 8, 0.01, 100)
word2VecTest.train()
随即在屏幕上出现
Epoch: 0 Loss: 79.00619918702192
Epoch: 1 Loss: 77.1949051975184
Epoch: 2 Loss: 75.5505838525415
...
一直到Epoch: 99
然后获取某词的Word2Vec向量:
word2VecTest.getVector("Python")
返回
array([ 1.14491387, -0.78824729, 0.71995315, -0.93400529, 0.34522999, -0.3574056 , -0.04240728, 0.25278651])
再试一个例子:
word2VecTest.getVector("步步为营")
返回
array([0.34550844, -0.78473227, -0.34814744, -0.31263506, 0.5799202, -1.2485133, -0.53662021, -0.07883783])
符合我们上面参数要求的八维Word2Vec向量。
有了向量坐标,就可以计算不同单词之间的距离和相似度了。当我们有一个较大的训练文本时,这些向量可以“模拟”出一部分语义信息。
3. 讨论
在这篇文章中我们借助表格工具比较清楚地展示了Word2Vec Skip-gram模型训练过程中的正向传播和反向传播。当然,本文中的表格工具只是为了让这一过程显得更直观,所以和真实的计算过程相比有相当多的简化。另外,本文中的Python代码也只是将重要步骤勾勒了出来,真实的优化等过程都没有被写进来。
文中有误之处,恳请指正,多谢。