正文
在学习Transformer这个模型前对seq2seq架构有个了解时很有必要的
先上图
输入和输出
首先理解模型时第一眼应该理解输入和输出最开始我就非常纠结
有一个Inputs,一个Outputs(shift right)和一个Output Probabilities,首先需要借助这三个输入/输出来初步了解该模型的运行方式。这里以一个英译汉的任务来举例,在训练时Input端的输入就是英语,Outputs(shift right)端输入的就是对应的汉语译文,Output Probabilities输出的就是模型预测的下一个词语(汉语),首先要确定一点,输出的词是一个一个出来的,而不是一起出来的,第n个词的预测需要依赖前n-1个词,如果之前没有接触过seq2seq,这里就会有个疑问,Outputs(shift right)已经将答案给模型了,那这个训练有什么意义呢?这里就涉及到Masked Multi-Head Attention,这个Masked让模型在预测第n个词语的时候,会将第n个词语及之后的词语给盖住,让模型接触不到后面的内容,这样保证模型不去“抄答案”。
那对于Outputs(shift right)为什么论文中要加一个shift right,是因为模型在输出第n个词时候需要前n-1个,那要输出第一个词怎么办呢,这里人为定义了一个<start>,解码器中输入的所有句子都是以<start>开头的。
可以看到,所有直接的数据在输入编/解码器之前都会经过一次Embedding和一次Positional Encoding,对于Embedding,计算机无法直接理解中文或者英文,需要将其编码为向量方便操作,one-hot就是用来做这个的,但是对于大模型来说,动辄几万个词,如果使用one-hot编码,词向量将是几万维的,这是不可接受的,因此有了更加高效的方法,例如Word2Vec、GloVe、FastText等,就拿Word2Vec来说,他能够捕捉单词语义的相似性,例如大树
这个词语的词向量为v1
,树木
这个词的词向量为v2
,蓝色这个词语的词向量为v3
,那么v1
与v2
的点积就比v1
与v3
的点积要大,两个词的词向量点积越大,那这两个词的语义越相近,这是由Word2Vec这个算法所决定的。
Positional Encoding
位置编码,在seq2seq模型中,我们主要运用的是RNN模型作为主干,加上注意力机制来改善效果,RNN模型的输入不论是中文还是英文,不论是编码器还是解码器,都是一个词一个词给进去的,在处理序列数据时,每个时间步的计算依赖于前一个时间步的输出。这种顺序依赖性使得RNN很难利用并行计算来加速训练和推理过程,这就造成了性能瓶颈,同时过长的输入会导致梯度爆炸或者梯度消失。
而Transformer中,完全抛弃了RNN的基本结构,使用自注意力机制来处理输入序列,可以对输入序列中的所有元素同时进行处理,从而大大提高了计算速度和效率。而由于数据是一起被放入模型中,一起被处理,其位置信息就丢失了,seq2seq中的词是一个一个输入进去的,输入的先后顺序就隐藏了位置信息,因此为了保存数据的位置信息,我们就需要Positional Encoding(位置编码)。
在论文中的解决方案是这样的,通过一定的方法(后面会介绍)为句子中的每个词生成一个位置信息,然后将这个位置信息直接加到对应的词向量上面去,过程如下
Positional Encoding是如何生成的呢,论文中的方法为Sine and Cosine Positional Encodings,其思想是,为输入序列中的每个位置生成一个固定的向量,这个向量的构造方式是通过不同频率的正弦和余弦函数来实现的。具体的公式如下:
\[PE_{(pos, 2i)} = \sin\left(\frac{pos}{10000^{2i/d_{\text{model}}}}\right)
\]
\[ PE_{(pos, 2i+1)} = \cos\left(\frac{pos}{10000^{2i/d_{\text{model}}}}\right) $$
其中,`pos` 是位置,`i` 是向量的维度索引,$d_{\text{model}}$ 是模型的embedding维度。这种方法确保了每个位置的编码向量是唯一的,并且不同位置之间的距离可以通过这些编码向量进行区分。这个公式看起来很头大,其实不必过于纠结,知道它是干什么用的就行,这个方法也不是完美方法,在后来的bert中就没用使用这个方法了,说明还是存在一些问题的。
### 编码器
左边的一块叫做编码器,右边的一块叫做解码器,这个取名很形象,借用之前的例子,编码器将英语编码成一种只有计算机才能理解的语言(不是计算机直接理解的计算机语言),再通过解码器解码成目标语言。
#### Multi-Head Attention
首先,我们需要搞清楚什么是自注意力机制,也就是 self-Attention。
所谓注意力机制,我的理解就是将重点放在重要的地方。举个简单的例子,当我们翻译句子时,例如原句是 "A black man",当我们翻译完了 "A black" 时,到 "man" 时,我们脑子里还是有 "A black man" 这个句子,但是此时我们的注意力肯定是放在 "man" 这个单词上,而会忽略 "A black",就是对这三个单词的注意力的重视程度(权重)不一样,"man" 这个单词的权重很高,而其余的很低。
这里可能有人会质疑,翻译不就是一个词一个词的翻译吗?其实翻译也是要看上下文的。你翻译 "A black" 时,就是 "一个黑色的",但是你看到 "man" 时,你不能直接翻译成 "一个黑色的人",而要翻译成 "一个黑人"。所以翻译任务不是简单的逐词翻译,而要联系上下文。在翻译很长的信息时,如果我们对所有的上下文都同样对待,将会陷入信息的海洋中迷失自己。因此,在翻译过程中为每个词添加权重时是很有必要的。
我们在翻译过程中,经验和大脑与生俱来的抽象能力自动帮我们实现了这一注意力的过程,但是如何把这个思想传递给模型呢?这里就是注意力机制要做的事情。
首先我之前提到过使用现代词嵌入技术是可以在向量中反映出词之间的相似程度的,我把每个词向量和同一句话中的其它词向量求内积,就可以得到每个词向量和其它词向量的相似度(可以理解为关联的强弱,因为这个词向量也是模型从海量文本中学习来的,比如大量文本中都出现了红苹果,几乎没有黄苹果,那么显然红和苹果的关联性就远大于黄和苹果的关联性。)暂不考虑其细节,我们似乎可以用这种点积的大小来反映各个词之间的关联程度,既然关联程度不一样,我们可以量化这种不同从而得到一个权重矩阵也就是注意力矩阵。
先看这张图
![](https://oss.xuziao.cn//blogdata/image/ML/2024-07-01-19-21-attention-1.png)
这里的Q、K、V就是经过Encoding之后的词向量,将其记作X如果按照之前的思路,我们现在应该$X \cdot X^T$,然而如果是这样的话显然就限定了数据的分布,而且如果词嵌入向量没有训练好的的话这里会十分影响模型性能,而且好像没有什么可以训练的参数,我们应该在这里加入一点东西让网络复杂一点,因此我们引入了三个独立的线性层,分别记作W1,W2和W3,我们将$X \cdot W_1$记作Q,$X \cdot W_2$记作K,$X \cdot W_3$记作V,这就是自注意力机制中Q、K、V的来历。
那在我们使用$Q \cdot K^T$就能得到输入句子中每个词与其他词之间的相似度的矩阵,为保证梯度稳定性,我们进行一个常规的归一化,通过数学推导,$Q \cdot K^T$的均值为0,方差为$d$(词嵌入向量的维度),归一化之后的式子如下
\]
\frac{Q \cdot K^T}{\sqrt{d}}
\[我们已经得到了处理后的内积,离我们想要的权重矩阵就只有一步之遥了,将其映射到0-1之间的概率分布即可,这里我们可以选用Sigmoid函数或者Softmax函数,论文中作者使用了Softmax函数,那我们就可以得到注意力矩阵了
\]
Softmax(\frac{Q \cdot K^T}{\sqrt{d}})
\[得到了注意力矩阵(权重矩阵),我们将其乘进V矩阵(原始矩阵)中得到加权后的矩阵,也就是加了注意力之后的矩阵
\]
Softmax(\frac{Q \cdot K^T}{\sqrt{d}}) \cdot V
\[这就是论文里的公式,用流程图展示如下
![](https://oss.xuziao.cn//blogdata/image/ML/2024-07-01-19-58-attention-2.png)
以上我们解释了Attention,那么Multi-Head又该如何解释呢?
在向量X(经过Encoding之后的词向量)进入自注意力机制模块前将他”断开“,不同的维度进入不同的自注意力机制模块进行相同的的运算,例如”你“这个词,假设它的词嵌入向量Y是512维的[a0, a1, a2, ···, a510, a511],我们只使用两个头,也就是h=2,那么就将Y截断,[a0, a1, ···, a254, a255]进入第一个自注意力机制模块进行计算,[a256, a257, ···, a510, a511]进入第二个模块经行同样的计算,在各自计算完成后拼接(concat)起来,再通过一个全连接层增加模型的复杂度。事实上,这样做是很有必要的,这样可以训练多个注意力矩阵提取不同维度的信息,增加了模型的复杂度,同时通过拆分维度把计算量分成一小块一小块的了,提高了并行性。
至此,我们走完了Multi-Head Attention这个模块。
#### Add & Norm
对于Add,借鉴了残差结构,就是将Multi-Head Attention输出的结果与向量X加一下,这样可以保证梯度稳定,这又是如何实现的呢?
编码器旁边有个Nx,说明肯定不止一层,这里如果加一下的话输出的结果就从f(x)变成了f(x)+x,这样在求导时就由f'(x)变成了f'(x)+1,可以一定程度上缓解梯度消失的问题。
Multi-Head Attention输出的结果与向量X加完的结果进行一个Layer Normalizaiton
\]
\text{LayerNorm}(x) = \gamma \frac{x - \mu}{\sqrt{\sigma^2 + \epsilon}} + \beta
\[其中,$ \gamma $和$ \beta $是可训练的参数,其中为什么要用LN而不是BN主要是由于每个句子的含有词的长度不同,会导致数值不太稳定和一些其他考虑。注意,BN是对所有的batch的某个feature做归一化,LN是对某个batch的所有feature做归一化。
#### Feed Forward
基于位置的前馈网络(FFN)
过程:
\]
FFN(x) = max(0, xW_1 + b_1)W_2 + b_2
\[两个全连接层中间夹着一个ReLU,为什么需要这个东西呢?首先分析一下输入到FFN的数据的形状应该是`(b, n, d)`的,那它跟CNN中的有什么区别呢?
首先对于CNN来说我们解释b、n、d的含义:
> `b`: 批处理大小,即一次处理的图像数量。例如,如果一次输入处理 32 张图片,则 `b = 32`。
>
> `n`: 特征数量,这通常对应于卷积层和池化层之后的特征图数量。例如,如果最后一层的卷积层有 512 个输出通道,那么 `n = 512`。
>
> `d`: 特征维度,指每个特征向量的长度。这可能是卷积层输出特征图的宽度和高度的乘积。例如,如果最后一层的特征图的大小为 7x7,那么 `d = 7 * 7 = 49`。
那对于Transformer来说,b、n、d的含义又有所区别
> b: Batch size,即一次输入网络的数据样本数。这个维度表示在一次前向传递中处理的序列数量。也就是指的句子数量。
>
> n: Sequence length,序列的长度。这个维度表示每个输入序列中包含的词数量。也就是指句子里面词的数量。
>
> d: Feature dimension,特征的维度。这个维度表示每个标记的嵌入向量的长度。也就是之前提到的d。
对于一个三维向量后面接上一个全连接层,CNN是如何处理的呢?
全连接层会将这个三维数据展平成二维数据 `(b, n*d)`,然后再输入到全连接层中进行分类。例如,假设你有一个批量大小为 32 的输入,经过卷积和池化操作后得到 512 个 7x7 的特征图,那么输入到全连接层的数据形状将会是 `(32, 512, 49)`,在展平后变成 `(32, 512 * 49)`,即 `(32, 25088)`。
那Transformer可不可以效仿这种做法呢?
显然是不行的因为每个句子的长度不可能都是相同的,那么n就是变化的,`n*d`的值就是变化的,由于这种变化的特性,我们无法确定一个`(n*d, out_dim)`的矩阵,做预测的时候,由于每个句子的n不同,也很难完成任务,因此不能完全效仿CNN的做法,只得另谋它路。那文中是如何实现这个全连接层的呢?
作者使用了一个`1*1`的卷积,通过改变通道数的方式来实现全连接层的效果。
先将输入数据的形状从 `(b, n, d)` 转换为 `(b, d, n)`(这一步为了适应conv1函数的输入参数),然后对其使用一个 `1*1` 的卷积层,假设隐藏层大小为 512,将输出通道数设为 512,数据形状将变成 `(b, 512, n)`。然后经过一次 ReLU 激活函数,再用同样的操作,将输出通道数设置为 d,将数据形状变回 `(b, d, n)`,最后调整回原始形状 `(b, n, d)`。
**实现方法可能有点出入,但是思想就是利用1*1的卷积来实现全连接层的作用,在只需要大致理解模型时候不用过多关注这些细节,只需知道这里就是两个全连接层夹着一个ReLU即可**
### 解码器
学完编码器各个组件后会发现编码器这边其实没有什么新东西了,只有唯一一个Masked Multi-Head Attention特别一点了。
#### Masked Multi-Head Attention
对于一个训练好的模型来说,假如我们要做英译汉,最理想的情况是这样的:
1. 我们在 `Inputs` 端输入 "A red apple"。
2. `Outputs(shift right)` 端会自动输入一个 `<start>` 作为起始标记。
3. 解码器依据输入在经过一系列的变化,`Output Probabilities` 输出 "一个"。
4. `Outputs(shift right)` 端会自动输入 `<start> 一个`。
5. 解码器依据输入在经过一系列的变化,`Output Probabilities` 输出 "红色的"。
6. `Outputs(shift right)` 端会自动输入 `<start> 一个 红色的`。
7. 解码器依据输入在经过一系列的变化,`Output Probabilities` 输出 "苹果"。
8. `Outputs(shift right)` 端会自动输入 `<start> 一个 红色的 苹果`。
9. 解码器依据输入在经过一系列的变化,`Output Probabilities` 输出 `<end>`。
10. 翻译完成。
可以看到,结果是一个一个输出的,第n个词的输出需要依赖前n-1个词的输入,训练过程也是一样
1. 我们在 `Inputs` 端输入 "A red apple"。
2. `Outputs(shift right)` 端会自动输入一个 `<start>` 作为起始标记。
3. 解码器依据输入在经过一系列的变化,但是实际情况下,如果训练的不够,`Output Probabilities` 输出结果很可能不是 "一个",而是其他的,我们就用交叉熵损失函数来计算损失值(量化它的输出与标准答案“一个”的差异),根据这个来调整网络的参数。
4. `Outputs(shift right)` 端会自动输入 `<start> 一个`,注意,不是`<start>`加上`Output Probabilities` 输出的不标准的答案,而是标准答案,这个方法叫**Teacher forcing**,试想如果第一个输出就不对,用错的结果继续生成的也只能是错误的结果,最后随着训练的继续只能越错越多,十分不利于模型的收敛,因此我们的输入端是要求输入标准答案的。也正是因为有了这种机制,我们让模型去预测`一个`的同时,也能让模型去预测`红色的`,因为训练过程中的输入不依赖上一步的输出,这也就为并行计算提供了可能。
5. 一直重复3,4步骤直至句子结束
但是有个问题,假如我们现在需要`<start> 一个`,来看模型预测的结果与`红色的`的差距,我们该怎么从标准答案里把`一个`选出来呢,毕竟我们给模型的数据是整个句子`一个 红色的 苹果`。
我们先将整个句子经过Embedding之后传入Masked Multi-Head Attention块,再计算 $ Q \cdot K^{T} $后得到的矩阵做一个遮盖的处理
| | \<start\> | 一个 | 红色的 | 苹果 |
| --------- | --------- | ---- | ------ | ---- |
| \<start\> | 0.36 | -inf | -inf | -inf |
| 一个 | -0.28 | 0.13 | -inf | -inf |
| 红色的 | -0.9 | 0.42 | 1.17 | -inf |
| 苹果 | -0.3 | 0.17 | 0.5 | 0.25 |
这样在生成注意力矩阵时,经过softmax时权重几乎会变为0,就不会考虑后面的内容了。
其余的内容与Multi-Head Attention一模一样。
接下来后面的内容在了解玩编码器后就很好理解了。
数据经过Masked Multi-Head Attention后经过一个Add & Norm,之后的结构可以看到是和编码器一模一样的,唯一的区别就是输入
![](https://oss.xuziao.cn//blogdata/image/ML/2024-07-02-15-11-transformer-decoder.png)
它需要三个输入,分别是`Q、K、V`,其中`K、V`来自编码器最终的输出,`Q`来自刚刚处理完成的数据,经过编码器一模一样的操作之后得到最终输出。
### 输出
将最后得到的数据首先经过一次线性变换,然后Softmax得到输出的概率分布,然后通过词典,输出概率最大的对应的单词作为我们的预测输出。\]