「深度学习」Transformer 模型详解
本文主要参考自“数学家是我理想”的《Transformer 详解》 ,这位大神讲得十分清晰透彻,甚至有配套的B站讲解视频,强烈建议搭配食用!
Transformer 是 Google Brain 在 2017 年底发表的论文 《Attention is all you need》 中所提出的基于多头自注意力机制的 seq2seq 模型。现在已经取得了大范围的应用和扩展,如 BERT 就是从 Transformer 中衍生出来的预训练语言模型。
下面将从以下几个部分详细介绍 Transformer 模型:
对 Transformer 的直观认识
Positional Encoding
Self Attention Mechanism
残差连接和 Layer Normalization
Transformer Encoder 整体结构
Transformer Decoder 整体结构
总结
参考文章
0x00 对 Transformer 的直观认识
Transformer 和 LSTM 的最大区别,就是 LSTM 的训练是迭代的、串行的,必须要等当前字处理完,才可以处理下一个字。而 Transformer 的训练时并行的,即所有字是同时训练的,这样就大大提高了了计算效率。
Transformer 模型主要分为两大部分,分别是 Encoder 和 Decoder。Encoder 负责把输入序列编码为隐藏层(下图中第 2 步用九宫格代表的部分),然后再由 Decoder 把隐藏层解码为自然语言序列。此外,Transformer 使用了位置嵌入 (Positional Encoding) 来理解语言的顺序,并使用自注意力机制(Self Attention Mechanism) 来学习上下文信息。

从图1中可以看出,Encoder 与 Decoder 的结构十分相似,因此本文将着重讨论 Encoder 部分,即重点分析自然语言序列映射为隐藏层的数学表达的过程。理解了 Encoder 的结构,再理解 Decoder 就很简单了。

下面我们将按照图2中的标注信息一步步解析各方框内部的结构与工作原理。
0x01 Positional Encoding
由于 Transformer 模型摒弃了 RNN 结构,因此需要一个“东西”来标记各个字之间的时序 or 位置关系,这样模型才能理解语言中的顺序关系。这个“东西”,就是位置嵌入,也就是 Positional Encoding 。
One possible solution to give the model some sense of order is to add a piece of information to each word about its position in the sentence. We call this “piece of information”, the positional encoding.
位置嵌入的维度与输入嵌入的维度是相同的,均为 [max_sequence_length, embedding_dimension]
,一般简记为 $[S, E]$ 。
1. 从0开始设计一个位置嵌入
在开始之前,首先需要明确一点,Transformer Encoder 的输入是输入嵌入与位置嵌入的简单相加(不是拼接,就是单纯的对应位置上的数值进行加和)。
这就意味着它的值应该是「有界」的,比如我们考虑线性地为每个时间步分配一个数字,即第一个单词为1,第二个为2,依此类推。这种「无界」的编码方式会导致位置嵌入的值过大,难免抢了输入嵌入的风头,对模型造成干扰。此外,最后一个单词要比第一个单词大太多,与输入嵌入合并后也难免会出现特征在数值上的倾斜。
那如果把它们规约到0-1之间呢?即第一个单词为0,最后一个单词为1,具体公式就是 $PE(pos) = \frac{pos}{S-1}$ ,其中 $pos$ 指的是一句话中某个字的位置,取值范围是 [0, max_sequence_length)。但这种方式又引入新的问题:假设较短文本总共30个字,较长文本总共90个字,那么在较短文本中任意两个字位置编码的差为 1/30,同时在某一个较长文本中也有两个字的位置编码的差是 1/30,但这两个字中间实际上隔了两个字。这显然是不合适的,因为相同的差值,在不同长度的句子中却表现出不同的含义。
2. 一个更加理想的设计
经过上面的讨论,我们知道在理想情况下,位置嵌入的设计应该满足以下条件:
- 它应该为每个字输出唯一的位置编码;
- 不同长度的句子中,任意两个字之间的差值应保持一致性;
- 它的值应该是有界的;
基于此,论文中使用了 sin 和 cos 函数的线性变换来提供给模型位置信息:
$$PE(pos,2i)=sin(\frac{pos}{10000^{2i/d_model}})$$
$$PE(pos,2i+1)=cos(\frac{pos}{10000^{2i/d_model}})$$
其中 $i$ 指的是字向量的维度序号,取值范围是 [0, d_model/2),$d_model$ 指的是 embedding_dimension 的值。
我们可以这样去理解上述公式:设 $t$ 为一句话中某个词的位置,$\boldsymbol{p}_t \in R^d$ 表示位置 $t$ 时刻这个词的位置嵌入向量,$\boldsymbol{p}_t$ 的定义如下:
$$\boldsymbol{p}_t = \begin{cases} sin(w_i, t) &\text{, if i是偶数;} \ cos(w_i, t) &\text{, if i是奇数;} \end{cases}$$
其中,$w_i = \frac{1}{10000^{2i/d}}$ ,$i$ 为 embedding 维度的下标。
随着 $i$ 的增大,$w_i$ 越来越小,周期变化会越来越慢,最终产生独一的纹理位置信息 —— 一个 $S \times E$ 维的位置嵌入矩阵 $PE$ ,从而使得模型学到位置之间的依赖关系和自然语言的时序特性。
3. 画个图来瞧瞧!
“Talk is cheap, show me the code!”
1 | import numpy as np |

