Python-和-Spacy-自然语言处理-全-

Python 和 Spacy 自然语言处理(全)

原文:zh.annas-archive.org/md5/6486160625cd6415415045f5e6aed837

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

Image

越来越多的时候,当你拨打银行或互联网服务提供商的电话时,你可能会听到对方说:“你好,我是你的数字助手。请问有什么问题?”如今,机器人可以使用自然语言与人类对话,而且它们变得越来越聪明。尽管如此,仍然很少有人理解这些机器人是如何工作的,或者他们如何在自己的项目中使用这些技术。

自然语言处理(NLP)——一种帮助机器理解和响应人类语言的人工智能分支——是任何数字助手产品核心技术的关键。本书将为你提供开始创建自己的 NLP 应用所需的技能。在本书的最后,你将学会如何将 NLP 方法应用于现实问题,比如分析句子、捕捉文本的含义、创作原创文本,甚至构建自己的聊天机器人。

使用 Python 进行自然语言处理

如果你想开发一个 NLP 应用程序,你可以从各种工具和技术中进行选择。本书中的所有示例都使用 Python 代码实现,且使用了 Python 的 spaCy NLP 库。以下是一些你可能想选择 Python 和 spaCy 进行 NLP 开发的有力理由。

Python 是一种高级编程语言,具有以下特点:

简洁性 如果你是编程新手,Python 是一个很好的入门语言,因为它非常易学。由于其简洁性,Python 允许你编写别人也能轻松理解的代码。例如,Python 的简洁性帮助聊天机器人开发者与那些没有太多编程经验的语言学家协作。

普及性 Python 是最受欢迎的编程语言之一。绝大多数常用的 API 都有 Python 包装器,你可以通过 pip 安装工具轻松安装。通过 pip 安装 Python 包装器的能力简化了获取你可能希望在 NLP 应用中使用的第三方工具的过程。

在 AI 生态系统中的重要地位 AI 生态系统中有很多 Python 库可供使用。这些库的可用性简化了你开发 NLP 应用程序的过程,使你可以在众多库中选择最适合解决特定任务的工具。

spaCy 库

本书使用 spaCy,这是一个流行的 Python 库,包含了处理自然语言文本所需的语言数据和算法。正如你将在本书中学到的,spaCy 易于使用,因为它提供了表示自然语言文本元素的容器对象,例如句子和单词。这些对象又具有表示语言学特征的属性,如词性。写作时,spaCy 提供了针对英语、德语、希腊语、西班牙语、法语、意大利语、立陶宛语、挪威博克 mål 语、荷兰语、葡萄牙语以及多种语言的预训练模型。此外,spaCy 提供了内置的可视化工具,你可以通过编程调用它们来生成句子的句法结构或文档中的命名实体的图形。

spaCy 库还原生支持一些其他流行的 Python NLP 库不具备的高级 NLP 特性。例如,spaCy 原生支持词向量(在第五章中将详细讨论),而自然语言工具包(NLTK)则不支持。在使用后者时,你需要使用像 Gensim 这样的第三方工具,它是 word2vec 算法的 Python 实现。

使用 spaCy,你可以定制现有的模型或单独的模型组件,并且你可以从头开始训练自己的模型,以满足应用程序的需求(你将在第十章学习如何做到这一点)。你还可以连接由其他流行的机器学习(ML)库训练的统计模型,例如 TensorFlow、Keras、scikit-learn 和 PyTorch。此外,spaCy 可以与 Python 的 AI 生态系统中的其他库无缝协作,允许你例如在聊天机器人应用中利用计算机视觉,正如你将在第十二章中所做的那样。

谁应该阅读本书?

本书适合那些有兴趣学习如何在实践中使用 NLP 的人。特别是,对于那些希望为企业或仅仅为了娱乐开发聊天机器人的人来说,这本书可能会很有趣。不论你是否具备 NLP 或编程的背景或经验,你都可以跟随本书提供的代码示例,因为这些示例都包含了详细的过程解释。

对 Python 有一定的基础知识将有所帮助,因为本书并不涉及 Python 语法的基础知识。此外,示例假设你具有初中级的英语语法和句法理解。附录是一些不太为人所知的语言学概念的参考。如果你对 NLP 概念有较好的理解并且具备基本的编程知识,示例将更容易理解。

本书内容概览

《Python 和 spaCy 的自然语言处理》 开始时简要介绍了用于处理和分析自然语言数据的 NLP 技术的基本元素和方法。接着,书中会介绍越来越复杂的技术,帮助你应对自然语言给计算机处理和分析带来的挑战。每章的“尝试一下”部分将帮助你巩固刚学到的内容。

以下是你将在每章中找到的内容:

第一章: 自然语言处理原理 简要介绍了自然语言处理(NLP)技术的基本元素。它描述了生成 NLP 库数据的机器学习技术,如 spaCy,包括用于解决 NLP 问题的统计语言建模和统计神经网络模型。然后,它讲解了 NLP 应用开发人员面临的任务和挑战。

第二章: 文本处理管道 解释了 spaCy 是什么,它的设计目的是什么,然后展示了如何快速入门。它涵盖了设置工作环境的步骤,并通过使用文本处理管道进行编码,管道是一系列基本的 NLP 操作,用于确定话语的含义和意图。

第三章: 使用容器对象与自定义 spaCy 介绍了 spaCy 的架构,重点讲解了库中可用的核心数据结构。你将通过跟随示例,亲自体验 spaCy 的关键对象。你还将学习如何自定义管道组件,以满足你应用的需求。

第四章: 提取和使用语言特征 说明了如何提取语言特征,如依赖关系标签、词性标注和命名实体。你将学习如何生成并遍历句子的依赖树,探索句法关系。通过这些操作,你将学习如何以编程方式与聊天机器人用户继续对话、简化长文本并完成其他有用的任务。

第五章: 使用词向量 解释了 spaCy 的模型如何将自然语言单词映射到实数向量,从而允许你用词语进行数学运算。你将学习如何使用 spaCy 的相似度方法,通过比较容器对象的词向量来确定它们意义的相似度。

第六章: 查找模式和遍历依赖树 深入探讨了意义提取、句法依赖分析、名词短语切分和实体识别的细节。你将完成从原始文本中提取意义所需的所有步骤,使用词序列模式和遍历依赖树。本章介绍了 spaCy 的 Matcher 工具来查找模式,并讨论了在何种情况下你仍然需要依赖上下文来确定正确的处理方法。

第七章: 可视化 讨论了如何利用 spaCy 内置的 displaCy 可视化工具,你可以用它来可视化浏览器中的句法依赖关系和命名实体。可视化有助于你立即识别数据中的模式。

第八章: 意图识别 演示了意图提取,这是聊天机器人开发中的一个常见任务。你将学习如何从文本数据中提取意义,这通常是一个具有挑战性的任务,但在 Python 中只需几行代码即可完成。

第九章: 将用户输入存储到数据库中 教你如何自动从用户输入中提取关键词并将其存储到关系型数据库中,然后你可以使用这些关键词填写订单表单或其他业务文档。

第十章: 训练模型 介绍了如何训练 spaCy 的命名实体识别器和依赖分析器,以满足你的应用程序中 spaCy 默认模型无法覆盖的需求。它详细讲解了如何用新示例训练一个现有的预训练模型,以及如何从头开始训练一个空白模型。

第十一章: 部署你的聊天机器人 引导你完成将聊天机器人应用部署到 Telegram——一个流行的即时通讯服务——的过程,这样它就可以通过互联网与用户互动。

第十二章: 实现网页数据和图像处理 展示了如何让你的聊天机器人使用 spaCy 和其他 Python 人工智能生态系统中的库,从维基百科提取问题答案,并对用户提交的图像做出反应。

附录: 语言学入门 包含了一本简短的指南,讲解书中最常讨论的语法和句法元素。没有语言学背景的读者可以将其作为参考。

第一章:自然语言处理是如何工作的

图片

在 19 世纪,探险者发现了rongorongo,这是一种神秘的象形文字系统,位于复活节岛(通常被称为复活节岛)。研究人员从未成功破译 rongorongo 铭文,甚至没有弄清楚这些铭文是书写还是原始书写(传达信息但与语言无关的图形符号)。此外,虽然我们知道铭文的创造者也建造了摩艾像,这些大大的雕像是复活节岛最著名的象征,但建造者的动机仍然不明。我们只能推测。

如果你不了解人们的写作方式——或者他们描述事物的方式——你很可能就无法理解他们生活中的其他方面,包括他们做什么以及为什么这么做。

自然语言处理(NLP) 是人工智能的一个子领域,旨在处理和分析自然语言数据。它包括教机器用自然语言与人类互动(自然语言是通过使用自然发展出来的语言)。通过创建旨在处理比复活节岛上发现的那二十几块石板更大的未知数据集的机器学习算法,数据科学家可以了解我们如何使用语言。它们还能做的不仅仅是解读古代铭文。

今天,你可以使用算法来观察那些语义和语法规则已经被很好理解的语言(不同于 rongorongo 铭文),然后构建能够“理解”该语言发音的应用程序。企业可以利用这些应用程序帮助人类摆脱枯燥、单调的任务。例如,一个应用程序可以接受食物订单或回答客户经常提出的技术支持问题。

毫不奇怪,生成和理解自然语言是 NLP 中最具前景且最具挑战性的任务。在本书中,你将使用 Python 编程语言,通过 spaCy(一个领先的开源 Python 自然语言处理库)来构建一个自然语言处理器。但在开始之前,本章将概述构建自然语言处理器时幕后发生的事情。

计算机如何理解语言?

如果计算机只是没有情感的机器,那怎么可能训练它们理解人类语言并作出恰当的回应呢?事实上,机器无法本地理解自然语言。如果你希望你的计算机对语言数据进行计算操作,你需要一个能够将自然语言单词转换为数字的系统。

通过词嵌入映射单词和数字

词嵌入是一种将单词与数字关联的技术。在词嵌入中,你将单词映射到实数向量,这些向量将每个单词的意义分布到对应词向量的坐标上。具有相似意义的单词应当在这种向量空间中彼此接近,从而让你通过单词的“邻居”来确定其含义。

以下是此类实现的一个片段:

the 0.0897 0.0160 -0.0571 0.0405 -0.0696  ...

and -0.0314 0.0149 -0.0205 0.0557 0.0205  ...

of -0.0063 -0.0253 -0.0338 0.0178 -0.0966 ...

to 0.0495 0.0411 0.0041 0.0309 -0.0044    ...

in -0.0234 -0.0268 -0.0838 0.0386 -0.0321 ...

这个片段将单词“the”,“and”,“of”,“to”和“in”映射到它后面的坐标。如果你用图形方式表示这些坐标,那么具有相似意义的单词也会在图形中靠得更近。(但这并不意味着你可以期望在像这里展示的片段这样的文本表示中,相似意义的单词会聚集在一起。词向量空间的文本表示通常以最常见的单词开头,比如“the”,“and”等。这是词向量空间生成器布局单词的方式。)

注意

多维向量空间的图形表示可以通过二维或三维投影的形式来实现。为了准备这样的投影,你可以分别使用向量的前两个或三个主成分(或坐标)。我们将在第五章回到这一概念。

一旦你有了将单词映射到数字向量的矩阵,你就可以对这些向量进行算术运算。例如,你可以确定单词、句子,甚至整个文档的语义相似性(同义性)。

在数学上,确定两个单词之间的语义相似性就变成了计算它们对应向量之间的余弦相似性,或者计算向量之间的夹角余弦。尽管计算语义相似性的完整解释超出了本书的范围,第五章将更详细地讲解如何处理词向量。

使用机器学习进行自然语言处理

你可以使用机器学习算法来生成用于填充向量的数字。机器学习是人工智能的一个子领域,它创建可以从数据中自动学习的计算机系统,而无需明确编程。机器学习算法可以对新数据做出预测,学习识别图像和语音,分类照片和文本文件,自动化控制,并协助游戏开发。

机器学习使计算机能够完成那些否则非常困难,甚至不可能完成的任务。举个例子,如果你想用传统的编程方法为机器编写下象棋程序,其中你明确指定算法在每种情境下应该做什么,想象一下你需要定义多少个 if...else 条件。即便你成功了,使用这种应用程序的用户很快就会发现你逻辑中的弱点,并利用这些弱点在游戏中获胜,直到你在代码中做出必要的修正。

相比之下,基于机器学习算法构建的应用程序并不依赖于预定义的逻辑,而是利用从过去经验中学习的能力。因此,基于机器学习的国际象棋应用会寻找它从之前的游戏中记住的位置,并做出能达到最佳位置的移动。它将这些过去的经验存储在一个统计模型中,具体内容请参见 “什么是 NLP 中的统计模型?” 以及 第 8 页。

在 spaCy 中,除了生成词向量外,机器学习还可以帮助你完成三个任务:句法依赖解析(确定句子中单词之间的关系)、词性标注(识别名词、动词及其他词类)和命名实体识别(将专有名词分为人名、组织名和地点等类别)。我们将在接下来的章节中详细讨论这些内容。

一个典型的机器学习系统的生命周期包括三个步骤:模型训练、测试和预测。

模型训练

在第一阶段,你通过向算法输入大量数据来训练模型。为了让这些算法给出可靠的结果,你必须提供足够多的输入数据——比如比 rongorongo 铭文多得多。当涉及到 NLP 时,像 Wikipedia 和 Google News 这样的平台就包含了足够的文本,几乎可以喂养任何机器学习算法。但如果你想构建一个特定于你用例的模型,你可能会让它从使用你网站的客户那里学习。

图 1-1 提供了模型训练阶段的高层次描述。

image

图 1-1:使用大量文本数据作为输入,通过机器学习算法生成统计模型

你的模型处理大量的文本数据,以理解哪些单词具有共同的特征;然后它为这些单词创建反映这些共享特征的词向量。

正如你将在 “什么是 NLP 中的统计模型?” 和 第 8 页 中学到的那样,这样的词向量空间并不是为 NLP 构建的统计模型的唯一组成部分。实际的结构通常更加复杂,它提供了一种根据上下文提取每个单词语言特征的方法。

在第十章中,你将学习如何使用新示例训练一个已经存在的、预训练的模型,以及如何从头开始训练一个空白模型。

测试

一旦你训练好了模型,你可以选择性地对其进行测试,以了解它的表现如何。测试模型时,你将输入它之前未见过的文本,检查它是否能成功识别训练过程中学到的语义相似性和其他特征。

进行预测

如果一切按预期工作,你可以使用该模型在你的 NLP 应用中进行预测。例如,你可以使用它预测你输入的文本的依赖树结构,如图 1-2 所示。依赖树结构表示句子中单词之间的关系。

image

图 1-2:使用统计模型预测发话的依赖树结构

在视觉上,我们可以使用不同长度的弧线来表示依赖树,连接语法上相关的单词对。例如,这里展示的依赖树告诉我们动词“sent”与代词“she”相匹配。

为什么在自然语言处理中使用机器学习?

你的算法预测并不是事实陈述;它们通常是根据一定的确定性计算得出的。为了实现更高的准确度,你需要实现更复杂的算法,这些算法效率较低,且实施起来不太实用。通常,人们会努力在准确性和性能之间找到合理的平衡。

由于机器学习模型无法做到完美预测,你可能会想知道机器学习是否是用于构建自然语言处理(NLP)应用中模型的最佳方法。换句话说,是否存在一种基于严格定义规则的更可靠方法,类似于编译器和解释器处理编程语言时使用的方法?简短的回答是否定的。原因如下。

首先,编程语言包含的单词数量相对较少。例如,Java 编程语言包含 61 个保留字,每个保留字在该语言中都有预定义的含义。

相比之下,《牛津英语词典》 1989 年发布,收录了当前使用的 171,476 个单词。2010 年,哈佛大学和谷歌的一个研究团队统计了约 1,022,000 个单词,这些单词出现在包含约 4%已出版图书的数字化文本中。该研究估计,语言每年会增长几千个单词。为每个单词分配一个对应的数字将需要太长时间。

但即使你尝试去做,你也会发现,出于几个原因,确定自然语言中使用的单词数量几乎是不可能的。首先,什么算作一个独立的单词并不清楚。例如,我们应该把动词“count”算作一个单词,还是两个,或更多呢?在一种情况下,“count”可能意味着“有价值或重要性”。在另一种情况下,它可能意味着“一个接一个地说数字”。当然,“count”也可以是一个名词。

我们是否也应该把屈折变化——名词的复数形式、动词时态等——当作独立的实体来计算呢?我们是否应该计算借词(从外语中借用的单词)、科学术语、俚语和缩写?显然,自然语言的词汇定义很松散,因为很难弄清楚应该包括哪些单词组。在像 Java 这样的编程语言中,试图在代码中包括一个未知单词会迫使编译器中断处理并报错。

对于形式规则,也存在类似的情况。像它的词汇一样,许多自然语言的形式规则定义得很松散。有些规则会引发争议,比如分裂不定式,这是一种语法结构,其中副词被放置在不定式动词和它的介词之间。以下是一个例子:

spaCy allows you to programmatically extract the meaning of an utterance.

在这个例子中,副词“programmatically”将介词和不定式“to extract”分开。那些认为分裂不定式不正确的人可能会建议将句子改写如下:

spaCy allows you to extract the meaning of an utterance programmatically.

但无论你对分裂不定式有什么看法,你的 NLP 应用程序应该能同等好地理解这两个句子。

相比之下,处理编程语言代码的计算机程序并未设计来处理这种问题。原因在于,编程语言的正式规则被严格定义,不留任何歧义。例如,考虑以下用 SQL 编程语言编写的语句,你可能会用它来向数据库表中插入数据:

INSERT INTO table1 VALUES(1, 'Maya', 'Silver')

这个陈述相当直白。即使你不知道 SQL,你也能很容易猜到,这个语句应该是向表 1 中插入三个值。

现在,假设你把它修改成如下:

INSERT VALUES(1, 'Maya', 'Silver') INTO table1

从讲英语读者的角度来看,第二个陈述应该与第一个陈述具有相同的意义。毕竟,如果你像读英语句子那样去读,它仍然是有意义的。但如果你尝试在 SQL 工具中执行它,你将会遇到错误missing INTO keyword。那是因为 SQL 解析器——像任何其他编程语言中使用的解析器——依赖于硬编码的规则,这意味着你必须精确地按照它预期的方式指定你希望它执行的操作。在这个例子中,SQL 解析器期望在关键字INSERT之后紧跟着看到INTO关键字,而没有任何其他可能的选项。

毫无疑问,这种限制在自然语言中是无法实现的。考虑到所有这些差异,显而易见的是,像我们为编程语言定义计算模型那样,定义一套正式规则来指定自然语言的计算模型是低效甚至不可能的。

我们不使用基于规则的方法,而是采用基于观察的方法。机器学习算法通过生成统计模型来检测大量语言数据中的模式,并对新出现的、以前未见过的文本数据的句法结构做出预测,而不是通过为每个单词分配一个预定的数字来编码语言。

图 1-3 总结了自然语言和编程语言处理的工作流程。

自然语言处理系统使用一个基础统计模型来预测输入文本的意义,然后生成适当的响应。相比之下,处理编程代码的编译器则应用一套严格定义的规则。

image

图 1-3:左侧是处理自然语言的基本工作流程,右侧是处理编程语言的基本工作流程

什么是自然语言处理中的统计模型?

在自然语言处理中,统计模型包含了语言单位(如单词和短语)概率分布的估计,从而使你能够为这些单位分配语言特征。在概率论和统计学中,某一变量的概率分布是一个数值表,它将该变量的所有可能结果映射到在实验中发生这些结果的概率。表 1-1 展示了“count”一词在给定句子中可能的词性标签概率分布。(记住,一个单词可能在不同的语境中充当多个词性。)

表 1-1: 语境中语言单位的概率分布示例

动词 名词
78% 22%

当然,你会在另一种语境中看到“count”一词的其他图示。

统计语言建模对于许多自然语言处理任务至关重要,比如自然语言生成和自然语言理解。因此,统计模型几乎是所有自然语言处理应用的核心。

图 1-4 提供了自然语言处理应用如何使用统计模型的概念性描述。

image

图 1-4:自然语言处理应用架构的高层次概念图

应用程序与 spaCy 的 API 进行交互,后者抽象了底层的统计模型。统计模型包含诸如词向量和语言学注释等信息。语言学注释可能包括如词性标签和句法注释等特征。统计模型还包含一组机器学习算法,可以从存储的数据中提取必要的信息。

在实际系统中,模型的数据通常以二进制格式存储。二进制数据对人类来说不太友好,但它是机器的最佳伙伴,因为它容易存储并且加载速度快。

神经网络模型

在 NLP 工具(如 spaCy)中用于句法依赖解析、词性标注和命名实体识别的统计模型是神经网络模型。一个神经网络是一组预测算法。它由大量简单的处理单元组成,类似于大脑中的神经元,这些单元通过发送和接收信号与邻近节点进行交互。

在神经网络中,节点通常被分组到各个层次中,包括输入层、输出层和位于中间的一个或多个隐藏层。每个层中的节点(除输出层外)通过连接与后续层的每个节点相连。每个连接都有一个与之相关的权重值。在训练过程中,算法会调整权重值,以最小化其预测中的误差。这种架构使得神经网络能够识别模式,甚至在复杂的数据输入中也能做到。

从概念上讲,我们可以如图 1-5 所示表示神经网络。

image

图 1-5:神经网络布局和一个节点操作的概念性图示

当信号传入时,它会乘以一个权重值,这是一个实数。传递到神经网络的输入和权重值通常来自网络训练期间生成的词向量。

神经网络将每个节点的乘法结果相加,然后将总和传递给激活函数。激活函数生成的结果通常在 0 到 1 之间,从而产生一个新的信号,传递给后续层中的每个节点,或者在输出层的情况下,传递给输出参数。通常,输出层的节点数量等于给定算法的可能输出结果的数量。例如,用于词性标注的神经网络应该在输出层有与系统支持的词性标签数量相等的节点,正如图 1-6 所示。

image

图 1-6:简化的词性标注过程图示

然后,词性标注器会输出给定单词在特定上下文中的所有可能词性的概率分布。

卷积神经网络用于自然语言处理(NLP)

一个真实的神经网络模型的架构可能相当复杂,它由多个不同的层组成。因此,spaCy 中使用的神经网络模型是一个卷积神经网络(CNN),它包括一个卷积层,该卷积层在词性标注器、依赖解析器和命名实体识别器之间共享。卷积层将a 组检测滤波器应用于输入数据的区域,以测试是否存在特定特征。

作为一个例子,让我们看看卷积神经网络(CNN)在前面示例中执行词性标注任务时的工作原理:

Can we count on them?

卷积层并不是单独分析每个词,而是首先将句子划分为多个块。你可以将 NLP 中的句子视为一个矩阵,其中每一行表示一个词的向量形式。所以,如果每个词向量有 300 维,而你的句子有五个词,那么你就会得到一个 5 × 300 的矩阵。卷积层可能使用一个大小为三的检测滤波器,应用于三个连续的词,从而得到一个 3 × 300 的平铺区域大小。这应该能提供足够的上下文信息,帮助做出每个词的词性标注决定。

使用卷积方法进行词性标注的操作如图 1-7 所示。

image

图 1-7:卷积方法在 NLP 任务中如何工作的概念性展示

在前面的例子中,标注器面临的最具挑战性的任务是确定“count”一词的词性。问题在于,这个词可以是动词,也可以是名词,具体取决于上下文。但是,当标注器看到包含“we count on”这个词组时,这个任务就变得轻松了。在这种情况下,很明显,“count”一词只能是动词。

深入了解卷积架构的内部工作原理超出了本书的范围。如需了解有关 spaCy 中使用的统计模型背后的神经网络模型架构的更多信息,请查看 spaCy API 文档中的“神经网络模型架构”部分。

仍需你自己处理的部分

如你在前一节中学到的,spaCy 使用神经模型进行句法依赖分析、词性标注和命名实体识别。由于 spaCy 为你提供了这些功能,那么作为 NLP 应用程序的开发者,你需要做的还有什么呢?

spaCy 不能为你做的一件事是识别用户的意图。例如,假设你在卖衣服,你的在线应用程序接收到了用户的以下请求:

I want to order a pair of jeans.

该应用程序应该能够识别用户打算订购一条牛仔裤。

如果你使用 spaCy 对这个语句执行句法依赖分析,你将得到图 1-8 中所示的结果。

image

图 1-8:样本语句的依赖树

请注意,spaCy 在生成的语法树中并没有标记任何内容作为用户的意图。事实上,如果它做了这样的标记,反而显得很奇怪。原因在于,spaCy 并不知道你是如何实现应用逻辑的,也不知道你具体期待看到哪种意图。选择哪些词作为任务中的关键术语完全由你决定。

为了从话语或言论中提取意义,你需要理解以下关键方面:关键词、上下文和意义转换。

关键词

你可以利用句法依存解析的结果来选择对意义识别最重要的词汇。在“I want to order a pair of jeans.”这个例子中,关键词可能是“order”和“jeans”。

通常,及物动词和它的直接宾语能够很好地构成意图。但在这个特定的例子中,情况有点复杂。你需要遍历依存树,提取“order”(及物动词)和“jeans”(与直接宾语“pair”相关的介词的宾语)。

上下文

在选择关键词时,上下文可能很重要,因为相同的短语在不同应用中可能有不同的含义。假设你需要处理以下的言论:

I want the newspaper delivered to my door.

根据上下文,这句话可能是请求订阅一份报纸,或者请求将报纸送到门口。在第一种情况下,关键词可能是“want”和“newspaper”。在后一种情况下,关键词可能是“delivered”和“door”。

意义转换

通常,人们会用不止一句话来表达一个非常简单的意图。比如,考虑以下话语:

I already have a relaxed pair of jeans. Now I want a skinny pair.

在这篇论述中,反映意图的词语出现在两个不同的句子中,如图 1-9 所示。

image

图 1-9:识别话语的意图

正如你可能猜到的,“want”和“jeans”这两个词最能描述这段话的意图。以下是找到最佳描述用户意图的关键词的一般步骤:

  1. 在话语中,找到一个现在时的及物动词。

  2. 找出步骤 1 中发现的及物动词的直接宾语。

  3. 如果在上一步中找到的直接宾语是代词形式,找到它在前文中的先行词。

使用 spaCy,你可以轻松地以编程方式实现这些步骤。我们将在第八章中详细描述这一过程。

总结

在这一章中,你学到了自然语言处理的基础知识。你现在知道,与人类不同,机器使用基于向量的单词表示方法,这使你能够对自然语言单元(包括单词、句子和文档)进行数学运算。

你学到的词向量是基于神经网络架构的统计模型实现的。接着,你了解了作为一个 NLP 应用开发者,仍然需要你自己完成的任务。*

第二章:文本处理管道**

Image

现在你已经理解了 NLP 应用程序的结构,是时候在实际操作中看到这些基础概念的应用了。在本章中,你将安装 spaCy 并设置工作环境。接着,你将学习 文本处理管道,这是你用来确定话语的含义和意图的一系列基本 NLP 操作。这些操作包括分词、词形还原、词性标注、句法依赖解析和命名实体识别。

设置工作环境

在开始使用 spaCy 之前,你需要通过在机器上安装以下软件组件来设置工作环境:

  • Python 2.7 或更高版本,或 3.4 或更高版本

  • spaCy 库

  • spaCy 的统计模型

你需要 Python 2.7 或更高版本,或 3.4 或更高版本才能使用 spaCy v2.0.x。可以在 www.python.org/downloads/ 下载并按照说明设置 Python 环境。接下来,在你的 Python 环境中使用 pip 安装 spaCy,运行以下命令:

$ pip install spacy

如果你的系统上有多个 Python 安装,请选择与你想要使用的 Python 安装相关的 pip 可执行文件。例如,如果你想在 Python 3.5 中使用 spaCy,应该运行以下命令:

$ pip3.5 install spacy

如果你的系统中已经安装了 spaCy,你可能想将其升级到新版本。本书中的示例假设你使用的是 spaCy v2.0.x 或更高版本。你可以使用以下命令验证安装的 spaCy 版本:

$ python -m spacy info

你可能需要将 python 命令替换为适用于你特定环境中的 Python 可执行文件的命令,例如 python3.5。从现在开始,我们将使用 pythonpip,无论你的系统使用的是哪个可执行文件。

如果你决定将已安装的 spaCy 包升级到最新版本,可以使用以下 pip 命令进行升级:

$ pip install -U spacy

安装 spaCy 的统计模型

spaCy 安装包并不包括你开始使用库时所需的统计模型。这些统计模型包含从一组来源收集的关于特定语言的知识。你必须单独下载并安装每个你想要使用的模型。

有多个预训练的统计模型可供不同语言使用。例如,针对英语,可以从 spaCy 的网站下载以下模型:en_core_web_smen_core_web_mden_core_web_lgen_vectors_web_lg。这些模型遵循以下命名规则:lang_type_genre_sizeLang指定语言。Type表示模型的功能(例如,core 是一个通用模型,包括词汇、语法、实体和向量)。Genre表示模型训练的文本类型:web(如 Wikipedia 或类似的媒体资源)或 news(新闻文章)。Size表示模型的大小:lg是大型,md是中型,sm是小型。模型越大,所需的磁盘空间也越大。例如,en_vectors_web_lg-2.1.0模型需要 631MB,而en_core_web_sm-2.1.0只需要 10MB。

为了跟随本书中提供的示例,en_core_web_sm(最轻量的模型)就能很好地工作。当你使用 spaCy 的下载命令时,spaCy 会默认选择这个模型:

$ python -m spacy download en

命令中的en快捷链接指示 spaCy 下载并安装适用于英语的最佳默认模型。在这个上下文中,最佳匹配的模型是指为指定语言(例如英语)生成的通用模型,且是最轻量的。

要下载特定的模型,您必须指定其名称,如下所示:

$ python -m spacy download en_core_web_md

安装完成后,你可以使用安装时指定的相同快捷链接加载模型:

nlp = spacy.load('en')

使用 spaCy 的基本 NLP 操作

让我们开始执行一系列基本的 NLP 操作,我们称之为处理管道。spaCy 会在后台为你执行所有这些操作,让你能够专注于应用程序的特定逻辑。图 2-1 提供了该过程的简化示意图。

image

图 2-1:处理管道的高层视图

处理管道通常包括分词、词形还原、词性标注、句法依存分析和命名实体识别。我们将在本节中介绍这些任务。

分词

任何自然语言处理(NLP)应用程序通常对文本执行的第一个操作是将文本解析为词元,词元可以是单词、数字或标点符号。分词是第一个操作,因为所有其他操作都需要你首先获取词元。

以下代码展示了分词过程:

➊ import spacy

➋ nlp = spacy.load('en') 

➌ doc = nlp(u'I am flying to Frisco')

➍ print([w.text for w in doc])

我们通过导入 spaCy 库 ➊ 来访问它的功能。然后,我们使用en快捷链接 ➋ 加载一个模型包,以创建一个 spaCy 语言类的实例。语言对象包含该语言的词汇和来自统计模型的其他数据。我们将这个语言对象命名为nlp

接下来,我们将刚刚创建的对象 ③ 应用到示例句子中,创建一个Doc 对象实例。Doc 对象是一个用于存储一系列 Token 对象的容器。spaCy 会根据你提供的文本隐式地生成它。

到此为止,仅用三行代码,spaCy 已经生成了示例句子的语法结构。你如何使用它完全取决于你自己。在这个非常简单的示例中,你只需要打印出每个标记的文本内容 ④。

脚本将示例句子的标记输出为一个列表:

['I', 'am', 'flying', 'to', 'Frisco']

文本内容——组成标记的字符组,例如标记“am”中的字母“a”和“m”——是 Token 对象的许多属性之一。你还可以提取分配给标记的各种语言特征,正如你将在以下示例中看到的那样。

词形还原

词形是一个标记的基本形式。你可以把它看作是标记如果列在字典中会出现的形式。例如,“flying”这个标记的词形是“fly”。词形还原是将单词形式还原为其词形的过程。以下脚本提供了一个使用 spaCy 进行词形还原的简单示例:

import spacy

nlp = spacy.load('en')

