用Python手撕word2vec

在NLP任务中,最细粒度的单位是词,词组成句子,句子组成段落,段落组成篇章,因此在做NLP任务的时候,需要用某种形式来把词表示出来,word2vec用向量来表示词语,能够来表征词与词的映射关系,举一个例子来说,word2vec能够发现man - woman = king - queen的关系, word2vec的本质其是一个双层的浅神经网络,那么word2vec到底是如何实现的呢?

词的one-hot表示

神经网络是无法直接词的,例如英语中的you或say这样的单词,要用神经网络来处理词,需要先将词转化为机器能够识别的向量形式,一种最简单的转换方式为 one-hot 表示,在one-hot表示中,只有一个元素是1,其他的元素都是0。

用"You say goodbye and I say hello."这句话来介绍one-hot表示,在这句话中一共有7个单词(包含末尾的句号),因此需要准备元素个数与词汇个数相等的向量,并将单词 ID 对应的元素设为 1,其他元素设为 0,其中you和goodbye的表示如下图所示

神经网络的输入层的神经元个数也因此能够固定下来,输入层由 7 个神经元表示,分别对应于 7 个单词,如下图所示,神经元为1的地方用黑色绘制,为0的地方用白色绘制。

word2vec的模型

可能到这里,有小伙伴就会很奇怪,讲了这么多word2vec到底是干嘛用的呢?其实,word2vec是一个进行推理任务的浅层神经网络(两层),基于推理的方式不同,word2vec有两种神经网络的模型,一种是CBOWcontinuous bag-of-words)模型,另一种是skip-gram模型。

CBOW

CBOW模型的推理是通过上下文来预测目标词,即给出一句话,从这句话中挖去一个词,然后给出周围的词,来预测挖去的这个词应该是什么,如下图预测"?"处单词的推理过程。

CBOW模型接收上下文信息作为输入,并输出(可能出现的)各个单词的出现概率,例如上下文用 ['you', 'goodbye'] 这样的单词列表表示, CBOW 模型有两个输入层(因为我们对上下文仅考虑两个单词,所以输入层有两个,如果对上下文考虑N个单词,则输入层也会有N个),经过中间层到达输出层。这里,从输入层到中间层的变换由相同的全连接层(权重为WinW_in)完成,从中间层到输出层神经元的变换由另一个全连接层(权重为WoutW_out )完成。

假设语料库中只有"You say goodbye and I say hello."七个单词,则上图中这个输出层有 7 个神经元,神经元的个数为语料库中所有词的个数,输出层的神经元是各个单词的得分,它的值越大,说明对应单词的出现概率就越高。得分是指在被解释为概率之前的值,对这些得分应用 Softmax 函数,就可以得到概率,下图所示了CBOW模型的正反向的传播,MatMul层表示输入层或中间层与权重的乘积,Softmax with Loss 层表示之前介绍过的Softmax 层和 Cross Entropy Error 层的合并。

skip-gram

skip-gram 模型是反转了 CBOW 模型处理的上下文和目标词的模型,即通过目标词来预测上下文,用下图进行对比就一清二楚了。

CBOW 模型从上下文的多个单词预测中间的单词(目标词),而 skip-gram 模型则从中间的单词(目标词)预测周围的多个单词(上下文),那么skip-gram 模型的网络结构就可以用下图来表示

这里需要注意的是,skip-gram 模型的损失函数与CBOW模型不同,skip-gram 模型的输入层只有一个,输出层的数量则与上下文的单词个数相等,因此,首先要分别求出各个输出层的损失(通过 Softmax with Loss 层等),然后将它们加起来作为最后的损失,skip-gram 模型的损失函数可以表示为下式

L=1Tt=1T(log p(wt1wt)+log p(wt+1wt))L=-\frac{1}{T}\sum_{t=1}^T(log\space p(w_{t-1}|w_t) + log\space p(w_{t+1}|w_t))

word2vec的高速化

Embedding

在one-hot 表示中,输入层的神经元是语料库中词语的个数,假设语料库中含有100万个词语,中间层的神经元个数是100,则输入层与权重的乘积就如下图所示,可以看到计算这个巨大向量和权重矩阵的乘积要花费大量的计算资源,并且仔细观察下图,可以看到输入层与权重的乘积所做的无非就是将矩阵的某个特定的行提取出来,直觉上并不需要进行如此大量的矩阵乘法运算。因此,需要使用一种新的方式抽取单词对应行的向量,Embedding就应运而生。

