开通VIP,畅享免费电子书等14项超值服
首页
好书
留言交流
下载APP
联系客服
2023.09.18
公众号后台回复“Transformer”即可获得本文最新高清PDF文件!
虽然,网上已经有了大量的关于这篇论文的解析,不过好菜不怕晚笔者在这里也会谈谈自己对于它的理解以及运用。按照我们一贯解读论文的顺序,首先让我们先一起来看看作者当时为什么要提出Transformer这个模型?需要解决什么样的问题?现在的模型有什么样的缺陷?
在论文的摘要部分作者提到,现在主流的序列模型都是基于复杂的循环神经网络或者是卷积神经网络构造而来的Encoder-Decoder模型,并且就算是目前性能最好的序列模型也都是基于注意力机制下的Encoder-Decoder架构。为什么作者会不停的提及这些传统的Encoder-Decoder模型呢?接着,作者在介绍部分谈到,由于传统的Encoder-Decoder架构在建模过程中,下一个时刻的计算过程会依赖于上一个时刻的输出,而这种固有的属性就限制了传统的Encoder-Decoder模型就不能以并行的方式进行计算,如图1-1所示。
Thisinherentlysequentialnatureprecludesparallelizationwithintrainingexamples,whichbecomescriticalatlongersequencelengths,asmemoryconstraintslimitbatchingacrossexamples.
随后作者谈到,尽管最新的研究工作已经能够使得传统的循环神经网络在计算效率上有了很大的提升,但是本质的问题依旧没有得到解决。
Recentworkhasachievedsignificantimprovementsincomputationalefficiencythroughfactorizationtricksandconditionalcomputation,whilealsoimprovingmodelperformanceincaseofthelatter.Thefundamentalconstraintofsequentialcomputation,however,remains.
因此,在这篇论文中,作者首次提出了一种全新的Transformer架构来解决这一问题,如图1-2所示。当然,Transformer架构的优点在于它完全摈弃了传统的循环结构,取而代之的是只通过注意力机制来计算模型输入与输出的隐含表示,而这种注意力的名字就是大名鼎鼎的自注意力机制(self-attention),也就是图1-2中的Multi-HeadAttention模块。
Tothebestofourknowledge,however,theTransformeristhefirsttransductionmodelrelyingentirelyonself-attentiontocomputerepresentationsofitsinputandoutputwithoutusingsequence-alignedRNNsorconvolution.
总体来说,所谓自注意力机制就是通过某种运算来直接计算得到句子在编码过程中每个位置上的注意力权重;然后再以权重和的形式来计算得到整个句子的隐含向量表示。最终,Transformer架构就是基于这种的自注意力机制而构建的Encoder-Decoder模型。
在介绍完整篇论文的提出背景后,下面就让我们一起首先来看一看自注意力机制的庐山真面目,然后再来探究整体的网络架构。
首先需要明白一点的是,所谓的自注意力机制其实就是论文中所指代的“ScaledDot-ProductAttention“。在论文中作者说道,注意力机制可以描述为将query和一系列的key-value对映射到某个输出的过程,而这个输出的向量就是根据query和key计算得到的权重作用于value上的权重和。
Anattentionfunctioncanbedescribedasmappingaqueryandasetofkey-valuepairstoanoutput,wherethequery,keys,values,andoutputareallvectors.Theoutputiscomputedasaweightedsumofthevalues,wheretheweightassignedtoeachvalueiscomputedbyacompatibilityfunctionofthequerywiththecorrespondingkey.
不过想要更加深入的理解query、key和value的含义,得需要结合Transformer的解码过程,这部分内容将会在后续进行介绍。具体的,自注意力机制的结构如图1-3所示。
从图1-3可以看出,自注意力机制的核心过程就是通过Q和K计算得到注意力权重;然后再作用于V得到整个权重和输出。具体的,对于输入Q、K和V来说,其输出向量的计算公式为:
其中Q、K和V分别为3个矩阵,且其(第2个)维度分别为(从后面的计算过程其实可以发现(1.1)中除以的过程就是图1-3中所指的Scale过程。
之所以要进行缩放这一步是因为通过实验作者发现,对于较大的来说在完成后将会得到很大的值,而这将导致在经过sofrmax操作后产生非常小的梯度,不利于网络的训练。
Wesuspectthatforlargevaluesofdk,thedotproductsgrowlargeinmagnitude,pushingthesoftmaxfunctionintoregionswhereithasextremelysmallgradients.
如果仅仅只是看着图1-3中的结构以及公式中的计算过程显然是不那么容易理解自注意力机制的含义,例如初学者最困惑的一个问题就是图1-3中的Q、K和V分别是怎么来的?下面,我们来看一个实际的计算示例。现在,假设输入序列“我是谁”,且已经通过某种方式得到了1个形状为的矩阵来进行表示,那么通过图1-3所示的过程便能够就算得到Q、K以及V[2]。
从图1-4的计算过程可以看出,Q、K和V其实就是输入X分别乘以3个不同的矩阵计算而来(但这仅仅局限于Encoder和Decoder在各自输入部分利用自注意力机制进行编码的过程,Encoder和Decoder交互部分的Q、K和V另有指代)。此处对于计算得到的Q、K、V,你可以理解为这是对于同一个输入进行3次不同的线性变换来表示其不同的3种状态。在计算得到Q、K、V之后,就可以进一步计算得到权重向量,计算过程如图1-5所示。
如图1-5所示,在经过上述过程计算得到了这个注意力权重矩阵之后我们不禁就会问到,这些权重值到底表示的是什么呢?对于权重矩阵的第1行来说,0.7表示的就是“我”与“我”的注意力值;0.2表示的就是“我”与”是”的注意力值;0.1表示的就是“我”与“谁”的注意力值。换句话说,在对序列中的“我“进行编码时,应该将0.7的注意力放在“我”上,0.2的注意力放在“是”上,将0.1的注意力放在谁上。
同理,对于权重矩阵的第3行来说,其表示的含义就是,在对序列中”谁“进行编码时,应该将0.2的注意力放在“我”上,将0.1的注意力放在“是”上,将0.7的注意力放在“谁”上。从这一过程可以看出,通过这个权重矩阵模型就能轻松的知道在编码对应位置上的向量时,应该以何种方式将注意力集中到不同的位置上。
不过从上面的计算结果还可以看到一点就是,模型在对当前位置的信息进行编码时,会过度的将注意力集中于自身的位置(虽然这符合常识)而可能忽略了其它位置[2]。因此,作者采取的一种解决方案就是采用多头注意力机制(MultiHeadAttention),这部分内容我们将在稍后看到。
Itexpandsthemodel’sabilitytofocusondifferentpositions.Yes,intheexampleabove,containsalittlebitofeveryotherencoding,butitcouldbedominatedbythetheactualworditself.
在通过图1-5示的过程计算得到权重矩阵后,便可以将其作用于V,进而得到最终的编码输出,计算过程如图1-6所示。
根据如图1-6所示的过程,我们便能够得到最后编码后的输出向量。当然,对于上述过程我们还可以换个角度来进行观察,如图1-7所示。
从图1-7可以看出,对于最终输出“是”的编码向量来说,它其实就是原始“我是谁”3个向量的加权和,而这也就体现了在对“是”进行编码时注意力权重分配的全过程。
当然,对于整个图1-5到图1-6的过程,我们还可以通过如图1-8所示的过程来进行表示。
可以看出通过这种自注意力机制的方式确实解决了作者在论文伊始所提出的“传统序列模型在编码过程中都需顺序进行的弊端”的问题,有了自注意力机制后,仅仅只需要对原始输入进行几次矩阵变换便能够得到最终包含有不同位置注意力信息的编码向量。
对于自注意力机制的核心部分到这里就介绍完了,不过里面依旧有很多细节之处没有进行介绍。例如Encoder和Decoder在进行交互时的Q、K、V是如何得到的?在图1-3中所标记的Mask操作是什么意思,什么情况下会用到等等?这些内容将会在后续逐一进行介绍。
下面,让我们继续进入到MultiHeadAttention机制的探索中。
经过上面内容的介绍,我们算是在一定程度上对于自注意力机制有了清晰的认识,不过在上面我们也提到了自注意力机制的缺陷就是:模型在对当前位置的信息进行编码时,会过度的将注意力集中于自身的位置,因此作者提出了通过多头注意力机制来解决这一问题。同时,使用多头注意力机制还能够给予注意力层的输出包含有不同子空间中的编码表示信息,从而增强模型的表达能力。
Multi-headattentionallowsthemodeltojointlyattendtoinformationfromdifferentrepresentationsubspacesatdifferentpositions.
在说完为什么需要多头注意力机制以及使用多头注意力机制的好处之后,下面我们就来看一看到底什么是多头注意力机制。
如图1-9所示,可以看到所谓的多头注意力机制其实就是将原始的输入序列进行多组的自注意力处理过程;然后再将每一组自注意力的结果拼接起来进行一次线性变换得到最终的输出结果。具体的,其计算公式为:
其中
同时,在论文中,作者使用了个并行的自注意力模块(8个头)来构建一个注意力层,并且对于每个自注意力模块都限定了。从这里其实可以发现,论文中所使用的多头注意力机制其实就是将一个大的高维单头拆分成了个多头。因此,整个多头注意力机制的计算过程我们可以通过如图1-10所示的过程来进行表示。
注意:图中的d_m就是指
如图1-10所示,根据输入序列X和我们就计算得到了,进一步根据公式就得到了单个自注意力模块的输出;同理,根据X和就得到了另外一个自注意力模块输出。最后,根据公式将水平堆叠形成,然后再用乘以便得到了整个多头注意力层的输出。同时,根据图1-9中的计算过程,还可以得到。
到此,对于整个Transformer的核心部分,即多头注意力机制的原理就介绍完了。
在多头注意力中,对于初学者来说一个比较经典的问题就是,在相同维度下使用单头和多头的区别是什么?这句话什么意思呢?以图1-10中示例为例,此时的自注意力中使用了两个头,每个头的维度为,即采用了多头的方式。另外一种做法就是,只是用一个头,但是其维度为,即采用单头的方式。那么在这两种情况下有什么区别呢?
首先,从论文中内容可知,作者在头注意力机制与多头个数之间做了如下的限制
从式可以看出,单个头注意力机制的维度乘上多头的个数就等于模型的维度。
注意:后续的d_m,以及都是指代模型的维度。
同时,从图1-10中可以看出,这里使用的多头数量,即。此时,对于第1个头来说有:
对于第2个头来说有:
最后,可以将在横向堆叠起来进行一个线性变换得到最终的。因此,对于图1-10所示的计算过程,我们还可以通过图1-13来进行表示。
从图1-13可知,在一开始初始化这3个权重矩阵时,可以直接同时初始化个头的权重,然后再进行后续的计算。而且事实上,在真正的代码实现过程中也是采用的这样的方式,这部分内容将在3.3.2节中进行介绍。因此,对图1-13中的多头计算过程,还可以根据图1-14来进行表示。
说了这么多,终于把铺垫做完了。此时,假如有如图1-15所示的头注意力计算过程:
如图1-15所示,该计算过程采用了头注意力机制来进行计算,且头的计算过程还可通过图1-16来进行表示。
那现在的问题是图1-16中的能够计算得到吗?答案是不能。为什么?因为我没有告诉你这里的等于多少。如果我告诉你多头,那么毫无疑问图1-16的计算过程就等同于图1-14的计算过程,即
且此时。但是如果我告诉你多头,那么图1-16的计算过程会变成
那么此时则为。
现在回到一开始的问题上,根据上面的论述我们可以发现,在固定的情况下,不管是使用单头还是多头的方式,在实际的处理过程中直到进行注意力权重矩阵计算前,两者之前没有任何区别。当进行进行注意力权重矩阵计算时,越大那么就会被切分得越小,进而得到的注意力权重分配方式越多,如图1-19所示。
从图1-19可以看出,如果,那么最终可能得到的就是一个各个位置只集中于自身位置的注意力权重矩阵;如果,那么就还可能得到另外一个注意力权重稍微分配合理的权重矩阵;同理如此。因而多头这一做法也恰好是论文作者提出用于克服模型在对当前位置的信息进行编码时,会过度的将注意力集中于自身的位置的问题。这里再插入一张真实场景下同一层的不同注意力权重矩阵可视化结果图:
同时,当不一样时,的取值也不一样,进而使得对权重矩阵的scale的程度不一样。例如,如果,那么当时,则;当时,则。
所以,当模型的维度确定时,一定程度上越大整个模型的表达能力越强,越能提高模型对于注意力权重的合理分配。
在正式介绍Transformer的网络结构之前,我们先来一起看看Transformer如何对字符进行Embedding处理。
如果是换做之前的网络模型,例如CNN或者RNN,那么对于文本向量化的步骤就到此结束了,因为这些网络结构本身已经具备了捕捉时序特征的能力,不管是CNN中的n-gram形式还是RNN中的时序形式。但是这对仅仅只有自注意力机制的网络结构来说却不行。为什么呢?根据自注意力机制原理的介绍我们知道,自注意力机制在实际运算过程中不过就是几个矩阵来回相乘进行线性变换而已。因此,这就导致即使是打乱各个词的顺序,那么最终计算得到的结果本质上却没有发生任何变换,换句话说仅仅只使用自注意力机制会丢失文本原有的序列信息。
如图2-2所示,在经过词嵌入表示后,序列“我在看书”经过了一次线性变换。现在,我们将序列变成“书在看我”,然后同样以中间这个权重矩阵来进行线性变换,过程如图2-3所示。
根据图2-3中的计算结果来看,序列在交换位置前和交换位置后计算得到的结果在本质上并没有任何区别,仅仅只是交换了对应的位置。因此,基于这样的原因,Transformer在原始输入文本进行TokenEmbedding后,又额外的加入了一个PositionalEmbedding来刻画数据在时序上的特征。
Sinceourmodelcontainsnorecurrenceandnoconvolution,inorderforthemodeltomakeuseoftheorderofthesequence,wemustinjectsomeinformationabouttherelativeorabsolutepositionofthetokensinthesequence.
说了这么多,那到底什么又是PositionalEmbedding呢?数无形时少直觉,下面我们先来通过一幅图直观看看经过PositionalEmbedding处理后到底产生了什么样的变化。
如图2-4所示,横坐标表示输入序列中的每一个Token,每一条曲线或者直线表示对应Token在每个维度上对应的位置信息。在左图中,每个维度所对应的位置信息都是一个不变的常数;而在右图中,每个维度所对应的位置信息都是基于某种公式变换所得到。换句话说就是,左图中任意两个Token上的向量都可以进行位置交换而模型却不能捕捉到这一差异,但是加入右图这样的位置信息模型却能够感知到。例如位置20这一处的向量,在左图中无论你将它换到哪个位置,都和原来一模一样;但在右图中,你却再也找不到与位置20处位置信息相同的位置。
下面,笔者通过两个实际的示例来进行说明。
如图2-5所示,原始输入在经过TokenEmbedding后,又加入了一个常数位置信息的的PositionalEmbedding。在经过一次线性变换后便得到了图2-5左右边所示的结果。接下来,我们再交换序列的位置,并同时进行PositionalEmbedding观察其结果。
如图2-6所示,在交换序列位置后,采用同样的PositionalEmbedding进行处理,并且进行线性变换。可以发现,其计算结果同图2-5中的计算结果本质上也没有发生变换。因此,这就再次证明,如果PositionalEmbedding中位置信息是以常数形式进行变换,那么这样的PositionalEmbedding是无效的。
在Transformer中,作者采用了如公式所示的规则来生成各个维度的位置信息,其可视化结果如图2-4右所示。
其中就是这个PositionalEmbedding矩阵,表示具体的某一个位置,表示具体的某一维度。
最终,在融入这种非常数的PositionalEmbedding位置信息后,便可以得到如图2-7所示的对比结果。
从图2-7可以看出,在交换位置前与交换位置后,与同一个权重矩阵进行线性变换后的结果截然不同。因此,这就证明通过PositionalEmbedding可以弥补自注意力机制不能捕捉序列时序信息的缺陷。
说完Transformer中的Embedding后,接下来我们再来继续探究Transformer的网络结构。
如图2-8所示便是一个单层Transformer网络结构图。
如图2-8所示,整个Transformer网络包含左右两个部分,即Encoder和Decoder。下面,我们就分别来对其中的各个部分进行介绍。
首先,对于Encoder来说,其网络结构如图2-8左侧所示(尽管论文中是以6个这样相同的模块堆叠而成,但这里我们先以堆叠一层来进行介绍,多层的Transformer结构将在稍后进行介绍)。
如图2-9所示,对于Encoder部分来说其内部主要由两部分网络所构成:多头注意力机制和两层前馈神经网络。
TheencoderiscomposedofastackofN=6identicallayers.Eachlayerhastwosub-layers.Thefirstisamulti-headself-attentionmechanism,andthesecondisasimple,position-wisefullyconnectedfeed-forwardnetwork.
Weapplydropouttotheoutputofeachsub-layer,beforeitisaddedtothesub-layerinputandnormalized.
进一步,为了便于在这些地方使用残差连接,这两部分网络输出向量的维度均为。
对于第2部分的两层全连接网络来说,其具体计算过程为
其中输入的维度为,第1层全连接层的输出维度为,第2层全连接层的输出为,且同时对于第1层网络的输出还运用了Relu激活函数。
到此,对于单层Encoder的网络结构就算是介绍完了,接下来让我们继续探究Decoder部分的网络结构。
同Encoder部分一样,论文中也采用了6个完全相同的网络层堆叠而成,不过这里我们依旧只是先看1层时的情况。对于Decoder部分来说,其整体上与Encoder类似,只是多了一个用于与Encoder输出进行交互的多头注意力机制,如图2-10所示。
不同于Encoder部分,在Decoder中一共包含有3个部分的网络结构。最上面的和最下面的部分(暂时忽略Mask)与Encoder相同,只是多了中间这个与Encoder输出(Memory)进行交互的部分,作者称之为“Encoder-Decoderattention”。对于这部分的输入,Q来自于下面多头注意力机制的输出,K和V均是Encoder部分的输出(Memory)经过线性变换后得到。而作者之所以这样设计也是在模仿传统Encoder-Decoder网络模型的解码过程。
In'encoder-decoderattention'layers,thequeriescomefromthepreviousdecoderlayer,andthememorykeysandvaluescomefromtheoutputoftheencoder.Thismimicsthetypicalencoder-decoderattentionmechanismsinsequence-to-sequencemodels
为了能够更好的理解这里Q、K、V的含义,我们先来看看传统的基于Encoder-Decoder的Seq2Seq翻译模型是如何进行解码的,如图2-11所示。
如图2-11所示是一个经典的基于Encoder-Decoder的机器翻译模型。左下边部分为编码器,右下边部分为解码器,左上边部分便是注意力机制部分。在图2-11中,表示的是在编码过程中,各个时刻的隐含状态,称之为每个时刻的Memory;表示解码当前时刻时的隐含状态。此时注意力机制的思想在于,希望模型在解码的时刻能够参考编码阶段每个时刻的记忆。
因此,在解码第一个时刻''时,会首先同每个记忆状态进行相似度比较得到注意力权重。这个注意力权重所蕴含的意思就是,在解码第一个时刻时应该将的注意力放在编码第一个时刻的记忆上(其它的同理),最终通过加权求和得到4个Memory的权重和,即contextvector。同理,在解码第二时刻'我'时,也会遵循上面的这一解码过程。可以看出,此时注意力机制扮演的就是能够使得Encoder与Decoder进行交互的角色。
回到Transformer的Encoder-Decoderattention中,K和V均是编码部分的输出Memory经过线性变换后的结果(此时的Memory中包含了原始输入序列每个位置的编码信息),而Q是解码部分多头注意力机制输出的隐含向量经过线性变换后的结果。在Decoder对每一个时刻进行解码时,首先需要做的便是通过Q与K进行交互(query查询),并计算得到注意力权重矩阵;然后再通过注意力权重与V进行计算得到一个权重向量,该权重向量所表示的含义就是在解码时如何将注意力分配到Memory的各个位置上。这一过程我们可以通过如图2-12和图2-13所示的过程来进行表示。
如图2-12所示,待解码向量和Memory分别各自乘上一个矩阵后得到Q、K、V。
如图2-13所示,在解码第1个时刻时,首先Q通过与K进行交互得到权重向量,此时可以看做是Q(待解码向量)在K(本质上也就是Memory)中查询Memory中各个位置与Q有关的信息;然后将权重向量与V进行运算得到解码向量,此时这个解码向量可以看作是考虑了Memory中各个位置编码信息的输出向量,也就是说它包含了在解码当前时刻时应该将注意力放在Memory中哪些位置上的信息。
进一步,在得到这个解码向量并经过图2-10中最上面的两层全连接层后,便将其输入到分类层中进行分类得到当前时刻的解码输出值。
当第1个时刻的解码过程完成之后,解码器便会将解码第1个时刻时的输入,以及解码第1个时刻后的输出均作为解码器的输入来解码预测第2个时刻的输出。整个过程可以通过如图2-14所示的过程来进行表示。
如图2-14所示,Decoder在对当前时刻进行解码输出时,都会将当前时刻之前所有的预测结果作为输入来对下一个时刻的输出进行预测。假设现在需要将'我是谁'翻译成英语'whoami',且解码预测后前两个时刻的结果为'whoam',接下来需要对下一时刻的输出'i'进行预测,那么整个过程就可以通过图2-15和图2-16来进行表示。
如图2-15所示,左上角的矩阵是解码器对输入'whoam'这3个词经过解码器中自注意力机制编码后的结果;左下角是编码器对输入'我是谁'这3个词编码后的结果(同图2-12中的一样);两者分别在经过线性变换后便得到了Q、K和V这3个矩阵。此时值得注意的是,左上角矩阵中的每一个向量在经过自注意力机制编码后,每个向量同样也包含了其它位置上的编码信息。
进一步,Q与K作用和便得到了一个权重矩阵;再将其与V进行线性组合便得到了Encoder-Decoderattention部分的输出,如图2-16所示。
如图2-16所示,左下角便是Q与K作用后的权重矩阵,它的每一行就表示在对Memory(这里指图2-16中的V)中的每一位置进行解码时,应该如何对注意力进行分配。例如第3行的含义就是在解码当前时刻时应该将的注意力放在Memory中的'我'上,其它同理。这样,在经过解码器中的两个全连接层后,便得到了解码器最终的输出结果。接着,解码器会循环对下一个时刻的输出进行解码预测,直到预测结果为'
同时,这里需要注意的是,在通过模型进行实际的预测时,只会取解码器输出的其中一个向量进行分类,然后作为当前时刻的解码输出。例如图2-16中解码器最终会输出一个形状为[3,tgt_vocab_len]的矩阵,那么只会取其最后一个向量喂入到分类器中进行分类得到当前时刻的解码输出。具体细节见后续代码实现。
在介绍完预测时Decoder的解码过程后,下面就继续来看在网络在训练过程中是如何进行解码的。从2.2.3小节的内容可以看出,在真实预测时解码器需要将上一个时刻的输出作为下一个时刻解码的输入,然后一个时刻一个时刻的进行解码操作。显然,如果训练时也采用同样的方法那将是十分费时的。因此,在训练过程中,解码器也同编码器一样,一次接收解码时所有时刻的输入进行计算。这样做的好处,一是通过多样本并行计算能够加快网络的训练速度;二是在训练过程中直接喂入解码器正确的结果而不是上一时刻的预测值(因为训练时上一时刻的预测值可能是错误的)能够更好的训练网络。
例如在用平行预料'我是谁'<==>'whoami'对网络进行训练时,编码器的输入便是'我是谁',而解码器的输入则是'whoami',对应的正确标签则是'whoami
假设现在解码器的输入'whoami'在分别乘上一个矩阵进行线性变换后得到了Q、K、V,且Q与K作用后得到了注意力权重矩阵(此时还未进行softmax操作),如图2-17所示。
从图2-17可以看出,此时已经计算得到了注意力权重矩阵。由第1行的权重向量可知,在解码第1个时刻时应该将(严格来说应该是经过softmax后的值)的注意力放到''上,的注意力放到'who'上等等。不过此时有一个问题就是,在2.2.3节中笔者介绍到,模型在实际的预测过程中只是将当前时刻之前(包括当前时刻)的所有时刻作为输入来预测下一个时刻,也就是说模型在预测时是看不到当前时刻之后的信息。因此,Transformer中的Decoder通过加入注意力掩码机制来解决了这一问题。
self-attentionlayersinthedecoderalloweachpositioninthedecodertoattendtoallpositionsinthedecoderuptoandincludingthatposition.Weneedtopreventleftwardinformationflowinthedecodertopreservetheauto-regressiveproperty.Weimplementthisinsideofscaleddot-productattentionbymaskingout(settingto∞)allvaluesintheinputofthesoftmaxwhichcorrespondtoillegalconnections.
如图2-18所示,左边依旧是通过Q和K计算得到了注意力权重矩阵(此时还未进行softmax操作),而中间的就是所谓的注意力掩码矩阵,两者在相加之后再乘上矩阵V便得到了整个自注意力机制的输出,也就是图2-10中的MaskedMulti-HeadAttention。
那为什么注意力权重矩阵加上这个注意力掩码矩阵就能够达到这样的效果呢?以图2-18中第1行权重为例,当解码器对第1个时刻进行解码时其对应的输入只有'',因此这就意味着此时应该将所有的注意力放在第1个位置上(尽管在训练时解码器一次喂入了所有的输入),换句话说也就是第1个位置上的权重应该是1,而其它位置则是0。从图2-17可以看出,第1行注意力向量在加上第1行注意力掩码,再经过softmax操作后便得到了一个类似的向量。那么,通过这个向量就能够保证在解码第1个时刻时只能将注意力放在第1个位置上的特性。同理,在解码后续的时刻也是类似的过程。
到此,对于整个单层Transformer的网络结构以及编码解码过程就介绍完了,更多细节内容见后续代码实现。
在刚接触Transformer的时候,有的人会认为在Decoder中,既然已经有了Attentionmask那么为什么还需要PositionalEmbedding呢?如图2-18所示,持这种观点的朋友认为,Attentionmask已经有了使得输入序列依次输入解码器的能力,因此就不再需要PositionalEmbedding了。这样想对吗?
根据2.2.4节内容的介绍可以知道,Attentionmask的作用只有一个,那就是在训练过程中掩盖掉当前时刻之后所有位置上的信息,而这也是在模仿模型在预测时只能看到当前时刻及其之前位置上的信息。因此,持有上述观点的朋友可能是把“能看见”和“能看见且有序”混在一起了。
虽然看似有了Attentionmask这个掩码矩阵能够使得Decoder在解码过程中可以有序地看到当前位置之前的所有信息,但是事实上没有PositionalEmbedding的Attentionmask只能做到看到当前位置之前的所有信息,而做不到有序。前者的“有序”指的是喂入解码器中序列的顺序,而后者的“有序”指的是序列本身固有的语序。
如果不加PostionalEmbedding的话,那么以下序列对于模型来说就是一回事:
虽然此时Attentionmask具有能够让上述序列一个时刻一个时刻的按序喂入到解码器中,但是它却无法识别出这句话本身固有的语序。
在Transformer中各个部分的Q、K、V到底是怎么来的一直以来都是初学者最大的一个疑问,并且这部分内容在原论文中也没有进行交代,只是交代了如何根据Q、K、V来进行自注意力机制的计算。虽然在第2部分的前面几个小节已经提及过了这部分内容,但是这里再给大家进行一次总结。
根据图2-8(Transformer结构图)可知,在整个Transformer中涉及到自注意力机制的一共有3个部分:Encoder中的Multi-HeadAttention;Decoder中的MaskedMulti-HeadAttention;Encoder和Decoder交互部分的Multi-HeadAttention。
①对于Encoder中的Multi-HeadAttention来说,其原始q、k、v均是Encoder的Token输入经过Embedding后的结果。q、k、v分别经过一次线性变换(各自乘以一个权重矩阵)后得到了Q、K、V(也就是图1-4中的示例),然后再进行自注意力运算得到Encoder部分的输出结果Memory。
②对于Decoder中的MaskedMulti-HeadAttention来说,其原始q、k、v均是Decoder的Token输入经过Embedding后的结果。q、k、v分别经过一次线性变换后得到了Q、K、V,然后再进行自注意力运算得到MaskedMulti-HeadAttention部分的输出结果,即待解码向量。
③对于Encoder和Decoder交互部分的Multi-HeadAttention,其原始q、k、v分别是上面的带解码向量、Memory和Memory。q、k、v分别经过一次线性变换后得到了Q、K、V(也就是图2-12中的示例),然后再进行自注意力运算得到Decoder部分的输出结果。之所以这样设计也是在模仿传统Encoder-Decoder网络模型的解码过程。
在第2部分中,笔者详细介绍了单层Transformer网络结构中的各个组成部分。尽管多层Transformer就是在此基础上堆叠而来,不过笔者认为还是有必要在这里稍微提及一下。
如图3-1所示便是一个单层Transformer网络结构图,左边是编码器右边是解码器。而多层的Transformer网络就是在两边分别堆叠了多个编码器和解码器的网络模型,如图3-2所示。
如图3-2所示便是一个多层的Transformer网络结构图(原论文中采用了6个编码器和6个解码器),其中的每一个Encoder都是图3-1中左边所示的网络结构(Decoder同理)。可以发现,它真的就是图3-1堆叠后的形式。不过需要注意的是其整个解码过程。
在多层Transformer中,多层编码器先对输入序列进行编码,然后得到最后一个Encoder的输出Memory;解码器先通过MaskedMulti-HeadAttention对输入序列进行编码,然后将输出结果同Memory通过Encoder-DecoderAttention后得到第1层解码器的输出;接着再将第1层Decoder的输出通过MaskedMulti-HeadAttention进行编码,最后再将编码后的结果同Memory通过Encoder-DecoderAttention后得到第2层解码器的输出,以此类推得到最后一个Decoder的输出。
值得注意的是,在多层Transformer的解码过程中,每一个Decoder在Encoder-DecoderAttention中所使用的Memory均是同一个。
由于在实现多头注意力时需要考虑到各种情况下的掩码,因此在这里需要先对这部分内容进行介绍。在Transformer中,主要有两个地方会用到掩码这一机制。第1个地方就是在2.2.4节中介绍到的AttentionMask,用于在训练过程中解码的时候掩盖掉当前时刻之后的信息;第2个地方便是对一个batch中不同长度的序列在Padding到相同长度后,对Padding部分的信息进行掩盖。下面分别就这两种情况进行介绍。
如图3-3所示,在训练过程中对于每一个样本来说都需要这样一个对称矩阵来掩盖掉当前时刻之后所有位置的信息。
从图3-3可以看出,这个注意力掩码矩阵的形状为[tgt_len,tgt_len],其具体Mask原理在2.2.4节中笔者已经介绍过l,这里就不再赘述。在后续实现过程中,我们将通过generate_square_subsequent_mask方法来生成这样一个矩阵。同时,在后续多头注意力机制实现中,将通过attn_mask这一变量名来指代这个矩阵。
在Transformer中,使用到掩码的第2个地方便是PaddingMask。由于在网络的训练过程中同一个batch会包含有多个文本序列,而不同的序列长度并不一致。因此在数据集的生成过程中,就需要将同一个batch中的序列Padding到相同的长度。但是,这样就会导致在注意力的计算过程中会考虑到Padding位置上的信息。
如图3-4所示,P表示Padding的位置,右边的矩阵表示计算得到的注意力权重矩阵。可以看到,此时的注意力权重对于Padding位置山的信息也会加以考虑。因此在Transformer中,作者通过在生成训练集的过程中记录下每个样本Padding的实际位置;然后再将注意力权重矩阵中对应位置的权重替换成负无穷,经softmax操作后对应Padding位置上的权重就变成了0,从而达到了忽略Padding位置信息的目的。这种做法也是Encoder-Decoder网络结构中通用的一种办法。
如图3-5所示,对于'我是谁PP'这个序列来说,前3个字符是正常的,后2个字符是Padding后的结果。因此,其Mask向量便为[True,True,True,False,False]。通过这个Mask向量可知,需要将权重矩阵的最后两列替换成负无穷,在后续我们会通过torch.masked_fill这个方法来完成这一步,并且在实现时将使用key_padding_mask来指代这一向量。
到此,对于Transformer中所要用到Mask的地方就介绍完了,下面正式来看如何实现多头注意力机制。
根据前面的介绍可以知道,多头注意力机制中最为重要的就是自注意力机制,也就是需要前计算得到Q、K和V,如图3-6所示。
然后再根据Q、K、V来计算得到最终的注意力编码,如图3-7所示:
综上所述,我们可以给出类MyMultiHeadAttentiond的定义为
在定义完初始化函数后,便可以定义如下所示的多头注意力前向传播的过程
在上述代码中,query、key、value指的并不是图3-6中的Q、K和V,而是没有经过线性变换前的输入。例如在编码时三者指的均是原始输入序列src;在解码时的MaskMulti-HeadAttention中三者指的均是目标输入序列tgt;在解码时的Encoder-DecoderAttention中三者分别指的是MaskMulti-HeadAttention的输出、Memory和Memory。key_padding_mask指的是编码或解码部分,输入序列的Padding情况,形状为[batch_size,src_len]或者[batch_size,tgt_len];attn_mask指的就是注意力掩码矩阵,形状为[tgt_len,src_len],它只会在解码时使用。
注意,在上面的这些维度中,tgt_len本质上指的其实是query_len;src_len本质上指的是key_len。只是在不同情况下两者可能会是一样,也可能会是不一样。
在定义完类MyMultiHeadAttentiond后,就需要定义出多头注意力的实际计算过程。由于这部分代码较长,所以就分层次进行介绍。
defmulti_head_attention_forward(query,#[tgt_len,batch_size,embed_dim]key,#[src_len,batch_size,embed_dim]value,#[src_len,batch_size,embed_dim]num_heads,dropout_p,out_proj_weight,#[embed_dim=vdim*num_heads,embed_dim]out_proj_bias,training=True,key_padding_mask=None,#[batch_size,src_len/tgt_len]q_proj_weight=None,#[embed_dim,kdim*num_heads]k_proj_weight=None,#[embed_dim,kdim*num_heads]v_proj_weight=None,#[embed_dim,vdim*num_heads]attn_mask=None,#[tgt_len,src_len]):#第一阶段:计算得到Q、K、Vq=F.linear(query,q_proj_weight)#[tgt_len,batch_size,embed_dim]x[embed_dim,kdim*num_heads]#=[tgt_len,batch_size,kdim*num_heads]k=F.linear(key,k_proj_weight)#[src_len,batch_size,embed_dim]x[embed_dim,kdim*num_heads]#=[src_len,batch_size,kdim*num_heads]v=F.linear(value,v_proj_weight)#[src_len,batch_size,embed_dim]x[embed_dim,vdim*num_heads]#=[src_len,batch_size,vdim*num_heads]在上述代码中,第17-23行所做的就是根据输入进行线性变换得到图3-6中的Q、K和V。
接着,在上述代码中第5-6行所完成的就是图3-7中的缩放过程;第8-16行用来判断或修改attn_mask的维度,当然这几行代码只会在解码器中的MaskedMulti-HeadAttention中用到。
#第三阶段:计算得到注意力权重矩阵q=q.contiguous().view(tgt_len,bsz*num_heads,head_dim).transpose(0,1)#[batch_size*num_heads,tgt_len,kdim]#因为前面是num_heads个头一起参与的计算,所以这里要进行一下变形,以便于后面计算。且同时交换了0,1两个维度k=k.contiguous().view(-1,bsz*num_heads,head_dim).transpose(0,1)#[batch_size*num_heads,src_len,kdim]v=v.contiguous().view(-1,bsz*num_heads,head_dim).transpose(0,1)#[batch_size*num_heads,src_len,vdim]attn_output_weights=torch.bmm(q,k.transpose(1,2))#[batch_size*num_heads,tgt_len,kdim]x[batch_size*num_heads,kdim,src_len]#=[batch_size*num_heads,tgt_len,src_len]这就num_heads个QK相乘后的注意力矩阵继续,在上述代码中第1-7行所做的就是交换Q、K、V中的维度,以便于多个样本同时进行计算;第9行代码便是用来计算注意力权重矩阵;其中上contiguous()方法是将变量放到一块连续的物理内存中;bmm的作用是用来计算两个三维矩阵的乘法操作[4]。
需要提示的是,大家在看代码的时候,最好是仔细观察一下各个变量维度的变化过程,笔者也在每次运算后进行了批注。
进一步,在上述代码中第2-3行便是用来执行图3-3中的步骤;第4-8行便是用来执行图3-5中的步骤,同时还进行了维度扩充。
在实现完类MyMultiHeadAttention的全部代码后,便可以通过类似如下的方式进行使用。
在上述代码中,第6-11行其实也就是Encoder中多头注意力机制的实现过程。同时,在计算过程中还可以打印出各个变量的维度变化信息:
下面,首先要介绍的就是对于Embedding部分的编码实现。
这里首先要实现的便是最基础的TokenEnbedding,也是字符转向量的一种常用做法,如下所示:
如上代码所示便是TokenEmbedding的实现过程,由于这部分代码并不复杂所以就不再逐行进行介绍。注意,第12行代码对原始向量进行缩放是出自论文中3.4部分的描述。
在2.1.2节中笔者已经对PositionalEmbedding的原理做了详细的介绍,其每个位置的变化方式如式所示。
进一步,我们还可以对式中括号内的参数进行化简得到如式中的形式。
由此,根据式便可以实现PositionalEmbedding部分的代码,如下所示:
在实现完这部分代码后,便可以通过如下方式进行使用:
在介绍完Embedding部分的编码工作后,下面就开始正式如何来实现Transformer网络结构。如图4-2所示,对于Transformer网络的实现一共会包含4个部分:TransformerEncoderLayer、TransformerEncoder、TransformerDecoderLayer和TransformerDecoder,其分别表示定义一个单独编码层、构造由多个编码层组合得到的编码器、定义一个单独的解码层以及构造由多个解码层得到的解码器。
需要注意的是,图4-2中的一个EncoderLayer指的就是图3-2中的一个对应的Encoder,DecoderLayer同理。
首先,我们需要实现最基本的编码层单元,也就是图4-2中的TransformerEncoderLayer,其内部结构为图4-3所示的前向传播过程(不包括Embedding部分)。
对于这部分前向传播过程,可以通过如下代码来进行实现:
classMyTransformerEncoderLayer(nn.Module):def__init__(self,d_model,nhead,dim_feedforward=2048,dropout=0.1):super(MyTransformerEncoderLayer,self).__init__()''':paramd_model:d_k=d_v=d_model/nhead=64,模型中向量的维度,论文默认值为512:paramnhead:多头注意力机制中多头的数量,论文默认为值8:paramdim_feedforward:全连接中向量的维度,论文默认值为2048:paramdropout:丢弃率,论文中的默认值为0.1'''self.self_attn=MyMultiheadAttention(d_model,nhead,dropout=dropout)self.dropout1=nn.Dropout(dropout)self.norm1=nn.LayerNorm(d_model)self.linear1=nn.Linear(d_model,dim_feedforward)self.dropout=nn.Dropout(dropout)self.linear2=nn.Linear(dim_feedforward,d_model)self.activation=F.reluself.dropout2=nn.Dropout(dropout)self.norm2=nn.LayerNorm(d_model)在上述代码中,第10行用来定义一个多头注意力机制模块,并传入相应的参数;第11-20行代码便是用来定义其它层归一化和线性变换的模块。在完成类MyTransformerEncoderLayer的初始化后,便可以实现整个前向传播的forward方法:
在上述代码中,第7-8行便是用来实现图4-3中Multi-HeadAttention部分的前向传播过程;第10-11行用来实现多头注意力后的Add&Norm部分;第13-16行用来实现图4-3中最上面的FeedForward部分和Add&Norm部分。
这里再次提醒大家,在阅读代码的时候最好是将对应的维度信息带入以便于理解。
在实现完一个标准的编码层之后,便可以基于此来实现堆叠多个编码层,从而得到Transformer中的编码器。对于这部分内容,可以通过如下代码来实现:
def_get_clones(module,N):returnnn.ModuleList([copy.deepcopy(module)for_inrange(N)])classMyTransformerEncoder(nn.Module):def__init__(self,encoder_layer,num_layers,norm=None):super(MyTransformerEncoder,self).__init__()'''encoder_layer:就是包含有多头注意力机制的一个编码层num_layers:克隆得到多个encoderlayers论文中默认为6norm:归一化层'''self.layers=_get_clones(encoder_layer,num_layers)#克隆得到多个encoderlayers论文中默认为6self.num_layers=num_layersself.norm=norm在上述代码中,第1-2行是用来定义一个克隆多个编码层或解码层功能函数;第12行中的encoder_layer便是一个实例化的编码层,self.layers中保存的便是一个包含有多个编码层的ModuleList。在完成类MyTransformerEncoder的初始化后,便可以实现整个前向传播的forward方法:
在上述代码中,第8-10行便是用来实现多个编码层堆叠起来的效果,并完成整个前向传播过程;第11-13行用来对多个编码层的输出结果进行层归一化并返回最终的结果。
在完成Transformer中编码器的实现过程后,便可以将其用于对输入序列进行编码。例如可以仅仅通过一个编码器对输入序列进行编码,然后将最后的输出喂入到分类器当中进行分类处理,这部分内容在后续也会进行介绍。下面先看一个使用示例。
if__name__=='__main__':src_len=5batch_size=2dmodel=32num_head=3num_layers=2src=torch.rand((src_len,batch_size,dmodel))#shape:[src_len,batch_size,embed_dim]src_key_padding_mask=torch.tensor([[True,True,True,False,False],[True,True,True,True,False]])#shape:[batch_size,src_len]my_transformer_encoder_layer=MyTransformerEncoderLayer(d_model=dmodel,nhead=num_head)my_transformer_encoder=MyTransformerEncoder(encoder_layer=my_transformer_encoder_layer,num_layers=num_layers,norm=nn.LayerNorm(dmodel))memory=my_transformer_encoder(src=src,mask=None,src_key_padding_mask=src_key_padding_mask)print(memory.shape)#torch.Size([5,2,32])在上述代码中,第2-6行定义了编码器中各个部分的参数值;第11-12行则是首先定义一个编码层,然后再定义由多个编码层组成的编码器;第15-16行便是用来得到整个编码器的前向传播输出结果,并且需要注意的是在编码器中不需要掩盖当前时刻之后的位置信息,所以mask=None。
在介绍完编码器的实现后,下面就开始介绍如何实现Transformer中的解码器部分。同编码器的实现流程一样,首先需要实现的依旧是一个标准的解码层,也就是图4-4所示的前向传播过程(不包括Embedding部分)。
在实现完一个标准的解码层之后,便可以基于此来实现堆叠多个解码层,从而得到Transformer中的解码器。对于这部分内容,可以通过如下代码来实现:
在上述代码中,第4行用来克隆得到多个解码层;第20-25行用来实现多层解码层的前向传播过程;第28行便是用来返回最后的结果。
在实现完Transformer中各个基础模块的话,下面就可以来搭建最后的Transformer模型了。总体来说这部分的代码也相对简单,只需要将上述编码器解码器组合到一起即可,具体代码如下所示:
在上述代码中,src表示编码器的输入;tgt表示解码器的输入;src_mask为空,因为编码时不需要对当前时刻之后的位置信息进行掩盖;tgt_mask用于掩盖解码输入中当前时刻以后的所有位置信息;memory_mask为空;src_key_padding_mask表示对编码输入序列填充部分的Token进行mask;tgt_key_padding_mask表示对解码输入序列填充部分的Token进行掩盖;memory_key_padding_mask表示对编码器的输出部分进行掩盖,掩盖原因等同于编码输入时的mask操作。
到此,对于整个Transformer的网络结构就算是搭建完毕了,不过这还没有实现论文中基于Transformer结构的翻译模型,而这部分内容笔者也将会在下一节中进行详细的介绍。当然,出了上述模块之外,Transformer中还有两个部分需要实现的就是参数初始化方法和注意力掩码矩阵生成方法,具体代码如下:
def_reset_parameters(self):forpinself.parameters():ifp.dim()>1:xavier_uniform_(p)defgenerate_square_subsequent_mask(self,sz):mask=(torch.triu(torch.ones(sz,sz))==1).transpose(0,1)mask=mask.float().masked_fill(mask==0,float('-inf')).masked_fill(mask==1,float(0.0))returnmask#[sz,sz]4.2.7Transfromer使用示例在实现完Transformer的整个完了结构后,便可以通过如下步骤进行使用:
在上述代码中,第7-13行用来生成模拟的输入数据;第15-16行用来实例化类MyTransformer;第17行用来生成解码输入时的注意力掩码矩阵;第18-21行用来执行Transformer网络结构的前向传播过程。
经过前面几节内容的介绍,相信各位读者对于Transformer的基本原理以及实现过程已经有了一个较为清晰的认识。不过想要对一个网络模型有更加深刻的认识,那么最好的办法便是从数据预处理到模型训练,自己完完全全的经历一遍。因此,为了使得大家能够更加透彻的理解Transformer的整个工作流程,在本节中笔者将继续带着大家一起来还原论文中的文本翻译模型。
如图5-1所示便是Transformer网络的整体结构图,对于这部分内容在上一节内容中总体上算是介绍完了,只是在数据预处理方面还未涉及。下面,笔者就以Multi30K[9]中的English-German平行语料为例进行介绍(注意这并不是论文中所用到数据集)。
本部分完整代码可参见[11]。
在这里,我们使用到的平行语料一共包含有6个文件train.de、train.en、val.de、val.en、test_2016_flickr.de和test_2016_flickr.en,其分别为德语训练语料、英语训练语料、德语验证语料、英语验证语料、德语测试语料和英语测试语料。同时,这三部分的样本量分别为29000、1014和1000条。
如下所示便是一条平行预料数据,其中第1行为德语,第2行为英语,后续我们需要完成的就是搭建一个翻译模型将德语翻译为英语。
ZweijungeweieMnnersindim,FreieninderNhevielerBüsche.Twoyoung,Whitemalesareoutsidenearmanybushes.5.1.2数据集预览在正式介绍如何构建数据集之前,我们先通过几张图来了解一下整个构建的流程,以便做到心中有数,不会迷路。
如图5-2所示,左边部分为原始输入,右边部分为目标输入。从图5-2可以看出,第1步需要完成的就是对原始语料进行tokenize操作。如果是对类似英文这样的语料进行处理,那就是直接按空格切分即可。但是需要注意的是要把其中的逗号和句号也给分割出来。第2步需要做的就是根据tokenize后的结果对原始输入和目标输入分别建立一个字典。第3步需要做的则是将tokenize后结果根据字典中的索引将其转换成token序列。第4步则是对同一个batch中的序列以最长的为标准其它样本进行padding,并且同时需要在目标输入序列的前后加上起止符(即'
如图5-3所示,在完成前面4个步骤后,对于目标序列来说第5步需要做的就是将第4步处理后的结果划分成tgt_input和tgt_output。从图5-3右侧可以看出,tgt_input和tgt_output是相互对应起来的。例如对于第1个样本来说,解码第1个时刻的输入应该是'2',而此时刻对应的正确标签就应该是tgt_output中的'8';解码第2个时刻的输入应该是tgt_input中的'8',而此时刻对应的正确标签就应该是tgt_output中的'45',以此类推下去。最后,第6步则是根据src_input和tgt_input各自的padding情况,得到一个paddingmask向量(注意由于这里tgt_input中的两个样本长度一样,所以并不需要padding),其中'T'表示padding的位置。当然,这里的tgt_mask并没有画出。
同时,图5-3中各个部分的结果体现在Transformer网络中的情况如图5-4所示。
以上就是基于Transformer架构的翻译模型数据预处理的整个大致流程,下面我们开始正式来通过编码实现这一过程。
第1步:定义tokenize
如果是对类似英文这样的语料进行处理,大部分就是直接按空格切分即可。但是需要注意的是单词中的某些缩写也需要给分割出来,例如'you're'需要分割成'you'和're''。因此,这部分代码可以借助torchtext中的get_tokenizer方法来实现,具体代码如下:
这里返回的是两个tokenizer,分别用于对德语和英语进行序列化。例如对于如下文本来说
s='MoonHotel,it'sveryinteresting.'其tokenize后的结果为:
第2步:建立词表
在介绍完tokenize的实现方法后,我们就可以正式通过torchtext.vocab中的Vocab方法来构建词典了,代码如下:
defbuild_vocab(tokenizer,filepath,min_freq,specials=None):ifspecialsisNone:specials=['
在完成上述过程后,我们将得到两个Vocab类的实例化对象。
一个为原始序列的字典:
一个为目标序列的字典:
{'
其中min_freq表示在构建词表时忽略掉出现次数小于该值的字。
第3步:转换为Token序列
在得到构建的字典后,便可以通过如下函数来将训练集、验证集和测试集转换成Token序列:
defdata_process(self,filepaths):'''将每一句话中的每一个词根据字典转换成索引的形式:paramfilepaths::return:'''raw_de_iter=iter(open(filepaths[0],encoding='utf8'))raw_en_iter=iter(open(filepaths[1],encoding='utf8'))data=[]for(raw_de,raw_en)inzip(raw_de_iter,raw_en_iter):de_tensor_=torch.tensor([self.de_vocab[token]fortokeninself.tokenizer['de'](raw_de.rstrip('\n'))],dtype=torch.long)en_tensor_=torch.tensor([self.en_vocab[token]fortokeninself.tokenizer['en'](raw_en.rstrip('\n'))],dtype=torch.long)data.append((de_tensor_,en_tensor_))returndata在上述代码中,第11-4行分别用来将原始序列和目标序列转换为对应词表中的Token形式。在处理完成后,就会得到类似如下的结果:
其中左边的一列就是原始序列的Token形式,右边一列就是目标序列的Token形式,每一行构成一个样本。
第4步:padding处理
从上面的输出结果(以及图5-2中第③步后的结果)可以看到,无论是对于原始序列来说还是目标序列来说,在不同的样本中其对应长度都不尽相同。但是在将数据输入到相应模型时却需要保持同样的长度,因此在这里我们就需要对Token序列化后的样本进行padding处理。同时需要注意的是,一般在这种生成模型中,模型在训练过程中只需要保证同一个batch中所有的原始序列等长,所有的目标序列等长即可,也就是说不需要在整个数据集中所有样本都保证等长。
因此,在实际处理过程中无论是原始序列还是目标序列都会以每个batch中最长的样本为标准对其它样本进行padding,具体代码如下:
defgenerate_batch(self,data_batch):de_batch,en_batch=[],[]for(de_item,en_item)indata_batch:#开始对一个batch中的每一个样本进行处理。de_batch.append(de_item)#编码器输入序列不需要加起止符#在每个idx序列的首位加上起始token和结束tokenen=torch.cat([torch.tensor([self.BOS_IDX]),en_item,torch.tensor([self.EOS_IDX])],dim=0)en_batch.append(en)#以最长的序列为标准进行填充de_batch=pad_sequence(de_batch,padding_value=self.PAD_IDX)#[de_len,batch_size]en_batch=pad_sequence(en_batch,padding_value=self.PAD_IDX)#[en_len,batch_size]returnde_batch,en_batch在上述代码中,第6-7行用来在目标序列的首尾加上特定的起止符;第9-10行则是分别对一个batch中的原始序列和目标序列以各自当中最长的样本为标准进行padding(这里的pad_sequence导入自torch.nn.utils.rnn)。
第5步:构造mask向量
在上述代码中,第1-4行是用来生成一个形状为[sz,sz]的注意力掩码矩阵,用于在解码过程中掩盖当前position之后的position;第6-17行用来返回Transformer中各种情况下的mask矩阵,其中src_mask在这里并没有作用。
第6步:构造DataLoade与使用示
经过前面5步的操作,整个数据集的构建就算是已经基本完成了,只需要再构造一个DataLoader迭代器即可,代码如下:
defload_train_val_test_data(self,train_file_paths,val_file_paths,test_file_paths):train_data=self.data_process(train_file_paths)val_data=self.data_process(val_file_paths)test_data=self.data_process(test_file_paths)train_iter=DataLoader(train_data,batch_size=self.batch_size,shuffle=True,collate_fn=self.generate_batch)valid_iter=DataLoader(val_data,batch_size=self.batch_size,shuffle=True,collate_fn=self.generate_batch)test_iter=DataLoader(test_data,batch_size=self.batch_size,shuffle=True,collate_fn=self.generate_batch)returntrain_iter,valid_iter,test_iter在上述代码中,第2-4行便是分别用来将训练集、验证集和测试集转换为Token序列;第5-10行则是分别构造3个DataLoader,其中generate_batch将作为一个参数传入来对每个batch的样本进行处理。在完成类LoadEnglishGermanDataset所有的编码过程后,便可以通过如下形式进行使用:
各位读者在阅读这部分代码时最好是能够结合图5-2到5-4进行理解,这样效果可能会更好。在介绍完数据集构建的整个过程后,下面就开始正式进入到翻译模型的构建中。
总体来说,基于Transformer的翻译模型的网络结构其实就是图5-4所展示的所有部分,只是在前面介绍Transformer网络结构时笔者并没有把Embedding部分的实现给加进去。这是因为对于不同的文本生成模型,其Embedding部分会不一样(例如在诗歌生成这一情景中编码器和解码器共用一个TokenEmbedding即可,而在翻译模型中就需要两个),所以将两者进行了拆分。同时,待模型训练完成后,在inference过程中Encoder只需要执行一次,所以在此过程中也需要单独使用Transformer中的Encoder和Decoder。
首先,我们需要定义一个名为TranslationModel的类,其前向传播过程代码如下所示:
在定义完logits的前向传播过后,便可以通过如下形式进行使用:
接着,我们需要再定义一个Encoder和Decoder在inference中使用,代码如下:
defencoder(self,src):src_embed=self.src_token_embedding(src)#[src_len,batch_size,embed_dim]src_embed=self.pos_embedding(src_embed)#[src_len,batch_size,embed_dim]memory=self.my_transformer.encoder(src_embed)returnmemorydefdecoder(self,tgt,memory,tgt_mask):tgt_embed=self.tgt_token_embedding(tgt)#[tgt_len,batch_size,embed_dim]tgt_embed=self.pos_embedding(tgt_embed)#[tgt_len,batch_size,embed_dim]outs=self.my_transformer.decoder(tgt_embed,memory=memory,tgt_mask=tgt_mask)#[tgt_len,batch_size,embed_dim]returnouts在上述代码中,第1-5行用于在inference时对输入序列进行编码并得到memory(只需要执行一次);第7-11行用于根据memory和当前解码时刻的输入对输出进行预测,需要循环执行多次,这部分内容详见模型预测部分。
在定义完成整个翻译模型的网络结构后下面就可以开始训练模型了。由于这部分代码较长,所以下面笔者依旧以分块的形式进行介绍:
第1步:载入数据集
首先我们可以根据前面的介绍,通过类LoadEnglishGermanDataset来载入数据集,其中config中定义了模型所涉及到的所有配置参数。
第2步:定义模型并初始化权重
第3步:定义损失学习率与优化器
在上述代码中,第1行是定义交叉熵损失函数,并同时指定需要忽略的索引ignore_index。因为根据图5-3的tgt_output可知,有些位置上的标签值其实是Padding后的结果,因此在计算损失的时候需要将这些位置给忽略掉。第2行代码则是论文中所提出来的动态学习率计算过程,其计算公式为:
具体实现代码为:
classCustomSchedule(nn.Module):def__init__(self,d_model,warmup_steps=4000):super(CustomSchedule,self).__init__()self.d_model=torch.tensor(d_model,dtype=torch.float32)self.warmup_steps=warmup_stepsself.step=1.def__call__(self):arg1=self.step**-0.5arg2=self.step*(self.warmup_steps**-1.5)self.step+=1.return(self.d_model**-0.5)*min(arg1,arg2)通过CustomSchedule,就能够在训练过程中动态的调整学习率。学习率随step增加而变换的结果如图5-5所示:
从图5-5可以看出,在前warm_up个step中,学习率是线性增长的,在这之后便是非线性下降,直至收敛与0.0004。
第4步:开始训练
Epoch:2,Trainloss:5.685,Trainacc:0.240947Epoch:2,Trainloss:5.668,Trainacc:0.241493Epoch:2,Trainloss:5.714,Trainacc:0.224682Epoch:2,Trainloss:5.660,Trainacc:0.235888Epoch:2,Trainloss:5.584,Trainacc:0.242052Epoch:2,Trainloss:5.611,Trainacc:0.2434285.2.3模型预测在介绍完模型的训练过程后接下来就来看模型的预测部分。生成模型的预测部分不像普通的分类任务只需要将网络最后的输出做argmax操作即可,生成模型在预测过程中往往需要按时刻一步步进行来进行。因此,下面我们这里定义一个translate函数来执行这一过程,具体代码如下:
在上述代码中,第6行是将待翻译的源序列进行序列化操作;第8-11行则是通过函数greedy_decode函数来对输入进行解码;第12行则是将最后解码后的结果由Token序列在转换成实际的目标语言。同时,greedy_decode函数的实现如下:
最后,我们只需要调用如下函数便可以完成对原始输入语言的翻译任务:
在上述代码中,第5-14行是定义网络结构,以及恢复本地保存的网络权重;第15行则是开始执行翻译任务;第19-28行为翻译示例,其输出结果为:
德语:EineGruppevonMenschenstehtvoreinemIglu.翻译:Agroupofpeoplestandinginfraonofanigloo.英语:Agroupofpeoplearefacinganigloo.=========德语:EinMannineinemblauenHemdstehtaufeinerLeiterundputzteinFenster.翻译:Amaninablueshirtisstandingonaladdercleaningawindow.英语:Amaninablueshirtisstandingonaladdercleaningawindow.其中第1句德语为训练集之外的数据。由于篇幅限制,余下两节内容在这里就不在介绍。公众号回台回复“Transformer”即可获得全文高清PDF!
转发送礼活动
礼物包括:
①《动手学深度学习》、《统计学习方法》和《跟我一起学机器学习》任选一本;
②「月来客栈」定制马克杯一个;
③《赤壁赋》大号鼠标垫一个。
[1]VaswaniA,ShazeerN,ParmarN,etal.Attentionisallyouneed