Python-文本分析蓝图-全-

Python 文本分析蓝图(全)

原文:zh.annas-archive.org/md5/c63f0fe6d74b904d41494495addce0ab

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

书面文字是一种强大的东西。古苏美尔人发明了第一种书面语言,古腾堡印刷术的引入使书面文字传播知识和启蒙思想到全世界。语言对人类思维如此重要,以至于人类学家声称我们复杂推理能力的发展与语言同时发展。以文本形式呈现的语言捕捉了大多数人类思想、行为和行动,我们的生活日益被其主导。我们通过电子邮件与同事交流,通过通讯工具与朋友和家人联系,通过社交媒体工具与分享我们热情的其他人交流。领导人通过演讲(和推文)激励着大批人群,这些演讲被记录为文本,领先的研究人员通过发表的研究论文传达其发现,公司通过季度报告传达其健康状况。即使这本书也使用文本传播知识。分析和理解文本赋予我们获取知识和做出决策的能力。文本分析是关于编写能够分析大量以文本形式存在的信息的计算机程序。在购买产品或访问餐馆之前,我们会阅读客户评论。然后公司可以利用同样的评论来改进其产品或服务。出版商可以分析互联网上的讨论,以估算在委托书籍之前对某种编程语言的需求。

对于计算机来说,理解文本相比其他类型的数据要困难得多。虽然有语法规则和句子构成的指导,但这些规则通常不严格遵循,而且严重依赖上下文。即使语法正确,机器也很难正确解释文本。一个人在发布推文时选择的词语可能与写邮件表达相同的思想时大不相同。近年来,统计技术和机器学习算法取得了重大进展,使我们能够克服许多这些障碍,从文本数据中获取价值。新模型能够比仅基于词频的先前方法更好地捕捉文本的语义意义。但也有许多业务任务,这些简单模型表现出令人惊讶的良好性能。

例如,在我们的一个客户项目中,一家家电制造商通过分析产品评论,能够理解影响客户购买的关键主题,并调整其营销信息以便专注于这些方面。在另一个案例中,一家电子商务零售商使用深度神经网络来分类客户查询,并将其路由到正确的部门以实现更快的解决方案。分析科学期刊摘要使一家研发公司能够检测新材料的趋势,并相应调整其研究。一家时尚公司通过查看社交网络中的帖子,确定了其客户群体中的超级主题。通过本书,我们试图将我们在这些以及许多其他项目中的经验转化为您可以轻松在自己项目中重复使用的蓝图。

书籍的方法

本书旨在支持数据科学家和开发人员,使其能够快速进入文本分析和自然语言处理领域。因此,我们重点放在开发实用解决方案上,这些解决方案可以作为您日常业务中的蓝图。在我们的定义中,蓝图是常见问题的最佳实践解决方案。这是一个模板,您可以轻松复制并适应以供重复使用。对于这些蓝图,我们使用了生产就绪的 Python 框架进行数据分析、自然语言处理和机器学习。尽管如此,我们也介绍了底层的模型和算法。

我们不要求您在自然语言处理领域有任何先前知识,但会为您提供快速入门所需的背景知识。在每章中,我们解释并讨论了不同的解决方案方法及其潜在优缺点。因此,您不仅会获得解决特定问题的知识,还会得到一组可立即使用并根据自己数据和需求进行定制的蓝图。

每个包含在 13 章中的用例都涵盖了文本分析特定方面的自包含应用(见表 P-1)。基于示例数据集,我们逐步开发和解释这些蓝图。

Table P-1. 章节概述

章节 数据集
第一章,从文本数据中获取早期洞见开始统计探索文本数据 联合国大会辩论 Pandas, Regex
第二章,使用 API 提取文本洞见使用不同的 Python 模块从流行的 API 提取数据 GitHub、Twitter 和 Wikipedia API Requests, Tweepy
第三章,网页抓取和数据提取使用 Python 库下载网页并提取内容 Reuters 网站 Requests, Beautiful Soup, Readability-lxml, Scrapy
第四章,为统计和机器学习准备文本数据数据清洗和语言处理简介 Reddit 自发布帖子 Regex, spaCy
第五章,特征工程和句法相似性特征和向量化简介 ABC 新闻的 100 万条头条新闻 scikit-learn, NumPy
第六章,文本分类算法文本分类算法使用机器学习算法对软件 Bug 进行分类 Java 开发工具的 Bug 报告 scikit-learn
第七章,如何解释文本分类器解释模型和分类结果 Java 开发工具的 Bug 报告 scikit-learn, Lime, Anchor, ELI5
第八章,无监督方法:主题建模和聚类使用无监督方法获取文本的无偏见洞见 联合国大会辩论 scikit-learn, Gensim
第九章,文本摘要使用基于规则和机器学习方法创建新闻文章和论坛帖子的简短摘要 路透社新闻文章、旅行论坛帖子 Sumy, scikit-learn
第十章,使用词嵌入探索语义关系使用词嵌入探索和可视化特定数据集中的语义相似性 Reddit 自发布帖子 Gensim
第十一章,对文本数据进行情感分析在亚马逊产品评论中识别客户情感 亚马逊产品评论 Transformers, scikit-learn, NLTK
第十二章,构建知识图谱使用预训练模型和自定义规则提取命名实体及其关系 路透社有关并购的新闻 spaCy
第十三章,在生产环境中使用文本分析将情感分析蓝图部署为 Google Cloud 平台上的 API 并进行扩展 FastAPI, Docker, conda, Kubernetes, gcloud

选题反映了日常文本分析工作中最常见的问题类型。典型任务包括数据获取、统计数据探索以及监督和无监督机器学习的使用。业务问题涵盖内容分析(“人们在谈论什么?”)到自动文本分类。

先决条件

本书将教会您如何在 Python 生态系统中高效解决文本分析问题。我们将详细解释文本分析和机器学习的所有概念,但假设您已经掌握了 Python 的基本知识,包括像 Pandas 这样的基础库。您还应该熟悉 Jupyter 笔记本,以便在阅读本书时进行代码实验。如果还不熟悉,请参考learnpython.orgdocs.python.orgDataCamp上的教程。

即使我们解释了所使用算法的一般思想,我们不会深入细节。您应该能够按照示例进行操作并重复使用代码,而无需完全理解其背后的数学原理。尽管如此,具备大学水平的线性代数和统计知识会有所帮助。

一些重要的库

每个数据分析项目都始于数据探索和数据处理。最受欢迎的 Python 库之一是Pandas。它提供丰富的功能来访问、转换、分析和可视化数据。如果您以前没有使用过这个框架,我们建议先查看官方介绍,10 minutes to Pandas,或者其他免费的在线教程。

多年来,scikit-learn一直是 Python 的机器学习工具包。它实现了大量的监督和无监督机器学习算法,以及许多用于数据预处理的函数。我们在几章中使用 scikit-learn 来将文本转换为数值向量,并进行文本分类。

然而,当涉及到深度神经模型时,像 PyTorch 或 TensorFlow 这样的框架明显优于 scikit-learn。我们在第十一章中用来进行情感分析的是来自 Hugging Face 的Transformers library。自 BERT 发布以来,基于 transformer 的模型在需要理解文本含义的任务上表现优异,而 Transformers 库提供了方便访问多个预训练模型的途径。

我们最喜欢的自然语言处理库是spaCy。自 2016 年首次发布以来,spaCy 拥有不断增长的用户群。尽管是开源的,但它主要由Explosion公司开发。对于许多语言,可以使用预训练的神经语言模型进行词性标注、依赖解析和命名实体识别。我们在书的编写过程中使用了 spaCy 2.3.2,特别是用于数据准备(第四章)和知识提取(第十二章)。在出版时,spaCy 3.0 将推出全新的基于 Transformer 的模型,支持 PyTorch 和 TensorFlow 的自定义模型以及定义端到端工作流程的模板。

我们使用的另一个 NLP 库是Gensim,由 Radim Řehůřek 维护。Gensim 侧重于语义分析,并提供了学习主题模型(第八章)和词嵌入(第十章)所需的一切。

这本书只简要提及了一些自然语言处理的其他库,这些库可能对您有所帮助。这些包括 NLTK(Python NLP 库的功能丰富的前辈)、TextBlob(易于上手)、Stanford 的 Stanza 和 CoreNLP,以及 Flair(用于高级任务的最新模型)。我们的目标不是对所有现有的内容进行概述,而是选择和解释在我们项目中表现最佳的那些库。

可与本书并读的书籍

由于我们专注于实际解决方案,您可能希望查阅一些额外的书籍以获取更多详细信息或我们未涵盖的主题。以下是一些建议,可与本书并读:

本书使用的约定

本书使用以下排版约定:

斜体

指示新术语、URL、电子邮件地址、文件名和文件扩展名。

常量宽度

用于程序清单,以及在段落内引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

常量宽度粗体

显示用户应直接输入的命令或其他文本。

常量宽度斜体

显示应由用户提供的值或由上下文确定的值替换的文本。

提示

此元素表示提示或建议。

注意

此元素表示一般注释。

警告

此元素指示警告或注意。

注意

此元素指示蓝图。

使用代码示例

蓝图的整个目的是被复制。因此,我们在我们的GitHub 存储库中提供了本书中开发的所有代码。

每章您将找到一个可执行的 Jupyter 笔记本,其中包含书中的代码以及可能省略的一些附加函数或蓝图。该存储库还包含必要的数据集和一些额外信息。

运行笔记本的最简单方法是在Google Colab,Google 的公共云平台上。您甚至不需要在本地计算机上安装 Python;只需单击 GitHub 上相应章节的 Colab 链接(需要 Google 帐号)。但是,我们还添加了在 GitHub 存储库中设置自己(虚拟)Python 环境的说明。我们设计了 Jupyter 笔记本,使您可以在本地和 Google Colab 上运行它们。

库、数据和网站可能会不断变化。因此,书中的逐字代码可能在将来无法正确运行。为了解决这个问题,我们将保持存储库的更新。如果您发现任何技术问题或有改进代码的建议,请毫不犹豫地在存储库中创建问题或发送拉取请求。

如果您有技术问题或在使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com。对于技术问题,我们建议在 GitHub 存储库中创建问题,并参考 O'Reilly 的勘误页面了解书中的错误。

本书旨在帮助您完成工作。通常,如果本书提供示例代码,您可以在自己的程序和文档中使用它。除非您复制了代码的大部分,否则无需征得我们的许可。例如,编写使用本书多个代码片段的程序无需许可。销售或分发奥莱利书籍的示例需要许可。引用本书并引用示例代码来回答问题无需许可。将本书的大量示例代码整合到产品文档中需要许可。

您可以在自己的项目中自由使用我们的代码,无需征得许可。特别是如果您公开重新发布我们的代码,我们感谢您的署名。署名通常包括标题、作者、出版商和 ISBN。例如:“Python 文本分析的蓝图,作者 Jens Albrecht、Sidharth Ramachandran 和 Christian Winkler(O’Reilly,2021),ISBN 978-1-492-07408-3。”

如果您认为您使用的示例代码超出了公平使用范围或上述许可,请随时通过permissions@oreilly.com与我们联系。

奥莱利在线学习

注意

40 多年来,奥莱利媒体为企业提供技术和商业培训、知识和洞察力,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专业知识。奥莱利的在线学习平台为您提供按需访问的实时培训课程、深度学习路径、交互式编码环境以及来自奥莱利和其他 200 多家出版商的大量文本和视频。欲了解更多信息,请访问http://oreilly.com

如何联系我们

请将有关本书的评论和问题发送给出版商:

  • 奥莱利媒体公司

  • 1005 Gravenstein Highway North

  • 加利福尼亚州塞巴斯托波尔 95472

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

我们为本书制作了一个网页,列出勘误、示例和任何额外信息。您可以访问https://oreil.ly/text-analytics-with-python获取此页面。

发送电子邮件至bookquestions@oreilly.com以评论或询问有关本书的技术问题。

获取关于我们的书籍和课程的新闻和信息,请访问http://oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

在 Twitter 上关注我们:http://twitter.com/oreillymedia

观看我们在 YouTube 上的视频:http://youtube.com/oreillymedia

致谢

写一本书对于作者来说是一种挑战,对于他们的家人和朋友们也是如此。我们所有人都预料到这需要很多时间,但我们仍然对为每个章节开发故事所需的时间感到惊讶。由于我们都全职工作,因此讨论、编码、写作和重写的时间不得不从我们的家庭中抽取。

与 O’Reilly 合作对我们来说是一种极大的愉悦。从最初的提议到写作期间,再到生产阶段,我们都享受与专业人士合作,并且从他们的提示和建议中受益匪浅。对我们来说最紧张的时期是撰写各章节的时候。在那段时间里,我们得到了我们的开发编辑 Amelia Blevins 的完美支持。如果没有她的帮助和改进,这本书可能会一直停留在不易阅读的状态。

我们还要感谢我们的审阅人员 Oliver Zeigermann、Benjamin Bock、Alexander Schneider 和 Darren Cook。他们利用他们的专业知识和大量时间提出了卓越的建议和改进,并且找出了文本和笔记本中的错误。

当我们使用库的最新功能时,有时会遇到问题或不兼容性。作为我们分析流水线中的核心组件,与 Explosion 团队(Ines Montani、Sofie Van Landeghem 和 Adriane Boyd)的合作非常愉快。他们对涵盖 spaCy 的章节的评论非常有帮助。同样感谢 textacy 的开发者 Burton DeWilde 检查代码的部分。

^(1) Devlin, Jacob, et al., “BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding.” 2018. https://arxiv.org/abs/1810.04805.

第一章:从文本数据中获得早期见解

在每个数据分析和机器学习项目中的第一个任务是熟悉数据。事实上,对数据有基本了解始终是获得稳健结果的关键。描述性统计提供可靠且稳健的见解,并有助于评估数据质量和分布。

在考虑文本时,词语和短语的频率分析是数据探索的主要方法之一。虽然绝对词频通常不太有趣,但相对或加权频率却是如此。例如,当分析政治文本时,最常见的词可能包含许多明显和不足为奇的术语,如人民国家政府等。但是,如果比较不同政治党派甚至同一党派政客文本中的相对词频,你可以从中学到很多不同之处。

你将学到什么,我们将构建什么

本章介绍了文本统计分析的蓝图。它可以让你快速入门,并介绍了后续章节中需要了解的基本概念。我们将从分析分类元数据开始,然后专注于词频分析和可视化。

学习完本章后,你将具备关于文本处理和分析的基础知识。你将知道如何对文本进行标记化、过滤停用词,并使用频率图和词云分析文本内容。我们还将介绍 TF-IDF 加权作为一个重要概念,该概念将在本书后面用于文本向量化时再次提到。

本章的蓝图侧重于快速结果,并遵循“保持简单,傻瓜!”的原则。因此,我们主要使用 Pandas 作为数据分析的首选库,结合正则表达式和 Python 核心功能。第四章将讨论用于数据准备的高级语言学方法。

探索性数据分析

探索性数据分析是系统地检查聚合级别数据的过程。典型方法包括数值特征的摘要统计以及分类特征的频率计数。直方图和箱线图将说明值的分布,时间序列图将展示其演变。

自然语言处理中,包含新闻、推文、电子邮件或服务呼叫等文本文档的数据集被称为语料库。对这样一个语料库的统计探索具有不同的方面。一些分析侧重于元数据属性,而其他分析则处理文本内容。图 1-1 展示了文本语料库的典型属性,其中一些包含在数据源中,而另一些可以计算或推导得出。文档元数据包括多个描述性属性,这些属性对聚合和筛选非常有用。类似时间的属性对理解语料库的演变至关重要。如果有的话,与作者相关的属性允许您分析作者群体,并将这些群体相互比较。

图 1-1. 文本数据探索的统计特征。

内容的统计分析基于词语和短语的频率。通过第四章中描述的语言数据预处理方法,我们将扩展分析的范围到特定的词类和命名实体。此外,文档的描述性分数可以包含在数据集中或通过某种特征建模推导得出。例如,回复用户帖子的数量可以作为受欢迎程度的一种衡量标准。最后,通过本书后面描述的某种方法,可以确定有趣的软事实,如情感或情绪分数。

需要注意的是,在处理文本时,绝对数字通常并不是非常有趣的。仅仅因为单词问题出现了一百次,并不包含任何相关信息。但是,问题的相对频率在一周内翻了一番可能是引人注目的。

引入数据集

分析政治文本,无论是新闻还是政党纲领或议会辩论,都可以为国家和国际议题提供有趣的见解。通常,多年来的文本是公开可用的,因此可以获取对时代精神的洞察。让我们来看看作为政治分析师的角色,他想要了解这样一个数据集的分析潜力。

为此,我们将使用联合国大会辩论数据集。该语料库由哈佛大学的米哈伊洛夫、巴图罗和达桑迪于 2017 年创建,“用于理解和衡量世界政治中的国家偏好”。联合国几乎所有的 200 个国家在年度大会上都有机会就全球议题如国际冲突、恐怖主义或气候变化发表意见。

Kaggle 上的原始数据集以两个 CSV 文件的形式提供,一个大文件包含演讲内容,一个小文件包含演讲者信息。为简化事务,我们准备了一个单独的压缩 CSV 文件包含所有信息。您可以在我们的 GitHub 代码库 中找到准备代码及其结果文件。

在 Pandas 中,可以使用 pd.read_csv() 加载 CSV 文件。让我们加载文件并显示DataFrame的两条随机记录:

file = "un-general-debates-blueprint.csv"
df = pd.read_csv(file)
df.sample(2)

输出:

会话 年份 国家 国家名称 演讲者 职位 文本
3871 51 1996 PER 秘鲁 弗朗西斯科·图德拉·范·布鲁赫尔·道格拉斯 外交部长 在此,我首先要向您和本届大会转达秘鲁人民的问候和祝贺……
4697 56 2001 GBR 英国 杰克·斯特劳 外交部长 请允许我热情地祝贺您,先生,您担任第五十六届大会主席一职。\n 这...

第一列包含记录的索引。会话号和年份的组合可以视为表的逻辑主键。country 列包含标准化的三位字母国家 ISO 代码,接着是关于演讲者及其职位的两列。最后一列包含演讲文本。

我们的数据集很小,仅包含几千条记录。这是一个很好的数据集,因为我们不会遇到性能问题。如果您的数据集较大,请参考“处理大型数据集” 了解更多选项。

蓝图:使用 Pandas 获取数据概览

在我们的第一个蓝图中,我们仅使用元数据和记录计数来探索数据分布和质量;我们还没有查看文本内容。我们将按以下步骤进行操作:

  1. 计算汇总统计信息。

  2. 检查缺失值。

  3. 绘制有趣属性的分布图。

  4. 比较不同类别之间的分布。

  5. 可视化时间发展。

在分析数据之前,我们至少需要了解一些关于DataFrame结构的信息。表 1-1 显示了一些重要的描述性属性或函数。

表 1-1. Pandas 数据框信息获取命令

df.columns 列名列表
df.dtypes 元组(列名,数据类型) 在 Pandas 1.0 版本之前,字符串被表示为对象。
df.info() 数据类型及内存消耗 使用 memory_usage='deep' 可以获得文本的良好内存消耗估算。
df.describe() 汇总统计信息 对于分类数据,请使用 include='O' 参数。

计算列的汇总统计信息

Pandas 的describe函数为DataFrame的列计算统计摘要。它可以在单个系列上工作,也可以在整个DataFrame上工作。在后一种情况下,默认输出限于数值列。当前,我们的DataFrame只包含会话号和年份作为数值数据。让我们添加一个新的数值列到DataFrame中,该列包含文本长度,以获取关于演讲长度分布的额外信息。我们建议使用describe().T来转置结果,以在表示中交换行和列:

df['length'] = df['text'].str.len()

df.describe().T

Out:

计数 平均值 标准差 最小值 25% 50% 75% 最大值
会话 7507.00 49.61 12.89 25.00 39.00 51.00 61.00 70.00
年份 7507.00 1994.61 12.89 1970.00 1984.00 1996.00 2006.00 2015.00
长度 7507.00 17967.28 7860.04 2362.00 12077.00 16424.00 22479.50 72041.00

describe(),没有额外的参数,计算值的总数、均值和标准差,以及只有数值列的五数总结DataFrame包含sessionyearlength的 7,507 个条目。对于yearsession来说,均值和标准差没有太多意义,但最小值和最大值仍然很有趣。显然,我们的数据集包含了从 1970 年到 2015 年的第 25 届至第 70 届联合国大会的演讲。

对非数字列的摘要可以通过指定include='O'np.object的别名)来生成。在这种情况下,我们还会得到计数、唯一值的数量、最顶部的元素(如果有很多具有相同出现次数的话,则获取其一个)及其频率。由于唯一值的数量对文本数据来说没有用,所以让我们只分析countryspeaker列:

df[['country', 'speaker']].describe(include='O').T

Out:

计数 唯一 最顶部 频率
国家 7507 199 ITA 46
发言者 7480 5428 谢悠姆·梅斯芬 12

数据集包含来自 199 个独特国家和显然 5,428 位发言者的数据。国家数量是有效的,因为此列包含标准化的 ISO 代码。但计算像speaker这样的文本列的唯一值通常不会得到有效结果,如下一节将展示的。

检查缺失数据

通过查看前表中的计数,我们可以看到speaker列存在缺失值。因此,让我们使用df.isna()df.isnull()的别名)来检查所有列的空值,并计算结果的摘要:

df.isna().sum()

Out:

session            0
year               0
country            0
country_name       0
speaker           27
position        3005
text               0
length             0
dtype: int64

我们需要谨慎使用speakerposition列,因为输出告诉我们这些信息并不总是可用的!为了避免任何问题,我们可以用一些通用值来替换缺失值,比如unknown speakerunknown position,或者只是空字符串。

Pandas 提供了 df.fillna() 函数来实现这一目的:

df['speaker'].fillna('unknown', inplace=True)

但即使是现有的值可能也存在问题,因为同一演讲者的姓名有时拼写不同甚至含糊不清。以下语句计算包含演讲者列中 Bush 的所有文档的每位演讲者的记录数量:

df[df['speaker'].str.contains('Bush')]['speaker'].value_counts()

Out:

George W. Bush        4
Mr. George W. Bush    2
George Bush           1
Mr. George W Bush     1
Bush                  1
Name: speaker, dtype: int64

除非我们解决这些歧义,否则对发言者姓名的任何分析都会产生错误结果。因此,最好检查分类属性的不同值。了解到这一点后,我们将忽略演讲者信息。

绘制数值分布图

用于可视化数值分布的一种方式是使用 箱线图 来展示数值分布的五数概括。Pandas 内置的绘图功能能够轻松生成这种图表。让我们看一下 length 列的箱线图:

df['length'].plot(kind='box', vert=False)

Out:

正如这个图表所示,50% 的演讲(中间的箱子)长度大约在 12,000 到 22,000 个字符之间,中位数约为 16,000,并且右侧有很多异常值的长尾。该分布显然是左偏的。通过绘制直方图,我们可以获取更多细节:

df['length'].plot(kind='hist', bins=30)

Out:

对于直方图,length 列的值范围被划分为 30 个等宽的间隔,即柱状。y 轴显示每个柱中的文档数量。

比较不同类别的数值分布

当不同数据子集被检查时,数据的特殊性通常会变得明显。用于比较不同类别分布的一种优秀可视化方式是 Seaborn 的 catplot

我们展示箱线图和小提琴图,以比较联合国安全理事会五个常任理事国演讲长度的分布(图 1-2)。因此,sns.catplot 的 x 轴类别是 country

where = df['country'].isin(['USA', 'FRA', 'GBR', 'CHN', 'RUS'])
sns.catplot(data=df[where], x="country", y="length", kind='box')
sns.catplot(data=df[where], x="country", y="length", kind='violin')

图 1-2. 箱线图(左)和小提琴图(右),展示了选定国家演讲长度的分布情况。

小提琴图是箱线图的“平滑”版本。通过小提琴体的宽度来可视化频率,同时箱线仍然可见于小提琴内部。这两种图表显示,对于俄罗斯而言,演讲长度的值分布范围要比英国大得多。但是,如俄罗斯的多个峰值存在只有在小提琴图中才能明显看出。

时间序列的可视化发展

如果您的数据包含日期或时间属性,将数据随时间的发展进行可视化通常会很有趣。首先,可以通过分析每年演讲次数来创建时间序列。我们可以使用 Pandas 的分组函数 size() 来返回每个组的行数。通过简单地附加 plot(),我们可以可视化生成的 DataFrame(图 1-3,左侧):

df.groupby('year').size().plot(title="Number of Countries")

时间轴反映了联合国成员国数量的发展,因为每个国家每年只有一次发言机会。事实上,联合国今天有 193 个成员国。有趣的是,随着更多国家参与辩论,所需的演讲长度也在减少,如下面的分析所显示(见图 1-3,右图):

df.groupby('year').agg({'length': 'mean'}) \
  .plot(title="Avg. Speech Length", ylim=(0,30000))

图 1-3. 随时间变化的国家数量和平均演讲长度。
注意

Pandas 数据框不仅可以在 Jupyter 笔记本中轻松可视化,还可以通过内置函数导出到 Excel (.xlsx)、HTML、CSV、LaTeX 和许多其他格式。甚至还有一个to_clipboard()函数。查看文档获取详情。

蓝图:构建一个简单的文本预处理流水线

元数据分析,如类别、时间、作者和其他属性,可以为语料库提供一些初步见解。但更有趣的是深入挖掘实际内容,探索不同子集或时间段中的常见词语。在本节中,我们将开发一个基本的蓝图,准备文本进行快速的初步分析,由一系列步骤组成(见图 1-4)。由于每个操作的输出形成下一个操作的输入,这样的顺序也称为处理流水线,将原始文本转换为一系列标记。

图 1-4. 简单的预处理流水线。

这里呈现的流水线包括三个步骤:大小写转换为小写、分词和停用词去除。这些步骤将在第四章中深入讨论和扩展,我们将使用 spaCy。为了保持快速和简单,我们在这里基于正则表达式构建自己的分词器,并展示如何使用任意的停用词列表。

使用正则表达式进行分词

分词是从字符序列中提取单词的过程。在西方语言中,单词通常由空格和标点符号分隔。因此,最简单和最快的分词器是 Python 的本地str.split()方法,它以空格分割。更灵活的方式是使用正则表达式。

正则表达式和 Python 库reregex将在第四章中详细介绍。在这里,我们希望应用一个简单的模式来匹配单词。在我们的定义中,单词至少包含一个字母以及数字和连字符。纯数字被跳过,因为它们几乎只代表这个语料库中的日期、讲话或会话标识符。

频繁使用的表达式[A-Za-z]不适合匹配字母,因为它会忽略像äâ这样的重音字母。更好的选择是 POSIX 字符类\p{L},它选择所有 Unicode 字母。请注意,我们需要使用regex而不是re来处理 POSIX 字符类。以下表达式匹配由至少一个字母组成的标记(\p{L}),前后是任意的字母数字字符(\w包括数字、字母和下划线)和连字符(-)的序列:

import regex as re

def tokenize(text):
    return re.findall(r'[\w-]*\p{L}[\w-]*', text)

让我们尝试使用语料库中的一个示例句子:

text = "Let's defeat SARS-CoV-2 together in 2020!"
tokens = tokenize(text)
print("|".join(tokens))

输出:

Let|s|defeat|SARS-CoV-2|together|in

处理停用词

文本中最常见的词是诸如限定词、助动词、代词、副词等常见词汇。这些词称为停用词。停用词通常不携带太多信息,但由于其高频率而隐藏了有趣的内容。因此,在数据分析或模型训练之前通常会删除停用词。

在本节中,我们展示如何丢弃预定义列表中包含的停用词。许多语言都有通用的停用词列表,并且几乎所有的自然语言处理库都集成了这些列表。我们将在这里使用 NLTK 的停用词列表,但你可以使用任何单词列表作为过滤器。^(2) 为了快速查找,你应该总是将列表转换为集合。集合是基于哈希的数据结构,类似于字典,具有几乎恒定的查找时间:

import nltk

stopwords = set(nltk.corpus.stopwords.words('english'))

我们从给定列表中移除停用词的方法,封装成下面展示的小函数,通过简单的列表推导来实现检查。作为 NLTK 的列表只包含小写词汇,因此将标记转换为小写:

def remove_stop(tokens):
    return [t for t in tokens if t.lower() not in stopwords]

通常,您需要将领域特定的停用词添加到预定义的列表中。例如,如果您正在分析电子邮件,术语dearregards可能会出现在几乎所有文档中。另一方面,您可能希望将预定义列表中的某些词视为非停用词。我们可以使用 Python 的两个集合运算符|(并集/或)和-(差集)添加额外的停用词并排除列表中的其他词:

include_stopwords = {'dear', 'regards', 'must', 'would', 'also'}
exclude_stopwords = {'against'}

stopwords |= include_stopwords
stopwords -= exclude_stopwords

NLTK 的停用词列表保守,仅包含 179 个词。令人惊讶的是,would不被视为停用词,而wouldn’t却是。这说明了预定义停用词列表常见的问题:不一致性。请注意,删除停用词可能会显著影响语义目标分析的性能,详细说明请参见“为什么删除停用词可能是危险的”。

除了或替代固定的停用词列表外,将每个在文档中出现频率超过 80%的单词视为停用词也可能很有帮助。这些常见词汇会使内容难以区分。scikit-learn 向量化器的参数max_df,如第五章中所述,正是为此而设计。另一种方法是根据词类别(词性)过滤单词。这个概念将在第四章中解释。

用一行代码处理流水线

让我们回到包含语料库文档的DataFrame。我们想要创建一个名为tokens的新列,其中包含每个文档的小写化、标记化文本,而且没有停用词。为此,我们使用一个可扩展的处理流程模式。在我们的案例中,我们将所有文本转换为小写,进行标记化,并去除停用词。通过简单扩展这个流程,可以添加其他操作:

pipeline = [str.lower, tokenize, remove_stop]

def prepare(text, pipeline):
    tokens = text
    for transform in pipeline:
        tokens = transform(tokens)
    return tokens

如果我们将所有这些放入一个函数中,它就成为了 Pandas 的mapapply操作的完美用例。在数学和计算机科学中,接受其他函数作为参数的函数(如mapapply)称为高阶函数

表 1-2. Pandas 高阶函数

函数 描述
Series.map 逐个元素作用于 Pandas 的Series
Series.apply map相同,但允许额外参数
DataFrame.applymap 逐个元素作用于 Pandas 的DataFrame(与Series上的map相同)
DataFrame.apply 作用于DataFrame的行或列,并支持聚合

Pandas 支持在系列和数据框上的不同高阶函数(表 1-2)。这些函数不仅可以让您以一种易于理解的方式指定一系列的功能数据转换,而且可以轻松并行化。例如,Python 包pandarallel提供了mapapply的并行版本。

Apache Spark这样的可扩展框架支持更加优雅的数据框操作。事实上,在分布式编程中,mapreduce操作基于函数式编程的同一原理。此外,许多编程语言,包括 Python 和 JavaScript,都有针对列表或数组的本地map操作。

使用 Pandas 的一个高阶操作,应用功能转换变成了一行代码。

df['tokens'] = df['text'].apply(prepare, pipeline=pipeline)

现在tokens列包含每个文档中提取的令牌的 Python 列表。当然,这个额外的列基本上会将DataFrame的内存消耗翻倍,但它允许您直接快速访问令牌以进行进一步分析。尽管如此,以下蓝图设计的方式使得令牌化也可以在分析过程中即时执行。通过这种方式,性能可以用内存消耗来交换:要么在分析前进行一次令牌化并消耗内存,要么在分析过程中动态令牌化并等待。

我们还添加了另一列,包含令牌列表的长度,以便后续摘要使用:

df['num_tokens'] = df['tokens'].map(len)

注意

tqdm(阿拉伯语中“进展”的发音是taqadum)是 Python 中优秀的进度条库。它支持传统的循环,例如使用tqdm_range替代range,并且通过提供在数据框上的progress_mapprogress_apply操作支持 Pandas。^(3) 我们在 GitHub 上的相关笔记本使用这些操作,但在本书中我们仅使用纯粹的 Pandas。

词频分析的蓝图

频繁使用的单词和短语可以帮助我们基本了解讨论的主题。然而,词频分析忽略了单词的顺序和上下文。这就是著名的词袋模型的理念(参见第 5 章):所有单词都被扔进一个袋子里,它们在里面翻滚成一团乱麻。原始文本中的排列被丢失,只有词项的频率被考虑进来。这个模型对于情感分析或问题回答等复杂任务效果不佳,但对于分类和主题建模却表现出色。此外,它是理解文本内容起点良好的一种方式。

在本节中,我们将开发多个蓝图来计算和可视化词频。由于原始频率过高导致不重要但频繁出现的单词占主导地位,因此我们在过程末尾还将引入 TF-IDF。我们将使用Counter来实现频率计算,因为它简单且速度极快。

蓝图:使用计数器计数单词

Python 的标准库中内置了一个名为Counter的类,它正如其名字所示:用来计数。^(4) 使用计数器的最简单方式是从一个项目列表创建它,本例中是代表单词或标记的字符串。生成的计数器基本上是一个包含这些项目作为键和它们频率作为值的字典对象。

让我们通过一个简单的例子来说明它的功能:

from collections import Counter

tokens = tokenize("She likes my cats and my cats like my sofa.")

counter = Counter(tokens)
print(counter)

Out:

Counter({'my': 3, 'cats': 2, 'She': 1, 'likes': 1, 'and': 1, 'like': 1,
         'sofa': 1})

计数器需要一个列表作为输入,因此任何文本都需要预先进行令牌化。计数器的好处在于它可以通过第二个文档的令牌列表进行增量更新。

more_tokens = tokenize("She likes dogs and cats.")
counter.update(more_tokens)
print(counter)

Out:

Counter({'my': 3, 'cats': 3, 'She': 2, 'likes': 2, 'and': 2, 'like': 1,
         'sofa': 1, 'dogs': 1})

要查找语料库中最频繁出现的单词,我们需要从所有文档中的单词列表创建一个计数器。一个简单的方法是将所有文档连接成一个巨大的标记列表,但对于较大的数据集来说这不可扩展。对于每个单个文档,调用计数器对象的update函数要高效得多。

counter = Counter()

df['tokens'].map(counter.update)

我们在这里做了一个小技巧,将 counter.update 放入 map 函数中。奇迹发生在 update 函数内部。整个 map 调用运行得非常快;对于 7500 篇联合国演讲,仅需约三秒,并且与标记总数成线性关系。原因是一般而言字典和特别是计数器都实现为哈希表。单个计数器相对于整个语料库来说非常紧凑:它只包含每个单词一次以及其频率。

现在,我们可以使用相应的计数器函数检索文本中最常见的单词:

print(counter.most_common(5))

输出:

[('nations', 124508),
 ('united', 120763),
 ('international', 117223),
 ('world', 89421),
 ('countries', 85734)]

为了进一步处理和分析,将计数器转换为 Pandas DataFrame 要方便得多,这正是以下蓝图函数最终要做的。标记构成 DataFrame 的索引,而频率值存储在名为 freq 的列中。行已排序,使得最常见的单词出现在前面:

def count_words(df, column='tokens', preprocess=None, min_freq=2):

    # process tokens and update counter
    def update(doc):
        tokens = doc if preprocess is None else preprocess(doc)
        counter.update(tokens)

    # create counter and run through all data
    counter = Counter()
    df[column].map(update)

    # transform counter into a DataFrame
    freq_df = pd.DataFrame.from_dict(counter, orient='index', columns=['freq'])
    freq_df = freq_df.query('freq >= @min_freq')
    freq_df.index.name = 'token'

    return freq_df.sort_values('freq', ascending=False)

该函数的第一个参数是一个 Pandas DataFrame,第二个参数是包含标记或文本的列名。由于我们已经将准备好的标记存储在包含演讲内容的 DataFrame 的列 tokens 中,我们可以使用以下两行代码计算包含单词频率并显示前五个标记的 DataFrame

freq_df = count_words(df)
freq_df.head(5)

输出:

标记 频率
国家 124508
联合 120763
国际 117223
世界 89421
国家 85734

如果我们不想对一些特殊分析使用预先计算的标记,我们可以使用自定义预处理函数作为第三个参数来动态标记文本。例如,我们可以通过文本的即时标记化生成并计数所有具有 10 个或更多字符的单词:

    count_words(df, column='text',
                preprocess=lambda text: re.findall(r"\w{10,}", text))

count_words 的最后一个参数定义了要包含在结果中的最小标记频率。其默认值设置为 2,以削减出现仅一次的偶发标记的长尾部分。

蓝图:创建频率图

Python 中有几十种生成表格和图表的方法。我们喜欢 Pandas 和其内置的绘图功能,因为它比纯 Matplotlib 更容易使用。我们假设由前述蓝图生成的 DataFrame freq_df 用于可视化。基于这样一个 DataFrame 创建频率图现在基本上变成了一行代码。我们再添加两行格式化代码:

ax = freq_df.head(15).plot(kind='barh', width=0.95)
ax.invert_yaxis()
ax.set(xlabel='Frequency', ylabel='Token', title='Top Words')

输出:

使用水平条 (barh) 来显示词频极大地提高了可读性,因为单词在 y 轴上以可读的形式水平显示。 y 轴被反转以将顶部的单词放置在图表的顶部。 可以选择修改坐标轴标签和标题。

蓝图:创建词云

像之前显示的频率分布图一样,详细显示了标记频率的信息。 但是,对于不同的时间段、类别、作者等进行频率图的比较是相当困难的。 相比之下,词云通过不同字体大小来可视化频率。 它们更容易理解和比较,但缺乏表格和条形图的精确性。 您应该记住,长单词或带有大写字母的单词会吸引不成比例的高关注度。

Python 模块 wordcloud 从文本或计数器生成漂亮的词云。 使用它的最简单方法是实例化一个词云对象,带有一些选项,例如最大单词数和停用词列表,然后让 wordcloud 模块处理标记化和停用词移除。 以下代码显示了如何为 2015 年美国演讲的文本生成词云,并显示生成的图像与 Matplotlib:

from wordcloud import WordCloud
from matplotlib import pyplot as plt

text = df.query("year==2015 and country=='USA'")['text'].values[0]

wc = WordCloud(max_words=100, stopwords=stopwords)
wc.generate(text)
plt.imshow(wc, interpolation='bilinear')
plt.axis("off")

然而,这仅适用于单个文本,而不是(可能很大的)文档集。 对于后一种用例,首先创建一个频率计数器,然后使用函数 generate_from_frequencies() 要快得多。

我们的蓝图是在此函数周围做了一点小包装,以支持由 count_words 创建的 Pandas Series 包含的频率值。 WordCloud 类已经有许多选项可以微调结果。 我们在以下函数中使用了其中一些来演示可能的调整,但您应该查看详细文档:

def wordcloud(word_freq, title=None, max_words=200, stopwords=None):

    wc = WordCloud(width=800, height=400,
                   background_color= "black", colormap="Paired",
                   max_font_size=150, max_words=max_words)

    # convert DataFrame into dict
    if type(word_freq) == pd.Series:
        counter = Counter(word_freq.fillna(0).to_dict())
    else:
        counter = word_freq

    # filter stop words in frequency counter
    if stopwords is not None:
        counter = {token:freq for (token, freq) in counter.items()
                              if token not in stopwords}
    wc.generate_from_frequencies(counter)

    plt.title(title)

    plt.imshow(wc, interpolation='bilinear')
    plt.axis("off")

该函数有两个方便的参数来过滤单词。 skip_n 跳过列表中前 n 个单词。 显然,在联合国语料库中,像 unitednationsinternational 这样的单词位于列表的前列。 可视化之后,过滤掉特定但无趣的频繁单词可能更有帮助。 第二个过滤器是一个(额外的)停用词列表。 有时,仅在可视化时过滤掉特定频繁但无趣的单词是有帮助的。 ^(5)

因此,让我们来看看 2015 年的演讲(图示 1-5)。 左侧的词云可视化了最常见的单词,未经过滤。 而右侧的词云则将整个语料库中最频繁的 50 个单词视为停用词:

freq_2015_df = count_words(df[df['year']==2015])
plt.figure()
wordcloud(freq_2015_df['freq'], max_words=100)
wordcloud(freq_2015_df['freq'], max_words=100, stopwords=freq_df.head(50).index)

图 1-5. 2015 年演讲的词云,包含所有单词(左)和不包含 50 个最频繁单词(右)。

显然,正确的词云在去除了语料库中最频繁出现的词后,更好地展示了 2015 年的主题,但仍然存在像 todaychallenges 这样频繁且不具体的词语。我们需要一种方法来减少这些词语的权重,如下一节所示。

蓝图:TF-IDF 排名

如图 1-5 所示,可视化最频繁出现的词通常并不会带来深刻的洞见。即使去除停用词,最常见的词通常是显而易见的特定领域术语,在数据的任何子集(切片)中都相似。但我们希望更加重视那些在给定数据切片中比“通常”更频繁出现的词语。这样的切片可以是语料库的任何子集,例如单篇演讲、某个十年的演讲,或者来自某个国家的演讲。

我们希望突出显示那些在切片中实际词频高于其总概率所表明的词语。有多种算法可以衡量词语的“惊讶”因素。其中一种最简单但效果最好的方法是将词频与逆文档频率结合(见侧边栏)。

让我们定义一个函数来计算语料库中所有术语的 IDF。它几乎与 count_words 相同,不同之处在于每个标记仅在每个文档中计算一次(counter.update(set(tokens))),并且在计数后计算 IDF 值。参数 min_df 用作罕见词的长尾过滤器。该函数的结果再次是一个 DataFrame

def compute_idf(df, column='tokens', preprocess=None, min_df=2):

    def update(doc):
        tokens = doc if preprocess is None else preprocess(doc)
        counter.update(set(tokens))

    # count tokens
    counter = Counter()
    df[column].map(update)

    # create DataFrame and compute idf
    idf_df = pd.DataFrame.from_dict(counter, orient='index', columns=['df'])
    idf_df = idf_df.query('df >= @min_df')
    idf_df['idf'] = np.log(len(df)/idf_df['df'])+0.1
    idf_df.index.name = 'token'
    return idf_df

IDF 值需要一次性计算整个语料库(不要在此处使用子集!),然后可以在各种分析中使用。我们使用此函数创建一个包含每个标记 IDF 值的 DataFrame (idf_df):

idf_df = compute_idf(df)

由于 IDF 和词频 DataFrame 都有一个由标记组成的索引,我们可以简单地将两个 DataFrame 的列相乘,以计算术语的 TF-IDF 分数:

freq_df['tfidf'] = freq_df['freq'] * idf_df['idf']

让我们比较基于词频的词云和语料库中第一年和最后一年演讲的 TF-IDF 分数。我们去除了一些代表各自辩论会话次数的停用词。

freq_1970 = count_words(df[df['year'] == 1970])
freq_2015 = count_words(df[df['year'] == 2015])

freq_1970['tfidf'] = freq_1970['freq'] * idf_df['idf']
freq_2015['tfidf'] = freq_2015['freq'] * idf_df['idf']

#wordcloud(freq_df['freq'], title='All years', subplot=(1,3,1))
wordcloud(freq_1970['freq'], title='1970 - TF',
          stopwords=['twenty-fifth', 'twenty-five'])
wordcloud(freq_2015['freq'], title='2015 - TF',
          stopwords=['seventieth'])
wordcloud(freq_1970['tfidf'], title='1970 - TF-IDF',
          stopwords=['twenty-fifth', 'twenty-five', 'twenty', 'fifth'])
wordcloud(freq_2015['tfidf'], title='2015 - TF-IDF',
          stopwords=['seventieth'])

通过 TF-IDF 加权的词云在 图 1-6 中生动展示了其威力。尽管 1970 年和 2015 年最常见的词几乎相同,但 TF-IDF 加权的可视化强调了政治主题的差异。

图 1-6. 两个选定年份演讲中,按纯计数(上)和 TF-IDF(下)加权的词语。

有经验的读者可能会想知道,为什么我们要自己实现计算单词数和计算 IDF 值的函数,而不是使用 scikit-learn 的 CountVectorizerTfidfVectorizer 类。实际上,有两个原因。首先,向量化器为每个单个文档生成加权词频向量,而不是数据集的任意子集。其次,结果是矩阵(适合机器学习),而不是数据框架(适合切片、聚合和可视化)。最终,为了生成 图 1-6 中的结果,我们将不得不编写大致相同数量的代码行,但错过了从头介绍这一重要概念的机会。scikit-learn 的向量化器将在 第五章 中详细讨论。

蓝图:寻找关键词上下文

词云和频率图是视觉总结文本数据的强大工具。然而,它们通常也会引发关于为什么某个术语如此突出的问题。例如,前面讨论的 2015 TF-IDF 词云显示了术语 pvsdgssids,您可能不知道它们的含义。为了弄清楚这一点,我们需要一种检查这些词在原始未准备文本中实际出现情况的方法。一种简单而聪明的方法是关键词上下文分析(KWIC 分析)。它生成显示关键词左右上下文的等长文本片段列表。以下是 sdgs 的 KWIC 列表示例,它为我们解释了这个术语:

5 random samples out of 73 contexts for 'sdgs':
 of our planet and its people. The   SDGs   are a tangible manifestation of th
nd, we are expected to achieve the   SDGs   and to demonstrate dramatic develo
ead by example in implementing the   SDGs   in Bangladesh. Attaching due impor
the Sustainable Development Goals (  SDGs  ). We applaud all the Chairs of the
new Sustainable Development Goals (  SDGs  ) aspire to that same vision. The A

显然,sdgs 是 SDGs 的小写版本,SDGs 代表“可持续发展目标”。通过相同的分析,我们可以了解 sids 代表“小岛屿发展中国家”。这是解释 2015 年主题的重要信息!pv 则是一个标记化的人为产物。实际上,它是引用参考文献的剩余部分,例如 (A/70/PV.28),表示“第 70 届大会,28 号议事录”,即第 70 届大会的第 28 次发言。

注意

当您遇到不认识或不理解的令牌时,请务必深入了解细节!通常它们携带重要信息(如 sdgs),您作为分析师应能够解释。但您也经常会发现 pv 等人为产物。如果与您的分析无关,则应将其丢弃或正确处理。

KWIC 分析已在 NLTK 和 textacy 中实现。我们将使用 textacy 的 KWIC 函数,因为它快速且适用于未标记化的文本。因此,我们可以搜索跨越多个标记的字符串,如“气候变化”,而 NLTK 无法做到。NLTK 和 textacy 的 KWIC 函数仅适用于单个文档。要将分析扩展到 DataFrame 中的若干文档,我们提供以下函数:

from textacy.text_utils import KWIC

def kwic(doc_series, keyword, window=35, print_samples=5):

    def add_kwic(text):
        kwic_list.extend(KWIC(text, keyword, ignore_case=True,
                              window_width=window, print_only=False))

    kwic_list = []
    doc_series.map(add_kwic)

    if print_samples is None or print_samples==0:
        return kwic_list
    else:
        k = min(print_samples, len(kwic_list))
        print(f"{k} random samples out of {len(kwic_list)} " + \
              f"contexts for '{keyword}':")
        for sample in random.sample(list(kwic_list), k):
            print(re.sub(r'[\n\t]', ' ', sample[0])+'  '+ \
                  sample[1]+'  '+\
                  re.sub(r'[\n\t]', ' ', sample[2]))

该函数通过将map应用于每个文档来迭代收集关键字上下文的关键字上下文,这是我们已经在单词计数蓝图中使用过的技巧,非常有效,并且还可以对更大的语料库进行 KWIC 分析。 默认情况下,该函数返回形式为(left context, keyword, right context)的元组列表。 如果print_samples大于 0,则会打印结果的随机样本。^(8) 当您处理大量文档时,采样尤其有用,因为列表的前几个条目否则将来自单个或非常少量的文档。

之前的sdgs的 KWIC 列表是通过以下调用生成的:

kwic(df[df['year'] == 2015]['text'], 'sdgs', print_samples=5)

蓝图:分析 N-Grams

仅仅知道气候是一个常见的词并不能告诉我们太多关于讨论主题的信息,因为,例如,climate changepolitical climate有完全不同的含义。 即使是change climate也不同于climate change。 因此,将频率分析从单个词扩展到两个或三个词的短序列可能会有所帮助。

基本上,我们正在寻找两种类型的词序列:化合物和搭配词。 化合物是具有特定含义的两个或更多个词的组合。 在英语中,我们发现以封闭形式出现的化合物,例如earthquake;以连字符形式出现的化合物,例如self-confident;以及以开放形式出现的化合物,例如climate change。 因此,我们可能需要将两个标记视为单个语义单位。 相反,搭配词是经常一起使用的词。 通常,它们由形容词或动词和名词组成,例如red carpetunited nations

在文本处理中,我们通常处理 bigrams(长度为 2 的序列),有时甚至是 trigrams(长度为 3)。 大小为 1 的 n-grams 是单个单词,也称为unigrams。 坚持保持n 3的原因是,不同的 n-grams 数量随着n的增加呈指数增长,而它们的频率以相同的方式减少。 到目前为止,大多数 trigrams 在语料库中只出现一次。

以下函数优雅地生成了一组标记序列的 n-gram:^(9)

def ngrams(tokens, n=2, sep=' '):
    return [sep.join(ngram) for ngram in zip(*[tokens[i:] for i in range(n)])]

text = "the visible manifestation of the global climate change"
tokens = tokenize(text)
print("|".join(ngrams(tokens, 2)))

输出:

the visible|visible manifestation|manifestation of|of the|the global|
global climate|climate change

如您所见,大多数 bigrams 包含了像介词和冠词之类的停止词。 因此,建议构建不含停用词的 bigrams。 但是我们需要小心:如果首先删除停止词然后构建 bigrams,则会生成原始文本中不存在的 bigrams,例如示例中的“manifestation global”。 因此,我们在所有标记上创建 bigrams,但仅保留不包含任何停止词的 bigrams,使用此修改后的ngrams函数:

def ngrams(tokens, n=2, sep=' ', stopwords=set()):
    return [sep.join(ngram) for ngram in zip(*[tokens[i:] for i in range(n)])
            if len([t for t in ngram if t in stopwords])==0]

print("Bigrams:", "|".join(ngrams(tokens, 2, stopwords=stopwords)))
print("Trigrams:", "|".join(ngrams(tokens, 3, stopwords=stopwords)))

输出:

Bigrams: visible manifestation|global climate|climate change
Trigrams: global climate change

使用此ngrams函数,我们可以向我们的DataFrame添加一个包含所有 bigrams 的列,并应用单词计数蓝图以确定前五个 bigrams:

df['bigrams'] = df['text'].apply(prepare, pipeline=[str.lower, tokenize]) \
                          .apply(ngrams, n=2, stopwords=stopwords)

count_words(df, 'bigrams').head(5)

输出:

标记 频率
联合国 103236
国际社会 27786
大会 27096
安全理事会 20961
人权 19856

您可能已经注意到我们在标记化过程中忽略了句子边界。因此,我们将生成最后一个句子的最后一个词和下一个句子的第一个词的无意义双字词。这些双字词不会很频繁,所以它们对数据探索并不重要。如果我们想要避免这种情况,我们需要识别句子边界,这比词标记化要复杂得多,在这里并不值得努力。

现在让我们扩展我们基于 TF-IDF 的单字词分析,包括双字词。我们添加了双字词的 IDF 值,计算了所有 2015 年演讲的 TF-IDF 加权双字词频率,并从结果的 DataFrame 生成了一个词云:

# concatenate existing IDF DataFrame with bigram IDFs
idf_df = pd.concat([idf_df, compute_idf(df, 'bigrams', min_df=10)])

freq_df = count_words(df[df['year'] == 2015], 'bigrams')
freq_df['tfidf'] = freq_df['freq'] * idf_df['idf']
wordcloud(freq_df['tfidf'], title='all bigrams', max_words=50)

正如我们在图 1-7 左侧的词云中看到的那样,气候变化是 2015 年的一个常见双字词。但是,为了理解气候的不同上下文,了解仅包含气候的双字词可能会很有趣。我们可以在气候上使用文本过滤器来实现这一点,并再次将结果绘制为词云(图 1-7,右侧):

where = freq_df.index.str.contains('climate')
wordcloud(freq_df[where]['freq'], title='"climate" bigrams', max_words=50)

图 1-7. 所有双字词和包含单词climate的双字词的词云。

这里介绍的方法创建并加权所有不包含停用词的 n-gram。初步分析的结果看起来相当不错。我们只关心不频繁出现的双字词的长尾部分。还有更复杂但计算成本更高的算法可用于识别搭配词,例如在NLTK 的搭配词查找器中。我们将在第四章和第十章展示识别有意义短语的替代方法。

蓝图:比较时间间隔和类别之间的频率

你肯定知道Google 趋势,你可以跟踪一些搜索词随时间的发展。这种趋势分析按日计算频率,并用线状图可视化。我们想要跟踪我们的 UN 辩论数据集中某些关键词随着年份的变化情况,以了解诸如气候变化、恐怖主义或移民等主题的重要性增长或减少的情况。

创建频率时间线

我们的方法是计算每个文档中给定关键词的频率,然后使用 Pandas 的 groupby 函数汇总这些频率。以下函数是第一个任务的。它从标记列表中提取给定关键词的计数:

def count_keywords(tokens, keywords):
    tokens = [t for t in tokens if t in keywords]
    counter = Counter(tokens)
    return [counter.get(k, 0) for k in keywords]

让我们通过一个小例子来演示功能:

keywords = ['nuclear', 'terrorism', 'climate', 'freedom']
tokens = ['nuclear', 'climate', 'climate', 'freedom', 'climate', 'freedom']

print(count_keywords(tokens, keywords))

输出:

[1, 0, 3, 2]

正如你所见,该函数返回一个单词计数的列表或向量。事实上,它是一个非常简单的关键词计数向量化器。如果我们将此函数应用于我们DataFrame中的每个文档,我们将得到一个计数的矩阵。接下来显示的蓝图函数count_keywords_by正是这样的第一步。然后,该矩阵再次转换为一个DataFrame,最终按提供的分组列进行聚合和排序。

def count_keywords_by(df, by, keywords, column='tokens'):

    freq_matrix = df[column].apply(count_keywords, keywords=keywords)
    freq_df = pd.DataFrame.from_records(freq_matrix, columns=keywords)
    freq_df[by] = df[by] # copy the grouping column(s)

    return freq_df.groupby(by=by).sum().sort_values(by)

这个函数非常快,因为它只需处理关键词。在笔记本电脑上,对早期的四个关键词进行统计只需两秒钟。让我们来看看结果:

freq_df = count_keywords_by(df, by='year', keywords=keywords)

输出:

nuclear terrorism climate freedom year
1970 192 7 18 128
1971 275 9 35 205
... ... ... ... ...
2014 144 404 654 129
2015 246 378 662 148
注意

即使在我们的例子中只使用了属性year作为分组标准,但蓝图函数允许您跨任何离散属性比较单词频率,例如国家、类别、作者等。事实上,您甚至可以指定一个分组属性列表,以计算例如按国家和年份计数。

生成的DataFrame已经完全准备好用于绘图,因为每个关键词都有一个数据系列。使用 Pandas 的plot函数,我们得到了一个类似于 Google 趋势的漂亮折线图(参见图 1-8):

freq_df.plot(kind='line')

图 1-8。每年选定词汇的频率。

注意 1980 年代“核”词的高峰,表明了军备竞赛,以及 2001 年恐怖主义的高峰。引人注目的是,“气候”主题在 1970 年代和 1980 年代已经引起了一些关注。真的吗?好吧,如果你用 KWIC 分析(“蓝图:寻找上下文关键词”)检查一下,你会发现在那些年代,“气候”一词几乎完全是以比喻意义使用的。

创建频率热图

假设我们想分析全球危机的历史发展,比如冷战、恐怖主义和气候变化。我们可以选择一些显著词汇,并像前面的例子中那样通过线图来可视化它们的时间线。但是,如果线图超过四五条线,它们会变得令人困惑。一个没有这种限制的替代可视化方法是热图,如 Seaborn 库所提供的。因此,让我们为我们的过滤器添加更多关键词,并将结果显示为热图(参见图 1-9)。

keywords = ['terrorism', 'terrorist', 'nuclear', 'war', 'oil',
            'syria', 'syrian', 'refugees', 'migration', 'peacekeeping',
            'humanitarian', 'climate', 'change', 'sustainable', 'sdgs']

freq_df = count_keywords_by(df, by='year', keywords=keywords)

# compute relative frequencies based on total number of tokens per year
freq_df = freq_df.div(df.groupby('year')['num_tokens'].sum(), axis=0)
# apply square root as sublinear filter for better contrast
freq_df = freq_df.apply(np.sqrt)

sns.heatmap(data=freq_df.T,
            xticklabels=True, yticklabels=True, cbar=False, cmap="Reds")

图 1-9。随时间变化的词频热图。

对于这种分析,有几点需要考虑:

任何类型的比较都应优先使用相对频率。

如果每年或每个类别的令牌总数不稳定,绝对术语频率可能存在问题。例如,在我们的例子中,如果越来越多的国家每年都在发言,绝对频率自然会上升。

谨慎解释基于关键词列表的频率图表。

虽然图表看起来像是主题的分布,但事实并非如此!可能还有其他代表相同主题的词语,但未包含在列表中。关键词也可能有不同的含义(例如,“讨论的气候”)。高级技术如主题建模(第八章)和词嵌入(第十章)在这里可以提供帮助。

使用亚线性缩放。

由于频率值差异很大,对于频率较低的令牌可能很难看到任何变化。因此,你应该对频率进行亚线性缩放(我们应用了平方根 np.sqrt)。视觉效果类似于降低对比度。

结语

我们展示了如何开始分析文本数据。文本准备和标记化的过程被保持简单以获得快速结果。在第四章中,我们将介绍更复杂的方法,并讨论不同方法的优缺点。

数据探索不仅应该提供初步的见解,而且实际上应该帮助您对数据产生信心。你应该记住的一件事是,你应该总是确定任何奇怪令牌出现的根本原因。KWIC 分析是搜索这类令牌的一个好工具。

对于内容的初步分析,我们介绍了几种词频分析的蓝图。术语的加权基于术语频率或术语频率和逆文档频率(TF-IDF)的组合。这些概念稍后将在第五章中继续讨论,因为 TF-IDF 加权是机器学习中标准的文档向量化方法之一。

文本分析有很多方面在本章中我们没有涉及:

  • 作者相关的信息可以帮助识别有影响力的作家,如果这是你的项目目标之一的话。作者可以通过活动、社交分数、写作风格等来区分。

  • 有时候比较不同作者或不同语料库在相同主题上的可读性是很有趣的。textacy有一个名为 textstats 的函数,可以在一次遍历文本中计算不同的可读性分数和其他统计数据。

  • 一个有趣的工具,用于识别和可视化不同类别之间的特征术语(例如政党)是 Jason Kessler 的Scattertext库。

  • 除了纯 Python 之外,你还可以使用交互式的视觉工具进行数据分析。Microsoft 的 PowerBI 有一个不错的词云插件和许多其他选项来生成交互式图表。我们提到它是因为在桌面版中免费使用,并支持 Python 和 R 用于数据准备和可视化。

  • 对于较大的项目,我们建议设置搜索引擎,如Apache SOLRElasticsearch,或Tantivy。这些平台创建了专门的索引(还使用 TF-IDF 加权),以便进行快速全文搜索。Python API 适用于所有这些平台。

^(1) 查看Pandas 文档获取完整列表。

^(2) 您可以类似地处理 spaCy 的列表,使用spacy.lang.en.STOP_WORDS

^(3) 查看文档获取更多细节。

^(4) NLTK 类FreqDist派生自Counter,并添加了一些便利功能。

^(5) 注意,如果调用generate_from_frequencieswordcloud模块会忽略停用词列表。因此,我们需要额外进行过滤。

^(6) 例如,scikit-learn 的TfIdfVectorizer会添加+1

^(7) 另一种选择是在分母中添加+1,以避免未见术语导致的除零。这种技术称为平滑

^(8) textacy 的KWIC函数中的参数print_only类似工作,但不进行抽样。

^(9) 查看斯科特·特里格利亚的博文了解解释。

第二章:使用 API 提取文本洞见

当您想要确定研究问题的方法或开始进行文本分析项目时,数据的可用性通常是第一个障碍。一个简单的谷歌搜索或更具体的数据集搜索将提供精选数据集,我们将在本书的后续章节中使用其中一些。根据您的项目,这些数据集可能是通用的,不适合您的用例。您可能需要创建自己的数据集,而应用程序编程接口(API)是以编程方式自动提取数据的一种方法。

你将学到什么,我们将构建什么

在本章中,我们将概述 API,并介绍从流行网站(如GitHubTwitter)提取数据的蓝图。您将了解如何使用身份验证令牌,处理分页,了解速率限制,并自动化数据提取。在本章末尾,您将能够通过对任何已识别服务进行 API 调用来创建自己的数据集。虽然这些蓝图以 GitHub 和 Twitter 等具体示例为例,但它们可以用来处理任何 API。

应用程序编程接口

API 是允许软件应用程序或组件在无需知道它们如何实现的情况下进行通信的接口。API 提供一组定义和协议,包括可以进行的请求类型,要使用的数据格式以及预期的响应。 API 是开发人员在构建网站,应用程序和服务时常用的一组软件接口。例如,当您几乎与任何服务注册新帐户时,将要求您使用一次性代码或链接验证您的电子邮件地址或电话号码。通常,开发人员会使用认证服务提供的 API 来启用此功能,而不是构建整个流程。这允许将服务提供的核心功能与使用 API 构建其他必要但不唯一的功能分离。您可以阅读Zapier提供的关于 API 的直观非技术介绍,以更好地理解。

编程 API 如何与文本分析项目中的数据连接?除了允许基本功能如身份验证外,网站上的常见功能也作为 API 提供,为我们提供了另一种访问数据的方式。例如,第三方工具利用 API 在社交媒体上创建帖子或添加评论。我们可以使用这些相同的 API 将这些信息读取并存储在本地以创建我们的数据集。例如,假设你是一家消费品公司的分析师,希望评估市场营销活动的表现。你可以使用 Twitter 搜索 API 提取数据,过滤包含活动口号或标签的推文,并分析文本以了解人们的反应。或者考虑到你被培训提供商要求帮助确定新课程的未来技术领域。一种方法是使用 StackOverflow API 提取关于正在提问的问题的数据,并使用文本分析识别出新兴主题。

使用 API 是优于对网站进行抓取的首选方法。它们被设计为可调用的函数,易于使用,并且可以自动化。特别是在处理频繁变化的数据或项目必须反映最新信息时,它们特别推荐使用。在使用任何 API 时,重要的是花时间仔细阅读文档。文档提供了关于具体 API 调用、数据格式、参数以及用户权限、速率限制等其他详细信息。

注意

并非所有 API 都是免费提供的,有些提供者有不同的计划以支持不同类型的客户。例如,Twitter API 有标准版、高级版和企业版。标准版是公共 API(任何具有开发者帐户的人都可以使用),而高级版和企业版则仅供付费客户使用。在本章中,我们将仅使用公共 API。

蓝图:使用 Requests 模块从 API 中提取数据

随着基于 HTTP 标准驱动的 Web 的普及,URL 往往是 API 的主要规范。我们将使用包含在标准 Python 发行版中的 requests 库作为访问和提取 API 数据的主要方式。为了说明这一蓝图,我们将使用 GitHub API。GitHub 是一个流行的代码托管平台,其中托管了几个开源项目,如 Python、scikit-learn、TensorFlow,以及本书的代码。假设您想确定不同编程语言(如 Python、Java 和 JavaScript)的流行程度。我们可以从 GitHub 提取关于流行存储库使用的语言的数据,并确定每种语言的普及程度。或者考虑您的组织正在 GitHub 上托管一个项目,并希望确保用户和贡献者遵守行为准则。我们可以提取贡献者编写的问题和评论,并确保不使用冒犯性语言。在这个蓝图中,我们将阅读和理解 API 的文档,发出请求,解析输出,并创建一个可用于解决我们用例的数据集。

我们想要调用的第一个 API 是列出 GitHub 上的所有存储库。REST API 文档的入口点可以在 GitHub 上找到。您可以搜索特定方法(也称为端点)或导航到 GitHub 页面 查看其详细信息,如图 2-1 所示。

图 2-1. 列出公共存储库的 API 文档。

如文档所述,这是一个使用GET方法的调用,将按创建顺序提供存储库列表。让我们使用requests.get方法进行调用,并查看响应状态:

import requests

response = requests.get('https://api.github.com/repositories',
                        headers={'Accept': 'application/vnd.github.v3+json'})
print(response.status_code)

Out:

200

200 响应代码表示对 API 的调用成功。我们还可以评估响应对象的编码,以确保正确处理它。响应对象中包含的重要元素之一是headers对象。它是一个字典,包含更详细的信息,如服务器名称、响应时间戳、状态等。在下面的代码中,我们只提取了 API 返回的内容类型和服务器详细信息,但建议您查看此对象的所有元素。大部分信息都在详细的 API 文档中,但检查响应是确保正确解析响应的另一种方式:

print (response.encoding)
print (response.headers['Content-Type'])
print (response.headers['server'])

Out:

utf-8
application/json; charset=utf-8
GitHub.com

查看响应参数,我们了解到响应遵循 UTF-8 编码,并且内容以 JSON 格式返回。内容可以直接通过 content 元素访问,它以字节形式提供有效载荷。由于我们已经知道响应是一个 JSON 对象,因此我们还可以使用 json() 命令来读取响应。这将创建一个列表对象,其中每个元素都是一个仓库。我们展示了响应中的第一个元素,用于识别创建的第一个 GitHub 仓库。出于简洁起见,我们将输出限制为前 200 个字符:

import json
print (json.dumps(response.json()[0], indent=2)[:200])

输出:

{
  "id": 1,
  "node_id": "MDEwOlJlcG9zaXRvcnkx",
  "name": "grit",
  "full_name": "mojombo/grit",
  "private": false,
  "owner": {
    "login": "mojombo",
    "id": 1,
    "node_id": "MDQ6VXNlcjE=",

虽然前一个响应包含仓库列表,但在寻找特定编程语言时并不有用。使用搜索 API 可能更好,我们将在下一步中使用它:

response = requests.get('https://api.github.com/search/repositories')
print (response.status_code)

输出:

422

上一个请求未成功,因为返回了 422 状态码。此代码表示请求正确,但服务器无法处理请求。这是因为我们没有提供任何搜索查询参数,如 文档 中所述。在查看响应之前,检查和理解状态非常重要。您可以在 HTTP 规范 中查看每个状态码的详细定义。

假设我们想要识别用 Python 编写的与数据科学相关的 GitHub 仓库。我们将通过添加第二个参数 params 并附上搜索条件来修改请求。搜索查询需要按照 GitHub 文档 中描述的规则构建。根据这些规则,我们的搜索查询被编码为查找 data_science,将 language 过滤为 Python (language:python),并将两者组合 (+)。这个构造的查询作为参数 q 传递给了 params。我们还传递了包含 Accept 参数的参数 headers,其中我们指定了 text-match+json,以便响应包含匹配的元数据并以 JSON 格式提供响应:

response = requests.get('https://api.github.com/search/repositories',
    params={'q': 'data_science+language:python'},
    headers={'Accept': 'application/vnd.github.v3.text-match+json'})
print(response.status_code)

输出:

200

如 API 文档中为 /search/repositories 端点提供的示例所述,响应包含一个带有 total_countincomplete_resultsitems 的字典。重要的是要注意,此响应格式与我们之前看到的 /repositories 端点不同,我们必须相应地解析此结构。在这里,我们列出了搜索返回的前五个仓库的名称:

for item in response.json()['items'][:5]:
    printmd('**' + item['name'] + '**' + ': repository ' +
            item['text_matches'][0]['property'] + ' - \"*' +
            item['text_matches'][0]['fragment'] + '*\" matched with ' + '**' +
            item['text_matches'][0]['matches'][0]['text'] + '**')

输出:

DataCamp: repository description - "*DataCamp data-science courses*" matched with
data

data-science-from-scratch: repository description - "*code for Data Science From
Scratch book*" matched with Data Science

data-science-blogs: repository description - "*A curated list of data science
blogs*" matched with data science

galaxy: repository description - "*Data intensive science for everyone.*" matched
with Data

data-scientist-roadmap: repository description - "*Tutorial coming with "data
science roadmap" graphe.*" matched with data science

我们已经看到如何发出请求并解析响应。现在考虑监控存储库中的评论并确保它们符合社区指南的用例。我们将使用列出存储库问题端点来实现这一目标。在这里,我们必须指定所有者和存储库名称以获取所有问题评论,响应将包含该存储库中所有评论的列表。让我们为流行的深度学习框架 PyTorch 存储库发出此请求:

response = requests.get(
    'https://api.github.com/repos/pytorch/pytorch/issues/comments')
print('Response Code', response.status_code)
print('Number of comments', len(response.json()))

Out:

Response Code 200
Number of comments 30

尽管我们看到响应成功,但返回的评论数量仅为 30 个。PyTorch 是一个受欢迎的框架,拥有许多合作者和用户。在浏览器中查看存储库的问题页面将显示评论数量要多得多。那么,我们缺少了什么?

分页

这是许多 API 用于限制响应中元素数量的技术。存储库中的评论总数可能很大,尝试响应所有评论将耗时且成本高昂。因此,GitHub API 实现了分页概念,每次仅返回一页,本例中每页包含 30 个结果。响应对象中的links字段提供了响应中页面数的详细信息。

response.links

Out:

{'next': {'url': 'https://api.github.com/repositories/65600975/issues/
comments?page=2',
  'rel': 'next'},
 'last': {'url': 'https://api.github.com/repositories/65600975/issues/
comments?page=1334',
  'rel': 'last'}}

next字段为我们提供了下一页的 URL,该页包含下一个 30 个结果,而last字段提供了指向最后一页的链接,显示了总共有多少搜索结果。每页 30 个结果的数量也在文档中指定,并且通常可以配置到某个最大值。这对我们意味着什么?为了获取所有结果,我们必须实现一个函数,该函数将解析一页上的所有结果,然后调用下一个 URL,直到到达最后一页。这是一个递归函数,我们检查是否存在next链接并递归调用相同的函数。每页的评论都附加到output_json对象中,最终返回。为了限制我们检索的评论数量,我们使用过滤器参数仅获取自 2020 年 7 月以来的评论。根据文档,日期必须使用 ISO 8601 格式指定,并使用since关键字作为参数提供:

def get_all_pages(url, params=None, headers=None):
    output_json = []
    response = requests.get(url, params=params, headers=headers)
    if response.status_code == 200:
        output_json = response.json()
        if 'next' in response.links:
            next_url = response.links['next']['url']
            if next_url is not None:
                output_json += get_all_pages(next_url, params, headers)
    return output_json

out = get_all_pages(
    "https://api.github.com/repos/pytorch/pytorch/issues/comments",
    params={
        'since': '2020-07-01T10:00:01Z',
        'sorted': 'created',
        'direction': 'desc'
    },
    headers={'Accept': 'application/vnd.github.v3+json'})
df = pd.DataFrame(out)

print (df['body'].count())
df[['id','created_at','body']].sample(1)

Out:

3870

id created_at body
2176 286601372 2017-03-15T00:09:46Z @soumith 能否解释哪个依赖项出了问题?我找不到你提到的 PR。

通过使用递归分页函数,我们已经捕获了 PyTorch 仓库约 3,800 条评论,并且在之前的表格中看到了其中一个示例。我们创建的数据集可以用于应用文本分析蓝图,例如识别不符合社区指南的评论并标记进行审查。它还可以通过在程序化的时间间隔运行来增强,以确保始终捕获最新的评论。

速率限制

在提取评论时可能注意到的一个问题是,我们只能检索到大约 3,800 条评论。然而,实际的评论数量要多得多。这是由于 API 应用了速率限制。为了确保 API 能够继续为所有用户提供服务并避免对基础设施造成负载,供应商通常会实施速率限制。速率限制指定了在特定时间范围内可以向端点发出多少请求。GitHub 的速率限制策略如下所述:

对于未经身份验证的请求,速率限制允许每小时最多 60 次请求。未经身份验证的请求与发起请求的用户相关联的是来源 IP 地址,而不是用户本身。

使用 head 方法可以从 API 中仅检索头部信息,然后查看 X-Ratelimit-LimitX-Ratelimit-RemainingX-RateLimit-Reset 头部元素中的信息,这些信息包含在响应对象的头部部分中。

response = requests.head(
    'https://api.github.com/repos/pytorch/pytorch/issues/comments')
print('X-Ratelimit-Limit', response.headers['X-Ratelimit-Limit'])
print('X-Ratelimit-Remaining', response.headers['X-Ratelimit-Remaining'])

# Converting UTC time to human-readable format
import datetime
print(
    'Rate Limits reset at',
    datetime.datetime.fromtimestamp(int(
        response.headers['X-RateLimit-Reset'])).strftime('%c'))

Out:

X-Ratelimit-Limit 60
X-Ratelimit-Remaining 0
Rate Limits reset at Sun Sep 20 12:46:18 2020

X-Ratelimit-Limit 指示每个时间单位内(在本例中为一小时)可以发出多少个请求,X-Ratelimit-Remaining 是仍然可以在不违反速率限制的情况下进行的请求数量,而 X-RateLimit-Reset 则指示速率将重置的时间。不同的 API 端点可能具有不同的速率限制。例如,GitHub 搜索 API 拥有每分钟的速率限制。如果通过超出速率限制的请求来超过速率限制,则 API 将以状态码 403 响应。

在进行 API 调用时,我们必须遵守速率限制,并调整我们的调用方式,以确保不会过载服务器。就像在之前的例子中从仓库中提取评论一样,我们每小时可以允许进行 60 次 API 调用。我们可以依次发起请求,从而快速耗尽限制,这就是我们先前的蓝图的工作方式。下面展示的函数 handle_rate_limits 会减慢请求速度,以确保它们在整个时间段内被间隔地发起。它通过应用休眠函数将剩余请求均匀分布在剩余时间内来实现这一点。这将确保我们的数据提取蓝图遵守速率限制,并且将请求间隔化,以确保所有请求的数据都被下载:

from datetime import datetime
import time

def handle_rate_limits(response):
    now = datetime.now()
    reset_time = datetime.fromtimestamp(
        int(response.headers['X-RateLimit-Reset']))
    remaining_requests = response.headers['X-Ratelimit-Remaining']
    remaining_time = (reset_time - now).total_seconds()
    intervals = remaining_time / (1.0 + int(remaining_requests))
    print('Sleeping for', intervals)
    time.sleep(intervals)
    return True

网络通信,包括 API 调用,可能因多种原因失败,例如中断的连接、DNS 查询失败、连接超时等。默认情况下,requests 库不实现任何重试机制,因此我们蓝图的一个很好的补充是实现一个重试策略的实现。这将允许在指定的失败条件下重试 API 调用。可以使用HTTPAdapter库来实现,它允许更精细地控制正在进行的底层 HTTP 连接。在这里,我们初始化一个适配器,其中包含指定失败尝试时的五次重试策略。我们还指定了这些重试仅在接收到错误状态码 500503504 时才执行。此外,我们还指定了backoff_factor^(1)的值,该值确定了在第二次尝试后的指数增加时间延迟,以确保我们不会过度请求服务器。

每个请求对象都创建一个默认的Sessions对象,它管理和持久化跨不同请求的连接设置,如 cookies、认证和代理,应该是无状态的。到目前为止,我们依赖于默认的Sessions对象,但是为了使用我们的重试策略覆盖连接行为,我们必须指定一个自定义适配器,这将使我们能够使用重试策略。这意味着我们将使用新的http Session对象来发起我们的请求,如下面的代码所示:

from requests.adapters import HTTPAdapter
from requests.packages.urllib3.util.retry import Retry

retry_strategy = Retry(
    total=5,
    status_forcelist=[500, 503, 504],
    backoff_factor=1
)

retry_adapter = HTTPAdapter(max_retries=retry_strategy)

http = requests.Session()
http.mount("https://", retry_adapter)
http.mount("http://", retry_adapter)

response = http.get('https://api.github.com/search/repositories',
                   params={'q': 'data_science+language:python'})

for item in response.json()['items'][:5]:
    print (item['name'])

Out:

DataCamp
data-science-from-scratch
data-science-blogs
galaxy
data-scientist-roadmap

将所有这些内容整合在一起,我们可以修改蓝图以处理分页、速率限制和重试,如下所示:

from requests.adapters import HTTPAdapter
from requests.packages.urllib3.util.retry import Retry

retry_strategy = Retry(
    total=5,
    status_forcelist=[500, 503, 504],
    backoff_factor=1
)

retry_adapter = HTTPAdapter(max_retries=retry_strategy)

http = requests.Session()
http.mount("https://", retry_adapter)
http.mount("http://", retry_adapter)

def get_all_pages(url, param=None, header=None):
    output_json = []
    response = http.get(url, params=param, headers=header)
    if response.status_code == 200:
        output_json = response.json()
        if 'next' in response.links:
            next_url = response.links['next']['url']
            if (next_url is not None) and (handle_rate_limits(response)):
                output_json += get_all_pages(next_url, param, header)
    return output_json

如果你仔细查看速率限制的文档,你会发现根据所使用的身份验证类型有不同的速率限制。到目前为止,我们的所有请求都是未经认证的请求,速率限制较低。我们可以通过注册账户将我们的数据提取应用程序标识给 GitHub。然后,我们可以对 API 发出经过身份验证的请求,从而增加速率限制。这种做法确保未经认证的用户或欺诈性应用程序无法滥用 API,大多数 API 提供者都不允许未经身份验证的方式访问 API。

这个蓝图展示了如何使用简单的 Python requests 模块从任何 API 中提取数据,并创建自己的数据集。这是大多数 API 请求工作的基本方式,适用于一次性分析和新数据源的初步探索。回到我们的用例,如果你想要识别流行的深度学习框架以便开始学习,那么这个蓝图将是一个不错的选择。或者假设您的组织已经有了销售预测模型,您想评估添加财经市场新闻对该模型准确性的影响。假设有一个提供财经新闻的 API,你可以轻松地创建一个数据集,应用文本分析蓝图,并测试其对模型的相关性。

蓝图:使用 Tweepy 提取 Twitter 数据

为了使开发人员更容易使用其 API,许多流行服务提供了多种编程语言的包,或者至少有一个或多个社区支持的模块。虽然 API 得到官方支持,但这些包是维护良好的 Python 模块,包含了额外的功能,使它们易于使用。这意味着你可以专注于你想要提取的数据类型,而不是 API 调用、身份验证等技术细节。在这个蓝图中,我们将使用一个名为Tweepy的社区开发和支持的 Python 模块来从 Twitter 中提取数据。Twitter 维护了一个不同语言的库列表,其中包括几个 Python 的库。我们选择了 Tweepy,因为它得到了积极的维护并被许多研究人员使用。虽然这个蓝图使用 Tweepy 从 Twitter API 中提取数据,但所描述的步骤对于任何其他 API 都是类似的。

我们之前描述了如何使用 Twitter 分析新营销活动的有效性。另一个用例可能是执行文本分析,以了解加密货币的流行度和情感,以预测其在经济中的采纳和价值。Twitter 是一个社交媒体网络,用户可以即时分享短消息,经常在实时反应世界事件中,如重大灾难或流行的体育赛事。用户还可以添加地理位置信息,这使我们能够了解某个城市或地理区域中最流行的当前事件。在由政府实施的 COVID-19 封锁期间,一些研究人员使用 Twitter 数据了解病毒的传播以及封锁的影响,并将这些作为经济健康的预测变量之一。

警告

请注意,在使用像 Twitter 这样的公共 API 时,您将从许多用户的公共时间线检索数据,这些数据可能包含强烈甚至冒犯性的语言,包括脏话。请注意此点,并根据您的用例适当处理数据。

获取凭证

使用任何 API 的第一步是验证自己或您的应用程序。Twitter 要求其 API 的所有用户都注册为开发者,并提供使用 API 的原因的详细信息。这有助于他们识别您并防止任何未经授权的访问。您必须 注册自己作为开发者。如果您还没有 Twitter 账户,则还需要创建一个账户。您将被要求说明创建开发者账户的目的,并回答关于如何使用 Twitter API 的其他问题。图 2-2 显示了这些屏幕的示例。请提供详细的回答,以确保 Twitter 充分了解您创建开发者账户的目的。例如,在这个蓝图中,我们希望使用 API 提取推文以说明其操作方式。由于我们只打算使用提取功能,因此问题“您的应用程序是否会使用推文、转推、喜欢、关注或直接消息功能?”不适用并且可以取消选择。在继续之前,您必须阅读并理解每个问题。请注意,这些要求对每个 API 都可能有所不同,并且可能会随时更改。

图 2-2. 创建 Twitter 开发者账户的注册流程示意图。

现在您已经拥有开发者账户,下一步是创建应用程序。应用程序的凭证在进行 API 调用时使用,因此指定创建应用程序的原因非常重要。您需要提供应用程序名称、创建原因以及与应用程序相关联的网站 URL。如果您将此应用程序用于研究和学习目的,则可以在应用程序描述中说明,并提供与项目相关的大学页面或 GitHub 存储库的 URL。一旦 Twitter 批准了应用程序,您可以转到 Keys and tokens 标签,如 图 2-3 所示,那里会显示 API keyAPI secret key 字段。请注意,这些是在进行 API 调用时用于身份验证的凭证,不要泄露它们是非常重要的。

图 2-3. 创建 Twitter 应用程序并获取凭证。

安装和配置 Tweepy

Tweepy 的项目存储库和 文档 是关于使用 Tweepy 的所有信息的最佳来源。我们可以通过在终端中输入 pip install tweepy 来安装 Tweepy。接下来,我们必须使用 tweepy.AppAuthHandler 模块对应用进行 Twitter API 的身份验证,我们使用前一步骤中获取的 API 密钥和 API 秘密密钥进行此操作。最后,我们实例化 tweepy.API 类,它将用于进行所有后续对 Twitter API 的调用。一旦连接建立,我们可以确认 API 对象的主机和版本。请注意,由于我们对公共信息的只读访问感兴趣,我们使用 仅应用程序身份验证

import tweepy

app_api_key = 'YOUR_APP_KEY_HERE'
app_api_secret_key = 'YOUR_APP_SECRET_HERE'

auth = tweepy.AppAuthHandler(app_api_key, app_api_secret_key)
api = tweepy.API(auth)

print ('API Host', api.host)
print ('API Version', api.api_root)

输出:

API Host api.twitter.com
API Version /1.1

从搜索 API 中提取数据

假设我们想分析加密货币的感知并确定其受欢迎程度。我们将使用搜索 API 检索提到这一点的所有推文以创建我们的数据集。Twitter API 也使用分页来返回多页结果,但我们不会实现自己的管理方式,而是使用 Tweepy 库提供的 Cursor 对象来遍历结果。我们将搜索查询传递给 API 对象,并另外指定要提取的推文的语言(在这种情况下为英语)。我们选择只检索 100 项,并通过将结果加载为 JSON 对象来创建 DataFrame

search_term = 'cryptocurrency'

tweets = tweepy.Cursor(api.search,
                       q=search_term,
                       lang="en").items(100)

retrieved_tweets = [tweet._json for tweet in tweets]
df = pd.json_normalize(retrieved_tweets)

df[['text']].sample(3)

文本
59 嗨!我一直在使用 OKEx,它让购买、出售和存储加密货币(如比特币)变得非常简单和安全。… https://t.co/4m0mpyQTSN
17 今天连接上📉 #getconnected #bitcointrading #Bitcoin #BitcoinCash #bitcoinmining #cryptocurrency https://t.co/J60bCyFPUI
22 RT @stoinkies: 我们已经有了超过 100 位关注者!\n 赠品时间!\n 关注 + 转推 + 喜欢此推文 = 赢取 200 个 Dogecoin!\n 每个参与者还将获得…

我们已成功完成了 API 调用,并可以在上一个表格中看到检索到的推文的文本,这些推文已显示出有趣的方面。例如,我们看到了 RT 这个词的使用,它表示转推(用户分享了另一条推文)。我们看到了表情符号的使用,这是该媒体的一个强烈特征,并且还注意到一些推文被截断了。Twitter 实际上对每条推文所包含的字符数施加了限制,最初为 140 个字符,后来扩展到了 280 个字符。这导致创建了一个 扩展的推文对象,我们必须在使用 Tweepy 检索结果时显式指定它。此外,您必须知道,Twitter 搜索 API 的标准版本仅提供过去一周的结果,必须注册高级或企业版本才能获得历史推文。

注意

每个终端点,Twitter 指定了count的最大值。这是单个响应页面返回的最大结果数。例如,搜索终端点指定了count=100的最大值,而user_timeline的最大值为count=200

让我们扩展我们的搜索,包括与加密货币主题相关的其他关键字,比如crypto,并且暂时过滤转发。这是通过在搜索词中附加带有减号的filter关键字来完成的。我们还指定了希望使用tweet_mode=extended参数获取所有推文的全文。标准搜索 API只搜索过去七天内发布的最新推文样本,但即使这样也可能是一个大数字,为了避免长时间等待来运行蓝图,我们限制了自己的推文数到 12,000 条。我们指定了参数count=30,这是一次调用中可以检索到的最大推文数。因此,我们必须进行 400 次这样的调用来获取我们的数据集,同时考虑到速率限制。这在 API 规定的每 15 分钟 450 个请求的速率限制内。在尝试这个蓝图时,可能会超过这个速率限制,因此我们通过设置wait_on_rate_limit参数启用 Tweepy 提供的自动等待功能。我们还设置了wait_on_rate_limit_notify以便在这种等待时间内得到通知。如果您在速率限制内,以下函数应该在约五分钟内执行完毕:

api = tweepy.API(auth,
                 wait_on_rate_limit=True,
                 wait_on_rate_limit_notify=True,
                 retry_count=5,
                 retry_delay=10)

search_term = 'cryptocurrency OR crypto -filter:retweets'

tweets = tweepy.Cursor(api.search,
                       q=search_term,
                       lang="en",
                       tweet_mode='extended',
                       count=30).items(12000)

retrieved_tweets = [tweet._json for tweet in tweets]

df = pd.json_normalize(retrieved_tweets)
print('Number of retrieved tweets ', len(df))
df[['created_at','full_text','entities.hashtags']].sample(2)

Out:

Number of retrieved tweets  12000
created_at full_text entities.hashtags
10505 Sat Sep 19 22:30:12 +0000 2020 Milk 被创造出来是为了让流动性提供者(持有 LP 代币的人)受益,因为他们可以在 SpaceSwap 抵押 LP 代币,作为奖励他们会得到 MILK 代币以及 0.3%的 UniSwap 佣金。\n\n👇👇👇\nhttps://t.co/M7sGbIDq4W\n#DeFi #加密货币 #UniSwap #另类币 [{'text’: ‘DeFi', ‘indices’: [224, 229]}, {'text’: ‘加密货币', ‘indices’: [230, 236]}, {'text’: ‘UniSwap', ‘indices’: [246, 254]}, {'text’: ‘另类币', ‘indices’: [256, 261]}]
11882 Sat Sep 19 20:57:45 +0000 2020 您可以从我们的策划活动中获得股息。参与的最低要求是 2000 #steem 代理... 通过代理,您不会失去本金。我们可以用#bitcoin 和所有主要的#加密货币处理支付... #加密货币\nhttps://t.co/4b3iH2AI4S [{'text’: ’steem', ‘indices’: [86, 92]}, {'text’: ‘bitcoin', ‘indices’: [195, 203]}, {'text’: ‘cryptocurrencies', ‘indices’: [218, 235]}, {'text’: ‘加密货币', ‘indices’: [239, 244]}]

API 提供了大量信息,如前两条推文的示例所示,其中包含推文发送日期、推文内容等重要元素。Twitter 还返回了多个实体,如包含在推文中的标签,查看讨论加密货币时使用哪些标签将会很有趣:

def extract_entities(entity_list):
    entities = set()
    if len(entity_list) != 0:
        for item in entity_list:
            for key,value in item.items():
                if key == 'text':
                    entities.add(value.lower())
    return list(entities)

df['Entities'] = df['entities.hashtags'].apply(extract_entities)
pd.Series(np.concatenate(df['Entities'])).value_counts()[:25].plot(kind='barh')

上述代码创建了 图 2-4 中显示的图表,显示了与加密货币一起使用的重要标签。其中包括比特币和以太坊等加密货币的示例,以及它们的交易简码 btceth。还涉及到诸如 交易空投 等相关活动。还提到了诸如 金融科技applecash 的实体。乍一看,它已经让您了解到正在讨论的各种术语和实体,交易简码的存在表明这些推文中可能包含一些市场信息。虽然这只是一种实体的简单计数,但我们可以使用此数据集应用更高级的文本分析技术来确定关于加密货币的流行情绪及其实体之间的关系。请注意,由于 Twitter 搜索运行的时间以及 API 的随机选择,结果可能会有所不同。

图 2-4. 讨论加密货币时使用的常见标签。

从用户时间线提取数据

搜索并不是与 Twitter 互动的唯一方式,我们也可以使用 API 按特定用户或账户提取推文。这可能是像著名名人或世界领导人这样的个人,也可能是像体育队这样的组织。例如,假设我们想比较两个流行的一级方程式车队,梅赛德斯和法拉利的推文。我们可以提取他们发送的所有推文,并对比它们的个别风格和它们关注的主要主题。我们提供账户的用户名(MercedesAMGF1),以检索此账户发送的所有推文:

api = tweepy.API(auth, wait_on_rate_limit=True, wait_on_rate_limit_notify=True)

tweets = tweepy.Cursor(api.user_timeline,
                       screen_name='MercedesAMGF1',
                       lang="en",
                       tweet_mode='extended',
                       count=100).items(5000)

retrieved_tweets = [tweet._json for tweet in tweets]
df = pd.io.json.json_normalize(retrieved_tweets)
print ('Number of retrieved tweets ', len(df))

Out:

Number of retrieved tweets  3232

正如您所见,尽管我们请求了 5,000 条推文,但我们只能检索到大约 3,200 条。这是 API 设置的一个 限制。让我们也使用他们的账户用户名 (ScuderiaFerrari) 检索法拉利车队的推文:

def get_user_timeline(screen_name):
    api = tweepy.API(auth,
                     wait_on_rate_limit=True,
                     wait_on_rate_limit_notify=True)
    tweets = tweepy.Cursor(api.user_timeline,
                           screen_name=screen_name,
                           lang="en",
                           tweet_mode='extended',
                           count=200).items()
    retrieved_tweets = [tweet._json for tweet in tweets]
    df = pd.io.json.json_normalize(retrieved_tweets)
    df = df[~df['retweeted_status.id'].isna()]
    return df

df_mercedes = get_user_timeline('MercedesAMGF1')
print ('Number of Tweets from Mercedes', len(df_mercedes))
df_ferrari = get_user_timeline('ScuderiaFerrari')
print ('Number of Tweets from Ferrari', len(df_ferrari))

Out:

Number of Tweets from Mercedes 180
Number of Tweets from Ferrari 203

警告

Tweepy 实现中的一个怪癖是,在转发的情况下,full_text 列会被截断,必须使用 retweeted_status.full_text 列来检索推文的所有字符。对于我们的用例,转发并不重要,我们通过检查 retweeted_status.id 是否为空来过滤它们。然而,根据用例的不同,您可以添加条件,在转发的情况下将 full_text 列替换为 retweeted_status.full_text 列。

移除转发后,每个团队句柄的推文数量显著减少。我们将重复使用来自第一章的词云蓝图,并使用函数 wordcloud 快速可视化两个团队的推文,并识别他们关注的关键词。梅赛德斯的推文似乎主要关注车队参与的比赛,如 tuscangpbritishgpraceday。另一方面,法拉利的推文则宣传他们的商品,如 ferraristore,以及车手,如 enzofittischumachermick

from blueprints.exploration import wordcloud

plt.figure()
wordcloud(df_mercedes['full_text'],
          max_words=100,
          stopwords=df_mercedes.head(5).index)

wordcloud(df_ferrari['full_text'],
          max_words=100,
          stopwords=df_ferrari.head(5).index)

Out:

从流 API 提取数据

一些 API 提供接近实时的数据,也可以称为 流数据。在这种情况下,API 希望将数据“推送”给我们,而不是像我们目前所做的那样等待“获取”请求。Twitter Streaming API 就是一个例子。该 API 实时提供发送的推文样本,并可以根据多个标准进行过滤。由于这是持续的数据流,我们必须以不同的方式处理数据提取过程。Tweepy 在 StreamListener 类中已经提供了基本功能,其中包含 on_data 函数。每当流 API 推送新推文时,将调用此函数,并且我们可以根据特定用例定制它以实施特定逻辑。

继续以加密货币用例为例,假设我们想要对不同加密货币的情绪进行持续更新以进行交易决策。在这种情况下,我们将追踪提到加密货币的实时推文,并持续更新其流行度分数。另一方面,作为研究人员,我们可能对分析用户在重大现场事件(如超级碗或选举结果公布)期间的反应感兴趣。在这种情况下,我们将监听整个事件的持续时间,并将结果存储以进行后续分析。为了使此蓝图通用化,我们创建了如下所示的 FileStreamListener 类,它将管理流入推文的所有操作。对于 Twitter API 推送的每条推文,将调用 on_data 方法。在我们的实现中,我们将传入的推文收集到批次中,每批 100 条,然后带有时间戳写入文件。可以根据系统可用的内存选择不同的批次大小。

from datetime import datetime
import math

class FileStreamListener(tweepy.StreamListener):

    def __init__(self, max_tweets=math.inf):
        self.num_tweets = 0
        self.TWEETS_FILE_SIZE = 100
        self.num_files = 0
        self.tweets = []
        self.max_tweets = max_tweets

    def on_data(self, data):
        while (self.num_files * self.TWEETS_FILE_SIZE < self.max_tweets):
            self.tweets.append(json.loads(data))
            self.num_tweets += 1
            if (self.num_tweets < self.TWEETS_FILE_SIZE):
                return True
            else:
                filename = 'Tweets_' + str(datetime.now().time()) + '.txt'
                print (self.TWEETS_FILE_SIZE, 'Tweets saved to', filename)
                file = open(filename, "w")
                json.dump(self.tweets, file)
                file.close()
                self.num_files += 1
                self.tweets = []
                self.num_tweets = 0
                return True
        return False

    def on_error(self, status_code):
        if status_code == 420:
            print ('Too many requests were made, please stagger requests')
            return False
        else:
            print ('Error {}'.format(status_code))
            return False

要访问流 API,基本的应用程序认证是不够的。我们还必须提供用户认证,这可以在之前显示的同一页找到。这意味着流 API 请求是由我们创建的应用程序代表用户(在本例中是我们自己的帐户)发出的。这也意味着我们必须使用 OAuthHandler 类,而不是我们目前使用的 AppAuthHandler

user_access_token = 'YOUR_USER_ACCESS_TOKEN_HERE'
user_access_secret = 'YOUR_USER_ACCESS_SECRET_HERE'

auth = tweepy.OAuthHandler(app_api_key, app_api_secret_key)
auth.set_access_token(user_access_token, user_access_secret)
api = tweepy.API(auth, wait_on_rate_limit=True, wait_on_rate_limit_notify=True)

在初始化FileStreamListener对象时,我们还指定了希望提取的最大推文数。这充当停止条件,如果未指定,则进程将持续运行,直到用户终止或由于服务器错误而停止。我们通过传递认证对象(api.auth)和管理流的对象(fileStreamListener)来初始化 Twitter 流。我们还要求提供扩展推文。完成这些步骤后,我们可以使用过滤函数并提供想要跟踪的关键字来开始跟踪流中的实时推文:

fileStreamListener = FileStreamListener(5000)
fileStream = tweepy.Stream(auth=api.auth,
                           listener=fileStreamListener,
                           tweet_mode='extended')
fileStream.filter(track=['cryptocurrency'])

如果你希望在单独的线程中运行提取器,可以将关键字async=True传递给过滤函数,这将在单独的线程中持续运行。一旦它运行一段时间并存储了推文,我们可以像以前一样将其读入 Pandas 的DataFrame中。当发生错误时,FileStreamListener不会尝试重试,而是仅打印错误status_code。建议您实现失败处理并自定义on_data方法以适应使用情况。

这些蓝图仅提供了访问流行 API 进行数据提取的指导。由于每个 API 都不同,相应的 Python 模块提供的功能也会不同。例如,Wikipedia是另一个用于提取文本数据的流行来源,而wikipediaapi是支持此数据提取的 Python 模块之一。可以通过命令**pip install wikipediaapi**来安装它,由于这是一个公开可用的数据源,因此不需要进行身份验证或生成访问令牌。您只需要指定维基百科的版本(语言)和您要提取数据的主题名称。以下代码片段显示了下载“加密货币”维基百科条目的步骤,并显示了该文章的前几行:

import wikipediaapi

wiki_wiki = wikipediaapi.Wikipedia(
        language='en',
        extract_format=wikipediaapi.ExtractFormat.WIKI
)

p_wiki = wiki_wiki.page('Cryptocurrency')
print (p_wiki.text[:200], '....')

Out:

A cryptocurrency (or crypto currency) is a digital asset designed to work
as a medium of exchange wherein individual coin ownership records are stored
in a ledger existing in a form of computerized da ....

结语

在本章中,我们首先介绍了蓝图,这些蓝图利用 Python 的 requests 库进行 API 调用和数据提取。我们还介绍了如何处理分页结果、速率限制和重试。这些蓝图适用于任何类型的 API,并且非常适合如果您希望控制和定制数据提取的多个方面。在下一组蓝图中,我们使用了 Tweepy 从 Twitter API 提取数据。这是一个由社区开发的 Python 库的示例,支持流行的 API,并提供经过测试的功能。您通常不必担心实现自己的分页或回退策略,因此这是少了一个要担心的事情。如果您的使用情况需要从流行的 API 获取数据,那么使用这样一个现成的包非常方便。

^(1) 延迟被定义为 time_delay={backoff factor} * (2 ** ({number of total retries} - 1)),在连续调用之间引入。

第三章:网站抓取和数据提取

经常会发生这样的情况,你访问一个网站并发现内容很有趣。如果只有几页,可能可以自己阅读所有内容。但是一旦有大量内容,就不可能自己阅读所有内容了。

要使用本书描述的强大文本分析蓝图,首先必须获取内容。大多数网站不会有“下载所有内容”按钮,因此我们必须找到一个巧妙的方法来下载(“抓取”)页面。

通常我们主要对每个网页的内容部分感兴趣,对导航等不太感兴趣。一旦我们在本地有了数据,我们可以使用强大的提取技术来将页面分解为标题、内容以及一些元信息(发布日期、作者等)。

你将学到的内容及我们将要构建的东西

在本章中,我们将向你展示如何从网站获取 HTML 数据,并使用强大的工具从这些 HTML 文件中提取内容。我们将以一个特定数据源,路透社新闻存档中的内容为例。

在第一步中,我们将下载单个 HTML 文件,并使用不同的方法从每个文件中提取数据。

通常你不会对单个页面感兴趣。因此,我们将建立一个蓝图解决方案。我们将下载并分析一个新闻存档页面(其中包含所有文章的链接)。完成后,我们将知道所引用文档的 URL。然后你可以下载这些 URL 的文档并提取它们的内容到 Pandas 的DataFrame中。

学习完本章后,你将对下载 HTML 和提取数据的方法有一个很好的概述。你将熟悉 Python 提供的不同内容提取方法。我们将看到一个完整的示例,用于下载和提取数据。对于你自己的工作,你将能够选择一个合适的框架。在本章中,我们将提供用于提取经常使用的元素的标准蓝图,你可以重复使用。

抓取和数据提取

网站抓取是一个复杂的过程,通常包括三个不同阶段,如图 3-1 所示。

图 3-1。抓取过程概述。

在第一步中,我们必须生成网站所有有趣的 URL。然后,我们可以使用不同的工具从相应的 URL 下载页面。最后,我们将从下载的页面中提取“净”数据;在此阶段我们也可以使用不同的策略。当然,永久保存提取的数据是至关重要的。在本章中,我们使用 Pandas 的DataFrame,它提供了各种持久化机制。

介绍路透社新闻存档

假设我们对分析当前和过去的政治局势感兴趣,并正在寻找适当的数据集。我们希望找到一些趋势,揭示一个词或主题何时首次引入等等。为此,我们的目标是将文档转换为一个 Pandas DataFrame

显然,新闻头条和文章非常适合作为这些需求的数据库。如果可能的话,我们应该找到一个档案,可以追溯几年,甚至几十年。

一些报纸拥有这样的档案,但大多数也会有一定的政治偏见,如果可能的话,我们希望避免这种情况。我们正在寻找尽可能中立的内容。

这就是我们决定使用路透社新闻档案的原因。路透社是一家国际新闻组织,作为新闻机构运作;换句话说,它向许多不同的出版物提供新闻。它成立了一百多年,档案中有大量新闻文章。由于许多原因,它是内容的良好来源:

  • 它在政治上是中立的。

  • 它拥有大量的新闻档案。

  • 新闻文章被分类在不同的部分中。

  • 焦点不在于特定的地区。

  • 几乎每个人都会在那里找到一些有趣的头条新闻。

  • 它对下载数据有宽松的政策。

  • 它的连接速度非常快。

URL 生成

要从路透社档案下载内容,我们需要知道内容页面的 URL。一旦知道了 URL,下载本身就很容易,因为有强大的 Python 工具可以实现这一点。

乍一看,找到网址似乎很容易,但实际上往往并非如此简单。这个过程称为URL 生成,在许多爬行项目中,这是最困难的任务之一。我们必须确保不会系统性地错过网址;因此,在开始时仔细思考这个过程至关重要。如果正确执行,URL 生成也可以节省大量时间。

在下载之前

注意:有时下载数据是非法的。规则和法律情况可能取决于数据托管的国家及其下载到的国家。通常,网站会有一个名为“使用条款”或类似的页面,值得一看。

如果数据只是临时保存,同样的规则也适用于搜索引擎。就像 Google 等搜索引擎无法阅读和理解它们索引的每一页的使用条款一样,有一个非常老的协议称为robots 排除标准。使用这个的网站在顶级有一个名为robots.txt的文件。这个文件可以自动下载和解释。对于单个网站,也可以手动读取并解释数据。经验法则是,如果没有Disallow: *,你应该被允许下载和(暂时)保存内容。

有许多不同的可能性:

爬取

从网站的主页(或某一部分)开始,下载同一网站上的所有链接。爬行可能需要一些时间。

URL 生成器

编写一个 URL 生成器是一个稍微复杂一点的解决方案。这在像论坛、博客等分级组织内容的地方最为适用。

搜索引擎

请求搜索引擎获取特定的 URL,并仅下载这些特定的 URL。

网站地图

一个名为sitemap.xml的标准,最初是为搜索引擎而设计的,是一个有趣的替代方案。一个名为 sitemap.xml 的文件包含网站上所有页面的列表(或子站点的引用)。与 robots.txt 相反,文件名并非固定,有时可以在 robots.txt 中找到。最好的猜测是在网站的顶级目录中查找 sitemap.xml

RSS

RSS 格式最初是为新闻订阅而设计,并且仍然广泛用于订阅内容频繁变化的来源。它通过 XML 文件工作,不仅包含 URL,还包括文档标题,有时还有文章摘要。

专业程序

通过使用在 GitHub 上可用的专门程序(如 Facebook 聊天下载器 用于 Facebook 聊天,Instaloader 用于 Instagram 等),简化从社交网络和类似内容的下载数据。

在以下各节中,我们将重点放在 robots.txtsitemaps.xml 和 RSS 订阅上。本章稍后,我们将展示使用 URL 生成器的多阶段下载。

注意:如果有 API 可用,请使用 API 下载数据

使用 API 而不是生成 URL、下载内容和提取内容,更简单且更稳定。关于此,您将在 第二章 中找到更多信息。

蓝图:下载和解释 robots.txt

在网站上找到内容通常并不那么容易。为了看到前面提到的技术实际操作,我们将查看 Reuters 新闻档案。当然,(几乎)任何其他网站都会以类似的方式工作。

正如讨论的,robots.txt 是一个很好的起点:

# robots_allow.txt for www.reuters.com
# Disallow: /*/key-developments/article/*

User-agent: *
Disallow: /finance/stocks/option
[...]
Disallow: /news/archive/commentary

SITEMAP: https://www.reuters.com/sitemap_index.xml
SITEMAP: https://www.reuters.com/sitemap_news_index.xml
SITEMAP: https://www.reuters.com/sitemap_video_index.xml
SITEMAP: https://www.reuters.com/sitemap_market_index.xml
SITEMAP: https://www.reuters.com/brandfeature/sitemap

User-agent: Pipl
Disallow: /
[...]

有些用户代理程序不被允许下载任何内容,但其他用户可以这样做。我们可以用 Python 程序来检查这一点:

import urllib.robotparser
rp = urllib.robotparser.RobotFileParser()
rp.set_url("https://www.reuters.com/robots.txt")
rp.read()
rp.can_fetch("*", "https://www.reuters.com/sitemap.xml")

Out:

True

蓝图:从 sitemap.xml 查找 URL

Reuters 甚至友好地提到了新闻 站点地图 的 URL,实际上只包含对 其他站点地图文件 的引用。让我们下载它。撰写时的节选如下:^(1)

[...]
<url>
  <loc>https://www.reuters.com/article/
us-health-vaping-marijuana-idUSKBN1WG4KT</loc>
  <news:news>
    <news:publication>
      <news:name>Reuters</news:name>
      <news:language>eng</news:language>
    </news:publication>
    <news:publication_date>2019-10-01T08:37:37+00:00</news:publication_date>
    <news:title>Banned in Boston: Without vaping, medical marijuana patients
               must adapt</news:title>
    <news:keywords>Headlines,Credit RSS</news:keywords>
  </news:news>
</url>
[...]

最有趣的部分是带有 <loc> 的行,因为它包含文章的 URL。过滤掉所有这些 <loc> 行将导致一个可以随后下载的新闻文章 URL 列表。

由于 Python 拥有一个非常丰富的库生态系统,找到一个站点地图解析器并不难。有几种可用,比如ultimate-sitemap-parser。然而,这种解析器下载整个站点地图层次结构,对于我们来说有点过于复杂,因为我们只需 URL。

sitemap.xml转换为 Python 中称为dict的关联数组(哈希)非常容易:^(2)

import xmltodict
import requests

sitemap = xmltodict.parse(requests.get(
          'https://www.reuters.com/sitemap_news_index1.xml').text)

让我们在实际下载文件之前检查一下dict中有什么内容^(3):

urls = [url["loc"] for url in sitemap["urlset"]["url"]]
# just print the first few URLs to avoid using too much space
print("\n".join(urls0:3))

Out:

https://www.reuters.com/article/us-japan-fukushima/ex-tepco-bosses-cleared-
over-fukushima-nuclear-disaster-idUSKBN1W40CP
https://www.reuters.com/article/us-global-oil/oil-prices-rise-as-saudi-supply-
risks-come-into-focus-idUSKBN1W405X
https://www.reuters.com/article/us-saudi-aramco/iran-warns-against-war-as-us-
and-saudi-weigh-response-to-oil-attack-idUSKBN1W40VN

我们将在下一节中使用这些 URL 列表并下载它们的内容。

蓝图:从 RSS 中找到 URL

由于路透社是一个新闻网站,它也通过 RSS 提供其文章的访问。几年前,如果你可以订阅这个源,浏览器会在 URL 旁边显示一个 RSS 图标。虽然那些日子已经过去,但现在仍然不难找到 RSS 源的 URL。在网站底部,我们可以看到一行带有导航图标的内容,如[图 3-2 所示。

图 3-2。链接到 RSS 源的路透社网站的一部分。

看起来像 WIFI 指示器的图标是指向 RSS 订阅页面的链接。通常(有时更容易),这可以通过查看相应网页的源代码并搜索RSS来找到。

世界新闻的 RSS 源 URL 是http://feeds.reuters.com/Reuters/worldNews^(4),在 Python 中可以轻松解析如下:

import feedparser
feed = feedparser.parse('http://feeds.reuters.com/Reuters/worldNews')

RSS 文件的具体格式可能因网站而异。然而,大多数情况下,我们会找到标题和链接作为字段^(5):

[(e.title, e.link) for e in feed.entries]

Out:

[('Cambodian police search for British woman, 21, missing from beach',
  'http://feeds.reuters.com/~r/Reuters/worldNews/~3/xq6Hy6R9lxo/cambodian-
police-search-for-british-woman-21-missing-from-beach-idUSKBN1X70HX'),
 ('Killing the leader may not be enough to stamp out Islamic State',
  'http://feeds.reuters.com/~r/Reuters/worldNews/~3/jbDXkbcQFPA/killing-the-
leader-may-not-be-enough-to-stamp-out-islamic-state-idUSKBN1X7203'), [...]
]

在我们的情况下,我们更感兴趣的是包含在id字段中的“真实”URL:

[e.id for e in feed.entries]

Out:

['https://www.reuters.com/article/us-cambodia-britain-tourist/cambodian-
police-search-for-british-woman-21-missing-from-beach-
idUSKBN1X70HX?feedType=RSS&feedName=worldNews',
 'https://www.reuters.com/article/us-mideast-crisis-baghdadi-future-analys/
killing-the-leader-may-not-be-enough-to-stamp-out-islamic-state-
idUSKBN1X7203?feedType=RSS&feedName=worldNews',
 'https://www.reuters.com/article/us-britain-eu/eu-approves-brexit-delay-
until-january-31-as-pm-johnson-pursues-election-
idUSKBN1X70NT?feedType=RSS&feedName=worldNews', [...]
]

太好了,我们找到了一种替代方法,可以在没有sitemap.xml的情况下获取 URL 列表。

有时你仍会遇到所谓的Atom feeds,它们基本上以不同的格式提供与 RSS 相同的信息。

如果你想要实现一个网站监控工具,定期查看路透社新闻(或其他新闻源)或 RSS(或 Atom)是一个不错的方法。

如果你对整个网站感兴趣,寻找sitemap.xml是一个绝佳的主意。有时可能会很难找到(提示可能在robots.txt中),但多数情况下额外努力去找它几乎总是值得的。

如果找不到sitemap.xml并且你计划定期下载内容,那么转向 RSS 是一个很好的第二选择。

在可能的情况下,尽量避免对 URL 进行爬取。这个过程很难控制,可能需要很长时间,并且可能得到不完整的结果。

下载数据

乍一看,下载数据可能看起来是网页抓取过程中最困难和耗时的部分。通常情况下,并非如此,因为您可以以高度标准化的方式完成它。

在本节中,我们展示了使用 Python 库和外部工具下载数据的不同方法。特别是对于大型项目,使用外部程序具有一些优势。

与几年前相比,今天的互联网速度快得多。大型网站已经通过使用内容交付网络做出了反应,这可以将它们的速度提高几个数量级。这对我们非常有帮助,因为实际的下载过程并不像过去那样慢,而是更多地受限于我们自己的带宽。

蓝图:使用 Python 下载 HTML 页面

要下载 HTML 页面,需要知道 URL。正如我们所见,URL 包含在站点地图中。让我们使用这个列表来下载内容:

%%time
s = requests.Session()
for url in urls[0:10]:
    # get the part after the last / in URL and use as filename
    file = url.split("/")[-1]

    r = s.get(url)
    if r.ok:
        with open(file, "w+b") as f:
            f.write(r.text.encode('utf-8'))
    else:
        print("error with URL %s" % url)

Out:

CPU times: user 117 ms, sys: 7.71 ms, total: 124 ms
Wall time: 314 ms

根据您的互联网连接速度不同,可能需要更长时间,但下载速度相当快。通过使用会话抽象,我们通过利用保持活动状态、SSL 会话缓存等来确保最大速度。

在下载 URL 时使用适当的错误处理

下载 URL 时,您正在使用网络协议与远程服务器通信。可能会发生许多种错误,例如 URL 更改、服务器未响应等。这个例子只是显示了一个错误消息;在实际生活中,您的解决方案可能需要更加复杂。

蓝图:使用 wget 下载 HTML 页面

用于大规模下载页面的好工具是wget,这是一个几乎所有平台都可用的命令行工具。在 Linux 和 macOS 上,wget应该已经安装或可以通过包管理器轻松安装。在 Windows 上,可以在https://oreil.ly/2Nl0b获取到一个端口。

wget支持 URL 列表进行下载和 HTTP 保持活动状态。通常,每个 HTTP 请求需要单独的 TCP 连接(或 Diffie-Hellman 密钥交换;参见“高效下载的技巧”)。wget-nc选项将检查文件是否已经下载过。这样,我们可以避免重复下载内容。现在我们随时可以停止进程并重新启动而不会丢失数据,这在 Web 服务器阻止我们、互联网连接中断等情况下非常重要。让我们将上一个蓝图中的 URL 列表保存到文件中,并将其用作下载的模板:

with open("urls.txt", "w+b") as f:
    f.write("\n".join(urls).encode('utf-8'))

现在去你的命令行(或 Jupyter 中的终端标签)并调用wget

wget -nc -i urls.txt

-i选项告诉wget要下载的 URL 列表。看到wget由于-nc选项跳过现有文件(很有趣)以及下载速度的快慢。

wget也可以用于递归下载网站,使用选项-r

锁定的危险!

要小心,这可能导致长时间运行的进程,最终可能导致您被锁定在网站外。在尝试递归下载时,将-r-l(递归级别)结合使用通常是一个好主意。

有几种不同的下载数据的方式。对于中等数量的页面(如几百到一千页),直接在 Python 程序中下载是标准的方式。我们推荐使用requests库,因为它易于使用。

下载超过几千页通常最好通过多阶段过程来完成,首先生成 URL 列表,然后通过专用程序(如wget)在外部下载它们。

提取半结构化数据

在接下来的部分中,我们将探讨从路透社文章中提取数据的不同方法。我们将从使用正则表达式开始,然后转向完整的 HTML 解析器。

最终我们将对多篇文章的数据感兴趣,但作为第一步,我们将集中精力在一篇文章上。让我们以“波士顿禁用:没有电子烟,医用大麻患者必须适应”为例。

蓝图:使用正则表达式提取数据

浏览器将是剖析文章的最重要工具之一。首先打开 URL 并使用“查看源代码”功能。在第一步中,我们可以看到标题很有趣。查看 HTML,标题被<title><h1>包围。

[...]
<title>Banned in Boston: Without vaping, medical marijuana patients
must adapt - Reuters</title>
[...]
<h1 class="ArticleHeader_headline">Banned in Boston: Without vaping,
medical marijuana patients must adapt</h1>
[...]

HTML 代码随时间变化

本节描述的程序使用了在撰写本书时当前的 HTML 代码。但是,出版商可以随时更改其网站结构甚至删除内容。一个替代方法是使用来自网络档案馆的数据。路透社网站在那里被镜像,快照被保留以保持布局和 HTML 结构。

还要查看该书的 GitHub 存档。如果布局发生了变化,并且程序无法再正常工作,那里将提供替代链接(和网站地图)。

通过正则表达式,可以以编程方式提取标题而无需使用其他库。让我们首先下载文章并将其保存到名为us-health-vaping-marijuana-idUSKBN1WG4KT.html的本地文件中。

import requests

url = 'https://www.reuters.com/article/us-health-vaping-marijuana-idUSKBN1WG4KT'

# use the part after the last / as filename
file = url.split("/")[-1] + ".html"
r = requests.get(url)
with open(file, "w+b") as f:
    f.write(r.text.encode('utf-8'))

提取标题的 Python 蓝图可能如下所示:

import re

with open(file, "r") as f:
  html = f.read()
  g = re.search(r'<title>(.*)</title>', html, re.MULTILINE|re.DOTALL)
  if g:
    print(g.groups()[0])

输出:

Banned in Boston: Without vaping, medical marijuana patients must adapt - Reuters

re库没有完全整合到 Python 字符串处理中。换句话说,它不能作为字符串的方法调用。由于我们的 HTML 文档由许多行组成,因此我们必须使用re.MULTILINE|re.DOTALL。有时需要级联调用re.search,但这确实使代码难以阅读。

在 Python 中,使用re.search而不是re.match至关重要,这与许多其他编程语言不同。后者试图匹配整个字符串,并且由于在<title>之前和</title>之后有数据,它会失败。

蓝图:使用 HTML 解析器进行提取

文章还有更多有趣的部分,使用正则表达式提取起来很繁琐。在文章中有文本,与之相关的出版日期以及作者的名称。使用 HTML 解析器(^(6))可以更容易地实现这一点。幸运的是,Python 包 Beautiful Soup 可以很好地处理这些任务。如果尚未安装 Beautiful Soup,请使用pip install bs4conda install bs4进行安装。Beautiful Soup 很宽容,也可以解析通常在管理不善的网站上找到的“不良”HTML。

接下来的几节利用了新闻档案中所有文章具有相同的结构这一事实。幸运的是,这对大多数大型网站来说是真实的,因为这些页面不是手工制作的,而是由内容管理系统从数据库生成的。

提取标题/头条

在 Beautiful Soup 中选择内容使用所谓的选择器,在 Python 程序中需要提供这些选择器。找到它们有些棘手,但有结构化的方法可以解决。几乎所有现代浏览器都支持 Web Inspector,用于查找 CSS 选择器。在加载文章时在浏览器中打开 Web Inspector(通常按 F12 键即可),然后单击 Web Inspector 图标,如图 3-3 所示。

图 3-3. Chrome 浏览器中的 Web Inspector 图标。

悬停在标题上,您将看到相应的元素突出显示,如图 3-4 所示。

图 3-4. 使用 Web Inspector 的 Chrome 浏览器。

单击标题以在 Web Inspector 中显示它。它应该看起来像这样:

<h1 class="ArticleHeader_headline">Banned in Boston: Without vaping, medical
marijuana patients must adapt</h1>

使用 CSS 表示法,^(7)可以通过h1.ArticleHeader_headline选择此元素。Beautiful Soup 理解到:

from bs4 import Beautiful Soup
soup = Beautiful Soup(html, 'html.parser')
soup.select("h1.ArticleHeader_headline")

Out:

[<h1 class="ArticleHeader_headline">Banned in Boston: Without vaping, medical
marijuana patients must adapt</h1>]

Beautiful Soup 使得这更加简单,让我们可以直接使用标签名:

soup.h1

Out:

<h1 class="ArticleHeader_headline">Banned in Boston: Without vaping, medical
marijuana patients must adapt</h1>

通常,前一个 HTML 片段中最有趣的部分是没有 HTML 混杂物围绕的真实文本。Beautiful Soup 可以提取这部分内容:

soup.h1.text

Out:

'Banned in Boston: Without vaping, medical marijuana patients must adapt'

请注意,与正则表达式解决方案相比,Beautiful Soup 已经去除了不必要的空格。

不幸的是,这对标题的效果不太好:

soup.title.text

Out:

'\n                Banned in Boston: Without vaping, medical marijuana patients
must adapt - Reuters'

在这里,我们需要手动剥离数据并消除- Reuters后缀。

提取文章正文

与之前描述的查找标题选择器的过程类似,您可以轻松找到选择器div.StandardArticleBody_body中的文本内容。在使用select时,Beautiful Soup 返回一个列表。从底层的 HTML 结构可以明显看出,该列表仅包含一个项目,或者我们只对第一个元素感兴趣。在这里我们可以使用方便的方法select_one

soup.select_one("div.StandardArticleBody_body").text

Out:

"WASHINGTON (Reuters) - In the first few days of the four-month ban [...]"

提取图像标题

但是,请注意,除了文本之外,此部分还包含带有可能单独相关的标题的图片。因此,再次使用 Web Inspector 悬停在图片上并找到相应的 CSS 选择器。所有图片都包含在 <figure> 元素中,因此让我们选择它们:

soup.select("div.StandardArticleBody_body figure img")

Out:

[<img aria-label="FILE PHOTO: An employee puts down an eighth of an ounce
  marijuana after letting a customer smell it outside the Magnolia cannabis
  lounge in Oakland, California, U.S. April 20, 2018. REUTERS/Elijah Nouvelage"
  src="//s3.reutersmedia.net/resources/r/
  ?m=02&amp;d=20191001&amp;t=2&amp;i=1435991144&amp;r=LYNXMPEF90
  39L&amp;w=20"/>, <img src="//s3.reutersmedia.net/resources/r/
  ?m=02&amp;d=20191001&amp;t=2&amp;i=1435991145&amp;r=LYNXMPEF90
  39M"/>]

仔细检查结果,此代码仅包含一个图像,而浏览器显示许多图像。这是在网页中经常可以找到的一种模式。图像的代码不在页面本身中,而是由客户端 JavaScript 后添加。技术上这是可能的,尽管不是最佳的风格。从内容的角度来看,如果图像源包含在原始生成的服务器页面中,并通过 CSS 后来可见,那将更好。这也将有助于我们的提取过程。总之,我们更感兴趣的是图像的标题,因此正确的选择器应该是将 img 替换为 figcaption

soup.select("div.StandardArticleBody_body figcaption")

Out:

[<figcaption><div class="Image_caption"><span>FILE PHOTO:
  An employee puts down an eighth of an ounce marijuana after letting a
  customer smell it outside the Magnolia cannabis lounge in Oakland,
  California, U.S. April 20, 2018. REUTERS/Elijah Nouvelage</span></
  div></figcaption>,

 <figcaption class="Slideshow_caption">Slideshow<span class="Slideshow_count">
  (2 Images)</span></figcaption>]

提取 URL

在下载许多 HTML 文件时,如果它们未单独保存,通常很难找到文件的原始 URL。此外,URL 可能会更改,通常最好使用标准的(称为 canonical)URL。幸运的是,有一个 HTML 标签称为 <link rel="canonical">,可以用于此目的。该标签不是强制性的,但它非常常见,因为搜索引擎也会考虑它,有助于良好的排名:

soup.find("link", {'rel': 'canonical'})['href']

Out:

'https://www.reuters.com/article/us-health-vaping-marijuana-idUSKBN1WG4KT'

提取列表信息(作者)

查看源代码,文章的作者在 <meta name="Author"> 标签中提到。

soup.find("meta", {'name': 'Author'})['content']

Out:

'Jacqueline Tempera'

然而,这只返回了一个作者。阅读文本,还有另一个作者,但不幸的是,这并未包含在页面的元信息中。当然,可以通过在浏览器中选择元素并使用 CSS 选择器再次提取:

sel = "div.BylineBar_first-container.ArticleHeader_byline-bar \
 div.BylineBar_byline span"
soup.select(sel)

Out:

[<span><a href="/journalists/jacqueline-tempera" target="_blank">
  Jacqueline Tempera</a>, </span>,
 <span><a href="/journalists/jonathan-allen" target="_blank">
  Jonathan Allen</a></span>]

提取作者姓名非常直接:

[a.text for a in soup.select(sel)]

Out:

['Jacqueline Tempera, ', 'Jonathan Allen']

语义和非语义内容

与前面的例子相比,sel 选择器不是 语义 的。选择是基于布局类似的类。目前这种方式效果不错,但如果布局改变,很可能会出现问题。因此,如果代码不仅仅是一次性或批处理运行,而且将来还应该运行,则最好避免这些类型的选择。

提取链接的文本(章节)

这一部分很容易提取。再次使用 Web Inspector,我们可以找到以下 CSS 选择器:

soup.select_one("div.ArticleHeader_channel a").text

Out:

'Politics'

提取阅读时间

通过 Web Inspector 可以轻松找到阅读时间:

soup.select_one("p.BylineBar_reading-time").text

Out:

'6 Min Read'

提取属性(ID)

拥有唯一标识文章的主键是有帮助的。ID 也出现在 URL 中,但可能需要一些启发式和高级分割才能找到它。使用浏览器的查看源代码功能并搜索此 ID,我们看到它是文章容器的 id 属性:

soup.select_one("div.StandardArticle_inner-container")['id']

Out:

'USKBN1WG4KT'

提取归属信息

除了作者外,文章还有更多的归属。它们可以在文本末尾找到,并放置在一个特殊的容器中:

soup.select_one("p.Attribution_content").text

Out:

'Reporting Jacqueline Tempera in Brookline and Boston, Massachusetts, and
Jonathan Allen in New York; Editing by Frank McGurty and Bill Berkrot'

提取时间戳

对于许多统计目的来说,知道文章发布的时间非常关键。这通常在部分旁边提到,但不幸的是它是以人类可读的方式构建的(如“3 天前”)。这可以被解析,但很繁琐。知道真实的发布时间后,可以在 HTML 头元素中找到正确的元素:

ptime = soup.find("meta", { 'property': "og:article:published_time"})['content']
print(ptime)

Out:

2019-10-01T19:23:16+0000

一个字符串已经很有帮助了(特别是在后面我们将看到的表示法中),但 Python 提供了将其轻松转换为日期时间对象的功能:

from dateutil import parser
parser.parse(ptime)

Out:

datetime.datetime(2019, 10, 1, 19, 23, 16, tzinfo=tzutc())

如果更相关的话,也可以对modified_time而不是published_time执行相同操作。

仅使用正则表达式进行粗略提取。HTML 解析器速度较慢,但使用起来更加简单且更稳定。

通常,查看文档的语义结构并使用具有语义类名的 HTML 标签是有意义的,以找到结构元素的值。这些标签的优势在于它们在大量网页上都是相同的。因此,只需要实现一次其内容的提取,就可以重复使用。

除了极端简单的情况外,尽量在可能的情况下使用 HTML 解析器。以下侧栏讨论了几乎在任何 HTML 文档中都可以找到的一些标准结构。

蓝图:爬虫

到目前为止,我们已经看过如何下载网页并使用 HTML 解析技术提取内容。从业务角度来看,查看单个页面通常并不那么有趣,但是你想要看到整体图片。为此,你需要更多的内容。

幸运的是,我们掌握的知识可以结合起来下载内容档案或整个网站。这通常是一个多阶段的过程,首先需要生成 URL,下载内容,找到更多 URL,依此类推。

本节详细解释了其中一个这样的“爬虫”示例,并创建了一个可扩展的蓝图,可用于下载成千上万(甚至百万)页。

引入使用案例

解析单个路透社文章是一个不错的练习,但路透社档案规模更大,包含许多文章。也可以使用我们已经涵盖的技术来解析更多内容。想象一下,你想要下载和提取,例如,一个带有用户生成内容的整个论坛或一个包含科学文章的网站。正如之前提到的,通常最难找到正确的文章 URL。

不过在这种情况下并非如此。可以使用sitemap.xml,但路透社慷慨地提供了一个专门的存档页面https://www.reuters.com/news/archive。还提供了分页功能,因此可以向前回溯时间。

图 3-5 展示了下载存档部分(称为爬虫)的步骤。该过程如下:

  1. 定义应下载存档的页数。

  2. 将存档的每一页下载到名为page-000001.htmlpage-000002.html等文件中,以便更轻松地检查。如果文件已经存在,则跳过此步骤。

  3. 对于每个page-.html*文件,提取引用文章的 URL。

  4. 对于每个文章的网址,将文章下载到本地的 HTML 文件中。如果文章文件已经存在,则跳过此步骤。

  5. 对于每个文章文件,提取内容到一个dict中,并将这些dict组合成一个 Pandas 的DataFrame

图 3-5. 爬虫流程图。

在更通用的方法中,可能需要在步骤 3 中创建中间网址(如果有年份、月份等的概述页面)才能最终到达文章网址。

该过程的构造方式使得每个步骤都可以单独运行,并且只需要执行一次下载。这被证明非常有用,特别是当我们需要提取大量文章/网址时,因为单个缺失的下载或格式不正确的 HTML 页面并不意味着必须重新开始整个下载过程。此外,该过程随时可以重新启动,并且仅下载尚未下载的数据。这称为幂等性,在与“昂贵”的 API 交互时经常是一个有用的概念。

最终的程序看起来是这样的:

import requests
from bs4 import Beautiful Soup
import os.path
from dateutil import parser

def download_archive_page(page):
    filename = "page-%06d.html" % page
    if not os.path.isfile(filename):
        url = "https://www.reuters.com/news/archive/" + \
              "?view=page&page=%d&pageSize=10" % page
        r = requests.get(url)
        with open(filename, "w+") as f:
            f.write(r.text)

def parse_archive_page(page_file):
    with open(page_file, "r") as f:
        html = f.read()

    soup = Beautiful Soup(html, 'html.parser')
    hrefs = ["https://www.reuters.com" + a['href']
               for a in soup.select("article.story div.story-content a")]
    return hrefs

def download_article(url):
    # check if article already there
    filename = url.split("/")[-1] + ".html"
    if not os.path.isfile(filename):
        r = requests.get(url)
        with open(filename, "w+") as f:
            f.write(r.text)

def parse_article(article_file):
    with open(article_file, "r") as f:
        html = f.read()
    r = {}
    soup = Beautiful Soup(html, 'html.parser')
    r['id'] = soup.select_one("div.StandardArticle_inner-container")['id']
    r['url'] = soup.find("link", {'rel': 'canonical'})['href']
    r['headline'] = soup.h1.text
    r['section'] = soup.select_one("div.ArticleHeader_channel a").text    
    r['text'] = soup.select_one("div.StandardArticleBody_body").text
    r['authors'] = [a.text
                    for a in soup.select("div.BylineBar_first-container.\
 ArticleHeader_byline-bar\
                                          div.BylineBar_byline span")]
    r['time'] = soup.find("meta", { 'property':
                                    "og:article:published_time"})['content']
    return r

定义了这些函数后,它们可以用参数调用(这些参数可以很容易地更改):

# download 10 pages of archive
for p in range(1, 10):
    download_archive_page(p)

# parse archive and add to article_urls
import glob

article_urls = []
for page_file in glob.glob("page-*.html"):
    article_urls += parse_archive_page(page_file)

# download articles
for url in article_urls:
    download_article(url)

# arrange in pandas DataFrame
import pandas as pd

df = pd.DataFrame()
for article_file in glob.glob("*-id???????????.html"):
    df = df.append(parse_article(article_file), ignore_index=True)

df['time'] = pd.to_datetime(df.time)

错误处理和生产质量软件

为简单起见,本章讨论的所有示例程序都不使用错误处理。然而,对于生产软件,应使用异常处理。由于 HTML 可能会经常变化且页面可能不完整,错误可能随时发生,因此大量使用 try/except 并记录错误是一个好主意。如果出现系统性错误,应找出根本原因并消除它。如果错误只是偶尔发生或由于格式不正确的 HTML 导致的,您可能可以忽略它们,因为这可能也是由服务器软件引起的。

使用前面描述的下载和保存文件机制,提取过程随时可以重新启动,也可以单独应用于某些有问题的文件。这通常是一个很大的优势,有助于快速获得干净的提取数据集。

生成 URL 通常与提取内容一样困难,并且经常与之相关。在许多情况下,这必须重复多次以下载例如分层内容。

在下载数据时,始终为每个网址找到一个文件名并保存到文件系统中。你将不得不比你想象中更频繁地重新启动这个过程。避免反复下载所有内容非常有用,特别是在开发过程中。

如果您已经下载并提取了数据,您可能希望将其持久化以供以后使用。一种简单的方法是将其保存在单独的 JSON 文件中。如果您有很多文件,使用目录结构可能是一个不错的选择。随着页面数量的增加,即使这样也可能扩展性不佳,最好使用数据库或其他列存储数据存储解决方案。

密度基础文本提取

从 HTML 中提取结构化数据并不复杂,但很繁琐。如果您想从整个网站提取数据,那么值得付出努力,因为您只需针对有限数量的页面类型实施提取。

但是,您可能需要从许多不同的网站提取文本。针对每个网站实施提取并不具有良好的可扩展性。有一些元数据可以很容易找到,比如标题、描述等。但是文本本身并不那么容易找到。

从信息密度的角度来看,有一些启发式方法允许提取文本。其背后的算法测量了信息密度,因此自动消除了重复信息,如标题、导航、页脚等。实施起来并不简单,但幸运的是在名为python-readability的库中已经提供了。其名称源自一个现在已经被废弃的浏览器插件 Readability,它的构想是从网页中移除混乱内容,使其易于阅读,这正是这里所需要的。要开始使用,我们首先必须安装python-readabilitypip install readability-lxml)。

使用 Readability 提取路透社内容

让我们看看这在路透社示例中是如何工作的。我们保留已下载的 HTML,当然您也可以使用文件或 URL:

from readability import Document

doc = Document(html)
doc.title()

Out:

'Banned in Boston: Without vaping, medical marijuana patients must adapt -
Reuters'

如您所见,这很容易。可以通过相应元素提取标题。但是,该库还可以执行一些附加技巧,例如查找页面的标题或摘要:

doc.short_title()

Out:

'Banned in Boston: Without vaping, medical marijuana patients must adapt'

那已经相当不错了。让我们来看看它在实际内容中的表现如何:

doc.summary()

Out:

'<html><body><div><div class="StandardArticleBody_body"><p>BOSTON (Reuters) -
In the first few days of [...] </p>

<div class="Attribution_container"><div class="Attribution_attribution">
<p class="Attribution_content">Reporting Jacqueline Tempera in Brookline
and Boston, Massachusetts, and Jonathan Allen in New York; Editing by Frank
McGurty and Bill Berkrot</p></div></div></div></div></body></html>'

数据仍然保留了一些 HTML 结构,这对于包含段落的部分很有用。当然,可以再次使用 Beautiful Soup 提取正文部分:

density_soup = Beautiful Soup(doc.summary(), 'html.parser')
density_soup.body.text

Out:

'BOSTON (Reuters) - In the first few days of the four-month ban on all vaping
products in Massachusetts, Laura Lee Medeiros, a medical marijuana patient,
began to worry.\xa0 FILE PHOTO: An employee puts down an eighth of an ounce
marijuana after letting a customer smell it outside the Magnolia cannabis
lounge in Oakland, California, U.S. [...]

Reporting Jacqueline Tempera in Brookline and Boston, Massachusetts, and
Jonathan Allen in New York; Editing by Frank McGurty and Bill Berkrot'

在这种情况下,结果是非常好的。在大多数情况下,python-readability表现得相当不错,并且避免了实施过多特殊情况的需要。然而,使用此库的成本是不确定性。它是否总是按预期方式工作,例如无法提取结构化数据(如时间戳、作者等)(尽管可能存在其他启发式方法)?

摘要 密度基础文本提取

基于密度的文本提取在使用启发式和关于 HTML 页面信息分布的统计信息时非常强大。请记住,与实施特定提取器相比,结果几乎总是更差。但是,如果您需要从许多不同类型的页面或者您根本没有固定布局的存档中提取内容,那么这种方法可能是值得一试的。

与结构化方法相比,执行详细的质量保证工作更加重要,因为启发式和统计方法有时可能会走错方向。

一体化方法

Scrapy 是另一个 Python 包,提供了一体化的爬虫和内容提取方法。其方法与前文描述的方法类似,尽管 Scrapy 更适合下载整个网站,而不仅仅是其中的一部分。

Scrapy 的面向对象、整体方法确实很好,并且代码易读。但是,重新启动爬虫和提取而不必重新下载整个网站确实非常困难。

与前文描述的方法相比,下载还必须在 Python 中进行。对于页面数量庞大的网站,无法使用 HTTP keep-alive,并且 gzip 编码也很困难。这两者可以通过像 wget 这样的工具在模块化方法中轻松集成外部下载。

蓝图:使用 Scrapy 爬取路透社存档

让我们看看如何在 Scrapy 中下载存档和文章。请继续安装 Scrapy(可以通过 conda install scrapypip install scrapy 进行安装)。

import scrapy
import logging

class ReutersArchiveSpider(scrapy.Spider):
    name = 'reuters-archive'

    custom_settings = {
        'LOG_LEVEL': logging.WARNING,
        'FEED_FORMAT': 'json',
        'FEED_URI': 'reuters-archive.json'
    }

    start_urls = [
        'https://www.reuters.com/news/archive/',
    ]

    def parse(self, response):
        for article in response.css("article.story div.story-content a"):
            yield response.follow(article.css("a::attr(href)").extract_first(),
                                  self.parse_article)
        next_page_url = response.css('a.control-nav-next::attr(href)').\
                        extract_first()
        if (next_page_url is not None) & ('page=2' not in next_page_url):
            yield response.follow(next_page_url, self.parse)

    def parse_article(self, response):
        yield {
          'title': response.css('h1::text').extract_first().strip(),
          'section': response.css('div.ArticleHeader_channel a::text').\
                     extract_first().strip(),
          'text': "\n".join(response.\
                  css('div.StandardArticleBody_body p::text').extract())
        }

Scrapy 以面向对象的方式工作。对于每个所谓的 spider,都需要实现一个从 scrapy.Spider 派生的类。Scrapy 添加了大量的调试输出,在上一个示例中通过 logging.WARNING 进行了减少。基类自动使用 start_urls 调用解析函数 parse。该函数提取文章的链接并调用 parse_article 函数进行 yield。该函数又从文章中提取一些属性并以 dict 形式 yield 返回。最后,爬取下一页的链接,但在获取第二页之前停止。

yield 在 Scrapy 中有双重功能。如果 yield 一个 dict,它将被添加到结果中。如果 yield 一个 Request 对象,则会获取并解析该对象。

Scrapy 和 Jupyter

Scrapy 优化用于命令行使用,但也可以在 Jupyter 笔记本中调用。由于 Scrapy 使用了(古老的) Twisted 环境,所以在笔记本中无法重新启动爬取,因此您只有一次机会(否则必须重新启动笔记本):

# this can be run only once from a Jupyter notebook
# due to Twisted
from scrapy.crawler import CrawlerProcess
process = CrawlerProcess()

process.crawl(ReutersArchiveSpider)
process.start()

以下是一些值得一提的事情:

  • 一体化方法看起来既优雅又简洁。

  • 由于大部分编码都花在提取文章数据上,这些代码必须经常更改。为此,必须重新启动爬虫(如果在 Jupyter 中运行脚本,则还必须启动 Jupyter 笔记本服务器),这极大地增加了周转时间。

  • JSON 可以直接生成是件好事。请注意,由于 JSON 文件是追加的,如果在启动爬虫进程之前不删除文件,可能会导致无效的 JSON。这可以通过使用所谓的 jl 格式(JSON 行)来解决,但这只是一个变通方法。

  • Scrapy 提出了一些不错的想法。在我们的日常工作中,我们并不使用它,主要是因为调试很困难。如果需要持久化 HTML 文件(我们强烈建议这样做),它会失去很多优势。面向对象的方法很有用,可以在不用太多精力的情况下在 Scrapy 之外实现。

由于 Scrapy 也使用 CSS 选择器来提取 HTML 内容,基本技术与其他方法相同。不过,在下载方法上有相当大的差异。由于 Twisted 作为后端,会产生一些额外开销,并施加特殊的编程模型。

仔细考虑是否适合您项目需求的一体化方法。对于某些网站,可能已经有现成的 Scrapy 爬虫可供使用和重用。

爬取可能遇到的问题

在爬取内容之前,考虑可能的版权和数据保护问题总是值得的。

越来越多的 Web 应用程序使用像 React 这样的框架构建。它们只有一个单页面,并通过 API 传输数据。这通常导致禁用 JavaScript 后网站无法工作。有时,专门为搜索引擎构建的特定 URL 对于爬取也很有用。通常可以在 sitemap.xml 中找到这些内容。您可以尝试在浏览器中关闭 JavaScript,然后查看网站是否仍然可用。

如果需要 JavaScript,可以通过使用浏览器的 Web Inspector 在 Network 标签中查找请求,并在应用程序中点击。有时,JSON 用于数据传输,这使得与 HTML 相比提取通常更加容易。但是,仍然需要生成单独的 JSON URL,并可能有额外的参数以避免跨站请求伪造(CSRF)

请求可能会变得非常复杂,比如在 Facebook 时间线上,Instagram 或 Twitter 上。显然,这些网站试图保留他们的内容,避免被爬取。

对于复杂情况,通过使用 Selenium(一个最初用于自动化测试 Web 应用程序的框架)或者 无头浏览器 可以“远程控制”浏览器可能很有用。

像 Google 这样的网站会尝试检测自动下载尝试并开始发送验证码。其他网站也可能会发生这种情况。大多数情况下,这与特定的 IP 地址绑定在一起。然后必须使用正常的浏览器“解锁”网站,并且自动请求之间应该发送较长的间隔。

避免内容提取的另一种方法是使用混淆的 HTML 代码,其中 CSS 类的名称完全是随机的。如果名称不变,最初找到正确的选择器可能会更费力,但之后应该会自动运行。如果名称每天都更改(例如),内容提取将变得非常困难。

总结与建议

网络抓取是一种强大且可扩展的获取内容的技术。必要的 Python 基础设施以非常出色的方式支持抓取项目。请求库和 Beautiful Soup 的组合很舒适,对于中等规模的抓取工作效果很好。

正如我们在整章中所看到的,我们可以将大型抓取项目系统地分解为 URL 生成和下载阶段。如果文档数量变得非常大,那么与请求相比,外部工具如wget可能更合适。一旦所有内容都被下载,就可以使用 Beautiful Soup 来提取内容。

如果想要最小化等待时间,所有阶段都可以并行运行。

无论如何,你都应该了解法律方面的问题,并且要表现得像一个“道德的抓取者”,尊重robots.txt中的规则。

^(1) 路透社是一个新闻网站,每天都在变化。因此,运行代码时会得到完全不同的结果!

^(2) 你可能需要先用**pip install xmltodict**安装该包。

^(3) 路透社是一个新闻网站,内容不断更新。请注意,你的结果肯定会有所不同!

^(4) 就在撰写本文的时候,路透社停止提供 RSS 源,引发了公众的强烈抗议。我们希望 RSS 源会得到恢复。本章的 Jupyter 笔记本 在 GitHub 上 使用了来自互联网档案馆的 RSS 源的存档版本。

^(5) 正如之前所述,路透社是一个动态生成的网站,你的结果会有所不同!

^(6) HTML 不能用正则表达式解析。

^(7) 参见 Eric A. Meyer 和 Estelle Weyl 的CSS:权威指南,第 4 版(O'Reilly,2017)

第四章:为统计和机器学习准备文本数据

从技术上讲,任何文本文档都只是一系列字符。为了在内容上构建模型,我们需要将文本转换为一系列单词或更一般地说,被称为标记的有意义的字符序列。但仅仅这样是不够的。想象一下单词序列New York,它应该被视为一个单一的命名实体。正确地识别这样的单词序列作为复合结构需要复杂的语言处理。

数据准备或一般的数据预处理不仅涉及将数据转换为可以用于分析的形式,还涉及消除干扰噪声。什么是噪声,什么不是,这取决于您将要执行的分析。在处理文本时,噪声呈现不同的形式。原始数据可能包括应在大多数情况下移除的 HTML 标记或特殊字符。但是,频繁出现的具有很少含义的单词,所谓的停用词,会给机器学习和数据分析引入噪声,因为它们使得检测模式变得更加困难。

您将学到什么以及我们将要构建什么

在本章中,我们将为文本预处理流水线开发蓝图。该流水线将接受原始文本作为输入,对其进行清理、转换,并提取文本内容的基本特征。我们首先使用正则表达式进行数据清理和标记化,然后专注于使用spaCy进行语言处理。spaCy 是一个功能强大的自然语言处理库,具有现代 API 和最先进的模型。对于一些操作,我们将利用textacy,这是一个提供一些很好的附加功能,特别是用于数据预处理的库。我们还会在有需要时指向 NLTK 和其他库。

在学习本章之后,您将了解数据准备的必需和可选步骤。您将学会如何使用正则表达式进行数据清理,以及如何使用 spaCy 进行特征提取。通过提供的蓝图,您将能够快速为自己的项目建立数据准备流水线。

数据预处理流水线

数据预处理通常涉及一系列步骤。通常,这个序列被称为流水线,因为您将原始数据输入到流水线中,并从中获得转换和预处理后的数据。在第一章中,我们已经构建了一个简单的数据处理流水线,包括标记化和停用词去除。我们将在本章中使用术语流水线作为处理步骤序列的通用术语。图 4-1 概述了我们将在本章中为预处理流水线构建的蓝图。

图 4-1。文本数据预处理的典型预处理步骤流水线。

我们管道中的第一个主要操作块是数据清理。我们首先识别并删除文本中的噪声,如 HTML 标记和不可打印字符。在字符标准化过程中,特殊字符,如重音符号和连字符,被转换为标准表示。最后,如果 URL 或电子邮件地址与分析无关或存在隐私问题,则可以屏蔽或删除它们。现在文本已经清洁到足以开始语言处理了。

这里,分词 将文档分割成单独的标记列表,例如单词和标点符号。词性标注 是确定词的类别的过程,无论它是名词、动词、冠词等。词形还原 将屈折词映射到它们的不变词根,即词素(例如,“are” → “be”)。命名实体识别 的目标是在文本中识别对人物、组织、地点等的引用。

最后,我们希望创建一个包含准备好的用于分析和机器学习的数据的数据库。因此,所需的准备步骤因项目而异。您可以决定在问题特定管道中包含哪些以下蓝图。

介绍数据集:Reddit 自发帖子

处理文本数据在处理用户生成内容(UGC)时特别具有挑战性。与专业报告、新闻和博客中的精心编辑的文本相比,社交媒体中的用户贡献通常很短,并包含大量缩写、标签、表情符号和拼写错误。因此,我们将使用在 Kaggle 上托管的Reddit 自发帖子数据集。完整数据集包含大约 100 万条带有标题和内容的用户帖子,分为 1,013 个不同的子版块,每个子版块包含 1,000 条记录。我们将仅使用汽车类别中包含的 20,000 条帖子的子集。本章准备的数据集是第十章中单词嵌入分析的基础。

将数据加载到 Pandas

原始数据集由两个单独的 CSV 文件组成,一个包含帖子,另一个包含一些有关子版块的元数据,包括类别信息。这两个文件都通过 pd.read_csv() 加载到 Pandas 的 DataFrame 中,然后合并为一个单一的 DataFrame

import pandas as pd

posts_file = "rspct.tsv.gz"
posts_df = pd.read_csv(posts_file, sep='\t')

subred_file = "subreddit_info.csv.gz"
subred_df = pd.read_csv(subred_file).set_index(['subreddit'])

df = posts_df.join(subred_df, on='subreddit')

蓝图:标准化属性名称

在我们开始处理数据之前,我们将将数据集特定的列名称更改为更通用的名称。我们建议始终将主要的 DataFrame 命名为 df,并将要分析的文本列命名为 text。对于常见变量和属性名称的此类命名约定,使得在不同项目中重用蓝图代码变得更容易。

让我们来看看该数据集的列列表:

print(df.columns)

输出:

Index(['id', 'subreddit', 'title', 'selftext', 'category_1', 'category_2',
       'category_3', 'in_data', 'reason_for_exclusion'],
      dtype='object')

对于列重命名和选择,我们定义一个名为 column_mapping 的字典,其中每个条目定义了当前列名到新名称的映射。映射到 None 的列和未提及的列将被丢弃。这种转换的字典非常适合文档化并易于重复使用。然后使用这个字典来选择和重命名我们想要保留的列。

column_mapping = {
    'id': 'id',
    'subreddit': 'subreddit',
    'title': 'title',
    'selftext': 'text',
    'category_1': 'category',
    'category_2': 'subcategory',
    'category_3': None, # no data
    'in_data': None, # not needed
    'reason_for_exclusion': None # not needed
}

# define remaining columns
columns = [c for c in column_mapping.keys() if column_mapping[c] != None]

# select and rename those columns
df = df[columns].rename(columns=column_mapping)

如前所述,我们将数据限制在汽车类别中:

df = df[df['category'] == 'autos']

让我们简要看一下样本记录,以对数据有个初步印象:

df.sample(1).T

14356
id 7jc2k4
subreddit volt
title 2017 伏特行车记录仪
text Hello.我正在考虑购买一款行车记录仪。有人有推荐吗?我一般寻找一款可充电的,这样我就不必把电线引到点烟器里去了。除非有关于如何正确安装不露电线的说明。谢谢!
category autos
subcategory chevrolet

保存和加载 DataFrame

在每个数据准备步骤之后,将相应的 DataFrame 写入磁盘作为检查点非常有帮助。Pandas 直接支持多种序列化选项。像 CSV 或 JSON 这样的文本格式可以轻松导入到大多数其他工具中。然而,数据类型信息丢失(CSV)或仅保存基本信息(JSON)。Python 的标准序列化格式 pickle 受到 Pandas 支持,因此是一个可行的选择。它速度快且保留所有信息,但只能由 Python 处理。“Pickling” 一个数据帧很简单;您只需指定文件名:

df.to_pickle("reddit_dataframe.pkl")

然而,我们更倾向于将数据帧存储在 SQL 数据库中,因为它们为您提供 SQL 的所有优势,包括过滤、连接和从许多工具轻松访问。但与 pickle 不同,只支持 SQL 数据类型。例如,包含对象或列表的列不能简单地以这种方式保存,需要手动序列化。

在我们的示例中,我们将使用 SQLite 来持久化数据帧。SQLite 与 Python 集成良好。此外,它只是一个库,不需要服务器,因此文件是自包含的,并且可以在不同团队成员之间轻松交换。为了更大的功能和安全性,我们建议使用基于服务器的 SQL 数据库。

我们使用 pd.to_sql() 将我们的 DataFrame 保存为 SQLite 数据库中的 posts 表。DataFrame 索引不会被保存,任何现有数据都会被覆盖:

import sqlite3

db_name = "reddit-selfposts.db"
con = sqlite3.connect(db_name)
df.to_sql("posts", con, index=False, if_exists="replace")
con.close()

可以使用 pd.read_sql() 轻松恢复 DataFrame

con = sqlite3.connect(db_name)
df = pd.read_sql("select * from posts", con)
con.close()

清洁文本数据

当处理用户请求或评论时,相对于精心编辑的文章,通常需要处理一些质量问题:

特殊格式和程序代码

文本可能仍然包含特殊字符、HTML 实体、Markdown 标记等。这些残留物应提前清理,因为它们会复杂化标记化并引入噪音。

问候语、签名、地址等。

个人交流通常包含毫无意义的客套话和称呼姓名的问候语,这些通常对分析无关紧要。

回复

如果你的文本中包含重复问题文本的答案,你需要删除重复的问题。保留它们会扭曲任何模型和统计数据。

在本节中,我们将演示如何使用正则表达式识别和删除数据中的不需要的模式。查看以下侧边栏,获取有关 Python 中正则表达式的更多详细信息。

看看 Reddit 数据集中的以下文本示例:

text = """
After viewing the [PINKIEPOOL Trailer](https://www.youtu.be/watch?v=ieHRoHUg)
it got me thinking about the best match ups.
<lb>Here's my take:<lb><lb>[](/sp)[](/ppseesyou) Deadpool<lb>[](/sp)[](/ajsly)
Captain America<lb>"""

如果这段文本经过清理和润色,结果肯定会有所改善。有些标签只是网页抓取的产物,所以我们会将它们清除掉。因为我们对 URL 和其他链接不感兴趣,所以我们也会丢弃它们。

蓝图:使用正则表达式识别噪音

在大型数据集中识别质量问题可能会很棘手。当然,你可以并且应该查看一部分数据的样本。但很可能你不会发现所有的问题。更好的方法是定义粗略模式,指示可能存在的问题,并通过程序检查完整数据集。

下面的函数可以帮助您识别文本数据中的噪音。我们所说的噪音是指所有非纯文本的东西,可能会干扰进一步的分析。该函数使用正则表达式搜索一些可疑字符,并将它们在所有字符中的份额作为杂质的分数返回。非常短的文本(少于min_len个字符)将被忽略,因为在这里,单个特殊字符将导致显著的杂质并扭曲结果。

import re

RE_SUSPICIOUS = re.compile(r'[&#<>{}\[\]\\]')

def impurity(text, min_len=10):
    """returns the share of suspicious characters in a text"""
    if text == None or len(text) < min_len:
        return 0
    else:
        return len(RE_SUSPICIOUS.findall(text))/len(text)

print(impurity(text))

Out:

0.09009009009009009

在精心编辑的文本中,你几乎永远不会找到这些字符,因此通常情况下得分应该非常低。对于前面的文本示例,根据我们的定义,约 9%的字符是“可疑的”。当然,搜索模式可能需要适应包含特殊字符的语料库或类似标记的文本。然而,它不需要完全匹配;它只需要足够好以指示潜在的质量问题。

对于 Reddit 数据,我们可以用以下两个语句获取最“不纯净”的记录。请注意,我们使用 Pandas 的apply()而不是类似的map(),因为它允许我们向应用的函数传递额外的参数,如min_len。^(1)

# add new column to data frame
df['impurity'] = df['text'].apply(impurity, min_len=10)

# get the top 3 records
df[['text', 'impurity']].sort_values(by='impurity', ascending=False).head(3)

文本 杂质
19682 我在考虑购买一辆 335i,行驶 39,000 英里,CPO 保修还剩 11 个月。我询问了交... 0.21
12357 我打算租用带导航包的 a4 高级版自动挡。车辆价格:<ta... 0.17
2730 Breakdown below:Elantra GT2.0L 4 缸6 速手动变速器... 0.14

显然,有许多像<lb>(换行符)和<tab>(制表符)这样的标签包含在内。让我们利用我们在第一章中的单词计数蓝图,结合简单的正则表达式分词器,来检查是否还有其他标签:

from blueprints.exploration import count_words
count_words(df, column='text', preprocess=lambda t: re.findall(r'<[\w/]*>', t))

频率 词元
100729
642

现在我们知道,尽管这两个标签很常见,但它们是唯一的标签。

蓝图:使用正则表达式去除噪音

我们的数据清理方法包括定义一组正则表达式,并识别问题模式及相应的替换规则。^(2) 蓝图函数首先用它们的纯文本表示替换所有 HTML 转义符(例如&amp;),然后用空格替换特定模式。最后,修剪空白序列:

import html

def clean(text):
    # convert html escapes like &amp; to characters.
    text = html.unescape(text)
    # tags like <tab>
    text = re.sub(r'<[^<>]*>', ' ', text)
    # markdown URLs like [Some text](https://....)
    text = re.sub(r'\[([^\[\]]*)\]\([^\(\)]*\)', r'\1', text)
    # text or code in brackets like [0]
    text = re.sub(r'\[[^\[\]]*\]', ' ', text)
    # standalone sequences of specials, matches &# but not #cool
    text = re.sub(r'(?:^|\s)[&#<>{}\[\]+|\\:-]{1,}(?:\s|$)', ' ', text)
    # standalone sequences of hyphens like --- or ==
    text = re.sub(r'(?:^|\s)[\-=\+]{2,}(?:\s|$)', ' ', text)
    # sequences of white spaces
    text = re.sub(r'\s+', ' ', text)
    return text.strip()

警告

要小心:如果您的正则表达式定义不够精确,您可能会在此过程中意外删除有价值的信息而未注意到!重复符号+*尤其危险,因为它们匹配无界字符序列,可能会删除大部分文本。

让我们对前面的示例文本应用clean函数并检查结果:

clean_text = clean(text)
print(clean_text)
print("Impurity:", impurity(clean_text))

输出:

After viewing the PINKIEPOOL Trailer it got me thinking about the best
match ups. Here's my take: Deadpool Captain America
Impurity: 0.0

看起来不错。一旦处理了第一个模式,您应该再次检查清理后文本的杂质,并在必要时添加进一步的清理步骤:

df['clean_text'] = df['text'].map(clean)
df['impurity']   = df['clean_text'].apply(impurity, min_len=20)

df[['clean_text', 'impurity']].sort_values(by='impurity', ascending=False) \
                              .head(3)

清理文本 杂质
14058 Mustang 2018、2019 年还是 2020 年?必备条件!!1. 信用评分达到 780 分以上,以获得最低的利率!2. 加入信用社来为车辆融资!3. 或者找一个贷款人来为车辆融资... 0.03
18934 在经销商那里,他们提供了一个车内照明的选项,但我在网上找不到任何相关信息。有人得到了吗?它看起来怎么样?有人有照片。不确定这是什么意思... 0.03
16505 我正在看四辆凯曼,价格都差不多。主要区别在于里程、年限,还有一款不是 S 型。https://www.cargurus.com/Cars/inventorylisting/viewDetailsFilterV... 0.02

即使根据我们的正则表达式,最肮脏的记录现在看起来也非常干净。但除了我们搜索的粗糙模式之外,还有更微妙的字符变体可能会引起问题。

蓝图:使用 textacy 进行字符归一化

注意以下句子,其中包含与字母变体和引号字符相关的典型问题:

text = "The café “Saint-Raphaël” is loca-\nted on Côte dʼAzur."

重音字符可能会带来问题,因为人们并不一致地使用它们。例如,tokens Saint-RaphaëlSaint-Raphael 将不会被识别为相同的。此外,文本经常包含由于自动换行而分隔的单词。像文本中使用的花哨 Unicode 连字符和撇号这样的字符对于标记化来说可能是个问题。针对所有这些问题,规范化文本并用 ASCII 等效物替换重音字符和花哨字符是有意义的。

我们将使用 textacy 来实现这一目的。textacy 是一个与 spaCy 配套使用的 NLP 库,它将语言学部分交给 spaCy,专注于预处理和后处理。因此,其预处理模块包括了一系列用于规范化字符以及处理常见模式(如 URLs、电子邮件地址、电话号码等)的函数,我们将在下面使用它们。表 4-1 展示了 textacy 预处理函数的一部分。所有这些函数都可以独立于 spaCy 在纯文本上工作。

表 4-1. textacy 预处理函数子集

功能 描述
normalize_hyphenated_words 重新组合被连字符分隔的单词
normalize_quotation_marks 用 ASCII 等效物替换所有类型的花哨引号
normalize_unicode 统一 Unicode 中不同格式的重音字符
remove_accents 尽可能用 ASCII 替换重音字符,否则删除它们
replace_urls 类似于 URLs,比如 https://xyz.com
replace_emails 将电子邮件替换为 EMAIL
replace_hashtags 类似于标签 #sunshine
replace_numbers 类似于数字 1235
replace_phone_numbers 类似于电话号码 +1 800 456-6553
replace_user_handles 类似于用户句柄 @pete
replace_emojis EMOJI 替换表情符号等

我们这里展示的蓝图函数,使用 textacy 标准化了花哨的连字符和引号,并通过它来去除重音:

import textacy.preprocessing as tprep

def normalize(text):
    text = tprep.normalize_hyphenated_words(text)
    text = tprep.normalize_quotation_marks(text)
    text = tprep.normalize_unicode(text)
    text = tprep.remove_accents(text)
    return text

当这个应用到之前的例句时,我们得到以下结果:

print(normalize(text))

输出:

The cafe "Saint-Raphael" is located on Cote d'Azur.

注意

鉴于 Unicode 规范化有许多方面,您可以查看其他库。例如,unidecode 在这方面表现出色。

蓝图:基于模式的数据屏蔽与 textacy

文本,尤其是用户写的内容,经常包含不仅是普通单词,还有多种标识符,比如 URLs、电子邮件地址或电话号码等。有时我们特别对这些项感兴趣,例如分析提及最频繁的 URLs。然而,在许多情况下,出于隐私或无关紧要的原因,删除或屏蔽这些信息可能更为合适。

textacy 提供了一些方便的 replace 函数用于数据屏蔽(参见 表 4-1)。大部分函数基于正则表达式,可以通过 开源代码 轻松访问。因此,每当需要处理这些项中的任何一个时,textacy 都有一个相应的正则表达式可以直接使用或根据需要进行调整。让我们通过一个简单的调用来说明这一点,以找到语料库中最常用的 URL:

from textacy.preprocessing.resources import RE_URL

count_words(df, column='clean_text', preprocess=RE_URL.findall).head(3)

token freq
www.getlowered.com 3
http://www.ecolamautomotive.com/#!2/kv7fq 2
https://www.reddit.com/r/Jeep/comments/4ux232/just_ordered_an_android_head_unit_joying_jeep/ 2

对于我们在本数据集中要执行的分析(见 第十章),我们对这些 URL 不感兴趣。它们更多地代表了一个干扰因素。因此,我们将用 replace_urls 替换文本中的所有 URL,实际上这只是调用了 RE_URL.sub。textacy 所有替换函数的默认替换是用下划线括起来的通用标记,如 _URL_。您可以通过指定 replace_with 参数选择自己的替换。通常情况下,不完全移除这些项是有意义的,因为这样可以保持句子的结构不变。下面的调用演示了这一功能:

from textacy.preprocessing.replace import replace_urls

text = "Check out https://spacy.io/usage/spacy-101"

# using default substitution _URL_
print(replace_urls(text))

Out:

Check out _URL_

为了最终完成数据清理,我们对数据应用标准化和数据屏蔽函数:

df['clean_text'] = df['clean_text'].map(replace_urls)
df['clean_text'] = df['clean_text'].map(normalize)

数据清理就像打扫你的房子一样。你总会发现一些脏角落,而且你永远也不可能把房子完全打扫干净。所以当它足够干净时你就停止清理了。这就是我们目前对数据的假设。在后续的过程中,如果分析结果受到剩余噪音的影响,我们可能需要重新进行数据清理。

最后,我们将文本列重命名为 clean_text 变成 text,删除杂质列,并将 DataFrame 的新版本存储在数据库中。

df.rename(columns={'text': 'raw_text', 'clean_text': 'text'}, inplace=True)
df.drop(columns=['impurity'], inplace=True)

con = sqlite3.connect(db_name)
df.to_sql("posts_cleaned", con, index=False, if_exists="replace")
con.close()

分词

我们已经在 第一章 中引入了一个正则表达式分词器,它使用了一个简单的规则。然而,在实践中,如果我们希望正确处理一切,分词可能会相当复杂。考虑下面的文本片段作为例子:

text = """
2019-08-10 23:32: @pete/@louis - I don't have a well-designed
solution for today's problem. The code of module AC68 should be -1.
Have to think a bit... #goodnight ;-) 😩😬"""

显然,定义词和句边界的规则并不是那么简单。那么什么是一个标记?不幸的是,没有明确的定义。我们可以说,一个标记是一个在语义分析中有用的语言单元。这个定义意味着分词在某种程度上依赖于应用程序。例如,在许多情况下,我们可以简单地丢弃标点符号,但如果我们想要保留表情符号像 :-) 用于情感分析,则不行。对于包含数字或标签的标记也是如此。尽管大多数分词器,包括 NLTK 和 spaCy 中使用的分词器,都基于正则表达式,但它们应用的规则相当复杂,有时是语言特定的。

我们将首先为基于分词的正则表达式开发我们自己的蓝图,然后简要介绍 NLTK 的分词器。spaCy 中的分词将在本章的下一节作为 spaCy 综合处理的一部分进行讨论。

蓝图:正则表达式分词

分词的有用函数包括re.split()re.findall()。前者在匹配表达式时将字符串分割,而后者提取所有匹配特定模式的字符序列。例如,在第一章中,我们使用 POSIX 模式\[\w-]*\p{L}\[\w-]*regex库来查找至少包含一个字母的字母数字字符序列。scikit-learn 的CountVectorizer默认使用模式\w\w+进行分词。它匹配所有由两个或更多字母数字字符组成的序列。应用于我们的示例句子时,它产生以下结果:^(3)

tokens = re.findall(r'\w\w+', text)
print(*tokens, sep='|')

输出:

2019|08|10|23|32|pete|louis|don|have|well|designed|solution|for|today
problem|The|code|of|module|AC68|should|be|Have|to|think|bit|goodnight

遗憾的是,所有特殊字符和表情符号都丢失了。为了改善结果,我们添加了一些表情符号的额外表达式,并创建了可重用的正则表达式RE_TOKENVERBOSE选项允许对复杂表达式进行可读格式化。以下是tokenize函数和示例说明其用法:

RE_TOKEN = re.compile(r"""
 ( [#]?[@\w'’\.\-\:]*\w     # words, hashtags and email addresses
 | [:;<]\-?[\)\(3]          # coarse pattern for basic text emojis
 | [\U0001F100-\U0001FFFF]  # coarse code range for unicode emojis
 )
 """, re.VERBOSE)

def tokenize(text):
    return RE_TOKEN.findall(text)

tokens = tokenize(text)
print(*tokens, sep='|')

输出:

2019-08-10|23:32|@pete|@louis|I|don't|have|a|well-designed|solution
for|today's|problem|The|code|of|module|AC68|should|be|-1|Have|to|think
a|bit|#goodnight|;-)|😩|😬

这个表达式应该能够在大多数用户生成内容上产生合理的结果。它可用于快速为数据探索分词,正如在第一章中所解释的那样。对于 scikit-learn 向量化器的默认分词,它也是一个很好的替代选择,该向量化器将在下一章中介绍。

使用 NLTK 进行分词

让我们简要了解一下 NLTK 的分词器,因为 NLTK 经常用于分词。标准的 NLTK 分词器可以通过快捷方式word_tokenize调用。它在我们的示例文本上产生以下结果:

import nltk

tokens = nltk.tokenize.word_tokenize(text)
print(*tokens, sep='|')

输出:

2019-08-10|23:32|:|@|pete/|@|louis|-|I|do|n't|have|a|well-designed
solution|for|today|'s|problem|.|The|code|of|module|AC68|should|be|-1|.
Have|to|think|a|bit|...|#|goodnight|;|-|)||😩😬

该函数在内部结合了TreebankWordTokenizerPunktSentenceTokenizer。它适用于标准文本,但在处理标签或文本表情符号时存在缺陷。NLTK 还提供了RegexpTokenizer,它基本上是re.findall()的包装器,具有一些附加的便利功能。此外,NLTK 中还有其他基于正则表达式的分词器,如TweetTokenizer或多语言ToktokTokenizer,您可以在本章的GitHub笔记本中查看。

分词建议

如果您的目标是在特定领域的标记模式上达到高精度,您可能需要使用自定义正则表达式。幸运的是,您可以在开源库中找到许多常见模式的正则表达式,并根据自己的需求进行调整。^(4)

一般情况下,您应该注意应用程序中的以下问题案例,并定义如何处理它们:^(5)

  • 包含句点的标记,比如Dr.Mrs.U.xyz.com

  • 连字符,比如rule-based

  • 缩写词(连接词缩写),如 couldn'twe'veje t'aime

  • 数字表达式,如电话号码((123) 456-7890)或日期(2019 年 8 月 7 日

  • 表情符号,主题标签,电子邮件地址或网址

常见库中的分词器特别在这些标记方面有所不同。

使用 spaCy 进行语言处理

spaCy 是一个强大的语言数据处理库。它提供了一个集成的处理组件流水线,默认包括分词器、词性标注器、依存解析器和命名实体识别器(详见 图 4-2)。分词基于复杂的语言相关规则和正则表达式,而所有后续步骤都使用预训练的神经模型。

图 4-2. spaCy 的自然语言处理流水线。

spaCy 的哲学是在整个处理过程中保留原始文本。而不是转换它,spaCy 添加了信息层。用于表示处理过的文本的主要对象是 Doc 对象,它本身包含了 Token 对象的列表。任何一组标记的选择都会创建一个 Span。每种这些对象类型都有逐步确定的属性。

在本节中,我们将解释如何使用 spaCy 处理文档,如何处理标记及其属性,如何使用词性标签以及如何提取命名实体。我们将在 第 12 章 深入探讨 spaCy 更高级的概念,其中我们编写自己的管道组件,创建自定义属性,并使用解析器生成的依存树进行知识提取。

警告

本书示例的开发使用的是 spaCy 版本 2.3.2. 如果您已经在使用仍在开发中的 spaCy 3.0,那么您的结果可能会略有不同。

实例化管道

让我们开始使用 spaCy。作为第一步,我们需要通过调用 spacy.load() 并指定要使用的模型文件来实例化 spaCy 的 Language 类的对象。^(6) 在本章中,我们将使用小型英语语言模型 en_core_web_sm。通常用于 Language 对象的变量是 nlp

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

现在这个 Language 对象包含了共享的词汇表、模型和处理管道。您可以通过该对象的属性检查管道组件:

nlp.pipeline

输出:

[('tagger', <spacy.pipeline.pipes.Tagger at 0x7fbd766f84c0>),
 ('parser', <spacy.pipeline.pipes.DependencyParser at 0x7fbd813184c0>),
 ('ner', <spacy.pipeline.pipes.EntityRecognizer at 0x7fbd81318400>)]

默认的处理流程包括标注器、解析器和命名实体识别器(ner),所有这些都是依赖于语言的。分词器没有明确列出,因为这一步骤总是必要的。

spaCy 的分词速度相当快,但所有其他步骤都基于神经模型,消耗大量时间。与其他库相比,spaCy 的模型速度是最快的。处理整个流程大约需要 10 到 20 倍于仅仅进行分词的时间,每个步骤所占的时间相似。例如,对 1,000 个文档进行分词如果需要一秒钟,标记、解析和命名实体识别可能会额外花费五秒钟。如果处理大型数据集,这可能成为问题。因此,最好关闭你不需要的部分。

通常你只需要分词器和词性标注器。在这种情况下,你应该像这样禁用解析器和命名实体识别:

nlp = spacy.load("en_core_web_sm", disable=["parser", "ner"])

如果你只需要分词器而不需要其他东西,你也可以简单地在文本上调用 nlp.make_doc

文本处理

通过调用 nlp 对象执行流水线。调用返回一个 spacy.tokens.doc.Doc 类型的对象,一个访问 token、span(token 范围)及其语言标注的容器。

nlp = spacy.load("en_core_web_sm")
text = "My best friend Ryan Peters likes fancy adventure games."
doc = nlp(text)

spaCy 是面向对象的同时也是非破坏性的。原始文本始终保留。当你打印 doc 对象时,它使用 doc.text,这个属性包含原始文本。但 doc 也是一个 token 的容器,你可以像对待迭代器一样使用它们:

for token in doc:
    print(token, end="|")

Out:

My|best|friend|Ryan|Peters|likes|fancy|adventure|games|.|

每个 token 实际上是 spaCy 类 Token 的对象。Token 和 doc 都有许多用于语言处理的有趣属性。Table 4-2 显示了每个流水线组件创建的这些属性。^(7)

表 4-2. spaCy 内置流水线创建的属性选择

组件 创建
分词器 Token.is_punct, Token.is_alpha, Token.like_email, Token.like_url
词性标注器 Token.pos_
依赖解析器 Token.dep_, Token.head, Doc.sents, Doc.noun_chunks
命名实体识别器 Doc.ents, Token.ent_iob_, Token.ent_type_

我们提供了一个小型实用函数 display_nlp,生成包含 token 及其属性的表格。在内部,我们为此创建了一个 DataFrame,并将文档中的 token 位置用作索引。默认情况下,此函数跳过标点符号。Table 4-3 显示了我们示例句子的输出:

def display_nlp(doc, include_punct=False):
    """Generate data frame for visualization of spaCy tokens."""
    rows = []
    for i, t in enumerate(doc):
        if not t.is_punct or include_punct:
            row = {'token': i,  'text': t.text, 'lemma_': t.lemma_,
                   'is_stop': t.is_stop, 'is_alpha': t.is_alpha,
                   'pos_': t.pos_, 'dep_': t.dep_,
                   'ent_type_': t.ent_type_, 'ent_iob_': t.ent_iob_}
            rows.append(row)

    df = pd.DataFrame(rows).set_index('token')
    df.index.name = None
    return df

表 4-3. spaCy 文档处理的结果,由 display_nlp 生成

text lemma_ is_stop is_alpha pos_ dep_ ent_type_ ent_iob_
0 My -PRON- True True DET poss O
1 best good False True ADJ amod O
2 friend friend False True NOUN nsubj O
3 Ryan Ryan False True PROPN compound PERSON B
4 Peters Peters False True PROPN appos PERSON I
5 likes like False True VERB ROOT O
6 fancy fancy False True ADJ amod O
7 adventure adventure False True NOUN compound O
8 games game False True NOUN dobj O

对于每个标记,您可以找到词元、一些描述性标志、词性标签、依赖标签(这里未使用,但在第十二章中使用),以及可能有关实体类型的信息。is_<something> 标志是基于规则创建的,但所有词性、依赖和命名实体属性都基于神经网络模型。因此,这些信息总是存在一定的不确定性。用于训练的语料库包含新闻文章和在线文章的混合体。如果您的数据具有相似的语言特征,则模型的预测非常准确。但是,如果您的数据非常不同——例如,您正在处理 Twitter 数据或 IT 服务台工单——您应该意识到这些信息是不可靠的。

警告

spaCy 使用带有下划线的标记属性约定,例如 pos_ 返回可读的文本表示。不带下划线的 pos 返回 spaCy 的词性标签的数值标识符。^(8) 这些数值标识符可以作为常量导入,例如 spacy.symbols.VERB。请确保不要混淆它们!

蓝图:自定义标记化

标记化是管道中的第一步,一切都依赖于正确的标记。在大多数情况下,spaCy 的标记器表现良好,但有时会在井号、连字符和下划线上分割,这并不总是您想要的。因此,可能需要调整其行为。让我们以以下文本作为例子:

text = "@Pete: choose low-carb #food #eat-smart. _url_ ;-) 😋👍"
doc = nlp(text)

for token in doc:
    print(token, end="|")

Out:

@Pete|:|choose|low|-|carb|#|food|#|eat|-|smart|.|_|url|_|;-)|😋|👍|

spaCy 的标记器完全基于规则。首先,它在空格字符上分割文本。然后,它使用由正则表达式定义的前缀、后缀和中缀分割规则来进一步分割剩余的标记。异常规则用于处理语言特定的异常情况,如 can’t,应该分割为 can’t,词元为 cannot。^(9)

如您在示例中所见,spaCy 的英文标记器包含一个中缀规则,用于在连字符处拆分。此外,它还有一个前缀规则,用于拆分类似 #_ 的字符。它对以 @ 开头的标记和表情符号也适用。

一种选项是在后处理步骤中使用 doc.retokenize 合并标记。然而,这并不能修复任何计算错误的词性标签和句法依赖,因为这些依赖于标记化。因此,更改标记化规则并在一开始创建正确的标记可能会更好。

对于这个问题,最好的方法是创建自己的分词器变体,具有单独的中缀、前缀和后缀分割规则。^(10) 下面的函数以“最小侵入”方式创建了一个具有单独规则的分词器对象:我们只是从 spaCy 的默认规则中删除了相应的模式,但保留了主要部分的逻辑:

from spacy.tokenizer import Tokenizer
from spacy.util import compile_prefix_regex, \
                       compile_infix_regex, compile_suffix_regex

def custom_tokenizer(nlp):

    # use default patterns except the ones matched by re.search
    prefixes = [pattern for pattern in nlp.Defaults.prefixes
                if pattern not in ['-', '_', '#']]
    suffixes = [pattern for pattern in nlp.Defaults.suffixes
                if pattern not in ['_']]
    infixes  = [pattern for pattern in nlp.Defaults.infixes
                if not re.search(pattern, 'xx-xx')]

    return Tokenizer(vocab          = nlp.vocab,
                     rules          = nlp.Defaults.tokenizer_exceptions,
                     prefix_search  = compile_prefix_regex(prefixes).search,
                     suffix_search  = compile_suffix_regex(suffixes).search,
                     infix_finditer = compile_infix_regex(infixes).finditer,
                     token_match    = nlp.Defaults.token_match)

nlp = spacy.load('en_core_web_sm')
nlp.tokenizer = custom_tokenizer(nlp)

doc = nlp(text)
for token in doc:
    print(token, end="|")

Out:

@Pete|:|choose|low-carb|#food|#eat-smart|.|_url_|;-)|😋|👍|

警告

在修改分词的过程中要小心,因为它们的影响可能会很微妙,修复一组案例可能会破坏另一组案例。例如,通过我们的修改,像Chicago-based这样的标记将不再被分割。此外,如果 Unicode 字符的连字符和破折号没有被规范化,可能会出现问题。

蓝图:处理停用词

spaCy 使用语言特定的停用词列表直接在分词后为每个标记设置is_stop属性。因此,过滤停用词(以及类似的标点符号标记)非常容易:

text = "Dear Ryan, we need to sit down and talk. Regards, Pete"
doc = nlp(text)

non_stop = [t for t in doc if not t.is_stop and not t.is_punct]
print(non_stop)

Out:

[Dear, Ryan, need, sit, talk, Regards, Pete]

可以通过导入spacy.lang.en.STOP_WORDS来访问包含超过 300 个条目的英文停用词列表。当创建一个nlp对象时,该列表被加载并存储在nlp.Defaults.stop_words下。我们可以通过设置 spaCy 词汇表中相应单词的is_stop属性来修改 spaCy 的默认行为:^(11)

nlp = spacy.load('en_core_web_sm')
nlp.vocab['down'].is_stop = False
nlp.vocab['Dear'].is_stop = True
nlp.vocab['Regards'].is_stop = True

如果我们重新运行上一个示例,我们将得到以下结果:

[Ryan, need, sit, down, talk, Pete]

蓝图:基于词性提取词元

词形还原是将单词映射到其未屈折根的过程。像housinghousedhouse这样的词被视为相同,对于统计、机器学习和信息检索具有许多优势。它不仅可以改善模型的质量,还可以减少训练时间和模型大小,因为词汇量如果只保留未屈折形式会更小。此外,将单词类型限制为特定类别,如名词、动词和形容词,通常也是有帮助的。这些词类型称为词性标签

让我们首先深入了解词形还原。可以通过lemma_属性访问标记或跨度的词元,如下例所示:

text = "My best friend Ryan Peters likes fancy adventure games."
doc = nlp(text)

print(*[t.lemma_ for t in doc], sep='|')

Out:

-PRON-|good|friend|Ryan|Peters|like|fancy|adventure|game|.

正确地分配词元需要查找字典和对单词的词性的知识。例如,名词meeting的词元是meeting,而动词meet的词元是meet。在英语中,spaCy 能够做到这种区分。然而,在大多数其他语言中,词形还原纯粹基于字典,忽略了词性依赖。请注意,像Imeyouher这样的人称代词在 spaCy 中总是得到词元-PRON-

在这份蓝图中我们将使用的另一个标记属性是词性标记。表 4-3 显示 spaCy 文档中的每个标记都有两个词性属性:pos_tag_tag_ 是从用于训练模型的标记集中提取的标记。对于 spaCy 的英语模型,它们是在 OntoNotes 5 语料库上训练的,这是宾夕法尼亚树库标记集。对于德语模型,这将是斯图加特-图宾根标记集。 pos_ 属性包含通用词性标记集的简化标记。^(12) 我们建议使用此属性,因为其值将在不同模型之间保持稳定。表 4-4 显示了完整的标记集描述。

表 4-4. 通用词性标记

Tag 描述 例子
ADJ 形容词(描述名词) 大的,绿色的,非洲的
ADP 介词(前置词和后置词) 在,上
ADV 副词(修改动词或形容词) 非常,确切地,总是
AUX 助动词(伴随动词) 能(做),是(在做)
CCONJ 连接连词 和,或,但是
DET 限定词(关于名词) 这个,一个,所有(事物),你的(想法)
INTJ 感叹词(独立词,感叹词,表达情感) 嗨,是的
NOUN 名词(普通名词和专有名词) 房子,电脑
NUM 基数 九,9,IX
PROPN 专有名词,名字或名字的一部分 彼得,柏林
PRON 代词,代替名词 我,你,我自己,谁
PART 粒子(只有与其他单词一起才有意义)
PUNCT 标点符号字符 ,。;
SCONJ 从属连词 在…之前,因为,如果
SYM 符号(类似单词) $,©
VERB 动词(所有时态和方式) 去,去过,思考
X 任何无法分配的东西 grlmpf

词性标记是作为单词过滤器的出色选择。在语言学中,代词、介词、连词和限定词被称为功能词,因为它们的主要功能是在句子内创建语法关系。名词、动词、形容词和副词是内容词,句子的意义主要取决于它们。

通常,我们只对内容词感兴趣。因此,我们可以使用词性标记来选择我们感兴趣的单词类型,并且丢弃其余部分。例如,可以生成一个仅包含文档中名词和专有名词的列表:

text = "My best friend Ryan Peters likes fancy adventure games."
doc = nlp(text)

nouns = [t for t in doc if t.pos_ in ['NOUN', 'PROPN']]
print(nouns)

输出:

[friend, Ryan, Peters, adventure, games]

我们可以很容易地为此目的定义一个更通用的过滤器函数,但是 textacy 的 extract.words 函数方便地提供了此功能。它还允许我们根据词性和其他标记属性(如 is_punctis_stop)进行过滤。因此,过滤函数允许同时进行词性选择和停用词过滤。在内部,它的工作原理与我们之前展示的名词过滤器所示的方式相同。

以下示例展示了如何从样本句子中提取形容词和名词的标记:

import textacy

tokens = textacy.extract.words(doc,
            filter_stops = True,           # default True, no stopwords
            filter_punct = True,           # default True, no punctuation
            filter_nums = True,            # default False, no numbers
            include_pos = ['ADJ', 'NOUN'], # default None = include all
            exclude_pos = None,            # default None = exclude none
            min_freq = 1)                  # minimum frequency of words

print(*[t for t in tokens], sep='|')

Out:

best|friend|fancy|adventure|games

最终,我们提取过滤后的词元列表的蓝图函数只是这个函数的一个小包装。通过转发关键字参数(**kwargs),这个函数接受与 textacy 的extract.words相同的参数。

def extract_lemmas(doc, **kwargs):
    return [t.lemma_ for t in textacy.extract.words(doc, **kwargs)]

lemmas = extract_lemmas(doc, include_pos=['ADJ', 'NOUN'])
print(*lemmas, sep='|')

Out:

good|friend|fancy|adventure|game

注意

使用词元而不是屈折词通常是个好主意,但并非总是如此。例如,在情感分析中,“好”和“最好”会产生不同的效果。

蓝图:提取名词短语

在第一章中,我们说明了如何使用 n-gram 进行分析。n-gram 是句子中n个连续词的简单枚举。例如,我们之前使用的句子包含以下二元组:

My_best|best_friend|friend_Ryan|Ryan_Peters|Peters_likes|likes_fancy
fancy_adventure|adventure_games

许多这些二元组对于分析并不十分有用,例如,likes_fancymy_best。对于三元组而言情况可能会更糟。但是我们如何检测具有实际含义的词序列呢?一种方法是对词性标记应用模式匹配。spaCy 具有一个相当强大的基于规则的匹配器,而 textacy 则提供了一个便捷的基于模式的短语提取包装器。以下模式提取带有前置形容词的名词序列:

text = "My best friend Ryan Peters likes fancy adventure games."
doc = nlp(text)

patterns = ["POS:ADJ POS:NOUN:+"]
spans = textacy.extract.matches(doc, patterns=patterns)
print(*[s.lemma_ for s in spans], sep='|')

Out:

good friend|fancy adventure|fancy adventure game

或者,您可以使用 spaCy 的doc.noun_chunks进行名词短语提取。但是,由于返回的块还可能包括代词和限定词,因此此功能不太适合用于特征提取:

print(*doc.noun_chunks, sep='|')

Out:

My best friend|Ryan Peters|fancy adventure games

因此,我们根据词性模式定义了我们的名词短语提取蓝图。该函数接受一个doc,一组词性标记以及一个分隔字符,用于连接名词短语中的单词。构造的模式搜索由形容词或名词后跟名词序列组成的短语。返回的是词元。我们的例子提取所有由形容词或名词后跟名词序列组成的短语:

def extract_noun_phrases(doc, preceding_pos=['NOUN'], sep='_'):
    patterns = []
    for pos in preceding_pos:
        patterns.append(f"POS:{pos} POS:NOUN:+")
    spans = textacy.extract.matches(doc, patterns=patterns)
    return [sep.join([t.lemma_ for t in s]) for s in spans]

print(*extract_noun_phrases(doc, ['ADJ', 'NOUN']), sep='|')

Out:

good_friend|fancy_adventure|fancy_adventure_game|adventure_game

蓝图:提取命名实体

命名实体识别指的是在文本中检测人物、地点或组织等实体的过程。每个实体可以由一个或多个标记组成,例如旧金山。因此,命名实体由Span对象表示。与名词短语类似,检索命名实体的列表以供进一步分析也很有帮助。

如果你再次查看表 4-3,你会看到用于命名实体识别的标记属性,ent_type_ent_iob_ent_iob_包含了一个标记是否开始一个实体(B)、是否在一个实体内部(I)或是否在外部(O)的信息。与遍历标记不同,我们还可以直接通过doc.ents访问命名实体。在这里,实体类型的属性被称为label_。让我们通过一个例子来说明这一点:

text = "James O'Neill, chairman of World Cargo Inc, lives in San Francisco."
doc = nlp(text)

for ent in doc.ents:
    print(f"({ent.text}, {ent.label_})", end=" ")

Out:

(James O'Neill, PERSON) (World Cargo Inc, ORG) (San Francisco, GPE)

spaCy 的displacy模块还提供命名实体识别的可视化,这大大增强了结果的可读性,并在视觉上支持误分类实体的识别:

from spacy import displacy

displacy.render(doc, style='ent')

命名实体被正确识别为一个人物、一个组织和一个地缘政治实体(GPE)。但请注意,如果您的语料库缺乏明确的语法结构,则命名实体识别的准确性可能不会很高。详细讨论请参阅“命名实体识别”。

对于特定类型的命名实体提取,我们再次利用 textacy 的一个便利函数:

def extract_entities(doc, include_types=None, sep='_'):

    ents = textacy.extract.entities(doc,
             include_types=include_types,
             exclude_types=None,
             drop_determiners=True,
             min_freq=1)

    return [sep.join([t.lemma_ for t in e])+'/'+e.label_ for e in ents]

例如,使用此函数我们可以检索PERSONGPE(地缘政治实体)类型的命名实体:

print(extract_entities(doc, ['PERSON', 'GPE']))

Out:

["James_O'Neill/PERSON", 'San_Francisco/GPE']

大型数据集上的特征提取

现在我们了解了 spaCy 提供的工具,我们最终可以构建我们的语言特征提取器了。图 4-3 说明了我们要做的事情。最终,我们希望创建一个可用作统计分析和各种机器学习算法输入的数据集。一旦提取完成,我们将在数据库中持久化预处理好的“即用”数据。

图 4-3. 使用 spaCy 从文本中提取特征。

蓝图:创建一个函数来获取所有内容

此蓝图函数将前面章节中的所有提取功能结合起来。它将我们想要提取的所有内容整齐地放在代码中的一个位置,这样如果您在此处添加或更改内容,后续步骤无需调整:

def extract_nlp(doc):
    return {
    'lemmas'          : extract_lemmas(doc,
                                     exclude_pos = ['PART', 'PUNCT',
                                        'DET', 'PRON', 'SYM', 'SPACE'],
                                     filter_stops = False),
    'adjs_verbs'      : extract_lemmas(doc, include_pos = ['ADJ', 'VERB']),
    'nouns'           : extract_lemmas(doc, include_pos = ['NOUN', 'PROPN']),
    'noun_phrases'    : extract_noun_phrases(doc, ['NOUN']),
    'adj_noun_phrases': extract_noun_phrases(doc, ['ADJ']),
    'entities'        : extract_entities(doc, ['PERSON', 'ORG', 'GPE', 'LOC'])
    }

该函数返回一个包含我们想要提取的所有内容的字典,如本例所示:

text = "My best friend Ryan Peters likes fancy adventure games."
doc = nlp(text)
for col, values in extract_nlp(doc).items():
    print(f"{col}: {values}")

Out:

lemmas: ['good', 'friend', 'Ryan', 'Peters', 'like', 'fancy', 'adventure', \
         'game']
adjs_verbs: ['good', 'like', 'fancy']
nouns: ['friend', 'Ryan', 'Peters', 'adventure', 'game']
noun_phrases: ['adventure_game']
adj_noun_phrases: ['good_friend', 'fancy_adventure', 'fancy_adventure_game']
entities: ['Ryan_Peters/PERSON']

返回的列名列表将在接下来的步骤中需要。我们不是硬编码它,而是简单地调用extract_nlp并传入一个空文档来检索列表:

nlp_columns = list(extract_nlp(nlp.make_doc('')).keys())
print(nlp_columns)

Out:

['lemmas', 'adjs_verbs', 'nouns', 'noun_phrases', 'adj_noun_phrases', 'entities']

蓝图:在大型数据集上使用 spaCy

现在我们可以使用此函数从数据集的所有记录中提取特征。我们获取并添加在本章开头创建和保存的清理文本及其标题:

db_name = "reddit-selfposts.db"
con = sqlite3.connect(db_name)
df = pd.read_sql("select * from posts_cleaned", con)
con.close()

df['text'] = df['title'] + ': ' + df['text']

在开始自然语言处理处理之前,我们初始化要填充值的新DataFrame列:

for col in nlp_columns:
    df[col] = None

spaCy 的神经模型受益于在 GPU 上运行。因此,在开始之前,我们尝试在 GPU 上加载模型:

if spacy.prefer_gpu():
    print("Working on GPU.")
else:
    print("No GPU found, working on CPU.")

现在我们需要决定使用哪个模型和流水线组件。记住要禁用不必要的组件以提高运行时效率!我们坚持使用默认流水线的小型英语模型,并使用我们自定义的分词器,在连字符上进行分割:

nlp = spacy.load('en_core_web_sm', disable=[])
nlp.tokenizer = custom_tokenizer(nlp) # optional

在处理较大数据集时,建议使用 spaCy 的批处理来获得显著的性能提升(在我们的数据集上大约提升了 2 倍)。函数nlp.pipe接受一个文本的可迭代对象,在内部作为一个批次处理它们,并按照输入数据的顺序生成处理过的Doc对象列表。

要使用它,我们首先必须定义一个批处理大小。然后我们可以循环处理这些批次并调用nlp.pipe

batch_size = 50

for i in range(0, len(df), batch_size):
    docs = nlp.pipe(df['text'][i:i+batch_size])

    for j, doc in enumerate(docs):
        for col, values in extract_nlp(doc).items():
            df[col].iloc[i+j] = values

在内部循环中,我们从处理过的doc中提取特征,并将这些值写回到DataFrame中。在没有使用 GPU 的数据集上,整个过程大约需要六到八分钟,在 Colab 上使用 GPU 时大约需要三到四分钟。

新创建的列非常适合使用来自第一章的函数进行频率分析。让我们来检查汽车类别中提到最频繁的名词短语:

count_words(df, 'noun_phrases').head(10).plot(kind='barh').invert_yaxis()

Out:

持久化结果

最后,我们将完整的DataFrame保存到 SQLite 中。为此,我们需要将提取的列表序列化为以空格分隔的字符串,因为大多数数据库不支持列表:

df[nlp_columns] = df[nlp_columns].applymap(lambda items: ' '.join(items))

con = sqlite3.connect(db_name)
df.to_sql("posts_nlp", con, index=False, if_exists="replace")
con.close()

结果表提供了一个坚实且可以直接使用的基础,用于进一步的分析。实际上,我们将在第十章再次使用这些数据来训练从提取的词形中得到的词向量。当然,预处理步骤取决于您要对数据执行什么操作。像我们的蓝图生成的单词集合这样的工作非常适合进行基于词袋向量化的任何类型的统计分析和机器学习。您将需要根据依赖于单词序列知识的算法来调整这些步骤。

关于执行时间的注意事项

完整的语言处理确实非常耗时。事实上,仅处理 20,000 个 Reddit 帖子就需要几分钟的时间。相比其他库,虽然 spaCy 处理速度非常快,但标记、解析和命名实体识别却是代价昂贵的。因此,如果您不需要命名实体,您应该明确地禁用解析器和命名实体识别,以节省超过 60%的运行时间。

使用nlp.pipe批处理数据并使用 GPU 是加快 spaCy 数据处理速度的一种方法。但是,一般来说,数据准备也是并行化的一个完美候选项。在 Python 中并行任务的一个选项是使用multiprocessing库。特别是对于数据框操作的并行化,还有一些可伸缩的替代方案值得一试,即DaskModinVaexpandarallel是一个直接向 Pandas 添加并行应用运算符的库。

无论如何,观察进展并获取运行时估计都是有帮助的。正如在第一章中已经提到的那样,tqdm库是一个非常好的工具,因为它为迭代器和数据框操作提供了进度条。我们的 GitHub 笔记本在可能的情况下都使用了 tqdm。

还有更多

我们从数据清洗开始,经历了整个语言处理的流程。然而,还有一些方面我们没有详细涉及,但可能对您的项目有帮助,甚至是必要的。

语言检测

许多语料库包含不同语言的文本。每当您处理多语言语料库时,您必须选择其中一个选项:

  • 如果其他语言仅代表可以忽略的少数群体,则将每个文本都视为语料库主要语言(例如英语)。

  • 将所有文本翻译为主要语言,例如,通过使用谷歌翻译。

  • 识别语言并在接下来的步骤中进行依赖语言的预处理。

有一些优秀的语言检测库。我们推荐使用 Facebook 的fastText 库。fastText 提供了一个预训练模型,可以快速准确地识别 176 种语言。我们在本章的GitHub 仓库中提供了一个使用 fastText 进行语言检测的额外蓝图。

textacy 的make_spacy_doc函数允许您在可用时自动加载相应的语言模型进行语言处理。默认情况下,它使用基于Google 的紧凑语言检测器 v3的语言检测模型,但您也可以接入任何语言检测功能(例如,fastText)。

拼写检查

用户生成的内容常常存在很多拼写错误。如果拼写检查器能自动纠正这些错误,那将会很棒。SymSpell是一个流行的拼写检查器,有一个Python 端口。然而,正如您从智能手机上了解到的那样,自动拼写纠正可能会引入有趣的错误。因此,您确实需要检查质量是否真正得到了提高。

令牌标准化

通常,相同术语存在不同拼写方式或要特别对待和统计相同的术语变体。在这种情况下,标准化这些术语并映射到共同标准是很有用的。以下是一些示例:

  • 美国或 U.S. → 美国

  • 点击泡沫 → 点 com 泡沫

  • 慕尼黑 → 慕尼黑

您可以使用 spaCy 的短语匹配器将这种规范化作为后处理步骤集成到其管道中。如果您不使用 spaCy,则可以使用简单的 Python 字典将不同拼写映射到其规范化形式。

总结和建议

“垃圾进,垃圾出”是数据项目中经常提到的问题。这在文本数据中尤为突出,因为文本数据本身就存在噪音。因此,数据清洗是任何文本分析项目中最重要的任务之一。花足够的精力确保高质量的数据,并进行系统化的检查。在本节中,我们展示了许多解决质量问题的方案。

可靠分析和稳健模型的第二个前提是规范化。许多文本机器学习算法基于词袋模型,该模型根据单词频率生成文档间的相似度概念。一般来说,当进行文本分类、主题建模或基于 TF-IDF 进行聚类时,最好使用词形还原后的文本。在更复杂的机器学习任务(如文本摘要、机器翻译或问答系统)中,模型需要反映语言的多样性,因此应避免或仅在必要时使用这些规范化或停用词去除方法。

^(1) Pandas 操作mapapply已在《蓝图:构建简单文本预处理流水线》中详细解释。

^(2) 专门用于 HTML 数据清理的库,如 Beautiful Soup,已在第三章中介绍。

^(3) 星号操作符(*)将列表展开为print函数的单独参数。

^(4) 例如,查看NLTK 的推特分词器用于文本表情符号和 URL 的正则表达式,或查看 textacy 的编译正则表达式

^(5) 有关概述,请参阅Craig Trim 的《Tokenization 艺术》

^(6) 查看spaCy 的网站,了解可用模型的列表。

^(7) 查看spaCy 的 API获取完整列表。

^(8) 查看spaCy 的 API获取完整的属性列表。

^(9) 查看spaCy 的分词使用文档获取详细信息和说明性示例。

^(10) 查看spaCy 的分词器使用文档获取详细信息。

^(11) 通过这种方式修改停用词列表可能在 spaCy 3.0 中被弃用。相反,建议创建各语言类的修改子类。本章的GitHub 笔记本提供详细信息。

^(12) 有关更多信息,请参阅通用词性标签

第五章:特征工程和句法相似性

正如我们在第一章中看到的那样,文本与结构化数据有着显著的不同。最引人注目的差异之一是,文本由单词表示,而结构化数据(大部分情况下)使用数字。从科学角度来看,数学研究的几个世纪已经对数字有了非常好的理解和复杂的方法。信息科学吸收了这些数学研究,并在此基础上发明了许多创造性的算法。机器学习的最新进展已经将许多以前非常特定的算法泛化,并使其适用于许多不同的用例。这些方法直接从数据中“学习”,并提供了一个无偏见的视角。

要使用这些工具,我们必须找到文本到数字的映射。考虑到文本的丰富性和复杂性,显然单一的数字无法代表文档的含义。需要更复杂的东西。在数学中,实数的自然扩展是一组实数,称为向量。几乎所有文本分析和机器学习中的文本表示都使用向量;详见第六章。

向量存在于向量空间中,大多数向量空间具有额外的属性,如范数和距离,这对我们是有帮助的,因为它们暗示了相似性的概念。正如我们将在后续章节中看到的,测量文档之间的相似性对于大多数文本分析应用至关重要,但它本身也很有趣。

您将学到什么,我们将构建什么

在本章中,我们将讨论文档的向量化。这意味着我们将把非结构化文本转换为包含数字的向量。

有很多方法可以用来向量化文档。由于文档向量化是所有机器学习任务的基础,我们将花一些时间来设计和实现我们自己的向量化器。如果您需要一个专门用于自己项目的向量化器,您可以将其用作蓝图。

随后,我们将关注两种已在 scikit-learn 中实现的流行模型:词袋模型和 TF-IDF 改进模型。我们将使用这些方法下载大量文档数据集并进行向量化。正如您将看到的那样,数据量和可扩展性可能会带来许多问题。

尽管向量化是更复杂的机器学习算法的基础技术,它也可以单独用于计算文档之间的相似性。我们将详细讨论其工作原理、优化方法以及如何实现可扩展性。对于更丰富的词表示,请参阅第十章,对于更上下文化的方法,请参阅第十一章。

在学习了本章后,您将了解如何使用单词或组合作为特征将文档转换为数字(向量)。^(1) 我们将尝试不同的文档向量化方法,您将能够确定适合您用例的正确方法。您将了解文档相似性为何重要以及计算它的标准方法。我们将通过一个包含许多文档的示例详细介绍如何有效地向量化它们并计算相似性。

第一部分通过实际构建一个简单的向量化器介绍了向量化器的概念。这可以作为您在项目中必须构建的更复杂的向量化器的蓝图。计算单词出现次数并将其用作向量称为词袋模型,并且已经创建了非常多功能的模型。

与数据集(拥有超过 1,000,000 个新闻标题)一起,我们在 TF-IDF 部分介绍了一个用例,并展示了可扩展的蓝图架构。我们将建立一个文档向量化的蓝图和文档的相似性搜索。更具挑战性的是,我们将尝试识别语料库中最相似(但非完全相同)的标题。

用于实验的玩具数据集

非常令人惊讶的是,许多实验证明,对于许多文本分析问题,只需知道单词是否出现在文档中就足够了。不必理解单词的含义或考虑单词顺序。由于底层映射特别简单且计算速度快,我们将从这些映射开始,并使用单词作为特征。

对于第一个蓝图,我们将集中在方法上,因此使用查尔斯·狄更斯的小说双城记中的几句话作为玩具数据集。我们将使用以下句子:

  • 最好的时代已经来临。

  • 最坏的时代已经过去。

  • 智慧的时代已经来临。

  • 愚蠢的时代已经过去。

蓝图:建立您自己的向量化器

由于向量化文档是本书后续章节的基础,我们深入探讨了向量化器的工作原理。通过实现我们自己的向量化器来最好地实现这一点。如果您需要在自己的项目中实现自定义向量化器或需要根据特定要求调整现有向量化器,可以使用本节中的方法。

为了尽可能简化,我们将实现所谓的单热向量化器。该向量化器通过记录单词是否出现在文档中来创建二进制向量,如果出现则为 1,否则为 0。

我们将开始创建一个词汇表并为单词分配编号,然后进行向量化,并在此二进制空间中分析相似性。

枚举词汇表

从单词作为特征开始,我们必须找到一种将单词转换为向量维度的方法。从文本中提取单词通过标记化完成,如第二章中解释的那样。^(2)

因为我们只关心一个单词是否出现在文档中,所以我们只需列举这些单词:

sentences = ["It was the best of times",
             "it was the worst of times",
             "it was the age of wisdom",
             "it was the age of foolishness"]

tokenized_sentences = [[t for t in sentence.split()] for sentence in sentences]
vocabulary = set([w for s in tokenized_sentences for w in s])

import pandas as pd
pd.DataFrame([[w, i] for i,w in enumerate(vocabulary)])

输出:

0
年龄 1
最好 2
愚蠢 3
4
5
6
时代 7
8
智慧 9
最坏 10

正如你所看到的,单词根据它们第一次出现的顺序进行了编号。这就是我们所说的“字典”,包括单词(词汇表)及其相应的编号。现在,我们可以使用这些数字而不是单词来排列它们到以下向量中。

文档向量化

要比较向量、计算相似性等等,我们必须确保每个文档的向量具有相同的维度。为了实现这一点,我们对所有文档使用相同的词典。如果文档中不包含某个词,我们就在相应的位置放置一个 0;否则,我们将使用 1。按照惯例,行向量用于表示文档。向量的维度与词典的长度一样大。在我们的例子中,这不是问题,因为我们只有少数几个词。然而,在大型项目中,词汇表很容易超过 10 万个词。

让我们在实际使用库之前计算所有句子的一热编码:

def onehot_encode(tokenized_sentence):
    return [1 if w in tokenized_sentence else 0 for w in vocabulary]

onehot = [onehot_encode(tokenized_sentence)
         for tokenized_sentence in tokenized_sentences]

for (sentence, oh) in zip(sentences, onehot):
    print("%s: %s" % (oh, sentence))

输出:

[0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1]: It was the best of times
[1, 1, 1, 0, 0, 0, 1, 1, 0, 1, 0]: it was the worst of times
[0, 1, 1, 0, 1, 0, 0, 1, 1, 1, 0]: it was the age of wisdom
[0, 1, 1, 1, 1, 0, 0, 1, 0, 1, 0]: it was the age of foolishness

对于每个句子,我们现在计算了一个向量表示。将文档转换为一热向量时,我们丢失了关于单词在文档中出现频率及顺序的信息。

超出词汇表的文档

如果我们尝试保持词汇表固定并添加新文档会发生什么?这取决于文档的单词是否已经包含在词典中。当然,可能所有单词都已知:

onehot_encode("the age of wisdom is the best of times".split())

输出:

[0, 1, 0, 0, 1, 0, 1, 0, 1, 1, 1]

然而,反之也完全可能。如果我们试图将只包含未知单词的句子向量化,我们会得到一个零向量:

onehot_encode("John likes to watch movies. Mary likes movies too.".split())

输出:

[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

这个句子在语料库中与其他句子没有“交互”。从严格的角度来看,这个句子与语料库中的任何句子都不相似。对于单个句子来说,这没有问题;如果这种情况经常发生,需要调整词汇表或语料库。

文档-词项矩阵

将每个文档的行向量排列成一个矩阵,其中行枚举文档,我们得到了文档-词项矩阵。文档-词项矩阵是所有文档的向量表示,也是本书中几乎所有机器学习任务的最基本构建块。在本章中,我们将用它来计算文档相似性:

pd.DataFrame(onehot, columns=vocabulary)

输出:

注意:对于文档-词项矩阵,使用列表和数组在词汇量较小时效果最佳。对于大词汇量,我们将不得不找到更聪明的表示方式。Scikit-learn 负责此事,并使用所谓的稀疏向量和矩阵来自SciPy

计算相似性

计算文档之间的相似性是通过计算对应位置的共同 1 的数量来进行的。在一热编码中,这是一种非常快速的操作,因为可以通过对向量进行AND运算并计算结果向量中的 1 的数量来计算。让我们计算前两个句子的相似性:

sim = [onehot[0][i] & onehot[1][i] for i in range(0, len(vocabulary))]
sum(sim)

Out:

4

我们经常会遇到的另一种计算相似性的可能方式是使用两个文档向量的标量积(通常称为点积)。标量积通过将两个向量的对应分量相乘并将这些乘积相加来计算。通过观察乘积只有在两个因子都为 1 时才为 1 的事实,我们有效地计算了向量中共同 1 的数量。让我们试一试:

np.dot(onehot[0], onehot[1])

Out:

4

相似性矩阵

如果我们有兴趣找出所有文档之间的相似性,有一个很棒的快捷方式可以只用一个命令计算所有的数值!从前一节的公式推广,我们得出文档 i 和文档 j 的相似性如下:

S ij = d i · d j

如果我们想要使用之前的文档-词项矩阵,我们可以将标量积写成一个和:

S ij = k D ik D jk = k D ik (D T ) kj = (D·D T ) ij

因此,这只是我们的文档-词项矩阵与其转置的矩阵乘积。在 Python 中,这现在很容易计算(输出中的句子已添加,以便更轻松地检查相似性):^(3)

np.dot(onehot, np.transpose(onehot))

Out:

array([[6, 4, 3, 3],       # It was the best of times
       [4, 6, 4, 4],       # it was the worst of times
       [3, 4, 6, 5],       # it was the age of wisdom
       [3, 4, 5, 6]])      # it was the age of foolishness

显然,最高的数值位于对角线上,因为每个文档最相似于它自己。矩阵必须是对称的,因为文档 AB 的相似性与 BA 的相似性相同。除此之外,我们可以看到第二个句子平均来说与所有其他句子最相似,而第三个和最后一个文档成对最相似(它们仅相差一个单词)。如果忽略大小写,第一个和第二个文档也是如此。

理解文档向量化器的工作原理对于实现自己的向量化器至关重要,但也有助于欣赏现有向量化器的所有功能和参数。这就是为什么我们实现了我们自己的向量化器。我们详细查看了向量化的不同阶段,从构建词汇表开始,然后将文档转换为二进制向量。

后来,我们分析了文档之间的相似性。事实证明,它们对应向量的点积是一个很好的度量。

独热向量在实践中也被广泛使用,例如在文档分类和聚类中。然而,scikit-learn 还提供了更复杂的向量化器,在接下来的几节中我们将使用它们。

词袋模型

独热编码已经为我们提供了文档的基本表示形式作为向量。然而,它没有处理文档中单词的出现次数。如果我们想计算每个文档中单词的频率,那么我们应该使用所谓的词袋表示法。

尽管有些简单,但这些模型被广泛使用。对于分类和情感检测等情况,它们表现合理。此外,还有像潜在狄利克雷分配(LDA)这样的主题建模方法,显式地需要词袋模型。^(4)

蓝图:使用 scikit-learn 的 CountVectorizer

不必自己实现词袋模型,我们使用 scikit-learn 提供的算法。

注意到相应的类被称为CountVectorizer,这是我们在 scikit-learn 中进行特征提取的第一次接触。我们将详细查看这些类的设计及其方法调用的顺序:

from sklearn.feature_extraction.text import CountVectorizer
cv = CountVectorizer()

我们来自独热编码的示例句子实际上非常简单,因为我们的数据集中没有句子包含多次单词。让我们再添加一些句子,并以此为基础使用 CountVectorizer

more_sentences = sentences + \
                 ["John likes to watch movies. Mary likes movies too.",
                  "Mary also likes to watch football games."]

CountVectorizer 分为两个明显的阶段:首先它必须学习词汇表;之后它可以将文档转换为向量。

拟合词汇表

首先,它需要学习词汇表。现在这更简单了,因为我们可以直接传递包含句子的数组:

cv.fit(more_sentences)

CountVectorizer(analyzer='word', binary=False, decode_error='strict',
                dtype=<class 'numpy.int64'>, encoding='utf-8', input='content',
                lowercase=True, max_df=1.0, max_features=None, min_df=1,
                ngram_range=(1, 1), preprocessor=None, stop_words=None,
                strip_accents=None, token_pattern='(?u)\\b\\w\\w+\\b',
                tokenizer=None, vocabulary=None)

不要担心所有这些参数;我们稍后会讨论重要的参数。让我们首先看看 CountVectorizer 使用的词汇表,这里称为特征名称

print(cv.get_feature_names())

Out:

['age', 'also', 'best', 'foolishness', 'football', 'games',
 'it',  'john', 'likes', 'mary', 'movies', 'of', 'the', 'times',
 'to', 'too', 'was', 'watch', 'wisdom', 'worst']

我们已经创建了一个词汇表和所谓的特征,使用 CountVectorizer。方便地,词汇表按字母顺序排序,这使我们更容易决定是否包含特定单词。

将文档转换为向量

在第二步中,我们将使用 CountVectorizer 将文档转换为向量表示:

dt = cv.transform(more_sentences)

结果是我们在上一节中已经遇到的文档-术语矩阵。然而,它是一个不同的对象,因为 CountVectorizer 创建了一个稀疏矩阵。让我们来检查一下:

dt

Out:

<6x20 sparse matrix of type '<class 'numpy.int64'>'
with 38 stored elements in Compressed Sparse Row format>

稀疏矩阵非常高效。它只需保存 38 个元素,而不是存储 6 × 20 = 120 个元素!稀疏矩阵通过跳过所有零元素来实现这一点。

让我们试着恢复我们先前的文档-术语矩阵。为此,我们必须将稀疏矩阵转换为(稠密的)数组。为了使其更易读,我们将其转换为 Pandas 的 DataFrame

pd.DataFrame(dt.toarray(), columns=cv.get_feature_names())

Out:

文档-词项矩阵看起来与我们的单热向量化器非常相似。但请注意,列是按字母顺序排列的,并且注意第五行有几个 2。这源自文档"John likes to watch movies. Mary likes movies too.",其中有很多重复词语。

蓝图:计算相似性

现在在文档之间找到相似性更加困难,因为仅仅计算文档中共同出现的 1 不再足够。一般来说,每个词的出现次数可能更多,我们必须考虑这一点。不能使用点积来做这个,因为它也对向量的长度(文档中的词数)敏感。此外,欧氏距离在高维向量空间中并不是很有用。这就是为什么通常使用文档向量之间的角度作为相似性的度量。两个向量之间的夹角的余弦定义如下:

cos ( 𝐚 , 𝐛 ) = 𝐚·𝐛 ||𝐚||·||𝐛|| = a i b i a i a i b i b i

Scikit-learn 通过提供cosine_similarity实用函数简化了这个计算。让我们来检查前两个句子的相似性:

from sklearn.metrics.pairwise import cosine_similarity
cosine_similarity(dt[0], dt[1])

Out:

array([[0.83333333]])

与早期章节中手工相似性比较起来,cosine_similarity提供了一些优势,因为它被适当地标准化,并且值只能在 0 到 1 之间。

计算所有文档的相似性当然也是可能的;scikit-learn 已经优化了cosine_similarity,因此可以直接传递矩阵:

pd.DataFrame(cosine_similarity(dt, dt)))

Out:

0 1 2 3 4 5
0 1.000000 0.833333 0.666667 0.666667 0.000000 0.000000
1 0.833333 1.000000 0.666667 0.666667 0.000000 0.000000
2 0.666667 0.666667 1.000000 0.833333 0.000000 0.000000
3 0.666667 0.666667 0.833333 1.000000 0.000000 0.000000
4 0.000000 0.000000 0.000000 0.000000 1.000000 0.524142
5 0.000000 0.000000 0.000000 0.000000 0.524142 1.000000

同样,矩阵在对角线上具有最高值是对称的。很容易看出文档对 0/1 和 2/3 最相似。文档 4/5 与其他文档没有任何相似性,但它们彼此之间有些相似性。回顾这些句子,这正是人们所期望的。

词袋模型适用于各种用例。对于分类、情感检测和许多主题模型,它们会偏向于最频繁出现的词语,因为它们在文档-词项矩阵中的数值最高。通常这些词语并不带有太多意义,可以定义为停用词。

由于这些方法高度依赖领域特定,更通用的方法会“惩罚”那些在所有文档语料库中出现太频繁的词语。这被称为TF-IDF 模型,将在下一节讨论。

TF-IDF 模型

在我们之前的例子中,许多句子以“这是时候”开头。这在很大程度上增加了它们的相似性,但实际上,您通过这些词获得的实际信息很少。TF-IDF 通过计算总词出现次数来处理这一点。它会减少常见词的权重,同时增加不常见词的权重。除了信息理论的测量[⁵]之外,在阅读文档时,您还可以观察到:如果遇到不常见的词,作者很可能想要用它们传达重要信息。

使用 TfidfTransformer 优化文档向量

如我们在第二章中所见,与计数相比,更好的信息衡量方法是计算倒排文档频率,并对非常常见的单词使用惩罚。TF-IDF 权重可以从词袋模型计算出来。让我们再次尝试使用先前的模型,看看文档-术语矩阵的权重如何变化:

from sklearn.feature_extraction.text import TfidfTransformer
tfidf = TfidfTransformer()
tfidf_dt = tfidf.fit_transform(dt)
pd.DataFrame(tfidf_dt.toarray(), columns=cv.get_feature_names())

Out:

正如您所见,有些词已经被缩小了(例如“it”),而其他词则没有被缩小那么多(例如“wisdom”)。让我们看看对相似性矩阵的影响:

pd.DataFrame(cosine_similarity(tfidf_dt, tfidf_dt))

Out:

0 1 2 3 4 5
0 1.000000 0.675351 0.457049 0.457049 0.00000 0.00000
1 0.675351 1.000000 0.457049 0.457049 0.00000 0.00000
2 0.457049 0.457049 1.000000 0.675351 0.00000 0.00000
3 0.457049 0.457049 0.675351 1.000000 0.00000 0.00000
4 0.000000 0.000000 0.000000 0.000000 1.000000 0.43076
5 0.000000 0.000000 0.000000 0.000000 0.43076 1.000000

我们确实达到了期望的效果!文档对 0/1 和 2/3 仍然非常相似,但数字也减少到一个更合理的水平,因为文档对在重要词语上有所不同。现在常见词的权重较低。

引入 ABC 数据集

作为实际的用例,我们将使用一份来自 Kaggle 的数据集,其中包含新闻标题。标题源自澳大利亚新闻源 ABC,时间跨度为 2003 至 2017 年。CSV 文件只包含时间戳和标题,没有标点符号,且全部小写。我们将 CSV 文件加载到 Pandas 的DataFrame中,并查看前几个文档:

headlines = pd.read_csv("abcnews-date-text.csv", parse_dates=["publish_date"])
print(len(headlines))
headlines.head()

Out:

1103663
发布日期 新闻标题
0 2003-02-19 ABA 决定不授予社区广播许可证...
1 2003-02-19 澳大利亚 ACT 州的火灾目击者必须意识到诽谤问题
2 2003-02-19 A G 呼吁举行基础设施保护峰会
3 2003-02-19 空中新西兰员工在澳大利亚罢工要求加薪
4 2003-02-19 空中新西兰罢工将影响澳大利亚旅客

此数据集中有 1,103,663 个标题。请注意,标题不包括标点符号,并且全部转换为小写。除了文本之外,数据集还包括每个标题的出版日期。

正如我们之前看到的,可以使用词袋模型(在 scikit-learn 术语中的计数向量)计算 TF-IDF 向量。由于使用 TF-IDF 文档向量非常常见,因此 scikit-learn 创建了一个“快捷方式”来跳过计数向量,直接计算 TF-IDF 向量。相应的类称为TfidfVectorizer,我们将在下面使用它。

在下面的内容中,我们还将fittransform的调用组合成了fit_transform,这样做很方便:

from sklearn.feature_extraction.text import TfidfVectorizer
tfidf = TfidfVectorizer()
dt = tfidf.fit_transform(headlines["headline_text"])

这可能需要一段时间,因为需要分析和向量化许多文档。查看文档-术语矩阵的维度:

dt

输出:

<1103663x95878 sparse matrix of type '<class 'numpy.float64'>'
with 7001357 stored elements in Compressed Sparse Row format>

行数是预期的,但是列数(词汇表)非常大,几乎有 100,000 个单词。通过简单的计算可以得出,对数据进行天真的存储会导致 1,103,663 * 95,878 个元素,每个浮点数使用 8 字节,大约使用 788GB 的 RAM。这显示了稀疏矩阵的令人难以置信的有效性,因为实际使用的内存只有“仅”56,010,856 字节(大约 0.056GB;通过dt.data.nbytes找到)。这仍然很多,但是可以管理。

不过,计算两个向量之间的相似性又是另一回事了。Scikit-learn(以及其基础 SciPy)针对稀疏向量进行了高度优化,但是进行示例计算(前 10,000 个文档的相似性)仍然需要一些时间:

%%time
cosine_similarity(dt[0:10000], dt[0:10000])

输出:

CPU times: user 154 ms, sys: 261 ms, total: 415 ms

Wall time: 414 ms

array([[1.      , 0.      , 0.      , ..., 0.        , 0.        , 0.        ],
       [0.      , 1.      , 0.      , ..., 0.        , 0.        , 0.        ],
       [0.      , 0.      , 1.      , ..., 0.        , 0.        , 0.        ],
       ...,
       [0.      , 0.      , 0.      , ..., 1.        , 0.16913596, 0.16792138],
       [0.      , 0.      , 0.      , ..., 0.16913596, 1.        , 0.33258708],
       [0.      , 0.      , 0.      , ..., 0.16792138, 0.33258708, 1.        ]])

在接下来的章节中进行机器学习时,许多这些线性代数计算是必要的,并且必须一遍又一遍地重复。通常操作随着特征数量呈二次方增长(O(N²))。优化矢量化,通过移除不必要的特征,不仅有助于计算相似性,而且对于可扩展的机器学习至关重要。

蓝图:降低特征维度

现在我们已经为我们的文档找到了特征,并用它们来计算文档向量。正如我们在示例中看到的,特征数量可能会非常大。许多机器学习算法需要大量计算,并且随着特征数量的增加而扩展,通常甚至是多项式的。因此,特征工程的一部分侧重于减少这些真正必要的特征。

在本节中,我们展示了如何实现这一点的蓝图,并衡量了它们对特征数量的影响。

移除停用词

首先,我们可以考虑删除具有最少含义的词语。尽管这取决于领域,但通常有一些最常见的英语单词列表,常识告诉我们通常可以忽略它们。这些词被称为停用词。常见的停用词包括冠词、助动词和代词。有关更详细的讨论,请参阅第四章。在删除停用词时要小心,因为它们可能包含在特殊文本中具有特定领域含义的某些词语!

由于几乎任何语言中都有几百个常见的停用词,因此这并没有极大地减少维度。然而,由于停用词非常常见,这应该显著减少存储元素的数量。这导致内存消耗更少,并且计算速度更快,因为需要相乘的数字更少。

让我们使用标准的 spaCy 停用词,并检查对文档-术语矩阵的影响。请注意,我们将停用词作为命名参数传递给 TfidfVectorizer

from spacy.lang.en.stop_words import STOP_WORDS as stopwords
print(len(stopwords))
tfidf = TfidfVectorizer(stop_words=stopwords)
dt = tfidf.fit_transform(headlines["headline_text"])
dt

Out:

305
<1103663x95600 sparse matrix of type '<class 'numpy.float64'>'
with 5644186 stored elements in Compressed Sparse Row format>

仅使用 305 个停用词,我们成功将存储的元素数量减少了 20%。矩阵的维数几乎相同,但由于确实出现在标题中的 95,878 - 95,600 = 278 个停用词较少,列数更少。

最小频率

查看余弦相似度的定义,我们可以很容易地看到,只有当两个向量在相应索引处具有非零值时,它们的分量才会有贡献。这意味着我们可以忽略所有出现少于两次的词!TfidfVectorizer(以及 CountVectorizer)有一个称为 min_df 的参数。

tfidf = TfidfVectorizer(stop_words=stopwords, min_df=2)
dt = tfidf.fit_transform(headlines["headline_text"])
dt

Out:

<1103663x58527 sparse matrix of type '<class 'numpy.float64'>'
with 5607113 stored elements in Compressed Sparse Row format>

显然,有很多单词仅出现一次(95,600 - 58,527 = 37,073)。这些单词也应该只存储一次;通过存储元素数量的检查,我们应该得到相同的结果:5,644,186 - 5,607,113 = 37,073。在执行此类转换时,集成这些合理性检查总是很有用的。

丢失信息

注意:通过使用 min_df=2,我们在向量化此文档语料库的标题时没有丢失任何信息。如果我们计划以后用相同的词汇量向量化更多文档,我们可能会丢失信息,因为在原始文档中仅出现一次的单词,在新文档中再次出现时,将无法在词汇表中找到。

min_df 也可以采用浮点值。这意味着一个词必须在至少一部分文档中出现。通常情况下,即使对于较低的 min_df 数量,这也会显著减少词汇量:

tfidf = TfidfVectorizer(stop_words=stopwords, min_df=.0001)
dt = tfidf.fit_transform(headlines["headline_text"])
dt

Out:

<1103663x6772 sparse matrix of type '<class 'numpy.float64'>'
with 4816381 stored elements in Compressed Sparse Row format>

这种转换可能过于严格,导致词汇量过低。根据文档的数量,您应将 min_df 设置为一个较低的整数,并检查对词汇表的影响。

最大频率

有时文本语料库可能有一个特殊的词汇表,其中有很多重复出现的术语,这些术语太特定,不能包含在停用词列表中。对于这种情况,scikit-learn 提供了max_df参数,可以消除语料库中过于频繁出现的术语。让我们看看当我们消除所有至少在 10% 的标题中出现的词时,维度是如何减少的:

tfidf = TfidfVectorizer(stop_words=stopwords, max_df=0.1)
dt = tfidf.fit_transform(headlines["headline_text"])
dt

Out:

<1103663x95600 sparse matrix of type '<class 'numpy.float64'>'
with 5644186 stored elements in Compressed Sparse Row format>

max_df设置为低至 10% 的值并没有消除任何一个词!^(6)我们的新闻标题非常多样化。根据您拥有的语料库类型,尝试使用max_df可能非常有用。无论如何,您都应该始终检查维度如何变化。

蓝图:通过使特征更具体来改进特征

到目前为止,我们只使用了标题的原始词,并通过停用词和频率计数减少了维度。我们还没有改变特征本身。通过语言分析,有更多的可能性。

进行语言分析

使用 spaCy,我们可以对所有标题进行词形还原,并只保留词形还原形式。这需要一些时间,但我们预计会找到一个更小的词汇表。首先,我们必须进行语言分析,这可能需要一些时间才能完成(参见第四章了解更多细节):

import spacy

nlp = spacy.load("en")
nouns_adjectives_verbs = ["NOUN", "PROPN", "ADJ", "ADV", "VERB"]
for i, row in headlines.iterrows():
    doc = nlp(str(row["headline_text"]))
    headlines.at[i, "lemmas"] = " ".join([token.lemma_ for token in doc])
    headlines.at[i, "nav"] = " ".join([token.lemma_ for token in doc
                     if token.pos_ in nouns_adjectives_verbs])

蓝图:使用词形还原代替单词进行文档向量化

现在,我们可以使用词形还原对数据进行向量化,并查看词汇表的减少情况:

tfidf = TfidfVectorizer(stop_words=stopwords)
dt = tfidf.fit_transform(headlines["lemmas"].map(str))
dt

Out:

<1103663x71921 sparse matrix of type '<class 'numpy.float64'>'
with 5053610 stored elements in Compressed Sparse Row format>

节省近 25,000 个维度是很多的。在新闻标题中,对数据进行词形还原可能不会丢失任何信息。在其他用例中,比如第十一章,情况完全不同。

蓝图:限制词类

使用之前生成的数据,我们可以限制自己只考虑名词、形容词和动词进行向量化,因为介词、连词等被认为带有很少的意义。这会再次减少词汇量:

tfidf = TfidfVectorizer(stop_words=stopwords)
dt = tfidf.fit_transform(headlines["nav"].map(str))
dt

Out:

<1103663x68426 sparse matrix of type '<class 'numpy.float64'>'
with 4889344 stored elements in Compressed Sparse Row format>

在这里几乎没有什么可以获得的,这可能是因为标题主要包含名词、形容词和动词。但是在您自己的项目中,情况可能完全不同。根据您分析的文本类型,限制词类不仅会减少词汇量,还会减少噪音。建议先尝试对语料库的一小部分进行操作,以避免由于昂贵的语言分析而导致长时间等待。

蓝图:移除最常见的单词

根据我们的学习,去除频繁出现的词可以导致文档-词矩阵的条目大大减少。在进行无监督学习时尤其有用,因为通常不会对常见的、无足轻重的常用词感兴趣。

为了进一步减少噪音,我们现在尝试消除最常见的英文单词。请注意,通常还会涉及可能具有重要含义的单词。有各种各样的单词列表;它们可以很容易地在互联网上找到。来自 Google 的列表非常流行,并直接可在 GitHub 上获取。Pandas 可以直接读取该列表,只需告诉它是一个没有列标题的 CSV 文件。然后,我们将指示TfidfVectorizer使用该列表作为停用词:

top_10000 = pd.read_csv("https://raw.githubusercontent.com/first20hours/\
google-10000-english/master/google-10000-english.txt", header=None)
tfidf = TfidfVectorizer(stop_words=set(top_10000.iloc[:,0].values))
dt = tfidf.fit_transform(headlines["nav"].map(str))
dt

Out:

<1103663x61630 sparse matrix of type '<class 'numpy.float64'>'
with 1298200 stored elements in Compressed Sparse Row format>

正如您所见,矩阵现在减少了 350 万个存储的元素。词汇量减少了 68,426 - 61,630 = 6,796 个词,因此 ABC 标题中甚至有超过 3,000 个最常见的英文单词没有被使用。

删除频繁单词是从数据集中去除噪音并集中于不常见单词的优秀方法。但是,刚开始使用时应该小心,因为即使频繁单词也有意义,并且它们在您的文档语料库中可能也具有特殊含义。我们建议额外执行这样的分析,但不应仅限于此。

蓝图:通过 N-Grams 添加上下文

到目前为止,我们仅使用单词作为特征(我们文档向量的维度),作为我们向量化的基础。使用这种策略,我们失去了大量的上下文信息。使用单词作为特征不尊重单词出现上下文。在后续章节中,我们将学习如何通过像词嵌入这样的复杂模型克服这种限制。在我们当前的示例中,我们将使用一种更简单的方法,并利用单词组合,即所谓的n-grams。两个词的组合称为bigrams;三个词的组合称为trigrams

幸运的是,CountVectorizerTfidfVectorizer具有相应的选项。与前几节试图减少词汇量的做法相反,我们现在通过词组增强词汇量。有许多这样的组合;它们的数量(以及词汇量)几乎与n的指数级增长。^(7) 因此,我们要小心,并从 bigrams 开始:

tfidf = TfidfVectorizer(stop_words=stopwords, ngram_range=(1,2), min_df=2)
dt = tfidf.fit_transform(headlines["headline_text"])
print(dt.shape)
print(dt.data.nbytes)
tfidf = TfidfVectorizer(stop_words=stopwords, ngram_range=(1,3), min_df=2)
dt = tfidf.fit_transform(headlines["headline_text"])
print(dt.shape)
print(dt.data.nbytes)

Out:

(1103663, 559961)
67325400
(1103663, 747988)
72360104

尽管 RAM 大小并没有增加太多,但将特征维度从 95,600 增加到 2,335,132 甚至 5,339,558 是相当痛苦的。对于某些需要特定上下文信息的任务(如情感分析),n-grams 非常有用。但是,始终注意维度是非常有用的。

还可以将 n-grams 与语言特征和常见单词结合起来,大大减少词汇量:

tfidf = TfidfVectorizer(ngram_range=(1,2),
        stop_words=set(top_10000.iloc[:,0].values))
dt = tfidf.fit_transform(headlines["nav"].map(str))
dt

Out:

<1103663x385857 sparse matrix of type '<class 'numpy.float64'>'
with 1753239 stored elements in Compressed Sparse Row format>
Compared to the original bigram vectorization with min_df=2 above,
there are just 82,370 dimensions left from 67,325,400

Scikit-learn 提供了许多不同的向量化器。通常,从TfidfVectorizer开始是个不错的主意,因为它是最多才多艺的之一。

TfidfVectorizer 的选项

TF-IDF 甚至可以关闭,因此可以无缝切换到CountVectorizer。由于参数众多,找到完美的选项可能需要一些时间。

找到正确的特征集通常是乏味的,并需要通过TfidfVectorizer的(许多)参数进行实验,如min_dfmax_df或通过 NLP 简化文本。在我们的工作中,我们已经通过将min_df设置为5max_df设置为0.7获得了良好的经验。最终,这些时间的投资是非常值得的,因为结果将严重依赖于正确的向量化。然而,并没有金弹,这种特征工程严重依赖于使用情况和向量计划使用。

TF-IDF 方法本身可以通过使用次正常术语频率或归一化所得到的向量来改进。后者对于快速计算相似性非常有用,我们将在本章后面演示其使用。前者主要适用于长文档,以避免重复单词获得过高的权重。

非常仔细地考虑特征维度

在我们以前的例子中,我们使用了单词和二元组作为特征。根据使用情况,这可能已经足够了。这对于像新闻这样有常见词汇的文本效果很好。但是,您经常会遇到特殊词汇的情况(例如科学出版物或写给保险公司的信函),这将需要更复杂的特征工程。

要牢记维度的数量。

正如我们所见,使用诸如ngram_range之类的参数可能会导致大的特征空间。除了 RAM 使用情况外,这也将成为许多机器学习算法的问题,因为会导致过拟合。因此,当更改参数或向量化方法时,始终考虑(增加)特征维度是一个好主意。

ABC 数据集中的语法相似性

相似性是机器学习和文本分析中最基本的概念之一。在这一部分中,我们将看一些在 ABC 数据集中找到相似文档的具有挑战性的问题。

在上一节中查看可能的向量化之后,我们现在将使用其中的一种方法来计算相似性。我们将提供一个蓝图,展示如何从 CPU 和 RAM 的角度高效执行这些计算。由于我们处理大量数据,因此必须广泛使用NumPy 库

在第一步中,我们使用停用词和二元组对数据进行向量化:

# there are "test" headlines in the corpus
stopwords.add("test")
tfidf = TfidfVectorizer(stop_words=stopwords, ngram_range=(1,2), min_df=2, \
                        norm='l2')
dt = tfidf.fit_transform(headlines["headline_text"])

现在我们可以将这些向量用于我们的蓝图。

蓝图:查找最接近虚构标题的标题

假设我们想要在我们的数据中找到一个与我们记得的标题最接近的标题,但只是粗略地。这很容易解决,因为我们只需对我们的新文档进行向量化:

made_up = tfidf.transform(["australia and new zealand discuss optimal apple \
 size"])

现在我们必须计算与语料库中每个标题的余弦相似度。我们可以通过循环来实现这一点,但使用 scikit-learn 的cosine_similarity函数会更容易:

sim = cosine_similarity(made_up, dt)

结果是一个“语料库中的头条数量” × 1 矩阵,其中每个数字表示与语料库中文档的相似性。使用np.argmax给出最相似文档的索引:

headlines.iloc[np.argmax(sim)]

Out:

publish_date           2011-08-17 00:00:00
headline_text    new zealand apple imports
Name: 633392, dtype: object

最相似的头条中没有苹果的大小澳大利亚,但它确实与我们虚构的头条有些相似。

蓝图:在大型语料库中找到两个最相似的文档(更加困难)

当处理多个文档的语料库时,您可能经常会被问到“是否有重复?”或“这之前提到过吗?” 这些问题都归结为在语料库中查找最相似(甚至可能是完全相同)的文档。我们将解释如何实现这一点,并再次以我们的示例数据集 ABC 来说明。头条的数量将被证明是一个挑战。

您可能会认为在语料库中找到最相似的文档就像计算所有文档之间的cosine_similarity一样简单。但是,这是不可能的,因为 1,103,663 × 1,103,663 = 1,218,072,017,569。即使是最先进的计算机的 RAM 也无法容纳超过一万亿个元素。完全可以执行所需的矩阵乘法,而无需等待太长时间。

显然,这个问题需要优化。由于文本分析经常需要处理许多文档,这是一个非常典型的挑战。通常,第一个优化步骤是深入研究所有需要的数字。我们可以轻松地观察到文档相似性关系是对称和标准化的。

换句话说,我们只需要计算相似矩阵的次对角线元素(图 5-1)

图 5-1. 需要在相似矩阵中计算的元素。只有对角线以下的元素需要计算,因为它们的数量与对角线上的元素镜像相同。对角线上的元素都是 1。

这将把元素数量减少到 1,103,663 × 1,103,662 / 2 = 609,035,456,953,可以在循环迭代中计算,并保留只有最相似的文档。然而,单独计算所有这些元素并不是一个好选择,因为必要的 Python 循环(每次迭代仅计算一个矩阵元素)会消耗大量 CPU 性能。

不是计算相似矩阵的各个元素,我们将问题分成不同的块,并一次计算 10,000 × 10,000 个 TF-IDF 向量的相似子矩阵^(8)。每个这样的矩阵包含 100,000,000 个相似度,仍然适合在 RAM 中。当然,这会导致计算太多元素,我们必须对 111 × 110 / 2 = 6,105 个子矩阵执行此操作(参见图 5-2)。

从前面的部分,我们知道迭代大约需要 500 毫秒来计算。这种方法的另一个优点是利用数据局部性,使我们更有可能在 CPU 缓存中拥有必要的矩阵元素。我们估计一切应该在大约 3,000 秒内完成,大约相当于一小时。

图 5-2. 将矩阵分成子矩阵,我们可以更轻松地计算;问题被分成块(这里是 4 × 4),块内的白色和对角线元素在计算时是冗余的。

我们能否改进这一点?是的,事实上,可以实现另一个 10 倍的加速。这通过使用 TfidfVectorizer 的相应选项来对 TF-IDF 向量进行归一化来实现。之后,可以使用 np.dot 计算相似性:^(9)

%%time
np.dot(dt[0:10000], np.transpose(dt[0:10000]))

输出:

CPU times: user 16.4 ms, sys: 0 ns, total: 16.4 ms
Wall time: 16 ms
<10000x10000 sparse matrix of type '<class 'numpy.float64'>'
with 1818931 stored elements in Compressed Sparse Row format>

每次迭代中,我们保存最相似的文档及其相似性,并在迭代过程中进行调整。为了跳过相同的文档(或更精确地说,具有相同文档向量的文档),我们只考虑相似性 < 0.9999。事实证明,在稀疏矩阵中使用 < 关系是极其低效的,因为所有不存在的元素都被假定为 0。因此,我们必须富有创造性地寻找另一种方法:

%%time
batch = 10000
max_sim = 0.0
max_a = None
max_b = None
for a in range(0, dt.shape[0], batch):
    for b in range(0, a+batch, batch):
        print(a, b)
        r = np.dot(dt[a:a+batch], np.transpose(dt[b:b+batch]))
        # eliminate identical vectors
        # by setting their similarity to np.nan which gets sorted out
        r[r > 0.9999] = np.nan
        sim = r.max()
        if sim > max_sim:
            # argmax returns a single value which we have to
            # map to the two dimensions
            (max_a, max_b) = np.unravel_index(np.argmax(r), r.shape)
            # adjust offsets in corpus (this is a submatrix)
            max_a += a
            max_b += b
            max_sim = sim

输出:

CPU times: user 6min 12s, sys: 2.11 s, total: 6min 14s
Wall time: 6min 12s

幸运的是,这没有花太多时间!max_amax_b 包含具有最大相似性的标题的索引(避免相同的标题)。让我们来看一下结果:

print(headlines.iloc[max_a])
print(headlines.iloc[max_b])

输出:

publish_date                                2014-09-18 00:00:00
headline_text    vline fails to meet punctuality targets report
Name: 904965, dtype: object
publish_date                         2008-02-15 00:00:00
headline_text    vline fails to meet punctuality targets
Name: 364042, dtype: object

使用块计算方法,我们在几分钟内计算了近万亿的相似性。由于我们找到了相似但不相同的文档,因此结果是可以解释的。不同的日期表明这些绝对也是不同的标题。

蓝图:查找相关词汇

到目前为止,我们已经分析了文档的相似性。但语料库在隐含中具有更多信息,具体而言是有关相关词汇的信息。在我们的意义上,如果单词在相同的文档中使用,则它们是相关的。如果这些单词经常一起出现在文档中,那么它们应该“更”相关。举个例子,考虑单词 zealand,它几乎总是与 new 一起出现;因此,这两个单词是相关的

我们希望与文档-术语矩阵而非文档-术语矩阵一起工作,这只是其转置形式。我们不再取行向量,而是取列向量。但是,我们需要重新向量化数据。假设两个词很少使用,并且它们恰好同时出现在同一标题中。它们的向量将是相同的,但这不是我们要寻找的情况。例如,让我们考虑一个名为 扎福德·毕布罗克斯 的人,在两篇文章中提到了他。我们的算法将为这些词分配 100%的相关分数。尽管这是正确的,但不是非常显著。因此,我们只考虑出现至少 1000 次的单词,以获得良好的统计显著性:

tfidf_word = TfidfVectorizer(stop_words=stopwords, min_df=1000)
dt_word = tfidf_word.fit_transform(headlines["headline_text"])

词汇量非常小,我们可以直接计算余弦相似性。将行向量变为列向量,我们只需转置矩阵,使用 NumPy 的方便 .T 方法:

r = cosine_similarity(dt_word.T, dt_word.T)
np.fill_diagonal(r, 0)

如果要找到最大条目,最简单的方法是将其转换为一维数组,通过 np.argsort 获取排序元素的索引,并恢复用于词汇查找的原始索引:

voc = tfidf_word.get_feature_names()
size = r.shape[0] # quadratic
for index in np.argsort(r.flatten())[::-1][0:40]:
    a = int(index/size)
    b = index%size
    if a > b:  # avoid repetitions
        print('"%s" related to "%s"' % (voc[a], voc[b]))

Out:

"sri" related to "lanka"
"hour" related to "country"
"seekers" related to "asylum"
"springs" related to "alice"
"pleads" related to "guilty"
"hill" related to "broken"
"trump" related to "donald"
"violence" related to "domestic"
"climate" related to "change"
"driving" related to "drink"
"care" related to "aged"
"gold" related to "coast"
"royal" related to "commission"
"mental" related to "health"
"wind" related to "farm"
"flu" related to "bird"
"murray" related to "darling"
"world" related to "cup"
"hour" related to "2014"
"north" related to "korea"

很容易解释这些结果。对于一些词组合(如 气候变化 ),我们已经恢复了频繁的二元组。另一方面,我们还可以看到在标题中并未相邻的相关词汇,如 饮酒驾驶 。通过使用转置的文档-术语矩阵,我们进行了一种 共现分析

长时间运行程序的技巧,如句法相似性

以下是长时间运行程序的一些效率提示:

在等待过长之前进行基准测试

在对整个数据集进行计算之前,通常先运行单个计算并推断整个算法将运行多长时间以及需要多少内存是非常有用的。您应该努力理解随着复杂性增加运行时间和内存消耗的增长方式(线性、多项式、指数)。否则,您可能不得不等待很长时间,然后发现几个小时(甚至几天)后仅完成了 10%的进度而内存已经耗尽。

尝试将问题分解为较小的部分

将问题分解为较小的块可以极大地帮助。正如我们在新闻语料库中最相似文档中所见,这样的运行只需大约 20 分钟,并且没有使用大量内存。与朴素方法相比,我们将在长时间运行后发现内存不足。此外,通过将问题分解为部分,您可以利用多核架构甚至将问题分布在多台计算机上。

总结与结论

在本节中,我们已准备好向量化和句法相似性的蓝图。几乎所有文本相关的机器学习项目(如分类、主题建模和情感检测)都需要文档向量作为其基础。

结果表明,特征工程是实现这些复杂机器学习算法出色性能的最强大杠杆之一。因此,尝试不同的向量化器,调整它们的参数,并观察生成的特征空间是一个绝佳的主意。确实有很多可能性,而且有充分的理由:尽管优化这一步骤需要一些时间,但通常是非常值得的,因为分析管道后续步骤的结果将极大受益于此。

本章中使用的相似性度量仅作为文档相似性的示例。对于更复杂的需求,还有更复杂的相似性算法,你将在后续章节中了解到。

在信息检索中,寻找相似文档是一个众所周知的问题。还有更复杂的评分方法,如BM25。如果你需要一个可扩展的解决方案,非常流行的Apache Lucene库(它是像Apache SolrElasticsearch这样的搜索引擎的基础)利用这一点,在生产场景中用于非常大的文档集合。

在接下来的章节中,我们将经常重新讨论相似性。我们将探讨如何整合单词语义和文档语义,并利用预先训练过的大型文档语料库来实现最先进的性能,使用迁移学习。

^(1) 在后面的章节中,我们将探讨向量化单词(第十章)和文档(第十一章)的其他可能性。

^(2) 有更复杂的算法来确定词汇,比如SentencePieceBPE,如果你想减少特征数,这些都值得一看。

^(3) 令人困惑的是,numpy.dot既用于点积(内积),也用于矩阵乘法。如果 Numpy 检测到两个行向量或列向量(即一维数组)具有相同的维度,它计算点积并生成一个标量。如果不是,而且传递的二维数组适合矩阵乘法,它执行这个操作并生成一个矩阵。所有其他情况都会产生错误。这很方便,但涉及到很多启发式方法。

^(4) 更多关于 LDA 的内容请参见第八章。

^(5) 例如,参见熵的定义,作为不确定性和信息的度量。基本上,这表明低概率值比更可能的值包含更多信息。

^(6) 当然,这与已使用的停用词列表有关。在新闻文章中,最常见的词通常是停用词。在特定领域的文本中,情况可能完全不同。使用停用词通常是更安全的选择,因为这些列表是经过精心策划的。

^(7) 如果所有词组合都可能且被使用,它将呈指数级增长。但由于这种情况不太可能发生,维度会以次指数级增长。

^(8) 我们选择了 10,000 个维度,因为生成的矩阵可以保持在内存中(即使在中等硬件上也应该可以使用大约 1 GB 的内存)。

^(9) 通过使用处理器特定的库,例如在 Anaconda 中订阅 Intel 频道,可以显著加快所有计算。这将使用 AVX2、AVX-512 等指令以及并行化。MKLOpenBlas 是线性代数库的不错选择。

第六章:文本分类算法

互联网通常被称为巨大的促成者:它通过在线工具和平台帮助我们在日常生活中取得很多成就。另一方面,它也可能是信息超载和无休止搜索的来源。无论是与同事、客户、合作伙伴还是供应商进行沟通,电子邮件和其他消息工具都是我们日常工作生活中固有的一部分。品牌通过社交媒体平台如 Facebook 和 Twitter 与客户互动,并获取产品的宝贵反馈。软件开发者和产品经理使用类似Trello的工单应用程序来跟踪开发任务,而开源社区则使用GitHub的问题跟踪和Bugzilla来追踪需要修复的软件缺陷或需要添加的新功能。

虽然这些工具对完成工作很有用,但它们也可能变得无法控制,并迅速成为信息泛滥的源头。许多电子邮件包含推广内容、垃圾邮件和营销通讯,通常会分散注意力。同样,软件开发者很容易被大量的错误报告和功能请求淹没,这些会降低他们的生产力。为了充分利用这些工具,我们必须采用分类、过滤和优先处理更重要信息与不太相关信息的技术。文本分类是其中一种技术,可以帮助我们实现这一目标。

最常见的例子是由电子邮件提供商提供的垃圾邮件检测。在这种文本分类应用中,每封收件箱中的电子邮件都会被分析,以确定其是否包含有意义和有用的内容,或者是否是无用的无关信息。这样一来,邮件应用程序就可以仅展示相关和重要的电子邮件,过滤掉不太有用的信息泛滥。另一个应用是分类进入的客户服务请求或软件错误报告。如果我们能够对它们进行分类并分配给正确的人员或部门,那么它们将会更快地得到解决。文本分类有多种应用场景,在本章中,我们将开发一个可以跨多个应用场景应用的蓝图。

您将学到什么以及我们将构建什么

在本章中,我们将使用监督学习技术构建文本分类的蓝图。我们将使用包含某个软件应用程序错误报告的数据集,并使用这个蓝图来预测这些错误的优先级及特定模块。学习完本章后,您将了解如何应用监督学习技术,将数据分为训练和测试部分,使用准确度指标验证模型性能,并应用交叉验证技术。您还将了解二元分类和多类分类等不同类型的文本分类。

引入 Java 开发工具 Bug 数据集

软件技术产品通常很复杂,并且由几个互动组件组成。例如,假设您是一个开发 Android 应用程序播放播客的团队的一部分。除了播放器本身外,还可以有诸如库管理器、搜索和发现等单独的组件。如果用户报告无法播放任何播客,则需要意识到这是一个需要立即解决的关键 bug。另一位用户可能会报告他们喜欢的播客未显示的问题。这可能不那么关键,但重要的是确定这是否需要由库管理团队处理,或者实际上是搜索和发现团队的问题。为了确保快速响应时间,准确分类问题并将其分配给正确的团队至关重要。Bug 是任何软件产品不可避免的一部分,但快速响应将确保客户满意并继续使用您的产品。

在本章中,我们将使用蓝图对 Java 开发工具(JDT)开源项目 中开发期间提出的 bug 和问题进行分类。JDT 项目是 Eclipse 基金会的一部分,该基金会开发 Eclipse 集成开发环境(IDE)。JDT 提供了开发人员使用 Eclipse IDE 编写 Java 代码所需的所有功能。JDT 用户使用 Bugzilla 工具报告 bug 和跟踪问题,Bugzilla 是一款流行的开源 bug 跟踪软件,也被 Firefox 和 Eclipse 平台等其他开源项目使用。包含所有这些项目的 bug 的数据集可以在 GitHub 上找到,我们将使用 JDT 项目的 bug 数据集。

以下部分加载了一个包含 JDT bug 数据集的 CSV 文件。该数据集包含 45,296 个 bug 和每个 bug 的一些可用特征。我们列出了报告的所有特征的列表,并更详细地查看了其中一些,以了解 bug 报告的具体内容:

df = pd.read_csv('eclipse_jdt.csv')
print (df.columns)
df[['Issue_id','Priority','Component','Title','Description']].sample(2)

输出:

Index(['Issue_id', 'Priority', 'Component', 'Duplicated_issue', 'Title',
       'Description', 'Status', 'Resolution', 'Version', 'Created_time',
       'Resolved_time'],
      dtype='object')

问题编号 优先级 组件 标题 描述
38438 239715 P3 UI TestCaseElement 的属性测试器不存在 I20080613-2000; ; 不确定这是否属于 JDT/Debug 还是 Platform/Debug。; ; 我今天在我的错误日志中看到了这个错误消息多次,但我还不确定如何重现它。; ; -- 错误详细信息...
44129 395007 P3 UI [package explorer] Java 包文件夹上不可用的刷新操作 M3.; ; 对于普通源文件夹,F5(刷新)作为上下文菜单项可用,但对于 e4 Java 包资源管理器中的 Java 包文件夹则不可用。; ; 请恢复 3.x 的功能。

根据前表显示的细节,我们可以看到每个 bug 报告包含以下重要特征:

问题编号

用于跟踪 bug 的问题的主键。

优先级

这个从 P1(最关键)到 P5(最不关键)变化,并定义了 bug 的严重程度(一个分类字段)。

组件

这指的是项目中特定的架构部分,bug 出现的地方。这可以是 UI、APT 等(一个分类字段)。

标题

这是用户输入的简短摘要,简要描述 bug(一个全文字段)。

描述

这是对产生 bug 的软件行为及其对使用影响的更详细描述(一个全文字段)。

在创建 bug 报告时,用户遵循 JDT Bugzilla 网站上提到的指南。这些指南描述了用户在提出 bug 时需要提供的信息,以便开发人员能够快速解决问题。该网站还包括帮助用户确定给特定 bug 分配优先级的指南。我们的蓝图将使用这些 bug 报告开发一个监督学习算法,该算法可用于自动为未来提出的任何 bug 分配优先级。

在前一节中,我们对数据集和每个 bug 报告的各种特征有了高层次的理解。现在让我们更详细地探索单个 bug 报告。我们随机抽样一个单个 bug(您可以选择不同的 random_state 值以查看不同的 bug),并转置结果,以便更详细地显示结果。如果不进行转置,描述特性将以截断的方式显示,而现在我们可以看到所有内容:

df.sample(1).T

Out:

11811
Issue_id 33113
Priority P3
Component 调试
Title 评估 URLClassLoader 中的 for 循环挂起问题
Description 调试 HelloWorld 程序中断到断点。在 DisplayView 中;突出显示并;显示以下代码片段:;; for (int i = 0; i < 10; i++) {; System.out.println(i);; };; 而不仅仅报告没有明确的返回值;调试器在; URLClassLoader;显然尝试加载 int 类。您需要多次单击“继续”按钮,直到评估完成。DebugView 不显示暂停的原因(线程仅标记为“评估”)。如果关闭“挂起未捕获异常”偏好设置,此行为将不会发生。
Status 验证通过
Resolution 已修复
Version 2.1
Created_time 2003-02-25 15:40:00 -0500
Resolved_time 2003-03-05 17:11:17 -0500

从上表中我们可以看到,这个错误是在调试组件中引发的,程序在评估for循环时会崩溃。我们还可以看到,用户给了一个中等优先级(P3),这个错误在一周内被修复了。我们可以看到,这个错误的报告者遵循了指南并提供了大量信息,这也帮助软件开发人员理解和识别问题并提供修复。大多数软件用户知道,他们提供的信息越多,开发人员理解问题并提供修复就越容易。因此,我们可以假设大多数错误报告包含足够的信息,以便我们创建一个监督学习模型。

输出图描述了不同优先级的错误报告分布情况。我们可以看到大多数错误被分配了 P3 级别。尽管这可能是因为 Bugzilla 将 P3 作为默认选项,但更可能的是这反映了用户在选择其错误报告的优先级时的自然倾向。他们认为该错误不具有高优先级(P1),同时又不希望他们的错误报告完全不被考虑,因此选择了 P5。这在许多现实现象中都有所体现,并通常称为正态分布,其中大多数观测值位于中心或平均值处,而末端的观测值较少。这也可以被视为钟形曲线的可视化。

df['Priority'].value_counts().sort_index().plot(kind='bar')

Out:

优先级为 P3 与其他优先级之间的巨大差异是构建监督学习模型的问题,并被称为类别不平衡。因为类别 P3 的观察数量比其他类别(P1、P2、P4 和 P5)大一个数量级,文本分类算法对 P3 错误的信息要比其他优先级(P1、P2、P4 和 P5)多得多:我们将看到优先级特征的类别不平衡如何影响我们的解决方案,并试图在蓝图中稍后克服这一问题。这与人类学习某些东西相似。如果你见过更多的某种结果的例子,你会更多“预测”相同的结果。

在下面的片段中,我们可以看到针对 JDT 的每个组件报告了多少个错误。UI 和核心组件比文档或 APT 组件报告的错误要多得多。这是预期的,因为软件系统的某些组件比其他组件更大更重要。例如,文档组件包括软件的文档部分,被软件开发人员用来理解功能,但可能不是一个工作组件。另一方面,核心组件是 JDT 的一个重要功能组件,因此分配给它的错误要多得多:

df['Component'].value_counts()

Out:

UI       17479
Core     13669
Debug    7542
Text     5901
APT      406
Doc      299
Name: Component, dtype: int64

蓝图:构建文本分类系统

我们将逐步构建文本分类系统,并将所有这些步骤结合起来,提供一个统一的蓝图。这种文本分类系统属于更广泛的监督学习模型类别。监督学习是指一类机器学习算法,它使用标记的数据点作为训练数据,来学习独立变量和目标变量之间的关系。学习这种关系的过程也称为训练机器学习模型。如果目标变量是连续的数值变量,如距离、销售单位或交易金额,我们会训练一个回归模型。然而,在我们的情况下,目标变量(优先级)是一个类别变量,我们将选择一个分类方法来训练监督学习模型。该模型将使用标题或描述等独立变量来预测错误的优先级或组件。监督机器学习方法旨在学习从输入到输出变量的映射函数,数学上定义如下:

y = f ( X )

在上述方程中,y 是输出或目标变量,f 是映射函数,X 是输入变量或一组变量。

由于我们使用包含标记目标变量的数据,这被称为监督学习。图 6-1 说明了监督学习模型的工作流程。工作流程分为两个阶段:训练阶段和预测阶段。训练阶段从包含训练观测(如错误报告)和相关标签(我们想要预测的优先级或软件组件)的训练数据开始。虽然许多训练观测的特征可以直接使用,但仅此可能不足以学习映射函数,我们希望增加领域知识以帮助模型更好地理解关系。例如,我们可以添加一个显示错误报告何时报告的特征,因为如果错误在周初报告,则很可能更快修复。这一步骤称为特征工程,其结果是每个文档的一组特征向量。监督学习模型的训练步骤接受特征向量及其相关标签作为输入,并试图学习映射函数。在训练步骤结束时,我们得到了映射函数,也称为训练模型,可以用来生成预测。

在预测阶段,模型接收到一个新的输入观察值(例如一个错误报告),并且像在训练阶段应用的方式一样转换文档以生成特征向量。新的特征向量被馈送到训练好的模型中以生成预测结果(例如一个错误的优先级)。通过这种方式,我们实现了一种预测标签的自动化方式。

图 6-1. 用于分类的监督学习算法工作流程。

文本分类是一个监督学习算法的示例,其中我们使用文本数据和文本向量化等自然语言处理技术来为给定的文档分配一个分类目标变量。分类算法可以归为以下几类:

二元分类

实际上,这是多类分类的特殊情况,其中一个观察值可以有两个值中的任何一个(二元)。例如,给定的电子邮件可以标记为垃圾邮件或非垃圾邮件。但是每个观察值只会有一个标签。

多类分类

在这种类型的分类算法中,每个观察值与一个标签相关联。例如,错误报告可以从优先级的五个类别 P1、P2、P3、P4 或 P5 中选择一个单一值。类似地,当尝试识别错误报告所在的软件组件时,每个错误可以属于六个类别之一(UI、核心、调试、文本、APT 或 Doc)。

多标签分类

在这种类型的分类算法中,每个观察值可以分配给多个标签。例如,一篇单一的新闻文章可以被标记为多个标签,如安全性、技术和区块链。可以使用多个二元分类模型来生成最终结果,但我们不会在我们的蓝图中涵盖此部分。

第一步:数据准备

在继续构建文本分类模型之前,我们必须执行一些必要的预处理步骤来清洁数据,并以适合机器学习算法应用的方式格式化数据。由于我们的目标是根据标题和描述来识别错误报告的优先级,我们只选择与文本分类模型相关的列。我们还使用 dropna 函数删除任何包含空值的行。最后,我们组合标题和描述列以创建单个文本值,并且应用 第四章 中的文本清洁蓝图来删除特殊字符。在删除特殊字符后,我们过滤掉那些文本字段少于 50 个字符的观察值。这些错误报告填写不正确,并且包含的问题描述很少,对于训练模型没有帮助:

df = df[['Title','Description','Priority']]
df = df.dropna()
df['text'] = df['Title'] + ' ' + df['Description']
df = df.drop(columns=['Title','Description'])
df.columns

输出:

	Index(['Priority', 'text'], dtype='object')

然后:

	df['text'] = df['text'].apply(clean)
	df = df[df['text'].str.len() > 50]
	df.sample(2)

输出:

优先级 文本
28311 P3 需要在生成文件时重新运行 APT 反依赖项 如果生成的文件满足另一个文件中的缺失类型,我们应在该文件上重新运行 APT,以修复新类型。当前的 Java 编译执行了正确的操作,但 APT 没有。需要跟踪具有缺失类型的文件,并在回合结束时重新编译生成新类型的文件。为了良好的性能,需要跟踪名称,并仅编译那些生成的缺失类型。
25026 P2 外部化字符串向导:可用性改进 M6 测试通过 由于大多数 Java 开发者不会面对 Eclipse 模式,我会将复选框移动到 Accessor 类的区域下方。此外,如果工作空间中不存在 org.eclipse.osgi.util.NLS,向导不应提供此选项。这将避免普通 Java 开发者面对此选项。

我们可以从前面两个缺陷报告的文本特征总结中看到,我们的清理步骤已删除了许多特殊字符;我们仍然保留了形成描述的代码结构和语句的大部分。这是模型可以用来理解缺陷的有用信息,也会影响其是否属于更高优先级的因素。

第二步:训练-测试分离

在训练监督学习模型的过程中,我们试图学习一个最接近真实世界行为的函数。我们利用训练数据中的信息来学习这个函数。随后,评估我们学到的函数与真实世界行为的接近程度至关重要,因此我们将整个数据集划分为训练集和测试集来实现这一目标。我们通常使用百分比划分数据,其中较大份额分配给训练集。例如,如果数据集有 100 个观测值,并且按 80-20 的比例进行训练-测试分离,则训练集将包含 80 个观测值,测试集将包含 20 个观测值。模型现在在训练集上进行训练,仅使用这 80 个观测值来学习函数。我们将使用这 20 个观测值的测试集来评估学习到的函数。如图 6-2 所示,这一过程进行了说明。

在训练阶段:

y train = F ( X train )

在评估过程中:

y prediction = F ( X test )

图 6-2. 以 80-20 比例划分的训练-测试集。

模型仅看到训练集中的 80 个观测数据,并且学到的函数现在应用于完全独立和未见过的测试集上以生成预测。我们知道测试集中目标变量的真实值,并将这些与预测进行比较,以真实地评估学到的函数的表现以及它与真实世界行为的接近程度:

a c c u r a c y = e r r o r _ m e t r i c ( y prediction , y true )

在测试分割上评估学习到的模型提供了文本分类模型错误的无偏估计,因为测试分割中的观察结果是从训练观察结果中随机抽样的,不是学习过程的一部分。测试分割将在模型评估过程中使用,并且有几种可以用来衡量此错误的度量标准,这将在“第 4 步:模型评估”中讨论。

我们使用sklearn.model_selection.train_test_split函数来实现训练-测试分割,并将test_size参数设为 0.2(表示我们的数据的 20%作为测试分割)。此外,我们还必须指定我们的自变量和目标变量,该方法会返回一个包含四个元素的列表;前两个元素是自变量拆分为训练和测试分割,后两个元素是目标变量拆分。该函数的一个重要参数是random_state。这个数字影响着如何对行进行抽样,因此哪一组观察结果进入训练分割,哪一组观察结果进入测试分割。如果提供不同的数字,80-20 分割将保持不变,但不同的观察结果将进入训练和测试分割。重要的是要记住,要复制相同的结果,你必须选择相同的random_state值。例如,如果你想要检查在添加新的自变量后模型的变化情况,你必须能够比较添加新变量前后的准确度。因此,你必须使用相同的random_state,以便确定是否发生了变化。要注意的最后一个参数是stratify,它确保目标变量的分布在训练和测试分割中保持不变。如果这个分布没有保持不变,那么训练分割中某个类别的观察结果可能会有更多,这不符合训练数据中的分布,导致模型学习一个不现实的函数:

X_train, X_test, Y_train, Y_test = train_test_split(df['text'],
                                                    df['Priority'],
                                                    test_size=0.2,
                                                    random_state=42,
                                                    stratify=df['Priority'])

print('Size of Training Data ', X_train.shape[0])
print('Size of Test Data ', X_test.shape[0])

输出:

Size of Training Data  36024
Size of Test Data  9006

第三步:训练机器学习模型

创建文本分类蓝图的下一步是使用适当的算法训练监督式机器学习模型。当处理文本分类时,SVM 是一种常用的算法之一,我们将首先介绍该方法,然后说明为什么它非常适合我们的任务。

考虑一个在 X-Y 平面上的点集,每个点属于两个类别中的一个:十字或圆圈,如图 6-3 所示。支持向量机通过选择一条清晰地分隔这两个类别的直线来工作。当然,可能存在几条这样的直线(用虚线选项表示),算法选择能在最靠近的十字和圆圈点之间提供最大分离的直线。这些最靠近的十字和圆圈点称为支持向量。在示例中,我们能够识别出一个能够清晰分隔十字和圆圈点的超平面,但实际情况中可能难以实现这一点。例如,可能有几个圆圈点位于极左侧,这时生成超平面就不可能了。算法通过允许一定灵活性的容差参数tol来处理这种情况,并在决定超平面时接受误分类点的错误。

图 6-3. 简单二维分类示例中的超平面和支持向量。

在继续运行支持向量机模型之前,我们必须将文本数据准备成算法可以使用的合适格式。这意味着我们必须找到一种方法将文本数据表示为数值格式。最简单的方法是计算每个词在一个缺陷报告中出现的次数,并将所有词的计数组合起来,为每个观察结果创建一个数值表示。这种技术的缺点是常见的单词将有很大的值,并可能被误认为是重要特征,这种情况并非真实。因此,我们采用首选选项,即使用词频逆文档频率(TF-IDF)向量化来表示文本,详细解释请参见第五章。

tfidf = TfidfVectorizer(min_df = 10, ngram_range=(1,2), stop_words="english")
X_train_tf = tfidf.fit_transform(X_train)

在前一步执行的 TF-IDF 向量化生成了一个稀疏矩阵。当处理文本数据时,SVM 算法更为适用,因为它更适合处理稀疏数据,相比其他算法如随机森林。它们还更适合处理纯数值型输入特征(就像我们的情况),而其他算法则能够处理数值和分类输入特征的混合。对于我们的文本分类模型,我们将使用由 scikit-learn 库提供的sklearn.svm.LinearSVC模块。实际上,SVM 可以使用不同的核函数进行初始化,线性核函数在处理文本数据时推荐使用,因为可以考虑到大量线性可分的特征。它也更快速适应,因为需要优化的参数更少。scikit-learn 包提供了线性 SVM 的不同实现,如果你有兴趣,可以通过阅读“SVC Versus LinearSVC Versus SGDClassifier”来了解它们之间的区别。

在以下代码中,我们使用特定的random_state初始化模型,并指定了容差值为 0.00001。这些参数是针对我们使用的模型类型具体指定的,我们将在本章后面展示如何为这些参数值找到最优值。现在,我们从指定一些默认值开始,然后调用fit方法,确保使用我们在前一步创建的向量化独立变量:

model1 = LinearSVC(random_state=0, tol=1e-5)
model1.fit(X_train_tf, Y_train)

Out:

LinearSVC(C=1.0, class_weight=None, dual=True, fit_intercept=True,
          intercept_scaling=1, loss='squared_hinge', max_iter=1000,
          multi_class='ovr', penalty='l2', random_state=0, tol=1e-05,
          verbose=0)

在执行上述代码后,我们使用训练数据拟合了一个模型,结果显示了生成的模型的各种参数。由于我们只指定了random_state和容差,大多数参数都是默认值。

第四步:模型评估

现在我们有一个可以用来预测测试集中所有观测目标变量的模型。对于这些观测,我们也知道真实的目标变量,因此我们可以计算我们模型的表现。有许多可以用来量化我们模型准确性的指标,在本节中我们将介绍其中三个。

验证我们的文本分类模型最简单的方法是通过准确率:模型正确预测数量与观测总数的比率。数学上可以表示如下:

A c c u r a c y = Numberofcorrectpredictions Totalnumberofpredictionsmade

为了衡量模型的准确性,我们使用训练好的模型生成预测并与真实值进行比较。为了生成预测,我们必须对独立变量的测试集应用相同的向量化,然后调用训练模型的预测方法。一旦我们有了预测结果,我们可以使用下面展示的accuracy_score方法来自动生成这个度量,通过比较测试集的真实值和模型预测值来完成:

X_test_tf = tfidf.transform(X_test)

Y_pred = model1.predict(X_test_tf)
print ('Accuracy Score - ', accuracy_score(Y_test, Y_pred))

Out:

Accuracy Score -  0.8748612036420165

如您所见,我们取得了 87.5%的高准确率,表明我们有一个能够准确预测缺陷优先级的好模型。请注意,如果您使用不同的random_state初始化模型,则可能得到不同但相似的分数。始终比较训练模型与基线方法(可能基于简单的经验法则或业务知识)的表现是个好主意。我们可以使用sklearn.svm.DummyClassifier模块,它提供诸如most_frequent的简单策略,基线模型始终预测出现频率最高的类别,或者stratified,它生成符合训练数据分布的预测:

clf = DummyClassifier(strategy='most_frequent')
clf.fit(X_train, Y_train)
Y_pred_baseline = clf.predict(X_test)
print ('Accuracy Score - ', accuracy_score(Y_test, Y_pred_baseline))

输出:

Accuracy Score -  0.8769709082833667

我们可以清楚地看到,我们训练的模型并未增加任何价值,因为其表现与始终选择 P3 类别的基线相当。我们还需深入挖掘模型在不同优先级上的表现如何。它在预测 P1 或 P5 优先级方面表现更好吗?为了分析这一点,我们可以使用另一个评估工具,称为混淆矩阵。混淆矩阵是一个网格,比较了所有分类观察的预测值与实际值。混淆矩阵最常见的表示是针对只有两个标签的二元分类问题。

我们可以通过将一个类别视为 P3,将另一个类别视为所有其余类别,来修改我们的多类别分类问题以适应这种表示。让我们看看图 6-4,这是一个仅预测特定缺陷是否具有优先级 P3 的混淆矩阵的示例表示。

图 6-4. 优先级 P3 和非 P3 的混淆矩阵。

行代表预测结果,列代表实际值。矩阵中的每个单元格都是落入该格的观察计数:

真阳性

预测为正且确实为正的观察计数。

真阴性

预测为负且确实为负的观察计数。

假阳性

预测为正但实际为负的观察计数。

假阴性

预测为负但实际为正的观察计数。

基于此列表,我们可以使用以下方程自动推导出准确度度量:

A c c u r a c y = (TruePositive+TrueNegative) (TruePositive+TrueNegative+FalsePositive+FalseNegative)

这不过是所有预测正确与总预测数的比率而已。

精确率和召回率

使用混淆矩阵的真正价值在于精确率和召回率等其他度量,这些度量能够更深入地了解模型在不同类别下的表现。

让我们考虑正(P3)类,并考虑精确率:

P r e c i s i o n = TruePositive (TruePositive+FalsePositive)

此指标告诉我们预测的正例中实际上是正例的比例,或者说我们的模型在预测正类时的准确性。如果我们希望对我们的正面预测有把握,那么这是一个必须最大化的指标。例如,如果我们将电子邮件分类为垃圾邮件(正类),那么我们必须在这方面做到准确;否则,一封好的电子邮件可能会意外地发送到垃圾邮件文件夹。

源自混淆矩阵的另一个衡量指标是召回率:

R e c a l l = TruePositive (TruePositive+FalseNegative)

此指标告诉我们实际正值中被我们的模型识别的比例。高召回率意味着我们的模型能够捕捉现实中大多数的正类分类。这在成本未识别正例很高的情况下尤为重要,例如,如果一个患者患有癌症但我们的模型未能识别出来。

从前面的讨论中,我们可以得出结论,无论模型的应用是什么,精确度和召回率都是重要的指标。F1 分数是一个创建这两个度量的调和平均值的指标,也可以用作评估模型整体准确性的代理:

F 1 S c o r e = 2(PrecisionRecall) (Precision+Recall)

现在我们已经对混淆矩阵有了理解,让我们回到我们的蓝图,并添加评估训练模型的混淆矩阵的步骤。请注意,早期的表示被简化为二元分类,而我们的模型实际上是一个多类分类问题,因此混淆矩阵会相应地改变。例如,我们模型的混淆矩阵可以通过函数confusion_matrix生成,如下所示:

Y_pred = model1.predict(X_test_tf)
confusion_matrix(Y_test, Y_pred)

输出:

array([[  17,    6,  195,    5,    0],
       [   7,   14,  579,    7,    0],
       [  21,   43, 7821,   13,    0],
       [   0,    7,  194,   27,    0],
       [   0,    0,   50,    0,    0]])

这也可以通过使用plot_confusion_matrix函数以热图形式进行可视化,如下所示:

plot_confusion_matrix(model1,X_test_tf,
                      Y_test, values_format='d',
                      cmap=plt.cm.Blues)
plt.show()

我们可以使用与前述相同的方法为每个类别定义精确度和召回率,但现在还将包括被错误分类到其他类别的观察计数。

例如,类别 P3 的精度可以计算为正确预测的 P3 值(7,821)与所有预测的 P3 值(195 + 579 + 7,821 + 194 + 50)的比率,结果如下:

精确度(P3) = 7,821 / 8,839 = 0.88

类似地,P3 的召回率可以计算为正确预测的 P3 值与所有实际 P3 值(21 + 43 + 7,821 + 13 + 0)的比率,结果如下:

召回率(P2) = 7,821 / 7,898 = 0.99

直接确定这些度量的更简单方法是使用 scikit-learn 的classification_report函数,它可以自动计算这些值:

print(classification_report(Y_test, Y_pred))

输出:

              precision    recall  f1-score   support

          P1       0.38      0.08      0.13       223
          P2       0.20      0.02      0.04       607
          P3       0.88      0.99      0.93      7898
          P4       0.52      0.12      0.19       228
          P5       0.00      0.00      0.00        50

    accuracy                           0.87      9006
   macro avg       0.40      0.24      0.26      9006
weighted avg       0.81      0.87      0.83      9006

根据我们的计算和之前的分类报告,一个问题变得明显:尽管 P3 类别的召回率和精确度值相当高,但其他类别的这些值很低,甚至在某些情况下为 0(P5)。模型的整体准确率为 88%,但如果我们硬编码我们的预测始终为 P3,这也将在 88%的时间内是正确的。这清楚地表明我们的模型并未学习到太多显著的信息,而只是预测了多数类别。这凸显了在模型评估期间,我们必须分析几个指标,而不能仅依赖准确率。

类别不平衡

模型表现如此的原因是由于我们之前观察到的优先级类别中的类别不平衡。尽管 P3 优先级有接近 36,000 个错误,但其他优先级类别的错误数量只有大约 4,000 个,其他情况更少。这意味着当我们训练我们的模型时,它只能学习 P3 类别的特征。

有几种技术可以用来解决类别不平衡的问题。它们属于上采样和下采样技术的两类。上采样技术是指用于人工增加少数类观测数量(例如我们例子中的非 P3 类别)的方法。这些技术可以从简单地添加多个副本到使用 SMOTE 等方法生成新观测数据。^(1) 下采样技术是指用于减少多数类观测数量(例如我们例子中的 P3 类别)的方法。我们将选择随机下采样 P3 类别,使其观测数量与其他类别相似:

# Filter bug reports with priority P3 and sample 4000 rows from it
df_sampleP3 = df[df['Priority'] == 'P3'].sample(n=4000)

# Create a separate DataFrame containing all other bug reports
df_sampleRest = df[df['Priority'] != 'P3']

# Concatenate the two DataFrame to create the new balanced bug reports dataset
df_balanced = pd.concat([df_sampleRest, df_sampleP3])

# Check the status of the class imbalance
df_balanced['Priority'].value_counts()

Out:

P3    4000
P2    3036
P4    1138
P1    1117
P5    252
Name: Priority, dtype: int64

请注意,在执行下采样时,我们正在丢失信息,这通常不是一个好主意。但是,每当遇到类别不平衡的问题时,这会阻止我们的模型学习正确的信息。我们尝试通过使用上采样和下采样技术来克服这一问题,但这将始终涉及到数据质量的妥协。虽然我们选择了一种简单的方法,请查看下面的侧边栏,了解处理这种情况的各种方法。

文本分类最终蓝图

现在,我们将结合到目前为止列出的所有步骤,创建我们的文本分类蓝图:

# Loading the balanced DataFrame

df = df_balanced[['text', 'Priority']]
df = df.dropna()

# Step 1 - Data Preparation

df['text'] = df['text'].apply(clean)

# Step 2 - Train-Test Split
X_train, X_test, Y_train, Y_test = train_test_split(df['text'],
                                                    df['Priority'],
                                                    test_size=0.2,
                                                    random_state=42,
                                                    stratify=df['Priority'])
print('Size of Training Data ', X_train.shape[0])
print('Size of Test Data ', X_test.shape[0])

# Step 3 - Training the Machine Learning model

tfidf = TfidfVectorizer(min_df=10, ngram_range=(1, 2), stop_words="english")
X_train_tf = tfidf.fit_transform(X_train)

model1 = LinearSVC(random_state=0, tol=1e-5)
model1.fit(X_train_tf, Y_train)

# Step 4 - Model Evaluation

X_test_tf = tfidf.transform(X_test)
Y_pred = model1.predict(X_test_tf)
print('Accuracy Score - ', accuracy_score(Y_test, Y_pred))
print(classification_report(Y_test, Y_pred))

Out:

Size of Training Data  7634
Size of Test Data  1909
Accuracy Score -  0.4903090623363017
              precision    recall  f1-score   support

          P1       0.45      0.29      0.35       224
          P2       0.42      0.47      0.44       607
          P3       0.56      0.65      0.61       800
          P4       0.39      0.29      0.33       228
          P5       0.00      0.00      0.00        50

    accuracy                           0.49      1909
   macro avg       0.37      0.34      0.35      1909
weighted avg       0.47      0.49      0.48      1909

根据结果,我们可以看到我们的准确率现在达到了 49%,这不太好。进一步分析,我们可以看到对于 P1 和 P2 优先级,精确度和召回率值已经提高,这表明我们能够更好地预测具有这些优先级的错误。然而,显然对于 P5 优先级的错误,这个模型并没有提供任何信息。我们看到这个模型比使用分层策略的简单基线模型表现更好,如下所示。尽管早期的模型具有更高的准确性,但实际上并不是一个好模型,因为它是无效的。这个模型也不好,但至少呈现了一个真实的画面,并告诉我们我们不能用它来生成预测:

clf = DummyClassifier(strategy='stratified')
clf.fit(X_train, Y_train)
Y_pred_baseline = clf.predict(X_test)
print ('Accuracy Score - ', accuracy_score(Y_test, Y_pred_baseline))

输出:

Accuracy Score -  0.30434782608695654

下面是一些我们模型对这些优先级的预测准确的示例:

# Create a DataFrame combining the Title and Description,
# Actual and Predicted values that we can explore
frame = { 'text': X_test, 'actual': Y_test, 'predicted': Y_pred }
result = pd.DataFrame(frame)

result[((result['actual'] == 'P1') | (result['actual'] == 'P2')) &
       (result['actual'] == result['predicted'])].sample(2)

输出:

文本 实际 预测
64 Java 启动器:如果只有一个元素,不要提示要启动的元素。我想通过选择它并单击调试工具项来调试一个 CU。我被提示选择一个启动器,然后我还必须在第二页上选择唯一可用的类。第二步是不必要的。第一页上的下一步按钮应该被禁用。注意:DW,第一次在工作空间中启动某个东西时,你必须经历这种痛苦...这是由于调试器对不同语言是可插拔的。在这种情况下,启动器选择是通用调试支持,选择要启动的类是特定于 Java 调试支持。为了促进懒加载插件并避免启动器对可启动目标进行详尽搜索,启动器选择页面不会轮询可插拔的启动页面,以查看是否可以使用当前选择完成。一旦你为项目选择了默认启动器,启动器选择页面就不会再打扰你。移至非活动状态以供 6 月后考虑
5298 快速步进 toString 当您快速步进并选择一个对象显示详细信息时,我们会在日志中得到异常。这是因为 toString 尝试在步骤进行时进行评估。我们必须允许在评估过程中进行步进,所以这是一个棘手的时间问题。 </log-entr P1 P1

以下是模型预测不准确的情况:

result[((result['actual'] == 'P1') | (result['actual'] == 'P2')) &
       (result['actual'] != result['predicted'])].sample(2)

输出:

文本 实际 预测
4707 Javadoc 向导:默认包存在问题 20020328 1. 空项目。在默认包中创建 A.java 2. 启动导出向导选择默认包按下完成按钮 3. 创建失败 javadoc:包 A 的源文件不存在 为包 A 加载源文件... 1 个错误 不知道这是否是一般的 javadoc 问题 P1 P2
16976 断点条件编译器不应关心非 NLS 字符串 我有一个项目,在这个项目中,我设置了编译器选项,将非外部化字符串的使用设置为警告。当我想在包含字符串对象.equals 的断点条件上设置条件时,由于编译错误,我总是在这一点上中断……然后我不得不这样写我的条件:boolean cond = object.equals //\(NON-NLS-1\) return cond 以避免这个问题。调试器是否可以使用特定的编译器,它将忽略当前项目/工作区的编译器选项,而仅使用默认的选项呢? P2 P3

我们的模型不准确,从观察预测结果来看,不清楚描述和优先级之间是否存在关系。为了提高模型的准确性,我们必须执行额外的数据清理步骤,如词形还原,去除噪声标记,修改min_dfmax_df,包括三元组等。我们建议您修改“大数据集上的特征提取”中提供的当前clean函数,并检查其性能。另一种选择是确定所选模型的正确超参数,在下一节中,我们将介绍交叉验证和网格搜索技术,这些技术可以帮助我们更好地理解模型性能,并得出优化的模型。

Blueprint: 使用交叉验证估算实际准确度指标

在训练模型之前,我们创建了一个训练-测试分离,以便能够准确评估我们的模型。根据测试分离,我们得到了 48.7%的准确度。然而,我们希望提高这个准确度。我们可以使用的一些技术包括添加额外的特征,如三元组,添加额外的文本清理步骤,选择不同的模型参数,然后在测试分离上检查性能。我们的结果始终基于一个我们使用训练-测试分离创建的单个留出数据集。如果我们返回并更改random_stateshuffle我们的数据,那么我们可能会得到一个不同的测试分离,对于相同的模型可能会有不同的准确度。因此,我们严重依赖于给定的测试分离来确定我们模型的准确度。

交叉验证 是一种技术,允许我们在数据的不同分割上进行训练和验证,以便最终训练的模型在欠拟合过拟合之间取得适当的平衡。欠拟合是指我们训练的模型未能很好地学习底层关系,并对每个观察结果进行类似的预测,这些预测与真实值相去甚远。这是因为所选模型复杂性不足以建模现象(错误的模型选择)或者学习关系的观察样本不足。过拟合是指选择的模型非常复杂,在训练过程中很好地拟合了底层模式,但在测试数据上产生显著偏差。这表明训练的模型在未见数据上泛化能力不强。通过使用交叉验证技术,我们可以通过在数据的多个分割上进行训练和测试,意识到这些缺点,并得出模型更真实的性能。

有许多交叉验证的变体,其中最广泛使用的是 K 折交叉验证。图 6-5 展示了一种 K 折策略,我们首先将整个训练数据集分成 K 份。在每次迭代中,模型在不同的 K-1 折数据集上进行训练,并在保留的第 K 折上进行验证。整体性能被视为所有保留的 K 折上性能的平均值。通过这种方式,我们不仅仅基于一个测试分割来评估模型的准确性,而是基于多个这样的分割,同样我们也在多个训练数据的分割上进行模型训练。这使我们可以利用所有观察样本来训练我们的模型,因为我们不需要单独的保留测试分割。

图 6-5. 一种 K 折交叉验证策略,每次训练模型时选择不同的留出集(阴影部分)。其余集合形成训练数据的一部分。

要执行交叉验证,我们将使用 scikit-learn 中的cross_val_score方法。它的参数包括需要拟合的模型、训练数据集以及我们想要使用的折数。在这种情况下,我们使用五折交叉验证策略,根据训练观测数和计算基础设施的可用性,这可以在 5 到 10 之间变化。该方法返回每次交叉验证迭代的验证分数,并且我们可以计算所有验证折叠中得到的平均值。从结果中,我们可以看到验证分数从 36%变化到 47%不等。这表明我们之前在测试数据集上报告的模型准确率是乐观的,并且是特定的训练测试分割方式的产物。从交叉验证中得到的更实际的准确率平均分为 44%。执行此练习以理解任何模型的真实潜力非常重要。我们再次执行向量化步骤,因为我们将使用整个数据集,而不仅仅是训练分割:

# Vectorization

tfidf = TfidfVectorizer(min_df = 10, ngram_range=(1,2), stop_words="english")
df_tf = tfidf.fit_transform(df['text']).toarray()

# Cross Validation with 5 folds

scores = cross_val_score(estimator=model1,
                         X=df_tf,
                         y=df['Priority'],
                         cv=5)

print ("Validation scores from each iteration of the cross validation ", scores)
print ("Mean value across of validation scores ", scores.mean())
print ("Standard deviation of validation scores ", scores.std())

输出:

Validation scores from each iteration of the cross validation
[0.47773704 0.47302252 0.45468832 0.44054479 0.3677318 ]
Mean value across of validation scores  0.44274489261393396
Standard deviation of validation scores  0.03978852971586144

注意

使用交叉验证技术允许我们使用所有观测数据,而不需要创建单独的保留测试分割。这为模型提供了更多的学习数据。

蓝图:使用网格搜索执行超参数调优

网格搜索是一种通过评估不同作为模型参数的参数来提高模型准确性的有用技术。它通过尝试不同的超参数组合来最大化给定指标(例如准确率)来实现这一目标。例如,如果我们使用sklearn.svm.SVC模型,它有一个名为kernel的参数,可以采用几个值:linearrbf(径向基函数)、poly(多项式)等等。此外,通过设置预处理流水线,我们还可以测试不同的ngram_range值用于 TF-IDF 向量化。当我们进行网格搜索时,我们提供要评估的参数值集,并结合交叉验证方法来训练模型,从而确定最大化模型准确性的超参数集。这种技术的最大缺点是它对 CPU 和时间要求高;换句话说,我们需要测试许多可能的超参数组合,以确定表现最佳的数值集。

为了测试我们模型的超参数的正确选择,我们首先创建了一个training_pipeline,在其中定义我们想要运行的步骤。在这种情况下,我们指定了 TF-IDF 向量化和 LinearSVC 模型训练。然后,我们定义了一组参数,我们希望使用变量grid_param进行测试。由于参数值特定于管道中的某个步骤,因此在指定grid_param时,我们使用步骤的名称作为前缀。例如,min_df是向量化步骤使用的参数,因此称为tfidf__min_df。最后,我们使用GridSearchCV方法,该方法提供了测试整个管道的多个版本以及不同超参数集合的功能,并生成交叉验证分数,从中选择性能最佳的版本:

training_pipeline = Pipeline(
    steps=[('tfidf', TfidfVectorizer(stop_words="english")),
            ('model', LinearSVC(random_state=42, tol=1e-5))])

grid_param = [{
    'tfidf__min_df': [5, 10],
    'tfidf__ngram_range': [(1, 3), (1, 6)],
    'model__penalty': ['l2'],
    'model__loss': ['hinge'],
    'model__max_iter': [10000]
}, {
    'tfidf__min_df': [5, 10],
    'tfidf__ngram_range': [(1, 3), (1, 6)],
    'model__C': [1, 10],
    'model__tol': [1e-2, 1e-3]
}]

gridSearchProcessor = GridSearchCV(estimator=training_pipeline,
                                   param_grid=grid_param,
                                   cv=5)
gridSearchProcessor.fit(df['text'], df['Priority'])

best_params = gridSearchProcessor.best_params_
print("Best alpha parameter identified by grid search ", best_params)

best_result = gridSearchProcessor.best_score_
print("Best result identified by grid search ", best_result)

Out:

Best alpha parameter identified by grid search  {'model__loss': 'hinge',
'model__max_iter': 10000, 'model__penalty': 'l2', 'tfidf__min_df': 10,
'tfidf__ngram_range': (1, 6)}
Best result identified by grid search  0.46390780513357777

我们评估了两个min_dfngram_range的值,并使用两组不同的模型参数。在第一组中,我们尝试了 l2 model_penalty和 hinge model_loss,最多进行了 1,000 次迭代。在第二组中,我们尝试改变正则化参数C和模型的tolerance值。虽然我们之前看到了最佳模型的参数,但我们也可以检查生成的所有其他模型的性能,以了解参数值之间的相互作用。您可以查看前五个模型及其参数值如下:

gridsearch_results = pd.DataFrame(gridSearchProcessor.cv_results_)
gridsearch_results[['rank_test_score', 'mean_test_score',
                    'params']].sort_values(by=['rank_test_score'])[:5]

rank_test_score mean_test_score params
3 1 0.46 {'model__loss’: ‘hinge', ‘model__max_iter’: 10000, ‘model__penalty’: ‘l2', ‘tfidf__min_df’: 10, ‘tfidf__ngram_range’: (1, 6)}
2 2 0.46 {'model__loss’: ‘hinge', ‘model__max_iter’: 10000, ‘model__penalty’: ‘l2', ‘tfidf__min_df’: 10, ‘tfidf__ngram_range’: (1, 3)}
0 3 0.46 {'model__loss’: ‘hinge', ‘model__max_iter’: 10000, ‘model__penalty’: ‘l2', ‘tfidf__min_df’: 5, ‘tfidf__ngram_range’: (1, 3)}
1 4 0.46 {'model__loss’: ‘hinge', ‘model__max_iter’: 10000, ‘model__penalty’: ‘l2', ‘tfidf__min_df’: 5, ‘tfidf__ngram_range’: (1, 6)}
5 5 0.45 {'model__C’: 1, ‘model__tol’: 0.01, ‘tfidf__min_df’: 5, ‘tfidf__ngram_range’: (1, 6)}

蓝图总结与结论

让我们通过将其应用于不同的分类任务来总结文本分类的蓝图步骤。如果您还记得,我们在本章开头提到,为了能够快速修复错误,我们必须确定错误的优先级,并将其分配给正确的团队。可以通过识别错误属于软件的哪个部分来自动执行分配。我们已经看到,错误报告有一个名为Component的功能,其中的值包括CoreUIDoc。这有助于将错误分配给正确的团队或个人,从而加快解决速度。这项任务类似于确定错误优先级,并将帮助我们理解蓝图如何应用于任何其他应用程序。

我们用以下更改更新蓝图:

  • 附加步骤,包括网格搜索以确定最佳超参数,并限制测试的选项数量以增加运行时

  • 使用sklearn.svm.SVC函数的额外选项来比较性能并尝试非线性核函数

# Flag that determines the choice of SVC and LinearSVC
runSVC = True

# Loading the DataFrame

df = pd.read_csv('eclipse_jdt.csv')
df = df[['Title', 'Description', 'Component']]
df = df.dropna()
df['text'] = df['Title'] + df['Description']
df = df.drop(columns=['Title', 'Description'])

# Step 1 - Data Preparation
df['text'] = df['text'].apply(clean)
df = df[df['text'].str.len() > 50]

if (runSVC):
    # Sample the data when running SVC to ensure reasonable run-times
    df = df.groupby('Component', as_index=False).apply(pd.DataFrame.sample,
                                                       random_state=21,
                                                       frac=.2)

# Step 2 - Train-Test Split
X_train, X_test, Y_train, Y_test = train_test_split(df['text'],
                                                    df['Component'],
                                                    test_size=0.2,
                                                    random_state=42,
                                                    stratify=df['Component'])
print('Size of Training Data ', X_train.shape[0])
print('Size of Test Data ', X_test.shape[0])

# Step 3 - Training the Machine Learning model
tfidf = TfidfVectorizer(stop_words="english")

if (runSVC):
    model = SVC(random_state=42, probability=True)
    grid_param = [{
        'tfidf__min_df': [5, 10],
        'tfidf__ngram_range': [(1, 3), (1, 6)],
        'model__C': [1, 100],
        'model__kernel': ['linear']
    }]
else:
    model = LinearSVC(random_state=42, tol=1e-5)
    grid_param = {
        'tfidf__min_df': [5, 10],
        'tfidf__ngram_range': [(1, 3), (1, 6)],
        'model__C': [1, 100],
        'model__loss': ['hinge']
    }

training_pipeline = Pipeline(
    steps=[('tfidf', TfidfVectorizer(stop_words="english")), ('model', model)])

gridSearchProcessor = GridSearchCV(estimator=training_pipeline,
                                   param_grid=grid_param,
                                   cv=5)

gridSearchProcessor.fit(X_train, Y_train)

best_params = gridSearchProcessor.best_params_
print("Best alpha parameter identified by grid search ", best_params)

best_result = gridSearchProcessor.best_score_
print("Best result identified by grid search ", best_result)

best_model = gridSearchProcessor.best_estimator_

# Step 4 - Model Evaluation

Y_pred = best_model.predict(X_test)
print('Accuracy Score - ', accuracy_score(Y_test, Y_pred))
print(classification_report(Y_test, Y_pred))

输出:

Size of Training Data  7204
Size of Test Data  1801
Best alpha parameter identified by grid search  {'model__C': 1,
'model__kernel': 'linear', 'tfidf__min_df': 5, 'tfidf__ngram_range': (1, 6)}
Best result identified by grid search  0.739867279666898
Accuracy Score -  0.7368128817323709
              precision    recall  f1-score   support

         APT       1.00      0.25      0.40        16
        Core       0.74      0.77      0.75       544
       Debug       0.89      0.77      0.82       300
         Doc       0.50      0.17      0.25        12
        Text       0.61      0.45      0.52       235
          UI       0.71      0.81      0.76       694

    accuracy                           0.74      1801
   macro avg       0.74      0.54      0.58      1801
weighted avg       0.74      0.74      0.73      1801

基于准确性和分类报告,我们实现了 73%的准确性,我们可以得出结论,该模型能够更准确地预测软件组件所指的 Bug,而不是优先级。虽然部分改进归功于网格搜索和交叉验证的额外步骤,但大部分只是因为模型能够识别描述与其所指的组件之间的关系。组件功能并没有显示出我们之前注意到的同等级别的类不平衡问题。但是,即使在组件内部,我们也可以看到对 Doc 软件组件的差结果,该组件的观察数量较其他组件少。另外,与基线相比,我们可以看到这个模型在性能上有所提高。我们可以尝试平衡我们的数据,或者我们可以做出一个明智的商业决策,即模型更重要的是预测那些具有较多 Bug 的软件组件:

clf = DummyClassifier(strategy='most_frequent')
clf.fit(X_train, Y_train)
Y_pred_baseline = clf.predict(X_test)
print ('Accuracy Score - ', accuracy_score(Y_test, Y_pred_baseline))

输出:

Accuracy Score -  0.38534147695724597

让我们也尝试了解这个模型如何进行预测,看看它在哪些方面表现良好,在哪些方面失败。我们首先将采样两个预测准确的观察结果:

# Create a DataFrame combining the Title and Description,
# Actual and Predicted values that we can explore
frame = { 'text': X_test, 'actual': Y_test, 'predicted': Y_pred }
result = pd.DataFrame(frame)

result[result['actual'] == result['predicted']].sample(2)

输出:

文本 实际 预测
28225 移动静态初始化程序缺乏原子撤销。当移动方法时,可以使用单个撤销命令原子地撤销移动。但是当移动静态初始化程序时,只能在源文件和目标文件中发出 Undo 命令来撤销。 UI UI
30592 断点命中时,调试视图窃取焦点 M5 - I20060217-1115 当您调试具有断点的程序时,当调试器命中断点时,按下 Ctrl+Sht+B 不会删除断点,即使该行看起来已经获得焦点。要实际删除断点,必须在编辑器中单击正确的行并重新按下键 Debug Debug

我们可以看到,当将错误分类为 Debug 组件时,描述中使用了诸如debuggerbreakpoint之类的术语,而当将错误分类为 UI 时,我们看到了Undomovement的指示。这似乎表明训练好的模型能够学习描述中的单词与相应软件组件之间的关联。让我们也看看一些预测错误的观察结果:

result[result['actual'] != result['predicted']].sample(2)

输出:

文本 实际 预测
16138 @see 标签上的行包装创建了一个新的警告,即无效的参数声明。在启用了 javadoc 检查的 eclipse 3.0M5 中,行包装将导致警告 Javadoc: Invalid parameters declaration 这将导致警告: /** * @see com.xyz.util.monitoring.MonitoringObserver#monitorSetValue / 这样不会: /* * @see com.xyz.util.monitoring.MonitoringObserver#monitorSetValue * 文本 核心
32903 创建字符串数组后,eclipse 无法识别对象的方法。在任何程序中键入这些行。 String abc = new String System。在 System 后。eclipse 将不会列出所有可用的方法 核心 UI

在这里,要识别不正确分类的原因更加困难,但如果我们想要提高模型的准确性,我们必须进一步分析。在建立模型之后,我们必须调查我们的预测并理解为什么模型做出这些预测。有几种技术可以用来解释模型的预测,这将在第七章中更详细地讨论。

结语

本章中,我们提出了在构建监督文本分类模型的不同步骤中执行的蓝图。它始于数据准备步骤,包括如有必要的类平衡。然后我们展示了创建训练和测试分割的步骤,包括使用交叉验证作为到达模型准确度的首选技术。然后我们介绍了网格搜索作为验证不同超参数设置以找到最优组合的技术之一。监督学习是一个广泛的领域,有多种应用,如贷款违约预测、广告点击预测等。这个蓝图提供了一个端到端的技术,用于构建监督学习模型,并且也可以扩展到文本分类以外的问题。

进一步阅读

^(1) Nitesh Chawla 等人的文章“Synthetic Minority Over-Sampling Technique.” 人工智能研究杂志 16 (2002 年 6 月). https://arxiv.org/pdf/1106.1813.pdf.

第七章:如何解释文本分类器

在前几章中,我们已经学习了许多关于针对非结构化文本数据的高级分析方法。从统计学开始,使用自然语言处理,我们从文本中找到了有趣的见解。

使用监督方法进行分类,我们通过训练算法将文本文档分配到已知类别。虽然我们已经检查了分类过程的质量,但我们忽略了一个重要的方面:我们不知道模型为什么决定将一个类别分配给一个文本。

如果类别是正确的,这可能听起来不重要。然而,在日常生活中,您经常必须解释您自己的决定,并使它们对他人透明。对于机器学习算法也是如此。

在现实项目中,您很可能经常听到“为什么算法分配了这个类别/情绪?”的问题。甚至在此之前,了解算法是如何学习的将帮助您通过使用不同的算法、添加特征、更改权重等来改进分类。与结构化数据相比,对于文本来说,这个问题更为重要,因为人类可以解释文本本身。此外,文本有许多人为因素,比如电子邮件中的签名,最好避免这些因素,并确保它们不是分类中的主要特征。

除了技术视角之外,还有一些法律方面需要注意。您可能需要证明您的算法没有偏见或不歧视。欧盟的 GDPR 甚至要求对公共网站上做出决策(比如只允许某种支付方式)的算法进行证明。

最后但同样重要的是,信任需要信息。如果您尽可能地公开您的结果,您将大大增加某人对您的方法的信心和信任。

你将学到什么,我们将构建什么

在本章中,我们将介绍几种解释监督机器学习模型结果的方法。在可能的情况下,我们将建立在先前章节中的分类示例之上。

我们将从重新审视第六章中的错误报告的分类开始。一些报告被正确分类,一些没有。我们将退后一步,分析分类是否总是二进制决策。对于某些模型来说,它不是,我们将计算错误报告属于某个类别的概率,并与正确值(所谓的 )进行核对。

在接下来的部分中,我们将分析哪些特征决定了模型的决策。我们可以使用支持向量机来计算这一点。我们将尝试解释结果,并看看我们是否可以利用这些知识来改进方法。

之后,我们将采取更一般的方法,并介绍本地可解释的模型无关解释(LIME)。LIME(几乎)不依赖于特定的机器学习模型,可以解释许多算法的结果。

近年来人们在研究可解释人工智能方面投入了大量工作,并提出了一种更复杂的模型称为Anchor,我们将在本章的最后部分介绍它。

在学习了本章之后,您将了解到解释监督学习模型结果的不同方法。您将能够将这些方法应用于您自己的项目,并决定哪种方法最适合您的特定需求。您将能够解释结果并创建直观的可视化,以便非专家也能轻松理解。

蓝图:使用预测概率确定分类置信度

您可能还记得第六章中的例子,我们尝试根据其组件对缺陷报告进行分类。现在我们将使用在该章节中找到的最佳参数来训练支持向量机。其余的符号表示保持不变:

svc = SVC(kernel="linear", C=1, probability=True, random_state=42)
svc.fit(X_train_tf, Y_train)

如果您还记得分类报告,我们的平均精确度和召回率为 75%,因此分类效果相当不错。但也有一些情况下预测与实际值不同。现在我们将更详细地查看这些预测结果,以了解是否有可以用来区分“好”和“坏”预测的模式,而不查看实际结果,因为在真实的分类场景中这些将是未知的。

为此,我们将使用支持向量机模型的predict_proba函数,该函数告诉我们 SVM 的内部情况,即它对各个类别计算的概率(显然,预测本身的概率最高)。^(1) 作为参数,它期望一个由文档向量组成的矩阵。结果是不同类别的概率。作为第一步,我们将从预测结果构建一个DataFrame

X_test_tf = tfidf.transform(X_test)
Y_pred = svc.predict(X_test_tf)
result = pd.DataFrame({ 'text': X_test.values, 'actual': Y_test.values,
                        'predicted': Y_pred })

让我们尝试使用测试数据集的一个文档,并假设我们想优化我们的分类,主要关注预测错误的情况:

result[result["actual"] != result["predicted"]].head()

Out:

文本 实际 预测
2 在执行 JDT/UI 时 Delta 处理器中的 NPE... Core UI
15 在编辑器中插入文本块时排版不佳... UI 文本
16 在调试相同对象时的差异... Debug Core
20 模板中对类成员使用 Foreach 不起作用... Core UI
21 交换比较运算符的左右操作数... UI Core

文档 21 看起来是一个很好的候选。预测的类“Core”是错误的,但“left”和“right”听起来也像是 UI(这将是正确的)。让我们深入研究一下:

text = result.iloc[21]["text"]
print(text)

Out:

exchange left and right operands for comparison operators changes semantics
Fix for Bug 149803 was not good.; ; The right fix should do the following;
if --> if --> if ; if ; if

这看起来是更详细分析的一个不错的候选项,因为它包含了既可能是核心也可能是 UI 的词汇。也许如果我们查看概率,我们就能更详细地理解这一点。计算这个是相当容易的:

 svc.predict_proba(X_test_tf[21])

输出:

array([[0.002669, 0.46736578, 0.07725225, 0.00319434, 0.06874877,
        0.38076986]])

记住类别的顺序是 APT、核心、调试、文档、文本和 UI,该算法对核心的确信度比对 UI 高一些,而 UI 则是其次选择。

这种情况总是这样吗?我们将尝试找出答案,并计算测试数据集中所有文档的决策概率,并将其添加到一个 DataFrame 中:

class_names = ["APT", "Core", "Debug", "Doc", "Text", "UI"]
prob = svc.predict_proba(X_test_tf)
# new dataframe for explainable results
er = result.copy().reset_index()
for c in enumerate(class_names):
    er[c] = prob[:, i]

让我们看看数据帧的一些样本,并找出是否在算法相当确信其决策(即,所选类别的概率远高于其他类别)的情况下预测更准确:

er[["actual", "predicted"] + class_names].sample(5, random_state=99)

输出:

实际 预测 APT 核心 调试 文档 文本 UI
266 UI UI 0.000598 0.000929 0.000476 0.001377 0.224473 0.772148
835 文本 文本 0.002083 0.032109 0.001481 0.002085 0.696666 0.265577
998 文本 文本 0.000356 0.026525 0.003425 0.000673 0.942136 0.026884
754 核心 文本 0.003862 0.334308 0.011312 0.015478 0.492112 0.142927
686 UI UI 0.019319 0.099088 0.143744 0.082969 0.053174 0.601705

查看表格,只有一个错误的预测(754)。在这种情况下,算法相当“不确定”,并且以低于 50% 的概率决定了类别。我们能找到这种情况的模式吗?

让我们尝试构建两个 DataFrame,一个包含正确的预测,另一个包含错误的预测。然后,我们将分析最高概率的分布,看看是否能找到任何差异:

er['max_probability'] = er[class_names].max(axis=1)
correct = (er[er['actual'] == er['predicted']])
wrong   = (er[er['actual'] != er['predicted']])

我们现在将其绘制成直方图:

correct["max_probability"].plot.hist(title="Correct")
wrong["max_probability"].plot.hist(title="Wrong")

输出:

我们可以看到,在正确预测的情况下,模型经常以高概率决定,而当决策错误时,概率明显较低。正如我们稍后将看到的那样,错误类别中的高概率小峰值是由于短文本或缺失单词造成的。

最后,我们将看看是否可以改进结果,如果我们只考虑已经做出概率超过 80% 决策的情况:

high = er[er["max_probability"] > 0.8]
print(classification_report(high["actual"], high["predicted"]))

输出:

                precision    recall  f1-score   support

         APT       0.90      0.75      0.82        12
        Core       0.94      0.89      0.92       264
       Debug       0.94      0.99      0.96       202
         Doc       1.00      0.67      0.80         3
        Text       0.78      0.75      0.77        72
          UI       0.90      0.92      0.91       342

    accuracy                           0.91       895
   macro avg       0.91      0.83      0.86       895
weighted avg       0.91      0.91      0.91       895

将其与原始结果进行比较,如下所示:

print(classification_report(er["actual"], er["predicted"]))

输出:

              precision    recall  f1-score   support

         APT       0.90      0.56      0.69        16
        Core       0.76      0.77      0.76       546
       Debug       0.90      0.78      0.84       302
         Doc       1.00      0.25      0.40        12
        Text       0.64      0.51      0.57       236
          UI       0.72      0.82      0.77       699

    accuracy                           0.75      1811
   macro avg       0.82      0.62      0.67      1811
weighted avg       0.75      0.75      0.75      1811

我们可以看到,在预测核心、调试、文本和用户界面组件的精度方面,我们有了显著的改进,同时也提高了召回率。这很棒,因为支持向量机(SVM)的解释使我们进入了一个数据子集,分类器在这里工作得更好。然而,在样本较少的组件(Apt、Doc)中,实际上只改善了召回率。看来这些类别中的样本太少了,算法基于文本的信息也太少,难以作出决策。在 Doc 的情况下,我们刚刚移除了大部分属于此类的文档,从而提高了召回率。

然而,改进是有代价的。我们排除了超过 900 份文件,大约是数据集的一半。因此,总体而言,我们在较小的数据集中实际上找到了更少的文档!在某些项目中,让模型仅在“确定”情况下做决策,并且丢弃模棱两可的情况(或手动分类),可能是有用的。这通常取决于业务需求。

在这一部分中,我们发现了预测概率与结果质量之间的相关性。但我们尚未理解模型如何预测(即使用哪些单词)。我们将在下一节中进行分析。

蓝图:测量预测模型的特征重要性

在本节中,我们希望找出哪些特征对模型找到正确类别是相关的。幸运的是,我们的 SVM 类可以告诉我们必要的参数(称为系数):

svc.coef_

输出:

<15x6403 sparse matrix of type '<class 'numpy.float64'>'
       with 64451 stored elements in Compressed Sparse Row format>

6403 是词汇表的大小(检查len(tfidf.get_feature_names()),但是 15 是从哪里来的呢?这有点复杂。从技术上讲,系数组织成一个矩阵,每个类与其他类以一对一的方式竞争。由于我们有六个类别,并且类别不必与自身竞争,因此有 15 个组合(组合数 6 选 2)。这 15 个系数如表 7-1 所述组织。

表 7-1. 多类支持向量分类器的系数布局

APT 核心 调试 Doc 文本 用户界面
APT 0 1 2 3 4
核心 5 6 7 8
调试 9 10 11
Doc 12 13
文本 14
用户界面

系数结构取决于机器学习模型

如果您使用其他分类器,则系数可能具有完全不同的组织结构。即使对于 SVM,使用 SGDClassifier 创建的非线性模型也每类只有一个系数集。当我们讨论 ELI5 时,我们将看到一些示例。

应首先阅读行,因此如果我们想要了解模型如何区分 APT 和核心组件,我们应该查看系数的索引 0。然而,我们更感兴趣的是核心和 UI 的差异,因此我们取索引 8. 在第一步中,我们按照它们的值对系数进行排序,并保留词汇位置的索引:

# coef_[8] yields a matrix, A[0] converts to array and takes first row
coef = svc.coef_[8].A[0]
vocabulary_positions = coef.argsort()
vocabulary = tfidf.get_feature_names()

接下来,我们现在获取顶部的正负贡献:

top_words = 10
top_positive_coef = vocabulary_positions[-top_words:].tolist()
top_negative_coef = vocabulary_positions[:top_words].tolist()

然后,我们将这些聚合到一个 DataFrame 中,以便更容易地显示结果:

core_ui = pd.DataFrame([[vocabulary[c],
                  coef[c]] for c in top_positive_coef + top_negative_coef],
                  columns=["feature", "coefficient"]).sort_values("coefficient")

我们希望可视化系数的贡献,以便易于理解。正值偏好核心组件,负值偏好 UI,如图 7-1 所示。为此,我们使用以下方法:

core_ui.set_index("feature").plot.barh()

这些结果非常容易解释。SVM 模型很好地学习到,compilerast 这些词语是特定于核心组件的,而 wizarduidialog 则用于识别 UI 组件中的错误。似乎在 UI 中更倾向于快速修复,这强调了核心的长期稳定性。

我们刚刚找到了整个 SVM 模型选择核心和 UI 之间的重要特征。但这并不表明哪些特征对于识别可以归类为核心的 bug 很重要。如果我们想要获取这些核心组件的特征,并考虑到先前的矩阵,我们需要索引 5、6、7 和 8. 采用这种策略,我们忽略了 APT 和核心之间的差异。要考虑到这一点,我们需要减去索引 0:

c = svc.coef_
coef = (c[5] + c[6] + c[7] + c[8] - c[0]).A[0]
vocabulary_positions = coef.argsort()

图 7-1. UI 的词语贡献(负)和核心的贡献(正)。

其余代码几乎与之前的代码相同。我们现在将图表扩展到 20 个词语(图 7-2):

top_words = 20
top_positive_coef = vocabulary_positions[-top_words:].tolist()
top_negative_coef = vocabulary_positions[:top_words].tolist()
core = pd.DataFrame([[vocabulary[c], coef[c]]
                      for c in top_positive_coef + top_negative_coef], 
                    columns=["feature", "coefficient"]).\
          sort_values("coefficient")
core.set_index("feature").plot.barh(figsize=(6, 10),
              color=[['red']*top_words + ['green']*top_words])

在图表中,您可以看到模型用于识别核心组件的许多词语,以及主要用于识别其他组件的词语。

您可以使用本蓝图中描述的方法,使 SVM 模型的结果透明和可解释。在许多项目中,这已被证明非常有价值,因为它消除了机器学习的“魔力”和主观性。

这方法效果相当好,但我们还不知道模型对某些词语的变化有多敏感。这是一个更复杂的问题,我们将在下一节中尝试回答。

图 7-2. 偏好或反对核心组件的系数。

蓝图:使用 LIME 解释分类结果

LIME 是 “局部可解释模型无关解释” 的首字母缩写,是一个用于可解释机器学习的流行框架。它是在 华盛顿大学 构想的,并且在 GitHub 上 公开可用。

让我们看看 LIME 的定义特征。它通过单独查看每个预测局部地工作。通过修改输入向量以找到预测敏感的局部组件来实现这一点。

可解释性需要计算时间

运行解释器代码可能需要相当长的时间。我们尝试通过调整示例的方式,让您在普通计算机上等待时间不超过 10 分钟。但是,通过增加样本大小,这可能很容易需要几个小时。

从向量周围的行为来看,它将得出哪些组件更重要或不重要的结论。LIME 将可视化贡献,并解释算法对个别文档的决策机制。

LIME 不依赖于特定的机器学习模型,可以应用于多种问题。并非所有模型都符合条件;模型需要预测类别的概率。并非所有支持向量机模型都能做到这一点。此外,在像文本分析中常见的高维特征空间中使用复杂模型进行预测时间较长并不是很实际。由于 LIME 试图局部修改特征向量,因此需要执行大量预测,在这种情况下需要很长时间才能完成。

最后,LIME 将根据每个样本生成模型解释,并帮助您理解模型。您可以用它来改进模型,也可以用来解释分类的工作原理。虽然模型仍然是黑箱,但您将获得一些可能发生在箱子里的知识。

让我们回到前一节的分类问题,并尝试为几个样本找到 LIME 解释。由于 LIME 需要文本作为输入和分类概率作为输出,我们将向量化器和分类器安排在管道中:

from sklearn.pipeline import make_pipeline
pipeline = make_pipeline(tfidf, best_model)

如果我们给它一些文本,流水线应该能够进行预测,就像这里做的那样:

pipeline.predict_proba(["compiler not working"])

输出:

array([[0.00240522, 0.95605684, 0.00440957, 0.00100242, 0.00971824,
        0.02640771]])

分类器建议将此置于类别 2 中的概率非常高,即核心。因此,我们的流水线正按照我们希望的方式运行:我们可以将文本文档作为参数传递给它,并返回文档属于每个类别的概率。现在是打开 LIME 的时候了,首先导入该包(您可能需要使用pipconda先安装该包)。之后,我们将创建一个解释器,这是 LIME 的核心元素之一,负责解释单个预测:

from lime.lime_text import LimeTextExplainer
explainer = LimeTextExplainer(class_names=class_names)

我们检查DataFrame中错误预测的类别如下:

er[er["predicted"] != er["actual"]].head(5)

输出:

索引 文本 实际 预测 APT 核心 调试 文档 文本 UI
2 2 Delta 处理器中的 NPE 执行 JDT/UI ... 核心 UI 0.003357 0.309548 0.046491 0.002031 0.012309 0.626265
15 15 在编辑器中插入文本块严重对齐不良... UI 文本 0.001576 0.063076 0.034610 0.003907 0.614473 0.282356
16 16 调试相同对象时的差异 W... 调试 核心 0.002677 0.430862 0.313465 0.004193 0.055838 0.192965
20 20 模板的 foreach 对类成员不起作用... 核心 UI 0.000880 0.044018 0.001019 0.000783 0.130766 0.822535
21 21 交换比较中左右操作数... UI 核心 0.002669 0.467366 0.077252 0.003194 0.068749 0.380770

看看相应记录(我们的情况下是第 21 行):

id = 21
print('Document id: %d' % id)
print('Predicted class =', er.iloc[id]["predicted"])
print('True class: %s' % er.iloc[id]["actual"])

Out:

Document id: 21
Predicted class = Core
True class: UI

现在是 LIME 向我们解释的时候了!

exp = explainer.explain_instance(result.iloc[id]["text"],
      pipeline.predict_proba, num_features=10, labels=[1, 5])
print('Explanation for class %s' % class_names[1])
print('\n'.join(map(str, exp.as_list(label=1))))
print()
print('Explanation for class %s' % class_names[5])
print('\n'.join(map(str, exp.as_list(label=5))))

Out:

Explanation for class Core
('fix', -0.14306948642919184)
('Bug', 0.14077384623641856)
('following', 0.11150012169630388)
('comparison', 0.10122423126000728)
('Fix', -0.0884162779420967)
('right', 0.08315255286108318)
('semantics', 0.08143857054730141)
('changes', -0.079427782008582)
('left', 0.03188240169394561)
('good', -0.0027133756042246504)

Explanation for class UI
('fix', 0.15069083664026453)
('Bug', -0.14853911521141774)
('right', 0.11283930406785869)
('comparison', -0.10654654371478504)
('left', -0.10391669738035045)
('following', -0.1003931859632352)
('semantics', -0.056644426928774076)
('Fix', 0.05365037666619837)
('changes', 0.040806391076561165)
('good', 0.0401761761717476)

LIME 展示了哪些词语它认为对某个类别有利(正面)或不利(负面)。这与我们在 SVM 示例中实现的情况非常相似。更好的是,现在它独立于模型本身;它只需要支持predict_proba(这也适用于随机森林等)。

使用 LIME,您可以将分析扩展到更多类别,并创建它们特定词语的图形表示:

exp = explainer.explain_instance(result.iloc[id]["text"],
            pipeline.predict_proba, num_features=6, top_labels=3)
exp.show_in_notebook(text=False)

Out:

这看起来很直观,更适合解释甚至包含在演示中。我们可以清楚地看到fixright 对于分配 UI 类别至关重要,同时反对核心。然而,Bug 表示核心,正如comparisonsemantics 一样。不幸的是,这不是人类接受作为分类规则的样子;它们似乎过于具体,没有抽象化。换句话说,我们的模型看起来过拟合

改进模型

有了这些知识和熟悉票务的专家的经验,您可以改进模型。例如,我们可以询问Bug 是否真的特定于核心,或者我们最好将其作为停用词。把所有内容转换为小写可能也会证明有用。

LIME 甚至可以帮助您找到有助于全面解释模型性能的代表性样本。这个功能被称为子模块挑选,工作原理如下:

from lime import submodular_pick
import numpy as np
np.random.seed(42)
lsm = submodular_pick.SubmodularPick(explainer, er["text"].values,
                                        pipeline.predict_proba,
                                        sample_size=100,
                                        num_features=20,
                                        num_exps_desired=5)

个别“挑选”可以像之前笔记本中显示的那样进行可视化,并且现在更加完整,带有高亮显示。我们在这里只展示了第一个挑选:

lsm.explanations[0].show_in_notebook()

Out:

在以下情况下,我们可以解释结果,但它似乎并没有学习抽象化,这又是过拟合的迹象。

LIME 软件模块适用于 scikit-learn 中的线性支持向量机,但不适用于具有更复杂内核的支持向量机。图形化展示很好,但不直接适合演示。因此,我们将看看 ELI5,这是一种替代实现,试图克服这些问题。

蓝图:使用 ELI5 解释分类结果

ELI5(“Explain it to me like I’m 5”)是另一个流行的机器学习解释软件库,也使用 LIME 算法。由于它可用于非线性 SVM,并且具有不同的 API,我们将简要介绍它,并展示如何在我们的案例中使用它。

ELI5 需要一个使用libsvm训练过的模型,而我们之前的 SVC 模型不幸不是这样的。幸运的是,训练 SVM 非常快速,因此我们可以用相同的数据创建一个新的分类器,但使用基于libsvm的模型,并检查其性能。你可能还记得第六章中的分类报告,它提供了模型质量的良好总结:

from sklearn.linear_model import SGDClassifier
svm = SGDClassifier(loss='hinge', max_iter=1000, tol=1e-3, random_state=42)
svm.fit(X_train_tf, Y_train)
Y_pred_svm = svm.predict(X_test_tf)
print(classification_report(Y_test, Y_pred_svm))

Out:

              precision    recall  f1-score   support

         APT       0.89      0.50      0.64        16
        Core       0.77      0.78      0.77       546
       Debug       0.85      0.84      0.85       302
         Doc       0.75      0.25      0.38        12
        Text       0.62      0.59      0.60       236
          UI       0.76      0.79      0.78       699

    accuracy                           0.76      1811
   macro avg       0.77      0.62      0.67      1811
weighted avg       0.76      0.76      0.76      1811

看看最后一行,这大致与我们使用 SVC 取得的效果一样好。因此,解释它是有意义的!使用 ELI5,找到这个模型的解释是很容易的:

import eli5
eli5.show_weights(svm, top=10, vec=tfidf, target_names=class_names)

正面特征(即词汇)显示为绿色。更浓烈的绿色意味着该词对应类别的贡献更大。红色则完全相反:出现在红色中的词汇会“排斥”类别(例如,第二行下部的“refactoring”强烈排斥Core类)。<BIAS>则是一个特例,包含所谓的截距,即模型的系统性失败。

如您所见,我们现在为各个类别获得了权重。这是由于非线性 SVM 模型在多类场景下与 SVC 不同的工作方式。每个类别都“打分”,没有竞争。乍一看,这些词看起来非常合理。

ELI5 还可以解释单个观察结果:

eli5.show_prediction(svm, X_test.iloc[21],  vec=tfidf, target_names=class_names)

这是一个很好的可视化工具,用于理解哪些词汇对算法决定类别具有贡献。与原始的 LIME 软件包相比,使用 ELI5 需要的代码要少得多,你可以将 ELI5 用于非线性 SVM 模型。根据你的分类器和使用情况,你可能会选择 LIME 或 ELI5。由于使用了相同的方法,结果应该是可比较的(如果不是相同的)。

工作正在进行中

ELI5 仍在积极开发中,您可能会在新版本的 scikit-learn 中遇到困难。在本章中,我们使用了 ELI5 版本 0.10.1。

ELI5 是一个易于使用的软件库,用于理解和可视化分类器的决策逻辑,但它也受到底层 LIME 算法的缺点的影响,例如只能通过示例来解释的可解释性。为了使黑盒分类更透明,获得模型使用的“规则”将是有见地的。这是华盛顿大学团队创建后续项目 Anchor 的动机。

蓝图:使用 Anchor 解释分类结果

类似于 LIME,Anchor与任何黑盒模型都兼容。作为解释工具,它创建了规则,即所谓的锚点,用于解释模型的行为。阅读这些规则,你不仅能够解释模型的预测,还能以与模型学习相同的方式进行预测。

相较于 LIME,Anchor 在通过规则更好地解释模型方面具有显著优势。然而,软件本身还是比较新的,仍在不断完善中。并非所有示例对我们都适用,因此我们选择了一些有助于解释分类模型的方法。

使用带有屏蔽词的分布

Anchor 有多种使用方式。我们从所谓的未知分布开始。Anchor 将通过用词汇unknown替换预测中被认为不重要的现有标记,解释模型的决策方式。

再次,我们将使用 ID 为 21 的文档。在这种情况下,分类器需要在两个概率大致相同的类别之间进行选择,这对研究是一个有趣的示例。

为了在文本中创建(语义)差异,Anchor 使用 spaCy 的词向量,并需要包含这些向量的 spaCy 模型,例如en_core_web_lg

因此,作为先决条件,您应该安装anchor-expspacy(使用condapip),并加载以下模型:

python -m spacy download en_core_web_lg

在第一步中,我们可以实例化我们的解释器。解释器具有一些概率元素,因此最好同时重新启动随机数生成器:

np.random.seed(42)
explainer_unk = anchor_text.AnchorText(nlp, class_names, \
                use_unk_distribution=True)

让我们检查预测结果及其替代方案,并将其与真实情况进行比较。predicted_class_ids包含预测类的索引,按概率降序排列,因此元素 0 是预测值,元素 1 是其最接近的竞争者:

text = er.iloc[21]["text"]
actual = er.iloc[21]["actual"]
# we want the class with the highest probability and must invert the order
predicted_class_ids = np.argsort(pipeline.predict_proba([text])[0])[::-1]
pred = explainer_unk.class_names[predicted_class_ids[0]]
alternative = explainer_unk.class_names[predicted_class_ids[1]]
print(f'predicted {pred}, alternative {alternative}, actual {actual}')

Out:

predicted Core, alternative UI, actual UI

在下一步中,我们将让算法找出预测的规则。参数与之前的 LIME 相同:

exp_unk = explainer_unk.explain_instance(text, pipeline.predict, threshold=0.95)

计算时间取决于 CPU 的速度,可能需要高达 60 分钟。

现在一切都包含在解释器中,因此我们可以查询解释器,了解模型的内部工作情况:

print(f'Rule: {" AND ".join(exp_unk.names())}')
print(f'Precision: {exp_unk.precision()}')

Out:

Rule: following AND comparison AND Bug AND semantics AND for
Precision: 0.9865771812080537

因此,规则告诉我们,单词followingcomparisonBugsemantic的组合会导致“Core”预测,精度超过 98%,但不幸的是这是错误的。现在,我们还可以找到模型将其分类为 Core 的典型示例:

print(f'Made-up examples where anchor rule matches and model predicts {pred}\n')
print('\n'.join([x[0] for x in exp_unk.examples(only_same_prediction=True)]))

下面显示的 UNK 标记代表“未知”,意味着对应位置的词汇不重要:

Made-up examples where anchor rule matches and model predicts Core

UNK left UNK UNK UNK UNK comparison operators UNK semantics Fix for Bug UNK UNK
exchange left UNK UNK operands UNK comparison operators changes semantics Fix fo
exchange UNK and UNK operands UNK comparison UNK UNK semantics UNK for Bug UNK U
exchange UNK and right UNK for comparison UNK UNK semantics UNK for Bug 149803 U
UNK left UNK UNK operands UNK comparison UNK changes semantics UNK for Bug 14980
exchange left UNK right UNK UNK comparison UNK changes semantics Fix for Bug UNK
UNK UNK and right operands for comparison operators UNK semantics Fix for Bug 14
UNK left and right operands UNK comparison operators changes semantics UNK for B
exchange left UNK UNK operands UNK comparison operators UNK semantics UNK for Bu
UNK UNK UNK UNK operands for comparison operators changes semantics Fix for Bug

我们还可以要求提供符合规则但模型预测错误类的示例:

print(f'Made-up examples where anchor rule matches and model predicts \
 {alternative}\n')
print('\n'.join([x[0] for x in exp_unk.examples(partial_index=0, \
      only_different_prediction=True)]))

Out:

Made-up examples where anchor rule matches and model predicts UI

exchange left and right UNK for UNK UNK UNK UNK Fix for UNK 149803 was not UNK .
exchange left UNK UNK UNK for UNK UNK UNK semantics Fix for Bug 149803 UNK not U
exchange left UNK UNK operands for comparison operators UNK UNK Fix UNK Bug 1498
exchange left UNK right operands UNK comparison UNK UNK UNK Fix for UNK UNK UNK
exchange left and right operands UNK UNK operators UNK UNK Fix UNK UNK UNK UNK U
UNK UNK and UNK UNK UNK comparison UNK UNK UNK Fix for UNK UNK was not good UNK
exchange left and UNK UNK UNK UNK operators UNK UNK Fix UNK Bug 149803 was not U
exchange left and right UNK UNK UNK operators UNK UNK UNK for Bug 149803 UNK UNK
exchange left UNK right UNK for UNK operators changes UNK Fix UNK UNK UNK was no
UNK left UNK UNK operands UNK UNK operators changes UNK UNK for UNK 149803 was n

老实说,这对模型来说并不是一个好结果。我们本来期望模型学习的底层规则会对不同组件特定的单词比较敏感。然而,并没有明显的理由可以解释为什么followingBug会特定于核心。这些都是一些通用词汇,不太具有任何类别的特征。

UNK 令牌有点误导。即使它们在这个样本中并不重要,它们可能被其他真实的单词替换,这些单词会影响算法的决策。Anchor 也可以帮助我们说明这一点。

使用真实词语进行工作

通过在解释器的原始构造函数中替换use_unk_distribution=False,我们可以告诉 Anchor 使用真实词语(类似于使用 spaCy 的词向量替换)并观察模型的行为:

np.random.seed(42)
explainer_no_unk = anchor_text.AnchorText(nlp, class_names,
                   use_unk_distribution=False, use_bert=False)
exp_no_unk = explainer_no_unk.explain_instance(text, pipeline.predict,
             threshold=0.95)
print(f'Rule: {" AND ".join(exp_no_unk.names())}')
print(f'Precision: {exp_no_unk.precision()}')

输出:

Rule: following AND Bug AND comparison AND semantics AND left AND right
Precision: 0.9601990049751243

这些规则与之前未知的分布有些不同。似乎有些单词变得更加特定于核心,如leftright,而其他词语如for则消失了。

让我们还让 Anchor 生成一些替代文本,这些文本也会(错误地)被分类为核心,因为前面的规则也适用:

Examples where anchor applies and model predicts Core:

exchange left and right suffixes for comparison operators affects semantics NEED
exchange left and right operands for comparison operators depends semantics UPDA
exchange left and right operands for comparison operators indicates semantics so
exchange left and right operands for comparison operators changes semantics Firm
exchange left and right operands into comparison dispatchers changes semantics F
exchange left and right operands with comparison operators changes semantics Fix
exchange left and right operands beyond comparison operators changes semantics M
exchange left and right operands though comparison representatives changes seman
exchange left and right operands before comparison operators depends semantics M
exchange left and right operands as comparison operators changes semantics THING

一些单词已经改变,并且并没有影响分类结果。在某些情况下,只是介词,通常情况下这不会影响结果。然而,operators也可以被dispatchers替换而不会影响结果。Anchor 向您展示它对这些修改是稳定的。

将先前的结果与模型正确预测“UI”的结果进行比较。同样,这种差异影响单词如changesmetaphors等,这些单词显然比前一个例子中的较小修改更具意义,但很难想象你作为一个人类会将这些词解释为不同类别的信号:

Examples where anchor applies and model predicts UI:

exchange left and good operands for comparison operators changes metaphors Fix i
exchange landed and right operands for comparison supervisors changes derivation
exchange left and happy operands for correlation operators changes equivalences
exchange left and right operands for scenario operators changes paradigms Fix be
exchange left and right operands for trade customers occurs semantics Fix as BoT
exchange did and right operands than consumer operators changes analogies Instal
exchange left and few operands for reason operators depends semantics Fix for Bu
exchange left and right operands for percentage operators changes semantics MESS
exchange left and right pathnames after comparison operators depends fallacies F
exchange left and right operands of selection operators changes descriptors Fix

Anchor 还有一种直观的方式在笔记本中显示结果,重要的单词会被突出显示,同时还包括它计算出的规则:^(2)

exp_unk.show_in_notebook()

输出:

由于你很可能也熟悉软件开发,单靠规则很难确定正确的类别。换句话说,这意味着当模型使用语料库训练时,模型似乎是相当脆弱的。只有那些具有大量背景知识的项目贡献者才能可能真正确定“正确”的类别(我们稍后将在第十一章回顾)。因此,发现分类器有效并不一定意味着它真正学习的方式对我们是透明的。

总结本节,Anchor 很有趣。Anchor 的作者选择版本号 0.0.1 并非偶然;该程序仍处于起步阶段。在我们的实验中,我们看到了一些小问题,要使其在生产环境中运行,还需要改进许多事情。但从概念上来说,它已经非常令人信服,可以用于解释单个预测并使模型透明化。特别是计算出的规则几乎是独一无二的,任何其他解决方案都无法创建。

结语

使用本章介绍的技术将有助于使您的模型预测更加透明。

从技术角度来看,这种透明性可以极大地帮助您在选择竞争模型或改进特征模型时提供支持。本章介绍的技术能够深入了解模型的“内部运作”,有助于检测和改进不可信的模型。

从商业角度来看,可解释性对项目来说是一个很好的销售主张。如果不只是追求黑盒模型,而是使模型透明化,那么在谈论模型并展示它们时会更容易。最近的文章在福布斯VentureBeat上已经专注于这一有趣的发展。当您想要构建可信的机器学习解决方案时,“信任”模型将变得越来越重要。

可解释人工智能是一个年轻的领域。我们可以预期未来会看到巨大的进步,更好的算法和改进的工具。

大多数情况下,机器学习方法都很好地作为黑盒模型运行。只要结果一致,我们就不需要为模型辩护,这样也挺好。但如果其中任何一个受到质疑(这种情况变得越来越普遍),那么可解释人工智能的时代就已经到来了。

^(1) 从图形上来看,您可以将这些概率视为样本到由 SVM 定义的超平面的距离。

^(2) 我们在让此工作起来时遇到了一些困难,因为它只适用于数值类别。我们计划提交一些拉取请求,以使上游也适用于文本类别。

第八章:无监督方法:主题建模和聚类

当处理大量文档时,您想要在不阅读所有文档的情况下首先问的问题之一是“它们在谈论什么?”您对文档的主题感兴趣,即文档中经常一起使用的(理想情况下是语义的)单词。

主题建模试图通过使用统计技术从文档语料库中找出主题来解决这个挑战。根据您的向量化(见第五章),您可能会发现不同类型的主题。主题由特征(单词、n-gram 等)的概率分布组成。

主题通常彼此重叠;它们并不明确分开。文档也是如此:不可能将文档唯一地分配给一个主题;文档始终包含不同主题的混合体。主题建模的目的不是将主题分配给任意文档,而是找出语料库的全局结构。

通常,一组文档具有由类别、关键词等确定的显式结构。如果我们想要查看语料库的有机构成,那么主题建模将对揭示潜在结构有很大帮助。

主题建模已经被人们熟知很长一段时间,并在过去的 15 年中获得了巨大的流行,这主要归因于 LDA(一种用于发现主题的随机方法)的发明。LDA 灵活多变,允许进行许多修改。然而,它并不是主题建模的唯一方法(尽管通过文献,您可能会认为它是唯一的,因为很多文献都倾向于 LDA)。概念上更简单的方法包括非负矩阵分解、奇异值分解(有时称为 LSI)等。

您将学到什么以及我们将构建什么

在这一章中,我们将深入研究各种主题建模方法,试图找到这些方法之间的差异和相似之处,并在同一个用例上运行它们。根据您的需求,尝试单一方法可能是个不错的主意,但比较几种方法的结果也是一个好选择。

在学习了本章后,您将了解到不同的主题建模方法及其特定的优缺点。您将了解到主题建模不仅可以用于发现主题,还可以用于快速创建文档语料库的摘要。您将学会选择正确的实体粒度来计算主题模型的重要性。您已经通过许多参数实验找到了最佳的主题模型。您可以通过数量方法和数据来评判生成的主题模型的质量。

我们的数据集:联合国大会辩论

我们的用例是语义分析联合国大会辩论语料库。您可能从早期关于文本统计的章节了解过这个数据集。

这一次,我们更感兴趣的是演讲的含义和语义内容,以及我们如何将它们按主题排列。我们想知道演讲者在谈论什么,并回答这样的问题:文档语料库中是否有结构?有哪些主题?哪一个最突出?这种情况随时间而变化吗?

检查语料库的统计数据

在开始主题建模之前,检查底层文本语料库的统计数据总是一个好主意。根据此分析的结果,您通常会选择分析不同的实体,例如文档、部分文本或段落。

我们对作者和其他信息不是很感兴趣,因此只需处理提供的一个 CSV 文件即可:

import pandas as pd
debates = pd.read_csv("un-general-debates.csv")
debates.info()

输出:

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 7507 entries, 0 to 7506
Data columns (total 4 columns):
session    7507 non-null int64
year       7507 non-null int64
country    7507 non-null object
text       7507 non-null object
dtypes: int64(2), object(2)
memory usage: 234.7+ KB

结果看起来不错。文本列中没有空值;我们可能稍后会使用年份和国家,它们也只有非空值。

演讲非常长,涵盖了许多主题,因为每个国家每年只能发表一次演讲。演讲的不同部分几乎总是由段落分隔。不幸的是,数据集存在一些格式问题。比较两篇选定演讲的文本:

print(repr(df.iloc[2666]["text"][0:200]))
print(repr(df.iloc[4729]["text"][0:200]))

输出:

'\ufeffIt is indeed a pleasure for me and the members of my delegation to
extend to Ambassador Garba our sincere congratulations on his election to the
presidency of the forty-fourth session of the General '
'\ufeffOn behalf of the State of Kuwait, it\ngives me pleasure to congratulate
Mr. Han Seung-soo,\nand his friendly country, the Republic of Korea, on
his\nelection as President of the fifty-sixth session of t'

正如您所见,在一些演讲中,换行符用于分隔段落。在其他演讲的转录中,换行符用于分隔行。因此,为了恢复段落,我们不能只是在换行符处拆分。事实证明,在行尾出现的句号、感叹号或问号处拆分效果也很好。我们忽略停止后的空格:

import re
df["paragraphs"] = df["text"].map(lambda text: re.split('[.?!]\s*\n', text))
df["number_of_paragraphs"] = df["paragraphs"].map(len)

根据 第二章 中的分析,我们已经知道每年的演讲数量变化不大。段落数量也是这样吗?

%matplotlib inline
debates.groupby('year').agg({'number_of_paragraphs': 'mean'}).plot.bar()

输出:

段落平均数量随时间显著减少。我们本应该预期,随着每年演讲者人数的增加和演讲总时间的限制。

除此之外,统计分析显示数据集没有系统性问题。语料库仍然很新;任何一年都没有缺失数据。我们现在可以安全地开始揭示潜在结构并检测主题。

准备工作

主题建模是一种机器学习方法,需要矢量化数据。所有主题建模方法都从文档-术语矩阵开始。回顾这个矩阵的含义(它在 第四章 中介绍过),其元素是对应文档(行)中单词(列)的词频(或经常作为 TF-IDF 权重进行缩放)。该矩阵是稀疏的,因为大多数文档只包含词汇的一小部分。

让我们计算演讲和演讲段落的 TF-IDF 矩阵。首先,我们需要从 scikit-learn 中导入必要的包。我们从一个简单的方法开始,使用标准的 spaCy 停用词:

from sklearn.feature_extraction.text import TfidfVectorizer
from spacy.lang.en.stop_words import STOP_WORDS as stopwords

计算演讲的文档-术语矩阵很容易;我们还包括二元组:

tfidf_text = TfidfVectorizer(stop_words=stopwords, min_df=5, max_df=0.7)
vectors_text = tfidf_text.fit_transform(debates['text'])
vectors_text.shape

输出:

(7507, 24611)

对于段落来说,稍微复杂一些,因为我们首先必须展平列表。在同一步骤中,我们省略空段落:

# flatten the paragraphs keeping the years
paragraph_df = pd.DataFrame([{ "text": paragraph, "year": year } 
                               for paragraphs, year in \
                               zip(df["paragraphs"], df["year"]) 
                                    for paragraph in paragraphs if paragraph])

tfidf_para_vectorizer = TfidfVectorizer(stop_words=stopwords, min_df=5,
                                        max_df=0.7)
tfidf_para_vectors = tfidf_para_vectorizer.fit_transform(paragraph_df["text"])
tfidf_para_vectors.shape

输出:

(282210, 25165)

当然,段落矩阵的行数要多得多。列数(单词数)也不同,因为 min_dfmax_df 在选择特征时有影响,文档的数量也已经改变。

非负矩阵分解(NMF)

在文档语料库中找到潜在结构的概念上最简单的方法是对文档-术语矩阵进行因子分解。幸运的是,文档-术语矩阵只有正值元素;因此,我们可以使用线性代数中允许我们表示矩阵为两个其他非负矩阵的乘积的方法。按照惯例,原始矩阵称为 V,而因子是 WH

V W · H

或者我们可以以图形方式表示它(可视化进行矩阵乘法所需的维度),如图 8-1 所示。

根据维度的不同,可以执行精确的因子分解。但由于这样做计算成本更高,近似因子分解已经足够。

图 8-1. 概要的非负矩阵分解;原始矩阵 V 被分解为 W 和 H。

在文本分析的背景下,WH 都有一个解释。矩阵 W 的行数与文档-术语矩阵相同,因此将文档映射到主题(文档-主题矩阵)。H 的列数与特征数相同,因此显示了主题由特征构成的方式(主题-特征矩阵)。主题的数量(W 的列数和 H 的行数)可以任意选择。这个数字越小,因子分解的精确度就越低。

蓝图:使用 NMF 创建文档的主题模型

在 scikit-learn 中为演讲执行此分解真的很容易。由于(几乎)所有主题模型都需要主题数量作为参数,我们任意选择了 10 个主题(后来证明这是一个很好的选择):

from sklearn.decomposition import NMF

nmf_text_model = NMF(n_components=10, random_state=42)
W_text_matrix = nmf_text_model.fit_transform(tfidf_text_vectors)
H_text_matrix = nmf_text_model.components_

TfidfVectorizer 类似,NMF 也有一个 fit_transform 方法,返回其中一个正因子矩阵。可以通过 NMF 类的 components_ 成员变量访问另一个因子。

主题是单词分布。我们现在将分析这个分布,看看我们是否可以找到主题的解释。看看图 8-1,我们需要考虑 H 矩阵,并找到每行(主题)中最大值的索引,然后将其用作词汇表中的查找索引。因为这对所有主题模型都有帮助,我们定义一个输出摘要的函数:

def display_topics(model, features, no_top_words=5):
    for topic, word_vector in enumerate(model.components_):
        total = word_vector.sum()
        largest = word_vector.argsort()[::-1] # invert sort order
        print("\nTopic %02d" % topic)
        for i in range(0, no_top_words):
            print(" %s (%2.2f)" % (features[largest[i]],
                  word_vector[largest[i]]*100.0/total))

调用此函数,我们可以得到 NMF 在演讲中检测到的主题的良好总结(数字是单词对各自主题的百分比贡献):

display_topics(nmf_text_model, tfidf_text_vectorizer.get_feature_names())

输出:

| 主题 00 co (0.79)

操作 (0.65)

裁军 (0.36)

核 (0.34)

关系 (0.25) | 主题 01 恐怖主义 (0.38)

挑战 (0.32)

可持续 (0.30)

千年 (0.29)

改革 (0.28) | 主题 02 非洲 (1.15)

非洲 (0.82)

南 (0.63)

纳米比亚 (0.36)

代表团 (0.30) | 主题 03 阿拉伯 (1.02)

以色列 (0.89)

巴勒斯坦的 (0.60)

黎巴嫩 (0.54)

以色列的 (0.54) | 主题 04 美国的 (0.33)

美国 (0.31)

拉丁 (0.31)

巴拿马 (0.21)

玻利维亚 (0.21) |

| 主题 05 太平洋 (1.55)

岛屿 (1.23)

索罗门 (0.86)

岛屿 (0.82)

斐济 (0.71) | 主题 06 苏联 (0.81)

共和国 (0.78)

核 (0.68)

越南 (0.64)

社会主义 (0.63) | 主题 07 几内亚 (4.26)

赤道 (1.75)

比绍 (1.53)

巴布亚 (1.47)

共和国 (0.57) | 主题 08 欧洲 (0.61)

欧洲 (0.44)

合作 (0.39)

波斯尼亚 (0.34)

赫尔采哥维纳 (0.30) | 主题 09 加勒比 (0.98)

小 (0.66)

巴哈马 (0.63)

圣 (0.63)

巴巴多斯 (0.61) |

主题 00 和 主题 01 看起来非常有前景,因为人们正在讨论核裁军和恐怖主义。这些确实是联合国大会辩论中的真实主题。

然而,后续主题或多或少集中在世界不同地区。这是因为演讲者主要提到自己的国家和邻国。这在主题 03 中特别明显,反映了中东的冲突。

查看单词在主题中的贡献百分比也很有趣。由于单词数量众多,单个贡献相当小,除了主题 07 中的几内亚。正如我们后面将看到的,单词在主题内的百分比是主题模型质量的一个很好的指标。如果主题内的百分比迅速下降,则表明该主题定义良好,而缓慢下降的单词概率表明主题不太明显。直觉上找出主题分离得有多好要困难得多;我们稍后将进行审视。

发现“大”主题有多大将会很有趣,即每个主题主要可以分配给多少篇文档。可以通过文档-主题矩阵计算,并对所有文档中的各个主题贡献求和来计算这一点。将它们与总和归一化,并乘以 100 给出一个百分比值:

W_text_matrix.sum(axis=0)/W_text_matrix.sum()*100.0

输出:

array([11.13926287, 17.07197914, 13.64509781, 10.18184685, 11.43081404,
        5.94072639,  7.89602474,  4.17282682, 11.83871081,  6.68271054])

我们可以清楚地看到,有较小和较大的主题,但基本上没有离群值。具有均匀分布是质量指标。例如,如果你的主题模型中有一两个大主题与其他所有主题相比,你可能需要调整主题数量。

在接下来的部分,我们将使用演讲段落作为主题建模的实体,并尝试找出是否改进了主题。

蓝图:使用 NMF 创建段落的主题模型

在联合国的一般辩论中,以及许多其他文本中,不同的主题通常会混合,这使得主题建模算法难以找到个别演讲的共同主题。特别是在较长的文本中,文档往往涵盖多个而不仅仅是一个主题。我们如何处理这种情况?一种想法是在文档中找到更具主题一致性的较小实体。

在我们的语料库中,段落是演讲的自然分割,我们可以假设演讲者在一个段落内试图坚持一个主题。在许多文档中,段落是一个很好的候选对象(如果可以识别),我们已经准备好了相应的 TF-IDF 向量。让我们尝试计算它们的主题模型:

nmf_para_model = NMF(n_components=10, random_state=42)
W_para_matrix = nmf_para_model.fit_transform(tfidf_para_vectors)
H_para_matrix = nmf_para_model.components_

我们之前开发的display_topics函数可以用来找到主题的内容:

display_topics(nmf_para_model, tfidf_para_vectorizer.get_feature_names())

Out:

| 主题 00 国家 (5.63)

united (5.52)

组织 (1.27)

州 (1.03)

宪章 (0.93) | 主题 01 总的 (2.87)

会议 (2.83)

大会 (2.81)

先生 (1.98)

主席 (1.81) | 主题 02 国家 (4.44)

发展 (2.49)

经济 (1.49)

发展 (1.35)

贸易 (0.92) | 主题 03 人民 (1.36)

和平 (1.34)

东 (1.28)

中 (1.17)

巴勒斯坦 (1.14) | 主题 04 核 (4.93)

武器 (3.27)

裁军 (2.01)

条约 (1.70)

扩散 (1.46) |

| 主题 05 权利 (6.49)

人类 (6.18)

尊重 (1.15)

基础 (0.86)

全球 (0.82) | 主题 06 非洲 (3.83)

南 (3.32)

非洲 (1.70)

纳米比亚 (1.38)

种族隔离 (1.19) | 主题 07 安全 (6.13)

理事会 (5.88)

永久 (1.50)

改革 (1.48)

和平 (1.30) | 主题 08 国际 (2.05)

世界 (1.50)

共同体 (0.92)

新 (0.77)

和平 (0.67) | 主题 09 发展 (4.47)

可持续 (1.18)

经济 (1.07)

社会 (1.00)

目标 (0.93) |

与以前用于演讲主题建模的结果相比,我们几乎失去了所有国家或地区,除了南非和中东地区。这些都是由于引发了世界其他地区兴趣的地区冲突。段落中的主题如“人权”,“国际关系”,“发展中国家”,“核武器”,“安理会”,“世界和平”和“可持续发展”(最后一个可能只是最近才出现)与演讲的主题相比显得更加合理。观察单词的百分比值,我们可以看到它们下降得更快,主题更加显著。

潜在语义分析/索引

另一种执行主题建模的算法是基于所谓的奇异值分解(SVD),这是线性代数中的另一种方法。

从图形上看,我们可以将奇异值分解(SVD)视为以一种方式重新排列文档和单词,以揭示文档-词矩阵中的块结构。在topicmodels.info有一个这个过程的良好可视化。图 8-2 显示了文档-词矩阵的开始和最终的块对角形式。

利用主轴定理,正交 n × n 矩阵有一个特征值分解。不幸的是,我们没有正交的方形文档-词矩阵(除了少数情况)。因此,我们需要一种称为奇异值分解的泛化。在其最一般的形式中,该定理表明任何 m × n 矩阵**V **都可以分解如下:

V = U · Σ · V *

图 8-2. 使用 SVD 进行主题建模的可视化。

U 是一个单位 m × m 矩阵,V* 是一个 n × n 矩阵,Σ 是一个 m × n 对角矩阵,其中包含奇异值。对于这个方程,有确切的解,但是由于它们需要大量的时间和计算工作来找到,所以我们正在寻找可以快速找到的近似解。这个近似方法仅考虑最大的奇异值。这导致Σ成为一个 t × t 矩阵;相应地,Um × tV* 有 t × n 的维度。从图形上看,这类似于非负矩阵分解,如图 8-3 所示。

图 8-3. 示意奇异值分解。

奇异值是Σ的对角元素。文档-主题关系包含在U中,而词-主题映射由V**表示。注意,U的元素和V**的元素都不能保证是正的。贡献的相对大小仍然是可解释的,但概率解释不再有效。

蓝图:使用 SVD 为段落创建主题模型

在 scikit-learn 中,SVD 的接口与 NMF 的接口相同。这次我们直接从段落开始:

from sklearn.decomposition import TruncatedSVD

svd_para_model = TruncatedSVD(n_components = 10, random_state=42)
W_svd_para_matrix = svd_para_model.fit_transform(tfidf_para_vectors)
H_svd_para_matrix = svd_para_model.components_

我们之前定义的用于评估主题模型的函数也可以使用:

display_topics(svd_para_model, tfidf_para_vectorizer.get_feature_names())

输出:

| 主题 00 国家(0.67)

联合(0.65)

国际(0.58)

和平(0.46)

世界(0.46) | 主题 01 一般(14.04)

装配(13.09)

会话(12.94)

先生(10.02)

总统(8.59) | 主题 02 国家(19.15)

发展(14.61)

经济(13.91)

发展中(13.00)

会议(10.29) | 主题 03 国家(4.41)

联合(4.06)

发展(0.95)

组织(0.84)

宪章(0.80) | 主题 04 核(21.13)

武器(14.01)

裁军(9.02)

条约(7.23)

扩散(6.31) |

| 主题 05 权利(29.50)

人类(28.81)

核(9.20)

武器(6.42)

尊重(4.98) | 主题 06 非洲(8.73)

南方(8.24)

联合(3.91)

非洲(3.71)

国家(3.41) | 主题 07 理事会(14.96)

安全(13.38)

非洲(8.50)

南方(6.11)

非洲(3.94) | 主题 08 世界(48.49)

国际(41.03)

和平(32.98)

社区(23.27)

非洲(22.00) | 主题 09 发展(63.98)

可持续(20.78)

和平(20.74)

目标(15.92)

非洲(15.61) |

大多数生成的主题与非负矩阵分解的主题非常相似。然而,中东冲突这一主题这次没有单独出现。由于主题-词映射也可能具有负值,因此归一化从主题到主题有所不同。只有构成主题的单词的相对大小才是相关的。

不用担心负百分比。这是因为 SVD 不保证 W 中的值为正,因此个别单词的贡献可能为负。这意味着出现在文档中的单词“排斥”相应的主题。

如果我们想确定主题的大小,现在就要查看分解的奇异值:

svd_para.singular_values_

Out:

array([68.21400653, 39.20120165, 36.36831431, 33.44682727, 31.76183677,
       30.59557993, 29.14061799, 27.40264054, 26.85684195, 25.90408013])

主题的大小与 NMF 方法的段落相当相符。

NMF 和 SVF 都使用了文档-词矩阵(应用了 TF-IDF 转换)作为主题分解的基础。此外,U矩阵的维度与W的维度相同;VH也是如此。因此,这两种方法产生类似且可比较的结果并不奇怪。由于这些方法计算速度很快,因此我们建议在实际项目中首先使用线性代数方法。

现在我们将摆脱这些基于线性代数的方法,专注于概率主题模型,在过去 20 年中已经变得极为流行。

潜在狄利克雷分配

LDA 可以说是当今使用最广泛的主题建模方法。它在过去 15 年间变得流行,并且可以灵活地适应不同的使用场景。

它是如何工作的?

LDA 将每个文档视为包含不同主题。换句话说,每个文档是不同主题的混合。同样,主题是从词中混合而来。为了保持每个文档中主题数量的少而且只包含一些重要词语,LDA 最初使用狄利克雷分布,即所谓的狄利克雷先验。这一分布用于为文档分配主题和为主题找到单词。狄利克雷分布确保文档只有少量主题,并且主题主要由少量单词定义。假设 LDA 生成了像之前那样的主题分布,一个主题可能由诸如条约裁军等词汇构成,而另一个主题则由可持续发展等词汇组成。

在初始分配之后,生成过程开始。它使用主题和单词的狄利克雷分布,并尝试用随机抽样重新创建原始文档中的单词。这个过程必须多次迭代,因此计算量很大。^(2) 另一方面,结果可以用来为任何确定的主题生成文档。

蓝图:使用 LDA 为段落创建主题模型

Scikit-learn 隐藏了所有这些差异,并使用与其他主题建模方法相同的 API:

from sklearn.feature_extraction.text import CountVectorizer

count_para_vectorizer = CountVectorizer(stop_words=stopwords, min_df=5,
                        max_df=0.7)
count_para_vectors = count_para_vectorizer.fit_transform(paragraph_df["text"])

from sklearn.decomposition import LatentDirichletAllocation

lda_para_model = LatentDirichletAllocation(n_components = 10, random_state=42)
W_lda_para_matrix = lda_para_model.fit_transform(count_para_vectors)
H_lda_para_matrix = lda_para_model.components_

等待时间

由于概率抽样的原因,该过程比 NMF 和 SVD 需要更长时间。期望至少分钟,甚至小时的运行时间。

我们的效用函数可以再次用于可视化段落语料库的潜在主题:

display_topics(lda_para_model, tfidf_para.get_feature_names())

Out:

| 主题 00 非洲(2.38)

人们(1.86)

南方(1.57)

纳米比亚(0.88)

政权(0.75)| 主题 01 共和国(1.52)

政府(1.39)

联合(1.21)

和平(1.16)

人民(1.02)| 主题 02 普通(4.22)

大会(3.63)

会议(3.38)

总统(2.33)

先生(2.32)| 主题 03 人类(3.62)

权利(3.48)

国际(1.83)

法律(1.01)

恐怖主义(0.99)| 主题 04 世界(2.22)

人们(1.14)

国家(0.94)

年(0.88)

今天(0.66)|

| 主题 05 和平(1.76)

安全(1.63)

东方(1.34)

中间(1.34)

以色列(1.24)| 主题 06 国家(3.19)

发展(2.70)

经济(2.22)

发展(1.61)

国际(1.45)| 主题 07 核(3.14)

武器(2.32)

裁军(1.82)

国家(1.47)

军备(1.46)| 主题 08 国家(5.50)

联合(5.11)

国际(1.46)

安全(1.45)

组织(1.44)| 主题 09 国际(1.96)

世界(1.91)

和平(1.60)

经济(1.00)

关系(0.99)|

有趣的是观察到,与前述的线性代数方法相比,LDA 生成了完全不同的主题结构。人们是三个完全不同主题中最突出的词。在主题 04 中,南非与以色列和巴勒斯坦有关联,而在主题 00 中,塞浦路斯、阿富汗和伊拉克有关联。这不容易解释。这也反映在主题的逐渐减少的单词权重中。

其他主题更容易理解,比如气候变化、核武器、选举、发展中国家和组织问题。

在这个例子中,LDA 的结果并不比 NMF 或 SVD 好多少。然而,由于抽样过程,LDA 并不仅限于样本主题仅仅由单词组成。还有几种变体,比如作者-主题模型,也可以抽样分类特征。此外,由于在 LDA 领域有很多研究,其他想法也经常被发表,这些想法大大超出了文本分析的焦点(例如,见 Minghui Qiu 等人的 “不仅仅是我们说了什么,而是我们如何说它们:基于 LDA 的行为-主题模型” 或 Rahji Abdurehman 的 “关键词辅助 LDA:探索监督主题建模的新方法”)。

蓝图:可视化 LDA 结果

由于 LDA 非常流行,Python 中有一个很好的包来可视化 LDA 结果,称为 pyLDAvis。^(3) 幸运的是,它可以直接使用 sciki-learn 的结果进行可视化。

注意,这需要一些时间:

import pyLDAvis.sklearn

lda_display = pyLDAvis.sklearn.prepare(lda_para_model, count_para_vectors,
                            count_para_vectorizer, sort_topics=False)
pyLDAvis.display(lda_display)

Out:

可视化中提供了大量信息。让我们从“气泡”话题开始,并点击它。现在看一下红色条,它们象征着当前选定话题中的单词分布。由于条的长度没有迅速减少,说明话题 2 并不十分显著。这与我们在 “Blueprint: Creating a Topic Model for Paragraphs with LDA” 表格中看到的效果相同(看看话题 1,在那里我们使用了数组索引,而 pyLDAvis 从 1 开始枚举话题)。

为了可视化结果,话题从原始维度(单词数)通过主成分分析(PCA)映射到二维空间,这是一种标准的降维方法。这导致了一个点;圆圈被添加以查看话题的相对大小。可以通过在准备阶段传递 mds="tsne" 参数来使用 T-SNE 替代 PCA。这改变了话题之间的距离映射,并显示了较少重叠的话题气泡。然而,这只是在可视化时将许多单词维度投影到仅两个维度的一个副作用。因此,查看话题的单词分布并不完全依赖于低维度的可视化是一个好主意。

看到话题 4、6 和 10(“国际”)之间的强重叠是很有趣的,而话题 3(“大会”)似乎远离其他所有话题。通过悬停在其他话题气泡上或点击它们,您可以查看右侧的单词分布。尽管不是所有话题都完全分离,但有些话题(如话题 1 和话题 7)远离其他话题。尝试悬停在它们上面,您会发现它们的单词内容也不同。对于这样的话题,提取最具代表性的文档并将它们用作监督学习的训练集可能是有用的。

pyLDAvis 是一个很好的工具,适合在演示文稿中使用截图。尽管看起来探索性十足,但真正的探索在于修改算法的特征和超参数。

使用 pyLDAvis 能让我们很好地了解话题是如何相互排列的,以及哪些单词是重要的。然而,如果我们需要更质量的话题理解,可以使用额外的可视化工具。

Blueprint: 使用词云来显示和比较话题模型

到目前为止,我们已经使用列表显示了话题模型。这样,我们可以很好地识别不同话题的显著程度。然而,在许多情况下,话题模型用于给出关于语料库有效性和更好可视化的第一印象。正如我们在 第一章 中看到的,词云是展示这一点的定性和直观工具。

我们可以直接使用词云来展示我们的主题模型。代码可以很容易地从之前定义的display_topics函数中推导出来:

import matplotlib.pyplot as plt
from wordcloud import WordCloud

def wordcloud_topics(model, features, no_top_words=40):
    for topic, words in enumerate(model.components_):
        size = {}
        largest = words.argsort()[::-1] # invert sort order
        for i in range(0, no_top_words):
            size[features[largest[i]]] = abs(words[largest[i]])
        wc = WordCloud(background_color="white", max_words=100,
                       width=960, height=540)
        wc.generate_from_frequencies(size)
        plt.figure(figsize=(12,12))
        plt.imshow(wc, interpolation='bilinear')
        plt.axis("off")
        # if you don't want to save the topic model, comment the next line
        plt.savefig(f'topic{topic}.png')

通过使用此代码,我们可以定性地比较 NMF 模型(图 8-4)的结果与 LDA 模型(图 8-5)。较大的单词在各自的主题中更为重要。如果许多单词的大小大致相同,则该主题没有明显表现:

wordcloud_topics(nmf_para_model, tfidf_para_vectorizer.get_feature_names())
wordcloud_topics(lda_para_model, count_para_vectorizer.get_feature_names())

使用单独的缩放制作词云

词云中的字体大小在每个主题内部使用缩放,因此在绘制任何最终结论之前,验证实际数字非常重要。

现在的展示更加引人入胜。很容易在两种方法之间匹配主题,比如 0-NMF 与 8-LDA。对于大多数主题来说,这是显而易见的,但也存在差异。1-LDA(“人民共和国”)在 NMF 中没有相对应项,而 9-NMF(“可持续发展”)在 LDA 中找不到。

由于我们找到了主题的良好定性可视化,我们现在对主题分布随时间的变化感兴趣。

图 8-4。展示 NMF 主题模型的词云。

图 8-5。展示 LDA 主题模型的词云。

蓝图:计算文档主题分布和时间演变

正如您在本章开头的分析中所看到的,演讲的元数据随时间变化。这引发了一个有趣的问题,即主题的分布随时间如何变化。结果表明,这很容易计算并且具有洞察力。

像 scikit-learn 的向量化器一样,主题模型也有一个transform方法,用于计算现有文档的主题分布,保持已拟合的主题模型不变。让我们首先使用这个方法将 1990 年之前和之后的演讲分开。为此,我们为 1990 年之前和之后的文档创建 NumPy 数组:

import numpy as np
before_1990 = np.array(paragraph_df["year"] < 1990)
after_1990 = ~ before_1990

然后,我们可以计算相应的W矩阵:

W_para_matrix_early = nmf_para_model.transform(tfidf_para_vectors[before_1990])
W_para_matrix_late  = nmf_para_model.transform(tfidf_para_vectors[after_1990])
print(W_para_matrix_early.sum(axis=0)/W_para_matrix_early.sum()*100.0)
print(W_para_matrix_late.sum(axis=0)/W_para_matrix_late.sum()*100.0)

Out:

['9.34', '10.43', '12.18', '12.18', '7.82', '6.05', '12.10', '5.85', '17.36',
 '6.69']
['7.48', '8.34', '9.75', '9.75', '6.26', '4.84', '9.68', '4.68', '13.90',
 '5.36']

结果非常有趣,某些百分比发生了显著变化;特别是后期年份中倒数第二个主题的大小要小得多。现在,我们将尝试更深入地研究主题及其随时间的变化。

让我们尝试计算各个年份的分布,看看是否能找到可视化方法来揭示可能的模式:

year_data = []
years = np.unique(paragraph_years)
for year in tqdm(years):
    W_year = nmf_para_model.transform(tfidf_para_vectors[paragraph_years \
                                      == year])
    year_data.append([year] + list(W_year.sum(axis=0)/W_year.sum()*100.0))

为了使图表更直观,我们首先创建一个包含两个最重要单词的主题列表:

topic_names = []
voc = tfidf_para_vectorizer.get_feature_names()
for topic in nmf_para_model.components_:
    important = topic.argsort()
    top_word = voc[important[-1]] + " " + voc[important[-2]]
    topic_names.append("Topic " + top_word)

然后,我们将结果与以前的主题作为列名合并到一个DataFrame中,这样我们可以轻松地进行可视化,如下所示:

df_year = pd.DataFrame(year_data,
               columns=["year"] + topic_names).set_index("year")
df_year.plot.area()

Out:

在生成的图表中,您可以看到主题分布随着年份的变化而变化。我们可以看到,“可持续发展”主题在持续增加,而“南非”在种族隔离制度结束后失去了流行度。

相比于展示单个(猜测的)单词的时间发展,主题似乎更自然,因为它们源于文本语料库本身。请注意,此图表是通过一种纯无监督的方法生成的,因此其中没有偏见。一切都已经在辩论数据中;我们只是揭示了它。

到目前为止,我们在主题建模中仅使用了 scikit-learn。在 Python 生态系统中,有一个专门用于主题模型的库称为 Gensim,我们现在将对其进行调查。

使用 Gensim 进行主题建模

除了 scikit-learn,Gensim 是另一个在 Python 中执行主题建模的流行工具。与 scikit-learn 相比,它提供了更多用于计算主题模型的算法,并且还可以给出关于模型质量的估计。

蓝图:为 Gensim 准备数据

在我们开始计算 Gensim 模型之前,我们必须准备数据。不幸的是,API 和术语与 scikit-learn 不同。在第一步中,我们必须准备词汇表。Gensim 没有集成的分词器,而是期望每篇文档语料库的每一行已经被分词了:

# create tokenized documents
gensim_paragraphs = [[w for w in re.findall(r'\b\w\w+\b' , paragraph.lower())
                          if w not in stopwords]
                             for paragraph in paragraph_df["text"]]

分词后,我们可以用这些分词后的文档初始化 Gensim 字典。将字典视为从单词到列的映射(就像我们在 第二章 中使用的特征):

from gensim.corpora import Dictionary
dict_gensim_para = Dictionary(gensim_paragraphs)

与 scikit-learn 的 TfidfVectorizer 类似,我们可以通过过滤出现频率不够高或者太高的单词来减少词汇量。为了保持低维度,我们选择单词至少出现在五篇文档中,但不能超过文档的 70%。正如我们在 第二章 中看到的,这些参数可以进行优化,并需要一些实验。

在 Gensim 中,这通过参数 no_belowno_above 过滤器实现(在 scikit-learn 中,类似的是 min_dfmax_df):

dict_gensim_para.filter_extremes(no_below=5, no_above=0.7)

读取了字典后,我们现在可以使用 Gensim 计算词袋矩阵(在 Gensim 中称为 语料库,但我们将坚持我们当前的术语):

bow_gensim_para = [dict_gensim_para.doc2bow(paragraph) \
                    for paragraph in gensim_paragraphs]

最后,我们可以执行 TF-IDF 转换。第一行适配词袋模型,而第二行转换权重:

from gensim.models import TfidfModel
tfidf_gensim_para = TfidfModel(bow_gensim_para)
vectors_gensim_para = tfidf_gensim_para[bow_gensim_para]

vectors_gensim_para 矩阵是我们将在 Gensim 中进行所有即将进行的主题建模任务的矩阵。

蓝图:使用 Gensim 进行非负矩阵分解

让我们首先检查 NMF 的结果,看看我们是否可以复现 scikit-learn 的结果:

from gensim.models.nmf import Nmf
nmf_gensim_para = Nmf(vectors_gensim_para, num_topics=10,
                      id2word=dict_gensim_para, kappa=0.1, eval_every=5)

评估可能需要一些时间。虽然 Gensim 提供了一个 show_topics 方法来直接显示主题,但我们有一个不同的实现,使其看起来像 scikit-learn 的结果,这样更容易进行比较:

display_topics_gensim(nmf_gensim_para)

输出:

| 主题 00 国家 (0.03)

联合 (0.02)

人类 (0.02)

权利 (0.02)

角色 (0.01) | 主题 01 非洲 (0.02)

南部 (0.02)

人们 (0.02)

政府 (0.01)

共和国 (0.01) | 主题 02 经济 (0.01)

发展 (0.01)

国家 (0.01)

社会 (0.01)

国际(0.01)| 主题 03 国家(0.02)

发展中(0.02)

资源(0.01)

海(0.01)

发达(0.01)| 主题 04 以色列(0.02)

阿拉伯(0.02)

巴勒斯坦(0.02)

理事会(0.01)

安全(0.01)|

| 主题 05 组织(0.02)

宪章(0.02)

原则(0.02)

成员(0.01)

尊重(0.01)| 主题 06 问题(0.01)

解决方案(0.01)

东部(0.01)

情况(0.01)

问题(0.01)| 主题 07 核(0.02)

公司(0.02)

操作(0.02)

裁军(0.02)

武器(0.02)| 主题 08 会议(0.02)

将军(0.02)

大会(0.02)

先生(0.02)

总统(0.02)| 主题 09 世界(0.02)

和平(0.02)

人民(0.02)

安全(0.01)

国家(0.01)|

NMF 也是一种统计方法,因此结果不应与我们用 scikit-learn 计算的结果完全相同,但它们非常相似。Gensim 有用于计算主题模型的一致性评分的代码,作为质量指标。让我们试试这个:

from gensim.models.coherencemodel import CoherenceModel

nmf_gensim_para_coherence = CoherenceModel(model=nmf_gensim_para,
                                           texts=gensim_paragraphs,
                                           dictionary=dict_gensim_para,
                                           coherence='c_v')
nmf_gensim_para_coherence_score = nmf_gensim_para_coherence.get_coherence()
print(nmf_gensim_para_coherence_score)

Out:

0.6500661701098243

分数随主题数量变化。如果想找到最佳主题数,常见的方法是运行多个不同值的 NMF,计算一致性评分,然后选择最大化评分的主题数。

让我们尝试用 LDA 做同样的事情并比较质量指标。

蓝图:使用 Gensim 的 LDA

使用 Gensim 运行 LDA 与准备好的数据一样简单如使用 NMF。 LdaModel 类有许多用于调整模型的参数;我们在这里使用推荐的数值:

from gensim.models import LdaModel
lda_gensim_para = LdaModel(corpus=bow_gensim_para, id2word=dict_gensim_para,
    chunksize=2000, alpha='auto', eta='auto', iterations=400, num_topics=10, 
    passes=20, eval_every=None, random_state=42)

我们对主题的词分布很感兴趣:

display_topics_gensim(lda_gensim_para)

Out:

| 主题 00 气候(0.12)

公约(0.03)

太平洋(0.02)

环境(0.02)

海(0.02)| 主题 01 国家(0.05)

人民(0.05)

政府(0.03)

国家(0.02)

支持(0.02)| 主题 02 国家(0.10)

联合(0.10)

人类(0.04)

安全(0.03)

权利(0.03)| 主题 03 国际(0.03)

社区(0.01)

努力(0.01)

新(0.01)

全球(0.01)| 主题 04 非洲(0.06)

非洲人(0.06)

大陆(0.02)

恐怖主义者(0.02)

罪行(0.02)|

| 主题 05 世界(0.05)

年份(0.02)

今天(0.02)

和平(0.01)

时间(0.01)| 主题 06 和平(0.03)

冲突(0.02)

区域(0.02)

人民(0.02)

国家(0.02)| 主题 07 南(0.10)

苏丹(0.05)

中国(0.04)

亚洲(0.04)

索马里(0.04)| 主题 08 将军(0.10)

大会(0.09)

会议(0.05)

总统(0.04)

秘书(0.04)| 主题 09 发展(0.07)

国家(0.05)

经济(0.03)

可持续(0.02)

2015 年(0.02)|

主题的解释并不像 NMF 生成的解释那样容易。如前所示检查一致性评分,我们发现较低的评分为 0.45270703180962374。Gensim 还允许我们计算 LDA 模型的困惑度评分。困惑度衡量概率模型预测样本的能力。当我们执行 lda_gensim_para.log_perplexity(vectors_gensim_para) 时,我们得到一个困惑度评分为 -9.70558947109483。

蓝图:计算一致性评分

Gensim 还可以计算主题一致性。方法本身是一个包含分割、概率估计、确认度量计算和聚合的四阶段过程。幸运的是,Gensim 有一个CoherenceModel类,封装了所有这些单一任务,我们可以直接使用它:

from gensim.models.coherencemodel import CoherenceModel

lda_gensim_para_coherence = CoherenceModel(model=lda_gensim_para,
    texts=gensim_paragraphs, dictionary=dict_gensim_para, coherence='c_v')
lda_gensim_para_coherence_score = lda_gensim_para_coherence.get_coherence()
print(lda_gensim_para_coherence_score)

Out:

0.5444930496493174

nmf替换lda,我们可以为我们的 NMF 模型计算相同的得分:

nmf_gensim_para_coherence = CoherenceModel(model=nmf_gensim_para,
    texts=gensim_paragraphs, dictionary=dict_gensim_para, coherence='c_v')
nmf_gensim_para_coherence_score = nmf_gensim_para_coherence.get_coherence()
print(nmf_gensim_para_coherence_score)

Out:

0.6505110480127619

分数要高得多,这意味着与 LDA 相比,NMF 模型更接近真实主题。

计算 LDA 模型各个主题的一致性得分更加简单,因为它直接由 LDA 模型支持。让我们首先看一下平均值:

top_topics = lda_gensim_para.top_topics(vectors_gensim_para, topn=5)
avg_topic_coherence = sum([t[1] for t in top_topics]) / len(top_topics)
print('Average topic coherence: %.4f.' % avg_topic_coherence)

Out:

Average topic coherence: -2.4709.

我们还对各个主题的一致性得分感兴趣,这些得分包含在top_topics中。但是,输出内容太冗长(检查一下!),因此我们试图通过仅将一致性得分与主题中最重要的单词一起打印来压缩它:

[(t[1], " ".join([w[1] for w in t[0]])) for t in top_topics]

Out:

[(-1.5361194241843663, 'general assembly session president secretary'),
 (-1.7014902754187737, 'nations united human security rights'),
 (-1.8485895463251694, 'country people government national support'),
 (-1.9729985026779555, 'peace conflict region people state'),
 (-1.9743434414778658, 'world years today peace time'),
 (-2.0202823396586433, 'international community efforts new global'),
 (-2.7269347656599225, 'development countries economic sustainable 2015'),
 (-2.9089975883502706, 'climate convention pacific environmental sea'),
 (-3.8680684770508753, 'africa african continent terrorist crimes'),
 (-4.1515707817343195, 'south sudan china asia somalia')]

使用 Gensim 可以轻松计算主题模型的一致性得分。绝对值很难解释,但是通过变化方法(NMF 与 LDA)或主题数可以让您了解您希望在主题模型中前进的方向。一致性得分和一致性模型是 Gensim 的一大优势,因为它们(尚)未包含在 scikit-learn 中。

由于很难估计“正确”的主题数量,我们现在看一种创建层次模型的方法,不需要固定的主题数作为参数。

蓝图:找到最佳主题数

在前面的章节中,我们始终使用了 10 个主题。到目前为止,我们还没有将此主题模型的质量与具有较少或更多主题数的不同模型进行比较。我们希望找到一种结构化的方式来找到最佳主题数量,而无需深入解释每个主题模型。

原来有一种方法可以实现这一点。主题模型的“质量”可以通过先前引入的一致性得分来衡量。为了找到最佳一致性得分,我们现在将为不同数量的主题使用 LDA 模型来计算它。我们将尝试找到最高得分,这应该给我们提供最佳的主题数量:

from gensim.models.ldamulticore import LdaMulticore
lda_para_model_n = []
for n in tqdm(range(5, 21)):
    lda_model = LdaMulticore(corpus=bow_gensim_para, id2word=dict_gensim_para,
                             chunksize=2000, eta='auto', iterations=400,
                             num_topics=n, passes=20, eval_every=None,
                             random_state=42)
    lda_coherence = CoherenceModel(model=lda_model, texts=gensim_paragraphs,
                                   dictionary=dict_gensim_para, coherence='c_v')
    lda_para_model_n.append((n, lda_model, lda_coherence.get_coherence()))

一致性计算需要时间

计算 LDA 模型(及其一致性)在计算上是昂贵的,因此在现实生活中,最好优化算法,仅计算少量模型和困惑度。有时,如果只计算少量主题的一致性得分,这可能是有意义的。

现在我们可以选择哪个主题数产生良好的一致性得分。注意,通常随着主题数量的增加,得分会增加。选择太多的主题会使解释变得困难:

pd.DataFrame(lda_para_model_n, columns=["n", "model", \
    "coherence"]).set_index("n")[["coherence"]].plot(figsize=(16,9))

Out:

总体而言,图表随主题数量增加而增长,这几乎总是情况。但是,我们可以看到在 13 和 17 个主题时出现了“峰值”,因此这些数字看起来是不错的选择。我们将为 17 个主题的结果进行可视化:

display_topics_gensim(lda_para_model_n[12][1])

Out:

| 主题 00 和平 (0.02)

国际 (0.02)

合作 (0.01)

国家 (0.01)

地区 (0.01) | 主题 01 将军 (0.05)

装配 (0.04)

会议 (0.02)

总统 (0.03)

先生 (0.03) | 主题 02 联合 (0.04)

国家 (0.04)

国家 (0.03)

欧洲 (0.02)

联盟 (0.02) | 主题 03 国家 (0.07)

联合 (0.07)

安全 (0.03)

理事会 (0.02)

国际 (0.02) | 主题 04 发展 (0.03)

将军 (0.02)

会议 (0.02)

装配 (0.02)

可持续 (0.01) | 主题 05 国际 (0.03)

恐怖主义 (0.03)

国家 (0.01)

伊拉克 (0.01)

行为 (0.01) |

| 主题 06 和平 (0.03)

东部 (0.02)

中东 (0.02)

以色列 (0.02)

解决方案 (0.01) | 主题 07 非洲 (0.08)

南方 (0.05)

非洲 (0.05)

纳米比亚 (0.02)

共和国 (0.01) | 主题 08 国家 (0.04)

小 (0.04)

岛屿 (0.03)

海洋 (0.02)

太平洋 (0.02) | 主题 09 世界 (0.03)

国际 (0.02)

问题 (0.01)

战争 (0.01)

和平 (0.01) | 主题 10 人类 (0.07)

权利 (0.06)

法律 (0.02)

尊重 (0.02)

国际 (0.01) | 主题 11 气候 (0.03)

变革 (0.03)

全球 (0.02)

环境 (0.01)

能源 (0.01) |

| 主题 12 世界 (0.03)

人们 (0.02)

未来 (0.01)

年度 (0.01)

今天 (0.01) | 主题 13 人民 (0.03)

独立 (0.02)

人民 (0.02)

斗争 (0.01)

国家 (0.01) | 主题 14 人民 (0.02)

国家 (0.02)

政府 (0.02)

人道主义 (0.01)

难民 (0.01) | 主题 15 国家 (0.05)

发展 (0.03)

经济 (0.03)

发展中 (0.02)

贸易 (0.01) | 主题 16 核 (0.06)

武器 (0.04)

裁军 (0.03)

武器 (0.03)

条约 (0.02) |

大多数主题都很容易解释,但有些主题很难(如 0、3、8),因为它们包含许多单词,大小相近,但不完全相同。17 个主题的主题模型是否更容易解释?实际上并非如此。连贯性得分更高,但这并不一定意味着更明显的解释。换句话说,如果主题数量过多,仅依赖连贯性得分可能是危险的。尽管理论上,较高的连贯性应有助于更好的可解释性,但通常存在权衡,选择较少的主题可以使生活更轻松。回顾连贯性图表,10 似乎是一个不错的选择,因为它是连贯性得分的局部最大值

由于明显很难找到“正确”的主题数量,我们现在将看看一种创建层次模型并且不需要固定主题数量作为参数的方法。

蓝图:使用 Gensim 创建层次狄利克雷过程

退一步,回想一下在 “Blueprint: Using LDA with Gensim” 中关于主题的可视化。主题的大小差异很大,有些主题有较大的重叠。如果结果能先给我们更广泛的主题,然后在其下方列出一些子主题,那将是非常好的。这正是层次狄利克雷过程(HDP)的确切想法。层次主题模型应该先给我们几个广泛的主题,这些主题有良好的分离性,然后通过添加更多词汇和更详细的主题定义来进一步详细说明。

HDP 目前仍然比较新,尚未进行广泛的分析。Gensim 在研究中也经常被使用,并且已经集成了 HDP 的实验性实现。由于我们可以直接使用已有的向量化,尝试起来并不复杂。请注意,我们再次使用词袋向量化,因为狄利克雷过程本身可以正确处理频繁出现的词:

from gensim.models import HdpModel
hdp_gensim_para = HdpModel(corpus=bow_gensim_para, id2word=dict_gensim_para)

HDP 能够估计主题的数量,并能展示其识别出的所有内容:

hdp_gensim_para.print_topics(num_words=10)

输出:

结果有时很难理解。可以先执行一个只包含少数主题的“粗略”主题建模。如果发现某个主题确实很大或者怀疑可能有子主题,可以创建原始语料库的子集,其中仅包含那些与该主题具有显著混合的文档。这需要一些手动交互,但通常比仅使用 HDP 得到更好的结果。在这个开发阶段,我们不建议仅使用 HDP。

主题模型专注于揭示大量文档语料库的主题结构。由于所有文档被建模为不同主题的混合物,它们不适合于将文档分配到确切的一个主题中。这可以通过聚类来实现。

Blueprint: 使用聚类揭示文本数据的结构

除了主题建模,还有许多其他无监督方法。并非所有方法都适用于文本数据,但许多聚类算法可以使用。与主题建模相比,对我们来说重要的是每个文档(或段落)都被分配到一个簇中。

对于单一类型的文本,聚类效果良好

在我们的情况下,合理假设每个文档属于一个簇,因为一个段落中可能包含的不同内容并不多。对于更大的文本片段,我们更倾向于使用主题建模来考虑可能的混合情况。

大多数聚类方法需要簇的数量作为参数,虽然有少数方法(如均值漂移)可以猜测正确的簇数量。后者大多数不适用于稀疏数据,因此不适合文本分析。在我们的情况下,我们决定使用 k-means 聚类,但 birch 或谱聚类应该以类似的方式工作。有几种解释说明了 k-means 算法的工作原理。^(4)

聚类比主题建模慢得多

对于大多数算法,聚类需要相当长的时间,甚至比 LDA 还要长。因此,在执行下一个代码片段中的聚类时,请做好大约等待一小时的准备。

scikit-learn 的聚类 API 与我们在主题模型中看到的类似:

from sklearn.cluster import KMeans
k_means_text = KMeans(n_clusters=10, random_state=42)
k_means_text.fit(tfidf_para_vectors)

KMeans(n_clusters=10, random_state=42)

但是现在要找出有多少段落属于哪个聚类变得更容易了。所有必要的东西都在 k_means_para 对象的 labels_ 字段中。对于每个文档,它包含了聚类算法分配的标签:

np.unique(k_means_para.labels_, return_counts=True)

输出:

(array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], dtype=int32),
array([133370,  41705,  12396,   9142,  12674,  21080,  19727,  10563,
         10437,  11116]))

在许多情况下,你可能已经发现了一些概念上的问题。如果数据太异构,大多数聚类往往很小(包含相对较小的词汇),并伴随着一个吸收所有剩余的大聚类。幸运的是(由于段落很短),这在这里并不是问题;聚类 0 比其他聚类要大得多,但它并不是数量级。让我们用 y 轴显示聚类的大小来可视化分布(参见 图 8-6):

sizes = []
for i in range(10):
    sizes.append({"cluster": i, "size": np.sum(k_means_para.labels_==i)})
pd.DataFrame(sizes).set_index("cluster").plot.bar(figsize=(16,9))

可视化聚类的工作方式与主题模型类似。但是,我们必须手动计算各个特征的贡献。为此,我们将集群中所有文档的 TF-IDF 向量相加,并仅保留最大值。

图 8-6. 聚类大小的可视化。

这些是它们相应单词的权重。实际上,这与前面的代码唯一的区别就是:

def wordcloud_clusters(model, vectors, features, no_top_words=40):
    for cluster in np.unique(model.labels_):
        size = {}
        words = vectors[model.labels_ == cluster].sum(axis=0).A[0]
        largest = words.argsort()[::-1] # invert sort order
        for i in range(0, no_top_words):
            size[features[largest[i]]] = abs(words[largest[i]])
        wc = WordCloud(background_color="white", max_words=100,
                       width=960, height=540)
        wc.generate_from_frequencies(size)
        plt.figure(figsize=(12,12))
        plt.imshow(wc, interpolation='bilinear')
        plt.axis("off")
        # if you don't want to save the topic model, comment the next line
        plt.savefig(f'cluster{cluster}.png')

wordcloud_clusters(k_means_para, tfidf_para_vectors,
                   tfidf_para_vectorizer.get_feature_names())

输出:

正如你所看到的,结果与各种主题建模方法(幸运地)并没有太大不同;你可能会认出核武器、南非、大会等主题。然而,请注意,聚类更加明显。换句话说,它们有更具体的单词。不幸的是,这并不适用于最大的聚类 1,它没有明确的方向,但有许多具有相似较小尺寸的单词。这是与主题建模相比聚类算法的典型现象。

聚类计算可能需要相当长的时间,尤其是与 NMF 主题模型相比。积极的一面是,我们现在可以自由选择某个聚类中的文档(与主题模型相反,这是明确定义的)并执行其他更复杂的操作,如层次聚类等。

聚类的质量可以通过使用一致性或 Calinski-Harabasz 分数来计算。这些指标并不针对稀疏数据进行优化,计算时间较长,因此我们在这里跳过它们。

进一步的想法

在本章中,我们展示了执行主题建模的不同方法。但是,我们只是触及了可能性的表面:

  • 可以在向量化过程中添加 n-gram。在 scikit-learn 中,通过使用ngram_range参数可以轻松实现这一点。Gensim 有一个特殊的Phrases类。由于 n-gram 具有更高的 TF-IDF 权重,它们可以在话题的特征中起到重要作用,并添加大量的上下文信息。

  • 由于我们已经使用多年来依赖时间相关的话题模型,您也可以使用国家或大洲,并找出其大使在演讲中最相关的话题。

  • 使用整个演讲而不是段落来计算 LDA 话题模型的一致性分数,并进行比较。

总结和建议

在日常工作中,无监督方法(如话题建模或聚类)通常被用作了解未知文本语料库内容的首选方法。进一步检查是否选择了正确的特征或是否仍可优化,这也是非常有用的。

计算话题时,最重要的决定之一是你将用来计算话题的实体。正如我们蓝图示例所示,文件并不总是最佳选择,特别是当它们非常长,并且由算法确定的子实体组成时。

找到正确的话题数量始终是一个挑战。通常,这必须通过计算质量指标来迭代解决。一个经常使用的更为实用的方法是尝试合理数量的话题,并找出结果是否可解释。

使用(大量)更多的话题(如几百个),话题模型经常被用作文本文档的降维技术。通过生成的向量化,可以在潜在空间中计算相似度分数,并且通常与 TF-IDF 空间中的朴素距离相比,产生更好的结果。

结论

话题模型是一种强大的技术,并且计算成本不高。因此,它们可以广泛用于文本分析。使用它们的首要原因是揭示文档语料库的潜在结构。

话题模型对于获取大型未知文本的总结和结构的概念也是有用的。因此,它们通常在分析的开始阶段被常规使用。

由于存在大量不同的算法和实现方法,因此尝试不同的方法并查看哪种方法在给定的文本语料库中产生最佳结果是有意义的。基于线性代数的方法速度很快,并且通过计算相应的质量指标,可以进行分析。

在执行主题建模之前以不同方式聚合数据可以导致有趣的变化。正如我们在联合国大会辩论数据集中看到的那样,段落更适合,因为发言者一个接一个地讨论了一个话题。如果您有来自许多作者的语料库,将每位作者的所有文本串联起来将为您提供不同类型作者的人物模型。

^(1) Blei, David M., et al. “潜在狄利克雷分配。” 机器学习研究杂志 3 (4–5): 993–1022. doi:10.1162/jmlr.2003.3.4-5.993.

^(2) 要了解更详细的描述,请参阅Wikipedia 页面

^(3) pyLDAvis 必须单独安装,使用**pip install pyldavisconda install pyldavis**。

^(4) 参见,例如,安德烈·A·沙巴林的k-means 聚类页面或纳夫塔利·哈里斯的“可视化 K-Means 聚类”

第九章:文本摘要

互联网上有大量关于每个主题的信息。Google 搜索返回数百万条搜索结果,其中包含文本、图像、视频等内容。即使我们只考虑文本内容,也不可能全部阅读。文本摘要方法能够将文本信息压缩成几行或一个段落的简短摘要,并使大多数用户能够理解。文本摘要的应用不仅限于互联网,还包括类似法律助理案例摘要、书籍梗概等领域。

您将学到什么以及我们将要构建的内容

在本章中,我们将从文本摘要的介绍开始,并概述所使用的方法。我们将分析不同类型的文本数据及其特定特征,这些特征对于确定摘要方法的选择非常有用。我们将提供适用于不同用例的蓝图,并分析它们的性能。在本章末尾,您将对不同的文本摘要方法有很好的理解,并能够为任何应用选择合适的方法。

文本摘要

很可能在生活中的某个时刻,您有意或无意地进行了摘要任务。例如,告诉朋友您昨晚观看的电影,或尝试向家人解释您的工作。我们都喜欢向世界其他地方提供我们经历的简要总结,以分享我们的感受并激励他人。文本摘要被定义为在保留有用信息的同时生成更简洁的长文本摘要的方法,而不会失去整体背景。这是一种我们非常熟悉的方法:在阅读课程教材、讲义笔记甚至本书时,许多学生会尝试突出重要的句子或做简短的笔记来捕捉重要概念。自动文本摘要方法允许我们使用计算机来完成这项任务。

摘要方法可以大致分为抽取生成方法。在抽取式摘要中,会从给定的文本中识别重要短语或句子,并将它们组合成整个文本的摘要。这些方法通过正确分配权重来识别文本的重要部分,删除可能传达冗余信息的句子,对文本的不同部分进行排名,并将最重要的部分组合成摘要。这些方法选择原始文本的一部分作为摘要,因此每个句子在语法上都是准确的,但可能不会形成连贯的段落。

另一方面,抽象总结方法尝试像人类一样转述并生成摘要。这通常涉及使用能够生成提供文本语法正确摘要的短语和句子的深度神经网络。然而,训练深度神经网络的过程需要大量的训练数据,并且涉及 NLP 的多个子领域,如自然语言生成、语义分割等。

抽象总结方法是一个活跃研究领域,有几种方法致力于改进现有技术。Hugging Face 的Transformers提供了一个使用预训练模型执行总结任务的实现。我们将在第十一章详细探讨预训练模型和 Transformers 库的概念。在许多用例中,萃取式总结更受青睐,因为这些方法实现简单且运行速度快。在本章中,我们将专注于使用萃取式总结的蓝图。

假设您在一家法律公司工作,希望查看历史案例以帮助准备当前案例。由于案件程序和判决非常长,他们希望生成摘要,并仅在相关时查看整个案例。这样的摘要帮助他们快速查看多个案例,并有效分配时间。我们可以将此视为应用于长篇文本的文本总结示例。另一个用例可能是媒体公司每天早晨向订阅者发送新闻简报,重点突出前一天的重要事件。客户不喜欢长邮件,因此创建每篇文章的简短摘要对保持他们的参与至关重要。在这种用例中,您需要总结较短的文本。在处理这些项目时,也许您需要在使用 Slack 或 Microsoft Teams 等聊天沟通工具的团队中工作。有共享的聊天组(或频道),所有团队成员可以彼此交流。如果您在会议中离开几个小时,聊天信息可能会迅速积累,导致大量未读消息和讨论。作为用户,浏览 100 多条未读消息很困难,并且无法确定是否错过了重要内容。在这种情况下,通过自动化机器人总结这些错过的讨论可能会有所帮助。

在每个用例中,我们看到不同类型的文本需要总结。让我们简要再次呈现它们:

  • 结构化撰写的长篇文本,包含段落并分布在多页之间。例如案件程序、研究论文、教科书等。

  • 短文本,如新闻文章和博客,其中可能包含图像、数据和其他图形元素。

  • 多个短文本片段采用对话形式,可以包含表情符号等特殊字符,结构不是很严谨。例如 Twitter 的线索、在线讨论论坛和群组消息应用程序。

这些类型的文本数据每种呈现信息方式不同,因此用于一个类型的摘要方法可能不适用于另一种。在我们的蓝图中,我们提出适用于这些文本类型的方法,并提供指导以确定适当的方法。

抽取方法

所有抽取方法都遵循这三个基本步骤:

  1. 创建文本的中间表示。

  2. 基于选择的表示对句子/短语进行评分。

  3. 对句子进行排名和选择,以创建文本摘要。

虽然大多数蓝图会按照这些步骤进行,但它们用来创建中间表示或分数的具体方法会有所不同。

数据预处理

在继续实际蓝图之前,我们将重复使用第三章中的蓝图来读取我们想要总结的给定 URL。在这份蓝图中,我们将专注于使用文本生成摘要,但您可以研究第三章以获取从 URL 提取数据的详细概述。为了简洁起见,文章的输出已经缩短;要查看整篇文章,您可以访问以下 URL:

import reprlib
r = reprlib.Repr()
r.maxstring = 800

url1 = "https://www.reuters.com/article/us-qualcomm-m-a-broadcom-5g/\
 what-is-5g-and-who-are-the-major-players-idUSKCN1GR1IN"
article_name1 = download_article(url1)
article1 = parse_article(article_name1)
print ('Article Published on', r.repr(article1['time']))
print (r.repr(article1['text']))

输出:

Article Published on '2018-03-15T11:36:28+0000'
'LONDON/SAN FRANCISCO (Reuters) - U.S. President Donald Trump has blocked
microchip maker Broadcom Ltd’s (AVGO.O) $117 billion takeover of rival Qualcomm
(QCOM.O) amid concerns that it would give China the upper hand in the next
generation of mobile communications, or 5G. A 5G sign is seen at the Mobile
World Congress in Barcelona, Spain February 28, 2018\. REUTERS/Yves HermanBelow
are some facts... 4G wireless and looks set to top the list of patent holders
heading into the 5G cycle. Huawei, Nokia, Ericsson and others are also vying to
amass 5G patents, which has helped spur complex cross-licensing agreements like
the deal struck late last year Nokia and Huawei around handsets. Editing by Kim
Miyoung in Singapore and Jason Neely in LondonOur Standards:The Thomson Reuters
Trust Principles.'

注意

我们使用reprlib包,该包允许我们自定义打印语句的输出。在这种情况下,打印完整文章的内容是没有意义的。我们限制输出的大小为 800 个字符,reprlib包重新格式化输出,显示文章开头和结尾的选定序列词语。

蓝图:使用主题表示进行文本摘要

让我们首先尝试自己总结一下例子 Reuters 文章。阅读完之后,我们可以提供以下手动生成的摘要:

5G 是下一代无线技术,将依赖更密集的小天线阵列,提供比当前 4G 网络快 50 到 100 倍的数据速度。这些新网络预计不仅将数据传输速度提高到手机和电脑,还将扩展到汽车、货物、农作物设备等各种传感器。高通是今天智能手机通信芯片市场的主导者,人们担心新加坡的博通公司收购高通可能会导致高通削减研发支出或将公司战略重要部分出售给其他买家,包括在中国的买家。这可能会削弱高通,在 5G 竞赛中促进中国超越美国的风险。

作为人类,我们理解文章传达的内容,然后生成我们理解的摘要。然而,算法没有这种理解,因此必须依赖于识别重要主题来确定是否应将句子包括在摘要中。在示例文章中,主题可能是技术、电信和 5G 等广泛主题,但对于算法来说,这只是一组重要单词的集合。我们的第一种方法试图区分重要和不那么重要的单词,从而使我们能够将包含重要单词的句子排名较高。

使用 TF-IDF 值识别重要词语

最简单的方法是基于句子中单词的 TF-IDF 值的总和来识别重要句子。详细解释 TF-IDF 在第五章中提供,但对于这个蓝图,我们应用 TF-IDF 向量化,然后将值聚合到句子级别。我们可以为每个句子生成一个分数,作为该句子中每个单词的 TF-IDF 值的总和。这意味着得分高的句子包含的重要单词比文章中的其他句子多:

from sklearn.feature_extraction.text import TfidfVectorizer
from nltk import tokenize

sentences = tokenize.sent_tokenize(article1['text'])
tfidfVectorizer = TfidfVectorizer()
words_tfidf = tfidfVectorizer.fit_transform(sentences)

在这种情况下,文章中大约有 20 句话,我们选择创建一个只有原始文章大小的 10%的简要总结(大约两到三句话)。我们对每个句子的 TF-IDF 值进行求和,并使用np.argsort对它们进行排序。这种方法按升序对每个句子的索引进行排序,我们使用[::-1]来逆转返回的索引。为了确保与文章中呈现的思路相同,我们按照它们出现的顺序打印所选的摘要句子。我们可以看到我们生成的摘要结果,如下所示:

# Parameter to specify number of summary sentences required
num_summary_sentence = 3

# Sort the sentences in descending order by the sum of TF-IDF values
sent_sum = words_tfidf.sum(axis=1)
important_sent = np.argsort(sent_sum, axis=0)[::-1]

# Print three most important sentences in the order they appear in the article
for i in range(0, len(sentences)):
    if i in important_sent[:num_summary_sentence]:
        print (sentences[i])

输出:

LONDON/SAN FRANCISCO (Reuters) - U.S. President Donald Trump has blocked
microchip maker Broadcom Ltd’s (AVGO.O) $117 billion takeover of rival Qualcomm
(QCOM.O) amid concerns that it would give China the upper hand in the next
generation of mobile communications, or 5G.
5G networks, now in the final testing stage, will rely on denser arrays of
small antennas and the cloud to offer data speeds up to 50 or 100 times faster
than current 4G networks and serve as critical infrastructure for a range of
industries.
The concern is that a takeover by Singapore-based Broadcom could see the firm
cut research and development spending by Qualcomm or hive off strategically
important parts of the company to other buyers, including in China, U.S.
officials and analysts have said.

在这种方法中,我们使用 TF-IDF 值创建文本的中间表示,根据这些值对句子进行评分,并选择三个得分最高的句子。使用这种方法选择的句子与我们之前写的手动摘要一致,并捕捉了文章涵盖的主要要点。一些细微差别,比如 Qualcomm 在行业中的重要性和 5G 技术的具体应用,被忽略了。但这种方法作为快速识别重要句子并自动生成新闻文章摘要的良好蓝图。我们将这个蓝图封装成一个名为tfidf_summary的函数,该函数在附带的笔记本中定义并在本章后面再次使用。

LSA 算法

在基于抽取的摘要方法中,使用的一种现代方法是潜在语义分析(LSA)。LSA 是一种通用方法,用于主题建模、文档相似性和其他任务。LSA 假设意思相近的词会出现在同一篇文档中。在 LSA 算法中,我们首先将整篇文章表示为一个句子-词矩阵。文档-词矩阵的概念已在第八章中介绍过,我们可以将该概念调整为适合句子-词矩阵。每行代表一个句子,每列代表一个词。该矩阵中每个单元格的值是词频通常按 TF-IDF 权重进行缩放。该方法的目标是通过创建句子-词矩阵的修改表示来将所有单词减少到几个主题中。为了创建修改后的表示,我们应用非负矩阵分解的方法,将该矩阵表示为具有较少行/列的两个新分解矩阵的乘积。您可以参考第八章更详细地了解这一方法。在矩阵分解步骤之后,我们可以通过选择前 N 个重要主题生成摘要,然后选择每个主题中最重要的句子来形成我们的摘要。

我们不再从头开始应用 LSA,而是利用sumy包,可以使用命令**pip install sumy**进行安装。该库提供了同一库内的多种摘要方法。此库使用一个集成的停用词列表,并结合来自 NLTK 的分词器和词干处理功能,但可以进行配置。此外,它还能够从纯文本、HTML 和文件中读取输入。这使我们能够快速测试不同的摘要方法,并更改默认配置以适应特定的使用案例。目前,我们将使用默认选项,包括识别前三个句子:

from sumy.parsers.plaintext import PlaintextParser
from sumy.nlp.tokenizers import Tokenizer
from sumy.nlp.stemmers import Stemmer
from sumy.utils import get_stop_words

from sumy.summarizers.lsa import LsaSummarizer

LANGUAGE = "english"
stemmer = Stemmer(LANGUAGE)

parser = PlaintextParser.from_string(article1['text'], Tokenizer(LANGUAGE))
summarizer = LsaSummarizer(stemmer)
summarizer.stop_words = get_stop_words(LANGUAGE)

for sentence in summarizer(parser.document, num_summary_sentence):
    print (str(sentence))

输出:

LONDON/SAN FRANCISCO (Reuters) - U.S. President Donald Trump has blocked
microchip maker Broadcom Ltd’s (AVGO.O) $117 billion takeover of rival Qualcomm
(QCOM.O) amid concerns that it would give China the upper hand in the next
generation of mobile communications, or 5G.
Moving to new networks promises to enable new mobile services and even whole
new business models, but could pose challenges for countries and industries
unprepared to invest in the transition.
The concern is that a takeover by Singapore-based Broadcom could see the firm
cut research and development spending by Qualcomm or hive off strategically
important parts of the company to other buyers, including in China, U.S.
officials and analysts have said.

通过分析结果,我们看到 TF-IDF 的结果仅有一句与 LSA 的结果有所不同,即第 2 句。虽然 LSA 方法选择突出显示一个关于挑战主题的句子,但 TF-IDF 方法选择了一个更多关于 5G 信息的句子。在这种情况下,两种方法生成的摘要并没有非常不同,但让我们分析一下这种方法在更长文章上的工作效果。

我们将这个蓝图封装成一个函数lsa_summary,该函数在附带的笔记本中定义,并可重复使用:

r.maxstring = 800
url2 = "https://www.reuters.com/article/us-usa-economy-watchlist-graphic/\
 predicting-the-next-u-s-recession-idUSKCN1V31JE"
article_name2 = download_article(url2)
article2 = parse_article(article_name2)
print ('Article Published', r.repr(article1['time']))
print (r.repr(article2['text']))

输出:

Article Published '2018-03-15T11:36:28+0000'
'NEW YORK A protracted trade war between China and the United States, the
world’s largest economies, and a deteriorating global growth outlook has left
investors apprehensive about the end to the longest expansion in American
history. FILE PHOTO: Ships and shipping containers are pictured at the port of
Long Beach in Long Beach, California, U.S., January 30, 2019\.   REUTERS/Mike
BlakeThe recent ...hton wrote in the June Cass Freight Index report.  12.
MISERY INDEX The so-called Misery Index adds together the unemployment rate and
the inflation rate. It typically rises during recessions and sometimes prior to
downturns. It has slipped lower in 2019 and does not look very miserable.
Reporting by Saqib Iqbal Ahmed; Editing by Chizu NomiyamaOur Standards:The
Thomson Reuters Trust Principles.'

然后:

summary_sentence = tfidf_summary(article2['text'], num_summary_sentence)
for sentence in summary_sentence:
    print (sentence)

输出:

REUTERS/Mike BlakeThe recent rise in U.S.-China trade war tensions has brought
forward the next U.S. recession, according to a majority of economists polled
by Reuters who now expect the Federal Reserve to cut rates again in September
and once more next year.
On Tuesday, U.S. stocks jumped sharply higher and safe-havens like the Japanese
yen and Gold retreated after the U.S. Trade Representative said additional
tariffs on some Chinese goods, including cell phones and laptops, will be
delayed to Dec. 15.
ISM said its index of national factory activity slipped to 51.2 last month, the
lowest reading since August 2016, as U.S. manufacturing activity slowed to a
near three-year low in July and hiring at factories shifted into lower gear,
suggesting a further loss of momentum in economic growth early in the third
quarter.

最后:

summary_sentence = lsa_summary(article2['text'], num_summary_sentence)
for sentence in summary_sentence:
    print (sentence)

输出:

NEW YORK A protracted trade war between China and the United States, the
world’s largest economies, and a deteriorating global growth outlook has left
investors apprehensive about the end to the longest expansion in American
history.
REUTERS/Mike BlakeThe recent rise in U.S.-China trade war tensions has brought
forward the next U.S. recession, according to a majority of economists polled
by Reuters who now expect the Federal Reserve to cut rates again in September
and once more next year.
Trade tensions have pulled corporate confidence and global growth to multi-year
lows and U.S. President Donald Trump’s announcement of more tariffs have raised
downside risks significantly, Morgan Stanley analysts said in a recent note.

在这里,选择的摘要句子的差异变得更加明显。贸易战紧张局势的主要话题被两种方法捕捉到,但 LSA 摘要器还突出了投资者的担忧和企业信心等重要话题。虽然 TF-IDF 试图在其选择的句子中表达相同的观点,但它没有选择正确的句子,因此未能传达这一观点。还有其他基于主题的摘要方法,但我们选择突出 LSA 作为一个简单且广泛使用的方法。

注意

有趣的是,sumy库还提供了自动文本摘要的一个最古老的方法(LuhnSummarizer)的实现,该方法由Hans Peter Luhn 于 1958 年创造。这种方法也是基于通过识别重要词汇的计数和设置阈值来表示主题。您可以将其用作文本摘要实验的基准方法,并比较其他方法提供的改进。

蓝图:使用指示器表示对文本进行摘要

指示器表示方法旨在通过使用句子的特征及其与文档中其他句子的关系来创建句子的中间表示,而不仅仅是使用句子中的单词。TextRank是指示器方法中最流行的例子之一。TextRank 受 PageRank 启发,是一种“基于图的排名算法,最初由 Google 用于排名搜索结果。根据 TextRank 论文的作者,基于图的算法依赖于网页结构的集体知识,而不是单个网页内容的分析”,这导致了改进的性能。在我们的背景下应用,我们将依赖句子的特征和它们之间的链接,而不是依赖每个句子所包含的主题。

首先我们将尝试理解 PageRank 算法的工作原理,然后将方法应用于文本摘要问题。让我们考虑一个网页列表(A、B、C、D、E 和 F)及其彼此之间的链接。在图 9-1 中,页面 A 包含指向页面 D 的链接。页面 B 包含指向 A 和 D 的链接,依此类推。我们还可以用一个矩阵表示,行表示每个页面,列表示来自其他页面的入链。图中显示的矩阵表示我们的图,行表示每个节点,列表示来自其他节点的入链,单元格的值表示它们之间边的权重。我们从一个简单的表示开始(1 表示有入链,0 表示没有)。然后我们可以通过将每个网页的出链总数进行除法来归一化这些值。例如,页面 C 有两个出链(到页面 E 和 F),因此每个出链的值为 0.5。

图 9-1. 网页链接和相应的 PageRank 矩阵。

对于给定页面的 PageRank 是所有具有链接的其他页面的 PageRank 的加权和。这也意味着计算 PageRank 是一个迭代函数,我们必须从每个页面的一些假设的 PageRank 初始值开始。如果我们假设所有初始值为 1,并按照图 9-2 所示的方式进行矩阵乘法,我们可以在一次迭代后得到每个页面的 PageRank(不考虑此示例的阻尼因子)。

Brin 和 Page 的研究论文表明,重复进行多次迭代计算后,数值稳定,因此我们得到每个页面的 PageRank 或重要性。TextRank 通过将文本中的每个句子视为一个页面和因此图中的一个节点来调整先前的方法。节点之间边的权重由句子之间的相似性决定,TextRank 的作者建议通过计算共享词汇标记的数量(归一化为两个句子的大小)来实现简单的方法。还有其他相似度度量,如余弦距离和最长公共子串也可以使用。

图 9-2. PageRank 算法的一次迭代应用。

由于 sumy 包还提供了 TextRank 实现,我们将使用它为我们之前看到的关于美国经济衰退的文章生成总结的句子:

from sumy.summarizers.text_rank import TextRankSummarizer

parser = PlaintextParser.from_string(article2['text'], Tokenizer(LANGUAGE))
summarizer = TextRankSummarizer(stemmer)
summarizer.stop_words = get_stop_words(LANGUAGE)

for sentence in summarizer(parser.document, num_summary_sentence):
    print (str(sentence))

REUTERS/Mike BlakeThe recent rise in U.S.-China trade war tensions has brought
forward the next U.S. recession, according to a majority of economists polled
by Reuters who now expect the Federal Reserve to cut rates again in September
and once more next year.
As recession signals go, this so-called inversion in the yield curve has a
solid track record as a predictor of recessions.
Markets turned down before the 2001 recession and tumbled at the start of the
2008 recession.

当总结句之一保持不变时,这种方法选择返回其他两个可能与本文主要结论相关联的句子。虽然这些句子本身可能并不重要,但使用基于图的方法选择了支持文章主题的高度关联句子。我们将这个蓝图封装成一个函数textrank_summary,允许我们进行重复使用。

我们还想看看这种方法在我们之前查看过的关于 5G 技术的较短文章上的运作:

parser = PlaintextParser.from_string(article1['text'], Tokenizer(LANGUAGE))
summarizer = TextRankSummarizer(stemmer)
summarizer.stop_words = get_stop_words(LANGUAGE)

for sentence in summarizer(parser.document, num_summary_sentence):
    print (str(sentence))

Out:

Acquiring Qualcomm would represent the jewel in the crown of Broadcom’s
portfolio of communications chips, which supply wi-fi, power management, video
and other features in smartphones alongside Qualcomm’s core baseband chips -
radio modems that wirelessly connect phones to networks.
Qualcomm (QCOM.O) is the dominant player in smartphone communications chips,
making half of all core baseband radio chips in smartphones.
Slideshow (2 Images)The standards are set by a global body to ensure all phones
work across different mobile networks, and whoever’s essential patents end up
making it into the standard stands to reap huge royalty licensing revenue
streams.

我们看到,结果捕捉到了高通收购的中心思想,但没有提及 LSA 方法选择的 5G 技术。TextRank 通常在长文本内容的情况下表现更好,因为它能够使用图链接识别最重要的句子。在较短的文本内容中,图不是很大,因此网络智慧发挥的作用较小。让我们使用来自维基百科的更长内容的例子来进一步说明这一点。我们将重复使用来自第二章的蓝图,下载维基百科文章的文本内容。在这种情况下,我们选择描述历史事件或事件系列的文章:蒙古入侵欧洲。由于这是更长的文本,我们选择总结大约 10 句话,以提供更好的总结:

p_wiki = wiki_wiki.page('Mongol_invasion_of_Europe')
print (r.repr(p_wiki.text))

Out:

'The Mongol invasion of Europe in the 13th century occurred from the 1220s into
the 1240s. In Eastern Europe, the Mongols destroyed Volga Bulgaria, Cumania,
Alania, and the Kievan Rus\' federation. In Central Europe, the Mongol armies
launched a tw...tnotes\nReferences\nSverdrup, Carl (2010). "Numbers in Mongol
Warfare". Journal of Medieval Military History. Boydell Press. 8: 109–17 [p.
115]. ISBN 978-1-84383-596-7.\n\nFurther reading\nExternal links\nThe Islamic
World to 1600: The Golden Horde'

然后:

r.maxstring = 200

num_summary_sentence = 10

summary_sentence = textrank_summary(p_wiki.text, num_summary_sentence)

for sentence in summary_sentence:
    print (sentence)

我们将结果展示为原始维基百科页面中的突出显示句子(Figure 9-3),以展示使用 TextRank 算法通过从文章的每个部分中选择最重要的句子,几乎准确地对文章进行了总结。我们可以比较这与 LSA 方法的工作,但我们将这留给读者使用先前的蓝图作为练习。根据我们的经验,当我们想要总结大量的文本内容时,例如科学研究论文、作品集以及世界领导人的演讲或多个网页时,我们会选择像 TextRank 这样基于图的方法。

Figure 9-3. 维基百科页面,突出显示了选定的摘要句子。

衡量文本摘要方法的性能

到目前为止,我们在蓝图中已经看到了许多方法来生成某段文本的摘要。每个摘要在细微之处都有所不同,我们必须依靠我们的主观评估。在选择最适合特定使用案例的方法方面,这无疑是一个挑战。在本节中,我们将介绍常用的准确度度量标准,并展示它们如何被用来经验性地选择最佳的摘要方法。

我们必须理解,要自动评估某段给定文本的摘要,必须有一个可以进行比较的参考摘要。通常,这是由人类编写的摘要,称为黄金标准。每个自动生成的摘要都可以与黄金标准进行比较,以获得准确度的度量。这也为我们提供了比较多种方法并选择最佳方法的机会。然而,我们经常会遇到一个问题,即并非每个使用案例都有人类生成的摘要存在。在这种情况下,我们可以选择一个代理度量来视为黄金标准。在新闻文章的案例中,一个例子就是标题。虽然它是由人类编写的,但作为一个代理度量它并不准确,因为它可能非常简短,更像是一个引导性陈述来吸引用户。虽然这可能不会给我们带来最佳结果,但比较不同摘要方法的性能仍然是有用的。

用于 Gisting 评估的召回导向的 Understudy(ROUGE)是最常用的测量摘要准确性的方法之一。有几种类型的 ROUGE 度量标准,但基本思想很简单。它通过比较自动生成的摘要与黄金标准之间的共享术语数量来得出准确度的度量。ROUGE-N 是一种度量标准,用于衡量常见的 n-gram(ROUGE-1 比较单个词,ROUGE-2 比较二元组,依此类推)。

原始的 ROUGE 论文 比较了在自动生成的摘要中出现的单词中有多少也出现在金标准中。这就是我们在 第六章 中介绍的 召回率。因此,如果金标准中大多数单词也出现在生成的摘要中,我们将获得高分。然而,单靠这一指标并不能讲述整个故事。考虑到我们生成了一个冗长但包含金标准中大多数单词的摘要。这个摘要将获得高分,但它不是一个好的摘要,因为它没有提供简洁的表示。这就是为什么 ROUGE 测量已经扩展到将共享单词的数量与生成的摘要中的总单词数进行比较。这表明了精度:生成摘要中实际有用的单词数。我们可以结合这些措施生成 F 分数。

让我们看一个我们生成摘要的 ROUGE 示例。由于我们没有金标准的人工生成摘要,我们使用文章标题作为金标准的代理。虽然这样计算独立简单,但我们利用名为 rouge_scorer 的 Python 包来使我们的生活更轻松。这个包实现了我们后来将使用的所有 ROUGE 测量,并且可以通过执行命令 **pip install rouge_scorer** 进行安装。我们利用一个打印实用函数 print_rouge_score 来展示得分的简洁视图:

num_summary_sentence = 3
gold_standard = article2['headline']
summary = ""

summary = ''.join(textrank_summary(article2['text'], num_summary_sentence))
scorer = rouge_scorer.RougeScorer(['rouge1'], use_stemmer=True)
scores = scorer.score(gold_standard, summary)
print_rouge_score(scores)

输出:

rouge1 Precision: 0.06 Recall: 0.83 fmeasure: 0.11

先前的结果显示,TextRank 生成的摘要具有高召回率但低精度。这是我们金标准是一个极短标题的结果,本身并不是最佳选择,但在这里用于说明。我们度量标准的最重要用途是与另一种总结方法进行比较,在这种情况下,让我们与 LSA 生成的摘要进行比较:

summary = ''.join(lsa_summary(article2['text'], num_summary_sentence))
scores = scorer.score(gold_standard, summary)
print_rouge_score(scores)

输出:

rouge1 Precision: 0.04 Recall: 0.83 fmeasure: 0.08

上述结果表明,在这种情况下,TextRank 是优越的方法,因为它具有更高的精度,而两种方法的召回率相同。我们可以轻松地扩展 ROUGE-1 到 ROUGE-2,这将比较两个词(二元组)的公共序列的数量。另一个重要的指标是 ROUGE-L,它通过识别参考摘要与生成摘要之间的最长公共子序列来衡量。句子的子序列是一个新句子,可以从原始句子中删除一些单词而不改变剩余单词的相对顺序。这个指标的优势在于它不专注于精确的序列匹配,而是反映句子级词序的顺序匹配。让我们分析维基百科页面的 ROUGE-2 和 ROUGE-L 指标。再次强调,我们没有一个金标准,因此我们将使用简介段落作为我们金标准的代理:

num_summary_sentence = 10
gold_standard = p_wiki.summary

summary = ''.join(textrank_summary(p_wiki.text, num_summary_sentence))

scorer = rouge_scorer.RougeScorer(['rouge2','rougeL'], use_stemmer=True)
scores = scorer.score(gold_standard, summary)
print_rouge_score(scores)

输出:

rouge2 Precision: 0.18 Recall: 0.46 fmeasure: 0.26
rougeL Precision: 0.16 Recall: 0.40 fmeasure: 0.23

然后:

summary = ''.join(lsa_summary(p_wiki.text, num_summary_sentence))

scorer = rouge_scorer.RougeScorer(['rouge2','rougeL'], use_stemmer=True)
scores = scorer.score(gold_standard, summary)
print_rouge_score(scores)

输出:

rouge2 Precision: 0.04 Recall: 0.08 fmeasure: 0.05
rougeL Precision: 0.12 Recall: 0.25 fmeasure: 0.16

根据结果,我们看到 TextRank 比 LSA 更准确。我们可以使用与前面展示的相同方法来查看哪种方法对较短的维基百科条目效果最好,这将留给读者作为练习。当应用到您的用例时,重要的是选择正确的摘要进行比较。例如,在处理新闻文章时,您可以查找文章内包含的摘要部分,而不是使用标题,或者为少数文章生成自己的摘要。这样可以在不同方法之间进行公平比较。

蓝图:使用机器学习进行文本总结

许多人可能参与了关于旅行规划、编程等主题的在线讨论论坛。在这些平台上,用户以线程的形式进行交流。任何人都可以开始一个线程,其他成员则在该线程上提供他们的回应。线程可能会变得很长,关键信息可能会丢失。在这个蓝图中,我们将使用从研究论文中提取的数据,^(2) 这些数据包含了一个线程中所有帖子的文本以及该线程的摘要,如图 9-4 所示。

在这个蓝图中,我们将使用机器学习来帮助我们自动识别整个线程中最重要的帖子,这些帖子准确地总结了整个线程。我们首先使用注释者的摘要为我们的数据集创建目标标签。然后生成能够确定特定帖子是否应该出现在摘要中的特征,并最终训练一个模型并评估其准确性。手头的任务类似于文本分类,但是在帖子级别上执行。

虽然论坛线程用于说明这个蓝图,但它也可以轻松地用于其他用例。例如,考虑CNN 和每日邮报新闻摘要任务DUC,或SUMMAC数据集。在这些数据集中,你会找到每篇文章的文本和突出显示的摘要句子。这些与本蓝图中呈现的每个线程的文本和摘要类似。

图片

图 9-4。一个线程中的帖子及其来自旅行论坛的对应摘要。

第 1 步:创建目标标签

第一步是加载数据集,了解其结构,并使用提供的摘要创建目标标签。我们已经执行了初始的数据准备步骤,创建了一个格式良好的DataFrame,如下所示。请参阅书籍的 GitHub 仓库中的Data_Preparation笔记本,详细了解这些步骤:

import pandas as pd
import numpy as np

df = pd.read_csv('travel_threads.csv', sep='|', dtype={'ThreadID': 'object'})
df[df['ThreadID']=='60763_5_3122150'].head(1).T

170
日期 2009 年 9 月 29 日,1:41
文件名 thread41_system20
线程 ID 60763_5_3122150
标题 需要预订哪些景点?
帖子编号 1
text Hi I am coming to NY in Oct! So excited" Have wanted to visit for years. We are planning on doing all the usual stuff so wont list it all but wondered which attractions should be pre booked and which can you just turn up at> I am plannin on booking ESB but what else? thanks x
用户 ID musicqueenLon...
summary A woman was planning to travel NYC in October and needed some suggestions about attractions in the NYC. She was planning on booking ESB.Someone suggested that the TOTR was much better compared to ESB. The other suggestion was to prebook the show to avoid wasting time in line.Someone also suggested her New York Party Shuttle tours.

这个数据集中的每一行都指的是主题中的一个帖子。每个主题由一个唯一的 ThreadID 标识,DataFrame 中可能有多行具有相同的 ThreadIDTitle 列指的是用户开始主题时使用的名称。每个帖子的内容都在 text 列中,还包括其他细节,比如创建帖子的用户的姓名(userID)、帖子创建时间(Date)以及在主题中的位置(postNum)。对于这个数据集,每个主题都提供了人工生成的摘要,位于 summary 列中。

我们将重用第四章中的正则表达式清理和 spaCy 流水线蓝图,以删除帖子中的特殊格式、URL 和其他标点符号。我们还将生成文本的词形还原表示,用于预测。你可以在本章的附带笔记本中找到函数定义。由于我们正在使用 spaCy 的词形还原功能,执行可能需要几分钟才能完成:

# Applying regex based cleaning function
df['text'] = df['text'].apply(regex_clean)
# Extracting lemmas using spacy pipeline
df['lemmas'] = df['text'].apply(clean)

我们数据集中的每个观测都包含一个帖子,该帖子是主题的一部分。如果我们在这个层面应用训练-测试分割,那么可能会导致两个属于同一主题的帖子分别进入训练集和测试集,这将导致训练不准确。因此,我们使用 GroupShuffleSplit 将所有帖子分组到它们各自的主题中,然后随机选择 80% 的主题来创建训练数据集,其余的主题组成测试数据集。这个函数确保属于同一主题的帖子属于同一数据集。GroupShuffleSplit 函数实际上并不分割数据,而是提供了一组索引,这些索引标识了由 train_splittest_split 确定的数据的分割。我们使用这些索引来创建这两个数据集:

from sklearn.model_selection import GroupShuffleSplit

gss = GroupShuffleSplit(n_splits=1, test_size=0.2)
train_split, test_split = next(gss.split(df, groups=df['ThreadID']))

train_df = df.iloc[train_split]
test_df = df.iloc[test_split]

print ('Number of threads for Training ', train_df['ThreadID'].nunique())
print ('Number of threads for Testing ', test_df['ThreadID'].nunique())

输出:

Number of threads for Training  559
Number of threads for Testing  140

我们的下一步是确定每篇文章的目标标签。目标标签定义了是否应将特定文章包含在摘要中。我们通过将每篇文章与注释员摘要进行比较,并选择最相似的文章来确定这一点。有几种度量标准可用于确定两个句子的相似性,但在我们的用例中,我们处理短文本,因此选择了Jaro-Winkler 距离。我们使用textdistance包,该包还提供其他距离度量的实现。您可以使用命令**pip install textdistance**轻松安装它。您还可以轻松修改蓝图,并根据您的用例选择度量标准。

在接下来的步骤中,我们根据所选择的度量标准确定相似性并对主题中的所有帖子进行排序。然后,我们创建名为summaryPost的目标标签,其中包含一个 True 或 False 值,指示此帖子是否属于摘要。这是基于帖子的排名和压缩因子。我们选择了 30%的压缩因子,这意味着我们选择按相似性排序的所有帖子中的前 30%来包含在摘要中:

import textdistance

compression_factor = 0.3

train_df['similarity'] = train_df.apply(
    lambda x: textdistance.jaro_winkler(x.text, x.summary), axis=1)
train_df["rank"] = train_df.groupby("ThreadID")["similarity"].rank(
    "max", ascending=False)

topN = lambda x: x <= np.ceil(compression_factor * x.max())
train_df['summaryPost'] = train_df.groupby('ThreadID')['rank'].apply(topN)

train_df[['text','summaryPost']][train_df['ThreadID']=='60763_5_3122150'].head(3)

输出:

text summaryPost
170 嗨,我十月份要去纽约!好兴奋!多年来一直想去参观。我们计划做所有传统的事情,所以不会列出所有的事情,但想知道哪些景点应该提前预订,哪些可以直接到场?我打算预订帝国大厦,还有什么?谢谢 x True
171 如果我是你,我不会去帝国大厦,TOPR 要好得多。你还有哪些景点考虑? False
172 自由女神像,如果您计划去雕像本身或埃利斯岛(而不是乘船经过):http://www.statuecruises.com/ 另外,我们更喜欢提前预订演出和戏剧,而不是尝试购买当天票,因为这样可以避免排队浪费时间。如果这听起来对您有吸引力,请看看 http://www.broadwaybox.com/ True

正如您在前面的结果中看到的,对于给定的主题,第一和第三篇文章被标记为summaryPost,但第二篇文章不重要,不会被包含在摘要中。由于我们定义了目标标签的方式,很少情况下可能会将非常短的帖子包含在摘要中。当一个短帖子包含与主题标题相同的词时,可能会发生这种情况。这对摘要没有用,我们通过将所有包含 20 个词或更少的帖子设置为不包含在摘要中来进行修正:

train_df.loc[train_df['text'].str.len() <= 20, 'summaryPost'] = False

步骤 2:添加帮助模型预测的特征

由于我们在这个蓝图中处理的是论坛主题,我们可以生成一些额外的特征来帮助我们的模型进行预测。主题的标题简洁地传达了主题,并且在识别应该在摘要中实际选择的帖子时可能会有所帮助。我们不能直接将标题包含为一个特征,因为对于主题中的每个帖子来说它都是相同的,但是我们可以计算帖子与标题之间的相似度作为其中一个特征:

train_df['titleSimilarity'] = train_df.apply(
    lambda x: textdistance.jaro_winkler(x.text, x.Title), axis=1)

另一个有用的特征可能是帖子的长度。短帖子可能是在询问澄清问题,不会捕捉到主题的最有用的知识。长帖子可能表明正在分享大量有用信息。帖子在主题中的位置也可能是一个有用的指标,用于确定是否应该将其包含在摘要中。这可能会根据论坛主题的组织方式而有所不同。在旅行论坛的情况下,帖子是按时间顺序排序的,帖子的发生是通过列postNum给出的,我们可以直接将其用作一个特征:

# Adding post length as a feature
train_df['textLength'] = train_df['text'].str.len()

作为最后一步,让我们使用TfidfVectorizer创建我们之前提取的词元的向量化表示。然后,我们创建一个新的DataFrametrain_df_tf,其中包含向量化的词元和我们之前创建的附加特征:

feature_cols = ['titleSimilarity','textLength','postNum']

train_df['combined'] = [
    ' '.join(map(str, l)) for l in train_df['lemmas'] if l is not '']
tfidf = TfidfVectorizer(min_df=10, ngram_range=(1, 2), stop_words="english")
tfidf_result = tfidf.fit_transform(train_df['combined']).toarray()

tfidf_df = pd.DataFrame(tfidf_result, columns=tfidf.get_feature_names())
tfidf_df.columns = ["word_" + str(x) for x in tfidf_df.columns]
tfidf_df.index = train_df.index
train_df_tf = pd.concat([train_df[feature_cols], tfidf_df], axis=1)

添加特征的这一步骤可以根据使用情况进行扩展或定制。例如,如果我们想要总结更长的文本,那么一个句子所属的段落将是重要的。通常,每个段落或部分都试图捕捉一个思想,并且在该水平上使用的句子相似性度量将是相关的。如果我们试图生成科学论文的摘要,那么引用次数和用于这些引用的句子已被证明是有用的。我们还必须在测试数据集上重复相同的特征工程步骤,我们在附带的笔记本中展示了这一点,但在这里排除了。

步骤 3:构建机器学习模型

现在我们已经生成了特征,我们将重用第六章中的文本分类蓝图,但是使用RandomForestClassifier模型代替 SVM 模型。在构建用于摘要的机器学习模型时,我们可能有除了向量化的文本表示之外的其他特征。特别是在存在数字和分类特征的组合的情况下,基于树的分类器可能会表现得更好:

from sklearn.ensemble import RandomForestClassifier

model1 = RandomForestClassifier()
model1.fit(train_df_tf, train_df['summaryPost'])

输出:

RandomForestClassifier(bootstrap=True, ccp_alpha=0.0, class_weight=None,
                       criterion='gini', max_depth=None, max_features='auto',
                       max_leaf_nodes=None, max_samples=None,
                       min_impurity_decrease=0.0, min_impurity_split=None,
                       min_samples_leaf=1, min_samples_split=2,
                       min_weight_fraction_leaf=0.0, n_estimators=100,
                       n_jobs=None, oob_score=False, random_state=20, verbose=0,
                       warm_start=False)

让我们在测试主题上应用这个模型,并预测摘要帖子。为了确定准确性,我们连接所有识别的摘要帖子,并通过与注释摘要进行比较生成 ROUGE-1 分数:

# Function to calculate rouge_score for each thread
def calculate_rouge_score(x, column_name):
    # Get the original summary - only first value since they are repeated
    ref_summary = x['summary'].values[0]

    # Join all posts that have been predicted as summary
    predicted_summary = ''.join(x['text'][x[column_name]])

    # Return the rouge score for each ThreadID
    scorer = rouge_scorer.RougeScorer(['rouge1'], use_stemmer=True)
    scores = scorer.score(ref_summary, predicted_summary)
    return scores['rouge1'].fmeasure

test_df['predictedSummaryPost'] = model1.predict(test_df_tf)
print('Mean ROUGE-1 Score for test threads',
      test_df.groupby('ThreadID')[['summary','text','predictedSummaryPost']] \
      .apply(calculate_rouge_score, column_name='predictedSummaryPost').mean())

输出:

Mean ROUGE-1 Score for test threads 0.3439714323225145

我们看到,测试集中所有主题的平均 ROUGE-1 分数为 0.34,与其他公共摘要任务上的抽取式摘要分数相当。您还会注意到排行榜上使用预训练模型如 BERT 改善了分数,我们在第十一章中详细探讨了这一技术。

random.seed(2)
random.sample(test_df['ThreadID'].unique().tolist(), 1)

Out:

['60974_588_2180141']

让我们也来看看由这个模型生成的一个摘要结果,以了解它可能有多有用:

example_df = test_df[test_df['ThreadID'] == '60974_588_2180141']
print('Total number of posts', example_df['postNum'].max())
print('Number of summary posts',
      example_df[example_df['predictedSummaryPost']].count().values[0])
print('Title: ', example_df['Title'].values[0])
example_df[['postNum', 'text']][example_df['predictedSummaryPost']]

Out:

Total number of posts 9
Number of summary posts 2
Title:  What's fun for kids?

postNum text
551 4 看来你真的很幸运,因为有很多事情可以做,包括艾尔姆伍德艺术节(http://www.elmwoodartfest.org),为年轻人准备的特别活动,表演(包括我最喜欢的本地歌手之一尼基·希克斯的表演),以及各种美食。艾尔姆伍德大道是该地区最丰富多彩且充满活力的社区之一,非常适合步行。布法罗爱尔兰文化节也将在汉堡的周末举行,正好在展览会场地:www.buf...
552 5 根据您的时间安排,快速到尼亚加拉大瀑布旅行是一个很好的选择。从汉堡开车 45 分钟,非常值得投资时间。否则,您可以去安哥拉的一些海滩享受时光。如果女孩们喜欢购物,您可以去加勒利亚购物中心,这是一个非常大的商场。如果您喜欢一个更有特色的下午,可以在艾尔布赖特诺艺术画廊午餐,漫步艾尔姆伍德大道,然后逛逛一些时尚店铺,这将是一个很酷的下午。达里恩湖主题公园距离...

在前面的例子中,原始主题包括九个帖子,其中两个被选出来总结主题,如前所示。阅读总结帖子显示,主题是关于年轻人的活动,已经有一些具体建议,比如艾尔姆伍德大道,达里恩湖主题公园等。想象一下,在浏览论坛搜索结果时,鼠标悬停时提供这些信息。这为用户提供了足够准确的摘要,以决定是否有趣并单击获取更多详细信息或继续查看其他搜索结果。您还可以轻松地将此蓝图与其他数据集重新使用,如开头所述,并自定义距离函数,引入附加功能,然后训练模型。

结尾语

在本章中,我们介绍了文本摘要的概念,并提供了可用于为不同用例生成摘要的蓝图。如果您希望从诸如网页、博客和新闻文章等短文本生成摘要,则基于 LSA 摘要器的主题表示的第一个蓝图将是一个不错的选择。如果您处理的文本更大,例如演讲稿、书籍章节或科学文章,则使用 TextRank 的蓝图将是一个更好的选择。这些蓝图作为您迈向自动文本摘要的第一步非常棒,因为它们简单又快速。然而,使用机器学习的第三个蓝图为您的特定用例提供了更定制的解决方案。只要您拥有必要的标注数据,就可以通过添加特征和优化机器学习模型来定制此方法以提高性能。例如,您的公司或产品可能有多个管理用户数据、条款和条件以及其他流程的政策文件,您希望为新用户或员工总结这些文件的重要内容。您可以从第三个蓝图开始,并通过添加特征(例如从句数量、使用块字母、是否存在粗体或下划线文本等)来定制第二步,以帮助模型总结政策文件中的重要要点。

进一步阅读

^(1) 您可以在GitHub上找到有关该软件包的更多信息,包括我们在设计此蓝图时使用的使用指南。

^(2) Sansiri Tarnpradab 等人提出了一种通过层次注意力网络实现在线论坛讨论摘要的方法。https://arxiv.org/abs/1805.10390。也可以查看数据集(.zip

第十章:利用词嵌入探索语义关系

相似性的概念对所有机器学习任务都是基础性的。在第五章中,我们解释了如何基于词袋模型计算文本相似性。给定两个文档的 TF-IDF 向量,它们的余弦相似度可以轻松计算,我们可以使用这些信息来搜索、聚类或分类相似的文档。

然而,在词袋模型中,相似性的概念完全基于两个文档中共同单词的数量。如果文档没有共享任何标记,文档向量的点积以及因此的余弦相似度将为零。考虑以下关于一部新电影的两条社交平台评论:

“多么美妙的电影。”

“这部电影很棒。”

显然,尽管使用完全不同的词语,这些评论具有类似的含义。在本章中,我们将介绍词嵌入作为捕捉单词语义并用于探索语义相似性的一种手段。

您将学到什么以及我们将构建什么

对于我们的用例,我们假设我们是市场研究人员,希望使用关于汽车的文本来更好地理解汽车市场中的一些关系。具体而言,我们想探索汽车品牌和型号之间的相似性。例如,品牌 A 的哪些型号与品牌 B 的特定型号最相似?

我们的语料库包括 Reddit 自我帖子数据集汽车类别中的 20 个子社区,这在第四章中已经使用过。每个子社区都包含关于 Mercedes、Toyota、Ford 和 Harley-Davidson 等品牌的汽车和摩托车的 1,000 条帖子。由于这些帖子是用户编写的问题、答案和评论,我们实际上可以了解到这些用户隐含地认为什么是相似的。

我们将再次使用Gensim 库,这在第八章中已经介绍过。它提供了一个良好的 API 来训练不同类型的嵌入,并使用这些模型进行语义推理。

学习本章后,您将能够使用词嵌入进行语义分析。您将知道如何使用预训练的嵌入,如何训练自己的嵌入,如何比较不同的模型以及如何可视化它们。您可以在我们的GitHub 代码库中找到本章的源代码以及部分图像。

语义嵌入的理由

在前几章中,我们使用 TF-IDF 向量化我们的模型。这种方法易于计算,但也有一些严重的缺点:

  • 文档向量具有由词汇量大小定义的非常高的维度。因此,向量非常稀疏;即大多数条目为零。

  • 它在短文本(如 Twitter 消息、服务评论和类似内容)中表现不佳,因为短文本中共同词的概率较低。

  • 高级应用例如情感分析、问答或机器翻译需要准确捕捉单词的实际含义以正确工作。

尽管词袋模型在分类或主题建模等任务中表现出色,但仅当文本足够长且有足够的训练数据时。请记住,词袋模型中的相似性仅基于显著共同单词的存在。

嵌入则是一个密集的数值向量表示对象,捕捉某种语义相似性。当我们在文本分析的背景下讨论嵌入时,我们必须区分单词嵌入和文档嵌入。单词嵌入是单个单词的向量表示,而文档嵌入是代表文档的向量。在这一章节中,我们将重点关注单词的密集向量表示。

单词嵌入

嵌入算法的目标可以定义如下:给定一个维度d,找到单词的向量表示,使得具有相似含义的单词具有相似的向量。维度d是任何单词嵌入算法的超参数。通常设置在 50 到 300 之间。

维度本身没有预定义或人类可理解的含义。相反,模型从文本中学习单词之间的潜在关系。图 10-1(左)展示了这一概念。我们对每个单词有五维向量。这些维度中的每一个代表单词之间某种关系,使得在这一维度上相似的单词具有类似的值。所示的维度名称是对这些值的可能解释。

图 10-1. 密集向量表示语义相似性的标注(左)可用于回答类比问题(右)。我们对向量维度命名为“Royalty”等来展示可能的解释。^(1)

训练的基本思想是在相似上下文中出现的单词具有相似的含义。这被称为分布假设。例如,以下描述tesgüino的句子:^(2)

  • 桌子上有一瓶 ___。

  • 每个人都喜欢 ___。

  • 开车前不要 ___。

  • 我们用玉米制造 ___。

即使不了解tesgüino这个词,通过分析典型语境,你也能对其含义有相当好的理解。你还可以识别语义上相似的单词,因为你知道它是一种酒精饮料。

使用单词嵌入进行类比推理

真正令人惊讶的是,用这种方法构建的词向量使我们能够通过向量代数检测类似于“queen is to king like woman is to man”的类比(见图 10-1 右侧)。设 v ( w ) 为单词 w 的词嵌入。那么这个类比可以用数学方式表达如下:

v ( q u e e n ) - v ( k i n g ) v ( w o m a n ) - v ( m a n )

如果这个近似等式成立,我们可以将这个类比重述为一个问题:像“king”对应于“man”,“woman”对应于什么?或者数学上表示为:^(3)

v ( w o m a n ) + v ( k i n g ) - v ( m a n ) ?

这种方式允许一种模糊推理来回答类似于这样的类比问题:“巴黎是法国的首都,那么德国的首都是什么?”或者在市场研究场景中,正如我们将要探索的那样:“考虑到 F-150 是福特的皮卡,那么丰田的类似车型是什么?”

嵌入类型

已经开发了几种算法来训练词嵌入。Gensim 允许您训练 Word2Vec 和 FastText 词嵌入。GloVe 词嵌入可以用于相似性查询,但不能与 Gensim 一起训练。我们介绍了这些算法的基本思想,并简要解释了更先进但也更复杂的上下文嵌入方法。您将在本章末找到原始论文的参考文献和进一步的解释。

Word2Vec

尽管之前已经有过词嵌入的方法,但谷歌的 Tomáš Mikolov(Mikolov 等人,2013 年)的工作标志着一个里程碑,因为它在类比任务上显著优于以前的方法,特别是刚刚解释的那些任务。Word2Vec 有两个变体,即连续词袋模型(CBOW)和跳字模型(见图 10-2)。

图 10-2. 连续词袋模型(左)与跳字模型(右)。

这两种算法都在文本上使用一个滑动窗口,由目标词 w t 和上下文窗口大小 c 定义。在这个例子中,c = 2 ,即训练样本由五个词组成 w t-2 , , w t+2 。其中一种训练样本以粗体显示:... is trying things to see ...。在 CBOW 架构(左侧),模型被训练来预测从上下文词到目标词。这里,一个训练样本由上下文词的独热编码向量的总和或平均值以及目标词作为标签。相比之下,skip-gram 模型(右侧)被训练来预测给定目标词的上下文词。在这种情况下,每个目标词为每个上下文词生成一个单独的训练样本;没有向量平均。因此,skip-gram 训练速度较慢(对于大窗口大小来说要慢得多!),但通常能够更好地处理不常见的词语。

这两种嵌入算法都使用了一个简单的单层神经网络和一些技巧来进行快速和可扩展的训练。学习到的嵌入实际上是由隐藏层的权重矩阵定义的。因此,如果你想学习 100 维的向量表示,隐藏层必须由 100 个神经元组成。输入和输出的词语都由独热向量表示。嵌入的维度和上下文窗口的大小 c 都是所有这里介绍的嵌入方法中的超参数。我们将在本章后面探讨它们对嵌入的影响。

GloVe

全局向量(GloVe)方法,由斯坦福自然语言处理组在 2014 年开发,使用全局共现矩阵来计算词向量,而不是一个预测任务(Pennington 等,2014 年)。一个大小为 V 的词汇的共现矩阵具有维度 V × V 。矩阵中的每个单元 ( i , j ) 包含基于固定上下文窗口大小的词 w iw j 的共现次数。这些嵌入是通过类似于主题建模或降维技术中使用的矩阵分解技术来推导的。

这个模型被称为全局,因为共现矩阵捕获全局语料库统计,与只使用局部上下文窗口进行预测任务的 Word2Vec 形成对比。 GloVe 通常不比 Word2Vec 表现更好,但根据训练数据和任务的不同,它产生类似的好结果(参见 Levy 等人,2014 年,进行讨论)。

FastText

我们介绍的第三种模型再次由一支由Tomáš Mikolov领导的团队在 Facebook 开发(Joulin 等人,2017 年)。 主要动机是处理词汇外的词汇。 无论是 Word2Vec 还是 GloVe,都仅为训练语料库中包含的词汇生成词嵌入。 相比之下,FastText利用字符 n-gram 的子词信息来推导向量表示。 例如,fasttext的字符三元组是fasaststtttetexext。 使用的 n-gram 长度(最小和最大)是模型的超参数。

任何单词向量都是从其字符 n-grams 的嵌入构建的。 并且即使是模型以前未见过的单词,大多数字符 n-gram 也有嵌入。 例如,fasttext的向量将类似于fasttext,因为它们有共同的 n-grams。 因此,FastText 非常擅长为通常是词汇外的拼写错误的单词找到嵌入。

深度上下文化的嵌入

单词的语义含义往往取决于其上下文。 想想“我是对的”和“请右转”中right一词的不同含义^(4)。 所有这三种模型(Word2Vec,GloVe 和 FastText)每个单词仅有一个向量表示;它们无法区分依赖上下文的语义。

类似来自语言模型的嵌入(ELMo)的上下文化嵌入考虑上下文,即前后的单词(Peters 等人,2018 年)。 没有为每个单词存储一个可以简单查找的单词向量。 相反,ELMo 通过多层双向长短期记忆神经网络(LSTM)传递整个句子,并从内部层的权重组合每个单词的向量。 最近的模型如 BERT 及其后继模型通过使用注意力变换器而不是双向 LSTM 改进了这种方法。 所有这些模型的主要优点是迁移学习:能够使用预训练的语言模型并针对特定的下游任务(如分类或问题回答)进行微调。 我们将在第十一章中更详细地介绍这个概念。

蓝图:在预训练模型上使用相似性查询

所有这些理论之后,让我们开始一些实践。在我们的第一个例子中,我们使用预训练的嵌入。这些具有优势,即其他人已经在大型语料库(如维基百科或新闻文章)上花费了训练工作。在我们的蓝图中,我们将检查可用的模型,加载其中一个,并对单词向量进行推理。

加载预训练模型

几个模型可以公开下载。^(5) 我们稍后会描述如何加载自定义模型,但在这里,我们将使用 Gensim 的方便下载 API。

根据默认设置,Gensim 将模型存储在 ~/gensim-data 下。如果您想将其更改为自定义路径,可以在导入下载器 API 之前设置环境变量 GENSIM_DATA_DIR。我们将所有模型存储在本地目录 models 中:

import os
os.environ['GENSIM_DATA_DIR'] = './models'

现在让我们看看可用的模型。以下行将由 api.info()['models'] 返回的字典转换为 DataFrame,以获得格式良好的列表,并显示总共 13 个条目中的前五个:

import gensim.downloader as api

info_df = pd.DataFrame.from_dict(api.info()['models'], orient='index')
info_df[['file_size', 'base_dataset', 'parameters']].head(5)

file_size base_dataset parameters
fasttext-wiki-news-subwords-300 1005007116 Wikipedia 2017, UMBC webbase corpus and statmt.org news dataset (16B tokens) {'dimension’: 300}
conceptnet-numberbatch-17-06-300 1225497562 ConceptNet, word2vec, GloVe, and OpenSubtitles 2016 {'dimension’: 300}
word2vec-ruscorpora-300 208427381 Russian National Corpus (about 250M words) {'dimension’: 300, ‘window_size’: 10}
word2vec-google-news-300 1743563840 Google News (about 100 billion words) {'dimension’: 300}
glove-wiki-gigaword-50 69182535 Wikipedia 2014 + Gigaword 5(6B tokens, uncased) {'dimension’: 50}

我们将使用 glove-wiki-gigaword-50 模型。这个具有 50 维单词向量的模型体积较小,但对我们的目的来说完全足够。它在大约 60 亿个小写标记上进行了训练。api.load 如果需要会下载模型,然后将其加载到内存中:

model = api.load("glove-wiki-gigaword-50")

我们下载的文件实际上并不包含完整的 GloVe 模型,而只包含纯粹的词向量。由于未包含模型的内部状态,这种简化模型无法进一步训练。

相似性查询

给定一个模型,可以通过属性 model.wv['king'] 或甚至更简单地通过快捷方式 model['king'] 访问单词 king 的向量。让我们看看 kingqueen 的 50 维向量的前 10 个分量。

v_king = model['king']
v_queen = model['queen']

print("Vector size:", model.vector_size)
print("v_king  =", v_king[:10])
print("v_queen =", v_queen[:10])
print("similarity:", model.similarity('king', 'queen'))

输出:

Vector size: 50
v_king  = [ 0.5   0.69 -0.6  -0.02  0.6  -0.13 -0.09  0.47 -0.62 -0.31]
v_queen = [ 0.38  1.82 -1.26 -0.1   0.36  0.6  -0.18  0.84 -0.06 -0.76]
similarity: 0.7839043

顾名思义,在许多维度上的值是相似的,导致高达 0.78 的高相似性分数。因此,queenking 相当相似,但它是最相似的词吗?好的,让我们通过调用相应的函数来检查与 king 最相似的三个词:

model.most_similar('king', topn=3)

输出:

[('prince', 0.824), ('queen', 0.784), ('ii', 0.775)]

实际上,男性的princequeen更相似,但queen在列表中排名第二,其后是罗马数字 II,因为许多国王被称为“第二”。

单词向量的相似性分数通常通过余弦相似度计算,这在第五章中介绍过。Gensim 提供了几种变体的相似性函数。例如,cosine_similarities方法计算单词向量与其他单词向量数组之间的相似度。让我们比较king与更多单词:

v_lion = model['lion']
v_nano = model['nanotechnology']

model.cosine_similarities(v_king, [v_queen, v_lion, v_nano])

Out:

array([ 0.784,  0.478, -0.255], dtype=float32)

基于模型的训练数据(维基百科和 Gigaword),模型假设单词kingqueen相似,与lion略有相似,但与nanotechnology完全不相似。需要注意的是,与非负 TF-IDF 向量不同,单词嵌入在某些维度上也可能是负的。因此,相似度值范围从+ 1- 1不等。

先前使用的most_similar()函数还允许两个参数,positivenegative,每个参数都是向量列表。如果p o s i t i v e = [ p o s 1 , , p o s n ]n e g a t i v e = [ n e g 1 , , n e g m ],那么此函数将找到与 i=1 n p o s i - j=1 m n e g j最相似的单词向量。

因此,我们可以用 Gensim 来制定关于皇室的类比查询:

model.most_similar(positive=['woman', 'king'], negative=['man'], topn=3)

Out:

[('queen', 0.852), ('throne', 0.766), ('prince', 0.759)]

以及关于德国首都的问题:

model.most_similar(positive=['paris', 'germany'], negative=['france'], topn=3)

Out:

[('berlin', 0.920), ('frankfurt', 0.820), ('vienna', 0.818)]

我们也可以省略负面列表,以找到与francecapital之和最接近的单词:

model.most_similar(positive=['france', 'capital'], topn=1)

Out:

[('paris', 0.784)]

实际上,它就是paris!这真是令人惊叹,显示了词向量的巨大威力。然而,正如在机器学习中一样,模型并不完美。它们只能学习到数据中存在的内容。因此,并非所有相似性查询都会产生如此惊人的结果,下面的例子就说明了这一点:

model.most_similar(positive=['greece', 'capital'], topn=3)

Out:

[('central', 0.797), ('western', 0.757), ('region', 0.750)]

显然,模型没有足够的训练数据来推导雅典和希腊之间的关系。

注意

Gensim 还提供了余弦相似度的一种变体,most_similar_cosmul。这对于类比查询比前面显示的方法更有效,因为它平滑了一个大相似性项主导方程的效果(Levy 等,2015)。然而,对于前面的例子,返回的单词将是相同的,但相似性分数将更高。

如果您使用来自维基百科和新闻文章的编辑文本来训练嵌入,您的模型将能够很好地捕捉到类似首都-国家的事实关系。但是,对于市场研究问题,比较不同品牌产品的情况呢?通常这些信息在维基百科上找不到,而是在最新的社交平台上,人们在讨论产品。如果您在社交平台上使用用户评论来训练嵌入,您的模型将学习到来自用户讨论的词语关联。这样,它就成为了人们对关系的认知表示,独立于其是否客观真实。这是一个有趣的副作用,您应该意识到。通常,您希望捕捉到这种特定应用的偏见,这也是我们接下来要做的事情。但是请注意,每个训练语料库都包含一定的偏见,这可能还会导致一些不希望的副作用(参见“男人对计算机程序员如同女人对家庭主妇”)。

训练和评估自己嵌入的蓝图

在本节中,我们将在 Reddit Selfposts 数据集中的 2 万个关于汽车的用户帖子上训练和评估特定领域的嵌入。在开始训练之前,我们必须考虑数据准备的选项,因为这总是对模型在特定任务中的实用性产生重要影响的因素。

数据准备

Gensim 要求输入训练的令牌序列。除了分词之外,还有一些其他方面需要考虑数据准备。根据分布假设,经常一起出现或在相似上下文中的单词将获得相似的向量。因此,我们应确保确实识别了这些共现关系。如果像我们这里的示例一样训练句子不多,您应在预处理中包括这些步骤:

  1. 清理文本,去除不需要的标记(符号、标签等)。

  2. 将所有单词转换为小写。

  3. 使用引理。

所有这些都使得词汇量保持较小,训练时间较短。当然,如果根据这些规则修剪我们的训练数据,屈折形式和大写字词将会是词汇外的情况。对于我们想要进行的名词语义推理来说,这不是问题,但如果我们想要分析例如情感,这可能会成为问题。此外,您应考虑以下标记类别:

停用词

停用词可以提供有关非停用词语境的宝贵信息。因此,我们更倾向于保留停用词。

数字

根据应用程序的不同,数字可能是有价值的,也可能只是噪音。在我们的例子中,我们正在查看汽车数据,并且肯定希望保留像328这样的标记,因为它是宝马车型的名称。如果数字携带相关信息,则应保留这些数字。

另一个问题是我们是否应该按句子拆分,还是仅保留帖子的原样。考虑虚构帖子“I like the BMW 328. But the Mercedes C300 is also great.”这两个句子在我们的相似性任务中应该被视为两个不同的帖子吗?可能不应该。因此,我们将所有用户帖子中的所有词形的列表视为一个单独的“句子”用于训练。

我们已经为第四章中的 2 万条 Reddit 汽车帖子准备了词形。因此,在这里我们可以跳过数据准备的这一部分,直接将词形加载到 Pandas 的DataFrame中:

db_name = "reddit-selfposts.db"
con = sqlite3.connect(db_name)
df = pd.read_sql("select subreddit, lemmas, text from posts_nlp", con)
con.close()

df['lemmas'] = df['lemmas'].str.lower().str.split() # lower case tokens
sents = df['lemmas'] # our training "sentences"

短语

特别是在英语中,如果一个词是复合短语的一部分,那么该词的含义可能会发生变化。例如,timing beltseat beltrust belt。所有这些复合词虽然都可以在我们的语料库中找到,但它们的含义各不相同。因此,将这些复合词视为单个标记可能更为合适。

我们可以使用任何算法来检测这些短语,例如 spaCy 检测名词块(见“使用 spaCy 进行语言处理”)。还有许多统计算法可用于识别这样的搭配,如异常频繁的 n-gram。原始的 Word2Vec 论文(Mikolov 等人,2013)使用了一种简单但有效的基于点间互信息(PMI)的算法,基本上衡量了两个词出现之间的统计依赖性。

对于我们现在正在训练的模型,我们使用了一个高级版本,称为归一化点间互信息(NPMI),因为它能提供更稳健的结果。鉴于其值范围有限,从- 1+ 1,它也更容易调整。我们在初始运行中将 NPMI 阈值设定为一个相当低的值,即 0.3. 我们选择使用连字符作为短语中单词的分隔符。这将生成类似harley-davidson的复合标记,无论如何这些标记都会在文本中找到。如果使用默认的下划线分隔符,则会产生不同的标记:

from gensim.models.phrases import Phrases, npmi_scorer

phrases = Phrases(sents, min_count=10, threshold=0.3,
                  delimiter=b'-', scoring=npmi_scorer)

通过这种短语模型,我们可以识别一些有趣的复合词:

sent = "I had to replace the timing belt in my mercedes c300".split()
phrased = phrases[sent]
print('|'.join(phrased))

Out:

I|had|to|replace|the|timing-belt|in|my|mercedes-c300

timing-belt很好,但我们不希望为品牌和型号名称的组合构建复合词,比如奔驰 c300。因此,我们将分析短语模型,找到一个合适的阈值。显然,选择的值太低了。以下代码导出我们语料库中找到的所有短语及其分数,并将结果转换为DataFrame以便轻松检查:

phrase_df = pd.DataFrame(phrases.export_phrases(sents),
                         columns =['phrase', 'score'])
phrase_df = phrase_df[['phrase', 'score']].drop_duplicates() \
            .sort_values(by='score', ascending=False).reset_index(drop=True)
phrase_df['phrase'] = phrase_df['phrase'].map(lambda p: p.decode('utf-8'))

现在我们可以检查哪个阈值适合奔驰

phrase_df[phrase_df['phrase'].str.contains('mercedes')]

短语 分数
83 奔驰 0.80
1417 奔驰 c300 0.47

如我们所见,阈值应该大于 0.5 且小于 0.8。通过检查宝马福特哈雷戴维森等几个其他品牌,我们确定 0.7 是一个很好的阈值,可以识别复合供应商名称,但保持品牌和型号分开。实际上,即使是 0.7 这样严格的阈值,短语模型仍然保留了许多相关的词组,例如street glide(哈雷戴维森)、land cruiser(丰田)、forester xt(斯巴鲁)、water pumpspark plugtiming belt

我们重建了我们的短语分析器,并在我们的DataFrame中为复合词创建了一个新列,该列包含单词标记:

phrases = Phrases(sents, min_count=10, threshold=0.7,
                  delimiter=b'-', scoring=npmi_scorer)

df['phrased_lemmas'] = df['lemmas'].map(lambda s: phrases[s])
sents = df['phrased_lemmas']

我们数据准备步骤的结果是由词形和短语组成的句子。现在,我们将训练不同的嵌入模型,并检查我们能从中获得哪些见解。

蓝图:使用 Gensim 训练模型

使用 Gensim 可以方便地训练 Word2Vec 和 FastText 嵌入。以下调用Word2Vec在语料库上训练了 100 维的 Word2Vec 嵌入,窗口大小为 2,即目标词的±2 个上下文词。为了说明,还传递了一些其他相关超参数。我们使用 skip-gram 算法,并在四个线程中训练网络五次迭代:

from gensim.models import Word2Vec

model = Word2Vec(sents,       # tokenized input sentences
                 size=100,    # size of word vectors (default 100)
                 window=2,    # context window size (default 5)
                 sg=1,        # use skip-gram (default 0 = CBOW)
                 negative=5,  # number of negative samples (default 5)
                 min_count=5, # ignore infrequent words (default 5)
                 workers=4,   # number of threads (default 3)
                 iter=5)      # number of epochs (default 5)

在 i7 笔记本电脑上,处理 2 万个句子大约需要 30 秒,速度相当快。增加样本数和迭代次数,以及更长的向量和更大的上下文窗口,会增加训练时间。例如,在这种设置下训练 30 大小的 100 维向量,跳跃图算法大约需要 5 分钟。相比之下,CBOW 的训练时间与上下文窗口的大小无关。

以下调用将完整模型保存到磁盘。完整模型意味着包括所有内部状态的完整神经网络。这样,模型可以再次加载并进一步训练:

model.save('./models/autos_w2v_100_2_full.bin')

算法的选择以及这些超参数对生成的模型影响很大。因此,我们提供了一个训练和检查不同模型的蓝图。参数网格定义了将为 Word2Vec 或 FastText 训练哪些算法变体(CBOW 或 skip-gram)和窗口大小。我们也可以在这里变化向量大小,但这个参数的影响不是很大。根据我们的经验,在较小的语料库中,50 或 100 维的向量效果很好。因此,我们在实验中将向量大小固定为 100:

from gensim.models import Word2Vec, FastText

model_path = './models'
model_prefix = 'autos'

param_grid = {'w2v': {'variant': ['cbow', 'sg'], 'window': [2, 5, 30]},
              'ft': {'variant': ['sg'], 'window': [5]}}
size = 100

for algo, params in param_grid.items():
    for variant in params['variant']:
        sg = 1 if variant == 'sg' else 0
        for window in params['window']:
            if algo == 'w2v':
                model = Word2Vec(sents, size=size, window=window, sg=sg)
            else:
                model = FastText(sents, size=size, window=window, sg=sg)

            file_name = f"{model_path}/{model_prefix}_{algo}_{variant}_{window}"
            model.wv.save_word2vec_format(file_name + '.bin', binary=True)

由于我们只想分析语料库内的相似性,我们不保存完整的模型,而是仅保存纯单词向量。这些由KeyedVectors类表示,并且可以通过模型属性model.wv访问。这样生成的文件更小,并且完全足够我们的目的。

警告

要注意信息丢失!当您重新加载仅由单词向量组成的模型时,它们无法进一步训练。此外,FastText 模型失去了为超出词汇表单词推导嵌入的能力。

蓝图:评估不同的模型

实际上,对于特定领域任务和语料库,算法化地确定最佳超参数是相当困难的。因此,检查模型的表现并手动验证它们如何执行以识别一些已知的关系并非坏主意。

仅包含单词向量的保存文件很小(每个约 5 MB),因此我们可以将许多文件加载到内存中并运行一些比较。我们使用五个模型的子集来说明我们的发现。这些模型存储在一个由模型名称索引的字典中。您可以添加任何您想比较的模型,甚至是早期预训练的 GloVe 模型:

from gensim.models import KeyedVectors

names = ['autos_w2v_cbow_2', 'autos_w2v_sg_2',
         'autos_w2v_sg_5', 'autos_w2v_sg_30', 'autos_ft_sg_5']
models = {}

for name in names:
    file_name = f"{model_path}/{name}.bin"
    models[name] = KeyedVectors.load_word2vec_format(file_name, binary=True)

我们提供了一个小的蓝图函数用于比较。它接受一个模型列表和一个单词,并生成一个DataFrame,其中包含根据每个模型最相似的单词:

def compare_models(models, **kwargs):

    df = pd.DataFrame()
    for name, model in models:
        df[name] = [f"{word} {score:.3f}"
                    for word, score in model.most_similar(**kwargs)]
    df.index = df.index + 1 # let row index start at 1
    return df

现在让我们看看参数对我们计算的模型有什么影响。因为我们要分析汽车市场,我们查看与宝马最相似的单词:

compare_models([(n, models[n]) for n in names], positive='bmw', topn=10)

autos_w2v_cbow_2 autos_w2v_sg_2 autos_w2v_sg_5 autos_w2v_sg_30 autos_ft_sg_5
1 梅赛德斯 0.873 奔驰 0.772 奔驰 0.808 xdrive 0.803 宝马 0.819
2 莱克萨斯 0.851 奔驰 0.710 335i 0.740 328i 0.797 bmwfs 0.789
3 大众 0.807 保时捷 0.705 328i 0.736 f10 0.762 m135i 0.774
4 奔驰 0.806 莱克萨斯 0.704 奔驰 0.723 335i 0.760 335i 0.773
5 沃尔沃 0.792 奔驰 0.695 x-drive 0.708 535i 0.755 梅赛德斯-奔驰 0.765
6 哈雷 0.783 梅赛德斯 0.693 135i 0.703 宝马 0.745 奔驰 0.760
7 保时捷 0.781 奔驰-奔驰 0.680 梅赛德斯 0.690 x-drive 0.740 35i 0.747
8 斯巴鲁 0.777 奥迪 0.675 e92 0.685 5 系列 0.736 奔驰 0.747
9 MB 0.769 335i 0.670 奔驰-奔驰 0.680 550i 0.728 135i 0.746
10 大众 0.768 135i 0.662 奔驰 0.679 435i 0.726 435i 0.744

有趣的是,窗口大小为 2 的第一批模型主要生成其他汽车品牌,而窗口大小为 30 的模型基本上生成了不同 BMW 型号的列表。事实上,较短的窗口强调范式关系,即可以在句子中互换的词语。在我们的案例中,这将是品牌,因为我们正在寻找类似BMW的词语。较大的窗口捕获更多的语法关系,其中词语之间的相似性在于它们经常在相同的上下文中出现。窗口大小为 5,即默认值,产生了两者的混合。对于我们的数据,CBOW 模型最好地表示了范式关系,而语法关系则需要较大的窗口大小,因此更适合由 skip-gram 模型捕获。FastText 模型的输出显示了其性质,即拼写相似的词语得到相似的分数。

寻找相似概念

窗口大小为 2 的 CBOW 向量在范式关系上非常精确。从一些已知术语开始,我们可以使用这样的模型来识别领域的核心术语和概念。表 10-1 展示了在模型autos_w2v_cbow_2上进行一些相似性查询的输出。列concept是我们添加的,以突出我们预期的输出词语类型。

表 10-1. 使用 CBOW 模型和窗口大小为 2 查找选定词语的最相似邻居

Word Concept Most Similar
toyota 汽车品牌 ford mercedes nissan certify dodge mb bmw lexus chevy honda
camry 汽车型号 corolla f150 f-150 c63 is300 ranger 335i 535i 328i rx
spark-plug 汽车部件 water-pump gasket thermostat timing-belt tensioner throttle-body serpentine-belt radiator intake-manifold fluid
washington 地点 oregon southwest ga ottawa san_diego valley portland mall chamber county

当然,答案并不总是符合我们的期望;它们只是类似的词语。例如,Toyota 的列表中不仅包含汽车品牌,还包括多种型号。然而,在实际项目中,业务部门的领域专家可以轻松识别错误的术语,仍然找到有趣的新联想。但是,在以这种方式处理词嵌入时,手动筛选绝对是必要的。

我们自己模型上的类比推理

现在让我们看看我们的不同模型如何能够检测类似的概念。我们想要知道 Toyota 是否有一款与 Ford 的 F-150 皮卡相媲美的产品。因此,我们的问题是:“Toyota”对应于“Ford”的“F-150”的什么?我们使用之前的函数compare_models并对结果进行转置,以比较不同模型的wv.most_similar()结果:

compare_models([(n, models[n]) for n in names],
               positive=['f150', 'toyota'], negative=['ford'], topn=5).T

Out:

1 2 3 4 5
autos_w2v_cbow_2 f-150 0.850 328i 0.824 s80 0.820 93 0.819 4matic 0.817
autos_w2v_sg_2 f-150 0.744 f-250 0.727 dodge-ram 0.716 tacoma 0.713 ranger 0.708
autos_w2v_sg_5 tacoma 0.724 tundra 0.707 f-150 0.664 highlander 0.644 4wd 0.631
autos_w2v_sg_30 4runner 0.742 tacoma 0.739 4runners 0.707 4wd 0.678 tacomas 0.658
autos_ft_sg_5 toyotas 0.777 toyo 0.762 tacoma 0.748 tacomas 0.745 f150s 0.744

实际上,Toyota Tacoma 直接与 F-150 以及 Toyota Tundra 竞争。考虑到这一点,窗口大小为 5 的跳字模型给出了最佳结果。[⁶]实际上,如果你用gmc替换toyota,你会得到sierra,如果你要chevy,你会得到silverado作为这个模型最相似的车型。所有这些都是竞争激烈的全尺寸皮卡。对于其他品牌和车型,这也效果很好,但当然最适合那些在 Reddit 论坛中广泛讨论的模型。

可视化嵌入的蓝图

如果我们像本章一样基于词嵌入探索我们的语料库,我们对实际相似度分数不感兴趣,因为整个概念本质上是模糊的。我们想要理解的是基于接近性和相似性概念的语义关系。因此,视觉表现对于探索词嵌入及其关系非常有帮助。在本节中,我们将首先使用不同的降维技术来可视化嵌入。之后,我们将展示如何通过视觉探索给定关键词的语义邻域。正如我们将看到的那样,这种数据探索可以揭示领域特定术语之间非常有趣的关系。

应用降维蓝图

高维向量可以通过将数据投影到二维或三维来进行可视化。如果投影效果良好,可以直观地检测到相关术语的聚类,并更深入地理解语料库中的语义概念。我们将寻找相关词汇的聚类,并使用窗口大小为 30 的模型探索某些关键词的语义邻域,这有利于同位语关系。因此,我们期望看到一个“BMW”词汇组,包含 BMW 相关术语,一个“Toyota”词汇组,包含 Toyota 相关术语,等等。

在机器学习领域,降维也有许多用例。一些学习算法对高维且常稀疏的数据存在问题。诸如 PCA、t-SNE 或 UMAP(见“降维技术”)之类的降维技术试图通过投影来保留或甚至突出数据分布的重要方面。其一般思想是以一种方式投影数据,使得在高维空间中彼此接近的对象在投影中也接近,而远离的对象仍然保持距离。在我们的示例中,我们将使用 UMAP 算法,因为它为可视化提供了最佳结果。但是由于 umap 库实现了 scikit-learn 的估算器接口,你可以轻松地用 scikit-learn 的 PCATSNE 类替换 UMAP 缩减器。

下面的代码块包含了使用 UMAP 将嵌入投影到二维空间的基本操作,如 图 10-3 所示。在选择嵌入模型和要绘制的词(在本例中我们采用整个词汇表)之后,我们使用目标维数 n_components=2 实例化 UMAP 降维器。我们像往常一样使用余弦而不是标准的欧氏距离度量。然后通过调用 reducer.fit_transform(wv) 将嵌入投影到 2D。

from umap import UMAP

model = models['autos_w2v_sg_30']
words = model.vocab
wv = [model[word] for word in words]

reducer = UMAP(n_components=2, metric='cosine', n_neighbors = 15, min_dist=0.1)
reduced_wv = reducer.fit_transform(wv)

图 10-3. 我们模型的所有词嵌入的二维 UMAP 投影。突出显示了一些词及其最相似的邻居,以解释此散点图中的一些聚类。

我们在这里使用 Plotly Express 进行可视化,而不是 Matplotlib,因为它有两个很好的特性。首先,它生成交互式图。当你用鼠标悬停在一个点上时,相应的词将被显示出来。此外,你可以放大和缩小并选择区域。Plotly Express 的第二个很好的特性是它的简单性。你只需要准备一个带有坐标和要显示的元数据的 DataFrame。然后你只需实例化图表,本例中为散点图 (px.scatter):

import plotly.express as px

plot_df = pd.DataFrame.from_records(reduced_wv, columns=['x', 'y'])
plot_df['word'] = words
params = {'hover_data': {c: False for c in plot_df.columns},
          'hover_name': 'word'}

fig = px.scatter(plot_df, x="x", y="y", opacity=0.3, size_max=3, **params)
fig.show()

你可以在我们的 GitHub 仓库 中的 embeddings 包中找到一个更通用的蓝图函数 plot_embeddings。它允许你选择降维算法,并突出显示低维投影中的选定搜索词及其最相似的邻居。对于 图 10-3 中的绘图,我们事先手动检查了一些聚类,然后明确命名了一些典型的搜索词来着色聚类。^(7) 在交互视图中,你可以在悬停在点上时看到这些词。

下面是生成此图的代码:

from blueprints.embeddings import plot_embeddings

search = ['ford', 'lexus', 'vw', 'hyundai',
          'goodyear', 'spark-plug', 'florida', 'navigation']

plot_embeddings(model, search, topn=50, show_all=True, labels=False,
                algo='umap', n_neighbors=15, min_dist=0.1)

对于数据探索,仅可视化搜索词集合及其最相似的邻居可能更有趣。图 10-4 展示了以下几行代码生成的示例。展示的是搜索词及其前 10 个最相似的邻居:

search = ['ford', 'bmw', 'toyota', 'tesla', 'audi', 'mercedes', 'hyundai']

plot_embeddings(model, search, topn=10, show_all=False, labels=True,
    algo='umap', n_neighbors=15, min_dist=10, spread=25)

图 10-4. 选定关键词及其最相似邻居的二维 UMAP 投影。

图 10-5 显示了相同的关键词,但具有更多相似邻居的三维绘图。Plotly 允许您旋转和缩放点云,这样可以轻松调查感兴趣的区域。以下是生成该图的调用:

plot_embeddings(model, search, topn=30, n_dims=3,
    algo='umap', n_neighbors=15, min_dist=.1, spread=40)

要可视化如 tacoma is to toyota like f150 is to ford 的类比,应使用线性 PCA 转换。UMAP 和 t-SNE 都以非线性方式扭曲原始空间。因此,投影空间中的差异向量方向可能与原始方向毫无关联。即使 PCA 也因剪切而扭曲,但效果不及 UMAP 或 t-SNE 明显。

图 10-5. 选定关键词及其最相似邻居的三维 UMAP 投影。

蓝图:使用 TensorFlow Embedding Projector

一个很好的替代自实现可视化函数的选择是 TensorFlow Embedding Projector。它还支持 PCA、t-SNE 和 UMAP,并为数据过滤和突出显示提供了一些便利选项。您甚至无需安装 TensorFlow 就可以使用它,因为有一个在线版本可用。一些数据集已加载为演示。

要显示我们自己的单词嵌入与 TensorFlow Embedding Projector,我们需要创建两个以制表符分隔值的文件:一个包含单词向量的文件和一个可选的包含嵌入元数据的文件,在我们的情况下,它们只是单词。这可以通过几行代码实现:

import csv

name = 'autos_w2v_sg_30'
model = models[name]

with open(f'{model_path}/{name}_words.tsv', 'w', encoding='utf-8') as tsvfile:
    tsvfile.write('\n'.join(model.vocab))

with open(f'{model_path}/{name}_vecs.tsv', 'w', encoding='utf-8') as tsvfile:
    writer = csv.writer(tsvfile, delimiter='\t',
                        dialect=csv.unix_dialect, quoting=csv.QUOTE_MINIMAL)
    for w in model.vocab:
        _ = writer.writerow(model[w].tolist())

现在我们可以将我们的嵌入加载到投影仪中,并浏览 3D 可视化效果。要检测聚类,应使用 UMAP 或 t-SNE。图 10-6 显示了我们嵌入的 UMAP 投影的截图。在投影仪中,您可以单击任何数据点或搜索单词,并突出显示其前 100 个邻居。我们选择 harley 作为起点来探索与哈雷 - 戴维森相关的术语。正如您所见,这种可视化在探索领域重要术语及其语义关系时非常有帮助。

图 10-6. 使用 TensorFlow Embedding Projector 可视化嵌入。

蓝图:构建相似性树

这些词及其相似关系可以被解释为网络图,如下所示:词表示图的节点,当两个节点“非常”相似时,就创建一条边。此标准可以是节点位于它们的前 n 个最相似邻居之间,或者是相似度分数的阈值。然而,一个词附近的大多数词不仅与该词相似,而且彼此也相似。因此,即使对于少量词的子集,完整的网络图也会有太多的边,以至于无法理解的可视化。因此,我们从略微不同的角度出发,创建这个网络的子图,即相似性树。图 10-7 展示了这样一个根词 noise 的相似性树。

图 10-7. noise 最相似的单词的相似性树。

我们提供两个蓝图函数来创建这样的可视化效果。第一个函数 sim_tree 从根词开始生成相似性树。第二个函数 plot_tree 创建绘图。我们在两个函数中都使用 Python 的图形库 networkx

让我们首先看一下 sim_tree。从根词开始,我们寻找前 n 个最相似的邻居。它们被添加到图中,并且相应地创建边。然后,我们对每个新发现的邻居及其邻居执行相同的操作,依此类推,直到达到与根节点的最大距离。在内部,我们使用队列 (collections.deque) 实现广度优先搜索。边的权重由相似度确定,稍后用于设置线宽:

import networkx as nx
from collections import deque

def sim_tree(model, word, top_n, max_dist):

    graph = nx.Graph()
    graph.add_node(word, dist=0)

    to_visit = deque([word])
    while len(to_visit) > 0:
        source = to_visit.popleft() # visit next node
        dist = graph.nodes[source]['dist']+1

        if dist <= max_dist: # discover new nodes
            for target, sim in model.most_similar(source, topn=top_n):
                if target not in graph:
                    to_visit.append(target)
                    graph.add_node(target, dist=dist)
                    graph.add_edge(source, target, sim=sim, dist=dist)
    return graph

函数 plot_tree 只需几个调用来创建布局并绘制节点和边,并对其进行一些样式设置。我们使用 Graphviz 的 twopi 布局来创建节点的雪花状位置。为简化起见,这里略去了一些细节,但你可以在 GitHub 上找到完整代码

from networkx.drawing.nx_pydot import graphviz_layout

def plot_tree(graph, node_size=1000, font_size=12):

    pos = graphviz_layout(graph, prog='twopi', root=list(graph.nodes)[0])

    colors = [graph.nodes[n]['dist'] for n in graph] # colorize by distance
    nx.draw_networkx_nodes(graph, pos, node_size=node_size, node_color=colors,
                           cmap='Set1', alpha=0.4)
    nx.draw_networkx_labels(graph, pos, font_size=font_size)

    for (n1, n2, sim) in graph.edges(data='sim'):
         nx.draw_networkx_edges(graph, pos, [(n1, n2)], width=sim, alpha=0.2)

    plt.show()

图 10-7 使用这些函数和参数生成。

model = models['autos_w2v_sg_2']
graph = sim_tree(model, 'noise', top_n=10, max_dist=3)
plot_tree(graph, node_size=500, font_size=8)

它展示了与 noise 最相似的单词及其与 noise 的最相似单词,直到设想的距离为 3。可视化表明,我们创建了一种分类法,但实际上并非如此。我们只选择在我们的图中包含可能的边的子集,以突出“父”词与其最相似的“子”词之间的关系。这种方法忽略了兄弟之间或祖父辈之间可能的边。然而,视觉呈现有助于探索围绕根词的特定应用领域的词汇。然而,Gensim 还实现了用于学习单词之间分层关系的 Poincaré embeddings

本图使用了窗口大小为 2 的模型,突显了不同种类和同义词的噪声。如果我们选择较大的窗口大小,我们将得到与根词相关的更多概念。图 10-8 是使用以下参数创建的:

model = models['autos_w2v_sg_30']
graph = sim_tree(model, 'spark-plug', top_n=8, max_dist=2)
plot_tree(graph, node_size=500, font_size=8)

图 10-8. 与火花塞最相似的单词的相似性树。

在这里,我们选择了 spark-plug 作为根词,并选择了窗口大小为 30 的模型。生成的图表很好地概述了与 spark-plugs 相关的领域特定术语。例如,p0302 等代码是不同汽缸中点火故障的标准化 OBD2 故障代码。

当然,这些图表也揭示了我们数据准备中的一些弱点。我们看到 spark-plugsparkplugsparkplugs 四个节点,它们都代表着相同的概念。如果我们希望为所有这些形式的写法创建单一的嵌入向量,就必须将它们合并成一个标记。

结语

探索特定关键术语在领域特定模型中相似邻居可以是一种有价值的技术,以发现领域特定语料库中单词之间的潜在语义关系。尽管单词相似性的整体概念本质上是模糊的,但我们通过仅在约 20,000 用户关于汽车的帖子上训练一个简单的神经网络,产生了非常有趣和可解释的结果。

与大多数机器学习任务一样,结果的质量受到数据准备的强烈影响。根据您要完成的任务,您应该有意识地决定对原始文本应用哪种规范化和修剪。在许多情况下,使用词形和小写字母单词能产生良好的相似性推理嵌入。短语检测可能有助于改进结果,还可以识别应用领域中可能重要的复合术语。

我们使用了 Gensim 来训练、存储和分析我们的嵌入向量。Gensim 非常流行,但您可能也想检查可能更快的替代方案,比如 (Py)Magnitude 或者 finalfusion。当然,您也可以使用 TensorFlow 和 PyTorch 来训练不同类型的嵌入向量。

今天,语义嵌入对所有复杂的机器学习任务至关重要。然而,对于诸如情感分析或释义检测等任务,您不需要单词的嵌入,而是需要句子或完整文档的嵌入。已经发表了许多不同的方法来创建文档嵌入(Wolf, 2018; Palachy, 2019)。一个常见的方法是计算句子中单词向量的平均值。一些 spaCy 模型在其词汇表中包含了单词向量,并且可以基于平均单词向量计算文档相似性。然而,对于单个句子或非常短的文档,仅平均单词向量的方法效果还不错。此外,整个方法受到袋装词袋思想的限制,其中不考虑单词顺序。

当前最先进的模型利用了语义嵌入的能力以及词序。在下一章节中,我们将使用这样的模型进行情感分类。

进一步阅读

^(1) 受到 Adrian Colyer 的“词向量的惊人力量”博文的启发。

^(2) 这个经常被引用的例子最初来自语言学家尤金·尼达,于 1975 年提出。

^(3) Jay Alammar 的博文“图解 Word2Vec”生动地解释了这个方程。

^(4) 拥有相同发音但不同意义的单词被称为同音异义词。如果它们拼写相同,则被称为同形异义词

^(5) 例如,来自RaRe Technologies3Top

^(6) 如果你自己运行这段代码,由于随机初始化的原因,结果可能会与书中打印的略有不同。

^(7) 你可以在电子版和GitHub上找到彩色的图表。

第十一章:在文本数据上执行情感分析

在我们在现实世界中的每一次互动中,我们的大脑在潜意识中不仅通过所说的话来注册反馈,还使用面部表情、身体语言和其他物理线索。然而,随着越来越多的沟通变成数字化形式,它越来越多地出现在文本形式中,我们无法评估物理线索。因此,通过他们写的文本理解一个人的情绪或感受是非常重要的,以便形成对他们信息完整理解。

例如,现在很多客户支持都通过软件服务系统或者自动聊天机器人来自动化。因此,了解客户感受的唯一方式就是通过理解他们回复中的情感。因此,如果我们处理一个特别愤怒的客户,就非常重要要在回复时特别小心,以免进一步激怒他们。同样,如果我们想要了解客户对特定产品或品牌的看法,我们可以分析他们在社交媒体渠道上关于该品牌的帖子、评论或者评价的情感,并理解他们对品牌的感受。

从文本中理解情感是具有挑战性的,因为有几个方面需要推断,这些方面并不直接明显。一个简单的例子是来自亚马逊购买的笔记本电脑的以下客户评价:

这台笔记本电脑存在严重问题。它的速度完全符合规格,非常慢!启动时间更长。

如果一个人类读它,他们可以察觉到关于笔记本电脑速度的讽刺表达,以及它启动时间长的事实,这导致我们得出结论这是一个负面评价。然而,如果我们只分析文本,很明显速度完全符合规格。启动时间较长的事实也可能被认为是一件好事,除非我们知道这是需要小的参数。情感分析的任务也特定于所使用的文本数据类型。例如,报纸文章以结构化方式编写,而推文和其他社交媒体文本则遵循松散结构,并且存在俚语和不正确的标点符号。因此,并不存在一种可以适用于所有情景的蓝图。相反,我们将提供一套可以用来进行成功情感分析的蓝图。

您将学到什么,我们将构建什么

在本章中,我们将探讨多种技术,用于从文本数据片段中估计情感。我们将从简单的基于规则的技术开始,并逐步深入到更复杂的方法,最终使用来自 Google 的 BERT 等最新语言模型。通过这些技术的介绍,我们的目的是提升对客户情感的理解,并为您提供一套可以应用于各种用例的蓝图。例如,结合第二章中的 Twitter API 蓝图(见 ch02.xhtml#ch-api),您可以确定公众对某一特定人物或政治问题的情感。您还可以在组织内使用这些蓝图来分析客户投诉或支持电子邮件中的情感,从而了解客户的满意度。

情感分析

大量信息以文本形式提供,根据通信的上下文,可以将信息分类为客观文本和主观文本。客观文本包含简单的事实陈述,如我们在教科书或维基百科文章中找到的内容。这类文本通常只呈现事实,不表达观点或情感。另一方面,主观文本传达了某人的反应,或包含了情感、情绪或感觉的信息。这在社交媒体渠道如推特中或顾客在产品评论中典型地表现出来。我们进行情感分析研究,以了解通过文本表达的个体心态状态。因此,情感分析最适用于包含此类信息的主观文本,而不是客观文本。在开始分析之前,我们必须确保拥有捕捉我们寻找的情感信息的正确类型数据集。

一段文本的情感可以在短语、句子或文档级别确定。例如,如果我们以客户写给公司的电子邮件为例,将会有几段,每段中包含多个句子。可以为每个句子和每个段落计算情感。虽然第 1 段可能是积极的,但第 3 和第 4 段可能是消极的。因此,如果我们想要确定该客户表达的整体情感,我们需要确定将每段的情感聚合到文档级别的最佳方法。在我们提供的蓝图中,我们在句子级别计算情感。

进行情感分析的技术可以分解为简单的基于规则的技术和监督式机器学习方法。基于规则的技术更容易应用,因为它们不需要标注的训练数据。监督学习方法提供更好的结果,但包括标记数据的额外努力。我们将在我们的用例中展示,可能有简单的方法来绕过这个要求。在本章中,我们将提供以下一套蓝图:

  • 使用基于词典的方法进行情感分析。

  • 通过从文本数据构建附加特征并应用监督式机器学习算法进行情感分析。

  • 使用转移学习技术和预训练语言模型如 BERT 进行情感分析。

介绍亚马逊客户评论数据集。

假设您是一家领先消费电子公司市场部门的分析师,并希望了解您的智能手机产品与竞争对手的比较情况。您可以轻松比较技术规格,但更有趣的是了解产品的消费者感知。您可以通过分析顾客在亚马逊产品评论中表达的情感来确定这一点。利用蓝图并对每个品牌的每条评论的情感进行汇总,您将能够确定顾客如何看待每个品牌。同样,如果您的公司计划通过在相邻类别引入产品来扩展业务,该怎么办?您可以分析一个段落中所有产品的顾客评论,例如媒体平板电脑、智能手表或行动摄像机,并根据汇总的情感确定一个顾客满意度较低的段落,因此您的产品具有更高的潜在成功机会。

对于我们的蓝图,我们将使用一个包含亚马逊不同产品的客户评论的数据集,涵盖多个产品类别。这个亚马逊客户评论数据集已经由斯坦福大学的研究人员抓取和编译好了。^(1) 最新版本 包括了 1996 年至 2018 年间从亚马逊网站抓取的产品评论,涵盖了多个类别。它包括产品评论、产品评级以及其他信息,如有用的投票和产品元数据。对于我们的蓝图,我们将专注于产品评论,并仅使用那些只有一句话的评论。这是为了保持蓝图的简单性,并且去掉聚合步骤。一个包含多个句子的评论可能包含积极和消极的情感。因此,如果我们标记一个评论中所有句子具有相同的情感,那将是不正确的。我们只使用部分类别的数据,以便其可以适应内存并减少处理时间。这个数据集已经准备好了,但你可以参考存储库中的 Data_Preparation 笔记本了解步骤并可能扩展它。蓝图适用于任何类型的数据集,因此如果你可以访问强大的硬件或云基础设施,那么你可以选择更多的类别。

现在让我们看一下数据集:

df = pd.read_json('reviews.json', lines=True)
df.sample(5)

Out:

overall verified reviewerID asin text summary
163807 5 False A2A8GHFXUG1B28 B0045Z4JAI 不错的无咖啡因... 对于一种无咖啡因咖啡来说味道不错 😃 好!
195640 5 True A1VU337W6PKAR3 B00K0TIC56 对于我的小温室来说,我无法找到比这个系统更好的选择,设置容易,喷嘴也表现非常好。 对于我的小温室来说,我无法找到比这个系统更好的选择。
167820 4 True A1Z5TT1BBSDLRM B0012ORBT6 品质不错的产品,价格合理,省去了一趟商店的旅程。 四星评价
104268 1 False A4PRXX2G8900X B005SPI45U 我喜欢生的薯片的理念 - 可以和我自制的莎莎酱和鳄梨酱一起吃 - 但这些味道真是太恶心了。 没有更好的选择,但味道仍然很差。
51961 1 True AYETYLNYDIS2S B00D1HLUP8 仿制品来自中国,一分钱一分货。 绝对不是原装产品

查看数据集摘要,我们可以看到它包含以下列:

Overall

这是评论者对产品的最终评级。从 1(最低)到 5(最高)。

Verified

这表明产品购买是否经过了亚马逊的验证。

ReviewerID

这是亚马逊为每个评论者分配的唯一标识符。

ASIN

这是亚马逊用来识别产品的唯一产品代码。

文本

用户提供的评论中的实际文本。

Summary

这是用户提供的评论的标题或摘要。

text 包含客户评价的主要内容,表达了用户的观点。尽管其他信息也有用,但我们将专注于在蓝图中使用此列。

蓝图:使用基于词典的方法执行情感分析

作为分析师在亚马逊客户评价数据上工作,可能遇到的第一个挑战是缺少目标标签。我们无法自动知道特定评价是积极还是消极的。文本是因为产品完美运作而表达快乐,还是因为产品在第一次使用时损坏而表达愤怒?直到我们实际阅读评价,我们都无法确定这一点。这是具有挑战性的,因为我们将不得不阅读接近 30 万条评价,并手动为每一条评价分配目标情感。我们通过使用基于词典的方法来解决这个问题。

什么是词典?词典 就像一个包含一系列词汇并使用专家知识编制的字典。词典的关键区别因素在于它包含特定知识并且是为特定目的而收集的。我们将使用包含常用词汇和捕捉与之关联情感的情感词典。一个简单的例子是词汇 happy,情感得分为 1,另一个例子是词汇 frustrated,其得分为-1。有几种标准化的词典可供使用,流行的包括 AFINN 词典、SentiWordNet、Bing Liu 的词典以及 VADER 词典等。它们在词汇量和表达方式上各不相同。例如,AFINN 词典 是一个包含 3,300 个词汇的单一词典,每个词汇都分配了从-3 到+3 的有符号情感分数。负/正表示极性,大小表示强度。另一方面,如果我们看 Bing Liu 词典,它以两个列表的形式存在:一个为积极词汇,另一个为消极词汇,总共有 6,800 个词汇。大多数情感词典适用于英语,但也有适用于德语^(2)及其他 81 种语言的词典,这是由该研究论文生成的^(3)。

句子或短语的情感是通过首先从选择的 lexicon 中识别每个单词的情感分数,然后将它们相加以得出整体情感来确定的。通过使用这种技术,我们避免了手动查看每个评论并分配情感标签的需要。相反,我们依赖于 lexicon,它为每个单词提供专家情感分数。对于我们的第一个蓝图,我们将使用必应刘 lexicon,但您可以自由地扩展蓝图以使用其他 lexicon。 lexicon 通常包含单词的多个变体并排除停用词,因此在这种方法中标准的预处理步骤并不重要。只有 lexicon 中存在的单词才会真正得分。这也导致了这种方法的一个缺点,我们将在蓝图的末尾讨论它。

必应刘 lexicon

必应刘 lexicon 已经编制,将单词分成表达积极意见和表达消极意见的两类。这个 lexicon 还包含拼写错误的单词,更适合用于从在线讨论论坛、社交媒体和其他类似来源提取的文本,并因此应该在亚马逊客户评论数据上产生更好的结果。

必应刘 lexicon 可从作者的网站作为zip 文件获得,其中包含一组积极和消极的单词。它也作为 NLTK 库中的语料库提供,我们可以在下载后使用。一旦我们提取了 lexicon,我们将创建一个可以保存 lexicon 单词及其相应情感分数的字典。我们的下一步是为数据集中的每个评论生成评分。我们首先将文本内容转换为小写;然后使用 NLTK 包中的 word_tokenize 函数,将句子分割成单词,并检查这个单词是否属于我们的 lexicon,如果是,我们将单词的相应情感分数添加到评论的总情感分数中。作为最后一步,我们基于句子中的单词数量对这个分数进行归一化。这个功能被封装在函数 bing_liu_score 中,并应用于数据集中的每个评论:

from nltk.corpus import opinion_lexicon
from nltk.tokenize import word_tokenize
nltk.download('opinion_lexicon')

print('Total number of words in opinion lexicon', len(opinion_lexicon.words()))
print('Examples of positive words in opinion lexicon',
      opinion_lexicon.positive()[:5])
print('Examples of negative words in opinion lexicon',
      opinion_lexicon.negative()[:5])

Out:

Total number of words in opinion lexicon 6789
Examples of positive words in opinion lexicon ['a+', 'abound', 'abounds',
'abundance', 'abundant']
Examples of negative words in opinion lexicon ['2-faced', '2-faces',
'abnormal', 'abolish', 'abominable']

然后:

# Let's create a dictionary which we can use for scoring our review text
df.rename(columns={"reviewText": "text"}, inplace=True)
pos_score = 1
neg_score = -1
word_dict = {}

# Adding the positive words to the dictionary
for word in opinion_lexicon.positive():
        word_dict[word] = pos_score

# Adding the negative words to the dictionary
for word in opinion_lexicon.negative():
        word_dict[word] = neg_score

def bing_liu_score(text):
    sentiment_score = 0
    bag_of_words = word_tokenize(text.lower())
    for word in bag_of_words:
        if word in word_dict:
            sentiment_score += word_dict[word]
    return sentiment_score / len(bag_of_words)

df['Bing_Liu_Score'] = df['text'].apply(bing_liu_score)
df[['asin','text','Bing_Liu_Score']].sample(2)

Out:

asin text Bing_Liu_Score
188097 B00099QWOU 一如预期 0.00
184654 B000RW1XO8 按设计工作... 0.25

现在我们已经计算出情感分数,我们想要检查计算出的分数是否符合基于客户提供的评分的预期。我们可以比较具有不同评分的评论的情感分数,而不是对每个评论都进行检查。我们预期,一个五星评价的评论的情感分数会高于一个一星评价的评论。在下一步中,我们将为每个类型的星级评分缩放每个评论的分数在 1 到-1 之间,并计算所有评论的平均情感分数:

df['Bing_Liu_Score'] = preprocessing.scale(df['Bing_Liu_Score'])
df.groupby('overall').agg({'Bing_Liu_Score':'mean'})

Out:

overall Bing_Liu_Score
1 -0.587061
2 -0.426529
4 0.344645
5 0.529065

前述蓝图使我们能够使用任何类型的情感词汇表快速确定情感分数,并且还可以作为比较其他复杂技术的基准,这应该能提高情感预测的准确性。

基于词汇表的方法的缺点

尽管基于词汇表的方法很简单,但我们观察到它有一些明显的缺点:

  • 首先,我们受限于词汇表的大小;如果一个词不在所选的词汇表中,那么我们无法在确定该评论的情感分数时使用这些信息。在理想情况下,我们希望使用一个涵盖语言中所有单词的词汇表,但这是不可行的。

  • 其次,我们假设所选的词汇表是一个金标准,并信任作者提供的情感分数/极性。这是一个问题,因为特定的词汇表可能不适合特定的用例。在前面的例子中,Bing Liu 词汇表是相关的,因为它捕捉到了在线语言的使用,并在其词汇表中包含了常见的拼写错误和俚语。但如果我们正在处理推文数据集,那么 VADER 词汇表将更适合,因为它支持流行缩写(例如,LOL)和表情符号。

  • 最后,词汇表的最大缺点之一是它忽略了否定词。由于词汇表只匹配单词而不是短语,这将导致包含“not bad”的句子获得负分,而实际上它更中性。

要改进我们的情感检测,我们必须探索使用监督式机器学习方法。

监督学习方法

使用监督学习方法是有益的,因为它允许我们对数据中的模式进行建模,并创建一个接近现实的预测函数。它还为我们提供了选择不同技术并确定提供最大准确性的技术的灵活性。有关监督式机器学习的更详细概述,请参阅第六章。

要使用这种方法,我们需要标记数据,这可能不容易得到。通常,需要两个或更多的人类注释者查看每个评论,并确定情感。如果注释者意见不一致,那么可能需要第三个注释者来打破僵局。通常会有五个注释者,其中三个人对意见达成一致以确认标签。这可能会很乏味和昂贵,但在处理实际业务问题时是首选的方法。

然而,在许多情况下,我们可以在不经过昂贵的标注过程的情况下测试监督学习方法。一个更简单的选择是检查数据中可能帮助我们自动注释的任何代理指标。让我们在亚马逊评论的案例中说明这一点。如果有人给了一个五星级的产品评分,那么我们可以假设他们喜欢他们使用的产品,并且这应该在他们的评论中反映出来。同样,如果有人为一个产品提供了一星评级,那么他们对此不满意,并且可能有一些负面的话要说。因此,我们可以将产品评分作为衡量特定评论是积极还是消极的代理措施。评级越高,特定评论就越积极。

准备数据以进行监督学习方法

因此,在将我们的数据集转换为监督学习问题的第一步中,我们将使用评级自动注释我们的评论。我们选择将所有评级为 4 和 5 的评论标注为积极,并根据之前提供的推理将评级为 1 和 2 的评论标注为消极。在数据准备过程中,我们还过滤掉了评级为 3 的评论,以提供积极和消极评论之间更清晰的分离。这一步骤可以根据您的用例进行定制。

df = pd.read_json('reviews.json', lines=True)

# Assigning a new [1,0] target class label based on the product rating
df['sentiment'] = 0
df.loc[df['overall'] > 3, 'sentiment'] = 1
df.loc[df['overall'] < 3, 'sentiment'] = 0

# Removing unnecessary columns to keep a simple DataFrame
df.drop(columns=[
    'reviewTime', 'unixReviewTime', 'overall', 'reviewerID', 'summary'],
        inplace=True)
df.sample(3)

Out:

verified asin text sentiment
176400 True B000C5BN72 everything was as listed and is in use all appear to be in good working order 1
65073 True B00PK03IVI this is not the product i received. 0
254348 True B004AIKVPC Just like the dealership part. 1

正如您从呈现的评论选择中可以看出,我们创建了一个名为sentiment的新列,其中包含根据用户提供的评分值为 1 或 0 的值。现在我们可以将其视为一个监督学习问题,我们将使用text中的内容来预测情感:积极(1)或消极(0)。

蓝图:文本数据向量化和应用监督学习算法

在这个蓝图中,我们将通过首先清洗文本数据,然后进行向量化,最后应用支持向量机模型来构建一个监督学习的机器学习算法。

步骤 1:数据准备

为了预处理数据,我们将应用来自第四章的正则表达式蓝图,以删除任何特殊字符、HTML 标签和 URL:

df['text_orig'] = df['text'].copy()
df['text'] = df['text'].apply(clean)

然后,我们将应用来自同一章节的数据准备蓝图,该蓝图使用了 spaCy 流水线。这确保文本被标准化为小写形式,不包括数字和标点,并且格式化为后续步骤可以使用的格式。请注意,执行此步骤可能需要几分钟的时间。在某些情况下,可能在清理步骤中删除了评论中的所有标记,这种情况下不再有必要包括这样的评论:

df["text"] = df["text"].apply(clean_text)

# Remove observations that are empty after the cleaning step
df = df[df['text'].str.len() != 0]

步骤 2:训练-测试分割

我们将数据分割,使得接下来的向量化步骤仅使用训练数据集。我们按照 80-20 的比例划分数据,并通过指定目标变量情感为stratify参数来确认正负类在两个划分中显示出类似的分布:

from sklearn.model_selection import train_test_split
X_train, X_test, Y_train, Y_test = train_test_split(df['text'],
                                                    df['sentiment'],
                                                    test_size=0.2,
                                                    random_state=42,
                                                    stratify=df['sentiment'])

print ('Size of Training Data ', X_train.shape[0])
print ('Size of Test Data ', X_test.shape[0])

print ('Distribution of classes in Training Data :')
print ('Positive Sentiment ', str(sum(Y_train == 1)/ len(Y_train) * 100.0))
print ('Negative Sentiment ', str(sum(Y_train == 0)/ len(Y_train) * 100.0))

print ('Distribution of classes in Testing Data :')
print ('Positive Sentiment ', str(sum(Y_test == 1)/ len(Y_test) * 100.0))
print ('Negative Sentiment ', str(sum(Y_test == 0)/ len(Y_test) * 100.0))

Out:

Size of Training Data  234108
Size of Test Data  58527
Distribution of classes in Training Data :
Positive Sentiment  50.90770071932612
Negative Sentiment  49.09229928067388
Distribution of classes in Testing Data :
Positive Sentiment  50.9081278726058
Negative Sentiment  49.09187212739419

步骤 3:文本向量化

下一步是将清理后的文本转换为可用特征的步骤。机器学习模型无法理解文本数据,只能处理数值数据。我们重新使用了 TF-IDF 向量化的蓝图来创建向量化表示。我们选择了min_df参数为 10,并且不包括二元组。此外,我们在前一步已经移除了停用词,因此在向量化过程中无需再处理此问题。我们将使用相同的向量化器来转换测试集,该测试集将在评估过程中使用:

from sklearn.feature_extraction.text import TfidfVectorizer

tfidf = TfidfVectorizer(min_df = 10, ngram_range=(1,1))
X_train_tf = tfidf.fit_transform(X_train)
X_test_tf = tfidf.transform(X_test)

步骤 4:训练机器学习模型

如第六章所述,当处理文本数据时,支持向量机是首选的机器学习算法。SVM 在处理具有大量数值特征的数据集时表现良好,特别是我们使用的 LinearSVC 模块非常快速。我们还可以选择基于树的方法,如随机森林或 XGBoost,但根据我们的经验,准确性相当,并且由于训练时间快,可以更快地进行实验:

from sklearn.svm import LinearSVC

model1 = LinearSVC(random_state=42, tol=1e-5)
model1.fit(X_train_tf, Y_train)

Out:

LinearSVC(C=1.0, class_weight=None, dual=True, fit_intercept=True,
          intercept_scaling=1, loss='squared_hinge', max_iter=1000,
          multi_class='ovr', penalty='l2', random_state=42, tol=1e-05,
          verbose=0)

然后:

from sklearn.metrics import accuracy_score
from sklearn.metrics import roc_auc_score

Y_pred = model1.predict(X_test_tf)
print ('Accuracy Score - ', accuracy_score(Y_test, Y_pred))
print ('ROC-AUC Score - ', roc_auc_score(Y_test, Y_pred))

Out:

Accuracy Score -  0.8658396979172006
ROC-AUC Score -  0.8660667427476778

正如我们所看到的,该模型的准确率约为 86%。让我们来看一些模型的预测结果和评论文本,以对模型进行一次审查:

sample_reviews = df.sample(5)
sample_reviews_tf = tfidf.transform(sample_reviews['text'])
sentiment_predictions = model1.predict(sample_reviews_tf)
sentiment_predictions = pd.DataFrame(data = sentiment_predictions,
                                     index=sample_reviews.index,
                                     columns=['sentiment_prediction'])
sample_reviews = pd.concat([sample_reviews, sentiment_predictions], axis=1)
print ('Some sample reviews with their sentiment - ')
sample_reviews[['text_orig','sentiment_prediction']]

Out:

Some sample reviews with their sentiment -

text_orig sentiment_prediction
29500 这是一个不错的夜灯,但显然用途不多! 1
98387 太小了,不知道该怎么做或如何使用它们 0
113648 没有使房间“足够蓝” - 无条件退回 0
281527 卓越 1
233713 与 OEM 相匹配,看起来不错 1

我们可以看到,该模型能够合理地预测评论。例如,用户在评论 98387 中认为产品太小不好用,被标记为负面。再看评论 233713,用户表示产品穿着合适且外观不错,被标记为正面。该模型与使用 Bing Liu 词汇表的基准模型相比如何?

def baseline_scorer(text):
    score = bing_liu_score(text)
    if score > 0:
        return 1
    else:
        return 0

Y_pred_baseline = X_test.apply(baseline_scorer)
acc_score = accuracy_score(Y_pred_baseline, Y_test)
print (acc_score)

输出:

0.7521998393903668

它确实提升了 75%的基准模型准确率,虽然准确率还可以进一步提高,但这是一个能够快速产生结果的简单蓝图。例如,如果你想要了解客户对你的品牌与竞争对手的感知,那么使用这个蓝图并聚合每个品牌的情感将会给你一个公平的理解。或者,假设你想要创建一个帮助人们决定是否观看电影的应用程序。使用这个蓝图分析从 Twitter 或 YouTube 评论中收集的数据,你可以确定人们的情感倾向,然后提供建议。在下一个蓝图中,我们将描述一种更复杂的技术,可以用来提高准确性。

使用深度学习的预训练语言模型

语言在几个世纪以来不断演变,并且仍在不断变化中。虽然有语法规则和形成句子的指导方针,但这些规则通常不严格遵循,且严重依赖于上下文。一个人在发推文时选择的词语与写电子邮件表达相同思想时选择的词语会有很大不同。而且在许多语言(包括英语)中,例外情况实在太多!因此,计算机程序要理解基于文本的交流是很困难的。通过使算法深入理解语言,使用语言模型可以克服这一难题。

语言模型是自然语言的数学表示,允许我们理解句子的结构和其中的词语。有几种类型的语言模型,但在本蓝图中我们将专注于预训练语言模型的使用。这些语言模型的最重要特征是它们利用深度神经网络架构,并在大型数据语料库上进行训练。语言模型的使用极大地提高了自然语言处理任务的性能,如语言翻译、自动拼写校正和文本摘要。

深度学习和迁移学习

深度学习通常用来描述一组利用人工神经网络(ANNs)的机器学习方法。人工神经网络受人类大脑启发,试图模仿生物系统中神经元之间的连接和信息处理活动。简单来说,它试图使用一个由多层节点组成的互连网络来建模函数,网络边的权重通过数据学习。有关更详细的解释,请参考Hands-On Machine Learning(O’Reilly,2019)的第 II 部分,作者是 Aurélien Géron。

转移学习是深度学习中的一项技术,允许我们通过将模型转移到特定用例来受益于预训练的广泛可用语言模型。它使我们能够利用在一个任务中获得的知识和信息,并将其应用到另一个问题上。作为人类,我们擅长这样做。例如,我们最初学习弹吉他,但随后可以相对容易地应用这些知识来更快地学会大提琴或竖琴(比完全初学者快)。当相同的概念应用于机器学习算法时,就被称为转移学习

这个想法首次在计算机视觉行业中流行起来,一个大规模的图像识别挑战促使几个研究小组竞相建立复杂的深度神经网络,网络层数深达数层,以降低挑战中的错误。其他研究人员发现,这些复杂模型不仅对该挑战有效,还可以通过微小调整适用于其他图像识别任务。这些大型模型已经学习了关于图像的基本特征(如边缘、形状等),可以在不需要从头开始训练的情况下,针对特定应用进行微调。在过去两年中,同样的技术已成功应用于文本分析。首先,在大型文本语料库(通常来自公开可用的数据源,如维基百科)上训练一个深度神经网络。所选择的模型架构是 LSTM 或 Transformer 的变体。^(4) 在训练这些模型时,会在句子中去掉一个词(掩码),预测任务是确定给定句子中所有其他词的情况下的掩码词。回到我们的人类类比,也许有更多的 YouTube 视频教你如何弹吉他而不是竖琴或大提琴。因此,首先学习弹吉他将是有益的,因为有大量的资源可用,然后将这些知识应用到不同的任务,如学习竖琴或大提琴。

大型模型训练时间长,耗时较多。幸运的是,许多研究团队已经公开了这些预训练模型,包括来自 fastai 的ULMFiT,来自 Google 的BERT,来自 OpenAI 的GPT-2,以及来自 Microsoft 的Turing。图 11-1 展示了应用迁移学习的最后一步,即保持预训练模型的初始层不变,重新训练模型的最终层以更好地适应手头的任务。通过这种方式,我们可以将预训练模型应用于文本分类和情感分析等特定任务。

图 11-1. 迁移学习。网络中较早层的参数通过对大语料进行训练而学习,而最终层的参数则被解冻,并允许在特定数据集上进行微调训练。

对于我们的蓝图,我们将使用 Google 发布的预训练模型 BERT。BERT 是双向编码器表示转换的缩写。它使用 Transformers 架构,并使用大量文本数据训练模型。在本蓝图中使用的模型(bert-base-uncased)是在结合了英文维基百科和 Books 语料库的基础上,使用掩蔽语言模型(MLM)进行训练的。BERT 模型的其他版本可以基于不同语料库进行训练。例如,有一个 BERT 模型是在德语维基百科文章上训练的。掩蔽语言模型随机掩盖输入中的一些标记(单词),其目标是仅基于上下文(周围单词)预测掩蔽词的原始词汇 ID。由于是双向的,模型从两个方向查看每个句子,能够更好地理解上下文。此外,BERT 还使用子词作为标记,这在识别单词含义时提供了更精细的控制。另一个优点是 BERT 生成上下文感知的嵌入。例如,在一个句子中使用单词cell时,根据周围单词,它可以具有生物参考或实际上指的是监狱单元的含义。要更详细地了解 BERT 的工作原理,请参阅“进一步阅读”。

蓝图:使用迁移学习技术和预训练语言模型

这份蓝图将向您展示如何利用预训练语言模型进行情感分类。考虑这样一个使用案例,您希望根据表达的情感采取行动。例如,如果一个客户特别不满意,您希望将他们转接到最优秀的客户服务代表那里。能够准确检测情感非常重要,否则您可能会失去他们。或者,假设您是一个依赖公共网站如Yelp上的评价和评级的小企业。为了提高评分,您希望通过向不满意的客户提供优惠券或特别服务来跟进。准确性对于定位正确的客户非常重要。在这些使用案例中,我们可能没有大量数据来训练模型,但高准确度是至关重要的。我们知道情感受到词语使用上下文的影响,而使用预训练语言模型可以改善我们的情感预测。这使我们能够超越我们拥有的有限数据集,融入来自一般使用的知识。

在我们的蓝图中,我们将使用 Transformers 库,因为它具有易于使用的功能和对多个预训练模型的广泛支持。"选择 Transformers 库"提供了关于这个主题的更多详细信息。Transformers 库不断更新,多位研究人员在其中贡献。

第一步:加载模型和标记化

使用 Transformers 库的第一步是导入所选模型所需的三个类。这包括config类,用于存储重要的模型参数;tokenizer,用于标记化和准备文本进行模型训练;以及model类,定义模型架构和权重。这些类特定于模型架构,如果我们想要使用不同的架构,那么需要导入相应的类。我们从预训练模型中实例化这些类,并选择最小的 BERT 模型,bert-base-uncased,它有 12 层深,并包含 1.1 亿个参数!

使用 Transformers 库的优势在于,它已经为许多模型架构提供了多个预训练模型,您可以在这里查看。当我们从预训练模型实例化一个模型类时,模型架构和权重将从由 Hugging Face 托管的 AWS S3 存储桶中下载。这可能会花费一些时间,但在您的机器上缓存后,就不需要再次下载。请注意,由于我们使用预训练模型来预测情感(积极与消极),我们指定finetuning_task='binary'。在运行此蓝图之前,我们在附带的笔记本中提供了额外的安装 Python 包的说明。

from transformers import BertConfig, BertTokenizer, BertForSequenceClassification

config = BertConfig.from_pretrained('bert-base-uncased',finetuning_task='binary')
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
model = BertForSequenceClassification.from_pretrained('bert-base-uncased')

我们必须将输入文本数据转换为模型架构所需的标准格式。我们定义一个简单的get_tokens方法,将我们评论的原始文本转换为数值。预训练模型将每个观察作为固定长度序列接受。因此,如果一个观察比最大序列长度短,则用空(零)标记进行填充,如果它更长,则进行截断。每个模型架构都有一个它支持的最大序列长度。分词器类提供了一个分词函数,它将句子分割成标记,填充句子以创建固定长度序列,并最终表示为可在模型训练期间使用的数值。此函数还添加了注意力掩码,以区分那些包含实际单词的位置和包含填充字符的位置。以下是这个过程如何工作的示例:

def get_tokens(text, tokenizer, max_seq_length, add_special_tokens=True):
  input_ids = tokenizer.encode(text,
                               add_special_tokens=add_special_tokens,
                               max_length=max_seq_length,
                               pad_to_max_length=True)
  attention_mask = [int(id > 0) for id in input_ids]
  assert len(input_ids) == max_seq_length
  assert len(attention_mask) == max_seq_length
  return (input_ids, attention_mask)

text = "Here is the sentence I want embeddings for."
input_ids, attention_mask = get_tokens(text,
                                       tokenizer,
                                       max_seq_length=30,
                                       add_special_tokens = True)
input_tokens = tokenizer.convert_ids_to_tokens(input_ids)
print (text)
print (input_tokens)
print (input_ids)
print (attention_mask)

Out:

Here is the sentence I want embeddings for.
['[CLS]', 'here', 'is', 'the', 'sentence', 'i', 'want', 'em', '##bed',
'##ding', '##s', 'for', '.', '[SEP]', '[PAD]', '[PAD]', '[PAD]', '[PAD]',
'[PAD]', '[PAD]', '[PAD]', '[PAD]', '[PAD]', '[PAD]', '[PAD]', '[PAD]',
'[PAD]', '[PAD]', '[PAD]', '[PAD]']
[101, 2182, 2003, 1996, 6251, 1045, 2215, 7861, 8270, 4667, 2015, 2005, 1012,
102, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0]

我们观察到的第一个标记是[CLS]标记,它代表分类,这是 BERT 模型的预训练任务之一。此标记用于标识句子的开始,并在模型内存储整个句子的聚合表示。我们还在句子末尾看到了[SEP]标记,它代表分隔符。当 BERT 用于非分类任务(如语言翻译)时,每个观察将包括一对文本(例如,英文文本和法文文本),而[SEP]标记用于将第一个文本与第二个文本分隔开。然而,由于我们正在构建一个分类模型,分隔符标记后面跟随着[PAD]标记。我们指定了序列长度为 30,由于我们的测试观察并不那么长,在末尾添加了多个填充标记。另一个有趣的观察是,像embedding这样的词不是一个标记,而实际上被分割成em##bed##ding##s##用于识别子词标记,这是 BERT 模型的一个特殊特性。这使得模型能够更好地区分词根、前缀和后缀,并尝试推断它以前可能没有见过的单词的含义。

一个重要的注意点是,由于深度学习模型使用基于上下文的方法,建议使用原始形式的文本而不进行任何预处理,这样允许分词器从其词汇表中生成所有可能的标记。因此,我们必须再次使用原始的text_orig列而不是清理过的text列来分割数据。然后,让我们将相同的函数应用于我们的训练和测试数据,这次使用max_seq_length为 50:

X_train, X_test, Y_train, Y_test = train_test_split(df['text_orig'],
                                                    df['sentiment'],
                                                    test_size=0.2,
                                                    random_state=42,
                                                    stratify=df['sentiment'])
X_train_tokens = X_train.apply(get_tokens, args=(tokenizer, 50))
X_test_tokens = X_test.apply(get_tokens, args=(tokenizer, 50))

深度学习模型使用像TensorFlowPyTorch这样的框架在 GPU 上进行训练。张量是这些框架用来表示和处理数据的基本数据结构,可以在 N 维中存储数据。用象征性的方式来可视化张量,我们可以将其类比为棋盘。假设我们用 0 标记未占用的位置,用 1 标记白子占用的位置,用 2 标记黑子占用的位置。我们得到一个 8×8 矩阵,表示特定时间点上棋盘的状态。如果我们现在想要跟踪并存储多个动作,我们将得到多个 8×8 矩阵,这些可以存储在我们所谓的tensor中。张量是数据的 n 维表示,包含一组坐标空间函数的分量数组。跟踪历史棋局动作的张量将是一个 3 阶张量,而初始的 8×8 矩阵也可以被认为是张量,但是是一个 2 阶张量。

这只是一个简单的解释,但是为了更深入地理解,我们建议阅读 Joseph C. Kolecki 的“An Introduction to Tensors for Students of Physics and Engineering”。在我们的案例中,我们创建了三个张量,包含标记(包含大小为 50 的多个数组的张量)、输入掩码(包含大小为 50 的数组的张量)和目标标签(包含大小为 1 的标量的张量):

import torch
from torch.utils.data import TensorDataset

input_ids_train = torch.tensor(
    [features[0] for features in X_train_tokens.values], dtype=torch.long)
input_mask_train = torch.tensor(
    [features[1] for features in X_train_tokens.values], dtype=torch.long)
label_ids_train = torch.tensor(Y_train.values, dtype=torch.long)

print (input_ids_train.shape)
print (input_mask_train.shape)
print (label_ids_train.shape)

Out:

torch.Size([234104, 50])
torch.Size([234104, 50])
torch.Size([234104])

我们可以窥探一下这个张量中的内容,并看到它包含了句子中每个标记对应的 BERT 词汇映射。数字 101 表示开始,102 表示结束评论句子。我们将这些张量组合成一个 TensorDataset,这是模型训练期间用来加载所有观察结果的基本数据结构。

input_ids_train[1]

Out:

tensor([ 101, 2009, 2134, 1005, 1056, 2147, 6314, 2055, 2009, 1037, 5808, 1997,
        2026, 2769,  102,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0])

然后:

train_dataset = TensorDataset(input_ids_train,input_mask_train,label_ids_train)

步骤 2:模型训练

现在我们已经预处理和标记化了数据,我们准备训练模型。由于深度学习模型的大内存使用和计算需求,我们采用了与前一蓝图中使用的 SVM 模型不同的方法。所有训练观测数据被分成批次(由train_batch_size定义),并从所有观测数据中随机采样(使用RandomSampler),然后通过模型的各层向前传递。当模型通过所有批次看到了所有训练观测数据时,就说它已经训练了一个 epoch。因此,一个 epoch 是通过训练数据中的所有观测值的一次传递。batch_size的组合和 epoch 数确定了模型训练的时间长度。选择较大的batch_size减少了 epoch 中的前向传递次数,但可能会导致更高的内存消耗。选择更多的 epochs 给模型更多时间来学习参数的正确值,但也会导致更长的训练时间。对于这个蓝图,我们定义了batch_size为 64,num_train_epochs为 2:

from torch.utils.data import DataLoader, RandomSampler

train_batch_size = 64
num_train_epochs = 2

train_sampler = RandomSampler(train_dataset)
train_dataloader = DataLoader(train_dataset,
                              sampler=train_sampler,
                              batch_size=train_batch_size)
t_total = len(train_dataloader) // num_train_epochs

print ("Num examples = ", len(train_dataset))
print ("Num Epochs = ", num_train_epochs)
print ("Total train batch size  = ", train_batch_size)
print ("Total optimization steps = ", t_total)

输出:

Num examples =  234104
Num Epochs =  2
Total train batch size  =  64
Total optimization steps =  1829

当一个批次中的所有观测数据通过模型的各层向前传递后,反向传播算法将以反向方向应用。这种技术允许我们自动计算神经网络中每个参数的梯度,从而为我们提供了一种调整参数以减少误差的方法。这类似于随机梯度下降的工作原理,但我们不打算详细解释。《动手学习机器学习》(O’Reilly,2019)第四章提供了一个很好的介绍和数学解释。需要注意的关键点是,在训练深度学习算法时,影响反向传播的参数(如学习率和优化器的选择)决定了模型学习参数并达到更高准确度的速度。然而,并没有科学上的原因说明某种方法或值更好,但许多研究者^(5)试图确定最佳选择。根据 BERT 论文中的参数和 Transformers 库中的推荐,我们为蓝图做出了明智的选择,如下所示:

from transformers import AdamW, get_linear_schedule_with_warmup

learning_rate = 1e-4
adam_epsilon = 1e-8
warmup_steps = 0

optimizer = AdamW(model.parameters(), lr=learning_rate, eps=adam_epsilon)
scheduler = get_linear_schedule_with_warmup(optimizer,
                                            num_warmup_steps=warmup_steps,
                                            num_training_steps=t_total)

在设置训练循环之前,我们检查是否有可用的 GPU(见“在 Google Colab 免费使用 GPU”)。如果有,模型和输入数据将被传输到 GPU,然后我们通过模型运行输入来设置前向传递以产生输出。由于我们已经指定了标签,我们已经知道与实际情况的偏差(损失),并且我们使用反向传播来调整参数以计算梯度。优化器和调度器步骤用于确定参数调整的量。请注意特殊条件,即将梯度剪裁到最大值,以防止梯度爆炸问题的出现。

现在我们将所有这些步骤包装在嵌套的for循环中——一个用于每个时期,另一个用于每个时期中的每个批次——并使用之前介绍的 TQDM 库来跟踪训练进度,同时打印损失值:

from tqdm import trange, notebook

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
train_iterator = trange(num_train_epochs, desc="Epoch")

# Put model in 'train' mode
model.train()

for epoch in train_iterator:
    epoch_iterator = notebook.tqdm(train_dataloader, desc="Iteration")
    for step, batch in enumerate(epoch_iterator):

        # Reset all gradients at start of every iteration
        model.zero_grad()

        # Put the model and the input observations to GPU
        model.to(device)
        batch = tuple(t.to(device) for t in batch)

        # Identify the inputs to the model
        inputs = {'input_ids':      batch[0],
                  'attention_mask': batch[1],
                  'labels':         batch[2]}

        # Forward Pass through the model. Input -> Model -> Output
        outputs = model(**inputs)

        # Determine the deviation (loss)
        loss = outputs[0]
        print("\r%f" % loss, end='')

        # Back-propogate the loss (automatically calculates gradients)
        loss.backward()

        # Prevent exploding gradients by limiting gradients to 1.0
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)

        # Update the parameters and learning rate
        optimizer.step()
        scheduler.step()

到目前为止,我们已经对下载的 BERT 模型进行了参数微调,以适应对亚马逊客户评论的情感分析。如果模型正确学习参数值,您应该观察到损失值在多次迭代中减少。在训练步骤结束时,我们可以将模型和分词器保存到选择的输出文件夹中:

model.save_pretrained('outputs')

第三步:模型评估

在测试数据上评估我们的模型类似于训练步骤,只有细微差别。首先,我们必须评估整个测试数据集,因此不需要进行随机抽样;相反,我们使用SequentialSampler类加载观测值。然而,我们仍然受限于一次加载的观测数目,因此必须使用test_batch_size来确定这一点。其次,我们不需要进行反向传播或调整参数,只执行前向传播。模型为我们提供包含损失值和输出概率值的输出张量。我们使用np.argmax函数确定具有最大概率的输出标签,并通过与实际标签比较来计算准确率:

import numpy as np
from torch.utils.data import SequentialSampler

test_batch_size = 64
test_sampler = SequentialSampler(test_dataset)
test_dataloader = DataLoader(test_dataset,
                             sampler=test_sampler,
                             batch_size=test_batch_size)

# Load the pretrained model that was saved earlier
# model = model.from_pretrained('/outputs')

# Initialize the prediction and actual labels
preds = None
out_label_ids = None

# Put model in "eval" mode
model.eval()

for batch in notebook.tqdm(test_dataloader, desc="Evaluating"):

    # Put the model and the input observations to GPU
    model.to(device)
    batch = tuple(t.to(device) for t in batch)

    # Do not track any gradients since in 'eval' mode
    with torch.no_grad():
        inputs = {'input_ids':      batch[0],
                  'attention_mask': batch[1],
                  'labels':         batch[2]}

        # Forward pass through the model
        outputs = model(**inputs)

        # We get loss since we provided the labels
        tmp_eval_loss, logits = outputs[:2]

        # There maybe more than one batch of items in the test dataset
        if preds is None:
            preds = logits.detach().cpu().numpy()
            out_label_ids = inputs['labels'].detach().cpu().numpy()
        else:
            preds = np.append(preds, logits.detach().cpu().numpy(), axis=0)
            out_label_ids = np.append(out_label_ids,
                                      inputs['labels'].detach().cpu().numpy(),
                                      axis=0)

# Get final loss, predictions and accuracy
preds = np.argmax(preds, axis=1)
acc_score = accuracy_score(preds, out_label_ids)
print ('Accuracy Score on Test data ', acc_score)

输出:

Accuracy Score on Test data  0.9535086370393152

我们的测试数据结果显示模型准确率提高到 95%,比我们先前基于 TF-IDF 和 SVM 的基线提高了 10 个百分点。这些都是使用最先进的语言模型的好处,这很可能是 BERT 在大型语料库上训练的结果。评论内容相当简短,早期的模型只有这些数据来学习关系。另一方面,BERT 是上下文感知的,并且可以将其对评论中单词的先前信息传递出来。通过微调learning_rate等超参数或增加训练轮次,可以提高准确性。由于预训练语言模型的参数数量远远超过我们用于微调的观测数目,因此在此过程中必须小心避免过拟合!

使用保存的模型

如果您单独运行评估,则可以直接加载微调的模型,而无需再次进行训练。请注意,这与最初用于从 transformers 加载预训练模型的相同函数相同,但这次我们使用的是我们自己训练的微调模型。

如您所见,使用预训练语言模型可以提高模型的准确性,但也涉及许多额外步骤,并可能会带来成本,如使用 GPU(在 CPU 上训练一个有用的模型可能需要 50 到 100 倍的时间)。预训练模型非常庞大且不够内存高效。在生产中使用这些模型通常更加复杂,因为加载数百万参数到内存中需要时间,并且它们在实时场景中的推理时间较长。一些像DistilBERTALBERT这样的预训练模型已经专门开发,以在准确性和模型简单性之间取得更有利的权衡。您可以通过重复使用蓝图并更改适当的模型类来轻松尝试此功能,以选择 Transformers 库中提供的distil-bert-uncasedalbert-base-v1模型,以检查准确性。

总结语

在本章中,我们介绍了几种可用于情感分析的蓝图。它们从简单的基于词汇的方法到复杂的最新语言模型。如果您的用例是对特定主题使用 Twitter 数据进行一次性分析以确定情感,则第一个蓝图最合适。如果您希望根据客户评论中表达的情感创建产品/品牌排名或根据情感对客户投诉进行路由,则监督式机器学习方法(如第二和第三个蓝图中描述的方法)更加合适。如果准确性最重要,则使用预训练语言模型可以获得最佳结果,但这也是一种更复杂且昂贵的技术。每个蓝图都适合特定的用例,关键是确定哪种方法适合您的需求。总体而言,您必须找到一种适合您用例的方法,建议始终从简单开始,然后增加复杂性以获得更好的结果。

进一步阅读

^(1) J. McAuley 和 J. Leskovec。“隐藏因素和隐藏主题:理解评论文本中的评分维度。” RecSys,2013. https://snap.stanford.edu/data/web-Amazon.html.

^(2) “德国情感分析兴趣小组,德语多领域情感词典”,https://oreil.ly/WpMhF.

^(3) Yanqing Chen 和 Steven Skiena。为所有主要语言构建情感词典。词典可在Kaggle上获取。

^(4) Ashish Vaswani 等人。“关注就是一切:Attention Is All You Need。” 2017. https://arxiv.org/abs/1706.03762.

^(5) Robin M. Schmidt, Frank Schneider 和 Phillipp Hennig。“穿越拥挤山谷:深度学习优化器基准测试。” 2020. https://arxiv.org/pdf/2007.01547.pdf.

第十二章:构建知识图谱

在本书中,我们一直在探索文本分析的多个蓝图。我们的目标始终是通过统计和机器学习帮助识别数据中的模式。在第十章中,我们解释了如何使用嵌入来回答类似“德国对应巴黎的是什么?”的问题。嵌入表示从训练文档中学习的某种隐含知识,基于相似性的概念。

知识库相反,由“柏林是德国的首都”形式的结构化陈述组成。在这种情况下,“首都”是两个特定实体 柏林德国 之间明确定义的关系。由许多实体及其关系形成的网络在数学意义上是一个图,即知识图谱。图 12-1 展示了一个简单的知识图谱,说明了这个例子。在本章中,我们将介绍从非结构化文本中提取结构化信息并构建基本知识图谱的蓝图。

图 12-1. 一个简单的知识图谱。

What You’ll Learn and What We’ll Build

信息抽取是自然语言处理中最困难的任务之一,因为语言的复杂性和固有歧义性。因此,我们需要应用一系列不同步骤来发现实体和关系。本节中的示例用例是基于公司业务新闻文章创建知识图谱。

在本章中,我们将深入探讨 spaCy 的高级语言处理功能。我们将使用预训练的神经模型结合自定义规则进行命名实体识别、指代消解和关系抽取。我们还将解释执行实体链接的必要步骤,但不会深入到实现细节。

阅读完本章后,你将具备开始构建自己知识库的基本语言和技术知识。你可以在我们的GitHub 仓库找到本章的源代码和额外信息。

知识图谱

知识图谱是一个大型语义网络。它包括节点,如人物、地点、事件或公司,以及代表这些节点之间正式关系的边,如图 12-1 所示。

谷歌、微软、Facebook 等大公司都使用知识图谱来支持他们的搜索引擎和查询服务。¹ 现在,越来越多的公司开始构建自己的知识图谱,以获取市场洞察或为聊天机器人提供支持。但是最大的知识图谱分布在全球各地:Linked Open Data指的是网络上所有可通过统一资源标识符(URI)识别的可用数据。这是在语义网领域经过 20 年学术发展的结果(参见“语义网和 RDF”)。

节点和边的类型由本体精确定义,本体本身是一个领域术语使用的知识库。例如,公共本体 Wikidata 为 Figure 12-1 中使用的所有类型提供了定义。² 每个定义都有一个唯一的 URI(例如,“city”是http://www.wikidata.org/wiki/Q515)。事实上,Wikidata 包含了类型定义和实际对象,以可查询的格式存储。

信息提取

从文本中提取结构化信息需要几个典型步骤,如 Figure 12-2 所示。首先是命名实体识别,找到文本中的命名实体并标记其正确类型,例如,人物、组织或地点。同一实体通常会在文档中被不同变体的名称或代词多次引用。第二步是共指解析,识别和解决这些共指,以防止重复和信息丢失。

与共指解析密切相关,并且通常是下一步,是实体链接的任务。在这里,目标是将文本中的提及链接到本体中的唯一现实世界实体,例如,Berlin链接到 URIhttp://www.wikidata.org/entity/Q64。因此,任何歧义都被消除:Q64 是德国的柏林,而不是新罕布什尔州的柏林(顺便说一下,在 Wikidata 中是 Q821244)。这对于连接不同来源的信息并真正构建知识库至关重要。

图 12-2。信息提取过程。

最后一步是关系抽取,识别这些实体之间的关系。在应用场景中,你通常只会考虑几个感兴趣的关系,因为从任意文本中正确提取这种信息很困难。

最后,你可以将图存储在图数据库中,作为知识型应用程序的后端。这些图数据库将数据存储为 RDF 三元组(三元存储)或属性图形式,其中节点和边可以具有任意属性。常用的图数据库包括 GraphDB(三元存储)、Neo4j 和 Grakn(属性图形式)。

对于每个步骤,您可以选择基于规则的方法或机器学习。我们将使用 spaCy 的现有模型以及规则进行补充。不过,我们不会训练自己的模型。使用规则来提取领域特定知识的优势在于,您可以快速开始,无需训练数据。正如我们将看到的那样,结果允许进行一些非常有趣的分析。但是,如果您计划在大规模上建立企业知识库,您可能需要为命名实体和关系检测以及实体链接训练自己的模型。

引入数据集

假设您在金融业务中工作,并希望跟踪并购新闻。如果您能够自动识别公司名称及其所涉及的交易类型,并将结果存入知识库,那将是很棒的。在本章中,我们将解释有关提取公司信息的构建块。例如,我们将提取关系“公司 1 收购公司 2”。

为了模拟这样的情景,我们使用了一个公开可用的数据集,著名的Reuters-21578新闻语料库。它包含由路透社在 1987 年发布的 90 个类别的超过 20,000 篇新闻文章。选择此数据集是因为它是免费且易于获取的。实际上,它作为 NLTK 标准语料库之一可用,并且您可以简单地使用 NLTK 下载它:

import nltk
nltk.download('reuters')

我们仅处理并购类别(acq)的文章。为了满足我们的目的,我们将所有文章加载到一个DataFrame中,并按照“清理文本数据”中的蓝图进行了一些数据清洗。干净的数据对于识别命名实体和关系至关重要,因为神经模型受益于结构良好的句子。对于这个数据集,我们替换了 HTML 转义字符,删除了股票代码符号,替换了诸如mln代表million的缩写,并纠正了一些拼写错误。我们也放弃了标题,因为它们仅以大写字母编写。但完整的文章内容仍保留下来。所有清洗步骤都可以在GitHub的笔记本中找到。让我们来看一下我们DataFrame中经过清理的文章样本:

USAir Group Inc said a U.S. District Court in Pittsburgh issued a temporary
restraining order to prevent Trans World Airlines Inc from buying additional
USAir shares. USAir said the order was issued in response to its suit, charging
TWA chairman Carl Icahn and TWA violated federal laws and made misleading
statements. TWA last week said it owned 15 % of USAir's shares. It also offered
to buy the company for 52 dollars a share cash or 1.4 billion dollars.

因此,在我们制定信息提取蓝图时,这是我们心目中的数据。但是,以下各节中的大多数句子都是简化的示例,以更好地解释这些概念。

命名实体识别

在数据清洗后,我们可以开始我们信息提取过程的第一步:命名实体识别。命名实体识别在第四章中作为 spaCy 标准流水线的一部分进行了简要介绍。spaCy 是我们在本章中所有蓝图的首选库,因为它快速且具有我们将利用的可扩展 API。但您也可以使用 Stanza 或 Flair(参见“NER 的替代方案:Stanza 和 Flair”)。

spaCy 为许多语言提供了经过训练的 NER 模型。英语模型是在包含 18 种不同实体类型的大型OntoNotes5 语料库上训练的。表 12-1 列出了这些类型的一个子集。其余类型适用于数值实体。

表 12-1. OntoNotes 5 语料库的部分 NER 类型

NER 类型 描述 NER 类型 描述
PERSON 人物,包括虚构的 PRODUCT 车辆、武器、食品等(不包括服务)
NORP 国籍或宗教或政治团体 EVENT 具名飓风、战役、战争、体育赛事等
FAC 设施:建筑物、机场、高速公路、桥梁等 WORK_OF_ART 书籍、歌曲等的标题
ORG 组织:公司、机构等 LAW 公布为法律的具名文件
GPE 国家、城市、州 LANGUAGE 任何具名语言
LOCATION 非 GPE 位置、山脉、水体

默认情况下,加载语言模型时会启用 NER 标记器。我们首先通过使用标准(小型)英语模型 en_core_web_sm 初始化一个 nlp 对象,并打印 NLP 流水线的组件:^(4)

nlp = spacy.load('en_core_web_sm')
print(*nlp.pipeline, sep='\n')

输出:

('tagger', <spacy.pipeline.pipes.Tagger object at 0x7f98ac6443a0>)
('parser', <spacy.pipeline.pipes.DependencyParser object at 0x7f98ac7a07c0>)
('ner', <spacy.pipeline.pipes.EntityRecognizer object at 0x7f98ac7a0760>)

处理文本后,我们可以直接通过 doc.ents 访问命名实体。每个实体都有一个文本和描述实体类型的标签。这些属性在下面代码的最后一行用于打印在文本中识别的实体列表:

text = """Hughes Tool Co Chairman W.A. Kistler said its merger with
Baker International Corp was still under consideration.
We hope to come soon to a mutual agreement, Kistler said.
The directors of Baker filed a law suit in Texas to force Hughes
to complete the merger."""
doc = nlp(text)

print(*[(e.text, e.label_) for e in doc.ents], sep=' ')

输出:

(Hughes Tool Co, ORG) (W.A. Kistler, PERSON) (Baker International Corp, ORG)
(Kistler, ORG) (Baker, PERSON) (Texas, GPE) (Hughes, ORG)

利用 spaCy 的漂亮的可视化模块 displacy,我们可以生成句子及其命名实体的视觉表示。这对检查结果非常有帮助:

from spacy import displacy
displacy.render(doc, style='ent')

输出:

总体来说,spaCy 的命名实体识别器表现很好。在我们的例子中,它能够检测到所有命名实体。然而,第二句和第三句中 KistlerBaker 的标签并不正确。事实上,对于 NER 模型来说,区分人物和组织是一个挑战,因为这些实体类型的使用方式非常相似。我们将在后面的蓝图中解决这类问题,以进行基于名称的共指消解。

蓝图:使用基于规则的命名实体识别

如果您希望识别模型未经训练的领域特定实体,您当然可以使用 spaCy 自行训练您的模型。但训练模型需要大量的训练数据。通常,为自定义实体类型指定简单规则就足够了。在本节中,我们将展示如何使用规则来检测像“司法部”(或者“Justice Department”)这样的政府组织在 Reuters 数据集中的方法。

spaCy 为此提供了一个EntityRuler,这是一个流水线组件,可以与或者代替统计命名实体识别器一起使用。与正则表达式搜索相比,spaCy 的匹配引擎更强大,因为模式是在 spaCy 的标记序列上定义的,而不仅仅是字符串。因此,您可以使用任何标记属性,如词形或词性标签来构建您的模式。

所以,让我们定义一些模式规则,以匹配美国政府的部门和经常在我们的语料库中提到的证券交易委员会

from spacy.pipeline import EntityRuler

departments = ['Justice', 'Transportation']
patterns = [{"label": "GOV",
             "pattern": [{"TEXT": "U.S.", "OP": "?"},
                         {"TEXT": "Department"}, {"TEXT": "of"},
                         {"TEXT": {"IN": departments}, "ENT_TYPE": "ORG"}]},
             {"label": "GOV",
              "pattern": [{"TEXT": "U.S.", "OP": "?"},
                          {"TEXT": {"IN": departments}, "ENT_TYPE": "ORG"},
                          {"TEXT": "Department"}]},
             {"label": "GOV",
              "pattern": [{"TEXT": "Securities"}, {"TEXT": "and"},
                          {"TEXT": "Exchange"}, {"TEXT": "Commission"}]}]

每条规则由一个带有标签的字典组成,在我们的案例中是自定义实体类型GOV,以及令牌序列必须匹配的模式。您可以为同一标签指定多个规则,就像我们在这里所做的一样。^(5) 例如,第一条规则匹配带有文本"U.S."(可选,用"OP": "?"表示)、"Department""of""Justice""Transportation"的令牌序列。请注意,这些规则会对已识别出的类型ORG的实体进行进一步的细化。因此,这些模式必须在 spaCy 的命名实体模型之上而不是代替它应用。

基于这些模式,我们创建了一个EntityRuler并将其添加到我们的流水线中:

entity_ruler = EntityRuler(nlp, patterns=patterns, overwrite_ents=True)
nlp.add_pipe(entity_ruler)

现在,当我们调用nlp时,这些组织将自动用新类型GOV标记:

text = """Justice Department is an alias for the U.S. Department of Justice.
Department of Transportation and the Securities and Exchange Commission
are government organisations, but the Sales Department is not."""

doc = nlp(text)
displacy.render(doc, style='ent')

输出:

蓝图:规范化命名实体

简化不同实体提及到单一名称的解析的一种方法是规范化或标准化提及。在这里,我们将进行第一次规范化,这通常是有帮助的:移除不具体的后缀和前缀。看看这个例子:

text = "Baker International's shares climbed on the New York Stock Exchange."

doc = nlp(text)
print(*[([t.text for t in e], e.label_) for e in doc.ents], sep='\n')

输出:

(['Baker', 'International', "'s"], 'ORG')
(['the', 'New', 'York', 'Stock', 'Exchange'], 'ORG')

在第一句中,尽管所有格-s 不是公司名称的一部分,令牌序列Baker International's被检测为一个实体。类似的情况是《纽约证券交易所》中的文章。无论文章实际上是否是名称的一部分,实体有时会在提及时带有文章,有时则没有。因此,通常移除文章和所有格-apostrophe-s 简化了提及的链接。

警告

如同任何规则一样,存在着错误的可能性:想象一下《华尔街日报》麦当劳。如果你需要保留这些情况下的冠词或者所有格-apostrophe,你必须为规则定义异常。

我们的蓝图函数展示了如何在 spaCy 中实现诸如移除前导冠词和尾随所有格-apostrophe-s 等规范化。由于我们不允许直接更新实体,我们创建了实体的副本,并将修改应用于该副本:

from spacy.tokens import Span

def norm_entities(doc):
    ents = []
    for ent in doc.ents:
        if ent[0].pos_ == "DET": # leading article
            ent = Span(doc, ent.start+1, ent.end, label=ent.label)
        if ent[-1].pos_ == "PART": # trailing particle like 's
            ent = Span(doc, ent.start, ent.end-1, label=ent.label)
        ents.append(ent)
    doc.ents = tuple(ents)
    return doc

在 spaCy 中,实体是具有定义的开始和结束以及额外标签的Span对象。我们循环遍历实体,并根据需要调整实体的第一个和最后一个标记的位置。最后,我们用修改后的副本替换doc.ents

该函数以一个 spaCy 的Doc对象(命名为doc)作为参数,并返回一个Doc。因此,我们可以将其用作另一个管道组件,简单地将其添加到现有管道中:

nlp.add_pipe(norm_entities)

现在我们可以对示例句子重复这个过程,并检查结果:

doc = nlp(text)
print(*[([t.text for t in e], e.label_) for e in doc.ents], sep='\n')

Out:

(['Baker', 'International'], 'ORG')
(['New', 'York', 'Stock', 'Exchange'], 'ORG')

合并实体标记

在许多情况下,将像前面示例中的复合名称视为单个标记是有意义的,因为它简化了句子结构。spaCy 提供了一个内置的管道函数merge_entities来实现这一目的。我们将其添加到我们的 NLP 管道中,确保每个命名实体正好只有一个标记:

from spacy.pipeline import merge_entities
nlp.add_pipe(merge_entities)

doc = nlp(text)
print(*[(t.text, t.ent_type_) for t in doc if t.ent_type_ != ''])

Out:

('Baker International', 'ORG') ('New York Stock Exchange', 'ORG')

即使合并实体在本章后期简化了我们的蓝图,这并不总是一个好主意。例如,考虑像伦敦证券交易所这样的复合实体名称。将其合并为单个标记后,这个实体与伦敦市的隐含关系将会丢失。

共指消解

在信息提取中最大的障碍之一是实体提及出现在许多不同的拼写形式中(也称为表面形式)。看看以下句子:

休斯工具公司主席 W.A.基斯勒表示其与贝克国际公司的合并仍在考虑中。基斯勒表示希望达成一致意见。贝克将迫使休斯完成合并。美国司法部的审查今天已经完成。司法部将在与证券交易委员会磋商后阻止这一合并。

如我们所见,实体通常以其全名引入,而后续提及则使用缩写版本。这是必须解决的一种共指类型,以理解正在发生的情况。图 12-3 显示了一个无(左)和有(右)统一名称的共现图。这样的共现图将在下一节中构建,是显示出现在同一文章中的实体对的可视化。

图 12-3. 同一文章的共现图在核心引用解析前(左)和后(右)的对比。

共指消解 是确定单个文本中实体不同提及的任务,例如缩写名称、别名或代词。这一步骤的结果是一组共指提及,称为提及簇,例如 {休斯工具公司, 休斯, 其}。本节的目标是识别相关的提及并在文档中进行链接。

为了这个目的,我们为指代消解和名称统一开发了几个蓝图(见图 12-4)。我们将限制自己只处理组织和个人,因为这些是我们感兴趣的实体类型。首先,我们将通过字典查找解析像SEC这样的别名。然后我们将在文档中匹配名称到第一次提及。例如,我们将从“Kistler”创建到“W.A. Kistler”的链接。之后,间接指代(回指)如第一句中的代词its将被解决。最后,我们将再次规范化已解析实体的名称。所有这些步骤将作为附加的管道函数实现。

图 12-4. 命名实体识别和指代消解的流程图。

实体链接更进一步。这里的实体提及在语义级别上被消歧,并链接到现有知识库中的唯一条目。因为实体链接本身是一项具有挑战性的任务,我们不会提供其蓝图,而只是在本节末讨论它。

蓝图:使用 spaCy 的标记扩展

我们需要一种技术上的方法,从不同实体的各个提及创建到主参照(referent)的链接。在核心 ference 解决后,例如文章示例中的“Kistler”的标记应指向“(W.A. Kistler, PERSON)”。spaCy 的扩展机制允许我们定义自定义属性,这是将此类信息与标记一起存储的完美方法。因此,我们创建了两个标记扩展ref_n(参照名称)和ref_t(参照类型)。这些属性将为每个标记初始化为 spaCy 指定的默认值:

from spacy.tokens import Token
Token.set_extension('ref_n', default='')
Token.set_extension('ref_t', default='')

下一个展示的init_coref函数确保每个类型为ORGGOVPERSON的实体提供一个初始参照。这种初始化对于接下来的功能是必需的:

def init_coref(doc):
    for e in doc.ents:
        if e.label_ in ['ORG', 'GOV', 'PERSON']:
            e[0]._.ref_n, e[0]._.ref_t = e.text, e.label_
    return doc

自定义属性通过标记的下划线属性访问。请注意,在merge_entities之后,每个实体提及e由一个单一标记e[0]组成,我们在其中设置了这些属性。我们也可以在实体跨度而不是标记上定义这些属性,但我们希望稍后对代词解析使用相同的机制。

蓝图:执行别名解析

我们首先的目标是解决众所周知的领域别名,比如Transportation Department代表“美国交通部”,以及像 SEC 或 TWA 这样的缩写词。解决这类别名的简单方法是使用查找字典。我们为 Reuters 语料库中的所有缩写词和一些常见别名准备了这样一个字典,并将其作为本章蓝图模块的一部分提供。^(6) 这里是一些示例查找:

from blueprints.knowledge import alias_lookup

for token in ['Transportation Department', 'DOT', 'SEC', 'TWA']:
    print(token, ':', alias_lookup[token])

Out:

Transportation Department : ('U.S. Department of Transportation', 'GOV')
DOT : ('U.S. Department of Transportation', 'GOV')
SEC : ('Securities and Exchange Commission', 'GOV')
TWA : ('Trans World Airlines Inc', 'ORG')

每个令牌别名都映射到一个元组,包括实体名称和类型。下面显示的函数alias_resolver检查实体文本是否在字典中找到。如果是,将更新其ref属性为查找到的值:

def alias_resolver(doc):
    """Lookup aliases and store result in ref_t, ref_n"""
    for ent in doc.ents:
        token = ent[0].text
        if token in alias_lookup:
            a_name, a_type = alias_lookup[token]
            ent[0]._.ref_n, ent[0]._.ref_t = a_name, a_type
    return propagate_ent_type(doc)

解决了别名后,我们还可以纠正命名实体类型,以防其被错误标识。这是通过函数propagate_ent_type完成的。它更新所有已解析的别名,并将在下一个基于名称的指代消解蓝图中使用:

def propagate_ent_type(doc):
    """propagate entity type stored in ref_t"""
    ents = []
    for e in doc.ents:
        if e[0]._.ref_n != '': # if e is a coreference
            e = Span(doc, e.start, e.end, label=e[0]._.ref_t)
        ents.append(e)
    doc.ents = tuple(ents)
    return doc

现在,我们将alias_resolver添加到我们的流水线中:

nlp.add_pipe(alias_resolver)

现在我们可以检查结果。为此,我们提供的蓝图包含一个实用函数display_ner,用于为doc对象中的标记创建一个DataFrame,并包括本章相关属性:

from blueprints.knowledge import display_ner
text = """The deal of Trans World Airlines is under investigation by the
U.S. Department of Transportation.
The Transportation Department will block the deal of TWA."""
doc = nlp(text)
display_ner(doc).query("ref_n != ''")[['text', 'ent_type', 'ref_n', 'ref_t']]

输出:

文本 实体类型 参考编号 参考类型
3 美国国际航空公司 ORG 美国国际航空公司 ORG
9 美国交通部 GOV 美国交通部 GOV
12 交通部 GOV 美国交通部 GOV
18 TWA ORG 美国国际航空公司 ORG

蓝图:解决名称变体

别名解析仅在别名在前期已知的情况下有效。但是由于文章中几乎任何名称都可能存在变体,因此构建所有这些名称的词典是不可行的。再次看一下我们介绍示例中第一句中识别的命名实体:

在这里,您会找到“Kistler”的指代“W.A. Kistler(PERSON)”,“Baker”的指代“Baker International Corp(ORG)”,以及“休斯”的指代“休斯工具公司(ORG)”。正如您所看到的,缩写的公司名称经常被误认为是人物,特别是在以模拟形式使用时。在这个蓝图中,我们将解决这些指代,并为每个提及分配正确的实体类型。

为此,我们将利用新闻文章中的一个常见模式。实体通常首先以其全名介绍,后续提及使用缩写版本。因此,我们将通过将名称与实体的第一次提及匹配来解决次要引用。当然,这是一个启发式规则,可能会产生错误的匹配。例如,休斯也可能指同一篇文章中的公司以及传奇企业家霍华德·休斯(确实是休斯工具公司的创始人)。但这类情况在我们的数据集中很少见,我们决定在正确的启发式案例中接受这种不确定性。

我们为名称匹配定义了一个简单的规则:如果所有单词按相同顺序出现在主要提及中,次要提及就匹配主要提及。为了检查这一点,下一个显示的函数name_match将次要提及m2转换为正则表达式,并在主要提及m1中搜索匹配项:

def name_match(m1, m2):
    m2 = re.sub(r'[()\.]', '', m2) # ignore parentheses and dots
    m2 = r'\b' + m2 + r'\b' # \b marks word boundary
    m2 = re.sub(r'\s+', r'\\b.*\\b', m2)
    return re.search(m2, m1, flags=re.I) is not None

例如,Hughes Co.的次要提及会被转换为'\bHughes\b.*\bCo\b',这与 Hughes Tool Co 匹配。\b确保只匹配整个单词,而不是子词如Hugh

基于此匹配逻辑,下面展示的name_resolver函数实现了基于名称的组织和个人共指解析:

def name_resolver(doc):
    """create name-based reference to e1 as primary mention of e2"""
    ents = [e for e in doc.ents if e.label_ in ['ORG', 'PERSON']]
    for i, e1 in enumerate(ents):
        for e2 in ents[i+1:]:
            if name_match(e1[0]._.ref_n, e2[0].text):
                e2[0]._.ref_n = e1[0]._.ref_n
                e2[0]._.ref_t = e1[0]._.ref_t
    return propagate_ent_type(doc)

首先,我们创建一个所有组织和个人实体的列表。然后,将实体e1e2的所有对比较。该逻辑确保实体e1在文档中始终出现在e2之前。如果e2匹配e1,其指示物将自动传播到其后续共指。

我们将此函数添加到nlp流程中,并检查结果:

nlp.add_pipe(name_resolver)

doc = nlp(text)
displacy.render(doc, style='ent')

Out:

现在我们的示例中每个命名实体都具有正确的类型。我们还可以检查实体是否映射到其第一次提及:

display_ner(doc).query("ref_n != ''")[['text', 'ent_type', 'ref_n', 'ref_t']]

Out:

text ent_type ref_n ref_t
0 Hughes Tool Co ORG Hughes Tool Co ORG
2 W.A. Kistler PERSON W.A. Kistler PERSON
7 Baker International Corp. ORG Baker International Corp. ORG
22 Kistler PERSON W.A. Kistler PERSON
25 Baker ORG Baker International Corp. ORG
28 Hughes ORG Hughes Tool Co ORG

蓝图:使用 NeuralCoref 进行指代消解

在语言学中,指代是依赖于前文的词语。考虑我们示例句子的这种变化:

text = """Hughes Tool Co said its merger with Baker
was still under consideration. Hughes had a board meeting today.
W.A. Kistler mentioned that the company hopes for a mutual agreement.
He is reasonably confident."""

这里的公司是指代词。来自 Hugging Face 的NeuralCoref是一个解决这类共指的库。该算法结合基于词嵌入的特征向量(参见第十章),使用两个神经网络识别共指簇及其主要提及物。

NeuralCoref 作为 spaCy 的流水线扩展实现,因此完美地适合我们的流程。我们使用greedyness值为 0.45 创建神经共指解析器,并将其添加到我们的流水线中。greedyness控制模型的敏感性,在一些实验后,我们决定选择比默认值 0.5 稍微严格一些的值(更高的准确性,较低的召回率):

from neuralcoref import NeuralCoref
neural_coref = NeuralCoref(nlp.vocab, greedyness=0.45)
nlp.add_pipe(neural_coref, name='neural_coref')

NeuralCoref 还利用 spaCy 的扩展机制向DocSpanToken对象添加自定义属性。处理文本时,我们可以通过doc._.coref_clusters属性访问检测到的共指簇。在我们的示例中,已经识别出三个这样的簇:

doc = nlp(text)
print(*doc._.coref_clusters, sep='\n')

Out:

Hughes Tool Co: [Hughes Tool Co, its]
Hughes: [Hughes, the company]
W.A. Kistler: [W.A. Kistler, He]

NeuralCoref 在 Span 对象(令牌序列)上工作,因为一般的共指不仅限于命名实体。因此,蓝图函数 anaphor_coref 为每个令牌检索第一个共指集群,并搜索具有其 ref_n 属性值的第一个命名实体。在我们的案例中,这只会是组织和人物。一旦找到,它将把代词令牌的 ref_nref_t 值设置为主参考中的相同值:

def anaphor_coref(doc):
    """anaphora resolution"""
    for token in doc:
        # if token is coref and not already dereferenced
        if token._.in_coref and token._.ref_n == '':
            ref_span = token._.coref_clusters[0].main # get referred span
            if len(ref_span) <= 3: # consider only short spans
                for ref in ref_span: # find first dereferenced entity
                    if ref._.ref_n != '':
                        token._.ref_n = ref._.ref_n
                        token._.ref_t = ref._.ref_t
                        break
    return doc

再次,我们将这个解析器加入到我们的流水线中并检查结果:

nlp.add_pipe(anaphor_coref)
doc = nlp(text)
display_ner(doc).query("ref_n != ''") \
  [['text', 'ent_type', 'main_coref', 'ref_n', 'ref_t']]

Out:

文本 实体类型 主共指 ref_n ref_t
0 Hughes Tool Co 组织 Hughes Tool Co Hughes Tool Co 组织
2 Hughes Tool Co Hughes Tool Co 组织
5 Baker 人物 None Baker 人物
11 Hughes 组织 Hughes Hughes Tool Co 组织
18 W.A. Kistler 人物 W.A. Kistler W.A. Kistler 人物
21 the Hughes Hughes Tool Co 组织
22 公司 Hughes Hughes Tool Co 组织
29 He W.A. Kistler W.A. Kistler 人物

现在我们的流水线包括图示 12-4 中显示的所有步骤。

警告

警惕长时间运行时间!NeuralCoref 将总体处理时间增加了 5–10 倍。因此,您应该仅在必要时使用指代消解。

名称规范化

尽管我们的名称解析在文章中统一了公司提及,但是公司名称在文章之间仍然不一致。在一篇文章中我们会看到 Hughes Tool Co.,而在另一篇文章中我们会看到 Hughes Tool。实体链接器可以用来将不同的实体提及链接到唯一的规范表示,但在没有实体链接器的情况下,我们将使用(解析后的)名称实体作为其唯一标识符。由于前面的共指解析步骤,解析后的名称总是文章中第一个,因此通常也是最完整的提及。因此,错误的可能性并不大。

不过,我们必须通过去除诸如 Co.Inc. 这样的法律后缀来协调公司提及。以下函数使用正则表达式来实现此目标:

def strip_legal_suffix(text):
    return re.sub(r'(\s+and)?(\s+|\b(Co|Corp|Inc|Plc|Ltd)\b\.?)*$', '', text)

print(strip_legal_suffix('Hughes Tool Co'))

Out:

Hughes Tool

最后的流水线函数 norm_names 将最终的规范化应用于存储在 ref_n 属性中的每个共指解析后的组织名称。请注意,使用这种方法,Hughes (人物)Hughes (组织) 仍然会保持分开的实体。

def norm_names(doc):
    for t in doc:
        if t._.ref_n != '' and t._.ref_t in ['ORG']:
            t._.ref_n = strip_legal_suffix(t._.ref_n)
            if t._.ref_n == '':
                t._.ref_t = ''
    return doc

nlp.add_pipe(norm_names)

有时,命名实体识别器会错误地将法律后缀(例如 Co.Inc.)单独分类为命名实体。如果这样的实体名称被剥离成空字符串,我们只需忽略它以便稍后处理。

实体链接

在前面的章节中,我们开发了一个操作流程,其目的是统一命名实体的不同提及。但所有这些都是基于字符串的,除了语法表示之外,我们没有将“美国司法部”这样的字符串与所代表的现实世界实体联系起来。相比之下,实体链接器的任务是全局解析命名实体,并将它们链接到唯一标识的现实世界实体。实体链接从“字符串到实体”的转换。^(8)

从技术上讲,这意味着每个提及都映射到一个 URI。URI 又可以用来指代现有知识库中的实体。这可以是公共本体,例如 Wikidata 或 DBpedia,也可以是公司内部的私有知识库。URI 可以是 URL(例如网页),但不一定要是。例如,美国司法部在 Wikidata 有一个 URI http://www.wikidata.org/entity/Q1553390,这也是一个包含有关该实体信息的网页。如果您构建自己的知识库,则不需要为每个 URI 创建网页;它们只需要是唯一的。顺便说一下,DBpedia 和 Wikidata 使用不同的 URI,但您将在 DBpedia 上找到对 Wikidata URI 的交叉引用。两者当然都包含指向维基百科网页的链接。

如果一个实体通过完全限定名称(如“美国司法部”)提及,实体链接就很简单。但是,“司法部”而没有“美国”就已经相当模糊,因为许多州都有“司法部”。实际意义取决于上下文,实体链接器的任务是根据上下文敏感地将这种模糊提及映射到正确的 URI。这是一个相当大的挑战,仍然是持续研究的领域。在商业项目中进行实体链接的常见解决方案是使用公共服务(参见“实体链接服务”)。

或者,您可以创建自己的实体链接器。一个简单的解决方案是基于名称的查找字典。但这种方法不考虑上下文,并且无法解决不同实体的名称歧义。为此,您需要更复杂的方法。最先进的解决方案使用嵌入和神经模型进行实体链接。例如,spaCy 还提供了这样的实体链接功能。要使用 spaCy 的实体链接器,首先必须为您指定的描述创建嵌入(参见第十章),从而捕获其语义。然后,您可以训练模型,学习将提及映射到正确 URI 的上下文敏感映射。然而,实体链接器的设置和训练超出了本章的范围。

蓝图:创建共现图

在前面的部分,我们花了很多精力来规范命名实体并至少解析文档内的核心参考。现在我们终于准备好分析实体对之间的第一个关系了:它们在文章中的共同提及。为此,我们将创建一个共现图,这是知识图的最简形式。共现图中的节点是实体,例如组织。如果两个实体在相同的上下文中提及,例如在一篇文章、一个段落或一个句子中,它们之间就会共享一个(无向)边。

图 12-5 显示了路透社语料库中一起提及的公司的共现图的部分。边的宽度可视化了共现频率。模块性,这是一种用于识别网络中紧密相关的群体或社群的结构性指标,被用来着色节点和边。^(9)

图 12-5. 从路透社语料库生成的共现图的最大连通分量。

当然,我们不知道这里的关系类型。实际上,两个实体的共同提及只是表明可能存在一些关系。除非我们真的分析句子,否则我们无法确定。但是,即使简单探索共现也可能有所启示。例如,图 12-5 中的中心节点是“证券交易委员会”,因为它在许多文章中与许多其他实体一起提及。显然,该实体在并购中扮演重要角色。不同的集群给我们留下了一些关于涉及特定交易的公司(或社群)的印象。

要绘制共现图,我们必须从文档中提取实体对。对于涵盖多个主题领域的较长文章,最好在段落甚至句子内搜索共现。但是涉及并购的路透社文章非常专注,所以我们在这里坚持使用文档级别。让我们简要地走一遍提取和可视化共现的过程。

从文档中提取共现

函数 extract_coocs 返回给定 Doc 对象中指定类型的实体对列表:

from itertools import combinations

def extract_coocs(doc, include_types):
    ents = set([(e[0]._.ref_n, e[0]._.ref_t)
                for e in doc.ents if e[0]._.ref_t in include_types])
    yield from combinations(sorted(ents), 2)

我们首先创建一个核心解析实体名称和类型的集合。有了这个,我们使用 Python 标准库 itertools 中的 combinations 函数来创建所有实体对。每对都按字典顺序排序(sorted(ents)),以防止重复条目,比如 “(Baker, Hughes)” 和 “(Hughes, Baker)” 的出现。

为了高效处理整个数据集,我们再次使用 spaCy 的流式处理,通过调用 nlp.pipe(在第四章介绍过)。由于我们不需要在文档中解析指代关系以找出文档内的共现,因此在这里禁用了相应的组件:

batch_size = 100

coocs = []
for i in range(0, len(df), batch_size):
    docs = nlp.pipe(df['text'][i:i+batch_size],
                    disable=['neural_coref', 'anaphor_coref'])
    for j, doc in enumerate(docs):
        coocs.extend([(df.index[i+j], *c)
                      for c in extract_coocs(doc, ['ORG', 'GOV'])])

让我们看一下第一篇文章识别出的共现:

print(*coocs[:3], sep='\n')

输出:

(10, ('Computer Terminal Systems', 'ORG'), ('Sedio N.V.', 'ORG'))
(10, ('Computer Terminal Systems', 'ORG'), ('Woodco', 'ORG'))
(10, ('Sedio N.V.', 'ORG'), ('Woodco', 'ORG'))

在信息提取中,始终建议具有某种可追溯性,以便在出现问题时识别信息的来源。因此,我们保留了文章的索引,这在我们的情况下是 Reuters 语料库的文件 ID,与每个共现元组(这里是 ID 10)一起。根据这个列表,我们生成了一个DataFrame,每个实体组合有一个条目,其频率和找到这个共现的文章 ID(限制为五个)。

coocs = [([id], *e1, *e2) for (id, e1, e2) in coocs]
cooc_df = pd.DataFrame.from_records(coocs,
             columns=('article_id', 'ent1', 'type1', 'ent2', 'type2'))
cooc_df = cooc_df.groupby(['ent1', 'type1', 'ent2', 'type2'])['article_id'] \
                 .agg(['count', 'sum']) \
                 .rename(columns={'count': 'freq', 'sum': 'articles'}) \
                 .reset_index().sort_values('freq', ascending=False)
cooc_df['articles'] = cooc_df['articles'].map(
                        lambda lst: ','.join([str(a) for a in lst[:5]]))

这里是我们在语料库中发现的三对最频繁的实体:

cooc_df.head(3)

输出:

ent1 type1 ent2 type2 freq articles
12667 美国世界航空公司 ORG USAir 集团 ORG 22 1735,1771,1836,1862,1996
5321 单眼巨人 ORG 迪克森斯集团 ORG 21 4303,4933,6093,6402,7110
12731 美国交通部 GOV USAir 集团 ORG 20 1735,1996,2128,2546,2799

使用 Gephi 可视化图形

实际上,这个DataFrame已经代表了我们图形的边缘列表。对于可视化,我们更喜欢图表,这是一个用于图形分析的开源工具。因为它是交互式的,所以比 Python 的图形库 NetworkX 要好得多。^(10) 为了使用 Gephi,我们需要将图的节点和边的列表保存为 Graph Exchange XML 格式。幸运的是,NetworkX 提供了一个将图导出为这种格式的函数。因此,我们可以简单地将我们的DataFrame转换为 NetworkX 图,并将其保存为.gexf文件。我们舍弃了罕见的实体对,以保持图的紧凑性,并重新命名了频率列,因为 Gephi 会自动使用weight属性来调整边的宽度。

import networkx as nx

graph = nx.from_pandas_edgelist(
           cooc_df[['ent1', 'ent2', 'articles', 'freq']] \
           .query('freq > 3').rename(columns={'freq': 'weight'}),
           source='ent1', target='ent2', edge_attr=True)

nx.readwrite.write_gexf(graph, 'cooc.gexf', encoding='utf-8',
                        prettyprint=True, version='1.2draft')

将文件导入 Gephi 后,我们仅选择了最大的组件(连接的子图),并手动删除了一些只有少数连接的节点,以清晰起见。^(11) 结果呈现在图 12-5 中。

注意

有时,最有趣的关系是不频繁的关系。例如,考虑一下即将发生的合并的第一次公告,或者过去提到过但后来被遗忘的令人惊讶的关系。先前无关的实体的突然共现可能是开始对关系进行更深入分析的信号。

关系提取

即使共现图已经为我们提供了关于公司网络的一些有趣见解,但它并没有告诉我们关系的类型。例如,考虑图中左下角由 Schlumberger、Fairchild Semiconductor 和 Fujitsu 公司组成的子图。到目前为止,我们对这些公司之间的关系一无所知;这些信息仍然隐藏在这样的句子中:

富士通希望扩展。它计划收购施伦贝尔格的工业单元傲胜公司 80%的股份。

在本节中,我们将介绍基于模式的关系抽取的两个蓝图。第一个更简单的蓝图搜索形式为“主语-谓语-宾语”的标记短语。第二个使用句子的语法结构——依赖树来以更复杂的规则获取更精确的结果。最终,我们将生成一个基于四种关系(acquiressellssubsidiary-ofchairperson-of)的知识图谱。说实话,我们将使用较为宽松的acquiressells定义,这样更容易识别。它们也会匹配句子如“富士通计划收购傲胜公司 80%股权”甚至“富士通撤回了收购傲胜公司的选项”。

关系抽取是一个复杂的问题,因为自然语言的歧义性和不同种类及变化的关系。基于模型的关系抽取方法是当前研究的一个热门话题。^(12) 还有一些公开可用的训练数据集,如FewRel。然而,训练一个模型来识别关系仍然主要停留在研究阶段,超出了本书的范围。

蓝图:使用短语匹配提取关系

第一个蓝图类似于基于规则的实体识别:它试图根据标记序列的模式识别关系。让我们从一个简化版本的介绍性例子开始解释这种方法。

text = """Fujitsu plans to acquire 80% of Fairchild Corp, an industrial unit
of Schlumberger."""

我们可以通过搜索以下模式来找到这个句子中的关系:

ORG {optional words, not ORG} acquire {optional words, not ORG} ORG
ORG {optional words, not ORG} unit of {optional words, not ORG} ORG 

spaCy 的基于规则的匹配器允许我们找到不仅涉及文本标记而且包括它们属性(如词形或词性)的模式。要使用它,我们必须首先定义一个匹配器对象。然后,我们可以向匹配器添加带有标记模式的规则:

from spacy.matcher import Matcher

matcher = Matcher(nlp.vocab)

acq_synonyms = ['acquire', 'buy', 'purchase']
pattern = [{'_': {'ref_t': 'ORG'}}, # subject
           {'_': {'ref_t': {'NOT_IN': ['ORG']}}, 'OP': '*'},
           {'POS': 'VERB', 'LEMMA': {'IN': acq_synonyms}},
           {'_': {'ref_t': {'NOT_IN': ['ORG']}}, 'OP': '*'},
           {'_': {'ref_t': 'ORG'}}] # object
matcher.add('acquires', None, pattern)

subs_synonyms = ['subsidiary', 'unit']
pattern = [{'_': {'ref_t': 'ORG'}}, # subject
           {'_': {'ref_t': {'NOT_IN': ['ORG']}},
            'POS': {'NOT_IN': ['VERB']}, 'OP': '*'},
           {'LOWER': {'IN': subs_synonyms}}, {'TEXT': 'of'},
           {'_': {'ref_t': {'NOT_IN': ['ORG']}},
            'POS': {'NOT_IN': ['VERB']}, 'OP': '*'},
           {'_': {'ref_t': 'ORG'}}] # object
matcher.add('subsidiary-of', None, pattern)

第一个模式是针对acquires关系的。它返回所有由组织名称组成的跨度,后跟任意不是组织的标记,匹配几个acquire的同义词的动词,再次是任意标记,最后是第二个组织名称。subsidiary-of的第二个模式工作方式类似。

当然,这些表达式很难阅读。一个原因是我们使用了自定义属性ref_t而不是标准的ENT_TYPE。这是为了匹配没有标记为实体的指代词,例如代词。另一个原因是我们包含了一些NOT_IN子句。这是因为带有星号操作符(*)的规则总是危险的,因为它们搜索长度不受限制的模式。对标记的附加条件可以减少假匹配的风险。例如,我们希望匹配“施卢姆伯格的工业部门费尔德,但不是“富士通提到了施卢姆伯格的一个部门。”在开发规则时,您总是需要通过复杂性来换取精确性。我们将在一分钟内讨论acquires关系的问题。

现在蓝图功能extract_rel_match接收处理过的Doc对象和匹配器,并将所有匹配转换为主谓宾三元组:

def extract_rel_match(doc, matcher):
    for sent in doc.sents:
        for match_id, start, end in matcher(sent):
            span = sent[start:end]  # matched span
            pred = nlp.vocab.strings[match_id] # rule name
            subj, obj = span[0], span[-1]
            if pred.startswith('rev-'): # reversed relation
                subj, obj = obj, subj
                pred = pred[4:]
            yield ((subj._.ref_n, subj._.ref_t), pred,
                   (obj._.ref_n, obj._.ref_t))

谓词由规则名称确定;所涉及的实体仅是匹配跨度的第一个和最后一个标记。我们限制搜索到句子级别,因为在整个文档中,我们可能会发现跨多个句子的假阳性的高风险。

通常,规则按“主谓宾”的顺序匹配,但实体在文本中经常以相反的顺序出现,就像“施卢姆伯格的部门费尔德公司”。在这种情况下,关于subsidiary-of关系的实体顺序是“宾-谓-主”。extract_rel_match已经准备好处理这种情况,并在规则具有rev-前缀时切换主体和客体:

pattern = [{'_': {'ref_t': 'ORG'}}, # subject
           {'LOWER': {'IN': subs_synonyms}}, # predicate
           {'_': {'ref_t': 'ORG'}}] # object
matcher.add('rev-subsidiary-of', None, pattern)

现在我们能够检测到句子中的acquiressubsidiary-of的两种变体:

text = """Fujitsu plans to acquire 80% of Fairchild Corp, an industrial unit
of Schlumberger. The Schlumberger unit Fairchild Corp received an offer."""
doc = nlp(text)
print(*extract_rel_match(doc, matcher), sep='\n')

Out:

(('Fujitsu', 'ORG'), 'acquires', ('Fairchild', 'ORG'))
(('Fairchild', 'ORG'), 'subsidiary-of', ('Schlumberger', 'ORG'))
(('Fairchild', 'ORG'), 'subsidiary-of', ('Schlumberger', 'ORG'))

虽然这些规则在我们的例子中运行良好,但acquires的规则并不是很可靠。动词acquire可以出现在许多不同的实体组合中。因此,存在诸如以下这种的假阳性的高概率匹配:

text = "Fairchild Corp was acquired by Fujitsu."
print(*extract_rel_match(nlp(text), matcher), sep='\n')

Out:

(('Fairchild', 'ORG'), 'acquires', ('Fujitsu', 'ORG'))

或者这样一个:

text = "Fujitsu, a competitor of NEC, acquired Fairchild Corp."
print(*extract_rel_match(nlp(text), matcher), sep='\n')

Out:

(('NEC', 'ORG'), 'acquires', ('Fairchild', 'ORG'))

显然,我们的规则并不适用于被动从句(“被...收购”),在这种情况下,主语和宾语交换位置。我们也不能处理包含命名实体或否定的插入,因为它们会产生假匹配。要正确处理这些情况,我们需要了解句子的句法结构。而这些知识可以从依赖树中获取。

但是让我们先移除匹配器中不可靠的acquires规则:

if matcher.has_key("acquires"):
    matcher.remove("acquires")

蓝图:使用依赖树提取关系

语言的语法规则对每个句子强加了一种句法结构。每个词在与其他词的关系中起特定作用。例如,名词在句子中可以是主语或宾语;这取决于它与动词的关系。在语言学理论中,句子的词汇是层级相互依存的,而在自然语言处理管道中,解析器的任务是重建这些依赖关系。^(13) 其结果是依赖树,也可以通过displacy进行可视化:

text = "Fujitsu, a competitor of NEC, acquired Fairchild Corp."
doc = nlp(text)
displacy.render(doc, style='dep',
                options={'compact': False, 'distance': 100})

依赖树中的每个节点代表一个词。边缘用依赖信息标记。根节点通常是句子的谓语,本例中是acquired,有一个主语(nsubj)和一个宾语(obj)作为直接子节点。这个第一层,根加子节点,已经代表了句子“Fujitsu acquired Fairchild Corp.”的本质。

我们也来看看带有被动从句的例子。在这种情况下,助动词(auxpass)表示acquired以被动形式使用,Fairchild是被动主语(nsubjpass):

警告

依赖标签的值取决于解析器模型训练的语料库。它们还因语言而异,因为不同语言有不同的语法规则。因此,您绝对需要检查依赖解析器使用的标签集。

函数extract_rel_dep实现了一个规则,用于基于依赖关系识别基于动词的关系,例如acquires

def extract_rel_dep(doc, pred_name, pred_synonyms, excl_prepos=[]):
    for token in doc:
        if token.pos_ == 'VERB' and token.lemma_ in pred_synonyms:
            pred = token
            passive = is_passive(pred)
            subj = find_subj(pred, 'ORG', passive)
            if subj is not None:
                obj = find_obj(pred, 'ORG', excl_prepos)
                if obj is not None:
                    if passive: # switch roles
                        obj, subj = subj, obj
                    yield ((subj._.ref_n, subj._.ref_t), pred_name,
                           (obj._.ref_n, obj._.ref_t))

主循环迭代文档中的所有标记,并搜索表明我们关系的动词。此条件与我们之前使用的平面模式规则相同。但是当我们检测到可能的谓语时,我们现在遍历依赖树以找到正确的主语和宾语。find_subj搜索谓语的左子树,而find_obj搜索谓语的右子树。这些功能未在书中打印,但您可以在本章的 GitHub 笔记本中找到它们。它们使用广度优先搜索来查找最接近的主语和宾语,因为嵌套句子可能有多个主语和宾语。最后,如果谓语表示被动从句,主语和宾语将被交换。

注意,这个函数也适用于sells关系:

text = """Fujitsu said that Schlumberger Ltd has arranged
to sell its stake in Fairchild Inc."""
doc = nlp(text)
print(*extract_rel_dep(doc, 'sells', ['sell']), sep='\n')

Out:

(('Schlumberger', 'ORG'), 'sells', ('Fairchild', 'ORG'))

在这种情况下,Fairchild Inc.是与sell依赖树中最接近的对象,并正确地被识别为所调查关系的对象。但仅仅是“最接近”并不总是足够。考虑这个例子:

实际上,这里我们有一个三方关系:Schlumberger 将 Fairchild 卖给 Fujitsu。我们的sells关系意图表达“一家公司卖出(整体或部分)另一家公司”。另一部分由acquires关系覆盖。但是,我们如何在这里检测到正确的对象呢?在这个句子中,Fujitsu 和 Fairchild 都是介词对象(依赖pobj),而 Fujitsu 是最接近的。介词是关键:Schlumberger 将某物卖给“Fujitsu”,所以这不是我们要找的关系。在提取函数中,参数excl_prepos的目的是跳过具有指定介词的对象。以下是不带(A)和带有(B)介词过滤器的输出:

print("A:", *extract_rel_dep(doc, 'sells', ['sell']))
print("B:", *extract_rel_dep(doc, 'sells', ['sell'], ['to', 'from']))

Out:

A: (('Schlumberger', 'ORG'), 'sells', ('Fujitsu', 'ORG'))
B:

让我们来看看我们的新关系提取函数在几个示例变体上的工作情况:

texts = [
     "Fairchild Corp was bought by Fujitsu.", # 1
     "Fujitsu, a competitor of NEC Co, acquired Fairchild Inc.", # 2
     "Fujitsu is expanding." +
     "The company made an offer to acquire 80% of Fairchild Inc.", # 3
     "Fujitsu plans to acquire 80% of Fairchild Corp.", # 4
     "Fujitsu plans not to acquire Fairchild Corp.", # 5
     "The competition forced Fujitsu to acquire Fairchild Corp." # 6
]

acq_synonyms = ['acquire', 'buy', 'purchase']
for i, text in enumerate(texts):
    doc = nlp(text)
    rels = extract_rel_dep(doc, 'acquires', acq_synonyms, ['to', 'from'])
    print(f'{i+1}:', *rels)

Out:

1: (('Fujitsu', 'ORG'), 'acquires', ('Fairchild', 'ORG'))
2: (('Fujitsu', 'ORG'), 'acquires', ('Fairchild', 'ORG'))
3: (('Fujitsu', 'ORG'), 'acquires', ('Fairchild', 'ORG'))
4: (('Fujitsu', 'ORG'), 'acquires', ('Fairchild', 'ORG'))
5: (('Fujitsu', 'ORG'), 'acquires', ('Fairchild', 'ORG'))
6:

正如我们所看到的,前四句中的关系已经被正确提取出来。然而,第 5 句包含否定,仍然返回acquires。这是一个典型的假阳性案例。我们可以扩展我们的规则以正确处理这种情况,但在我们的语料库中否定很少,我们接受了简单算法的不确定性。相比之下,第 6 句则是一个可能的假阴性的例子。尽管提到了关系,但由于这句子的主语是competition而不是公司之一,因此没有被检测到。

实际上,基于依赖的规则本质上是复杂的,每种使其更精确的方法都会导致更多的复杂性。在不使代码过于复杂的情况下,找到精度(更少的假阳性)和召回率(更少的假阴性)之间的良好平衡是一种挑战。

尽管存在这些缺陷,基于依赖的规则仍然能够产生良好的结果。然而,这一过程的最后一步取决于命名实体识别、共指消解和依赖解析的正确性,而所有这些都不能以 100%的准确率工作。因此,总会有一些假阳性和假阴性。但是,这种方法已经足够好,可以产生非常有趣的知识图谱,正如我们将在下一节中做的那样。

创建知识图谱

现在我们知道如何提取特定的关系,我们可以将所有内容整合在一起,并从整个 Reuters 语料库创建知识图谱。我们将提取组织、人员以及四个关系“acquires”、“sells”、“subsidiary-of”和“executive-of”。图 12-6 显示了带有一些选择的子图的结果图。

为了在依赖解析和命名实体识别中获得最佳结果,我们使用 spaCy 的大型模型和完整管道。如果可能,我们将使用 GPU 加速 NLP 处理:

if spacy.prefer_gpu():
    print("Working on GPU.")
else:
    print("No GPU found, working on CPU.")
nlp = spacy.load('en_core_web_lg')

pipes = [entity_ruler, norm_entities, merge_entities,
         init_coref, alias_resolver, name_resolver,
         neural_coref, anaphor_coref, norm_names]
for pipe in pipes:
    nlp.add_pipe(pipe)

在我们开始信息提取过程之前,我们为“执行者”关系创建了两个类似于“子公司”关系的额外规则,并将它们添加到我们基于规则的matcher中:

ceo_synonyms = ['chairman', 'president', 'director', 'ceo', 'executive']
pattern = [{'ENT_TYPE': 'PERSON'},
           {'ENT_TYPE': {'NOT_IN': ['ORG', 'PERSON']}, 'OP': '*'},
           {'LOWER': {'IN': ceo_synonyms}}, {'TEXT': 'of'},
           {'ENT_TYPE': {'NOT_IN': ['ORG', 'PERSON']}, 'OP': '*'},
           {'ENT_TYPE': 'ORG'}]
matcher.add('executive-of', None, pattern)

pattern = [{'ENT_TYPE': 'ORG'},
           {'LOWER': {'IN': ceo_synonyms}},
           {'ENT_TYPE': 'PERSON'}]
matcher.add('rev-executive-of', None, pattern)

图 12-6. 从路透社语料库中提取的知识图,包括三个选定的子图(使用 Gephi 可视化)。

然后,我们定义一个函数来提取所有关系。我们的四种关系中,两种被匹配器覆盖,另外两种被基于依赖的匹配算法覆盖:

def extract_rels(doc):
    yield from extract_rel_match(doc, matcher)
    yield from extract_rel_dep(doc, 'acquires', acq_synonyms, ['to', 'from'])
    yield from extract_rel_dep(doc, 'sells', ['sell'], ['to', 'from'])

提取关系的剩余步骤,将其转换为 NetworkX 图,并将图存储在 gexf 文件中供 Gephi 使用,基本上遵循“蓝图:创建共现图”。我们在这里跳过它们,但您将再次在 GitHub 存储库中找到完整的代码。

这里是最终数据框架的几条记录,包含了图的节点和边,正如它们被写入 gexf 文件的方式:

主体 主体类型 谓语 宾语 宾语类型 频率 文章
883 泛美航空公司 ORG 收购 美国航空集团 ORG 7 2950,2948,3013,3095,1862,1836,7650
152 卡尔·伊坎 PERSON 担任执行者 泛美航空公司 ORG 3 1836,2799,3095
884 泛美航空公司 ORG 销售 美国航空集团 ORG 1 9487

用 Gephi 的帮助再次创建的图 12-6 中的路透社图的可视化效果。该图由许多相当小的组件(不连通的子图)组成;因为大多数公司只在一两篇新闻文章中被提及,我们仅提取了四种关系,因此简单的共现不包括在这里。我们在图中手动放大了其中三个子图。它们代表的是公司网络,这些网络已经在共现图中出现过(图 12-5),但现在我们知道了关系类型,并且得到了一个更清晰的图像。

不要盲目地信任结果

我们经历的每个处理步骤都有潜在的错误可能性。因此,存储在图中的信息并不完全可靠。事实上,这始于文章本身的数据质量。如果您仔细观察图 12-6 中左上角的示例,您会注意到图中出现了“富士通”和“Futjitsu”这两个实体。这实际上是原始文本中的拼写错误。

在图 12-6 右侧放大的子网络中,您可以发现表面上相互矛盾的信息:“皮德蒙特收购美国航空”和“美国航空收购皮德蒙特”。事实上,两者都是正确的,因为这两家企业都收购了对方的部分股份。但这也可能是其中一家相关规则或模型的错误。要追踪这种问题,有必要存储一些关于提取关系来源的信息。这就是为什么我们在每条记录中包含文章列表的原因。

最后,请注意我们的分析完全没有考虑到一个方面:信息的及时性。世界在不断变化,关系也在变化。因此,我们图中的每一条边都应该有时间戳。因此,要创建一个具有可信信息的知识库,仍然有很多工作要做,但我们的蓝图为开始提供了坚实的基础。

结语

在本章中,我们探讨了如何通过从非结构化文本中提取结构化信息来构建知识图谱。我们经历了信息提取的整个过程,从命名实体识别到通过指代消解到关系提取。

正如您所见,每一步都是一个挑战,我们总是在规则化方法和模型化方法之间做出选择。规则化方法的优势在于您无需训练数据。因此,您可以立即开始;您只需定义规则即可。但是,如果您尝试捕捉的实体类型或关系复杂难以描述,最终要么会得到过于简单并返回许多错误匹配的规则,要么是非常复杂且难以维护的规则。使用规则时,始终很难在召回率(找到大多数匹配项)和精确度(仅找到正确匹配项)之间找到良好的平衡。而且,您需要相当多的技术、语言和领域专业知识才能编写出好的规则。在实践中,您还必须测试和进行大量实验,直到您的规则足够强大以满足您的应用需求。

相比之下,基于模型的方法具有一个巨大的优势,即它们可以从训练数据中学习这些规则。当然,其缺点是您需要大量高质量的训练数据。如果这些训练数据特定于您的应用领域,那么您必须自己创建它们。在文本领域,手动标记训练数据尤其麻烦且耗时,因为有人必须先阅读和理解文本,然后才能设置标签。事实上,今天在机器学习领域,获得好的训练数据是最大的瓶颈。

缓解缺乏训练数据问题的一个可能解决方案是弱监督学习。其思想是通过像本章定义的规则或甚至通过程序生成它们的方式来创建一个大数据集。当然,由于规则并非完美,这个数据集会有噪音。但令人惊讶的是,我们可以在低质量数据上训练出高质量的模型。弱监督学习用于命名实体识别和关系提取,与本节中涵盖的许多其他主题一样,是当前的研究热点。如果您想了解更多关于信息提取和知识图谱创建的最新技术,可以查阅以下参考资料。它们为进一步阅读提供了良好的起点。

进一步阅读

  • Barrière, Caroline 的《语义网背景下的自然语言理解》。瑞士:斯普林格出版社。2016 年。https://www.springer.com/de/book/9783319413358

  • Gao, Yuqing,Jisheng Liang,Benjamin Han,Mohamed Yakout 和 Ahmed Mohamed 的《构建大规模、准确且更新的知识图谱》。KDD 教程,2018 年。https://kdd2018tutorialt39.azurewebsites.net

  • Han, Xu,Hao Zhu,Pengfei Yu,Ziyun Wang,Yuan Yao,Zhiyuan Liu 和 Maosong Sun 的《FewRel:一种大规模监督少样本关系分类数据集及其最新评估》。EMNLP 会议论文,2018 年。https://arxiv.org/abs/1810.10147

  • Jurafsky, Dan 和 James H. Martin 的《语音与语言处理》。第 3 版(草案),第十八章和第二十二章。2019 年。https://web.stanford.edu/~jurafsky/slp3

  • Lison, Pierre,Aliaksandr Hubin,Jeremy Barnes 和 Samia Touileb 的《无标注数据命名实体识别:弱监督方法》。ACL 会议论文,2020 年https://arxiv.org/abs/2004.14723

^(1) 参见 Natasha Noy,Yuqing Gao,Anshu Jain,Anant Narayanan,Alan Patterson 和 Jamie Taylor 的《产业规模知识图谱:经验与挑战》。2019 年。https://queue.acm.org/detail.cfm?id=3332266

^(2) 详情请见https://oreil.ly/nzhUR

^(3) Tim Berners-Lee 等人,《语义网:对计算机有意义的新形式的 Web 内容将引发新的可能性革命》。《科学美国人》284 号 5 月 2001 年。

^(4) 星号操作符(*)将列表展开为print的单独参数。

^(5) 参见spaCy 的基于规则匹配的使用文档以了解语法的解释,并查看https://explosion.ai/demos/matcher上的交互式模式探索器。

^(6) 在本章的笔记本上,你将找到一个用于缩略语检测的额外蓝图,位于GitHub

^(7) 更多详情请参见 Wolf(2017)的“Chatbots 的最新神经指代消解技术”

^(8) 谷歌在 2012 年推出其知识图谱时提出了这一口号。

^(9) 你可以在本书的电子版和我们的GitHub 存储库中找到彩色插图。

^(10) 你可以在本章笔记本的 GitHub 上找到该图的 NetworkX 版本。

^(11) 更多详细信息请参阅我们本章的 GitHub 仓库

^(12) 参见 最新技术概述

^(13) 与依存分析器相反,成分分析器根据嵌套短语创建层次化的句子结构。

第十三章:在生产中使用文本分析

到目前为止,我们已经介绍了几个蓝图,并了解了它们在多个用例中的应用。任何分析或机器学习模型在其他人能够轻松使用时,才能发挥其最大价值。在本章中,我们将提供几个蓝图,让您可以共享我们早期章节中的文本分类器,并部署到云环境中,使任何人都能使用我们构建的内容。

假设您在本书的第十章中使用了一个蓝图来分析来自 Reddit 的各种汽车型号数据。如果您的同事对于使用同样的分析方法来研究摩托车行业感兴趣,修改数据源并重复使用代码应该是简单的。实际情况可能更为复杂,因为您的同事首先必须设置一个类似于您使用的环境,安装相同版本的 Python 和所有必需的软件包。他们可能使用的是不同的操作系统,安装步骤也会有所不同。或者考虑到您向分析的客户展示时非常满意,三个月后他们回来要求您覆盖更多行业。现在,您必须重复相同的分析,但确保代码和环境保持不变。这次分析的数据量可能更大,您的系统资源可能不足,促使您使用云计算资源。您必须在云服务提供商上执行安装步骤,这可能会迅速变得耗时。

您将学到什么,我们将构建什么

经常发生的情况是,您能够产生出色的结果,但由于其他希望使用它们的同事无法重新运行代码和复现结果,因此这些结果无法使用。在本章中,我们将向您展示一些技术,确保您的分析或算法可以轻松被任何人重复使用,包括您自己在以后的阶段。如果我们能够让其他人更轻松地使用我们分析的输出会怎样?这消除了一个额外的障碍,并增加了我们结果的可访问性。我们将向您展示如何将您的机器学习模型部署为简单的 REST API,允许任何人在其自己的工作或应用程序中使用您模型的预测。最后,我们将向您展示如何利用云基础设施实现更快的运行时或为多个应用程序和用户提供服务。由于大多数生产服务器和服务运行在 Linux 上,本章包含许多在 Linux shell 或终端中运行最佳的可执行命令和指令。但是,它们在 Windows PowerShell 中同样有效。

蓝图:使用 Conda 创建可重现的 Python 环境

本书介绍的蓝图使用 Python 和包生态系统来完成多个文本分析任务。与任何编程语言一样,Python 经常更新并支持多个版本。此外,像 Pandas、NumPy 和 SciPy 这样的常用包也有定期的发布周期,当它们升级到新版本时。尽管维护者们尽力确保新版本向后兼容,但有可能您去年完成的分析在最新版本的 Python 下无法运行。您的蓝图可能使用了最新版本库中已弃用的方法,这会导致分析无法重现,除非知道所使用库的版本。

假设您将蓝图以 Jupyter 笔记本或 Python 模块的形式与同事分享;当他们尝试运行时,可能会遇到如下常见错误:

import spacy

输出:

---------------------------------------------------------------------------
ModuleNotFoundError                       Traceback (most recent call last)
<ipython-input-1-76a01d9c502b> in <module>
----> 1 import spacy
ModuleNotFoundError: No module named 'spacy'

在大多数情况下,ModuleNotFoundError 可以通过手动使用命令 **pip install <module_name>** 安装所需的包来轻松解决。但想象一下,对于每个非标准包都要这样做!此命令还会安装最新版本,这可能不是您最初使用的版本。因此,确保可重复性的最佳方法是使用标准化的方式共享用于运行分析的 Python 环境。我们使用 conda 包管理器以及 Miniconda Python 发行版来解决这个问题。

注意

有几种方法可以解决创建和共享 Python 环境的问题,conda 只是其中之一。pip 是标准的 Python 包安装程序,随 Python 附带,并广泛用于安装 Python 包。venv 可用于创建虚拟环境,每个环境可以拥有其自己的 Python 版本和一组已安装的包。Conda 结合了包安装程序和环境管理器的功能,因此是我们首选的选项。重要的是要区分 conda 和 Anaconda/Miniconda 发行版。这些发行版包括 Python 和 conda,以及处理数据所需的基本包。虽然 conda 可以直接通过 pip 安装,但最简单的方法是安装 Miniconda,这是一个包含 conda、Python 和一些必需包的小型引导版本。

首先,我们必须按照以下步骤安装 Miniconda 发行版。这将创建一个基本安装,其中只包含 Python、conda 和一些基本软件包如pipzlib等。现在,我们可以为每个项目创建单独的环境,其中只包含我们需要的软件包,并且与其他环境隔离开来。这很有用,因为您所做的任何更改,如安装额外的软件包或升级到不同的 Python 版本,不会影响任何其他项目或应用程序,因为它们使用自己的环境。可以通过以下命令执行此操作:

conda create -n env_name [list_of_packages]

执行前面的命令将使用 Miniconda 首次安装时可用的默认版本创建一个新的 Python 环境。让我们创建一个名为blueprints的环境,其中明确指定 Python 版本和要安装的附加软件包列表如下:

$ conda create -n blueprints numpy pandas scikit-learn notebook python=3.8
Collecting package metadata (current_repodata.json): - done
Solving environment: \ done
 Package Plan
  environment location: /home/user/miniconda3/envs/blueprints
  added / updated specs:
    - notebook
    - numpy
    - pandas
    - python=3.8
    - scikit-learn

The following packages will be downloaded:

    package                    |            build
    ---------------------------|-----------------
    blas-1.0                   |              mkl           6 KB
    intel-openmp-2020.1        |              217         780 KB
    joblib-0.16.0              |             py_0         210 KB
    libgfortran-ng-7.3.0       |       hdf63c60_0        1006 KB
    mkl-2020.1                 |              217       129.0 MB
    mkl-service-2.3.0          |   py37he904b0f_0         218 KB
    mkl_fft-1.1.0              |   py37h23d657b_0         143 KB
    mkl_random-1.1.1           |   py37h0573a6f_0         322 KB
    numpy-1.18.5               |   py37ha1c710e_0           5 KB
    numpy-base-1.18.5          |   py37hde5b4d6_0         4.1 MB
    pandas-1.0.5               |   py37h0573a6f_0         7.8 MB
    pytz-2020.1                |             py_0         184 KB
    scikit-learn-0.23.1        |   py37h423224d_0         5.0 MB
    scipy-1.5.0                |   py37h0b6359f_0        14.4 MB
    threadpoolctl-2.1.0        |     pyh5ca1d4c_0          17 KB
    ------------------------------------------------------------
                                           Total:       163.1 MB

The following NEW packages will be INSTALLED:

  _libgcc_mutex      pkgs/main/linux-64::_libgcc_mutex-0.1-main
  attrs              pkgs/main/noarch::attrs-19.3.0-py_0
  backcall           pkgs/main/noarch::backcall-0.2.0-py_0
  blas               pkgs/main/linux-64::blas-1.0-mkl
  bleach             pkgs/main/noarch::bleach-3.1.5-py_0
  ca-certificates    pkgs/main/linux-64::ca-certificates-2020.6.24-0

(Output truncated)

执行完命令后,可以通过执行**conda activate <env_name>**来激活它,您会注意到命令提示符前缀带有环境名称。您可以进一步验证 Python 的版本与您指定的版本是否相同:

$ conda activate blueprints
(blueprints) $ python --version
Python 3.8

使用命令**conda env list**可以查看系统中所有环境的列表,如下所示。输出将包括基本环境,这是安装 Miniconda 时创建的默认环境。特定环境前的星号表示当前活动环境,在我们的例子中是刚刚创建的环境。请确保在制作蓝图时继续使用此环境:

(blueprints) $ conda env list
# conda environments:
#
base                     /home/user/miniconda3
blueprints            *  /home/user/miniconda3/envs/blueprints

conda确保每个环境可以拥有其自己版本的相同软件包,但这可能会增加存储成本,因为每个环境中将使用相同版本的每个软件包。使用硬链接在一定程度上可以减少这种影响,但在软件包使用硬编码路径的情况下可能无效。然而,我们建议在切换项目时创建另一个环境。但是,建议使用命令**conda remove --name <env_name> --all**删除未使用的环境是一个良好的实践。

这种方法的优点在于,当您想与他人共享代码时,可以指定应运行的环境。可以使用命令**conda env export > environment.yml**将环境导出为 YAML 文件。在运行此命令之前,请确保您处于所需的环境中(通过运行**conda activate <environment_name>**):

(blueprints) $ conda env export > environment.yml
(blueprints) $ cat environment.yml
name: blueprints
channels:
  - defaults
dependencies:
  - _libgcc_mutex=0.1=main
  - attrs=19.3.0=py_0
  - backcall=0.2.0=py_0
  - blas=1.0=mkl
  - bleach=3.1.5=py_0
  - ca-certificates=2020.6.24=0
  - certifi=2020.6.20=py37_0
  - decorator=4.4.2=py_0
  - defusedxml=0.6.0=py_0
  - entrypoints=0.3=py37_0
  - importlib-metadata=1.7.0=py37_0
  - importlib_metadata=1.7.0=0
  - intel-openmp=2020.1=217
  - ipykernel=5.3.0=py37h5ca1d4c_0
(output truncated)

如输出所示,environment.yml 文件列出了环境中使用的所有包及其依赖关系。任何人都可以通过运行命令 **conda env create -f environment.yml** 使用此文件重新创建相同的环境。然而,此方法可能存在跨平台限制,因为 YAML 文件中列出的依赖关系特定于平台。因此,如果您在 Windows 系统上工作并导出了 YAML 文件,则在 macOS 系统上可能无法正常工作。

这是因为 Python 包所需的某些依赖项取决于平台。例如,Intel MKL 优化 是特定于某种架构的,可以用 OpenBLAS 库来替代。为了提供一个通用的环境文件,我们可以使用命令 **conda env export --from-history > environment.yml**,它只生成您明确请求的包的列表。运行此命令的输出如下,列出了我们在创建环境时安装的包。与先前的环境文件相比,前者还列出了像 attrsbackcall 这样的包,它们是 conda 环境的一部分,但并非我们请求的。当在新平台上使用此类 YAML 文件创建环境时,conda 将自动识别和安装默认包及其特定于平台的依赖关系。此外,将安装我们明确指定的包及其依赖项:

(blueprints) $ conda env export --from-history > environment.yml
(blueprints) $ cat environment.yml
name: blueprints
channels:
  - defaults
dependencies:
  - scikit-learn
  - pandas
  - notebook
  - python=3.8
  - numpy
prefix: /home/user/miniconda3/envs/blueprints

使用 --from-history 选项的缺点是创建的环境不是原始环境的复制品,因为基本包和依赖项是特定于平台的,因此不同。如果要使用此环境的平台与创建环境的平台相同,则不建议使用此选项。

蓝图:使用容器创建可复制的环境

虽然像 conda 这样的包管理器帮助安装多个包并管理依赖关系,但仍有几个特定于平台的二进制文件可能会妨碍可复制性。为了简化事务,我们利用了一个称为 容器 的抽象层。这个名字来源于航运业,标准尺寸的航运集装箱用于通过船舶、卡车和铁路运输各种商品。无论物品类型或运输方式如何,集装箱确保任何遵循该标准的人都可以运输这些物品。同样地,我们使用 Docker 容器来标准化我们工作的环境,并确保每次重新创建时都能生成相同的环境,无论在哪里运行或由谁运行。Docker 是实现此功能的最流行工具之一,我们将在本蓝图中使用它。图 13-1 显示了 Docker 的工作高级概述。

图 13-1. Docker 的工作流程。

我们需要从下载链接安装 Docker。一旦设置完成,请在命令行中运行 sudo docker run hello-world 来测试一切是否设置正确,您应该看到如下输出。请注意,Docker 守护程序绑定到一个 Unix 套接字,由 root 用户拥有,因此需要使用 sudo 运行所有命令。如果无法提供 root 访问权限,您也可以尝试一个实验版本的 Docker:

$ sudo docker run hello-world

Hello from Docker!
This message shows that your installation appears to be working correctly.

To generate this message, Docker took the following steps:
 1\. The Docker client contacted the Docker daemon.
 2\. The Docker daemon pulled the "hello-world" image from the Docker Hub.
    (amd64)
 3\. The Docker daemon created a new container from that image which runs the
    executable that produces the output you are currently reading.
 4\. The Docker daemon streamed that output to the Docker client, which sent it
    to your terminal.

To try something more ambitious, you can run an Ubuntu container with:
 $ docker run -it ubuntu bash

Share images, automate workflows, and more with a free Docker ID:
 https://hub.docker.com/

For more examples and ideas, visit:
 https://docs.docker.com/get-started/

我们可以将构建 Docker 容器与购买汽车类比。我们从预配置选项中选择开始。这些配置已经选择了一些组件,例如引擎类型(排量、燃料类型)、安全功能、设备水平等。我们可以自定义许多这些组件,例如升级到更节能的引擎或添加额外的组件,如导航系统或加热座椅。最后,我们确定我们喜欢的配置并订购汽车。类似地,我们在 Dockerfile 中指定我们想要创建的环境配置。这些配置以一组顺序执行的指令形式描述,结果是创建一个 Docker 镜像。Docker 镜像就像根据我们的 Dockerfile 创建的首选汽车配置。所有 Docker 镜像都是可扩展的,因此我们可以扩展现有的 Docker 镜像并通过添加特定步骤来自定义它。运行 Docker 镜像的最后一步会创建一个 Docker 容器,这就像根据您的首选配置交付给您的汽车一样。在这种情况下,它是一个包含操作系统及其它工具和软件包的完整环境,如在 Dockerfile 中指定的。它在硬件上运行并使用主机系统提供的接口,但与主机系统完全隔离。事实上,它是根据您设计的方式运行的服务器的最小版本。从相同镜像实例化的每个 Docker 容器无论在哪个主机系统上运行都将是相同的。这很强大,因为它允许您封装您的分析和环境,并在笔记本电脑、云端或组织的服务器上运行并期望相同的行为。

我们将创建一个 Docker 镜像,其中包含与我们分析中使用的相同的 Python 环境,这样其他人可以通过拉取镜像并实例化为容器来复现我们的分析。虽然我们可以从头开始指定我们的 Docker 镜像,但最好是从现有的镜像开始,并定制其中的某些部分来创建我们自己的版本。这样的镜像称为父镜像。一个好地方去搜索父镜像是Docker Hub 注册表,这是一个包含预构建 Docker 镜像的公共仓库。您会找到官方支持的镜像,如Jupyter 数据科学笔记本,以及像我们为第九章创建的用户创建的镜像。Docker 仓库中的每个镜像也可以直接用来运行容器。您可以使用**sudo docker search**命令搜索镜像,并添加参数以格式化结果,如下所示,在这里我们搜索可用的 Miniconda 镜像:

$ sudo docker search miniconda
NAME                                        STARS
continuumio/miniconda3                      218
continuumio/miniconda                       77
conda/miniconda3                            35
conda/miniconda3-centos7                    7
yamitzky/miniconda-neologd                  3
conda/miniconda2                            2
atavares/miniconda-rocker-geospatial        2

我们看到有一个 Miniconda3 的镜像,这将是我们自己的 Dockerfile 的一个很好的起点。请注意,所有 Dockerfile 都必须以FROM关键字开头,指定它们派生自哪个镜像。如果您从头开始指定一个 Dockerfile,那么您将使用FROM scratch关键字。Miniconda 镜像和 Dockerfile 的详细信息显示了该镜像如何派生自一个 Debian 父镜像,并且只添加额外的步骤来安装和设置 conda 包管理器。在使用父 Docker 镜像时,检查它来自可信的来源非常重要。Docker Hub 提供了额外的标准,如“官方镜像”,有助于识别官方来源。

让我们按照我们的 Dockerfile 中定义的步骤来进行。我们从 Miniconda3 镜像开始,然后添加一个步骤来创建我们的自定义环境。我们使用ARG指令来指定我们 conda 环境的名称参数(blueprints)。然后,我们使用ADDenvironment.yml文件从构建上下文复制到镜像中。最后,通过将**conda create**命令作为RUN的参数来创建 conda 环境:

FROM continuumio/miniconda3

# Add environment.yml to the build context and create the environment
ARG conda_env=blueprints
ADD environment.yml /tmp/environment.yml
RUN conda env create -f /tmp/environment.yml

在接下来的步骤中,我们希望确保环境在容器中被激活。因此,我们将其添加到.bashrc脚本的末尾,这将在容器启动时始终运行。我们还使用ENV指令更新PATH环境变量,以确保 conda 环境是容器内部到处使用的 Python 版本:

# Activating the environment and starting the jupyter notebook
RUN echo "source activate ${conda_env}" > ~/.bashrc
ENV PATH /opt/conda/envs/${conda_env}/bin:$PATH

在最后一步,我们希望自动启动一个 Jupyter 笔记本,以便此 Docker 容器的用户可以以交互方式运行分析。我们使用ENTRYPOINT指令来配置将作为可执行文件运行的容器。Dockerfile 中只能有一个这样的指令(如果有多个,则只有最后一个有效),它将是容器启动时要运行的最后一个命令,通常用于启动像我们要运行的 Jupyter 笔记本这样的服务器。我们指定额外的参数来在容器本身的 IP 地址(0.0.0.0)上运行服务器,在特定端口(8888)上以 root 用户身份运行(--allow-root),并且默认不在浏览器中打开(--no-browser)。当容器启动时,我们不希望它在其浏览器中打开 Jupyter 服务器。相反,我们将使用指定的端口将主机机器连接到此容器,并通过那里的浏览器访问它:

# Start jupyter server on container
EXPOSE 8888
ENTRYPOINT ["jupyter","notebook","--ip=0.0.0.0", \
      "--port=8888","--allow-root","--no-browser"]

我们使用docker build命令根据我们的 Dockerfile 创建镜像。我们使用-t参数指定镜像的名称,并添加用户名和镜像名称。这在以后要引用镜像时非常有用。虽然没有必要指定用户名,但稍后我们会看到这样做的好处。在构建镜像时使用的 Dockerfile 由-f参数指定。如果未指定任何内容,则 Docker 将选择指定PATH参数所在目录中名为Dockerfile的文件。PATH参数还指定了在 Docker 守护程序上构建过程的“上下文”中查找文件的位置。此目录中的所有文件都将在构建过程中被打包为tar文件并发送到守护程序。这些文件必须包括所有要添加到镜像中的文件和工件,例如将复制到镜像以创建 conda 环境的environment.yml文件。

docker build -t username/docker_project -f Dockerfile [PATH]

执行此命令时,Docker 守护程序开始通过运行 Dockerfile 中指定的步骤来创建镜像。通常,您会在已经包含所有文件和 Dockerfile 的同一目录中执行该命令。我们使用.来引用当前目录指定PATH参数:

$ sudo docker build -t textblueprints/ch13:v1 .
Sending build context to Docker daemon  5.363MB
Step 1/8 : FROM continuumio/miniconda3
 ---> b4adc22212f1
Step 2/8 : ARG conda_env=blueprints
 ---> 959ed0c16483
Step 3/8 : ADD environment.yml /tmp/environment.yml
 ---> 60e039e09fa7
Step 4/8 : RUN conda env create -f /tmp/environment.yml
 ---> Running in 85d2f149820b
Collecting package metadata (repodata.json): ...working... done
Solving environment: ...working... done

Downloading and Extracting Packages

(output truncated)

Removing intermediate container 85d2f149820b
Step 5/8 : RUN echo "source activate ${conda_env}" > ~/.bashrc
 ---> e0ed2b448211
Step 6/8 : ENV PATH /opt/conda/envs/${conda_env}/bin:$PATH
 ---> 7068395ce2cf
Step 7/8 : EXPOSE 8888
 ---> Running in f78ac4aa0569
Removing intermediate container f78ac4aa0569
 ---> 06cfff710f8e
Step 8/8 : ENTRYPOINT ["jupyter","notebook","--ip=0.0.0.0",
                       "--port=8888","--allow-root","--no-browser"]
 ---> Running in 87852de682f4
Removing intermediate container 87852de682f4
 ---> 2b45bb18c071
Successfully built 2b45bb18c071
Successfully tagged textblueprints/ch13:v1

构建完成后,可以通过运行命令**sudo docker images**来检查镜像是否成功创建。你会注意到已经下载了continuumio/miniconda3镜像,并且还创建了包含你的用户名和docker_project指定的镜像。第一次构建 Docker 会花费更长时间,因为需要下载父镜像,但后续的更改和重建会快得多:

$ sudo docker images
REPOSITORY                          TAG                 IMAGE ID
textblueprints/ch13                 v1                  83a05579afe6
jupyter/minimal-notebook            latest              d94723ae86d1
continuumio/miniconda3              latest              b4adc22212f1
hello-world                         latest              bf756fb1ae65

我们可以通过运行以下命令创建这个环境的运行实例,也称为容器

docker run -p host_port:container_port username/docker_project:tag_name

-p 参数允许端口转发,基本上将接收到的任何请求发送到 host_portcontainer_port。默认情况下,Jupyter 服务器只能访问容器内的文件和目录。但是,我们希望从运行在容器内部的 Jupyter 服务器中访问本地目录中的 Jupyter 笔记本和代码文件。我们可以使用 -v host_volume:container_volume 将本地目录附加到容器作为卷,这将在容器内创建一个新目录,指向一个本地目录。这样做可以确保当容器关闭时,对 Jupyter 笔记本的任何更改都不会丢失。这是在本地使用 Docker 容器进行工作的推荐方法。让我们通过运行以下命令启动我们的 Docker 容器:

sudo docker run -p 5000:8888 -v \
/home/user/text-blueprints/ch13/:/work textblueprints/ch13:v1

Out:

[NotebookApp] Writing notebook server cookie secret to
/root/.local/share/jupyter/runtime/notebook_cookie_secret
[NotebookApp] Serving notebooks from local directory: /
[NotebookApp] The Jupyter Notebook is running at:
[NotebookApp] http://aaef990b90a3:8888/?token=xxxxxx
[NotebookApp]  or http://127.0.0.1:8888/?token=xxxxxx
[NotebookApp] Use Control-C to stop this server and shut down all kernels
(twice to skip confirmation).
[NotebookApp]

    To access the notebook, open this file in a browser:
        file:///root/.local/share/jupyter/runtime/nbserver-1-open.html
    Or copy and paste one of these URLs:
        http://aaef990b90a3:8888/?token=xxxxxx
     or http://127.0.0.1:8888/?token=xxxxxx

现在您看到的日志实际上是在容器内端口 8888 上启动的 Jupyter 服务器的日志。由于我们已将主机端口映射到 5000,您可以复制 URL 并仅将端口号更改为 5000 以访问 Jupyter 服务器。您还将在这里找到一个名为 work 的目录,其中应包含来自映射的本地目录中的所有文件。您还可以通过运行命令 **sudo docker container ps** 检查所有运行中的容器的状态。我们还可以使用 --name argument 为每个运行的容器指定名称,如果不使用此选项,则 Docker 守护程序将分配一个随机创建的名称,如您在此处看到的:

$ sudo docker container ls
CONTAINER ID        IMAGE                    STATUS              NAMES
862e5b0570fe        textblueprints/ch13:v1   Up About a minute   musing_chaum

如果您退出运行此命令的终端窗口,则容器也将关闭。要在分离模式下运行它,只需在运行命令中添加 -d 选项。容器启动时,将打印已启动容器的容器 ID,并且您可以使用 sudo docker logs <container-id> 监视日志。我们已在此 Docker 容器中复制了用于运行分析的完整环境,在下一个蓝图中,让我们看看分享它的最佳技术。

将此镜像与任何人共享的最简单方法是将其推送到 Docker Hub 注册表。您可以注册一个免费帐户。Docker Hub 是 Docker 镜像的公共存储库,每个镜像都由用户名、镜像名称和标签唯一标识。例如,我们用作父镜像的miniconda3软件包被标识为continuumio/miniconda3:latest,您分享的任何镜像将用您的用户名标识。因此,在之前构建镜像时,指定的用户名必须与登录 Docker Hub 时使用的用户名相同。创建凭据后,您可以单击创建存储库,并选择一个名称并为存储库提供描述。在我们的情况下,我们创建了一个名为"ch13"的存储库,将包含本章的 Docker 镜像。完成后,您可以使用命令**sudo docker login**登录,并输入您的用户名和密码。为增加安全性,请按照说明安全地存储您的密码。

注意

默认情况下,在构建 Docker 镜像的过程中,PATH参数指定的所有目录和文件都是构建上下文的一部分。在前面的命令中,我们使用.符号指定路径为当前目录,但这是不必要的,因为我们只需要包含构建和容器所需的选择文件列表。例如,我们需要environment.yml但不需要 Jupyter 笔记本(.ipynb)文件。重要的是要在.dockerignore文件中指定排除的文件列表,以确保不希望的文件不会自动添加到容器中。我们的.dockerignore文件如下所示:

  .git
  .cache
  figures
  **/*.html
  **/*.ipynb
  **/*.css

另一件需要确保的事情是host_port(在蓝图中指定为 5000)是开放的,且系统中没有其他应用程序在使用。理想情况下,您应使用 1024 到 49151 之间的端口号作为user ports,但您也可以轻松地通过运行命令**sudo ss -tulw**来检查这一点,该命令将提供已使用端口的列表。

下一步是标记您希望与 tag_name 一起分享的图像,以识别其包含内容。在我们的情况下,我们使用 v1 标记图像,以表示这是本章的第一个版本。我们运行命令 sudo docker tag 2b45bb18c071 textblueprints/ch13:v1,其中 2b45bb18c071 是图像 ID。现在我们可以使用命令 sudo docker push textblueprints/ch13 推送我们的文件。现在任何想要运行您项目的人都可以简单地运行命令 docker pull your_username/docker_project:tag_name 来创建与您相同的环境,无论他们可能在个人工作中使用的系统如何。例如,您可以通过简单运行命令 docker pull textblueprints/ch09:v1 开始在 第九章 中的蓝图工作。然后,您可以附加包含克隆存储库的目录的卷。Docker Hub 是一个流行的公共注册表,并且在 Docker 中默认配置,但每个云提供商也有他们自己的版本,许多组织为他们内部应用程序和团队设置了私有注册表。

当使用具有多个科学计算包的 conda 环境时,Docker 镜像可能会变得很大,因此在将其推送到 Docker Hub 时可能会对带宽造成压力。一个更有效的方法是在存储库的基本路径中包含 Dockerfile。例如,包含本章代码的 GitHub 存储库包含一个 Dockerfile,该文件可以用于创建运行代码所需的精确环境。这个蓝图轻松地允许您将分析从本地系统移动到具有额外资源的云机器上,重新创建相同的工作环境。当数据大小增加或分析时间过长时,这尤其有用。

蓝图:为您的文本分析模型创建 REST API

假设您使用了提供在 第十一章 的蓝图来分析您组织中客户支持票的情绪。您的公司正在进行一项提高客户满意度的活动,他们希望向不满意的客户提供优惠券。技术团队的同事联系您寻求帮助自动化此活动。虽然他们可以拉取 Docker 容器并重现您的分析,但他们更倾向于一种更简单的方法,即提供支持票的文本并获取是否为不满意客户的响应。通过将我们的分析封装在一个 REST API 中,我们可以创建一个简单的方法,任何人都可以访问,而不需要重新运行蓝图。他们甚至不一定需要知道 Python,因为 REST API 可以从任何语言调用。在 第二章 中,我们使用了流行网站提供的 REST API 来提取数据,而在这个蓝图中,我们将创建我们自己的 REST API。

我们将利用以下三个组件来托管我们的 REST API:

  • FastAPI:一个用于构建 API 的快速 Web 框架

  • Gunicorn:处理所有传入请求的 Web 服务网关接口服务器

  • Docker:扩展我们在之前蓝图中使用的 Docker 容器

让我们创建一个名为 app 的新文件夹,我们将在其中放置所有我们需要用来提供情感预测的代码。它将遵循以下目录结构,并包含如下文件。main.py 是我们将创建 FastAPI 应用程序和情感预测方法的地方,preprocessing.py 包含我们的辅助函数。models 目录包含我们需要用来计算预测的训练模型,即 sentiment_vectorizersentiment_classification。最后,我们有 Dockerfile、environment.ymlstart_script.sh,它们将用于部署我们的 REST API:

├── app
│   ├── main.py
│   ├── Dockerfile
│   ├── environment.yml
│   ├── models
│   │   ├── sentiment_classification.pickle
│   │   └── sentiment_vectorizer.pickle
│   ├── preprocessing.py
│   └── start_script.sh

FastAPI 是一个快速的 Python 框架,用于构建 API。它能够将来自 Web 服务器的请求重定向到 Python 中定义的特定函数。它还负责根据指定的模式验证传入的请求,并且非常适合创建简单的 REST API。我们将在这个 API 中封装我们在 第十一章 中训练的模型的 predict 函数。让我们逐步解释 main.py 文件中的代码,并说明其工作原理。您可以通过运行 pip install fastapi 安装 FastAPI,并通过运行 pip install gunicorn 安装 Gunicorn。

安装了 FastAPI 后,我们可以使用以下代码创建一个应用:

from fastapi import FastAPI
app = FastAPI()

FastAPI 库使用包含的 Web 服务器运行此应用程序,并且可以将接收到的请求路由到 Python 文件中的方法。这通过在函数定义的开头添加 @app.post 属性来指定。我们指定端点为 api/v1/sentiment,我们 Sentiment API 的第一个版本,它接受 HTTP POST 请求。API 可以随时间演变,功能发生变化,将它们分隔成不同的版本是有用的,以确保旧版本的用户不受影响:

class Sentiment(Enum):
    POSITIVE = 1
    NEGATIVE = 0

@app.post("/api/v1/sentiment", response_model=Review)
def predict(review: Review, model = Depends(load_model())):
    text_clean = preprocessing.clean(review.text)
    text_tfidf = vectorizer.transform([text_clean])
    sentiment = prediction_model.predict(text_tfidf)
    review.sentiment = Sentiment(sentiment.item()).name
    return review

predict 方法从输入中检索文本字段,并执行预处理和向量化步骤。它使用我们之前训练的模型来预测产品评论的情感。返回的情感被指定为 Enum 类,以限制 API 可能的返回值。输入参数 review 被定义为 Review 类的一个实例。该类如下所示,包含评论文本作为必填字段,还有 reviewerIDproductIDsentiment。FastAPI 使用 “type hints” 来猜测字段的类型 (str) 并执行必要的验证。正如我们将看到的,FastAPI 会根据 OpenAPI 规范自动生成我们 API 的 web 文档,通过这些文档可以直接测试 API。我们添加了 schema_extra 作为一个示例,以充当开发人员使用该 API 的指南:

class Review(BaseModel):
    text: str
    reviewerID: Optional[str] = None
    asin: Optional[str] = None
    sentiment: Optional[str] = None

    class Config:
        schema_extra = {
            "example": {
                "text": "This was a great purchase, saved me much time!",
                "reviewerID": "A1VU337W6PKAR3",
                "productID": "B00K0TIC56"
            }
        }

你可能已经注意到函数定义中使用了 Depends 关键字。这允许我们在调用函数之前加载必需的依赖项或其他资源。这被视为另一个 Python 函数,并在此定义:

def load_model():
    try:
        print('Calling Depends Function')
        global prediction_model, vectorizer
        prediction_model = pickle.load(
            open('models/sentiment_classification.pickle', 'rb'))
        vectorizer = pickle.load(open('models/tfidf_vectorizer.pickle', 'rb'))
        print('Models have been loaded')
    except Exception as e:
        raise ValueError('No model here')

注意

Pickle 是 Python 序列化框架之一,是模型保存/导出的常见方式之一。其他标准化格式包括 joblibONNX。一些深度学习框架使用它们自己的导出格式。例如,TensorFlow 使用 SavedModel,而 PyTorch 使用 pickle,但实现了自己的 save() 函数。根据你使用的模型保存/导出类型,适应加载和预测函数非常重要。

在开发过程中,FastAPI 可以与任何 Web 服务器一起运行(例如 uvicorn),但建议使用成熟的、支持多个工作线程的完整的 Web 服务网关接口(WSGI)服务器。我们选择使用 Gunicorn 作为我们的 WSGI 服务器,因为它提供了一个可以接收请求并重定向到 FastAPI 应用的 HTTP 服务器。

安装后,可以输入以下命令运行它:

gunicorn -w 3 -b :5000 -t 5 -k uvicorn.workers.UvicornWorker main:app

-w 参数用于指定要运行的工作进程数量,本例中为三个工作者。-b 参数指定了 WSGI 服务器监听的端口,-t 表示超时值,超过五秒后服务器将杀死并重新启动应用程序,以防止其无响应。-k 参数指定了要调用以运行应用程序的工作者类的实例(uvicorn),通过引用 Python 模块(main)和名称(app)来指定。

在部署 API 之前,我们必须重新检查 environment.yml 文件。在第一版本中,我们描述了生成和分享 environment.yml 文件的方法,以确保你的分析可复现。然而,在将代码部署到生产环境时,不建议使用此方法。虽然导出的 environment.yml 文件是一个起点,但我们必须手动检查并确保它不包含未使用的包。还重要的是指定包的确切版本号,以确保包更新不会干扰你的生产部署。我们使用一个名为 Vulture 的 Python 代码分析工具,它可以识别未使用的包以及其他死代码片段。让我们为 app 文件夹运行此分析:

vulture app/

输出:

app/main.py:11: unused variable 'POSITIVE' (60% confidence)
app/main.py:12: unused variable 'NEGATIVE' (60% confidence)
app/main.py:16: unused variable 'reviewerID' (60% confidence)
app/main.py:17: unused variable 'asin' (60% confidence)
app/main.py:20: unused class 'Config' (60% confidence)
app/main.py:21: unused variable 'schema_extra' (60% confidence)
app/main.py:40: unused variable 'model' (100% confidence)
app/main.py:44: unused attribute 'sentiment' (60% confidence)
app/preprocessing.py:30: unused import 'spacy' (90% confidence)
app/preprocessing.py:34: unused function 'display_nlp' (60% confidence)

除了潜在问题列表,Vulture 还提供了置信度分数。请使用识别出的问题作为检查这些导入的指针。在上一个示例中,我们知道我们定义的类变量用于验证 API 的输入并且肯定被使用。我们可以看到,即使 spacydisplay_nlp 是预处理模块的一部分,但它们并未在我们的应用程序中使用。我们可以选择将它们和相应的依赖项从 YAML 文件中删除。

您还可以通过运行 **conda list** 命令确定 conda 环境中每个软件包的版本,然后使用此信息创建最终清理后的环境 YAML 文件,如下所示:

name: sentiment-app
channels:
  - conda-forge
dependencies:
  - python==3.8
  - fastapi==0.59.0
  - pandas==1.0.5
  - scikit-learn==0.23.2
  - gunicorn==20.0.4
  - uvicorn==0.11.3

作为最后一步,我们可以将 API Docker 化,以便更容易地在其自己的容器中运行整个应用程序,特别是当我们想要在云提供商上托管它时,正如我们将在下一个蓝图中看到的那样。我们对比之前的蓝图在 Dockerfile 中进行了两个更改,如下所示:

# Copy files required for deploying service to app folder in container
COPY . /app
WORKDIR /app

使用上述指令将当前 app 文件夹的所有内容复制到 Docker 镜像中,其中包含部署和运行 REST API 所需的所有文件。然后使用 WORKDIR 指令将容器中的当前目录更改为 app 文件夹:

# Start WSGI server on container
EXPOSE 5000
RUN ["chmod", "+x", "start_script.sh"]
ENTRYPOINT [ "/bin/bash", "-c" ]
CMD ["./start_script.sh"]

然后,我们提供了运行 WSGI 服务器的步骤,首先在容器上公开端口 5000。接下来,我们在 start_script 上启用权限,以便 Docker 守护程序在容器启动时执行它。我们使用 ENTRYPOINT(用于启动要运行脚本的 bash shell)和 CMD(用于将实际脚本指定为传递给 bash shell 的参数)的组合,它激活 conda 环境并启动 Gunicorn 服务器。由于我们在 Docker 容器中运行服务器,我们做了一个小改变,指定将 access-logfile 写入到 STDOUT (-) 以确保我们仍然可以查看它们:

#!/bin/bash
source activate my_env_name
GUNICORN_CMD_ARGS="--access-logfile -" gunicorn -w 3 -b :5000 -t 5 \
          -k uvicorn.workers.UvicornWorker main:app -

我们构建 Docker 镜像并按照之前蓝图中的相同步骤运行它。这将导致一个正在运行的 Docker 容器,其中 Gunicorn WSGI 服务器正在运行 FastAPI 应用程序。我们必须确保从容器所在的主机系统转发一个端口:

$ sudo docker run -p 5000:5000 textblueprints/sentiment-app:v1
    [INFO] Starting gunicorn 20.0.4
    [INFO] Listening at: http://0.0.0.0:5000 (11)
    [INFO] Using worker: sync
    [INFO] Booting worker with pid: 14

我们可以从不同的程序调用正在运行 API 的容器。在单独的终端窗口或 IDE 中,创建一个测试方法,调用 API 并传入一个样本评论以检查响应。我们使用本地 IP 以端口 5000 发起调用,该端口被转发到容器的端口 5000,从中我们接收响应,如下所示:

import requests
import json

url = 'http://0.0.0.0:5000/api/v1/sentiment'
data = {
    'text':
    'I could not ask for a better system for my small greenhouse, \
 easy to set up and nozzles do very well',
    'reviewerID': 'A1VU337W6PKAR3',
    'productID': 'B00K0TIC56'
}
input_data = json.dumps(data)
headers = {'content-type': 'application/json', 'Accept-Charset': 'UTF-8'}
r = requests.post(url, data=input_data, headers=headers)
print(r.text)

Out:

{
  "prediction": "POSITIVE"
}

我们可以看到我们的 API 生成了预期的响应。让我们也检查一下这个 API 的文档,可以在http://localhost:5000/docs找到。它应该生成一个页面,如图 13-2 所示,并且点击我们的 /api/v1/sentiment 方法的链接将提供关于如何调用该方法的额外细节,并且还有尝试的选项。这使得其他人可以提供不同的文本输入并查看 API 生成的结果,而无需编写任何代码。

Docker 容器始终以非特权模式启动,这意味着即使有终端错误,它也只会限制在容器内部,而不会对主机系统产生任何影响。因此,我们可以安全地在容器内部以根用户身份运行服务器,而不必担心对主机系统造成影响。

图 13-2. 由 FastAPI 提供的 API 规范和测试。

您可以运行前面讨论的 sudo docker tagsudo docker push 命令的组合来共享 REST API。您的同事可以轻松地拉取此 Docker 镜像来运行 API,并使用它识别不满意的客户通过提供他们的支持票证。在下一个蓝图中,我们将在云服务提供商上运行 Docker 镜像,并使其在互联网上可用。

蓝图:使用云服务提供者部署和扩展您的 API

部署机器学习模型并监视其性能是一项复杂的任务,并包括多种工具选项。这是一个不断创新的领域,不断努力使数据科学家和开发人员更容易。有几个云服务提供商和多种部署和托管 API 的方式。本蓝图介绍了一种简单的方式来部署我们在之前蓝图中创建的 Docker 容器,使用 Kubernetes。Kubernetes 是一个开源技术,提供功能来部署和管理 Docker 容器到任何底层物理或虚拟基础设施。在本蓝图中,我们将使用 Google Cloud Platform (GCP),但大多数主要提供商都支持 Kubernetes。我们可以直接将 Docker 容器部署到云服务,并使 REST API 对任何人都可用。但是,我们选择在 Kubernetes 集群内部部署它,因为这样可以轻松地扩展和缩小部署。

你可以在 GCP 上注册一个免费帐户。通过与云服务提供商的注册,你可以租用第三方提供的计算资源,并需要提供你的计费详情。在此蓝图期间,我们将保持在免费层限制内,但重要的是要密切关注你的使用情况,以确保你没有因忘记关闭某些云资源而被收费!完成注册流程后,你可以通过访问 GCP 控制台 的计费部分进行检查。在使用此蓝图之前,请确保你有一个包含 REST API 的 Docker 镜像,并已将其推送并可用于 Docker Hub 或任何其他容器注册表中。

让我们从理解如何部署 REST API 开始,如 图 13-3 所示。我们将使用 GCP 创建一个可扩展的计算集群。这只是由称为 节点 的个体服务器集合。所示的计算集群有三个这样的节点,但可以根据需要进行扩展。我们将使用 Kubernetes 将 REST API 部署到集群的每个节点上。假设我们从三个节点开始,这将创建三个 Docker 容器的副本,每个运行在一个节点上。这些容器仍未暴露给互联网,我们利用 Kubernetes 运行负载均衡器服务,该服务提供了通往互联网的网关,并根据其利用率重定向请求到每个容器。除了简化我们的部署流程外,使用 Kubernetes 还可以自动创建额外的实例来处理节点故障和流量波动。

图 13-3. Kubernetes 架构图。

让我们在 GCP 中创建一个项目,用于我们的部署。访问 Google Cloud,选择右上角的创建项目选项,并使用你选择的名称创建一个项目(我们选择情感 REST API)。项目创建完成后,点击左上角的导航菜单,进入名为 Kubernetes Engine 的服务,如 图 13-4 所示。你需要点击启用计费链接,并选择在注册时设置的付款账户。你也可以直接点击计费选项卡,并为你的项目设置计费信息。假设你正在使用免费试用运行此蓝图,你将不会被收费。在此之后,需要几分钟来为我们的项目启用此功能。完成后,我们就可以继续进行部署了。

图 13-4. 在 GCP 控制台的 Kubernetes Engine 选项中启用计费。

我们可以继续使用 Google Cloud Platform 的Web 控制台或命令行工具进行工作。尽管提供的功能保持不变,但为了简洁起见并使您能够复制命令,我们选择在蓝图中使用命令行界面描述步骤。请按照说明安装 Google Cloud SDK,然后运行以下命令使用 Kubernetes 命令行工具:

gcloud components install kubectl

在新的终端窗口中,我们首先通过运行**gcloud auth login**来验证我们的用户账户。这将打开浏览器并将您重定向到 Google 认证页面。完成后,在此终端窗口中不会再次询问您。我们配置项目和计算区域,选择我们刚刚创建的项目,并从所有可用选项中选择靠近您的位置;我们选择了 us-central1-a:

gcloud config set project sentiment-rest-api
gcloud config set compute/zone us-central1-a

我们的下一步是创建一个 Google Kubernetes Engine 计算集群。这个计算集群将用于部署我们的 Docker 容器。让我们创建一个包含三个节点的集群,并请求一个类型为 n1-standard-1 的机器。这种类型的机器配备了 3.75GB 的 RAM 和 1 个 CPU。我们可以请求更强大的机器,但对于我们的 API 来说,这应该足够了:

gcloud container clusters create \  sentiment-app-cluster --num-nodes 3 \
--machine-type n1-standard-1

GCP 中的每个容器集群都配备了HorizontalPodAutoscaling,它负责监控 CPU 利用率并在需要时添加机器。请求的机器将被配置并分配给集群,一旦执行完毕,您可以通过运行gcloud compute instances list来验证正在运行的计算实例:

$ gcloud compute instances list
NAME                                   ZONE           MACHINE_TYPE   STATUS
gke-sentiment-app-cluste-default-pool  us-central1-a  n1-standard-1  RUNNING
gke-sentiment-app-cluste-default-pool  us-central1-a  n1-standard-1  RUNNING
gke-sentiment-app-cluste-default-pool  us-central1-a  n1-standard-1  RUNNING

现在我们的集群已经启动并运行,我们将通过 Kubernetes 将我们在前一个蓝图中创建的 Docker 镜像部署到这个集群。我们的 Docker 镜像在 Docker Hub 上可用,并通过username/project_name:tag唯一标识。我们通过以下命令将我们的部署命名为sentiment-app

kubectl create deployment sentiment-app --image=textblueprints/sentiment-app:v0.1

一旦启动,我们可以通过命令kubectl get pods确认它正在运行,这将显示我们有一个正在运行的 pod。在这里,一个 pod 类似于一个容器;换句话说,一个 pod 等同于提供的镜像的一个运行中的容器。但是,我们有一个三节点集群,我们可以轻松地部署更多我们的 Docker 镜像实例。让我们使用以下命令将其扩展到三个副本:

kubectl scale deployment sentiment-app --replicas=3

您可以验证其他 Pod 是否已经开始运行了。有时,由于容器部署到集群中的节点,可能会有延迟,并且您可以使用命令kubectl describe pods找到详细信息。通过拥有多个副本,我们使我们的 REST API 在发生故障时仍然可以持续可用。例如,假设其中一个 Pod 因错误而停机;仍将有两个实例提供 API。Kubernetes 还将自动在发生故障时创建另一个 Pod 以维护所需的状态。这也是因为 REST API 是无状态的,并且在其他情况下需要额外的故障处理。

虽然我们已经部署并扩展了 REST API,但我们尚未将其提供给互联网。在这最后一步中,我们将添加一个名为sentiment-app-loadbalancerLoadBalancer服务,它作为 HTTP 服务器将 REST API 暴露给互联网,并根据流量将请求定向到三个 Pod。重要的是要区分参数port,这是LoadBalancer暴露的端口,和target-port,这是每个容器暴露的端口:

kubectl expose deployment sentiment-app --name=sentiment-app-loadbalancer
--type=LoadBalancer --port 5000 --target-port 5000

如果运行kubectl get service命令,它将提供运行的所有 Kubernetes 服务的列表,包括sentiment-app-loadbalancer。需要注意的参数是EXTERNAL-IP,它可以用于访问我们的 API。可以使用链接http://[EXTERNAL-IP]:5000/apidocs访问sentiment-app,该链接将提供 Swagger 文档,并且可以向http://[EXTERNAL-IP]:5000/api/v1/sentiment发出请求:

$ kubectl expose deployment sentiment-app --name=sentiment-app-loadbalancer \
--type=LoadBalancer --port 5000 --target-port 5000
service "sentiment-app-loadbalancer" exposed
$ kubectl get service
NAME                         TYPE           CLUSTER-IP    EXTERNAL-IP
kubernetes                   ClusterIP      10.3.240.1    <none>
sentiment-app-loadbalancer   LoadBalancer   10.3.248.29   34.72.142.113

假设您重新训练了模型并希望通过 API 提供最新版本。我们必须使用新标签(v0.2)构建新的 Docker 映像,然后使用命令kubectl set image将映像设置为该标签,Kubernetes 将以滚动方式自动更新集群中的 Pod。这确保了我们的 REST API 将始终可用,但也使用了滚动策略来部署新版本。

当我们想要关闭部署和集群时,可以运行以下命令首先删除LoadBalancer服务,然后拆除集群。这也将释放您正在使用的所有计算实例:

kubectl delete service sentiment-app-loadbalancer
gcloud container clusters delete sentiment-app-cluster

这个蓝图提供了一种简单的方法来使用云资源部署和扩展您的机器学习模型,并且不涵盖可以对生产部署至关重要的几个其他方面。通过持续监控诸如准确性之类的参数并添加重新训练的触发器,可以跟踪模型的性能。为了确保预测的质量,必须有足够的测试用例和其他质量检查,然后才能从 API 返回结果。此外,良好的软件设计必须提供身份验证、身份管理和安全性,这应该是任何公开可用的 API 的一部分。

蓝图:自动版本控制和部署构建

在之前的蓝图中,我们创建了我们的 REST API 的第一个部署。考虑到您现在可以访问额外的数据并重新训练模型以达到更高的准确性水平。我们希望使用 GitHub Actions 提供一种自动化方式来部署更新到您的 API。由于本书和 sentiment-app 的代码都托管在 GitHub 上,因此使用 GitHub Actions 是合理的,但根据环境的不同,您也可以使用其他工具,比如 GitLab

我们假设您已经在重新训练后保存了模型文件。让我们检查我们的新模型文件,并对 main.py 进行任何额外的更改。您可以在 Git 存储库 上看到这些增加。一旦所有更改都已经检入,我们决定满意并准备部署这个新版本。我们必须使用 git tag v0.2 命令将当前状态标记为我们要部署的状态。这将绑定标签名(v0.2)到当前提交历史中的特定点。标签通常应遵循 语义化版本,其中版本号以 MAJOR.MINOR.PATCH 形式分配,通常用于标识给定软件模块的更新。一旦分配了标签,可以进行额外的更改,但这些更改不会被视为已标记状态的一部分。它始终指向原始提交。我们可以通过运行 git push origin tag-name 将创建的标签推送到存储库。

使用 GitHub Actions,我们创建了一个部署流水线,使用标记存储库的事件来触发部署流水线的启动。此流水线定义在位于文件夹 .github/workflow/ 中的 main.yml 文件中,并定义了每次分配新标记时运行的步骤。因此,每当我们想要发布 API 的新版本时,我们可以创建一个新的标记并将其推送到存储库。

让我们来看一下部署步骤:

name: sentiment-app-deploy

on:
  push:
    tags:
      - '*'

jobs:
  build:
    name: build
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:

文件以一个名称开始,用于识别 GitHub 工作流程,而 on 关键字则指定了触发部署的事件。在这种情况下,我们指定只有包含标签的 Git 推送命令才会启动此部署。这样可以确保我们不会在每次提交时都进行部署,并且通过标签控制 API 的部署。我们还可以选择仅构建特定的标签,例如主要版本修订。jobs 指定了必须运行的一系列步骤,并设置了 GitHub 用于执行操作的环境。build 参数定义了要使用的构建机器类型(ubuntu),以及整个步骤系列的超时值(设为 10 分钟)。

接下来,我们将第一组操作指定如下:

    - name: Checkout
      uses: actions/checkout@v2

    - name: build and push image
      uses: docker/build-push-action@v1
      with:
        username: ${{ secrets.DOCKER_USERNAME }}
        password: ${{ secrets.DOCKER_PASSWORD }}
        repository: sidhusmart/sentiment-app
        tag_with_ref: true
        add_git_labels: true
        push: ${{ startsWith(github.ref, 'refs/tags/') }}

    - name: Get the Tag Name
      id: source_details
      run: |-
        echo ::set-output name=TAG_NAME::${GITHUB_REF#refs/tags/}

第一步通常是检出,它在构建机器上检出最新的代码。下一步是使用来自标签的最新提交构建 Docker 容器,并将其推送到 Docker Hub 注册表。docker/build-push-action@v1 是一个已经在GitHub Marketplace中可用的 GitHub 操作,我们重复使用它。注意使用密码来传递用户凭据。您可以通过访问 GitHub 存储库的“设置”>“密码”选项卡来加密和存储您的部署所需的用户凭据,如图 13-5 所示。这使我们能够保持安全性并启用无需任何密码提示的自动构建。我们使用与 Git 提交中使用的相同标签对 Docker 镜像进行标记。我们添加另一个步骤来获取标签,并将其设置为环境变量TAG_NAME,这将在更新群集时使用。

图 13-5. 使用密码向存储库添加凭据。

对于部署步骤,我们必须连接到正在运行的 GCP 集群并更新我们用于部署的映像。首先,我们必须将PROJECT_IDLOCATION_NAMECLUSTER_NAMEGCLOUD_AUTH添加到密码中以启用此操作。我们将这些编码为密码,以确保我们的云部署项目细节不会公开存储。您可以通过使用提供的说明来获取GCLOUD_AUTH,并将下载的密钥中的值添加为此字段的密码。

部署的下一步包括在构建机器上设置gcloud实用程序,并使用它获取 Kubernetes 配置文件:

    # Setup gcloud CLI
    - uses: GoogleCloudPlatform/github-actions/setup-gcloud@master
      with:
        version: '290.0.1'
        service_account_key: ${{ secrets.GCLOUD_AUTH }}
        project_id: ${{ secrets.PROJECT_ID }}

    # Get the GKE credentials so we can deploy to the cluster
    - run: |-
        gcloud container clusters get-credentials ${{ secrets.CLUSTER_NAME }} \
                                           --zone ${{ secrets.LOCATION_ZONE }}

最后,我们使用最新的 Docker 镜像更新 Kubernetes 部署。这是我们使用TAG_NAME来标识我们在第二步推送的最新发布的地方。最后,我们添加一个操作来监视我们集群中滚动的状态:

    # Deploy the Docker image to the GKE cluster
    - name: Deploy
      run: |-
        kubectl set image --record deployment.apps/sentiment-app \
                  sentiment-app=textblueprints/sentiment-app:\
                  ${{ steps.source_details.outputs.TAG_NAME }}

    # Verify that deployment completed
    - name: Verify Deployment
      run: |-
        kubectl rollout status deployment.apps/sentiment-app
        kubectl get services -o wide

使用存储库的“操作”选项卡可以跟踪构建流水线的各个阶段,如图 13-6 所示。在部署流水线的末尾,应该可以在相同的网址上获取更新后的 API 版本,并且还可以通过访问 API 文档来进行测试。

当代码和模型文件足够小以打包到 Docker 镜像中时,此技术效果很好。如果我们使用深度学习模型,情况通常并非如此,不建议创建大型 Docker 容器。在这种情况下,我们仍然使用 Docker 容器来打包和部署我们的 API,但模型文件驻留在主机系统上,并且可以附加到 Kubernetes 集群上。对于云部署,这使用像Google 持久磁盘这样的持久存储。在这种情况下,我们可以通过执行集群更新并更改附加的卷来执行模型更新。

图 13-6. 通过推送 Git 标签启动的 GitHub 部署工作流程。

总结

我们引入了多个蓝图,旨在允许您分享使用本书前几章创建的分析和项目。我们首先向您展示如何创建可复制的 conda 环境,这将使您的队友或其他学习者能够轻松重现您的结果。借助 Docker 环境的帮助,我们甚至可以更轻松地分享您的分析,创建一个完整的环境,无论合作者使用的平台或基础设施如何,都能正常工作。如果有人希望将您的分析结果集成到他们的产品或服务中,我们可以将机器学习模型封装为一个可以从任何语言或平台调用的 REST API。最后,我们提供了一个蓝图,可以轻松创建您的 API 的云部署,根据使用情况进行扩展或缩减。这种云部署可以轻松更新为模型的新版本或额外功能。在增加每一层抽象的同时,我们使分析对不同(和更广泛的)受众可访问,并减少了暴露的细节量。

进一步阅读

posted @ 2024-06-17 17:11  绝不原创的飞龙  阅读(17)  评论(0编辑  收藏  举报