知识架构:

封面

本文的主要作用是在阅读过程中做一些摘录。对于「机器学习」领域, c7w 虽然曾尝试从各个领域入门,也尝试训过一些模型,但是还是缺少系统性、结构性的学习。希望阅读本书能带来更多的收获吧。

与前面的一些笔记相比,本文更加侧重于「实践」。也就是说切实地提升自己的代码能力。

Part C 包含:

  • § 7 优化算法
    • 优化与深度学习,优化存在的挑战
    • 梯度下降(略)
    • Momentum, Adagrad
    • RMSProp, AdaDelta
    • Adam
  • § 8 计算性能
    • 多 GPU 计算
    • 多 GPU 计算时模型的保存与加载
  • § 10 NLP
    • Word2Vec, fastText, GloVe
    • Encoder-Decoder, Beam Search
    • Attention

优化算法

优化与深度学习

本节将讨论优化与深度学习的关系,以及优化在深度学习中的挑战。

在一个深度学习问题中,我们通常会预先定义一个损失函数。有了损失函数以后,我们就可以使用优化算法试图将其最小化。在优化中,这样的损失函数通常被称作优化问题的目标函数。依据惯例,优化算法通常只考虑最小化目标函数。其实,任何最大化问题都可以很容易地转化为最小化问题,只需令目标函数的相反数为新的目标函数即可。

优化的挑战:

  • 局部最小值
  • Saddle Point

Gradient Descent 与 SGD

(之前的笔记中记录已十分详细,此处略去)

动量法

 指数移动加权平均法,是指各数值的加权系数随时间呈指数式递减,越靠近当前时刻的数值加权系数就越大

 指数移动加权平均较传统的平均法来说,一是不需要保存过去所有的数值;二是计算量显著减小。

目标函数有关自变量的梯度代表了目标函数在自变量当前位置下降最快的方向。因此,梯度下降也叫作最陡下降。在每次迭代中,梯度下降根据自变量当前位置,沿着当前位置的梯度更新自变量。

然而,如果自变量的迭代方向仅仅取决于自变量当前位置,这可能会带来一些问题。

让我们考虑一个输入和输出分别为二维向量 $\boldsymbol{x} = [x_1, x_2]^\top$​ 和标量的目标函数 $f(\boldsymbol{x})=0.1x_1^2+2x_2^2$​。

img

可以看到,同一位置上,目标函数在竖直方向($x_2$ 轴方向)比在水平方向($x_1$ 轴方向)的斜率的绝对值更大。因此,给定学习率,梯度下降迭代自变量时会使自变量在竖直方向比在水平方向移动幅度更大。那么,我们需要一个较小的学习率从而避免自变量在竖直方向上越过目标函数最优解。然而,这会造成自变量在水平方向上朝最优解移动变慢。

但如果我们试着将学习率调得稍大一点,此时自变量在竖直方向不断越过最优解并逐渐发散。

动量法的提出是为了解决梯度下降的上述问题。设时间步 $t$​ 的自变量为 $\boldsymbol{x}_t$​,学习率为 $\eta_t$​,对应梯度为 $\boldsymbol g_t$​。
在时间步 $0$​,动量法创建速度变量 $\boldsymbol{v}_0$​,并将其元素初始化成 0。在时间步 $t>0$​,动量法对每次迭代的步骤做如下修改:

其中,动量超参数$\gamma$满足$0 \leq \gamma < 1$。当$\gamma=0$时,动量法等价于小批量随机梯度下降。

img

在动量法中,自变量在各个方向上的移动幅度不仅取决当前梯度,还取决于过去的各个梯度在各个方向上是否一致。

实现的话也是大调库:

d2l.train_pytorch_ch7(torch.optim.SGD, {'lr': 0.004, 'momentum': 0.9},
                    features, labels)

Adagrad

AdaGrad 算法根据自变量在每个维度的梯度值的大小来调整各个维度上的学习率,从而避免统一的学习率难以适应所有维度的问题。

AdaGrad 算法会使用一个小批量随机梯度 $\boldsymbol{g}_t$​​ 按元素平方的累加变量 $\boldsymbol{s}_t$​​。在时间步 0,AdaGrad 将 $\boldsymbol{s}_0$​​ 中每个元素初始化为 0。在时间步 $t$​​,首先将小批量随机梯度 $\boldsymbol{g}_t$​​ 按元素平方后累加到变量 $\boldsymbol{s}_t$​​:

接着,我们将目标函数自变量中每个元素的学习率通过按元素运算重新调整一下:

其中 $\eta$​ 是学习率,$\epsilon$​ 是为了维持数值稳定性而添加的常数,如 $10^{-6}$​​​。这里开方、除法和乘法的运算都是按元素运算的。这些按元素运算使得目标函数自变量中每个元素都分别拥有自己的学习率。

