明天你会感谢今天奋力拼搏的你。
ヾ(o◕∀◕)ノヾ
原文出自:https://jalammar.github.io/illustrated-transformer/
注意力机制是一个有助于提升神经机器翻译应用性能的概念。在本文中,我们将研究Transformer——一种利用注意力机制来提高这些模型训练速度的模型,其由 Vaswani 等人在 2017 年的论文《Attention is All You Need》中提出。在特定任务中,Transformer的性能优于谷歌神经机器翻译模型。然而,其最大的优势在于它易于并行化处理。事实上,谷歌云推荐将Transformer作为参考模型来使用他们的云张量处理单元(Cloud TPU)服务。那么,让我们试着剖析这个模型并看看它是如何运作的。
哈佛大学自然语言处理小组创建了一个指南,用PyTorch(一种深度学习框架)实现对该论文进行注释,大家可以参考。
在本篇文中,我们将尝试把事情简化一点,并且逐个介绍这些概念,希望能让那些对这一主题没有深入了解的人更容易理解。
首先,我们把模型看作一个单一的黑箱。在机器翻译应用中,它会接收一种语言的句子,并输出另一种语言的译文。
The Transformer内部,我们看到一个编码组件、一个解码组件以及它们之间的连接。
编码组件是一组编码器的堆叠(论文《Attention is All You Need》中将六个编码器堆叠在一起——数字六并没有什么神奇之处,完全可以尝试其他组合方式)。解码组件是由相同数量的解码器组成的堆叠。
这些编码器的结构完全相同(但它们不共享权重)。每个编码器都分为两个子层:
编码器的输入首先流经一个自注意力层——在编码特定单词时,该层有助于编码器查看输入句子中的其他单词。我们将在本文后面更详细地探讨自注意力机制。自注意力层的输出被送入一个前馈神经网络。完全相同的前馈网络独立地应用于每个位置。解码器也有这两个层,但在它们之间有一个注意力层,该层有助于解码器聚焦于输入句子的相关部分(类似于seq2seq模型中的注意力机制)。
既然我们已经了解了模型的主要组成部分,现在让我们开始研究各种向量/张量以及它们在这些组件之间是如何流动的,从而将训练有素的模型的输入转换为输出。与一般的自然语言处理(NLP)应用一样,我们首先使用嵌入算法将每个输入单词转换为一个向量。
每个单词都嵌入到大小为 512 的向量中。如下图所示,我们将用这些简单的框来表示这些向量。
嵌入操作(embedding)仅发生在最底层的编码器中。所有编码器共有的抽象概念是,它们接收一个向量列表,每个向量的大小为512——在最底层编码器中,这些向量是词嵌入(word embeddings),但在其他编码器中,则是直接位于其下方的编码器的输出。这个列表的大小是一个我们可以设置的超参数——基本上就是训练数据集中最长句子的长度。
在对输入序列中的单词进行嵌入之后,每个单词都会流经编码器的两层,如下图所示:
在这里我们开始看到Transformer的一个关键特性,即每个位置的单词在编码器中通过自己的路径流动。这些路径在自注意力层之间存在依赖关系。然而,前馈层没有这些依赖关系,因此各种路径在流经前馈层时可以并行执行。
接下来,我们将把示例换成一个更短的句子,并且查看编码器的每个子层中会发生什么。
编码器接收一个向量列表作为输入。它通过将这些向量传入一个“自注意力”层,然后传入前馈神经网络,再将输出向上发送到下一个编码器来处理这个列表。
如上图所示,每个位置的单词都经过一个自注意力(self - attention)过程。然后,它们各自经过一个前馈神经网络——每个向量分别流经的这个网络是完全相同的网络。
假设下面这个句子是我们想要翻译的输入句子:
“The animal didn't cross the street because it was too tired”
句中的“它”指的是什么?是指街道还是指动物呢?这对人类来说是个简单的问题,但对算法来说就没那么简单了。
当模型处理“它”这个单词时,自注意力机制能让它将“它”与“动物”联系起来。
当模型处理每个单词(输入序列中的每个位置)时,自注意力机制能让它查看输入序列中的其他位置以获取有助于对这个单词进行更好编码的线索。
如果你熟悉循环神经网络(RNN),可以想象一下保持隐藏状态是如何让RNN将之前处理过的单词/向量的表示与当前正在处理的单词/向量相结合的。自注意力机制是Transformer用来将对其他相关单词的“理解”融入到我们当前正在处理的单词中的方法。
如上图,由于我们正在编码器#5(堆栈中最上面的编码器)中对单词“it”进行编码,注意力机制的一部分聚焦于“这只动物”,并将其表示的一部分融入到对“it”的编码之中。
务必查看Tensor2Tensor笔记本,在那里你可以加载一个Transformer模型,并使用这个交互式可视化工具进行检查。
首先来看如何使用向量计算自注意力(self - attention),然后再看它是如何实际被实现的——使用矩阵。
计算自注意力的第一步是从编码器的每个输入向量(在本例中,即每个单词的嵌入表示)创建三个向量。所以对于每个单词,我们创建一个查询(Query)向量、一个键(Key)向量和一个值(Value)向量。这些向量是通过将嵌入表示乘以我们在训练过程中训练得到的三个矩阵而创建出来的。
请注意,这些新向量的维度比嵌入向量的维度小。它们的维度是64,而嵌入向量和编码器的输入/输出向量的维度是512。它们不一定非得更小,这是一种架构选择,目的是使多头注意力(multi - headed attention)的计算(基本上)保持恒定。
如上图所示,将x1乘以查询权重矩阵得到q1,即与该单词相关的“Query”向量。我们最终为输入句子中的每个单词创建了一个“查询”、一个“键”和一个“值”的投影。
“查询(query)”、“键(key)”和“值(value)”向量是什么?
它们是在计算和思考注意力机制时很有用的抽象概念。当你继续阅读下面关于注意力是如何计算的(内容)后,你就会基本了解每个向量所起作用的所有需要知道的内容。
计算自注意力的第二步是计算一个得分。假设我们正在为这个例子中的第一个单词“Thinking(思考)”计算自注意力。我们需要针对这个单词对输入句子中的每个单词进行打分。这个得分决定了在编码某个位置的单词时,要对输入句子的其他部分给予多少关注。得分是通过将查询向量与我们要打分的相应单词的键向量做点积来计算的。所以,如果我们正在处理位置#1处单词的自注意力,第一个得分将是q1和k1的点积。第二个得分将是q1和k2的点积。如下图所示:
第三步和第四步是将分数除以8(论文中使用的密钥向量维度的平方根——64。这会使梯度更稳定。这里可能有其他可能的值,但这是默认值),然后将结果通过softmax操作。Softmax会对分数进行归一化,使它们都是正数且总和为1。如下图:
这个softmax得分决定了每个单词在这个位置的表达程度。显然,处于这个位置的单词将具有最高的softmax得分,但有时候关注与当前单词相关的另一个单词是有用的。
第五步是将每个值向量乘以softmax得分(为将它们求和做准备)。这里的直觉是要保持我们想要关注的单词的值不变,并且弱化不相关的单词(例如通过将它们乘以像0.001这样的极小数字)。
第六步是对加权值向量求和。这就得到了该位置(对于第一个单词而言)自注意力层的输出。
这就结束了自注意力计算。得到的向量是我们可以传送到前馈神经网络的向量。然而,在实际实现中,为了更快的处理速度,这个计算是以矩阵形式进行的。所以,在我们已经了解了单词层面计算的直觉之后,现在让我们来看看(矩阵形式的计算)。
第一步是计算查询(Query)、键(Key)和值(Value)矩阵。我们通过将嵌入(embeddings)打包进一个矩阵X,并将其与我们训练得到的权重矩阵(WQ、WK、WV)相乘来实现这一点。
X矩阵中的每一行都对应输入句子中的一个单词。我们再次看到嵌入向量(512,或图中的4个方框)与查询/键/值向量(64,或图中的3个方框)在大小上的差异。
最后,由于我们处理的是矩阵,我们可以将步骤二到六简化为一个公式来计算自注意力层的输出。
论文通过添加一种称为“多头”注意力的机制,进一步优化了自注意力层。这在两个方面提高了注意力层的性能:
如上图:在多头注意力机制中,我们为每个头维护单独的查询(Q)、键(K)、值(V)权重矩阵,从而得到不同的查询(Q)、键(K)、值(V)矩阵。和之前一样,我们将X与权重矩阵WQ、WK、WV相乘以生成查询(Q)、键(K)、值(V)矩阵。
如果我们按照上述概述进行同样的自注意力计算,只是用不同的权重矩阵重复八次,我们最终会得到八个不同的Z矩阵。如下图所示:
这给我们留下了一个小挑战。前馈层期望的不是八个矩阵——它期望的是一个单一矩阵(每个单词一个向量)。所以我们得想办法把这八个矩阵压缩成一个矩阵。我们该怎么做呢?我们将这些矩阵连接起来,然后让它们乘以一个额外的权重矩阵WO。
多注意力机制基本上就是这些内容了。我知道这涉及不少矩阵。让我试着把它们都放在一个可视化图表里,这样我们就能在一个地方查看它们了。
既然我们已经谈到了注意力头(机制),让我们重新回顾一下之前的例子,看看在我们对示例句子中的单词“it”进行编码时,不同的注意力头聚焦于哪些地方。
当我们编码单词“it”时,一个注意力头主要关注“the animal”(这个动物),而另一个则关注“tired”(疲惫的)——从某种意义上说,模型对单词“it”的表征融合了“animal”和“tired”两者的部分表征。
然而,如果我们将所有的注意力头都添加到图片中,情况可能会更难解释:
到目前为止,在我们所描述的模型中缺少一种考虑输入序列中单词顺序的方法。
为了解决这个问题,Transformer给每个输入嵌入(embedding)添加了一个向量。这些向量遵循一种特定的模式,模型会学习这种模式,这有助于它确定每个单词的位置或者序列中不同单词之间的距离。这里的直觉是,将这些值添加到嵌入中,一旦它们被投影到Q/K/V向量并且在点积注意力计算期间,就能为嵌入向量提供有意义的距离。
上图所示,为了使模型了解单词的顺序,我们添加位置编码向量——其值遵循特定的模式。
如果我们假设嵌入的维度为4,那么实际的位置编码将如下所示:
这种模式可能看起来像什么?
在下图中,每一行对应一个向量的位置编码。因此,第一行将是我们添加到输入序列中第一个单词的嵌入中的向量。每一行包含512个值——每个值介于1和 -1之间。我们对其进行了颜色编码,以便能够看到这种模式。
上图是一个针对20个单词(行)的位置编码的实际示例,嵌入大小为512(列)。你可以看到它从中心位置一分为二。这是因为左半部分的值是由一个函数(使用正弦函数)生成的,而右半部分的值是由另一个函数(使用余弦函数)生成的。然后它们被连接起来形成每个位置编码向量。
位置编码的公式在论文(第3.5节)中有描述。你可以在get_timing_signal_1d()函数中看到生成位置编码的代码。这不是位置编码的唯一可能方法。然而,它具有能够扩展到未见过序列长度的优势(例如,如果我们训练好的模型被要求翻译一个比我们训练集中的任何句子都长的句子)。
上述的位置编码来自Tensor2Tensor对Transformer(变换器)的实现。论文中展示的方法略有不同,它不是直接拼接,而是交织这两个信号。这是生成该图的代码,下图展示了其样子:
在继续之前,我们需要提及编码器架构中的一个细节,即每个编码器中的每个子层(自注意力机制、前馈神经网络)都有一个环绕它的残差连接,并且之后有一个层归一化步骤。
如果我们要将向量以及与自注意力机制相关的层归一化操作可视化,其样子会是这样的:
这也适用于解码器的子层。如果我们构思一个由2个堆叠的编码器和解码器组成的Transformer,它看起来会是这样的:
既然我们已经涵盖了编码器方面的大部分概念,我们基本上也知道解码器的各个组件是如何工作的了。让我们来看看它们是如何协同工作的。
编码器首先处理输入序列。然后,最上层编码器的输出被转换为一组注意力向量K和V。这些向量将被每个解码器用于其“编码器 - 解码器注意力”层,这有助于解码器聚焦于输入序列中的适当位置。
解码器中的自注意力层与编码器中的自注意力层运行方式略有不同:
解码器堆栈输出一个浮点数向量。我们如何将其转换为单词呢?这是最后一个线性层的工作,其后跟着一个Softmax(softmax)层。
既然我们已经讲解了通过一个经过训练的Transformer的整个前向传播过程,那么了解一下训练该模型的直觉会很有用。
在训练期间,一个未经训练的模型会经历完全相同的前向传播过程。但由于我们是在一个有标记的训练数据集上对其进行训练,所以我们可以将其输出与实际正确的输出进行比较。
为了便于理解,假设我们的输出词汇表只包含六个单词(“a”、“am”、“i”、“thanks”、“student”和“<eos>”(“句末”的缩写))。
模型的输出词汇表是在预处理阶段创建的,甚至在我们开始训练之前就创建好了。
一旦我们定义好输出词汇表,就可以使用一个宽度相同的向量来表示词汇表中的每个单词。这也被称为独热编码(one - hot encoding)。例如,我们可以用以下向量来表示单词“am”。
示例:对我们输出词汇表进行独热编码。
在这个回顾之后,让我们来讨论一下模型的损失函数——在训练阶段我们正在优化的指标,以便最终得到一个经过训练且有望非常准确的模型。
假设我们正在训练我们的模型。假设这是训练阶段的第一步,我们正在一个简单的例子上进行训练——将“merci”(法语的“谢谢”)翻译成“thanks”(英语的“谢谢”)。
这意味着,我们希望输出是一个概率分布,以表明“thanks”这个单词,但由于这个模型尚未经过训练,这种情况不太可能马上发生。
上图,由于模型的参数(权重)都是随机初始化的,(未经训练的)模型针对每个单元/单词产生具有任意值的概率分布。我们可以将其与实际输出进行比较,然后使用反向传播调整模型的所有权重,使输出更接近期望输出。
如何比较两个概率分布?我们只需将一个(概率分布)从另一个(概率分布)中减去。更多细节,请查看cross-entropy和Kullback-Leibler divergence。
但请注意,这只是一个过于简化的例子。更现实的情况是,我们将使用不止一个单词的句子。例如——输入:“je suis étudiant”(我是学生),预期输出:“i am a student”(我是一名学生)。这实际上意味着,我们希望我们的模型能够连续输出概率分布,其中:
在针对一个样本句子的训练示例中,我们的模型将针对训练的目标概率分布:
在一个足够大的数据集上对模型进行足够长时间的训练后,我们希望得到的概率分布会如图所示:
上图,我们希望在经过训练后,模型能够输出我们所期望的正确译文。当然,如果这个短语是训练数据集的一部分,那就不能真正说明问题(参见:交叉验证)。请注意,即使某个位置不太可能是该时间步的输出,它也会得到一小部分概率——这是softmax的一个非常有用的特性,有助于训练过程。
现在,由于模型一次生成一个输出,我们可以假设模型是从该概率分布中选择概率最高的单词,并丢弃其余部分。这是一种方法(称为贪婪解码)。另一种方法是保留前两个单词(例如,“我”和“一个”),然后在下一步中,运行模型两次:一次假设第一个输出位置是单词“我”,另一次假设第一个输出位置是单词“一个”,并且无论哪个版本在考虑第1和第2个位置时产生的误差较小,就保留它。我们对第2和第3个位置……等重复此操作。这种方法称为“束搜索”,在我们的例子中,束宽为2(意味着在任何时候,内存中都保留两个部分假设(未完成的翻译)),并且top_beams也为2(意味着我们将返回两个翻译)。这些都是您可以尝试的超参数。
我希望你觉得这是一个有用的起点,可以借此了解Transformer的主要概念来打破僵局。如果你想深入了解,我建议采取以下后续步骤:
全部评论