本章是Transformers精讲,并配备哈佛版的基于Pytorch的实现代码
一、宏观角度
1、首先将该模型视为一个黑匣子。 在机器翻译应用程序中,它将采用一种语言的句子,并以另一种语言输出其翻译。
2、继续瓦解,可以看到一个编码组件、一个解码组件以及它们之间的连接。
3、编码组件是一堆Encoder
(论文将其中六个编码器堆叠在一起 - 6 没有什么神奇之处,绝对可以尝试其他排列)。 解码组件是相同数量的Decoder
的堆栈。
4、这些编码器在结构上都是相同的(但它们不共享权重)。 每一层又分为两个子层:
Encoder
的输入首先流经Self-Attention
,该层帮助编码器在对特定单词进行编码时查看输入句子中的其他单词。 Self-Attention
将在后续小节详细介绍。
Self-Attention
的输出被馈送到前馈神经网络。 完全相同的前馈网络独立应用于每个位置。
5、Decoder
具有这两个层,但它们之间是一个注意力层,帮助解码器关注输入句子的相关部分(类似于 seq2seq
模型中注意力的作用)。
6、有了宏观的感受,再来看一下原论文中的图

拆解完再回过来看是不是更清晰,接下来我将逐一介绍其中的各个模块,并配有Pytorch
代码实现
二、Self-Attention
2.1 理论部分
其实按照模型流程应该先介绍输入部分
输入部分包括:词向量嵌入 + 位置向量嵌入(对应维度直接相加)
但是,先讲完Self-Attention
,你就会明白为什么光有词向量嵌入还不够,还需要位置向量嵌入
正式开始Self-Attention
假设我们已经得到了模型的输入,每个单词都嵌入到大小为 512 的向量中。我将用这些简单的框表示这些向量
将单词嵌入到输入序列中后,每个单词都会流经Encoder
的两个子层。
接下来,我将示例切换为较短的句子,去查看编码器的每个子层中发生的情况
正如已经提到的,Encoder
接收向量列表作为输入。 它通过将这些向量传递到Self-Attention
层,然后传递到前馈神经网络,然后将输出向上发送到下一个Encoder
来处理该列表。
Self-Attention
即自注意力机制,感动陌生很正常,因为当时正是在这篇论文中提出的
下面我将介绍到底什么叫自注意力机制
假设以下句子是要翻译的输入句子:
”The animal didn't cross the street because it was too tired
”
这句话中的it
指的是什么? 指的是街道还是动物? 这对人类来说是一个简单的问题,但对算法来说就不那么简单了。
当模型处理it
这个词时,自注意力使其能够将it
与“动物”联系起来。
当模型处理每个单词(输入序列中的每个位置)时,自注意力允许它查看输入序列中的其他位置以寻找有助于更好地编码该单词的线索。
你肯定还是不明白它是怎么做的,接下来是细节部分
我们首先看看如何使用向量计算自注意力,然后继续看看它是如何实际实现的——使用矩阵。
计算自注意力的第一步是从每个编码器的输入向量(在本例中为每个单词的嵌入)创建三个向量。 因此,对于每个单词,我们创建一个查询向量、一个键向量和一个值向量。 这些向量是通过将嵌入乘以我们在训练过程中训练的三个矩阵来创建的。
将 x1
乘以 Wq
权重矩阵会产生 q1
,即与该单词关联的“查询”向量。 我们最终为输入句子中的每个单词创建一个“查询”、一个“键”和一个“值”投影。
这里注意一下维度, 在论文中的Embedding
维度d_model = 512
,给出的Key
维度和Value
维度均为64
即d_k = d_v = d_model / h = 64
,那么对应QKV
的矩阵Wq
、Wk
、Wv
大小都应该是(512,64)
这样就能根据输入得到一个查询向量q1
,一组键值对<k1,v1>
有了QKV
, 接下来需要按照Attention
的流程计算q1
和k1
的Score
根据论文中提到的缩放点积注意力(Scaled Dot-Product Attention):