由于 $\boldsymbol{s}_t$ 一直在累加按元素平方的梯度,自变量中每个元素的学习率在迭代过程中一直在降低(或不变)。所以,当学习率在迭代早期降得较快且当前解依然不佳时,AdaGrad算法在迭代后期由于学习率过小,可能较难找到一个有用的解。

通过名称为 Adagrad 的优化器方法,我们便可使用 PyTorch 提供的 AdaGrad 算法来训练模型。

d2l.train_pytorch_ch7(torch.optim.Adagrad, {'lr': 0.1}, features, labels)

RMSProp

针对最后我们提出的 Adagrad 算法存在的迭代后期 learning_rate 过小问题,RMSProp 算法被提出。

不同于 AdaGrad 算法里状态变量 $\boldsymbol{s}_t$ 是截至时间步 $t$ 所有小批量随机梯度 $\boldsymbol{g}_t$ 按元素平方和,RMSProp 算法将这些梯度按元素平方做指数加权移动平均。具体来说,给定超参数 $0 \leq \gamma < 1$,RMSProp 算法在时间步 $t>0$ 计算 $\boldsymbol{s}_t \leftarrow \gamma \boldsymbol{s}_{t-1} + (1 - \gamma) \boldsymbol{g}_t \odot \boldsymbol{g}_t$​.

和 AdaGrad 算法一样,RMSProp 算法将目标函数自变量中每个元素的学习率通过按元素运算重新调整,然后更新自变量 $\boldsymbol{x}_t \leftarrow \boldsymbol{x}_{t-1} - \frac{\eta}{\sqrt{\boldsymbol{s}_t + \epsilon}} \odot \boldsymbol{g}_t $.

因为 RMSProp 算法的状态变量 $\boldsymbol{s}_t$ 是对平方项 $\boldsymbol{g}_t \odot \boldsymbol{g}_t$ 的指数加权移动平均,所以可以看作是最近 $ \dfrac 1 {1-\gamma}$ 个时间步的小批量随机梯度平方项的加权平均。如此一来,自变量每个元素的学习率在迭代过程中就不再一直降低(或不变)。

d2l.train_pytorch_ch7(torch.optim.RMSprop, {'lr': 0.01, 'alpha': 0.9},
                    features, labels)

AdaDelta

除了 RMSProp 算法以外,另一个常用优化算法 AdaDelta 算法也针对 AdaGrad 算法在迭代后期可能较难找到有用解的问题做了改进。有意思的是,AdaDelta 算法没有学习率这一超参数

AdaDelta 算法也像 RMSProp 算法一样,使用了小批量随机梯度 $\boldsymbol{g}_t$ 按元素平方的指数加权移动平均变量 $\boldsymbol{s}_t$。在时间步 0,它的所有元素被初始化为 0。给定超参数 $0 \leq \rho < 1$(对应RMSProp算法中的 $\gamma$),在时间步 $t>0$,同RMSProp算法一样计算 $ \boldsymbol{s}_t \leftarrow \rho \boldsymbol{s}_{t-1} + (1 - \rho) \boldsymbol{g}_t \odot \boldsymbol{g}_t $。​

与 RMSProp 算法不同的是,AdaDelta 算法还维护一个额外的状态变量 $\Delta\boldsymbol{x}_t$,其元素同样在时间步 0 时被初始化为 0。我们使用 $\Delta\boldsymbol{x}_{t-1}$ 来计算自变量的变化量:$ \boldsymbol{g}_t’ \leftarrow \sqrt{\frac{\Delta\boldsymbol{x}_{t-1} + \epsilon}{\boldsymbol{s}_t + \epsilon}} \odot \boldsymbol{g}_t $​,其中 $\epsilon$ 是为了维持数值稳定性而添加的常数,如$10^{-5}$。

接着更新自变量:$ \boldsymbol{x}_t \leftarrow \boldsymbol{x}_{t-1} - \boldsymbol{g}’_t $​。

最后,我们使用 $\Delta\boldsymbol{x}_t$ 来记录自变量变化量 $\boldsymbol{g}’_t$ 按元素平方的指数加权移动平均:$\Delta\boldsymbol{x}_t \leftarrow \rho \Delta\boldsymbol{x}_{t-1} + (1 - \rho) \boldsymbol{g}’_t \odot \boldsymbol{g}’_t$。

可以看到,如不考虑 $\epsilon$​ 的影响,AdaDelta 算法跟 RMSProp 算法的不同之处在于使用 $\sqrt{\Delta\boldsymbol{x}_{t-1}}$​ 来替代学习率 $\eta$​

d2l.train_pytorch_ch7(torch.optim.Adadelta, {'rho': 0.9}, features, labels)