图3可视化了 $PE$ 矩阵的热力图,可以看到,每个位置 $pos$ 都生成了唯一的位置嵌入。
1 | plt.figure(figsize=(8, 5)) |

从图4中可以看到,随着维度 $i$ 的增大,纹理越来越平缓,与前面的分析一致。
0x02 Self-Attention Mechanism
关于自注意力机制在 深度学习」Attention? Attention! 有过详细介绍,这里不再赘述其原理,直接看具体操作过程。
图示自注意力的过程
对于输入句子 $X$ ,通过 Input Embedding 得到每个字的字向量,同时通过 Positional Encoding 得到每个字的位置向量,二者相加,得到该字最终的向量表示,第 $t$ 个字记作 $x_t$ 。
接着我们定义三个矩阵 $W_Q, W_K, W_V$ ,使用这三个矩阵分别对所有的字向量进行三次线性变换,于是又衍生出三个新的向量 $q_t, k_t, v_t$ (见下图)。把这些向量分别拼接起来,得到三个大矩阵: 查询矩阵 Q、键矩阵 K、值矩阵 V。

为了获得第一个字的注意力权重,我们需要用第一个字的查询向量 q1 乘以键矩阵 K 的转置,然后将得到的值经过 softmax 进行归约,使得它们的和为 1(见下图):
1 | [0, 4, 2] |

有了权重之后,将权重其分别乘以对应字的值向量 $v_t$(见下图):
1 | 0.0 * [1, 2, 3] = [0.0, 0.0, 0.0] |

最后将这些权重化后的值向量求和,得到第一个字的输出(见下图):
1 | [0.0, 0.0, 0.0] |

对其它的输入向量也执行相同的操作,即可得到通过 self-attention 后的所有输出(见下图):

上面简单介绍了 self-attention 的计算过程,在实际操作中,论文还加入了一些小 tricks ,我们一一来介绍:
Trick1: 矩阵优化
上面介绍的方法需要一个循环遍历所有的字 $x_t$ ,我们可以把上面的向量计算变成矩阵运算的形式,从而一次计算出所有时刻的输出。
这样第一步就不是计算某个时刻的 $q_t,k_t,v_t$ 了,而是一次计算所有时刻的 $Q,K$ 和 $V$ 。

接下来将 $Q$ 和 $K^T$ 相乘,然后除以 $\sqrt{d_k}$ 缩放点积的值(Scaling the dot product),经过 softmax 以后再乘以 $V$ 即可得到最终的输出矩阵 $Z$。
- 关于为什么要缩放点积的值?
这是因为 softmax 函数对非常大的输入很敏感,可能会使梯度传播出现问题(kill the gradient),并且导致学习的速度下降(slow down learning),甚至学习的停止,因而需要除以 $\sqrt{d_k}$ 来对输入的向量做缩放。
- 那为什么要除以 $\sqrt{d_k}$ 呢?
我们想象一下,对于 $R^{k}$ 空间内的某个向量 $\alpha$ ,如果 $\alpha$ 所有坐标的值均为 c,那么 $\alpha$ 的欧式距离就是 $\sqrt{k}c$ ,除以 $\sqrt{k}$ 其实就是在除以向量平均的增长长度。

Trick2: 多头注意力机制
这篇论文还提出了 Multi-Head Attention 的概念。其实原理很简单,就像现实中我们每个人对一篇文章的理解都不同,把这些不同的理解综合提炼出来,结果一定更优。
具体实现上,我们可以定义多组 $Q,K,V$ 矩阵,让它们分别关注不同的上下文。$Q,K,V$ 的计算过程同原来一样,只不过线性变换的矩阵从一组 $(W^Q,W^K,W^V)$ 变成了多组 $(W_0^Q,W_0^K,W_0^V) ,(W_1^Q,W_1^K,W_1^V),\dots$ 。如图7所示:

对于输入矩阵 $X$ ,每一组 $Q,K,V$ 都可以得到一个输出矩阵 $Z$ ,如图8所示:

