word2vec
之前我们使用基于计数的方法得到了单词的分布式表示。本章我们将讨论基于推理的方法。
基于推理的方法使用了推理机制,用的是神经网络。本章我们将花很多时间考察 word2vec 的结构,并通过代码实现来加深对它的理解。
# 1. 基于推理的方法和神经网络
用向量表示单词的方法大致可以分为两种:
- 基于计数的方法
- 基于推理的方法
两者的背景都是分布式假设。
# 1.1 基于计数的方法的问题
上一章所说的基于计数的方法根据一个单词周围的单词的出现频数来表示该单词。具体来说,先生成所有单词的共现矩阵,再对这个矩阵进行 SVD,以获得密集向量(单词的分布式表示)。但是如果词汇量超过 100 万个,那么使用基于计数的方法就需要生成一个 100 万 × 100 万的庞大矩阵,但对如此庞大的矩阵执行 SVD 显然是不现实的。
而基于推理的方法使用神经网络,通常在 mini-batch 数据上进行学习,因此神经网络一次只看一部分学习数据(mini-batch),并反复更新权重。学习机制的差异如下图所示:
基于推理的方法和基于计数的方法相比,还有一些其他的优点。我们之后说明。
# 1.2 基于推理的方法的概要
基于推理的方法的主要操作是“推理”,即当给出周围的单词(上下文)时,预测“?”处会出现什么单词:
解开上图中的推理问题并学习规律,就是基于推理的方法的主要任务。通过反复求解这些推理问题,可以学习到单词的出现模式。
从模型的视角出发,这个推理问题如下图所示:
基于推理的方法引入了某种模型,我们将神经网络用于此模型。这个模型接收上下文信息作为输入,并输出(可能出现的)各个单词的出现概率。在这样的框架中,使用语料库来学习模型,使之能做出正确的预测。另外,作为模型学习的产物,我们得到了单词的分布式表示。这就是基于推理的方法的全貌。
# 1.3 神经网络中单词的处理方法
从现在开始,我们将使用神经网络来处理单词。但是,神经网络无法直接处理 you 或 say 这样的单词,要用神经网络处理单词,需要先将单词转化为固定长度的向量。
一种方式是是将单词转换为 one-hot 表示(只有一个元素是 1,其他元素都是 0)。
只要将单词转化为固定长度的向量,神经网络的输入层的神经元个数就可以固定下来,输入的神经元如下:
- 输入层由 7 个神经元表示,分别对应于 7 个单词。
现在事情变得很简单了。因为只要将单词表示为向量,这些向量就可以由构成神经网络的各种“层”来处理。比如,对于one-hot表示的某个单词,使用全连接层对其进行变换的情况如下图所示:
- 全连接层通过箭头连接所有节点。这些箭头拥有权重 (参数),它们和输入层神经元的加权和成为中间层的神经元。
本章使用的全连接层将省略偏置(这是为了配合后文对 word2vec 的说明)。没有偏置的全连接层相当于在计算矩阵乘积,即 MatMul 层。
神经元之间的连接是用箭头表示的。之后,为了明确地显示权重,我们用如下图所示的表示方法:
- 将全连接层的权重表示为一个 7 × 3 形状的
矩阵
现在这里的全连接层变换可以写成如下的 Python 代码:
import numpy as np
c = np.array([[1, 0, 0, 0, 0, 0, 0]]) # 输入
W = np.random.randn(7, 3) # 权重
h = np.dot(c, W) # 中间节点
print(h)
# [[-0.70012195 0.25204755 -0.79774592]]
2
3
4
5
6
- 这里的输入数据(变量c)的维数(ndim)是 2,这是考虑了 mini-batch 的处理。
但这里注意一下 c 与 W 进行矩阵乘积计算的地方(下图),其计算效果相当于“提取”权重的对应行向量:
- 这里仅为了提取权重的行向量而进行矩阵乘积计算好像不是很有效率。之后会对这一部分进行改进。
# 2. 简单的 word2vec
我们要做的事情就是用神经网络完成“输入上下文,模型输出各个单词的出现概率”的任务。这里我们使用由原版 word2vec 提出的名为 continuous bag-of-words(CBOW)的模型作为神经网络。
word2vec 一词最初用来指程序或者工具,但现在也指神经网络的模型。CBOW 模型和 skip-gram 模型是 word2vec 中使用的两个神经网络。
# 2.1 CBOW 模型的推理
CBOW 模型是根据上下文预测目标词的神经网络(“目标词”是指中间的单词,它周围的单词是“上下文”)。通过训练这个 CBOW 模型,使其能尽可能地进行正确的预测,我们可以获得单词的分布式表示。
CBOW 模型的输入是上下文。这个上下文用 ['you', 'goodbye'] 这样的单词列表表示。我们将其转换为 one-hot 表示,以便 CBOW 模型可以进行处理。其模型的网络可画成下图:
- 这里,因为我们对上下文仅考虑两个单词,所以输入层有两个。如果对上下文考虑 N 个单词,则输入层会有 N 个
- 有两个输入层,经过中间层到达输出层。两层之间的变化由全连接层完成。
- 图中画的两个
是同一个矩阵。
我们注意一下上图的中间层。此时,中间层的神经元是各个输入层经全连接层变换后得到的值的“平均”。就上面的例子而言,经全连接层变换后,第 1 个输入层转化为
再看一下上图的输出层,这个输出层有 7 个神经元,这些神经元对应于各个单词。输出层的神经元是各个单词的得分,它的值越大,说明对应单词的出现概率就越高。得分是指在被解释为概率之前的值,对这些得分应用 Softmax 函数,就可以得到概率。
上图中从输入层到中间层的变换由全连接层(权重是
权重
中间层的神经元数量比输入层少这一点很重要。中间层需要将预测单词所需的信息压缩保存,从而产生密集的向量表示。这时,中间层被写入了我们人类无法解读的代码,这相当于“编码”工作。而从中间层的信息获得期望结果的过程则称为“解码”。这一过程将被编码的信息复原为我们可以理解的形式。
我们再从层视角来展示一下 CBOW 模型:
CBOW 模型总结
CBOW 模型一开始有两个 MatMul 层,这两个层的输出被加在一起。然后,对这个相加后得到的值乘以 0.5 求平均,可以得到中间层的神经元。最后,将另一个 MatMul 层应用于中间层的神经元,输出得分。
下面我们来实现 CBOW 模型的推理(即求得分的过程):
import sys
sys.path.append('..')
import numpy as np
from common.layers import MatMul
# 样本的上下文数据
c0 = np.array([[1, 0, 0, 0, 0, 0, 0]])
c1 = np.array([[0, 0, 1, 0, 0, 0, 0]])
# 权重的初始值
W_in = np.random.randn(7, 3)
W_out = np.random.randn(3, 7)
# 生成层
in_layer0 = MatMul(W_in)
in_layer1 = MatMul(W_in)
out_layer = MatMul(W_out)
# 正向传播
h0 = in_layer0.forward(c0)
h1 = in_layer1.forward(c1)
h = 0.5 * (h0 + h1) # 计算中间数据
s = out_layer.forward(h) # 计算各个单词的得分
print(s)
# [[ 0.30916255 0.45060817 -0.77308656 0.22054131 0.15037278
# -0.93659277 -0.59612048]]
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
- 注意
in_layer0
和in_layer1
是共享的权重矩阵
# 2.2 CBOW 模型的学习
CBOW 模型在输出层输出了各个单词的得分。通过对这些得分应用 Softmax 函数,可以获得概率。这个概率表示哪个单词会出现在给定的上下文(周围单词)中间。
- 在这个例子中,上下文是 you 和 goodbye,正确解标签(神经网络应该预测出的单词)是 say。如果网络具有“良好的权重”,那么在表示概率的神经元中,对应正确解的神经元的得分应该更高。
CBOW 模型的学习就是调整权重,以使预测准确。其结果是,权重
CBOW模型只是学习语料库中单词的出现模式。如果语料库不一样,学习到的单词的分布式表示也不一样。
现在,我们来考虑一下上述神经网络的学习。这里我们处理的模型是一个进行多类别分类的神经网络。因此,对其进行学习只是使用一下 Softmax 函数和交叉熵误差。首先,使用 Softmax 函数将得分转化为概率,再求这些概率和监督标签之间的交叉熵误差,并将其作为损失进行学习:
向上一节介绍的进行推理的 CBOW 模型加上 Softmax 层和 Cross Entropy Error 层,就可以得到损失。这就是 CBOW 模型计算损失的流程,对应于神经网络的正向传播。
# 2.3 word2vec 的权重和分布式表示
word2vec 中使用的网络有两个权重,分别是输入侧的全连接层的权重(
一般而言,输入侧的权重
那么,我们最终应该使用哪个权重作为单词的分布式表示呢?这里有三个选项:A. 只使用输入侧的权重;B. 只使用输出侧的权重; C. 同时使用两个权重。方案 A 和方案 B 只使用其中一个权重。而在采用方案 C 的情况下,根据如何组合这两个权重,存在多种方式,其中一个方式就是简单地将这两个权重相加。
就 word2vec(特别是 skip-gram 模型)而言,最受欢迎的是只使用输入侧的权重。 许多研究中也都仅使用输入侧的权重
有文献通过实验证明了 word2vec 的 skip-gram 模型中
的有效性。另外,在与 word2vec 相似的 GloVe 方法中,通过将两个权重相加,也获得了良好的结果。
# 2.4 学习数据的准备
我们先来准备学习用的数据,仍以“You say goodbye and I say hello.”这个只有一句话的语料库为例进行说明。
# 2.4.1 上下文和目标词
我们要做的事情是,当向神经网络输入上下文时,使目标词出现的概率高。先从语料库生成上下文和目标词:
首先,将语料库的文本转化成单词 ID:
text = 'You say goodbye and I say hello.'
corpus, word_to_id, id_to_word = preprocess(text)
print(corpus)
# [0 1 2 3 4 1 5 6]
print(id_to_word)
# {0: 'you', 1: 'say', 2: 'goodbye', 3: 'and', 4: 'i', 5: 'hello', 6: '.'}
2
3
4
5
6
7
然后,从单词 ID 列表 corpus 生成 contexts 和 target:
我们来实现这个生成上下文和目标词的函数:
def create_contexts_target(corpus, window_size=1):
'''生成上下文和目标词
:param corpus: 语料库(单词ID列表)
:param window_size: 窗口大小(当窗口大小为1时,左右各1个单词为上下文)
:return: 上下文和目标词
'''
target = corpus[window_size:-window_size]
contexts = []
for idx in range(window_size, len(corpus)-window_size):
cs = []
for t in range(-window_size, window_size + 1):
if t == 0:
continue
cs.append(corpus[idx + t])
contexts.append(cs)
return np.array(contexts), np.array(target)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
使用以下这个函数:
contexts, target = create_contexts_target(corpus, window_size=1)
print(contexts)
# [[0 2]
# [1 3]
# [2 4]
# [3 1]
# [4 5]
# [1 6]]
print(target)
# [1 2 3 4 1 5]
2
3
4
5
6
7
8
9
10
11
这样就从语料库生成了上下文和目标词。不过,因为这些上下文和目标词的元素还是单词 ID,所以还需要将它们转化为 one-hot 表示。
# 2.4.2 转化为 one-hot 表示
- 这里需要注意各个多维数组的形状。在上面的例子中,使用单词 ID 时的 contexts 的形状是 (6,2),将其转化为 one-hot 表示后,形状变为 (6,2,7)。
我们使用 convert_one_hot()
函数以将单词 ID 转化为 one-hot 表示,其实现不再说明:
vocab_size = len(word_to_id)
target = convert_one_hot(target, vocab_size)
contexts = convert_one_hot(contexts, vocab_size)
2
3
至此,学习数据的准备就完成了,
# 2.5 CBOW 模型的实现
# 2.5.1 构建出 SimpleCBOW
模型
CBOW 的模型如下:
将其实现为 SimpleCBOW
类,先看一下它的初始化方法:
import sys
sys.path.append('..')
import numpy as np
from common.layers import MatMul, SoftmaxWithLoss
class SimpleCBOW:
def __init__(self, vocab_size, hidden_size):
"""
:param vocab_size: 词汇个数
:param hidden_size: 中间层的神经元个数
"""
V, H = vocab_size, hidden_size
# 初始化权重
W_in = 0.01 * np.random.randn(V, H).astype('f')
W_out = 0.01 * np.random.randn(H, V).astype('f')
# 生成层
self.in_layer0 = MatMul(W_in)
self.in_layer1 = MatMul(W_in)
self.out_layer = MatMul(W_out)
self.loss_layer = SoftmaxWithLoss()
# 将所有的权重和梯度整理到列表中
layers = [self.in_layer0, self.in_layer1, self.out_layer]
self.params, self.grads = [], []
for layer in layers:
self.params += layer.params
self.grads += layer.grads
# 将单词的分布式表示设置为成员变量
self.word_vecs = W_in
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
- 用来处理输 入侧上下文的 MatMul 层的数量与上下文的单词数量相同(本例中是两个)。另外,我们使用相同的权重来初始化它们。
- 最后,将该神经网络中使用的权重参数和梯度分别保存在列表类型的成员变量
params
和grads
中。
这里,多个层共享相同的权重。因此,params 列表中存在多个相同的权重。但是,在 params 列表中存在多个相同的权重的情况下,Adam、Momentum 等优化器的运行会变得不符合预期(至少就我们的代码而言)。为此,在 Trainer 类的内部,在更新参数时会进行简单的去重操作。关于这一点,这里省略说明,感兴趣的读者可以参考 common/trainer.py 的
remove_duplicate(params, grads)
。
接下来,我们来实现神经网络的正向传播 forward()
函数。这个函数接收参数 contexts 和 target,并返回损失(loss):
def forward(self, contexts, target):
h0 = self.in_layer0.forward(contexts[:, 0])
h1 = self.in_layer1.forward(contexts[:, 1])
h = (h0 + h1) * 0.5
score = self.out_layer.forward(h)
loss = self.loss_layer.forward(score, target)
return loss
2
3
4
5
6
7
contexts
是一个三维 np 数组,比如之前例子的 shape=(6,2,7),各维度分别表示 batch-size、上下文的窗口大小、one-hot 向量。target
是二维数组,各维度分别表示 batch-size、one-hot 向量,比如之前的 shape=(6,7)
最后,我们实现反向传播 backward()
。这个反向传播的计算图如下图所示:
代码实现:
def backward(self, dout=1):
ds = self.loss_layer.backward(dout)
da = self.out_layer.backward(ds)
da *= 0.5
self.in_layer1.backward(da)
self.in_layer0.backward(da)
return None
2
3
4
5
6
7
至此,反向传播的实现就结束了。我们已经将各个权重参数的梯度保存在了成员变量 grads 中。
# 2.5.2 学习的实现
首先,给神经网络准备好学习数据。然后,求梯度,并逐步更新权重参数。
import sys
sys.path.append('..') # 为了引入父目录的文件而进行的设定
from common.trainer import Trainer
from common.optimizer import Adam
from simple_cbow import SimpleCBOW
from common.util import preprocess, create_contexts_target, convert_one_hot
window_size = 1
hidden_size = 5
batch_size = 3
max_epoch = 1000
text = 'You say goodbye and I say hello.'
corpus, word_to_id, id_to_word = preprocess(text)
vocab_size = len(word_to_id)
contexts, target = create_contexts_target(corpus, window_size)
target = convert_one_hot(target, vocab_size)
contexts = convert_one_hot(contexts, vocab_size)
model = SimpleCBOW(vocab_size, hidden_size)
optimizer = Adam()
trainer = Trainer(model, optimizer)
trainer.fit(contexts, target, max_epoch, batch_size)
trainer.plot()
word_vecs = model.word_vecs
for word_id, word in id_to_word.items():
print(word, word_vecs[word_id])
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
- Trainer 类会执行神经网络的学习过程,包括从学习数据中选出 mini-batch 给神经网络以算出梯度,并将这个梯度给优化器以更新权重参数等一系列操作
上面代码的运行结果:
这里,使用 word_vecs 这个变量保存权重。word_vecs 的各行保存了对应的单词 ID 的分布式表示。实际运行一下,可以得到下述结果:
you [-0.9031807 -1.0374491 -1.4682057 -1.3216232 0.93127245]
say [ 1.2172916 1.2620505 -0.07845993 0.07709391 -1.2389531 ]
goodbye [-1.0834033 -0.8826921 -0.33428606 -0.5720131 1.0488235 ]
and [ 1.0244362 1.0160093 -1.6284224 -1.6400533 -1.0564581]
i [-1.0642933 -0.9162385 -0.31357735 -0.5730831 1.041875 ]
hello [-0.9018145 -1.035476 -1.4629668 -1.3058501 0.9280102]
. [ 1.0985303 1.1642815 1.4365371 1.3974973 -1.0714306]
2
3
4
5
6
7
我们终于将单词表示为了密集向量!这就是单词的分布式表示。我们有理由相信,这样的分布式表示能够很好地捕获单词含义。如果能换成更大、更实用的语料库,相信会获得更好的结果。但是,这样在处理速度方面又会出现新的问题,这是因为当 前这个 CBOW 模型的实现在处理效率方面存在几个问题。下一章我们将改进这个简单的 CBOW 模型。
# 3. 对简单的 word2vec 的补充说明
至此,我们详细探讨了 word2vec 的 CBOW 模型。接下来,我们将对 word2vec 补充说明几个非常重要的话题。
# 3.1 CBOW 模型和概率
我们从概率的角度看一下 CBOW 模型。
关于概率论的几个前提知识介绍:
表示联合概率,表示事件 A 和事件 B 同时发生的概率 表示后验概率(条件概率),表示在事件 B发生时事件 A 发生的概率
我们用数学式来表示当给定上下文
使用这个式子可以简洁地表示 CBOW 模型的损失函数。交叉熵误差函数为
可以看出,CBOW 模型的损失函数只是对后验概率
CBOW 模型学习的任务就是让上式表示的损失函数尽可能地小。这里,我们只考虑了窗口大小为 1 的情况,不过其他的窗口大小(或者窗口大小为 m 的一般情况)也很容易用数学式表示
# 3.2 skip-gram 模型
word2vec 有两个模型:一个是我们已经讨论过的 CBOW 模型;另一个是被称为 skip-gram 的模型。
- CBOW 模型从上下文的多个单词预测目标词
- skip-gram 模型则从目标词预测上下文
skip-gram 模型的网络结构如下图所示:
skip-gram 模型的输入层只有一个,输出层的数量则与上下文的单词个数相等。因此,首先要分别求出各个输出层的损失(通过 Softmax with Loss 层等),然后将它们加起来作为最后的损失。
现在从概率的角度来看 skip-gram,它可以建模为:
再假设上下文的单词之间没有相关性,并扩展到整个语料库,可以得到:
比较两个模型的损失函数
- skip-gram 模型的预测次数和上下文单词数量一样多,所以它的损失函数需要求各个上下文单词对应的损失的总和。
- CBOW 模型只需要求目标词的损失。
那么,我们应该使用 CBOW 模型和 skip-gram 模型中的哪一个呢?答案应该是 skip-gram 模型。这是因为,从单词的分布式表示的准确度来看,在大多数情况下,skip-gram 模型的结果更好。
- 随着语料库规模的增大,在低频词和类推问题的性能方面,skip-gram 模型往往会有更好的表现
- 就学习速度而言, CBOW 模型比 skip-gram 模型要快
skip-gram 模型根据一个单词预测其周围的单词,这是一个非常难的问题。可以说 skip-gram 模型要解决的是更难的问题。经过这个更难的问题的锻炼,skip-gram 模型能提供更好的单词的分布式表示。
这里不再介绍 skip-gram 模型的实现。
# 3.3 基于计数与基于推理
我们已经了解了基于计数的方法和基于推理的方法,两者存在显著差异。我们就其他方面来对比一下这两种方法。
首先,我们考虑需要向词汇表添加新词并更新单词的分布式表示的场景。基于计数的方法需要从头开始计算。即便是想稍微修改一下单词的分布式表示,也需要重新完成生成共现矩阵、进行 SVD 等一系列操作。相反,基于推理的方法(word2vec)允许参数的增量学习。在这方面,基于推理的方法(word2vec)具有优势。
其次,两种方法得到的单词的分布式表示的性质和准确度有什么差异呢?就分布式表示的性质而言,基于计数的方法主要是编码单词的相似性,而 word2vec(特别是 skip-gram 模型)除了单词的相似性以外,还能理解更复杂的单词之间的模式(比如“king − man + woman = queen”)。
实际上,有研究表明,就单词相似性的定量评价而言,基于推理的方法和基于计数的方法难分上下。另外一个重要的事实是,基于推理的方法和基于计数的方法存在关联性。
此外,在 word2vec 之后,有研究人员提出了 GloVe 方法。GloVe 方法融合了基于推理的方法和基于计数的方法。该方法的思想是,将整个语料库的统计数据的信息纳入损失函数,进行 mini-batch 学习。据此,这两个方法论成功地被融合在了一起。