Adam

Adam 算法在 RMSProp 算法基础上对小批量随机梯度也做了指数加权移动平均,所以 Adam 算法可以看做是 RMSProp 算法与动量法的结合。

Adam 算法使用了动量变量 $\boldsymbol{v}_t$ 和 RMSProp 算法中小批量随机梯度按元素平方的指数加权移动平均变量 $\boldsymbol{s}_t$,并在时间步 0 将它们中每个元素初始化为 0。

给定超参数 $0 \leq \beta_1 < 1$(算法作者建议设为 0.9),时间步 $t$ 的动量变量 $\boldsymbol{v}_t$ 即小批量随机梯度 $\boldsymbol{g}_t$ 的指数加权移动平均:$\boldsymbol{v}_t \leftarrow \beta_1 \boldsymbol{v}_{t-1} + (1 - \beta_1) \boldsymbol{g}_t $。​

和 RMSProp 算法中一样,给定超参数 $0 \leq \beta_2 < 1$(算法作者建议设为0.999),
将小批量随机梯度按元素平方后的项 $\boldsymbol{g}_t \odot \boldsymbol{g}_t$ 做指数加权移动平均得到 $\boldsymbol{s}_t$:$\boldsymbol{s}_t \leftarrow \beta_2 \boldsymbol{s}_{t-1} + (1 - \beta_2) \boldsymbol{g}_t \odot \boldsymbol{g}_t$

  • 偏差修正

由于我们将 $\boldsymbol{v}_0$ 和 $\boldsymbol{s}_0$ 中的元素都初始化为 0,在时间步 $t$ 我们得到 $\boldsymbol{v}_t = (1-\beta_1) \sum_{i=1}^t \beta_1^{t-i} \boldsymbol{g}_i$。

将过去各时间步小批量随机梯度的权值相加,得到 $(1-\beta_1) \sum_{i=1}^t \beta_1^{t-i} = 1 - \beta_1^t$。

需要注意的是,当 $t$ 较小时,过去各时间步小批量随机梯度权值之和会较小。例如,当 $\beta_1 = 0.9$ 时,$\boldsymbol{v}_1 = 0.1\boldsymbol{g}_1$。

为了消除这样的影响,对于任意时间步 $t$,我们可以将 $\boldsymbol{v}_t$ 再除以 $1 - \beta_1^t$​,从而使过去各时间步小批量随机梯度权值之和为 1。这也叫作偏差修正。

在 Adam 算法中,我们对变量 $\boldsymbol{v}_t$ 和 $\boldsymbol{s}_t$ 均作偏差修正:

接下来,Adam 算法使用以上偏差修正后的变量 $\hat{\boldsymbol{v}}_t$ 和 $\hat{\boldsymbol{s}}_t$,将模型参数中每个元素的学习率通过按元素运算重新调整:$\boldsymbol{g}_t’ \leftarrow \frac{\eta \hat{\boldsymbol{v}}_t}{\sqrt{\hat{\boldsymbol{s}}_t} + \epsilon}$。​​其中 $\eta$ ​是学习率,$\epsilon$ ​是为了维持数值稳定性而添加的常数,如 $10^{-8}$​。

和 AdaGrad 算法、RMSProp 算法以及 AdaDelta 算法一样,目标函数自变量中每个元素都分别拥有自己的学习率。最后,使用 $\boldsymbol{g}_t’$ ​迭代自变量:$\boldsymbol{x}_t \leftarrow \boldsymbol{x}_{t-1} - \boldsymbol{g}_t’$。

d2l.train_pytorch_ch7(torch.optim.Adam, {'lr': 0.01}, features, labels)

计算性能:多 GPU 计算

  • 多 GPU 计算

先定义一个模型:

import torch
net = torch.nn.Linear(10, 1).cuda()
print(net) # Linear(in_features=10, out_features=1, bias=True)

要想使用 PyTorch 进行多 GPU 计算,最简单的方法是直接用 torch.nn.DataParallel 将模型wrap一下即可:

net = torch.nn.DataParallel(net)
net

输出:
DataParallel(
  (module): Linear(in_features=10, out_features=1, bias=True)
)

这时,默认所有存在的 GPU 都会被使用。

如果我们机子中有很多 GPU (例如上面显示我们有 4 张显卡,但是只有第 0、3 块还剩下一点点显存),但我们只想使用 0、3 号显卡,那么我们可以用参数 device_ids 指定即可:torch.nn.DataParallel(net, device_ids=[0, 3])

  • 多 GPU 模型的保存与加载

按之前的方法,被 DataParallel 包围的模型保存时正常,但加载时会出问题。

正确的方法是保存的时候只保存 net.module:

