一、Transformers整体架构概述
Transformers 是一种基于自注意力机制的架构,最初在2017年由Vaswani等人在论文《Attention Is All You Need》中提出。这种架构彻底改变了自然语言处理(NLP)领域,因为它能够有效地处理序列数据,并且能够捕捉长距离依赖关系。
Transformers整体架构如下:
主要架构由左侧的编码器(Encoder)和右侧的解码器(Decoder)构成。
对于Decoder主要做了一件什么事儿,可参考之前的文章。
本次我们主要来看解码器如何工作。
二、编码器的输出
假设有这样一个任务,我们要将德文翻译为英文,这个就会使用到Encoder-Decoder整个结构。
德文:ich mochte ein bier
英文:i want a beer
为了更好地模拟真实情况(每一句话长度不可能相同),我们需要将德文进行Padding,填充1个P符号;英文目标增加一个End,表示这句话翻译完了,用E符号表示。那么这两句话就变为
德文:ich mochte ein bier P
英文:i want a beer E
这个Encoder结束后,会给我们一个输出向量,这个向量将会在Decoder结构中会形成K和V进行使用。
Encoder的输出可以当作真实序列的高级表达,或者说是对真实序列的高级拆分。
三、解码器如何工作
在Transformer模型中,解码器(Decoder)的主要作用是通过自回归模型生成目标序列。
自回归的含义是这样的:
在预测 “东方红,太阳升。” 的时候: 会优先传入 “东”,然后预测出来“方”, 接着传入“东方”, 然后预测出来“东方红”, 接着传入“东方红”,然后预测出来“,”, 接着再传入“东方红,”,然后预测出来“太” ... 直到最后预测出来整句话:“东方红,太阳升。”。
所以,自回归就是基于已出现的词预测未来的词
接下来我们先来搞明白解码器具体做了一件什么事儿,忽略如何做的细节。
四、解码器输入
4.1 (Shifted)Outputs
解码器的输入基本上与编码器输入类似,如果忘记了可以去查看:Transformers基本原理—Encoder如何进行编码(1)
4.1.1 如何理解(Shifted)Outputs
(Shifted)Outputs表示输入序列词的偏移。
对于Transformer结构来说,我们的1组数据应包含3个:Encoder输入是1个,Decoder输入是1个,目标是1个。
比如:
Encoder输入enc_inputs为德文 “ich mochte ein bier P”
Decoder输入dec_inputs为英文 “S i want a beer”
优化目标输入target_inputs为英文 “i want a beer E”
对于Decoder来说,就是要通过“S”预测目标“i”,通过“S i”预测目标“want”,通过“S i want”预测“a”,通过“S i want a”预测“beer”,通过“S i want a beer”预测“E”。
S表示给序列一个开始预测的提示,E表示给序列一个停止预测的提示。
4.1.2 如何完成(Shifted)Outputs
这里的处理方法与Encoder是一样,但是一定要注意输入的是带偏移的序列。
我们定义1个大词表,它包含了输入所有的词的位置,如下所示:
tgt_vocab = {'P': 0, 'i': 1, 'want': 2, 'a': 3, 'beer': 4, 'S': 5, 'E': 6}
那对应于 英文 “S i want a beer” ,其编码端的输入就是:
dec_inputs: tensor([[5, 1, 2, 3, 4]])
接着我们将这5个词通过nn.Embedding给映射到高维空间,形成词向量,为了方便展示,我们映射到6维空间(一般映射到2的n次方维度,最高512维)。
此处的计算逻辑与Encoder一模一样,不再赘述,直接附结果。
这样我们就完成了输入文本的词嵌入。
4.2 位置嵌入
位置嵌入原理与Encoder中一模一样,此处不再赘述,可参考:Transformers基本原理—Encoder如何进行编码(1)?
这样我们就完成了输入文本的位置嵌入。
4.3 融合词嵌入和位置编码
由于两个向量shape都是一样的,因此直接相加即可,结果如下:
五、带掩码的多头注意力机制
在Decoder的多头注意力机制中,QKV的计算方式与Encoder中一模一样,重点只需要关注掩码的方式即可。
由于Encoder自回归的原因,所以,此处的掩码应遵循预测时不看到未来信息且不关注Padding符号的原则。
5.1 不看未来信息
以“S i want a beer”为例:
在通过“S”预测时,不应该注意后面的“i want a beer”;在通过“S i”预测时,不应该注意后面的“want a beer”。
因此这个注意力矩阵就应该遵循这个规律:将看不到的词的注意力设置为0。
而这个我们恰巧可以通过上三角矩阵来完成,具体代码如下:
def get_attn_subsequent_mask(seq): # 取出传进来的向量的三个维度,分别是batch_size,seq_length,d_model attn_shape = [seq.size(0), seq.size(1), seq.size(1)] # 通过np.triu形成一个上三角矩阵,填充为1 subsequent_mask = np.triu(np.ones(attn_shape), k=1) # 由array转为torch的tensor格式 subsequent_mask = torch.from_numpy(subsequent_mask).byte() return subsequent_mask
5.2 不看Padding的词
这里的处理方式与Encoder一致,主要为了让对Padding的注意力降为0.
以“S i want P P”为例,具体代码如下:
# 需要判断哪些位置是填充的 def get_attn_pad_mask(seq_q, seq_k): # enc_inputs, enc_inputs 告诉后面的句子后面的层 那些是被pad符号填充的 pad的目的是让batch里面的每一行长度一致 # 获取seq_q和seq_k的batch_size和长度 batch_size, len_q = seq_q.size() batch_size, len_k = seq_k.size() # 判断输入的位置编码是否是0并升维度,0表明这个并不是有效的词,只是填充或者标点等,后续计算自注意力时不关注这些词 pad_attn_mask = seq_k.data.eq(0).unsqueeze(1) # batch_size x 1 x len_k(=len_q), one is maskingtensor([[[False, False, False, False, True]]]) True代表为pad符号 # 扩展mask矩阵,使其维度为batch_size x len_q x len_k # (1,1,5) ->> (1,5,5) return pad_attn_mask.expand(batch_size, len_q, len_k)
如果序列为“S i want a beer”为例,则掩码矩阵如下:
5.3 合并的masked矩阵
显然,我们在对序列进行编码的时候,肯定希望这个序列既不能偷看未来信息,也不能关注Padding信息,因此,需要将两个掩码进行融),得到self-attention的mask。
具体做法为:两个mask矩阵直接求和,大于0的地方说明要么是未来信息,要么是Padding信息。
“S i want P P”的合并mask矩阵就如下所示:
“S i want a beer”的合并mask矩阵就如下所示:
这样,我们就得到了最终的mask矩阵,只需要将融合的mask矩阵的在计算注意力时填充为-1e10即可,这样经过Softmax之后得到的注意力就约等于0,不会去关注这部分信息。
5.4 多头注意力机制
将【4.3 融合词嵌入和位置编码】的结果当作X输入Decoder的多头进行计算即可,只需要在计算自注意力的时候根据合并的masked矩阵进行掩码填充,防止关注不需要关注的信息即可。
第1步:QKV的计算
X 为我们输入的词嵌入,假设此处的shape为(2,4)。
WQ、WK、WV矩阵为优化目标,WQ、WK矩阵的维度一定相同,此处均为(4,3);WV矩阵维度可以相同也可以不同,此处为(4,3)。
Q、K、V经过矩阵运算后,shape均为(2,3)。
第2步:计算最终输出
1、Q矩阵与K矩阵的转置相乘,得到词与词之间的注意力分数矩阵,位于(i,j)位置表示第i个词与第j个词之间的注意力分数。
矩阵运算:(2,3) * (3,2) ->> (2,2)
2、归一化注意力分数后,矩阵shape不会改变,只改变内部数值大小。【根据mask的填充就发生在Softmax前,确保不关注未来信息与padding信息】
矩阵大小:(2,2)
3、与V矩阵相乘,得到最终输出Z矩阵。
矩阵运算:(2,2) * (2,3) ->> (2,3)
Z矩阵就是原始输入经过变换后的输出,且整个过程中只需要优化WQ、WK、WV矩阵,计算量大大减少。
这样,我们就完成了Decoder的第一个多头计算。
六、残差连接和层归一化
6.1 残差连接
为了解决深层网络退化问题,残差连接是经常被使用的,在代码内也就一行:
dec_outputs = output + residual dec_outputs:最终输出,判断原始输出好还是经过多头后的输出好 output:经过多头后的输出 residual:原始输入
6.2 层归一化
层归一化只需要将最终输出做一下LayerNorm即可,也是一行代码即可完成。
dec_outputs = nn.LayerNorm(output + residual)
这样,我们就完成了残差连接与层归一化,如图所示:
需要注意的就是本层的输出将会作为Q,传递给下一个多头。
七、第二层多头自注意力机制
这一层的数据来自于两个位置:一个是Encoder的输出,一个是Decoder的第一层多头输出。
7.1 Encoder的输出
在编码端,会产生一个对原始输入的高级表达,假设变量名为:enc_outputs。
这个编码端的输出将会被当作K矩阵和V矩阵使用。
其中,enc_outputs既是K又是V,两者完全一样。
同时,还需要将Encoder的原始输入传递给第二层多头,需要根据这个原始输入形成掩码矩阵,让解码时关注K,以确保解码器在生成每个词时,只利用编码器输出中有效的、相关的信息。
7.2 Decoder第一层多头输出
Decoder第一层多头输出的结果就是对原始解码输入的抽象表达,会被当作Q输入第二层多头内。
7.3 Encoder—Decoder逻辑
当Q给多头后,会通过与Encoder的K进行点乘,得到与要查询(Q)的注意力,判断哪些内容与Q类似,最后再与V计算,得到最终的输出。
一个通俗的理解就是你买了一个变形金刚(transformer是变形金刚)的玩具,拿到的东西会有:各个零件、组装说明书。
Encoder的结果就是提供了各个零件的组装说明书;Decoder就是各个零件的查询。
当我们想要将其组装好时,会在Decoder内查询(Q)每个零件的用法,通过组装说明书(Encoder的结果),寻找与当前零件最相似的说明(寻找高注意力的零件:Q · K^T),最后根据零件用法进行组装(输出最后结果)成一个变形金刚。
当然,这里的QKV的流程是人类赋予的,只是与现实中的数据库查表过程类似,与组装变形金刚类似,让我们更加容易接受的一种说法。
个人对Encoder—Decoder的理解:
Encoder就是对原始输入的高阶抽象表达(低级表达的高级抽象),比如不同语言虽然表面上不一样(各种各样的写法),但是在更高维的向量空间是相似的,存在一套大一统高阶向量,只是我们无法用语言来描述。
就像有些数据在二维不可分,但是落在高维空间就可分了一样,我们无法观察更高维的空间,但它们是存在的。
Decoder就是当我们在高维空间查询(Q)时,就能找到相似(注意力强弱)的高级抽象表达(Q · K^T),然后通过V输出为低级表达,让我们能看得懂。
正如作为三维生物的我们无法理解更高维空间,需要一个中介将高维空间转化为低维空间表达;或者我们作为三维生物,如果二维生物可沟通,我们亦可当作中介将高维空间翻译给低维生物,我们就是这个多头机制,我们就是这个中介。
所以,Encoder—Decoder机制更像是一个大的翻译,大的中介,形成了高维与低维通信的方法。
7.4 Decoder第二层多头的掩码机制
由于Q代表当前要生成的词,而K代表编码器中的词,我们要去零件库寻找与Q相似的零件,那么我们希望的肯定是这个零件是有效的,以确保解码器在生成每个词时,只利用编码器输出中有效的、相关的信息。
因此,我们在形成掩码矩阵时,主要根据Encoder的输入序列来形成掩码矩阵,而不是Decoder输入的序列,当然,Q肯定也有Padding,但这里我们不必过度关注。
所以,在第二层多头掩码时,我们更关注K的Padding掩码,确保解码得到的信息也是有效的。
具体代码如下:
def get_attn_pad_mask(seq_q, seq_k): batch_size, len_q = seq_q.size() batch_size, len_k = seq_k.size() # eq(zero) is PAD token pad_attn_mask = seq_k.data.eq(0).unsqueeze(1) # batch_size x 1 x len_k(=len_q), one is masking return pad_attn_mask.expand(batch_size, len_q, len_k) # batch_size x len_q x len_k
比如,输入序列为:[1,2,3,4,0],最后一位是Padding,那么掩码矩阵如下图示例:
7.5 Decoder第二层多头的输出
至此,我们就将Q进行了解码输出,得到了与Q最为相似的表达。
如图所示步骤:
八、残差连接和层归一化
接着就是再次残差连接与层归一化,与上述步骤完全一样。(为了保持连贯性,还是再写一遍)
8.1 残差连接
为了解决深层网络退化问题,残差连接是经常被使用的,在代码内也就一行:
dec_outputs = output + residual dec_outputs:最终输出,判断原始输出好还是经过多头后的输出好 output:经过多头后的输出 residual:原始输入
8.2 层归一化
层归一化只需要将最终输出做一下LayerNorm即可,也是一行代码即可完成。
dec_outputs = nn.LayerNorm(output + residual)
这样,我们就完成了残差连接与层归一化,如图所示:
九、前馈神经网络
该前馈神经网络接受来自归一化后的输出,shape为(batch_size, seq_length, dmodel)。
batch_size:批处理大小 seq_length:序列长度,比如64个Token dmodel:词嵌入映射维度
在Transformer中,由于我们输入的是序列数据,因此Conv1d就被优先考虑使用了,但是这里需要一定的前置知识(点击可看Conv1d原理),才能懂得Transformer中FNN的工作原理。
9.1 Conv1d连接
由于在Pytorch内Conv1d要求维度优先,因此需要对输入数据进行维度转换。
self.conv1 = nn.Conv1d(in_channels=d_model, out_channels=d_ff, kernel_size=1,bias=False) self.conv2 = nn.Conv1d(in_channels=d_ff, out_channels=d_model, kernel_size=1,bias=False) # 维度优先由6维升维到d_ff维 output = nn.ReLU()(self.conv1(inputs.transpose(1, 2))) # 这里因为一维卷积需要维度再前所以要交换特征位置 # 再降维到d_model维度,一般为词嵌入维度 output = self.conv2(output).transpose(1, 2)
假设输入的数据为(1, 5 , 6),d_ff为2048,那么整个过程的数据shape流动就为:
(1,5,6) ->转置>> (1,6,5)->卷积>> (1,2048,5)->卷积>>(1,6,5)->转置>>(1,5,6)
经过调整提取之后,输出shape仍为(1,5,6)。
至此,前馈神经网络完成。
十、残差连接和层归一化
接着就是再次残差连接与层归一化,与上述步骤完全一样。(为了保持连贯性,还是再写一遍)
10.1 残差连接
为了解决深层网络退化问题,残差连接是经常被使用的,在代码内也就一行:
dec_outputs = output + residual dec_outputs:最终输出,判断原始输出好还是经过多头后的输出好 output:经过多头后的输出 residual:原始输入
10.2 层归一化
层归一化只需要将最终输出做一下LayerNorm即可,也是一行代码即可完成。
dec_outputs = nn.LayerNorm(output + residual)
这样,我们就完成了残差连接与层归一化,如图所示:
十一、线性层及Softmax
在得到归一化的输出之后,经过线性层将输出维度映射为指定类别,比如,你的目标词表有1000个词,那么就会通过softmax预测输出的这1000个词哪个词概率最高,最高的即是目标输出。
代码如下:
self.projection = nn.Linear(d_model, tgt_vocab_size, bias=False) dec_logits = self.projection(dec_outputs).view(-1, dec_logits.size(-1))
可以看到,经过50个epoch,是能够将德文成功翻译为英文的,至此,我们已经将Transformer剖析完成。
图片