前言
前面写了两篇关于transformer的原理的文章,分别是
Transformer原理 | Sunrise (forchenxi.github.io)
Transformer block | Sunrise (forchenxi.github.io)
这篇主要是将哈佛大学2018年写的一篇关于transformer的详细注解包括pytorch
版本的实现过程《The Annotated Transformer》翻译为中文。
原文地址:The Annotated Transformer (harvard.edu)
知乎翻译文章地址:搞懂Transformer结构,看这篇PyTorch实现就够了 - 知乎 (zhihu.com)
正文
0 准备工作
1 | # !pip install http://download.pytorch.org/whl/cu80/torch-0.3.0.post4-cp36-cp36m-linux_x86_64.whl numpy matplotlib spacy torchtext seaborn |
1 背景
减少序列处理任务的计算量是一个很重要的问题,也是Extended Neural GPU、ByteNet和ConvS2S等网络的动机。上面提到的这些网络都以CNN为基础,并行计算所有输入和输出位置的隐藏表示。
在这些模型中,关联来自两个任意输入或输出位置的信号所需的操作数随位置间的距离增长而增长,比如ConvS2S呈线性增长,ByteNet呈现以对数形式增长,这会使学习较远距离的两个位置之间的依赖关系变得更加困难。而在Transformer中,操作次数则被减少到了常数级别。
Self-attention有时候也被称为Intra-attention,是在单个句子不同位置上做的Attention,并得到序列的一个表示。它能够很好地应用到很多任务中,包括阅读理解、摘要、文本蕴涵,以及独立于任务的句子表示。端到端的网络一般都是基于循环注意力机制而不是序列对齐循环,并且已经有证据表明在简单语言问答和语言建模任务上表现很好。
据我们所知,Transformer是第一个完全依靠Self-attention而不使用序列对齐的RNN或卷积的方式来计算输入输出表示的转换模型。
2 模型结构
目前大部分比较热门的神经序列转换模型都有Encoder-Decoder结构。Encoder将输入序列 (x1, x2, ..., xn)
映射到一个连续表示序列 z=(z1,....zn)
。
对于编码得到的z,Decoder每次解码生成一个符号,直到生成完整的输出序列: (y1,....yn)
。对于每一步解码,模型都是自回归的,即在生成下一个符号时将先前生成的符号作为附加输入。
1 | class EncoderDecoder(nn.Module): |
代码中有一些注释,我是专门针对transformer注释的(即仅在transformer模型结构的前提下,部分属性是这样理解的,如果是其他Encoder-Decoder模型则可能有其他的含义)。Transformer的整体结构如下图所示,在Encoder和Decoder中都使用了Multi-head attention(本质是多个Self-attention), Point-wise和全连接层。
Encoder
1 | def clones(module, n): |
我们在每两个子层之间都使用了残差连接(Residual Connection) 和归一化。# BN/LN的计算方式,详见PyTorch正则化和批标准化 | Sunrise (forchenxi.github.io)
1 | # LayerNorm可以自行实现,也可以使用Pytorch内置的 |
也就是说,每个子层的输出为(LayerNorm(x + SubLayer(x)))
,其中SubLayer(x)
是由子层自动实现的函数。我们在每个子层的输出上使用Dropout,然后将其添加到下一子层的输入并进行归一化。
为了能方便地使用这些残差连接,模型中所有的子层和Embedding层的输出都设定成了相同的维度,即dmodel=512
。
这里残差网络的forward方法,sublayer参数由传入的方法而定,在transformer模型中一般有两种情况,一种情况:sublayer是multi-head attention层,进行一次残差连接;另一种情况:sublayer是feed forward层,进行一次残差连接。
1 | class SublayerConnection(nn.Module): |
每层都有两个子层组成。第一个子层实现了“多头”的 Self-attention,第二个子层则是一个简单的Position-wise的全连接前馈网络。
1 | class EncoderLayer(nn.Module): |
Decoder
Decoder也是由N=6个相同层组成。
1 | class Decoder(nn.Module): |
通过transformer encoder-decoder的图形结构能够看出来,decoder也是由N个DecoderLayer层堆叠而成,但是DecoderLayer相比EncoderLayer,除了multi-head attention和feed forward两个子层之外,还插入了第三种子层对编码器栈的输出实行“多头”的Attention。图中这一子层在原有的两个子层的前面插入,与编码器类似,在每个子层两端使用残差连接进行短路,然后进行层的规范化处理。
1 | class DecoderLayer(nn.Module): |
我们还修改解码器中的Self-attention子层以防止当前位置Attend到后续位置。这种Masked的Attention是考虑到输出Embedding会偏移一个位置,确保了生成位置i的预测时,仅依赖小于i的位置处的已知输出,相当于把后面不该看到的信息屏蔽掉。
1 | def subsequent_mask(size): |
下面的Attention mask图显示了允许每个目标词(行)查看的位置(列)。在训练期间,当前解码位置的词不能Attend到后续位置的词。
1 | plt.figure(figsize=(5, 5)) |
Attention
Attention函数可以将Query和一组Key-Value对映射到输出,其中Query、Key、Value和输出都是向量。 输出是值的加权和,其中分配给每个Value的权重由Query与相应Key的兼容函数计算。
我们称这种特殊的Attention机制为”Scaled Dot-Product Attention”。输入包含维度为dk的Query和Key,以及维度为dv的Value。 我们首先分别计算Query与各个Key的点积,然后将每个点积除以dk的平方根,最后使用Softmax函数来获得Key的权重。
在具体实现时,我们可以以矩阵的形式进行并行运算,这样能加速运算过程。具体来说,将所有的Query、Key和Value向量分别组合成矩阵Q、K和V,这样输出矩阵可以表示为:
1 | def attention(query, key, value, mask=None, dropout=None): |
“多头”机制能让模型考虑到不同位置的Attention,另外“多头”Attention可以在不同的子空间表示不一样的关联关系,使用单个Head的Attention一般达不到这种效果。
我们的工作中使用 ℎ=8个Head并行的Attention,对每一个Head来说有 dk = dv = model /ℎ=64,总计算量与完整维度的单个Head的Attention很相近。
1 | class MultiHeadAttention(nn.Module): |
Attention在模型中的应用
Transformer中以三种不同的方式使用了“多头”Attention:
1) 在”Encoder-Decoder Attention”层,Query来自先前的解码器层,并且Key和Value来自Encoder的输出。Decoder中的每个位置Attend输入序列中的所有位置,这与Seq2Seq模型中的经典的Encoder-Decoder Attention机制一致。
2) Encoder中的Self-attention层。在Self-attention层中,所有的Key、Value和Query都来同一个地方,这里都是来自Encoder中前一层的输出。Encoder中当前层的每个位置都能Attend到前一层的所有位置。
3) 类似的,解码器中的Self-attention层允许解码器中的每个位置Attend当前解码位置和它前面的所有位置。这里需要屏蔽解码器中向左的信息流以保持自回归属性。具体的实现方式是在缩放后的点积Attention中,屏蔽(设为负无穷)Softmax的输入中所有对应着非法连接的Value。
Position-wise前馈网络
除了Attention子层之外,Encoder和Decoder中的每个层都包含一个全连接前馈网络,分别地应用于每个位置。其中包括两个线性变换,然后使用ReLU作为激活函数。
虽然线性变换在不同位置上是相同的,但它们在层与层之间使用不同的参数。这其实是相当于使用了两个内核大小为1的卷积。这里设置输入和输出的维数为dmodel=512,内层的维度为dff=2048。
1 | class PositionWiseFeedForward(nn.Module): |
Embedding和Softmax
与其他序列转换模型类似,我们使用预学习的Embedding将输入Token序列和输出Token序列转化为dmodel维向量。我们还使用常用的预训练的线性变换和Softmax函数将解码器输出转换为预测下一个Token的概率。在我们的模型中,我们在两个Embedding层和Pre-softmax线性变换之间共享相同的权重矩阵,类似于。在Embedding层中,我们将这些权重乘以根号dmodel。
1 | class Embeddings(nn.Module): |
位置编码
由于我们的模型不包含递归和卷积结构,为了使模型能够有效利用序列的顺序特征,我们需要加入序列中各个Token间相对位置或Token在序列中绝对位置的信息。在这里,我们将位置编码添加到编码器和解码器栈底部的输入Embedding。由于位置编码与Embedding具有相同的维度dmodel,因此两者可以直接相加。其实这里还有许多位置编码可供选择,其中包括可更新的和固定不变的。
在此项工作中,我们使用不同频率的正弦和余弦函数:
此外,在编码器和解码器堆栈中,我们在Embedding与位置编码的加和上都使用了Dropout机制。 在基本模型上, 我们使用pdrop=0.1的比率。
1 | class PositionEncoding(nn.Module): |
这里的位置编码的代码实现方式与公式给出的有一定的转变,下面这种实现方式更容易理解
1 | _2i = torch.arange(0, d_model, step=2, device=device).float() |
上面代码给出的实现方式的转换原理如下:
如下所示,位置编码将根据位置添加正弦曲线。曲线的频率和偏移对于每个维度是不同的。
1 | plt.figure(figsize=(15, 5)) |
我们也尝试了使用预学习的位置Embedding,但是发现这两个版本的结果基本是一样的。我们选择正弦曲线版本的实现,因为使用此版本能让模型能够处理大于训练语料中最大序了使用列长度的序列。
3 完整模型
下面定义了连接完整模型并设置超参的函数。
1 | def make_model(src_vocab, tgt_vocab, n=6, d_model=512, d_ff=2048, h=8, dropout=0.1): |
4 训练
本节介绍模型的训练方法。
快速穿插介绍训练标准编码器解码器模型需要的一些工具。首先我们定义一个包含源和目标句子的批训练对象用于训练,同时构造掩码。
批和掩码
1 | class Batch: |
接下来,我们创建一个通用的训练和得分函数来跟踪损失。我们传入一个通用的损失计算函数,它也处理参数更新。
训练循环
1 | def run_epoch(data_iter, model, loss_compute): |
优化器
注意:这部分非常重要,需要这种设置训练模型。
1 | class NoamOpt: |
当前模型学习率在不同模型大小和超参数的情况下的曲线示例。
1 | opts = [NoamOpt(512, 1, 4000, None), |
正则化&标签平滑
在训练期间,我们采用了平滑值为0.1[2]的标签平滑。 这种做法提高了困惑度,因为模型变得更加不确定,但提高了准确性和BLEU分数。
我们使用KL div loss实现标签平滑。 相比使用独热目标分布,我们创建一个分布,其包含正确单词的置信度和整个词汇表中分布的其余平滑项。
1 | class LabelSmoothing(nn.Module): |
这里标签平滑时的赋值方法:这里假定size个值中有一个为padding值(赋值0),然后还有一个值是真值(赋值confidence),剩下的值的和为1-confidence的,即每个值为smoothing/(size-2),所有值的加和为1。
在这里,我们可以看到标签平滑的示例。
1 | # Example of label smoothing. |
如果对给定的选择非常有信心,标签平滑实际上会开始惩罚模型。
1 | crit = LabelSmoothing(5, 0, 0.1) |
这里惩罚的含义是指啥?没有太搞明白。
当x=1时,d=4,predict = [[0.0000, 0.2500, 0.2500, 0.2500, 0.2500]]
取完对数之后,tensor_logs = [[ -inf, -1.3863, -1.3863, -1.3863, -1.3863]]
根据指定标签(torch.LongTensor([1]))标签平滑后的true_dist为:
tensor([[0.0000, 0.9000, 0.0333, 0.0333, 0.0333]])
计算tensor_logs和true_dist之间的损失值为:0.9514
当x=2时,d=5,predict = [[0.0000, 0.4000, 0.2000, 0.2000, 0.2000]]
取完对数之后,tensor_logs = [[ -inf, -0.9163, -1.6094, -1.6094, -1.6094]]
标签平滑后的true_dist依然为:[[0.0000, 0.9000, 0.0333, 0.0333, 0.0333]]
实际上只要置信度和标签索引保持不变,true_dist也不会改变
计算tensor_logs和true_dist之间的损失值为:0.5507
当x=97时,d=100,predict = [[0.0000, 0.9700, 0.0100, 0.0100, 0.0100]]
tensor_logs = [[ -inf, -0.0305, -4.6052, -4.6052, -4.6052]]
5 第一个例子
数据生成
1 | def data_gen(v, batch, nbatches): |
损失计算
1 | class SimpleLossCompute: |
贪心解码
1 | # Train the simple copy task. |
这里面的训练输入值x(src)为1~11之间的随机整数,size为(30, 10),但是随后将每一行的第1维都置为了1(推测是一种特殊标志,即非训练数据的表示);训练标签y(trg_y)相比x仅是size有所改变,为(30, 9),其与输入x相对应,剔除掉了每一行的第1维;同样还有一个训练标签trg,其作为输入经过decoder(有masked多头注意力),其损失对比的是模型的输出out和trg_y之间的损失。
到这里好像才刚理解,原来decoder的输入是标签值,但是这里的trg剔除了每一行的最后一维(不太理解为啥),size也是(30, 9)。在第二个遮蔽的多头注意力时,才开始使用encoder输出的memory值。
因为这里的输入x和输出y是相同的值,所以此次训练的任务是一个复制任务。
为简单起见,此代码使用贪心解码来预测翻译。
1 | def greed_decode(model, src, src_mask, max_len, start_symbol): |