torch.save(net.module.state_dict(), "./8.4_model.pt")
new_net.load_state_dict(torch.load("./8.4_model.pt")) # 加载成功

或者先将 new_netDataParallel 包括以下再用上面报错的方法进行模型加载:

torch.save(net.state_dict(), "./8.4_model.pt")
new_net = torch.nn.Linear(10, 1)
new_net = torch.nn.DataParallel(new_net)
new_net.load_state_dict(torch.load("./8.4_model.pt")) # 加载成功

NLP

Word2Vec

Word2vec 是 Google 于 2013 年推出的开源的获取词向量 word2vec 的工具包。它包括了一组用于 word embedding 的模型,这些模型通常都是用浅层(两层)神经网络训练词向量。

Word2vec 的模型以大规模语料库作为输入,然后生成一个向量空间(通常为几百维)。词典中的每个词都对应了向量空间中的一个独一的向量,而且语料库中拥有共同上下文的词映射到向量空间中的距离会更近

本节参考 https://www.zybuluo.com/Dounm/note/591752,梳理其发展历程及原理。

  • 神经概率语言模型

1

  • 训练样本为 (Context(w), w),其中 $w \in Corpus$, $Context(w)$ 为其前面的 $n-1$ 个词
  • $X_w$ 为直接将 $Context(w)$​ 收尾顺次相接得到的 $(n-1) \cdot m$ 长度的向量,其中 $m$ 为词向量长度
  • $Z_w = \tanh (WX_w + p), \, y_w = Uz_w + q$.

于是在对 $y_w$ Softmax 归一化之后,$y_w$ 在对应维度的分量就是对应词的预测概率。

这个模型存在的问题:计算量太大。假设 $n \sim 5, m \sim 10^2, |Z_w| \sim 10^2, |y_w| = | \Sigma| \sim 10^5$,那么隐层和输出层之间的矩阵运算,以及输出层的 Softmax 运算会大大增加模型的计算量。

  • Word2vec 对网格结构的优化

7

网格结构删除了隐藏层,而且 Projection Layer 也由“拼接”变成了“求和”,因此 Projection Layer 的结点数为词向量对应维数;输出层规模仍然是词典中词的个数。

但是,对于神经网络而言,我们的输入层的参数是中各个词的词向量,那么这个词向量是怎么来的呢?

事实上,我们在给定 $x$ 计算 $y_i$ 的时候,采用的方法是 $Y = Wx$,即 $y_i = w_i^Tx$,即矩阵 $W$ 第 $i$ 行与投影层做点积。于是这个词向量就可以直接用输出层和映射层之间的参数 $W$ 的第 $i$ 行 $w_i$ 来表示。

这样一来的话,我们训练神经网络的参数,就相当于训练了每个词的词向量,也就得到了词典中每个词的词向量。

  • 对 Softmax 归一化的优化

但是这样我们还是没有绕开 Softmax 对计算量的消耗。

上述式子的计算瓶颈在于分母。分母需要枚举一遍词典中所有的词,而词典中的词的数目在 $10^5$ 的量级。同时,我们需要对语料库中的每个词进行训练并按照这个公式计算所有 $y_i$ 的归一化概率,而语料库中的词的数量级通常在 million 甚至 billion 量级,这样一来的话,训练复杂度就无法接受。

因此,Word2vec 提出了两种优化 Softmax 计算过程的方法,同样也对应着 Word2vec 的两种框架,即: Hieraichical Softmax 和 Negative Sampling。

  • Hierarchical Softmax

本框架之所以叫 Hierarchical Softmax,是因为它利用了树实现了分层的 Softmax,即用树形结构替代了输出层的结构。

其具体作用原理我们会在后续介绍 CBOW 模型时介绍。

  • Negative Sampling

除了使用上述 Hierarchical Softmax 方法之外,也可以使用 Noise Contrastive Estimation 来优化。

NCE posits that a good model should be able to differentiate data from noise by means of logistic regression.

Word2vec 采用的 Negative Sampling 是 NCE 的一种简化版本,目的是为了提高训练速度以及改善所得词的质量。相比于 Hierarchical Softmax,Negative Sampling 不再采用 Huffman 树,而是采用随机负采样

考虑:

我们要让这个值最大化,也就是说要最大化 $w_i$ 和 $x$ 的余弦相似度,最小化非 $w_i$ 与 $x$ 的余弦相似度。

我们可以将分子的 $(Context(w), w_i)$​​ 看做一个正样本,将分母的 $(Context(w), w_k)$​​ 看做负样本,这里 $k \ne i$。

问题在于,上面公式将词典里的几乎所有词都看做了负样本,因此计算分母太耗时间。所以,我们使用 Negative Sampling 的思路,每次只从词典里随机选一些词作为当前词 $w$​ 的负样本(称为 $NCE(w)$​​),而不是以所有的字典里的其他词作为负样本。

