论文:Attention Is All You Need
视频:Transformer论文逐段精读【论文精读】_哔哩哔哩_bilibili
课程(推荐先看这个):李宏毅机器学习:self-attention(自注意力机制)和transformer及其变形
代码:/tensorflow/tensor2tensor
The Annotated Transformer
万字逐行解析与实现Transformer iioSnail的博客-CSDN博客_transformer实战
本文主要参考博文并摘取文字和图片:李沐论文精读系列一: ResNet、Transformer、GAN、BERT_神洛华的博客
Transformer模型详解_爱编程真是太好了的博客
The Illustrated Transformer – Jay Alammar – Visualizing machine learning one concept at a time.
目录
1 简介
2 结论
3 导论
4 背景
5 模型架构
模型特点
李宏毅关于自回归的解释
模型架构图
代码
6 编码器和解码器
6.1 编码器
6.2 解码器
结构
decoder端的输入
6.3 编码器和解码器结构图
6.4 为什么使用LN而不是BN
三维表示
二维表示
7 注意力机制
7.1 Self-Attention
7.2 缩放的点积注意力(Scaled Dot-Product Attention)
步骤
矩阵实现
为什么进行缩放
如何做mask
7.3 多头注意力
李宏毅的解释
论文的解释
使用多头自注意力的好处
7.4 Decoder 的 Encode-Decode Attention 层
7.5 注意力在Transformer中的应用(总结)
7.6 基于位置的前馈神经网络(Position-wise Feed-Forward Networks)
7.7 输出层Softmax
7.8 Embeddings词嵌入
7.9 位置编码(Positional Encoding)
8 为什么使用自注意力机制
9 实验
训练数据和批处理
硬件和时间
优化器
正则化
模型配置
10 评价
11.模型建立和应用
导包
构建完整模型
测试一下模型
模型训练
损失计算
工具类
训练状态类
训练函数
第一个例子
生成数据
使用贪心算法解码(Greedy Decoding)
实战:德译英
数据加载
Iterators迭代器
训练模型
测试结果
模型完整代码
12.补充
并行计算
copy.copy()和copy.deepcopy()
contiguous()
1 简介
主流的序列转换模型(由一个序列生成另一个序列)都是基于复杂的循环或卷积神经网络,这个模型包含一个编码器和一个解码器。论文提出了一个新的简单网络结构——Transformer,其仅仅是基于注意力机制,而完全不需要循环或卷积 。在两个机器翻译任务上的实验表明,该模型具有更好的性能,同时并行度更好,并且训练时间更少。泛化到其它任务效果也不错。
这篇文章最开始只是针对机器翻译来写的,transformer在机器翻译上效果也很好。但是随着bert、GPT等把这种架构用在更多的NLP任务上,甚至后面CV和video等也可以使用注意力机制,整个工作就火出圈了。
2 结论
本文介绍了Transformer,这是第一个完全基于注意力的序列转换模型,用多头自注意力(multi-headed self-attention)代替了 encoder-decoder 架构中最常用的循环层。对于翻译任务,Transformer可以比基于循环或卷积层的体系结构训练更快。
未来方向:将Transformer应用于文本之外的涉及输入和输出模式的问题中任务,以有效处理大型输入&输出任务,如图像、音频和视频等。让生成不那么时序化。
3 导论
序列建模和转换问题(如机器翻译)最新方法是LSTM和GRN等,后面许多研究都围绕循环语言模型和编码器-解码器体系结构进行。
循环网络模型通常是考虑了输入和输出序列的中字符位置的计算,计算限制为是顺序的。这种机制带来了两个问题:
1.时刻t的隐藏状态ht,是由上一时刻隐藏状态 ht−1和 t时刻输入共同决定的。这样可以把之前学到的历史信息都放在隐藏状态里,一个个传递下去。这种固有的时序模型难以并行化处理,无法利用GPU/TPU的并行计算功能,计算性能就很差。这些年做了一些并行化改进,但是问题依然存在。
2.存在长距离衰减问题,解码阶段越靠后的内容,翻译效果越差。如果不想丢掉,就要把ht维度设置的很高,并且在每一个时间步的信息都把它存下来,这样会造成内存开销很大。尽管 LSTM 等门控机制的结构一定程度上缓解了长期依赖的问题,但是对于特别长期的依赖现象,LSTM 依旧无能为力。
This inherently sequential nature precludes parallelization within training examples, which becomes critical at longer sequence lengths, as memory constraints limit batching across examples
自注意力模型的权重是动态生成的,因此可以处理变长的信息序列,优势不仅仅在于对词语进行编码时能充分考虑到词语上下文中的所有信息,还在于序列是一次性送入模型,通过矩阵运算做到并行化处理。attention在此之前,已经成功的应用在encoder-decoder 架构中,但主要是用在如何把编码器的信息有效的传递给解码器,所以是和RNN一起使用的。
本文提出的Transformer,不再使用循环神经层,而是纯基于注意力机制,来构造输入和输出之间的全局依赖关系(draw global dependencies between input and output)。Transformer可以进行更多的并行化,训练时间更短,翻译效果更好。
4 背景
使用卷积神经网络替换循环神经网络,并行计算所有输入和输出位置的隐藏表示,是扩展神经GPU,ByteNet和ConvS2S的基础,因为这样可以减少时序计算。但是CNN对长序列难以建模,因为卷积计算时,卷积核/感受野比较小,如果序列很长,需要使用多层卷积才可以将两个比较远的位置关联起来。但是使用Transformer的注意力机制的话,每次(一层)就能看到序列中所有的位置,就不存在这个问题。关联来自两个任意输入或输出位置的数据所需的操作数量,随着距离增长,对于ConvS2S呈线性,对于ByteNet呈对数,而对于Transformer是常数,因为一次就看到了。
但是卷积的好处是,一个输出可以有多个通道,每个通道可以认为是识别不同的模式,作者也想得到这种多通道输出的效果,所以提出了Multi-Head Attention多头注意力机制,去模拟卷积多通道输出效果。
Attention可以理解为一种序列聚焦方法,基本思想是对序列分配注意力权重,把注意力集中在最相关的序列上,在此之前已成功用于多种任务。但Transformer是第一个完全依靠self-attention,而不使用卷积或循环的的encoder-decoder 转换模型。
5 模型架构
大部分序列模型(neural sequence transduction models)都是encoder-decoder结构。encoder负责将一个符号表示的输入序列 (x1,...,xn) 映射为一个连续表示的序列 z=(z1,...,zn)。然后将 z作为Decoder的其中一个输入,decoder会一次一个的产生字符输出序列(output sequence of symbols) (y1,...,ym)。在每个时刻,模型都是自回归的(auto-regressive),也就是上一个时刻的产生的字符,作为写一个时刻额外的输入。
而Transformer也是这样的encoder-decoder结构.
模型特点
Encoder 和 Decoder 的输入都是单词的 Embedding 向量 和 位置编码(Positional Encoding)。Encoder 的初始输入是训练集——输入序列 X(x1,...xn),对于句子而言xt表示第t个词,将X映射到一个连续的表示 z=(z1,...zn)中,其中zt是对应于xt的一个向量表示。Decoder 的初始输入是训练集的标签Y,并且需要整体右移(Shifted Right)一位。此外在 Decoder 中,第二子层的输入为 Encoder 的输出(key 向量和 value 向量)以及前一子层的输出(query 向量)。解码器生成输出序列 (y1,...ym),每一步生成一个元素。编码器和解码器序列可以不一样长。解码器是自回归(auto-regressive)模型,它在生成下一个结果时,会将先前生成的所有结果加入输入序列。自回归模型的特点:过去时刻的输出可以作为当前时刻的输入。At each step the model is auto-regressive , consuming the previously generated symbols as additional input when generating the next.
最后的输出要通过Linear层(全连接层),再通过 softmax 做预测。
李宏毅关于自回归的解释
Decoder先输出BEGIN 这个Token产生“机”这个输出,现在Decoder的输入有 BEGIN 和“机”,根据这两个输入,输出一个蓝色的向量,根据这个蓝色的向量确定第二个输出,再作为输入,继续输出后续的文字,以此类推
模型架构图
Transformer采用这种架构设计,对编码器和解码器使用堆叠的自注意力和全连接层,分别如下图的左半部分和右半部分所示。
整体上Transformer由四部分组成:
Inputs : 可以理解为原始文本。等于Word Embedding(Inputs) + Positional Embedding
Word Embedding:假设我们有两个字典
[0(<bos>), 1(<eos>), 2(<pad>), 3(<unk>), 4(Love), 5(I), 6(You), ..., 100(other)]
[0(<bos>), 1(<eos>), 2(<pad>), 3(<unk>), 4(爱), 5(我), 6(你), ..., 100(其他)]
“I love you”根据字典对应的index转换为[5, 4, 6]。
假设句子长度固定为7,最终转变为向量[[ 0, 5, 4, 6, 1, 2, 2 ]]
,这里的Shape为(1,7),1是batch size, 7是句子长度。而向量中的0表示开始(<bos>
),1表示结束(<eos>
),2表示填充(<pad>
),最后有两个2因为需要填充两个字符。将文字转换为向量后,就会经过Embedding层对向量进行编码,将一个字符编码成dmodel
维的向量
Outputs : Outputs是上一次Decoder的输出。等于Word Embedding(Outputs) + Positional EmbeddingEncoders stack : 由六个相同的Encoder层组成,除了第一个Encoder层的输入为Inputs,其他Encoder层的输入为上一个Encoder层的输出Decoders stack : 由六个相同的Decoder层组成,除了第一个Decoder层的输入为Outputs和最后一个Encoder层的输出,其他Decoder层的输入为上一个Decoder层的输出和最后一个Encoder层的输出
每一个 encoder 和 decoder 的内部简版结构如下图,Encoder层和Decoder层之间的差别在于Decoder中多了一个Encoder-Decoder Attention子层,而其他两个子层的结构在两者中是相同的:
代码
注意:
src_embed、tgt_embed分别对encoder和decoder的输入做Embedding和Position Encoding。 在EncoderDecoder初始化的时候,src_embed等于nn.Sequential(Embeddings(d_model, src_vocab), c(position)),也就是分两步:先做词嵌入Embeddings,然后做PositionalEncoding,其中PositionalEncoding会产生位置编码,并与Embeddings的输出相加generator负责对Decoder的输出做最后的预测(Linear+Softmax),generator的调用是放在模型外面的。在推理时,generator使用的并不是decoder的所有输出,而是最后一个out[:, -1]
,而在训练时,则是使用Decoder的全部输出。encoder的输出作为memory,输入decoder(self.decode函数)src: 未进行word embedding的句子,例如`[[ 0, 5, 4, 6, 1, 2, 2 ]]`,shape为(1, 7),即batch size为1,句子词数为7。其中0为bos,1为eos, 2为padtgt: 未进行word embedding的目标句子,例如`[[ 0, 7, 6, 8, 1, 2, 2 ]]`src_mask 用于对encoder的多头注意力,盖住非句子的部分,例如`[[True, True, True, True, True, False, False]]`。 相当于对上面`[[ 0, 5, 4, 6, 1, 2, 2 ]]`中最后的`2,2`进行掩盖。
[True,False,False,False,False,False,False], # 从该行开始,每次多一个True
[True,True,False,False,False,False,False],
[True,True,True,False,False,False,False],
[True,True,True,True,False,False,False],
[True,True,True,True,True,False,False], # 由于该句一共5个词,所以从该行开始一直都只是5个True
[True,True,True,True,True,False,False],
[True,True,True,True,True,False,False],
decoder每一层的corss attention做掩码tgt_mask用于对decoder每一层的Mask Attention做掩码 shape为(N, L, L),其中N为batch size, L为target的句子长度。例如(1, 7, 7),对于上面的例子,一个下三角矩阵,右上三角全为false,对角线及左下三角为true当完成decoder的计算后,接下来可以使用self.generator(nn.Linear+Softmax)来进行最后的预测。Output Probabilities是线性层后经过Softmax的概率分布。最大的值对应的index,然后再去字典中查询,就知道预测的词是什么了。
class EncoderDecoder(nn.Module):"""标准的Encoder-Decoder架构""" def __init__(self, encoder, decoder, src_embed, tgt_embed, generator):super(EncoderDecoder, self).__init__()self.encoder = encoderself.decoder = decoderself.src_embed = src_embed # 源序列embeddingself.tgt_embed = tgt_embed # 目标序列embeddingself.generator = generator # 生成目标单词的概率def forward(self, src, tgt, src_mask, tgt_mask):"""接收和处理原序列,目标序列,以及他们的mask"""return self.decode(self.encode(src, src_mask), src_mask,tgt, tgt_mask)def encode(self, src, src_mask):return self.encoder(self.src_embed(src), src_mask)def decode(self, memory, src_mask, tgt, tgt_mask):return self.decoder(self.tgt_embed(tgt), memory, src_mask, tgt_mask)
6 编码器和解码器
6.1 编码器
假设句子长度为n,那么编码器的输入是n个长为d的 embedding 向量与位置编码进行结合后的向量。
编码器由N=6个相同encoder层堆栈组成。下面是克隆encoder层用到的函数
def clones(module, N):"""产生N个相同的层"""return nn.ModuleList([copy.deepcopy(module) for _ in range(N)])
下面的代码将encoder层克隆之后,最后一层结果经过LayerNorm,输出
初始化用到的layer就是下文的EncoderLayer。
x: 进行过Embedding和位置编码后的输入inputs。Shape为(batch_size, 词数,词向量维度)。例如(1, 7, 512),batch_size为1,7个词,每个词512维
class Encoder(nn.Module):"Core encoder is a stack of N layers"def __init__(self, layer, N):super(Encoder, self).__init__()self.layers = clones(layer, N)self.norm = LayerNorm(layer.size)def forward(self, x, mask):"Pass the input (and mask) through each layer in turn."for layer in self.layers:x = layer(x, mask)return self.norm(x)
每层有两个子层:
1.multi-head self-attention
2.FFNN层(前馈神经网络层,Feed Forward Neural Network),其实就是MLP,为了fancy一点,就把名字起的很长。
对照上图,进行层内连接
size: 就是d_model,也是词向量的维度。
self_attn: MultiHead Self-Attention模型
class EncoderLayer(nn.Module):"Encoder is made up of self-attn and feed forward (defined below)"def __init__(self, size, self_attn, feed_forward, dropout):super(EncoderLayer, self).__init__()self.self_attn = self_attnself.feed_forward = feed_forwardself.sublayer = clones(SublayerConnection(size, dropout), 2) # 克隆两个SublayerConnection,第一个给Attention用,第二个给Feed Forward用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) # 前馈网络,残差连接
特点:
两个子层都使用残差连接(residual connection),即每个子层的输出是LayerNorm(x + Sublayer(x)),其中Sublayer(x)是当前子层的函数。 注意,下面残差连接的代码是先做LayerNorm,然后再送入子层,和论文不太一样最后一个EncoderLayer层Add后并没有Norm,所以要补一个,这也就是为什么在Encoder类的返回是return self.norm(x)
而不是直接return x
。
class SublayerConnection(nn.Module):"""Add+Norm"""def __init__(self, size, dropout):super(SublayerConnection, self).__init__()self.norm = LayerNorm(size) # 实现见后文self.dropout = nn.Dropout(dropout)def forward(self, x, sublayer):"""add norm"""norm = self.dropout(sublayer(self.norm(x)))return x + norm
然后进行层归一化(layer normalization)
dmodel的设置:
为了简单起见,模型中的所有子层以及嵌入层的输出向量维度都是dmodel= 512(如果输入输出维度不一样,残差连接就需要做投影,将其映射到统一维度)。这和之前的CNN或MLP做法是不一样的,比如CNN会改变空间维度和通道维度各层统一维度使得模型比较简单,只有N和 dmodel两个参数需要调。这个也影响到后面一系列网络,比如bert和GPT等等。
6.2 解码器
结构
解码器同样由 N=6个相同的decoder层堆栈组成。
初始化用到的layer就是下文的decoder
x: 进行过Embedding和位置编码后的“输入outputs”。Shape为(batch_size, 词数,词向量维度)。 例如(1, 7, 512),batch_size为1,7个词,每个词512维。在预测时,x的词数会不断变化,x的shape第一次为(1, 1, 512),第二次为(1, 2, 512),以此类推。
class Decoder(nn.Module):"Generic N layer decoder with masking."def __init__(self, layer, N):super(Decoder, self).__init__()self.layers = clones(layer, N)self.norm = LayerNorm(layer.size)def forward(self, x, memory, src_mask, tgt_mask):for layer in self.layers:x = layer(x, memory, src_mask, tgt_mask)return self.norm(x)
每个层有三个子层。
1.masked multi-head self-attention:做预测的时候,接受encoder 的输入,和自己之前的输出,决定接下来输出的一个向量
2.Encoder-Decoder Attention :编码器输出最终向量,将会输入到每个解码器的Encoder-Decoder Attention层,用来帮解码器把注意力集中输入序列的合适位置。
3. FFNN
与编码器类似,每个子层都使用残差连接,最后进行层归一化。
下面对三个子层进行连接。
size: d_model,也就是词向量的维度。
encoder-decoder-attention层中,q向量来自上一层的输入,k和v向量是encoder最后层的输出向量memory
class DecoderLayer(nn.Module):"Decoder is made of self-attn, src-attn, and feed forward (defined below)"def __init__(self, size, self_attn, src_attn, feed_forward, dropout):super(DecoderLayer, self).__init__()self.size = sizeself.self_attn = self_attnself.src_attn = src_attnself.feed_forward = feed_forwardself.sublayer = clones(SublayerConnection(size, dropout), 3)def forward(self, x, memory, src_mask, tgt_mask): m = memoryx = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, tgt_mask))# DecoderLayer的第二个Attention(中间那个),attention的key和value使用的是memory x = self.sublayer[1](x, lambda x: self.src_attn(x, m, m, src_mask))return self.sublayer[2](x, self.feed_forward)
decoder端的输入
Encoder的输入是原句子(即要被翻译的句子),而Decoder的输入是目标句子(即翻译后的句子)。decoder在运行的时候,t 时刻的预测需要之前所有时刻的输出作为输入。举例说明:我爱中国
→I Love China
位置关系:
0-“I”1-“Love”2-“China”
操作:整体右移一位(Shifted Right)
0-</s>【起始符】目的是为了预测下一个Token1-“I”2-“Love”3-“China”
具体步骤:
Time Step 1
初始输入: 起始符</s>
中间输入:(我爱中国
)Encoder EmbeddingDecoder:产生预测I
Time Step 2
初始输入:起始符</s>
+I
中间输入:(我爱中国
)Encoder EmbeddingDecoder:产生预测Love
Time Step 3
初始输入:起始符</s>
+I
+Love
中间输入:(我爱中国
)Encoder EmbeddingDecoder:产生预测China
decoder端的输入在训练和推理阶段是不同的。
在推理的时候,Decoder是一遍一遍的执行,每次的输入都是之前的所有输出在训练的时候,一次将目标句子(训练集的标签)全部送给Decoder,通过掩码(mask)的方式来得到和推理一个一个同样的结果。 掩码效果相当于Decoder的输入整体右移(Shifted Right)一位。
6.3 编码器和解码器结构图
假设一个 Transformer 是由 2 层编码器和两层解码器组成的,那么结构如下图所示:
decoder 有很多层 self-attention,Transformer 论文中,每一层 self-attention 的输出都是与 encoder 最后的输出 sequence 做 cross attention。但是也可以用不同的设计,比如Decoder可以连接Encoder中的许多层而不一定只是最后一层。
6.4 为什么使用LN而不是BN?
Normalization有很多种,但是它们都有一个共同的目的,那就是把输入转化成均值为0,方差为1的数据。我们在把数据送入激活函数之前进行 Normalization,因为我们不希望输入数据落在激活函数的饱和区。
三维表示
输入可以看成3维向量:
对于CV任务,N是样本数,C是通道数,高是图片的H×W对于NLP任务,N是样本数,C是序列长度,高是每个词的向量长度(512)
Batch Normalization和Layer Normalization可以用下图表示。图中要在蓝色的区域内做Normalization
二维表示
Batch Normalization:在特征/通道维度做归一化,即归一化不同样本的同一特征。
计算不定长序列时,不够长的序列后面会pad 0,这样做特征维度归一化缺少实际意义。输入序列长度变化大时,不同 batch 计算出来的均值和方差抖动很大。预测时使用训练时记录下来的全局均值和方差。如果预测时新样本特别长,超过训练时的长度,那么超过部分是没有记录的均值和方差的,预测会出现问题。
Layer Normalization:在每一个样本上计算均值和方差,进行归一化,即归一化一个样本所有特征。
NLP任务中一个序列的所有token都是同一语义空间,进行LN归一化有实际意义因为是在每个样本内做的,序列变长时,相比BN计算的数值更稳定。不需要存一个全局的均值和方差,预测样本长度不影响最终结果。
Batch Normalization和Layer Normalization的实现细节,可以参考Batch Normalization和Dropout_iwill323的博客
CS231n笔记-CNN网络结构_iwill323的博客
下面`torch.nn.BatchNorm2d`的作用一致。官方文档BatchNorm2d — PyTorch 1.13 documentation
features: int类型,含义为特征数。也就是一个词向量的维度,一般和dmodel一致。
x: 为Attention层或者Feed Forward层的输出。Shape和Encoder的输入一样。(其实整个过程中,x的shape都不会发生改变)。例如,x的shape为(1, 7, 512),即batch_size为1,7个单词,每个单词是512维度的向量。
class LayerNorm(nn.Module):"""构造一个layernorm模块"""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 = epsdef forward(self, x):"""Norm"""mean = x.mean(-1, keepdim=True) # 按最后一个维度求均值。mean的shape为 (1, 7, 1)std = x.std(-1, keepdim=True) # 按最后一个维度求方差。std的shape为 (1, 7, 1)return self.a_2 * (x - mean) / (std + self.eps) + self.b_2
7 注意力机制
7.1 Self-Attention
Attention 和 Self-Attention 的区别:
1.以 Encoder-Decoder 框架为例,输入 Source 和输出 Target 内容是不一样的,比如对于英-中机器翻译来说,Source是英文句子,Target是对应的翻译出的中文句子,Attention 发生在 Target 的元素 Query 和 Source 中的所有元素之间。2.Self Attention是 Source 内部元素之间发生的Attention 机制,也可以理解为 Target=Source 这种特殊情况下的 Attention。
所谓self-attention自注意力机制,就是探索当前序列(self)中每一个元素对当前元素(self)的影响程度(相关程度),而传统attention的注意力概率分布来自外部。两者具体计算过程是一样的,只是计算对象发生了变化而已。
比如我们当前要翻译的句子为The animal didn’t cross the street because it was too tired。在翻译it时,它究竟指代的是什么呢,street还是animal?要确定it指代的内容,毫无疑问我们需要同时关注到这个词的上下文语境中的所有词。下图是模型的最上一层(下标0是第一层,5是第六层)Encoder的Attention可视化图。可以看到,在编码it的时候有一个Attention Head注意到了Animal,因此编码后的it有Animal的语义。
self-attention 会使用每个输入向量计算出三个新的向量,分别称为Query、Key、Value,这三个向量是用 embedding 向量(包含位置编码)与不同的矩阵相乘得到的结果,矩阵是随机初始化的。输出被计算为value的加权求和,其中每个value的权重由query与对应key计算所得,其实就是query与对应key相似度,该分数值决定了在某个位置 encode 一个词时,对输入句子的其他部分的关注程度(也即其他部分对该位置元素的贡献程度)。输出也是向量,由于是加权平均,所以输出和value的维度一致。
不同的相似函数导致不一样的注意力版本,有两个最常用的attention函数:
加法attention/abs/1409.0473
使用具有单个隐层的前馈网络计算,q和k维度不一致也可以进行;
点积(乘法)attention
实现细节见下文。虽然理论上点积attention和加法attention复杂度相似,但在实践中,点积attention可以使用高度优化的矩阵乘法来实现,因此点积attention计算更快、更节省空间。
比较:当向量维度(下文的dk)的值比较小的时候,这两个机制的性能相差相近,当dk比较大时,加法attention比点积attention性能好。
transformer用的是缩放的点积注意力Scaled Dot-Product Attention
7.2 缩放的点积注意力(Scaled Dot-Product Attention)
步骤
输入为query、key(维度都是dk,一般512)以及values(维度是dv)。计算query和所有key的点积,得到两个向量的相似度(结果越大相似度越高);然后对每个点积结果除以sqrt(dk)点积结果输入softmax函数,对每一行做softmax,行与行之间独立,结果就是value的权重。对value进行加权求和
下图中α带有'上标,代表经过了softmax处理
也可以参考下图,thinking machines是输入序列
矩阵实现
在实际的应用场景,需要把上面的向量计算变为矩阵的运算。下图所示为矩阵运算的形式。其中X为输入对应的词向量矩阵,WQ、WK、WV为相应的线性变换矩阵,Q、K、V为X经过线性变换得到的Query向量矩阵、Key向量矩阵和Value向量矩阵
计算的输出矩阵为:
关于维度:
1. 上图中Q的形状是(n,dk),K的形状是(m,dk),结果形状是(n,m),n是query的个数,m是key的个数,一行就是一个query对所有key的内积值。V矩阵形状是(m,dv),A和V相乘结果是(n, dv),每一行就是我们要的输出。
2. K、V 矩阵的序列长度(m)是一样的,而 Q 矩阵的序列长度(n)可以和前两者不一样。这种情况发生在解码器部分的Encoder-Decoder Attention层中,Q 矩阵来自解码器输出tgt,而 K、V 矩阵则是来自编码器最后的输出memory。
3. value 的维度(dv)和向量 query 或 key 的维度(dk)不一定相同,但是Q和K的维度必须一样,因为要计算矩阵相乘。
代码中的Q,K,V有两种Shape,如果是Self-Attention,Shape为(batch,词数, dmodel),例如(1, 7, 512),即batch_size为1,一句7个单词,每个单词512维。但如果是Multi-Head Attention,则Shape为(batch, head数, 词数,d_model/head数),例如(1, 8, 7, 64),即Batch_size为1,8个head,一句7个单词,512/8=64。这样其实也能看出来,所谓的MultiHead其实就是将512拆开了。在Transformer中,由于使用的是MultiHead Attention,所以Q,K,V的Shape只会是第二种。
对于Self Attention来说,返回结果Shape为(batch, 词数, d_model),对于MultiHead Attention来说,返回结果Shape为(batch, head数, 词数,d_model/head数)
对于不同类别的attention层,mask的类别不一样。
def attention(query, key, value, mask=None, dropout=None):"Compute 'Scaled Dot Product Attention'"d_k = query.size(-1)scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k) # shape为(batch_size, head数,词数,词数)if mask is not None:scores = scores.masked_fill(mask == 0, -1e9)p_attn = scores.softmax(dim=-1) # 若是MultiHead Attention,则shape为(batch, head数, 词数,词数)if dropout is not None:p_attn = dropout(p_attn) # 注意力系数return torch.matmul(p_attn, value), p_attn
上图中的z1和 z2,再通过全连接层,就能输出该 Encoder 层的输出r1 和r2 ,作为下一层 encoder 的输入。
Self-Attention 是所有的输入向量共同参与了这个过程。 x1和 x2互相不知道对方的信息,但因为在第一个步骤 Self-Attention 中发生了信息交换,所以r1 和r2各自都有从x1和 x2得来的信息了。
下图来自李宏毅课程,也比较清晰,不过矩阵形状和该论文中的规定相比差了一个转置
为什么进行缩放
维度dk很大时,query 与key的点乘结果的方差会很大,最大的值softmax结果接近1,剩下的值接近0,softmax结果向两端靠拢,反向传播时发生梯度消失,相当于训练快要收敛了,跑不动。为了抵消这种影响,我们将点积缩小1/sqrt(dk) 倍。
We suspect that for large values ofdk, the dot products grow large in agnitude, pushing the softmax function into regions where it has extremely small gradients. To counteract this effect, we scale the dot products by 1/sqrt(dk).
To illustrate why the dot products get large, assume that the components ofqandkare independent random variables with mean 0 and variance 1. Then their dot product has mean 0 and variancedk.
从softmax求梯度的角度理解,参见反向传播之一:softmax函数 - 知乎:
求梯度结果:
假设q和k是均值为0方差为1的随机变量,那么点积的结果 q⋅k均值为0,方差为dk,于是softmax结果向两端靠拢,yi和yj要么接近0,要么接近1,带入上式,梯度接近为0
如何做mask
mask 表示掩码,它对某些值进行掩盖,使其在参数更新时不产生效果。Transformer 模型里面涉及两种 mask,分别是 padding mask 和 sequence mask。其中,padding mask 在所有的 scaled dot-product attention 里面都需要用到,而 sequence mask 只有在 decoder 的 self-attention 里面用到。
Padding Mask
很多时候,每个批次输入序列长度是不一样的,但机器学习模型一般要求输入的大小是一致的,所以要对输入序列根据最大长度进行对齐,具体来说就是给在较短的序列后面填充 0。但是如果输入的序列太长,则是截取左边的内容,把多余的直接舍弃。因为这些填充的位置,其实是没什么意义的,attention机制不应该把注意力放在这些位置上,所以我们需要进行Padding Mask。padding mask 实际上是一个张量,每个值都是一个Boolean,值为 false 的地方就是要进行处理的地方。
代码中的src_mask。Shape为(batch_size, 1, 1, 词数),例如[[[1., 1., 1., 1., 1., 1., 1., 0., 0., 0.]]],表示第一个句子的前7个是单词(包括bos、eos和unk),后面3个是填充。而mask就是要对后面三个进行mask。例如,mask前,score的shape为(1, 2, 10, 10),2个head,10个词。score在mask前为:
[[[[x1, x2, ..., x7, x8, x9, x10],
...
[x1, x2, ..., x7, x8, x9, x10],
...
[x1, x2, ..., x7, x8, x9, x10]]]]
score在mask后为:
[[[[x1, x2, ..., x7, -1e9, -1e9, -1e9],
...
[x1, x2, ..., x7, -1e9, -1e9, -1e9],
...
[x1, x2, ..., x7, -1e9, -1e9, -1e9]]]]
Sequence mask
decoder做预测的时候,t 的时刻的解码输出应该只能依赖于 t 时刻之前的输出,不能看见未来的信息,而计算的时候,Q会和所有的K做计算。在Q和K的计算结果经过 Softmax 层之前,对应位置进行mask。
以下函数会在构建tgt的mask时使用。它产生一个Shape为(1, size, size)下三角矩阵,这个矩阵的上三角的值全为0,对角线及下三角的值全为1。前面加个1是为了和tgt的tensor维度保持一致,因为tgt最前面是batch_size。torch.triu生成一个左上角全为1的方阵,subsequent_mask == 0将 0 全部变为True, 1变为False
两者比较:src_mask的shape为(batch_size, 1, 1, 词数),而tgt_mask的shape为(batch_size, 1, 词数, 词数)。这是因为src要对后面非句子的词整个mask,所以只需要在最后一维做就行了。 而tgt需要斜着进行mask,所以需要一个方阵来进行。
def subsequent_mask(size):"Mask out subsequent positions."attn_shape = (1, size, size)subsequent_mask = torch.triu(torch.ones(attn_shape), diagonal=1).type(torch.uint8)return subsequent_mask == 0
下面的attention mask显示了每个tgt单词(行)允许查看(列)的位置。在训练时将当前单词的未来信息屏蔽掉,阻止此单词关注到后面的单词。
plt.figure(figsize=(5,5))plt.imshow(subsequent_mask(20)[0])
mask的具体做法是,将需要mask的位置上的值置为一个很小的数(1e-9),这样的话,经过 softmax,这些位置的概率就会接近0。填充值并不是0,而是-1e9,原因是后续还要进行softmax,而softmax会让负无穷变为0。
if mask is not None:scores = scores.masked_fill(mask == 0, -1e9)
对于 decoder 的 self-attention,里面使用到的 scaled dot-product attention,同时需要padding mask 和 sequence mask 作为 attn_mask,具体实现就是两个mask相加作为attn_mask。其他情况,attn_mask 一律等于 padding mask。
mask部分参考:爱编程真是太好了的博客
7.3 多头注意力
有时候需要考虑多种相关性,于是计算多组 q,k,v,不同的 q,k,v 负责查找不同种类的相关性。可以类比CNN中同时使用多个卷积核的作用,直观上讲,多头的注意力有助于网络捕捉到更丰富的特征/信息。
Multi-head attention allows the model to jointly attend to information from different representation subspaces at different positions. With a single attention head, averaging inhibits this.
李宏毅的解释
下图为 2 heads 的情况, (q,k,v) 由一组变成多组,第一类的放在一起算,第二类的放在一起算。相关性变多了,所以参数也增加了,原来只需要三个 W矩阵,现在需要六个 W矩阵。下图是算第一种相关性的过程
下图是计算第二种相关性的过程
与单个的 self attention 相比,Multi-head Self-attention 最后多了一步:由多个输出组合得到一个输出。将刚刚得到的所有 b组成一个向量,再乘以矩阵,输出一个 bi,目的就是将不同种类的相关性整合在一起,成为一个整体,作为 a1 的输出 b1。下面是一个 2 heads 的例子
论文的解释
Instead of performing a single attention function withdmodel-dimensional keys, values and queries, we found it beneficial to linearly project the queries, keys and valueshtimes with different, learned linear projections todk,dkanddvdimensions, respectively. On each of these projected versions of queries, keys and values we then perform the attention function in parallel, yieldingdv-dimensional output values. These are concatenated and once again projected, resulting in the final values
在Multi-head Attention中,每个head的输入都只是模型的输入词向量矩阵的一部分,从词向量矩阵的最后一维进行切分。论文采用 h=8个head,模型输入的词向量矩阵大小为n∗dmodel,则对每个head使用dk=dv=dmodel/h=64,每个head的输入矩阵大小为n∗(dmodel/8),最后得到8个矩阵Z。由于每个头都降了维,所以总计算成本与具有全部维度的单个head attention相似。 输出要做concat,然后使用WO矩阵进行投影,其中映射由权重矩阵完成:
输入 X 和8组权重矩阵 WQ,WK,WV相乘,得到 8 组 Q, K, V 矩阵。进行attention计算,得到 8 组 Z 矩阵。
把8组Z矩阵在矩阵的最后一个维度上进行拼接,得到一个大的矩阵,乘以权重矩阵 WO,将其映射回 d 维向量(相当于将多维特征进行汇聚),得到最终的矩阵 Z。这个矩阵包含了所有 attention heads(注意力头) 的信息。
矩阵Z会输入到 FFNN层。(前馈神经网络层接收的也是 1 个矩阵,而不是8个。其中每行的向量表示一个词)
完整的过程:
1. query,key,value是什么:
1). 对于Encoder的Attention层来说,query,key,value全都是输入x,从EncoderLayer类中的`self.self_attn(x, x, x, mask)`这段代码也可以看出来。
2). 对于Decoder最前面那个Mask Attention,query,key,value也全都是输入x。
3). 对于Decoder中间的那个Attention,它将Encoder的输出memory作为key和value,将输入x作为query。通过DecoderLayer中的代码 `self.src_attn(x, m, m, src_mask)`可看出来。
2. mask扩展维度:
原本mask维度是3,但是多头注意力机制中,query的维度会由3变成4,也就是在第2个维度插入了head,变为(batch, head数, 词数,d_model/head数)。mask为了和query的维度保持一致,所以也要扩展成4维。
3. 求MultiHead的Q,K,V
1). 首先,通过定义的W^q,W^k,W^v求出SelfAttention的Q,K,V,Shape为(batch, 词数, d_model),对应代码为 `linear(x)`
2). 分成多头,即将Shape由(batch, 词数, d_model)变为(batch, 词数, head数,d_model/head数)。对应代码为 `view(nbatches, -1, self.h, self.d_k)`
3). 交换“词数”和“head数”这两个维度,shape变为(batch, head数, 词数,d_model/head数)。对应代码为 `transpose(1, 2)`
class MultiHeadedAttention(nn.Module):def __init__(self, h, d_model, dropout=0.1):"""Take in model size and number of heads."""super(MultiHeadedAttention, self).__init__()assert d_model % h == 0# We assume d_v always equals d_kself.d_k = d_model // hself.h = h# 定义W^q, W^k, W^v和W^o矩阵。self.linears = clones(nn.Linear(d_model, d_model), 4) self.attn = Noneself.dropout = nn.Dropout(p=dropout)def forward(self, query, key, value, mask=None):"""输入的q,k,v是形状 [batch, L, d_model]。输出的x 的形状同上。"""if mask is not None:# Same mask applied to all h heads.mask = mask.unsqueeze(1)nbatches = query.size(0)# 1) qkv变化:[batch, L, d_model] ->[batch, h, L, d_model/h]# 因为初始化不同,所以最后输出的k,q,v矩阵不同query, key, value = [lin(x).view(nbatches, -1, self.h, self.d_k).transpose(1, 2)for lin, x in zip(self.linears, (query, key, value))]# 2) 计算注意力attn 得到 attn*v 与注意力系数attn# qkv :[batch, h, L, d_model/h] -->x:[b, h, L, d_model/h], attn[b, h, L, L]x, self.attn = attention(query, key, value, mask=mask, dropout=self.dropout)# 3) 上一步的结果合并在一起还原成原始输入序列的形状 [batch, L, d_model]x = (x.transpose(1, 2).contiguous().view(nbatches, -1, self.h * self.d_k))del querydel keydel value# 最终通过W^o矩阵再执行一次线性变换,得到最终结果return self.linears[-1](x)
使用多头自注意力的好处
多语义匹配:本身缩放点积注意力是没什么参数可以学习的,就是计算点积、softmax、加权和而已。但是使用Multi-head attention之后,投影到低维的权重矩阵 WQ, WK , WV是可以学习的,而且有h=8次学习机会。使得模型可以在不同语义空间下学到不同的的语义表示,也扩展了模型关注不同位置的能力。类似卷积中多通道的感觉。例如,“小明养了一只猫,它特别调皮可爱,他非常喜欢它”。“猫”从指代的角度看,与“它”的匹配度最高,但从属性的角度看,与“调皮”“可爱”的匹配度最高。标准的 Attention 模型无法处理这种多语义的情况。
注意力结果互斥:自注意力结果需要经过softmax归一化,导致自注意力结果之间是互斥的,无法同时关注多个输入。使用多组自注意力模型产生多组不同的注意力结果,则不同组注意力模型可能关注到不同的输入,从而增强模型的表达能力。
那么我们如何来理解多个head分别注意到的内容呢?下面给出两个图来举例说明。第一个图给出了在翻译it
时两个head分别注意到的内容,从中可以很明显的看到,第一个head注意到了animal
,而第二个head注意到了tired
,这就保证了翻译的正确性。
第二个图中给出了所有head分别注意到的内容,这时候attention究竟能否抓取到最需要被获取的信息变得不再那么直观。
7.4 Decoder 的 Encode-Decode Attention 层
在Decoder中的第二个注意力层,输入不仅有前一层的输出,还有来自 Encoder 的输出。把 Encoder 产生的向量作为Decoder 的 key 和 value,Decoder 的向量作为query,然后进行Attention。
在做预测时,步骤如下:
给 Decoder 输入 Encoder 对整个句子 embedding 的结果和一个特殊的开始符号</s>
。Decoder 将产生预测,在我们的例子中应该是I
。给 Decoder 输入 Encoder 的 embedding 结果和</s> I
,在这一步 Decoder 应该产生预测am
。给 Decode r输入 Encoder 的 embedding 结果和</s> I am
,在这一步 Decoder 应该产生预测a
。给 Decoder 输入 Encoder 的 embedding 结果和</s> I am a
,在这一步 Decoder 应该产生预测student
。给 Decoder 输入 Encoder 的 embedding 结果和</s> I am a student
, Decoder 应该生成句子结尾的标记,Decoder 应该输出</eos>
。然后 Decoder 生成了</eos>
,翻译完成。
7.5 注意力在Transformer中的应用(总结)
Transformer中用3种不同的方式使用multi-head attention:
encoder包含self-attention层。在self-attention层中,所有key,value和query来自同一个地方,即原始输入或encoder中前一层的输出。每一层的输出其实就是输入的加权和(权重来自输入本身,即各个元素的相似度),于是encoder中的每个位置都可以关注到encoder上一层的所有位置。对于多头,会学习出h个不同的距离空间。假设句子长度为n,那么编码器的输入是n个长为d的向量,最后一层向外输出就是n个长为d的向量。decoder中的masked-self-attention层。它让decoder中的每个位置都关注decoder层中当前位置之前的所有位置(包括当前位置)。为了保持解码器的自回归特性,需要防止解码器中的信息向左流动,后面位置的权重要设为0。在缩放点积attention的内部,通过屏蔽softmax输入中所有的非法连接值(设置为 −∞)实现了这一点。输入是m个长为d的向量,最后一层向外输出是m个长为d的向量。encoder-decoder的attention层中,不再是自注意力。query来自前面的decoder层(n个长为d的向量),而keys和values来自encoder的输出(m个长为d的向量)。这使得decoder中的每个位置都能关注到输入序列中的所有位置。
7.6 基于位置的前馈神经网络(Position-wise Feed-Forward Networks)
编码器和解码器中的每个层都包含一个全连接的前馈网络,该前馈网络应对每个向量单独计算,可以并行。该前馈网络包括两个线性变换,并在两个线性变换中间有一个ReLU激活函数。虽然线性变换在不同位置上是相同的,但它们在层与层之间使用不同的参数。
In addition to attention sub-layers, each of the layers in our encoder and decoder contains a fully connected feed-forward network, which is applied to each position separately and identically
class PositionwiseFeedForward(nn.Module):"Implements FFN equation."def __init__(self, d_model, d_ff, dropout=0.1):super(PositionwiseFeedForward, self).__init__()self.w_1 = nn.Linear(d_model, d_ff)self.w_2 = nn.Linear(d_ff, d_model)self.dropout = nn.Dropout(dropout)def forward(self, x):return self.w_2(self.dropout(F.relu(self.w_1(x))))
Position-wise 就是同一个MLP对每个token作用一次。两层线性变换第一层输入512维,输出2048维;第二层输入2048维,输出512维,好做残差。其实就是一个单隐藏层的MLP,中间的隐藏层将输入扩大4倍,最后输出的时候又回到输入的大小。
对比transformer和RNN,发现两者都是使用MLP来进行语义空间的转换,但区别是二者传递信息的方式不一样:(图来自神洛华的博客)
在整个过程中attetion所起到的作用就是把整个序列里面的信息抓取出来做一次汇聚(拼接后映射回d维),因为每个点已经包含了序列中感兴趣的信息,所以在做投影、MLP、映射成为更想要的语义空间的时候,只要在每个点独立进行运算就行了。
RNN和transformer都是用一个线性层或者MLP来进行语义空间的转换,但是不同之处在于传递序列信息的方式:
RNN是把上一时刻输出和t时刻输入一起,传递给当前时刻,并用MLP做语义转换。Transformer是通过attention层为每个token直接关联全局的序列信息,然后用MLP做语义转换。
为什么MLP Block先升维再降维?
神经网络中线性连接可以写成 d=W⋅x。其中三者维度分别是m×1、m×n、n×1。
m>n:升维,将特征进行各种类型的特征组合,提高模型分辨能力 m<n:降维,去除区分度低的组合特征。所以一般神经网络都是先做宽再做窄。
7.7 输出层Softmax
Linear and Softmax Layer 的主要作用是将decoder端输出的浮点数向量转化为具体的token。
linear层将decoder输出的vector映射为一个维度更大的logits向量。假设模型已知10000个从训练集学习到的单词(输出单词表),对应的logits向量维度就是10000,每个维度的值对应每个单词的分数。
softmax 层将这些分数转化为概率,加合为一。与概率最高的维度对应的单词会被当作当前时间点的输出。
vocab: 词典的大小。x为Decoder的输出,例如x.shape为(1, 7, 512),其中1为batch size, 7为句子长度,512为词向量的维度。这里使用的是log_softmax而非softmax,效果应该是一样的。log_softmax能够解决函数overflow和underflow,加快运算速度,提高数据稳定性(因为因为softmax会进行指数操作)。
class Generator(nn.Module):"""定义标准的linear+softmax生成步骤"""def __init__(self, d_model, vocab):super(Generator, self).__init__()self.proj = nn.Linear(d_model, vocab)def forward(self, x):return F.log_softmax(self.proj(x), dim=-1)
7.8 Embeddings词嵌入
1.输入是一个一个的词(或者叫词源,token),需要将其映射成向量,用可学习的Embeddings将input和output的token和转换为 dmodel 维的向量2.当 decoder 层全部执行完毕后,需要在结尾再添加普通的线性变换和 softmax 层,将解码器输出转换为概率,预测结果token,在最后的softmax函数前这个操作也是embedding。在我们的模型中,两个Embedding层共享相同的权重矩阵,这样训练起来简单一些。这些权重要乘以 sqrt(dmodel),因为学embedding时多多少少会把一个向量的L2 Norm(L2范数)学到相对比较小,比如接近1,于是向量维度一大,学到的权重值就会很小。但是位置编码不会把权重的L2 Norm学成接近1,所以把权重乘上sqrt(dmodel)之后,token embedding和位置编码Positional Encoding相加的时候,在一个量级上。(都在-1到1之间)
d_model: 词向量的维度
vocab: 词典的大小。
class Embeddings(nn.Module):def __init__(self, d_model, vocab):super(Embeddings, self).__init__()self.lut = nn.Embedding(vocab, d_model) # 词向量层,生成一个矩阵,大小是 vocab * d_modelself.d_model = d_model # 表示embedding的维度def forward(self, x):embed = self.lut(x)dk = math.sqrt(self.d_model)return embed * dk
参考:Pytorch nn.Embedding的基本使用
7.9 位置编码(Positional Encoding)
Transformer和RNN相比,一个重要的提升就是Transformer可以并行计算,其实就是Transformer的Encoder可以一次性接收整个句子,而RNN需要一个词一个词接收。但一次性接收整个句子就会淡化句子中词与词的位置关系,例如 “我爱你”和“爱你我” 可能对网络来说没什么不一样的。 为了解决这个问题,Transformer给encoder层和decoder层的输入加入关于词符相对或者绝对位置的一些信息,这样的话如果两个词在不同的位置出现了,由于位置编码不同,最终得到的向量也是不同的。
Positional Encoding维度和embedding的维度一样,都是dmodel。这个向量能决定当前词的位置,或者说在一个句子中不同的词之间的距离。有多种位置编码可以选择
在这项工作中,使用不同频率的正弦和余弦函数:
其中pos是指当前词在句子中的位置,i是指词向量中每个元素的index,可以看出,在偶数位置,使用正弦编码,在奇数位置,使用余弦编码。这些波长形成一个从 2π 到 10000⋅2π的集合级数。
下面代码计算div_term时,并没有直接使用 pos/10000^(2i/dmodel) 公式,而是使用它的变种 e^(−2iln(10000)/dmodel),原因可能是因为这样计算效率较高。
class PositionalEncoding(nn.Module):"Implement the PE function."def __init__(self, d_model, dropout, max_len=5000):super(PositionalEncoding, self).__init__()self.dropout = nn.Dropout(p=dropout)# 初始化Shape为(max_len, d_model)的PE (positional encoding)pe = torch.zeros(max_len, d_model)# 初始化一个tensor [[0, 1, 2, 3, ...]]position = torch.arange(0, max_len).unsqueeze(1)# 这里就是sin和cos括号中的内容,通过e和ln进行了变换div_term = torch.exp(torch.arange(0, d_model, 2) * -(math.log(10000.0) / d_model))# 计算PE(pos, 2i)pe[:, 0::2] = torch.sin(position * div_term)# 计算PE(pos, 2i+1)pe[:, 1::2] = torch.cos(position * div_term)# 为了方便计算,在最外面unsqueeze出一个batchpe = pe.unsqueeze(0)# 如果一个参数不参与梯度下降,但又希望保存model的时候将其保存下来# 这个时候就可以用register_bufferself.register_buffer("pe", pe)def forward(self, x):"""x 为embedding后的inputs,例如(1,7, 128),batch size为1,7个单词,单词维度为128"""# 将x和positional encoding相加。x = x + self.pe[:, : x.size(1)].requires_grad_(False)return self.dropout(x)
展示编码结果
plt.figure(figsize=(15, 5))pe = PositionalEncoding(20, 0)y = pe.forward(Variable(torch.zeros(1, 100, 20)))plt.plot(np.arange(100), y[0, :, 4:8].data.numpy())plt.legend(["dim %d"%p for p in [4,5,6,7]])
为什么这样编码就能引入词语的位置信息呢?如果只按照第一个公式那样根据输入矩阵的下标值进行编码的话,显然编码的是词汇的绝对位置信息,即绝对位置编码。但词语的相对位置也是非常重要的,于是Transformer中引入正弦函数。正弦函数能够表示词语的相对位置信息,主要是基于以下两个公式,这表明位置k+p的位置向量可以表示为位置k的特征向量的线性变化,这为模型捕捉单词之间的相对位置关系提供了非常大的便利。
最终编码向量每个元素值都是在-1到1之间,于是,为什么embedding的权重要乘上sqrt(dmodel),就可以理解了。
此外,我们会将embedding和位置编码的和再加一个dropout。对于基本模型,我们使用的dropout比例是Pdrop=0.1。
Transformer中的position encoding - 知乎
8 为什么使用自注意力机制
使用self-attention是基于三个问题,也就是下图中后三列
上图n是序列长度,d是token维度。
Attention:
计算复杂度:矩阵Q*K,两个矩阵都是n行d列,所以相乘时复杂度是O(n2⋅d),其它还有一些计算量但影响不大;顺序计算量:指的是在算layer的时候,下一步计算必须要等前面多少步计算完成。整个计算主要就是矩阵乘法,矩阵里面并行度是很高的,所以可以认为顺序计算量就是O(1);最大路径长度:也就是从一个点关联到任何一个点的路径长度。attention是一次看到整个序列,所以只需要一次操作,复杂度为 O(1)
k就是卷积核大小,一般是3、5之类的;而n和d现在的模型都是做到几百几千,所以可以认为前三种操作,计算复杂度差不多,但是并行度是attention和卷积更好;且attention在信息的融合上更好(最大路径长度=1)。
实际上attention对模型的假设更少,导致模型需要更多的数据和更大的模型才能训练到和RNN或CNN差不多的效果。所以现在基于transformer的模型都是很大,训练起来很贵。
9 实验
训练数据和批处理
在标准的WMT English-German dataset上进行了训练,其中包含约450万个句子对。这些句子使用byte-pair编码进行编码,源语句和目标语句共享大约37000个词符的词汇表。对于英语-法语翻译,我们使用大得多的WMT English-French dataset,它包含3600万个句子,并将词符分成32000个word-piece词汇表。序列长度相近的句子一起进行批处理。每个训练批处理的句子对包含大约25000个源词符和25000个目标词符。对base models进行了总共10万steps或12小时的训练。而对于big models,每个step训练时间为1.0秒,big models训练了30万steps(3.5 天)
bpe编码,是因为英语/德语中有很多ing之类的词根,又或者一个动词有几种形式。如果直接使用token进行表示,词表就太大了。bpe就可以把词根提取出来,这样词表会小很多,而且还能表示不同时态等等这些相关信息。
共用词表可以使编码器和解码器共用一个embedding,权重共享,模型更简单。
硬件和时间
在一台具有8个NVIDIA P100 GPU的机器上训练模型。每个batch训练步骤耗时约0.4秒。基础模型共训练了10万步或12小时。对于我们的大型模型(在表3的底部描述),步长为1.0秒。大型模型接受了30万步(3.5天)的训练。
因为TPU非常适合做很大的矩阵乘法,所以后面Google都推荐自己的员工多使用TPU
优化器
我们使用Adam优化器,其中 β1=0.9, β2=0.98并且ϵ=10−9。学习率是根据以下公式计算出来的:学习律是根据模型宽度的-0.5次方(就是说当模型越宽的时候,学习的向量越长的时候,学习率会低一点)
即
学习率几乎是不用调的:adam对学习率不敏感;学习率已经把模型考虑进来了,schedule也已经算是不错的schedule了,所以学习率是不需要调的
其中:
lrate: 学习率的调整参数。注意这个并不是学习率,假设你设置的学习率为0.8,则第 i i i步的学习率为:0.8∗lrate(i)。dmodel:模型的维度,即词向量的维度step_num: 步数。注意:执行一个backward(也可以说是一个batch)step加1,并不是optimizer.step()一次step加1.warmup_steps: warmup多少步。从一个小的值慢慢地爬到一个高的值,爬到之后,再根据步数按照0.5次方衰减。在Transformer中使用的是4000,也就是预热4000步。
# 学习率调整函数def rate(step, model_size, factor, warmup):# 避免分母为0if step == 0:step = 1# 这里比原公式还多了一个factor,factor默认取1,相当于没有多。return factor * (model_size ** (-0.5) * min(step ** (-0.5), step * warmup ** (-1.5)))
下面是一个学习率调整的例子
def example_learning_schedule():# 准备3个样例,三个参数分别为:d_model, factor, warmup_stepsopts = [[512, 1, 4000], # example 1[512, 1, 8000], # example 2[256, 1, 4000], # example 3]# 随便定义一个没啥用的模型dummy_model = torch.nn.Linear(1, 1)# 记录这三个例子的学习率变化learning_rates = []# 分别运行上面的三个例子for idx, example in enumerate(opts):# 定义Adam优化器optimizer = torch.optim.Adam(dummy_model.parameters(), lr=1, betas=(0.9, 0.98), eps=1e-9)# 定义LambdaLR调整学习率。"*example"称为序列解包lr_scheduler = LambdaLR(optimizer=optimizer, lr_lambda=lambda step: rate(step, *example))# 用于存储当前样例的学习率变化tmp = []# 进行20000次训练for step in range(20000):# 记录学习率tmp.append(optimizer.param_groups[0]["lr"])# 参数更新optimizer.step()# 更新学习率。更新学习率要放在optimizer.step()之后lr_scheduler.step()# 记录当前样例的学习率learning_rates.append(tmp)# 将学习率变化变成learning_rates = torch.tensor(learning_rates)# Enable altair to handle more than 5000 rowsalt.data_transformers.disable_max_rows()"""组织统计图需要的数据,最终效果为:|Learning Rate | model_size:warmup | step----------------------------------------------0 |1.74e-07 |512:4000 | 01 |1.74e-07 |512:4000 | 12 |3.49e-07 |512:4000 | 2...."""opts_data = pd.concat([pd.DataFrame({"Learning Rate": learning_rates[warmup_idx, :],"model_size:warmup": ["512:4000", "512:8000", "256:4000"][warmup_idx],"step": range(20000),})for warmup_idx in [0, 1, 2]])# 绘制统计表return (alt.Chart(opts_data).mark_line().properties(width=600).encode(x="step", y="Learning Rate", color="model_size:warmup:N").interactive())example_learning_schedule()
正则化
采用两种正则化:
Residual Dropout
将dropout应用到每个子层(多头注意力和MLP)的输出上,在子层输出进入残差连接之前,和进入LayerNorm之前,都使用dropout。此外,在编码器和解码器中,token embedding+Positional Encoding时也使用了dropout。对于base模型,我们使用drop概率为 0.1。
标签平滑Label Smoothing
在训练过程中,正确标签对应的softmax要逼近于1,其输入几乎要无穷大,这是很不合理的,会使模型训练困难。在Transformer的训练中,使用Label Smoothing技术对标签做了平滑处理。label smoothing ϵls= 0.1意味着,对于正确的词,只需要softmax到0.1就行了。这让模型不易理解,因为模型不确信度下降,但减少模型overconfidence,减少overfitting,提高了准确性和BLEU得分。
During training, we employed label smoothing of value 𝜖𝑙𝑠=0.1 (cite). This hurts perplexity, as the model learns to be more unsure, but improves accuracy and BLEU score.
公式为:
其中 Pi 是标签的第i维的值;K为类别总数; ϵ为平滑因子,取值范围为[0,1], 通常为0.1,
例如,假设我们的标签是2, 词典大小为5,则对应的one-hot向量则为:[0,0,0,1,0],现在取平滑因子 ϵ=0.2,则平滑后的Label为:
[0.2/4,0.2/4,0.2/4,1−0.2,0.2/4]=[0.05,0.05,0.05,0.8,0.05]
之后计算loss时,使用平滑后的标签。直观上的理解就是:模型你别太自信,就算你预测对了也对你进行一点惩罚,防止你过度相信这个特定样本的特定结果。
不过下面的LabelSmoothing的实现与上面说的略有不同,主要有两点:
这个LabelSmoothing类将损失计算也放了进来,也就是它除了负责标签平滑外,还负责计算损失。由于词典中有一项是“填充”(<pad>),而网络在什么情况下都不应该预测为<pad>,所以在LabelSmoothing的时候不应该让<pad>参与。在本代码实现中,所有的<blank>指都是<pad>
假设我们的词典为{0: <pad>, 1: <bos>, 2: <eos>, 3: I, 4: love, ..., 1999: you},此时词典大小为2000,所以one-hot向量的维度也是2000。如果我们做预测,无论什么情况我们的标签都不应该是<pad>([1, 0, 0, …]),因为我们不预测<pad>。所以我们在平滑的时候,应该忽略<blank>,所以公式中的 K−1 也要变成K−2
class LabelSmoothing(nn.Module):"""该类除了标签平滑外,还包含了损失的计算。所以此类为 LabelSmoothing + LossFunction"""def __init__(self, size, padding_idx, smoothing=0.0):"""size: 目标词典的大小。padding_idx: 空格('<blank>')在词典中对应的index,`<blank>`等价于`<pad>`smoothing: 平滑因子,0表示不做平滑处理"""super(LabelSmoothing, self).__init__()# 定义损失函数,这里使用的是KL Divergence Loss,也是一种多分类常用的损失函数# KLDivLoss官方文档:/docs/stable/generated/torch.nn.KLDivLoss.htmlself.criterion = nn.KLDivLoss(reduction="sum")self.padding_idx = padding_idxself.confidence = 1.0 - smoothingself.smoothing = smoothingself.size = size# true distribution,平滑后的标签self.true_dist = Nonedef forward(self, x, target):"""x: generator输出的概率分布。Shape为(batch_size, 词典大小)target: 目标标签。Shape为(batch_size)"""# 确保generator的输出维度和词典大小一致,否则后面计算loss的时候就会出错assert x.size(1) == self.size# 创建一个与x有相同Shape的tensor。假设x的shape为(2, 6),即batch_size为2,词典大小为6。true_dist = x.data.clone()"""将true_dist全部填充为 self.smoothing / (self.size - 2)。假设 smoothing=0.2,则为全部填充为 0.2 / 4= 0.05此时true_dist则为:[[0.05, 0.05, 0.05, 0.05, 0.05, 0.05],[0.05, 0.05, 0.05, 0.05, 0.05, 0.05]]"""true_dist.fill_(self.smoothing / (self.size - 2))"""scatter_: 官方地址 /docs/stable/generated/torch.Tensor.scatter_.html将true_dist的1维度上与target.data.unsqueeze(1)对应的值变为src。假设此例中target.data.unsqueeze(1) 为[[2], [3]],即2个数据的标签分别为2,3则true_dist执行过scatter后变为:[[0.05, 0.05, 0.8, 0.05, 0.05, 0.05],[0.05, 0.05, 0.05, 0.8, 0.05, 0.05]]"""true_dist.scatter_(1, target.data.unsqueeze(1), self.confidence)# 将"空格"所在元素填充为0。true_dist[:, self.padding_idx] = 0# 找出target中,label为<blank>的标签。例如target为['i', 'love', 'you', '<blank>', '<blank>']# 那么mask则为[[1], [3]],表示第1个和第3个为空格。# 但在实际应用场景中标签中不应该出现"<blank>"mask = torch.nonzero(target.data == self.padding_idx)if mask.dim() > 0:"""将"<blank>"所在的label整个设置为0。例如,假设现在true_dist为:[[0.05, 0.05, 0.8, 0.05, 0.05, 0.05],[0.05, 0.05, 0.05, 0.8, 0.05, 0.05],[0.8, 0.05, 0.05, 0.05, 0.05, 0.05]]其中第3行为“<blank>”的label,则执行完这行代码后,则为:[[0.05, 0.05, 0.8, 0.05, 0.05, 0.05],[0.05, 0.05, 0.05, 0.8, 0.05, 0.05],[0.00, 0.00, 0.00, 0.0, 0.00, 0.00]]"""true_dist.index_fill_(0, mask.squeeze(), 0.0)# 保存平滑标签后的labelself.true_dist = true_dist"""使用平滑后的标签计算损失由于对`<blank>`部分进行了mask,所以在这部分是不会参与损失计算的"""return self.criterion(x, true_dist.clone().detach())
看一个Label smoothing的例子:
def example_label_smoothing():# 定义LabelSmoothing, 词典大小为5, <blank>对应index为0,平滑因子为0.4crit = LabelSmoothing(5, 0, 0.4)# 定义predict,即网络的预测分布。# 这里的1e-9在源码中为0。由于KLDivLoss需要对predict求log,而0会导致结果为负无穷# 所以我这里将0改成了1e-9。predict = torch.FloatTensor([[1e-9, 0.2, 0.7, 0.1, 1e-9],[1e-9, 0.2, 0.7, 0.1, 1e-9],[1e-9, 0.2, 0.7, 0.1, 1e-9],[1e-9, 0.2, 0.7, 0.1, 1e-9],[1e-9, 0.2, 0.7, 0.1, 1e-9],])loss = crit(x=predict.log(), target=torch.LongTensor([2, 1, 0, 3, 3]))print("loss:", loss)print("Before label smoothing:\n", torch.zeros(5, 6).scatter_(1, torch.LongTensor([2, 1, 0, 3, 3]).unsqueeze(1), 1))print("After label smoothing:\n", crit.true_dist)LS_data = pd.concat([pd.DataFrame({"target distribution": crit.true_dist[x, y].flatten(),"columns": y,"rows": x,})for y in range(5)for x in range(5)])return (alt.Chart(LS_data).mark_rect(color="Blue", opacity=1).properties(height=200, width=200).encode(alt.X("columns:O", title=None),alt.Y("rows:O", title=None),alt.Color("target distribution:Q", scale=alt.Scale(scheme="viridis")),).interactive())example_label_smoothing()
上图中越黄,表示这个位置的值越接近1, 反之则越接近0。没平滑前,应该是黄色的部分巨黄(=1),其他部位都是巨紫(=0),平滑之后,黄色部位的黄色素给其他紫的部分匀了一些(除了’<blank>'位置)。
下面是另一个平滑的例子,从该例子可以看到当模型非常自信的时候就会给予其一个微小的惩罚:
def loss(x, crit):# x是从0到100的一个不断增大的数。 d=x+3,比x大一点。d = x + 3"""模拟模型的输出。一开始x为1,模型输出为:[[0.0000, 0.2500, 0.2500, 0.2500, 0.2500]]此时模型模型还不太会预测当x到100时,模型输出为:[[0.0000, 0.9706, 0.0098, 0.0098, 0.0098]]此时模型可以很自信的说结果就是 1"""predict = torch.FloatTensor([[0, x / d, 1 / d, 1 / d, 1 / d]])# 计算模型损失。由于使用的是KLDivLoss,所以要对predict进行log操作return crit(predict.log(), torch.LongTensor([1])).datadef penalization_visualization():crit = LabelSmoothing(5, 0, 0.1)loss_data = pd.DataFrame({# x从1开始不断增大,模拟模型的表现越来越好"Loss": [loss(x, crit) for x in range(1, 100)],"Steps": list(range(99)),}).astype("float")return (alt.Chart(loss_data).mark_line().properties(width=350).encode(x="Steps",y="Loss",).interactive())penalization_visualization()
从上图可以看出,大约到20次的时候,惩罚效果就出来了。模型虽然很自信的能说出正确答案,但是给他一个小的惩罚,越自信,损失反而越大。
模型配置
可以看到模型虽然比较复杂,但是没有多少超参数可以调,使得后面的人工作简单很多。
10 评价
Transformer(attention机制)能用在几乎所有NLP任务上并且提升性能,类似于CNN对整个CV领域的革新,CNN提供了一个框架,不需要那么多的特征提取或者模型建模,学会CNN就行了。Transformer也是一样,不需要那么多的文本预处理,不需要为每个任务设计不同的架构,用Transformer这个架构就能够在各个任务上取得好的成绩,并且预设的模型也让大家的训练变得更加简单。
现在transformer在CV、语音、video等领域也广泛使用,等于一个架构可以适用于所有领域,任何个领域的突破,能在别的领域都能被使用,减少新技术的应用时间。 而且Transformer可以融合多模态的数据(文字、图片、语音等),大家都用同一个架构提取特征,可以都抽取到同一个语义空间,使得我们可以用文字、图片、语音等训练更大更好的模型。
虽然Transformer效果这么好,但是对它的理解还在初级阶段。
不是只需要attention就足够了。最新的一些结果表明,attention在里面只是起到一个聚合序列信息的作用 ,但是后面的MLP/残差连接是缺一不可的,如果去掉的话,模型是基本训练不出什么的。Attention不会对序列的顺序建模。为何Attention能打败RNN?RNN可以显式地建模序列信息,应该比attention更好。现在大家觉得attention使用了更广泛的归纳偏置,使得他能处理更一般化的信息;这也是为什么attention没有做空间上的假设,但是比CNN/RNN能做到更好的效果。代价就是假设更一般,所以抓取数据信息能力变差,必须使用更大的模型和更多的数据才能训练到一个比较好的效果,因此transform模型一个比一个大,一个比一个贵。
11.模型建立和应用
导包
使用到的类库版本如下:
pandas==1.3.5torch==1.11.0+cu113torchdata==0.3.0torchtext==0.12spacy==3.2altair==4.1jupytext==1.13flake8blackGPUtil
一定要按照该版本进行安装,尤其是torchtext与torchdata,否则后面会报错
除了上面的库外,还需要用到spacy进行分词。关于spacy可参考该文章。在安装完spacy后,需要安装德语和英语的库,命令如下:
!python -m spacy download de_core_news_sm!python -m spacy download en_core_web_sm
import osfrom os.path import existsimport torchimport torch.nn as nn# Pad: 用于对句子进行长度填充,官方地址为:/docs/stable/generated/torch.nn.functional.pad.htmlfrom torch.nn.functional import log_softmax, padimport math# copy: 用于对模型进行深拷贝import copyimport timefrom torch.optim.lr_scheduler import LambdaLRimport pandas as pd# 和matplotlib.pypolt类似,用于绘制统计图,但功能功能强大# 可以绘制可交互的统计图。官网地址为:https://altair-viz.github.io/getting_started/overview.htmlimport altair as alt# 用于将 iterable 风格的 dataset 转为 map风格的 dataset,详情可参考:/zhaohongfei_358/article/details/122742656from torchtext.data.functional import to_map_style_datasetfrom torch.utils.data import DataLoader# 用于构建词典from torchtext.vocab import build_vocab_from_iterator# datasets:用于加载Multi30k数据集import torchtext.datasets as datasets# spacy: 一个易用的分词工具,详情可参考:/zhaohongfei_358/article/details/125469155import spacy# GPU工具类,本文中用于显示GPU使用情况import GPUtil# 用于忽略警告日志import warnings# 设置忽略警告warnings.filterwarnings("ignore")
构建完整模型
src_vocab:原词典的大小。假设你要做英译汉,那么src_vocab就是英文词典的大小tgt_vocab: 目标词典的大小。N: EncoderLayer和DecoderLayer的数量d_model: 词向量的大小。d_ff: 模型中Feed Forward层中隐层神经元的数量h: MultiHead中head的数量def make_model(src_vocab, tgt_vocab, N=6, d_model=512, d_ff=2048, h=8, dropout=0.1):# 用于将模型深度拷贝一份(相当于全新的new一个)c = copy.deepcopy# 1. 构建多头注意力机制attn = MultiHeadedAttention(h, d_model)# 2. 构建前馈神经网络ff = PositionwiseFeedForward(d_model, d_ff, dropout)# 3. 构建位置编码position = PositionalEncoding(d_model, dropout)# 4. 构建Transformer模型model = EncoderDecoder(# Encoder层。Encoder(EncoderLayer(d_model, c(attn), c(ff), dropout), N),# Decoder层,其包含两个Attention层,复制两份Decoder(DecoderLayer(d_model, c(attn), c(attn), c(ff), dropout), N),# inputs的编码器和位置编码nn.Sequential(Embeddings(d_model, src_vocab), c(position)),# outputs的编码器和位置编码nn.Sequential(Embeddings(d_model, tgt_vocab), c(position)),# Decoder最后的Linear层和Softmax,用于预测下一个tokenGenerator(d_model, tgt_vocab),)# 使用xavier均匀分布的方式进行模型参数初始化for p in model.parameters():if p.dim() > 1:nn.init.xavier_uniform_(p)return model
测试一下模型
使用前向传递来测试一下模型。使用Transformer来记住input。因为模型没训练过,所以输出是随机的。
def inference_test():"""测试模型"""# 构架测试模型,原词典和目标词典大小都为11,EncoderLayer和DecoderLayer的数量为2test_model = make_model(11, 11, 2)test_model.eval()# 定义inputs, shape为(1, 10),即一个句子,该句子10个单词。src = torch.LongTensor([[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]])# 定义src_mask,即所有的词都是有效的,没有填充词src_mask = torch.ones(1, 1, 10)# 将输入送给encoder,获取memorymemory = test_model.encode(src, src_mask)# 初始化ys为[[0]],用于保存预测结果,其中0表示'<bos>'ys = torch.zeros(1, 1).type_as(src)# 循环调用decoder,一个个的进行预测。例如:假设我们要将“I love you”翻译成# “我爱你”,则第一次的`ys`为(<bos>),然后输出为“I”。然后第二次`ys`为(<bos>, I)# 输出为"love",依次类推,直到decoder输出“<eos>”或达到句子长度。# 这里输出9次因为前面已经生成了一个单词memoryfor i in range(9):# 将encoder的输出memory和之前Decoder的所有输出作为参数,让Decoder来预测下一个tokenout = test_model.decode(# ys就是Decoder之前的所有输出。注意mask的大小和ys一样memory, src_mask, ys, subsequent_mask(ys.size(1)).type_as(src.data))# 将Decoder的输出送给generator进行预测。这里只取最后一个词的输出进行预测。# 因为你传的tgt的词数是变化的,第一次是(<bos>),第二次是(<bos>, I)# 所以你的out的维度也是变化的,变化的就是(batch_size, 词数,词向量)中词数这个维度# 既然只能取一个,那当然是最后一个词最合适。prob = test_model.generator(out[:, -1])# 取出数值最大的那个,它的index在词典中对应的词就是预测结果_, next_word = torch.max(prob, dim=1)# 取出预测结果next_word = next_word.data[0]# 将这一次的预测结果和之前的拼到一块,作为之后Decoder的输入ys = torch.cat([ys, torch.empty(1, 1).type_as(src.data).fill_(next_word)], dim=1)print("Example Untrained Model Prediction:", ys)inference_test()
模型训练
损失计算
class SimpleLossCompute:"""该类除了包含损失计算外,还包含模型generator部分的前向传递。"""def __init__(self, generator, criterion):"""generator: Generator类对象,用于根据Decoder的输出预测下一个tokencriterion: LabelSmoothing类对象,用于对Label进行平滑和计算损失"""self.generator = generatorself.criterion = criteriondef __call__(self, x, y, norm):"""x: EncoderDecoder的输出,也就是Decoder的输出y: batch.tgt_y,要被预测的所有token,例如src为`<bos> I love you <eos>`,则`tgt_y`则为`我 爱 你 <eos>`norm: batch.ntokens, tgt_y中的有效token数。用于对loss进行正则化。"""# 调用generator预测token。(EncoderDecoder的forward中并没有调用generator)x = self.generator(x)"""这里首先使用KLDivLoss进行了损失计算,随后又除以batch.ntokens对loss进行正则化。"""sloss = (self.criterion(x.contiguous().view(-1, x.size(-1)), y.contiguous().view(-1))/ norm)return sloss.data * norm, sloss
例子说明正则化:
假设我们的第一个batch(假设batch_size=2)的tgt_y对应的句子为:<bos> 我 爱 你 <eos> <pad> <pad> ... 和 <bos> 苹果 好 吃 <eos> <pad> <pad>。
则我们的loss相当于对8个预测结果进行了计算,即我 爱 你 <eos> 苹果 好 吃 <eos>
假设我们的第二个batch的tgt_y对应的句子为:<bos> 中国 是 ...(此处省略100个字) <eos> 和 <bos> 华夏 文明 发源 .. <eos>。
则我们的loss对200个预测结果进行了计算
如果不做平均的话,显然第二个大,然后就会出现loss一会大一会小的情况,所以要求一下平均,也就是除以有效的token数。
工具类
class Batch:"""定义一个Batch,来存放一个batch的src,tgt,src_mask等对象。方便后续的取用"""def __init__(self, src, tgt=None, pad=2): # 2 = <blank>"""src: 和EncoderDecoder#forward中的那个src一致。未进行word embedding的句子,例如`[[ 0, 5, 4, 6, 1, 2, 2 ]]`上例shape为(1, 7),即batch size为1,句子大小为7。其中0为bos,1为eos, 2为padtgt: 和src类似。是目标句子。"""self.src = src"""构造src_mask:就是将src中pad的部分给盖住,因为这些不属于句子成分,不应该参与计算。例如,src为[[ 0, 5, 4, 6, 1, 2, 2 ]],则src_mask则为:[[[ True, True, True, True, True, False, False ]]]。因为最后两个2(pad)不属于句子成分。(“<bos>”、“<eos>”和“<unk>”是要算作句子成分的)这里unsqueeze一下是因为后续是要对Attention中的scores进行mask,而scores的len(shape)=3,为了与scores保持一致,所以unsqueeze(-2)一下。具体可参考attention函数中的注释。"""self.src_mask = (src != pad).unsqueeze(-2)if tgt is not None:"""每个句子都去掉最后一个词。例如tgt的Shape为(16, 30),即batch_size为16,每个句子30个单词,执行该代码后为(16, 29)。这么做的原因是:tgt存储的是Decoder的输入,而Decoder的输入是不可能包含最后一个词的。例如,我们要预测`<bos> 我 爱 你 <eos>`,第一次我们会给Decoder传`<bos>`,第二次传`<bos> 我`,而最后一次会传`<bos> 我 爱 你`。所以tgt不会出现最后一个词,所以要去掉。"""self.tgt = tgt[:, :-1]"""与上面差不多,去掉句子的第一个词,也就是“<bos>”tgt_y 存储的是希望预测的结果,所以不需要'<bos>'例如,传入encoder的是"<bos> I love you <eos>",初始传入decoder为"<bos>",则我们想最终能“一个个”的预测出“我 爱 你 <eos>”,所以要把<bos>去掉,因为其不是我们想要预测的token。"""self.tgt_y = tgt[:, 1:]"""构造tgt_mask:tgt_mask与src_mask略有不同,除了需要盖住pad部分,还需要将对角线右上的也都盖住,具体原因可参考:/zhaohongfei_358/article/details/125858248例如:[[ 0, 5, 4, 6, 1, 2, 2 ]],则tgt_mask则为:[[[ True, False, False, False, False, False, False],[ True, True, False, False, False, False, False],[ True, True, True, False, False, False, False],[ True, True, True, True, False, False, False],[ True, True, True, True, True, False, False],[ True, True, True, True, True, False, False],[ True, True, True, True, True, False, False]]]"""self.tgt_mask = self.make_std_mask(self.tgt, pad)"""此Batch的tgt_y的总token数量。值为一个数字,例如 tensor(266) 表示tgt_y有266个有意义的token也就是除去<pad>部分的词的数量。保存这个是为了用于loss的正则化,后面loss部分会详细说明。注意这里是tgt_y,而tgt_y去掉了“<bos>”,所以token数是不包含“<bos>”的。"""self.ntokens = (self.tgt_y != pad).data.sum()@staticmethoddef make_std_mask(tgt, pad=2):"""生成tgt_mask"""# 首先生成非句子成分部分的mask# 例如 [[ 0, 5, 4, 6, 1, 2, 2 ]] 的mask为 [[[ True, True, True, True, True, False, False ]]]tgt_mask = (tgt != pad).unsqueeze(-2)"""subsequent_mask用于获取阶梯式Mask。然后再和tgt_mask进行&操作。例如:tgt_mask为[[[ True, True, False ]]]subsequent_mask结果为:[[[ True, False, False ],[ True, True, False ],[ True, True, True ]]]则tgt_mask & subsequent_mask结果为:[[[ True, False, False ],[ True, True, False ],[ True, True, False ]]]"""tgt_mask = tgt_mask & subsequent_mask(tgt.size(-1)).type_as(tgt_mask.data)return tgt_mask
训练状态类
class TrainState:"""用于保存一些训练状态"""# step的次数,但注意是一次loss.backward()算一次,或者说一个batch算一次# 并不是一次optimizer.step()算一次。在后面的训练代码中,可能会累计多次loss# 然后进行一次optimizer.step()step: int = 0# 参数更新的次数。这个才是optimizer.step()的次数accum_step: int = 0samples: int = 0 # 记录训练过的样本数量tokens: int = 0 # 记录处理过的token数量(target的)
训练函数
def run_epoch(data_iter,model,loss_compute,optimizer,scheduler,mode="train",accum_iter=1,train_state=TrainState(),):"""进行一个epoch训练data_iter: 可迭代对象,一次返回一个Batch对象model: Transformer模型,EncoderDecoder类对象loss_compute: SimpleLossCompute对象,用于计算损失optimizer: Adam优化器。验证时,optimizer是DummyOptimizerscheduler:LambdaLR对象,用于调整Adam的学习率,实现WarmUp若对调整学习率不熟悉,可参考:/zhaohongfei_358/article/details/125759911验证时,scheduler是DummyScheduleraccum_iter: 每计算多少个batch更新一次参数,默认为1,也就是每个batch都对参数进行更新train_state: TrainState对象,用于保存一些训练状态"""start = time.time()# 记录target的总token数,每次打印日志后,进行清0tokens = 0# 记录tgt_y的总token数,用于对total_loss进行正则化total_tokens = 0total_loss = 0n_accum = 0 # 本次epoch更新了多少次模型参数for i, batch in enumerate(data_iter):# 前向传递。等价于model(batch.src, batch.tgt, batch.src_mask, batch.tgt_mask)# 但注意,这里的out是Decoder的输出,并不是Generator的输出,因为在EncoderDecoder# 的forward中并没有使用generator。generator的调用放在了loss_compute中out = model.forward(batch.src, batch.tgt, batch.src_mask, batch.tgt_mask)"""计算损失,传入的三个参数分别为:1. out: EncoderDecoder的输出,该值并没有过最后的线性层,过线性层被集成在了计算损失中2. tgt_y: 要被预测的所有token,例如src为`<bos> I love you <eos>`,则`tgt_y`则为`我 爱 你 <eos>`3. ntokens:这批batch中有效token的数量。用于对loss进行正则化。返回两个loss,其中loss_node是正则化之后的,所以梯度下降时用这个。而loss是未进行正则化的,用于统计total_loss。"""loss, loss_node = loss_compute(out, batch.tgt_y, batch.ntokens)# loss_node = loss_node / accum_iterif mode == "train" or mode == "train+log":# 计算梯度loss_node.backward()# 记录step次数train_state.step += 1# 记录样本数量。batch.src.shape[0]获取的是Batch sizetrain_state.samples += batch.src.shape[0]# 记录处理过的token数train_state.tokens += batch.ntokens# 如果达到了accum_iter次,就进行一次参数更新if i % accum_iter == 0:optimizer.step()optimizer.zero_grad(set_to_none=True)# 记录本次epoch的参数更新次数n_accum += 1# 记录模型的参数更新次数train_state.accum_step += 1# 更新学习率scheduler.step()# 累计losstotal_loss += loss# 累计处理过的tokenstotal_tokens += batch.ntokens# 累计从上次打印日志开始处理过得tokenstokens += batch.ntokens# 每40个batch打印一次日志。if i % 40 == 1 and (mode == "train" or mode == "train+log"):# 打印一下当前的学习率lr = optimizer.param_groups[0]["lr"]# 记录这40个batch的消耗时间elapsed = time.time() - start# 打印日志print(("Epoch Step: %6d | Accumulation Step: %3d | Loss: %6.2f "+ "| Tokens / Sec: %7.1f | Learning Rate: %6.1e")# i: 本次epoch的第几个batch# n_accum: 本次epoch更新了多少次模型参数# loss / batch.ntokens: 对loss进行正则化,然后再打印loss,其实这里可以直接用loss_node# tokens / elapsed: 每秒可以处理的token数# lr: 学习率(learning rate),这里打印学习率的目的是看一下warmup下学习率的变化% (i, n_accum, loss / batch.ntokens, tokens / elapsed, lr))# 重置开始时间start = time.time()# 重置token数tokens = 0del lossdel loss_node# 返回正则化之后的total_loss,返回训练状态return total_loss / total_tokens, train_state
第一个例子
先从一个最简单的例子开始:从一个很小的词典中随机选取一些词做为输入,目标就是重新输出这些词。称为copy任务。
生成数据
def data_gen(V, batch_size, nbatches):"""生成一组随机数据。(该方法仅用于Demo)V: 词典的大小batch_sizenbatches: 生成多少个batchreturn: yield一个Batch对象"""# 生成{nbatches}个batchfor i in range(nbatches):# 生成一组输入数据data = torch.randint(1, V, size=(batch_size, 10))# 将每行的第一个词都改为1,即"<bos>"data[:, 0] = 1# 该数据不需要梯度下降src = data.requires_grad_(False).clone().detach()tgt = data.requires_grad_(False).clone().detach()# 返回一个Batch对象yield Batch(src, tgt, 0)
使用贪心算法解码(Greedy Decoding)
简单起见,使用贪心算法解码(Greedy Decoding)进行翻译任务的预测。
在这里,所谓的贪心算法就是Transformer的正常推理过程:先求出encoder的输出memory,然后利用memory一个一个的求出token
# 这个代码其实和最开始的inference_test()是一样的def greedy_decode(model, src, src_mask, max_len, start_symbol):"""进行模型推理,推理出所有预测结果。:param model: Transformer模型,即EncoderDecoder类对象:param src: Encoder的输入inputs,Shape为(batch_size, 词数)例如:[[1, 2, 3, 4, 5, 6, 7, 8, 0, 0]]即一个句子,该句子有10个词,分别为1,2,...,0:param src_mask: src的掩码,掩盖住非句子成分。:param max_len: 一个句子的最大长度。:param start_symbol: '<bos>' 对应的index,在本例中始终为0:return: 预测结果,例如[[1, 2, 3, 4, 5, 6, 7, 8]]"""# 将src送入Transformer的Encoder,输出memorymemory = model.encode(src, src_mask)# 初始化ys为[[0]],用于保存预测结果,其中0表示'<bos>'ys = torch.zeros(1, 1).fill_(start_symbol).type_as(src.data)# 循环调用decoder,一个个的进行预测。例如:假设我们要将“I love you”翻译成# “我爱你”,则第一次的`ys`为(<bos>),然后输出为“I”。然后第二次`ys`为(<bos>, I)# 输出为"love",依次类推,直到decoder输出“<eos>”或达到句子最大长度。for i in range(max_len - 1):# 将encoder的输出memory和之前Decoder的所有输出作为参数,让Decoder来预测下一个tokenout = model.decode(# ys就是Decoder之前的所有输出memory, src_mask, ys, subsequent_mask(ys.size(1)).type_as(src.data))# 将Decoder的输出送给generator进行预测。这里只取最后一个词的输出进行预测。# 因为你传的tgt的词数是变化的,第一次是(<bos>),第二次是(<bos>, I)# 所以你的out的维度也是变化的,变化的就是(batch_size, 词数,词向量)中词数这个维度# 前面的词向量送给generator预测的话,预测出来的也是前面的词,所以只能取最后一个。prob = model.generator(out[:, -1])# 取出数值最大的那个,它的index在词典中对应的词就是预测结果_, next_word = torch.max(prob, dim=1)# 取出预测结果next_word = next_word.data[0]# 将这一次的预测结果和之前的拼到一块,作为之后Decoder的输入ys = torch.cat([ys, torch.zeros(1, 1).type_as(src.data).fill_(next_word)], dim=1)# 返回最终的预测结果。return ys
接下来进行一个简单的模型训练,来实现copy任务
# 训练一个简单的copy任务def example_simple_model():# 定义词典大小为11V = 11# 定义损失函数criterion = LabelSmoothing(size=V, padding_idx=0, smoothing=0.0)# 构建模型,src和tgt的词典大小都为2,Layer数量为2model = make_model(V, V, N=2)# 使用Adam优化器optimizer = torch.optim.Adam(model.parameters(), lr=0.5, betas=(0.9, 0.98), eps=1e-9)# 自定义Warmup学习率lr_scheduler = LambdaLR(optimizer=optimizer,lr_lambda=lambda step: rate(step, model_size=model.src_embed[0].d_model, factor=1.0, warmup=400),)batch_size = 80# 运行20个epochfor epoch in range(20):# 将模型调整为训练模式model.train()# 训练一个Batchrun_epoch(# 生成20个batch对象data_gen(V, batch_size, 20),model,SimpleLossCompute(model.generator, criterion),optimizer,lr_scheduler,mode="train",)model.eval()# 在一个epoch后,进行模型验证run_epoch(data_gen(V, batch_size, 5),model,SimpleLossCompute(model.generator, criterion),# 不进行参数更新DummyOptimizer(),# 不调整学习率DummyScheduler(),mode="eval",)[0] # run_epoch返回loss和train_state,这里取loss,所以是[0]# 但是源码中并没有接收这个loss,所以这个[0]实际上没什么意义# 将模型调整为测试模式,准备开始推理model.eval()# 定义一个src 0-9,看看model能不能重新输出0-9src = torch.LongTensor([[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]])# 句子最大长度就是10,max_len = src.shape[1]# 不需要掩码,因为这10个都是有意义的数字src_mask = torch.ones(1, 1, max_len)# 使用greedy_decoder函数进行推理print(greedy_decode(model, src, src_mask, max_len=max_len, start_symbol=0))example_simple_model()
推断时用到了以下函数:
# 用于验证时,不进行参数更新。class DummyOptimizer(torch.optim.Optimizer):def __init__(self):self.param_groups = [{"lr": 0}]Nonedef step(self):Nonedef zero_grad(self, set_to_none=False):None# 用于验证时,不进行学习率调整。class DummyScheduler:def step(self):None
Epoch Step: 1 | Accumulation Step: 2 | Loss: 3.15 | Tokens / Sec: 822.1 | Learning Rate: 5.5e-06
Epoch Step: 1 | Accumulation Step: 2 | Loss: 2.05 | Tokens / Sec: 862.9 | Learning Rate: 6.1e-05
....略
Epoch Step: 1 | Accumulation Step: 2 | Loss: 0.12 | Tokens / Sec: 996.8 | Learning Rate: 1.0e-03
Epoch Step: 1 | Accumulation Step: 2 | Loss: 0.08 | Tokens / Sec: 999.6 | Learning Rate: 1.1e-03
tensor([[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]])
如果运行了这个例子,你可能会有一点小疑问:①为什么Epoch Step始终为1;②为什么Accumulation Step始终为2
关于第一点:首先,Epoch Step指的是在本次epoch中执行到了第几个batch(从0开始)。其次因为在run_epoch中,会在第1,41,81,…个batch打印日志,而在本例中一共就20个batch,所以一个epoch只会打印一次日志。第二点:Accumulation Step指的是在本次epoch中执行参数更新(optimizier.step())的次数。在第一打印日志时,一共执行了2次参数更新,又因为一个epoch只会打印一次日志,所以该值始终为2。
实战:德译英
一个使用IWSLT德语-英语翻译任务的真实示例。这个任务比论文中考虑的WMT任务小得多,但这个任务也能说明整个(翻译)系统。还展示了如何使用多GPU处理,使任务能真正快速地训练。
数据加载
使用torchtext
进行数据加载,并使用spacy进行分词。spacy可以参考这篇文章。加载数据集一定要使用这两个版本torchdata==0.3.0, torchtext==0.12
,否则会加载失败。
def load_tokenizers():"""加载spacy分词模型:return: 返回德语分词模型和英语分词模型"""try:spacy_de = spacy.load("de_core_news_sm")except IOError:# 如果报错,说明还未安装分词模型,进行安装后重新加载os.system("python -m spacy download de_core_news_sm")spacy_de = spacy.load("de_core_news_sm")try:spacy_en = spacy.load("en_core_web_sm")except IOError:os.system("python -m spacy download en_core_web_sm")spacy_en = spacy.load("en_core_web_sm")# 返回德语分词模型和英语分词模型return spacy_de, spacy_en
def build_vocabulary(spacy_de, spacy_en):"""构建德语词典和英语词典:return: 返回德语词典和英语词典,均为:Vocab对象Vocab对象官方地址为:/text/stable/vocab.html#vocab"""# 构建德语分词方法def tokenize_de(text):return tokenize(text, spacy_de)# 构建英语分词方法def tokenize_en(text):return tokenize(text, spacy_en)print("Building German Vocabulary ...")"""其中train, val, test都是可迭代对象。例如:next(iter(train)) 返回一个tuple,为:('Zwei junge weiße Männer sind im Freien in der Nähe vieler Büsche.','Two young, White males are outside near many bushes.')"""train, val, test = datasets.Multi30k(language_pair=("de", "en"))"""build_vocab_from_iterator:根据一个可迭代对象生成一个词典。其返回一个Vocab对象,官方地址为:/text/stable/vocab.html#vocab其接收是三个参数1. iterator,需要传入一个可迭代对象。里面为分好词的数据,例如:[("I", "love", "you"), ("you", "love", "me")]2. min_freq,最小频率,当一个单词的出现频率达到最小频率后才会被算到词典中。例如,如果min_freq=2,则上例中只有“you”会被算到词典中,因为其他单词都只出现一次。3.specials, 特殊词汇,例如'<bos>', '<unk>'等。特殊单词会被加到词典的最前面。假设我们调用的是:vocab = build_vocab_from_iterator([("I", "love", "you"), ("you", "love", "me")],min_freq=1,specials=["<s>", "</s>"],)vocab对应的词典则为:{0:<s>, 1:</s>, 2:love, 3:you, 4:I, 5:me}"""vocab_src = build_vocab_from_iterator(yield_tokens(train + val + test, tokenize_de, index=0),min_freq=2,specials=["<s>", "</s>", "<blank>", "<unk>"],)# 开始构建英语词典,与上面一样print("Building English Vocabulary ...")train, val, test = datasets.Multi30k(language_pair=("de", "en"))vocab_tgt = build_vocab_from_iterator(yield_tokens(train + val + test, tokenize_en, index=1),min_freq=2,specials=["<s>", "</s>", "<blank>", "<unk>"],)# 设置默认index为`<unk>`,后面对于那些不认识的单词就会自动归为`<unk>`vocab_src.set_default_index(vocab_src["<unk>"])vocab_tgt.set_default_index(vocab_tgt["<unk>"])# 返回构建好的德语词典和英语词典return vocab_src, vocab_tgtdef load_vocab(spacy_de, spacy_en):"""加载德语词典和英语词典。由于构建词典的过程需要花费一定时间,所以该方法就是对build_vocabulary的进一步封装,增加了缓存机制。:return: 返回德语词典和英语词典,均为Vocab对象"""# 如果不存在缓存文件,说明是第一次构建词典if not exists("vocab.pt"):# 构建词典,并写入缓存文件vocab_src, vocab_tgt = build_vocabulary(spacy_de, spacy_en)torch.save((vocab_src, vocab_tgt), "vocab.pt")else:# 如果存在缓存文件,直接加载vocab_src, vocab_tgt = torch.load("vocab.pt")# 输出些日志:print("Finished.\nVocabulary sizes:")print("vocab_src size:", len(vocab_src))print("vocab_tgt size:", len(vocab_tgt))return vocab_src, vocab_tgt# 全局参数,后续还要用# 加载德语和英语分词器spacy_de, spacy_en = load_tokenizers()# 加载德语词典(源词典)和英语词典(目标词典)vocab_src, vocab_tgt = load_vocab(spacy_de, spacy_en)
Building German Vocabulary ...Building English Vocabulary ...Finished.Vocabulary sizes:83156384
Iterators迭代器
批处理对训练速度非常重要。我们希望有非常均匀的批次,绝对最小的填充。为此,我们必须对默认的torchtext批处理进行一些修改。此代码修补了torchtext的默认批处理,以确保我们通过搜索足够的句子来找到稳定的批处理。
def collate_batch(batch,src_pipeline,tgt_pipeline,src_vocab,tgt_vocab,device,max_padding=128,pad_id=2,):"""Dataloader中的collate_fn函数。该函数的作用是:将文本句子处理成数字句子,然后pad到固定长度,最终batch到一起:param batch: 一个batch的语句对。例如:[('Ein Kleinkind ...', 'A toddler in ...'), # [(德语), (英语).... # ......] # ... ]:param src_pipeline: 德语分词器,也就是tokenize_de方法,后面会定义其实就是对spacy_de的封装:param tgt_pipeline: 英语分词器,也就是tokenize_en方法:param src_vocab: 德语词典,Vocab对象:param tgt_vocab: 英语词典,Vocab对象:param device: cpu或cuda:param max_padding: 句子的长度。pad长度不足的句子和裁剪长度过长的句子,目的是让不同长度的句子可以组成一个tensor:param pad_id: '<blank>'在词典中对应的index:return: src和tgt。处理后并batch后的句子。例如:src为:[[0, 4354, 314, ..., 1, 2, 2, ..., 2], [0, 4905, 8567, ..., 1, 2, 2, ..., 2]]其中0是<bos>, 1是<eos>, 2是<blank>src的Shape为(batch_size, max_padding)tgt同理。"""# 定义'<bos>'的index,在词典中为0,所以这里也是0bs_id = torch.tensor([0], device=device) # <s> token id# 定义'<eos>'的indexeos_id = torch.tensor([1], device=device) # </s> token id# 用于存储处理后的src和tgtsrc_list, tgt_list = [], []# 循环遍历句子对儿for (_src, _tgt) in batch:"""_src: 德语句子,例如:Ein Junge wirft Blätter in die Luft._tgt: 英语句子,例如:A boy throws leaves into the air.""""""将句子进行分词,并将词转成对应的index。例如:"I love you" -> ["I", "love", "you"] ->[1136, 2468, 1349] -> [0, 1136, 2468, 1349, 1]其中0,1是<bos>和<eos>。Vocab对象可以将list中的词转为index,例如:`vocab_tgt(["I", "love", "you"])` 的输出为:[1136, 2468, 1349]"""processed_src = torch.cat(# 将<bos>,句子index和<eos>拼到一块[bs_id,torch.tensor(# 进行分词后,转换为index。src_vocab(src_pipeline(_src)),dtype=torch.int64,device=device,),eos_id,],0,)processed_tgt = torch.cat([bs_id,torch.tensor(tgt_vocab(tgt_pipeline(_tgt)),dtype=torch.int64,device=device,),eos_id,],0,)"""将长度不足的句子进行填充到max_padding的长度的,然后增添到list中pad:假设processed_src为[0, 1136, 2468, 1349, 1]第二个参数为: (0, 72-5)第三个参数为:2则pad的意思表示,给processed_src左边填充0个2,右边填充67个2。最终结果为:[0, 1136, 2468, 1349, 1, 2, 2, 2, ..., 2]"""src_list.append(pad(processed_src,(0, max_padding - len(processed_src),),value=pad_id,))tgt_list.append(pad(processed_tgt,(0, max_padding - len(processed_tgt),),value=pad_id,))# 将多个src句子堆叠到一起src = torch.stack(src_list)tgt = torch.stack(tgt_list)# 返回batch后的结果return (src, tgt)
def create_dataloaders(device,vocab_src,vocab_tgt,spacy_de,spacy_en,batch_size=12000,max_padding=128,is_distributed=True,):"""创建train_dataloader和valid_dataloader:param device: cpu或cuda:param vocab_src: 源词典,本例中为德语词典:param vocab_tgt: 目标词典,本例中为英语词典:param spacy_de: 德语分词器:param spacy_en: 英语分词器:param batch_size: batch_size:param max_padding: 句子的最大长度:return: train_dataloader和valid_dataloader"""# 定义德语分词器:def tokenize_de(text):return tokenize(text, spacy_de)# 定义英语分词器def tokenize_en(text):return tokenize(text, spacy_en)# 创建批处理工具,即应该如何将一批数据汇总成一个Batchdef collate_fn(batch):return collate_batch(batch,tokenize_de,tokenize_en,vocab_src,vocab_tgt,device,max_padding=max_padding,pad_id=vocab_src.get_stoi()["<blank>"],)# 加载数据集train_iter, valid_iter, test_iter = datasets.Multi30k(language_pair=("de", "en"))"""将Iterator类型的Dataset转为Map类型的Dataset。如果你不熟悉,可以参考:/zhaohongfei_358/article/details/122742656经过测试,发现其实不转也可以。效果没差别"""train_iter_map = to_map_style_dataset(train_iter) # DistributedSampler needs a dataset len()train_sampler = (DistributedSampler(train_iter_map) if is_distributed else None)valid_iter_map = to_map_style_dataset(valid_iter)valid_sampler = (DistributedSampler(valid_iter_map) if is_distributed else None)# 构建DataLoader,若DataLoader不熟悉,请参考文章:# /zhaohongfei_358/article/details/122742656train_dataloader = DataLoader(train_iter_map,batch_size=batch_size,shuffle=(train_sampler is None),sampler=train_sampler,collate_fn=collate_fn,)valid_dataloader = DataLoader(valid_iter_map,batch_size=batch_size,shuffle=(valid_sampler is None),sampler=valid_sampler,collate_fn=collate_fn,)return train_dataloader, valid_dataloader
训练模型
def train_worker(device,vocab_src,vocab_tgt,spacy_de,spacy_en,config):"""训练模型:param device: cpu或cuda:param vocab_src: 源词典,本例中为德语词典:param vocab_tgt: 目标词典,本例中为英语词典:param spacy_de: 德语分词器:param spacy_en: 英语分词器:param config: 一个保存了配置参数的dict,例如学习率啥的"""print(f"Train worker process using device: {device} for training")# 找出目标词典中‘<blank>’所对应的indexpad_idx = vocab_tgt["<blank>"]# 设置词向量大小。d_model = 512# 构建模型,Layer数为6model = make_model(len(vocab_src), len(vocab_tgt), N=6)model.to(device)# 定义损失函数criterion = LabelSmoothing(size=len(vocab_tgt), padding_idx=pad_idx, smoothing=0.1)criterion.to(device)# 创建train_dataloader和valid_dataloadertrain_dataloader, valid_dataloader = create_dataloaders(device,vocab_src,vocab_tgt,spacy_de,spacy_en,batch_size=config["batch_size"],max_padding=config["max_padding"])# 创建Adam优化器optimizer = torch.optim.Adam(model.parameters(), lr=config["base_lr"], betas=(0.9, 0.98), eps=1e-9)# 定义Warmup学习率策略lr_scheduler = LambdaLR(optimizer=optimizer,lr_lambda=lambda step: rate(step, d_model, factor=1, warmup=config["warmup"]),)# 创建train_state,保存训练状态train_state = TrainState()# 开始训练for epoch in range(config["num_epochs"]):model.train()print(f"[Epoch {epoch} Training ====", flush=True)_, train_state = run_epoch((Batch(b[0], b[1], pad_idx) for b in train_dataloader),model,SimpleLossCompute(model.generator, criterion),optimizer,lr_scheduler,mode="train+log",accum_iter=config["accum_iter"],train_state=train_state,)"""展示GPU使用情况,例如:| ID | GPU | MEM |------------------| 0 | 11% | 6% |"""if torch.cuda.is_available():GPUtil.showUtilization()# 每训练一个epoch保存一次模型file_path = "%s%.2d.pt" % (config["file_prefix"], epoch)torch.save(model.state_dict(), file_path)if torch.cuda.is_available():torch.cuda.empty_cache()# 在一个epoch后,进行模型验证print(f"[Epoch {epoch} Validation ====")model.eval()# 跑验证集中的数据,看看loss有多少sloss = run_epoch((Batch(b[0], b[1], pad_idx) for b in valid_dataloader),model,SimpleLossCompute(model.generator, criterion),DummyOptimizer(),DummyScheduler(),mode="eval",)# 打印验证集的Lossprint("Validation Loss:", sloss[0].data)if torch.cuda.is_available():torch.cuda.empty_cache()# 全部epoch训练完毕后,保存模型file_path = "%sfinal.pt" % config["file_prefix"]torch.save(model.state_dict(), file_path)
def load_trained_model():"""加载模型或训练模型。若没有找到模型,说明没有训练过,则进行训练:return: Transformer对象,即EncoderDecoder类对象"""# 定义一些模型训练参数config = {"batch_size": 32,"num_epochs": 8, # epoch数量"accum_iter": 10, # 每10个batch更新一次模型参数"base_lr": 1.0, # 基础学习率,根据这个学习率进行warmup"max_padding": 72, # 句子的最大长度"warmup": 3000, # Warmup3000次,也就是从第3000次学习率开始下降"file_prefix": "multi30k_model_", # 模型文件前缀名}device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')model_path = "multi30k_model_final.pt"# 如果模型不存在,则训练一个模型if not exists(model_path):train_worker(device, vocab_src, vocab_tgt, spacy_de, spacy_en, config)# 初始化模型实例model = make_model(len(vocab_src), len(vocab_tgt), N=6)# 加载模型参数model.load_state_dict(torch.load("multi30k_model_final.pt"))return model# 加载或训练模型model = load_trained_model()
Train worker process using GPU: 0 for training[GPU0] Epoch 0 Training ====Epoch Step:1 | Accumulation Step: 1 | Loss: 7.66 | Tokens / Sec: 2988.2 | Learning Rate: 5.4e-07Epoch Step:41 | Accumulation Step: 5 | Loss: 7.43 | Tokens / Sec: 2845.3 | Learning Rate: 1.1e-05……Epoch Step: 881 | Accumulation Step: 89 | Loss: 3.99 | Tokens / Sec: 2812.8 | Learning Rate: 2.4e-04| ID | GPU | MEM |------------------| 0 | 91% | 32% |[GPU0] Epoch 0 Validation ====(tensor(3.8559, device='cuda:0'), <__main__.TrainState object at 0x7fdcf315e110>)[GPU0] Epoch 1 Training ====
下面是分布式版本的训练函数
def train_worker(gpu,ngpus_per_node,vocab_src,vocab_tgt,spacy_de,spacy_en,config,is_distributed=False,):print(f"Train worker process using GPU: {gpu} for training", flush=True)torch.cuda.set_device(gpu)pad_idx = vocab_tgt["<blank>"]d_model = 512model = make_model(len(vocab_src), len(vocab_tgt), N=6)model.cuda(gpu)module = modelis_main_process = Trueif is_distributed:dist.init_process_group("nccl", init_method="env://", rank=gpu, world_size=ngpus_per_node)model = DDP(model, device_ids=[gpu])module = model.moduleis_main_process = gpu == 0criterion = LabelSmoothing(size=len(vocab_tgt), padding_idx=pad_idx, smoothing=0.1)criterion.cuda(gpu)train_dataloader, valid_dataloader = create_dataloaders(gpu,vocab_src,vocab_tgt,spacy_de,spacy_en,batch_size=config["batch_size"] // ngpus_per_node,max_padding=config["max_padding"],is_distributed=is_distributed,)optimizer = torch.optim.Adam(model.parameters(), lr=config["base_lr"], betas=(0.9, 0.98), eps=1e-9)lr_scheduler = LambdaLR(optimizer=optimizer,lr_lambda=lambda step: rate(step, d_model, factor=1, warmup=config["warmup"]),)train_state = TrainState()for epoch in range(config["num_epochs"]):if is_distributed:train_dataloader.sampler.set_epoch(epoch)valid_dataloader.sampler.set_epoch(epoch)model.train()print(f"[GPU{gpu}] Epoch {epoch} Training ====", flush=True)_, train_state = run_epoch((Batch(b[0], b[1], pad_idx) for b in train_dataloader),model,SimpleLossCompute(module.generator, criterion),optimizer,lr_scheduler,mode="train+log",accum_iter=config["accum_iter"],train_state=train_state,)GPUtil.showUtilization()if is_main_process:file_path = "%s%.2d.pt" % (config["file_prefix"], epoch)torch.save(module.state_dict(), file_path)torch.cuda.empty_cache()print(f"[GPU{gpu}] Epoch {epoch} Validation ====", flush=True)model.eval()sloss = run_epoch((Batch(b[0], b[1], pad_idx) for b in valid_dataloader),model,SimpleLossCompute(module.generator, criterion),DummyOptimizer(),DummyScheduler(),mode="eval",)print(sloss)torch.cuda.empty_cache()if is_main_process:file_path = "%sfinal.pt" % config["file_prefix"]torch.save(module.state_dict(), file_path)def train_distributed_model(vocab_src, vocab_tgt, spacy_de, spacy_en, config):from the_annotated_transformer import train_workerngpus = torch.cuda.device_count()os.environ["MASTER_ADDR"] = "localhost"os.environ["MASTER_PORT"] = "12356"print(f"Number of GPUs detected: {ngpus}")print("Spawning training processes ...")mp.spawn(train_worker,nprocs=ngpus,args=(ngpus, vocab_src, vocab_tgt, spacy_de, spacy_en, config, True),)def train_model(vocab_src, vocab_tgt, spacy_de, spacy_en, config):if config["distributed"]:train_distributed_model(vocab_src, vocab_tgt, spacy_de, spacy_en, config)else:train_worker(0, 1, vocab_src, vocab_tgt, spacy_de, spacy_en, config, False)def load_trained_model():config = {"batch_size": 32,"distributed": False,"num_epochs": 8,"accum_iter": 10,"base_lr": 1.0,"max_padding": 72,"warmup": 3000,"file_prefix": "multi30k_model_",}model_path = "multi30k_model_final.pt"if not exists(model_path):train_model(vocab_src, vocab_tgt, spacy_de, spacy_en, config)model = make_model(len(vocab_src), len(vocab_tgt), N=6)model.load_state_dict(torch.load("multi30k_model_final.pt"))return modelif is_interactive_notebook():model = load_trained_model()
测试结果
在最后我们可以使用验证集来简单的测试一下我们的模型
# Load data and model for output checksdef check_outputs(valid_dataloader,model,vocab_src,vocab_tgt,n_examples=15,pad_idx=2,eos_string="</s>",):results = [()] * n_examplesfor idx in range(n_examples):print("\nExample %d ========\n" % idx)b = next(iter(valid_dataloader))rb = Batch(b[0], b[1], pad_idx)greedy_decode(model, rb.src, rb.src_mask, 64, 0)[0]src_tokens = [vocab_src.get_itos()[x] for x in rb.src[0] if x != pad_idx]tgt_tokens = [vocab_tgt.get_itos()[x] for x in rb.tgt[0] if x != pad_idx]print("Source Text (Input) : "+ " ".join(src_tokens).replace("\n", ""))print("Target Text (Ground Truth) : "+ " ".join(tgt_tokens).replace("\n", ""))model_out = greedy_decode(model, rb.src, rb.src_mask, 72, 0)[0]model_txt = (" ".join([vocab_tgt.get_itos()[x] for x in model_out if x != pad_idx]).split(eos_string, 1)[0]+ eos_string)print("Model Output: " + model_txt.replace("\n", ""))results[idx] = (rb, src_tokens, tgt_tokens, model_out, model_txt)return resultsdef run_model_example(n_examples=5):global vocab_src, vocab_tgt, spacy_de, spacy_enprint("Preparing Data ...")_, valid_dataloader = create_dataloaders(torch.device("cpu"),vocab_src,vocab_tgt,spacy_de,spacy_en,batch_size=1,is_distributed=False,)print("Loading Trained Model ...")model = make_model(len(vocab_src), len(vocab_tgt), N=6)model.load_state_dict(torch.load("multi30k_model_final.pt", map_location=torch.device("cpu")))print("Checking Model Outputs:")example_data = check_outputs(valid_dataloader, model, vocab_src, vocab_tgt, n_examples=n_examples)return model, example_datarun_model_example()
模型完整代码
import copyimport mathimport timeimport numpy as npimport torchimport torch.nn as nnimport torch.nn.functional as Ffrom torch.autograd import Variable########################################################### 生成模型##########################################################class EncoderDecoder(nn.Module):"""标准的Encoder-Decoder架构"""def __init__(self, encoder, decoder, src_embed, tgt_embed, generator):super(EncoderDecoder, self).__init__()self.encoder = encoderself.decoder = decoderself.src_embed = src_embed # 源序列embeddingself.tgt_embed = tgt_embed # 目标序列embeddingself.generator = generator # 生成目标单词的概率def forward(self, src, tgt, src_mask, tgt_mask):"""接收和处理原序列,目标序列,以及他们的mask"""return self.decode(self.encode(src, src_mask), src_mask,tgt, tgt_mask)def encode(self, src, src_mask):return self.encoder(self.src_embed(src), src_mask)def decode(self, memory, src_mask, tgt, tgt_mask):return self.decoder(self.tgt_embed(tgt), memory, src_mask, tgt_mask)class Generator(nn.Module):"""定义标准的linear+softmax生成步骤"""def __init__(self, d_model, vocab):super(Generator, self).__init__()self.proj = nn.Linear(d_model, vocab)def forward(self, x):return F.log_softmax(self.proj(x), dim=-1)# Encoder部分def clones(module, N):"""产生N个相同的层"""return nn.ModuleList([copy.deepcopy(module) for _ in range(N)])class Encoder(nn.Module):"""N层堆叠的Encoder"""def __init__(self, layer, N):super(Encoder, self).__init__()self.layers = clones(layer, N)self.norm = LayerNorm(layer.size)def forward(self, x, mask):"""每层layer依次通过输入序列与mask"""for layer in self.layers:x = layer(x, mask)return self.norm(x)class LayerNorm(nn.Module):"""构造一个layernorm模块"""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 = epsdef forward(self, x):"""Norm"""mean = x.mean(-1, keepdim=True)std = x.std(-1, keepdim=True)return self.a_2 * (x - mean) / (std + self.eps) + self.b_2class SublayerConnection(nn.Module):"""Add+Norm"""def __init__(self, size, dropout):super(SublayerConnection, self).__init__()self.norm = LayerNorm(size)self.dropout = nn.Dropout(dropout)def forward(self, x, sublayer):"""add norm"""norm = self.dropout(sublayer(self.norm(x)))return x + normclass EncoderLayer(nn.Module):"""Encoder分为两层Self-Attn和Feed Forward"""def __init__(self, size, self_attn, feed_forward, dropout):super(EncoderLayer, self).__init__()self.self_attn = self_attnself.feed_forward = feed_forwardself.sublayer = clones(SublayerConnection(size, dropout), 2)self.size = sizedef forward(self, x, mask):"""Self-Attn和Feed Forward"""x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, mask))return self.sublayer[1](x, self.feed_forward)# Decoder部分class Decoder(nn.Module):"""带mask功能的通用Decoder结构"""def __init__(self, layer, N):super(Decoder, self).__init__()self.layers = clones(layer, N)self.norm = LayerNorm(layer.size)def forward(self, x, memory, src_mask, tgt_mask):for layer in self.layers:x = layer(x, memory, src_mask, tgt_mask)return self.norm(x)class DecoderLayer(nn.Module):"""Decoder is made of self-attn, src-attn, and feed forward"""def __init__(self, size, self_attn, src_attn, feed_forward, dropout):super(DecoderLayer, self).__init__()self.size = sizeself.self_attn = self_attnself.src_attn = src_attnself.feed_forward = feed_forwardself.sublayer = clones(SublayerConnection(size, dropout), 3)def forward(self, x, memory, src_mask, tgt_mask):"""将decoder的三个Sublayer串联起来"""m = memoryx = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, tgt_mask))x = self.sublayer[1](x, lambda x: self.src_attn(x, m, m, src_mask))return self.sublayer[2](x, self.feed_forward)def subsequent_mask(size):"""mask后续的位置,返回[size, size]尺寸下三角Tensor对角线及其左下角全是1,右上角全是0"""attn_shape = (1, size, size)subsequent_mask = torch.triu(torch.ones(attn_shape), diagonal=1).type(torch.uint8)return subsequent_mask == 0# Attentiondef attention(query, key, value, mask=None, dropout=None):"""计算Attention即点乘V"""d_k = query.size(-1)# [B, h, L, L]scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k)if mask is not None:scores = scores.masked_fill(mask == 0, -1e9)p_attn = F.softmax(scores, dim=-1)if dropout is not None:p_attn = dropout(p_attn)return torch.matmul(p_attn, value), p_attnclass MultiHeadedAttention(nn.Module):def __init__(self, h, d_model, dropout=0.1):"""Take in model size and number of heads."""super(MultiHeadedAttention, self).__init__()assert d_model % h == 0# We assume d_v always equals d_kself.d_k = d_model // hself.h = h# 定义W^q, W^k, W^v和W^o矩阵。self.linears = clones(nn.Linear(d_model, d_model), 4) self.attn = Noneself.dropout = nn.Dropout(p=dropout)def forward(self, query, key, value, mask=None):"""输入的q,k,v是形状 [batch, L, d_model]。输出的x 的形状同上。"""if mask is not None:# Same mask applied to all h heads.mask = mask.unsqueeze(1)nbatches = query.size(0)# 1) qkv变化:[batch, L, d_model] ->[batch, h, L, d_model/h]# 因为初始化不同,所以最后输出的k,q,v矩阵不同query, key, value = [lin(x).view(nbatches, -1, self.h, self.d_k).transpose(1, 2)for lin, x in zip(self.linears, (query, key, value))]# 2) 计算注意力attn 得到 attn*v 与注意力系数attn# qkv :[batch, h, L, d_model/h] -->x:[b, h, L, d_model/h], attn[b, h, L, L]x, self.attn = attention(query, key, value, mask=mask, dropout=self.dropout)# 3) 上一步的结果合并在一起还原成原始输入序列的形状 [batch, L, d_model]x = (x.transpose(1, 2).contiguous().view(nbatches, -1, self.h * self.d_k))del querydel keydel value# 最终通过W^o矩阵再执行一次线性变换,得到最终结果return self.linears[-1](x)class PositionwiseFeedForward(nn.Module):"""实现FFN函数"""def __init__(self, d_model, d_ff, dropout=0.1):super(PositionwiseFeedForward, self).__init__()self.w_1 = nn.Linear(d_model, d_ff)self.w_2 = nn.Linear(d_ff, d_model)self.dropout = nn.Dropout(dropout)def forward(self, x):return self.w_2(self.dropout(F.relu(self.w_1(x))))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 # 表示embedding的维度def forward(self, x):embed = self.lut(x)dk = math.sqrt(self.d_model)return embed * dkclass PositionalEncoding(nn.Module):"Implement the PE function."def __init__(self, d_model, dropout, max_len=5000):super(PositionalEncoding, self).__init__()self.dropout = nn.Dropout(p=dropout)# 初始化Shape为(max_len, d_model)的PE (positional encoding)pe = torch.zeros(max_len, d_model)# 初始化一个tensor [[0, 1, 2, 3, ...]]position = torch.arange(0, max_len).unsqueeze(1)# 这里就是sin和cos括号中的内容,通过e和ln进行了变换div_term = torch.exp(torch.arange(0, d_model, 2) * -(math.log(10000.0) / d_model))# 计算PE(pos, 2i)pe[:, 0::2] = torch.sin(position * div_term)# 计算PE(pos, 2i+1)pe[:, 1::2] = torch.cos(position * div_term)# 为了方便计算,在最外面unsqueeze出一个batchpe = pe.unsqueeze(0)# 如果一个参数不参与梯度下降,但又希望保存model的时候将其保存下来# 这个时候就可以用register_bufferself.register_buffer("pe", pe)def forward(self, x):"""x 为embedding后的inputs,例如(1,7, 128),batch size为1,7个单词,单词维度为128"""# 将x和positional encoding相加。x = x + self.pe[:, : x.size(1)].requires_grad_(False)return self.dropout(x)# 定义一个接受超参数并生成完整模型的函数def make_model(src_vocab, tgt_vocab, N=6, d_model=512, d_ff=2048, h=8, dropout=0.1):"""根据输入的超参数构建一个模型"""c = copy.deepcopyattn = MultiHeadedAttention(h, d_model)ff = PositionwiseFeedForward(d_model, d_ff, dropout)position = PositionalEncoding(d_model, dropout)model = EncoderDecoder(Encoder(EncoderLayer(d_model, c(attn), c(ff), dropout), N),Decoder(DecoderLayer(d_model, c(attn), c(attn),c(ff), dropout), N),nn.Sequential(Embeddings(d_model, src_vocab), c(position)),nn.Sequential(Embeddings(d_model, tgt_vocab), c(position)),Generator(d_model, tgt_vocab))# 使用xavier初始化参数for p in model.parameters():if p.dim() > 1:nn.init.xavier_uniform_(p)return model
12.补充
并行计算
Transformer的出现解决了传统seq2seq模型无法并行训练的问题,例如将"I am fine" 翻译成“我很好”,seq2seq模型在每个时间步只能逐次翻译出每个token,而Transformer可以翻译出一整句话。值得注意的是Encoder端默认在训练和测试阶段都是并行化一次得到整个句子的representation,但decoder端的并行计算需要teach-forcing和masked self-attention,而且只在训练阶段有效。因为预测阶段缺少真实数据,如果有了真实数据作为decoder端每个时间步的输入,则可以打破RNNs在预测时需要上一个时间步的输出才能进行后续计算的限制,从而进行并行训练。来源
copy.copy()和copy.deepcopy()
代码中定义了clones函数
def clones(module, N):"Produce N identical layers."return nn.ModuleList([copy.deepcopy(module) for _ in range(N)])
研究一下copy.copy()和copy.deepcopy()。可以复制一个数
tensor_list = [copy.copy(4) for _ in range(3)]print(tensor_list)
[4, 4, 4]
可以复制变量
import copyimport torchtensor = torch.tensor([[1,2],[4,5]])tensor_list = [copy.deepcopy(tensor) for _ in range(3)]print(tensor_list)
[tensor([[1, 2],[4, 5]]), tensor([[1, 2],[4, 5]]), tensor([[1, 2],[4, 5]])]
copy.copy()和copy.deepcopy()的区别在哪里?
参考Python中的copy.copy()和copy.deepcopy()区别在哪里? - 知乎
当我们在 Python 中使用赋值语句 (=) 来创建复合对象的副本时,例如,列表或类实例或基本上任何包含其他对象的对象,Python 并没有克隆对象本身,相反,它只是将引用绑定到目标对象上。两者实质上是同一段内存copy.copy()是浅拷贝,浅层复制是指复制一个对象的引用并将其存储在一个新的变量中的过程。改变原变量,被拷贝的新数据也会随着改变。
tensor = torch.tensor([[1,2],[4,5]])tensor_list = [copy.copy(tensor) for _ in range(3)]tensor[0,1]=10print(tensor_list)
[tensor([[ 1, 10],[ 4, 5]]), tensor([[ 1, 10],[ 4, 5]]), tensor([[ 1, 10],[ 4, 5]])]
copy.copy()是深拷贝。通过深度拷贝,我们可以创建一个独立于原始数据的新对象,但包含相同的值,而不是为相同的值创建新的引用。改变原变量,被拷贝的新数据不会随着改变通常,你想复制一个复合对象,例如在一个方法的开始,然后修改克隆的对象,但保持原始对象的原样,以便以后再使用它。为了达到这个目的,我们需要对该对象进行深度复制。
tensor = torch.tensor([[1,2],[4,5]])tensor_list = [copy.deepcopy(tensor) for _ in range(3)]tensor[0,1]=10print(tensor_list)
[tensor([[1, 2],[4, 5]]), tensor([[1, 2],[4, 5]]), tensor([[1, 2],[4, 5]])]
下面做一个实验,看看copy后旧变量的地址:
tensor = torch.tensor([[1,2,3],[4,5,6]])print('tensor:',id(tensor))tensor_list = [copy.copy(tensor) for _ in range(3)]tensor_list_deep = [copy.deepcopy(tensor) for _ in range(3)]for item in range(len(tensor_list)):print('tensor_list:',id(tensor_list[item]))for item in range(len(tensor_list_deep)):print('tensor_list_deep:',id(tensor_list_deep[item]))
tensor: 139727141596240tensor_list: 139724417321872tensor_list: 139724417318992tensor_list: 139724416945328tensor_list_deep: 13972441734tensor_list_deep: 139724417320624tensor_list_deep: 139724417321008
发现无论是深拷贝和浅拷贝,新列表中的变量和原始变量的内存地址都是不一样的,对于深拷贝来说,这是比较有意思的地方。
contiguous()
contiguous()的执行对象是Tensor,一般在view()之前都要用一下。
Tensor多维数组底层实现是使用一块连续内存的1维数组(行优先顺序存储),Tensor在元信息中保存了多维数组的形状,在访问元素时,通过多维度索引转化成1维数组相对于数组起始位置的偏移量即可找到对应的数据)
某些Tensor操作(如transpose、permute、narrow、expand)与原Tensor共享内存数据,不会另外开一块内存存储数据,也不会改变底层数组的存储顺序【原来的Tensor数据没有任何变化,会通过新的索引方式来实现语义上的相邻】。
但原来语义相邻、内存也相邻的元素在执行这样的操作后,形成的新的Tensor元素只在语义上相邻,但在内存上不相邻了。此时我们说新Tensor is not contiguous。
对一个Tensor执行contiguous(),如果该Tensor语义相邻、内存也相邻,则contiguous()不会有任何操作;但如果该Tensor语义相邻,内存上不相邻,则会重新开辟一块内存空间存放此Tensor的数据,使得语义和内存上都相邻。
为什么需要Tensor在内存上相邻呢?因为torch.view是需要Tensor在内存上是相邻的