Python-文本分析蓝图-一-

201 阅读1小时+

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 APIRequests, 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)

输出:

 会话年份国家国家名称演讲者职位文本
3871511996PER秘鲁弗朗西斯科·图德拉·范·布鲁赫尔·道格拉斯外交部长在此,我首先要向您和本届大会转达秘鲁人民的问候和祝贺……
4697562001GBR英国杰克·斯特劳外交部长请允许我热情地祝贺您,先生,您担任第五十六届大会主席一职。\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.0049.6112.8925.0039.0051.0061.0070.00
年份7507.001994.6112.891970.001984.001996.002006.002015.00
长度7507.0017967.287860.042362.0012077.0016424.0022479.5072041.00

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

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

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

Out:

 计数唯一最顶部频率
国家7507199ITA46
发言者74805428谢悠姆·梅斯芬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.applymap相同,但允许额外参数
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)

输出:

nuclearterrorismclimatefreedomyear
1970192718128
1971275935205
...............
2014144404654129
2015246378662148
注意

即使在我们的例子中只使用了属性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

 idcreated_atbody
21762866013722017-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,它让购买、出售和存储加密货币(如比特币)变得非常简单和安全。… t.co/4m0mpyQTSN
17今天连接上📉 #getconnected #bitcointrading #Bitcoin #BitcoinCash #bitcoinmining #cryptocurrency t.co/J60bCyFPUI
22RT @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_atfull_textentities.hashtags
10505Sat Sep 19 22:30:12 +0000 2020Milk 被创造出来是为了让流动性提供者(持有 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]}]
11882Sat 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)