引言
Transformer的重要性不用多说了吧,NLP现在最火了两个模型——BERT和GPT,一个是基于它的编码器实现的,另一个是基于它的解码器实现的。
凡是我不能创造的,我都不能理解。
为了更好的理解Transformer1模型,我们需要知道它的实现细节。本文我们就如庖丁解牛一般,剖析它的原理与实现细节——通过Pytorch实现。
为了更好的理解Transformer,英文阅读能力好的建议先看一下它的原始论文1,以及两篇非常好的解释文章(这里和这里)。本文会结合这些文章的内容,争取阐述清楚每个知识点。由于内容有点多,可能会分成三篇文章。
为了方便,本文把原文的翻译结果也贴出来,翻译放到引用内。
背景
循环模型通常是对输入和输出序列的符号位置进行因子计算。在计算期间对齐位置和时间步,基于前一时间步的隐藏状态和当前时间步的输入,循环生成了一系列隐藏状态。这种固有的顺序特性排除了训练的并行化,这在更长的序列中成为重要问题,因为有限的内存限制了长样本的批次化。虽然最近有学者通过因子分解和条件计算技巧重大的提升了计算效率,同时提升了模型的表现。但是序列计算的基本限制仍然存在。
注意力机制已经变成了序列建模和各种任务中的转导模型的必备成分,允许为依赖建模而不必考虑输入和输出序列中的距离远近。除了少数情况外,这种注意力机制都与循环神经网路结合使用。
本文我们提出了Transformer,一个移除循环网络、完全基于注意力机制来为输入和输出的全局依赖建模的模型。Transformer 允许更多的并行化,并且翻译质量可以达到最牛逼水平,只需要在8个P100 GPU上训练12个小时。
模型架构
Transformer模型抛弃了RNN和CNN,是一个完全利用自注意去计算输入和输出的编码器-解码器模型。并且它还可以并行计算,同时计算效率也很高。
模型整体架构如图1所示。
大部分有竞争力的神经网络序列转导模型都有一个编码器-解码器(Encoder-Decoder)结构。编码器映射一个用符号表示的输入序列到一个连续的序列表示。给定,解码器生成符号的一个输出序列,一次生成一个元素。在每个时间步,模型是自回归(auto-regressive)的,在生成下个输出时消耗上一次生成的符号作为附加的输入。
Transformer沿用该结构并在编码器和解码器中都使用叠加的自注意和基于位置的全连接网络,分别对应图1左半部和右半部。
我们先来看左边编码器部分。
编码器
编码器是上图左边红色部分,解码器是上图右边蓝色部分。
编码器: 编码器是由个相同的层(参数独立)堆叠而成的。
上图中的是叠加次的意思,原文中编码器是由6个相同的层堆叠而成的。如下图所示:
低层编码器的输出作为上一层编码器的输入。
每层都有两个子层(sub-layer),第一个子层是多头注意力层(Multi-Head Attention),第二个是简单的基于位置的全连接前馈神经网络(positionwise fully connected feed-forward network)。
意思是每个编码器层都是由两个子层组成,第一个是论文中提出的多头注意力,这个比较重要,可以说是该篇论文的核心,理解了多头注意力整篇论文就理解的差不多了。后面会详细探讨。 经过多头注意力之后先进行残差连接,再做层归一化。
我们在两个子层周围先进行残差连接,然后进行层归一化(Layer Normalization)。这样,我们每个子层的输出是,其中是子层自己实现的函数。为了利用残差连接,该模型中的所有子层和嵌入层,输出的维度都统一为。
残差连接体现在上图的Add
,层归一化就是上图的Norm
。残差连接名字很唬人,其实原理非常简单,如下图:
假设网络中某层输入后的输出为,不管激活函数是什么,经过深层网络都可能导致梯度消失的情况。增加残差连接,相当于某层输入后的输出为。最坏的情况相当于没有经过这一层,直接输入到高层,这样高层的表现至少能和低层一样好。
而层归一化针对每个输入的每个维度进行归一化操作。假设有个维度,,层归一化首先计算这个维度的均值和方差,然后进行归一化得到,接着做一个缩放,类似批归一化。
其中就是LN层的输出,是点乘操作,和是输入各个维度的均值和方差,和是两个可学习的参数,和的维度相同。
Transformer中输入的维度。
下面我们通过Pytorch实现上面编码器中介绍的部分,Pytorch版的Transformer依据的是另一个神作2,也是一篇论文,里面完整的实现了Transformer。本文的实现也是根据这篇论文来的,他们的代码写得非常优雅,从可重用性和抽象性来看,体现了非常高的技术,值得仔细研究学习。
首先导入所有需要的包。然后我们定义一个克隆函数,Transformer中多处用到了叠加,叠加就可以通过克隆来实现。
ModuleList
可以和Python普通列表一样进行索引,但是里面的模型会被合理的注册到网络中,这样里面模型的参数在梯度下降的时候进行更新。下面来看编码器的代码实现。
编码器的输入是前文中提到的子层(sub-layer),因此这里克隆了份子层,由于用的是深克隆,虽然模型是一模一样的,但是每个模型学到的参数肯定是不同的。
注意这里输入mask
的作用,编码器输入mask
一般是在进行批处理时,由于每个句子的长度可能不等,因此对于过短的句子,需要填充<pad>
字符,一般用表示,而这里的mask
就能标出哪些字符为填充字符,这样可以不需要进行计算,以提高效率。
注意这里用到的的层归一化,是对整个编码器的输出进行层归一化,即在编码器最终结果输出到解码器之前,做的层归一化。
下面看一下层归一化LayerNorm
的实现。
我们注意输入的维度,最后一个维度就是嵌入层的大小,我们就是对该维度进行归一化。这里还有一点需要补充的就是,层归一化要学习的参数只有两个,上文公式中的和,这里分别对应和。所以通过nn.Parameter
去构造这两个参数,这样这两个参数会出现在该模型的parameters()
方法中,并且可以注册到模型中。
由于层数较深,为了防止模型过拟合,故增加了Dropout。
我们应用dropout到每个子层的输出,在它被加到子层的输入(残差连接)和层归一化之前。此外,我们将dropout应用于编码器和解码器栈中的嵌入和位置编码的和。对于基本模型,我们使用dropout比率为。
第一个应用Dropout的位置就是加入位置编码的词嵌入,后文会探讨。然后就是多头注意力层和全连接层的输出位置。
这里将上图中的Dropout
、Add
和Norm
也设计成了一个模型(nn.Module
):
这样,我们个子层的输出是,其中是一个子层自己实现的函数。我们对每个子层的输出应用Dropout ,在其添加到(高层)子层输入并进行层归一化之前。
注意这里代码实现和原文中说的有点不同,主要是层归一化的位置,原文如上图所示,叫Post-LN;这里的实现其实是上图所示,叫做 Pre-LN。有人3
我们知道编码器叠加了层(EncoderLayer
),每层有两个子层,第一个是多头注意力层,第二个是一个简单的基于位置的全连接神经网络。
每个子层接了一个上面实现的SublayerConnection
。
其中sublayer[0]
就是第一个子层连接,其中封装了第一个子层,即多头注意力层,我们上面已经知道它会对立面的网络层的输出进行残差连接和Dropout
等操作。这里的多头注意层通过lambda
表达式调用了self.self_attn
,因为注意力层有三个输入和一个mask
。
然后输入到第二个子层连接,其中封装的是基于位置的全连接层。
🚨下面我们开始触碰到核心部分——多头注意力层了。
注意力
注意力经NEURAL MACHINE TRANSLATION BY JOINTLY LEARNING TO ALIGN AND TRANSLATE提出后就迅速应用到了各种Seq2Seq模型中,关于注意力可以参考这篇论文。这么经典的论文,博主也进行了翻译。
原文中用到的自注意力(self-attention)和经典的注意力机制中的注意力有点不同,具体我们来看一下。
给定自注意力层个输入,就能产生个输出。
自注意机制允许输入序列关注自己,来发现它们应该更关注自己的哪一部分。输出就是针对输入中每个单词的注意力得分。
以机器翻译任务为例,假设想翻译下面这段英文4。
”The animal didn't cross the street because it was too tired
”(这个动物没有横穿街道,因为它太累了。)
上文中的它it
指代什么?街道street
还是动物animal
,我们人类能很容易回答,因为我们知道只有动物才会累。
但是如何让算法知道这一点呢?答案就是自注意。
当模型处理单词it
时,自注意让模型能关联it
到animal
。随着模型不断的处理每个单词(输入序列中的每个位置),自注意允许模型查看输入序列中的其他位置来获得信息以更好地计算单词的注意力得分。
原文中通过一个注意力函数来计算注意力。
注意力函数可以说是匹配一个query和一系列key-value对到一个输出的函数。其中的query,key,value和输出都是向量。value的加权和得到输出,每个value的权重是通过一个query和相应key的某个函数计算。
这里query、key和value又是什么意思,翻译过来就是查询、键和值。可以理解为信息检索中的查询操作,如下图。假如我们输入“自然语言处理是什么”(少输入了一个是,不过不影响)。Key可以看成为每篇文章的标题,Value就是每篇文章的相关内容。
不过在自注意力中,Query/Key/Value都是根据同一个输入乘上不同的权重得到的。
计算自注意力的第一步就是,为编码器层的每个输入,都创建三个向量,分别是query向量,key向量和value向量。
正如我们上面所说,每个向量都是乘上一个权重矩阵得到的,这些权重矩阵是随模型一起训练的。
以上图的为例,假设输入“Thinking”、“Machines”两个单词,这个例子来自文章The Illustrated Transformer
我们发现将query、key和value分别用不同的、学到的线性映射倍到、和维效果更好,而不是用维的query、key和value执行单个attention函数。 基于每个映射版本的query、key和value,我们并行执行attention函数,产生维输出值。 将它们连接并再次映射,产生最终值。
这里的三个权重矩阵就是原文中说的三个线性映射,暂时忽略其中的倍等描述。
进行线性映射的目的是转换向量的维度,转换成一个更小的维度。原文中是将维转换为维。
比如输入乘以矩阵得到query向量,然后乘以和分别得到key向量和value向量。
第二步 是计算注意力得分,假设我们想计算单词“Thinking”的注意力得分,我们需要对输入序列中的所有单词(包括自身)都进行某个操作。得到单词“Thinking”对于输入序列中每个单词的注意力得分,如果某个位置的得分越大,那么在生成编码时就越需要考虑这个位置。或者说注意力就是衡量和的相关性,相关性越大,那么在得到最终输出时,对应的在生成输出时贡献也越大。
那么这里所说的操作是什么呢?其实很简单,就是点乘。表示两个向量在多大程度上指向同一方向。类似余弦相似度,除了没有对向量的模进行归一化。
所以如果我们计算单词“Thinking”的注意力得分,需要计算对和的点积。如上图所示。
第三步和第四步 是进行进行缩放,原文中是除以,然后经过softmax函数,使得每个得分都是正的,且总和为。
经过Softmax之后的值就可以看成是一个权重了,也称为注意力权重。决定每个单词在生成这个位置的编码时能够共享多大程度。
第五步 用每个单词的value向量乘上对应的注意力权重。这一步用于保存我们想要注意单词的信息(给定一个很大的权重),而抑制我们不关心的单词信息(给定一个很小的权重)。
第六步 累加第五步的结果,得到一个新的向量,也就是自注意力层在这个位置(这里是对于第一个单词“Thinking”来说)的输出。举一个极端的例子,假设某个单词的权重非常大,比如是,其他单词都是,那么这一步的输出就是该单词对应的value向量。
这就是计算第一个单词的自注意力输出完整过程。自注意力层的魅力在于,计算所有单词的输出可以通过矩阵运算一次完成。
我们把所有的输入编入一个矩阵,上面的例子有两个输入,所以这里的矩阵有两行。分别乘上权重矩阵就得到了向量矩阵。
然后除以进行缩放,再经过Softmax,得到注意力权重矩阵,接着乘以value向量矩阵,就一次得到了所有单词的输出矩阵。
注意权重矩阵都是可以训练的,因此通过训练,可以为每个输入单词生成不同的注意力得分,从而得到不同的输出。
我们上面描述的就是论文中的下面内容,原文中称为缩放点乘注意力。
我们称我们这种特定的注意力为缩放点乘注意力(下图)。输入query和key的维度是,value的维度是。我们计算query和所有key的点乘结果,然后除以,最后应用一个softmax函数就得到value的相应权重。
在实践中,我们同时计算一组query的注意力函数,这一组query被压缩到一个矩阵,key和value也分别被压缩到矩阵和。我们通过下面的公式计算输出矩阵:
最常用的注意力函数是Bahdanau注意力,和点乘注意力。点乘注意力除了没有通过缩放外,和我们算法中的注意力函数相同。Bahdanau注意力通过一个单隐藏层的全连接网络计算。尽管这两个函数的复杂度都是相似的,但是点乘注意力在实际中更快、更节省空间。因为它能通过高度优化的矩阵乘法实现。尽管在值不大的情况下,两者性能差不多,Bahdanau注意力超过没有对大的值缩放的点乘注意力,我们认为,对于大的值,点乘的结果也变得非常大,导致softmax函数到极其小梯度的区域,为了防止这点,我们缩放点积结果到。
我们就可以得到注意力函数的实现:
至此,我们理解了注意力的计算。下面就来挑战多头注意力。
多头注意力
先来看下原文的描述。
我们发现将query、key和value分别用不同的、学到的线性映射倍到、和维效果更好,而不是用维的query、key和value执行单个attention函数。 基于每个映射版本的query、key和value,我们并行执行attention函数,产生维输出值。 将它们连接并再次映射,产生最终值,如下图所示。
多头注意力允许模型能对齐不同表示子空间信息到不同的位置。而普通的只有一个头的注意力会因为求平均而抑制了这一点。
其中参数矩阵, , and 。
在本文中,我们设置个并行的注意力层,或注意力头。每个头中的,由于每个头维度的减少,总的计算量和正常维度的单头注意力差不多()。
多头的意思就是,同时计算多次自注意力,不过与原本的计算一次自注意力不同,计算多次注意力时的维度缩小为原来的倍。原文中,由于维度缩小倍,意味着所需要的计算量也缩小为倍,总共有个头。最终总的计算量和不缩小维度的单头注意力差不多。
原来计算一次注意力,只能学到一种信息,现在我们对于同一位置计算8次注意力,可以理解为学到了8种关注信息。可能有的关注语义信息、有的关注句法信息等等。这样扩展了模型的表达能力。
注意,在Pytorch实现的时候,上图的其实都是输入,对应的三个线性层,就是原文说的线性映射,原来是映射到维,原文变成了映射成8个维的向量矩阵。
⚡不要错误的认为多头注意力需要计算多次,牛逼的地方在于,仍然可以通过一次矩阵运算同时计算8个自注意力输出。
从上图可以看出,叠加了个自注意力,每个都是独立运算的,最终把个自注意力的输出连接(concat)在一起,变成一个矩阵,再经过一个线性变换,得到最终输出。
为了理解多头注意力,我们以为例,让输入矩阵乘以,分别得到,如下图:
来看一下维度,输入的维度是,表示有两个输入,词嵌入维度为;
权重矩阵的维度都是,表示把词嵌入维度由进行线性变换,转换为;
不同的权重矩阵,得到了不同的query向量矩阵,它们的维度是。
上面的维度都很小,为了便于演示,实际上原文词嵌入+位置编码后的维度是;
之前介绍的在多头注意力下,都变成了8个,即。
那么多头注意力是如何通过矩阵运算一次计算多个注意力的输出呢?
第一步,把多个权重矩阵拼接起来,让输入乘以权重矩阵,分别得到矩阵。
接下来通过矩阵的变形操作(reshape),增加一个维度,变成叠加的三个query。
对于剩下的都进行这样的操作,然后将变形后的输入到注意力函数中。
通过矩阵运算,得到叠加的矩阵,最终通过拼接(concat)操作,去掉增加的那个维度,然后再经过一个线性层,再次映射,得到最终输出。
多头注意力的实现如上,输入接收query,key,value
可以同时适用到解码器中。
我们模型中注意力机制的应用
Transformer以三种方式使用多头注意力:
- 在编码器-解码器注意力层,query来自前一个解码器层,key和value来编码器输出。这允许解码器中每个位置能注意到输入序列中所有位置。这模仿了seq2seq模型中的典型的编码器-解码器的注意力机制。
- 编码器中的自注意层。在自注意层中,所有的key,value和query都来自同一个地方,在这里是编码器中前一层的输出,编码器中每个位置都能注意到编码器前一层的所有位置。
- 类似地,解码器中的自注意层允许解码器中的每个位置注意解码器中直到并包括该位置的所有位置。我们需要防止解码器中的左向信息流以保持自回归(auto-regressive)属性。我们在缩放点乘注意力中实现这点,通过屏蔽(mask)softmax的输入中所有不合法连接的值(设置为)。
基于位置的前馈网络
除了注意力子层,我们编码器和解码器中每个层都包含一个全连接前馈网络,它单独且相同地应用于每个位置。它包含了两个线性变换,其中有一个ReLU激活。
尽管线性变换对于不同位置来说是相同的,但每个子层中的参数都是不同的。还可以说是两个内核大小为1的卷积层。输入和输出的维度是,内部层的维度。
它的输入是所有位置的注意力向量,引入的目的是转换注意力输出向量的表示空间,增加模型的表现能力,更好的作为下一个注意力层的输入。
至此编码器部分基本了解完毕了,除了
嵌入层
与其他序列转导模型类似,我们使用学习的嵌入层去转换输入和输出单词到维的词向量。在嵌入层中,我们把它的权重乘了。
嵌入层比较简单。
位置编码
因为我们的模型不包含循环和卷积,为了使用序列顺序信息,我们必须接入一些关于序列中单词相对或绝对位置的信息 。为此,我们将位置编码添加到编码器和解码器栈底部的输入词嵌入中。位置编码和词嵌入有相同的维度,所以它们可以求和。有多种位置编码可以选择,例如通过学习得到的和固定的位置编码。
我们使用不同频率的正弦和余弦函数来表示位置编码:
其中表示位置,表示维度。也就是说,位置编码的每个维度都对应一个正弦曲线。波长形成一个从到的等比数列。我们之所以选择这个函数,是因为我们假设这个函数可以让模型很容易地学到相对位置的注意力,因为对于任何固定的偏移量,都能表示为的一个线性函数。我们也实验了学过的位置嵌入,然后发现这两种方式产生了几乎同样的结果。我们选择正弦版本是因为允许模型推断比训练期间遇到的更长的序列。
为了使用序列顺序信息,作者提出了利用不同频率的正弦和余弦函数表示位置编码。序列顺序信息重要性是不言而喻的。比如以下两个句子:
作者用词嵌入向量➕位置编码得到输入向量,这里简单解释一下为什么作者选用正弦和余弦函数。
假设我们自己设置位置编码,一个简单的办法是增加索引到词嵌入向量5。
假设表示词嵌入向量。这种方法有一个很大的问题,即句子越长,后面单词的序号就越大,而且索引值过大,可能会掩盖了嵌入向量的“光辉”。
你说序号太大了,那么我把每个序号都除以句子长度总不大了吧。听起来不错,但是这引入了另一个问题,就是由于句子的长度不同,导致同样的值可能代表不同的意思,这样让我们的模型很困惑。比如在句长为的句子中表示第个单词,但是在句长为的句子中表示第个单词。
💡 因为我们上面句子长度为8,,何不用二进制来表示顺序信息呢?如上图所示。从上往下看,比如4对应“100”,5对应“101”。
这里我们用3位表示就足够了,一般我们可以设置成。
那这种方法就很好了吗?
- 我们仍然没有完全归一化。我们想要位置编码也符合某种分布。最好让正负数的分布均匀,这个很好实现,可以通过函数,将[0,1] -> [-1,1]
- 我们的二进制向量来自离散函数,而不是连续函数的离散化。
我们的位置编码应该满足下面的要求6:
- 对于每个时间步(句子中的单词位置),它都能输出独一无二的编码
- 任意两个时间步之间的距离都应该是一个常量,而不因句子长度而变
- 我们的模型应该能轻易地泛化到更长的句子,它的值应该是有界的
- 位置编码必须是确定的
作者提出的编码方式是一个简单且天才的技术,满足了上面所有的要求。首先,它不是一个标量,而是一个包含特定位置信息的维向量。其次,该编码并没有整合到模型中。相反,这个向量用于为每个单词设置关于它在句子中位置的信息。换言之,通过注入单词的顺序来增强模型的输入。
令为输入序列中某个位置,是该位置的位置编码,是向量维度。是通过以下公式产生位置编码向量的函数:
其中
由该式子可以看出,频率是随着向量维度降低的(由降低成)。因此波长形成一个从到的等比数列。
我们也能想象位置编码是一个包含各个频率的正弦和余弦向量,其中可以被整除。
为什么正弦和余弦的组合可以表示顺序。假设我们用二进制来表示数字。
可以看到,随着十进制数的增加,每个位的变化率是不一样的,越低位的变化越快,红色位和,每个数字都会变化一次;
而黄色位,每个数字才会变化一次。
但是二进制值的是离散的,浪费了它们之间无限的浮点数。所以我们使用它们的连续浮动版本-正弦函数。
此外,通过降低它们的频率,我们可以从红色位变成黄色位,这样就实现了这种低位到高位的变换。如下图所示:
下面补充一下波长和频率的计算:
对于正弦函数来说,波长(周期)的计算如上图。任意的波长是,频率是。
最后,通过设置位置编码的维度和词嵌入向量的维度一致,可以将位置编码加入到词向量。
原文中提到
对于任何固定的偏移量,都要能表示为的一个线性函数。
上图顶部是长度为200、维度为150的序列转置后的位置矩阵,上图底部是所在的位置向量中的第个分量位置的正弦余弦函数图像,来自 Hands-on Machine Learning with Scikit Learn, Keras, TensorFlow: Concepts, Tools and Techniques to Build Intelligent Systems 2nd Edition
对每个频率相应的正-余弦对,存在一个线性转换:
证明6:
假设是一个的矩阵,我们想要找到其中的元素满足:
利用三角函数两角和的正弦公式和余弦公式,得到:
得到下面两个等式:
相应的,可得:
所以,就得到了最终的矩阵为:
从上可以看出,最终的转换与无关。
类似地,我们可以找到其他正-余弦对的,最终允许我们表示为一个对任意固定偏移量的线性函数。这个属性,使模型很容易学得相对位置信息。
这解释了为什么要选择交替的正弦和余弦函数,仅通过正弦或余弦函数达不到这一点。
我们实现位置编码如下:
参考
- Attention Is All You Need ↩︎ ↩︎
- The Annotated Transformer ↩︎
- On Layer Normalization in the Transformer Architecture ↩︎
- The Illustrated Transformer ↩︎
- Master Positional Encoding ↩︎
- Transformer Architecture: The Positional Encoding ↩︎ ↩︎