在NLP领域,单词的密集向量表示称为词嵌入(word embedding)或者单词的分布式表示(distributed representation),Embedding其实做的工作很简单,就是提取特定行的内容,即Embedding 的正向传播只是从权重矩阵 W 中提取特定的行,并将该特定行的神经元原样传给下一层。那么Embedding的反向传播就是从上一层(输出侧的层)传过来的梯度将原样传给下一层(输入侧的层),如下图所示,图中 idx 表示需要提取的行的索引。

注意,在反向传播中,有可能idx的元素是重复的,比如当idx[0, 2, 0, 4] 时会发生下图所示的问题,因此应该把 dh 各行的值累加到 dW 的对应行中。

负采样

不论是CBOW模型还是skip-gram 模型,推断结果都是预测正确目标词的概率,也就是说如果语料库中有100万个词,那么输出层的神经元个数也是100万个,然后从这100万个神经元中选取概率最大的一个词(CBOW模型),这同样也会出现one-hot表示的那样会耗费大量的计算资源。

其实,除了中间层与输出权重庞大的矩阵乘积之外,还有一点会影响效率的就是Softmax层的计算,随着语料库词语个数的增加,Softmax的计算量也会增加,还是以100万个词的语料库为例,Softmax层的计算将变成下面的式子

yl=exp(sk)i=11000000exp(si)y_l=\frac{exp(s_k)}{\sum_{i=1}^{1000000}exp(s_i)}

k个元素(词)的 Softmax的计算式(各个元素的得分为 sis_i),因为假定词汇量是 100 万个,所分母需要进行 100 万次的 exp 计算,这个计算也与词汇量成正比,因此,需要一个可以替代 Softmax的“轻量”的计算。

那有没有效率更好的计算方法呢?其中的一种解决方式就是负采样(negative sampling),负采样简单来说就是把多分类问题转化为二分类问题,以CBOW模型举例,推理过程是从100万个词中预测正确目标词的概率,这明显是个庞大的100万个类别的多分类问题,那能不能变成二分类问题呢?比如句子"You say goodbye and I say hello."用you和goodbye来预测say时,将推理过程变成”目标词是say吗“这个问题,就把多分类问题变成了二分类问题,即是say和不是say,那么输出层就只可以只用一个或两个神经元来代替,用一个神经元结果就是目标词是say的概率,如下图所示

到此时呢,仅仅是推理了正例(目标词)的概率,还不确定负例(预测错误的词)会产生怎样的结果,在之前的例子中,上下文是 you 和 goodbye,目标词是 say,我们到目前为止只是对正例 say 进行了二分类,但是对 say 之外的负例一无所知,而我们真正要做的事情是,对于正例(say),使 Sigmoid 层的输出接近 1;对于负例(say 以外的单词),使 Sigmoid 层的输出接近 0。

那么有必要对所有负例都进行推理学习吗?答案显示是没必要,如果全部负例都推理学习,那就又回到了100万个类别的多分类问题了,作为一种近似方法,我们仅需要选取特定个数的负例进行推理学习,只采用少数负例这就是负采样的含义,如下图所示,负例的个数可以很少(例如10个负例),注意该图中加了Embedding Dot层,Embedding Dot层与Embedding 层有所不同,主要是Embedding Dot层需要将中间层的结果与输出权重WoutW_out进行内积。

总而言之,负采样方法既可以求将正例作为目标词时的损失,同时也可以采样(选出)若干个负例,对这些负例求损失。然后,将这些数据(正例和采样出来的负例)的损失加起来,将其结果作为最终的损失,负采样输出侧的传播如下图所示

word2vec的实现

上面介绍了word2vec的一些基本的概念,其实就是一个简单的浅层(两层)神经网络,只是对于输入层和输出层需要做些高速化的处理,下面介绍word2vec的代码实现。

Embedding层的实现

输入侧的Embedding 层的实现较为简单,如下面代码所示

class Embedding:
def __init__(self, W):
self.params = [W]
self.grads = [np.zeros_like(W)]
self.idx = None

def forward(self, idx):
W, = self.params
self.idx = idx
out = W[idx]
return out

def backward(self, dout):
dW, = self.grads
dW[...] = 0
for i, word_id in enumerate(self.idx):
dW[word_id] += dout[i]
return None

输出侧的Embedding 层需要与输出权重WoutW_out进行矩阵乘积,如下图所示

因此,输出侧的Embedding 层要和dot运算层合并起来进行处理,结合后的Embedding Dot 层的代码如下所示

class EmbeddingDot:
def __init__(self, W):
self.embed = Embedding(W)
self.params = self.embed.params
self.grads = self.embed.grads
self.cache = None

