深度学习(9)Transformer-code
Transformer
Transformer:(Self-attention)自注意力机制的序列到序列的模型
一、模型结构概述
如下是Transformer的两个结构示意图:
上图是从一篇英文博客中截取的Transformer的结构简图,下图是原论文中给出的结构简图,更细粒度一些,可以结合着来看。
模型大致分为Encoder
(编码器)和Decoder
(解码器)两个部分,分别对应上图中的左右两部分。
编码器由N个相同的层堆叠在一起(我们后面的实验取N=6),每一层又有两个子层:
- 第一个子层是一个
Multi-Head Attention
(==多头的自注意机制==)- Self-attention多个头类似于cnn中多个卷积核的作用,使用多头注意力,能够从不同角度提取信息,提高信息提取的全面性。
- 第二个子层是一个简单的
Feed Forward
(全连接前馈网络) - 两个子层都添加了一个残差连接+==layer normalization==的操作。
解码器同样是堆叠了N个相同的层,不过和编码器中每层的结构稍有不同。
- 第一个子层是一个
Multi-Head Attention
(==多头的自注意机制==) - 第二个子层是一个简单的
Feed Forward
(全连接前馈网络) - ==Masked Multi-Head Attention==
- 每个子层同样也用了==residual==以及layer normalization。
模型的输入由Input Embedding
和Positional Encoding
(位置编码)两部分组合而成。
模型的输出由Decoder的输出简单的经过softmax得到。
二、模型输入
首先我们来看模型的输入是什么样的,先明确模型输入,后面的模块理解才会更直观。输入部分包含两个模块,Embedding
和Positional Encoding
。
2.1 Embedding层
Embedding层的作用是将某种格式的输入数据,例如文本,转变为模型可以处理的向量表示,来描述原始数据所包含的信息。Embedding
层输出的可以理解为当前时间步的特征,如果是文本任务,这里就可以是Word Embedding
,如果是其他任务,就可以是任何合理方法所提取的特征。
1 | class Embeddings(nn.Module): |
2.2 位置编码
Positional Encodding
位置编码的作用是为模型提供当前时间步的前后出现顺序的信息。因为Transformer不像RNN那样的循环结构有前后不同时间步输入间天然的先后顺序,所有的时间步是同时输入,并行推理的,因此在时间步的特征中融合进位置编码的信息是合理的。位置编码可以有很多选择,可以是固定的,也可以设置成可学习的参数。这里,我们使用固定的位置编码。具体地,使用不同频率的sin和cos函数来进行位置编码,如下所示:
\[
\begin{gathered}
P E_{(p o s, 2 i)}=\sin \left(p o s / 10000^{2 i / d_{\text {model
}}}\right) \\
P E_{(p o s, 2 i+1)}=\cos \left(p o s / 10000^{2 i / d_{\text {model
}}}\right)
\end{gathered}
\]
其中pos代表时间步的下标索引,向量也就是第pos个时间步的位置编码,编码长度同Embedding
层,这里我们设置的是512。上面有两个公式,代表着位置编码向量中的元素,奇数位置和偶数位置使用两个不同的公式。思考:为什么上面的公式可以作为位置编码?我的理解:在上面公式的定义下,
时间步p和时间步p+k的位置编码的内积,即是与p无关,只与k有关的定值(不妨自行证明下试试)。也就是说,任意两个相距k个时间步的位置编码向量的内积都是相同的,这就相当于蕴含了两个时间步之间相对位置关系的信息。此外,每个时间步的位置编码又是唯一的,这两个很好的性质使得上面的公式作为位置编码是有理论保障的。下面是位置编码模块的代码实现:
1 | class PositionalEncoding(nn.Module): |
因此,可以认为,最终模型的输入是若干个时间步对应的embedding,每一个时间步对应一个embedding,可以理解为是当前时间步的一个综合的特征信息,即包含了本身的语义信息,又包含了当前时间步在整个句子中的位置信息。
2.3 Encoder和Decoder都包含输入模块
此外有一个点刚刚接触Transformer的同学可能不太理解,编码器和解码器两个部分都包含输入,且两部分的输入的结构是相同的,只是推理时的用法不同,编码器只推理一次,而解码器是类似RNN那样循环推理,不断生成预测结果的。
怎么理解?假设我们现在做的是一个法语-英语的机器翻译任务,想把Je suis étudiant
翻译为I am a student
。那么我们输入给编码器的就是时间步数为3的embedding数组,编码器只进行一次并行推理,即获得了对于输入的法语句子所提取的若干特征信息。而对于解码器,是循环推理,逐个单词生成结果的。最开始,由于什么都还没预测,我们会将编码器提取的特征,以及一个句子起始符传给解码器,解码器预期会输出一个单词I
。然后有了预测的第一个单词,我们就将I
输入给解码器,会再预测出下一个单词am
,再然后我们将I am
作为输入喂给解码器,以此类推直到预测出句子终止符完成预测。
三、Encoder
3.1 编码器
编码器作用是用于对输入进行特征提取,为解码环节提供有效的语义信息整体来看编码器由N个编码器层简单堆叠而成,因此实现非常简单,代码如下:
1 | # 定义一个clones函数,来更方便的将某个结构复制若干份 |
上面的代码中有一个小细节,就是编码器的输入除了x,也就是embedding以外,还有一个mask
,为了介绍连续性,这里先忽略,后面会讲解。下面我们来看看单个的编码器层都包含什么,如何实现。
3.2 编码器层
每个编码器层由两个子层连接结构组成:第一个子层包括一个多头自注意力层和规范化层以及一个残差连接;第二个子层包括一个前馈全连接层和规范化层以及一个残差连接;如下图所示:
可以看到,两个子层的结构其实是一致的,只是中间核心层的实现不同
我们先定义一个SubLayerConnection类来描述这种结构关系:
1 | class SublayerConnection(nn.Module): |
注:上面的实现中,我对残差的链接方案进行了小小的调整,和原论文有所不同。把x从norm中拿出来,保证永远有一条“高速公路”,这样理论上会收敛的快一些,但我无法确保这样做一定是对的,请一定注意。定义好了SubLayerConnection,我们就可以实现EncoderLayer的结构了.
1 | class EncoderLayer(nn.Module): |
继续往下拆解,我们需要了解 attention层 和 feed_forward层的结构以及如何实现。
3.3 注意力机制 Self-Attention
人类在观察事物时,无法同时仔细观察眼前的一切,只能聚焦到某一个局部。通常我们大脑在简单了解眼前的场景后,能够很快把注意力聚焦到最有价值的局部来仔细观察,从而作出有效判断。或许是基于这样的启发,大家想到了在算法中利用注意力机制。注意力计算:它需要三个指定的输入Q(query),K(key),V(value),然后通过下面公式得到注意力的计算结果。
- 相似度计算 : 与 运算,得到 矩阵,复杂度为
- softmax计算:对每行做softmax,复杂度为 ,则n行的复杂度为
- 加权和: 与 运算,得到 矩阵,复杂度为
故最后self-attention的时间复杂度为 ; 对于受限的self-attention,每个元素仅能和周围 个元素进行交互,即和 个 维向量做内积运算,复杂度为 ,则 个元素的总时间复杂度为
计算流程图如下:
可以这么简单的理解,当前时间步的注意力计算结果,是一个组系数 * 每个时间步的特征向量value的累加,而这个系数,通过当前时间步的query和其他时间步对应的key做内积得到,这个过程相当于用自己的query对别的时间步的key做查询,判断相似度,决定以多大的比例将对应时间步的信息继承过来。下面是注意力模块的实现代码:
1 | def attention(query, key, value, mask=None, dropout=None): |
3.4 多头注意力机制
刚刚介绍了attention机制,在搭建EncoderLayer时候所使用的Attention模块,实际使用的是多头注意力,可以简单理解为多个注意力模块组合在一起。
多头注意力机制的作用:这种结构设计能让每个注意力机制去优化每个词汇的不同特征部分,从而均衡同一种注意力机制可能产生的偏差,让词义拥有来自更多元表达,实验表明可以从而提升模型效果。
对于multi-head attention,假设有 个head,这里 是一个常数,对于每个head,首先需要把三个矩阵分别映射到 维度。这里考虑一种简化情况: 。(对于dot-attention计算方式, 与 可以不同)。
- 输入线性映射的复杂度: 与 运算,忽略常系数,复杂度为 。
- Attention操作复杂度:主要在相似度计算及加权和的开销上, 与 运算,复杂度为
- 输出线性映射的复杂度:concat操作拼起来形成 的矩阵,然后经过输出线性映射,保证输入输出相同,所以是 与 计算,复杂度为
故最后的复杂度为:
举个更形象的例子,bank是银行的意思,如果只有一个注意力模块,那么它大概率会学习去关注类似money、loan贷款这样的词。如果我们使用多个多头机制,那么不同的头就会去关注不同的语义,比如bank还有一种含义是河岸,那么可能有一个头就会去关注类似river这样的词汇,这时多头注意力的价值就体现出来了。下面是多头注意力机制的实现代码:
1 | class MultiHeadedAttention(nn.Module): |
3.5 前馈全连接层
EncoderLayer中另一个核心的子层是 Feed Forward Layer,我们这就介绍一下。在进行了Attention操作之后,encoder和decoder中的每一层都包含了一个全连接前向网络,对每个position的向量分别进行相同的操作,包括两个线性变换和一个ReLU激活输出:
Feed Forward Layer 其实就是简单的由两个前向全连接层组成,核心在于,Attention模块每个时间步的输出都整合了所有时间步的信息,==而Feed Forward Layer每个时间步只是对自己的特征的一个进一步整合,与其他时间步无关。==
1 | class PositionwiseFeedForward(nn.Module): |
到这里Encoder中包含的主要结构就都介绍了,上面的代码中涉及了两个小细节还没有介绍,layer normalization 和 mask,下面来简单讲解一下。
3.6. 规范化层
规范化层的作用:它是所有深层网络模型都需要的标准网络层,因为随着网络层数的增加,通过多层的计算后输出可能开始出现过大或过小的情况,这样可能会导致学习过程出现异常,模型可能收敛非常慢。因此都会在一定层后接规范化层进行数值的规范化,使其特征数值在合理范围内。Transformer中使用的normalization手段是layer norm,实现代码很简单,如下:
1 | class LayerNorm(nn.Module): |
3.7 掩码及其作用
掩码:掩代表遮掩,码就是我们张量中的数值,它的尺寸不定,里面一般只有0和1;代表位置被遮掩或者不被遮掩。掩码的作用: 在transformer中,掩码主要的作用有两个,一个是屏蔽掉无效的padding区域,一个是屏蔽掉来自“未来”的信息。
Encoder中的掩码主要是起到第一个作用,Decoder中的掩码则同时发挥着两种作用。屏蔽掉无效的padding区域:我们训练需要组batch进行,就以机器翻译任务为例,一个batch中不同样本的输入长度很可能是不一样的,此时我们要设置一个最大句子长度,然后对空白区域进行padding填充,而填充的区域无论在Encoder还是Decoder的计算中都是没有意义的,因此需要用mask进行标识,屏蔽掉对应区域的响应。屏蔽掉来自未来的信息:我们已经学习了attention的计算流程,它是会综合所有时间步的计算的,那么在解码的时候,就有可能获取到未来的信息,这是不行的。因此,这种情况也需要我们使用mask进行屏蔽。现在还没介绍到Decoder,如果没完全理解,可以之后再回过头来思考下。mask的构造代码如下:
1 | def subsequent_mask(size): |
以上便是编码器部分的全部内容,有了这部分内容的铺垫,解码器的介绍就会轻松一些。
四、 Decoder
4.1 解码器整体结构
解码器的作用:根据编码器的结果以及上一次预测的结果,输出序列的下一个结果。整体结构上,解码器也是由N个相同层堆叠而成。构造代码如下:
1 | #使用类Decoder来实现解码器 |
4.2 解码器层
每个解码器层由三个子层连接结构组成,第一个子层连接结构包括一个多头自注意力子层和规范化层以及一个残差连接,第二个子层连接结构包括一个多头注意力子层和规范化层以及一个残差连接,第三个子层连接结构包括一个前馈全连接子层和规范化层以及一个残差连接。
解码器层中的各个子模块,如,多头注意力机制,规范化层,前馈全连接都与编码器中的实现相同。
有一个细节需要注意,第一个子层的多头注意力和编码器中完全一致, 第二个子层,它的多头注意力模块中,query来自上一个子层,key 和 value 来自编码器的输出。可以这样理解,就是第二层负责,利用解码器已经预测出的信息作为query,去编码器提取的各种特征中,查找相关信息并融合到当前特征中,来完成预测。
1 | #使用DecoderLayer的类实现解码器层 |
五、模型输出
输出部分就很简单了,每个时间步都过一个 线性层 + softmax层
线性层的作用:通过对上一步的线性变化得到指定维度的输出,也就是转换维度的作用。转换后的维度对应着输出类别的个数,如果是翻译任务,那就对应的是文字字典的大小。
六、模型构建
下面是Transformer总体架构图,回顾一下,再看这张图,是不是每个模块的作用都有了基本的认知。
1 | # Model Architecture |
Transformer Q&A
3,Transformer的Feed Forward层在训练的时候到底在训练什么? - zzzzzzz的回答 - 知乎 https://www.zhihu.com/question/499274875/answer/2250085650
Feed forward network (FFN)的作用?
Transformer在抛弃了 LSTM 结构后,FFN 中的激活函数成为了一个主要的提供非线性变换的单元。
GELU原理?相比RELU的优点?
ReLU会确定性的将输入乘上一个0或者1(当x<0时乘上0,否则乘上1),Dropout则是随机乘上0。而GELU虽然也是将输入乘上0或1,但是输入到底是乘以0还是1,是在取决于输入自身的情况下随机选择的。
什么意思呢?具体来说:
我们将神经元的输入 乘上一个服从伯努利分布的 。而该伯努利分布又是依赖于 的:
其中, ,那么 就是标准正态分布的累积分布函数。这么做的原因是因为神经元的输入 往往遵循正态分布,尤其是深度网络中普遍存在Batch Normalization的情况下。当减小时,的值也会减小,此时被“丢弃”的可能性更高。所以说这是随机依赖于输入的方式。
现在,给出GELU函数的形式:
其中 是上文提到的标准正态分布的累积分布函数。因为这个函数没有解析解,所以要用近似函数来表示。
图像:
导数形式:
GELU和RELU一样,可以解决梯度消失,所以,GELU的优点就是在ReLU上增加随机因素,x越小越容易被mask掉。
==为什么用layernorm不用batchnorm?==
对于RNN来说,sequence的长度是不一致的,所以用很多padding来表示无意义的信息。如果BN会导致有意义的embedding损失信息。所以,BN一般用于CNN,而LN用于RNN。
layernorm是在hidden size的维度进行的,跟batch和seq_len无关。每个hidden state都计算自己的均值和方差,这是因为不同hidden state的量纲不一样。beta和gamma的维度都是(hidden_size,),经过白化的hidden state * beta + gamma得到最后的结果。
LN在BERT中主要起到白化的作用,增强模型稳定性(如果删除则无法收敛)
==Multi-head Self-Attention==
如果是单头注意力,就是每个位置的embedding对应 三个向量,这三个向量分别是embedding点乘 矩阵得来的。每个位置的Q向量去乘上所有位置的K向量,其结果经过softmax变成attention score,以此作为权重对所有V向量做加权求和即可。
用公式表示为:
其中, 为 向量的hidden size。除以 叫做scaled dot product.
多头注意力是怎样的呢?
Transformer中先通过切头(spilt)再分别进行Scaled Dot-Product Attention。
step1:一个768维的hidden向量,被映射成Q,K,V。 然后三个向量分别切分成12(head_num)个小的64维的向量,每一组小向量之间做attention。不妨假设batch_size为32,seqlen为512,隐层维度为768,12个head。
hidden(32 x 512 x 768) -> Q(32 x 512 x 768) -> 32 x 12 x 512 x 64 hidden(32 x 512 x 768) -> K(32 x 512 x 768) -> 32 x 12 x 512 x 64 hidden(32 x 512 x 768) -> V(32 x 512 x 768) -> 32 x 12 x 512 x 64
step2:然后Q和K之间做attention,得到一个32 x 12 x 512 x 512的权重矩阵(时间复杂度O( )),然后根据这个权重矩阵加权V中切分好的向量,得到一个32 x 12 x 512 x 64 的向量,拉平输出为768向量。
32 x 12 x 512 x 64(query_hidden) * 32 x 12 x 64 x 512(key_hidden) -> 32 x 12 x 512 x 512 32 x 12 x 64 x 512(value_hidden) * 32 x 12 x 512 x 512 (权重矩阵) -> 32 x 12 x 512 x 64
然后再还原成 -> 32 x 512 x 768 。简言之是12个头,每个头都是一个64维度,分别去与其他的所有位置的hidden embedding做attention然后再合并还原。
多头机制为什么有效?
类似于CNN中通过多通道机制进行特征选择。Transformer中使用切头(split)的方法,是为了在不增加复杂度( )的前提下享受类似CNN中“不同卷积核”的优势。
==为什么要做scaled dot product?==
当输入信息的维度 d 比较高,会导致 softmax 函数接近饱和区,梯度会比较小。因此,缩放点积模型可以较好地解决这一问题。
==为什么用双线性点积模型(即Q,K两个向量)?==
双线性点积模型使用Q,K两个向量,而不是只用一个Q向量,这样引入非对称性,更具健壮性(Attention对角元素值不一定是最大的,也就是说当前位置对自身的注意力得分不一定最高)。
==Transformer的非线性来自于哪里?==
- FFN的gelu激活函数
- self-attention:注意self-attention是非线性的(因为有相乘和softmax)