从 RNN 到 Transformer
这篇博客是我在学习了李沐大神的《动手学深度学习(中文版)》的第八章循环神经网络,第九章现代循环神经网络,第十章注意力机制之后写就的,预期会结合自己对 RNN 和 Attention 的理解讨论神经网络是如何从 RNN 发展到 Transformer 的。

RNN 的出现
自回归模型
RNN 之前,我们学习了多层感知机和卷积神经网络,而这两种模型之所以有效,是因为它们所处理的数据都是独立同分布的。而现实中经常会遇到一些数据不是独立同分布的,而是存在时间或空间上的顺序,我们称之为序列数据(例如文章中的文字、视频的图像帧)。打乱序列数据的顺序会显著改变数据原有的信息,使其丧失原本的意义。为了能够对序列模型进行处理,我们希望能够建立一个模型,其能够捕捉到序列中含有的顺序信息。
这样的模型显然可以很好地完成时序预测的任务,即给定前 t-1 时刻的数据,预测 t 时刻的数据。
- 自回归模型
自回归模型是一个直接的例子,它直接把前 \(\tau\) 时刻的数据作为输入,直接预测下一个时刻的输出:
\[x_t \sim P(x_t|x_{t-1}, x_{t-2}, x_{t-3}, ..., x_{t-\tau}) \tag{1}\]- 隐变量自回归模型
另一种模型则更加巧妙,其通过保留一些对过去观测的总结 \(h\),同时更新预测和新的总结:
\[h_t = g(h_{t-1}, x_{t-1}) \\ \hat x_t = P(x_t | h_t)\]其大体的框架如下图所示:
这两种模型是如何生成训练数据的呢?一个经典方法是使用历史观测来预测下一个未来预测。由于时间会不断改变,一个常见的假设是虽然特定值 \(x_t\) 可能会改变,但是序列本身的动力学不会改变。这样的假设是合理的,因为新的动力学一定受新的数据影响,而我们不可能用目前所掌握的数据来预测新的动力学。统计学家称不变的动力学为静止的(stationary)。因此,整个序列的估计值都将通过下面的方式获得:
\[P(x_1,...,x_T) = \prod\limits_{t=1}^{T}P(x_t|x_{t-1},...,x_1) \tag{2}\]注意,如果是处理离散的对象,就需要转化为分类问题
马尔可夫模型
马尔可夫模型太常见了,这里就简单讲讲它的概念。
马尔可夫模型是符合如下假设的模型:t 时刻模型的状态仅仅取决于其前 \(\tau\) 个时刻的状态,与更前面的状态无关。特殊情况,当 \(\tau = 1\) 的时候,我们称之为一阶马尔可夫模型。
类似地,我们可以定义二阶、三阶马尔可夫模型
因果关系
往往时序模型是带有因果关系的,虽然有时我们的模型可以求出反向的条件概率分布,但这并不意味着其是有意义的。如果我们改变 \(x_t\),显然 t 时刻之前的值、分布是不受到影响的。
自回归模型代码
实验部分,我们使用了一个简单的两层的神经网络进行预测,代码请见官方教程。结果显示该模型在短期的预测上还是很准确的,但是一旦步数增加之后(从 16-step 开始),就开始出现较大偏差。
另外,如果直接使用预测的值作为输入来进行多步预测的话,会导致误差不断积累,预测的效果奇差无比。
内插法:在现有观测值之间进行估计 外推法:对超出已知观测范围进行预测
循环神经网络
上面我们讲的一个简单的自回归模型其实就是一个没有隐状态的神经网络,或者说其隐状态就是全连接层第一层的输出,其不是一个很好的表征。
所以我们下面考虑有隐状态的循环神经网络:
还是比较简单的,其原理就用下面的两个公式解释了:
\[H_t = \phi(X_t W_{xh} + H_{t-1} W_{hh} + b_h) \\ O_t = H_tW_{hq} + b_q\]对于为什么是这样的形式,或许是参考了状态空间模型。(挖坑ing
在评估模型时,我们使用 困惑度(Perplexity) 作为指标,其定义如下:
\[\text{exp}\big(-\frac{1}{n}\sum\limits_{t=1}^{n}\log P(x_t|x_{t-1},...,x_1)\big) \tag{3}\]在最好的情况下困惑度为1,最坏的情况下困惑度为正无穷大,基线是随机预测,困惑度为词表中唯一词元的数量。
- 代码部分的话,就讲一下
torch.nn.RNN
的用法吧:
定义一个循环神经网络层的代码为 rnn = torch.nn.RNN(input_size, hidden_size, num_layers)
其中各个参数的含义如下表所示:
参数名 | 说明 |
---|---|
input_size |
每个时间步输入向量的维度 |
hidden_size |
隐状态(hidden state)的维度 |
num_layers |
RNN 堆叠的层数(默认为 1) |
nonlinearity |
激活函数,默认 'tanh' ,可设为 'relu' |
batch_first |
若为 True,输入输出为 (batch, seq, feature),否则为 (seq, batch, feature) |
dropout |
多层时使用 dropout(只在层与层之间) |
bidirectional |
是否使用双向 RNN |
另外,在运行之前,RNN 需要初始化状态,一般情况下初始化全零的维度为 (num_layers * num_directions, batch_size, num_hiddens)
即可,如果想提升效果可以使用预热操作(warm-up),给定特定的输入 prefix,利用其输出的结果状态作为初始状态。
- 前向传播过程
如果 batch_first=True
,则:
输入:X
的维度是 (batch_size, seq_len, input_size)
输出有两个:output
:所有状态组成的张量,维度为 (batch_size, seq_len, hidden_size * num_directions)
;hn
:最后一个时间步的隐状态,维度为 (num_layers * num_directions, batch_size, hidden_size)
一般我们会选取 output[-1]
取出最后一个输出的状态,用于后续操作。
如果
batch_size=False
那么需要交换输入和输入的前两个维度
- 反向传播过程
梯度的公式就不推导了,根据模型的结构图应该很容易就能推导出来,值得注意的是我们需要递归的去计算梯度 \(\partial h_t / \partial w_h\) 的值,公式如下:
\[\frac{\partial h_t}{\partial w_h} = \frac{\partial f(x_t,h_{t-1},w_h)}{\partial w_h} + \sum\limits_{i=1}^{t-1}\bigg(\prod\limits_{j=i+1}^{t}\frac{\partial f(x_j,h_{j-1},w_h)}{\partial h_{j-1}}\bigg)\frac{\partial f(x_i,h_{i-1},w_h)}{\partial w_h} \tag{4}\]当 t 很大时,这个链会变得很长,难以计算,如果进行完全计算的话会导致梯度爆炸或梯度消失,为了解决这一问题,我们可以采取截断时间步的策略。
标准的 Backpropagation Through Time(BPTT)采用的固定步数截断梯度。
我们不对完整的时间序列做反向传播,而是:每训练几步,就只在这几步时间内计算和目标的损失,传播梯度,剪掉更早时间步的依赖(使用detach()方法),这样可以减小计算图的大小,防止长距离依赖造成的梯度问题。
随机截断就是在标准的 BPTT 上,采用随机步长进行截断的方法。
- Tips
截断 BPTT 会损失部分长期依赖信息,但换来更快训练、稳定性更高;对于很长序列(如文本生成、时间序列预测),是训练 RNN 的标准做法;对于 nn.LSTM 和 nn.GRU 也一样适用。
- 几种方法的比较
图 3 说明了当基于循环神经网络使用通过时间反向传播分析《时间机器》书中前几个字符的三种策略:
第一行采用随机截断,方法是将文本划分为不同长度的片断;
第二行采用常规截断,方法是将文本分解为相同长度的子序列。 这也是我们在循环神经网络实验中一直在做的;
第三行采用通过时间的完全反向传播,结果是产生了在计算上不可行的表达式。
遗憾的是,虽然随机截断在理论上具有吸引力, 但很可能是由于多种因素在实践中并不比常规截断更好。 首先,在对过去若干个时间步经过反向传播后, 观测结果足以捕获实际的依赖关系。 其次,增加的方差抵消了时间步数越多梯度越精确的事实。 第三,我们真正想要的是只有短范围交互的模型。 因此,模型需要的正是截断的通过时间反向传播方法所具备的轻度正则化效果。
简单来说,就是使用固定步长截断就够了,因为大多数模型只需要捕捉局部的信息就足矣,随机截断反而弄巧成拙了。
另外,由于状态在传递过程中始终和固定的矩阵 \(W_{hh}\) 相乘,所以在求梯度的时候会遇到矩阵的高次幂,而在这个幂中,小于1的特征值将会消失,大于1的特征值将会发散。这在数值上是不稳定的,表现形式为梯度消失或梯度爆炸。
RNN 的改进
长短期记忆网络(LSTM)
LSTM 是 RNN 的改进方案,其使用了多个门控网络来对长期的记忆进行选择。RNN 的问题是记忆在通过较长时间的传递后逐渐消失,即来自序列最前面的输入的影响会随着时间增长而减少;另一种情况是,RNN 默认认为上一时刻的影响最大,但实际中会遇到相对而言独立的输入,例如前一篇文章的末尾和下一篇文章的开头。LSTM 针对这一问题,提出了遗忘门、输入门、输出门三个门控网络,以及候选记忆元,使得网络能够对记忆进行较为灵活的调整。
图 4 描绘了 LSTM 中记忆和隐状态传递的逻辑,其可用下面的几个公式描述:
\[\begin{split}\begin{aligned} F_t=\sigma(H_{t-1}W_{hf}+X_{t}W_{xf}+b_f) \\ I_t=\sigma(H_{t-1}W_{hi}+X_{t}W_{xi}+b_i)\\ O_t=\sigma(H_{t-1}W_{ho}+X_{t}W_{xo}+b_o)\\ \tilde C_t = \tanh(H_{t-1}W_{hc}+X_{t}W_{xc}+b_c)\\ C_t = F_t \odot C_{t-1} + I_t \odot \tilde C_t\\ H_t = O_t \odot \tanh(C_t) \end{aligned}\end{split}\]和 RNN 一样,LSTM 也需要初始化状态和记忆。代码部分就不贴了,和 RNN 几乎一样,区别在于其输出的状态有两个。
门控循环单元(GRU)
GRU 可视为 LSTM 的精简版本,其只使用两个门控网络:重置门和更新门,其结构如下图所示:
其原理可以用下面的公式表示:
\[\begin{split}\begin{aligned} R_t=\sigma(H_{t-1}W_{hr}+X_{t}W_{xr}+b_r) \\ Z_t=\sigma(H_{t-1}W_{hz}+X_{t}W_{xz}+b_z) \\ \tilde H_t = \tanh((R_t \odot H_{t-1})W_{hh} + X_{t}W_{xh} + b_h) \\ H_t = Z_t \odot H_{t-1} + (1-Z_t) \odot \tilde H_t \end{aligned}\end{split}\]LSTM vs GRU
LSTM 和 GRU 有各自的优缺点,可从下表中看出。
对比项 | LSTM | GRU | 原因分析 |
---|---|---|---|
结构复杂度 | 高 | 低 | LSTM 有三个门(输入门、遗忘门、输出门)和一个细胞状态,而 GRU 只有两个门(重置门、更新门)并无独立细胞状态 |
参数数量 | 多 | 少 | GRU 门更少,因此参数也更少(减少约 25%) |
训练速度 | 慢 | 快 | GRU 参数更少,收敛速度快;对硬件资源的压力较小 |
捕捉长期依赖 | 强 | 中 | LSTM 中的 cell state 通过遗忘门有更强的长期记忆保留能力 |
泛化能力 | 相对更强 | 相对稍弱 | 在复杂任务(如语言建模、机器翻译)中,LSTM 有更细致的状态控制能力 |
调参难度 | 略高 | 略低 | LSTM 超参数多,训练和调优较复杂 |
在小数据集表现 | 有时较差 | 较好 | GRU 更简单,能在小数据集上更快学习,避免过拟合 |
序列到序列学习(seq2seq)
我们想要利用循环神经网络来解决机器翻译的问题。需要注意的是机器翻译是 Many-to-Many 的问题,其输入为不定长序列,输出也是不定长的。显然对于不定长的输入序列我们不好直接做处理,这时就需要采用编码器-解码器架构了。
所谓编码器-解码器架构就是先把不定长的输入映射到定长的隐状态,然后使用解码器变成不定长的输出序列。还是很好理解的,毕竟比较常见()
图 6 就展示了整个框架的工作流程,输入序列是英文,输出序列是法文,包括了标点和结束符。值得注意的是解码器的第一个输入是固定的,是一个起始符标志着句子的开始。图中的每一个方框就代表着一个循环神经网络单元,可以看到编码器最后一层的输出用于指导后续解码器每一个单元的输出,我们称之为上下文向量(context vector)。且编码器除了起始时刻之外的输入是其前一时刻的输出。(在训练阶段是上一个目标单词)
在输出的时候我们所采取的是贪心策略,但这样其实并不能保证结果是全局最优的。使用贪心策略会忽视一些高概率分支,为了改进这一点我们可以尝试束搜索(beam search)的方法,即每次搜索保留概率前 k 的序列。当 k 趋于无穷大的时候就成了完全遍历。
但是这个模型有个很显然的缺陷,那就是当输入足够长的时候,编码器输出的上下文向量其实并不能很好地指导后面每一个单词的翻译。因为上下文向量含有的信息是全局性的,但其实我们在翻译的时候只需要考虑单词附近的上下文就行了,我们需要给原始输入分配不同的注意力来对后续翻译进行更好的指导。
所以就引入了注意力机制。
Attention 机制
注意力机制来源于神经科学,我们设想机器拥有和人类一样的注意力,在庞大的信息世界中,其能够关注到目前最重要的信息指导行为。从这个角度去看,注意力机制的本质就是对信息进行加权,问题就在于我们怎么设计这个过程。
简单的想法就是相似性匹配,给定一个数据(查询),我希望能够在信息集中查找与其相似度最高的数据(键),根据他们之间的相似性,使用信息集中的数据来为给定数据进行赋值(值)。
举个简单的例子就能够很好地说明上面这一点。
插值是一个很通俗的例子,我们给定一系列数据点有两个坐标分别是 x,y,其是由某个函数所生成的,当然生成过程可以考虑噪声,也可以忽略。现在我希望估计这个函数在某个 x 坐标下的 y 值,该怎么实现呢?
按照上面的思路,我们要考虑的就是这个 x 附近有哪些点,根据 x 坐标之间的距离为 y 值进行加权得到估计即可。可以参见下面的公式。
\[f(x) = \sum_{i=1}^n \frac{K(x - x_i)}{\sum_{j=1}^n K(x - x_j)} y_i \tag{5}\]其中 \(K\) 是距离的度量,可以选择 L1,L2 距离,这里我们选用的是高斯核函数(Gaussian kernel),其定义如下:
\[K(u) = \frac{1}{\sqrt{2\pi}} \exp(-\frac{u^2}{2}) \tag{6}\]所以式 (5) 就可以写成如下的表达式:
\[\begin{split}\begin{aligned} f(x) &=\sum_{i=1}^n \alpha(x, x_i) y_i\\ &= \sum_{i=1}^n \frac{\exp\left(-\frac{1}{2}(x - x_i)^2\right)}{\sum_{j=1}^n \exp\left(-\frac{1}{2}(x - x_j)^2\right)} y_i \\&= \sum_{i=1}^n \mathrm{softmax}\left(-\frac{1}{2}(x - x_i)^2\right) y_i. \end{aligned}\end{split} \tag{7}\]我们把最后 softmax 得到的系数称之为注意力权重,如果我们把注意力权重进行热力图可视化的话,其看上去会是下面这样:
可以看到的确如我们所想的那样,离得越近的点的权重越大。上面的这个例子是 Nadaraya-Watson 核回归模型,其可以很好地帮助我们理解注意力的思想。但这样的模型是不含有参数的,我们无法对其进行学习,无法进行调整,所以下面我们介绍含参的注意力汇聚。
参数加在哪比较好呢?一种策略如下:
\[\begin{split}\begin{aligned}f(x) &= \sum_{i=1}^n \alpha(x, x_i) y_i \\&= \sum_{i=1}^n \frac{\exp\left(-\frac{1}{2}((x - x_i)w)^2\right)}{\sum_{j=1}^n \exp\left(-\frac{1}{2}((x - x_j)w)^2\right)} y_i \\&= \sum_{i=1}^n \mathrm{softmax}\left(-\frac{1}{2}((x - x_i)w)^2\right) y_i.\end{aligned}\end{split} \tag{8}\]在高斯核的指数部分乘上一个系数 w,这么做的好处其实是很容易理解的。如果 w 是个很小的值,那么我们就越关注更远的点,相当于增加注意力的范围;如果 w 是比较大的值,那么我们就更加关注距离更近的点,让注意力更加尖锐。
上图所示是 w 比较大的时候的注意力权重热力图,可见注意力更加尖锐,关注的范围很小,这样虽然在训练集上会有比较好的拟合效果,但不能泛化,说白了就是容易过拟合。
现在我们把问题变得更加一般化,也更加复杂一些。我们认为信息集是由一系列键值对(K-V-pairs)组成的,输入就是一个查询(Query),我希望计算这个查询和键之间的距离,来为查询赋值。事实上,我们可以认为Q和K属于同一个空间,根据两者在该空间的距离,计算Q在V空间中的值。
用于计算距离的函数,我们也称之为注意力评分函数,简称评分函数。
\[f(\mathbf{q}, (\mathbf{k}_1, \mathbf{v}_1), \ldots, (\mathbf{k}_m, \mathbf{v}_m)) = \sum_{i=1}^m \alpha(\mathbf{q}, \mathbf{k}_i) \mathbf{v}_i \in \mathbb{R}^v \tag{9}\] \[\alpha(\mathbf{q}, \mathbf{k}_i) = \mathrm{softmax}(a(\mathbf{q}, \mathbf{k}_i)) = \frac{\exp(a(\mathbf{q}, \mathbf{k}_i))}{\sum_{j=1}^m \exp(a(\mathbf{q}, \mathbf{k}_j))} \in \mathbb{R} \tag{10}\]上面的公式描述了注意力机制的过程,其中 \(\alpha\) 是注意力评分函数。
另外,在计算距离时,我们可以不用考虑所有的 K,这是合理的,因为对于不相关的信息我们不需要计算与其的注意力。在实际中我们通过掩蔽 softmax 来实现这样的功能,我们将不需要关注的值在 softmax 之前设置为一个极小的值,这样在经过 softmax 之后权重就接近于 0
实际中我们常常使用的两种注意力机制分别是加性注意力和缩放点积注意力,它们各自的公式如下:
- 加性注意力
给定查询 \(\mathbf{q} \in \mathbb{R}^q\) 和键 \(\mathbf{k} \in \mathbb{R}^k\),其中可学习的参数是 \(\mathbf W_q\in\mathbb R^{h\times q}\),\(\mathbf W_k\in\mathbb R^{h\times k}\) 和 \(\mathbf w_v\in\mathbb R^{h}\)
- 缩放点积注意力
在实践中,我们一般通过批量操作提高效率,同时增加训练稳定性,例如基于 n 个查询和 m 个键值对计算注意力,其中查询和键的长度为 d,值的长度为 v,查询 \(\mathbf Q\in\mathbb R^{n\times d}\),键 \(\mathbf K\in\mathbb R^{m\times d}\) 和 值 \(\mathbf V\in\mathbb R^{m\times v}\) 的缩放注意力是:
\[\mathrm{softmax}\left(\frac{\mathbf Q \mathbf K^\top }{\sqrt{d}}\right) \mathbf V \in \mathbb{R}^{n\times v} \tag{12}\]这个公式很眼熟,Transformer 使用的就是缩放点积注意力,它相比于加性注意力更加有效,因为点积运算本身就能反映向量之间的距离。之所以除以 \(\sqrt{d}\) 是为了保持方差。其他方面的区别可以从下面的表格中见出。
对比项 | 加性注意力(Additive Attention) | 缩放点积注意力(Scaled Dot-Product Attention) | |
---|---|---|---|
计算方式 | 使用一个前馈神经网络(如 MLP)对 query 和 key 的组合进行打分(非线性) | 使用 query 和 key 的点积并缩放后打分(线性) | |
效率(速度) | 计算开销较大,不能很好并行化 | 计算开销较小,可以充分利用矩阵乘法并行 | |
适合小模型 | 是,特别是在小型任务或低维表示中表现较好 | 不太适合,因为点积对低维时的分布不稳定 | |
适合大模型 | 不太适合,计算资源消耗大 | 是,尤其是在大模型(如 Transformer)中高效可扩展 | |
计算复杂度 | O(n × dh)(涉及非线性层) | O(n × d)(主要是矩阵乘法) | |
实现复杂度 | 较高,涉及多个线性变换与非线性激活 | 较低,只需线性投影和缩放点积 | |
主要应用场景 | 早期的 Seq2Seq 模型(如 Bahdanau Attention) | 现代模型如 Transformer、BERT、GPT 等 |
- Bahdanau 注意力
前面的 seq2seq 模型中,我们使用的是编码器-解码器架构,使用上下文向量来辅助输出,现在,为了能够增强上下文向量的表达能力,我们引入注意力机制,这种注意力被称为Bahdanau 注意力,下图描述了其架构。
其实现的操作其实就是把解码器隐状态 \(s_{t-1}\) 作为查询,将编码器隐状态 \(h_t\) 作为键和值进行加性注意力计算,得到新的上下文向量 \(c_{t'}\),公式如下:
\[\mathbf{c}_{t'} = \sum_{t=1}^T \alpha(\mathbf{s}_{t' - 1}, \mathbf{h}_t) \mathbf{h}_t \tag{13}\]其实就是加性注意力在序列翻译中的应用罢了。
- 多头注意力
所谓多头注意力,表面上看是将多个不同注意力的结果进行拼接,实际写代码的时候只要进行一些简单的维度变化就可以了。
另外评论区有人讨论说这是一个巨大的单头注意力,其实并非,每个注意力的Q和K都不一样,怎么能是一个单头注意力呢?代码部分其实还挺复杂的,主要是对数据的维度变化比较令人困扰。
详情就直接看官方文档吧!
个人认为多头注意力之所以有效,就是因为它通过把数据投影到不同的空间中计算距离,更加精确了。
- 自注意力
所谓自注意力就是指进行注意力运算的 Q,K,V 都具有相同的来源,自注意力其实是一种编码的方式,但效果很好,编码能力比之前的方法都强上不少。
另外需要补充一点的是注意力机制其实并不能提取出序列的顺序信息,为了弥补这一点,我们可以采用位置编码(Positional Encoding——PE)的方法,将位置信息加入到向量的表示中去,常见的位置编码的方法如下:
\[\begin{split}\begin{aligned} p_{i, 2j} &= \sin\left(\frac{i}{10000^{2j/d}}\right),\\p_{i, 2j+1} &= \cos\left(\frac{i}{10000^{2j/d}}\right).\end{aligned}\end{split} \tag{14}\]这样编码不仅可以保持相对位置信息,还可以保持绝对位置信息。
- 相对位置信息
这么看来,如果我们把所有向量某一维的值放在坐标系中的话,就能能够围成一个圆,在这个维度上,任意的两个向量都可以通过旋转得到。
- 绝对位置信息
同一个位置对应的编码始终不变(绝对位置),与输入内容无关。正余弦位置编码使用不同频率的三角函数,生成连续稠密的向量表示,从而在保持表达力的同时避免了类似 one-hot 编码中稀疏高维的问题,使得表示更紧凑、更适合神经网络处理。
另外位置编码通过使用三角函数在编码维度上降低频率。 由于输出是浮点数,因此此类连续表示比二进制表示法更节省空间。
Transformer
在前面的部分,我们通过动手写代码跑实验也能够发现注意力机制具有很强的编解码能力,此外,自注意力同时具有并行计算和最短的最大路径长度这两个优势,为了能够充分利用这一点,完全利用 Attention 机制的神经网络 Transformer 堂堂诞生!
尽管Transformer最初是应用于在文本数据上的序列到序列学习,但现在已经推广到各种现代的深度学习中,例如语言、视觉、语音和强化学习领域。
如图 11,Transformer 模型由编码器和解码器两大部分组成,而这两个部分又分别由许多小的 block 组成。
我们首先观察 Encoder 部分,每一个模块包含了两个子模块,分别是多头自注意力和逐位前馈网络,每个模块后进行残差连接和规范化操作。而 Decoder 部分则多了一个掩蔽多头注意力,这是考虑到顺序回归的性质,模型在 t 时刻只知道 t 之前的输入。另外解码器中的多头注意力采用的是编码器最后一层的 K & V,Q 使用的是解码器的,这和我们的认知也是相符的。
代码就不多讲了,可以见官方文档,这里只挑一些细节讲讲
- 为什么嵌入向量值需要缩放?
代码里,编码器前向传播过程有这么一行 X = self.pos_encoding(self.embedding(X) * math.sqrt(self.num_hiddens))
,可以看到它对嵌入向量进行了缩放,这样做的原因在于,当你从 nn.Embedding
得到嵌入时,其元素一般是小范围的随机值,例如均值 0、方差 1/d 的正态分布。所以它的 L2 范数大概是 \(\sqrt{d}\),这时,如果你不进行缩放,就会出现以下问题:
由于位置编码(PositionalEncoding)通常在 [-1, 1] 范围内波动。而原始嵌入的值波动很小(因为初始化很小),位置编码会压倒嵌入信息。模型早期训练阶段,注意力可能只看位置,不看内容。
- 在实现的时候需要手动添加位置编码和掩码机制
torch 自带的 torch.nn.Transformer 类中并不含有位置编码和掩码机制,这部分代码需要手动实现。
另外其前向传播的过程中,输入和输出的维度都是 (seq_len, batch_size, emb_size)
,和一般的网络不同(和循环神经网络类似)
解码器的掩码注意力实现比较复杂,当时看代码困扰了我半天,其训练和推理过程中需要把前面时间步的输入传递给下一次前向传播,这样才能正常做注意力。
目前就先写这么多吧,等什么时候有其他想写的再补充。
一些个人观点
为什么注意力机制是优秀的?我以为原因在于注意力机制的设计是符合自然规律的,在某个维度上接近的两个事物或数据,推测其在另一个维度接近是很自然的。更何况多头注意力机制的存在,让两个数据可以在更多的维度空间进行比较。
注意力机制本质上是一个编码的过程,其通过计算注意力权重来计算数据在值空间的编码。现在看来神经网络或者机器学习的终极目标就是寻找一个强大的编码器-解码器,它能够以尽可能少的计算量对数据进行高效编解码,使得信息的损失尽可能少,当然最理想的情况是没有损失。
Transformer 的缺陷就在于其推理阶段参数量和计算量过大了,每次加入新词后都需要重新计算注意力,时间复杂度是很高的。但是其高度可并行化的优点是非常利好训练的的。下一代模型还真不好说是什么样的,但是肯定不会突然蹦出来一个崭新的模型,一定是有多年的积累的。就算是 Transformer,其源头最早甚至能追溯到上个世纪的注意力机制。
量化这一块,目前有点想法但不多,或许可以从多头 Q,K,V 矩阵的相似性上考虑裁剪一些冗余的参数,或者对矩阵进行分块以降低参数?再谈吧。
Leave a comment