本文的学习路线:
📍 Attention
从生物学的角度引入到计算机视角,介绍了什么是Attention
▶️介绍Encoder-Decoder框架
(目前大部分Attention Model都是依附于该框架实现)▶️ 介绍了Attention模型中的基础概念查询、键和值
▶️ 通过讲解Nadaraya-Watson核回归模型
来了解常见的注意力汇聚模型 ▶️ 介绍了Attention Model中两个常用的注意力评分函数
▶️ 从基础的Attention引入到Self-Attention
和Multihead-Attention
📍 Transformer
从宏观的角度了解Transformer 框架
▶️ 然后分别研究Transformer的输入
、Encoder-Decoder
和输出
▶️ Transformer的训练过程
▶️ Transformer 的代码实现
🚩 参考
主要参考了李沐老师和李宏毅老师的机器学习课程,Jay Alammar的博客以及其他资料。
文章目录
- 注意力
- 注意力概述(Attention)
- Encoder-Decoder
- 查询、键和值
- 注意力汇聚: Nadaraya-Watson 核回归
- 注意力评分函数
- 加性注意力
- 缩放点积注意力
- 自注意力(Self-Attention)
- 自注意力的定义和计算
- 自注意力的应用
- Self-Attention 🆚 CNN 🆚 RNN
- 多头自注意力 (Multihead Attention)
- Transformer
- Transformer的整体结构
- Transformer的输入
- 单词Embedding
- 位置Encoding
- Transformer的Encoder-Decoder
- Encoder block
- Decoder block
- Transformer的输出
- Transformer的训练过程和损失函数
- 训练过程
- 损失函数
- Transformer的代码实现
- 基于位置的前馈神经网络
- 残差连接和层规范化
- 编码器
- 解码器
- 训练
- 参考
注意力
注意力概述(Attention)
📒注意力提示
首先,我们从生物学的角度理解注意力。
人一般基于非自主性提示
和自主性提示
有选择地引导注意力的焦点。
- 非自主性提示 (偏向于感官的输入)
想象一下,假如我们面前有五个物品: 一份报纸、一篇研究论文、一杯咖啡、一本笔记本和一本书。所有纸制品都是黑白印刷的,但咖啡杯是红色的。咖啡杯在这种视觉环境中是突出和显眼的, 不由自主地引起人们的注意。
- 自主性提示(偏向于意识的控制)
喝咖啡后,我们会变得兴奋并想读书, 所以转过头,重新聚焦眼睛,然后看看书。此时选择书是受到了认知和意识的控制。
Encoder-Decoder
目前大部分attention模型都是依附于Encoder-Decoder框架进行实现。 所以我们先来讲解下Encoder-Decoder框架。
Encoder-Decoder框架顾名思义也就是编码-解码框架,在NLP中Encoder-Decoder框架主要被用来处理序列-序列问题。也就是输入一个序列,生成一个序列的问题。这两个序列可以分别是任意长度。
具体到NLP中的任务比如:
文本摘要,输入一篇文章(序列数据),生成文章的摘要(序列数据)
文本翻译,输入一句或一篇英文(序列数据),生成翻译后的中文(序列数据)
问答系统,输入一个question(序列数据),生成一个answer(序列数据)
基于Encoder-Decoder框架具体使用什么模型实现,由大家自己决定~用的较多的应该就是
seq2seq模型
和Transformer
了。
📒Encoder-Decoder中的输入和输出
上面我们通过生物学的角度理解了注意力,其中输入是五个物品的视觉信号,输出是我们决定将眼睛注视在哪个物品上。
下面来看看如何在计算机中表示这种输入和输出。
- 输入
1)输入是一个向量
2)输入是一组向量
❔ 如何把现实中的语言、声音编码成一组向量呢?
1)把自然语言
编码成一组向量。
如下图,代表的词汇编码方式有两种:
左边表示的是独热编码(One-hot Encoding)
右侧表示的是单词嵌入(Word Embedding)
单词嵌入比独热编码更能反映词义相近单词之间的关系。
每个单词可以编码成一个向量,一个句子就是一组向量。
2) 把语音
编码成一组向量
取一个25ms的窗口,这个窗口中采400个样本点,组成一个向量。
每次将窗口向后滑动10ms取样,组成一组向量。
3) 把图
编码成一组向量
比如下图的社会关系图中,每个人的个人信息都可以编码成一个向量。很多人的信息就构成了一组向量。
📒输出
- 每一个向量对应一个输出
我们重点关注这种情况
- 整个序列只输出一个标签
- 模型自己决定输出序列的长度
📒Encoder-Decoder中的结构原理
上图就是Encoder-Decoder框架在NLP领域中抽象后的最简单的结构图。
- Encoder
Encoder:编码器,对于输入的序列<x1,x2,x3…xn>进行编码,使其转化为一个语义编码C,这个C中就储存了序列<x1,x2,x3…xn>的信息。
❔Encoder 是怎么编码的呢?
编码方式有很多种,在文本处理领域主要有RNN/LSTM/GRU/BiRNN/BiLSTM/BiGRU
,可以依照自己的喜好来选择编码方式
🌰我们以RNN为例来具体说明一下:
以上图为例,输入<x1,x2,x3,x4>,通过RNN生成隐藏层的状态值<h1,h2,h3,h4>,如何确定语义编码C呢?最简单的办法直接用最后时刻输出的ht作为C的状态值,这里也就是可以用h4直接作为语义编码C的值,也可以将所有时刻的隐藏层的值进行汇总,然后生成语义编码C的值,这里就是C=q(h1,h2,h3,h4),q是非线性激活函数。
得到了语义编码C之后,接下来就是要在Decoder中对语义编码C进行解码了。
- Decoder
Decoder:解码器,根据输入的语义编码C,然后将其解码成序列数据,解码方式也可以采用RNN/LSTM/GRU/BiRNN/BiLSTM/BiGRU
。
Decoder和Encoder的编码解码方式可以任意组合。
❔Decoder 是怎么解码的呢?
基于seq2seq
模型有两种解码方式:
1️⃣ 解码方法1:《Learning Phrase Representations using RNN Encoder–Decoder for Statistical Machine Translation》
该方法指出,因为语义编码C
包含了整个输入序列的信息,所以在解码的每一步都引入C
。文中Ecoder-Decoder均是使用RNN,在计算每一时刻的输出yt时,都应该输入语义编码C
,即
h
⟨
t
⟩
=
f
(
h
⟨
t
−
1
⟩
,
y
t
−
1
,
c
)
\mathbf{h}_{\langle t\rangle}=f\left(\mathbf{h}_{\langle t-1\rangle}, y_{t-1}, \mathbf{c}\right)
h⟨t⟩=f(h⟨t−1⟩,yt−1,c)
类似的,下一个符号的条件分布是:
P
(
y
t
∣
y
t
−
1
,
y
t
−
2
,
…
,
y
1
,
c
)
=
g
(
h
⟨
t
⟩
,
y
t
−
1
,
c
)
P\left(y_t \mid y_{t-1}, y_{t-2}, \ldots, y_1, \mathbf{c}\right)=g\left(\mathbf{h}_{\langle t\rangle}, y_{t-1}, \mathbf{c}\right)
P(yt∣yt−1,yt−2,…,y1,c)=g(h⟨t⟩,yt−1,c)
其中
h
t
h_t
ht为当前t时刻的隐藏层的值,
y
t
−
1
y_{t-1}
yt−1为上一时刻的预测输出,作为t时刻的输入,每一时刻的语义编码C是相同地。
2️⃣ 解码方法2:《Sequence to Sequence Learning with Neural Networks》
这个编码方式相对简单,只在Decoder的初始输入引入语义编码C,将语义编码C作为隐藏层状态值
h
0
h_0
h0的初始值,
P
(
y
t
∣
y
t
−
1
,
y
t
−
2
,
…
,
y
1
,
c
)
=
g
(
h
⟨
t
⟩
,
y
t
−
1
)
P\left(y_t \mid y_{t-1}, y_{t-2}, \ldots, y_1, \mathbf{c}\right)=g\left(\mathbf{h}_{\langle t\rangle}, y_{t-1}\mathbf{}\right)
P(yt∣yt−1,yt−2,…,y1,c)=g(h⟨t⟩,yt−1)
如上图,该模型读取一个输入句子“ABC”,并产生“WXYZ”作为输出句子。模型在输出句尾标记后停止进行预测。注意,LSTM读取反向输入句子,因为这样做会在数据中引入许多短期依赖关系
❔上述基于
seq2seq
模型的两种解码方式有什么缺点?
如果按照方法1解码:在生成目标句子的单词时,不论生成哪个单词,是y1,y2也好,还是y3也好,他们使用的语义编码C都是一样的。而语义编码C是由输入序列X的每个单词经过Encoder 编码产生的,这意味着不论是生成哪个单词,y1,y2还是y3,其实输入序列X中任意单词对生成某个目标单词yi来说影响力都是相同的。(其实如果Encoder是RNN的话,理论上越是后输入的单词影响越大,并非等权的,估计这也是为何Google提出Sequence to Sequence模型时发现把输入句子逆序输入做翻译效果会更好的小Trick的原因)
如果按照方法2解码:整个序列的信息压缩在了一个语义编码C中,用一个语义编码C来记录整个序列的信息,序列较短还行,如果序列是长序列,比如是一篇上万字的文章,我们要生成摘要,那么只是用一个语义编码C来表示整个序列的信息肯定会损失很多信息,而且序列一长,就可能出现梯度消失问题,这样将所有信息压缩在一个C里面显然就不合理。
既然基于
seq2seq
模型有两种解码方式都不太好(两种解码方式都只采用了一个语义编码C),而基于attention
模型的编码方式中采用了多个C
上图就是引入了Attention 机制的Encoder-Decoder框架。咱们一眼就能看出上图不再只有一个单一的语义编码C,而是有多个C1,C2,C3这样的编码。当我们在预测Y1时,可能Y1的注意力是放在C1上,那咱们就用C1作为语义编码,当预测Y2时,Y2的注意力集中在C2上,那咱们就用C2作为语义编码,以此类推,就模拟了人类的注意力机制。
🌰以机器翻译例子"Tom chase Jerry" - "汤姆追逐杰瑞"来说明注意力机制:
当我们在翻译"杰瑞"的时候,为了体现出输入序列中英文单词对于翻译当前中文单词不同的影响程度,比如给出类似下面一个概率分布值:
(Tom,0.3)(Chase,0.2)(Jerry,0.5)
每个英文单词的概率代表了翻译当前单词“杰瑞”时,注意力分配模型分配给不同英文单词的注意力大小。这对于正确翻译目标语单词肯定是有帮助的,因为引入了新的信息。同理,目标句子中的每个单词都应该学会其对应的源语句子中单词的注意力分配概率信息。这意味着在生成每个单词Yi的时候,原先都是相同的中间语义表示C会替换成根据当前生成单词而不断变化的Ci。理解AM模型的关键就是这里,即由固定的中间语义表示C换成了根据当前输出单词来调整成加入注意力模型的变化的Ci。
每个Ci 对应这不同源语句子单词的注意力分配概率,比如对于上面的英汉翻译来说,对应的信息可能如下:
C
汤姆
=
g
(
0.6
∗
f
2
(
"Tom"),
0.2
∗
f
2
(
Chase),
0.2
∗
f
2
("Jerry"))
C
追逐
=
g
(
0.2
∗
f
2
(
"Tom"),
0.7
∗
f
2
(
Chase),
0.1
∗
f
2
(
"Jerry")
)
C
杰瑞
=
g
(
0.3
∗
f
2
(
"Tom"),
0.2
∗
f
2
(Chase),
0.5
∗
f
2
(
"Jerry"))
\begin{aligned} & \mathrm{C}_{\text {汤姆 }}=\mathrm{g}(0.6 * \mathrm{f} 2(\text { "Tom"), } 0.2 * \mathrm{f} 2(\text { Chase), } 0.2 * \mathrm{f} 2 \text { ("Jerry")) } \\ & \mathrm{C}_{\text {追逐 }}=\mathrm{g}(0.2 * \mathrm{f} 2(\text { "Tom"), } 0.7 * \mathrm{f} 2(\text { Chase), } 0.1 * \mathrm{f} 2(\text { "Jerry") }) \\ & \mathrm{C}_{\text {杰瑞 }}=\mathrm{g}(0.3 * \mathrm{f} 2(\text { "Tom"), } 0.2 * \mathrm{f} 2 \text { (Chase), } 0.5 * \mathrm{f} 2(\text { "Jerry")) } \end{aligned}
C汤姆 =g(0.6∗f2( "Tom"), 0.2∗f2( Chase), 0.2∗f2 ("Jerry")) C追逐 =g(0.2∗f2( "Tom"), 0.7∗f2( Chase), 0.1∗f2( "Jerry") )C杰瑞 =g(0.3∗f2( "Tom"), 0.2∗f2 (Chase), 0.5∗f2( "Jerry"))
f2(“Tom”),f2(“Chase”),f2(“Jerry”)就是对应的隐藏层的值h(“Tom”),h(“Chase”),h(“Jerry”)。g函数就是加权求和。αi表示权值分布。因此Ci的公式就可以写成:
C
i
=
∑
j
=
1
n
α
i
j
h
j
C_i=\sum_{j=1}^n \alpha_{i j} h_j
Ci=j=1∑nαijhj
怎么知道attention模型所需要的输入句子单词注意力分配概率分布值 a i j a_{ij} aij呢? 我们可以通过下文介绍的
注意力评分函数
求得
查询、键和值
下面来看看如何通过自主性的与非自主性的注意力提示, 用神经网络来设计注意力机制的框架。
首先,考虑一个相对简单的状况, 即只使用非自主性提示。 要想将选择偏向于感官输入, 则可以简单地使用参数化的全连接层, 甚至是非参数化的最大汇聚层或平均汇聚层。
在注意力机制的背景下,自主性提示被称为查询(query)
。 给定任何查询,注意力机制通过注意力汇聚(attention pooling)
将选择引导至感官输入(sensory inputs,例如中间特征表示)
。 在注意力机制中,这些感官输入被称为值(value)
。 更通俗的解释,每个值都与一个键(key)
配对, 这可以想象为感官输入的非自主提示。
如上图: 注意力机制通过注意力汇聚(注意力的分配方法)将查询(自主性提示)和键(非自主性提示)结合在一起,实现对值(感官输入)的选择倾向。
注意力汇聚: Nadaraya-Watson 核回归
上图中的
注意力汇聚
是怎么实现的呢?
接下来,我们通过讲解Nadaraya-Watson核回归模型来了解常见的注意力汇聚模型
(平均汇聚、非参数注意力汇聚、带参数注意力汇聚
)。
Nadaraya-Watson核回归模型 是一个简单但完整的例子,可以用于演示具有注意力机制的机器学习。
简单起见, 考虑下面这个回归问题: 给定的成对的"输入-输出"数据集
{
(
x
1
,
y
1
)
,
…
,
(
x
n
,
y
n
)
}
\left\{\left(x_1, y_1\right), \ldots,\left(x_n, y_n\right)\right\}
{(x1,y1),…,(xn,yn)}, 如何学习
f
f
f来预测任意新输入
x
x
x的输出
y
^
=
f
(
x
)
\hat{y}=f(x)
y^=f(x) ?
根据下面的非线性函数生成一个人工数据集, 其中加入的噪声项为
ϵ
\epsilon
ϵ ,其中
ϵ
\epsilon
ϵ 服从均值为 0 和标准差为
0.5
0.5
0.5 的正态分布。:
y i = 2 sin ( x i ) + x i 0.8 + ϵ , y_i=2 \sin \left(x_i\right)+x_i^{0.8}+\epsilon, yi=2sin(xi)+xi0.8+ϵ,
📒平均汇聚
平均汇聚相当于对所有输入样本分配相同程度的注意力
我们先使用最简单的估计器来解决回归问题: 基于平均汇聚来计算所有训练样本输出值的平均值:
f ( x ) = 1 n ∑ i = 1 n y i f(x)=\frac{1}{n} \sum_{i=1}^n y_i f(x)=n1i=1∑nyi
如下图所示,这个估计器确实不够聪明: 真实函数 f
(“Truth”)和预测函数(“Pred”)相差很大。
📒非参数注意力汇聚
非参数注意力汇聚相当于对靠近x的样本分配更大程度的注意力。
显然,平均汇聚忽略了输入
x
i
x_i
xi。 于是提出了一个更好的想法: 根据输入的位置对输出
y
i
y_i
yi 进行加权:
f
(
x
)
=
∑
i
=
1
n
K
(
x
−
x
i
)
∑
j
=
1
n
K
(
x
−
x
j
)
y
i
,
f(x)=\sum_{i=1}^n \frac{K\left(x-x_i\right)}{\sum_{j=1}^n K\left(x-x_j\right)} y_i,
f(x)=i=1∑n∑j=1nK(x−xj)K(x−xi)yi,
其中
K
K
K是核 (kernel) 。公式所描述的估计器被称为 Nadaraya-Watson核回归 (Nadaraya-Watson kernel regression)。
这里我们不会深入讨论核函数的细节, 但受此启发, 我们可以从注意力机制框架的角度重写, 成为一个更加通用的注意力汇聚 (attention pooling) 公式:
f
(
x
)
=
∑
i
=
1
n
α
(
x
,
x
i
)
y
i
,
f(x)=\sum_{i=1}^n \alpha\left(x, x_i\right) y_i,
f(x)=i=1∑nα(x,xi)yi,
其中
x
x
x是查询,
(
x
i
,
y
i
)
\left(x_i, y_i\right)
(xi,yi)是键值对。注意力汇聚是
y
i
y_i
yi的加权平均。将查询
x
x
x和键
x
i
x_i
xi之间的关系建模为注意力权重 (attention weight)
α
(
x
,
x
i
)
\alpha\left(x, x_i\right)
α(x,xi), 这个权重将被分配给每一个对应值
y
i
y_i
yi 。对于任何查询, 模型在所有键值对注意力权重都是一个有效的概率分布:它们是非负的, 并且总和为 1 。
为了更好地理解注意力汇聚, 我们考虑一个高斯核 (Gaussian kernel), 其定义为:
K
(
u
)
=
1
2
π
exp
(
−
u
2
2
)
K(u)=\frac{1}{\sqrt{2 \pi}} \exp \left(-\frac{u^2}{2}\right)
K(u)=2π1exp(−2u2)
将高斯核代入可以得到:
f
(
x
)
=
∑
i
=
1
n
α
(
x
,
x
i
)
y
i
=
∑
i
=
1
n
exp
(
−
1
2
(
x
−
x
i
)
2
)
∑
j
=
1
n
exp
(
−
1
2
(
x
−
x
j
)
2
)
y
i
=
∑
i
=
1
n
softmax
(
−
1
2
(
x
−
x
i
)
2
)
y
i
.
\begin{aligned} f(x) & =\sum_{i=1}^n \alpha\left(x, x_i\right) y_i \\ & =\sum_{i=1}^n \frac{\exp \left(-\frac{1}{2}\left(x-x_i\right)^2\right)}{\sum_{j=1}^n \exp \left(-\frac{1}{2}\left(x-x_j\right)^2\right)} y_i \\ & =\sum_{i=1}^n \operatorname{softmax}\left(-\frac{1}{2}\left(x-x_i\right)^2\right) y_i . \end{aligned}
f(x)=i=1∑nα(x,xi)yi=i=1∑n∑j=1nexp(−21(x−xj)2)exp(−21(x−xi)2)yi=i=1∑nsoftmax(−21(x−xi)2)yi.
在上式中, 如果一个键
x
i
x_i
xi越是接近给定的查询
x
x
x, 那么分配给这个键对应值
y
i
y_i
yi的注意力权重就会越大, 也就“获得了 更多的注意力"。值得注意的是, Nadaraya-Watson核回归是一个非参数模型。因此,上式是非参数的注意力汇聚 (nonparametric attention pooling)模型。
接下来, 我们将基于这个非参数的注意力汇聚模型来绘制预测结果。你会发现新的模型预测 线是平滑的, 并且比平均汇聚的预测更接近真实。
现在,我们来观察注意力的权重。 这里测试数据的输入相当于查询,而训练数据的输入相当于键。 因为两个输入都是经过排序的,因此由观察可知“查询-键”对越接近, 注意力汇聚的注意力权重就越高。
📒带参数注意力汇聚
带参数注意力汇聚相当于在非参数的注意力机制上添加一个可学习的参数
非参数的Nadaraya-Watson核回归具有一致性(consistency)的优点: 如果有足够的数据,此模型会收敛到最优结果。 尽管如此,我们还是可以轻松地将可学习的参数集成到注意力汇聚中。
例如,在下面的查询
x
x
x和键
x
i
x_i
xi之间的距离乘以可学习参数
w
w
w:
f ( x ) = ∑ i = 1 n α ( x , x i ) y i = ∑ i = 1 n exp ( − 1 2 ( ( x − x i ) w ) 2 ) ∑ j = 1 n exp ( − 1 2 ( ( x − x j ) w ) 2 ) y i = ∑ i = 1 n softmax ( − 1 2 ( ( x − x i ) w ) 2 ) y i \begin{aligned}f(x) & =\sum_{i=1}^n \alpha\left(x, x_i\right) y_i \\& =\sum_{i=1}^n \frac{\exp \left(-\frac{1}{2}\left(\left(x-x_i\right) w\right)^2\right)}{\sum_{j=1}^n \exp \left(-\frac{1}{2}\left(\left(x-x_j\right) w\right)^2\right)} y_i \\& =\sum_{i=1}^n \operatorname{softmax}\left(-\frac{1}{2}\left(\left(x-x_i\right) w\right)^2\right) y_i\end{aligned} f(x)=i=1∑nα(x,xi)yi=i=1∑n∑j=1nexp(−21((x−xj)w)2)exp(−21((x−xi)w)2)yi=i=1∑nsoftmax(−21((x−xi)w)2)yi
我们将通过训练这个模型来学习注意力汇聚的参数。训练完带参数的注意力汇聚模型后可以发现: 在尝试拟合带噪声的训练数据时, 预测结果绘制的线不如之前非参数模型的平滑。
为什么新的模型更不平滑了呢? 下面看一下输出结果的绘制图: 与非参数的注意力汇聚模型相比, 带参数的模型加入可学习的参数后, 曲线在注意力权重较大的区域变得更不平滑。
❓ 为什么要在机器学习中引入注意力机制呢?
在全连接层,FC只能考虑相邻的几个数据,但是无法考虑到整个序列。
注意力机制(self-attention)可以考虑到整个序列的信息。因此,输出的向量带有全局的上下文信息。
例如,下图中的
b
1
b^1
b1 是综合考虑了整个输入序列(
a
1
,
a
2
,
a
3
,
a
4
a^1,a^2,a^3,a^4
a1,a2,a3,a4)得到的。
但是对于
a
1
,
a
2
,
a
3
,
a
4
a^1,a^2,a^3,a^4
a1,a2,a3,a4的关注程度是不一样的(也可以理解为相关性
α
\alpha
α),我们后续再细讲如何分配注意力。
注意力评分函数
接下来,我们讲解如何通过注意力评分函数来分配注意力。
我们使用高斯核来对查询(query)和键(key)之间的关系建模。 我们可以将高斯核指数部分视为注意力评分函数(attention scoring function), 简称评分函数(scoring function)
, 然后把这个函数的输出结果输入到softmax函数
中进行运算。 通过上述步骤,我们将得到与键对应的值的概率分布(即注意力权重)。 最后,注意力汇聚的输出就是基于这些注意力权重的值的加权和
。
下图说明了如何将注意力汇聚的输出计算成为值的加权和, 其中a表示注意力评分函数。 由于注意力权重是概率分布, 因此加权和其本质上是加权平均值。
用数学语言描述, 假设有一个查询 q ∈ R q \mathbf{q} \in \mathbb{R}^q q∈Rq 和 m m m个"键一值"对 ( k 1 , v 1 ) , … , ( k m , v m ) \left(\mathbf{k}_1, \mathbf{v}_1\right), \ldots,\left(\mathbf{k}_m, \mathbf{v}_m\right) (k1,v1),…,(km,vm), 其中 k i ∈ R k , v i ∈ R v \mathbf{k}_i \in \mathbb{R}^k, \mathbf{v}_i \in \mathbb{R}^v ki∈Rk,vi∈Rv。注意 力汇聚函数 f f f就被表示成值的加权和:
f ( q , ( k 1 , v 1 ) , … , ( k m , v m ) ) = ∑ i = 1 m α ( q , k i ) v i ∈ R v , f\left(\mathbf{q},\left(\mathbf{k}_1, \mathbf{v}_1\right), \ldots,\left(\mathbf{k}_m, \mathbf{v}m\right)\right)=\sum_{i=1}^m \alpha\left(\mathbf{q}, \mathbf{k}_i\right) \mathbf{v}_i \in \mathbb{R}^v, f(q,(k1,v1),…,(km,vm))=i=1∑mα(q,ki)vi∈Rv,
其中查询 q \mathbf{q} q和键 k i \mathbf{k}_i ki的注意力权重(标量)是通过注意力评分函数 a a a将两个向量映射成标量, 再经过softmax运算得到的:
α ( q , k i ) = softmax ( a ( q , k i ) ) = exp ( a ( q , k i ) ) ∑ j = 1 m exp ( a ( q , k j ) ) ∈ R . \alpha\left(\mathbf{q}, \mathbf{k}_i\right)=\operatorname{softmax}\left(a\left(\mathbf{q}, \mathbf{k}_i\right)\right)=\frac{\exp \left(a\left(\mathbf{q}, \mathbf{k}i\right)\right)}{\sum{j=1}^m \exp \left(a\left(\mathbf{q}, \mathbf{k}_j\right)\right)} \in \mathbb{R} . α(q,ki)=softmax(a(q,ki))=∑j=1mexp(a(q,kj))exp(a(q,ki))∈R.
正如我们所看到的,选择不同的注意力评分函数a会导致不同的注意力汇聚操作。 在本节中,我们将介绍两个流行的评分函数(加性注意力、缩放点积注意力)
,稍后将用他们来实现更复杂的注意力机制
。
📒掩蔽softmax操作
掩蔽softmax操作, 是为实现下文的评分函数做铺垫。
正如上面提到的,softmax操作用于输出一个概率分布作为注意力权重。 在某些情况下,并非所有的值都应该被纳入到注意力汇聚中。 例如,为了高效处理小批量数据集, 某些文本序列被填充了没有意义的特殊词元。 为了仅将有意义的词元作为值来获取注意力汇聚, 我们可以指定一个有效序列长度(即词元的个数), 以便在计算softmax时过滤掉超出指定范围的位置。 通过这种方式,我们可以在下面的masked_softmax
函数中 实现这样的掩蔽softmax操作(masked softmax operation), 其中任何超出有效长度的位置都被掩蔽并置为0。
#@save
def masked_softmax(X, valid_lens):
"""通过在最后一个轴上掩蔽元素来执行softmax操作"""
# X:3D张量,valid_lens:1D或2D张量
if valid_lens is None:
return nn.functional.softmax(X, dim=-1)
else:
shape = X.shape
if valid_lens.dim() == 1:
valid_lens = torch.repeat_interleave(valid_lens, shape[1])
else:
valid_lens = valid_lens.reshape(-1)
# 最后一轴上被掩蔽的元素使用一个非常大的负值替换,从而其softmax输出为0
X = d2l.sequence_mask(X.reshape(-1, shape[-1]), valid_lens,
value=-1e6)
return nn.functional.softmax(X.reshape(shape), dim=-1)
为了演示此函数是如何工作的, 考虑由两个2×4矩阵表示的样本, 这两个样本的有效长度分别为2和3。 经过掩蔽softmax操作,超出有效长度的值都被掩蔽为0。
masked_softmax(torch.rand(2, 2, 4), torch.tensor([2, 3]))
tensor([[[0.5423, 0.4577, 0.0000, 0.0000],
[0.6133, 0.3867, 0.0000, 0.0000]],
[[0.3324, 0.2348, 0.4329, 0.0000],
[0.2444, 0.3943, 0.3613, 0.0000]]])
同样,我们也可以使用二维张量,为矩阵样本中的每一行指定有效长度。
masked_softmax(torch.rand(2, 2, 4), torch.tensor([[1, 3], [2, 4]]))
tensor([[[1.0000, 0.0000, 0.0000, 0.0000],
[0.4142, 0.3582, 0.2275, 0.0000]],
[[0.5565, 0.4435, 0.0000, 0.0000],
[0.3305, 0.2070, 0.2827, 0.1798]]])
加性注意力
首先,我们先来了解下第一种注意力评分函数:加性注意力
📒加性注意力(Addtive) 的数学定义
一般来说, 当查询和键是不同长度的矢量时, 我们可以使用加性注意力作为评分函数。给定查询
q
∈
R
q
\mathbf{q} \in \mathbb{R}^q
q∈Rq和 键
k
∈
R
k
\mathbf{k} \in \mathbb{R}^k
k∈Rk, 加性注意力 (additive attention) 的评分函数为 :
a ( q , k ) = w v ⊤ tanh ( W q q + W k k ) ∈ R a(\mathbf{q}, \mathbf{k})=\mathbf{w}_v^{\top} \tanh \left(\mathbf{W}_q \mathbf{q}+\mathbf{W}_k \mathbf{k}\right) \in \mathbb{R} a(q,k)=wv⊤tanh(Wqq+Wkk)∈R
其中可学习的参数是 W q ∈ R h × q 、 W k ∈ R h × k \mathbf{W}_q \in \mathbb{R}^{h \times q} 、 \mathbf{W}_k \in \mathbb{R}^{h \times k} Wq∈Rh×q、Wk∈Rh×k和 w v ∈ R h \mathbf{w}_v \in \mathbb{R}^h wv∈Rh 。将查询和键连结起来后输入到一个多 层感知机 (MLP) 中, 感知机包含一个隐藏层, 其隐藏单元数是一个超参数 h h h。通过使用 tanh \tanh tanh作为激活函数, 并且禁用偏置项。
📒加性注意力(Addtive) 的实现
#@save
class AdditiveAttention(nn.Module):
"""加性注意力"""
def __init__(self, key_size, query_size, num_hiddens, dropout, **kwargs):
super(AdditiveAttention, self).__init__(**kwargs)
self.W_k = nn.Linear(key_size, num_hiddens, bias=False)
self.W_q = nn.Linear(query_size, num_hiddens, bias=False)
self.w_v = nn.Linear(num_hiddens, 1, bias=False)
self.dropout = nn.Dropout(dropout)
def forward(self, queries, keys, values, valid_lens):
queries, keys = self.W_q(queries), self.W_k(keys)
# 在维度扩展后,
# queries的形状:(batch_size,查询的个数,1,num_hidden)
# key的形状:(batch_size,1,“键-值”对的个数,num_hiddens)
# 使用广播方式进行求和
features = queries.unsqueeze(2) + keys.unsqueeze(1)
features = torch.tanh(features)
# self.w_v仅有一个输出,因此从形状中移除最后那个维度。
# scores的形状:(batch_size,查询的个数,“键-值”对的个数)
scores = self.w_v(features).squeeze(-1)
self.attention_weights = masked_softmax(scores, valid_lens)
# values的形状:(batch_size,“键-值”对的个数,值的维度)
return torch.bmm(self.dropout(self.attention_weights), values)
我们用一个小例子来演示上面的AdditiveAttention
类, 其中查询、键和值的形状为(批量大小,步数或词元序列长度,特征大小), 实际输出为(2,1,20)、(2,10,2)和(2,10,4)。 注意力汇聚输出的形状为(批量大小,查询的步数,值的维度)。
queries, keys = torch.normal(0, 1, (2, 1, 20)), torch.ones((2, 10, 2))
# values的小批量,两个值矩阵是相同的
values = torch.arange(40, dtype=torch.float32).reshape(1, 10, 4).repeat(
2, 1, 1)
valid_lens = torch.tensor([2, 6])
attention = AdditiveAttention(key_size=2, query_size=20, num_hiddens=8,
dropout=0.1)
attention.eval()
attention(queries, keys, values, valid_lens)
tensor([[[ 2.0000, 3.0000, 4.0000, 5.0000]],
[[10.0000, 11.0000, 12.0000, 13.0000]]], grad_fn=<BmmBackward0>)
尽管加性注意力包含了可学习的参数,但由于本例子中每个键都是相同的, 所以注意力权重是均匀的,由指定的有效长度决定。
d2l.show_heatmaps(attention.attention_weights.reshape((1, 1, 2, 10)),
xlabel='Keys', ylabel='Queries')
缩放点积注意力
然后我们再来看看第二种注意力评分函数:缩放点积注意力。(在self-attention中使用的就是缩放点积注意力)
📒缩放点积注意力(Dot-product) 的数学定义
使用点积可以得到计算效率更高的评分函数, 但是点积操作要求查询和键具有相同的长度 d d d 假设查询和键的所有元素 都是独立的随机变量, 并且都满足零均值和单位方差, 那么两个向量的点积的均值为 0 , 方差为 d d d 。为确保无论向量长 度如何, 点积的方差在不考虑向量长度的情况下仍然是 1 , 我们将点积除以 d \sqrt{d} d, 则缩放点积注意力 (scaled dotproduct attention)评分函数为:
a ( q , k ) = q ⊤ k / d a(\mathbf{q}, \mathbf{k})=\mathbf{q}^{\top} \mathbf{k} / \sqrt{d} a(q,k)=q⊤k/d
在实践中, 我们通常从小批量的角度来考虑提高效率, 例如基于 n n n个查询和 m m m个键一值对计算注意力, 其中查询和键的
softmax ( Q K ⊤ d ) V ∈ R n × v . \operatorname{softmax}\left(\frac{\mathbf{Q } \mathbf{K}^{\top}}{\sqrt{d}}\right) \mathbf{V} \in \mathbb{R}^{n \times v} . softmax(dQK⊤)V∈Rn×v.
📒图解缩放点积的实现过程
首先我们以单个元素的计算为例 (下述的图省略了除以根号d的操作)
1)计算注意力得分(Attention score)
如下图,以计算
a
1
,
2
a_{1,2}
a1,2
a
1
,
3
a_{1,3}
a1,3
a
1
,
4
a_{1,4}
a1,4 为例。
先将
a
1
a^1
a1和
W
q
W^q
Wq 相乘,得到
q
1
q^1
q1。
然后分别将
a
2
a^2
a2
a
3
a^3
a3
a
4
a^4
a4 和
W
k
W^k
Wk 相乘,得到
k
2
k^2
k2
k
3
k^3
k3
k
4
k^4
k4
接着,分别将
q
1
q^1
q1和
k
2
k^2
k2
k
3
k^3
k3
k
4
k^4
k4进行点积运算得到
a
1
,
2
a_{1,2}
a1,2
a
1
,
3
a_{1,3}
a1,3
a
1
,
4
a_{1,4}
a1,4
2)通过Soft-max进行归一化
通过下式将注意力得分进行soft-max
操作。
α
1
,
i
′
=
exp
(
α
1
,
i
)
/
∑
j
exp
(
α
1
,
j
)
\alpha_{1, i}^{\prime}=\exp \left(\alpha_{1, i}\right) / \sum_j \exp \left(\alpha_{1, j}\right)
α1,i′=exp(α1,i)/j∑exp(α1,j)
3) 加权求和
首先,分别将
a
1
a^1
a1
a
2
a^2
a2
a
3
a^3
a3
a
4
a^4
a4 和
W
v
W^v
Wv 相乘,得到
v
1
v^1
v1
v
2
v^2
v2
v
3
v^3
v3
v
4
v^4
v4
然后 按照下式求得
b
1
b^1
b1
b
1
=
∑
i
α
1
,
i
′
v
i
b^{\mathbf{1}}=\sum_i \alpha_{1, i}^{\prime} \boldsymbol{v}^i
b1=i∑α1,i′vi
同理,通过下式计算得到
b
2
b^2
b2
b
2
=
∑
i
α
2
,
i
′
v
i
\boldsymbol{b}^2=\sum_i \alpha_{2, i}^{\prime} \boldsymbol{v}^{\boldsymbol{i}}
b2=i∑α2,i′vi
依次类推,我们可以计算得到
b
1
b^1
b1,
b
2
b^2
b2 ,
b
3
b^3
b3,
b
4
b^4
b4 。并且计算过程可以并行
执行。
然后,我们尝试将上述的计算过程表述成矩阵运算。
a) 首先,我们来观察下
q
i
q^i
qi
k
i
k^i
ki
v
i
v^i
vi 的计算过程。
b) 然后,再来观察下注意力得分
a
1
,
i
a_{1,i}
a1,i的计算过程
a
2
,
i
a_{2,i}
a2,i 同理
c) 然后,我们可以得到
A
′
A'
A′ 的矩阵表达形式。
A
=
K
T
Q
A
′
=
s
o
f
t
m
a
x
(
A
)
A=K^{T}Q \\ A'=softmax(A)
A=KTQA′=softmax(A)
d) 最终得到输出张量
O
O
O
O
=
V
A
′
O=VA'
O=VA′
e)总结
- 当输入的数据是 I I I, 将 I I I 分别和可学习的参数 W q W^q Wq, W k W^k Wk, W v W^v Wv 相乘,得到 Q , K , V Q,K,V Q,K,V
-
A
=
K
T
Q
A=K^{T}Q
A=KTQ 然后对A进行softmax操作,得到
注意力矩阵(Attention Matrix)
A ′ A' A′ - 最终得到输出张量
O
=
V
A
′
O=VA'
O=VA′
📒缩放点积注意力的代码实现
在下面的缩放点积注意力的实现中, 我们使用了暂退法进行模型正则化。
#@save
class DotProductAttention(nn.Module):
"""缩放点积注意力"""
def __init__(self, dropout, **kwargs):
super(DotProductAttention, self).__init__(**kwargs)
self.dropout = nn.Dropout(dropout)
# queries的形状:(batch_size,查询的个数,d)
# keys的形状:(batch_size,“键-值”对的个数,d)
# values的形状:(batch_size,“键-值”对的个数,值的维度)
# valid_lens的形状:(batch_size,)或者(batch_size,查询的个数)
def forward(self, queries, keys, values, valid_lens=None):
d = queries.shape[-1]
# 设置transpose_b=True为了交换keys的最后两个维度
scores = torch.bmm(queries, keys.transpose(1,2)) / math.sqrt(d)
self.attention_weights = masked_softmax(scores, valid_lens)
return torch.bmm(self.dropout(self.attention_weights), values)
为了演示上述的DotProductAttention
类, 我们使用与先前加性注意力例子中相同的键、值和有效长度。 对于点积操作,我们令查询的特征维度与键的特征维度大小相同。
queries = torch.normal(0, 1, (2, 1, 2))
attention = DotProductAttention(dropout=0.5)
attention.eval()
attention(queries, keys, values, valid_lens)
tensor([[[ 2.0000, 3.0000, 4.0000, 5.0000]],
[[10.0000, 11.0000, 12.0000, 13.0000]]])
与加性注意力演示相同,由于键包含的是相同的元素, 而这些元素无法通过任何查询进行区分,因此获得了均匀的注意力权重。
d2l.show_heatmaps(attention.attention_weights.reshape((1, 1, 2, 10)),
xlabel='Keys', ylabel='Queries')
自注意力(Self-Attention)
自注意力的定义和计算
📒Attention和Self-Attention 的区别
首先我们先来看看Attention的优缺点。
- Attention 的优点:
1.速度快。Attention机制不再依赖于RNN,解决了RNN不能并行计算的问题。这里需要说明一下,基于Attention机制的seq2seq模型,因为是有监督的训练,所以咱们在训练的时候,在decoder阶段并不是说预测出了一个词,然后再把这个词作为下一个输入,因为有监督训练,咱们已经有了target的数据,所以是可以并行输入的,可以并行计算decoder的每一个输出,但是再做预测的时候,是没有target数据地,这个时候就需要基于上一个时间节点的预测值来当做下一个时间节点decoder的输入。所以节省的是训练的时间。
2.效果好。效果好主要就是因为注意力机制,能够获取到局部的重要信息,能够抓住重点。 - Attention 的缺点
1.只能在Decoder阶段实现并行运算,Encoder部分依旧采用的是RNN,LSTM这些按照顺序编码的模型,Encoder部分还是无法实现并行运算,不够完美。
2.就是因为Encoder部分目前仍旧依赖于RNN,所以对于中长距离之间,两个词相互之间的关系没有办法很好的获取。
Self-Attention 是在Attention的基础上,针对上述的两个缺点进行改进得到的。
- Self-Attention和Attention的区别
在一般任务的Encoder-Decoder框架中,输入Source
和输出Target
内容是不一样的。比如对于英-中机器翻译来说,Source是英文句子,Target是对应的翻译出的中文句子,Attention机制发生在Target的元素和Source中的所有元素之间
。
Self Attention,指的是Source内部元素之间或者Target内部元素之间发生的Attention机制
,也可以理解为Target=Source这种特殊情况下的注意力计算机制
下面我们讲解一个中英翻译的例子来理解self-attention
例如,句子”The animal didn't cross the street because it was too tired”
中的it
代指的是什么?即寻找这句话中和it
最相关的单词。
通过self-attention我们发现it
在这句话中与之最相关的是The animal
可以这么通俗的理解:
寻找英文句子对应的中文翻译,这个是Attention (只能在解码阶段使用,而不能够在编码阶段使用)
寻找英文句子中单词与单词之间的关系,这个是Self-Attention (在编码阶段就可以使用,可以寻找到输入句子间的内部关联)
- Self-Attention的好处
1)如此引入Self Attention后会更容易捕获句子中长距离的相互依赖的特征,因为如果是RNN或者LSTM,需要依次序序列计算,对于远距离的相互依赖的特征,要经过若干时间步步骤的信息累积才能将两者联系起来,而距离越远,有效捕获的可能性越小。但是Self Attention在计算过程中会直接将句子中任意两个单词的联系通过一个计算步骤直接联系起来,所以远距离依赖特征之间的距离被极大缩短,有利于有效地利用这些特征。
2)Self Attention对于增加计算的并行性也有直接帮助作用
Self Attention正好弥补了attention机制的两个缺点,因此Self Attention逐渐被广泛使用。
分步自注意力机制的计算过程:
Transformer 论文中采用的
缩放点积注意力
。作者的理由是:点积注意力计算起来更快更简单。
1)计算query、key、value向量
假如输入序列是"Thinking Machines",x1,x2就是对应地"Thinking"和"Machines"添加过位置编码之后的词向量,然后词向量通过三个权值矩阵
W
Q
,
W
K
,
W
V
W^Q,W^K,W^V
WQ,WK,WV
转变成为计算Attention值所需的Query,Keys,Values向量。
2)计算注意力得分。
假设我们现在在计算输入中第一个单词Thinking的自注意力。我们需要使用自注意力给输入句子中的每个单词打分,这个分数决定当我们编码某个位置的单词的时候,应该对其他位置上的单词给予多少关注度。
这个得分是query和key的点乘积得出来的。
例如,我们要算第一个位置的注意力得分的时候就要将第一个单词的query和其他的key依次相乘,在这里就是
q
1
k
1
q_1 k_1
q1k1和
q
1
k
2
q_1 k_2
q1k2
3) 将计算获得的注意力分数除以8。
为什么选8?是因为key向量的维度
d
k
d_k
dk是64,取其平方根,这样让梯度计算的时候更稳定。默认是这么设置的,当然也可以用其他值。
4)softmax归一化
然后通过softmax计算,将每个单词之间的得分向量转换成[0,1]之间的概率分布,同时更加凸显单词之间的关系。
5)将每个value向量乘以注意力分数。
这是为了留下我们想要关注的单词的value,并把其他不相关的单词丢掉。
6)将上一步的结果相加,输出本位置的注意力结果。
📒将上述过程用矩阵计算:
计算Query, Key, Value矩阵。直接把输入的向量打包成一个矩阵
X
X
X, 再把它乘以训练好的
W
Q
W^Q
WQ、
W
K
W^K
WK、
W
V
W^V
WV。
实际使用中,每一个样本,也就是每一条序列数据都是以矩阵的形式输入地,故可以看到下图中,X矩阵是由"Tinking"和"Machines"词向量组成的矩阵,然后跟过变换得到Q,K,V。假设词向量是512维,X矩阵的维度是(2,512)。 W Q , W K , W V W^Q,W^K,W^V WQ,WK,WV均是(512,64)维,得到的Query,Keys,Values就都是(2,64)维。
如上图,X矩阵的每一行都代表输入句子中的一个单词。我们看一下维度的差异:原文中嵌入矩阵的长度为 512 , q 、 k 、 v 矩阵的长度为 64 ;在这里我们分别用 4 个格子表示和3个格子表示。
因为我们现在用矩阵处理,所以可以直接将之前的第二步到第六步压缩到一个公式中一步到位获得最终的注意力结果Z
首先计算注意力机制得分 ,score是一个2x2的矩阵。
score
=
Q
⋅
K
T
\text { score }=\mathrm{Q} \cdot \mathrm{K}^{\mathrm{T}}
score =Q⋅KT
然后进行归一化
score
=
score
/
d
k
\text { score }=\text { score } / \sqrt{\mathrm{d}_{\mathrm{k}}}
score = score /dk
接着,经过softmax后,score转换成一个值分布在[0,1]之间的
α
α
α概率分布矩阵,
α
α
α大小为(2,2) 。
α
=
s
o
f
t
m
a
x
(
score
)
\alpha = softmax(\text{score})
α=softmax(score)
最后,根据每个单词之间的概率分布,然后乘上对应的Values值,α与V进行点积
Z
=
α
⋅
V
Z=\alpha \cdot V
Z=α⋅V
V的为维度是(2,64),(2,2)x(2,64)最后得到的Z是(2,64)维的矩阵
整体的计算图如下:
自注意力的应用
🔨自然语言处理
Self-Attention最广泛的应用是在自然语言处理(Natural Langue Processing ,NLP)
- Transformer《Attention Is All You Need》
- BERT 《BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding》
🔨语音处理
对语音信号进行采样,得到一组向量。
如果输入的序列长度为L, 则Attention Matrix如下图所示:
- Truncated Self-Attention 《Transformer-Transducer: End-to-End Speech Recognition with Self-Attention》
以往的attention是注意在整个sequence上面,而truncated self-attention只考虑部分sequence,因为语音的sequence非常长。具体多长是一个需要调整的参数,也就是说只注意一个窗口内的元素,至于这个窗口是多大是一个待调整的参数。
🔨图像处理
一张图片也可以看做是一组向量
- Self-Attention GAN 《Self-Attention Generative Adversarial Networks》
- Detection Transformer (DETR) 《End-to-End Object Detection with Transformers》
🔨更多
- 《Long Range Arena: A Benchmark for Efficient Transformers》比较了不同注意力模型的性能。
- 《Efficient Transformers: A Survey》 对多种Transformer的模型进行了综述。
Self-Attention 🆚 CNN 🆚 RNN
下面我们来看一下Self-Attention 和 卷积神经网络(CNN)、循环神经网络(RNN)之间的区别和联系。
Self-attention 🆚 CNN
CNN只能注意到recptive filed
中的内容,可以把CNN看做一个简化的Self-attention。
也可以把Self-attention看做是具有learnable receptive field 的CNN
.
《On the Relationship between Self-Attention and Convolutional Layers》 介绍了Self-Attention和CNN之间的关系
《An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale》 表明:在少量数据的情况下,CNN表现的更好。但是在大量的数据下,Self-Attention的表现更出色。
Self-attention 🆚 RNN
1)对于RNN而言,在序列开始的的元素,随着序列的增加,很难影响到靠后的元素;而对于Self-Attention而言,考虑了全局的注意力信息,序列中靠前的元素依然可能对靠后的元素产生较大的影响。
2)对于RNN而言,当前时间点元素的输出都依赖于前一个时间点元素的输出。是顺序操作的,无法并行;而对于Self-Attention而言,是可以并行操作的,提升了计算效率。
《Transformers are RNNs:Fast Autoregressive Transformers with Linear Attention》 这篇文章进一步介绍了Transformer和RNN之间的关系
Self-Attention 🆚 CNN 🆚 RNN
接下来比较下面几个架构,目标都是将由n个词元组成的序列映射到另一个长度相等的序列,其中的每个输入词元或输出词元都由d维向量表示。具体来说,将比较的是卷积神经网络、循环神经网络和自注意力这几个架构的计算复杂性、顺序操作和最大路径长度。请注意,顺序操作会妨碍并行计算,而任意的序列位置组合之间的路径越短,则能更轻松地学习序列中的远距离依赖关系
考虑一个卷积核大小为
k
k
k 的卷积层。在后面的章节将提供关于使用卷积神经网络处理序列的更多详细信息。目前只需要 知道的是, 由于序列长度是
n
n
n, 输入和输出的通道数量都是
d
d
d, 所以卷积层的计算复杂度为
O
(
k
n
d
2
)
\mathcal{O}\left(k n d^2\right)
O(knd2) 。如上图所示, 卷积神经网络是分层的, 因此为有
O
(
1
)
\mathcal{O}(1)
O(1) 个顺序操作, 最大路径长度为
O
(
n
/
k
)
\mathcal{O}(n / k)
O(n/k) 。例如,
x
1
\mathbf{x}_1
x1 和
x
5
\mathbf{x}_5
x5 处于上图图中卷积核大 小为 3 的双层卷积神经网络的感受野内。
当更新循环神经网络的隐状态时,
d
×
d
d \times d
d×d 权重矩阵和
d
d
d 维隐状态的乘法计算复杂度为
O
(
d
2
)
\mathcal{O}\left(d^2\right)
O(d2) 。由于序列长度为
n
n
n, 因此循 环神经网络层的计算复杂度为
O
(
n
d
2
)
\mathcal{O}\left(n d^2\right)
O(nd2) 。根据上图 有
O
(
n
)
\mathcal{O}(n)
O(n) 个顺序操作无法并行化, 最大路径长度也是
O
(
n
)
\mathcal{O}(n)
O(n) 。
在自注意力中, 查询、键和值都是
n
×
d
n \times d
n×d 矩阵。考虑 缩放的"点一积“注意力, 其中
n
×
d
n \times d
n×d 矩阵乘以
d
×
n
d \times n
d×n 矩 阵。之后输出的
n
×
n
n \times n
n×n 矩阵乘以
n
×
d
n \times d
n×d 矩阵。因此, 自注意力具有
O
(
n
2
d
)
\mathcal{O}\left(n^2 d\right)
O(n2d) 计算复杂性。正如在 图10.6.1中所讲, 每个 词元都通过自注意力直接连接到任何其他词元。因此, 有
O
(
1
)
\mathcal{O}(1)
O(1) 个顺序操作可以并行计算, 最大路径长度也是
O
(
1
)
\mathcal{O}(1)
O(1) 。
总而言之,卷积神经网络和自注意力都拥有并行计算的优势, 而且自注意力的最大路径长度最短。 但是因为其计算复杂度是关于序列长度的二次方,所以在很长的序列中计算会非常慢。
多头自注意力 (Multihead Attention)
在实践中,当给定相同的查询、键和值的集合时, 我们希望模型可以基于相同的注意力机制学习到不同的行为, 然后将不同的行为作为知识组合起来, 捕获序列内各种范围的依赖关系 (例如,短距离依赖和长距离依赖关系)。 因此,允许注意力机制组合使用查询、键和值的不同子空间表示(representation subspaces)可能是有益的。
为此,与其只使用单独一个注意力汇聚, 我们可以用独立学习得到的h组不同的 线性投影(linear projections)来变换查询、键和值。 然后,这h组变换后的查询、键和值将并行地送到注意力汇聚中。 最后,将这h个注意力汇聚的输出拼接在一起, 并且通过另一个可以学习的线性投影进行变换, 以产生最终输出。 这种设计被称为多头注意力(multihead attention)。 对于h个注意力汇聚输出,每一个注意力汇聚都被称作一个头(head)。 下图 展示了使用全连接层来实现可学习的线性变换的多头注意力。
📒 多头自注意力机制的数学定义
在实现多头注意力之前, 让我们用数学语言将这个模型形式化地描述出来。给定查询 q ∈ R d q \mathbf{q} \in \mathbb{R}^{d_q} q∈Rdq 、键 k ∈ R d k \mathbf{k} \in \mathbb{R}^{d_k} k∈Rdk和 值 v ∈ R d v \mathbf{v} \in \mathbb{R}^{d_v} v∈Rdv,每个注意力头 h i ( i = 1 , … , h ) \mathbf{h}_i(i=1, \ldots, h) hi(i=1,…,h)的计算方法为:
h i = f ( W i ( q ) q , W i ( k ) k , W i ( v ) v ) ∈ R p v \mathbf{h}_i=f\left(\mathbf{W}_i^{(q)} \mathbf{q}, \mathbf{W}_i^{(k)} \mathbf{k}, \mathbf{W}_i^{(v)} \mathbf{v}\right) \in \mathbb{R}^{p_v} hi=f(Wi(q)q,Wi(k)k,Wi(v)v)∈Rpv
其中, 可学习的参数包括 W i ( q ) ∈ R p q × d q 、 W i ( k ) ∈ R p k × d k 和 W i ( v ) ∈ R p v × d v \mathbf{W}_i^{(q)} \in \mathbb{R}^{p_q \times d_q} 、 \mathbf{W}_i^{(k)} \in \mathbb{R}^{p_k \times d_k \text { 和 }} \mathbf{W}_i^{(v)} \in \mathbb{R}^{p_v \times d_v} Wi(q)∈Rpq×dq、Wi(k)∈Rpk×dk 和 Wi(v)∈Rpv×dv, 以及代表注意力汇聚的函数 f f f 。 f f f可以加性注意力和缩放点积注意力。多头注意力的输出需要经过另一个线性转换, 它对应着 h h h个头连结后的结 果, 因此其可学习参数是 W o ∈ R p o × h p v \mathbf{W}_o \in \mathbb{R}^{p_o \times h p_v} Wo∈Rpo×hpv :
W o [ h 1 ⋮ h h ] ∈ R p o . \mathbf{W}_o\left[\begin{array}{c} \mathbf{h}_1 \\ \vdots \\ \mathbf{h}_h \end{array}\right] \in \mathbb{R}^{p_o} . Wo h1⋮hh ∈Rpo.
基于这种设计, 每个头都可能会关注输入的不同部分, 可以表示比简单加权平均值更复杂的函数。
📒 图解多头自注意力机制的实现过程
如下图,以两头的自注意力机制为例。其中head-1
和head-2
的可学习权重W
不同。
设
a
i
a^i
ai和
a
j
a^j
aj 是输入序列中的两个元素。对于head-1
而言,
b
i
,
1
b^{i,1}
bi,1的计算过程如下。
对于head-2
而言,
b
i
,
2
b^{i,2}
bi,2的计算过程如下。
然后将矩阵
W
O
W^O
WO和矩阵
[
b
i
,
0
,
b
i
,
1
]
[b^{i,0},b^{i,1}]
[bi,0,bi,1]相乘,得到
b
i
b^i
bi
📒 多头注意力机制的好处:
- 它扩展了模型专注于不同位置的能力。
在上面例子里只计算一个自注意力的的例子中,编码“Thinking”的时候,虽然最后 Z 1 Z_1 Z1或多或少包含了其他位置单词的信息,但是它实际编码中还是被“Thinking”单词本身所支配。
如果我们翻译一个句子,比如“The animal didn’t cross the street because it was too tired”
,我们会想知道“it”
指的是哪个词,这时模型的“多头”注意力机制会起到作用。 - 它给了注意层多个“表示子空间”。
就是在多头注意力中同时用多个不同的 W Q W^Q WQ、 W K W^K WK、 W V W^V WV权重矩阵(Transformer使用8个头部,因此我们最终会得到8个计算结果),每个权重都是随机初始化的。经过训练每个 W Q W^Q WQ、 W K W^K WK、 W V W^V WV都能将输入的矩阵投影到不同的表示子空间。
Transformer中的一个多头注意力(有8个head)的计算,就相当于用自注意力做8次不同的计算,并得到8个不同的结果 Z Z Z。
但是这会存在一点问题,多头注意力出来的结果会进入一个前馈神经网络,这个前馈神经网络可不能一下接收8个注意力矩阵,它的输入需要是单个矩阵(矩阵中每个行向量对应一个单词),所以我们需要一种方法把这8个压缩成一个矩阵。怎么做呢?我们将这些矩阵连接起来,然后将乘以一个附加的权重矩阵 W O W^O WO
以上就是多头自注意力的全部内容。让我们把多头注意力上述内容 放到一张图里看一下子:
现在我们已经看过什么是多头注意力了,让我们回顾一下之前的一个例子,再看一下编码“it”的时候每个头的关注点都在哪里:
编码it,用两个head的时候:其中一个更关注the animal,另一个更关注tired。此时该模型对it的编码。除了it本身的表达之外,同时也包含了the animal和tired的相关信息
如果我们把所有的头的注意力都可视化一下,就是下图这样,但是看起来事情好像突然又复杂了。
📒 多头注意力机制的代码实现
在实现过程中, 我们选择缩放点积注意力作为每一个注意力头。为了避免计算代价和参数代价的大幅增长, 我们设定
p
q
=
p
k
=
p
v
=
p
o
/
h
p_q=p_k=p_v=p_o / h
pq=pk=pv=po/h 值得注意的是, 如果我们将查询、键和值的线性变换的输出数量设置为
p
q
h
=
p
k
h
=
p
v
h
=
p
o
p_q h=p_k h=p_v h=p_o
pqh=pkh=pvh=po ,则可以并行计算
h
h
h个头。在下面的实现中,
p
o
p_o
po是通过参数num_hiddens
指定的。
#@save
class MultiHeadAttention(nn.Module):
"""多头注意力"""
def __init__(self, key_size, query_size, value_size, num_hiddens,
num_heads, dropout, bias=False, **kwargs):
super(MultiHeadAttention, self).__init__(**kwargs)
self.num_heads = num_heads
self.attention = d2l.DotProductAttention(dropout)
self.W_q = nn.Linear(query_size, num_hiddens, bias=bias)
self.W_k = nn.Linear(key_size, num_hiddens, bias=bias)
self.W_v = nn.Linear(value_size, num_hiddens, bias=bias)
self.W_o = nn.Linear(num_hiddens, num_hiddens, bias=bias)
def forward(self, queries, keys, values, valid_lens):
# queries,keys,values的形状:
# (batch_size,查询或者“键-值”对的个数,num_hiddens)
# valid_lens 的形状:
# (batch_size,)或(batch_size,查询的个数)
# 经过变换后,输出的queries,keys,values 的形状:
# (batch_size*num_heads,查询或者“键-值”对的个数,
# num_hiddens/num_heads)
queries = transpose_qkv(self.W_q(queries), self.num_heads)
keys = transpose_qkv(self.W_k(keys), self.num_heads)
values = transpose_qkv(self.W_v(values), self.num_heads)
if valid_lens is not None:
# 在轴0,将第一项(标量或者矢量)复制num_heads次,
# 然后如此复制第二项,然后诸如此类。
valid_lens = torch.repeat_interleave(
valid_lens, repeats=self.num_heads, dim=0)
# output的形状:(batch_size*num_heads,查询的个数,
# num_hiddens/num_heads)
output = self.attention(queries, keys, values, valid_lens)
# output_concat的形状:(batch_size,查询的个数,num_hiddens)
output_concat = transpose_output(output, self.num_heads)
return self.W_o(output_concat)
为了能够使多个头并行计算, 上面的MultiHeadAttention
类将使用下面定义的两个转置函数。 具体来说,transpose_output
函数反转了transpose_qkv
函数的操作。
#@save
def transpose_qkv(X, num_heads):
"""为了多注意力头的并行计算而变换形状"""
# 输入X的形状:(batch_size,查询或者“键-值”对的个数,num_hiddens)
# 输出X的形状:(batch_size,查询或者“键-值”对的个数,num_heads,
# num_hiddens/num_heads)
X = X.reshape(X.shape[0], X.shape[1], num_heads, -1)
# 输出X的形状:(batch_size,num_heads,查询或者“键-值”对的个数,
# num_hiddens/num_heads)
X = X.permute(0, 2, 1, 3)
# 最终输出的形状:(batch_size*num_heads,查询或者“键-值”对的个数,
# num_hiddens/num_heads)
return X.reshape(-1, X.shape[2], X.shape[3])
#@save
def transpose_output(X, num_heads):
"""逆转transpose_qkv函数的操作"""
X = X.reshape(-1, num_heads, X.shape[1], X.shape[2])
X = X.permute(0, 2, 1, 3)
return X.reshape(X.shape[0], X.shape[1], -1)
下面我们使用键和值相同的小例子来测试我们编写的MultiHeadAttention
类。 多头注意力输出的形状是(batch_size
,num_queries
,num_hiddens
)。
num_hiddens, num_heads = 100, 5
attention = MultiHeadAttention(num_hiddens, num_hiddens, num_hiddens,
num_hiddens, num_heads, 0.5)
attention.eval()
MultiHeadAttention(
(attention): DotProductAttention(
(dropout): Dropout(p=0.5, inplace=False)
)
(W_q): Linear(in_features=100, out_features=100, bias=False)
(W_k): Linear(in_features=100, out_features=100, bias=False)
(W_v): Linear(in_features=100, out_features=100, bias=False)
(W_o): Linear(in_features=100, out_features=100, bias=False)
)
batch_size, num_queries = 2, 4
num_kvpairs, valid_lens = 6, torch.tensor([3, 2])
X = torch.ones((batch_size, num_queries, num_hiddens))
Y = torch.ones((batch_size, num_kvpairs, num_hiddens))
attention(X, Y, Y, valid_lens).shape
# torch.Size([2, 4, 100])
Transformer
论文《Attention is all you need》 是Transformer模型的开山之作
Transformer 的整体结构主要由三部分组成:Input 、Encoder-Decoder 、Output
Transformer的整体结构
首先,我们从上至下一点点的来看一看Transformer的结构
让我们首先将模型看作一个单独的黑盒。在机器翻译应用程序中,它将获取一种语言的句子,并输出另一种语言的翻译。
打开这个黑盒,我们首先可以看到一个编码器(encoder)模块和一个解码器(decoder)模块,以及二者之间存在某种关联。
再往里看一下,编码器模块是n个encoder组件堆在一起,同样解码器模块也是n个decoder组件堆在一起 (n是一个可调参数)。
n个编码器组件的结构是相同的,但是它们之间的权重是不共享的
。
- 编码器
每个编码器组件都可以分为2个子层。编码器的输入首先会进入一个自注意力层(Self-Attention),自注意力层的输出会传递给一个前馈神经网络(Feed Forward Neural Network),每个编码器组件都是在相同的位置使用结构相同的前馈神经网络。
- 解码器
解码器组件也含有前面编码器中提到的两个层,区别在于这两个层之间还夹了一个注意力层,多出来的这个自注意力层的作用是让解码器能够注意到输入句子中相关的部分(和seq2seq中的attention一样的作用)。
到这里,我们已经对Transformer有了一个大致的认识。接下来,我们将逐个仔细讲解构成Transformer的三大部分: Input 、Encoder-Decoder 、Output
Transformer的输入
首先,我们来看一下Transformer的第一个部分:输入
Transformer 中单词的输入表示 x由Input Embeddings(单词向量)
和Poditional Encoding(位置编码)
相加得到。
为了让模型能知道单词的顺序,我们添加了位置编码,位置编码是遵循某些特定模式的。
位置编码向量和单词embedding的维度是一样的,比如下边都是四个格子:
❔ 为什么是将positional encoding与词向量相加,而不是拼接呢?
拼接相加都可以,只是本身词向量的维度512维就已经蛮大了,再拼接一个512维的位置向量,变成1024维,这样训练起来会相对慢一些,影响效率。两者的效果是差不多地,既然效果差不多当然是选择学习习难度较小的相加了。
单词Embedding
单词的 Embedding 有很多种方式可以获取,例如可以采用 Word2Vec、Glove, one-hot
等算法预训练得到,也可以在 Transformer 中训练得到。
- ont-hot 编码
- Word2Vec编码
位置Encoding
Transformer 中除了单词的 Embedding,还需要使用位置Embedding 表示单词出现在句子中的位置。因为 Transformer 不采用 RNN 的结构,而是使用全局信息,不能利用单词的顺序信息,而这部分信息对于 NLP 来说非常重要。所以 Transformer 中使用位置 Embedding 保存单词在序列中的相对或绝对位置。
在处理词元序列时,循环神经网络是逐个的重复地处理词元的, 而自注意力则因为并行计算而放弃了顺序操作。
为了使用序列的顺序信息,通过在输入表示中添加 位置编码(positional encoding)
来注入绝对的或相对的位置信息。
位置编码可以通过学习得到也可以直接固定得到。
Transformer中采用的是基于正弦函数和余弦函数的固定位置编码;BERT中的位置编码是通过训练得到的。
📒基于正弦函数和余弦函数的固定位置编码
《Attention Is All You Need》论文中Transformer使用的是正余弦位置编码。位置编码通过使用不同频率的正弦、余弦函数生成,然后和对应的位置的词向量相加,位置向量维度必须和词向量的维度一致。
假设输入表示 X ∈ R n × d \mathbf{X} \in \mathbb{R}^{n \times d} X∈Rn×d包含一个序列中n个词元的 d 维嵌入表示。位置编码使用相同形状的位置嵌入矩阵 P ∈ R n × d \mathbf{P} \in \mathbb{R}^{n \times d} P∈Rn×d 输 出 X + P \mathbf{X}+\mathbf{P} X+P , 第 i i i行、第 2 j 2j 2j列和 2 j + 1 2j+1 2j+1列上的元素为:
p
i
,
2
j
=
sin
(
i
1000
0
2
j
/
d
)
p
i
,
2
j
+
1
=
cos
(
i
1000
0
2
j
/
d
)
.
\begin{aligned} p_{i, 2 j} & =\sin \left(\frac{i}{10000^{2 j / d}}\right) \\ p_{i, 2 j+1} & =\cos \left(\frac{i}{10000^{2 j / d}}\right) . \end{aligned}
pi,2jpi,2j+1=sin(100002j/di)=cos(100002j/di).
其中
i
i
i表示单词在句子中的绝对位置,
i
=
0
,
1
,
2
…
i=0,1,2…
i=0,1,2… 例如:Jerry
在"Tom chase Jerry"
中的
i
=
2
i=2
i=2;
d
m
o
d
e
l
d_{model}
dmodel表示词向量的维度,在这里
d
m
o
d
e
l
=
512
d_{model}=512
dmodel=512;
2
j
2j
2j和
2
j
+
1
2j+1
2j+1表示奇偶性,
j
j
j表示词向量中的第几维,例如这里
d
m
o
d
e
l
=
512
d_{model}=512
dmodel=512,故
j
=
0
,
1
,
2
…
255
j=0,1,2…255
j=0,1,2…255。
乍一看, 这种基于三角函数的设计看起来很奇怪。在解释这个设计之前, 让我们先在下面的PositionalEncoding类 中实现它。
#@save
class PositionalEncoding(nn.Module):
"""位置编码"""
def __init__(self, num_hiddens, dropout, max_len=1000):
super(PositionalEncoding, self).__init__()
self.dropout = nn.Dropout(dropout)
# 创建一个足够长的P
self.P = torch.zeros((1, max_len, num_hiddens))
X = torch.arange(max_len, dtype=torch.float32).reshape(
-1, 1) / torch.pow(10000, torch.arange(
0, num_hiddens, 2, dtype=torch.float32) / num_hiddens)
self.P[:, :, 0::2] = torch.sin(X)
self.P[:, :, 1::2] = torch.cos(X)
def forward(self, X):
X = X + self.P[:, :X.shape[1], :].to(X.device)
return self.dropout(X)
在位置嵌入矩阵P中, 行代表词元在序列中的位置,列代表位置编码的不同维度。 从下面的例子中可以看到位置嵌入矩阵的第6列和第7列的频率高于第8列和第9列。 第6列和第7列之间的偏移量(第8列和第9列相同)是由于正弦函数和余弦函数的交替。
📒绝对位置信息
为了明白沿着编码维度单调降低的频率与绝对位置信息的关系, 让我们打印出0,1,…,7的二进制表示形式。 正如所看到的,每个数字、每两个数字和每四个数字上的比特值 在第一个最低位、第二个最低位和第三个最低位上分别交替。
for i in range(8):
print(f'{i}的二进制是:{i:>03b}')
0的二进制是:000
1的二进制是:001
2的二进制是:010
3的二进制是:011
4的二进制是:100
5的二进制是:101
6的二进制是:110
7的二进制是:111
在二进制表示中,较高比特位的交替频率低于较低比特位, 与下面的热图所示相似,只是位置编码通过使用三角函数在编码维度上降低频率。 由于输出是浮点数,因此此类连续表示比二进制表示法更节省空间。
📒相对位置信息
除了捕获绝对位置信息之外, 上述的位置编码还允许模型学习得到输入序列中相对位置信息。这是因为对于任何确定的位置偏移
δ
\delta
δ, 位置
i
+
δ
i+\delta
i+δ处 的位置编码可以线性投影位置
i
i
i处的位置编码来表示。
这种投影的数学解释是, 令
ω
j
=
1
/
1000
0
2
j
/
d
\omega_j=1 / 10000^{2 j / d}
ωj=1/100002j/d, 对于任何确定的位置偏移
δ
\delta
δ, 中的任何一对
(
p
i
,
2
j
,
p
i
,
2
j
+
1
)
\left(p_{i, 2 j}, p_{i, 2 j+1}\right)
(pi,2j,pi,2j+1) 都可以 线性投影到
(
p
i
+
δ
,
2
j
,
p
i
+
δ
,
2
j
+
1
)
\left(p_{i+\delta, 2 j}, p_{i+\delta, 2 j+1}\right)
(pi+δ,2j,pi+δ,2j+1) :
[ cos ( δ ω j ) sin ( δ ω j ) − sin ( δ ω j ) cos ( δ ω j ) ] [ p i , 2 j p i , 2 j + 1 ] = [ cos ( δ ω j ) sin ( i ω j ) + sin ( δ ω j ) cos ( i ω j ) − sin ( δ ω j ) sin ( i ω j ) + cos ( δ ω j ) cos ( i ω j ) ] = [ sin ( ( i + δ ) ω j ) cos ( ( i + δ ) ω j ) ] = [ p i + δ , 2 j p i + δ , 2 j + 1 ] , \begin{aligned} & {\left[\begin{array}{cc} \cos \left(\delta \omega_j\right) & \sin \left(\delta \omega_j\right) \\ -\sin \left(\delta \omega_j\right) & \cos \left(\delta \omega_j\right) \end{array}\right]\left[\begin{array}{c} p_{i, 2 j} \\ p_{i, 2 j+1} \end{array}\right] } \\ = & {\left[\begin{array}{c} \cos \left(\delta \omega_j\right) \sin \left(i \omega_j\right)+\sin \left(\delta \omega_j\right) \cos \left(i \omega_j\right) \\ -\sin \left(\delta \omega_j\right) \sin \left(i \omega_j\right)+\cos \left(\delta \omega_j\right) \cos \left(i \omega_j\right) \end{array}\right] } \\ = & {\left[\begin{array}{c} \sin \left((i+\delta) \omega_j\right) \\ \cos \left((i+\delta) \omega_j\right) \end{array}\right] } \\ = & {\left[\begin{array}{c} p_{i+\delta, 2 j} \\ p_{i+\delta, 2 j+1} \end{array}\right], } \end{aligned} ===[cos(δωj)−sin(δωj)sin(δωj)cos(δωj)][pi,2jpi,2j+1][cos(δωj)sin(iωj)+sin(δωj)cos(iωj)−sin(δωj)sin(iωj)+cos(δωj)cos(iωj)][sin((i+δ)ωj)cos((i+δ)ωj)][pi+δ,2jpi+δ,2j+1],
2 × 2 2 \times 2 2×2投影矩阵不依赖于任何位置的索引 i i i
📒Transformer中采用的位置编码
一直说位置向量遵循某个模式,这个模式到底是什么?
在下面的图中,每一行对应一个位置编码。所以第一行就是我们输入序列中第一个单词的位置编码,之后我们要把它加到词嵌入向量上。
如上图表示的是一个句子有20个词(纵轴),词嵌入向量的长度为512(横轴)。可以看到图像从中间一分为二,因为左半部分是由正弦函数生成的。右半部分由余弦函数生成。然后将它们二者拼接起来,形成了每个位置的位置编码。
可以在get_timing_signal_1d()中看到生成位置编码的代码。这不是位置编码的唯一方法。但是使用正余弦编码有诸多好处,具体可以看这里:Transformer 结构详解:位置编码
但是需要注意注意一点,上图的可视化是官方Tensor2Tensor库中的实现方法,将sin和cos拼接起来。但是和论文原文写的不一样,论文原文的3.5节写了位置编码的公式,采用的是基于正弦函数和余弦函数的固定位置编码。论文中公式的写法可以看这个代码:transformer_positional_encoding_graph
Transformer的Encoder-Decoder
接下来,我们来看一下Transformer的第二个部分:
Encoder-Decoder
主要由Encoder block
和Decoder Block
构成。
上图是论文中 Transformer 的内部结构图,左侧为 Encoder block,右侧为 Decoder block。红色圈中的部分为 Multi-Head Attention
,是由多个 Self-Attention组成的,可以看到 Encoder block 包含一个 Multi-Head Attention,而 Decoder block 包含两个 Multi-Head Attention (其中有一个用到 Masked
)。Multi-Head Attention 上方还包括一个 Add & Norm 层
,Add
表示残差连接 (Residual Connection)
用于防止网络退化,Norm
表示 Layer Normalization
,用于对每一层的激活值进行归一化。
Encoder block
Encoder block
中包括N个Encoder
, 每个Encoder的结构相同,但是权重不共享。
每个Encoder主要由Multi-Head Attention、Add&Norm、Feed Forward
三种结构。接下来,我们将依次讲解这三种结构。
📒 Multi-Head Attention
Self-attention
只使用了一组
W
Q
,
W
K
,
W
V
W^Q, W^K, W^V
WQ,WK,WV 来进行变换得到Query,Keys,Values。而Multi-Head Attention
使用多组
W
Q
,
W
K
,
W
V
W^Q, W^K, W^V
WQ,WK,WV 得到多组Query,Keys,Values
,然后每组分别计算得到一个Z矩 阵,最后将得到的多个Z矩阵进行拼接。Transformer里面是使用了N组不同的
W
Q
,
W
K
,
W
V
\mathrm{W}^{\mathrm{Q}}, \mathrm{W}^{\mathrm{K}}, \mathrm{W}^{\mathrm{V}}
WQ,WK,WV 。
📒 Add&Normalize
每个编码器中的每个子层(自注意力层、前馈神经网络)都有一个残差连接(Add),之后是做了一个层归一化(layer-normalization)。
将过程中的向量相加和layer-norm可视化如下所示:
- Add
Add,就是在Z的基础上加了一个残差块X
,加入残差块X的目的是为了防止在深度神经网络训练中发生退化问题。
退化的意思就是深度神经网络通过增加网络的层数,Loss逐渐减小,然后趋于稳定达到饱和,然后再继续增加网络层数,Loss反而增大。
❔ 为什么深度神经网络会发生退化?
举个例子:假如某个神经网络的最优网络层数是18层,但是我们在设计的时候并不知道到底多少层是最优解,本着层数越深越好的理念,我们设计了32层,那么32层神经网络中有14层其实是多余地,我们要想达到18层神经网络的最优效果,必须保证这多出来的14层网络必须进行恒等映射
,恒等映射的意思就是说,输入什么,输出就是什么,可以理解成F(x)=x这样的函数,因为只有进行了这样的恒等映射咱们才能保证这多出来的14层神经网络不会影响我们最优的效果。
❔ 残差块又是什么?
上图就是构造的一个残差块,可以看到X是这一层残差块的输入,也称作F(X)为残差,X为输入值,F(X)是经过第一层线性变化并激活后的输出,该图表示在残差网络中,第二层进行线性变化之后激活之前,F(X)加入了这一层输入值X,然后再进行激活后输出。在第二层输出值激活前加入X,这条路径称作shortcut连接
。
❔ 为什么添加了残差块能防止神经网络退化问题呢?
添加了残差块后,输出就变成了h(X)=F(X)+X。如果要让h(X)=X,那么是不是相当于只需要让F(X)=0就可以了。神经网络通过训练变成0是比变成X容易很多地,因为一般初始化神经网络的参数的时候就是设置的[0,1]之间的随机数,所以经过网络变换后很容易接近于0。
🌰 举例,如上图:假设该网络只经过线性变换,没有bias也没有激活函数。我们发现因为随机初始化权重一般偏向于0,那么经过该网络的输出值为[0.6 0.6],很明显会更接近与[0 0],而不是[2 1],相比与学习h(x)=x,模型要更快到学习F(x)=0。
并且ReLU能够将负数激活为0,过滤了负数的线性变化,也能够更快的使得F(x)=0。这样当网络自己决定哪些网络层为冗余层时,使用ResNet的网络很大程度上解决了学习恒等映射的问题,用学习残差F(x)=0更新该冗余层的参数来代替学习h(x)=x更新冗余层的参数。
这样当网络自行决定了哪些层为冗余层后,通过学习残差F(x)=0来让该层网络恒等映射上一层的输入,使得有了这些冗余层的网络效果与没有这些冗余层的网络效果相同,这样很大程度上解决了网络的退化问题。
Transformer中加上的X也就是Multi-Head Attention的输入,X矩阵。
- Normalize
❔ 为什么要进行Normalize呢?
在神经网络进行训练之前,都需要对于输入数据进行Normalize归一化,目的有二:1,能够加快训练的速度。2.提高训练的稳定性。
❔ 什么是Layer Normalization,什么是Batch Normalization
《Layer Normalization》 首次提出了层规范化。
《Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift》 首次提出了批量规范化
Batch Normalization 的处理对象是对一批样本, Layer Normalization 的处理对象是单个样本。Batch Normalization 是对这批样本的同一维度特征做归一化, Layer Normalization 是对这单个样本的所有维度特征做归一化。
❔ 为什么使用Layer Normalization(LN)而不使用Batch Normalization(BN)呢?
-
论文《PowerNorm: Rethinking Batch Normalization in Transformers》指出,Transformer中BN表现不太好的原因可能在于CV和NLP数据特性的不同,对于NLP数据,前向和反向传播中,batch统计量及其梯度都不太稳定。
-
论文《Understanding and Improving Layer Normalization》主要研究LN的优点,除了一般意义上认为可以稳定前向输入分布,加快收敛快,还有没有啥原因。最后的结论有:
a)相比于稳定前向输入分布,反向传播时mean和variance计算引入的梯度更有用,可以稳定反向传播的梯度
b) 去掉 gain和bias这两个参数可以在很多数据集上有提升,可能是因为这两个参数会带来过拟合,因为这两个参数是在训练集上学出来的 -
论文《Leveraging Batch Normalization for Vision Transformers》研究对于CV data,能不能使用BN。主要提出了如下观点:
1)LN特别适合处理变长数据,因为是对channel维度做操作(这里指NLP中的hidden维度),和句子长度和batch大小无关
2)BN比LN在inference的时候快,因为不需要计算mean和variance,直接用running mean和running variance就行
3)直接把VIT中的LN替换成BN,容易训练不收敛,原因是FFN没有被Normalized,所以还要在FFN block里面的两层之间插一个BN层。(可以加速20% VIT的训练)
上述解释参考了回答1和回答2
❔ Layer Normalization 的位置可以改变吗?
论文《On Layer Normalization in the Transformer Architecture》研究了为什么学习率预热阶段在训练Transformer中很重要,并表明层归一化的位置很重要。原始的Transformer,LN位于残差块之外(如下图a),在初始化时输出层附近参数的期望梯度很大。当使用大的学习率时,这会导致训练的不稳定;在残差块中使用LN的Transformer(如下图b),可以在没有预热阶段的情况下进行训练,并且收敛得更快。
📒 Feed-Forward Networks
全连接层公式如下:
FFN
(
x
)
=
max
(
0
,
x
W
1
+
b
1
)
W
2
+
b
2
\operatorname{FFN}(\mathrm{x})=\max \left(0, \mathrm{xW}_1+\mathrm{b}_1\right) \mathrm{W}_2+\mathrm{b}_2
FFN(x)=max(0,xW1+b1)W2+b2
这里的全连接层是一个两层的神经网络,先线性变换,然后ReLU非线性,再线性变换。
这里的x就是我们Multi-Head Attention的输出Z,还是引用上面的例子,那么Z是(2,512)维的矩阵,假设W1是(512,1024),其中W2与W1维度相反(1024,612),那么按照上面的公式:FFN(Z)=(2,512)x(512,1024)x(1024,512)=(2,512),我们发现维度没有发生变化,这两层网络就是为了将输入的Z映射到更加高维的空间中(2,64)x(64,1024)=(2,1024),然后通过非线性函数ReLU进行筛选,筛选完后再变回原来的维度。
然后经过Add&Normalize,输入下一个encoder中,经过6个encoder后输入到decoder中,至此Transformer的Encoder部分就全部介绍完了。
Decoder block
解码器有两种类型,我们先来了解下自回归的Decoder(AT)和非自回归的解码器(NAT)
Decoder-Autoregressive(AT) 🆚 Decoder-Non-autoregressive
如下图,Decoder每次选择softmax后概率最大的元素作为输出
每次预测时,需要将前一时刻的预测输出作为输入。第一次预测时,输入的是START
起始符。
但是,如上图所示会永远不知道在什么时候停止。
因此在词汇表中加入了END
作为终止符
-
Decoder-Autoregressive(AT)
自回归的解码器(AT), 当预测输出END
时,AT就会停止工作。
-
Decoder-Non-autoregressive
二对于非自回归的解码器(NAT)而言,如何决定输出的长度呢?有两种方法:a) 额外采用一个预测器来预测输出的长度。b) 输出一个很长的序列,但忽略END
后的输出。
NAT的优点:可以并行计算,且生成更稳定(如TTS)
但是NAT一般没有AT的效果好,因此在Transformer中我们用的是AT模型。
📒 Decoder Block 的工作流程
现在我们已经介绍了编码器的大部分概念,(因为encoder的decoder组件差不多)我们基本上也知道了解码器的组件是如何工作的。那让我们直接看看二者是如何协同工作的。
编码器首先处理输入序列,将最后一个编码器组件的输出转换为一组注意向量K和V。每个解码器组件将在“encoder-decoder attention”层中使用编码器传过来的K和V,这有助于解码器将注意力集中在输入序列中的适当位置:
如上图:完成编码阶段后,我们开始进行解码阶段。在解码阶段每一轮计算都只预测 输出(而不是直接输出一整个句子),在本例中是输出一个翻译之后的英语单词。
输出步骤会一直重复,直到遇到句子结束符 表明transformer的解码器已完成输出。
每一步的输出都会在下一个时间步输入到底部解码器,解码器会像编码器一样运算并输出结果(每次往外蹦一个词)。
跟编码器一样,在解码器中我们也为其添加位置编码,以指示每个单词的位置。
📒Transformer Decoder的输入
Decoder的输入分为两类:
一种是训练时的输入,一种是预测时的输入。
训练时的输入就是已经对准备好对应的target数据
。例如翻译任务,Encoder输入"Tom chase Jerry",Decoder输入"汤姆追逐杰瑞"。
如下图,在训练时,把Groud Truth作为输入,这种策略也叫作Teacher Forcing
预测时的输入,一开始输入的是起始符,然后每次输入是上一时刻Transformer的输出
。例如,输入"“,输出"汤姆”,输入"汤姆",输出"汤姆追逐",输入"汤姆追逐",输出"汤姆追逐杰瑞",输入"汤姆追逐杰瑞",输出"汤姆追逐杰瑞"结束。
📒Masked Multi-Head Attention
与Encoder的Multi-Head Attention计算原理一样,只是多加了一个mask
掩码,它对某些值进行掩盖,使其在参数更新时不产生效果。Transformer 模型里面涉及两种 mask,分别是 padding mask
和 sequence mask
。
❔ 什么是 padding mask 呢?
- padding mask
因为每个批次输入序列长度是不一样的也就是说,我们要对输入序列进行对齐 。具体来说,就是给在较短的序列后面填充 0。但是如果输入的序列太长,则是截取左边的内容,把多余的直接舍弃。因为这些填充的位置,其实是没什么意义的,所以我们的attention机制不应该把注意力放在这些位置上,所以我们需要进行一些处理。
具体的做法是,把这些位置的值加上一个非常大的负数(负无穷),这样的话,经过 softmax,这些位置的概率就会接近0!
❔ 什么是 sequence mask 呢?
- sequence mask
sequence mask 是为了使得 decoder 不能看见未来的信息。对于一个序列,在 time_step 为 t 的时刻,我们的解码输出应该只能依赖于 t 时刻之前的输出,而不能依赖 t 之后的输出。因此我们需要想一个办法,把 t 之后的信息给隐藏起来。这在训练的时候有效,因为训练的时候每次我们是将target数据完整输入进decoder中地,预测时不需要,预测的时候我们只能得到前一时刻预测出的输出。
❔ 怎么实现sequence mask呢?
产生一个上三角矩阵,上三角的值全为0。把这个矩阵作用在每一个序列上,就可以达到我们的目的。
🌰下面我们来举一个例子来看看mask-self attention 的工作流程。
第一步:是 Decoder 的输入矩阵和 Mask 矩阵输入矩阵包含 “<Begin> I have a cat
” (0, 1, 2, 3, 4) 五个单词的表示向量,Mask 是一个 5×5 的矩阵。在 Mask 可以发现单词 0 只能使用单词 0 的信息,而单词 1 可以使用单词 0, 1 的信息,即只能使用之前的信息。
第二步:接下来的操作和之前的 Self-Attention 一样,通过输入矩阵
X
\mathbf{X}
X 计算得到
Q
K
,
V
\mathbf{Q} \mathbf{K}, \mathbf{V}
QK,V 矩阵。然后 计算
Q
\mathbf{Q}
Q 和
K
T
K^T
KT 的乘积
Q
K
T
Q K^T
QKT 。
第三步:在得到
Q
K
T
QK^T
QKT之后需要进行 Softmax,计算 attention score,我们在 Softmax 之前需要使用Mask矩阵遮挡住每一个单词之后的信息,遮挡操作如下:
得到 Mask
Q
K
T
QK^T
QKT 之后在 Mask
Q
K
T
QK^T
QKT上进行 Softmax,每一行的和都为 1。但是单词 0 在单词 1, 2, 3, 4 上的 attention score 都为 0。
第四步:使用 Mask
Q
K
T
QK^T
QKT与矩阵 V相乘,得到输出 Z,则单词 1 的输出向量
Z
1
Z_1
Z1是只包含单词 1 信息的。
第五步:通过上述步骤就可以得到一个 Mask Self-Attention 的输出矩阵
Z
i
Z_i
Zi,然后和 Encoder 类似,通过 Multi-Head Attention 拼接多个输出
Z
i
Z_i
Zi然后计算得到第一个 Multi-Head Attention 的输出Z,Z与输入X维度一样。
在Encoder中的Multi-Head Attention也是需要进行mask地,只不过Encoder中只需要padding mask即可,而Decoder中需要padding mask和sequence mask。OK除了这点mask不一样以外,其他的部分均与Encoder一样啦~
📒基于Encoder-Decoder 的Multi-Head Attention
Encoder中的Multi-Head Attention是基于Self-Attention地。Decoder中的第二个Multi-Head Attention就只是基于Attention,它的输入Query
来自于Masked Multi-Head Attention的输出,Keys
和Values
来自于Encoder中最后一层的输出。因此也叫做Cross Attention
。
❔ Cross Attention 中,
Keys
和Values
一定要来自于Encoder中最后一层 的输出吗?
《Rethinking and Improving Natural Language Generation with Layer-Wise Multi-View Decoding》提出了不同的交叉注意力方式
Transformer的输出
❔ Decoder输出的是一个浮点型向量,如何把它变成一个词?这就是最后一个线性层和softmax要做的事情。
线性层就是一个简单的全连接神经网络(Linear)
,它将解码器生成的向量映射到logits向量中。
假设我们的模型词汇表是10000个英语单词,它们是从训练数据集中学习的。那logits向量维数也是10000,每一维对应一个单词的分数。
然后,softmax层
将这些分数转化为概率(全部为正值,加起来等于1.0),选择其中概率最大的位置的词汇作为当前时间步的输出。
Transformer的训练过程和损失函数
训练过程
现在我们已经了解了Transformer的整个前向传播的过程,那我们继续看一下训练过程。
在训练期间,未经训练的模型会进行相同的前向传播过程。由于我们是在有标记的训练数据集上训练它,所以我们可以将其输出与实际的输出进行比较。
为了便于理解,我们假设预处理阶段得到的词汇表只包含六个单词(“a”, “am”, “i”, “thanks”, “student”, “”)。
如上表:这个词汇表是在预处理阶段就创建的,在训练之前就已经得到了。
一旦我们定义好了词汇表,我们就可以使用长度相同的向量(独热码,one-hot 向量)来表示词汇表中的每个单词。例如,我们可以用以下向量表示单词“am”:
接下来让我们讨论一下模型的损失函数,损失函数是我们在训练阶段优化模型的指标,通过损失函数,可以帮助我们获得一个准确的、我们想要的模型。
损失函数
假设现在是训练阶段的第一步,我们用一个简单的例子(一个句子就一个词)来训练模型:把 “merci” 翻译成 “thanks”。
这意味着,我们希望输出是表示“谢谢”的概率分布。但由于这个模型还没有经过训练,所以目前还不太可能实现。
如上表:由于模型的参数都是随机初始化的,未经训练的模型为每个单词生成任意的概率分布。
我们可以将其与实际输出进行比较,然后使用反向传播调整模型的权重,使输出更接近我们所需要的值。
如何比较两种概率分布?在这个例子中我们只是将二者相减。实际应用中的损失函数请查看交叉熵损失和Kullback–Leibler散度。
上述只是最最简单的一个例子。现在我们来使用一个短句子(一个词的句子升级到三三个词的句子了),比如输入 “je suis étudiant” 预期的翻译结果为: “i am a student”。
所以我们希望模型不是一次输出一个词的概率分布了,能不能连续输出概率分布,最好满足下边要求:
每个概率分布向量长度都和词汇表长度一样。我们的例子中词汇表长度是6,实际操作中一般是30000或50000。
在我们的例子中第一个概率分布应该在与单词“i”相关的位置上具有最高的概率
第二种概率分布在与单词“am”相关的单元处具有最高的概率
以此类推,直到最后输出分布指示“<eos>
”符号。除了单词本身之外,单词表中也应该包含诸如“<eos>
”的信息,这样softmax之后指向“<eos>
”位置,标志解码器输出结束。
在足够大的训练集上训练足够时间之后,我们期望产生的概率分布如下所示:
这个是我们训练之后最终得到的结果。当然这个概率并不能表明某个词是否是训练集之中的词汇。
在这里你可以看到softmax的一个特性,就是即使其他单词并不是本时间步的输出,
也会有一丁点的概率存在,这一特性有助于帮助模型进行训练。
🔍搜索序列
模型一次产生一个输出,在这么多候选中我们如何获得我们想要的输出呢?接下来,我们介绍三种搜索算法。
序列搜索策略包括贪心搜索、穷举搜索和束搜索。
如上图,红色箭头指示的是贪心算法,绿色箭头指示的是束搜索,搜索所有的路径是穷举搜索。
贪心搜索(greedy decoding)
:在每个时间步,贪心搜索选择具有最高条件概率的词元
预测输出序列“A”“B”“C”和“”。 这个输出序列的条件概率是 0.5 × 0.4 × 0.4 × 0.6 = 0.048 0.5 \times 0.4 \times 0.4 \times 0.6=0.048 0.5×0.4×0.4×0.6=0.048。
那么贪心搜索存在的问题是什么呢? 现实中, 最优序列(optimal sequence)应该是最大化
∏
t
′
=
1
T
′
P
(
y
t
′
∣
y
1
,
…
,
y
t
′
−
1
,
c
)
\prod_{t^{\prime}=1}^{T^{\prime}} P\left(y_{t^{\prime}} \mid y_1, \ldots, y_{t^{\prime}-1}, \mathbf{c}\right)
∏t′=1T′P(yt′∣y1,…,yt′−1,c) 值的输出序列, 这是基于输入序列生成输出序列的条件嘅率。然而, 贪心搜索无法保证 得到最优序列。
如上图,在时间步2,应该选择具有第二高条件概率的词元“C”(而非最高条件概率的词元)
-
穷举搜索(exhaustive search)
: 穷举地列举所有可能的输出序列及其条件概率, 然后计算输出条件概率最高的一个。
虽然我们可以使用穷举搜索来获得最优序列, 但是计算量很庞大。 -
束搜索(beam search)
:束搜索 (beam search) 是贪心搜索的一个改进版本。它有一个超参数,名为束宽 (beam size) k k k 。在时间步 1 , 我们 先择具有最高条件概率的 k k k 个词元。这 k k k 个词元将分别是 k k k 个候选输出序列的第一个词元。在随后的每个时间步, 基于上 一时间步的 k k k 个候选输出序列, 我们将继续从 k ∣ Y ∣ k|\mathcal{Y}| k∣Y∣ 个可能的选择中 挑出具有最高条件概率的 k k k 个候选输出序列。
如上图,演示了束搜索过程(束宽:2,输出序列的最大长度:3)。候选输出序列是
A
、
C
、
A
B
、
C
E
、
A
B
D
A 、 C 、 A B 、 C E 、 A B D
A、C、AB、CE、ABD 和
C
E
D
C E D
CED
假设输出的词表只包含五个元素:
Y
=
{
A
,
B
,
C
,
D
,
E
}
\mathcal{Y}=\{A, B, C, D, E\}
Y={A,B,C,D,E}, 其中有一个是“”。设置 束宽为 2 , 输出序列的最大长度为 3 。在时间步 1 , 假设具有最高条件概率
P
(
y
1
∣
c
)
P\left(y_1 \mid \mathbf{c}\right)
P(y1∣c) 的词元是
A
A
A 和
C
C
C 。在时间步 2 , 我们 计算所有
y
2
∈
Y
y_2 \in \mathcal{Y}
y2∈Y 为:
P
(
A
,
y
2
∣
c
)
=
P
(
A
∣
c
)
P
(
y
2
∣
A
,
c
)
,
P
(
C
,
y
2
∣
c
)
=
P
(
C
∣
c
)
P
(
y
2
∣
C
,
c
)
,
\begin{aligned} & P\left(A, y_2 \mid \mathbf{c}\right)=P(A \mid \mathbf{c}) P\left(y_2 \mid A, \mathbf{c}\right), \\ & P\left(C, y_2 \mid \mathbf{c}\right)=P(C \mid \mathbf{c}) P\left(y_2 \mid C, \mathbf{c}\right), \end{aligned}
P(A,y2∣c)=P(A∣c)P(y2∣A,c),P(C,y2∣c)=P(C∣c)P(y2∣C,c),
从这十个值中选择最大的两个, 比如
P
(
A
,
B
∣
c
)
P(A, B \mid \mathbf{c})
P(A,B∣c) 和
P
(
C
,
E
∣
c
)
P(C, E \mid \mathbf{c})
P(C,E∣c) 。然后在时间步 3 , 我们计算所有
y
3
∈
Y
y_3 \in \mathcal{Y}
y3∈Y 为:
P
(
A
,
B
,
y
3
∣
c
)
=
P
(
A
,
B
∣
c
)
P
(
y
3
∣
A
,
B
,
c
)
,
P
(
C
,
E
,
y
3
∣
c
)
=
P
(
C
,
E
∣
c
)
P
(
y
3
∣
C
,
E
,
c
)
,
\begin{aligned} & P\left(A, B, y_3 \mid \mathbf{c}\right)=P(A, B \mid \mathbf{c}) P\left(y_3 \mid A, B, \mathbf{c}\right), \\ & P\left(C, E, y_3 \mid \mathbf{c}\right)=P(C, E \mid \mathbf{c}) P\left(y_3 \mid C, E, \mathbf{c}\right), \end{aligned}
P(A,B,y3∣c)=P(A,B∣c)P(y3∣A,B,c),P(C,E,y3∣c)=P(C,E∣c)P(y3∣C,E,c),
从这十个值中选择最大的两个, 即
P
(
A
,
B
,
D
∣
c
)
P(A, B, D \mid \mathbf{c})
P(A,B,D∣c) 和
P
(
C
,
E
,
D
∣
c
)
P(C, E, D \mid \mathbf{c})
P(C,E,D∣c), 我们会得到六个候选输出序列:(1)
A
A
A; (2)
C
C
C; (3)
A
,
B
A, B
A,B; (4)
C
,
E
C, E
C,E; (5)
A
,
B
,
D
A, B, D
A,B,D; (6)
C
,
E
,
D
C, E, D
C,E,D 。
最后, 基于这六个序列(例如, 丢弃包括“”和之后的部分),我们获得最终候选输出序列集合。然后我们选择其 中条件概率乘积最高的序列作为输出序列:
1
L
α
log
P
(
y
1
,
…
,
y
L
∣
c
)
=
1
L
α
∑
t
′
=
1
L
log
P
(
y
t
′
∣
y
1
,
…
,
y
t
′
−
1
,
c
)
,
\frac{1}{L^\alpha} \log P\left(y_1, \ldots, y_L \mid \mathbf{c}\right)=\frac{1}{L^\alpha} \sum_{t^{\prime}=1}^L \log P\left(y_{t^{\prime}} \mid y_1, \ldots, y_{t^{\prime}-1}, \mathbf{c}\right),
Lα1logP(y1,…,yL∣c)=Lα1t′=1∑LlogP(yt′∣y1,…,yt′−1,c),
其中
L
L
L 是最终候选序列的长度,
α
\alpha
α 通常设置为
0.75
0.75
0.75 。因为一个较长的序列在 (9.8.4) 的求和中会有更多的对数项, 因此分 母中的
L
α
L^\alpha
Lα 用于惩罚长序列。
束搜索的计算量为
O
(
k
∣
Y
∣
T
′
)
\mathcal{O}\left(k|\mathcal{Y}| T^{\prime}\right)
O(k∣Y∣T′), 这个结果介于贪心搜索和穷举搜索之间。实际上, 贪心搜索可以看作一种束宽为 1 的特 殊类型的束搜索。通过灵活地选择束宽, 束搜索可以在正确率和计算代价之间进行权衡。
那么该选取哪种序列搜索策略呢? 如果精度最重要,则显然是穷举搜索。 如果计算成本最重要,则显然是贪心搜索。 而束搜索的实际应用则介于这两个极端之间。
Transformer的代码实现
首先,我们先回顾下Transformer的整体架构
Transformer作为编码器-解码器架构的一个实例,其整体架构图如下:
Transformer是由编码器和解码器组成的。基于Bahdanau注意力实现的序列到序列的学习相比,Transformer的编码器和解码器是基于自注意力的模块叠加而成的,源(输入)序列和目标(输出)序列的嵌入(embedding)表示将加上位置编码(positional encoding),再分别输入到编码器和解码器中。
从宏观角度来看, Transformer的编码器是由多个相同的层叠加而成的, 每个 层都有两个子层(子层表示为 sublayer)。第一个子层是多头自注意力 (multi-head self-attention)汇聚; 第二个子层 是基于位置的前馈网络 (positionwise feed-forward network)。具体来说, 在计算编码器的自注意力时, 查询、键和值 都来自前一个编码器层的输出。受残差网络的启发, 每个子层都采用了残差连接 (residual connection) 。在 Transformer中, 对于序列中任何位置的任何输入 x ∈ R d \mathbf{x} \in \mathbb{R}^d x∈Rd, 都要求满足sublayer ( x ) ∈ R d (\mathbf{x}) \in \mathbb{R}^d (x)∈Rd, 以便残差连接满足 x + sublayer ( x ) ∈ R d \mathbf{x}+\operatorname{sublayer}(\mathbf{x}) \in \mathbb{R}^d x+sublayer(x)∈Rd 。在残差连接的加法计算之后,紧接着应用层规范化 (layer normalization)。 因此, 输入序列对应的每个位置, Transformer编码器都将输出一个 d d d 维表示向量。
Transformer解码器也是由多个相同的层叠加而成的,并且层中使用了残差连接和层规范化。除了编码器中描述的两个子层之外,解码器还在这两个子层之间插入了第三个子层,称为编码器-解码器注意力(encoder-decoder attention)层。在编码器-解码器注意力中,查询来自前一个解码器层的输出,而键和值来自整个编码器的输出。在解码器自注意力中,查询、键和值都来自上一个解码器层的输出。但是,解码器中的每个位置只能考虑该位置之前的所有位置。这种掩蔽(masked)注意力保留了自回归(auto-regressive)属性,确保预测仅依赖于已生成的输出词元。
在此之前已经描述并实现了基于缩放点积多头注意力 和位置编码 。接下来将实现Transformer模型的剩余部分。
首先,先导入需要的包
import math
import pandas as pd
import torch
from torch import nn
from d2l import torch as d2l
基于位置的前馈神经网络
基于位置的前馈网络对序列中的所有位置的表示进行变换时使用的是同一个多层感知机(MLP),这就是称前馈网络是基于位置的(positionwise)的原因。在下面的实现中,输入X
的形状(批量大小,时间步数或序列长度,隐单元数或特征维度)将被一个两层的感知机转换成形状为(批量大小,时间步数,ffn_num_outputs
)的输出张量。
#@save
class PositionWiseFFN(nn.Module):
"""基于位置的前馈网络"""
def __init__(self, ffn_num_input, ffn_num_hiddens, ffn_num_outputs,
**kwargs):
super(PositionWiseFFN, self).__init__(**kwargs)
self.dense1 = nn.Linear(ffn_num_input, ffn_num_hiddens)
self.relu = nn.ReLU()
self.dense2 = nn.Linear(ffn_num_hiddens, ffn_num_outputs)
def forward(self, X):
return self.dense2(self.relu(self.dense1(X)))
下面的例子显示,改变张量的最里层维度的尺寸,会改变成基于位置的前馈网络的输出尺寸。因为用同一个多层感知机对所有位置上的输入进行变换,所以当所有这些位置的输入相同时,它们的输出也是相同的。
ffn = PositionWiseFFN(4, 4, 8)
ffn.eval()
ffn(torch.ones((2, 3, 4)))[0]
残差连接和层规范化
在一个小批量的样本内基于批量规范化对数据进行重新中心化和重新缩放的调整。层规范化和批量规范化的目标相同,但层规范化是基于特征维度进行规范化。尽管批量规范化在计算机视觉中被广泛应用,但在自然语言处理任务中(输入通常是变长序列)批量规范化通常不如层规范化的效果好。
以下代码对比不同维度的层规范化和批量规范化的效果。
ln = nn.LayerNorm(2)
bn = nn.BatchNorm1d(2)
X = torch.tensor([[1, 2], [2, 3]], dtype=torch.float32)
# 在训练模式下计算X的均值和方差
print('layer norm:', ln(X), '\nbatch norm:', bn(X))
layer norm: tensor([[-1.0000, 1.0000],
[-1.0000, 1.0000]], grad_fn=<NativeLayerNormBackward0>)
batch norm: tensor([[-1.0000, -1.0000],
[ 1.0000, 1.0000]], grad_fn=<NativeBatchNormBackward0>)
现在可以使用残差连接和层规范化来实现AddNorm
类。暂退法也被作为正则化方法使用。
#@save
class AddNorm(nn.Module):
"""残差连接后进行层规范化"""
def __init__(self, normalized_shape, dropout, **kwargs):
super(AddNorm, self).__init__(**kwargs)
self.dropout = nn.Dropout(dropout)
self.ln = nn.LayerNorm(normalized_shape)
def forward(self, X, Y):
return self.ln(self.dropout(Y) + X)
残差连接要求两个输入的形状相同,以便加法操作后输出张量的形状相同。
add_norm = AddNorm([3, 4], 0.5)
add_norm.eval()
add_norm(torch.ones((2, 3, 4)), torch.ones((2, 3, 4))).shape
# torch.Size([2, 3, 4])
编码器
有了组成Transformer编码器的基础组件,现在可以先实现编码器中的一个层。下面的EncoderBlock
类包含两个子层:多头自注意力和基于位置的前馈网络,这两个子层都使用了残差连接和紧随的层规范化。
#@save
class EncoderBlock(nn.Module):
"""Transformer编码器块"""
def __init__(self, key_size, query_size, value_size, num_hiddens,
norm_shape, ffn_num_input, ffn_num_hiddens, num_heads,
dropout, use_bias=False, **kwargs):
super(EncoderBlock, self).__init__(**kwargs)
self.attention = d2l.MultiHeadAttention(
key_size, query_size, value_size, num_hiddens, num_heads, dropout,
use_bias)
self.addnorm1 = AddNorm(norm_shape, dropout)
self.ffn = PositionWiseFFN(
ffn_num_input, ffn_num_hiddens, num_hiddens)
self.addnorm2 = AddNorm(norm_shape, dropout)
def forward(self, X, valid_lens):
Y = self.addnorm1(X, self.attention(X, X, X, valid_lens))
return self.addnorm2(Y, self.ffn(Y))
正如从代码中所看到的,Transformer编码器中的任何层都不会改变其输入的形状。
X = torch.ones((2, 100, 24))
valid_lens = torch.tensor([3, 2])
encoder_blk = EncoderBlock(24, 24, 24, 24, [100, 24], 24, 48, 8, 0.5)
encoder_blk.eval()
encoder_blk(X, valid_lens).shape
# torch.Size([2, 100, 24])
下面实现的Transformer编码器的代码中,堆叠了num_layers
个EncoderBlock
类的实例。由于这里使用的是值范围在−1和1之间的固定位置编码,因此通过学习得到的输入的嵌入表示的值需要先乘以嵌入维度的平方根进行重新缩放,然后再与位置编码相加。
#@save
class TransformerEncoder(d2l.Encoder):
"""Transformer编码器"""
def __init__(self, vocab_size, key_size, query_size, value_size,
num_hiddens, norm_shape, ffn_num_input, ffn_num_hiddens,
num_heads, num_layers, dropout, use_bias=False, **kwargs):
super(TransformerEncoder, self).__init__(**kwargs)
self.num_hiddens = num_hiddens
self.embedding = nn.Embedding(vocab_size, num_hiddens)
self.pos_encoding = d2l.PositionalEncoding(num_hiddens, dropout)
self.blks = nn.Sequential()
for i in range(num_layers):
self.blks.add_module("block"+str(i),
EncoderBlock(key_size, query_size, value_size, num_hiddens,
norm_shape, ffn_num_input, ffn_num_hiddens,
num_heads, dropout, use_bias))
def forward(self, X, valid_lens, *args):
# 因为位置编码值在-1和1之间,
# 因此嵌入值乘以嵌入维度的平方根进行缩放,
# 然后再与位置编码相加。
X = self.pos_encoding(self.embedding(X) * math.sqrt(self.num_hiddens))
self.attention_weights = [None] * len(self.blks)
for i, blk in enumerate(self.blks):
X = blk(X, valid_lens)
self.attention_weights[
i] = blk.attention.attention.attention_weights
return X
下面我们指定了超参数来创建一个两层的Transformer编码器。 Transformer编码器输出的形状是(批量大小,时间步数目,num_hiddens
)。
encoder = TransformerEncoder(
200, 24, 24, 24, 24, [100, 24], 24, 48, 8, 2, 0.5)
encoder.eval()
encoder(torch.ones((2, 100), dtype=torch.long), valid_lens).shape
# torch.Size([2, 100, 24])
解码器
Transformer解码器也是由多个相同的层组成。在DecoderBlock
类中实现的每个层包含了三个子层:解码器自注意力、“编码器-解码器”注意力和基于位置的前馈网络。这些子层也都被残差连接和紧随的层规范化围绕。
正如在本节前面所述,在掩蔽多头解码器自注意力层(第一个子层)中,查询、键和值都来自上一个解码器层的输出。关于序列到序列模型(sequence-to-sequence model),在训练阶段,其输出序列的所有位置(时间步)的词元都是已知的;然而,在预测阶段,其输出序列的词元是逐个生成的。因此,在任何解码器时间步中,只有生成的词元才能用于解码器的自注意力计算中。为了在解码器中保留自回归的属性,其掩蔽自注意力设定了参数dec_valid_lens
,以便任何查询都只会与解码器中所有已经生成词元的位置(即直到该查询位置为止)进行注意力计算。
class DecoderBlock(nn.Module):
"""解码器中第i个块"""
def __init__(self, key_size, query_size, value_size, num_hiddens,
norm_shape, ffn_num_input, ffn_num_hiddens, num_heads,
dropout, i, **kwargs):
super(DecoderBlock, self).__init__(**kwargs)
self.i = i
self.attention1 = d2l.MultiHeadAttention(
key_size, query_size, value_size, num_hiddens, num_heads, dropout)
self.addnorm1 = AddNorm(norm_shape, dropout)
self.attention2 = d2l.MultiHeadAttention(
key_size, query_size, value_size, num_hiddens, num_heads, dropout)
self.addnorm2 = AddNorm(norm_shape, dropout)
self.ffn = PositionWiseFFN(ffn_num_input, ffn_num_hiddens,
num_hiddens)
self.addnorm3 = AddNorm(norm_shape, dropout)
def forward(self, X, state):
enc_outputs, enc_valid_lens = state[0], state[1]
# 训练阶段,输出序列的所有词元都在同一时间处理,
# 因此state[2][self.i]初始化为None。
# 预测阶段,输出序列是通过词元一个接着一个解码的,
# 因此state[2][self.i]包含着直到当前时间步第i个块解码的输出表示
if state[2][self.i] is None:
key_values = X
else:
key_values = torch.cat((state[2][self.i], X), axis=1)
state[2][self.i] = key_values
if self.training:
batch_size, num_steps, _ = X.shape
# dec_valid_lens的开头:(batch_size,num_steps),
# 其中每一行是[1,2,...,num_steps]
dec_valid_lens = torch.arange(
1, num_steps + 1, device=X.device).repeat(batch_size, 1)
else:
dec_valid_lens = None
# 自注意力
X2 = self.attention1(X, key_values, key_values, dec_valid_lens)
Y = self.addnorm1(X, X2)
# 编码器-解码器注意力。
# enc_outputs的开头:(batch_size,num_steps,num_hiddens)
Y2 = self.attention2(Y, enc_outputs, enc_outputs, enc_valid_lens)
Z = self.addnorm2(Y, Y2)
return self.addnorm3(Z, self.ffn(Z)), state
为了便于在“编码器-解码器”注意力中进行缩放点积计算和残差连接中进行加法计算,编码器和解码器的特征维度都是num_hiddens
。
decoder_blk = DecoderBlock(24, 24, 24, 24, [100, 24], 24, 48, 8, 0.5, 0)
decoder_blk.eval()
X = torch.ones((2, 100, 24))
state = [encoder_blk(X, valid_lens), valid_lens, [None]]
decoder_blk(X, state)[0].shape
# torch.Size([2, 100, 24])
现在我们构建了由num_layers
个DecoderBlock
实例组成的完整的Transformer解码器。最后,通过一个全连接层计算所有vocab_size
个可能的输出词元的预测值。解码器的自注意力权重和编码器解码器注意力权重都被存储下来,方便日后可视化的需要。
class TransformerDecoder(d2l.AttentionDecoder):
def __init__(self, vocab_size, key_size, query_size, value_size,
num_hiddens, norm_shape, ffn_num_input, ffn_num_hiddens,
num_heads, num_layers, dropout, **kwargs):
super(TransformerDecoder, self).__init__(**kwargs)
self.num_hiddens = num_hiddens
self.num_layers = num_layers
self.embedding = nn.Embedding(vocab_size, num_hiddens)
self.pos_encoding = d2l.PositionalEncoding(num_hiddens, dropout)
self.blks = nn.Sequential()
for i in range(num_layers):
self.blks.add_module("block"+str(i),
DecoderBlock(key_size, query_size, value_size, num_hiddens,
norm_shape, ffn_num_input, ffn_num_hiddens,
num_heads, dropout, i))
self.dense = nn.Linear(num_hiddens, vocab_size)
def init_state(self, enc_outputs, enc_valid_lens, *args):
return [enc_outputs, enc_valid_lens, [None] * self.num_layers]
def forward(self, X, state):
X = self.pos_encoding(self.embedding(X) * math.sqrt(self.num_hiddens))
self._attention_weights = [[None] * len(self.blks) for _ in range (2)]
for i, blk in enumerate(self.blks):
X, state = blk(X, state)
# 解码器自注意力权重
self._attention_weights[0][
i] = blk.attention1.attention.attention_weights
# “编码器-解码器”自注意力权重
self._attention_weights[1][
i] = blk.attention2.attention.attention_weights
return self.dense(X), state
@property
def attention_weights(self):
return self._attention_weights
训练
依照Transformer架构来实例化编码器-解码器模型。在这里,指定Transformer的编码器和解码器都是2层,都使用4头注意力。
为了进行序列到序列的学习,下面在“英语-法语”机器翻译数据集上训练Transformer模型。
num_hiddens, num_layers, dropout, batch_size, num_steps = 32, 2, 0.1, 64, 10
lr, num_epochs, device = 0.005, 200, d2l.try_gpu()
ffn_num_input, ffn_num_hiddens, num_heads = 32, 64, 4
key_size, query_size, value_size = 32, 32, 32
norm_shape = [32]
train_iter, src_vocab, tgt_vocab = d2l.load_data_nmt(batch_size, num_steps)
encoder = TransformerEncoder(
len(src_vocab), key_size, query_size, value_size, num_hiddens,
norm_shape, ffn_num_input, ffn_num_hiddens, num_heads,
num_layers, dropout)
decoder = TransformerDecoder(
len(tgt_vocab), key_size, query_size, value_size, num_hiddens,
norm_shape, ffn_num_input, ffn_num_hiddens, num_heads,
num_layers, dropout)
net = d2l.EncoderDecoder(encoder, decoder)
d2l.train_seq2seq(net, train_iter, lr, num_epochs, tgt_vocab, device)
# loss 0.032, 5679.3 tokens/sec on cuda:0
训练结束后,使用Transformer模型将一些英语句子翻译成法语,并且计算它们的BLEU分数。
engs = ['go .', "i lost .", 'he\'s calm .', 'i\'m home .']
fras = ['va !', 'j\'ai perdu .', 'il est calme .', 'je suis chez moi .']
for eng, fra in zip(engs, fras):
translation, dec_attention_weight_seq = d2l.predict_seq2seq(
net, eng, src_vocab, tgt_vocab, num_steps, device, True)
print(f'{eng} => {translation}, ',
f'bleu {d2l.bleu(translation, fra, k=2):.3f}')
go . => va !, bleu 1.000
i lost . => j'ai perdu ., bleu 1.000
he's calm . => il est calme ., bleu 1.000
i'm home . => je suis chez moi ., bleu 1.000
当进行最后一个英语到法语的句子翻译工作时,让我们可视化Transformer的注意力权重。编码器自注意力权重的形状为(编码器层数,注意力头数,num_steps
或查询的数目,num_steps
或“键-值”对的数目)。
enc_attention_weights = torch.cat(net.encoder.attention_weights, 0).reshape((num_layers, num_heads,
-1, num_steps))
enc_attention_weights.shape
# torch.Size([2, 4, 10, 10])
在编码器的自注意力中,查询和键都来自相同的输入序列。因为填充词元是不携带信息的,因此通过指定输入序列的有效长度可以避免查询与使用填充词元的位置计算注意力。接下来,将逐行呈现两层多头注意力的权重。每个注意力头都根据查询、键和值的不同的表示子空间来表示不同的注意力。
d2l.show_heatmaps(
enc_attention_weights.cpu(), xlabel='Key positions',
ylabel='Query positions', titles=['Head %d' % i for i in range(1, 5)],
figsize=(7, 3.5))
为了可视化解码器的自注意力权重和“编码器-解码器”的注意力权重,我们需要完成更多的数据操作工作。例如用零填充被掩蔽住的注意力权重。值得注意的是,解码器的自注意力权重和“编码器-解码器”的注意力权重都有相同的查询:即以序列开始词元(beginning-of-sequence,BOS)打头,再与后续输出的词元共同组成序列。
dec_attention_weights_2d = [head[0].tolist()
for step in dec_attention_weight_seq
for attn in step for blk in attn for head in blk]
dec_attention_weights_filled = torch.tensor(
pd.DataFrame(dec_attention_weights_2d).fillna(0.0).values)
dec_attention_weights = dec_attention_weights_filled.reshape((-1, 2, num_layers, num_heads, num_steps))
dec_self_attention_weights, dec_inter_attention_weights = \
dec_attention_weights.permute(1, 2, 3, 0, 4)
dec_self_attention_weights.shape, dec_inter_attention_weights.shape
# (torch.Size([2, 4, 6, 10]), torch.Size([2, 4, 6, 10]))
由于解码器自注意力的自回归属性,查询不会对当前位置之后的“键-值”对进行注意力计算。
# Plusonetoincludethebeginning-of-sequencetoken
d2l.show_heatmaps(
dec_self_attention_weights[:, :, :, :len(translation.split()) + 1],
xlabel='Key positions', ylabel='Query positions',
titles=['Head %d' % i for i in range(1, 5)], figsize=(7, 3.5))
与编码器的自注意力的情况类似,通过指定输入序列的有效长度,输出序列的查询不会与输入序列中填充位置的词元进行注意力计算。
d2l.show_heatmaps(
dec_inter_attention_weights, xlabel='Key positions',
ylabel='Query positions', titles=['Head %d' % i for i in range(1, 5)],
figsize=(7, 3.5))
尽管Transformer架构是为了序列到序列的学习而提出的,但Transformer编码器或Transformer解码器通常被单独用于不同的深度学习任务中。
- 小结
- Transformer是编码器-解码器架构的一个实践,尽管在实际情况中编码器或解码器可以单独使用。
- 在Transformer中,多头自注意力用于表示输入序列和输出序列,不过解码器必须通过掩蔽机制来保留自回归属性。
- Transformer中的残差连接和层规范化是训练非常深度模型的重要工具。
- Transformer模型中基于位置的前馈网络使用同一个多层感知机,作用是对所有序列位置的表示进行转换。
参考
💗感谢如下文章或者视频对我的启发和帮助~
本文代码主要参考了李沐老师的《Dive into Deep Learning》书中的两节内容
“现代循环神经网络”
“注意力机制”
本文中的讲解和图片部分源自李宏毅老师的机器学习课程
【機器學習2021】自注意力機制 (Self-attention) (上)】
【機器學習2021】自注意力機制 (Self-attention) (下)
【機器學習2021】Transformer (上)
【機器學習2021】Transformer (下)
本文中的图解部分源自:Jay Alammar 的两篇文章
《The Illustrated Transformer》
《Visualizing A Neural Machine Translation Model (Mechanics of Seq2seq Models With Attention)》
其他关于Transformer的讲解,参考的有如下文章
Transformer模型详解(图解最完整版)
史上最小白之Transformer详解
💭 若有不足和疏漏,欢迎交流和指正!