引言

Transformer的重要性不用多说了吧,NLP现在最火了两个模型——BERT和GPT,一个是基于它的编码器实现的,另一个是基于它的解码器实现的。

凡是我不能创造的,我都不能理解。

为了更好的理解Transformer​1​模型,我们需要知道它的实现细节。本文我们就如庖丁解牛一般,剖析它的原理与实现细节——通过Pytorch实现。

为了更好的理解Transformer,英文阅读能力好的建议先看一下它的原始论文​1​​,以及两篇非常好的解释文章(​​这里​​​和​​这里​​)。本文会结合这些文章的内容,争取阐述清楚每个知识点。由于内容有点多,可能会分成三篇文章。

为了方便,本文把原文的翻译结果也贴出来,翻译放到引用内。

背景

深入浅出Transformer(一)_如何理解Transformer

循环模型通常是对输入和输出序列的符号位置进行因子计算。在计算期间对齐位置和时间步,基于前一时间步的隐藏状态深入浅出Transformer(一)_归一化_02和当前时间步深入浅出Transformer(一)_位置编码_03的输入,循环生成了一系列隐藏状态深入浅出Transformer(一)_Transformer详解_04。这种固有的顺序特性排除了训练的并行化,这在更长的序列中成为重要问题,因为有限的内存限制了长样本的批次化。虽然最近有学者通过因子分解和条件计算技巧重大的提升了计算效率,同时提升了模型的表现。但是序列计算的基本限制仍然存在。

注意力机制已经变成了序列建模和各种任务中的转导模型的必备成分,允许为依赖建模而不必考虑输入和输出序列中的距离远近。除了少数情况外,这种注意力机制都与循环神经网路结合使用。

本文我们提出了Transformer,一个移除循环网络、完全基于注意力机制来为输入和输出的全局依赖建模的模型。Transformer 允许更多的并行化,并且翻译质量可以达到最牛逼水平,只需要在8个P100 GPU上训练12个小时。

模型架构

Transformer模型抛弃了RNN和CNN,是一个完全利用自注意去计算输入和输出的编码器-解码器模型。并且它还可以并行计算,同时计算效率也很高。

模型整体架构如图1所示。

深入浅出Transformer(一)_位置编码_05

大部分有竞争力的神经网络序列转导模型都有一个编码器-解码器(Encoder-Decoder)结构。编码器映射一个用符号表示的输入序列深入浅出Transformer(一)_Transformer详解_06到一个连续的序列表示深入浅出Transformer(一)_如何理解Transformer_07。给定深入浅出Transformer(一)_如何理解Transformer_08,解码器生成符号的一个输出序列深入浅出Transformer(一)_位置编码_09,一次生成一个元素。在每个时间步,模型是自回归(auto-regressive)的,在生成下个输出时消耗上一次生成的符号作为附加的输入。

Transformer沿用该结构并在编码器和解码器中都使用叠加的自注意和基于位置的全连接网络,分别对应图1左半部和右半部。

我们先来看左边编码器部分。

编码器

深入浅出Transformer(一)_如何理解Transformer_10

编码器是上图左边红色部分,解码器是上图右边蓝色部分。

编码器: 编码器是由深入浅出Transformer(一)_编码器_11个相同的层(参数独立)堆叠而成的。

上图中的深入浅出Transformer(一)_位置编码_12是叠加深入浅出Transformer(一)_编码器_13次的意思,原文中编码器是由6个相同的层堆叠而成的。如下图所示:

深入浅出Transformer(一)_归一化_14

低层编码器的输出作为上一层编码器的输入。

深入浅出Transformer(一)_位置编码_15

每层都有两个子层(sub-layer),第一个子层是多头注意力层(Multi-Head Attention),第二个是简单的基于位置的全连接前馈神经网络(positionwise fully connected feed-forward network)。

意思是每个编码器层都是由两个子层组成,第一个是论文中提出的多头注意力,这个比较重要,可以说是该篇论文的核心,理解了多头注意力整篇论文就理解的差不多了。后面会详细探讨。 经过多头注意力之后先进行残差连接,再做层归一化。

我们在两个子层周围先进行残差连接,然后进行层归一化(Layer Normalization)。这样,我们每个子层的输出是深入浅出Transformer(一)_位置编码_16,其中深入浅出Transformer(一)_编码器_17是子层自己实现的函数。为了利用残差连接,该模型中的所有子层和嵌入层,输出的维度都统一为深入浅出Transformer(一)_位置编码_18

残差连接体现在上图的​​Add​​​,层归一化就是上图的​​Norm​​。残差连接名字很唬人,其实原理非常简单,如下图:

深入浅出Transformer(一)_如何理解Transformer_19

假设网络中某层输入深入浅出Transformer(一)_归一化_20后的输出为深入浅出Transformer(一)_Transformer详解_21,不管激活函数是什么,经过深层网络都可能导致梯度消失的情况。增加残差连接,相当于某层输入深入浅出Transformer(一)_归一化_20后的输出为深入浅出Transformer(一)_位置编码_23。最坏的情况相当于没有经过深入浅出Transformer(一)_Transformer详解_21这一层,直接输入到高层,这样高层的表现至少能和低层一样好。

而层归一化针对每个输入的每个维度进行归一化操作。假设有深入浅出Transformer(一)_编码器_25个维度,深入浅出Transformer(一)_归一化_26,层归一化首先计算这深入浅出Transformer(一)_编码器_25个维度的均值和方差,然后进行归一化得到深入浅出Transformer(一)_归一化_28,接着做一个缩放,类似批归一化。

