本章内容包括:
- 了解变换器如何实现无限堆叠和扩展
- 微调变换器以适应你的应用
- 应用变换器进行长文档的抽取式和概括式总结
- 使用变换器生成合理、语法正确的文本
- 估算变换器的信息容量
变换器正在改变世界。变换器带给人工智能的智能化提升正在转变文化、社会和经济。变换器首次让我们开始质疑人类智慧和创造力的长期经济价值。变换器的涟漪效应不仅仅局限于经济。它们不仅改变了我们的工作和娱乐方式,甚至还改变了我们的思维、沟通和创造方式。在不到一年的时间里,启用变换器的人工智能(即大语言模型,LLMs)创造了全新的职位类别,如提示工程、实时内容策划和事实核查(基准)。科技公司正在争相招聘能够设计有效LLM提示并将LLM整合到工作流程中的工程师。变换器正在自动化并加速信息经济职位的生产力,这些职位以前需要人类的创造力和抽象能力,而机器无法轻易胜任。
随着变换器自动化越来越多的信息经济任务,工人们开始重新思考他们的工作是否真如他们所想的那样对雇主至关重要。例如,一些有影响力的网络安全专家正在炫耀,他们每天都通过数十条ChatGPT的建议来增强他们的思考、计划和创造力。微软新闻和MSN在2020年裁掉了记者,取而代之的是能够自动策划和总结新闻文章的变换器模型。这场内容质量的下降竞赛可能不会对媒体公司及其广告商和员工带来好结果。
在本章中,你将学习如何使用变换器提高自然语言文本的准确性和深度。即使你的雇主试图通过编程消除你的工作,你也将学会如何编程变换器,为自己创造新的机会。编程或被编程,自动化或被自动化。
变换器不仅是自然语言生成的最佳选择,也是自然语言理解的最佳选择。任何依赖于意义的向量表示的系统都能从变换器中受益:
- 一度,Replika使用GPT-3生成了超过20%的回复。
- Qary使用BERT生成开放域问答。
- 谷歌使用基于BERT的模型来改善搜索结果并查询知识图谱。
- NBoost使用变换器为Elasticsearch创建语义搜索代理。
- aidungeon.io使用GPT-3生成各种无限的房间。
- 大多数用于语义搜索的向量数据库都依赖于变换器。
即使你只想精通提示工程,对变换器的理解也将帮助你设计避免LLM能力漏洞的提示。LLM有很多漏洞,以至于工程师和统计学家在思考LLM失败时常常使用“瑞士奶酪模型”。LLM的对话界面使得学习如何引导这些机智的对话AI系统进行有价值的工作变得容易。了解LLM如何工作并能够微调它们以适应自己应用的人员,将会掌握一台强大机器的操控权。想象一下,如果你能够构建一个可以帮助学生解决算术和数学题的“TutorGPT”,你会多么抢手。Kigali的Rising Academies的Shabnam Aggarwal就用她的Rori.AI WhatsApp数学辅导机器人为初中生做到了这一点。Vishvesh Bhat也为大学数学学生做了类似的热情项目。
9.1 递归与重复
变换器是自回归NLP模型中的最新重大进展。自回归模型一次预测一个离散的输出值,通常是自然语言文本中的一个标记或单词。自回归模型将输出回收并作为输入重新使用,以预测下一个输出,因此自回归神经网络是递归的。递归这个词是一个通用术语,指的是任何将输出回收到输入中的过程,这个过程可以无限进行,直到算法或计算终止。计算机科学中的递归函数会不断调用自己,直到获得所需的结果。
但是,变换器在比循环神经网络(RNN)更大和更通用的方式上是递归的。变换器被称为递归神经网络,而不是循环神经网络,因为递归是一个更通用的术语,适用于任何回收输入的系统。术语“循环”专门用于描述LSTM和GRU等RNN,其中每个神经元将在序列标记的每一步将其输出回收到同一神经元的输入中。
变换器是一个递归算法,但不包含循环神经元。正如你在第8章中学到的,循环神经网络将在每个单独的神经元或RNN单元内回收它们的输出。而变换器则等到最后一层才输出可以回收回输入的标记嵌入。整个变换器网络,包括编码器和解码器,必须运行才能预测每个标记,以便该标记可以帮助预测下一个标记。在计算机科学中,你可以看到变换器是一个大的递归函数,内部调用一系列非递归函数。图9.1展示了变换器是如何递归运行的,逐个生成标记。
由于变换器内部没有递归,它不需要展开计算。这使得变换器比RNN具有巨大的优势。在变换器中,单个神经元和层可以一次性并行运行。而在RNN中,你必须依次按顺序运行神经元和层的函数。展开所有这些递归函数调用需要大量的计算资源,而且必须按顺序执行。你不能跳过步骤或者并行运行;它们必须按顺序执行,直到整个文本处理完成。变换器通过简化这个问题,将任务转化为预测单个标记的更简单任务。这样,变换器的所有神经元可以在GPU或多核CPU上并行运行,从而显著加快预测所需的时间。
变换器使用最后一个预测的输出作为输入来预测下一个输出。但变换器是递归的,而不是循环的。循环神经网络包括RNN、LSTM和GRU。当研究人员将五个NLP思想结合起来创建变换器架构时,他们发现这个架构的整体能力远大于各部分能力的总和。让我们详细看看这些思想。
9.1.1 注意力并不是你所需要的一切
三个创新相结合,使得变换器能够模仿人类对话的能力:
- 字节对编码(BPE) — 基于字符序列统计对单词进行标记化,而不是基于空格和标点符号
- 注意力机制 — 使用连接矩阵(注意力)将重要的词汇模式跨越长篇文本连接起来
- 位置编码 — 跟踪每个标记或模式在标记序列中的位置
字节对编码(BPE)是变换器的一个经常被忽视的增强功能。BPE最初是为了将文本以压缩的二进制(字节序列)格式编码而发明的,但它真正展现出其价值是在作为NLP管道中的标记化工具时,例如在搜索引擎中使用。互联网搜索引擎的词汇库通常包含数百万个独特的单词。想象一下,搜索引擎需要理解和索引的所有重要名称。BPE可以高效地将你的词汇减少几个数量级。典型的变换器BPE词汇表大小通常只有5,000个标记,而当你为每个标记存储一个长的嵌入向量时,这就非常重要。一个基于整个互联网训练的BPE词汇表很容易适配到典型的笔记本电脑或GPU的内存中。
注意力机制因其使其他部分得以实现的功能,获得了大部分的功劳。与卷积神经网络(CNN)和RNN的复杂数学(和计算复杂性)相比,注意力机制是一个更简单的方式。注意力机制去除了编码器和解码器网络的递归,因此变换器没有RNN的梯度消失或梯度爆炸问题。变换器处理文本的长度受到限制,因为注意力机制依赖于每层输入和输出的固定长度嵌入序列。注意力机制本质上是一个跨越整个标记序列的单一CNN核。与通过卷积或递归逐步处理文本不同,注意力矩阵仅需一次与整个标记嵌入序列相乘。
变换器中递归的消失带来了一个新的挑战,因为变换器会一次性读取整个标记序列。它也会一次性输出标记,使得双向变换器成为一个显而易见的选择。变换器在读取或写入文本时并不关心标记的正常因果顺序。为了让变换器了解标记的因果序列,加入了位置编码。位置编码通过将嵌入向量与正弦和余弦函数相乘,分布在整个嵌入序列中,因此它不需要在向量嵌入中增加额外的维度。这种编码方式使得变换器能够根据标记在文本中的位置进行细致的调整。例如,带有位置编码的“sincerely”在电子邮件的开头和结尾时,含义是不同的。
限制标记序列长度带来了级联效应的效率提升,使得变换器相比其他架构具有意想不到的强大优势:可扩展性。BPE、注意力和位置编码的结合创造了前所未有的可扩展性。这三项创新和神经网络的简化帮助创建了一个既更具堆叠性,又更易于并行化的网络:
- 堆叠性 — 变换器层的输入和输出具有完全相同的结构,因此可以堆叠以增加容量。
- 并行性 — 所有变换器层都依赖于大型矩阵乘法,而非复杂的递归和逻辑切换门。
变换器层的堆叠性与实现注意力机制所需的矩阵乘法的并行性相结合,创造了一个新的可扩展性水平。当研究人员将他们的大容量变换器应用于他们能够找到的最大数据集(基本上是整个互联网)时,他们感到非常震惊。那些在极大数据集上训练的极大变换器能够解决以前被认为无法触及的NLP问题。
你可能会认为,关于注意力强大作用的讨论其实没什么特别的。变换器肯定不仅仅是对输入文本中的每个标记进行简单的矩阵乘法。变换器结合了许多其他较少为人知的创新,比如BPE、自监督训练和位置编码。注意力矩阵是所有这些想法之间的连接,使它们能够有效地协同工作,并使得变换器能够准确地模拟文本中所有单词之间的连接,一次性完成。
9.1.2 语言的乐高积木
与CNN和RNN(LSTM和GRU)一样,变换器的每一层都为输入文本的意义或思维提供了更深层次的表示。但与CNN和RNN不同,变换器的编码器部分的每一层输出的张量与前一层的大小和形状完全相同,提供了输入文本的统一表示。变换器层顶部的“按钮”与上面下一层的“孔”完全匹配,就像一堆乐高积木一样。同样,变换器神经网络的解码器部分传递一个固定大小的嵌入序列,表示它将在下一次迭代中生成的输出标记的语义(意义)。一个变换器层的输出可以直接输入到下一个变换器层。这使得变换器的层比CNN更具堆叠性。你可能还记得在第7章中,如何在CNN层中跟踪各种输入和输出的大小和形状是多么困难。
每个变换器层都会输出一个一致的编码,大小和形状相同。编码只是嵌入,但针对的是标记序列,而不是单个标记。在NLP中,当你听到“编码”这个词时,它通常指的是存储在2D张量中的嵌入向量序列。通过保持该编码张量一致的形状,每一层中的注意力矩阵可以跨越输入文本的整个长度,使其具有与前一层完全相同的内部结构和数学。你可以堆叠任意多的相同变换器编码器和解码器层,创建适合数据内容的深度神经网络。
你可能会认为,变换器编码张量的固定大小会限制它处理更长或更短的输入和输出标记序列的灵活性。然而,通过保持编码数组一致的形状,你可以创建接受相同形状输入的深度学习层,然后堆叠任意多的这些层。这种乐高积木般的堆叠性和互操作性可能看起来不算什么大问题,但它很可能是变换器最重要的优势之一。在图9.2中,你可以看到这些堆叠的变换器积木在编码一个标记序列时的样子,例如“什么是BERT?”
9.1.2 语言的乐高积木
输入到变换器中的标记序列总是会被截断和填充,以确保它具有所需的长度,无论正在处理的文本有多长或多短。例如,标记序列[What] [is] [BERT] [?]可以被编码为四个嵌入列向量,创建一个N×4的张量或数组。但对于一个包含五个或更多标记的句子,这样的编码对于回答这个问题来说就太短了。对于RNN(例如LSTM),你可以在遇到序列结束(<EOS>)标记时停止处理。但对于变换器,你需要添加填充标记,通常是零向量,以填满序列,使其长度达到你希望变换器处理的最大长度序列。这看似浪费了处理能力和内存,因为要求变换器代码对所有这些零进行计算;然而,这为神经网络架构节省了大量复杂性,因为你可以重复使用完全相同的变换器层,创建一个真正深度(大)的语言模型。堆叠变换器层的极限就是天空(和数据集的大小)。网络底部用于填充的零很可能会在你的文本编码经过变换器的层时被有意义的数值填充。
每个标记嵌入向量(列)可以有数百甚至数千个维度。而标记序列的长度可以是数百或数千个标记。因此,典型的编码张量可能具有384 × 1024的形状。对于短的三标记输入到变换器,你需要用1,020个零向量填充标记序列。别担心——这些零不会停留太久。当变换器逐步将问题转化为对BERT的解释时,它会逐步用有意义的数字填充这些填充向量。在图9.2中,你可以看到编码张量中,第一层和第二层变换器之间的一些零已经被非零值填充。如果你对这个示例感到怀疑,你可以使用Andrej Karpathy的Jupyter Notebooks(minGPT和nanoGPT),亲自展示真实的张量,这些Notebook将变换器简化到其本质。9
在你思考保持一致的编码和嵌入张量形状的重要性时,考虑一下如何一致地使用“编码”和“嵌入”这两个词可能会有所帮助。许多NLP初学者将这两个术语互换使用,但在阅读了本章之后,你应该已经具备足够细致的理解,可以更精确地使用这些术语。如果你始终使用“编码”一词来描述嵌入向量序列,它有助于塑造你对这些数值数组形状和大小的心理模型。
提示:不要将自然语言编码张量与Python字符串编码格式混淆。当你谈论Python字符集编码,或使用str.encode()和str.decode()方法时,你需要指定一个编码名称,如UTF-8、latin或ASCII。在Python世界中,“编码”一词通常指的是用于将字符串呈现为字母或整数序列的编解码器(算法)的名称。
到2024年,“嵌入”一词的使用频率是“编码”三倍,但这可能会改变,因为越来越多的人开始了解变换器和NLP。如果你不需要明确指出你指的是哪一种,你可以使用术语“语义向量”或“语义张量”,这些术语你在第6章中学到过。
像所有张量和向量一样,编码保持一致的结构,使得它们在整个代码中以相同的方式表示标记序列(文本)的意义。变换器被设计为接受这些编码向量作为输入,传递前一层对文本理解的记忆。这使得如果你有足够的训练数据来利用所有的容量,你可以堆叠任意多的变换器层。这种可扩展性使得变换器突破了RNN的递减回报上限。
由于注意力机制只是一个连接矩阵,它可以作为矩阵乘法与PyTorch的线性层一起实现。当你在GPU或多核CPU上运行PyTorch网络时,矩阵乘法是并行的。这意味着更大的变换器可以并行化,并且这些更大的模型可以训练得更快。堆叠性加上并行性等于可扩展性。
变换器层的设计使得输入和输出具有相同的大小和形状,因此这些变换器层可以像乐高积木一样堆叠,所有层都具有相同的形状。变换器中最引起大多数研究者注意的创新是注意力机制。如果你想理解为什么变换器对NLP和AI研究人员如此激动人心,可以从这里开始。与使用递归或卷积的其他深度学习NLP架构不同,变换器架构使用堆叠的注意力层块,这些层基本上是完全连接的前馈层。
在第8章中,你使用RNN构建了编码器和解码器来转换文本序列。在编码器-解码器(转码器或转导)网络中,编码器处理输入序列中的每个元素,将句子提炼为一个固定长度的思维向量(或上下文向量)。然后,这个思维向量可以传递给解码器,用于生成新的标记序列。
编码器-解码器架构有一个大限制——它无法处理更长的文本。如果一个概念或思维通过多个句子或一个长而复杂的句子表达,那么编码的思维向量无法准确地概括所有的思维。Bahdanau等人提出的注意力机制12被证明可以提高序列到序列的性能,特别是在长句子上;然而,它并没有缓解递归模型的时间序列复杂性。
“Attention Is All You Need”中的变换器架构的引入13推动了语言模型的进步并让它们进入公众视野。变换器架构引入了多个协同工作的特性,使得以前无法实现的性能成为可能。变换器架构中最广为人知的创新是自注意力。类似于GRU或LSTM中的输入和遗忘门,注意力机制在长输入字符串中创建了概念和单词模式之间的连接。
在接下来的几节中,你将了解变换器背后的基本概念,并看看模型的架构。然后,你将使用PyTorch中变换器模块的基本实现来实现一个语言翻译模型,这是“Attention Is All You Need”中的参考任务,看看它在设计上如何既强大又优雅。
自注意力
在我们编写本书第一版时,Hannes 和 Cole(第一版的共同作者)已经专注于注意力机制。如今已经过去六年,注意力仍然是深度学习中研究最多的话题。注意力机制使得在处理LSTM难以应对的问题时,能力得到了飞跃提升:
- 对话——生成对话提示、查询或语句的合理响应
- 抽象总结或改写——生成长文本的简短表述,总结句子、段落,甚至几页文本
- 开放领域问答——回答变换器曾经阅读过的任何内容的一般性问题
- 阅读理解问答——回答有关短文本的提问(通常少于一页)
- 编码——表示一段文本含义的单一向量或嵌入向量序列——有时被称为任务独立的句子嵌入
- 翻译和代码生成——基于程序目的的简单英语描述,生成合理的代码表达式和程序
自注意力是实现注意力机制最直接和常见的方法。它将输入的嵌入向量序列通过线性投影处理。线性投影仅仅是一个点积或矩阵乘法。这个点积创建了键(key)、值(value)和查询(query)向量。查询向量与键向量一起使用,创建一个上下文向量,用来表示单词的嵌入向量及其与查询的关系。然后,这个上下文向量用于获取加权和的值。在实践中,所有这些操作都在一组查询、键和值上进行,这些查询、键和值被分别打包在矩阵Q、K和V中。
实现注意力算法的线性代数有两种方式:加法注意力和点积注意力。点积注意力的一个缩放版本在变换器中最为有效。对于点积注意力,查询向量Q和键向量K之间的标量积会根据模型中维度的数量进行缩放。这使得点积对于大维度的嵌入和较长文本序列更具数值稳定性。方程9.1展示了如何计算查询、键和值矩阵Q、K和V的自注意力输出。
高维的点积由于大数法则的作用,在softmax中产生了小的梯度。为了抵消这种效应,查询和键矩阵的乘积会被缩放。softmax对结果向量进行归一化,使得它们都是正数并且总和为1。然后,这个“评分”矩阵会与值矩阵相乘,得到加权值矩阵。
与RNN不同,在RNN中存在递归和共享权重,而在自注意力中,查询、键和值矩阵中使用的所有向量都来自输入序列的嵌入向量。整个机制可以通过高度优化的矩阵乘法操作来实现。Q × K的乘积形成一个方阵,可以理解为输入序列中单词之间的连接。图9.3展示了一个简单的示例。
多头自注意力
多头自注意力是自注意力方法的一种扩展,它创建多个注意力头,每个头关注文本中单词的不同方面。因此,如果一个标记具有多个含义,并且这些含义都与输入文本的解释相关,那么它们可以在不同的注意力头中得到体现。你可以将每个注意力头看作是文本主体的编码向量的另一个维度,类似于单个标记的嵌入向量的额外维度(见第6章)。查询、键和值矩阵会分别与每个不同的dq、dk和dv维度相乘n次(n-heads——注意力头的数量),以计算总的注意力函数输出。n-heads值是变换器架构的一个超参数,通常是一个小值,与变换器模型中的变换器层数量相当。dv维度的输出会被拼接起来,并通过一个Wₒ矩阵再次进行投影,如下方的方程所示。
多个注意力头使得模型能够关注不同的位置,而不仅仅是集中在一个单一单词上。这有效地创建了多个不同的向量子空间,在这些子空间中,变换器可以为文本中单词模式的一个子集编码一个特定的泛化。在原始变换器论文中,模型使用了n = 8个注意力头,因此。多头设置中的降维目的是确保计算和拼接的成本几乎等于单个注意力头的全维度大小。
仔细观察,你会发现由Q和K的乘积创建的注意力矩阵(注意力头)具有相同的形状,并且它们都是方形的(行数与列数相等)。这意味着注意力矩阵只是将输入的嵌入序列旋转为新的嵌入序列,而不改变它们的形状或大小。这使得我们可以解释注意力矩阵在处理特定示例输入文本时的作用。
在图9.4中,你可以看到构成多头自注意力变换器层的PyTorch层。事实证明,多头注意力机制实际上只是一个完全连接的线性层,从内部来看。当一切都完成后,深度学习模型最深层的部分无非是巧妙地堆叠了本质上是线性回归和逻辑回归的结构。这就是为什么变换器能够如此成功的原因,也正是为什么理解早期章节中描述的线性回归和逻辑回归的基础知识对你来说如此重要。
9.2 填补注意力机制的空白
注意力机制弥补了之前章节中讨论的RNN和CNN的一些问题,但也带来了额外的挑战。基于RNN的编码器-解码器在处理较长的文本段落时效果不佳,特别是在相关的单词模式相隔较远时。即使是长句子,对于进行翻译的RNN来说也是一种挑战。16 注意力机制通过允许语言模型拾取文本开头的重要概念,并将其与接近结尾的文本连接起来,弥补了这一点。这个机制为变换器提供了一种方式,可以回溯到它曾经见过的任何单词。不幸的是,加入注意力机制迫使你从变换器中移除所有的递归。
CNN是另一种连接输入文本中远距离概念的方法。CNN通过创建卷积层的层次结构来实现这一点,逐步缩小(压缩和编码)它所处理文本中的信息编码。这种层次结构意味着CNN拥有关于长文本中模式大规模位置的信息。不幸的是,卷积层的输出和输入通常具有不同的形状。因此,CNN是不可堆叠的,这使得它们在扩展以应对更大的容量和更大的训练数据集时变得棘手。为了给变换器提供所需的统一数据结构,以支持堆叠性,变换器使用字节对编码和位置编码,将语义和位置信息均匀地分布在编码张量中。
9.2.1 位置编码
输入文本中的单词顺序是有意义的,因此你需要一种方法,将一些位置信息嵌入到在变换器层之间传递的嵌入序列中。位置编码只是一个函数,它向输入嵌入添加关于单词在序列中相对或绝对位置的信息。这些编码与输入嵌入具有相同的维度 d_model,因此可以与嵌入向量相加。“Attention is All You Need”讨论了学习型和固定型位置编码,并提出了一种使用不同频率的正弦和余弦函数的正弦波函数,定义如下:
这个映射函数之所以被选择,是因为对于任何偏移量k,PE(pos+k)可以表示为PE(pos)的线性函数。简而言之,模型应该能够轻松学习相对位置的注意力。
让我们看看如何在PyTorch中实现这一点。如以下代码所示,官方的PyTorch序列到序列建模教程使用了基于前述函数的PositionalEncoding nn.Module实现。
代码清单 9.1 PyTorch PositionalEncoding
>>> 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), :] #6
... return self.dropout(x)
- #1 AIAYN中推荐的位置信息丢弃率为10%。
- #2
d_model - #3 标记位置(索引)是pe(位置编码)矩阵的第一维(行),嵌入维度是列。
- #4 pe项与标记位置的正弦或余弦成正比。
- #5 pe矩阵是对嵌入向量的加性偏置。
- #6 AIAYN中推荐的位置信息丢弃率为10%。
你将在你构建的翻译变换器中使用这个模块。然而,首先,你需要填补模型的其余细节,以便完成对架构的理解。
9.2.2 连接所有组件
现在,既然你已经了解了BPE、嵌入、位置编码和多头自注意力的原理与原因,你就掌握了变换器层的所有元素。你只需要在输出端添加一个低维线性层,将所有的注意力权重聚集在一起,以生成输出的嵌入序列,并且该线性层的输出需要进行缩放(归一化),以确保所有层具有相同的尺度。这些线性和归一化层堆叠在注意力层之上,从而创建可重用、可堆叠的变换器块,如图9.5所示。
你可以看到,输入提示在底部进入原始变换器模型,在序列的标记嵌入被传入编码器之前,位置编码会被添加。编码器包含N层注意力头,后面是完全连接的前馈层。所有子层(注意力层和完全连接层)的输出都会应用层归一化。N个解码器层的结构与编码器层完全相同,每个解码器层都有一个注意力头、层归一化、完全连接层和最后的层归一化。第一个解码器层添加了一个额外的注意力头和归一化层,用于处理来自先前生成的输出标记的左移标记序列。此处的框图修改了原始图示,明确显示了输出标记返回到解码器输入。还展示了替代的输出标记选择算法,如束搜索和深度优先搜索。
在“Attention is All You Need”中描述的原始变换器中,有N = 6个编码器层以及六个解码器层。然而,较新的生成预训练变换器(GPT)架构已经取消了编码器层,且大型语言模型通常堆叠了数百个相同的解码器层。若想深入了解现代GPT架构,可以查看Grant Sanderson的3Blue1Brown可视化和解释。18
编码器
编码器由多个编码器层组成。每个编码器层有两个子层:一个多头注意力层和一个逐位置的完全连接的前馈网络。每个子层都有一个残差连接,且每个编码器层的输出会进行归一化,使得在层之间传递的编码值都位于0到1之间。变换器层(PyTorch模块)中所有子层的输出(注意力层和完全连接层)在层之间传递时,其维度为d_model,传递给编码器的输入嵌入序列与位置编码相加后再输入到编码器中。
解码器
解码器与编码器几乎相同,但它有三个子层,而不是两个。新增加的子层是一个完全连接层,类似于多头自注意力矩阵,但这个新子层只包含零和一。这会创建一个屏蔽,屏蔽掉当前目标标记右侧的输出序列(对于像英语这样的从左到右的语言)。这确保了位置i的预测只能依赖于较小位置i的先前输出。换句话说,在训练过程中,注意力矩阵不允许“提前窥视”它应该生成的后续标记,以最小化损失函数。这防止了训练过程中的泄漏或“作弊”,迫使变换器只关注它已经看到或生成的标记。在RNN的解码器中不需要屏蔽,因为每次只有一个标记被揭示给网络,但在训练期间,变换器的注意力矩阵能够一次性访问整个序列。图9.6展示了如何堆叠多个变换器编码器层,并将它们连接到多个变换器解码器层,以创建一个能够一次生成一个标记的变换器。
9.2.3 变换器翻译
变换器适用于许多任务。“Attention Is All You Need”展示了一种变换器,它在翻译准确性上超过了任何先前的方法。使用torchtext,你将准备Multi30k数据集,用于训练一个德语到英语的变换器翻译模型,使用torch.nn.Transformer模块。在这一部分中,你将自定义变换器类的解码器部分,以输出每个子层的自注意力权重。你将使用这些自注意力权重矩阵来解释输入的德语文本中的单词是如何结合起来的,从而生成用于生成英语文本的嵌入。训练完模型后,你将用它对测试集进行推理,亲自查看它如何将德语文本翻译成英语。
准备数据
你可以使用Hugging Face的datasets包来简化数据处理,并确保你的文本以可预测的格式输入到变换器中,这种格式与PyTorch兼容,如下所示。这是任何深度学习项目中最棘手的部分之一:确保数据集的结构和API与PyTorch训练循环所期望的一致。翻译数据集尤其棘手,除非你使用Hugging Face。下一个代码清单从Hugging Face加载Helsinki NLP的Opus Books数据集。该数据集包含1600万对对齐的句子,涵盖16种不同的语言,旨在训练翻译语言模型。
代码清单 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
})
})
#1 Hugging face datasets 包
并非所有Hugging Face数据集都预定义了测试和验证数据集的划分。但你总是可以使用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.'}}
#1 使用内置的“iter”函数将Hugging Face的可迭代对象转换为Python迭代器
如果你想使用自己创建的自定义数据集,遵循开放标准总是个好主意,比如使用Hugging Face数据集包,如代码清单9.2所示,它为你提供了结构化数据集的最佳实践方法。请注意,Hugging Face中的翻译数据集包含配对句子的数组,并且在字典中存储了语言代码。翻译示例的字典键是两个字母的语言代码(来自ISO 639-2标准),19 字典值是数据集中每种语言的句子。
提示:如果你抵制自己发明数据结构的冲动,而是使用广泛认可的开放标准,你将避免潜在的、通常难以察觉的bug。
如果你有GPU可用,你可能希望使用它来训练变换器。变换器是为GPU设计的,它们的矩阵乘法操作用于算法中所有计算密集的部分。CPU对于大多数预训练的变换器模型(LLM除外)是足够的,但GPU可以为训练或微调变换器节省大量时间。例如,GPT2在一个相对较小(40 MB)的训练数据集上,在16核CPU上训练需要三天时间,而在2,560核GPU上训练同样的数据集仅需2小时(速度提升40倍,核心数提升160倍)。以下代码将启用可用的GPU(如果有的话)。
代码清单 9.4 启用任何可用的GPU
>>> DEVICE = torch.device(
... 'cuda' if torch.cuda.is_available()
... else 'cpu')
为了简化操作,你可以分别使用专用的分词器对源语言和目标语言文本进行标记化。如果你使用Hugging Face的分词器,它们将跟踪变换器所需的所有特殊标记,这些标记几乎适用于任何机器学习任务:
- 序列开始标记:通常是"
<SOS>"或"<s>" - 序列结束标记:通常是"
<EOS>"或"</s>" - 词汇外(未知)标记:通常是"
<OOV>"或"<unk>" - 掩码标记:通常是"
<mask>" - 填充标记:通常是"
<pad>"
序列开始标记用于触发解码器生成适合序列中第一个标记的标记。许多生成任务要求你使用序列结束标记,这样解码器就知道何时停止递归生成更多标记。有些数据集使用相同的标记表示序列开始和序列结束标记。它们不需要唯一,因为解码器总是知道它何时开始一个新的生成循环。填充标记用于填充序列的末尾,适用于短于最大序列长度的示例。掩码标记用于有意隐藏已知标记,用于训练任务无关的编码器,如BERT。这类似于你在第6章中为训练词嵌入使用skip-grams时所做的。
你可以为这些标记(特殊)标记选择任何标记,但你需要确保它们不是数据集中已用的单词。所以,如果你在写一本关于自然语言处理的书,并且不希望你的分词器对示例中的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)
#1 源语言(SRC)是英语('en')。
#2 目标语言(TGT)是德语('de')。
#3 ByteLevel分词器效率较低,但比字符级分词器更健壮(没有OOV标记)。
你的BPE分词器的ByteLevel部分确保你的分词器在标记化文本时不会错过任何细节(或字节)。字节级BPE分词器始终可以通过结合其词汇表中256个可能的单字节标记之一来构建任何字符。这意味着它可以处理任何使用Unicode字符集的语言。如果它之前没有见过某个字符或未包含该字符,它将回退到表示Unicode字符的单个字节。它在表示包含新字符或未见过的标记的文本时需要比通常多70%的标记(几乎是词汇大小的两倍)。
字符级BPE分词器也有其缺点。字符级分词器必须在其词汇中包含每个多字节的Unicode字符,以避免任何无意义的词汇外(OOV)标记。这可能为需要处理大部分Unicode字符的多语言变换器创建一个巨大的词汇表。在现实世界中,当优化变换器的BPE分词器以节省内存,并平衡变换器在问题上的准确性时,通常可以忽略历史语言和一些罕见的现代语言。
注:BPE分词器是变换器五个关键“超级能力”之一,使得它们如此有效。字节级BPE分词器在表示单词意义方面不如字符级分词器有效,尽管它们永远不会有OOV标记。因此,在生产应用中,你可能希望同时训练字符级BPE分词器和字节级分词器。这样,你可以比较结果,并选择最适合你应用的方案(准确性和速度)。
翻译变换器模型
此时,你已经对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。为了增加一些趣味,创建一个自定义解码器,额外输出来自解码器每个子层的多头自注意力权重列表,如下所示。这听起来可能有点复杂,但实际上,如果你只是继承torch.nn.TransformerDecoderLayer和torch.nn.TransformerDecoder并增强forward()方法来返回附加输出——即注意力权重,它是相当直接的。
代码清单 9.5 使用自定义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:
... """与解码类似,但返回多头注意力权重。"""
... 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
#1 保存来自多头注意力层的权重
#2 除了目标输出外,还返回注意力权重
代码清单 9.6 使用自定义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:
... """像`TransformerDecoder`一样,但缓存多头注意力"""
... 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
#1 每次调用`forward()`时重置注意力权重的列表
#2 保存来自该解码器层的注意力权重
对父类版本的forward()方法唯一的修改是将权重缓存到列表成员变量attention_weights中。
总结一下,你扩展了torch.nn.TransformerDecoder及其子层组件torch.nn.TransformerDecoderLayer,主要是出于探索目的。也就是说,你将保存变换器模型中不同解码器层的多头自注意力权重,然后进行配置和训练。每个类中的forward()方法几乎原封不动地复制了父类中的方法,唯一的不同是修改部分,用来保存注意力权重。
torch.nn.Transformer是序列到序列模型的一个简化版本,包含了主要的秘密武器——编码器和解码器中的多头自注意力。如该模块的源代码所示,20该模型并不假定使用嵌入层或位置编码。现在,你将创建一个TranslationTransformer模型,使用自定义解码器组件,扩展torch.nn.Transformer模块,如代码清单9.7所示。首先定义构造函数,它接受src_vocab_size(源语言嵌入大小)和tgt_vocab_size(目标语言嵌入大小)参数,并使用它们初始化基本的torch.nn.Embedding。注意,在构造函数中创建了一个PositionalEncoding成员pos_enc,用于添加单词位置信息。
代码清单 9.7 扩展nn.Transformer用于翻译,使用自定义解码器
>>> 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
- #1
einops使得张量重塑更容易,使用数学家熟悉的符号。 - #2
TranslationTransformer扩展了torch.nn.Transformer。 - #3 创建
CustomDecoderLayer的实例,以供CustomDecoder使用。 - #4 创建
CustomDecoder的实例,它收集来自CustomDecoderLayers的注意力权重,用于变换器。 - #5 定义输入和目标序列的嵌入层。
- #6 为源语言和目标语言序列添加位置编码。
- #7 用于目标单词概率的最终线性层。
请注意,rearrange已从einops包导入。21它在张量重塑和打乱时非常有用,尤其是在研究生水平的应用数学课程中很常见。要了解为什么需要rearrange()张量,请参阅torch.nn.Transformer文档。22如果你将任何张量的维度弄错了,它会破坏整个管道,有时甚至不可见。
代码清单 9.8 torch.nn.Transformer形状和维度描述
S: 源序列长度
T: 目标序列长度
N: 批量大小
E: 嵌入维度(特征数量)
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创建的数据集是批量优先的——批量大小是形状元组中的第一个维度(N)。因此,借用变换器文档中的命名法,你的源语言和目标语言张量的形状分别是(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,其余为0。prepare_src()方法生成填充掩码,然后将源语言张量重塑为模型期望的形状。接着,方法应用位置编码,并将源语言嵌入与模型维度的平方根相乘。该方法返回应用了位置编码的源语言张量及其对应的填充掩码。
prepare_tgt()方法用于目标序列,它与prepare_src()几乎完全相同。该方法返回调整过位置编码的目标序列(tgt),并返回目标键填充掩码(target key padding mask)。然而,它还返回一个“后续”掩码tgt_mask,这是一个三角矩阵,矩阵中的列(1)允许被观察。要生成后续掩码,可以使用基类中定义的Transformer.generate_square_subsequent_mask()方法。
Listing 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)之后,喂入一个线性归约层,如下所示。我们这样做是为了保持训练和推理的一致性。
Listing 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所有子模块的权重,如Listing 9.12所示。Xavier初始化通常用于Transformer,因此在这里使用它。PyTorch的nn.Module文档描述了apply(fn)方法,该方法递归地将fn应用到调用者的每个子模块。
Listing 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
#1 调用模型的apply()方法。行末的分号(;)会在IPython和Jupyter笔记本中抑制apply()的输出,但并不是必须的。
模型的各个组成部分已经定义,完整的模型如Listing 9.13所示。
Listing 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)
#1 掩盖解码器中所有对未来(后续)标记的注意力,以防止在训练时泄漏。
最终,你拥有了一个完整的Transformer!你应该能够用它在几乎任何语言对之间进行翻译,甚至是像繁体中文和日语这样的字符丰富的语言。而且,你可以明确访问所有可能需要调整的超参数。例如,你可以增加目标或源语言的词汇大小,以高效处理繁体中文和日语这样的字符丰富语言。
注意:繁体中文和日语(汉字)被称为字符丰富语言,因为它们拥有比欧洲语言更多的独特字符。中文和日语使用表意字符,看起来有点像小的象形符号或抽象的象形文字。例如,日语中的“日”字可以表示“日”,看起来像你在日历中可能看到的日块。日语的表意字符大致相当于单词片段,介于英语语言中的词素和单词之间。这意味着你会在表意语言中拥有比欧洲语言更多的独特字符。例如,传统日语使用约3500个独特的汉字,而英语在最常见的20000个单词中大约有7000个独特的音节。
你甚至可以根据源(编码器)或目标(解码器)语言改变编码器和解码器两侧的层数。你还可以创建一个翻译Transformer,简化文本,用于向五岁小孩或成人解释复杂的概念,或者在专注于对话的ELI5(像我五岁一样解释)Mastodon服务器上进行交流。如果减少解码器中的层数,这会创建一个“容量”瓶颈,迫使解码器简化或压缩从编码器输出的概念。同样,编码器或解码器层中的注意力头数可以调整,以增加或减少Transformer的容量(复杂性)。
训练TranslationTransformer
现在,让我们为翻译任务创建一个模型实例并初始化权重,为训练做好准备,如Listing 9.14所示。在模型的维度方面,你可以使用默认值,这些默认值与原始“Attention Is All You Need” Transformer的尺寸相对应。由于编码器和解码器的构建块由重复、可堆叠的层组成,你可以根据需要配置模型的层数。
Listing 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
#1 显示模型的字符串表示,查看你创建了什么
PyTorch会创建一个漂亮的__str__表示模型,显示所有层及其内部结构,包括输入和输出的形状。你甚至可以看到模型的层与本章或网上的Transformer图示之间的对比。从模型字符串表示的前半部分,你可以看到所有编码器层的结构完全相同。每个TransformerEncoderLayer的输入和输出形状相同,这确保了你可以堆叠它们而不需要在它们之间重塑线性层。Transformer层就像摩天大楼的楼层或孩子堆叠的木块,每一层的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)
)
...
注意,你在构造函数中设置了源语言和目标语言词汇表的大小。你还传递了源语言和目标语言填充标记的索引,供模型在准备源语言、目标语言和相关掩码序列时使用。现在你已经定义了模型,可以稍作检查,确保没有明显的代码错误,然后再设置训练和推理管道。你可以创建一批随机整数张量作为源语言和目标语言,并将它们传递给模型,如Listing 9.15所示。
Listing 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])
#1 使用torch.randint(low, high, size),其中size是张量形状的元组 #2 对模型进行前向传递,输入为src和tgt
我们创建了两个张量src和tgt,它们包含1到100之间的随机整数,且均匀分布。你的模型接受批处理优先(batch-first)形状的张量,因此我们确保批处理大小(在此例中为10)相同;否则,在前向传递时会出现运行时错误,类似于:
RuntimeError: the batch number of src and tgt must be equal
显然,源语言和目标语言的序列长度不需要匹配,这通过成功调用model(src, tgt)得到了验证。
提示:在为训练设置新的序列到序列模型时,你可能想要初始使用较小的调优参数。这包括限制最大序列长度、减少批处理大小,并指定较小的训练循环或epoch数量。这将使你更容易调试模型或管道中的问题,从而更快地实现端到端执行。在这个启动阶段,注意不要对模型的能力或准确性做出任何结论;目标只是让管道运行起来。
现在,你可以自信地认为模型准备就绪,下一步是定义训练的优化器和标准,如Listing 9.16所示。“Attention Is All You Need”使用了带有预热期的Adam优化器,在预热期内学习率增加,随后训练期间学习率逐渐下降。你将使用一个静态学习率1e–4,它比Adam的默认学习率1e–2要小。这应该提供稳定的训练,只要你运行足够的epoch。你可以通过学习率调度进行实验,作为练习。接下来,本章中你会看到其他基于Transformer的模型也使用静态学习率。对于标准的此类任务,你将使用torch.nn.CrossEntropyLoss作为标准。
Listing 9.16 优化器和标准
LEARNING_RATE = 0.0001
optimizer = torch.optim.Adam(model.parameters(), lr=LEARNING_RATE)
criterion = nn.CrossEntropyLoss(ignore_index=TRG_PAD_IDX) #1
#1 忽略输入梯度计算中的填充标记
Ben Trevett为PyTorch Transformer初学者教程贡献了大量代码。他和他的同事们编写了一个出色的、富有信息的Jupyter Notebook系列,用于他们的PyTorch Seq2Seq教程,涵盖了序列到序列模型。他们的“Attention Is All You Need”笔记本提供了一个从头实现基本Transformer模型的实现。为了避免重新发明轮子,接下来的训练和评估驱动代码来自Ben的笔记本,并做了些许修改。
train()函数实现了一个类似于你见过的其他训练循环。在批处理迭代之前,记得将模型置于训练模式,如Listing 9.17所示。同时请注意,目标语言中的最后一个标记是EOS标记,因此在将其作为输入传递给模型之前,我们会将其去掉。我们希望模型预测字符串的结尾。该函数返回每次迭代的平均损失。
Listing 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)
#1 确保模型处于训练模式 #2 去掉trg中的最后一个标记,即EOS标记,这样它就不会作为模型的输入
evaluate()函数与train()类似。你将模型设置为评估模式,并像往常一样使用torch.no_grad()范式进行推理。
Listing 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)
#1 将模型设置为评估模式 #2 禁用推理时的梯度计算
接下来,定义一个简单的实用函数epoch_time(),用于计算训练过程中经过的时间,如Listing 9.19所示。
Listing 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
现在,让我们继续设置训练。你将训练的epoch数设置为15,给模型足够的机会使用之前选择的学习率1e–4进行训练。你可以尝试不同的学习率和epoch数量的组合。在未来的示例中,你将使用早停机制来避免过拟合和不必要的训练时间。在这里,你声明了一个BEST_MODEL_FILE文件名,在每个epoch之后,如果验证损失相比之前的最好损失有所改善,则保存模型并更新最好损失,如Listing 9.20所示。
Listing 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,因为验证损失在退出循环之前仍在下降。让我们加载最好的模型并在测试集上运行evaluate()函数,查看模型的表现。
Listing 9.21 加载并评估最好的TranslationTransformer
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 |
你的翻译Transformer在测试集上实现了约1.6的对数损失。对于在如此小的数据集上训练的翻译模型来说,这个结果还不错。对数损失为1.59对应着20%的概率(exp(-1.59))生成正确的标记和它在测试集中提供的确切位置。由于对于给定的德语文本有许多不同正确的英语翻译,这对于一个能够在普通笔记本电脑上训练的模型来说是一个合理的准确度。
TranslationTransformer推理
希望到目前为止,你已经确信你的模型已经准备好成为你个人的德语到英语翻译器。执行翻译只需要稍微多一点的设置工作,这部分工作你可以在Listing 9.22中的translate_sentence()函数中完成。简而言之,首先,如果源句子尚未进行分词处理,则开始分词,并用<sos>和<eos>标记进行封装。接着,调用模型的prepare_src()方法来转换源序列,并生成源语言的键填充掩码,就像在训练和评估中所做的那样。然后,将准备好的源数据(src)和源语言键填充掩码(src_key_padding_mask)传入模型的编码器,并保存输出(enc_src)。接下来是有趣的部分:生成目标句子或翻译。首先,初始化一个列表trg_indexes,其中包含<sos>标记。在循环中——只要生成的序列没有达到最大长度——将当前的预测trg_indexes转换为张量。使用模型的prepare_tgt()方法准备目标序列,创建目标键填充掩码和目标句子掩码。将当前的解码器输出、编码器输出和这两个掩码传入解码器。获取解码器输出中的最新预测标记,并将其添加到trg_indexes中。如果预测是<eos>标记(或达到了最大句子长度),则退出循环。该函数返回目标索引(转换为单词)和模型解码器的注意力权重。
Listing 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
#1 准备源字符串,使用<sos>和<eos>标记将其封装起来 #2 用<sos>标记的索引初始化trg_indexes(预测值) #3 每次循环时,获取最新预测的标记 #4 在遇到<eos>标记时,退出推理循环
translate_sentence()函数将你的大Transformer模型封装成一个便捷的包,你可以用它来翻译任何你遇到的德语句子。
TRANSLATIONTRANSFORMER推理:示例 1
现在,你可以在一个示例文本上使用translate_sentence()函数。由于你可能不懂德语,你可以使用测试数据中的一个随机示例。试试句子:“Eine Mutter und ihr kleiner Sohn genießen einen schönen Tag im Freien”。在OPUS(Opus Parallel Corpus)数据集中,字符大小写已经折叠,因此你传入Transformer的文本应该是“eine mutter und ihr kleiner sohn genießen einen schönen tag im freien”。而你所期望的正确翻译如下:A mother and her little [or young] son are enjoying a beautiful day outdoors。
Listing 9.23 加载test_data中索引为10的示例
example_idx = 10
src = vars(test_data.examples[example_idx])['src']
trg = vars(test_data.examples[example_idx])['trg']
print(src)
print(trg)
输出:
src = ['eine', 'mutter', 'und', 'ihr', 'kleiner', 'sohn', 'genießen',
'einen', 'schönen', 'tag', 'im', 'freien', '.']
trg = ['a', 'mother', 'and', 'her', 'young', 'song', 'enjoying',
'a', 'beautiful', 'day', 'outside', '.']
看起来OPUS数据集并不完美——目标(翻译)标记序列中缺少动词“are”,它位于“song”和“enjoying”之间。德语单词“kleiner”可以翻译为“little”或“young”,但是OPUS数据集示例只提供了其中一种可能的正确翻译。至于“young song”,这似乎有点奇怪。也许,这在OPUS测试数据集中是一个拼写错误。
现在,你可以通过你的翻译器将源标记序列传递进去,看看它如何处理这个模糊性。
Listing 9.24 翻译测试数据示例
translation, attention = translate_sentence(src, SRC, TRG, model, device)
print(f'translation = {translation}')
输出:
translation = ['a', 'mother', 'and', 'her', 'little', 'son', 'enjoying',
'a', 'beautiful', 'day', 'outside', '.', '<eos>']
有趣的是,似乎在OPUS数据集中,德语单词“sohn”(儿子)的翻译存在拼写错误。该数据集错误地将“sohn”从德语翻译为英语的“song”。根据上下文,模型似乎很好的推测出母亲(很可能)和她的小(年轻)儿子在一起。模型给出了形容词“little”而不是“young”,这是可以接受的,因为德语单词“kleiner”的直接翻译是“smaller”。
现在,让我们将注意力集中在注意力机制上。在你的模型中,你定义了一个CustomDecoder,它会在每次前向传播时保存每个解码器层的平均注意力权重。你现在拥有了翻译中的注意力权重。接下来,写一个函数使用matplotlib可视化每个解码器层的自注意力。
Listing 9.25 可视化Transformer解码器层的自注意力权重
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker
def display_attention(sentence, translation, attention_weights):
n_attention = len(attention_weights)
n_cols = 2
n_rows = n_attention // n_cols + n_attention % n_cols
fig = plt.figure(figsize=(15,25))
for i in range(n_attention):
attention = attention_weights[i].squeeze(0)
attention = attention.cpu().detach().numpy()
ax = fig.add_subplot(n_rows, n_cols, i+1)
ax.matshow(attention, cmap='gist_yarg')
ax.tick_params(labelsize=12)
ax.set_xticklabels([''] + ['<sos>'] +
[t.lower() for t in sentence]+['<eos>'],
rotation=45)
ax.set_yticklabels(['']+translation)
ax.xaxis.set_major_locator(ticker.MultipleLocator(1))
ax.yaxis.set_major_locator(ticker.MultipleLocator(1))
plt.show()
plt.close()
该函数绘制每个序列索引的注意力值,x轴是原始句子,y轴是翻译。我们使用gist_yarg色图,因为它使用了适合打印的灰度方案。现在,你可以显示“母亲和儿子享受美好的一天”的注意力。
Listing 9.26 可视化测试示例翻译的自注意力权重
display_attention(src, translation, attention_weights)
观察前两个解码器层的图表时,我们可以看到注意力的集中区域开始沿对角线发展(见图9.7)。
在随后的第三层和第四层中,注意力的集中区域似乎变得更加精细(见图9.8)。
在最后两层中,我们看到注意力主要集中在直接的逐字翻译上,沿着对角线,这正是你可能会预期的情况。注意到文章名词和形容词名词的搭配形成了阴影簇。例如,"son"显然与德语单词"sohn"有强烈的注意力联系,但同时也对"kleiner"给予了一定的关注。
你从测试集中随机选择了这个示例,以了解模型的翻译能力。注意力图显示,模型似乎能够识别句子中的关系,但词语的重要性仍然在很大程度上是位置性的。换句话说,原始句子中当前词语的德语单词通常被翻译为目标输出中相同或相似位置的英语单词。通过查看图9.9中第五层和第六层的注意力矩阵,你可以看到,英语单词“mother”与德语输出单词“mutter”之间有着强烈的联系。
TRANSLATIONTRANSFORMER推理:示例 2
再看一个例子,这次来自验证集,输入序列和输出序列中的子句顺序不同,看看注意力是如何发挥作用的。加载并打印位于下一个列表中验证样本索引25的数据。
Listing 9.27 加载valid_data中索引为25的示例
example_idx = 25
...
src = vars(valid_data.examples[example_idx])['src']
trg = vars(valid_data.examples[example_idx])['trg']
...
print(f'src = {src}')
print(f'trg = {trg}')
输出:
src = ['zwei', 'hunde', 'spielen', 'im', 'hohen', 'gras', 'mit',
'einem', 'orangen', 'spielzeug', '.']
trg = ['two', 'dogs', 'play', 'with', 'an', 'orange', 'toy', 'in',
'tall', 'grass', '.']
即使你德语理解不太好,也似乎很明显,橙色玩具("orangen spielzeug")位于源句子的末尾,而“在高草中”("im hohen gras")位于中间。然而,在英语句子中,“在高草中”完成了句子,而“与橙色玩具”是玩耍动作的直接接受者,位于句子的中间部分。使用你的模型翻译这个句子。
Listing 9.28 翻译验证数据样本
translation, attention = translate_sentence(src, SRC, TRG, model, device)
print(f'translation = {translation}')
输出:
translation = ['two', 'dogs', 'are', 'playing', 'with', 'an', 'orange',
'toy', 'in', 'the', 'tall', 'grass', '.', '<eos>']
这是一个相当令人激动的结果,考虑到模型大约训练了15分钟(具体时间取决于你的计算能力)。接下来,通过调用display_attention()函数,使用src、translation和attention来绘制注意力权重。
Listing 9.29 可视化验证示例翻译的自注意力权重
display_attention(src, translation, attention)
图9.10显示了最后两层(第5层和第6层)的图表。这个样本很好地展示了注意力权重是如何突破顺序位置模式,实际上关注句子中较早或较晚的词,展示了多头自注意力机制的真正独特性和强大能力。
为了总结这一部分,你将计算模型的双语评估代替分数(BLEU score)。torchtext包提供了一个函数bleu_score来完成这个计算。你可以使用以下函数,再次来自Trevett先生的笔记本,用于对数据集进行推理并返回分数:
from torchtext.data.metrics import bleu_score
...
def calculate_bleu(data, src_field, trg_field, model, device,
max_len = 50):
trgs = []
pred_trgs = []
for datum in data:
src = vars(datum)['src']
trg = vars(datum)['trg']
pred_trg, _ = translate_sentence(
src, src_field, trg_field, model, device, max_len)
# 去掉 <eos> 标记
pred_trg = pred_trg[:-1]
pred_trgs.append(pred_trg)
trgs.append([trg])
return bleu_score(pred_trgs, trgs)
计算测试数据的BLEU分数:
bleu_score = calculate_bleu(test_data, SRC, TRG, model, device)
print(f'BLEU score = {bleu_score*100:.2f}')
输出:
BLEU score = 37.68
作为对比,Ben Trevett的教程代码使用卷积序列到序列模型,获得了33.3的BLEU分数,而小规模的Transformer模型得分大约为35。你的模型使用了与原始“Attention Is All You Need” Transformer相同的维度,因此它表现如此出色也就不足为奇了。
9.3 双向反向传播和BERT
有时,你可能想预测序列中间的某个词——例如,被屏蔽的词。Transformer模型也能处理这种情况。模型不必仅仅局限于从左到右按“因果”方式读取文本;它也可以从屏蔽词的另一侧从右到左读取文本。在生成文本时,你的模型被训练来预测文本末尾的未知词。但Transformer也可以预测文本中间的词,例如,如果你试图揭示被删除的穆勒报告中黑条掩盖的部分。
当你想预测示例文本中的未知词时,可以利用屏蔽词前后已知的词。人类读者或NLP管道可以从任何地方开始处理文本。而对于NLP,你总是有一个特定的文本片段,长度是有限的,你希望处理它。所以你可以从文本的末尾开始,从开始处开始,或者两者兼有!这是BERT用来创建任何文本内容的任务独立嵌入的洞察。BERT在训练过程中,学习了预测被屏蔽词的通用任务,类似于你在第6章中使用skip-gram训练词嵌入的方式。就像词嵌入训练一样,BERT通过简单地屏蔽掉单个词并训练一个双向Transformer模型来恢复屏蔽词,从无标签文本中创建了大量有用的训练数据。
2018年,Google AI的研究人员推出了一个新的语言模型,他们称之为BERT。BERT并不是以《芝麻街》的角色命名的;它代表的是“双向编码器表示来自Transformer”(Bidirectional Encoder Representations from Transformers),基本上就是一个双向Transformer。双向Transformer是机器领域的一次巨大飞跃。在第10章中,你将学习到帮助Transformer(升级版RNN)在许多最难的NLP问题中登上榜单的三项技巧。让RNN具备同时从两个方向读取的能力就是其中一个创新技巧,它帮助机器在阅读理解任务上超越了人类。
BERT模型有两个版本——BERT base和BERT large——由一系列包含前馈和注意力层的编码器Transformer组成。与之前的Transformer模型(如OpenAI GPT)不同,BERT使用掩蔽语言建模(Masked Language Modeling,MLM)目标函数来训练深度双向Transformer。MLM涉及随机屏蔽输入序列中的标记,然后尝试根据上下文预测实际的标记。与典型的从左到右的语言模型训练相比,MLM目标使BERT能够通过在所有层中结合标记的左右上下文来更好地推广语言表示。BERT模型在半无监督的方式下进行了预训练,使用的是没有表格和图表的英文维基百科(25亿个单词)和BooksCorpus(8亿个单词,GPT也基于此进行训练)。通过简单调整输入和输出层,模型可以进行微调,以在特定的句子级别和标记级别任务上取得最先进的结果。
9.3.1 分词与预训练
BERT的输入序列可以模糊地表示一个单独的句子或一对句子。BERT使用WordPiece嵌入,每个序列的第一个标记总是设置为特殊的[CLS]标记。句子通过一个结尾的分隔符标记[SEP]来区分。序列中的标记通过一个单独的段落嵌入进一步区分,每个标记要么分配给句子A,要么分配给句子B。此外,还会向序列添加一个位置嵌入,使得输入表示的每个标记的位置是由相应的标记、段落和位置嵌入的总和形成的,如图9.11所示。
在预训练期间,一定比例的输入标记会被随机屏蔽(用[MASK]标记替代),模型需要预测这些被屏蔽标记的实际标记ID。在实践中,15%的WordPiece标记被选择进行屏蔽训练;然而,缺点是,在微调阶段没有[MASK]标记。为了解决这个问题,作者提出了一种公式,用来将选定的需要屏蔽的标记(15%)80%的时间替换为[MASK]标记。剩余的20%中,10%的时间替换为随机标记,10%的时间保持原始标记。此外,除了MLM目标的预训练外,还进行了下一个句子预测(NSP)的次级训练。许多下游任务,如问答(QA),依赖于理解两个句子之间的关系,单纯的语言建模无法解决这些问题。在NSP训练中,作者通过为每个样本选择句子对A和B,并将它们标记为IsNext和NotNext,生成了一个简单的二分类NSP任务。预训练样本的一半,句子B紧跟句子A出现在语料库中,另一半则随机选择句子B。这种简单的解决方案表明,有时不需要过度思考一个问题。
9.3.2 微调
对于大多数BERT任务,你会希望加载BERT base或BERT large模型,并将所有参数从预训练中初始化,然后针对你的特定任务微调模型。微调通常应该是直接的;你只需要插入特定任务的输入和输出,然后开始训练所有参数。从头开始训练模型与初步预训练相比,微调的开销要小得多。BERT在众多任务上表现得非常出色。例如,在发布时,BERT在通用语言理解评估(GLUE)基准测试中超越了当时最先进的OpenAI GPT模型。此外,BERT在斯坦福问答数据集(SQuAD v1.1)上也超越了顶级的系统(集成方法),该任务要求从给定的维基百科段落中选择一个文本片段作为问题的答案。不出所料,BERT在该任务的变体SQuAD v2.0中也表现最好,在这个变体中,问题的答案有时并不存在于提供的文本中。
9.3.3 实现
借鉴本章前面关于原始Transformer的讨论,BERT的配置中,L表示Transformer层数,H表示隐藏层大小,A表示自注意力头的数量。BERT base的配置为L = 12,H = 768,A = 12,总共有1.1亿个参数。BERT large的配置为L = 24,H = 1024,A = 16,共有3.4亿个参数!大型模型在所有任务上优于基础模型;然而,具体选择哪个模型可能取决于你可用的硬件资源,你可能会发现使用基础模型已经足够。对于基础模型和大型配置,预训练模型有大小写版本。对于无大小写版本,文本会在预训练WordPiece分词之前转换为全小写,而大小写模型则不做任何更改。
原始BERT实现作为TensorFlow Tensor2Tensor库的一部分开源发布。TensorFlow Hub作者在BERT学术论文发布时,还发布了一个Google Colab笔记本,展示了如何微调BERT进行句子对分类任务。运行该笔记本需要注册访问Google计算引擎,并获取Google云存储桶。写作时,Google继续为首次使用者提供资金支持,但通常在你用完初始试用额度后,你需要支付计算资源费用。
注:当你深入研究NLP模型,尤其是具有深层Transformer堆叠的模型时,可能会发现当前的计算机硬件不足以支持训练或微调大型模型的计算任务。你将需要评估为满足工作负载而构建个人计算机的成本,并将其与按需付费的云计算和虚拟计算服务进行对比。我们在本书中提到了基本的硬件要求和计算选项;然而,讨论“正确”的PC配置或提供详尽的竞争计算选项超出了本书的范围。除了Google计算引擎,附录E提供了设置Amazon Web Services(AWS)GPU的说明。
BERT模型的PyTorch版本最初是在pytorch-pretrained-bert库中实现的,随后被整合到必不可少的Hugging Face Transformers库中。你最好花一些时间阅读“Getting Started”文档,并查看该网站上对Transformer模型及相关任务的总结。要安装Transformers库,只需使用pip install transformers。安装后,从Transformers中导入BertModel,并使用BertModel.from_pretrained()API通过名称加载模型。你可以在接下来的代码中打印出加载的bert-base-uncased模型的结构概要,以了解其架构。
Listing 9.30 BERT架构的PyTorch总结
from transformers import BertModel
model = BertModel.from_pretrained('bert-base-uncased')
print(model)
输出:
BertModel(
(embeddings): BertEmbeddings(
(word_embeddings): Embedding(30522, 768, padding_idx=0)
(position_embeddings): Embedding(512, 768)
(token_type_embeddings): Embedding(2, 768)
(LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True)
(dropout): Dropout(p=0.1, inplace=False)
)
(encoder): BertEncoder(
(layer): ModuleList(
(0): BertLayer(
(attention): BertAttention(
(self): BertSelfAttention(
(query): Linear(in_features=768, out_features=768, bias=True)
(key): Linear(in_features=768, out_features=768, bias=True)
(value): Linear(in_features=768, out_features=768, bias=True)
(dropout): Dropout(p=0.1, inplace=False)
)
(output): BertSelfOutput(
(dense): Linear(in_features=768, out_features=768, bias=True)
(LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True)
(dropout): Dropout(p=0.1, inplace=False)
)
)
(intermediate): BertIntermediate(
(dense): Linear(in_features=768, out_features=3072, bias=True)
)
(output): BertOutput(
(dense): Linear(in_features=3072, out_features=768, bias=True)
(LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True)
(dropout): Dropout(p=0.1, inplace=False)
)
... #1
(11): BertLayer(
(attention): BertAttention(...)
(intermediate): BertIntermediate(
(dense): Linear(in_features=768, out_features=3072, bias=True)
)
(output): BertOutput(
(dense): Linear(in_features=3072, out_features=768, bias=True)
(LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True)
(dropout): Dropout(p=0.1, inplace=False)
) ) ) )
(pooler): BertPooler(
(dense): Linear(in_features=768, out_features=768, bias=True)
(activation): Tanh() ) )
#1 为了简洁起见,第2到第9层被省略。
加载BERT模型后,你可以显示其字符串表示以获取其结构概述。如果你正在考虑设计自己的自定义双向Transformer,这是一个很好的起点。但在大多数情况下,你可以直接使用BERT来创建准确表示大多数文本含义的英语文本编码。对于像聊天机器人意图标注(分类或标记)、情感分析、社交媒体内容审查、语义搜索和FAQ问答这样的应用,预训练的BERT模型可能就是你所需的一切。如果你考虑将嵌入存储在向量数据库中以进行语义搜索,原始BERT编码是最好的选择。
在下一部分,你将看到如何使用预训练的BERT模型来识别有毒的社交媒体消息。然后,你将看到如何通过在你的数据集上训练更多的epoch来微调BERT模型,以适应你的应用程序。你将学习如何通过微调BERT显著提高有毒评论分类的准确性,同时避免过拟合。
9.3.4 微调预训练的BERT模型用于文本分类
2018年,Conversation AI团队(由Jigsaw和Google联合组成)举办了一个Kaggle竞赛,旨在开发一个模型来检测社交媒体帖子中的各种类型的有毒内容。当时,LSTM和CNN是最先进的技术。具有注意力机制的双向LSTM在这场竞赛中取得了最佳成绩。BERT的优势在于,它可以同时从当前处理的词语左右两边学习词语上下文。这使得它在创建多用途编码或嵌入向量时特别有用,适用于分类问题,如检测有毒的社交媒体评论。而且,由于BERT是基于大量语料库进行预训练的,你不需要庞大的数据集或超级计算机,就能通过迁移学习的力量微调一个具有良好性能的模型。
在本节中,你将使用这个库快速微调一个预训练的BERT模型,用于分类有毒社交媒体帖子。之后,你将进行一些调整以改进模型,继续努力打击不良行为,消除网络喷子。
有毒数据集
你可以从kaggle.com下载“有毒评论分类挑战”数据集(archive.zip)。你可以将数据放在$HOME/.nlpia2-data/目录下,和书中其他所有大型数据集一起。如果愿意。当你解压archive.zip文件时,会看到它包含训练集(train.csv)和测试集(test.csv),以独立的CSV文件形式存储。在现实世界中,你可能会将训练集和测试集合并,创建自己的验证集和测试集样本。但为了使你的结果能够与竞赛网站上的数据进行比较,你应首先只使用训练集。
首先,使用pandas加载训练数据,并查看前几条记录,如Listing 9.31所示。通常,你会想查看数据集中的示例,以了解数据格式和内容。尝试做你要求模型做的任务,通常有助于判断问题是否适合NLP处理。以下是训练集中的前五个示例。幸运的是,数据集已按顺序排序,首先包含非有毒的帖子,因此你在本节开始时无需阅读任何有毒评论。如果你的奶奶叫Terri,那么你可以在这一节最后的代码块中的最后一行代码处闭上眼睛!
Listing 9.31 加载有毒评论数据集
import pandas as pd
df = pd.read_csv('data/train.csv') #1
df.head()
输出:
comment_text toxic severe obscene threat insult hate
Explanation\nWhy the edits made 0 0 0 0 0 0
D'aww! He matches this backgrou 0 0 0 0 0 0
Hey man, I'm really not trying 0 0 0 0 0 0
"\nMore\nI can't make any real 0 0 0 0 0 0
You, sir, are my hero. Any chan 0 0 0 0 0 0
>>> df.shape
(159571, 8)
#1 提取下载的有毒评论.csv文件到数据目录
好吧!幸运的是,前五条评论都不是粗俗的,所以它们适合在本书中打印。
提示:通常,在这个阶段,你会探索并分析数据,关注文本样本的特性和标签的准确性,或许还会问自己一些关于数据的问题。评论的长度通常是多少?句子长度或评论长度是否与毒性有关?可以考虑关注一些严重有毒的评论。它们与普通有毒评论有什么区别?类别分布是怎样的?在训练方法中,你是否需要考虑可能的类别不平衡问题?
你可能更希望开始训练模型,所以我们将数据集分为训练集和验证集(评估集)。由于有近160,000个样本可供模型调优,我们选择使用80/20的训练/测试拆分。
Listing 9.32 将数据集拆分为训练集和验证集
from sklearn.model_selection import train_test_split
random_state = 42
labels = ['toxic', 'severe', 'obscene', 'threat', 'insult', 'hate']
X = df[['comment_text']]
y = df[labels]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2,
random_state=random_state) #1
#1 设置一致的random_state,以确保每次运行时拆分结果相同
现在,你的数据已经存储在一个pandas DataFrame中,并具有描述性的列名,可以用来解释模型的测试结果。
接下来是最后一个数据提取、转换和加载(ETL)任务:添加一个包装函数,确保传递给Transformer的批处理示例具有正确的形状和内容。你将使用simpletransformers库,该库为各种Hugging Face模型提供了分类任务的包装器,包括多标签分类——不要与多类或多输出分类模型混淆。scikit-learn包还包含一个MultiOutputClassifier包装器,可以用来为每个目标标签创建多个估算器(模型)。
注:多标签分类器是一个为每个输入输出多个不同的预测离散分类标签(例如有毒、严重、粗俗)。对于多标签分类器,使用多热向量(multi-hot vectors),而不是在传统分类器中使用的单热向量(one-hot vectors)。这使得你的文本可以被赋予多个不同的标签。就像托尔斯泰的《安娜·卡列尼娜》中的虚构家庭一样,一个有毒评论可以在许多不同的方式下同时有毒。你可以把多标签分类器看作是为文本应用标签或表情符号。为了避免混淆,你可以将模型称为标签器(tagger)或标签模型(tagging model),以便他人不会误解。
由于每条评论可能被分配多个标签(零个或多个),因此MultiLabelClassificationModel是解决这种问题的最佳选择。根据文档,MultiLabelClassificationModel期望训练样本的格式为["text", [label1, label2, label3, …]]。这保持了数据集的外部形状一致,无论你想跟踪多少种不同的毒性。Hugging Face的transformers模型能够处理这种数据结构的任何数量的标签(标签),但你需要在整个管道中保持一致,对于每个示例使用相同数量的标签。你需要一个固定维度的多热向量,包含零和一,这样你的模型就知道在哪个位置放置每种毒性预测。下一个代码示例展示了如何在训练和评估模型时,通过包装函数安排数据批次。
Listing 9.33 为模型创建数据集
def get_dataset(X, y):
data = [[X.iloc[i][0], y.iloc[i].values.tolist()]
for i in range(X.shape[0])]
return pd.DataFrame(data, columns=['text', 'labels'])
train_df = get_dataset(X_train, y_train)
eval_df = get_dataset(X_test, y_test)
print(train_df.shape, eval_df.shape)
输出:
((127656, 2), (31915, 2))
train_df.head() #1
text labels
0 Grandma Terri Should Burn in Trash \nGrandma T... [1, 0, 0, 0, 0, 0]
1 , 9 May 2009 (UTC)\nIt would be easiest if you... [0, 0, 0, 0, 0, 0]
2 "\n\nThe Objectivity of this Discussion is dou... [0, 0, 0, 0, 0, 0]
3 Shelly Shock\nShelly Shock is. . .( ) [0, 0, 0, 0, 0, 0]
4 I do not care. Refer to Ong Teng Cheong talk p... [0, 0, 0, 0, 0, 0]
#1 检查DataFrame格式是否与模型输入要求一致
现在,你可以看到,如果奶奶和外祖母成为网络霸凌者的侮辱对象,这个数据集的毒性标准就比较低。这意味着即使你正在保护极为敏感或年轻的用户,这个数据集也可能非常有用。如果你正试图保护那些已经习惯于在网上经历残酷对待的现代成年人或数字原住民,你可以通过其他来源的更极端示例来扩充这个数据集。
检测有毒评论与SimpleTransformers
现在,你已经有了一个函数,用于将批量标注文本传递给模型并打印一些信息,以监控进展。接下来是选择一个BERT模型进行下载。你只需要设置一些基本的参数,然后就可以加载一个预训练的BERT模型进行多标签分类并启动微调(训练)。
Listing 9.34 设置训练参数
import logging
logging.basicConfig(level=logging.INFO) #1
model_type = 'bert' #2
model_name = 'bert-base-cased'
output_dir = f'{model_type}-example1-outputs'
model_args = {
'output_dir': output_dir, # 保存结果的路径
'overwrite_output_dir': True, # 允许重新运行而无需手动清除输出目录
'manual_seed': random_state, #3
'no_cache': True,
}
#1 基本的日志设置,用于在训练期间输出模型的日志
#2 “model_type, model_name”将在接下来的代码段中用于加载base-cased BERT模型
#3 为了结果可重复,使用与train_test_split()中相同的种子值
在接下来的代码段中,你将加载预训练的bert-base-cased模型,并配置它以输出我们有毒评论数据集中的标签数量(共有六个标签),并使用你的model_args字典进行训练初始化。
Listing 9.35 加载预训练模型并进行微调
from sklearn.metrics import roc_auc_score
from simpletransformers.classification import MultiLabelClassificationModel
model = MultiLabelClassificationModel(
model_type, model_name, num_labels=len(labels),
args=model_args)
你应该训练这个模型以便能够用它进行预测和推理:
model.train_model(train_df=train_df) #1
#1 这是警告信息建议的“下游任务”微调。
train_model()函数为你做了大量工作。它加载了预训练的BertTokenizer,用于你选择的bert-base-cased模型,并利用它将train_df['text']分词为模型训练的输入。该函数将这些输入与train_df[labels]结合生成一个TensorDataset,并通过PyTorch的DataLoader进行包装,然后在批次中进行迭代,完成训练循环。
换句话说,通过几行代码和一次数据遍历(一个epoch),你就已经微调了一个拥有1.1亿参数的12层Transformer模型!接下来的问题是:这是否帮助了模型的翻译能力?我们来对验证集进行推理并检查结果。
Listing 9.36 评估
result, model_outputs, wrong_predictions = model.eval_model(eval_df,
acc=roc_auc_score) #1
print(result)
输出:
{'LRAP': 0.9955934600588362,
'acc': 0.9812396881786198,
'eval_loss': 0.04415484298031397}
#1 Jigsaw有毒评论分类挑战使用ROC AUC作为准确度度量。
受试者工作特征(ROC)曲线下的面积(AUC)度量通过计算分类器在精确度与召回率曲线下的积分(面积),平衡了分类器错误的不同方式。这确保了模型错误的概率值越自信,越会受到惩罚,而接近真实值的模型则会获得较低的惩罚。roc_auc_score函数会提供所有示例的微平均值,以及它为每个文本可能选择的不同标签。
ROC AUC微平均分数本质上是所有predict_proba错误值的总和,或者预测的概率值与每个示例由人工标注者提供的0或1值之间的差异。在评估模型准确性时,始终记得这个思维模型。准确度只是模型预测结果与人工标注者认定的正确答案之间的接近程度,并不是关于词语意义、意图或效果的绝对真理。有毒性是一个非常主观的特质。
ROC AUC分数为0.981开局不错。虽然它不会为你赢得任何荣誉,但它提供了一个令人鼓舞的反馈,表明你的训练模拟和推理设置是正确的。
eval_model()和train_model()的实现都在MultiLabelClassificationModel和ClassificationModel的基类中。评估代码应该对你来说很熟悉,因为它使用了with torch.no_grad()上下文管理器来进行推理,这也是我们所期望的。最好花时间查看方法的实现,特别是train_model(),它有助于了解你在下一节中选择的配置选项是如何在训练和评估过程中应用的。
更好的BERT
现在你已经有了一个初步的模型,你可以通过进一步的微调来提高BERT模型的性能。这里的“更好”意味着提高AUC分数。就像在现实世界中一样,你需要根据具体情况决定“更好”的含义。所以,别忘了关注模型的预测如何影响使用你模型的用户体验。如果你能找到一个更直接衡量“更好”的指标,你应该用它代替AUC分数,并在下面的代码中替换。
在前面的训练代码的基础上,现在是时候开始改进模型的准确度了。通过一些预处理清理文本是相对直接的。本书的示例源代码中有一个TextPreprocessor类,用于替换常见拼写错误、展开缩写以及执行其他杂项清理工作,如删除多余的空白字符。首先,将加载的train.csv数据框中的comment_text列重命名为original_text。然后应用预处理器到原始文本,并将清理后的文本存回comment_text列。
Listing 9.37 预处理评论文本
from preprocessing.preprocessing import TextPreprocessor
tp = TextPreprocessor()
loaded ./inc/preprocessing/json/contractions.json
loaded ./inc/preprocessing/json/misc_replacements.json
loaded ./inc/preprocessing/json/misspellings.json
df = df.rename(columns={'comment_text':'original_text'})
df['comment_text'] = df['original_text'].apply(
lambda x: tp.preprocess(x)) #1
pd.set_option('display.max_colwidth', 45)
df[['original_text', 'comment_text']].head()
#1 'original_text'保持不变,可以与新处理的'comment_text'进行对比。
清理文本后,接下来是调整模型初始化和训练参数。在第一次训练中,你接受了默认的输入序列长度(128),因为模型并未提供明确的max_sequence_length值。BERT base模型最多可以处理512长度的序列。当你增加max_sequence_length时,你可能需要减少train_batch_size和eval_batch_size以适应GPU内存,具体取决于可用的硬件。你可以探索评论文本的长度,找到一个最优的最大长度。请注意,在某个时刻,训练和评估时间增加时,使用更长的序列并不会显著提高模型准确度,所以你将获得递减的回报。对于这个例子,选择一个300的max_sequence_length,它介于默认值128和模型的最大容量之间。同时,显式选择train_batch_size和eval_batch_size以适应GPU内存。
警告:如果在训练或评估开始后不久出现GPU内存异常,你会很快意识到批处理大小设置得太大。而你不一定要基于此警告来最大化批处理大小。警告可能只会在训练过程较晚时出现,破坏了长时间运行的训练过程,而批处理大小较大并不总是更好。较小的批处理大小有时有助于使训练在梯度下降过程中更加随机(stochastic)。更随机的训练有时能帮助模型跳过高维非凸误差表面中的山脊和鞍点。
回想一下,在第一次微调时,模型训练了一个epoch。你可能的直觉是,模型可能训练得更久会取得更好的结果。这是正确的。你需要找到一个训练的最佳时机,避免模型过拟合训练样本。配置选项以在训练过程中启用评估,并设置早停参数。训练中的评估分数将用于早停。设置evaluate_during_training=True来启用评估,并设置use_early_stopping=True。随着模型学习如何泛化,性能在评估步骤之间可能会出现波动,因此你不想因为在最近一次评估步骤中的准确度下降而停止训练。配置早停的耐心度,这是指在没有改善的情况下(定义为大于某个delta的变化)进行的连续评估次数,你将设置early_stopping_patience=4,因为你有点耐心,但也有底线。使用early_stopping_delta=0,因为任何小的改进都算是进步。
在训练过程中反复将这些Transformer模型保存到磁盘(例如,在每次评估阶段后或每个epoch后)会占用时间和磁盘空间。对于这个例子,你希望保留训练过程中生成的最佳模型,因此指定best_model_dir来保存表现最好的模型,如下所示。将其保存在output_dir下的一个位置是方便的,这样你可以组织所有训练结果,便于后续的实验。
Listing 9.38 设置训练过程中的评估和早停参数
model_type = 'bert'
model_name = 'bert-base-cased'
output_dir = f'{model_type}-example2-outputs' #1
best_model_dir = f'{output_dir}/best_model'
model_args = {
'output_dir': output_dir,
'overwrite_output_dir': True,
'manual_seed': random_state,
'no_cache': True,
'best_model_dir': best_model_dir,
'max_seq_length': 300,
'train_batch_size': 24,
'eval_batch_size': 24,
'gradient_accumulation_steps': 1,
'learning_rate': 5e-5,
'evaluate_during_training': True,
'evaluate_during_training_steps': 1000,
'save_eval_checkpoints': False,
"save_model_every_epoch": False,
'save_steps': -1, # 保存模型会浪费时间
'reprocess_input_data': True,
'num_train_epochs': 5, #2
'use_early_stopping': True,
'early_stopping_patience': 4, #3
'early_stopping_delta': 0,
}
#1 将output_dir路径从“example1”更改为“example2”,以便区分不同模型
#2 num_train_epochs是最大epoch数;如果损失(loss)停止改善,训练可能会更早停止(“早停”)
#3 连续多少个没有改善的epoch后停止训练
通过调用model.train_model()来训练模型,正如之前所做的。这一次,你将evaluate_during_training设置为True,因此需要包含一个eval_df(你的验证数据集)。这允许你的训练过程在训练模型时估计模型在实际应用中的表现。如果验证准确度在几个(early_stopping_patience)epoch中持续下降,模型将停止训练,以避免继续恶化。
Listing 9.39 加载预训练模型并进行微调(带早停)
model = MultiLabelClassificationModel(
model_type, model_name, num_labels=len(labels),
args=model_args)
model.train_model(
train_df=train_df, eval_df=eval_df, acc=roc_auc_score,
show_running_loss=False, verbose=False)
在训练过程中,最好的模型已经保存在best_model_dir中。显而易见,这就是你要用于推理的模型。更新后的评估代码段会加载通过将best_model_dir传递给model_name参数来加载最佳模型。
Listing 9.40 使用最佳模型进行评估
best_model = MultiLabelClassificationModel(
model_type, best_model_dir,
num_labels=len(labels), args=model_args)
result, model_outputs, wrong_predictions = best_model.eval_model(
eval_df, acc=roc_auc_score)
print(result)
输出:
{'LRAP': 0.996060542761153,
'acc': 0.9893854727083252,
'eval_loss': 0.040633044850540305}
现在,结果看起来更好。0.989的准确率让我们与2018年初的顶级挑战解决方案竞争。也许你会认为98.9%的准确率有些过于完美。你是对的。流利的德语使用者需要深入挖掘几个翻译错误,而假阴性——那些被错误标记为正确的测试样本——则更难找出。
如果你像我一样,身边没有一个流利的德语翻译,那么你可能更欣赏一个更侧重英语的翻译应用程序:语法检查和纠正。即使你仍在学习英语,你也能体会到拥有一个个性化工具来帮助你写出语法正确的英文文本的好处。一个个性化的语法检查器可能是你个人的杀手级应用,帮助你提高沟通能力并推进你的NLP职业生涯。Transformer广泛应用于帮助写作者构建句子、段落甚至整篇文章。
Transformer真正闪光的应用是在生成合理的、没有实质内容的文本。聊天机器人就是Transformer的完美应用。你可以构建一个Transformer,将一个人的陈述转化为另一个人的合理回应,而不是将文本从英语翻译成德语。这可以为网站或客户服务聊天机器人提供无休止的聊天对话AI(缺少智能部分)。只要你不期望Transformer真正理解或推理任何事情,Transformer可以成为娱乐工具,甚至是语言学习的对话练习。毕竟,如果没有前额皮质来规划和推理,大脑的语言中枢也无法生成智能的词汇。
尽管如此,Transformer能够进行长时间的开放式对话,这让许多人对解决语言建模问题充满了希望。聪明的人们开始认为,如果Transformer被扩展,它们可能模仿甚至超越人类大脑语言中心的能力。在第10章中,你将看到扩展规模似乎能为Transformer带来一些惊人的新能力。你还将学会如何通过推理、规划和常识知识系统增强基于Transformer的语言模型,这是构建世界变革性对话机器智能的关键补充。
9.4 测试自己
-
Transformer层的输入和输出维度与其他深度学习层(如CNN、RNN或LSTM层)有何不同?
- Transformer层的输入和输出维度通常保持一致,并且所有层之间都使用相同的维度。这与CNN和RNN有所不同,它们的维度可能会在层与层之间发生变化。Transformer层的结构使得每一层都可以与其他层进行堆叠,避免了在其他深度学习层中常见的维度不一致问题。
-
如何扩展像BERT或GPT-2这样的Transformer网络的信息容量?
- 扩展Transformer网络的信息容量可以通过增加网络的深度(增加层数)、增加每层的隐藏单元数量、增大注意力头的数量、使用更大的词汇表以及增加训练数据的规模来实现。这些方法都会增加模型的学习和表示能力。
-
估算获取高准确度所需信息容量的经验法则是什么?
- 通常,信息容量与模型的参数数量和训练数据的规模密切相关。一个经验法则是,数据集越复杂,模型所需的参数和计算能力就越大。此外,足够多的训练数据可以帮助模型更好地泛化,并减少过拟合的风险。
-
衡量两个深度学习网络相对信息容量的好指标是什么?
- 衡量两个深度学习网络相对信息容量的一个好指标是它们的参数数量和每层的容量。更高的参数数量和更深的网络结构通常意味着网络有更多的表达能力,可以捕捉到更复杂的模式。
-
有什么技巧可以减少训练Transformer解决像摘要问题这样的问题所需的标注数据量?
- 可以通过迁移学习来减少所需的标注数据量。预训练模型(如BERT或GPT-2)可以在大规模无标注数据上进行训练,然后在特定任务上进行微调。这样,微调时所需的标注数据量大大减少。此外,数据增强和无监督学习也是常用的技巧。
-
如何衡量摘要生成器或翻译器的准确性或损失,特别是当一个问题可能有多个正确答案时?
- 对于有多个正确答案的问题,常用的衡量指标包括BLEU分数(用于机器翻译),ROUGE分数(用于摘要生成),或者F1分数。ROUGE和BLEU都会考虑到多个正确答案,并提供模型生成的候选答案与参考答案之间的重叠度。
总结
- 通过保持每一层输入和输出的一致性,Transformer获得了它的核心超能力:无限堆叠性。
- Transformer结合了三项关键创新,达到了改变世界的NLP能力:BPE分词、多头注意力和位置编码。
- GPT Transformer架构是大多数文本生成任务(如翻译和对话聊天机器人)的最佳选择。
- 尽管BERT Transformer模型已经发布超过五年(本书发布时),它仍然是大多数自然语言理解(NLU)问题的最佳选择。
- 如果你选择一个高效的预训练模型,你可以对其进行微调,使用便宜的硬件,如笔记本电脑或免费的在线GPU资源,在许多困难的Kaggle问题上取得竞争力的结果。