Word2vec 的作者们测试发现,最佳的分布是 $\frac 3 4$ 次幂的 Unigram Distribution,也就是说,选中语料库中词 $w$ 的概率为 $\dfrac {count(w)^{3/4}} {\sum_{u \in D}count(u)^{3/4}}$.

其实在做出随机选取负样本的动作之后,我们就已经抛弃了 Softmax 这个函数所代表的归一化的意思了。也就代表了我们已经不再关注求解语言模型的问题,而只关注求解词向量的问题。

  • Continous Bag-of-words

这里我们以 CBOW(Continous Bag-of-words)模型来说明 Hierarchical Softmax 的作用方法。

2

这里首先对上图进行一定的解释:

  • $Context(w)$: 由 $w$ 前后各 $c$ 个词构成
  • 输入层:包含 $2c$ 个词的词向量
  • 投影层:将 $2c$ 个词向量累加求和得到的 $X_w$
  • 输出层:输出层对应的是一棵 Huffman 树。该 Huffman 树以语料中出现的词为叶子节点,以各词在语料中出现的次数当做权值构造出来的。该树中,叶子节点总共有 $N$ 个($N = |D| := |Corpus|$)。

首先定义以下符号:

  • $p^w$:从根节点到 $w$ 对应的那个叶子结点在树中的路径
  • $l^w$:路径 $p^w$ 上包含的结点数目
  • $p^w_1, p^w_2, \cdots, p^w_{l^w}$:路径 $p^w$ 上对应的结点
  • $d^w_2, d^w_3, \cdots, d^w_{l^w}$:路径 $p^w$ 上的结点对应的 Huffman 编码
  • $\theta^w_1, \theta^w_2, \cdots, \theta^w_{l^w}$:路径 $p^w$ 上非叶子结点对应的词向量

从根节点出发到某个叶子节点的路径上,每次分支都可视为进行了一次二分类。以下的推导过程中,我们默认左边(编码为0)是负类,右边(编码为1)是正类。于是有:

  • 分为正类的概率:$\sigma(X_w^T\theta) = \dfrac 1 {1 + e^{-X_w^T\theta}}$
  • 分为负类的概率:$1-\sigma(X_w^T\theta)$

其中 $\theta$ 即为当前非叶子结点的对应词向量。

所以 Hierarchical Softmax 的思想就是:对于词典 $D$ 中的任意词 $w$,Huffman 树中必存在一条从根节点到词对应叶子节点 $w$ 的路径 $p^w$。这条路径 $p^w$ 上存在 $l^w-1$ 个分支,将每个分支看做一次二分类,每次分类就产生一个概率,将这些概率连乘,即得到我们所需近似的 $p(w | Context(w))$

然后我们就可以使用极大似然法,只需将这个近似的条件概率最大化,就可以达到我们想要的训练效果。这里我们略去对其求梯度的推导过程。

  • Skip-gram

Skip-gram 模型是已知当前词 $w$,对其上下文中的词 $Context(w)$ 进行预测。

举个例子,假设文本序列是 “the”“man”“loves”“his”“son”。以 “loves” 作为中心词,设背景窗口大小为 2。Skip-gram 模型所关心的是,给定中心词 “loves”,生成与它距离不超过 2 个词的背景词 “the”“man”“his”“son” 的条件概率,即

我们要做的就是使用极大似然法最大化这个条件概率。

fastText

在 Word2Vec 中,我们并没有直接利用构词学中的信息。无论是在跳字模型还是连续词袋模型中,我们都将形态不同的单词用不同的向量来表示。例如,“dog” 和 “dogs” 分别用两个不同的向量表示,而模型中并未直接表达这两个向量之间的关系。鉴于此,fastText 提出了子词嵌入的方法,从而试图将构词信息引入 Word2Vec 中的跳字模型。

在 fastText 中,每个中心词被表示成子词的集合。下面我们用单词 “where” 作为例子来了解子词是如何产生的。首先,我们在单词的首尾分别添加特殊字符 “<” 和 “>” 以区分作为前后缀的子词。然后,将单词当成一个由字符构成的序列来提取 $n$​ 元语法。例如,当 $n=3$​ 时,我们得到所有长度为3的子词:“<wh>”“whe”“her”“ere”“<re>” 以及特殊子词 “<where>”

在 fastText 中,对于一个词 $w$​​,我们将它所有长度在 $3 \sim 6$​ ​的子词和特殊子词的并集记为 $\mathcal{G}_w$​​。那么词典则是所有词的子词集合的并集。假设词典中子词 $g$​​ 的向量为 $\boldsymbol{z}_g$​​,那么跳字模型中词 $w$​​ 的作为中心词的向量 $\boldsymbol{v}_w$ ​​​则表示成