深入浅出Transformer(一)_编码器_29
其中深入浅出Transformer(一)_Transformer详解_30就是LN层的输出,深入浅出Transformer(一)_编码器_31是点乘操作,深入浅出Transformer(一)_编码器_32深入浅出Transformer(一)_如何理解Transformer_33是输入各个维度的均值和方差,深入浅出Transformer(一)_位置编码_34深入浅出Transformer(一)_位置编码_35是两个可学习的参数,和深入浅出Transformer(一)_Transformer详解_30的维度相同。

Transformer中输入的维度深入浅出Transformer(一)_位置编码_37

下面我们通过Pytorch实现上面编码器中介绍的部分,Pytorch版的Transformer依据的是另一个神作​2​,也是一篇论文,里面完整的实现了Transformer。本文的实现也是根据这篇论文来的,他们的代码写得非常优雅,从可重用性和抽象性来看,体现了非常高的技术,值得仔细研究学习。

import numpy as np
import torch
import torch.nn as nn
import math, copy, time
from torch.autograd import Variable
import matplotlib.pyplot as plt
import seaborn # # seaborn在可视化self-attention的时候用的到
seaborn.set_context(context="talk")

# 防止jupyter plt.show崩溃
import os
os.environ['KMP_DUPLICATE_LIB_OK']='True'

首先导入所有需要的包。然后我们定义一个克隆函数,Transformer中多处用到了叠加,叠加就可以通过克隆来实现。

def clones(module, N):
'''
生成N个相同的层
'''
# 每个进行的都是深克隆
return nn.ModuleList([copy.deepcopy(module) for _ in range(N)])

​ModuleList​​可以和Python普通列表一样进行索引,但是里面的模型会被合理的注册到网络中,这样里面模型的参数在梯度下降的时候进行更新。下面来看编码器的代码实现。

class Encoder(nn.Module):
'''
Encoder堆叠了N个相同的层,下层的输出当成上层的输入
'''

def __init__(self, layer, N):
super(Encoder, self).__init__()

self.layers = clones(layer, N)
self.norm = LayerNorm(layer.size)

def forward(self, x, mask):
'''
依次将输入和mask传递到每层
:param x: [batch_size, input_len, emb_size]
'''
for layer in self.layers:
# 下层的输出当成上层的输入
x = layer(x, mask)
# 最后进行层归一化
return self.norm(x)

编码器的输入是前文中提到的子层(sub-layer),因此这里克隆了深入浅出Transformer(一)_编码器_13份子层,由于用的是深克隆,虽然模型是一模一样的,但是每个模型学到的参数肯定是不同的。

注意这里输入​​mask​​​的作用,编码器输入​​mask​​​一般是在进行批处理时,由于每个句子的长度可能不等,因此对于过短的句子,需要填充​​<pad>​​​字符,一般用深入浅出Transformer(一)_如何理解Transformer_39表示,而这里的​​​mask​​就能标出哪些字符为填充字符,这样可以不需要进行计算,以提高效率。

注意这里用到的的层归一化,是对整个编码器的输出进行层归一化,即在编码器最终结果输出到解码器之前,做的层归一化。

深入浅出Transformer(一)_编码器_40

下面看一下层归一化​​LayerNorm​​的实现。

class LayerNorm(nn.Module):
'''
构建一个层归一化模块
'''

def __init__(self, features, eps=1e-6):
super(LayerNorm, self).__init__()
self.a_2 = nn.Parameter(torch.ones(features))
self.b_2 = nn.Parameter(torch.zeros(features))
self.eps = eps

def forward(self, x):
'''
:param x: [batch_size, input_len, emb_size]
'''
# 计算最后一个维度的均值和方差
mean = x.mean(-1, keepdim=True)
std = x.std(-1, keepdim=True)
return self.a_2 * (x - mean) / (std + self.eps) + self.b_2

我们注意输入深入浅出Transformer(一)_归一化_20的维度,最后一个维度就是嵌入层的大小,我们就是对该维度进行归一化。这里还有一点需要补充的就是,层归一化要学习的参数只有两个,上文公式深入浅出Transformer(一)_Transformer详解_42中的深入浅出Transformer(一)_位置编码_34深入浅出Transformer(一)_Transformer详解_30,这里分别对应深入浅出Transformer(一)_如何理解Transformer_45深入浅出Transformer(一)_位置编码_46。所以通过​​​nn.Parameter​​​去构造这两个参数,这样这两个参数会出现在该模型的​​parameters()​​方法中,并且可以注册到模型中。

由于层数较深,为了防止模型过拟合,故增加了Dropout。

我们应用dropout到每个子层的输出,在它被加到子层的输入(残差连接)和层归一化之前。此外,我们将dropout应用于编码器和解码器栈中的嵌入和位置编码的和。对于基本模型,我们使用dropout比率为深入浅出Transformer(一)_编码器_47

深入浅出Transformer(一)_Transformer详解_48

第一个应用Dropout的位置就是加入位置编码的词嵌入,后文会探讨。然后就是多头注意力层和全连接层的输出位置。

这里将上图中的​​Dropout​​​、​​Add​​​和​​Norm​​​也设计成了一个模型(​​nn.Module​​):

class SublayerConnection(nn.Module):
'''
残差连接然后接层归一化
为了简化代码,先进行层归一化
'''

def __init__(self, size, dropout):
'''
:param size: 模型的维度,原文中统一为512
:param dropout: Dropout的比率,原文中为0.1
'''
super(SublayerConnection, self).__init__()
self.norm = LayerNorm(size)
self.dropout = nn.Dropout(dropout)