最后把这些输出矩阵 $Z_i$ 拼接在一起,并通过一个全连接层,将 embedding 维重新映射回 $d_k$ ,这样我们就得到了一个表示能力更强的矩阵。
Narrow and wide self-attention
通常,我们有两种方式来实现multi-head的self-attention:
默认的做法是我们会把embedding的向量切割成块,比如说我们有一个256大小的embedding vector,并且我们使用8个attention head,那么我们会把这vector切割成8个维度大小为32的块。对于每一块,我们生成它的queries,keys和values,它们每一个的size都是32,那么也就意味着我们矩阵 $W^Q, W^K, W^V$ 的大小都是 $32 \times 32$ 。
还有一种方法是,我们可以让矩阵 $W^Q, W^K, W^V$ 的大小都是 $256 \times 256$ ,并且把每一个attention head都应用到全部的256维大小的向量上。
显然,第一种方法的速度会更快,并且能够更节省内存,第二种方法能够得到更好的结果(同时也花费更多的时间和内存)。这两种方法分别叫做narrow and wide self-attention。
Trick3: Padding & Mask
在实际训练时,我们通常会采用 mini-batch 的方式来计算,也就是一次计算多句话的注意力,这样输入 $X$ 的维度就成了 [batch_size, sequence_length, embedding_dimension]
。由于不同句子可能不等长,因此我们需要按照这个 mini-batch 中最大的句长对剩余的句子进行补齐,一般用 0 进行填充,这个过程叫做 padding 。

但这时在进行 softmax 就会产生问题。我们回顾一下 softmax 函数:
$$\sigma(z_i) = \frac{e^{z_i}}{\sum^K_{j=1} e^{z_j}}$$
当 $z_i = 0$ 时,$e^0 = 1$ ,是有值的。这就相当于被 padding 的无效部分也参与了运算,这可能会产生很大的隐患。
因此,需要做一个 mask 操作,使这些无效区域不参与运算,一般可以通过增加一个很大的负数偏置来实现:
$$Z_{padding} = Z_{padding} + -\infin$$
0x03 Add & Norm
残差连接
Add 实际上就是做一个残差连接。我们在上一步得到了经过经过注意力矩阵加权之后的 $V$ ,也就是 $Attention(Q, K, V)$ ,我们对它进行一下转置,使其和 $X_{emb}$ 的维度一致,即 [batch_szie, sequence_length, embedding_dimension]
,然后把它们加起来做残差连接:
$$X_{emb} + Attention(Q, K, V)$$
Layer Normalization
Batch Normalization 是以batch为单位计算归一化统计量的,但在序列任务中,通常各个样本的长度都是不同的,具体表现为特征维度不同。这时,在padding区域采样得到的统计信息并不能反映全局分布,比如一个batch中有32个样本,一个样本长度为90,其余均为30,那么在padding的60个维度上,只有一个样本点包含有效信息,显然它无法反映全局分布,因而会导致BN的效果不好。
Layer Normalization 则是对同一个样本的所有特征做归一化统计量,它是一个独立于batch size的算法,针对网络中某一层的所有神经元的输入进行归一。

更多有关 normalization 的内容请参阅这篇文章。
0x04 Feed-Forward Network
该部分的主要结构就是一个加入了 Add & Norm 的全连接层,只需注意每个 Encoder Block 中的 FeedForward 层权重都是共享的即可。
图11展示了更多地细节:

输入 $x_1,x_2$ 经 self-attention 层之后变成 $z_1,z_2$,然后和输入 $x_1,x_2$ 进行残差连接,经过 LayerNorm 后输出给全连接层,全连接层后再经过一次残差连接和 LayerNorm 便得到 encoder 的输出。
0x05 Transformer Encoder 整体结构
经过前面的介绍,我们已经基本了解了 Transformer Encoder 的主要构成部分,注意到图1所示 Encoder 的整体结构中,有个 $N \times$ ,这意味着 transformer 实际上包含了 N 个这样的 encoder block 连接在一起。
下面从数学的角度再回顾一下整个 encoder 的过程:
1)字向量与位置编码
$$X = Embedding(X) + Positional\ Encoding$$
2)自注意力机制
$$Q = Linear(X) = X \cdot W_Q$$
$$K = Linear(X) = X \cdot W_K$$
$$V = Linear(X) = X \cdot W_V$$
$$X_{attn} = Attention(Q, K, V)$$
3)Self-Attention 残差连接与 LayerNorm
$$X_{attn} = X + X_{attn}$$
$$X_{attn} = LayerNorm(X_{attn})$$
4)Feed-Forward
$$X_{hidden} = Linear(ReLU(Linear(X_{attn})))$$
5)Feed-Forward 残差连接与 LayerNorm
$$X_{hidden} = X_{attn} + X_{hidden}$$
$$X_{hidden} = LayerNorm(X_{hidden})$$
0x06 Transformer Decoder 整体结构
我们先整理观察一下 Decoder 结构,从下到上依次是:
Masked Multi-Head Self-Attention
Multi-Head Encoder-Decoder Attention
FeedForward Network
注意到这三个部分都包含了一个 Add & Norm 的操作。

