第九章:一种可堆叠的深度学习(transformers)
本章内容包括
- 理解 transformers 如此强大的原因。
- 看看 transformers 如何为自然语言处理提供无限的“堆叠”选项。
- 编码文本以创建有意义的向量表示。
- 解码语义向量以生成文本。
- 为你的应用程序对 transformers(BERT、GPT)进行微调。
- 将 transformers 应用于长文档的抽取和摘要概括。
- 使用 transformers 生成语法正确且有趣的文本。
- 估算 transformer 网络为特定问题所需的信息容量。
Transformers正在改变世界。transformers 为人工智能带来的增强智能正在改变文化、社会和经济。transformers 首次让我们质疑人类智能和创造力的长期经济价值。而 transformers 的涟漪效应不仅仅限于经济。transformers 不仅正在改变我们的工作和娱乐方式,甚至还在改变我们的思维、沟通和创造方式。在不到一年的时间里,被称为大型语言模型(LLMs)的 transformer 启用的人工智能创造了全新的工作类别,如prompt engineering和实时内容策划与事实核查(grounding)。科技公司正在争相招募能够设计有效的 LLM 提示并将 LLMs 纳入其工作流程的工程师。transformers 正在自动化和加速信息经济工作的生产力,而这些工作以前需要机器无法达到的创造力和抽象水平。
随着 transformers 自动化越来越多的信息经济任务,工作者们开始重新考虑他们的工作是否像他们想象的那样对雇主至关重要。例如,有影响力的网络安全专家每天都在吹嘘,他们正在借助 ChatGPT 的数十个建议来增强他们的思维、规划和创造力。2020 年,微软新闻和 MSN.com 网站裁员了其新闻记者,用能够自动策划和摘要新闻文章的 transformer 模型取代了他们。这场(内容质量阶梯的)竞赛可能不会对媒体公司或他们的广告客户和员工带来好的结果。
在本章中,你将学习如何使用transformers来提高自然语言文本的准确性和思考性。即使你的雇主试图用编程来替代你的工作,你也会知道如何使用 transformers 来为自己创造新的机会。编程还是被编程。自动化还是被自动化。
并且 transformers 不仅是自然语言生成的最佳选择,也是自然语言理解的最佳选择。任何依赖于意义向量表示的系统都可以从 transformers 中受益。
- 有一段时间,Replika 使用GPT-3来生成超过 20%的回复。
- Qary 使用 BERT 生成开放域问题答案。
- 谷歌使用基于BERT的模型来改进搜索结果并查询知识图谱。
nboost
使用变压器为 ElasticSearch 创建语义搜索代理- aidungeon.io 使用 GPT-3 生成无尽种类的房间
- 大多数语义搜索的向量数据库都依赖于变压器。^([2])
即使你只想精通提示工程,你对变压器的理解也将帮助你设计避开 LLM(大型语言模型)能力缺陷的 LLM 提示。而 LLM 充满漏洞,以至于工程师和统计学家在思考 LLM 失败时常常使用瑞士奶酪模型。^([3]) LLM 的会话界面使学习如何诱导尖刻的对话人工智能系统做出有价值的工作变得容易。了解 LLM 如何工作并能够为自己的应用程序微调它们的人,将掌握一台强大的机器。想象一下,如果你能够构建一个能够帮助学生解决算术和数学问题的“TutorGPT”,那么你将会受到多么追捧。基加利的 Rising Academies 的 Shabnam Aggarwal 正用她的 Rori.AI WhatsApp 数学辅导机器人帮助中学生做到这一点。^([4]) ^([5]) 而 Vishvesh Bhat 则将这一点做为自己的激情项目帮助大学数学学生做到了。^([6]) ^([7])
9.1 递归 vs 循环
Transformers 是自回归式自然语言处理模型中的最新重大进展。自回归模型一次预测一个离散输出值,通常是自然语言文本中的一个标记或词语。自回归模型将输出循环利用作为输入来预测下一个输出,因此自回归神经网络是递归的。词语“递归”是一个通用术语,用于描述将输出再次引入输入的任何循环过程,这个过程可以无限地继续,直到算法或计算“终止”。在计算机科学中,递归函数会一直调用自身,直到达到期望的结果。
但是,变压器的递归方式更大、更一般,而不像循环神经网络那样。变压器被称为递归NN,而不是循环NN,因为递归是一个更一般的术语,用于描述任何将输入循环利用的系统。^([8]) 术语循环专门用于描述像 LSTM 和 GRU 这样的 RNN,其中各个神经元将其输出循环到同一神经元的输入,以便在序列标记的每个步骤中进行。
Transformers 是一种递归算法,但不包含循环神经元。正如你在第八章学到的那样,循环神经网络在每个单个神经元或 RNN 单元内循环利用其输出。但是 Transformers 等待到最后一层才输出一个可以回收到输入中的令牌嵌入。整个 Transformer 网络,包括编码器和解码器,必须运行以预测每个令牌,以便该令牌可以用来帮助预测下一个。在计算机科学世界中,你可以看到 Transformer 是一个大的递归函数调用一系列非递归函数内部。整个 Transformer 递归运行以生成一个令牌。
因为 Transformer 内部没有循环,所以不需要“展开”。这使得 Transformers 比 RNN 有巨大优势。Transformer 中的单个神经元和层可以同时并行运行。对于 RNN,你必须按顺序依次运行神经元和层的函数。展开所有这些循环函数调用需要大量计算资源,并且必须按顺序执行。你不能跳过或并行运行它们。它们必须按顺序一直运行到整个文本的结尾。Transformer 将问题分解为一个更小的问题,一次预测一个令牌。这样,Transformer 的所有神经元都可以在 GPU 或多核 CPU 上并行运行,从而大大加快预测所需的时间。
他们使用最后预测的输出作为输入来预测下一个输出。但是 Transformers 是递归而不是循环的。循环神经网络(RNNs)包括变分自动编码器、RNNs、LSTMs 和 GRUs。当研究人员将五种自然语言处理思想结合起来创建 Transformer 架构时,他们发现总体能力远远超过其各部分之和。让我们详细看看这些思想。
9.1.1 注意力不是你所需要的全部
- 字节对编码(BPE):基于字符序列统计而不是空格和标点符号对单词进行标记化
- 注意力:使用连接矩阵(注意力)在长段文本中连接重要的单词模式
- 位置编码:跟踪令牌序列中每个令牌或模式的位置
字节对编码(Byte pair encoding,BPE)经常被忽视,是 transformer 的一种常见增强。BPE 最初是为了将文本编码成压缩的二进制(字节序列)格式而发明的。但是,当 BPE 被用作 NLP 流水线(如搜索引擎)中的分词器时,它真正展现了其作用。互联网搜索引擎通常包含数百万个词汇。想象一下搜索引擎预期要理解和索引的所有重要名称。BPE 可以有效地将您的词汇量减少几个数量级。典型的 transformer BPE 词汇量仅为 5000 个标记。当您为每个标记存储一个长的嵌入向量时,这是一件大事。一个在整个互联网上训练的 BPE 词汇表可以轻松适应典型笔记本电脑或 GPU 的 RAM 中。
注意力机制获得了 transformer 成功的大部分赞誉,因为它使其他部分成为可能。注意力机制比 CNN 和 RNN 的复杂数学(和计算复杂度)更简单。注意力机制消除了编码器和解码器网络的循环。因此,transformer 既没有 RNN 的梯度消失问题,也没有梯度爆炸问题。transformer 在处理的文本长度上受到限制,因为注意力机制依赖于每层的输入和输出的固定长度的嵌入序列。注意力机制本质上是一个跨越整个令牌序列的单个 CNN 核。注意力矩阵不是通过卷积或循环沿着文本滚动,而是简单地将其一次性乘以整个令牌嵌入序列。
transformer 中的循环丢失造成了一个新的挑战,因为 transformer 一次性操作整个序列。transformer 一次性读取整个令牌序列。而且它也一次性输出令牌,使得双向 transformer 成为一个明显的方法。transformer 在读取或写入文本时不关心令牌的正常因果顺序。为了给 transformer 提供关于令牌因果序列的信息,添加了位置编码。而且甚至不需要向量嵌入中的额外维度,位置编码通过将它们乘以正弦和余弦函数分散在整个嵌入序列中。位置编码使 transformer 对令牌的理解能够根据它们在文本中的位置进行微妙调整。有了位置编码,邮件开头的词“真诚”与邮件末尾的词“真诚”具有不同的含义。
限制令牌序列长度对效率改进产生了连锁反应,为变压器赋予了意外的强大优势:可扩展性。BPE 加上注意力和位置编码结合在一起,创造了前所未有的可扩展性。这三项创新和神经网络的简化结合在一起,创建了一个更易堆叠和更易并行化的网络。
- 堆叠性:变压器层的输入和输出具有完全相同的结构,因此它们可以堆叠以增加容量。
- 并行性:模板化的变压器层主要依赖于大型矩阵乘法,而不是复杂的递归和逻辑切换门。
变压器层的堆叠性与用于注意机制的矩阵乘法的可并行性相结合,创造了前所未有的可扩展性。当研究人员将其大容量变压器应用于他们能找到的最大数据集(基本上是整个互联网)时,他们感到惊讶。在极大的数据集上训练的极大变压器能够解决以前认为无法解决的 NLP 问题。聪明的人们开始认为,世界改变性的对话式机器智能(AGI)可能只有几年的时间,如果它已经存在的话。
9.1.2 关于一切的关注
你可能认为所有关于注意力强大之说都是无中生有。毕竟,变压器不仅仅是在输入文本的每个令牌上进行简单的矩阵乘法。变压器结合了许多其他不那么知名的创新,如 BPE、自监督训练和位置编码。注意力矩阵是所有这些想法之间的连接器,帮助它们有效地协同工作。注意力矩阵使变压器能够准确地建模长篇文本中所有单词之间的联系,一次完成。
与 CNN 和 RNN(LSTM 和 GRU)一样,变压器的每一层都为您提供了输入文本含义或思想的越来越深入的表示。但与 CNN 和 RNN 不同,变压器层的输出编码与之前的层大小和形状完全相同。同样,对于解码器,变压器层输出一个固定大小的嵌入序列,表示输出令牌序列的语义(含义)。一个变压器层的输出可以直接输入到下一个变压器层中,使层更加堆叠,而不是 CNN 的情况。每个层内的注意力矩阵跨越整个输入文本的长度,因此每个变压器层具有相同的内部结构和数学。您可以堆叠尽可能多的变压器编码器和解码器层,为数据的信息内容创建您所需的深度神经网络。
每个变压器层都输出一个一致的编码,大小和形状相同。编码只是嵌入,但是针对标记序列而不是单个标记。事实上,许多自然语言处理初学者将术语“编码”和“嵌入”视为同义词,但在本章之后,您将了解到它们之间的区别。作为名词使用的“嵌入”一词比“编码”更受欢迎 3 倍,但随着更多的人在学习变压器方面跟上你的步伐,情况将会改变。[9]
与所有向量一样,编码保持一致的结构,以便它们以相同的方式表示您的标记序列(文本)的含义。变压器被设计为接受这些编码向量作为其输入的一部分,以保持对文本前几层理解的“记忆”。这使您可以堆叠任意多层的变压器层,只要您有足够的训练数据来利用所有这些容量。这种“可伸缩性”使得变压器能够突破循环神经网络的收益递减上限。
由于注意力机制只是一个连接矩阵,因此可以将其实现为与 PyTorch Linear
层的矩阵乘法。当您在 GPU 或多核 CPU 上运行 PyTorch 网络时,矩阵乘法是并行化的。这意味着可以并行化更大的变压器,并且这些更大的模型可以训练得更快。堆叠性加上可并行化等于可扩展性。
变压器层被设计为具有相同大小和形状的输入和输出,以便变压器层可以像形状相同的乐高积木一样堆叠。吸引大多数研究人员注意力的变压器创新是注意力机制。如果您想要了解使变压器对自然语言处理和人工智能研究人员如此兴奋的原因,请从那里开始。与使用循环或卷积的其他深度学习自然语言处理架构不同,变压器架构使用堆叠的注意力层块,它们本质上是具有相同形状的全连接前馈层。
在第八章,您使用了循环神经网络来构建编码器和解码器以转换文本序列。在编码器-解码器(转码器或传导)网络中,[10]编码器处理输入序列中的每个元素,将句子提炼成一个固定长度的思想向量(或上下文向量)。然后,该思想向量可以传递给解码器,解码器将其用于生成一个新的标记序列。
编码器-解码器架构有一个很大的限制 —— 它无法处理更长的文本。如果一个概念或思想用多个句子或一个复杂的长句表达,那么编码的思想向量就无法准确地概括所有这些思想。 Bahdanau 等人提出的注意机制 ^([11]) 解决了这个问题,并显示出改善序列到序列性能,特别是对于长句子,但它并不能缓解循环模型的时间序列复杂性。
在“Attention Is All You Need”中引入的变压器架构推动了语言模型向前发展并进入了公众视野。变压器架构引入了几个协同特性,共同实现了迄今为止不可能的性能:
变压器架构中最广为人知的创新是自注意力。类似于 GRU 或 LSTM 中的记忆和遗忘门,注意机制在长输入字符串中创建概念和词模式之间的连接。
在接下来的几节中,你将学习变压器背后的基本概念,并查看模型的架构。然后,你将使用变压器模块的基本 PyTorch 实现来实现一个语言翻译模型,因为这是“Attention Is All You Need”中的参考任务,看看它在设计上是如何强大而优雅的。
自注意力
当我们写第一版这本书时,汉斯和科尔(第一版合著者)已经专注于注意机制。现在已经过去 6 年了,注意力仍然是深度学习中最研究的话题。注意机制为那些 LSTM 难以处理的问题的能力提升了一大步:
- 对话 —— 生成对话提示、查询或话语的合理响应。
- 抽象摘要或释义:: 生成长文本的新的较短措辞,总结句子、段落,甚至是数页的文本。
- 开放域问题回答:: 回答变压器曾经阅读过的关于任何事物的一般问题。
- 阅读理解问题回答:: 回答关于一小段文本(通常少于一页)的问题。
- 编码:: 单个向量或一系列嵌入向量,表示文本内容在向量空间中的含义 —— 有时被称为任务无关的句子嵌入。
- 翻译和代码生成 —— 基于纯英文程序描述生成合理的软件表达和程序。
自注意力是实现注意力的最直接和常见的方法。它接受嵌入向量的输入序列,并将它们通过线性投影处理。线性投影仅仅是点积或矩阵乘法。这个点积创建了键、值和查询向量。查询向量与键向量一起被用来为单词的嵌入向量和它们与查询的关系创建一个上下文向量。然后这个上下文向量被用来得到值的加权和。在实践中,所有这些操作都是在包含在矩阵中的查询、键和值的集合上进行的,分别是Q、K和V。
实现注意力算法的线性代数有两种方式:加性注意力或点积注意力。在 transformers 中效果最好的是点积注意力的缩放版本。对于点积注意力,查询向量Q和键向量K之间的数量积会根据模型中有多少维度而被缩小。这使得点积对于大尺寸嵌入和长文本序列更加稳定。以下是如何计算查询、键和值矩阵Q、K和V的自注意力输出。
方程式 9.1 自注意力输出
[Attention(Q, K, V ) = softmax(\frac{QK^{T}}{\sqrt{d_{k}}})V]
高维度点积会导致 softmax 中的梯度变小,这是由大数定律决定的。为了抵消这种效应,查询和键矩阵的乘积要被(\frac{1}{\sqrt{d_{k}}})缩放。softmax 对结果向量进行归一化,使它们都是正数且和为 1。这个“打分”矩阵然后与值矩阵相乘,得到图 9.1 中的加权值矩阵。^([13]) ^([14])
图 9.1 缩放点积注意力
与 RNN 不同,在自注意力中,查询、键和值矩阵中使用的所有向量都来自输入序列的嵌入向量。整个机制可以通过高度优化的矩阵乘法操作来实现。而Q K产品形成一个可以被理解为输入序列中单词之间连接的方阵。图 9.2 中展示了一个玩具例子。
图 9.2 作为单词连接的编码器注意力矩阵
多头自注意力
多头自注意力是将自注意力方法扩展为创建多个注意力头,每个头都关注文本中不同的词方面。因此,如果一个标记具有多个与输入文本解释相关的意义,那么它们可以分别在不同的注意力头中考虑到。你可以将每个注意力头视为文本编码向量的另一个维度,类似于单个标记的嵌入向量的附加维度(见第六章)。查询、键和值矩阵分别由不同的(d_q)、(d_k)和(d_v)维度乘以n(n_heads,注意力头的数量)次,以计算总的注意力函数输出。n_heads值是变压器架构的超参数,通常较小,可与变压器模型中的变压器层数相媲美。(d_v)维输出被连接,然后再次使用(W^o)矩阵进行投影,如下一个方程所示。
方程式 9.2 多头自注意力
[MultiHeadAttention(Q, K, V ) = Concat(head_1, …, head_n) W^o\ 其中\ head_i = Attention(QW_i^Q, KW_i^K, VW_i^V)]
多个头使得模型能够关注不同位置,而不仅仅是以单个词为中心的位置。这有效地创建了几个不同的向量子空间,其中变压器可以为文本中的词模式子集编码特定的泛化。在原始变压器论文中,模型使用n=8 个注意力头,使得(d_k = d_v = \frac{d_{model}}{n} = 64)。多头设置中的降维是为了确保计算和连接成本几乎等同于完整维度的单个注意力头的大小。
如果你仔细观察,你会发现由Q和K的乘积创建的注意力矩阵(注意力头)都具有相同的形状,它们都是方阵(行数与列数相同)。这意味着注意力矩阵仅将嵌入的输入序列旋转为新的嵌入序列,而不影响嵌入的形状或大小。这使得能够解释注意力矩阵对特定示例输入文本的作用。
图 9.3 多头自注意力
结果表明,多头注意力机制实际上只是一个全连接的线性层。毕竟,最深层的深度学习模型实质上只是线性和逻辑回归的巧妙堆叠。这就是为什么变压器如此成功令人惊讶的原因。这也是为什么理解前几章描述的线性和逻辑回归的基础知识是如此重要的原因。
9.2 填充注意力空白
注意机制弥补了前几章的 RNN 和 CNN 存在的一些问题,但也带来了一些额外的挑战。基于 RNN 的编码器-解码器在处理较长的文本段落时效果不佳,其中相关的单词模式相距甚远。即使是长句对于进行翻译的 RNN 来说也是一个挑战。[¹⁵] 注意机制通过允许语言模型在文本开头捕捉重要概念并将其连接到文本末尾的文本来弥补了这一点。注意机制使得变压器能够回溯到它曾经见过的任何单词。不幸的是,添加注意机制会迫使你从变压器中删除所有的循环。
CNN 是连接输入文本中相距甚远概念的另一种方法。CNN 可以通过创建一系列逐渐“缩颈”文本信息编码的卷积层来实现这一点。这种分层结构意味着 CNN 具有有关长文本文档中模式的大规模位置的信息。不幸的是,卷积层的输出和输入通常具有不同的形状。因此,CNN 不可叠加,这使得它们难以扩展以处理更大容量和更大训练数据集。因此,为了给变压器提供其需要的用于可堆叠的统一数据结构,变压器使用字节对编码和位置编码来在编码张量中均匀传播语义和位置信息。
9.2.1 位置编码
输入文本中的单词顺序很重要,因此你需要一种方法将一些位置信息嵌入到在变压器的各层之间传递的嵌入序列中。位置编码简单地是一个函数,它将一个单词在序列中的相对或绝对位置的信息添加到输入嵌入中。编码具有与输入嵌入相同的维度(d_{model}),因此它们可以与嵌入向量相加。论文讨论了学习的和固定的编码,并提出了一个以正弦和余弦为基础的正弦函数,具有不同的频率,定义为:
方程式 9.3 位置编码函数
[PE_{(pos, 2i)} = sin(\frac{pos}{10000^{\frac{2i}{d_{model}}}})\ PE_{(pos, 2i+1)} = cos(\frac{pos}{10000^{\frac{2i}{d_{model}}}})]
选择这个映射函数是因为对于任何偏移 k,(PE_{(pos+k)}) 可以表示为 (PE_{pos}) 的线性函数。简而言之,模型应该能够轻松地学会关注相对位置。
让我们看看这如何在 Pytorch 中编码。官方 Pytorch 序列到序列建模教程提供了基于前述函数的 PositionEncoding nn.Module 的实现:
清单 9.1 Pytorch 位置编码
>>> import math >>> import torch >>> from torch import nn ... >>> class PositionalEncoding(nn.Module): ... def __init__(self, d_model=512, dropout=0.1, max_len=5000): ... super().__init__() ... self.dropout = nn.Dropout(p=dropout) # #1 ... self.d_model = d_model # #2 ... self.max_len = max_len # #3 ... pe = torch.zeros(max_len, d_model) # #4 ... position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1) ... div_term = torch.exp(torch.arange(0, d_model, 2).float() * ... (-math.log(10000.0) / d_model)) ... pe[:, 0::2] = torch.sin(position * div_term) # #5 ... pe[:, 1::2] = torch.cos(position * div_term) ... pe = pe.unsqueeze(0).transpose(0, 1) ... self.register_buffer('pe', pe) ... ... def forward(self, x): ... x = x + self.pe[:x.size(0), :] ... return self.dropout(x)
你将在构建的翻译变压器中使用此模块。但是,首先,我们需要填充模型的其余细节,以完善您对架构的理解。
9.2.2 连接所有部分
现在你已经了解了 BPE、嵌入、位置编码和多头自注意力的原因和方法,你理解了变压器层的所有要素。你只需要在输出端添加一个较低维度的线性层,将所有这些注意力权重收集在一起,以创建嵌入的输出序列。线性层的输出需要进行缩放(归一化),以使所有层具有相同的尺度。这些线性和归一化层堆叠在注意力层之上,以创建可重复使用的可堆叠变压器块,如图 9.4 所示。
图 9.4 变压器架构
在原始变压器中,编码器和解码器均由N = 6 个堆叠的相同编码器和解码器层组成。
编码器
编码器由多个编码器层组成。每个编码器层有两个子层:一个多头注意力层和一个位置感知的全连接前馈网络。在每个子层周围都有一个残差连接。每个编码器层的输出都被归一化,以使所有层之间传递的编码值的范围在零和一之间。传递给编码器的输入嵌入序列在输入编码器之前与位置编码相加。
解码器
解码器与模型中的编码器几乎相同,但子层数量为三而不是一个。新的子层是一个完全连接的层,类似于多头自注意力矩阵,但只包含零和一。这会创建一个掩码,用于右边的当前目标令牌的输出序列(在英语等从左到右的语言中)。这确保了对于位置i的预测只能依赖于先前的输出,对于小于i的位置。换句话说,在训练期间,注意力矩阵不允许“偷看”它应该生成的后续令牌,以最小化损失函数。这样可以防止在训练过程中出现泄漏或“作弊”,强制变压器只关注它已经看到或生成的令牌。在 RNN 中,解码器内部不需要掩码,因为每个令牌在训练过程中只向网络逐个显示。但是,在训练期间,变压器注意力矩阵可以一次性访问整个序列。
图 9.5 编码器和解码器层之间的连接
9.2.3 变压器翻译示例
变压器适用于许多任务。《注意力就是你的一切》论文展示了一个变压器,其翻译精度优于任何之前的方法。使用 torchtext
,你将准备 Multi30k 数据集,用于训练一个德语-英语翻译的 Transformer,使用 torch.nn.Transformer
模块。在本节中,你将自定义 Transformer
类的解码器部分,以输出每个子层的自注意力权重。你使用自注意力权重矩阵来解释输入德语文本中的单词是如何组合在一起的,以生成输出的英语文本中使用的嵌入。训练模型后,你将在测试集上使用它进行推理,以查看它将德语文本翻译成英语的效果如何。
准备数据
你可以使用 Hugging Face 数据集包来简化记录工作,并确保你的文本以与 PyTorch 兼容的可预测格式输入到 Transformer 中。这是任何深度学习项目中最棘手的部分之一,确保你的数据集的结构和 API 与你的 PyTorch 训练循环所期望的相匹配。翻译数据集特别棘手,除非你使用 Hugging Face:
列表 9.2 在 Hugging Face 格式中加载翻译数据集
>>> from datasets import load_dataset # #1 >>> opus = load_dataset('opus_books', 'de-en') >>> opus DatasetDict({ train: Dataset({ features: ['id', 'translation'], num_rows: 51467 }) })
并不是所有的 Hugging Face 数据集都有预定义的测试和验证数据集拆分。但你可以像列表 9.3 中所示的那样,使用 train_test_split
方法创建自己的拆分。
列表 9.3 在 Hugging Face 格式中加载翻译数据集
>>> sents = opus['train'].train_test_split(test_size=.1) >>> sents DatasetDict({ train: Dataset({ features: ['id', 'translation'], num_rows: 48893 }) test: Dataset({ features: ['id', 'translation'], num_rows: 2574 }) })
在开始长时间的训练之前,检查数据集中的一些示例总是一个好主意。这可以帮助你确保数据符合你的预期。opus_books
并不包含很多书籍。所以它不是很多样化(代表性)的德语样本。它只被分成了 50,000 对齐的句子对。想象一下只有几本翻译过的书籍可供阅读时学习德语是什么感觉。
>>> next(iter(sents['test'])) # #1 {'id': '9206', 'translation': {'de': 'Es war wenigstens zu viel in der Luft.', 'en': 'There was certainly too much of it in the air.'}}
如果你想使用自己创建的自定义数据集,遵循像 Hugging Face 数据集包中所示的开放标准总是一个好主意,它给出了一个“最佳实践”的数据集结构方法。注意,在 Hugging Face 中的翻译数据集包含一个带有语言代码的句子对数组和一个字典。翻译示例的 dict
键是两字母语言代码(来自 ISO 639-2)^([17])。示例文本的 dict
值是数据集中每种语言中的句子。
提示
如果你抵制了发明自己的数据结构的冲动,而是使用广泛认可的开放标准,你就能避免一些隐蔽的、有时无法检测到的错误。
如果你有 GPU,你可能想要用它来训练 Transformer。Transformer 由于其矩阵乘法运算而适用于 GPU,GPU 可用于算法中所有最计算密集的部分。对于大多数预训练的 Transformer 模型(除了 LLM),CPU 是足够的,但 GPU 可以节省大量用于训练或微调 Transformer 的时间。例如,GPT2 在 16 核 CPU 上使用相对较小(40 MB)的训练数据集训练了 3 天。在 2560 核 GPU 上,相同数据集训练时间为 2 小时(速度提升 40 倍,核心数增加 160 倍)。9.4 节将启用你的 GPU(如果有)。
第 9.4 节 启用任何可用的 GPU
>>> DEVICE = torch.device( ... 'cuda' if torch.cuda.is_available() ... else 'cpu')
为了简化操作,你可以分别使用专门的分词器对源语言文本和目标语言文本进行标记化处理。如果你使用 Hugging Face 的分词器,它们将跟踪你几乎任何机器学习任务中需要的所有特殊标记:
- 序列开始标记::通常为
""
或者"
" 序列结束标记::通常为""
或"
"
- 未知词(out-of-vocabulary)标记::通常为
""
,""
- 屏蔽标记::通常为
""
- 填充标记::通常为
""
序列开始标记用于触发解码器生成适合序列中第一个标记的标记。许多生成性问题将需要你有一个序列结束标记,这样解码器就知道何时可以停止递归生成更多标记。一些数据集使用相同的标记表示序列开始和序列结束。它们不需要是唯一的,因为你的解码器始终会“知道”何时开始新的生成循环。填充标记用于在示例短于最大序列长度时填充序列末尾。屏蔽标记用于故意隐藏已知标记,以用于训练诸如 BERT 之类的任务无关编码器。这类似于第六章使用跳字训练词嵌入时所做的操作。
你可以选择任何标记作为这些标记(特殊标记),但你需要确保它们不是数据集词汇表中使用的词汇。因此,如果你正在撰写一本关于自然语言处理的书,并且你不希望你的分词器在示例 SOS 和 EOS 标记上出现问题,你可能需要更有创意地生成文本中找不到的标记。
为了加快标记化和训练速度,并避免标记从源语言文本示例泄漏到生成的目标语言文本中,你可以为每种语言创建一个单独的 Hugging Face 分词器。你可以选择任何语言对,但原始的 AIAYN 论文演示例通常是从英语(源语言)到德语(目标语言)的翻译。
>>> SRC = 'en' # #1 >>> TGT = 'de' # #2 >>> SOS, EOS = '<s>', '</s>' >>> PAD, UNK, MASK = '<pad>', '<unk>', '<mask>' >>> SPECIAL_TOKS = [SOS, PAD, EOS, UNK, MASK] >>> VOCAB_SIZE = 10_000 ... >>> from tokenizers import ByteLevelBPETokenizer # #3 >>> tokenize_src = ByteLevelBPETokenizer() >>> tokenize_src.train_from_iterator( ... [x[SRC] for x in sents['train']['translation']], ... vocab_size=10000, min_frequency=2, ... special_tokens=SPECIAL_TOKS) >>> PAD_IDX = tokenize_src.token_to_id(PAD) ... >>> tokenize_tgt = ByteLevelBPETokenizer() >>> tokenize_tgt.train_from_iterator( ... [x[TGT] for x in sents['train']['translation']], ... vocab_size=10000, min_frequency=2, ... special_tokens=SPECIAL_TOKS) >>> assert PAD_IDX == tokenize_tgt.token_to_id(PAD)
您的 BPE 分词器中的 ByteLevel
部分确保您的分词器在对文本进行分词时永远不会漏掉任何一个字节。字节级别 BPE 分词器始终可以通过组合其词汇表中提供的 256 个可能的单字节令牌之一来构建任何字符。这意味着它可以处理任何使用 Unicode 字符集的语言。字节级别分词器将会简单地回退到表示 Unicode 字符的各个字节,如果它以前没有见过或者没有将其包含在其令牌词汇表中。字节级别分词器将需要平均增加 70% 的令牌数量(词汇表大小几乎翻倍)来表示包含它未曾训练过的字符或令牌的新文本。
字符级别 BPE 分词器也有其缺点。字符级别分词器必须将每个多字节 Unicode 字符都包含在其词汇表中,以避免出现任何无意义的 OOV(词汇外)标记。对于预期处理大部分 Unicode 字符涵盖的 161 种语言的多语言变压器,这可能会创建一个巨大的词汇表。Unicode 代码点有 149,186 个字符,用于历史(例如埃及象形文字)和现代书面语言。这大约是存储变压器分词器中所有嵌入和令牌所需的内存的 10 倍。在实际应用中,通常会忽略历史语言和一些罕见的现代语言,以优化变压器 BPE 分词器的内存使用,并将其与您的问题的变压器准确性平衡。
重要提示
BPE 分词器是变压器的五个关键“超级能力”之一,使它们如此有效。而 ByteLevel
BPE 分词器虽然永远不会有 OOV(Out Of Vocabulary)标记,但在表示单词含义方面并不像预期的那样有效。因此,在生产应用中,您可能希望同时训练管道使用字符级别 BPE 分词器和字节级分词器。这样,您就可以比较结果,并选择为您的应用提供最佳性能(准确性和速度)的方法。
您可以使用英文分词器构建一个预处理函数,用于 展平 Dataset
结构并返回不带填充的标记 ID 列表的列表。
def preprocess(examples): src = [x[source_lang] for x in examples["translation"]] src_toks = [tokenize_src(x) for x in src] # tgt = [x[target_lang] for x in examples["translation"]] # tgt_toks = [tokenize_tgt(x) for x in tgt] return src_toks
翻译变压器模型
现在,你已经对 Multi30k 数据中的句子进行了标记化,并将其转换为了分别对应源语言和目标语言(德语和英语)词汇表的索引张量。数据集已经被拆分为独立的训练、验证和测试集,并且已经用批量训练的迭代器进行了包装。现在数据已经准备好了,你需要将注意力转移到设置模型上。Pytorch 提供了 “Attention Is All You Need” 中提出的模型实现,torch.nn.Transformer
。你会注意到构造函数接受几个参数,其中一些是很熟悉的,比如 d_model=512
、nhead=8
、num_encoder_layers=6
和 num_decoder_layers=6
。默认值设置为论文中使用的参数。除了用于前馈维度、丢弃和激活的几个参数之外,该模型还支持 custom_encoder
和 custom_decoder
。为了让事情变得有趣起来,创建一个自定义解码器,除了输出每个子层中的多头自注意力层的注意力权重外,还可以创建一个具有辅助输出的 forward() 方法 - 注意力权重的列表。
列表 9.5 将 torch.nn.TransformerDecoderLayer 扩展为额外返回多头自注意力权重
>>> from torch import Tensor >>> from typing import Optional, Any >>> class CustomDecoderLayer(nn.TransformerDecoderLayer): ... def forward(self, tgt: Tensor, memory: Tensor, ... tgt_mask: Optional[Tensor] = None, ... memory_mask: Optional[Tensor] = None, ... tgt_key_padding_mask: Optional[Tensor] = None ... ) -> Tensor: ... """Like decode but returns multi-head attention weights.""" ... tgt2 = self.self_attn( ... tgt, tgt, tgt, attn_mask=tgt_mask, ... key_padding_mask=tgt_key_padding_mask)[0] ... tgt = tgt + self.dropout1(tgt2) ... tgt = self.norm1(tgt) ... tgt2, attention_weights = self.multihead_attn( ... tgt, memory, memory, # #1 ... attn_mask=memory_mask, ... key_padding_mask=mem_key_padding_mask, ... need_weights=True) ... tgt = tgt + self.dropout2(tgt2) ... tgt = self.norm2(tgt) ... tgt2 = self.linear2( ... self.dropout(self.activation(self.linear1(tgt)))) ... tgt = tgt + self.dropout3(tgt2) ... tgt = self.norm3(tgt) ... return tgt, attention_weights # #2
列表 9.6 将 torch.nn.TransformerDecoder 扩展为额外返回多头自注意力权重列表
>>> class CustomDecoder(nn.TransformerDecoder): ... def __init__(self, decoder_layer, num_layers, norm=None): ... super().__init__( ... decoder_layer, num_layers, norm) ... ... def forward(self, ... tgt: Tensor, memory: Tensor, ... tgt_mask: Optional[Tensor] = None, ... memory_mask: Optional[Tensor] = None, ... tgt_key_padding_mask: Optional[Tensor] = None ... ) -> Tensor: ... """Like TransformerDecoder but cache multi-head attention""" ... self.attention_weights = [] # #1 ... output = tgt ... for mod in self.layers: ... output, attention = mod( ... output, memory, tgt_mask=tgt_mask, ... memory_mask=memory_mask, ... tgt_key_padding_mask=tgt_key_padding_mask) ... self.attention_weights.append(attention) # #2 ... ... if self.norm is not None: ... output = self.norm(output) ... ... return output
与父类版本的 .forward()
唯一的改变就是将权重缓存在列表成员变量 attention_weights
中。
现在回顾一下,你已经对 torch.nn.TransformerDecoder
及其子层组件 torch.nn.TransformerDecoderLayer
进行了扩展,主要是出于探索性的目的。也就是说,你保存了将要配置和训练的 Transformer 模型中不同解码器层的多头自注意力权重。这些类中的 forward() 方法几乎与父类一模一样,只是在保存注意力权重时做了一些不同的改动。
torch.nn.Transformer
是一个相对简单的序列到序列模型,其中包含主要的秘密武器,即编码器和解码器中的多头自注意力。如果查看该模块的源代码 ^([18]),则该模型不假设使用嵌入层或位置编码。现在,您将创建使用自定义解码器组件的TranslationTransformer模型,通过扩展torch.nn.Transformer
模块。首先定义构造函数,它接受src_vocab_size
用于源嵌入大小的参数,以及tgt_vocab_size
用于目标的参数,并使用它们初始化基本的torch.nn.Embedding
。注意,在构造函数中创建了一个PositionalEncoding
成员变量,pos_enc
,用于添加单词位置信息。
列表 9.7 扩展 nn.Transformer 以使用 CustomDecoder 进行翻译
>>> from einops import rearrange # #1 ... >>> class TranslationTransformer(nn.Transformer): # #2 ... def __init__(self, ... device=DEVICE, ... src_vocab_size: int = VOCAB_SIZE, ... src_pad_idx: int = PAD_IDX, ... tgt_vocab_size: int = VOCAB_SIZE, ... tgt_pad_idx: int = PAD_IDX, ... max_sequence_length: int = 100, ... d_model: int = 512, ... nhead: int = 8, ... num_encoder_layers: int = 6, ... num_decoder_layers: int = 6, ... dim_feedforward: int = 2048, ... dropout: float = 0.1, ... activation: str = "relu" ... ): ... ... decoder_layer = CustomDecoderLayer( ... d_model, nhead, dim_feedforward, # #3 ... dropout, activation) ... decoder_norm = nn.LayerNorm(d_model) ... decoder = CustomDecoder( ... decoder_layer, num_decoder_layers, ... decoder_norm) # #4 ... ... super().__init__( ... d_model=d_model, nhead=nhead, ... num_encoder_layers=num_encoder_layers, ... num_decoder_layers=num_decoder_layers, ... dim_feedforward=dim_feedforward, ... dropout=dropout, custom_decoder=decoder) ... ... self.src_pad_idx = src_pad_idx ... self.tgt_pad_idx = tgt_pad_idx ... self.device = device ... ... self.src_emb = nn.Embedding( ... src_vocab_size, d_model) # #5 ... self.tgt_emb = nn.Embedding(tgt_vocab_size, d_model) ... ... self.pos_enc = PositionalEncoding( ... d_model, dropout, max_sequence_length) # #6 ... self.linear = nn.Linear( ... d_model, tgt_vocab_size) # #7
请注意从einops
^([19])包导入rearrange
的重要性。数学家喜欢它用于张量重塑和洗牌,因为它使用了研究生级别应用数学课程中常见的语法。要了解为什么需要rearrange()
你的张量,请参阅torch.nn.Transformer
文档 ^([20])。如果您把任何张量的任何维度都弄错了,它将破坏整个管道,有时会无形中出现问题。
列表 9.8 torch.nn.Transformer 的“形状”和维度描述
S: source sequence length T: target sequence length N: batch size E: embedding dimension number (the feature number) src: (S, N, E) tgt: (T, N, E) src_mask: (S, S) tgt_mask: (T, T) memory_mask: (T, S) src_key_padding_mask: (N, S) tgt_key_padding_mask: (N, T) memory_key_padding_mask: (N, S) output: (T, N, E)
使用torchtext
创建的数据集是批量优先的。因此,借用 Transformer 文档中的术语,您的源和目标张量分别具有形状*(N, S)和(N, T)。要将它们馈送到torch.nn.Transformer
(即调用其forward()
方法),需要对源和目标进行重塑。此外,您希望对源和目标序列应用嵌入加上位置编码。此外,每个都需要一个填充键掩码*,目标需要一个内存键掩码。请注意,您可以在类的外部管理嵌入和位置编码,在管道的培训和推理部分。但是,由于模型专门用于翻译,您选择在类内封装源和目标序列准备。为此,您定义了用于准备序列和生成所需掩码的prepare_src()
和prepare_tgt()
方法。
列表 9.9 TranslationTransformer prepare_src()
>>> def _make_key_padding_mask(self, t, pad_idx): ... mask = (t == pad_idx).to(self.device) ... return mask ... ... def prepare_src(self, src, src_pad_idx): ... src_key_padding_mask = self._make_key_padding_mask( ... src, src_pad_idx) ... src = rearrange(src, 'N S -> S N') ... src = self.pos_enc(self.src_emb(src) ... * math.sqrt(self.d_model)) ... return src, src_key_padding_mask
make_key_padding_mask()
方法返回一个张量,在给定张量中填充标记的位置设置为 1,否则为零。prepare_src()
方法生成填充蒙版,然后将src
重新排列为模型期望的形状。然后,它将位置编码应用于源嵌入,乘以模型维度的平方根。这直接来自于“注意力机制都是你需要的”。该方法返回应用了位置编码的src
,以及适用于它的键填充蒙版。
用于目标序列的prepare_tgt()
方法几乎与prepare_src()
相同。它返回已调整位置编码的tgt
,以及目标键填充蒙版。但是,它还返回一个“后续”蒙版,tgt_mask
,它是一个三角形矩阵,用于允许观察的一行中的列(1)。要生成后续蒙版,你可以使用基类中定义的Transformer.generate_square_subsequent_mask()
方法,如下清单所示。
清单 9.10 TranslationTransformer prepare_tgt()
>>> def prepare_tgt(self, tgt, tgt_pad_idx): ... tgt_key_padding_mask = self._make_key_padding_mask( ... tgt, tgt_pad_idx) ... tgt = rearrange(tgt, 'N T -> T N') ... tgt_mask = self.generate_square_subsequent_mask( ... tgt.shape[0]).to(self.device) ... tgt = self.pos_enc(self.tgt_emb(tgt) ... * math.sqrt(self.d_model)) ... return tgt, tgt_key_padding_mask, tgt_mask
你在模型的forward()
方法中使用prepare_src()
和prepare_tgt()
。在准备好输入后,它只是调用父类的forward()
,并在从(T,N,E)转换回批量优先(N,T,E)后,将输出馈送到线性缩减层。我们这样做是为了保持训练和推断的一致性。
清单 9.11 TranslationTransformer forward()
>>> def forward(self, src, tgt): ... src, src_key_padding_mask = self.prepare_src( ... src, self.src_pad_idx) ... tgt, tgt_key_padding_mask, tgt_mask = self.prepare_tgt( ... tgt, self.tgt_pad_idx) ... memory_key_padding_mask = src_key_padding_mask.clone() ... output = super().forward( ... src, tgt, tgt_mask=tgt_mask, ... src_key_padding_mask=src_key_padding_mask, ... tgt_key_padding_mask=tgt_key_padding_mask, ... memory_key_padding_mask=memory_key_padding_mask) ... output = rearrange(output, 'T N E -> N T E') ... return self.linear(output)
同样,定义一个init_weights()
方法,可调用来初始化 Transformer 的所有子模块的权重。在 Transformer 中常用 Xavier 初始化,因此在这里使用它。Pytorch 的nn.Module
文档^([21])描述了apply(fn)
方法,该方法递归地将fn
应用到调用者的每个子模块上。
清单 9.12 TranslationTransformer init_weights()
>>> def init_weights(self): ... def _init_weights(m): ... if hasattr(m, 'weight') and m.weight.dim() > 1: ... nn.init.xavier_uniform_(m.weight.data) ... self.apply(_init_weights); # #1
模型的各个组件已经定义好了,完整的模型在下一个清单中展示。
清单 9.13 TranslationTransformer 完整模型定义
>>> class TranslationTransformer(nn.Transformer): ... def __init__(self, ... device=DEVICE, ... src_vocab_size: int = 10000, ... src_pad_idx: int = PAD_IDX, ... tgt_vocab_size: int = 10000, ... tgt_pad_idx: int = PAD_IDX, ... max_sequence_length: int = 100, ... d_model: int = 512, ... nhead: int = 8, ... num_encoder_layers: int = 6, ... num_decoder_layers: int = 6, ... dim_feedforward: int = 2048, ... dropout: float = 0.1, ... activation: str = "relu" ... ): ... decoder_layer = CustomDecoderLayer( ... d_model, nhead, dim_feedforward, ... dropout, activation) ... decoder_norm = nn.LayerNorm(d_model) ... decoder = CustomDecoder( ... decoder_layer, num_decoder_layers, decoder_norm) ... ... super().__init__( ... d_model=d_model, nhead=nhead, ... num_encoder_layers=num_encoder_layers, ... num_decoder_layers=num_decoder_layers, ... dim_feedforward=dim_feedforward, ... dropout=dropout, custom_decoder=decoder) ... ... self.src_pad_idx = src_pad_idx ... self.tgt_pad_idx = tgt_pad_idx ... self.device = device ... self.src_emb = nn.Embedding(src_vocab_size, d_model) ... self.tgt_emb = nn.Embedding(tgt_vocab_size, d_model) ... self.pos_enc = PositionalEncoding( ... d_model, dropout, max_sequence_length) ... self.linear = nn.Linear(d_model, tgt_vocab_size) ... ... def init_weights(self): ... def _init_weights(m): ... if hasattr(m, 'weight') and m.weight.dim() > 1: ... nn.init.xavier_uniform_(m.weight.data) ... self.apply(_init_weights); ... ... def _make_key_padding_mask(self, t, pad_idx=PAD_IDX): ... mask = (t == pad_idx).to(self.device) ... return mask ... ... def prepare_src(self, src, src_pad_idx): ... src_key_padding_mask = self._make_key_padding_mask( ... src, src_pad_idx) ... src = rearrange(src, 'N S -> S N') ... src = self.pos_enc(self.src_emb(src) ... * math.sqrt(self.d_model)) ... return src, src_key_padding_mask ... ... def prepare_tgt(self, tgt, tgt_pad_idx): ... tgt_key_padding_mask = self._make_key_padding_mask( ... tgt, tgt_pad_idx) ... tgt = rearrange(tgt, 'N T -> T N') ... tgt_mask = self.generate_square_subsequent_mask( ... tgt.shape[0]).to(self.device) # #1 ... tgt = self.pos_enc(self.tgt_emb(tgt) ... * math.sqrt(self.d_model)) ... return tgt, tgt_key_padding_mask, tgt_mask ... ... def forward(self, src, tgt): ... src, src_key_padding_mask = self.prepare_src( ... src, self.src_pad_idx) ... tgt, tgt_key_padding_mask, tgt_mask = self.prepare_tgt( ... tgt, self.tgt_pad_idx) ... memory_key_padding_mask = src_key_padding_mask.clone() ... output = super().forward( ... src, tgt, tgt_mask=tgt_mask, ... src_key_padding_mask=src_key_padding_mask, ... tgt_key_padding_mask=tgt_key_padding_mask, ... memory_key_padding_mask = memory_key_padding_mask, ... ) ... output = rearrange(output, 'T N E -> N T E') ... return self.linear(output)
最后,你拥有了一个完整的 Transformer!你应该能够用它来在几乎任何一对语言之间进行翻译,甚至包括像传统中文和日语这样字符丰富的语言。你可以明确地访问所有你可能需要调整模型以解决问题的超参数。例如,你可以增加目标语言或源语言的词汇量,以有效处理字符丰富的语言,比如传统中文和日语。
注
由于中文和日语(汉字)拥有比欧洲语言更多的独特字符,所以它们被称为字符丰富。中文和日语使用形码字符。形码字符看起来有点像小的象形文字或抽象的象形图。例如,汉字字符"日"可以表示"天",它看起来有点像日历上可能看到的日期方块。日语形码字符在英语中大致相当于形态素和词之间的词素。这意味着在形码语言中,您将有比欧洲语言更多的独特字符。例如,传统日语使用大约 3500 个独特汉字字符。英语在最常用的 20000 个单词中有大约 7000 个独特音节。
即使是变压器的编码器和解码器端的层数也可以根据源(编码器)或目标(解码器)语言进行更改。您甚至可以创建一个翻译变压器,将复杂概念简化为 5 岁的孩子或专注于 ELI5(“像我 5 岁时解释”)对话的 Mastodon 服务器上的成年人。如果减少解码器的层数,这将创建一个"容量"瓶颈,迫使解码器简化或压缩来自编码器的概念。同样,编码器或解码器层中的注意力头的数量可以调整以增加或减少变压器的容量(复杂性)。
训练 TranslationTransformer
现在让我们为我们的翻译任务创建一个模型实例,并初始化权重以准备训练。对于模型的维度,您使用默认值,这些默认值与原始的"Attention Is All You Need"变压器的大小相对应。请注意,由于编码器和解码器构建块包括可堆叠的重复层,因此您可以配置模型以使用任意数量的这些层。
列表 9.14 实例化 TranslationTransformer
>>> model = TranslationTransformer( ... device=DEVICE, ... src_vocab_size=tokenize_src.get_vocab_size(), ... src_pad_idx=tokenize_src.token_to_id('<pad>'), ... tgt_vocab_size=tokenize_tgt.get_vocab_size(), ... tgt_pad_idx=tokenize_tgt.token_to_id('<pad>') ... ).to(DEVICE) >>> model.init_weights() >>> model # #1
PyTorch 创建了一个漂亮的_\_str\_\_
模型表示。它显示了所有层及其内部结构,包括输入和输出的形状。您甚至可以看到您的模型的层与本章或在线看到的变压器图的类比。从变压器的文本表示的前半部分,您可以看到所有的编码器层具有完全相同的结构。每个TransformerEncoderLayer
的输入和输出具有相同的形状,因此这可以确保您可以将它们堆叠在一起而不需要在它们之间重塑线性层。变压器层就像摩天大楼或儿童木块的楼层一样。每个层级具有完全相同的 3D 形状。
TranslationTransformer( (encoder): TransformerEncoder( (layers): ModuleList( (0-5): 6 x TransformerEncoderLayer( (self_attn): MultiheadAttention( (out_proj): NonDynamicallyQuantizableLinear( in_features=512, out_features=512, bias=True) ) (linear1): Linear( in_features=512, out_features=2048, bias=True) (dropout): Dropout(p=0.1, inplace=False) (linear2): Linear( in_features=2048, out_features=512, bias=True) (norm1): LayerNorm((512,), eps=1e-05, elementwise_affine=True) (norm2): LayerNorm((512,), eps=1e-05, elementwise_affine=True) (dropout1): Dropout(p=0.1, inplace=False) (dropout2): Dropout(p=0.1, inplace=False) ) ) (norm): LayerNorm((512,), eps=1e-05, elementwise_affine=True) ) ...
注意,在构造函数中设置源词汇表和目标词汇表的大小。你还将传递源填充符和目标填充符的索引,以便模型在准备源、目标和相关掩码序列时使用。现在,你已经定义好了模型,请花点时间做一个快速的健全检查,确保没有明显的编码错误,然后再设置训练和预测流水线。你可以为源和目标创建随机整数张量的“批次”,并将它们传递给模型,如下面的示例所示。
清单 9.15 使用随机张量进行快速模型验证
>>> src = torch.randint(1, 100, (10, 5)).to(DEVICE) # #1 >>> tgt = torch.randint(1, 100, (10, 7)).to(DEVICE) ... >>> with torch.no_grad(): ... output = model(src, tgt) # #2 ... >>> print(output.shape) torch.Size([10, 7, 5893])
我们创建了两个张量 src
和 tgt
,每个张量中的随机整数均匀分布在 1 到 100 之间。你的模型接受批次优先形状的张量,因此我们确保批次大小(本例中为 10)相同,否则在前向传递中将会出现运行时错误,错误如下所示:
RuntimeError: the batch number of src and tgt must be equal
源序列和目标序列的长度不必相等,这一点很明显,model(src, tgt) 的成功调用证实了这一点。
提示
在为训练设置新的序列到序列模型时,你可能希望在设置中初始使用较小的参数。这包括限制最大序列长度、减小批次大小以及指定较小数量的训练循环或 epochs。这将使得在模型和/或流水线中调试问题并使程序能够端到端执行更容易。在这个“引导”阶段,不要对模型的能力/准确性做出任何结论;目标只是让流水线运行起来。
鉴于你对模型的准备工作感到自信,下一步是为训练定义优化器和损失函数。《Attention is All You Need》使用了 Adam 优化器,其中学习率在训练的开始阶段逐渐增加,然后在训练的过程中逐渐减小。你将使用一个静态的学习率 1e-4,该学习率小于 Adam 的默认学习率 1e-2。只要你愿意运行足够的 epochs,这应该能够提供稳定的训练。如果你有兴趣,可以尝试学习率调度。本章稍后介绍的其他基于 Transformer 的模型会使用静态学习率。对于这类任务来说,你将使用 torch.nn.CrossEntropyLoss
作为损失函数。
清单 9.16 优化器和损失函数
>>> LEARNING_RATE = 0.0001 >>> optimizer = torch.optim.Adam(model.parameters(), lr=LEARNING_RATE) >>> criterion = nn.CrossEntropyLoss(ignore_index=TRG_PAD_IDX) # #1
Ben Trevett 为 Pytorch Transformer 初学者教程贡献了大量代码。他和同事们为他们的 Pytorch Seq2Seq 教程编写了一系列出色且信息丰富的 Jupyter 笔记本系列^([23]),涵盖了序列到序列模型。他们的 Attention Is All You Need^([24]) 笔记本提供了一个基本的 Transformer 模型的从零开始的实现。为了避免重复造轮子,在接下来的部分中,训练和评估驱动代码是从 Ben 的笔记本中借用的,只做了少量修改。
train()
函数实现了类似于你见过的其他训练循环。在批次迭代之前记得将模型设置为train
模式。此外,请注意,在传递给模型之前,目标中的最后一个标记,即 EOS 标记,已从trg
中删除。我们希望模型能够预测字符串的结束。该函数返回每次迭代的平均损失。
第 9.17 节 模型训练函数
>>> def train(model, iterator, optimizer, criterion, clip): ... ... model.train() # #1 ... epoch_loss = 0 ... ... for i, batch in enumerate(iterator): ... src = batch.src ... trg = batch.trg ... optimizer.zero_grad() ... output = model(src, trg[:,:-1]) # #2 ... output_dim = output.shape[-1] ... output = output.contiguous().view(-1, output_dim) ... trg = trg[:,1:].contiguous().view(-1) ... loss = criterion(output, trg) ... loss.backward() ... torch.nn.utils.clip_grad_norm_(model.parameters(), clip) ... optimizer.step() ... epoch_loss += loss.item() ... ... return epoch_loss / len(iterator)
evaluate()
函数类似于train()
。你将模型设置为eval
模式,并像通常一样使用with torch.no_grad()
范式进行直接推理。
第 9.18 节 模型评估函数
>>> def evaluate(model, iterator, criterion): ... model.eval() # #1 ... epoch_loss = 0 ... ... with torch.no_grad(): # #2 ... for i, batch in enumerate(iterator): ... src = batch.src ... trg = batch.trg ... output = model(src, trg[:,:-1]) ... output_dim = output.shape[-1] ... output = output.contiguous().view(-1, output_dim) ... trg = trg[:,1:].contiguous().view(-1) ... loss = criterion(output, trg) ... epoch_loss += loss.item() ... return epoch_loss / len(iterator)
然后,定义一个直接的实用函数epoch_time()
,用于计算训练过程中经过的时间,如下所示。
第 9.19 节 用于计算经过时间的实用函数
>>> def epoch_time(start_time, end_time): ... elapsed_time = end_time - start_time ... elapsed_mins = int(elapsed_time / 60) ... elapsed_secs = int(elapsed_time - (elapsed_mins * 60)) ... return elapsed_mins, elapsed_secs
现在,让我们继续设置训练。你将训练的轮次数设置为 15,以便模型有足够的机会以之前选择的学习率1e-4
进行训练。你可以尝试不同的学习率和轮次数的组合。在未来的例子中,你将使用早停机制来避免过拟合和不必要的训练时间。在这里,你声明了一个文件名BEST_MODEL_FILE
,并且在每个轮次之后,如果验证损失优于之前的最佳损失,那么模型将会被保存,最佳损失将会被更新,如下所示。
第 9.20 节 运行 TranslationTransformer 模型训练并将最佳模型保存到文件中
>>> import time >>> N_EPOCHS = 15 >>> CLIP = 1 >>> BEST_MODEL_FILE = 'best_model.pytorch' >>> best_valid_loss = float('inf') >>> for epoch in range(N_EPOCHS): ... start_time = time.time() ... train_loss = train( ... model, train_iterator, optimizer, criterion, CLIP) ... valid_loss = evaluate(model, valid_iterator, criterion) ... end_time = time.time() ... epoch_mins, epoch_secs = epoch_time(start_time, end_time) ... ... if valid_loss < best_valid_loss: ... best_valid_loss = valid_loss ... torch.save(model.state_dict(), BEST_MODEL_FILE) ... print(f'Epoch: {epoch+1:02} | Time: {epoch_mins}m {epoch_secs}s') ... train_ppl = f'{math.exp(train_loss):7.3f}' ... print(f'\tTrain Loss: {train_loss:.3f} | Train PPL: {train_ppl}') ... valid_ppl = f'{math.exp(valid_loss):7.3f}' ... print(f'\t Val. Loss: {valid_loss:.3f} | Val. PPL: {valid_ppl}')
Epoch: 01 | Time: 0m 55s Train Loss: 4.835 | Train PPL: 125.848 Val. Loss: 3.769 | Val. PPL: 43.332 Epoch: 02 | Time: 0m 56s Train Loss: 3.617 | Train PPL: 37.242 Val. Loss: 3.214 | Val. PPL: 24.874 Epoch: 03 | Time: 0m 56s Train Loss: 3.197 | Train PPL: 24.448 Val. Loss: 2.872 | Val. PPL: 17.679 ... Epoch: 13 | Time: 0m 57s Train Loss: 1.242 | Train PPL: 3.463 Val. Loss: 1.570 | Val. PPL: 4.805 Epoch: 14 | Time: 0m 57s Train Loss: 1.164 | Train PPL: 3.204 Val. Loss: 1.560 | Val. PPL: 4.759 Epoch: 15 | Time: 0m 57s Train Loss: 1.094 | Train PPL: 2.985 Val. Loss: 1.545 | Val. PPL: 4.689
注意,在退出循环之前,验证损失仍在减小,我们可能可以再运行几个轮次。让我们加载最佳模型,并在测试集上运行evaluate()
函数,看看模型的表现如何。
第 9.21 节 从文件加载最佳模型并在测试数据集上执行评估
>>> model.load_state_dict(torch.load(BEST_MODEL_FILE)) >>> test_loss = evaluate(model, test_iterator, criterion) >>> print(f'| Test Loss: {test_loss:.3f} | Test PPL: {math.exp(test_loss):7.3f} |') | Test Loss: 1.590 | Test PPL: 4.902 |
你的翻译转换器在测试集上实现了约 1.6 的对数损失。对于在如此小的数据集上训练的翻译模型来说,这还算不错。1.59 的对数损失对应于生成正确标记的概率约为 20%(exp(-1.59)
),并且在测试集中提供的确切位置。由于对于给定的德语文本,有许多不同的正确英语翻译,所以这对于可以在普通笔记本电脑上进行训练的模型来说是合理的准确率。
TranslationTransformer 推理
现在,你确信你的模型已经准备好成为你个人的德语到英语的翻译器了。执行翻译只需要稍微多一点的设置工作,你会在接下来的代码清单的translate_sentence()
函数中完成。简而言之,如果源句子还没有被分词,就先对其进行分词,然后在句子的开头和结尾加上 和 标记。接下来,调用模型的prepare_src()
方法来转换 src 序列,并生成与训练和评估中相同的源键填充蒙版。然后,运行准备好的src
和src_key_padding_mask
通过模型的编码器,并保存其输出(在enc_src
中)。现在,这是有趣的部分,目标句子(即翻译)的生成。首先,初始化一个列表trg_indexes
,包含 SOS 标记。在循环中 - 只要生成的序列还没有达到最大长度 - 将当前预测的 trg_indexes 转换为张量。使用模型的prepare_tgt()
方法准备目标序列,创建目标键填充蒙版和目标句子蒙版。将当前解码器输出、编码器输出和两个蒙版通过解码器。从解码器输出中获取最新预测的标记,并将其附加到 trg_indexes。如果预测是一个 标记(或达到最大句子长度),则退出循环。该函数返回目标索引转换为标记(单词)和模型中解码器的注意权重。
代码清单 9.22 定义 translate_sentence() 函数以执行推断
>>> def translate_sentence(sentence, src_field, trg_field, ... model, device=DEVICE, max_len=50): ... model.eval() ... if isinstance(sentence, str): ... nlp = spacy.load('de') ... tokens = [token.text.lower() for token in nlp(sentence)] ... else: ... tokens = [token.lower() for token in sentence] ... tokens = ([src_field.init_token] + tokens ... + [src_field.eos_token]) # #1 ... src_indexes = [src_field.vocab.stoi[token] for token in tokens] ... src = torch.LongTensor(src_indexes).unsqueeze(0).to(device) ... src, src_key_padding_mask = model.prepare_src(src, SRC_PAD_IDX) ... with torch.no_grad(): ... enc_src = model.encoder(src, ... src_key_padding_mask=src_key_padding_mask) ... trg_indexes = [ ... trg_field.vocab.stoi[trg_field.init_token]] # #2 ... ... for i in range(max_len): ... tgt = torch.LongTensor(trg_indexes).unsqueeze(0).to(device) ... tgt, tgt_key_padding_mask, tgt_mask = model.prepare_tgt( ... tgt, TRG_PAD_IDX) ... with torch.no_grad(): ... output = model.decoder( ... tgt, enc_src, tgt_mask=tgt_mask, ... tgt_key_padding_mask=tgt_key_padding_mask) ... output = rearrange(output, 'T N E -> N T E') ... output = model.linear(output) ... ... pred_token = output.argmax(2)[:,-1].item() # #3 ... trg_indexes.append(pred_token) ... ... if pred_token == trg_field.vocab.stoi[ ... trg_field.eos_token]: # #4 ... break ... ... trg_tokens = [trg_field.vocab.itos[i] for i in trg_indexes] ... translation = trg_tokens[1:] ... ... return translation, model.decoder.attention_weights
你的translate_sentence()
将你的大型变压器封装成一个方便的包,你可以用来翻译任何德语句子。
自然语言处理实战第二版(MEAP)(五)(2)https://developer.aliyun.com/article/1519662