真实世界的自然语言处理(一)
原文:zh.annas-archive.org/md5/0bc67f8f61131022ce5bcb512033ea38译者:飞龙协议:CC BY-NC-SA 4.0前言前言在过去二十年里,我一直在机器学习(ML)、自然语言处理(NLP)和教育的交叉领域工作,我一直热衷于教育和帮助人们学习新技术。这就是为什么当我听说有机会出版一本关于 NLP 的书时,我毫不犹豫地接受了。过去几年,人工智能(AI)
原文:
zh.annas-archive.org/md5/0bc67f8f61131022ce5bcb512033ea38
译者:飞龙
前言
前言
在过去二十年里,我一直在机器学习(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
),您可以在这些课程中学习自然语言处理的基础知识以及如何使用这些库来解决各种自然语言处理任务。
关于作者
![]() |
萩原真人于 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 所示。
图 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 显示了这些不同领域之间的重叠关系。
图 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”。正如你所看到的,截至目前为止,大多数(如果不是全部)商业机器翻译系统都无法理解除正在翻译的句子之外的上下文。学术界在解决这个问题上花费了大量研究力量,但这仍然是自然语言处理中被认为尚未解决的问题之一。
图 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 所示。
图 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 所示的结果。
图 1.5“a girl”子树
在解析过程中创建的树形结构称为解析树,或简称解析。图中的子树因为并不涵盖整个树(即,不显示从“S”到单词的全部内容)而被称为子树。使用我们之前讨论过的句子“I saw a girl with a telescope”,试着手动解析一下看看。如果你一直使用产生式规则分解句子,直到得到最终的“S”符号,你就可以得到图 1.6 所示的树形结构。
图 1.6“I saw a girl with a telescope.”的解析树
如果图 1.6 中的树形结构和你得到的不一样,不用担心。实际上,有另一个解析树是这个句子的有效解析,如图 1.7 所示。
图 1.7“I saw a girl with a telescope.”的另一个解析树
如果你仔细看这两棵树,你会注意到一个区别,即“PP”(介词短语)的位置或连接位置。 实际上,这两个分析树对应于我们在第 1.1 节中讨论的这个句子的两种不同解释。 第一棵树(图 1.6),其中 PP 连接动词“saw”,对应于男孩使用望远镜看女孩的解释。 在第二棵树(图 1.7)中,其中 PP 连接到名词“a girl”,男孩看到了拿着望远镜的女孩。 解析是揭示句子结构和语义的一个重要步骤,但在像这样的情况下,仅靠解析无法唯一确定句子的最可能解释。
另一种解析的类型被称为依存句法分析。 依存句法分析使用依存语法来描述句子的结构,不是以短语为单位,而是以词和它们之间的二元关系为单位。 例如,先前句子的依存句法分析结果如图 1.8 所示。
图 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 应用同样重要。在本节中,我简要介绍了每个阶段涉及的内容。请注意,这些阶段之间没有明确的界限。应用开发者(研究人员、工程师、经理和其他利益相关者)经常会在一些阶段之间反复试验。
图 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 应用程序的典型结构。有两个主要基础设施:训练基础设施和服务基础设施。训练基础设施通常是离线的,用于训练应用程序所需的机器学习模型。它接收训练数据,将其转换为可以由管道处理的某种数据结构,并通过转换数据和提取特征进一步处理数据。这一部分因任务而异。最后,如果模型是神经网络,将数据实例分批处理并馈送到模型中,该模型经过优化以最小化损失。如果你不理解我在最后一句说的是什么,不要担心,我们将在第二章讨论与神经网络一起使用的技术术语。训练好的模型通常是序列化并存储以传递给服务基础设施。
图 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 所示),都是基于 监督式机器学习 范式构建的。监督式机器学习是一种机器学习类型,其中算法是通过具有监督信号的数据进行训练的——对于每个输入都有期望的结果。该算法被训练成尽可能准确地重现这些信号。对于情感分析,这意味着系统是在包含每个输入句子的所需标签的数据上进行训练的。
图 2.1 情感分析流水线
2.2 处理自然语言处理数据集
正如我们在上一节中讨论的,许多现代自然语言处理应用都是使用监督式机器学习开发的,其中算法是从标有期望结果的数据中训练出来的,而不是使用手写规则。几乎可以说,数据是机器学习的关键部分,因此了解它是如何结构化并与机器学习算法一起使用的至关重要。
2.2.1 什么是数据集?
数据集 简单地意味着一组数据。如果你熟悉关系型数据库,你可以将数据集想象成一个表的转储。它由符合相同格式的数据片段组成。在数据库术语中,数据的每个片段对应一个记录,或者表中的一行。记录可以有任意数量的字段,对应数据库中的列。
在自然语言处理中,数据集中的记录通常是某种类型的语言单位,比如单词、句子或文档。自然语言文本的数据集称为 语料库(复数形式为 语料库)。举个例子,我们来想象一个(假想的)用于垃圾邮件过滤的数据集。该数据集中的每条记录都是一对文本和标签,其中文本是一句话或一段文字(例如,来自一封电子邮件),而标签指定文本是否是垃圾邮件。文本和标签都是记录的字段。
一些自然语言处理数据集和语料库具有更复杂的结构。例如,一个数据集可能包含一系列句子,其中每个句子都用详细的语言信息进行了注释,例如词性标签、句法树、依存结构和语义角色。如果一个数据集包含了一系列句子,并且这些句子带有它们的句法树注释,那么这个数据集被称为树库。最著名的例子是宾夕法尼亚树库(Penn Treebank,PTB)(realworldnlpbook.com/ch2.html#ptb
),它一直作为培训和评估自然语言处理任务(如词性标注和句法分析)的事实标准数据集。
与记录密切相关的术语是实例。在机器学习中,实例是进行预测的基本单位。例如,在前面提到的垃圾邮件过滤任务中,一个实例是一段文本,因为对单个文本进行预测(垃圾邮件或非垃圾邮件)。实例通常是从数据集中的记录创建的,就像在垃圾邮件过滤任务中一样,但并非总是如此——例如,如果您拿一个树库来训练一个 NLP 任务,该任务检测句子中的所有名词,那么每个单词,而不是一个句子,就成为一个实例,因为对每个单词进行预测(名词或非名词)。最后,标签是附加到数据集中某些语言单位的信息片段。一个垃圾邮件过滤数据集有与每个文本是否为垃圾邮件相对应的标签。一个树库可能具有每个词的词性标签的标签。标签通常在监督式机器学习环境中用作训练信号(即训练算法的答案)。请参见图 2.2,了解数据集的这些部分的描绘。
图 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 描绘了训练/验证/测试分割以及整个训练流水线。
图 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 中所示的尺度。
图 2.4 一维空间中的词嵌入
这是一个进步。现在我们可以表示“猫”和“狗”彼此之间比“披萨”更相似的事实。但是,“披萨”仍然比“猫”更接近“狗”。如果你想把它放在一个距离“猫”和“狗”都一样远的地方怎么办?也许只有一个维度太限制了。在这个基础上再添加一个维度如何,如图 2.5 所示?
图 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 说明了这个三维空间。
图 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 可以产生句子的固定长度表示。
图 2.7 循环神经网络(RNN)
我们在这里将使用的另一种类型的神经网络组件是线性层。线性层,也称为全连接层,以线性方式将一个向量转换为另一个向量。正如前面提到的,层只是神经网络的一个子结构的花哨术语,因为你可以将它们堆叠在一起形成一个更大的结构。
请记住,神经网络可以学习输入和输出之间的非线性关系。为什么我们想要有更受限制(线性)的东西呢?线性层用于通过减少(或增加)维度来压缩(或扩展)向量。例如,假设你从 RNN 接收到一个 64 维的向量(64 个浮点数的数组)作为句子的嵌入,但你只关心对预测有重要作用的少量数值。在情感分析中,你可能只关心与五种不同情感标签对应的五个数值,即极强正面、正面、中性、负面和极强负面。但是你无法从嵌入的 64 个数值中提取出这五个数值。这正是线性层派上用场的地方 - 你可以添加一个层,将一个 64 维的向量转换为一个 5 维的向量,而神经网络会想办法做得很好,如图 2.8 所示。
图 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 显示了神经网络的整体训练过程。
图 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 中显示的界面。
图 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 所示。请注意,语义相关的单词(“猫”和“狗”)被放置在彼此附近。
图 3.1 单词嵌入在三维空间中
注意 实际上,你可以嵌入(即,用一系列数字表示)不仅仅是单词,还有几乎任何东西 —— 字符、字符序列、句子或类别。你可以使用相同的方法嵌入任何分类变量,尽管在本章中,我们将专注于自然语言处理中两个最重要的概念 —— 单词和句子。
3.1.2 嵌入为什么重要?
嵌入为什么重要?嗯,单词嵌入不仅重要,而且 至关重要 用于使用神经网络解决自然语言处理任务。神经网络是纯数学计算模型,只能处理数字。它们无法进行符号操作,例如连接两个字符串或使动词变为过去时,除非这些项目都用数字和算术操作表示。另一方面,自然语言处理中的几乎所有内容,如单词和标签,都是符号和离散的。这就是为什么你需要连接这两个世界,使用嵌入就是一种方法。请参阅图 3.2,了解如何在自然语言处理应用中使用单词嵌入的概述。
图 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 代码点的组合(例如重音符号)来表示一个字符。标点符号,如".“(句号)、”,“(逗号)和”?"(问号),也是字符。
图 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 分别被称为bigram和trigram。
在 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 中的模型。
图 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 所示。这个网络非常简单。它以一个词嵌入作为输入,并通过一个线性层扩展到一组分数,每个上下文词一个。希望这不会像许多人想象的那样令人畏惧!
图 3.5 Skip-gram 模型结构
3.4.4 Softmax
现在让我们来讨论如何“训练”Skip-gram 模型并学习我们想要的单词嵌入。关键在于将这个过程转化为一个分类任务,在这个任务中,模型预测哪些单词出现在上下文中。这里的“上下文”指的只是一个固定大小的窗口(例如,上下各 5 个单词),窗口以目标单词(例如,“狗”)为中心。当窗口大小为 2 时,请参见图 3.6 以便了解。实际上,这是一个“假”的任务,因为我们对模型的预测本身并不感兴趣,而是对训练模型时产生的副产品(单词嵌入)感兴趣。在机器学习和自然语言处理中,我们经常虚构一个假任务来训练另一些东西作为副产品。
图 3.6 目标单词和上下文单词(窗口大小=2 时)
注意 这种机器学习设置,其中训练标签是从给定数据集自动生成的,也可以称为自监督学习。最近流行的技术,如单词嵌入和语言建模,都使用了自监督学习。
使神经网络解决分类任务相对容易。你需要做以下两件事情:
-
修改网络,以便产生概率分布。
-
使用交叉熵作为损失函数(我们马上会详细介绍这一点)。
你可以使用称为softmax的东西来进行第一个。Softmax 是一个函数,它将K个浮点数向量转化为概率分布,首先“压缩”这些数字,使其适合 0.0-1.0 的范围,然后将它们归一化,使其总和等于 1。如果你对概率的概念不熟悉,请将其替换为置信度。概率分布是网络对个别预测(在这种情况下,上下文单词)的置信度值的集合。Softmax 在保持输入浮点数的相对顺序的同时执行所有这些操作,因此大的输入数字仍然在输出分布中具有大的概率值。图 3.7 以概念上的方式说明了这一点。
图 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 显示了该模型的结构。
图 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 则对每个单词 标记 的组合进行了预测!
图 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 模型的结构。
图 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 仅使用在目标词之前出现的上下文单词进行预测,但这只是一个微小的差异。
图 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 中显示的图。这里有很多有趣的东西,但快速浏览时,您会注意到以下词语聚类,它们在语义上相关:
-
底部左侧:与网络相关的词语(posts,article,blog,comments,. . . )。
-
上方左侧:与时间相关的词语(day,week,month,year,. . . )。
-
中间:数字(0,1,2,. . . )。令人惊讶的是,这些数字向底部递增排序。GloVe 仅从大量的文本数据中找出了哪些数字较大。
-
底部右侧:月份(january,february,. . . )和年份(2004,2005,. . . )。同样,年份似乎按照递增顺序排列,几乎与数字(0,1,2,. . . )平行。
图 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(".")) /
图 4.1 平均嵌入向量
这种方法相当简单,实际上在许多自然语言处理应用中都有使用。然而,它有一个关键问题,即它无法考虑词序。因为输入元素的顺序不影响平均结果,你会得到“Mary loves John”和“John loves Mary”两者相同的向量。尽管它能胜任手头的任务,但很难想象有多少自然语言处理应用会希望这种行为。
如果我们退后一步,思考一下我们人类如何阅读语言,这种“平均”与现实相去甚远。当我们阅读一句话时,我们通常不会孤立地阅读单个单词并首先记住它们,然后再弄清楚句子的含义。我们通常从头开始扫描句子,逐个单词地阅读,同时将“部分”句子在我们的短期记忆中的含义保持住直到你正在阅读的部分。换句话说,你在阅读时保持了一种对句子的心理表征。当你达到句子的末尾时,这种心理表征就是它的含义。
我们是否可以设计一个神经网络结构来模拟这种对输入的逐步阅读?答案是肯定的。这种结构被称为循环神经网络(RNNs),我将在接下来详细解释。
4.1.2 RNN 抽象
如果你分解前面提到的阅读过程,其核心是以下一系列操作的重复:
-
阅读一个词。
-
根据迄今为止所阅读的内容(你的“心理状态”),弄清楚这个词的含义。
-
更新心理状态。
-
继续下一个词。
让我们通过一个具体的例子来看看这是如何工作的。如果输入句子是 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 进行说明。
图 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 被称为简单 RNN或Elman 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 所示。
图 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 所示。
图 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 的结果)。
图 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 成功地通过它们的语法性将句子分组。
图 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,了解损失如何向后传播到参数和先前的函数。
图 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)
图 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 更新函数的示意图。
图 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)。
图 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 显示了预测标签和真实标签之间以及召回率和精确率之间的关系。
图 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 格式配置训练流水线的方法。
-
你可以构建一个类似于情感分析器的句子分类应用来实现语言检测器。
更多推荐
所有评论(0)