doc = nlp(u'this product integrates both libraries for downloading and
applying patches')

for token in doc:

  print(➊token.text, ➋token.lemma_)

脚本中的前三行与之前的脚本相同。回顾一下,这些行导入了 spaCy 库,使用en快捷方式加载了一个英文模型,创建了一个文本处理管道,并将管道应用于示例句子——通过创建一个 Doc 对象,你可以访问句子的语法结构。

注意

在语法中,句子结构是指单个单词、短语和从句在句子中的排列方式。句子的语法意义取决于这种结构组织。

一旦你拥有了包含示例句子标记的 Doc 对象,你就可以在循环中遍历这些标记,并打印出标记的文本内容 ➊ 以及其对应的词形 ➋。该脚本将输出如下结果(为了便于阅读,我已将其整理成表格):


this        this

product     product

integrates  integrate

both        both

libraries   library

for         for

downloading download

and         and

applying    apply

patches     patch

左侧列包含标记,右侧列包含它们的词形。

应用词形还原进行语义识别

词形还原是语义识别任务中的一个重要步骤。为了理解这一点,让我们回到上一节中的示例句子:

I am flying to Frisco.

假设这个句子被提交到一个与在线系统交互的 NLP 应用程序,该系统提供了一个 API 用于预定旅行票务。该应用程序处理客户的请求,从中提取必要的信息,然后将信息传递给底层 API。这个设计可能看起来像图 2-2 所示。

image

图 2-2:在提取客户请求中必要信息的过程中使用词形还原

自然语言处理应用尝试从客户请求中获取以下信息:一种旅行方式(飞机、火车、公交等)和目的地。应用程序首先需要确定客户是想要机票、火车票还是公交票。为此,应用程序会搜索与预定义关键词列表中的某个关键词匹配的单词。简化这些关键词搜索的一个简单方法是,首先将句子中的所有单词转换为它们的词元。这样,预定义的关键词列表就会简短而清晰。例如,你不需要包括“fly”这个词的所有词形(如“fly”,“flying”,“flew”和“flown”),就能作为客户想要机票的指示,从而将所有可能的变体简化为该词的基础形式——也就是“fly”。

词元化在应用程序尝试从提交的请求中确定目的地时也非常有用。全球城市有很多昵称。但系统需要的是官方名称,预设的执行词元化的分词器无法区分城市、国家等的昵称和官方名称。为了解决这个问题,你可以向现有的分词器实例添加特例规则。

以下脚本演示了如何实现目的地城市的词元化。它会打印出组成句子的单词的词元。

   import spacy

   from spacy.symbols import ORTH, LEMMA

   nlp = spacy.load('en')

   doc = nlp(u'I am flying to Frisco')

   print([w.text for w in doc])

➊ special_case = [{ORTH: u'Frisco', LEMMA: u'San Francisco'}]

➋ nlp.tokenizer.add_special_case(u'Frisco', special_case)

➌ print([w.lemma_ for w in nlp(u'I am flying to Frisco')])

你为单词Frisco定义了一个特例 ➊,通过将其默认词元替换为San Francisco。然后你将这个特例添加到分词器实例中 ➋。一旦添加,分词器实例将在每次请求Frisco的词元时使用这个特例。为了确保一切按预期工作,你可以打印出句子中单词的词元 ➌。

脚本生成以下输出:

['I', 'am', 'flying', 'to', 'Frisco']

['-PRON-', 'be', 'fly', 'to', 'San Francisco']

输出列出了句子中所有单词的词元(lemmas),但不包括Frisco,对于Frisco,它列出的是San Francisco

词性标注

词性标注告诉你在特定句子中给定单词的词性(名词、动词等)。(回想一下第一章,一个单词根据出现的上下文可以作为多个词性。)

在 spaCy 中,词性标注可以包含关于一个标记(token)的详细信息。对于动词,可能会告诉你以下特征:时态(过去、现在或将来)、体(简单、进行时或完成时)、人称(第一、第二或第三)和数(单数或复数)。

提取这些动词词性标签可以帮助在仅进行标记化和词形还原不足以判断时识别用户意图。例如,前一节中的机票预订应用程序的词形还原脚本并不能决定 NLP 应用程序如何选择句子中的词汇来构建请求底层 API 的请求。在实际情况中,做出这样的决定可能相当复杂。例如,客户的请求可能包含多于一个句子:

I have flown to LA. Now I am flying to Frisco.

对于这些句子,词形还原的结果如下:

['-PRON-', 'have', 'fly', 'to', 'LA', '.', 'now', '-PRON-', 'be', 'fly', 'to', 'San Francisco', '.']

仅执行词形还原并不足够;应用程序可能会将第一个句子中的“fly”和“LA”作为关键字,从而表示客户打算飞往洛杉矶,而实际上客户打算飞往旧金山。问题的部分原因是词形还原会将动词转换为其原形形式,这使得很难知道它们在句子中扮演的角色。

这就是词性标签发挥作用的地方。在英语中,核心词性包括名词、代词、限定词、形容词、动词、副词、介词、连词和感叹词。(有关这些词性的更多信息,请参阅附录中的语言学入门。)在 spaCy 中,这些相同的类别——以及一些额外的符号、标点符号等类别——被称为粗粒度词性,并通过 Token.pos(整数)和 Token.pos_(unicode)属性以固定标签集的形式提供。

此外,spaCy 提供了细粒度词性标签,它们提供了关于标记的更详细的信息,涵盖了形态学特征,例如动词时态和代词类型。显然,细粒度词性标签的列表包含了比粗粒度列表更多的标签。细粒度词性标签可以通过 Token.tag(整数)和 Token.tag_(unicode)属性获得。

表 2-1 列出了 spaCy 中用于英文模型的一些常见词性标签。

表 2-1: 一些常见的 spaCy 词性标签

标签(细粒度词性) 词性(粗粒度词性) 形态学 描述
NN 名词 数量=单数 名词,单数
NNS 名词 数量=复数 名词,复数
PRP 代词 代词类型=人称 代词,人称代词
`PRP 标签(细粒度词性) 词性(粗粒度词性) 形态学
--- --- --- ---
NN 名词 数量=单数 名词,单数
NNS 名词 数量=复数 名词,复数
PRP 代词 代词类型=人称 代词,人称代词
代词 代词类型=人称 有主格 代词,所有格
VB 动词 动词形式=原形 动词,基础形式
VBD 动词 动词形式=陈述 时态=过去 动词,过去时

| VBG | 动词 | 动词形式=进行时 时态=现在 |

方面=进行时 | 动词,动名词或现在分词 |

JJ 形容词 程度=原级 形容词

注意

你可以在“词性标注”部分的注释规范手册中找到 spaCy 中使用的所有细粒度词性标签,手册链接为 spacy.io/api/annotation#pos-tagging

时态和体是自然语言处理应用中最有趣的动词特性之一。它们一起表示动词与时间的关系。例如,我们使用动词的现在时进行体形式来描述当前正在发生的事情或即将发生的事情。要形成现在时进行体动词,你需要在动词“to be”的现在时形式前加上-ing 动词。例如,在句子“I am looking into it.”中,你在动词“looking”前加上“am”——这是动词“to be”在第一人称单数的现在时形式。在这个例子中,“am”表示现在时,“looking”指向进行时体。

使用词性标签来寻找相关动词

票务预订应用可以使用 spaCy 提供的细粒度词性标签来过滤对话中的动词,只选择那些可能有助于确定顾客意图的动词。

在进入这个过程的代码之前,让我们尝试弄清楚顾客可能会用什么样的表达来表示他们想预订飞往 LA 的机票。我们可以通过查找包含以下词根组合的句子来开始:“fly”、“to”和“LA”。这里有一些简单的选项:

I flew to LA.

I have flown to LA.

I need to fly to LA.

I am flying to LA.

I will fly to LA.

注意,虽然这些句子如果简化为词根形式都会包含“fly to LA”组合,但只有其中一部分句子暗示顾客有预订飞往 LA 的机票的意图。前两句显然不合适。

快速分析表明,动词“fly”的过去式和过去完成式——这两个时态出现在前两句——并不暗示我们要寻找的意图。只有不定式和现在进行时形式才是合适的。以下脚本演示了如何在示例对话中找到这些形式:

import spacy

nlp = spacy.load('en')

doc = nlp(u'I have flown to LA. Now I am flying to Frisco.')

print([w.text for w in doc if ➊w.tag_== ➋'VBG' or w.tag_== ➌'VB'])

tag_属性 ➊ 是 Token 对象的一个属性,包含分配给该对象的细粒度词性标签。你可以对构成对话的 tokens 执行循环,检查是否分配给某个 token 的细粒度词性标签是VB(动词的不定式形式) ➌ 或 VBG(动词的现在进行时形式) ➋。

在示例对话中,只有第二句中的动词“flying”符合指定条件。所以你应该看到以下输出:

['flying']

当然,细粒度的词性标签不仅仅是分配给动词的;它们也分配给句子中的其他词性。例如,spaCy 会将“LA”和“Frisco”识别为专有名词——表示人名、地名、物体或组织名称的名词——并用PROPN标签标记它们。如果你想,你可以将以下代码行添加到之前的脚本中:

print([w.text for w in doc if w.pos_ == 'PROPN'])

添加该代码应该输出以下列表:

['LA', 'Frisco']

示例对话中的专有名词出现在列表中。

上下文很重要

细粒度的词性标签可能并不总是足以确定话语的含义。为此,你可能仍然需要依赖上下文。举个例子,考虑以下话语:“I am flying to LA.” 在这个例子中,词性标注器会将动词“flying”标注为 VBG,因为它是现在进行时。但由于我们使用这种动词形式来描述正在发生的事情或即将发生的事情,这句话的含义可能是“我已经在天上,飞往洛杉矶。”或者“我要飞往洛杉矶。”当提交给机票预订 NLP 应用程序时,该应用程序应该仅将其中一句解读为“我需要一张去洛杉矶的机票。”同样,考虑以下话语:“I am flying to LA. In the evening, I have to be back in Frisco.” 这很可能意味着说话者想要一张从洛杉矶飞往弗里斯科的晚班机票。你可以在《使用上下文改善机票预订聊天机器人》的第 91 页中找到更多关于基于上下文识别含义的例子。

句法关系

现在让我们将适当的专有名词与之前词性标注器选出的动词结合起来。回想一下,你可以用来识别话语意图的动词列表中,在第二个句子中只有“flying”这个动词。那么如何获得最佳描述话语背后意图的动词/专有名词组合呢?人类显然会将来自同一句话的动词和专有名词组合在一起。因为第一句中的动词“flown”不符合指定的条件(记住,只有不定式和现在进行时形式符合条件),所以你只能为第二句组合这样的词对:“flying, Frisco”。

为了以编程方式处理这些情况,spaCy 提供了一个句法依赖解析器,它能够发现句子中各个词汇之间的句法关系,并通过单一的弧线将句法相关的词对连接起来。

就像前面章节中讨论的词元和词性标签一样,句法依赖标签是 spaCy 分配给构成 Doc 对象中文本的 Token 对象的语言学特征。例如,依赖标签 dobj 代表“直接宾语”。我们可以通过图 2-3 中所示的箭头弧线来说明它所表示的句法关系。

主词与子词

句法依存关系标签描述了句子中两个单词之间的句法关系类型。在这样的对中,一个单词是句法上的支配词(也叫做主词或父项),另一个是依存词(也叫做子项)。spaCy 将句法依存关系标签分配给对中的依存词。例如,在“need, ticket”这一对中,提取自句子“I need a plane ticket”,单词“ticket”是子项,而“need”是主词,因为“need”是所谓的动词短语中的动词。在同一个句子中,“a plane ticket”是名词短语:名词“ticket”是主词,而“a”和“plane”是它的子项。要了解更多,请查阅《依存语法与短语结构语法》,见第 185 页。

每个句子中的每个单词都有一个确切的主词。因此,一个单词只能作为一个主词的子项。相反的情况并不总是成立。同一个单词可以作为多个对中的主词。后者意味着该主词有多个子项。这也解释了为什么依存关系标签总是赋给子项。

image

图 2-3:句法依存弧的图示表示

dobj标签分配给单词“ticket”,因为它是该关系的子项。依存关系标签总是赋给子项。在你的脚本中,你可以使用Token.head属性来确定关系的主词。

你可能还想查看句子中其他的主词/子项关系,像图 2-4 中展示的那些关系。

image

图 2-4:整个句子中的主词/子项关系

如你所见,句子中的同一个单词可以参与多种句法关系。表 2-2 列出了最常用的一些英语依存关系标签。

表 2-2: 一些常见的依存关系标签

依存关系标签 描述
acomp 形容词补语
amod 形容词修饰语
aux 助动词
compound 复合词
dative 与格
det 限定词
dobj 直接宾语
nsubj 名词主语
pobj 介词宾语
ROOT

ROOT标签标记的是其主词是自身的词。通常,spaCy 会将其分配给句子的主动词(谓语的核心动词)。每个完整的句子应该有一个带有ROOT标签的动词和一个带有nsubj标签的主语。其他元素是可选的。

注意

本书中的大多数示例假定提交的文本是完整的句子,并使用ROOT标签来定位句子的主动词。请记住,这并不适用于所有可能的输入。

以下脚本演示了如何访问示例中在 “词性标注” 中的语篇中标记的句法依赖标签,参考 第 21 页:

import spacy

nlp = spacy.load('en')

doc = nlp(u'I have flown to LA. Now I am flying to Frisco.')

for token in doc:

  print(token.text, ➊token.pos_, ➋token.dep_)

该脚本输出了粗粒度的词性标签 ➊(见 表 2-1)和分配给构成示例语篇的标记 ➋ 的依赖标签:


I      PRON   nsubj

have   VERB   aux

flown  VERB   ROOT

to     ADP    prep

LA     PROPN  pobj

.      PUNCT  punct

Now    ADV    advmod

I      PRON   nsubj

am     VERB   aux

flying VERB   ROOT

to     ADP    prep

Frisco PROPN  pobj

.      PUNCT  punct

但它没有展示的是单词是如何通过通常所说的 依赖弧 在句子中相互关联的,这一点在本节开始时已作解释。要查看示例语篇中的依赖弧,请将前面脚本中的循环替换为以下内容:

for token in doc:

  print(➊token.head.text, token.dep_, token.text)

一个标记对象的头部属性 ➊ 指的是该标记的句法主语。当你打印这一行时,你会看到语篇中的单词是如何通过句法依赖关系互相连接的。如果它们以图形化方式呈现,你会看到每一行都对应一个弧线,除了 ROOT 关系。原因是分配给该标签的单词是句子中唯一没有主语的单词:


flown  nsubj  I

flown  aux    have

flown  ROOT   flown

flown  prep   to

to     pobj   LA

flown  punct  .

flying advmod Now

flying nsubj  I

flying aux    am

flying ROOT   flying

flying prep   to

to     pobj   Frisco

flying punct  .

查看之前的句法依赖列表,让我们尝试找出哪些标签指向的标记可以最好地描述客户的意图:换句话说,你需要找到一对能够单独恰当地描述客户意图的标记。

你可能对标记为 ROOTpobj 依赖标签的标记感兴趣,因为在这个例子中它们是意图识别的关键。如前所述,ROOT 标签标记了句子的主要动词,而 pobj 在本例中标记了与动词共同总结整个话语意义的实体。

以下脚本定位分配给这两个依赖标签的单词:

   import spacy

   nlp = spacy.load('en')

   doc = nlp(u'I have flown to LA. Now I am flying to Frisco.')

➊ for sent in doc.sents:

  ➋ print([w.text for w in sent ➌if w.dep_ == 'ROOT' or w.dep_ == 'pobj'])

在这个脚本中,你 分割语篇 ➊,通过 doc.sents 属性将句子分开,该属性会遍历文档中的句子。当你需要在语篇的每个句子中找到某些词性时,将文本分割成单独的句子是非常有用的。(我们将在下一章讨论 doc.sents,在那里你会看到如何使用句子级别的索引引用文档中的标记。)这使你能够基于分配给标记的特定依赖标签,创建每个句子的潜在关键词列表 ➋。此示例中使用的过滤条件是根据前一个脚本生成的句法相关对的检查来选择的。特别是,你挑选出带有 ROOTpobj 依赖标签的标记 ➌,因为这些标记组成了你感兴趣的对。

脚本的输出应该如下所示:

['flown', 'LA']

['flying', 'Frisco']

在这两个句子对中,输出的名词是被标记为 pobj 的词汇。你可以在你的机票预订应用程序中使用它,选择与动词最匹配的名词。在这种情况下,就是“flying”(飞行),它与“Frisco”(旧金山)匹配。

这是一个简化的使用依赖标签进行信息提取的示例。在接下来的章节中,你将看到如何在句子的依存树甚至整篇语篇中迭代提取所需信息的更复杂示例。

试试看

现在你知道如何利用词形还原、词性标注和句法依赖标签,你可以将它们结合起来做一些有用的事情。尝试将前面各节的示例结合成一个脚本,正确识别出说话者飞往旧金山的意图。

你的脚本应该生成以下输出:

 ['fly', 'San Francisco']

为了实现这一点,从本节的最新脚本开始,并增强循环中的条件语句,添加条件以考虑细粒度的词性标签,如在“词性标注”中第 21 页讨论的内容。然后将词形还原功能添加到脚本中,如在“词形还原”中第 18 页讨论的内容。

命名实体识别

命名实体是你可以通过专有名词引用的真实对象。它可以是人、组织、地点或其他实体。命名实体在自然语言处理中的重要性在于,它们揭示了用户所谈论的地点或组织。以下脚本查找了前面示例中使用的语料中的命名实体:

 import spacy

 nlp = spacy.load('en')

 doc = nlp(u'I have flown to LA. Now I am flying to Frisco.')

 for token in doc:

➊ if token.ent_type != 0:

    print(token.text, ➋token.ent_type_)

如果一个词汇的 ent_type 属性未设置为 0 ➊,那么该词汇就是命名实体。如果是这样,你就打印该词汇的 ent_type_ 属性 ➋,它包含了该命名实体的类型,且以 Unicode 编码形式表示。最终,脚本应输出如下结果:

LA     GPE

Frisco GPE

LAFrisco 都被标记为 GPE,即“地理政治实体”的缩写,包括国家、城市、州和其他地名。

总结

在本章中,你为使用 spaCy 设置了工作环境。然后,你学习了简单的脚本,演示了如何使用 spaCy 的功能来执行基本的自然语言处理操作,以提取重要信息。这些操作包括分词、词形还原和识别句子中各个词汇之间的句法关系。本章提供的示例是简化的,并未反映现实世界的场景。要编写一个更复杂的脚本来使用 spaCy,你需要实现一个算法,从依存树中推导出必要的词汇,使用分配给词汇的语言学特征。我们将在第四章回顾如何提取和使用语言学特征,并将在第六章详细介绍依存树。

在下一章中,您将了解 spaCy API 的关键对象,包括容器和处理管道组件。此外,您还将学习使用 spaCy 的 C 级数据结构和接口来创建能够处理大量文本的 Python 模块。

第三章:与容器对象和定制 spaCy**工作

Image

你可以将构成 spaCy API 的主要对象分为两类:容器(例如 Tokens 和 Doc 对象)和处理管道组件(例如 词性标注器和命名实体识别器)。本章将进一步探讨容器对象。通过使用容器对象及其方法,你可以访问 spaCy 为文本中的每个词元分配的语言学注释。

你还将学习如何定制管道组件以适应你的需求,并使用 Cython 代码加速耗时的 NLP 任务。

spaCy 的容器对象

一个 容器对象 将多个元素组织成一个单一的单元。它可以是一个对象集合,如词元或句子,或与单个对象相关的一组注释。例如,spaCy 的 Token 对象是一个容器,用于存储与文本中单个词元相关的一组注释,如该词元的词性。spaCy 中的容器对象模仿了自然语言文本的结构:一篇文本由句子组成,每个句子包含多个词元。

Token、Span 和 Doc 是从用户角度来看最常用的 spaCy 容器对象,分别表示一个词元、一个短语或句子以及一段文本。一个容器可以包含其他容器——例如,一个 Doc 包含多个 Token。在本节中,我们将探讨如何与这些容器对象一起工作。

获取 Doc 对象中词元的索引

一个 Doc 对象包含通过对提交的文本进行分词处理而生成的 Token 对象集合。这些词元具有索引,使你可以根据它们在文本中的位置来访问它们,如 图 3-1 所示。

image

图 3-1:Doc 对象中的词元

这些词元的索引从 0 开始,这意味着文档的长度减去 1 即为结束位置的索引。为了将 Doc 实例拆分为词元,你可以通过从起始词元到结束词元的迭代,将词元导出为 Python 列表:

>>> [doc[i] for i in range(len(doc))]

[A, severe, storm, hit, the, beach, .]

值得注意的是,我们可以通过显式地使用构造函数来创建一个 Doc 对象,如下例所示:

>>> from spacy.tokens.doc import Doc

>>> from spacy.vocab import Vocab

>>> doc = Doc(➊Vocab(), ➋words=[u'Hi', u'there'])

doc

Hi there

我们调用 Doc 的构造函数,并传入以下两个参数:一个 词汇对象 ➊——它是一个存储容器,提供词汇数据,如词性(形容词、动词、名词等)——以及一组词元,用于添加到正在创建的 Doc 对象中 ➋。

迭代一个词元的句法子节点

假设我们需要在句子的句法依赖解析中查找一个词元的左子节点。例如,我们可以对一个名词执行此操作,以获得其形容词(如果有的话)。如果我们想知道哪些形容词可以修饰给定的名词,我们可能需要进行此操作。举个例子,考虑以下句子:

I want a green apple.

图 3-2 中的图示突出了相关的句法依赖关系。

image

图 3-2:左侧句法依赖关系示例

为了编程方式地获取单词“apple”在此示例句子中的左侧句法子节点,我们可能会使用以下代码:

>>> doc = nlp(u'I want a green apple.')

>>> [w for w in doc[4].lefts]

[a, green]

在这个脚本中,我们简单地遍历 apple 的子节点,并将它们以列表的形式输出。

有趣的是,在这个例子中,单词“apple”的左侧句法子节点代表了该标记所有句法子节点的完整序列。实际上,这意味着我们可以用Token.children替换Token.lefts,后者会找到标记的所有句法子节点:

>>> [w for w in doc[4].children]

结果列表将保持不变。

我们还可以使用Token.rights来获取标记的右侧句法子节点:在这个例子中,单词“apple”是单词“want”的右侧子节点,如图 3-1 所示。

doc.sents 容器

通常,分配给标记的语言注释只有在标记所在的句子上下文中才有意义。例如,关于单词是否为名词或动词的信息可能只适用于该单词所在的句子(如前面章节讨论的“count”一词)。在这种情况下,能够通过句子级索引访问文档中的标记会非常有用。

doc.sents属性使我们能够将文本分割成单独的句子,如下例所示:

   >>> doc = nlp(u'A severe storm hit the beach. It started to rain.')

➊ >>> for sent in doc.sents:

➋ ...   [sent[i] for i in range(len(sent))]

   ...

   [A, severe, storm, hit, the, beach, .]

   [It, started, to, rain, .]

   >>>

我们遍历doc中的句子 ➊,为每个句子创建一个单独的标记列表 ➋。

同时,我们仍然可以使用全局或文档级索引引用多句文本中的标记,如下所示:

>>> [doc[i] for i in range(len(doc))]

[A, severe, storm, hit, the, beach, ., It, started, to, rain, .]

如果需要检查正在处理的文本中第二个句子的第一个单词是否为代词(比如我们想找出两个句子之间的联系:第一个句子包含名词,第二个句子包含指代该名词的代词),那么通过句子级索引引用文档中的标记对象会非常有用:

>>> for i,sent in enumerate(doc.sents):

...   if i==1 and sent[0].pos_== 'PRON':

...     print('The second sentence begins with a pronoun.')

The second sentence begins with a pronoun.

在这个例子中,我们在for循环中使用枚举器通过索引区分句子。这样我们可以筛选出不感兴趣的句子,只检查第二个句子。

确定句子中的第一个单词非常简单,因为它的索引总是 0。那最后一个呢?例如,如果我们需要找出文本中有多少个句子以动词结尾(当然不算句号)怎么办?

>>> counter = 0

>>> for sent in doc.sents:

...   if sent[len(sent)-2].pos_ == 'VERB':

...     counter+=1

>>> print(counter)

1

虽然句子的长度各不相同,但我们可以通过len()函数轻松确定给定句子的长度。我们将len(sent)的值减去 2,原因有二:首先,索引总是从 0 开始,结束时是 size-1;其次,示例中两个句子的最后一个标记是句号,我们需要忽略它。

doc.noun_chunks 容器

Doc 对象的doc.noun_chunks属性允许我们迭代文档中的名词短语。名词短语是以名词为核心的短语。例如,前一句包含以下名词短语:

A noun chunk

a phrase

a noun

its head

使用doc.noun_chunks,我们可以按如下方式提取它们:

>>> doc = nlp(u'A noun chunk is a phrase that has a noun as its head.')

>>> for chunk in doc.noun_chunks:

...   print(chunk)

或者,我们可以通过迭代句子中的名词并找到每个名词的句法子节点来提取名词短语。如在“迭代标记的句法子节点”中所示,您看到过如何基于句法依赖分析提取短语的示例。现在让我们将这一技术应用到本示例中的样本句子,手动组成名词短语:

 for token in doc:

➊ if token.pos_=='NOUN':

     chunk = ''

  ➋ for w in token.children:

     ➌ if w.pos_ == 'DET' or w.pos_ == 'ADJ':

         chunk = chunk + w.text + ' '

➍ chunk = chunk + token.text

   print(chunk)

迭代标记时,我们只选择名词 ➊。接下来,在内层循环中,我们迭代名词的子节点 ➋,只选择那些是限定词或形容词的标记来构成名词短语(名词短语还可以包含其他词性,比如副词) ➌。然后我们将名词添加到短语中 ➍。因此,脚本的输出应与前一个示例相同。

试试看

注意,用于修饰名词的词(限定词和形容词)始终是名词的左侧句法子节点。这使得我们能够在之前的代码中将Token.children替换为Token.lefts,然后根据需要移除对子节点是限定词或形容词的检查。

重写之前的代码片段,整合此处建议的修改。最终生成的名词短语集应该与您的脚本中的保持一致。

Span 对象

Span 对象是 Doc 对象的一个切片。在前面的部分中,您已经看到了如何将它作为句子和名词短语的容器,分别来源于doc.sentsdoc.noun_chunks

Span 对象的使用不仅限于作为句子或名词短语的容器。我们还可以通过指定索引范围,将文档中相邻的多个标记包含在内,如以下示例所示:

>>> doc=nlp('I want a green apple.')

>>> doc[2:5]

a green apple

Span 对象包含几个方法,其中最有趣的一个是span.merge(),它允许我们将 Span 合并为单一标记,从而重新标记文档。当文本包含由多个单词组成的名称时,这非常有用。

以下示例中的句子包含了两个由多个单词组成的地名(“Golden Gate Bridge”和“San Francisco”),我们可能希望将它们归为一类。默认的标记化方式不会将这些多词地名识别为单一标记。查看当我们列出文本的标记时会发生什么:

>>> doc = nlp(u'The Golden Gate Bridge is an iconic landmark in San Francisco.')

>>> [doc[i] for i in range(len(doc))]

[The, Golden, Gate, Bridge, is, an, iconic, landmark, in, San, Francisco, .]

每个单词和标点符号都是它自己的标记。

使用span.merge()方法,我们可以改变这种默认行为:

>>> span = doc[1:4]

>>> lem_id = doc.vocab.strings[span.text]

>>> span.merge(lemma = lem_id)

Golden Gate Bridge

在这个示例中,我们为“Golden Gate Bridge”span 创建一个词形,然后将该词形作为参数传递给span.merge()。(准确地说,我们传递了通过doc.vocab.string属性获取的词形 ID。)

请注意,span.merge()方法默认不会合并相应的词形。当没有参数时,它将合并的词元的词形设置为被合并的 span 中第一个词元的词形。为了指定我们希望分配给合并词元的词形,我们将其作为词形参数传递给span.merge(),如下面所示。

让我们检查一下词形还原器、词性标注器和依存解析器是否能够正确处理新创建的词形:

>>> for token in doc:

      print(token.text, token.lemma_, token.pos_, token.dep_)

这应该会产生以下输出:


The                the                DET   det

Golden Gate Bridge Golden Gate Bridge PROPN nsubj

is                 be                 VERB  ROOT

an                 an                 DET   det

iconic             iconic             ADJ   amod

landmark           landmark           NOUN  attr

in                 in                 ADP   prep

San                san                PROPN compound

Francisco          francisco          PROPN pobj

.                  .                  PUNCT punct

列表中显示的所有属性都已正确分配给“Golden Gate Bridge”词元。

试试看

前面示例中的句子还包含“San Francisco”,这是另一个多词地名,你可能希望将其合并为单个词元。为了实现这一点,请执行与前面的代码片段中“Golden Gate Bridge”span 相同的操作。

在确定文档中“San Francisco”span 的起始和结束位置时,别忘了,位于新创建的“Golden Gate Bridge”词元右侧的词元索引已经相应地向下移动了。

自定义文本处理流水线

在前面的章节中,你了解了 spaCy 的容器对象如何表示语言单元,如文本和单个词元,从而使你能够提取与它们相关的语言特征。现在,让我们来看看 spaCy API 中创建这些容器并将相关数据填充到其中的对象。

这些对象被称为处理流水线组件。正如你已经了解的,默认情况下,流水线设置包括词性标注器、依存解析器和实体识别器。你可以像这样检查你的 nlp 对象可用的流水线组件:

>>> nlp.pipe_names

['tagger', 'parser', 'ner']

如下文所讨论的,spaCy 允许你自定义流水线中的组件,以最适合你的需求。

禁用流水线组件

spaCy 允许你加载选定的流水线组件,并禁用不必要的组件。你可以通过设置disable参数来做到这一点:

nlp = spacy.load('en', disable=['parser'])

在这个示例中,我们创建了一个没有依存解析器的处理流水线。如果我们在文本上调用这个 nlp 实例,词元将不会收到依存标签。以下示例清楚地说明了这一点:

>>> doc = nlp(u'I want a green apple.')

>>> for token in doc:

...   print(➊token.text, ➋token.pos_, ➌token.dep_)

I     PRON

want  VERB

a     DET

green ADJ

apple NOUN

.     PUNCT

我们尝试为每个来自示例句子的词元打印出以下信息:文本内容➊、词性标记➋和依存标签➌。但依存标签没有出现。

逐步加载模型

你可以通过spacy.load()一次性执行多个操作来加载模型。例如,当你进行以下调用时:

nlp = spacy.load('en')

spaCy 在幕后执行以下步骤:

  1. 在查看要加载的模型名称时,spaCy 会识别它应该初始化哪个语言类。在这个例子中,spaCy 会创建一个包含共享词汇和其他语言数据的英语类实例。

  2. spaCy 遍历处理管道的名称,创建相应的组件,并将它们添加到处理管道中。

  3. spaCy 从磁盘加载模型数据,并将其提供给语言类实例。

这些实现细节被spacy.load()隐藏,通常情况下,这会为你节省时间和精力。但有时,你可能需要显式地实现这些步骤,以便对过程进行更精细的控制。例如,你可能需要将一个自定义组件加载到处理管道中。该组件可以打印有关管道中 Doc 对象的一些信息,如标记数量或某些词性是否存在。

像往常一样,更精细的控制要求你提供更多的信息。首先,你需要获取实际的模型名称,而不是指定快捷方式,这样你才能获取模型包的路径。

你可以按照以下方式识别模型的完整名称:

>>> print(nlp.meta['lang'] + '_' + nlp.meta['name'])

en_core_web_sm

代码中使用的nlp.meta属性是一个字典,包含已加载模型的元数据。在此示例中,你需要的是模型的语言和模型的名称。

既然你知道了模型的名称,你可以通过使用get_package_path工具函数来找到它在系统中的位置:

>>> from spacy import util

>>> util.get_package_path('en_core_web_sm')

PosixPath('/usr/local/lib/python3.5/site-packages/en_core_web_sm')

这段代码中指定的路径可能会因你的 Python 安装目录不同而有所不同。但无论如何,这不是完整的路径。你需要再附加一个文件夹名。这个文件夹的名称由模型名称和附加的模型版本组成。(这是模型包所在的位置。)你可以通过以下方式确定它的名称:

>>> print(nlp.meta['lang'] + '_' + nlp.meta['name'] + '-' + nlp.

meta['version'])

en_core_web_sm-2.0.0

你可能还想查看与模型一起使用的管道组件列表。(了解在模型上下文中支持哪些组件以及因此可以加载到管道中是很重要的。)你可以通过nlp.meta属性的'pipeline'字段来获取此信息,如下所示(或通过在“自定义文本处理管道”一文的第 37 页(page 37)中介绍的nlp.pipe_names属性):

>>> nlp.meta['pipeline']

['tagger', 'parser', 'ner']

有了这些信息,我们可以创建一个脚本,按照本节开头提供的步骤进行操作:

   >>> lang = 'en'

   >>> pipeline = ['tagger', 'parser', 'ner']

   >>> model_data_path = '/usr/local/lib/python3.5/site-packages/en_core_web_sm/

   en_core_web_sm-2.0.0'

➊ >>> lang_cls = spacy.util.get_lang_class(lang) 

   >>> nlp = lang_cls() 

➋ >>> for name in pipeline:

➌ ...   component = nlp.create_pipe(name) 

➍ ...   nlp.add_pipe(component)

➎ >>> nlp.from_disk(model_data_path)

在这个脚本中,我们使用spacy.util.get_lang_class() ➊来加载一个语言类。加载哪个类取决于作为参数指定的两字母语言代码。在这个例子中,我们加载英语。接下来,在一个循环中 ➋,我们创建 ➌ 并添加 ➍ 管道组件到处理管道中。然后,我们从磁盘加载一个模型,指定你机器上使用的路径 ➎。

看这段代码,可能会觉得一旦我们将管道组件添加到处理管道中,它们就变得可用。实际上,在加载模型数据之前,我们不能使用它们,所以如果我们省略脚本中的最后一行代码,我们甚至无法使用这个 nlp 实例创建 Doc 对象。

自定义管道组件

通过自定义管道组件,你可以最好地满足应用程序的需求。例如,假设你希望模型的命名实体识别系统将“Festy”识别为一个城市区。默认情况下,它会将其识别为一个组织,如下所示:

>>> doc = nlp(u'I need a taxi to Festy.')

>>> for ent in doc.ents:

...  print(ent.text, ent.label_)

Festy ORG

标签ORG代表公司、机构和其他组织。但你希望让实体识别器将其分类为DISTRICT类型的实体。

实体识别器组件在 spaCy API 中作为EntityRecognizer类实现。通过使用该类的方法,你可以初始化一个ner实例,然后将其应用于文本。在大多数情况下,你无需显式执行这些操作;spaCy 在你创建 nlp 对象并创建 Doc 对象时会自动为你处理这些操作。

但是当你想用自己的示例更新现有模型的命名实体识别系统时,你需要显式地使用一些ner对象的方法。

在以下示例中,你首先需要向支持的实体类型列表中添加一个名为DISTRICT的新标签。然后你需要创建一个训练示例,这将是你向实体识别器展示的内容,让它学习如何将DISTRICT标签应用到文本中。准备步骤的最简单实现可能如下所示:

LABEL = 'DISTRICT'

TRAIN_DATA = [

➊ ('We need to deliver it to Festy.', {

    ➋ 'entities': [(25, 30, 'DISTRICT')]

  }),

➌ ('I like red oranges', {

'entities': []

  })

]

为了简单起见,这个训练集只包含两个训练样本(通常,你需要提供更多的样本)。每个训练样本包括一个句子,这个句子可能包含也可能不包含需要分配新实体标签的实体 ➊。如果样本中包含实体,你需要指定它的起始和结束位置 ➋。训练集中的第二个句子根本不包含“Festy”这个词 ➌。这是因为训练过程的组织方式。第十章会更深入地讲解这个过程的细节。

下一步是向实体识别器添加一个新的实体标签DISTRICT:但在此之前,你必须获取ner管道组件的实例。你可以通过以下方式实现:

ner = nlp.get_pipe('ner')

一旦你有了ner对象,你可以使用ner.add_label()方法向其中添加新的标签,如下所示:

ner.add_label(LABEL)

另一个在开始训练实体识别器之前需要进行的操作是禁用其他管道,以确保在训练过程中只有实体识别器会被更新:

nlp.disable_pipes('tagger')

nlp.disable_pipes('parser')

然后你可以开始使用本节前面创建的TRAIN_DATA列表中的训练样本来训练实体识别器:

optimizer = nlp.entity.create_optimizer()

import random

for i in range(25):

    random.shuffle(TRAIN_DATA)

    for text, annotations in TRAIN_DATA:

        nlp.update([text], [annotations], sgd=optimizer)

在训练过程中,样本示例会以循环的方式随机顺序展示给模型,以便有效地更新底层模型的数据,避免根据训练示例的顺序进行任何泛化。执行将花费一些时间。

一旦前面的代码成功完成,你可以测试更新后的优化器如何识别 Festy 这个标记:

>>> doc = nlp(u'I need a taxi to Festy.')

>>> for ent in doc.ents:

... print(ent.text, ent.label_) 

...

Festy DISTRICT

根据输出,它工作正常。

请记住,当你关闭此 Python 解释器会话时,你刚刚所做的更新将会丢失。为了解决这个问题,Pipe类——EntityRecognizer类和其他处理流程组件类的父类——有一个to_disk()方法,可以将管道序列化到磁盘:

>>> ner.to_disk('/usr/to/ner')

现在你可以使用from_disk()方法将更新后的组件加载到新的会话中。为了确保它正常工作,关闭当前的解释器会话,启动一个新的会话,然后运行以下代码:

   >>> import spacy

   >>> from spacy.pipeline import EntityRecognizer

➊ >>> nlp = spacy.load('en', disable=['ner'])

➋ >>> ner = EntityRecognizer(nlp.vocab)

➌ >>> ner.from_disk('/usr/to/ner')

➍ >>> nlp.add_pipe(ner)

你加载模型,禁用其默认的ner组件 ➊。接着,你创建一个新的ner实例 ➋,并将其从磁盘加载数据 ➌。然后,你将ner组件添加到处理流程中 ➍。

现在你可以这样测试它:

>>> doc = nlp(u'We need to deliver it to Festy.')

>>> for ent in doc.ents:

... print(ent.text, ent.label_)

Festy DISTRICT

如你所见,实体识别器正确地标记了“Festy”这个名字。

虽然我只向你展示了如何定制命名实体识别器,但你也可以以类似的方式定制其他处理流程组件。

使用 spaCy 的 C 级数据结构

即使是 spaCy,涉及大量文本处理的自然语言处理(NLP)操作也可能非常耗时。例如,你可能需要为某个特定名词编写最合适的形容词列表,为此你需要检查大量的文本。如果处理速度对你的应用程序至关重要,spaCy 允许你利用 Cython 的 C 级数据结构和接口。Cython 是 spaCy 编写的语言之一(另一个是 Python)。因为 Cython 是 Python 的超集,所以 Cython 几乎认为所有 Python 代码都是有效的 Cython 代码。除了 Python 的功能,Cython 还允许你本地调用 C 函数并声明快速的 C 类型,从而使编译器能够生成非常高效的代码。你可能希望使用 Cython 来加速耗时的文本处理操作。

spaCy 的核心数据结构作为 Cython 对象实现,spaCy 的公共 API 允许你访问这些结构。有关详细信息,请参阅文档中的 Cython 架构页面,链接为 spacy.io/api/cython/

原理

要在 spaCy 中使用 Cython 代码,你必须将其转换为 Python 扩展模块,然后可以将其导入到你的程序中,如图 3-3 所示。

图片

图 3-3:从 Cython 脚本构建 Python 扩展模块

你可以通过将 Cython 代码保存为.pyx文件,然后运行一个setup.py Python 脚本来完成这一步,该脚本首先将 Cython 代码转换为相应的 C 或 C++代码,然后调用 C 或 C++编译器。脚本会生成 Python 扩展模块。

准备工作环境并获取文本文件

在开始构建 Cython 代码之前,你需要在机器上安装 Cython 并获取一个大型文本文件作为工作材料。

使用pip在你的机器上安装 Cython:

pip install Cython

接下来,为了模拟一个耗时的任务并衡量性能,你需要一个大型文本文件。为此,你可以使用一个Wikipedia dump file,它包含了一组用 XML 包装的页面。Wikipedia dump 文件可以在dumps.wikimedia.org/enwiki/latest/下载。滚动到enwiki-latest-pages-articles.xml-.bz2文件,并选择一个足够大进行测试的文件。但除非你愿意花几个小时等待你的机器完成测试代码,否则不要选择太大的文件。10–100MB 大小的 dump 文件应该足够合适。

下载完文件后,使用像gensim.corpora.wikicorpus这样的工具提取原始文本(radimrehurek.com/gensim/corpora/wikicorpus.html),该工具专为从 Wikipedia 数据库 dump 构建文本语料库而设计。

你的 Cython 脚本

现在我们来写一个 Cython 脚本,用于分析文本文件。为简单起见,假设你要做的就是统计提交文本中的人称代词数量。这意味着你需要统计被分配了PRP词性标签的 token 数量。

警告

如文档所述,旨在从 Cython 使用的 C 级别方法优先考虑速度而非安全性。代码中的错误可能导致执行突然崩溃。

在本地文件系统中的一个目录下,创建一个名为spacytext.pyx的文件,并将以下代码插入其中:

   from cymem.cymem cimport Pool

   from spacy.tokens.doc cimport Doc

   from spacy.structs cimport TokenC

   from spacy.typedefs cimport hash_t

➊ cdef struct DocStruct:

       TokenC* c

       int length

➋ cdef int counter(DocStruct* doc, hash_t tag):

       cdef int cnt = 0

       for c in doc.c[:doc.length]:

          if c.tag == tag:

             cnt += 1

       return cnt

➌ cpdef main(Doc mydoc):

       cdef int cnt

       cdef Pool mem = Pool()

       cdef DocStruct* doc_ptr = <DocStruct*>mem.alloc(1, sizeof(DocStruct))

       doc_ptr.c = mydoc.c

       doc_ptr.length = mydoc.length

       tag = mydoc.vocab.strings.add('PRP')

       cnt = counter(doc_ptr, tag)

       print(doc_ptr.length)

       print(cnt)

我们首先使用一组cimport语句导入必要的 Cython 模块,主要来自 spaCy 库。

然后,我们定义 Cython 结构体DocStruct,作为处理中文本的容器,以及TokenC*变量 ➊,它是一个指向 spaCy 中用于表示 Token 对象的数据容器TokenC结构体的指针。

接下来,我们定义一个 Cython 函数counter ➋,用于统计文本中的人称代词数量。

注意

cdef函数在导入该模块的 Python 代码中不可用。如果你想创建一个函数,既能在 Python 中可见,又能同时利用 C 级别的数据结构和接口,你需要将该函数声明为cpdef

最后,我们定义一个cpdef的 Cython/Python 主函数 ➌,我们可以在 Python 中使用它。

构建 Cython 模块

与 Python 不同,你必须编译 Cython 代码。你可以通过多种方式进行编译,其中最好的方式是编写一个 distutils/setuptools setup.py Python 脚本。在与 Cython 脚本相同的目录中创建一个setup.py文件。你的setup.py文件应包括以下代码:

   from distutils.core import setup

   from Cython.Build import cythonize

➊ import numpy

   setup(name='spacy text app',

     ➋ ext_modules=cythonize("spacytext.pyx", language="c++"),

      ➌ include_dirs=[numpy.get_include()]

          )

这是一个常规的 distutils/setuptools setup.py脚本,只是多了两个与我们当前示例相关的部分。首先,我们导入了numpy ➊,然后显式指定了在哪里找到库的.h文件 ➌。我们这样做是为了避免在某些系统中发生的numpy/arrayobject.h编译错误。我们使用另一个设置选项,language = "c++" ➋,以指示设置过程使用 C++编译器,而不是默认的 C 编译。

现在我们有了设置脚本,你可以构建你的 Cython 代码。你可以在系统终端中执行此操作,方法如下:

python setup.py build_ext --inplace

在编译过程中会显示一堆信息,其中一些可能是警告,但很少是关键性的。例如,你可能会看到这个消息,但它对过程并不关键:

#warning "Using deprecated NumPy API ...

测试模块

编译成功完成后,spacytext模块将被添加到你的 Python 环境中。为了测试新创建的模块,打开一个 Python 会话并运行以下命令:

>>> from spacytext import main

如果没有显示错误,你可以输入以下命令(假设你的文本数据保存在一个test.txt文件中):

   >>> import spacy

   >>> nlp = spacy.load('en')

➊ >>> f= open("test.txt","rb")

   >>> contents =f.read()

➋ >>> doc = nlp(contents[:100000].decode('utf8'))

➌ >>> main(doc)

   21498

   216

你以二进制模式打开包含文本数据的文件(在本例中)以获取一个字节对象 ➊。如果文件太大,你可以在创建 Doc 对象时只选择其部分内容 ➋。一旦你创建了 Doc 对象,就可以使用 Cython 创建的spacytext模块,调用其main()函数 ➌。

spacytext.main()函数生成的输出中的第一个数字表示提交的文本中找到的总词汇数。第二个数字表示在同一文本中找到的人称代词的数量。

总结

在本章中,你学习了 spaCy 中最重要的容器对象。你还学会了如何自定义文本处理管道,并使用 Cython 中的 spaCy C 级数据结构和接口。

第四章:提取和使用语言特征**

Image

在前几章中,你学习了如何访问语言特征,如词性标注、句法依赖关系和命名实体,作为文本处理管道的一部分。本章将向你展示如何使用词性标注和句法依赖标签来提取和生成文本,帮助你构建提问型聊天机器人、定位文本中的特定短语等。

几乎每个 NLP 应用程序都需要从文本中提取特定的信息,并生成与特定情境相关的新文本。例如,一个聊天机器人必须能够与用户进行对话,这意味着它必须能够识别出用户文本中的特定部分,然后生成适当的回应。让我们看看如何使用语言特征做到这一点。

使用词性标注提取和生成文本

词性标注可以帮助你从文本中检索特定类型的信息,它们还可以帮助你根据提交的句子生成全新的句子。在这一部分,我们将向你介绍一些新的词性标注,编写一个脚本来查找描述金额的短语,并将陈述句转化为疑问句。有关 spaCy 中用于英语模型的常见词性标注列表,请参见表 2-1 中的第 22 页。

数字、符号和标点符号标注

除了名词、动词和句子中的其他词的词性标注外,spaCy 还有符号、数字和标点符号的标注。让我们通过处理以下句子来看这些标注:

The firm earned $1.5 million in 2017.

首先,让我们从句子中的标记中提取粗粒度的词性特征,看看 spaCy 如何区分不同的词性类别。我们可以通过以下脚本实现:

>>> import spacy

>>> nlp = spacy.load('en')

>>> doc = nlp(u"The firm earned $1.5 million in 2017.")

>>> for token in doc:

...   print(token.text, ➊token.pos_, ➋spacy.explain(token.pos_))

...

我们为提交的句子创建一个 Doc 对象,然后输出粗粒度的词性标注➊。我们还使用spacy.explain()函数,它会返回给定语言特征的描述➋。

输出应如下所示:


The     DET   determiner

firm    NOUN  noun

earned  VERB  verb

$       SYM   symbol

1.5     NUM   numeral

million NUM   numeral

in      ADP   adposition

2017    NUM   numeral

.       PUNCT punctuation

注意到粗粒度标注器将数字、符号和标点符号区分为独立的类别。如你所见,它甚至能识别出拼写出来的“million”。

现在,为了比较,我们将输出该示例句子的粗粒度和细粒度词性标注,并为细粒度标注添加一列描述:

>>> for token in doc:

...   print(token.text, token.pos_, token.tag_, spacy.explain(token.tag_))

输出应如下所示:


The     DET   DT  determiner

firm    NOUN  NN  noun, singular

earned  VERB  VBD verb, past tense

$       SYM   $   symbol, currency

1.5     NUM   CD  cardinal number

million NUM   CD  cardinal number

in      ADP   IN  conjunction, subordinating or preposition

2017    NUM   CD  cardinal number

.       PUNCT .   punctuation mark, sentence closer

第二列和第三列分别包含粗粒度和细粒度词性标注。第四列给出了第三列中细粒度标注的描述。

细粒度标签将每个类别细分为子类别。例如,粗粒度类别 SYM(符号)有三个细粒度子类别。它们分别是:$ 代表货币符号,# 代表数字符号,SYM 代表其他所有符号,如 +、−、×、÷、=。这种细分在你需要区分不同类型符号时非常有用。例如,你可能在处理数学文章时,想要脚本识别数学公式中常见的符号。或者,你可能在编写一个需要识别财务报告中的货币符号的脚本。

注意

由于 spaCy 的词性标注器依赖于标记的上下文来生成标签,你可能会遇到不同上下文中使用的标记得到不同标签的情况。

现在让我们看看如何利用这些特定的词性标签来提取和生成文本。

提取货币描述

假设你正在开发一个处理财务报告的应用程序,需要从冗长乏味的文本中提取必要的信息。实际上,财务报告通常很大,但你真正需要的只是其中的数字。特别是,你关心的是那些表示金额且以货币符号开头的短语。例如,你的脚本应该能够从前述示例句子中提取出短语“$1.5 million”,而不是“2017”。

以下脚本展示了如何仅依靠词性标签提取句子中的短语。你可以将此脚本保存到文件中,然后运行它,或者在 Python 会话中执行代码:

   import spacy

   nlp = spacy.load('en')

   doc = nlp(u"The firm earned $1.5 million in 2017.")

   phrase = ''

➊ for token in doc:

   ➋ if token.tag_ == '$':

         phrase = token.text

         i = token.i+1

      ➌ while doc[i].tag_ == 'CD':

             phrase += doc[i].text + ' '

             i += 1

      ➍ break

phrase = phrase[:-1]

print(phrase)

我们遍历句子的标记 ➊,寻找一个词性标签为 $ 的标记 ➋。这个标签表示货币符号,通常用于表示金额的短语开头。一旦找到货币符号,我们就开始构建短语,检查紧随其后的标记是否是数字。为此,我们实现了一个 while 循环,循环中我们获取货币符号右侧的标记,并检查它们是否有 CD 标签,这是表示基数的词性标签 ➌。当我们遇到非数字标记时,我们退出 while 循环并跳出遍历句子标记的 for 循环 ➍。

当我们运行脚本时,输出应该如下所示:

$1.5 million

这正是我们所期望的输出。

请记住,分配给 $ 细粒度词性标签的货币符号不一定是$。这个词性标签也可能标记其他常见的货币符号,如 £ 和 €。例如,上述脚本会识别“£1,500,000”这个短语。

尝试这个

我们编写了这个脚本,用于从提交的句子中提取一个指代金额的短语。一旦脚本找到该短语,它便会完成执行。但在实践中,你可能会遇到一个句子,其中有多个这样的短语,例如以下示例:“公司在 2017 年赚了 150 万美元,而 2016 年赚了 120 万美元。”

修改脚本,使其能够提取句子中所有指代金额的短语。为此,删除break语句,以防止循环在找到第一个感兴趣的短语后结束。然后,将负责准备和打印找到的短语的代码(脚本中的最后两行)移入循环中,这样你就可以对每个在提交的句子中找到的感兴趣短语调用这两行代码。

将陈述转换为问题

假设你的自然语言处理应用程序必须能够根据提交的陈述生成问题。例如,聊天机器人与用户保持对话的一种方式是通过向用户提问确认性问题。当用户说“我确定”时,聊天机器人可能会问“你真的确定吗?”要做到这一点,聊天机器人必须能够生成一个相关的问题。

假设用户提交的句子是这样的:

I can promise it is worth your time.

这个句子包含了几个动词和代词,每个都有不同的形态。为了更清楚地看到这一点,我们来看看 spaCy 为这个句子中的标记分配的词性标注:

>>> doc = nlp(u"I can promise it is worth your time.")

>>> for token in doc:

...   print(token.text, token.pos_, token.tag_)

...

我们打印出这些标记、它们的粗粒度词性标注和精粒度词性标注,生成如下输出:


I       PRON PRP

can     VERB MD

promise VERB VB

it      PRON PRP

is      VERB VBZ

worth   ADJ  JJ

your    ADJ  PRP$

time    NOUN NN

.      PUNCT .

从精细的词性标注中,你可以区分句中动词和代词的形态类别。例如,精细的词性标注PRP标记人称代词,PRP$标记所有格代词,这使得你能够在程序中区分这两种代词。我们在处理这个示例时会需要这些信息。

这里讨论的句子的确认性问题可能如下所示(当然,另一个陈述会需要另一个确认性问题):

Can you really promise it is worth my time?

从人类的角度来看,从陈述中形成这个问题看起来相当简单:你只需要改变一些词语的顺序,适当地更改代词,并将副词修饰语“really”添加到主谓动词(紧跟在主语后面的那个动词)。但如何在程序中实现所有这些操作呢?

我们来看看一些词性标注。在这个示例句子中,形成问题所涉及的动词是“can”和“promise”。精细的词性标注将第一个动词“can”标记为情态助动词,而将第二个动词标记为基本形式的动词。注意,在前面的确认性问题中,情态助动词与人称代词交换了位置,这一过程称为倒装。我们需要在脚本中实现这一点。

关于代词,聊天机器人应该遵循常见的日常对话模式。表 4-1 总结了此类应用中的代词使用方法。

表 4-1: 聊天机器人中代词的使用

人称代词 物主代词
聊天机器人 我, 我自己 我的, 我的东西
用户 你的, 你的东西

换句话说,聊天机器人将自己称为“我”或“我自己”,并将用户称为“你”。

以下步骤概述了我们需要做的事情,以从原始陈述中生成一个问题:

  1. 将原句中的单词顺序从“主语 + 情态助动词 + 不定式动词”更改为“情态助动词 + 主语 + 不定式动词”。

  2. 将人称代词“I”(句子的主语)替换为“你”。

  3. 将物主代词“你的”替换为“我的”。

  4. 将副词修饰语“really”放置在动词“promise”之前,以强调后者。

  5. 将句末的标点符号“.”替换为“?”。

以下脚本实现了这些步骤:

  import spacy

  nlp = spacy.load('en')

  doc = nlp(u"I can promise it is worth your time.")

  sent = ''

  for i,token in enumerate(doc):

➊ if token.tag_ == 'PRP' and doc[i+1].tag_ == 'MD' and doc[i+2].tag_ == 'VB':

    ➋ sent = doc[i+1].text.capitalize() + ' ' + doc[i].text

       sent = sent + ' ' + ➌doc[i+2:].text

    ➍ break

 #By now, you should have: 'Can I promise it is worth your time.'

   #Retokenization

➎ doc=nlp(sent)

   for i,token in enumerate(doc):

 ➏ if token.tag_ == 'PRP' and token.text == 'I':

        sent = doc[:i].text + ' you ' + doc[i+1:].text

        break

 #By now, you should have: 'Can you promise it is worth your time.'

 doc=nlp(sent)

 for i,token in enumerate(doc):

➐ if token.tag_ == 'PRP$' and token.text == 'your':

       sent = doc[:i].text + ' my ' + doc[i+1:].text

       break

 #By now, you should have: 'Can you promise it is worth my time.' 

 doc=nlp(sent)

 for i,token in enumerate(doc):

   if token.tag_ == 'VB':

    ➑ sent = doc[:i].text + ' really ' + doc[i:].text

      break

 #By now, you should have: 'Can you really promise it is worth my time.'

 doc=nlp(sent)

➒ sent = doc[:len(doc)-1].text + '?'

 #Finally, you should have: 'Can you really promise it is worth my time?'

 print(sent)

我们在单独的 for 循环中执行前四个步骤。首先,我们遍历句子中的标记,改变主语和动词的顺序,使句子变为一个问题。在这个例子中,我们寻找一个紧跟在人称代词后的情态助动词(标记为 MD),并且后面跟着不定式动词 ➊。找到这一系列词后,我们将情态助动词立即移到人称代词前面,置于句首 ➋。

为了组成一个新句子,我们使用在 Python 中称为 切片 的技术,它允许我们通过指定起始和结束索引,从序列对象(如字符串或列表)中提取子序列。在这种情况下,我们可以对 Doc 对象应用切片,以从中提取给定的标记子序列。例如,slice doc[2:] 将包含从索引 2 开始到文档末尾的标记,在这个例子中是“保证这值得你花时间。” ➌。一旦我们将情态助动词移到新位置,就退出 for 循环 ➍。

你可能会想,为什么我们不直接使用人称代词和情态助动词的索引来进行倒装呢?因为我们知道人称代词位于索引 0,而情态助动词位于索引 1,为什么我们还需要使用一个循环,遍历整个词汇集来找到情态助动词的位置呢?动词不总是紧跟主语,因此应该是句子的第二个单词吗?

事实上,句子并不总是从主语开始。例如,如果句子是“果然,我可以保证这值得你花时间。”呢?在这种情况下,脚本会知道省略前两个词并从主语开始处理。

由于倒装,我们得到一个新的句子作为字符串。为了进一步处理这个句子,我们需要为它获取一个 Doc 对象 ➎。

接下来,我们创建一个新的for循环,将个人代词“I”替换为个人代词“you”。为此,我们搜索个人代词(标记为PRP)。如果个人代词是“I”,我们将其替换为“you” ➏。然后我们退出for循环。

我们重复这个过程,通过搜索PRP$标签 ➐来将所有的物主代词“your”替换成“my”。然后,在一个新的for循环中,我们找到一个不定式形式的动词,并在它前面插入副词修饰语“really” ➑。

最后,我们将句子的句号替换为问号。这是唯一一个不需要使用循环的步骤。原因是,在所有可能的句子中,句号和问号都位于句子的末尾,因此我们可以通过len(doc)-1的索引可靠地找到它们 ➒。

当我们运行这段代码时,我们应该得到以下输出:

Can you really promise it is worth my time?

这个脚本是一个好的开始,但它并不能处理每一个提交的陈述句。例如,句子中可能包含一个除“I”之外的个人代词,但我们的脚本没有明确检查这一点。此外,有些句子没有助动词,比如句子“I love eating ice cream”(我喜欢吃冰淇淋)。在这种情况下,我们必须使用“do”这个词来构成问题,而不是像“can”或“should”这样的词,像这样:“Do you really love eating ice cream?” 但如果句子包含动词“to be”,比如“I am sleepy”(我困了),我们必须把动词移到句首,变成这样:“Are you sleepy?”

这个聊天机器人真正的实现必须能够为提交的句子选择合适的选项。你可以在“决定聊天机器人应该问什么问题”一节中看到一个“做”的例子,位于第 56 页。

尝试这个

检查“将陈述句转化为问题”中的脚本时,你可能会注意到其中一些代码块看起来非常相似,包含重复的操作。在每一步中,你都会替换句子中的某个部分,然后重新分词。这意味着你可以尝试将代码进行泛化,把重复的操作放入一个函数中。

在编写这样的函数之前,花点时间了解它需要接受哪些参数,以便执行你在脚本中看到的文本操作。特别地,你需要明确指定你要搜索的词元和你希望对其执行的操作,方法是将其替换为另一个词元或在它前面添加一个词元。

一旦你定义了这个函数,就可以编写调用它的主代码,实现与原脚本相同的功能。

在文本处理中使用句法依赖标签

正如你在“使用词性标签提取和生成文本”一节中学习的那样,词性标签是智能文本处理的强大工具。但在实际操作中,你可能需要了解更多关于句子中的词元信息,以便更智能地处理它。

例如,你可能需要知道一个人称代词是句子的主语还是语法宾语。有时候,这个任务很简单。人称代词“I”,“he”,“she”,“they”和“we”几乎总是主语。当用作宾语时,“I”变成“me”,比如在句子“A postman brought me a letter.”中。

但当涉及到一些其他人称代词时,比如“you”或“it”,它们作为主语或宾语时看起来是一样的,这一点可能不那么清晰。考虑以下两个句子:“I know you. You know me.” 在第一个句子中,“you”是动词“know”的直接宾语。在第二个句子中,“you”是动词的主语。

让我们使用句法依存关系标签和词性标签来解决这个问题。然后我们将应用句法依存关系标签来构建一个更好的问答聊天机器人版本。

区分主语和宾语

要程序化地确定像“you”或“it”这样的代词在给定句子中的角色,你需要检查分配给它的依存关系标签。通过将词性标签与依存关系标签结合使用,你可以获得更多关于句子中词汇的信息。

让我们回到前面的句子,并查看对其进行依存解析的结果:

>>> doc = nlp(u"I can promise it is worth your time.")

>>> for token in doc:

...   print(token.text, token.pos_, token.tag_, token.dep_, spacy.explain(token.dep_))

我们提取词性标签、依存关系标签以及依存关系标签的描述:

I       PRON  PRP  nsubj     nominal subject

can     VERB  MD   aux       auxiliary

promise VERB  VB   ROOT      None

it      PRON  PRP  nsubj     nominal subject

is      VERB  VBZ  ccomp     clausal complement

worth   ADJ   JJ   acomp     adjectival complement

your    ADJ   PRP$ poss      possession modifier

time    NOUN  NN   npadvmod  noun phrase as adverbial modifier

.       PUNCT .    punct     punctuation

第二列和第三列分别包含粗粒度和细粒度的词性标签。第四列包含依存关系标签,第五列包含这些依存关系标签的描述。

将词性标签和依存关系标签结合起来,可以为你提供更清晰的每个词汇在句子中的语法角色,比单独使用词性标签或依存关系标签更有帮助。例如,在这个例子中,分配给词汇“is”的词性标签VBZ表示它是第三人称单数现在时动词,而依存关系标签ccomp则表示“is”是一个从句补语(带有内部主语的依赖从句)。在这个例子中,“is”是动词“promise”的从句补语,内部主语是“it”。

为了弄清楚在“I know you. You know me.”中“you”的角色,我们需要查看以下为这些词汇分配的词性标签和依存关系标签:

I     PRON  PRP  nsubj  nominal subject

know  VERB  VBP  ROOT   None

you   PRON  PRP  dobj   direct object

.     PUNCT .    punct  punctuation

You   PRON  PRP  nsubj  nominal subject

know  VERB  VBP  ROOT   None

me    PRON  PRP  dobj   direct object

.     PUNCT .    Punct  punctuation

在这两种情况下,“you”都被分配了相同的词性标签:PRONPRP(粗粒度和细粒度标签,分别)。但这两个情况有不同的依存关系标签:第一个句子中的dobj和第二个句子中的nsubj

决定聊天机器人应该提问什么问题

有时候,你可能需要浏览句子的依存树以提取必要的信息。例如,考虑以下聊天机器人与用户之间的对话:

User: I want an apple.

Bot: Do you want a red apple?

User: I want a green apple.

Bot: Why do you want a green one?

聊天机器人能够通过提问继续对话。但请注意,名词“apple”是否有形容词修饰语在决定它应该提什么类型的问题时起着关键作用。

英语中有两种基本的提问类型:是/否问题和信息型问题。是/否问题,比如我们在“将陈述转为问题”一节中讨论的例子,在第 51 页,只有两个可能的答案:是或否。要形成这种类型的问题,可以将情态助动词放在主语前面,主语后面是主要动词。例如:“Could you modify it?”

信息型问题的回答应该提供比简单的“是”或“否”更多的信息。它们以疑问词开头,如“what”,“where”,“when”,“why”或“how”。在疑问词之后,形成信息型问题的过程与是/否问题相同。例如:“What do you think about it?”

在前面苹果示例中的第一个案例中,聊天机器人询问的是一个是/否问题。在第二个案例中,当用户用形容词“green”修饰名词“apple”时,聊天机器人会询问一个信息型问题。

图 4-1 中的流程图总结了这种方法。

image

图 4-1:输入句子中是否存在修饰语决定了聊天机器人提问的问题类型。

以下脚本只是分析提交的句子以决定提问什么类型的问题,然后形成适当的问题。我们将通过单独的部分来逐步讲解代码,但你应该将整个程序保存为一个名为question.py的文件。

首先导入sys模块,它提供了接受句子作为处理参数的功能:

import spacy

import sys

这是对之前脚本的改进,之前我们是将要分析的句子硬编码在程序中。现在用户可以提交自己的句子作为输入。

接下来,我们定义一个函数,它可以识别并提取提交文档中任何作为直接宾语的名词短语。例如,如果你提交的文档包含句子“I want a green apple.”,它将返回短语“a green apple”:

 def find_chunk(doc):

   chunk = ''

➊ for i,token in enumerate(doc):

  ➋ if token.dep_ == 'dobj':

     ➌ shift = len([w for w in token.children])

     ➍ #print([w for w in token.children])

     ➎ chunk = doc[i-shift:i+1]

        break

 return chunk

我们遍历提交的句子中的标记 ➊,并通过检查其依赖标签是否为dobj ➋来寻找作为直接宾语的单词。在句子“I want a green apple.”中,直接宾语是名词“apple”。一旦我们找到了直接宾语,就需要确定它的句法子项 ➌,因为它们组成了我们用来决定应该提问什么类型问题的部分。为了调试目的,我们还可能需要查看直接宾语的子项 ➍。

为了提取块,我们对Doc对象进行切片,计算切片的起始和结束索引,方法如下:起始索引是直接宾语的索引减去其语法子节点的数量。正如你可能猜到的,这是最左侧子节点的索引。结束索引是直接宾语的索引加一,所以块中包含的最后一个标记是直接宾语 ➎。

为了简化,脚本中实现的算法假设直接宾语只有左向的子节点。事实上,情况并非总是如此。例如,在以下句子中,“I want to touch a wall painted green.”(我想摸一面涂了绿色的墙。)我们需要检查直接宾语“wall”的左右子节点。此外,因为“green”不是“wall”的直接子节点,我们需要沿着依存树走,确定“green”是“wall”的修饰语。我们将在第六章中更深入地讨论前修饰语和后修饰语。

以下函数检查块并决定聊天机器人应提出什么样的问题:

 def determine_question_type(chunk):

➊ question_type = 'yesno'

   for token in chunk:

  ➋ if token.dep_ == 'amod':

    ➌ question_type = 'info'

   return question_type

我们将question_type变量初始化为yesno,表示是/否问题类型 ➊。然后,在提交的块中,我们搜索标记为amod的标记,它表示形容词修饰语 ➋。若找到,我们将question_type变量设置为'info',表示信息性问题类型 ➌。

一旦我们确定了使用哪种问题类型,以下函数会根据提交的句子生成一个问题:


def generate_question(doc, question_type):

  sent = ''

  for i,token in enumerate(doc):

    if token.tag_ == 'PRP' and doc[i+1].tag_ == 'VBP':

     sent = 'do ' + doc[i].text

     sent = sent + ' ' + doc[i+1:].text

     break

  doc=nlp(sent)

  for i,token in enumerate(doc):

    if token.tag_ == 'PRP' and token.text == 'I':

     sent = doc[:i].text + ' you ' + doc[i+1:].text

     break

   doc=nlp(sent)

➊ if question_type == 'info':

     for i,token in enumerate(doc):

       if token.dep_ == 'dobj':

         sent = 'why ' + doc[:i].text + ' one ' + doc[i+1:].text

         break

➋ if question_type == 'yesno':

     for i,token in enumerate(doc):

       if token.dep_ == 'dobj':

     ➌ sent = doc[:i-1].text + ' a red ' + doc[i:].text

        break

   doc=nlp(sent)

   sent = doc[0].text.capitalize() +' ' + doc[1:len(doc)-1].text + '?'

   return sent

在一系列for循环中,我们通过进行倒装和改变人称代词,将提交的陈述句转化为疑问句。在这个例子中,因为陈述句中没有情态助动词,我们在人称代词前加上动词“do”来构成疑问句。(记住,这只适用于某些句子;在更完整的实现中,我们需要通过编程来确定使用哪种处理方法。)

如果question_type被设置为info,我们在问题的开头加上“why” ➊。如果question_type变量设置为yesno ➋,我们在问题中的直接宾语前插入一个形容词来修饰它。在这个例子中,为了简单起见,我们硬编码了形容词。我们选择了形容词“red” ➌,它在某些句子中可能听起来有些奇怪。例如,我们可以说,“Do you want a red orange?”(你想要一个红色的橙子?),但不能说,“Do you want a red idea?”(你想要一个红色的想法?)。在这个聊天机器人的更好实现中,我们可以找到一种方法来编程确定适合的形容词来修饰直接宾语。我们将在第六章中回到这个话题。

另外需要注意的是,这里使用的算法假设提交的句子以标点符号结束,比如“.” 或 “!”。

现在我们已经定义了所有函数,以下是脚本的主块:

➊ if len(sys.argv) > 1:

     sent = sys.argv[1]

     nlp = spacy.load('en')

  ➋ doc = nlp(sent)

  ➌ chunk = find_chunk(doc)

  ➍ if str(chunk) == '':

       print('The sentence does not contain a direct object.')

       sys.exit()

  ➎ question_type = determine_question_type(chunk)

  ➏ question = generate_question(doc, question_type)

     print(question)

  else:

     print('You did not submit a sentence!')

首先,我们检查用户是否作为命令行参数传递了一个句子➊。若提交了句子,我们会将 spaCy 的处理管道应用于该句子,创建一个 Doc 对象实例➋。

然后,我们将文档发送给find_chunk函数,该函数应该返回一个包含直接宾语的名词短语,例如“一个绿色的苹果”,以便进一步处理➌。如果提交的句子中没有这样的名词短语➍,我们将收到消息“该句子不包含直接宾语”。

接下来,我们将刚刚提取的短语传递给determine_question_type函数,该函数根据短语的结构决定要提出什么问题➎。

最后,我们将提交的句子和问题类型传递给generate_question函数,该函数将生成一个适当的问题并将其作为字符串返回➏。

脚本的输出取决于提交的具体句子。以下是一些可能的变体:

➊ $ python question.py 'I want a green apple.'

   Why do you want a green one?

➋ $ python question.py 'I want an apple.'

   Do you want a red apple?

➌ $ python question.py 'I want...'

   The sentence does not contain a direct object.

➍ $ python question.py

   You did not submit a sentence!

如果我们提交一个包含形容词修饰语的句子,例如“绿色”修饰直接宾语“苹果”,脚本应生成一个信息性问题➊。

如果句子包含没有形容词修饰语的直接宾语,脚本应返回一个是/否问题➋。

如果我们提交一个没有直接宾语的句子,脚本应立即识别这一点并要求我们重新提交➌。

最后,如果我们忘记提交句子,脚本应返回一个适当的消息➍。

试试这个

如前所述,上节中讨论的脚本并不适用于所有句子。该脚本通过添加“do”来构成问题,但只适用于不含助动词的句子。

增强该脚本的功能,使其能够处理包含情态助动词的陈述句。例如,对于以下陈述句:“我可能想要一个绿色的苹果”,脚本应生成“你为什么可能想要一个绿色的苹果?”有关如何将包含情态助动词的陈述句转化为问题的详细信息,请参考《将陈述句转化为问题》(见第 51 页)。

总结

语言特征是所有 NLP 任务的核心。本章向你介绍了一些处理文本和生成文本的智能技术,运用了语言特征。你学会了如何提取某种类型的短语(例如,指代金额的短语),然后编写了一个使用依赖标签和词性标签的脚本,生成了对用户提交句子的有意义的回应。

我们将在第六章中重新讨论语言特征,在那里你将把它们应用于更复杂的场景。

第五章:使用词向量

图片

词向量是表示自然语言单词意义的一系列实数。如同你在第一章中学到的,它们使机器能够理解人类语言。在本章中,你将使用词向量来计算不同文本的语义相似性,这将使你能够,例如,根据文本所涵盖的主题对这些文本进行分类。

你将首先从概念上了解词向量,这样你就能大致了解如何在数学上计算以向量形式表示的单词之间的语义相似性。接着,你将学习机器学习算法如何生成在 spaCy 模型中实现的词向量。你将使用 spaCy 的 相似性方法,该方法通过比较容器对象的词向量来确定它们意义的接近程度。你还将学习如何在实际中使用词向量并执行预处理步骤,例如选择关键词,以提高你的操作效率。

理解词向量

在构建统计模型时,我们将单词映射到反映单词语义相似性的实数向量。你可以将词向量空间想象成一个云,在这个云中,具有相似意义的词的向量相互靠近。例如,表示“土豆”这个词的向量应该比表示“哭泣”这个词的向量更接近表示“胡萝卜”这个词的向量。为了生成这些词向量,我们必须能够对这些词的意义进行编码。本节将概述几种编码意义的方法。

通过坐标定义意义

生成有意义的词向量的一种方法是将现实世界中的物体或类别分配给每个词向量的坐标。例如,假设你正在为以下词汇生成词向量:罗马、意大利、雅典和希腊。词向量应在数学上反映出罗马是意大利的首都,并且与意大利的关系是雅典所没有的。同时,它们还应该反映出雅典和罗马都是首都城市,而希腊和意大利是国家。表格 5-1 展示了该词向量空间可能以矩阵形式呈现的样子。

表格 5-1: 简化的词向量空间

国家 首都 希腊语 意大利语
意大利 1 0 0 1
罗马 0 1 0 1
希腊 1 0 1 0
雅典 0 1 1 0

我们已经将每个词的意义分布在四维空间的坐标上,表示“国家”、“首都”、“希腊语”和“意大利语”这几个类别。在这个简化的例子中,一个坐标值可以是 1 或 0,表示一个对应的词是否属于某个类别。

一旦你拥有一个词向量空间,其中数字的向量捕捉了相应单词的意义,你就可以对这个向量空间进行向量运算,以深入理解一个单词的意义。为了找出雅典是哪个国家的首都,你可以使用以下公式,其中每个标记代表其对应的向量,X 是一个未知的向量:

Italy - Rome = X - Athens

这个公式表达了一种类比,其中 X 代表与雅典的关系类似于意大利罗马之间关系的词向量。为了解出 X,我们可以像这样重新写这个公式:

X = Italy - Rome + Athens

我们首先通过减去相应的向量元素,将罗马的向量从意大利的向量中减去。然后我们将得到的向量和雅典的向量相加。表 5-2 总结了这个计算过程。

表 5-2: 在词向量空间上执行向量数学运算

国家 首都 希腊 意大利
意大利 1 0 0 1
+ 罗马 0 1 0 1
雅典 0 1 1 0
希腊 1 0 1 0

通过从意大利的词向量中减去罗马的词向量,然后再加上雅典的词向量,我们得到一个等于希腊的词向量。

使用维度表示意义

尽管我们刚才创建的向量空间只有四个类别,但现实世界中的向量空间可能需要成千上万个类别。如此大的向量空间对于大多数应用来说不切实际,因为它需要一个巨大的词嵌入矩阵。例如,如果你有 10,000 个类别和 1,000,000 个实体需要编码,你将需要一个 10,000 × 1,000,000 的嵌入矩阵,进行操作时将非常耗时。减少嵌入矩阵大小的明显方法是减少向量空间中的类别数量。

现实世界中的词向量空间不是使用坐标来表示所有类别,而是使用向量之间的距离来量化和分类语义相似性。各个维度通常没有固有的意义,而是表示向量空间中的位置,向量之间的距离则表明相应单词意义的相似度。

以下是从fastText(一个词向量库)提取的 300 维词向量空间的一个片段,您可以在* fasttext.cc/docs/en/english-vectors.html *下载它:

compete   -0.0535 -0.0207 0.0574 0.0562 ... -0.0389 -0.0389

equations -0.0337 0.2013 -0.1587 0.1499 ...  0.1504 0.1151

Upper     -0.1132 -0.0927 0.1991 -0.0302 ... -0.1209 0.2132

mentor     0.0397 0.1639 0.1005 -0.1420 ... -0.2076 -0.0238

reviewer  -0.0424 -0.0304 -0.0031 0.0874 ... 0.1403 -0.0258

每一行包含一个词,以一个多维空间中的实数向量表示。图形上,我们可以用二维或三维投影表示这样一个 300 维的向量空间。为了准备这样的投影,我们可以分别使用向量的前两或前三个主坐标。图 5-1 展示了 300 维向量空间中向量的二维投影。

image

图 5-1:二维投影的多维向量空间片段

你可能会注意到的一个有趣细节是,分别连接希腊与雅典、意大利与罗马的线几乎是平行的。它们的长度看起来也相当可比。实际上,这意味着,如果你有上述四个向量中的三个,你可以计算出缺失向量的大致位置,因为你知道如何移动向量以及移动的距离。

图中的向量展示了国家和首都之间的关系,但它们也可以代表其他类型的关系,例如男女关系、动词时态等。

相似度方法

在 spaCy 中,每种类型的容器对象都有一个相似度方法,该方法允许你通过比较单词向量计算两个容器对象之间的语义相似度估计值,无论它们的类型如何。为了计算跨度和文档的相似度(它们没有自己的单词向量),spaCy 会对它们所包含的词汇的单词向量进行平均。

注意

spaCy 的小型模型(那些模型大小指示符为 %sm 的模型)不包括单词向量。你仍然可以使用这些模型的相似度方法来比较标记、跨度和文档,但结果的准确性较低。

即使两个容器对象不同,你也可以计算它们的语义相似度。例如,你可以将 Token 对象与 Span 对象进行比较,将 Span 对象与 Doc 对象进行比较,等等。

以下示例计算了一个 Span 对象与一个 Doc 对象的相似度:

>>> doc=nlp('I want a green apple.')

>>> doc.similarity(doc[2:5])

0.7305813588233471

这段代码计算了句子“I want a green apple.”与从同一句话中提取的短语“a green apple”之间的语义相似度估计值。正如你所看到的,计算出的相似度足够高,可以认为这两个对象的内容相似(相似度的范围是从 0 到 1)。

不出所料,当你将一个对象与它自身进行比较时,similarity() 方法返回 1:

>>> doc.similarity(doc)

1.0

>>> doc[2:5].similarity(doc[2:5])

1.0

你还可以将一个 Doc 对象与另一个 Doc 对象中的一个片段进行比较:

>>> doc2=nlp('I like red oranges.')

>>> doc2.similarity(doc[2:5])

0.28546574467463354

这里,我们将存储在 doc2 中的句子“I like red oranges.”与从 doc 中提取的“a green apple”跨度进行比较。在这种情况下,这次的相似度并不高。是的,橙子和苹果都是水果(相似度方法识别了这一点),但动词“want”和“like”表达的是不同的存在状态。

你还可以比较两个标记。在以下示例中,我们将 Token 对象“oranges”与包含单个标记“apple”的 Span 对象进行比较。

>>> token = doc2[3:4][0]

>>> token

oranges

>>> token.similarity(doc[4:5])

0.3707084280155993

首先,我们显式地将包含单个标记“oranges”的 Span 对象通过引用跨度中的第一个元素转换为 Token 对象。然后,我们计算它与“apple”跨度的相似度。

similarity() 方法能够识别属于相同或相似类别并经常出现在相关上下文中的单词,对这些单词显示出较高的相似度。

选择用于语义相似度计算的关键词

相似度方法将为你计算语义相似度,但为了让该计算结果有用,你需要选择正确的关键字进行比较。为了理解其原因,考虑以下文本片段:

Redwoods are the tallest trees in the world. They are most common in the coastal forests of California.

我们可以根据想要使用的类别集以多种方式对这段文本进行分类。例如,如果我们在寻找关于地球上最高植物的文本,“最高树木”和“全球范围内”将是关键短语。将这些短语与搜索短语“最高植物”和“地球上的”进行比较,应该会显示出很高的语义相似度。我们可以通过使用 Doc 对象的 doc.noun_chunk 属性提取名词短语,然后使用相似度方法检查这些名词短语与搜索短语的相似度来实现这一点。

但如果我们在寻找关于世界上某个地方的文本,那么“加利福尼亚”将是关键字。当然,我们无法预先知道文本中会出现哪个地理政治名称:它可能是加利福尼亚,或者是亚马逊地区。无论是什么,它都应该在语义上类似于“地理学”这样的词,我们可以将其与文本中的其他名词进行比较(或者,更好地,仅与其命名实体进行比较)。如果我们能够确定有很高的相似度,我们就可以假设该命名实体代表一个地理政治名称。(我们也可以提取 Token 对象的 token.ent_type 属性来实现这一点,正如第二章中所描述的。但我们无法使用命名实体识别来检查那些不是命名实体的单词的相似性,比如水果。)

安装词向量

如果你的 Python 环境中安装了 spaCy 模型,你可以立即开始使用词向量。你还可以安装第三方词向量包。不同的统计模型使用不同的词向量,因此你进行的操作结果会略有不同,具体取决于你使用的模型。你可以尝试几个模型,以确定哪个模型在你的特定应用中表现更好。

利用 spaCy 模型附带的词向量

许多 spaCy 模型都包含词向量。例如,en_vectors_web_lg 包含超过一百万个在 300 维向量空间中定义的独特词向量。有关特定模型的详细信息,请查看 github.com/explosion/spacy-models/releases/

通常,小型模型(其名称以 sm 结尾)不包含词向量。相反,它们包含上下文敏感的 张量,这仍然允许你使用相似度方法比较标记、跨度和文档——尽管这样做的准确性较低。

要跟随本章中的示例,你可以使用任何 spaCy 模型,甚至是小型模型。但如果你安装一个较大的模型,结果会更加准确。关于如何安装 spaCy 模型的详细信息,请参考 “Installing Statistical Models for spaCy” 章节的 第 16 页。请注意,你的环境中可能安装了多个模型。

使用第三方词向量

你还可以使用 spaCy 的第三方词向量包。你可以检查一个第三方包是否比 spaCy 模型中提供的原生词向量更适合你的应用。例如,你可以使用一个包含英语词向量的 fastText 预训练模型,下载地址是 fasttext.cc/docs/en/english-vectors.html。包的名称会标识包的大小、词向量的维度以及用于训练词向量的数据类型。例如,wiki-news-300d-1M.vec.zip 表示它包含一百万个 300 维度的词向量,这些向量是在 Wikipedia 和 statmt.org 新闻数据集上训练的。

下载包后,解压它,然后从包中的词向量创建一个新的模型,这样你就可以在 spaCy 中使用它。为此,导航到你保存包的文件夹,然后使用 init-model 命令行工具,如下所示:

$ python -m spacy init-model en /tmp/en_vectors_wiki_lg --vectors-loc wiki-news-300d-1M.vec

该命令将从 wiki-news-300d-1M.vec 文件中提取的词向量转换为 spaCy 格式,并为它们创建新的模型目录 /tmp/en_vectors_wiki_lg。如果一切顺利,你会看到以下信息:

Reading vectors from wiki-news-300d-1M.vec

Open loc

999994it [02:05, 7968.84it/s]

Creating model...

0it [00:00, ?it/s]

    Successfully compiled vocab

    999731 entries, 999994 vectors

一旦你创建了模型,你可以像使用常规的 spaCy 模型一样加载它:

nlp = spacy.load('/tmp/en_vectors_wiki_lg')

然后你可以像往常一样创建一个 Doc 对象:

doc = nlp(u'Hi there!')

与常规的 spaCy 模型不同,为在 spaCy 中使用而转换的第三方模型可能不支持 spaCy 针对 doc 对象中文本进行的一些操作。例如,如果你尝试使用 doc.sents 将 doc 拆分成句子,你会遇到以下错误:ValueError: [E030] Sentence boundaries unset...

比较 spaCy 对象

我们将使用词向量来计算容器对象的相似性,这是使用词向量的最常见任务。在本章的剩余部分,我们将探讨一些你可能需要确定语言单位语义相似性的场景。

使用语义相似性进行分类任务

确定两个对象的句法相似性可以帮助你将文本分类或挑选出相关的文本。例如,假设你在网站上筛选用户评论,以找出所有与“fruits”相关的评论。假设你有以下需要评估的语句:

I want to buy this beautiful book at the end of the week. 

Sales of citrus have increased over the last year. 

How much do you know about this type of tree?

你可以轻松识别出只有第二句话与水果直接相关,因为它包含了“柑橘”这个词。但要通过编程选择出这个句子,你必须将“水果”这个词的词向量与样本句子中的词向量进行比较。

让我们从做这项任务最简单但最不成功的方法开始:将“水果”与每个句子进行比较。如前所述,spaCy 通过比较两个容器对象的相应词向量来确定它们的相似度。为了将单个 Token 与整个句子进行比较,spaCy 将句子的词向量进行平均,从而生成一个全新的向量。以下脚本将每个前述句子样本与“水果”这个词进行比较:

   import spacy

   nlp = spacy.load('en')

➊ token = nlp(u'fruits')[0]

➋ doc = nlp(u'I want to buy this beautiful book at the end of the week. Sales of

   citrus have increased over the last year. How much do you know about this type

   of tree?')

➌ for sent in doc.sents:

     print(sent.text)

 ➍ print('similarity to', token.text, 'is', token.similarity(sent),'\n')

我们首先为“水果”这个词创建一个 Token 对象 ➊。然后我们将管道应用于我们正在分类的句子,创建一个单一的 Doc 对象来保存它们 ➋。我们将文档切割成句子 ➌,然后打印出每个句子及其与“水果”这个 Token 的语义相似度,这个相似度是通过 Token 对象的 similarity 方法获得的 ➍。

输出应该像这样(尽管实际数字将取决于你使用的模型):

I want to buy this beautiful book at the end of the week.

similarity to fruits is 0.06307832979619851 

Sales of citrus have increased over the last year.

similarity to fruits is 0.2712141843864381 

How much do you know about this type of tree?

similarity to fruits is 0.24646341651210604

“水果”这个词与第一句话的相似度非常小,表明这句话与水果没有关系。第二句话——包含“柑橘”这个词的那一句——与“水果”最为相关,这意味着脚本正确地识别了相关句子。

但请注意,脚本也将第三句话识别为与水果相关,可能是因为它包含了“树”这个词,而水果长在树上。认为相似度计算算法“知道”橙子和柑橘是水果是天真的。它所知道的只是这些词(“橙子”和“柑橘”)通常与“水果”这个词共享相同的上下文,因此它们在向量空间中靠得很近。但“树”这个词也常常出现在与“水果”相关的上下文中。例如,“果树”这个短语并不罕见。因此,计算出的“水果”(或其词元“fruit”)和“树”之间的相似度接近我们为“柑橘”和“水果”得到的结果。

这种分类文本的方法还有一个问题。当然,在实际应用中,你可能有时需要处理的文本要比本节中使用的样本文本大得多。如果你正在平均的大文本非常庞大,最重要的词可能对句法相似度值几乎没有影响。

为了从相似度方法中获得更准确的结果,我们需要对文本进行一些准备。让我们看看如何改进脚本。

提取名词作为预处理步骤

一个更好的分类技术是提取最重要的词汇,并仅比较这些词汇。以这种方式准备文本进行处理叫做预处理,它可以帮助提高你的 NLP 操作成功的概率。例如,你可以尝试比较某些词性的词向量,而不是比较整个对象的词向量。在大多数情况下,你会专注于名词——无论它们是作为主语、直接宾语还是间接宾语——以识别它们所在文本中传达的意义。例如,在句子“几乎所有的野生狮子生活在非洲”中,你可能会专注于“狮子”,“非洲”或“非洲的狮子”。类似地,在关于水果的句子中,我们专注于挑选出名词“柑橘”。在其他情况下,你可能需要其他词汇,如动词,来决定文本的主题。例如,假设你经营一家农产品公司,必须对来自生产、加工和销售农产品的报价进行分类。你经常会看到像“我们种植蔬菜”或“我们拿番茄去加工”这样的句子。在这个例子中,动词和之前例句中的名词一样重要。

让我们修改第 70 页上的脚本。我们将不再将“水果”与整个句子进行比较,而是仅与句子的名词进行比较:

   import spacy

   nlp = spacy.load('en')

➊ token = nlp(u'fruits')[0]

   doc = nlp(u'I want to buy this beautiful book at the end of the week. Sales of

   citrus have increased over the last year. How much do you know about this type

   of tree?')

similarity = {}

➋ for i, sent in enumerate(doc.sents):

   ➌ noun_span_list = [sent[j].text for j in range(len(sent)) if sent[j].pos_ 

      == 'NOUN']

   ➍ noun_span_str = ' '.join(noun_span_list)

   ➎ noun_span_doc = nlp(noun_span_str)

   ➏ similarity.update({i:token.similarity(noun_span_doc)})

    print(similarity)

我们首先定义了“水果”这个词项,然后用它进行一系列的比较 ➊。遍历每个句子中的词项 ➋,我们提取出名词并将其存储在 Python 列表中 ➌。接下来,我们将列表中的名词连接成一个普通字符串 ➍,然后将该字符串转换为一个 Doc 对象 ➎。然后,我们将这个 Doc 与“水果”词项进行比较,以确定它们的语义相似度。我们将每个词项的句法相似度值存储在一个 Python 字典中 ➏,并最终将其打印出来。

脚本的输出应该类似于以下内容:

{0: 0.17012682516221458, 1: 0.5063824302533686, 2: 0.6277196645922878}

如果你将这些数字与前一个脚本的结果进行比较,你会注意到这次每个句子与“水果”一词的相似度更高。但总体结果看起来相似:第一句的相似度最低,而其他两句的相似度要高得多。

尝试这个

在前面的示例中,只比较“水果”与名词时,你通过只考虑最重要的单词(在本例中是名词)改进了相似度计算的结果。你将“水果”这个词与从每个句子提取的所有名词进行了比较,合并在一起。更进一步,你可以查看这些名词与“水果”一词在语义上的关系,以找出哪个名词的相似度最高。这对于评估文档与“水果”一词的整体相似度非常有用。为了实现这一点,你需要修改之前的脚本,以便计算“水果”与每个句子中的名词之间的相似度,从而找出与之最相似的名词。

提取和比较命名实体

在某些情况下,除了提取你比较的文本中的每个名词外,你可能只想提取某种类型的名词,例如命名实体。假设你正在比较以下文本:

“Google Search,通常简称为 Google,是目前使用最广泛的搜索引擎。它每天处理大量搜索。”

“Microsoft Windows 是一系列由微软开发和销售的专有操作系统。该公司还生产广泛的其他桌面和服务器软件。”

“Titicaca 是安第斯山脉中的一个大而深的山地湖泊。它被认为是世界上最高的可航行湖泊。”

理想情况下,你的脚本应该能识别出前两段文本是关于大型科技公司的,而第三段则不是。但比较这些文本中的所有名词可能并不十分有用,因为其中许多词汇,比如第一句中的“number”,与上下文无关。句子之间的差异包括以下单词:“Google”、“Search”、“Microsoft”、“Windows”、“Titicaca”和“Andes”。spaCy 能识别出这些都是命名实体,这使得从文本中提取和识别它们变得非常轻松,下面的脚本演示了这一过程:

   import spacy

   nlp = spacy.load('en')

   #first sample text

   doc1 = nlp(u'Google Search, often referred to as simply Google, is the most

   used search engine nowadays. It handles a huge number of searches each day.') 

   #second sample text

   doc2 = nlp(u'Microsoft Windows is a family of proprietary operating systems

   developed and sold by Microsoft. The company also produces a wide range of

   other software for desktops and servers.') 

   #third sample text

   doc3 = nlp(u"Titicaca is a large, deep, mountain lake in the Andes. It is

   known as the highest navigable lake in the world.")

➊ docs = [doc1,doc2,doc3]

➋ spans = {}

➌ for j,doc in enumerate(docs):

   ➍ named_entity_span = [doc[i].text for i in range(len(doc)) if 

      doc[i].ent_type != 0]

   ➎ print(named_entity_span)

   ➏ named_entity_span = ' '.join(named_entity_span)

   ➐ named_entity_span = nlp(named_entity_span)

   ➑ spans.update({j:named_entity_span})

我们将包含样本文本的 Docs 分组到一个列表中,以便在循环中迭代 ➊。我们定义一个 Python 字典来存储每个文本的关键词 ➋。在迭代 Docs 的循环中 ➌,我们为每个文本提取这些关键词,并将它们保存在一个单独的列表中,只选择标记为命名实体的词汇 ➍。然后我们打印出该列表,查看它包含的内容 ➎。接下来,我们将这个列表转换为一个普通的字符串 ➏,然后将其应用管道,将其转换为一个 Doc 对象 ➐。然后我们将这个 Doc 添加到之前定义的 spans 字典中 ➑。

脚本应该产生以下输出:

['Google', 'Search', 'Google']

['Microsoft', 'Windows', 'Microsoft']

['Titicaca', 'Andes']

现在我们可以看到每个文本中的词汇,它们的向量将被用来进行比较。

接下来,我们在这些 spans 上调用 similarity() 并打印结果:

print('doc1 is similar to doc2:',spans[0].similarity(spans[1]))

print('doc1 is similar to doc3:',spans[0].similarity(spans[2]))

print('doc2 is similar to doc3:',spans[1].similarity(spans[2]))

这次的输出应如下所示:

doc1 is similar to doc2: 0.7864886939527678

doc1 is similar to doc3: 0.6797676349647936

doc2 is similar to doc3: 0.6621659567003596

这些图表表明,第一篇和第二篇文本之间的相似度最高,这两篇都是关于美国 IT 公司的。那么,词向量是如何“知道”这一事实的呢?它们可能知道,因为“Google”和“Microsoft”这两个词在训练文本语料库中经常出现在相同的文本中,而不是与“Titicaca”和“Andes”这些词一起出现。

总结

在本章中,你使用了词向量,它们是表示单词意义的实数向量。这些表示方式让你可以运用数学来确定语言单元的语义相似度,这对于文本分类任务非常有用。

但是,当你尝试在不对文本进行任何预处理的情况下确定两个文本的相似性时,数学方法可能效果不佳。通过应用预处理,你可以将文本简化为最重要的单词,这些单词有助于判断文本的主题。在特别大的文本中,你可能会挑选出其中的命名实体,因为它们最可能最好地描述文本的类别。

第六章:查找模式与遍历依赖树**

Image

如果你希望你的应用程序能够对文本进行分类、提取特定短语或判断它与另一个文本的语义相似度,它必须能够“理解”用户提交的语句,并生成有意义的回应。

你已经学会了一些执行这些任务的技巧。本章讨论了另外两种方法:使用词序模式来分类和生成文本,以及遍历语法依赖树来从中提取必要的信息。我将向你介绍 spaCy 的 Matcher 工具,用于查找模式。我还将讨论在何种情况下你可能仍然需要依赖上下文来决定合适的处理方法。

词序模式

词序模式由施加特定要求的单词特征组成,要求序列中的每个单词必须满足某些条件。例如,短语“I can”将匹配以下词序模式:“代词 + 情态助动词”。通过搜索词序模式,你可以识别具有相似语言特征的单词序列,从而使输入能够被正确分类和处理。

例如,当你收到一个以使用“情态助动词 + 专有名词”模式开头的问题时,比如“Can George”,你就知道这个问题是关于专有名词所指代的某人或某物的能力、可能性、许可或义务。

在接下来的章节中,你将学习通过识别常见的语言特征模式来分类句子。

基于语言特征查找模式

我们需要在文本中查找模式,因为在大多数情况下,我们甚至无法在文本中找到两个完全相同的句子。通常,一篇文本由不同的句子组成,每个句子包含不同的单词。为每个句子编写处理代码是不可行的。

幸运的是,一些看起来完全不同的句子可能遵循相同的词序模式。例如,考虑以下两个句子:“We can overtake them.” 和 “You must specify it.” 这两个句子没有任何相同的单词。但如果你查看这些句子中单词的语法依赖标签,就会出现一个模式,如以下脚本所示:

   import spacy

   nlp = spacy.load('en')

   doc1 = nlp(u'We can overtake them.')

   doc2 = nlp(u'You must specify it.')

➊ for i in range(len(doc1)-1): 

   ➋ if doc1[i].dep_ == doc2[i].dep_:

     ➌ print(doc1[i].text, doc2[i].text, doc1[i].dep_, spacy.explain(doc1[i].dep_))

因为这两个句子的单词数量相同,我们可以在一个循环中遍历这两个句子中的单词➊。如果在这两个句子中具有相同索引的单词的依赖标签相同➋,我们就打印这些单词及其对应的标签,并提供每个标签的描述➌。

输出应如下所示:

We       You     nsubj  nominal subject

can      must    aux    auxiliary

overtake specify ROOT   None

them     it      dobj   direct object

如你所见,两个句子的依赖标签列表是相同的。这意味着这两个句子基于以下句法依赖标签遵循相同的单词顺序模式:“主语 + 助动词 + 动词 + 直接宾语”。

还要注意,这些示例句子的词性标签(粗粒度和细粒度)列表也是相同的。如果我们在前面的脚本中将所有对.dep_ 的引用替换为.pos_,我们将得到以下结果:

We       You     PRON  pronoun

can      must    VERB  verb

overtake specify VERB  verb

them     it      PRON  pronoun

这些示例句子不仅匹配句法依赖标签模式,还匹配词性标签的模式。

尝试这个

在前面的示例中,我们创建了两个 Doc 对象——每个示例句子一个。但在实际应用中,一篇文本通常包含许多句子,这使得逐句创建 Doc 对象的方法不太实用。重写脚本,使其只创建一个 Doc 对象。然后使用第二章中介绍的doc.sents属性对每个句子进行操作。

但请注意,doc.sents是一个生成器对象,这意味着它不可通过下标访问——你无法通过索引来引用它的项。为了解决这个问题,将doc.sents转换为列表,如下所示:

sents = list(doc.sents)

当然,你可以通过for循环遍历doc.sents,按顺序获取请求的sents

检查语句是否符合模式

在前面的示例中,我们比较了两个示例句子,以找出它们共享的语言特征模式。但在实际应用中,我们很少需要比较句子之间是否共享共同的模式。相反,检查提交的句子是否符合我们已经感兴趣的模式会更有用。

例如,假设我们要在用户输入的语句中找到表示以下某一项的语句:能力、可能性、许可或义务(与描述已经发生、正在发生或定期发生的实际行动的语句相对)。例如,我们要找到“我能做到”而不是“我做到了”。

为了区分不同的语句,我们可能需要检查某个语句是否符合以下模式:“主语 + 助动词 + 动词 + …… + 直接宾语 ……”。省略号表示直接宾语不一定位于动词后面,这使得这个模式与前面示例中的模式略有不同。

以下句子符合模式:“I might send them a card as a reminder.”。在这个句子中,名词“card”是直接宾语,代词“them”是间接宾语,它将直接宾语与动词“send”分开。这个模式并没有指定直接宾语在句子中的位置,它仅要求直接宾语的存在。

图 6-1 展示了这个设计的图形化表示:

image

图 6-1:根据语言特征检查提交的语句与词序模式的一致性

在以下脚本中,我们定义了一个实现此模式的函数,然后在一个示例句子上进行测试:

   import spacy

   nlp = spacy.load('en')

➊ def dep_pattern(doc):

  ➋ for i in range(len(doc)-1):

    ➌ if doc[i].dep_ == 'nsubj' and doc[i+1].dep_ == 'aux' and

       doc[i+2].dep_ == 'ROOT':

      ➍ for tok in doc[i+2].children:

             if tok.dep_ == 'dobj':

          ➎ return True

  ➏ return False

➐ doc = nlp(u'We can overtake them.')

   if ➑dep_pattern(doc):

     print('Found')

   else:

     print('Not found')

在这个脚本中,我们定义了dep_pattern函数,它以 Doc 对象作为参数 ➊。在函数中,我们遍历 Doc 对象的标记 ➋,搜索“主语 + 助动词 + 动词”模式 ➌。如果找到了这个模式,我们检查动词是否在其句法子节点中有一个直接宾语 ➍。最后,如果找到了直接宾语,函数返回True ➎。否则,返回False ➏。

在主代码中,我们将文本处理管道应用于示例句子 ➐,并将 Doc 对象传递给dep_pattern函数 ➑,如果该示例符合函数中实现的模式,则输出Found,否则输出Not found

因为本示例中使用的示例符合该模式,脚本应该输出以下结果:

Found

在接下来的几个部分中,你将看到一些使用dep_pattern函数的示例。

使用 spaCy 的 Matcher 来查找单词序列模式

在上一节中,你学会了如何通过遍历文档的标记并检查它们的语言特征来找到单词序列模式。事实上,spaCy 为这个任务预定义了一个功能,叫做Matcher,它是一个专门设计用来根据模式规则找到标记序列的工具。例如,使用 Matcher 实现“主语 + 助动词 + 动词”模式的代码可能如下所示:

   import spacy

   from spacy.matcher import Matcher

   nlp = spacy.load("en")

➊ matcher = Matcher(nlp.vocab)

➋ pattern = [{"DEP": "nsubj"}, {"DEP": "aux"}, {"DEP": "ROOT"}]

➌ matcher.add("NsubjAuxRoot", None, pattern)

   doc = nlp(u"We can overtake them.")

➍ matches = matcher(doc)

➎ for match_id, start, end in matches:

       span = doc[start:end]

    ➏ print("Span: ", span.text)

       print("The positions in the doc are: ", start, "-", end)

我们创建一个 Matcher 实例,将与 Matcher 将要处理的文档共享的词汇对象传入 ➊。然后我们定义一个模式,指定一个单词序列应该匹配的依赖标签 ➋。我们将新创建的模式添加到 Matcher 中 ➌。

接下来,我们可以将 Matcher 应用于示例文本,并将匹配的标记以列表形式获取 ➍。然后我们遍历这个列表 ➎,打印出模式标记在文本中的起始和结束位置 ➏。

脚本应该输出以下结果:

Span: We can overtake

The positions in the doc are: 0 - 3

Matcher 允许你在文本中找到一个模式,而不需要显式地遍历文本的标记,从而将实现细节隐藏在你背后。因此,你可以获得组成符合指定模式的单词序列的起始和结束位置。当你对紧跟在一起的单词序列感兴趣时,这种方法非常有用。

但通常你需要一个包含分散在句子中的单词的模式。例如,你可能需要实现类似于我们在“检查话语中的模式”中使用的“主语 + 助动词 + 动词 + ... + 直接宾语 ...”模式。问题是你无法事先知道“主语 + 助动词 + 动词”序列与直接宾语之间可以有多少个单词。Matcher 不允许你定义这样的模式。因此,在本章剩余部分,我将手动定义模式。

应用多个模式

你可以将多个匹配模式应用于一个话语,以确保它满足所有条件。例如,你可能会将一个话语与两个模式进行比较:一个实现依赖标签序列(如在“检查话语模式”中讨论的第 77 页),另一个则检查词性标签的顺序。如果你想确保话语中的直接宾语是人称代词,这将非常有用。若是这样,你可以开始确定赋予代词意义的名词,并且该名词在对话中有其他提及。

从图示上来看,这个设计可能像图 6-2 一样。

image

图 6-2:将多个匹配模式应用于用户输入

除了使用在“检查话语模式”中定义的依赖标签序列外,你还可以通过实现基于词性标签的模式来定义一个新函数。词性标签模式可能会搜索句子,以确保主语和直接宾语是人称代词。这个新函数可能会实现以下模式:“人称代词 + 情态助动词 + 基本形式动词 + . . . + 人称代词 . . .”。

这是代码:

import spacy

nlp = spacy.load('en')

#Insert the dep_pattern function from a previous listing here

#...

➊ def pos_pattern(doc):

  ➋ for token in doc:

       if token.dep_ == 'nsubj' and token.tag_ != 'PRP':

         return False

       if token.dep_ == 'aux' and token.tag_ != 'MD':

         return False

       if token.dep_ == 'ROOT' and token.tag_ != 'VB':

         return False

       if token.dep_ == 'dobj' and token.tag_ != 'PRP':

         return False

  ➌ return True

   #Testing code

   doc = nlp(u'We can overtake them.')

➍ if dep_pattern(doc) and pos_pattern(doc):

       print('Found')

   else:

       print('Not found')

我们首先添加之前脚本中定义的dep_pattern函数的代码。为了创建第二个模式,我们定义pos_pattern函数 ➊,该函数包含一个带有多个if语句的for循环 ➋。每个if语句检查句子的某个部分是否匹配某个词性标签。当函数检测到不匹配时,它返回False。否则,在所有检查完成且未发现不匹配的情况下,函数返回True ➌。

为了测试这些模式,我们将管道应用于一个句子,然后检查该句子是否匹配这两个模式 ➍。因为示例中使用的句子匹配了这两个模式,所以我们应该看到以下输出:

Found

但是,如果我们用这个句子替换示例句子:“I might send them a card as a reminder.”,我们应该看到以下输出:

Not found

原因在于该句子不匹配词性标签模式,因为直接宾语“card”不是人称代词,即使该句子完全满足第一个模式的条件。

基于定制特征创建模式

在创建词序列模式时,你可能需要通过定制 spaCy 提供的语言特征来增强其功能,以满足你的需求。例如,你可能希望前面的脚本识别另一个模式,通过数目来区分代词(无论是单数还是复数)。如果你需要查找代词所指的前一句中的名词时,这将非常有用。

尽管 spaCy 按数量区分名词,但它并不对代词做这样的区分。然而,识别代词是单数还是复数的能力在意义识别或信息提取的任务中非常有用。例如,考虑以下对话:

The trucks are traveling slowly. We can overtake them.

如果我们能确认第二个句子中的直接宾语“them”是一个复数代词,那么我们就有理由相信它指的是第一个句子中的复数名词“trucks”。我们经常使用这种技术根据上下文来识别代词的含义。

以下脚本定义了一个pron_pattern函数,用于找到提交句子中的任何直接宾语,确定该直接宾语是否为个人代词,然后判断该代词是单数还是复数。脚本在测试了“检查语句模式”第 77 页和“应用多个模式”第 80 页中定义的两个模式之后,应用该函数到示例句子中。

   import spacy

   nlp = spacy.load('en')

   #Insert the dep_pattern and pos_pattern functions from the previous

   listings here

   #...

➊ def pron_pattern(doc):

  ➋ plural = ['we','us','they','them']

     for token in doc:

    ➌ if token.dep_ == 'dobj' and token.tag_ == 'PRP':

      ➍ if token.text in plural:

        ➎ return 'plural'

         else:

        ➏ return 'singular'

  ➐ return 'not found'

   doc = nlp(u'We can overtake them.')

   if dep_pattern(doc) and pos_pattern(doc):

       print('Found:', 'the pronoun in position of direct object is',

       pron_pattern(doc))

   else:

       print('Not found')

我们首先通过在脚本中添加在“检查语句模式”和“应用多个模式”中定义的dep_patternpos_pattern函数来开始。在pron_pattern函数 ➊中,我们定义了一个包含所有可能的复数个人代词的 Python 列表 ➋。接下来,我们定义一个循环,遍历提交句子中的标记,查找作为直接宾语的个人代词 ➌。如果找到这样的标记,我们会检查它是否在复数个人代词的列表中 ➍。如果是,函数返回plural ➎。否则,它返回singular ➏。如果函数未能检测到直接宾语,或者找到的代词不是个人代词,它将返回Not found ➐。

对于句子“We can overtake them.”,我们应该得到以下输出:

Found: the pronoun in position of direct object is plural

我们可以利用这些信息找到前一句中代词对应的名词。

选择应用哪些模式

一旦定义了这些模式,你就可以根据不同情况选择应用哪些模式。请注意,即使一个句子未完全满足dep_patternpos_pattern函数,它仍然可能匹配pron_pattern函数。例如,句子“I know it.”不符合dep_patternpos_pattern函数,因为它没有情态助动词。但它满足pron_pattern,因为它包含一个作为直接宾语的个人代词。

这些模式之间的松散耦合使你可以将它们与其他模式一起使用或独立使用。例如,如果你想确保句子的主语和直接宾语都是名词,你可以将dep_pattern(检查句子是否符合“主语 + 助动词 + 动词 + ... + 直接宾语...”模式)与“名词 + 情态助动词 + 基本形式动词 + ... + 名词...”模式结合使用。这两个模式将匹配以下示例:

Developers might follow this rule.

正如你所猜测的那样,不同方式组合模式的能力使你可以用更少的代码处理更多的场景。

在聊天机器人中使用词序列模式生成陈述

如前所述,NLP 中最具挑战性的任务是理解和生成自然语言文本。聊天机器人必须理解用户的输入,然后生成适当的响应。基于语言学特征的词序列模式可以帮助你实现这些功能。

在第四章中,你学会了如何将陈述转化为相关问题,以继续与用户的对话。通过使用词序列模式,你还可以生成其他类型的响应,例如相关的陈述。

假设你的聊天机器人收到了以下用户输入:

The symbols are clearly distinguishable. I can recognize them promptly.

聊天机器人可能会做出如下反应:

I can recognize symbols promptly too.

你可以使用前面章节中实现的模式来完成这个文本生成任务。步骤列表可能如下所示:

  1. 将对话输入与之前定义的dep_patternpos_pattern函数进行匹配,分别找到符合“主语 + 助动词 + 动词 + ... + 直接宾语...”和“代词 + 情态助动词 + 基本形式动词 + ... + 代词...”模式的陈述。

  2. 检查步骤 1 中找到的陈述是否与pron_pattern模式匹配,以确定直接宾语的人称代词是复数还是单数。

  3. 通过搜索与人称代词相同数量的名词,找到给人称代词赋予意义的名词。

  4. 将步骤 1 中句子中作为直接宾语的人称代词替换为步骤 3 中找到的名词。

  5. 在生成的陈述末尾添加单词“too”。

以下脚本实现了这些步骤。它使用了本章前面定义的dep_patternpos_patternpron_pattern函数(为了节省空间,省略了它们的代码)。它还引入了两个新函数:find_noungen_utterance。为了方便起见,我们将分三个步骤来讲解代码:初步操作和find_noun函数,它用于查找与人称代词匹配的名词;gen_utterance函数,它根据该问题生成相关陈述;最后是测试陈述的代码。以下是第一部分:

   import spacy

   nlp = spacy.load('en')

   #Insert the dep_pattern, pos_pattern and pron_pattern functions from the 

   previous listings here

   #...

➊ def find_noun(➋sents, ➌num):

     if num == 'plural':

    ➍ taglist = ['NNS','NNPS']

     if num == 'singular':

    ➎ taglist = ['NN','NNP']

  ➏ for sent in reversed(sents):

    ➐ for token in sent:

      ➑ if token.tag_ in taglist:

           return token.text

     return 'Noun not found'

在插入dep_patternpos_patternpron_pattern函数的代码后,我们定义了find_noun函数,该函数接受两个参数 ➊。第一个参数包含从话语开始到满足所有模式的句子的句子列表。在这个示例中,这个列表将包括话语中的所有句子,因为只有最后一句话满足所有模式 ➋。但是,给代词赋予意义的名词可以在之前的句子中找到。

传递给find_noun的第二个参数是满足所有模式的句子中直接宾语代词的位置 ➌。pron_pattern函数确定了这一点。如果该参数的值为'plural',我们定义一个包含用于标记复数名词的细粒度词性标签的 Python 列表 ➍。如果是'singular',我们创建一个标签列表,包含用于标记单数名词的细粒度词性标签 ➎。

for循环中,我们按逆序遍历句子,从包含要替换的代词的句子开始 ➏。我们从最接近的句子开始,因为我们要找的名词很可能就在这里。在这里,我们使用 Python 的 reversed 函数,它返回一个列表的反向迭代器。在内层循环中,我们遍历每个句子中的标记 ➐,寻找其细粒度词性标签是否在之前定义的标签列表中 ➑。

然后我们定义了gen_utterance函数,它生成我们的新语句:

   def gen_utterance(doc, noun):

     sent = ''

  ➊ for i,token in enumerate(doc):

    ➋ if token.dep_ == 'dobj' and token.tag_ == 'PRP':

      ➌ sent = doc[:i].text + ' ' + noun + ' ' + doc[i+1:len(doc)-2].text + 'too.'

      ➍ return sent

   ➎ return 'Failed to generate an utterance'

我们使用for循环遍历句子 ➊ 中的标记,寻找直接宾语为人称代词 ➋。找到后,我们生成一个新的表达式。我们通过将人称代词替换为匹配的名词,并在其末尾加上“too”来修改原句 ➌。然后,函数返回这个新生成的表达式 ➍。如果没有找到以人称代词形式出现的直接宾语,函数会返回一个错误信息 ➎。

现在我们已经准备好了所有函数,我们可以使用以下代码对示例表达式进行测试:

➊ doc = nlp(u'The symbols are clearly distinguishable. I can recognize them 

   promptly.')

➋ sents = list(doc.sents)

   response = ''

   noun = ''

➌ for i, sent in enumerate(sents): 

     if dep_pattern(sent) and pos_pattern(sent):

  ➍ noun = find_noun(sents[:i], pron_pattern(sent))

       if noun != 'Noun not found':

    ➎ response = gen_utterance(sents[i],noun)

       break

print(response)

在将管道应用于示例话语 ➊ 后,我们将其转换为句子列表 ➋。然后我们遍历这个列表 ➌,寻找符合dep_patternpos_pattern函数定义的模式的句子。接着,我们使用find_noun函数确定在上一步找到的句子中赋予代词意义的名词 ➍。最后,我们调用get_utterance函数生成响应表达式 ➎。

前面的代码输出应该是这样的:

I can recognize symbols too.

试试这个

请注意,前面的代码仍然有改进的空间,因为原始语句在名词“symbols”前面包含了冠词“the”。更好的输出应该在名词前加上相同的冠词。为了生成在此上下文中最有意义的语句,需要扩展脚本,使其在名词前插入冠词“the”,使其变成“我也能识别这些符号”。为此,你需要检查名词前面是否有冠词,然后将该冠词添加进去。

从句法依赖树中提取关键词

在话语中找到符合特定模式的单词序列,可以帮助你构建一个语法正确的回应——无论是陈述句还是疑问句,都基于提交的文本。但是,这些模式并不总是有助于提取文本的含义。

例如,在第二章的票务预订应用中,用户可能会提交如下句子:

I need an air ticket to Berlin.

你可以通过搜索模式“to + GPE”轻松找到用户的目的地,其中GPE是指国家、城市和州的命名实体。这个模式可以匹配像“to London”(去伦敦)、“to California”(去加利福尼亚)这样的短语。

但是假设用户提交了以下其中一句话:

I am going to the conference in Berlin. I need an air ticket. 

I am going to the conference, which will be held in Berlin. I would like to

book an air ticket.

如你所见,“to + GPE”模式在这两个例子中都无法找到目的地。在这两个例子中,“to”直接指向的是“the conference”,而不是柏林。你需要使用类似“to + . . . + GPE”的模式。那么,如何知道在“to”和“GPE”之间需要或者允许插入什么呢?例如,下面的句子包含了“to + . . . + GPE”模式,但与预订前往柏林的机票无关:

I want to book a ticket on a direct flight without landing in Berlin.

通常,你需要检查句子中单词之间的关系,以获得必要的信息。这就是走访句子的依赖树能提供巨大帮助的地方。

走访依赖树意味着以自定义顺序进行遍历——不一定从第一个标记遍历到最后一个标记。例如,你可以在找到所需组件后立即停止遍历依赖树。记住,句子的依赖树显示了单词对之间的句法关系。我们通常将这些关系表示为箭头,连接一个关系的头词和子词。句子中的每个单词都涉及至少一个关系。这保证了如果从ROOT开始遍历整个句子的依赖树,你会通过句子中的每个单词。

在本节中,我们将通过分析句子的结构来搞清楚用户的意图。

走访依赖树进行信息提取

让我们回到票务预订应用的例子。为了找到用户的预期目的地,你可能需要遍历句子的依赖树,判断“to”是否与“Berlin”在语义上相关。如果你记住组成依赖树的头/子句关系(在第 25 页的“头和子句”框中介绍),这将很容易实现。

图 6-3 展示了句子“我去柏林参加会议”的依赖树:

image

图 6-3:一个话语的句法依赖树

动词“going”是句子的根,它不是任何其他单词的子节点。它右边的直接子节点是“to”。如果你沿着依赖树遍历,每次移动到当前单词的右子节点,最终你会到达“Berlin”。这表明句子中“to”和“Berlin”之间存在语义联系。

遍历标记的头

现在,让我们来分析如何以编程方式表示句子中“to”和“Berlin”之间的关系。一个方法是从左到右遍历依赖树,从“to”开始,每次选择当前单词的直接右子节点。如果你能通过这种方式从“to”到达“Berlin”,那么可以合理地假设这两个词之间存在语义联系。

但这种方法有一个缺点。在某些情况下,一个单词可能有多个右子节点。例如,在句子“我去参加在柏林举行的 spaCy 会议”中,单词“conference”有两个直接的右子节点:单词“on”和“held”。这就要求你检查多个分支,增加了代码的复杂性。

另一方面,尽管一个头可以有多个子节点,但句子中的每个单词都有且只有一个头。这意味着你也可以从右到左移动,从“Berlin”开始,试图到达“to”。以下脚本在det_destination函数中实现了这一过程:

   import spacy

   nlp = spacy.load('en')

   #Here's the function that figures out the destination

➊ def det_destination(doc):

     for i, token in enumerate(doc):

   ➋ if token.ent_type != 0 and token.ent_type_ == 'GPE':

      ➌ while True:

        ➍ token = token.head

           if token.text == 'to':

          ➎ return doc[i].text

        ➏ if token.head == token:

             return 'Failed to determine'

     return 'Failed to determine'

   #Testing the det_destination function

   doc = nlp(u'I am going to the conference in Berlin.')

➐ dest = det_destination(doc)

   print('It seems the user wants a ticket to ' + dest)

det_destination函数 ➊中,我们遍历提交的句子中的所有标记,寻找GPE实体 ➋。如果找到了,我们启动一个while循环 ➌,遍历每个标记的头,起始位置是包含GPE实体的标记 ➍。当循环到达包含“to”的标记 ➎或句子的根节点时,停止。我们可以通过比较标记与其头来检查是否达到了根节点 ➏,因为根标记的头总是指向它自身。(或者,也可以检查ROOT标签。)

为了测试这个功能,我们将管道应用到示例句子上,然后调用det_destination函数进行处理 ➐。

脚本应生成以下输出:

It seems the user wants a ticket to Berlin

如果我们修改示例句子,使其不包含“to”或GPE命名实体,应该会得到如下输出:

It seems the user wants a ticket to Failed to determine

我们可以改进脚本,使其在无法确定用户目的地的情况下使用另一条消息。

使用依存树压缩文本

依存句法树方法不仅限于聊天机器人。你可以在例如报告处理应用中使用它。假设你需要开发一个应用程序,必须通过提取报告中最重要的信息来压缩零售报告。

例如,你可能想选择包含数字的句子,生成一个关于销售量、收入和成本的简洁数据总结。(你已经在第四章中学会了如何提取数字。)然后,为了使你的新报告更简洁,你可能会缩短选中的句子。

作为一个快速示例,考虑下面的句子:

The product sales hit a new record in the first quarter, with 18.6 million units sold.

处理后,它应该看起来像这样:

The product sales hit 18.6 million units sold.

要完成这一任务,你可以通过以下步骤分析句子的依存树:

  1. 通过从包含数字的标记开始,沿着标记的头部从左到右遍历,提取包含该数字的整个短语(在这个例子中是 18.6)。

  2. 从提取短语的主词(其头部不在短语中)开始,沿着依存树遍历到句子的主要动词,遍历头部并将其收集,用于形成新的句子。

  3. 提取主要动词的主语,并与其左侧的子节点一起处理,这些通常包括限定词以及可能的一些修饰语。

图 6-4 表示这一过程。

image

图 6-4:一个将句子压缩到仅包含重要元素的示例

让我们从第一步开始,在这个例子中应该提取短语“18.6 百万单位销售”。以下代码片段展示了如何以编程方式实现这一过程:

 doc = nlp(u"The product sales hit a new record in the first quarter, with 18.6 million units sold.")

 phrase = ''

 for token in doc:

➊ if token.pos_ == 'NUM':

       while True:

         phrase = phrase + ' ' + token.text

      ➋ token = token.head

      ➌ if token not in list(token.head.lefts):

           phrase = phrase + ' ' + token.text

        ➍ break

    ➎ break

 print(phrase.strip())

我们遍历句子的标记,寻找表示数字的标记 ➊。如果找到了,我们开始一个while循环,遍历右侧的头部 ➋,从数字标记开始,并将每个头部的文本附加到phrase变量中,形成一个新短语。为了确保下一个标记的头部在该标记的右侧,我们检查该标记是否在其头部的左子节点列表中 ➌。一旦这个条件返回假,我们跳出while循环 ➍,然后跳出外部for循环 ➎。

接下来,我们从包含数字的短语的主词(在这个例子中是“sold”)开始,沿着标记的头部遍历,直到到达句子的主要动词(在这个例子中是“hit”),排除介词(在这个例子中是“with”)。我们可以按照下面的代码实现这一过程:

 while True:

➊ token = doc[token.i].head

   if token.pos_ != 'ADP':

  ➋ phrase = token.text + phrase

➌ if token.dep_ == 'ROOT':

➍ break

我们在while循环中遍历标记的头部 ➊,将每个头部的文本附加到正在形成的短语 ➋。在到达主要动词(标记为ROOT) ➌后,我们跳出循环 ➍。

最后,我们提取句子的主语及其左侧的词:“The”和“product”。在这个例子中,主语是“sales”,所以我们提取出以下名词短语:“The product sales”。可以使用以下代码来完成这一操作:

➊ for tok in token.lefts:

  ➋ if tok.dep_ == 'nsubj':

   ➌ phrase = ' '.join([tok.text for tok in tok.lefts]) + ' ' + tok.text + ' '

      + phrase

      break

➍ print(phrase)

我们首先迭代主要动词的子节点 ➊,寻找主语 ➋。然后,我们将主语的子节点和短语的主语加到前面 ➌。要查看结果短语,我们打印出来 ➍。

输出应该如下所示:

The product sales hit 18.6 million units sold.

结果是原始句子的压缩版本。

试试这个

编写一个脚本,通过提取仅包含金额相关短语的句子来压缩财务报告。此外,脚本需要将选定的句子进行压缩,使其仅包含主语、主要动词、金额相关短语,以及从金额短语的主词到句子的主要动词之间的所有词汇。例如,给定以下句子:

The company, whose profits reached a record high this year, largely attributed

to changes in management, earned a total revenue of $4.26 million.

你的脚本应该返回以下句子:

The company earned revenue of $4.26 million.

在这个例子中,“million”是短语“$4.26 million”中的关键词。 “million”的主词是“of”,它是“revenue”的子词,而“revenue”又是“earned”(句子的主要动词)的子词。

利用上下文提升购票聊天机器人功能

正如你现在毫无疑问已经意识到的那样,没有一种适用于所有智能文本处理任务的单一解决方案。例如,本章前面展示的购票脚本,只有在提交的句子包含“to”时才会找到目的地。

使这些脚本更有用的一种方法是考虑上下文来确定适当的响应。让我们增强购票脚本的功能,使其能够处理更广泛的用户输入,包括不包含“to + GPE”组合的表达。例如,看看以下的表达:

I am attending the conference in Berlin.

在这里,用户表达了前往柏林的意图,但没有使用“to”。句子中仅包含GPE实体“Berlin”。在这种情况下,聊天机器人提出确认性问题是合理的,例如:

You want a ticket to Berlin, right?

改进后的购票聊天机器人应该根据三种不同的情况生成不同的输出:

  • 用户明确表示有意购买前往某个目的地的机票。

  • 用户是否想要前往提到的目的地,目前还不清楚。

  • 用户没有提到任何目的地。

根据用户输入的类别,聊天机器人会生成相应的回答。图 6-5 展示了如何在图表中表示这种用户输入处理方式。

image

图 6-5:购票应用中用户输入处理的示例

以下脚本实现了这一设计。为了方便,代码分为几个部分。

第一个代码片段包含 guess_destination 函数,该函数在句子中搜索 GPE 实体。同时,我们还需要插入在 “迭代标记的头部” 中定义并讨论的 dep_destination 函数,见第 87 页。回想一下,这个函数会在句子中搜索 “to + GPE” 模式。我们需要 dep_destinationguess_destination 函数来分别处理用户输入的第一种和第二种场景。

import spacy

nlp = spacy.load('en')

#Insert the dep_destination function from a previous listing here

#...

def guess_destination(doc):

  for token in doc:

 ➊ if token.ent_type != 0 and token.ent_type_ == 'GPE': 

    ➋ return token.text

➌ return 'Failed to determine'

guess_destination 函数中的代码会遍历句子中的标记,寻找 GPE 实体 ➊。一旦找到,函数将其返回给调用代码 ➋。如果没有找到,函数将返回 'Failed to determine' ➌,表示句子中没有 GPE 实体。

在接下来的 gen_function 中,我们根据前面片段中定义的函数返回的内容生成响应。

 def gen_response(doc):

➊ dest = det_destination(doc)

   if dest != 'Failed to determine':

  ➋ return 'When do you need to be in ' + dest + '?'

➌ dest = guess_destination(doc)

   if dest != 'Failed to determine':

   ➍ return 'You want a ticket to ' + dest +', right?'

 ➎ return 'Are you flying somewhere?'

gen_response 函数中的代码首先调用 det_destination 函数 ➊,该函数判断一个话语是否包含 “to + GPE” 组合。如果找到这样的组合,我们假设用户想要前往某个目的地,并且他们需要明确他们的出发时间 ➋。

如果 det_destination 函数没有在话语中找到 “to + GPE” 组合,我们会调用 guess_destination 函数 ➌。该函数尝试查找 GPE 实体。如果找到了该实体,它会询问用户是否想飞往该目的地 ➍。否则,如果在话语中没有找到 GPE 实体,脚本会询问用户是否想飞往某个地方 ➎。

为了测试代码,我们将管道应用于一个句子,然后将文档传递给我们在前面的代码中使用的 gen_response 函数:

doc = nlp(u'I am going to the conference in Berlin.') 

print(gen_response(doc))

对于本示例提交的语句,您应该看到以下输出:

When do you need to be in Berlin?

您可以尝试使用示例语句来查看不同的输出。

通过找到合适的修饰语让聊天机器人更智能

让聊天机器人更智能的一种方法是使用依赖树来查找特定单词的修饰语。例如,您可以教会您的应用程序识别适用于特定名词的形容词。然后,您可以告诉机器人,“我想读一本书”,智能机器人可能会这样回答:“您想要一本小说书吗?”

修饰语 是短语或从句中的可选元素,用于改变另一个元素的意思。去掉修饰语通常不会改变句子的基本意思,但会使句子变得不那么具体。举个简单的例子,考虑以下两句话:

I want to read a book.

I want to read a book on Python.

第一句话没有使用修饰语。第二句使用了修饰语“on Python”,使得请求更加详细。

如果您想要更具体,必须使用修饰语。例如,为了生成对用户的适当回应,您可能需要学习哪些修饰语可以与特定的名词或动词一起使用。

考虑以下短语:

That exotic fruit from Africa.

在这个名词短语中,“fruit”是中心词,“that”和“exotic”是前修饰词——位于被修饰单词前的修饰词——而“from Africa”是后修饰词短语——一个跟随被限定或修饰单词的修饰词。图 6-6 展示了该短语的依存树。

image

图 6-6:前修饰词和后修饰词的示例

假设你想确定单词“fruit”的可能形容词修饰词。(形容词修饰词总是前修饰词。)此外,你还想查看在同一个单词的后修饰词中可以找到哪些GPE实体。这些信息可能会在以后帮助你在关于水果的对话中生成发言。

以下脚本实现了这个设计:

   import spacy

   nlp = spacy.load('en')

➊ doc = nlp(u"Kiwano has jelly-like flesh with a refreshingly fruity taste. This 

   is a nice exotic fruit from Africa. It is definitely worth trying.")

➋ fruit_adjectives = []

➌ fruit_origins = []

   for token in doc:

  ➍ if token.text == 'fruit': 

    ➎ fruit_adjectives = fruit_adjectives + [modifier.text for modifier in

       token.lefts if modifier.pos_ == 'ADJ']

    ➏ fruit_origins = fruit_origins + [doc[modifier.i + 1].text for modifier 

       in token.rights if modifier.text == 'from' and doc[modifier.i + 1].ent_

       type != 0]

   print('The list of adjectival modifiers for word fruit:', fruit_adjectives)

   print('The list of GPE names applicable to word fruit as postmodifiers:', 

   fruit_origins)

我们首先将管道应用于一个包含单词“fruit”的简短文本,该单词具有前修饰词和后修饰词 ➊。我们定义了两个空列表:fruit_adjectives ➋ 和 fruit_origins ➌。第一个列表将保存所有找到的“fruit”的形容词修饰词。第二个列表将保存所有在“fruit”的后修饰词中找到的GPE实体。

接下来,在一个遍历整个文本的循环中,我们寻找单词“fruit” ➍。一旦找到该单词,我们首先通过获取它的左侧句法子节点来确定其形容词前修饰词,并仅选择形容词(限定词和复合词也可以是前修饰词)。我们将形容词修饰词附加到fruit_adjectives列表 ➎。

然后,我们通过检查“fruit”右侧的句法子节点来寻找后修饰词。特别是,我们寻找命名实体,然后将它们附加到fruit_origins列表 ➏。

脚本输出以下两个列表:

The list of adjectival modifiers for word fruit: ['nice', 'exotic']

The list of GPE names applicable to word fruit as postmodifiers: ['Africa']

现在,你的机器人“知道”水果可以是美味的、异国的(或既美味又异国),并且可能来自非洲。

总结

当你需要处理一段话,甚至只是一个短语时,通常重要的是要查看其结构,以确定它匹配哪些通用模式。通过使用 spaCy 的语言学特性,你可以检测到这些模式,从而让你的脚本理解用户的意图并作出适当回应。

使用基于语言学特征的模式在你需要识别句子的通用结构时效果很好,这些结构包括主语、情态助动词、主要动词和直接宾语。但一个真实世界的应用程序需要识别更复杂的句子结构,并为更广泛的用户输入做好准备。这就是句子的句法依存树变得非常有用的地方。你可以通过不同的方式遍历句子的依存树,从中提取必要的信息。例如,你可以使用依存树查找特定单词的修饰语,然后利用这些信息生成智能文本。

第七章:VISUALIZATIONS**

Image

发现数据中的见解,也许最简单的方法就是以图形方式呈现数据。像图 7-1 所示的可视化效果,能够让你立即识别数据中的模式。

在本章中,你将学习如何使用 spaCy 内置的可视化工具:displaCy 依存关系可视化器和 displaCy 命名实体可视化器,来生成句子的句法结构和文档中的命名实体的可视化效果。

我们将从探索这些可视化工具的互动演示开始,这些演示可以在 Explosion AI 网站上找到(Explosion AI 是 spaCy 的开发者),以便了解 spaCy 的可视化工具可以完成什么功能。接下来,你将学习如何在你的机器上启动 displaCy Web 服务器,从而能够在 spaCy 中编程可视化一个 Doc 对象。你还将学习如何自定义你的可视化效果。最后,你将学习如何使用 displaCy 渲染手动准备的数据,而无需传入一个 Doc 对象。

使用 spaCy 内置可视化工具入门

让我们首先探索 displaCy 依存关系可视化器和 displaCy 命名实体可视化器是如何工作的。开始使用 spaCy 内置可视化工具的最快方法是利用它们提供的互动演示,链接可见于explosion.ai/demos/。在这个页面上,你会找到两个 displaCy 可视化工具的演示页面链接以及其他一些演示链接。

displaCy 依存关系可视化器

displaCy 依存关系可视化器为提交的文本生成句法依存关系的可视化效果。要使用其互动演示,请访问explosion.ai/demos/displacy/。将“Text to parse”文本框中的示例句子替换为你的文本,然后点击框右侧的搜索图标(放大镜),生成可视化效果。结果可能类似于图 7-1。

image

图 7-1:Explosion AI 网站上的 displaCy 依存关系可视化器

该依存关系可视化器展示了提交文本中的词性标记和句法依存关系,立即呈现其句法结构。

该可视化工具允许你使用“合并标点符号”和“合并短语”复选框来自定义图形。“合并标点符号”将标点符号与前一个词合并,使得可视化效果更加紧凑,从而提高可读性。“合并短语”将每个名词短语合并为一个单一的词元,正如图 7-1 中的示例所示。默认情况下,这两个选项都是启用的。

你可以通过取消选中相应的框来更改其中一个或两个默认设置。例如,如果你取消选中“I want a Greek pizza now”的“合并短语”框,你将看到该句子的更详细的依赖解析,展示名词短语“a Greek pizza”中的依赖关系。

保持选中“合并短语”框可以让你获得一个更加紧凑的依赖树,这在处理包含多个名词短语的句子时尤其有用。例如,考虑以下句子:“I see a few young people working in their vegetable field.” 它包含两个名词短语:“a few young people”和“their vegetable field”。第一个是动词“see”的直接宾语,第二个是修饰动词“work”的介词短语中的宾语,分别显示依赖标签dobjpobj。严格来说,这些依赖标签是与相应短语中的名词相关,而不是与整个句子相关。

除了“合并标点符号”和“合并短语”选项外,你还可以从可用模型列表中选择一个统计语言模型来使用。这个选项让你可以在不下载和安装到你的环境中的情况下,尝试一个依赖解析模型。目前,你可以从以下模型中选择:en_core_web_smen_core_web_mden_core_web_lg,以及其他欧洲语言(如德语、西班牙语、葡萄牙语、法语、意大利语和荷兰语)的“小(sm)”模型。

displaCy 命名实体可视化工具

displaCy 命名实体可视化工具为提交的文本生成命名实体可视化图。你可以在* explosion.ai/demos/displacy-ent/* 上找到它的交互式演示。从用户的角度来看,它的工作方式与上一节讨论的 displaCy 依赖可视化工具演示类似。要为文本生成可视化图,将其输入文本框中,然后点击搜索图标。可视化工具会处理你的查询并在窗口底部输出原始文本,突出显示已发现的命名实体及其标签,如图 7-2 所示。

你可以通过选中或取消选中“实体标签”下的框来选择应用程序应在提交的文本中识别哪些类型的命名实体。在图 7-2 所示的示例中,你将PERCENTCARDINAL添加到默认选择的实体标签类型列表中。添加PERCENT实体类型会告诉可视化工具识别表示百分比的短语或包含“%”符号的短语。添加CARDINAL实体类型则保证可视化工具能够识别与提交文本中的数字相关的短语。

您应该选择哪些框取决于您的上下文。在处理财务报告时,您可能会选择 moneydate 框。但如果报告中包含多个公司的财务活动记录,您可能还希望选择 ORG 实体标签框,以指示可视化器在文本中突出显示公司名称。

image

图 7-2:Explosion AI 网站上的 displaCy 命名实体可视化器

从 spaCy 内部进行可视化

从 spaCy v2.0 开始,displaCy 可视化工具已集成到核心库中。这意味着在安装 spaCy 后,您可以立即在 Python 代码中使用它们。

为此,您必须使用以下流程:启动一个内置的 Web 服务器,然后将一个 Doc 对象(或 Doc 对象的列表)发送给它进行渲染。服务器会为提交的 Doc 生成可视化,您随后可以在浏览器中查看它。我们将在本节中介绍几个示例。

可视化依存句法分析

以下脚本展示了生成一个句子的依存树可视化的最简单方法:

   import spacy

   nlp = spacy.load('en')

➊ doc = nlp(u"I want a Greek pizza.")

➋ from spacy import displacy

➌ displacy.serve(doc, ➍style='dep')

我们创建一个 Doc 对象并提交给 displaCy ➊。然后我们从核心库中导入 displaCy 库 ➋,接着可以启动一个 displaCy 网络服务器,并将 Doc 对象传递给它。两个操作都是通过调用 displacy.serve() 方法 ➌ 来完成的。通过将参数 style 设置为 'dep',我们指示 displaCy 使用依存句法可视化器 ➍,为 Doc 对象中的文本生成依存树可视化。如果您有兴趣实现本章前面探讨的复选框选项,请参考“试试看”在 104 页中的内容。

无论您是在 Python 会话中运行此代码,还是作为单独的脚本运行,执行会进入一个无限循环,并显示来自 displaCy 网络服务器的消息。您应该看到的初始消息如下:

Serving on port 5000...

Using the 'dep' visualizer

这意味着服务器已经为提交的文本生成了依存树可视化,并且在您的主机上通过 5000 端口(默认端口)提供 HTTP 请求。实际上,这意味着您可以将浏览器指向 http://localhost:5000 来查看该可视化。在此示例中,它应该像图 7-3 所示。

image

图 7-3:您可以从 Python 代码中生成并在浏览器中查看的依存树可视化示例

要关闭 displaCy 服务器,请在启动服务器的终端中按 CTRL-C。这样,您应该会看到来自服务器的以下最终消息:

Shutting down server on port 5000.

关闭服务器后,您将无法在浏览器中生成新的可视化副本,但您仍然可以查看已经生成的副本。

试试看

通过修改前一部分中依赖关系可视化器的脚本,尝试使用命名实体可视化器。为了指示 displaCy 应用命名实体可视化器,将displacy.serve()方法的 style 参数设置为'ent'

为了生成更有趣的可视化效果,你可能会使用包含多句的较长文本。例如,可以尝试使用以下文本:

Microsoft Windows is a family of proprietary operating systems developed and

sold by Microsoft. Bill Gates announced Microsoft Windows on November 10,

1983\. Microsoft first released Windows for sale on November 20, 1985\. Windows

1.0 was initially sold for $100.00, and its sales surpassed 500,000 copies in

April 1987\. For comparison, more than a million copies of Windows 95 were sold

in just the first 4 days.

在对 Doc 对象调用displacy.serve()方法后,将浏览器指向http://localhost:5000以查看可视化效果。请注意实体识别器在此示例文本中识别的命名实体及其类型。特别是,你应该看到实体识别器能识别出人名、产品名、公司名以及与日期、数字和金钱相关的短语。

逐句可视化

当你处理单个句子时,依赖关系树的可视化效果很好。但当你在可视化长文本时,图形可能会变得冗长且占用空间,使得它们在一行中显示时变得难以阅读。尽管 displaCy 为每个句子生成单独的可视化效果,但当你传入包含多个句子的 Doc 时,它会将它们排列在一行中。

你也许想要逐句进行可视化,而不是传入一个 Doc 对象。例如,当你需要从整篇文章中提取含义,并且想要探索一系列句子时,这可能会很有用。从版本 2.0.12 开始,displaCy 允许你传入 Span 对象,然后将可视化效果按行排列。如果你想为doc.sents中找到的每个句子生成一个可视化效果,可以传入doc.sents的列表,如以下代码所示:

   import spacy

   nlp = spacy.load('en')

   doc = nlp(u"I have a relaxed pair of jeans. Now I want a skinny pair.")

➊ spans = list(doc.sents)

   from spacy import displacy

   displacy.serve(➋spans, style='dep')

如第三章所讨论,Doc 对象的doc.sents属性是一个迭代器,遍历 Doc 对象中的句子。因此,你不能使用这个属性通过索引引用句子,但你可以在循环中迭代它们,或者创建一个 Span 对象的列表,其中每个 span 代表一个句子。在这段代码中,我们将 Doc 中的句子转换为 Span 对象的列表➊。然后我们将该 Span 列表传递给displacy.serve()进行可视化➋。

这将为每个句子生成一个可视化效果,并按行排列,允许你通过垂直滚动查看它们。

使用 Options 参数定制可视化效果

除了你到目前为止在示例中看到的 docs 和 style 参数外,displacy.serve()方法还可以接受其他几个参数。options参数可能是最有趣的之一,因为它允许你定义一个设置字典,用于定制可视化的布局。在本节中,我们将介绍一些最有用的options设置。

使用依赖关系可视化器选项

长句子在一行内显示时可能很难查看。在这种情况下,你可以创建紧凑模式的可视化效果,这种模式占用更少的空间。为此,你需要在options参数中将'compact'选项设置为True,如本脚本所示。脚本还更改了可视化器使用的字体。(displaCy API 文档中可用选项的完整列表可以在spacy.io/api/top-level/#options-dep/找到。)

import spacy

nlp = spacy.load('en')

doc = nlp(u"I want a Greek pizza.")

from spacy import displacy

options = {➊'compact': True, ➋'font': 'Tahoma'}

displacy.serve(doc, style='dep', ➌options=options)

displacy.serve()方法期望options参数是一个字典。在这个例子中,我们只设置了两个选项:将'compact'选项设置为True ➊,将'font'选项设置为'Tahoma' ➋,分别更改了它们的默认值。(可视化器允许你使用大多数标准的网页字体,如 Arial、Courier 等。)然后我们将选项字典传递给options参数 ➌。

图 7-4 展示了运行脚本后,当你将浏览器指向http://localhost:5000时应该看到的内容。

你在图中看到的方形弧线可能看起来不太寻常,但它们使整体可视化更加紧凑,通常可以避免你需要滚动才能看到整个图示。

image

图 7-4:定制化依存关系可视化示例

试试看

在“displaCy 依存关系可视化器”交互式演示中,讨论了第 98 页,你使用了“合并短语”和“合并标点符号”选项。在 spaCy 中,你可以通过collapse_phrases选项将名词短语合并为一个标记(图 7-1 展示了 displaCy 可视化器网站上相应的选项),默认设置为Falsecollapse_punct选项用于将标点符号附加到标记上,默认设置为True

修改之前脚本中的代码,使其将collapse_phrases选项设置为True,并传递给选项集。运行脚本,然后在浏览器中查看生成的可视化效果,确保名词短语作为一个整体显示。

使用命名实体可视化选项

命名实体可视化选项的列表(可以在spacy.io/api/top-level/#displacy_options-ent/找到)比依存关系可视化选项的列表要短得多。在使用实体可视化器时,你可以选择通过'ents'选项突出显示哪些实体类型,并通过'colors'选项覆盖默认颜色。

第一个选项是两个选项中更重要的,因为它允许你指示可视化器仅高亮显示选定类型的实体。以下示例展示了你可能想要限制可视化器显示的实体类型的情况。

在这个脚本中,我们没有提供任何实体可视化器的选项,这意味着它会高亮显示提交的文档中的所有类型的实体。

import spacy

nlp = spacy.load('en')

doc = nlp(u"In 2011, Google launched Google +, its fourth foray into social

networking.")

➊ doc.user_data['title'] = "An example of an entity visualization"

   from spacy import displacy

   displacy.serve(doc, style=➋'ent')

我们使用文档的 user_data 属性为文档设置标题 ➊。displaCy 可视化器会自动将此属性中的文本作为可视化的标题。为可视化添加标题是可选的,但在需要注释可视化时非常有用。

我们将 displacy.serve() 方法的样式参数设置为 'ent' ➋,指示 displaCy 使用命名实体可视化器。生成的可视化效果应类似于图 7-5 中的效果(尽管这些图像是灰度的,网站上使用的是彩色)。

image

图 7-5:使用默认选项生成的命名实体可视化示例

总体来说,视觉效果看起来不错。但在这个上下文中,突出显示序数“第四”可能是不必要的。在这种情况下,很难弄清楚为什么我们需要提取这个信息。使用 'ents' 选项时,我们选择要突出显示的实体类型。以下代码演示了如何实现这一点。要查看效果,我们将前一个脚本的最后一行代码替换为以下两行代码,然后运行更新后的脚本:

options = {➊'ents': ["ORG", "PRODUCT", "DATE"], ➋'colors': {"ORG": "aqua",

"PRODUCT": "aqua"}}

displacy.serve(doc, style='ent', options=options)

这次,可视化器不应识别除 ORGPRODUCTDATE 类型之外的任何实体 ➊。此示例还说明了如何使用 'colors' 选项来更改分配给实体类型的默认颜色。在这个示例中,我们将 ORGPRODUCT 类型映射到颜色 "aqua" ➋。

注意

使用 'colors' 选项时,可以将实体类型映射到网页颜色名称或十六进制颜色代码。在第 105 页的示例中,使用十六进制代码 "``#00FFFF``" 等同于使用颜色名称 "``aqua``"

图 7-6 说明了生成的可视化效果应该是什么样的。

image

图 7-6:使用指定的 'ents''colors' 选项生成的实体可视化示例

如您所见,此可视化几乎与图 7-5 中显示的完全相同。但这次可视化器没有突出显示 ORDINAL 类型的实体,因为我们传入的 'ents' 选项列表中不包括此类型。

将可视化导出为文件

在之前的示例中,我们使用了 displaCy Web 服务器来查看正在生成的可视化。正如您在这些示例中学到的,只要通过相同的 displacy.serve() 调用启动的服务器正在运行,您就可以在浏览器中打开通过 displacy.serve() 方法生成的可视化。

使用 displacy.render() 方法,您可以避免这个限制并创建一个可供后续使用的可视化。displacy.render() 方法允许您将标记渲染为 HTML 页面,然后将其保存在单独的文件中。稍后,您可以在任何浏览器中打开该文件,而无需调用 Web 浏览器。

以下脚本展示了如何使用displacy.render()方法来进行命名实体可视化,如图 7-5 所示:

import spacy

nlp = spacy.load('en')

doc = nlp(u"In 2011, Google launched Google +, its fourth foray into social

   networking.")

➊ doc.user_data["title"] = "An example of an entity visualization"

   #In the next block, you instruct displaCy to render the markup wrapped as a

   full HTML page.

   from spacy import displacy

➋ html = displacy.render(doc, style='ent', ➌page=True)

   #In the next block, you save the html file generated by displacy.render() to

   disk on your machine. 

➍ from pathlib import Path

➎ output_path = Path("/visualizations/ent_visual.html")

➏ output_path.open("w", encoding="utf-8").write(html)

我们可以将脚本中的代码分为三个部分,每一部分都以注释行开始。第一部分应该是大家熟悉的。这里,我们创建一个文本处理管道,然后将其应用于文本。接着我们使用 Doc 的user_data属性为 Doc 设置一个标题 ➊。

在第二个代码块中,我们使用displacy.render()方法为前一步创建的 Doc 渲染命名实体可视化➋。与displacy.serve()不同,displacy.render()不会启动一个 Web 服务器,而是生成一个可视化的 HTML 标记。通过将页面参数设置为True,我们指示displacy.render()生成一个完整 HTML 页面的标记 ➌。

在最后一个代码块中,我们从 Python 3.4 引入的pathlib模块中导入了Path类➍。我们可以使用Path对路径对象执行系统调用。在这个例子中,我们在以下路径上实例化了这个类:/visualizations/ent_visual.html ➎,假设我们在本地文件系统中已经有了/visualizations文件夹(否则会抛出异常)。然后,我们打开ent_visual.html文件(如果文件不存在,将创建该文件),并将前一步生成的 HTML 页面写入该文件 ➏。

总结来说,这个脚本生成一个包含提交文本命名实体可视化的 HTML 文件,并将该文件保存在你的文件系统中。如果你进入保存文件的文件夹并双击该文件,它将在浏览器中打开。

使用 displaCy 手动渲染数据

displaCy 可视化工具允许你手动创建数据集,然后进行渲染,而不是将数据作为 doc 或 span 传递进来。当你需要,比如说,可视化其他 NLP 库的输出或使用一组自定义标签或自定义依赖关系标签来创建可视化时,这会非常有用。(我将在第十章中讨论如何制作这些自定义标签和标签。)

作为一个例子,接下来我们手动渲染句子“I want a Greek pizza.”

数据格式化

首先,你需要将数据转换为 displaCy 的格式:一个包含两个列表的字典:"words""arcs",如下代码所示:

sent = {

    "words": [

     ➊ {"text": "I", "tag": "PRON"},

        {"text": "want", "tag": "VERB"},

        {"text": "a", "tag": "DET"},

        {"text": "Greek", "tag": "ADJ"},

        {"text": "pizza", "tag": "NOUN"}

    ],

    "arcs": [

     ➋ {"start": 0, "end": 1, "label": "nsubj", "dir": "left"},

        {"start": 2, "end": 4, "label": "det", "dir": "left"},

        {"start": 3, "end": 4, "label": "amod", "dir": "left"},

        {"start": 1, "end": 4, "label": "dobj", "dir": "right"}

    ]

}

sent字典包含两个列表:"words""arcs",每个列表中包含一组字典。在"words"列表中的字典为句子中的特定标记分配一个标签 ➊,而在"arcs"列表中的字典定义了依赖树中的一条弧,连接句子中两个语法上相关的单词 ➋。在这个例子中,句子中有五个单词,并定义了四个语法关系。正因如此,字典在"words"列表中包含五个项目,在"arcs"列表中包含四个项目。

现在我们已经有了包含数据的字典,我们需要为示例句子生成一个依赖关系解析的可视化效果;可以使用以下代码进行渲染:

➊ from spacy import displacy

   displacy.serve(➋sent, style="dep", ➌manual=True)

请注意,我们不需要导入整个 spaCy 库。我们只需从中导入 displacy 模块 ➊。然后调用 displacy.serve() 方法,将 sent 字典作为第一个参数传入,代替 Doc 对象 ➋。第三个参数 manual 告诉 displaCy 我们是手动创建的数据集用于渲染 ➌,因此 displaCy 不需要从 Doc 对象中提取数据。

尝试这个

当你选择手动创建一个字典并将数据渲染为可视化时,你可以使用自定义标签,例如指定可视化工具使用细粒度的词性标签,而不是默认的粗粒度标签。

你可以通过在传入 Doc 对象进行渲染时,仅仅将 fine_grained 选项设置为 True 来完成此任务,但为了练习,试着手动实现这一过程。

在 “格式化数据” 的示例中(见第 108 页),更改 "sent" 字典中 "words" 列表里的标签,使它们的值成为细粒度标签。接着,启动 displaCy 服务器,并指示它基于 "sent" 字典中指定的数据生成可视化。然后,打开浏览器并访问 http://localhost:5000 来查看可视化效果。

总结

你在之前的章节中已经见过句法结构的可视化图像,但在这一章中,你学习了如何使用 displaCy 依赖关系可视化工具生成这些可视化效果。你还学习了如何使用 displaCy 命名实体可视化工具生成命名实体信息的图像。

第八章:意图识别**

Image

聊天机器人应足够聪明,能够理解用户的需求。例如,一个对话型聊天机器人必须识别用户的意图,才能与用户维持正确的对话,而一个食品订购聊天机器人则需要理解顾客的意图才能接单。虽然意图识别的任务在前面的章节中有所涉及,但本章会更深入地讨论它。

你将从通过提取发话中的及物动词和直接宾语来识别用户的意图开始。然后,你将探索如何从一系列句子中推导出用户的意图,识别不同可能意图的同义词,并通过语义相似性来确定用户的意图。

提取及物动词和直接宾语以进行意图识别

通常,你可以通过三步来识别用户的意图:将句子解析为标记,将标记连接起来,标记之间的弧线表示句法关系,并通过遍历这些弧线提取相关标记。在许多情况下,提取句子的及物动词和直接宾语可以识别用户的意图,如图 8-1 所示的句法依赖解析所示。

image

图 8-1:句子句法结构的图形表示示例

连接及物动词和直接宾语的弧线表示用户的意图是寻找一家酒店,或者如果你将及物动词和直接宾语合并成一个词,就是findHotel。你可以在程序的后续部分使用这种结构作为意图标识符,如下所示的代码片段:

      intent = extract_intent(doc) 

      if intent == 'orderPizza': 

        print('We need you to answer some questions to place your order.') 

        ...

      elif intent == 'showPizza': 

        print('Would you like to look at our menu?')

        ...

注意

在第十一章中,你将看到更多关于如何在聊天机器人应用的代码中使用意图标识符的详细示例。

但有时从及物动词/直接宾语对中找出含义并不那么容易。你可能需要探讨及物动词和直接宾语的句法关系,以找到最佳描述意图的动词和名词。

在其他情况下,用户的意图并未明确表达出来,因此你必须通过推测隐含的意图来理解。在本节中,你将学习如何使用句法依赖结构来提取意图的策略。

获取及物动词/直接宾语对

让我们通过检查每个标记的依赖标签,寻找dobj来从句子中提取及物动词/直接宾语对。一旦找到直接宾语,我们就可以通过获取直接宾语的句法主干,轻松找到相应的及物动词,正如以下脚本所示:

   import spacy

   nlp = spacy.load('en')

➊ doc = nlp(u'show me the best hotel in berlin')

➋ for token in doc:

     if token.dep_ == 'dobj':

       print(➌token.head.text + token.text.capitalize())

在这个脚本中,我们首先对示例句子 ➊ 应用管道处理,然后遍历标记,查找其依赖标签为 dobj 的标记 ➋。找到后,我们通过获取直接宾语的中心词 ➌ 来确定对应的及物动词。在这个例子中,我们还将及物动词和其直接宾语连接起来,以便将意图以单一单词的形式表达出来。

脚本生成以下字符串:

showHotel

请记住,并非所有包含及物动词/直接宾语对的句子都表示意图。例如,“他给了我一本书”只是陈述事实。我们可以通过检查动词的特征来过滤掉这样的句子,仅挑选出那些动词为现在时并且不是第三人称的句子。然而,像这样的句子从客户与接单聊天机器人之间的对话中听到的可能性不大。

使用 token.conjuncts 提取多个意图

有时,你可能会遇到似乎表达多个意图的句子。例如,考虑以下句子:

I want a pizza and cola.

在这种情况下,用户想要订购一份披萨和一瓶可乐。但在大多数情况下,你可以将这些意图视为单一复杂意图的一部分。尽管用户请求不同类型的物品,你通常会将这个句子视为由多个物品组成的单一订单。在这个例子中,你可能会将意图识别为 orderPizza,这是通过将及物动词和直接宾语对结合起来形成的,但会提取 pizzacola 作为订单中的物品。

图 8-2 显示了示例句子的依赖树。

image

图 8-2:包含直接宾语及其连接词的句子的依赖树

在图示中,你可以看到有两条箭头指向直接宾语“pizza”和与之相关的连接词“cola”。名词的连接词是通过连词如“和”、“或”等连接的另一个名词。为了提取直接宾语及其相关的连接词,我们可以使用以下代码:

   doc = nlp(u'I want a pizza and cola.')

   #extract the direct object and the conjunct associated with it

   for token in doc:

     if token.dep_ == 'dobj':

       dobj = [token.text]

       conj = [t.text for t in ➊token.conjuncts]

   #compose the list of the extracted elements

➋ dobj_conj = dobj + conj 

   print(dobj_conj)

我们通过使用直接宾语 ➊ 的 Token 对象的 conjuncts 属性来提取与直接宾语相关的连接词。一旦获得了直接宾语及其连接词,我们将它们合并成一个列表 ➋。

脚本输出应该如下所示:

['pizza', 'cola']

为了构建意图,我们还应提取动词。我们在已经拥有直接宾语的情况下,获取直接宾语的句法中心词是获得动词的最简单方法(你在《获取及物动词/直接宾语对》中第 113 页看到过这个例子):

verb = dobj.head

然后,使用动词和直接宾语的文本属性,我们可以组合出意图。

试一试

在 第 114 页提供的脚本中,你通过 Token 对象的 conjuncts 属性访问了与直接宾语相关的连接词。在新的脚本中,替换这一行代码,通过寻找从直接宾语向外延伸的标记为 conj 的弧线来提取连接词。你可以在同一个循环中完成这项工作,在该循环中,你通过寻找标记为 dobj 的弧线获得直接宾语。务必检查 conj 弧线的头词是否与直接宾语匹配。

使用词汇表提取意图

在某些情况下,除了及物动词和直接宾语外,其他词汇可能更好地描述用户的意图。这些词汇通常与及物动词或直接宾语相关。因此,你需要进一步探讨及物动词和直接宾语的句法关系,以发现能够最好表达意图的词汇。

举个例子,考虑以下话语:

I want to place an order for a pizza.

在这个句子中,“want”和“pizza”这两个词最能表达意图,但它们都不是直接宾语或及物动词。然而,通过查看话语的依赖树,你会发现“want”和“pizza”分别与及物动词“place”和直接宾语“order”相关。图 8-3 展示了这里讨论的依赖树。

image

图 8-3:一个话语的依赖树,其中的及物动词和直接宾语并未传达用户的意图

为了从话语中提取这些词,我们将使用一个预定义的词汇表,然后在用户的话语中搜索这些词。

一位经验丰富的程序员可能会质疑硬编码如此长的列表的有效性,尤其是在多种不同场景下使用时。但如果这个列表是针对特定场景的,例如下订单披萨,它可能出奇的简短,这使得这种方法非常高效。以下代码实现了这种方法:

   #apply the pipeline to the sample sentence

   doc = nlp(u'I want to place an order for a pizza.')

   # extract the direct object and its transitive verb

   dobj = ''

   tverb = ''

   for token in doc:

  ➊ if token.dep_ == 'dobj':

       dobj = token

       tverb = token.head 

   # extract the verb for the intent's definition

   intentVerb = ''

   verbList = ['want', 'like', 'need', 'order']

➋ if tverb.text in verbList: 

     intentVerb = tverb

➌ else:

     if tverb.head.dep_ == 'ROOT':

       intentVerb = tverb.head

   # extract the object for the intent's definition

   intentObj = ''

   objList = ['pizza', 'cola']

➍ if dobj.text in objList: 

     intentObj = dobj

   else:

     for child in dobj.children:

       if child.dep_ == 'prep': 

        ➎ intentObj = list(child.children)[0]

           break

    ➏ elif child.dep_ == 'compound': 

           intentObj = child

           break

   # print the intent expressed in the sample sentence 

   print(intentVerb.text + intentObj.text.capitalize())

和往常一样,我们从查找并提取直接宾语及其及物动词 ➊ 开始。一旦得到它们,我们检查它们是否能在相应的预定义词汇表中找到。这里我们当然使用简化的词表:verbList 列表包含顾客可能用来下订单的动词,而 objList 列表包含可能的菜单项作为直接宾语。我们首先检查及物动词 ➋。如果它不在允许的动词列表 ➌ 中,我们就检查句子的主动词(ROOT),它是及物动词的头词。如果及物动词是句子的主动词,这个实现依然有效,因为主动词(ROOT)的头词指向它自己。

接下来,我们继续检查直接宾语 ➍。如果它不在允许的词汇列表中,我们检查它的句法子节点。我们首先检查直接宾语的介词。如果存在介词,我们会选取介词的子节点(它只能有一个子节点) ➎,该子节点始终是介词的宾语。

为了使这种方法适用于更多的情况,光是检查直接宾语的子节点中的介词是不够的。例如,下面这句话就无法使用此逻辑:“I want to place a pizza order”(我想下个披萨订单),其中没有介词子分支。相反,直接宾语有一个左子节点,“pizza”(披萨),spaCy 将其标记为复合词。因此,我们检查直接宾语的子节点中是否有复合词 ➏。

最后,我们打印表示意图标识符的字符串。我们应该得到以下字符串:

wantPizza

注意,我们使用 wantPizza 作为意图标识符,而不是 placeOrder(如果我们仅仅使用及物动词/直接宾语对,本来会得到这个标识符)。这种方法使我们能够减少应用程序中使用的意图标识符的数量。

使用同义词和语义相似性来找出词义

英语,像许多其他自然语言一样,允许你以不同的方式表达相同的思想和意图,因为它包含了同义词——意思几乎相同的单词或短语。

作为一个聊天机器人应用程序的开发者,你需要考虑到用户可能会使用一套相当广泛的短语来表达每个应用程序应支持的意图。这意味着你的应用程序必须能够识别用户输入中的同义短语。事实上,如果你是在像 Google 的 Dialogflow 这样的流行机器人平台上构建你的聊天机器人,你需要为每个可能的意图提交一组短语。然后,你可以在后台使用这些话语来训练机器人的模型。

识别同义词有不止一种方法。一种方法是使用预定义的同义词列表。你可以将感兴趣的单词与这些列表进行比对,从而根据它所在的列表识别单词的意义。另一种方法是基于语义相似性来识别同义词,这个任务在第五章中有详细描述。我将在接下来的部分讨论这两种方法。

使用预定义列表识别同义词

你已经知道,在大多数情况下,及物动词和它的直接宾语能最准确地描述一个短语的意图。识别两个短语是否表达相同意图的一个简单方法是,确保这两个短语中的及物动词是同义的,并且它们的直接宾语也是同义的。

例如,下面这三句话表达了相同的意图,你可以将其定义为 orderPizza(订披萨):

I want a dish. I'd like to order a pizza. Give me a pie.

处理这些话语的步骤如下:

  1. 执行依存句法分析,从句子中提取及物动词及其直接宾语。

  2. 使用预定义的同义词列表,替换及物动词和直接宾语,用应用程序识别的单词替代。

  3. 构建表示意图的字符串。

图 8-4 中的图示总结了这些步骤,展示了这如何适用于例如“我想要一份菜”的情况。

image

图 8-4:使用同义词列表处理意图识别

在依赖分析之后(在你应用管道处理语句时会隐式完成),你提取出直接宾语和及物动词,然后将它们与对应的同义词列表进行对比。如果找到匹配项,你将该单词替换为列表中的第一个元素,应用程序应该能够识别这个词。

让我们来看一下这个场景的 Python 实现可能是什么样的:

   #apply the pipeline to the sample sentence

   doc = nlp(u'I want a dish.')

   #extract the transitive verb and its direct object from the dependency tree

➊ for token in doc:

     if token.dep_ == 'dobj':

       verb = token.head.text 

       dobj = token.text

   #create a list of tuples for possible verb synonyms

➋ verbList = [('order','want','give','make'),('show','find')]

   #find the tuple containing the transitive verb extracted from the sample

➌ verbSyns = [item for item in verbList if verb in item]

   #create a list of tuples for possible direct object synonyms

➍ dobjList = [('pizza','pie','dish'),('cola','soda')]

   #find the tuple containing the direct object extracted from the sample

   dobjSyns = [item for item in dobjList if dobj in item]

   #replace the transitive verb and the direct object with synonyms supported by

   the application

   #and compose the string that represents the intent 

➎ intent = verbSyns[0][0] + dobjSyns[0][0].capitalize()

   print(intent)

我们首先为示例句子创建一个 Doc 对象实例。然后,我们通过 Doc 对象迭代可用的依赖树,提取及物动词及其直接宾语 ➊。接下来,我们创建一个包含所有允许的及物动词及其同义词的元组列表 ➋。每个元组的第一个元素是应用程序可以识别的及物动词,元组中的其他元素是其同义词。

现在我们已经定义了允许的及物动词及其同义词,并将它们放入一个元组列表中,我们可以遍历整个列表,寻找包含从示例句子提取的及物动词的元组 ➌。

同样,我们为已识别的直接宾语及其同义词创建一个元组列表,然后找到包含从示例中提取的直接宾语的元组 ➍。

最后,我们将选择的元组的第一个元素拼接起来,组成意图名称 ➎。结果,打印命令应该输出以下字符串:

orderPizza

请记住,选择给定动词的同义词集合在很大程度上取决于我们正在创建的应用类型。例如,在一个接收披萨订单的机器人应用中,“make”和“give”这两个动词可以被认为是同义的。原因是用户在点披萨时可能交替使用“Make me a pizza”和“Give me a pizza”这两种说法。

试试看

使用第 118 页提供的示例代码作为创建新脚本的基础。让脚本保持原有功能,但当找不到及物动词、直接宾语或两者时,将意图名称生成“unrecognized”。为了测试代码,可以用示例句子进行实验,修改它以便看到新功能的效果。例如,你可以使用以下句子:

I want an apple.

用一个包含列表中没有的动词的句子来测试它。

另外,你可能会尝试使用同义词列表处理前面章节中讨论的连接词问题。

通过语义相似性识别隐含的意图

现实世界的实现可能会比本章所展示的示例更为复杂。即使使用大量预定义的同义词列表,也不总是奏效。原因在于用户以多种不同方式表达他们的意图,而且他们并不总是明确表达。

识别隐含意图很大程度上依赖于上下文。例如,如果你的机器人是为特定类型的任务设计的,比如订餐,它应该能够识别暗示请求的短语,如“我想吃一个馅饼。”,并理解这是下披萨订单的意图。

一种广为人知的技巧是通过询问澄清性问题来让用户更明确地表达他们的意图。为了确定该问什么问题,你可以计算之前用户话语的语义相似度。

图 8-5 展示了如何实现这一任务。

image

图 8-5:通过计算语义相似度并询问澄清性问题来识别隐含意图

你首先分析输入语句的依存关系树,以提取直接宾语及其及物动词。例如,如果在预定义的同义词列表中找不到直接宾语,如在“使用预定义列表识别同义词”第 117 页中讨论的那样,你可以尝试确定直接宾语与列表中词汇的相似度。基于计算语义相似度的结果,你可以生成一个澄清性问题。

让我们通过代码来实现这一技巧,我将其分成几个部分。和往常一样,我们从将文本处理管道应用于示例句子开始:

doc = nlp(u'I feel like eating a pie.')

然后我们提取直接宾语词元:

for token in doc:

  if token.dep_ == 'dobj':

    dobj = token

我们为“食物”一词创建一个词元。我们将计算这个词元与直接宾语词元之间的语义相似度:

tokens = nlp(u'food')

如果相似度超过预定阈值,应用程序会猜测用户很可能有下单的意图。然后,它会提出一个澄清性问题以确认这一点:

if dobj.similarity(tokens[0]) > 0.6:

  question = 'Would you like to look at our menu?'

回想一下第五章中提到的,spaCy 使用词向量来计算词元的语义相似度。两个向量在向量空间中的距离越近,它们之间的相似度就越高。在这个示例中,我们使用 0.6 作为假设直接宾语与食品产品相似的最低相似度标准。

尝试这个

当然,你无法预先知道用户将使用哪些短语,也无法预测识别用户意图的难易程度。你的应用程序也无法做到这一点。这就是为什么实际应用通常结合多种方法来识别意图。结合基于识别同义词的方法与基于处理隐含意图的方法(如前文所述),这样你可以应对更多的可能情况。首先尝试使用基于同义词的方法从发话中提取意图。如果失败,再尝试基于语义相似性的方式。如果两种方法都失败,你可以将发话标记为表达了无法识别的意图。

从一系列句子中提取意图

在一个话语中,反映用户意图的词语可能分布在不同的句子中,如以下示例所示:

I have finished my pizza. I want another one.

你的机器人应该能够处理这种场景,从整个话语中提取用户的意图。在本节中,我将向你介绍一种实现这一目标的技术。

遍历话语的依赖结构

让我们首先看看话语的依赖解析,它将揭示每个句子中的及物动词/直接宾语对,如图 8-6 所示。

image

图 8-6:整个话语的依赖解析的可视化表示

图中的淡箭头表示相关的依赖关系。换句话说,你需要将代词“one”替换成它所代表的名词“pizza”。但是 spaCy 中的依赖解析器并不会显示这一连接,因为它无法连接来自不同句子的标记。所以,确定这些依赖关系的任务交给了你。

替换代词与其先行词

先行词是一个表达(如单词或从句),它为代词(如代词或代动词)赋予了意义。在这种意图提取中,你需要确定先行词,并将相应的代词替换为先行词。你可以通过以下步骤进行操作:

  1. 解析整个话语的依赖关系。

  2. 将话语分解为句子。

  3. 找到与及物动词的直接宾语相关的代词先行词,用于意图定义。

图 8-7 以图示方式展示了这些步骤。

image

图 8-7:从一系列句子中提取意图的图示

在 spaCy 中,我们可以通过几行代码实现前两个步骤:

doc = nlp(u'I have finished my pizza. I want another one.')

我们将doc.sents属性返回的对象转换为列表,这样我们就可以通过索引引用文本中的每个句子。(我们也可以直接使用for循环遍历doc.sents中的句子序列。)

接下来,我们定义两个列表,分别包含允许的及物动词和允许的直接宾语:

verbList = [('order','want','give','make'),('show','find')]

dobjList = [('pizza','pie','pizzaz'),('cola','soda')]

这些列表包含同义词元组(详情请参见 “使用预定义列表识别同义词” 以及 第 117 页)。

我们还需要定义一个允许的替代品列表。为此,我们必须确定直接宾语可能是什么代词。首先,让我们弄清楚可以替代最后一个句子的其他短语,并在每个短语中突出显示直接宾语。可能的替代包括以下内容:

I want another one. I want it again. I want the same. I want more.

因此,我们可以将替代列表定义如下:

substitutes = ('one','it','same','more')

与及物动词和直接宾语列表不同,替代列表结构简单,因为我们不需要对替代项进行分组。同一个替代项可以指代任何一个直接宾语。

除了这些列表,我们还可能想定义一个字典,以便在提取过程中存储意图定义的各个部分:

intent = {'verb': '', 'dobj': ''}

现在我们已经准备好开始意图识别过程:

for sent in doc.sents:

  for token in sent:

    if token.dep_ == 'dobj':

      verbSyns = [item for item in verbList if token.head.text in item]

   ➊ dobjSyns = [item for item in dobjList if token.text in item]

      substitute =  [item for item in substitutes if token.text in item]

      if ➋(dobjSyns != [] or substitute != []) and ➌verbSyns != []:

          intent['verb'] = verbSyns[0][0]

   ➍ if dobjSyns != []:

          intent['dobj'] = dobjSyns[0][0]

外层循环遍历存储在 Doc 对象中的句子序列。内层循环则遍历句子中的每个标记。我们检查每个标记,看看它是否是直接宾语。如果是,我们进一步判断该直接宾语是否在直接宾语同义词列表或替代列表中 ➊。我们还会检查对应的及物动词是否在及物动词同义词列表中。

只有当直接宾语在直接宾语同义词列表或替代列表中时,我们才会提取它 ➋。例如,我们不会对以下短语中的及物动词感兴趣(除非我们卖苹果,当然):

I want an apple.

如果及物动词不在允许列表 ➌ 中,即使它的直接宾语满足此条件,我们也不关心该及物动词,如以下短语所示:

I like it.

这就是为什么,在提取及物动词之前,我们不仅检查直接宾语是否在直接宾语同义词列表或替代列表中,还要检查及物动词是否在及物动词同义词列表中。

最后,为了提取定义意图的直接宾语,我们需要确保它能在直接宾语同义词列表 ➍ 中找到。现在我们可以组成意图定义了:

intentStr = intent['verb'] + intent['dobj'].capitalize()

可选地,我们可能想要打印出来,以确保一切按预期工作:

print(intentStr)

我们应该得到以下输出:

orderPizza

这个结果表明用户打算点一个披萨。

尝试一下

在某些语境中,几个句子可能会把一个先行词与其代词分开。例如,考虑以下句子序列:

I have finished my pizza. It was delicious. I want another one.

编辑 第 124 页 提供的脚本,使其能够处理这个或类似的句子序列。

总结

意图识别是一项复杂的任务,可能需要你结合多种方法。在本章中,你学习了如何提取话语依存树中最重要的部分用于意图识别。然后,你使用预定义的列表、语义相似度或两者结合的方法进行分析。你还通过用指代词替换其指代词先行词,从一系列句子中提取意图。

第九章:将用户输入存储在数据库中**

Image

许多为业务设计的应用程序在某个时候都需要将它们处理的数据转移到数据库中。例如,一个食品订购聊天机器人可能会在与客户对话中提取信息并填写订单表单后将其保存。一旦订单出现在数据库中,它就可以进行进一步处理,并最终将产品运送给客户。

本章讨论了如何将从提交文本中提取的信息转换为结构化格式,以便你可以在关系(行和列)数据库中存储和操作它。通过示例,你将学习如何将聊天机器人将输入文本剪碎成片段,并从中构建一个准备好进入数据库的结构。

将非结构化数据转换为结构化数据

结构化数据 是使用预定义的数据模式在格式化存储库中组织的。如果你以前使用过关系数据库,你会知道必须首先将要输入数据库的任何数据转换为结构化格式,以便它适合表或相关表中。

应用程序收到的自然语言输入存在一个问题,即非结构化,意味着它没有预定义的组织模式。非结构化数据的典型例子包括文本和多媒体内容,比如电子邮件、网页、业务文件、视频、照片等等。尽管你仍然可以将非结构化数据存储在数据库中,通常在插入时需要进行一些预处理。例如,你可能需要标记照片以便数据库能够对其进行分类,或者为文本文档分配 ID 以便数据库能够区分它们。

有时,你可能需要对非结构化文本内容执行更为激进的转换,例如从中提取信息片段,然后将这些片段分组到一个格式化的结构中。例如,业务聊天机器人通常需要解析客户的话语以填写某种表单。不同的应用程序可能仅从网页中提取特定元素,并为这些元素打上标签,然后将信息转换为表格,正如图 9-1 中所示。

image

图 9-1:将非结构化内容转换为结构化数据的示例

像 spaCy 这样的工具通过在句子中为每个标记打上语言学注释的方式揭示了文本的内部结构。这种预处理使你能够从中提取特定元素,通常通过检查文本的句法依赖标签来实现。图 9-2 描述了一个食品订购聊天机器人如何通过依赖于 spaCy 为每个标记分配的句法依赖标签,识别并提取用户话语中的必要元素时的情形,这是应用文本处理流程到其上的结果。

image

图 9-2:将原始文本转换为行-列数据的高层视图

接下来,你将看到一旦提取了这些元素,如何将它们结构化并插入到数据库表格中作为一行数据。

将数据提取到交换格式中

许多当前的关系型数据库原生支持多种常见的数据交换格式。例如,MySQL 原生支持 XML 和 JSON,这两种是网络上最常见的数据交换格式。

你选择的数据格式会影响你收集数据的方式。例如,如果你使用的数据库支持 JSON,你可以直接将数据提取为 JSON 对象,然后将其发送到数据库进行进一步处理。JSON 对象是一种由大括号包围的键值数据格式,格式如下:

{"product": "pizza", "type": "Chicago", "qty": 1}

除了基本值,如字符串和数字外,JSON 还支持复杂值,如数组和其他 JSON 对象。你将在“构建数据库驱动的聊天机器人”一节中看到这一点,见第 132 页。

实际上,使用 JSON 格式大大简化了在 Python 脚本中为数据库构建数据结构的过程。首先,你不需要准备符合较少使用的格式的结构,这样使得你的代码不那么依赖于特定的数据库类型。其次,JSON 对象中的元素可以按照任意顺序排列,这对从输入文本中确定和提取必要元素的过程施加了较少的限制。

图 9-3 展示了一个食品订购聊天机器人应用如何使用 JSON 与其底层数据库进行交互。

image

图 9-3:食品订购聊天机器人应用的工作流程

在步骤 1 中,用户向聊天机器人提交一个请求,要求购买希腊披萨。在步骤 2 中,聊天机器人使用 spaCy 处理提交的语言表达,生成一个包含订单所需信息的 JSON 对象。在步骤 3 中,表示订单表单的 JSON 对象被提交到数据库,数据库存储该表单并为聊天机器人生成关于订单的响应。在步骤 4 中,聊天机器人通知用户订单是否已经成功下单。

将应用逻辑移至数据库

请注意,在图 9-3 所示的聊天机器人应用中的数据库,不仅存储提交的 JSON 对象,还会生成关于保存订单操作是否成功的响应。原因是数据库执行了一部分应用逻辑。

对于数据库驱动的应用程序来说,将与数据处理相关的应用逻辑保留在数据库中是相当常见的做法。这种方法可以减少应用程序逻辑层与底层数据库之间的数据传输,消除冗余,提高数据处理效率,并确保数据安全。

图 9-4 详细描述了图 9-3 中展示的聊天机器人应用的数据库部分。

image

图 9-4:聊天机器人应用中使用的数据库的更详细视图,如图 9-3 所示

在这个应用程序中,数据库将把输入的 JSON 对象转换为关系数据,并以确保数据正确完整的方式将其存储在关系表中。如果某个字段的值缺失,客户将收到一条消息,告知他们应该提供哪些信息。

在将输入数据移到表中之前,你可以借助存储过程、SQL 语句中的ON ERROR子句,或定义在数据表上的触发器来检查每个字段的值。关于 SQL 的更详细讨论超出了本书的范围。但在“准备你的数据库环境”第 135 页中,你会看到一个使用 SQL 创建数据库基础设施并通过 Python 与其交互的示例。

注意

如果你使用的数据库不支持将 JSON 数据转换为关系数据等功能,你将需要在 Python 中自行实现检查数据完整性的逻辑;然而,这部分内容超出了本章的讨论范围。

构建基于数据库的聊天机器人

现在你已经大致了解了如何实现一个基于数据库的聊天机器人应用程序,让我们为图 9-3 中展示的应用创建一个简单的示例。该应用应处理用户的发话,提取必要的信息来填写订单表格,如产品名称、产品类型和数量。然后,这些信息将被打包成一个 JSON 对象,并发送到底层数据库。数据库应将该 JSON 对象拆解成关系数据,并根据数据的完整性向应用发送响应。

收集数据并构建 JSON 对象

我们将从开发应用程序的逻辑层开始,使用 Python 构建一个 JSON 对象,随后可以将其发送到任何类型的数据库。以下代码展示了该实现的可能样式:

   import spacy

   nlp = spacy.load('en')

   doc = nlp(u'I want a Greek pizza.')

➊ orderdict ={}

➋ for token in doc:

  ➌ if token.dep_ == 'dobj':

       dobj = token

    ➍ orderdict.update(product = dobj.lemma_)

    ➎ for child in dobj.lefts:

      ➏ if child.dep_ == 'amod' or child.dep_ == 'compound': 

           orderdict.update(ptype = child.text )

      ➐ elif child.dep_ == 'det': 

           orderdict.update(qty = 1 )

      ➑ elif child.dep_ == 'nummod': 

           orderdict.update(qty = child.text)

       break

我们将orderdict字典定义为正在创建的 JSON 对象的容器➊。稍后,我们将能够轻松地将这个字典转换为 JSON 字符串。

然后,我们遍历发话中的标记 ➋,寻找直接宾语 ➌。我们可能想要一个比萨,或者可能会要求某人给我们做一个比萨。无论哪种情况,“比萨”都将是我们发话中的直接宾语,因此我们在这里寻找直接宾语。当然,实际实现中会进行更多的检查。

一旦找到它,我们在orderdict字典中定义一个键值对,将product作为键,直接对象的词根作为值➍。我们使用词形还原将产品名称的可能词形简化为其基本形式(在大多数情况下将复数转换为单数)。

接下来,我们遍历直接对象的句法左子节点 ➎,因为我们期望在这里获得关于请求的产品类型的信息。就句法依赖标签而言,产品的type可以是复合词或形容词修饰语(amod)➏。例如,spaCy 会将短语“a Greek pizza”中的“Greek”视为形容词修饰语,而将“a Chicago pizza”中的“Chicago”视为复合词。

现在,我们检查修饰符或复合词的子节点中是否存在限定词。如果存在“a”限定词,则意味着客户请求一个单位的产品 ➐。相反,带有依赖标签nummod的词则表示特定数量的单位 ➑。

使用以下命令打印orderdict字典:

print(orderdict)

这应该给出以下结果:

{'product': 'pizza', 'ptype': 'Greek', 'qty': 1}

现在我们有一个 JSON 字符串,可以将其发送到底层数据库进行进一步处理。

将数字单词转换为数字

在进入将你的 JSON 字符串发送到数据库的代码之前,考虑一下当用户明确指定产品数量时,情况会如何,如以下语句所示:

I want two Greek pizzas.

如果你将这段代码放入之前的脚本中,你将得到以下结果:

{'product': 'pizza', 'ptype': 'Greek', 'qty': two}

在第一个示例句子中,'qty'键的值是一个数字。在第二个示例中,它是一个以单词拼写的数字。在此阶段,这种差异看起来不是什么问题。但问题在于,我们在创建关系表时必须为每个列定义一个数据类型。如果尝试将其他类型的数据插入该列,将会失败。

你应该为你的聊天机器人做好准备,因为客户会以任何他们喜欢的方式指定产品数量。为了解决这个问题,你需要将表示数字单词的字符串转换为相应的整数值。

为此,定义一个包含拼写为单词的数字并按递增顺序排序的列表;然后遍历该列表以找到正确的数字。在此示例中,我们定义一个数字单词列表,范围从“zero”到“twenty”,假设我们不期望客户在一次交易中购买超过二十个同样的产品。

我们需要实现一个转换场景,该场景作为一个函数,接受一个数字单词或数字(在后者情况下,不需要转换)并返回一个数字。然后,我们应该使用这个函数来修改前一部分中的脚本代码。下面是该函数实现可能的样子:

➊ def word2int(numword):

     num = 0

  ➋ try:

    ➌ num = int(numword)

    ➍ return num

     except ValueError:

    ➎ pass

  ➏ words = ["zero", "one", "two", "three", "four", "five", "six", "seven",

     "eight","nine", "ten", "eleven", "twelve", "thirteen", "fourteen", 

     "fifteen", "sixteen", "seventeen", "eighteen", "nineteen", "twenty"]

  ➐ for idx, word in enumerate(words):

        ➑ if word in numword:

             num = idx 

  ➒ return num

word2int()函数接受一个参数:要转换为相应数字的数字单词,或者已经是数字的情况,在这种情况下我们不需要转换它 ➊。该函数必须处理这两种情况,因为我们无法预知客户的发言中会是哪种情况。

我们使用tryexcept块来处理无需转换的情况➋。我们检查输入是否为整数➌,如果是,我们只需要返回输入的数字 ➍。否则,我们忽略尝试将非数字值当作整数处理所引发的错误,并继续将其转换为数字 ➎。

我们定义了一个数字单词列表,从zero开始,并按递增顺序列出它们 ➏。然后我们使用enumerate()方法 ➐遍历这个列表,寻找函数接收到的输入单词 ➑。当我们找到匹配项时,我们返回当前迭代的编号(该单词在列表中的索引)作为输入数字单词的数字表示 ➒。

word2int()函数定义添加到之前的脚本中。然后移动到脚本的末尾,找到以下代码行:

      elif child.dep_ == 'nummod': 

        orderdict.update(qty = child.text)

按照以下方式进行更改,使用本节中定义的word2int()函数:

      elif child.dep_ == 'nummod': 

        orderdict.update(qty = word2int(child.text))

现在让我们看看脚本如何处理这个句子:

I want two Greek pizzas.

这次,你应该得到以下结果:

{'product': 'pizza', 'ptype': 'Greek', 'qty': 2}

'qty'字段的值现在是一个数字,我们拥有一致的格式来发送到数据库。

准备你的数据库环境

为了准备你的数据库环境,你需要安装或获得访问数据库的权限;创建你需要的数据库组件,例如数据库模式、表格等;并安装一个能够与数据库进行交互的 Python 模块。

虽然你可以选择任何能够接收和处理 JSON 数据的数据库,例如 Oracle 数据库,本节使用 MySQL。MySQL 数据库长期支持最流行的数据交换格式,XML 和 JSON。而且,MySQL 是全球最受欢迎的开源数据库,并且可以在大多数现代操作系统上使用,包括 Linux、Windows、Unix 和 macOS。MySQL 提供了一个免费可下载版本,并且还有商业版来满足特定的业务需求。

对于本章,你可以使用 MySQL Community Edition——在 GPL 许可证下可以自由下载的版本。要了解更多关于 MySQL Community Edition 的信息,请访问其官方网站 *www.mysql.com/products/community/*

你首先需要在系统上安装 MySQL。截至本文写作时,MySQL 8.0 是最新版本。请参考 MySQL 8.0 参考手册中的“安装和升级 MySQL”章节 dev.mysql.com/doc/refman/8.0/en/installing.html,或者查看未来版本 MySQL 的等效章节。这里,你将找到适用于你的操作系统的详细安装说明。

安装完成后,您可以使用安装指南为您的操作系统指定的命令启动 MySQL 服务器。在开始与数据库工作之前,您需要获取在安装过程中生成的mysql超级用户(‘root’@‘localhost’)密码。您可以在安装错误日志文件中找到该密码。

一旦获得超级用户密码,您可以从系统终端使用以下命令连接到 MySQL 服务器:

$ mysql -uroot -p

Enter password: ******

mysql>

如果您更喜欢使用图形界面,您可以利用 MySQL Workbench (www.mysql.com/products/workbench/), 这是一款统一的可视化工具,旨在建模和管理 MySQL 数据库。

连接到服务器后,您的第一步是为 root 用户选择一个新密码,替换安装过程中生成的随机密码。使用以下命令进行更改:

ALTER USER 'root'@'localhost' IDENTIFIED BY 'Your-pswd';

现在,您已准备好开始在服务器上开发应用所需的基础设施。您将首先创建一个数据库,用作应用需要交互的其他对象的容器。要创建数据库,请在mysql>提示符下输入以下命令:

mysql> CREATE DATABASE mybot;

Query OK, 1 row affected (0.03 sec)

然后选择新创建的数据库进行使用,如下所示:

mysql> USE mybot;

Database changed

您已准备好开始创建数据库结构。对于此示例,您需要使用以下命令创建一个单独的表:

CREATE TABLE orders (

  id INT NOT NULL AUTO_INCREMENT,

  product VARCHAR(30),

  ptype VARCHAR(30),

  qty INT,

  PRIMARY KEY (id)

);

设置好数据库基础设施后,您需要安装 MySQL Connector/Python 驱动程序,它允许您的 Python 代码与该基础设施进行交互。在任何操作系统上,您可以通过pip安装 Connector/Python,如下所示:

pip install mysql-connector-python

有关如何安装此驱动程序的更多详细信息,请查阅文档 dev.mysql.com/doc/connector-python/en/

使用以下简单脚本,确保您已安装 Connector/Python:

import mysql.connector

cnx = mysql.connector.connect(user='root', password='Your_pswd',

                              host='127.0.0.1',

                              database='mybot')

cnx.close()

如果安装成功,您应该看不到任何错误信息。

将数据发送到底层数据库

让我们回到第 134 页的脚本。以下代码将连接到您的数据库,并将订单数据传递到orders表中。将此代码附加到脚本中:

   import json

➊ json_str = json.dumps(orderdict)

   import mysql.connector

   from mysql.connector import errorcode

   try:

  ➋ cnx = mysql.connector.connect(user='root', password='Your_pswd',

                                   host='127.0.0.1',

                                   database='mybot')

  ➌ query = ("""INSERT INTO orders (product, ptype, qty)

     SELECT product, ptype, qty FROM

         JSON_TABLE(

        ➍ %s,

           "$" COLUMNS(

             qty    INT PATH '$.qty', 

             product   VARCHAR(30) PATH "$.product",

             ptype     VARCHAR(30) PATH "$.ptype"

           )

         ) AS jt1""")

  ➎ cursor = cnx.cursor()

  ➏ cursor.execute(query, ➐(json_str,))

  ➑ cnx.commit()

➒ except mysql.connector.Error as err:

     print("Error-Code:", err.errno)

     print("Error-Message: {}".format(err.msg))

   finally:

     cursor.close()

     cnx.close()

我们首先将orderdict字典转换为 JSON 字符串 ➊。接下来,我们连接到数据库 ➋,并定义一个插入 SQL 语句,将其传递到数据库进行处理 ➌。注意语句中使用了占位符(称为绑定变量) ➍。使用占位符允许我们编写在运行时接受输入的 SQL 语句。

在我们执行语句之前,我们先创建一个mysql.connector光标对象 ➎,它使我们能够对连接到的数据库中的对象执行操作。然后我们可以执行INSERT语句 ➏,将我们在这段代码开头获得的 JSON 字符串 ➐ 绑定到语句中的占位符。请注意,JSON_TABLE函数将提交的 JSON 数据转换为表格数据,从而使其适合插入到关系型表中。

在执行完INSERT语句后,我们需要显式地使用commit()方法 ➑提交语句的更改。否则,当连接关闭时(无论是显式地使用cnx.close(),还是脚本执行完成时),插入操作将会回滚。

如果数据库端发生错误,except块将开始执行 ➒。在下一部分,你将学习如何在传入的 JSON 字符串不包含所有字段时利用此功能。

现在执行脚本。如果你没有看到任何错误消息,请返回到上一节中使用的mysql提示符并输入以下选择语句:

mysql> SELECT * FROM orders;

ID   PRODUCT     PTYPE    QTY

---- ----------- -------- ---

1    pizza       Greek    2

如果你能看到这个输出,说明你的 Python 脚本按预期工作。

当用户的请求没有包含足够的信息时

有时,用户的请求可能没有包含足够的信息来填写订单表单中的所有字段。例如,考虑以下话语:

I want two pizzas.

表 9-1 展示了应用程序从这个句子生成的订单表单。

表 9-1: 缺失信息的订单表单

product ptype quantity
pizza 2

ptype字段的值缺失,因为用户没有指定他们想要的披萨类型。为了解决这个问题,可以按照以下方式增强之前脚本中的INSERT语句:

  query = ("""INSERT INTO orders (product, ptype, qty)

  SELECT product, ptype, qty FROM

      JSON_TABLE(

        %s,

        "$" 

        COLUMNS(

             qty    INT PATH '$.qty' ➊ERROR ON EMPTY, 

             product   VARCHAR(30) PATH "$.product" ➊ERROR ON EMPTY,

             ptype     VARCHAR(30) PATH "$.ptype" ➊ERROR ON EMPTY

        )

      ) AS jt1""");

我们为JSON_TABLE中的每一列添加了ERROR ON EMPTY选项 ➊。这个选项允许我们处理插入不包含所有应有字段的 JSON 字符串时产生的错误。

现在,当你使用“我想要两张披萨。”这个示例句子执行脚本时,你应该能看到以下输出:

Error-Code: 3665

Error-Message: Missing value for JSON_TABLE column 'ptype'

我们可以扩展脚本,以便在这种情况下,聊天机器人询问客户澄清订单,提问如下:

What type of pizza do you want?

一个答案可能是这样的:

I want Greek ones.

代表我们预期收到的答案的句子结构与原始句子的结构相似。因此,我们可以使用与分析原始句子时相同的代码来分析这个答案。当然,这种方法假设了用户的响应。一个实际的实现会以这种方法为起点,然后根据需要扩展到其他可能的响应结构。例如,用户的回答可能是一个单词,“Greek”。在这种情况下,我们只需检查它是否包含在我们的披萨类型列表中。

尝试这个

错误信息会告诉你缺失的是哪个具体字段。但你仍然需要从信息中提取出这个字段名,以便你可以要求客户澄清订单中的某个具体部分。一种方法是查看信息中介词的宾语。例如,在信息中,Error-Message: Missing value for JSON_TABLE column 'ptype',介词的宾语是ptype

总结

在这一章中,你学习了如何将原始文本切割成片段,以便将文本插入到关系型数据库中。你使用了 JSON 格式与能够处理 JSON 输入的数据库进行交互,将其提取为关系数据。你还学会了如何借助纯 SQL 在数据库中实现一些应用逻辑,使数据处理更加接近数据本身。为了实现更复杂的场景,你可能需要使用触发器和存储过程——有关详细信息,可以参阅你使用的数据库文档。

第十章:训练模型

Image

正如你在第一章中学到的,spaCy 包含了用于执行命名实体识别、词性标注、句法依赖解析和语义相似性预测的统计神经网络模型。但你并不局限于仅使用预训练好的现成模型。你也可以使用自己的训练样本训练一个模型,根据应用需求调整它的管道组件。

本章讲解如何训练 spaCy 的命名实体识别器和依赖解析器,这些是你最常需要定制的管道组件,以使你使用的模型适应特定的应用场景。原因是某些领域通常需要特定的实体集,且有时需要特定的依赖解析方式。你将学习如何使用新的例子训练现有模型,或者从零开始训练一个空模型。你还将把定制的管道组件保存到磁盘,以便在以后的脚本或模型中加载。

训练模型的管道组件

你很少需要从零开始训练一个模型来满足你应用的特定需求。相反,你可以使用现有的模型,并仅更新你需要更改的管道组件。这个过程通常包括两个步骤:准备训练样本(包含注释的句子集合,模型可以从中学习),然后将管道组件暴露给这些训练样本,正如在图 10-1 中所示。

image

图 10-1:管道组件的训练过程

为了准备训练样本,你需要将原始文本数据转化为包含句子和每个词元注释的训练样本。在训练过程中,spaCy 利用训练样本来调整模型的权重:目标是最小化模型预测的误差(称为损失)。简单来说,算法计算词元与其注释之间的关系,以确定该词元应该分配给该注释的可能性。

一个现实世界的实现可能需要数百甚至数千个训练样本,才能高效地训练模型的某个组件。在开始训练该组件之前,你需要暂时禁用模型的其他所有管道组件,以保护它们免受不必要的修改。

训练实体识别器

假设你正在为一家出租车公司开发一个聊天机器人应用。该应用必须能够正确识别所有指代城市及其周边区域的地名。为此,你可能需要用你自己的例子来更新模型的命名实体识别系统,使其识别例如“Solnce”这个词——它指的是某个城市的一个社区——作为一个地理政治实体。以下章节将描述你如何完成这个任务。

决定是否需要训练实体识别器

让我们首先看看默认英语模型中的现有命名实体识别器(通常是en_core_web_sm模型)是如何识别感兴趣的命名实体的。你可能不需要更新命名实体识别器。对于这项任务,你可以使用像这样的常见出租车预定句子:

Could you pick me up at Solnce?

要查看识别器如何在句子中分类“Solnce”,请使用以下脚本打印句子的命名实体:

import spacy

nlp = spacy.load('en')

doc = nlp(u'Could you pick me up at Solnce?')

  for ent in doc.ents:

    print(ent.text, ent.label_)

在这个示例中,“Solnce”是唯一的命名实体,因此脚本生成了以下单行输出:

Solnce LOC

请注意,这个实体的输出可能会根据你使用的模型和句子有所不同。要获取输出中LOC实体标签的描述,你可以使用spacy.explain()函数:

>>> print(spacy.explain('LOC'))

'Non-GPE locations, mountain ranges, bodies of water'

结果是命名实体识别器将“Solnce”分类为非GPE位置,这与预期不符。要将其更改为将“Solnce”分类为GPE类型的实体,你需要更新识别器,如以下章节所述。

注意

为了简单起见,我们在这个示例中使用了一个单一的命名实体。但你可以创建更多地区名称来训练识别器。

与其更新现有的识别器,你可以用一个自定义识别器来替代它。然而,在这种情况下,你需要更多的训练示例来保留那些与GPE实体无关但你可能仍然需要的功能。

创建训练示例

一旦你知道需要训练实体识别器以满足应用需求,下一步是创建一组适当的训练示例。为此,你需要一些相关文本。

最佳的数据来源可能是你之前收集的真实客户输入。选择包含你需要用于训练的命名实体的话语。通常,你会将客户输入以纯文本格式记录在文件中。例如,出租车应用的客户输入日志文件可能包含以下话语:

Could you send a taxi to Solnce? 

Is there a flat rate to the airport from Solnce? 

How long is the wait for a taxi right now?

要从这些话语中创建训练示例,你需要将它们转换为元组列表,其中每个训练示例表示一个单独的元组,如下所示:

train_exams = [

 ➊ ('Could you send a taxi to Solnce?', {

     ➋ 'entities': [(25, 32, 'GPE')]

    }),

    ('Is there a flat rate to the airport from Solnce?', {

        'entities': [(41, 48, 'GPE')]

    }),

    ('How long is the wait for a taxi right now?', {

        'entities': []

    })

]

每个元组由两个值组成:一个表示话语的字符串➊和一个包含该话语中找到的实体注释的字典。实体的注释包括其在话语中的起始和结束位置(以构成话语的字符为单位)以及要分配给实体的标签➋。

自动化示例创建过程

正如你无疑已经意识到的,手动创建训练示例集可能会耗时且容易出错,尤其是当你需要处理数百或数千条话语时。你可以通过使用以下脚本来自动化这一繁琐的任务,它可以快速从提交的文本中创建一组训练示例。

   import spacy

   nlp = spacy.load('en')

➊ doc = nlp(u'Could you send a taxi to Solnce? I need to get to Google. Could

   you send a taxi an hour later?')

➋ #f = open("test.txt","rb")

   #contents =f.read()

   #doc = nlp(contents.decode('utf8'))

➌ train_exams = []

➍ districts = ['Solnce', 'Greenwal', 'Downtown']

   for sent in doc.sents:

     entities = [] 

     for token in sent:

       if token.ent_type != 0: 

        ➎ start = token.idx - sent.start_char

           if token.text in districts:

             entity = (start, start + len(token), 'GPE')

           else:

             entity = (start, start + len(token), token.ent_type_)

           entities.append(entity)

     tpl = (sent.text, {'entities': entities})

  ➏ train_exams.append(tpl)

为了便于阅读,我们按照通常的方式处理一些话语:通过在脚本中硬编码它们 ➊。但注释掉的代码行展示了我们如何从文件中获取话语 ➋。

一旦我们获得了话语——无论是来自文件还是明确传递给文档——我们就可以开始从中生成训练示例列表。我们首先创建一个空列表 ➌。接下来,我们需要定义一个包含希望模型以不同方式识别的实体名称的列表 ➍。(在这个例子中是区的列表。)

记住,真实的客户输入可能包含识别器已经正确识别的实体(例如,Google 或 London),所以我们不应该在分类这些实体时改变识别器的行为。我们为这些实体创建训练示例,并处理用于生成训练示例的所有实体,而不仅仅是新的实体。真实实现的训练集必须包括不同类型实体的多个示例。根据应用需求,训练集可能包含数百个示例。

我们遍历提交的所有话语,在每次迭代中创建一个新的空实体列表。然后,为了填充这个列表,我们遍历话语中的标记,查找实体。对于每个找到的实体,我们确定它在话语中的起始字符索引 ➎。然后,我们通过将 len(token) 加到起始索引来计算结束索引。

此外,我们必须检查该实体是否在我们希望为其分配新标签的实体列表中。如果是,我们为其分配 GPE 标签。否则,识别器将使用实体注释中的当前标签。之后,我们可以定义一个表示训练示例的元组,然后将其追加到训练集 ➏。

脚本将正在生成的训练示例发送到 train_exams 列表,脚本执行后该列表应该如下所示:

>>> train_exams

[

  ➊ ('Could you send a taxi to Solnce?', {'entities': [(25, 31, 'GPE')]}),

  ➋ ('I need to get to Google.', {'entities': [(17, 23, 'ORG')]}),

  ➌ ('Could you send a taxi an hour later?', {'entities': []})

]

为简便起见,我们在这里使用的训练集仅包含少量训练示例。请注意,只有第一个示例包含我们需要让识别器熟悉的实体(本例中的区列表) ➊。这并不意味着第二个和第三个训练示例没有用处。第二个训练示例 ➋ 混入了另一种实体类型,防止识别器“忘记”之前学到的内容。

第三个训练示例不包含任何实体 ➌。为了改善学习结果,我们需要混合不仅是其他类型实体的示例,还包括不包含任何实体的示例。下一节“训练过程”将讨论训练过程的详细信息。

禁用其他管道组件

spaCy 文档建议在开始训练某个管道组件之前禁用所有其他管道组件,这样你只需修改想要更新的组件。以下代码禁用了除命名实体识别器外的所有管道组件。你需要将这段代码添加到前一节中介绍的脚本,或者在该脚本执行后的同一个 Python 会话中执行(我们将在下一节中添加最终的代码片段,涵盖训练过程):

other_pipes = [pipe for pipe in nlp.pipe_names if pipe != 'ner']

nlp.disable_pipes(*other_pipes)

现在你已经准备好开始训练命名实体识别器,让它学习识别训练示例中定义的新实体。

训练过程

在训练过程中,你会对训练示例进行打乱并循环遍历,调整模型,使其权重更加准确地反映令牌和注解之间的关系。欲了解更多关于神经网络模型的详细解释,包括权重的含义,请参考第一章。

为了提高准确性,你可以在训练循环中应用几种技术。例如,以下代码演示了如何批量处理训练示例。这种技术通过不同的表示方式展示训练示例,以避免训练语料中出现的泛化问题。

将以下代码添加到在“创建训练示例”一节中首次介绍的脚本中,该脚本在前一节中已被修改。

   import random

   from spacy.util import minibatch, compounding

➊ optimizer = nlp.entity.create_optimizer()

   for i in range(25):

    ➋ random.shuffle(train_exams)

       max_batch_size = 3

    ➌ batch_size = compounding(2.0, max_batch_size, 1.001)

    ➍ batches = minibatch(train_exams, size=batch_size)

       for batch in batches:

           texts, annotations = zip(*batch)

        ➎ nlp.update(texts, annotations, sgd=optimizer)

➏ ner = nlp.get_pipe('ner')

➐ ner.to_disk('/usr/to/ner')

在我们开始训练之前,我们需要创建一个优化器 ➊——一个在训练过程中用于存储模型权重更新之间中间结果的函数。我们可以使用 nlp.begin_training() 方法创建优化器。但该方法会移除现有的实体类型。在本示例中,由于我们正在更新现有模型,并且不希望它“遗忘”已有的实体类型,我们使用 nlp.entity.create_optimizer() 方法。该方法为命名实体识别器创建优化器,并且不会丢失现有的实体类型。

在训练过程中,脚本以循环的方式将示例展示给模型,并且是随机顺序的,以避免因示例的顺序而产生任何泛化 ➋。脚本还会将训练示例分批处理,spaCy 文档建议,当训练示例数量足够大时,这可能会提高训练过程的有效性。为了让批次大小在每一步中变化,我们使用compounding(``)方法,它生成一个批次大小的生成器。特别地,它生成一个无限的复合值序列:从第一个参数指定的值开始,通过将前一个值乘以作为第三个参数指定的复合率来计算下一个值,并且不超过第二个参数指定的最大值 ➌。然后,我们使用minibatch()方法批处理训练示例。这样,批次大小参数就会被设置为前一行代码中调用compounding()方法生成的迭代器 ➍。

接下来,我们对批次进行迭代,每次迭代时更新命名实体识别器模型。每个批次都需要通过调用nlp.update() ➎来更新模型,这会对批次中包含的示例中的每个实体进行预测,然后检查提供的标注,看预测是否正确。如果预测错误,训练过程会调整底层模型中的权重,以便下次正确的预测能得分更高。

最后,我们需要将更新后的命名实体识别器组件序列化到磁盘,以便以后在另一个脚本(或另一个 Python 会话)中加载它。为此,我们首先必须从管道中获取该组件 ➏,然后使用其to_disk()方法 ➐将其保存到磁盘。确保你已经在系统中创建了/usr/to目录。

评估更新后的识别器

现在你可以测试更新后的识别器。如果你正在 Python 会话中执行本章讨论的示例,关闭它,打开一个新的会话,并输入以下代码以确保模型已经做出了正确的泛化。(如果你已经从前面的代码段中构建了一个单独的脚本并运行它,你可以将以下代码作为单独的脚本运行,或者在 Python 会话中运行它。)

   import spacy

   from spacy.pipeline import EntityRecognizer

➊ nlp = spacy.load('en', disable=['ner'])

➋ ner = EntityRecognizer(nlp.vocab)

➌ ner.from_disk('/usr/to/ner')

➍ nlp.add_pipe(ner, "custom_ner")

➎ print(nlp.meta['pipeline'])

➏ doc = nlp(u'Could you pick me up at Solnce?')

   for ent in doc.ents:

     print(ent.text, ent.label_)

我们首先加载没有命名实体识别器组件的管道组件 ➊。原因是,训练一个现有模型的管道组件不会永久性地覆盖该组件的原始行为。当我们加载一个模型时,构成模型管道的组件的原始版本会默认加载;因此,要使用更新版,我们必须显式地从磁盘加载它。这使我们能够拥有多个相同管道组件的自定义版本,并在必要时加载适当的版本。

我们通过两个步骤创建这个新组件:首先从EntityRecognizer类构建一个新的管道实例 ➋,然后从磁盘加载数据到其中,指定我们序列化识别器的目录 ➌。

接下来,我们将加载的命名实体识别组件添加到当前的管道中, 可选择使用自定义名称 ➍。如果我们打印出当前可用的管道组件名称 ➎,我们应该能够看到自定义名称在 'tagger''parser' 名称之间。

剩下的唯一任务是测试加载的命名实体识别组件。确保使用与训练数据集中的句子不同的句子➏。

结果,我们应该看到如下输出:

Available pipe components: ['tagger', 'parser', 'custom_ner']

Solnce GPE

更新后的命名实体识别组件现在可以正确识别自定义实体名称。

创建一个新的依赖解析器

在接下来的部分中,你将学习如何创建一个适合特定任务的自定义依赖解析器。特别是,你将训练一个解析器,揭示句子中的语义关系,而不是句法依赖关系。语义关系指的是句子中单词和短语的意义之间的关系。

自定义句法解析以理解用户输入

为什么你需要语义关系呢?假设你的聊天机器人应用需要理解用户用普通英语表达的请求,然后将其转换为 SQL 查询,以便传递给数据库。为了实现这一点,应用会执行句法解析以提取意义,将输入分解为若干部分,并用它们来构建数据库查询。例如,假设你有以下句子需要解析:

Find a high paid job with no experience.

从这个句子生成的 SQL 查询可能如下所示:

SELECT * FROM jobs WHERE salary = 'high' AND experience = 'no'

首先,让我们看看一个常规的依赖解析器如何处理样本句子。为此,你可以使用以下脚本:

import spacy

nlp = spacy.load('en')

doc = nlp(u'Find a high paid job with no experience.')

print([(t.text, t.dep_, t.head.text) for t in doc])

脚本会输出每个标记的文本、其依赖标签和句法头。如果你使用的是en_core_web_sm模型,结果应该如下所示:

[

  ('Find', 'ROOT', 'Find'), 

  ('a', 'det', 'job'),

  ('high', 'amod', 'job'),

  ('paid', 'amod', 'job'),

  ('job', 'dobj', 'Find'),

  ('with', 'prep', 'Find'),

  ('no', 'det', 'experience'),

  ('experience', 'pobj', 'with'),

  ('.', 'punct', 'Find')

]

从图示上看,这种依赖关系解析像是图 10-2。

image

图 10-2:样本句子的依赖关系解析

这种句法解析可能无法帮助你从句子中生成所需的数据库查询。前面这一节中展示的 SQL 查询使用SELECT语句来选择满足“高薪”和“无经验”要求的工作。在这种逻辑中,“job”不仅应该与“high paid”建立联系,还应该与“no experience”建立联系,但句法解析并没有将“job”与“no experience”连接起来。

为了满足你的处理需求,你可能需要以简化生成数据库查询的方式更改标签。为此,你需要实现一个自定义解析器,显示语义关系,而不是句法依赖关系。在这种情况下,这意味着你希望在“job”和“experience”这两个词之间建立一条弧线。接下来的部分将描述如何实现这一点。

决定使用的语义关系类型

首先,你需要选择一组关系类型来用于标注。spaCy 文档包含一个自定义消息解析器的示例(spacy.io/usage/training/#intent-parser),它使用了以下语义关系:ROOTPLACEATTRIBUTEQUALITYTIMELOCATION。例如,你可以将PLACE标注为某项活动发生的地点,比如在句子“I need a hotel in Berlin.”中的“hotel”。而“Berlin”则会作为该句中的LOCATION,这样可以区分地理区域和更小的设置。

为了遵循这个示例中使用的语义,你可能会向列表中添加一个新类型:ACTIVITY,你可以用它来标注样本句子中的“job”一词。(当然,你也可以使用原来的关系类型集。毕竟,工作通常与工作场所相关联,在这种情况下你可以使用PLACE类型。)

创建训练示例

正如训练管道组件的过程通常一样,你首先需要准备训练示例。在训练解析器时,你需要知道每个标记的依赖标签以及每个关系的头词。在这个例子中,你只使用了几个训练示例,以保持简短和简单。当然,现实中的实现需要更多的示例来训练解析器组件。

TRAINING_DATA = [

    ('find a high paying job with no experience', {

        'heads': [0, 4, 4, 4, 0, 7, 7, 4],

        'deps': ['ROOT', '-', 'QUALITY', 'QUALITY', 'ACTIVITY', '-', 'QUALITY', 'ATTRIBUTE']

    }),

    ('find good workout classes near home', {

        'heads': [0, 4, 4, 4, 0, 6, 4], 

        'deps': ['ROOT', '-', 'QUALITY', 'QUALITY', 'ACTIVITY', 'QUALITY', 'ATTRIBUTE']

    })
]

注意,新的解析器中,语法相关的词语可能不总是语义上相关的。为了清楚地看到这一点,你可以执行以下测试,这会生成一个列表,列出从TRAINING_DATA列表中第一个训练示例的样本句子中找到的句法依赖的头词:

import spacy

nlp = spacy.load('en')

doc = nlp(u'find a high paying job with no experience')

heads = []

for token in doc:

    heads.append(token.head.i)

print(heads)

假设你使用的是en_core_web_sm模型,这段代码应该输出以下的标记头索引:

[0, 4, 4, 4, 0, 4, 7, 5]

当你将这个列表与TRAINING_DATA列表中为同一句子提供的头词进行对比时,你应该会注意到一些差异。例如,在训练示例中,“with”是“experience”一词的子词,而根据标准的句法规则,“with”在这句话中应该是“job”的子词。如果稍微改变一下句子,这个偏差就能得到合理解释:

find a high paying job without any experience

就语义而言,“without”可以被看作是“experience”的修饰词,因为“without”改变了“experience”的含义。修饰词反过来总是依赖于它所修饰的词。因此,考虑到语义,在这个例子中将“without”视为 without/experience 对中的子词是相当合理的。

训练解析器

以下脚本演示了如何从头开始训练一个解析器,使用空白模型。在这个示例中,创建一个全新的解析器比更新现有解析器更为合理:原因在于,尝试训练一个现有的句法依赖解析器来识别语义关系会非常困难,因为这两种关系往往是冲突的。但这并不意味着你不能将自定义解析器与现有模型一起使用。你可以将其加载到任何模型中,替换其原始的句法依赖解析器。

为了训练解析器,以下脚本使用了前面部分定义的TRAINING_DATA列表中的训练示例。确保在以下代码前添加TRAINING_DATA列表:

   import spacy

➊ nlp = spacy.blank('en')

➋ parser = nlp.create_pipe('parser')

➌ nlp.add_pipe(parser, first=True)

➍ for text, annotations in TRAINING_DATA:

  ➎ for d in annotations.get('deps', []):

    ➏ parser.add_label(d)

➐ optimizer = nlp.begin_training()

   import random

➑ for i in range(25):

       ➒ random.shuffle(TRAINING_DATA)

       for text, annotations in TRAINING_DATA:

           nlp.update([text], [annotations], sgd=optimizer)

➓ parser.to_disk('/home/oracle/to/parser')

我们从创建一个空白模型开始 ➊。然后,我们创建一个空白的解析器组件 ➋,并将其添加到模型的管道中 ➌。

在这个示例中,我们从TRAINING_DATA列表中派生出解析器需要使用的标签集合,这个列表是我们必须添加到代码中的。我们在两个循环中实现这个操作。在外部循环中,我们遍历训练示例,从每个示例中提取带有头部和依赖注释的元组 ➍。在内部循环中,我们遍历这些注释元组,从deps列表中提取每个标签 ➎并将其添加到解析器中 ➏。

现在我们可以开始训练过程。首先,我们获取一个优化器 ➐,然后实现一个简单的训练循环 ➑,将训练示例随机打乱顺序 ➒。接着,我们遍历训练示例,在每次迭代时更新解析器模型。

最后,我们将自定义解析器序列化到磁盘,以便稍后在其他脚本中加载和使用 ➓。

测试您的自定义解析器

你可以使用以下脚本将自定义解析器从磁盘加载到现有模型的管道中:

   import spacy

   from spacy.pipeline import DependencyParser

➊   nlp = spacy.load('en', disable=['parser'])

➋ parser = DependencyParser(nlp.vocab)

➌ parser.from_disk('/home/oracle/to/parser')

➍ nlp.add_pipe(parser, "custom_parser")

   print(nlp.meta['pipeline'])

   doc = nlp(u'find a high paid job with no degree')

➎ print([(w.text, w.dep_, w.head.text) for w in doc if w.dep_ != '-'])

请注意,这个脚本与之前在《评估更新后的识别器》一节中展示的加载定制命名实体识别器的脚本类似,见第 148 页。我们加载一个常规模型,禁用其中的某个组件——在这个例子中是解析器 ➊。接着,我们创建一个解析器 ➋并加载之前序列化到磁盘的数据 ➌。为了使解析器可用,我们需要将其添加到模型的管道中 ➍。然后我们可以测试它 ➎。

该脚本应生成以下输出:

['tagger', 'ner', 'custom_parser']

[

  ('find', 'ROOT', 'find'),

  ('high', 'QUALITY', 'job'),

  ('paid', 'QUALITY', 'job'),

  ('job', 'ACTIVITY', 'find'),

  ('no', 'QUALITY', 'degree'),

  ('degree', 'ATTRIBUTE', 'job')

]

原始的解析器组件已被替换为定制的解析器组件,在常规模型中使用,而其他管道组件保持不变。稍后,我们可以通过使用spacy.load('en')加载模型来重新加载原始组件。

试试这个

现在你已经有了一个经过训练的自定义解析器,用来揭示语义关系,你可以开始使用它了。继续本节中的示例,编写一个脚本,从普通英语请求中生成 SQL 语句。在该脚本中,检查每个请求的 ROOT 元素,以确定是否需要构造 SELECT 语句。然后使用 ACTIVITY 元素引用数据库表,生成的语句将在该表上执行。使用 QUALITYATTRIBUTE 元素在语句的 WHERE 子句中。

总结

你可以从 spaCy 下载一组预训练的统计模型,立即使用。但这些模型可能并不总是适合你的目的。你可能希望改善现有模型中的某个管道组件,或者创建一个新的组件,放入空白模型中,更好地满足你的应用需求。

在本章中,你学习了如何训练一个现有的命名实体识别组件,以识别默认未正确标注的额外实体。然后,你学习了如何训练一个自定义解析器组件,以预测一种与输入文本相关的树形结构,该结构显示的是语义关系而非句法依赖。

在这两种情况下,第一步(也许是最重要且最耗时的一步)是准备训练数据。完成此步骤后,你只需要几行代码,就能实现你的自定义组件的训练循环。

第十一章:部署你自己的聊天机器人

Image

在前面的章节中,你通过手动将文本分配给 doc 对象来将所有输入硬编码到 NLP 脚本中。但是当你为像接收订单这样的任务构建聊天机器人时,事情会变得更加复杂。你需要将应用程序部署到一个机器人频道,比如 Telegram,这样可以促进机器人与用户之间的通信。

本章从概述如何组织一个聊天机器人应用程序开始。你将学习如何为你的聊天机器人准备一个平台,并将其部署到 Telegram 平台。你将了解如何使用 Telegram API 处理多种类型的用户输入,并保持对话状态,以跟踪哪些问题已经被询问过。

聊天机器人实现与部署的工作原理

本节将详细介绍典型聊天机器人与用户之间如何传递信息,以及这种传输所需的结构。

一个典型的聊天机器人应用程序由多个层次组成。在你已经在本地机器上实现了处理用户输入的逻辑之后,你还需要一个消息平台应用程序,允许你创建供程序操作的账户。用户不会直接与本地机器上的机器人实现互动;相反,他们将通过消息平台与机器人聊天。除了消息平台之外,你的聊天机器人可能还需要一些额外的服务,比如数据库或其他存储。

图 11-1 中的示意图展示了典型聊天机器人应用程序如何将这些层次结合起来。

image

图 11-1:用户与集成到消息平台的机器人之间的典型交互

机器人应用程序首先以无限循环的方式向消息平台发送请求,检查用户是否已开始对话。这些请求包括在开发者创建机器人时生成的认证令牌。认证令牌(也叫访问令牌或 API 密钥)是唯一的,允许消息平台识别来自此特定机器人的请求。

当用户向机器人发送消息时,消息平台处理它,并将其转发给接收方。机器人选择一个合适的处理程序—一个针对特定类型的用户消息生成响应的程序—并将生成的回复发送给用户。

聊天机器人用来与用户互动的中介程序通常是由消息平台应用提供的机器人平台,比如 Skype、Facebook Messenger 或 Telegram。从消息平台的角度看,机器人是一个在消息平台内运行的第三方应用程序。

下一节将指导你如何将用 Python 实现的聊天机器人部署到 Telegram 的机器人平台。你将看到一些特定于 Telegram 机器人平台的实现细节,并学习如何使用其功能,使机器人开发变得更加简便。

我选择了 Telegram 机器人平台作为这个例子,因为它为 Python 开发者提供了全面的资源,包括 Python Telegram Bot 文档、指南和教程,以及 GitHub 上的示例。也就是说,Telegram 提供了构建 Python 聊天机器人的一切所需。在其他消息应用程序中,例如 Facebook Messenger,你需要使用第三方工具,如 Flask 或 Ngrok,这会使机器人的实现更加复杂,并且无法严格集中在 NLP 任务上。

使用 Telegram 作为你的机器人平台

Telegram 是一款基于云的即时通讯应用,也是全球领先的消息应用之一。除了其他功能外,它提供了一个创建机器人的平台,并提供了一个 Python 库,提供了易于使用的接口。你可以在 Android、iOS、Windows、Linux 和 macOS 平台上使用 Telegram。但它主要是为智能手机设计的。

创建 Telegram 账户并授权你的机器人

在你能够在 Telegram 中创建机器人之前,你必须注册一个 Telegram 账户。为此,你需要一部运行 iOS 或 Android 的智能手机或平板。Telegram 的 PC 版本无法完成此操作。然而,一旦你创建了 Telegram 账户,你就可以在 PC 上使用它。

你可以在 telegramguide.com/create-a-telegram-account/ 上找到创建 Telegram 账户的详细步骤。一旦你拥有了 Telegram 账户,你就可以创建一个机器人。你可以通过智能手机或 PC 来完成这个操作,具体步骤如下所示:

  1. 在 Telegram 应用中,搜索 @BotFather 或者打开链接 telegram.me/botfather/。BotFather 是一个 Telegram 机器人,用于管理你账户中的所有其他机器人。

  2. 在 BotFather 页面,点击开始按钮,你将看到可以用来设置 Telegram 机器人命令的列表。

  3. 要创建一个新的机器人,请在写信息框中输入/newbot命令。系统会提示你为你的机器人设置一个名称和用户名。然后你会得到一个新机器人的授权令牌。图 11-2 展示了这一过程在智能手机上的截图。

image

图 11-2:在智能手机上创建 Telegram 机器人的过程

现在,你可以将你在本地机器上用 Python 文件实现的机器人功能与刚刚在 Telegram 创建的机器人进行集成,具体操作将在下一节讨论。

注意

重要的是要知道,你刚刚在 Telegram 中创建的机器人并没有实现处理用户输入的逻辑。事实上,它只是你需要自己实现的实际机器人的一个封装。

开始使用 python-telegram-bot 库

若要将 Python 中实现的聊天机器人功能连接到 Telegram,你需要python-telegram-bot库,该库建立在 Telegram Bot API 之上。该库为开发 Telegram 应用的机器人程序员提供了一个易于使用的接口。它使你能够专注于编写机器人的代码,而不必关注消息传递应用与机器人实现之间的交互细节。

python-telegram-bot库是一个自由软件,采用 LGPLv3 许可证发布。你可以通过pip使用以下命令安装或升级它:

$ pip install python-telegram-bot --upgrade

注意

本章其余部分提供的示例假设你正在使用python-telegram-bot版本 12.0 或更高版本。

安装好库后,使用以下代码行进行快速测试,以验证你是否能通过 Python 访问 Telegram 机器人。此测试需要网络连接才能正常工作。

import telegram

bot = telegram.Bot(token='XXXXXX...')

'XXXXX'的位置,填写你在创建机器人时获得的令牌。然后使用这一行代码检查你的凭证:

print(bot.get_me())

如果bot.get_me()函数返回你的凭证,则你之前指定的机器人认证令牌是有效的。

使用 telegram.ext 对象

要构建一个真正的机器人,你需要使用telegram.ext对象,包括telegram.ext.Updatertelegram.ext.Dispatcher。这两个对象是库中最重要的对象,因为它们在每个实现中都是必需的。简而言之,Updater对象接收来自 Telegram 的消息,并将其传递给Dispatcher。然后,Dispatcher将数据传递给适当的处理程序进行处理。以下代码展示了如何在一个简单的回显机器人中使用这些对象,该机器人会回复每条消息,内容与原始消息相同:

   from telegram.ext import Updater, MessageHandler, Filters

   #function that implements the message handler 

➊ def echo(update, context):

     update.message.reply_text(update.message.text)

   #creating an Updater instance

➋ updater = Updater('TOKEN', use_context=True)

   #registering a handler to handle input text messages

   updater.dispatcher.add_handler(MessageHandler(Filters.text, echo))

   #starting polling updates from the messenger 

   updater.start_polling()

   updater.idle()

我们首先从telegram.ext包中导入UpdaterMessageHandler模块。然后定义echo()函数,它接收两个对象作为参数:updatecontext ➊。update对象表示一个传入的消息,可以是文本、照片、贴纸等。context对象包含一些属性,可以存储来自同一聊天和用户的数据。updatecontext这两个对象是在后台自动生成的,并传递给回调函数——这是分配给特定处理程序的消息处理函数。在这个示例中,文本消息处理程序的回调函数是echo();它包含一行代码,指示 Telegram 将用户的文本消息原封不动地返回。

接下来,我们创建一个Updater对象➋,我们将使用它来协调脚本中的机器人执行过程。当我们创建一个Updater对象时,Dispatcher对象会自动为我们创建,这样我们就可以注册不同类型的输入数据的处理程序,比如文本和照片。在这个例子中,我们注册了一个处理程序,专门处理文本消息,并将之前在脚本中实现的回调函数传给它。现在,每当聊天机器人收到包含文本的 Telegram 消息时,它都会调用这个回调函数。

然后,我们通过调用Updaterstart_polling()方法来启动机器人,该方法启动从消息应用程序中轮询新消息的过程。由于start_polling()是一个非阻塞方法,我们还必须调用Updateridle()方法,这会阻塞我们的脚本,直到接收到消息或用户输入退出快捷键(CTRL-C)。有关python-telegram-bot库中可用的类和方法的更多细节,请参阅 Python Telegram Bot 的文档。

为了测试这个脚本,可以在连接互联网的机器上运行它。运行后,任何 Telegram 用户都可以与你的聊天机器人开始对话。在 Telegram 应用中,搜索@<用户名>,输入你为机器人创建时指定的用户名,然后选择它。要开始对话,点击/start按钮或输入/start命令。然后,你可以开始向机器人发送消息。因为你实现了一个回音机器人,所以机器人回复的任何消息应该都包含你发送的相同文本。

创建一个使用 spaCy 的 Telegram 聊天机器人

在上一节中,我们使用了python-telegram-bot库,并构建了一个集成到 Telegram 中的简单脚本。现在,让我们增强我们的实现,添加 spaCy,以确保在 Telegram 中创建的机器人能够完全运行。

以下代码创建了一个简单的机器人,该机器人处理用户的语句并判断它是否包含直接宾语。根据这些信息,它会生成一个回复消息。这段代码本身并不特别有用,但它应该向你展示如何将使用 spaCy 实现的文本处理代码与使用python-telegram-bot库实现的代码连接起来。

   import spacy

   from telegram.ext import Updater, MessageHandler, Filters

   #the callback function that uses spaCy

➊ def utterance(update, context):

     msg = update.message.text

     nlp = spacy.load('en')

     doc = nlp(msg)

     for token in doc:

       if token.dep_ == 'dobj':

         update.message.reply_text('We are processing your request...') 

         return

     update.message.reply_text('Please rephrase your request. Be as specific as

     possible!')     

   #the code responsible for interactions with Telegram

   updater = Updater('TOKEN', use_context=True)

   updater.dispatcher.add_handler(MessageHandler(Filters.text, utterance))

   updater.start_polling()

   updater.idle()

请注意,负责与 Telegram 交互的代码与前一个脚本中的代码是一样的。唯一的区别在于回调函数的实现➊。在这种情况下,utterance()函数使用 spaCy 来处理用户的输入。

在那个函数中,我们首先从传递给该函数的update对象中提取消息文本。接下来,我们将其转换为一个 spaCy 的 Doc 对象,然后检查这个语句中是否包含直接宾语。如果语句中不包含直接宾语,我们会要求用户更加具体。例如,用户可能会说“我饿了”,这暗示着他们想要点一些食物。但要下订单,我们需要他们更加明确;比如“我想要一个披萨”。

这个例子最有趣的一点是,它展示了 spaCy 能够处理的输入在机器人应用中可能来自哪里。在前几章的示例中,我们使用了硬编码在脚本中的输入。这是你第一次看到现实中的聊天机器人如何获取输入。

扩展聊天机器人功能

现在,你大致知道如何将使用 spaCy 的聊天机器人集成到 Telegram 中,让我们创建一个更有趣的机器人。例如,你可以增强前一节中机器人的功能,使其从用户的消息中提取意图,而不是仅仅打印“正在处理请求”的消息。为了实现这一点,你可以重用前面章节中的某个脚本。

回到第八章的“使用预定义列表识别同义词”脚本,该脚本使用同义词列表来提取用户输入的意图。将此脚本中的代码放入一个独立的函数中,比如命名为extract_intent(),该函数应接收一个参数——作为 Doc 对象的用户消息文本(确保排除脚本开头的硬编码输入行以及脚本末尾打印意图的那行)。此外,你创建的函数必须返回一个识别出的意图,作为字符串返回。在你创建的脚本中,将新函数放在回调函数上方,并将回调函数修改为如下所示:

...

def extract_intent(doc):

  #Put the code from Chapter 8 here 

def utterance(update, context):

  msg = update.message.text

  nlp = spacy.load('en')

  doc = nlp(msg)

  for token in doc:

    if token.dep_ == 'dobj':

   ➊ intent = extract_intent(doc) 

   ➋ if intent == 'orderPizza': 

        update.message.reply_text('We need some more information to place your

        order.')

      elif intent == 'showPizza': 

        update.message.reply_text('Would you like to look at our menu?')

      else:

        update.message.reply_text('Your intent is not recognized.')

      return

  update.message.reply_text('Please rephrase your request. Be as specific as

  possible!')     

...

我们在用户输入的回调函数中调用了新创建的extract_intent()函数来获取用户的意图 ➊。然后,根据获取的意图采取适当的行动。在这个例子中,我们只是简单地向用户发送一条相关的消息 ➋。

虽然我们可以将第八章中的代码直接放入回调函数,但这样做会降低代码的可读性,因此被认为是不好的编程习惯。

保持当前聊天状态

你现在使用的机器人不仅仅是评估用户的消息;它还能识别用户的意图。不过,这还不足以从用户那里接收指令。主要的问题在于,即使机器人已经识别了意图,到了需要向用户提问的时刻,它仍会对每个用户输入使用相同的回调函数。

为了解决这个问题,你需要保持当前聊天的状态,这样机器人就知道哪些问题已经回答,哪些问题还需要询问。接着,你需要修改回调函数,使其能够根据当前聊天状态处理用户消息。

这个机器人可以这样工作:如果机器人还没有发现用户意图,它应该询问用户表达意图。找到意图后,机器人应该切换到另一个与当前聊天状态相关的问题。

为了简化这种实现,python-telegram-bot 库包含了 ConversationHandler 对象;它允许你通过将入口点和对话状态与处理程序绑定,来定义对话的入口点和状态。

一个入口点——例如,类似 /start 的 Telegram 命令——与一个处理程序绑定,可以触发聊天的开始。处理程序的回调函数必须返回对话的初始状态;这个动作决定了接下来用户消息应该使用哪个处理程序。要改变对话的状态,处理程序的回调函数在回复用户后返回一个新的状态。

以下代码片段展示了如何使用 ConversationHandler 在聊天机器人和用户之间切换对话状态:

def start(update, context):

...

  ➊ return 'ORDERING'

def intent_ext(update, context):

...

  ➋ if context.user_data.has_key('intent'):

        return 'ADD_INFO'

    else:

        update.message.reply_text('Please rephrase your request.')

        return 'ORDERING'

def add_info(update, context):

...

    return ConversationHandler.END

def cancel(update, context):

...

    return ConversationHandler.END

...

def main():

...

    disp = updater.dispatcher

    conv_handler = ConversationHandler(

        entry_points=[CommandHandler('start', start)],

        states={

            ➌ 'ORDERING': [MessageHandler(Filters.text,

                                        intent_ext)

                        ],

            'ADD_INFO': [MessageHandler(Filters.text,

                                        add_info)

                        ],

        },

        fallbacks=[CommandHandler('cancel', cancel)]

    )

    disp.add_handler(conv_handler)

...

使用 ConversationHandler 允许我们定义多个回调函数,并指定它们的调用顺序。回调函数处理用户的消息,根据处理结果,可能会改变对话流程的状态。

在这个例子中,/start 命令的回调函数将对话切换到 ORDERING 状态 ➊,这意味着接下来用户发送的消息将由 intent_ext() 函数处理。原因是 intent_ext() 函数是属于 ORDERING 状态的处理程序 ➌ 中的回调函数,正如在 ConversationHandler 对象的 states 字典中所定义的那样。

请注意,聊天机器人可以根据条件逻辑在状态之间切换,正如 intent_ext() 函数中所展示的那样:在那里,只有当识别到意图 ➋ 时,对话的状态才会变更为 ADD_INFO(即收集附加信息的状态)。

将所有部分组合在一起

现在你已经对如何构建一个遵循预定义对话流程的 Telegram 机器人有了初步了解,接下来让我们看看完整实现这种脚本的样子。这个机器人需要依次向用户询问一系列问题,以完成一个订单表单。因为这是一个简化的例子,聊天机器人只能处理一个意图,orderPizza,并且在填写订单表单时只要求用户指定披萨的种类。

以下脚本分成多个部分,每个部分代表一个函数定义:

import logging

import sys

import spacy

from telegram.ext import Updater, CommandHandler, MessageHandler, Filters, ConversationHandler

#allows you to obtain generic debug info

logger = logging.getLogger(__name__)

logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)

def extract_intent(doc):

  #Here should be the code created as suggested in the Expanding the Chatbot section earlier

  ...

  return intent

extract_intent() 函数从提交的语句中提取意图。我们将在接下来的 intent_ext() 回调函数中调用这个函数。extract_intent() 函数的代码在这里没有提供,但你可以参考前面在《扩展聊天机器人》中描述的代码,见第 161 页。

def details_to_str(user_data):

    details = list()

    for key, value in user_data.items():

        details.append('{} - {}'.format(key, value))

    return "\n".join(details).join(['\n', '\n'])

details_to_str() 函数简单地将 user_data 字典的内容转换为字符串。user_data 字典包含了聊天机器人从对话中提取的信息,比如用户想要的披萨种类和披萨数量。机器人将这些信息包含在发送给用户的最终消息中。

到目前为止,我们已经定义了将在机器人回调函数中直接或间接调用的辅助函数。现在让我们定义回调函数。

def start(update, context):

    update.message.reply_text('Hi! This is a pizza ordering app. Do you want to order something?')

    return 'ORDERING'

start()函数是/start Telegram 命令的回调函数。换句话说,聊天机器人在开始聊天时会调用这个函数。该函数返回ORDERING状态,这意味着接收到的下一个消息将由附加到ORDERING状态处理器(此例中的intent_ext()函数)的回调进行处理。

def intent_ext(update, context):

  msg = update.message.text

  nlp = spacy.load('en')

  doc = nlp(msg)

  for token in doc:

    if token.dep_ == 'dobj':

      intent = extract_intent(doc) 

      if intent == 'orderPizza': 

        context.user_data['product'] = 'pizza'

        update.message.reply_text('We need some more information to place your order. What type

        of pizza do you want?')

        return 'ADD_INFO'

      else:

        update.message.reply_text('Your intent is not recognized. Please rephrase your request.')

        return 'ORDERING'

      return

  update.message.reply_text('Please rephrase your request. Be as specific as possible!')

为了简单起见,这里使用的intent_ext()函数只能识别一个意图:orderPizza。如果它检测到此意图,它将返回ADD_INFO状态。否则,它将返回ORDERING状态,这将导致再次调用intent_ext()函数以处理下一个用户消息。ADD_INFO状态的处理函数可以这样实现:

def add_info(update, context):

  msg = update.message.text

  nlp = spacy.load('en')

  doc = nlp(msg)

  for token in doc:

    if token.dep_ == 'dobj':

      dobj = token

      for child in dobj.lefts:

        if child.dep_ == 'amod' or child.dep_ == 'compound': 

          context.user_data['type'] = child.text

          user_data = context.user_data

          update.message.reply_text("Your order has been placed."

                                    "{}"

                                    "Have a nice day!".format(details_to_str(user_data)))

          return ConversationHandler.END

    update.message.reply_text("Cannot extract necessary info. Please try again.")

    return 'ADD_INFO'

add_info()函数是ADD_INFO状态处理器的回调函数。在这个实现中,它期望用户在订购披萨时指定他们想要的披萨类型,然后将状态切换为ConversationHandler.END,即最后一个状态,如下所示:

def cancel(update, context):

    update.message.reply_text("Have a nice day!")

    return ConversationHandler.END

这里使用的cancel()函数只是向用户发送一个告别消息,并将状态切换为ConversationHandler.END

最后,main()函数应该如下所示:

def main():

    #Replace TOKEN with a real token 

    updater = Updater("TOKEN", use_context=True)

    disp = updater.dispatcher

    conv_handler = ConversationHandler(

        entry_points=[CommandHandler('start', start)],

        states={

            'ORDERING': [MessageHandler(Filters.text,

                                        intent_ext)

                        ],

            'ADD_INFO': [MessageHandler(Filters.text,

                                        add_info)

                        ],

        },

        fallbacks=[CommandHandler('cancel', cancel)]

    )

    disp.add_handler(conv_handler)

    updater.start_polling()

    updater.idle()

if __name__ == '__main__':

    main()

像往常一样,机器人脚本的main()函数协调机器人执行过程。

你可以通过使用计算机上的 Telegram 网页版应用或智能手机上的 Telegram 应用来测试脚本。图 11-3 展示了 Telegram 网页版应用运行脚本时的截图。

image

图 11-3:使用 Telegram 网页版应用测试你的机器人

动手试试

修改前面章节中的脚本,使其能够识别和处理比orderPizza更多的意图。另一个相关的意图可能是showPizza,意味着用户想查看菜单。为了实现这一点,你需要修改intent_ext()函数,在文档处理循环中添加if intent == 'showPizza'条件。同时,你还需要在ConversationHandler对象的states字典中添加一个新的状态——例如SHOW_MENU——并实现其回调函数。

总结

在本章中,你学习了如何使用 Telegram 机器人平台这个流行的消息应用程序来实现和部署一个简单的聊天机器人应用。你学会了如何在对话中定义和保持状态。特别地,你还看到了一个例子,展示了你可能会使用 spaCy 处理的用户消息究竟来自哪里。

第十二章:实现网页数据与图像处理**

Image

现实生活中的聊天机器人应该能够应对各种输入,例如用户提出的关于不熟悉话题的问题,甚至是通过消息应用发送的图片。例如,聊天机器人应用的用户不仅可以发送文本消息,还可以发送照片,机器人应能适当地回应这两者。

本章提供了一些示例,展示如何在开发机器人应用时使用 Python 人工智能生态系统中的其他库。首先,你将结合 spaCy 和 Wikipedia,查找来自用户问题的关键词的信息。接下来,你将借助 Clarifai(一个图像和视频识别工具)获取提交图像的描述标签,从而使你的应用能够解释视觉内容。

然后,你将把所有组件组合在一起,构建一个 Telegram 机器人,通过从 Wikipedia 提取信息来生成与文本和图片相关的回应。

工作原理

图 12-1 显示了我们将在本章中构建的机器人示意图。这个机器人设计用于理解文本消息和图片,并通过 Wikipedia 返回文本回应。

image

图 12-1:一个可以处理文本消息和图片的机器人的工作原理

使用这个机器人,用户可以发送文本消息或图片。如果发布的是图片,机器人会将其发送到图像识别工具进行处理。该工具会返回一组描述标签,提供图片的文字描述。如果发布的是文本消息,机器人将使用像 spaCy 这样的 NLP 工具从中提取关键词或关键短语。然后,机器人使用标签或关键短语在 Wikipedia(或互联网上的其他地方)找到最相关的内容,并将其中的一部分返回给用户。你可以在你设计的聊天机器人中使用这种场景来进行各种话题的对话,无论是娱乐、学习还是个人使用。

让你的机器人从 Wikipedia 中找到问题的答案

让我们先讨论一下你可以在机器人中实现的技术,使其能够解释广泛的文本消息。前面的章节讨论了通常用于商业目的的机器人如何向用户询问特定信息,然后使用答案来填充订单或预订请求。相比之下,设计用来进行非正式对话的机器人应该能够回答用户的各种问题。

帮助聊天机器人回答用户问题的一种方法是选择问题中的关键词或关键短语,作为提示,以确定答案中应包含的信息。一旦得到了这个关键词或关键短语,就可以使用它来搜索答案,使用像 Python 的 Wikipedia API 等工具。Wikipedia 的 API 允许你以编程方式访问和解析 Wikipedia 内容,通过搜索关键词来检索最相关的 Wikipedia 文章中的内容。以下章节将描述如何做到这一点。

但是在继续看例子之前,请确保你正在使用最新的 spaCy 模型,因为新版本的依存分析准确性更高。你可以使用以下命令检查当前模型的版本:

nlp.meta['version']

然后访问 explosion.ai/demos/displacy/ 演示页面(在第七章中讨论过)查看最新的 spaCy 模型稳定版本。或者,你也可以访问 spaCy 的文档 spacy.io/usage/ 来查看 spaCy 的最新版本。spaCy 及其模型遵循相同的版本控制方案。根据这些信息,你可能希望更新你当前使用的模型。有关如何下载和安装 spaCy 模型的详细信息,请参见第二章。

确定问题的主题

在一个问题中,某些词比其他词更为重要,特别是当你试图确定说话者想要询问的内容时。有时候,仅仅看问题中的一个词就足够了,比如跟在介词后面的名词。例如,用户可能会用以下任何问题让机器人寻找关于犀牛的信息:

Have you heard of rhinos? Are you familiar with rhinos? What could you tell me about rhinos?

让我们看看这种句子的依存分析可能是什么样的。图 12-2 展示了第一句话依存分析的图形表示。

image

图 12-2:包含介词宾语的句子的依存分析

该分析展示了在这类问题中,通过提取介词宾语,可以获得“犀牛”这个词。“犀牛”将是该问题中最有助于找到答案的词。以下代码片段展示了如何提取问题中第一次出现的介词宾语:

doc = nlp(u"Have you heard of rhinos?")

for t in doc:

  if t.dep_ == 'pobj' and (t.pos_ == 'NOUN' or t.pos_ == 'PROPN'):

    phrase = (' '.join([child.text for child in t.lefts]) + ' ' + t.text).lstrip()

    break

在代码中,我们还会提取介词宾语的左孩子节点,因为该宾语可能有重要的修饰成分,如下例所示:“你能告诉我关于野生山羊的事吗?”当给出这个问题时,代码应将“野生山羊”赋值给phrase变量。

注意在末尾使用了break语句,它确保只有句子中介词的第一个宾语会被提取出来。例如,在句子“告诉我关于美利坚合众国的事”中,“美利坚合众国”这一短语会被提取出来,但“美国”不会。

但这种行为并不总是理想的。那么,如果用户问:“告诉我关于天空颜色的事”呢?这时我们需要应用更复杂的逻辑。特别是,我们可能希望提取紧跟第一个介词宾语之后的任何介词宾语,前提是后者依赖于前者。

这是你可能实现该逻辑的方法:

doc = nlp(u"Tell me about the color of the sky.")

for t in doc:

  if t.dep_ == 'pobj' and (t.pos_ == 'NOUN' or t.pos_ == 'PROPN'):

    phrase = (' '.join([child.text for child in t.lefts]) + ' ' + t.text).lstrip()

    if bool([prep for prep in t.rights if prep.dep_ == 'prep']): 

      prep = list(t.rights)[0]

      pobj = list(prep.children)[0] 

      phrase = phrase + ' ' + prep.text + ' ' + pobj.text

    break

请注意,只有当第一个介词对象的依赖项中存在前述的介词对象时,这段代码才会处理作为第一个介词对象依赖项的介词对象。否则,这段代码将与前面展示的代码一样工作。

现在,让我们来看一下以下示例中的另一种问题类型,其中两个词——一个动词和它的主语——提供了最有信息量的内容,用来回应用户的问题:

Do you know what an elephant eats? Tell me how dolphins sleep. What is an API?

图 12-3 展示了这类句子的依存句法解析可能是什么样的。

image

图 12-3:句子的依存句法解析,其中主语/动词对是发现发言者想要了解的最有信息量的元素

通过查看图中的解析,请注意,在试图确定发言者询问内容时,句子末尾出现的主语/动词对是最有信息量的。程序上,你可以使用以下代码从句子中提取主语和动词对:

doc = nlp(u"Do you know what an elephant eats?")

for t in reversed(doc):

  if t.dep_ == 'nsubj' and (t.pos_ == 'NOUN' or t.pos_ == 'PROPN'):

    phrase = t.text + ' ' + t.head.text

    break

在检查这段代码时,请注意我们使用 Python 的reversed()函数从句子的末尾反向遍历。原因是我们需要找到句子中的最后一个主语/动词对,就像这个例子一样:“你知道大象吃什么吗?”在这个句子中,我们关注的是“elephant eats”这一短语,而不是“you know”,尽管它也是一个主语/动词对。

此外,在某些问题中,句子中的最后一个名词是一个动词的直接宾语,对于确定问题的内容至关重要,就像以下示例一样:

How to feed a cat?

在这个句子中,单独提取直接宾语“cat”是不够的,因为我们还需要“feed”这个词来理解问题。理想情况下,我们会生成关键短语“feeding a cat”。也就是说,我们会将动词的“to”不定式形式替换为动名词形式,通过添加“-ing”,优化这个关键短语以便进行网络搜索。图 12-4 展示了这个句子的依存句法解析。

image

图 12-4:包含动词/直接宾语对作为最具信息量短语的句子的依存句法解析

这段句法解析表明,提取所需的短语是容易的,因为直接宾语和其及物动词通过直接连接相互关联。

这里讨论的提取的代码实现可能看起来像这样:

doc = nlp(u"How to feed a cat?")

for t in reversed(doc):

  if t.dep_ == 'dobj' and (t.pos_ == 'NOUN' or t.pos_ == 'PROPN'):

    phrase = t.head.lemma_ + 'ing' + ' ' + t.text

    break

在这种情况下,我们再次从句子的末尾反向遍历。为了理解为什么这样做,可以考虑以下句子:“告诉我关于如何喂猫的一些事情。”它包含了两个动词/直接宾语对,但我们只关心句子末尾的那个。

试试看

修改上一部分中提取短语“elephant eats”的代码,使得从句子中提取的关键短语包括主语可能的修饰词,而不包括可能的限定词。例如,在句子“告诉我雌性猎豹是如何捕猎的”中,你的脚本应该返回“雌性猎豹捕猎”,并去掉名词短语中的“a”限定词。作为你可能如何实现这一点的示例,请参阅下面的代码图 12-2。在那段代码中,你提取了介词的宾语的修饰词。

此外,还要添加检查,以查看被提取的短语中的动词是否有直接宾语,如果有,将直接宾语附加到关键短语中。例如,问题“你知道海龟每次产多少个蛋吗?”应该返回以下关键短语:“海龟产蛋”。

使用维基百科回答用户问题

现在你已经有了一个关键短语,它可以帮助你找到生成与用户问题相关的回答所需的信息,你需要检索这些信息。机器人可以从多个地方获取用户问题的答案,选择合适的来源取决于应用场景,但维基百科是一个很好的起点。wikipedia Python 库(* pypi.org/project/wikipedia/ *)允许你在 Python 代码中访问维基百科文章。

你可以通过 pip 安装该库,方法如下:

pip install wikipedia

为了测试新安装的库,请使用以下脚本,它依赖于上一部分的代码片段,从提交的句子中提取关键字。然后,它将该关键字用作维基百科搜索词。

   import spacy

   import wikipedia

   nlp = spacy.load('en')

   doc = nlp(u"What do you know about rhinos?")

   for t in doc:

     if t.dep_ == 'pobj' and (t.pos_ == 'NOUN' or t.pos_ == 'PROPN'):

 ➊ phrase = (' '.join([child.text for child in t.lefts]) + ' ' + t.text).

       lstrip()

       break

➋ wiki_resp = wikipedia.page(phrase)

   print("Article title: ", wiki_resp.title)

   print("Article url: ", wiki_resp.url)

   print("Article summary: ", wikipedia.summary(phrase, sentences=1))

在这个脚本中,我们从提交的句子中提取一个关键字或关键短语 ➊,并将其发送到 wikipedia.page() 函数,后者返回与给定关键字最相关的文章 ➋。然后我们只需打印出文章的标题、URL 和第一句。

该脚本生成的输出应该如下所示:

Article title:  Rhinoceros

Article url:  https://en.wikipedia.org/wiki/Rhinoceros

Article summary:  A rhinoceros (, from Greek  rhinokero–s, meaning 'nose-horned', from  rhis,

meaning 'nose', and  keras, meaning 'horn'), commonly abbreviated to rhino, is one of ...

试试看

增强上一部分的脚本,使它能够“看到”第一个介词宾语及其依赖的介词宾语的子元素。例如,在问题“你听说过黄番茄炒蛋吗?”中,它应该提取关键短语“黄番茄炒蛋”。

对聊天中发送的图片作出反应

除了文本消息,消息应用的用户通常还会发布图片。其他人通常会根据图片的内容发表评论。例如,一个用户发布了一张葡萄的照片,另一个用户留下了这样的评论:“我喜欢水果,它含有丰富的纤维和维生素。”你如何教一个机器人做同样的事情呢?一种方法是为图像生成描述性标签,机器人可以利用这些标签进行处理。这时,你需要一个图像识别工具,比如 Clarifai,它提供了内置的模型,这些模型通过来自不同领域(如服装、旅游或名人)的照片进行训练。

Clarifai 允许一个机器人为提交的照片获取一组类别,使得机器人能够猜测图像中展示的内容。你可以通过两步获取照片的有用类别。首先,使用 Clarifai 的通用图像识别模型获取描述性标签(带有概率的对象),这可以大致告诉你照片展示的是什么内容。例如,“没有人”标签的出现意味着照片中没有人。

其次,在检查标签之后,你可以对同一张照片应用更具体的模型,比如 Clarifai 的食品或服装模型。这两个模型分别训练用于识别食品和时尚相关的物品。这一次,你将获得另一组更细致的标签,从而更好地了解照片的内容。欲了解 Clarifai 的所有图像识别模型,请访问其模型页面:www.clarifai.com/models/

使用 Clarifai 生成图像描述性标签

Clarifai 提供了一个 Python 客户端与其识别 API 进行交互。你可以使用pip安装最新的稳定包:

pip install clarifai --upgrade

在开始使用 Clarifai 库之前,你必须通过创建一个账户并点击GET API KEY按钮来获取 API 密钥,网址为www.clarifai.com/

一旦你获得密钥,就可以测试 Clarifai 库。以下简单脚本将一张图片传递给 Clarifai 模型,并打印出一组表达图片可能类别的标签:

   from clarifai.rest import ClarifaiApp, client, Image

   app = ClarifaiApp(api_key='YOUR_API_KEY')

➊ model = app.public_models.general_model

   filename = '/your_path/grape.jpg'

➋ image = Image(file_obj=open(filename, 'rb'))

   response = model.predict([image])

➌ concepts = response['outputs'][0]['data']['concepts']

   for concept in concepts:

     print(concept['name'], concept['value'])

在这个示例中,我们使用 Clarifai 的 Predict API 与通用模型进行调用➊。Clarifai 只接受像素作为输入,因此请确保以'rb'模式打开图像文件➋,该模式以二进制格式打开文件供读取。Predict API 会为提交的照片生成一系列描述性标签,如水果、葡萄、健康等➌,让代码能够“理解”图像内容。

本示例中使用的grape.jpg文件包含了图 12-5 中显示的照片。

image

图 12-5:前述脚本中提交给 Clarifai 的照片

脚本为照片生成的概念列表应该如下所示:

no person 0.9968359470367432

wine 0.9812138080596924

fruit 0.9805494546890259

juicy 0.9788177013397217

health 0.9755384922027588

grow 0.9669009447097778

grape 0.9660607576370239

...

每个条目代表一个类别,以及图像符合该类别的概率。因此,列表中的第一个标签告诉我们,提交的照片中没有人,概率为 0.99。请注意,并非所有标签都会直接描述所呈现的内容。例如,标签“葡萄酒”被包含在内,可能是因为葡萄酒是由葡萄制成的。标签列表中间接标签的存在为你的机器人提供了更多的选项来解读图像。

利用标签生成图像的文本回应

现在你知道如何为图像获取描述性标签,那么你如何利用这些标签来回应图像呢?或者你如何从生成的标签列表中选择最重要的标签?考虑以下一般性问题:

  • 你可能只希望考虑那些具有高概率的标签。为此,你可以为标签选择一个概率阈值。例如,只考虑前五个或十个标签。

  • 你可能只会选择与当前聊天上下文相关的标签。第十一章展示了如何使用context.user_data字典在 Telegram 机器人中保持当前聊天的上下文。

  • 你可能会遍历生成的标签,搜索某个特定的标签。例如,你可能会搜索“水果”或“健康”标签,以确定是否继续在这个话题上展开对话。

下一节中讨论的机器人将实现第三种选项。

将所有模块整合到一个 Telegram 机器人中

在本章的其余部分,我们将构建一个使用 Wikipedia API 和 Clarifai API 的 Telegram 聊天机器人。这个机器人将能够智能地响应食物的文本和图像。有关如何在 Telegram 中创建新机器人的详细信息,请参考第十一章。

导入库

代码的导入部分必须包括我们将在机器人代码中使用的所有库。在本例中,我们导入了访问 Telegram Bot API、Wikipedia API、Clarifai API 和 spaCy 所需的库。

import spacy

import wikipedia

from telegram.ext import Updater, CommandHandler, MessageHandler, Filters

from clarifai.rest import ClarifaiApp, Image

如果你按照本章和第十一章中的指示操作,所有这些库应该已经安装在你的系统中。

编写助手函数

接下来,我们需要实现助手函数,这些函数将在机器人的回调函数中被调用。keyphrase()函数接收一个句子作为 Doc 对象,并尝试从中提取出最具信息量的单词或短语,正如我们在“确定问题的主题是什么”一节中讨论的那样,在第 171 页有详细说明。以下实现使用了你在该节中看到的代码片段,并对其进行了调整,以便在一个函数中使用:

def keyphrase(doc): 

  for t in doc:

    if t.dep_ == 'pobj' and (t.pos_ == 'NOUN' or t.pos_ == 'PROPN'):

      return (' '.join([child.text for child in t.lefts]) + ' ' + t.text).

      lstrip()

  for t in reversed(doc):

    if t.dep_ == 'nsubj' and (t.pos_ == 'NOUN' or t.pos_ == 'PROPN'):

      return t.text + ' ' + t.head.text

  for t in reversed(doc):

    if t.dep_ == 'dobj' and (t.pos_ == 'NOUN' or t.pos_ == 'PROPN'):

      return t.head.text + 'ing' + ' ' + t.text 

  return False

请注意,代码中的条件是按优先级排序的。因此,如果找到了介词的宾语,我们就提取它并退出,而不再检查其他条件。当然,一些复杂的问题可能会符合多个条件,但如果检查这一点,会使函数实现变得更加复杂。

keyphrase() 函数类似,photo_tags() 函数旨在为用户的输入确定最具描述性的词。但与 keyphrase() 不同,它分析的是一张照片。它通过 Clarifai 来帮助进行分析,Clarifai 为提交的照片生成一组描述性标签。此实现只使用了两个 Clarifai 模型:通用模型和食品模型。

def photo_tags(filename):

  app = ClarifaiApp(api_key=CLARIFAI_API_KEY)

  model = app.public_models.general_model

  image = Image(file_obj=open(filename, 'rb'))

  response = model.predict([image])

  concepts = response['outputs'][0]['data']['concepts']

  for concept in concepts:

    if concept['name'] == 'food':

      food_model = app.public_models.food_model

      result = food_model.predict([image])

      first_concept = result['outputs'][0]['data']['concepts'][0]['name']

      return first_concept

  return response['outputs'][0]['data']['concepts'][1]['name']

这段代码首先应用通用模型。如果在生成的标签列表中找到 'food' 标签,它将应用食品模型来为图像中显示的食物项目获取更多描述性标签。此实现将只使用第一个标签作为搜索的关键字。

现在,我们已经得到了关键字或关键短语,无论是通过 keyphrase() 函数还是通过 photo_tags() 函数,我们需要获取与该关键字或关键短语紧密相关的信息。以下 wiki() 函数完成了这项工作:

def wiki(concept):

  nlp = spacy.load('en')

  wiki_resp = wikipedia.page(concept)

  doc = nlp(wiki_resp.content)

  if len(concept.split()) == 1:

    for sent in doc.sents:

      for t in sent:

        if t.text == concept and t.dep_ == 'dobj':

          return sent.text

  return list(doc.sents)[0].text

我们在这里使用的算法会在检索到的内容中搜索包含关键字作为直接宾语的句子。

但是,这个简单的实现只能智能处理单个词输入。当提交一个词时,我们在这里使用的算法只会从维基百科文章中提取与该词相关的第一个句子。

编写回调和 main() 函数

接下来,我们添加机器人的回调函数。start() 函数简单地在收到 /start 命令时向用户发送问候。

def start(update, context):

    update.message.reply_text('Hi! This is a conversational bot. Ask me something.')

text_msg() 函数是处理机器人用户文本消息的回调函数。

def text_msg(update, context):

  msg = update.message.text

  nlp = spacy.load('en')

  doc = nlp(msg)

  concept = keyphrase(doc)

  if concept != False:

    update.message.reply_text(wiki(concept))

  else: 

    update.message.reply_text('Please rephrase your question.')

首先,我们将 spaCy 的管道应用于消息,将其转换为 Doc 对象。然后,我们将 Doc 发送到之前讨论过的 keyphrase() 函数,从消息中提取关键字或关键短语。返回的关键字或关键短语随后会传递给 wiki() 函数,以获取相关的信息,这在本实现中应该是一个单独的句子。

以下代码中显示的 photo() 函数是处理用户提交照片的机器人回调函数:

def photo(update, context):

  photo_file = update.message.photo[-1].get_file()

  filename = '{}.jpg'.format(photo_file.file_id)

  photo_file.download(filename) 

  concept = photo_tags(filename)

  update.message.reply_text(wiki(concept))

该函数将提交的图像作为文件进行检索,并将其发送给前面讨论过的帮助函数进行进一步处理,相关内容可以参考“编写帮助函数”在第 178 页的内容。

最后,我们添加了 main() 函数,在其中为文本消息和照片注册处理程序。

def main():

    updater = Updater("YOUR_TOKEN", use_context=True)

    disp = updater.dispatcher

    disp.add_handler(CommandHandler("start", start))

    disp.add_handler(MessageHandler(Filters.text, text_msg))

    disp.add_handler(MessageHandler(Filters.photo, photo))

    updater.start_polling()

    updater.idle()

if __name__ == '__main__':

    main()

这个 Telegram 机器人的main()函数非常简洁。我们创建了Updater并将机器人的令牌传递给它。接着我们获取调度器以注册处理程序。在这个示例中,我们注册了三个处理程序。第一个是/start命令的处理程序。第二个处理来自用户的文本消息。第三个处理用户发布的照片。注册处理程序后,我们通过调用updater.start_polling()启动机器人,然后调用updater.idle()来阻塞脚本,等待用户消息或退出快捷键(CTRL-C)。

测试机器人

现在我们已经创建了机器人,是时候测试它了。你可以在智能手机或电脑上测试它。在智能手机上,在 Telegram 应用中搜索你的机器人名称,后跟@符号,然后输入/start命令开始聊天。在电脑上,使用 Telegram Web 访问* web.telegram.org*。

在收到机器人的问候后,发送一个简单的请求,例如“告诉我关于水果的事。”机器人应该会回应一个从相关的维基百科文章中提取的单句。为了简化起见,选择一个使用直接宾语(此示例中的“水果”)作为关键词的句子。

你也可以提交一张照片,检查机器人会给出什么样的评论。图 12-6 展示了这样的测试截图。

image

图 12-6:我们创建的机器人的截图

请记住,此实现只能正确处理食物照片。

试试这个

请注意,前一部分提供的机器人实现无法生成对许多不同类型用户输入的智能响应。我们使用的wiki()函数只能正确处理那些keyphrase()返回单一单词的请求。如果该关键词是一个直接宾语,它的效果会更好。此外,机器人只能对食物图片做出智能回应。

增强wiki()函数,使其能够处理短语,而不仅仅是一个单词,例如“海豚睡觉”。对于这样的短语,找到合适的句子需要使用依存标签,因为你需要找到一个主语/动词对。此外,你还需要将词语还原为它们的基本词形。例如,“海豚睡觉”和“海豚睡着”应该符合搜索条件。

你可能还想增强photo_tags()函数的功能,使其不仅可以处理食物照片,还可以处理显示其他物品的照片——例如衣物。

总结

在本章中,你看到了如何将 spaCy 与 Python 的其他库结合使用,构建一个能够处理不同类型数据的 AI 应用程序。通过使用 Wikipedia 和 Clarifai 的 Python API,我们设计了一个可以对图片做出反应并从维基百科中提取文本的聊天机器人,这些技术使得机器人成为一个更智能的对话者。

阅读完这本书后,你可能想要扩展和提升你所学的知识。最自然的方式是继续通过与聊天机器人进行实验来增强你的知识。从使用第十一章中提供的说明,使用 Python 构建一个 Telegram 脚本开始;接下来,按照本章提供的说明增强其功能。然后,继续改进你在本书中学到的算法,使它们更加适合你的应用场景。

第十三章:语言学基础

Image

本书的大多数章节专注于分析句子结构,以使用 spaCy 识别单词序列中的模式。为了理解句子分析和模式,您需要一些基本的语言学知识。本附录包含了一个语言学基础部分,供您参考。

依存语法与短语结构语法的对比

默认情况下,spaCy 使用的是依存语法,而不是语言学中更常用的短语结构语法。本节解释了这两种语法类型之间的区别。如果您有正式的语言学背景,可能会发现这些信息很有帮助。

也被称为基于成分的语法,短语结构语法根据单词如何组合成句子中的成分来建模自然语言。在句法中,成分是指在句子中作为一个单一单位发挥作用的单词组合。短语结构规则将句子分解为其成分部分,形成一棵树结构,从单个单词开始,逐渐构建更大的成分。

相对而言,依存语法是一种基于单词的语法,关注的是单词之间的关系,而不是成分之间的关系。因此,依存解析(例如本书中展示的解析)形成一棵树,反映了句子中单词与单词之间的关系。

图 A-1 展示了使用这两种语法类型解析句子的示例。

image

图 A-1:基于成分的短语结构语法(左)和基于单词的依存语法(右)的树结构示例

短语结构树根据句子由名词短语和动词短语构成这一事实,将句子分解。那些短语出现在层级结构的第二层,直接位于句子(S)标记下方——这是正式的顶层。在最底层是构成这些短语的单个单词。

相对而言,依存结构将动词作为句子的结构中心。其他单词通过定向链接(称为依存关系)与动词直接或间接连接。spaCy 默认使用的依存语法将句子的语法结构表示为单词之间的一一对应关系。

每种关系表示一种语法功能,其中一个单词是子项(或依赖词),另一个是主项(或支配词)。例如,在“blue sky”这一对词组中,“sky”是主导词,而“blue”——它的修饰词——是从属词。你可以把主项看作是最具相对“重要性”的单词,缺少它,子项就没有意义。相对地,关系的主项通常可以单独出现在句子中,而不需要子项(例如,在图 A-1 中展示的句子里,你不需要“非洲”或“野生”,也不需要“快速”)。

图 A-2 展示了该概念的图形表示。

image

图 A-2:基于主词/子节点概念的依存关系树结构示例

请注意,在图 A-2 中显示的依存关系树与图 A-1 中右侧的表示相似。这两种表示之间的唯一区别是视觉上的:虽然图 A-1 中的树形图有一个金字塔形的视图,但图 A-2 中的树形图使用标注的有向弧线来强调主词/子节点链接。

句子中的每个单词必须连接到恰好一个主词。但同一个单词可能没有子节点、一个子节点或多个子节点。spaCy 语法假设句子的主词(ROOT 词性)是它自己的主词。在这个例子中,动词“run”是句子的主词,因此表示该词的 Token 对象的 head 属性将指向同一个 Token 对象。

请注意,主词/子节点关系与句子中的线性顺序无关。例如,子节点“wild”在其主词“animals”之前,但子节点“quickly”在其主词“run”之后。

常见语法概念

本节讨论了本书中使用的更高级的语法概念,包括及物动词和直接宾语、介词宾语、情态助动词和人称代词。

及物动词和直接宾语

直接宾语是一个名词(或名词短语),表示一个动词直接作用的对象。及物动词接受直接宾语。在大多数情况下,为了识别意图,及物动词和其直接宾语是句子中最重要的提取词。这是因为这些词通常能最准确地描述动作和被作用的事物。例如,在句子“I want a pizza”中,“want”和“pizza”表达了句子的意图。

介词宾语

介词用于将名词短语与句子中的其他词连接。介词如“in”,“above”,“under”,“after”和“before”表示空间或时间关系。其他介词如“to”,“of”和“for”则表示语义角色。例如,在句子“你会在书下找到信封”中,介词“under”表示信封与书之间的空间关系。在句子“我会将它部署到一个频道”中,介词“to”表示介词短语“一个频道”所表达的目标角色。

介词的宾语(在理论语言学中称为补语)是跟随在介词后面的名词、代词或名词短语。在句子“I wrote a series of articles”中,词“articles”就是介词的宾语。

在某些问题中,提取介词的宾语可能会给你提供最有用的单词或短语,以帮助找到答案,正如在问题“什么可以解决气候变化?”中所示,短语“气候变化”是确定问题主题的关键短语。

spaCy 依赖分析器将介词标记为 'prep',介词的宾语标记为 'pobj'

情态助动词

情态助动词包括“may”、“might”、“can”、“could”、“must”、“ought”、“shall”、“should”、“will”和“would”等。它们与动词原形一起使用,表示情态——即可能性、许可、能力、必要性、意愿或建议。

spaCy 词性标注器能够识别情态助动词,并通过精细的词性标签 'MD' 标记它们。句法依赖分析器将其标记为 'aux'。例如,在需要从问题中重构句子时,你可能需要检查句子是否使用了情态助动词。

人称代词

人称代词指代特定的人、物体或多个人或物体。在英语中,人称代词有多种形式,根据它们在句子中的语法角色进行区分:

  • 主格形式(I, you, he, she, it, we, they)通常用作动词的主语。

  • 宾格形式(me, you, him, her, it, us, them)通常用作动词或介词的宾语。

  • 反身形式(myself, yourself/yourselves, himself, herself, itself, ourselves, themselves)通常指代同一从句中指定的主语。

spaCy 分析器根据人称代词的形式分配不同的依赖标签。因此,主格形式的人称代词通常会分配 'nsubj' 依赖标签,表示“名词性主语”。有趣的是,在许多用户生成的聊天机器人句子中,句子的主语是“I”。

在宾格形式中,人称代词可以被分配为 'dobj''iobj',分别代表直接宾语和间接宾语。反身代词通常也作为直接宾语或间接宾语出现。

posted @ 2025-11-27 09:16  绝不原创的飞龙  阅读(13)  评论(0)    收藏  举报