def forward(self, x, sublayer):
'''
应用残差连接到任何同样大小的子层
'''
return x + self.dropout(sublayer(self.norm(x)))

这样,我们个子层的输出是深入浅出Transformer(一)_位置编码_16,其中深入浅出Transformer(一)_编码器_17是一个子层自己实现的函数。我们对每个子层的输出应用Dropout ,在其添加到(高层)子层输入并进行层归一化之前。

深入浅出Transformer(一)_Transformer详解_51

注意这里代码实现和原文中说的有点不同,主要是层归一化的位置,原文如上图深入浅出Transformer(一)_编码器_52所示,叫Post-LN;这里的实现其实是上图深入浅出Transformer(一)_编码器_53所示,叫做 Pre-LN。有人​​3

我们知道编码器叠加了深入浅出Transformer(一)_编码器_13层(​​​EncoderLayer​​),每层有两个子层,第一个是多头注意力层,第二个是一个简单的基于位置的全连接神经网络。

每个子层接了一个上面实现的​​SublayerConnection​​。

class EncoderLayer(nn.Module):
'''
编码器是由self-attention和FFN(全连接层神经网络)组成,其中self-attention和FNN分别用SublayerConnection封装
'''

def __init__(self, size, self_attn, feed_forward, dropout):
'''
:param: size: 模型的大小
:param: self_attn: 注意力层
:param: feed_forward: 全连接层
'''
super(EncoderLayer, self).__init__()
self.self_attn = self_attn
self.feed_forward = feed_forward
# 编码器层共有两个子层
self.sublayer = clones(SublayerConnection(size, dropout), 2)
self.size = size

def forward(self, x, mask):
x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, mask))
return self.sublayer[1](x, self.feed_forward)

其中​​sublayer[0]​​​就是第一个子层连接,其中封装了第一个子层,即多头注意力层,我们上面已经知道它会对立面的网络层的输出进行残差连接和​​Dropout​​​等操作。这里的多头注意层通过​​lambda​​​表达式调用了​​self.self_attn​​​,因为注意力层有三个输入和一个​​mask​​。

然后输入到第二个子层连接,其中封装的是基于位置的全连接层。

🚨下面我们开始触碰到核心部分——多头注意力层了。

注意力

注意力经​​NEURAL MACHINE TRANSLATION BY JOINTLY LEARNING TO ALIGN AND TRANSLATE​​​提出后就迅速应用到了各种Seq2Seq模型中,关于注意力可以参考这篇论文。这么经典的论文,博主也进行了翻译。

原文中用到的自注意力(self-attention)和经典的注意力机制中的注意力有点不同,具体我们来看一下。

给定自注意力层深入浅出Transformer(一)_编码器_55个输入,就能产生深入浅出Transformer(一)_编码器_55个输出。

深入浅出Transformer(一)_Transformer详解_57

自注意机制允许输入序列关注自己,来发现它们应该更关注自己的哪一部分。输出就是针对输入中每个单词的注意力得分。

以机器翻译任务为例,假设想翻译下面这段英文​4​。

”​​The animal didn't cross the street because it was too tired​​”(这个动物没有横穿街道,因为它太累了。)

上文中的它​​it​​​指代什么?街道​​street​​​还是动物​​animal​​,我们人类能很容易回答,因为我们知道只有动物才会累。

但是如何让算法知道这一点呢?答案就是自注意。

当模型处理单词​​it​​​时,自注意让模型能关联​​it​​​到​​animal​​。随着模型不断的处理每个单词(输入序列中的每个位置),自注意允许模型查看输入序列中的其他位置来获得信息以更好地计算单词的注意力得分。

深入浅出Transformer(一)_编码器_58

原文中通过一个注意力函数来计算注意力。

注意力函数可以说是匹配一个query和一系列key-value对到一个输出的函数。其中的query,key,value和输出都是向量。value的加权和得到输出,每个value的权重是通过一个query和相应key的某个函数计算。

这里query、key和value又是什么意思,翻译过来就是查询、键和值。可以理解为信息检索中的查询操作,如下图。假如我们输入“自然语言处理是什么”(少输入了一个是,不过不影响)。Key可以看成为每篇文章的标题,Value就是每篇文章的相关内容。

深入浅出Transformer(一)_归一化_59

不过在自注意力中,Query/Key/Value都是根据同一个输入乘上不同的权重得到的。深入浅出Transformer(一)_Transformer详解_60

深入浅出Transformer(一)_Transformer详解_61

计算自注意力的第一步就是,为编码器层的每个输入,都创建三个向量,分别是query向量,key向量和value向量。

正如我们上面所说,每个向量都是乘上一个权重矩阵得到的,这些权重矩阵是随模型一起训练的。

以上图的为例,假设输入“Thinking”、“Machines”两个单词,这个例子来自文章​​The Illustrated Transformer​

我们发现将query、key和value分别用不同的、学到的线性映射深入浅出Transformer(一)_编码器_62倍到深入浅出Transformer(一)_Transformer详解_63深入浅出Transformer(一)_Transformer详解_63深入浅出Transformer(一)_Transformer详解_65维效果更好,而不是用深入浅出Transformer(一)_编码器_66维的query、key和value执行单个attention函数。 基于每个映射版本的query、key和value,我们并行执行attention函数,产生深入浅出Transformer(一)_Transformer详解_65维输出值。 将它们连接并再次映射,产生最终值。

这里的三个权重矩阵就是原文中说的三个线性映射,暂时忽略其中的深入浅出Transformer(一)_Transformer详解_30倍等描述。

