Transformer理解
参考博客:
https://jalammar.github.io/illustrated-transformer/
https://github.com/aespresso/a_journey_into_math_of_ml
https://www.tensorflow.org/tutorials/text/transformer#encoder_and_decoder
学了 TextCNN、LSTM后,谈起如今NLP最流行、最热的模型,当然是Transformer、bert,语言模型、命名实体识别、机器翻译等任务,很多都开始用Transformer,或者说是bert预训练模型来做,在机器阅读理解榜单中(SQuAD2.0),机器成绩已经超越人类表现!
这些天看了几个经典博客、视频,最后读了一遍源码,加深了对模型的理解,整体结构也基本上理顺了。
背景:
transformer是谷歌大脑在2017年底发表的论文 attention is all you need中所提出的seq2seq模型。现在已经取得了大范围的应用和扩展,而BERT就是从 transformer中衍生出来的预训练语言模型。
其中主要的应用的方式是2步——先进行预训练语言模型——然后把预训练的模型适配给下游任务(分类、生成、标记等)。其中:预训练模型非常重要,预训练的模型的性能直接影响下游任务。
原理
先上图吧:
整个Transformer结构分为:Encoding(编码器)和Decoding(解码器)两大部分;而编码器又有N个编码器层,解码器也有N个解码器层;
那么先把一个编码器层搞清楚,串联N个就能理解了;理解好了编码器,解码器就快了。
一个编码器层包含五个组成部分:
$\begin{cases} 1. Positional Encoding\\2.Multi-Head Attention\\3. Add\&Norm\\4.FeedForward\\5.Add\&Norm\end{cases}$
看似很复杂,一个一个来就不怕:
1.Positional Encoding
首先得清楚为何Transformer用位置嵌入?在LSTM中我们用一个词一个词灌进去,从而学习了时序关系,但是transformer模型没有循环神经网络的迭代操作, 它是将所有词一起喂进去,并行操作的。所以我们必须提供每个字的位置信息给transformer, 才能识别出语言中的顺序关系。
位置嵌入的定义其实就是作者自定义的一个函数,来做到区别每个词在句子中的位置,仅此而已。
定义 :
位置嵌入的维度为$[max \ sequence \ length, \ embedding \ dimension]$, 嵌入的维度同词向量的维度, $max \ sequence \ length$属于超参数, 指的是限定的最大单个句长.
在transformer模型中,一般以字为单位,不需要分词了, 首先我们要初始化字向量为$[vocab \ size, \ embedding \ dimension]$, $vocab \ size$为总共的字库数量, $embedding \ dimension$为字向量的维度, 也是每个字的向量。(这里的理解和之前的TextCNN LSTM中的$Embedding$一致!)
在这里论文中使用了$sine$和$cosine$函数的线性变换来提供给模型位置信息:
$$PE_{(pos,2i)} = sin(pos / 10000^{2i/d_{\text{model}}}) \quad PE_{(pos,2i+1)} = cos(pos / 10000^{2i/d_{\text{model}}})$$
上式中$pos$指的是句中字的位置, 取值范围是$[0, \ max \ sequence \ length)$, $i$指的是词向量的维度, 取值范围是$[0, \ embedding \ dimension)$, 上面有$sin$和$cos$一组公式, 也就是对应着$embedding \ dimension$维度的一组奇数和偶数的序号的维度, 例如$0, 1$一组, $2, 3$一组, 分别用上面的$sin$和$cos$函数做处理, 从而产生不同的周期性变化。
看源码(就是对应的上面的公式):
1 | def get_angles(pos, i, d_model): |
输出这个周期性的矩阵图:
1 | pos_encoding = positional_encoding(50, 512) |
2.Muti-Head Attention
在前面的基础上,我们已经有了词向量矩阵和位置嵌入了,例如有一些样本。维度是:$[batch size, \ sequence \ length]$,再在字典中找到对应的字向量,变为:$[batch size, \ sequence \ length, \ embedding \ dimension]$,同时我们再加上位置嵌入(位置嵌入维度一致,直接元素相加即可),相加后的维度还是$[batch size, \ sequence \ length, \ embedding \ dimension]$
要了解Muti-Head Attention,首先要知道self-attention,Multi无非是在其基础上并行了多个头而已。
2.1 self Attention
Attention机制的创新点就在于这里,为了学到多重含义的表示,我们想让一个字的向量包含这句话所有字的一个相关程度(后面还会说),那么首先初始化三个权重矩阵$W_Q、W_K、W_V$,然后将$X_{embedding}$与这三个权重矩阵相乘,得到$Q、K、V$
也就是:
$$\begin{cases}Q=X_{embedding} W_Q \ K=X_{embedding} W_K \ V=X_{embedding} W_V\end{cases}$$
下面用图来理解更舒适!
得到了$Q、K、V$之后 那么我们用$q\times k$也就是对于一个字(中文是字,英文是词)它的score包含所有的自身$q$和别的字的$k$相乘,当然这里相乘肯定是和$k$的转置相乘哈!从而就可以得到一个注意力矩阵!(点积:两个向量越相似,点积则越大!)这里你会观察到,对角线上也就是每个字,自己对自己的相关程度,一行就是一个字中所有字与它的相关性。然后再对每一行做归一化($softmax$),这样就保证对一个字来说,所有字与它的相关程度概率和为1!
然后论文中又除了一个$\sqrt{d_k}$,是为了把注意力矩阵变成标准正态分布,使得softmax归一化后的结果更加稳定,以便于反向传播时候获取平衡的梯度,最后将注意力矩阵给$V$加权,为啥要给$V$加权,其实就是因为注意力矩阵维度是$[batch \ size, \ sequence \ length, \ sequence \ length]$,而$V$维度是$[batch \ size ,\ sequence \ length, \ embedding \ dimension]$为了使得维度保持不变,则乘以$V$后为: $[batch \ size ,\ sequence \ length, \ embedding \ dimension]$,从而再次和$X_{embedding}$的维度相同了,是不是很妙!
$${Attention(Q, K, V) = softmax_k(\frac{QK^T}{\sqrt{d_k}}) V} $$
下图是论文中对$d_k$的解释:
源码分析:
包含$Q,K$相乘,注意到相乘的时候有个转置操作,以及后面对$V$加权,和我们给出的公式其实是一致的,这里的mask语句后面会讲。
1 | def scaled_dot_product_attention(q, k, v, mask): |
2.2 Multi-Head Attention
那么对于多头其实是一样的,只是在开始的时候将$embedding \ dimension$分割成了$h$份(头的个数),这里每个头权重都不同,多头训练效果理论上肯定更好(反正想着就是这样,至于为什么,也不好解释)
,最后再把它及联拼接。大佬的图展现的很好:
源码分析:
1 | class MultiHeadAttention(tf.keras.layers.Layer): |
2.3 Mask
有两个mask:$\begin{cases}1.padding \ mask \\2.lookahead mask(翻译任务中预测文本时(decoder部分))\end{cases}$
2.3.1 padding mask
当我们在确定$Max \ length$时候,对于不够长的句子肯定要做$padding$但是对于为0的那一部分在$softmax$时候会变为1:
回顾$softmax$函数:
$$
\sigma (\mathbf {z} )_{i}= \frac {e^{z_i} } {\sum _{j=1} ^ {K} e^ {z_j} }
$$
$$ $$
$e^0$是1, 是有值的, 这样的话 $softmax$ 中被 $padding$ 的部分就参与了运算, 就等于是让无效的部分参与了运算,这样肯定不对, 这时就需要做一个$mask$让这些无效区域不参与运算, 我们一般给无效区域加一个很大的负数的偏置, 也就是:
$$z_{illegal}=z_{illegal}+bias_{illegal}$$
$$bias_{illegal}\to-\infty$$
$$e^{z_{illegal}}\to0$$
源码分析:
1 | def create_padding_mask(seq): |
输出效果(把原本为0的地方变成了1):
1 | x = tf.constant([[7, 6, 0, 0, 1], [1, 2, 3, 0, 0], [0, 0, 0, 4, 5]]) |
最后再在一句代码中体现,如果mask不是None,则在此处乘以负无穷:
1 | def scaled_dot_product_attention(q, k, v, mask): |
2.3.2 Lookahead mask
这里我也不知道咋翻译(前瞻遮罩?)这个mask操作就是在翻译的时候,要预测第三个词,将仅使用第一个和第二个词,与此类似,预测第四个词,仅使用第一个,第二个和第三个词,依此类推。
源码分析:
1 | def create_look_ahead_mask(size): |
输出结果:
(每一行是一个时刻,第一个时刻,遮盖了后两个(遮盖操作后也就是变为了1)),用第一个字预测第二个字;第二个时刻,遮盖了第三个字,用第1、2个字预测第三个字;第三个时刻,则是用前三个字去预测结束符……当然这里其实每次预测的时候解码器还加上了编码器输出的embedding向量,这一点后面会详细说!
1 | x = tf.random.uniform((1, 3)) |
3.Add&Norm
3.1残差连接
归纳:模型太深,需要避免梯度消失
我们在上一步得到了经过注意力矩阵加权之后的$V$, 也就是$Attention(Q, \ K, \ V)$, 我们对它进行一下转置, 使其和$X_{embedding}$的维度一致, 也就是$[batch \ size, \ sequence \ length, \ embedding \ dimension]$, 然后把他们加起来做残差连接, 直接进行元素相加, 因为他们的维度一致:
$$X_{embedding} + Attention(Q, \ K, \ V)$$
在之后的运算里, 每经过一个模块的运算, 都要把运算之前的值和运算之后的值相加, 从而得到残差连接, 训练的时候可以使梯度直接走捷径反传到最初始层:
$$X + SubLayer(X) $$
3.2 $LayerNorm$:
归纳:加速收敛
$Layer Normalization$的作用是把神经网络中隐藏层归一为标准正态分布, 也就是$i.i.d$独立同分布, 以起到加快训练速度, 加速收敛的作用:
$$ \mu_i=\frac {1} {m}\sum^{m} _ { i=1 } x _ {ij} $$
上式中以矩阵的行$(row)$为单位求均值;
$$\sigma^{2} _ { j } =\frac { 1 } { m } \sum^ { m } _ { i=1 } (x _ { ij } -\mu_ { j } )^ { 2 } $$
上式中以矩阵的行$(row)$为单位求方差;
$$LayerNorm(x)=\alpha \odot \frac{x_{ij}-\mu_{i}}
{\sqrt{\sigma^{2}_{i}+\epsilon}} + \beta \tag{eq.6}$$
然后用每一行的每一个元素减去这行的均值, 再除以这行的标准差, 从而得到归一化后的数值, $\epsilon$是为了防止除$0$;
之后引入两个可训练参数$\alpha, \ \beta$来弥补归一化的过程中损失掉的信息, 注意$\odot$表示元素相乘而不是点积, 我们一般初始化$\alpha$为全$1$, 而$\beta$为全$0$.
源码分析:
在源码中就是一个加号代表了一切!out1 = self.layernorm1(x + attn_output)
1 | class EncoderLayer(tf.keras.layers.Layer): |
4.FeedForward
前向传播层,就是一个全连接,dff设置了内部全连接层数,和以往的没什么区别,不多说了。
源码分析:
1 | def point_wise_feed_forward_network(d_model, dff): # dff内部层维数 |
Transformer encoder整体结构
1). 字向量与位置编码:
$$X = EmbeddingLookup(X) + PositionalEncoding $$
$$X \in \mathbb{R}^{batch \ size \ * \ seq. \ len. \ * \ embed. \ dim.} $$
2). 自注意力机制:
$$Q = Linear(X) = XW_{Q}$$
$$K = Linear(X) = XW_{K} $$
$$V = Linear(X) = XW_{V}$$
$$X_{attention} = SelfAttention(Q, \ K, \ V) $$
3). 残差连接与$Layer \ Normalization$
$$X_{attention} = X + X_{attention} $$
$$X_{attention} = LayerNorm(X_{attention}) $$
4). $FeedForward$, 其实就是两层线性映射并用激活函数激活, 比如说$ReLU$:
$$X_{hidden} = Activate(Linear(Linear(X_{attention})))$$
5). 重复3).:
$$X_{hidden} = X_{attention} + X_{hidden}$$
$$X_{hidden} = LayerNorm(X_{hidden})$$
$$X_{hidden} \in \mathbb{R}^{batch \ size \ * \ seq. \ len. \ * \ embed. \ dim.} $$
Transformer decoder部分
还是这个图最直观,注意观察编码器和解码器的差异,最下面的一块其实差不多,只是解码器加了一个Mask,这个mask当然是Lookahead mask,因为翻译任务里面,我们是在解码器中输入一个词,解码器拿着编码器最终隐藏层输出的向量来预测下一个词,所以需要去遮盖后面的词:
**解码器预测过程:**
第1时刻——输入'I,解码器拿着编码器输出的embedding向量去预测'am'。
第2时刻——输入'am',解码器拿着embedding向量 + 'I' 去预测 'a'
第3时刻——输入'a',编码器拿着embedding向量 + 'I' + 'a' 去预测 'student'...
源码分析:
1 | class DecoderLayer(tf.keras.layers.Layer): |
这里的self.mha1和self.mha2就是图中解码器层定义的两个Multi-Head Attention,这里上面的Multi-Head Attention也就是self.mha2,它是只做了padding mask的,这个和编码器的一致,但是下面的这个Multi-Head Attention(self.mha1)就不一样了,它的mask自然是Lookahead mask,用于遮盖后面的词,现在基本上前后就可以串起来了!
注意看两个的输入:
1 | ```self.mha2(enc_output, enc_output, out1, padding_mask) |
对于下面的Multi-Head Attentionself.mha1,它和编码器层那里的代码一致,都是接收三个相同的x(也就是q、k、v)
但是对于上面的Multi-Head Attentionself.mha2,它的输入是不同的,它是用的编码器的输出和解码器下面的Multi-Head Attentionself.mha1的输出out1来共同输出out3,之前不理解为什么编码器那里要写三个x,写一个不也可以吗?反正都是一样,现在明白了,是为了和解码器这里的输入做到格式一致!!!
编码器层、解码器层都理解完了,最后编码器串联N个,解码器串联N个就OK啦!
一个图说明了一切:
之前不理解为什么画了这么多的线,一根线不行吗?还真不行,因为每次的解码器在预测的时候需要拿编码器输出的embedding向量呀!!
源码分析:
编码器和解码器无非就是做了N个编码器层和解码器层,然后这里的training代表的是否训练,因为训练的时候和预测的时候不一样。
编码器:
1 | class Encoder(tf.keras.layers.Layer): |
解码器:
1 | class Decoder(tf.keras.layers.Layer): |

