优化算法部分太迷糊了,几道题错完了,打个卡。把错题解析放上去。
在 Section 11.4 中,我们提到,目标函数有关自变量的梯度代表了目标函数在自变量当前位置下降最快的方向。因此,梯度下降也叫作最陡下降(steepest descent)。在每次迭代中,梯度下降根据自变量当前位置,沿着当前位置的梯度更新自变量。然而,如果自变量的迭代方向仅仅取决于自变量当前位置,这可能会带来一些问题。对于noisy gradient,我们需要谨慎的选取学习率和batch size, 来控制梯度方差和收敛的结果。
gt=∂w1|Bt|∑i∈Btf(xi,wt−1)=1|Bt|∑i∈Btgi,t−1.
Condition Number of Hessian Matrix:
condH=λmaxλmin
where λmax,λmin
is the maximum amd minimum eignvalue of Hessian matrix.
让我们考虑一个输入和输出分别为二维向量x=[x1,x2]⊤
和标量的目标函数:
f(x)=0.1x21+2x22
condH=40.2=20→ill-conditioned
, according to convex optimizaiton conclusions, we need step size η
在二阶优化中,我们使用Hessian matrix的逆矩阵(或者pseudo inverse)来左乘梯度向量 i.e.Δx=H−1g
,这样的做法称为precondition,相当于将 H
映射为一个单位矩阵,拥有分布均匀的Spectrum,也即我们去优化的等价标函数的Hessian matrix为良好的identity matrix。
与Section 11.4一节中不同,这里将x21
系数从1减小到了0.1。下面实现基于这个目标函数的梯度下降,并演示使用学习率为0.4
时自变量的迭代轨迹。
%matplotlib inline import sys sys.path.append("/home/kesci/input") import d2lzh1981 as d2l import torch eta = 0.4 def f_2d(x1, x2): return 0.1 * x1 ** 2 + 2 * x2 ** 2 def gd_2d(x1, x2, s1, s2): return (x1 - eta * 0.2 * x1, x2 - eta * 4 * x2, 0, 0) d2l.show_trace_2d(f_2d, d2l.train_2d(gd_2d))
epoch 20, x1 -0.943467, x2 -0.000073
可以看到,同一位置上,目标函数在竖直方向(x2
轴方向)比在水平方向(x1
轴方向)的斜率的绝对值更大。因此,给定学习率,梯度下降迭代自变量时会使自变量在竖直方向比在水平方向移动幅度更大。那么,我们需要一个较小的学习率从而避免自变量在竖直方向上越过目标函数最优解。然而,这会造成自变量在水平方向上朝最优解移动变慢。
下面我们试着将学习率调得稍大一点,此时自变量在竖直方向不断越过最优解并逐渐发散。
eta = 0.6 d2l.show_trace_2d(f_2d, d2l.train_2d(gd_2d))
epoch 20, x1 -0.387814, x2 -1673.365109
动量法的提出是为了解决梯度下降的上述问题。设时间步 t
的自变量为 xt,学习率为 ηt。 在时间步 t=0,动量法创建速度变量 m0,并将其元素初始化成 0。在时间步 t>0
,动量法对每次迭代的步骤做如下修改:
mtxt←βmt−1+ηtgt,←xt−1−mt,
Another version:
mtxt←βmt−1+(1−β)gt,←xt−1−αtmt,
αt=ηt1−β
其中,动量超参数 β
满足 0≤β<1。当 β=0
时,动量法等价于小批量随机梯度下降。
在解释动量法的数学原理前,让我们先从实验中观察梯度下降在使用动量法后的迭代轨迹。
def momentum_2d(x1, x2, v1, v2): v1 = beta * v1 + eta * 0.2 * x1 v2 = beta * v2 + eta * 4 * x2 return x1 - v1, x2 - v2, v1, v2 eta, beta = 0.4, 0.5 d2l.show_trace_2d(f_2d, d2l.train_2d(momentum_2d))
epoch 20, x1 -0.062843, x2 0.001202
可以看到使用较小的学习率 η=0.4
和动量超参数 β=0.5 时,动量法在竖直方向上的移动更加平滑,且在水平方向上更快逼近最优解。下面使用较大的学习率 η=0.6
,此时自变量也不再发散。
eta = 0.6 d2l.show_trace_2d(f_2d, d2l.train_2d(momentum_2d))
epoch 20, x1 0.007188, x2 0.002553
为了从数学上理解动量法,让我们先解释一下指数加权移动平均(exponential moving average)。给定超参数 0≤β<1
,当前时间步 t 的变量 yt 是上一时间步 t−1 的变量 yt−1 和当前时间步另一变量 xt
的线性组合:
yt=βyt−1+(1−β)xt.
我们可以对 yt
展开:
yt=(1−β)xt+βyt−1=(1−β)xt+(1−β)⋅βxt−1+β2yt−2=(1−β)xt+(1−β)⋅βxt−1+(1−β)⋅β2xt−2+β3yt−3=(1−β)∑i=0tβixt−i
(1−β)∑i=0tβi=1−βt1−β(1−β)=(1−βt)
Approximate Average of 11−β
Steps
令 n=1/(1−β)
,那么 (1−1/n)n=β1/(1−β)
。因为
limn→∞(1−1n)n=exp(−1)≈0.3679,
所以当 β→1
时,β1/(1−β)=exp(−1),如 0.9520≈exp(−1)。如果把 exp(−1) 当作一个比较小的数,我们可以在近似中忽略所有含 β1/(1−β) 和比 β1/(1−β) 更高阶的系数的项。例如,当 β=0.95
时,
yt≈0.05∑i=0190.95ixt−i.
因此,在实际中,我们常常将 yt
看作是对最近 1/(1−β) 个时间步的 xt 值的加权平均。例如,当 γ=0.95 时,yt 可以被看作对最近20个时间步的 xt 值的加权平均;当 β=0.9 时,yt 可以看作是对最近10个时间步的 xt 值的加权平均。而且,离当前时间步 t 越近的 xt
值获得的权重越大(越接近1)。
现在,我们对动量法的速度变量做变形:
mt←βmt−1+(1−β)(ηt1−βgt).
Another version:
mt←βmt−1+(1−β)gt.
xt←xt−1−αtmt,
αt=ηt1−β
由指数加权移动平均的形式可得,速度变量 vt
实际上对序列 {ηt−igt−i/(1−β):i=0,…,1/(1−β)−1} 做了指数加权移动平均。换句话说,相比于小批量随机梯度下降,动量法在每个时间步的自变量更新量近似于将前者对应的最近 1/(1−β) 个时间步的更新量做了指数加权移动平均后再除以 1−β
。所以,在动量法中,自变量在各个方向上的移动幅度不仅取决当前梯度,还取决于过去的各个梯度在各个方向上是否一致。在本节之前示例的优化问题中,所有梯度在水平方向上为正(向右),而在竖直方向上时正(向上)时负(向下)。这样,我们就可以使用较大的学习率,从而使自变量向最优解更快移动。
相对于小批量随机梯度下降,动量法需要对每一个自变量维护一个同它一样形状的速度变量,且超参数里多了动量超参数。实现中,我们将速度变量用更广义的状态变量states
表示。
def get_data_ch7(): data = np.genfromtxt('/home/kesci/input/airfoil4755/airfoil_self_noise.dat', delimiter='\t') data = (data - data.mean(axis=0)) / data.std(axis=0) return torch.tensor(data[:1500, :-1], dtype=torch.float32), \ torch.tensor(data[:1500, -1], dtype=torch.float32) features, labels = get_data_ch7() def init_momentum_states(): v_w = torch.zeros((features.shape[1], 1), dtype=torch.float32) v_b = torch.zeros(1, dtype=torch.float32) return (v_w, v_b) def sgd_momentum(params, states, hyperparams): for p, v in zip(params, states): v.data = hyperparams['momentum'] * v.data + hyperparams['lr'] * p.grad.data p.data -= v.data
我们先将动量超参数momentum
设0.5
d2l.train_ch7(sgd_momentum, init_momentum_states(), {'lr': 0.02, 'momentum': 0.5}, features, labels)
loss: 0.243297, 0.057950 sec per epoch
将动量超参数momentum
增大到0.9
d2l.train_ch7(sgd_momentum, init_momentum_states(), {'lr': 0.02, 'momentum': 0.9}, features, labels)
loss: 0.260418, 0.059441 sec per epoch
可见目标函数值在后期迭代过程中的变化不够平滑。直觉上,10倍小批量梯度比2倍小批量梯度大了5倍,我们可以试着将学习率减小到原来的1/5。此时目标函数值在下降了一段时间后变化更加平滑。
d2l.train_ch7(sgd_momentum, init_momentum_states(), {'lr': 0.004, 'momentum': 0.9}, features, labels)
loss: 0.243650, 0.063532 sec per epoch
在Pytorch中,torch.optim.SGD
已实现了Momentum。
d2l.train_pytorch_ch7(torch.optim.SGD, {'lr': 0.004, 'momentum': 0.9}, features, labels)
loss: 0.243692, 0.048604 sec per epoch
在之前介绍过的优化算法中,目标函数自变量的每一个元素在相同时间步都使用同一个学习率来自我迭代。举个例子,假设目标函数为f
,自变量为一个二维向量[x1,x2]⊤,该向量中每一个元素在迭代时都使用相同的学习率。例如,在学习率为η的梯度下降中,元素x1和x2都使用相同的学习率η
来自我迭代:
x1←x1−η∂f∂x1,x2←x2−η∂f∂x2.
在“动量法”一节里我们看到当x1
和x2
的梯度值有较大差别时,需要选择足够小的学习率使得自变量在梯度值较大的维度上不发散。但这样会导致自变量在梯度值较小的维度上迭代过慢。动量法依赖指数加权移动平均使得自变量的更新方向更加一致,从而降低发散的可能。本节我们介绍AdaGrad算法,它根据自变量在每个维度的梯度值的大小来调整各个维度上的学习率,从而避免统一的学习率难以适应所有维度的问题 [1]。
AdaGrad算法会使用一个小批量随机梯度gt
按元素平方的累加变量st。在时间步0,AdaGrad将s0中每个元素初始化为0。在时间步t,首先将小批量随机梯度gt按元素平方后累加到变量st
:
st←st−1+gt⊙gt,
其中⊙
是按元素相乘。接着,我们将目标函数自变量中每个元素的学习率通过按元素运算重新调整一下:
xt←xt−1−ηst+ϵ−−−−−√⊙gt,
其中η
是学习率,ϵ是为了维持数值稳定性而添加的常数,如10−6
。这里开方、除法和乘法的运算都是按元素运算的。这些按元素运算使得目标函数自变量中每个元素都分别拥有自己的学习率。
需要强调的是,小批量随机梯度按元素平方的累加变量st
出现在学习率的分母项中。因此,如果目标函数有关自变量中某个元素的偏导数一直都较大,那么该元素的学习率将下降较快;反之,如果目标函数有关自变量中某个元素的偏导数一直都较小,那么该元素的学习率将下降较慢。然而,由于st
一直在累加按元素平方的梯度,自变量中每个元素的学习率在迭代过程中一直在降低(或不变)。所以,当学习率在迭代早期降得较快且当前解依然不佳时,AdaGrad算法在迭代后期由于学习率过小,可能较难找到一个有用的解。
下面我们仍然以目标函数f(x)=0.1x21+2x22
为例观察AdaGrad算法对自变量的迭代轨迹。我们实现AdaGrad算法并使用和上一节实验中相同的学习率0.4。可以看到,自变量的迭代轨迹较平滑。但由于st
的累加效果使学习率不断衰减,自变量在迭代后期的移动幅度较小。
%matplotlib inline import math import torch import sys sys.path.append("/home/kesci/input") import d2lzh1981 as d2l def adagrad_2d(x1, x2, s1, s2): g1, g2, eps = 0.2 * x1, 4 * x2, 1e-6 # 前两项为自变量梯度 s1 += g1 ** 2 s2 += g2 ** 2 x1 -= eta / math.sqrt(s1 + eps) * g1 x2 -= eta / math.sqrt(s2 + eps) * g2 return x1, x2, s1, s2 def f_2d(x1, x2): return 0.1 * x1 ** 2 + 2 * x2 ** 2 eta = 0.4 d2l.show_trace_2d(f_2d, d2l.train_2d(adagrad_2d))
epoch 20, x1 -2.382563, x2 -0.158591
下面将学习率增大到2。可以看到自变量更为迅速地逼近了最优解。
eta = 2 d2l.show_trace_2d(f_2d, d2l.train_2d(adagrad_2d))
epoch 20, x1 -0.002295, x2 -0.000000
同动量法一样,AdaGrad算法需要对每个自变量维护同它一样形状的状态变量。我们根据AdaGrad算法中的公式实现该算法。
def get_data_ch7(): data = np.genfromtxt('/home/kesci/input/airfoil4755/airfoil_self_noise.dat', delimiter='\t') data = (data - data.mean(axis=0)) / data.std(axis=0) return torch.tensor(data[:1500, :-1], dtype=torch.float32), \ torch.tensor(data[:1500, -1], dtype=torch.float32) features, labels = get_data_ch7() def init_adagrad_states(): s_w = torch.zeros((features.shape[1], 1), dtype=torch.float32) s_b = torch.zeros(1, dtype=torch.float32) return (s_w, s_b) def adagrad(params, states, hyperparams): eps = 1e-6 for p, s in zip(params, states): s.data += (p.grad.data**2) p.data -= hyperparams['lr'] * p.grad.data / torch.sqrt(s + eps)
使用更大的学习率来训练模型。
d2l.train_ch7(adagrad, init_adagrad_states(), {'lr': 0.1}, features, labels)
loss: 0.242258, 0.061548 sec per epoch
通过名称为“adagrad”的Trainer
实例,我们便可使用Pytorch提供的AdaGrad算法来训练模型。
d2l.train_pytorch_ch7(torch.optim.Adagrad, {'lr': 0.1}, features, labels)
loss: 0.243800, 0.060953 sec per epoch
转存失败重新上传取消
我们在“AdaGrad算法”一节中提到,因为调整学习率时分母上的变量st
一直在累加按元素平方的小批量随机梯度,所以目标函数自变量每个元素的学习率在迭代过程中一直在降低(或不变)。因此,当学习率在迭代早期降得较快且当前解依然不佳时,AdaGrad算法在迭代后期由于学习率过小,可能较难找到一个有用的解。为了解决这一问题,RMSProp算法对AdaGrad算法做了修改。该算法源自Coursera上的一门课程,即“机器学习的神经网络”。
我们在“动量法”一节里介绍过指数加权移动平均。不同于AdaGrad算法里状态变量st
是截至时间步t所有小批量随机梯度gt按元素平方和,RMSProp算法将这些梯度按元素平方做指数加权移动平均。具体来说,给定超参数0≤γ0
计算
vt←βvt−1+(1−β)gt⊙gt.
和AdaGrad算法一样,RMSProp算法将目标函数自变量中每个元素的学习率通过按元素运算重新调整,然后更新自变量
xt←xt−1−αvt+ϵ−−−−−√⊙gt,
其中η
是学习率,ϵ是为了维持数值稳定性而添加的常数,如10−6。因为RMSProp算法的状态变量st是对平方项gt⊙gt的指数加权移动平均,所以可以看作是最近1/(1−β)
个时间步的小批量随机梯度平方项的加权平均。如此一来,自变量每个元素的学习率在迭代过程中就不再一直降低(或不变)。
照例,让我们先观察RMSProp算法对目标函数f(x)=0.1x21+2x22
中自变量的迭代轨迹。回忆在“AdaGrad算法”一节使用的学习率为0.4的AdaGrad算法,自变量在迭代后期的移动幅度较小。但在同样的学习率下,RMSProp算法可以更快逼近最优解。
%matplotlib inline import math import torch import sys sys.path.append("/home/kesci/input") import d2lzh1981 as d2l def rmsprop_2d(x1, x2, s1, s2): g1, g2, eps = 0.2 * x1, 4 * x2, 1e-6 s1 = beta * s1 + (1 - beta) * g1 ** 2 s2 = beta * s2 + (1 - beta) * g2 ** 2 x1 -= alpha / math.sqrt(s1 + eps) * g1 x2 -= alpha / math.sqrt(s2 + eps) * g2 return x1, x2, s1, s2 def f_2d(x1, x2): return 0.1 * x1 ** 2 + 2 * x2 ** 2 alpha, beta = 0.4, 0.9 d2l.show_trace_2d(f_2d, d2l.train_2d(rmsprop_2d))
epoch 20, x1 -0.010599, x2 0.000000
接下来按照RMSProp算法中的公式实现该算法。
def get_data_ch7(): data = np.genfromtxt('/home/kesci/input/airfoil4755/airfoil_self_noise.dat', delimiter='\t') data = (data - data.mean(axis=0)) / data.std(axis=0) return torch.tensor(data[:1500, :-1], dtype=torch.float32), \ torch.tensor(data[:1500, -1], dtype=torch.float32) features, labels = get_data_ch7() def init_rmsprop_states(): s_w = torch.zeros((features.shape[1], 1), dtype=torch.float32) s_b = torch.zeros(1, dtype=torch.float32) return (s_w, s_b) def rmsprop(params, states, hyperparams): gamma, eps = hyperparams['beta'], 1e-6 for p, s in zip(params, states): s.data = gamma * s.data + (1 - gamma) * (p.grad.data)**2 p.data -= hyperparams['lr'] * p.grad.data / torch.sqrt(s + eps)
我们将初始学习率设为0.01,并将超参数γ
设为0.9。此时,变量st可看作是最近1/(1−0.9)=10个时间步的平方项gt⊙gt
的加权平均。
d2l.train_ch7(rmsprop, init_rmsprop_states(), {'lr': 0.01, 'beta': 0.9}, features, labels)
loss: 0.243334, 0.063004 sec per epoch
通过名称为“rmsprop”的Trainer
实例,我们便可使用Gluon提供的RMSProp算法来训练模型。注意,超参数γ
通过gamma1
指定。
d2l.train_pytorch_ch7(torch.optim.RMSprop, {'lr': 0.01, 'alpha': 0.9}, features, labels)
loss: 0.244934, 0.062977 sec per epoch
除了RMSProp算法以外,另一个常用优化算法AdaDelta算法也针对AdaGrad算法在迭代后期可能较难找到有用解的问题做了改进 [1]。有意思的是,AdaDelta算法没有学习率这一超参数。
AdaDelta算法也像RMSProp算法一样,使用了小批量随机梯度gt
按元素平方的指数加权移动平均变量st。在时间步0,它的所有元素被初始化为0。给定超参数0≤ρ0
,同RMSProp算法一样计算
st←ρst−1+(1−ρ)gt⊙gt.
与RMSProp算法不同的是,AdaDelta算法还维护一个额外的状态变量Δxt
,其元素同样在时间步0时被初始化为0。我们使用Δxt−1
来计算自变量的变化量:
g′t←Δxt−1+ϵst+ϵ−−−−−−−−−√⊙gt,
其中ϵ
是为了维持数值稳定性而添加的常数,如10−5
。接着更新自变量:
xt←xt−1−g′t.
最后,我们使用Δxt
来记录自变量变化量g′t
按元素平方的指数加权移动平均:
Δxt←ρΔxt−1+(1−ρ)g′t⊙g′t.
可以看到,如不考虑ϵ
的影响,AdaDelta算法与RMSProp算法的不同之处在于使用Δxt−1−−−−−√来替代超参数η
。
AdaDelta算法需要对每个自变量维护两个状态变量,即st
和Δxt
。我们按AdaDelta算法中的公式实现该算法。
def init_adadelta_states(): s_w, s_b = torch.zeros((features.shape[1], 1), dtype=torch.float32), torch.zeros(1, dtype=torch.float32) delta_w, delta_b = torch.zeros((features.shape[1], 1), dtype=torch.float32), torch.zeros(1, dtype=torch.float32) return ((s_w, delta_w), (s_b, delta_b)) def adadelta(params, states, hyperparams): rho, eps = hyperparams['rho'], 1e-5 for p, (s, delta) in zip(params, states): s[:] = rho * s + (1 - rho) * (p.grad.data**2) g = p.grad.data * torch.sqrt((delta + eps) / (s + eps)) p.data -= g delta[:] = rho * delta + (1 - rho) * g * g
d2l.train_ch7(adadelta, init_adadelta_states(), {'rho': 0.9}, features, labels)
loss: 0.243485, 0.084914 sec per epoch
通过名称为“adadelta”的Trainer
实例,我们便可使用pytorch提供的AdaDelta算法。它的超参数可以通过rho
来指定。
d2l.train_pytorch_ch7(torch.optim.Adadelta, {'rho': 0.9}, features, labels)
loss: 0.267756, 0.061329 sec per epoch
Adam算法在RMSProp算法基础上对小批量随机梯度也做了指数加权移动平均 [1]。下面我们来介绍这个算法。
Adam算法使用了动量变量mt
和RMSProp算法中小批量随机梯度按元素平方的指数加权移动平均变量vt,并在时间步0将它们中每个元素初始化为0。给定超参数0≤β1<1(算法作者建议设为0.9),时间步t的动量变量mt即小批量随机梯度gt
的指数加权移动平均:
mt←β1mt−1+(1−β1)gt.
和RMSProp算法中一样,给定超参数0≤β2<1
(算法作者建议设为0.999), 将小批量随机梯度按元素平方后的项gt⊙gt做指数加权移动平均得到vt
:
vt←β2vt−1+(1−β2)gt⊙gt.
由于我们将m0
和s0中的元素都初始化为0, 在时间步t我们得到mt=(1−β1)∑ti=1βt−i1gi。将过去各时间步小批量随机梯度的权值相加,得到 (1−β1)∑ti=1βt−i1=1−βt1。需要注意的是,当t较小时,过去各时间步小批量随机梯度权值之和会较小。例如,当β1=0.9时,m1=0.1g1。为了消除这样的影响,对于任意时间步t,我们可以将mt再除以1−βt1,从而使过去各时间步小批量随机梯度权值之和为1。这也叫作偏差修正。在Adam算法中,我们对变量mt和vt
均作偏差修正:
m^t←mt1−βt1,
v^t←vt1−βt2.
接下来,Adam算法使用以上偏差修正后的变量m^t
和m^t
,将模型参数中每个元素的学习率通过按元素运算重新调整:
g′t←ηm^tv^t−−√+ϵ,
其中η
是学习率,ϵ是为了维持数值稳定性而添加的常数,如10−8。和AdaGrad算法、RMSProp算法以及AdaDelta算法一样,目标函数自变量中每个元素都分别拥有自己的学习率。最后,使用g′t
迭代自变量:
xt←xt−1−g′t.
我们按照Adam算法中的公式实现该算法。其中时间步t
通过hyperparams
参数传入adam
函数。
%matplotlib inline import torch import sys sys.path.append("/home/kesci/input") import d2lzh1981 as d2l def get_data_ch7(): data = np.genfromtxt('/home/kesci/input/airfoil4755/airfoil_self_noise.dat', delimiter='\t') data = (data - data.mean(axis=0)) / data.std(axis=0) return torch.tensor(data[:1500, :-1], dtype=torch.float32), \ torch.tensor(data[:1500, -1], dtype=torch.float32) features, labels = get_data_ch7() def init_adam_states(): v_w, v_b = torch.zeros((features.shape[1], 1), dtype=torch.float32), torch.zeros(1, dtype=torch.float32) s_w, s_b = torch.zeros((features.shape[1], 1), dtype=torch.float32), torch.zeros(1, dtype=torch.float32) return ((v_w, s_w), (v_b, s_b)) def adam(params, states, hyperparams): beta1, beta2, eps = 0.9, 0.999, 1e-6 for p, (v, s) in zip(params, states): v[:] = beta1 * v + (1 - beta1) * p.grad.data s[:] = beta2 * s + (1 - beta2) * p.grad.data**2 v_bias_corr = v / (1 - beta1 ** hyperparams['t']) s_bias_corr = s / (1 - beta2 ** hyperparams['t']) p.data -= hyperparams['lr'] * v_bias_corr / (torch.sqrt(s_bias_corr) + eps) hyperparams['t'] += 1
d2l.train_ch7(adam, init_adam_states(), {'lr': 0.01, 't': 1}, features, labels)
loss: 0.242722, 0.089254 sec per epoch
d2l.train_pytorch_ch7(torch.optim.Adam, {'lr': 0.01}, features, labels)
loss: 0.242389, 0.073228 sec per epoch
下列算法中哪个没有使用到Exponential Moving Average:
RMSProp
Adam
Adagrad
SGD Momentum
答案解释
选项1: RMSProp的自适应学习率分母使用了EMA
选项2: Adam自适应学习率的分子和分母都使用了EMA
选项3: Adagrad的自适应学习率没有使用EMA,而是对梯度平方进行累加,因而存在梯度消失的问题
选项4: Momentum也即对梯度计算EMA
2.
下列关于RMSProp, AdaGrad, AdaDelta, Adam等高阶优化算法的说法错误的是:
RMSProp利用Exponential Moving Average解决了Adagrad梯度消失的问题
AdaGrad出现梯度消失的原因是自适应学习率分母的不断累加使其存在最终趋于0的可能
AdaDelta是基于RMSProp的改进算法,其只有一个超参数
Adam没有使用Momentum算法
答案解释
选项1: 正确,RMSProp不是直接对梯度平方进行累加,而是使用EMA对上一时刻的自适应学习率的分母进行衰减
选项2: 正确,当梯度一直不为0时,Adagrad的自适应学习率的分母会不断累加,使自适应学习率趋于0,出现梯度消息的问题
选项3: 正确,Adelta是基于RMSprop的改进,只需传入EMA的衰减参数
选项4: 错误,Adam使用了Momentum算法,其是RMSProp与Momentum的结合
3.
下列关于Adam的说法错误的是:
Adam使用了Exponential Moving Average
Adam对大小相差很大数量级的梯度都可以rescale到相近的大小
Adam是RMSProp和Momentum算法的结合,并对EMA权重进行了无偏操作
Adam使用了两次Exponential Moving Average,并且二者使用相同的衰减参数
答案解释
选项1: 正确
选项2: 正确,Adam算法中的 mtm_tmt 和 vtv_tvt (原文符号)分别是梯度的一阶矩和二阶矩估计,二者相比,可以使更新量rescale到1的附近。
选项3: 正确
选项4: 错误,mtm_tmt 和 vtv_tvt 均使用了EMA,但是二者的衰减参数并不相同
WORD2VEC,前段时间在研究这部分内容,但是研究的效果很不好,这次好好学习一下word2vec,希望能从这节课中学习到一些内容。学习结果如下:
1.
相比于使用 one-hot 向量表示词语,以下哪一项不是词嵌入模型的优点:
训练好的词向量中能够包含更多语义信息
词向量的维度是可以自由设定的
词嵌入的实现与使用都更方便
词嵌入模型需要运用大规模语料进行训练
答案解释
选项1:one-hot 向量只是一个简单的编码,很难包含复杂的语义信息如词语的相似性等,而训练好的词向量则可以从向量的空间关系上去体现词语间的关系,从而蕴含一定的语义信息
选项2:用 one-hot 向量表示词语时,为了使每个词语都获得唯一的编码,向量长度至少要与词典大小相当,而词嵌入模型中的词向量维度则没有这个限制(实际上,词嵌入可以看作是对 one-hot 词向量基于语义相似度进行的一个降维操作)
选项3:词嵌入模型首先需要在大规模语料库上进行训练,才能得到更有意义的词向量,其次在后续模型的训练过程中,可能还需要进行进一步的模型参数优化,所以在实现和使用上,都是比 one-hot 向量更复杂的
选项4:无论是 skip-gram 模型还是 CBOW 模型,都是假设词语的含义是由其周围的单词所决定的,而为了使模型能够“学会”词语的含义,就必须将其置于大规模语料库上进行长时间的训练
2.
对定义好的 Embedding 层 embed = nn.Embedding(num_embedding=5, embed_dim=10)
进行前向计算操作 x = embed(torch.tensor([[1, 2], [3, 4], [5, 6]], dtype=torch.long))
,得到的张量形状是:
3×2×53\times 2\times 53×2×5
3×2×103\times 2\times 103×2×10
5×3×25\times 3\times 25×3×2
10×3×210\times 3\times 210×3×2
答案解释
nn.Embedding
层的实际作用就是将整数张量中的下标,替换为词向量,从张量形状上看,就是在最后添加 embed_dim
维,故得到的张量形状是 3×2×103\times 2\times 103×2×10,代码中 num_embedding
为词典的大小。
参考视频11分53秒左右
3.
在大语料库上进行大规模的词向量训练时,以下哪项操作是不必要的:
在训练时使用负采样近似,即对每个中心词都采集若干噪音词
分别定义中心词和背景词的词嵌入层
在词典中去掉出现频率极低的词,或将其在文本中替换为
等特殊字符
将词向量的维度设定得尽可能的大,使之匹配词典的大小
答案解释
选项1:大语料库意味着大的词典,若不使用负采样近似方法,词嵌入模型进行前向计算和梯度回传时,softmax
的计算代价将是难以承受的
选项2:由于 skip-gram 模型(或 CBOW 模型)的假设中,中心词和背景词都处于一种不对称的关系,而模型的数学表达式里,向量的点积项 u⊤vu^\top vu⊤v 却又是对称的,所以只能通过引入两个词嵌入层来保留假设中的非对称关系
选项3:大语料库中通常含有非常多的低频词,若不对其进行处理,将会严重损害模型的泛化能力,甚至降低高频词词向量的质量,同时,更大的词典也会意味着更大的存储和计算开销
选项4:词嵌入本质上就是在对词典进行降维操作,所以过大的词向量维度,反而可能会导致模型过拟合
我们在“循环神经网络的从零开始实现”一节中使用 one-hot 向量表示单词,虽然它们构造起来很容易,但通常并不是一个好选择。一个主要的原因是,one-hot 词向量无法准确表达不同词之间的相似度,如我们常常使用的余弦相似度。
Word2Vec 词嵌入工具的提出正是为了解决上面这个问题,它将每个词表示成一个定长的向量,并通过在语料库上的预训练使得这些向量能较好地表达不同词之间的相似和类比关系,以引入一定的语义信息。基于两种概率模型的假设,我们可以定义两种 Word2Vec 模型:
,其中 wc 为中心词,wo
,其中 Wo
在这里我们主要介绍 Skip-Gram 模型的实现,CBOW 实现与其类似,读者可之后自己尝试实现。后续的内容将大致从以下四个部分展开:
import collections import math import random import sys import time import os import numpy as np import torch from torch import nn import torch.utils.data as Data
简单来说,Word2Vec 能从语料中学到如何将离散的词映射为连续空间中的向量,并保留其语义上的相似关系。那么为了训练 Word2Vec 模型,我们就需要一个自然语言语料库,模型将从中学习各个单词间的关系,这里我们使用经典的 PTB 语料库进行训练。PTB (Penn Tree Bank) 是一个常用的小型语料库,它采样自《华尔街日报》的文章,包括训练集、验证集和测试集。我们将在PTB训练集上训练词嵌入模型。
数据集训练文件 ptb.train.txt
示例:
aer banknote berlitz calloway centrust cluett fromstein gitano guterman ...
pierre N years old will join the board as a nonexecutive director nov. N
mr. is chairman of n.v. the dutch publishing group
...
with open('/home/kesci/input/ptb_train1020/ptb.train.txt', 'r') as f: lines = f.readlines() # 该数据集中句子以换行符为分割 raw_dataset = [st.split() for st in lines] # st是sentence的缩写,单词以空格为分割 print('# sentences: %d' % len(raw_dataset)) # 对于数据集的前3个句子,打印每个句子的词数和前5个词 # 句尾符为 '' ,生僻词全用 '' 表示,数字则被替换成了 'N' for st in raw_dataset[:3]: print('# tokens:', len(st), st[:5])
# sentences: 42068 # tokens: 24 ['aer', 'banknote', 'berlitz', 'calloway', 'centrust'] # tokens: 15 ['pierre', '', 'N', 'years', 'old'] # tokens: 11 ['mr.', ' ', 'is', 'chairman', 'of']
counter = collections.Counter([tk for st in raw_dataset for tk in st]) # tk是token的缩写 counter = dict(filter(lambda x: x[1] >= 5, counter.items())) # 只保留在数据集中至少出现5次的词 idx_to_token = [tk for tk, _ in counter.items()] token_to_idx = {tk: idx for idx, tk in enumerate(idx_to_token)} dataset = [[token_to_idx[tk] for tk in st if tk in token_to_idx] for st in raw_dataset] # raw_dataset中的单词在这一步被转换为对应的idx num_tokens = sum([len(st) for st in dataset]) '# tokens: %d' % num_tokens
'# tokens: 887100'
文本数据中一般会出现一些高频词,如英文中的“the”“a”和“in”。通常来说,在一个背景窗口中,一个词(如“chip”)和较低频词(如“microprocessor”)同时出现比和较高频词(如“the”)同时出现对训练词嵌入模型更有益。因此,训练词嵌入模型时可以对词进行二次采样。 具体来说,数据集中每个被索引词 wi
将有一定概率被丢弃,该丢弃概率为
P(wi)=max(1−tf(wi)−−−−−√,0)
其中 f(wi)
是数据集中词 wi 的个数与总词数之比,常数 t 是一个超参数(实验中设为 10−4)。可见,只有当 f(wi)>t 时,我们才有可能在二次采样中丢弃词 wi
,并且越高频的词被丢弃的概率越大。具体的代码如下:
def discard(idx): ''' @params: idx: 单词的下标 @return: True/False 表示是否丢弃该单词 ''' return random.uniform(0, 1) < 1 - math.sqrt( 1e-4 / counter[idx_to_token[idx]] * num_tokens) subsampled_dataset = [[tk for tk in st if not discard(tk)] for st in dataset] print('# tokens: %d' % sum([len(st) for st in subsampled_dataset])) def compare_counts(token): return '# %s: before=%d, after=%d' % (token, sum( [st.count(token_to_idx[token]) for st in dataset]), sum( [st.count(token_to_idx[token]) for st in subsampled_dataset])) print(compare_counts('the')) print(compare_counts('join'))
# tokens: 375995 # the: before=50770, after=2161 # join: before=45, after=45
def get_centers_and_contexts(dataset, max_window_size): ''' @params: dataset: 数据集为句子的集合,每个句子则为单词的集合,此时单词已经被转换为相应数字下标 max_window_size: 背景词的词窗大小的最大值 @return: centers: 中心词的集合 contexts: 背景词窗的集合,与中心词对应,每个背景词窗则为背景词的集合 ''' centers, contexts = [], [] for st in dataset: if len(st) < 2: # 每个句子至少要有2个词才可能组成一对“中心词-背景词” continue centers += st for center_i in range(len(st)): window_size = random.randint(1, max_window_size) # 随机选取背景词窗大小 indices = list(range(max(0, center_i - window_size), min(len(st), center_i + 1 + window_size))) indices.remove(center_i) # 将中心词排除在背景词之外 contexts.append([st[idx] for idx in indices]) return centers, contexts all_centers, all_contexts = get_centers_and_contexts(subsampled_dataset, 5) tiny_dataset = [list(range(7)), list(range(7, 10))] print('dataset', tiny_dataset) for center, context in zip(*get_centers_and_contexts(tiny_dataset, 2)): print('center', center, 'has contexts', context)
dataset [[0, 1, 2, 3, 4, 5, 6], [7, 8, 9]] center 0 has contexts [1, 2] center 1 has contexts [0, 2, 3] center 2 has contexts [0, 1, 3, 4] center 3 has contexts [2, 4] center 4 has contexts [3, 5] center 5 has contexts [4, 6] center 6 has contexts [5] center 7 has contexts [8] center 8 has contexts [7, 9] center 9 has contexts [7, 8]
注:数据批量读取的实现需要依赖负采样近似的实现,故放于负采样近似部分进行讲解。
在跳字模型中,每个词被表示成两个 d
维向量,用来计算条件概率。假设这个词在词典中索引为 i ,当它为中心词时向量表示为 vi∈Rd,而为背景词时向量表示为 ui∈Rd 。设中心词 wc 在词典中索引为 c,背景词 wo 在词典中索引为 o
,我们假设给定中心词生成背景词的条件概率满足下式:
P(wo∣wc)=exp(u⊤ovc)∑i∈Vexp(u⊤ivc)
embed = nn.Embedding(num_embeddings=10, embedding_dim=4) print(embed.weight) x = torch.tensor([[1, 2, 3], [4, 5, 6]], dtype=torch.long) print(embed(x))
Parameter containing: tensor([[-0.7417, -1.9469, -0.5745, 1.4267], [ 1.1483, 1.4781, 0.3064, -0.2893], [ 0.6840, 2.4566, -0.1872, -2.2061], [ 0.3386, 1.3820, -0.3142, 0.2427], [ 0.4802, -0.6375, -0.4730, 1.2114], [ 0.7130, -0.9774, 0.5321, 1.4228], [-0.6726, -0.5829, -0.4888, -0.3290], [ 0.3152, -0.6827, 0.9950, -0.3326], [-1.4651, 1.2344, 1.9976, -1.5962], [ 0.0872, 0.0130, -2.1396, -0.6361]], requires_grad=True) tensor([[[ 1.1483, 1.4781, 0.3064, -0.2893], [ 0.6840, 2.4566, -0.1872, -2.2061], [ 0.3386, 1.3820, -0.3142, 0.2427]], [[ 0.4802, -0.6375, -0.4730, 1.2114], [ 0.7130, -0.9774, 0.5321, 1.4228], [-0.6726, -0.5829, -0.4888, -0.3290]]], grad_fn=)
X = torch.ones((2, 1, 4)) Y = torch.ones((2, 4, 6)) print(torch.bmm(X, Y).shape)
torch.Size([2, 1, 6])
def skip_gram(center, contexts_and_negatives, embed_v, embed_u): ''' @params: center: 中心词下标,形状为 (n, 1) 的整数张量 contexts_and_negatives: 背景词和噪音词下标,形状为 (n, m) 的整数张量 embed_v: 中心词的 embedding 层 embed_u: 背景词的 embedding 层 @return: pred: 中心词与背景词(或噪音词)的内积,之后可用于计算概率 p(w_o|w_c) ''' v = embed_v(center) # shape of (n, 1, d) u = embed_u(contexts_and_negatives) # shape of (n, m, d) pred = torch.bmm(v, u.permute(0, 2, 1)) # bmm((n, 1, d), (n, d, m)) => shape of (n, 1, m) return pred
由于 softmax 运算考虑了背景词可能是词典 V
中的任一词,对于含几十万或上百万词的较大词典,就可能导致计算的开销过大。我们将以 skip-gram 模型为例,介绍负采样 (negative sampling) 的实现来尝试解决这个问题。
负采样方法用以下公式来近似条件概率 P(wo∣wc)=exp(u⊤ovc)∑i∈Vexp(u⊤ivc)
:
P(wo∣wc)=P(D=1∣wc,wo)∏k=1,wk∼P(w)KP(D=0∣wc,wk)
其中 P(D=1∣wc,wo)=σ(u⊤ovc)
,σ(⋅) 为 sigmoid 函数。对于一对中心词和背景词,我们从词典中随机采样 K 个噪声词(实验中设 K=5)。根据 Word2Vec 论文的建议,噪声词采样概率 P(w) 设为 w 词频与总词频之比的 0.75
次方。
def get_negatives(all_contexts, sampling_weights, K): ''' @params: all_contexts: [[w_o1, w_o2, ...], [...], ... ] sampling_weights: 每个单词的噪声词采样概率 K: 随机采样个数 @return: all_negatives: [[w_n1, w_n2, ...], [...], ...] ''' all_negatives, neg_candidates, i = [], [], 0 population = list(range(len(sampling_weights))) for contexts in all_contexts: negatives = [] while len(negatives) < len(contexts) * K: if i == len(neg_candidates): # 根据每个词的权重(sampling_weights)随机生成k个词的索引作为噪声词。 # 为了高效计算,可以将k设得稍大一点 i, neg_candidates = 0, random.choices( population, sampling_weights, k=int(1e5)) neg, i = neg_candidates[i], i + 1 # 噪声词不能是背景词 if neg not in set(contexts): negatives.append(neg) all_negatives.append(negatives) return all_negatives sampling_weights = [counter[w]**0.75 for w in idx_to_token] all_negatives = get_negatives(all_contexts, sampling_weights, 5)
注:除负采样方法外,还有层序 softmax (hiererarchical softmax) 方法也可以用来解决计算量过大的问题,请参考原书10.2.2节。
class MyDataset(torch.utils.data.Dataset): def __init__(self, centers, contexts, negatives): assert len(centers) == len(contexts) == len(negatives) self.centers = centers self.contexts = contexts self.negatives = negatives def __getitem__(self, index): return (self.centers[index], self.contexts[index], self.negatives[index]) def __len__(self): return len(self.centers) def batchify(data): ''' 用作DataLoader的参数collate_fn @params: data: 长为batch_size的列表,列表中的每个元素都是__getitem__得到的结果 @outputs: batch: 批量化后得到 (centers, contexts_negatives, masks, labels) 元组 centers: 中心词下标,形状为 (n, 1) 的整数张量 contexts_negatives: 背景词和噪声词的下标,形状为 (n, m) 的整数张量 masks: 与补齐相对应的掩码,形状为 (n, m) 的0/1整数张量 labels: 指示中心词的标签,形状为 (n, m) 的0/1整数张量 ''' max_len = max(len(c) + len(n) for _, c, n in data) centers, contexts_negatives, masks, labels = [], [], [], [] for center, context, negative in data: cur_len = len(context) + len(negative) centers += [center] contexts_negatives += [context + negative + [0] * (max_len - cur_len)] masks += [[1] * cur_len + [0] * (max_len - cur_len)] # 使用掩码变量mask来避免填充项对损失函数计算的影响 labels += [[1] * len(context) + [0] * (max_len - len(context))] batch = (torch.tensor(centers).view(-1, 1), torch.tensor(contexts_negatives), torch.tensor(masks), torch.tensor(labels)) return batch batch_size = 512 num_workers = 0 if sys.platform.startswith('win32') else 4 dataset = MyDataset(all_centers, all_contexts, all_negatives) data_iter = Data.DataLoader(dataset, batch_size, shuffle=True, collate_fn=batchify, num_workers=num_workers) for batch in data_iter: for name, data in zip(['centers', 'contexts_negatives', 'masks', 'labels'], batch): print(name, 'shape:', data.shape) break
centers shape: torch.Size([512, 1]) contexts_negatives shape: torch.Size([512, 60]) masks shape: torch.Size([512, 60]) labels shape: torch.Size([512, 60])
应用负采样方法后,我们可利用最大似然估计的对数等价形式将损失函数定义为如下
∑t=1T∑−m≤j≤m,j≠0[−logP(D=1∣w(t),w(t+j))−∑k=1,wk∼P(w)KlogP(D=0∣w(t),wk)]
根据这个损失函数的定义,我们可以直接使用二元交叉熵损失函数进行计算:
class SigmoidBinaryCrossEntropyLoss(nn.Module): def __init__(self): super(SigmoidBinaryCrossEntropyLoss, self).__init__() def forward(self, inputs, targets, mask=None): ''' @params: inputs: 经过sigmoid层后为预测D=1的概率 targets: 0/1向量,1代表背景词,0代表噪音词 @return: res: 平均到每个label的loss ''' inputs, targets, mask = inputs.float(), targets.float(), mask.float() res = nn.functional.binary_cross_entropy_with_logits(inputs, targets, reduction="none", weight=mask) res = res.sum(dim=1) / mask.float().sum(dim=1) return res loss = SigmoidBinaryCrossEntropyLoss() pred = torch.tensor([[1.5, 0.3, -1, 2], [1.1, -0.6, 2.2, 0.4]]) label = torch.tensor([[1, 0, 0, 0], [1, 1, 0, 0]]) # 标签变量label中的1和0分别代表背景词和噪声词 mask = torch.tensor([[1, 1, 1, 1], [1, 1, 1, 0]]) # 掩码变量 print(loss(pred, label, mask)) def sigmd(x): return - math.log(1 / (1 + math.exp(-x))) print('%.4f' % ((sigmd(1.5) + sigmd(-0.3) + sigmd(1) + sigmd(-2)) / 4)) # 注意1-sigmoid(x) = sigmoid(-x) print('%.4f' % ((sigmd(1.1) + sigmd(-0.6) + sigmd(-2.2)) / 3))
embed_size = 100 net = nn.Sequential(nn.Embedding(num_embeddings=len(idx_to_token), embedding_dim=embed_size), nn.Embedding(num_embeddings=len(idx_to_token), embedding_dim=embed_size))
def train(net, lr, num_epochs): device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') print("train on", device) net = net.to(device) optimizer = torch.optim.Adam(net.parameters(), lr=lr) for epoch in range(num_epochs): start, l_sum, n = time.time(), 0.0, 0 for batch in data_iter: center, context_negative, mask, label = [d.to(device) for d in batch] pred = skip_gram(center, context_negative, net[0], net[1]) l = loss(pred.view(label.shape), label, mask).mean() # 一个batch的平均loss optimizer.zero_grad() l.backward() optimizer.step() l_sum += l.cpu().item() n += 1 print('epoch %d, loss %.2f, time %.2fs' % (epoch + 1, l_sum / n, time.time() - start)) train(net, 0.01, 5)
train on cpu
epoch 1, loss 0.61, time 221.30s
epoch 2, loss 0.42, time 227.70s
epoch 3, loss 0.38, time 240.50s
epoch 4, loss 0.36, time 253.79s
epoch 5, loss 0.34, time 238.51s
注:由于本地CPU上训练时间过长,故只截取了运行的结果,后同。大家可以自行在网站上训练。
def get_similar_tokens(query_token, k, embed): ''' @params: query_token: 给定的词语 k: 近义词的个数 embed: 预训练词向量 ''' W = embed.weight.data x = W[token_to_idx[query_token]] # 添加的1e-9是为了数值稳定性 cos = torch.matmul(W, x) / (torch.sum(W * W, dim=1) * torch.sum(x * x) + 1e-9).sqrt() _, topk = torch.topk(cos, k=k+1) topk = topk.cpu().numpy() for i in topk[1:]: # 除去输入词 print('cosine sim=%.3f: %s' % (cos[i], (idx_to_token[i]))) get_similar_tokens('chip', 3, net[0])
cosine sim=0.446: intel
cosine sim=0.427: computer
cosine sim=0.427: computers
词嵌入进阶:
1.
对于 Skip-Gram, CBOW, GloVe 等词嵌入方法的理解,以下哪项是错误的:
词嵌入模型的训练本质上是在优化模型预测各词语同时出现的概率
词嵌入模型的设计和训练语料库的选取都很重要
不管是什么任务,在使用他人已经训练好的词向量时,直接复制词向量的权重就行了,不需要再进行任何额外的操作
GloVe 模型用到了语料库上全局的统计信息,而 Skip-Gram 和 CBOW 模型则只用到了局部的统计信息
答案解释
选项1:详见视频4:40
选项2:抽象地说,词嵌入方法都是通过在大规模的语料库上进行训练,来让模型更好地“理解”词义,而好的模型设计则能提高训练的效率及模型的上界
选项3:由于他人训练词向量时用到的语料库和当前任务上的语料库通常都不相同,所以词典中包含的词语以及词语的顺序都可能有很大差别,此时应当根据当前数据集上词典的顺序,来依次读入词向量,同时,为了避免训练好的词向量在训练的最初被破坏,还可以适当调整嵌入层的学习速率甚至设定其不参与梯度下降
选项4:详见 GloVe 方法的训练目标公式,与视频4:50
2.
关于 GloVe 方法基于 Skip-Gram 的改动,以下哪项描述是错误的:
GloVe 使用了非概率分布的变量,并添加了中心词和背景词的偏差项,这样做是在松弛概率的规范性,即各个概率事件的概率和加起来等于1
GloVe 使用了一个单调递增的权重函数来加权各个损失项
由于交叉熵损失函数在很多任务中都被证明比平方损失函数更加有效,所以 GloVe 沿用了交叉熵损失函数
GloVe 的损失函数计算公式中用到了语料库上的全局统计信息
答案解释
详见视频3:40
3.
关于利用词向量求近义词和类比词,以下哪项描述是错误的:
我们可以直接使用他人预训练好的词向量,而不必从头开始训练
载入预训练词向量时,语料库和词向量维度的选取并不会对任务的表现有所影响
词语含义上的相似性和词向量空间中的余弦相似性是可以对应的
求类比词时可以复用求近义词的代码
答案解释
选项1:由于我们的计算资源和时间都很有限,所以我们通常都会加载他人预训练好的词向量,而非在大规模语料库上从头开始训练
选项2:在进行预训练词向量的载入时,我们需要根据任务的特性来选定语料库的大小和词向量的维度,以均衡模型的表达能力和泛化能力,同时还要兼顾计算的时间复杂度
选项3:详见视频08:20
选项4:求类比词时我们先会对给定的三个词的词向量进行加减运算,以得到一个虚拟的词向量,再去求这个虚拟词向量的近义词,就可以找到类比词
在“Word2Vec的实现”一节中,我们在小规模数据集上训练了一个 Word2Vec 词嵌入模型,并通过词向量的余弦相似度搜索近义词。虽然 Word2Vec 已经能够成功地将离散的单词转换为连续的词向量,并能一定程度上地保存词与词之间的近似关系,但 Word2Vec 模型仍不是完美的,它还可以被进一步地改进:
实际中,我们常常在大规模的语料上训练这些词嵌入模型,并将预训练得到的词向量应用到下游的自然语言处理任务中。本节就将以 GloVe 模型为例,演示如何用预训练好的词向量来求近义词和类比词。
先简单回顾以下 Word2Vec 的损失函数(以 Skip-Gram 模型为例,不考虑负采样近似):
−∑t=1T∑−m≤j≤m,j≠0logP(w(t+j)∣w(t))
其中
P(wj∣wi)=exp(u⊤jvi)∑k∈Vexp(u⊤kvi)
是 wi
为中心词,wj 为背景词时 Skip-Gram 模型所假设的条件概率计算公式,我们将其简写为 qij
。
注意到此时我们的损失函数中包含两个求和符号,它们分别枚举了语料库中的每个中心词和其对应的每个背景词。实际上我们还可以采用另一种计数方式,那就是直接枚举每个词分别作为中心词和背景词的情况:
−∑i∈V∑j∈Vxijlogqij
其中 xij
表示整个数据集中 wj 作为 wi
的背景词的次数总和。
我们还可以将该式进一步地改写为交叉熵 (cross-entropy) 的形式如下:
−∑i∈Vxi∑j∈Vpijlogqij
其中 xi
是 wi 的背景词窗大小总和,pij=xij/xi 是 wj 在 wi
的背景词窗中所占的比例。
从这里可以看出,我们的词嵌入方法实际上就是想让模型学出 wj
有多大概率是 wi 的背景词,而真实的标签则是语料库上的统计数据。同时,语料库中的每个词根据 xi
的不同,在损失函数中所占的比重也不同。
注意到目前为止,我们只是改写了 Skip-Gram 模型损失函数的表面形式,还没有对模型做任何实质上的改动。而在 Word2Vec 之后提出的 GloVe 模型,则是在之前的基础上做出了以下几点改动:
和 q'ij=exp(u⊤jvi)
综上,我们获得了 GloVe 模型的损失函数表达式:
∑i∈V∑j∈Vh(xij)(u⊤jvi+bi+cj−logxij)2
由于这些非零 xij
是预先基于整个数据集计算得到的,包含了数据集的全局统计信息,因此 GloVe 模型的命名取“全局向量”(Global Vectors)之意。
GloVe 官方 提供了多种规格的预训练词向量,语料库分别采用了维基百科、CommonCrawl和推特等,语料库中词语总数也涵盖了从60亿到8,400亿的不同规模,同时还提供了多种词向量维度供下游模型使用。
torchtext.vocab
中已经支持了 GloVe, FastText, CharNGram 等常用的预训练词向量,我们可以通过声明 torchtext.vocab.GloVe
类的实例来加载预训练好的 GloVe 词向量。
import torch import torchtext.vocab as vocab print([key for key in vocab.pretrained_aliases.keys() if "glove" in key]) cache_dir = "/home/kesci/input/GloVe6B5429" glove = vocab.GloVe(name='6B', dim=50, cache=cache_dir) print("一共包含%d个词。" % len(glove.stoi)) print(glove.stoi['beautiful'], glove.itos[3366])
['glove.42B.300d', 'glove.840B.300d', 'glove.twitter.27B.25d', 'glove.twitter.27B.50d', 'glove.twitter.27B.100d', 'glove.twitter.27B.200d', 'glove.6B.50d', 'glove.6B.100d', 'glove.6B.200d', 'glove.6B.300d'] 一共包含400000个词。 3366 beautiful
由于词向量空间中的余弦相似性可以衡量词语含义的相似性(为什么?),我们可以通过寻找空间中的 k 近邻,来查询单词的近义词。
def knn(W, x, k): ''' @params: W: 所有向量的集合 x: 给定向量 k: 查询的数量 @outputs: topk: 余弦相似性最大k个的下标 [...]: 余弦相似度 ''' cos = torch.matmul(W, x.view((-1,))) / ( (torch.sum(W * W, dim=1) + 1e-9).sqrt() * torch.sum(x * x).sqrt()) _, topk = torch.topk(cos, k=k) topk = topk.cpu().numpy() return topk, [cos[i].item() for i in topk] def get_similar_tokens(query_token, k, embed): ''' @params: query_token: 给定的单词 k: 所需近义词的个数 embed: 预训练词向量 ''' topk, cos = knn(embed.vectors, embed.vectors[embed.stoi[query_token]], k+1) for i, c in zip(topk[1:], cos[1:]): # 除去输入词 print('cosine sim=%.3f: %s' % (c, (embed.itos[i]))) get_similar_tokens('chip', 3, glove)
cosine sim=0.856: chips cosine sim=0.749: intel cosine sim=0.749: electronics
100%|█████████▉| 398393/400000 [00:30<00:00, 38997.22it/s]
get_similar_tokens('baby', 3, glove)
cosine sim=0.839: babies cosine sim=0.800: boy cosine sim=0.792: girl
get_similar_tokens('beautiful', 3, glove)
cosine sim=0.921: lovely cosine sim=0.893: gorgeous cosine sim=0.830: wonderful
除了求近义词以外,我们还可以使用预训练词向量求词与词之间的类比关系,例如“man”之于“woman”相当于“son”之于“daughter”。求类比词问题可以定义为:对于类比关系中的4个词“a
之于 b 相当于 c 之于 d”,给定前3个词 a,b,c 求 d。求类比词的思路是,搜索与 vec(c)+vec(b)−vec(a) 的结果向量最相似的词向量,其中 vec(w) 为 w的词向量。
def get_analogy(token_a, token_b, token_c, embed): ''' @params: token_a: 词a token_b: 词b token_c: 词c embed: 预训练词向量 @outputs: res: 类比词d ''' vecs = [embed.vectors[embed.stoi[t]] for t in [token_a, token_b, token_c]] x = vecs[1] - vecs[0] + vecs[2] topk, cos = knn(embed.vectors, x, 1) res = embed.itos[topk[0]] return res get_analogy('man', 'woman', 'son', glove)
'daughter'
get_analogy('beijing', 'china', 'tokyo', glove)
'japan'
get_analogy('bad', 'worst', 'big', glove)
'biggest'
get_analogy('do', 'did', 'go', glove)