进行线性映射的目的是转换向量的维度,转换成一个更小的维度。原文中是将深入浅出Transformer(一)_位置编码_69维转换为深入浅出Transformer(一)_编码器_70维。

比如输入深入浅出Transformer(一)_Transformer详解_71乘以矩阵深入浅出Transformer(一)_编码器_72得到query向量深入浅出Transformer(一)_如何理解Transformer_73,然后乘以深入浅出Transformer(一)_归一化_74深入浅出Transformer(一)_位置编码_75分别得到key向量深入浅出Transformer(一)_编码器_76和value向量深入浅出Transformer(一)_Transformer详解_77

第二步 是计算注意力得分,假设我们想计算单词“Thinking”的注意力得分,我们需要对输入序列中的所有单词(包括自身)都进行某个操作。得到单词“Thinking”对于输入序列中每个单词的注意力得分,如果某个位置的得分越大,那么在生成编码时就越需要考虑这个位置。或者说注意力就是衡量深入浅出Transformer(一)_Transformer详解_78深入浅出Transformer(一)_如何理解Transformer_79的相关性,相关性越大,那么在得到最终输出时,深入浅出Transformer(一)_如何理解Transformer_79对应的深入浅出Transformer(一)_如何理解Transformer_81在生成输出时贡献也越大。

那么这里所说的操作是什么呢?其实很简单,就是点乘。表示两个向量在多大程度上指向同一方向。类似余弦相似度,除了没有对向量的模进行归一化。

深入浅出Transformer(一)_归一化_82

所以如果我们计算单词“Thinking”的注意力得分,需要计算深入浅出Transformer(一)_如何理解Transformer_73深入浅出Transformer(一)_编码器_76深入浅出Transformer(一)_归一化_85的点积。如上图所示。

第三步和第四步 是进行进行缩放,原文中是除以深入浅出Transformer(一)_编码器_86,然后经过softmax函数,使得每个得分都是正的,且总和为深入浅出Transformer(一)_Transformer详解_87

深入浅出Transformer(一)_位置编码_88

经过Softmax之后的值就可以看成是一个权重了,也称为注意力权重。决定每个单词在生成这个位置的编码时能够共享多大程度。

第五步 用每个单词的value向量乘上对应的注意力权重。这一步用于保存我们想要注意单词的信息(给定一个很大的权重),而抑制我们不关心的单词信息(给定一个很小的权重)。

第六步 累加第五步的结果,得到一个新的向量,也就是自注意力层在这个位置(这里是对于第一个单词“Thinking”来说)的输出。举一个极端的例子,假设某个单词的权重非常大,比如是深入浅出Transformer(一)_Transformer详解_87,其他单词都是深入浅出Transformer(一)_如何理解Transformer_39,那么这一步的输出就是该单词对应的value向量。

深入浅出Transformer(一)_Transformer详解_91

这就是计算第一个单词的自注意力输出完整过程。自注意力层的魅力在于,计算所有单词的输出可以通过矩阵运算一次完成。

深入浅出Transformer(一)_如何理解Transformer_92

我们把所有的输入编入一个矩阵深入浅出Transformer(一)_编码器_93,上面的例子有两个输入,所以这里的深入浅出Transformer(一)_编码器_93矩阵有两行。分别乘上权重矩阵深入浅出Transformer(一)_Transformer详解_95就得到了深入浅出Transformer(一)_编码器_96向量矩阵。

深入浅出Transformer(一)_位置编码_97

然后除以深入浅出Transformer(一)_如何理解Transformer_98进行缩放,再经过Softmax,得到注意力权重矩阵,接着乘以value向量矩阵深入浅出Transformer(一)_编码器_99,就一次得到了所有单词的输出矩阵深入浅出Transformer(一)_Transformer详解_100

注意权重矩阵深入浅出Transformer(一)_Transformer详解_95都是可以训练的,因此通过训练,可以为每个输入单词生成不同的注意力得分,从而得到不同的输出。

我们上面描述的就是论文中的下面内容,原文中称为缩放点乘注意力

我们称我们这种特定的注意力为缩放点乘注意力(下图)。输入query和key的维度是深入浅出Transformer(一)_Transformer详解_63,value的维度是深入浅出Transformer(一)_Transformer详解_65。我们计算query和所有key的点乘结果,然后除以深入浅出Transformer(一)_如何理解Transformer_104,最后应用一个softmax函数就得到value的相应权重。

在实践中,我们同时计算一组query的注意力函数,这一组query被压缩到一个矩阵深入浅出Transformer(一)_位置编码_105,key和value也分别被压缩到矩阵深入浅出Transformer(一)_位置编码_106深入浅出Transformer(一)_编码器_107。我们通过下面的公式计算输出矩阵:
深入浅出Transformer(一)_如何理解Transformer_108
最常用的注意力函数是Bahdanau注意力,和点乘注意力。点乘注意力除了没有通过深入浅出Transformer(一)_归一化_109缩放外,和我们算法中的注意力函数相同。Bahdanau注意力通过一个单隐藏层的全连接网络计算。尽管这两个函数的复杂度都是相似的,但是点乘注意力在实际中更快、更节省空间。因为它能通过高度优化的矩阵乘法实现。

尽管在深入浅出Transformer(一)_Transformer详解_63值不大的情况下,两者性能差不多,Bahdanau注意力超过没有对大的深入浅出Transformer(一)_Transformer详解_63值缩放的点乘注意力,我们认为,对于大的深入浅出Transformer(一)_Transformer详解_63值,点乘的结果也变得非常大,导致softmax函数到极其小梯度的区域,为了防止这点,我们缩放点积结果到深入浅出Transformer(一)_归一化_109

