文章目录
原理讲解
当面对百万级的文本,就算是隐藏层是检索功能,其计算量也是相当大,而且还会造成冗余计算,这时候对高频词抽样以及负采样就应运而生了。
对高频词抽样
举例,原始文本为“The quick brown fox jumps over the laze dog”,如果我使用大小为2的窗口,那么我们可以得到图中展示的那些训练样本。
但是对于“the”这种常用高频单词,这样的处理方式会存在下面两个问题:
-
当我们得到成对的单词训练样本时,(“fox”, “the”) 这样的训练样本并不会给我们提供关于“fox”更多的语义信息,因为“the”在每个单词的上下文中几乎都会出现。
-
由于在文本中“the”这样的常用词出现概率很大,因此我们将会有大量的(”the“,…)这样的训练样本,而这些样本数量远远超过了我们学习“the”这个词向量所需的训练样本数。
Word2Vec通过“抽样”模式来解决这种高频词问题。它的基本思想如下:对于我们在训练原始文本中遇到的每一个单词,它们都有一定概率被我们从文本中删掉,而这个被删除的概率与单词的频率有关。
如果我们设置窗口大小span=10(即skip_window=5),并且从我们的文本中删除所有的“the”,那么会有下面的结果:
-
由于我们删除了文本中所有的“the”,那么在我们的训练样本中,“the”这个词永远也不会出现在我们的上下文窗口中。
-
当“the”作为input word时,我们的训练样本数至少会减少10个。
抽样率
word2vec的C语言代码实现了一个计算在词汇表中保留某个词概率的公式。
w
i
wi
wi是一个单词,
Z
(
w
i
)
Z(wi)
Z(wi)是
w
i
wi
wi这个单词在所有语料中出现的频次。举个栗子,如果单词“peanut”在10亿规模大小的语料中出现了1000次,那么
Z
(
"
p
e
a
n
u
t
"
)
=
1000
/
1000000000
=
1
e
−
6
Z("peanut")=1000/1000000000=1e−6
Z("peanut")=1000/1000000000=1e−6。
在代码中还有一个参数叫“sample”,这个参数代表一个阈值,默认值为0.001**(在gensim包中的Word2Vec类说明中,这个参数默认为0.001,文档中对这个参数的解释为“ threshold** for configuring which higher-frequency words are randomly downsampled”)。这个值越小意味着这个单词被保留下来的概率越小(即有越大的概率被我们删除)。
P ( w i ) P(wi) P(wi)代表着保留某个单词的概率:
P ( w i ) = ( Z ( w i ) 0.001 + 1 ) × 0.001 Z ( w i ) P(wi)=(Z(wi)0.001+1)×0.001Z(wi) P(wi)=(Z(wi)0.001+1)×0.001Z(wi)
图中x轴代表着 Z ( w i ) Z(wi) Z(wi),即单词wi在语料中出现频率,y轴代表某个单词被保留的概率。对于一个庞大的语料来说,单个单词的出现频率不会很大,即使是常用词,也不可能特别大。
从这个图中,我们可以看到,随着单词出现频率的增高,它被采样保留的概率越来越小,我们还可以看到一些有趣的结论:
-
当 Z ( w i ) < = 0.0026 时, P ( w i ) = 1.0 Z(wi)<=0.0026时,P(wi)=1.0 Z(wi)<=0.0026时,P(wi)=1.0。当单词在语料中出现的频率小于0.0026时,它是100%被保留的,这意味着只有那些在语料中出现频率超过0.26%的单词才会被采样。
-
当 Z ( w i ) = 0.00746 时, P ( w i ) = 0.5 Z(wi)=0.00746时,P(wi)=0.5 Z(wi)=0.00746时,P(wi)=0.5,意味着这一部分的单词有50%的概率被保留。
-
当 Z ( w i ) = 1.0 时, P ( w i ) = 0.033 Z(wi)=1.0时,P(wi)=0.033 Z(wi)=1.0时,P(wi)=0.033,意味着这部分单词以3.3%的概率被保留。
负采样(negative sampling)
训练一个神经网络意味着要输入训练样本并且不断调整神经元的权重,从而不断提高对目标的准确预测。每当神经网络经过一个训练样本的训练,它的权重就会进行一次调整。
正如我们上面所讨论的,vocabulary的大小决定了我们的Skip-Gram神经网络将会拥有大规模的权重矩阵,所有的这些权重需要通过我们数以亿计的训练样本来进行调整,这是非常消耗计算资源的,并且实际中训练起来会非常慢。
**负采样(negative sampling)**解决了这个问题,它是用来提高训练速度并且改善所得到词向量的质量的一种方法。不同于原本每个训练样本更新所有的权重,负采样每次让一个训练样本仅仅更新一小部分的权重,这样就会降低梯度下降过程中的计算量。
当我们用训练样本 ( input word: “fox”,output word: “quick”) 来训练我们的神经网络时,“ fox”和“quick”都是经过one-hot编码的。如果我们的vocabulary大小为10000时,在输出层,我们期望对应“quick”单词的那个神经元结点输出1,其余9999个都应该输出0。在这里,这9999个我们期望输出为0的神经元结点所对应的单词我们称为**“negative” word**。
当使用负采样时,我们将随机选择一小部分的negative words(比如选5个negative words)来更新对应的权重。我们也会对我们的“positive” word进行权重更新(在我们上面的例子中,这个单词指的是”quick“)。
在论文中,作者指出指出对于小规模数据集,选择5-20个negative words会比较好,对于大规模数据集可以仅选择2-5个negative words。
回忆一下我们的隐层-输出层拥有300 x 10000的权重矩阵。如果使用了负采样的方法我们仅仅去更新我们的positive word-“quick”的和我们选择的其他5个negative words的结点对应的权重,共计6个输出神经元,相当于每次只更新300×6=1800个权重。对于3百万的权重来说,相当于只计算了0.06%的权重,这样计算效率就大幅度提高。
如何选择negative words
我们使用“一元模型分布(unigram distribution)”来选择“negative words”。
要注意的一点是,一个单词被选作negative sample的概率跟它出现的频次有关,出现频次越高的单词越容易被选作negative words。
在word2vec的C语言实现中,你可以看到对于这个概率的实现公式。每个单词被选为“negative words”的概率计算公式与其出现的频次有关。
代码中的公式实现如下:
P ( w i ) = f ( w i ) 3 / 4 ∑ j = 0 n ( f ( w j ) 3 / 4 ) P(wi)=f(wi)3/4∑j=0n(f(wj)3/4) P(wi)=f(wi)3/4∑j=0n(f(wj)3/4)
每个单词被赋予一个权重,即f(wi), 它代表着单词出现的频次。
公式中开3/4的根号完全是基于经验的,论文中提到这个公式的效果要比其它公式更加出色。你可以在google的搜索栏中输入“ p l o t y = x ( 3 / 4 ) a n d y = x plot y = x^(3/4) and y = x ploty=x(3/4)andy=x”,然后看到这两幅图(如下图),仔细观察x在[0,1]区间内时y的取值,x3/4有一小段弧形,取值在y=x函数之上。
负采样的C语言实现非常的有趣。unigram table有一个包含了一亿个元素的数组,这个数组是由词汇表中每个单词的索引号填充的,并且这个数组中有重复,也就是说有些单词会出现多次。那么每个单词的索引在这个数组中出现的次数该如何决定呢,有公式 P ( w i ) ∗ t a b l e _ s i z e P(wi)∗table\_size P(wi)∗table_size,也就是说计算出的负采样概率*1亿=单词在表中出现的次数。
有了这张表以后,每次去我们进行负采样时,只需要在0-1亿范围内生成一个随机数,然后选择表中索引号为这个随机数的那个单词作为我们的negative word即可。一个单词的负采样概率越大,那么它在这个表中出现的次数就越多,它被选中的概率就越大。
负采样是word2vec中的一种效率优化策略。
word2vec在预测词语并进行梯度传播时,最后一层是个多分类,会计算整个词典中每个词的概率。但这个词表往往是很大的,所以softmax本身的计算量非常大,但在反向传播时大部分参数的梯度都是0,造成了很大的资源浪费,间接降低了训练的速度。
为了加速训练,负采样应运而生,采样就是从这样负样本中抽样。
例如原本词表有10000个词,传统方法对所有词进行softmax,会更新全部10000个词的参数;如果我们从词表中随机抽样5个负样本,再加上1个原始词,一共6个词,那么在此次梯度更新中就只更新这6个词的;更新数量为原来的1/20
Pytorh实现
模拟数据生成
模拟生成词典
每个词语被采样到的概率是不同的,这个概率一般跟词语在语料库中的词频成正相关。
我们模拟生成一个vocab_size = 50
的词频表:
import numpy as np
import torch
from torch import nn
vocab_size = 50
freq = np.random.randint(0, 100, vocab_size) # 词频为0~100的随机数
freq = torch.Tensor(freq)
查看词频
freq
>>> tensor([61., 56., 68., 72., 52., 33., 11., 87., 37., 3., 19., 2., 21., 2., 42., 50., 72., 95., 90., 2., 1., 59., 85., 57., 16., 4., 37., 53., 32., 75., 94., 21., 24., 87., 89., 18., 8., 15., 79., 29., 15., 81., 98., 41., 41., 91., 50., 82., 45., 36.])
模拟神经网络的输出
我们假设训练时的batch_size=2
, 那么网络的输出应该是2×50
的Tensor,这里我们用随机正态分布生成一个模拟的output:
batch_size = 2
output = torch.randn((batch_size, vocab_size)) # shape=(bacth_size, vocab_size)
output
>>> tensor([[-0.2118, -0.0755, 0.1277, 0.3777, 2.7633, -1.6847, -1.2935, -0.1957,
-0.9873, -0.2581, -0.3289, -0.0335, -0.1918, 1.2244, -1.5086, 1.3795,
-0.1605, -0.3805, -0.1456, 0.2734, -1.0849, 0.9878, -2.1371, -0.4581,
-0.6431, 0.9746, -0.1652, 1.0515, -1.2448, 0.3780, -0.5121, 1.2127,
1.4855, 0.3335, -0.2251, -0.3576, -0.1632, 0.1664, 1.8266, 1.1960,
-0.0321, -0.4959, 0.9196, 0.9370, 0.9276, 2.0438, 0.6892, -0.1583,
-0.0250, -1.5720],
[-0.7941, 0.2379, 0.7931, 0.3189, 1.4842, 1.1777, 0.3826, -3.4740,
-1.9079, -1.7246, 1.0923, 0.3330, -0.8291, -0.2273, -0.7343, 0.5267,
-0.6273, 0.0078, 0.1371, 1.4892, 0.0487, -0.0239, 0.3308, 0.5969,
-1.5041, 0.5253, 1.2802, 0.5977, -0.4532, -1.0343, 0.8519, 0.4107,
1.3690, 3.1969, 1.2605, -0.5892, -1.1081, -0.0129, 0.4972, 0.5785,
0.7631, -1.2258, -0.6963, 0.6320, -0.7767, 0.1905, 0.6363, -2.4888,
-0.5544, -0.0315]])
模拟目标label
在训练过程中每条样本都有label,网络的目标就是让输出的结果尽可能接近这个label,我们同样用随机一组label;这个label也可以视作是与“负样本”相对的“正样本”。
labels = torch.LongTensor(np.random.randint(0, vocab_size, batch_size)) pos_sample = labels.view(batch_size, -1)
pos_sample
>>> tensor([[44],
[ 9]])
采样
在一个batch中,需要对于每个样本都采样得到sample_size
个词语负样本,我们取sample_size=5
。
采样使用的是torch.multinominal()
函数,具体使用方法可以看我的另一篇笔记:
采样时可以直接将词频表作为torch.multinominal()
的参数,不需要手动归一化,就可以根据词频大小进行加权、不放回的采样。
由于同一个batch内,不同样本之间的采样时相互独立的, 所以需要将shape=vocab×1
词频表用tile方法在dim=1维度上拓展batch倍,变为shape=vocab×batch
,
sample_size = 5
weights = freq.view(1, -1).tile(batch_size, 1)
neg_sample = torch.multinomial(weights, 5)
查看负采样的结果:
neg_sample
>>> tensor([[21, 34, 27, 28, 1],
[24, 42, 7, 47, 5]])
用mask实现负采样
用-inf填充未被采样到的词,再Softmax
mask = torch.ones_like(output, dtype=torch.bool)
mask = mask.scatter(1, neg_sample, False)
mask = mask.scatter(1, pos_sample, False)
masked_output = output.masked_fill(mask, -np.inf)
masked_output
tensor([[ -inf, -0.0755, -inf, -inf, -inf, -inf, -inf, -inf,
-inf, -inf, -inf, -inf, -inf, -inf, -inf, -inf,
-inf, -inf, -inf, -inf, -inf, 0.9878, -inf, -inf,
-inf, -inf, -inf, 1.0515, -1.2448, -inf, -inf, -inf,
-inf, -inf, -0.2251, -inf, -inf, -inf, -inf, -inf,
-inf, -inf, -inf, -inf, 0.9276, -inf, -inf, -inf,
-inf, -inf],
[ -inf, -inf, -inf, -inf, -inf, 1.1777, -inf, -3.4740,
-inf, -1.7246, -inf, -inf, -inf, -inf, -inf, -inf,
-inf, -inf, -inf, -inf, -inf, -inf, -inf, -inf,
-1.5041, -inf, -inf, -inf, -inf, -inf, -inf, -inf,
-inf, -inf, -inf, -inf, -inf, -inf, -inf, -inf,
-inf, -inf, -0.6963, -inf, -inf, -inf, -inf, -2.4888,
-inf, -inf]])
计算loss
criterion = nn.NLLLoss(reduction='mean')
softmax = nn.Softmax(1)
logits = softmax(masked_output)
lopgits
>>> tensor([[0.0000, 0.0919, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000,
0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000,
0.0000, 0.0000, 0.0000, 0.2662, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000,
0.2837, 0.0285, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0791, 0.0000,
0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.2506,
0.0000, 0.0000, 0.0000, 0.0000, 0.0000],
[0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.7622, 0.0000, 0.0073, 0.0000,
0.0418, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000,
0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0522, 0.0000, 0.0000,
0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000,
0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.1170, 0.0000, 0.0000,
0.0000, 0.0000, 0.0195, 0.0000, 0.0000]])
loss = criterion(logits, labels)
loss
>>> tensor(-0.1462)
将采样到的词全部取出来,再Softmax
将正样本和负样本拼接
sample = torch.cat([pos_sample, neg_sample], 1)
sample
>>> tensor([[44, 21, 34, 27, 28, 1],
[ 9, 24, 42, 7, 47, 5]])
取出采样到的正/负样本
sampled_output = output.gather(1, sample)
sampled_output
>>> tensor([[ 0.9276, 0.9878, -0.2251, 1.0515, -1.2448, -0.0755],
[-1.7246, -1.5041, -0.6963, -3.4740, -2.4888, 1.1777]])
logits = softmax(sampled_output)
logits
>>> tensor([[0.2506, 0.2662, 0.0791, 0.2837, 0.0285, 0.0919],
[0.0418, 0.0522, 0.1170, 0.0073, 0.0195, 0.7622]])
计算loss
new_labels = torch.zeros(batch_size, dtype=torch.long)
loss = criterion(logits, new_labels)
loss
>>> tensor(-0.1462)