GloVe

  • 在有些情况下,交叉熵损失函数有劣势。GloVe 模型采用了平方损失,并通过词向量拟合预先基于整个数据集计算得到的全局统计信息。
  • 任意词的中心词向量和背景词向量在 GloVe 模型中是等价的。

Encoder-Decoder

  • 总览

img

图中描述了使用编码器—解码器将上述英语句子翻译成法语句子的一种方法。

在训练数据集中,我们可以在每个句子后附上特殊符号“<eos>”(end of sequence)以表示序列的终止。

编码器每个时间步的输入依次为英语句子中的单词、标点和特殊符号“<eos>”。图中使用了编码器在最终时间步的隐藏状态作为输入句子的表征或编码信息。

解码器在各个时间步中使用输入句子的编码信息和上个时间步的输出以及隐藏状态作为输入。我们希望解码器在各个时间步能正确依次输出翻译后的法语单词、标点和特殊符号”<eos>”。

需要注意的是,解码器在最初时间步的输入用到了一个表示序列开始的特殊符号”<bos>”(beginning of sequence)。

  • Encoder (Usually RNN)

编码器的作用是把一个不定长的输入序列变换成一个定长的背景变量 $\boldsymbol{c}$,并在该背景变量中编码输入序列信息。常用的编码器是循环神经网络。

让我们考虑批量大小为 1 的时序数据样本。假设输入序列是 $x_1,\ldots,x_T$,$x_i$ 是输入句子中的第 $i$ 个词。在时间步 $t$,循环神经网络将输入 $x_t$ 的特征向量 $\boldsymbol{x}_t$ 和上个时间步的隐藏状态 $\boldsymbol{h}_{t-1}$ 变换为当前时间步的隐藏状态 $\boldsymbol{h}_t$。我们可以用函数 $f$ 表达循环神经网络隐藏层的变换:

接下来,编码器通过自定义函数 $q$ 将各个时间步的隐藏状态变换为背景变量

例如,当选择 $q(\boldsymbol{h}_1, \ldots, \boldsymbol{h}_T) = \boldsymbol{h}_T$ 时,背景变量是输入序列最终时间步的隐藏状态 $\boldsymbol{h}_T$。

以上描述的编码器是一个单向的循环神经网络,每个时间步的隐藏状态只取决于该时间步及之前的输入子序列。我们也可以使用双向循环神经网络构造编码器。在这种情况下,编码器每个时间步的隐藏状态同时取决于该时间步之前和之后的子序列(包括当前时间步的输入),并编码了整个序列的信息。

  • Decoder

刚刚已经介绍,编码器输出的背景变量 $\boldsymbol{c}$ 编码了整个输入序列 $x_1, \ldots, x_T$ 的信息。给定训练样本中的输出序列 $y_1, y_2, \ldots, y_{T’}$,对每个时间步 $t’$(符号与输入序列或编码器的时间步 $t$ 有区别),解码器输出 $y_{t’}$ 的条件概率将基于之前的输出序列 $y_1,\ldots,y_{t’-1}$ 和背景变量 $\boldsymbol{c}$,即$P(y_{t’} \mid y_1, \ldots, y_{t’-1}, \boldsymbol{c})$。

为此,我们可以使用另一个循环神经网络作为解码器。在输出序列的时间步 $t^\prime$,解码器将上一时间步的输出 $y_{t^\prime-1}$ 以及背景变量 $\boldsymbol{c}$ 作为输入,并将它们与上一时间步的隐藏状态 $\boldsymbol{s}_{t^\prime-1}$ 变换为当前时间步的隐藏状态 $\boldsymbol{s}_{t^\prime}$。因此,我们可以用函数 $g$ 表达解码器隐藏层的变换:

有了解码器的隐藏状态后,我们可以使用自定义的输出层和 softmax 运算来计算 $P(y_{t^\prime} \mid y_1, \ldots, y_{t^\prime-1}, \boldsymbol{c})$,例如,基于当前时间步的解码器隐藏状态 $\boldsymbol{s}_{t^\prime}$、上一时间步的输出 $y_{t^\prime-1}$ 以及背景变量 $\boldsymbol{c}$ 来计算当前时间步输出$y_{t^\prime}$的概率分布。

  • 输出层的贪婪搜索不一定能得到最优输出序列