我们就可以得到注意力函数的实现:

def attention(query, key, value, mask=None, dropout=None):
'''
计算缩放点乘注意力
:param query: [batch_size, self.h, input_len, self.d_k]
:param key: [batch_size, self.h, input_len, self.d_k]
:param value: [batch_size, self.h, input_len, self.d_k]
'''
d_k = query.size(-1)
# query: [batch_size, self.h, input_len, self.d_k]
# key.transpose: [batch_size, self.h, self.d_k, input_len]
# 此时就是批矩阵相乘 固定batch_size, self.h -> input_len, self.d_k x self.d_k, input_len = input_len, input_len
# -> [batch_size, self.h, input_len, input_len]
scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k)
# 源序列也需要mask,因为批次内语句长短不一,对于短的语句,就需要填充<pad>字符
if mask is not None:
# 根据mask句子,把屏蔽的位置填-1e9,然后计算softmax的时候,-1e9的位置就被计算为0
scores = scores.masked_fill(mask == 0, -1e9)
p_attn = torch.softmax(scores, dim=-1) # 经过softmax得到注意力权重
if dropout:
p_attn = dropout(p_attn)
# [batch_size, self.h, input_len, input_len] x [batch_size, self.h, input_len, self.d_k]
# -> [batch_size, self.h, input_len, self.d_k]
return torch.matmul(p_attn, value), p_attn # 返回最终的输出 和 注意力权重(可用户绘图)

至此,我们理解了注意力的计算。下面就来挑战多头注意力。

多头注意力

深入浅出Transformer(一)_编码器_114

先来看下原文的描述。

我们发现将query、key和value分别用不同的、学到的线性映射深入浅出Transformer(一)_编码器_62倍到深入浅出Transformer(一)_Transformer详解_63深入浅出Transformer(一)_Transformer详解_63深入浅出Transformer(一)_Transformer详解_65维效果更好,而不是用深入浅出Transformer(一)_编码器_66维的query、key和value执行单个attention函数。 基于每个映射版本的query、key和value,我们并行执行attention函数,产生深入浅出Transformer(一)_Transformer详解_65维输出值。 将它们连接并再次映射,产生最终值,如下图所示。

多头注意力允许模型能对齐不同表示子空间信息到不同的位置。而普通的只有一个头的注意力会因为求平均而抑制了这一点。

深入浅出Transformer(一)_Transformer详解_121
其中
深入浅出Transformer(一)_编码器_122

参数矩阵深入浅出Transformer(一)_编码器_123, 深入浅出Transformer(一)_归一化_124, 深入浅出Transformer(一)_Transformer详解_125 and 深入浅出Transformer(一)_Transformer详解_126

在本文中,我们设置深入浅出Transformer(一)_归一化_127个并行的注意力层,或注意力头。每个头中的深入浅出Transformer(一)_位置编码_128,由于每个头维度的减少,总的计算量和正常维度的单头注意力差不多(深入浅出Transformer(一)_归一化_129)。

深入浅出Transformer(一)_Transformer详解_130

多头的意思就是,同时计算多次自注意力,不过与原本的计算一次自注意力不同,计算多次注意力时的维度缩小为原来的深入浅出Transformer(一)_Transformer详解_30倍。原文中深入浅出Transformer(一)_编码器_132,由于维度缩小深入浅出Transformer(一)_Transformer详解_30倍,意味着所需要的计算量也缩小为深入浅出Transformer(一)_Transformer详解_30倍,总共有深入浅出Transformer(一)_Transformer详解_30个头。最终总的计算量和不缩小维度的单头注意力差不多。

原来计算一次注意力,只能学到一种信息,现在我们对于同一位置计算8次注意力,可以理解为学到了8种关注信息。可能有的关注语义信息、有的关注句法信息等等。这样扩展了模型的表达能力。

注意,在Pytorch实现的时候,上图的深入浅出Transformer(一)_编码器_96其实都是输入深入浅出Transformer(一)_编码器_93,对应的三个线性层,就是原文说的线性映射,原来是映射到深入浅出Transformer(一)_位置编码_69维,原文变成了映射成8个深入浅出Transformer(一)_编码器_70维的深入浅出Transformer(一)_编码器_96向量矩阵。

⚡不要错误的认为多头注意力需要计算多次,牛逼的地方在于,仍然可以通过一次矩阵运算同时计算8个自注意力输出。

从上图可以看出,叠加了深入浅出Transformer(一)_Transformer详解_30个自注意力,每个都是独立运算的,最终把深入浅出Transformer(一)_Transformer详解_30个自注意力的输出连接(concat)在一起,变成一个矩阵,再经过一个线性变换,得到最终输出。

为了理解多头注意力,我们以深入浅出Transformer(一)_位置编码_143为例,让输入矩阵深入浅出Transformer(一)_编码器_93乘以深入浅出Transformer(一)_Transformer详解_145,分别得到深入浅出Transformer(一)_如何理解Transformer_146,如下图:

深入浅出Transformer(一)_归一化_147

来看一下维度,输入深入浅出Transformer(一)_编码器_93的维度是深入浅出Transformer(一)_归一化_149,表示有两个输入,词嵌入维度为深入浅出Transformer(一)_编码器_150

权重矩阵深入浅出Transformer(一)_Transformer详解_145的维度都是深入浅出Transformer(一)_如何理解Transformer_152,表示把词嵌入维度由深入浅出Transformer(一)_编码器_150进行线性变换,转换为深入浅出Transformer(一)_如何理解Transformer_154

