原文:Natural Language Processing with Transformers
译者:飞龙
前言
当你阅读这些文字时,一个奇迹正在发生:这页上的涂鸦正在在你的大脑皮层中转化为单词、概念和情感。我的 2021 年 11 月的思想现在已成功侵入你的大脑。如果它们设法引起你的注意并在这个严酷而竞争激烈的环境中存活足够长的时间,它们可能有机会再次通过你与他人分享这些思想而繁殖。由于语言,思想已经变得空气中传播并且高度传染的大脑细菌——而且没有疫苗即将出现。
幸运的是,大多数大脑细菌是无害的,而一些则非常有用。事实上,人类的大脑细菌构成了我们最宝贵的两个财富:知识和文化。就像我们没有健康的肠道细菌就无法正常消化一样,没有健康的大脑细菌,我们也无法正常思考。你的大部分想法实际上并不是你自己的:它们在感染你之前在许多其他大脑中产生、生长和演变。因此,如果我们想要构建智能机器,我们将需要找到一种方法来感染它们。
好消息是,在过去几年中,另一个奇迹已经在发生:深度学习的几项突破为强大的语言模型奠定了基础。由于你正在阅读这本书,你可能已经看到了一些令人惊讶的这些语言模型的演示,比如 GPT-3,它可以根据“一只青蛙遇到了一只鳄鱼”这样的简短提示来写一个完整的故事。虽然它还不是莎士比亚,但有时很难相信这些文本是由人工神经网络写的。事实上,GitHub 的 Copilot 系统正在帮助我写这些文字:你永远不会知道我真正写了多少。
这一革命远不止于文本生成。它涵盖了自然语言处理(NLP)的整个领域,从文本分类到摘要、翻译、问答、聊天机器人、自然语言理解(NLU)等等。无论何处有语言、语音或文本,都有 NLP 的应用。你已经可以向你的手机询问明天的天气,或者与虚拟帮助台助手聊天以解决问题,或者从似乎真正理解你的查询的搜索引擎中获得有意义的结果。但是这项技术是如此新颖,以至于最好的可能还在未来。
像大多数科学进步一样,NLP 领域的这一最新革命也是建立在数百位无名英雄的辛勤工作之上的。但是它的成功有三个关键因素是显而易见的:
- Transformer是一种神经网络架构,它是在 2017 年由一组谷歌研究人员发表的一篇开创性论文“注意力机制是你所需要的一切”中提出的。在短短几年内,它席卷了整个领域,击败了通常基于循环神经网络(RNNs)的先前架构。Transformer 架构在捕捉长序列数据中的模式和处理庞大数据集方面表现出色,以至于它的使用现在已经远远超出了 NLP 领域,例如扩展到图像处理任务。
- 在大多数项目中,你可能无法访问一个庞大的数据集来从头开始训练模型。幸运的是,通常可以下载一个在通用数据集上预训练的模型:然后你所需要做的就是在自己的(小得多的)数据集上进行微调。自 2010 年代初以来,在图像处理领域,预训练已经成为主流,但在自然语言处理(NLP)领域,它仅限于无上下文的词嵌入(即,个别词的密集向量表示)。例如,“bear”这个词在“teddy bear”和“to bear”中具有相同的预训练嵌入。然后,在 2018 年,几篇论文提出了完整的语言模型,可以在各种 NLP 任务中进行预训练和微调;这彻底改变了游戏规则。
- 模型中心,如 Hugging Face 的,也是一个改变游戏规则的存在。在早期,预训练模型随处可见,所以很难找到你需要的东西。墨菲定律保证了 PyTorch 用户只能找到 TensorFlow 模型,反之亦然。而且,当你找到一个模型时,弄清楚如何对其进行微调并不总是容易的。这就是 Hugging Face 的 Transformers 库发挥作用的地方:它是开源的,支持 TensorFlow 和 PyTorch,而且可以轻松地从 Hugging Face Hub 下载最先进的预训练模型,为你的任务配置它,对你的数据集进行微调,并进行评估。该库的使用量正在迅速增长:2021 年第四季度,它被超过五千家组织使用,并且每月使用
pip
安装超过四百万次。此外,该库及其生态系统正在扩展到 NLP 之外:图像处理模型也可用。您还可以从 Hub 下载大量数据集来训练或评估您的模型。
那么,你还能要求什么呢?嗯,就是这本书!它是由 Hugging Face 的开源开发人员撰写的,包括 Transformers 库的创造者!——这一点可以看出:你将在这些页面中找到的信息的广度和深度令人震惊。它涵盖了从 Transformer 架构本身到 Transformers 库及其周围整个生态系统的一切。我特别欣赏这种实践方法:你可以在 Jupyter 笔记本中跟着做,所有的代码示例都直截了当,易于理解。作者在训练非常大的 Transformer 模型方面拥有丰富的经验,并提供了大量的技巧和窍门,以确保一切都能高效运行。最后但同样重要的是,他们的写作风格直接而生动:读起来就像一部小说。
简而言之,我非常喜欢这本书,我相信你也会喜欢。任何对使用最先进的语言处理功能构建产品感兴趣的人都需要阅读它。它充满了所有正确的大脑细菌!
Aurélien Géron
2021 年 11 月,新西兰奥克兰
¹ 对于大脑卫生提示,请参阅 CGP Grey 的关于模因的优秀视频。
前言
自 2017 年推出以来,Transformer 已成为应对各种自然语言处理(NLP)任务的事实标准,无论是在学术界还是工业界。您可能没有注意到,但今天您可能已经与 Transformer 互动了:谷歌现在使用 BERT 来增强其搜索引擎,以更好地理解用户的搜索查询。同样,OpenAI 的 GPT 系列模型因其生成类似人类的文本和图像的能力而多次成为主流媒体的头条新闻。¹ 这些 Transformer 现在驱动着应用程序,比如GitHub 的 Copilot,正如图 P-1 所示,它可以将评论转换为自动生成的源代码,为您自动创建神经网络!
那么,是什么让 Transformer 几乎一夜之间改变了这个领域?就像许多伟大的科学突破一样,它是几个想法的综合,比如注意力、迁移学习和扩展神经网络,它们当时正在研究界中酝酿。
但是,无论它有多有用,要在行业中获得关注,任何新的花哨方法都需要工具来使其易于访问。
Transformers 库及其周边生态系统通过使从业者能够轻松使用、训练和共享模型来回应了这一需求。这大大加速了 Transformer 的采用,该库现在被超过五千个组织使用。在本书中,我们将指导您如何为实际应用程序训练和优化这些模型。
图 P-1. GitHub Copilot 的一个示例,根据任务的简要描述,该应用程序提供了整个类的建议(class
后面的所有内容都是自动生成的)
这本书适合谁?
这本书是为数据科学家和机器学习工程师写的,他们可能听说过涉及 Transformer 的最新突破,但缺乏深入指南来帮助他们将这些模型适应到自己的用例中。这本书不是为机器学习入门而写的,我们假设您能够熟练使用 Python 进行编程,并且对像PyTorch和TensorFlow这样的深度学习框架有基本的了解。我们还假设您有一些在 GPU 上训练模型的实际经验。尽管这本书侧重于
Transformers 的 PyTorch API,第二章向您展示了如何将所有示例转换为 TensorFlow。
以下资源为本书涵盖的主题提供了良好的基础。我们假设您的技术知识大致在它们的水平上:
- 使用 Scikit-Learn 和 TensorFlow 进行实践性机器学习,作者 Aurélien Géron(O’Reilly)
- 使用 fastai 和 PyTorch 进行深度学习,作者 Jeremy Howard 和 Sylvain Gugger(O’Reilly)
- 使用 PyTorch 进行自然语言处理,作者 Delip Rao 和 Brian McMahan(O’Reilly)
- Hugging Face 课程,由 Hugging Face 的开源团队
您将学到什么
本书的目标是让您能够构建自己的语言应用程序。为此,它侧重于实际用例,并只在必要时深入理论。本书的风格是实践性的,我们强烈建议您通过运行代码示例来进行实验。
本书涵盖了 NLP 中 Transformer 的所有主要应用,每一章(有少数例外)都专门致力于一个任务,结合一个现实的用例和数据集。每一章还介绍了一些额外的概念。以下是我们将要涵盖的任务和主题的高级概述:
- 第一章,你好,Transformer,介绍了 Transformer 并将其置于上下文中。它还介绍了 Hugging Face 生态系统。
- 第二章,“文本分类”,侧重于情感分析的任务(一种常见的文本分类问题),并介绍了
Trainer
API。 - 第三章,“Transformer 解剖”,更深入地探讨了 Transformer 架构,为接下来的章节做准备。
- 第四章,“多语言命名实体识别”,专注于在多种语言文本中识别实体的任务(一个标记分类问题)。
- 第五章,“文本生成”,探讨了 Transformer 模型生成文本的能力,并介绍了解码策略和度量标准。
- 第六章,“摘要”,深入探讨了文本摘要的复杂序列到序列任务,并探索了用于此任务的度量标准。
- 第七章,“问答”,侧重于构建基于评论的问答系统,并介绍了 Haystack 的检索功能。
- 第八章,“使 Transformer 在生产中更高效”,侧重于模型性能。我们将研究意图检测的任务(一种序列分类问题),并探索知识蒸馏、量化和修剪等技术。
- 第九章,“处理少量或没有标签的数据”,探讨了在缺乏大量标记数据的情况下改善模型性能的方法。我们将构建一个 GitHub 问题标记器,并探索零样本分类和数据增强等技术。
- 第十章,“从头开始训练 Transformer”,向您展示了如何从头开始构建和训练一个用于自动完成 Python 源代码的模型。我们将研究数据集流和大规模训练,并构建我们自己的分词器。
- 第十一章,“未来方向”,探讨了 Transformer 面临的挑战,以及这一领域的研究正在进行的一些令人兴奋的新方向。
Transformer 为使用和训练 Transformer 模型提供了几层抽象。我们将从易于使用的管道开始,通过几行代码就可以将文本示例传递给模型,并调查预测结果。然后我们将转向分词器、模型类和Trainer
API,这些允许我们为自己的用例训练模型。稍后,我们将向您展示如何用
加速库替换Trainer
,这使我们完全控制训练循环,并允许我们完全从头开始训练大规模 Transformer!尽管每一章基本上是独立的,但后面章节的任务难度会增加。因此,我们建议先从第一章和第二章开始,然后再进入最感兴趣的主题。
除了
Transformer 和
加速器,我们还将广泛使用
数据集,它与其他库无缝集成。
数据集提供了与 Pandas 类似的数据处理功能,但是从头开始设计,用于处理大型数据集和机器学习。
有了这些工具,您几乎可以解决任何自然语言处理挑战!
软件和硬件要求
由于本书的实践性方法,我们强烈建议您在阅读每一章时运行代码示例。由于我们涉及 Transformer,您需要访问一台配备 NVIDIA GPU 的计算机来训练这些模型。幸运的是,有几种免费的在线选项可供您使用,包括:
要运行示例,您需要按照我们在书的 GitHub 存储库中提供的安装指南。您可以在https://github.com/nlp-with-transformers/notebooks找到这个指南和代码示例。
提示
我们大部分章节都是使用 NVIDIA Tesla P100 GPU 开发的,它们有 16GB 的内存。一些免费平台提供的 GPU 内存较少,因此在训练模型时可能需要减少批处理大小。
本书使用的约定
本书使用以下排版约定:
斜体
表示新术语、URL、电子邮件地址、文件名和文件扩展名。
常量宽度
用于程序清单,以及在段落中引用程序元素,如变量或函数名、数据库、数据类型、环境变量、语句和关键字。
常量宽度粗体
显示用户应该按照字面意思输入的命令或其他文本。
常量宽度斜体
显示应由用户提供的值或由上下文确定的值替换的文本。
提示
这个元素表示提示或建议。
注意
这个元素表示一般说明。
警告
这个元素表示警告或注意。
致谢
要写一本关于机器学习中发展最快的领域之一的书,没有许多人的帮助是不可能的。我们感谢美妙的 O’Reilly 团队,特别是 Melissa Potter,Rebecca Novack 和 Katherine Tozer,感谢他们的支持和建议。这本书还受益于花费无数时间为我们提供宝贵反馈的令人惊叹的审阅者。我们特别感谢 Luca Perozzi,Hamel Husain,Shabie Iqbal,Umberto Lupo,Malte Pietsch,Timo Möller 和 Aurélien Géron 的详细审阅。我们感谢deepset的 Branden Chan 在扩展 Haystack 库以支持第七章中的用例方面的帮助。本书中美丽的插图要归功于了不起的Christa Lanz——感谢你让这本书变得格外特别。我们还很幸运地得到了整个 Hugging Face 团队的支持。非常感谢 Quentin Lhoest 在
数据集方面回答了无数问题,Lysandre Debut 在与 Hugging Face Hub 相关的一切方面的帮助,Sylvain Gugger 在
加速方面的帮助,以及 Joe Davison 在第九章中对零样本学习的启发。我们还要感谢 Sidd Karamcheti 和整个Mistral 团队为 GPT-2 添加稳定性调整,使第十章成为可能。这本书完全是用 Jupyter 笔记本写的,我们要感谢 Jeremy Howard 和 Sylvain Gugger 创建了像fastdoc这样令人愉快的工具,使这成为可能。
Lewis
对 Sofia,感谢你始终给予的支持和鼓励——没有这两者,这本书就不会存在。经过长时间的写作,我们终于可以再次享受周末了!
Leandro
感谢 Janine,在这漫长的一年里,有许多深夜和忙碌的周末,你的耐心和鼓励支持。
Thomas
我首先要感谢 Lewis 和 Leandro 提出了这本书的想法,并强烈推动以如此美丽和易于访问的格式出版。我还要感谢所有 Hugging Face 团队,他们相信 AI 是一个社区努力的使命,以及整个 NLP/AI 社区,他们与我们一起构建和使用本书中描述的库和研究。
我们所建立的不仅仅是重要的,我们所走过的旅程才是真正重要的,我们有幸能够与成千上万的社区成员和像你们今天一样的读者一起走这条路。衷心感谢你们所有人。
¹ NLP 研究人员倾向于以Sesame Street中的角色命名他们的创作。我们将在第一章中解释所有这些首字母缩略词的含义。
第一章:你好,Transformer
2017 年,谷歌的研究人员发表了一篇关于序列建模的新型神经网络架构的论文。这种被称为Transformer的架构在机器翻译任务中优于循环神经网络(RNN),无论是翻译质量还是训练成本。
与此同时,一种名为 ULMFiT 的有效迁移学习方法表明,对非常大和多样化的语料库进行长短期记忆(LSTM)网络训练可以产生具有很少标记数据的最先进文本分类器。
这些进步是当今两个最著名的 Transformer 的催化剂:生成式预训练 Transformer(GPT)和来自 Transformer 的双向编码器表示(BERT)。通过将 Transformer 架构与无监督学习相结合,这些模型消除了需要从头开始训练特定任务的架构,并在 NLP 几乎每个基准测试中取得了显著的突破。自 GPT 和 BERT 发布以来,出现了一系列 Transformer 模型;最突出的条目的时间表如图 1-1 所示。
图 1-1。Transformer 时间表
但我们正在走得太快了。要理解 Transformer 的新颖之处,我们首先需要解释:
- 编码器-解码器框架
- 注意机制
- 迁移学习
在本章中,我们将介绍支持 transformer 广泛应用的核心概念,参观一些它们擅长的任务,并最后看一下 Hugging Face 工具和库的生态系统。
让我们首先探讨编码器-解码器框架和在 transformer 崛起之前的架构。
编码器-解码器框架
在 transformer 之前,像 LSTM 这样的循环架构是自然语言处理中的最新技术。这些架构在网络连接中包含反馈循环,允许信息从一个步骤传播到另一个步骤,使它们非常适合对文本等序列数据进行建模。如图 1-2 左侧所示,RNN 接收一些输入(可以是单词或字符),通过网络传递,并输出一个称为隐藏状态的向量。同时,模型通过反馈循环向自身反馈一些信息,然后在下一步中使用。如果我们像图 1-2 右侧所示“展开”循环,就可以更清楚地看到这一点:RNN 在每一步中传递其状态的信息给序列中的下一个操作。这使得 RNN 能够跟踪先前步骤的信息,并将其用于输出预测。
图 1-2。在时间上展开 RNN
这些架构(并且继续)被广泛用于 NLP 任务、语音处理和时间序列。您可以在 Andrej Karpathy 的博客文章“循环神经网络的不合理有效性”中找到它们能力的精彩阐述。
RNN 在机器翻译系统的发展中发挥了重要作用,其目标是将一种语言中的单词序列映射到另一种语言。这种任务通常使用编码器-解码器或序列到序列架构,适用于输入和输出都是任意长度序列的情况。编码器的工作是将输入序列的信息编码成通常称为最后隐藏状态的数值表示。然后将该状态传递给解码器,解码器生成输出序列。
一般来说,编码器和解码器组件可以是任何能够建模序列的神经网络架构。这在图 1-3 中对一对 RNNs 进行了说明,其中英语句子“Transformers are great!”被编码为一个隐藏状态向量,然后解码以生成德语翻译“Transformer sind grossartig!”输入单词依次通过编码器,输出单词从上到下逐个生成。
图 1-3。具有一对 RNNs 的编码器-解码器架构(一般来说,这里显示的循环层比这里显示的要多得多)
尽管其简洁而优雅,这种架构的一个弱点是编码器的最终隐藏状态创建了一个信息瓶颈:它必须代表整个输入序列的含义,因为这是解码器在生成输出时所能访问的全部内容。这对于长序列尤其具有挑战性,因为序列开头的信息可能在压缩到单一固定表示的过程中丢失。
幸运的是,通过允许解码器访问编码器的所有隐藏状态,可以摆脱这一瓶颈。这一般机制称为注意力,⁶,它是许多现代神经网络架构的关键组成部分。了解注意力是如何为 RNNs 开发的将使我们能够更好地理解 Transformer 架构的主要构建模块之一。让我们深入了解一下。
注意机制
注意力的主要思想是,编码器不是为输入序列产生单个隐藏状态,而是在每一步输出一个解码器可以访问的隐藏状态。然而,同时使用所有状态会为解码器创建一个巨大的输入,因此需要一些机制来优先使用哪些状态。这就是注意力的作用:它允许解码器在每个解码时间步为每个编码器状态分配不同数量的权重或“注意力”。这个过程在图 1-4 中进行了说明,显示了注意力在预测输出序列的第三个标记时的作用。
图 1-4。具有注意机制的编码器-解码器架构,用于一对 RNNs
通过关注每个时间步最相关的输入标记,这些基于注意力的模型能够学习生成翻译中的单词与源句中的单词之间的非平凡对齐。例如,图 1-5 可视化了英语到法语翻译模型的注意权重,其中每个像素表示一个权重。该图显示了解码器如何能够正确对齐两种语言中顺序不同的单词“zone”和“Area”。
图 1-5。英语和法语生成翻译中的 RNN 编码器-解码器单词对齐(由 Dzmitry Bahdanau 提供)
尽管注意力使得翻译质量大大提高,但使用循环模型作为编码器和解码器仍然存在一个主要缺点:计算是固有的顺序性的,无法在输入序列上并行化。
使用 transformer 引入了一种新的建模范式:完全放弃循环,而是完全依赖一种称为自注意力的特殊形式的注意力。我们将在第三章中更详细地介绍自注意力,但基本思想是允许注意力作用于神经网络同一层中的所有状态。这在图 1-6 中显示,编码器和解码器都有自己的自注意机制,其输出被馈送到前馈神经网络(FF NNs)。这种架构可以比循环模型快得多地训练,并为自然语言处理中的许多最新突破铺平了道路。
图 1-6。原始 Transformer 的编码器-解码器架构
在原始 Transformer 论文中,翻译模型是从头开始在各种语言的大量句对语料库上进行训练的。然而,在 NLP 的许多实际应用中,我们无法获得大量标记文本数据来训练我们的模型。要启动 transformer 革命,还缺少最后一块拼图:迁移学习。
自然语言处理中的迁移学习
如今,在计算机视觉中,通常使用迁移学习来训练卷积神经网络(如 ResNet)进行一个任务的训练,然后在新任务上对其进行调整或微调。这允许网络利用从原始任务中学到的知识。在架构上,这涉及将模型分为主体和头部,其中头部是一个特定任务的网络。在训练过程中,主体的权重学习源域的广泛特征,并使用这些权重来初始化新任务的新模型。⁷与传统监督学习相比,这种方法通常能够在各种下游任务上更有效地训练高质量的模型,并且需要更少的标记数据。图 1-7 显示了这两种方法的比较。
图 1-7。传统监督学习(左)和迁移学习(右)的比较
在计算机视觉中,模型首先在包含数百万张图片的大规模数据集(如ImageNet)上进行训练。这个过程称为预训练,其主要目的是教会模型图片的基本特征,如边缘或颜色。然后,这些预训练模型可以在下游任务上进行微调,例如使用相对较少的标记示例(通常每类几百个)对花卉物种进行分类。微调模型通常比从头开始训练的监督模型在相同数量的标记数据上实现更高的准确性。
尽管迁移学习已成为计算机视觉中的标准方法,但多年来,对于自然语言处理的类似预训练过程并不清楚。因此,NLP 应用通常需要大量标记数据才能实现高性能。即使如此,其性能也无法与视觉领域所实现的性能相媲美。
在 2017 年和 2018 年,几个研究小组提出了新的方法,最终使得迁移学习在自然语言处理中起作用。这始于 OpenAI 研究人员的洞察,他们通过使用从无监督预训练中提取的特征,在情感分类任务上取得了强大的性能。⁸接着是 ULMFiT,它引入了一个通用框架,用于调整预训练的 LSTM 模型以适应各种任务。⁹
如图 1-8 所示,ULMFiT 包括三个主要步骤:
预训练
初始的训练目标非常简单:根据先前的单词预测下一个单词。这个任务被称为语言建模。这种方法的优雅之处在于不需要标记的数据,并且可以利用来自维基百科等来源的大量可用文本。¹⁰
领域适应
一旦语言模型在大规模语料库上预训练,下一步就是将其适应于领域语料库(例如,从维基百科到 IMDb 电影评论的语料库,如图 1-8 所示)。这个阶段仍然使用语言建模,但现在模型必须预测目标语料库中的下一个单词。
微调
在这一步中,语言模型通过一个用于目标任务的分类层进行微调(例如,在图 1-8 中对电影评论的情感进行分类)。
图 1-8. ULMFiT 过程(由 Jeremy Howard 提供)
通过引入 NLP 中的预训练和迁移学习的可行框架,ULMFiT 提供了使 Transformer 起飞的缺失环节。2018 年,发布了两个将自注意力与迁移学习相结合的 Transformer:
GPT
仅使用 Transformer 架构的解码器部分,以及 ULMFiT 相同的语言建模方法。GPT 是在 BookCorpus 上预训练的,¹¹其中包括来自各种流派的 7000 本未发表的书籍,包括冒险、奇幻和浪漫。
BERT
使用 Transformer 架构的编码器部分,以及一种特殊形式的语言建模称为掩码语言建模。掩码语言建模的目标是预测文本中随机掩码的单词。例如,给定一个句子“我看着我的[MASK]
,看到[MASK]
迟到了。”模型需要预测由[MASK]
表示的掩码单词的最可能的候选项。BERT 是在 BookCorpus 和英文维基百科上预训练的。
GPT 和 BERT 在各种 NLP 基准测试中树立了新的技术水平,并开启了 Transformer 时代。
然而,由于不同的研究实验室在不兼容的框架(PyTorch 或 TensorFlow)中发布其模型,对于 NLP 从业者来说,将这些模型移植到自己的应用程序并不总是容易的。随着
Transformers的发布,逐渐构建了超过 50 种架构的统一 API。这个库催生了对 Transformer 的研究爆炸,并迅速渗透到 NLP 从业者中,使得将这些模型整合到今天的许多实际应用程序中变得容易。让我们来看看!
Hugging Face Transformers:弥合差距
将新的机器学习架构应用于新任务可能是一个复杂的过程,通常涉及以下步骤:
- 在代码中实现模型架构,通常基于 PyTorch 或 TensorFlow。
- 从服务器加载预训练的权重(如果可用)。
- 预处理输入,将其通过模型,并应用一些特定于任务的后处理。
- 实现数据加载器,并定义损失函数和优化器来训练模型。
每个步骤都需要为每个模型和任务编写自定义逻辑。传统上(但并非总是如此!),当研究小组发布新文章时,他们通常会连同模型权重一起发布代码。然而,这些代码很少是标准化的,通常需要数天的工程来适应新的用例。
这就是
Transformers 对 NLP 从业者的救援之处!它为各种 Transformer 模型提供了标准化接口,以及用于适应这些模型到新用例的代码和工具。该库目前支持三个主要的深度学习框架(PyTorch、TensorFlow 和 JAX),并允许您轻松地在它们之间切换。此外,它提供了任务特定的头部,因此您可以轻松地在下游任务上微调 Transformer,如文本分类、命名实体识别和问题回答。这减少了从实践者训练和测试一些模型所需的时间,从一周减少到一个下午!
您将在下一节中亲自看到这一点,在那里我们将展示,只需几行代码,
Transformers 就可以应用于解决您在实际中可能遇到的一些最常见的 NLP 应用。
Transformer 应用之旅
每个 NLP 任务都始于一段文本,比如以下关于某个在线订单的虚构客户反馈:
text = """Dear Amazon, last week I ordered an Optimus Prime action figure from your online store in Germany. Unfortunately, when I opened the package, I discovered to my horror that I had been sent an action figure of Megatron instead! As a lifelong enemy of the Decepticons, I hope you can understand my dilemma. To resolve the issue, I demand an exchange of Megatron for the Optimus Prime figure I ordered. Enclosed are copies of my records concerning this purchase. I expect to hear from you soon. Sincerely, Bumblebee."""
根据您的应用,您正在处理的文本可能是法律合同、产品描述或其他内容。在客户反馈的情况下,您可能想知道反馈是积极的还是消极的。这项任务被称为情感分析,是文本分类的更广泛主题的一部分,我们将在第二章中探讨。现在,让我们看看如何从我们的文本中提取情感,使用
Transformers。
文本分类
正如我们将在后面的章节中看到的,
Transformers 具有分层 API,允许您以不同的抽象级别与库进行交互。在本章中,我们将从管道开始,它将所有将原始文本转换为经过精细调整的模型一系列预测所需的步骤抽象出来。
在
Transformers 中,我们通过调用pipeline()
函数并提供我们感兴趣的任务的名称来实例化一个管道:
from transformers import pipeline classifier = pipeline("text-classification")
第一次运行此代码时,您将看到一些进度条出现,因为管道会自动从Hugging Face Hub下载模型权重。第二次实例化管道时,库将注意到您已经下载了权重,并将使用缓存版本。默认情况下,text-classification
管道使用的是专为情感分析设计的模型,但它也支持多类和多标签分类。
现在我们有了我们的管道,让我们生成一些预测!每个管道都将一个文本字符串(或字符串列表)作为输入,并返回一系列预测。每个预测都是一个 Python 字典,所以我们可以使用 Pandas 将它们漂亮地显示为DataFrame
:
import pandas as pd outputs = classifier(text) pd.DataFrame(outputs)
标签 | 分数 | |
0 | NEGATIVE | 0.901546 |
在这种情况下,模型非常确信文本具有消极情绪,这是有道理的,因为我们正在处理一个愤怒客户的投诉!请注意,对于情感分析任务,管道只返回POSITIVE
或NEGATIVE
标签中的一个,因为另一个可以通过计算1-score
来推断。
现在让我们来看另一个常见的任务,识别文本中的命名实体。
命名实体识别
预测客户反馈的情绪是一个很好的第一步,但通常您想知道反馈是否是关于特定的项目或服务。在自然语言处理中,像产品、地点和人这样的现实世界对象被称为命名实体,从文本中提取它们被称为命名实体识别(NER)。我们可以通过加载相应的管道并将我们的客户评论提供给它来应用 NER:
ner_tagger = pipeline("ner", aggregation_strategy="simple") outputs = ner_tagger(text) pd.DataFrame(outputs)
实体组 | 分数 | 单词 | 开始 | 结束 | |
0 | ORG | 0.879010 | 亚马逊 | 5 | 11 |
1 | MISC | 0.990859 | 奥普蒂默斯·普莱姆 | 36 | 49 |
2 | LOC | 0.999755 | 德国 | 90 | 97 |
3 | MISC | 0.556569 | Mega | 208 | 212 |
4 | PER | 0.590256 | ##tron | 212 | 216 |
5 | ORG | 0.669692 | 欺诈者 | 253 | 259 |
6 | MISC | 0.498350 | ##icons | 259 | 264 |
7 | MISC | 0.775361 | Megatron | 350 | 358 |
8 | MISC | 0.987854 | 奥普蒂默斯·普莱姆 | 367 | 380 |
9 | PER | 0.812096 | 大黄蜂 | 502 | 511 |
您可以看到管道检测到了所有实体,并为每个实体分配了类别,例如ORG
(组织)、LOC
(位置)或PER
(人)。在这里,我们使用了aggregation_strategy
参数根据模型的预测对单词进行分组。例如,实体“奥普蒂默斯·普莱姆”由两个单词组成,但被分配了一个单一的类别:MISC
(杂项)。分数告诉我们模型对其识别的实体有多自信。我们可以看到它对“欺诈者”和“Megatron”的第一次出现的识别最不自信,它们都未能被分组为单个实体。
注释
看到上一个表格中word
列中的奇怪的井号符号(#
)了吗?这些是模型的分词器产生的,它将单词分割成称为标记的原子单位。您将在第二章中学习有关标记化的所有内容。
提取文本中的所有命名实体是不错的,但有时我们想提出更有针对性的问题。这就是我们可以使用问答的地方。
问答
在问答中,我们向模型提供了一段文本,称为上下文,以及一个我们想要提取答案的问题。然后模型返回对应于答案的文本范围。让我们看看当我们针对客户反馈提出具体问题时会得到什么:
reader = pipeline("question-answering") question = "What does the customer want?" outputs = reader(question=question, context=text) pd.DataFrame([outputs])
分数 | 开始 | 结束 | 答案 | |
0 | 0.631291 | 335 | 358 | 与 Megatron 的交换 |
我们可以看到,除了答案之外,管道还返回了start
和end
整数,这些整数对应于找到答案跨度的字符索引(就像 NER 标记一样)。我们将在第七章中调查几种问答的变体,但这种特定的称为抽取式问答,因为答案直接从文本中提取。
通过这种方法,您可以快速阅读并从客户的反馈中提取相关信息。但是,如果您收到一大堆冗长的投诉,而您没有时间全部阅读,该怎么办呢?让我们看看摘要模型是否能帮上忙!
摘要
文本摘要的目标是将长文本作为输入,并生成一个包含所有相关事实的简短版本。这比以前的任务要复杂得多,因为它要求模型生成连贯的文本。现在应该是一个熟悉的模式,我们可以像下面这样实例化一个摘要管道:
summarizer = pipeline("summarization") outputs = summarizer(text, max_length=45, clean_up_tokenization_spaces=True) print(outputs[0]['summary_text'])
Bumblebee ordered an Optimus Prime action figure from your online store in Germany. Unfortunately, when I opened the package, I discovered to my horror that I had been sent an action figure of Megatron instead.
这个摘要还不错!虽然原始文本的部分被复制,但模型能够捕捉到问题的本质,并正确识别“大黄蜂”(出现在末尾)是投诉的作者。在这个例子中,您还可以看到我们传递了一些关键字参数,如max_length
和clean_up_tokenization_spaces
给管道;这些允许我们在运行时调整输出。
但是当您收到一种您不懂的语言的反馈时会发生什么?您可以使用谷歌翻译,或者您可以使用自己的转换器为您翻译!
翻译
与摘要类似,翻译是一个输出生成文本的任务。让我们使用翻译管道将英文文本翻译成德文:
translator = pipeline("translation_en_to_de", model="Helsinki-NLP/opus-mt-en-de") outputs = translator(text, clean_up_tokenization_spaces=True, min_length=100) print(outputs[0]['translation_text'])
Sehr geehrter Amazon, letzte Woche habe ich eine Optimus Prime Action Figur aus Ihrem Online-Shop in Deutschland bestellt. Leider, als ich das Paket öffnete, entdeckte ich zu meinem Entsetzen, dass ich stattdessen eine Action Figur von Megatron geschickt worden war! Als lebenslanger Feind der Decepticons, Ich hoffe, Sie können mein Dilemma verstehen. Um das Problem zu lösen, Ich fordere einen Austausch von Megatron für die Optimus Prime Figur habe ich bestellt. Anbei sind Kopien meiner Aufzeichnungen über diesen Kauf. Ich erwarte, bald von Ihnen zu hören. Aufrichtig, Bumblebee.
同样,该模型产生了一个非常好的翻译,正确使用了德语的正式代词,如“Ihrem”和“Sie”。在这里,我们还展示了如何覆盖管道中的默认模型,以选择最适合您应用的模型——您可以在 Hugging Face Hub 上找到成千上万种语言对的模型。在我们退后一步,看看整个 Hugging Face 生态系统之前,让我们再看一个应用。
文本生成
假设您希望能够通过访问自动完成功能更快地回复客户反馈。使用文本生成模型,您可以这样做:
generator = pipeline("text-generation") response = "Dear Bumblebee, I am sorry to hear that your order was mixed up." prompt = text + "\n\nCustomer service response:\n" + response outputs = generator(prompt, max_length=200) print(outputs[0]['generated_text'])
Dear Amazon, last week I ordered an Optimus Prime action figure from your online store in Germany. Unfortunately, when I opened the package, I discovered to my horror that I had been sent an action figure of Megatron instead! As a lifelong enemy of the Decepticons, I hope you can understand my dilemma. To resolve the issue, I demand an exchange of Megatron for the Optimus Prime figure I ordered. Enclosed are copies of my records concerning this purchase. I expect to hear from you soon. Sincerely, Bumblebee. Customer service response: Dear Bumblebee, I am sorry to hear that your order was mixed up. The order was completely mislabeled, which is very common in our online store, but I can appreciate it because it was my understanding from this site and our customer service of the previous day that your order was not made correct in our mind and that we are in a process of resolving this matter. We can assure you that your order
好吧,也许我们不想使用这个完成来安抚大黄蜂,但您大致明白了。
现在您已经看到了一些转换器模型的很酷的应用,您可能想知道训练是在哪里进行的。本章中使用的所有模型都是公开可用的,并且已经针对手头的任务进行了微调。然而,一般来说,您可能希望在自己的数据上微调模型,在接下来的章节中,您将学习如何做到这一点。
但是,训练模型只是 NLP 项目的一小部分——能够高效处理数据、与同事分享结果以及使您的工作可复制也是关键组成部分。幸运的是,
Transformers 周围有一个大型生态系统,支持现代机器学习工作流的许多有用工具。让我们来看看。
Hugging Face 生态系统
从
Transformers 开始,迅速发展成为一个由许多库和工具组成的整个生态系统,以加速您的 NLP 和机器学习项目。 Hugging Face 生态系统主要由两部分组成:一系列库和 Hub,如图 1-9 所示。库提供代码,而 Hub 提供预训练模型权重、数据集、用于评估指标的脚本等。在本节中,我们将简要介绍各个组件。我们将跳过
Transformers,因为我们已经讨论过它,并且在本书的过程中还会看到更多。
图 1-9. Hugging Face 生态系统概述
Hugging Face Hub
正如前面所述,迁移学习是推动转换器成功的关键因素之一,因为它使得可以重用预训练模型来处理新任务。因此,能够快速加载预训练模型并进行实验至关重要。
Hugging Face Hub 托管了超过 20,000 个免费可用的模型。如图 1-10 所示,有任务、框架、数据集等过滤器,旨在帮助您浏览 Hub 并快速找到有前途的候选模型。正如我们在管道中看到的那样,在您的代码中加载一个有前途的模型实际上只是一行代码的距离。这使得尝试各种模型变得简单,并且让您可以专注于项目的领域特定部分。
图 1-10. Hugging Face Hub 的模型页面,左侧显示了过滤器,右侧显示了模型列表
除了模型权重,Hub 还托管数据集和用于计算指标的脚本,这些脚本可以让您重现已发布的结果或利用额外的数据进行应用。
Hub 还提供模型和数据集 卡片,以记录模型和数据集的内容,并帮助您对是否适合您做出明智的决定。Hub 最酷的功能之一是,您可以通过各种特定任务的交互式小部件直接尝试任何模型,如图 1-11 所示。
图 1-11. Hugging Face Hub 的示例模型卡片:右侧显示了允许您与模型交互的推理小部件
让我们继续我们的旅程与 Tokenizers。
注意
PyTorch 和 TensorFlow 也提供了自己的中心,并且值得检查,如果 Hugging Face Hub 上没有特定的模型或数据集。
Hugging Face 标记化器
在本章中我们看到的每个管道示例背后都有一个标记化步骤,将原始文本分割成称为标记的较小部分。我们将在第二章中详细介绍这是如何工作的,但现在理解标记可能是单词、单词的部分,或者只是标点符号等字符就足够了。Transformer 模型是在这些标记的数值表示上进行训练的,因此正确进行这一步对整个 NLP 项目非常重要!
Tokenizers 提供许多标记化策略,并且由于其 Rust 后端,它在标记化文本方面非常快速。它还负责所有的预处理和后处理步骤,例如规范化输入和将模型输出转换为所需的格式。使用 Tokenizers,我们可以像加载预训练模型权重一样加载标记器。
我们需要一个数据集和指标来训练和评估模型,所以让我们来看看负责这方面的数据集。
Hugging Face 数据集
加载、处理和存储数据集可能是一个繁琐的过程,特别是当数据集变得太大而无法容纳在您的笔记本电脑的 RAM 中时。此外,通常需要实现各种脚本来下载数据并将其转换为标准格式。
Datasets 通过为数千个数据集提供标准接口来简化这个过程。它还提供智能缓存(这样您就不必每次运行代码时都重新进行预处理),并通过利用一种称为内存映射的特殊机制来避免 RAM 限制,该机制将文件的内容存储在虚拟内存中,并使多个进程更有效地修改文件。该库还与流行的框架如 Pandas 和 NumPy 兼容,因此您无需离开您喜爱的数据整理工具的舒适区。
然而,如果您无法可靠地衡量性能,拥有一个好的数据集和强大的模型是毫无价值的。不幸的是,经典的 NLP 指标有许多不同的实现,可能会略有不同,并导致误导性的结果。通过提供许多指标的脚本,Datasets 有助于使实验更具再现性,结果更值得信赖。
有了 Transformers、Tokenizers 和 Datasets 库,我们有了训练自己的 Transformer 模型所需的一切!然而,正如我们将在第十章中看到的那样,有些情况下我们需要对训练循环进行细粒度控制。这就是生态系统中最后一个库发挥作用的地方:Accelerate。
Hugging Face 加速
如果您曾经不得不在 PyTorch 中编写自己的训练脚本,那么当尝试将在笔记本电脑上运行的代码移植到组织的集群上运行时,可能会遇到一些头痛。Accelerate 为您的正常训练循环添加了一层抽象,负责处理训练基础设施所需的所有自定义逻辑。这实际上通过简化必要时的基础设施更改来加速您的工作流程。
这总结了 Hugging Face 开源生态系统的核心组件。但在结束本章之前,让我们看一看在尝试在现实世界中部署 Transformer 时会遇到的一些常见挑战。
Transformer 的主要挑战
在本章中,我们已经对可以使用 Transformer 模型解决的各种自然语言处理任务有了一瞥。阅读媒体头条时,有时会觉得它们的能力是无限的。然而,尽管它们很有用,Transformer 远非灵丹妙药。以下是与它们相关的一些挑战,我们将在整本书中探讨:
语言
自然语言处理研究主要以英语为主导。还有一些其他语言的模型,但很难找到稀有或低资源语言的预训练模型。在第四章中,我们将探讨多语言 Transformer 及其进行零-shot 跨语言转移的能力。
数据可用性
尽管我们可以使用迁移学习大大减少模型需要的标记训练数据量,但与人类执行任务所需的量相比,仍然是很多。解决标记数据很少或没有的情况是第九章的主题。
处理长文档
自注意力在段落长的文本上效果非常好,但当我们转向整个文档等更长的文本时,成本就会变得非常高。我们将在第十一章中讨论缓解这一问题的方法。
不透明性
与其他深度学习模型一样,Transformer 在很大程度上是不透明的。很难或不可能解开模型为何做出某种预测的“原因”。当这些模型被部署用于做出关键决策时,这是一个特别困难的挑战。我们将在第二章和第四章中探讨一些探究 Transformer 模型错误的方法。
偏见
Transformer 模型主要是在互联网文本数据上进行预训练的。这会将数据中存在的所有偏见都印刻到模型中。确保这些偏见既不是种族主义的、性别歧视的,也不是更糟糕的,是一项具有挑战性的任务。我们将在第十章中更详细地讨论其中一些问题。
尽管令人生畏,许多这些挑战是可以克服的。除了特定提到的章节外,我们将在接下来的几乎每一章中涉及这些主题。
结论
希望到目前为止,您已经对如何开始训练和将这些多才多艺的模型集成到您自己的应用程序中感到兴奋!您在本章中已经看到,只需几行代码,您就可以使用最先进的模型进行分类、命名实体识别、问答、翻译和摘要,但这实际上只是“冰山一角”。
在接下来的章节中,您将学习如何将 Transformer 适应于各种用例,比如构建文本分类器,或者用于生产的轻量级模型,甚至从头开始训练语言模型。我们将采取实践方法,这意味着对于每个涵盖的概念,都会有相应的代码,您可以在 Google Colab 或您自己的 GPU 机器上运行。
现在我们已经掌握了 Transformer 背后的基本概念,是时候开始动手进行我们的第一个应用了:文本分类。这是下一章的主题!
¹ A. Vaswani et al., “Attention Is All You Need”, (2017). 这个标题非常吸引人,以至于不少于50 篇后续论文的标题中都包含了“all you need”!
² J. Howard and S. Ruder, “Universal Language Model Fine-Tuning for Text Classification”, (2018).
- A. Radford et al., “Improving Language Understanding by Generative Pre-Training”, (2018).
- J. Devlin et al., “BERT: Pre-Training of Deep Bidirectional Transformers for Language Understanding”, (2018).
- I. Sutskever, O. Vinyals, and Q.V. Le, “Sequence to Sequence Learning with Neural Networks”, (2014).
- D. Bahdanau, K. Cho, and Y. Bengio, “Neural Machine Translation by Jointly Learning to Align and Translate”, (2014).
- 权重是神经网络的可学习参数。
- A. Radford, R. Jozefowicz, and I. Sutskever, “Learning to Generate Reviews and Discovering Sentiment”, (2017).
- 在这个时候的相关工作是 ELMo(来自语言模型的嵌入),它展示了如何通过预训练 LSTMs 可以为下游任务生成高质量的词嵌入。
- 这对于英语而言更为真实,而对于世界上大多数语言来说,获取大规模的数字化文本语料库可能会很困难。找到弥合这一差距的方法是自然语言处理研究和活动的一个活跃领域。
- Y. Zhu et al., “Aligning Books and Movies: Towards Story-Like Visual Explanations by Watching Movies and Reading Books”, (2015).
- Rust是一种高性能的编程语言。
第三章:Transformer 剖析
在第二章中,我们看到了微调和评估 Transformer 所需的内容。现在让我们来看看它们在内部是如何工作的。在本章中,我们将探索 Transformer 模型的主要构建模块以及如何使用 PyTorch 实现它们。我们还将提供如何在 TensorFlow 中进行相同操作的指导。我们首先将专注于构建注意力机制,然后添加必要的部分来使 Transformer 编码器工作。我们还将简要介绍编码器和解码器模块之间的架构差异。通过本章结束时,您将能够自己实现一个简单的 Transformer 模型!
虽然通常不需要对 Transformer 架构有深入的技术理解来使用
Transformer 并为您的用例微调模型,但对于理解和应对 Transformer 的局限性并在新领域中使用它们可能会有所帮助。
本章还介绍了 Transformer 的分类法,以帮助您了解近年来出现的各种模型。在深入代码之前,让我们先来看一下开启 Transformer 革命的原始架构的概述。
Transformer 架构
正如我们在第一章中所看到的,原始 Transformer 基于“编码器-解码器”架构,这种架构被广泛用于诸如机器翻译之类的任务,其中一个词序列被翻译成另一种语言。这种架构由两个组件组成:
编码器
将输入的令牌序列转换为嵌入向量序列,通常称为“隐藏状态”或“上下文”
解码器
使用编码器的隐藏状态迭代地生成一个令牌序列的输出,每次生成一个令牌
如图 3-1 所示,编码器和解码器本身由几个构建模块组成。
图 3-1。Transformer 的编码器-解码器架构,编码器显示在图的上半部分,解码器显示在图的下半部分
我们很快将详细查看每个组件,但我们已经可以在图 3-1 中看到一些特征,这些特征表征了 Transformer 架构:
- 输入文本被标记化,并使用我们在第二章中遇到的技术转换为“令牌嵌入”。由于注意力机制不知道令牌的相对位置,我们需要一种方法将一些关于令牌位置的信息注入到输入中,以建模文本的顺序性质。因此,令牌嵌入与包含每个令牌的位置信息的“位置嵌入”相结合。
- 编码器由一堆“编码器层”或“块”组成,类似于在计算机视觉中堆叠卷积层。解码器也是如此,它有自己的一堆“解码器层”。
- 编码器的输出被馈送到每个解码器层,然后解码器生成一个最可能的下一个令牌序列的预测。这一步的输出然后被反馈到解码器中以生成下一个令牌,依此类推,直到达到特殊的序列结束(EOS)令牌。在图 3-1 的示例中,想象一下解码器已经预测了“Die”和“Zeit”。现在它将这两个作为输入以及所有编码器的输出来预测下一个令牌,“fliegt”。在下一步中,解码器获得“fliegt”作为额外的输入。我们重复这个过程,直到解码器预测出 EOS 令牌或者达到最大长度。
Transformer 架构最初是为机器翻译等序列到序列任务设计的,但编码器和解码器块很快被改编为独立的模型。虽然有数百种不同的 transformer 模型,但它们大多属于以下三种类型之一:
仅编码器
这些模型将文本输入序列转换为丰富的数值表示,非常适合文本分类或命名实体识别等任务。BERT 及其变种,如 RoBERTa 和 DistilBERT,属于这类架构。在这种架构中,对于给定标记的表示取决于左侧(标记之前)和右侧(标记之后)的上下文。这经常被称为双向注意力。
仅解码器
给定一个文本提示,比如“谢谢午餐,我吃了一个…”,这些模型将通过迭代预测最有可能的下一个词来自动完成序列。GPT 模型系列属于这一类。在这种架构中,对于给定标记的表示仅取决于左侧上下文。这经常被称为因果或自回归注意力。
编码器-解码器
这些模型用于对一个文本序列到另一个文本序列的复杂映射进行建模;它们适用于机器翻译和摘要任务。除了我们已经看到的 Transformer 架构,它结合了编码器和解码器,BART 和 T5 模型也属于这一类。
注意
实际上,解码器-仅模型与仅编码器模型的应用区别有些模糊。例如,像 GPT 系列中的解码器-仅模型可以被用于传统上被认为是序列到序列任务的翻译等任务。同样,像 BERT 这样的仅编码器模型可以应用于通常与编码器-解码器或仅解码器模型相关的摘要任务。¹
现在您已经对 Transformer 架构有了高层次的理解,让我们更仔细地看看编码器的内部工作。
编码器
正如我们之前看到的,transformer 的编码器由许多相邻堆叠的编码器层组成。如图 3-2 所示,每个编码器层接收一系列嵌入,并通过以下子层进行处理:
- 多头自注意力层
- 应用于每个输入嵌入的全连接前馈层
每个编码器层的输出嵌入与输入的大小相同,我们很快就会看到编码器堆栈的主要作用是“更新”输入嵌入,以产生编码一些上下文信息的表示。例如,如果“keynote”或“phone”这样的词靠近“apple”,那么“apple”这个词将被更新为更“公司化”而不是更“水果化”。
图 3-2. 放大到编码器层
每个子层也使用跳跃连接和层归一化,这是训练深度神经网络的标准技巧。但要真正理解 transformer 的工作原理,我们必须深入了解。让我们从最重要的构建模块开始:自注意力层。
自注意力
正如我们在第一章中讨论的那样,注意力是一种机制,它允许神经网络为序列中的每个元素分配不同数量的权重或“注意力”。对于文本序列,元素是标记嵌入,就像我们在第二章中遇到的那样,其中每个标记都被映射到某个固定维度的向量。例如,在 BERT 中,每个标记表示为一个 768 维向量。“自注意力”中的“自”指的是这些权重是针对同一集合中的所有隐藏状态计算的,例如,编码器的所有隐藏状态。相比之下,与循环模型相关的注意力机制涉及计算每个编码器隐藏状态对于给定解码时间步的解码器隐藏状态的相关性。
自注意力的主要思想是,我们可以使用整个序列来计算每个嵌入的加权平均,而不是为每个标记使用固定的嵌入。另一种表述方法是,给定标记嵌入序列x 1 , … , x n,自注意力会产生一个新嵌入序列x 1 ‘ , … , x n ’,其中每个x i '都是所有x j的线性组合:
x i ' = ∑ j=1 n w ji x j
系数w ji被称为注意力权重,并且被归一化,使得∑ j w ji = 1。要了解为什么对标记嵌入进行平均可能是一个好主意,请考虑当你看到“flies”这个词时会想到什么。你可能会想到讨厌的昆虫,但如果你得到更多的上下文,比如“time flies like an arrow”,那么你会意识到“flies”是指动词。同样,我们可以通过以不同的比例组合所有标记嵌入,也许通过给“time”和“arrow”的标记嵌入分配更大的权重w ji,来创建一个包含这个上下文的“flies”的表示。以这种方式生成的嵌入称为上下文化嵌入,并且早于像 ELMo 这样的语言模型中的 transformers 的发明。2 显示了该过程的图表,我们在其中说明了如何通过自注意力,根据上下文,可以生成“flies”的两种不同表示。
图 3-3。显示了自注意力如何将原始标记嵌入(上部)更新为上下文化嵌入(下部),以创建包含整个序列信息的表示。
现在让我们看看如何计算注意力权重。
缩放点积注意力
有几种实现自注意力层的方法,但最常见的是来自 Transformer 架构的“缩放点积注意力”。实现这种机制需要四个主要步骤:
- 将每个标记嵌入投影到称为查询、键和值的三个向量中。
- 计算注意力分数。我们使用相似性函数来确定查询和键向量之间的关系程度。正如其名称所示,缩放点积注意力的相似性函数是点积,通过嵌入的矩阵乘法进行高效计算。相似的查询和键将具有较大的点积,而那些没有共同之处的将几乎没有重叠。这一步的输出被称为注意力分数,对于具有 n 个输入标记的序列,有一个相应的 n×n 的注意力分数矩阵。
- 计算注意力权重。点积通常会产生任意大的数,这可能会使训练过程不稳定。为了处理这个问题,首先将注意力分数乘以一个缩放因子来归一化它们的方差,然后通过 softmax 进行归一化,以确保所有列的值总和为 1。得到的 n×n 矩阵现在包含了所有的注意力权重,wji。
- 更新标记嵌入。一旦计算出注意力权重,我们将它们乘以值向量 v1,直到 vn,以获得嵌入的更新表示 xi’。
我们可以使用一个称为“BertViz for Jupyter”的巧妙库来可视化注意力权重的计算。这个库提供了几个函数,可以用来可视化 Transformer 模型中注意力的不同方面。为了可视化注意力权重,我们可以使用neuron_view
模块,它跟踪权重的计算过程,以展示查询和键向量是如何组合产生最终权重的。由于 BertViz 需要访问模型的注意力层,我们将使用 BertViz 的模型类来实例化我们的 BERT 检查点,然后使用show()
函数来为特定的编码器层和注意力头生成交互式可视化。请注意,您需要点击左侧的“+”来激活注意力可视化:
from transformers import AutoTokenizer from bertviz.transformers_neuron_view import BertModel from bertviz.neuron_view import show model_ckpt = "bert-base-uncased" tokenizer = AutoTokenizer.from_pretrained(model_ckpt) model = BertModel.from_pretrained(model_ckpt) text = "time flies like an arrow" show(model, "bert", tokenizer, text, display_mode="light", layer=0, head=8)
从可视化中,我们可以看到查询和键向量的值被表示为垂直条带,其中每个条带的强度对应于其大小。连接线的权重根据标记之间的注意力而加权,我们可以看到“flies”的查询向量与“arrow”的键向量有最强的重叠。
让我们通过实现计算缩放点积注意力的操作图来更详细地了解这个过程,如图 3-4 所示。
图 3-4. 缩放点积注意力中的操作
在本章中,我们将使用 PyTorch 来实现 Transformer 架构,但 TensorFlow 中的步骤是类似的。我们提供了两个框架中最重要函数的映射,详见表 3-1。
表 3-1。本章中使用的 PyTorch 和 TensorFlow(Keras)类和方法
PyTorch | TensorFlow (Keras) | 创建/实现 |
nn.Linear |
keras.layers.Dense |
一个密集的神经网络层 |
nn.Module |
keras.layers.Layer |
模型的构建模块 |
nn.Dropout |
keras.layers.Dropout |
一个 dropout 层 |
nn.LayerNorm |
keras.layers.LayerNormalization |
层归一化 |
nn.Embedding |
keras.layers.Embedding |
一个嵌入层 |
nn.GELU |
keras.activations.gelu |
高斯误差线性单元激活函数 |
nn.bmm |
tf.matmul |
批量矩阵乘法 |
model.forward |
model.call |
模型的前向传播 |
我们需要做的第一件事是对文本进行标记化,因此让我们使用我们的标记器提取输入 ID:
inputs = tokenizer(text, return_tensors="pt", add_special_tokens=False) inputs.input_ids
tensor([[ 2051, 10029, 2066, 2019, 8612]])
正如我们在第二章中看到的,句子中的每个标记都被映射到标记器词汇表中的唯一 ID。为了保持简单,我们还通过设置add_special_tokens=False
来排除了[CLS]
和[SEP]
标记。接下来,我们需要创建一些密集的嵌入。在这种情况下,“密集”意味着嵌入中的每个条目都包含非零值。相比之下,在第二章中看到的 one-hot 编码是“稀疏”的,因为除一个之外的所有条目都是零。在 PyTorch 中,我们可以通过使用torch.nn.Embedding
层来实现这一点,该层充当每个输入 ID 的查找表:
from torch import nn from transformers import AutoConfig config = AutoConfig.from_pretrained(model_ckpt) token_emb = nn.Embedding(config.vocab_size, config.hidden_size) token_emb
Embedding(30522, 768)
在这里,我们使用了AutoConfig
类来加载与bert-base-uncased
检查点相关联的config.json文件。在
Transformers 中,每个检查点都分配了一个配置文件,指定了诸如vocab_size
和hidden_size
之类的各种超参数,例如我们的示例中显示每个输入 ID 将映射到nn.Embedding
中存储的 30,522 个嵌入向量之一,每个嵌入向量的大小为 768。AutoConfig
类还存储其他元数据,例如标签名称,用于格式化模型的预测。
请注意,此时的标记嵌入与它们的上下文无关。这意味着在上一个示例中的“flies”等同义词(拼写相同但含义不同的单词)具有相同的表示。随后的注意力层的作用将是混合这些标记嵌入,以消除歧义并使用上下文的内容来形成每个标记的表示。
现在我们有了查找表,我们可以通过输入 ID 生成嵌入:
inputs_embeds = token_emb(inputs.input_ids) inputs_embeds.size()
torch.Size([1, 5, 768])
这给我们提供了一个形状为[batch_size, seq_len, hidden_dim]
的张量,就像我们在第二章中看到的一样。我们将推迟位置编码,因此下一步是创建查询、键和值向量,并使用点积作为相似性函数来计算注意力分数:
import torch from math import sqrt query = key = value = inputs_embeds dim_k = key.size(-1) scores = torch.bmm(query, key.transpose(1,2)) / sqrt(dim_k) scores.size()
torch.Size([1, 5, 5])
这创建了一个每个批次样本的5 × 5注意力分数矩阵。我们将在后面看到,查询、键和值向量是通过将独立的权重矩阵W Q,K,V应用于嵌入来生成的,但现在为了简单起见,我们将它们保持相等。在缩放的点积注意力中,点积被嵌入向量的大小缩放,以便在训练过程中不会得到太多的大数,这可能会导致我们接下来将应用的 softmax 饱和。
注
torch.bmm()
函数执行批量矩阵-矩阵乘积,简化了注意力分数的计算,其中查询和键向量的形状为[batch_size, seq_len, hidden_dim]
。如果忽略批处理维度,我们可以通过简单地转置键张量的形状为[hidden_dim, seq_len]
,然后使用矩阵乘积来收集所有点积在[seq_len, seq_len]
矩阵中。由于我们希望对批中的所有序列独立地执行此操作,我们使用torch.bmm()
,它接受两个矩阵批次,并将第一个批次中的每个矩阵与第二个批次中的相应矩阵相乘。
现在让我们应用 softmax:
import torch.nn.functional as F weights = F.softmax(scores, dim=-1) weights.sum(dim=-1)
tensor([[1., 1., 1., 1., 1.]], grad_fn=<SumBackward1>)
最后一步是将注意力权重乘以值:
attn_outputs = torch.bmm(weights, value) attn_outputs.shape
torch.Size([1, 5, 768])
就是这样——我们已经完成了实现简化形式的自注意力的所有步骤!请注意,整个过程只是两次矩阵乘法和一个 softmax,因此你可以将“自注意力”看作是一种花哨的平均形式。
让我们将这些步骤封装成一个我们以后可以使用的函数:
def scaled_dot_product_attention(query, key, value): dim_k = query.size(-1) scores = torch.bmm(query, key.transpose(1, 2)) / sqrt(dim_k) weights = F.softmax(scores, dim=-1) return torch.bmm(weights, value)
我们的注意机制使用相等的查询和键向量将为上下文中相同的单词分配非常大的分数,特别是对于当前单词本身:查询与自身的点积始终为 1。但实际上,一个单词的含义更多地受到上下文中的补充单词的影响,而不是相同的单词,例如,“flies”的含义更好地通过“time”和“arrow”的信息来定义,而不是通过另一个“flies”的提及。我们如何促进这种行为呢?
让模型通过使用三个不同的线性投影为一个标记的查询、键和值创建不同的向量集。
多头注意力
在我们简单的例子中,我们只是使用嵌入“原样”来计算注意力分数和权重,但这远非全部。实际上,自注意力层对每个嵌入应用三个独立的线性变换,以生成查询、键和值向量。这些变换将嵌入投影到不同的空间,并且每个投影都携带其自己的可学习参数集,这使得自注意力层能够关注序列的不同语义方面。
同时拥有多个线性投影集也被证明是有益的,每个代表一个所谓的注意头。结果的多头注意力层在图 3-5 中有所说明。但为什么我们需要多个注意头呢?原因是一个头的 softmax 倾向于主要关注相似性的某个方面。拥有多个头允许模型同时关注多个方面。例如,一个头可以关注主谓交互,而另一个可以找到附近的形容词。显然,我们不会手工将这些关系编码到模型中,它们完全是从数据中学习到的。如果你熟悉计算机视觉模型,你可能会看到它与卷积神经网络中的滤波器的相似之处,其中一个滤波器可以负责检测脸部,另一个可以在图像中找到车轮。
图 3-5。多头注意力
让我们首先编写一个单个注意力头来实现这一层:
class AttentionHead(nn.Module): def __init__(self, embed_dim, head_dim): super().__init__() self.q = nn.Linear(embed_dim, head_dim) self.k = nn.Linear(embed_dim, head_dim) self.v = nn.Linear(embed_dim, head_dim) def forward(self, hidden_state): attn_outputs = scaled_dot_product_attention( self.q(hidden_state), self.k(hidden_state), self.v(hidden_state)) return attn_outputs
在这里,我们初始化了三个独立的线性层,它们对嵌入向量进行矩阵乘法,以产生形状为[batch_size, seq_len, head_dim]
的张量,其中head_dim
是我们投影到的维度的数量。虽然head_dim
不一定要小于标记的嵌入维度(embed_dim
),但实际上它被选择为embed_dim
的倍数,以便每个头部的计算是恒定的。例如,BERT 有 12 个注意力头,因此每个头的维度是768 / 12 = 64。
现在我们有了一个单独的注意力头,我们可以将每个头的输出连接起来,以实现完整的多头注意力层:
class MultiHeadAttention(nn.Module): def __init__(self, config): super().__init__() embed_dim = config.hidden_size num_heads = config.num_attention_heads head_dim = embed_dim // num_heads self.heads = nn.ModuleList( [AttentionHead(embed_dim, head_dim) for _ in range(num_heads)] ) self.output_linear = nn.Linear(embed_dim, embed_dim) def forward(self, hidden_state): x = torch.cat([h(hidden_state) for h in self.heads], dim=-1) x = self.output_linear(x) return x
注意,注意力头的连接输出也通过最终的线性层,以产生适合下游前馈网络的形状为[batch_size, seq_len, hidden_dim]
的输出张量。为了确认,让我们看看多头注意力层是否产生了我们输入的预期形状。我们在初始化MultiHeadAttention
模块时传递了之前从预训练 BERT 模型加载的配置。这确保我们使用与 BERT 相同的设置:
multihead_attn = MultiHeadAttention(config) attn_output = multihead_attn(inputs_embeds) attn_output.size()
torch.Size([1, 5, 768])
它起作用了!在注意力这一部分结束时,让我们再次使用 BertViz 来可视化单词“flies”两种不同用法的注意力。在这里,我们可以使用 BertViz 的head_view()
函数,通过计算预训练检查点的注意力,并指示句子边界的位置:
from bertviz import head_view from transformers import AutoModel model = AutoModel.from_pretrained(model_ckpt, output_attentions=True) sentence_a = "time flies like an arrow" sentence_b = "fruit flies like a banana" viz_inputs = tokenizer(sentence_a, sentence_b, return_tensors='pt') attention = model(**viz_inputs).attentions sentence_b_start = (viz_inputs.token_type_ids == 0).sum(dim=1) tokens = tokenizer.convert_ids_to_tokens(viz_inputs.input_ids[0]) head_view(attention, tokens, sentence_b_start, heads=[8])
这个可视化将注意力权重显示为连接正在更新嵌入的标记(左)与正在被关注的每个单词(右)的线条。线条的强度表示注意力权重的强度,深色线条表示接近 1 的值,淡色线条表示接近 0 的值。
在这个例子中,输入由两个句子组成,而[CLS]
和[SEP]
标记是 BERT 的分词器中的特殊标记,我们在第二章中遇到过。从可视化中我们可以看到的一件事是,注意力权重在属于同一句子的单词之间最强,这表明 BERT 可以知道它应该关注同一句子中的单词。然而,对于单词“flies”,我们可以看到 BERT 已经确定了第一个句子中“arrow”是重要的,第二个句子中是“fruit”和“banana”。这些注意力权重使模型能够区分“flies”作为动词或名词,取决于它出现的上下文!
现在我们已经涵盖了注意力,让我们来看看如何实现编码器层中缺失的位置逐层前馈网络。
前馈层
编码器和解码器中的前馈子层只是一个简单的两层全连接神经网络,但有一个变化:它不是将整个嵌入序列作为单个向量处理,而是独立地处理每个嵌入。因此,这一层通常被称为位置逐层前馈层。您可能还会看到它被称为具有大小为 1 的核的一维卷积,通常是由具有计算机视觉背景的人(例如,OpenAI GPT 代码库使用这种命名方式)。文献中的一个经验法则是,第一层的隐藏大小应为嵌入大小的四倍,最常用的是 GELU 激活函数。这是假设大部分容量和记忆发生的地方,也是在扩大模型规模时最常扩展的部分。我们可以将其实现为一个简单的nn.Module
,如下所示:
class FeedForward(nn.Module): def __init__(self, config): super().__init__() self.linear_1 = nn.Linear(config.hidden_size, config.intermediate_size) self.linear_2 = nn.Linear(config.intermediate_size, config.hidden_size) self.gelu = nn.GELU() self.dropout = nn.Dropout(config.hidden_dropout_prob) def forward(self, x): x = self.linear_1(x) x = self.gelu(x) x = self.linear_2(x) x = self.dropout(x) return x
请注意,诸如nn.Linear
之类的前馈层通常应用于形状为(batch_size, input_dim)
的张量,其中它独立地作用于批处理维度的每个元素。这实际上对除了最后一个维度之外的任何维度都是真实的,因此当我们传递形状为(batch_size, seq_len, hidden_dim)
的张量时,该层将独立地作用于批处理和序列中的所有令牌嵌入,这正是我们想要的。让我们通过传递注意力输出来测试一下:
feed_forward = FeedForward(config) ff_outputs = feed_forward(attn_outputs) ff_outputs.size()
torch.Size([1, 5, 768])
我们现在已经拥有了创建一个完整的 transformer 编码器层的所有要素!唯一剩下的决定是在哪里放置跳过连接和层归一化。让我们看看这如何影响模型架构。
添加层归一化
正如前面提到的,Transformer 架构使用了层归一化和跳过连接。前者将批处理中的每个输入归一化为零均值和单位方差。跳过连接将一个张量传递到模型的下一层而不进行处理,并将其添加到处理过的张量中。在将层归一化放置在 transformer 的编码器或解码器层中时,文献中采用了两种主要选择:
后层归一化
这是 Transformer 论文中使用的安排;它将层归一化放置在跳过连接之间。这种安排很难从头开始训练,因为梯度可能会发散。因此,你经常会看到一个称为“学习率预热”的概念,在训练过程中学习率会逐渐从一个小值增加到某个最大值。
层前归一化
这是文献中最常见的安排;它将层归一化放置在跳过连接的范围内。这在训练过程中往往更加稳定,通常不需要任何学习率预热。
两种安排的区别在图 3-6 中有所说明。
图 3-6. Transformer 编码器层中层归一化的不同安排
我们将使用第二种安排,因此我们可以简单地将我们的构建模块连接在一起:
class TransformerEncoderLayer(nn.Module): def __init__(self, config): super().__init__() self.layer_norm_1 = nn.LayerNorm(config.hidden_size) self.layer_norm_2 = nn.LayerNorm(config.hidden_size) self.attention = MultiHeadAttention(config) self.feed_forward = FeedForward(config) def forward(self, x): # Apply layer normalization and then copy input into query, key, value hidden_state = self.layer_norm_1(x) # Apply attention with a skip connection x = x + self.attention(hidden_state) # Apply feed-forward layer with a skip connection x = x + self.feed_forward(self.layer_norm_2(x)) return x
现在让我们用我们的输入嵌入来测试一下:
encoder_layer = TransformerEncoderLayer(config) inputs_embeds.shape, encoder_layer(inputs_embeds).size()
(torch.Size([1, 5, 768]), torch.Size([1, 5, 768]))
我们现在已经从头开始实现了我们的第一个 transformer 编码器层!然而,我们设置编码器层的方式有一个问题:它们对令牌的位置是完全不变的。由于多头注意力层实际上是一种花哨的加权和,令牌位置的信息会丢失。⁴
幸运的是,有一个简单的技巧可以使用位置嵌入来纳入位置信息。让我们看看。
位置嵌入
位置嵌入基于一个简单但非常有效的思想:用一个与位置相关的值模式增强令牌嵌入,这些值排列在一个向量中。如果该模式对每个位置都是特征性的,那么每个堆栈中的注意力头和前馈层可以学习将位置信息纳入它们的转换中。
有几种方法可以实现这一点,其中最流行的方法之一是使用可学习的模式,特别是当预训练数据集足够大时。这与令牌嵌入的方式完全相同,但是使用位置索引而不是令牌 ID 作为输入。通过这种方式,在预训练期间学习到了一种有效的编码令牌位置的方式。
让我们创建一个自定义的Embeddings
模块,它结合了一个令牌嵌入层,将input_ids
投影到一个稠密的隐藏状态,以及一个位置嵌入,对position_ids
做同样的事情。最终的嵌入就是这两种嵌入的和:
class Embeddings(nn.Module): def __init__(self, config): super().__init__() self.token_embeddings = nn.Embedding(config.vocab_size, config.hidden_size) self.position_embeddings = nn.Embedding(config.max_position_embeddings, config.hidden_size) self.layer_norm = nn.LayerNorm(config.hidden_size, eps=1e-12) self.dropout = nn.Dropout() def forward(self, input_ids): # Create position IDs for input sequence seq_length = input_ids.size(1) position_ids = torch.arange(seq_length, dtype=torch.long).unsqueeze(0) # Create token and position embeddings token_embeddings = self.token_embeddings(input_ids) position_embeddings = self.position_embeddings(position_ids) # Combine token and position embeddings embeddings = token_embeddings + position_embeddings embeddings = self.layer_norm(embeddings) embeddings = self.dropout(embeddings) return embeddings
embedding_layer = Embeddings(config) embedding_layer(inputs.input_ids).size()
torch.Size([1, 5, 768])
我们看到嵌入层现在为每个令牌创建了一个单一的稠密嵌入。
虽然可学习的位置嵌入易于实现并被广泛使用,但也有一些替代方案:
绝对位置表示
Transformer 模型可以使用由调制正弦和余弦信号组成的静态模式来编码标记的位置。当没有大量数据可用时,这种方法特别有效。
相对位置表示
尽管绝对位置很重要,但可以说在计算嵌入时,周围的标记最重要。相对位置表示遵循这种直觉,并对标记之间的相对位置进行编码。这不能仅通过在开始时引入一个新的相对嵌入层来设置,因为相对嵌入会根据我们从序列的哪个位置进行关注而为每个标记更改。相反,注意力机制本身被修改,增加了考虑标记之间相对位置的额外项。DeBERTa 等模型使用这样的表示。⁵
现在让我们将所有这些组合起来,通过将嵌入与编码器层组合来构建完整的 Transformer 编码器:
class TransformerEncoder(nn.Module): def __init__(self, config): super().__init__() self.embeddings = Embeddings(config) self.layers = nn.ModuleList([TransformerEncoderLayer(config) for _ in range(config.num_hidden_layers)]) def forward(self, x): x = self.embeddings(x) for layer in self.layers: x = layer(x) return x
让我们检查编码器的输出形状:
encoder = TransformerEncoder(config) encoder(inputs.input_ids).size()
torch.Size([1, 5, 768])
我们可以看到我们为批处理中的每个标记获得一个隐藏状态。这种输出格式使得架构非常灵活,我们可以轻松地将其调整为各种应用,比如预测掩码语言建模中的缺失标记,或者在问答中预测答案的起始和结束位置。在接下来的部分中,我们将看到如何构建一个类似于我们在第二章中使用的分类器。
添加分类头部
Transformer 模型通常分为与任务无关的主体和与任务相关的头部。当我们在第四章中查看
Transformer 的设计模式时,我们将再次遇到这种模式。到目前为止,我们构建的是主体,因此如果我们希望构建文本分类器,我们将需要将分类头部附加到主体上。我们对每个标记都有一个隐藏状态,但我们只需要做一个预测。有几种方法可以解决这个问题。传统上,这种模型中的第一个标记用于预测,我们可以附加一个 dropout 和一个线性层来进行分类预测。以下类扩展了用于序列分类的现有编码器:
class TransformerForSequenceClassification(nn.Module): def __init__(self, config): super().__init__() self.encoder = TransformerEncoder(config) self.dropout = nn.Dropout(config.hidden_dropout_prob) self.classifier = nn.Linear(config.hidden_size, config.num_labels) def forward(self, x): x = self.encoder(x)[:, 0, :] # select hidden state of [CLS] token x = self.dropout(x) x = self.classifier(x) return x
在初始化模型之前,我们需要定义我们想要预测多少个类别:
config.num_labels = 3 encoder_classifier = TransformerForSequenceClassification(config) encoder_classifier(inputs.input_ids).size()
torch.Size([1, 3])
这正是我们一直在寻找的。对于批处理中的每个示例,我们得到输出中每个类别的非归一化 logits。这对应于我们在第二章中使用的 BERT 模型,用于检测推文中的情绪。
这结束了我们对编码器的分析,以及我们如何将其与特定任务的头部结合起来。现在让我们把注意力(双关语!)转向解码器。
解码器
如图 3-7 所示,解码器和编码器之间的主要区别在于解码器有两个注意力子层:
掩码多头自注意力层
确保我们在每个时间步生成的标记仅基于过去的输出和当前正在预测的标记。如果没有这一点,解码器在训练过程中可以通过简单地复制目标翻译来作弊;屏蔽输入确保任务不是微不足道的。
编码器-解码器注意力层
对编码器堆栈的输出键和值向量执行多头注意力,其中解码器的中间表示充当查询。⁶ 这样,编码器-解码器注意力层学习如何关联来自两个不同序列的标记,比如两种不同的语言。解码器在每个块中都可以访问编码器的键和值。
让我们看一下我们需要对自注意力层进行的修改,以包含掩码,并将编码器-解码器注意力层的实现作为一个作业问题留下。掩码自注意力的技巧是引入一个掩码矩阵,在下对角线上为 1,在上方为 0:
seq_len = inputs.input_ids.size(-1) mask = torch.tril(torch.ones(seq_len, seq_len)).unsqueeze(0) mask[0]
tensor([[1., 0., 0., 0., 0.], [1., 1., 0., 0., 0.], [1., 1., 1., 0., 0.], [1., 1., 1., 1., 0.], [1., 1., 1., 1., 1.]])
在这里,我们使用了 PyTorch 的tril()
函数来创建下三角矩阵。一旦有了这个掩码矩阵,我们可以使用Tensor.masked_fill()
来将所有的零替换为负无穷大,从而防止每个注意力头窥视未来的标记:
scores.masked_fill(mask == 0, -float("inf"))
tensor([[[26.8082, -inf, -inf, -inf, -inf], [-0.6981, 26.9043, -inf, -inf, -inf], [-2.3190, 1.2928, 27.8710, -inf, -inf], [-0.5897, 0.3497, -0.3807, 27.5488, -inf], [ 0.5275, 2.0493, -0.4869, 1.6100, 29.0893]]], grad_fn=<MaskedFillBackward0>)
图 3-7。放大到 transformer 解码器层
通过将上限值设置为负无穷大,我们保证了一旦我们对分数进行 softmax 计算,注意力权重都将为零,因为e -∞ = 0(回想一下,softmax 计算的是归一化指数)。我们可以通过对我们在本章早些时候实现的缩放点积注意力函数进行小的修改,轻松地包含这种掩码行为:
def scaled_dot_product_attention(query, key, value, mask=None): dim_k = query.size(-1) scores = torch.bmm(query, key.transpose(1, 2)) / sqrt(dim_k) if mask is not None: scores = scores.masked_fill(mask == 0, float("-inf")) weights = F.softmax(scores, dim=-1) return weights.bmm(value)
从这里开始构建解码器层就很简单了;我们指向Andrej Karpathy 的 minGPT的出色实现以获取详细信息。
我们在这里给了你很多技术信息,但现在你应该对 Transformer 架构的每个部分是如何工作有了很好的理解。在我们继续构建比文本分类更高级的任务模型之前,让我们稍微回顾一下,看看不同 transformer 模型的景观以及它们之间的关系。
遇见 Transformer
正如你在本章中看到的,transformer 模型有三种主要的架构:编码器、解码器和编码器-解码器。早期 transformer 模型的初步成功引发了模型开发的寒武纪爆发,研究人员在不同大小和性质的各种数据集上构建模型,使用新的预训练目标,并调整架构以进一步提高性能。尽管模型的种类仍在快速增长,但它们仍然可以分为这三类。
在本节中,我们将简要介绍每个类别中最重要的 transformer 模型。让我们从看一下 transformer 家族谱开始。
Transformer 的生命之树
随着时间的推移,每种主要架构都经历了自己的演变。这在图 3-8 中有所体现,该图显示了一些最重要的模型及其后代。
图 3-8。一些最重要的 transformer 架构的概述
在包含 50 多种不同架构的
Transformers 中,这个家族谱并不能提供所有存在的模型的完整概述:它只是突出了一些架构上的重要里程碑。我们在本章中深入讨论了原始的 Transformer 架构,所以让我们更仔细地看一下一些关键后代,从编码器分支开始。
编码器分支
基于 Transformer 架构的第一个仅编码器模型是 BERT。在发布时,它在流行的 GLUE 基准测试中表现优异,⁷该测试衡量了自然语言理解(NLU)在多个不同难度的任务中的表现。随后,BERT 的预训练目标和架构已经被调整以进一步提高性能。仅编码器模型仍然在 NLU 任务(如文本分类、命名实体识别和问答)的研究和行业中占主导地位。让我们简要地看一下 BERT 模型及其变种:
BERT
BERT 在预训练时具有两个目标:预测文本中的屏蔽标记,以及确定一个文本段是否可能跟随另一个文本段。前者任务称为屏蔽语言建模(MLM),后者称为下一个句子预测(NSP)。
DistilBERT
尽管 BERT 取得了很好的结果,但其规模使得在需要低延迟的环境中部署变得棘手。通过在预训练期间使用一种称为知识蒸馏的技术,DistilBERT 在使用 40%更少的内存和速度提高 60%的情况下,实现了 BERT 性能的 97%。您可以在第八章中找到有关知识蒸馏的更多细节。
RoBERTa
在发布 BERT 后的一项研究发现,通过修改预训练方案可以进一步提高其性能。RoBERTa 在更大的批次上进行更长时间的训练,并且放弃了 NSP 任务。这些改变显著提高了其性能,与原始 BERT 模型相比。
XLM
在跨语言语言模型(XLM)的工作中探索了构建多语言模型的几个预训练目标,包括来自 GPT 类模型的自回归语言建模和来自 BERT 的 MLM。此外,XLM 预训练论文的作者介绍了翻译语言建模(TLM),这是对多语言输入的 MLM 的扩展。通过对这些预训练任务进行实验,他们在几个多语言 NLU 基准测试以及翻译任务上取得了最先进的结果。
XLM-RoBERTa
在 XLM 和 RoBERTa 的工作之后,XLM-RoBERTa 或 XLM-R 模型通过大规模扩展训练数据,进一步推动了多语言预训练的发展。其开发者利用 Common Crawl 语料库创建了一个包含 2.5TB 文本的数据集,然后在该数据集上进行了 MLM 编码器的训练。由于数据集只包含没有平行文本(即翻译)的数据,因此 XLM 的 TLM 目标被取消。这种方法在低资源语言上明显优于 XLM 和多语言 BERT 变体。
ALBERT
ALBERT 模型引入了三个改变,使得编码器架构更加高效。首先,它将标记嵌入维度与隐藏维度解耦,从而允许嵌入维度较小,从而节省参数,特别是当词汇量变大时。其次,所有层共享相同的参数,这进一步减少了有效参数的数量。最后,NSP 目标被替换为句子排序预测:模型需要预测两个连续句子的顺序是否交换,而不是预测它们是否完全属于一起。这些改变使得可以使用更少的参数训练更大的模型,并在 NLU 任务上取得更优越的性能。
ELECTRA
标准 MLM 预训练目标的一个局限性是,在每个训练步骤中,只有被屏蔽的标记的表示会被更新,而其他输入标记不会被更新。为了解决这个问题,ELECTRA 采用了两模型方法:第一个模型(通常较小)类似于标准的屏蔽语言模型,预测被屏蔽的标记。然后,称为鉴别器的第二个模型被要求预测第一个模型输出的标记中哪些是最初被屏蔽的。因此,鉴别器需要对每个标记进行二元分类,这使得训练效率提高了 30 倍。对于下游任务,鉴别器像标准的 BERT 模型一样进行微调。
DeBERTa
DeBERTa 模型引入了两个架构变化。首先,每个标记表示为两个向量:一个用于内容,另一个用于相对位置。通过将标记的内容与它们的相对位置分离,自注意力层可以更好地建模附近标记对的依赖关系。另一方面,单词的绝对位置也很重要,特别是对于解码。因此,在标记解码头的 softmax 层之前添加了绝对位置嵌入。DeBERTa 是第一个(作为集合)在 SuperGLUE 基准上击败人类基线的模型,这是 GLUE 的更难版本,由几个子任务组成,用于衡量 NLU 性能。
现在我们已经强调了一些主要的仅编码器架构,让我们来看一下仅解码器模型。
解码器分支
Transformer 解码器模型的进展在很大程度上是由 OpenAI 带头的。这些模型在预测序列中的下一个单词方面表现出色,因此主要用于文本生成任务。它们的进展得益于使用更大的数据集,并将语言模型扩展到越来越大的规模。让我们来看看这些迷人生成模型的演变:
GPT
GPT 的引入将 NLP 中的两个关键思想结合在一起:新颖而高效的 Transformer 解码器架构和迁移学习。在这种设置下,模型通过基于先前单词预测下一个单词来进行预训练。该模型在 BookCorpus 上进行了训练,并在分类等下游任务上取得了很好的结果。
GPT-2
受到简单且可扩展的预训练方法成功的启发,原始模型和训练集被扩大,产生了 GPT-2。这个模型能够生成连贯文本的长序列。由于可能被滥用的担忧,该模型是分阶段发布的,先发布较小的模型,后来再发布完整的模型。
CTRL
像 GPT-2 这样的模型可以继续输入序列(也称为提示)。然而,用户对生成序列的风格几乎没有控制。条件 Transformer 语言(CTRL)模型通过在序列开头添加“控制标记”来解决这个问题。这些标记允许控制生成文本的风格,从而实现多样化的生成。
GPT-3
在将 GPT 扩展到 GPT-2 的成功之后,对不同规模的语言模型行为进行了彻底分析,发现了计算、数据集大小、模型大小和语言模型性能之间的简单幂律关系。受到这些见解的启发,GPT-2 被放大了 100 倍,产生了拥有 1750 亿参数的 GPT-3。除了能够生成令人印象深刻的逼真文本段落外,该模型还表现出了少样本学习的能力:通过几个示例,如将文本翻译成代码的新任务,模型能够在新示例上完成任务。OpenAI 没有开源这个模型,但通过OpenAI API提供了一个接口。
GPT-Neo/GPT-J-6B
GPT-Neo 和 GPT-J-6B 是类似 GPT 的模型,由EleutherAI训练,这是一个旨在重新创建和发布 GPT-3 规模模型的研究人员集体。当前的模型是完整 1750 亿参数模型的较小变体,具有 13 亿、27 亿和 60 亿参数,并且与 OpenAI 提供的较小 GPT-3 模型具有竞争力。
transformers 树的最终分支是编码器-解码器模型。让我们来看一下。
编码器-解码器分支
尽管使用单个编码器或解码器堆栈构建模型已经很普遍,但 Transformer 架构的编码器-解码器变体有许多新颖的应用,涵盖了 NLU 和 NLG 领域:
T5
T5 模型通过将所有 NLU 和 NLG 任务统一转换为文本到文本任务。²³ 所有任务都被构建为序列到序列任务,采用编码器-解码器架构是自然的。例如,对于文本分类问题,这意味着文本被用作编码器输入,解码器必须生成标签作为普通文本,而不是类别。我们将在第六章中更详细地讨论这个问题。T5 架构使用了原始的 Transformer 架构。使用大型抓取的 C4 数据集,该模型通过将所有任务转换为文本到文本任务,进行了掩码语言建模以及 SuperGLUE 任务的预训练。具有 110 亿参数的最大模型在几个基准测试中取得了最先进的结果。
BART
BART 将 BERT 和 GPT 的预训练程序结合到编码器-解码器架构中。²⁴ 输入序列经历了几种可能的转换,从简单的屏蔽到句子排列、标记删除和文档旋转。这些修改后的输入通过编码器,解码器必须重建原始文本。这使得模型更加灵活,因为可以将其用于 NLU 和 NLG 任务,并且在两者上都实现了最先进的性能。
M2M-100
传统上,翻译模型是为一种语言对和翻译方向构建的。自然地,这无法扩展到许多语言,而且可能存在语言对之间的共享知识,可以用于罕见语言之间的翻译。M2M-100 是第一个可以在 100 种语言之间进行翻译的翻译模型。²⁵ 这允许在罕见和代表性不足的语言之间进行高质量的翻译。该模型使用前缀标记(类似于特殊的[CLS]
标记)来指示源语言和目标语言。
BigBird
Transformer 模型的一个主要限制是最大上下文大小,这是由于注意机制的二次内存需求。BigBird 通过使用一种稀疏形式的注意力来解决这个问题,从而实现了线性扩展。²⁶ 这允许将大多数 BERT 模型中的 512 个标记的上下文急剧扩展到 BigBird 中的 4,096 个标记。这在需要保留长依赖性的情况下特别有用,比如在文本摘要中。
我们在本节中看到的所有模型的预训练检查点都可以在Hugging Face Hub上找到,并且可以根据前一章中描述的情况使用
Transformers 进行微调。
结论
在本章中,我们从 Transformer 架构的核心开始,深入研究了自注意力,随后添加了构建 Transformer 编码器模型所需的所有必要部分。我们为标记和位置信息添加了嵌入层,为了补充注意力头,我们添加了一个前馈层,最后我们为模型主体添加了一个分类头来进行预测。我们还研究了 Transformer 架构的解码器部分,并总结了本章中最重要的模型架构。
现在您对基本原理有了更好的理解,让我们超越简单的分类,构建一个多语言命名实体识别模型。
¹ Y. Liu and M. Lapata, “Text Summarization with Pretrained Encoder”, (2019)。
- M.E. Peters 等人,《深度上下文化的词表示》,(2017 年)。
- A. Vaswani 等人,《Attention Is All You Need》,(2017 年)。
- 更高级的术语是,自注意力和前馈层被称为置换等变 - 如果输入被置换,那么层的相应输出将以完全相同的方式被置换。
- 通过结合绝对和相对位置表示的思想,旋转位置嵌入在许多任务上取得了出色的结果。GPT-Neo 是具有旋转位置嵌入的模型的一个例子。
- 请注意,与自注意力层不同,编码器-解码器注意力中的关键和查询向量可以具有不同的长度。这是因为编码器和解码器的输入通常涉及不同长度的序列。因此,该层中的注意力分数矩阵是矩形的,而不是正方形的。
- A. Wang 等人,《GLUE: A Multi-Task Benchmark and Analysis Platform for Natural Language Understanding》,(2018 年)。
- J. Devlin 等人,《BERT: Pre-Training of Deep Bidirectional Transformers for Language Understanding》,(2018 年)。
- V. Sanh 等人,《DistilBERT, a Distilled Version of BERT: Smaller, Faster, Cheaper and Lighter》,(2019 年)。
- Y. Liu 等人,《RoBERTa: A Robustly Optimized BERT Pretraining Approach》,(2019 年)。
- G. Lample 和 A. Conneau,《跨语言语言模型预训练》,(2019 年)。
- A. Conneau 等人,《规模化的无监督跨语言表示学习》,(2019 年)。
- Z. Lan 等人,《ALBERT: A Lite BERT for Self-Supervised Learning of Language Representations》,(2019 年)。
- K. Clark 等人,《ELECTRA: Pre-Training Text Encoders as Discriminators Rather Than Generators》,(2020 年)。
- P. He 等人,《DeBERTa: Decoding-Enhanced BERT with Disentangled Attention》,(2020 年)。
- A. Wang 等人,《SuperGLUE: A Stickier Benchmark for General-Purpose Language Understanding Systems》,(2019 年)。
- A. Radford 等人,《通过生成预训练改进语言理解》,OpenAI(2018 年)。
- A. Radford 等人,《语言模型是无监督多任务学习者》,OpenAI(2019 年)。
- N.S. Keskar 等人,《CTRL: A Conditional Transformer Language Model for Controllable Generation》,(2019 年)。
- J. Kaplan 等人,《神经语言模型的缩放定律》,(2020 年)。
- T. Brown 等人,《语言模型是少样本学习者》,(2020 年)。
²² S. Black 等,“GPT-Neo: Large Scale Autoregressive Language Modeling with Mesh-TensorFlow”,(2021); B. Wang 和 A. Komatsuzaki,“GPT-J-6B: A 6 Billion Parameter Autoregressive Language Model”,(2021)。
²³ C. Raffel 等,“Exploring the Limits of Transfer Learning with a Unified Text-to-Text Transformer”,(2019)。
²⁴ M. Lewis 等,“BART: Denoising Sequence-to-Sequence Pre-Training for Natural Language Generation, Translation, and Comprehension”,(2019)。
²⁵ A. Fan 等,“Beyond English-Centric Multilingual Machine Translation”,(2020)。
²⁶ M. Zaheer 等,“Big Bird: Transformers for Longer Sequences”,(2020)。
第四章:多语言命名实体识别
到目前为止,在这本书中,我们已经应用 transformers 来解决英语语料库上的 NLP 任务 - 但是当你的文档是用希腊语、斯瓦希里语或克林贡语写的时候,你该怎么办呢?一种方法是在 Hugging Face Hub 上搜索合适的预训练语言模型,并在手头的任务上对其进行微调。然而,这些预训练模型往往只存在于像德语、俄语或普通话这样的“高资源”语言中,这些语言有大量的网络文本可用于预训练。另一个常见的挑战是当你的语料库是多语言的时候:在生产中维护多个单语模型对你或你的工程团队来说都不是什么乐趣。
幸运的是,有一类多语言 transformers 可以拯救我们。像 BERT 一样,这些模型使用掩码语言建模作为预训练目标,但它们是在一百多种语言的文本上联合训练的。通过在许多语言的大型语料库上进行预训练,这些多语言 transformers 实现了零-shot 跨语言转移。这意味着对一个语言进行微调的模型可以应用于其他语言,而无需进一步的训练!这也使得这些模型非常适合“代码切换”,即说话者在单一对话的上下文中交替使用两种或多种语言或方言。
在本章中,我们将探讨如何对一种名为 XLM-RoBERTa 的单一 transformer 模型(在第三章介绍)进行微调,以执行跨多种语言的命名实体识别(NER)。正如我们在第一章中看到的,NER 是一种常见的 NLP 任务,用于识别文本中的人物、组织或地点等实体。这些实体可以用于各种应用,例如从公司文件中获取见解,增强搜索引擎的质量,或者仅仅是从语料库中构建结构化数据库。
在本章中,让我们假设我们想为一个位于瑞士的客户执行 NER,那里有四种官方语言(英语通常作为它们之间的桥梁)。让我们首先找到一个适合这个问题的多语言语料库。
注
零-shot 转移或零-shot 学习通常指的是在一个标签集上训练模型,然后在另一个标签集上对其进行评估的任务。在 transformers 的上下文中,零-shot 学习也可能指的是像 GPT-3 这样的语言模型在一个甚至没有进行微调的下游任务上进行评估。
The Dataset
在本章中,我们将使用跨语言 TRansfer 多语言编码器(XTREME)基准的子集,称为 WikiANN 或 PAN-X。² 这个数据集包括许多语言的维基百科文章,包括瑞士四种最常用的语言:德语(62.9%)、法语(22.9%)、意大利语(8.4%)和英语(5.9%)。每篇文章都以“内外开始”(IOB2)格式标注了LOC
(位置)、PER
(人物)和ORG
(组织)标签。在这种格式中,B-
前缀表示实体的开始,属于同一实体的连续标记被赋予I-
前缀。O
标签表示该标记不属于任何实体。例如,以下句子:
Jeff Dean 是 Google 在加利福尼亚的计算机科学家
将以 IOB2 格式标记,如 Table 4-1 所示。
表 4-1。一个带有命名实体注释的序列示例
Tokens | Jeff | Dean | is | a | computer | scientist | at | in | California | |
Tags | B-PER | I-PER | O | O | O | O | O | B-ORG | O | B-LOC |
要加载 XTREME 中的 PAN-X 子集之一,我们需要知道要传递给 load_dataset()
函数的 数据集配置。每当处理具有多个领域的数据集时,可以使用 get_dataset_config_names()
函数查找可用的子集:
from datasets import get_dataset_config_names xtreme_subsets = get_dataset_config_names("xtreme") print(f"XTREME has {len(xtreme_subsets)} configurations")
XTREME has 183 configurations
哇,这有很多配置!让我们缩小搜索范围,只查找以“PAN”开头的配置:
panx_subsets = [s for s in xtreme_subsets if s.startswith("PAN")] panx_subsets[:3]
['PAN-X.af', 'PAN-X.ar', 'PAN-X.bg']
好的,看来我们已经确定了 PAN-X 子集的语法:每个子集都有一个两个字母的后缀,看起来是一个ISO 639-1 语言代码。这意味着要加载德语语料库,我们将 de
代码传递给 load_dataset()
的 name
参数,如下所示:
from datasets import load_dataset load_dataset("xtreme", name="PAN-X.de")
为了创建一个真实的瑞士语料库,我们将根据 PAN-X 中各语言的口语比例抽样德语(de
)、法语(fr
)、意大利语(it
)和英语(en
)语料库。这将创建一个语言不平衡的情况,这在现实世界的数据集中非常常见,因为在少数语言中获取标记示例可能会很昂贵,因为缺乏精通该语言的领域专家。这种不平衡的数据集将模拟在多语言应用程序中工作时的常见情况,我们将看到如何构建一个适用于所有语言的模型。
为了跟踪每种语言,让我们创建一个 Python defaultdict
,将语言代码存储为键,DatasetDict
类型的 PAN-X 语料库存储为值:
from collections import defaultdict from datasets import DatasetDict langs = ["de", "fr", "it", "en"] fracs = [0.629, 0.229, 0.084, 0.059] # Return a DatasetDict if a key doesn't exist panx_ch = defaultdict(DatasetDict) for lang, frac in zip(langs, fracs): # Load monolingual corpus ds = load_dataset("xtreme", name=f"PAN-X.{lang}") # Shuffle and downsample each split according to spoken proportion for split in ds: panx_ch[lang][split] = ( ds[split] .shuffle(seed=0) .select(range(int(frac * ds[split].num_rows))))
在使用 shuffle()
方法确保不会意外地偏向我们的数据集拆分时,select()
允许我们根据 fracs
中的值对每个语料库进行降采样。让我们看看训练集中每种语言有多少示例,通过访问 Dataset.num_rows
属性:
import pandas as pd pd.DataFrame({lang: [panx_ch[lang]["train"].num_rows] for lang in langs}, index=["Number of training examples"])
de | fr | it | en | |
训练示例数量 | 12580 | 4580 | 1680 | 1180 |
按设计,我们在德语中有比其他所有语言加起来更多的示例,因此我们将其用作从中执行零-shot 跨语言转移到法语、意大利语和英语的起点。让我们检查德语语料库中的一个示例:
element = panx_ch["de"]["train"][0] for key, value in element.items(): print(f"{key}: {value}")
langs: ['de', 'de', 'de', 'de', 'de', 'de', 'de', 'de', 'de', 'de', 'de', 'de'] ner_tags: [0, 0, 0, 0, 5, 6, 0, 0, 5, 5, 6, 0] tokens: ['2.000', 'Einwohnern', 'an', 'der', 'Danziger', 'Bucht', 'in', 'der', 'polnischen', 'Woiwodschaft', 'Pommern', '.']
与我们之前遇到的 Dataset
对象一样,我们示例的键对应于 Arrow 表的列名,而值表示每列中的条目。特别是,我们看到 ner_tags
列对应于将每个实体映射到类 ID。这对人眼来说有点神秘,所以让我们创建一个新列,其中包含熟悉的 LOC
、PER
和 ORG
标签。为此,首先要注意的是我们的 Dataset
对象具有一个 features
属性,该属性指定了与每列关联的基础数据类型:
for key, value in panx_ch["de"]["train"].features.items(): print(f"{key}: {value}")
tokens: Sequence(feature=Value(dtype='string', id=None), length=-1, id=None) ner_tags: Sequence(feature=ClassLabel(num_classes=7, names=['O', 'B-PER', 'I-PER', 'B-ORG', 'I-ORG', 'B-LOC', 'I-LOC'], names_file=None, id=None), length=-1, id=None) langs: Sequence(feature=Value(dtype='string', id=None), length=-1, id=None)
Sequence
类指定该字段包含一系列特征,对于 ner_tags
,这对应于一系列 ClassLabel
特征。让我们从训练集中挑选出这个特征:
tags = panx_ch["de"]["train"].features["ner_tags"].feature print(tags)
ClassLabel(num_classes=7, names=['O', 'B-PER', 'I-PER', 'B-ORG', 'I-ORG', 'B-LOC', 'I-LOC'], names_file=None, id=None)
我们可以使用我们在第二章中遇到的 ClassLabel.int2str()
方法,在我们的训练集中创建一个新的列,其中包含每个标签的类名。我们将使用 map()
方法返回一个 dict
,其中键对应于新列名,值为类名的 list
:
def create_tag_names(batch): return {"ner_tags_str": [tags.int2str(idx) for idx in batch["ner_tags"]]} panx_de = panx_ch["de"].map(create_tag_names)
现在我们已经将标签转换为人类可读的格式,让我们看看训练集中第一个示例中的标记和标签是如何对齐的:
de_example = panx_de["train"][0] pd.DataFrame([de_example["tokens"], de_example["ner_tags_str"]], ['Tokens', 'Tags'])
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | |
Tokens | 2.000 | Einwohnern | an | der | Danziger | Bucht | in | der | polnischen | Woiwodschaft | Pommern | . |
标签 | O | O | O | O | B-LOC | I-LOC | O | O | B-LOC | B-LOC | I-LOC | O |
LOC
标签的存在是有意义的,因为句子“2,000 Einwohnern an der Danziger Bucht in der polnischen Woiwodschaft Pommern”在英语中的意思是“2,000 inhabitants at the Gdansk Bay in the Polish voivodeship of Pomerania”,而 Gdansk Bay 是波罗的海的一个海湾,“voivodeship”对应于波兰的一个州。
作为对标签是否存在异常不平衡的快速检查,让我们计算每个实体在每个拆分中的频率:
from collections import Counter split2freqs = defaultdict(Counter) for split, dataset in panx_de.items(): for row in dataset["ner_tags_str"]: for tag in row: if tag.startswith("B"): tag_type = tag.split("-")[1] split2freqs[split][tag_type] += 1 pd.DataFrame.from_dict(split2freqs, orient="index")
ORG | LOC | PER | |
验证 | 2683 | 3172 | 2893 |
测试 | 2573 | 3180 | 3071 |
训练 | 5366 | 6186 | 5810 |
看起来很好 - PER
、LOC
和ORG
频率的分布在每个拆分中大致相同,因此验证和测试集应该能够很好地衡量我们的 NER 标记器的泛化能力。接下来,让我们看一下一些流行的多语言 Transformer 以及它们如何适应我们的 NER 任务。
多语言 Transformer
多语言 Transformer 涉及与其单语对应物相似的架构和训练程序,唯一的区别在于用于预训练的语料库包含许多语言的文档。这种方法的一个显著特点是,尽管没有接收到区分语言的明确信息,但由此产生的语言表示能够很好地跨语言进行泛化,适用于各种下游任务。在某些情况下,这种跨语言转移的能力可以产生与单语模型竞争的结果,从而避免了需要为每种语言训练一个模型的需求!
为了衡量 NER 的跨语言转移的进展,通常使用CoNLL-2002和CoNLL-2003数据集作为英语、荷兰语、西班牙语和德语的基准。这个基准由用相同的LOC
、PER
和ORG
类别注释的新闻文章组成,但它还包含一个额外的MISC
标签,用于不属于前三组的其他实体。多语言 Transformer 模型通常以三种不同的方式进行评估:
en
在英语训练数据上进行微调,然后在每种语言的测试集上进行评估。
each
在单语测试数据上进行微调和评估,以衡量每种语言的性能。
all
在所有训练数据上进行微调,以便在每种语言的测试集上进行评估。
我们将采用类似的评估策略来进行我们的 NER 任务,但首先我们需要选择一个模型来评估。最早的多语言 Transformer 之一是 mBERT,它使用与 BERT 相同的架构和预训练目标,但将许多语言的维基百科文章添加到预训练语料库中。从那时起,mBERT 已经被 XLM-RoBERTa(或简称 XLM-R)取代,因此这是我们将在本章中考虑的模型。
正如我们在第三章中看到的,XLM-R 仅使用 MLM 作为 100 种语言的预训练目标,但与其前身相比,其预训练语料库的规模巨大:每种语言的维基百科转储和来自网络的 2.5 terabytes的 Common Crawl 数据。这个语料库的规模比早期模型使用的语料库大几个数量级,并为缅甸语和斯瓦希里语等低资源语言提供了显著的信号增强,因为这些语言只有少量的维基百科文章。
模型名称中的 RoBERTa 指的是预训练方法与单语 RoBERTa 模型相同。RoBERTa 的开发人员在几个方面改进了 BERT,特别是通过完全删除下一个句子预测任务。³ XLM-R 还放弃了 XLM 中使用的语言嵌入,并使用 SentencePiece 直接对原始文本进行标记化。⁴ 除了其多语言性质之外,XLM-R 和 RoBERTa 之间的一个显著差异是各自词汇表的大小:25 万个标记与 5.5 万个标记!
XLM-R 是多语言 NLU 任务的一个很好的选择。在下一节中,我们将探讨它如何能够高效地在许多语言中进行标记化。
仔细看一下分词
XLM-R 使用了一个名为 SentencePiece 的分词器,而不是使用 WordPiece 分词器,该分词器是在所有一百种语言的原始文本上进行训练的。为了了解 SentencePiece 与 WordPiece 的比较,让我们以通常的方式使用 nlpt_pin01 Transformers 加载 BERT 和 XLM-R 分词器:
from transformers import AutoTokenizer bert_model_name = "bert-base-cased" xlmr_model_name = "xlm-roberta-base" bert_tokenizer = AutoTokenizer.from_pretrained(bert_model_name) xlmr_tokenizer = AutoTokenizer.from_pretrained(xlmr_model_name)
通过对一小段文本进行编码,我们还可以检索每个模型在预训练期间使用的特殊标记:
text = "Jack Sparrow loves New York!" bert_tokens = bert_tokenizer(text).tokens() xlmr_tokens = xlmr_tokenizer(text).tokens()
BERT | [CLS] | Jack | Spa | ##rrow | loves | New | York | ! | [SEP] | None |
XLM-R | ▁Jack | ▁Spar | row | ▁love | s | ▁New | ▁York | ! |
在这里,我们看到 XLM-R 使用了和<\s>
来表示序列的开始和结束,而不是 BERT 用于句子分类任务的[CLS]
和[SEP]
标记。这些标记是在标记化的最后阶段添加的,我们将在下面看到。
分词器管道
到目前为止,我们把分词看作是一个将字符串转换为我们可以通过模型传递的整数的单个操作。这并不完全准确,如果我们仔细看一下,我们会发现它实际上是一个完整的处理管道,通常包括四个步骤,如图 4-1 所示。
图 4-1。分词管道中的步骤
让我们仔细看看每个处理步骤,并用示例句子“Jack Sparrow loves New York!”来说明它们的效果:
规范化
这一步对应于你对原始字符串应用的一系列操作,使其更“干净”。常见的操作包括去除空格和去除重音字符。Unicode normalization是许多分词器应用的另一种常见的规范化操作,用于处理通常存在各种写同一字符的方式的事实。这可能会使得两个“相同”的字符串(即具有相同的抽象字符序列)看起来不同;Unicode 规范化方案如 NFC、NFD、NFKC 和 NFKD 用标准形式替换了写同一字符的各种方式。规范化的另一个例子是小写化。如果模型只接受和使用小写字符,这种技术可以用来减少它所需的词汇量。规范化后,我们的示例字符串将变成“jack sparrow loves new york!”。
预分词
这一步将文本分割成较小的对象,这些对象给出了训练结束时你的标记的上限。一个好的思考方式是,预分词器将把你的文本分割成“单词”,你的最终标记将是这些单词的一部分。对于允许这样做的语言(英语、德语和许多印欧语言),字符串通常可以根据空格和标点符号分割成单词。例如,这一步可能会将我们的["jack", "spa
rrow", "loves", "new", "york", "!"]
转换成这些单词。然后,这些单词更容易在管道的下一步中使用字节对编码(BPE)或 Unigram 算法分割成子词。然而,将文本分割成“单词”并不总是一个微不足道和确定性的操作,甚至不是一个有意义的操作。例如,在中文、日文或韩文等语言中,将符号分组成像印欧语言单词那样的语义单元可能是一个非确定性的操作,有几个同样有效的分组。在这种情况下,最好不要对文本进行预分词,而是使用一个特定于语言的库进行预分词。
分词器模型
一旦输入文本被规范化和 pretokenized,分词器会在单词上应用一个子词分割模型。这是流程中需要在你的语料库上进行训练(或者如果你使用的是预训练分词器,则已经进行了训练)的部分。模型的作用是将单词分割成子词,以减少词汇量的大小,并尝试减少词汇表外标记的数量。存在几种子词分割算法,包括 BPE、Unigram 和 WordPiece。例如,我们的运行示例在分词器模型应用后可能看起来像[jack, spa, rrow, loves, new, york, !]
。请注意,此时我们不再有一个字符串列表,而是一个整数列表(输入 ID);为了保持示例的说明性,我们保留了单词,但删除了引号以表示转换。
后处理
这是分词流程的最后一步,在这一步中,可以对标记列表应用一些额外的转换,例如在输入标记索引序列的开头或结尾添加特殊标记。例如,BERT 风格的分词器会添加分类和分隔符标记:[CLS, jack, spa, rrow, loves, new, york, !, SEP]
。然后,这个序列(请记住,这将是一个整数序列,而不是你在这里看到的标记)可以被馈送到模型中。
回到我们对 XLM-R 和 BERT 的比较,我们现在明白了 SentencePiece 在后处理步骤中添加了和<\s>
,而不是[CLS]
和[SEP]
(作为惯例,我们将在图形说明中继续使用[CLS]
和[SEP]
)。让我们回到 SentencePiece 分词器,看看它有什么特别之处。
SentencePiece 分词器
SentencePiece 分词器基于一种称为 Unigram 的子词分割类型,并将每个输入文本编码为 Unicode 字符序列。这个特性对于多语言语料库特别有用,因为它允许 SentencePiece 对重音、标点和许多语言(比如日语)没有空格字符这一事实保持不可知。SentencePiece 的另一个特点是将空格分配给 Unicode 符号 U+2581,或者称为▁字符,也叫做下四分之一块字符。这使得 SentencePiece 能够在不依赖于特定语言的 pretokenizers 的情况下,对序列进行去标记化处理。例如,在前一节的例子中,我们可以看到 WordPiece 丢失了“York”和“!”之间没有空格的信息。相比之下,SentencePiece 保留了标记化文本中的空格,因此我们可以无歧义地将其转换回原始文本。
"".join(xlmr_tokens).replace(u"\u2581", " ")
'<s> Jack Sparrow loves New York!</s>'
现在我们了解了 SentencePiece 的工作原理,让我们看看如何将我们的简单示例编码成适合 NER 的形式。首先要做的是加载带有标记分类头的预训练模型。但我们不会直接从 nlpt_pin01 Transformers 中加载这个头,而是自己构建它!通过深入研究 nlpt_pin01 Transformers API,我们可以用几个简单的步骤来实现这一点。
用于命名实体识别的 Transformer
在第二章中,我们看到对于文本分类,BERT 使用特殊的[CLS]
标记来表示整个文本序列。然后,这个表示被馈送到一个全连接或密集层,以输出所有离散标签值的分布,如图 4-2 所示。
图 4-2。为序列分类微调基于编码器的 Transformer
BERT 和其他仅编码器的 Transformer 在 NER 方面采取了类似的方法,只是每个单独的输入标记的表示被馈送到相同的全连接层,以输出标记的实体。因此,NER 经常被构建为标记分类任务。该过程看起来像图 4-3 中的图表。
Transformer 编码器的命名实体识别架构。宽线性层显示相同的线性层应用于所有隐藏状态。
图 4-3。为命名实体识别微调基于编码器的 Transformer
到目前为止,一切顺利,但在标记分类任务中,我们应该如何处理子词?例如,图 4-3 中的第一个名字“Christa”被标记为子词“Chr”和“##ista”,那么应该分配B-PER
标签给哪一个(或哪些)呢?
在 BERT 论文中,作者将这个标签分配给第一个子词(在我们的例子中是“Chr”),并忽略后面的子词(“##ista”)。这是我们将在这里采用的约定,我们将用IGN
表示被忽略的子词。我们稍后可以很容易地将第一个子词的预测标签传播到后续子词中的后处理步骤。我们也可以选择包括“##ista”子词的表示,通过分配一个B-LOC
标签的副本,但这违反了 IOB2 格式。
幸运的是,我们在 BERT 中看到的所有架构方面都适用于 XLM-R,因为它的架构基于 RoBERTa,与 BERT 相同!接下来,我们将看到
ransformer 如何支持许多其他任务,只需进行轻微修改。
Transformer 模型类的解剖
Transformer 围绕着每种架构和任务都有专门的类进行组织。与不同任务相关的模型类根据For
约定命名,或者在使用AutoModel
类时为AutoModelFor
。
然而,这种方法有其局限性,为了激励更深入地了解
Transformer API,考虑以下情景。假设你有一个解决 NLP 问题的好主意,这个问题一直在你脑海中挥之不去,你想用一个 Transformer 模型来解决它。于是你和老板安排了一次会议,通过精心制作的 PowerPoint 演示文稿,你向老板提出,如果你能最终解决这个问题,你可以增加部门的收入。老板对你色彩丰富的演示和利润的谈话印象深刻,慷慨地同意给你一周的时间来构建一个概念验证。满意结果后,你立刻开始工作。你启动 GPU 并打开笔记本。你执行from transformers import BertForTaskXY
(注意TaskXY
是你想解决的虚构任务),当可怕的红色填满屏幕时,你的脸色变了:ImportError: cannot import name *BertForTaskXY*
。哦,不,没有 BERT 模型适用于你的用例!如果你不得不自己实现整个模型,你怎么能在一周内完成项目?你应该从哪里开始呢?
不要惊慌!
Transformer 被设计为让您轻松扩展现有模型以适应您的特定用例。您可以加载预训练模型的权重,并且可以访问特定任务的辅助函数。这使您可以用非常少的开销为特定目标构建自定义模型。在本节中,我们将看到如何实现我们自己的自定义模型。
主体和头部
使
Transformers 如此通用的主要概念是将架构分为 主体 和 头部(正如我们在第一章中看到的)。我们已经看到,当我们从预训练任务切换到下游任务时,我们需要用适合任务的最后一层替换模型。这最后一层称为模型头部;它是特定于任务的部分。模型的其余部分称为主体;它包括与任务无关的标记嵌入和变换器层。这种结构也反映在
Transformers 代码中:模型的主体在类似 BertModel
或 GPT2Model
的类中实现,返回最后一层的隐藏状态。特定于任务的模型,如 BertForMaskedLM
或 BertForSequenceClassification
,使用基础模型,并在隐藏状态的顶部添加必要的头部,如图 4-4 所示。
图 4-4. BertModel
类仅包含模型的主体,而 BertFor
类将主体与给定任务的专用头部组合起来
接下来我们将看到,这种主体和头部的分离使我们能够为任何任务构建自定义头部,并将其直接安装在预训练模型的顶部。
为标记分类创建自定义模型
让我们来练习为 XLM-R 构建一个自定义的标记分类头。由于 XLM-R 使用与 RoBERTa 相同的模型架构,我们将使用 RoBERTa 作为基础模型,但增加了特定于 XLM-R 的设置。请注意,这是一个教育性的练习,向您展示如何为自己的任务构建自定义模型。对于标记分类,已经存在一个 XLMRobertaForTokenClassification
类,您可以从
Transformers 中导入。如果愿意,您可以跳到下一节,直接使用那个。
要开始,我们需要一个数据结构来表示我们的 XLM-R NER 标记器。作为第一个猜测,我们将需要一个配置对象来初始化模型和一个 forward()
函数来生成输出。让我们继续构建我们的 XLM-R 标记分类的类:
import torch.nn as nn from transformers import XLMRobertaConfig from transformers.modeling_outputs import TokenClassifierOutput from transformers.models.roberta.modeling_roberta import RobertaModel from transformers.models.roberta.modeling_roberta import RobertaPreTrainedModel class XLMRobertaForTokenClassification(RobertaPreTrainedModel): config_class = XLMRobertaConfig def __init__(self, config): super().__init__(config) self.num_labels = config.num_labels # Load model body self.roberta = RobertaModel(config, add_pooling_layer=False) # Set up token classification head self.dropout = nn.Dropout(config.hidden_dropout_prob) self.classifier = nn.Linear(config.hidden_size, config.num_labels) # Load and initialize weights self.init_weights() def forward(self, input_ids=None, attention_mask=None, token_type_ids=None, labels=None, **kwargs): # Use model body to get encoder representations outputs = self.roberta(input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids, **kwargs) # Apply classifier to encoder representation sequence_output = self.dropout(outputs[0]) logits = self.classifier(sequence_output) # Calculate losses loss = None if labels is not None: loss_fct = nn.CrossEntropyLoss() loss = loss_fct(logits.view(-1, self.num_labels), labels.view(-1)) # Return model output object return TokenClassifierOutput(loss=loss, logits=logits, hidden_states=outputs.hidden_states, attentions=outputs.attentions)
config_class
确保在初始化新模型时使用标准的 XLM-R 设置。如果要更改默认参数,可以通过覆盖配置中的默认设置来实现。使用 super()
方法调用 RobertaPreTrainedModel
类的初始化函数。这个抽象类处理预训练权重的初始化或加载。然后我们加载我们的模型主体,即 RobertaModel
,并用自己的分类头扩展它,包括一个 dropout 和一个标准的前馈层。请注意,我们设置 add_pooling_layer=False
以确保返回所有隐藏状态,而不仅仅是与 [CLS]
标记相关联的隐藏状态。最后,我们通过调用从 RobertaPreTrainedModel
继承的 init_weights()
方法来初始化所有权重,这将加载模型主体的预训练权重并随机初始化我们的标记分类头的权重。
唯一剩下的事情就是定义模型在前向传递中应该做什么,使用 forward()
方法。在前向传递期间,数据首先通过模型主体进行馈送。有许多输入变量,但我们现在只需要 input_ids
和 attention_mask
。然后,模型主体输出的隐藏状态通过 dropout 和分类层进行馈送。如果我们在前向传递中还提供标签,我们可以直接计算损失。如果有注意力掩码,我们需要做一些额外的工作,以确保我们只计算未掩码标记的损失。最后,我们将所有输出封装在一个 TokenClassifierOutput
对象中,这样我们就可以从前几章中熟悉的命名元组中访问元素。
通过实现一个简单类的两个函数,我们就可以构建自己的自定义转换器模型。而且,由于我们继承自PreTrainedModel
,我们立即就可以访问所有有用的
Transformer 实用工具,比如from_pretrained()
!让我们看看如何将预训练权重加载到我们的自定义模型中。
加载自定义模型
现在我们准备加载我们的标记分类模型。除了模型名称之外,我们还需要提供一些额外的信息,包括我们将用于标记每个实体的标签以及每个标签与 ID 之间的映射,反之亦然。所有这些信息都可以从我们的tags
变量中派生出来,作为一个ClassLabel
对象,它具有一个我们可以用来派生映射的names
属性:
index2tag = {idx: tag for idx, tag in enumerate(tags.names)} tag2index = {tag: idx for idx, tag in enumerate(tags.names)}
我们将把这些映射和tags.num_classes
属性存储在我们在第三章中遇到的AutoConfig
对象中。通过向from_pretrained()
方法传递关键字参数来覆盖默认值:
from transformers import AutoConfig xlmr_config = AutoConfig.from_pretrained(xlmr_model_name, num_labels=tags.num_classes, id2label=index2tag, label2id=tag2index)
AutoConfig
类包含了模型架构的蓝图。当我们使用AutoModel.from_pretrained(*model_ckpt*)
加载模型时,与该模型关联的配置文件会自动下载。然而,如果我们想要修改诸如类的数量或标签名称之类的东西,那么我们可以首先加载配置,然后使用我们想要自定义的参数加载配置。
现在,我们可以像往常一样使用from_pretrained()
函数加载模型权重,还可以使用额外的config
参数。请注意,我们没有在我们的自定义模型类中实现加载预训练权重;我们通过从RobertaPreTrainedModel
继承来免费获得这一点:
import torch device = torch.device("cuda" if torch.cuda.is_available() else "cpu") xlmr_model = (XLMRobertaForTokenClassification .from_pretrained(xlmr_model_name, config=xlmr_config) .to(device))
为了快速检查我们是否正确初始化了标记器和模型,让我们在我们已知实体的小序列上测试预测:
input_ids = xlmr_tokenizer.encode(text, return_tensors="pt") pd.DataFrame([xlmr_tokens, input_ids[0].numpy()], index=["Tokens", "Input IDs"])
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | |
Tokens | ▁Jack | ▁Spar | row | ▁love | s | ▁New | ▁York | ! | ||
Input IDs | 0 | 21763 | 37456 | 15555 | 5161 | 7 | 2356 | 5753 | 38 | 2 |
正如你在这里看到的,起始和结束标记分别被赋予了 ID 0 和 2。
最后,我们需要将输入传递给模型,并通过取 argmax 来提取预测,以获得每个标记最有可能的类:
outputs = xlmr_model(input_ids.to(device)).logits predictions = torch.argmax(outputs, dim=-1) print(f"Number of tokens in sequence: {len(xlmr_tokens)}") print(f"Shape of outputs: {outputs.shape}")
Number of tokens in sequence: 10 Shape of outputs: torch.Size([1, 10, 7])
在这里,我们看到 logits 的形状为[batch_size, num_tokens, num_tags]
,每个标记都被赋予了七个可能的 NER 标记中的一个 logit。通过枚举序列,我们可以快速看到预训练模型的预测:
preds = [tags.names[p] for p in predictions[0].cpu().numpy()] pd.DataFrame([xlmr_tokens, preds], index=["Tokens", "Tags"])
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | |
Tokens | ▁Jack | ▁Spar | row | ▁love | s | ▁New | ▁York | ! | ||
Tags | O | I-LOC | B-LOC | B-LOC | O | I-LOC | O | O | I-LOC | B-LOC |
毫不奇怪,我们的具有随机权重的标记分类层还有很多需要改进的地方;让我们在一些带标签的数据上进行微调,使其变得更好!在这样做之前,让我们把前面的步骤封装成一个辅助函数,以备后用:
def tag_text(text, tags, model, tokenizer): # Get tokens with special characters tokens = tokenizer(text).tokens() # Encode the sequence into IDs input_ids = xlmr_tokenizer(text, return_tensors="pt").input_ids.to(device) # Get predictions as distribution over 7 possible classes outputs = model(inputs)[0] # Take argmax to get most likely class per token predictions = torch.argmax(outputs, dim=2) # Convert to DataFrame preds = [tags.names[p] for p in predictions[0].cpu().numpy()] return pd.DataFrame([tokens, preds], index=["Tokens", "Tags"])
在我们训练模型之前,我们还需要对输入进行标记化处理并准备标签。我们接下来会做这个。
用于 NER 的标记化文本
现在我们已经确定了标记器和模型可以对单个示例进行编码,我们的下一步是对整个数据集进行标记化处理,以便我们可以将其传递给 XLM-R 模型进行微调。正如我们在第二章中看到的那样,
Datasets 提供了一种快速的方法来使用map()
操作对Dataset
对象进行标记化处理。为了实现这一点,我们首先需要定义一个具有最小签名的函数:
function(examples: Dict[str, List]) -> Dict[str, List]
其中examples
相当于Dataset
的一个切片,例如panx_de['train'][:10]
。由于 XLM-R 标记器返回模型输入的输入 ID,我们只需要用注意力掩码和编码关于每个 NER 标记与每个标记相关联的哪个标记的信息的标签 ID 来增加这些信息。
按照
Transformers documentation中采用的方法,让我们看看这如何在我们的单个德语示例中运作,首先将单词和标签收集为普通列表:
words, labels = de_example["tokens"], de_example["ner_tags"]
接下来,我们对每个单词进行标记化,并使用is_split_into_words
参数告诉标记器我们的输入序列已经被分成了单词:
tokenized_input = xlmr_tokenizer(de_example["tokens"], is_split_into_words=True) tokens = xlmr_tokenizer.convert_ids_to_tokens(tokenized_input["input_ids"]) pd.DataFrame([tokens], index=["Tokens"])
0 | 1 | 2 | 3 | 4 | 5 | 6 | … | 18 | 19 | 20 | 21 | 22 | 23 | 24 | |
标记 | ▁2.000 | ▁Einwohner | n | ▁an | ▁der | ▁Dan | … | schaft | ▁Po | mmer | n | ▁ | . |
在这个例子中,我们可以看到标记器将“Einwohnern”分成了两个子词,“▁Einwohner”和“n”。由于我们遵循的约定是只有“▁Einwohner”应该与B-LOC
标签相关联,我们需要一种方法来屏蔽第一个子词之后的子词表示。幸运的是,tokenized_input
是一个包含word_ids()
函数的类,可以帮助我们实现这一点:
word_ids = tokenized_input.word_ids() pd.DataFrame([tokens, word_ids], index=["Tokens", "Word IDs"])
0 | 1 | 2 | 3 | 4 | 5 | 6 | … | 18 | 19 | 20 | 21 | 22 | 23 | 24 | |
标记 | ▁2.000 | ▁Einwohner | n | ▁an | ▁der | ▁Dan | … | schaft | ▁Po | mmer | n | ▁ | . | ||
单词 ID | None | 0 | 1 | 1 | 2 | 3 | 4 | … | 9 | 10 | 10 | 10 | 11 | 11 | None |
在这个例子中,我们可以看到word_ids
已经将每个子词映射到words
序列中的相应索引,因此第一个子词“▁2.000”被分配索引 0,而“▁Einwohner”和“n”被分配索引 1(因为“Einwohnern”是words
中的第二个单词)。我们还可以看到像和<\s>
这样的特殊标记被映射为None
。让我们将-100 设置为这些特殊标记和训练过程中希望屏蔽的子词的标签:
previous_word_idx = None label_ids = [] for word_idx in word_ids: if word_idx is None or word_idx == previous_word_idx: label_ids.append(-100) elif word_idx != previous_word_idx: label_ids.append(labels[word_idx]) previous_word_idx = word_idx labels = [index2tag[l] if l != -100 else "IGN" for l in label_ids] index = ["Tokens", "Word IDs", "Label IDs", "Labels"] pd.DataFrame([tokens, word_ids, label_ids, labels], index=index)
0 | 1 | 2 | 3 | 4 | 5 | … | 19 | 20 | 21 | 22 | 23 | 24 | |
标记 | ▁2.000 | ▁Einwohner | n | ▁an | ▁der | … | ▁Po | mmer | n | ▁ | . | ||
单词 ID | None | 0 | 1 | 1 | 2 | 3 | … | 10 | 10 | 10 | 11 | 11 | None |
标签 ID | -100 | 0 | 0 | -100 | 0 | 0 | … | 6 | -100 | -100 | 0 | -100 | -100 |
标签 | IGN | O | O | IGN | O | O | … | I-LOC | IGN | IGN | O | IGN | IGN |
注
为什么我们选择-100 作为屏蔽子词表示的 ID?原因是在 PyTorch 中,交叉熵损失类torch.nn.CrossEntropyLoss
有一个名为ignore_index
的属性,其值为-100。在训练过程中会忽略此索引,因此我们可以使用它来忽略与连续子词相关联的标记。
这就是全部!我们可以清楚地看到标签 ID 与标记对齐,所以让我们通过定义一个包装所有逻辑的单个函数,将其扩展到整个数据集:
def tokenize_and_align_labels(examples): tokenized_inputs = xlmr_tokenizer(examples["tokens"], truncation=True, is_split_into_words=True) labels = [] for idx, label in enumerate(examples["ner_tags"]): word_ids = tokenized_inputs.word_ids(batch_index=idx) previous_word_idx = None label_ids = [] for word_idx in word_ids: if word_idx is None or word_idx == previous_word_idx: label_ids.append(-100) else: label_ids.append(label[word_idx]) previous_word_idx = word_idx labels.append(label_ids) tokenized_inputs["labels"] = labels return tokenized_inputs
现在我们已经有了编码每个拆分所需的所有要素,让我们编写一个可以迭代的函数:
def encode_panx_dataset(corpus): return corpus.map(tokenize_and_align_labels, batched=True, remove_columns=['langs', 'ner_tags', 'tokens'])
通过将此函数应用于DatasetDict
对象,我们可以得到每个拆分的编码Dataset
对象。让我们使用这个来对我们的德语语料库进行编码:
panx_de_encoded = encode_panx_dataset(panx_ch["de"])
现在我们有了一个模型和一个数据集,我们需要定义一个性能指标。
性能指标
评估 NER 模型类似于评估文本分类模型,通常报告精确度、召回率和F[1]-分数的结果。唯一的微妙之处在于,实体的所有单词都需要被正确预测,才能将预测视为正确。幸运的是,有一个名为seqeval的巧妙库专门用于这类任务。例如,给定一些占位符 NER 标签和模型预测,我们可以通过 seqeval 的classification_report()
函数计算指标:
from seqeval.metrics import classification_report y_true = [["O", "O", "O", "B-MISC", "I-MISC", "I-MISC", "O"], ["B-PER", "I-PER", "O"]] y_pred = [["O", "O", "B-MISC", "I-MISC", "I-MISC", "I-MISC", "O"], ["B-PER", "I-PER", "O"]] print(classification_report(y_true, y_pred))
precision recall f1-score support MISC 0.00 0.00 0.00 1 PER 1.00 1.00 1.00 1 micro avg 0.50 0.50 0.50 2 macro avg 0.50 0.50 0.50 2 weighted avg 0.50 0.50 0.50 2
正如我们所看到的,seqeval期望预测和标签作为列表的列表,每个列表对应于我们的验证或测试集中的单个示例。为了在训练过程中集成这些指标,我们需要一个函数,可以获取模型的输出并将其转换为seqeval所期望的列表。以下方法可以确保我们忽略与后续子词相关联的标签 ID:
import numpy as np def align_predictions(predictions, label_ids): preds = np.argmax(predictions, axis=2) batch_size, seq_len = preds.shape labels_list, preds_list = [], [] for batch_idx in range(batch_size): example_labels, example_preds = [], [] for seq_idx in range(seq_len): # Ignore label IDs = -100 if label_ids[batch_idx, seq_idx] != -100: example_labels.append(index2tag[label_ids[batch_idx][seq_idx]]) example_preds.append(index2tag[preds[batch_idx][seq_idx]]) labels_list.append(example_labels) preds_list.append(example_preds) return preds_list, labels_list
有了性能指标,我们可以开始实际训练模型了。
微调 XLM-RoBERTa
现在我们有了所有微调模型的要素!我们的第一个策略是在 PAN-X 的德语子集上微调我们的基础模型,然后评估其在法语、意大利语和英语上的零射击跨语言性能。像往常一样,我们将使用
Transformers Trainer
来处理我们的训练循环,所以首先我们需要使用TrainingArguments
类定义训练属性:
from transformers import TrainingArguments num_epochs = 3 batch_size = 24 logging_steps = len(panx_de_encoded["train"]) // batch_size model_name = f"{xlmr_model_name}-finetuned-panx-de" training_args = TrainingArguments( output_dir=model_name, log_level="error", num_train_epochs=num_epochs, per_device_train_batch_size=batch_size, per_device_eval_batch_size=batch_size, evaluation_strategy="epoch", save_steps=1e6, weight_decay=0.01, disable_tqdm=False, logging_steps=logging_steps, push_to_hub=True)
在每个时代结束时,我们评估模型对验证集的预测,调整权重衰减,并将save_steps
设置为一个较大的数字,以禁用检查点并加快训练速度。
这也是一个好时机,确保我们已登录到 Hugging Face Hub(如果您在终端工作,可以执行命令huggingface-cli login
):
from huggingface_hub import notebook_login notebook_login()
我们还需要告诉Trainer
如何在验证集上计算指标,因此我们可以使用之前定义的align_predictions()
函数来提取seqeval计算F[1]-score 所需格式的预测和标签:
from seqeval.metrics import f1_score def compute_metrics(eval_pred): y_pred, y_true = align_predictions(eval_pred.predictions, eval_pred.label_ids) return {"f1": f1_score(y_true, y_pred)}
最后一步是定义一个数据收集器,以便我们可以将每个输入序列填充到批处理中的最大序列长度。
Transformers 提供了专门用于标记分类的数据收集器,它将填充标签以及输入:
from transformers import DataCollatorForTokenClassification data_collator = DataCollatorForTokenClassification(xlmr_tokenizer)
填充标签是必要的,因为与文本分类任务不同,标签也是序列。这里的一个重要细节是,标签序列用值-100 进行填充,正如我们所见,这个值会被 PyTorch 损失函数忽略。
在本章的过程中,我们将训练几个模型,因此我们将通过创建model_init()
方法来避免为每个Trainer
初始化一个新模型。该方法加载一个未经训练的模型,并在train()
调用开始时调用:
def model_init(): return (XLMRobertaForTokenClassification .from_pretrained(xlmr_model_name, config=xlmr_config) .to(device))
现在我们可以将所有这些信息与编码的数据集一起传递给Trainer
:
from transformers import Trainer trainer = Trainer(model_init=model_init, args=training_args, data_collator=data_collator, compute_metrics=compute_metrics, train_dataset=panx_de_encoded["train"], eval_dataset=panx_de_encoded["validation"], tokenizer=xlmr_tokenizer)
然后按照以下方式运行训练循环,并将最终模型推送到 Hub:
trainer.train() trainer.push_to_hub(commit_message="Training completed!")
Epoch | Training Loss | Validation Loss | F1 |
1 | 0.2652 | 0.160244 | 0.822974 |
2 | 0.1314 | 0.137195 | 0.852747 |
3 | 0.0806 | 0.138774 | 0.864591 |
这些 F1 分数对于 NER 模型来说相当不错。为了确认我们的模型按预期工作,让我们在我们简单示例的德语翻译上进行测试:
text_de = "Jeff Dean ist ein Informatiker bei Google in Kalifornien" tag_text(text_de, tags, trainer.model, xlmr_tokenizer)
0 | 1 | 2 | 3 | 4 | 5 | … | 8 | 9 | 10 | 11 | 12 | 13 | |
Tokens | ▁Jeff | ▁De | an | ▁ist | ▁ein | … | ▁bei | ▁in | ▁Kaliforni | en | |||
Tags | O | B-PER | I-PER | I-PER | O | O | … | O | B-ORG | O | B-LOC | I-LOC | O |
这很有效!但我们不应该对基于单个示例的性能过于自信。相反,我们应该对模型的错误进行适当和彻底的调查。在下一节中,我们将探讨如何在 NER 任务中进行这样的调查。
错误分析
在我们深入探讨 XLM-R 的多语言方面之前,让我们花一分钟来调查我们模型的错误。正如我们在第二章中看到的,对模型进行彻底的错误分析是训练和调试 Transformer(以及机器学习模型一般)最重要的方面之一。有几种失败模式,其中模型看起来表现良好,而实际上它存在一些严重的缺陷。训练可能失败的例子包括:
- 我们可能会意外地屏蔽太多的标记,也会屏蔽一些标签,以获得真正有希望的损失下降。
compute_metrics()
函数可能存在一个高估真实性能的错误。- 我们可能会将零类或
O
实体包括在 NER 中作为正常类别,这将严重扭曲准确性和F[1]-分数,因为它是绝大多数类别。
当模型的表现远低于预期时,查看错误可能会提供有用的见解,并揭示很难仅通过查看代码就能发现的错误。即使模型表现良好,并且代码中没有错误,错误分析仍然是了解模型优势和劣势的有用工具。这些都是我们在将模型部署到生产环境时需要牢记的方面。
对于我们的分析,我们将再次使用我们手头上最强大的工具之一,即查看损失最大的验证示例。我们可以重复使用我们构建的函数的大部分内容来分析第二章中的序列分类模型,但现在我们将计算样本序列中每个标记的损失。
让我们定义一个可以应用于验证集的方法:
from torch.nn.functional import cross_entropy def forward_pass_with_label(batch): # Convert dict of lists to list of dicts suitable for data collator features = [dict(zip(batch, t)) for t in zip(*batch.values())] # Pad inputs and labels and put all tensors on device batch = data_collator(features) input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) labels = batch["labels"].to(device) with torch.no_grad(): # Pass data through model output = trainer.model(input_ids, attention_mask) # logit.size: [batch_size, sequence_length, classes] # Predict class with largest logit value on classes axis predicted_label = torch.argmax(output.logits, axis=-1).cpu().numpy() # Calculate loss per token after flattening batch dimension with view loss = cross_entropy(output.logits.view(-1, 7), labels.view(-1), reduction="none") # Unflatten batch dimension and convert to numpy array loss = loss.view(len(input_ids), -1).cpu().numpy() return {"loss":loss, "predicted_label": predicted_label}
现在我们可以使用map()
将这个函数应用到整个验证集,并将所有数据加载到DataFrame
中进行进一步分析:
valid_set = panx_de_encoded["validation"] valid_set = valid_set.map(forward_pass_with_label, batched=True, batch_size=32) df = valid_set.to_pandas()
标记和标签仍然使用它们的 ID 进行编码,因此让我们将标记和标签映射回字符串,以便更容易阅读结果。对于带有标签-100 的填充标记,我们分配一个特殊的标签IGN
,以便稍后过滤它们。我们还通过将它们截断到输入的长度来消除loss
和predicted_label
字段中的所有填充:
index2tag[-100] = "IGN" df["input_tokens"] = df["input_ids"].apply( lambda x: xlmr_tokenizer.convert_ids_to_tokens(x)) df["predicted_label"] = df["predicted_label"].apply( lambda x: [index2tag[i] for i in x]) df["labels"] = df["labels"].apply( lambda x: [index2tag[i] for i in x]) df['loss'] = df.apply( lambda x: x['loss'][:len(x['input_ids'])], axis=1) df['predicted_label'] = df.apply( lambda x: x['predicted_label'][:len(x['input_ids'])], axis=1) df.head(1)
attention_mask | input_ids | labels | loss | predicted_label | input_tokens | |
0 | [1, 1, 1, 1, 1, 1, 1] | [0, 10699, 11, 15, 16104, 1388, 2] | [IGN, B-ORG, IGN, I-ORG, I-ORG, I-ORG, IGN] | [0.0, 0.014679872, 0.0, 0.009469474, 0.010393422, 0.01293836, 0.0] | [I-ORG, B-ORG, I-ORG, I-ORG, I-ORG, I-ORG, I-ORG] | [ |
每一列都包含一个标记、标签、预测标签等的列表,让我们逐个查看这些标记。pandas.Series.explode()
函数允许我们在一行中为原始行列表中的每个元素创建一行。由于一行中的所有列表长度相同,我们可以并行进行操作。我们还丢弃了我们命名为IGN
的填充标记,因为它们的损失无论如何都是零。最后,我们将仍然是numpy.Array
对象的损失转换为标准浮点数:
df_tokens = df.apply(pd.Series.explode) df_tokens = df_tokens.query("labels != 'IGN'") df_tokens["loss"] = df_tokens["loss"].astype(float).round(2) df_tokens.head(7)
attention_mask | input_ids | labels | loss | predicted_label | input_tokens |
1 | 10699 | B-ORG | 0.01 | B-ORG | ▁Ham |
1 | 15 | I-ORG | 0.01 | I-ORG | ▁( |
1 | 16104 | I-ORG | 0.01 | I-ORG | ▁Unternehmen |
1 | 1388 | I-ORG | 0.01 | I-ORG | ▁) |
1 | 56530 | O | 0.00 | O | ▁WE |
1 | 83982 | B-ORG | 0.34 | B-ORG | ▁Luz |
1 | 10 | I-ORG | 0.45 | I-ORG | ▁a |
有了这样的数据,我们现在可以按输入标记对其进行分组,并聚合每个标记的损失、计数、均值和总和。最后,我们按损失的总和对聚合数据进行排序,并查看验证集中累积损失最多的标记:
( df_tokens.groupby("input_tokens")[["loss"]] .agg(["count", "mean", "sum"]) .droplevel(level=0, axis=1) # Get rid of multi-level columns .sort_values(by="sum", ascending=False) .reset_index() .round(2) .head(10) .T )
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | |
输入标记 | ▁ | ▁der | ▁in | ▁von | ▁/ | ▁und | ▁( | ▁) | ▁’’ | ▁A |
计数 | 6066 | 1388 | 989 | 808 | 163 | 1171 | 246 | 246 | 2898 | 125 |
平均值 | 0.03 | 0.1 | 0.14 | 0.14 | 0.64 | 0.08 | 0.3 | 0.29 | 0.02 | 0.44 |
总和 | 200.71 | 138.05 | 137.33 | 114.92 | 104.28 | 99.15 | 74.49 | 72.35 | 59.31 | 54.48 |
我们可以在这个列表中观察到几种模式:
- 空格标记具有最高的总损失,这并不奇怪,因为它也是列表中最常见的标记。然而,它的平均损失要低得多。这意味着模型不会在对其进行分类时遇到困难。
- 像“in”、“von”、“der”和“und”这样的词出现相对频繁。它们经常与命名实体一起出现,有时也是它们的一部分,这解释了为什么模型可能会混淆它们。
- 括号、斜杠和单词开头的大写字母很少见,但平均损失相对较高。我们将进一步调查它们。
我们还可以对标签 ID 进行分组,并查看每个类别的损失:
( df_tokens.groupby("labels")[["loss"]] .agg(["count", "mean", "sum"]) .droplevel(level=0, axis=1) .sort_values(by="mean", ascending=False) .reset_index() .round(2) .T )
0 | 1 | 2 | 3 | 4 | 5 | 6 | |
标签 | B-ORG | I-LOC | I-ORG | B-LOC | B-PER | I-PER | O |
计数 | 2683 | 1462 | 3820 | 3172 | 2893 | 4139 | 43648 |
平均值 | 0.66 | 0.64 | 0.48 | 0.35 | 0.26 | 0.18 | 0.03 |
总和 | 1769.47 | 930.94 | 1850.39 | 1111.03 | 760.56 | 750.91 | 1354.46 |
我们看到B-ORG
的平均损失最高,这意味着确定组织的开始对我们的模型构成了挑战。
我们可以通过绘制标记分类的混淆矩阵来进一步分解这一点,在那里我们看到组织的开始经常与随后的I-ORG
标记混淆:
from sklearn.metrics import ConfusionMatrixDisplay, confusion_matrix def plot_confusion_matrix(y_preds, y_true, labels): cm = confusion_matrix(y_true, y_preds, normalize="true") fig, ax = plt.subplots(figsize=(6, 6)) disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=labels) disp.plot(cmap="Blues", values_format=".2f", ax=ax, colorbar=False) plt.title("Normalized confusion matrix") plt.show()
plot_confusion_matrix(df_tokens["labels"], df_tokens["predicted_label"], tags.names)
从图中我们可以看出,我们的模型往往最容易混淆B-ORG
和I-ORG
实体。否则,它在分类其余实体方面表现相当不错,这可以从混淆矩阵近似对角线的性质中清楚地看出。
现在我们已经在标记级别上检查了错误,让我们继续看一下损失较大的序列。为了进行这种计算,我们将重新审视我们的“未爆炸”的DataFrame
,并通过对每个标记的损失求和来计算总损失。为此,让我们首先编写一个帮助我们显示带有标签和损失的标记序列的函数:
def get_samples(df): for _, row in df.iterrows(): labels, preds, tokens, losses = [], [], [], [] for i, mask in enumerate(row["attention_mask"]): if i not in {0, len(row["attention_mask"])}: labels.append(row["labels"][i]) preds.append(row["predicted_label"][i]) tokens.append(row["input_tokens"][i]) losses.append(f"{row['loss'][i]:.2f}") df_tmp = pd.DataFrame({"tokens": tokens, "labels": labels, "preds": preds, "losses": losses}).T yield df_tmp df["total_loss"] = df["loss"].apply(sum) df_tmp = df.sort_values(by="total_loss", ascending=False).head(3) for sample in get_samples(df_tmp): display(sample)
0 | 1 | 2 | 3 | 4 | … | 13 | 14 | 15 | 16 | 17 | |
标记 | ▁’’ | 8 | . | ▁Juli | ▁’’ | … | n | ischen | ▁Gar | de | |
标签 | B-ORG | IGN | IGN | I-ORG | I-ORG | … | IGN | IGN | I-ORG | IGN | IGN |
预测 | O | O | O | O | O | … | I-ORG | I-ORG | I-ORG | I-ORG | O |
损失 | 7.89 | 0.00 | 0.00 | 6.88 | 8.05 | … | 0.00 | 0.00 | 0.01 | 0.00 | 0.00 |
0 | 1 | 2 | 3 | 4 | … | 14 | 15 | 16 | 17 | 18 | |
— | — | — | — | — | — | — | — | — | — | — | — |
标记 | ▁’ | ▁’’ | ▁Τ | Κ | ▁’’ | … | k | ▁’’ | ▁’ | ala | |
标签 | O | O | O | IGN | O | … | IGN | I-LOC | I-LOC | IGN | IGN |
预测 | O | O | B-ORG | O | O | … | O | O | O | O | O |
损失 | 0.00 | 0.00 | 3.59 | 0.00 | 0.00 | … | 0.00 | 7.66 | 7.78 | 0.00 | 0.00 |
0 | 1 | 2 | 3 | 4 | … | 10 | 11 | 12 | 13 | 14 | |
— | — | — | — | — | — | — | — | — | — | — | — |
标记 | ▁联合 | ▁国家 | ▁多 | 维 | ▁整合 | … | ▁中央 | ▁非洲 | ▁共和国 | ||
标签 | B-PER | I-PER | I-PER | IGN | I-PER | … | I-PER | I-PER | I-PER | I-PER | IGN |
预测 | B-ORG | I-ORG | I-ORG | I-ORG | I-ORG | … | I-ORG | I-ORG | I-ORG | I-ORG | I-ORG |
损失 | 6.46 | 5.59 | 5.51 | 0.00 | 5.11 | … | 4.77 | 5.32 | 5.10 | 4.87 | 0.00 |
显然,这些样本的标签出现了问题;例如,联合国和中非共和国分别被标记为一个人!与此同时,第一个例子中的“8. Juli”被标记为一个组织。原来 PAN-X 数据集的注释是通过自动化过程生成的。这样的注释通常被称为“银标准”(与人工生成的注释的“金标准”相对),并且并不奇怪,自动化方法未能产生合理的标签。事实上,这种失败模式并不是自动方法的特有现象;即使在人类仔细注释数据时,当注释者的注意力分散或者他们简单地误解句子时,也会出现错误。
我们早些时候注意到的另一件事是,括号和斜杠的损失相对较高。让我们看一些带有开括号的序列的例子:
df_tmp = df.loc[df["input_tokens"].apply(lambda x: u"\u2581(" in x)].head(2) for sample in get_samples(df_tmp): display(sample)
0 | 1 | 2 | 3 | 4 | 5 | |
标记 | ▁Ham | a | ▁( | ▁Unternehmen | ▁) | |
标签 | B-ORG | IGN | I-ORG | I-ORG | I-ORG | IGN |
预测 | B-ORG | I-ORG | I-ORG | I-ORG | I-ORG | I-ORG |
损失 | 0.01 | 0.00 | 0.01 | 0.01 | 0.01 | 0.00 |
0 | 1 | 2 | 3 | 4 | 5 | |
— | — | — | — | — | — | — |
标记 | ▁Kesk | kül | a | ▁( | ▁Mart | na |
标签 | B-LOC | IGN | IGN | I-LOC | I-LOC | IGN |
预测 | B-LOC | I-LOC | I-LOC | I-LOC | I-LOC | I-LOC |
损失 | 0.02 | 0.00 | 0.00 | 0.01 | 0.01 | 0.00 |
通常情况下,我们不会将括号及其内容包括在命名实体的一部分,但这似乎是自动提取标注文档的方式。在其他例子中,括号中包含地理位置的说明。虽然这确实也是一个位置,但我们可能希望在注释中将其与原始位置断开。这个数据集由不同语言的维基百科文章组成,文章标题通常包含括号中的某种解释。例如,在第一个例子中,括号中的文本表明哈马是一个“Unternehmen”,或者在英语中是公司。当我们推出模型时,了解这些重要细节是很重要的,因为它们可能对模型所属的整个流水线的下游性能产生影响。
通过相对简单的分析,我们已经确定了我们的模型和数据集的一些弱点。在实际用例中,我们将在这一步上进行迭代,清理数据集,重新训练模型,并分析新的错误,直到我们对性能感到满意。
在这里,我们分析了单一语言的错误,但我们也对跨语言的性能感兴趣。在下一节中,我们将进行一些实验,看看 XLM-R 中的跨语言转移效果如何。
跨语言转移
现在我们已经在德语上对 XLM-R 进行了微调,我们可以通过Trainer
的predict()
方法评估它对其他语言的转移能力。由于我们计划评估多种语言,让我们创建一个简单的函数来为我们执行这些操作:
def get_f1_score(trainer, dataset): return trainer.predict(dataset).metrics["test_f1"]
我们可以使用这个函数来检查测试集的性能,并在dict
中跟踪我们的分数:
f1_scores = defaultdict(dict) f1_scores["de"]["de"] = get_f1_score(trainer, panx_de_encoded["test"]) print(f"F1-score of [de] model on [de] dataset: {f1_scores['de']['de']:.3f}")
F1-score of [de] model on [de] dataset: 0.868
这对于 NER 任务来说是相当不错的结果。我们的指标大约在 85%左右,我们可以看到模型似乎在ORG
实体上遇到了最大的困难,可能是因为这些在训练数据中最不常见,而且 XLM-R 的词汇表中许多组织名称都很少见。其他语言呢?为了热身,让我们看看我们在德语上进行微调的模型在法语上的表现如何:
text_fr = "Jeff Dean est informaticien chez Google en Californie" tag_text(text_fr, tags, trainer.model, xlmr_tokenizer)
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | |
标记 | ▁Jeff | ▁De | an | ▁est | ▁informatic | ien | ▁chez | ▁en | ▁Cali | for | nie | |||
Tags | O | B-PER | I-PER | I-PER | O | O | O | O | B-ORG | O | B-LOC | I-LOC | I-LOC | O |
不错!尽管两种语言的名称和组织都是相同的,但模型成功地正确标记了“Kalifornien”的法语翻译。接下来,让我们通过编写一个简单的函数来对整个法语测试集上的德语模型的表现进行量化,该函数对数据集进行编码并生成分类报告:
def evaluate_lang_performance(lang, trainer): panx_ds = encode_panx_dataset(panx_ch[lang]) return get_f1_score(trainer, panx_ds["test"])
f1_scores["de"]["fr"] = evaluate_lang_performance("fr", trainer) print(f"F1-score of [de] model on [fr] dataset: {f1_scores['de']['fr']:.3f}")
F1-score of [de] model on [fr] dataset: 0.714
尽管我们在微观平均指标上看到了约 15 个点的下降,但请记住,我们的模型没有看到任何一个标记的法语示例!一般来说,性能下降的大小与语言之间的“距离”有关。尽管德语和法语被归为印欧语系,但它们从技术上属于不同的语系:分别是日耳曼语和罗曼语。
接下来,让我们评估意大利语的性能。由于意大利语也是一种罗曼语言,我们期望得到与法语相似的结果:
f1_scores["de"]["it"] = evaluate_lang_performance("it", trainer) print(f"F1-score of [de] model on [it] dataset: {f1_scores['de']['it']:.3f}")
F1-score of [de] model on [it] dataset: 0.692
事实上,我们的期望得到了F[1]-分数的证实。最后,让我们检查英语的性能,英语属于日耳曼语系:
f1_scores["de"]["en"] = evaluate_lang_performance("en", trainer) print(f"F1-score of [de] model on [en] dataset: {f1_scores['de']['en']:.3f}")
F1-score of [de] model on [en] dataset: 0.589
令人惊讶的是,尽管我们可能直觉地认为德语与英语更相似,但我们的模型在英语上表现得最差。在对德语进行微调并进行零-shot 转移到法语和英语之后,接下来让我们考虑何时直接在目标语言上进行微调是有意义的。
零-shot 转移何时有意义?
到目前为止,我们已经看到,在德语语料库上微调 XLM-R 可以获得约 85%的F[1]-分数,在任何额外的训练的情况下,该模型能够在我们语料库中的其他语言上取得适度的性能。问题是,这些结果有多好,它们与在单语语料库上微调的 XLM-R 模型相比如何?
在本节中,我们将通过在不断增加大小的训练集上微调 XLM-R 来探讨这个问题。通过这种方式跟踪性能,我们可以确定零-shot 跨语言转移何时更优越,这在实践中对于指导是否收集更多标记数据的决策可能是有用的。
为简单起见,我们将保持与在德语语料库上进行微调运行相同的超参数,只是我们将调整TrainingArguments
的logging_steps
参数,以考虑训练集大小的变化。我们可以将所有这些封装在一个简单的函数中,该函数接受与单语语料库对应的DatasetDict
对象,通过num_samples
对其进行下采样,并在该样本上对 XLM-R 进行微调,以返回最佳时期的指标:
def train_on_subset(dataset, num_samples): train_ds = dataset["train"].shuffle(seed=42).select(range(num_samples)) valid_ds = dataset["validation"] test_ds = dataset["test"] training_args.logging_steps = len(train_ds) // batch_size trainer = Trainer(model_init=model_init, args=training_args, data_collator=data_collator, compute_metrics=compute_metrics, train_dataset=train_ds, eval_dataset=valid_ds, tokenizer=xlmr_tokenizer) trainer.train() if training_args.push_to_hub: trainer.push_to_hub(commit_message="Training completed!") f1_score = get_f1_score(trainer, test_ds) return pd.DataFrame.from_dict( {"num_samples": [len(train_ds)], "f1_score": [f1_score]})
就像我们在德语语料库上进行微调一样,我们还需要将法语语料库编码为输入 ID、注意掩码和标签 ID:
panx_fr_encoded = encode_panx_dataset(panx_ch["fr"])
接下来,让我们通过在一个包含 250 个示例的小训练集上运行该函数来检查我们的函数是否有效:
training_args.push_to_hub = False metrics_df = train_on_subset(panx_fr_encoded, 250)
num_samples | f1_score | |
0 | 250 | 0.137329 |
我们可以看到,仅有 250 个示例时,在法语上进行微调的性能远远低于从德语进行零-shot 转移。现在让我们将训练集大小增加到 500、1,000、2,000 和 4,000 个示例,以了解性能的增加情况:
for num_samples in [500, 1000, 2000, 4000]: metrics_df = metrics_df.append( train_on_subset(panx_fr_encoded, num_samples), ignore_index=True)
我们可以通过绘制测试集上的F[1]-分数作为不断增加的训练集大小的函数来比较在法语样本上微调与从德语进行零-shot 跨语言转移的性能:
fig, ax = plt.subplots() ax.axhline(f1_scores["de"]["fr"], ls="--", color="r") metrics_df.set_index("num_samples").plot(ax=ax) plt.legend(["Zero-shot from de", "Fine-tuned on fr"], loc="lower right") plt.ylim((0, 1)) plt.xlabel("Number of Training Samples") plt.ylabel("F1 Score") plt.show()
从图中我们可以看到,零-shot 转移在大约 750 个训练示例之前仍然具有竞争力,之后在法语上进行微调达到了与我们在德语上进行微调时获得的类似性能水平。尽管如此,这个结果也不容忽视!根据我们的经验,即使是让领域专家标记数百个文档也可能成本高昂,特别是对于 NER,其中标记过程是细粒度且耗时的。
我们可以尝试一种最终的技术来评估多语言学习:一次在多种语言上进行微调!让我们看看我们可以如何做到这一点。
一次在多种语言上进行微调
到目前为止,我们已经看到从德语到法语或意大利语的零射击跨语言转移会导致性能下降约 15 个百分点。缓解这一点的一种方法是同时在多种语言上进行微调。为了看看我们可以获得什么类型的收益,让我们首先使用
数据集中的concatenate_datasets()
函数将德语和法语语料库连接在一起:
from datasets import concatenate_datasets def concatenate_splits(corpora): multi_corpus = DatasetDict() for split in corpora[0].keys(): multi_corpus[split] = concatenate_datasets( [corpus[split] for corpus in corpora]).shuffle(seed=42) return multi_corpus
panx_de_fr_encoded = concatenate_splits([panx_de_encoded, panx_fr_encoded])
对于训练,我们将再次使用前几节中相同的超参数,因此我们只需更新训练器中的日志步骤、模型和数据集:
training_args.logging_steps = len(panx_de_fr_encoded["train"]) // batch_size training_args.push_to_hub = True training_args.output_dir = "xlm-roberta-base-finetuned-panx-de-fr" trainer = Trainer(model_init=model_init, args=training_args, data_collator=data_collator, compute_metrics=compute_metrics, tokenizer=xlmr_tokenizer, train_dataset=panx_de_fr_encoded["train"], eval_dataset=panx_de_fr_encoded["validation"]) trainer.train() trainer.push_to_hub(commit_message="Training completed!")
让我们看看模型在每种语言的测试集上的表现:
for lang in langs: f1 = evaluate_lang_performance(lang, trainer) print(f"F1-score of [de-fr] model on [{lang}] dataset: {f1:.3f}")
F1-score of [de-fr] model on [de] dataset: 0.866 F1-score of [de-fr] model on [fr] dataset: 0.868 F1-score of [de-fr] model on [it] dataset: 0.815 F1-score of [de-fr] model on [en] dataset: 0.677
它在法语拆分上的表现比以前好得多,与德语测试集的表现相匹配。有趣的是,它在意大利语和英语拆分上的表现也提高了大约 10 个百分点!因此,即使在另一种语言中添加训练数据,也会提高模型在未知语言上的表现。
让我们通过比较分别在每种语言上进行微调和在所有语料库上进行多语言学习的性能来完成我们的分析。由于我们已经在德语语料库上进行了微调,我们可以使用我们的train_on_subset()
函数在剩余的语言上进行微调,其中num_samples
等于训练集中的示例数:
corpora = [panx_de_encoded] # Exclude German from iteration for lang in langs[1:]: training_args.output_dir = f"xlm-roberta-base-finetuned-panx-{lang}" # Fine-tune on monolingual corpus ds_encoded = encode_panx_dataset(panx_ch[lang]) metrics = train_on_subset(ds_encoded, ds_encoded["train"].num_rows) # Collect F1-scores in common dict f1_scores[lang][lang] = metrics["f1_score"][0] # Add monolingual corpus to list of corpora to concatenate corpora.append(ds_encoded)
现在我们已经在每种语言的语料库上进行了微调,下一步是将所有拆分合并在一起,创建一个包含所有四种语言的多语言语料库。与之前的德语和法语分析一样,我们可以使用concatenate_splits()
函数在我们在上一步生成的语料库列表上执行此步骤:
corpora_encoded = concatenate_splits(corpora)
现在我们有了我们的多语言语料库,我们可以使用训练器运行熟悉的步骤:
training_args.logging_steps = len(corpora_encoded["train"]) // batch_size training_args.output_dir = "xlm-roberta-base-finetuned-panx-all" trainer = Trainer(model_init=model_init, args=training_args, data_collator=data_collator, compute_metrics=compute_metrics, tokenizer=xlmr_tokenizer, train_dataset=corpora_encoded["train"], eval_dataset=corpora_encoded["validation"]) trainer.train() trainer.push_to_hub(commit_message="Training completed!")
最后一步是从训练器在每种语言的测试集上生成预测。这将让我们了解多语言学习的实际效果如何。我们将在我们的f1_scores
字典中收集F[1]-分数,然后创建一个DataFrame
,总结我们多语言实验的主要结果:
for idx, lang in enumerate(langs): f1_scores["all"][lang] = get_f1_score(trainer, corpora[idx]["test"])
scores_data = {"de": f1_scores["de"], "each": {lang: f1_scores[lang][lang] for lang in langs}, "all": f1_scores["all"]} f1_scores_df = pd.DataFrame(scores_data).T.round(4) f1_scores_df.rename_axis(index="Fine-tune on", columns="Evaluated on", inplace=True) f1_scores_df
评估语言 | de | fr | it | en |
微调于 | ||||
— | — | — | — | — |
de | 0.8677 | 0.7141 | 0.6923 | 0.5890 |
每种语言 | 0.8677 | 0.8505 | 0.8192 | 0.7068 |
all | 0.8682 | 0.8647 | 0.8575 | 0.7870 |
从这些结果中,我们可以得出一些一般性的结论:
- 多语言学习可以显著提高性能,特别是如果跨语言转移的低资源语言属于相似的语言家族。在我们的实验中,我们可以看到德语、法语和意大利语在
all
类别中实现了类似的性能,这表明这些语言彼此之间更相似,而不是与英语相似。 - 作为一般策略,专注于语言家族内部的跨语言转移是一个好主意,特别是在处理日语等不同脚本的情况下。
与模型小部件交互
在本章中,我们已经将许多经过精细调整的模型推送到了 Hub。虽然我们可以使用pipeline()
函数在本地机器上与它们交互,但 Hub 提供了适合这种工作流程的小部件。例如,我们在图 4-5 中展示了一个示例,用于我们的transformersbook/xlm-roberta-base-finetuned-panx-all
检查点,可以看到它在识别德语文本的所有实体方面做得很好。
图 4-5。Hugging Face Hub 上小部件的示例
结论
在本章中,我们看到了如何使用一个在 100 种语言上预训练的单一 Transformer 来处理多语言语料库上的 NLP 任务:XLM-R。尽管我们能够展示出,当只有少量标记示例可用于微调时,从德语到法语的跨语言转移是有竞争力的,但是如果目标语言与基础模型进行微调的语言显著不同,或者不是预训练期间使用的 100 种语言之一,通常不会出现良好的性能。像 MAD-X 这样的最新提议正是为这些低资源场景而设计的,而且由于 MAD-X 是建立在
Transformer 之上,您可以轻松地将本章中的代码适应它!⁶
到目前为止,我们已经研究了两个任务:序列分类和标记分类。这两者都属于自然语言理解的范畴,其中文本被合成为预测。在下一章中,我们将首次研究文本生成,其中模型的输入和输出都是文本。
¹ A. Conneau 等人,“Unsupervised Cross-Lingual Representation Learning at Scale”,(2019)。
² J. Hu 等人,“XTREME: A Massively Multilingual Multi-Task Benchmark for Evaluating Cross-Lingual Generalization”,(2020);X. Pan 等人,“跨语言姓名标记和链接 282 种语言”,计算语言学协会第 55 届年会论文集 1(2017 年 7 月):1946-1958,http://dx.doi.org/10.18653/v1/P17-1178。
³ Y. Liu 等人,“RoBERTa: A Robustly Optimized BERT Pretraining Approach”,(2019)。
⁴ T. Kudo 和 J. Richardson,“SentencePiece: A Simple and Language Independent Subword Tokenizer and Detokenizer for Neural Text Processing”,(2018)。
⁵ J. Devlin 等人,“BERT: Pre-Training of Deep Bidirectional Transformers for Language Understanding”,(2018)。
⁶ J. Pfeiffer 等人,“MAD-X: An Adapter-Based Framework for Multi-Task Cross-Lingual Transfer”,(2020)。