上一节里已经提到,在准备训练数据集时,我们通常会在样本的输入序列和输出序列后面分别附上一个特殊符号 “<eos>” 表示序列的终止。我们在接下来的讨论中也将沿用上一节的全部数学符号。为了便于讨论,假设解码器的输出是一段文本序列。设输出文本词典 $\mathcal{Y}$(包含特殊符号 “<eos>” )的大小为 $\left|\mathcal{Y}\right|$,输出序列的最大长度为 $T’$。所有可能的输出序列一共有 $\mathcal{O}(\left|\mathcal{Y}\right|^{T’})$ 种。这些输出序列中所有特殊符号 “<eos>” 后面的子序列将被舍弃。

让我们先来看一个简单的解决方案:贪婪搜索(greedy search)。对于输出序列任一时间步 $t’$​​,我们从 $|\mathcal{Y}|$​​ 个词中搜索出条件概率最大的词

作为输出。一旦搜索出 “<eos>” 符号,或者输出序列长度已经达到了最大长度 $T’$​,便完成输出。

这样做的问题是,这样“逐层贪心”的策略不一定能够得到最优输出序列。

  • 穷举搜索的开销过于庞大
  • Beam Search!

束搜索(beam search)是对贪婪搜索的一个改进算法。

img

它有一个束宽(beam size)超参数。我们将它设为 $k$​​。在时间步1时,选取当前时间步条件概率最大的 $k$​​ 个词,分别组成 $k$​​ 个候选输出序列的首词。在之后的每个时间步,基于上个时间步的 $k$ ​​个候选输出序列,从 $k\left|\mathcal{Y}\right|$​​ 个可能的输出序列中选取条件概率最大的 $k$​ ​​个,作为该时间步的候选输出序列。最终,我们从各个时间步的候选输出序列中筛选出包含特殊符号 “<eos& gt;”的序列,并将它们中所有特殊符号 “<eos>” 后面的子序列舍弃,得到最终候选输出序列的集合。

在最终候选输出序列的集合中,我们取以下分数最高的序列作为输出序列:

其中 $L$​​ 为最终候选序列长度,$\alpha$​​ 一般可选为0.75。分母上的 $L^\alpha$ ​​是为了惩罚较长序列在以上分数中较多的对数相加项。分析可知,束搜索的计算开销为 $\mathcal{O}(k\left|\mathcal{Y}\right|T’)$​​。这介于贪婪搜索和穷举搜索的计算开销之间。此外,贪婪搜索可看作是束宽为 1 的束搜索。束搜索通过灵活的束宽 $k$ ​​来权衡计算开销和搜索质量。

Attention Mechanism

现在,让我们再次思考上一节提到的翻译例子:输入为英语序列 “They”“are”“watching”“.”,输出为法语序列 “Ils”“regardent”“.”。

不难想到,解码器在生成输出序列中的每一个词时可能只需利用输入序列某一部分的信息。例如,在输出序列的时间步 1,解码器可以主要依赖 “They”“are” 的信息来生成 “Ils”,在时间步 2 则主要使用来自 “watching” 的编码信息生成 “regardent”,最后在时间步3则直接映射句号 “.”。这看上去就像是在解码器的每一时间步对输入序列中不同时间步的表征或编码信息分配不同的注意力一样。这也是注意力机制的由来。

仍然以循环神经网络为例,注意力机制通过对编码器所有时间步的隐藏状态做加权平均来得到背景变量。解码器在每一时间步调整这些权重,即注意力权重,从而能够在不同时间步分别关注输入序列中的不同部分并编码进相应时间步的背景变量。本节我们将讨论注意力机制是怎么工作的。

在上一节里我们区分了输入序列或编码器的索引 $t$ ​与输出序列或解码器的索引 $t’$​。该节中,解码器在时间步 $t’$ ​的隐藏状态 $\boldsymbol{s}_{t’} = g(\boldsymbol{y}_{t’-1}, \boldsymbol{c}, \boldsymbol{s}_{t’-1})$,其中 $\boldsymbol{y}_{t’-1}$​ 是上一时间步 $t’-1$​ 的输出 $y_{t’-1}$ ​的表征,且任一时间步 $t’$ ​使用相同的背景变量 $\boldsymbol{c}$​。

但在注意力机制中,解码器的每一时间步将使用可变的背景变量。记 $\boldsymbol{c}_{t’}$ ​是解码器在时间步 $t’$ ​的背景变量,那么解码器在该时间步的隐藏状态可以改写为:$\boldsymbol{s}_{t’} = g(\boldsymbol{y}_{t’-1}, \boldsymbol{c}_{t’}, \boldsymbol{s}_{t’-1})$。

这里的关键是如何计算背景变量 $\boldsymbol{c}_{t’}$ ​和如何利用它来更新隐藏状态 $\boldsymbol{s}_{t’}$​。下面将分别描述这两个关键点。

  • 计算背景变量

我们先描述第一个关键点,即计算背景变量。

img

图中描绘了注意力机制如何为解码器在时间步 2 计算背景变量。

