基于 Transformer 的编码器-解码器模型
!pip install transformers==4.2.1
!pip install sentencepiece==0.1.95
基于 Transformer 的编码器-解码器模型由 Vaswani 等人在著名的论文 《Attention is all you need》 中提出,如今已成为自然语言处理(NLP)领域中事实上的标准编码器-解码器架构。
近来,针对基于 Transformer 的编码器-解码器模型,学界涌现了大量关于不同预训练目标的研究,例如 T5、Bart、Pegasus、ProphetNet、Marge 等,但模型架构基本保持不变。
本博文旨在详细解释基于 Transformer 的编码器-解码器架构是如何为*序列到序列(sequence-to-sequence)*问题建模的。我们将重点关注该架构定义的数学模型,以及如何在推理过程中使用该模型。在此过程中,我们会介绍一些 NLP 领域序列到序列模型的背景知识,并将基于 Transformer 的编码器-解码器架构分解为编码器和解码器两部分进行剖析。我们提供了大量插图,并将基于 Transformer 的编码器-解码器模型的理论与其在 🤗Transformers 中的推理实践联系起来。请注意,本博文不解释如何训练此类模型——这将是未来一篇博文的主题。
基于 Transformer 的编码器-解码器模型是多年来在表示学习和模型架构方面研究的成果。本 notebook 简要总结了神经编码器-解码器模型的历史。若想了解更多背景,建议读者阅读 Sebastion Ruder 这篇精彩的博文。此外,建议读者对自注意力架构有基本了解。Jay Alammar 的这篇博文可以很好地帮助大家回顾原始的 Transformer 模型。
在撰写本 notebook 时,🤗Transformers 包含了 T5、Bart、MarianMT 和 Pegasus 等编码器-解码器模型,相关文档的模型总结部分对此有概述。
本 notebook 分为四个部分:
- 背景 - 简要介绍神经编码器-解码器模型的历史,重点关注基于 RNN 的模型。
- 编码器-解码器 - 介绍基于 Transformer 的编码器-解码器模型,并解释该模型如何用于推理。
- 编码器 - 详细解释模型的编码器部分。
- 解码器 - 详细解释模型的解码器部分。
每个部分都建立在前一部分的基础上,但也可以独立阅读。
背景
自然语言生成(NLG)是 NLP 的一个子领域,其中的任务最适合用序列到序列问题来表达。这类任务可以定义为找到一个能将输入词序列映射到目标词序列的模型。一些经典例子是摘要和翻译。下文中,我们假设每个词都被编码成一个向量表示。 个输入词因此可以表示为一个由 个输入向量组成的序列:
因此,序列到序列问题可以通过寻找一个从 个向量的输入序列 到 个目标向量的序列 的映射 来解决,其中目标向量的数量 是事先未知的,并取决于输入序列:
Sutskever 等人 (2014) 指出,深度神经网络 (DNN),“*尽管具有灵活性和强大能力,但只能定义一种输入和目标都能用固定维度向量合理编码的映射。*”
使用 DNN 模型 解决序列到序列问题将意味着目标向量的数量 必须是*事先*已知的,并且必须独立于输入 。这并非最优,因为对于 NLG 任务,目标词的数量通常取决于输入 的内容,而不仅仅是输入长度 。例如,一篇 1000 词的文章可以被总结为 200 词,也可以是 100 词,这取决于其内容。
2014 年,Cho 等人和 Sutskever 等人提出使用纯粹基于循环神经网络 (RNN) 的编码器-解码器模型来处理*序列到序列*任务。与 DNN 不同,RNN 能够对映射到可变数量目标向量的问题进行建模。让我们更深入地了解一下基于 RNN 的编码器-解码器模型的工作原理。
在推理过程中,编码器 RNN 通过相继更新其*隐藏状态* 来编码一个输入序列 。在处理完最后一个输入向量 后,编码器的隐藏状态定义了输入编码 。因此,编码器定义了如下映射:
然后,用输入编码初始化解码器的隐藏状态,在推理过程中,解码器 RNN 被用来以自回归的方式生成目标序列。下面我们来解释一下。
在数学上,解码器定义了在给定隐藏状态 的情况下,目标序列 的概率分布:
根据贝叶斯法则,该分布可以分解为单个目标向量的条件分布,如下所示:
因此,如果该架构能够对给定所有先前目标向量的下一个目标向量的条件分布进行建模:
那么它就可以通过简单地将所有条件概率相乘来对给定隐藏状态 的任何目标向量序列的分布进行建模。
那么,基于 RNN 的解码器架构是如何对 进行建模的呢?
在计算上,模型将前一个内部隐藏状态 和前一个目标向量 依次映射到当前的内部隐藏状态 和一个 logit 向量 (下图中用深红色表示):
这里的 被定义为 ,即基于 RNN 的编码器的输出隐藏状态。随后,使用 softmax 操作将 logit 向量 转换为下一个目标向量的条件概率分布:
有关 logit 向量和最终概率分布的更多细节,请参见脚注。从上面的方程我们可以看出,当前目标向量 的分布直接以先前目标向量 和先前隐藏状态 为条件。因为先前隐藏状态 依赖于所有先前的目标向量 ,可以说基于 RNN 的解码器是*隐式地*(即*间接地*)对条件分布 进行建模。
可能的目标向量序列 的空间非常大,以至于在推理时,必须依赖解码方法 来有效地从 中采样高概率的目标向量序列。
给定这样的解码方法,在推理过程中,下一个输入向量 可以从 中采样,然后被追加到输入序列中,这样解码器 RNN 就可以对 进行建模,以*自回归*的方式采样下一个输入向量 ,依此类推。
基于 RNN 的编码器-解码器模型的一个重要特点是定义了*特殊*向量,例如 和 向量。 向量通常表示最后的输入向量 ,用于“提示”编码器输入序列已结束,并且也定义了目标序列的结尾。一旦从 logit 向量中采样得到 ,生成过程就完成了。 向量表示在解码的第一步输入到解码器 RNN 的输入向量 。为了输出第一个 logit ,需要一个输入,但由于第一步还没有生成任何输入,因此将一个特殊的 输入向量送入解码器 RNN。好吧 - 相当复杂!让我们通过一个例子来阐述和逐步分析。
展开的 RNN 编码器用绿色表示,展开的 RNN 解码器用红色表示。
英文句子 "I want to buy a car",表示为 、、、、、 和 被翻译成德语:"Ich will ein Auto kaufen",定义为 、、、、 以及 。首先,输入向量 由编码器 RNN 处理并更新其隐藏状态。请注意,因为我们只对编码器最终的隐藏状态 感兴趣,所以我们可以忽略 RNN 编码器的目标向量。然后编码器 RNN 以同样的方式处理输入句子的其余部分 , , , , , ,在每一步都更新其隐藏状态,直到到达向量 。在上图中,连接展开的编码器 RNN 的水平箭头表示隐藏状态的顺序更新。编码器 RNN 的最终隐藏状态由 表示,它完全定义了输入序列的*编码*,并用作解码器 RNN 的初始隐藏状态。这可以看作是在编码后的输入上*调节*解码器 RNN。
为了生成第一个目标向量,解码器接收 向量,在上图设计中表示为 。然后,RNN 的目标向量通过*语言模型头部 (LM Head)* 前馈层进一步映射到 logit 向量 ,以定义第一个目标向量的条件分布,如上所述
单词 被采样(由连接 和 的灰色箭头表示),因此可以对第二个目标向量进行采样
以此类推,直到第 步,从 中采样得到 向量,解码过程结束。最终的目标序列为 ,在我们上面的例子中即 "Ich will ein Auto kaufen"。
总而言之,一个基于 RNN 的编码器-解码器模型,由 和 表示,它通过分解的方式定义了分布
在推理过程中,高效的解码方法可以自回归地生成目标序列 。
基于 RNN 的编码器-解码器模型席卷了 NLG 社区。2016 年,谷歌宣布将完全用一个基于 RNN 的编码器-解码器模型来取代其经过大量特征工程的翻译服务(参见此处)。
尽管如此,基于 RNN 的编码器-解码器模型有两个缺陷。首先,RNN 存在梯度消失问题,这使得捕捉长程依赖关系变得非常困难,参见 Hochreiter et al. (2001)。其次,RNN 固有的循环架构在编码时阻碍了有效的并行化,参见 Vaswani et al. (2017)。
论文中的原话是“*尽管 DNN 具有灵活性和强大能力,但它们只能应用于输入和目标可以被合理地编码为固定维度向量的问题*”,此处略有改动。
对于卷积神经网络 (CNN),情况也基本相同。虽然可变长度的输入序列可以被送入 CNN,但目标的维度将始终依赖于输入的维度或固定为特定值。
在第一步,隐藏状态被初始化为零向量,并与第一个输入向量 一起送入 RNN。
神经网络可以定义所有单词的概率分布,即 ,过程如下。首先,网络定义一个从输入 到嵌入向量表示 的映射,这对应于 RNN 的目标向量。然后将嵌入向量表示 传递给“语言模型头部 (language model head)”层,这意味着它将与*词嵌入矩阵*相乘,即 ,从而计算出 与每个编码向量 之间的分数。得到的向量称为 logit 向量 ,它可以通过应用 softmax 操作映射到所有单词的概率分布:。
集束搜索解码 (Beam-search decoding) 就是这样一种解码方法的例子。不同的解码方法超出了本笔记的范围。建议读者参考这篇关于解码方法的交互式笔记。
Sutskever 等人 (2014) 的工作将输入序列进行了反转,因此在上面的示例中,输入向量将对应于 、、、、、 以及 。这样做的动机是为了让对应的词对(例如 和 )之间建立更短的连接。该研究小组强调,反转输入序列是其模型在机器翻译任务上性能提升的一个关键原因。
编码器-解码器
2017 年,Vaswani 等人引入了 Transformer,从而催生了*基于 Transformer* 的编码器-解码器模型。
与基于 RNN 的编码器-解码器模型类似,基于 Transformer 的编码器-解码器模型也由一个编码器和一个解码器组成,两者都是*残差注意力块 (residual attention blocks)* 的堆叠。基于 Transformer 的编码器-解码器模型的关键创新在于,这种残差注意力块可以在不表现出循环结构的情况下处理可变长度 的输入序列 。不依赖循环结构使得基于 Transformer 的编码器-解码器能够高度并行化,这让该模型在现代硬件上的计算效率比基于 RNN 的编码器-解码器模型高出几个数量级。
提醒一下,为了解决*序列到序列*问题,我们需要找到一个从输入序列 到可变长度 的输出序列 的映射。让我们看看如何使用基于 Transformer 的编码器-解码器模型来找到这种映射。
与基于 RNN 的编码器-解码器模型类似,基于 Transformer 的编码器-解码器模型定义了给定输入序列 的目标向量 的条件分布:
基于 Transformer 的编码器部分将输入序列 编码为一个*隐藏状态序列* ,从而定义了映射
然后,基于 Transformer 的解码器部分对给定编码隐藏状态序列 的目标向量序列 进行条件概率分布建模
根据贝叶斯法则,这个分布可以被分解为目标向量 在给定编码隐藏状态 和所有先前目标向量 的条件概率分布的乘积:
在此,基于 Transformer 的解码器将编码的隐藏状态序列 和所有先前已生成的目标向量 映射到 *logit* 向量 。然后,logit 向量 经过 *softmax* 操作处理,以定义条件分布 ,就像对基于 RNN 的解码器所做的那样。然而,与基于 RNN 的解码器不同,目标向量 的分布*明确地*(或直接地)以所有先前的目标向量 为条件,我们稍后会更详细地看到这一点。第 0 个目标向量 在这里由一个特殊的“句子开始” 向量表示。
定义了条件分布 后,我们现在可以在推理时*自回归地*生成输出,从而定义从输入序列 到输出序列 的映射。
让我们将基于 Transformer 的编码器-解码器模型的*自回归*生成全过程可视化。
基于 Transformer 的编码器以绿色显示,基于 Transformer 的解码器以红色显示。与上一节一样,我们展示了英语句子“I want to buy a car”如何被翻译成德语“Ich will ein Auto kaufen”。该英语句子由 、、、、、 和 表示,德语翻译则由 、、、、 和 定义。
首先,编码器处理完整的输入序列 = "I want to buy a car"(由浅绿色向量表示),将其转换为一个带上下文的编码序列 。例如, 定义了一个编码,该编码不仅依赖于输入 = "buy",还依赖于所有其他词 "I", "want", "to", "a", "car" 和 "EOS",即上下文。
接下来,输入编码 和 BOS 向量,即 ,一起被送入解码器。解码器处理输入 和 ,得到第一个 logit (以深红色显示),以定义第一个目标向量 的条件分布
接下来,从分布中采样第一个目标向量 = (由灰色箭头表示),现在可以再次将其送入解码器。解码器现在处理 = "BOS" 和 = "Ich" 来定义第二个目标向量 的条件分布
我们可以再次采样并生成目标向量 = "will"。我们以自回归的方式继续,直到在第 6 步从条件分布中采样到 EOS 向量
依此类推,以自回归的方式进行。
重要的是要理解,编码器仅在第一次前向传播中使用,用于将 映射到 。从第二次前向传播开始,解码器可以直接利用之前计算的编码 。为清楚起见,让我们为上面的示例说明第一次和第二次前向传播。
可以看出,只有在步骤 中,我们才需要将“I want to buy a car EOS”编码为 。在步骤 中,“I want to buy a car EOS”的上下文编码被解码器直接重用。
在 🤗Transformers 中,这种自回归生成是在调用 .generate()
方法时在幕后完成的。让我们使用我们的一个翻译模型来实际操作一下。
from transformers import MarianMTModel, MarianTokenizer
tokenizer = MarianTokenizer.from_pretrained("Helsinki-NLP/opus-mt-en-de")
model = MarianMTModel.from_pretrained("Helsinki-NLP/opus-mt-en-de")
# create ids of encoded input vectors
input_ids = tokenizer("I want to buy a car", return_tensors="pt").input_ids
# translate example
output_ids = model.generate(input_ids)[0]
# decode and print
print(tokenizer.decode(output_ids))
输出
<pad> Ich will ein Auto kaufen
调用 .generate()
在幕后做了很多事情。首先,它将 input_ids
传递给编码器。其次,它将一个预定义的标记(在 MarianMTModel
的情况下是 符号)连同编码后的 input_ids
一起传递给解码器。第三,它应用集束搜索(beam search)解码机制,根据最后一个解码器输出自回归地采样下一个输出词。有关集束搜索解码工作原理的更多详细信息,建议阅读这篇博客文章。
在附录中,我们提供了一个代码片段,展示了如何“从头开始”实现一个简单的生成方法。为了完全理解自回归生成在幕后是如何工作的,强烈建议阅读附录。
总结一下:
- 基于 Transformer 的编码器定义了从输入序列 到上下文编码序列 的映射。
- 基于 Transformer 的解码器定义了条件分布 。
- 给定一个合适的解码机制,输出序列 可以从 自回归地采样得到。
太好了,现在我们对基于 Transformer 的编码器-解码器模型的工作原理有了大致的了解,我们可以深入研究模型的编码器和解码器部分了。更具体地说,我们将看到编码器如何利用自注意力层来产生上下文相关的向量编码序列,以及自注意力层如何实现高效的并行化。然后,我们将详细解释自注意力层在解码器模型中是如何工作的,以及解码器如何通过交叉注意力层来依赖编码器的输出,以定义条件分布 。在此过程中,基于 Transformer 的编码器-解码器模型如何解决基于 RNN 的编码器-解码器模型的长程依赖问题将变得显而易见。
对于 "Helsinki-NLP/opus-mt-en-de"
,解码参数可以在这里访问,我们可以看到该模型使用了 num_beams=6
的集束搜索。
编码器
如上一节所述,基于 Transformer 的编码器将输入序列映射到上下文编码序列
仔细观察架构,基于 Transformer 的编码器是残差连接的编码器块的堆叠。每个编码器块由一个双向自注意力层和两个前馈层组成。为简单起见,我们在本笔记本中忽略归一化层。此外,我们将不再进一步讨论两个前馈层的作用,而只是将其视为每个编码器块中所需的最终向量到向量的映射。双向自注意力层将每个输入向量 与所有输入向量 相关联,通过这种方式将输入向量 转换为一个更“精炼”的自身上下文表示,定义为 。因此,第一个编码器块将输入序列 (下图中浅绿色所示)的每个输入向量从与上下文无关的向量表示转换为与上下文相关的向量表示,接下来的编码器块会进一步精炼这种上下文表示,直到最后一个编码器块输出最终的上下文编码 (下图中深绿色所示)。
让我们可视化编码器如何将输入序列“I want to buy a car EOS”处理成一个上下文编码序列。与基于 RNN 的编码器类似,基于 Transformer 的编码器也在输入序列中添加一个特殊的“序列结束”输入向量,以提示模型输入向量序列已结束。
我们的示例基于 Transformer 的编码器由三个编码器块组成,其中第二个编码器块在右侧的红色框中更详细地显示了前三个输入向量 。双向自注意力机制由红色框下部的全连接图表示,两个前馈层显示在红色框的上部。如前所述,我们将只关注双向自注意力机制。
可以看出,自注意力层的每个输出向量 都直接依赖于所有输入向量 。这意味着,例如,“want”这个词的输入向量表示,即 ,与“buy”这个词,即 建立了直接关系,同时也与“I”这个词,即 建立了关系。“want”的输出向量表示,即 ,因此为“want”这个词提供了一个更精细的上下文表示。
让我们来深入了解一下双向自注意力机制是如何工作的。对于编码器块中的输入序列 中的每个输入向量 ,会通过三个可训练的权重矩阵 分别投影成一个键向量 、一个值向量 和一个查询向量 (下图中分别以橙色、蓝色和紫色表示)。
注意,相同的权重矩阵被应用于每个输入向量 。在将每个输入向量 投影到查询向量、键向量和值向量之后,每个查询向量 会与所有的键向量 进行比较。键向量 中与查询向量 越相似,则其对应的值向量 对输出向量 越重要。更具体地说,一个输出向量 被定义为所有值向量 的加权和,再加上输入向量 。其中的权重与 和各自的键向量 之间的余弦相似度成正比,数学上表示为 ,如下面的方程所示。要全面了解自注意力层,建议读者阅读这篇博客文章或原始论文。
好的,这听起来相当复杂。让我们为上面例子中的一个查询向量来图解说明双向自注意力层。为简单起见,我们假设示例中基于 transformer 的解码器只使用一个注意力头 config.num_heads = 1
并且没有应用归一化。
左侧再次显示了之前图示的第二个编码器块,右侧则详细展示了针对第二个输入向量 (对应输入词“want”)的双向自注意力机制。首先,所有输入向量 被投影成各自的查询向量 (上图中仅显示前三个查询向量,以紫色表示)、值向量 (以蓝色表示)和键向量 (以橙色表示)。然后查询向量 与所有键向量的转置相乘,即 ,随后进行 softmax 操作以得到自注意力权重。最后,将自注意力权重与相应的值向量相乘,并加上输入向量 ,以输出“want”这个词的“精炼”表示,即 (右侧以深绿色显示)。整个方程在右侧方框的上半部分进行了图解。通过 和 的相乘,使得“want”的向量表示可以与所有其他输入词“I”、“to”、“buy”、“a”、“car”、“EOS”的向量表示进行比较,从而自注意力权重反映了其他每个输入向量表示 对于“want”的精炼表示 的重要性。
为了进一步理解双向自注意力层的影响,让我们假设处理以下句子:“The house is beautiful and well located in the middle of the city where it is easily accessible by public transport”(这所房子很漂亮,位置很好,在市中心,公共交通很方便)。单词“it”指的是“house”,两者相隔12个“位置”。在基于transformer的编码器中,双向自注意力层仅通过一次数学运算就能将“house”的输入向量与“it”的输入向量关联起来(可与本节第一个图示进行比较)。相比之下,在基于RNN的编码器中,一个相隔12个“位置”的词至少需要12次数学运算,这意味着在基于RNN的编码器中,需要线性数量的数学运算。这使得基于RNN的编码器更难建模长距离的上下文表示。此外,很明显,基于transformer的编码器比基于RNN的编解码器模型更不容易丢失重要信息,因为编码的序列长度保持不变,即 ,而RNN将长度从 压缩到仅为 ,这使得RNN很难有效地编码输入词之间的长距离依赖关系。
除了更容易学习长距离依赖关系外,我们还可以看到Transformer架构能够并行处理文本。从数学上讲,这可以通过将自注意力公式写成查询、键和值矩阵的乘积来轻松证明。
输出 是通过一系列矩阵乘法和一个可以有效并行化的 softmax 操作计算出来的。请注意,在基于 RNN 的编码器模型中,隐藏状态 的计算必须按顺序进行:计算第一个输入向量 的隐藏状态,然后计算第二个输入向量的隐藏状态,该状态依赖于第一个隐藏向量的隐藏状态,依此类推。RNN 的顺序性使其无法有效并行化,与基于 Transformer 的编码器模型相比,在现代 GPU 硬件上的效率要低得多。
很好,现在我们应该对 a) 基于 Transformer 的编码器模型如何有效建模长程上下文表示,以及 b) 它们如何高效处理长序列输入向量有了更好的理解。
现在,让我们编写一个我们 MarianMT
编码器-解码器模型中编码器部分的简短示例,以验证所解释的理论在实践中是否成立。
关于前馈层在基于 Transformer 的模型中所扮演角色的详细解释超出了本笔记的范围。在 Yun 等人 (2017) 的文章中指出,前馈层对于将每个上下文向量 单独映射到所需的输出空间至关重要,而*自注意力*层本身无法做到这一点。这里应该注意,每个输出标记 都由相同的前馈层处理。更多细节,建议读者阅读该论文。
然而,EOS 输入向量不必附加到输入序列中,但在许多情况下已被证明可以提高性能。与此相反,基于 Transformer 的解码器的第 0 个 目标向量是必需的,作为预测第一个目标向量的起始输入向量。
from transformers import MarianMTModel, MarianTokenizer
import torch
tokenizer = MarianTokenizer.from_pretrained("Helsinki-NLP/opus-mt-en-de")
model = MarianMTModel.from_pretrained("Helsinki-NLP/opus-mt-en-de")
embeddings = model.get_input_embeddings()
# create ids of encoded input vectors
input_ids = tokenizer("I want to buy a car", return_tensors="pt").input_ids
# pass input_ids to encoder
encoder_hidden_states = model.base_model.encoder(input_ids, return_dict=True).last_hidden_state
# change the input slightly and pass to encoder
input_ids_perturbed = tokenizer("I want to buy a house", return_tensors="pt").input_ids
encoder_hidden_states_perturbed = model.base_model.encoder(input_ids_perturbed, return_dict=True).last_hidden_state
# compare shape and encoding of first vector
print(f"Length of input embeddings {embeddings(input_ids).shape[1]}. Length of encoder_hidden_states {encoder_hidden_states.shape[1]}")
# compare values of word embedding of "I" for input_ids and perturbed input_ids
print("Is encoding for `I` equal to its perturbed version?: ", torch.allclose(encoder_hidden_states[0, 0], encoder_hidden_states_perturbed[0, 0], atol=1e-3))
输出
Length of input embeddings 7. Length of encoder_hidden_states 7
Is encoding for `I` equal to its perturbed version?: False
我们比较输入词嵌入的长度,即 embeddings(input_ids)
,它对应于 ,与 encoder_hidden_states
的长度,它对应于 。此外,我们还将词序列“I want to buy a car”和其微扰版本“I want to buy a house”通过编码器传递,以检查当输入序列中仅最后一个词发生改变时,第一个输出编码(对应于“I”)是否会不同。
正如预期的那样,输入词嵌入的输出长度和编码器输出编码的长度,即 和 是相等的。其次,可以注意到,当最后一个词从 "car" 变为 "house" 时, 的编码输出向量的值是不同的。然而,如果理解了双向自注意力,这应该不足为奇。
附带一提,诸如 BERT 之类的*自编码*模型与*基于 Transformer 的*编码器模型具有完全相同的架构。*自编码*模型利用这种架构在开放领域的文本数据上进行大规模的自监督预训练,从而可以将任何词序列映射到深层的双向表示。在 Devlin 等人 (2018) 的研究中,作者表明,一个预训练的 BERT 模型,在其之上增加一个单一的特定任务分类层,可以在十一项 NLP 任务上取得最先进的结果。🤗Transformers 的所有*自编码*模型都可以在这里找到。
解码器
如*编码器-解码器*部分所述,*基于 Transformer 的*解码器定义了在给定上下文化编码序列的情况下目标序列的条件概率分布
根据贝叶斯法则,可以将其分解为下一个目标向量的条件分布的乘积,条件是上下文化编码序列和所有先前的目标向量
首先,让我们了解一下基于 Transformer 的解码器如何定义概率分布。基于 Transformer 的解码器是一堆*解码器块*,其后跟一个密集层,即“LM 头”。解码器块堆栈将上下文化编码序列 和一个以 向量为前缀并截断到最后一个目标向量的目标向量序列(即 )映射到一个编码的目标向量序列 。然后,“LM 头”将编码的目标向量序列 映射到一个 logit 向量序列 ,其中每个 logit 向量 的维度对应于词汇表的大小。这样,对于每个 ,通过对 应用 softmax 操作,可以获得整个词汇表的概率分布。这些分布定义了条件分布
。“LM 头”通常与词嵌入矩阵的转置绑定,即 。直观地说,这意味着对于所有 ,“LM 头”层将编码的输出向量 与词汇表中的所有词嵌入 进行比较,因此 logit 向量 表示编码输出向量与每个词嵌入之间的相似度分数。softmax 操作只是将相似度分数转换为概率分布。对于每个 ,以下等式成立
总而言之,为了对目标向量序列 的条件分布进行建模,目标向量 会前置一个特殊的 向量,即 ,然后首先与经过上下文处理的编码序列 一起映射到对数向量序列 。因此,每个对数目标向量 使用 softmax 操作被转换为目标向量 的条件概率分布。最后,所有目标向量 的条件概率相乘以得到完整目标向量序列的条件概率。
与基于 transformer 的编码器相比,在基于 transformer 的解码器中,编码后的输出向量 应该是对下一个目标向量 的良好表示,而不是对输入向量本身的表示。此外,编码后的输出向量 应该以所有经过上下文处理的编码序列 为条件。为满足这些要求,每个解码器块都包含一个单向自注意力层,其后是一个交叉注意力层和两个前馈层 与所有先前的输入向量 相关联,以对所有 。单向自注意力层仅将其每个输入向量 的下一个目标向量的概率分布进行建模。交叉注意力层将其每个输入向量 与所有经过上下文处理的编码向量 相关联,从而也以编码器的输入为条件来确定下一个目标向量的概率分布。
好的,让我们为我们的英译德翻译示例将基于 transformer 的解码器可视化。
我们可以看到,解码器将输入 “BOS”、“Ich”、“will”、“ein”、“Auto”、“kaufen”(以浅红色显示)与 “I”、“want”、“to”、“buy”、“a”、“car”、“EOS” 的上下文序列(即 ,以深绿色显示)一起映射到对数向量 (以深红色显示)。
对每个 应用 softmax 操作,可以定义条件概率分布
总的条件概率
因此可以计算为以下乘积
右边的红色方框显示了前三个目标向量 的解码器块。下部展示了单向自注意力机制,中部展示了交叉注意力机制。让我们首先关注单向自注意力。
与双向自注意力一样,在单向自注意力中,查询向量 (下图紫色所示)、键向量 (下图橙色所示)和值向量 (下图蓝色所示)都是从它们各自的输入向量 (下图浅红色所示)投影得到的。然而,在单向自注意力中,每个查询向量 仅与其各自的键向量和所有先前的键向量(即 )进行比较,以产生各自的注意力权重。这可以防止输出向量 (下图深红色所示)包含有关后续输入向量 的任何信息,其中 。与双向自注意力一样,注意力权重随后会与它们各自的值向量相乘并求和。
我们可以将单向自注意力总结如下
请注意,键向量和值向量的索引范围是 而不是 ,后者是双向自注意力中键向量的范围。
让我们为上面示例中的输入向量 演示一下单向自注意力。
可以看出,仅取决于和。因此,我们将单词“Ich”的向量表示,即,仅与它自身和“BOS”目标向量(即)建立关系,而不与单词“will”的向量表示(即)建立关系。
那么,为什么在解码器中使用单向自注意力而不是双向自注意力如此重要呢?如上所述,基于Transformer的解码器定义了一个从输入向量序列到对应下一个解码器输入向量的对数(logits)的映射,即。在我们的例子中,这意味着,例如,输入向量 = "Ich"被映射到对数向量,然后用于预测输入向量。因此,如果可以访问后续的输入向量,解码器就会简单地复制“will”的向量表示,即,作为其输出。这将被前馈到最后一层,使得编码后的输出向量基本上只对应于向量表示。
这显然是不利的,因为基于Transformer的解码器将永远学不会根据之前所有的词来预测下一个词,而只是将目标向量通过网络复制到,对于所有的。为了定义下一个目标向量的条件分布,该分布不能以下一个目标向量本身为条件。从来预测是没有意义的,因为这个分布以它本应建模的目标向量为条件。因此,单向自注意力架构使我们能够定义一个因果概率分布,这对于有效地建模下一个目标向量的条件分布是必要的。
太好了!现在我们可以转向连接编码器和解码器的层——交叉注意力机制!
交叉注意力层接收两个向量序列作为输入:单向自注意力层的输出,即,以及上下文编码向量。与自注意力层一样,查询向量是前一层输出向量的投影,即。然而,键向量和值向量是上下文编码向量的投影。定义了键、值和查询向量后,查询向量会与所有键向量进行比较,相应的分数用于加权各自的值向量,就像双向自注意力一样,以得到输出向量,对于所有的。交叉注意力可以总结如下:
请注意,键和值向量的索引范围是,对应于上下文编码向量的数量。
让我们以上述示例中的输入向量为例,可视化交叉注意力机制。
我们可以看到,查询向量(紫色所示)源自(红色所示),因此它依赖于单词“Ich”的向量表示。然后,查询向量与键向量(黄色所示)进行比较,这些键向量对应所有编码器输入向量 = "I want to buy a car EOS"的上下文编码表示。这将“Ich”的向量表示与所有编码器输入向量直接关联起来。最后,注意力权重与值向量(绿松石色所示)相乘,除了输入向量之外,还得到输出向量(深红色所示)。
那么,直观上这里到底发生了什么?每个输出向量是编码器所有输入值投影的加权和,再加上输入向量本身(参见上图公式)。需要理解的关键机制如下:输入解码器向量的查询投影与编码器输入向量的键投影的相似度越高,编码器输入向量的值投影就越重要。通俗地说,这意味着解码器输入表示与编码器输入表示越“相关”,该输入表示对解码器输出表示的影响就越大。
酷!现在我们可以看到,该架构如何很好地将每个输出向量 的生成条件,设置为编码器输入向量 与输入向量 之间的交互。此时另一个重要的观察是,该架构完全独立于上下文编码向量 的数量 ,而输出向量 的生成是以其为条件的。所有用于派生键向量 和值向量 的投影矩阵 和 在所有位置 之间共享,并且所有值向量 被求和为一个加权平均向量。现在,为什么基于 Transformer 的解码器不会遇到基于 RNN 的解码器所面临的长期依赖问题,也变得显而易见了。因为每个解码器 logit 向量都 *直接* 依赖于每个编码输出向量,所以比较第一个编码输出向量和最后一个解码器 logit 向量所需的数学运算基本上只需要一步。
总而言之,单向自注意力层负责将每个输出向量的生成条件,设置为所有先前的解码器输入向量和当前输入向量;而交叉注意力层则负责进一步将每个输出向量的生成条件,设置为所有编码后的输入向量。
为了验证我们的理论理解,让我们继续上面编码器部分的编码示例。
词嵌入矩阵 为每个输入词提供一个唯一的、*与上下文无关的* 向量表示。该矩阵通常被固定为“LM Head”层。然而,“LM Head”层完全可以由一个完全独立的“编码向量到 logit”的权重映射组成。
同样,详细解释前馈层在基于 Transformer 的模型中扮演的角色超出了本笔记的范围。在 Yun 等人 (2017) 的论文中,他们认为前馈层对于将每个上下文向量 单独映射到所需的输出空间至关重要,而*自注意力*层本身无法做到这一点。这里需要注意的是,每个输出词元 都由相同的前馈层处理。更多细节,建议读者阅读该论文。
from transformers import MarianMTModel, MarianTokenizer
import torch
tokenizer = MarianTokenizer.from_pretrained("Helsinki-NLP/opus-mt-en-de")
model = MarianMTModel.from_pretrained("Helsinki-NLP/opus-mt-en-de")
embeddings = model.get_input_embeddings()
# create token ids for encoder input
input_ids = tokenizer("I want to buy a car", return_tensors="pt").input_ids
# pass input token ids to encoder
encoder_output_vectors = model.base_model.encoder(input_ids, return_dict=True).last_hidden_state
# create token ids for decoder input
decoder_input_ids = tokenizer("<pad> Ich will ein", return_tensors="pt", add_special_tokens=False).input_ids
# pass decoder input ids and encoded input vectors to decoder
decoder_output_vectors = model.base_model.decoder(decoder_input_ids, encoder_hidden_states=encoder_output_vectors).last_hidden_state
# derive embeddings by multiplying decoder outputs with embedding weights
lm_logits = torch.nn.functional.linear(decoder_output_vectors, embeddings.weight, bias=model.final_logits_bias)
# change the decoder input slightly
decoder_input_ids_perturbed = tokenizer("<pad> Ich will das", return_tensors="pt", add_special_tokens=False).input_ids
decoder_output_vectors_perturbed = model.base_model.decoder(decoder_input_ids_perturbed, encoder_hidden_states=encoder_output_vectors).last_hidden_state
lm_logits_perturbed = torch.nn.functional.linear(decoder_output_vectors_perturbed, embeddings.weight, bias=model.final_logits_bias)
# compare shape and encoding of first vector
print(f"Shape of decoder input vectors {embeddings(decoder_input_ids).shape}. Shape of decoder logits {lm_logits.shape}")
# compare values of word embedding of "I" for input_ids and perturbed input_ids
print("Is encoding for `Ich` equal to its perturbed version?: ", torch.allclose(lm_logits[0, 0], lm_logits_perturbed[0, 0], atol=1e-3))
输出
Shape of decoder input vectors torch.Size([1, 5, 512]). Shape of decoder logits torch.Size([1, 5, 58101])
Is encoding for `Ich` equal to its perturbed version?: True
我们将解码器输入词嵌入的输出形状,即 `embeddings(decoder_input_ids)`(对应于 ,这里 `<pad>` 对应于 BOS,“Ich will das” 被分词为 4 个词元)与 `lm_logits` 的维度(对应于 )进行比较。此外,我们还将词序列“`<pad>` Ich will ein”和稍微扰动过的版本“`<pad>` Ich will das”与 `encoder_output_vectors` 一起传递给解码器,以检查当输入序列中只有最后一个词被改变时(“ein” -> “das”),第二个 `lm_logit`(对应于“Ich”)是否会不同。
正如预期的那样,解码器输入词嵌入和 lm_logits 的输出形状,即 和 的维度在最后一个维度上是不同的。虽然序列长度相同(=5),但解码器输入词嵌入的维度对应于 `model.config.hidden_size`,而 `lm_logit` 的维度对应于词汇表大小 `model.config.vocab_size`,如上所述。其次,可以注意到,当最后一个词从“ein”变为“das”时, 的编码输出向量的值是相同的。然而,如果理解了单向自注意力,这应该不足为奇。
最后,顺便提一下,像 GPT2 这样的 *自回归* 模型,其架构与基于 *Transformer* 的解码器模型相同,**前提是** 去掉交叉注意力层,因为独立的自回归模型不依赖任何编码器输出。所以自回归模型本质上与 *自编码* 模型相同,只是用单向注意力取代了双向注意力。这些模型也可以在海量开放域文本数据上进行预训练,以在自然语言生成(NLG)任务上展现出令人印象深刻的性能。在 Radford 等人 (2019) 的论文中,作者们展示了一个预训练的 GPT2 模型可以在各种 NLG 任务上取得 SOTA 或接近 SOTA 的结果,而无需太多微调。所有 🤗Transformers 的 *自回归* 模型都可以在这里找到。
好了,就是这样!现在,您应该对基于 *Transformer* 的编码器-解码器模型以及如何使用 🤗Transformers 库有了很好的理解。
非常感谢 Victor Sanh、Sasha Rush、Sam Shleifer、Oliver Åstrand、Ted Moskovitz 和 Kristian Kyvik 提供了宝贵的反馈。
附录
如上所述,以下代码片段展示了如何为基于 *Transformer* 的编码器-解码器模型编写一个简单的生成方法。在这里,我们使用 `torch.argmax` 实现了一种简单的 *贪心* 解码方法来对目标向量进行采样。
from transformers import MarianMTModel, MarianTokenizer
import torch
tokenizer = MarianTokenizer.from_pretrained("Helsinki-NLP/opus-mt-en-de")
model = MarianMTModel.from_pretrained("Helsinki-NLP/opus-mt-en-de")
# create ids of encoded input vectors
input_ids = tokenizer("I want to buy a car", return_tensors="pt").input_ids
# create BOS token
decoder_input_ids = tokenizer("<pad>", add_special_tokens=False, return_tensors="pt").input_ids
assert decoder_input_ids[0, 0].item() == model.config.decoder_start_token_id, "`decoder_input_ids` should correspond to `model.config.decoder_start_token_id`"
# STEP 1
# pass input_ids to encoder and to decoder and pass BOS token to decoder to retrieve first logit
outputs = model(input_ids, decoder_input_ids=decoder_input_ids, return_dict=True)
# get encoded sequence
encoded_sequence = (outputs.encoder_last_hidden_state,)
# get logits
lm_logits = outputs.logits
# sample last token with highest prob
next_decoder_input_ids = torch.argmax(lm_logits[:, -1:], axis=-1)
# concat
decoder_input_ids = torch.cat([decoder_input_ids, next_decoder_input_ids], axis=-1)
# STEP 2
# reuse encoded_inputs and pass BOS + "Ich" to decoder to second logit
lm_logits = model(None, encoder_outputs=encoded_sequence, decoder_input_ids=decoder_input_ids, return_dict=True).logits
# sample last token with highest prob again
next_decoder_input_ids = torch.argmax(lm_logits[:, -1:], axis=-1)
# concat again
decoder_input_ids = torch.cat([decoder_input_ids, next_decoder_input_ids], axis=-1)
# STEP 3
lm_logits = model(None, encoder_outputs=encoded_sequence, decoder_input_ids=decoder_input_ids, return_dict=True).logits
next_decoder_input_ids = torch.argmax(lm_logits[:, -1:], axis=-1)
decoder_input_ids = torch.cat([decoder_input_ids, next_decoder_input_ids], axis=-1)
# let's see what we have generated so far!
print(f"Generated so far: {tokenizer.decode(decoder_input_ids[0], skip_special_tokens=True)}")
# This can be written in a loop as well.
输出
Generated so far: Ich will ein
在这个代码示例中,我们展示的正是前面描述的内容。我们将输入“I want to buy a car”与 词元一起传递给编码器-解码器模型,并从第一个 logit (即 `lm_logits` 的第一行)进行采样。在这里,我们的采样策略很简单:贪心地选择概率最高的下一个解码器输入向量。然后,我们以自回归的方式,将采样到的解码器输入向量与之前的输入一起传递给编码器-解码器模型,并再次采样。我们重复这个过程第三次。结果,模型生成了“Ich will ein”这几个词。结果非常准确——这是输入正确翻译的开头部分。
在实践中,会使用更复杂的解码方法来对 `lm_logits` 进行采样。其中大部分方法都在这篇博客文章中有所介绍。