不同的深入浅出Transformer(一)_编码器_72权重矩阵,得到了不同的query向量矩阵深入浅出Transformer(一)_如何理解Transformer_146,它们的维度是深入浅出Transformer(一)_位置编码_157

上面的维度都很小,为了便于演示,实际上原文词嵌入+位置编码后的维度是深入浅出Transformer(一)_编码器_158

之前介绍的深入浅出Transformer(一)_Transformer详解_95在多头注意力下,都变成了8个,即深入浅出Transformer(一)_如何理解Transformer_160

那么多头注意力是如何通过矩阵运算一次计算多个注意力的输出呢?

深入浅出Transformer(一)_Transformer详解_161

第一步,把多个权重矩阵拼接起来,让输入深入浅出Transformer(一)_编码器_93乘以权重矩阵,分别得到深入浅出Transformer(一)_编码器_96矩阵。

深入浅出Transformer(一)_Transformer详解_164

接下来通过矩阵的变形操作(reshape),增加一个维度,变成叠加的三个query。

深入浅出Transformer(一)_Transformer详解_165

对于剩下的深入浅出Transformer(一)_归一化_166都进行这样的操作,然后将变形后的深入浅出Transformer(一)_编码器_96输入到注意力函数中。

深入浅出Transformer(一)_如何理解Transformer_168

通过矩阵运算,得到叠加的深入浅出Transformer(一)_Transformer详解_100矩阵,最终通过拼接(concat)操作,去掉增加的那个维度,然后再经过一个线性层,再次映射,得到最终输出。

class MultiHeadedAttention(nn.Module):
'''
多头注意力机制实现
'''

def __init__(self, h, d_model, dropout=0.1):
'''
输入维度和head数量h
'''
super(MultiHeadedAttention, self).__init__()

assert d_model % h == 0
# d_k是每个head的维度
self.d_k = d_model // h
self.h = h
# 四个线性层,三个在输入端,一个在输出端
# 在计算注意力之前先将query,key,value进行线性变换效果更好
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:
# 同样的mask应用到所有h个head
mask = mask.unsqueeze(1)
n_batches = query.size(0) # 批次大小

# 1) 在批次内对query,key,value进行线性运算,分别转换成h个d_k维度的query,key,value:维度 d_model => h x d_k,
# 对self.linears与(query,key,value)进行zip,相当于分别把query,key,value喂给前三个线性层,得到线性变换后的query,key,value
# 如 query: [batch_size, input_len, d_model] -> 线性层 -> [batch_size, input_len, d_model]
# -> view -> [batch_size, input_len, self.h, self.d_k] -> transpose -> [batch_size, self.h, input_len, self.d_k]
query, key, value = [l(x).view(n_batches, -1, self.h, self.d_k).transpose(1, 2)
for l, x in zip(self.linears, (query, key, value))]

# 2) 对批次内所有线性变换后的向量调用注意力函数
x, self.attn = attention(query, key, value, mask=mask, dropout=self.dropout)
# 3) 通过view执行类似连接Z的操作,然后应用到最后一个线性层
# view方法需要tensor是连续的,因此调用contiguous方法
# x : [batch_size, self.h, input_len, self.d_k] -> transpose -> [batch_size, input_len, self.h, self.d_k]
# -> view -> [batch_size, input_len, self.h*self.d_k]
x = x.transpose(1, 2).contiguous().view(n_batches, -1, self.h * self.d_k)

return self.linears[-1](x)

多头注意力的实现如上,输入接收​​query,key,value​​可以同时适用到解码器中。

我们模型中注意力机制的应用

Transformer以三种方式使用多头注意力:

  1. 在编码器-解码器注意力层,query来自前一个解码器层,key和value来编码器输出。这允许解码器中每个位置能注意到输入序列中所有位置。这模仿了seq2seq模型中的典型的编码器-解码器的注意力机制。
  2. 编码器中的自注意层。在自注意层中,所有的key,value和query都来自同一个地方,在这里是编码器中前一层的输出,编码器中每个位置都能注意到编码器前一层的所有位置。
  3. 类似地,解码器中的自注意层允许解码器中的每个位置注意解码器中直到并包括该位置的所有位置。我们需要防止解码器中的左向信息流以保持自回归(auto-regressive)属性。我们在缩放点乘注意力中实现这点,通过屏蔽(mask)softmax的输入中所有不合法连接的值(设置为深入浅出Transformer(一)_Transformer详解_170)。

基于位置的前馈网络

除了注意力子层,我们编码器和解码器中每个层都包含一个全连接前馈网络,它单独且相同地应用于每个位置。它包含了两个线性变换,其中有一个ReLU激活。
深入浅出Transformer(一)_位置编码_171
尽管线性变换对于不同位置来说是相同的,但每个子层中的参数都是不同的。还可以说是两个内核大小为1的卷积层。输入和输出的维度是深入浅出Transformer(一)_位置编码_18,内部层的维度深入浅出Transformer(一)_编码器_173

深入浅出Transformer(一)_如何理解Transformer_174

它的输入是所有位置的注意力向量,引入深入浅出Transformer(一)_位置编码_175的目的是转换注意力输出向量的表示空间,增加模型的表现能力,更好的作为下一个注意力层的输入。

class PositionWiseFeedForward(nn.Module):
'''
实现FFN网路
'''

