上一章我们学习了 word2vec 的机制,并实现了 CBOW 模型。因为 CBOW 模型是一个简单的 2 层神经网络,所以实现起来比较简单。但是, 目前的实现存在几个问题,其中最大的问题是,随着语料库中处理的词汇量 的增加,计算量也随之增加。
对上一章中简单的 word2vec 进行两点改进:引入名为 Embedding 层的新层,以及引入名为 Negative Sampling 的新损失函数。
上一章的 CBOW 模型接收拥有 2 个单词的上下文,并 基于它们预测 1 个单词(目标词)。
三个计算大户
1.H=Win x
2.S=H Wout
3.Y=Softmax with loss(s)
我们反应过来了,我们要的h,其实就是在Win中取出跟X对应的那一行
即可那用不着dot,直接取就行了
这个我们给他起名叫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 #加逗号把array外面的方括号去点了
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]
# 或者
# np.add.at(dW, self.idx, dout)
return None
这里,取出权重梯度dW,通过dW[…]=0,将dW的元素设为0(并不是将dW设为0,而是保持dW的形状保持不变,将他的元素设为0)
然后,将上一层传来的梯度dout写入idx指定的行。
这里我们看到,0的位置的梯度累加了
此外for循环语句的实现也可以通过Numpy的np.add.at()进行。
np.add.at(A,idx,B)将B加到A上,此时可以通过idx指定A中需要进行加法
通常情况下,NumPy 的内置方法比 Python 的 for循环处理更快。 这是因为 NumPy 的内置方法在底层做了高速化和提高处理效率的 优化。因此,上面的代码如果使用 np.add.at()来实现,效率会比 使用 for循环处理高得多。
下面解决问题2和3
中间层的神经元和权重矩阵(Wout)的乘积
中间层向量的 大小是 100,权重矩阵的大小是 100 × 1 000 000 万,如此巨大的矩阵乘积 计算需要大量时间(也需要大量内存)。此外,因为反向传播时也要进行同 样的计算,所以很有必要将矩阵乘积计算“轻量化”
考虑用二分类拟合多分类
多分类:给you和goodbye,目标词是谁?
二分类:“当上下文是you和goodbye时,目标词是say吗?
二 分 类 处 理 的 是 答 案 为“Yes/No”的 问 题。诸 如,“这 个 数 字 是 7 吗?”“这是猫吗?”“目标词是 say 吗?”等,这些问题都可以用 “Yes/No”来回答
那么我们的计算就变成下图
输出层的神经元仅有一个。因此,要计算中间层和输出 侧的权重矩阵的乘积,只需要提取 say 对应的列(单词向量),并用它与中间层的神经元计算内积即可。
要使用神经网络解决二分类问题,需要使用 sigmoid 函数将得分转化为 概率。为了求损失,我们使用交叉熵误差作为损失函数。这些都是二分类神 经网络的老套路。
在多分类的情况下,输出层使用Softmax函数将得分转化为概率,损 失函数使用交叉熵误差。在二分类的情况下,输出层使用sigmoid 函数, 损失函数也使用交叉熵误差。
输出 −log y;当 t 为 0 时,输出 −log (1 − y)。
在图 4-12 中,向 Sigmoid with Loss 层输入正确解标签 1,这意 味着现在正在处理的问题的答案是“Yes”。当答案是“No”时, 向 Sigmoid with Loss 层输入 0。
实现这个层
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 #加逗号把array外面的方括号去点了
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]
# 或者
# np.add.at(dW, self.idx, dout)
return None
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
我们需要对所有的负例进行学习吗?
Non non non, pas du tout~
我们只需要选几个个负例即可
采样有很多方法,比如
基于语料库中各个单词的出现次数求出概率分布后,只需根据这个概率 分布进行采样就可以了。通过根据概率分布进行采样,语料库中经常出现的 单词将容易被抽到,而“稀有单词”将难以被抽到
负采样应当尽可能多地覆盖负例单词,但是考虑到计算的复杂度, 有必要将负例限定在较小范围内(5 个或者 10 个)。
这里,如果只选 择稀有单词作为负例会怎样呢?结果会很糟糕。因为在现实问题中, 稀有单词基本上不会出现。也就是说,处理稀有单词的重要性较低。 相反,处理好高频单词才能获得更好的结果
看一下python是怎么实现的
word2vec 中提出的负采样对刚才的概率分布增加了一个步骤。
这是为了防止低频单词被忽略。更准确地说,通过取 0.75 次方,低频单词的概率将稍微变高。我们来看一个具体例子,如下所示
import sys
sys.path.append('..')
from common.np import * # import numpy as np
from common.layers import Embedding, SigmoidWithLoss
import collections
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]
if not GPU:
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)
else:
# 在用GPU(cupy)计算时,优先速度
# 有时目标词存在于负例中
negative_sample = np.random.choice(self.vocab_size, size=(batch_size, self.sample_size),
replace=True, p=self.word_p)
return negative_sample
在进行初始化时,UnigramSampler 类取 3 个参数,分别是单词 ID 列表 格式的 corpus、对概率分布取的次方值 power(默认值是 0.75)和负例的采 样个数 sample_size。
UnigramSampler 类有 get_negative_sample(target) 方法, 该方法以参数 target 指定的单词 ID 为正例,对其他的单词 ID 进行采样。
corpus = np.array([0, 1, 2, 3, 4, 1, 2, 3])
power = 0.75
sample_size = 2 #负例的采样个数
sampler = UnigramSampler(corpus, power, sample_size)
target = np.array([1, 3, 0])
#GPU=False
negative_sample = sampler.get_negative_sample(target)
print(negative_sample)
[[0 3]
[1 2]
[2 3]]
这里,考虑将 [1, 3, 0] 这 3 个数据的 mini-batch 作为正例。此时,对 各个数据采样 2 个负例。在上面的例子中,可知第 1 个数据的负例是 [0, 3], 第 2 个是 [1, 2],第 3 个是 [2, 3]。这样一来,我们就完成了负采样。
最后,我们来实现负采样。我们把它实现为 NegativeSamplingLoss 类, 首先从初始化开始
class NegativeSamplingLoss:
def __init__(self, W, corpus, power=0.75, sample_size=5):
#输出侧权重的 W、语料库(单词 ID 列表)corpus、概率分布的次方值 power 和负例的采样数 sample_size。
self.sample_size = sample_size
#生成上一节所说的 UnigramSampler 类,并使用成员变量 sampler 保存
self.sampler = UnigramSampler(corpus, power, sample_size)
#成员变量 loss_layers 和 embed_dot_layers 中以列表格式保存了必要的层。
#在这两个列表中生成 sample_size + 1 个层,这是因为需要生成一个正例用的层和 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 = [], [] #params存储的是w的值
for layer in self.embed_dot_layers:
self.params += layer.params
self.grads += layer.grads
#接收的参数是中间层的神经元 h 和正例目标词 target。
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) #正确解标签都是1
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
成员变量 loss_layers 和 embed_dot_layers 中以列表格式保存了必要的 层。在这两个列表中生成 sample_size + 1 个层,这是因为需要生成一个正 例用的层和 sample_size 个负例用的层。这里,我们假设列表的第一个层处 理正例。也就是说,loss_layers[0] 和 embed_dot_layers[0] 是处理正例的层。 然后,将 embed_dot_layers 层使用的权重和梯度分别保存在数组中。
forward(h, target) 方法接收的参数是中间层的神经元 h 和正例目标 词 target。这里进行的处理是,首先使用 self.sampler 采样负例,并设为 negative_sample。然后,分别对正例和负例的数据进行正向传播,求损失的 和。具体而言,通过 Embedding Dot 层的 forward 输出得分,再将这个得 分和标签一起输入 Sigmoid with Loss 层来计算损失。这里需要注意的是, 正例的正确解标签为 1,负例的正确解标签为 0。
反向传播的实现非常简单,只需要以与正向传播相反的顺序调用各层的 backward() 函数即可。在正向传播时,中间层的神经元被复制了多份,这相 当于 1.3.4.3 节中介绍的 Repeat 节点。因此,在反向传播时,需要将多份梯 度累加起来。以上就是负采样的实现的说明。
到目前为止,我们进行了 word2vec 的改进。首先说明了 Embedding 层,又介绍了负采样的方法,然后对这两者进行了实现。现在我们进一步来 实现进行了这些改进的神经网络,并在 PTB 数据集上进行学习,以获得更 加实用的单词的分布式表示。
import sys
sys.path.append('..')
from common.np import * # import numpy as np
from common.layers import Embedding
from ch04.negative_sampling_layer import NegativeSamplingLoss
class CBOW:
def __init__(self, vocab_size, hidden_size, window_size, corpus):
#vocab_size 是词汇量,hidden_size 是中间层的神经元个数,corpus 是单词 ID 列表。
#window_size 指定上下文的大小,即上下文包含多少个周围单词。如果 window_size 是 2,则目标词的左右 2 个单词(共 4 个单词)将成为上下文。
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')
# 生成层
#创建 2 * window_size 个Embedding 层,并将其保存在成员变量 in_layers 中。然后,创建 Negative Sampling Loss 层。
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
在 SimpleCBOW类(改进前的实现)中,输入侧的权重和输出侧的 权重的形状不同,输出侧的权重在列方向上排列单词向量。而 CBOW类的输出侧的权重和输入侧的权重形状相同,都在行方向 上排列单词向量。这是因为 NegativeSamplingLoss类中使用了 Embedding 层
1.这个初始化方法有 4 个参数。vocab_size 是词汇量,hidden_size 是中间 层的神经元个数,corpus 是单词 ID 列表。另外,通过 window_size 指定上下 文的大小,即上下文包含多少个周围单词。如果 window_size 是 2,则目标 词的左右 2 个单词(共 4 个单词)将成为上下文
2.在权重的初始化结束后,继续创建层。这里,创建 2 * window_size 个 Embedding 层,并将其保存在成员变量 in_layers 中。然后,创建 Negative Sampling Loss 层
3.在创建好层之后,将神经网络中使用的参数和梯度放入成员变量 params 和 grads 中。另外,为了之后可以访问单词的分布式表示,将权重 W_in 设置 为成员变量 word_vecs。
4.这里的实现只是按适当的顺序调用各个层的正向传播(或反向传播), 这是对上一章的 SimpleCBOW 类的自然扩展。不过,虽然 forward (contexts, target) 方法取的参数仍是上下文和目标词,但是它们是单词 ID 形式的(上 一章中使用的是 one-hot 向量,不是单词 ID),具体示例如图 4-19 所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-y3kXhN2s-1639141023224)(4/31.png)]
最后,我们来实现 CBOW 模型的学习部分。其实只是复用一下神经网络的学习,
import sys
sys.path.append('..')
from common import config
# 在用GPU运行时,请打开下面的注释(需要cupy)
# ===============================================
# config.GPU = True
# ===============================================
from common.np import *
import pickle
from common.trainer import Trainer
from common.optimizer import Adam
from cbow import CBOW
from skip_gram import SkipGram
from common.util import create_contexts_target, to_cpu, to_gpu
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)
if config.GPU:
contexts, target = to_gpu(contexts), to_gpu(target)
# 生成模型等
model = CBOW(vocab_size, hidden_size, window_size, corpus)
# model = SkipGram(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
if config.GPU:
word_vecs = to_cpu(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' # or 'skipgram_params.pkl'
with open(pkl_file, 'wb') as f:
pickle.dump(params, f, -1)
本次的 CBOW 模型的窗口大小为 5,隐藏层的神经元个数为 100。虽 然具体取决于语料库的情况,但是一般而言,当窗口大小为 2 ~ 10、中间 层的神经元个数(单词的分布式表示的维数)为50~500时,结果会比较好。
这次我们利用的 PTB 语料库比之前要大得多,因此学习需要很长时间 (半天左右)。作为一种选择,我们提供了使用 GPU 运行的模式。如果要使 用 GPU 运行,需要打开顶部的“# config.GPU = True”。不过,使用 GPU 运行需要有一台安装了 NVIDIA GPU 和 CuPy 的机器。
在学习结束后,取出权重(输入侧的权重),并保存在文件中以备后用 (用于单词和单词 ID 之间的转化的字典也一起保存)。这里,使用 Python 的 pickle 功能进行文件保存。pickle 可以将 Python 代码中的对象保存到文 件中(或者从文件中读取对象)。
现在,我们来评价一下上一节学习到的单词的分布式表示。这里我们 使用第 2 章中实现的 most_similar() 函数,显示几个单词的最接近的单词
import sys
sys.path.append('..')
from common.util import most_similar, analogy
import pickle
pkl_file = 'cbow_params.pkl' #cbow_params.pkl
# pkl_file = 'skipgram_params.pkl'
with open(pkl_file, 'rb') as f:
params = pickle.load(f)
word_vecs = params['word_vecs']
word_to_id = params['word_to_id']
id_to_word = params['id_to_word']
# most similar task
querys = ['you', 'year', 'car', 'toyota']
for query in querys:
most_similar(query, word_to_id, id_to_word, word_vecs, top=5)
# analogy task
print('-'*50)
analogy('king', 'man', 'queen', word_to_id, id_to_word, word_vecs)
analogy('take', 'took', 'go', word_to_id, id_to_word, word_vecs)
analogy('car', 'cars', 'child', word_to_id, id_to_word, word_vecs)
analogy('good', 'better', 'bad', word_to_id, id_to_word, word_vecs)
基于类推问题可以在一定程度上衡量“是否正确理解了单词含义或语法 问题”。
因此,在自然语言处理的应用中,能够高精度地解决类推问题的单 词的分布式表示应该可以获得好的结果。
但是,单词的分布式表示的优劣对 目标应用贡献多少(或者有无贡献),取决于待处理问题的具体情况,
比如 应用的类型或语料库的内容等。
也就是说,不能保证类推问题的评价高,目 标应用的结果就一定好。这一点请一定注意