def forward(self, h, idx):
target_W = self.embed.forward(idx)
out = np.sum(target_W * h, axis=1)

self.cache = (h, target_W)
return out

def backward(self, dout):
h, target_W = self.cache
dout = dout.reshape(dout.shape[0], 1)

dtarget_W = dout * h
self.embed.backward(dtarget_W)
dh = dout * target_W
return dh

负采样的实现

负采样首先要做的事随机抽取负例,随机抽取负例有个技巧,就是要让语料库中经常出现的单词容易被抽到,让语料库中不经常出现的单词难以被抽到。所以,通常的抽取方式是基于语料库中单词使用频率的采样方法会先计算语料库中各个单词的出现次数,并将其表示为“概率分布”,然后使用这个概率分布对单词进行采样, 随机抽取负例由下面的UnigramSampler 类来实现

class UnigramSampler:
def __init__(self, corpus, power, sample_size):
self.sample_size = sample_size
self.vocab_size = None
self.word_p = None

counts = collections.Counter()
for word_id in corpus:
counts[word_id] += 1

vocab_size = len(counts)
self.vocab_size = vocab_size

self.word_p = np.zeros(vocab_size)
for i in range(vocab_size):
self.word_p[i] = counts[i]

self.word_p = np.power(self.word_p, power)
self.word_p /= np.sum(self.word_p)

def get_negative_sample(self, target):
batch_size = target.shape[0]

negative_sample = np.zeros((batch_size, self.sample_size), dtype=np.int32)

for i in range(batch_size):
p = self.word_p.copy()
target_idx = target[i]
p[target_idx] = 0
p /= p.sum()
negative_sample[i, :] = np.random.choice(self.vocab_size, size=self.sample_size, replace=False, p=p)

return negative_sample

UnigramSampler 类中,简单的介绍一下np.random.choice()方法,np.random.choice() 可以用于随机抽样,如果指定 size 参数,将执行多次采样,如果指定 replace=False,将进行无放回采样,通过给参数 p 指定表示概率分布的列表,因此通过语料库中词不同的概率,将进行基于概率分布的负采样,负采样的实现就如下面代码所示

class NegativeSamplingLoss:
def __init__(self, W, corpus, power=0.75, sample_size=5):
self.sample_size = sample_size
self.sampler = UnigramSampler(corpus, power, sample_size)
self.loss_layers = [SigmoidWithLoss() for _ in range(sample_size + 1)]
self.embed_dot_layers = [EmbeddingDot(W) for _ in range(sample_size + 1)]

self.params, self.grads = [], []
for layer in self.embed_dot_layers:
self.params += layer.params
self.grads += layer.grads

def forward(self, h, target):
batch_size = target.shape[0]
negative_sample = self.sampler.get_negative_sample(target)

# 正例的正向传播
score = self.embed_dot_layers[0].forward(h, target)
correct_label = np.ones(batch_size, dtype=np.int32)
loss = self.loss_layers[0].forward(score, correct_label)

# 负例的正向传播
negative_label = np.zeros(batch_size, dtype=np.int32)
for i in range(self.sample_size):
negative_target = negative_sample[:, i]
score = self.embed_dot_layers[1 + i].forward(h, negative_target)
loss += self.loss_layers[1 + i].forward(score, negative_label)

return loss

def backward(self, dout=1):
dh = 0
for l0, l1 in zip(self.loss_layers, self.embed_dot_layers):
dscore = l0.backward(dout)
dh += l1.backward(dscore)

return dh

同样要注意的是,负例的损失要进行累加而不是覆盖。

CBOW模型的实现

有了Embedding 层和 Negative Sampling Loss 层,CBOW模型的实现就很简单了,CBOW模型的代码如下所示

class CBOW:
def __init__(self, vocab_size, hidden_size, window_size, corpus):
V, H = vocab_size, hidden_size

# 初始化权重
W_in = 0.01 * np.random.randn(V, H).astype('f')
W_out = 0.01 * np.random.randn(V, H).astype('f')

# 生成层
self.in_layers = []
for i in range(2 * window_size):
layer = Embedding(W_in) # 使用Embedding层
self.in_layers.append(layer)
self.ns_loss = NegativeSamplingLoss(W_out, corpus, power=0.75, sample_size=5)

# 将所有的权重和梯度整理到列表中
layers = self.in_layers + [self.ns_loss]
self.params, self.grads = [], []
for layer in layers:
self.params += layer.params
self.grads += layer.grads

# 将单词的分布式表示设置为成员变量
self.word_vecs = W_in