def __init__(self, d_model, d_ff, dropout=0.1):
super(PositionWiseFeedForward, self).__init__()
# 将输入转换为d_ff维度
self.w_1 = nn.Linear(d_model, d_ff)
# 将d_ff转换回d_model
self.w_2 = nn.Linear(d_ff, d_model)
self.dropout = nn.Dropout(dropout)

def forward(self, x):
return self.w_2(self.dropout(torch.relu(self.w_1(x))))

至此编码器部分基本了解完毕了,除了

深入浅出Transformer(一)_Transformer详解_176

嵌入层

与其他序列转导模型类似,我们使用学习的嵌入层去转换输入和输出单词到深入浅出Transformer(一)_编码器_66维的词向量。在嵌入层中,我们把它的权重乘了深入浅出Transformer(一)_位置编码_178

嵌入层比较简单。

class Embeddings(nn.Module):
def __init__(self, d_model, vocab):
super(Embeddings, self).__init__()
self.lut = nn.Embedding(vocab, d_model) # 词典大小 嵌入大小
self.d_model = d_model

def forward(self, x):
'''
x: [batch_size, input_len]
'''
# 把得到的词嵌入向量乘以sqrt(d_model)
return self.lut(x) * math.sqrt(self.d_model) # [batch_size, input_len, d_model]

位置编码

因为我们的模型不包含循环和卷积,为了使用序列顺序信息,我们必须接入一些关于序列中单词相对或绝对位置的信息 。为此,我们将位置编码添加到编码器和解码器栈底部的输入词嵌入中。位置编码和词嵌入有相同的维度深入浅出Transformer(一)_编码器_66,所以它们可以求和。有多种位置编码可以选择,例如通过学习得到的和固定的位置编码。

我们使用不同频率的正弦和余弦函数来表示位置编码:
深入浅出Transformer(一)_如何理解Transformer_180
其中深入浅出Transformer(一)_位置编码_181表示位置,深入浅出Transformer(一)_归一化_182表示维度。也就是说,位置编码的每个维度都对应一个正弦曲线。波长形成一个从深入浅出Transformer(一)_位置编码_183深入浅出Transformer(一)_编码器_184的等比数列。我们之所以选择这个函数,是因为我们假设这个函数可以让模型很容易地学到相对位置的注意力,因为对于任何固定的偏移量深入浅出Transformer(一)_如何理解Transformer_185深入浅出Transformer(一)_编码器_186都能表示为深入浅出Transformer(一)_位置编码_187的一个线性函数。

我们也实验了学过的位置嵌入,然后发现这两种方式产生了几乎同样的结果。我们选择正弦版本是因为允许模型推断比训练期间遇到的更长的序列。

深入浅出Transformer(一)_Transformer详解_188

为了使用序列顺序信息,作者提出了利用不同频率的正弦和余弦函数表示位置编码。序列顺序信息重要性是不言而喻的。比如以下两个句子:

我爱你
你爱我

作者用词嵌入向量➕位置编码得到输入向量,这里简单解释一下为什么作者选用正弦和余弦函数。

假设我们自己设置位置编码,一个简单的办法是增加索引到词嵌入向量​5​。

深入浅出Transformer(一)_位置编码_189

假设深入浅出Transformer(一)_归一化_190表示词嵌入向量。这种方法有一个很大的问题,即句子越长,后面单词的序号就越大,而且索引值过大,可能会掩盖了嵌入向量的“光辉”。

深入浅出Transformer(一)_如何理解Transformer_191

你说序号太大了,那么我把每个序号都除以句子长度总不大了吧。听起来不错,但是这引入了另一个问题,就是由于句子的长度不同,导致同样的值可能代表不同的意思,这样让我们的模型很困惑。比如深入浅出Transformer(一)_编码器_192在句长为深入浅出Transformer(一)_编码器_193的句子中表示第深入浅出Transformer(一)_编码器_150个单词,但是在句长为深入浅出Transformer(一)_Transformer详解_195的句子中表示第深入浅出Transformer(一)_归一化_196个单词。

深入浅出Transformer(一)_归一化_197

💡 因为我们上面句子长度为8,深入浅出Transformer(一)_Transformer详解_198,何不用二进制来表示顺序信息呢?如上图所示。从上往下看,比如4对应“100”,5对应“101”。

这里我们用3位表示就足够了,一般我们可以设置成深入浅出Transformer(一)_如何理解Transformer_199

那这种方法就很好了吗?

  1. 我们仍然没有完全归一化。我们想要位置编码也符合某种分布。最好让正负数的分布均匀,这个很好实现,可以通过函数深入浅出Transformer(一)_编码器_200,将[0,1] -> [-1,1]
  2. 我们的二进制向量来自离散函数,而不是连续函数的离散化。

我们的位置编码应该满足下面的要求​6​:

  1. 对于每个时间步(句子中的单词位置),它都能输出独一无二的编码
  2. 任意两个时间步之间的距离都应该是一个常量,而不因句子长度而变
  3. 我们的模型应该能轻易地泛化到更长的句子,它的值应该是有界的
  4. 位置编码必须是确定的

作者提出的编码方式是一个简单且天才的技术,满足了上面所有的要求。首先,它不是一个标量,而是一个包含特定位置信息的深入浅出Transformer(一)_归一化_201维向量。其次,该编码并没有整合到模型中。相反,这个向量用于为每个单词设置关于它在句子中位置的信息。换言之,通过注入单词的顺序来增强模型的输入。