先进行点积, 再进行缩放, 计算完q1
与句中所有单词的k1,k2……kn
的得分(这里采用点积得到)后, 再对Score
除以根号下d_k
, 完成缩放, 最后再通过Softmax
得到Attention
权重, 加权求和结果称为z1
上面的讨论全部都是针对一个单词的, 但是在实际的运算中, 由于Encoder
是线性Stack
起来的, 所以其实Encoder
的训练是可以并行的, 即多个单词做完Embedding后作为一个矩阵并行计算, 假设输入矩阵X
,通过Wq
、Wk
、Wv
计算后可以得到Q、K、V
:

最后,由于我们处理的是矩阵,我们可以将上述步骤压缩为一个公式来计算自注意力层的输出:
$$ Attention\left( Q,K,V \right) \ =\ Soft\max \left( \frac{QK^T}{\sqrt[]{d_k}} \right) V $$

这里除以根号下 d_k 的解释:
当d_k
非常大时, 求得的内积可能会非常大, 如果不进行缩放, 不同的内积大小可能差异会非常大, Softmax
在指数运算可能将梯度推到特别小, 导致梯度消失
当 d_k
较大时,很有可能存在某个 key
,其与query
计算出来的对齐分数远大于其他的key
与该 query
算出的对齐分数。这时,softmax
函数对另外的qk
偏导数都趋于 0.
这样结果就是,softmax
函数梯度过低(趋于零),使得模型误差反向传播经过softmax
函数后无法继续传播到模型前面部分的参数上,造成这些参数无法得到更新,最终影响模型的训练效率
2.2 代码实现
Pytorch代码实现如下
1 | def attention(query, key, value, mask=None, dropout=None): |
query
,key
,和value
的维度是(batch_size, seq_len, d_model)
,其中seq_len
是输入序列的长度,d_model
是模型的隐藏单元数在线性映射的步骤中,
l(x).view(nbatches, -1, self.h, self.d_k).transpose(1, 2)
使用了多头 (self.h
) 和d_k
,它将query
,key
,和value
映射到了(batch_size, h, seq_len, d_k)
的维度torch.matmul(query, key.transpose(-2, -1))
计算注意力分数,其中query
和key
经过了transpose
操作以匹配维度,最终得到的分数维度是(batch_size, h, seq_len, seq_len)
,在最后两个维度的行上做了softmax
在
mask
步骤中,如果有掩码,就使用scores.masked_fill(mask == 0, -1e9)
将不应考虑的位置的分数设置为一个极小的值-1e9
通过
F.softmax(scores, dim=-1)
对分数进行softmax
操作,得到注意力权重p_attn
,其维度为(batch_size, h, seq_len, seq_len)
F.softmax(scores, dim=-1)
如果是一个二维的张量,dim=0
表示在列上做softmax
,dim=1
表示在行上做softmax
最后通过
torch.matmul(p_attn, value)
得到经过注意力权重调节后的值,其维度为(batch_size, h, seq_len, d_k)
batch_size
是每个 batch 的大小,seq_len
是序列的长度,h
是头数,d_k
是每个头的隐藏单元数。在多头注意力机制中,通过对头数进行拼接,最后的输出维度为(batch_size, seq_len, h * d_k)
2.3 多头注意力机制
多头的思路和
CNN
中的多个卷积核起到的作用明显是一致的. 所谓”多头”, 放在卷积神经网络里就是卷积层多个卷积核的特征提取过程, 在这里就是进行多次注意力的提取, 就像多个卷积核一样, 多次不同的初始化矩阵经过训练可能会有多种不同的特征, 每个头用于将输入嵌入投影到不同的表示子空间中,更有利于不同角度的特征抽取和信息提取。通过多头注意力,为每个头维护单独的
Q/K/V
权重矩阵,从而产生不同的Q/K/V
矩阵。 正如我们之前所做的那样,我们将X
乘以WQ/WK/WV
矩阵以生成Q/K/V
矩阵。如果进行与上面概述相同的自注意力计算,只是使用不同的权重矩阵进行八次不同的计算,我们最终会得到八个不同的
Z
矩阵但是接下来的前馈层不需要八个矩阵——它需要一个矩阵(每个单词一个向量)
所以我们需要一种方法将这八个压缩成一个矩阵
该怎么做呢? 将矩阵连接起来,然后将它们乘以一个附加的权重矩阵
Wo
这几乎就是多头自注意力的全部内容。 这是相当多的矩阵。
用下图进行汇总
Pytorch代码实现如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31class MultiHeadedAttention(nn.Module):
def __init__(self, h, d_model, dropout=0.1):
super(MultiHeadedAttention, self).__init__()
# 判断d - model % h是否有余数,如果有就报错
assert d_model % h == 0
# d-model一般为512(序列符号的embedding长度),h是头数一般为8
self.h = h
# 两者相除得到d_k的长度,即query、key矩阵中的列数
self.d_k = d_model // h
# 这里定义的4个线性层, 相当于Wq、Wk、Wv、Wo四个投影矩阵
self.linears = clones(nn.Linear(d_model, d_model), 4)
self.attn = None
self.dropout = nn.Dropout(p=dropout)
def forward(self, query, key, value, mask=None):
if mask is not None:
# Same mask applied to all h heads.
mask = mask.unsqueeze(1)
nbatches = query.size(0)
# 1) Do all the linear projections in batch from d_model => h x d_k
# zip中的(query, key, value)相当于原始的输入, 即 词嵌入 + 位置嵌入
# l(x) 相当于原始嵌入输入过了Wq、Wk、Wv三个映射矩阵得到query, key, value
# 然后再reshape到 nbatches * head * seq_len * d_k(64)
query, key, value = [l(x).view(nbatches, -1, self.h, self.d_k).transpose(1, 2) for l, x in zip(self.linears, (query, key, value))]
# 2) Apply attention on all the projected vectors in batch.
x, self.attn = attention(query, key, value, mask=mask, dropout=self.dropout)
# 3) "Concat" using a view and apply a final linear.
x = x.transpose(1, 2).contiguous().view(nbatches, -1, self.h * self.d_k)
return self.linears[-1](x)
三、Positional Encoding
3.1 理论部分
正式因为Transformer
采用了纯粹的Attention
结构, 不像RNN
一样能够通过时间步来反映句子中单词的前后关系, 即不能得知位置信息。要知道, 在NLP
任务中, 语序是一个相当重要的属性, 所以必须要通过某种方式让Transformer
得知单词的位置, 作者通过位置编码在每次进入Encoder
和Decoder
前将位置信息写入。这样来看, 与其叫位置编码, 不如叫位置嵌入。
位置编码可以直接与Embedding
的向量相加:
那这个位置编码是怎么得到的呢?
作者的做法非常有意思, 对不同的单词位置, 不同的Embedding
维度, 它的编码都是唯一的, 应用正弦和余弦函数也方便Transformer
学到位置的特征. 如果将当前单词位置记为pos
, 而词向量的某个维度记为i
, 那么位置编码的方法为:
如果我们假设嵌入的维数为4
,则实际的位置编码将如下所示:
根据上述PE
的位置编码公式,在这个式子中, 编码周期不受单词位置影响, 仅仅与模型开始设计的d_model
和Embedding
的不同维度i
相关
对于不同的i
,根据三角函数的周期公式T = 2Π / w
,i
的范围是[0,256]
可以得到PE
的的周期变化范围是[2Π,10000 * 2Π ]
这样看,同一位置上的词语, 对于不同的Embedding
维度, 都得到不同的编码, 并且随着i
的增大, 位置编码的值的变化就越来越慢. 这种编码对于不同维度的Embedding
来说是唯一的, 因此模型能够学习到关于Embedding
的位置信息。
为什么会选择如上公式呢?作者表示:
已知三角函数公式如下:
偏移k
后,得到的PE
如下:
作者希望借助上述绝对位置的编码公式,让模型能够学习到相对位置信息
3.2 代码实现
Pytorch代码实现如下
1 | class Embeddings(nn.Module): |
四、Residuals + LN + FFN
在继续之前,需要提及Encoder
架构中的一个细节,即每个编码器中的每个子层(自注意力,ffnn
)周围都有一个残差连接,并且后面是层归一化步骤。
4.1 残差连接
如果要可视化与自注意力相关的向量和层归一化操作,如下图:
这也适用于Decoder
的子层。 如果我们考虑一个由 2
个堆叠编码器和解码器组成的 Transformer
,它看起来会是这样的:
4.2 层归一化
Layer Norm
也是一种类似于Batch Norm
的归一化方式, 同样能起到加快收敛的作用, 在NLP任务中比较常用
Batch Norm
中, 记录下一个Batch
中每维Feature
的均值和方差, 并进行放缩和平移, 即对不同样本的同一个通道特征进行归一化
在Layer Norm
中, 只是换了一个维度, 我们对同一个样本的不同通道进行归一化
BN和LN的区别:
主要区别在于 normalization的方向不同!
Batch
顾名思义是对一个batch
进行操作。假设我们有 10行 3列 的数据,即我们的batchsize = 10
,每一行数据有三个特征,假设这三个特征是**[身高、体重、年龄]。那么BN
是针对每一列(特征)进行缩放,例如算出[身高]**的均值与方差,再对身高这一列的10个数据进行缩放。体重和年龄同理。这是一种“列缩放”。
而layer
方向相反,它针对的是每一行进行缩放。即只看一笔数据,算出这笔所有特征的均值与方差再缩放。这是一种“行缩放”。
细心的你已经看出来,layer normalization
对所有的特征进行缩放,这显得很没道理。我们算出一行这**[身高、体重、年龄]**三个特征的均值方差并对其进行缩放,事实上会因为特征的量纲不同而产生很大的影响。但是BN
则没有这个影响,因为BN
是对一列进行缩放,一列的量纲单位都是相同的。
那么我们为什么还要使用LN
呢?因为NLP
领域中,LN
更为合适。
如果我们将一批文本组成一个batch
,那么BN
的操作方向是,对每句话的第一个词进行操作。但语言文本的复杂性是很高的,任何一个词都有可能放在初始位置,且词序可能并不影响我们对句子的理解。而BN
是针对每个位置进行缩放,这不符合NLP的规律。
而LN
则是针对一句话进行缩放的,且LN一般用在第三维度,如[batchsize, seq_len, dims]
中的dims
,一般为词向量的维度,或者是RNN
的输出维度等等,这一维度各个特征的量纲应该相同。因此也不会遇到上面因为特征的量纲不同而导致的缩放问题。
BN
感觉是对样本内部特征的缩放,LN
是样本直接之间所有特征的缩放。为啥BN
不适合NLP
是因为NLP模型训练里的每次输入的句子都是多个句子,并且长度不一,那么 针对每一句的缩放才更加合理,才能表达每个句子之间代表不同的语义表示,这样让模型更加能捕捉句子之间的上下语义关系。如果要用BN
,它首先要面临的长度不一的问题。有时候batch size
越小的bn
效果更不好
4.3 代码实现
Pytorch代码实现如下
1 | class LayerNorm(nn.Module): |
五、Decoder
5.1 理论部分
现在我们已经涵盖了Encoder
方面的大部分概念,我们基本上也知道了解码器的组件是如何工作的
但让我们看看它们是如何协同工作的
Encoder
首先处理输入序列。 然后,顶部Decoder
的输出被转换为一组注意力向量K
和 V
。这些向量将由每个Decoder
在其Encoder-Decoder Attention
层中使用,这有助于Decoder
关注输入序列中的适当位置:
以下步骤重复该过程,直到到达特殊符号,指示Decoder
已完成其输出。 每个步骤的输出在下一个时间步骤中被馈送到底部Decoder
,并且Decoder
像编码器一样冒泡其解码结果
我们将位置编码嵌入并添加到这些Decoder
输入中以指示每个单词的位置,这和处理Encoder
的输入时一样
但是我们要知道Decoder
中的Attention
层的运行方式与Encoder
中的运行方式略有不同:
在Decoder
中,Attention
层只允许关注输出序列中较早的位置。 这是通过在自注意力计算中的 softmax
步骤之前屏蔽未来位置(将它们设置为 -inf)来完成的,因为在推理的时候肯定不能看到后面的结果
“Encoder-Decoder Attention”层的工作方式与多头自注意力类似,只不过它从其下面的层创建查询矩阵,并从Encoder最后一层的输出中获取键和值矩阵
若仍然沿用传统Seq2Seq+RNN
的思路, Decoder是一个顺序操作的结构, 我们代入一个场景来看看。假设我们要执行机器翻译任务, 要将我 是 学生
翻译为I am Student
, 假设所有参数与论文中提到的参数一样, batch size
视为1. 根据前面已知的知识, Encoder
堆叠后的输入和Embedding
的大小是相同的
在这里有三个词语, Embedding
且通过Encoder
后的编码大小为(3,512)
。下面对Decoder
进行训练:
- 将起始符
<start>
作为初始Decoder输入, 经过Decoder处理和分类得到输出I
. - 将
<start> I
作为Decoder输入, 经过Decoder处理和分类得到输出am
. - 将
<start> I am
作为Decoder输入, 经过Decoder处理和分类得到输出Student
. - 将
<start> I am student
作为Decoder输入, 经过Decoder处理和分类得到结束符<end>
.
这种预测的方式也称为自回归
如果想做到并行训练, 需要将上面的过程转化为一个这样的矩阵直接作为Decoder
的输入
因为在训练时已知任务标签, 所以可以产生类似的效果,这种方法被称为Teacher Forcing
在论文的图中, Mask
操作顺序被放在Q
和K
计算并缩放后, Softmax
计算前。如果继续计算下去, 不做Mask
, 与V
相乘后得到Attention
, 所有时间步信息全部都被泄露给Decoder
, 必须用Mask
将当前预测的单词信息和之后的单词信息全部遮住。
遮住的方法非常简单, 首先不能使用0
进行遮盖, 因为Softmax
中用零填充会产生错误, e^0=1
. 所以必须要用−∞
来填充那些不能被看见的部分. 我们直接生成一个下三角全为0
, 上三角全部为负无穷的矩阵, 与原数据相加就能完成遮盖的效果
做Softmax
时, 所有的负无穷全变成了0
, 不再干扰计算:
其实Mask
在对句子的无效部分
PS:Decoder
仍然依赖与先前输出结果作为输入, 所以在正式使用时不能实现并行预测, 但在训练的时结果是已知的, 可以实现并行训练
5.2 代码实现
Pytorch代码实现如下
1 | class Decoder(nn.Module): |
1 | class DecoderLayer(nn.Module): |
六、The Final Linear and Softmax Layer
Decoder
堆栈输出浮点数向量。 我们如何把它变成一个词?
这就是最后一个Linear
层的工作,后面是 Softmax
层
线性层是一个简单的全连接神经网络,它将Decoder
堆栈产生的向量投影到一个更大的向量中,称为logits
向量
假设我们的模型知道从训练数据集中学习的 10000 个独特的英语单词(我们模型的“输出词汇”)
这将使 logits
向量有 10000 个单元格宽——每个单元格对应一个唯一单词的分数。 这就是我们解释线性层模型输出的方式。
然后,Softmax
层将这些分数转换为概率(全部为正,全部加起来为 1.0)。 选择概率最高的单元格,并生成与其关联的单词作为该时间步的输出。