真实世界的自然语言处理(全)

真实世界的自然语言处理(全)

原文:zh.annas-archive.org/md5/0bc67f8f61131022ce5bcb512033ea38

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

前言

在过去二十年里,我一直在机器学习(ML)、自然语言处理(NLP)和教育的交叉领域工作,我一直热衷于教育和帮助人们学习新技术。这就是为什么当我听说有机会出版一本关于 NLP 的书时,我毫不犹豫地接受了。

过去几年,人工智能(AI)领域经历了许多变化,包括基于神经网络的方法的爆炸式普及和大规模预训练语言模型的出现。这一变化使得许多先进的语言技术成为可能,其中包括你每天都会与之交互的语音虚拟助手、语音识别和机器翻译等。然而,NLP 的“技术堆栈”,以预训练模型和迁移学习为特征,最近几年已经稳定下来,并预计将保持稳定,至少在未来几年内。这就是为什么我认为现在是开始学习 NLP 的好时机。

编写一本关于 AI 的书绝非易事。感觉就像你在追逐一个不会减速等待你的移动目标。当我开始写这本书时,Transformer 刚刚发布,BERT 还不存在。在写作过程中,我们在这本书中使用的主要 NLP 框架 AllenNLP 经历了两次重大更新。很少有人使用 Hugging Face Transformer,这是一款广受欢迎的深度 NLP 库,目前被全球许多实践者使用。在两年内,由于 Transformer 和预训练语言模型(如 BERT)的出现,NLP 领域的格局发生了彻底的变化。好消息是,现代机器学习的基础,包括单词和句子嵌入、RNN 和 CNN,尚未过时,并且仍然重要。本书旨在捕捉帮助您构建真实世界 NLP 应用程序的思想和概念的“核心”。

市场上有许多关于 ML 和深度学习的优秀书籍,但其中一些过分强调数学和理论。书籍教授的内容与行业需求存在差距。我希望这本书能填补这一差距。

致谢

没有许多人的帮助,这本书是不可能完成的。我必须首先感谢 Manning 出版社的开发编辑 Karen Miller。在编写这本书的过程中,感谢你的支持和耐心。我还要感谢 Manning 团队的其他成员:技术开发编辑 Mike Shepard、审稿编辑 Adriana Sabo、制作编辑 Deirdre Hiam、副本编辑 Pamela Hunt、校对员 Keri Hales 和技术校对员 Mayur Patil。Denny(www.designsonline.id/)还为本书创作了一些高质量的插图。

我还要感谢在阅读本书手稿后提供宝贵反馈的审稿人:Al Krinker、Alain Lompo、Anutosh Ghosh、Brian S. Cole、Cass Petrus、Charles Soetan、Dan Sheikh、Emmanuel Medina Lopez、Frédéric Flayol、George L. Gaines、James Black、Justin Coulston、Lin Chen、Linda Ristevski、Luis Moux、Marc-Anthony Taylor、Mike Rosencrantz、Nikos Kanakaris、Ninoslav Čerkez、Richard Vaughan、Robert Diana、Roger Meli、Salvatore Campagna、Shanker Janakiraman、Stuart Perks、Taylor Delehanty 和 Tom Heiman。

我要感谢 Allen Institute for Artificial Intelligence 的 AllenNLP 团队。我与该团队的 Matt Gardner、Mark Neumann 和 Michael Schmitz 进行了很好的讨论。我一直钦佩他们的优秀工作,使深度 NLP 技术易于访问并普及于世界。

最后,但同样重要的是,我要感谢我的出色妻子 Lynn。她不仅帮助我选择了本书的正确封面图像,而且在整本书的编写过程中一直理解和支持我的工作。

关于本书

实战自然语言处理不是一本典型的 NLP 教材。我们专注于构建实际的 NLP 应用程序。这里的实战有两层含义:首先,我们关注构建实际 NLP 应用程序所需的内容。作为读者,您将学习不仅如何训练 NLP 模型,还将学习如何设计、开发、部署和监控它们。在这一过程中,您还将了解到现代 NLP 模型的基本构建模块,以及对构建 NLP 应用程序有用的 NLP 领域的最新发展。其次,与大多数入门书籍不同,我们采用自顶向下的教学方法。我们不是采用自下而上的方法,一页页地展示神经网络理论和数学公式,而是专注于快速构建“只管用”的 NLP 应用程序。然后,我们深入研究构成 NLP 应用程序的各个概念和模型。您还将学习如何使用这些基本构建模块构建符合您需求的端到端定制 NLP 应用程序。

谁应该阅读本书

本书主要面向希望学习 NLP 基础知识以及如何构建 NLP 应用程序的软件工程师和程序员。我们假设您,读者,在 Python 中具有基本的编程和软件工程技能。如果您已经从事机器学习工作,但希望转入 NLP 领域,本书也会很有用。无论哪种情况,您都不需要任何 ML 或 NLP 的先前知识。您不需要任何数学知识来阅读本书,尽管对线性代数的基本理解可能会有所帮助。本书中没有一个数学公式。

本书的组织方式:路线图

本书共分三部分,共包括 11 章。第一部分涵盖了自然语言处理(NLP)的基础知识,在这里我们学习如何使用 AllenNLP 快速构建 NLP 应用程序,包括情感分析和序列标注等基本任务。

  • 第一章从介绍自然语言处理的“什么”和“为什么”开始——什么是自然语言处理,什么不是自然语言处理,自然语言处理技术如何被使用,以及自然语言处理与其他人工智能领域的关系。

  • 第二章演示了如何构建您的第一个自然语言处理应用程序,即情感分析器,并介绍了现代自然语言处理模型的基础——词嵌入和循环神经网络(RNNs)。

  • 第三章介绍了自然语言处理应用程序的两个重要构建块,即词嵌入和句子嵌入,并演示了如何使用和训练它们。

  • 第四章讨论了最简单但最重要的自然语言处理任务之一,即句子分类,以及如何使用循环神经网络(RNNs)来完成此任务。

  • 第五章涵盖了诸如词性标注和命名实体提取之类的序列标注任务。它还涉及到一种相关技术,即语言建模。

第二部分涵盖了包括序列到序列模型、Transformer 以及如何利用迁移学习和预训练语言模型来构建强大的自然语言处理应用在内的高级自然语言处理主题。

  • 第六章介绍了序列到序列模型,它将一个序列转换为另一个序列。我们在一个小时内构建了一个简单的机器翻译系统和一个聊天机器人。

  • 第七章讨论了另一种流行的神经网络架构,卷积神经网络(CNNs)。

  • 第八章深入探讨了 Transformer,这是当今最重要的自然语言处理模型之一。我们将演示如何使用 Transformer 构建一个改进的机器翻译系统和一个拼写检查器。

  • 第九章在上一章的基础上展开,并讨论了迁移学习,这是现代自然语言处理中的一种流行技术,使用预训练的语言模型如 BERT。

第三部分涵盖了在开发对真实世界数据具有鲁棒性、并进行部署和提供的自然语言处理应用程序时变得相关的主题。

  • 第十章详细介绍了开发自然语言处理应用程序时的最佳实践,包括批处理和填充、正则化以及超参数优化。

  • 第十一章通过讨论如何部署和提供自然语言处理模型来结束本书。它还涵盖了如何解释和解释机器学习模型。

关于代码

本书包含许多源代码示例,既有编号列表,也有与普通文本一样的行内代码。在这两种情况下,源代码都以像这样的固定宽度字体格式化,以使其与普通文本分开。有时代码也会加粗,以突出显示与章节中的先前步骤有所不同的代码,例如当一个新功能添加到现有代码行时。

在许多情况下,原始源代码已经被重新格式化;我们已经添加了换行符并重新安排了缩进以适应书中可用的页面空间。在罕见情况下,即使这样做还不够,列表中也包括了行继续标记(➥)。此外,当文本中描述代码时,源代码中的注释通常会被从列表中移除。代码注释伴随着许多列表,突出显示重要概念。

本书示例中的代码可从 Manning 网站(www.manning.com/books/real-world-natural-language-processing)和 GitHub(github.com/mhagiwara/realworldnlp)下载。

大部分代码也可以在 Google Colab 上运行,这是一个免费的基于网络的平台,您可以在其中运行您的机器学习代码,包括 GPU 硬件加速器。

liveBook 讨论论坛

购买《实用自然语言处理》包含免费访问 Manning Publications 运行的私人网络论坛,您可以在该论坛上对书籍发表评论,提出技术问题,并从作者和其他用户那里获得帮助。要访问论坛,请转到 livebook.manning.com/book/real-world-natural -language-processing/discussion。您还可以在 livebook.manning.com/#!/discussion 上了解更多关于 Manning 论坛和行为规则的信息。

Manning 对我们的读者的承诺是提供一个场所,让个体读者和读者与作者之间进行有意义的对话。这不是对作者参与的任何具体数量的承诺,作者对论坛的贡献仍然是自愿的(且无偿的)。我们建议您尝试向作者提出一些具有挑战性的问题,以免他的兴趣减退!只要本书在印刷状态下,论坛和之前讨论的存档将可以从出版商的网站上访问到。

其他在线资源

我们在本书中大量使用的两个自然语言处理框架,AllenNLP 和 Hugging Face Transformers,都有很棒的在线课程(guide.allennlp.org/huggingface.co/course),您可以在这些课程中学习自然语言处理的基础知识以及如何使用这些库来解决各种自然语言处理任务。

关于作者

Hagiwara 萩原真人于 2009 年从名古屋大学获得计算机科学博士学位,专注于自然语言处理和机器学习。他曾在谷歌和微软研究院实习,并在百度、乐天技术研究所和 Duolingo 工作过,担任工程师和研究员。他现在经营自己的研究和咨询公司 Octanove Labs,专注于自然语言处理在教育应用中的应用。

关于封面插图

封面上的图案Real-World Natural Language Processing的标题是“Bulgare”,或者来自保加利亚的人。 这幅插图摘自雅克·格拉塞·德·圣索维尔(1757–1810)的各国服装收藏品,该收藏品名为Costumes de Différents Pays,于 1797 年在法国出版。 每幅插图都是精细绘制和手工上色的。 格拉塞·德·圣索维尔收藏的丰富多样性生动地提醒我们,仅 200 年前世界各地的城镇和地区在文化上是多么独立。 人们相互隔离,使用不同的方言和语言。 在街头或乡间,仅凭着他们的服装就可以轻易辨别他们住在哪里,以及他们的职业或生活地位。

自那时以来,我们的着装方式已经发生了变化,那时如此丰富的地区多样性已经消失。 现在很难区分不同大陆的居民,更不用说不同的城镇、地区或国家了。 或许我们已经用文化多样性换取了更丰富多彩的个人生活——当然也换来了更丰富多样和快节奏的技术生活。

在很难辨认出一本计算机书籍与另一本书籍之时,曼宁通过基于两个世纪前区域生活丰富多样性的书籍封面,庆祝计算机业的创造力和主动性,这些生活被格拉塞·德·圣索维尔的图片重新唤起。

第一部分:基础知识

欢迎来到美丽而充满活力的自然语言处理(NLP)世界!NLP 是人工智能(AI)的一个子领域,涉及计算方法来处理、理解和生成人类语言。NLP 被用于您日常生活中与之交互的许多技术中——垃圾邮件过滤、会话助手、搜索引擎和机器翻译。本书的第一部分旨在向您介绍该领域,并使您了解如何构建实用的 NLP 应用程序。

在第一章中,我们将介绍 NLP 的“什么”和“为什么”——什么是 NLP,什么不是 NLP,NLP 技术如何使用,以及它与其他 AI 领域的关系。

在第二章中,您将在一小时内使用强大的 NLP 框架 AllenNLP 的帮助下构建一个完整的工作 NLP 应用程序——情感分析器。您还将学习使用基本的机器学习(ML)概念,包括单词嵌入循环神经网络(RNNs)。如果这听起来令人生畏,不用担心——我们将逐渐向您介绍这些概念,并提供直观的解释。

第三章深入探讨了深度学习方法到 NLP 中最重要的概念之一——单词和句子嵌入。该章节演示了如何使用甚至训练它们使用您自己的数据。

第四章和第五章涵盖了基本的 NLP 任务,句子分类和序列标注。虽然简单,但这些任务具有广泛的应用,包括情感分析、词性标注和命名实体识别。

这部分将使您熟悉现代自然语言处理(NLP)的一些基本概念,并且我们将在此过程中构建有用的 NLP 应用程序。

第一章:自然语言处理入门

本章内容包括

  • 自然语言处理(NLP)是什么,它不是什么,为什么它是一个有趣而具有挑战性的领域

  • 自然语言处理与其他领域的关系,包括人工智能(AI)和机器学习(ML)

  • 典型的自然语言处理应用程序和任务是什么

  • 典型自然语言处理应用程序的开发和结构

这不是一本机器学习或深度学习的入门书籍。你不会学到如何以数学术语编写神经网络,或者如何计算梯度,等等。但是不用担心,即使你对这些概念一无所知,我会在需要的时候进行解释,不会使用数学术语,而是从概念上解释。事实上,这本书不包含任何数学公式,一个都没有。此外,多亏了现代深度学习库,你真的不需要理解数学就能构建实用的自然语言处理应用程序。如果你有兴趣学习机器学习和深度学习背后的理论和数学,可以找到很多优秀的资源。

但你至少需要对 Python 编程感到自在,并了解它的生态系统。然而,你不需要成为软件工程领域的专家。事实上,本书的目的是介绍开发自然语言处理应用程序的软件工程最佳实践。你也不需要事先了解自然语言处理。再次强调,本书旨在对这个领域进行初步

运行本书中代码示例需要 Python 版本 3.6.1 或更高版本以及 AllenNLP 版本 2.5.0 或更高版本。请注意,我们不支持 Python 2,主要是因为这本书中我要大量使用的深度自然语言处理框架 AllenNLP(allennlp.org/)只支持 Python 3。如果还没有升级到 Python 3,我强烈建议你进行升级,并熟悉最新的语言特性,如类型提示和新的字符串格式化语法。即使你正在开发非自然语言处理的应用程序,这也会很有帮助。

如果你还没有准备好 Python 开发环境,不要担心。本书中的大多数示例可以通过 Google Colab 平台(colab.research.google.com)运行。你只需要一个网页浏览器就可以构建和实验自然语言处理模型!

本书将使用 PyTorch(pytorch.org/)作为主要的深度学习框架。这对我来说是一个难以选择的决定,因为有几个深度学习框架同样适合构建自然语言处理应用程序,包括 TensorFlow、Keras 和 Chainer。有几个因素使得 PyTorch 在这些框架中脱颖而出——它是一个灵活且动态的框架,使得原型设计和调试自然语言处理模型更容易;它在研究界越来越受欢迎,所以很容易找到一些主要模型的开源实现;而之前提到的深度自然语言处理框架 AllenNLP 是建立在 PyTorch 之上的。

1.1 什么是自然语言处理(NLP)?

NLP 是一种处理人类语言的原则性方法。从形式上来说,它是人工智能(AI)的一个子领域,指的是处理、理解和生成人类语言的计算方法。之所以将其归为人工智能,是因为语言处理被认为是人类智能的一个重要组成部分。使用语言可以说是区分人类与其他动物的最显著的技能之一。

1.1.1 什么是自然语言处理(NLP)?

NLP 包括一系列算法、任务和问题,它们以人类生成的文本作为输入,并生成一些有用的信息,如标签、语义表示等,作为输出。其他任务,如翻译、摘要和文本生成,直接产生文本作为输出。无论哪种情况,重点是产生一些有用的输出本身(例如翻译),或作为其他下游任务的输入(例如解析)。我将在第 1.3 节介绍一些流行的 NLP 应用和任务。

你可能会想知道为什么自然语言处理中明确地带有“自然”一词。一个语言被称为自然语言意味着什么?是否存在自然语言?英语是自然语言吗?哪种更自然:西班牙语还是法语?

这里的“自然”一词用于将自然语言与形式语言进行对比。从这个意义上说,人类所说的所有语言都是自然语言。许多专家认为语言在数万年前自然形成,并自那时起有机地发展壮大。另一方面,形式语言是人类发明的一种语言类型,其语法(即什么是语法正确的)和语义(即它的含义)严格而明确地定义。

C 和 Python 等编程语言是形式语言的好例子。这些语言被定义得非常严格,以至于始终清楚什么是语法正确的,什么是语法错误的。当你在这些语言中编写代码并运行编译器或解释器时,要么会得到语法错误,要么不会。编译器不会说:“嗯,这段代码可能有 50%是语法正确的。”此外,如果在相同的代码上运行程序,假设外部因素如随机种子和系统状态保持不变,你的程序的行为总是相同的。你的解释器不会有 50%的时间显示一种结果,另外 50%的时间显示另一种结果。

这在人类语言中并非如此。你可以写出一句可能是语法正确的句子。例如,你认为短语“我跟谁说话了”是语法错误的吗?在某些语法主题上,即使是专家之间也会存在意见分歧。这就是人类语言有趣但具有挑战性的地方,也是整个自然语言处理领域存在的原因。人类语言是有歧义的,这意味着它们的解释通常不是唯一的。在人类语言中,结构(句子如何构成)和语义(句子的含义)都可能存在歧义。举个例子,让我们仔细看一下下一句:

他用望远镜看到一个女孩。

当你读到这句话时,你认为谁有望远镜呢?是男孩,他用望远镜看着一个女孩(从远处),还是女孩,她有望远镜被男孩看见了?这句话似乎至少有两种解释,如图 1.1 所示。

CH01_F01_Hagiwara

图 1.1 “他用望远镜看到一个女孩”两种解释。

当你读到这句话时感到困惑的原因是因为你不知道短语“用望远镜”是关于什么的。更具体地说,你不知道这个介词短语(PP)修改的是什么。这被称为PP-attachment问题,是语法歧义的经典例子。一个具有语法歧义的句子有多种解释句子结构的方式。你可以根据你相信的句子结构来对句子进行多种解释。

在自然语言中可能出现的另一种歧义类型是语义歧义。当一个词或句子的含义,而不是它的结构,是模糊的时候,就是语义歧义。例如,让我们看看以下句子:

我看到了一只蝙蝠。

这句话的结构毫无疑问。句子的主语是“我”,宾语是“一个球棒”,由动词“看到”连接。换句话说,它没有语法上的歧义。但它的含义呢?“看到”至少有两种意思。一种是动词“看”的过去式。另一种是用锯子切割某个物体。同样,“一个球棒”可能意味着两种非常不同的东西:是夜间飞行的哺乳动物还是用来打击球的木头?总的来说,这句话是说我观察到了一只夜间飞行的哺乳动物,还是我切割了一个棒球或板球棒?或者(残酷地)我用锯子切割了一只夜间动物?你永远不知道,至少单从这句话来看。

歧义是使自然语言丰富但也难以处理的原因。我们不能简单地在一段文字上运行编译器或解释器,然后“搞定”。我们需要面对人类语言的复杂性和微妙性。我们需要一种科学的、原则性的方法来处理它们。这就是自然语言处理的全部意义所在。

欢迎来到自然语言的美丽世界。

1.1.2 什么不是自然语言处理?

现在让我们考虑以下情景,并思考你将如何解决这个问题:你正在一家中型公司担任初级开发人员,该公司拥有面向消费者的产品线。现在是星期五下午 3 点。随着周末的临近,团队的其他成员变得越来越不安。就在这时,你的老板来到了你的小隔间。

“嘿,有一分钟吗?我有一些有趣的东西要给你看。我刚刚发给你。”

你的老板刚刚给你发了一封带有一个巨大压缩文件的电子邮件。

“好的,所以这是一个巨大的 TSV 文件。它包含了关于我们产品的调查问题的所有回答。我刚刚从市场团队那里得到了这些数据。”

显然,市场团队一直通过一系列在线调查问题收集用户对其中一个产品的意见。

“调查问题包括标准问题,比如‘你是怎么知道我们的产品的?’和‘你喜欢我们的产品吗?’还有一个自由回答的问题,我们的客户可以写下他们对我们产品的感受。问题是,市场团队意识到在线系统中存在一个错误,第二个问题的答案根本没有被记录在数据库中。”

“等等,那么我们没办法知道客户对我们的产品有什么感觉了?”这听起来怪怪的。这一定是一个复制粘贴错误。当你第一次创建在线数据收集界面时,你复制粘贴了后端代码,而没有修改 ID 参数,导致一些数据字段丢失。

“所以,”你的老板继续说道。“我在想我们是否能够以某种方式恢复丢失的数据。市场团队现在有点绝望,因为他们需要在下周初向副总裁报告结果。”

在这一点上,你的不好的感觉已经得到了确认。除非你想出一个尽快完成的方法,否则你的周末计划将被毁掉。

“你不是说你对一些机器学习感兴趣吗?我觉得这对你来说是一个完美的项目。无论如何,如果你能试试并告诉我你的发现,那就太好了。你觉得周一之前能有一些结果吗?”

“好吧,我试试看。”

你知道在这里“不行”是不可接受的回答。满意了你的回答,你的老板微笑着离开了。

你开始浏览 TSV 文件。让你松了一口气的是,它的结构相当标准——它有几个字段,比如时间戳和提交 ID。每行的末尾是一个用于自由回答问题的冗长字段。这就是它们,你想。至少你知道可以在哪里找到一些线索。

快速浏览字段后,你发现了诸如“一个非常好的产品!”和“很糟糕。它总是崩溃!”等响应。不算太糟糕,你想。至少你能捕捉到这些简单的情况。你开始编写以下方法来捕捉这两种情况:

def get_sentiment(text):
    """Return 1 if text is positive, -1 if negative.
       Otherwise, return 0."""
    if 'good' in text:
        return 1
    elif 'bad' in text:
        return -1
    return 0

然后你对文件中的响应运行这个方法,并记录结果,以及原始输入。按照预期,这个方法似乎能够捕捉到包含“好”或“坏”几个响应。

但是接下来你开始看到一些令人担忧的东西,如下所示:

“我想不出一个使用这个产品的好理由。”:正面

“还行。”:负面

糟糕,你想。否定。是的,当然。但这个很容易处理。你修改了方法如下:

def get_sentiment(text):
    """Return 1 if text is positive, -1 if negative.
       Otherwise, return 0."""
    sentiment = 0
    if 'good' in text:
        sentiment = 1
    elif 'bad' in text:
        sentiment = -1
    if 'not' in text or "n't" in text:
        sentiment *= -1
    return sentiment

你再次运行脚本。这一次,它似乎按预期运行,直到你看到了一个更复杂的例子:

“这个产品不仅便宜,而且质量也非常好!”:负面

“嗯,你想。这可能并不像我最初想的那么简单。也许否定词必须在‘好’或‘坏’附近才能有效果。想知道接下来可以采取什么步骤,你向下滚动以查看更多示例,这时你看到了这样的回答:

“我一直很想要这个功能!”: negative

“它做得很糟糕。”: negative

你默默地咒骂自己。一个语言中的一个单词怎么会有完全相反的两个意思?此时,你对周末愉快的小希望已经消失了。你已经在想下周一对老板使用什么借口了。

作为本书的读者,你会更清楚。你会知道 NLP 不是简单地在自然语言文本中加入一堆 if 和 then。这是一个更有原则性的处理自然语言的方法。在接下来的章节中,你将学习在编写一行代码之前应该如何处理这个问题,以及如何为手头的任务构建一个定制的 NLP 应用程序。

1.1.3 AI、ML、DL 和 NLP

在深入研究 NLP 的细节之前,澄清它与其他类似领域的关系是有用的。你们大多数人至少听说过人工智能(AI)和机器学习(ML)。你们可能也听说过深度学习(DL),因为它在当今流行媒体中引起了很多关注。图 1.2 显示了这些不同领域之间的重叠关系。

CH01_F02_Hagiwara

图 1.2 不同领域之间的关系:AI、ML、DL 和 NLP

人工智能(AI)是一个广泛的领域,致力于利用机器实现类似人类的智能。它涵盖了一系列子领域,包括机器学习、自然语言处理、计算机视觉和语音识别。该领域还包括推理、规划和搜索等子领域,这些子领域既不属于机器学习也不属于自然语言处理,也不在本书的范围内。

机器学习(ML)通常被认为是人工智能的一个子领域,它通过经验和数据改进计算机算法。这包括学习一个基于过去经验将输入映射到输出的一般函数(监督学习)、从数据中提取隐藏的模式和结构(无监督学习),以及根据间接奖励学习如何在动态环境中行动(强化学习)。在本书中,我们将大量使用监督机器学习,这是训练 NLP 模型的主要范式。

深度学习(DL)是机器学习的一个子领域,通常使用深度神经网络。这些神经网络模型之所以称为“深度”,是因为它们由许多层组成。只是神经网络的一个子结构的花哨说法。通过具有许多堆叠层,深度神经网络可以学习数据的复杂表示,并可以捕捉输入和输出之间的高度复杂的关系。

随着可用数据量和计算资源的增加,现代 NLP 越来越多地使用机器学习和深度学习。现代 NLP 的应用和任务通常建立在机器学习管道之上,并从数据中进行训练。但请注意,在图 1.2 中,NLP 的一部分与机器学习不重叠。诸如计数单词和衡量文本相似性之类的传统方法通常不被视为机器学习技术本身,尽管它们可以是 ML 模型的重要构建块。

我还想提一下与自然语言处理(NLP)相关的其他领域。其中一个领域是计算语言学(CL)。顾名思义,计算语言学是语言学的一个子领域,它使用计算方法来研究人类语言。CL 和 NLP 的主要区别在于前者涵盖了研究语言的科学方法,而后者关注的是使计算机执行与语言相关的有用任务的工程方法。人们经常将这些术语互换使用,部分原因是由于历史原因。例如,该领域中最负盛名的会议被称为 ACL,实际上代表着“计算语言学协会!”

另一个相关领域是文本挖掘。文本挖掘是一种针对文本数据的数据挖掘类型。它的重点是从非结构化的文本数据中获取有用的见解,这种文本数据不易被计算机解释。这些数据通常来自各种来源,如网络爬虫和社交媒体。虽然其目的与 NLP 略有不同,但这两个领域相似,我们可以为两者使用相同的工具和算法。

为什么选择 NLP?

如果你正在阅读这篇文章,你至少对 NLP 有一些兴趣。为什么 NLP 令人兴奋?为什么值得更深入了解 NLP,尤其是现实中的 NLP?

第一个原因是 NLP 正在蓬勃发展。即便没有最近的人工智能和机器学习热潮,NLP 比以往任何时候都更为重要。我们正在见证实用 NLP 应用在我们日常生活中的出现,比如对话代理(想想苹果的 Siri、亚马逊的 Alexa 和谷歌的 Assistant)以及接近人类水平的机器翻译(想想谷歌翻译)。许多 NLP 应用已经成为我们日常活动的一部分,比如垃圾邮件过滤、搜索引擎和拼写纠正,我们稍后还会讨论。斯坦福大学选修 NLP 课程的学生人数从 2008 年到 2018 年增加了五倍(realworldnlpbook.com/ch1.html#tweet1)。同样,参加 EMNLP(实证自然语言处理方法)这一顶级 NLP 会议的人数在短短一年内翻了一番(realworldnlpbook.com/ch1.html#tweet2)。其他主要的 NLP 会议也经历了类似的参与者和论文提交量的增加(realworldnlpbook.com/ch1.html#nivre17)。

第二个原因是自然语言处理(NLP)是一个不断发展的领域。自然语言处理本身有着悠久的历史。最初的尝试建立机器翻译系统的实验,名为乔治城-IBM 实验,始于 1954 年。自那次实验起 30 多年来,大多数 NLP 系统都依赖于手写规则。是的,这与你在 1.1.1 节中看到的没有太大不同。第一个里程碑出现在 1980 年代末,是使用统计方法和机器学习进行 NLP。许多 NLP 系统开始利用从数据训练的统计模型。这导致了 NLP 近期的一些成功,其中最著名的包括 IBM Watson。第二个里程碑变化更为剧烈。从 2000 年代末开始,所谓的深度学习,即深度神经网络模型,迅速席卷了这个领域。到 2010 年代中期,深度神经网络模型成为了该领域的新标准。

这第二个里程碑变化如此巨大和迅速,以至于值得在这里注意。基于新的神经网络的自然语言处理模型不仅更有效,而且更简单。例如,以前复制甚至是一个简单的基准机器翻译模型都需要很多专业知识和努力。一个最流行的用于统计机器翻译的开源软件包,称为 Moses(www.statmt.org/moses/),是一个庞然大物,包含数十万行代码和数十个支持模块和工具。专家们花了几个小时的时间来安装软件并使其正常工作。另一方面,截至 2018 年,只要有一些先前的编程经验,任何人都可以运行一个比传统的统计模型更强大的神经机器翻译系统,代码量只有几千行以下(例如,请参阅 TensorFlow 的神经机器翻译教程github.com/tensorflow/nmt)。此外,新的神经网络模型是“端到端”训练的,这意味着那些庞大的、整体的网络接收输入并直接产生输出。整个模型都是为了匹配所需的输出而进行训练的。另一方面,传统的机器学习模型由(至少)几个子模块组成。这些子模块是使用不同的机器学习算法分别训练的。在本书中,我将主要讨论基于现代神经网络的自然语言处理方法,但也会涉及一些传统概念。

第三个也是最后一个原因是自然语言处理是具有挑战性的。理解和产生语言是人工智能的核心问题,正如我们在前一节中看到的。在过去的十年左右,主要的自然语言处理任务,如语音识别和机器翻译的准确性和性能都得到了显著提高。但是,人类水平的语言理解距离解决尚远。

要快速验证这一点,打开你最喜欢的机器翻译服务(或简单地使用谷歌翻译),然后输入这句话:“I saw her duck.” 尝试将其翻译成西班牙语或其他你理解的语言。你应该看到像“pato”这样的词,在西班牙语中意思是“一只鸭子”。但是你是否注意到了这句话的另一种解释?请参见图 1.3,其中包含了两种解释。这里的“duck”可能是一个动词,意思是“蹲下”。尝试在此之后添加另一句话,例如“她试图避开一只飞来的球。” 机器翻译是否以任何方式改变了第一种翻译?答案很可能是否定的。你仍然应该在翻译中看到同样的“pato”。正如你所看到的,截至目前为止,大多数(如果不是全部)商业机器翻译系统都无法理解除正在翻译的句子之外的上下文。学术界在解决这个问题上花费了大量研究力量,但这仍然是自然语言处理中被认为尚未解决的问题之一。

CH01_F03_Hagiwara

图 1.3 “我看见她的鸭子”的两种解释。

与机器人技术和计算机视觉等其他人工智能领域相比,语言有其自己的特点。与图像不同,话语和句子的长度是可变的。你可以说一个非常短的句子(“你好。”)或一个非常长的句子(“一个快速的棕色狐狸……”)。大多数机器学习算法不擅长处理可变长度的东西,你需要想办法用更固定的东西来表示语言。如果你回顾一下这个领域的历史,你会发现自然语言处理主要关注的问题是如何数学上表示语言。向量空间模型和词嵌入(在第三章中讨论)就是一些例子。

语言的另一个特点是它是离散的。这意味着语言中的事物作为概念是分离的。例如,如果你拿一个词“rat”并将它的第一个字母改为下一个,你会得到“sat”。在计算机内存中,它们之间的差异仅仅是一个比特。然而,除了它们都以“at”结尾以外,这两个单词之间没有关系,也许老鼠可以坐着。不存在介于“rat”和“sat”之间的东西。这两者是完全离散的,独立的概念,只是拼写相似。另一方面,如果你拿一张汽车的图像并将一个像素的值改变一个比特,你仍然得到一辆几乎与改变前相同的汽车。也许颜色略有不同。换句话说,图像和声音是连续的,这意味着你可以做出小的修改而不会对它们的本质产生太大影响。许多数学工具包,如向量、矩阵和函数,都擅长处理连续性的事物。自然语言处理的历史实际上是挑战语言的这种离散性的历史,而且直到最近我们才开始在这个方面取得一些成功,例如,使用词嵌入。

1.2 自然语言处理的应用

正如我之前提到的,自然语言处理已经成为我们日常生活的一个组成部分。在现代生活中,我们日常通信的越来越大的部分是在线完成的,而我们的在线通信仍然主要是自然语言文本。想想你最喜欢的社交网络服务,如 Facebook 和 Twitter。虽然你可以发布照片和视频,但是很大一部分的通信仍然是文本。只要你在处理文本,就需要自然语言处理。例如,你怎么知道某个帖子是垃圾邮件?你怎么知道哪些帖子是你最可能“喜欢”的?你怎么知道哪些广告是你最可能点击的?

因为许多大型互联网公司需要以某种方式处理文本,所以很有可能他们中的许多人已经在使用 NLP。您还可以从他们的“招聘”页面确认这一点-您会看到他们一直在招聘 NLP 工程师和数据科学家。NLP 在许多其他行业和产品中也以不同程度使用,包括但不限于客户服务,电子商务,教育,娱乐,金融和医疗保健,这些都以某种方式涉及文本。

许多自然语言处理(NLP)系统和服务可以分类或通过结合一些主要类型的 NLP 应用和任务来构建。在本节中,我将介绍一些最受欢迎的 NLP 应用以及常见的 NLP 任务。

1.2.1 NLP 应用

NLP 应用是一种主要目的是处理自然语言文本并从中提取一些有用信息的软件应用。与一般软件应用类似,它可以以各种方式实现,例如离线数据处理脚本,离线独立应用程序,后端服务或具有前端的全栈服务,具体取决于其范围和用例。它可以为最终用户直接使用,供其他后端服务使用其输出,或供其他企业用作 SaaS(软件即服务)使用。

如果您的需求是通用的,并且不需要高度定制,则可以直接使用许多 NLP 应用,例如机器翻译软件和主要 SaaS 产品(例如,Google Cloud API)。如果您需要定制化和/或需要处理特定目标领域,则还可以构建自己的 NLP 应用。这正是您将在本书中学到的内容!

机器翻译

机器翻译可能是最受欢迎且易于理解的 NLP 应用之一。机器翻译(MT)系统将给定的文本从一种语言翻译为另一种语言。MT 系统可以作为全栈服务(例如,Google 翻译)实现,也可以作为纯后端服务(例如,NLP SaaS 产品)实现。输入文本所使用的语言称为源语言,而输出文本所使用的语言称为目标语言。MT 涵盖了一系列 NLP 问题,包括语言理解和生成,因为 MT 系统需要理解输入然后生成输出。MT 是 NLP 中研究最深入的领域之一,也是最早的 NLP 应用之一。

机器翻译中的一个挑战是 流畅度充分性 之间的权衡。翻译必须流畅,意思是输出必须在目标语言中听起来自然。翻译还必须充分,意思是输出必须尽可能地反映输入表达的意思。这两者经常发生冲突,特别是当源语言和目标语言不是很相似时(例如,英语和汉语)。你可以写出一句精确、逐字的翻译,但这样做通常会导致输出在目标语言中听起来不自然。另一方面,你可以编造一些听起来自然但可能不反映准确含义的东西。优秀的人类翻译者以一种创造性的方式解决了这种权衡。他们的工作是提出在目标语言中自然的翻译,同时反映原文的含义。

语法和拼写错误校正

当今大多数主要的网络浏览器都支持拼写纠正。即使你忘记了如何拼写“密西西比”,你也可以尽力输入你记得的内容,浏览器会用修正来突出显示它。一些文字处理软件应用程序,包括最近版本的微软 Word,不仅仅纠正拼写。它们还指出语法错误,比如使用“it's”而不是“its”。这并不是一件容易的事情,因为从某种意义上说,这两个单词都是“正确的”(拼写上没有错误),系统需要从上下文中推断它们是否被正确使用。一些商业产品(尤其是 Grammarly,www.grammarly.com/)专门用于语法错误校正。一些产品走得更远,指出了错误的标点使用甚至写作风格。这些产品在母语和非母语使用者中都很受欢迎。

由于非母语使用者的数量增加,语法错误校正的研究变得活跃起来。传统上,针对非母语使用者的语法错误校正系统是一次处理一个错误类型的。例如,你可以想象一个子系统,它只检测和纠正非母语使用者中非常常见的冠词使用错误(a, an, the 等)。最近的语法错误校正方法与机器翻译的方法类似。你可以将(可能是错误的)输入看作是一种语言,将校正后的输出看作是另一种语言。然后你的任务就是在这两种语言之间“翻译”!

搜索引擎

NLP 的另一个已经成为我们日常生活中不可或缺部分的应用是搜索引擎。很少有人会将搜索引擎视为 NLP 应用,但 NLP 在使搜索引擎变得有用方面起着如此重要的作用,以至于在这里提到它们是值得的。

页面分析是自然语言处理在搜索引擎中广泛应用的领域之一。您是否想知道为什么在搜索“dogs”时,您不会看到任何“hot dog”页面?如果您有使用开源软件如 Solr 和 Elasticsearch 构建自己的全文搜索引擎的经验,并且仅使用了基于单词的索引,那么您的搜索结果页面将会充满“hot dogs”,即使您只想搜索“dogs”。主要商业搜索引擎通过运行正在被索引的页面内容经过 NLP 流水线处理来解决这个问题,该流水线能够识别“hot dogs”不是一种“dogs”。但是,关于页面分析所涉及的 NLP 流水线的程度和类型是搜索引擎的机密信息,很难知道。

查询分析是搜索引擎中另一个 NLP 应用。如果您注意到,当您搜索某位名人时,Google 会显示一个包含照片和个人简介的框,或者当您搜索某些时事时,会显示一个包含最新新闻故事的框,那就是查询分析在起作用。查询分析能够识别查询的意图(用户想要什么),并相应地显示相关信息。一种常用的实现查询分析的方法是将其视为分类问题,其中一个 NLP 流水线将查询分类为意图类别(如名人、新闻、天气、视频),尽管商业搜索引擎运行查询分析的细节通常是高度机密的。

最后,搜索引擎并不仅仅是关于分析页面和分类查询。它们还有很多其他功能,为您的搜索提供更便利的功能之一就是查询纠正。当您在查询时拼写错误或语法错误时,Google 和其他主要的搜索引擎会显示带有标签“显示结果:“和“您是指:”的纠正。这是与我之前提到的语法错误纠正有些类似,只是它针对搜索引擎用户使用的错误和查询进行了优化。

对话系统

对话系统是人类可以与之对话的机器。对话系统的领域有着悠久的历史。最早的对话系统之一是在 1966 年开发的 ELIZA。

但是直到最近,对话系统才逐渐进入我们的日常生活。近年来,由消费者面向的“会话式 AI”产品(如亚马逊 Alexa 和谷歌助手)的普及推动了对话系统的 popularity 的近似指数增长。事实上,根据 2018 年的一项调查,美国家庭中已经有 20%拥有智能音箱。您可能还记得在 2018 年的 Google IO 主题演讲中,谷歌的会话式 AI——谷歌 Duplex 展示了向发型沙龙和餐厅打电话,并与业务人员进行自然对话,并代表用户预约的情景,令人惊叹不已。

两种主要类型的对话系统是面向任务和聊天机器人。面向任务的对话系统用于实现特定目标(例如,预订机票)、获取一些信息,并且,正如我们所见,预订餐馆。面向任务的对话系统通常被构建为一个包含几个组件的自然语言处理管道,包括语音识别、语言理解、对话管理、响应生成和语音合成,这些组件通常是分开训练的。类似于机器翻译,然而,也有新的深度学习方法,其中对话系统(或其子系统)是端到端训练的。

另一种对话系统是聊天机器人,其主要目的是与人类进行交谈。传统的聊天机器人通常由一组手写规则管理(例如,当人类说这个时,说那个)。最近,深度神经网络的使用变得越来越流行,特别是序列到序列模型和强化学习。然而,由于聊天机器人不提供特定目的,评估聊天机器人,即评估特定聊天机器人的好坏,仍然是一个未决问题。

1.2.2 NLP 任务

幕后,许多自然语言处理应用是通过组合多个解决不同自然语言处理问题的自然语言处理组件构建的。在本节中,我介绍了一些在自然语言处理应用中常用的显著的自然语言处理任务。

文本分类

文本分类是将文本片段分类到不同类别的过程。这个自然语言处理任务是最简单但也是最广泛使用的之一。你可能之前没听说过“文本分类”这个术语,但我打赌你们大多数人每天都从这个自然语言处理任务中受益。例如,垃圾邮件过滤就是一种文本分类。它将电子邮件(或其他类型的文本,如网页)分类为两类——垃圾邮件或非垃圾邮件。这就是为什么当你使用 Gmail 时你几乎不会收到垃圾邮件,当你使用 Google 时你几乎看不到垃圾(低质量)网页。

另一种文本分类称为情感分析,这是我们在第 1.1 节中看到的。情感分析用于自动识别文本中的主观信息,如意见、情绪和感情。

词性标注

词性(POS)是共享相似语法属性的单词类别。 例如,在英语中,名词描述了物体、动物、人和概念等许多事物的名称。 名词可以用作动词的主语、动词的宾语和介词的宾语。 相比之下,动词描述了动作、状态和事件。 其他英语词性包括形容词(green, furious)、副词(cheerfully, almost)、限定词(a, the, this, that)、介词(in, from, with)、连词(and, yet, because)等。 几乎所有的语言都有名词和动词,但其他词性在语言之间有所不同。 例如,许多语言,如匈牙利语、土耳其语和日语,使用 后置词 而不是介词,后置词放在单词后面,为其添加一些额外的含义。 一组自然语言处理研究人员提出了一组覆盖大多数语言中常见词性的标签,称为 通用词性标签集 (realworldnlpbook.com/ch1.html#universal-pos)。 这个标签集被广泛用于语言无关的任务。

词性标注是给句子中的每个单词打上相应词性标签的过程。 你们中的一些人可能在学校已经做过这个了。 举个例子,让我们来看句子“I saw a girl with a telescope.” 这个句子的词性标签如图 1.4 所示。

CH01_F04_Hagiwara

图 1.4 词性标注(POS)

这些标签来自宾树库词性标签集,它是训练和评估各种自然语言处理任务(如词性标注和解析)的最受欢迎的标准语料库。 传统上,词性标注是通过诸如隐马尔可夫模型(HMMs)和条件随机场(CRFs)之类的序列标记算法来解决的。 最近,循环神经网络(RNNs)已成为训练高准确性词性标注器的流行和实用选择。 词性标注的结果通常被用作其他下游自然语言处理任务(如机器翻译和解析)的输入。 我将在第五章中更详细地介绍词性标注。

解析

解析是分析句子结构的任务。 广义上说,解析主要有两种类型,成分解析依存解析,我们将在接下来详细讨论。

成分句法分析使用上下文无关文法来表示自然语言句子。(详见mng.bz/GO5q 有关上下文无关文法的简要介绍)。上下文无关文法是一种指定语言的较小构建块(例如,单词)如何组合成较大构建块(例如,短语和从句),最终形成句子的方法。换言之,它指定了最大单位(句子)如何被分解为短语和从句,一直到单词。语言单元之间的交互方式由一组产生式规则来指定:

S -> NP VP

NP -> DT NN | PRN | NP PP
VP -> VBD NP | VBD PN PP
PP -> IN NP

DT -> a
IN -> with
NN -> girl | telescope
PRN -> I
VBD -> saw

产生式规则描述了从左侧符号(例如,“S”)到右侧符号(例如,“NP VP”)的转换。第一条规则意味着句子是名词短语(NP)后跟动词短语(VP)。其中的一些符号(例如,DT,NN,VBD)可能看起来很熟悉——是的,它们是我们刚刚在词性标注部分看到的词性标记。事实上,你可以把词性标记看作行为类似的最小语法类别(因为它们就是!)。

现在解析器的工作就是找出如何从句子中的原始单词到达最终符号(在本例中是“S”)。你可以将这些规则看作是从右侧符号向左侧符号的转换规则,通过向后遍历箭头来做。例如,使用规则“DT  a”和“NN  girl”,你可以将“a girl”转换为“DT NN”。然后,如果你使用“NP  DT NN”,你可以将整个短语缩减为“NP”。如果你将这个过程以树状图的方式呈现出来,你会得到类似图 1.5 所示的结果。

CH01_F05_Hagiwara

图 1.5“a girl”子树

在解析过程中创建的树形结构称为解析树,或简称解析。图中的子树因为并不涵盖整个树(即,不显示从“S”到单词的全部内容)而被称为子树。使用我们之前讨论过的句子“I saw a girl with a telescope”,试着手动解析一下看看。如果你一直使用产生式规则分解句子,直到得到最终的“S”符号,你就可以得到图 1.6 所示的树形结构。

CH01_F06_Hagiwara

图 1.6“I saw a girl with a telescope.”的解析树

如果图 1.6 中的树形结构和你得到的不一样,不用担心。实际上,有另一个解析树是这个句子的有效解析,如图 1.7 所示。

CH01_F07_Hagiwara

图 1.7“I saw a girl with a telescope.”的另一个解析树

如果你仔细看这两棵树,你会注意到一个区别,即“PP”(介词短语)的位置或连接位置。 实际上,这两个分析树对应于我们在第 1.1 节中讨论的这个句子的两种不同解释。 第一棵树(图 1.6),其中 PP 连接动词“saw”,对应于男孩使用望远镜看女孩的解释。 在第二棵树(图 1.7)中,其中 PP 连接到名词“a girl”,男孩看到了拿着望远镜的女孩。 解析是揭示句子结构和语义的一个重要步骤,但在像这样的情况下,仅靠解析无法唯一确定句子的最可能解释。

另一种解析的类型被称为依存句法分析。 依存句法分析使用依存语法来描述句子的结构,不是以短语为单位,而是以词和它们之间的二元关系为单位。 例如,先前句子的依存句法分析结果如图 1.8 所示。

CH01_F08_Hagiwara

图 1.8 “我用望远镜看见了一个女孩”的依存解析。

请注意,每个关系都是有方向性的,并带有标签。 一个关系指明了一个词依赖于另一个词以及两者之间的关系类型。 例如,连接“a”到“girl”的关系标记为“det”,表示第一个词是第二个词的冠词。 如果你把最中心的词“saw”拉向上方,你会注意到这些词和关系形成了一棵树。 这样的树被称为依存树

依存语法的一个优点是它们对于某些词序变化是不可知的,这意味着句子中某些词的顺序不会改变依存树。 例如,在英语中,有些自由地将副词放在句子中的位置,特别是当副词描述由动词引起的动作的方式时。 例如,“我小心地涂了房子”和“我小心地涂了房子”都是可以接受的,并且意思相同。 如果用依存语法表示这些句子,那么词“carefully”总是修改动词“painted”,而且两个句子具有完全相同的依存树。 依存语法捕捉了句子的短语结构以上的东西-它们捕捉了关于词之间关系的更根本的东西。 因此,依存句法分析被认为是向自然语言语义分析迈出的重要一步。 一组研究人员正在开发一个称为 Universal Dependencies 的正式的语言无关依存语法,这个依存语法受语言启发,并且适用于许多语言,类似于通用 POS 标记集。

文本生成

文本生成,也称为自然语言生成(NLG),是从其他内容生成自然语言文本的过程。从更广泛的意义上讲,我们之前讨论过的机器翻译涉及到一个文本生成的问题,因为机器翻译系统需要在目标语言中生成文本。同样,摘要、文本简化和语法错误修正都会产生自然语言文本作为输出,并且都是文本生成任务的实例。因为所有这些任务都以自然语言文本作为输入,所以它们被称为文本到文本生成。

另一类文本生成任务称为数据到文本生成。对于这些任务,输入是非文本数据。例如,对话系统需要根据对话当前状态生成自然的表达。出版商可能希望根据事件(例如体育比赛结果和天气)生成新闻文本。还存在着对生成最能描述给定图像的自然语言文本的兴趣,称为图像字幕

最后,第三类文本分类是无条件文本生成,其中自然语言文本是从模型中随机生成的。您可以训练模型,使其能够生成随机的学术论文,Linux 源代码,甚至是诗歌和剧本。例如,Andrej Karpathy 训练了一个 RNN 模型,使用了莎士比亚的全部作品,并成功地生成了看起来完全像他的作品的文本片段(realworldnlpbook.com/ch1.html#karpathy15),如下所示:

PANDARUS:
Alas, I think he shall be come approached and the day
When little srain would be attain'd into being never fed,
And who is but a chain and subjects of his death,
I should not sleep.

Second Senator:
They are away this miseries, produced upon my soul,
Breaking and strongly should be buried, when I perish
The earth and thoughts of many states.

DUKE VINCENTIO:
Well, your wit is in the care of side and that.

Second Lord:
They would be ruled after this chamber, and
my fair nues begun out of the fact, to be conveyed,
Whose noble souls I'll have the heart of the wars.

Clown:
Come, sir, I will make did behold your worship.

VIOLA:
I'll drink it.

传统上,文本生成是通过手工制作的模板和规则来解决的,这些模板和规则用于从某些信息生成文本。您可以将其视为解析的反向过程,解析过程中使用规则来推断有关自然语言文本的信息,正如我们之前讨论的那样。近年来,神经网络模型越来越成为自然语言生成的流行选择,无论是文本到文本生成(序列到序列模型)、数据到文本生成(编码器-解码器模型)还是无条件文本生成(神经语言模型和生成对抗网络,或 GANs)。我们将在第五章更深入地讨论文本生成。

1.3 构建 NLP 应用程序

在本节中,我将向您展示 NLP 应用程序通常是如何开发和构建的。尽管具体细节可能会因案例而异,但了解典型的过程有助于您在开始开发应用程序之前进行规划和预算。如果您事先了解开发 NLP 应用程序的最佳实践,这也会有所帮助。

1.3.1 NLP 应用程序的开发

NLP 应用的开发是一个高度迭代的过程,包括许多研究、开发和运营阶段(见图 1.9)。大多数学习材料,如书籍和在线教程,主要关注训练阶段,尽管应用开发的所有其他阶段对于实际 NLP 应用同样重要。在本节中,我简要介绍了每个阶段涉及的内容。请注意,这些阶段之间没有明确的界限。应用开发者(研究人员、工程师、经理和其他利益相关者)经常会在一些阶段之间反复试验。

CH01_F09_Hagiwara

图 1.9 NLP 应用的开发循环

数据收集

大多数现代自然语言处理(NLP)应用都是基于机器学习的。根据定义,机器学习需要训练 NLP 模型的数据(记住我们之前讨论过的 ML 的定义——它是通过数据来改进算法的)。在这个阶段,NLP 应用开发者讨论如何将应用构建为一个 NLP/ML 问题,以及应收集哪种类型的数据。数据可以从人类那里收集(例如,通过雇佣内部注释者并让他们浏览一堆文本实例),众包(例如,使用亚马逊机械土耳其等平台),或自动机制(例如,从应用程序日志或点击流中收集)。

你可能首先选择不使用机器学习方法进行你的 NLP 应用,这完全可能是正确的选择,这取决于各种因素,比如时间、预算、任务的复杂性以及你可能能够收集的数据量。即使在这种情况下,收集少量数据进行验证也可能是一个好主意。我将在第十一章更详细地讨论 NLP 应用的训练、验证和测试。

分析和实验

收集数据后,您将进入下一个阶段,进行分析和运行一些实验。对于分析,您通常寻找诸如:文本实例的特征是什么?训练标签的分布情况如何?您能否提出与训练标签相关的信号?您能否提出一些简单的规则,以合理的准确性预测训练标签?我们甚至应该使用 ML 吗?这个清单不胜枚举。这个分析阶段包括数据科学的方面,各种统计技术可能会派上用场。

你运行实验来快速尝试一些原型。这个阶段的目标是在你全力投入并开始训练庞大模型之前将可能的方法集缩小到几个有前途的方法。通过运行实验,你希望回答的问题包括:哪些类型的自然语言处理任务和方法适用于这个自然语言处理应用?这是一个分类、解析、序列标记、回归、文本生成还是其他一些问题?基线方法的性能如何?基于规则的方法的性能如何?我们是否应该使用机器学习?有关有前途方法的训练和服务时间的估计是多少?

我把这两个阶段称为“研究”阶段。这个阶段的存在可以说是自然语言处理应用与其他通用软件系统之间最大的区别。由于其特性,很难预测机器学习系统或自然语言处理系统的性能和行为。在这一点上,你可能还没有写一行生产代码,但完全没问题。这个研究阶段的目的是防止你在以后的阶段浪费精力编写后来证明是无用的生产代码。

训练

在这一点上,你已经对你的自然语言处理应用的方法有了相当清晰的想法。这时你开始增加更多的数据和计算资源(例如,GPU)来训练你的模型。现代自然语言处理模型通常需要花费几天甚至几周的时间进行训练,尤其是基于神经网络模型的模型。逐渐增加你训练的数据量和模型的大小是一种很好的实践。你不想花几周的时间训练一个庞大的神经网络模型,只是发现一个更小、更简单的模型效果一样好,甚至更糟糕的是,你在模型中引入了一个 bug,而你花了几周时间训练的模型根本没用!

在这个阶段,保持你的训练流水线可复制是至关重要的。很可能你需要用不同的超参数集合运行这个流水线多次,超参数是在启动模型学习过程之前设置的调整值。很可能几个月甚至几年后你还需要再次运行这个流水线。我会在第十章讨论一些训练自然语言处理/机器学习模型的最佳实践。

实施

当你有一个表现良好的模型时,你就会进入实施阶段。这是你开始使你的应用“投入生产”的时候。这个过程基本上遵循软件工程的最佳实践,包括:为你的自然语言处理模块编写单元和集成测试,重构你的代码,让其他开发人员审查你的代码,提高你的自然语言处理模块的性能,并将你的应用程序打包成 Docker 镜像。我将在第十一章更详细地讨论这个过程。

部署

你的 NLP 应用程序终于准备好部署了。你可以以多种方式部署你的 NLP 应用程序 —— 它可以是一个在线服务、一个定期批处理作业、一个离线应用程序,或者是一个离线一次性任务。如果这是一个需要实时提供预测的在线服务,将其打造成一个微服务以使其与其他服务松耦合是个好主意。无论如何,对于你的应用程序来说,使用持续集成(CI)是一个很好的实践,在这种情况下,你在每次对应用程序进行更改时都会运行测试,并验证你的代码和模型是否按预期工作。

监控

开发 NLP 应用程序的一个重要的最终步骤是监控。这不仅包括监控基础架构,比如服务器 CPU、内存和请求延迟,还包括更高级别的 ML 统计信息,比如输入和预测标签的分布。在这个阶段要问一些重要的问题是:输入实例是什么样子的?它们是否符合你构建模型时的预期?预测的标签是什么样子的?预测的标签分布是否与训练数据中的分布相匹配?监控的目的是检查你构建的模型是否按预期运行。如果传入的文本或数据实例或预测的标签与你的期望不符,那么你可能遇到了一个领域外的问题,这意味着你收到的自然语言数据的领域与你的模型训练的领域不同。机器学习模型通常不擅长处理领域外数据,预测精度可能会受到影响。如果这个问题变得明显,那么重新开始整个过程可能是一个好主意,从收集更多的领域内数据开始。

1.3.2 NLP 应用程序的结构

现代基于机器学习的 NLP 应用程序的结构出人意料地相似,主要有两个原因——一个是大多数现代 NLP 应用程序在某种程度上依赖于机器学习,并且它们应该遵循机器学习应用程序的最佳实践。另一个原因是,由于神经网络模型的出现,一些 NLP 任务,包括文本分类、机器翻译、对话系统和语音识别,现在可以端到端地进行训练,正如我之前提到的。其中一些任务过去是复杂的、包含数十个组件且具有复杂管道的庞然大物。然而,现在,一些这样的任务可以通过不到 1000 行的 Python 代码来解决,只要有足够的数据来端到端地训练模型。

图 1.10 展示了现代 NLP 应用程序的典型结构。有两个主要基础设施:训练基础设施和服务基础设施。训练基础设施通常是离线的,用于训练应用程序所需的机器学习模型。它接收训练数据,将其转换为可以由管道处理的某种数据结构,并通过转换数据和提取特征进一步处理数据。这一部分因任务而异。最后,如果模型是神经网络,将数据实例分批处理并馈送到模型中,该模型经过优化以最小化损失。如果你不理解我在最后一句说的是什么,不要担心,我们将在第二章讨论与神经网络一起使用的技术术语。训练好的模型通常是序列化并存储以传递给服务基础设施。

CH01_F10_Hagiwara

图 1.10 典型 NLP 应用程序的结构

服务基础设施的任务是在给定新实例的情况下生成预测,例如类别、标签或翻译。这个基础设施的第一部分,读取实例并将其转换为一些数字,与训练的部分类似。事实上,你必须保持数据集读取器和转换器相同。否则,这两个过程数据的方式将产生差异,也被称为训练 - 服务差异。在处理实例后,它被馈送到预训练模型以生成预测。我将在第十一章更多地讨论设计 NLP 应用程序的方法。

概述

  • 自然语言处理(NLP)是人工智能(AI)的一个子领域,指的是处理、理解和生成人类语言的计算方法。

  • NLP 面临的挑战之一是自然语言中的歧义性。有句法和语义歧义。

  • 有文本的地方就有 NLP。许多技术公司使用 NLP 从大量文本中提取信息。典型的 NLP 应用包括机器翻译、语法错误纠正、搜索引擎和对话系统。

  • NLP 应用程序以迭代方式开发,更多注重研究阶段。

  • 许多现代自然语言处理(NLP)应用程序严重依赖于机器学习(ML),并且在结构上与 ML 系统相似。

第二章:您的第一个 NLP 应用程序

本章内容包括:

  • 使用 AllenNLP 构建情感分析器

  • 应用基本的机器学习概念(数据集,分类和回归)

  • 应用神经网络概念(词嵌入,循环神经网络,线性层)

  • 通过减少损失训练模型

  • 评估和部署您的模型

在 1.1.2 节中,我们看到了如何使用 NLP。在本章中,我们将讨论如何以更有原则性和现代化的方式进行 NLP。具体而言,我们希望使用神经网络构建一个情感分析器。尽管我们要构建的情感分析器是一个简单的应用程序,并且该库(AllenNLP)会处理大部分工作,但它是一个成熟的 NLP 应用程序,涵盖了许多现代 NLP 和机器学习的基本组件。我将沿途介绍重要的术语和概念。如果您一开始不理解某些概念,请不要担心。我们将在后面的章节中再次讨论在此处介绍的大部分概念。

2.1 介绍情感分析

在 1.1.2 节中描述的情景中,您希望从在线调查结果中提取用户的主观意见。您拥有对自由回答问题的文本数据集合,但缺少对“您对我们的产品有何评价?”问题的答案,您希望从文本中恢复它们。这个任务称为情感分析,是一种在文本中自动识别和分类主观信息的文本分析技术。该技术广泛应用于量化以非结构化方式书写的意见、情感等方面的文本资源。情感分析应用于各种文本资源,如调查、评论和社交媒体帖子。

在机器学习中,分类意味着将某样东西归类为一组预定义的离散类别。情感分析中最基本的任务之一就是极性的分类,即将表达的观点分类为正面、负面或中性。您可以使用超过三个类别,例如强正面、正面、中性、负面或强负面。如果您使用过可以使用五级评分表达的网站(如亚马逊),那么这可能听起来很熟悉。

极性分类是一种句子分类任务。另一种句子分类任务是垃圾邮件过滤,其中每个句子被分类为两类——垃圾邮件或非垃圾邮件。如果只有两个类别,则称为二元分类。如果有超过两个类别(前面提到的五星级分类系统,例如),则称为多类分类

相反,当预测是连续值而不是离散类别时,称之为 回归。如果你想根据房屋的属性来预测房屋的价格,比如它的社区、卧室和浴室的数量以及平方英尺,那就是一个回归问题。如果你尝试根据从新闻文章和社交媒体帖子中收集到的信息来预测股票价格,那也是一个回归问题。(免责声明:我并不是在建议这是预测股价的适当方法。我甚至不确定它是否有效。)正如我之前提到的,大多数语言单位,如字符、单词和词性标签,都是离散的。因此,自然语言处理中大多数使用的机器学习都是分类,而不是回归。

注意 逻辑回归,一种广泛使用的统计模型,通常用于分类,尽管它的名字中有“回归”一词。是的,我知道这很令人困惑!

许多现代自然语言处理应用,包括我们将在本章中构建的情感分析器(如图 2.1 所示),都是基于 监督式机器学习 范式构建的。监督式机器学习是一种机器学习类型,其中算法是通过具有监督信号的数据进行训练的——对于每个输入都有期望的结果。该算法被训练成尽可能准确地重现这些信号。对于情感分析,这意味着系统是在包含每个输入句子的所需标签的数据上进行训练的。

CH02_F01_Hagiwara

图 2.1 情感分析流水线

2.2 处理自然语言处理数据集

正如我们在上一节中讨论的,许多现代自然语言处理应用都是使用监督式机器学习开发的,其中算法是从标有期望结果的数据中训练出来的,而不是使用手写规则。几乎可以说,数据是机器学习的关键部分,因此了解它是如何结构化并与机器学习算法一起使用的至关重要。

2.2.1 什么是数据集?

数据集 简单地意味着一组数据。如果你熟悉关系型数据库,你可以将数据集想象成一个表的转储。它由符合相同格式的数据片段组成。在数据库术语中,数据的每个片段对应一个记录,或者表中的一行。记录可以有任意数量的字段,对应数据库中的列。

在自然语言处理中,数据集中的记录通常是某种类型的语言单位,比如单词、句子或文档。自然语言文本的数据集称为 语料库(复数形式为 语料库)。举个例子,我们来想象一个(假想的)用于垃圾邮件过滤的数据集。该数据集中的每条记录都是一对文本和标签,其中文本是一句话或一段文字(例如,来自一封电子邮件),而标签指定文本是否是垃圾邮件。文本和标签都是记录的字段。

一些自然语言处理数据集和语料库具有更复杂的结构。例如,一个数据集可能包含一系列句子,其中每个句子都用详细的语言信息进行了注释,例如词性标签、句法树、依存结构和语义角色。如果一个数据集包含了一系列句子,并且这些句子带有它们的句法树注释,那么这个数据集被称为树库。最著名的例子是宾夕法尼亚树库(Penn Treebank,PTB)(realworldnlpbook.com/ch2.html#ptb),它一直作为培训和评估自然语言处理任务(如词性标注和句法分析)的事实标准数据集。

与记录密切相关的术语是实例。在机器学习中,实例是进行预测的基本单位。例如,在前面提到的垃圾邮件过滤任务中,一个实例是一段文本,因为对单个文本进行预测(垃圾邮件或非垃圾邮件)。实例通常是从数据集中的记录创建的,就像在垃圾邮件过滤任务中一样,但并非总是如此——例如,如果您拿一个树库来训练一个 NLP 任务,该任务检测句子中的所有名词,那么每个单词,而不是一个句子,就成为一个实例,因为对每个单词进行预测(名词或非名词)。最后,标签是附加到数据集中某些语言单位的信息片段。一个垃圾邮件过滤数据集有与每个文本是否为垃圾邮件相对应的标签。一个树库可能具有每个词的词性标签的标签。标签通常在监督式机器学习环境中用作训练信号(即训练算法的答案)。请参见图 2.2,了解数据集的这些部分的描绘。

CH02_F02_Hagiwara

图 2.2 数据集、记录、字段、实例和标签

2.2.2 斯坦福情感树库

为了构建情感分析器,我们将使用斯坦福情感树库(SST;nlp.stanford.edu/sentiment/),这是截至目前最广泛使用的情感分析数据集之一。前往链接中的 Train, Dev, Test Splits in PTB Tree Format 下载数据集。SST 与其他数据集的一个不同之处在于,情感标签不仅分配给句子,而且分配给句子中的每个单词和短语。例如,数据集的一些摘录如下:

(4
  (2 (2 Steven) (2 Spielberg))
    (4
      (2 (2 brings) (3 us))
      (4 (2 another) (4 masterpiece))))

(1
  (2 It)
  (1
    (1 (2 (2 's) (1 not))
      (4 (2 a) (4 (4 great) (2 (2 monster) (2 movie)))))
    (2 .)))

现在不用担心细节——这些树以人类难以阅读的 S 表达式编写(除非你是 Lisp 程序员)。请注意以下内容:

  • 每个句子都带有情感标签(4 和 1)。

  • 每个单词也被注释了,例如,(4 masterpiece)和(1 not)。

  • 每个短语也被注释了,例如,(4(2 another)(4 masterpiece))。

数据集的这种属性使我们能够研究单词和短语之间的复杂语义交互。 例如,让我们将以下句子的极性作为一个整体来考虑:

这部电影实际上既不是那么有趣,也不是非常机智。

上面的陈述肯定是一个负面的,尽管,如果你专注于单词的个别词语(比如有趣机智),你可能会被愚弄成认为它是一个积极的。 如果您构建一个简单的分类器,它从单词的个别“投票”中获取结果(例如,如果其大多数单词为积极,则句子为积极),这样的分类器将难以正确分类此示例。 要正确分类此句子的极性,您需要理解否定“既不…也不”的语义影响。 为了这个属性,SST 已被用作可以捕获句子的句法结构的神经网络模型的标准基准(realworldnlpbook.com/ch2.html#socher13)。 但是,在本章中,我们将忽略分配给内部短语的所有标签,并仅使用句子的标签。

2.2.3 训练、验证和测试集

在我们继续展示如何使用 SST 数据集并开始构建我们自己的情感分析器之前,我想简要介绍一些机器学习中的重要概念。 在 NLP 和 ML 中,通常使用几种不同类型的数据集来开发和评估模型是常见的。 一个广泛使用的最佳实践是使用三种不同类型的数据集拆分——训练、验证和测试集。

训练(或训练)集是用于训练 NLP/ML 模型的主要数据集。 通常将来自训练集的实例直接馈送到 ML 训练管道中,并用于学习模型的参数。 训练集通常是这里讨论的三种类型的拆分中最大的。

验证集(也称为开发开发集)用于模型选择。 模型选择是一个过程,在这个过程中,从所有可能使用训练集训练的模型中选择适当的 NLP/ML 模型,并且这是为什么它是必要的。 让我们想象一种情况,在这种情况下,您有两种机器学习算法 A 和 B,您希望用它们来训练一个 NLP 模型。 您同时使用这两个算法,并获得了模型 A 和 B。 现在,您如何知道哪个模型更好呢?

“那很容易,”您可能会说。“在训练集上评估它们两个。”乍一看,这似乎是个好主意。 您在训练集上运行模型 A 和 B,并查看它们在准确度等度量方面的表现。 为什么人们要费心使用单独的验证集来选择模型?

答案是 过拟合 —— 自然语言处理和机器学习中另一个重要概念。过拟合是指训练模型在训练集上拟合得非常好,以至于失去了其泛化能力的情况。让我们想象一个极端情况来说明这一点。假设算法 B 是一个非常非常强大的算法,可以完全记住所有东西。可以把它想象成一个大的关联数组(或 Python 中的字典),它可以存储它曾经遇到过的所有实例和标签对。对于垃圾邮件过滤任务来说,这意味着模型会以训练时呈现的确切文本及其标签的形式进行存储。如果在评估模型时呈现相同的文本,它将返回存储的标签。另一方面,如果呈现的文本与其记忆中的任何其他文本略有不同,模型就一无所知,因为它以前从未见过。

你认为这个模型在训练集上进行评估时会表现如何?答案是……是的,100%!因为模型记住了训练集中的所有实例,所以它可以简单地“重播”整个数据集并进行完美分类。现在,如果你在电子邮件软件上安装了这个算法,它会成为一个好的垃圾邮件过滤器吗?绝对不会!因为无数的垃圾邮件看起来与现有邮件非常相似,但略有不同,或者完全是新的,所以如果输入的电子邮件与存储在内存中的内容只有一个字符的不同,模型就一无所知,并且在投入生产时将毫无用处。换句话说,它的泛化能力非常差(事实上是零)。

你如何防止选择这样的模型呢?通过使用验证集!验证集由与训练集类似的独立实例组成。因为它们与训练集独立,所以如果你在验证集上运行训练过的模型,你就可以很好地了解模型在训练集之外的表现。换句话说,验证集为模型的泛化能力提供了一个代理。想象一下,如果之前的“记住所有”算法训练的模型在验证集上进行评估。因为验证集中的实例与训练集中的实例类似但独立,所以你会得到非常低的准确率,知道模型的性能会很差,甚至在部署之前。

验证集还用于调整超参数。超参数是关于机器学习算法或正在训练的模型的参数。例如,如果你将训练循环(也称为epoch,关于更多解释请见后文)重复N次,那么这个N就是一个超参数。如果你增加神经网络的层数,你就改变了关于模型的一个超参数。机器学习算法和模型通常有许多超参数,调整它们对模型的性能至关重要。你可以通过训练多个具有不同超参数的模型并在验证集上评估它们来做到这一点。事实上,你可以将具有不同超参数的模型视为不同的模型,即使它们具有相同的结构,超参数调整可以被视为一种模型选择。

最后,测试集用于使用新的、未见过的数据对模型进行评估。它包含的实例与训练集和验证集是独立的。它可以让你很好地了解模型在“野外”中的表现。

你可能会想知道为什么需要另外一个独立的数据集来评估模型的泛化能力。难道你不能只使用验证集吗?再次强调,你不应该仅仅依赖于训练集和验证集来衡量你的模型的泛化能力,因为你的模型也可能以微妙的方式对验证集进行过拟合。这一点不太直观,但让我举个例子。想象一下,你正在疯狂地尝试大量不同的垃圾邮件过滤模型。你编写了一个脚本,可以自动训练一个垃圾邮件过滤模型。该脚本还会自动在验证集上评估训练好的模型。如果你用不同的算法和超参数组合运行此脚本 1,000 次,并选择在验证集上性能最好的一个模型,那么它是否也会在完全新的、未见过的实例上表现最好呢?可能不会。如果你尝试大量的模型,其中一些可能纯粹是由于偶然性而在验证集上表现相对较好(因为预测本质上存在一些噪音,和/或者因为这些模型恰好具有一些使它们在验证集上表现更好的特性),但这并不能保证这些模型在验证集之外表现良好。换句话说,可能会将模型过度拟合到验证集上。

总之,在训练 NLP 模型时,使用一个训练集来训练你的模型候选者,使用一个验证集来选择好的模型,并使用一个测试集来评估它们。用于 NLP 和 ML 评估的许多公共数据集已经分成了训练/验证/测试集。如果你只有一个数据集,你可以自己将其分成这三个数据集。常用的是 80:10:10 分割。图 2.3 描绘了训练/验证/测试分割以及整个训练流水线。

CH02_F03_Hagiwara

图 2.3 训练/验证/测试分割和训练流水线

2.2.4 使用 AllenNLP 加载 SST 数据集

最后,让我们看看如何在代码中实际加载数据集。在本章的其余部分,我们假设你已经安装了 AllenNLP(版本 2.5.0)和相应版本的 allennlp-models 包,通过运行以下命令:

pip install allennlp==2.5.0
pip install allennlp-models==2.5.0

并导入了如下所示的必要类和模块:

from itertools import chain
from typing import Dict

import numpy as np
import torch
import torch.optim as optim
from allennlp.data.data_loaders import MultiProcessDataLoader
from allennlp.data.samplers import BucketBatchSampler
from allennlp.data.vocabulary import Vocabulary
from allennlp.models import Model
from allennlp.modules.seq2vec_encoders import Seq2VecEncoder, PytorchSeq2VecWrapper
from allennlp.modules.text_field_embedders import TextFieldEmbedder, BasicTextFieldEmbedder
from allennlp.modules.token_embedders import Embedding
from allennlp.nn.util import get_text_field_mask
from allennlp.training import GradientDescentTrainer
from allennlp.training.metrics import CategoricalAccuracy, F1Measure
from allennlp_models.classification.dataset_readers.stanford_sentiment_tree_bank import \    StanfordSentimentTreeBankDatasetReader

很遗憾,截至目前为止,AllenNLP 并不官方支持 Windows。但别担心——本章节中的所有代码(实际上,本书中的所有代码)都可以作为 Google Colab 笔记本 (www.realworldnlpbook.com/ch2.html#sst-nb) 使用,你可以在那里运行和修改代码并查看结果。

你还需要定义以下两个在代码片段中使用的常量:

EMBEDDING_DIM = 128
HIDDEN_DIM = 128

AllenNLP 已经支持一个名为 DatasetReader 的抽象,它负责从原始格式(无论是原始文本还是一些奇特的基于 XML 的格式)中读取数据集并将其返回为一组实例。我们将使用 StanfordSentimentTreeBankDatasetReader(),它是一种特定处理 SST 数据集的 DatasetReader,如下所示:

reader = StanfordSentimentTreeBankDatasetReader()
train_path = 'https:/./s3.amazonaws.com/realworldnlpbook/data/stanfordSentimentTreebank/trees/train.txt'
dev_path = 'https:/./s3.amazonaws.com/realworldnlpbook/data/stanfordSentimentTreebank/trees/dev.txt'

此片段将为 SST 数据集创建一个数据集读取器,并定义训练和开发文本文件的路径。

2.3 使用词嵌入

从这一部分开始,我们将开始构建情感分析器的神经网络架构。Architecture 只是神经网络结构的另一个词。构建神经网络很像建造房屋等结构。第一步是弄清楚如何将输入(例如,情感分析的句子)馈送到网络中。

正如我们之前所见,自然语言处理中的所有内容都是离散的,这意味着形式和含义之间没有可预测的关系(记得“rat”和“sat”)。另一方面,神经网络最擅长处理数字和连续的东西,这意味着神经网络中的所有内容都需要是浮点数。我们如何在这两个世界之间“搭桥”——离散和连续?关键在于词嵌入的使用,我们将在本节中详细讨论。

2.3.1 什么是词嵌入?

Word embeddings 是现代自然语言处理中最重要的概念之一。从技术上讲,嵌入是通常离散的东西的连续向量表示。词嵌入是一个词的连续向量表示。如果你对向量的概念不熟悉,vector 是数学上对数字的单维数组的名称。简单来说,词嵌入是用一个 300 元素数组(或任何其他大小的数组)填充的非零浮点数来表示每个单词的一种方式。概念上非常简单。那么,为什么它在现代自然语言处理中如此重要和普遍呢?

正如我在第一章中提到的,自然语言处理的历史实际上是对语言“离散性”的持续战斗的历史。在计算机眼中,“猫”和“狗”的距离与它们与“披萨”的距离是相同的。编程上处理离散词的一种方法是为各个词分配索引,如下所示(这里我们简单地假设这些索引按字母顺序分配):

  • index("cat") = 1

  • index("dog") = 2

  • index("pizza") = 3

  • ...

这些分配通常由查找表管理。一个 NLP 应用或任务处理的整个有限单词集被称为词汇。但是这种方法并不比处理原始单词更好。仅仅因为单词现在用数字表示,就不意味着你可以对它们进行算术运算,并得出“猫”与“狗”(1 和 2 之间的差异)同样相似,就像“狗”与“披萨”(2 和 3 之间的差异)一样。这些指数仍然是离散和任意的。

“如果我们可以在数值尺度上表示它们呢?”几十年前,一些自然语言处理研究人员想知道。我们能否想出一种数值尺度,其中单词是

以点表示,以使语义上更接近的词(例如,“狗”和“猫”,它们都是动物)在几何上也更接近?从概念上讲,数值尺度将类似于图 2.4 中所示的尺度。

CH02_F04_Hagiwara

图 2.4 一维空间中的词嵌入

这是一个进步。现在我们可以表示“猫”和“狗”彼此之间比“披萨”更相似的事实。但是,“披萨”仍然比“猫”更接近“狗”。如果你想把它放在一个距离“猫”和“狗”都一样远的地方怎么办?也许只有一个维度太限制了。在这个基础上再添加一个维度如何,如图 2.5 所示?

CH02_F05_Hagiwara

图 2.5 二维空间中的词嵌入

许多改进!因为计算机在处理多维空间方面非常擅长(因为你可以简单地用数组表示点),你可以一直这样做,直到你有足够数量的维度。让我们有三个维度。在这个三维空间中,你可以将这三个词表示如下:

  • vec("cat") = [0.7, 0.5, 0.1]

  • vec("dog") = [0.8, 0.3, 0.1]

  • vec("pizza") = [0.1, 0.2, 0.8]

图 2.6 说明了这个三维空间。

CH02_F06_Hagiwara

图 2.6 三维空间中的词嵌入

这里的 x 轴(第一个元素)表示“动物性”的某种概念,而 z 轴(第三维)对应于“食物性”。(我编造了这些数字,但你明白我的意思。)这就是单词嵌入的本质。您只是将这些单词嵌入到了一个三维空间中。通过使用这些向量,您已经“知道”了语言的基本构建块是如何工作的。例如,如果您想要识别动物名称,那么您只需查看每个单词向量的第一个元素,并查看值是否足够高。与原始单词索引相比,这是一个很好的起点!

你可能会想知道这些数字实际上来自哪里。这些数字实际上是使用一些机器学习算法和大型文本数据集“学习”的。我们将在第三章中进一步讨论这一点。

顺便说一下,我们有一种更简单的方法将单词“嵌入”到多维空间中。想象一个具有与单词数量相同的维度的多维空间。然后,给每个单词一个向量,其中填充了零但只有一个 1,如下所示:

  • vec("cat") = [1, 0, 0]

  • vec("dog") = [0, 1, 0]

  • vec("pizza") = [0, 0, 1]

请注意,每个向量在对应单词的索引位置只有一个 1。这些特殊向量称为one-hot 向量。这些向量本身并不非常有用,不能很好地表示这些单词之间的语义关系——这三个单词彼此之间的距离都是相等的——但它们仍然(是一种非常愚蠢的)嵌入。当嵌入不可用时,它们通常被用作机器学习算法的输入。

2.3.2 使用单词嵌入进行情感分析

首先,我们创建数据集加载器,负责加载数据并将其传递给训练流水线,如下所示(稍后在本章中对此数据进行更多讨论):

sampler = BucketBatchSampler(batch_size=32, sorting_keys=["tokens"])
train_data_loader = MultiProcessDataLoader(reader, train_path,
                                           batch_sampler=sampler)
dev_data_loader = MultiProcessDataLoader(reader, dev_path,
                                           batch_sampler=sampler)

AllenNLP 提供了一个有用的 Vocabulary 类,管理着一些语言单位(如字符、单词和标签)到它们的 ID 的映射。您可以告诉该类从一组实例中创建一个 Vocabulary 实例,如下所示:

vocab = Vocabulary.from_instances(chain(train_data_loader.iter_instances(),
                                        dev_data_loader.iter_instances()),
                                  min_count={'tokens': 3})

然后,您需要初始化一个 Embedding 实例,它负责将 ID 转换为嵌入,如下代码片段所示。嵌入的大小(维度)由 EMBEDDING_DIM 决定:

token_embedding = Embedding(num_embeddings=vocab.get_vocab_size('tokens'),
                            embedding_dim=EMBEDDING_DIM)

最后,您需要指定哪些索引名称对应于哪些嵌入,并将其传递给 BasicTextFieldEmbedder,如下所示:

word_embeddings = BasicTextFieldEmbedder({"tokens": token_embedding})

现在,您可以使用 word_embeddings 将单词(或更准确地说是标记,我将在第三章中更详细地讨论)转换为它们的嵌入。

2.4 神经网络

越来越多的现代自然语言处理应用程序是使用神经网络构建的。你可能已经看到了许多现代神经网络模型在计算机视觉和游戏领域所取得的惊人成就(例如自动驾驶汽车和打败人类冠军的围棋算法),而自然语言处理也不例外。在本书中,我们将使用神经网络来构建大多数自然语言处理示例和应用程序。在本节中,我们讨论了神经网络是什么以及它们为什么如此强大。

2.4.1 什么是神经网络?

神经网络是现代自然语言处理(以及许多其他相关人工智能领域,如计算机视觉)的核心。它是如此重要,如此广泛的研究主题,以至于需要一本书(或者可能是几本书)来全面解释它是什么以及所有相关的模型、算法等。在本节中,我将简要解释其要点,并根据需要在后面的章节中详细介绍。

简而言之,神经网络(也称为人工神经网络)是一个通用的数学模型,它将一个向量转换为另一个向量。就是这样。与你在大众媒体中读到和听到的内容相反,它的本质是简单的。如果你熟悉编程术语,可以将其看作是一个接受一个向量,内部进行一些计算,并将另一个向量作为返回值的函数。那么它为什么如此重要呢?它与编程中的普通函数有何不同呢?

第一个区别在于神经网络是可训练的。不要把它仅仅看作是一个固定的函数,而更像是一组相关函数的“模板”。如果你使用编程语言编写了一个包含一些常数的数学方程组的函数,当你输入相同的输入时,你总是会得到相同的结果。相反,神经网络可以接收“反馈”(输出与期望输出的接近程度)并调整其内部常数。那些“神奇”的常数被称为权重或更普遍地称为参数。下次运行时,你期望它的答案更接近你想要的结果。

第二个区别在于它的数学能力。如果可能的话,如果你要使用你最喜欢的编程语言编写一个执行情感分析等功能的函数,那将会非常复杂。(还记得第一章中那个可怜的软件工程师吗?)理论上,只要有足够的模型能力和训练数据,神经网络就能够近似于任何连续函数。这意味着,无论你的问题是什么,只要输入和输出之间存在关系,并且你为模型提供足够的计算能力和训练数据,神经网络就能够解决它。

神经网络通过学习非线性函数来实现这一点。什么是线性函数呢?线性函数是指,如果你将输入改变了 x,输出将始终以 c * x 的常数倍变化,其中 c 是一个常数。例如,2.0 * x 是线性的,因为如果你将 x 改变 1.0,返回值总是增加 2.0。如果你将这个函数画在图上,输入和输出之间的关系形成一条直线,这就是为什么它被称为线性的原因。另一方面,2.0 * x * x 不是线性的,因为返回值的变化量不仅取决于你改变 x 的量,还取决于 x 的值。

这意味着线性函数无法捕捉输入和输出之间以及输入变量之间的更复杂的关系。相反,诸如语言之类的自然现象是高度非线性的。如果你改变了输入 x(例如,句子中的一个词),输出的变化量不仅取决于你改变了多少 x,还取决于许多其他因素,如 x 本身的值(例如,你将 x 改变为什么词)以及其他变量(例如,x 的上下文)是什么。神经网络,这种非线性数学模型,有可能捕捉到这样复杂的相互作用。

2.4.2 循环神经网络(RNNs)和线性层

两种特殊类型的神经网络组件对情感分析非常重要——循环神经网络(RNNs)和线性层。我将在后面的章节中详细解释它们,但我会简要描述它们是什么以及它们在情感分析(或一般而言,句子分类)中的作用。

循环神经网络(RNN)是一种带有循环的神经网络,如图 2.7 所示。它具有一个内部结构,该结构被一次又一次地应用于输入。用编程的类比来说,这就像编写一个包含 for word in sentence:循环遍历输入句子中的每个单词的函数。它可以输出循环内部变量的中间值,或者循环完成后变量的最终值,或者两者兼而有之。如果你只取最终值,你可以将 RNN 用作将句子转换为具有固定长度的向量的函数。在许多自然语言处理任务中,你可以使用 RNN 将句子转换为句子的嵌入。还记得词嵌入吗?它们是单词的固定长度表示。类似地,RNN 可以产生句子的固定长度表示。

CH02_F07_Hagiwara

图 2.7 循环神经网络(RNN)

我们在这里将使用的另一种类型的神经网络组件是线性层。线性层,也称为全连接层,以线性方式将一个向量转换为另一个向量。正如前面提到的,只是神经网络的一个子结构的花哨术语,因为你可以将它们堆叠在一起形成一个更大的结构。

请记住,神经网络可以学习输入和输出之间的非线性关系。为什么我们想要有更受限制(线性)的东西呢?线性层用于通过减少(或增加)维度来压缩(或扩展)向量。例如,假设你从 RNN 接收到一个 64 维的向量(64 个浮点数的数组)作为句子的嵌入,但你只关心对预测有重要作用的少量数值。在情感分析中,你可能只关心与五种不同情感标签对应的五个数值,即极强正面、正面、中性、负面和极强负面。但是你无法从嵌入的 64 个数值中提取出这五个数值。这正是线性层派上用场的地方 - 你可以添加一个层,将一个 64 维的向量转换为一个 5 维的向量,而神经网络会想办法做得很好,如图 2.8 所示。

CH02_F08_Hagiwara

图 2.8 线性层

2.4.3 情感分析的架构

现在,你已经准备好将各个组件组合起来构建情感分析器的神经网络了。首先,你需要按照以下步骤创建 RNN:

encoder = PytorchSeq2VecWrapper(
    torch.nn.LSTM(EMBEDDING_DIM, HIDDEN_DIM, batch_first=True))

不要太担心 PytorchSeq2VecWrapper 和 batch_first=True。在这里,你正在创建一个 RNN(或更具体地说,一种叫做 LSTM 的 RNN)。输入向量的大小是 EMBEDDING_DIM,我们之前看到的,而输出向量的大小是 HIDDEN_DIM。

接下来,你需要创建一个线性层,如下所示:

self.linear = torch.nn.Linear(in_features=encoder.get_output_dim(),
                              out_features=vocab.get_vocab_size('labels'))

输入向量的大小由 in_features 定义,而输出向量的大小则由 out_features 定义。因为我们要将句子嵌入转换为一个向量,其元素对应于五个情感标签,所以我们需要指定编码器输出的大小,并从词汇表中获取标签的总数。

最后,我们可以连接这些组件并构建一个模型,如下所示的代码。

列表 2.1 构建情感分析模型

class LstmClassifier(Model):
    def __init__(self,
                 word_embeddings: TextFieldEmbedder,
                 encoder: Seq2VecEncoder,
                 vocab: Vocabulary,
                 positive_label: str = '4') -> None:
        super().__init__(vocab)
        self.word_embeddings = word_embeddings

        self.encoder = encoder

        self.linear = torch.nn.Linear(in_features=encoder.get_output_dim(),
                                      out_features=vocab.get_vocab_size('labels'))

        self.loss_function = torch.nn.CrossEntropyLoss()         ❶

    def forward(self,                                            ❷
                tokens: Dict[str, torch.Tensor],
                label: torch.Tensor = None) -> torch.Tensor:
        mask = get_text_field_mask(tokens)

        embeddings = self.word_embeddings(tokens)
        encoder_out = self.encoder(embeddings, mask)
        logits = self.linear(encoder_out)

        output = {"logits": logits}
        if label is not None:
            self.accuracy(logits, label)
            self.f1_measure(logits, label)
            output["loss"] = self.loss_function(logits, label)   ❸

        return output

❶ 定义损失函数(交叉熵)

❷ forward() 函数是模型中大部分计算发生的地方。

❸ 计算损失并将其分配给返回字典中的“loss”键

我希望你专注于最重要的函数 forward(),每个神经网络模型都有它。它的作用是接收输入,经过神经网络的子组件处理后,产生输出。虽然这个函数有一些我们尚未涉及的陌生逻辑(例如掩码和损失),但重要的是你可以像将输入(标记)转换的函数一样将模型的子组件(词嵌入,RNN 和线性层)链接在一起,并在管道的末尾得到一些称为logits的东西。在统计学中,logit 是一个具有特定含义的术语,但在这里,你可以将其视为类别的分数。对于特定标签的分数越高,表示该标签是正确的信心就越大。

2.5 损失函数和优化

神经网络使用有监督学习进行训练。如前所述,有监督学习是一种基于大量标记数据学习将输入映射到输出的机器学习类型。到目前为止,我只介绍了神经网络如何接收输入并生成输出。我们如何才能使神经网络生成我们实际想要的输出呢?

神经网络不仅仅是像常规的编程语言中的函数那样。它们是可训练的,意味着它们可以接收一些反馈并调整其内部参数,以便下一次为相同的输入产生更准确的输出。请注意,这包含两个部分-接收反馈和调整参数,分别通过损失函数和优化来实现,下面我将解释它们。

损失函数是衡量机器学习模型输出与期望输出之间距离的函数。实际输出与期望输出之间的差异称为损失。在某些情况下,损失也称为成本。无论哪种情况,损失越大,则模型越差,你希望它尽可能接近零。例如,以情感分析为例。如果模型认为一句话是 100%的负面,但训练数据显示它是非常积极的,那么损失会很大。另一方面,如果模型认为一句话可能是 80%的负面,而训练标签确实是负面的,那么损失会很小。如果两者完全匹配,损失将为零。

PyTorch 提供了广泛的函数来计算损失。我们在这里需要的是交叉熵损失,它通常用于分类问题,如下所示:

self.loss_function = torch.nn.CrossEntropyLoss()

后续可以通过以下方式将预测和来自训练集的标签传递给它来使用:

output["loss"] = self.loss_function(logits, label)

然后,这就是魔术发生的地方。 由于其数学属性,神经网络知道如何改变其内部参数以使损失变小。 在接收到一些大损失后,神经网络会说:“哎呀,抱歉,那是我的错,但我下一轮会做得更好!” 并更改其参数。 记得我说过编写一个具有一些魔术常量的编程语言的函数吗? 神经网络就像那样的函数,但它们确切地知道如何改变魔术常量以减少损失。 它们对训练数据中的每个实例都这样做,以便尽可能为尽可能多的实例产生更多的正确答案。 当然,它们在调整参数仅一次后就不能达到完美的答案。 需要对训练数据进行多次通过,称为epochs。 图 2.9 显示了神经网络的整体训练过程。

CH02_F09_Hagiwara

图 2.9 神经网络的整体训练过程

神经网络从输入中使用当前参数集计算输出的过程称为前向传递。 这就是为什么列表 2.1 中的主要函数被称为 forward()。 将损失反馈给神经网络的方式称为反向传播。 通常使用一种称为随机梯度下降(SGD)的算法来最小化损失。 将损失最小化的过程称为优化,用于实现此目的的算法(例如 SGD)称为优化器。 您可以使用 PyTorch 初始化优化器如下:

optimizer = optim.Adam(model.parameters())

在这里,我们使用一种称为 Adam 的优化器。 在神经网络社区中提出了许多类型的优化器,但共识是没有一种优化算法适用于任何问题,您应该准备为您自己的问题尝试多种优化算法。

好了,那是很多技术术语。 暂时你不需要了解这些算法的细节,但如果你学习一下这些术语及其大致含义会很有帮助。 如果你用 Python 伪代码来写整个训练过程,它将显示为第 2.2 列表所示。 请注意,有两个嵌套循环,一个是在 epochs 上,另一个是在 instances 上。

第 2.2 列表神经网络训练循环的伪代码

MAX_EPOCHS = 100
model = Model()

for epoch in range(MAX_EPOCHS):
    for instance, label in train_set:
        prediction = model.forward(instance)
        loss = loss_function(prediction, label)
        new_model = optimizer(model, loss)
        model = new_model

2.6 训练您自己的分类器

在本节中,我们将使用 AllenNLP 的训练框架来训练我们自己的分类器。 我还将简要介绍批处理的概念,这是在训练神经网络模型中使用的重要实用概念。

2.6.1 批处理

到目前为止,我忽略了一个细节——批处理。 我们假设每个实例都会进行一次优化步骤,就像您在之前的伪代码中看到的那样。 但实际上,我们通常会将若干个实例分组并将它们馈送到神经网络中,每个组更新模型参数,而不是每个实例。 我们将这组实例称为一个批次

批处理是一个好主意,原因有几个。第一个是稳定性。任何数据都存在噪声。您的数据集可能包含采样和标记错误。如果您为每个实例更新模型参数,并且某些实例包含错误,则更新受到噪声的影响太大。但是,如果您将实例分组为批次,并为整个批次计算损失,而不是为单个实例计算,您可以“平均”小错误,并且反馈到您的模型会稳定下来。

第二个原因是速度。训练神经网络涉及大量的算术操作,如矩阵加法和乘法,并且通常在 GPU(图形处理单元)上进行。因为 GPU 被设计成可以并行处理大量的算术操作,所以如果您一次传递大量数据并一次处理它,而不是逐个传递实例,通常会更有效率。把 GPU 想象成一个海外的工厂,根据您的规格制造产品。因为工厂通常被优化为大量制造少量种类的产品,并且在通信和运输产品方面存在开销,所以如果您为大量产品制造少量订单,而不是为少量产品制造大量订单,即使您希望以任何方式获得相同数量的产品,也更有效率。

使用 AllenNLP 轻松将实例分组成批次。该框架使用 PyTorch 的 DataLoader 抽象,负责接收实例并返回批次。我们将使用一个 BucketBatchSampler,它将实例分组成长度相似的桶,如下代码片段所示。我将在后面的章节中讨论它的重要性:

sampler = BucketBatchSampler(batch_size=32, sorting_keys=["tokens"])
train_data_loader = MultiProcessDataLoader(reader, train_path, batch_sampler=sampler)
dev_data_loader = MultiProcessDataLoader(reader, dev_path, batch_sampler=sampler)

参数 batch_size 指定了批量的大小(批量中的实例数)。调整此参数通常有一个“最佳点”。它应该足够大,以产生我之前提到的批处理的任何效果,但也应该足够小,以便批次适合 GPU 内存,因为工厂有一次可以制造的产品的最大容量。

2.6.2 将一切放在一起

现在您已经准备好训练情感分析器了。我们假设您已经定义并初始化了您的模型如下:

model = LstmClassifier(word_embeddings, encoder, vocab)

查看完整的代码清单(www.realworldnlpbook.com/ch2.html#sst-nb),了解模型的外观和如何使用它。

AllenNLP 提供了 Trainer 类,它作为将所有组件放在一起并管理训练流水线的框架,如下所示:

trainer = GradientDescentTrainer(
    model=model,
    optimizer=optimizer,
    data_loader=train_data_loader,
    validation_data_loader=dev_data_loader,
    patience=10,
    num_epochs=20,
    cuda_device=-1)

trainer.train()

你向训练器提供模型、优化器、迭代器、训练集、开发集和你想要的时期数,并调用 train 方法。最后一个参数,cuda_device,告诉训练器使用哪个设备(CPU 或 GPU)进行训练。在这里,我们明确地使用 CPU。这将运行列在列表 2.2 中的神经网络训练循环,并显示进展情况,包括评估指标。

2.7 评估你的分类器

当训练自然语言处理/机器学习模型时,你应该始终监控损失随时间的变化。如果训练正常进行,你应该看到损失随时间而减少。它不一定每个时期都会减少,但作为一般趋势,它应该会减少,因为这正是你告诉优化器要做的事情。如果它在增加或显示出奇怪的值(如 NaN),通常意味着你的模型过于局限或代码中存在错误的迹象。

除了损失之外,监控你在任务中关心的其他评估指标也很重要。损失是一个纯数学概念,衡量了模型与答案之间的接近程度,但较小的损失并不总是能保证在自然语言处理任务中获得更好的性能。

你可以使用许多评估指标,取决于你的自然语言处理任务的性质,但无论你在做什么任务,你都需要了解的一些指标包括准确率、精确率、召回率和 F-度量。粗略地说,这些指标衡量了你的模型预测与数据集定义的预期答案匹配的程度。暂时来说,知道它们用于衡量分类器的好坏就足够了(更多细节将在第四章介绍)。

要在训练期间使用 AllenNLP 监控和报告评估指标,你需要在你的模型类中实现 get_metrics() 方法,该方法返回从指标名称到它们的值的字典,如下所示。

列表 2.3 定义评估指标

   def get_metrics(self, reset: bool = False) -> Dict[str, float]:
        return {'accuracy': self.accuracy.get_metric(reset),
                **self.f1_measure.get_metric(reset)}

self.accuracy 和 self.f1_measure 在 init() 中定义如下:

    self.accuracy = CategoricalAccuracy()
    self.f1_measure = F1Measure(positive_index)

当你使用定义好的指标运行 trainer.train() 时,你会在每个时期后看到类似下面的进度条:

accuracy: 0.7268, precision: 0.8206, recall: 0.8703, f1: 0.8448, batch_loss: 0.7609, loss: 0.7194 ||: 100%|##########| 267/267 [00:13<00:00, 19.28it/s]
accuracy: 0.3460, precision: 0.3476, recall: 0.3939, f1: 0.3693, batch_loss: 1.5834, loss: 1.9942 ||: 100%|##########| 35/35 [00:00<00:00, 119.53it/s]

你可以看到训练框架报告了这些指标,分别针对训练集和验证集。这不仅有助于评估模型,还有助于监控训练的进展。如果看到任何异常值,比如极低或极高的数字,你会知道出了问题,甚至在训练完成之前就能发现。

你可能已经注意到训练集和验证集的指标之间存在很大的差距。具体来说,训练集的指标比验证集的指标高得多。这是过拟合的常见症状,我之前提到过,即模型在训练集上拟合得非常好,以至于失去了在外部的泛化能力。这就是为什么监控指标使用验证集也很重要,因为仅仅通过观察训练集的指标,你无法知道它是表现良好还是过拟合!

2.8 部署你的应用程序

制作自己的 NLP 应用程序的最后一步是部署它。训练模型只是故事的一半。你需要设置它,以便它可以为它从未见过的新实例进行预测。确保模型提供预测在实际的 NLP 应用程序中是至关重要的,而且在这个阶段可能会投入大量的开发工作。在本节中,我将展示如何使用 AllenNLP 部署我们刚刚训练的模型。这个主题在第十一章中会更详细地讨论。

2.8.1 进行预测

要对你的模型从未见过的新实例进行预测(称为测试实例),你需要通过与训练相同的神经网络管道来传递它们。它必须完全相同——否则,你将冒着结果扭曲的风险。这被称为训练-服务偏差,我将在第十一章中解释。

AllenNLP 提供了一个方便的抽象称为预测器,它的工作是接收原始形式的输入(例如,原始字符串),将其通过预处理和神经网络管道传递,并返回结果。我为 SST 编写了一个特定的预测器称为 SentenceClassifierPredictor(realworldnlpbook.com/ch2 .html#predictor),你可以按照以下方式调用它:

predictor = SentenceClassifierPredictor(model, dataset_reader=reader)
logits = predictor.predict('This is the best movie ever!')['logits']

注意,预测器返回模型的原始输出,在这种情况下是 logits。记住,logits 是与目标标签对应的一些分数,所以如果你想要预测的标签本身,你需要将其转换为标签。你现在不需要理解所有的细节,但可以通过首先取 logits 的 argmax 来完成这个操作,argmax 返回具有最大值的 logit 的索引,然后通过查找 ID 来获取标签,如下所示:

label_id = np.argmax(logits)
print(model.vocab.get_token_from_index(label_id, 'labels'))

如果这个打印出“4”,那么恭喜你!标签“4”对应着“非常积极”,所以你的情感分析器刚刚预测到句子“这是有史以来最好的电影!”是非常积极的,这的确是正确的。

2.8.2 提供预测

最后,你可以使用 AllenNLP 轻松部署训练好的模型。如果你使用 JSON 配置文件(我将在第四章中解释),你可以将训练好的模型保存到磁盘上,然后快速启动一个基于 Web 的界面,你可以向你的模型发送请求。要做到这一点,你需要安装 allennlp-server,这是一个为 AllenNLP 提供预测的 Web 接口的插件,如下所示:

git clone https:/./github.com/allenai/allennlp-server
pip install —editable allennlp-server

假设你的模型保存在 examples/sentiment/model 下,你可以使用以下 AllenNLP 命令运行一个基于 Python 的 Web 应用程序:

$ allennlp serve \ 
    --archive-path examples/sentiment/model/model.tar.gz \
    --include-package examples.sentiment.sst_classifier \
    --predictor sentence_classifier_predictor \
    --field-name sentence

如果你使用浏览器打开 http:/./localhost:8000/,你将看到图 2.10 中显示的界面。

CH02_F10_Hagiwara

图 2.10 在 Web 浏览器上运行情感分析器

尝试在句子文本框中输入一些句子,然后点击预测。您应该在屏幕右侧看到逻辑值。它们只是一组原始的逻辑值,很难阅读,但您可以看到第四个值(对应标签“非常积极”)是最大的,模型正在按预期工作。

您还可以直接从命令行向后端进行 POST 请求,如下所示:

curl -d '{"sentence": "This is the best movie ever!"}'
    -H "Content-Type: application/json" \
    -X POST http:/./localhost:8000/predict

这应该返回与上面看到的相同的 JSON:

{"logits":[-0.2549717128276825,-0.35388273000717163,
-0.0826418399810791,0.7183976173400879,0.23161858320236206]}

好了,就到这里吧。在这一章中我们讨论了很多内容,但不要担心——我只是想告诉你,构建一个实际可用的自然语言处理应用程序是很容易的。也许你曾经发现一些关于神经网络和深度学习的书籍或在线教程让人望而生畏,甚至在创建任何实际可用的东西之前就放弃了学习。请注意,我甚至没有提到任何诸如神经元、激活、梯度和偏导数等概念,这些概念通常是其他学习资料在最初阶段教授的。这些概念确实很重要,而且有助于了解,但多亏了强大的框架如 AllenNLP,你也能够构建实用的自然语言处理应用程序,而不必完全了解其细节。在后面的章节中,我将更详细地讨论这些概念。

摘要

  • 情感分析是一种文本分析技术,用于自动识别文本中的主观信息,如其极性(积极或消极)。

  • 训练集、开发集和测试集用于训练、选择和评估机器学习模型。

  • 词嵌入使用实数向量表示单词的含义。

  • 循环神经网络(RNN)和线性层用于将一个向量转换为另一个不同大小的向量。

  • 使用优化器训练神经网络,以使损失(实际输出与期望输出之间的差异)最小化。

  • 在训练过程中监视训练集和开发集的指标非常重要,以避免过拟合。

第三章:单词和文档嵌入

本章包括

  • 单词嵌入是什么以及它们为什么重要

  • Skip-gram 模型如何学习单词嵌入以及如何实现它

  • GloVe 嵌入是什么以及如何使用预训练的向量

  • 如何使用 Doc2Vec 和 fastText 训练更高级的嵌入

  • 如何可视化单词嵌入

在第二章中,我指出神经网络只能处理数字,而自然语言中几乎所有内容都是离散的(即,分离的概念)。要在自然语言处理应用中使用神经网络,你需要将语言单位转换为数字,例如向量。例如,如果你希望构建一个情感分析器,你需要将输入句子(单词序列)转换为向量序列。在本章中,我们将讨论单词嵌入,这是实现这种桥接的关键。我们还将简要介绍几个在理解嵌入和神经网络的一般性质中重要的基本语言组件。

3.1 引入嵌入

正如我们在第二章中讨论的,嵌入是通常离散的事物的实值向量表示。在本节中,我们将重新讨论嵌入是什么,并详细讨论它们在自然语言处理应用中的作用。

3.1.1 什么是嵌入?

单词嵌入是一个单词的实值向量表示。如果你觉得向量的概念令人生畏,可以把它们想象成一维的浮点数数组,就像下面这样:

  • vec("cat") = [0.7, 0.5, 0.1]

  • vec("dog") = [0.8, 0.3, 0.1]

  • vec("pizza") = [0.1, 0.2, 0.8]

因为每个数组都包含三个元素,你可以将它们绘制为三维空间中的点,如图 3.1 所示。请注意,语义相关的单词(“猫”和“狗”)被放置在彼此附近。

CH03_F01_Hagiwara

图 3.1 单词嵌入在三维空间中

注意 实际上,你可以嵌入(即,用一系列数字表示)不仅仅是单词,还有几乎任何东西 —— 字符、字符序列、句子或类别。你可以使用相同的方法嵌入任何分类变量,尽管在本章中,我们将专注于自然语言处理中两个最重要的概念 —— 单词和句子。

3.1.2 嵌入为什么重要?

嵌入为什么重要?嗯,单词嵌入不仅重要,而且 至关重要 用于使用神经网络解决自然语言处理任务。神经网络是纯数学计算模型,只能处理数字。它们无法进行符号操作,例如连接两个字符串或使动词变为过去时,除非这些项目都用数字和算术操作表示。另一方面,自然语言处理中的几乎所有内容,如单词和标签,都是符号和离散的。这就是为什么你需要连接这两个世界,使用嵌入就是一种方法。请参阅图 3.2,了解如何在自然语言处理应用中使用单词嵌入的概述。

CH03_F02_Hagiwara

图 3.2 使用词嵌入与 NLP 模型

词嵌入,就像任何其他神经网络模型一样,可以进行训练,因为它们只是一组参数(或“魔法常数”,我们在上一章中谈到过)。词嵌入在以下三种情况下与您的 NLP 模型一起使用:

  • 情况 1:同时使用任务的训练集训练词嵌入和您的模型。

  • 情况 2:首先,独立训练词嵌入使用更大的文本数据集。或者,从其他地方获取预训练的词嵌入。然后使用预训练的词嵌入初始化您的模型,并同时使用任务的训练集对它们和您的模型进行训练。

  • 情况 3:与情况 2 相同,除了您在训练模型时固定词嵌入。

在第一种情况下,词嵌入是随机初始化的,并且与您的 NLP 模型一起使用相同的数据集进行训练。这基本上就是我们在第二章中构建情感分析器的方式。用一个类比来说,这就像一个舞蹈老师同时教一个婴儿走路和跳舞。这并不是完全不可能的事情(事实上,有些婴儿可能通过跳过走路部分而成为更好、更有创意的舞者,但不要在家里尝试这样做),但很少是一个好主意。如果先教会婴儿正确站立和行走,然后再教会他们如何跳舞,他们可能会有更好的机会。

类似地,同时训练 NLP 模型和其子组件词嵌入并不罕见。但是,许多大规模、高性能的 NLP 模型通常依赖于使用更大数据集预训练的外部词嵌入(情况 2 和 3)。词嵌入可以从未标记的大型文本数据集中学习,即大量的纯文本数据(例如维基百科转储),这通常比用于任务的训练数据集(例如斯坦福情感树库)更容易获得。通过利用这样的大量文本数据,您可以在模型看到任务数据集中的任何实例之前就向其教授关于自然语言的许多知识。在一个任务上训练机器学习模型,然后为另一个任务重新利用它被称为迁移学习,这在许多机器学习领域中,包括 NLP 在内,变得越来越受欢迎。我们将在第九章进一步讨论迁移学习。

再次使用跳舞婴儿的类比,大多数健康的婴儿都会自己学会站立和行走。他们可能会得到一些成人的帮助,通常来自他们的亲近照顾者,比如父母。然而,这种“帮助”通常比从聘请的舞蹈老师那里得到的“训练信号”丰富得多,也更便宜,这就是为什么如果他们先学会走路,然后再学会跳舞,效果会更好的原因。许多用于行走的技能会转移到跳舞上。

方案 2 和方案 3 之间的区别在于,在训练 NLP 模型时是否调整了词嵌入,或者精调了词嵌入。这是否有效可能取决于你的任务和数据集。教你的幼儿芭蕾可能会对他们的步履有好处(例如通过改善他们的姿势),从而可能对他们的舞蹈有积极的影响,但是方案 3 不允许发生这种情况。

你可能会问的最后一个问题是:嵌入是怎么来的呢?之前我提到过,它们可以从大量的纯文本中进行训练。本章将解释这是如何实现的以及使用了哪些模型。

3.2 语言的基本单元:字符、词和短语

在解释词嵌入模型之前,我会简单介绍一些语言的基本概念,如字符、词和短语。当你设计你的 NLP 应用程序的结构时,了解这些概念将会有所帮助。图 3.3 展示了一些例子。

3.2.1 字符

字符(在语言学中也称为字形)是书写系统中的最小单位。在英语中,"a"、"b"和"z"都是字符。字符本身并不一定具有意义,也不一定在口语中代表任何固定的声音,尽管在某些语言中(例如中文)大多数字符都有这样的特点。许多语言中的典型字符可以用单个 Unicode 代码点(通过 Python 中的字符串文字,如"\uXXXX")表示,但并非总是如此。许多语言使用多个 Unicode 代码点的组合(例如重音符号)来表示一个字符。标点符号,如"."(句号)、","(逗号)和"?"(问号),也是字符。

CH03_F03_Hagiwara

图 3.3 NLP 中使用的语言基本单元

3.2.2 词、标记、词素和短语

是语言中可以独立发音并通常具有一定意义的最小单位。在英语中,"apple"、"banana"和"zebra"都是词。在大多数使用字母脚本的书面语言中,词通常由空格或标点符号分隔。然而,在一些语言(如中文、日文和泰文)中,词并没有明确由空格分隔,并且需要通过预处理步骤(称为分词)来识别句子中的词。

NLP 中与单词相关的一个概念是标记。标记是在书面语言中扮演特定角色的连续字符字符串。大多数单词(“苹果”,“香蕉”,“斑马”)在书写时也是标记。标点符号(如感叹号“!”)是标记,但不是单词,因为不能单独发音。在 NLP 中,“单词”和“标记”通常可以互换使用。实际上,在 NLP 文本(包括本书)中,当你看到“单词”时,通常指的是“标记”,因为大多数 NLP 任务只处理以自动方式处理的书面文本。标记是一个称为标记化的过程的输出,我将在下面进行详细解释。

另一个相关概念是形态素。形态素是语言中的最小意义单位。一个典型的单词由一个或多个形态素组成。例如,“苹果”既是一个单词,也是一个形态素。“苹果”是由两个形态素“苹果”和“-s”组成的单词,用来表示名词的复数形式。英语中还包含许多其他形态素,包括“-ing”,“-ly”,“-ness”和“un-”。在单词或句子中识别形态素的过程称为形态分析,它在 NLP/语言学应用中有广泛的应用,但超出了本书的范围。

短语是一组在语法角色上扮演特定角色的单词。例如,“the quick brown fox”是一个名词短语(像一个名词那样表现的一组词),而“jumps over the lazy dog”是一个动词短语。在 NLP 中,短语的概念可能被宽泛地用来表示任何一组单词。例如,在许多 NLP 文献和任务中,像“洛杉矶”这样的词被视为短语,虽然在语言学上,它们更接近一个词。

3.2.3 N-grams

最后,在 NLP 中,你可能会遇到n-gram的概念。n-gram 是一个或多个语言单位(如字符和单词)的连续序列。例如,一个单词 n-gram 是一个连续的单词序列,如“the”(一个单词),“quick brown”(两个单词),“brown fox jumps”(三个单词)。同样,字符 n-gram 由字符组成,例如“b”(一个字符),“br”(两个字符),“row”(三个字符)等,它们都是由“brown”组成的字符 n-gram。当 n = 1 时,大小为 1 的 n-gram 称为unigram。大小为 2 和 3 的 n-gram 分别被称为bigramtrigram

在 NLP 中,单词 n-gram 通常被用作短语的代理,因为如果枚举一个句子的所有 n-gram,它们通常包含语言上有趣的单元,与短语(例如“洛杉矶”和“起飞”)对应。类似地,当我们想捕捉大致对应于形态素的子词单位时,我们使用字符 n-gram。在 NLP 中,当你看到“n-grams”(没有限定词)时,它们通常是单词n-gram。

注意:有趣的是,在搜索和信息检索中,n-grams 通常指用于索引文档的字符 n-grams。当你阅读论文时,要注意上下文暗示的是哪种类型的 n-grams。

3.3 分词、词干提取和词形还原

我们介绍了在自然语言处理中经常遇到的一些基本语言单位。在本节中,我将介绍一些典型自然语言处理流水线中处理语言单位的步骤。

3.3.1 分词

分词 是将输入文本分割成较小单元的过程。有两种类型的分词:单词分词和句子分词。单词分词 将一个句子分割成标记(大致相当于单词和标点符号),我之前提到过。句子分词 则将可能包含多个句子的文本分割成单个句子。如果说分词,通常指的是 NLP 中的单词分词。

许多自然语言处理库和框架支持分词,因为它是自然语言处理中最基本且最常用的预处理步骤之一。接下来,我将向你展示如何使用两个流行的自然语言处理库——NLTK (www.nltk.org/) 和 spaCy (spacy.io/) 进行分词。

注意:在运行本节示例代码之前,请确保两个库都已安装。在典型的 Python 环境中,可以通过运行 pip install nltk 和 pip install spacy 进行安装。安装完成后,你需要通过命令行运行 python -c "import nltk; nltk.download('punkt')" 来下载 NLTK 所需的数据和模型,以及通过 python -m spacy download en 来下载 spaCy 所需的数据和模型。你还可以通过 Google Colab (realworldnlpbook.com/ch3.html#tokeni zation) 在不安装任何 Python 环境或依赖项的情况下运行本节中的所有示例。

要使用 NLTK 的默认单词和句子分词器,你可以从 nltk.tokenize 包中导入它们,如下所示:

>>> import nltk
>>> from nltk.tokenize import word_tokenize, sent_tokenize

你可以用一个字符串调用这些方法,它们会返回一个单词或句子的列表,如下所示:

>>> s = '''Good muffins cost $3.88\nin New York.  Please buy me two of them.\n\nThanks.'''

>>> word_tokenize(s)
['Good', 'muffins', 'cost', '$', '3.88', 'in', 'New', 'York', '.', 'Please',
 'buy', 'me', 'two', 'of', 'them', '.', 'Thanks', '.']

>>> sent_tokenize(s)
['Good muffins cost $3.88\nin New York.', 'Please buy me two of them.',
 'Thanks.']

NLTK 实现了除我们在此使用的默认方法之外的各种分词器。如果你有兴趣探索更多选项,可以参考其文档页面 (www.nltk.org/api/nltk.tokenize.html)。

你可以使用 spaCy 如下进行单词和句子的分词:

>>> import spacy
>>> nlp = spacy.load('en_core_web_sm')

>>> doc = nlp(s)

>>> [token.text for token in doc]
['Good', 'muffins', 'cost', '$', '3.88', '\n', 'in', 'New', 'York', '.', ' ',
 'Please', 'buy', 'me', 'two', 'of', 'them', '.', '\n\n', 'Thanks', '.']

>>> [sent.string.strip() for sent in doc.sents]
['Good muffins cost $3.88\nin New York.', 'Please buy me two of them.', 'Thanks.']

请注意,NLTK 和 spaCy 的结果略有不同。例如,spaCy 的单词分词器保留换行符('\n')。标记器的行为因实现而异,并且没有每个自然语言处理从业者都同意的单一标准解决方案。尽管标准库(如 NLTK 和 spaCy)提供了一个良好的基线,但根据您的任务和数据,准备好进行实验。此外,如果您处理的是英语以外的语言,则您的选择可能会有所不同(并且可能会根据语言而有所限制)。如果您熟悉 Java 生态系统,Stanford CoreNLP(stanfordnlp.github.io/CoreNLP/)是另一个值得一试的良好自然语言处理框架。

最后,用于基于神经网络的自然语言处理模型的一个日益流行和重要的标记化方法是字节对编码(BPE)。字节对编码是一种纯统计技术,将文本分割成任何语言的字符序列,不依赖于启发式规则(如空格和标点符号),而只依赖于数据集中的字符统计信息。我们将在第十章更深入地学习字节对编码。

3.3.2 词干提取

词干提取是识别词干的过程。词干是在去除其词缀(前缀和后缀)后的单词的主要部分。例如,“apples”(复数)的词干是“apple”。具有第三人称单数 s 的“meets”的词干是“meet”。“unbelievable”的词干是“believe”。它通常是在屈折后保持不变的一部分。

词干提取——即将单词规范化为更接近其原始形式的东西——在许多自然语言处理应用中具有巨大的好处。例如,在搜索中,如果使用单词干而不是单词对文档进行索引,可以提高检索相关文档的机会。在许多基于特征的自然语言处理流水线中,您可以通过处理单词干来减轻 OOV(词汇外)问题。例如,即使您的字典中没有“apples”的条目,您也可以使用其词干“apple”作为代理。

用于提取英语单词词干的最流行算法称为波特词干提取算法,最初由马丁·波特编写。它由一系列用于重写词缀的规则组成(例如,如果一个词以“-ization”结尾,将其更改为“-ize”)。NLTK 实现了该算法的一个版本,称为 PorterStemmer 类,可以如下使用:

>>> from nltk.stem.porter import PorterStemmer
>>> stemmer = PorterStemmer()

>>> words = ['caresses', 'flies', 'dies', 'mules', 'denied',
...          'died', 'agreed', 'owned', 'humbled', 'sized',
...          'meetings', 'stating', 'siezing', 'itemization',
...          'sensational', 'traditional', 'reference', 'colonizer',
...          'plotted']
>>> [stemmer.stem(word) for word in words]
['caress', 'fli', 'die', 'mule', 'deni',
 'die', 'agre', 'own', 'humbl', 'size',
 'meet', 'state', 'siez', 'item',
 'sensat', 'tradit', 'refer', 'colon',
 'plot']

词干提取并非没有局限性。在许多情况下,它可能过于激进。例如,你可以从上一个例子中看到,波特词干提取算法将“colonizer”和“colonize”都改为了“colon”。我无法想象有多少应用会愿意将这三个词视为相同的条目。此外,许多词干提取算法不考虑上下文甚至词性。在前面的例子中,“meetings” 被改为 “meet”,但你可以争辩说 “meetings” 作为复数名词应该被提取为 “meeting”,而不是 “meet”。因此,截至今天,很少有自然语言处理应用使用词干提取。

3.3.3 词形还原

词元是单词的原始形式,通常在字典中作为首字母出现。它也是词在屈折之前的基本形式。例如,“meetings”(作为复数名词)的词元是“meeting”。 “met”(动词过去式)的词元是“meet”。注意它与词干提取的区别,词干提取仅仅是从单词中剥离词缀,无法处理此类不规则的动词和名词。

使用 NLTK 运行词形还原很简单,如下所示:

>>> from nltk.stem import WordNetLemmatizer
>>> lemmatizer = WordNetLemmatizer()

>>> [lemmatizer.lemmatize(word) for word in words]
['caress', 'fly', 'dy', 'mule', 'denied',
 'died', 'agreed', 'owned', 'humbled', 'sized',
 'meeting', 'stating', 'siezing', 'itemization',
 'sensational', 'traditional', 'reference', 'colonizer',
 'plotted']

spaCy 的代码看起来像这样:

>>> doc = nlp(' '.join(words))
>>> [token.lemma_ for token in doc]
['caress', 'fly', 'die', 'mule', 'deny',
 'die', 'agree', 'own', 'humble', 'sized',
 'meeting', 'state', 'siezing', 'itemization',
 'sensational', 'traditional', 'reference', 'colonizer',
 'plot']

请注意,词形还原固有地要求您知道输入单词的词性,因为词形还原取决于它。例如,“meeting” 作为名词应该被还原为 “meeting”,而如果是动词,结果应该是 “meet”。NLTK 中的 WordNetLemmatizer 默认将所有内容视为名词,这就是为什么在结果中看到许多未还原的单词(“agreed”,“owned” 等)。另一方面,spaCy 可以从单词形式和上下文中自动推断词性,这就是为什么大多数还原的单词在其结果中是正确的。词形还原比词干提取更加耗费资源,因为它需要对输入进行统计分析和/或某种形式的语言资源(如字典),但由于其语言正确性,它在自然语言处理中有更广泛的应用。

3.4 Skip-gram 和 连续词袋(CBOW)

在之前的章节中,我解释了词嵌入是什么以及它们如何在自然语言处理应用中使用。在这一节中,我们将开始探索如何使用两种流行的算法——Skip-gram 和 CBOW——从大量文本数据中计算词嵌入。

3.4.1 词嵌入来自何处

在第 3.1 节中,我解释了词嵌入是如何用单个浮点数数组表示词汇表中的每个单词的:

  • vec("cat") = [0.7, 0.5, 0.1]

  • vec("dog") = [0.8, 0.3, 0.1]

  • vec("pizza") = [0.1, 0.2, 0.8]

现在,到目前为止的讨论中缺少一条重要信息。这些数字从哪里来?我们雇用一群专家让他们提出这些数字吗?这几乎是不可能的手动分配它们。在典型的大型语料库中存在数十万个唯一的词,而数组应该至少长达 100 维才能有效,这意味着你需要调整超过数千万个数字。

更重要的是,这些数字应该是什么样子的?你如何确定是否应该为“狗”向量的第一个元素分配 0.8,还是 0.7,或者其他任何数字?

答案是,这些数字也是使用训练数据集和像本书中的任何其他模型一样的机器学习模型进行训练的。接下来,我将介绍并实现训练词嵌入最流行的模型之一——Skip-gram 模型。

3.4.2 使用词语关联

首先,让我们退一步思考人类是如何学习“一只狗”等概念的。我不认为你们中的任何人曾经被明确告知过什么是狗。自从你还是一个蹒跚学步的孩童时,你就知道了这个叫做“狗”的东西,而没有任何其他人告诉你,“哦,顺便说一下,这个世界上有一种叫‘狗’的东西。它是一种四条腿的动物,会叫。”这是怎么可能的呢?你通过大量与外部世界的物理(触摸和闻到狗)、认知(看到和听到狗)和语言(阅读和听到有关狗的信息)的互动获得了这个概念。

现在让我们想一想教计算机“狗”这个概念需要什么条件。我们能让计算机“体验”与与“狗”概念相关的外部世界的互动吗?尽管典型的计算机不能四处移动并与实际狗有互动(截至目前为止,写作本文时还不能),但不教计算机“狗”是有可能的一种方法是使用与其他单词的关联。例如,如果你查看大型文本语料库中“狗”的出现,哪些词倾向于与之一起出现?“宠物”、“尾巴”、“气味”、“吠叫”、“小狗”——可能有无数个选项。那“猫”呢?也许是“宠物”、“尾巴”、“毛皮”、“喵喵叫”、“小猫”等等。因为“狗”和“猫”在概念上有很多共同之处(它们都是流行的宠物动物,有尾巴等),所以这两组上下文词也有很大的重叠。换句话说,你可以通过查看同一上下文中出现的其他单词来猜测两个单词彼此之间有多接近。这被称为分布假设,在自然语言处理中有着悠久的历史。

注意在人工智能中有一个相关术语——分布式表示。单词的分布式表示简单地是词嵌入的另一个名称。是的,这很令人困惑,但这两个术语在自然语言处理中都是常用的。

我们现在已经更近了一步。如果两个单词有很多上下文单词是共同的,我们可以给这两个单词分配相似的向量。你可以将一个单词向量看作是其上下文单词的“压缩”表示。那么问题就变成了:如何“解压缩”一个单词向量以获取其上下文单词?如何甚至在数学上表示一组上下文单词?从概念上讲,我们希望设计一个模型,类似于图 3.4 中的模型。

CH03_F04_Hagiwara

图 3.4 解压缩一个单词向量

表示一组单词的一种数学方法是为词汇表中的每个单词分配一个分数。我们可以将上下文单词表示为一个关联数组(在 Python 中为字典),从单词到它们与“dog”相关程度的“分数”,如下所示:

{"bark": 1.4,
 "chocolate": 0.1,
 ...,
 "pet": 1.2,
 ...,
 "smell": 0.6,
 ...}

模型的唯一剩下的部分是如何生成这些“分数”。如果你按单词 ID(可能按字母顺序分配)对这个列表进行排序,那么这些分数可以方便地由一个 N 维向量表示,其中 N 是整个词汇表的大小(我们考虑的唯一上下文单词的数量),如下所示:

[1.4, 0.1, ..., 1.2, ..., 0.6, ...]

“解压缩器”唯一需要做的就是将单词嵌入向量(具有三个维度)扩展到另一个 N 维向量。

这可能对你们中的一些人听起来非常熟悉 — 是的,这正是线性层(也称为全连接层)所做的。我在第 2.4.2 节简要讨论了线性层,但现在正是深入了解它们真正作用的好时机。

3.4.3 线性层

线性层以线性方式将一个向量转换为另一个向量,但它们究竟是如何做到这一点的呢?在讨论向量之前,让我们简化并从数字开始。你如何编写一个函数(比如说,在 Python 中的一个方法),以线性方式将一个数字转换为另一个数字?记住,线性意味着如果你将输入增加 1,输出总是以固定量(比如说,w)增加,无论输入的值是多少。例如,2.0 * x 是一个线性函数,因为如果你将 x 增加 1,值始终增加 2.0,无论 x 的值是多少。你可以写一个这样的函数的一般版本,如下所示:

def linear(x):
    return w * x + b

现在假设参数 w 和 b 是固定的,并在其他地方定义。你可以确认输出(返回值)在增加或减少 x 1 时始终会变化 w。当 x = 0 时,b 是输出的值。这在机器学习中称为偏差

现在,假设有两个输入变量,比如,x1 和 x2?你是否仍然可以编写一个函数,将两个输入变量线性转换为另一个数字?是的,这样做所需的改变非常少,如下所示:

def linear2(x1, x2):
    return w1 * x1 + w2 * x2 + b

你可以通过检查输出变量的值来确认其线性性,如果你将 x1 增加 1,输出变量的值将增加 w1;如果你将 x2 增加 1,输出变量的值将增加 w2,而不管其他变量的值如何。偏差 b 仍然是当 x1 和 x2 都为 0 时的输出值。

例如,假设我们有 w1 = 2.0,w2 = -1.0,b = 1。对于输入(1,1),该函数返回 2。如果你增加 x1 1 并将(2,1)作为输入,你将得到 4,比 2 多 w1。如果你增加 x2 1 并将(1,2)作为输入,你将得到 1,比 2 少 1(或比 w2 多)。

在这一点上,我们可以开始思考将其推广到向量。如果有两个输出变量,比如 y1 和 y2,会怎么样?你能否仍然写出关于这两个输入的线性函数?是的,你可以简单地两次复制线性变换,使用不同的权重和偏差,如下所示:

def linear3(x1, x2):
    y1 = w11 * x1 + w12 * x2 + b1
    y2 = w21 * x1 + w22 * x2 + b2
    return [y1, y2]

好的,情况有点复杂,但你实际上编写了一个将二维向量转换为另一个二维向量的线性层函数!如果你增加输入维度(输入变量的数量),这个方法将变得水平更长(即每行增加更多的加法),而如果你增加输出维度,这个方法将变得垂直更长(即更多的行)。

在实践中,深度学习库和框架以一种更高效、通用的方式实现线性层,并且通常大部分计算发生在 GPU 上。然而,从概念上了解线性层——神经网络最重要、最简单的形式,对理解更复杂的神经网络模型应该是至关重要的。

注意:在人工智能文献中,你可能会遇到感知器的概念。感知器是一个只有一个输出变量的线性层,应用于分类问题。如果你堆叠多个线性层(= 感知器),你就得到了多层感知器,这基本上是另一个称为具有一些特定结构的前馈神经网络。

最后,你可能想知道本节中看到的常数 w 和 b 是从哪里来的。这些正是我在第 2.4.1 节中谈到的“魔法常数”。你调整这些常数,使线性层(以及整个神经网络)的输出通过优化过程更接近你想要的结果。这些魔法常数也被称为机器学习模型的参数

把所有这些放在一起,我们希望 Skip-gram 模型的结构如图 3.5 所示。这个网络非常简单。它以一个词嵌入作为输入,并通过一个线性层扩展到一组分数,每个上下文词一个。希望这不会像许多人想象的那样令人畏惧!

CH03_F05_Hagiwara

图 3.5 Skip-gram 模型结构

3.4.4 Softmax

现在让我们来讨论如何“训练”Skip-gram 模型并学习我们想要的单词嵌入。关键在于将这个过程转化为一个分类任务,在这个任务中,模型预测哪些单词出现在上下文中。这里的“上下文”指的只是一个固定大小的窗口(例如,上下各 5 个单词),窗口以目标单词(例如,“狗”)为中心。当窗口大小为 2 时,请参见图 3.6 以便了解。实际上,这是一个“假”的任务,因为我们对模型的预测本身并不感兴趣,而是对训练模型时产生的副产品(单词嵌入)感兴趣。在机器学习和自然语言处理中,我们经常虚构一个假任务来训练另一些东西作为副产品。

CH03_F06_Hagiwara

图 3.6 目标单词和上下文单词(窗口大小=2 时)

注意 这种机器学习设置,其中训练标签是从给定数据集自动生成的,也可以称为自监督学习。最近流行的技术,如单词嵌入和语言建模,都使用了自监督学习。

使神经网络解决分类任务相对容易。你需要做以下两件事情:

  • 修改网络,以便产生概率分布。

  • 使用交叉熵作为损失函数(我们马上会详细介绍这一点)。

你可以使用称为softmax的东西来进行第一个。Softmax 是一个函数,它将K个浮点数向量转化为概率分布,首先“压缩”这些数字,使其适合 0.0-1.0 的范围,然后将它们归一化,使其总和等于 1。如果你对概率的概念不熟悉,请将其替换为置信度。概率分布是网络对个别预测(在这种情况下,上下文单词)的置信度值的集合。Softmax 在保持输入浮点数的相对顺序的同时执行所有这些操作,因此大的输入数字仍然在输出分布中具有大的概率值。图 3.7 以概念上的方式说明了这一点。

CH03_F07_Hagiwara

图 3.7 软最大值

将神经网络转化为分类器所需的另一个组件是交叉熵。交叉熵是一种用于衡量两个概率分布之间距离的损失函数。如果两个分布完全匹配,则返回零,如果两个分布不同,则返回较高的值。对于分类任务,我们使用交叉熵来比较以下内容:

  • 神经网络产生的预测概率分布(softmax 的输出)

  • 目标概率分布,其中正确类别的概率为 1.0,其他的都是 0.0

Skip-gram 模型的预测逐渐接近实际的上下文词,同时学习单词嵌入。

在 AllenNLP 上实现 Skip-gram

使用 AllenNLP 将这个模型转化为可工作的代码相对直接。请注意,本节中列出的所有代码都可以在 Google Colab 笔记本上执行(realworldnlpbook.com/ch3.html#word2vec-nb)。首先,你需要实现一个数据集读取器,该读取器将读取一个纯文本语料库,并将其转换为 Skip-gram 模型可以使用的一组实例。数据集读取器的详细信息对于本讨论并不关键,因此我将省略完整的代码清单。你可以克隆本书的代码仓库(github.com/mhagiwara/realworldnlp),并按照以下方式导入它:

from examples.embeddings.word2vec import SkipGramReader

或者,如果你有兴趣,你可以从 realworldnlpbook.com/ch3.html#word2vec 查看完整的代码。你可以按照以下方式使用读取器:

reader = SkipGramReader()
text8 = reader.read('https:/./realworldnlpbook.s3.amazonaws.com/data/text8/text8')

此外,在这个示例中,请确保导入所有必要的模块并定义一些常量,如下所示:

from collections import Counter

import torch
import torch.optim as optim
from allennlp.data.data_loaders import SimpleDataLoader
from allennlp.data.vocabulary import Vocabulary
from allennlp.models import Model
from allennlp.modules.token_embedders import Embedding
from allennlp.training.trainer import GradientDescentTrainer
from torch.nn import CosineSimilarity
from torch.nn import functional

EMBEDDING_DIM = 256
BATCH_SIZE = 256

在这个示例中,我们将使用 text8(mattmahoney.net/dc/textdata)数据集。该数据集是维基百科的一部分,经常用于训练玩具词嵌入和语言模型。你可以迭代数据集中的实例。token_in 是模型的输入标记,token_out 是输出(上下文词):

>>> for inst in text8:
>>>     print(inst)
...
Instance with fields:
     token_in: LabelField with label: ideas in namespace: 'token_in'.'
     token_out: LabelField with label: us in namespace: 'token_out'.'

Instance with fields:
     token_in: LabelField with label: ideas in namespace: 'token_in'.'
     token_out: LabelField with label: published in namespace: 'token_out'.'

Instance with fields:
     token_in: LabelField with label: ideas in namespace: 'token_in'.'
     token_out: LabelField with label: journal in namespace: 'token_out'.'

Instance with fields:
     token_in: LabelField with label: in in namespace: 'token_in'.'
     token_out: LabelField with label: nature in namespace: 'token_out'.'

Instance with fields:
     token_in: LabelField with label: in in namespace: 'token_in'.'
     token_out: LabelField with label: he in namespace: 'token_out'.'

Instance with fields:
     token_in: LabelField with label: in in namespace: 'token_in'.'
     token_out: LabelField with label: announced in namespace: 'token_out'.'
...

然后,你可以构建词汇表,就像我们在第二章中所做的那样,如下所示:

vocab = Vocabulary.from_instances(
    text8, min_count={'token_in': 5, 'token_out': 5})

注意,我们使用了 min_count 参数,该参数设置了每个 token 出现的最低限制。此外,让我们按照以下方式定义用于训练的数据加载器:

data_loader = SimpleDataLoader(text8, batch_size=BATCH_SIZE)
data_loader.index_with(vocab)

然后,我们定义一个包含所有要学习的词嵌入的嵌入对象:

embedding_in = Embedding(num_embeddings=vocab.get_vocab_size('token_in'),
                         embedding_dim=EMBEDDING_DIM)

这里,EMBEDDING_DIM 是每个单词向量的长度(浮点数的数量)。一个典型的 NLP 应用程序使用几百维的单词向量(在本例中为 256),但该值在任务和数据集上有很大的依赖性。通常建议随着训练数据的增长使用更长的单词向量。

最后,你需要实现 Skip-gram 模型的主体,如下所示。

清单 3.1 在 AllenNLP 中实现的 Skip-gram 模型。

class SkipGramModel(Model):                                   ❶

    def __init__(self, vocab, embedding_in):
        super().__init__(vocab)

        self.embedding_in = embedding_in                      ❷

        self.linear = torch.nn.Linear(
            in_features=EMBEDDING_DIM,
            out_features=vocab.get_vocab_size('token_out'),
            bias=False)                                       ❸

    def forward(self, token_in, token_out):                   ❹

        embedded_in = self.embedding_in(token_in)             ❺

        logits = self.linear(embedded_in)                     ❻

        loss = functional.cross_entropy(logits, token_out)    ❼

        return {'loss': loss}

❶ AllenNLP 要求每个模型都必须继承自 Model。

❷ 嵌入对象从外部传入,而不是在内部定义。

❸ 这创建了一个线性层(请注意我们不需要偏置)。

❹ 神经网络计算的主体在 forward() 中实现。

❺ 将输入张量(单词 ID)转换为单词嵌入。

❻ 应用线性层。

❼ 计算损失。

注意以下几点:

  • AllenNLP 要求每个模型都必须继承自 allennlp.models.Model。

  • 模型的初始化函数(init)接受一个 Vocabulary 实例和定义在外部的任何其他参数或子模型。它还定义任何内部的参数或模型。

  • 模型的主要计算是在 forward()中定义的。它将实例中的所有字段(在这个例子中是 token_in 和 token_out)作为张量(多维数组),并返回一个包含'loss'键的字典,该键将被优化器用于训练模型。

你可以使用以下代码来训练这个模型。

列表 3.2 训练 Skip-gram 模型的代码

reader = SkipGramReader()
text8 = reader.read(' https:/./realworldnlpbook.s3.amazonaws.com/data/text8/text8')

vocab = Vocabulary.from_instances(
    text8, min_count={'token_in': 5, 'token_out': 5})

data_loader = SimpleDataLoader(text8, batch_size=BATCH_SIZE)
data_loader.index_with(vocab)

embedding_in = Embedding(num_embeddings=vocab.get_vocab_size('token_in'),
                         embedding_dim=EMBEDDING_DIM)

model = SkipGramModel(vocab=vocab,
                      embedding_in=embedding_in)
optimizer = optim.Adam(model.parameters())

trainer = GradientDescentTrainer(
    model=model,
    optimizer=optimizer,
    data_loader=data_loader,
    num_epochs=5,
    cuda_device=CUDA_DEVICE)
trainer.train()

训练需要一段时间,所以我建议首先截取训练数据,比如只使用前一百万个标记。你可以在 reader.read()后插入 text8 = list(text8)[:1000000]。训练结束后,你可以使用列表 3.3 中展示的方法获取相关词(具有相同意义的词)。这个方法首先获取给定词(标记)的词向量,然后计算它与词汇表中的每个其他词向量的相似度。相似性是使用所谓的余弦相似度来计算的。简单来说,余弦相似度是两个向量之间角度的反义词。如果两个向量相同,那么它们之间的角度就是零,相似度就是 1,这是可能的最大值。如果两个向量是垂直的,角度是 90 度,余弦相似度就是 0。如果向量完全相反,余弦相似度就是-1。

列表 3.3 使用词嵌入获取相关词的方法

def get_related(token: str, embedding: Model, vocab: Vocabulary, 
                num_synonyms: int = 10):
    token_id = vocab.get_token_index(token, 'token_in')
    token_vec = embedding.weight[token_id]
    cosine = CosineSimilarity(dim=0)
    sims = Counter()

    for index, token in vocab.get_index_to_token_vocabulary('token_in').items():
        sim = cosine(token_vec, embedding.weight[index]).item()
        sims[token] = sim

    return sims.most_common(num_synonyms)

如果你对“one”和“december”这两个词运行这个模型,你将得到表 3.1 中展示的相关词列表。虽然你可能会看到一些与查询词无关的词,但整体上,结果看起来很不错。

表 3.1 “one” 和 “december”的相关词

“一” “十二月”
十二月
一月
尼克斯
伦敦
植物
六月
斯密森
二月
d 卡努尼
女演员 十月

最后一点说明:如果你想要在实践中使用 Skip-gram 来训练高质量的词向量,你需要实现一些技术,即负采样和高频词子采样。虽然它们是重要的概念,但如果你刚开始学习,并想了解自然语言处理的基础知识,它们可能会分散你的注意力。如果你对了解更多感兴趣,请查看我在这个主题上写的这篇博客文章:realworldnlpbook.com/ch3.html#word2vec-blog

3.4.6 连续词袋(CBOW)模型

通常与 Skip-gram 模型一起提到的另一个词嵌入模型是连续词袋(CBOW)模型。作为 Skip-gram 模型的近亲,同时提出(realworldnlpbook.com/ch3.html# mikolov13),CBOW 模型的结构与 Skip-gram 模型相似,但上下颠倒。该模型试图解决的“假”任务是从一组上下文词预测目标词。这与填空题类型的问题类似。例如,如果你看到一个句子“I heard a ___ barking in the distance.”,大多数人可能会立即猜到答案“dog”。图 3.8 显示了该模型的结构。

CH03_F08_Hagiwara

图 3.8 连续词袋 (CBOW) 模型

我不打算在这里从头实现 CBOW 模型,有几个原因。如果你理解了 Skip-gram 模型,实现 CBOW 模型应该很简单。此外,CBOW 模型在词语语义任务上的准确性通常略低于 Skip-gram 模型,并且 CBOW 在 NLP 中使用的频率也较低。这两个模型都在原始的 Word2vec (code .google.com/archive/p/word2vec/) 工具包中实现,如果你想自己尝试它们,尽管如此,由于更近期、更强大的词嵌入模型的出现(例如 GloVe 和 fastText),这些基本的 Skip-gram 和 CBOW 模型现在使用得越来越少,这些模型在本章的其余部分中介绍。

3.5 GloVe

在前一节中,我实现了 Skip-gram,并展示了如何利用大量文本数据训练词嵌入。但是如果你想构建自己的 NLP 应用程序,利用高质量的词嵌入,同时避开所有麻烦呢?如果你无法承担训练词嵌入所需的计算和数据呢?

与训练词嵌入相反,你总是可以下载其他人发布的预训练词嵌入,这是许多 NLP 从业者做的。在本节中,我将介绍另一种流行的词嵌入模型——GloVe,名为 Global Vectors。由 GloVe 生成的预训练词嵌入可能是当今 NLP 应用中最广泛使用的嵌入。

3.5.1 GloVe 如何学习词嵌入

之前描述的两个模型与 GloVe 的主要区别在于前者是局部的。简而言之,Skip-gram 使用预测任务,其中上下文词(“bark”)从目标词(“dog”)预测。CBOW 基本上做相反的事情。这个过程重复了数据集中的每个单词标记的次数。它基本上扫描整个数据集,并询问:“这个词可以从另一个词预测吗?”对于数据集中每个单词的每次出现都会问这个问题。

让我们思考一下这个算法的效率。如果数据集中有两个或更多相同的句子呢?或者非常相似的句子呢?在这种情况下,Skip-gram 将重复多次相同的一组更新。你可能会问,“‘bark’ 可以从 ‘dog’ 预测吗?” 但很有可能你在几百个句子前已经问过了完全相同的问题。如果你知道 “dog” 和 “bark” 这两个词在整个数据集中共同出现了 N 次,那为什么要重复这 N 次呢?这就好像你在把 “1” 加 N 次到另一个东西上(x + 1 + 1 + 1 + ... + 1),而你其实可以直接加 N 到它上面(x + N)。我们能否直接利用这个 全局 信息呢?

GloVe 的设计受到这一见解的启发。它不是使用局部单词共现,而是使用整个数据集中的聚合单词共现统计信息。假设 “dog” 和 “bark” 在数据集中共同出现了 N 次。我不会深入讨论模型的细节,但粗略地说,GloVe 模型试图从两个单词的嵌入中预测这个数字 N。图 3.9 描绘了这个预测任务。它仍然对单词关系进行了一些预测,但请注意,它对于每个单词 类型 的组合进行了一次预测,而 Skip-gram 则对每个单词 标记 的组合进行了预测!

CH03_F09_Hagiwara

图 3.9 GloVe

标记和类型 如第 3.3.1 节所述,标记 是文本中单词的出现。一个语料库中可能会有同一个单词的多次出现。另一方面,类型 是一个独特的、唯一的词。例如,在句子 “A rose is a rose is a rose.” 中,有八个标记但只有三种类型(“a”,“rose”,和 “is”)。如果你熟悉面向对象编程,它们大致相当于实例和类。一个类可以有多个实例,但一个概念只有一个类。

3.5.2 使用预训练的 GloVe 向量

实际上,并不是很多自然语言处理(NLP)从业者自己从头开始训练 GloVe 向量。更常见的是,我们下载并使用预训练的词向量,这些词向量是使用大型文本语料库预训练的。这不仅快捷,而且通常有助于使您的 NLP 应用程序更准确,因为这些预训练的词向量(通常由词向量算法的发明者公开)通常是使用比我们大多数人负担得起的更大的数据集和更多的计算资源进行训练的。通过使用预训练的词向量,您可以“站在巨人的肩膀上”,并快速利用从大型文本语料库中提炼出的高质量语言知识。

在本节的其余部分,让我们看看如何使用预先训练的 GloVe 嵌入下载并搜索相似单词。首先,您需要下载数据文件。官方的 GloVe 网站(nlp.stanford.edu/projects/glove/)提供了使用不同数据集和向量大小训练的多个词嵌入文件。您可以选择任何一个(尽管取决于您选择的文件大小可能很大)并解压缩它。在接下来的内容中,我们假设您将其保存在相对路径 data/glove/下。

大多数词嵌入文件都以相似的方式格式化。每一行都包含一个单词,后跟一系列与其单词向量对应的数字。数字的数量与维度一样多(在上述网站上分发的 GloVe 文件中,您可以从文件名后缀中以 xxx 维的形式了解维度)。每个字段由一个空格分隔。以下是 GloVe 词嵌入文件的摘录:

...
if 0.15778 0.17928 -0.45811 -0.12817 0.367 0.18817 -4.5745 0.73647 ...
one 0.38661 0.33503 -0.25923 -0.19389 -0.037111 0.21012 -4.0948 0.68349 ...
has 0.08088 0.32472 0.12472 0.18509 0.49814 -0.27633 -3.6442 1.0011 ...
...

正如我们在第 3.4.5 节中所做的那样,我们想要做的是接受一个查询词(比如,“狗”)并在N维空间中找到它的邻居。一种方法是计算查询词与词汇表中的每个其他词之间的相似性,并按其相似性对单词进行排序,如清单 3.3 所示。根据词汇表的大小,这种方法可能非常缓慢。这就像线性扫描数组以找到元素而不是使用二进制搜索一样。

相反,我们将使用近似最近邻算法快速搜索相似单词。简而言之,这些算法使我们能够快速检索最近的邻居,而无需计算每个单词对之间的相似性。具体而言,我们将使用 Annoy(github.com/spotify/annoy)库,这是来自 Spotify 的用于近似最近邻搜索的库。您可以通过运行 pip install annoy 来安装它。它使用随机投影实现了一种流行的近似最近邻算法称为局部敏感哈希(LSH)。

要使用 Annoy 搜索相似的单词,您首先需要构建一个索引,可以按照清单 3.4 所示进行。请注意,我们还正在构建一个从单词索引到单词的字典,并将其保存到单独的文件中,以便以后方便进行单词查找(清单 3.5)。

清单 3.4 构建 Annoy 索引

from annoy import AnnoyIndex
import pickle

EMBEDDING_DIM = 300
GLOVE_FILE_PREFIX = 'data/glove/glove.42B.300d{}'

def build_index():
    num_trees = 10

    idx = AnnoyIndex(EMBEDDING_DIM)

    index_to_word = {}
    with open(GLOVE_FILE_PREFIX.format('.txt')) as f:
        for i, line in enumerate(f):
            fields = line.rstrip().split(' ')
            vec = [float(x) for x in fields[1:]]
            idx.add_item(i, vec)
            index_to_word[i] = fields[0]

    idx.build(num_trees)
    idx.save(GLOVE_FILE_PREFIX.format('.idx'))
    pickle.dump(index_to_word,
                open(GLOVE_FILE_PREFIX.format('.i2w'), mode='wb'))

读取 GloVe 嵌入文件并构建 Annoy 索引可能会相当慢,但一旦构建完成,访问它并检索相似单词的速度可以非常快。这种配置类似于搜索引擎,其中构建索引以实现几乎实时检索文档。这适用于需要实时检索相似项但数据集更新频率较低的应用程序。示例包括搜索引擎和推荐引擎。

清单 3.5 使用 Annoy 索引检索相似单词

def search(query, top_n=10):
    idx = AnnoyIndex(EMBEDDING_DIM)
    idx.load(GLOVE_FILE_PREFIX.format('.idx'))
    index_to_word = pickle.load(open(GLOVE_FILE_PREFIX.format('.i2w'),
                                     mode='rb'))
    word_to_index = {word: index for index, word in index_to_word.items()}

    query_id = word_to_index[query]
    word_ids = idx.get_nns_by_item(query_id, top_n)
    for word_id in word_ids:
        print(index_to_word[word_id])

如果你运行这个对于单词“狗”和“十二月”,你将得到表 3.2 中显示的与这两个单词最相关的 10 个单词列表。

表 3.2 “狗”和“十二月”的相关词

“狗” “十二月”
十二月
小狗 一月
十月
十一月
九月
婴儿 二月
公牛 八月
小孩 七月
孩子 四月
猴子 三月

你可以看到每个列表中包含与查询单词相关的许多单词。你会在每个列表的顶部看到相同的单词——这是因为两个相同向量的余弦相似度总是 1,它的最大可能值。

3.6 fastText

在前一节中,我们看到了如何下载预训练的单词嵌入并检索相关的单词。在本节中,我将解释如何使用自己的文本数据使用 fastText,一种流行的单词嵌入工具包,训练单词嵌入。当你的文本数据不是在普通领域(例如,医疗、金融、法律等)中,和/或者不是英文时,这将非常方便。

3.6.1 利用子词信息

到目前为止,在本章中我们看到的所有词嵌入方法都为每个单词分配了一个独特的单词向量。例如,“狗”和“猫”的单词向量被视为独立的,并且在训练时独立训练。乍一看,这似乎没有什么问题。毕竟,它们确实是不同的单词。但是,如果单词分别是“狗”和“小狗”呢?因为“-y”是一个表示亲近和喜爱的英语后缀(其他例子包括“奶奶”和“奶奶”、“小猫”和“小猫”),这些词对有一定的语义联系。然而,将单词视为独立的单词嵌入算法无法建立这种联系。在这些算法的眼中,“狗”和“小狗”只不过是 word_823 和 word_1719 而已。

这显然是局限性的。在大多数语言中,单词拼写(你如何书写)和单词语义(它们的意思)之间有着强烈的联系。例如,共享相同词根的单词(例如,“study”和“studied”、“repeat”和“repeatedly”以及“legal”和“illegal”)通常是相关的。通过将它们视为独立的单词,单词嵌入算法正在丢失很多信息。它们如何利用单词结构并反映所学单词嵌入中的相似性呢?

fastText,是 Facebook 开发的一种算法和词嵌入库,是这样一个模型。它使用子词信息,这意味着比单词更小的语言单位的信息,来训练更高质量的词嵌入。具体来说,fastText 将单词分解为字符 n-gram(第 3.2.3 节)并为它们学习嵌入。例如,如果目标单词是“doggy”,它首先在单词的开头和结尾添加特殊符号并为<do,dog,ogg,ggy,gy>学习嵌入,当 n=3。 “doggy”的向量只是所有这些向量的总和。其余的架构与 Skip-gram 的架构非常相似。图 3.10 显示了 fastText 模型的结构。

CH03_F10_Hagiwara

图 3.10 fastText 的架构

利用子词信息的另一个好处是可以减轻词汇外(OOV)问题。许多 NLP 应用和模型假设一个固定的词汇表。例如,典型的词嵌入算法如 Skip-gram 只学习在训练集中遇到的单词的词向量。但是,如果测试集包含在训练集中未出现的单词(称为 OOV 单词),模型将无法为它们分配任何向量。例如,如果您从上世纪 80 年代出版的书籍中训练 Skip-gram 词嵌入,并将其应用于现代社交媒体文本,它将如何知道要为“Instagram”分配什么向量?它不会。另一方面,由于 fastText 使用子词信息(字符 n-gram),它可以为任何 OOV 单词分配词向量,只要它们包含在训练数据中看到的字符 n-gram(这几乎总是如此)。它可能猜到它与一些快速相关(“Insta”)和图片(“gram”)有关。

3.6.2 使用 fastText 工具包

Facebook 提供了 fastText 工具包的开源代码,这是一个用于训练前面章节讨论的词嵌入模型的库。在本节的其余部分,让我们看看使用这个库来训练词嵌入是什么感觉。

首先,转到官方文档(realworldnlpbook.com/ch3.html #fasttext)并按照说明下载和编译该库。在大多数环境中,只需克隆 GitHub 存储库并从命令行运行 make 即可。编译完成后,您可以运行以下命令来训练基于 Skip-gram 的 fastText 模型:

$ ./fasttext skipgram -input ../data/text8 -output model

我们假设在../data/text8 下有一个文本数据文件,您想要用作训练数据,但如果需要,请更改这个位置。这将创建一个 model.bin 文件,这是一个训练模型的二进制表示。训练完模型后,您可以获得任何单词的词向量,甚至是在训练数据中从未见过的单词,方法如下:

$ echo "supercalifragilisticexpialidocious" \
| ./fasttext print-word-vectors model.bin
supercalifragilisticexpialidocious 0.032049 0.20626 -0.21628 -0.040391 -0.038995 0.088793 -0.0023854 0.41535 -0.17251 0.13115 ...

3.7 文档级嵌入

到目前为止,我描述的所有模型都是为单词学习嵌入。如果您只关注词级任务,比如推断词之间的关系,或者将它们与更强大的神经网络模型(如循环神经网络(RNN))结合使用,它们可以是非常有用的工具。然而,如果您希望使用词嵌入和传统机器学习工具(如逻辑回归和支持向量机(SVM))解决与更大语言结构(如句子和文档)相关的 NLP 任务,词级嵌入方法仍然是有限的。您如何用向量表示来表示更大的语言单元,比如句子?您如何使用词嵌入进行情感分析,例如?

一个实现这一目标的方法是简单地使用句子中所有词向量的平均值。您可以通过取第一个元素、第二个元素的平均值等等,然后通过组合这些平均数生成一个新的向量。您可以将这个新向量作为传统机器学习模型的输入。尽管这种方法简单且有效,但它也有很大的局限性。最大的问题是它不能考虑词序。例如,如果您仅仅对句子中的每个单词向量取平均值,那么句子“Mary loves John.”和“John loves Mary.”的向量将完全相同。

NLP 研究人员提出了可以专门解决这个问题的模型和算法。其中最流行的之一是Doc2Vec,最初由 Le 和 Mikolov 在 2014 年提出(cs.stanford.edu/~quocle/paragraph_vector.pdf)。这个模型,正如其名称所示,学习文档的向量表示。事实上,“文档”在这里只是指任何包含多个单词的可变长度文本。类似的模型还被称为许多类似的名称,比如句子 2Vec段落 2Vec段落向量(这是原始论文的作者所用的),但本质上,它们都指的是相同模型的变体。

在本节的其余部分,我将讨论一种称为段落向量分布记忆模型(PV-DM)的 Doc2Vec 模型之一。该模型与我们在本章前面学习的 CBOW 非常相似,但有一个关键的区别——多了一个向量,称为段落向量,作为输入。该模型从一组上下文单词段落向量预测目标词。每个段落都被分配一个不同的段落向量。图 3.11 展示了 PV-DM 模型的结构。另外,PV-DM 仅使用在目标词之前出现的上下文单词进行预测,但这只是一个微小的差异。

CH03_F11_Hagiwara

图 3.11 段落向量分布记忆模型

这段向量会对预测任务有什么影响?现在您从段落向量中获得了一些额外信息来预测目标单词。由于模型试图最大化预测准确性,您可以预期段落向量会更新,以便它提供一些在句子中有用的“上下文”信息,这些信息不能被上下文词向量共同捕获。作为副产品,模型学会了反映每个段落的整体含义,以及词向量。

几个开源库和包支持 Doc2Vec 模型,但其中一个最广泛使用的是 Gensim(radimrehurek.com/gensim/),可以通过运行 pip install gensim 来安装。Gensim 是一个流行的自然语言处理工具包,支持广泛的向量和主题模型,例如 TF-IDF(词频和逆文档频率)、LDA(潜在语义分析)和词嵌入。

要使用 Gensim 训练 Doc2Vec 模型,您首先需要读取数据集并将文档转换为 TaggedDocument。可以使用此处显示的 read_corpus() 方法来完成:

from gensim.utils import simple_preprocess
from gensim.models.doc2vec import TaggedDocument

def read_corpus(file_path):
    with open(file_path) as f:
        for i, line in enumerate(f):
            yield TaggedDocument(simple_preprocess(line), [i])

我们将使用一个小数据集,其中包含来自 Tatoeba 项目(tatoeba.org/)的前 200,000 个英文句子。您可以从 mng.bz/7l0y 下载数据集。然后,您可以使用 Gensim 的 Doc2Vec 类来训练 Doc2Vec 模型,并根据训练的段落向量检索相似的文档,如下所示。

列表 3.6 训练 Doc2Vec 模型并检索相似文档

    from gensim.models.doc2vec import Doc2Vec

    train_set = list(read_corpus('data/mt/sentences.eng.200k.txt'))
    model = Doc2Vec(vector_size=256, min_count=3, epochs=30)
    model.build_vocab(train_set)
    model.train(train_set,
                total_examples=model.corpus_count,
                epochs=model.epochs)

    query_vec = model.infer_vector(
        ['i', 'heard', 'a', 'dog', 'barking', 'in', 'the', 'distance'])
    sims = model.docvecs.most_similar([query_vec], topn=10)
    for doc_id, sim in sims:
        print('{:3.2f} {}'.format(sim, train_set[doc_id].words)) 

这将显示与输入文档“I heard a dog barking in the distance.”相似的文档列表,如下所示:

0.67 ['she', 'was', 'heard', 'playing', 'the', 'violin']
0.65 ['heard', 'the', 'front', 'door', 'slam']
0.61 ['we', 'heard', 'tigers', 'roaring', 'in', 'the', 'distance']
0.61 ['heard', 'dog', 'barking', 'in', 'the', 'distance']
0.60 ['heard', 'the', 'door', 'open']
0.60 ['tom', 'heard', 'the', 'door', 'open']
0.60 ['she', 'heard', 'dog', 'barking', 'in', 'the', 'distance']
0.59 ['heard', 'the', 'door', 'close']
0.59 ['when', 'he', 'heard', 'the', 'whistle', 'he', 'crossed', 'the', 'street']
0.58 ['heard', 'the', 'telephone', 'ringing']

注意这里检索到的大多数句子与听到声音有关。事实上,列表中有一个相同的句子,因为我一开始就从 Tatoeba 中获取了查询句子!Gensim 的 Doc2Vec 类有许多超参数,您可以使用它们来调整模型。您可以在他们的参考页面上进一步了解该类(radimrehurek.com/gensim/models/doc2vec.html)。

3.8 可视化嵌入

在本章的最后一节中,我们将把重点转移到可视化词嵌入上。正如我们之前所做的,给定一个查询词检索相似的词是一个快速检查词嵌入是否正确训练的好方法。但是,如果您需要检查多个词以查看词嵌入是否捕获了单词之间的语义关系,这将变得令人疲倦和耗时。

如前所述,词嵌入简单地是 N 维向量,也是 N 维空间中的“点”。我们之所以能够在图 3.1 中以 3-D 空间可视化这些点,是因为 N 是 3。但是在大多数词嵌入中,N 通常是一百多,我们不能简单地将它们绘制在 N 维空间中。

一个解决方案是将维度降低到我们可以看到的东西(二维或三维),同时保持点之间的相对距离。这种技术称为降维。我们有许多降低维度的方法,包括 PCA(主成分分析)和 ICA(独立成分分析),但迄今为止,用于单词嵌入的最广泛使用的可视化技术是称为t-SNE(t-分布随机近邻嵌入,发音为“tee-snee”)的方法。虽然 t-SNE 的细节超出了本书的范围,但该算法试图通过保持原始高维空间中点之间的相对邻近关系来将点映射到较低维度的空间。

使用 t-SNE 的最简单方法是使用 Scikit-Learn (scikit-learn.org/),这是一个流行的用于机器学习的 Python 库。安装后(通常只需运行 pip install scikit-learn),您可以像下面展示的那样使用它来可视化从文件中读取的 GloVe 向量(我们使用 Matplotlib 来绘制图表)。

清单 3.7 使用 t-SNE 来可视化 GloVe 嵌入

from sklearn.manifold import TSNE
import matplotlib.pyplot as plt

def read_glove(file_path):
    with open(file_path) as f:
        for i, line in enumerate(f):
            fields = line.rstrip().split(' ')
            vec = [float(x) for x in fields[1:]]
            word = fields[0]
            yield (word, vec)

words = []
vectors = []
for word, vec in read_glove('data/glove/glove.42B.300d.txt'):
    words.append(word)
    vectors.append(vec)

model = TSNE(n_components=2, init='pca', random_state=0)
coordinates = model.fit_transform(vectors)

plt.figure(figsize=(8, 8))

for word, xy in zip(words, coordinates):
    plt.scatter(xy[0], xy[1])
    plt.annotate(word,
                 xy=(xy[0], xy[1]),
                 xytext=(2, 2),
                 textcoords='offset points')

plt.xlim(25, 55)
plt.ylim(-15, 15)
plt.show()

在清单 3.7 中,我使用 xlim() 和 ylim() 将绘制范围限制在我们感兴趣的一些区域,以放大一些区域。您可能想尝试不同的值来聚焦绘图中的其他区域。

清单 3.7 中的代码生成了图 3.12 中显示的图。这里有很多有趣的东西,但快速浏览时,您会注意到以下词语聚类,它们在语义上相关:

  • 底部左侧:与网络相关的词语(postsarticleblogcomments,. . . )。

  • 上方左侧:与时间相关的词语(dayweekmonthyear,. . . )。

  • 中间:数字(0,1,2,. . . )。令人惊讶的是,这些数字向底部递增排序。GloVe 仅从大量的文本数据中找出了哪些数字较大。

  • 底部右侧:月份(january,february,. . . )和年份(2004,2005,. . . )。同样,年份似乎按照递增顺序排列,几乎与数字(0,1,2,. . . )平行。

CH03_F12_Hagiwara

图 3.12 由 t-SNE 可视化的 GloVe 嵌入

如果您仔细思考一下,一个纯粹的数学模型能够从大量的文本数据中找出这些词语之间的关系,这实在是一项不可思议的成就。希望现在您知道,如果模型知道“july”和“june”之间的关系密切相连,与从 word_823 和 word_1719 开始逐一解释所有内容相比,这有多么有利。

总结

  • 单词嵌入是单词的数字表示,它们有助于将离散单位(单词和句子)转换为连续的数学对象(向量)。

  • Skip-gram 模型使用具有线性层和 softmax 的神经网络来学习单词嵌入,作为“假”词语关联任务的副产品。

  • GloVe 利用单词共现的全局统计信息有效地训练单词嵌入。

  • Doc2Vec 和 fastText 分别用于学习文档级别的嵌入和带有子词信息的词嵌入。

  • 你可以使用 t-SNE 来可视化词嵌入。

第四章:句子分类

本章内容包括

  • 利用循环神经网络(RNN)处理长度可变的输入

  • 处理 RNN 及其变体(LSTM 和 GRU)

  • 使用常见的分类问题评估指标

  • 使用 AllenNLP 开发和配置训练管道

  • 将语言识别器构建为句子分类任务

在本章中,我们将研究句子分类任务,其中 NLP 模型接收一个句子并为其分配一些标签。垃圾邮件过滤器是句子分类的一个应用。它接收一封电子邮件并指定它是否为垃圾邮件。如果你想将新闻文章分类到不同的主题(商业、政治、体育等),也是一个句子分类任务。句子分类是最简单的 NLP 任务之一,具有广泛的应用范围,包括文档分类、垃圾邮件过滤和情感分析。具体而言,我们将重新审视第二章中介绍的情感分类器,并详细讨论其组成部分。在本节结束时,我们将研究句子分类的另一个应用——语言检测。

4.1 循环神经网络(RNN)

句子分类的第一步是使用神经网络(RNN)表示长度可变的句子。在本节中,我将介绍循环神经网络的概念,这是深度 NLP 中最重要的概念之一。许多现代 NLP 模型以某种方式使用 RNN。我将解释它们为什么很重要,它们的作用是什么,并介绍它们的最简单变体。

4.1.1 处理长度可变的输入

上一章中展示的 Skip-gram 网络结构很简单。它接受一个固定大小的词向量,通过线性层运行它,得到所有上下文词之间的分数分布。网络的结构和大小以及输入输出都在训练期间固定。

然而,自然语言处理(NLP)中面临的许多,如果不是大多数情况下,都是长度可变的序列。例如,单词是字符序列,可以短(“a”,“in”)也可以长(“internationalization”)。句子(单词序列)和文档(句子序列)可以是任何长度。即使是字符,如果将它们看作笔画序列,则可以是简单的(如英语中的“O”和“L”)或更复杂的(例如,“鬱”是一个包含 29 个笔画,并表示“抑郁”的中文汉字)。

正如我们在上一章中讨论的那样,神经网络只能处理数字和算术运算。这就是为什么我们需要通过嵌入将单词和文档转换为数字的原因。我们使用线性层将一个固定长度的向量转换为另一个向量。但是,为了处理长度可变的输入,我们需要找到一种处理方法,使得神经网络可以对其进行处理。

一个想法是首先将输入(例如,一系列单词)转换为嵌入,即一系列浮点数向量,然后将它们平均。假设输入句子为 sentence = ["john", "loves", "mary", "."],并且你已经知道句子中每个单词的单词嵌入 v("john")、v("loves")等。平均值可以用以下代码获得,并在图 4.1 中说明:

result = (v("john") + v("loves") + v("mary") + v(".")) / 

CH04_F01_Hagiwara

图 4.1 平均嵌入向量

这种方法相当简单,实际上在许多自然语言处理应用中都有使用。然而,它有一个关键问题,即它无法考虑词序。因为输入元素的顺序不影响平均结果,你会得到“Mary loves John”和“John loves Mary”两者相同的向量。尽管它能胜任手头的任务,但很难想象有多少自然语言处理应用会希望这种行为。

如果我们退后一步,思考一下我们人类如何阅读语言,这种“平均”与现实相去甚远。当我们阅读一句话时,我们通常不会孤立地阅读单个单词并首先记住它们,然后再弄清楚句子的含义。我们通常从头开始扫描句子,逐个单词地阅读,同时将“部分”句子在我们的短期记忆中的含义保持住直到你正在阅读的部分。换句话说,你在阅读时保持了一种对句子的心理表征。当你达到句子的末尾时,这种心理表征就是它的含义。

我们是否可以设计一个神经网络结构来模拟这种对输入的逐步阅读?答案是肯定的。这种结构被称为循环神经网络(RNNs),我将在接下来详细解释。

4.1.2 RNN 抽象

如果你分解前面提到的阅读过程,其核心是以下一系列操作的重复:

  1. 阅读一个词。

  2. 根据迄今为止所阅读的内容(你的“心理状态”),弄清楚这个词的含义。

  3. 更新心理状态。

  4. 继续下一个词。

让我们通过一个具体的例子来看看这是如何工作的。如果输入句子是 sentence = ["john", "loves", "mary", "."],并且每个单词已经表示为单词嵌入向量。另外,让我们将你的“心理状态”表示为 state,它由 init_state()初始化。然后,阅读过程由以下递增操作表示:

state = init_state()
state = update(state, v("john"))
state = update(state, v("loves"))
state = update(state, v("mary"))
state = update(state, v("."))

state 的最终值成为此过程中整个句子的表示。请注意,如果你改变这些单词处理的顺序(例如,交换“John”和“Mary”),state 的最终值也会改变,这意味着 state 也编码了一些有关词序的信息。

如果你可以设计一个网络子结构,可以在更新一些内部状态的同时应用于输入的每个元素,那么你可以实现类似的功能。RNNs 就是完全这样做的神经网络结构。简而言之,RNN 是一个带有循环的神经网络。其核心是在输入中的每个元素上应用的操作。如果你用 Python 伪代码来表示 RNN 做了什么,就会像下面这样:

def rnn(words):
    state = init_state()
    for word in words:
        state = update(state, word)
    return state

注意这里有一个被初始化并在迭代过程中传递的状态。对于每个输入单词,状态会根据前一个状态和输入使用update函数进行更新。对应于这个步骤(循环内的代码块)的网络子结构被称为单元。当输入用尽时,这个过程停止,状态的最终值成为该 RNN 的结果。见图 4.2 进行说明。

CH04_F02_Hagiwara

图 4.2 RNN 抽象

在这里你可以看到并行性。当你阅读一个句子(一串单词)时,每读一个单词后你内部心理对句子的表示,即状态,会随之更新。可以假设最终状态编码了整个句子的表示。

唯一剩下的工作是设计两个函数——init_state()update()。通常,状态初始化为零(即一个填满零的向量),所以你通常不用担心如何定义前者。更重要的是如何设计 update(),它决定了 RNN 的特性。

4.1.3 简单 RNNs 和非线性

在第 3.4.3 节中,我解释了如何使用任意数量的输入和输出来实现一个线性层。我们是否可以做类似的事情,并实现update(),它基本上是一个接受两个输入变量并产生一个输出变量的函数呢?毕竟,一个单元是一个有自己输入和输出的神经网络,对吧?答案是肯定的,它看起来像这样:

def update_simple(state, word):
    return f(w1 * state + w2 * word + b)

注意这与第 3.4.3 节中的 linear2() 函数非常相似。实际上,如果忽略变量名称的差异,除了 f() 函数之外,它们是完全一样的。由此类型的更新函数定义的 RNN 被称为简单 RNNElman RNN,正如其名称所示,它是最简单的 RNN 结构之一。

你可能会想,这里的 f() 函数是做什么的?它是什么样的?我们是否需要它?这个函数被称为激活函数非线性函数,它接受一个输入(或一个向量)并以非线性方式转换它(或转换向量的每个元素)。存在许多种非线性函数,它们在使神经网络真正强大方面起着不可或缺的作用。它们确切地做什么以及为什么它们重要需要一些数学知识来理解,这超出了本书的范围,但我将尝试用一个简单的例子进行直观解释。

想象一下你正在构建一个识别“语法正确”的英语句子的 RNN。区分语法正确的句子和不正确的句子本身就是一个困难的自然语言处理问题,实际上是一个成熟的研究领域(参见第 1.2.1 节),但在这里,让我们简化它,并考虑主语和动词之间的一致性。让我们进一步简化,并假设这个“语言”中只有四个词——“I”,“you”,“am” 和 “are”。如果句子是“I am” 或 “you are”,那么它就是语法正确的。另外两种组合,“I are” 和 “you am”,是不正确的。你想要构建的是一个 RNN,对于正确的句子输出 1,对于不正确的句子输出 0。你会如何构建这样一个神经网络?

几乎每个现代 NLP 模型的第一步都是用嵌入来表示单词。如前一章所述,它们通常是从大型自然语言文本数据集中学习到的,但在这里,我们只是给它们一些预定义的值,如图 4.3 所示。

CH04_F03_Hagiwara

图 4.3 使用 RNN 识别语法正确的英语句子

现在,让我们假设没有激活函数。前面的 update_simple() 函数简化为以下形式:

def update_simple_linear(state, word):
    return w1 * state + w2 * word + b

我们将假设状态的初始值简单地为 [0, 0],因为具体的初始值与此处的讨论无关。RNN 接受第一个单词嵌入 x1,更新状态,接受第二个单词嵌入 x2,然后生成最终状态,即一个二维向量。最后,将这个向量中的两个元素相加并转换为 result。如果 result 接近于 1,则句子是语法正确的。否则,不是。如果你应用 update_simple_linear() 函数两次并稍微简化一下,你会得到以下函数,这就是这个 RNN 的全部功能:

w1 * w2 * x1 + w2 * x2 + w1 * b + b

请记住,w1、w2 和 b 是模型的参数(也称为“魔法常数”),需要进行训练(调整)。在这里,我们不是使用训练数据集调整这些参数,而是将一些任意值赋给它们,然后看看会发生什么。例如,当 w1 = [1, 0],w2 = [0, 1],b = [0, 0] 时,这个 RNN 的输入和输出如图 4.4 所示。

CH04_F04_Hagiwara

图 4.4 当 w1 = [1, 0],w2 = [0, 1],b = [0, 0] 且没有激活函数时的输入和输出

如果你查看结果的值,这个 RNN 将不合语法的句子(例如,“I are”)与合语法的句子(例如,“you are”)分组在一起,这不是我们期望的行为。那么,我们尝试另一组参数值如何?让我们使用 w1 = [1, 0],w2 = [-1, 0],b = [0, 0],看看会发生什么(参见图 4.5 的结果)。

CH04_F05_Hagiwara

图 4.5 当 w1 = [1, 0],w2 = [-1, 0],b = [0, 0] 且没有激活函数时的输入和输出

这好多了,因为 RNN 成功地通过将 “I are” 和 “you am” 都赋值为 0 来将不符合语法的句子分组。然而,它也给语法正确的句子(“I am” 和 “you are”)赋予了完全相反的值(2 和 -2)。

我要在这里停下来,但事实证明,无论你如何努力,都不能使用这个神经网络区分语法正确的句子和不正确的句子。尽管你给参数分配了值,但这个 RNN 无法产生足够接近期望值的结果,因此无法根据它们的语法性将句子分组。

让我们退一步思考为什么会出现这种情况。如果你看一下之前的更新函数,它所做的就是将输入乘以一些值然后相加。更具体地说,它只是以线性方式转换输入。当你改变输入的值时,这个神经网络的结果总是会以某个恒定的量变化。但显然这是不可取的——你希望结果只在输入变量是某些特定值时才为 1。换句话说,你不希望这个 RNN 是线性的;你希望它是非线性的。

用类比的方式来说,想象一下,假设你的编程语言只能使用赋值(“=”)、加法(“+”)和乘法(“*”)。你可以在这样受限制的环境中调整输入值以得到结果,但在这样的情况下,你无法编写更复杂的逻辑。

现在让我们把激活函数 f() 加回去,看看会发生什么。我们将使用的具体激活函数称为双曲正切函数,或者更常见的是tanh,它是神经网络中最常用的激活函数之一。在这个讨论中,这个函数的细节并不重要,但简而言之,它的行为如下:当输入接近零时,tanh 对输入的影响不大,例如,0.3 或 -0.2。换句话说,输入几乎不经过函数而保持不变。当输入远离零时,tanh 试图将其压缩在 -1 和 1 之间。例如,当输入很大(比如,10.0)时,输出变得非常接近 1.0,而当输入很小时(比如,-10.0)时,输出几乎为 -1.0。如果将两个或更多变量输入激活函数,这会产生类似于 OR 逻辑门(或 AND 门,取决于权重)的效果。门的输出根据输入变为开启(1)和关闭(-1)。

当 w1 = [-1, 2],w2 = [-1, 2],b = [0, 1],并且使用 tanh 激活函数时,RNN 的结果更接近我们所期望的(见图 4.6)。如果将它们四舍五入为最接近的整数,RNN 成功地通过它们的语法性将句子分组。

CH04_F06_Hagiwara

图 4.6 当 w1 = [-1, 2],w2 = [-1, 2],b = [0, 1] 且激活函数为时的输入和输出

使用同样的类比,将激活函数应用于你的神经网络就像在你的编程语言中使用 AND、OR 和 IF 以及基本的数学运算,比如加法和乘法一样。通过这种方式,你可以编写复杂的逻辑并模拟输入变量之间的复杂交互,就像本节的例子一样。

注意:本节中我使用的例子是流行的 XOR(或异或)例子的一个略微修改版本,通常在深度学习教材中见到。这是神经网络可以解决但其他线性模型无法解决的最基本和最简单的例子。

关于 RNN 的一些最后说明——它们的训练方式与任何其他神经网络相同。最终的结果与期望结果使用损失函数进行比较,然后两者之间的差异——损失——用于更新“魔术常数”。在这种情况下,魔术常数是 update_simple()函数中的 w1、w2 和 b。请注意,更新函数及其魔术常数在循环中的所有时间步中都是相同的。这意味着 RNN 正在学习的是可以应用于任何情况的一般更新形式。

4.2 长短期记忆单元(LSTMs)和门控循环单元(GRUs)

实际上,我们之前讨论过的简单 RNN 在真实世界的 NLP 应用中很少使用,因为存在一个称为梯度消失问题的问题。在本节中,我将展示与简单 RNN 相关的问题以及更流行的 RNN 架构,即 LSTMs 和 GRUs,如何解决这个特定问题。

4.2.1 梯度消失问题

就像任何编程语言一样,如果你知道输入的长度,你可以在不使用循环的情况下重写一个循环。RNN 也可以在不使用循环的情况下重写,这使它看起来就像一个具有许多层的常规神经网络。例如,如果你知道输入中只有六个单词,那么之前的 rnn()可以重写如下:

def rnn(sentence):
    word1, word2, word3, word4, word5, word6 = sentence
    state = init_state()

    state = update(state, word1)
    state = update(state, word2)
    state = update(state, word3)
    state = update(state, word4)
    state = update(state, word5)
    state = update(state, word6)

    return state

不带循环的表示 RNN 被称为展开。现在我们知道简单 RNN 的 update()是什么样子(update_simple),所以我们可以用其实体替换函数调用,如下所示:

def rnn_simple(sentence):
    word1, word2, word3, word4, word5, word6 = sentence
    state = init_state()

    state = f(w1 * f(w1 * f(w1 * f(w1 * f(w1 * f(w1 * state + w2 * word1 + b) + w2 * word2 + b) + w2 * word3 + b) + w2 * word4 + b) + w2 * word5 + b) + w2 * word6 + b)
    return state

这变得有点丑陋,但我只是想让你注意到非常深度嵌套的函数调用和乘法。现在,回想一下我们在上一节中想要完成的任务——通过识别主谓一致来对语法正确的英语句子进行分类。假设输入是 sentence = ["The", "books", "I", "read", "yesterday", "were"]。在这种情况下,最内层的函数调用处理第一个词“The”,下一个处理第二个词“books”,依此类推,一直到最外层的函数调用,处理“were”。如果我们稍微修改前面的伪代码,如下代码片段所示,你就可以更直观地理解它:

def is_grammatical(sentence):
    word1, word2, word3, word4, word5, word6 = sentence
    state = init_state()

    state = process_main_verb(w1 *
        process_adverb(w1 *
            process_relative_clause_verb(w1 *
                process_relative_clause_subject(w1 *
                    process_main_subject(w1 *
                        process_article(w1 * state + w2 * word1 + b) +
                    w2 * word2 + b) +
                w2 * word3 + b) +
            w2 * word4 + b) + 
        w2 * word5 + b) + 
    w2 * word6 + b)

    return state

为了识别输入确实是一句语法正确的英语句子(或一句句子的前缀),RNN 需要保留有关主语(“书”)的信息在状态中,直到看到动词(“were”)而不会被中间的任何东西(“我昨天读了”)分散注意力。在先前的伪代码中,状态由函数调用的返回值表示,因此关于主题的信息(process_main_subject 的返回值)需要在链中传播到达最外层函数(process_main_verb)。这开始听起来像是一项困难的任务。

当涉及训练该 RNN 时,情况并不好。 RNN 和其他任何神经网络都使用称为反向传播的算法进行训练。反向传播是一种过程,在该过程中,神经网络的组成部分与先前的组成部分通信,以便调整参数以最小化损失。对于这个特定的示例,它是如何工作的。首先,您查看结果,即 is_grammatical 的返回值()并将其与您期望的内容进行比较。这两者之间的差称为损失。最外层函数 is_grammatical()基本上有四种方式来减少损失,使其输出更接近所需内容:1)调整 w1,同时固定嵌套函数 process_adverb()的返回值,2)调整 w2,3)调整 b,或 4)调整 process_adverb()的返回值,同时固定参数。调整参数(w1、w2 和 b)很容易,因为函数知道调整每个参数对其返回值的确切影响。然而,调整上一个函数的返回值是不容易的,因为调用者不知道函数内部的工作原理。因此,调用者告诉上一个函数(被调用方)调整其返回值以最小化损失。请参见图 4.7,了解损失如何向后传播到参数和先前的函数。

CH04_F07_Hagiwara

图 4.7 损失的反向传播

嵌套的函数调用重复这个过程并玩转电话游戏,直到消息传递到最内层函数。到那个时候,因为消息需要经过许多层,它变得非常微弱和模糊(或者如果有误解则非常强大和扭曲),以至于内部函数很难弄清楚自己做错了什么。

技术上讲,深度学习文献将此称为梯度消失问题梯度是一个数学术语,对应于每个函数从下一个函数接收到的信息信号,该信号指示它们应该如何改进其过程(如何更改其魔法常数)。反向电话游戏,其中消息从最终函数(=损失函数)向后传递,称为反向传播。我不会涉及这些术语的数学细节,但至少在概念上理解它们是有用的。

由于梯度消失问题,简单的循环神经网络(Simple RNNs)难以训练,在实践中现在很少使用。

4.2.2 长短期记忆(LSTM)

之前提到的嵌套函数处理语法信息的方式似乎太低效了。毕竟,为什么外部函数(is_grammatical)不直接告诉负责的特定函数(例如,process_main_subject)出了什么问题,而不是玩电话游戏呢?它不能这样做,因为每次函数调用后消息都可以完全改变其形状,这是由于 w2 和 f()。最外层函数无法仅从最终输出中告诉哪个函数负责消息的哪个部分。

我们如何解决这个低效性呢?与其每次通过激活函数传递信息并完全改变其形状,不如在每一步中添加和减去与正在处理的句子部分相关的信息?例如,如果 process_main_subject() 可以直接向某种“记忆”中添加有关主语的信息,并且网络可以确保记忆通过中间函数完整地传递,is_grammatical() 就会更容易告诉前面的函数如何调整其输出。

长短期记忆单元(LSTMs)是基于这一观点提出的一种 RNN 单元。LSTM 单元不是传递状态,而是共享“记忆”,每个单元都可以从中删除旧信息并/或添加新信息,有点像制造工厂中的装配线。具体来说,LSTM RNN 使用以下函数来更新状态:

def update_lstm(state, word):
    cell_state, hidden_state = state

    cell_state *= forget(hidden_state, word)
    cell_state += add(hidden_state, word)

    hidden_state = update_hidden(hidden_state, cell_state, word)

    return (cell_state, hidden_state)

CH04_F08_Hagiwara

图 4.8 LSTM 更新函数

尽管与其简单版本相比,这看起来相对复杂,但是如果你将其分解为子组件,就不难理解这里正在发生的事情,如下所述并在图 4.8 中显示:

  • LSTM 状态包括两个部分——细胞状态(“记忆”部分)和隐藏状态(“心理表征”部分)。

  • 函数 forget() 返回一个介于 0 和 1 之间的值,因此乘以这个数字意味着从 cell_state 中擦除旧的记忆。要擦除多少由 hidden_state 和 word(输入)决定。通过乘以介于 0 和 1 之间的值来控制信息流动称为门控。LSTM 是第一个使用这种门控机制的 RNN 架构。

  • 函数 add() 返回添加到记忆中的新值。该值再次是由 hidden_state 和 word 决定的。

  • 最后,使用一个函数更新 hidden_state,该函数的值是从前一个隐藏状态、更新后的记忆和输入单词计算得出的。

我通过隐藏一些数学细节在 forget()、add() 和 update_hidden() 函数中抽象了更新函数,这些细节对于这里的讨论不重要。如果你对深入了解 LSTM 感兴趣,我建议你阅读克里斯·奥拉在此主题上撰写的精彩博文(colah.github.io/posts/2015-08-Understanding-LSTMs/)。

因为 LSTMs 有一个在不同时间步保持不变的单元状态,除非显式修改,它们更容易训练并且相对表现良好。因为你有一个共享的“记忆”,函数正在添加和删除与输入句子的不同部分相关的信息,所以更容易确定哪个函数做了什么以及出了什么问题。来自最外层函数的错误信号可以更直接地到达负责函数。

术语说明:LSTM 是此处提到的一种特定类型的架构,但人们使用 “LSTMs” 来表示带有 LSTM 单元的 RNN。此外,“RNN” 常常用来指代“简单 RNN”,在第 4.1.3 节中介绍。在文献中看到“RNNs”时,你需要注意它们使用的确切架构。

4.2.3 门控循环单元(GRUs)

另一种 RNN 架构称为门控循环单元(GRUs),它使用门控机制。GRUs 的理念与 LSTMs 相似,但 GRUs 仅使用一组状态而不是两组。GRUs 的更新函数如下所示:

def update_gru(state, word): 
    new_state = update_hidden(state, word)

    switch = get_switch(state, word)

    state = swtich * new_state + (1 - switch) * state

    return state

GRUs 不使用擦除或更新内存,而是使用切换机制。单元首先从旧状态和输入计算出新状态。然后计算切换值,一个介于 0 和 1 之间的值。根据切换值选择新状态和旧状态之间的状态。如果它是 0,旧状态保持不变。如果它是 1,它将被新状态覆盖。如果它在两者之间,状态将是两者的混合。请参见图 4.9,了解 GRU 更新函数的示意图。

CH04_F09_Hagiwara

图 4.9 GRU 更新函数

请注意,与 LSTMs 相比,GRUs 的更新函数要简单得多。实际上,它的参数(魔术常数)比 LSTMs 需要训练的参数少。因此,GRUs 比 LSTMs 更快地训练。

最后,尽管我们介绍了两种不同类型的 RNN 架构,即 LSTM 和 GRU,但在社区中并没有一致的共识,哪种类型的架构对于所有应用最好。你通常需要将它们视为超参数,并尝试不同的配置。幸运的是,只要你使用现代深度学习框架如 PyTorch 和 TensorFlow,就很容易尝试不同类型的 RNN 单元。

4.3 准确率、精确率、召回率和 F-度量

在第 2.7 节,我简要地讨论了一些我们用于评估分类任务性能的指标。在我们继续实际构建一个句子分类器之前,我想进一步讨论我们将要使用的评估指标——它们的含义以及它们实际上衡量的内容。

4.3.1 准确率

准确率可能是我们所讨论的所有评估指标中最简单的。在分类设置中,准确率是你的模型预测正确的实例的比例。例如,如果有 10 封电子邮件,而你的垃圾邮件过滤模型正确地识别了其中的 8 封,那么你的预测准确率就是 0.8,或者 80%(见图 4.10)。

CH04_F10_Hagiwara

图 4.10 计算准确率

虽然简单,但准确率并不是没有局限性。具体来说,在测试集不平衡时,准确率可能会误导。一个不平衡的数据集包含多个类别标签,它们的数量差异很大。例如,如果一个垃圾邮件过滤数据集不平衡,可能包含 90% 的非垃圾邮件和 10% 的垃圾邮件。在这种情况下,即使一个愚蠢的分类器把一切都标记为非垃圾邮件,也能够达到 90% 的准确率。例如,如果一个“愚蠢”的分类器在图 4.10 中将所有内容都分类为“非垃圾邮件”,它仍然会达到 70% 的准确率(10 个实例中的 7 个)。如果你孤立地看这个数字,你可能会被误导以为分类器的性能实际上很好。当你使用准确率作为指标时,将其与假想的、愚蠢的分类器(多数投票)作为基准进行比较总是一个好主意。

4.3.2 精确率和召回率

剩下的指标——精确率、召回率和 F-度量——是在二元分类设置中使用的。二元分类任务的目标是从另一个类别(称为负类)中识别出一个类别(称为正类)。在垃圾邮件过滤设置中,正类是垃圾邮件,而负类是非垃圾邮件。

图 4.11 中的维恩图包含四个子区域:真正例、假正例、假负例和真负例。真正例(TP)是被预测为正类(= 垃圾邮件)并且确实属于正类的实例。假正例(FP)是被预测为正类(= 垃圾邮件)但实际上不属于正类的实例。这些是预测中的噪音,也就是被误认为垃圾邮件并最终出现在你的电子邮件客户端的垃圾邮件文件夹中的无辜非垃圾邮件。

另一方面,假阴性(FN)是被预测为负类但实际上属于正类的实例。这些是通过垃圾邮件过滤器漏过的垃圾邮件,最终出现在你的收件箱中。最后,真阴性(TN)是被预测为负类并且确实属于负类的实例(即出现在你的收件箱中的非垃圾邮件)。

精确率是模型将正确分类为正例的实例的比例。例如,如果你的垃圾邮件过滤器将三封邮件标记为垃圾邮件,并且其中有两封确实是垃圾邮件,则精确率将为 2/3,约为 66%。

召回率与精确率有些相反。它是你的模型在数据集中被正确识别为正例的正例占比。再以垃圾邮件过滤为例,如果你的数据集中有三封垃圾邮件,而你的模型成功识别了其中两封邮件为垃圾邮件,则召回率将为 2/3,约为 66%。

图 4.11 显示了预测标签和真实标签之间以及召回率和精确率之间的关系。

CH04_F11_Hagiwara

图 4.11 精确率和召回率

4.3.3 F-测量

你可能已经注意到了精确率和召回率之间的权衡。想象一下有一个非常谨慎的垃圾邮件过滤器。它只有在几千封邮件中输出一封邮件为垃圾邮件,但当它输出时,它总是正确的。这不是一个困难的任务,因为一些垃圾邮件非常明显 - 如果它们的文本中包含“v1@gra”这个词,并且是从垃圾邮件黑名单中的人发送的,将其标记为垃圾邮件应该是相当安全的。这个垃圾邮件过滤器的精确率是多少?100%。同样,还有另一个非常粗心的垃圾邮件过滤器。它将每封电子邮件都分类为垃圾邮件,包括来自同事和朋友的电子邮件。它的召回率是多少?100%。这两个垃圾邮件过滤器中的任何一个有用吗?几乎没有!

正如你所看到的,只关注精确率或召回率而忽视另一个是不好的做法,因为它们之间存在权衡。这就好比你在节食时只关注体重。你减了 10 磅?太棒了!但是如果你身高是 7 英尺呢?并不是很好。你需要同时考虑身高和体重-太多是多少取决于另一个变量。这就是为什么有像 BMI(身体质量指数)这样的衡量标准,它同时考虑了这两个指标。同样,研究人员提出了一种叫做 F-测量的度量标准,它是精确率和召回率的平均值(更准确地说是调和平均值)。通常使用的是一个叫做 F1-测量的特殊案例,它是 F-测量的等权版本。在分类设置中,衡量并尝试最大化 F-测量是一种很好的做法。

4.4 构建 AllenNLP 训练流程

在本节中,我们将重新审视第二章中构建的情感分析器,并详细讨论如何更详细地构建其训练流程。尽管我已经展示了使用 AllenNLP

要运行本节中的代码,您需要导入必要的类和模块,如下面的代码片段所示(本节中的代码示例也可以通过 Google Colab 访问,www.realworldnlpbook.com/ch2.html#sst-nb)。

from itertools import chain
from typing import Dict

import numpy as np
import torch
import torch.optim as optim
from allennlp.data.data_loaders import MultiProcessDataLoader
from allennlp.data.samplers import BucketBatchSampler
from allennlp.data.vocabulary import Vocabulary
from allennlp.models import Model
from allennlp.modules.seq2vec_encoders import Seq2VecEncoder, PytorchSeq2VecWrapper
from allennlp.modules.text_field_embedders import TextFieldEmbedder, BasicTextFieldEmbedder
from allennlp.modules.token_embedders import Embedding
from allennlp.nn.util import get_text_field_mask
from allennlp.training.metrics import CategoricalAccuracy, F1Measure
from allennlp.training.trainer import GradientDescentTrainer
from allennlp_models.classification.dataset_readers.stanford_sentiment_tree_bank import \
    StanfordSentimentTreeBankDatasetReader

4.4.1 实例和字段

如第 2.2.1 节所述,实例是机器学习算法进行预测的原子单位。数据集是同一形式实例的集合。大多数 NLP 应用的第一步是读取或接收一些数据(例如从文件或通过网络请求)并将其转换为实例,以便 NLP/ML 算法可以使用它们。

AllenNLP 支持一个称为 DatasetReader 的抽象,它的工作是读取一些输入(原始字符串、CSV 文件、来自网络请求的 JSON 数据结构等)并将其转换为实例。AllenNLP 已经为 NLP 中使用的主要格式提供了广泛的数据集读取器,例如 CoNLL 格式(在语言分析的流行共享任务中使用)和 Penn Treebank(一种流行的用于句法分析的数据集)。要读取 Standard Sentiment Treebank,可以使用内置的 StanfordSentimentTreeBankDatasetReader,我们在第二章中已经使用过了。您还可以通过覆盖 DatasetReader 的一些核心方法来编写自己的数据集阅读器。

AllenNLP 类 Instance 表示一个单独的实例。一个实例可以有一个或多个字段,这些字段保存某种类型的数据。例如,情感分析任务的实例有两个字段——文本内容和标签——可以通过将字段字典传递给其构造函数来创建,如下所示:

Instance({'tokens': TextField(tokens),
          'label': LabelField(sentiment)})

在这里,我们假设您已经创建了 tokens(一个标记列表)和 sentiment(一个与情感类别对应的字符串标签),并从读取输入文件中获取了它们。根据任务,AllenNLP 还支持其他类型的字段。

DatasetReader 的 read() 方法返回一个实例迭代器,使您能够枚举生成的实例并对其进行可视化检查,如下面的代码片段所示:

reader = StanfordSentimentTreeBankDatasetReader()

train_dataset = reader.read('path/to/sst/dataset/train.txt')
dev_dataset = reader.read('path/to/sst/dataset/dev.txt')

for inst in train_dataset + dev_dataset:
    print(inst)

在许多情况下,您可以通过数据加载器访问数据集阅读器。数据加载器是 AllenNLP 的一个抽象(实际上是 PyTorch 数据加载器的一个薄包装),它处理数据并迭代批量实例。您可以通过提供批量样本器来指定如何对实例进行排序、分组为批次并提供给训练算法。在这里,我们使用了一个 BucketBatchSampler,它通过根据实例的长度对其进行排序,并将长度相似的实例分组到一个批次中,如下所示:

reader = StanfordSentimentTreeBankDatasetReader()

sampler = BucketBatchSampler(batch_size=32, sorting_keys=["tokens"])
train_data_loader = MultiProcessDataLoader(
    reader, train_path, batch_sampler=sampler)
dev_data_loader = MultiProcessDataLoader(
    reader, dev_path, batch_sampler=sampler)

4.4.2 词汇表和标记索引器

许多 NLP 应用程序的第二个步骤是构建词汇表。在计算机科学中,词汇是一个表示语言中所有可能单词的理论概念。在 NLP 中,它通常只是指数据集中出现的所有唯一标记的集合。了解一种语言中所有可能的单词是不可能的,也不是 NLP 应用程序所必需的。词汇表中存储的内容称为词汇项目(或仅称为项目)。词汇项目通常是一个词,尽管根据手头的任务,它可以是任何形式的语言单位,包括字符、字符 n-gram 和用于语言注释的标签。

AllenNLP 提供了一个名为 Vocabulary 的类。它不仅负责存储数据集中出现的词汇项目,还保存了词汇项目和它们的 ID 之间的映射关系。如前所述,神经网络和一般的机器学习模型只能处理数字,而需要一种将诸如单词之类的离散项目映射到一些数字表示(如单词 ID)的方式。词汇还用于将 NLP 模型的结果映射回原始单词和标签,以便人类实际阅读它们。

您可以按如下方式从实例创建一个 Vocabulary 对象:

vocab = Vocabulary.from_instances(chain(train_data_loader.iter_instances(), 
                                        dev_data_loader.iter_instances()),
                                  min_count={'tokens': 3})

这里需要注意几点:首先,因为我们正在处理迭代器(由数据加载器的 iter_instances()方法返回),所以我们需要使用 itertools 的 chain 方法来枚举两个数据集中的所有实例。

其次,AllenNLP 的 Vocabulary 类支持命名空间,这是一种将不同的项目集分开的系统,以防它们混淆。这是为什么它们很有用——假设你正在构建一个机器翻译系统,并且刚刚读取了一个包含英语和法语翻译的数据集。如果没有命名空间,你将只有一个包含所有英语和法语单词的集合。在大多数情况下,这通常不是一个大问题,因为英语单词(“hi,” “thank you,” “language”)和法语单词(“bonjour,” “merci,” “langue”)在大多数情况下看起来非常不同。然而,一些单词在两种语言中看起来完全相同。例如,“chat”在英语中意思是“talk”,在法语中是“cat”,但很难想象有人想要混淆这两个词并分配相同的 ID(和嵌入)。为了避免这种冲突,Vocabulary 实现了命名空间并为不同类型的项目分配了单独的集合。

你可能注意到form_instances()函数调用有一个min_count参数。对于每个命名空间,它指定了数据集中必须出现的最小次数,以便将项目包含在词汇表中。所有出现频率低于此阈值的项目都被视为“未知”项目。这是一个好主意的原因是:在典型的语言中,很少有一些词汇会频繁出现(英语中的“the”,“a”,“of”),而有很多词汇出现的频率很低。这通常表现为词频的长尾分布。但这些频率极低的词汇不太可能对模型有任何有用的信息,并且正因为它们出现频率较低,从中学习有用的模式也很困难。此外,由于这些词汇有很多,它们会增加词汇表的大小和模型参数的数量。在这种情况下,自然语言处理中常见的做法是截去这长尾部分,并将所有出现频率较低的词汇合并为一个单一的实体(表示“未知”词汇)。

最后,令牌索引器是 AllenNLP 的一个抽象概念,它接收一个令牌并返回其索引,或者返回表示令牌的索引列表。在大多数情况下,独特令牌和其索引之间存在一对一的映射,但根据您的模型,您可能需要更高级的方式来对令牌进行索引(例如使用字符 n-gram)。

创建词汇表后,你可以告诉数据加载器使用指定的词汇表对令牌进行索引,如下代码片段所示。这意味着数据加载器从数据集中读取的令牌会根据词汇表的映射转换为整数 ID:

train_data_loader.index_with(vocab)
dev_data_loader.index_with(vocab)

4.4.3 令牌嵌入和 RNN

在使用词汇表和令牌索引器索引单词后,需要将它们转换为嵌入。一个名为 TokenEmbedder 的 AllenNLP 抽象来接收单词索引作为输入并将其转换为单词嵌入向量作为输出。你可以使用多种方式嵌入连续向量,但如果你只想将唯一的令牌映射到嵌入向量时,可以使用 Embedding 类,如下所示:

token_embedding = Embedding(
    num_embeddings=vocab.get_vocab_size('tokens'),
    embedding_dim=EMBEDDING_DIM)

这将创建一个 Embedding 实例,它接收单词 ID 并以一对一的方式将其转换为定长矢量。该实例支持的唯一单词数量由 num_embeddings 给出,它等于令牌词汇的大小。嵌入的维度(即嵌入矢量的长度)由 embedding_dim 给出。

接下来,让我们定义我们的 RNN,并将变长输入(嵌入词的列表)转换为输入的定长矢量表示。正如我们在第 4.1 节中讨论的那样,你可以将 RNN 看作是一个神经网络结构,它消耗一个序列的事物(词汇)并返回一个定长的矢量。AllenNLP 将这样的模型抽象化为 Seq2VecEncoder 类,你可以通过使用 PytorchSeq2VecWrapper 创建一个 LSTM RNN,如下所示:

encoder = PytorchSeq2VecWrapper(
    torch.nn.LSTM(EMBEDDING_DIM, HIDDEN_DIM, batch_first=True))

这里发生了很多事情,但本质上是将 PyTorch 的 LSTM 实现(torch.nn.LSTM)包装起来,使其可以插入到 AllenNLP 流程中。torch.nn.LSTM()的第一个参数是输入向量的维度,第二个参数是 LSTM 的内部状态的维度。最后一个参数 batch_first 指定了用于批处理的输入/输出张量的结构,但只要你使用 AllenNLP,你通常不需要担心其细节。

注意:在 AllenNLP 中,一切都是以批为单位,意味着任何张量的第一个维度始终等于批中实例的数量。

4.4.4 构建你自己的模型

现在我们已经定义了所有的子组件,我们准备构建执行预测的模型了。由于 AllenNLP 的良好抽象设计,你可以通过继承 AllenNLP 的 Model 类并覆盖 forward()方法来轻松构建你的模型。通常情况下,你不需要关注张量的形状和维度等细节。以下清单定义了用于分类句子的 LSTM RNN。

清单 4.1 LSTM 句子分类器

@Model.register("lstm_classifier")
class LstmClassifier(Model):                                   ❶
    def __init__(self,
                 embedder: TextFieldEmbedder,
                 encoder: Seq2VecEncoder,
                 vocab: Vocabulary,
                 positive_label: str = '4') -> None:
        super().__init__(vocab)
        self.embedder = embedder
        self.encoder = encoder

        self.linear = torch.nn.Linear(                         ❷
            in_features=encoder.get_output_dim(),
            out_features=vocab.get_vocab_size('labels'))

        positive_index = vocab.get_token_index(
            positive_label, namespace='labels')
        self.accuracy = CategoricalAccuracy()
        self.f1_measure = F1Measure(positive_index)            ❸

        self.loss_function = torch.nn.CrossEntropyLoss()       ❹

    def forward(self,                                          ❺
                tokens: Dict[str, torch.Tensor],
                label: torch.Tensor = None) -> torch.Tensor:
        mask = get_text_field_mask(tokens)

        embeddings = self.embedder(tokens)
        encoder_out = self.encoder(embeddings, mask)
        logits = self.linear(encoder_out)

        output = {"logits": logits}                            ❻
        if label is not None:
            self.accuracy(logits, label)
            self.f1_measure(logits, label)
            output["loss"] = self.loss_function(logits, label)

        return output

    def get_metrics(self, reset: bool = False) -> Dict[str, float]:
        return {'accuracy': self.accuracy.get_metric(reset),   ❼
                **self.f1_measure.get_metric(reset)}

❶ AllenNLP 模型继承自 Model。

❷ 创建线性层将 RNN 输出转换为另一个长度的向量

❸ F1Measure()需要正类的标签 ID。'4'表示“非常积极”。

❹ 用于分类任务的交叉熵损失。CrossEntropyLoss 直接接受 logits(不需要 softmax)。

❺ 实例被解构为各个字段并传递给 forward()。

❻ forward()的输出是一个字典,其中包含一个“loss”键。

❼ 返回准确率、精确率、召回率和 F1 分数作为度量标准

每个 AllenNLP 模型都继承自 PyTorch 的 Module 类,这意味着如果需要,你可以使用 PyTorch 的低级操作。这为你在定义模型时提供了很大的灵活性,同时利用了 AllenNLP 的高级抽象。

4.4.5 把所有东西都放在一起

最后,我们通过实现整个流程来训练情感分析器,如下所示。

清单 4.2 情感分析器的训练流程

EMBEDDING_DIM = 128
HIDDEN_DIM = 128

reader = StanfordSentimentTreeBankDatasetReader()

train_path = 'path/to/sst/dataset/train.txt'
dev_path = 'path/to/sst/dataset/dev.txt'

sampler = BucketBatchSampler(batch_size=32, sorting_keys=["tokens"])
train_data_loader = MultiProcessDataLoader(                            ❶
    reader, train_path, batch_sampler=sampler)
dev_data_loader = MultiProcessDataLoader(
    reader, dev_path, batch_sampler=sampler)

vocab = Vocabulary.from_instances(chain(train_data_loader.iter_instances(), 
                                        dev_data_loader.iter_instances()),
                                  min_count={'tokens': 3})

train_data_loader.index_with(vocab)
dev_data_loader.index_with(vocab)

token_embedding = Embedding(
    num_embeddings=vocab.get_vocab_size('tokens'),
    embedding_dim=EMBEDDING_DIM)

word_embeddings = BasicTextFieldEmbedder({"tokens": token_embedding})

encoder = PytorchSeq2VecWrapper(
    torch.nn.LSTM(EMBEDDING_DIM, HIDDEN_DIM, batch_first=True))

model = LstmClassifier(word_embeddings, encoder, vocab)               ❷

optimizer = optim.Adam(model.parameters())                            ❸

trainer = GradientDescentTrainer(                                     ❹
    model=model,
    optimizer=optimizer,
    data_loader=train_data_loader,
    validation_data_loader=dev_data_loader,
    patience=10,
    num_epochs=20,
    cuda_device=-1)

trainer.train()

❶ 定义如何构造数据加载器

❷ 初始化模型

❸ 定义优化器

❹ 初始化训练器

当创建 Trainer 实例并调用 train()时,训练流程完成。你需要传递所有用于训练的要素,包括模型、优化器、数据加载器、数据集和一堆超参数。

优化器实现了一个调整模型参数以最小化损失的算法。在这里,我们使用一种称为Adam的优化器,这是你作为首选项的一个很好的“默认”优化器。然而,正如我在第二章中提到的,你经常需要尝试许多不同的优化器,找出对你的模型效果最好的那一个。

4.5 配置 AllenNLP 训练流程

你可能已经注意到,列表 4.2 中很少有实际针对句子分类问题的内容。事实上,加载数据集、初始化模型,并将迭代器和优化器插入训练器是几乎每个 NLP 训练管道中的常见步骤。如果您想要为许多相关任务重复使用相同的训练管道而不必从头编写训练脚本呢?另外,如果您想要尝试不同配置集(例如,不同的超参数、神经网络架构)并保存您尝试过的确切配置呢?

对于这些问题,AllenNLP 提供了一个便捷的框架,您可以在 JSON 格式的配置文件中编写配置。其思想是您在 JSON 格式文件中编写您的训练管道的具体内容——例如要使用哪个数据集读取器、要使用哪些模型及其子组件,以及用于训练的哪些超参数。然后,您将配置文件提供给 AllenNLP 可执行文件,框架会负责运行训练管道。如果您想尝试模型的不同配置,只需更改配置文件(或创建一个新文件),然后再次运行管道,而无需更改 Python 代码。这是一种管理实验并使其可重现的良好实践。您只需管理配置文件及其结果——相同的配置始终产生相同的结果。

典型的 AllenNLP 配置文件由三个主要部分组成——数据集、您的模型和训练管道。下面是第一部分,指定了要使用的数据集文件以及如何使用:

"dataset_reader": {
    "type": "sst_tokens"
  },
  "train_data_path": "https:/./s3.amazonaws.com/realworldnlpbook/data/stanfordSentimentTreebank/trees/train.txt",
  "validation_data_path": "https:/./s3.amazonaws.com/realworldnlpbook/data/stanfordSentimentTreebank/trees/dev.txt"

此部分有三个键:dataset_reader、train_data_path 和 validation_data_path。第一个键 dataset_reader 指定要使用哪个 DatasetReader 来读取文件。在 AllenNLP 中,数据集读取器、模型、预测器以及许多其他类型的模块都可以使用装饰器语法注册,并且可以在配置文件中引用。例如,如果您查看下面定义了 StanfordSentimentTreeBankDatasetReader 的代码

@DatasetReader.register("sst_tokens")
class StanfordSentimentTreeBankDatasetReader(DatasetReader): 
    ...

你注意到它被 @DatasetReader.register("sst_tokens") 装饰。这将 StanfordSentimentTreeBankDatasetReader 注册为 sst_tokens,使您可以通过配置文件中的 "type": "sst_tokens" 来引用它。

在配置文件的第二部分,您可以如下指定要训练的主要模型:

"model": {
    "type": "lstm_classifier",

    "embedder": {
      "token_embedders": {
        "tokens": {
          "type": "embedding",
          "embedding_dim": embedding_dim
        }
      }
    },

    "encoder": {
      "type": "lstm",
      "input_size": embedding_dim,
      "hidden_size": hidden_dim
    }
}

如前所述,AllenNLP 中的模型可以使用装饰器语法注册,并且可以通过 type 键从配置文件中引用。例如,这里引用的 LstmClassifier 类定义如下:

@Model.register("lstm_classifier")
class LstmClassifier(Model):
    def __init__(self,
                 embedder: TextFieldEmbedder,
                 encoder: Seq2VecEncoder,
                 vocab: Vocabulary,
                 positive_label: str = '4') -> None:

模型定义 JSON 字典中的其他键对应于模型构造函数的参数名称。在前面的定义中,因为 LstmClassifier 的构造函数接受了两个参数,word_embeddings 和 encoder(除了 vocab,它是默认传递的并且可以省略,以及 positive_label,我们将使用默认值),所以模型定义有两个相应的键,它们的值也是模型定义,并且遵循相同的约定。

在配置文件的最后部分,指定了数据加载器和训练器。这里的约定与模型定义类似——你指定类的类型以及传递给构造函数的其他参数,如下所示:

  "data_loader": {
    "batch_sampler": {
      "type": "bucket",
      "sorting_keys": ["tokens"],
      "padding_noise": 0.1,
      "batch_size" : 32
    }
  },
  "trainer": {
    "optimizer": "adam",
    "num_epochs": 20,
    "patience": 10
  }

你可以在代码仓库中查看完整的 JSON 配置文件(realworldnlpbook.com/ch4.html#sst-json)。一旦你定义了 JSON 配置文件,你就可以简单地将其提供给 allennlp 命令,如下所示:

allennlp train examples/sentiment/sst_classifier.jsonnet \
    --serialization-dir sst-model \
    --include-package examples.sentiment.sst_classifier

--serialization-dir 指定了训练模型(以及其他一些信息,如序列化的词汇数据)将要存储的位置。你还需要使用 --include-package 指定到 LstmClassifier 的模块路径,以便配置文件能够找到注册的类。

正如我们在第二章中所看到的,当训练完成时,你可以使用以下命令启动一个简单的基于 web 的演示界面:

$ allennlp serve \ 
    --archive-path sst-model/model.tar.gz \
    --include-package examples.sentiment.sst_classifier \
    --predictor sentence_classifier_predictor \
    --field-name sentence

4.6 案例研究:语言检测

在本章的最后一节中,我们将讨论另一个场景——语言检测,它也可以被归纳为一个句子分类任务。语言检测系统,给定一段文本,检测文本所写的语言。它在其他自然语言处理应用中有着广泛的用途。例如,一个网络搜索引擎可能会在处理和索引网页之前检测网页所写的语言。Google 翻译还会根据输入文本框中键入的内容自动切换源语言。

让我们看看这实际上是什么样子。你能告诉下面每一句话是哪种语言吗?这些句子都来自 Tatoeba 项目(tatoeba.org/)。

我们需要你的帮助。

请考虑一下。

他们讨论了离开的计划。

我不知道我能不能做到。

昨天你在家,对吗?

它是一种快速而有效的通讯工具。

他讲了一个小时。

我想去喝一杯。

Ttwaliɣ nezmer ad nili d imeddukal.

答案是:西班牙语、德语、土耳其语、法语、葡萄牙语、世界语、意大利语、匈牙利语和柏柏尔语。我从 Tatoeba 上排名前 10 的最受欢迎的使用拉丁字母表的语言中挑选了它们。你可能对这里列出的一些语言不熟悉。对于那些不熟悉的人来说,世界语是一种在 19 世纪末发明的构造辅助语言。柏柏尔语实际上是一组与阿拉伯语等闲语族语言表亲关系的在北非某些地区使用的语言。

或许你能够认出其中一些语言,尽管你实际上并不会说它们。我想让你退后一步思考你是如何做到的。很有趣的是,人们可以在不会说这种语言的情况下做到这一点,因为这些语言都是用拉丁字母表写成的,看起来可能非常相似。你可能认出了其中一些语言的独特变音符号(重音符号)——例如,德语的“ü”和葡萄牙语的“ã”。这些对于这些语言来说是一个强有力的线索。或者你只是认识一些单词——例如,西班牙语的“ayuda”(意思是“帮助”)和法语的“pas”(“ne...pas”是法语的否定句语法)。似乎每种语言都有其自己的特点——无论是一些独特的字符还是单词——使得它很容易与其他语言区分开来。这开始听起来很像是机器学习擅长解决的一类问题。我们能否构建一个能够自动执行此操作的 NLP 系统?我们应该如何构建它?

4.6.1 使用字符作为输入

语言检测器也可以以类似的方式构建情感分析器。你可以使用 RNN 读取输入文本并将其转换为一些内部表示(隐藏状态)。然后,你可以使用一个线性层将它们转换为一组分数,对应于文本写成每种语言的可能性。最后,你可以使用交叉熵损失来训练模型。

一个主要区别在于情感分析器和语言检测器如何将输入馈送到 RNN 中。构建情感分析器时,我们使用了斯坦福情感树库,并且能够假设输入文本始终为英文且已经被标记化。但是对于语言检测来说情况并非如此。实际上,你甚至不知道输入文本是否是易于标记化的语言所写成——如果句子是用中文写的呢?或者是用芬兰语写的,芬兰语以其复杂的形态而臭名昭著?如果你知道是什么语言,你可以使用特定于该语言的标记器,但我们正在构建语言检测器,因为我们一开始并不知道是什么语言。这听起来像是一个典型的先有鸡还是先有蛋的问题。

为了解决这个问题,我们将使用字符而不是标记作为 RNN 的输入。这个想法是将输入分解为单个字符,甚至包括空格和标点符号,并将它们逐个馈送给 RNN。当输入可以更好地表示为字符序列时(例如中文或未知来源的语言),或者当您希望充分利用单词的内部结构时(例如我们在第三章中提到的 fastText 模型)时,使用字符是一种常见的做法。RNN 的强大表现力仍然可以捕获先前提到的字符和一些常见单词和 n-gram 之间的交互。

创建数据集阅读器

对于这个语言检测任务,我从 Tatoeba 项目中创建了 train 和 validation 数据集,方法是选择使用罗马字母的 Tatoeba 上最受欢迎的 10 种语言,并对训练集采样 10,000 个句子,验证集采样 1,000 个句子。以下是该数据集的摘录:

por De entre os designers, ele escolheu um jovem ilustrador e deu-lhe a tarefa.
por A apresentação me fez chorar.
tur Bunu denememize gerek var mı?
tur O korkutucu bir parçaydı.
ber Tebḍamt aɣrum-nni ɣef sin, naɣ?
ber Ad teddud ad twalid taqbuct n umaḍal n tkurt n uḍar deg Brizil?
eng Tom works at Harvard.
eng They fixed the flat tire by themselves.
hun Az arca hirtelen elpirult.
hun Miért aggodalmaskodsz? Hiszen még csak egy óra van!
epo Sidiĝu sur la benko.
epo Tiu ĉi kutime funkcias.
fra Vu d'avion, cette île a l'air très belle.
fra Nous boirons à ta santé.
deu Das Abnehmen fällt ihm schwer.
deu Tom war etwas besorgt um Maria.
ita Sono rimasto a casa per potermi riposare.
ita Le due più grandi invenzioni dell'uomo sono il letto e la bomba atomica: il primo ti tiene lontano dalle noie, la seconda le elimina.
spa He visto la película.
spa Has hecho los deberes.

第一个字段是一个三个字母的语言代码,描述了文本所使用的语言。第二个字段是文本本身。字段由制表符分隔。您可以从代码存储库获取数据集(github.com/mhagiwara/realworldnlp/tree/master/data/tatoeba)。

构建语言检测器的第一步是准备一个能够读取这种格式数据集的数据集阅读器。在之前的例子(情感分析器)中,因为 AllenNLP 已经提供了 StanfordSentimentTreeBankDatasetReader,所以您只需要导入并使用它。然而,在这种情况下,您需要编写自己的数据集阅读器。幸运的是,编写一个能够读取这种特定格式的数据集阅读器并不那么困难。要编写数据集阅读器,您只需要做以下三件事:

  • 通过继承 DatasetReader 创建自己的数据集阅读器类。

  • 覆盖 text_to_instance()方法,该方法接受原始文本并将其转换为实例对象。

  • 覆盖 _read()方法,该方法读取文件的内容并通过调用上面的 text_to_instance()方法生成实例。

语言检测器的完整数据集阅读器如列表 4.3 所示。我们还假设您已经导入了必要的模块和类,如下所示:

from typing import Dict

import numpy as np
import torch
import torch.optim as optim
from allennlp.common.file_utils import cached_path
from allennlp.data.data_loaders import MultiProcessDataLoader
from allennlp.data.dataset_readers import DatasetReader
from allennlp.data.fields import LabelField, TextField
from allennlp.data.instance import Instance
from allennlp.data.samplers import BucketBatchSampler
from allennlp.data.token_indexers import TokenIndexer, SingleIdTokenIndexer
from allennlp.data.tokenizers.character_tokenizer import CharacterTokenizer
from allennlp.data.vocabulary import Vocabulary
from allennlp.modules.seq2vec_encoders import PytorchSeq2VecWrapper
from allennlp.modules.text_field_embedders import BasicTextFieldEmbedder
from allennlp.modules.token_embedders import Embedding
from allennlp.training import GradientDescentTrainer
from overrides import overrides

from examples.sentiment.sst_classifier import LstmClassifier

列表 4.3 用于语言检测器的数据集阅读器

class TatoebaSentenceReader(DatasetReader):                    ❶
    def __init__(self,
                 token_indexers: Dict[str, TokenIndexer]=None):
        super().__init__()
        self.tokenizer = CharacterTokenizer()                  ❷
        self.token_indexers = token_indexers or {'tokens': SingleIdTokenIndexer()}

    @overrides
    def text_to_instance(self, tokens, label=None):            ❸
        fields = {}

        fields['tokens'] = TextField(tokens, self.token_indexers)
        if label:
            fields['label'] = LabelField(label)

        return Instance(fields)

    @overrides
    def _read(self, file_path: str):
        file_path = cached_path(file_path)                     ❹
        with open(file_path, "r") as text_file:
            for line in text_file:
                lang_id, sent = line.rstrip().split('\t')

                tokens = self.tokenizer.tokenize(sent)

                yield self.text_to_instance(tokens, lang_id)   ❺

❶ 每个新的数据集阅读器都继承自 DatasetReader。

❷ 使用 CharacterTokenizer()将文本标记为字符

❸ 在测试时标签将为 None。

❹ 如果 file_path 是 URL,则返回磁盘上缓存文件的实际路径

❺ 使用之前定义的 text_to_instance()生成实例

请注意,列表 4.3 中的数据集阅读器使用 CharacterTokenizer()将文本标记为字符。它的 tokenize()方法返回一个标记列表,这些标记是 AllenNLP 对象,表示标记,但实际上在这种情况下包含字符。

构建训练管道

一旦构建了数据集阅读器,训练流水线的其余部分看起来与情感分析器的类似。 实际上,我们可以在不进行任何修改的情况下重用之前定义的 LstmClassifier 类。 整个训练流水线在列表 4.4 中显示。 您可以从这里访问整个代码的 Google Colab 笔记本:realworldnlpbook.com/ch4.html#langdetect

列表 4.4 语言检测器的训练流水线

EMBEDDING_DIM = 16
HIDDEN_DIM = 16

reader = TatoebaSentenceReader()
train_path = 'https:/./s3.amazonaws.com/realworldnlpbook/data/tatoeba/sentences.top10langs.train.tsv'
dev_path = 'https:/./s3.amazonaws.com/realworldnlpbook/data/tatoeba/sentences.top10langs.dev.tsv'

sampler = BucketBatchSampler(batch_size=32, sorting_keys=["tokens"])
train_data_loader = MultiProcessDataLoader(
    reader, train_path, batch_sampler=sampler)
dev_data_loader = MultiProcessDataLoader(
    reader, dev_path, batch_sampler=sampler)

vocab = Vocabulary.from_instances(train_data_loader.iter_instances(),
                                  min_count={'tokens': 3})
train_data_loader.index_with(vocab)
dev_data_loader.index_with(vocab)

token_embedding = Embedding(num_embeddings=vocab.get_vocab_size('tokens'),
                            embedding_dim=EMBEDDING_DIM)
word_embeddings = BasicTextFieldEmbedder({"tokens": token_embedding})
encoder = PytorchSeq2VecWrapper(
    torch.nn.LSTM(EMBEDDING_DIM, HIDDEN_DIM, batch_first=True))

model = LstmClassifier(word_embeddings,
                       encoder,
                       vocab,
                       positive_label='eng')

train_dataset.index_with(vocab)
dev_dataset.index_with(vocab)

optimizer = optim.Adam(model.parameters())

trainer = GradientDescentTrainer(
    model=model,
    optimizer=optimizer,
    data_loader=train_data_loader,
    validation_data_loader=dev_data_loader,
    patience=10,
    num_epochs=20,
    cuda_device=-1)

trainer.train()

运行此训练流水线时,您将获得与以下大致相当的开发集上的指标:

accuracy: 0.9461, precision: 0.9481, recall: 0.9490, f1_measure: 0.9485, loss: 0.1560

这一点一点也不糟糕! 这意味着训练过的检测器在约 20 个句子中只犯了一个错误。 0.9481 的精确度意味着在 20 个被分类为英文的实例中只有一个假阳性(非英文句子)。 0.9490 的召回率意味着在 20 个真正的英文实例中只有一个假阴性(被检测器漏掉的英文句子)。

4.6.4 在未见过的实例上运行检测器

最后,让我们尝试在一组未见过的实例(既不出现在训练集也不出现在验证集中的实例)上运行我们刚刚训练过的检测器。 尝试向模型提供少量实例并观察其行为始终是一个好主意。

将实例提供给训练过的 AllenNLP 模型的推荐方法是使用预测器,就像我们在第二章中所做的那样。 但在这里,我想做一些更简单的事情,而是编写一个方法,给定一段文本和一个模型,运行预测流水线。 要在任意实例上运行模型,可以调用模型的 forward_on_instances() 方法,如下面的代码片段所示:

def classify(text: str, model: LstmClassifier):
    tokenizer = CharacterTokenizer()
    token_indexers = {'tokens': SingleIdTokenIndexer()}

    tokens = tokenizer.tokenize(text)
    instance = Instance({'tokens': TextField(tokens, token_indexers)})
    logits = model.forward_on_instance(instance)['logits']
    label_id = np.argmax(logits)
    label = model.vocab.get_token_from_index(label_id, 'labels')

    print('text: {}, label: {}'.format(text, label))

此方法首先接受输入(文本和模型)并通过分词器将其传递以创建实例对象。 然后,它调用模型的 forward_on_instance() 方法来检索 logits,即目标标签(语言)的分数。 通过调用 np.argmax 获取对应于最大 logit 值的标签 ID,然后通过使用与模型关联的词汇表对象将其转换为标签文本。

当我对一些不在这两个数据集中的句子运行此方法时,我得到了以下结果。 请注意,由于一些随机性,您得到的结果可能与我的不同:

text: Take your raincoat in case it rains., label: fra
text: Tu me recuerdas a mi padre., label: spa
text: Wie organisierst du das Essen am Mittag?, label: deu
text: Il est des cas où cette règle ne s'applique pas., label: fra
text: Estou fazendo um passeio em um parque., label: por
text: Ve, postmorgaŭ jam estas la limdato., label: epo
text: Credevo che sarebbe venuto., label: ita
text: Nem tudja, hogy én egy macska vagyok., label: hun
text: Nella ur nli qrib acemma deg tenwalt., label: ber
text: Kurşun kalemin yok, deǧil mi?, label: tur

这些预测几乎完美,除了第一句话——它是英文,而不是法文。 令人惊讶的是,模型在预测更难的语言(如匈牙利语)时完美无误地犯了一个看似简单的错误。 但请记住,对于英语为母语者来说,语言有多难并不意味着计算机分类时有多难。 实际上,一些“困难”的语言,比如匈牙利语和土耳其语,具有非常清晰的信号(重音符号和独特的单词),这使得很容易检测它们。 另一方面,第一句话中缺乏清晰的信号可能使它更难以从其他语言中分类出来。

作为下一步,你可以尝试一些事情:例如,你可以调整一些超参数,看看评估指标和最终预测结果如何变化。你还可以尝试增加测试实例的数量,以了解错误是如何分布的(例如,在哪两种语言之间)。你还可以把注意力集中在一些实例上,看看模型为什么会犯这样的错误。这些都是在处理真实世界的自然语言处理应用时的重要实践。我将在第十章中详细讨论这些话题。

摘要

  • 循环神经网络(RNN)是一种带有循环的神经网络。它可以将可变长度的输入转换为固定长度的向量。

  • 非线性是使神经网络真正强大的关键组成部分。

  • LSTM 和 GRU 是 RNN 单元的两个变体,比原始的 RNN 更容易训练。

  • 在分类问题中,你可以使用准确率、精确度、召回率和 F-度量来评估。

  • AllenNLP 提供了有用的自然语言处理抽象,例如数据集读取器、实例和词汇表。它还提供了一种以 JSON 格式配置训练流水线的方法。

  • 你可以构建一个类似于情感分析器的句子分类应用来实现语言检测器。

第五章:顺序标注和语言建模

本章涵盖

  • 使用顺序标注解决词性标注(POS)和命名实体识别(NER)

  • 使 RNNs 更强大——多层和双向循环神经网络(RNNs)

  • 使用语言模型捕捉语言的统计特性

  • 使用语言模型评估和生成自然语言文本

在本章中,我们将讨论顺序标注——一个重要的自然语言处理框架,系统会为每个单词打上相应的标签。许多自然语言处理应用,如词性标注和命名实体识别,可以被构建为顺序标注任务。在本章的后半部分,我将介绍语言模型的概念,这是自然语言处理中最基本但也最令人兴奋的主题之一。我将谈论它们为何重要以及如何使用它们来评估甚至生成一些自然语言文本。

5.1 介绍顺序标注

在上一章中,我们讨论了句子分类,任务是为给定的句子分配一些标签。垃圾邮件过滤、情感分析和语言检测是句子分类的一些具体例子。尽管许多现实世界的自然语言处理问题可以被规约为句子分类任务,但这种方法也可能相当有限,因为根据定义,该模型只允许我们为整个句子分配一个单一的标签。但如果你想要更细粒度的东西呢?例如,如果你想要对单个单词做一些操作,而不仅仅是句子呢?你遇到的最典型的场景是当你想要从句子中提取一些东西时,这并不能很容易地通过句子分类来解决。这就是顺序标注发挥作用的地方。

5.1.1 顺序标注是什么?

顺序标注 是一种自然语言处理任务,给定一个序列,比如一个句子,NLP 系统会为输入序列的每个元素(比如单词)分配一个标签。这与句子分类形成对比,句子分类仅为输入句子分配一个标签。图 5.1 展示了这种对比。

CH05_F01_Hagiwara

图 5.1 句子分类与顺序标注

但为什么这是个好主意呢?我们什么时候需要每个词都有一个标签?顺序标注非常方便的一个典型场景是当你想要分析一个句子并为每个词生成一些语言学信息。例如,词性标注(POS)就是一个很好的例子,如我在第一章中提到的,它为输入句子中的每个单词生成一个词性标签,比如名词、动词和介词,非常适合顺序标注。请参见图 5.2 进行说明。

CH05_F02_Hagiwara

图 5.2 使用顺序标注进行词性标注(POS)

词性标注是最基础、最重要的自然语言处理任务之一。许多英语单词(以及许多其他语言的单词)都是有歧义的,意味着它们有多种可能的解释。例如,单词“book”可以用来描述由页面组成的物理或电子对象(“我读了一本书”),也可以用来描述预订某物的行为(“我需要预订一次航班”)。下游的自然语言处理任务,比如解析和分类,在知道每个“book”的出现实际上意味着什么以便处理输入句子时受益匪浅。如果你要构建一个语音合成系统,你必须知道某些单词的词性才能正确地发音——名词“lead”(一种金属)与“bed”押韵,而动词“lead”(指导,引导)与“bead”押韵。词性标注是解决这种歧义的重要第一步。

另一个场景是当你想要从一个句子中提取一些信息片段时。例如,如果你想要提取名词短语和动词短语等子序列(短语),这也是一个序列标记任务。你如何使用标记来实现提取?这个想法是使用标记来标记所需信息片段的开始和结束(或开始和继续,取决于你如何表示它)。命名实体识别(NER)就是一个例子,它是从一个句子中识别真实世界实体的任务,比如专有名词和数字表达式(在图 5.3 中说明)。

CH05_F03_Hagiwara

图 5.3 使用序列标记的命名实体识别(NER)

注意,所有不属于任何命名实体的单词都被标记为 O(代表“外部”)。目前,你可以忽略图 5.3 中一些神秘的标签,比如 B-GPE 和 I-MONEY。在第 5.4 节中,我会更多地讨论如何将命名实体识别问题制定为一个序列标记问题。

5.1.2 使用 RNNs 编码序列

在句子分类中,我们使用递归神经网络(RNNs)将可变长度的输入转换为固定长度的向量。这个固定长度的向量通过一个线性层转换为一组“分数”,捕获了关于输入句子的信息,这对于推导句子标签是必要的。作为提醒,这个 RNN 的作用可以用以下伪代码和图 5.4 中显示的图表来表示:

def rnn_vec(words):
    state = init_state()
    for word in words:
        state = update(state, word)
    return state

CH05_F04_Hagiwara

图 5.4 句子分类的递归神经网络(RNN)

什么样的神经网络可以用于序列标记?我们似乎需要句子中每个输入单词的一些信息,而不仅仅是在末尾。如果您仔细查看 rnn_vec()的伪代码,您会注意到我们已经有了输入中每个单词的信息,这些信息由状态捕获。该函数恰好只返回状态的最终值,但我们没有理由不能存储状态的中间值并将它们作为列表返回,就像以下函数一样:

def rnn_seq(words):
    state = init_state()
    states = []
    for word in words:
        state = update(state, word)
        states.append(state)
    return states

如果你将此函数应用于图 5.2 中显示的“time flies”示例并展开它——也就是说,不使用循环写出它——它将如下所示:

state = init_state()
states = []
state = update(state, v("time"))
states.append(state)
state = update(state, v("flies"))
states.append(state)
state = update(state, v("like"))
states.append(state)
state = update(state, v("an"))
states.append(state)
state = update(state, v("arrow"))
states.append(state)
state = update(state, v("."))
states.append(state)

这里的 v()是一个函数,它返回给定单词的嵌入。这可以通过图 5.5 中所示的方式进行可视化。请注意,对于每个输入单词 word,网络都会产生捕获有关该单词的一些信息的相应状态。状态列表 states 的长度与 words 的长度相同。状态的最终值,即 states[-1],与先前的 rnn_vec()的返回值相同。

CH05_F05_Hagiwara

图 5.5 用于序列标记的递归神经网络(RNN)

如果将这个循环神经网络视为一个黑匣子,它接受一系列东西(例如,词嵌入)并将其转换为编码有关输入中各个单词信息的向量序列,因此该架构在 AllenNLP 中被称为Seq2Seq(代表“序列到序列”)编码器。

最后一步是将这个 RNN 的每个状态应用于一个线性层,以得到对每个标签的可能性的一组分数。如果这是一个词性标注器,我们

对于标签 NOUN,需要一个分数,对于 VERB,需要另一个分数,以此类推,适用于每个单词。此转换如图 5.6 所示。请注意,相同的线性层(具有相同的参数集)应用于每个状态。

CH05_F06_Hagiwara

图 5.6 将线性层应用于 RNN

总结一下,我们可以使用几乎与我们用于句子分类相同的结构进行序列标记,唯一的区别是前者为每个单词生成一个隐藏状态,而不仅仅是每个句子。要生成用于确定标签的分数,必须将线性层应用于每个隐藏状态。

5.1.3 在 AllenNLP 中实现 Seq2Seq 编码器

AllenNLP 实现了一个称为 Seq2SeqEncoder 的抽象类,用于抽象化所有接受向量序列并返回另一个修改后向量序列的 Seq2Seq 编码器。理论上,您可以继承该类并实现自己的 Seq2Seq 编码器。然而,在实践中,您很可能会使用 PyTorch/AllenNLP 提供的现成实现之一,例如 LSTM 和 GRU。请记住,当我们为情感分析器构建编码器时,我们使用了 PyTorch 的内置 torch.nn.LSTM,并将其包装为 PytorchSeq2VecWrapper,如下所示,这使其与 AllenNLP 的抽象兼容:

encoder = PytorchSeq2VecWrapper(
    torch.nn.LSTM(EMBEDDING_DIM, HIDDEN_DIM, batch_first=True))

AllenNLP 还实现了 PytorchSeq2SeqWrapper,它使用 PyTorch 的内置 RNN 实现之一,并使其符合 AllenNLP 的 Seq2SeqEncoder,因此你需要做的很少,只需要像这样初始化一个 Seq2Seq 编码器:

encoder = PytorchSeq2SeqWrapper(
    torch.nn.LSTM(EMBEDDING_DIM, HIDDEN_DIM, batch_first=True))

就是这样!还有一些需要注意的地方,但是你会惊奇地发现,为了使其用于顺序标记,你需要进行的更改很少,这得益于 AllenNLP 的强大抽象——大部分时间你只需要关心各个组件如何相互作用,而不需要关心这些组件工作的实现方式。

5.2 构建一个词性标注器

在本节中,我们将构建我们的第一个顺序标记应用程序—一个词性(POS)标注器。您可以在 Google Colab 笔记本上查看此部分的全部代码(realworldnlpbook.com/ch5.html#pos-nb)。我们假设您已经导入了所有必要的依赖项,如下所示:

from itertools import chain
from typing import Dict

import numpy as np
import torch
import torch.optim as optim

from allennlp.data.data_loaders import MultiProcessDataLoader
from allennlp.data.samplers import BucketBatchSampler
from allennlp.data.vocabulary import Vocabulary
from allennlp.models import Model
from allennlp.modules.seq2seq_encoders import Seq2SeqEncoder, PytorchSeq2SeqWrapper
from allennlp.modules.text_field_embedders import TextFieldEmbedder, BasicTextFieldEmbedder
from allennlp.modules.token_embedders import Embedding
from allennlp.nn.util import get_text_field_mask, sequence_cross_entropy_with_logits
from allennlp.training.metrics import CategoricalAccuracy
from allennlp.training import GradientDescentTrainer
from allennlp_models.structured_prediction.dataset_readers.universal_dependencies import UniversalDependenciesDatasetReader

from realworldnlp.predictors import UniversalPOSPredictor

5.2.1 读取数据集

如我们在第一章中所看到的那样,词性(POS)是一组共享相似语法属性的词汇类别。词性标注是将句子中的每个单词与相应的词性标记进行标记化的过程。用于 POS 标注的训练集遵循一组预定义的语言 POS 标签集。

要训练一个词性标注器,我们需要一个数据集,其中每个句子中的每个单词都标有相应的词性标记。在这个实验中,我们将使用英语 Universal Dependencies (UD)数据集。Universal Dependencies 是一个由一群研究者开发的语言无关的依存语法框架。UD 还定义了一个标签集,称为通用词性标记集 (realworldnlpbook.com/ch1.html#universal-pos)。UD 和 Universal POS 标记集的使用在 NLP 社区中非常流行,尤其是在诸如词性标注和解析等语言无关任务和模型中。

我们将使用 UD 的一个子语料库,名为*"A Gold Standard Universal Dependencies Corpus for English",该语料库建立在英语 Web Treebank (EWT)之上(realworldnlpbook.com/ch5.html#ewt),并可在创作共用许可下使用。如需要,您可以从数据集页面(realworldnlpbook.com/ch5.html#ewt-data)下载整个数据集。

Universal Dependencies 数据集以一种称为CoNLL-U 格式universaldependencies.org/docs/format.html)的格式分发。AllenNLP 模型包已经实现了一个名为 UniversalDependenciesDatasetReader 的数据集读取器,它以这种格式读取数据集,并返回包含词形、词性标签和依赖关系等信息的实例集合,因此你只需初始化并使用它,如下所示:

reader = UniversalDependenciesDatasetReader()
train_path = ('https:/./s3.amazonaws.com/realworldnlpbook/data/'
              'ud-treebanks-v2.3/UD_English-EWT/en_ewt-ud-train.conllu')
dev_path = ('https:/./s3.amazonaws.com/realworldnlpbook/'
            'data/ud-treebanks-v2.3/UD_English-EWT/en_ewt-ud-dev.conllu')

还有,不要忘记初始化数据加载器和一个词汇表实例,如下所示:

sampler = BucketBatchSampler(batch_size=32, sorting_keys=["words"])
train_data_loader = MultiProcessDataLoader(
    reader, train_path, batch_sampler=sampler)
dev_data_loader = MultiProcessDataLoader(
    reader, dev_path, batch_sampler=sampler)

vocab = Vocabulary.from_instances(chain(train_data_loader.iter_instances(),
                                        dev_data_loader.iter_instances()))
train_data_loader.index_with(vocab)
dev_data_loader.index_with(vocab)

5.2.2 定义模型和损失

构建词性标注器的下一步是定义模型。在前面的部分中,我们已经看到你可以使用 AllenNLP 内置的 PytorchSeq2VecWrapper 很少修改就初始化一个 Seq2Seq 编码器。让我们按照以下方式定义其他组件(词嵌入和 LSTM)以及模型所需的一些变量:

EMBEDDING_SIZE = 128
HIDDEN_SIZE = 128

token_embedding = Embedding(num_embeddings=vocab.get_vocab_size('tokens'),
                            embedding_dim=EMBEDDING_SIZE)
word_embeddings = BasicTextFieldEmbedder({"tokens": token_embedding})

lstm = PytorchSeq2SeqWrapper(
    torch.nn.LSTM(EMBEDDING_SIZE, HIDDEN_SIZE, batch_first=True))

现在我们准备定义词性标注器模型的主体,如下所示。

清单 5.1 词性标注器模型

class LstmTagger(Model):
    def __init__(self,
                 embedder: TextFieldEmbedder,
                 encoder: Seq2SeqEncoder,
                 vocab: Vocabulary) -> None:
        super().__init__(vocab)
        self.embedder = embedder
        self.encoder = encoder

        self.linear = torch.nn.Linear(
            in_features=encoder.get_output_dim(),
            out_features=vocab.get_vocab_size('pos'))

        self.accuracy = CategoricalAccuracy()                    ❶

    def forward(self,
                words: Dict[str, torch.Tensor],
                pos_tags: torch.Tensor = None,
                **args) -> Dict[str, torch.Tensor]:              ❷
        mask = get_text_field_mask(words)

        embeddings = self.embedder(words)
        encoder_out = self.encoder(embeddings, mask)
        tag_logits = self.linear(encoder_out)

        output = {"tag_logits": tag_logits}
        if pos_tags is not None:
            self.accuracy(tag_logits, pos_tags, mask)
            output["loss"] = sequence_cross_entropy_with_logits(
                tag_logits, pos_tags, mask)                      ❸

        return output

    def get_metrics(self, reset: bool = False) -> Dict[str, float]:
        return {"accuracy": self.accuracy.get_metric(reset)}

❶ 我们使用准确度来评估词性标注器。

❷ 我们需要**args 来捕获 AllenNLP 自动解构的不必要的实例字段。

❸ 使用序列交叉熵损失训练 Seq2Seq 编码器。

注意,清单 5.1 中显示的代码与我们用于构建情感分析器的 LstmClassifier 代码(清单 4.1)非常相似。实际上,除了一些命名差异之外,只存在一个基本差异——损失函数的类型。

回想一下,我们在句子分类任务中使用了一种称为交叉熵的损失函数,它基本上衡量了两个分布之间的距离。如果模型产生了真实标签的高概率,损失将很低。否则,它将很高。但是这假设每个句子只有一个标签。当每个词只有一个标签时,我们如何衡量预测与真实标签的差距?

答案是:仍然使用交叉熵,但是将其平均化到输入序列中的所有元素上。对于词性标注,你计算每个词的交叉熵,就像它是一个单独的分类任务一样,将其求和到输入句子中的所有词上,并除以句子的长度。这将给你一个反映你的模型平均预测输入句子的词性标签的好坏程度的数字。查看图 5.7 进行说明。

CH05_F07_Hagiwara

图 5.7 计算序列的损失

关于评估指标,POS 标注器通常使用准确率进行评估,我们将在这里使用。POS 标注的平均人类表现约为 97%,而最先进的 POS 标注器略高于此(realworldnlp book.com/ch5.html#pos-sota)。然而,需要注意准确率并非没有问题——假设存在一个相对罕见的 POS 标签(例如 SCONJ,表示从属连接),它仅占总标记数的 2%,而一个 POS 标注器每次出现都会搞砸它。如果标注器将其余标记都正确识别,则仍可达到 98% 的准确率。

5.2.3 构建训练流水线

现在我们准备好开始构建训练流水线了。与之前的任务一样,AllenNLP 中的训练流水线看起来非常相似。请查看下一个清单以查看训练代码。

清单 5.2 POS 标注器的训练流水线

model = LstmTagger(word_embeddings, encoder, vocab)

optimizer = optim.Adam(model.parameters())

trainer = GradientDescentTrainer(
    model=model,
    optimizer=optimizer,
    data_loader=train_data_loader,
    validation_data_loader=dev_data_loader,
    patience=10,
    num_epochs=10,
    cuda_device=-1)

trainer.train()

当运行此代码时,AllenNLP 会在两个阶段之间交替进行:1)使用训练集训练模型,2)使用验证集评估每个时代,同时监控两个集合上的损失和准确率。经过几个时代后,验证集的准确率会在约 88% 左右稳定。训练结束后,您可以运行下面显示的模型以查看一个未见过的实例:

predictor = UniversalPOSPredictor(model, reader)
tokens = ['The', 'dog', 'ate', 'the', 'apple', '.']
logits = predictor.predict(tokens)['tag_logits']
tag_ids = np.argmax(logits, axis=-1)

print([vocab.get_token_from_index(tag_id, 'pos') for tag_id in tag_ids])

此代码使用了 UniversalPOSPredictor,这是我为这个特定的 POS 标注器编写的一个预测器。虽然其细节并不重要,但如果您感兴趣,可以查看它的代码(realworldnlpbook.com/ch5#upos-predictor)。如果成功,这将显示一个 POS 标签列表:['DET', 'NOUN', 'VERB', 'DET', 'NOUN', 'PUNCT'],这确实是输入句子的正确 POS 标签序列。

5.3 多层和双向 RNN

正如我们迄今所见,RNN 是构建 NLP 应用程序的强大工具。在本节中,我将讨论它们的结构变体——多层和双向 RNN,这些是构建高度准确的 NLP 应用程序的更强大组件。

5.3.1 多层 RNN

如果将 RNN 视为黑盒子,则它是一个将一系列向量(单词嵌入)转换为另一系列向量(隐藏状态)的神经网络结构。输入和输出序列的长度相同,通常是输入标记的数量。这意味着您可以通过将 RNN 堆叠在一起多次重复这个“编码”过程。一个 RNN 的输出(隐藏状态)成为上面的另一个 RNN 的输入,这个 RNN 刚好位于前一个 RNN 的上面。较大神经网络的子结构(例如单个 RNN)称为,因为您可以像层一样将它们堆叠在一起。两层 RNN 的结构如图 5.8 所示。

CH05_F08_Hagiwara

图 5.8 两层 RNN

为什么这是一个好主意呢?如果你将 RNN 的一层看作是一个接受具体输入(例如,单词嵌入)并提取一些抽象概念(例如,POS 标签的得分)的机器,你可以期望,通过重复这个过程,RNN 能够随着层数的增加提取越来越抽象的概念。尽管没有完全经过理论证明,但许多真实世界的 NLP 应用都使用了多层 RNNs。例如,谷歌的神经机器翻译(NMT)系统使用了一个包括八层编码器和解码器的堆叠 RNN(realworldnlpbook.com/ch5.html#nmt-paper)。

要在你的 NLP 应用中使用多层 RNNs,你需要做的只是改变编码器的初始化方式。具体来说,你只需要使用 num_layers 参数指定层数,就像下一个代码片段中所示的那样,而 AllenNLP 会确保训练管道的其余部分按原样工作:

encoder = PytorchSeq2SeqWrapper(
    torch.nn.LSTM(
        EMBEDDING_SIZE, HIDDEN_SIZE, num_layers=2, batch_first=True))

如果你更改了这一行并重新运行 POS 标记器训练管道,你会注意到在验证集上的准确率几乎没有变化,或者略低于前一个单层 RNN 模型。这并不奇怪——进行 POS 标记所需的信息大多是表面的,比如被标记的单词的身份和相邻单词。很少情况下需要深入理解输入句子。另一方面,向 RNN 添加层并非没有额外的成本。它会减慢训练和推断的速度,并增加参数的数量,从而使其容易过拟合。对于这个小实验来说,向 RNN 添加层似乎弊大于利。当你改变网络的结构时,一定要记得验证其对验证集的影响。

5.3.2 双向 RNNs

到目前为止,我们一直将单词逐个输入 RNN——从句子的开头到结尾。这意味着当 RNN 处理一个单词时,它只能利用到目前为止遇到的信息,也就是单词的左侧上下文。当然,你可以从单词的左侧上下文中获得很多信息。例如,如果一个单词前面是情态动词(例如,“can”),那么下一个单词是动词的信号就很强烈。然而,右侧上下文也包含了很多信息。例如,如果你知道下一个单词是限定词(例如,“a”),那么左侧的“book”是一个动词,而不是名词的信号就很强烈。

双向 RNN(或简称双向 RNN)通过组合两个方向相反的 RNN 来解决这个问题。前向 RNN 是我们在本书中一直使用的正向 RNN,它从左到右扫描输入句子,并使用输入词和在其左侧所有信息来更新状态。而反向 RNN 则按相反的方向扫描输入句子。它使用输入词和在其右侧所有信息来更新状态。这相当于翻转输入句子的顺序并将其馈送给前向 RNN。双向 RNN 产生的最终隐藏状态是来自前向和后向 RNN 的隐藏状态的连接。详见图 5.9。

CH05_F09_Hagiwara

图 5.9 双向 RNN

让我们用具体的例子来说明。假设输入句子是“time flies like an arrow”,你想知道这个句子中间的单词“like”的词性标注。前向 RNN 处理“time”和“flies”,到达“like”时,它的内部状态(图 5.9 中的 A)编码了关于“time flies like”所有的信息。同样地,反向 RNN 处理“arrow”和“an”,到达“like”时,它的内部状态(图 5.9 中的 B)编码了关于“like an arrow”的所有信息。双向 RNN 的“like”的内部状态是这两个状态(A + B)的连接。您只需将两个向量连接在一起——不需要进行数学运算。因此,“like”的内部状态编码了整个句子的信息。这比只知道句子的一半要好得多!

实现双向 RNN 同样容易——您只需要在初始化 RNN 时添加 bidirectional=True 标志,如下所示:

encoder = PytorchSeq2SeqWrapper(
    torch.nn.LSTM(
        EMBEDDING_SIZE, HIDDEN_SIZE, bidirectional=True, batch_first=True))

如果你使用这个变化来训练 POS 标注器,验证集的准确率将从 ~88% 跳升到 91%。这意味着将词的两侧信息结合起来对于 POS 标注是有效的。

请注意,您可以通过堆叠双向 RNN 来结合本节介绍的两种技术。双向 RNN 的一层输出(由前向和后向层连接)成为另一层双向 RNN 的输入(见图 5.10)。您可以在初始化 PyTorch/AllenNLP 中的 RNN 时指定 num_layers 和 bidirectional 两个标志来实现此目的。

CH05_F10_Hagiwara

图 5.10 两层双向 RNN

5.4 命名实体识别

序列标注可以应用于许多信息提取任务,不仅仅是词性标注。在本节中,我将介绍命名实体识别(NER)的任务,并演示如何使用序列标注构建一个 NER 标注器。此部分的代码可以通过 Google Colab 平台查看和执行(realworldnlpbook.com/ch5#ner-nb)。

5.4.1 什么是命名实体识别?

正如前面提到的,命名实体是对现实世界实体的提及,如专有名词。通常由 NER 系统覆盖的常见命名实体包括以下内容:

  • 个人姓名(PER):艾伦·图灵、Lady Gaga、埃隆·马斯克

  • 组织(ORG):谷歌、联合国、巨人

  • 位置(LOC):雷尼尔山、巴厘岛、尼罗河

  • 地理政治实体(GPE):英国、旧金山、东南亚

然而,不同的 NER 系统处理不同的命名实体集合。在 NLP 中,命名实体的概念有点过载,意味着任何对应用程序用户感兴趣的提及。例如,在医学领域,你可能想提取药品和化学化合物的名称提及。在金融领域,公司、产品和股票符号可能是感兴趣的。在许多领域,数字和时间表达式也被视为命名实体。

识别命名实体本身就很重要,因为命名实体(谁、什么、在哪里、什么时候等)通常是大多数人感兴趣的。但是 NER 也是许多其他自然语言处理应用的重要第一步。一个这样的任务是关系抽取:从给定的文档中提取所有命名实体之间的关系。例如,给定一个新闻稿件,你可能想提取出其中描述的事件,比如哪家公司以什么价格收购了哪家其他公司。这通常假设所有各方都已通过 NER 识别。与 NER 密切相关的另一个任务是实体链接,其中命名实体的提及与某些知识库(如维基百科)相关联。当维基百科被用作知识库时,实体链接也称为维基化

但是你可能会想,仅仅提取命名实体有什么难的?如果它们只是专有名词,你可以简单地编制一个字典,比如所有的名人(或所有的国家,或你感兴趣的任何东西),然后使用它吗?这个想法是,每当系统遇到一个名词,它就会通过这个字典,并标记出现在其中的提及。这样的字典称为地名词典,许多 NER 系统确实使用它们作为一个组件。

然而,仅仅依靠这样的字典有一个主要问题——歧义性。前面我们看到一个单词类型可能有多个词性(例如,“book”既是名词又是动词),命名实体也不例外。例如,“Georgia”可以是一个国家的名字,也可以是美国的一个州,跨越美国的城镇和社区(乔治亚州,印第安纳州;乔治亚州,内布拉斯加州),一部电影,几首歌曲,船只和一个人名。像“book”这样的简单单词也可能是命名实体,包括:Book(路易斯安那州的一个社区),Book/Books(一个姓氏),The Books(一个美国乐队)等。如果它们是模糊的,简单地将提及与字典进行匹配将告诉你它们的身份。

幸运的是,句子通常提供了可以用于消歧提及的线索。 例如,如果句子中读到“我住在乔治亚州”,通常是“乔治亚州”是地点名称,而不是电影或人名的强烈信号。 NER 系统使用关于提及本身的信号(例如,它们是否在预定义字典中)以及关于它们上下文的信号(它们是否由某些词先导或跟随)的组合来确定它们的标记。

5.4.2 标记跨度

与词性标注不同,指向命名实体的提及可以跨越多个词,例如,“美国”和“世界贸易组织”。 在 NLP 中,跨度 只是一个或多个连续词的范围。 我们如何使用相同的序列标记框架来建模跨度?

NLP 中的一个常见做法是使用某种形式的编码将跨度转换为每个词的标记。 NER 中最常用的编码方案称为IOB2 标记。 它通过位置标记和类别标记的组合来表示跨度。 以下是三种类型的位置标记:

  • B(Beginning):分配给跨度的第一个(或唯一的)标记

  • 我(Inside):分配给跨度的所有标记的第一个标记之外的所有标记

  • O(Outside):分配给任何跨度之外的所有单词

现在,让我们看一下之前看到的 NER 示例,并显示在图 5.11 中。 标记“Apple”是 ORG(表示“组织”)的第一个(也是唯一的)标记,并分配了一个 B-ORG 标记。 类似地,“UK”是 GPE(表示“地缘政治实体”)的第一个和唯一的标记,并分配了 B-GPE。 对于“$1”和“billion”,表示货币表达式(MONEY)的第一个和第二个标记,分别分配了 B-MONEY 和 I-MONEY。 所有其他标记都被赋予 O。

CH05_F11_Hagiwara

图 5.11 命名实体识别(NER)使用序列标记

解决 NER 的其余管道与解决词性标注非常相似:两者都涉及为每个词分配适当的标记,并且可以通过 RNN 解决。 在接下来的部分中,我们将使用神经网络构建一个简单的 NER 系统。

5.4.3 实现命名实体识别器

要构建一个 NER 系统,我们使用由 Abhinav Walia 准备的命名实体识别注释语料库,该语料库已在 Kaggle 上发布(realworldnlpbook.com/ch5.html#ner-data)。 在接下来的内容中,我假设您已经下载并展开了数据集,并将其放置在 data/entity-annotated-corpus 下。 或者,您可以使用我上传到 S3 的数据集的副本(realworldnlpbook.com/ch5.html#ner-data-s3),这就是以下代码所做的事情。 我为这个数据集编写了一个数据集读取器(realworldnlpbook.com/ch5.html#ner-reader),所以您只需导入(或复制粘贴)它并使用它:

reader = NERDatasetReader('https:/./s3.amazonaws.com/realworldnlpbook/'
                          'data/entity-annotated-corpus/ner_dataset.csv')

由于数据集没有分为训练集、验证集和测试集,数据集读取器将为您分离为训练集和验证集。您所需要做的就是在初始化数据加载器时指定您想要的分割方式,如下所示:

sampler = BucketBatchSampler(batch_size=16, sorting_keys=["tokens"])
train_data_loader = MultiProcessDataLoader(
    reader, 'train', batch_sampler=sampler)
dev_data_loader = MultiProcessDataLoader(
    reader, 'dev', batch_sampler=sampler)

基于 RNN 的顺序标注模型和训练流程的其余部分与以前的示例(词性标注器)几乎相同。唯一的区别在于我们如何评估我们的 NER 模型。因为典型的 NER 数据集中大多数标签只是“O”,使用标签准确度很容易误导 —— 一个将所有东西标记为“O”的愚蠢系统可以获得非常高的准确度。相反,NER 通常被评估为一项信息提取任务,其目标是从文本中提取命名实体,而不仅仅是标记它们。我们希望基于检索到的命名实体的“干净程度”(有多少是实际实体)和“完整程度”(系统能够检索到多少实际实体)来评估 NER 系统。这些听起来熟悉吗?是的,这些就是我们在第 4.3 节中讨论过的召回率和精确度的定义。由于命名实体通常有多种类型,因此这些指标(精确度、召回率和 F1 度量)是按实体类型计算的。

注意,如果在计算这些指标时忽略实体类型,它被称为总体平均。例如,总体平均的精确度是所有类型的真阳性总数除以检索到的命名实体总数,而不管类型如何。另一方面,如果按实体类型计算这些指标,然后对它们进行平均,它被称为宏平均。例如,如果 PER 和 GPE 的精确度分别为 80%和 90%,则它的宏平均为 85%。接下来,AllenNLP 所计算的是总体平均。

AllenNLP 实现了 SpanBasedF1Measure,它计算每个类型的指标(精确度、召回率和 F1 度量),以及平均值。你可以在你的模型的 init()方法中定义这个指标,如下所示:

self.f1 = SpanBasedF1Measure(vocab, tag_namespace='labels')

并使用它在训练和验证过程中获得指标,如下所示:

def get_metrics(self, reset: bool = False) -> Dict[str, float]:
    f1_metrics = self.f1.get_metric(reset)
    return {'accuracy': self.accuracy.get_metric(reset),
            'prec': f1_metrics['precision-overall'],
            'rec': f1_metrics['recall-overall'],
            'f1': f1_metrics['f1-measure-overall']}

如果你运行这个训练流程,你会得到大约 0.97 的准确度,而精确度、召回率和 F1 度量将都在 0.83 左右。你还可以使用predict()方法来获得未见过的句子的命名实体标签,如下所示:

tokens = ['Apple', 'is', 'looking', 'to', 'buy', 'UK', 'startup',
          'for', '$1', 'billion', '.']
labels = predict(tokens, model)
print(' '.join('{}/{}'.format(token, label)
               for token, label in zip(tokens, labels)))

它将生成如下结果:

Apple/B-org is/O looking/O to/O buy/O UK/O startup/O for/O $1/O billion/O ./O

这并不完美 —— NER 标注器正确获取了第一个命名实体(“Apple”),但错过了另外两个(“UK”和“10 亿美元”)。如果你查看训练数据,你会发现提及“UK”的情况从未出现过,而且没有标注货币值。系统难以标记它从未见过的实体是毫不奇怪的。在自然语言处理(以及机器学习一般)中,测试实例的特征需要与训练数据匹配,才能使模型完全有效。

5.5 语言建模

在这一部分,我将稍微转换一下方向,介绍语言模型,这是自然语言处理中最重要的概念之一。我们将讨论它们是什么,它们为什么重要,以及如何使用我们迄今介绍的神经网络组件来训练它们。

5.5.1 什么是语言模型?

想象一下,你被要求预测接下来的单词是什么,给出一个部分句子:“My trip to the beach was ruined by bad ___。”接下来可能是什么词?许多事情都可能毁了一次海滩之行,但最有可能的是天气不好。也许是海滩上的没礼貌的人,或者可能是这个人在旅行前吃的不好的食物,但大多数人会同意在这个部分句子之后跟着“weather”是一个可能性很高的词。在这种情况下,很少有其他名词(peoplefooddogs)和其他词性的词(betherungreen)与“weather”一样合适。

刚才你所做的是为一个英文句子分配一些信念(或概率)。你刚刚比较了几个替代方案,并判断它们作为英文句子的可能性有多大。大多数人都会同意,“My trip to the beach was ruined by bad weather”的概率远远高于“My trip to the beach was ruined by bad dogs”。

形式上,语言模型是一种给出文本片段概率的统计模型。一个英文语言模型会为看起来像英文的句子分配较高的概率。例如,一个英文语言模型会给“My trip to the beach was ruined by bad weather”比给“My trip to the beach was ruined by bad dogs”或甚至“by weather was trip my bad beach the ruined to.”更高的概率。句子的语法越好,越有“意义”,概率就越高。

5.5.2 语言模型有什么用处?

你可能会想知道这样一个统计模型有什么用。虽然在回答填空题时预测下一个单词可能会派上用场,但语言模型在自然语言处理中扮演着什么特殊的角色呢?

答案是,对于生成自然语言的任何系统都是必不可少的。例如,机器翻译系统,它可以根据另一种语言中的句子生成一个语言中的句子,将受益于高质量的语言模型。为什么?假设我们想将一个西班牙语句子“Está lloviendo fuerte”翻译成英语(“It is raining hard”)。最后一个词“fuerte”有几个英语对应词——strongsharploudheavy等等。你如何确定哪个英语对应词在这种情况下是最合适的?解决这个问题的方法有很多种,但最简单的之一是使用英语语言模型并重新排列几个不同的翻译候选项。假设你已经翻译到“It is raining”,你只需要在西班牙语-英语词典中查找所有可能的对应词,并生成“It is raining strong”、“It is raining sharp”、“It is raining loud”、“It is raining hard”。然后,你只需要询问语言模型,这些候选项中哪一个具有最高的概率。

注意 实际上,神经机器翻译模型可以被视为在目标语言中生成句子的语言模型的一种变体,这种语言模型受其输入(源语言中的句子)的影响。这样的语言模型被称为条件语言模型,与我们在此讨论的无条件语言模型相对。我们将在第六章讨论机器翻译模型。

语音识别中也会出现类似的情况,这是另一个根据口语音频输入生成文本的任务。例如,如果有人说“你是对的”,语音识别系统如何知道实际上是“你是对的”?因为“you're”和“your”可能发音相同,同样,“right”和“write”,甚至“Wright”和“rite”也可能发音相同,系统的输出可能是“You're write”,“You're Wright”,“You're rite”,“Your right”,“Your write”,“Your Wright”等等。再次,解决这种歧义的最简单方法是使用语言模型。英语语言模型会正确重新排序这些候选项,并确定“you're right”是最可能的转录。

实际上,人类一直在做这种类型的消歧义,虽然是无意识的。当你在一个大型聚会上和别人交谈时,你接收到的实际音频信号通常非常嘈杂。大多数人仍然可以毫无问题地相互理解,因为人们的语言模型帮助他们“纠正”你听到的内容并填补任何缺失部分。如果你尝试用不太熟练的第二语言进行交流,你会注意到这一点——在嘈杂的环境中,你会更难以理解对方,因为你的语言模型不如你的第一语言好。

训练 RNN 语言模型

此时,你可能想知道预测下一个单词与为句子分配概率之间的联系是什么。这两者实际上是等价的。我不打算解释背后的理论,因为这需要你理解一些数学(尤其是概率论),我将在不涉及数学细节的情况下尝试一个直观的例子。

想象一下,你想要估计明天的天气有雨和地面潮湿的几率。让我们简化一下,假设只有两种天气,晴天和雨天。地面只有两种结果:干燥或潮湿。这相当于估计一个序列的概率:[rain, wet]。

进一步假设某一天下雨的可能性是 50-50。下雨后,地面潮湿的概率是 90%。那么,雨和地面潮湿的概率是多少?简单地是 50%乘以 90%,即 45%,或者 0.45。如果我们知道一个事件在另一个事件之后发生的概率,那么你可以简单地将两个概率相乘得到序列的总概率。这在概率论中被称为链规则

类似地,如果你能正确估计一部分句子后一个词出现的概率,你可以简单地将其与部分句子的概率相乘。从第一个词开始,你可以一直做下去,直到句子的结尾。例如,如果你想计算“去海滩的旅行是……”的概率,你可以将以下内容相乘:

  • 句子开头出现“The”的概率

  • “The”之后出现“trip”的概率

  • “旅行”之后出现“去”的概率

  • “去”之后出现“the”的概率

  • 依此类推

这意味着要建立一个语言模型,你需要一个能够预测下一个词的概率(或者更准确地说,是概率分布)的模型,考虑到上下文。你可能已经注意到这听起来有点熟悉。确实,在这里所做的事情与本章中我们一直在谈论的顺序标记模型非常相似。例如,一个词性标注模型预测可能的词性标签在给定上下文的情况下的概率分布。一个命名实体识别(NER)模型为可能的命名实体标签做同样的事情。不同之处在于,语言模型是为可能的下一个词做出预测,考虑到模型到目前为止遇到了什么。希望现在开始有些明白为什么我在本章中谈论语言模型了!

总之,要构建一个语言模型,你需要微调一个基于 RNN 的序列标注模型,使其稍微调整,以便它给出下一个单词的估计值,而不是 POS 或 NER 标签。在第三章中,我谈到了 Skip-gram 模型,它根据目标单词预测上下文中的单词。注意这里的相似之处——这两种模型都预测可能单词的概率。Skip-gram 模型的输入只是一个单词,而语言模型的输入是部分序列。你可以使用类似的机制,通过使用线性层将一个向量转换为另一个向量,然后使用 softmax 将其转换为概率分布,正如我们在第三章中讨论的那样。体系结构如图 5.12 所示。

CH05_F12_Hagiwara

图 5.12 基于 RNN 的语言模型架构

基于 RNN 的语言模型的训练方式与其他序列标注模型类似。我们使用的损失函数是序列交叉熵损失,它度量了预测单词与实际单词之间的“偏差”程度。交叉熵损失是每个单词计算的,并且在句子中的所有单词上进行平均。

5.6 使用 RNN 生成文本

我们看到语言模型为自然语言句子给出了概率。但更有趣的部分是,你可以使用语言模型从头开始生成自然语言句子!在本章的最后一节,我们将构建一个语言模型。你可以使用训练好的模型来评估和生成英语句子。你可以在 Google Colab 笔记本上找到此子节的整个脚本(realworldnlpbook.com/ch5.html#lm-nb)。

5.6.1 将字符馈送到 RNN

在本节的前半部分,我们将构建一个英语语言模型,并使用通用英语语料库对其进行训练。在我们开始之前,我们注意到,本章中构建的 RNN 语言模型是基于 字符 而不是基于单词或令牌的。到目前为止,我们所见过的所有 RNN 模型都是基于单词的,这意味着 RNN 的输入始终是单词序列。另一方面,在本节中,我们将使用的 RNN 接受字符序列作为输入。

理论上,RNNs 可以处理任何序列,无论是令牌、字符还是完全不同的东西(例如,语音识别的波形),只要它们可以转换为向量。在构建语言模型时,我们通常将字符作为输入,甚至包括空白和标点,将它们视为长度为 1 的单词。模型的其余部分完全相同——首先将单个字符嵌入(转换为向量),然后将其馈送到 RNN 中,然后训练 RNN,以便它能最好地预测可能出现的下一个字符的分布。

在决定是否应将单词或字符馈送到 RNN 时需要考虑一些因素。使用字符肯定会使 RNN 的效率降低,这意味着它需要更多的计算才能“理解”相同的概念。例如,基于单词的 RNN 可以在一个时间步接收到单词“dog”并更新其内部状态,而基于字符的 RNN 必须等到接收到三个元素 dog,以及可能的“_”(空格符)才能做到。基于字符的 RNN 需要“学会”这个由这三个字符组成的序列表示了某个特殊意义(“dog”这个概念)。

另一方面,通过向 RNN 馈送字符,您可以避开许多处理标记的问题。其中一个问题与处理词汇表外(OOV)的单词有关。当训练基于单词的 RNN 时,通常会固定整个词汇表的集合,通常通过枚举在训练集中出现的所有单词来实现。但是,每当在测试集中遇到一个 OOV 单词时,它就不知道如何处理它。通常情况下,它会给所有 OOV 单词分配一个特殊的标记 并以相同的方式处理它们,这并不理想。相反,基于字符的 RNN 仍然可以处理单个字符,因此它可能能够通过观察训练集中的“dog”所学到的规则,推断出“doggy”是什么意思,即使它从未见过确切的单词“doggy”。

5.6.2 使用语言模型评估文本

让我们开始构建一个基于字符的语言模型。第一步是读取一个纯文本数据集文件并生成用于训练模型的实例。我将展示如何在不使用数据集读取器的情况下构建实例以进行演示。假设您有一个 Python 字符串对象 text,您想将其转换为用于训练语言模型的实例。首先,您需要使用 CharacterTokenizer 将其分段为字符,如下所示:

from allennlp.data.tokenizers import CharacterTokenizer

tokenizer = CharacterTokenizer()
tokens = tokenizer.tokenize(text)

需要注意的是,这里的 tokens 是一个 Token 对象的列表。每个 Token 对象包含一个字符,而不是一个单词。然后,按照下面显示的方式在列表的开头和末尾插入 符号:

from allennlp.common.util import START_SYMBOL, END_SYMBOL

tokens.insert(0, Token(START_SYMBOL))
tokens.append(Token(END_SYMBOL))

在 NLP 中,在每个句子的开头和结尾插入这些特殊符号是一种常见做法。使用这些符号,模型可以区分句子中一个标记在中间出现与一个标记在开头或结尾出现的情况。例如,句点很可能出现在句子的末尾(“. ”)而不是开头(“ .”),语言模型可以给出两个非常不同的概率,而不使用这些符号是不可能做到的。

最后,您可以通过指定单独的文本字段来构建一个实例。请注意,语言模型的“输出”与输入完全相同,只是偏移了一个标记,如下所示:

from allennlp.data.fields import TextField
from allennlp.data.instance import Instance

input_field = TextField(tokens[:-1], token_indexers)
output_field = TextField(tokens[1:], token_indexers)
instance = Instance({'input_tokens': input_field,
                     'output_tokens': output_field})

这里的 token_indexers 指定了如何将各个标记映射到 ID。我们仍然使用迄今为止使用的 SingleIdTokenIndexer,如下所示:

from allennlp.data.token_indexers import TokenIndexer 

token_indexers = {'tokens': SingleIdTokenIndexer()}

图 5.13 显示了从该过程创建的实例。

CH05_F13_Hagiwara

图 5.13 用于训练语言模型的实例

训练流程的其余部分以及模型与本章前面提到的顺序标记模型非常相似。有关更多详细信息,请参见 Colab 笔记本。如下面的代码片段所示,在模型完全训练后,你可以从新的文本中构建实例、将它们转化为实例,并计算损失,该损失基本上衡量了模型在预测下一个字符方面的成功程度:

predict('The trip to the beach was ruined by bad weather.', model)
{'loss': 1.3882852}

predict('The trip to the beach was ruined by bad dogs.', model)
{'loss': 1.5099115}

predict('by weather was trip my bad beach the ruined to.', model)
{'loss': 1.8084583}

这里的损失是预测字符与期望字符之间的交叉熵损失。出现较多“不符合预期”的字符,损失值就会越高,因此你可以使用这些值来衡量输入作为英文文本的自然程度。正如预期的那样,自然句子(如第一个句子)得分低于非自然句子(如最后一个句子)。

注意,如果你计算交叉熵的 2 的幂,那么这个值就被称为困惑度。对于给定的固定自然语言文本,困惑度会降低,因为语言模型在预测下一个字符方面表现更好,所以它通常用于评估文献中的语言模型的质量。

5.6.3 使用语言模型生成文本

(完全训练好的) 语言模型最有趣的方面在于,它们可以根据给定的一些上下文来预测可能出现的下一个字符。具体而言,它们可以给出可能的下一个字符的概率分布,然后根据该分布选择确定下一个字符。例如,如果模型生成了“t”和“h”,并且 LM 是基于通用英文文本训练的,它可能会对字母“e”分配较高的概率,生成常见的英文单词,包括 thetheythem 等。如果你从 标记开始这个过程,并一直进行下去直到达到句子的结尾(即生成 ),你就可以从头开始生成一句英文句子。顺便说一句,这也是为什么像 这样的标记很有用——你需要将某些内容输入 RNN 以开始生成,并且你还需要知道句子何时结束。

让我们在下面的类似 Python 代码的伪代码中看一下这个过程:

def generate():
    state = init_state()
    token = <START>
    tokens = [<START>]
    while token != <END>:
        state = update(state, token)
        probs = softmax(linear(state))
        token = sample(probs)
        tokens.append(token)
    return tokens

这个循环看起来与更新 RNNs 的循环非常相似,但有一个关键区别:在这里,我们不接收任何输入,而是生成字符并将它们作为输入。换句话说,RNN 的操作对象是 RNN 自己迄今为止生成的字符序列。这种在其自身生成的过去序列上操作的模型称为 自回归模型。有关此过程的示例,请参见图 5.14。

CH05_F14_Hagiwara

图 5.14 使用 RNN 生成文本

在上一个代码段中,init_state()和 update()函数是初始化和更新 RNN 隐藏状态的函数,正如我们之前所见。 在生成文本时,我们假设模型及其参数已经训练好了大量的自然语言文本。softmax()函数是在给定向量上运行 Softmax 的函数,而 linear()是扩展/缩小向量大小的线性层。sample()函数根据给定的概率分布返回一个字符。例如,如果分布是“a”:0.6,“b”:0.3,“c”:0.1,则会在 60%的时间内选择“a”,30%的时间选择“b”,10%的时间选择“c”。这确保生成的字符串每次都不同,同时每个字符串看起来都像是英语句子。

注意,您可以使用 PyTorch 的 torch.multinomial()从概率分布中进行抽样。

如果使用 Tatoeba 中的英语句子进行训练,并按照这个算法生成句子,系统将会创建类似于以下举出的例子:

You can say that you don't know it, and why decided of yourself.
Pike of your value is to talk of hubies.
The meeting despoit from a police?
That's a problem, but us?
The sky as going to send nire into better.
We'll be look of the best ever studented.
There's you seen anything every's redusention day.
How a fail is to go there.
It sad not distaples with money.
What you see him go as famous to eat!

这不是个坏开端!如果你看看这些句子,有很多词语和短语看起来是合理的英语句子(“You can say that”、“That's a problem”、“to go there”、“see him go”等)。即使系统生成了奇怪的单词(“despoit”、“studented”、“redusention”、“distaples”),它们看起来几乎像真正的英语单词,因为它们基本上遵循英语的形态和音韵规则。这意味着语言模型成功地学习了英语的基本语言要素,如字母排列(拼写)、词形变化(形态学)以及基本句子结构(语法)。

然而,如果你将句子作为一个整体来看,很少有句子是有意义的(例如,你看到他去当名人吃饭)。这意味着我们训练的语言模型在建模句子的语义一致性方面存在缺陷。这可能是因为我们的模型不够强大(我们的 LSTM-RNN 需要将句子的所有内容压缩成一个 256 维的向量),或者训练数据集太小(只有 10,000 个句子),或者两者兼而有之。但是你可以轻易想象,如果我们不断增加模型的容量以及训练集的大小,该模型在生成逼真的自然语言文本方面将变得非常出色。2019 年 2 月,OpenAI 宣布开发了一个基于 Transformer 模型的巨型语言模型(我们将在第八章介绍),该模型在 40GB 的互联网文本上进行了训练。该模型显示,它可以在给定提示的情况下生成逼真的文本,展现了几乎完美的语法和长期的主题一致性。事实上,该模型非常出色,以至于 OpenAI 决定不发布他们训练的大型模型,因为他们担心技术可能被用于恶意目的。但是重要的是要记住,无论输出看起来多么智能,他们的模型都是基于我们在本章中的示例玩具模型的相同原理——只是尝试预测下一个字符!

总结

  • 序列标记模型会给输入中的每个词都打上一个标签,这可以通过递归神经网络(Recurrent Neural Networks, RNNs)来实现。

  • 词性标注(Part-of-speech tagging)和命名实体识别(Named Entity Recognition, NER)是序列标记任务的两个实例。

  • 多层 RNN 将多个 RNN 层堆叠在一起,而双向 RNN 结合了前向和后向 RNN 来编码整个句子。

  • 语言模型为自然语言文本分配概率,这是通过预测下一个词来实现的。

  • 你可以使用一个经过训练的语言模型来评估一个自然语言句子的“自然程度”,甚至是从零开始生成看起来逼真的文本。

第二部分:高级模型

过去几年,自然语言处理领域取得了迅猛的进步。具体来说,Transformer 和预训练语言模型(如 BERT)的出现彻底改变了该领域的格局以及从业者构建自然语言处理应用的方式。本书的这部分内容将帮助你跟上这些最新进展。

第六章介绍了序列到序列模型,这是一类重要的模型,它将使你能够构建更复杂的应用,比如机器翻译系统和聊天机器人。第七章讨论了另一种流行的神经网络架构,卷积神经网络(CNNs)。

第八章和第九章可以说是本书最重要和最令人兴奋的章节。它们分别涵盖了 Transformer 和迁移学习方法(如 BERT)。我们将演示如何利用这些技术构建高质量的机器翻译和拼写检查器等高级自然语言处理应用。

当你完成阅读这一部分时,你会自信地感觉到,通过你目前所学,你现在能够解决各种各样的自然语言处理任务。

第六章:序列到序列模型

本章包括

  • 使用 Fairseq 构建机器翻译系统

  • 使用 Seq2Seq 模型将一句话转换成另一句话

  • 使用束搜索解码器生成更好的输出

  • 评估机器翻译系统的质量

  • 使用 Seq2Seq 模型构建对话系统(聊天机器人)

在本章中,我们将讨论序列到序列(Seq2Seq)模型,这些模型是一些最重要的复杂自然语言处理模型,被用于广泛的应用场景,包括机器翻译。Seq2Seq 模型及其变种已经在许多实际应用中作为基本构建块使用,包括谷歌翻译和语音识别。我们将使用一个强大的框架来构建一个简单的神经机器翻译系统,以了解这些模型的工作原理以及如何使用贪婪和束搜索算法生成输出。在本章的结尾,我们将构建一个聊天机器人——一个可以与之对话的自然语言处理应用。我们还将讨论简单 Seq2Seq 模型的挑战和局限性。

6.1 介绍序列到序列模型

在前一章中,我们讨论了两种强大的自然语言处理模型,即序列标记和语言模型。回顾一下,序列标记模型接收一些单元的序列(例如,单词)并为每个单元分配一个标签(例如,词性标注)。而语言模型接收一些单元的序列(例如,单词),并估计给定序列在模型训练的领域中出现的概率。你还可以使用语言模型从零开始生成看起来真实的文本。请参阅图 6.1 以了解这两种模型的概况。

CH06_F01_Hagiwara

图 6.1 序列标记和语言模型

虽然这两种模型对于许多自然语言处理任务都非常有用,但对于某些任务,你可能希望兼顾这两者——让你的模型接收一些输入(例如,一句句子)并产生另一个东西(例如,另一句句子)作为响应。例如,如果你希望将用一种语言写的文本翻译成另一种语言,你需要让模型接收一个句子并产生另一个句子。你能用序列标记模型实现吗?不能,因为它们只能产生与输入句子中标记数量相同数量的输出标签。这显然对于翻译来说太过有限——一种语言中的表达(比如法语中的“Enchanté”)在另一种语言中可以有任意多或少的单词(比如英语中的“Nice to meet you”)。你能用语言模型实现吗?还是不能。虽然你可以使用语言模型生成看起来真实的文本,但你几乎无法控制它们生成的文本。事实上,语言模型不接受任何输入。

但是如果你仔细看图 6.1,你可能会注意到一些东西。左侧模型(序列标记模型)以句子作为输入,并生成某种形式的表示,而右侧模型则生成一个看起来像自然语言文本的长度可变的句子。我们已经有了构建我们想要的东西所需的组件,即一个接受句子并将其转换为另一个句子的模型。唯一缺失的部分是一种连接这两者的方法,以便我们可以控制语言模型生成什么。

实际上,当左侧模型完成处理输入句子时,循环神经网络已经生成了其抽象表示,该表示被编码在循环神经网络的隐藏状态中。如果你能简单地将这两者连接起来,使得句子表示从左到右传递,并且语言模型可以根据这个表示生成另一个句子,那么似乎你可以实现最初想要做的事情!

序列到序列模型,简称Seq2Seq模型,是基于这一见解构建的。Seq2Seq 模型由两个子组件组成,即编码器和解码器。见图 6.2 进行说明。编码器接受一系列单位(例如,一个句子)并将其转换为某种内部表示。另一方面,解码器从内部表示生成一系列单位(例如,一个句子)。总的来说,Seq2Seq 模型接受一个序列并生成另一个序列。与语言模型一样,生成过程在解码器产生一个特殊标记时停止,这使得 Seq2Seq 模型可以生成比输入序列更长或更短的输出。

CH06_F02_Hagiwara

图 6.2 序列到序列模型

有许多 Seq2Seq 模型的变体存在,这取决于你用于编码器的架构,你用于解码器的架构以及两者之间信息流动的方式。本章涵盖了最基本类型的 Seq2Seq 模型——简单地通过句子表示连接两个循环神经网络。我们将在第八章中讨论更高级的变体。

机器翻译是 Seq2Seq 模型的第一个,也是迄今为止最流行的应用。然而,Seq2Seq 架构是一个通用模型,适用于许多自然语言处理任务。在其中一项任务中,摘要生成,一个自然语言处理系统接受长文本(例如新闻文章)并生成其摘要(例如新闻标题)。Seq2Seq 模型可以用来将较长的文本“翻译”成较短的文本。另一个任务是对话系统,或者聊天机器人。如果你将用户的话语视为输入,系统的回应视为输出,对话系统的工作就是将前者“翻译”成后者。在本章后面,我们将讨论一个案例研究,在这个案例中,我们实际上使用了 Seq2Seq 模型构建了一个聊天机器人。另一个(有些令人惊讶的)应用是解析——如果你将输入文本视为一种语言,将其语法表示视为另一种语言,你可以使用 Seq2Seq 模型解析自然语言文本。

6.2 机器翻译 101

我们在第 1.2.1 节简要提及了机器翻译。简而言之,机器翻译(MT)系统是将给定文本从一种语言翻译成另一种语言的自然语言处理系统。输入文本所用语言称为源语言,而输出文本所用语言称为目标语言。源语言和目标语言的组合称为语言对

首先,让我们看一些例子,看看是什么样子,以及为什么将外语翻译成英语(或者任何其他你理解的语言)是困难的。在第一个例子中,让我们将一个西班牙句子翻译成英文,即,“Maria no daba una bofetada a la bruja verde.” 翻译成英文对应的是,“Mary did not slap the green witch.” 在说明翻译过程时的一个常见做法是绘制两个句子之间具有相同意思的单词或短语如何映射的图。两个实例之间的语言单位的对应称为对齐。图 6.3 显示了西班牙语和英语句子之间的对齐。

CH06_F03_Hagiwara

图 6.3 西班牙语和英语之间的翻译和词对齐

一些单词(例如,“Maria” 和 “Mary”,“bruja” 和 “witch”,以及 “verde” 和 “green”)完全一一对应。然而,一些表达(例如,“daba una bofetada” 和 “slap”)在某种程度上有很大不同,以至于你只能在西班牙语和英语之间对齐短语。最后,即使单词之间有一对一的对应关系,单词的排列方式,或者词序,在两种语言之间可能也会有所不同。例如,形容词在西班牙语中在名词之后添加(“la bruja verde”),而在英语中,它们在名词之前(“the green witch”)。在语法和词汇方面,西班牙语和英语在某种程度上是相似的,尤其是与中文和英语相比,尽管这个单一的例子显示了在两种语言之间进行翻译可能是一项具有挑战性的任务。

汉语和英语之间的情况开始变得更加复杂。图 6.4 展示了一句汉语句子(“布什与沙龙举行了会谈。”)和其英文翻译(“Bush held a talk with Shalon.”)之间的对齐。尽管汉语使用了自己的表意文字,但我们在这里使用了罗马化的句子以示简便。

CH06_F04_Hagiwara

图 6.4 汉语和英语之间的翻译和词对齐

现在你可以在图中看到更多交叉的箭头。与英语不同,汉语介词短语(比如“和沙龙一起”)通常从左边附着在动词上。此外,汉语不明确标记时态,机器翻译系统(以及人工翻译)需要“猜测”英文翻译中应该使用的正确时态。最后,汉译英的机器翻译系统还需要推断每个名词的正确数量(单数或复数),因为汉语名词没有根据数量明确标记(例如,“会谈”只是表示“谈话”,没有明确提及数量)。这是一个很好的例子,说明了翻译的难度取决于语言对。在语言学上不同的语言之间开发机器翻译系统(如中文和英文)通常比在语言学上类似的语言之间(如西班牙语和葡萄牙语)更具挑战性。

CH06_F05_Hagiwara

图 6.5 日语和英语之间的翻译和词对齐

让我们再看一个例子——从日语翻译成英语,在图 6.5 中有说明。图中所有的箭头都是交叉的,表示这两个句子的词序几乎完全相反。除了日语介词短语(例如“to music”)和关系从句从左边附着,跟汉语一样,宾语(例如例句中的“listening”在“我喜爱听”中)出现在动词之前。换句话说,日语是一种 SOV(主语-宾语-动词)的语言,而到目前为止我们提到的其他语言(英语、西班牙语和汉语)都是 SVO(主语-动词-宾语)的语言。结构上的差异是直接、逐字翻译效果不佳的原因之一。

注 这种语言的词序分类系统(如 SOV 和 SVO)常常用于语言类型学。世界上绝大多数语言都是 SOV(最常见)或 SVO(稍少一些),尽管少数语言遵循其他词序系统,例如阿拉伯语和爱尔兰语使用的 VSO(动词-主语-宾语)。很少一部分语言(不到所有语言的 3%)使用其他类型(VOS、OVS 和 OSV)。

除了前面图示的结构差异之外,许多其他因素也会使机器翻译成为一项困难的任务。其中之一是词汇差异。例如,如果你将日语单词“音楽”翻译成英语“music”,几乎没有歧义。“音楽”几乎总是“music”。然而,如果你将英语单词“brother”翻译成中文,你会面临歧义,因为中文对“哥哥”和“弟弟”使用不同的词语。在更极端的情况下,如果你将“cousin”翻译成中文,你会有八种不同的选择,因为在中国家庭制度中,你需要根据你的表兄弟是母亲的还是父亲的,是女性还是男性,比你大还是小,使用不同的词语。

另一个使机器翻译具有挑战性的因素是省略。你可以在图 6.5 中看到,日语中没有“我”的单词。在诸如中文、日语、西班牙语等许多其他语言中,当主语代词在上下文和/或动词形式中是明确的时候,你可以省略主语代词。这被称为zero pronoun,当从一个省略代词的语言翻译成一个省略频率较低的语言时(例如英语),它可能会成为一个问题。

在乔治敦-IBM 实验期间开发的最早的机器翻译系统之一是在冷战期间将俄语句子翻译成英语的。但它所做的不过是不比用双语词典查找每个单词并用其翻译替换它有多不同。上面展示的三个例子应该足以让你相信,简单地逐词替换太过于限制了。后来的系统包含了更大的词典和语法规则,但这些规则是由语言学家手动编写的,并不足以捕捉语言的复杂性(再次记住第一章中可怜的软件工程师)。

在神经机器翻译(NMT)出现之前,在学术界和工业界主导的机器翻译的主要范式称为统计机器翻译(SMT)。其背后的理念很简单:通过数据学习如何翻译,而不是通过手工制定规则。具体而言,SMT 系统学习如何从包含源语言文本和其在目标语言中的翻译的数据集中进行翻译。这些数据集称为平行语料库(或平行文本双文本)。通过查看两种语言中成对句子的集合,算法寻找一种语言中的单词应如何翻译为另一种语言的模式。由此产生的统计模型称为翻译模型。同时,通过查看一系列目标句子,算法可以学习目标语言中有效句子的外观。听起来耳熟吗?这正是语言模型的全部内容(请参阅前一章)。最终的 SMT 模型结合了这两个模型,并生成一种对输入的合理翻译,并且在目标语言中是一句有效、流畅的句子。

大约在 2015 年,强大的神经机器翻译(NMT)模型的出现颠覆了 SMT 的主导地位。SMT 和 NMT 有两个关键区别。首先,根据定义,NMT 基于神经网络,而神经网络以其准确建模语言的能力而闻名。因此,由 NMT 生成的目标句子往往比由 SMT 生成的句子更流畅和自然。其次,NMT 模型是端到端训练的,正如我在第一章中简要提到的那样。这意味着 NMT 模型由一个单一的神经网络组成,该网络接受输入并直接产生输出,而不是您需要独立训练的子模型和子模块的拼接。因此,与 SMT 模型相比,NMT 模型更容易训练,代码规模更小。

MT 已经在许多不同的行业和我们生活的方方面面得到了应用。将外语文本翻译成您理解的语言以快速抓住其含义的过程称为摘要。如果在摘要后认为文本足够重要,则可能会将其发送到正式的手动翻译中。专业翻译人员也使用 MT 进行工作。通常,源文本首先使用 MT 系统翻译为目标语言,然后由人类翻译人员编辑生成的文本。这种编辑称为后编辑。使用自动化系统(称为计算机辅助翻译或 CAT)可以加速翻译过程并降低成本。

6.3 构建你的第一个翻译器

在本节中,我们将构建一个可工作的 MT 系统。我们不会编写任何 Python 代码来实现,而是会充分利用现有的 MT 框架。许多开源框架使构建 MT 系统变得更加容易,包括 Moses(www.statmt.org/moses/)用于 SMT 和 OpenNMT(opennmt.net/)用于 NMT。在本节中,我们将使用 Fairseq(github.com/pytorch/fairseq),这是 Facebook 开发的一个 NMT 工具包,如今在 NLP 从业者中变得越来越流行。以下几个方面使 Fairseq 成为快速开发 NMT 系统的不错选择:1)它是一个现代化的框架,提供了许多预定义的最先进的 NMT 模型,您可以立即使用;2)它非常可扩展,意味着您可以通过遵循它们的 API 快速实现自己的模型;3)它非常快速,默认支持多 GPU 和分布式训练。由于其强大的模型,您可以在几小时内构建一个质量不错的 NMT 系统。

在开始之前,请在项目目录的根目录中运行pip install fairseq来安装 Fairseq。此外,请在您的 shell 中运行以下命令来下载并展开数据集(如果您使用的是 Ubuntu,则可能需要安装 unzip,可以通过运行sudo apt-get install unzip来安装):²

$ mkdir -p data/mt
$ wget https://realworldnlpbook.s3.amazonaws.com/data/mt/tatoeba.eng_spa.zip
$ unzip tatoeba.eng_spa.zip -d data/mt

我们将使用 Tatoeba 项目中的西班牙语和英语平行句子来训练一个西班牙语到英语的 MT 系统,这是我们在第四章中已经使用过的。该语料库包含大约 20 万个英语句子及其西班牙语翻译。我已经提前格式化了数据集,这样您就可以在不必担心获取数据、标记文本等方面的情况下使用它。数据集已经分为训练、验证和测试子集。

6.3.1 准备数据集

如前所述,MT 系统(包括 SMT 和 NMT)是机器学习模型,因此是根据数据训练的。MT 系统的开发过程看起来与任何其他现代 NLP 系统相似,如图 6.6 所示。首先,对平行语料库的训练部分进行预处理,并用于训练一组 NMT 模型候选者。接下来,使用验证部分来选择所有候选模型中表现最佳的模型。这个过程称为模型选择(请参阅第二章进行复习)。最后,最佳模型将在数据集的测试部分上进行测试,以获得反映模型优劣的评估指标。

CH06_F06_Hagiwara

图 6.6 构建 NMT 系统的流水线

MT 开发的第一步是对数据集进行预处理。但在进行预处理之前,你需要将数据集转换为易于使用的格式,通常是自然语言处理中的纯文本格式。实践中,用于训练 MT 系统的原始数据以多种不同格式出现,例如,纯文本文件(如果你很幸运的话)、专有软件的 XML 格式、PDF 文件和数据库记录。你的第一项任务是对原始文件进行格式化,使源句子和它们的目标翻译按句子对齐。结果文件通常是一个 TSV 文件,每行都是一个以制表符分隔的句子对,如下所示:

Let's try something.                   Permíteme intentarlo.
Muiriel is 20 now.                     Ahora, Muiriel tiene 20 años.
I just don't know what to say.         No sé qué decir.
You are in my way.                     Estás en mi camino.
Sometimes he can be a strange guy.     A veces él puede ser un chico raro.
...

在翻译对齐后,平行语料被输入到预处理管道中处理。具体的操作因应用程序和语言而异,但以下步骤最为常见:

  1. 过滤

  2. 清理

  3. 分词

在过滤步骤中,将从数据集中移除任何不适合用于训练 MT 系统的句子对。一个句子对是否太长、是否有用等因素影响很大,例如,任何其中一个文本长度过长(例如超过 1000 个单词)的句子对都无用,因为大多数 MT 模型不能建模这样长的句子。此外,任何其中一个句子过长但另一个句子过短的句子对都可能是由于数据处理或对齐错误而引起的噪音。例如,如果一个西班牙语句子有 10 个单词,其英语翻译的长度应该在 5 到 15 个单词之间。最后,如果平行语料库包含除源语言和目标语言之外的任何语言,应该移除这样的句子对。这种情况比你想象的要多得多——许多文档由于引用、解释或代码切换(在一个句子中混合多种语言)而成为多语言文档。语言检测(见第四章)可以帮助检测到这些异常情况。

过滤后,数据集中的句子可以进一步清理。该过程可能包括删除 HTML 标签和任何特殊字符,以及对字符(例如,繁体中文和简体中文)和拼写(例如,美式英语和英式英语)进行归一化。

如果目标语言使用类似于拉丁(a,b,c,...)或西里尔(а,б,в,...)字母表的脚本,区分大小写,您可能需要规范化大小写。通过这样做,您的 MT 系统将“NLP”与“nlp”和“Nlp”分组在一起。通常,这是一件好事,因为通过具有三个不同表示的单一概念,MT 模型必须从数据中学习它们实际上是单一概念。规范化大小写也会减少不同单词的数量,从而使训练和预测更快。但是,这也将“US”和“Us”以及“us”分组在一起,这可能不是一种理想的行为,具体取决于您处理的数据类型和领域。在实践中,这些决策,包括是否规范化大小写,都是通过观察它们对验证数据性能的影响来谨慎做出的。

机器翻译和 NLP 的数据清理

请注意,这里提到的清理技术并不特定于 MT。任何 NLP 应用和任务都可以从经过精心设计的过滤和清理操作的流程中受益。然而,对于 MT 来说,清理训练数据尤为重要,因为翻译的一致性对于构建强大的 MT 模型至关重要。如果您的训练数据在某些情况下使用“NLP”,而在其他情况下使用“nlp”,则模型将难以找到正确翻译该单词的方法,而人类很容易理解这两个单词代表一个概念。

此时,数据集仍然是一堆字符字符串。大多数 MT 系统操作单词,因此您需要对输入进行标记化(第 3.3 节)以识别单词。根据语言,您可能需要运行不同的流程(例如,对于中文和日文,需要进行词段切分)。

您之前下载和展开的 Tatoeba 数据集已经通过了所有这些预处理流程。现在,您已经准备好将数据集交给 Fairseq 了。第一步是告诉 Fairseq 将输入文件转换为二进制格式,以便训练脚本可以轻松读取它们,如下所示:

$ fairseq-preprocess \
      --source-lang es \
      --target-lang en \
      --trainpref data/mt/tatoeba.eng_spa.train.tok \
      --validpref data/mt/tatoeba.eng_spa.valid.tok \
      --testpref data/mt/tatoeba.eng_spa.test.tok \
      --destdir data/mt-bin \
      --thresholdsrc 3 \
      --thresholdtgt 3

当成功时,您应该在终端上看到一条“Wrote preprocessed data to data/mt-bin”的消息。您还应该在 data/mt-bin 目录下找到以下一组文件:

dict.en.txt dict.es.txt  test.es-en.en.bin  test.es-en.en.idx  test.es-en.es.bin  test.es-en.es.idx  train.es-en.en.bin  train.es-en.en.idx  train.es-en.es.bin  train.es-en.es.idx  valid.es-en.en.bin  valid.es-en.en.idx  valid.es-en.es.bin  valid.es-en.es.idx

此预处理步骤的关键功能之一是构建词汇表(在 Fairseq 中称为dictionary),它是从词汇项(通常为单词)到它们的 ID 的映射。注意目录中的两个字典文件 dict.en.txt 和 dict.es.txt。MT 涉及两种语言,因此系统需要维护两个映射,每个语言一个。

6.3.2 训练模型

现在,训练数据已转换为二进制格式,您可以准备好训练 MT 模型了。按下面所示使用包含二进制文件的目录以及几个超参数来调用 fairseq-train 命令:

$ fairseq-train \
    data/mt-bin \
    --arch lstm \
    --share-decoder-input-output-embed \
    --optimizer adam \
    --lr 1.0e-3 \
    --max-tokens 4096 \
    --save-dir data/mt-ckpt

您不必担心理解大多数参数的含义(至少暂时不用)。此时,您只需要知道使用指定目录中存储的数据(data/mt-bin)使用 LSTM 架构(-arch lstm)和一堆其他超参数来训练模型,并将结果保存在 data/mt-ckpt(checkpoint 的缩写)中即可。

运行此命令时,终端会交替显示两种进度条——一个用于训练,另一个用于验证,如下所示:

| epoch 001:  16%|???▏                | 61/389 [00:13<01:23,  3.91it/s, loss=8.347, ppl=325.58, wps=17473, ups=4, wpb=3740.967, bsz=417.180, num_updates=61, lr=0.001, gnorm=2.099, clip=0.000, oom=0.000, wall=17, train_wall=12]

| epoch 001 | valid on 'valid' subset | loss 4.208 | ppl 18.48 | num_updates 389

验证结果对应的行内容很容易区分——它们会说“验证”子集。每个时期,训练过程会轮流进行两个阶段:训练和验证。机器学习中使用的一个概念——一个时期,意味着对整个训练数据的一次遍历。在训练阶段,使用训练数据计算损失,然后以使新的参数集降低损失的方式调整模型参数。在验证阶段,模型参数被固定,使用一个单独的数据集(验证集)来衡量模型在该数据集上的表现。

我在第一章中提到过,验证集用于模型选择,这是从单个培训集中选择最佳的机器学习模型的过程。在这里,通过交替进行训练和验证阶段,我们使用验证集来检查所有中间模型(即第一个时期后的模型,第二个时期后的模型,等等)的性能。换言之,我们使用验证阶段来监视培训的进展情况。

为什么这是个好方法?我们通过在每个时期之后插入验证阶段获得了许多好处,但最重要的好处是避免过度拟合——验证数据之所以重要正是因为这个原因。为了进一步说明这一点,让我们看看在我们的西班牙语到英语机器翻译模型的训练过程中,训练集和验证集的损失如何随着时间变化,如图 6.7 所示。

随着训练的进行,训练损失变得越来越小,并逐渐趋近于零,因为这正是我们告诉优化器要做的:使损失尽可能地降低。检查训练损失是否在一个个时期后稳步下降是一个很好的“健全性检查”,可以验证您的模型和培训流水线是否按预期工作。

另一方面,如果您看一下验证损失,它在前几个时期内会下降,但在一定点之后逐渐回升,形成一个 U 形曲线——这是过度拟合的一个典型迹象。经过几个时期的培训后,您的模型在训练集上表现得非常好,开始失去其对验证集的泛化性。

CH06_F07_Hagiwara

图 6.7 训练和验证损失

让我们用机器翻译中的一个具体例子来说明当模型过度拟合时实际发生了什么。例如,如果您的训练数据包含了英文句子“It is raining hard”及其西班牙语翻译“Esta lloviendo fuerte”,而其他句子中没有包含“hard”一词,那么过拟合的模型可能会认为“fuerte”是“hard”的唯一可能翻译。一个正确拟合的模型可能会留下一些余地,让其他西班牙语单词出现作为“hard”的翻译,但一个过拟合的机器翻译系统总是会将“hard”翻译为“fuerte”,这是根据训练集“正确”的做法,但显然不是您想要构建健壮的机器翻译系统的理想选择。例如,“She is trying hard”中“hard”的最佳翻译方式并不是“fuerte”。

如果您看到验证损失开始上升,那么继续保持训练过程是没有意义的,因为很有可能您的模型已经在某种程度上过度拟合了数据。在这种情况下的一种常见做法,称为提前停止,是终止训练。具体来说,如果您的验证损失在一定数量的轮次内没有改善,您就停止训练,并使用验证损失最低时的模型。等待训练终止的轮次数称为耐心。在实践中,最关心的指标(例如 BLEU;请参阅第 6.5.2 节)用于提前停止,而不是验证损失。

好了,现在关于训练和验证就说到这里。图 6.7 中的图表表明验证损失在第 8 轮左右最低,所以你可以在大约 10 轮后停止(通过按 Ctrl + C),否则该命令会一直运行下去。Fairseq 将自动将最佳模型参数(根据验证损失)保存到 checkpoint_best.pt 文件中。

警告 如果您只使用 CPU 进行训练,可能需要很长时间。第十一章解释了如何使用 GPU 加速训练。

6.3.3 运行翻译器

模型训练完成后,您可以调用 fairseq-interactive 命令以交互方式在任何输入上运行您的机器翻译模型。您可以通过指定二进制文件位置和模型参数文件来运行该命令,如下所示:

$ fairseq-interactive \
    data/mt-bin \
    --path data/mt-ckpt/checkpoint_best.pt \
    --beam 5 \
    --source-lang es \
    --target-lang en

看到提示“Type the input sentence and press return”后,尝试逐一输入(或复制粘贴)以下西班牙语句子:

¡ Buenos días !
¡ Hola !
¿ Dónde está el baño ?
¿ Hay habitaciones libres ?
¿ Acepta tarjeta de crédito ?
La cuenta , por favor .

请注意这些句子中的标点和空白——Fairseq 假定输入已经进行了分词。您的结果可能会略有不同,这取决于许多因素(深度学习模型的训练通常涉及一些随机性),但您会得到类似以下的结果(我加粗了以示强调):

¡ Buenos días !
S-0     ¡ Buenos días !
H-0     -0.20546913146972656    Good morning !
P-0     -0.3342 -0.3968 -0.0901 -0.0007
¡ Hola !
S-1     ¡ Hola !
H-1     -0.12050756067037582    Hi !
P-1     -0.3437 -0.0119 -0.0059
¿ Dónde está el baño ?
S-2     ¿ Dónde está el baño ?
H-2     -0.24064254760742188    Where &apos;s the restroom ?
P-2     -0.0036 -0.4080 -0.0012 -1.0285 -0.0024 -0.0002
¿ Hay habitaciones libres ?
S-3     ¿ Hay habitaciones libres ?
H-3     -0.25766071677207947    Is there free rooms ?
P-3     -0.8187 -0.0018 -0.5702 -0.1484 -0.0064 -0.0004
¿ Acepta tarjeta de crédito ?
S-4     ¿ Acepta tarjeta de crédito ?
H-4     -0.10596384853124619    Do you accept credit card ?
P-4     -0.1347 -0.0297 -0.3110 -0.1826 -0.0675 -0.0161 -0.0001
La cuenta , por favor .
S-5     La cuenta , por favor .
H-5     -0.4411449432373047     Check , please .
P-5     -1.9730 -0.1928 -0.0071 -0.0328 -0.0001

这里大部分的输出句子都几乎完美,除了第四句(我会翻译成“有免费的房间吗?”)。即使考虑到这些句子都是任何一本旅行西班牙短语书中都可以找到的简单例子,但对于一个在一个小时内构建的系统来说,这并不是一个坏的开始!

6.4 Seq2Seq 模型的工作原理

在本节中,我们将深入探讨构成 Seq2Seq 模型的各个组件,包括编码器和解码器。我们还将涵盖用于解码目标句子的算法——贪婪解码和波束搜索解码。

6.4.1 编码器

正如我们在本章开始看到的,Seq2Seq 模型的编码器与我们在第五章中讨论的顺序标记模型并没有太大的不同。它的主要工作是接受输入序列(通常是一个句子)并将其转换为固定长度的向量表示。你可以使用像图 6.8 中所示的 LSTM-RNN。

CH06_F08_Hagiwara

图 6.8 Seq2Seq 模型的编码器

与顺序标记模型不同,我们只需要 RNN 的最终隐藏状态,然后将其传递给解码器生成目标句子。你也可以使用多层 RNN 作为编码器,这种情况下句子表示是每一层输出的串联,如图 6.9 所示。

CH06_F09_Hagiwara

图 6.9 使用多层 RNN 作为编码器

同样地,你可以使用双向(甚至是双向多层)RNN 作为编码器。最终的句子表示是正向层和反向层输出的串联,如图 6.10 所示。

CH06_F10_Hagiwara

图 6.10 使用双向 RNN 作为编码器

注意 这是一个小细节,但要记得 LSTM 单元产生两种类型的输出:单元状态和隐藏状态(请参阅 4.2.2 节)。在使用 LSTM 编码序列时,我们通常只使用最终隐藏状态,而丢弃单元状态。把单元状态看作是类似于临时循环变量,用于计算最终结果(隐藏状态)。请参见图 6.11 进行说明。

CH06_F11_Hagiwara

图 6.11 使用 LSTM 单元的编码器

6.4.2 解码器

同样,Seq2Seq 模型的解码器与我们在第五章中介绍的语言模型类似。实际上,它们除了一个关键的区别外完全相同——解码器从编码器那里获取输入。我们在第五章中介绍的语言模型称为无条件语言模型,因为它们在没有任何输入或前提条件的情况下生成语言。另一方面,根据某些输入(条件)生成语言的语言模型称为条件语言模型。Seq2Seq 解码器是一种条件语言模型,其中条件是编码器生成的句子表示。请参见图 6.12,了解 Seq2Seq 解码器的工作原理的示例。

CH06_F12_Hagiwara

图 6.12 Seq2Seq 模型的解码器

就像语言模型一样,Seq2Seq 解码器从左到右生成文本。与编码器一样,您可以使用 RNN 来实现这一点。解码器也可以是多层 RNN。然而,解码器不能是双向的——你不能从两边生成一个句子。正如第五章中提到的那样,对过去生成的序列进行操作的模型被称为自回归模型

非自回归模型

如果你认为简单地从左到右生成文本太过受限制,那么你有道理。人类也不总是线性地写语言——我们经常在之后修订、添加和删除单词和短语。此外,线性地生成文本并不是很高效。句子的后半部分需要等待直到它的前半部分完成,这使得并行化生成过程非常困难。截至本文撰写时,研究人员正在大力开发非自回归的机器翻译模型,这些模型不会以线性方式生成目标句子(例如,请参阅 Salesforce Research 的这篇论文:arxiv.org/abs/1711.02281)。然而,它们在翻译质量上还没有超过自回归模型,大多数研究和生产的机器翻译系统仍然采用自回归模型。

解码器在训练阶段和预测阶段的行为略有不同。让我们先看看它是如何训练的。在训练阶段,我们确切地知道源句应该被翻译成目标句。换句话说,我们确切地知道解码器应该逐词生成什么。因此,解码器的训练方式与顺序标记模型的训练方式相似(参见第五章)。

首先,解码器被喂入由编码器产生的句子表示和一个特殊标记,该标记表示句子的开始。 第一个 RNN 单元处理这两个输入并产生第一个隐藏状态。 隐藏状态向量被馈送到一个线性层,该层收缩或扩展此向量以匹配词汇表的大小。 然后得到的向量通过 softmax,将其转换为概率分布。 此分布规定了词汇表中每个单词在接下来出现的可能性。

然后,这就是训练发生的地方。 如果输入是“Maria no daba una bofetada a la bruja verde”,那么我们希望解码器生成其英文等效句子:“Mary did not slap the green witch.” 这意味着我们希望最大化第一个 RNN 单元生成“Mary”的概率,给定输入句子。 这是本书中我们在很多地方见过的一个多类别分类问题——词嵌入(第三章),句子分类(第四章)和序列标记(第五章)。 您使用交叉熵损失来衡量期望结果与网络实际输出之间的差距有多远。 如果“Mary”的概率很大,那么好——网络会产生较小的损失。 另一方面,如果“Mary”的概率很小,则网络会产生较大的损失,这会鼓励优化算法大幅更改参数(魔法常量)。

然后,我们移动到下一个单元。 下一个单元接收由第一个单元计算的隐藏状态和单词“Mary”,不管第一个单元生成了什么。 与使用语言模型生成文本时喂入先前单元生成的标记不同,我们约束解码器的输入,以防止其“偏离”。 第二个单元基于这两个输入产生隐藏状态,然后用于计算第二个单词的概率分布。 我们通过将分布与期望输出“did”进行比较来计算交叉熵损失,并继续移动到下一个单元。 我们一直这样做,直到达到最终标记,即。 句子的总损失是句子中所有单词产生的所有损失的平均值,如图 6.13 所示。

CH06_F13_Hagiwara

图 6.13 训练 Seq2Seq 解码器

最后,以这种方式计算的损失用于调整解码器的模型参数,以便下一次它能生成期望的输出。 请注意,在此过程中也会调整编码器的参数,因为损失通过句子表示一直传播回编码器。 如果编码器产生的句子表示不好,那么解码器无论如何努力,都无法生成高质量的目标句子。

6.4.3 贪婪解码

现在让我们看看解码器在预测阶段的行为,其中给定了一个源句子给网络,但我们不知道正确的翻译应该是什么。在这个阶段,解码器的行为很像我们在第五章讨论过的语言模型。它被提供了由编码器产生的句子表示,以及一个特殊的标记,表示句子的开头。第一个循环神经网络单元处理这两个输入并产生第一个隐藏状态,然后将其馈送到线性层和 softmax 层,以产生目标词汇的概率分布。关键部分来了——与训练阶段不同,你不知道接下来应该出现的正确单词,所以你有多个选项。你可以选择任何一个具有相当高概率的随机单词(比如“dog”),但最好的选择可能是选择概率最高的单词(如果是“Mary”那就太幸运了)。机器翻译系统生成刚刚选择的单词,然后将其馈送到下一个循环神经网络单元。这个过程重复进行,直到遇到特殊标记。图 6.14 说明了这个过程。

CH06_F14_Hagiwara

图 6.14 使用 Seq2Seq 解码器进行预测

好的,我们都准备好了吗?我们可以继续评估我们的机器翻译系统了吗,因为它正在尽其所能产生最佳的翻译?不要那么快——在这种方式解码目标句子时可能会出现许多问题。

首先,机器翻译解码的目标是最大化整个目标句子的概率,而不仅仅是单个单词。这正是你训练网络要做的事情——为正确的句子产生最大的概率。然而,前面描述的每一步选择单词的方式是为了最大化该单词的概率。换句话说,这种解码过程只保证了局部最大概率。这种短视、局部最优的算法在计算机科学中被称为贪婪,我刚刚解释的解码算法被称为贪婪解码。然而,仅仅因为你在每一步都在最大化单词的概率并不意味着你在最大化整个句子的概率。一般来说,贪婪算法不能保证产生全局最优解,而使用贪婪解码可能会让你陷入次优翻译的困境。这并不是很直观,所以让我用一个简单的例子来说明这一点。

当你在每个时间步选择单词时,你有多个单词可以选择。你选择其中一个然后移动到下一个循环神经网络单元,它会产生另一组可能选择的单词,这取决于你之前选择的单词。这可以用一个树状结构来表示,就像图 6.15 所示的那样。该图显示了你在一个时间步选择的单词(例如“did”)如何分支到下一个时间步可以选择的一组可能单词(“you”和“not”)。

CH06_F15_Hagiwara

图 6.15 解码决策树

每个单词到单词的转换都加上了一个分数,该分数对应于选择该转换的概率有多大。你的目标是在从时间步 1 到 4 遍历一条路径时最大化得分的总和。在数学上,概率是 0 到 1 之间的实数,并且你应该将(而不是相加)每个概率相乘以获得总数,但我在这里简化了问题。例如,如果你从“Mary”到“did”,然后到“you”和“do”,你刚生成了一个句子“Mary did you do”,总分是 1 + 5 + 1 = 7。

在之前看到的贪婪解码器生成时间步 2 的“did”后,它将面临两个选择:用 5 分数生成“you”或用 3 分数生成“not”。因为它只是选择得分最高的那个,它会选择“you”并继续前进。然后在时间步 3 之后,它将面临另一个分支——用 1 分数生成“do”或用 2 分数生成“know”。同样,它将选择最大的分数,这样你就会得到“Mary did you know”的翻译,其分数为 1+ 5 + 1 = 8。

这并不是一个坏结果。至少,它不像第一条路径一样糟糕,它的总得分为 7。通过在每个分支上选择最大分数,你确保你的最终结果至少是像样的。然而,如果你在时间步 3 选择了“not”呢?乍一看,这似乎不是个好主意,因为你得到的分数只有 3,比你走另一条路径的 5 小。但在下一个时间步,通过生成“slap”,你得到了 5 分的分数。回顾起来,这是正确的决定——总体而言,你得到了 1 + 3 + 5 = 9 分,这比沿着另一个“you”路径得到的分数要高。通过牺牲短期回报,你能够在长期获得更大的回报。但是由于贪婪解码器的近视性质,它永远不会选择这条路径——它无法回溯并改变其心意,一旦选择了一条而不是另一条路径。

如果看一下图 6.15 中的玩具示例,选择哪个方向以最大化总分数似乎很容易,但在现实中,你不能“预见”未来——如果你处于时间步 t,你无法预测在时间步 t + 1 及以后会发生什么,直到你实际选择一个单词并将其馈送到 RNN 中为止。但是,最大化单个概率的路径不一定是最优解。你无法尝试每个可能的路径并查看你得到的分数,因为词汇表通常包含成千上万个独特的单词,这意味着可能的路径数呈指数增长。

令人沮丧的事实是,您无法合理地期望在短时间内找到最大化整个句子概率的最优解路径。但是你可以避免陷入困境(或至少减少陷入困境的可能性),这就是梁搜索解码器的作用。

6.4.4 梁搜索解码

让我们想象如果你处于同样的情境下应该怎么做。假设你是一名大学大二的学生,在本学年结束前,你需要决定选择哪个专业。你的目标是在你一生中最大化收入(或幸福或其他你关心的东西),但你不知道哪个专业对于这一目标来说是最好的。你不能尝试每个可能的专业并观察几年后的结果——专业太多,你也无法回到过去。并且仅仅因为有些专业在短期内看起来很有吸引力(例如选择经济学专业可能会带来在大型投资银行的好实习机会),并不意味着这条道路在长期来看是最好的(请看 2008 年发生了什么)。

在这种情况下,你可以做的一件事是通过同时选择多个专业(双专业或辅修)而不是 100%致力于特定的专业,来进行投机。几年后,如果情况与你想象的不同,仍然可以更改主意并追求另一种选择,如果你贪婪地选择专业(即只考虑短期前景),则这是不可能的。

梁搜索解码的主要思想类似于这个——不是只选择一条路径,而是同时追求多条路径(称为假设)。这样,你就为“黑马”留下了一些空间,也就是那些在最初得分不高但可能后来表现出色的假设。让我们使用图 6.16 中的示例,这是图 6.15 的略微修改版本。

梁搜索解码的关键思想是使用(图 6.16 底部),可以将其看作是一种缓冲区,可以同时保留多个假设。梁的大小,即它可以保持的假设数,称为梁宽度。让我们使用大小为 2 的梁并看看会发生什么。最初,你的第一个假设只包含一个词“Mary”,得分为 0。当你转向下一个单词时,你选择的单词被附加到假设中,并且得分增加了你刚刚走过的路径的得分。例如,当你转到“did”时,它会生成一个新的假设,包含“Mary did”和得分 1。

CH06_F16_Hagiwara

图 6.16 梁搜索解码

如果在任何特定时间步有多个词可供选择,假设可能会产生多个子假设。在时间步 2,你有三个不同的选择——“你”,“不”和“n’t”——这会生成三个新的子假设:[Mary did you] (6),[Mary did not] (4) 和 [Mary did n’t] (3)。这就是束搜索解码的关键部分:因为束中只有有限的空间,任何不够好的假设在按分数排序后都会从束中掉下来。因为在这个例子中束只能容纳两个假设,除了前两个以外的任何东西都会被挤出束外,这就留下了[Mary did you] (6)和[Mary did not] (4)。

在时间步 3,每个剩余的假设可以产生多达两个子假设。第一个([Mary did you] (6))将生成[Mary did you know] (8)和[Mary did you do] (7),而第二个([Mary did not] (4))会变成[Mary did not slap] (9)。这三个假设按分数排序,最好的两个将作为束搜索解码的结果返回。

恭喜——现在你的算法能够找到最大化总分数的路径。通过同时考虑多个假设,束搜索解码可以增加你找到更好解决方案的机会。然而,它永远不是完美的——注意,一个同样好的路径[Mary did n’t do],得分为 9,在时间步 3 就从束中掉出来了。要“拯救”它,你需要增加束宽度到 3 或更大。一般来说,束宽度越大,翻译结果的期望质量就越高。然而,这是有一个折衷的:因为计算机需要考虑多个假设,随着束宽度的增加,它会线性变慢。

在 Fairseq 中,你可以使用 —beam 选项来改变束大小。在第 6.3.3 节的示例中,我使用了 —beam 5 来使用束宽度为 5。你已经在不知不觉中使用了束搜索。如果你使用相同的命令调用 —beam 1,这意味着你使用的是贪婪解码而不是束搜索,你可能会得到略有不同的结果。当我尝试这样做时,我得到的结果几乎相同,除了最后一个:“counts, please”,这不是“La cuenta, por favor.” 的一个很好的翻译。这意味着使用束搜索确实有助于提高翻译质量!

6.5 评估翻译系统

在这一节中,我想简要谈谈评估机器翻译系统的话题。准确评估机器翻译系统是一个重要的话题,无论在理论上还是在实践中。

6.5.1 人工评估

评估机器翻译系统输出的最简单、最准确的方法是使用人工评估。毕竟,语言是为人类翻译的。被人类认为好的翻译应该是好的。

正如之前提到的,我们对好的翻译有一些考虑因素。对于这两个方面最重要且常用的概念是充分性(也称为忠实度)和流畅性(也与可理解性密切相关)。充分性是源句子中的信息在翻译中反映的程度。如果通过阅读它的翻译,你可以重构源句子表达的大量信息,那么该翻译具有很高的充分性。流畅性则是翻译在目标语言中的自然程度。例如,如果你正在翻译为英语,“Mary did not slap the green witch”是一种流畅的翻译,而“Mary no had a hit with witch, green”则不是,尽管这两种翻译几乎是同样充分的。请注意,这两个方面在某种程度上是独立的-你可以想象一种流畅但不充分的翻译(例如,“Mary saw a witch in the forest”是一种完全流畅但不充分的翻译),反之亦然,就像之前的例子一样。能够产生既充分又流畅输出的 MT 系统是有价值的。

MT 系统通常通过将其翻译呈现给人类注释器并要求他们对其每个方面进行 5 或 7 点的评价来进行评估。流畅性更容易判断,因为它只需要目标句子的单语种听众,而充分性需要源语言和目标语言的双语种人员。

6.5.2 自动评估

尽管人工评估给出了 MT 系统质量的最准确评估,但并不总是可行的。在大多数情况下,你可能无法负担雇用人类评估员以在你需要时评估 MT 系统的输出。如果你处理的是不常见的语言对,你可能根本找不到双语的说话者来评估其充分性。

但更重要的是,在开发 MT 系统时需要不断地评估和监测其质量。例如,如果你使用 Seq2Seq 模型去训练一个 NMT 系统,你需要每次调整一个超参数就重新评估其性能。否则,你就不知道你的更改对它的最终性能是否有好或坏的影响。更糟糕的是,如果你要做像“early stopping”(见第 6.3.2 节)这样的事情来决定何时停止训练过程,你需要在每个周期之后评估它的性能。你不可能雇用人来在每个周期评估你的中间模型-这将是开发 MT 系统的一个可怕的缓慢的方法。这也是浪费时间,因为最初模型的输出在很大程度上是垃圾,不值得人类评估。中间模型的输出之间存在大量的相关性,人类评估员将花费大量的时间评估非常相似甚至相同的句子。

因此,如果我们能够使用某种自动方式来评估翻译质量,将是可取的。这种工作方式类似于我们之前看到的其他 NLP 任务的一些自动度量,例如分类的准确度、精确度、召回率和 F1 度量。其思想是提前为每个输入实例创建期望的输出,并将系统的输出与之进行比较。通常,这是通过为每个源句准备一组人为创建的翻译(称为参考)并计算参考和系统输出之间的某种相似度来完成的。一旦你创建了参考并定义了指标,你就可以根据需要自动评估翻译质量多少次。

计算参考和系统输出之间相似度的最简单方法之一是使用单词错误率(WER)。WER 反映系统相对于参考的错误数量,通过插入、删除和替换的相对数量来衡量。该概念类似于编辑距离,不同之处在于 WER 是针对单词而不是字符计数的。例如,当参考句子是“玛丽没有打绿色的女巫”,系统翻译为“玛丽打了绿色的邪恶女巫”时,你需要进行三次“编辑”才能将后者与前者匹配——插入“没有”,用“打”替换“击中”,删除“邪恶”。如果你将三除以参考长度(= 7),就是你的 WER(= 3/7,或 0.43)。WER 越低,翻译质量越好。

尽管 WER 简单易用,但在评估机器翻译系统时如今并不常用。一个原因与多个参考有关。对于单个源句可能有多个同样有效的翻译,但是当存在多个参考时如何应用 WER 并不清楚。对于机器翻译中自动评估最常用的稍微复杂一点的指标是 BLEU(双语评估学习)。BLEU 通过使用修改后的精确度来解决多个参考的问题。接下来我将用一个简单的例子来说明这一点。

在以下表格中,我们评估一个候选人(系统的输出)“the the the the the the the”(顺便说一句,这是一个糟糕的翻译)与两个参考:“猫在地毯上”和“地毯上有只猫”。BLEU 的基本思想是计算候选中所有唯一单词的精度。因为候选中只有一个唯一单词“the”,如果计算其精度,它将自动成为候选的分数,即为 1,或 100%。但这似乎有些不对。

候选 the the the the the the the
参考 1 地毯
参考 2 那里 一只 地毯

因为参考译文中只有两个“the”,系统生成的虚假“the”不应该计入精度。换句话说,我们应该将它们视为误报。我们可以通过将精度的分母限制为参考译文中该词的最大出现次数来做到这一点。因为在这种情况下(在参考译文 1 中)是 2,其修改后的精度将为 2/7,约为 29%。在实践中,BLEU 不仅使用唯一的词(即一元词),还使用候选译文和参考译文中长度不超过 4 的所有唯一单词序列(n 元词)。

然而,我们可以通过另一种方式操纵这个指标——因为它基于精度而不是召回率,一个机器翻译系统可以通过产生很少的系统确信的词语来轻松获得高分。在前面的例子中,你只需简单地产生“cat”(甚至更简单地,“the”),BLEU 分数将达到 100%,这显然不是一个好的翻译。BLEU 通过引入简洁惩罚来解决这个问题,如果候选翻译比参考译文短,就会打折扣。

精确自动评估指标的开发是一个活跃的研究领域。许多新的指标被提出并用于解决 BLEU 的缺点。我们在这一部分只是浅尝辄止。虽然新的指标显示与人类评估的相关性更高,并声称更好,但 BLEU 仍然是目前最广泛使用的指标,主要是因为其简单性和悠久的传统。

6.6 案例研究:构建聊天机器人

在本节中,我将讨论 Seq2Seq 模型的另一个应用——聊天机器人,这是一种 NLP 应用,你可以与之进行对话。我们将使用 Seq2Seq 模型构建一个非常简单但功能齐全的聊天机器人,并讨论构建智能代理的技术和挑战。

6.6.1 引入对话系统

我在第 1.2.1 节简要介绍了对话系统。简而言之,主要有两种类型的对话系统:面向任务和聊天机器人。尽管面向任务的对话系统用于实现一些特定目标,例如在餐厅预订和获取一些信息,但聊天机器人用于与人类进行对话。由于商业对话人工智能系统如亚马逊 Alexa、苹果 Siri 和谷歌助手的成功和大量普及,对话技术目前是自然语言处理从业者的热门话题。

您可能不知道如何开始构建可以进行会话交流的自然语言处理应用程序。我们该如何构建一个“智能”的东西来“思考”,以便它能为人类输入生成有意义的响应?这似乎有些遥远和困难。但是,如果您退后一步,看看我们与其他人的典型对话,有多少实际上是“智能”的呢?如果您像我们大多数人一样,那么您正在进行的会话中有很大一部分都是自动驾驶的:“你好吗?”“我没事,谢谢”“祝你有个愉快的一天”“你也是!”等等。您可能还有一组“模板”回复,对于许多日常问题,例如“你在干什么?”和“你来自哪里?”这些问题可以通过查看输入来回答。甚至更复杂的问题,如“X 中你最喜欢的餐厅是什么?”(其中 X 是您城市的一个地区的名称)和“你最近看过任何 Y 类型的电影吗?”(其中 Y 是一种类型),都可以通过“模式匹配”并从记忆中检索相关信息来回答。

如果您将对话视为一组“回合”,其中响应是通过模式匹配来生成的,那么这看起来与典型的自然语言处理问题非常相似。特别是,如果您认为对话是一个问题,其中 NLP 系统只是将您的问题“翻译”为其响应,那么这正是我们可以使用本章迄今涵盖的 Seq2Seq 模型的地方。我们可以将之前(人类的)话语视为外国语句子,并将聊天机器人“翻译”成另一种语言。尽管在这种情况下,这两种语言都是英语,但是在 NLP 中,通常将输入和输出视为两种不同的语言,并将 Seq2Seq 模型应用于它们,包括摘要(将更长的文本缩短)和语法纠错(将有错误的文本纠正为无错误的文本)。

6.6.2 准备数据集

在本案例中,我们将使用自我对话语料库(github.com/jfainberg/self_dialogue_corpus),其中包含 24,165 个对话。这个数据集的特殊之处在于,这些对话并不是两个人之间的实际对话,而是由一人扮演双方所写的虚构对话。虽然还有其他几个基于文本的聊天机器人的对话数据集(例如 OpenSubtitles 数据集,opus.nlpl.eu/OpenSubtitles-v2018.php),但这些数据集通常存在噪声并且常常包含粗言秽语。相比之下,通过收集编造的对话,自我对话语料库在仅有一人的情况下提高了一半的质量(因为你只需要一个人而不是两个人!)。

与之前相同,我对语料进行了分词和转换,使其可被 Fairseq 解读。您可以按以下方式获取转换后的数据集:

$ mkdir -p data/chatbot
$ wget https://realworldnlpbook.s3.amazonaws.com/data/chatbot/selfdialog.zip
$ unzip selfdialog.zip -d data/chatbot

您可以使用以下 paste 命令的组合(以水平方式拼接文件)和 head 命令来查看训练部分的开头。请注意,我们使用 fr(表示“外语”,而不是“法语”)来表示我们正在从中翻译的“语言”:

$ paste data/chatbot/selfdialog.train.tok.fr data/chatbot/selfdialog.train.tok.en | head 
...
Have you played in a band ?    What type of band ?
What type of band ?    A rock and roll band .
A rock and roll band .    Sure , I played in one for years .
Sure , I played in one for years .    No kidding ?
No kidding ?    I played in rock love love .
I played in rock love love .    You played local ?
You played local ?    Yes
Yes    Would you play again ?
Would you play again ?    Why ?
...

如您所见,每一行都包含一个话语(在左侧)和对其的回应(在右侧)。请注意,此数据集与我们在第 6.3.1 节中使用的西班牙语-英语平行语料库具有相同的结构。下一步是运行 fairseq-preprocess 命令将其转换为二进制格式,如下所示:

$ fairseq-preprocess \
    --source-lang fr \
    --target-lang en \
    --trainpref data/chatbot/selfdialog.train.tok \
    --validpref data/chatbot/selfdialog.valid.tok \
    --destdir data/chatbot-bin \
    --thresholdsrc 3 \
    --thresholdtgt 3

再次,这与我们为西班牙语翻译器示例运行的内容类似。只需注意您指定的源语言—我们在这里使用的是 fr 而不是 es。

6.6.3 训练和运行聊天机器人

现在,聊天机器人的训练数据准备好了,让我们从这些数据中训练一个 Seq2Seq 模型。您可以像下面这样调用 fairseq-train 命令,几乎使用与上次相同的参数:

$ fairseq-train \
    data/chatbot-bin \
    --arch lstm \
    --share-decoder-input-output-embed \
    --optimizer adam \
    --lr 1.0e-3 \
    --max-tokens 4096 \
    --save-dir data/chatbot-ckpt

与先前一样,请注意验证损失随每个时代的变化。当我尝试时,验证损失在约五个时代内减小,然后开始缓慢上升。在观察到验证损失趋于稳定后,随时按 Ctrl + C 停止训练命令。Fairseq 将自动将最佳模型(按验证损失测量)保存为 checkpoint_best.pt。

最后,您可以通过调用 fairseq-interactive 命令来运行聊天机器人模型,如下所示:

$ fairseq-interactive \
    data/chatbot-bin \
    --path data/chatbot-ckpt/checkpoint_best.pt \
    --beam 5 \
    --source-lang fr \
    --target-lang en

与前面的示例一样,您可以输入源句并与聊天机器人对话,让它将它们“翻译”为另一种语言!以下是我与我训练的模型进行的一部分对话(为了清晰起见,我添加了粗体)。再次强调,您的结果可能不同:

Hi !
S-0     Hi !
H-0     -1.011711835861206      Hey .
P-0     -0.6860 -2.1352 -0.2140
How are you ?
S-1     How are you ?
H-1     -0.6552960276603699     Doing good , you ?
P-1     -0.6472 -0.8651 -1.0960 -0.9257 -0.3337 -0.0640
Good .
S-2     Good .
H-2     -0.8540937900543213     Do you have a favorite team ?
P-2     -2.3027 -0.0550 -1.7088 -0.4403 -0.1763 -2.0262 -0.1150 -0.0085
Yeah I like the Seahawks .
S-3     Yeah I like the Seahawks .
H-3     -0.6036218404769897     Do you have a favorite team ?
P-3     -2.7614 -0.0525 -1.0905 -0.3178 -0.3213 -0.1049 -0.1701 -0.0106
How about you ?
S-4     How about you ?
H-4     -1.2329542636871338     I 'm a big fan of them .
P-4     -0.7648 -1.8435 -1.6808 -1.2868 -0.2102 -0.1362 -3.5273 -1.0031 -0.6438
What 's your favorite movie ?
S-5     What 's your favorite movie ?
H-5     -0.8238655924797058     Oh , that 's a tough one . I 'd have to say Ghost Busters .
P-5     -3.2622 -0.3213 -1.4815 -0.1012 -0.3417 -0.3069 -0.2432 -0.6954 -1.0858 -2.3246 -0.4296 -0.0039 -0.0905 -2.6429 -0.1676 -0.3853 -0.1221
Oh yeah , I like that movie too .
S-6     Oh yeah , I like that movie too .
H-6     -1.0659444332122803     Yeah , that 's a good one .
P-6     -2.0782 -0.1724 -2.5089 -0.9560 -1.5034 -0.9040 -0.4471 -0.5435 -0.4801

在这个例子中,对话看起来很自然。因为自我对话语料库是通过限制可能的对话主题集来构建的,所以如果您保持在这些主题(电影、体育、音乐等)上,对话更有可能顺利进行。

然而,一旦您开始谈论不熟悉的话题,聊天机器人就会对其答案失去信心,如下所示:

What 's your name ?
S-0     What 's your name ?
H-0     -0.9807574152946472 I do n't know , but I do n't think I 've                           heard of them .
P-0     -1.4680 -2.2938 -0.0635 -1.0242 -1.2039 -0.5201 -0.3381 -2.2612 -0.1665 -1.6033 -0.6112 -1.5515 -0.8739 -0.8453 -1.0143 -0.4702 -0.3637
What do you do ?
S-1     What do you do ?
H-1     -1.170507788658142 I do n't know .
P-1     -0.9807 -2.1846 -0.3276 -0.9733 -1.3650 -1.1919
Are you a student ?
S-2     Are you a student ?
H-2     -0.9505285024642944 I 'm not sure .
P-2     -1.5676 -1.5270 -0.6944 -0.2493 -0.8445 -0.8204

这是一个众所周知的现象—一个简单的基于 Seq2Seq 的聊天机器人很快就会退化到生成“我不知道”和“我不确定”等模板回答,每当被问及它不熟悉的事物时。这与我们训练这个聊天机器人的方式有关。因为我们训练模型是为了使其在训练数据中最小化损失,它能采取的最佳策略是生成适用于尽可能多的输入句子的东西。非常通用的短语,比如“我不知道”,可以成为许多问题的答案,所以这是一个安全的策略,可以减少损失!

6.6.4 后续步骤

虽然我们的聊天机器人可以为许多输入产生逼真的响应,但它离完美还有很远的路要走。它处理不好的一个问题是专有名词。当你询问需要具体答案的问题时,就会看到这一点,比如:

What 's your favorite show ?
S-0     What 's your favorite show ?
H-0     -0.9829921722412109     I would have to say <unk> .
P-0     -0.8807 -2.2181 -0.4752 -0.0093 -0.0673 -2.9091 -0.9338 -0.3705

这里,是未知词的通用特殊符号。聊天机器人正在尝试回答某些问题,但该问题在训练数据中出现的频率太低,以至于不能被视为独立的词。这是一般简单 NMT 系统中出现的问题。因为模型需要将有关单词的所有信息压缩成 200 多维的数字向量,所以许多细节和类似单词之间的区别都被牺牲了。想像一下将关于你所在城市所有餐厅的信息压缩成一个 200 维的向量!

此外,我们训练的聊天机器人没有任何 "记忆" 或任何上下文概念。你可以通过问一系列相关问题来测试这一点,如下所示:

Do you like Mexican food ?
S-0     Do you like Mexican food ?
H-0     -0.805641770362854      Yes I do .
P-0     -1.0476 -1.1101 -0.6642 -0.6651 -0.5411
Why do you like it ?
S-1     Why do you like it ?
H-1     -1.2453081607818604     I think it 's a great movie .
P-1     -0.7999 -2.1023 -0.7766 -0.7130 -1.4816 -2.2745 -1.5750 -1.0524 -0.4324

在第二个问题中,聊天机器人在理解上下文方面有困难,并产生了完全无关的响应。要正确回答这样的问题,模型需要理解代词 "it" 指的是前面的名词,即本例中的 "Mexican food"。NLP 系统在现实世界中解决哪些提及指向哪些实体的任务被称为共指消解。系统还需要维护某种类型的记忆,以跟踪到目前为止在对话中讨论了哪些内容。

最后,在本章中讨论的简单 Seq2Seq 模型在处理长句子方面并不擅长。如果您回顾一下图 6.2,就会理解这一点 - 模型使用 RNN 读取输入语句,并使用固定长度的句子表示向量表示有关句子的所有内容,然后从该向量生成目标语句。无论输入是“Hi!”还是“The quick brown fox jumped over the lazy dog.”,句子表示都会成为瓶颈,特别是对于更长的输入。因此,在 2015 年左右,直到发明了一种称为 注意力 的机制来解决这个问题之前,神经 MT 模型无法击败传统的基于短语的统计 MT 模型。我们将在第八章详细讨论注意力。

概括

  • Seq2Seq 模型使用编码器和解码器将一个序列转换为另一个序列。

  • 你可以使用 fairseq 框架在一小时内构建工作中的翻译系统。

  • Seq2Seq 模型使用解码算法生成目标序列。贪心解码每一步都最大化概率,而束搜索则尝试同时考虑多个假设来寻找更好的解决方案。

  • 用于自动评估翻译系统的一个指标叫做 BLEU。

  • 通过使用 Seq2Seq 模型和对话数据集,可以构建一个简单的聊天机器人。

^(1.) 详细信息请参阅 Oriol Vinyals 等人的“Grammar as a Foreign Language”(2015 年;arxiv.org/abs/1412.7449)。

^(2.) 请注意每行开头的 $ 是由 shell 渲染的,您无需输入它。

第七章:卷积神经网络

本章内容包括

  • 通过检测模式解决文本分类问题。

  • 使用卷积层来检测模式并生成分数。

  • 使用池化层来聚合由卷积产生的分数。

  • 通过组合卷积和池化来构建卷积神经网络(CNN)。

  • 使用 AllenNLP 构建基于 CNN 的文本分类器。

在之前的章节中,我们介绍了线性层和 RNN,这是 NLP 中常用的两种主要神经网络体系结构。在本章中,我们介绍了另一种重要的神经网络类别,称为卷积神经网络(CNN)。CNN 具有与 RNN 不同的特征,使它们适用于检测语言模式至关重要的 NLP 任务,例如文本分类。

7.1 介绍卷积神经网络(CNN)

本节介绍了另一种类型的神经网络体系结构,称为卷积神经网络(CNN),它以与 RNN 不同的方式运行。CNN 特别擅长于模式匹配任务,在 NLP 社区中越来越受欢迎。

7.1.1 循环神经网络及其缺点。

在第四章中,我们讨论了句子分类,这是一个自然语言处理任务,接收一些文本作为输入并为其生成标签。我们还讨论了如何使用循环神经网络(RNN)来完成该任务。作为复习,RNN 是一种具有“循环”的神经网络,它从开头开始逐个元素地处理输入序列直到结束。在每一步更新的内部循环变量称为隐藏状态。当 RNN 完成处理整个序列时,最终时间步长处的隐藏状态表示输入序列的压缩内容,可用于包括句子分类在内的 NLP 任务。或者,您可以在每一步之后取出隐藏状态并将其用于为单词分配标签(例如 PoS 和命名实体标签)。在循环中反复应用的结构称为单元。具有简单乘法和非线性的 RNN 称为香草埃尔曼 RNN。另一方面,基于 LSTM 和 GRU 的 RNN 使用更复杂的单元,这些单元使用存储器和门。

RNN 在现代 NLP 中是一种强大的工具,具有广泛的应用范围;但是,它们并非没有缺点。首先,RNN 速度较慢-无论如何都需要逐个元素地扫描输入序列。它们的计算复杂度与输入序列的长度成正比。其次,由于它们的顺序性质,RNN 难以并行化。想象一个多层 RNN,其中多个 RNN 层堆叠在一起(如图 7.1 所示)。在朴素实现中,每一层都需要等到所有下面的层完成对输入的处理。

CH07_F01_Hagiwara

图 7.1 多层 RNN

其次,对于某些任务,RNN 结构过于复杂和低效。例如,在第四章中我们讲解了检测符合语法的句子的任务。在最简单的形式下,任务是在一个两个词组成的句子中识别主谓一致性的正确与否。如果句子包含诸如“I am”和“you are”之类的短语,那么它是符合语法的。如果包含“I are”或“you am”,那么就不符合语法。在第四章,我们构建了一个简单的带非线性的 LSTM-RNN 来识别有四个单词词汇量的两个词组成句子的语法正确性。但是,如果你需要对一个词汇量非常大的任意长度的句子进行分类,这个过程就开始变得非常复杂了。你的 LSTM 需要从大量的噪声(与一致性无关的其他单词和短语)中识别出信号(主谓一致性),同时学习使用更新操作来处理输入的每一个元素。

但是如果你仔细考虑,无论句子有多长或者词汇量有多大,你的网络的任务应该还是相当简单——如果句子包含有效的短语(如“I am”和“you are”),那么它符合语法。否则,不符合语法。实际上,这个任务与我们在第一章中看到的“如果-那么”情感分析器非常相似。很明显,LSTM RNN 的结构对于这个任务来说过于复杂,简单的文字和短语模式匹配就足够了。

7.1.2 句子分类的模式匹配

如果你看一下文本分类的一般情况,很多任务可以通过“模式匹配”来有效解决。以垃圾邮件过滤为例:如果你想要检测垃圾邮件,只需要查找像“v1agra”和“商机”这样的词语和短语,甚至不需要读完整封邮件;这些模式出现在什么地方并不重要。如果你想要从电影评论中检测情感,检测到像“amazing”和“awful”这样的积极和消极词语就足够了。换句话说,学习和检测这种本地语言模式,而不考虑它们的位置,对于文本分类任务是一种有效而高效的策略,也可能对其他自然语言处理任务有效。

在第三章,我们学习了 n 元语法的概念,即一个或多个词的连续序列。它们经常被用作自然语言处理中更正式定义的语言单位(如短语和从句)的代理。如果有一种工具能够遍历大量的文本噪声并检测作为信号的 n 元语法,那将非常适合文本分类。

7.1.3 卷积神经网络(CNNs)

卷积神经网络(CNN)正是做到这一点的。CNN 是一种神经网络类型,它涉及一种称为卷积的数学运算,简单来说,它检测有用于当前任务的局部模式。CNN 通常由一个或多个卷积层和一个或多个池化层组成,卷积层进行卷积操作,池化层负责聚合卷积结果。请参见图 7.2。分别在第 7.2 节和第 7.3 节中详细介绍卷积层和池化层。

CH07_F02_Hagiwara

图 7.2 卷积神经网络

CNN 受到人脑视觉系统的启发,在计算机视觉任务(如图像分类和目标检测)中被广泛使用。近年来,CNN 的使用在自然语言处理领域越来越流行,特别是在文本分类、序列标注和机器翻译等任务中。

7.2 卷积层

在本节中,我们将讨论卷积层,这是 CNN 架构的核心部分。术语卷积听起来可能有点可怕,但本质上它只是一种模式匹配。我们将使用图示和直观的例子来说明它的工作原理。

7.2.1 使用滤波器进行模式匹配

卷积层是 CNN 中最重要的组件。如前所述,卷积层将一种称为卷积的数学运算应用于输入向量,并产生输出。但是什么是卷积?理解卷积的严格定义需要了解线性代数,因此我们将使用类比和具体示例来理解它。想象一下,你手里拿着一个带有复杂图案的矩形玻璃块(就像你在教堂里看到的彩色玻璃),在观察它的同时将其滑动到输入序列上。如果输入模式与玻璃块的模式匹配,更多的光线透过玻璃进去,你会得到更大的输出值。如果输入模式看起来不像玻璃块的模式或者相反,你会得到更小的输出值。换句话说,你正在使用带有彩色玻璃块的道具在输入序列中寻找特定的模式。

这个类比比较模糊,所以让我们回顾一下我们在第四章中使用的语法检测的例子,并看看如何将卷积层应用到这个任务上。回顾一下,我们的神经网络接收一个包含两个词的句子作为输入,并需要区分出语法正确的序列和语法错误的序列。词汇表中只有四个词--“I”,“you”,“am”和“are”,它们由单词嵌入表示。类似地,输入句子只有四种可能性--“I am”,“I are”,“you am”和“you are”。你希望网络对前两种情况产生 1,对其他情况产生 0。请参见图 7.3 进行说明。

CH07_F03_Hagiwara

图 7.3 识别英文语法正确的句子

现在,让我们将词嵌入表示为模式。我们用黑色圆表示值-1,白色圆表示 1。然后,您可以将每个单词向量表示为两个圆的一对(请参见图 7.3 左侧的表)。同样,您可以将每个两个词的句子表示为两个向量的小“片段”,或者四个圆(请参见图 7.3 右侧的表)。我们的任务开始看起来更像是一个模式识别任务,网络需要学习对应于语法句子的黑白模式。

然后,让我们想象一个相同大小的“滤波器”(两个圆×两个圆),它充当我们之前讨论过的彩色玻璃。该滤波器的每个圆也是黑色或白色,对应值-1 和 1。您将通过这个滤波器查看一个模式,并确定是否这是您要找的模式。您可以通过将滤波器放在模式上并计算两者之间的颜色匹配数量来执行此操作。对于四个位置中的每一个,如果颜色匹配(黑色-黑色或白色-白色),则得分+1,如果不匹配(黑色-白色或白色-黑色),则得分-1。您的最终得分是四个分数的总和,从-4(无匹配)到+4(四次匹配)。请参见图 7.4 中的一些示例。

CH07_F04_Hagiwara

图 7.4 卷积滤波器示例

您得到的分数取决于模式和滤波器,但如图所示,当滤波器与模式相似时,分数变大,当两者不相似时,分数变小。当两者完全匹配时,您获得最大分数(4),当两者完全相反时,您获得最小分数(-4)。该滤波器充当输入的模式检测器。虽然这是一个非常简化的例子,但基本上显示了卷积层在做什么。在卷积神经网络中,这种滤波器称为

在更一般的设置中,您有一个任意长度的输入句子,并且从左到右将一个核滑过句子。请参见图 7.5 以了解此过程的示意图。该核反复应用于连续的两个词,以生成一系列分数。因为我们在这里使用的核覆盖了两个词,所以它被称为具有大小为 2 的核。而且,因为输入嵌入中有两个维度(称为通道),所以核的输入通道数量为 2。

CH07_F05_Hagiwara

图 7.5 在输入句子上滑动核

注意 嵌入维度被称为通道的原因是因为 CNN 最常应用于计算机视觉任务,其中输入通常是不同通道的 2-D 图像,这些通道对应于不同颜色的强度(如红色、绿色和蓝色)。在计算机视觉中,核是二维的,并在输入的 2-D 图像上移动,这也被称为 2-D 卷积。然而,在自然语言处理中,核通常是一维的(1-D 卷积),并且只有一个尺寸。

7.2.2 整流线性单元(ReLU)

作为下一步,让我们考虑如何使用核来获得期望的输出(图 7.3 中的 Desired 列)。如果我们使用图 7.4 中第二列所示的滤波器会怎样?从现在开始,我们将这个核称为核 1。这个核完全匹配第一个模式并给它一个高分,同时给其他模式给出零或负分数。图 7.6 显示了将核 1 应用于每个模式时的分数(称为分数 1)。

CH07_F06_Hagiwara

图 7.6 对模式应用核 1

现在让我们忘记分数的大小,专注于它们的符号(正数和负数)。前三个模式的符号在分数 1 和所需之间匹配,但对于最后一个模式则不是。要正确评分,即给出正分数,您需要使用另一个与最后一个模式完全匹配的滤波器。我们称这个核为核 2。图 7.7 显示了应用核 2 到每个模式时的分数(称为分数 2)。

核 2 可以为最后三个模式给出与所需符号匹配的正确分数,但不能为第一个模式。但是,如果仔细观察图 7.6 和 7.7,看起来如果有一种方法可以在核给出负分数时忽略输出,然后组合来自多个核的分数,那么就可以更接近所需的分数。

CH07_F07_Hagiwara

图 7.7 对模式应用核 2

让我们考虑一个函数,它将任何负输入夹紧为零,同时保持任何正值不变。在 Python 中,这个函数可以写成如下:

def f(x):
    if x >= 0:
        return x
    else:
        return 0

或者更简单

def f(x):
    return max(0, x)

您可以通过将此函数应用于分数 1 和分数 2 来忽略负值,如图 7.8 和 7.9 所示。

CH07_F08_Hagiwara

图 7.8 对分数 1 应用 ReLU

CH07_F09_Hagiwara

图 7.9 对分数 2 应用 ReLU

这个函数,被称为修正线性单元,或称为 ReLU(发音为“rel-you”),是深度学习中最简单但最常用的激活函数之一。 它通常与卷积层一起使用,虽然它非常简单(它只是将负值夹紧为零),但它仍然是一个激活函数,它使神经网络能够学习复杂的非线性函数(参见第四章,了解为什么非线性激活函数很重要)。 它还具有有利的数学属性,使得优化网络变得更容易,尽管理论细节超出了本书的范围。

7.2.3 合并分数

如果您查看图 7.8 和图 7.9,所谓的“固定”分数—显示在 f(Score 1) 和 f(Score 2) 列中—至少部分地捕捉到了期望的分数。 您所需做的就是将它们结合在一起(通过求和)并调整范围(通过除以 4)。 图 7.10 展示了这个结果。

CH07_F10_Hagiwara

图 7.10 结合两个内核的结果

在合并之后,分数与期望的结果完全匹配。 到目前为止,我们所做的一切都是设计与我们想要检测的模式相匹配的内核,然后简单地组合分数。 比较一下我们在第 4.1.3 节中处理的 RNN 示例,那里我们需要使用一些复杂的数值计算来推导参数。 希望这个例子足以向您展示 CNN 对于文本分类可以有多简单而强大!

我们在本节中处理的示例仅用于介绍 CNN 的基本概念,因此我们偷了很多懒。 首先,在实践中,模式和内核不仅仅是黑白的,而是包含实值数字。 应用内核到模式后的分数不是通过计算颜色匹配次数得到的,而是通过一种称为内积的数学运算得到的,它捕捉了两者之间的相似性。 第二,内核产生的分数不是通过某种任意的操作(就像我们在本节中所做的那样)组合在一起的,而通常是通过线性层(见 3.4.3 节)组合在一起的,该线性层可以学习针对输入的线性变换以产生输出。 最后,内核和最终线性层中的权重(魔法常数 w 和 b)都是 CNN 的可训练参数,这意味着它们的值会被调整,以使 CNN 能够产生期望的分数。

7.3 池化层

在前一节中,我们假设输入只是两个词——主语和动词的组合,尽管在实践中,CNN 的输入可以是任意长度的。您的 CNN 不仅需要检测模式,还需要在输入中可能存在的大量噪声中找到它们。正如我们在第 7.2 节中看到的,您将一个核从左到右滑过句子,并且核会重复应用于两个连续的单词以产生一系列分数。剩下的问题是如何处理这些产生的分数。具体来说,我们应该在图 7.11 中的“?”位置使用什么操作来获得所需的分数?这个操作需要具有一些属性——它必须是可以应用于任意数量的分数的东西,因为句子可能非常长。它还需要以一种对输入句子中目标模式(“我是”的单词嵌入)的位置不可知的方式聚合分数。您能想出答案吗?

CH07_F11_Hagiwara

图 7.11 聚合分数以获得所需分数

汇总分数的最简单方法是取它们的最大值。因为图 7.11 中的最大分数为 4,它将成为该层的输出。这种汇总操作称为池化,而执行汇总的神经网络子结构称为池化层。您还可以执行其他类型的数学运算来进行聚合,例如取平均值,尽管最常用的是取最大值(称为最大池化)。

汇总分数将被馈送到一个线性层,可选地与其他核的分数结合,并用作预测分数。整个过程如图 7.12 所示。现在我们有一个完全功能的 CNN!

与我们迄今看到的其他神经网络一样,线性层的输出被馈送到 softmax 以产生标签上的概率分布。然后将这些预测值与真实标签进行比较,以产生损失并用于优化网络。

在我们结束之前,对 CNN 还有几句话:请注意,图 7.12 中的 CNN 无论搜索模式(“我是”)在输入句子中的位置如何,都会产生相同的预测值。这是由于卷积核的局部性以及我们刚刚添加的最大池化层的属性。通常情况下,即使输入句子通过移动几个单词而被修改,CNN 也会产生相同的预测。从技术上讲,CNN 被称为变换不变,这是 CNN 的一个重要属性。如果您使用图像识别示例,则该属性可能更直观。猫的图像仍然是猫的图像,无论猫在图像中的位置如何。同样,一个语法正确的英文句子(例如,“我是学生”)仍然是语法正确的,即使句子通过在开头添加几个单词(例如,“那是对的”)而被转换为“那是对的,我是学生”。

CH07_F12_Hagiwara

图 7.12 带有多个卷积核的完整 CNN

因为 CNN 中的卷积核不相互依赖(与 RNN 不同,后续单元需要等待所有前面的单元完成输入处理),所以 CNN 计算效率高。GPU 可以并行处理这些卷积核,不用等待其他卷积核的输出。由于这个特性,CNN 通常比大小相似的 RNN 更快。

7.4 案例研究:文本分类

现在,我们已经了解了 CNN 的基础知识,在本节中,我们将使用 CNN 构建一个 NLP 应用并看看它在实践中的工作原理。正如之前提到的,CNN 在 NLP 中最受欢迎和直接的应用之一就是文本分类。CNN 擅长检测文本中的模式(如突出的单词和短语),这也是准确文本分类的关键。

7.4.1 复习:文本分类

我们已经在第二章和第四章中介绍了文本分类,但是为了回顾一下,文本分类是指一个 NLP 系统给定一段文本分配一个标签的任务。如果文本是一个电子邮件,标签是邮件是否为垃圾邮件,那就是垃圾邮件过滤。如果文本是一个文档(比如新闻文章),标签是它的主题(如政治、商业、技术或体育),那就叫做文档分类。根据输入和输出的不同,还存在许多其他变种的文本分类。但是在本节中,我们将再次处理情感分析,它的输入是一些表达作者主观意见的文本(如电影和产品评论),输出是意见的标签(如正面或负面,甚至星级评价),也被称为极性

在第二章和第四章中,我们构建了一个 NLP 系统,使用 Stanford Sentiment Treebank 检测给定电影评论的情感极性,这是一个包含电影评论及其极性(非常正面,正面,中立,负面,非常负面)的数据集。在本节中,我们将构建同样的文本分类器,但是使用 CNN 而不是 RNN。好消息是,我们可以重用第二章编写的大部分代码,在这一部分只需要修改几行代码将 RNN 替换为 CNN。这在很大程度上归功于 AllenNLP 强大而设计良好的抽象,它可以让您通过公共接口与许多具有不同架构的模块一起工作。让我们下面看看它的运行。

7.4.2 使用 CnnEncoder

记住,在第 4.4 节中,我们定义了文本分类的 LstmClassifier 如下:

class LstmClassifier(Model):
    def __init__(self,
                 embedder: TextFieldEmbedder,
                 encoder: Seq2VecEncoder,
                 vocab: Vocabulary,
                 positive_label: str = '4') -> None:
    ...

我们没有对这个定义进行深入的思考,但是从这个构造函数中,我们可以看到这个模型是建立在两个子组件之上的:一个名为embedderTextFieldEmbedder和一个名为encoderSeq2VecEncoder,除此之外还有词汇表和正标签的字符串,这些对我们的讨论不相关。我们在第三章详细讨论了词嵌入,尽管我们只是简要涉及了编码器。这个Seq2VecEncoder到底是什么意思呢?

在 AllenNLP 中,Seq2VecEncoder是一类神经网络架构,它接受一系列向量(或一般张量)并返回一个单个向量。RNN 是其中的一个例子,它接受由多个向量组成的可变长度输入,并在最后一个单元格中将其转换为单个向量。我们使用以下代码基于 LSTM-RNN 创建了一个Seq2VecEncoder的实例:

encoder = PytorchSeq2VecWrapper(
    torch.nn.LSTM(EMBEDDING_DIM, HIDDEN_DIM, batch_first=True))

但只要你的组件具有相同的输入和输出规范,你就可以使用任何神经网络架构作为Seq2VecEncoder。在编程语言中,Seq2VecEncoder类似于 Java(以及许多其他语言中的)中的接口——接口定义了你的类是什么样子的,它做什么,但它们不关心你的类是如何做到的。实际上,你的模型可以简单地对所有输入向量求和以产生输出,而不需要任何复杂的变换,比如非线性变换。事实上,这就是BagOfEmbeddingsEncoder—AllenNLP 中实现的Seq2VecEncoder之一的做法。

接下来,我们使用 CNN 将一系列向量“压缩”为单个向量。在 AllenNLP 中,基于 CNN 的Seq2VecEncoder实现为CnnEncoder,可以如下实例化:

encoder = CnnEncoder(
    embedding_dim=EMBEDDING_DIM,
    num_filters=8,
    ngram_filter_sizes=(2, 3, 4, 5))

在这个例子中,embedding_dim指定了输入嵌入的维度。第二个参数num_filters告诉我们将使用多少个过滤器(或内核,如第 7.2.1 节所解释的)。最后一个参数ngram_filter_sizes指定了 n-gram 大小的列表,即这些内核的大小。在这里,我们使用 2、3、4 和 5 的 n-gram 大小,这意味着有 8 个用于 bigram 的内核,8 个用于 trigram,以此类推,直到 5-gram。总而言之,这个 CNN 可以学习 32 个不同的内核来检测模式。CnnEncoder通过一个最大池化层运行这些内核的结果,并得出一个总结输入的单个向量。

训练流水线的其余部分几乎与我们在第二章中看到的 LSTM 版本相同。整个代码都可以在 Google Colab 上找到。www.realworldnlpbook.com/ch7.html#cnn-nb。但有一个注意事项:由于一些 n-gram 过滤器具有宽形状(例如,4-gram 和 5-gram),您需要确保每个文本字段至少具有该长度,即使原始文本很短(例如,只有一个或两个单词)。您需要了解 AllenNLP 中的批处理和填充工作原理(我们将在第十章中介绍)才能充分理解如何处理这一问题,但简而言之,您需要在初始化标记索引器时指定 token_min_padding_length 参数,如下所示:

token_indexer = SingleIdTokenIndexer(token_min_padding_length=5)
reader = StanfordSentimentTreeBankDatasetReader(
    token_indexers={'tokens': token_indexer})

7.4.3 训练和运行分类器

当您运行脚本时,您会在训练结束时看到类似以下日志输出:

{'best_epoch': 1,
 'best_validation_accuracy': 0.40236148955495005,
 'best_validation_f1_measure': 0.37362638115882874,
 'best_validation_loss': 1.346440097263881,
 'best_validation_precision': 0.4722222089767456,
 'best_validation_recall': 0.30909091234207153,
 'epoch': 10,
 'peak_cpu_memory_MB': 601.656,
 'training_accuracy': 0.993562734082397,
 'training_cpu_memory_MB': 601.656,
 'training_duration': '0:01:10.138277',
 'training_epochs': 10,
 'training_f1_measure': 0.994552493095398,
 'training_loss': 0.03471498479299275,
 'training_precision': 0.9968798756599426,
 'training_recall': 0.9922360181808472,
 'training_start_epoch': 0,
 'validation_accuracy': 0.35149863760217986,
 'validation_f1_measure': 0.376996785402298,
 'validation_loss': 3.045241366113935,
 'validation_precision': 0.3986486494541168,
 'validation_recall': 0.35757574439048767}

这意味着训练精度达到了约 99%,而验证精度则达到了约 40%。同样,这是过拟合的典型症状,即您的模型非常强大,可以很好地拟合训练数据,但不能很好地泛化到验证和测试数据集。我们的 CNN 具有许多能够记住训练数据中显著模式的过滤器,但这些模式未必有助于预测验证实例的标签。在本章中,我们不太担心过拟合。有关避免过拟合的常见技术,请参见第十章。

如果您想对新实例进行预测,可以使用与第二章相同的预测器。AllenNLP 中的预测器是您训练好的模型的一个轻量级包装器,负责将输入和输出格式化为 JSON 格式并将实例提供给模型。您可以使用以下代码段使用您训练好的 CNN 模型进行预测:

predictor = SentenceClassifierPredictor(model, dataset_reader=reader)
logits = predictor.predict('This is the best movie ever!')['logits']
label_id = np.argmax(logits)

print(model.vocab.get_token_from_index(label_id, 'labels'))

摘要

  • CNN 使用称为内核的过滤器和称为卷积的操作来检测输入中的局部语言模式。

  • 卷积层使用的激活函数称为 ReLU,它将负值截断为零。

  • CNN 然后使用池化层来聚合卷积层的结果。

  • CNN 预测是转换不变的,意味着即使对输入进行线性修改后也保持不变。

  • 您可以通过修改文本分类器的几行代码将基于 CNN 的编码器用作 AllenNLP 中的 Seq2VecEncoder。

第八章:注意力与 Transformer

本章内容包括

  • 使用注意力机制生成输入摘要,提高 Seq2Seq 模型的质量

  • 用自注意力替换 RNN 风格的循环,一种使输入摘要自身的机制

  • 用 Transformer 模型改进机器翻译系统

  • 使用 Transformer 模型和公开可用数据集构建高质量的拼写检查器

到目前为止,本书的重点一直是循环神经网络(RNNs),它是一种强大的模型,可应用于各种 NLP 任务,如情感分析、命名实体识别和机器翻译。在本章中,我们将介绍一个更加强大的模型——Transformer¹——一种基于自注意力概念的全新编码器-解码器神经网络架构。自 2017 年问世以来,它毫无疑问是最重要的 NLP 模型。它不仅是一个强大的模型(例如,用于机器翻译和各种 Seq2Seq 任务),而且还被用作许多现代 NLP 预训练模型的底层架构,包括 GPT-2(第 8.4.3 节)和 BERT(第 9.2 节)。自 2017 年以来的现代 NLP 发展最好的总结可以概括为“Transformer 时代”。

在本章中,我们首先介绍了注意力机制,这是机器翻译中取得突破的一种机制,然后介绍了自注意力,这是 Transformer 模型的基础概念。我们将构建两个 NLP 应用程序——西班牙语到英语的机器翻译器和一个高质量的拼写检查器,并学习如何将 Transformer 模型应用于您的日常应用程序。正如我们将在后面看到的那样,Transformer 模型可以在某些任务(如翻译和生成)中以接近人类水平的性能显著提高 NLP 系统的质量,而且几乎可以超越 RNNs。

8.1 什么是注意力?

在第六章中,我们介绍了 Seq2Seq 模型——一种使用编码器和解码器将一个序列转换为另一个的 NLP 模型。Seq2Seq 是一种多功能且强大的范式,尽管“简单” Seq2Seq 模型也不是没有局限性。在本节中,我们讨论了 Seq2Seq 模型的瓶颈,并激发了使用注意力机制的动机。

8.1.1 简单 Seq2Seq 模型的局限性

让我们先回顾一下 Seq2Seq 模型的工作原理。Seq2Seq 模型由编码器和解码器组成。解码器接收源语言中的一系列标记,并将其通过 RNN 运行,最终产生一个固定长度的向量。这个固定长度的向量是输入句子的表示。解码器,另一个 RNN,接收这个向量,并逐标记地产生目标语言中的一个序列。图 8.1 说明了如何使用简单的 Seq2Seq 模型将西班牙语句子翻译成英语。

CH08_F01_Hagiwara

图 8.1 简单 Seq2Seq 模型中的瓶颈

这个 Seq2Seq 架构非常简单而强大,但众所周知,它的基本版本(如图 8.1 所示)在翻译句子方面不如其他传统的机器翻译算法(如基于短语的统计机器翻译模型)。如果你仔细观察它的结构,可能就能猜出其中的原因——它的编码器试图将源句子中的所有信息“压缩”到句子表示中,这是一个固定长度的向量(例如,256 个浮点数),而解码器则试图仅从该向量中恢复整个目标句子。无论源句子有多长(或多短),向量的大小都是固定的。中间向量是一个巨大的瓶颈。如果你考虑一下人类实际上如何在语言之间进行翻译,这听起来相当困难且有些不寻常。专业的翻译人员不会一口气读完源句子然后把它的翻译写下来。他们会根据需要多次参考源句子,以翻译目标句子中的相关部分。

将所有信息压缩成一个向量可能(并且确实)对短句子有效,正如我们稍后在 8.2.2 节中将看到的那样,但随着句子变得越来越长,这种方法变得越来越困难。研究表明,基本 Seq2Seq 模型的翻译质量随着句子变得越来越长而变差。²

8.1.2 注意力机制

解码器如果能够在生成目标标记时参考编码器的某个特定部分,将会容易得多。这类似于人类翻译员(解码器)根据需要参考源句子(编码器)。

这可以通过使用 注意力 来实现,注意力是神经网络中的一种机制,它专注于输入的特定部分并计算其上下文相关的摘要。这就像拥有某种包含输入所有信息的键值存储,然后用查询(当前上下文)查找它一样。存储的值不仅仅是单个向量,而通常是一个向量列表,每个标记关联一个相应的键。这有效地增加了解码器在进行预测时可以参考的“内存”大小。

在讨论注意力机制如何在 Seq2Seq 模型中工作之前,让我们先看一下它以一般形式的运行情况。图 8.2 描绘了一个具有以下特征的通用注意力机制:

  1. 注意力机制的输入是值及其相关的键。输入值可以采用许多不同的形式,但在自然语言处理中,它们几乎总是向量列表。对于 Seq2Seq 模型,这里的键和值是编码器的隐藏状态,它们代表了输入句子的标记编码。

  2. 每个与值关联的键都使用注意力函数 f 与查询进行比较。通过将 f 应用于查询和每个键之一,您会得到一组分数,每个键值对一个,然后将其归一化以获得一组注意力权重。特定的函数 f 取决于体系结构(稍后会详细介绍)。对于 Seq2Seq 模型,这会给出一个输入令牌的分布。输入令牌越相关,其权重越大。

  3. 输入值由第 2 步中获得的相应权重加权,并相加以计算最终摘要向量。对于 Seq2Seq 模型,此摘要向量附加到解码器隐藏状态以辅助翻译过程。

CH08_F02_Hagiwara

图 8.2 使用注意力机制对输入进行总结

由于第 3 步,注意力机制的输出始终是输入向量的加权和,但它们如何加权是由注意力权重确定的,而注意力权重又是从键和查询计算得出的。换句话说,注意力机制计算的是 上下文(查询)相关的输入摘要。神经网络的下游组件(例如基于 RNN 的 Seq2Seq 模型的解码器,或者 Transformer 模型的上层)使用此摘要进一步处理输入。

在接下来的几节中,我们将学习 NLP 中两种最常用的注意力机制类型 —— 编码器-解码器注意力(也称为 交叉注意力;在基于 RNN 的 Seq2Seq 模型和 Transformer 中都使用)和自注意力(在 Transformer 中使用)。

8.2 带有注意力的序列到序列

在本节中,我们将学习如何将注意力机制应用于 RNN 基础的 Seq2Seq 模型,注意力机制是首次发明的。我们将研究它如何与具体示例一起工作,然后我们将使用 fairseq 实验带有和不带有注意力机制的 Seq2Seq 模型,以观察它对翻译质量的影响。

8.2.1 编码器-解码器注意力

正如我们之前所看到的,注意力是在特定上下文下创建输入摘要的机制。我们使用了一个键值存储和一个查询作为它如何工作的类比。让我们看看注意力机制如何在基于 RNN 的 Seq2Seq 模型中使用,使用随后的具体示例。

CH08_F03_Hagiwara

图 8.3 在基于 RNN 的 Seq2Seq 模型中添加注意力机制(浅色阴影框)

图 8.3 展示了一个带有注意力机制的 Seq2Seq 模型。一开始看起来很复杂,但实际上它只是一个基于 RNN 的 Seq2Seq 模型,在编码器顶部左上角的浅色阴影框中添加了一些额外的“东西”。如果你忽略里面的内容,将其视为黑匣子,它所做的就是简单地接受一个查询并从输入中返回某种摘要。它计算这个摘要的方式只是我们在 8.1.2 节中介绍的通用注意力形式的一个变体。它的执行步骤如下:

  1. 注意力机制的输入是编码器计算的隐藏状态列表。这些隐藏状态既用作键也用作值(即,键和值是相同的)。某个令牌(例如,“no”令牌)的编码器隐藏状态反映了关于该令牌及其之前所有令牌的信息(如果 RNN 是单向的),或整个句子(如果 RNN 是双向的)。

  2. 假设你已经解码到“Mary did.”。此时解码器的隐藏状态被用作查询,与每个键使用函数 f 进行比较。这会产生一个注意力分数列表,每个键值对应一个分数。这些分数确定了解码器在尝试生成跟在“Mary did.”后面的单词时应该关注输入的哪个部分。

  3. 这些分数被转换为概率分布(一组正值,总和为 1),用于确定哪些向量应该得到最多的关注。这个注意力机制的返回值是所有值的加权和,加权值为注意力分数经过 softmax 归一化后的值。

你可能想知道注意力函数 f 是什么样的。f 的几个变体是可能的,这取决于它如何计算键和查询之间的注意力分数,但这些细节在这里并不重要。值得注意的一点是,在提出注意力机制的原始论文中,作者使用了一个“迷你”神经网络来计算键和查询之间的注意力分数。

这个基于“迷你”网络的注意力函数不是你只需事后将其插入到 RNN 模型中就能让其正常工作的东西。它是作为整个网络的一部分进行优化的—也就是说,当整个网络通过最小化损失函数进行优化时,注意力机制也会变得更好,因为这样做也有助于解码器生成更好的翻译并降低损失函数。换句话说,整个网络,包括注意力机制,都是端到端训练的。这通常意味着,随着网络的优化,注意力机制开始学习只关注输入的相关部分,这通常是目标标记与源标记对齐的地方。换句话说,注意力计算了源标记和目标标记之间某种“软”单词对齐。

8.2.2 使用注意力构建 Seq2Seq 机器翻译

在第 6.3 节中,我们使用由 Facebook 开发的 NMT 工具包 fairseq 构建了我们的第一个机器翻译(MT)系统。 使用来自 Tatoeba 的平行数据集,我们构建了一个基于 LSTM 的 Seq2Seq 模型,将西班牙语句子翻译成英语。

在本节中,我们将尝试使用 Seq2Seq 机器翻译系统,并看看注意力如何影响翻译质量。 我们假设您已经按照我们构建 MT 系统的步骤操作,通过下载数据集并运行 fairseq-preprocess 和 fairseq-train 命令(第 6.3 节)。 之后,您运行了 fairseq-interactive 命令以将西班牙语句子交互式地翻译成英语。 您可能已经注意到,从这个仅花了您 30 分钟构建的 MT 系统得到的翻译实际上相当不错。 实际上,我们使用的模型架构(—arch lstm)默认内置了注意力机制。 请注意,当您运行以下 fairseq-train 命令时

fairseq-train \
    data/mt-bin \
    --arch lstm \
    --share-decoder-input-output-embed \
    --optimizer adam \
    --lr 1.0e-3 \
    --max-tokens 4096 \
    --save-dir data/mt-ckpt

您应该已经在终端中看到了您的模型的输出,如下所示:

...
LSTMModel(
  (encoder): LSTMEncoder(
    (embed_tokens): Embedding(16832, 512, padding_idx=1)
    (lstm): LSTM(512, 512)
  )
  (decoder): LSTMDecoder(
    (embed_tokens): Embedding(11416, 512, padding_idx=1)
    (layers): ModuleList(
      (0): LSTMCell(1024, 512)
    )
 (attention): AttentionLayer(
 (input_proj): Linear(in_features=512, out_features=512, bias=False)
 (output_proj): Linear(in_features=1024, out_features=512, bias=False)
 )
  )
)
...

这告诉您,您的模型有一个编码器和一个解码器,但解码器还有一个称为注意力的组件(类型为 AttentionLayer),如代码片段中的粗体所示。 这正是我们在第 8.2.1 节中讨论过的“小型网络”。

现在让我们训练相同的模型,但是不使用注意力机制。您可以在 fairseq-train 中添加—decoder-attention 0 来禁用注意力机制,同时保持其他所有内容不变,如下所示:

$ fairseq-train \
      data/mt-bin \
      --arch lstm \
      --decoder-attention 0 \
      --share-decoder-input-output-embed \
      --optimizer adam \
      --lr 1.0e-3 \
      --max-tokens 4096 \
      --save-dir data/mt-ckpt-no-attn

当您运行此命令时,您将看到类似的输出,接下来显示了模型的架构,但没有注意力机制:

LSTMModel(
  (encoder): LSTMEncoder(
    (embed_tokens): Embedding(16832, 512, padding_idx=1)
    (lstm): LSTM(512, 512)
  )
  (decoder): LSTMDecoder(
    (embed_tokens): Embedding(11416, 512, padding_idx=1)
    (layers): ModuleList(
      (0): LSTMCell(1024, 512)
    )
  )
)

正如我们在第 6.3.2 节中看到的,训练过程在训练和验证之间交替进行。 在训练阶段,神经网络的参数通过优化器进行优化。 在验证阶段,这些参数被固定,并且模型在称为验证集的数据集的一个保留部分上运行。 除了确保训练损失下降外,您还应该在训练过程中查看验证损失,因为它更好地表示了模型在训练数据之外的泛化能力。

在这个实验中,您应该观察到由注意力模型实现的最低验证损失约为 1.727,而无注意力模型的最低验证损失约为 2.243。 较低的损失值意味着模型更好地适应了数据集,因此这表明注意力有助于改善翻译。 让我们看看这是否属实。 正如我们在第 6.3.2 节中所做的,您可以通过运行以下 fairseq-interactive 命令来交互地生成翻译:

$ fairseq-interactive \
    data/mt-bin \
    --path data/mt-ckpt/checkpoint_best.pt \
    --beam 5 \
    --source-lang es \
    --target-lang en

在表 8.1 中,我们比较了带有和不带有注意力的模型生成的翻译。基于注意力的模型得到的翻译与我们在第 6.3.3 节中看到的一样。请注意,基于没有注意力的模型得到的翻译比具有注意力的模型要糟糕得多。如果您看一下“¿Hay habitaciones libres?”和“Maria no daba una bofetada a la bruja verde”的翻译,您会看到其中的陌生令牌“”(表示“未知”)。这里发生了什么?

表 8.1 模型带有和不带有注意力的翻译比较

西班牙语(输入) 带有注意力 没有注意力
¡Buenos días! 早上好! Good morning!
¡Hola! 你好! Hi!
¿Dónde está el baño? 厕所在哪里? Where’s the toilet?
¿Hay habitaciones libres? 有空房间吗? Are there rooms?
¿Acepta tarjeta de crédito? 你们接受信用卡吗? Do you accept credit card?
La cuenta, por favor. 请结账。 Check, please.
Maria no daba una bofetada a la bruja verde. Maria 没有打绿色女巫。 Mary wasn’t a of the pants.

这些是分配给词汇表之外(OOV)词汇的特殊令牌。我们在第 3.6.1 节中提及了 OOV 词汇(当我们介绍用于 FastText 的子词概念时)。大多数自然语言处理应用都在一个固定的词汇表中操作,每当它们遇到或尝试生成超出预定义集合的词汇时,这些词汇都会被替换为一个特殊的令牌 <unk>。这类似于当方法不知道如何处理输入时返回的特殊值(例如 Python 中的 None)。因为这些句子包含某些词汇(我怀疑它们是“libres”和“bofetada”),没有注意力的 Seq2Seq 模型,其内存是有限的,不知道该如何处理它们,简单地回归到最安全的操作,即生成一个通用的、捕获所有的符号 <unk>。另一方面,您可以看到注意力防止系统生成这些符号,并有助于提高生成的翻译的整体质量。

8.3 Transformer 和自注意力

在这一节中,我们将学习 Transformer 模型的工作原理,具体来说,是它如何利用一种称为自注意力的新机制生成高质量的翻译。自注意力机制使用每个令牌作为上下文,为每个令牌创建了整个输入的摘要。

8.3.1 自注意力

正如我们之前看到的,注意力是一种创建输入的上下文相关摘要的机制。对于基于 RNN 的 Seq2Seq 模型,输入是编码器隐藏状态,而上下文是解码器隐藏状态。Transformer 的核心思想,自注意力,也创建了输入的摘要,除了一个关键的区别——创建摘要的上下文也是输入本身。请参见图 8.4,了解自注意力机制的简化示例。

CH08_F04_Hagiwara

图 8.4 自注意力将输入转化为摘要。

为什么这很好?为什么它有效?正如我们在第四章中讨论的那样,RNN 也可以通过循环遍历输入标记并更新内部变量(隐藏状态)来创建输入的摘要。这是有效的-我们之前看到当 RNN 与注意力结合时可以生成良好的翻译,但是它们有一个关键问题:因为 RNN 按顺序处理输入,随着句子变得越来越长,处理标记之间的远程依赖关系变得越来越困难。

让我们看一个具体的例子。如果输入句子是“The Law will never be perfect, but its application should be just”,了解代词“its”指的是什么(“The Law”)对于理解句子的含义以及任何后续任务(如准确翻译句子)都很重要。然而,如果您使用 RNN 来编码这个句子,要学习这个代词的共指关系,RNN 需要先学习在隐藏状态中记住名词“The Law”,然后等待循环遇到目标代词(“its”),同时学会忽略之间的所有无关内容。对于神经网络来说,这听起来像是一种复杂的技巧。

但事情不应该那么复杂。像“its”这样的单数所有格代词通常指的是它们前面最近的单数名词,而与它们之间的词无关,因此简单的规则“用最近出现的名词替换它”就足够了。换句话说,在这种情况下,“随机访问”比“顺序访问”更适合。自注意力更擅长学习这种远程依赖关系,稍后我们将会看到。

让我们通过一个例子来了解自注意力是如何工作的。假设我们要将西班牙语翻译成英语,并且想要编码输入句子中的前几个单词“Maria no daba”。我们还将关注一个特定的标记“no”,以及如何从整个输入计算其嵌入。第一步是将目标标记与输入中的所有标记进行比较。自注意力通过使用投影 W[Q]将目标转换为查询,使用投影 W[K]将所有标记转换为键,并使用函数 f 计算注意力权重来完成这一步骤。由 f 计算得到的注意力权重通过 softmax 函数进行归一化和转换为概率分布。图 8.5 说明了这些步骤,注意力权重如何计算。与我们在 8.2.1 节中涵盖的编码器-解码器注意力机制一样,注意力权重决定了我们从输入标记中获得的值如何“混合”。对于像“its”这样的词,我们希望相关词的权重会更高,比如之前的例子中的“Law”。

CH08_F05_Hagiwara

图 8.5 从键和查询计算注意力权重

在下一步中,将每个输入令牌对应的向量通过投影 W[V] 转换为值向量。每个投影值都由相应的注意权重加权,并加总以生成摘要向量。请参见图 8.6 进行说明。

CH08_F06_Hagiwara

图 8.6 计算所有值的加权和

如果这是“常规”的编码器-解码器注意机制,那就是这样了。在解码期间,每个令牌只需要一个摘要向量。然而,编码器-解码器注意力和自注意力之间的一个关键区别是后者会针对输入中的每个令牌重复此过程。如图 8.7 所示,这会为输入产生一组新的嵌入,每个令牌一个。

CH08_F07_Hagiwara

图 8.7 为整个输入序列生成摘要(细节被省略)

自注意力产生的每个摘要都考虑了输入序列中的所有令牌,但权重不同。因此,对于诸如“its”之类的词,它可以融入一些来自相关词语的信息,例如“法律”,无论这两个词有多远。使用类比,自注意力通过对输入进行随机访问产生摘要。这与 RNN 形成对比,后者只允许对输入进行顺序访问,并且这也是 Transformer 之所以是编码和解码自然语言文本的强大模型之一的关键原因之一。

我们需要解释自注意力的最后一个细节才能完全理解它。现在,前面介绍的自注意机制只能使用输入序列的一个方面来生成摘要。例如,如果您希望自注意力学习每个代词指代哪个单词,它可以做到这一点——但您也可能希望根据其他一些语言学方面“混合”其他单词的信息。例如,您可能希望参考代词修改的其他单词(在这种情况下是“应用”)。解决方案是为每个令牌计算多组密钥、值和查询,并计算多组注意权重以“混合”关注不同输入方面的值。最终的嵌入是以这种方式生成的摘要的组合。这种机制被称为多头自注意力(图 8.8)。

CH08_F08_Hagiwara

图 8.8 多头自注意力生成具有多个密钥、值和查询的摘要。

如果你想要完全理解 Transformer 层的工作原理,你需要学习一些额外的细节,但本节已经涵盖了最重要的概念。如果你对更多细节感兴趣,请查看《图解 Transformer》jalammar.github.io/illustrated-transformer/),这是一个写得很好的指南,用易于理解的插图解释了 Transformer 模型。此外,如果你有兴趣用 Python 从零开始实现 Transformer 模型,请查看《注释版 Transformer》nlp.seas.harvard.edu/2018/04/03/attention.html)。

8.3.2 Transformer

Transformer 模型不仅仅使用单步自注意力来编码或解码自然语言文本。它重复应用自注意力到输入中,逐渐转换它们。与多层 RNN 一样,Transformer 还将一系列转换操作分组到一个层中,并重复应用它。图 8.9 显示了 Transformer 编码器的一个层。

每个层内都有很多操作,我们的目标不是解释每一个细节——你只需要理解多头自注意力是其核心,后跟通过前馈神经网络的转换(图 8.9 中的“FF”)。引入了残差连接和归一化层,以使模型更容易训练,尽管这些操作的细节超出了本书的范围。Transformer 模型反复应用这个层,将输入从文字的形式(原始词嵌入)转换为更抽象的东西(句子的“含义”)。在原始的 Transformer 论文中,Vaswani 等人用了六层进行机器翻译,尽管如今更大的模型通常使用 10-20 层。

CH08_F09_Hagiwara

图 8.9 一个具有自注意力和前馈层的 Transformer 编码器层

到这一步,你可能已经注意到自注意力操作完全独立于位置。换句话说,即使我们颠倒“Maria”和“daba”之间的单词顺序,自注意力的嵌入结果也完全相同,因为该操作只关注单词本身和来自其他单词的聚合嵌入,而不考虑它们的位置。这显然非常限制——自然语言句子的意义很大程度上取决于单词的顺序。那么,Transformer 如何编码单词顺序呢?

Transformer 模型通过生成一些人工嵌入来解决这个问题,这些嵌入在位置之间不同,并在将它们馈送到层之前添加到词嵌入中。这些嵌入被称为位置编码如图 8.10 所示,可以由某些数学函数(如正弦曲线)生成,或者在训练过程中根据位置学习。这样,Transformer 可以区分第一个位置的“Maria”和第三个位置的“Maria”,因为它们具有不同的位置编码。

CH08_F10_Hagiwara

图 8.10 将位置编码添加到输入中以表示词序

图 8.11 显示了 Transformer 解码器。虽然很多事情正在进行,但一定要注意两个重要的事情。首先,你会注意到一个额外的机制,称为交叉注意力,插入在自注意力和前馈网络之间。这个交叉注意力机制类似于我们在第 8.2 节介绍的编码器-解码器注意力机制。它的工作方式与自注意力完全相同,唯一的区别是注意力的值来自编码器,而不是解码器,总结了从编码器提取的信息。

CH08_F11_Hagiwara

图 8.11 Transformer 解码器层,具有自注意力和交叉注意力

最后,Transformer 模型以与我们之前在第 6.4 节学习的基于 RNN 的 Seq2Seq 模型完全相同的方式生成目标句子。解码器由特殊标记初始化,并生成可能的下一个标记的概率分布。从这里,你可以选择具有最大概率的标记(贪婪解码,如第 6.4.3 节所示),或者在寻找最大化总分数的路径时保留一些具有最高概率的标记(波束搜索,如第 6.4.4 节所示)。事实上,如果你把 Transformer 解码器看作一个黑匣子,它生成目标序列的方式与 RNN 完全相同,你可以使用相同的一组解码算法。换句话说,第 6.4 节介绍的解码算法是一种通用的算法,不受底层解码器架构的影响。

8.3.3 实验

现在我们知道了 Transformer 模型的工作原理,让我们用它构建一个机器翻译系统。好消息是,序列到序列工具包 Fairseq 已经支持基于 Transformer 的模型(以及其他强大的模型),可以在训练模型时通过--arch transformer选项指定。假设你已经预处理了我们用于构建西班牙语到英语机器翻译的数据集,你只需要调整给予fairseq-train的参数,如下所示:

fairseq-train \
  data/mt-bin \
  --arch transformer \
  --share-decoder-input-output-embed \
  --optimizer adam --adam-betas '(0.9, 0.98)' --clip-norm 0.0 \
  --lr 5e-4 --lr-scheduler inverse_sqrt --warmup-updates 4000 \
  --dropout 0.3 --weight-decay 0.0 \
  --criterion label_smoothed_cross_entropy --label-smoothing 0.1 \
  --max-tokens 4096 \
  --save-dir data/mt-ckpt-transformer

注意,这甚至可能在你的笔记本电脑上都无法运行。你真的需要 GPU 来训练 Transformer 模型。还要注意,即使有 GPU,训练也可能需要几个小时。更多关于使用 GPU 的信息请参见第 11.5 节。

这里出现了一些神秘的参数,但您不需要担心。当您运行此命令时,您可以看到模型结构。整个模型转储相当长,因此我们在清单 8.1 中省略了一些中间层。仔细观察,您会发现层次结构与我们之前显示的图形相对应。

清单 8.1 Fairseq 的 Transformer 模型转储

TransformerModel(
  (encoder): TransformerEncoder(
    (embed_tokens): Embedding(16832, 512, padding_idx=1)
    (embed_positions): SinusoidalPositionalEmbedding()
    (layers): ModuleList(
      (0): TransformerEncoderLayer(
        (self_attn): MultiheadAttention(                                   ❶
          (out_proj): Linear(in_features=512, out_features=512, bias=True)
        )
        (self_attn_layer_norm): LayerNorm((512,), eps=1e-05, elementwise_
         affine=True)
        (fc1): Linear(in_features=512, out_features=2048, bias=True)       ❷
        (fc2): Linear(in_features=2048, out_features=512, bias=True)
        (final_layer_norm): LayerNorm((512,), eps=1e-05, elementwise_affine=True)
      )
      ...
      (5): TransformerEncoderLayer(
        (self_attn): MultiheadAttention(
          (out_proj): Linear(in_features=512, out_features=512, bias=True)
        )
        (self_attn_layer_norm): LayerNorm((512,), eps=1e-05, elementwise_affine=True)
        (fc1): Linear(in_features=512, out_features=2048, bias=True)
        (fc2): Linear(in_features=2048, out_features=512, bias=True)
        (final_layer_norm): LayerNorm((512,), eps=1e-05, elementwise_affine=True)
      )
    )
  )
  (decoder): TransformerDecoder(
    (embed_tokens): Embedding(11416, 512, padding_idx=1)
    (embed_positions): SinusoidalPositionalEmbedding()
    (layers): ModuleList(
      (0): TransformerDecoderLayer(
        (self_attn): MultiheadAttention(                                   ❸
          (out_proj): Linear(in_features=512, out_features=512, bias=True)
        )
        (self_attn_layer_norm): LayerNorm((512,), eps=1e-05, elementwise_affine=True)
        (encoder_attn): MultiheadAttention(                                ❹
          (out_proj): Linear(in_features=512, out_features=512, bias=True)
        )
        (encoder_attn_layer_norm): LayerNorm((512,), eps=1e-05, elementwise_
         affine=True)
        (fc1): Linear(in_features=512, out_features=2048, bias=True)       ❺
        (fc2): Linear(in_features=2048, out_features=512, bias=True)
        (final_layer_norm): LayerNorm((512,), eps=1e-05, elementwise_affine=True)
      )
      ...
      (5): TransformerDecoderLayer(
        (self_attn): MultiheadAttention(
          (out_proj): Linear(in_features=512, out_features=512, bias=True)
        )
        (self_attn_layer_norm): LayerNorm((512,), eps=1e-05, elementwise_affine=True)
        (encoder_attn): MultiheadAttention(
          (out_proj): Linear(in_features=512, out_features=512, bias=True)
        )
        (encoder_attn_layer_norm): LayerNorm((512,), eps=1e-05, elementwise_
         affine=True)
        (fc1): Linear(in_features=512, out_features=2048, bias=True)
        (fc2): Linear(in_features=2048, out_features=512, bias=True)
        (final_layer_norm): LayerNorm((512,), eps=1e-05, elementwise_affine=True)
      )
    )
  )
)

❶ 编码器的自注意力

❷ 编码器的前馈网络

❸ 解码器的自注意力

❹ 解码器的编码器-解码器

❺ 解码器的前馈网络

当我运行时,验证损失在大约第 30 个时期后收敛,此时您可以停止训练。我将同一组西班牙语句子翻译成英文的结果如下:

¡ Buenos días !
S-0     ¡ Buenos días !
H-0     -0.0753164291381836     Good morning !
P-0     -0.0532 -0.0063 -0.1782 -0.0635
¡ Hola !
S-1     ¡ Hola !
H-1     -0.17134985327720642    Hi !
P-1     -0.2101 -0.2405 -0.0635
¿ Dónde está el baño ?
S-2     ¿ Dónde está el baño ?
H-2     -0.2670585513114929     Where &apos;s the toilet ?
P-2     -0.0163 -0.4116 -0.0853 -0.9763 -0.0530 -0.0598
¿ Hay habitaciones libres ?
S-3     ¿ Hay habitaciones libres ?
H-3     -0.26301929354667664    Are there any rooms available ?
P-3     -0.1617 -0.0503 -0.2078 -1.2516 -0.0567 -0.0532 -0.0598
¿ Acepta tarjeta de crédito ?
S-4     ¿ Acepta tarjeta de crédito ?
H-4     -0.06886537373065948    Do you accept credit card ?
P-4     -0.0140 -0.0560 -0.0107 -0.0224 -0.2592 -0.0606 -0.0594
La cuenta , por favor .
S-5     La cuenta , por favor .
H-5     -0.08584468066692352    The bill , please .
P-5     -0.2542 -0.0057 -0.1013 -0.0335 -0.0617 -0.0587
Maria no daba una bofetada a la bruja verde .
S-6     Maria no daba una bofetada a la bruja verde .
H-6     -0.3688890039920807     Mary didn &apos;t slapped the green witch .
P-6     -0.2005 -0.5588 -0.0487 -2.0105 -0.2672 -0.0139 -0.0099 -0.1503 -0.0602

大多数英文翻译几乎完美。令人惊讶的是,模型几乎完美地翻译了最困难的句子(“Maria no daba . . .”)。这可能足以说服我们,Transformer 是一个强大的翻译模型。在它的出现之后,这个模型成为了研究和商业机器翻译的事实标准。

8.4 基于 Transformer 的语言模型

在第 5.5 节中,我们介绍了语言模型,这是一种给文本赋予概率的统计模型。通过将文本分解为令牌序列,语言模型可以估计给定文本的“概率”。在第 5.6 节中,我们演示了通过利用这一特性,语言模型也可以用于“凭空”生成新的文本!

Transformer 是一个强大的模型,在 Seq2Seq 任务(如机器翻译)中取得了令人印象深刻的结果,尽管它的架构也可以用于语言建模和生成。在本节中,我们将学习如何使用 Transformer 进行语言建模和生成真实文本。

8.4.1 Transformer 作为语言模型

在第 5.6 节中,我们建立了基于字符 LSTM-RNN 的语言生成模型。简而言之,给定一个前缀(到目前为止生成的部分句子),模型使用基于 LSTM 的 RNN(一个带有循环的神经网络)来生成可能的下一个令牌的概率分布,如图 8.12 所示。

CH08_F12_Hagiwara

图 8.12 使用 RNN 生成文本

我们早些时候指出,通过将 Transformer 解码器视为黑匣子,您可以使用与我们之前介绍的 RNN 相同的一组解码算法(贪婪、束搜索等)。对于语言生成也是如此——通过将神经网络视为在给定前缀的情况下产生某种分数的黑匣子,您可以使用相同的逻辑生成文本,而不管底层模型如何。图 8.13 显示了类似 Transformer 的架构如何用于语言生成。除了一些细微差别(如缺乏交叉注意力)之外,结构几乎与 Transformer 解码器相同。

CH08_F13_Hagiwara

图 8.13 使用 Transformer 进行语言生成

以下片段显示了使用 Transformer 模型生成文本的类似 Python 的伪代码。在这里,model() 是主要的函数,模型计算发生在这里——它接受标记,将它们转换为嵌入,添加位置编码,并将它们传递到所有的 Transformer 层,将最终的隐藏状态返回给调用者。调用者然后将它们通过线性层传递,将它们转换为 logits,然后通过 softmax 转换为概率分布:

def generate():
    token = <START>
    tokens = [<START>]
    while token != <END>:
        hidden = model(tokens)
        probs = softmax(linear(hidden))
        token = sample(probs)
        tokens.append(token)
    return tokens

实际上,Seq2Seq 模型的解码和语言模型的语言生成是非常相似的任务,输出序列是逐标记生成的,将自身反馈给网络,就像前面的代码片段所示。唯一的区别在于,前者有某种形式的输入(源句子),而后者没有(模型自我反馈)。这两个任务也分别称为无条件生成有条件生成。图 8.14 描绘了这三个组件(网络、任务和解码)以及它们如何结合起来解决特定问题。

CH08_F14_Hagiwara

图 8.14 语言生成和 Seq2Seq 任务的三个组件

在本节的其余部分,我们将尝试使用一些基于 Transformer 的语言模型,并使用它们生成自然语言文本。我们将使用由 Hugging Face 开发的 transformers 库(huggingface.co/transformers/),这个库在过去几年已经成为了 NLP 研究人员和工程师使用 Transformer 模型的标准库。它提供了一些最先进的模型实现,包括 GPT-2(本节)和 BERT(下一章),以及预训练模型参数,您可以立即加载和使用。它还提供了一个简单、一致的接口,通过这个接口您可以与强大的 NLP 模型进行交互。

8.4.2 Transformer-XL

在许多情况下,您希望加载并使用由第三方提供的预训练模型(通常是模型的开发者),而不是从头开始训练它们。最近的 Transformer 模型相当复杂(通常具有数亿个参数),并且使用大量的数据集进行训练(数十吉字节的文本)。这将需要只有大型机构和科技巨头才能承受得起的 GPU 资源。甚至有些模型在训练时需要数天的时间,即使有十几个 GPU!好消息是,这些庞大的 Transformer 模型的实现和预训练模型参数通常由它们的创建者公开提供,以便任何人都可以将它们集成到他们的 NLP 应用程序中。

在这一部分中,我们首先将介绍 Transformer-XL,这是由 Google Brain 的研究人员开发的 Transformer 的一个变种。由于原始的 Transformer 模型中没有固有的“循环”,不像 RNNs,所以原始的 Transformer 不擅长处理超长的上下文。在用 Transformer 训练语言模型时,你首先将长文本分割成较短的块,比如 512 个单词,并将它们分别馈送到模型中。这意味着模型无法捕获超过 512 个单词的依赖关系。Transformer-XL⁴通过对原始 Transformer 模型进行一些改进来解决这个问题(“XL”表示额外长)。尽管这些改变的细节超出了本书的范围,在简单地说,该模型重复使用前一个段落的隐藏状态,有效地创建了一个在不同文本段之间传递信息的循环。它还改进了我们之前提到的位置编码方案,使得模型更容易处理更长的文本。

您只需在命令行中运行 pip install transformers 即可安装 transformers 库。您将与主要抽象进行交互的是分词器和模型。分词器将原始字符串拆分为一系列标记,而模型定义了架构并实现了主要逻辑。模型和预训练权重通常取决于特定的标记化方案,因此您需要确保您使用的分词器与模型兼容。

初始化一个分词器和一个模型,并使用一些指定的预训练权重的最简单方法是使用 AutoTokenizer 和 AutoModelWithLMHead 类,并调用它们的 from_pretrained()方法如下所示:

import torch
from transformers import AutoModelWithLMHead, AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained('transfo-xl-wt103')
model = AutoModelWithLMHead.from_pretrained('transfo-xl-wt103')

from_pre-trained()函数的参数是模型/预训练权重的名称。这是一个在名为 wt103(WikiText103)的数据集上训练的 Transformer-XL 模型。

您可能想知道 AutoModelWithLMHead 中的“LMHead”部分是什么意思。LM(语言模型)头是添加到神经网络中的特定层,它将其隐藏状态转换为一组分数,这些分数确定要生成的下一个标记。然后,这些分数(也称为 logits)被馈送到 softmax 层以获得可能的下一个标记的概率分布(图 8.15)。我们希望一个带有 LM 头的模型,因为我们有兴趣通过将 Transformer 作为语言模型来生成文本。但是,根据任务的不同,您可能还想要一个没有 LM 头的 Transformer 模型,并且只想使用其隐藏状态。这将是我们在下一章中要做的事情。

CH08_F15_Hagiwara

图 8.15 使用 Transformer 的语言模型头

下一步是初始化前缀,用于让语言模型生成故事的其余部分。可以使用 tokenizer.encode() 方法将字符串转换为标记 ID 列表,然后将其转换为张量。我们还将初始化变量 past,用于缓存内部状态并加速推理过程,如下所示:

generated = tokenizer.encode("On our way to the beach")
context = torch.tensor([generated])
past = None

现在,您已准备好生成文本的其余部分了。请注意,下面的代码与我们之前显示的伪代码相似。思路很简单:从模型获取输出,使用输出随机采样一个标记,并将其输入模型。反复这个过程。

for i in range(100):
    output = model(context, mems=past)
    token = sample_token(output.prediction_scores)

    generated.append(token.item())
    context = token.view(1, -1)
    past = output.mems

需要进行一些清理工作,以使张量的形状与模型兼容,我们暂时可以忽略此步骤。此处的 sample_token() 方法将模型的输出转换为概率分布,并从中随机采样一个标记。我没有显示该方法的完整代码,但您可以查看 Google Colab 笔记本(realworldnlpbook.com/ch8.html#xformer-nb)了解更多细节。此外,虽然我们在此处从零编写了生成算法,但如果您需要更全面的生成方式(如波束搜索),请查看该库开发者的官方示例脚本:mng.bz/wQ6q

在生成完成后,您可以通过调用 tokenizer.decode() 将标记 ID 转换为原始字符串,如下所示:

print(tokenizer.decode(generated))

运行后我得到了以下“故事”:

On our way to the beach, she finds, she finds the men who are in the group to be " in the group ". This has led to the perception that the " group " in the group is " a group of people in the group with whom we share a deep friendship, and which is a common cause to the contrary. " <eos> <eos> = = Background = = <eos> <eos> The origins of the concept of " group " were in early colonial years with the English Civil War. The term was coined by English abolitionist John

这不是一个坏的开始。我喜欢这个故事试图通过坚持“群体”概念来保持一致性的方式。然而,由于该模型仅训练于维基百科文本,其生成的结果并不真实,看起来有点过于正式。

8.4.3 GPT-2

GPT-2(代表生成预训练)是由 OpenAI 开发的迄今为止最著名的语言模型。你可能听说过关于一种语言模型生成如此真实无缝的自然语言文本,以至于你无法分辨其与人类写作的文本。从技术上讲,GPT-2 只是一个庞大的 Transformer 模型,就像我们之前介绍的那个一样。主要区别在于其规模(最大模型有 48 层!)以及该模型是通过从网络上收集到的大量自然语言文本进行训练的。OpenAI 团队公开发布了实现和预训练权重,因此我们可以轻松尝试这个模型。

初始化标记器和 GPT-2 模型,方法与 Transformer-XL 相同,如下所示:

tokenizer = AutoTokenizer.from_pretrained('gpt2-large')
model = AutoModelWithLMHead.from_pretrained('gpt2-large')

然后使用以下代码片段生成文本:

generated = tokenizer.encode("On our way to the beach")
context = torch.tensor([generated])
past = None

for i in range(100):
    output = model(context, past_key_values=past)
    token = sample_token(output.logits)

    generated.append(token.item())
    context = token.unsqueeze(0)
    past = output.past_key_values

print(tokenizer.decode(generated))

你可能已经注意到这段代码与 Transformer-XL 的代码几乎没有变化。在许多情况下,当切换不同的模型时,您不需要进行任何修改。这就是为什么 transformers 库如此强大的原因 - 您可以尝试并集成各种最先进的基于 Transformer 的模型到您的应用程序中,只需使用一个简单且一致的界面。正如我们将在下一章中看到的那样,这个库还集成到 AllenNLP 中,这使得使用最先进的模型构建强大的自然语言处理应用程序变得容易。

当我尝试这个代码时,GPT-2 生成了以下精美的段落:

On our way to the beach, there was a small island that we visited for the first time. The island was called 'A' and it is a place that was used by the French military during the Napoleonic wars and it is located in the south-central area of the island.

A is an island of only a few hundred meters wide and has no other features to distinguish its nature. On the island there were numerous small beaches on which we could walk. The beach of 'A' was located in the...

注意它和自然的阅读感。此外,GPT-2 模型擅长保持一致性-您可以看到“A”这个岛的名字在整个段落中始终使用。就我所知,世界上没有一个真正名为 A 的岛屿,这意味着这是模型简单地编造的。这是一个伟大的成就,模型记住了它刚刚创造的名字,并成功围绕它写了一个故事!

下面是 GPT-2 根据提示生成的另一段话:'Real World Natural Language Processing'是这本书的名字:

'Real World Natural Language Processing' is the name of the book. It has all the tools you need to write and program natural language processing programs on your computer. It is an ideal introductory resource for anyone wanting to learn more about natural language processing. You can buy it as a paperback (US$12), as a PDF (US$15) or as an e-book (US$9.99).

The author's blog has more information and reviews.

The free 'Real World Natural Language Processing' ebook has all the necessary tools to get started with natural language processing. It includes a number of exercises to help you get your feet wet with writing and programming your own natural language processing programs, and it includes a few example programs. The book's author, Michael Karp has also written an online course about Natural Language Processing.

'Real World Natural Language Processing: Practical Applications' is a free e-book that explains how to use natural language processing to solve problems of everyday life (such as writing an email, creating and

到 2019 年 2 月,当 GPT-2 发布时,我几乎刚开始写这本书,所以我怀疑 GPT-2 对此一无所知。对于一个没有关于这本书的任何先验知识的语言模型来说,这是一项惊人的工作,尽管我必须指出它价格和作者的错误。

8.4.4 XLM

最后,作为一个有趣的例子,我们将尝试多语言语言生成。XLM(跨语言语言模型)是由 Facebook AI Research 的研究人员提出的基于 Transformer 的跨语言语言模型,可以生成和编码多种语言的文本。通过学习如何编码多语言文本,模型可以用于不同语言之间的迁移学习。我们将在第九章介绍迁移学习。

您可以通过以下方式初始化分词器和模型,并使用预训练权重进行初始化:

tokenizer = AutoTokenizer.from_pretrained('xlm-clm-enfr-1024')
model = AutoModelWithLMHead.from_pretrained('xlm-clm-enfr-1024')

在这里,我们加载一个使用英语和法语(enfr)进行训练的 XLM 模型(xlm),使用因果语言模型(CLM)目标(clm)进行训练。CLM 只是以更高级的方式描述我们在本章中所做的内容-根据前缀预测下一个标记。XLM 通常用于对多语言文本进行编码,用于一些下游任务,如文本分类和机器翻译,但我们只是将其用作生成文本的语言模型。有关使用 XLM 生成多语言文本的代码片段,请参见清单 8.2。您可以再次重用大部分之前的代码片段,尽管您还需要指定您正在使用的语言(请注意 lang = 0 行)。此外,在这里,我们通过仅提供 BOS 标记(其索引为零)从头开始生成文本。

清单 8.2 生成多语言文本与 XLM

generated = [0] # start with just <s>
context = torch.tensor([generated])
lang = 0 # English

for i in range(100):
    langs = torch.zeros_like(context).fill_(lang)
    output = model(context, langs=langs)
    token = sample_token(output)

    generated.append(token.item())
    context = torch.tensor([generated])

print(tokenizer.decode(generated))

运行这个代码后,我得到了以下结果:

<s>and its ability to make decisions on the basis of its own. " </s>The government has taken no decisions on that matter, " Mr Hockey said. </s>A lot of the information is very sensitive. </s>The new research and information on the Australian economy, which is what we're going to get from people, and the information that we are going to be looking at, we're going to be able to provide and we 'll take it forward. </s>I'm not trying to make sure we're not

然后,让我们将语言更改为 1(表示法语),并再次运行相同的代码片段,这将给出下一段文本:

<s></s>En revanche, les prix des maisons individuelles n' ont guère augmenté ( - 0,1 % ). </s>En mars dernier, le taux de la taxe foncière, en légère augmentation à la hausse par rapport à février 2008\. </s>" Je n' ai jamais eu une augmentation " précise ". </s>" Je me suis toujours dit que ce n' était pas parce que c' était une blague. </s>En effet, j' étais un gars de la rue " </s>Les jeunes sont des gens qui avaient beaucoup d' humour... "

尽管这种生成质量不如我们之前实验的 GPT-2 那么好,但是看到一种单一模型可以同时生成英语和法语的文本非常令人耳目一新。如今,构建基于 Transformer 的多语言 NLP 模型以解决多种语言的 NLP 问题和任务越来越普遍。这也得益于 Transformer 对语言复杂性建模的强大能力。

8.5 案例研究:拼写检查器

在本章的最后一节中,我们将使用 Transformer 构建一个实用的 NLP 应用——拼写检查器。在现代世界中,拼写检查器无处不在。你的 Web 浏览器可能装备有一个拼写检查器,它会在拼写错误的单词下划线提示你。许多字处理器和编辑器也默认运行拼写检查器。一些应用程序(包括 Google Docs 和 Microsoft Word)甚至指出简单的语法错误。你是否想知道它们是如何工作的?我们将学习如何将其作为 NLP 问题进行规划、准备数据集、训练和改进模型。

8.5.1 拼写纠正作为机器翻译

拼写检查器接收这样一个文本:“tisimptant too spll chck ths dcment”,检测任何拼写和语法错误,并修复所有错误:“It's important to spell-check this document.” 如何使用自然语言处理技术解决这个任务?这些系统如何实现?

最简单的方法是将输入文本分词为单词,并检查每个单词是否在字典中。如果不在,你可以查找距离最近的有效单词并替换它。可以使用一些度量(如编辑距离)来计算距离。重复这个过程,直到没有需要更正的单词。这种逐个单词修正的算法被许多拼写检查器广泛使用,因为它很简单。

然而,这种类型的拼写检查器有几个问题。首先,就像示例中的第一个单词“tisimptant”一样,您如何知道句子的哪一部分实际上是一个单词?我副本中默认的微软 Word 拼写检查器指出它是“disputant”的拼写错误,尽管对于任何英语使用者来说,它实际上是两个(或更多)单词的拼写错误是显而易见的。用户还可能拼写标点符号(包括空格),这使得一切都变得复杂。其次,仅仅因为某个单词在词典中存在,并不意味着它就没有错误。例如,示例中的第二个单词“too”是“to”的拼写错误,但两者都是任何英语词典中都有的有效单词。您如何判断前者在这种情况下是错误的呢?第三,所有这些决定都是在没有上下文的情况下做出的。我尝试过的一个拼写检查器在这个例子中显示“thus”是替换“ths”的候选词之一。然而,从这个上下文(名词之前)来看,“this”是一个更合适的候选词是显而易见的,尽管“this”和“thus”都与“ths”相隔一个编辑距离,这意味着根据编辑距离来看,它们都是同样有效的选项。

通过添加一些启发式规则,您可以解决其中一些问题。例如,“too”更有可能是动词之前“to”的拼写错误,“this”更有可能出现在名词之前而不是“thus”。但这种方法显然不具备可扩展性。还记得第 1.1.2 节中可怜的初级开发者吗?语言广阔而充满异常。您不能仅仅通过编写这些简单单词的规则来处理语言的全部复杂性。即使您能够为这些简单单词编写规则,您又如何知道“tisimptant”实际上是两个单词呢?您会尝试在每个可能的位置拆分这个单词,看拆分后的单词是否与现有单词相似吗?如果输入的是一种没有空格的语言,比如中文和日语,会怎么样呢?

此时,您可能意识到这种“拆分和修复”的方法行不通。一般来说,在设计自然语言处理应用程序时,您应该从以下三个方面考虑:

  • 任务—正在解决什么任务?是分类、序列标注还是序列到序列问题?

  • 模型—您将使用什么模型?是前馈网络、循环神经网络还是 Transformer?

  • 数据集—您从哪里获取数据集来训练和验证您的模型?

根据我的经验,如今绝大多数自然语言处理应用程序都可以通过结合这些方面来解决。拼写检查器呢?因为它们以一段文本作为输入,并生成修复后的字符串,如果我们将其作为一个 Seq2Seq 任务使用 Transformer 模型来解决将会最直接。换句话说,我们将建立一个机器翻译系统,将带有拼写/语法错误的嘈杂输入转换为干净、无误的输出,如图 8.16 所示。您可以将这两个方面看作是两种不同的“语言”(或英语的“方言”)。

CH08_F16_Hagiwara

图 8.16 将拼写检查器训练为将“嘈杂”的句子翻译成“干净”的句子的 MT 系统

此时,您可能会想知道我们从哪里获取数据集。这通常是解决现实世界自然语言处理问题中最重要(也是最困难)的部分。幸运的是,我们可以使用公共数据集来完成这项任务。让我们深入研究并开始构建一个拼写检查器。

8.5.2 训练拼写检查器

我们将使用 GitHub Typo Corpus(github.com/mhagiwara/github-typo-corpus)作为训练拼写检查器的数据集。这个数据集是由我和我的合作者创建的,其中包含数十万个从 GitHub 自动收集的“打字错误”编辑。这是迄今为止最大的拼写错误及其校正数据集,这使得它成为训练拼写检查器的理想选择。

在准备数据集和训练模型之前,我们需要做出一个决定,那就是选择模型操作的原子语言单位。许多自然语言处理模型使用令牌作为最小单位(即,RNN/Transformer 被馈送一个令牌序列),但越来越多的自然语言处理模型使用单词或句子片段作为基本单位(第 10.4 节)。对于拼写校正,我们应该使用什么作为最小的单位?与许多其他自然语言处理模型一样,起初使用单词作为输入听起来像是一个很好的“默认”选择。然而,正如我们之前所看到的,令牌的概念并不适用于拼写校正——用户可能会弄乱标点符号,如果您正在处理令牌,这会使一切过于复杂。更重要的是,因为自然语言处理模型需要操作一个固定的词汇表,所以拼写校正器的词汇表需要包含训练期间遇到的每个单词的每个拼写错误。这将使得训练和维护这样一个自然语言处理模型变得不必要昂贵。

由于这些原因,我们将使用字符作为拼写检查器的基本单位,就像在第 5.6 节中一样。使用字符有几个优点——它可以保持词汇表的大小相当小(通常对于具有小字母表集的语言,如英语,不到一百个)。即使是充满打字错误的嘈杂数据集,您也不必担心膨胀您的词汇表,因为打字错误只是字符的不同排列。您还可以将标点符号(甚至空白符)视为词汇表中的字符之一。这使得预处理步骤非常简单,因为您不需要任何语言工具包(如标记器)来执行此操作。

注意:使用字符并非没有缺点。其中一个主要问题是使用它们会增加序列的长度,因为你需要将所有内容分解为字符。这使得模型变得庞大且训练速度变慢。

首先,让我们为训练拼写检查器准备数据集。构建拼写检查器所需的所有必要数据和代码都包含在此代码库中:github.com/mhagiwara/xfspell。经过分词和拆分的数据集位于 data/gtc 目录下(如 train.tok.fr、train.tok.en、dev.tok.fr、dev.tok.en)。后缀 en 和 fr 是机器翻译中常用的约定,其中“fr”表示“外语”,“en”表示英语,因为许多机器翻译研究项目最初是由希望将某种外语翻译为英语的人发起的。这里,我们将“fr”和“en”仅仅解释为“拼写纠错前的嘈杂文本”和“拼写纠错后的纠正文本”。

图 8.17 显示了根据 GitHub Typo Corpus 创建的拼写纠错数据集的摘录。请注意,文本被分割成单个字符,甚至包括空格(由“_”替换)。所有不在通用字母表(大写字母、小写字母、数字和一些常见标点符号)内的字符都被替换为“#”。您可以看到数据集包含各种纠正,包括简单的拼写错误(pubilc->public 在第 670 行,HYML->HTML 在第 672 行),更复杂的错误(mxnet 一词替换成 mxnet is not 在第 681 行,22th->22nd 在第 682 行),甚至不带任何更正的行(第 676 行)。这看起来是训练拼写检查器的一个好资源。

CH08_F17_Hagiwara

图 8.17 拼写纠错的训练数据

训练拼写检查器(或任何其他 Seq2Seq 模型)的第一步是对数据集进行预处理。因为数据集已经分割和格式化,你只需要运行 fairseq-preprocess 将数据集转换为二进制格式,操作如下:

fairseq-preprocess --source-lang fr --target-lang en \
    --trainpref data/gtc/train.tok \
    --validpref data/gtc/dev.tok \
    --destdir bin/gtc

然后,您可以使用以下代码立即开始训练模型。

列表 8.3 训练拼写检查器

fairseq-train \
    bin/gtc \
    --fp16 \
 --arch transformer \
 --encoder-layers 6 --decoder-layers 6 \
 --encoder-embed-dim 1024 --decoder-embed-dim 1024 \
 --encoder-ffn-embed-dim 4096 --decoder-ffn-embed-dim 4096 \
 --encoder-attention-heads 16 --decoder-attention-heads 16 \
    --share-decoder-input-output-embed \
    --optimizer adam --adam-betas '(0.9, 0.997)' --adam-eps 1e-09 --clip-norm 25.0 \
    --lr 1e-4 --lr-scheduler inverse_sqrt --warmup-updates 16000 \
    --dropout 0.1 --attention-dropout 0.1 --activation-dropout 0.1 \
    --weight-decay 0.00025 \
    --criterion label_smoothed_cross_entropy --label-smoothing 0.2 \
 --max-tokens 4096 \
    --save-dir models/gtc01 \
    --max-epoch 40

您不需要担心这里的大多数超参数——这组参数对我来说效果还不错,尽管可能还有其他参数组合效果更好。但是,您可能想注意一些与模型大小相关的参数,即:

  • 层数(-[encoder|decoder]-layers)

  • 自注意力的嵌入维度(-[encoder|decoder]-embed-dim)

  • 前馈层的嵌入维度(-[encoder/decoder]-ffn-embed-dim)

  • 注意力头数(-[encoder|decoder]-attention-heads)

这些参数决定了模型的容量。一般来说,这些参数越大,模型的容量就越大,尽管作为结果,模型也需要更多的数据、时间和 GPU 资源来进行训练。另一个重要的参数是—max-token,它指定加载到单个批次中的标记数。如果在 GPU 上遇到内存不足错误,请尝试调整此参数。

训练完成后,您可以运行以下命令使用训练好的模型进行预测:

echo "tisimptant too spll chck ths dcment." \
    | python src/tokenize.py \
    | fairseq-interactive bin/gtc \
    --path models/gtc01/checkpoint_best.pt \
    --source-lang fr --target-lang en --beam 10 \
    | python src/format_fairseq_output.py

因为 fairseq-interactive 界面也可以从标准输入接收源文本,所以我们直接使用 echo 命令提供文本。Python 脚本 src/format_fairseq_output.py,顾名思义,格式化来自 fairseq-interactive 的输出,并显示预测的目标文本。当我运行这个脚本时,我得到了以下结果:

tisimplement too spll chck ths dcment.

这相当令人失望。拼写检查器学会了如何将“imptant”修正为“implement”,尽管它未能纠正任何其他单词。我怀疑有几个原因。使用的训练数据,GitHub Typo Corpus,严重偏向于软件相关的语言和纠正,这可能导致了错误的更正(imptant -> implement)。此外,训练数据可能对于 Transformer 来说太小了。我们如何改进模型,使其能够更准确地纠正拼写错误呢?

8.5.3 改进拼写检查器

正如我们之前讨论的,拼写检查器不如预期工作的一个主要原因可能是因为模型在训练过程中没有暴露给更多种类、更大数量的拼写错误。但据我所知,没有这样的大型数据集公开可用于训练一个通用领域的拼写检查器。我们如何获取更多的数据来训练一个更好的拼写检查器呢?

这就是我们需要有创造性的地方。一个想法是从干净的文本中人工生成带有噪音的文本。如果你想一想,这是非常困难的(尤其对于一个机器学习模型)来纠正拼写错误,但很容易“破坏”干净的文本,以模拟人们如何打字错误,即使对于计算机也是如此。例如,我们可以从一些干净的文本(例如,几乎无限的从网页抓取的文本)中随机替换一些字母。如果你将以这种方式创建的人工生成的带噪音的文本与原始的干净文本配对,这将有效地创建一个新的、更大的数据集,你可以在其上训练一个更好的拼写检查器!

我们需要解决的剩下的问题是如何“破坏”干净的文本以生成看起来像人类所做的真实拼写错误。你可以编写一个 Python 脚本,例如,随机替换、删除和/或交换字母,虽然不能保证以这种方式生成的拼写错误与人类所做的拼写错误相似,也不能保证生成的人工数据集能为 Transformer 模型提供有用的见解。我们如何建模这样一个事实,例如,人们更有可能在“too”的地方输入“to”,而不是“two”呢?

这又开始听起来熟悉了。我们可以使用数据来模拟打字错误!但是如何做呢?这就是我们需要再次发挥创造力的地方——如果你“翻转”我们用来训练拼写检查器的原始数据集的方向,你可以观察到人们是如何打字错误的。如果你把干净的文本视为源语言,把嘈杂的文本视为目标语言,并为该方向训练一个 Seq2Seq 模型,那么你实际上是在训练一个“拼写损坏器”—一个将看起来很真实的拼写错误插入干净文本的 Seq2Seq 模型。请参见图 8.18 进行说明。

CH08_F18_Hagiwara

图 8.18 使用回译生成人工噪声数据

在机器学习文献中,使用原始训练数据的“反向”来从目标语言中的真实语料库中人工生成大量源语言数据的技术被称为 回译。这是一种提高机器翻译系统质量的流行技术。正如我们接下来将展示的,它也可以有效地提高拼写检查器的质量。

通过交换源语言和目标语言,您可以轻松训练一个拼写损坏器。您可以在运行 fairseq-preprocess 时将“en”(干净文本)作为源语言提供,将“fr”(嘈杂文本)作为目标语言,如下所示:

fairseq-preprocess --source-lang en --target-lang fr \
    --trainpref data/gtc/train.tok \
    --validpref data/gtc/dev.tok \
    --destdir bin/gtc-en2fr

我们不再详细介绍训练过程——你可以使用几乎相同的 fairseq-train 命令启动训练。只是不要忘记为 —save-dir 指定一个不同的目录。在训练结束后,您可以检查拼写损坏器是否确实能按预期损坏输入文本:

$ echo 'The quick brown fox jumps over the lazy dog.' | python src/tokenize.py \ 
    | fairseq-interactive \
    bin/gtc-en2fr \
    --path models/gtc-en2fr/checkpoint_best.pt \
    --source-lang en --target-lang fr \
 --beam 1 --sampling --sampling-topk 10 \
    | python src/format_fairseq_output.py
The quink brown fox jumps ove-rthe lazy dog.

注意我之前添加的额外选项,以粗体显示。这意味着 fairseq-interactive 命令使用采样(从概率最大的前 10 个标记中采样)而不是束搜索。当损坏干净文本时,通常最好使用采样而不是束搜索。简而言之,采样根据 softmax 层后的概率分布随机选择下一个标记,而束搜索则试图找到最大化输出序列分数的“最佳路径”。虽然束搜索在翻译某些文本时可以找到更好的解决方案,但在通过回译增加数据时,我们希望得到更嘈杂、更多样化的输出。过去的研究⁶也表明,采样(而不是束搜索)对通过回译增加数据效果更好。

从这里开始,一切皆有可能。你可以收集尽可能多的干净文本,使用刚刚训练的损坏程序生成嘈杂文本,并增加训练数据的大小。并不能保证人工错误看起来像人类所做的真实错误一样,但这并不重要,因为 1)源(嘈杂)侧仅用于编码,2)目标(干净)侧数据始终是由人类编写的“真实”数据,从中 Transformer 可以学习如何生成真实文本。你收集的文本数据越多,模型对无错误的真实文本的信心就越大。

我不会详细介绍我为增加数据量所采取的每一步,但这里是我所做的事情以及你也可以做的事情的总结。从公开可用的数据集(如 Tatoeba 和维基百科的转储)中收集尽可能多的干净和多样化的文本数据是一个方法。我最喜欢的方法是使用 OpenWebTextCorpus(skylion007.github.io/OpenWebTextCorpus/),这是一个开源项目,用于复制最初用于 GPT-2 训练的数据集。它由从 Reddit 的所有外部链接爬取的大量(40 GB)高质量网页文本组成。因为整个数据集的预处理和运行损坏程序可能需要几天甚至几周的时间,你可以取一个子集(比如说,1/1000),然后将其添加到数据集中。我取了数据集的 1/100 子集,对其进行了预处理,并运行了损坏程序,以获得嘈杂干净的平行数据集。这 1/100 子集单独就添加了五百多万对(相比之下,原始训练集仅包含约 240k 对)。你可以下载预训练权重并尝试存储库中的拼写检查器,而不是从头开始训练。

训练花了几天时间,甚至在多个 GPU 上,但当完成时,结果非常令人鼓舞。它不仅可以准确地修复拼写错误,如下所示

$ echo "tisimptant too spll chck ths dcment." \
    | python src/tokenize.py \
    | fairseq-interactive \
    bin/gtc-bt512-owt1k-upper \
    --path models/bt05/checkpoint_best.pt \
    --source-lang fr --target-lang en --beam 10 \
    | python src/format_fairseq_output.py
    It's important to spell check this document.

而且拼写检查器似乎也在某种程度上理解英语的语法,如下所示:

$ echo "The book wer about NLP." |
    | python src/tokenize.py \
    | fairseq-interactive \
   ...
The book was about NLP.

$ echo "The books wer about NLP." |
    | python src/tokenize.py \
    | fairseq-interactive \
   ...
The books were about NLP.

这个例子本身可能不能证明模型真正理解语法(即根据主语的数量使用正确的动词)。它可能只是学习了一些连续单词之间的关联,这可以通过任何统计 NLP 模型(如 n-gram 语言模型)实现。然而,即使在你让句子更加复杂之后,拼写检查器也显示出了惊人的弹性,如下一个代码片段所示:

$ echo "The book Tom and Jerry put on the yellow desk yesterday wer about NLP." |
    | python src/tokenize.py \
    | fairseq-interactive \
   ...
The book Tom and Jerry put on the yellow desk yesterday was about NLP.

$ echo "The books Tom and Jerry put on the yellow desk yesterday wer about NLP." |
    | python src/tokenize.py \
    | fairseq-interactive \
   ...
The books Tom and Jerry put on the yellow desk yesterday were about NLP.

从这些例子中,可以清楚地看出模型学会了如何忽略不相关的名词短语(例如“Tom and Jerry”和“yellow desk”),并专注于决定动词形式(“was”与“were”)的名词(“book(s)”)。我们更有信心它理解了基本的句子结构。我们所做的一切只是收集了大量干净的文本,并在其上训练了 Transformer 模型,结合了原始的训练数据和损坏器。希望通过这些实验,你能感受到 Transformer 模型的强大之处!

摘要

  • 注意力机制是神经网络中的一种机制,它专注于输入的特定部分,并计算其上下文相关的摘要。它类似于“软”版本的键-值存储。

  • 可以将编码器-解码器注意力机制添加到 Seq2Seq 模型中,以提高其翻译质量。

  • 自注意力是一种注意力机制,通过总结自身来产生输入的摘要。

  • Transformer 模型反复应用自注意力机制,逐渐转换输入。

  • 可以使用 Transformer 和一种称为回译的技术来构建高质量的拼写检查器。

^(1.)Vaswani 等人,“注意力机制就是一切”,(2017)。arxiv.org/abs/1706.03762

^(2.)Bahdanau 等人,“通过共同学习对齐和翻译进行神经机器翻译”,(2014)。arxiv.org/abs/1409.0473

^(3.)Bahdanau 等人,“通过共同学习对齐和翻译进行神经机器翻译”,(2014)。arxiv.org/abs/1409.0473

^(4.)Dai 等人,“Transformer-XL:超越固定长度上下文的注意力语言模型”,(2019)。arxiv.org/abs/1901.02860

^(5.)Lample 和 Conneau,“跨语言语言模型预训练”,(2019)。arxiv.org/abs/1901 .07291

^(6.)Edunov 等人,“大规模理解回译”,(2018)。arxiv.org/abs/1808.09381

第九章:使用预训练语言模型进行迁移学习

本章内容包括

  • 利用无标签文本数据的知识进行迁移学习

  • 使用自监督学习对大型语言模型进行预训练,如 BERT

  • 使用 BERT 和 Hugging Face Transformers 库构建情感分析器

  • 使用 BERT 和 AllenNLP 构建自然语言推断模型

2018 年通常被称为自然语言处理历史上的“拐点”。一位著名的 NLP 研究者,Sebastian Ruder(ruder.io/nlp-imagenet/)将这一变化称为“NLP 的 ImageNet 时刻”,他使用了一个流行的计算机视觉数据集的名称以及在其上进行预训练的强大模型,指出 NLP 社区正在进行类似的变革。强大的预训练语言模型,如 ELMo、BERT 和 GPT-2,在许多 NLP 任务上实现了最先进的性能,并在几个月内彻底改变了我们构建 NLP 模型的方式。

这些强大的预训练语言模型背后的一个重要概念是迁移学习,一种利用在另一个任务上训练的模型来改善一个任务性能的技术。在本章中,我们首先介绍这个概念,然后介绍 BERT,这是为 NLP 提出的最流行的预训练语言模型。我们将介绍 BERT 的设计和预训练,以及如何将该模型用于下游 NLP 任务,包括情感分析和自然语言推断。我们还将涉及其他流行的预训练模型,包括 ELMo 和 RoBERTa。

9.1 迁移学习

我们从介绍迁移学习开始这一章,这是本章中许多预训练语言模型(PLM)的基本机器学习概念。

9.1.1 传统机器学习

在传统机器学习中,在预训练语言模型出现之前,NLP 模型是根据任务进行训练的,它们仅对它们所训练的任务类型有用(图 9.1)。例如,如果你想要一个情感分析模型,你需要使用带有所需输出的数据集(例如,负面、中性和正面标签),而训练好的模型仅对情感分析有用。如果你需要构建另一个用于词性标注(一种 NLP 任务,用于识别单词的词性;请参阅第 5.2 节进行回顾)的模型,你需要通过收集训练数据并从头开始训练一个词性标注模型来完成。无论你的模型有多好,你都不能将情感分析模型“重用”于词性标注,因为这两者是为两个根本不同的任务而训练的。然而,这些任务都在同一语言上操作,这一切似乎都是浪费的。例如,知道 “wonderful”,“awesome” 和 “great” 都是具有积极意义的形容词,这将有助于情感分析和词性标注。在传统机器学习范式下,我们不仅需要准备足够大的训练数据来向模型教授这种“常识”,而且个别 NLP 模型还需要从给定的数据中学习关于语言的这些事实。

CH09_F01_Hagiwara

图 9.1 在传统机器学习中,每个训练好的模型仅用于一个任务。

9.1.2 词嵌入

在这一点上,你可能会意识到这听起来有些眼熟。回想一下我们在第 3.1 节关于词嵌入以及它们为什么重要的讨论。简而言之,词嵌入是单词的向量表示,这些向量是通过学习得到的,以便语义上相似的单词具有相似的表示。因此,例如,“dog” 和 “cat” 的向量最终会位于高维空间中的接近位置。这些表示是在一个独立的大型文本语料库上进行训练的,没有任何训练信号,使用诸如 Skip-gram 和 CBOW 等算法,通常统称为 Word2vec(第 3.4 节)。

在这些词嵌入训练之后,下游 NLP 任务可以将它们作为模型的输入(通常是神经网络,但不一定)。因为这些嵌入已经捕捉到单词之间的语义关系(例如,dogs 和 cats 都是动物),所以这些任务不再需要从头学习语言是如何工作的,这使它们在试图解决的任务中占据了上风。模型现在可以专注于学习无法被词嵌入捕捉到的更高级别概念(例如,短语、句法和语义)以及从给定的注释数据中学到的任务特定模式。这就是为什么使用词嵌入会给许多 NLP 模型带来性能提升的原因。

在第三章中,我们将这比作是教一个婴儿(= 一个自然语言处理模型)如何跳舞。通过让婴儿先学会稳步行走(= 训练词嵌入),舞蹈老师(= 任务特定数据集和训练目标)可以专注于教授具体的舞步,而不必担心婴儿是否能够站立和行走。这种“分阶段训练”方法使得如果你想教婴儿另一种技能(例如,教授武术),一切都变得更容易,因为他们已经对基本技能(行走)有了很好的掌握。

所有这一切的美妙之处在于,词嵌入可以独立于下游任务进行学习。这些词嵌入是预训练的,这意味着它们的训练发生在下游自然语言处理任务的训练之前。使用跳舞婴儿的类比,舞蹈老师可以安全地假设所有即将到来的舞蹈学生都已经学会了如何正确站立和行走。由算法开发者创建的预训练词嵌入通常是免费提供的,任何人都可以下载并将其集成到他们的自然语言处理应用程序中。这个过程在图 9.2 中有所说明。

CH09_F02_Hagiwara

图 9.2 利用词嵌入有助于构建更好的自然语言处理模型。

9.1.3 什么是迁移学习?

如果你总结一下之前对词嵌入所做的事情,你会发现你将一个任务的结果(即,用嵌入预测词共现)并将从中获得的知识转移到另一个任务(即,情感分析,或任何其他自然语言处理任务)。在机器学习中,这个过程被称为迁移学习,这是一系列相关的技术,用于通过在不同任务上训练的数据和/或模型来提高机器学习模型在某一任务中的性能。迁移学习总是由两个或多个步骤组成—首先为一个任务训练一个机器学习模型(称为预训练),然后调整并在另一个任务中使用它(称为适应)。如果同一个模型用于两个任务,第二步称为微调,因为你稍微调整了同一个模型,但是用于不同的任务。请参见图 9.3,以了解自然语言处理中迁移学习的示意图。

CH09_F03_Hagiwara

图 9.3 利用迁移学习有助于构建更好的自然语言处理模型。

过去几年中,迁移学习已成为构建高质量自然语言处理模型的主要方法,原因有两个。首先,由于强大的神经网络模型如 Transformer 和自监督学习(见第 9.2.2 节),几乎可以从几乎无限量的自然语言文本中引导出高质量的嵌入。这些嵌入在很大程度上考虑了自然语言文本的结构、上下文和语义。其次,由于迁移学习,任何人都可以将这些强大的预训练语言模型整合到他们的自然语言处理应用程序中,即使没有访问大量的文本资源,如网络规模语料库,或计算资源,如强大的 GPU。这些新技术的出现(Transformer、自监督学习、预训练语言模型和迁移学习)将自然语言处理领域推向了一个全新的阶段,并将许多自然语言处理任务的性能推向了接近人类水平。在接下来的子节中,我们将看到迁移学习在实际构建自然语言处理模型时的应用,同时利用诸如 BERT 等预训练语言模型。

请注意,所谓的领域自适应概念与迁移学习密切相关。领域自适应是一种技术,你在一个领域(例如,新闻)训练一个机器学习模型,然后将其调整到另一个领域(例如,社交媒体),但这些领域属于相同任务(例如,文本分类)。另一方面,在本章中涵盖的迁移学习应用于不同任务(例如,语言建模与文本分类)。你可以利用本章介绍的迁移学习范式来实现相同的效果,我们不会专门涵盖领域自适应作为一个单独的主题。有兴趣的读者可以从最近的一篇评论性文章中了解更多关于领域自适应的信息。¹

9.2 BERT

在本节中,我们将详细介绍 BERT。BERT(双向编码器表示转换器)²是迄今为止最流行和最具影响力的预训练语言模型,彻底改变了人们训练和构建自然语言处理模型的方式。我们将首先介绍上下文化嵌入及其重要性,然后讨论自监督学习,这是预训练语言模型中的一个重要概念。我们将涵盖 BERT 用于预训练的两个自监督任务,即,掩码语言模型和下一个句子预测,并介绍如何将 BERT 调整到你的应用程序中。

9.2.1 词嵌入的局限性

单词嵌入是一个强大的概念,可以提高应用程序的性能,尽管它们也有限制。一个明显的问题是它们无法考虑上下文。在自然语言中看到的单词通常是多义的,意味着它们可能根据上下文有多个含义。然而,由于单词嵌入是按标记类型训练的,所有不同的含义都被压缩成一个单一的向量。例如,为“dog”或“apple”训练一个单一的向量无法处理“热狗”或“大苹果”分别不是动物或水果这一事实。再举一个例子,考虑这些句子中“play”的含义:“They played games,” “I play Chopin,” “We play baseball,” 和 “Hamlet is a play by Shakespeare”(这些句子都来自 Tatoeba.org)。这些“play”的出现有不同的含义,分配一个单一的向量在下游的 NLP 任务中并不会有太大帮助(例如在将主题分类为体育、音乐和艺术方面)。

由于这个限制,自然语言处理(NLP)研究人员开始探索将整个句子转换为一系列考虑上下文的向量的方法,称为上下文化嵌入或简称为上下文化。有了这些表示,前面示例中“play”的所有出现将被分配不同的向量,帮助下游任务区分单词的不同用法。上下文化嵌入的重要里程碑包括 CoVe³ 和 ELMo(第 9.3.1 节),尽管最大的突破是由 BERT 实现的,这是一个基于 Transformer 的预训练语言模型,是本节的重点。

我们学习到 Transformer 使用一种称为自注意力的机制逐渐转换输入序列来总结它。BERT 的核心思想很简单:它使用 Transformer(准确地说是 Transformer 编码器)将输入转换为上下文化嵌入。Transformer 通过一系列层逐渐摘要输入。同样,BERT 通过一系列 Transformer 编码器层对输入进行上下文化处理。这在图 9.4 中有所说明。

CH09_F04_Hagiwara

图 9.4 BERT 通过注意力层处理输入以生成上下文化嵌入。

因为 BERT 基于 Transformer 架构,它继承了 Transformer 的所有优点。其自注意力机制使其能够在输入上进行“随机访问”,并捕获输入标记之间的长期依赖关系。与传统的语言模型(例如我们在第 5.5 节中介绍的基于 LSTM 的语言模型)不同,后者只能沿着一个方向进行预测,Transformer 可以在两个方向上考虑上下文。以“哈姆雷特是莎士比亚的一部戏剧”为例,对于“戏剧”这个词的上下文化嵌入可以包含来自“哈姆雷特”和“莎士比亚”的信息,这样就更容易捕捉到“戏剧舞台作品”的意思。

如果这个概念就像“BERT 只是一个 Transformer 编码器”那么简单,为什么它在这里值得有一个完整的章节呢?因为我们还没有回答两个重要的实际问题:如何训练和调整模型。神经网络模型,无论多么强大,如果没有特定的训练策略和获取训练数据的途径,都是无用的。此外,预训练模型没有特定的调整策略也是无用的。我们将在以下小节中讨论这些问题。

9.2.2 自监督学习

Transformer 最初是为了机器翻译而提出的,它是使用平行文本进行训练的。它的编码器和解码器被优化以最小化损失函数,即解码器输出和预期正确翻译之间的差异所定义的交叉熵。然而,预训练 BERT 的目的是得到高质量的上下文嵌入,而 BERT 只有一个编码器。我们如何“训练”BERT 以使其对下游自然语言处理任务有用呢?

如果你把 BERT 只看作是另一种得到嵌入的方式,你可以从词嵌入是如何训练的中得到灵感。回想一下,在第 3.4 节中,为了训练词嵌入,我们构造了一个“假”任务,即用词嵌入预测周围的单词。我们对预测本身不感兴趣,而是对训练的“副产品”感兴趣,即作为模型参数的词嵌入。这种数据本身提供训练信号的训练范式称为自监督学习,或者简称为自监督,在现代机器学习中。从模型的角度来看,自监督学习仍然是监督学习的一种类型——模型被训练以使得它最小化由训练信号定义的损失函数。不同之处在于训练信号的来源。在监督学习中,训练信号通常来自人类注释。在自监督学习中,训练信号来自数据本身,没有人类干预。

在过去的几年中,随着数据集越来越大和模型越来越强大,自监督学习已经成为预训练 NLP 模型的流行方式。但是为什么它能够如此成功呢?其中两个因素起到了作用——一个是这里的自监督类型在创建时非常简单(只需提取周围单词用于 Word2vec),但是解决它需要对语言有深入的理解。例如,重新使用我们在第五章中讨论的语言模型的例子,要回答“我的海滩之行被糟糕的 ___ 毁了”,系统不仅需要理解句子,还需要具备某种“常识”,了解什么样的事情可能会毁了一次海滩之行(例如,糟糕的天气,交通拥堵)。预测周围单词所需的知识范围从简单的搭配/联想(例如,“纽约的 ____ 雕像”),到句法和语法(例如,“我的生日是 ___ 五月”),再到语义(前面的例子)。第二个因素是几乎没有限制用于自监督的数据量,因为你所需要的只是干净的纯文本。你可以下载大型数据集(例如,维基百科转储)或爬取和过滤网页,这是训练许多预训练语言模型的流行方式之一。

9.2.3 BERT 预训练

现在我们都明白了自监督学习对于预训练语言模型有多么有用,让我们看看我们如何将其用于预训练 BERT。如前所述,BERT 只是一个将输入转换为考虑上下文的一系列嵌入的 Transformer 编码器。对于预训练词嵌入,你可以根据目标词的嵌入预测周围的单词。对于预训练单向语言模型,你可以根据目标之前的标记预测下一个标记。但是对于诸如 BERT 这样的双向语言模型,你不能使用这些策略,因为用于预测的输入(上下文化的嵌入)还取决于输入之前和之后的内容。这听起来像是一个先有鸡还是先有蛋的问题。

BERT 的发明者们通过一个称为掩码语言模型(MLM)的精妙思想来解决这个问题,在给定的句子中随机删除(掩盖)单词,并让模型预测被删除的单词是什么。具体来说,在句子中用一个特殊的占位符替换一小部分单词后,BERT 使用 Transformer 对输入进行编码,然后使用前馈层和 softmax 层推导出可能填充该空白的单词的概率分布。因为你已经知道答案(因为你首先删除了这些单词),所以你可以使用常规的交叉熵来训练模型,如图 9.5 所示。

CH09_F05_Hagiwara

图 9.5 使用掩码语言模型对 BERT 进行预训练

掩码和预测单词并不是一个完全新的想法——它与填空测试密切相关,测试者被要求在句子中替换被移除的单词。这种测试形式经常用于评估学生对语言的理解程度。正如我们之前所见,填写自然语言文本中的缺失单词需要对语言的深入理解,从简单的关联到语义关系。因此,通过告诉模型解决这种填空类型的任务,涵盖了大量文本数据,神经网络模型经过训练,使其能够产生融合了深层语言知识的上下文嵌入。

如果你想自己实现 BERT 的预训练,你可能会想知道这个输入[MASK]是什么,以及你实际上需要做什么。在训练神经网络时,人们经常使用特殊的标记,比如我们在这里提到的[MASK]。这些特殊的标记就像其他(自然出现的)标记一样,比如“狗”和“猫”的单词,只是它们在文本中不会自然出现(无论你多么努力,都找不到任何[MASK]在自然语言语料库中),神经网络的设计者定义了它们的含义。模型将学会为这些标记提供表示,以便它可以解决手头的任务。其他特殊标记包括 BOS(句子的开始)、EOS(句子的结束)和 UNK(未知单词),我们在之前的章节中已经遇到过。

最后,BERT 不仅使用掩码语言模型进行预训练,还使用了另一种类型的任务,称为下一句预测(NSP),其中向 BERT 提供了两个句子,并要求模型预测第二个句子是否是第一个句子的“真正”下一个句子。这是另一种类型的自监督学习(“伪造”任务),其训练数据可以在很少的人工干预下无限制地创建,因为你可以从任何语料库中提取两个连续的句子(或仅随机拼接两个句子)并为此任务创建训练数据。这个任务背后的原理是通过训练这个目标,模型将学会如何推断两个句子之间的关系。然而,这个任务的有效性一直在积极地讨论中(例如,RoBERTa 放弃了这个任务,而 ALBERT 将其替换为另一个称为句子顺序预测的任务),我们将不在这里详细讨论这个任务。

所有这些预训练听起来有些复杂,但好消息是你很少需要自己实现这一步。类似于词嵌入,这些语言模型的开发人员和研究人员在大量自然语言文本上预训练他们的模型(通常是 10 GB 或更多,甚至是 100 GB 或更多的未压缩文本),并使用许多 GPU,并且将预训练模型公开可用,以便任何人都可以使用它们。

9.2.4 调整 BERT

在迁移学习的第二(也是最后)阶段,预训练模型被调整以适应目标任务,使后者可以利用前者学到的信号。有两种主要方式可以使 BERT 适应个别下游任务:微调特征提取。在微调中,神经网络架构稍微修改,以便为所讨论的任务产生类型的预测,并且整个网络在任务的训练数据上持续训练,以使损失函数最小化。这正是你训练 NLP 任务的神经网络的方式,例如情感分析,其中有一个重要的区别—BERT“继承”了通过预训练学到的模型权重,而不是从头开始随机初始化并进行训练。通过这种方式,下游任务可以利用 BERT 通过大量数据预训练学到的强大表示。

BERT 架构修改的确切方式因最终任务而异,但在这里我将描述最简单的情况,即任务是对给定句子预测某种标签。这也被称为 句子预测任务,其中包括我们在第二章中介绍的情感分析。为了使下游任务能够提取句子的表示,BERT 在预训练阶段为每个句子添加一个特殊标记[CLS](用于 分类)。您可以使用此标记提取 BERT 的隐藏状态,并将其用作句子的表示。与其他分类任务一样,线性层可以将此表示压缩为一组“分数”,这些分数对应于每个标签是正确答案的可能性。然后,您可以使用 softmax 推导出一个概率分布。例如,如果您正在处理一个情感分析数据集,其中有五个标签(非常负面到非常正面),则您将使用线性层将维度降低到 5。这种线性层与 softmax 结合起来,插入到诸如 BERT 之类的较大的预训练模型中,通常被称为 头部。换句话说,我们正在将一个 分类头 附加到 BERT 上,以解决句子预测任务。整个网络的权重(头部和 BERT)都会被调整,以使损失函数最小化。这意味着通过反向传播微调 BERT 权重初始化的权重也会被调整。见图 9.6 以示例说明。

CH09_F06_Hagiwara

图 9.6 使用附加的分类头对 BERT 进行预训练和微调

另一种微调 BERT 的变体使用了所有嵌入,这些嵌入是在输入令牌上进行平均的。在这种称为 mean over timebag of embeddings 的方法中,BERT 生成的所有嵌入被求和并除以输入的长度,就像词袋模型一样,以产生一个单一的向量。这种方法不如使用 CLS 特殊令牌那么受欢迎,但根据任务的不同可能效果更好。图 9.7 阐明了这一点。

CH09_F07_Hagiwara

图 9.7 预训练和微调 BERT 使用时间平均和分类头

另一种用于下游 NLP 任务的 BERT 适应方式是 feature extraction。在这里,BERT 被用来提取特征,这些特征只是由 BERT 的最终层产生的一系列上下文化嵌入。你可以将这些向量简单地作为特征馈送到另一个机器学习模型中,并进行预测,如图 9.8 所示。

CH09_F08_Hagiwara

图 9.8 预训练和使用 BERT 进行特征提取

从图形上看,这种方法与微调类似。毕竟,你正在将 BERT 的输出馈送到另一个 ML 模型中。然而,存在两个微妙但重要的区别:首先,因为你不再优化神经网络,第二个 ML 模型不必是神经网络。一些机器学习任务(例如,无监督聚类)不是神经网络擅长解决的,特征提取在这些情况下提供了完美的解决方案。此外,你可以自由使用更“传统”的 ML 算法,如 SVM(支持向量机)、决策树和梯度提升方法(如 GBDT 或梯度提升决策树),这些算法可能在计算成本和性能方面提供更好的折衷方案。其次,因为 BERT 仅用作特征提取器,在适应阶段不会进行反向传播,其内部参数也不会更新。在许多情况下,如果微调 BERT 参数,你可以在下游任务中获得更高的准确性,因为这样做也会教导 BERT 更好地解决手头的任务。

最后,请注意这两种方式不是适应 BERT 的唯一方式。迁移学习是一个正在积极研究的主题,不仅在自然语言处理领域,而且在人工智能的许多领域都是如此,我们有许多其他方法来使用预训练的语言模型以发挥其最佳作用。如果你对此感兴趣,我建议查看在 NAACL 2019(顶级自然语言处理会议之一)上给出的教程,标题为“自然语言处理中的迁移学习”(mng.bz/o8qp)。

9.3 案例研究 1:使用 BERT 进行情感分析

在本节中,我们将再次构建情感分析器,但这次我们将使用 BERT,而不是 AllenNLP,我们将使用由 Hugging Face 开发的 Transformers 库,在上一章中使用该库进行语言模型预测。这里的所有代码都可以在 Google Colab 笔记本上访问(www.realworldnlpbook.com/ch9.html#sst)。你在本节中看到的代码片段都假定你按照以下方式导入了相关的模块、类和方法:

import torch
from torch import nn, optim
from transformers import AutoTokenizer, AutoModel, AdamW, get_cosine_schedule_with_warmup

在 Transformers 库中,你可以通过他们的名称指定预训练模型。在本节中,我们将一直使用大写的 BERT-base 模型 ('bert-base-cased'),因此让我们首先定义一个常量,如下所示:

BERT_MODEL = 'bert-base-cased'

Transformers 库还支持其他预训练的 BERT 模型,你可以在他们的文档(huggingface.co/transformers/pretrained_models.html)中看到。如果你想使用其他模型,你可以简单地将这个变量替换为你想要使用的模型名称,代码的其余部分在许多情况下都可以原封不动地工作(但并非总是如此)。

9.3.1 将输入划分为单词

我们构建 NLP 模型的第一步是构建一个数据集读取器。虽然 AllenNLP(或更确切地说,allennlp-modules 包)附带了一个用于 Stanford 情感树库的数据集读取器,但是该数据集读取器的输出仅与 AllenNLP 兼容。在本节中,我们将编写一个简单的方法来读取数据集并返回一系列批量输入实例。

在处理自然语言输入时,分词是最重要的步骤之一。正如我们在上一章中看到的那样,Transformers 库中的分词器可以通过 AutoTokenizer.from_pretrained() 类方法进行初始化,如下所示:

tokenizer = AutoTokenizer.from_pretrained(BERT_MODEL)

因为不同的预训练模型使用不同的分词器,所以重要的是要通过提供相同的模型名称来初始化与你将要使用的预训练模型匹配的分词器。

你可以使用分词器在字符串和令牌 ID 的列表之间进行转换,如下所示:

>>> token_ids = tokenizer.encode('The best movie ever!')

[101, 1109, 1436, 2523, 1518, 106, 102]

>>> tokenizer.decode(token_ids)

'[CLS] The best movie ever! [SEP]'

注意 BERT 的分词器在你的句子中添加了两个特殊的标记——[CLS] 和 [SEP]。正如之前讨论的那样,CLS 是一个特殊的标记,用于提取整个输入的嵌入,而 SEP 用于分隔两个句子,如果你的任务涉及对一对句子进行预测。因为我们在这里对单个句子进行预测,所以不需要过多关注这个标记。我们将在第 9.5 节讨论句子对分类任务。

深度神经网络很少处理单个实例。它们通常通过训练并为实例的批次进行预测以保持稳定性和性能。分词器还支持通过调用 call 方法(即,只需将对象用作方法)将给定输入转换为批次,如下所示:

>>> result = tokenizer(
>>>    ['The best movie ever!', 'Aweful movie'],
>>>    max_length=10,
>>>    pad_to_max_length=True,
>>>    truncation=True,
>>>    return_tensors='pt')

运行此代码时,输入列表中的每个字符串都将被标记化,然后生成的张量将用 0 进行填充,以使它们具有相同的长度。这里的填充意味着在每个序列的末尾添加 0,以便单个实例具有相同的长度并可以捆绑为单个张量,这对于更有效的计算是必需的(我们将在第十章中更详细地讨论填充)。方法调用包含几个其他参数,用于控制最大长度(max_length=10,表示将所有内容填充到长度为 10),是否填充到最大长度,是否截断过长的序列以及返回张量的类型(return_tensors='pt',表示它返回 PyTorch 张量)。此 tokenizer()调用的结果是一个包含以下三个键和三种不同类型的打包张量的字典:

>>> result['input_ids']

tensor([[ 101, 1109, 1436, 2523, 1518,  106,  102,    0,    0,    0],
        [ 101,  138, 7921, 2365, 2523,  102,    0,    0,    0,    0]])

>>> result['token_type_ids']

tensor([[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]])

>>> result['attention_mask']

tensor([[1, 1, 1, 1, 1, 1, 1, 0, 0, 0],
        [1, 1, 1, 1, 1, 1, 0, 0, 0, 0]])

input_ids 张量是从文本转换而来的标记 ID 的打包版本。请注意,每行都是一个矢量化的标记 ID,用 0 进行了填充,以便其长度始终为 10。token_type_ids 张量指定每个标记来自哪个句子。与之前的 SEP 特殊标记一样,只有在处理句对时才相关,因此张量只是简单地填满了 0。attention_mask 张量指定 Transformer 应该关注哪些标记。由于在输入 _ids 中存在填充元素(填充为 0),因此 attention_mask 中的相应元素都为 0,并且对这些标记的关注将被简单地忽略。掩码是神经网络中经常使用的一种常见技术,通常用于忽略类似于这里所示的批量张量中的不相关元素。第十章将更详细地介绍掩码。

正如您在这里看到的,Transformers 库的标记器不仅仅是标记化 - 它们为您创建了一个字符串列表,并为您创建了批量张量,包括辅助张量(token_type_ids 和 attention_mask)。您只需从数据集创建字符串列表,并将它们传递给 tokenizer()以创建传递给模型的批次。这种读取数据集的逻辑相当乏味且有点冗长,因此我将其打包在一个名为 read_dataset 的方法中,这里没有显示。如果您感兴趣,可以检查之前提到的 Google Colab 笔记本。使用此方法,您可以读取数据集并将其转换为批次列表,如下所示:

train_data = read_dataset('train.txt', batch_size=32, tokenizer=tokenizer, max_length=128)
dev_data = read_dataset('dev.txt', batch_size=32, tokenizer=tokenizer, max_length=128)

9.3.2 构建模型

在下一步中,我们将构建模型,将文本分类到它们的情感标签中。我们在这里构建的模型只是 BERT 的一个薄包装器。它所做的就是将输入通过 BERT 传递,取出其在 CLS 处的嵌入,将其传递到线性层以转换为一组分数(logits),并计算损失。

请注意,我们正在构建一个 PyTorch 模块,而不是 AllenNLP 模型,因此请确保从 nn.Module 继承,尽管这两种类型的模型的结构通常非常相似(因为 AllenNLP 的模型从 PyTorch 模块继承)。您需要实现 init(),在其中定义和初始化模型的子模块,以及 forward(),其中进行主要计算(“前向传递”)。下面显示了整个代码片段。

列表 9.1 使用 BERT 的情感分析模型

class BertClassifier(nn.Module):
    def __init__(self, model_name, num_labels):
        super(BertClassifier, self).__init__()
        self.bert_model = AutoModel.from_pretrained(model_name)                ❶

        self.linear = nn.Linear(self.bert_model.config.hidden_size, num_labels)❷

        self.loss_function = nn.CrossEntropyLoss()

    def forward(self, input_ids, attention_mask, token_type_ids, label=None):
        bert_out = self.bert_model(                                            ❸
          input_ids=input_ids,
          attention_mask=attention_mask,
          token_type_ids=token_type_ids)

        logits = self.linear(bert_out.pooler_output)                           ❹

        loss = None
        if label is not None:
            loss = self.loss_function(logits, label)                           ❺

        return loss, logits

❶ 初始化 BERT

❷ 定义一个线性层

❸ 应用 BERT

❹ 应用线性层

❺ 计算损失

该模块首先在 init()中定义 BERT 模型(通过 AutoModel.from_pretrained()类方法)、一个线性层(nn.Linear)和损失函数(nn.CrossEntropyLoss)。请注意,模块无法知道它需要分类到的标签数量,因此我们将其作为参数传递(num_labels)。

在 forward()方法中,它首先调用 BERT 模型。您可以简单地将三种类型的张量(input_ids、attention_mask 和 token_type_ids)传递给模型。模型返回一个包含 last_hidden_state 和 pooler_output 等内容的数据结构,其中 last_hidden_state 是最后一层的隐藏状态序列,而 pooler_output 是一个汇总输出,基本上是经过线性层转换的 CLS 的嵌入。因为我们只关心代表整个输入的池化输出,所以我们将后者传递给线性层。最后,该方法计算损失(如果提供了标签)并返回它,以及 logits,用于进行预测和衡量准确性。

注意我们设计方法签名的方式——它接受我们之前检查的三个张量,使用它们的确切名称。这样我们可以简单地解构一个批次并将其传递给 forward 方法,如下所示:

>>> model(**train_data[0])

(tensor(1.8050, grad_fn=<NllLossBackward>),
 tensor([[-0.5088,  0.0806, -0.2924, -0.6536, -0.2627],
         [-0.3816,  0.3512, -0.1223, -0.5136, -0.4421],
         ...
         [-0.4220,  0.3026, -0.1723, -0.4913, -0.4106],
         [-0.3354,  0.3871, -0.0787, -0.4673, -0.4169]],
        grad_fn=<AddmmBackward>))

注意,forward 传递的返回值是损失和 logits 的元组。现在您已经准备好训练您的模型了!

9.3.3 训练模型

在这个案例研究的第三和最后一步中,我们将训练和验证模型。尽管在前几章中,AllenNLP 已经处理了训练过程,但在本节中,我们将从头开始编写自己的训练循环,以便更好地理解自己训练模型所需的工作量。请注意,您也可以选择使用库自己的 Trainer 类(huggingface.co/transformers/main_classes/trainer.html),该类的工作方式类似于 AllenNLP 的 Trainer,通过指定其参数来运行训练循环。

我们在第 2.5 节中介绍了训练循环的基础知识,但是为了回顾一下,现代机器学习中,每个训练循环看起来都有些相似。如果您以伪代码的形式编写它,它将类似于下面显示的内容。

列表 9.2 神经网络训练循环的伪代码

MAX_EPOCHS = 100
model = Model()

for epoch in range(MAX_EPOCHS):
    for batch in train_set:
        loss, prediction = model.forward(**batch)
        new_model = optimizer(model, loss)
        model = new_model

这个训练循环几乎与清单 2.2 相同,只是它操作的是批次而不是单个实例。数据集产生一系列批次,然后传递给模型的前向方法。该方法返回损失,然后用于优化模型。通常模型还会返回预测结果,以便调用者可以使用结果计算一些指标,如准确率。

在我们继续编写自己的训练循环之前,我们需要注意两件事——在每个 epoch 中交替进行训练和验证是习惯的。在训练阶段,模型被优化(“魔法常数”被改变)基于损失函数和优化器。在这个阶段使用训练数据。在验证阶段,模型的参数是固定的,并且它的预测准确率是根据验证数据进行测量的。虽然在验证期间不使用损失进行优化,但通常会计算它以监视损失在训练过程中的变化,就像我们在第 6.3 节中所做的那样。

另一个需要注意的是,当训练诸如 BERT 之类的 Transformer 模型时,我们通常使用 热身,即在前几千个步骤中逐渐增加学习率(改变“魔法常数”)。这里的步骤只是反向传播的另一个名称,对应于清单 9.2 中的内部循环。这对于稳定训练是有用的。我们不会在这里讨论热身和控制学习率的数学细节——我们只是指出通常会使用学习率调度器来控制整个训练过程中的学习率。使用 Transformers 库,你可以定义一个优化器(AdamW)和一个学习率控制器如下:

optimizer = AdamW(model.parameters(), lr=1e-5)
scheduler = get_cosine_schedule_with_warmup(
    optimizer,
    num_warmup_steps=100,
    num_training_steps=1000)

我们在这里使用的控制器(get_cosine_schedule_with_warmup)将学习率从零增加到最大值,在前 100 个步骤内,然后逐渐降低(基于余弦函数,这就是它得名的原因)。如果你绘制学习率随时间变化的图表,它会像图 9.9 中的图表一样。

CH09_F09_Hagiwara

图 9.9 使用余弦学习率调度和热身,学习率首先上升,然后按照余弦函数下降。

现在我们准备训练我们基于 BERT 的情感分析器。接下来的清单展示了我们的训练循环。

清单 9.3 基于 BERT 的情感分析器的训练循环

for epoch in range(epochs):
    print(f'epoch = {epoch}')

    model.train()                                    ❶

    losses = []
    total_instances = 0
    correct_instances = 0
    for batch in train_data:
        batch_size = batch['input_ids'].size(0)
        move_to(batch, device)                       ❷

        optimizer.zero_grad()                        ❸

        loss, logits = model(**batch)                ❹
        loss.backward()                              ❺
        optimizer.step()
        scheduler.step()

        losses.append(loss)

        total_instances += batch_size
        correct_instances += torch.sum(torch.argmax(logits, dim=-1)    == batch['label']).item()                    ❻

    avr_loss = sum(losses) / len(losses)
    accuracy = correct_instances / total_instances
    print(f'train loss = {avr_loss}, accuracy = {accuracy}')

❶ 打开训练模式

❷ 将批次移到 GPU(如果可用)

❸ 记得重置梯度(在 PyTorch 中梯度会累积)。

❹ 前向传播

❺ 反向传播

❻ 通过计算正确实例的数量来计算准确率。

当你使用 PyTorch 训练模型时(因此,也是使用 AllenNLP 和 Transformers 两个构建在其上的库),请记得调用 model.train()以打开模型的“训练模式”。这很重要,因为一些层(如 BatchNorm 和 dropout)在训练和评估之间的行为不同(我们将在第十章中涵盖 dropout)。另一方面,在验证或测试模型时,请务必调用 model.eval()。

列表 9.3 中的代码没有显示验证阶段,但验证的代码几乎与训练的代码相同。在验证/测试模型时,请注意以下事项:

  • 如前所述,请确保在验证/测试模型之前调用 model.eval()。

  • 优化调用(loss.backward(),optimizer.step()和 scheduler.step())是不必要的,因为您没有更新模型。

  • 损失仍然被记录和报告以进行监视。确保将您的前向传递调用包装在 torch.no_grad()中——这将禁用梯度计算并节省内存。

  • 精度的计算方式完全相同(这是验证的重点!)。

当我运行这个时,我得到了以下输出到标准输出(省略了中间时期):

epoch = 0
train loss = 1.5403757095336914, accuracy = 0.31624531835205993
dev loss = 1.7507736682891846, accuracy = 0.2652134423251589
epoch = 1
...
epoch = 8
train loss = 0.4508829712867737, accuracy = 0.8470271535580525
dev loss = 1.687158465385437, accuracy = 0.48319709355131696
epoch = 9
...

开发精度在第 8 个时期达到了约 0.483 的峰值,此后没有改善。与我们从 LSTM(开发精度约为 0.35,在第二章中)和 CNN(开发精度约为 0.40,在第七章中)得到的结果相比,这是我们在此数据集上取得的最佳结果。我们做了很少的超参数调整,所以现在就得出 BERT 是我们比较的三个模型中最好的模型的结论还为时过早,但至少我们知道它是一个强大的基准模型!

9.4 其他预训练语言模型

BERT 既不是目前 NLP 社区中常用的预训练语言模型(PLMs)中的第一个,也不是最后一个。在本节中,我们将学习其他几个流行的 PLMs 以及它们与 BERT 的区别。这些模型中的大多数已经在 Transformers 库中实现并公开可用,因此您只需更改代码中的几行即可将它们与您的 NLP 应用集成。

9.4.1 ELMo

ELMo(来自语言模型的嵌入),于 2018 年初提出⁴,是最早用于从未标记文本中获取上下文嵌入的预训练语言模型之一。其核心思想很简单——训练一个基于 LSTM 的语言模型(类似于我们在第五章中训练的模型),并使用其隐藏状态作为下游自然语言处理任务的额外“特征”。因为语言模型被训练为在给定前文的情况下预测下一个标记,所以隐藏状态可以编码“理解语言”所需的信息。ELMo 还使用另一个反向 LM 执行相同的操作,并结合来自两个方向的嵌入,以便它还可以编码双向信息。请参见图 9.10 进行说明。

CH09_F10_Hagiwara

图 9.10 ELMo 通过组合前向和后向 LSTM 计算上下文嵌入。

在两个方向上对 LM 进行预训练之后,下游 NLP 任务可以简单地使用 ELMo 嵌入作为特征。请注意,ELMo 使用多层 LSTM,因此特征是从不同层中取出的隐藏状态的总和,以任务特定的方式加权。ELMo 的发明者们表明,添加这些特征可以提高各种 NLP 任务的性能,包括情感分析、命名实体识别和问答。虽然 ELMo 没有在 Hugging Face 的 Transformers 库中实现,但你可以在 AllenNLP 中相当容易地使用它。

ELMo 是一个历史上重要的 PLM,尽管它今天在研究或生产中不再经常使用——它早于 BERT(和 Transformer 的出现),而且有其他 PLM(包括 BERT)在今天广泛可用并且性能优于 ELMo。

9.4.2 XLNet

2019 年提出的 XLNet 是 BERT 的重要后继者,通常被引用为当今最强大的 PLM 之一。XLNet 解决了 BERT 训练中的两个主要问题:训练-测试偏差和掩码的独立性。第一个问题与 BERT 如何使用掩码语言模型(MLM)目标进行预训练有关。在训练时,BERT 被训练以便能够准确预测掩码标记,而在预测时,它只看到输入句子,其中不包含任何掩码。这意味着 BERT 在训练和测试之间暴露给的信息存在差异,从而产生了训练-测试偏差问题。

第二个问题与 BERT 如何对掩码标记进行预测有关。如果输入中有多个[MASK]标记,BERT 会同时对它们进行预测。乍一看,这种方法似乎没有任何问题——例如,如果输入是“The Statue of [MASK] in New [MASK]”,模型不会有困难地回答“Liberty”和“York”。如果输入是“The Statue of [MASK] in Washington, [MASK]”,大多数人(也可能是语言模型)会预测“Lincoln”和“DC”。但是,如果输入是以下内容:

[MASK]雕像位于[MASK][MASK]中

然后没有信息偏向于你的预测。BERT 不会从这个例子的训练中学习到“华盛顿特区的自由女神像”或“纽约的林肯像”这样的事实,因为这些预测都是并行进行的。这是一个很好的例子,表明你不能简单地对标记进行独立的预测,然后将它们组合起来创建一个有意义的句子。

注意 这个问题与自然语言的多模态性有关,这意味着联合概率分布中存在多种模式,并且独立做出的最佳决策的组合并不一定导致全局最佳决策。多模态性是自然语言生成中的一个重大挑战。

为了解决这个问题,你可以将预测顺序改为顺序预测,而不是并行预测。事实上,这正是典型语言模型所做的——逐个从左到右生成标记。然而,在这里,我们有一个插入了屏蔽标记的句子,并且预测不仅依赖于左边的标记(例如,前面示例中的“雕像的”),还依赖于右边的标记(“in”)。XLNet 通过以随机顺序生成缺失的标记来解决这个问题,如图 9.11 所示。例如,您可以选择首先生成“New”,这为下一个单词“York”和“Liberty”提供了强有力的线索,依此类推。请注意,预测仍然基于先前生成的所有标记。如果模型选择首先生成“Washington”,那么模型将继续生成“DC”和“Lincoln”,而不会混淆这两个标记。

CH09_F11_Hagiwara

图 9.11 XLNet 以任意顺序生成标记。

XLNet 已经在 Transformers 库中实现,您只需更改几行代码即可使用该模型。

9.4.3 RoBERTa

RoBERTa(来自“robustly optimized BERT”)是另一个在研究和工业中常用的重要 PLM。RoBERTa 重新审视并修改了 BERT 的许多训练决策,使其达到甚至超过后 BERT PLMs 的性能,包括我们之前介绍的 XLNet。截至本文撰写时(2020 年中期),我个人的印象是,RoBERTa 在 BERT 之后是被引用最多的第二个 PLM,并且在许多英文下游 NLP 任务中表现出稳健的性能。

RoBERTa 在 BERT 的基础上进行了几项改进,但最重要(也是最直接)的是其训练数据量。RoBERTa 的开发者收集了五个不同大小和领域的英文语料库,总计超过 160 GB 的文本(而训练 BERT 仅使用了 16 GB)。仅仅通过使用更多的数据进行训练,RoBERTa 在微调后的下游任务中超越了一些其他强大的 PLMs,包括 XLNet。第二个修改涉及我们在第 9.2.3 节中提到的下一句预测(NSP)目标,在该目标中,BERT 被预先训练以分类第二个句子是否是跟随语料库中第一个句子的“真实”句子。RoBERTa 的开发者发现,通过移除 NSP(仅使用 MLM 目标进行训练),下游任务的性能保持大致相同或略有提高。除此之外,他们还重新审视了批量大小以及 MLM 的屏蔽方式。综合起来,这个新的预训练语言模型在诸如问答和阅读理解等下游任务中取得了最先进的结果。

因为 RoBERTa 使用与 BERT 相同的架构,并且两者都在 Transformers 中实现,所以如果您的应用程序已经使用 BERT,那么切换到 RoBERTa 将非常容易。

注意与 BERT 与 RoBERTa 类似,跨语言语言模型 XLM(在第 8.4.4 节中介绍)有其“优化鲁棒性”的同类称为 XLM-R(缩写为 XML-RoBERTa)。⁸ XLM-R 对 100 种语言进行了预训练,并在许多跨语言 NLP 任务中表现出竞争力。

9.4.4 DistilBERT

尽管诸如 BERT 和 RoBERTa 等预训练模型功能强大,但它们在计算上是昂贵的,不仅用于预训练,而且用于调整和进行预测。例如,BERT-base(常规大小的 BERT)和 BERT-large(较大的对应物)分别具有 1.1 亿和 3.4 亿个参数,几乎每个输入都必须通过这个巨大的网络进行预测。如果您要对基于 BERT 的模型(例如我们在第 9.3 节中构建的模型)进行微调和预测,那么您几乎肯定需要一个 GPU,这并不总是可用的,这取决于您的计算环境。例如,如果您想在手机上运行一些实时文本分析,BERT 将不是一个很好的选择(它甚至可能无法适应内存)。

为了降低现代大型神经网络的计算需求,通常使用知识蒸馏(或简称蒸馏)。这是一种机器学习技术,其中给定一个大型预训练模型(称为教师模型),会训练一个较小的模型(称为学生模型)来模仿较大模型的行为。有关更多详细信息,请参见图 9.12。学生模型使用掩码语言模型(MLM)损失(与 BERT 相同),以及教师和学生之间的交叉熵损失。这将推动学生模型产生与教师尽可能相似的预测标记的概率分布。

CH09_F12_Hagiwara

图 9.12 知识蒸馏结合了交叉熵和掩码 LM 目标。

Hugging Face 的研究人员开发了 BERT 的精简版本称为DistilBERT,⁹它的大小缩小了 40%,速度提高了 60%,同时与 BERT 相比重新训练了 97%的任务性能。您只需将传递给 AutoModel.from_pretrained()的模型名称(例如,bert-base-cased)替换为精炼版本(例如,distilbert-base-cased),同时保持其余代码不变即可使用 DistilBERT。

9.4.5 ALBERT

另一个解决了 BERT 计算复杂性问题的预训练语言模型是 ALBERT,¹⁰简称“轻量 BERT”。与采用知识蒸馏不同,ALBERT 对其模型和训练过程进行了一些修改。

ALBERT 对其模型进行的一个设计变化是它如何处理词嵌入。在大多数深度 NLP 模型中,词嵌入由一个大的查找表表示和存储,该表包含每个词的一个词嵌入向量。这种管理嵌入的方式通常适用于较小的模型,如 RNN 和 CNN。然而,对于基于 Transformer 的模型,如 BERT,输入的维度(即长度)需要与隐藏状态的维度匹配,通常为 768 维。这意味着模型需要维护一个大小为 V 乘以 768 的大查找表,其中 V 是唯一词汇项的数量。因为在许多 NLP 模型中 V 也很大(例如,30,000),所以产生的查找表变得巨大,并且占用了大量的内存和计算。

ALBERT 通过将词嵌入查找分解为两个阶段来解决这个问题,如图 9.13 所示。第一阶段类似于从映射表中检索词嵌入,只是词嵌入向量的输出维度较小(比如,128 维)。在下一个阶段,使用线性层扩展这些较短的向量,使它们与模型的所需输入维度相匹配(比如,768)。这类似于我们如何使用 Skip-gram 模型扩展词嵌入(第 3.4 节)。由于这种分解,ALBERT 只需要存储两个较小的查找表(V × 128,加上 128 × 768),而不是一个大的查找表(V × 768)。

CH09_F13_Hagiwara

图 9.13 ALBERT(右)将词嵌入分解为两个较小的投影。

ALBERT 实施的另一个设计变化是 Transformer 层之间的参数共享。Transformer 模型使用一系列自注意力层来转换输入向量。这些层将输入转换的方式通常各不相同——第一层可能以一种方式转换输入(例如,捕获基本短语),而第二层可能以另一种方式进行转换(例如,捕获一些句法信息)。然而,这意味着模型需要针对每一层保留所有必要的参数(用于键、查询和值的投影),这是昂贵的,并且占用了大量内存。相反,ALBERT 的所有层都共享相同的参数集,这意味着模型重复应用相同的转换到输入上。这些参数被调整为这样一种方式,即尽管它们相同,一系列转换对于预测目标是有效的。

最后,ALBERT 使用一种称为 句子顺序预测(SOP)的训练目标进行预训练,而不是 BERT 采用的下一个句子预测(NSP)。正如前面提到的,RoBERTa 的开发人员和其他一些人发现 NSP 目标基本无用,并决定将其排除。ALBERT 用句子顺序预测 (SOP) 取代了 NSP,在这个任务中,模型被要求预测两个连续文本段落的顺序。例如:¹¹

  • (A) 她和她的男朋友决定去散步。 (B) 走了一英里后,发生了一些事情。

  • (C) 然而,周边区域的一位老师帮助了我站起来。(D) 起初,没有人愿意帮我站起来。

在第一个示例中,你可以知道 A 发生在 B 之前。在第二个示例中,顺序被颠倒,D 应该在 C 之前。这对人类来说很容易,但对机器来说是一个困难的任务——自然语言处理模型需要学会忽略表面主题信号(例如,“去散步”,“步行超过一英里”,“帮我站起来”),并专注于话语级连贯性。使用这种目标进行训练使得模型更加强大且可用于更深入的自然语言理解任务。

因此,ALBERT 能够通过更少的参数扩大其训练并超越 BERT-large。与 DistilBERT 一样,ALBERT 的模型架构与 BERT 几乎完全相同,您只需在调用 AutoModel.from_pretrained() 时提供模型名称即可使用它 (例如,albert-base-v1)。

9.5 案例研究 2:BERT 自然语言推理

在本章的最后一部分,我们将构建自然语言推理的 NLP 模型,这是一个预测句子之间逻辑关系的任务。我们将使用 AllenNLP 构建模型,同时演示如何将 BERT(或任何其他基于 Transformer 的预训练模型)集成到你的管道中。

9.5.1 什么是自然语言推理?

自然语言推理(简称 NLI)是确定一对句子之间逻辑关系的任务。具体而言,给定一个句子(称为前提)和另一个句子(称为假设),你需要确定假设是否从前提中逻辑推演出来。在以下示例中,这更容易理解。¹²

前提 假设 标签
一名男子查看一个身穿东亚某国制服的人物。 男子正在睡觉。 矛盾
一名年长和一名年轻男子微笑着。 两名男子对在地板上玩耍的猫笑着。 中性
进行多人踢足球的比赛。 一些男人正在运动。 蕴涵

在第一个例子中,假设(“这个人正在睡觉”)显然与前提(“一个人正在检查…”)相矛盾,因为一个人不能在睡觉时检查某事。在第二个例子中,你无法确定假设是否与前提矛盾或被前提蕴含(特别是“笑猫”的部分),这使得关系是“中性”的。在第三个例子中,你可以从前提中逻辑推断出假设——换句话说,假设被前提蕴含。

正如你猜到的那样,即使对于人类来说,NLI 也可能是棘手的。这项任务不仅需要词汇知识(例如,“人”的复数是“人们”,足球是一种运动),还需要一些“常识”(例如,你不能在睡觉时检查)。NLI 是最典型的自然语言理解(NLU)任务之一。你如何构建一个 NLP 模型来解决这个任务?

幸运的是,NLI 是 NLP 中一个经过深入研究的领域。NLI 最流行的数据集是标准自然语言推理(SNLI)语料库(nlp.stanford.edu/projects/snli/),已被大量用作 NLP 研究的基准。接下来,我们将使用 AllenNLP 构建一个神经 NLI 模型,并学习如何为这个特定任务使用 BERT。

在继续之前,请确保你已经安装了 AllenNLP(我们使用的是版本 2.5.0)和 AllenNLP 模型的模块。你可以通过运行以下代码来安装它们:

pip install allennlp==2.5.0
pip install allennlp-models==2.5.0

这也将 Transformers 库安装为一个依赖项。

9.5.2 使用 BERT 进行句对分类

在我们开始构建模型之前,请注意,NLI 任务的每个输入都由两部分组成:前提和假设。本书涵盖的大多数 NLP 任务仅有一个部分——通常是模型的输入的一个部分——通常是单个句子。我们如何构建一个可以对句子对进行预测的模型?

我们有多种方法来处理 NLP 模型的多部分输入。我们可以使用编码器对每个句子进行编码,并对结果应用一些数学操作(例如,串联、减法),以得到一对句子的嵌入(顺便说一句,这是孪生网络的基本思想¹³)。研究人员还提出了更复杂的具有注意力的神经网络模型,例如 BiDAF¹⁴。

然而,从本质上讲,没有什么阻止 BERT 接受多个句子。因为 Transformer 接受任何令牌序列,你可以简单地将两个句子串联起来并将它们输入模型。如果你担心模型混淆了两个句子,你可以用一个特殊的令牌,[SEP],将它们分开。你还可以为每个句子添加不同的值作为模型的额外信号。BERT 使用这两种技术对模型进行了少量修改,以解决句对分类任务,如 NLI。

流水线的其余部分与其他分类任务类似。特殊令牌[CLS]被附加到每个句子对,从中提取输入的最终嵌入。最后,您可以使用分类头将嵌入转换为一组与类相对应的值(称为logits)。这在图 9.14 中有所说明。

CH09_F14_Hagiwara

图 9.14 使用 BERT 对一对句子进行馈送和分类

在实践中,连接和插入特殊令牌都是由 SnliReader 处理的,这是专门用于处理 SNLI 数据集的 AllenNLP 数据集读取器。您可以初始化数据集并观察它如何将数据转换为 AllenNLP 实例,代码如下:

from allennlp.data.tokenizers import PretrainedTransformerTokenizer
from allennlp_models.pair_classification.dataset_readers import SnliReader

BERT_MODEL = 'bert-base-cased'
tokenizer = PretrainedTransformerTokenizer(model_name=BERT_MODEL, add_special_tokens=False)

reader = SnliReader(tokenizer=tokenizer)
dataset_url = 'https://realworldnlpbook.s3.amazonaws.com/data/snli/snli_1.0_dev.jsonl'
for instance in reader.read():
    print(instance)

数据集读取器从斯坦福 NLI 语料库中获取一个 JSONL(JSON 行)文件,并将其转换为一系列 AllenNLP 实例。我们指定了一个我在线上(S3)放置的数据集文件的 URL。请注意,在初始化分词器时,您需要指定 add_special_tokens=False。这听起来有点奇怪——难道我们不是应该在这里添加特殊令牌吗?这是必需的,因为数据集读取器(SnliReader)而不是分词器会处理特殊令牌。如果您仅使用 Transformer 库(而不是 AllenNLP),则不需要此选项。

前面的代码片段生成了以下生成的实例的转储:

Instance with fields:
         tokens: TextField of length 29 with text:
                [[CLS], Two, women, are, em, ##bracing, while, holding, to, go, packages,
 ., [SEP], The, sisters, are, hugging, goodbye, while, holding, to, go, 
 packages, after, just, eating, lunch, ., [SEP]]
                and TokenIndexers : {'tokens': 'SingleIdTokenIndexer'}
         label: LabelField with label: neutral in namespace: 'labels'.'

Instance with fields:
         tokens: TextField of length 20 with text:
                [[CLS], Two, women, are, em, ##bracing, while, holding, to, go, packages,
 ., [SEP], Two, woman, are, holding, packages, ., [SEP]]
                and TokenIndexers : {'tokens': 'SingleIdTokenIndexer'}
         label: LabelField with label: entailment in namespace: 'labels'.'

Instance with fields:
         tokens: TextField of length 23 with text:
                [[CLS], Two, women, are, em, ##bracing, while, holding, to, go, packages,
 ., [SEP], The, men, are, fighting, outside, a, del, ##i, ., [SEP]]
                and TokenIndexers : {'tokens': 'SingleIdTokenIndexer'}
         label: LabelField with label: contradiction in namespace: 'labels'.'
...

请注意,每个句子都经过了标记化,并且句子被连接并由[SEP]特殊令牌分隔。每个实例还有一个包含金标签的标签字段。

注意:您可能已经注意到令牌化结果中出现了一些奇怪的字符,例如##bracing 和##i。这些是字节对编码(BPE)的结果,这是一种将单词分割为所谓的子词单元的标记化算法。我们将在第十章中详细介绍 BPE。

9.5.3 使用 AllenNLP 与 Transformers

现在我们准备使用 AllenNLP 构建我们的模型。好消息是,由于 AllenNLP 的内置模块,您不需要编写任何 Python 代码来构建 NLI 模型——您只需要编写一个 Jsonnet 配置文件(就像我们在第四章中所做的那样)。AllenNLP 还无缝集成了 Hugging Face 的 Transformer 库,因此即使您想要将基于 Transformer 的模型(如 BERT)集成到现有模型中,通常也只需要进行很少的更改。

当将 BERT 集成到您的模型和流水线中时,您需要对以下四个组件进行更改:

  • Tokenizer—就像您在之前的 9.3 节中所做的那样,您需要使用与您正在使用的预训练模型相匹配的分词器。

  • Token indexer—Token indexer 将令牌转换为整数索引。由于预训练模型带有其自己预定义的词汇表,因此很重要您使用匹配的令牌索引器。

  • Token embedder—Token embedder 将令牌转换为嵌入。这是 BERT 的主要计算发生的地方。

  • Seq2Vec 编码器—BERT 的原始输出是一系列嵌入。您需要一个 Seq2Vec 编码器将其转换为单个嵌入向量。

如果这听起来令人生畏,不要担心—在大多数情况下,你只需要记住使用所需模型的名称来初始化正确的模块。接下来我会引导你完成这些步骤。

首先,让我们定义我们用于读取和转换 SNLI 数据集的数据集。我们之前已经用 Python 代码做过这个了,但在这里我们将在 Jsonnet 中编写相应的初始化。首先,让我们使用以下代码定义我们将在整个流水线中使用的模型名称。Jsonnet 相对于普通 JSON 的一个很酷的功能是你可以定义和使用变量:

local bert_model = "bert-base-cased";

配置文件中初始化数据集的第一部分看起来像以下内容:

"dataset_reader": {
    "type": "snli",
    "tokenizer": {
        "type": "pretrained_transformer",
        "model_name": bert_model,
        "add_special_tokens": false
    },
    "token_indexers": {
        "bert": {
            "type": "pretrained_transformer",
            "model_name": bert_model,
        }
    }
},

在顶层,这是初始化一个由类型 snli 指定的数据集读取器,它是我们之前尝试过的 SnliReader。数据集读取器需要两个参数—tokenizer 和 token_indexers。对于 tokenizer,我们使用一个 PretrainedTransformerTokenizer(类型:pretrained_transformer)并提供一个模型名称。同样,这是我们之前在 Python 代码中初始化和使用的分词器。请注意 Python 代码和 Jsonnet 配置文件之间的良好对应关系。大多数 AllenNLP 模块都设计得非常好,使得这两者之间有着很好的对应关系,如下表所示。

Python 代码 Jsonnet 配置
tokenizer = PretrainedTransformerTokenizer(model_name=BERT_MODEL,add_special_tokens=False) "tokenizer":

初始化令牌索引器部分可能看起来有点混乱。它正在使用模型名称初始化一个 PretrainedTransformerIndexer(类型:pretrained_transformer)。索引器将把索引结果存储到名为 bert 的部分(对应于令牌索引器的键)。幸运的是,这段代码是一个样板,从一个模型到另一个模型几乎没有变化,很可能当你在一个新的基于 Transformer 的模型上工作时,你可以简单地复制并粘贴这一部分。

至于训练/验证数据,我们可以使用本书的 S3 存储库中的数据,如下所示:

"train_data_path": "https://realworldnlpbook.s3.amazonaws.com/data/snli/snli_1.0_train.jsonl",
"validation_data_path": "https://realworldnlpbook.s3.amazonaws.com/data/snli/snli_1.0_dev.jsonl",

现在我们准备开始定义我们的模型:

"model": {
    "type": "basic_classifier",

    "text_field_embedder": {
        "token_embedders": {
            "bert": {
                "type": "pretrained_transformer",
                "model_name": bert_model
            }
        }
    },
    "seq2vec_encoder": {
        "type": "bert_pooler",
        "pretrained_model": bert_model
    }
},

在顶层,此部分定义了一个 BasicClassifier 模型(类型:basic_classifier)。它是一个通用的文本分类模型,它嵌入输入,使用 Seq2Vec 编码器对其进行编码,并使用分类头进行分类(带有 softmax 层)。您可以将您选择的嵌入器和编码器作为模型的子组件“插入”。例如,您可以通过单词嵌入嵌入标记,并使用 RNN 对序列进行编码(这是我们在第四章中所做的)。或者,您可以使用 CNN 对序列进行编码,就像我们在第七章中所做的那样。这就是 AllenNLP 设计的优点所在——通用模型仅指定了什么(例如,一个 TextFieldEmbedder 和一个 Seq2VecEncoder),但不是确切的如何(例如,单词嵌入、RNN、BERT)。您可以使用任何嵌入/编码输入的子模块,只要这些子模块符合指定的接口(即,它们是所需类的子类)。

在这个案例研究中,我们将首先使用 BERT 对输入序列进行嵌入。这是通过一个特殊的标记嵌入器,PretrainedTransformerEmbedder(类型:pretrained_transformer)实现的,它接受 Transformer 分词器的结果,经过预训练的 BERT 模型,并产生嵌入的输入。您需要将此嵌入器作为 token_embedders 参数的 bert 键的值传递(您之前为 token_indexers 指定的那个)。

然而,从 BERT 中得到的原始输出是一系列嵌入。因为我们感兴趣的是对给定的句子对进行分类,我们需要提取整个序列的嵌入,这可以通过提取与 CLS 特殊标记对应的嵌入来完成。AllenNLP 实现了一种称为 BertPooler(类型:bert_pooler)的 Seq2VecEncoder 类型,它正是这样做的。

在嵌入和编码输入之后,基本分类器模型处理剩下的事情——嵌入经过一个线性层,将它们转换为一组 logits,并且整个网络使用交叉熵损失进行训练,就像其他分类模型一样。整个配置文件如下所示。

列表 9.4 使用 BERT 训练 NLI 模型的配置文件

local bert_model = "bert-base-cased";

{
    "dataset_reader": {
        "type": "snli",
        "tokenizer": {
            "type": "pretrained_transformer",
            "model_name": bert_model,
            "add_special_tokens": false
        },
        "token_indexers": {
            "bert": {
                "type": "pretrained_transformer",
                "model_name": bert_model,
            }
        }
    },
    "train_data_path": "https://realworldnlpbook.s3.amazonaws.com/data/snli/snli_1.0_train.jsonl",
    "validation_data_path": "https://realworldnlpbook.s3.amazonaws.com/data/snli/snli_1.0_dev.jsonl",

    "model": {
        "type": "basic_classifier",

        "text_field_embedder": {
            "token_embedders": {
                "bert": {
                    "type": "pretrained_transformer",
                    "model_name": bert_model
                }
            }
        },
        "seq2vec_encoder": {
            "type": "bert_pooler",
            "pretrained_model": bert_model,
        }
    },
    "data_loader": {
        "batch_sampler": {
            "type": "bucket",
            "sorting_keys": ["tokens"],
            "padding_noise": 0.1,
            "batch_size" : 32
        }
    },
    "trainer": {
        "optimizer": {
            "type": "huggingface_adamw",
            "lr": 5.0e-6
        },
        "validation_metric": "+accuracy",
        "num_epochs": 30,
        "patience": 10,
        "cuda_device": 0
    }
}

如果您不熟悉数据加载器和训练器部分正在发生的事情也没关系。我们将在第十章讨论这些主题(批处理、填充、优化、超参数调整)。在将此配置文件保存在 examples/nli/snli_transformers.jsonnet 后,您可以通过运行以下代码开始训练过程:

allennlp train examples/nli/snli_transformers.jsonnet --serialization-dir models/snli

这将运行一段时间(即使在诸如 Nvidia V100 这样的快速 GPU 上也是如此),并在 stdout 上产生大量的日志消息。以下是我在四个时期后得到的日志消息的片段:

...
allennlp.training.trainer - Epoch 4/29
allennlp.training.trainer - Worker 0 memory usage MB: 6644.208
allennlp.training.trainer - GPU 0 memory usage MB: 8708
allennlp.training.trainer - Training
allennlp.training.trainer - Validating
allennlp.training.tensorboard_writer -                        Training |  Validation
allennlp.training.tensorboard_writer - accuracy           |     0.933  |     0.908
allennlp.training.tensorboard_writer - gpu_0_memory_MB    |  8708.000  |       N/A
allennlp.training.tensorboard_writer - loss               |     0.190  |     0.293
allennlp.training.tensorboard_writer - reg_loss           |     0.000  |     0.000
allennlp.training.tensorboard_writer - worker_0_memory_MB |  6644.208  |       N/A
allennlp.training.checkpointer - Best validation performance so far. Copying weights to 'models/snli/best.th'.
allennlp.training.trainer - Epoch duration: 0:21:39.687226
allennlp.training.trainer - Estimated training time remaining: 9:04:56
...

注意验证准确率(0.908)。考虑到这是一个三类分类,随机基线只会是 0.3。相比之下,当我用基于 LSTM 的 RNN 替换 BERT 时,我得到的最佳验证准确率约为~0.68。我们需要更仔细地进行实验,以公平地比较不同模型之间的差异,但这个结果似乎表明 BERT 是解决自然语言理解问题的强大模型。

摘要

  • 转移学习是一个机器学习概念,其中一个模型学习了一个任务,然后通过在它们之间转移知识来应用到另一个任务上。这是许多现代、强大、预训练模型的基本概念。

  • BERT 是一个使用掩码语言建模和下一句预测目标进行预训练的 Transformer 编码器,以产生上下文化的嵌入,一系列考虑上下文的词嵌入。

  • ELMo、XLNet、RoBERTa、DistilBERT 和 ALBERT 是现代深度自然语言处理中常用的其他流行的预训练模型。

  • 你可以直接使用 Hugging Face 的 Transformers 库构建基于 BERT 的 NLP 应用,也可以使用无缝集成 Transformers 库的 AllenNLP。

^(1.)Ramponi 和 Plank,“NLP 中的神经无监督领域自适应——一项调查”,(2020)。arxiv.org/abs/2006.00632

^(2.)Jacob Devlin,Ming-Wei Chang,Kenton Lee 和 Kristina Toutanova,“BERT:用于语言理解的深度双向 Transformer 预训练”,(2018)。arxiv.org/abs/1810.04805

^(3.)Bryan McCann,James Bradbury,Caiming Xiong 和 Richard Socher,“翻译中学到的:上下文化的词向量”,2017 年 NIPS 会议。

^(4.)Peters 等人,“深度上下文化的词表示”,(2018)。arxiv.org/abs/1802.05365

^(5.)点击这里查看有关如何使用 ELMo 与 AllenNLP 的详细文档:allennlp.org/elmo

^(6.)请参阅huggingface.co/transformers/model_doc/xlnet.html以获取文档。

^(7.)Liu 等人,“RoBERTa:一个稳健优化的 BERT 预训练方法,”(2019)。arxiv.org/abs/1907.11692

^(8.)Conneau 等人,“规模化的无监督跨语言表示学习”,(2019)。arxiv.org/abs/1911.02116

^(9.)Sanh 等人,“DistilBERT,BERT 的精简版:更小、更快、更便宜、更轻”,(2019)。arxiv.org/abs/1910.01108

^(10.)Lan 等人,“ALBERT:一种用于自监督学习语言表示的轻量级 BERT”,(2020)。arxiv.org/abs/1909.11942

^(11.)这些示例来自 ROCStories:cs.rochester.edu/nlp/rocstories/

^(12.)这些示例来自于nlpprogress.com/english/natural_language_inference.html.

^(13.)Reimers 和 Gurevych,“使用 Siamese BERT-Network 的句子嵌入:Sentence-BERT”,(2019)。arxiv.org/abs/1908.10084.

^(14.)Seo 等人,“面向机器理解的双向注意力流”,(2018)。arxiv.org/abs/1611.01603.

第三部分:投入生产

在第 1 和第二部分,我们学到了许多关于现代 NLP 中“建模”部分的知识,包括词嵌入、RNN、CNN 和 Transformer。然而,你仍然需要学习如何有效地训练、提供、部署和解释这些模型,以构建健壮和实用的 NLP 应用程序。

第十章涉及在开发 NLP 应用程序时触及到的重要机器学习技术和最佳实践,包括批处理和填充、正则化和超参数优化。

最后,如果第 1 到 10 章是关于构建 NLP 模型,第十一章则涵盖了发生在 NLP 模型外部 的一切。该章节涵盖了如何部署、提供、解释和解读 NLP 模型。

第十章:开发自然语言处理应用的十大最佳实践

本章内容包括

  • 通过对令牌进行排序、填充和掩码使神经网络推断更有效率

  • 应用基于字符和 BPE 的分词技术将文本分割成令牌

  • 通过正则化避免过拟合

  • 通过使用上采样、下采样和损失加权处理不平衡数据集

  • 优化超参数

到目前为止,我们已经涵盖了很多内容,包括 RNN、CNN 和 Transformer 等深度神经网络模型,以及 AllenNLP 和 Hugging Face Transformers 等现代 NLP 框架。然而,我们对训练和推断的细节关注不多。例如,如何高效地训练和进行预测?如何避免模型过拟合?如何优化超参数?这些因素可能会对模型的最终性能和泛化能力产生巨大影响。本章涵盖了您需要考虑的这些重要主题,以构建在实际中表现良好的稳健准确的 NLP 应用程序。

10.1 实例批处理

在第二章中,我们简要提到了批处理,这是一种机器学习技术,其中实例被分组在一起形成批次,并发送到处理器(CPU 或更常见的 GPU)。在训练大型神经网络时,批处理几乎总是必要的——它对于高效稳定的训练至关重要。在本节中,我们将深入探讨与批处理相关的一些技术和考虑因素。

10.1.1 填充

训练大型神经网络需要进行许多线性代数运算,如矩阵加法和乘法,这涉及同时对许多许多数字执行基本数学运算。这就是为什么它需要专门的硬件,如 GPU,设计用于高度并行化执行此类操作的处理器。数据被发送到 GPU 作为张量,它们只是数字的高维数组,以及一些指示,说明它需要执行什么类型的数学运算。结果被发送回作为另一个张量。

在第二章中,我们将 GPU 比作海外高度专业化和优化的工厂,用于大量生产相同类型的产品。由于在通信和运输产品方面存在相当大的开销,因此如果您通过批量运输所有所需材料来进行小量订单以制造大量产品,而不是按需运输材料,则效率更高。

材料和产品通常在标准化的容器中来回运输。如果你曾经自己装过搬家货舱或观察别人装过,你可能知道有很多需要考虑的因素来确保安全可靠的运输。你需要紧紧地把家具和箱子放在一起,以免在过渡过程中移位。你需要用毯子裹着它们,并用绳子固定它们,以防止损坏。你需要把重的东西放在底部,以免把轻的东西压坏,等等。

机器学习中的批次类似于现实世界中用于运输物品的容器。就像运输集装箱都是相同的尺寸和矩形形状一样,机器学习中的批次只是装有相同类型数字的矩形张量。如果你想要将不同形状的多个实例在单个批次中“运送”到 GPU,你需要将它们打包,使打包的数字形成一个矩形张量。

在自然语言处理中,我们经常处理长度不同的文本序列。因为批次必须是矩形的,所以我们需要进行填充(即在每个序列末尾加上特殊标记< PAD >),以便张量的每一行具有相同的长度。你需要足够多的填充标记,以使序列的长度相同,这意味着你需要填充短的序列,直到它们与同一批次中最长的序列一样长。示例见图 10.1。

![CH10 图 10.1 填充和分批。黑色方块表示标记,灰色方块表示 EOS(结束)标记,白色方块表示填充。实际上,自然语言文本中的每个标记通常表示为长度为D的向量,由词嵌入方法生成。这意味着每个批次的张量是一个三维张量,其“深度”为D。在许多自然语言处理模型中,序列被表示为大小为N×L×D的批次(见图 10.2),其中NLD分别表示批次中的实例数目、序列的最大长度和词嵌入的维度。CH10_F02_Hagiwara

图 10.2 嵌入序列的填充和分批创建了三维的矩形张量。

看起来越来越像真正的容器了!

10.1.2 排序

因为每个批次必须是矩形的,如果一个批次同时包含短序列和长序列,你需要为短序列添加大量填充,使它们与同一批次中最长的序列一样长。这通常会导致批次中存在一些浪费空间——见图 10.3 中的“batch 1”示例。最短的序列(六个标记)需要填充八个标记才能与最长的序列(14 个标记)长度相等。张量中的浪费空间意味着存储和计算的浪费,所以最好避免这种情况发生,但是怎么做呢?

CH10_F03_Hagiwara

图 10.3 在批处理之前对实例进行排序(右侧)可以减少总张量数量。

通过将相似大小的实例放在同一个批次中,可以减少填充的量。如果较短的实例只与其他同样较短的实例一起批处理,则它们不需要用许多填充标记进行填充。同样,如果较长的实例只与其他较长的实例一起批处理,则它们也不需要很多填充,因为它们已经很长了。一个想法是按照它们的长度对实例进行排序,并相应地进行批处理。图 10.3 比较了两种情况——一种是实例按其原始顺序进行批处理,另一种是在批处理之前对实例进行排序。每个批次下方的数字表示表示批次所需的标记数,包括填充标记。注意,通过排序,总标记数从 144 降低到 120。因为原始句子中的标记数没有变化,所以这纯粹是因为排序减少了填充标记的数量。较小的批次需要更少的内存来存储和更少的计算来处理,因此在批处理之前对实例进行排序可以提高训练的效率。

所有这些技术听起来有点复杂,但好消息是,只要使用高级框架(如 AllenNLP),你很少需要自己编写排序、填充和批处理实例的代码。回想一下,在第二章中构建情感分析模型时,我们使用了 DataLoader 和 BucketBatchSampler 的组合,如下所示:

train_data_loader = DataLoader(train_dataset,
                               batch_sampler=BucketBatchSampler(
                                   train_dataset,
                                   batch_size=32,
                                   sorting_keys=["tokens"]))

BucketBatchSampler 中给定的 sorting_keys 指定了要用于排序的字段。从名称可以猜出,通过指定“tokens”,你告诉数据加载器按照标记数对实例进行排序(在大多数情况下是你想要的)。流水线会自动处理填充和批处理,数据加载器会提供一系列批次供您的模型使用。

10.1.3 掩码

最后一个需要注意的细节是 掩码。掩码是一种操作,用于忽略与填充相对应的网络的某些部分。当你处理顺序标记或语言生成模型时,这变得特别重要。回顾一下,顺序标记是一种任务,其中系统为输入序列中的每个标记分配一个标签。我们在第五章中使用了顺序标记模型(RNN)构建了一个词性标注器。

如图 10.4 所示,顺序标记模型通过最小化给定句子中所有标记的每个标记损失来进行训练。我们这样做是因为我们希望最小化网络每个标记的“错误”数量。只要处理“真实”标记(图中的“time”,“flies”和“like”),这是可以接受的,尽管当输入批次包含填充标记时,这就成为一个问题。因为它们只是为了填充批次而存在,所以在计算总损失时应该忽略它们。

CH10_F04_Hagiwara

图 10.4 序列的损失是每个标记的交叉熵之和。

我们通常通过创建一个额外的用于掩码损失的向量来完成这个过程。用于掩码的向量的长度与输入相同,其元素为“真”标记和填充的“假”标记。在计算总损失时,你可以简单地对每个标记的损失和掩码进行逐元素乘积,然后对结果进行求和。

幸运的是,只要你正在使用 AllenNLP 构建标准的顺序标记模型,你很少需要自己实现掩码。记住,在第五章,我们按照列表 10.1 中所示编写了 POS 标签器模型的前向传播。在这里,我们从 get_text_field_mask() 辅助函数获取掩码向量,并使用 sequence_cross_entropy_with_logits() 计算最终损失。

列表 10.1 POS 标签器的前向传播

    def forward(self,
                words: Dict[str, torch.Tensor],
                pos_tags: torch.Tensor = None,
                **args) -> Dict[str, torch.Tensor]:
        mask = get_text_field_mask(words)

        embeddings = self.embedder(words)
        encoder_out = self.encoder(embeddings, mask)
        tag_logits = self.linear(encoder_out)

        output = {"tag_logits": tag_logits}
        if pos_tags is not None:
            self.accuracy(tag_logits, pos_tags, mask)
            output["loss"] = sequence_cross_entropy_with_logits(
                tag_logits, pos_tags, mask)

        return output

如果你偷看一下掩码中的内容(比如,在这个前向方法中插入一个打印语句),你会看到以下由二进制(真或假)值组成的张量:

tensor([[ True,  True,  True,  True,  True,  True,  True,  True, False],
        [ True,  True,  True,  True,  True,  True,  True,  True,  True],
        [ True,  True,  True,  True,  True,  True,  True,  True, False],
        [ True,  True,  True,  True,  True,  True,  True,  True,  True],
        [ True,  True,  True,  True,  True,  True,  True,  True, False],
        [ True,  True,  True,  True,  True,  True,  True,  True, False],
        [ True,  True,  True,  True,  True,  True,  True,  True, False],
        [ True,  True,  True,  True,  True,  True,  True,  True,  True],
        [ True,  True,  True,  True,  True,  True,  True,  True, False],
        [ True,  True,  True,  True,  True,  True,  True,  True,  True],
        [ True,  True,  True,  True,  True,  True,  True,  True,  True],

这个张量的每一行对应一个标记序列,False 的位置是填充发生的地方。损失函数(sequence_cross_entropy_with_logits)接收预测值、真实标签和掩码,并在忽略所有标记为 False 的元素时计算最终损失。

10.2 用于神经模型的标记化

在第三章,我们介绍了基本的语言单位(单词、字符和 n-gram)以及如何计算它们的嵌入。在本节中,我们将更深入地讨论如何分析文本并获取这些单位的过程——称为标记化。神经网络模型在处理标记时面临一系列独特的挑战,我们将介绍一些现代模型来解决这些挑战。

10.2.1 未知单词

词汇表是一个 NLP 模型处理的标记集合。许多神经网络自然语言处理模型在一组固定、有限的标记中运作。例如,在第二章构建情感分析器时,AllenNLP 管道首先对训练数据集进行标记化,并构造一个 Vocabulary 对象,该对象包含了所有出现次数超过,比如,三次以上的所有唯一标记。然后模型使用一个嵌入层将标记转换为单词嵌入,这是输入标记的一些抽象表示。

迄今为止,一切都很顺利,对吧?但是世界上的所有单词数量并不是有限的。我们不断创造以前不存在的新单词(我不认为一百年前人们谈论过“NLP”)。如果模型接收到在训练期间从未见过的单词怎么办?因为这个单词不是词汇表的一部分,所以模型甚至不能将其转换为索引,更不用说查找其嵌入了。这样的单词被称为词汇外(OOV)单词,它们是构建自然语言处理应用时最大的问题之一。

到目前为止,处理这个问题最常见(但不是最好)的方法是将所有的 OOV 标记表示为一个特殊的标记,通常称为 UNK(代表“未知”)。想法是每当模型看到一个不属于词汇表的标记时,它都会假装看到了一个特殊的标记 UNK,并像往常一样继续执行。这意味着词汇表和嵌入表都有一个专门的“插槽”用于 UNK,以便模型可以处理从未见过的词汇。UNK 的嵌入(以及任何其他参数)与其他常规标记一样进行训练。

你是否看到这种方法存在任何问题?将所有的 OOV 标记都用一个单一的 UNK 标记来对待意味着它们被折叠成一个单一的嵌入向量。无论是“NLP”还是“doggy”——只要是未见过的东西,总是被视为一个 UNK 标记并被分配相同的向量,这个向量成为各种词汇的通用、全能表示。因此,模型无法区分 OOv 词汇之间的差异,无论这些词汇的身份是什么。

如果你正在构建一个情感分析器,这可能是可以接受的。OOV 词汇从定义上来说非常少见,可能不会影响到大部分输入句子的预测。然而,如果你正在构建一个机器翻译系统或一个对话引擎,这将成为一个巨大的问题。如果每次看到新词汇时都产生“我不知道”,那么它就不会是一个可用的 MT 系统或聊天机器人!一般来说,与用于预测的 NLP 系统(情感分析、词性标注等)相比,对于语言生成系统(包括机器翻译和对话 AI),OOV 问题更为严重。

如何做得更好?在自然语言处理中,OOV 标记是一个如此严重的问题,以至于已经有很多研究工作在如何处理它们上面。在下面的小节中,我们将介绍基于字符和基于子词的模型,这是两种用于构建强大神经网络自然语言处理模型的常用技术。

10.2.2 字符模型

处理 OOV 问题最简单但最有效的解决方案是将字符视为标记。具体来说,我们将输入文本分解为单个字符,甚至包括标点符号和空白字符,并将它们视为常规标记。应用程序的其余部分保持不变——“单词”嵌入被分配给字符,然后由模型进一步处理。如果模型生成文本,它是逐字符地生成的。

实际上,当我们构建语言生成器时,我们在第五章使用了字符级模型。RNN 不是一次生成一个单词,而是一次生成一个字符,如图 10.5 所示。由于这种策略,模型能够生成看起来像英语但实际上不是的单词。请注意 10.2 列表中显示的输出中类似于英语的许多奇怪的单词(despoitstudentedredusentiondistaples).如果模型操作单词,它只会生成已知的单词(或者在不确定时生成 UNKs),这是不可能的。

CH10_F05_Hagiwara

图 10.5:生成文本字符级(包括空格)的语言生成模型

列 10.2:字符级语言模型生成的句子

You can say that you don't know it, and why decided of yourself.
Pike of your value is to talk of hubies.
The meeting despoit from a police?
That's a problem, but us?
The sky as going to send nire into better.
We'll be look of the best ever studented.
There's you seen anything every's redusention day.
How a fail is to go there.
It sad not distaples with money.
What you see him go as famous to eat!

基于字符的模型是多功能的,并对语言的结构做出了少量的假设。对于拥有小字母表的语言(比如英语),它有效地消除了未知单词,因为几乎任何单词,无论其多么罕见,都可以被分解为字符。对于拥有大字母表的语言(如中文),将其标记为字符也是一种有效的策略,尽管你需要注意“未知字符”的问题。

然而,这种策略并非没有缺点。最大的问题是效率低下。为了编码一个句子,网络(无论是 RNN 还是 Transformer)都需要处理其中的所有字符。例如,基于字符的模型需要处理“t”,“h”,“e”,和“_”(空格)来处理一个单词“the”,而基于单词的模型可以在一个步骤中完成。这种低效在输入序列变长时对 Transformer 的影响最大,注意计算的增长是二次方的。

10.2.3:子词模型

到目前为止,我们学习了两个极端——基于单词的方法效率很高,但在处理未知词方面表现不佳。基于字符的方法在处理未知词方面表现出色,但效率低下。有没有一种介于两者之间的标记化方法?我们能不能使用一些标记化方法既高效又能很好地处理未知词?

子词模型是神经网络针对这个问题的最新发明。在子词模型中,输入文本被分割成一个被称为子词的单位,这只是意味着比单词小的东西。对于什么是子词,没有正式的语言学定义,但它们大致对应于频繁出现的单词的一部分。例如,“dishwasher”的一种分段方法是“dish + wash + er”,尽管也可能有其他的分割方法。

一些算法的变体(如 WordPiece¹ 和 SentencePiece²)将输入标记化为子词,但迄今为止最广泛使用的是字节对编码(BPE)。³ BPE 最初是作为一种压缩算法发明的,但自 2016 年以来,它已被广泛用作神经模型的标记化方法,特别是在机器翻译中。

BPE 的基本概念是保持频繁单词(如“the”和“you”)和 n 元组(如“-able”和“anti-”)不分段,同时将较少出现的单词(如“dishwasher”)分解为子词(“dish + wash + er”)。将频繁单词和 n 元组放在一起有助于模型高效处理这些标记,而分解稀有单词可以确保没有 UNK 标记,因为一切都最终可以分解为单个字符,如果需要的话。通过根据频率灵活选择标记位置,BPE 实现了两全其美——既高效又解决了未知词问题。

让我们看看 BPE 如何确定在真实示例中进行标记化。BPE 是一种纯统计算法(不使用任何语言相关信息),通过一次合并最频繁出现的一对连续标记来操作。首先,BPE 将所有输入文本标记化为单个字符。例如,如果您的输入是四个单词 low、lowest、newer 和 wider,则它们将被标记化为 l o w _、l o w e s t _、n e w e r _ 和 w i d e r 。在这里,“”是一个特殊符号,表示每个单词的结尾。然后,算法识别出最频繁出现的任意两个连续元素。在这个例子中,对 l o 出现最频繁(两次),所以这两个字符被合并,得到 lo w _、lo w e s t _、n e w e r _、w i d e r 。然后,lo w 将被合并为 low,e r 将被合并为 er,er _ 将被合并为 er,此时您有 low 、low e s t 、n e w er、w i d er。此过程在图 10.6 中有所说明。

CH10_F06_Hagiwara

图 10.6 BPE 通过迭代地合并频繁出现的连续单元来学习子词单元。

注意,在四次合并操作之后,lowest 被分割为 low e s t,其中频繁出现的子字符串(如 low)被合并在一起,而不频繁出现的子字符串(如 est)被拆分开来。要对新输入(例如 lower)进行分割,将按顺序应用相同的合并操作序列,得到 low e r _。如果您从 52 个唯一字母(26 个大写字母和小写字母)开始,执行了 N 次合并操作,则您的词汇表中将有 52 + N 个唯一标记,其中 N 是执行的合并操作数。通过这种方式,您完全控制了词汇表的大小。

在实践中,你很少需要自己实现 BPE(或任何其他子词标记化算法)。这些算法在许多开源库和平台上都有实现。两个流行的选择是 Subword-NMT(github.com/rsennrich/subword-nmt)和 SentencePiece(github.com/google/sentencepiece)(它还支持使用 unigram 语言模型的子词标记化变体)。许多 NLP 框架中附带的默认标记器,比如 Hugging Face Transformers 中实现的标记器,都支持子词标记化。

10.3 避免过拟合

过拟合是构建任何机器学习应用时需要解决的最常见和最重要的问题之一。当一个机器学习模型拟合给定数据得非常好,以至于失去了对未见数据的泛化能力时,就说该模型过拟合了。换句话说,模型可能在训练数据上表现得非常好,并且在它上面表现良好,但是可能无法很好地捕捉其固有模式,并且在模型从未见过的数据上表现不佳。

因为过拟合在机器学习中非常普遍,研究人员和实践者过去已经提出了许多算法和技术来应对过拟合。在本节中,我们将学习两种这样的技术——正则化和提前停止。这些技术在任何机器学习应用中都很受欢迎(不仅仅是自然语言处理),值得掌握。

10.3.1 正则化

正则化在机器学习中指的是鼓励模型的简化和泛化的技术。你可以把它看作是一种惩罚形式之一,你

强加给你的机器学习模型以确保其尽可能通用。这是什么意思呢?假设你正在构建一个“动物分类器”,通过从语料库中训练词嵌入并在这个嵌入空间中为动物和其他东西之间划分一条线(即,你将每个单词表示为一个多维向量,并根据向量的坐标对单词是否描述动物进行分类)。让我们大大简化这个问题,假设每个单词都是一个二维向量,并且你得到了图 10.7 所示的图。现在你可以可视化一个机器学习模型如何通过在决策翻转不同类别之间的线来做出分类决策,这被称为分类边界。你会如何绘制一个分类边界,以便将动物(蓝色圆圈)与其他所有东西(三角形)分开?

CH10_F07_Hagiwara

图 10.7 动物 vs. 非动物分类图

分离动物的一个简单方法是绘制一条直线,就像图 10.8 中的第一个图中所示。这个简单的分类器会犯一些错误(在分类诸如“hot”和“bat”之类的单词时),但是它正确分类了大多数数据点。这听起来是一个不错的开始。

CH10_F08_Hagiwara

图 10.8 随着复杂性增加的分类边界

如果告诉你决策边界不一定是一条直线呢?你可能想画出图 10.8 中间所示的那样的东西。这个看起来更好一些——它比第一个少犯一些错误,虽然仍然不完美。对于机器学习模型来说,这似乎是可行的,因为形状很简单。

但是这里没有什么可以阻止你。如果你想要尽可能少地犯错误,你也可以画出像第三个图中所示的那样扭曲的东西。那个决策边界甚至不会犯任何分类错误,这意味着我们实现了 100%的分类准确性!

不要那么快——记住,直到现在,我们只考虑了训练时间,但是机器学习模型的主要目的是在测试时间达到良好的分类性能(即,它们需要尽可能正确地分类未观察到的新实例)。现在让我们想一想前面描述的三个决策边界在测试时间表现如何。如果我们假设测试实例的分布与我们在图 10.8 中看到的训练实例类似,那么新的“动物”点最有可能落在图中的右上区域。前两个决策边界将通过正确分类大多数新实例而实现相当的准确度。但是第三个呢?像图中显示的“热”的训练实例最有可能是例外而不是规则,因此试图适应尽可能多的训练实例的决策边界的曲线部分可能会在测试时间通过无意中错误分类测试实例时带来更多的伤害。这正是过拟合的样子——模型对训练数据拟合得太好,牺牲了其泛化能力,这就是这里发生的事情。

然后,问题来了,我们如何避免你的模型看起来像第三个决策边界?毕竟,它在正确分类训练数据方面做得非常好。如果你只看训练准确度和/或损失,那么没有什么能阻止你选择它。避免过拟合的一种方法是使用一个单独的、保留的数据集(称为验证集;参见 2.2.3 节)来验证模型的性能。但是即使不使用单独的数据集,我们能做到吗?

第三个决策边界看起来不对劲——它过于复杂。在其他所有条件相同的情况下,我们应该更喜欢简单的模型,因为一般来说,简单的模型更容易泛化。这也符合奥卡姆剃刀原理,即更简单的解决方案优于更复杂的解决方案。我们如何在训练拟合和模型简单性之间取得平衡呢?

这就是正则化发挥作用的地方。将正则化视为对模型施加的额外限制,以便优选更简单和/或更一般化的模型。该模型被优化,使其能够在获得最佳训练拟合的同时尽可能一般化。

由于过拟合是如此重要的话题,因此机器学习中已经提出了许多正则化技术。我们只介绍其中几个最重要的——L2 正则化(权重衰减),dropout 和提前停止。

L2 正则化

L2 正则化,也称为权重衰减,是不仅用于 NLP 或深度学习,而且用于广泛的 ML 模型的最常见的正则化方法之一。我们不会深入探讨它的数学细节,但简单来说,L2 正则化为模型的复杂度增加了惩罚,这个复杂度是通过其参数的大小来测量的。为了表示复杂的分类边界,ML 模型需要调整大量参数(“魔术常数”)到极端值,这由 L2 loss 来衡量,其捕获了它们距离零有多远。这样的模型会承担更大的 L2 惩罚,这就是为什么 L2 鼓励更简单的模型。如果你想了解更多关于 L2 正则化(以及 NLP 一般的其他相关主题),请查阅类似 Jurafsky 和 Martin 的Speech and Language Processing(web.stanford .edu/~jurafsky/slp3/5.pdf)或 Goodfellow 等人的Deep Learning(www.deep learningbook.org/contents/regularization.html)的教材。

Dropout

Dropout是另一种常用于神经网络的正则化技术。Dropout 通过在训练期间随机“放弃”神经元来工作,其中“神经元”基本上是中间层的一个维度,“放弃”意味着用零掩盖它。你可以将 dropout 视为对模型结构复杂性的惩罚以及对特定特征和值的依赖性。因此,网络试图通过剩余数量较少的值做出最佳猜测,这迫使它良好地泛化。Dropout 易于实现,在实践中非常有效,并且在许多深度学习模型中作为默认正则化方法使用。有关 dropout 的更多信息,请参考 Goodfellow 书中提到的正则化章节,其中详细介绍了正则化技术的数学细节。

10.3.2 提前停止

另一种在机器学习中应对过拟合的流行方法是提前停止。提前停止是一种相对简单的技术,当模型性能不再改善时(通常使用验证集损失来衡量),停止训练模型。在第六章中,我们绘制了学习曲线,当我们构建英西机器翻译模型(在图 10.9 中再次显示)时。请注意,验证损失曲线在第八个时期左右变平,在此之后开始上升,这是过拟合的迹象。提前停止会检测到这一点,停止训练,并使用损失最低的最佳时期的结果。一般来说,提前停止具有“耐心”参数,该参数是停止训练的非改善时期的数量。例如,当耐心是 10 个时期时,训练流程将在损失停止改善后等待 10 个时期才终止训练。

CH10_F09_Hagiwara

图 10.9 验证损失曲线在第 8 个时期左右变平,并逐渐上升。

为什么提前停止有助于减轻过拟合?它与模型复杂度有什么关系?不涉及数学细节,让模型学习复杂的、过拟合的决策边界需要一定的时间(训练时期)。大多数模型从一些简单的东西开始(例如直接的决策线)并逐渐在训练过程中增加其复杂性。通过提前停止训练,可以防止模型变得过于复杂。

许多机器学习框架都内置了提前停止的支持。例如,AllenNLP 的训练器默认支持提前停止。回忆一下,当我们训练基于 BERT 的自然语言推理模型时,在第 9.5.3 节使用了以下配置,其中我们使用了提前停止(耐心为 10)而没有过多关注。这使得训练器能够在验证指标在 10 个时期内没有改善时停止:

    "trainer": {
        "optimizer": {
            "type": "huggingface_adamw",
            "lr": 1.0e-5
        },
        "num_epochs": 20,
        "patience": 10,
        "cuda_device": 0
    }

10.3.3 交叉验证

交叉验证 不完全是一种正则化方法,但它是机器学习中常用的技术之一。在构建和验证机器学习模型时,通常情况是只有数百个实例可供训练。正如本书迄今所见,仅依靠训练集是无法训练出可靠的机器学习模型的——您需要一个单独的集合用于验证,最好再有一个单独的集合用于测试。您在验证/测试中使用的比例取决于任务和数据大小,但通常建议将 5-20% 的训练实例留作验证和测试。这意味着,如果您的训练数据较少,那么您的模型将只有几十个实例用于验证和测试,这可能会使估算的指标不稳定。此外,您选择这些实例的方式对评估指标有很大的影响,这并不理想。

交叉验证的基本思想是多次迭代这个阶段(将数据集分成训练和验证部分),使用不同的划分方式来提高结果的稳定性。具体来说,在一个典型的称为k 折交叉验证的设置中,您首先将数据集分成k个不同的相等大小的部分,称为折叠。您使用折叠中的一个进行验证,同时在其余部分(k - 1 个折叠)上训练模型,并重复此过程k次,每次使用不同的折叠进行验证。详见图 10.10 的示意图。

CH10_F10_Hagiwara

图 10.10:k 折交叉验证中,数据集被分为 k 个大小相等的折叠,其中一个用于验证。

每个折叠的验证指标都会计算,并且最终指标会在所有迭代中取平均。通过这种方式,您可以得到一个对评估指标的更稳定的估计,而不受数据集划分方式的影响。

在深度学习模型中,使用交叉验证并不常见,因为这些模型需要大量数据,如果您有大型数据集,则不需要交叉验证,尽管在传统和工业场景中,训练数据量有限时使用交叉验证更为常见。

10.4 处理不平衡数据集

在本节中,我们将重点讨论在构建自然语言处理(NLP)和机器学习(ML)模型时可能遇到的最常见问题之一——类别不平衡问题。分类任务的目标是将每个实例(例如电子邮件)分配给其中一个类别(例如垃圾邮件或非垃圾邮件),但这些类别很少均匀分布。例如,在垃圾邮件过滤中,非垃圾邮件的数量通常大于垃圾邮件的数量。在

文档分类中,某些主题(如政治或体育)通常要比其他主题更受欢迎。当某些类别的实例数量远远多于其他类别时,类别被称为不平衡(见图 10.11 中的示例)。

CH10_F11_Hagiwara

图 10.11:不平衡数据集

许多分类数据集存在不平衡的类别,这在训练分类器时会带来一些额外的挑战。小类别给模型带来的信号会被大类别压倒,导致模型在少数类别上表现不佳。在接下来的小节中,我将讨论一些在面对不平衡数据集时可以考虑的技术。

10.4.1 使用适当的评估指标

在您甚至开始调整数据集或模型之前,请确保您正在使用适当的指标验证您的模型。在第 4.3 节中,我们讨论了在数据集不平衡时使用准确性作为评估指标是一个坏主意的原因。在一个极端情况下,如果您的实例中有 90%属于类别 A,而其他 10%属于类别 B,即使一个愚蠢的分类器将类别 A 分配给一切,它也可以达到 90%的准确性。这被称为多数类基线。稍微聪明一点(但仍然愚蠢)的分类器,90%的时间随机分配标签 A,10%的时间随机分配标签 B,甚至不看实例,就可以达到 0.9 * 0.9 + 0.1 * 0.1 = 82%的准确性。这被称为随机基线,而数据集越不平衡,这些基线模型的准确性就会越高。

但是这种随机基线很少是少数类的良好模型。想象一下,如果您使用随机基线会发生什么事情。因为无论如何,它都会将类别 A 分配给 90%的时间,类别 B 会发生什么情况。换句话说,属于类别 B 的 90%实例将被分配给类别 A。换句话说,这种类别 B 的随机基线的准确性只有 10%。如果这是一个垃圾邮件过滤器,它将让 90%的垃圾邮件通过,无论内容是什么,只是因为您收到的邮件中有 90%不是垃圾邮件!这会造成一个糟糕的垃圾邮件过滤器。

如果您的数据集不平衡,并且您关心少数类别的分类性能,您应该考虑使用更适合这种情况的指标。例如,如果您的任务是“大海捞针”类型的设置,在这种情况下,目标是在其他实例中找到很少的实例,您可能希望使用 F1 度量而不是准确性。正如我们在第四章中看到的,F 度量是精确度(您的预测有多少是无草的)和召回率(您实际上找到了多少针)之间的某种平均值。因为 F1 度量是每个类别计算的,所以它不会低估少数类别。如果您想要测量模型的整体性能,包括多数类别,您可以计算宏平均的 F 度量,它只是每个类别计算的 F 度量的算术平均值。

10.4.2 上采样和下采样

现在让我们看看可以缓解类别不平衡问题的具体技术。首先,如果您可以收集更多的标记训练数据,您应该认真考虑首先这样做。与学术和机器学习竞赛设置不同,在这种设置中数据集是固定的,而您调整您的模型,而在现实世界中,您可以自由地做任何必要的事情来改进您的模型(当然,只要合法且实用)。通常,您可以做的最好的事情是让模型暴露于更多的数据。

如果您的数据集不平衡且模型正在做出偏向的预测,您可以对数据进行上采样下采样,以便各类别具有大致相等的表示。

在上采样中(参见图 10.12 中的第二张图),你通过多次复制实例人工增加少数类的大小。例如,我们之前讨论的场景——如果你复制类 B 的实例并将每个实例的副本增加八个,它们就会有相等数量的实例。这可以缓解偏见预测的问题。尽管有更复杂的数据增强算法,如 SMOTE⁵,但它们在自然语言处理中并不常用,因为人为生成语言示例固有的困难。

CH10_F12_Hagiwara

图 10.12 上采样和下采样

如果你的模型存在偏见,不是因为少数类太小,而是因为多数类太大,你可以选择进行下采样(图 10.12 中的第三张图)。在下采样中,你通过选择属于该类的实例的子集人工减少多数类的大小。例如,如果你从类 A 中随机抽取了九个实例中的一个,你最终会得到类 A 和类 B 中相等数量的实例。你可以以多种方式进行下采样——最简单的是随机选择子集。如果你想确保下采样后的数据集仍保留了原始数据的多样性,你可以尝试分层抽样,其中你根据某些属性定义的组对实例进行抽样。例如,如果你有太多的非垃圾邮件并想要进行下采样,你可以首先按发件人的域分组,然后在每个域中抽样一定数量的电子邮件。这将确保你的抽样数据集将包含多种域的多样性。

请注意,无论是上采样还是下采样都不是灵丹妙药。如果你对类的分布进行了过于激进的“修正”,你会冒着对多数类做出不公平预测的风险,如果这是你关心的话。一定要确保用一个合适的评估指标的验证集检查你的模型。

10.4.3 权重损失

缓解类不平衡问题的另一种方法是在计算损失时使用加权,而不是对训练数据进行修改。请记住,损失函数用于衡量模型对实例的预测与真实情况的“偏离”程度。当你衡量模型的预测有多糟糕时,你可以调整损失,使其在真实情况属于少数类时惩罚更严厉。

让我们来看一个具体的例子。二元交叉熵损失是用于训练二元分类器的常见损失函数,当正确标签为 1 时,它看起来像图 10.13 中所示的曲线。 x 轴是目标类别的预测概率,y 轴是预测将施加的损失量。当预测完全正确(概率 = 1)时,没有惩罚,而随着预测变得越来越糟糕(概率 < 1),损失增加。

CH10_F13_Hagiwara

图 10.13 二元交叉熵损失(正确标签为 1)

如果您更关心模型在少数类上的表现,可以调整这个损失。具体而言,您可以更改这个损失的形状(通过简单地将其乘以一个常数),只针对那个类别,以便当模型在少数类上犯错时,它会产生更大的损失。图 10.14 中的一条调整后的损失曲线就是顶部的那条。这种加权与上采样少数类具有相同的效果,尽管修改损失的计算成本更低,因为您不需要实际增加训练数据量。

CH10_F14_Hagiwara

图 10.14 加权二元交叉熵损失

在 PyTorch 和 AllenNLP 中实现损失权重很容易。PyTorch 的二元交叉熵实现 BCEWithLogitsLoss 已经支持为不同类别使用不同的权重。您只需要将 pos_weight 参数作为权重传递,如下所示:

>>> import torch
>>> import torch.nn as nn

>>> input = torch.randn(3)
>>> input
tensor([-0.5565,  1.5350, -1.3066])

>>> target = torch.empty(3).random_(2)
>>> target
tensor([0., 0., 1.])

>>> loss = nn.BCEWithLogitsLoss(reduction='none')
>>> loss(input, target)
tensor([0.4531, 1.7302, 1.5462])

>>> loss = nn.BCEWithLogitsLoss(reduction='none', pos_weight=torch.tensor(2.))
>>> loss(input, target)
tensor([0.4531, 1.7302, 3.0923])

在这段代码片段中,我们随机生成预测值(input)和真实值(target)。总共有三个实例,其中两个属于类别 0(多数类),一个属于类别 1(少数类)。我们先使用 BCEWithLogitsLoss 对象计算不加权的损失,这将返回三个损失值,每个实例一个。然后,我们通过传递权重 2 来计算加权损失——这意味着如果目标类别是正类(类别 1),则错误预测将被惩罚两倍。请注意,对应于类别 1 的第三个元素是非加权损失函数返回值的两倍。

10.5 超参数调整

在本章的最后一节,我们将讨论超参数调整。超参数是有关模型和训练算法的参数。这个术语与参数相对,参数是模型用于从输入中作出预测的数字。这就是我们在本书中一直称之为“魔术常数”的内容——它们类似于编程语言中的常数,尽管它们的确切值被优化自动调整,以使预测尽可能接近所需输出。

正确调整超参数对于许多机器学习模型正常工作并发挥其最高潜力至关重要,机器学习从业者花费大量时间来调整超参数。知道如何有效地调整超参数对于提高在构建自然语言处理和机器学习系统时的生产力有着巨大的影响。

10.5.1 超参数示例

超参数是“元”级别的参数——与模型参数不同,它们不用于进行预测,而是用于控制模型的结构以及模型的训练方式。例如,如果你正在处理词嵌入或者一个 RNN,那么用于表示单词的隐藏单元(维度)的数量就是一个重要的超参数。使用的 RNN 层数是另一个超参数。除了这两个超参数(隐藏单元和层数)之外,我们在第九章中介绍的 Transformer 模型还有一些其他参数,比如注意力头的数量和前馈网络的维度。甚至你使用的架构类型,例如 RNN 与 Transformer,也可以被视为一个超参数。

此外,您使用的优化算法也可能有超参数。例如,在许多机器学习设置中最重要的超参数之一——学习率(第 9.3.3 节),确定了每个优化步骤中调整模型参数的程度。迭代次数(通过训练数据集的次数)也是一个重要的超参数。

到目前为止,我们对这些超参数几乎没有给予任何关注,更不用说优化它们了。然而,超参数对机器学习模型的性能有着巨大的影响。事实上,许多机器学习模型都有一个“甜蜜点”超参数,使它们最有效,而使用超参数集在这个点之外可能会使模型表现不佳。

许多机器学习从业者通过手动调整超参数来调整超参数。这意味着你从一组看起来合理的超参数开始,并在验证集上测量模型的性能。然后,您稍微改变一个或多个超参数,并再次测量性能。您重复这个过程几次,直到达到“高原”,在这里任何超参数的更改都只提供了边际改进。

这种手动调整方法的一个问题是它是缓慢和随意的。假设你从一组超参数开始。你如何知道接下来应该调整哪些参数,以及多少?你如何知道何时停止?如果你有调整广泛的机器学习模型的经验,你可能对这些模型如何响应某些超参数更改有一些“直觉”,但如果没有,那就像在黑暗中射击一样。超参数调整是一个非常重要的主题,机器学习研究人员一直致力于寻找更好和更有组织的方法来优化它们。

10.5.2 网格搜索 vs. 随机搜索

我们明白手动优化超参数效率低下,但是我们应该如何进行优化呢?我们有两种更有组织的调整超参数的方式——网格搜索和随机搜索。

网格搜索中,你只需尝试优化的超参数值的每种可能组合。例如,假设你的模型只有两个超参数——RNN 层数和嵌入维度。你首先为这两个超参数定义合理的范围,例如,层数为[1, 2, 3],维度为[128, 256, 512]。然后,网格搜索会对每种组合进行模型验证性能的测量——(1, 128), (1, 256), (1, 512), (2, 128), . . . , (3, 512)——并简单选择表现最佳的组合。如果你将这些组合绘制在二维图上,它看起来像一个网格(见图 10.15 的示例),这就是为什么称之为网格搜索

网格搜索是优化超参数的一种简单直观的方式。然而,如果你有很多超参数和/或它们的范围很大,这种方法就会失控。可能的组合数量是指数级的,这使得在合理的时间内探索所有组合变得不可能。

CH10_F15_Hagiwara

图 10.15 网格搜索与随机搜索的超参数调优比较。(摘自 Bergstra 和 Bengio,2012;www.jmlr.org/papers/volume13/bergstra12a/bergstra12a.pdf.

网格搜索更好的替代方案是随机搜索。在随机搜索中,你不是尝试每种可能的超参数值的组合,而是随机抽样这些值,并在指定数量的组合(称为试验)上测量模型的性能。例如,在上述示例中,随机搜索可以选择(2, 87), (1, 339), (2, 101), (3, 254)等,直到达到指定数量的试验为止。请参见图 10.15 的示例(右侧)。

除非你的超参数搜索空间非常小(就像第一个示例一样),如果你想要高效地优化超参数,通常建议使用随机搜索而不是网格搜索。为什么?在许多机器学习设置中,并非每个超参数都是相等的——通常只有少数几个超参数实际上对性能有影响,而其他许多超参数则不然。网格搜索会浪费大量计算资源来寻找并不真正重要的超参数组合,同时无法详细探索那些真正重要的少数超参数(图 10.15,左侧)。另一方面,随机搜索可以在性能重要的轴上探索许多可能的点(图 10.15,右侧)。请注意,随机搜索可以通过在相同的试验数量下在 x 轴上探索更多点来找到更好的模型(总共九个试验)。

10.5.3 使用 Optuna 进行超参数调优

好的,我们已经介绍了一些调整超参数的方法,包括手动、网格和随机搜索,但是在实践中应该如何实现呢?你可以随时编写自己的 for 循环(或者在网格搜索的情况下是“for-loops”),尽管如果你需要为每个模型和任务编写这种样板代码,这将很快变得令人厌倦。

超参数优化是一个普遍的主题,许多机器学习研究人员和工程师一直在致力于改进算法和软件库。例如,AllenNLP 有自己的库叫做Allentunegithub.com/allenai/allentune),你可以很容易地将其与 AllenNLP 的训练流程集成起来。然而,在本节的剩余部分中,我将介绍另一个超参数调整库叫做Optunaoptuna.org/),并展示如何将其与 AllenNLP 一起使用以优化你的超参数。Optuna 实现了最先进的算法,可以高效地搜索最优超参数,并与包括 TensorFlow、PyTorch 和 AllenNLP 在内的广泛的机器学习框架集成。

首先,我们假设你已经安装了 AllenNLP(1.0.0+)和 AllenNLP 的 Optuna 插件。你可以通过运行以下命令来安装它们:

pip install allennlp
pip install allennlp_optuna

此外,根据官方文档的指示(github.com/himkt/allennlp -optuna),你需要运行下面的代码来注册 AllenNLP 的插件:

echo 'allennlp_optuna' >> .allennlp_plugins

我们将使用第二章中构建的基于 LSTM 的分类器对斯坦福情感树库数据集进行分类。你可以在书的代码库中找到 AllenNLP 的配置文件(www.realworldnlpbook.com/ch10.html#config)。注意,你需要引用变量(std.extVar)以便 Optuna 可以控制参数。具体来说,你需要在配置文件的开头定义它们:

local embedding_dim = std.parseJson(std.extVar('embedding_dim'));
local hidden_dim = std.parseJson(std.extVar('hidden_dim'));
local lr = std.parseJson(std.extVar('lr'));

然后,你需要告诉 Optuna 要优化哪些参数。你可以通过编写一个 JSON 文件(hparams.json (www.realworldnlpbook.com/ch10.html# hparams)来实现这一点。你需要指定你希望 Optuna 优化的每个超参数及其类型和范围,如下所示:

[
    {
        "type": "int",
        "attributes": {
            "name": "embedding_dim",
            "low": 64,
            "high": 256
        }
    },
    {
        "type": "int",
        "attributes": {
            "name": "hidden_dim",
            "low": 64,
            "high": 256
        }
    },
    {
        "type": "float",
        "attributes": {
            "name": "lr",
            "low": 1e-4,
            "high": 1e-1,
            "log": true
        }
    }
]

接下来,调用这个命令来开始优化:

allennlp tune \
    examples/tuning/sst_classifier.jsonnet \
    examples/tuning/hparams.json \
    --include-package examples \
    --serialization-dir result \
    --study-name sst-lstm \
    --n-trials 20 \
    --metrics best_validation_accuracy \
    --direction maximize

注意我们正在运行 20 次试验(—n-trials),以最大化验证准确性(—metrics best_validation_accuracy)作为度量标准(—direction maximize)。如果你没有指定度量标准和方向,Optuna 默认尝试最小化验证损失。

这将需要一些时间,但是在所有试验完成后,你将看到以下优化的一行摘要:

Trial 19 finished with value: 0.3469573115349682 and parameters: {'embedding_dim': 120, 'hidden_dim': 82, 'lr': 0.00011044322486693224}. Best is trial 14 with value: 0.3869209809264305.

最后,Optuna 支持广泛的优化结果可视化,包括非常好的等高线图(www.realworldnlpbook.com/ch10.html# contour),但在这里我们将简单地使用其基于 Web 的仪表板快速检查优化过程。你只需要按照以下命令从命令行调用其仪表板:

optuna dashboard --study-name sst-lstm --storage sqlite:///allennlp_optuna.db

现在,你可以访问 http:/./localhost:5006/dashboard 来查看仪表板,如图 10.16 所示。

CH10_F16_Hagiwara

图 10.16 Optuna 仪表板显示了每个试验的参数评估指标。

从这个仪表板上,你不仅可以迅速看到最优试验是第 14 次试验,而且可以看到每次试验的最优超参数。

摘要

  • 实例被排序、填充和批量化以进行更有效的计算。

  • 子单词分词算法(如 BPE)将单词拆分成比单词更小的单元,以减轻神经网络模型中的词汇外问题。

  • 正则化(如 L2 和 dropout)是一种用于鼓励机器学习中模型简单性和可泛化性的技术。

  • 你可以使用数据上采样、下采样或损失权重来解决数据不平衡问题。

  • 超参数是关于模型或训练算法的参数。可以通过手动、网格或随机搜索进行优化。更好的是,使用超参数优化库,如 Optuna,它与 AllenNLP 集成得很容易。

^(1.)Wu 等人,“谷歌神经机器翻译系统:填补人机翻译之间的差距”(2016)。arxiv.org/abs/1609.08144

^(2.)Kudo,“Subword Regularization:使用多个子单词提高神经网络翻译模型”(2018)。arxiv.org/abs/1804.10959

^(3.)Sennrich 等人,“使用子单词单元进行稀有词的神经机器翻译”(2016)。arxiv.org/abs/1508.07909

^(4.)参见www.derczynski.com/papers/archive/BPE_Gage.pdf

^(5.)Chawla 等人,“SMOTE:合成少数类过采样技术”(2002)。arxiv.org/abs/1106.1813

第十一章:部署和提供 NLP 应用程序

本章涵盖

  • 选择适合您的 NLP 应用程序的正确架构

  • 版本控制您的代码、数据和模型

  • 部署和提供您的 NLP 模型

  • 使用 LIT(Language Interpretability Tool)解释和分析模型预测

本书的第 1 至 10 章是关于构建 NLP 模型的,而本章涵盖的是不在 NLP 模型之外发生的一切。为什么这很重要?难道 NLP 不都是关于构建高质量的 ML 模型吗?如果您没有太多生产 NLP 系统的经验,这可能会让您感到惊讶,但典型现实世界的 ML 系统的很大一部分与 NLP 几乎没有关系。如图 11.1 所示,典型实际 ML 系统的只有一小部分是 ML 代码,但“ML 代码”部分由提供各种功能的许多组件支持,包括数据收集、特征提取和服务。让我们用核电站作为类比。在操作核电站时,只有一小部分涉及核反应。其他一切都是支持安全有效地生成和传输材料和电力的庞大而复杂的基础设施——如何利用生成的热量转动涡轮发电,如何安全冷却和循环水,如何高效传输电力等等。所有这些支持基础设施与核物理几乎无关。

CH11_F01_Hagiwara

图 11.1 一个典型的 ML 系统由许多不同的组件组成,而 ML 代码只是其中的一小部分。我们在本章中介绍了突出显示的组件。

部分原因是由于大众媒体上的“人工智能炒作”,我个人认为人们过分关注 ML 建模部分,而对如何以有用的方式为模型提供服务关注不足。毕竟,您的产品的目标是向用户提供价值,而不是仅仅为他们提供模型的原始预测。即使您的模型准确率达到 99%,如果您无法充分利用预测,使用户受益,那么它就没有用。用之前的类比来说,用户想要用电来驱动家用电器并照亮房屋,而不太在意电是如何生成的。

在本章的其余部分,我们将讨论如何构建您的 NLP 应用程序——我们侧重于在可靠和有效的方式设计和开发 NLP 应用程序时的一些最佳实践。然后我们谈论部署您的 NLP 模型——这是我们如何将 NLP 模型投入生产并提供其预测的方法。

11.1 构建您的 NLP 应用程序架构

机器学习工程仍然是软件工程。所有最佳实践(解耦的软件架构、设计良好的抽象、清晰易读的代码、版本控制、持续集成等)同样适用于 ML 工程。在本节中,我们将讨论一些特定于设计和构建 NLP/ML 应用程序的最佳实践。

11.1.1 机器学习之前

我明白这是一本关于 NLP 和 ML 的书,但在您开始着手处理您的 NLP 应用程序之前,您应该认真考虑您是否真的需要 ML 来解决您的产品问题。构建一个 ML 系统并不容易——需要花费大量的时间和金钱来收集数据、训练模型和提供预测。如果您可以通过编写一些规则来解决问题,那就这样做吧。作为一个经验法则,如果深度学习模型可以达到 80% 的准确率,那么一个更简单的基于规则的模型至少可以将您带到一半的路上。

此外,如果有现成的解决方案,您应该考虑使用。许多开源的 NLP 库(包括我们在整本书中广泛使用的 AllenNLP 和 Transformers 两个库)都提供了各种预训练模型。云服务提供商(如 AWS AI 服务 (aws.amazon.com/machine-learning/ai-services/)、Google Cloud AutoML (cloud.google.com/automl) 和 Microsoft Azure Cognitive Services (azure.microsoft.com/en-us/services/cognitive-services/))为许多领域提供了广泛的与 ML 相关的 API,包括 NLP。如果您的任务可以通过它们提供的解决方案进行零或少量修改来解决,那通常是构建 NLP 应用的一种成本效益较高的方式。毕竟,任何 NLP 应用程序中最昂贵的组件通常是高技能人才(即您的工资),在您全力投入并构建内部 NLP 解决方案之前,您应该三思而后行。

此外,您不应排除“传统”的机器学习方法。在本书中,我们很少关注传统的 ML 模型,但在深度 NLP 方法出现之前,您可以找到丰富的统计 NLP 模型的文献。使用统计特征(例如 n-gram)和 ML 模型(例如 SVM)快速构建原型通常是一个很好的开始。非深度学习算法,例如 梯度提升决策树(GBDT),通常以比深度学习方法更低的成本几乎同样有效,如果不是更好。

最后,我始终建议从开发验证集和选择正确的评估指标开始,甚至在开始选择正确的 ML 方法之前。验证集不需要很大,大多数人都可以抽出几个小时手动注释几百个实例。这样做有很多好处——首先,通过手动解决任务,你可以感受到在解决问题时什么是重要的,以及是否真的可以自动解决。其次,通过把自己置于机器的角度,你可以获得许多关于任务的见解(数据是什么样子,输入和输出数据是如何分布的,它们是如何相关的),这在实际设计 ML 系统来解决它时变得有价值。

11.1.2 选择正确的架构

除了极少数情况下,ML 系统的输出本身就是最终产品(比如机器翻译)之外,NLP 模块通常与一个更大的系统交互,共同为最终用户提供一些价值。例如,垃圾邮件过滤器通常被实现为嵌入在更大的应用程序(邮件服务)中的模块或微服务。语音助手系统通常是许多 ML/NLP 子组件的大型、复杂组合,包括语音识别、句子意图分类、问答和语音生成,它们相互交互。即使是机器翻译模型,如果包括数据管道、后端和最终用户交互的翻译界面,也可以是更大复杂系统中的一个小组件。

NLP 应用可以采取多种形式。令人惊讶的是,许多 NLP 组件可以被构造为一次性任务,它以一些静态数据作为输入,产生转换后的数据作为输出。例如,如果你有一组文档的静态数据库,并且想要按其主题对它们进行分类,你的 NLP 分类器可以是一个简单的一次性 Python 脚本,运行这个分类任务。如果你想要从同一数据库中提取通用实体(例如公司名称),你可以编写一个 Python 脚本来运行一个命名实体识别(NER)模型来实现。甚至一个基于文本相似度找到对象的文本推荐引擎也可以是一个每日任务,它从数据库读取数据并写入数据。你不需要设计一个复杂的软件系统,其中有许多服务相互交流。

许多其他 NLP 组件可以被构造成批量运行预测的(微)服务,这是我推荐的许多场景的架构。例如,垃圾邮件过滤器并不需要在每封邮件到达时立即对其进行分类 - 系统可以将到达系统的一定数量的邮件排队,并将批处理的邮件传递给分类器服务。NLP 应用程序通常通过某种中介(例如 RESTful API 或排队系统)与系统的其余部分进行通信。这种配置非常适合需要对其预测保持一定新鲜度的应用程序(毕竟,用户不希望等待几个小时直到他们的电子邮件到达收件箱),但要求并不那么严格。

最后,NLP 组件也可以设计成为提供实时预测的方式。例如,当观众需要演讲的实时字幕时,这是必要的。另一个例子是当系统想要根据用户的实时行为显示广告时。对于这些情况,NLP 服务需要接收一系列输入数据(如音频或用户事件),并生成另一系列数据(如转录文本或广告点击概率)。诸如 Apache Flink (flink.apache.org/) 这样的实时流处理框架经常用于处理此类流数据。另外,如果您的应用程序基于服务器-客户端架构,例如典型的移动和 Web 应用程序,并且您想向用户显示一些实时预测,您可以选择在客户端上运行 ML/NLP 模型,例如 Web 浏览器或智能手机。诸如 TensorFlow.js (www.tensorflow.org/js)、Core ML (developer.apple.com/documentation/coreml) 和 ML Kit (developers.google.com/ml-kit) 这样的客户端 ML 框架可用于此类目的。

11.1.3 项目结构

许多 NLP 应用程序遵循着类似的项目结构。一个典型的 NLP 项目可能需要管理数据集以从中训练模型,预处理数据生成的中间文件,由训练产生的模型文件,用于训练和推断的源代码,以及存储有关训练和推断的其他信息的日志文件。

因为典型的 NLP 应用程序有许多共同的组件和目录,所以如果您在启动新项目时只是遵循最佳实践作为默认选择,那将是有用的。以下是我为组织您的 NLP 项目提出的建议:

  • 数据管理—创建一个名为 data 的目录,并将所有数据放入其中。将其进一步细分为原始、中间和结果目录可能也会有所帮助。原始目录包含您外部获取的未经处理的数据集文件(例如我们在本书中一直在使用的斯坦福情感树库)或内部构建的文件。非常重要的一点是不要手动修改此原始目录中的任何文件。如果需要进行更改,请编写一个运行一些处理以针对原始文件运行的脚本,然后将结果写入中间目录的脚本,该目录用作中间结果的存储位置。或者创建一个管理您对原始文件进行的“差异”的补丁文件,并将补丁文件进行版本控制。最终的结果,例如预测和指标,应存储在结果目录中。

  • 虚拟环境—强烈建议您在虚拟环境中工作,以便您的依赖项分开且可重现。您可以使用诸如 Conda (docs.conda.io/en/latest/)(我推荐的)和 venv (docs.python.org/3/library/venv.html) 等工具为您的项目设置一个单独的环境,并使用 pip 安装单个软件包。Conda 可以将环境配置导出到一个 environment.yml 文件中,您可以使用该文件来恢复确切的 Conda 环境。您还可以将项目的 pip 包跟踪在一个 requirements.txt 文件中。更好的是,您可以使用 Docker 容器来管理和打包整个 ML 环境。这极大地减少了与依赖项相关的问题,并简化了部署和服务化。

  • 实验管理—NLP 应用程序的训练和推理管道通常包括多个步骤,例如预处理和连接数据,将其转换为特征,训练和运行模型,以及将结果转换回人类可读格式。如果试图手动记住管理这些步骤,很容易失控。一个好的做法是在一个 shell 脚本文件中跟踪管道的步骤,以便只需一个命令即可重现实验,或者使用依赖管理软件,如 GNU Make、Luigi (github.com/spotify/luigi) 和 Apache Airflow (airflow.apache.org/)。

  • 源代码—Python 源代码通常放在与项目同名的目录中,该目录进一步细分为诸如 data(用于数据处理代码)、model(用于模型代码)和 scripts(用于放置用于训练和其他一次性任务的脚本)等目录。

11.1.4 版本控制

您可能不需要说服您版本控制您的源代码很重要。像 Git 这样的工具帮助您跟踪变更并管理源代码的不同版本。NLP/ML 应用程序的开发通常是一个迭代过程,在此过程中,您(通常与其他人)对源代码进行许多更改,并尝试许多不同的模型。您很容易最终拥有一些略有不同版本的相同代码。

除了对源代码进行版本控制外,对数据和模型进行版本控制也很重要。这意味着您应该分别对训练数据、源代码和模型进行版本控制,如图 11.2 中虚线框所示。这是常规软件项目和机器学习应用之间的主要区别之一。机器学习是通过数据改进计算机算法的过程。根据定义,任何机器学习系统的行为都取决于其所接收的数据。这可能会导致即使您使用相同的代码,系统的行为也会有所不同的情况。

工具如 Git Large File Storage (git-lfs.github.com/)和 DVC (dvc.org)可以以无缝的方式对数据和模型进行版本控制。即使您不使用这些工具,您也应该至少将不同版本作为清晰命名的单独文件进行管理。

CH11_F02_Hagiwara

图 11.2 机器学习组件的版本控制:训练数据、源代码和模型

在一个更大更复杂的机器学习项目中,您可能希望将模型和特征管道的版本控制分开,因为机器学习模型的行为可能会因为您对输入进行预处理的方式不同而不同,即使是相同的模型和输入数据。这也将减轻我们稍后将在 11.3.2 节讨论的训练服务偏差问题。

最后,当您在机器学习应用上工作时,您将尝试许多不同的设置——不同的训练数据集、特征管道、模型和超参数的组合——这可能会很难控制。我建议您使用一些实验管理系统来跟踪训练设置,例如 Weights & Biases (wandb.ai/),但您也可以使用像手动输入实验信息的电子表格这样简单的东西。在跟踪实验时,请务必记录每个实验的以下信息:

  • 使用的模型代码版本、特征管道和训练数据的版本

  • 用于训练模型的超参数

  • 训练数据和验证数据的评估指标

像 AllenNLP 这样的平台默认支持实验配置,这使得前两项变得容易。工具如 TensorBoard,它们默认由 AllenNLP 和 Hugging Face 支持,使得跟踪各种指标变得轻而易举。

11.2 部署您的 NLP 模型

在本节中,我们将进入部署阶段,将您的 NLP 应用程序放在服务器上,并可供使用。我们将讨论部署 NLP/ML 应用程序时的实际考虑因素。

11.2.1 测试

与软件工程一样,测试是构建可靠的 NLP/ML 应用程序的重要组成部分。最基本和重要的测试是单元测试,它们自动检查软件的小单元(如方法和类)是否按预期工作。在 NLP/ML 应用程序中,对功能管道进行单元测试非常重要。例如,如果你编写了一个将原始文本转换为张量表示的方法,请确保它在典型和边界情况下都能正常工作。根据我的经验,这往往是错误 sneak in 的地方。从数据集读取、从语料库构建词汇表、标记化、将标记转换为整数 ID —— 这些都是预处理中必不可少但容易出错的步骤。幸运的是,诸如 AllenNLP 等框架为这些步骤提供了标准化、经过充分测试的组件,这使得构建 NLP 应用程序更加容易和无 bug。

除了单元测试之外,你还需要确保你的模型学到了它应该学到的东西。这对应于测试常规软件工程中的逻辑错误 —— 即软件运行时没有崩溃但产生了不正确的结果的错误类型。这种类型的错误在 NLP/ML 中更难捕捉和修复,因为你需要更多的了解学习算法在数学上是如何工作的。此外,许多 ML 算法涉及一些随机性,如随机初始化和抽样,这使得测试变得更加困难。

一个推荐的测试 NLP/ML 模型的技术是对模型输出进行 sanity checks。你可以从一个小而简单的模型开始,只使用几个带有明显标签的玩具实例。例如,如果你正在测试情感分析模型,可以按照以下步骤进行:

  • 为调试创建一个小而简单的模型,比如一个简单的玩具编码器,它只是将输入的单词嵌入平均化,并在顶部使用一个 softmax 层。

  • 准备一些玩具实例,比如“最棒的电影!”(积极)和“这是一部糟糕的电影!”(消极)。

  • 将这些实例提供给模型,并训练直到收敛。由于我们使用的是一个非常小的数据集,没有验证集,所以模型会严重过拟合到这些实例上,这完全可以接受。检查训练损失是否如预期下降。

  • 将相同的实例提供给训练好的模型,检查预测的标签是否与预期的标签匹配。

  • 使用更多玩具实例和更大的模型尝试上述步骤。

作为一种相关技术,我总是建议您从较小的数据集开始,特别是如果原始数据集很大。因为训练自然语言处理/机器学习模型需要很长时间(几小时甚至几天),您经常会发现只有在训练完成后才能发现代码中的一些错误。您可以对训练数据进行子采样,例如,只需取出每 10 个实例中的一个,以便整个训练过程能够迅速完成。一旦您确信您的模型按预期工作,您可以逐渐增加用于训练的数据量。这种技术也非常适合快速迭代和尝试许多不同的架构和超参数设置。当您刚开始构建模型时,您通常不清楚最适合您任务的最佳模型。有了较小的数据集,您可以快速验证许多不同的选项(RNN 与 Transformers,不同的分词器等),并缩小最适合的候选模型集。这种方法的一个警告是,最佳模型架构和超参数可能取决于训练数据的大小。因此,请不要忘记针对完整数据集运行验证。

最后,您可以使用集成测试来验证应用程序的各个组件是否结合正常工作。对于自然语言处理(NLP),这通常意味着运行整个流程,以查看预测是否正确。与单元测试类似,您可以准备一小部分实例,其中期望的预测是明确的,并将它们运行到经过训练的模型上。请注意,这些实例不是用于衡量模型的好坏,而是作为一个合理性检查,以确定您的模型是否能够为“显而易见”的情况产生正确的预测。每次部署新模型或代码时运行集成测试是一个好习惯。这通常是用于常规软件工程的持续集成(CI)的一部分。

11.2.2 训练-服务偏差

机器学习应用中常见的错误来源之一被称为训练-服务偏差,即在训练和推理时实例处理方式存在差异的情况。这可能发生在各种情况下,但让我们讨论一个具体的例子。假设您正在使用 AllenNLP 构建一个情感分析系统,并希望将文本转换为实例。您通常首先编写一个数据加载器,它读取数据集并生成实例。然后您编写一个 Python 脚本或配置文件,告诉 AllenNLP 模型应该如何训练。您对模型进行训练和验证。到目前为止,一切顺利。然而,当使用模型进行预测时,情况略有不同。您需要编写一个预测器,它会将输入文本转换为实例,并将其传递给模型的前向方法。请注意,现在您有两个独立的流程来预处理输入——一个用于数据集读取器中的训练,另一个用于预测器中的推理。

如果你想修改输入文本处理的方式会发生什么?例如,假设你发现了你想改进的分词过程中的某些内容,并且你在数据加载器中修改了输入文本的分词方式。你更新了数据加载器代码,重新训练了模型,并部署了模型。然而,你忘记了在预测器中更新相应的分词代码,实际上在训练和服务之间创建了一个输入分词方式不一致的差异。这在图 11.3 中有所说明。

CH11_F03_Hagiwara

图 11.3 训练-服务偏差是由于训练和服务之间输入处理方式的差异而引起的。

修复这个问题的最佳方法——甚至更好的是,第一次就预防它发生——是在训练和服务基础设施之间尽可能共享特征管道。在 AllenNLP 中的一种常见做法是在数据集读取器中实现一个名为 _text_to_instance()的方法,它接受一个输入并返回一个实例。通过确保数据集读取器和预测器都引用同一个方法,你可以尽量减少管道之间的差异。

在 NLP 中,输入文本被分词并转换为数字值的事实使得调试模型变得更加困难。例如,一个在分词中明显的错误,你可以用肉眼轻松发现,但如果一切都是数字值,那么很难识别。一个好的做法是将一些中间结果记录到一个日志文件中,以便稍后检查。

最后,请注意,神经网络在训练和服务之间的一些行为是不同的。一个显著的例子是dropout,一个我们在第 10.3.1 节中简要介绍过的正则化方法。简而言之,dropout 通过在神经网络中随机屏蔽激活值来对模型进行正则化。这在训练中是有道理的,因为通过去除激活,模型学会根据可用值做出稳健的预测。但是,请记住在服务时关闭它,因为你不希望你的模型随机丢弃神经元。PyTorch 模型实现了 train()和 eval()等方法,可以在训练和预测模式之间切换,从而影响像 dropout 这样的层的行为。如果你手动实现了训练循环,请记住调用 model.eval()来禁用 dropout。好消息是,诸如 AllenNLP 之类的框架可以自动处理这个问题,只要你使用它们的默认训练器。

11.2.3 监控

与其他软件服务一样,部署的 ML 系统应该持续监控。除了通常的服务器指标(例如,CPU 和内存使用率)之外,您还应该监视与模型的输入和输出相关的指标。具体来说,您可以监视一些高级统计信息,如输入值和输出标签的分布。正如前面提到的,逻辑错误是一种导致模型产生错误结果但不会崩溃的错误类型,在 ML 系统中最常见且最难找到。监控这些高级统计信息可以更容易地找到它们。像 PyTorch Serve 和 Amazon SageMaker(在第 11.3 节讨论)这样的库和平台默认支持监控。

11.2.4 使用 GPU

训练大型现代 ML 模型几乎总是需要像 GPU 这样的硬件加速器。回想一下第二章中,我们将海外工厂比作了 GPU 的类比,GPU 设计用于并行执行大量的算术运算,如向量和矩阵的加法和乘法。在本小节中,我们将介绍如何使用 GPU 加速 ML 模型的训练和预测。

如果您没有自己的 GPU 或以前从未使用过基于云的 GPU 解决方案,免费“尝试” GPU 的最简单方法是使用 Google Colab。转到其 URL(colab.research.google.com/),创建一个新笔记本,转到“运行时”菜单,并选择“更改运行时类型”。这将弹出如图 11.4 所示的对话框。

CH11_F04_Hagiwara

图 11.4 Google Colab 允许您选择硬件加速器的类型。

选择 GPU 作为硬件加速器的类型,并在代码块中输入 !nvidia-smi 并执行它。将显示一些关于您的 GPU 的详细信息,如下所示:

+-----------------------------------------------------------------------------+
| NVIDIA-SMI 460.56       Driver Version: 460.32.03    CUDA Version: 11.2     |
|-------------------------------+----------------------+----------------------+
| GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp  Perf  Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |
|                               |                      |               MIG M. |
|===============================+======================+======================|
|   0  Tesla T4            Off  | 00000000:00:04.0 Off |                    0 |
| N/A   39C    P8     9W /  70W |      3MiB / 15109MiB |      0%      Default |
|                               |                      |                  N/A |
+-------------------------------+----------------------+----------------------+

+-----------------------------------------------------------------------------+
| Processes:                                                                  |
|  GPU   GI   CI        PID   Type   Process name                  GPU Memory |
|        ID   ID                                                   Usage      |
|=============================================================================|
|  No running processes found                                                 |
+-----------------------------------------------------------------------------+

nvidia-smi 命令(简称 Nvidia 系统管理接口)是一个方便的工具,用于检查机器上 Nvidia GPU 的信息。从上面的代码片段中,您可以看到驱动程序和 CUDA(一种用于与 GPU 交互的 API 和库)的版本、GPU 类型(Tesla T4)、可用和已使用的内存(15109 MiB 和 3 MiB),以及当前使用 GPU 的进程列表(没有)。这个命令的最典型用法是检查当前进程使用了多少内存,因为在 GPU 编程中,如果您的程序使用的内存超过了可用内存,很容易出现内存不足的错误。

如果你使用云基础设施,比如 AWS(Amazon Web Services)和 GCP(Google Cloud Platform),你会发现有很多虚拟机模板,可以用来快速创建支持 GPU 的云实例。例如,GCP 提供了 Nvidia 官方的 GPU 优化图像,可以用作模板来启动 GPU 实例。AWS 提供了深度学习 AMIs(Amazon Machine Images),预先安装了基本的 GPU 库,如 CUDA,以及深度学习库,如 PyTorch。使用这些模板时,你不需要手动安装必要的驱动程序和库——你可以直接开始构建你的 ML 应用程序。请注意,尽管这些模板是免费的,但你需要为基础设施付费。支持 GPU 的虚拟机的价格通常比 CPU 机器高得多。在长时间运行之前,请确保检查它们的价格。

如果你要从头开始设置 GPU 实例,你可以找到详细的说明 ¹ 来设置必要的驱动程序和库。要使用本书中介绍的库(即,AllenNLP 和 Transformers)构建 NLP 应用程序,你需要安装 CUDA 驱动程序和工具包,以及支持 GPU 的 PyTorch 版本。

如果你的机器有 GPU,你可以通过在 AllenNLP 配置文件中指定 cuda_device 来启用 GPU 加速,如下所示:

    "trainer": {
        "optimizer": {
            "type": "huggingface_adamw",
            "lr": 1.0e-5
        },
        "num_epochs": 20,
        "patience": 10,
        "cuda_device": 0
}

这告诉训练器使用第一个 GPU 训练和验证 AllenNLP 模型。

如果你要从头开始编写 PyTorch 代码,你需要手动将模型和张量转移到 GPU 上。用比喻来说,这就像是你的材料被运往海外工厂的集装箱船上。首先,你可以指定要使用的设备(GPU ID),并调用张量和模型的 to()方法在设备之间移动它们。例如,你可以使用以下代码片段在使用 Hugging Face Transformers 的 GPU 上运行文本生成:

device = torch.device('cuda:0')
tokenizer = AutoTokenizer.from_pretrained("gpt2-large")
model = AutoModelWithLMHead.from_pretrained("gpt2-large")

generated = tokenizer.encode("On our way to the beach ")
context = torch.tensor([generated])

model = model.to(device)
context = context.to(device)

其余的与我们在第 8.4 节中使用的代码相同。

11.3 案例研究:提供和部署 NLP 应用程序

在本节中,我们将对一个案例研究进行概述,在其中,我们使用 Hugging Face 构建了一个 NLP 模型。具体地说,我们将使用预训练的语言生成模型(DistilGPT2),使用 TorchServe 进行服务,并使用 Amazon SageMaker 部署到云服务器。

11.3.1 用 TorchServe 提供模型

如你所见,部署 NLP 应用程序不仅仅是编写 ML 模型的 API。你需要考虑许多与生产相关的问题,包括如何使用多个 worker 并行化模型推理来处理高流量,如何存储和管理多个 ML 模型的不同版本,如何一致地处理数据的预处理和后处理,并且如何监视服务器的健康状况以及数据的各种指标。

由于这些问题如此常见,机器学习从业者一直在研究用于服务和部署机器学习模型的通用平台。在本节中,我们将使用 TorchServe (github.com/pytorch/serve),这是一个由 Facebook 和 Amazon 共同开发的用于服务 PyTorch 模型的易于使用的框架。TorchServe 附带了许多功能,可以解决前面提到的问题。

TorchServe 可通过以下方式安装:

pip install torchserve torch-model-archiver

在这个案例研究中,我们将使用一个名为 DistilGPT2 的预训练语言模型。DistilGPT2 是使用一种称为 知识蒸馏 的技术构建的 GPT-2 的较小版本。知识蒸馏(或简称 蒸馏)是一种机器学习技术,其中一个较小的模型(称为 学生)被训练成以模仿一个较大模型(称为 教师)产生的预测。这是训练一个产生高质量输出的较小模型的绝佳方式,通常比从头开始训练一个较小模型产生更好的模型。

首先,让我们通过运行以下命令从 Hugging Face 仓库下载预训练的 DistilGPT2 模型。请注意,您需要安装 Git Large File Storage (git-lfs.github.com/),这是一个用于处理 Git 下大文件的 Git 扩展:

git lfs install
git clone https://huggingface.co/distilgpt2

这将创建一个名为 distilgpt2 的子目录,其中包含 config.json 和 pytorch_model.bin 等文件。

接下来,您需要为 TorchServe 编写一个处理程序,这是一个轻量级的包装类,指定了如何初始化您的模型、预处理和后处理输入以及对输入进行推断。清单 11.1 显示了用于服务 DistilGPT2 模型的处理程序代码。实际上,处理程序中的任何内容都不特定于我们使用的特定模型(DistilGPT2)。只要使用 Transformers 库,您就可以将相同的代码用于其他类似 GPT-2 的模型,包括原始的 GPT-2 模型。

清单 11.1 TorchServe 的处理程序

from abc import ABC
import logging

import torch
from ts.torch_handler.base_handler import BaseHandler

from transformers import GPT2LMHeadModel, GPT2Tokenizer

logger = logging.getLogger(__name__)

class TransformersLanguageModelHandler(BaseHandler, ABC):
    def __init__(self):
        super(TransformersLanguageModelHandler, self).__init__()
        self.initialized = False
        self.length = 256
        self.top_k = 0
        self.top_p = .9
        self.temperature = 1.
        self.repetition_penalty = 1.

    def initialize(self, ctx):                        ❶
        self.manifest = ctx.manifest
        properties = ctx.system_properties
        model_dir = properties.get("model_dir")
        self.device = torch.device(
            "cuda:" + str(properties.get("gpu_id"))
            if torch.cuda.is_available()
            else "cpu"
        )

        self.model = GPT2LMHeadModel.from_pretrained(model_dir)
        self.tokenizer = GPT2Tokenizer.from_pretrained(model_dir)

        self.model.to(self.device)
        self.model.eval()

        logger.info('Transformer model from path {0} loaded successfully'.format(model_dir))
        self.initialized = True

    def preprocess(self, data):                   ❷
        text = data[0].get("data")
        if text is None:
            text = data[0].get("body")
        text = text.decode('utf-8')

        logger.info("Received text: '%s'", text)

        encoded_text = self.tokenizer.encode(
            text,
            add_special_tokens=False,
            return_tensors="pt")

        return encoded_text

    def inference(self, inputs):                  ❸
        output_sequences = self.model.generate(
            input_ids=inputs.to(self.device),
            max_length=self.length + len(inputs[0]),
            temperature=self.temperature,
            top_k=self.top_k,
            top_p=self.top_p,
            repetition_penalty=self.repetition_penalty,
            do_sample=True,
            num_return_sequences=1,
        )

        text = self.tokenizer.decode(
            output_sequences[0],
            clean_up_tokenization_spaces=True)

        return [text]

    def postprocess(self, inference_output):      ❹
        return inference_output

_service = TransformersLanguageModelHandler()

def handle(data, context):                        ❺
    try:
        if not _service.initialized:
            _service.initialize(context)

        if data is None:
            return None

        data = _service.preprocess(data)
        data = _service.inference(data)
        data = _service.postprocess(data)

        return data
    except Exception as e:
        raise e

❶ 初始化模型

❷ 对传入数据进行预处理和标记化

❸ 对数据进行推断

❹ 对预测进行后处理

❺ TorchServe 调用的处理程序方法

您的处理程序需要继承自 BaseHandler 并重写一些方法,包括 initialize() 和 inference()。您的处理程序脚本还包括 handle(),一个顶层方法,其中初始化和调用处理程序。

接下来要做的是运行 torch-model-archiver,这是一个命令行工具,用于打包您的模型和处理程序,具体操作如下:

torch-model-archiver \
    --model-name distilgpt2 \
    --version 1.0 \
    --serialized-file distilgpt2/pytorch_model.bin \
    --extra-files "distilgpt2/config.json,distilgpt2/vocab.json,distilgpt2/tokenizer.json,distilgpt2/merges.txt" \
    --handler ./torchserve_handler.py

前两个选项指定了模型的名称和版本。下一个选项 serialized-file 指定了您要打包的 PyTorch 模型的主要权重文件(通常以 .bin 或 .pt 结尾)。您还可以添加任何额外文件(由 extra-files 指定),这些文件是模型运行所需的。最后,您需要将刚编写的处理程序文件传递给 handler 选项。

完成后,这将在相同目录中创建一个名为 distilgpt2.mar(.mar 代表“模型归档”)的文件。让我们创建一个名为 model_store 的新目录,并将 .mar 文件移动到那里,如下所示。该目录用作模型存储库,所有模型文件都存储在其中并从中提供服务:

mkdir model_store
mv distilgpt2.mar model_store

现在您已经准备好启动 TorchServe 并开始为您的模型提供服务了!您只需运行以下命令:

torchserve --start --model-store model_store --models distilgpt2=distilgpt2.mar

当服务器完全启动后,您可以开始向服务器发出 HTTP 请求。它公开了几个端点,但如果您只想运行推断,您需要像下面这样调用 http://127.0.0.1:8080/predictions/ 并带上模型名称:

curl -d "data=In a shocking finding, scientist discovered a herd of unicorns living in a remote, previously unexplored valley, in the Andes Mountains. Even more surprising to the researchers was the fact that the unicorns spoke perfect English." -X POST http://127.0.0.1:8080/predictions/distilgpt2

在这里,我们使用了来自 OpenAI 关于 GPT-2 的原始帖子(openai.com/blog/better-language-models/)的提示。这将返回生成的句子,如下所示。考虑到该模型是精简的、较小版本,生成的文本质量还不错:

在一个令人震惊的发现中,科学家们发现了一群生活在安第斯山脉一个偏远、以前未被探索过的山谷的独角兽。更让研究人员感到惊讶的是,这些独角兽讲着一口流利的英语。他们在那里工作时曾说加泰罗尼亚语,所以这些独角兽不仅是当地群体的一部分,他们也是一个人口组成与他们以前的国家民族邻居相差不多的人群的一部分,这让人们对他们感到认同。

“在某种程度上,他们学得比他们原本可能学得更好,” 加州大学欧文分校的语言副教授安德烈亚·罗德里格斯说。“他们告诉我,其他人比他们想象的还要糟糕。”

像大多数研究一样,这些发现只会支持它们的母语。但它突显了独角兽和外国人之间令人难以置信的社会联系,特别是当他们被提供了一个新的困难的平台来研究和创造自己的语言时。

“找到这些人意味着了解彼此的细微差别,并更好地处理他们的残疾,” 罗德里格斯说。

...

当您完成时,您可以运行以下命令来停止服务:

torchserve --stop

11.3.2 使用 SageMaker 部署模型

Amazon SageMaker 是一个用于训练和部署机器学习模型的托管平台。它使您能够启动一个 GPU 服务器,在其中运行一个 Jupyter 笔记本,在那里构建和训练 ML 模型,并直接将它们部署在托管环境中。我们的下一步是将机器学习模型部署为云 SageMaker 端点,以便生产系统可以向其发出请求。使用 SageMaker 部署 ML 模型的具体步骤包括以下内容:

  1. 将您的模型上传到 S3。

  2. 注册并将推理代码上传到 Amazon Elastic Container Registry(ECR)。

  3. 创建一个 SageMaker 模型和一个端点。

  4. 向端点发出请求。

我们将按照官方教程(mng.bz/p9qK)稍作修改。首先,让我们转到 SageMaker 控制台(console.aws.amazon.com/sagemaker/home)并启动一个笔记本实例。当您打开笔记本时,请运行以下代码以安装必要的软件包并启动 SageMaker 会话:

!git clone https://github.com/shashankprasanna/torchserve-examples.git
!cd torchserve-examples

!git clone https://github.com/pytorch/serve.git
!pip install serve/model-archiver/

import boto3, time, json
sess    = boto3.Session()
sm      = sess.client('sagemaker')
region  = sess.region_name
account = boto3.client('sts').get_caller_identity().get('Account')

import sagemaker
role = sagemaker.get_execution_role()
sagemaker_session = sagemaker.Session(boto_session=sess)

bucket_name = sagemaker_session.default_bucket()

变量 bucket_name 包含一个类似于 sagemaker-xxx-yyy 的字符串,其中 xxx 是地区名称(如 us-east-1)。记下这个名称——您需要它来在下一步中将您的模型上传到 S3。

接下来,您需要通过从刚刚创建 .mar 文件的机器(而不是从 SageMaker 笔记本实例)运行以下命令来将您的模型上传到 S3 存储桶。在上传之前,您首先需要将您的 .mar 文件压缩成一个 tar.gz 文件,这是 SageMaker 支持的一种格式。记得用 bucket_name 指定的实际存储桶名称替换 sagemaker-xxx-yyy:

cd model_store
tar cvfz distilgpt2.tar.gz distilgpt2.mar
aws s3 cp distilgpt2.tar.gz s3://sagemaker-xxx-yyy/torchserve/models/

下一步是注册并将 TorchServe 推断代码推送到 ECR。在开始之前,在您的 SageMaker 笔记本实例中,打开 torchserve-examples/Dockerfile 并修改以下行(添加 —no-cache-dir transformers)。

RUN pip install --no-cache-dir psutil \
                --no-cache-dir torch \
                --no-cache-dir torchvision \
                --no-cache-dir transformers

现在您可以构建一个 Docker 容器并将其推送到 ECR,如下所示:

registry_name = 'torchserve'
!aws ecr create-repository --repository-name torchserve

image_label = 'v1'
image = f'{account}.dkr.ecr.{region}.amazonaws.com/{registry_name}:{image_label}'

!docker build -t {registry_name}:{image_label} .
!$(aws ecr get-login --no-include-email --region {region})
!docker tag {registry_name}:{image_label} {image}
!docker push {image}

现在您可以准备好创建一个 SageMaker 模型并为其创建一个端点,如下所示:

import sagemaker
from sagemaker.model import Model
from sagemaker.predictor import RealTimePredictor
role = sagemaker.get_execution_role()

model_file_name = 'distilgpt2'

model_data = f's3://{bucket_name}/torchserve/models/{model_file_name}.tar.gz'
sm_model_name = 'torchserve-distilgpt2'

torchserve_model = Model(model_data = model_data, 
                         image_uri = image,
                         role = role,
                         predictor_cls=RealTimePredictor,
                         name = sm_model_name)
endpoint_name = 'torchserve-endpoint-' + time.strftime("%Y-%m-%d-%H-%M-%S", time.gmtime())
predictor = torchserve_model.deploy(instance_type='ml.m4.xlarge',
                                    initial_instance_count=1,
                                    endpoint_name = endpoint_name)

预测器对象是可以直接调用以运行推断的,如下所示:

response = predictor.predict(data="In a shocking finding, scientist discovered a herd of unicorns living in a remote, previously unexplored valley, in the Andes Mountains. Even more surprising to the researchers was the fact that the unicorns spoke perfect English.")

响应内容应该类似于这样:

b'In a shocking finding, scientist discovered a herd of unicorns living in a remote, previously unexplored valley, in the Andes Mountains. Even more surprising to the researchers was the fact that the unicorns spoke perfect English. The unicorns said they would take a stroll in the direction of scientists over the next month or so.\n\n\n\n\nWhen contacted by Animal Life and Crop.com, author Enrique Martinez explained how he was discovered and how the unicorns\' journey has surprised him. According to Martinez, the experience makes him more interested in research and game development.\n"This is really what I want to see this year, and in terms of medical research, I want to see our population increase."<|endoftext|>'

恭喜!我们刚刚完成了我们的旅程——我们从第二章开始构建一个 ML 模型,并在本章中一直部署到了云平台。

11.4 解释和可视化模型预测

人们经常谈论标准化数据集上的指标和排行榜表现,但分析和可视化模型预测和内部状态对于现实世界中的自然语言处理应用非常重要。尽管深度学习模型在其所做的事情上可能非常出色,在某些自然语言处理任务上甚至达到了人类水平的性能,但这些深度模型是黑盒,很难知道它们为什么会做出某些预测。

因为这种(有些令人不安的)深度学习模型的属性,人工智能中的一个日益增长的领域称为可解释人工智能(XAI)正在努力开发方法来解释机器学习模型的预测和行为。解释机器学习模型对于调试非常有用——如果您知道它为什么做出某些预测,它会给您很多线索。在一些领域,如医疗应用和自动驾驶汽车,使机器学习模型可解释对于法律和实际原因至关重要。在本章的最后一节中,我们将介绍一个案例研究,在该案例研究中,我们使用语言可解释性工具(LIT)(pair-code.github.io/lit/)来可视化和解释自然语言处理模型的预测和行为。

LIT 是由 Google 开发的开源工具包,提供了一个基于浏览器的界面,用于解释和可视化 ML 预测。请注意,它是框架不可知的,这意味着它可以与任何选择的基于 Python 的 ML 框架一起使用,包括 AllenNLP 和 Hugging Face Transformers。LIT 提供了一系列功能,包括以下内容:

  • 显著性图——以彩色可视化输入的哪部分对达到当前预测起到了重要作用

  • 聚合统计信息——显示诸如数据集指标和混淆矩阵等聚合统计信息

  • 反事实——观察模型对生成的新样本的预测如何变化

在本节的其余部分,让我们选择我们训练的 AllenNLP 模型之一(第九章中基于 BERT 的情感分析模型)并通过 LIT 进行分析。LIT 提供了一组可扩展的抽象,如数据集和模型,以使使用任何基于 Python 的 ML 模型更加轻松。

首先,让我们安装 LIT。可以通过以下 pip 调用一次性安装它:

pip install lit-nlp

接下来,您需要使用 LIT 定义的抽象类包装您的数据集和模型。让我们创建一个名为 run_lit.py 的新脚本,并导入必要的模块和类,如下所示:

import numpy as np

from allennlp.models.archival import load_archive
from allennlp.predictors.predictor import Predictor
from lit_nlp import dev_server
from lit_nlp import server_flags
from lit_nlp.api import dataset as lit_dataset
from lit_nlp.api import model as lit_model
from lit_nlp.api import types as lit_types

from examples.sentiment.sst_classifier import LstmClassifier
from examples.sentiment.sst_reader import StanfordSentimentTreeBankDatasetReaderWithTokenizer

下面的代码展示了如何为 LIT 定义一个数据集。在这里,我们创建了一个仅包含四个硬编码示例的玩具数据集,但在实践中,您可能想要读取要探索的真实数据集。记得定义返回数据集类型规范的 spec() 方法:

class SSTData(lit_dataset.Dataset):
    def __init__(self, labels):
        self._labels = labels
        self._examples = [
            {'sentence': 'This is the best movie ever!!!', 'label': '4'},
            {'sentence': 'A good movie.', 'label': '3'},
            {'sentence': 'A mediocre movie.', 'label': '1'},
            {'sentence': 'It was such an awful movie...', 'label': '0'}
        ]

    def spec(self):
        return {
            'sentence': lit_types.TextSegment(),
            'label': lit_types.CategoryLabel(vocab=self._labels)
        }

现在,我们已经准备好定义主要模型了,如下所示。

列表 11.2 定义 LIT 的主要模型

class SentimentClassifierModel(lit_model.Model):
    def __init__(self):
        cuda_device = 0
        archive_file = 'model/model.tar.gz'
        predictor_name = 'sentence_classifier_predictor'

        archive = load_archive(                                       ❶
            archive_file=archive_file,
            cuda_device=cuda_device
        )

        predictor = Predictor.from_archive(archive, predictor_name=predictor_name)

        self.predictor = predictor                                    ❷
        label_map = archive.model.vocab.get_index_to_token_vocabulary('labels')
        self.labels = [label for _, label in sorted(label_map.items())]

    def predict_minibatch(self, inputs):
        for inst in inputs:
            pred = self.predictor.predict(inst['sentence'])           ❸
            tokens = self.predictor._tokenizer.tokenize(inst['sentence'])
            yield {
                'tokens': tokens,
                'probas': np.array(pred['probs']),
                'cls_emb': np.array(pred['cls_emb'])
            }

    def input_spec(self):
        return {
            "sentence": lit_types.TextSegment(),
            "label": lit_types.CategoryLabel(vocab=self.labels, required=False)
        }

    def output_spec(self):
        return {
            "tokens": lit_types.Tokens(),
            "probas": lit_types.MulticlassPreds(parent="label", vocab=self.labels),
            "cls_emb": lit_types.Embeddings()
        }

❶ 加载 AllenNLP 存档

❷ 提取并设置预测器

❸ 运行预测器的 predict 方法

在构造函数(init)中,我们正在从存档文件中加载一个 AllenNLP 模型,并从中创建一个预测器。我们假设您的模型放在 model/model.tar.gz 下,并且硬编码了其路径,但根据您的模型位置随意修改此路径。

模型预测是在 predict_minibatch() 中计算的。给定输入(简单地是数据集实例的数组),它通过预测器运行模型并返回结果。请注意,预测是逐个实例进行的,尽管在实践中,您应考虑批量进行预测,因为这会提高对较大输入数据的吞吐量。该方法还返回用于可视化嵌入的预测类别的嵌入(作为 cls_emb),这将用于可视化嵌入(图 11.5)。

CH11_F05_Hagiwara

图 11.5 LIT 可以显示显著性图、聚合统计信息和嵌入,以分析您的模型和预测。

最后,这是运行 LIT 服务器的代码:

model = SentimentClassifierModel()
models = {"sst": model}
datasets = {"sst": SSTData(labels=model.labels)}

lit_demo = dev_server.Server(models, datasets, **server_flags.get_flags())
lit_demo.serve()

运行上面的脚本后,转到 http:/./localhost:5432/ 在你的浏览器上。你应该会看到一个类似于图 11.5 的屏幕。你可以看到一系列面板,对应于有关数据和预测的各种信息,包括嵌入、数据集表和编辑器、分类结果以及显著性图(显示通过一种名为 LIME 的自动方法计算的标记贡献)³。

可视化和与模型预测进行交互是了解模型工作原理以及如何改进的好方法。

11.5 从这里开始去哪里

在本书中,我们只是浅尝了这个广阔而悠久的自然语言处理领域的表面。如果你对进一步学习 NLP 的实践方面感兴趣,Natural Language Processing in Action,作者是 Hobson Lane 和其他人(Manning Publications,2019),以及 Practical Natural Language Processing,作者是 Sowmya Vajjala 和其他人(O’Reilly,2020),可以成为下一个很好的步骤。Machine Learning Engineering,作者是 Andriy Burkov(True Positive Inc.,2020),也是学习机器学习工程主题的好书。

如果你对学习 NLP 的数学和理论方面更感兴趣,我建议你尝试一些流行的教材,比如 Speech and Language Processing,作者是 Dan Jurafsky 和 James H. Martin(Prentice Hall,2008)⁴,以及 Introduction to Natural Language Processing,作者是 Jacob Eisenstein(MIT Press,2019)。虽然 Foundations of Statistical Natural Language Processing,作者是 Christopher D. Manning 和 Hinrich Schütze(Cambridge,1999),有点过时,但它也是一本经典教材,可以为你提供广泛的 NLP 方法和模型打下坚实的基础。

也要记住,你通常可以免费在网上找到很棒的资源。一个免费的 AllenNLP 课程,“A Guide to Natural Language Processing with AllenNLP”(guide .allennlp.org/),以及 Hugging Face Transformers 的文档(huggingface.co/transformers/index.html)是学习这些库的深入了解的好地方。

最后,学习 NLP 最有效的方法实际上是自己动手。如果您的兴趣、工作或任何涉及处理自然语言文本的事情存在问题,请考虑您在本书中学到的任何技术是否适用。这是一个分类、标记还是序列到序列的问题?您使用哪些模型?您如何获得训练数据?您如何评估您的模型?如果您没有 NLP 问题,不用担心——请前往 Kaggle,在那里您可以找到许多与 NLP 相关的竞赛,您可以在处理真实世界问题时“动手”并获得 NLP 经验。NLP 会议和研讨会经常举办共享任务,参与者可以在共同任务、数据集和评估指标上进行竞争,这也是一个很好的学习方法,如果您想深入研究 NLP 的某个特定领域。

概要

  • 在现实世界的 NLP/ML 系统中,机器学习代码通常只是一个小部分,支持着复杂的基础设施,用于数据收集、特征提取以及模型服务和监控。

  • NLP 模块可以开发为一次性脚本、批量预测服务或实时预测服务。

  • 重要的是要对模型和数据进行版本控制,除了源代码。要注意训练和测试时间之间的训练服务偏差。

  • 您可以使用 TorchServe 轻松提供 PyTorch 模型,并将其部署到 Amazon SageMaker。

  • 可解释性人工智能是一个新的领域,用于解释和解释机器学习模型及其预测。您可以使用 LIT(语言可解释性工具)来可视化和解释模型预测。

^(1.)GCP: cloud.google.com/compute/docs/gpus/install-drivers-gpu; AWS: docs.aws.amazon.com/AWSEC2/latest/UserGuide/install-nvidia-driver.html

^(2.)还有另一个叫做 AllenNLP Interpret 的工具包(allennlp.org/interpret),它提供了一套类似的功能,用于理解 NLP 模型,尽管它专门设计用于与 AllenNLP 模型进行交互。

^(3.)Ribeiro 等人,“‘为什么我要相信你?’: 解释任何分类器的预测”(2016 年)。arxiv.org/abs/1602.04938

^(4.)你可以免费阅读第三版(2021 年)的草稿,网址为web.stanford.edu/~jurafsky/slp3/

posted @ 2024-05-02 22:33  绝不原创的飞龙  阅读(0)  评论(0编辑  收藏  举报