1. Masked Attention
具体来说,传统 Seq2Seq 中 Decoder 使用的是 RNN 模型,因此在训练过程中输入 $t$ 时刻的词,模型无论如何也看不到未来时刻的词,因为循环神经网络是由时间驱动的,只有当 $t$ 时刻运算结束了,才能看到 $t+1$ 时刻的词。
而 Transformer Decoder 抛弃了 RNN 结构,改为 Self-Attention,这就导致了一个问题:在训练过程中,整个 ground truth 都暴露在 Decoder 中,这显然是不对的。因而我们需要对 Decoder 的输入进行一些处理,该处理被称为 Mask。
举个例子,假设 Decoder 的 ground truth 为 “<start> I am fine” ,我们将这个句子输入到 Decoder 中:
经过 Word Embedding 和 Positional Encoding 之后得到 $X$ ;
对 $X$ 做三次线性变换 $W_Q,W_K,W_V$ ,得到 $Q,K,V$ ;
然后进行 self-attention 操作:
- 首先通过 $\frac{Q \times K^T}{\sqrt{d_k}}$ 得到 Scaled Scores ;
- 接下来非常关键,我们要对 Scaled Scores 进行 Mask 操作 :首先生成一个下三角全 0,上三角全为负无穷的矩阵,然后将其与 Scaled Scores 相加。
之后再做 softmax,就能将 - inf 变为 0,得到注意力权重矩阵;
最后将注意力权重矩阵与 $V$ 相乘即得到 self-attention 的输出。
至于 Multi-Head,无非就是并行的对上述步骤多做几次,前面在 Encoder 中已经介绍过,这里就不多赘述了。
2. Masked Encoder-Decoder Attention
其实这一部分的计算流程和前面 Masked Self-Attention 十分相似,结构也一摸一样,唯一的区别在于 $Q,K,V$ 的来源不同:
- 在这一步中, $K,V$ 是 Encoder 的输出,而 $Q$ 是 Decoder 中 Masked Self-Attention 的输出。

3. Feed-Forward
全连接层 + Add & Norm,不再赘述。
0x07 总结
至此,Transformer 的内容已经基本介绍完毕,我们用一张图抽象出其完整结构:

问题整理
下面有几个问题,是 wmathor 从网上找的,感觉看完之后能对 Transformer 有一个更深的理解。
Transformer 为什么需要进行 Multi-head Attention?
原论文中说到进行 Multi-head Attention 的原因是将模型分为多个头,形成多个子空间,可以让模型去关注不同方面的信息,最后再将各个方面的信息综合起来。其实直观上也可以想到,如果自己设计这样的一个模型,必然也不会只做一次 attention,多次 attention 综合的结果至少能够起到增强模型的作用,也可以类比 CNN 中同时使用多个卷积核的作用,直观上讲,多头的注意力有助于网络捕捉到更丰富的特征 / 信息。
Transformer 相比于 RNN/LSTM,有什么优势?为什么?
- RNN 系列的模型,无法并行计算,因为 T 时刻的计算依赖 T-1 时刻的隐层计算结果,而 T-1 时刻的计算依赖 T-2 时刻的隐层计算结果
- Transformer 的特征抽取能力比 RNN 系列的模型要好
为什么说 Transformer 可以代替 seq2seq?
这里用代替这个词略显不妥当,seq2seq 虽已老,但始终还是有其用武之地,seq2seq 最大的问题在于将 Encoder 端的所有信息压缩到一个固定长度的向量中,并将其作为 Decoder 端首个隐藏状态的输入,来预测 Decoder 端第一个单词 (token) 的隐藏状态。在输入序列比较长的时候,这样做显然会损失 Encoder 端的很多信息,而且这样一股脑的把该固定向量送入 Decoder 端,Decoder 端不能够关注到其想要关注的信息。Transformer 不但对 seq2seq 模型这两点缺点有了实质性的改进 (多头交互式 attention 模块),而且还引入了 self-attention 模块,让源序列和目标序列首先 “自关联” 起来,这样的话,源序列和目标序列自身的 embedding 表示所蕴含的信息更加丰富,而且后续的 FFN 层也增强了模型的表达能力,并且 Transformer 并行计算的能力远远超过了 seq2seq 系列模型。
0x08 参考文章
[1] Transformer详解
[2] 详解深度学习中“注意力机制”
[4] Transformer 中的 Positional Encoding