首先,函数 $a$​​​ 根据解码器在时间步 1 的隐藏状态和编码器在各个时间步的隐藏状态计算 softmax 运算的输入。softmax 运算输出概率分布并对编码器各个时间步的隐藏状态做加权平均,从而得到背景变量。

具体来说,令编码器在时间步 $t$ 的隐藏状态为 $\boldsymbol{h}_t$,且总时间步数为 $T$。那么解码器在时间步 $t’$ ​的背景变量为所有编码器隐藏状态的加权平均:

其中给定 $t’$​ 时,权重 $\alpha_{t’ t}$ ​在 $t=1,\ldots,T$ ​的值是一个概率分布。为了得到概率分布,我们可以使用 softmax 运算:

现在,我们需要定义如何计算上式中 softmax 运算的输入 $e_{t’ t}$​​。由于 $e_{t’ t} $ ​​同时取决于解码器的时间步 $t’$​​ 和编码器的时间步 $t$​​,我们不妨以解码器在时间步 $t’-1$ ​​的隐藏状态 $\boldsymbol{s}_{t’ - 1}$ ​​与编码器在时间步 $t$​​ 的隐藏状态 $\boldsymbol{h}_t$​​ 为输入,并通过函数 $a$ ​​计算$e_{t’ t}$​​:

这里函数 $a$ ​有多种选择,如果两个输入向量长度相同,一个简单的选择是计算它们的内积 $a(\boldsymbol{s}, \boldsymbol{h})=\boldsymbol{s}^\top \boldsymbol{h}$​。而最早提出注意力机制的论文则将输入连结后通过含单隐藏层的多层感知机变换:

其中$\boldsymbol{v}$​、$\boldsymbol{W}_s$​、$\boldsymbol{W}_h$​都是可以学习的模型参数。

  • 矢量化计算 (Query, Key & Value)

广义上,注意力机制的输入包括 query 以及对应的 key 和 value,其中 value 是需要加权平均的一组项。在加权平均中,value 的权重来自 $a(query, key)$。

在上面的例子中,查询项为解码器的隐藏状态 $s_{t’-1}$,Key 和 Value 均为编码器的隐藏状态 $h_t$。

让我们考虑一个常见的简单情形,即编码器和解码器的隐藏单元个数均为 $h$,且函数$a(\boldsymbol{s}, \boldsymbol{h})=\boldsymbol{s}^\top \boldsymbol{h}$。

假设我们希望根据解码器单个隐藏状态 $\boldsymbol{s}_{t’ - 1} \in \mathbb{R}^{h}$ 和编码器所有隐藏状态 $\boldsymbol{h}_t \in \mathbb{R}^{h}, t = 1,\ldots,T$ 来计算背景向量$\boldsymbol{c}_{t’}\in \mathbb{R}^{h}$。

我们可以将查询项矩阵 $\boldsymbol{Q} \in \mathbb{R}^{1 \times h}$ 设为 $\boldsymbol{s}_{t’ - 1}^\top$,并令键项矩阵 $\boldsymbol{K} \in \mathbb{R}^{T \times h}$ 和值项矩阵 $\boldsymbol{V} \in \mathbb{R}^{T \times h}$ 相同且第 $t$ 行均为 $\boldsymbol{h}_t^\top$​。

此时,我们只需要通过矢量化计算 $\text{softmax}(\boldsymbol{Q}\boldsymbol{K}^\top)\boldsymbol{V}$​​ 即可算出转置后的背景向量 $\boldsymbol{c}_{t’}^\top$。

当查询项矩阵 $\boldsymbol{Q}$ 的行数为 $n$ 时,上式将得到 $n$ 行的输出矩阵。输出矩阵与查询项矩阵在相同行上一一对应。

  • 更新隐藏状态 (GRU)

以门控循环单元为例,在解码器中我们可以对 GRU 中门控循环单元的设计稍作修改,从而变换上一时间步 $t’-1$ 的输出 $\boldsymbol{y}_{t’-1}$、隐藏状态 $\boldsymbol{s}_{t’ - 1}$ 和当前时间步 $t’$ 的含注意力机制的背景变量 $\boldsymbol{c}_{t’}$。

解码器在时间步 $t’$ 的隐藏状态为:$\boldsymbol{s}_{t’} = \boldsymbol{z}_{t’} \odot \boldsymbol{s}_{t’-1} + (1 - \boldsymbol{z}_{t’}) \odot \tilde{\boldsymbol{s}}_{t’}$.​

其中的重置门、更新门和候选隐藏状态分别为

其中含下标的 $\boldsymbol{W}$ ​和 $\boldsymbol{b}$ ​分别为门控循环单元的权重参数和偏差参数。