深入浅出Transformer(一)_如何理解Transformer_202为输入序列中某个位置,深入浅出Transformer(一)_编码器_203是该位置的位置编码,深入浅出Transformer(一)_归一化_201是向量维度。深入浅出Transformer(一)_Transformer详解_205是通过以下公式产生位置编码向量的函数:
深入浅出Transformer(一)_位置编码_206
其中
深入浅出Transformer(一)_如何理解Transformer_207
由该式子可以看出,频率是随着向量维度降低的(由深入浅出Transformer(一)_归一化_208降低成深入浅出Transformer(一)_位置编码_209)。因此波长形成一个从深入浅出Transformer(一)_编码器_210深入浅出Transformer(一)_归一化_211的等比数列。

我们也能想象位置编码深入浅出Transformer(一)_如何理解Transformer_212是一个包含各个频率的正弦和余弦向量,其中深入浅出Transformer(一)_归一化_201可以被深入浅出Transformer(一)_位置编码_214整除。
深入浅出Transformer(一)_如何理解Transformer_215
为什么正弦和余弦的组合可以表示顺序。假设我们用二进制来表示数字。
深入浅出Transformer(一)_位置编码_216

可以看到,随着十进制数的增加,每个位的变化率是不一样的,越低位的变化越快,红色位深入浅出Transformer(一)_如何理解Transformer_39深入浅出Transformer(一)_Transformer详解_87,每个数字都会变化一次;

而黄色位,每深入浅出Transformer(一)_编码器_219个数字才会变化一次。

但是二进制值的深入浅出Transformer(一)_Transformer详解_220是离散的,浪费了它们之间无限的浮点数。所以我们使用它们的连续浮动版本-正弦函数。

此外,通过降低它们的频率,我们可以从红色位变成黄色位,这样就实现了这种低位到高位的变换。如下图所示:

深入浅出Transformer(一)_Transformer详解_221

下面补充一下波长和频率的计算:

深入浅出Transformer(一)_编码器_222

对于正弦函数来说,波长(周期)的计算如上图。任意深入浅出Transformer(一)_Transformer详解_223的波长是深入浅出Transformer(一)_Transformer详解_224,频率是深入浅出Transformer(一)_如何理解Transformer_225

深入浅出Transformer(一)_Transformer详解_226

最后,通过设置位置编码的维度和词嵌入向量的维度一致,可以将位置编码加入到词向量。

原文中提到

对于任何固定的偏移量深入浅出Transformer(一)_如何理解Transformer_185深入浅出Transformer(一)_编码器_186都要能表示为深入浅出Transformer(一)_位置编码_187的一个线性函数。

深入浅出Transformer(一)_Transformer详解_230


上图顶部是长度为200、维度为150的序列转置后的位置矩阵深入浅出Transformer(一)_编码器_231,上图底部是所深入浅出Transformer(一)_如何理解Transformer_232在的位置向量中的第深入浅出Transformer(一)_位置编码_233个分量位置的正弦余弦函数图像,来自 Hands-on Machine Learning with Scikit Learn, Keras, TensorFlow: Concepts, Tools and Techniques to Build Intelligent Systems 2nd Edition

对每个频率深入浅出Transformer(一)_位置编码_234相应的正-余弦对,存在一个线性转换深入浅出Transformer(一)_归一化_235
深入浅出Transformer(一)_编码器_236

证明6​:

假设深入浅出Transformer(一)_Transformer详解_237是一个深入浅出Transformer(一)_归一化_238的矩阵,我们想要找到其中的元素深入浅出Transformer(一)_如何理解Transformer_239满足:
深入浅出Transformer(一)_归一化_240
利用三角函数两角和的正弦公式和余弦公式,得到:

深入浅出Transformer(一)_归一化_241
得到下面两个等式:
深入浅出Transformer(一)_如何理解Transformer_242
相应的,可得:
深入浅出Transformer(一)_归一化_243
所以,就得到了最终的矩阵深入浅出Transformer(一)_Transformer详解_237为:
深入浅出Transformer(一)_归一化_245
从上可以看出,最终的转换与深入浅出Transformer(一)_如何理解Transformer_202无关。

类似地,我们可以找到其他正-余弦对的深入浅出Transformer(一)_Transformer详解_237,最终允许我们表示深入浅出Transformer(一)_编码器_248为一个深入浅出Transformer(一)_如何理解Transformer_212对任意固定偏移量深入浅出Transformer(一)_如何理解Transformer_250的线性函数。这个属性,使模型很容易学得相对位置信息。

这解释了为什么要选择交替的正弦和余弦函数,仅通过正弦或余弦函数达不到这一点。

我们实现位置编码如下:

class PositionalEncoding(nn.Module):
def __init__(self, d_model, dropout, max_len=5000):
super(PositionalEncoding, self).__init__()
self.dropout = nn.Dropout(p=dropout)

pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len).unsqueeze(1)
div_term = torch.exp(torch.arange(0, d_model, 2) * -(math.log(10000.0) / d_model))
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
pe = pe.unsqueeze(0)
self.register_buffer('pe', pe) # 注册为一个缓存,但是不是模型的参数,默认随模型其他参数一起保存

def forward(self, x):
x = x + Variable(self.pe[:, :x.size(1)], requires_grad=False)
return self.dropout(x)

参考


  1. ​Attention Is All You Need​​​ ​​↩︎​​ ​​↩︎​
  2. ​The Annotated Transformer ​​​ ​​↩︎​
  3. ​On Layer Normalization in the Transformer Architecture​​​ ​​↩︎​
  4. ​The Illustrated Transformer​​​ ​​↩︎​
  5. ​Master Positional Encoding​​​ ​​↩︎​
  6. ​Transformer Architecture: The Positional Encoding​​​ ​​↩︎​​ ​​↩︎​