动手学CV-Pytorch计算机视觉 Transformer介绍和实战案例
2017年谷歌在一篇名为《Attention Is All You Need》的论文中,提出了一个基于attention(自注意力机制)结构来处理序列相关的问题的模型,名为Transformer。
Transformer在很多不同nlp任务中获得了成功,例如:文本分类、机器翻译、阅读理解等。在解决这类问题时,Transformer模型摒弃了固有的定式,并没有用任何CNN或者RNN的结构,而是使用了Attention注意力机制,自动捕捉输入序列不同位置处的相对关联,善于处理较长文本,并且该模型可以高度并行地工作,训练速度很快。
本文将大致按照以下几个模块进行讲解:
- 模型结构概览
- 模型输入
- Encoder
- Decoder
- 模型输出
- 模型构建
- 实战案例
每个模块会配合代码+注释+讲解
的方式来进行介绍,最后会有一个玩具级别的序列预测任务进行实战。
通过本文,希望可以帮助大家,初探Transformer的原理和用法,下面直接进入正式内容:
6.1.1 模型结构概览
如下是Transformer的两个结构示意图:
上图是从一篇英文博客中截取的Transformer的结构简图,下图是原论文中给出的结构简图,更细粒度一些,可以结合着来看。
模型大致分为Encoder
(编码器)和Decoder
(解码器)两个部分,分别对应上图中的左右两部分。
其中编码器由N个相同的层堆叠在一起(我们后面的实验取N=6),每一层又有两个子层。
第一个子层是一个Multi-Head Attention
(多头的自注意机制),第二个子层是一个简单的Feed Forward
(全连接前馈网络)。两个子层都添加了一个残差连接+layer normalization的操作。
模型的解码器同样是堆叠了N个相同的层,不过和编码器中每层的结构稍有不同。对于解码器的每一层,除了编码器中的两个子层Multi-Head Attention
和Feed Forward
,解码器还包含一个子层Masked Multi-Head Attention
,如图中所示每个子层同样也用了residual以及layer normalization。
模型的输入由Input Embedding
和Positional Encoding
(位置编码)两部分组合而成,模型的输出由Decoder的输出简单的经过softmax得到。
结合上图,我们对Transformer模型的结构做了个大致的梳理,只需要先有个初步的了解,下面对提及的每个模块进行详细介绍。
6.1.2 模型输入
首先我们来看模型的输入是什么样的,先明确模型输入,后面的模块理解才会更直观。
输入部分包含两个模块,Embedding
和 Positional Encoding
。
1. Embedding层
Embedding层的作用是将某种格式的输入数据,例如文本,转变为模型可以处理的向量表示,来描述原始数据所包含的信息。
Embedding
层输出的可以理解为当前时间步的特征,如果是文本任务,这里就可以是Word Embedding
,如果是其他任务,就可以是任何合理方法所提取的特征。
构建Embedding层的代码很简单,核心是借助torch提供的nn.Embedding
,如下:
class Embeddings(nn.Module):
def __init__(self, d_model, vocab):
"""
类的初始化函数
d_model:指词嵌入的维度
vocab:指词表的大小
"""
super(Embeddings, self).__init__()
#之后就是调用nn中的预定义层Embedding,获得一个词嵌入对象self.lut
self.lut = nn.Embedding(vocab, d_model)
#最后就是将d_model传入类中
self.d_model =d_model
def forward(self, x):
"""
Embedding层的前向传播逻辑
参数x:这里代表输入给模型的单词文本通过词表映射后的one-hot向量
将x传给self.lut并与根号下self.d_model相乘作为结果返回
"""
embedds = self.lut(x)
return embedds * math.sqrt(self.d_model)
2. 位置编码:
Positional Encodding
位置编码的作用是为模型提供当前时间步的前后出现顺序的信息。因为Transformer不像RNN那样的循环结构有前后不同时间步输入间天然的先后顺序,所有的时间步是同时输入,并行推理的,因此在时间步的特征中融合进位置编码的信息是合理的。
位置编码可以有很多选择,可以是固定的,也可以设置成可学习的参数。
这里,我们使用固定的位置编码。具体地,使用不同频率的sin和cos函数来进行位置编码,如下所示:
$$ PE_{(pos,2i)} = sin(pos / 10000^{2i/d_{\text{model}}}) $$
$$ PE_{(pos,2i+1)} = cos(pos / 10000^{2i/d_{\text{model}}}) $$
其中pos代表时间步的下标索引,向量 P E p o s PE_{pos} PEpos 也就是第pos个时间步的位置编码,编码长度同Embedding
层,这里我们设置的是512。上面有两个公式,代表着位置编码向量中的元素,奇数位置和偶数位置使用两个不同的公式。
思考:为什么上面的公式可以作为位置编码?
我的理解:在上面公式的定义下,时间步p和时间步p+k的位置编码的内积,即 P E p ⋅ P E p + k PE_{p} \cdot PE_{p+k} PEp⋅PEp+k 是与p无关,只与k有关的定值(不妨自行证明下试试)。也就是说,任意两个相距k个时间步的位置编码向量的内积都是相同的,这就相当于蕴含了两个时间步之间相对位置关系的信息。此外,每个时间步的位置编码又是唯一的,这两个很好的性质使得上面的公式作为位置编码是有理论保障的。
下面是位置编码模块的代码实现:
class PositionalEncoding(nn.Module):
def __init__(self, d_model, dropout, max_len=5000):
"""
位置编码器类的初始化函数
共有三个参数,分别是
d_model:词嵌入维度
dropout: dropout触发比率
max_len:每个句子的最大长度
"""
super(PositionalEncoding, self).__init__()
self.dropout = nn.Dropout(p=dropout)
# Compute the positional encodings
# 注意下面代码的计算方式与公式中给出的是不同的,但是是等价的,你可以尝试简单推导证明一下。
# 这样计算是为了避免中间的数值计算结果超出float的范围,
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len).unsqueeze(1)
div_term = torch.exp(torch.arange(0, d_model, 2) *
-(math.log(10000.0) / d_model))
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
pe = pe.unsqueeze(0)
self.register_buffer('pe', pe)
def forward(self, x):
x = x + Variable(self.pe[:, :x.size(1)], requires_grad=False)
return self.dropout(x)
因此,可以认为,最终模型的输入是若干个时间步对应的embedding,每一个时间步对应一个embedding,可以理解为是当前时间步的一个综合的特征信息,即包含了本身的语义信息,又包含了当前时间步在整个句子中的位置信息。
3. Encoder和Decoder都包含输入模块
此外有一个点刚刚接触Transformer的同学可能不太理解,编码器和解码器两个部分都包含输入,且两部分的输入的结构是相同的,只是推理时的用法不同,编码器只推理一次,而解码器是类似RNN那样循环推理,不断生成预测结果的。
怎么理解?假设我们现在做的是一个法语-英语的机器翻译任务,想把Je suis étudiant
翻译为I am a student
那么我们输入给编码器的就是时间步数为3的embedding数组,编码器只进行一次并行推理,即获得了对于输入的法语句子所提取的若干特征信息。
而对于解码器,是循环推理,逐个单词生成结果的。最开始,由于什么都还没预测,我们会将编码器提取的特征,以及一个句子起始符传给解码器,解码器预期会输出一个单词I
。然后有了预测的第一个单词,我们就将I
输入给解码器,会再预测出下一个单词am
,再然后我们将I am
作为输入喂给解码器,以此类推直到预测出句子终止符完成预测。
6.1.3 Encoder
这一小节介绍编码器部分的实现
1. 编码器
编码器作用是用于对输入进行特征提取,为解码环节提供有效的语义信息
整体来看编码器由N个编码器层简单堆叠而成,因此实现非常简单,代码如下:
# 定义一个clones函数,来更方便的将某个结构复制若干份
def clones(module, N):
"Produce N identical layers."
return nn.ModuleList([copy.deepcopy(module) for _ in range(N)])
class Encoder(nn.Module):
"""
Encoder
The encoder is composed of a stack of N=6 identical layers.
"""
def __init__(self, layer, N):
super(Encoder, self)