在这篇文章中,我们将看看“Transformer”——一个利用注意力机制来提高模型训练速度的模型。Transformer在特定任务中优于谷歌神经机器翻译模型。然而,最大的好处来自于Transformer如何使自己适合并行化。事实上,Google Cloud推荐使用The Transformer作为参考模型来使用他们的Cloud TPU产品。让我们试着把这个模型拆开看看它是如何运作的。
让我们先把模型看作一个单一的黑盒子。在机器翻译应用程序中,它将以一种语言的句子作为输入,并以另一种语言的翻译结果作为输出。
打开这个黑盒,我们看到一个编码组件,一个解码组件,以及它们之间的连接。
编码组件是一堆编码器(上图有六个编码器堆叠在一起——这里的6没有神奇之处,可以尝试其他的排列方式)。解码组件是一堆相同数量的解码器。
编码器在结构上都是相同的(但它们不共享权重)。每一层又分为两个子层:
编码器的输入首先通过一个自注意力层——这个层帮助编码器在编码一个特定的单词时查看输入句子中的其他单词。我们将在后面的文章中更深入地研究自注意力层。
自注意层的输出被馈送到前馈神经网络。完全相同的前馈网络独立应用于每个位置。
解码器有这两个层,但在它们之间是一个注意力层,它帮助解码器专注于输入句子的相关部分(类似于注意力在seq2seq模型中的作用)。
现在我们已经看到了模型的主要组成部分,让我们开始看看各种向量/张量,以及它们如何在这些组成部分之间流动,将训练模型的输入转化为输出。
与一般的NLP应用程序一样,我们首先使用嵌入算法将每个输入词转换为向量。
每个单词被嵌入到一个大小为512的向量中。我们用这些简单的方框来表示这些向量。
嵌入只发生在最底部的编码器中。所有编码器的共同抽象是,它们接收一个大小为512的向量列表——在底部的编码器中,这将是单词嵌入,但在其他编码器中,它将是编码器直接在下面的输出。这个列表的大小是我们可以设置的超参数——基本上它是我们训练数据集中最长句子的长度。
在我们的输入序列中嵌入单词后,每个单词都流经编码器的两层。
这里我们开始看到Transformer的一个关键属性,即每个位置上的单词在编码器中流经自己的路径。在自关注层中,这些路径之间存在依赖关系。但是,前馈层没有这些依赖关系,因此,在流经前馈层时,各种路径可以并行执行。
接下来,我们将切换到一个更短的句子,我们将看看在编码器的每个子层中发生了什么。
正如我们已经提到的,编码器接收一个向量列表作为输入。它通过将这些向量传递到“自关注”层来处理这个列表,然后进入前馈神经网络,然后将输出向上发送到下一个编码器。
每个位置的单词都经过一个自我注意的过程。然后,它们分别通过一个前馈神经网络——完全相同的网络,每个向量分别流过它
不要被我抛出的“自我关注”这个词所愚弄,好像它是每个人都应该熟悉的概念一样。在阅读《注意力就是你所需要的一切》这篇论文之前,我个人从未接触过这个概念。让我们提炼一下它是如何工作的。
假设下面的句子是我们想要翻译的输入句子:
The animal didn't cross the street because it was too tired
这个句子中的it
指的是什么?它指的是街道还是动物?这对人类来说是个简单的问题,但对算法来说就没那么简单了。
当模型处理it
这个词时,自我注意允许它将it
与animal
联系起来。
当模型处理每个单词(输入序列中的每个位置)时,自我注意允许它查看输入序列中的其他位置,以寻找有助于对该单词进行更好编码的线索。
如果你熟悉RNN,想想维持一个隐藏状态如何允许RNN将它之前处理过的单词/向量的表示与当前处理的单词/向量结合起来。自我关注是Transformer用来将对其他相关单词的“理解”转化为我们当前正在处理的单词的方法
当我们在编码器#5(堆栈中的顶部编码器)中编码单词“it”时,部分注意力机制集中在“动物”上,并将其部分表示放入“it”的编码中。
一定要查看Tensor2Tensor笔记本,在那里您可以加载Transformer模型,并使用这个交互式可视化来检查它。
让我们首先看看如何使用向量来计算自我注意力,然后继续看看它是如何实际实现的——使用矩阵。
计算自我注意的 第一步 是从每个编码器的输入向量(在本例中是每个单词的嵌入)中创建三个向量。因此,对于每个单词,我们创建一个查询向量、一个键向量和一个值向量。这些向量是通过将嵌入乘以我们在训练过程中训练的三个矩阵来创建的。
注意,这些新向量的维数比嵌入向量小。它们的维数为64,而嵌入向量和编码器输入/输出向量的维数为512。它们不必更小,这是一种架构选择,可以使多头注意力的计算(大部分)保持不变
将x1乘以WQ权重矩阵得到q1,即与该单词相关的“query”向量。我们最终为输入句子中的每个单词创建了一个“query”、一个“key”和一个“value”投影。
什么是query
、key
和value
向量?
它们是对计算和思考注意力很有用的抽象概念。一旦你继续阅读下面的注意力是如何计算的,你就会知道所有你需要知道的关于这些向量所扮演的角色。
计算自我关注的 第二步 是计算分数。假设我们在计算这个例子中的第一个单词“Thinking”的自我注意。我们需要对输入句子中的每个单词与这个单词进行评分。分数决定了当我们在某个位置编码单词时,对输入句子的其他部分的关注程度。
分数是通过取 query
向量 与我们要评分的单词的 key
向量 的点积来计算的。所以如果我们处理位置#1的单词的自我注意,第一个分数就是q1
和k1
的点积。第二个分数是q1
和k2
的点积。
第三步 和 第四步 是将分数除以8(论文中使用的关键向量维度的平方根- 64)。这将导致更稳定的梯度。这里可能有其他可能的值,但这是默认值),然后通过softmax操作传递结果。Softmax将分数归一化,所以它们都是正的,加起来等于1。
这个softmax分数决定了每个单词在这个位置表示多少。很明显,这个位置的单词softmax得分最高,但有时注意与当前单词相关的另一个单词是有用的。
第五步 是将每个值向量乘以softmax分数(准备将它们相加)。这里的直觉是保持我们想要关注的单词的值不变,并淹没不相关的单词(例如,通过将它们乘以像0.001这样的小数字)。
第六步 是对加权值向量求和。这就产生了自注意层在这个位置的输出(对于第一个单词)。
自我关注计算到此结束。得到的向量是我们可以发送给前馈神经网络的向量。然而,在实际实现中,为了更快地处理,这种计算是以矩阵形式完成的。现在我们来看一下,我们已经看到了对单词level的直观计算。
第一步 是计算Query、Key和Value矩阵。我们通过将我们的嵌入打包到矩阵X中,并将其乘以我们训练的权重矩阵(WQ, WK, WV)来做到这一点。
X矩阵中的每一行对应于输入句子中的一个单词。我们再次看到嵌入向量的大小(512,即图中的4个方框)和q/k/v向量的大小(64,即图中的3个方框)的差异
最后,由于我们处理的是矩阵,我们可以将第二步到第六步浓缩成一个公式来计算自关注层的输出。
矩阵形式的自关注计算
本文通过增加一种称为“多头”注意的机制进一步完善了自注意层。这从两个方面提高了注意力层的性能:
1、它扩展了模型关注不同位置的能力。是的,在上面的例子中,z1包含了一点其他编码,但它可能被实际的单词本身所支配。如果我们在翻译一个句子,比如“The animal didn’t cross the street because it was too tired”,知道“it”指的是哪个单词会很有用。
2、它为注意层提供了多个“表示子空间”。正如我们接下来将看到的,对于多头注意,我们不仅有一个,而且有多组查询/键/值权重矩阵(Transformer使用8个注意头,因此我们最终为每个编码器/解码器使用8组注意头)。每个集合都是随机初始化的。然后,在训练之后,每个集合用于将输入嵌入(或来自较低编码器/解码器的向量)投影到不同的表示子空间中。
通过多头关注,我们为每个头部保持单独的Q/K/V权重矩阵,从而产生不同的Q/K/V矩阵。和之前一样,我们将 X X X乘以 W Q / W K / W V W^Q/W^K/W^V WQ/WK/WV矩阵,得到 Q / K / V Q/K/V Q/K/V矩阵。
如果我们做同样的自我关注计算,只是8次不同的权重矩阵,我们最终会得到8个不同的Z矩阵
这给我们留下了一点挑战。前馈层不需要8个矩阵——它只需要一个矩阵(每个单词一个向量)。所以我们需要一种方法把这八个矩阵压缩成一个矩阵。
我们怎么做呢?我们连接矩阵,然后将它们乘以一个附加的权重矩阵 W o W^o Wo。
这就是多头自我关注的全部内容。我意识到这是相当多的矩阵。让我试着把它们放在一个图像中,这样我们就可以在一个地方看到它们
既然我们已经触及了注意头,让我们回顾一下之前的例子,看看当我们在例句中对单词“it”进行编码时,不同的注意头集中在哪里:
当我们对“it”这个词进行编码时,一个注意力头主要关注“the animal”,而另一个注意力头主要关注“tired”——从某种意义上说,模型对“it”这个词的表征融合了“animal”和“tired”的一些表征。
然而,如果我们把所有的注意力都加到画面上,事情就更难解释了:
到目前为止,我们所描述的模型中缺少的一件事是解释输入序列中单词顺序的方法。
为了解决这个问题,转换器向每个输入嵌入添加一个向量。这些向量遵循模型学习的特定模式,这有助于它确定每个单词的位置,或者序列中不同单词之间的距离。这里的直觉是,将这些值添加到嵌入中,一旦它们被投影到Q/K/V向量中,并且在点积注意期间,嵌入向量之间就会有意义的距离。
为了让模型了解单词的顺序,我们添加了位置编码向量——其值遵循特定的模式。
如果我们假设嵌入的维度为4,那么实际的位置编码将是这样的:
一个位置编码的真实例子,玩具嵌入大小为4
这种模式会是什么样子呢?
在下面的图中,每一行对应一个向量的位置编码。所以第一行就是我们要添加到输入序列中第一个单词的嵌入的向量。每行包含512个值,每个值在1到-1之间。我们用颜色标记了它们,这样图案就清晰可见了。
20个单词(行)的位置编码的真实示例,嵌入大小为512(列)。你可以看到它似乎从中心一分为二。这是因为左半部分的值是由一个函数(使用正弦)生成的,右半部分是由另一个函数(使用余弦)生成的。然后将它们连接起来形成每个位置编码向量。
本文描述了位置编码的公式(第3.5节)。您可以在get_timing_signal_1d()中看到生成位置编码的代码。这不是位置编码的唯一可能方法。然而,它的优点是能够扩展到看不见的序列长度(例如,如果我们训练的模型被要求翻译一个比我们训练集中的任何句子都长的句子)。
2020年7月更新:上面显示的位置编码来自Transformer的Tensor2Tensor实现。论文中展示的方法略有不同,因为它不是直接连接,而是将两个信号交织在一起。下图显示了它的样子。下面是生成它的代码:
在继续之前,我们需要提到的编码器架构中的一个细节是,每个编码器中的每个子层(自关注,ffnn)周围都有一个残余连接,然后是层规范化步骤。
如果我们要可视化向量和与自我关注相关的层范数操作,它看起来是这样的:
这也适用于解码器的子层。如果我们考虑一个由2个堆叠的编码器和解码器组成的Transformer,它看起来是这样的:
现在我们已经涵盖了编码器方面的大部分概念,我们基本上也知道了解码器的组件是如何工作的。让我们来看看它们是如何协同工作的。
编码器首先处理输入序列。然后,顶部编码器的输出被转换成一组注意力向量K和v。这些将被每个解码器在其“编码器-解码器注意力”层中使用,这有助于解码器专注于输入序列中的适当位置:
完成编码阶段后,我们开始解码阶段。解码阶段的每一步都从输出序列中输出一个元素(在本例中是英语翻译句子)。
以下步骤重复该过程,直到达到一个特殊符号,表明变压器解码器已完成其输出。每一步的输出在下一个时间步中被馈送到底部的解码器,解码器像编码器一样将它们的解码结果气泡化。就像我们对编码器输入所做的那样,我们在这些解码器输入中嵌入并添加位置编码来指示每个单词的位置。
(动图,建议在原文中看)
解码器中的自注意层与编码器中的自注意层的操作方式略有不同:
在解码器中,自注意层只允许关注输出序列中较早的位置。这是通过在自关注计算的softmax步骤之前屏蔽未来位置(将它们设置为-inf)来实现的。
“Encoder-Decoder Attention”层就像多头自注意层一样工作,除了它从它下面的层创建查询矩阵,并从编码器堆栈的输出中获取键和值矩阵。
解码器堆栈输出一个浮点数向量。我们怎么把它变成一个词?这是最后一个线性层的工作,然后是一个Softmax层。
线性层是一个简单的全连接神经网络,它将解码器堆栈产生的向量投影成一个更大的向量,称为logits向量。
让我们假设我们的模型知道从训练数据集中学习到的10,000个唯一的英语单词(我们模型的“输出词汇”)。这将使logits向量宽为10,000个单元格——每个单元格对应一个唯一单词的分数。这就是我们如何解释线性层之后的模型输出。
然后softmax层将这些分数转换为概率(所有分数都是正的,加起来都是1.0)。选择概率最高的单元格,并生成与之关联的单词作为此时间步骤的输出。
这个图从底部开始,产生的矢量作为解码器堆栈的输出。然后将其转换为输出字。
既然我们已经介绍了经过训练的Transformer的整个前向传递过程,那么了解一下训练模型的直觉将会很有用。
在训练期间,未经训练的模型将经历完全相同的向前传递。但由于我们是在一个标记的训练数据集上训练它,我们可以将它的输出与实际的正确输出进行比较。
为了可视化这一点,假设我们的输出词汇表只包含六个单词(“a”、“am”、“i”、“thanks”、“student”和“< eos >”(“end of sentence”的缩写))。我们的模型的输出词汇表是在我们开始训练之前的预处理阶段创建的。
一旦定义了输出词汇表,就可以使用相同宽度的向量来表示词汇表中的每个单词。这也被称为单热编码。例如,我们可以用下面的向量表示单词“am”:示例:输出词汇表的单次编码
在回顾之后,让我们讨论一下模型的损失函数——我们在训练阶段优化的指标,以得到一个训练好的、希望非常准确的模型。
假设我们正在训练我们的模型。假设这是我们训练阶段的第一步,我们在一个简单的例子上训练它——把“merci”翻译成“thanks”。
这意味着,我们希望输出是一个概率分布,表示单词“thanks”。但是由于这个模型还没有经过训练,所以这种情况还不太可能发生。
由于模型的参数(权重)都是随机初始化的,因此(未经训练的)模型为每个单元格/单词生成具有任意值的概率分布。我们可以将其与实际输出进行比较,然后使用反向传播调整所有模型的权重,使输出更接近期望的输出。
如何比较两个概率分布?我们简单地用一个减去另一个。要了解更多细节,请看交叉熵和Kullback-Leibler散度。
但请注意,这是一个过于简化的例子。更现实的做法是,我们将使用一个多于一个单词的句子。例如,输入:“je suis samudiant”,期望输出:“i am a student”。这实际上意味着,我们希望我们的模型连续输出概率分布,其中:
每个概率分布都由一个宽度为vocab_size的向量表示(在我们的示例中为6,但更实际的数字是30,000或50,000)。
第一个概率分布在与单词“i”相关的单元格上的概率最高
第二个概率分布在与单词“am”相关的单元格上的概率最高
以此类推,直到第五个输出分布表示“<句子结束>”符号,该符号也有一个与它相关联的来自10,000个元素词汇表的单元格。
我们训练模型的目标概率分布在一个样本句子的训练样例中。
在足够大的数据集上训练模型足够长的时间后,我们希望生成的概率分布看起来像这样:
希望经过训练,模型能输出我们期望的正确翻译。当然,如果这个短语是训练数据集的一部分,它并没有真正的指示(参见:交叉验证)。请注意,每个位置都有一点概率,即使它不太可能是那个时间步长的输出——这是softmax的一个非常有用的特性,它有助于训练过程。
现在,由于模型每次产生一个输出,我们可以假设模型从概率分布中选择概率最高的单词,并丢弃其余的单词。这是一种方法(称为贪婪解码)。另一种方法是保留前两个单词(例如,“I”和“a”),然后在下一步中运行模型两次:一次假设第一个输出位置是单词“I”,另一次假设第一个输出位置是单词“a”,考虑到位置#1和#2,产生更少错误的版本被保留。我们对位置2和位置3重复此操作。这个方法被称为“光束搜索”,在我们的例子中,beam_size是2(意味着在任何时候,两个部分假设(未完成的翻译)都保留在内存中),top_beams也是2(意味着我们将返回两个翻译)。这两个都是你可以试验的超参数。