Python深度学习进阶-第四章
Word2vec的高速化
第三章我们学习了word2vec的原理,并实现了CBOW模型
但是,上一章还是遗留了一个问题的嘞:
就是随着语料库的处理词汇量增加,计算量也会随之增加的,如果不进行解决的话
那么,还是有可能在语料库非常大的时候,让机器学习的效率大幅度下降的。
举个极端的例子:如果一组语料库要处理的内容需要的期限100万年,咱们等不起哇
因为这里可不是至尊宝和紫霞仙子的爱你一万年哇!
这时呢,就要对上一章的简单的word2vec进行两点改进:
1、引入名为Embedding层的新层
2、引入名为Negative Sampling 的新的损失函数
根据上一章的CBOW模型接收2个单词的上下文,并基于他们预测1个单词(目标词)
三个计算大户:
1、H = Win x
2、S = H Wout
3、Y = Softmax with loss(s)
4.1 word2vec 的改进
4.1.1 Embedding层
针对计算大户:
通俗理解:
看上述图片,可以轻松发现:
我们需要的东西其实可以在Win层直接取出来的,所以呢
曾今我们用于理解的dot(也就是相乘层)
那就是可有可无啦,遵循多一事不如少一事的原则,那就把它给删掉吧
直接取出我们想要的数据就够啦(想要的数据为图中颜色最深的那一排数据)
这时呢,我们亲切的称呼这个省事儿方案为:Embedding层
4.1.2 Embedding 层的实现
import numpy as np
W = np.arrange(21).reshape(7,3)
print(W)
实现Embedding层
Embedding 层的正向传播和反向传播的示例图
正向传播代码实现:
# in 代表输入,out 代表输出
# in
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
# Out
W = np.arrange(21).reshape(7, 3)
embed = Embedding(W)
embed.forward(2)
#输出得到的结果:
#array([6, 7, 8])
# in
embed.idx
# Out 2
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
总结:
1.这里,取出权重梯度dW,通过dW[…]=0,将dW的元素设为0(并不是将dW设为0,而是保持dW的形状保持不变,将他的元素设为0)
然后,将上一层传来的梯度dout写入idx指定的行。
这里我们看到,0的位置的梯度累加了
2.此外for循环语句的实现也可以通过Numpy的np.add.at()进行。
np.add.at(A,idx,B)将B加到A上,此时可以通过idx指定A中需要进行加法
新的收获:
通常情况下,NumPy 的内置方法比 Python 的 for循环处理更快。 这是因为 NumPy 的内置方法在底层做了高速化和提高处理效率的 优化。因此,上面的代码如果使用 np.add.at()来实现,效率会比 使用 for循环处理高得多。
下图是词汇量为100万时的word2vec:上下文是you和goodbye,目标词是say
的示意图:
softmax原理图:
在机器学习尤其是深度学习中,softmax是个非常常用而且比较重要的函数,尤其在多分类的场景中使用广泛。他把一些输入映射为0-1之间的实数,并且归一化保证和为1,因此多分类的概率之和也刚好为1。
首先我们简单来看看softmax是什么意思。顾名思义,softmax由两个单词组成,其中一个是max。对于max我们都很熟悉,比如有两个变量a,b。如果a>b,则max为a,反之为b。用伪码简单描述一下就是 if a > b return a; else b
。
另外一个单词为soft。max存在的一个问题是什么呢?如果将max看成一个分类问题,就是非黑即白,最后的输出是一个确定的变量。更多的时候,我们希望输出的是取到某个分类的概率,或者说,我们希望分值大的那一项被经常取到,而分值较小的那一项也有一定的概率偶尔被取到,所以我们就应用到了soft的概念,即最后的输出是每个分类被取到的概率。
公式:
总结:
softmax就是将概率较大的事件极端化,概率较小的事件极小化,但是呢,他们也保留了全部元素的概率,就是说最小的元素也是有取到的可能的,就像彩票一样,概率虽然小,但是还是有可能中奖的呢。
4.2.2 从多分类到二分类
考虑二分类拟合多分类:
多分类: 给you和goodbye,目标词是谁?
二分类:当上下文是you和goodbye时,目标词又是谁?
二分类原理:
二分类是用来处理只有两种情况:Yes/No的问题,例如:这个数字是7吗?等这类问题。
其他的则不可以用二分类啦。
这样的话我们的问题就变成了下图的原理图
特点:
输出的神经元只有一个,因此呢,要计算中间层和输出层的权重矩阵的乘积,只需要提取say对应的列(单词向量(单词的坐标嘛))并用它与中间层的神经元计算内积即可啦!(这样只需要处理一个say,压力瞬间减少不少,清爽!)
下图是仅仅计算目标得分的神经网络:
4.2.3 sigmoid函数和交叉熵误差
要使用神经网络解决二分类问题,需要使用 sigmoid 函数将得分转化为 概率。为了求损失,我们使用交叉熵误差作为损失函数。这些都是二分类神经网络的老套路。
理解:
交叉熵误差做损失函数(损失函数越小,则说明机器学习的越好的呢!)
在多分类的情况下,输出层使用Softmax函数将得分转化为概率,损 失函数使用交叉熵误差。在二分类的情况下,输出层使用sigmoid 函数, 损失函数也使用交叉熵误差。
4.2.4 多分类到二分类的实现
1.回顾一下多分类
2.转换成进行二分类的神经网络图例:
在图 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
4.2.5 负采样
下图是仅学习了正确案例(正确答案)
反例与正例的对比图例数据
提问:
我们需要对全部负例进行学习吗?
答案是:NO
其实,我们只需要选择几个负例即可了的
例如下图:
负采样的采样方法:
随机取样
基于语料库的统计抽样
这里呢,我们选择第二种
原理:高频词汇抽到的概率大,低频词汇抽到的概率小
基于语料库中各个单词的出现次数求出概率分布后,只需根据这个概率 分布进行采样就可以了。通过根据概率分布进行采样,语料库中经常出现的 单词将容易被抽到,而“稀有单词”将难以被抽到
负采样应当尽可能多地覆盖负例单词,但是考虑到计算的复杂度, 有必要将负例限定在较小范围内(5 个或者 10 个)。
这里,如果只选 择稀有单词作为负例会怎样呢?结果会很糟糕。因为在现实问题中, 稀有单词基本上不会出现。也就是说,处理稀有单词的重要性较低。 相反,处理好高频单词才能获得更好的结果。
下面我们看一下用Python怎么实现:
word2vec中提到的负采样对刚才的概率分布增加了一个步骤:
这里对概率做了一个平滑处理: 类似于Softmax
使用这种方法的原因:
是为了防止低频词被忽略的
通过取0.75次方,低频单词的概率将会稍微提高的,避免低频词等于零的尴尬
下面我们可以举一个例子:
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]。这样一来,我们就完成了负采样。
4.2.7 负采样的实现
最后,我们来实现负采样。我们把它实现为 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 节点。因此,在反向传播时,需要将多份梯 度累加起来。以上就是负采样的实现的说明。
这样的负采样改为四个
4.3 改进版本的word2vec的学习
目前为止,我们上述已经学习了embedding 层的实现,又介绍了负采样法的方法。
之后,我们对他两个进行了代码实现
现在冲锋的号角已经打响,到了最后关头
战士们,Never give up
我们最后来进一步实现这些改进的神经网络,并在PTB数据集上进行学习,以用来获得更加是用的单词分布式表示。
4.3.1 CBOW模型的实现
改进版本的CBOW类
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)]
4.3.2 CBOW模型的学习代码
最后,我们来实现 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 代码中的对象保存到文 件中(或者从文件中读取对象)。
4.3.3 CBOW模型的评价
现在,我们来评价一下上一节学习到的单词的分布式表示。这里我们 使用第 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)
4.4 wor2vec相关的其他话题
4.4.1 word2vec的应用例
4.4.2 单词向量的评价方法
基于类推问题可以在一定程度上衡量“是否正确理解了单词含义或语法 问题”。
因此,在自然语言处理的应用中,能够高精度地解决类推问题的单 词的分布式表示应该可以获得好的结果。
但是,单词的分布式表示的优劣对 目标应用贡献多少(或者有无贡献),取决于待处理问题的具体情况,
比如 应用的类型或语料库的内容等。
也就是说,不能保证类推问题的评价高,目 标应用的结果就一定好。这一点请一定注意
4.5 小结
本章所学的内容
- Embedding 层保存单词的分布式表示,在正向传播时,提取单词 ID 对应的向量
- 因为 word2vec 的计算量会随着词汇量的增加而成比例地增加,所以 最好使用近似计算来加速
- 负采样技术采样若干负例,使用这一方法可以将多分类问题转化为 二分类问题进行处理
- 基于 word2vec 获得的单词的分布式表示内嵌了单词含义,在相似的 上下文中使用的单词在单词向量空间上处于相近的位置
- word2vec 的单词的分布式表示的一个特性是可以基于向量的加减法 运算来求解类推问题
- word2vec 的迁移学习能力非常重要,它的单词的分布式表示可以应 用于各种各样的自然语言处理任务