横扫NLP 脚踏CV界的Transformer到底是什么? 为什么Work?深入浅出零基础通俗理解Transformer及代码
Transformer 模型在近几年来横扫 NLP 领域并获得大量好评,在分类、检测等任务上展现了极其强劲的性能。另一方面其在计算机视觉领域也带来了革命性提升,而且骨干网络上的发展也推动了下游任务的发展,可以说Transformer已经引起了学术界的广泛关注,有越来越多的研究人员投入其中。
Transformer是谷歌在2017年发表的论文Attention Is All You Need中提出的一种seq2seq模型,首先是在自然语言处理方面应用。
本篇博文将带着问题,边阅读文章和源码,阐述Transfomer的特点、框架、原理和相关理解。下面我们开始吧!
如果你对Transformer已经有相当的了解,那么推荐你直接阅读:Transformer之十万个为什么?
一、为什么是Transformer?
传统的CNN、RNN(或者LSTM,GRU)计算是顺序的、迭代的、串行的,即只能从左向右依次计算或者从右向左依次计算,而这会导致两个问题:
- 计算依赖问题::必须要等到当前字处理完才能处理下一个字,限制了模型的并行处理能力;
- 顺序计算中的信息丢失:CNN、RNN在层次很深时,因为有max pooling,会逐渐丢失一些信息,尽管残差块、门机制等结构在一定程度上缓解了这个问题,但是对于特别长期的依赖现象,传统神经网络依旧无能为力。
而Transformer使用了位置嵌入 (Positional Encoding) 来理解语言的顺序,使用自注意力机制(Self Attention Mechanism)和全连接层进行计算,所有字都是同时训练,具有更好的并行性,不仅大大提高了计算效率,从长远来看更符合GPU的逻辑。
二、什么是Transformer?
1、整体框架
首先Transformer的整体主要分为Encoder和Decoder两大部分。
输入的序列首先变成计算机便于处理的Embedding,然后Embedding传入Encoder进行编码,映射成隐藏层特征,经过Encoder后再结合上一次的output输入到Decoder中,最后用softmax计算序列下一个单词的概率。
2、Embedding
Embedding的作用是将输入(“我是一个学生”)初步整理成计算机能识别的特征信息。
Transformer将输入的序列首先转换为Word Embedding,由于 Transformer 模型没有循环神经网络的迭代操作,其还需要编码位置信息,即Positional Encoding。
2.1、字向量:Word embedding
如果你曾经学过one-hot编码,你就会知道one-hot编码无法表示词与词之间的关系。Word Embedding解决了这个问题,随着训练,他能够使得意思相近的单词表达结果越来越近。
Word Embedding首先设计一个可学习的权重矩阵W,通过词向量与权重矩阵进行相乘,不断训练得到新的表示结果。如下例:
爱” 和 “喜欢” 这两个词经过 one-hot 后分别表示为 10000 和 00001,权重矩阵如下:
相乘后,“爱”被编码成[ ],"喜欢“被编码成[ ],而这两个词的语法和意思比较相近,在学习过程中,权重矩阵的参数不断更新,从而使得[ ]和[ ]的值越来越近。这就是Word embedding的工作流程。
在pytorch中,我们使用torch自带的embedding功能,权重矩阵先进行随机初始化(当然也可以选择 Pre-trained 的结果),但设为 Trainable。这样在 training 过程中不断地对 Embeddings 进行改进
class Embeddings(nn.Module):
def __init__(self, d_model, vocab):
super(Embeddings, self).__init__()
self.lut = nn.Embedding(vocab, d_model) #vocab表示词汇表的数量,d_model表示embedding的维度,即词向量的维度
self.d_model = d_model #表示embedding的维度
def forward(self, x):
return self.lut(x) * math.sqrt(self.d_model)
2.2、位置编码:Positional Encoding
Transformer 摈弃了 RNN 的结构,因此需要一个东西来标记各个字之间的时序 or 位置关系,而这个东西,就是位置嵌入。
以往我们根据单词之间的间隔比例算距离,如果设置整个句子长度为1,如:Attention is all you need ,其中is和you之间的距离为0.5。而:To follow along you will first need to install PyTorch较长文本中子里的0.5距离则会隔很多单词,这显然不合适。
所以总结一下理想的位置编码应该满足:
- 为每个字输出唯一的编码:sunflower:
- 不同长度的句子之间,任何两个字之间的差值应该保持一致:mushroom:
- 值应该是有界的:herb:
作者为此设计了一种Positional Encoding,首先,它不是一个数字,而是一个包含句子中特定位置信息的d维向量。其次,这种嵌入方式没有集成到模型中,相反,这个向量是用来给句子中的每个字提供位置信息的,换句话说,我们通过注入每个字位置信息的方式,增强了模型的输入(其实说白了就是将位置嵌入和字嵌入相加,然后作为输入)
其计算位置的公式为:
符号解释:其中,pos代表是词在句子中的位置,d是词向量的维度(通常为512),2i代表d中的偶数维度,同理,i代表奇数维度。
公式解释:由公式可知,每一维 i 都对应不同周期的正余弦曲线: i=0 时是周期为 的 sin 函数, i=1 时是周期为 的cos 函数…位置嵌入在 embedding_dimension维度上产生一种包含位置信息的纹理,对于不同的两个位置 pos1 和 pos2 ,若它们在某一维i上有相同的编码值,则这两个位置的差值等于该维所在曲线的周期,即 。而对于另一个维度 ,由于 , 因此 和 在这个维度 j 上的编码值就不会相等,对于其它任意 k 也是如此。
综上可知,这种编码方式保证了不同位置在所有维度上不会被编码到完全一样的值,从而使每个位置都获得独一无二的编码。
在pytorch中,使用自带的Positional Embedding模块进行设计
# Positional Encoding
class PositionalEncoding(nn.Module):
"实现PE功能"
def __init__(self, d_model, dropout, max_len=5000): ##vocab表示词汇表的数量,d_model表示embedding的维度,即词向量的维度
super(PositionalEncoding, self).__init__()
self.dropout = nn.Dropout(p=dropout)
pe = torch.zeros(max_len, d_model) # max_len=5000表示句子最多有5000个词
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) # [1, max_len, d_model]
self.register_buffer('pe', pe) # 将pe注册到模型的buffers()属性中,这代表该变量对应的是一个持久态,不会有梯度传播给它,但是能被模型的state_dict记录下来。
def forward(self, x):
x = x + Variable(self.pe[:, :x.size(1)], requires_grad=False) #输入模型的整个Embedding是Word Embedding与Positional Encoding直接相加之后的结果。
return self.dropout(x)
3、Encoder
Encoder的作用是将刚刚初步整理的Embedding,在Encoder中通过注意力机制等进行进一步的编码。
Encoder部分是由个层相同小Encoder Layer串联而成。小Encoder Layer可以简化为两个部分:(1)多头自注意力层:Multi-Head Self Attention (2) 前向反馈网络层:Feed-Forward network。前者用于捕捉特征之间的关系,后者是进一步编码学习
3.1、自注意力机制:Self-Attention
之前通过word embedding和positional encoding我们得到了字(单词)编码的向量表示:
。
接着,我们现在设计三个变换:key = linear_k(x)、query = linear_q(x)、value = linear_v(x) 将编码后的
进行变换生成三个矩阵:查询矩阵
(Query)、键矩阵
(Key)、值矩阵
(Value)
于是所有的字向量:
又衍生出三个新的向量:
、
、
,将其代入公式:
,其中的
是为了防止Q和K的点积值过大,避免在经过softmax后梯度太小,形象表示为:
至此我们得到了单个自注意力的输出,当然可以把上面的向量计算变成矩阵的形式(即将多个字向量摞成矩阵形式,矩阵第t行为第t个词的向量),从而一次计算出所有时刻的输出。
3.2、多头自注意力层:Multi-Head Self Attention
Multi-Head Self Attention 实际上是由h个Self Attention 层并行组成(原文为8),就是说线性变换的矩阵从一组( 、 、 )变成多组( 、 、 )、( 、 、 )、( 、 、 )…了,对于输入矩阵X,每一组Q、K、V都可以得到一个输出矩阵Z,最后将他们拼接起来就好了。
pytorch实现:
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
# 每个head中向量的维度,通常是512/8=64
self.d_k = d_model // h
# head的数量,通常为8
self.h = h
# 设置4个变换,其中3个用于生成Q、K、V clones是一个方法,代表将结构相同的层实例化n次
self.linears = clones(nn.Linear(d_model, d_model), 4)
self.attn = None
self.dropout = nn.Dropout(p=dropout)
def forward(self, query, key, value, mask=None):
"""
实现MultiHeadedAttention。
输入的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) 矩阵点乘生成Q、K、V
# 这一步qkv变化:[batch, L, d_model] ->[batch, h, L, d_model/h]
query, key, value = \
[l(x).view(nbatches, -1, self.h, self.d_k).transpose(1, 2)
for l, x in zip(self.linears, (query, key, value))]
# 2) 计算注意力attn 并加权到V,得到Z,维度与Q、K、V类似。
# 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) 维度复原
# 上一步的结果合并在一起还原成原始输入序列的形状
x = x.transpose(1, 2).contiguous().view(nbatches, -1, self.h * self.d_k)
# 最后将Z输入最后一个全连接层
return self.linears[-1](x)
3.3、连接与归一化:Add & Norm
连接Add
Add操作和原理很简单,就是把上一步得到的Self-attention(Q、K、V)(如果是多头自注意力,就是对应拼接后的矩阵)与经过了Positional Encoding的embedding连接。
归一化
采用的是Layer Normalization,Layer Normalization的作用是把神经网络中隐藏层归一为标准正态分布,以起到加快训练速度,加速收敛。
1.首先以列为单位计算均值和方差:
2.然后用每列的每个元素减去这列的均值,再除以这列的标准差,最后得到这列的标准差。
Add&Norm的pytorch实现如下:
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 = eps
def 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_2
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"
return x + self.dropout(sublayer(self.norm(x)))
3.4、前向反馈网络层:Feed-Forward network
这部分实质上是两个全连接层映射,第一层是一个线性激活函数,第二层是激活函数是ReLU。
实现很简单:
# Position-wise Feed-Forward Networks
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))))
总览Encoder
通过上面的步骤,我们大致已经基本了解了Encoder的全部原理和主要构成,那么在此回顾整理一下:
根据图上的,将步骤总结如下:
- 字向量
- 位置编码
- 注意力层
- 残差连接(Add)与归一化(Norm)
- 前向反馈网络层(FFN)
- 再一次的残差连接(Add)与归一化(Norm)
4、Decoder
因为输入(“我是一个学生”)在Encoder中进行了编码,这里我们具体讨论Decoder的操作,也就是如何得到输出(“I am a student”)的过程。
Encoder与Decoder的关系可以用下图描述(以机器翻译为例):
Decoder的大框架如下:
可以看到Decoder主要由Masked Multi-Head Self-Attention、Multi-Head Encoder-Decoder Attention、FeedForward Network组成,和 Encoder 一样,上面三个部分的每一个部分,都有一个残差连接,后接一个 Layer Normalization。
4.1、掩码多头注意力层:Mask-Multi-Head-Attention
传统 Seq2Seq 中 Decoder 使用的是 RNN 模型,因此在训练过程中输入t时刻的词,模型无论如何也看不到未来时刻的词,因为循环神经网络是时间驱动的,但是Transformer 抛弃了RNN改为Self-Attention,会发生一个问题:整个ground truth都暴露在Decoder中,这显然不对。
所以这一层目的是忽略某些位置,不计算与其相关的注意力权重。为的是为了防止模型看到要预测的数据,防止泄露。
我们要对 Scaled Scores 进行 Mask,举个例子现在的ground truth 为 "<start> I am fine",当我们输入 “I” 时,模型目前仅知道包括 “I” 在内之前所有字的信息,即 “<start>” 和 “I” 的信息,不应该让其知道 “I” 之后词的信息。道理很简单,我们做预测的时候是按照顺序一个字一个字的预测,怎么能这个字都没预测完,就已经知道后面字的信息了呢?Mask 非常简单,首先生成一个下三角全 0,上三角全为负无穷的矩阵,然后将其与 Scaled Scores 相加即可:
之后为了数据更方便处理,做一次softmax,就能将负无穷变换为0,得到各个字之间的权重。而Multi无非就是并行的对上述步骤多做几次,不再赘述。
这一部分的pytorch实现为:
def subsequent_mask(size):
"""
mask后续的位置,返回[size, size]尺寸下三角Tensor
对角线及其左下角全是1,右上角全是0
"""
attn_shape = (1, size, size)
subsequent_mask = np.triu(np.ones(attn_shape), k=1).astype('uint8')
return torch.from_numpy(subsequent_mask) == 0
4.2、编码-解码 多头注意力层:Encoder-Decoder Multi-head Attention
这一部分就是之前讲过的多头自注意力层,只不过输入不同,这部分的K,V为之前Encoder的输出,Q为Decoder中Masked Multi-head Attention 的输出。
这一部分的pytorch实现为:
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 = size
self.self_attn = self_attn
self.src_attn = src_attn
self.feed_forward = feed_forward
self.sublayer = clones(SublayerConnection(size, dropout), 3)
def forward(self, x, memory, src_mask, tgt_mask):
"将decoder的三个Sublayer串联起来"
m = memory
x = 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)
4.3、输出层:Output
当decoder层全部执行完毕后,怎么把得到的向量映射为我们需要的词呢,只需要在结尾再添加一个全连接层和softmax层,假如我们的词典是1w个词,那最终softmax会输入1w个词的概率,概率值最大的对应的词就是我们最终的结果。
总览Decoder
对应上图,回顾整个Decoder的全部流程如下
Time Step 1
初始输入: 起始符 + Positional Encoding(位置编码)
中间输入:(我是一个学生)Encoder Embedding(Encoder编码后的文本特征)
最终输出:产生预测“I”
Time Step 2
初始输入:起始符 + “I”+ Positonal Encoding
中间输入:(我是一个学生)Encoder Embedding
最终输出:产生预测“I am”
…
…
Time Step n
初始输入:起始符 + “I”+ “am”+ Positonal Encoding
中间输入:(我是一个学生)Encoder Embedding
最终输出:产生预测“I am a student”
以上就是Transformer的Encoder和Decoder的全部内容。
可能读完你可能还一知半解,不要紧,可以看看这篇:Transformer之十万个为什么?
读完你会更加深入理解:什么是自注意力self-attention?Q、K、V是怎么得到的?为什么Q、K、V代表了注意力?Transformer到底在训练什么?Transformer如何实现并行化?
参考文献:
2、Transformer 详解:https://wmathor.com/index.php/archives/1438/
3、【NLP】Transformer:http://mantchs.com/2019/09/26/NLP/Transformer/
4、Transformer 中的 Positional Encoding:https://wmathor.com/index.php/archives/1453/
💖 个人简介:人工智能领域研究生,目前主攻文本生成图像(text to image)方向
📝 CSDN主页:中杯可乐多加冰
🎉 支持我:点赞👍+收藏⭐️+留言📝
- 点赞
- 收藏
- 关注作者
评论(0)