自然语言处理入门3——Embedding和神经网络加速计算
一、概述
前面介绍的神经网络方法速度比起传统计数方法提高了很多,但是仍然存在很大的局限性。首先我们之前的语料库很小,才几个词,最多也就几千个。如果假设我们现在的语料库有100万个单词(这其实很正常,不论中文还是英文,大型语料库可能还更大),那么输入的单词用one-hot表示法就有100万个维数,如果中间层向量大小是100,那么权重矩阵的大小就是[1000000,100],也就是要做1000000X100次乘法,需要耗费大量的时间和内存。反向传播的时候也有同样的问题。而且在做softmax计算的时候,分母要求100万次指数,并把这个100万个指数计算结果相加,计算成本可想而知,并且每个单词用长度为100万的向量来表示也非常耗资源。
所以书中提出了一种方法(《深度学习进阶:自然语言处理》,斋藤康毅)来解决这问题。
二、Embedding表示
首先,把单词进行embedding表示。如果语料库大小是100万,那么我们对单词做one-hot表示后变成[1,1000000]的向量,然后跟权重矩阵W_in相乘,这个计算很耗时耗资源,而本质上只是把对应矩阵每一行提取出来,如下图所示:
那么现在不如直接把权重矩阵的对应行提取出来就行了,不需要再花力气表示成one-hot,以及做乘法了。这个操作就叫做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
np.add.at(dW, self.idx, dout) # 矩阵相加,根据idx指定要相加的行
return None
import numpy as np
# 设定一个权重矩阵
W = np.arange(21).reshape(7,3)
print(W)
# 用权重矩阵对语料库进行embedding操作
corpus = Embedding(W)
# 取出第一个单词的embedding表示
word_embedding = corpus.forward(1)
print("word_embedding:",word_embedding)
# 输出
[[ 0 1 2]
[ 3 4 5]
[ 6 7 8]
[ 9 10 11]
[12 13 14]
[15 16 17]
[18 19 20]]
word_embedding: [3 4 5]
可以看到,第一个单词的embedding表示就是权重矩阵的第1行。
三、负采样
第二个方法就是负采样方法。假设还是要预测完形填空:“I ? hello and you say goodbye.”。所谓的负采样方法就是要将多分类转换成二分类,原来我们的神经网络输出是多分类的,并判断哪个输出的概率最高,现在二分类的话,只要判断i和hello之间的词是否是say,只要取出代表say的单词向量,和中间层相乘,得到的结果就是得分。根据这个得分来判断输出是否是say。并把softmax变成sigmoid函数,本质上sigmoid就是softmax在二分类情况下的特例。两者的区别如下图:
之前的多分类方法,得到打分结果后,进行softmax操作得到概率,取出最大的概率与标签say进行比较,获得交叉熵损失。
这是二分类的示意图,区别在于后半部分,这里不在通过矩阵乘法和输出矩阵相乘,而是先对标签say进行embedding表示,用输出矩阵获得embedding表示,再进行内积,输入到sigmoid函数中获取交叉熵损失。这里的结果就是是否是say,把原来的多分类问题转化成了二分类问题,把这个过程用一个函数来表示,称为EmbeddingDot。
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):
# 根据正确的标签值从输出权重矩阵中获取到embedding表示
target_W = self.embed.forward(idx)
# 把标签值的embedding结果和中间结果求内积
out = np.sum(target_W * h, axis=1)
# 保存正向传播时的结果,反向传播时用到
self.cache = (h, target_W)
# 这个输出值用来做sigmoid
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
负采样是因为,我们现在做的是二分类,结果就是yes或no,正例和反例,正例其实就是一个,比如“I ? hello you say goodbye.”,问好处的标准答案是say,所以预测结果是say的时候,返回应该是1,其余所有的情况都应该是0,而如果语料库总量是一百万,那么剩下的999999个单词输出都应该是0,这么大的量是很难训练的,何况这仅仅只是一种情况。所以书中提出的方法是对反例进行采样,选择概率最高的几种情况,作为反例的采样。
最终把正例的损失函数和反例的损失函数加在一起作为总的损失函数,进行反向传播,更新参数,完成训练。
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]
# 采样
negative_sample = np.random.choice(self.vocab_size,
size=(batch_size, self.sample_size),
replace=True, p=self.word_p)
return negative_sample
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]) # 真实值,batch_size=3
negative_sample = sampler.get_negative_sample(target)
print(negative_sample)
# 输出:
[[3 2]
[1 4]
[1 3]]
上面这段代码就是从语料库中采样出2个反例的例子。可以看到,真实值为1的时候,取的反例是[3,2];真实值为3的时候,取的反例是[1,4];真实值为0的时候,取的反例是[1,3]。把上面的内容结合在一起就是下面的负采样代码:
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
基于此,我们改进了上一篇文章(自然语言处理入门2——神经网络)中实现的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
四、案例测试
这里我使用了一个英文语料库,进行训练。
import sys
sys.path.append('..')
import numpy as np
import pickle
# 设定超参数
window_size = 5
hidden_size = 100
batch_size = 100
max_epoch = 10
# 读入数据
corpus, word_to_id, id_to_word = 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)
训练结果如下:
import sys
import pickle
# 余弦相似度计算
# eps防止除数为0
def cos_similarity(x, y, eps=1e-8):
nx = x/(np.sqrt(np.sum(x**2))+eps)
ny = y/(np.sqrt(np.sum(y**2))+eps)
return np.dot(nx, ny)
# 单词相似度排序函数
def most_similar(query, word_to_id, id_to_word, word_matrix, top=5):
# 1.取出查询词
if query not in word_to_id:
print('%s is not found' % query)
return
print('\n[query] '+query)
query_id = word_to_id[query]
query_vec = word_matrix[query_id]
# 2.计算余弦相似度
vocab_size = len(id_to_word)
similarity = np.zeros(vocab_size)
for i in range(vocab_size):
similarity[i] = cos_similarity(word_matrix[i], query_vec)
# 3.基于余弦相似度,按降序输出值
count = 0
for i in (-1*similarity).argsort():
if id_to_word[i] == query:
continue
print('%s : %s' % (id_to_word[i], similarity[i]))
count += 1
if count >= top:
return
pkl_file = 'cbow_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']
querys = ['you', 'year', 'car', 'toyota']
for query in querys:
most_similar(query, word_to_id, id_to_word, word_vecs, top=5)
# 输出:
[query] you
we : 0.73486328125
i : 0.65869140625
your : 0.6142578125
they : 0.60986328125
someone : 0.58544921875
[query] year
month : 0.857421875
week : 0.78076171875
summer : 0.7763671875
spring : 0.76171875
decade : 0.705078125
[query] car
truck : 0.61181640625
luxury : 0.60205078125
auto : 0.59619140625
window : 0.55712890625
cars : 0.54296875
[query] toyota
marathon : 0.685546875
weyerhaeuser : 0.65478515625
seita : 0.63232421875
engines : 0.626953125
chevrolet : 0.623046875
可以看到经过训练后,模型学到了单词比较好的向量化表示方式,you相似度最高的单词是we,year相似度最高的单词是month,car相似度最高的单词是truck,和我们人类的主观判断是比较接近的。