由于最近bert比较火热,并且bert的底层网络依旧使用的是transformer,因此再学习bert之前,有必要认真理解一下Transformer的基本原理以及self-attention的过程,本文参考Jay Alammar的一篇博文,翻译+学习
原文链接:https://jalammar.github.io/illustrated-transformer/
Transformer模型利用注意力机制来提高模型训练速度练。Transformer在特定任务中的性能优于谷歌的基于神经网络的翻译模型。然而,Transformer最大的好处是可以并行计算。
整个模型如果用黑盒的视角来看的话,将一句话输入到Transformer模型中,它的输出将是该句的翻译。
将这个黑盒打开,我们可以看到一组编码器和一组解码器:
整个Encoders是由6个相同的encoder组成,数字6没有什么特殊的含义,并且能够尝试一下其他的组合。同样,Decoders组件是一堆相同数量的decoder组成。
编码器在结构上都是相同的(但它们不共享权重)。每一个被分解成两个子层:
一个self-attention层和一个feed forwoard network,Encoder的输入首先通过一个self-attention层,这个层的作用是在处理一个特定单词的时候,编码器也能照顾到句子中的其他单词(作用类似与RNN的记忆传播功能)。我们将在稍后的文章中更深入地研究self-attention。self-attention的输出被输入到前馈神经网络。每个位置的前馈网络完全相同,但是相互是独立的
解码器也有这两层,但在这两层之间是一个Encoder-Decoder-attention层,它帮助解码器将注意力集中在输入句子的相关部分(类似于注意在seq2seq模型中所做的事情)。
现在我们已经了解了模型的主要组件,让我们开始研究各种向量/张量,以及它们如何在这些组件之间流动,从而将训练好的模型的输入变成输出。
与NLP应用程序中的一般情况一样,我们首先使用Embedding算法将每个输入单词转换为一个向量。
Embedding只作用在最底层的编码器中。所有编码器都会接收到一个embedding vector,每个vector的大小都是512,对于Encoder的最底层,其输入是embedding矩阵,但是对于其他层编码器,其输入是下面的编码器的输出。这个embedding vector的超参数是自定义的,基本上就是我们的训练数据集中最长句子的长度。
在输入序列进行word embedding之后,每个单词都流经编码器的两层。
在这里,我们看到Transformer的一个关键特性,即每个位置上的字在编码器中通过它自己的路径。在self-attention层中,这些路径之间存在依赖性。但是,前馈层没有这些依赖关系,因此在流经前馈层时可以并行执行各种路径。
接下来,我们将把示例转换为一个更短的句子,并查看在编码器的每个子层中发生了什么。
正如我们已经提到的,编码器接收一个向量作为输入。它通过将这些向量传递到一个“self-attention”层,然后进入前馈神经网络来处理这个向量,然后将输出向上发送到下一个编码器。
不要被我用“self-attention”这个词愚弄,好像每个人都应该熟悉这个概念。我个人从来没有遇到过这个概念,直到读了Attention is All You Need。让我们提炼一下它是如何工作的。
假设以下句子是我们要翻译的输入句子:
“这只动物没有过马路,因为它太累了。”
这句话中的“it”是什么意思?它指的是街道还是动物?这对人来说是一个简单的问题,但对算法来说就不那么简单了。
当模型处理单词“it”时,自我注意允许它将“it”与“animal”联系起来。
当模型处理每个单词(输入序列中的每个位置)时,self attention允许它查看输入序列中的其他位置,以寻找有助于对该单词进行更好编码的线索。
如果您熟悉RNN,请考虑如何维护一个隐藏状态,以允许RNN将它已处理的前一个单词/向量的表示与它正在处理的当前单词/向量结合起来。self-attention是Transformer用来将其他相关单词的“理解”转化为我们正在处理的单词的方法。
我们先来看看如何用向量来计算自我关注,然后再来看看它是如何实现的——使用矩阵。
计算自我注意的第一步是从编码器的每个输入向量中创建三个向量(在本例中,是每个单词的嵌入)。因此,对于每个单词,我们创建一个Query向量、一个Key向量和一个Value向量。这些向量是通过将embedding乘以我们在训练过程中训练的三个矩阵得到的。
注意,这些新向量在维数上比嵌入向量小。它们的维数为64,而嵌入和编码器的输入/输出向量的维数为512。它们不必更小,这是一种架构选择,可以使多头注意力(大部分)的计算保持不变。
什么是Query向量、一个Key向量和一个Value向量?
这些抽象概念对于计算和思考注意力很有用。当你开始阅读下面是如何计算注意力的,你就会知道你需要知道的关于这些向量所扮演的角色的几乎所有东西。
self-attention计算的第二步依旧是计算一个分数。假设我们计算本例中第一个单词“Thinking”的self-attention score。我们需要对输入句子中的每个单词进行打分。分数决定了我们在某个位置编码一个单词时,对输入句子的其他部分的关注程度。
分数是通过Query向量与对应单词的Key向量的点积计算出来的。所以如果我们处理位置1的单词的self-attention,第一个分数就是q1和k1的点积。第二个分数是q1和k2的点积。
第三步和第四步是将分数除以8(论文中使用的关键向量的维数的平方根- 64)。这可以使得梯度更加稳定。这里可能有其他可能的值,但这是默认值),然后通过softmax操作传递结果。Softmax将分数标准化,使它们都是正数,加起来等于1。
这个softmax得分决定了每个单词在这个位置的表达量。很明显,这个位置的单词会有最高的softmax得分,但是有时候更加关注与这个词相关的词也是有帮助的。
第五步是将每个Value向量乘以softmax分数(为求和做准备)。这里的直觉是保持我们想要关注的单词的值不变,以及淹没无关单词的值(例如,将它们乘以0.001这样的小数字)。
第六步是加权值向量的求和。这会在这个位置(对于第一个单词)产生self-attention层的输出。
这就是self-attention计算的结论。得到的向量是一个我们可以发送到前馈神经网络的向量。但是在实际的实现中,为了更快的处理,这种计算是以矩阵形式进行的。现在我们来看看这个我们已经在单词层面上看到了计算的直觉。
矩阵中的每一行对应输入句子中的一个单词。我们再次看到嵌入向量(512,或图中4个方框)和q/k/v向量(64,或图中3个方框)的大小差异
最后,由于我们处理的是矩阵,我们可以将步骤2到步骤6浓缩到一个公式中来计算self-attention层的输出。
通过添加“多头”注意机制,进一步细化了self-attention层。这从两个方面提高了注意力层的性能:
如果我们做同样的self-attention计算,就像我们上面概述的,用不同的权重矩阵做8次不同的计算,我们最终得到8个不同的Z矩阵。
这给我们留下了一点挑战。前馈层不需要八个矩阵——它只需要一个矩阵(每个单词对应一个向量)。所以我们需要一种方法把这8个压缩成一个矩阵。
我们怎么做呢?我们把这些矩阵连起来然后用一个额外的权重矩阵把它们乘起来。
这就是多头自我关注的全部内容。让我试着把它们都放在一个图像中这样我们就能在一个地方看到它们
既然我们已经提到了注意力头,让我们回顾一下之前的例子,看看当我们在例句中编码单词“it”时,不同的注意力头部集中在哪里:
然而,如果我们把所有注意力都集中到画面上,事情就很难解释了:
正如我们目前所描述的,模型中缺少的一件事是一种解释输入序列中单词顺序的方法。
为了解决这个问题,Transformer向每个embedding的输入添加一个向量。这些向量具有固定的模式,这有助于它确定每个单词的位置,或序列中不同单词之间的距离。直观的感觉是,将这些值添加到embedding vector中可以提供嵌入向量之间有意义的距离,这些向量一旦被投影到Q/K/V向量中,并且在点积注意期间。
如果我们假设embdding的维数为4,那么实际的位置编码应该是这样的:
这个模式是什么样的?
在下面的图中,每一行对应一个向量的位置编码。所以第一行就是我们要在输入序列中嵌入第一个单词的向量。每行包含512个值—每个值在1到-1之间。我们用颜色标记了它们,所以图案是可见的。
位置编码的公式在论文(3.5节)中有描述。您可以在get_timing_signal_1d()中看到生成位置编码的代码。这不是唯一可能的位置编码方法。然而,它的优势在于能够缩放到看不见的序列长度(例如,如果我们训练过的模型被要求翻译比我们训练集中的任何一个句子都长的句子)。
在继续之前,我们需要提到编码器架构中的一个细节,即每个编码器中的每个子层(self-attention, ffnn)在其周围都有一个剩余连接,然后是一个分层规范化步骤。
如果我们将向量和与自我关注相关的层范数运算形象化,它会是这样的:
这也适用于解码器的子层。如果我们考虑一个由两个堆叠编码器和解码器组成的变压器,它看起来是这样的:
既然我们已经介绍了编码器方面的大部分概念,我们基本上也知道了解码器的组件是如何工作的。但是让我们来看看它们是如何一起工作的。
编码器从处理输入序列开始。将顶层编码器的输出转换为一组注意向量K和v,每一个译码器在其“编码器-译码器注意”层中使用,使译码器在输入序列中适当的位置集中注意力:
下面的步骤重复这个过程,直到到达一个特殊的符号,表明变压器解码器已经完成了它的输出。每一步的输出都会在下一次的时候反馈给底层解码器,解码器就会像编码器一样,将解码结果放大。就像我们对编码器输入所做的那样,我们将位置编码嵌入并添加到这些译码器输入中以指示每个单词的位置。
解码器中的自注意层与编码器中的自注意层的工作方式略有不同:
在解码器中,自注意层只允许注意到输出序列中较早的位置。这是通过在自我注意计算的softmax步骤之前屏蔽未来位置(将它们设置为-inf)来实现的。
编码器-解码器注意层的工作原理与多头自注意层类似,只是它从下面的层创建查询矩阵,并从编码器堆栈的输出中获取键和值矩阵。
解码器堆栈输出一个浮点向量。我们怎么把它变成一个词?这是最后一个线性层的工作,然后是一个Softmax层。
线性层是一个简单的完全连接的神经网络,它将解码器堆栈产生的向量投影到一个更大的称为逻辑向量的向量上。
让我们假设我们的模型知道10,000个从它的训练数据集中学习的惟一英语单词(我们的模型的“输出词汇”)。这将使logits向量宽10000个单元格——每个单元格对应一个唯一单词的分数。这就是我们解释线性层之后的模型输出的方式。
softmax层然后将这些分数转换为概率(都是正数,加起来等于1.0)。选择概率最大的单元格,并生成与之关联的单词作为此时间步骤的输出。
既然我们已经介绍了通过一个经过训练的转换器的整个前向传递过程,那么了解一下训练模型的直观感受将是非常有用的。
在训练过程中,未经训练的模型会经历完全相同的传递过程。但是由于我们是在一个标记的训练数据集上训练它,我们可以将它的输出与实际正确的输出进行比较。
为了理解这一点,我们假设输出词汇表只包含6个单词(“a”、“am”、“i”、“thanks”、“student”和“”(“句子结束”的缩写))。
一旦定义了输出词汇表,就可以使用相同宽度的向量来表示词汇表中的每个单词。这也称为一热编码。例如,我们可以用以下向量表示am:
假设我们正在训练我们的模型。假设这是我们培训阶段的第一步,我们正在用一个简单的例子来培训它——把“谢谢”翻译成“谢谢”。
这意味着,我们希望输出是一个表示“谢谢”的概率分布。但是由于这个模型还没有经过训练,所以现在还不太可能实现。
如何比较两个概率分布?我们只要从另一个中减去一个。要了解更多细节,请查看交叉熵和库尔巴克-莱布尔散度。
但请注意,这是一个过于简化的示例。更实际一点,我们将使用一个句子,而不是一个单词。例如,输入:“je suis etudiant”,预期输出:“我是一名学生”。这真正的意思是,我们想要我们的模型连续输出概率分布,其中:
每个概率分布都由一个宽度为vocab_size的向量表示(在我们的玩具示例中是6,但更实际的情况是一个数字,比如3,000或10,000)
第一个概率分布在与“i”相关联的单元格上具有最高的概率
第二个概率分布在与am相关的单元格中具有最高的概率
以此类推,直到第五次输出分布表示“语句>的结尾”符号,该符号也有一个从10,000个元素词汇表中关联的单元格。
在对模型进行足够长时间的大数据集训练之后,我们希望得到的概率分布是这样的:
现在,因为这个模型每次产生一个输出,我们可以假设这个模型从概率分布中选择概率最大的单词,然后扔掉其余的。这是一种方法(称为贪婪解码)。另一个方法是坚持,说,前两个单词(说,比如“我”和“a”),然后在下一步中,运行模型两次:一次假设第一个输出位置是“我”这个词,而另一个假设第一个输出位置是‘我’这个词,和哪个版本产生更少的错误考虑# 1和# 2保存位置。我们对2号和3号位置重复这个。这种方法称为“beam search”,在我们的例子中,beam_size是2(因为我们在计算位置#1和#2的beam之后比较了结果),top_beam也是2(因为我们保留了两个单词)。这两个超参数都可以进行实验。