图解 Transformer
在之前的博客《图解机器翻译模型:基于注意力机制的 Seq2Seq》中,我们介绍了注意力(Attention
)机制,这也是深度学习模型中一种较为常用的方法。
在本篇博客中,我们将介绍一种新的模型 Transformer,一种使用注意力机制来提高模型训练速度的模型。Transformer 是在论文 Attention is All You Need 中首次提出的。Tensor2Tensor 包提供了其基于 TensorFlow 的实现版本。哈佛大学的 NLP 小组也创建了一个指南,使用 PyTorch 实现了对论文的注释。
Transformer 在特定任务上优于谷歌神经机器翻译(Neural Machine Translation
)模型。同时,Transformer 自身的并行化是其最大的优点。Google Cloud 也建议使用 Transformer 作为参考模型来使用他们的 Cloud TPU 产品。接下来,我们将对模型进行拆解,一识庐山真面目。
1.A High-Level Look
首先将模型视为一个黑盒。在机器翻译应用程序中,它接受一种语言的句子,在翻译为另一种语言后输出。
进入 Transformer,我们将看到一个编码组件(Encoders
)、一个解码组件(Decoders
)以及二者间的连接。
编码组件由多个编码器(Encoder
)组成。原论文将 个编码器进行堆叠,数字 并没有特殊的含义,也可以尝试其它数量的编排方式。解码组件是由相同数量的解码器(Decoder
)组成。
编码器在结构上都是相同的,但它们不共享权重。每个都分为两个子层。
编码器的输入首先经过自注意力层(Self-Attention
),该层使得编码器在对特定单词进行编码时能够查看输入句子中的其他单词。随后将会详细介绍 自注意力机制。
自注意力层的输出被送到前馈神经网络(Feed-Forward Neural Network
)。完全相同的前馈网络独立应用于每个位置。
解码器也有这两个层,但在二者之间还有一个注意力层(Encoder-Decoder Attention
),它使得解码器能专注于输入句子的相关部分(类似于 Seq2Seq
模型中的注意力机制)。
2.Bringing The Tensors Into The Picture
在了解了模型的主要组成部分之后,我们将开始研究各种向量或张量,以及它们是如何在这些组件间流动,将训练模型的输入转化为输出。
按照一般的 NLP 应用程序规范,我们首先使用嵌入算法将每个输入词转换为向量。每个单词都被嵌入表示为一个
嵌入工作仅在最底层的编码器(bottom-most encoder
)中进行。对于所有的编码器而言,它们将接收一个向量列表,每个向量的大小为 。在底层编码器中即为词嵌入,但在其他编码器中,它将是上一步的输出。该列表大小是一个超参数,一般设置为训练数据集中最长句子的长度。
在对输入句子进行嵌入后,每个单词都会经过编码器的这两个层。
此处,我们发现了 Transformer 的一个关键属性,即每个位置的单词在编码器中有自己的流经路径。自注意力层中的这些路径之间存在依赖关系。然而,前馈层中没有这些依赖关系,因此各种路径在流经前馈层时可以并行执行。
接下来,我们将使用一个较短的句子作为示例,观察编码器的每个子层会发生什么。
3.Now We Are Encoding
编码器接收一个向量列表作为输入。它将这些向量列表传递到自注意力层进行处理,接着传递到前馈神经网络层中,然后将输出继续传送到下一个编码器。
每个位置的单词都将经过一个自注意力过程。然后,它们各自通过一个完全相同的前馈神经网络。
4.Self-Attention at a High Level
现在我们来一起看一下自注意力是如何工作的。假设下面的句子作为待翻译的输入:
The animal didn’t cross the street because it was too tired
这句话中的 it
指的是什么?它指的是 street
还是 animal
?这对人类来说是一个简单的问题,但对算法来说却不那么简单。
当模型处理 it
这个词时,self-attention
允许它把 it
和 animal
联系起来。
当模型处理任意一个单词时,自注意力允许它查看输入序列中的其他位置的单词,寻找线索来更好地编码该单词。
如果你熟悉 RNN,可以回顾一下 RNN 是如何保持隐藏状态,来将先前已处理的单词/向量表示与当前正在处理的单词/向量表示进行结合。自注意力通过 Transformer 将对其他相关词的 理解 融入当前正在处理的词。
当我们在编码器 #5
(堆栈中顶部的编码器)中对单词 it
进行编码时,注意力机制一部分集中在 The Animal
上,并将其表示的这部分映射到 it
的编码中。
5.Self-Attention in Detail
首先看看如何使用向量计算自注意力,然后看看如何通过矩阵实现。
计算 self-attention
的第一步是为编码器的每个输入向量(在本例中是每个词的嵌入)创建三个向量。因此,对于每个单词,我们创建一个 Query
向量、一个 Key
向量和一个 Value
向量。这三个向量是通过将嵌入乘以在训练过程中训练得到的三个矩阵而创建的。
这些新向量的维度小于嵌入向量。它们的维度是 ,而嵌入和编码器的输入/输出向量维度是 。但它们也不需要更小,这是一种约定俗成的多头注意力计算机制。
那么什么是 query
、key
、value
向量呢?此处我们可以暂时先将它们理解为有益于计算和思考注意力机制的一种抽象。
计算 self-attention
的第二步是计算一个 。以本例中第一个词 Thinking
为例,计算该词与输入句子中每个词之间的分数,分数的高低决定了将多少注意力放在输入句子的其他部分。
分数是 query
向量与正在评分的相应单词的 key
向量的点积。因此,如果我们正在计算位置 #1
对应单词的自注意力,则第一个分数是 q1
和 k1
的点积,第二个分数是 q1
和 k2
的点积。
第三步和第四步是将 除以 (论文中使用 key
向量维度的平方根 ,这会使梯度更稳定。也可以是其他可能的值),然后通过 操作传递结果。 对分数进行归一化处理,使它们都为正值且总和为 。
分数决定了每个词在这个位置会表达多少。显然,本例中就是原位置的词具有最高的
第五步是将每个 value
向量乘以 分数。这里我们想要保持关注的单词的值不变,并掩盖不相关的单词。例如,将它们乘以 。
第六步是对加权后的 value
向量求和。即得到了在该位置(第一个单词)产生的自注意层输出。
self-attention
的计算到此结束,生成的向量将发送到前馈神经网络。然而,在实际实现中,此计算是以矩阵的形式完成的,以获取更快的处理速度。一起来看看吧!
6.Matrix Calculation of Self-Attention
第一步是计算 query
、key
、value
矩阵。首先,将嵌入(Embeddings
)打包到矩阵 中,然后将其乘以训练的权重矩阵(、、)。
矩阵中的每一行对应于输入句子中的一个单词。我们再次观察嵌入向量(,即图中的 个框)和 q
、k
、v
向量(,即图中的
由于我们处理的是矩阵,可以将第二步到第六步压缩为一个公式来计算自注意力层的输出。
7.The Beast With Many Heads
论文通过添加一种名为 多头 注意力的机制进一步深化了自注意力层。通过两种方式提高了注意力层的性能:
- 它扩展了模型关注不同位置的能力。在上面的示例中, 仅包含了其他所有编码的零光片羽,但它可能主要由实际单词本身来决定。如果我们要翻译像
The animal didn’t cross the street because it was too tired
这样的句子,知道it
指的是哪个词会很有帮助。 - 它为注意力层提供了多个表示子空间(
representation subspaces
)。对于多头注意力,有多组q
、k
、v
权重矩阵。Transformer 使用 个注意力头,所以最终每个编码器/解码器得到
对于多头注意力,我们为每个头维护单独的 Q
、K
、V
权重矩阵,从而产生不同的 Q
、K
、V
矩阵。同样的,将 乘以 、、 矩阵以生成 Q
、K
、V
矩阵。
在 次自注意力计算后,我们最终会得到 个不同的
然而,前馈层并不需要 个矩阵,仅一个矩阵即可(每个单词一个向量)。所以,需要一种方法将这
连接这些矩阵,然后将它们乘以一个额外的权重矩阵 。
以上大概就是多头注意力机制的全部内容,如下图所示。
既然已经谈到了注意力头,那么重新审视前面的例子,当我们在示例句子中对单词 it
进行编码时,不同的注意力头会集中在什么地方。
当我们对 it
这个词进行编码时,一个注意力头最关注 the animal
,而另一个注意力头则关注 tired
。在一定程度上,模型将 it
这个词用 animal
和 tired
表示。
如果将所有注意力头都添加进来,结果会更加难以解释。
8.Representing The Order of The Sequence Using Positional Encoding
截至目前,模型缺少一种解释输入序列中单词顺序的方法。
为了解决这个问题,Transformer 为每个输入的嵌入添加了一个向量(其值遵循特定模式)用以确定每个单词的位置或序列中不同单词之间的距离。直觉告诉我们,在将这些值添加到嵌入中后,就可以在嵌入向量之间提供有意义的距离。
假设嵌入的维度为 ,那么实际的位置编码将如下所示:
在下图中,每一行对应一个向量的位置编码。所以第一行是我们要添加到输入序列中第一个单词的嵌入向量。每行包含 个值,每个值介于 和
上图是嵌入大小为 (列)的
位置编码的公式在原论文(第 节)中有描述。可以在 get_timing_signal_1d()
中查看生成位置编码的代码。这不是唯一的位置编码方法。然而,它具有能够扩展到看不见的序列长度的优势(例如,如果我们训练的模型被要求翻译比我们训练集中的任何句子更长的句子)。
下面显示的位置编码是由 Transformer 的 Tranformer2Transformer
实现,与论文中展示的方法略有不同,它不是直接连接,而是将两个信号交织在一起,如下图所示。查看源代码。
9.The Residuals
在编码器架构中,需要注意的一个细节是,每个编码器中的每个子层周围都有一个残差连接(residual connection
),然后才是层归一化(layer-normalization
)的步骤。
如果要可视化与自注意力相关的向量和层归一化操作,则如下图所示:
这也适用于解码器的子层。如果考虑
10.The Decoder Side
到这里,我们已经介绍了编码器的大部分概念,大体上也知道了解码器是如何工作的。接下来,一起看看它们是如何协同工作的。
编码器首先处理输入序列。然后将最后一层编码器的输出转换为一组注意力向量 和 。每个解码器在其 编码器-解码器注意力层 中使用这些向量,用以帮助解码器将注意力集中在输入序列中的适当位置。
重复该过程,直至遇到一个特殊符号,表明解码器已完成其输出。每个步骤的输出在下一个时间步中被传送至最底部的解码器,然后解码器给出它们的解码结果。
和对编码器输入的操作类似,我们将位置编码嵌入后并添加到这些解码器的输入中,以指示每个单词的位置。
解码器中的自注意力层与编码器中的自注意力层的工作方式略有不同。在解码器中,自注意力层只允许关注输出序列中较早的位置。这是通过在自注意力计算的 步骤之前屏蔽后面位置(将它们设置为 -inf
)来完成的。
Encoder-Decoder Attention
层的工作方式与多头自注意力类似,并且它从它下面的层创建 query
矩阵,并从编码器堆栈的输出中获取 key
和 value
矩阵。
11.The Final Linear and Softmax Layer
解码器堆栈输出一个浮点向量,如何将其转换为一个词呢?这是最后一个线性层以及紧接着的 Softmax
层的工作 。
线性层是一个简单的全连接神经网络,它将解码器堆栈产生的向量投影到一个更大的向量中,称为
假设模型知道从训练数据集中学习 个唯一的英语单词(即模型的输出词汇表),那么 向量则有
然后 层将这些分数转化为概率(全部为正值,总和为 )。选择具有最高概率的单元格,并生成与其关联的单词作为该时间步的输出。
12.Recap Of Training
假设输出词汇表只包含六个词:a
、am
、i
、thanks
、student
和 <eos>
(end of sentence
的缩写)。
在定义了输出词汇表后,我们就可以使用相同宽度的向量来表示词汇表中的每个单词,这也称为独热(One-Hot
)编码。例如,我们可以使用下图所示的向量表示单词 am
。
在回顾之后,让——我们在训练阶段优化的指标,以形成一个训练有素、希望非常准确的模型。
13.The Loss Function
我们继续讨论一下模型的损失函数,不断优化该指标,希望训练得到一个较为准确的模型。
假设我们处在训练阶段的第一步,并且用一个简单的例子训练它,将 merci
翻译成 thanks
。
那么我们希望输出的是表示 thanks
一词的概率分布。但由于该模型还没有经过训练,所以这不太可能实现。
由于模型的参数(权重)都是随机初始化的,因此(未经训练的)模型会为每个单元格/单词生成一个具有任意值的概率分布。我们可以将它与实际输出进行比较,然后使用反向传播调整所有模型的权重,使输出更接近所需的输出。
如何比较两个概率分布呢?比如,计算两者的差值。更多信息可以参考 cross-entropy
和 Kullback–Leibler divergence
。
但这只是一个非常简单的例子。接下来,我们将使用一个非单个词的句子,例如:输入是 je suis étudiant
,预期输出为 i am a student
。我们希望模型能连续输出概率分布,其中:
- 每个概率分布都由一个宽度为
vocab_size
的向量表示。在本示例中为 ,但更实际的数字可能是 或 。 - 第一个概率分布在与单词
i
相关联的单元格中具有最高概率。 - 第二个概率分布在与单词
am
相关联的单元格中具有最高概率。 - 依此类推,直到第五个输出分布表示
<end of sentence>
符号。
在足够大的数据集上训练模型足够长的时间后,我们希望生成的概率分布如下图所示:
因为模型一次产生一个输出,我们假设模型从该概率分布中选择概率最高的词,并丢弃其余词。这种方法称为贪心解码(Greedy Decoding
)。
另一种方法名为集束搜索(Beam Search
) 。集束搜索是对贪心策略的一个改进。思路也很简单,就是稍微放宽一些考察的范围。在每一个时间步,不再只保留当前分数最高的 个输出,而是保留 num_beams
个。当 num_beams=1
时,集束搜索就退化成了贪心搜索。
比如,对于本例,保留前两个词,例如 I
和 a
。然后,在下一步中运行模型两次:假设 认为第一个输出位置是 I
这个词,假设 则认为第一个输出位置是 a
这个词。因为位置 #1
和 #2
都将被保留,任意一个假设都将会产生更少的错误。我们对位置 #2
和 #3
等重复此操作。