def forward(self, contexts, target):
h = 0
for i, layer in enumerate(self.in_layers):
h += layer.forward(contexts[:, i])
h *= 1 / len(self.in_layers)
loss = self.ns_loss.forward(h, target)
return loss

def backward(self, dout=1):
dout = self.ns_loss.backward(dout)
dout *= 1 / len(self.in_layers)
for layer in self.in_layers:
layer.backward(dout)
return None

这个初始化方法有 4 个参数。vocab_size 是词汇量,hidden_size 是中间层的神经元个数,corpus 是词 ID 列表。另外,通过 window_size 指定上下文的大小,即上下文包含多少个周围单词。如果 window_size 是 2,则目标词的左右 2 个词(共 4 个词)将成为上下文。

skip-gram 模型的实现

同CBOW模型类似,skip-gram 模型的代码如下所示

class SkipGram:
def __init__(self, vocab_size, hidden_size, window_size, corpus):
V, H = vocab_size, hidden_size
rn = np.random.randn

# 初始化权重
W_in = 0.01 * rn(V, H).astype('f')
W_out = 0.01 * rn(V, H).astype('f')

# 生成层
self.in_layer = Embedding(W_in)
self.loss_layers = []
for i in range(2 * window_size):
layer = NegativeSamplingLoss(W_out, corpus, power=0.75, sample_size=5)
self.loss_layers.append(layer)

# 将所有的权重和梯度整理到列表中
layers = [self.in_layer] + self.loss_layers
self.params, self.grads = [], []
for layer in layers:
self.params += layer.params
self.grads += layer.grads

# 将单词的分布式表示设置为成员变量
self.word_vecs = W_in

def forward(self, contexts, target):
h = self.in_layer.forward(target)

loss = 0
for i, layer in enumerate(self.loss_layers):
loss += layer.forward(h, contexts[:, i])
return loss

def backward(self, dout=1):
dh = 0
for i, layer in enumerate(self.loss_layers):
dh += layer.backward(dout)
self.in_layer.backward(dh)
return None

word2vec的学习

有了上面word2vec神经网络代码的实现,现在就要进行神经网络的学习了,学习的过程与ANN和CNN类似,用到了之前的Trainer类Adam优化方法,word2vec学习的过程以CBOW模型为例,skip-gram 模型与此类似,CBOW模型学习的代码如下

import numpy as np
import pickle
from dataset import ptb

# 设定超参数
window_size = 5
hidden_size = 100
batch_size = 100
max_epoch = 10

# 读入数据
corpus, word_to_id, id_to_word = ptb.load_data('train')
vocab_size = len(word_to_id)

contexts, target = create_contexts_target(corpus, window_size)

# 生成模型等
model = CBOW(vocab_size, hidden_size, window_size, corpus)
optimizer = Adam()
trainer = Trainer(model, optimizer)

# 开始学习
trainer.fit(contexts, target, max_epoch, batch_size)
trainer.plot()

# 保存必要数据,以便后续使用
word_vecs = model.word_vecs

params = {}
params['word_vecs'] = word_vecs.astype(np.float16)
params['word_to_id'] = word_to_id
params['id_to_word'] = id_to_word
pkl_file = 'cbow_params.pkl'
with open(pkl_file, 'wb') as f:
pickle.dump(params, f, -1)

create_contexts_target函数的作用是生成上下文和目标词的函数(输入参数和标签),该函数的实现代码如下

def create_contexts_target(corpus, window_size=1):
target = corpus[window_size:-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)

此外,在学习结束后,取出权重(输入侧的权重),并保存在文件中以备后用(用于单词和单词 ID 之间的转化的字典也一起保存)。这里,使用 Python 的 pickle 功能进行文件保存。pickle 可以将 Python 代码中的对象保存到文件中(或者从文件中读取对象)。

至此,word2vec的实现就到一段落了,将词转换为向量是非常重要的一项工作,这极大的提高了下游NLP任务的可操作性,因为如果可以将自然语言转化为向量,就可以使用常规的机器学习方法了(神经网络、SVM 等),就如下图所示

但是呢,word2vec是2013年Google公司发布的,现在(2020年)的NLP任务已经换了处理方向,大多是基于预训练模型(GPT、BERT等)和微调(Fine-tuning),然后在对不同的下游NLP任务采取不同的学习训练方式,在以后有机会会介绍一下GPT或者BERT的预训练模型,虽然现在采用这些预训练模型的NLP任务的准确度越来越高,但是像word2vec或者是tf-idf这些过时的处理方式也是有其用武之地的。

Author: Hongyi Guo
Link: https://guohongyi.com/2020/11/19/用Python手撕word2vec/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.