☝个知识点:点击上方蓝字可以关注我们
来源:jalammar.github.io
作者:Jay Alammar
编译:立夏
在 Visualizing A Neural Machine Translation Model 这篇文章中(见参考链接1),我们讨论了注意力——注意力是指可以帮助提高神经机器翻译模型性能的一个概念。本文我们将着眼于Transformer,它是一个用注意力来提高模型训练速度的模型。Transformer在执行某些特定任务的时候,其性能优于Google Neural Machine Translation(GNMT)模型。不止于此,Transformer最大的好处是它能让自己适应于并行化计算。此外,Google Cloud建议使用Transformer作为参考模型来使用他们的Cloud TPU产品。基于此,我们将尝试拆解这一模型并探究它是如何运作的。
Attention is All You Need(见参考链接2)这篇论文中提出了Transformer这一模型。该模型TensorFlow的代码实现已经加入到Tensor2Tensor的包中。哈佛大学的NLP小组用PyTorch实现了这一模型。在本文中,我们将尝试把一些事情简化并逐一介绍这些概念,使得对这一主题没有深入了解的人们也能够轻松理解。
一个宏观的视角
首先,我们将模型视为一个黑盒子。机器翻译的运行机制是将某种语言的句子输出为另一语言。
再进一步探究这个黑盒子,我们会看到编码和解码的组件,以及它们之间的联系。
编码组件是编码器的堆栈。(论文中将6个编码器堆叠在一起,但数字6并没有任何特殊含义,你可以尝试任何其他个数的编码器)。解码组件也是同样数量的解码器堆栈。
编码器的结构完全一致(但他们的权重并不相同),每一个都分为两个子层:
编码器的输入首先经过self-attention层,这一层的作用是在它对特定单词编码时,可以帮助编码器查看输入语句中的其他单词,以确定具体的语境。我们将在下文中对self-attention做进一步介绍。
Self-attention层所输出的内容会被输入到前馈神经网络层。完全相同的前馈神经网络会分别应用于每个编码器中。
解码器也同样有self-attention和前馈神经网络,但在两者之间还多了一个注意力层,它可以帮助解码器关注输入语句的相关部分(与seq2seq模型中注意力所起到的作用类似)。
画出张量
我们已经了解了模型的主要组件,那我们现在开始研究不同的向量/张量,以及它们如何在这些组件中流动,来达到将训练模型的输入转化为输出的目的。
与一般的NLP模型情况一样,我们首先使用嵌入算法将每个输入的单词转化为向量。
每个单词都嵌入到512维的向量中
词嵌入仅用于最底部的编码器中。所有编码器都会接收一组512维的向量。底部的编码器接收的是词嵌入,但其他编码器接收的则是其下方的编码器的输出。这一列表的大小是可以设置的超参数——基本上它是我们训练的数据集中最长的句子长度。
在输入序列中进行词嵌入之后,每一个向量都会经过编码器的每一层。
现在,我们开始看到Transformer的一个关键属性——每个位置的单词在编码器中沿着自己的路径流动。在self-attention层中,这些路径之间存在依赖关系。由于前馈层中不存在依赖关系,因此各种路径在经过前馈层时可以并行执行。
开始编码!
如上文所提到的,一个编码器接收一个向量列表作为输入,它通过列表中的向量传入self-attention层,然后进入前馈神经网络,最后输出到下一个编码器。
每个位置的单词都会通过一个Self-Attention层,然后它们再分别通过前馈神经网络。
从宏观看Self-Attention
不要被我频繁使用self-attention一词所迷惑,进而认为这是一个所有人都很熟悉的概念。我个人在阅读Attention is All You Need这篇论文之前,从未接触到这一概念。现在,我们来概括一下self-attention是如何工作的。
假设我们想翻译这一句子:
“The animal didn't cross the street because it was too tired”
那么,在句子中“it”指的是什么呢?它是指街道还是动物?对于人来说,这是一个极为简单的问题,但对算法来说就没那么简单了。
当模型处理每个单词(输入序列的每个位置)时,self-attention会让它也兼顾输入序列的其他位置,将其作为线索,进而让它更好地编码该单词。
如果你对RNNs十分熟悉,想想在保持隐藏状态时情况如何将RNN此前处理的单词/向量的表示与当前正在处理的进行合并。Transformer用self-attention将其他相关单词的“理解“融入到目前正在处理的单词中。
当我们在第5个编码器中编码“it”这一单词时,注意力机制会让模型关照到“The Animal”,将其表示融入到“it”编码中。
微观视角的Self-Attention
我们开始研究如何用向量计算self-attention,然后再看看它如何运用矩阵计算实现这一过程。
首先,从每个编码器所输入的向量中创建三个向量(在本文中,是指每个单词的嵌入)。所以对于每个单词,我们创建一个Query向量,一个Key向量和一个Value向量。这些向量是由词嵌入与3个我们训练的矩阵相乘得来。
请注意,这些新的向量维数小于词嵌入向量,它们是64维,而词嵌入与编码器输入/输出的向量为512维。它们的维数之所以更少是出于架构的选择,这样可以保证multi-headed attention在多数情况下稳定。
与权重矩阵
相乘产生相应的query向量,
,其他向量同理可得。
什么是Query, Key和Value向量?
它们是用于计算注意力的,你接着往下阅读就能明白注意力是如何计算的,并且将清楚地了解到每个向量在其中扮演了什么样的角色。
计算self-attention的第二步是计算一个得分。假设我们要计算本例中的第一个单词“Thinking”的self-attention得分。我们需要根据这个单词为输入语句中的每个词打分,这个分数决定了当我们在编码某一位置的单词时对输入语句的其他部分投入多少关注度。
这一得分通过计算Query向量点积和我们此前打分的其他单词的Key向量得到的。所以如果我们想计算位置1上的单词self-attention得分,第一个分数应该是和
的点积,第二个分数应该是
和
的点积。
第三第四步就是将所得分数除以8(本文使用的Key向量的维数平方根默认为64,使得有更稳定的梯度),将结果进行softmax操作,softmax将分数标准化,使它们都为正数,加起来等于1。
Softmax的结果决定了每个单词的在这一位置的权重。显然,在这个位置上的单词将获得最高的softmax得分,但有时也需要注意与当前单词相关的词汇。
第五步是将每个value向量和softmax得分相乘(以便后续求和)。保持我们想关注的单词的value值完整不变,并掩盖掉那些不相关的词汇(比如,将它们与像0.001那样很小的数字相乘)。
第六步将带权重的value向量相加,在这个位置上产生self-attention层的输出(第一个单词)。
以上总结了self-attention的计算步骤。所得到的结果向量可以将其输送到前馈神经网络中。然而,在实际的实现过程中,这种计算以矩阵的形式完成,以加快处理速度。
Self-Attention的矩阵计算
第一步,计算Query、Key、Value矩阵。词嵌入矩阵X乘以训练得到的权重矩阵()。
在矩阵X中每一列都代表输入语句中的一个词
最后,由于我们是以矩阵的形式进行计算,所以我们可以将步骤2到6浓缩为1个公式来计算self-attention层的输出。
以矩阵形式计算self-attention
Multi-headed Attention
这篇论文通过增加被称为“multi-headed” attention的机制,进一步提升了self-attention。这从两个方面改善了注意力层的性能:
-
它扩展了模型关注不同位置的能力。正如上述例子所提到的,
包含了每个其他单词的一些编码,但是它自身依然占据最大的权重。如果我们翻译像这样的句子:“The animal didn’t cross the street because it was too tired”,想要知道“it”在句中所指代的事物,那么这一功能是非常有用的。
-
它给予注意力层多个“表示子空间”。我们接下来将会看到,对于multi-headed attention,我们拥有不止一组Query/Key/Value权重矩阵(Transformer使用8个attention heads,所以我们最终为每个编码器/解码器设置了8组矩阵)。每组都是随机初始化的。在训练之后,每组被用于将输入的词嵌入(或者低层编码器/解码器的向量)投入到不同的表示子空间。
使用上文提到的方法用不同的权重矩阵做8次不同的计算,最终就能得到8个不同的Z矩阵
这留给了我们一些挑战。前馈层并不需要8个矩阵——它只需要1个矩阵(每个单词对应一个向量)。所以我们需要一种方法将8个矩阵浓缩成1个。
我们该如何做呢?将这些矩阵合并,然后让它们和一个额外的权重矩阵相乘。
这就是关于multi-headed self-attention的所有内容。让我将所有的矩阵放到同一张图看看。
既然我们已经了解了attention heads,那么不妨让我们重新回顾上文提到的例子,来看看当我们在编码例句中的单词”it”的时候,不同的attention heads把关注点放在哪里。
当我们编码“it”的时候,有一个attention head将关注点放在“the animal”,另一个则更关注“tired”。那么模型将会把“animal”和“tired”编码到“it”中。
如果我们将所有的attention heads放到同一张图片中,那么事情会变得更加难以解释:
使用位置编码表示序列的顺序
目前为止,我们尚未提到模型如何计算输入序列中的单词顺序这一问题。
为了解决这一问题,Transformer为每个输入词嵌入添加了一个向量。这些向量遵循模型学习的特定模式,这将帮助它决定每个单词的位置或者在序列中不同单词之间的距离。其思想是,将这些值与词嵌入相加会得到经过Q/K/V投射后的词嵌入向量并且在计算注意力点积期间得到词嵌入向量间的距离。
为了让模型了解单词的顺序,我们添加位置编码向量 ——其值遵循特定模式
如果我们假定词嵌入的维数为4,那么实际的位置编码将会如下图所示:
那么这一模式可能会是什么样呢?
在以下图表中,每行对应一个向量的位置编码。所以第一行将是我们将添加到输入序列中嵌入的第一个单词的向量。每行包括512个值——每个值在1到-1之间。为了使这一模式更直观地呈现出来,我们用颜色标记了每个值。
这是一个真实的例子,20个单词(行数),词嵌入向量是512维。你可以在中间看到它被明显地分开,这是因为左半边的值由正弦函数产生,右半边由余弦函数产生。它们会拼接形成每一个位置编码向量。
位置编码的公式在论文的3.5章,你可以看到用于生成位置编码的代码 get_timing_signal_1d()。这并不是唯一的方法,但它的优点在于对于不定长度的序列,模型也能够处理。(例如,我们训练后的模型可以翻译超出训练数据长度的任何句子)。
残 差
在继续之前,我们需要了解一个编码器架构中的细节,即每个子层(self-attention,ffnn)在每个编码器中都有一个残差连接它们,一般说是,层归一化。
如果我们要将向量和self-attention层的层归一化操作可视化,它将如下图所示:
这同样适用于解码器的子层。如果我们仅考虑由2个编码器和2个解码器组成的Transformer,如下图所示:
解码器
既然我们已经了解了有关编码器的大部分概念,那么我们现在来了解解码器的组件是如何工作的。先让我们看看它们是如何协同工作的。
编码器从处理输入序列开始,顶层编码器的输出被转换成一组注意向量K和V。这些将用于每个解码器中的“编码-解码注意力”层,帮助解码器关注输入序列中适当的位置。
在解码阶段,每一步都输出一个输出序列的元素。(在本例中,是翻译后的单词)
以下步骤重复该过程,直到出现表明输出结束的特殊符号。每一步的输出都会在下一个步骤时被反馈到底层解码器,并且解码器也和编码器一样,将输出结果向更高层传递。正如我们对编码器输入所操作的那样,我们嵌入并添加位置编码到那些解码器输入中去,以表明每个单词的位置。
Self-attention层在解码器中的操作与在编码器中的略微不同:
在解码器中,self-attention仅被允许关注到输出序列中较前的位置。这是在self-attention计算的softmax步骤前通过用掩码mask遮罩序列后面的位置(将它们设置为负无穷)。
编码-解码注意力层的工作与multi-headed self-attention类似,此外,它还会从它下面的层中创建Queries 矩阵并且从编码器堆栈输出中获取Keys和Values矩阵。
最后的线性层和Softmax层
解码器堆栈输出浮点型向量。我们如何将其转化为单词?这是最后一个线性层的工作,其后接Softmax层。
线性层是一个简单的全链接神经网络,它将解码器堆栈所产生的向量投射到更高维的向量中(logits vector)。
假设我们的模型通过训练数据学习到10000个不同的英文单词(即我们模型的“输出词汇”)。那么logits向量就有10000个维度,每个维度对应每个单词的分数。
然后,Softmax层将这些分数转化为概率(全都是正数,并且相加等于1)。选择概率最大的维度,并且与之关联的词汇将在这一步中输出。
编码器栈的输出向量经过线性层和softmax得到概率分布
训练回顾
上文我们已经介绍了整个Transformer的正向传递过程,我们现在来看看训练模型的思路。
在训练过程中,一个未经训练的模型将会通过完全相同的正向传递。但由于我们对标记的训练数据进行训练,我们可以将其的输出与实际正确的输出进行比较。
假设我们输出的词汇仅包括6个单词(a, am, I, thanks, student以及eos<end of sentence的缩写>)。
一旦定义了输出词汇,我们就可以使用相同宽度的向量来表示词汇表中的每个单词,这被称为one-hot编码。举个例子,我们可以用下图中的向量来表示单词“am”:
现在,让我们来讨论这个模型的损失函数——在训练阶段我们优化的指标可用以引导一个训练有素的精确模型。
损失函数
假设我们的第一步是用一个简单的例子进行训练——将“merci”翻译为“thanks”。
这意味着,我们希望输出的是表示单词”thanks“的概率分布。但由于这个模型没有经过充分的训练,因此这目前还不可能发生。
由于模型的参数是随机初始化的,未经训练的模型对每个单词的任意值产生概率分布。我们可以将其与实际输出进行比较,然后使用反向传播调整所有模型的权重,使输出更接近所需的输出。
你如何对两个概率分布进行比较呢?我们简单地从另一个中减去一个。如果你想了解更多细节,可以查看交叉熵(见参考链接3)和KL散度(见参考链接4。
请注意,这只是一个十分简单地例子。实际上,我们会使用一个句子而非一个单词。比如,输入“je suis étudiant”,期望输出“I am a student”。这意味着,我们想要我们的模型连续地输出概率分布,其中:
-
每个概率分布由一个维度等于词汇表大小的向量来表示;
-
在本例中,第一个概率分布中概率最大的是与”I”相关的维度;
-
在本例中,第二格概率分布中概率最大的是与”am“相关的维度;
-
一直重复输出,直到输出的概率分布显示<end of sentence>的符号。
在一个足够大的数据集中对模型进行了足量时间的训练之后,我们生成的概率分布如下图所示:
通过训练,该模型将输出我们所期望的正确翻译。请注意,即便不可能在时间步中输出,每个位置依然能获得一点概率,这是softmax一个十分有用的属性,可以改善训练过程。
现在,因为模型每次只能产生一个输出,我们可以假设模型从概率分布中选择了最大概率的单词,并舍弃掉其他单词。这是一种方法(称为贪婪解码)。另外一种方法是选择概率第一第二大的单词(比如,“I”和“a”),然后下一步运行模型两次:第一次假设第一个输出位置的单词为“I”,第二次假设第一个输出位置的单词是“me”,哪个版本的错误更少就保留那一版本的位置1和2。继续重复操作来确定后续的位置。这一方法被称为“beam search”,在我们的例子中beam_size是2(因为我们在计算了位置1和位置2的beams之后比较了结果),top_beam也是2(因为我们保留了2个单词)。你可以对这两个超参数进行实验。
参考链接
1.https://jalammar.github.io/visualizing-neural-machine-translation-mechanics-of-seq2seq-models-with-attention/
2.https://arxiv.org/abs/1706.03762
3.https://colah.github.io/posts/2015-09-Visual-Information/
4.https://www.countbayesie.com/blog/2017/5/9/kullback-leibler-divergence-explained
如果您还想了解更多有关Transformer的内容,可在公众号对话框中回复【Transformer 】,获取更多信息。
转发文章赢编程日历啦!!!
将本篇文章转发至朋友圈,无分组,保留三小时及以上。将截图发送到公众号后台,即可有机会获得我们送出的编程日历一套噢~
本次一共5个名额,赶紧转发吧!
(获奖名单在下周推文公布,请保持关注)