精通-Python-社交媒体挖掘-全-
精通 Python 社交媒体挖掘(全)
零、前言
在过去的几年里,社交媒体的人气急剧增长,越来越多的用户通过不同的平台分享各种信息。公司使用社交媒体平台来推广他们的品牌,专业人士在网上保持公众形象并使用社交媒体进行社交,普通用户讨论任何话题。更多的用户也意味着更多的数据等待挖掘。
你,这本书的读者,很可能是一个开发者、工程师、分析师、研究员或学生,希望将数据挖掘技术应用于社交媒体数据。作为数据挖掘从业者(或准从业者),从这个角度来看,不乏机遇和挑战。
用 Python 掌握社交媒体挖掘将为您提供利用这些丰富数据所需的基本工具。这本书将开始一段旅程,介绍 Python 中数据分析的主要工具,提供您开始使用 NLP、机器学习、社交网络分析和数据可视化等应用程序所需的信息。通过最受欢迎的社交媒体平台(包括推特、脸书、谷歌+、Stack Overflow、Blogger、YouTube 等)的逐步指南,您将了解如何从这些网络访问数据,以及如何执行不同类型的分析,以便从原始数据中提取有用的见解。
本书涉及三个主要方面,如下表所示:
- 社交媒体应用编程接口:每个平台都以不同的方式提供对其数据的访问。了解如何与他们互动可以回答问题:我们如何获取数据?还有我们能得到什么样的数据?这一点很重要,因为没有数据访问,就没有数据分析可进行。每一章都聚焦于不同的社交媒体平台,并提供了如何与相关 API 交互的细节。
- 数据挖掘技术:仅仅从应用编程接口中获取数据并不能给我们提供多少价值。下一步是回答问题:我们可以用数据做什么?每一章都提供了你需要的概念,来理解你可以用数据进行的分析,以及为什么它提供了价值。从理论上来说,选择是简单地抓需要的东西的表面,而不是过多地挖掘属于学术教科书的细节。目的是提供可以让你轻松入门的实际例子。
- 数据科学的 Python 工具:一旦我们理解了我们可以用数据做什么,最后一个问题是:我们怎么做? Python 已经成为数据科学的主要语言之一。其易于理解的语法和语义,加上其丰富的科学计算生态系统,为初学者提供了一个温和的学习曲线,同时提供了专家所需的所有尖锐工具。这本书介绍了科学计算领域中使用的主要 Python 库,如 NumPy、pandas、NetworkX、scikit-learn、NLTK 等等。实际示例将采用简短脚本的形式,您可以使用(也可能扩展)这些脚本对您访问过的社交媒体数据执行不同且有趣的分析。
如果探索这三个主要主题交汇的领域是一件有趣的事情,这本书就是为你准备的。
这本书涵盖了什么
第 1 章、社交媒体、社交数据和 Python ,介绍了使用 Python 应用于社交媒体的数据挖掘的主要概念。本章通过向读者简要介绍机器学习、自然语言处理、社交网络分析和数据可视化,讨论了数据科学的主要 Python 工具,并为设置 Python 环境提供了一些帮助。
第 2 章、# miningtwetter–Hashtags、Topics 和 Time Series ,开启了使用 Twitter 数据进行数据挖掘的实践讨论。在设置了一个推特应用程序与推特应用程序接口交互之后,本章解释了如何通过流式应用程序接口获取数据,以及如何对标签和文本执行一些频率分析。本章还讨论了一些时间序列分析,以了解推文随时间的分布。
第三章、推特上的用户、关注者和社区,继续关于推特挖掘的讨论,将注意力集中在用户和用户之间的互动上。本章展示了如何挖掘用户之间的联系和对话。本章中解释的有趣应用包括用户聚类(细分)以及如何衡量影响力和用户参与度。
第 4 章、脸书上的帖子、页面和用户交互,重点关注脸书和脸书图 API。在了解了如何与 Graph API 进行交互(包括安全性和隐私方面)之后,提供了如何从用户个人资料和脸书页面中挖掘帖子的示例。时间序列分析和用户参与的概念应用于用户交互,如评论、喜欢和反应。
第五章、谷歌+ 上的话题分析,涵盖了谷歌的社交网络。在了解如何访问谷歌集中式平台后,讨论了如何在谷歌+上搜索内容和用户的示例。本章还展示了如何将来自谷歌应用编程接口的数据嵌入到使用 Python 微框架 Flask 构建的定制网络应用程序中。
第六章、StackExchange 问答,讲解问答话题,以 StackExchange 网络为首要例子。读者有机会学习如何在这个网络的不同站点上搜索用户和内容,最著名的是 Stack Overflow。通过使用他们的数据转储进行在线处理,本章介绍了应用于文本分类的监督机器学习方法,并展示了如何将机器学习模型嵌入到实时应用中。
第 7 章、博客、RSS、维基百科和自然语言处理,教授文本分析。网络在文本挖掘方面充满了机会,本章展示了如何与几个数据源交互,如 WordPress.com 应用编程接口、博客应用编程接口、RSS 提要和维基百科应用编程接口。使用文本数据,本书中简要提到的自然语言处理的基本概念被形式化和扩展。然后,读者将通过如何从自由文本中提取实体引用的自定义示例来完成信息提取过程。
第八章挖掘所有数据!,提醒我们在数据挖掘方面,除了最常见的社交网络之外,还有很多机会。提供了如何从 YouTube、GitHub 和 Yelp 中挖掘数据的示例,以及如何构建自己的 API 客户端的讨论,以防特定平台不提供。
第 9 章、链接数据和语义网,概述了语义网及相关技术。本章讨论了链接数据、微格式和 RDF 的主题,并提供了如何从数据库和维基百科中挖掘语义信息的例子。
这本书你需要什么
本书中提供的代码示例假设您正在 Linux、macOS 或 Windows 上运行 Python 的最新版本。代码已经在 Python 3.4 上测试过了。和 Python 3.5。.旧版本(Python 3.3。或 Python 2。)不受明确支持。
第 1 章、社交媒体、社交数据和 Python ,提供了一些设置本地开发环境的说明,并介绍了将在整本书中使用的工具的简要列表。我们将利用一些基本的 Python 库进行科学计算(例如,NumPy、pandas 和 matplotlib)、机器学习(例如,scikit-learn)、自然语言处理(例如,NLTK)和社交网络分析(例如,NetworkX)。
这本书是给谁的
这本书是为中级 Python 开发人员编写的,他们希望使用公共 API 从社交媒体平台收集数据并执行统计分析,以便从数据中产生有用的见解。这本书假设了对 Python 标准库的基本理解,并提供了实际的例子来指导您创建基于社会数据的数据分析项目。
惯例
在这本书里,你会发现许多区分不同种类信息的文本样式。以下是这些风格的一些例子和对它们的意义的解释。
文本中的码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、伪 URL、用户输入和 Twitter 句柄如下所示:“此外,genre属性在这里以列表的形式呈现,具有可变数量的值。”
代码块设置如下:
from timeit import timeit
import numpy as np
if __name__ == '__main__':
setup_sum = 'data = list(range(10000))'
setup_np = 'import numpy as np;'
setup_np += 'data_np = np.array(list(range(10000)))'
当我们希望将您的注意力吸引到代码块的特定部分时,相关的行或项目以粗体显示:
Type your question, or type "exit" to quit.
> What's up with Gandalf and Frodo lately? They haven't been in the Shire for a while...
Question: What's up with Gandalf and Frodo lately? They haven't been in the Shire for a while...
Predicted labels: plot-explanation, the-lord-of-the-rings
任何命令行输入或输出都编写如下:
$ pip install --upgrade [package name]
新术语和重要词汇以粗体显示。你在屏幕上看到的文字,比如在菜单或者对话框中看到的文字,会出现这样的文字:“在key 和 access token配置页面上,开发者可以找到 API key 和 secret,以及 Access token 和 access token secret。”
注
警告或重要提示会出现在这样的框中。
型式
提示和技巧是这样出现的。
读者反馈
我们随时欢迎读者的反馈。让我们知道你对这本书的看法——你喜欢或不喜欢什么。读者反馈对我们来说很重要,因为它有助于我们开发出你真正能从中获益的标题。要向我们发送一般反馈,只需给 feedback@packtpub.com 发电子邮件,并在邮件主题中提及书名。如果你对某个主题有专业知识,并且对写作或投稿感兴趣,请参见我们位于www.packtpub.com/authors的作者指南。
客户支持
现在,您已经自豪地拥有了一本书,我们有许多东西可以帮助您从购买中获得最大收益。
下载示例代码
你可以从你在http://www.packtpub.com的账户下载这本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问http://www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以按照以下步骤下载代码文件:
- 使用您的电子邮件地址和密码登录或注册我们的网站。
- 将鼠标指针悬停在顶部的“支持”选项卡上。
- 点击代码下载和勘误表。
- 在搜索框中输入图书的名称。
- 选择要下载代码文件的书籍。
- 从您购买这本书的下拉菜单中选择。
- 点击代码下载。
下载文件后,请确保使用最新版本的解压缩文件夹:
- 视窗系统的 WinRAR / 7-Zip
- zipeg/izp/un ARX for MAC
- 适用于 Linux 的 7-Zip / PeaZip
这本书的代码包也托管在 https://github.com/bonzanini/Book-SocialMediaMiningPython 的 GitHub 上。我们还有来自丰富的图书和视频目录的其他代码包,可在https://github.com/PacktPublishing/获得。看看他们!
下载本书的彩色图片
我们还为您提供了一个 PDF 文件,其中包含本书中使用的截图/图表的彩色图像。彩色图像将帮助您更好地理解输出中的变化。您可以从https://www . packtpub . com/sites/default/files/downloads/masteringsocial mediamingwithpython _ color images . pdf下载此文件。
勘误表
尽管我们尽了最大努力来确保我们内容的准确性,但错误还是会发生。如果你在我们的某本书里发现一个错误,也许是文本或代码中的错误,如果你能向我们报告,我们将不胜感激。通过这样做,你可以让其他读者免受挫折,并帮助我们改进这本书的后续版本。如果您发现任何勘误表,请访问http://www.packtpub.com/submit-errata,选择您的书籍,点击勘误表提交表格链接,并输入您的勘误表的详细信息。一旦您的勘误表得到验证,您的提交将被接受,勘误表将上传到我们的网站或添加到该标题勘误表部分下的任何现有勘误表列表中。
要查看之前提交的勘误表,请前往https://www.packtpub.com/books/content/support并在搜索栏中输入图书名称。所需信息将出现在勘误表部分。
盗版
互联网上版权材料的盗版是所有媒体的一个持续问题。在 Packt,我们非常重视版权和许可证的保护。如果您在互联网上遇到任何形式的我们作品的非法拷贝,请立即向我们提供位置地址或网站名称,以便我们寻求补救。
请联系我们在 copyright@packtpub.com 的链接到可疑的盗版材料。
我们感谢您在保护我们的作者方面的帮助,以及我们为您带来有价值内容的能力。
问题
如果你对这本书的任何方面有问题,你可以联系我们在 questions@packtpub.com,我们将尽最大努力解决这个问题。
一、社交媒体、社交数据和 Python
这本书是关于使用 Python 将数据挖掘技术应用到社交媒体的。前一句中突出的三个关键词帮助我们定义了这本书的预期读者:任何对探索这三个主题交汇的领域感兴趣的开发人员、工程师、分析师、研究员或学生。
在本章中,我们将涵盖以下主题:
- 社交媒体和社交数据
- 从社交媒体中挖掘数据的整个过程
- 设置 Python 开发环境
- 用于数据科学的 Python 工具
- 用 Python 处理数据
开始
2015 年第二季度,脸书报告了近 15 亿月活跃用户。2013 年,推特报道每天有 5 亿多条推文。规模较小,但肯定是本书读者感兴趣的,2015 年,Stack Overflow 宣布,自网站开通以来,他们的平台上已有超过 1000 万个编程问题被提问。
当描述随着越来越多的用户通过不同平台分享越来越多的信息,社交媒体的受欢迎程度如何呈指数级增长时,这些数字只是冰山一角。这些丰富的数据为数据挖掘从业者提供了独特的机会。这本书的目的是通过使用社交媒体 API 来引导读者收集可以用 Python 工具分析的数据,以便对用户如何在社交媒体上进行交互产生有趣的见解。
本章为初步讨论社交媒体挖掘中的挑战和机遇奠定了基础,并介绍了将在以下章节中使用的一些 Python 工具。
社交媒体——挑战与机遇
在传统媒体中,用户通常只是消费者。信息朝一个方向流动:从出版商到用户。社交媒体打破了这种模式,让每个用户同时成为消费者和发布者。很多学术刊物都是以此为题,目的是定义社交媒体这个名词到底是什么意思(比如全世界的用户,团结起来!社交媒体的挑战与机遇,安德烈亚斯·m·卡普兰,迈克尔·海恩林, 2010 。不同社交媒体平台之间最常共享的方面如下:
- 基于互联网的应用
- 用户生成的内容
- 建立工作关系网
社交媒体是基于互联网的应用。很明显,互联网和移动技术的进步促进了社交媒体的发展。通过你的手机,你实际上可以立即连接到一个社交媒体平台,发布你的内容,或者赶上最新的新闻。
社交媒体平台由用户生成的内容驱动。与传统媒体模式相反,每个用户都是潜在的出版商。更重要的是,任何用户都可以通过像按钮一样的来分享内容、评论或表达积极的评价(有时也称为向上投票或竖起大拇指),从而与其他用户互动。
社交媒体是关于网络的。如上所述,社交媒体是关于用户与其他用户的互动。连接是大多数社交媒体平台的核心概念,你通过新闻源或时间线消费的内容是由你的连接驱动的。
由于这些主要功能在多个平台中占据核心地位,社交媒体被用于多种目的:
- 与朋友和家人保持联系(例如,通过脸书)
- 微博和了解最新消息(例如,通过推特)
- 与您的专业网络保持联系(例如,通过 LinkedIn)
- 共享多媒体内容(例如,通过 Instagram、YouTube、Vimeo 和 Flickr)
- 寻找问题的答案(例如,通过堆栈溢出、堆 StackExchange 和 Quora)
- 查找和组织感兴趣的项目(例如,通过 Pinterest)
这本书旨在回答一个中心问题:如何从来自社交媒体的数据中提取有用的知识?退一步说,我们需要定义什么是知识什么是有用。
传统的知识定义来自信息科学。知识的概念通常被描绘成金字塔的一部分,有时被称为知识层次,它以数据为基础,以信息为中间层,以知识为顶层。下图显示了这种知识层次结构:

图 1.1:从原始数据到语义知识
攀登金字塔意味着从原始数据中提炼知识。从原始数据到提炼知识的旅程经历了语境和意义的整合。随着我们爬上金字塔,我们构建的技术对原始数据有了更深的理解,更重要的是,对生成这些数据的用户有了更深的理解。换句话说,它变得更加有用。
在这种情况下,有用的知识意味着可操作的知识,即使决策者能够实施业务战略的知识。作为这本书的读者,你将理解从社会数据中提取价值的关键原则。了解用户如何通过社交媒体平台进行互动是这一旅程的关键方面之一。
以下部分列出了从社交媒体平台挖掘数据的一些挑战和机遇。
机会
开发数据挖掘系统的关键机会是从数据中提取有用的见解。该过程的目的是使用数据挖掘技术来回答有趣(有时是困难)的问题,以丰富我们对特定领域的知识。例如,在线零售店可以应用数据挖掘来了解他们的顾客如何购物。通过这种分析,他们能够向客户推荐产品,这取决于他们的购物习惯(例如,购买项目 A 的用户也购买项目 B)。总的来说,这将带来更好的客户体验和满意度,反过来也能产生更好的销量。
不同领域的许多组织可以应用数据挖掘技术来改进他们的业务。一些例子包括:
-
银行业:
- 确定忠实客户,为他们提供独家促销
- 识别欺诈交易模式以降低成本
-
医学:
- 了解患者行为以预测手术就诊
- 支持医生根据患者的病史确定成功的治疗方法
-
零售:
- 了解购物模式以改善客户体验
- 通过更好的针对性提高营销活动的有效性
- 分析实时交通数据,找到最快的食物运送路线
那么它是如何转化到社交媒体领域的呢?问题的核心在于用户如何通过社交媒体平台分享他们的数据。组织不再局限于分析他们直接收集的数据,他们可以访问更多的数据。
这种数据收集的解决方案是通过精心设计的语言无关的 API 实现的。事实上,社交媒体平台的一个常见做法是向希望将其应用程序与特定社交媒体功能集成的开发人员提供网络应用编程接口。
注
应用编程接口
应用程序编程接口 ( 应用编程接口)是一组过程定义和协议,根据允许的操作、输入和输出来描述软件组件(如库或远程服务)的行为。当使用第三方应用编程接口时,开发人员不需要担心组件的内部,只需要担心他们如何使用它。
对于网络应用编程接口这个术语,我们指的是一个向公众公开多个 URIs 的网络服务,可能在一个身份验证层之后,以访问数据。设计这种 API 的一种常见的架构方法叫做表示状态转移 ( REST )。实现 REST 架构的应用编程接口叫做 RESTful 应用编程接口。我们仍然更喜欢通用术语网络应用编程接口,因为许多现有的应用编程接口并不严格遵循 REST 原则。就本书而言,不需要对 REST 架构有深刻的理解。
挑战
社交媒体挖掘的一些挑战来自更广泛的数据挖掘领域。
在处理社交数据时,我们经常会处理大数据。要理解大数据的含义及其带来的挑战,我们可以回到传统定义( 3D 数据管理:控制数据量、速度和变化、道格·兰尼、 2001 )也就是所谓的大数据的三个 Vs:量、变化和速度。多年来,这一定义也通过增加更多的 Vs 得到了扩展,最显著的是价值,因为为组织提供价值是利用大数据的主要目的之一。关于最初的三个 Vs,卷意味着处理跨越多台机器的数据。当然,这需要与小型数据处理不同的基础设施(例如,内存)。此外,从数据增长如此之快以至于大的概念成为一个移动目标的意义上来说,体积也与速度相关联。最后,变体关注数据如何以不同的格式和结构呈现,它们之间通常不兼容,并且具有不同的语义。来自社交媒体的数据可以检查所有三个 Vs。
大数据的兴起推动了数据库技术新方法的发展,使之成为一个名为 NoSQL 的系统家族。该术语是多个数据库范例的总称,这些范例都具有远离传统关系数据的共同特征,促进了动态模式设计。虽然这本书不是关于数据库技术的,但从这个领域,我们仍然可以理解处理结构化、非结构化和半结构化数据的需要。短语结构化数据是指组织良好且通常以表格形式呈现的信息。因此,与关系数据库的连接是即时的。下表显示了书店销售的图书的结构化数据示例:
| **标题** | 型 | **价格** | | One thousand nine hundred and eighty-four | 政治小说 | Twelve | | 战争与和平 | 战争小说 | Ten |这种数据是结构化的,因为每个表示的项目都有一个精确的组织,具体来说,有三个属性,称为标题、流派和价格。
结构化数据的反面是非结构化数据,即没有预定义数据模型的信息,或者根本没有按照预定义数据模型组织的信息。非结构化数据通常是文本数据的形式,例如电子邮件、文档、社交媒体帖子等。贯穿本书的技术可以用来提取非结构化数据中的模式,以提供某种结构。
在结构化和非结构化数据之间,我们可以找到半结构化数据。在这种情况下,结构要么是灵活的,要么不是完全预定义的。它有时也被称为自描述结构。半结构化数据格式的一个典型例子是 JSON。顾名思义,JSON 借用了编程语言 JavaScript 的符号。这种数据格式已经变得非常流行,因为它被广泛用作 web 应用程序中客户机和服务器之间交换数据的一种方式。下面的代码片段显示了扩展上一本书数据的 JSON 表示的一个例子:
[
{
"title": "1984",
"price": 12,
"author": "George Orwell",
"genre": ["Political fiction", "Social science fiction"]
},
{
"title": "War and Peace",
"price": 10,
"genre": ["Historical", Romance", "War novel"]
}
]
从这个例子我们可以观察到,第一本书有author属性,而第二本书没有这个属性。此外,genre属性在此显示为一个列表,具有可变数量的值。在结构良好的(关系)数据格式中,这两个方面通常都会被避免,但在 JSON 中完全可以,在处理半结构化数据时更是如此。
对结构化和非结构化数据的讨论转化为以不同的方式处理不同的数据格式和处理数据完整性。短语数据完整性用于捕获来自脏数据、不一致数据或不完整数据的挑战组合。
在分析用户生成的内容时,数据不一致和不完整的情况非常常见,这需要引起注意,尤其是来自社交媒体的数据。几乎以正式的方式有条不紊地分享数据的用户是非常罕见的。相反,社交媒体往往由非正式环境组成,存在一些矛盾。例如,如果用户想在公司的脸书页面上投诉某个产品,用户首先需要像页面本身一样,这与因为某个公司的产品质量差而对其不满是完全相反的。了解用户在社交媒体平台上的互动方式对于设计一个好的分析至关重要。
*开发数据挖掘应用还需要我们考虑与数据访问相关的问题,尤其是当公司政策转化为缺乏数据进行分析时。换句话说,数据并不总是公开的。上一段讨论了在社交媒体挖掘中,与其他公司环境相比,这是一个不太重要的问题,因为大多数社交媒体平台都提供了精心设计的语言无关的 API,允许我们访问所需的数据。当然,这些数据的可用性仍然取决于用户如何共享他们的数据,以及他们如何授予我们访问权限。例如,脸书用户可以决定可以在他们的公共个人资料中显示的详细程度,以及只能向他们的朋友显示的详细程度。个人资料信息,如生日、当前位置和工作经历(以及更多),都可以单独标记为私有或公共。同样,当我们试图通过脸书应用编程接口访问这些数据时,注册我们应用程序的用户有机会只允许我们访问我们要求的数据的有限子集。
数据挖掘的最后一个一般挑战在于理解数据挖掘过程本身并能够解释它。换句话说,在我们开始分析数据之前提出正确的问题并不总是简单的。更多的时候,研发 ( R & D )流程是由探索性分析驱动的,也就是说,为了理解如何解决问题,我们首先需要开始篡改它。统计学中一个相关的概念是由短语描述的,相关性并不意味着因果关系。许多统计检验可以用来建立两个变量之间的相关性,即两个事件一起发生,但这不足以建立两个方向的因果关系。奇怪关联的有趣例子在网上随处可见。一个流行的案例发表在最著名的医学期刊之一《新英格兰医学杂志》上,显示了人均巧克力消费量与诺贝尔奖授予价格之间的有趣相关性(巧克力消费、认知功能,以及诺贝尔奖获得者、弗朗茨·h·梅瑟利、 2012 )。
在执行探索性分析时,一定要记住相关性(两个事件一起发生)是双向关系,而因果关系(事件 A 导致了事件 B)是单向关系。是巧克力让你更聪明,还是聪明人比一般人更喜欢巧克力?这两件事是偶然发生的吗?是否有第三个尚未被发现的变量在相关性中扮演了某种角色?简单地观察一个相关性不足以描述因果关系,但对我们正在观察的数据提出重要问题通常是一个有趣的起点。
以下部分概括了我们的应用程序与社交媒体应用编程接口交互并执行所需分析的方式。
社交媒体挖掘技术
本节简要讨论构建社交媒体挖掘应用程序的整个过程,然后在接下来的章节中深入探讨细节。
该过程可以总结为以下步骤:
- 证明
- 数据收集
- 数据清理和预处理
- 建模和分析
- 结果展示
图 1.2 显示了流程概述:

图 1.2:社交媒体挖掘的整体流程
认证步骤通常使用名为开放授权 ( 开放授权)的行业标准来执行。这个过程是三条腿,意思是它涉及三个参与者:用户、消费者(我们的应用程序)和资源提供者(社交媒体平台)。流程中的步骤如下:
- 用户同意消费者授予对社交媒体平台的访问权。
- 由于用户不直接将他们的社交媒体密码给消费者,消费者与资源提供者进行初始交换以生成令牌和秘密。这些用于签署每个请求并防止伪造。
- 然后,用户将使用令牌重定向到资源提供者,资源提供者将要求确认授权消费者访问用户的数据。
- 根据社交媒体平台的性质,它还会要求确认消费者是否可以代表用户执行任何操作,例如发布更新、共享链接等。
- 资源提供者为使用者颁发有效的令牌。
- 然后,令牌可以返回给确认访问的用户。
图 1.3 显示了 OAuth 过程,并参考了前面描述的每个步骤。需要记住的一点是,凭据(用户名/密码)的交换只在用户和资源提供者之间通过步骤 3 和 4 进行。所有其他交换都由令牌驱动:

图 1.3:OAuth 流程
从用户的角度来看,这个看似复杂的过程发生在用户访问我们的网络应用程序,点击用脸书(或推特、谷歌+等)登录按钮的时候。然后用户必须确认他们正在授予我们的应用程序权限,对他们来说一切都在幕后发生。
从开发人员的角度来看,好的一面是 Python 生态系统已经为大多数社交媒体平台建立了完善的库,这些库伴随着身份验证过程的实现。作为开发人员,一旦您向目标服务注册了应用程序,平台就会为您的应用程序提供必要的授权令牌。图 1.4 显示了一个名为文本挖掘介绍的定制推特应用的截图。在密钥和访问令牌配置页面,开发者可以找到 API 密钥和秘密,以及访问令牌和访问令牌秘密。我们将在相关章节中讨论每个社交媒体平台的授权细节:

图 1.4:名为文本挖掘介绍的推特应用程序的配置页面。该页面包含开发人员在其应用程序中使用的所有授权令牌。
数据收集、清理和预处理步骤也取决于我们正在处理的社交媒体平台。特别是,数据收集步骤与初始授权相关联,因为我们只能下载已被授权访问的数据。另一方面,清理和预处理对于我们决定用来对数据产生见解的数据建模和分析类型是起作用的。
回到图 1.2 ,建模和分析由标记为分析引擎的组件执行。我们将在本书中遇到的典型数据处理任务是文本挖掘和图形挖掘。
文本挖掘(也称为文本分析)是从非结构化文本数据中导出结构化信息的过程。文本挖掘适用于大多数社交媒体平台,因为用户可以以帖子或评论的形式发布内容。
文本挖掘应用程序的一些示例包括:
- 文档分类:这是将文档分配到一个或多个类别的任务
- 文档聚类:这是将文档分组为连贯且彼此不同的子集(称为聚类)的任务(例如,按主题或子主题)
- 文档摘要:这是创建文档的缩短版本的任务,目的是减少用户的信息过载,同时仍然保留原始来源中描述的最重要的方面
- 实体提取:这是将文本中的实体引用定位和分类到一些期望的类别中的任务,例如人员、位置或组织
- 情绪分析:这是对文本中表达的情绪和观点进行识别和分类的任务,目的是了解对特定产品、话题、服务等的态度
并非所有这些应用程序都是为社交媒体量身定制的,但通过这些平台获得的文本数据量越来越大,这使得社交媒体成为文本挖掘的天然游乐场。
图挖掘也是关注数据的结构。图形是一种简单易懂但功能强大的数据结构,它足够通用,可以应用于许多不同的数据表示。在图中,有两个主要组件需要考虑:节点,表示实体或对象;边,表示节点之间的关系或连接。在社交媒体的背景下,图的明显用途是代表我们用户的社交关系。更一般地说,在社会科学中,用于表示社会关系的图结构也被称为社会网络。
在社交媒体中使用这样的数据结构,我们自然可以将用户表示为节点,将他们的关系(如的好友或的关注者)表示为边。这样,像喜欢 Python 的朋友的朋友这样的信息只需遍历图形(即,沿着边从一个节点走到另一个节点)就变得很容易访问。图论和图挖掘提供了更多的选项来发现不像前面的例子那样清晰可见的更深刻的见解。**
在对社交媒体挖掘进行了高级别讨论后,下一节将介绍数据挖掘项目中常用的一些有用的 Python 工具。
数据科学的 Python 工具
到目前为止,我们一直在使用术语数据挖掘来指代我们将在本书中应用的问题和技术。这一节的标题,其实提到了术语数据科学。近年来,这个词的使用激增,尤其是在商业环境中,而许多学者和记者也批评它是一个时髦词。与此同时,其他学术机构也开始开设数据科学课程,并出版了许多关于这一主题的书籍和文章。我们不会对不同学科之间的界限有强烈的看法,而是局限于观察如今人们对多个领域的普遍兴趣,包括数据科学、数据挖掘、数据分析、统计学、机器学习、人工智能、数据可视化等等。我们讨论的话题本质上都是跨学科的,而且都是时不时互相借鉴。这当然是一个在这些领域工作的令人惊讶的时刻,公众对此非常感兴趣,有趣的项目也不断有新的进展。
本节的目的是介绍 Python 作为数据科学的工具,并描述我们将在接下来的章节中使用的 Python 生态系统的一部分。
Python 是数据分析项目中最有趣的语言之一。以下是使其适合特定用途的一些原因:
- 声明性和直观的语法
- 丰富的数据处理生态系统
- 效率
由于 Python 优雅的语法,它的学习曲线很浅。作为一种动态的解释语言,它有助于快速开发和交互式探索。数据处理的生态系统将在下面的章节中进行部分描述,这些章节将介绍我们将在本书中使用的主要包。
就效率而言,解释语言和高级语言并不以速度极快而闻名。像 NumPy 这样的工具通过在幕后连接到低级库,并公开友好的 Python 界面来实现效率。此外,许多项目使用 Cython ,这是 Python 的超集,通过允许定义强变量类型和编译成 c 语言来丰富语言。Python 世界中的许多其他项目正在解决效率问题,总体目标是使纯 Python 实现更快。在本书中,我们不会深入研究 Cython 或任何这些有前途的项目,但我们将利用 NumPy(尤其是通过使用 NumPy 的其他库)进行数据分析。
Python 开发环境设置
当这本书开始的时候,Python 3.5 刚刚发布,它的一些最新特性受到了一些关注,比如对异步编程的改进支持和类型提示的语义定义。就用法而言,Python 3.5 可能还没有被广泛使用,但它代表了该语言当前的发展路线。
注
本书中的示例与 Python 3 兼容,尤其是 3.4+和 3.5+版本。
在关于 Python 2 和 Python 3 之间选择的永无止境的讨论中,需要记住的一点是,对 Python 2 的支持将在几年后被打消(在撰写本文时,日落日期是 2020 年)。Python 2 中没有开发新特性,因为这个分支只用于错误修复。另一方面,很多库还是先为 Python 2 开发,后来才增加了对 Python 3 的支持。由于这个原因,有时在某些库的兼容性方面可能会出现小问题,这通常会被社区很快解决。一般来说,如果没有强烈的理由反对这种选择,首选应该是 Python 3,尤其是对于新的绿地项目。
pip 和 virtualenv
为了保持开发环境的干净,并方便从原型到生产的过渡,建议使用virtualenv来管理虚拟环境并安装依赖项。virtualenv是一个用于创建和管理隔离的 Python 环境的工具。通过使用隔离的虚拟环境,开发人员避免了用可能彼此不兼容的库污染全局 Python 环境。这些工具允许我们维护需要不同配置的多个项目,并轻松地从一个项目切换到另一个项目。此外,虚拟环境可以安装在没有管理权限的用户可以访问的本地文件夹中。
要在全局 Python 环境中安装virtualenv以使其对所有用户可用,我们可以从终端(Linux/Unix)或命令提示符(Windows)使用pip:
$ [sudo] pip install virtualenv
如果我们当前的用户在系统上没有管理员权限,那么sudo命令在 Linux/Unix 或 macOS 上可能是必需的。
如果已经安装了软件包,可以将其升级到最新版本:
$ pip install --upgrade [package name]
注
从 Python 3.4 开始,pip工具随 Python 一起发货。之前的版本需要单独安装pip,如项目页面上所述(https://github.com/pypa/pip)。该工具还可用于将自身升级到最新版本:
$ pip install --upgrade pip
一旦virtualenv是全局可用的,对于每个项目,我们可以定义一个单独的 Python 环境,其中依赖项被隔离安装,而不会篡改全局环境。这样,跟踪单个项目所需的依赖关系就变得非常容易。
要设置虚拟环境,请执行以下步骤:
$ mkdir my_new_project # creat new project folder
$ cd my_new_project # enter project folder
$ virtualenv my_env # setup custom virtual environment
这将在当前目录中创建一个my_env子文件夹,这也是我们正在创建的虚拟环境的名称。在这个子文件夹中,我们有所有必要的工具来创建独立的 Python 环境,包括 Python 二进制文件和标准库。为了激活环境,我们可以键入以下命令:
$ source my_env/bin/activate
一旦环境处于活动状态,提示中将显示以下内容:
(my_env)$
可以使用pip为该特定环境安装 Python 包:
(my_env)$ pip install [package-name]
环境激活时所有安装pip的新 Python 库都会安装到my_env/lib/python{VERSION}/site-packages中。请注意,作为一个本地文件夹,我们不需要管理权限来执行这个命令。
当我们想要停用虚拟环境时,我们可以简单地键入以下命令:
$ deactivate
前面描述的过程应该适用于随操作系统一起提供(或可供下载)的官方 Python 发行版。
Conda、Anaconda 和 Miniconda
还有一个选项需要考虑,叫做conda(http://conda.pydata.org/),它在科学界获得了一些吸引力,因为它使得依赖管理变得非常容易。Conda 是一个开源的软件包管理器和环境管理器,用于安装多个版本的软件包(以及相关的依赖关系),这使得从一个版本切换到另一个版本变得很容易。它支持 Linux、macOS 和 Windows,虽然最初是为 Python 创建的,但它可以用来打包和分发任何软件。
conda 附带的发行版主要有两个:包含电池的版本 Anaconda,它附带了大约 100 个已经安装的科学计算软件包,以及轻量级版本 Miniconda,它只附带 Python 和 conda 安装程序,没有外部库。
如果您是 Python 新手,有一些时间进行更大的下载并腾出磁盘空间,并且不想手动安装所有软件包,您可以开始使用 Anaconda。对于 Windows 和 macOS,Anaconda 提供了图形或命令行安装程序。图 1.5 显示了 macOS 上安装过程的屏幕截图。对于 Linux,只有命令行安装程序可用。在所有情况下,都可以在 Python 2 和 Python 3 之间进行选择。如果您希望完全控制您的系统,Miniconda 可能是您最喜欢的选择:

图 1.5:Anaconda 安装的屏幕截图
安装完 conda 版本后,为了创建新的 conda 环境,可以使用以下命令:
$ conda create --name my_env python=3.4 # or favorite version
可以使用以下命令激活环境:
$ conda activate my_env
类似于virtualenv发生的情况,环境名称将在提示中可见:
(my_env)$
可以使用以下命令为此环境安装新软件包:
$ conda install [package-name]
最后,您可以通过键入以下命令来停用环境:
$ conda deactivate
conda 的另一个很好的特性是也可以从 pip 安装包,所以如果一个特定的库不能通过conda install获得,或者它没有被更新到我们需要的最新版本,我们总是可以在使用 conda 环境时回到传统的 Python 包管理器。
如果没有特别说明,默认情况下,conda 会在https://anaconda.org上查找包裹,而pip则使用位于https://pypi.python.org/pypi的 Python 包裹索引 ( PyPI 简称 CheeseShop )。也可以指示两个安装程序从本地文件系统或私有存储库中安装软件包。
以下部分将使用pip来安装所需的软件包,但是如果您喜欢使用这种替代方法,您可以轻松切换到 conda。
高效的数据分析
本节介绍科学巨蟒的两个基础包: NumPy 和熊猫。
NumPy(数值 Python)提供快速高效的处理或类似数组的数据结构。对于数字数据,使用 Python 内置(例如,列表或字典)存储和操作数据比使用 NumPy 数组要慢得多。此外,NumPy 数组经常被其他库用作需要矢量化操作的不同算法的输入和输出容器。
要使用pip / virtualenv安装 NumPy,请使用以下命令:
$ pip install numpy
当使用包含电池的 Anaconda 发行版时,开发人员会发现 NumPy 和 pandas 都已预装,因此无需执行前面的安装步骤。
这个库的核心数据结构是名为ndarray的多维数组。
下面的代码片段是从交互式解释器运行的,展示了用 NumPy 创建一个简单数组的过程:
>>> import numpy as np
>>> data = [1, 2, 3] # a list of int
>>> my_arr = np.array(data)
>>> my_arr
array([1, 2, 3])
>>> my_arr.shape
(3,)
>>> my_arr.dtype
dtype('int64')
>>> my_arr.ndim
1
该示例显示,我们的数据由一个一维数组(即ndim属性)表示,如我们所料,该数组包含三个元素。数组的数据类型是int64,因为我们所有的输入都是整数。
我们可以通过分析一个简单的操作来观察 NumPy 数组的速度,例如使用timeit模块对一个列表求和:
# Chap01/demo_numpy.py
from timeit import timeit
import numpy as np
if __name__ == '__main__':
setup_sum = 'data = list(range(10000))'
setup_np = 'import numpy as np;'
setup_np += 'data_np = np.array(list(range(10000)))'
run_sum = 'result = sum(data)'
run_np = 'result = np.sum(data_np)'
time_sum = timeit(run_sum, setup=setup_sum, number=10000)
time_np = timeit(run_np, setup=setup_np, number=10000)
print("Time for built-in sum(): {}".format(time_sum))
print("Time for np.sum(): {}".format(time_np))
timeit模块将一段代码作为第一个参数,运行多次,产生运行所需的时间作为输出。为了专注于我们正在分析的特定代码段,初始数据设置和所需的导入被移动到setup参数,该参数将只运行一次,并且不包括在概要分析中。最后一个参数number将迭代次数限制在 10,000 次,而不是默认值 100 万次。您观察到的输出应该如下所示:
Time for built-in sum(): 0.9970562970265746
Time for np.sum(): 0.07551316602621228
内置的sum()功能比 NumPy sum()功能慢 10 倍以上。对于更复杂的代码片段,我们可以很容易地观察到更大数量级的差异。
型式
命名约定
Python 社区已经汇聚了一些事实上的“T2”标准来导入一些流行的库。NumPy 和熊猫是两个众所周知的例子,因为它们通常是用别名导入的,例如:import numpy as np
通过这种方式,可以使用np.function_name()访问 NumPy 功能,如前面的示例所示。同样,熊猫图书馆也别名为pd。原则上,用from numpy import *导入整个库命名空间被认为是不好的做法,因为它污染了当前的命名空间。
我们希望记住的 NumPy 阵列的一些特征详述如下:
- NumPy 数组的大小在创建时是固定的,不像 Python 列表那样可以动态更改,因此更改数组大小的操作实际上是创建一个新的数组并删除原来的数组。
- 数组中每个元素的数据类型必须相同(对象数组除外,因此内存大小可能不同)。
- NumPy 促进了向量运算的使用,产生了更紧凑和可读的代码。
本节介绍的第二个图书馆是熊猫。它建立在 NumPy 之上,因此它也提供了快速的计算,并且它提供了方便的数据结构,称为系列和数据帧,允许我们以灵活简洁的方式执行数据操作。
熊猫的一些好特征包括:
- 快速高效的数据操作对象
- 用于在不同格式(如 CSV、文本文件、MS Excel 电子表格或 SQL 数据结构)之间读写数据的工具
- 丢失数据的智能处理和相关数据对齐
- 大型数据集的基于标签的切片和切片
- 类似 SQL 的数据聚合和数据转换
- 支持时序功能
- 集成绘图功能
我们可以通过通常的程序从奶酪店安装熊猫:
$ pip install pandas
让我们考虑下面的例子,从 Python 交互式解释器运行,使用用户数据的一个小的虚构玩具例子:
>>> import pandas as pd
>>> data = {'user_id': [1, 2, 3, 4], 'age': [25, 35, 31, 19]}
>>> frame = pd.DataFrame(data, columns=['user_id', 'age'])
>>> frame.head()
user_id age
0 1 25
1 2 35
2 3 31
3 4 19
初始数据布局基于字典,其中键是用户的属性(用户 ID 和年龄表示为年数)。字典中的值是列表,对于每个用户,相应的属性根据位置对齐。一旦我们用这些数据创建了数据框架,数据的对齐就变得非常清晰。head()函数以表格形式打印数据,如果数据大于表格,则截断到前十行。
我们现在可以通过增加一列来扩充数据框:
>>> frame['over_thirty'] = frame['age'] > 30
>>> frame.head()
user_id age over_thirty
0 1 25 False
1 2 35 True
2 3 31 True
3 4 19 False
使用 pandas 声明性语法,我们不需要遍历整个列来访问它的数据,但是我们可以应用一个类似 SQL 的操作,如前面的例子所示。此操作使用现有数据创建布尔列。我们还可以通过添加新数据来扩充数据框架:
>>> frame['likes_python'] = pd.Series([True, False, True, True], index=frame.index)
>>> frame.head()
user_id age over_thirty likes_python
0 1 25 False True
1 2 35 True False
2 3 31 True True
3 4 19 False True
我们可以使用describe()方法观察一些基本的描述性统计:
>>> frame.describe()
user_id age over_thirty likes_python
count 4.000000 4.0 4 4
mean 2.500000 27.5 0.5 0.75
std 1.290994 7.0 0.57735 0.5
min 1.000000 19.0 False False
25% 1.750000 23.5 0 0.75
50% 2.500000 28.0 0.5 1
75% 3.250000 32.0 1 1
max 4.000000 35.0 True True
例如,我们 50%的用户超过 30 岁,其中 75%喜欢 Python。
注
下载示例代码
下载代码包的详细步骤在本书的前言中提到。请看看。
这本书的代码包也托管在 https://github.com/bonzanini/Book-SocialMediaMiningPython 的 GitHub 上。我们还有来自丰富的图书和视频目录的其他代码包,可在https://github.com/PacktPublishing/获得。看看他们!
机器学习
机器学习是研究和开发算法以从数据中学习并对数据进行预测的学科。它与数据挖掘密切相关,有时这两个字段的名称可以互换使用。这两个领域的共同区别大致如下:机器学习侧重于基于数据已知属性的预测,而数据挖掘侧重于基于数据未知属性的发现。这两个领域都从对方那里借用了算法和技术。这本书的目标之一是实用,所以我们承认,在学术上,这两个领域尽管有很大的重叠,但往往有不同的目标和假设,但我们不会对此过于担心。
机器学习应用程序的一些示例包括以下内容:
- 确定收到的电子邮件是否是垃圾邮件
- 从体育、金融或政治等已知主题列表中选择新闻文章的主题
- 分析银行交易以识别欺诈企图
- 从苹果查询决定用户对水果还是对电脑感兴趣
一些最流行的方法可以分为有监督和无监督的学习方法,在下一节中描述。这是一个过度的简化,没有描述机器学习领域的整个广度和深度,但这是一个很好的起点来欣赏它的一些技术细节。
监督学习方法可以用来解决分类等问题,在分类中,数据带有我们想要预测的附加属性,例如类的标签。在这种情况下,分类器可以将每个输入对象与期望的输出相关联。通过从输入对象的特征进行推断,分类器然后可以为新的未看到的输入预测期望的标签。常见的技术包括朴素贝叶斯 ( NB )、支持向量机 ( SVM )以及属于神经网络 ( NN )家族的模型,如感知器或多层感知器。
学习算法用来建立数学模型的样本输入称为训练数据,而我们想要获得预测的看不见的输入称为测试数据。机器学习算法的输入通常是向量的形式,向量的每个元素代表输入的特征。对于监督学习方法,分配给每个看不见的输入的期望输出通常被称为标签或目标。
无监督学习方法改为应用于数据没有相应输出值的问题。这类问题的一个典型例子是聚类。在这种情况下,一种算法试图找到数据中的隐藏结构,以便将相似的项目分组到集群中。另一个应用程序包括识别不属于特定组的项目(例如,异常检测)。常用聚类算法的一个例子是 k-means。
机器学习的主要 Python 包是 scikit-learn 。它是机器学习算法的开源集合,包括访问和预处理数据、评估算法输出以及可视化结果的工具。
您可以通过“奶酪店”安装 sci kit-学习通用程序:
$ pip install scikit-learn
在不深入技术细节的情况下,我们现在将演练 scikit 的一个应用程序-学会解决一个聚类问题。
由于我们还没有社交数据,我们可以使用 scikit-learn 附带的一个数据集。
我们正在使用的数据被称为费希尔鸢尾数据集,也称为鸢尾花数据集。它是由罗纳德·费雪在 20 世纪 30 年代引入的,现在是经典数据集之一:鉴于它的小尺寸,它经常被用在玩具示例的文献中。数据集包含来自三种鸢尾属植物的 50 个样本,每个样本有四个特征:花瓣和萼片的长度和宽度。
数据集通常被用作分类的展示示例,因为数据带有每个样本的正确标签,而它在聚类中的应用不太常见,主要是因为只有两个明显分离的清晰可见的聚类。考虑到它的小尺寸和简单的结构,它为 scikit-learn 的数据分析做了一个温和的介绍。如果你想运行这个例子,包括数据可视化部分,你还需要安装带有pip install matplotlib的 matplotlib 库。本章稍后将讨论 Python 数据可视化的更多细节。
让我们看看下面的示例代码:
# Chap01/demo_sklearn.py
from sklearn import datasets
from sklearn.cluster import KMeans
import matplotlib.pyplot as plt
if __name__ == '__main__':
# Load the data
iris = datasets.load_iris()
X = iris.data
petal_length = X[:, 2]
petal_width = X[:, 3]
true_labels = iris.target
# Apply KMeans clustering
estimator = KMeans(n_clusters=3)
estimator.fit(X)
predicted_labels = estimator.labels_
# Color scheme definition: red, yellow and blue
color_scheme = ['r', 'y', 'b']
# Markers definition: circle, "x" and "plus"
marker_list = ['o', 'x', '+']
# Assign colors/markers to the predicted labels
colors_predicted_labels = [color_scheme[lab] for lab in
predicted_labels]
markers_predicted = [marker_list[lab] for lab in
predicted_labels]
# Assign colors/markers to the true labels
colors_true_labels = [color_scheme[lab] for lab in true_labels]
markers_true = [marker_list[lab] for lab in true_labels]
# Plot and save the two scatter plots
for x, y, c, m in zip(petal_width,
petal_length,
colors_predicted_labels,
markers_predicted):
plt.scatter(x, y, c=c, marker=m)
plt.savefig('iris_clusters.png')
for x, y, c, m in zip(petal_width,
petal_length,
colors_true_labels,
markers_true):
plt.scatter(x, y, c=c, marker=m)
plt.savefig('iris_true_labels.png')
print(iris.target_names)
首先,我们将数据集加载到iris变量中,该变量是一个包含数据和关于数据的信息的对象。具体来说,iris.data包含数据本身,其形式为一个或多个 NumPy 数组,而iris.target包含一个数字标签,表示样本所属的类。在每个样本向量中,四个值分别代表萼片长度(以厘米为单位)、萼片宽度(以厘米为单位)、花瓣长度(以厘米为单位)和花瓣宽度(以厘米为单位)。使用 NumPy 数组的切片符号,我们将每个样本的第三和第四个元素分别提取到petal_length和petal_width中。这些将用于绘制二维表示的样本,即使向量有四维。
聚类过程由两行代码组成:一行代码创建KMeans算法的实例,第二行代码将数据添加到模型中。这个界面的简单性是 scikit-learn 的特点之一,在大多数情况下,它允许你用几行代码应用一个学习算法。对于 k-means 算法的应用,我们选择聚类数为 3,因为这是由数据给出的。请记住,提前知道适当数量的集群并不是经常发生的事情。确定正确(或最有趣)的聚类数本身就是一个挑战,这与应用聚类算法本身不同。由于这个例子的目的是简单介绍 scikit-learn 及其界面的简单性,所以我们走这条捷径。通常,更多的努力被投入到以 scikit-learn 理解的格式准备数据中。
示例的后半部分用于使用 matplotlib 可视化数据。首先,我们将使用color_scheme列表中定义的红色、黄色和蓝色来定义一个配色方案,以直观地区分三个聚类。其次,我们将利用这样一个事实,即每个样本的真实标签和聚类关联都是以整数形式给出的,从 0 开始,因此它们可以用作匹配其中一种颜色的索引。
请注意,虽然真实标签的数字与标签的特定含义(即类名)相关联;聚类编号只是用来说明给定的样本属于一个聚类,但没有关于该聚类含义的信息。具体来说,真实标签的三个类别分别是濑户鸢尾、云芝和弗吉尼亚鸢尾——数据集中表示的三种鸢尾。
该示例的最后几行生成了两个数据散点图,一个用于真实标签,另一个用于聚类关联,使用花瓣长度和宽度作为两个维度。两个图在图 1.6 中表示。当然,两个图中项目的位置是相同的,但是我们可以观察到的是算法是如何将三个组分开的。特别是左下方的聚类被其他两个明显分开,算法可以毫无疑问地轻松识别。相反,其他两个聚类更难区分,因为一些元素重叠,所以算法在这种情况下会出错。
再次值得一提的是,在这里我们可以发现错误,因为我们知道每个样本的真实类别。该算法只是根据输入的特征创建了一个关联:

图 1.6:虹膜数据的二维表示,根据真实标签(左)和聚类结果(右)进行着色
自然语言处理
自然语言处理 ( NLP )是与自然语言的自动分析、理解和生成的方法和技术的研究相关的学科,即人类自然书写或说出的语言。
在学术上,几十年来,它一直是一个活跃的研究领域,因为它的早期通常归功于计算机科学之父艾伦·图灵,他在 1950 年提出了一个评估机器智能的测试。这个概念相当简单:如果一个人类法官正在与两个代理人——一个人类和一个机器——进行书面对话,机器能欺骗法官认为它不是机器吗?如果出现这种情况,机器通过测试并显示出智能的迹象。
这个测试现在被称为图灵测试,在很长一段时间里,它只是计算机科学界的常识,但最近被流行媒体带到了更广泛的受众中。比如电影《模仿游戏》 ( 2014 )大致是根据艾伦·图灵的传记改编而成,片名也是对测试本身的明确引用。另一部提到图灵测试的电影是 Ex 玛奇纳 ( 2015 ),更强调一种人工智能的发展,这种人工智能可以为了自己的利益而撒谎和愚弄人类。在这部电影中,图灵测试在人类法官和看起来像人类的机器人艾娃之间进行,有直接的语言互动。在不破坏没有看过电影的人的结局的情况下,这个故事随着人工智能的发展,以一种阴暗而巧妙的方式显示出比人类聪明得多。有趣的是,这个未来机器人被训练使用搜索引擎日志,来理解和模仿人类如何提问。
人工智能的过去和假设的未来之间的这条小弯路是为了强调掌握人类语言将如何成为开发先进的人工智能 ( AI )的核心。尽管最近有了一些改进,但我们还没有完全实现,自然语言处理仍然是目前的一个热门话题。
在社交媒体的竞争中,我们明显的机会是有大量的自然语言等待挖掘。通过社交媒体获得的文本数据量在不断增加,对于许多问题,答案可能已经写好了;但是将原始文本转换成信息并不是一件容易的事情。对话一直在社交媒体上发生,用户在网络论坛上提出技术问题并找到答案,客户通过社交媒体上的评论或评论描述他们对特定产品的体验。了解这些对话的主题,找到回答这些问题的最专业的用户,并了解撰写这些评论的客户的意见,这些都是可以通过自然语言处理以相当高的准确性实现的任务。
转到 Python,NLP 最受欢迎的包之一是自然语言工具包 ( NLTK )。该工具包为许多常见的自然语言处理任务以及词汇资源和语言数据提供了友好的界面。
我们可以使用 NLTK 轻松执行的一些任务包括:
- 单词和句子的标记化,即把文本流分解成单个标记的过程
- 词类标注,即根据词类的句法功能给词类赋值,如名词、形容词、动词等
- 识别命名实体,例如,识别和分类人员、位置、组织等的引用
- 将机器学习技术(例如,分类)应用于文本
- 通常,从原始文本中提取信息
NLTK 的安装遵循通过奶酪店的通用程序:
$ pip install nltk
NLTK 与许多其他包的区别在于,这个框架还附带了用于特定任务的语言资源(即数据)。考虑到它们的大小,这些数据不包含在默认安装中,但必须单独下载。
安装程序在http://www.nltk.org/data.html有完整的记录,强烈建议您阅读本官方指南了解所有细节。
简而言之,从 Python 解释器中,您可以键入以下代码:
>>> import nltk
>>> nltk.download()
如果您在桌面环境中,这将打开一个新窗口,允许您浏览可用数据。如果桌面环境不可用,您将在终端中看到一个文本界面。您可以选择下载单个软件包,甚至下载所有数据(这将占用大约 2.2 GB 的磁盘空间)。
如果您使用管理员帐户工作,下载器会尝试将文件保存在中央位置(Windows 上的C:\nltk_data和 Unix 和 Mac 上的/usr/share/nltk_data),如果您是普通用户,下载器会尝试将文件保存在您的个人文件夹(例如,~/nltk_data)中。您也可以选择一个自定义文件夹,但是在这种情况下,NLTK 将寻找$NLTK_DATA环境变量,以知道在哪里可以找到它的数据,因此您需要相应地设置它。
如果磁盘空间不成问题,安装所有数据可能是最方便的选择,因为你只需安装一次,就可以忘记它。另一方面,下载所有内容并不能让你清楚地了解需要什么资源。如果你想完全控制你安装的东西,你可以一个一个地下载你需要的软件包。在这种情况下,在你的 NLTK 开发过程中,你会时不时地发现路上有一个LookupError形式的小凸起,这意味着你试图使用的一个资源丢失了,你必须下载它。
例如,在全新的 NLTK 安装之后,如果我们尝试从 Python 解释器中标记一些文本,我们可以键入以下代码:
>>> from nltk import word_tokenize
>>> word_tokenize('Some sample text')
Traceback (most recent call last):
# some long traceback here
LookupError:
*********************************************************************
Resource 'tokenizers/punkt/PY3/english.pickle' not found.
Please use the NLTK Downloader to obtain the resource: >>>
nltk.download()
Searched in:
- '/Users/marcob/nltk_data'
- '/usr/share/nltk_data'
- '/usr/local/share/nltk_data'
- '/usr/lib/nltk_data'
- '/usr/local/lib/nltk_data'
- ''
*********************************************************************
这个错误告诉我们负责标记化的punkt资源在任何常规文件夹中都找不到,所以我们必须回到 NLTK 下载器,通过获取这个包来解决这个问题。
假设我们现在有了一个完全正常工作的 NLTK 安装,我们可以回到前面的例子,用一些更多的细节来讨论标记化。
在自然语言处理的上下文中,标记化(也称为分段)是将一段文本分成称为标记或分段的更小单元的过程。虽然标记可以用许多不同的方式来解释,但通常我们对单词和句子感兴趣。使用word_tokenize()的一个简单例子如下:
>>> from nltk import word_tokenize
>>> text = "The quick brown fox jumped over the lazy dog"
>>> words = word_tokenize(text)
>>> print(words)
# ['The', 'quick', 'brown', 'fox', 'jumped', 'over', 'the', 'lazy', 'dog']
word_tokenize()的输出是一个字符串列表,每个字符串代表一个单词。本例中的单词边界由空格给出。以类似的方式,sent_tokenize()返回一个字符串列表,每个字符串代表一个句子,以标点符号为界。涉及两种功能的示例:
>>> from nltk import word_tokenize, sent_tokenize
>>> text = "The quick brown fox jumped! Where? Over the lazy dog."
>>> sentences = sent_tokenize(text)
>>> print(sentences)
#['The quick brown fox jumped!', 'Where?', 'Over the lazy dog.']
>>> for sentence in sentences:
... words = word_tokenize(sentence)
... print(words)
# ['The', 'quick', 'brown', 'fox', 'jumped', '!']
# ['Where', '?']
# ['Over', 'the', 'lazy', 'dog', '.']
如您所见,标点符号本身被视为标记,因此包含在word_tokenize()的输出中。这就引出了一个我们到目前为止还没有真正问过的问题:我们如何定义一个令牌?word_tokenize()功能实现了为标准英语设计的算法。由于本书的重点是来自社交媒体的数据,因此调查标准英语的规则是否也适用于我们的环境是公平的。让我们考虑一个推特数据的虚构例子:
>>> tweet = '@marcobonzanini: an example! :D http://example.com #NLP'
>>> print(word_tokenize(tweet))
# ['@', 'marcobonzanini', ':', 'an', 'example', '!', ':', 'D', 'http', ':', '//example.com', '#', 'NLP']
示例推文介绍了一些打破标准标记化的特性:
- 用户名的前缀是一个
@符号,因此@marcobonzanini被分成两个标记,其中@被识别为标点符号 - 像
:D这样的表情符号是聊天、短信、当然还有社交媒体上常见的俚语,但它们并不是标准英语的正式组成部分,因此出现了分裂 - 网址经常被用来分享文章或图片,但同样,它们不是标准英语的一部分,所以它们被分解成组件
- 像
#NLP这样的散列标签是以#为前缀的字符串,用于定义帖子的主题,这样其他用户就可以轻松地搜索主题或跟踪对话
前面的例子表明,一个表面上简单的问题,如标记化,可以隐藏许多棘手的边缘情况,这些情况需要比最初的直觉更聪明的东西来解决。幸运的是,NLTK 提供了以下现成的解决方案:
>>> from nltk.tokenize import TweetTokenizer
>>> tokenizer = TwitterTokenizer()
>>> tweet = '@marcobonzanini: an example! :D http://example.com #NLP'
>>> print(tokenizer.tokenize(tweet))
# ['@marcobonzanini', ':', 'an', 'example', '!', ':D', 'http://example.com', '#NLP']
前面的例子应该提供了 NLTK 简单界面的味道。我们将在整本书的不同场合使用这个框架。
随着人们对自然语言处理应用程序的兴趣越来越大,Python 对自然语言处理生态系统近年来有了显著的发展,许多有趣的项目越来越受到关注。特别是 Gensim ,被称为人类主题建模,是一个专注于语义分析的开源库。Gensim 和 NLTK 一样倾向于提供一个易于使用的界面,因此成为了人类的“T4”口号的一部分。推动其流行的另一个方面是效率,因为该库针对速度进行了高度优化,具有分布式计算选项,并且可以处理大型数据集,而无需将所有数据保存在内存中。
Gensim 的简单安装遵循通常的程序:
$ pip install gensim
主要的依赖项是 NumPy 和 SciPy,不过如果你想利用 Gensim 的分布式计算能力,你还需要安装 PYthon 远程对象 ( Pyro4 ):
$ pip install Pyro4
为了展示 Gensim 的简单界面,我们可以看一下文本摘要模块:
# Chap01/demo_gensim.py
from gensim.summarization import summarize
import sys
fname = sys.argv[1]
with open(fname, 'r') as f:
content = f.read()
summary = summarize(content, split=True)
for i, sentence in enumerate(summary):
print("%d) %s" % (i+1, sentence))
demo_gensim.py脚本取一个命令行参数,是一个文本文件的名称来概括。为了测试剧本,我从维基百科页面上拿了一段关于《指环王》的文字,特别是第一卷《指环王》情节的段落。可以使用以下命令调用该脚本:
$ python demo_gensim.py lord_of_the_rings.txt
这将产生以下输出:
1) They nearly encounter the Nazgûl while still in the Shire, but shake off pursuit by cutting through the Old Forest, where they are aided by the enigmatic Tom Bombadil, who alone is unaffected by the Ring's corrupting influence.
2) Aragorn leads the hobbits toward the Elven refuge of Rivendell, while Frodo gradually succumbs to the wound.
3) The Council of Elrond reveals much significant history about Sauron and the Ring, as well as the news that Sauron has corrupted Gandalf's fellow wizard, Saruman.
4) Frodo volunteers to take on this daunting task, and a "Fellowship of the Ring" is formed to aid him: Sam, Merry, Pippin, Aragorn, Gandalf, Gimli the Dwarf, Legolas the Elf, and the Man Boromir, son of the Ruling Steward Denethor of the realm of Gondor.
Gensim 中的summarize()函数实现了经典的 TextRank 算法。该算法根据句子的重要性对句子进行排序,并选择最特殊的句子来生成输出摘要。值得注意的是,这种方法是一种提取摘要技术,意思是输出只包含从输入中选择的句子,也就是说,没有文本转换、重新措辞等。输出大小约为原始文本的 25%。这可以通过可选的ratio参数来控制比例大小,或者word_count参数来控制固定的字数。在这两种情况下,输出将只包含完整的句子,也就是说,句子不会被分解以符合期望的输出大小。
社交网络分析
网络理论是图论的一部分,是将图作为离散对象之间关系的表示来研究的。它在社交媒体上的应用采取了社交网络分析 ( SNA )的形式,这是一种调查友谊或熟人等社会结构的策略。
NetworkX 是创建、操作和研究复杂网络结构的主要 Python 库之一。它提供了图形的数据结构,以及许多众所周知的标准图形算法。
对于奶酪店的安装,我们遵循通常的程序:
$ pip install networkx
下面的示例显示了如何创建一个简单的图,该图有几个代表用户的节点和节点之间的几条边,这些边代表用户之间的社交关系:
# Chap01/demo_networkx.py
import networkx as nx
from datetime import datetime
if __name__ == '__main__':
g = nx.Graph()
g.add_node("John", {'name': 'John', 'age': 25})
g.add_node("Peter", {'name': 'Peter', 'age': 35})
g.add_node("Mary", {'name': 'Mary', 'age': 31})
g.add_node("Lucy", {'name': 'Lucy', 'age': 19})
g.add_edge("John", "Mary", {'since': datetime.today()})
g.add_edge("John", "Peter", {'since': datetime(1990, 7, 30)})
g.add_edge("Mary", "Lucy", {'since': datetime(2010, 8, 10)})
print(g.nodes())
print(g.edges())
print(g.has_edge("Lucy", "Mary"))
# ['John', 'Peter', 'Mary', 'Lucy']
# [('John', 'Peter'), ('John', 'Mary'), ('Mary', 'Lucy')]
# True
节点和边都可以以 Python 字典的形式携带附加属性,这有助于描述网络的语义。
Graph类用来表示无向图,意思是不考虑边的方向。这一点从has_edge()功能的使用中可以清楚地看出,该功能检查露西和玛丽之间是否存在边缘。边缘插入在玛丽和露西之间,但函数显示方向被忽略。相同节点之间的其他边也将被忽略,也就是说,每个节点对只考虑一条边。Graph类允许自循环,尽管在我们的示例中,它们不是必需的。
NetworkX 支持的其他类型的图是有向图的DiGraph(节点的方向很重要)和节点间多条(平行)边的对应图,分别是MultiGraph和MultiDiGraph。
数据可视化
数据可视化(或数据可视化)是一门处理数据可视化表示的跨领域学科。可视化表示是强大的工具,提供了理解复杂数据的机会,也是展示和交流数据分析过程结果的有效方式。通过数据可视化,人们可以看到数据中不太清晰的方面。毕竟,如果一张图片抵得上千言万语,一个好的数据可视化可以让读者借助简单的图片吸收复杂的概念。例如,数据科学家可以在探索性数据分析步骤中使用数据可视化来理解数据。此外,数据科学家还可以使用数据可视化与非专家交流,并向他们解释数据的有趣之处。
Python 提供了许多数据可视化工具,例如本章机器学习一节中简要使用的 matplotlib 库。要安装库,请使用以下命令:
$ pip install matplotlib
Matplotlib 以多种格式制作出版物质量数字。这个库背后的理念是,开发人员应该能够用少量代码创建简单的情节。Matplotlib 图可以保存为不同的文件格式,例如便携式网络图形 ( PNG )或便携式文档格式 ( PDF )。
让我们考虑一个绘制一些二维数据的简单示例:
# Chap01/demo_matplotlib.py
import matplotlib.pyplot as plt
import numpy as np
if __name__ == '__main__':
# plot y = x^2 with red dots
x = np.array([1, 2, 3, 4, 5])
y = x * x
plt.plot(x, y, 'ro')
plt.axis([0, 6, 0, 30])
plt.savefig('demo_plot.png')
下图显示了该代码的输出:

图 1.7:用 matplotlib 创建的图
如前所述,将pyploy别名为plt是一种常见的命名约定,对于其他包也是如此。plot()函数采用两个类似序列的参数,分别包含x和y的坐标。在这个例子中,这些坐标被创建为 NumPy 数组,但是它们可能是 Python 列表。axis()功能定义轴的可见范围。当我们绘制 1 到 5 的平方时,x 轴的范围是 0-6,y 轴的范围是 0-30。最后,savefig()函数生成一个图像文件,输出在图 1.7 中可视化,根据文件扩展名猜测图像格式。
Matplotlib 为出版物制作了出色的图像,但有时需要一些交互性,以允许用户通过动态放大可视化的细节来探索数据。这种交互性在其他编程语言中更常见,例如,JavaScript(尤其是通过位于 https://d3js.org T2 的流行的 D3.js 库),它允许构建交互式的基于网络的数据可视化。虽然这不是本书的中心主题,但值得一提的是,由于将 Python 对象翻译成 Vega 语法的工具,Python 在这个领域并不逊色,Vega 语法是一种基于 JSON 的声明性格式,允许创建交互式可视化。
Python 和 JavaScript 可以很好地合作的一个特别有趣的情况是地理数据的情况。大多数社交媒体平台都可以通过移动设备访问。这提供了跟踪用户位置的机会,也包括数据分析的地理方面。用于编码和交换各种地理数据结构(如点或多边形)的常见数据格式是 GeoJSON(http://geojson.org)。顾名思义,这种格式是基于 JSON 的语法。
一个流行的绘制交互式地图的 JavaScript 库是活页(http://leafletjs.com)。JavaScript 和 Python 之间的桥梁由叶提供,这是一个 Python 库,它使地理数据可视化变得容易,例如,通过 GeoJSON,在 Layet . js 地图上使用 Python 处理地理数据。
还值得一提的是,Plotly ( https://plot.ly )等第三方服务为数据可视化的自动生成提供了支持,减轻了创建服务交互组件的负担。具体来说,Plotly 为使用 Python 客户端(https://plot.ly/python)创建定制数据可视化提供了充足的支持。这些图表由 Plotly 在线托管,并链接到用户帐户(公共托管免费,而私人图表有付费计划)。
用 Python 处理数据
在介绍了一些用于数据分析的最重要的 Python 包之后,我们后退一小步,描述一些感兴趣的工具,这些工具可以用 Python 从不同格式加载和操作数据。
大多数社交媒体 API 都以 JSON 或 XML 的形式提供数据。从这个角度来看,Python 配备了支持这些格式的包,它们是标准库的一部分。
为了方便起见,我们将重点关注 JSON,因为这种格式可以很好地映射到 Python 字典中,并且更容易阅读和理解。JSON 库的接口非常简单,您可以加载或转储数据,从 JSON 到 Python 字典。
让我们考虑以下片段:
# Chap01/demo_json.py
import json
if __name__ == '__main__':
user_json = '{"user_id": "1", "name": "Marco"}'
user_data = json.loads(user_json)
print(user_data['name'])
# Marco
user_data['likes'] = ['Python', 'Data Mining']
user_json = json.dumps(user_data, indent=4)
print(user_json)
# {
# "user_id": "1",
# "name": "Marco",
# "likes": [
# "Python",
# "Data Mining"
# ]
# }
json.loads()和json.dumps()函数管理从 JSON 字符串到 Python 字典以及从 Python 字典到 JSON 字符串的转换。还有两个对应的,json.load()和json.dump(),它们使用文件指针进行操作,以防您想要从/向文件加载或保存 JSON 数据。
json.dumps()函数还接受第二个参数indent,以指定缩进的字符数,这对于漂亮的打印很有用。
当手动分析更复杂的 JSON 文件时,使用外部 JSON 查看器可能会更方便,它可以在浏览器中执行漂亮的打印,允许用户根据自己的意愿折叠和扩展结构。
这方面有几个免费的工具,有些是基于 web 的服务,比如 JSON Viewer(http://jsonviewer . stack . Hu)。用户只需要粘贴一段 JSON,或者传递一个服务于一段 JSON 的网址,查看器就会加载它,并以用户友好的格式显示它。
下图显示了上一个示例中的 JSON 文档在 JSON 查看器中的显示方式:

图 1.8:在 JSON 查看器上漂亮打印的 JSON 的一个例子
正如我们在图 1.8 中看到的,likes字段是一个列表,可以折叠以隐藏其元素并简化可视化。虽然这个例子很小,但是这个特性在检查具有几个嵌套层的复杂文档时变得非常方便。
型式
当使用基于网络的服务或浏览器扩展时,加载大型 JSON 文档进行漂亮的打印会阻塞浏览器并降低系统速度。
构建复杂的数据管道
一旦我们正在构建的数据工具发展成比简单脚本更大的东西,将数据预处理任务分成小的单元,以便映射数据管道的所有步骤和依赖关系,这是非常有用的。
使用术语数据管道,我们打算进行一系列数据处理操作,清理、扩充和操作原始数据,将其转换为可被分析引擎消化的东西。任何重要的数据分析项目都需要一个由多个步骤组成的数据管道。
在原型阶段,通常将这些步骤分成不同的脚本,然后分别运行,例如:
$ python download_some_data.py
$ python clean_some_data.py
$ python augment_some_data.py
本例中的每个脚本都会产生以下脚本的输出,因此不同步骤之间存在依赖关系。我们可以将数据处理脚本重构为一个可以完成所有工作的大型脚本,然后一次性运行它:
$ python do_everything.py
此类脚本的内容可能类似于以下代码:
if __name__ == '__main__':
download_some_data()
clean_some_data()
augment_some_data()
前面的每一个函数都将包含初始单个脚本的主要逻辑。这种方法的问题是数据管道中可能会出现错误,因此我们还应该包含大量带有try和except的样板代码,以便控制可能出现的异常。此外,参数化这种代码可能会觉得有点笨拙。
总的来说,当从原型转向更稳定的东西时,值得考虑使用数据编排器,也称为工作流管理器。Spotify 引入的开源项目 Luigi 给出了 Python 中这种工具的一个很好的例子。使用 Luigi 这样的数据编排器的优势包括以下几点:
- 任务模板:每个数据任务都被定义为一个类,该类有一些方法来定义任务如何运行、它的依赖关系和它的输出
依赖关系图:可视化工具帮助数据工程师可视化和理解任务之间的依赖关系从中间故障中恢复:如果数据管道在任务进行到一半时出现故障,可以从最后的一致状态重新启动它
- 与命令行界面以及系统作业调度程序(如 cron 作业)的集成
- 可自定义的错误报告
我们不会深入挖掘 Luigi 的所有特性,因为详细的讨论将超出本书的范围,但是我们鼓励读者看一看这个工具,并使用它来产生一个更优雅、可复制、易于维护和扩展的数据管道。
总结
本章介绍了多方面主题的不同方面,例如使用 Python 应用于社交媒体的数据挖掘。我们经历了一些挑战和机遇,这些挑战和机遇让这个话题变得有趣,并对希望从社交媒体数据中收集有意义见解的企业有价值。
在介绍完主题之后,我们还讨论了社交媒体挖掘的整体流程,包括使用 OAuth 进行身份验证等方面。我们还分析了 Python 工具的细节,这些工具应该是任何数据挖掘从业者的数据工具箱的一部分。根据我们正在分析的社交媒体平台和我们关注的见解类型,Python 为机器学习、自然语言处理和系统网络体系结构提供了强大且成熟的包。
我们建议您使用本章的 pip 和部分中描述的virtualenv建立一个 Python 开发环境,因为这允许我们保持全局开发环境的干净。
下一章将集中讨论推特,特别是讨论如何通过推特应用编程接口访问推特数据,以及如何切割和切片这些数据以产生有趣的信息。*
二、挖掘推特——标签、主题和时间序列
这一章是关于推特上的数据挖掘。本章涵盖的主题包括以下内容:
- 使用 Tweepy 与推特应用编程接口交互
- 推特数据——推特的剖析
- 标记化和频率分析
- 推文中的标签和用户提及
- 时间序列分析
开始
推特是近年来最受欢迎的在线社交网络之一。他们提供的服务被称为微博,这是博客的一种变体,内容片段非常短——在推特的情况下,每条推文有 140 个字符的限制。与脸书等其他社交媒体平台不同,推特网络不是双向的,这意味着连接不必是相互的:你可以跟踪不跟着你回来的用户,反之亦然。
传统媒体正在采用社交媒体作为接触更广泛受众的方式,大多数名人都有一个推特账户与粉丝保持联系。用户实时讨论正在发生的事件,包括庆典、电视节目、体育赛事、政治选举等等。
Twitter 还负责推广使用 hashtag 这个术语,作为一种将对话分组并允许用户关注特定主题的方式。标签是以#符号为前缀的单个关键词,例如#halloween(用于展示万圣节服装的图片)或#WaterOnMars(在美国宇航局宣布他们在火星上发现水的证据后,这是一种趋势)。
考虑到用途的多样性,推特是数据挖掘者的潜在金矿,所以让我们开始吧。
推特应用编程接口
Twitter 提供了一系列 API 来提供对 Twitter 数据的编程访问,包括阅读推文、访问用户配置文件以及代表用户发布内容。
为了设置我们的项目来访问 Twitter 数据,有两个初步步骤,如下所示:
- 注册我们的应用程序
- 选择推特应用编程接口客户端
注册步骤需要几分钟时间。假设我们已经登录了我们的推特账户,我们所需要做的就是将浏览器指向位于http://apps.twitter.com的应用程序管理页面,并创建新的应用程序。
应用注册后,在密钥和访问令牌选项卡下,我们可以找到验证应用所需的信息。消费密钥和消费密钥(也分别称为 API 密钥和 API 密钥)是您的应用程序的一个设置。访问令牌和访问令牌密码是您的用户帐户设置。您的应用程序可能会通过多个用户的访问令牌请求访问。这些设置的访问级别定义了应用程序在代表用户与推特交互时可以做什么:只读是更保守的选择,因为应用程序不允许发布任何内容或通过直接消息与其他用户交互。
费率限制
推特应用编程接口限制对应用程序的访问。这些限制是基于每个用户设置的,或者更准确地说,是基于每个访问令牌设置的。这意味着,当应用程序使用仅应用程序身份验证时,会对整个应用程序全局考虑速率限制;而使用每用户身份验证方法,应用程序可以提高对 API 的全局请求数量。
熟悉利率限制的概念很重要,在官方文档中有描述(https://dev.twitter.com/rest/public/rate-limiting)。同样重要的是要考虑到不同的原料药有不同的速率限制(https://dev.twitter.com/rest/public/rate-limits)。
达到应用编程接口限制的含义是,推特将返回一条错误消息,而不是我们要求的数据。此外,如果我们继续对应用编程接口执行更多的请求,再次获得定期访问所需的时间将会增加,因为推特可能会将我们标记为潜在的滥用者。当我们的应用程序需要许多应用编程接口请求时,我们需要一种方法来避免这种情况。在 Python 中,time模块是标准库的一部分,它允许我们使用time.sleep()函数任意暂停代码执行。例如,伪代码如下:
# Assume first_request() and second_request() are defined.
# They are meant to perform an API request.
import time
first_request()
time.sleep(10)
second_request()
在这种情况下,第二个请求将在第一个请求之后 10 秒执行(由sleep()参数指定)。
搜索与流
推特提供了不止一个应用编程接口。事实上,下一节将解释访问推特数据的方式不止一种。为了简单起见,我们可以将我们的选项分为两类:REST APIs 和 Streaming API:

图 2.1:搜索相对于流的时间维度
区别,总结在图 2.1 中,相当简单:所有的 REST APIs 只允许你回到时间。当通过 REST API 与 Twitter 交互时,我们可以搜索现有的推文事实上,也就是已经发布并可供搜索的推文。这些 API 通常会限制您可以检索的推文数量,不仅仅是上一节讨论的速率限制,还包括时间跨度。事实上,通常可以追溯到大约一周前,这意味着旧的推文不可检索。关于 REST API 要考虑的第二个方面是,这些通常是尽力而为,但它们不能保证提供在 Twitter 上发布的所有推文,因为一些推文可能无法搜索,或者只是稍微延迟就被索引了。
另一方面,流媒体应用编程接口着眼于未来。一旦我们打开一个连接,我们就可以保持它的打开,并及时前进。通过保持 HTTP 连接打开,我们可以检索所有符合我们过滤标准的推文,因为它们已经发布了。
一般来说,流媒体应用编程接口是下载大量推文的首选方式,因为与平台的交互仅限于保持其中一个连接打开。不利的一面是,以这种方式收集推文可能会更耗时,因为我们需要等待推文发布后才能收集。
总而言之,当我们想要搜索由特定用户创作的推文或者我们想要访问我们自己的时间线时,REST APIs 是有用的,而当我们想要过滤特定的关键词并下载大量关于它的推文(例如,直播事件)时,Streaming API 是有用的。
从推特收集数据
为了与推特应用编程接口交互,我们需要一个 Python 客户端来实现对应用编程接口本身的不同调用。从官方文档中我们可以看到有几个选项(https://dev.twitter.com/overview/api/twitter-libraries)。没有一个是由推特官方维护的,它们得到了开源社区的支持。虽然有几个选项可供选择,但其中一些几乎是等效的,因此我们将选择在这里使用 Tweepy ,因为它为不同的功能提供了更广泛的支持,并且得到了积极的维护。
可通过pip安装库:
$ pip install tweepy==3.3.0
型式
Python 3 兼容性
我们专门安装了 Tweepy 的 3.3 版本,因为 Tweepy 和 Python 3 的最新版本有一个问题,这阻止了在我们的 Python 3.4 环境中运行这些示例。在撰写本文时,该问题仍未解决,但很可能很快就会得到解决。
与推特应用编程接口交互的第一部分包括设置认证,如前一节所述。注册应用程序后,此时,您应该有一个消费者密钥、消费者秘密、访问令牌和访问令牌秘密。
为了促进应用程序逻辑和配置之间的分离,我们将凭证存储在环境变量中。为什么不把这些值硬编码成 Python 变量呢?这有几个原因,在十二因素应用宣言(http://12factor.net/config)中有很好的总结。使用环境存储配置细节与语言和操作系统无关。在不同部署之间更改配置(例如,使用个人凭据进行本地测试,使用公司帐户进行生产)不需要对代码库进行任何更改。此外,环境变量不会被意外地检查到您的源代码控制系统中,让每个人都看到。
在 Unix 环境中,如 Linux 或 macOS,如果您的 shell 是 Bash,您可以如下设置环境变量:
$ export TWITTER_CONSUMER_KEY="your-consumer-key"
在 Windows 环境中,您可以从命令行设置变量,如下所示:
$ set TWITTER_CONSUMER_KEY="your-consumer-key"
对于我们需要的所有四个变量,应该重复该命令。
一旦设置好环境,我们将把创建 Twitter 客户端的 Tweepy 调用包装成两个函数:一个函数读取环境变量并执行身份验证,另一个函数创建与 Twitter 接口所需的 API 对象:
# Chap02-03/twitter_client.py
import os
import sys
from tweepy import API
from tweepy import OAuthHandler
def get_twitter_auth():
"""Setup Twitter authentication.
Return: tweepy.OAuthHandler object
"""
try:
consumer_key = os.environ['TWITTER_CONSUMER_KEY']
consumer_secret = os.environ['TWITTER_CONSUMER_SECRET']
access_token = os.environ['TWITTER_ACCESS_TOKEN']
access_secret = os.environ['TWITTER_ACCESS_SECRET']
except KeyError:
sys.stderr.write("TWITTER_* environment variables not set\n")
sys.exit(1)
auth = OAuthHandler(consumer_key, consumer_secret)
auth.set_access_token(access_token, access_secret)
return auth
def get_twitter_client():
"""Setup Twitter API client.
Return: tweepy.API object
"""
auth = get_twitter_auth()
client = API(auth)
return client
get_twitter_auth()功能负责认证。try/except块显示了如何读取环境变量。os模块包含一个名为os.environ的字典,可以通过按键访问,就像普通字典一样。如果其中一个TWITTER_*环境变量丢失,试图访问密钥将引发KeyError异常,我们将捕获该异常以显示错误消息并退出应用程序。
这个函数由get_twitter_client()调用,用于创建tweepy.API的一个实例,用于与 Twitter 进行多种不同类型的交互。将逻辑分解为两个独立函数的原因是,身份验证代码也可以在流应用编程接口中重用,我们将在下面的章节中讨论。
从时间线获取推文
一旦客户端验证到位,我们就可以开始使用它来下载推文。
我们先来考虑一个简单的场景:如何获取自己家时间线的前十条推文:
from tweepy import Cursor
from twitter_client import get_twitter_client
if __name__ == '__main__':
client = get_twitter_client()
for status in Cursor(client.home_timeline).items(10):
# Process a single status
print(status.text)
作为推特用户,你的家庭时间线就是你登录推特时看到的屏幕。它包含一系列来自你选择关注的账户的推文,最近和有趣的推文在顶部。
前面的片段展示了如何使用tweepy.Cursor循环浏览你的家庭时间线的前十项。首先,我们需要导入先前定义的Cursor和get_twitter_client函数。在主模块中,我们将使用这个函数来创建客户端,它提供了对推特应用编程接口的访问。特别是home_timeline属性是我们访问自己家的时间线所需要的,这将是传递给Cursor的论点。
tweepy.Cursor是一个可迭代的对象,这意味着它提供了一个易于使用的接口来对不同的对象执行迭代和分页。在前面的例子中,它提供了一个最小的抽象,允许开发人员循环访问对象本身,而不用担心如何向推特应用编程接口发出请求。
型式
可重复
Python 中的 iterable 是一个能够一次返回一个成员的对象。Iterables 包括内置数据类型,如列表或字典,以及实现__iter__()或__getitem__()方法的任何类的对象。
迭代中使用的status变量代表tweepy.Status的一个实例,这是 Tweepy 用来包装状态(即推文)的模型。在前面的代码片段中,我们只使用了它的文本,但是这个对象有许多属性。这些都在推文的结构部分有描述。
不仅仅是在屏幕上打印文本,我们更希望存储我们从应用编程接口中检索到的推文,以便我们以后可以进行一些分析。
我们将重构前面的片段,从我们自己的主页时间线获取推文,这样我们就可以将 JSON 信息存储在一个文件中:
# Chap02-03/twitter_get_home_timeline.py
import json
from tweepy import Cursor
from twitter_client import get_twitter_client
if __name__ == '__main__':
client = get_twitter_client()
with open('home_timeline.jsonl', 'w') as f:
for page in Cursor(client.home_timeline, count=200).pages(4):
for status in page:
f.write(json.dumps(status._json)+"\n")
运行该代码将在几分钟内生成一个home_timeline.jsonl文件。
型式
JSON 线路格式
上例中的文件扩展名为.jsonl,而不仅仅是.json。事实上,该文件采用的是 JSON Lines 格式(http://jsonlines.org/,这意味着该文件的每一行都是有效的 JSON 文档。例如,试图用json.loads()加载该文件的全部内容将会引发ValueError,因为全部内容不是有效的 JSON 文档。相反,如果我们使用的函数期望有效的 JSON 文档,我们需要一次处理一行。
JSON Lines 格式特别适合大规模处理:许多大数据框架允许开发人员轻松地将输入文件分割成可以由不同工作人员并行处理的块。
在这段代码中,我们遍历了四个页面,包含 200 条推文,每个都在光标的count参数中声明。这样做的原因是推特给出的一个限制:我们只能从我们的主页时间表中检索最近的 800 条推文。
如果我们从特定的用户时间线检索推文,即使用user_timeline方法而不是home_timeline,则该限制增加到 3200 条。
让我们考虑以下脚本来获取特定的用户时间表:
# Chap02-03/twitter_get_user_timeline.py
import sys
import json
from tweepy import Cursor
from twitter_client import get_twitter_client
if __name__ == '__main__':
user = sys.argv[1]
client = get_twitter_client()
fname = "user_timeline_{}.jsonl".format(user)
with open(fname, 'w') as f:
for page in Cursor(client.user_timeline, screen_name=user,
count=200).pages(16):
for status in page:
f.write(json.dumps(status._json)+"\n")
要运行脚本,我们需要为用户的屏幕名称提供一个命令行参数。例如,要检索我的整个时间线(目前远低于 3200 条推文的限制),我们将使用以下命令:
$ python twitter_get_user_timeline.py marcobonzanini
由于我的时间线的流量相对较低,我们可以从 Packt Publishing 帐户中检索到更多的推文,@PacktPub:
$ python twitter_get_user_timeline.py PacktPub
与我们在主页时间线中看到的类似,脚本将创建一个.jsonl文件,每行都有一个 JSON 文档。当我们达到 3200 的限制时,文件的大小大约是 10 Mb。
从概念上讲,获取用户时间线的代码与检索主时间线的代码非常相似。唯一的区别是用于指定我们感兴趣的用户的推特句柄的单一命令行参数。
到目前为止,我们通过 Tweepy 接口使用的单个推文的唯一属性是_json,它已经被用来存储原始的 JSON 响应。下一节讨论了推文的结构细节,然后继续讨论从推特获取数据的其他方法。
推文的结构
推文是一个复杂的对象。下表提供了其所有属性的列表及其含义的简要描述。特别是,特定推文的 API 响应内容完全包含在_json属性中,该属性被加载到 Python 字典中:
持有一个标识的属性(例如,用户标识或推文标识)有一个对应的属性,相同的值作为一个字符串重复出现。这是必要的,因为一些编程语言(最著名的是 JavaScript)不能支持超过 53 位的数字,而 Twitter 使用 64 位数字。为了避免数字问题,推特推荐使用*_str属性。
我们可以看到,并非所有的属性都被转换成 Python 内置类型,如字符串或布尔值。事实上,一些复杂的对象,如用户配置文件,完全包含在应用编程接口响应中,Tweepy 负责将这些对象转换成合适的模型。
以下示例展示了 Packt Publishing 以原始 JSON 格式发布的推文示例,该格式由应用编程接口给出,可通过_json属性访问(为了简洁起见,省略了几个字段)。
首先,预期格式的创建日期如下:
{
"created_at": "Fri Oct 30 05:26:05 +0000 2015",
entities属性是一个包含不同标签实体列表的字典。例如,hashtags元素显示给定推文中存在#Python标签。同样,我们有照片、网址和用户提及:
"entities": {
"hashtags": [
{
"indices": [
72,
79
],
"text": "Python"
}
],
"media": [
{
"display_url": "pic.twitter.com/muZlMreJNk",
"id": 659964467430735872,
"id_str": "659964467430735872",
"indices": [
80,
103
],
"type": "photo",
"url": "https://t.co/muZlMreJNk"
}
],
"symbols": [],
"urls": [
{
"indices": [
48,
71
],
"url": "https://t.co/NaBNan3iVt"
}
],
"user_mentions": [
{
"id": 80589255,
"id_str": "80589255",
"indices": [
33,
47
],
"name": "Open Source Way",
"screen_name": "opensourceway"
}
]
},
上表中很好地描述了以下属性,不需要太多信息就能理解。我们可以注意到这条推文的地理信息丢失了。我们还可以将之前关于实体的信息与text属性中存储的实际推文进行比较:
"favorite_count": 4,
"favorited": false,
"geo": null,
"id": 659964467539779584,
"id_str": "659964467539779584",
"lang": "en",
"retweet_count": 1,
"retweeted": false,
"text": "Top 3 open source Python IDEs by @opensourceway
https://t.co/NaBNan3iVt #Python https://t.co/muZlMreJNk",
user属性是一个字典,表示发送推文的用户,在本例中为@PacktPub。如前所述,这是一个复杂的对象,所有与用户相关的信息都嵌入在推文中:
"user": {
"created_at": "Mon Dec 01 13:16:47 +0000 2008",
"description": "Providing books, eBooks, video tutorials, and
articles for IT developers, administrators, and users.",
"entities": {
"description": {
"urls": []
},
"url": {
"urls": [
{
"display_url": "PacktPub.com",
"expanded_url": "http://www.PacktPub.com",
"indices": [
0,
22
],
"url": "http://t.co/vEPCgOu235"
}
]
}
},
"favourites_count": 548,
"followers_count": 10090,
"following": true,
"friends_count": 3578,
"id": 17778401,
"id_str": "17778401",
"lang": "en",
"location": "Birmingham, UK",
"name": "Packt Publishing",
"screen_name": "PacktPub",
"statuses_count": 10561,
"time_zone": "London",
"url": "http://t.co/vEPCgOu235",
"utc_offset": 0,
"verified": false
}
}
这个例子展示了分析推文时需要考虑的两个有趣的方面,如下所示:
- 实体已经被标记
- 用户配置文件是完全嵌入的
第一点意味着简化了实体分析,因为我们不需要显式搜索诸如哈希表、用户提及、嵌入的 URL 或媒体等实体,因为这些都是由 Twitter API 连同它们在文本中的偏移量(称为indices的属性)一起提供的。
第二点意味着我们不需要将用户配置文件信息存储在其他地方,然后通过例如外键来连接/合并数据。事实上,用户档案在每条推文中都会被重复复制。
型式
处理非规范化数据
嵌入冗余数据的方法与反规格化的概念有关。虽然规范化被认为是关系数据库设计中的良好实践,但是反规范化在大规模处理和属于宽 NoSQL 族的数据库中找到了它的作用。
这种方法背后的基本原理是,冗余存储用户配置文件所需的额外磁盘空间仅具有边际成本,而通过消除对连接/合并操作的需求而获得的收益(在性能方面)是可观的。
使用流应用编程接口
流式应用编程接口是在不超过速率限制的情况下获取大量数据的最受欢迎的方法之一。我们已经讨论了这个应用编程接口与其他 REST 应用编程接口的区别,特别是搜索应用编程接口,以及我们可能需要如何重新思考我们的应用程序与用户交互的方式。
在深入了解细节之前,值得看一下 Streaming API 的文档,了解它的特性(https://dev.twitter.com/streaming/overview)。
在本节中,我们将通过扩展 Tweepy 提供的默认StreamListener类来实现一个定制的流侦听器:
# Chap02-03/twitter_streaming.py
import sys
import string
import time
from tweepy import Stream
from tweepy.streaming import StreamListener
from twitter_client import get_twitter_auth
class CustomListener(StreamListener):
"""Custom StreamListener for streaming Twitter data."""
def __init__(self, fname):
safe_fname = format_filename(fname)
self.outfile = "stream_%s.jsonl" % safe_fname
def on_data(self, data):
try:
with open(self.outfile, 'a') as f:
f.write(data)
return True
except BaseException as e:
sys.stderr.write("Error on_data: {}\n".format(e))
time.sleep(5)
return True
def on_error(self, status):
if status == 420:
sys.stderr.write("Rate limit exceeded\n")
return False
else:
sys.stderr.write("Error {}\n".format(status))
return True
def format_filename(fname):
"""Convert fname into a safe string for a file name.
Return: string
"""
return ''.join(convert_valid(one_char) for one_char in fname)
def convert_valid(one_char):
"""Convert a character into '_' if "invalid".
Return: string
"""
valid_chars = "-_.%s%s" % (string.ascii_letters, string.digits)
if one_char in valid_chars:
return one_char
else:
return '_'
if __name__ == '__main__':
query = sys.argv[1:] # list of CLI arguments
query_fname = ' '.join(query) # string
auth = get_twitter_auth()
twitter_stream = Stream(auth, CustomListener(query_fname))
twitter_stream.filter(track=query, async=True)
流逻辑的核心在CustomListener类中实现,扩展StreamListener并覆盖两个方法:on_data()和on_error()。这些是当数据通过并且应用编程接口给出错误时触发的处理程序。
这两种方法的返回类型都是布尔值:True继续流,而False停止执行。因此,仅在出现致命错误时返回False非常重要,这样应用程序就可以继续下载数据。这样,如果出现暂时的错误,比如我们这边的网络打嗝,或者来自 Twitter 的 HTTP 503 错误,这意味着服务暂时不可用(但很可能很快就会回来),我们就可以避免杀死应用程序。
on_error()方法尤其会处理来自 Twitter 的显式错误。有关推特应用编程接口状态代码的完整列表,我们可以查看文档(https://dev.twitter.com/overview/api/response-codes)。我们对on_error()方法的实现只有在出现错误 420 时才会停止执行,这意味着我们受到了 Twitter API 的速率限制。我们越是超过费率限制,就越需要等待才能再次使用该服务。因此,出于这个原因,最好停止下载并调查问题。其他所有错误都将简单地打印在stderr界面上。总的来说,这比只使用print()要好,所以如果需要,我们可以将错误输出重定向到特定的文件。更好的是,我们可以使用logging模块来构建一个适当的日志处理机制,但是这超出了本章的范围。
当数据通过时,调用on_data()方法。该功能只是将收到的数据存储在.jsonl文件中。该文件的每一行都将包含一条 JSON 格式的推文。一旦写入数据,我们将返回True继续执行。如果在这个过程中出现任何问题,我们将捕捉到任何异常,在stderr上打印一条消息,让应用程序休眠五秒钟,然后再次返回True继续执行。在异常情况下,短暂的睡眠只是为了防止偶尔的网络故障导致应用程序卡住。
CustomListener类使用一个助手来清理查询,并将其用于文件名。事实上,format_filename()函数遍历给定的字符串,一次一个字符,并使用convert_valid()函数将无效字符转换为下划线。在本文中,有效字符是三个符号-破折号、下划线和点(-、_和. )-ASCII 字母和数字。
当我们运行twitter_streaming.py脚本时,我们必须从命令行提供参数。这些参数由一个空格隔开,将是收听者下载推文时使用的关键词。
举个例子,我运行了脚本来捕捉关于 2015 年新西兰和澳大利亚之间的橄榄球世界杯决赛的推文(https://en.wikipedia.org/wiki/2015_Rugby_World_Cup)。推特上关注该事件的粉丝大多使用#RWC2015(在整个比赛中使用)和#RWCFinal(用于关注关于最后一天的对话)标签。我将这两个标签和术语rugby一起用作流监听器的搜索关键词:
$ python twitter_streaming.py \#RWC2015 \#RWCFinal rugby
哈希符号#前的反斜杠是转义该字符所必需的,因为 shell 使用哈希来表示注释的开头。对字符进行转义可确保字符串正确传递给脚本。
赛事开球时间定于 2015 年 10 月 31 日日格林尼治标准时间 ( 格林尼治标准时间)下午 4 点。在下午 3 点到 6 点之间运行脚本三个小时,已经产生了近 800 MB 的数据,总共有 20 多万条推文。事件发生后,推特上的讨论持续了一段时间,但在此期间收集的数据量足以进行一些有趣的分析。
分析推文-实体分析
这一部分是关于分析推文中的实体。我们将使用上一节收集的数据进行一些频率分析。对这些数据进行切片和切割将允许用户产生一些有趣的统计数据,这些数据可以用来获得对数据的一些见解并回答一些问题。
分析像标签这样的实体很有趣,因为这些注释是作者标记推文主题的明确方式。
我们从分析帕克特出版公司的推文开始。随着 Packt Publishing 对开源软件的支持和推广,我们有兴趣找到 Packt Publishing 经常提到的是什么样的技术。
以下脚本从用户时间线中提取标签,生成最常见标签的列表:
# Chap02-03/twitter_hashtag_frequency.py
import sys
from collections import Counter
import json
def get_hashtags(tweet):
entities = tweet.get('entities', {})
hashtags = entities.get('hashtags', [])
return [tag['text'].lower() for tag in hashtags]
if __name__ == '__main__':
fname = sys.argv[1]
with open(fname, 'r') as f:
hashtags = Counter()
for line in f:
tweet = json.loads(line)
hashtags_in_tweet = get_hashtags(tweet)
hashtags.update(hashtags_in_tweet)
for tag, count in hashtags.most_common(20):
print("{}: {}".format(tag, count))
可以使用以下命令运行此代码:
$ python twitter_hashtag_frequency.py user_timeline_PacktPub.jsonl
这里,user_timeline_PacktPub.jsonl是我们之前收集的 JSON Lines 文件。
这个脚本从命令行中获取一个.jsonl文件的名称作为参数,并一次一行地读取它的内容。由于每一行都包含一个 JSON 文档,它将文档加载到tweet变量中,并使用get_hashtags()助手函数提取一个标签列表。这些类型的实体存储在hashtags变量中,该变量被声明为collections.Counter,这是一种特殊类型的字典,用于计数可散列的对象——在我们的例子中是字符串。计数器将字符串作为字典的键,将它们各自的频率作为值。
作为dict的子类,Counter对象本身就是一个无序的集合。most_common()方法负责根据关键字的值(最频繁的第一个)对关键字进行排序,并返回一个(key, value)元组列表。
get_hashtags()助手功能负责从推文中检索标签列表。加载到字典中的整个推文是这个函数的唯一参数。如果推文中存在实体,字典将有一个entities键。由于这是可选的,我们不能直接访问tweet['entities'],因为这可能会引发KeyError,所以我们将使用get()函数,如果实体不存在,则指定一个空字典作为默认值。第二步包括从实体中获取标签。由于entities也是字典,hashtags键也是可选的,所以我们再次使用get()功能,但是这一次,如果没有 hashtag,我们会指定一个空列表作为默认值。最后,我们将使用列表理解来遍历标签,以便提取它们的文本。使用lower()将标签规范化,以强制文本小写,因此像#Python或#PYTHON这样的提及都将被分组到#python中。
运行脚本来分析 PacktPub 推文会产生以下输出:
packt5dollar: 217
python: 138
skillup: 132
freelearning: 107
gamedev: 99
webdev: 96
angularjs: 83
bigdata: 73
javascript: 69
unity: 65
hadoop: 46
raspberrypi: 43
js: 37
pythonweek: 36
levelup: 35
r: 29
html5: 28
arduino: 27
node: 27
nationalcodingweek: 26
我们可以看到,PacktPub(比如#packt5dollar)有关于事件和促销的参考,但是大多数哈希表都提到了一种特定的技术,其中 Python 和 JavaScript 是推文最多的。
前面的脚本给出了 PacktPub 最常用的 hashtags 的概述,但是我们想深入一点。事实上,我们可以生成更多的描述性统计数据,让我们了解 Packt Publishing 是如何使用哈希表的:
# Chap02-03/twitter_hashtag_stats.py
import sys
from collections import defaultdict
import json
def get_hashtags(tweet):
entities = tweet.get('entities', {})
hashtags = entities.get('hashtags', [])
return [tag['text'].lower() for tag in hashtags]
def usage():
print("Usage:")
print("python {} <filename.jsonl>".format(sys.argv[0]))
if __name__ == '__main__':
if len(sys.argv) != 2:
usage()
sys.exit(1)
fname = sys.argv[1]
with open(fname, 'r') as f:
hashtag_count = defaultdict(int)
for line in f:
tweet = json.loads(line)
hashtags_in_tweet = get_hashtags(tweet)
n_of_hashtags = len(hashtags_in_tweet)
hashtag_count[n_of_hashtags] += 1
tweets_with_hashtags = sum([count for n_of_tags, count in
hashtag_count.items() if n_of_tags > 0])
tweets_no_hashtags = hashtag_count[0]
tweets_total = tweets_no_hashtags + tweets_with_hashtags
tweets_with_hashtags_percent = "%.2f" % (tweets_with_hashtags
/ tweets_total * 100)
tweets_no_hashtags_percent = "%.2f" % (tweets_no_hashtags /
tweets_total * 100)
print("{} tweets without hashtags
({}%)".format(tweets_no_hashtags,
tweets_no_hashtags_percent))
print("{} tweets with at least one hashtag
({}%)".format(tweets_with_hashtags,
tweets_with_hashtags_percent))
for tag_count, tweet_count in hashtag_count.items():
if tag_count > 0:
percent_total = "%.2f" % (tweet_count / tweets_total *
100)
percent_elite = "%.2f" % (tweet_count /
tweets_with_hashtags * 100)
print("{} tweets with {} hashtags ({}% total, {}%
elite)".format(tweet_count, tag_count,
percent_total, percent_elite))
我们可以使用以下命令运行前面的脚本:
$ python twitter_hashtag_stats.py user_timeline_PacktPub.jsonl
使用收集的数据,前一个命令将产生以下输出:
1373 tweets without hashtags (42.91%)
1827 tweets with at least one hashtag (57.09%)
1029 tweets with 1 hashtags (32.16% total, 56.32% elite)
585 tweets with 2 hashtags (18.28% total, 32.02% elite)
181 tweets with 3 hashtags (5.66% total, 9.91% elite)
29 tweets with 4 hashtags (0.91% total, 1.59% elite)
2 tweets with 5 hashtags (0.06% total, 0.11% elite)
1 tweets with 7 hashtags (0.03% total, 0.05% elite)
我们可以看到,PacktPub 的大部分推文中至少有一个 hashtag,这证实了这类实体在人们通过 Twitter 交流方式中的重要性。
另一方面,hashtag 计数的细分显示,每条推文的 hashtag 数量并不大。大约 1%的推文有四个或更多的标签。对于这个细分,我们将观察到两个不同的百分比:第一个是根据推文总数计算的,而第二个是根据至少有一条推文的推文数量计算的(称为精英集)。
以类似的方式,我们可以观察到用户提及,如下所示:
# Chap02-03/twitter_mention_frequency.py
import sys
from collections import Counter
import json
def get_mentions(tweet):
entities = tweet.get('entities', {})
hashtags = entities.get('user_mentions', [])
return [tag['screen_name'] for tag in hashtags]
if __name__ == '__main__':
fname = sys.argv[1]
with open(fname, 'r') as f:
users = Counter()
for line in f:
tweet = json.loads(line)
mentions_in_tweet = get_mentions(tweet)
users.update(mentions_in_tweet)
for user, count in users.most_common(20):
print("{}: {}".format(user, count))
可以使用以下命令运行该脚本:
$ python twitter_mention_frequency.py user_timeline_PacktPub.jsonl
这将产生以下输出:
PacktPub: 145
De_Mote: 15
antoniogarcia78: 11
dptech23: 10
platinumshore: 10
packtauthors: 9
neo4j: 9
Raspberry_Pi: 9
LucasUnplugged: 9
ccaraus: 8
gregturn: 8
ayayalar: 7
rvprasadTweet: 7
triqui: 7
rukku: 7
gileadslostson: 7
gringer_t: 7
kript: 7
zaherg: 6
otisg: 6
分析推文-文本分析
上一节分析了推文的实体字段。这为推文提供了有用的知识,因为这些实体是由推文作者明确策划的。这一部分将关注非结构化数据,即推文的原始文本。我们将讨论文本分析的各个方面,例如文本预处理和规范化,我们将对推文进行一些统计分析。在挖掘细节之前,我们将介绍一些术语。
标记化是预处理阶段的重要步骤之一。给定一个文本流(如推文状态),标记化是将文本分解成称为标记的单个单元的过程。最简单的形式是,这些单位是单词,但我们也可以进行更复杂的标记化,处理短语、符号等。
标记化听起来是一项微不足道的任务,自然语言处理社区已经对其进行了广泛的研究。第 1 章、社交媒体、社交数据、Python 提供了这一领域的简介,并提到了 Twitter 是如何改变标记化规则的,因为一条推文的内容包括表情符号、标签、用户提及、URL,与标准英语有很大不同。为此,在使用自然语言工具包 ( NLTK )库时,我们展示了TweetTokenizer类作为标记 Twitter 内容的工具。我们将在本章中再次使用这个工具。
另一个值得考虑的预处理步骤是停词去除。停止语是孤立地看待时不满足的词。这类词包括文章、命题、副词等。频率分析将显示这些词在任何数据集中通常是最常见的。虽然可以通过分析数据(例如,通过包含出现在超过任意百分比的文档中的单词,如 95%)来自动编译停止词列表,但通常情况下,更保守且仅使用常见的英语停止词是有好处的。NLTK 通过nltk.corpus.stopwords模块提供了一个常见的英语停止词列表。
停止词删除可以扩展到包括符号(如标点符号)或特定领域的词。在我们的推特上下文中,常见的停止词是术语RT(Retweet 的缩写)和 via ,通常用于提及被分享内容的作者。
最后,另一个重要的预处理步骤是归一化。这是一个总括术语,可以由几种类型的处理组成。一般来说,当我们需要在同一个单元中聚合不同的术语时,使用规范化。这里考虑的规范化的特例是用例规范化,其中每个术语都是小写的,这样原本大小写不同的字符串就会匹配(例如'python' == 'Python'.lower())。执行案例规范化的优势在于,给定术语的频率将自动聚合,而不是分散到同一术语的不同变体中。
我们将使用以下脚本进行第一学期频率分析:
# Chap02-03/twitter_term_frequency.py
import sys
import string
import json
from collections import Counter
from nltk.tokenize import TweetTokenizer
from nltk.corpus import stopwords
def process(text, tokenizer=TweetTokenizer(), stopwords=[]):
"""Process the text of a tweet:
- Lowercase
- Tokenize
- Stopword removal
- Digits removal
Return: list of strings
"""
text = text.lower()
tokens = tokenizer.tokenize(text)
return [tok for tok in tokens if tok not in stopwords and not
tok.isdigit()]
if __name__ == '__main__':
fname = sys.argv[1]
tweet_tokenizer = TweetTokenizer()
punct = list(string.punctuation)
stopword_list = stopwords.words('english') + punct + ['rt',
'via', '...']
tf = Counter()
with open(fname, 'r') as f:
for line in f:
tweet = json.loads(line)
tokens = process(text=tweet['text'],
tokenizer=tweet_tokenizer,
stopwords=stopword_list)
tf.update(tokens)
for tag, count in tf.most_common(20):
print("{}: {}".format(tag, count))
预处理逻辑的核心由process()函数实现。该函数将字符串作为输入,并返回字符串列表作为输出。前面提到的所有预处理步骤,大小写规范化、标记化和停止词删除都在这里用几行代码实现。
该函数还接受两个可选参数:一个标记器,即实现tokenize()方法的对象和一个停止词列表,因此可以自定义停止词移除过程。当应用停止词移除时,该函数还会在字符串上使用isdigit()函数移除数字标记(例如,'5'或'42')。
该脚本采用命令行参数对.jsonl文件进行分析。它初始化用于标记化的TweetTokenizer,然后定义一个停止词列表。这样的列表是由来自 NLTK 的常见英语终止词以及string.punctuation中定义的标点符号组成的。为了完成停止词列表,我们还包括'rt'、'via'和'...'标记(一个单字符的 Unicode 符号,代表一个水平省略号)。
可以使用以下命令运行该脚本:
$ python twitter_term_frequency.py filename.jsonl
输出如下:
free: 477
get: 450
today: 437
ebook: 342
http://t.co/wrmxoton95: 295
-: 265
save: 259
ebooks: 236
us: 222
http://t.co/ocxvjqbbiw: 214
#packt5dollar: 211
new: 208
data: 199
@packtpub: 194
': 192
hi: 179
titles: 177
we'll: 160
find: 160
guides: 154
videos: 154
sorry: 150
books: 149
thanks: 148
know: 143
book: 141
we're: 140
#python: 137
grab: 136
#skillup: 133
如我们所见,输出包含单词、标签、用户提及、网址和string.punctuation未捕获的 Unicode 符号的混合。关于额外的符号,我们可以简单地扩展停止词的列表来捕获这种类型的标点符号。关于标签和用户提及,这正是我们对TweetTokenizer的期望,因为所有这些标记都是有效的。也许我们没有预料到的是像we're和we'll这样的代币的存在,因为这些是两个独立代币的收缩形式,而不是单独的代币。如果缩略形式展开,在这两种情况下(我们是和我们将,我们所拥有的只是一系列的停止词,因为这些缩略形式通常是代词和常用动词。例如,维基百科上给出了英文缩写的完整列表。
处理英语这一方面的一种方法是将这些缩略词规范化为它们的扩展形式。例如,下面的函数获取一个令牌列表,并返回一个规范化的令牌列表(更准确地说,是一个生成器,因为我们正在使用yield关键字):
def normalize_contractions(tokens):
token_map = {
"i'm": "i am",
"you're": "you are",
"it's": "it is",
"we're": "we are",
"we'll": "we will",
}
for tok in tokens:
if tok in token_map.keys():
for item in token_map[tok].split():
yield item
else:
yield tok
让我们考虑来自交互式解释器的以下示例:
>>> tokens = tokens = ["we're", "trying", "something"]
>>> list(normalize_contractions(tokens))
['we', 'are', 'trying', 'something']
这种方法的主要问题是需要手动指定我们正在处理的所有收缩。虽然这种缩写的数量有限,但将所有内容翻译成字典将是一项乏味的工作。此外,还有一些不容易处理的歧义,例如我们之前遇到的we will的情况:这种收缩可以映射到 we will 以及we will中,当然这个列表更长。
一种不同的方法是将这些标记视为停止词,因为在规范化之后,它们的所有组成部分似乎都是停止词。
最佳行动方案可能取决于您的应用程序,因此关键问题是在process()函数中形式化的预处理步骤之后会发生什么。目前,我们还可以保持预处理不变,不需要明确处理收缩。
型式
产量和发电机
normalize_contractions()功能使用yield关键字代替return。这个关键字用来产生一个生成器,这是一个你只能迭代一次的迭代器,因为它不把它的项目存储在内存中,而是动态生成它们。优点之一是减少内存消耗,因此建议在大对象上迭代。关键字还允许在同一个函数调用中生成多个项目,而return将在调用后立即关闭计算(例如,normalize_contractions()中的for循环将仅在第一个令牌上运行)。
最后,我们将从不同的角度来分析术语和实体频率。
给定在twitter_hashtag_frequency.py和twitter_term_frequency.py中显示的源代码,我们可以通过实现一个简单的图来扩展这些脚本,而不是打印出最常见术语的频率:
# Chap02-03/twitter_term_frequency_graph.py
y = [count for tag, count in tf.most_common(30)]
x = range(1, len(y)+1)
plt.bar(x, y)
plt.title("Term Frequencies")
plt.ylabel("Frequency")
plt.savefig('term_distribution.png')
前面的片段展示了如何从twitter_term_frequency.py开始绘制术语频率;也很容易将其应用于 hashtag 频率。在@PacktPub对推文运行脚本后,您将创建看起来像图 2.2 的term_distribution.png文件:

图 2.2:最近@PacktPub 推文中术语的出现频率
上图没有报告术语本身,因为最后一个分析的重点是频率分布。从图中我们可以看到,左边有几个出现频率非常高的词条,比如最频繁的词条比位置 10 左右之后的词条出现频率高出一倍。当我们向图的右侧移动时,曲线变得不那么陡峭,这意味着右侧的术语共享相似的频率。
稍加修改,如果我们使用most_common(1000)(即最频繁出现的 1000 个术语)重新运行相同的代码,我们可以更清晰地观察到这一现象,如图图 2.3 :

图 2.3:1000 个最常见术语在@PacktPub 推文中出现的频率
我们可以在图 2.3 中观察到的曲线代表了一个幂律(https://en.wikipedia.org/wiki/Power_law)的近似值。在统计学中,幂律是两个量之间的函数关系;在这种情况下,术语的频率及其在按频率排列的术语排名中的位置。这种类型的分布总是呈现出长尾(https://en.wikipedia.org/wiki/Long_tail),也就是说有一小部分频繁项主导分布,而有大量频率较小的项。这种现象的另一个名称是 80-20 规则或帕累托原则(https://en.wikipedia.org/wiki/Pareto_principle),它指出大约 80%的影响来自 20%的原因(在我们的上下文中,20%的独特术语占所有术语出现的 80%)。
几十年前,美国语言学家乔治·齐夫推广了如今被称为齐夫定律(https://en.wikipedia.org/wiki/Zipf%27s_law)。这个经验定律指出,给定一组文档,任何单词的出现频率都与其在频率表中的排名成反比。换句话说,最频繁出现的单词将是第二频繁出现的单词的两倍,第三频繁出现的单词的三倍,以此类推。实际上,这个定律描述的是一种趋势,而不是精确的频率。有趣的是,齐夫定律可以推广到许多不同的自然语言,以及许多与语言无关的社会科学排名研究。
分析推文-时间序列分析
前面几节分析了推文的内容。在本节中,我们将讨论分析推特数据的另一个有趣的方面——推特随时间的分布。
一般来说,时间序列是由给定时间间隔内的连续观测值组成的数据点序列。由于推特提供了一个带有推文精确时间戳的created_at字段,我们可以将推文重新排列成时间桶,这样我们就可以检查用户对实时事件的反应。我们感兴趣的是观察用户群是如何发推的,而不仅仅是单个用户,因此通过流应用编程接口收集的数据最适合这种类型的分析。
本节的分析使用了 2015 年橄榄球世界杯决赛的数据集。这是一个很好的例子,说明了用户对实时事件的反应,如体育赛事、音乐会、政治选举以及从重大灾难到电视节目的一切。推特数据时间序列分析的其他应用是在线声誉管理。事实上,一家公司可能有兴趣监控用户(可能是客户)在社交媒体上对他们的评论,推特的动态特性似乎非常适合跟踪对产品新闻发布的反应。
我们将利用熊猫的能力来操纵时间序列,我们将再次使用 matplotlib 对该序列进行可视化解释:
# Chap02-03/twitter_time_series.py
import sys
import json
from datetime import datetime
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import pandas as pd
import numpy as np
import pickle
if __name__ == '__main__':
fname = sys.argv[1]
with open(fname, 'r') as f:
all_dates = []
for line in f:
tweet = json.loads(line)
all_dates.append(tweet.get('created_at'))
idx = pd.DatetimeIndex(all_dates)
ones = np.ones(len(all_dates))
# the actual series (at series of 1s for the moment)
my_series = pd.Series(ones, index=idx)
# Resampling / bucketing into 1-minute buckets
per_minute = my_series.resample('1Min', how='sum').fillna(0)
# Plotting the series
fig, ax = plt.subplots()
ax.grid(True)
ax.set_title("Tweet Frequencies")
hours = mdates.MinuteLocator(interval=20)
date_formatter = mdates.DateFormatter('%H:%M')
datemin = datetime(2015, 10, 31, 15, 0)
datemax = datetime(2015, 10, 31, 18, 0)
ax.xaxis.set_major_locator(hours)
ax.xaxis.set_major_formatter(date_formatter)
ax.set_xlim(datemin, datemax)
max_freq = per_minute.max()
ax.set_ylim(0, max_freq)
ax.plot(per_minute.index, per_minute)
plt.savefig('tweet_time_series.png')
可以使用以下命令运行代码:
$ python twitter_time_series.py stream__RWC2015__RWCFinal_Rugby.jsonl
该脚本读取作为命令行参数给出的.jsonl文件,按照前面脚本中的讨论,一次加载一行推文。因为我们只对推文的发布日期感兴趣,所以我们构建了一个列表all_dates,它只包含每条推文的created_at属性。
熊猫系列由创建时间索引,创建时间在idx变量中表示为之前收集的日期的pd.DatetimeIndex。这些日期的粒度可以追溯到第二个。然后,我们使用ones()功能创建np.array1,该功能将用于聚合推文频率。
一旦系列被创建,我们使用一种称为分时段或重采样的技术,这基本上改变了我们索引系列的方式,在这种情况下,每分钟将推文分组。当我们指定通过sum重新采样时,每个桶将包含在该特定分钟发布的推文计数。重采样结束时追加的fillna(0)调用只是一个预防措施,以防某个桶没有任何推文;在这种情况下,桶不会被丢弃,而是包含 0 作为频率。考虑到我们数据集的大小,这不太可能发生,但对于较小的数据来说,这是一个真正的可能性。
为了阐明重采样会发生什么,我们可以观察下面的例子,通过打印my_series.head()和per_minute.head()获得:
# Before resampling
# my_series.head()
2015-10-31 15:11:32 1
2015-10-31 15:11:32 1
2015-10-31 15:11:32 1
2015-10-31 15:11:32 1
2015-10-31 15:11:32 1
dtype: float64
# After resampling
# per_minute.head()
2015-10-31 15:11:00 195
2015-10-31 15:12:00 476
2015-10-31 15:13:00 402
2015-10-31 15:14:00 355
2015-10-31 15:15:00 466
dtype: float64
输出的第一列包含序列索引,它是datetime对象(时间精度低至秒),而第二列是与该索引相关联的值(频率)。
由于每秒钟有大量的推文,原始系列my_series多次显示相同的索引(在示例中为15:11:32)。一旦序列被重新采样,序列索引的粒度就下降到一分钟。
下面的代码使用 matplotlib 库来创建时间序列的可视化。Matplotlib 通常比其他数据分析库更详细,但并不复杂。这个例子中有趣的部分是使用MinuteLocator和DateFormatter来正确标注 x 轴上的时间间隔。在这种情况下,使用 20 分钟的时间间隔。
剧情保存在tweet_time_series.png中,我们可以在图 2.4 中观察到:

图 2.4:2015 年橄榄球世界杯决赛期间的推文频率
图 2.4 中时间戳的时区为 GMT,即英国时间。决赛的开球时间定在下午 4 点。正如我们所看到的,随着我们越来越接近开球时间,用户活动有所增加,比赛开始后不久就出现了一个高峰。很少有超过每分钟 2000 条推文的高峰是这场比赛的一些热门时刻——首先,新西兰遥遥领先,澳大利亚接近复出(对中立球迷来说,这是一场有趣的比赛)。最后一个巨大的高峰与比赛结束、颁奖仪式和持续了一段时间的庆祝活动相一致(尽管人流在下午 6 点左右停止)。
总结
本章介绍了一些使用推特数据的数据挖掘应用。我们讨论了如何向推特平台注册一个应用程序,以便获得凭证并与推特应用程序接口进行交互。我们考虑了下载推文的不同方式,特别是使用 REST 端点搜索已发布的推文,以及使用 Streaming API 保持连接打开并收集即将发布的推文。
在观察一条推文的解剖结构时,我们发现一条推文要比 140 个字符多得多。事实上,它是一个复杂的物体,里面有很多信息。
我们分析的起点开启了基于实体的频率分析的讨论。我们的重点一直放在 hashtags 上,这是 Twitter 的特性之一,被用户广泛采用来跟踪特定的主题。我们还讨论了自然语言处理 ( 自然语言处理)的一些方面,例如标记化和标记规范化。正如我们所看到的,推特上的语言没有遵循标准英语的惯例,有特定的特征,如标签、用户提及、网址、表情符号等。另一方面,我们发现推特上的语言遵循一个被称为齐夫定律的统计规则,就像任何其他大小合适的自然语言语料库一样。
为了总结我们的分析,我们看了看时间序列。观察用户对实时事件的反应非常有趣,时间序列分析可以成为分析大型数据集的强大工具。
下一章也是关于推特的,但重点是用户。特别是,我们想了解连接到这个社交网络的用户。
三、推特上的用户、关注者和社区
本章继续讨论挖掘推特数据。在重点分析了上一章的推文之后,我们现在将把注意力转移到用户、他们的联系和他们的互动上。
在本章中,我们将讨论以下主题:
- 如何为给定用户下载好友和关注者列表
- 如何分析用户之间的联系,共同的朋友等等
- 如何衡量推特上的影响力和参与度
- 聚类算法以及如何使用 scikit-learn 对用户进行聚类
- 网络分析以及如何利用它来挖掘推特上的对话
- 如何创建动态地图来可视化推文的位置
用户、朋友和追随者
推特和其他流行社交网络的主要区别之一是用户的连接方式。事实上,推特上的关系不一定是双向的。用户可以选择订阅其他用户的推文,成为他们的跟随者,但跟随者的行为可能不会得到回报。这与脸书和领英等其他社交网络的情况截然不同,在这些社交网络中,双方必须在发生关系之前确认关系。
用推特术语来说,关系的两个方向(我追随的人和追随我的人)有不同的名字。我追随的人被称为朋友,而跟随我的人被称为我的追随者。当关系是双向的,用户通常被描述为共同的朋友。
回到推特 API
Twitter API 提供了几个端点来检索关于追随者、朋友和一般用户配置文件的信息。特定端点的选择将由我们试图解决的任务决定。
快速浏览一下文档(https://dev.twitter.com/rest/public)将会突出一些有趣的文档。
从单用户配置文件开始,明显需要考虑的端点是users/show:给定一个屏幕名称或用户 ID,端点将为一个用户检索完整的配置文件。这一功能受到严重的速率限制,因为每 15 分钟只能有 180 个请求,这意味着在 15 分钟的窗口中只有 180 个用户配置文件。鉴于这种限制,只有当我们需要检索特定的配置文件时,才应该使用这个端点,它不适合批量下载。
可以使用followers/list端点(https://dev.twitter.com/rest/reference/get/followers/list)检索给定用户的关注者列表,该端点在 Tweepy 中使用API.followers()功能实现。类似地,friends/list端点允许检索给定用户的好友列表,Tweepy 实现作为API.friends()可用。这些端点的主要问题是严格的速率限制:在 15 分钟的窗口内只有 15 个请求,每个请求最多提供 20 个用户配置文件。这意味着每 15 分钟检索多达 300 个配置文件。
虽然这种方法对于拥有极少数粉丝和朋友的个人资料来说是可行的,但拥有数千粉丝的个人资料并不罕见(对于名人来说,这一数字可能高达数百万)。
此限制的变通方法是基于更适合大批量数据的其他端点的组合。followers/ids端点(https://dev.twitter.com/rest/reference/get/followers/ids)可以根据每个请求返回 5000 个用户标识的组。尽管在 15 分钟的窗口内也限制为 15 个请求,但很容易计算出我们可以检索的最终用户标识数(每一刻钟 75,000 个)如何比以前的限制好得多。
使用followers/ids端点后,我们只有用户关注者对应的用户 id 列表,但是我们还没有完整的配置文件。解决方案是将这些用户标识用作users/lookup端点(https://dev.twitter.com/rest/reference/get/users/lookup)的输入,该端点最多接受 100 个标识作为输入,并提供相应的完整配置文件列表作为输出。users/lookup的速率限制设置为每 15 分钟 180 个,相当于每一刻钟 18000 个配置文件。这个数字实际上限制了我们的下载。
如果我们想下载一批好友的个人资料,将friends/ids端点和前面提到的users/lookup结合起来,变通方法也可以。在速率限制方面,当下载朋友的 id 时,我们会遇到和跟随者一样的限制。要避免的一个小错误是认为两个下载过程完全独立;请记住users/lookup是瓶颈。对于同时下载好友和关注者配置文件的脚本,我们需要考虑到两个下载过程都需要users/lookup端点,因此对users/lookup的请求总数将达到好友和关注者的总和。对于既是朋友又是关注者的用户,只需要一次查找。
用户配置文件的结构
在深入挖掘如何下载大量关注者和好友简介的细节之前,我们将考虑单个用户的情况来了解一个用户简介的结构。我们可以使用users/show端点,因为这是一个一次性的例子,所以我们没有达到速率限制(下一节将讨论批量下载)。
端点通过API.get_user()函数在 Tweepy 中实现。我们可以重用我们在第 2 章、# miningtwetter-Hashtags、主题和时间序列中定义的身份验证代码(确保环境变量设置正确)。从交互式解释器中,我们将输入以下内容:
>>> from twitter_client import get_twitter_client
>>> import json
>>> client = get_twitter_client()
>>> profile = client.get_user(screen_name="PacktPub")
>>> print(json.dumps(profile._json, indent=4))
这段代码非常简单;一旦执行了身份验证,单个 API 调用将允许我们下载配置文件。函数调用返回的对象是tweepy.models.User类的一个实例,已经在第 2 章、# miningtwetter-Hashtags、Topics 和 Time Series 中提到过,它是存储不同用户属性的包装器。推特最初的 JSON 响应被存储为 Python 字典中的_json属性,我们将在屏幕上输出,使用json.dumps()和indent参数进行一些漂亮的打印。
我们将观察到一段类似于以下代码的 JSON(为了简洁起见,省略了几个属性):
{
"screen_name": "PacktPub",
"name": "Packt Publishing",
"location": "Birmingham, UK",
"id": 17778401,
"id_str": "17778401",
"description": "Providing books, eBooks, video tutorials, and
articles for IT developers, administrators, and users.",
"followers_count": 10209,
"friends_count": 3583,
"follow_request_sent": false,
"status": { ... },
"favourites_count": 556,
"protected": false,
"verified": false,
"statuses_count": 10802,
"lang": "en",
"entities": {
"description": {
"urls": []
},
"url": {
"urls": [
{
"indices": [
0,
22
],
"display_url": "PacktPub.com",
"expanded_url": "http://www.PacktPub.com",
"url": "http://t.co/vEPCgOu235"
}
]
}
},
"following": true,
"geo_enabled": true,
"time_zone": "London",
"utc_offset": 0,
}
下表描述了我们可以找到的所有字段:
| **属性名称** | **描述** | | `_json` | 这是一个带有用户配置文件的 JSON 响应的字典 | | `created_at` | 这是用户帐户创建的 UTC 日期时间 | | `contributors_enabled` | 这是指示**投稿人模式**启用(很少`true`)的标志 | | `default_profile` | 这是一个标志,表示用户没有改变配置文件主题 | | `description` | 这是描述用户配置文件的字符串 | | `default_profile_image` | 这是表示用户没有自定义个人资料图片的标志 | | `entities` | 这是 URL 或描述中的实体列表 | | `followers_count` | 这是追随者的数量 | | `follow_request_sent` | 这是指示是否发送了跟随请求的标志 | | `favourites_count` | 这是用户喜欢的推文数量 | | `following` | 这是指示经过身份验证的用户是否在跟踪的标志 | | `friends_count` | 这是朋友的数量 | | `geo_enabled` | 这是表示**地理标记**启用的标志 | | `id` | 这是作为大整数的用户的唯一标识 | | `id_str` | 这是字符串形式的用户的唯一标识 | | `is_translator` | 这是一个标志,表明用户是 Twitter 翻译社区的一部分 | | `lang` | 这是用户的语言代码 | | `listed_count` | 这是用户所属的公共列表的数量 | | `location` | 这是用户以字符串形式声明的位置 | | `name` | 这是用户的名称 | | `profile_*` | 这是配置文件相关的信息量(请参考下面的配置文件相关属性列表) | | `protected` | 这是指示用户是否保护其推文的标志 | | `status` | 这是一个嵌入了最新推文的对象(所有可用字段请参考[第 2 章](2.html "Chapter 2. #MiningTwitter – Hashtags, Topics, and Time Series")、*# miningtwetter-Hashtags、主题和时间序列*) | | `screen_name` | 这是用户的屏幕名称,也就是 Twitter 句柄 | | `statuses_count` | 这是推文的数量 | | `time_zone` | 这是用户声明的时区字符串 | | `utc_offset` | 这是相对于格林尼治标准时间/世界协调时的偏移量,以秒为单位 | | `url` | 这是用户提供的与配置文件关联的 URL | | `verified` | 这是指示用户是否被验证的标志 |配置文件相关属性列表如下:
-
profile_background_color:这是用户为其背景选择的十六进制色码 -
profile_background_tile:这是表示profile_background_image_url显示时应该平铺的标志 -
profile_link_color:这是用户选择在 Twitter UI 中显示链接的十六进制色码 -
profile_use_background_image:这是表示用户希望使用自己上传的背景图片的标志 -
profile_background_image_url:这是一个基于 HTTP 的 URL,指向上传的背景图片 -
profile_background_image_url_https:这个和之前的属性一样,但是基于 HTTPS -
profile_text_color:这是用户选择的十六进制颜色代码,用于在他们的推特用户界面中显示文本 -
profile_banner_url:这是用户上传的个人资料横幅的 HTTPS 网址 -
profile_sidebar_fill_color:这是用户选择的十六进制颜色代码,用于在他们的推特用户界面中显示侧栏背景 -
profile_image_url:这是一个基于 HTTP 的用户头像 URL -
profile_image_url_https:这个和之前的属性一样,但是基于 HTTPS -
profile_sidebar_border_color:这是用户选择的十六进制颜色代码,用他们的 Twitter UI 显示侧边栏边框
下一节将讨论如何为给定用户的朋友和追随者下载用户配置文件。
下载好友和关注者的个人资料
鉴于上一节中对相关端点的讨论,我们可以创建一个脚本,该脚本将用户名(屏幕名称)作为输入,并下载他们的完整个人资料、关注者列表(带有完整个人资料)和朋友列表(也带有完整个人资料):
# Chap02-03/twitter_get_user.py
import os
import sys
import json
import time
import math
from tweepy import Cursor
from twitter_client import get_twitter_client
MAX_FRIENDS = 15000
def usage():
print("Usage:")
print("python {} <username>".format(sys.argv[0]))
def paginate(items, n):
"""Generate n-sized chunks from items"""
for i in range(0, len(items), n):
yield items[i:i+n]
if __name__ == '__main__':
if len(sys.argv) != 2:
usage()
sys.exit(1)
screen_name = sys.argv[1]
client = get_twitter_client()
dirname = "users/{}".format(screen_name)
max_pages = math.ceil(MAX_FRIENDS / 5000)
try:
os.makedirs(dirname, mode=0o755, exist_ok=True)
except OSError:
print("Directory {} already exists".format(dirname))
except Exception as e:
print("Error while creating directory {}".format(dirname))
print(e)
sys.exit(1)
# get followers for a given user
fname = "users/{}/followers.jsonl".format(screen_name)
with open(fname, 'w') as f:
for followers in Cursor(client.followers_ids,
screen_name=screen_name).pages(max_pages):
for chunk in paginate(followers, 100):
users = client.lookup_users(user_ids=chunk)
for user in users:
f.write(json.dumps(user._json)+"\n")
if len(followers) == 5000:
print("More results available. Sleeping for 60 seconds to
avoid rate limit")
time.sleep(60)
# get friends for a given user
fname = "users/{}/friends.jsonl".format(screen_name)
with open(fname, 'w') as f:
for friends in Cursor(client.friends_ids,
screen_name=screen_name).pages(max_pages):
for chunk in paginate(friends, 100):
users = client.lookup_users(user_ids=chunk)
for user in users:
f.write(json.dumps(user._json)+"\n")
if len(friends) == 5000:
print("More results available. Sleeping for 60 seconds to
avoid rate limit")
time.sleep(60)
# get user's profile
fname = "users/{}/user_profile.json".format(screen_name)
with open(fname, 'w') as f:
profile = client.get_user(screen_name=screen_name)
f.write(json.dumps(profile._json, indent=4))
该脚本从命令行中获取一个参数,即您要分析的用户的屏幕名称。例如,可以使用以下命令运行该脚本:
$ python twitter_get_user.py PacktPub
由于在撰写本文时,PacktPub 拥有超过 10,000 名关注者,由于 API 的限制,脚本将需要 2 分钟以上的时间才能运行。如第 2 章、# miningtwetter-Hashtags、Topics 和 Time Series 中所述,该脚本使用time.sleep()来减缓执行速度,避免达到速率限制。传递给sleep()功能的秒数由应用编程接口决定。
分析你的网络
下载与给定个人资料的朋友和追随者相关的数据后,我们可以开始对这些连接创建的网络结构进行一些探索性的分析。图 3.1 展示了一个虚构的用户小网络的例子,用户之间的链接从第一人称的角度突出显示(也就是从贴有 ME 标签的用户的角度,所以我们用第一人称来描述图片):

图 3.1:本地网络的一个例子:朋友、追随者和共同的朋友
在这个例子中,我连接到四个不同的用户: PETER 和 JOHN 跟随我(所以他们被标记为 FOLLOWERS ,而我跟随 LUCY 、 MARY 和 JOHN (所以他们被标记为 FRIENDS )。约翰属于两个群体:朋友和追随者之间的交集被描述为共同的朋友。
从这个表象我们不知道的是,这四个用户之间是否也有联系。这就是我们在前一节下载的数据的本质:我们有关于给定个人资料的朋友和追随者的信息,但是如果我们想发现他们之间的联系,我们需要遍历他们的所有个人资料并下载相关数据。
有了这些数据,我们就有了关于关注者和朋友数量的基本统计,现在我们可以回答以下基本问题:
- 谁是我共同的朋友?
- 谁没跟着我回去?
- 我没跟谁回去?
以下脚本读取先前下载的 JSONL 文件,并计算统计数据来回答这些问题:
# Chap02-03/twitter_followers_stats.py
import sys
import json
def usage():
print("Usage:")
print("python {} <username>".format(sys.argv[0]))
if __name__ == '__main__':
if len(sys.argv) != 2:
usage()
sys.exit(1)
screen_name = sys.argv[1]
followers_file = 'users/{}/followers.jsonl'.format(screen_name)
friends_file = 'users/{}/friends.jsonl'.format(screen_name)
with open(followers_file) as f1, open(friends_file) as f2:
followers = []
friends = []
for line in f1:
profile = json.loads(line)
followers.append(profile['screen_name'])
for line in f2:
profile = json.loads(line)
friends.append(profile['screen_name'])
mutual_friends = [user for user in friends
if user in followers]
followers_not_following = [user for user in followers
if user not in friends]
friends_not_following = [user for user in friends
if user not in followers]
print("{} has {} followers".format(screen_name,
len(followers)))
print("{} has {} friends".format(screen_name, len(friends)))
print("{} has {} mutual friends".format(screen_name,
len(mutual_friends)))
print("{} friends are not following {}
back".format(len(friends_not_following), screen_name))
print("{} followers are not followed back by
{}".format(len(followers_not_following), screen_name))
该脚本使用用户名作为命令行参数,因此您可以使用以下命令运行它:
$ python twitter_followers_stats.py PacktPub
这将产生以下输出:
PacktPub has 10209 followers
PacktPub has 3583 friends
PacktPub has 2180 mutual friends
1403 friends are not following PacktPub back
8029 followers are not followed back by PacktPub
用来处理朋友和关注者的数据类型是一个普通的 Python list(),用用户名(【准确的说是screen_name)填充。代码读取两个 JSONL 文件,将屏幕名称添加到各自的列表中。然后使用三种列表理解来构建不同的统计数据。
型式
Python 中的列表理解
Python 支持一个叫做列表理解的概念,这是一种将一个列表(或任何可迭代列表)转换成另一个列表的优雅方式。在此过程中,可以通过自定义函数有条件地包含和转换元素,例如:
>>> numbers = [1, 2, 3, 4, 5]
>>> squares = [x*x for x in numbers]
>>> squares
[1, 4, 9, 16, 25]
前面的列表理解相当于下面的代码:
>>> squares = []
>>> for x in numbers:
... squares.append(x*x)
...
>>> squares
[1, 4, 9, 16, 25]
列表理解的一个很好的方面是它们也可以用简单的英语阅读,所以代码特别易读。
理解不仅仅局限于列表,事实上,它们也可以用来构建词典。
关于实现的这一方面,有几个注意事项。首先,JSONL 文件将包含唯一的配置文件:每个追随者(或朋友)将只列出一次,因此我们没有重复的条目。其次,在计算前面的统计数据时,项目的顺序是不相关的。事实上,我们所做的只是计算基于集合的操作(在这个例子中是交集和差集)。
我们可以重构代码,使用set()实现基本统计;脚本中的变化与我们如何加载数据和计算共同的朋友、没有跟踪回来的朋友和没有跟踪回来的追随者有关:
with open(followers_file) as f1, open(friends_file) as f2:
followers = set()
friends = set()
for line in f1:
profile = json.loads(line)
followers.add(profile['screen_name'])
for line in f2:
profile = json.loads(line)
friends.add(profile['screen_name'])
mutual_friends = friends.intersection(followers)
followers_not_following = followers.difference(friends)
friends_not_following = friends.difference(followers)
这将产生与使用列表的代码相同的输出。使用集合而不是列表的主要优势在于计算的复杂性:像包含(也就是检查item in list或item in set)这样的操作对于列表是以线性时间运行的,对于集合是以恒定时间运行的。遏制用于构建mutual_friends、followers_not_following和friends_not_following,因此这一简单的更改将显著影响脚本的总运行时间,并且随着关注者/朋友数量的增加,由于列表所需的线性复杂性,这种差异更加明显。
型式
计算复杂度
上一节使用了线性时间、恒定时间、线性复杂度等短语。计算复杂性的概念是计算机科学中的一个重要概念,因为它涉及算法运行所需的资源量。在这一段中,我们将讨论时间复杂度和算法运行所花费的时间,描述为其输入大小的函数。
当算法以线性时间运行时,对于大的输入大小,其运行时间随着输入的大小线性增加。描述这类算法的数学符号(称为大 O 符号)是O(n),其中n是输入的大小。
相反,对于在恒定时间内运行的算法,输入的大小不会影响运行时间。这种情况下使用的符号是O(1)。
一般来说,理解不同操作和数据结构的复杂性是开发非平凡程序的关键步骤,因为这可能会对我们系统的性能产生巨大影响。
此时,读者可能会对 NumPy 等库的使用感到好奇。正如在第 1 章、社交媒体、社交数据和 Python 中已经讨论过的,NumPy 为类似数组的数据结构提供了快速高效的处理,并且比简单列表提供了显著的性能提升。尽管针对速度进行了优化,但在这种特定情况下使用 NumPy 不会在性能方面产生任何特别的好处,这仅仅是因为包含操作的计算成本与列表的计算成本相同。重构前面的代码以使用 NumPy 会生成以下代码:
with open(followers_file) as f1, open(friends_file) as f2:
followers = []
friends = []
for line in f1:
profile = json.loads(line)
followers.append(profile['screen_name'])
for line in f2:
profile = json.loads(line)
friends.append(profile['screen_name'])
followers = np.array(followers)
friends = np.array(friends)
mutual_friends = np.intersect1d(friends,
followers,
assume_unique=True)
followers_not_following = np.setdiff1d(followers,
friends,
assume_unique=True)
friends_not_following = np.setdiff1d(friends,
followers,
assume_unique=True)
这个片段假设 NumPy 已经用别名np导入,如第 1 章、社交媒体、社交数据和 Python 中所述。该库提供了一些类似集合的操作- intersect1d和setdiff1d,但是底层数据结构仍然是类似数组的对象(类似列表)。这些函数的第三个参数assume_unique可以在输入数组具有唯一元素的假设下使用,就像我们的例子一样。这有助于加快计算速度,但底线保持不变:拥有数百个以上的关注者/朋友,在执行这些操作时,集合会更快。对于少数追随者/朋友来说,性能的差异不会很明显。
这本书提供的源代码提供了用twitter_followers_stats.py、twitter_followers_stats_set.py和twitter_followers_stats_numpy.py编码的三种不同的实现,其中包括一些时序计算,以便您可以用不同的数据进行实验。
注
总结这段重构的间歇期,主要的信息是为您正在处理的操作类型选择最合适的数据结构。
衡量影响力和参与度
社交媒体领域最常被提及的角色之一是神话般的影响者。这一数字导致了最近营销策略的范式转变(https://en.wikipedia.org/wiki/Influencer_marketing),其重点是针对关键个人,而不是整个市场。
影响者通常是其社区内的活跃用户;以推特为例,一个有影响力的人会发很多关于他们关心的话题的推文。有影响力的人在追随时有很好的关系,并被社区中的许多其他用户追随。一般来说,影响者也被认为是他们所在领域的专家,通常受到其他用户的信任。
这种描述应该解释为什么影响者是最近营销趋势的重要组成部分——一个影响者可以提高知名度,甚至成为特定产品或品牌的倡导者,并可以接触到大量的支持者。
无论你的主要兴趣是 Python 编程还是品酒,无论你的社交网络有多大(或多小),你可能已经知道你社交圈子里的影响者是谁:朋友、熟人或互联网上的随机陌生人,他们的观点你信任并重视,因为他们在给定主题上的专业知识。
一个不同但又有某种关联的概念是订婚。用户参与度或客户参与度是对特定产品或服务的响应的评估。在社交媒体环境中,创建内容片段的目的通常是为了推动公司网站或电子商务的流量。衡量参与度很重要,因为它有助于定义和理解策略,以最大限度地与您的网络互动,并最终带来业务。在推特上,用户通过转发或喜欢特定推文的方式参与进来,这反过来又为推文提供了更多的可见性。
在这一节中,我们将讨论社交媒体分析中一些有趣的方面,比如衡量影响力和参与度的可能性。在推特上,一个自然的想法是将影响力与特定网络中的用户数量联系起来。直觉上,高关注人数意味着一个用户可以接触到更多的人,但这并不能告诉我们一条推文是如何被感知的。
以下脚本比较了两个用户配置文件的一些统计信息:
# Chap02-03/twitter_influence.py
import sys
import json
def usage():
print("Usage:")
print("python {} <username1> <username2>".format(sys.argv[0]))
if __name__ == '__main__':
if len(sys.argv) != 3:
usage()
sys.exit(1)
screen_name1 = sys.argv[1]
screen_name2 = sys.argv[2]
从命令行读取两个屏幕名称后,我们将为每个名称建立一个关注者列表,包括关注者的数量,以计算可访问用户的数量:
followers_file1 = 'users/{}/followers.jsonl'.format(screen_name1)
followers_file2 = 'users/{}/followers.jsonl'.format(screen_name2)
with open(followers_file1) as f1, open(followers_file2) as f2:
reach1 = []
reach2 = []
for line in f1:
profile = json.loads(line)
reach1.append((profile['screen_name'],
profile['followers_count']))
for line in f2:
profile = json.loads(line)
reach2.append((profile['screen_name'],
profile['followers_count']))
然后,我们将从两个用户配置文件中加载一些基本统计数据(关注者计数和状态计数):
profile_file1 = 'users/{}/user_profile.json'.format(screen_name1)
profile_file2 = 'users/{}/user_profile.json'.format(screen_name2)
with open(profile_file1) as f1, open(profile_file2) as f2:
profile1 = json.load(f1)
profile2 = json.load(f2)
followers1 = profile1['followers_count']
followers2 = profile2['followers_count']
tweets1 = profile1['statuses_count']
tweets2 = profile2['statuses_count']
sum_reach1 = sum([x[1] for x in reach1])
sum_reach2 = sum([x[1] for x in reach2])
avg_followers1 = round(sum_reach1 / followers1, 2)
avg_followers2 = round(sum_reach2 / followers2, 2)
然后,我们将为这两个用户加载时间表,以观察他们的推文被支持或转发的次数:
timeline_file1 = 'user_timeline_{}.jsonl'.format(screen_name1)
timeline_file2 = 'user_timeline_{}.jsonl'.format(screen_name2)
with open(timeline_file1) as f1, open(timeline_file2) as f2:
favorite_count1, retweet_count1 = [], []
favorite_count2, retweet_count2 = [], []
for line in f1:
tweet = json.loads(line)
favorite_count1.append(tweet['favorite_count'])
retweet_count1.append(tweet['retweet_count'])
for line in f2:
tweet = json.loads(line)
favorite_count2.append(tweet['favorite_count'])
retweet_count2.append(tweet['retweet_count'])
然后,前面的数字被汇总到收藏夹和转发的平均数量中,包括绝对数量和每个关注者的数量:
avg_favorite1 = round(sum(favorite_count1) / tweets1, 2)
avg_favorite2 = round(sum(favorite_count2) / tweets2, 2)
avg_retweet1 = round(sum(retweet_count1) / tweets1, 2)
avg_retweet2 = round(sum(retweet_count2) / tweets2, 2)
favorite_per_user1 = round(sum(favorite_count1) / followers1, 2)
favorite_per_user2 = round(sum(favorite_count2) / followers2, 2)
retweet_per_user1 = round(sum(retweet_count1) / followers1, 2)
retweet_per_user2 = round(sum(retweet_count2) / followers2, 2)
print("----- Stats {} -----".format(screen_name1))
print("{} followers".format(followers1))
print("{} users reached by 1-degree
connections".format(sum_reach1))
print("Average number of followers for {}'s followers:
{}".format(screen_name1, avg_followers1))
print("Favorited {} times ({} per tweet, {} per
user)".format(sum(favorite_count1), avg_favorite1,
favorite_per_user1))
print("Retweeted {} times ({} per tweet, {} per
user)".format(sum(retweet_count1), avg_retweet1,
retweet_per_user1))
print("----- Stats {} -----".format(screen_name2))
print("{} followers".format(followers2))
print("{} users reached by 1-degree
connections".format(sum_reach2))
print("Average number of followers for {}'s followers:
{}".format(screen_name2, avg_followers2))
print("Favorited {} times ({} per tweet, {} per
user)".format(sum(favorite_count2), avg_favorite2,
favorite_per_user2))
print("Retweeted {} times ({} per tweet, {} per
user)".format(sum(retweet_count2), avg_retweet2,
retweet_per_user2))
该脚本从命令行获取两个参数,并假设数据已经下载。对于这两个用户,我们需要关注者的数据(用twitter_get_user.py下载)和他们各自的用户时间表(用twitter_get_user_timeline.py从第二章、# miningtwetter-Hashtags、Topics 和时间序列下载)。
该脚本有些冗长,因为它为两个配置文件计算相同的操作,并在终端上打印所有内容。我们可以把它分解成不同的部分。
首先,我们将调查追随者的追随者。这将提供与直接连接到给定用户的网络部分相关的一些信息。换句话说,它应该回答这样一个问题:如果我的所有追随者都转发我,我能接触到多少用户?我们将通过读取users/<user>/followers.jsonl文件并保存元组列表来实现这一点,其中每个元组代表一个追随者,并且是(screen_name, followers_count)形式。在这个阶段保持屏幕名称是有用的,以防我们想要找出谁是拥有最多关注者的用户(没有在脚本中计算,但是使用sorted()很容易产生)。
第二步,我们将从users/<user>/user_profile.json文件中读取用户简介,这样我们就可以获得关注者和推文总数的信息。通过到目前为止收集的数据,我们可以计算出在一个分离度内可到达的用户总数(追随者的追随者)和追随者的平均追随者数量。这是通过以下方式实现的:
sum_reach1 = sum([x[1] for x in reach1])
avg_followers1 = round(sum_reach1 / followers1, 2)
第一个使用列表理解来迭代前面提到的元组列表,而第二个是简单的算术平均,四舍五入到小数点后两位。
脚本的第三部分从第二章、# miningtwetter-Hashtags、Topics 和 Time Series 中产生的user_timeline_<user>.jsonl文件中读取用户时间线,并收集每条推文的转发次数和喜爱度信息。将所有内容放在一起,我们可以计算用户被转发或喜欢的次数,以及每条推文和每个追随者的平均转发/喜欢次数。
为了提供一个例子,我将进行一些虚荣心分析,并将我的@marcobonzanini账户与 Packt Publishing 进行比较:
$ python twitter_influence.py marcobonzanini PacktPub
该脚本产生以下输出:
----- Stats marcobonzanini -----
282 followers
1411136 users reached by 1-degree connections
Average number of followers for marcobonzanini's followers: 5004.03
Favorited 268 times (1.47 per tweet, 0.95 per user)
Retweeted 912 times (5.01 per tweet, 3.23 per user)
----- Stats PacktPub -----
10209 followers
29961760 users reached by 1-degree connections
Average number of followers for PacktPub's followers: 2934.84
Favorited 3554 times (0.33 per tweet, 0.35 per user)
Retweeted 6434 times (0.6 per tweet, 0.63 per user)
如你所见,关注者的原始数量没有显示出任何竞争,Packt Publishing 的关注者大约是我的 35 倍。当我们比较平均转发数和收藏数时,这个分析有趣的部分就出现了;显然,我的追随者比 PacktPub 的更关注我的内容。这足以证明我是一个有影响力的人,而 PacktPub 不是吗?显然不是。我们在这里观察到的是一个自然的结果,即我的推文可能更专注于特定的主题(Python 和数据科学),因此我的追随者已经对我发布的内容更感兴趣。另一方面,Packt Publishing 制作的内容高度多样化,因为它涵盖了许多不同的技术。这种多样性也体现在 PacktPub 的追随者中,他们包括开发人员、设计人员、科学家、系统管理员等等。由于这个原因,PacktPub 的每条推文都被较少比例的追随者发现有趣(即值得转发)。
挖掘你的追随者
在现实世界中,社交社区是一群有着一些共同条件的人。这一宽泛的定义包括,例如,位于精确地理区域的人群、具有相同政治或宗教信仰的人群,或有共同特定兴趣的人群,如阅读书籍。
社交社区的概念也是社交媒体平台的核心。虚拟环境中的社区边界可能比现实世界中更模糊,例如,地理方面的决定因素较少。就像在面对面的情况下,当有共同兴趣或条件的人开始互动时,社区自然会出现在社交媒体平台上。
我们可以对不同类型的社区进行第一次区分,这取决于它们的成员是否明确理解自己是社区的一部分。在显性社区中,成员和非成员确切知道自己是否属于该社区,通常会了解其他社区成员是谁。社区成员之间的互动比与非成员之间的互动更频繁。
另一方面,隐含的社区在没有被明确承认的情况下产生。这类社区的成员可能有共同的利益,但没有明确而牢固的联系。
本节建议对用户数据进行分析,以便将一组用户资料分组,目的是突出这些共同的兴趣或条件。
聚类,或称聚类分析,是一种机器学习技术,用于对项目进行分组,使得同一组中的对象(即聚类)彼此之间的相似度高于其他聚类中的对象。聚类属于无监督学习技术的范畴,这意味着我们正在处理的对象没有被明确标记。无监督学习的目的是发现数据中隐藏的结构。
聚类应用的一个常见示例是市场研究,例如,将具有相似行为/兴趣的客户群体进行细分,以便他们可以成为不同产品或营销活动的目标。社交网络分析是聚类发现有趣应用的另一个重要领域,因为它可以用于识别更大人群中的社区结构。
聚类不仅仅是一个特定的算法,而是一个可以被不同算法处理的一般任务。我们选择的分析方法是 K-Means ,这是最流行的聚类方法之一。K-Means 是一个方便的选择,因为它相对容易理解和实现,并且与其他聚类方法相比计算效率高。
K-Means 本质上采用两个参数:n 维空间中的多个输入向量,代表我们要聚类的对象,以及 K 作为我们要将输入划分到的聚类数。由于我们处于无监督的学习环境中,我们没有任何关于正确类别标签的基本事实信息。更具体地说,我们甚至不知道正确(或理想)的簇数。这个细节在聚类分析中是相当核心的,但暂时我们不会太担心。
上一段定义了我们想要在 n 维空间中聚类为向量的对象。更简单地说,这意味着每个用户简档将被表示为一个名为特征的 n 数值元素的向量。
我们选择的定义这些特征的方法是基于用户的描述,即他们提供的关于他们的兴趣或职业的文本信息。将此文本描述转换为要素向量的过程称为矢量化。K-Means 算法和矢量化工具都是通过 scikit-learn 中的简单界面实现的,scikit-learn 是 Python 的机器学习工具包,已经在第 1 章、社交媒体、社交数据和 Python 中介绍过。
矢量化过程包括将用户描述分解成令牌,然后为每个令牌分配特定的权重。本例采用的加权方案是常见的 TF-IDF 方法(https://en.wikipedia.org/wiki/Tf%E2%80%93idf),是词频 ( TF )和逆文档频率 ( IDF )的组合。TF 是一个局部统计数据,因为它表示一个单词在文档中出现频率。另一方面,IDF 是一个全球性的统计数据——它代表了一个词在一组文档中是多么罕见。这些统计数据通常用在搜索引擎和不同的文本挖掘应用程序中,提供了一个词在给定上下文中有多重要的度量。
TF-IDF 提供了简单直觉背后的数字权重;如果一个词在一个文档中频繁出现,并且在整个集合中很少出现,那么它可能非常代表这个特定的文档,因此它应该得到更高的权重。在这个应用程序中,我们基本上是将用户描述作为文档来处理,我们使用 TF-IDF 统计数据将这些用户描述表示为向量。一旦我们有了 n 维向量表示,K-Means 算法就可以用它来计算它们之间的相似度。
以下脚本利用了 scikit-learn 提供的工具,尤其是TfidfVectorizer和KMeans类:
# Chap02-03/twitter_cluster_users.py
import sys
import json
from argparse import ArgumentParser
from collections import defaultdict
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.cluster import KMeans
def get_parser():
parser = ArgumentParser("Clustering of followers")
parser.add_argument('--filename')
parser.add_argument('--k', type=int)
parser.add_argument('--min-df', type=int, default=2)
parser.add_argument('--max-df', type=float, default=0.8)
parser.add_argument('--max-features', type=int, default=None)
parser.add_argument('--no-idf', dest='use_idf', default=True,
action='store_false')
parser.add_argument('--min-ngram', type=int, default=1)
parser.add_argument('--max-ngram', type=int, default=1)
return parser
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
if args.min_ngram > args.max_ngram:
print("Error: incorrect value for --min-ngram ({}): it can't
be higher than --max-value ({})".format(args.min_ngram,
args.max_ngram))
sys.exit(1)
with open(args.filename) as f:
# load data
users = []
for line in f:
profile = json.loads(line)
users.append(profile['description'])
# create vectorizer
vectorizer = TfidfVectorizer(max_df=args.max_df,
min_df=args.min_df,
max_features=args.max_features,
stop_words='english',
ngram_range=(args.min_ngram,
args.max_ngram),
use_idf=args.use_idf)
# fit data
X = vectorizer.fit_transform(users)
print("Data dimensions: {}".format(X.shape))
# perform clustering
km = KMeans(n_clusters=args.k)
km.fit(X)
clusters = defaultdict(list)
for i, label in enumerate(km.labels_):
clusters[label].append(users[i])
# print 10 user description for this cluster
for label, descriptions in clusters.items():
print('---------- Cluster {}'.format(label+1))
for desc in descriptions[:10]:
print(desc)
由于TfidfVectorizer有多个选项来配置矢量的计算方式,我们将使用 Python 标准库的一部分ArgumentParser从命令行捕获这些选项。get_parser()函数定义参数如下:
--filename:这是我们要分析的 JSONL 文件名的路径--k:这是集群数--min-df:这是一个特征的最小文档频率(默认为2)--max-df:这是一个功能的最大文档频率(默认为0.8)--max-features:这是最大特征数(默认为None)--no-idf:这标志着我们是否希望仅使用 TF 来关闭 IDF 权重(默认情况下使用 IDF)--min-ngram:这是要抽取的 n 克的下边界(默认为1)--max-ngram:这是要抽取的 n 克的上边界(默认为1)
例如,我们可以使用以下命令运行脚本:
$ python twitter_cluster_users.py \
--filename users/marcobonzanini/followers.jsonl \
--k 5 \
--max-features 200 \
--max-ngram 3
唯一必须的参数是--filename指定要分析的配置文件的文件名,以及--k指定集群的数量。所有其他参数都是可选的,并定义TfidfVectorizer将如何为KMeans创建向量。
我们可以通过TfidfVectorizer来限制提取的特征数量,或者明确使用--max-features或者使用--min-df和--max-df指定特征的文档频率的期望范围。默认情况下,最小文档频率设置为2,这意味着少于两个文档中出现的特征将被忽略。另一方面,一个特征的最大文档频率被设置为0.8,这意味着出现在 80%以上文档中的特征将被忽略。根据数据集的大小,我们可以决定这些值是保守还是保守。基于特征的文档频率排除特征的目的是避免对不代表任务的特征进行计算。此外,用--max-features限制特征的总数也是为了在输入更小时加快计算速度。指定此选项后,将选择最常用的功能(取决于它们的文档频率)。
与其他参数不同的是,--no-idf选项不是用来指定特定的值,而是用来关闭 IDF 的使用(意味着在计算特征权重时只考虑 TF)。参数解析器允许我们通过提供一个变量名作为目标(dest=use_idf)来指定这个参数的行为,以及当给出参数时要执行的操作:store_false,而如果没有提供参数,变量默认为True。IDF 通常用于缩小文档集合中非常常见的术语的权重,因此没有任何特殊的区分能力。虽然在许多应用程序中,在您的加权函数中采用 IDF 非常合理,但在探索性数据分析步骤中,观察其效果仍然很重要,因此关闭它的选项只是我们盒子中的一个额外工具。
最后两个论点允许我们超越单个单词,使用 n-grams 作为特征。一般来说,n-gram 是 n 项的连续序列。在我们的应用程序中,我们有一段被标记为一系列单词的文本。单个单词也称为单字(n = 1 的 n-克)。其他常用的 n-grams 是二元模型(n = 2)和三元模型(n = 3),而根据应用可以使用更大的尺寸。比如给定一句快棕狐狸跳过懒狗,它的二元模型定义如下:快棕、快棕、棕狐狸、狐狸跳过等等。
使用 n-grams 而不仅仅是 unigrams 的好处是可以捕获短语。例如,考虑以下虚构的句子:
- 他是科学家,但他不喜欢数据
- 他是一名数据科学家
仅通过使用 unigrams,术语数据和科学家将在两行中被捕获,尽管它们以不同的方式被使用,而使用 bigrams,我们可以捕获短语数据科学家,与这两个单词相比,这提供了不同的含义。默认情况下,n-grams 的上下边界被设置为1,这意味着如果没有不同的指定,我们只使用 unigrams。
总结一下TfidfVectorizer是如何配置的,我们会注意到它还使用了stop_words参数,该参数被定义为从英语词汇中获取停止词。这是一个常见英语单词的列表,如和和,它们本身没有特定的含义,可能在几乎每篇文章中都有使用。虽然在普通英语中,停止词可能已经被max_df选项过滤掉了(因为它们的文档频率接近 100%),但推特上的用户通常不会使用流利的普通英语,而只是一个代表他们兴趣的关键词列表,以克服字符数量的限制。TfidfVectorizer还允许stop_words属性获取自定义单词列表,以防我们想要对其进行个性化设置。
如前所述运行代码将会输出一些有趣的结果。为简洁起见,输出部分再现,如图图 3.2* :

图 3.2:部分输出
为了简洁和突出 K-Means 算法的有趣行为,输出被缩短了。
在第一组追随者中,有一群学者和博士生,他们的工作主要是文本分析和信息检索。例如,在两个简档描述中找到短语PhD candidate和Information Retrieval是用户相似性的有力证据。
第二组由讲西班牙语的用户组成。K-Means 和TfidfVectorizer都没有任何关于多种语言的明确知识。唯一的语言学方面是对常见的英语单词使用停止词删除。K-Means 是否足够聪明,能够识别一种语言,并使用这些信息作为证据将这些用户分组在一起?请记住,我们正在构建的向量是基于单词包表示(准确地说,是 n 克包),因此相似性只是基于单词和 n 克的重叠。不是说西班牙语的人,我们可以在不同的个人资料中发现一些常见的单词(例如,en、y和de)。这些关键词在追随者(主要是说英语的人)的集合中可能相当罕见,因此它们的重要性反映在高 IDF 值中。它们也碰巧是西班牙语的停止词,但是由于停止词列表是为英语明确设计的,所以它们被保留为向量特征。除了语言之外,这些配置文件之间的联系可能相当松散。第一个和第三个提到了一些与计算机科学相关的术语,第二个提到了语言、交流和新媒体。遇火不发推。与其他配置文件的链接相当模糊,但这仍然是一个有趣的结果。
第三和第四个集群,就像第一个集群一样,是不言自明的,并且非常一致:集群 3 主要由 Python 开发人员组成,而集群 4 由数据科学家组成。
由于 K-Means 的初始化具有随机成分,用完全相同的参数重新运行代码并不能保证总是获得相同的结果。鼓励感兴趣的读者使用不同的脚本参数值(例如,或多或少的特性,只是单一图形对较长的 n 图形,文档频率范围窄或宽,等等),并观察这些如何影响算法的行为。
在我们开始讨论 K-Means 的时候,我们提到了聚类数 K 是如何和数据一起作为这个算法的主要输入的。找到给定数据集的最优 K 本身就是一个研究问题,它不同于如何执行实际的聚类。有多种方法可以用来达到这个目的。
最简单的方法是经验法则,包括将 K 设置为 n/2 比值的平方根,其中 n 是数据集中的对象数量。不同的方法涉及一些集群内一致性的度量,例如肘形方法或剪影。
挖掘对话
在重点介绍了用户档案以及它们是如何通过追随者/朋友关系明确联系在一起的之后,在本节中,我们将分析一种不同类型的交互——对话。在推特上,用户可以发布一条推文来回复特定的内容。当两个或更多的用户跟进这个过程时,一个适当的对话就可以展开了。
图 3.3 显示了一个表示为网络的对话。网络的每个节点都是一条推文(由其标识唯一标识),每个边代表对关系的回复。
这种关系有一个明确的方向,因为它只能走一条路(父子关系)。例如,如果推文 2 是对推文 1 的回复,我们就看不到推文 1 是对推文 2 的回复。这种关系的基数始终是一,意味着一条给定的推文可以是对一条且只有一条推文的回复(但我们可以有多条推文回复给定的推文,使这种关系成为一对多)。此外,不允许循环(例如,关系 1 到 2、2 到 3、3 到 1 的顺序是不可能的)。由于这些原因,我们所表示的图属于有向无环图或 DAG(https://en.wikipedia.org/wiki/Directed_acyclic_graph)的范畴。更准确地说,我们在这里表示的图形类型通常被称为有向树:

图 3.3:表示为网络的对话示例
虽然图中没有明确描述,但我们注意到这种关系是有时间限制的:只有在推文已经发布的情况下,你才能回复推文。
通过将推文和回复描述为图,我们可以利用图的属性和图论中的算法来挖掘对话。
例如,节点的度是图中给定节点的子节点数。从对话的角度来看,节点的程度对应于节点收到的回复数量。在图 3.3 的例子中,节点 1 有两个直接连接的节点,所以它的度是 2。节点 2、3 和 5 都有一个单独连接的节点,因此它们的度数为 1。最后,节点 4 和 6 没有连接到它们的节点,因此它们的度为零。节点 4 和 6 也被称为树的叶子,代表对话中的死胡同。另一方面,对话的开始,节点 1,也被称为树的根。
图论中的一个基本概念是路径的概念,它是连接一系列不同顶点的一系列边。给定一系列推文的图形表示,找到一条路径相当于跟踪一个对话。一个有趣的问题是找到最大长度的路径,也称为最长路径。
为了将这些图形概念应用到我们的推特数据中,我们可以使用第 1 章、社交媒体、社交数据和 Python 中已经介绍过的 NetworkX 库,因为它提供了高效的图形结构计算和简单的界面。下面的脚本将推文的 JSONL 文件作为输入,并生成一个有向图,正如我们刚刚讨论的:
# Chap02-03/twitter_conversation.py
import sys
import json
from operator import itemgetter
import networkx as nx
def usage():
print("Usage:")
print("python {} <filename>".format(sys.argv[0]))
if __name__ == '__main__':
if len(sys.argv) != 2:
usage()
sys.exit(1)
fname = sys.argv[1]
with open(fname) as f:
graph = nx.DiGraph()
for line in f:
tweet = json.loads(line)
if 'id' in tweet:
graph.add_node(tweet['id'],
tweet=tweet['text'],
author=tweet['user']['screen_name'],
created_at=tweet['created_at'])
if tweet['in_reply_to_status_id']:
reply_to = tweet['in_reply_to_status_id']
if reply_to in graph \
and tweet['user']['screen_name'] !=
graph.node[reply_to]['author']:
graph.add_edge(tweet['in_reply_to_status_id'],
tweet['id'])
# Print some basic stats
print(nx.info(graph))
# Find most replied tweet
sorted_replied = sorted(graph.degree_iter(),
key=itemgetter(1),
reverse=True)
most_replied_id, replies = sorted_replied[0]
print("Most replied tweet ({} replies):".format(replies))
print(graph.node[most_replied_id])
# Find longest conversation
print("Longest discussion:")
longest_path = nx.dag_longest_path(graph)
for tweet_id in longest_path:
node = graph.node[tweet_id]
print("{} (by {} at {})".format(node['tweet'],
node['author'],
node['created_at']))
该脚本可以按如下方式运行:
$ python twitter_conversation.py <filename>
在这个脚本中,我们将使用别名nx导入 NetworkX,如第 1 章、社交媒体、社交数据和 Python 中所述。我们将初始化一个空的有向图,它是用nx.DiGraph类实现的。输入 JSONL 文件的每一行代表一条推文,所以我们将循环遍历这个文件,为每条推文添加一个节点。用于此目的的add_node()函数使用一个强制参数,即节点标识,后跟可选关键字参数的数量,为节点提供附加属性。在我们的例子中,我们将包括作者的屏幕名称、推文全文和创建时间戳。
节点创建后,我们会检查该推文是否是在回复另一条推文时发布的;如果是这种情况,我们可能希望在节点之间添加一条边。在添加边之前,我们将首先确认被回复的推文已经存在于图中。这是为了避免包含对不在我们数据集中的推文的回复(例如,在我们开始使用 Streaming API 收集数据之前发布的推文)。如果我们试图连接的节点在图中不存在,add_edge()函数可以添加它,但是除了 ID 之外,我们没有任何关于它的属性的信息。我们还会检查推文的作者和回复的作者是否是不同的人。这是因为推特用户界面会自动将属于对话一部分的推文分组,但有些用户,例如,正在评论一个直播事件,或者只是写了一个超过 140 个字符的评论,他们只是回复自己的推文,以轻松创建一个多推文线程。虽然这可能是一个很好的特性,但它并不是真正的对话(事实上,它恰恰相反),所以我们决定忽略这种线索。如果你对寻找独白感兴趣,你可以修改代码来执行相反的操作:只有当推文的作者和回复的作者是同一个人时,才添加边。
建图后,我们先打印一些基本的统计数据,由nx.info()功能提供。然后我们会识别出回复次数最多的推文(即度最高的节点)和对话时间最长的推文(即路径最长)。
由于degree_iter()函数返回一个(节点,度)元组序列的迭代器,我们将使用itemgetter()按照度以相反的顺序对其进行排序。排序列表的第一项是回复最多的推文。
寻找最长对话的解决方案在dag_longest_path()函数中实现,该函数返回节点标识列表。为了重建对话,我们只需要遍历这些标识并打印出数据。
使用在第 2 章、# miningtwetter-Hashtags、主题和时间序列中创建的stream__RWC2015__RWCFinal_Rugby.jsonl文件运行的脚本产生以下输出(为简洁起见,省略了最长对话的输出):
Name:
Type: DiGraph
Number of nodes: 198004
Number of edges: 1440
Average in degree: 0.0073
Average out degree: 0.0073
Most replied tweet (15 replies):
{'author': 'AllBlacks', 'tweet': 'Get ready for live scoring here, starting shortly. You ready #AllBlacks fans? #NZLvAUS #TeamAllBlacks #RWC2015', 'created_at': 'Sat Oct 31 15:49:22 +0000 2015'}
Longest discussion:
# ...
如你所见,推文的数量远高于边的数量。这是由于很多推文与其他推文没有关联造成的,也就是只有一小部分推文是回复其他推文发送的。当然,这可能会有所不同,具体取决于数据以及我们是否决定还包括自我回复和旧推文(即已回复但不在我们数据集中的推文)。
在地图上绘制推文
本节讨论了使用地图可视化表示推文。数据可视化是一种很好的方式,可以提供易于理解的数据概览,因为图片可以提供数据集特定特征的摘要。
在一小部分推文中,我们可以以地理坐标的形式找到用户设备的地理定位细节。虽然许多用户在他们的手机上禁用了这一功能,但在数据挖掘方面仍然有一个有趣的机会来了解推文的地理分布。
本节介绍 GeoJSON,一种地理数据结构的通用数据格式,以及为我们的推文构建交互式地图的过程。
从推文到 GeoJSON
GeoJSON(http://geojson.org)是一种基于 JSON 的地理数据结构编码格式。GeoJSON 对象可以表示几何图形、要素或要素集合。几何图形只包含关于形状的信息;它的例子包括点、线串、多边形和更复杂的形状。特征扩展了这一概念,因为它们包含几何图形和附加(自定义)属性。最后,功能集合只是功能列表。
一个 GeoJSON 数据结构总是一个 JSON 对象。下面的代码片段显示了 GeoJSON 的一个示例,它表示一个具有两个不同点的集合,每个点用于固定一个特定的城市:
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
-0.12
51.5
]
},
"properties": {
"name": "London"
}
},
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
-74,
40.71
]
},
"properties": {
"name": "New York City"
}
}
]
}
在这个 GeoJSON 对象中,第一个键是被表示对象的type。此字段为必填字段,其值必须为以下值之一:
Point:这个用来表示单个位置MultiPoint:这代表多个位置LineString:指定一串经过两个或多个位置的线MultiLineString:这相当于多串线Polygon:这代表一串封闭的线,也就是第一个和最后一个位置是一样的GeometryCollection:这是不同几何图形的列表Feature:这是带有额外自定义属性的前述项目之一(不包括GeometryCollection)FeatureCollection:用于表示特征列表
假设前面例子中的type具有FeatureCollection值,我们将期望features字段是一个对象列表(每个对象都是一个Feature)。
示例中显示的两个要素是简单的点,因此在这两种情况下,coordinates字段都是两个元素的数组:经度和纬度。该字段还允许第三个元素存在,表示高度(省略时,高度假定为零)。
一旦我们理解了我们需要的结构,我们就可以从推文数据集中提取地理信息。以下脚本twitter_make_geojson.py读取 JSON Lines 格式的推文数据集,并生成地理信息相关的所有推文的 GeoJSON 文件:
# Chap02-03/twitter_make_geojson.py
import json
from argparse import ArgumentParser
def get_parser():
parser = ArgumentParser()
parser.add_argument('--tweets')
parser.add_argument('--geojson')
return parser
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
# Read tweet collection and build geo data structure
with open(args.tweets, 'r') as f:
geo_data = {
"type": "FeatureCollection",
"features": []
}
for line in f:
tweet = json.loads(line)
try:
if tweet['coordinates']:
geo_json_feature = {
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": tweet['coordinates']['coordinates']
},
"properties": {
"text": tweet['text'],
"created_at": tweet['created_at']
}
}
geo_data['features'].append(geo_json_feature)
except KeyError:
# Skip if json doc is not a tweet (errors, etc.)
continue
# Save geo data
with open(args.geojson, 'w') as fout:
fout.write(json.dumps(geo_data, indent=4))
脚本利用ArgumentParser读取命令行参数。它可以按如下方式运行:
$ python twitter_make_geojson.py \
--tweets stream__RWC2015__RWCFinal_Rugby.jsonl \
--geojson rwc2015_final.geo.json
在本例中,我们将读取第 2 章、# miningtwetter-Hashtags、Topics 和时间序列中描述的stream__RWC2015__RWCFinal_Rugby.jsonl文件(使用--tweets参数),而输出将存储在rwc2015_final.geo.json中,并传递给--geojson参数。
地理数据结构是FeatureCollection,在脚本中由geo_data字典表示。在遍历包含推文数据集的文件时,脚本会加载每个推文,并将其作为单个要素追加到要素集合中。对于每个要素,我们将把几何图形存储为具有相关坐标的点,以及一些附加属性,例如推文的文本和创建时间。如果数据集中的任何 JSON 文档不是正确的推文(例如,推特应用编程接口返回的错误),缺少所需的属性将触发KeyError,被try/except块捕获并静音。
脚本的最后一部分只是将地理信息转储到给定的 JSON 文件中,以便在自定义地图中使用。
用树叶绘制简易地图
本节介绍叶(https://folium.readthedocs.io/en/latest/),这是一个 Python 库,允许以最小的工作量生成交互式地图。
leaf 在 Python 的数据处理能力和 JavaScript 提供的用户界面机会之间架起了一座桥梁。具体来说,它允许 Python 开发人员将 GeoJSON 和 TopoJSON 数据与传单库集成,后者是构建交互式地图的功能最丰富的前端库之一。
使用像 leaf 这样的库的优势在于,它可以无缝地处理 Python 数据结构和 JavaScript、HTML 和 CSS 组件之间的转换。从 Python 开发人员的角度来看,不需要前端技术的知识,因为我们可以留在 Python 领域,只需将库的输出转储到一个 HTML 文件中(或者直接从 Jupyter Notebook 中将其可视化)。
该库可以使用pip安装在我们的虚拟环境中,如下所示:
$ pip install folium
注
本节中显示的示例是基于 0.2 版本的 leaf 库,因此总是值得仔细检查文档,因为作为一个新项目,对界面的一些重大更改仍然是可能的。
以下示例显示了一个以欧洲为中心的简单地图,其中显示了两个标记,一个在伦敦的顶部,一个在巴黎的顶部:
# Chap02-03/twitter_map_example.py
from argparse import ArgumentParser
import folium
def get_parser():
parser = ArgumentParser()
parser.add_argument('--map')
return parser
def make_map(map_file):
# Custom map
sample_map = folium.Map(location=[50, 5],
zoom_start=5)
# Marker for London
london_marker = folium.Marker([51.5, -0.12],
popup='London')
london_marker.add_to(sample_map)
# Marker for Paris
paris_marker = folium.Marker([48.85, 2.35],
popup='Paris')
paris_marker.add_to(sample_map)
# Save to HTML file
sample_map.save(map_file)
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
make_map(args.map)
该脚本使用一个参数ArgumentParser来选择输出文件,并且可以运行,例如,使用以下命令:
$ python twitter_map_example.py --map example_map.html
一旦我们运行了脚本,example_map.html文件将包含可以在浏览器中可视化的输出。图 3.4 显示了这个脚本的输出:

图 3.4:用树叶构建的地图示例
脚本的核心逻辑由make_map()函数实现。首先,我们将创建一个folium.Map对象,以特定的位置为中心(一组[latitude, longitude]坐标和特定的缩放比例)。zoom_start属性接受一个整数——使用一个较低的数字意味着我们正在缩小,看着更大的画面;而更大的数字相当于放大。
创建地图后,我们可以在地图上附加自定义标记。示例脚本显示了如何创建带有特定位置和气球状弹出窗口的标记。在图 3.4 中,位于巴黎的标记已被点击,并显示弹出窗口。
在看到了一个如何使用 leaf 的基本示例后,我们可以将这些概念应用到我们的推文数据集。以下示例显示了如何从 GeoJSON 文件加载标记列表,类似于我们之前构建的列表:
# Chap02-03/twitter_map_basic.py
from argparse import ArgumentParser
import folium
def get_parser():
parser = ArgumentParser()
parser.add_argument('--geojson')
parser.add_argument('--map')
return parser
def make_map(geojson_file, map_file):
tweet_map = folium.Map(location=[50, 5],
zoom_start=5)
geojson_layer = folium.GeoJson(open(geojson_file),
name='geojson')
geojson_layer.add_to(tweet_map)
tweet_map.save(map_file)
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
make_map(args.geojson, args.map)
脚本照常使用ArgumentParser,可以如下运行:
$ python twitter_map_basic.py \
--geojson rwc2015_final.geo.json \
--map rwc2015_final_tweets.html
--geojson参数用于传递之前创建的文件,该文件包含 GeoJSON 信息。--map参数用于提供输出文件的名称,在这种情况下,我们可以在图 3.5 中观察到的rwc2015_final_tweets.html HTML 页面。
这个脚本和前一个脚本的主要区别是我们实现make_map()函数的方式。我们从前面完成的地图对象初始化开始。这一次,我们将使用 GeoJSON 文件填充一个图层,而不是一个接一个地添加标记,该图层被添加到地图的顶部。folium.GeoJson对象处理来自 JSON 数据的转换,因此只需很少的工作就可以填充地图:

图 3.5:我们推文的基本地图
和我们之前做的一样,图 3.5 中的地图以欧洲为中心。我们可以观察到最拥挤的地区是英国(活动在伦敦举行),但以最初的缩放比例,很难更好地理解推文是如何在当地分发的。
解决这个问题的一种方法是放大地图,因为地图是交互式的。树叶和传单也提供了另一种选择,在密集的区域分组标记。
以下脚本利用了专用于此目的的MarkerCluster对象:
# Chap02-03/twitter_map_clustered.py
from argparse import ArgumentParser
import folium
def get_parser():
parser = ArgumentParser()
parser.add_argument('--geojson')
parser.add_argument('--map')
return parser
def make_map(geojson_file, map_file):
tweet_map = folium.Map(location=[50, 5],
zoom_start=5)
marker_cluster = folium.MarkerCluster().add_to(tweet_map)
geojson_layer = folium.GeoJson(open(geojson_file),
name='geojson')
geojson_layer.add_to(marker_cluster)
tweet_map.save(map_file)
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
make_map(args.geojson, args.map)
该脚本像往常一样使用ArgumentParser,例如,可以使用以下命令运行:
$ python twitter_map_clustered.py \
--geojson rwc2015_final.geo.json \
--map rwc2015_final_tweets_clustered.html
参数的含义与前面的脚本相同。这次输出存储在rwc2015_final_tweets_clustered.html中,可以在浏览器中打开。
图 3.6 显示了我们放大突出伦敦区域后这张地图的一部分。从这里我们可以观察到一些标记是如何组合成一个簇的,显示为一个圆形对象,它报告了其中的元素数量。
当将指针移动到其中一个聚类的顶部时,地图将突出显示该聚类所代表的区域,因此用户甚至可以在放大之前更好地了解局部密度。在图 3.6 中,我们将突出显示位于伦敦西南部的一个包含 65 个项目的集群(该事件的地点 Twickenham 体育场所在的区域):

图 3.6:带有聚类标记的叶图示例
可以结合 leaf 的不同特性,打造更丰富的用户体验。例如,我们可以混合使用 GeoJSON、集群和弹出窗口,让用户点击单个标记并看到准确的推文。
使用集群和弹出窗口的地图创建示例如下所示:
def make_map(geojson_file, map_file):
tweet_map = folium.Map(location=[50, 5],
zoom_start=5)
marker_cluster = folium.MarkerCluster().add_to(tweet_map)
geodata = json.load(open(geojson_file))
for tweet in geodata['features']:
tweet['geometry']['coordinates'].reverse()
marker = folium.Marker(tweet['geometry']['coordinates'],
popup=tweet['properties']['text'])
marker.add_to(marker_cluster)
tweet_map.save(map_file)
前面定义的make_map()函数可以代替前面脚本中的函数,因为界面是一样的。
需要注意的是,这里创建的Marker对象期望坐标为[latitude, longitude],而 GeoJSON 格式使用[longitude, latitude]。因此,坐标阵列在用于定义marker之前被反转。
图 3.7 展示了地图的一个示例,放大后突出显示了体育场。其中一个标记已被点击,因此相关的推文会显示在弹出窗口中:

图 3.7:上一张地图(图 3.6 )已经放大,聚类作为单个标记可用
当使用大量标记时,重要的是要考虑到用户界面方面可能会出现一些性能问题。特别是,当组合标记和弹出窗口时,在几百个项目之后,地图可能会特别慢,或者浏览器可能会拒绝加载它。如果是这种情况,建议使用较小的数据集进行实验,或者减少同一地图中使用的各种要素。
总结
本章继续讨论从推特上挖掘数据。在第 2 章、# miningtwetter-Hashtags、主题和时间序列中重点介绍了文本和频率之后,本章重点分析了用户连接和交互。我们讨论了如何提取关于显式联系(即追随者和朋友)的信息,以及如何比较用户之间的影响力和参与度。
关于用户社区的讨论已经导致了针对群组用户的无监督学习方法的引入,该方法根据他们的简档描述,使用聚类算法。
我们对与直播事件相关的数据应用了网络分析技术,以便从推文中挖掘对话,了解如何识别回复数量最多的推文,以及如何确定最长的对话。
最后,我们还展示了如何通过将推文绘制到地图上来理解推文的地理分布。通过使用 Python 库 leaf,我们展示了如何用最少的努力实现地理数据的美丽可视化。
在下一章,我们将把焦点转移到一个不同的社交网络上,可能是现在的*社交网络:脸书。**
四、脸书上的帖子、页面和用户交互
在一本关于社交媒体挖掘的书中,读者可能期待有一章是关于脸书的。脸书成立于 2004 年,最初仅限于哈佛学生,如今它已经是一家价值数十亿美元的公司,拥有近 15 亿月活跃用户。它的流行使它成为数据挖掘的一个非常有趣的游乐场。
在本章中,我们将讨论以下主题:
- 创建一个与脸书平台互动的应用程序
- 与脸书图形应用编程接口交互
- 从经过身份验证的用户中挖掘帖子
- 挖掘脸书页面、可视化帖子和衡量参与度
- 从一组帖子构建单词云
脸书图形应用编程接口
脸书图形应用编程接口是脸书平台的核心,也是支持第三方与脸书集成的主要组件之一。顾名思义,它提供了一个一致的类似图形的数据视图,表示对象和它们之间的联系。不同的平台组件允许开发人员访问脸书数据,并将脸书功能集成到第三方应用程序中。
就数据挖掘机会而言,2014 年随着 API 2.0 版本的发布,出现了重大转变。数据分析的主要兴趣对象之一是社交图,即用户之间的联系(友谊)列表。由于 2.0 版本的 Graph API,想要访问这些信息的应用程序必须明确请求user_friends权限,但 API 只会返回同样是给定应用程序用户的好友列表。
实际上,这一选择改变了过去数据分析的金矿。本节讨论用 Python 创建脸书应用程序以及与脸书图形应用编程接口交互的基础。
注册您的应用
通过注册的应用程序可以访问脸书应用编程接口。开发人员必须注册他们的应用程序,以便获得使用图形应用编程接口所需的凭据。作为脸书用户,你必须明确注册为开发者才能创建应用程序,你的账户必须通过手机或信用卡进行验证。
在脸书开发者网站(https://developers.facebook.com)上,程序很简单:点击我的应用菜单下的添加新应用链接,会打开对话框,如图图 4.1 所示,我们提供Social Media Mining作为示例应用的显示名称(有 32 个字符的限制):

图 4.1:创建新脸书应用程序的对话框窗口
一旦我们为我们的应用选择了名称和类别,我们就可以点击创建应用标识来确认它的创建。
此时,我们为应用选择的名称在我的应用菜单下可见,点击将打开应用仪表盘,如图图 4.2 :

图 4.2:应用仪表板上的视图
该面板提供访问应用编程接口所需的关键信息,如应用程序标识和应用程序机密。要查看应用程序机密,您需要提供您的脸书密码来确认您的身份。不用说,出于明显的安全原因,这些细节不会与任何人分享。
在 App ID 和 App Secret 之间,还有对 API 版本的引用(图 4.2中的例子是使用 v2.5 )。默认情况下,新应用的应用编程接口版本将是可用的最新版本。在 2014 年的 F8 会议上,脸书宣布他们决定为特定的 API 版本提供至少两年的支持。这是需要记住的一条重要信息,因为当某个特定的应用编程接口版本不再受支持时,如果您不更新它,您的应用程序可能会停止正常运行。
型式
脸书平台版本化
文档中讨论了脸书平台的版本控制策略(https://developers.facebook.com/docs/apps/versions,以及特定版本的日落日期(https://developers.facebook.com/docs/apps/changelog)。
最初,您的应用程序设置为开发模式。此选项仅允许您(作为作者)和从角色菜单中明确指定为开发人员或测试人员的任何用户访问。花一些时间在仪表板上了解基本配置选项及其含义是值得的。
认证和安全
为了访问用户的配置文件信息,以及关于他们与其他对象(例如,页面、地点等)交互的信息,您的应用程序必须获得具有适当权限的访问令牌。
令牌对于用户-应用程序组合是唯一的,用于处理用户授予应用程序的权限。生成访问令牌需要用户交互,这意味着用户必须向脸书确认(通常通过对话窗口)他们正在向应用程序授予所需的权限。
出于测试目的,获取访问令牌的另一种方法是使用图形应用编程接口浏览器(https://developers.facebook.com/tools/explorer),这是一个由脸书开发的工具,为开发人员提供了一个与图形应用编程接口交互的方便界面。图 4.3 展示了图形应用编程接口浏览器的使用,在我们从可用应用程序列表中选择我们的应用程序后,我们可以从获取令牌菜单中单击获取用户访问令牌:

图 4.3:从图形应用编程接口资源管理器生成访问令牌
这个动作会打开一个对话框窗口,就像图 4.4 中的那个,我们可以用它来指定我们想要在访问令牌中包含什么样的权限。确认应用程序的权限将为访问令牌生成字母数字字符串,该字符串从创建时起两小时内有效。单击令牌旁边的小信息图标将显示该信息。

图 4.4:从图形应用编程接口浏览器中选择访问令牌的权限
从图 4.4 我们可以看到,一个脸书应用的权限是特别细粒度的。这样,安装您的应用程序的用户将完全了解他们想要与您的应用程序共享的数据类型。
对于以下示例,我们将使用诸如用户全名、位置和帖子等字段。要让代码正确检索这些信息,我们需要勾选相应的权限(例如,user_location、user_posts等)。
注
用户第一次访问应用程序时,脸书会显示一个对话框来总结权限列表。用户将有机会查看应用程序请求的权限列表。
使用 Python 访问脸书图应用编程接口
一旦定义了应用细节,我们就可以通过 Python 以编程方式访问脸书图形应用编程接口。
脸书没有为 Python 提供官方客户端。使用请求库实现我们自己的客户端可能是一个有趣的练习,以便理解应用编程接口的特性,但是幸运的是,已经有一些选项可以简化这个过程。
对于我们的例子,我们将使用 facebook-sdk ,也是基于请求库,它提供了一个易于使用的界面来从网络服务中获取数据。在撰写本文时,PyPI 上可用的库的最新版本是 1.0.0,它完全支持 Python 3。以前的版本在 Python 3 兼容性方面出现了一些问题。我们可以从虚拟环境中使用pip安装库,如下所示:
$ pip install facebook-sdk
按照上一节描述的步骤获得临时令牌后,我们可以立即测试该库。
首先,就像我们在第 2 章、# miningtwetter-Hashtags、Topics 和 Time Series 以及第 3 章、Twitter 上的用户、关注者和社区中配置我们对 Twitter 的访问一样,让我们将令牌保存在一个将由脚本读取的环境变量中。在提示符下,使用以下命令:
$ export FACEBOOK_TEMP_TOKEN="your-token"
以下脚本facebook_my_profile.py连接到图形应用编程接口,并查询经过身份验证的用户的配置文件:
# Chap04/facebook_my_profile.py
import os
import json
import facebook
if __name__ == '__main__':
token = os.environ.get('FACEBOOK_TEMP_TOKEN')
graph = facebook.GraphAPI(token)
profile = graph.get_object('me', fields='name,location')
print(json.dumps(profile, indent=4))
该脚本不接受任何参数,因此只需使用以下命令即可运行:
$ python facebook_my_profile.py
输出是由应用编程接口返回的 JSON 对象的转储:
{
"name": "Marco Bonzanini",
"location": {
"name": "London, United Kingdom",
"id": "106078429431815"
},
"id": "10207505820417553"
}
get_object()函数将脸书图中特定对象的 ID 或名称作为第一个参数,并返回所需的信息。在我们的示例中,me标识只是经过身份验证的用户的别名。在不指定第二个参数和字段的情况下,应用编程接口将简单地返回对象的标识和名称。在这种情况下,我们明确要求将name和location包含在输出中。如您所见,location不仅仅是一个字符串,而是一个有自己字段的复杂对象(因为没有指定其他内容,所以该位置包含的字段只是id和name)。
从GraphAPI类获取数据的接口非常简单。该类还提供了发布和更新脸书数据的工具,允许应用程序与脸书平台交互(例如,通过在经过身份验证的用户的墙上张贴一些内容)。
我们关注的主要方法如下:
get_object(id, **args):这将检索一个给定了id的对象,并接受可选的关键字参数get_objects(ids, **args):这将检索一个给定了ids列表的对象列表,并且还接受可选的关键字参数get_connections(id, connection_name, **args):这将检索一个对象列表,这些对象以connection_name关系连接到由id标识的对象,并且还带有可选的关键字参数request(path, args=None, post_args=None, files=None, method=None):这是一种通用方法,用于实现对 API 的特定请求,使用 API 文档中定义的path,可选参数定义如何执行 API 调用
facebook_my_profile.py脚本中的示例使用了get_object()方法下载当前用户的配置文件。在这种情况下,给出了一个可选的关键字参数fields,来指定我们想要从 API 中检索的属性。文档中规定了用户配置文件的完整属性列表。
遵循 API 规范,我们可以看到如何个性化字段的字符串,以便获得给定配置文件的更多信息。特别是,我们还可以执行嵌套请求,并包含连接到给定概要文件的对象的详细信息。在我们的示例中,我们检索了位置,它是类型为Page(https://developers . Facebook . com/docs/graph-API/reference/Page/)的对象。由于每个页面都附加了一些属性,我们也可以将它们包含在我们的请求中,例如,更改get_object()请求:
profile = graph.get_object("me", fields='name,location{location}')
first_level{second_level}语法允许查询嵌套对象。在这个特殊的例子中,命名可能会令人困惑,因为location是我们正在检索的一级和二级属性的名称。理解这个小难题的解决方案是理解数据类型。第一级location是用户档案的一个属性,它的数据类型是一个脸书页面(有自己的 ID、名称和其他属性)。第二层location是前述脸书页面的一个属性,它是一个实际位置的描述符,由latitude和longitude等属性组成。带有额外二级location的前一个脚本的输出如下:
{
"name": "Marco Bonzanini",
"location": {
"name": "London, United Kingdom",
"id": "106078429431815"
},
"id": "10207505820417553",
"location": {
"id": "106078429431815",
"location": {
"city": "London",
"latitude": 51.516434161634,
"longitude": -0.12961888255995,
"country": "United Kingdom"
}
}
}
注
一个location对象(https://developers . Facebook . com/docs/graph-API/reference/v 2.5/location)默认检索到的属性为city、country、latitude和longitude。
如本节开头所述,随着脸书图形应用编程接口的更新版本,一些数据挖掘机会受到了限制。特别是,只有当所有相关的个人资料都是我们应用程序的用户时,挖掘社交图(即友谊关系)才有可能。以下脚本试图获取经过身份验证的用户的好友列表:
# Chap04/facebook_get_friends.py
import os
import facebook
import json
if __name__ == '__main__':
token = os.environ.get('FACEBOOK_TEMP_TOKEN')
graph = facebook.GraphAPI(token)
user = graph.get_object("me")
friends = graph.get_connections(user["id"], "friends")
print(json.dumps(friends, indent=4))
即使对应用编程接口的调用需要user_friends权限才能授予我们的应用程序,但脚本无论如何都无法检索到许多关于朋友的数据,因为经过身份验证的用户(即me)目前是应用程序的唯一用户。以下是输出示例:
{
"data": [],
"summary": {
"total_count": 266
}
}
如我们所见,我们唯一能检索到的信息是给定用户的好友总数,而好友的数据由空列表表示。如果一些朋友决定使用我们的应用程序,我们将能够通过这次通话检索他们的个人资料。
我们将在本节结束时简要介绍图形应用编程接口强加的速率限制。如文档(https://developers . Facebook . com/docs/graph-API/advanced/限速)中所述,很少遇到限速。限额是按应用程序和每个用户计算的,也就是说,如果应用程序达到每日费率限额,应用程序发出的所有呼叫都将受到限制,而不仅仅是给定用户的呼叫。每日津贴是根据前一天和今天登录的用户数量计算的——这个总和就是基本用户数量。然后,在 60 分钟的窗口内,该应用程序允许每个用户调用 200 次应用编程接口。虽然这对于我们的示例来说已经足够了,但是建议您查看文档,以了解费率限制在您的应用程序中可能产生的影响。
在下一节中,我们将下载认证用户的所有帖子,并开始对这些数据进行一些数据分析。
挖掘你的岗位
在用一个简单的例子介绍了 Python facebook-sdk 之后,我们将开始挖掘数据挖掘的机会。第一个练习是下载我们自己的帖子(也就是通过身份验证的用户发布的帖子)。
facebook_get_my_posts.py脚本连接到图形应用编程接口,并获得由经过身份验证的用户me发布的帖子列表。帖子保存在my_posts.jsonl文件中,使用我们已经在第 2 章、# miningtwetter-Hashtags、主题和时间序列和第 3 章、推特上的用户、关注者和社区中采用的 JSON Lines 格式(文件的每一行都是 JSON 文档):
# Chap04/facebook_get_my_posts.py
import os
import json
import facebook
import requests
if __name__ == '__main__':
token = os.environ.get('FACEBOOK_TEMP_TOKEN')
graph = facebook.GraphAPI(token)
posts = graph.get_connections('me', 'posts')
while True: # keep paginating
try:
with open('my_posts.jsonl', 'a') as f:
for post in posts['data']:
f.write(json.dumps(post)+"\n")
# get next page
posts = requests.get(posts['paging']['next']).json()
except KeyError:
# no more pages, break the loop
break
该脚本不采用任何命令行参数,因此只需使用以下命令即可运行:
$ python facebook_get_my_posts.py
这个脚本提供了一个有趣的分页示例,由于帖子列表太长,无法通过一个 API 调用来收集,所以脸书提供了分页信息。
使用get_connections()方法执行的初始 API 调用返回帖子的第一页(存储在posts['data']中),以及在不同页面之间循环所需的详细信息,可在posts['paging']中获得。由于分页功能没有在 Python facebook-sdk 库中实现,我们需要通过直接使用请求库来后退。幸运的是,图形应用编程接口提供的响应包含了我们需要请求的确切网址,以便获得下一页的帖子。事实上,如果我们检查posts['paging']['next']变量的值,我们将看到代表要查询的精确网址的字符串,包括访问令牌、应用编程接口版本号和所有必需的细节。
分页是在while True循环中执行的,当我们到达最后一页时,这个循环被KeyError异常中断。由于最后一页将不包含对posts['paging']['next']的引用,试图访问字典的这个键将引发异常,唯一的目的是打破循环。
一旦脚本执行完毕,我们就可以检查my_posts.jsonl文件的内容了。文件的每一行都是一个 JSON 文档,它包含一个唯一的标识、贴在帖子上的消息文本以及 ISO 8601 格式的创建时间。这是一个 JSON 文档的例子,代表一篇下载的文章:
{
"created_time": "2015-11-04T08:01:21+0000",
"id": "10207505820417553_10207338487234328",
"message": "The slides of my lighting talk at the PyData London
meetup last night\n"
}
就像get_object()函数一样,get_connections()也可以带一个fields参数,以便检索所需对象的更多属性。以下脚本重构了前面的代码,以便为我们的帖子获取更多有趣的属性:
# Chap04/facebook_get_my_posts_more_fields.py
import os
import json
import facebook
import requests
if __name__ == '__main__':
token = os.environ.get('FACEBOOK_TEMP_TOKEN')
graph = facebook.GraphAPI(token)
all_fields = [
'message',
'created_time',
'description',
'caption',
'link',
'place',
'status_type'
]
all_fields = ','.join(all_fields)
posts = graph.get_connections('me', 'posts', fields=all_fields)
while True: # keep paginating
try:
with open('my_posts.jsonl', 'a') as f:
for post in posts['data']:
f.write(json.dumps(post)+"\n")
# get next page
posts = requests.get(posts['paging']['next']).json()
except KeyError:
# no more pages, break the loop
break
我们想要检索的所有字段都在all_fields列表中声明,然后按照图形应用编程接口的要求,该列表被连接成一串逗号分隔的属性名称。然后,该值通过fields关键字参数传递给get_connections()方法。
下一节讨论关于帖子结构的更多细节。
柱子的结构
帖子是一个复杂的对象,因为它本质上可以是用户决定发布的任何内容。下表总结了帖子的有趣属性,并简要描述了它们的含义:
| **属性名称** | **描述** | | `id` | 表示唯一标识符的字符串 | | `application` | 带有用于发布此帖子的应用程序信息的`App`对象 | | `status_type` | 代表帖子类型的字符串(例如`added_photos`或`shared_story`) | | `message` | 表示帖子状态消息的字符串 | | `created_time` | 以 ISO 8601 格式发布帖子的日期字符串 | | `updated_time` | 以 ISO 8601 格式显示上次修改日期的字符串 | | `message_tags` | 邮件中标记的配置文件列表 | | `from` | 发布消息的配置文件 | | `to` | 帖子中提到或针对的个人资料列表 | | `place` | 贴在帖子上的位置信息 | | `privacy` | 对象的隐私设置 | | `story_tags` | 与`message_tags`相同 | | `with_tags` | 标记为*的个人资料列表与帖子作者*在一起 | | `properties` | 任何附加视频的属性列表(例如,视频的长度) |一个Post对象的属性甚至比前一个表中的属性更多。官方文档中给出了完整的属性列表(https://developers . Facebook . com/docs/graph-API/reference/v 2.5/post),其中这个对象的复杂性更加清晰。
时频分析
下载完我们所有的帖子后,我们会根据不同帖子的创建时间进行第一次分析。该分析的目的是突出用户的行为,例如用户在一天中的什么时间在脸书发布最多的内容。
facebook_post_time_stats.py脚本使用ArgumentParser获得命令行输入,即带有帖子的.jsonl文件:
# Chap04/facebook_post_time_stats.py
import json
from argparse import ArgumentParser
import dateutil.parser
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from datetime import datetime
def get_parser():
parser = ArgumentParser()
parser.add_argument('--file',
'-f',
required=True,
help='The .jsonl file with all the posts')
return parser
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
with open(args.file) as f:
posts = []
for line in f:
post = json.loads(line)
created_time = dateutil.parser.parse(post['created_time'])
posts.append(created_time.strftime('%H:%M:%S'))
ones = np.ones(len(posts))
idx = pd.DatetimeIndex(posts)
# the actual series (a series of 1s for the moment)
my_series = pd.Series(ones, index=idx)
# Resampling into 1-hour buckets
per_hour = my_series.resample('1H', how='sum').fillna(0)
# Plotting
fig, ax = plt.subplots()
ax.grid(True)
ax.set_title("Post Frequencies")
width = 0.8
ind = np.arange(len(per_hour))
plt.bar(ind, per_hour)
tick_pos = ind + width / 2
labels = []
for i in range(24):
d = datetime.now().replace(hour=i, minute=0)
labels.append(d.strftime('%H:%M'))
plt.xticks(tick_pos, labels, rotation=90)
plt.savefig('posts_per_hour.png')
该脚本可以从命令行运行,如下所示:
$ python facebook_post_time_stats.py -f my_posts.jsonl
该脚本首先生成一个帖子列表,其中包含每个帖子的创建时间。dateutil.parser.parse()函数有助于将 ISO 8601 日期字符串读入datetime对象,然后使用strftime()函数将其转换为 HH:MM:SS 字符串。
然后,创建时间列表被用来索引熊猫系列,这最初只是一系列熊猫。然后,该系列按小时重新采样,总结帖子。在这一点上,我们有一系列的 24 个项目,一天中的每个小时一个,以及在那个特定的小时内发布的帖子的数量。脚本的最后一部分旨在将系列绘制为简单的条形图,以便可视化一天中帖子的分布。
图 4.5 为曲线图:

图 4.5:帖子的时间频率
如我们所见,出版时间在下午晚些时候和晚上之间达到高峰,但它在白天传播得相当好(在晚上和凌晨记录的频率最少)。需要考虑的一个方面是不考虑位置,即创建时间归一化为协调世界时 ( UTC ),因此不考虑原时区。例如,如果下午 4 点在美国东海岸发布了一篇文章,其创建时间将记录为世界协调时晚上 9 点。这是因为东部标准时间 ( 东部标准时间)相当于 UTC - 05:00,即标准时间(秋冬季,不遵守夏令时)比 UTC 晚 5 小时。
挖掘脸书页面
脸书不仅被想与朋友和亲戚联系的个人使用,也被想与人交往的公司、品牌和组织使用。脸书页面由脸书个人帐户创建和管理,可以通过多种方式使用:
- 共享企业信息(例如,餐馆或网上商店)
- 代表名人(例如,足球运动员或摇滚乐队)
- 与观众联系(例如,作为在线社区的扩展)
与个人账户不同,Pages 可以发布公开可见的帖子。普通用户可以喜欢一个页面,这意味着他们将直接在他们的新闻提要(即他们的个性化脸书主页)上接收由该页面发布的内容更新。
例如,Packt Publishing 的脸书页面位于https://www.facebook.com/PacktPub,它包含了关于 PacktPub 的一般信息,以及与 PacktPub 发布的书籍、电子书和视频教程相关的帖子。
从 API 文档(https://developers . Facebook . com/docs/graph-API/reference/page)中,我们可以看到给定 Page 可用的各种信息。特别是,页面的有趣属性包括以下内容:
id:该页面的数字标识符name:脸书显示的页面名称about:页面的文字描述link:本页的脸书链接website:组织的网页(如果有)general_info:页面一般信息的文本字段likes:喜欢本页面的用户数量
一个Page对象也可以连接到其他对象,文档用与其他对象的边(即连接)来描述所有的可能性。例如:
posts:页面发布的帖子列表photos:本页照片albums:本页发布的相册列表picture:本页简介图片
特定类型的 Pages 还可以显示针对其业务类型定制的附加信息(例如,餐厅可以显示其营业时间,乐队可以显示乐队成员列表,等等)。
以下脚本查询脸书图形应用编程接口来检索关于特定脸书页面的一些基本信息:
# Chap04/facebook_get_page_info.py
import os
import json
import facebook
from argparse import ArgumentParser
def get_parser():
parser = ArgumentParser()
parser.add_argument('--page')
return parser
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
token = os.environ.get('FACEBOOK_TEMP_TOKEN')
fields = [
'id',
'name',
'about',
'likes',
'website',
'link'
]
fields = ','.join(fields)
graph = facebook.GraphAPI(token)
page = graph.get_object(args.page, fields=fields)
print(json.dumps(page, indent=4))
该脚本使用ArgumentParser实例从命令行获取页面名称(或页面标识),例如:
$ python facebook_get_page_info.py --page PacktPub
输出如下:
{
"id": "204603129458",
"website": "http://www.PacktPub.com",
"likes": 6357,
"about": "Packt Publishing provides books, eBooks, video
tutorials, and articles for IT developers, administrators, and
users.",
"name": "Packt Publishing",
"link": "https://www.facebook.com/PacktPub/"
}
我们还可以使用脸书图形应用编程接口浏览器来获得可用字段的概述。
从页面获取帖子
在讨论了如何获取页面的基本信息后,我们将检查下载页面发布的帖子并以允许稍后分析帖子的常用 JSON Lines 格式存储它们的过程。
该过程类似于用于下载由经过身份验证的用户发布的帖子的过程,但是它还包括用于计算用户参与度的信息:
# Chap04/facebook_get_page_posts.py
import os
import json
from argparse import ArgumentParser
import facebook
import requests
def get_parser():
parser = ArgumentParser()
parser.add_argument('--page')
parser.add_argument('--n', default=100, type=int)
return parser
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
token = os.environ.get('FACEBOOK_TEMP_TOKEN')
graph = facebook.GraphAPI(token)
all_fields = [
'id',
'message',
'created_time',
'shares',
'likes.summary(true)',
'comments.summary(true)'
]
all_fields = ','.join(all_fields)
posts = graph.get_connections('PacktPub',
'posts',
fields=all_fields)
downloaded = 0
while True: # keep paginating
if downloaded >= args.n:
break
try:
fname = "posts_{}.jsonl".format(args.page)
with open(fname, 'a') as f:
for post in posts['data']:
downloaded += 1
f.write(json.dumps(post)+"\n")
# get next page
posts = requests.get(posts['paging']['next']).json()
except KeyError:
# no more pages, break the loop
break
该脚本使用ArgumentParser的一个实例从命令行获取页面名称或页面 ID,以及我们想要下载的帖子数量。在示例代码中,帖子数量是可选的(默认为100)。正如我们之前从认证用户那里下载帖子时所做的那样,我们将定义想要包含在结果中的字段列表。特别是,由于我们将使用这些数据来执行一些与用户参与度相关的分析,我们希望结果包含关于帖子被喜欢、分享或评论的次数的信息。这是通过添加shares、likes.summary(true)和comments.summary(true)字段来实现的。对于喜欢和评论,额外的summary(true)属性是必需的,以包括汇总统计,即聚合计数。
下载在while True循环中执行,使用的方法类似于认证用户发布的方法。差异由downloaded计数器给出,该计数器针对每个检索到的帖子递增。这是用来限制我们想要下载的帖子数量的,主要是因为 Pages 经常发布大量的内容。
该脚本可以按如下方式运行:
$ python facebook_get_page_posts.py --page PacktPub --n 500
运行前面的命令将查询脸书图形应用编程接口,并产生具有 500 个帖子的posts_PacktPub.jsonl文件,每行一个。下面的代码展示了一个印刷精美的单个帖子的例子(即.jsonl文件的一行):
{
"id": "post-id",
"created_time": "date in ISO 8601 format",
"message": "Text of the message",
"comments": {
"data": [ /* list of comments */ ],
"paging": {
"cursors": {
"after": "cursor-id",
"before": "cursor-id"
}
},
"summary": {
"can_comment": true,
"order": "ranked",
"total_count": 4
}
},
"likes": {
"data": [ /* list of users */ ],
"paging": {
"cursors": {
"after": "cursor-id",
"before": "cursor-id"
}
},
"summary": {
"can_like": true,
"has_liked": false,
"total_count": 10
}
},
"shares": {
"count": 9
}
}
我们可以看到,帖子是一个复杂的对象,有不同的嵌套信息。衡量用户参与度的字段有shares、likes和comments。shares字段仅报告分享故事的用户总数。由于隐私设置,其他细节不包括在内。当用户分享一条内容时,他们实际上是在创建自己的帖子,所以这条新帖子不应该被他们网络之外的其他用户看到。另一方面,comments和likes字段是连接到帖子本身的对象,因此有更多细节可供它们使用。
对于comments字段,data键包含带有注释相关信息的对象列表。特别是,每个注释如下所示:
{
"created_time": "date in ISO 8601 format",
"from": {
"id": "user-id",
"name": "user-name"
},
"id": "comment-id",
"message": "text of the message"
}
comments对象还包括一个paging字段,用于保存对光标的引用,以防注释数量超过一页。给定原始请求,明确引用summary(true)属性,也包括简短的摘要统计。我们尤其对total_count感兴趣。
likes对象有点类似于comments对象,尽管在这种情况下数据没有那么复杂。具体来说,有一个喜欢这篇文章的用户的用户标识列表。同样,我们也有总喜欢数的汇总统计,因为在请求中指定了summary(true)属性。
数据下载后,我们可以执行不同类型的离线分析。
脸书反应和图形应用编程接口 2.6
这一章起草后不久,脸书推出了一个名为反应的新功能。Reactions 是 Like 按钮的延伸,它允许用户表达他们对特定帖子的感觉,而不仅仅是 Like。用户现在能表达的新感情叫爱,哈哈,哇,伤心,生气(还有喜欢)。图 4.6 向用户展示了新按钮的外观:

图 4.6:脸书反应的可视化
支持脸书反应的图形应用编程接口的第一个版本是 2.6。本章中的示例主要基于 API 的 2.5 版本,因此如果您计划在数据分析中包含此功能,您应该确保指向正确的版本。关于如何访问与 Reactions 相关的数据的信息可以在官方文档中找到(https://developers . Facebook . com/docs/graph-API/reference/post/Reactions)。
从开发人员的角度来看,Reactions 与 Likes 非常相似。如果我们请求关于对应用编程接口的反应的信息,我们为每个帖子返回的文档结构类似于下面的代码:
{
"message": "The content of the post",
"created_time": "creation date in ISO 8601",
"id": "the ID of the post",
"reactions": {
"data": [
{
"id": "some-user-id",
"name": "John Doe",
"type": "WOW"
},
{
"id": "another-user-id",
"name": "Jane Doe",
"type": "LIKE"
},
/* more reactions... */
]
},
"likes": {
"data": /* data about likes as before */
}
}
虽然本章中的示例将点赞和评论作为理解参与度的一种方式,但它们可以很容易地扩展到包括这一新功能,该功能旨在帮助出版商更好地理解他们的用户和朋友如何看待他们的内容。
测量啮合
我们从分析脸书·佩奇发布的帖子开始,讨论了一些与用户参与度相关的问题。图形应用编程接口提供的数据包括以下细节:
- 帖子被分享的次数
- 喜欢这篇文章的用户数量
- 与帖子相关的用户数量
- 帖子上的评论数量
事实上,分享、喜欢和评论都是用户可以对他人发布的内容进行的操作。虽然很难理解具体行为背后的真正原因(例如,如何区分讽刺的喜欢和真实的喜欢?),而对这类行为背后的心理进行深度分析,已经超出了本书的范围,所以为了这些数据挖掘的例子,我们就假设互动越多的帖子越成功。术语成功在这里是双引号,因为我们还没有定义成功的确切含义,所以在这一部分,我们只是计算用户与页面交互的次数。
以下脚本打印了一些关于脸书·佩奇发布的帖子的信息,该帖子的互动次数最多:
# Chap04/facebook_top_posts.py
import json
from argparse import ArgumentParser
def get_parser():
parser = ArgumentParser()
parser.add_argument('--page')
return parser
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
fname = "posts_{}.jsonl".format(args.page)
all_posts = []
with open(fname) as f:
for line in f:
post = json.loads(line)
n_likes = post['likes']['summary']['total_count']
n_comments = post['comments']['summary']['total_count']
try:
n_shares = post['shares']['count']
except KeyError:
n_shares = 0
post['all_interactions'] = n_likes + n_shares + n_comments
all_posts.append(post)
most_liked_all = sorted(all_posts,
key=lambda x: x['all_interactions'],
reverse=True)
most_liked = most_liked_all[0]
message = most_liked.get('message', '-empty-')
created_at = most_liked['created_time']
n_likes = most_liked['likes']['summary']['total_count']
n_comments = most_liked['comments']['summary']['total_count']
print("Post with most interactions:")
print("Message: {}".format(message))
print("Creation time: {}".format(created_at))
print("Likes: {}".format(n_likes))
print("Comments: {}".format(n_comments))
try:
n_shares = most_liked['shares']['count']
print("Shares: {}".format(n_shares))
except KeyError:
pass
print("Total: {}".format(most_liked['all_interactions']))
脚本使用ArgumentParser获取命令行参数,可以如下运行:
$ python facebook_top_posts.py --page PacktPub
当我们遍历帖子时,我们为每个帖子引入一个all_interactions键,计算为喜欢、分享和评论的总和。如果帖子还没有被任何用户分享,那么shares键将不会出现在字典中,因此对post['shares']['count']的访问已经被包括在try/except块中,如果该键不存在,该块默认共享数量为 0。
新的all_interactions键被用作sorted()功能中的排序选项,它返回按反向交互次数排序的帖子列表。
脚本的最后部分只是打印一些信息。输出如下:
Post with most interactions:
Message: It's back! Our $5 sale returns!
Creation time: 2015-12-17T11:51:00+0000
Likes: 10
Comments: 4
Shares: 9
Total: 23
虽然找到互动次数最多的帖子是一个有趣的练习,但它并没有告诉我们太多关于整体情况的信息。
我们的下一步是验证一天中的特定时间是否比其他时间更成功,也就是说,在给定时间发布帖子是否会给我们带来更多的互动。
下面的脚本使用pandas.DataFrame来聚合交互的统计数据,并使用一个小时的时段来绘制结果,就像我们之前对经过身份验证的用户所做的那样:
# Chap04/facebook_top_posts_plot.py
import json
from argparse import ArgumentParser
import numpy as np
import pandas as pd
import dateutil.parser
import matplotlib.pyplot as plt
from datetime import datetime
def get_parser():
parser = ArgumentParser()
parser.add_argument('--page')
return parser
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
fname = "posts_{}.jsonl".format(args.page)
all_posts = []
n_likes = []
n_shares = []
n_comments = []
n_all = []
with open(fname) as f:
for line in f:
post = json.loads(line)
created_time = dateutil.parser.parse(post['created_time'])
n_likes.append(post['likes']['summary']['total_count'])
n_comments.append(post['comments']['summary']['total_count'])
try:
n_shares.append(post['shares']['count'])
except KeyError:
n_shares.append(0)
n_all.append(n_likes[-1] + n_shares[-1] + n_comments[-1])
all_posts.append(created_time.strftime('%H:%M:%S'))
idx = pd.DatetimeIndex(all_posts)
data = {
'likes': n_likes,
'comments': n_comments,
'shares': n_shares,
'all': n_all
}
my_series = pd.DataFrame(data=data, index=idx)
# Resampling into 1-hour buckets
per_hour = my_series.resample('1h', how='sum').fillna(0)
# Plotting
fig, ax = plt.subplots()
ax.grid(True)
ax.set_title("Interaction Frequencies")
width = 0.8
ind = np.arange(len(per_hour['all']))
plt.bar(ind, per_hour['all'])
tick_pos = ind + width / 2
labels = []
for i in range(24):
d = datetime.now().replace(hour=i, minute=0)
labels.append(d.strftime('%H:%M'))
plt.xticks(tick_pos, labels, rotation=90)
plt.savefig('interactions_per_hour.png')
可以使用以下命令运行该脚本:
$ python facebook_top_posts_plot.py --page PacktPub
输出是保存在interactions_per_hour.png文件中的 matplotlib 图形,我们可以在图 4.7 中可视化。
代码在.jsonl文件中循环,建立列表来存储每个帖子的各种统计数据:点赞数、分享数、评论数以及它们的总和。这些列表中的每一个都是数据框中的一列,使用创建时间(只是时间,而不是日期)对其进行索引。使用之前已经应用的重采样技术,一个小时时段内发布的所有帖子将被聚合,并且它们的频率将被求和。出于本练习的目的,只考虑交互的总和,但也可以单独绘制单个统计数据:

图 4.7:交互频率(每小时合计)
该图显示了分别标记为 08:00 和 09:00 的时段中的最高互动数量,这意味着在聚合中,上午 8 点之后和上午 10 点之前发布的帖子获得了最高互动数量。
观察数据集后,我们注意到上午 8 点到 10 点的时间段也是大多数帖子发布的时间。如果我们决定绘制帖子频率,在这种情况下,我们将观察到类似于在图 4.7 中观察到的分布(这是留给感兴趣的读者的练习)。
这种情况下,事情的核心是聚合模式,也就是重采样方法中的how属性。当我们使用sum时,上午 8 点到 10 点的时段似乎有更多的交互,仅仅是因为有更多的帖子可以交互,所以总的来说并不能真正告诉我们这些帖子是否普遍成功。解决方法很简单:我们可以将脚本修改为how='mean'并重新运行代码,而不是how='sum'。输出如下图 4.8 所示:

图 4.8:交互频率(平均每小时)
图 4.8 显示了每小时聚集的平均交互次数的分布。在这个图中,上午 8 点到 10 点的时间段看起来没有之前那么成功。相反,最多的相互作用集中在凌晨 1 点至 5 点之间,有两个峰值。
这个简单的练习表明,以不同的方式聚合数据可以为同一个问题给出不同的结果。将讨论进行得更深入一点,需要注意的是,在这种情况下,我们没有两条重要的信息。首先,我们没有关于有多少人喜欢 PacktPub 页面的历史信息,也就是说,有多少人真正看到了他们新闻提要中的帖子。如果许多人最近才开始关注该页面,他们就不太可能与旧帖子互动,因此统计数据可能偏向于新帖子。其次,我们没有关于人口统计的信息,尤其是用户的位置:凌晨 1 点至 5 点的时间段似乎有点奇怪,但如前所述,创建时间被标准化为世界协调时时区。换句话说,世界协调时凌晨 1 点至 5 点对应于例如印度的早晨或太平洋海岸的下午晚些时候/晚上。
如果我们有关于用户人口统计的知识,我们也可以了解发布新帖子的最佳时间。例如,如果我们的大多数用户位于太平洋海岸,并且喜欢在下午晚些时候进行互动(根据他们的时区),那么将帖子发布时间安排在世界协调时凌晨 1 点是值得的。
将帖子可视化为单词云
在分析了交互之后,我们将注意力转移回帖子的内容。
单词云,也称为标签云(https://en.wikipedia.org/wiki/Tag_cloud),是文本数据的视觉表示。每个单词的重要性通常由它在图像中的大小来表示。
在本节中,我们将使用单词云 Python 包,它提供了一种极其简单的方法来生成单词云。首先,我们需要使用以下命令在虚拟环境中安装库及其依赖项(一个映像库):
$ pip install wordcloud
$ pip install Pillow
枕头是老的蟒蛇成像库 ( PIL )项目的分叉,因为 PIL 显然已经停产了。在它的特性中,枕头支持 Python 3,所以在这个简短的安装之后,我们就可以开始了。
下面的脚本读取一个.jsonl文件,作为从 PacktPub 存储帖子的文件,并创建一个带有单词 cloud 的.png文件:
# Chap04/facebook_posts_wordcloud.py
import os
import json
from argparse import ArgumentParser
import matplotlib.pyplot as plt
from nltk.corpus import stopwords
from wordcloud import WordCloud
def get_parser():
parser = ArgumentParser()
parser.add_argument('--page')
return parser
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
fname = "posts_{}.jsonl".format(args.page)
all_posts = []
with open(fname) as f:
for line in f:
post = json.loads(line)
all_posts.append(post.get('message', ''))
text = ' '.join(all_posts)
stop_list = ['save', 'free', 'today',
'get', 'title', 'titles', 'bit', 'ly']
stop_list.extend(stopwords.words('english'))
wordcloud = WordCloud(stopwords=stop_list).generate(text)
plt.imshow(wordcloud)
plt.axis("off")
image_fname = 'wordcloud_{}.png'.format(args.page)
plt.savefig(image_fname)
像往常一样,脚本使用ArgumentParser的实例来获取命令行参数(页面名称或页面标识)。
该脚本创建了一个列表all_posts,其中包含每个帖子的文本消息。我们使用post.get('message', '')而不是直接访问字典,因为message键可能不会出现在每个帖子中(例如,在没有评论的图像的情况下),即使这个事件非常罕见。
帖子列表然后被连接成一个字符串,text,这将是生成单词云的主要输入。WordCloud对象采用一些可选参数来定义词云的某些方面。特别是,该示例使用stopwords参数来定义将从单词云中移除的单词列表。我们在此列表中包含的单词是在自然语言工具包 ( NLTK )库中定义的标准英语停止词,以及一些在 PacktPub 帐户中经常使用但并不真正具有有趣含义的自定义关键词(例如,指向bit.ly的链接和特定标题的报价参考)。
输出图像示例如下图图 4.9 :

图 4.9:单词云示例
上图展示了 Packt Publishing 提供的一些产品(书籍、电子书和视频),以及他们的出版物中讨论的一些主要技术(如 Python、JavaScript、R、Arduino、游戏开发等),主要关键词为 data 。
总结
本章介绍了在社交媒体领域使用脸书的一些数据挖掘应用程序,他是一个大玩家,如果不是主要玩家的话。
在讨论了脸书图形应用编程接口的一些方面及其发展,以及在数据挖掘方面的含义后,我们构建了一个脸书应用程序,用于与脸书平台接口。
本章中显示的数据挖掘应用程序与经过身份验证的用户和脸书页面的配置文件相关。我们看到了如何计算与经过身份验证的用户的发布习惯以及关注给定页面更新的用户的交互相关的统计数据。使用简单的聚合和可视化技术,这种类型的分析可以用相对少量的代码来执行。最后,我们使用了一种称为词云的技术来可视化重要的关键词,这些关键词有望代表一堆已发布的帖子中的重要主题。
下一章的重点是谷歌开发的最新社交网络之一 Google+。
五、谷歌 Plus 话题分析
本章重点介绍谷歌+(有时称为谷歌 Plus 或简称 G+),这是最近加入社交网络领域的大玩家之一。它于 2011 年推出,被描述为“横跨谷歌所有服务的社交层”(https://en.wikipedia.org/wiki/Google%2B)。它的用户群经历了极其快速的增长,前两周有 1000 万用户。经过多次重新设计,在 2015 年底,谷歌披露了他们对社区和收藏的更大关注,将服务转向基于兴趣的网络。
在本章中,我们将讨论以下主题:
- 如何借助 Python 与 Google+ API 进行交互
- 如何在 Google+上搜索人物或页面
- 如何使用 web 框架 Flask 在 web GUI 中可视化搜索结果
- 如何处理用户帖子中的内容以提取感兴趣的关键词
开始使用谷歌+应用编程接口
Google+ API 是 Google+的编程接口。这个应用编程接口可以用来整合你的应用程序或网站与谷歌+,类似于我们已经讨论过的推特和脸书。本节讨论注册应用程序和开始使用谷歌+应用编程接口的过程。
在开始之前,如果我们还没有注册,我们需要一个谷歌账户(https://www.google.com/accounts)。谷歌提供了几种服务(例如,Gmail、Blogger 等),但账户管理是集中的。这意味着,如果您是这些服务之一的用户,您的帐户可以快速设置为谷歌+。
注册登录后,起点就是谷歌开发者控制台(https://console.developers.google.com/start)。从控制台,我们需要创建我们的第一个项目。图 5.1 显示了项目创建的对话框,我们只需要为我们的项目指定一个名称:

图 5.1:在谷歌开发者控制台中创建项目
项目创建后,我们需要专门启用谷歌+应用编程接口。从项目仪表板中,使用谷歌 API组件允许我们管理 API 访问,创建新的凭证,等等。类似于单个谷歌帐户可以用来访问多个谷歌服务的方式,一个项目可以使用多个 API,只要它们被启用。一旦我们找到了社交应用编程接口组下的谷歌+应用编程接口,我们只需点击一下就可以启用它。图 5.2 显示概述,如下:

图 5.2:为我们的项目启用谷歌+应用编程接口
API 启用后,系统会立即提醒我们,为了消费 API,我们需要某种凭证,如图图 5.3 :

图 5.3:启用 API 后,我们需要设置凭证
从左侧菜单可轻松访问凭证选项卡。有三种不同类型的凭据: API 密钥、 OAuth 客户端 ID 或服务帐户密钥(后者仅在服务器到服务器使用谷歌云 API 时需要)。
简单的 API 访问需要一个 API 键,即不访问任何私有用户数据的 API 调用。此密钥支持应用程序级身份验证,主要用于出于会计目的衡量项目使用情况(有关费率限制的更多详细信息,请参见以下部分)。
授权的应用编程接口访问需要一个 OAuth 客户端标识,也就是说,一个访问私有用户数据的应用编程接口调用。在调用之前,有权访问私有数据的用户必须明确授予对您的应用程序的访问权限。不同的谷歌应用编程接口声明不同的范围,也就是说,必须由用户批准的一组允许的操作。
如果您不确定应用程序需要的凭据类型,仪表板还提供了一个帮助我选择选项(如图 5.4所示),该选项将通过几个简单的问题引导您完成选择,这些问题将阐明您的应用程序需要的权限级别:

图 5.4:选择应用编程接口访问凭证类型的下拉菜单
一旦我们设置了我们需要的项目和访问键,我们就可以研究如何以编程方式访问谷歌+应用编程接口。
谷歌为谷歌 API 提供了一个官方 Python 客户端,可以用pip在我们的虚拟环境中安装,使用通常的程序:
$ pip install google-api-python-client
客户端将通过googleapiclient包提供,该包也简称为apiclient。下一节展示了测试应用编程接口使用的第一个工作示例。
在 Google+上搜索
第一个例子显示在gplus_search_example.py脚本中,查询搜索人员或页面的谷歌+应用编程接口。
这个例子假设您已经通过项目仪表板上的凭证页面设置了一个简单访问的 API 键——我们还没有访问个人数据。与我们对 Twitter 和脸书所做的类似,我们将遵循将凭据存储为环境变量的模式。例如,如果我们使用的是 Bash 提示符,我们可以使用以下命令:
$ export GOOGLE_API_KEY="your-api-key-here"
如果我们希望,也可以将export命令包含在 shell 脚本中:
# Chap05/gplus_search_example.py
import os
import json
from argparse import ArgumentParser
from apiclient.discovery import build
def get_parser():
parser = ArgumentParser()
parser.add_argument('--query', nargs='*')
return parser
if __name__ == '__main__':
api_key = os.environ.get('GOOGLE_API_KEY')
parser = get_parser()
args = parser.parse_args()
service = build('plus',
'v1',
developerKey=api_key)
people_feed = service.people()
search_query = people_feed.search(query=args.query)
search_results = search_query.execute()
print(json.dumps(search_results, indent=4))
该脚本使用ArgumentParser实例从命令行读取查询字符串。例如,如果我们想要查询术语packt,我们可以运行如下脚本:
$ python gplus_search_example.py --query packt
解析器的--query参数是用nargs='*'属性定义的,因此可以为搜索查询添加和使用多个术语。
使用 API 的起点是使用build()函数创建一个service对象。从 Google API 客户端,我们将导入服务构建器,它以我们要交互的服务的名称(plus)及其 API 的版本(v1)作为强制参数,后面是developerKey,也就是我们之前定义的 API 键。
注
支持的 api 列表及其最新版本见https://developers . Google . com/API-client-library/python/API/文档。
一般来说,服务构建器的使用方式如下:
from apiclient.discovery import build
service = build('api_name', 'api_version', [extra params])
一旦构建了service对象,它就可以提供对不同资源(分组为集合)的访问。在示例中,我们将查询people集合。
一旦构建并执行了搜索查询,脚本只需将 JSON 输出转储到屏幕上,这样我们就可以理解格式了:
{
"nextPageToken": "token-string",
"selfLink": "link-to-this-request",
"title": "Google+ People Search Results",
"etag": "etag-string",
"kind": "plus#peopleFeed",
"items": [
{
"kind": "plus#person",
"objectType": "page",
"image": {
"url": "url-to-image"
},
"id": "112328881995125817822",
"etag": "etag-string",
"displayName": "Packt Publishing",
"url": "https://plus.google.com/+packtpublishing"
},
{
/* more items ... */
}
]
}
为了简洁,输出被简化了,但是整体结构是清晰的。第一级属性(例如,title、selfLink等)定义了结果集的一些特征。结果列表包含在items列表中。每个物品都由id属性唯一标识,也由其url表示。本例中显示的结果可以包含页面和人员的混合,如objectType属性中所定义的。displayName属性由用户(或页面管理员)选择,通常显示在相应的 G+页面中。
将搜索结果嵌入网络图形用户界面
在本节中,我们将扩展第一个示例,以便可视化我们的搜索结果。作为显示项目的一种方式,我们将使用一个动态自动生成的网页,这样我们就可以使用熟悉的界面在每个人或页面的显示名称旁边显示配置文件图像。这种可视化可以帮助我们消除共享相同显示名称的不同结果之间的歧义。
为了达到这个目标,我们将引入Flask(http://flask.pocoo.org/),这是一个用于 web 开发的微框架,允许我们快速生成 web 界面。
广义地说,Python 和 web 开发是齐头并进的,几个与 web 相关的 Python 库已经存在了很多年,达到了一个有趣的成熟水平。与其他框架相比,Flask 相对年轻,但它也达到了成熟的水平,并被广泛的社区采用。由于 web 开发不是本书的核心概念,我们将采用 Flask,这是由于它的微性质——它对我们应用程序的结构不做任何假设,并且使我们更容易用相对少量的代码开始。
Flask 的一些特性包括:
- 开发服务器和调试器
- 对单元测试的集成支持
- 使用 Jinja2 库支持模板化
- 它是基于 Unicode 的
- 各种用例的大量扩展
使用pip的安装过程是通常的,如下:
$ pip install flask
前面的命令将安装微框架和相关的依赖项。
感兴趣的读者可以通过packkt Publishing以各种标题拓宽知识面,例如, Matt Copperwaite 和 Charles Leifer 的学习烧瓶框架或 Jack 斯托福的标准烧瓶获取更高级的用例。本章将直接跳到一个例子,尽量减少关于 Flask 的讨论:
# Chap05/gplus_search_web_gui.py
import os
import json
from flask import Flask
from flask import request
from flask import render_template
from apiclient.discovery import build
app = Flask(__name__)
api_key = os.environ.get('GOOGLE_API_KEY')
@app.route('/')
def index():
return render_template('search_form.html')
@app.route('/search', methods=['POST'])
def search():
query = request.form.get('query')
if not query:
# query not given, show an error message
message = 'Please enter a search query!'
return render_template('search_form.html', message=message)
else:
# search
service = build('plus',
'v1',
developerKey=api_key)
people_feed = service.people()
search_query = people_feed.search(query=query)
search_results = search_query.execute()
return render_template('search_results.html',
query=query,
results=search_results['items'])
if __name__ == '__main__':
app.run(debug=True)
gplus_search_web_gui.py脚本在 Flask 中实现了一个基本的 web 应用,展示了一个查询 Google+ API 并显示结果的简单表单。
应用程序只是Flask类的一个实例,它的主要方法是run(),用于启动 web 服务器(在示例中,我们将在调试模式下运行它,以便在需要时简化调试过程)。
web 应用程序的行为由其路由定义,也就是说,当在特定的 URL 上执行请求时,Flask 会在所需的代码段上路由该请求,这将产生响应。在最简单的形式中,Flask 中的路由只是简单的修饰功能。
Python 中的装饰器
虽然在 Python 中使用装饰器非常简单,但是如果您是这个主题的新手,理解它们并不容易。
装饰器只是一个函数,它可以通过充当目标函数的包装器来丰富另一个函数的行为。这种行为的改变是动态的,因为它不需要目标函数代码的任何改变,也不需要子类化的使用。通过这种方式,可以改进特定的功能,而不会给现有的代码库带来任何特殊的复杂性。
一般来说,装饰者是一个强大而优雅的工具,可以证明在不同的情况下极其有用。
在前面的例子中,index()和search()是修饰的函数,在这两种情况下,修饰者都是app.route()。装饰器在目标函数的正上方被调用,并以@符号作为前缀。
烧瓶路线和模板
app.route()装饰器获取第一个参数,该参数带有我们想要访问的资源的相对 URL。第二个参数是特定网址支持的 HTTP 方法列表。如果没有给出第二个参数,则默认为GET方法。
index()功能用于显示包含搜索表单的进入页面,可在相对网址/获得。该功能通过render_template()功能返回保存在search_form.html模板中的网页。
当 Flask 利用 Jinja2 模板库(http://jinja.pocoo.org/)时,render_template()函数读取存储在 HTML 文件中的代码,应用模板指令,并返回最终的 HTML 页面作为输出。
不需要过多讨论最好留给感兴趣的读者在官方文档中查看的细节,使用模板库的目的是引入一种嵌入到网页中的特殊语法,该语法可以被解析以动态生成 HTML。
templates/search_form.html文件的来源,相对于运行 Flask 的 Python 文件定位,在我们的例子中为gplus_search_web_gui.py,如下所示:
<html>
<body>
{% if message %}
<p>{{ message }}</p>
{% endif %}
<form action="/search" method="post">
Search for:
<input type="text" name="query" />
<input type="submit" name="submit" value="Search!" />
</form>
</body>
</html>
源代码包含一个基本页面(为简洁起见过于简化),只有一个表单。这个页面上唯一的模板指令是一个if块,它检查message变量并在一个段落中显示它(如果有的话)。
使用开机自检方法将表单操作设置为相对网址/search。这是gplus_search_web_gui.py文件search()中第二个修饰功能的配置。
search()功能是与 Google+ API 进行交互的地方。首先,函数期望通过表单传递一个query参数。该参数可通过request.form字典访问。
Flask 中的全局request对象通常用于访问传入的请求数据。request.form字典提供了对使用 POST 方法通过表单传递的数据的访问。
如果没有给出查询,search()功能将再次显示搜索表单,包括一条错误消息。render_template()函数获取多个关键字参数,并将它们传递给模板,在这种情况下,唯一的参数是message。
另一方面,如果提供了查询,search()函数将执行与 Google+ API 的交互,并将结果馈送到search_results.html模板。templates/search_results.html文件的来源如下:
<html>
<link rel="stylesheet"
href="{{ url_for('static', filename='style.css') }}" />
<body>
Searching for <strong>{{ query }}</strong>:
{% for item in results %}
<div class="{{ loop.cycle('row_odd', 'row_even') }}">
<a href="{{ item.url }}">{{ item.displayName }}</a>
({{ item.objectType}})<br />
<a href="{{ item.url }}">
<img src="{{ item.image.url }}" />
</a>
</div>
{% endfor %}
<p><a href="/">New search</a></p>
</body>
</html>
主体的核心是一个for循环,它遍历结果列表。对于列表中的每个项目,都会显示一个带有给定项目详细信息的<div>元素。我们注意到打印特定变量值的语法是用双花括号构造的,例如,{{ item.displayName }}将打印每个项目的显示名称。另一方面,控制流程包含在{%和%}符号中。
该示例还使用了一些有趣的工具,这些工具允许与个性化结果页面外观所必需的级联样式表 ( CSS )轻松集成。loop.cycle()函数用于在一系列字符串或变量之间循环,它在这里用于将不同的 CSS 类分配给形成结果的<div>块。这样,不同的行可以用不同的(替代的)颜色突出显示。另一个工具是url_for()功能,用于提供特定资源的网址。类似于templates文件夹的static文件夹必须放在运行 Flask 应用程序的gplus_search_web_gui.py文件的相同目录中。它用于提供静态文件,如图像或 CSS 定义。static/style.css文件包含搜索结果页面中使用的 CSS 的定义,如下所示:
.row_odd {
background-color: #eaeaea;
}
.row_even {
background-color: #fff;
}
虽然 web 开发的主题与数据挖掘没有严格的关系,但它提供了一种快速原型化一些简单用户界面的简单方法。主题相当广泛,这一章并不旨在全面讨论。一个感兴趣的读者被邀请深入研究网络开发和 Flask 的细节。
该示例可以使用以下命令行运行:
$ python gplus_search_web_gui.py
这将运行 Flask 应用程序,它将等待一个 HTTP 请求,以便提供 Python 代码中定义的响应。运行脚本后,应用程序应该在前台运行,并且终端上应该出现以下输出:
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
127.0.0.1地址是本地主机,而端口5000是 Flask 的默认值。
型式
通过网络可见的 Flask 服务器
如果您在虚拟机或通过网络运行 Flask 应用程序,您需要为此计算机使用适当的网络地址,而不是本地主机地址。
此外,您需要确保服务器在外部可见,以便可以通过网络访问。因为这可能会带来安全隐患,所以首先建议您考虑是否信任网络用户。
为了使 Flask 应用程序在外部可见,您可以禁用调试模式或将服务器绑定到适当的地址,例如:
app.run(host='0.0.0.0')
这里,0.0.0.0表示本地机器上的所有地址。
我们现在可以打开一个浏览器窗口,指向http://127.0.0.1:5000,如图图 5.5 :

图 5.5:Flask 应用程序的入口页面
如果我们点击搜索!按钮不插入任何输入,app 会再次显示带有错误信息的表单,如图图 5.6 :

图 5.6:如果没有给出查询,将显示错误消息
另一方面,如果正确给出查询,结果页面将显示为图 5.7 (该图给出的查询为packt):

图 5.7:在谷歌+应用编程接口上搜索的结果
正如我们所看到的,每个项目都显示在它自己的块中,具有交替的背景颜色和关于项目本身的所需信息。项目名称在锚点标签(<a>)中表示,因此它们是可点击的,并链接到相关的谷歌+页面。
来自 Google+页面的笔记和活动
在搜索一个谷歌+页面并在网络图形用户界面中可视化结果后,我们将继续下载给定页面的活动列表。活动相当于谷歌+的脸书帖子。默认情况下,一个活动被认为是一个笔记,也就是在 Google+上共享的一段文字。
以下脚本gplus_get_page_activities.py用于从 Google+页面收集活动列表:
# Chap05/gplus_get_page_activities.py
import os
import json
from argparse import ArgumentParser
from apiclient.discovery import build
def get_parser():
parser = ArgumentParser()
parser.add_argument('--page')
parser.add_argument('--max-results', type=int, default=100)
return parser
if __name__ == '__main__':
api_key = os.environ.get('GOOGLE_API_KEY')
parser = get_parser()
args = parser.parse_args()
service = build('plus',
'v1',
developerKey=api_key)
activity_feed = service.activities()
activity_query = activity_feed.list(
collection='public',
userId=args.page,
maxResults='100'
)
fname = 'activities_{}.jsonl'.format(args.page)
with open(fname, 'w') as f:
retrieved_results = 0
while activity_query and retrieved_results < args.max_results:
activity_results = activity_query.execute()
retrieved_results += len(activity_results['items'])
for item in activity_results['items']:
f.write(json.dumps(item)+"\n")
activity_query = service.activities().list_next(activity_query,
activity_results)
该脚本使用ArgumentParser从命令行获取几个输入参数。--page选项是必需的,因为它包含我们正在寻找的页面(或用户)的标识。该参数可以是页面的数字标识,也可以是其谷歌+句柄(例如,Packt Publishing G+页面的+packtpublishing)。另一个参数是我们想要检索的结果(即活动)的最大数量。该参数可选,默认为100。
该脚本可以按如下方式运行:
$ python gplus_get_page_activities.py --page +packtpublishing
--max-results 1000
几秒钟后,我们会在activities_+packtpublishing.jsonl文件中找到 JSON 格式的活动列表。这个文件,就像我们为 Twitter 和脸书做的一样,是根据 JSON Lines 规范格式化的,这意味着每一行都是一个有效的 JSON 文档。
单个活动的完整描述可在https://developers . Google . com/+/web/API/rest/latest/activities # resource的文档中找到。下表概述了最重要的字段:
kind:这是项目类型(即plus#activity)etag:这是实体标签串(参考https://en.wikipedia.org/wiki/HTTP_ETagtitle:这是活动的简称verb:这不是帖子就是分享actor:这是一个代表分享或发布活动的用户的对象published:这是 ISO 8601 格式的出版日期updated:这是 ISO 8601 格式的上次更新日期id:这是活动的唯一标识符url:这是活动的 URLobject:这是一个复杂的对象,包含活动的所有细节,例如内容、原始参与者(如果活动是共享,这将不同于活动的参与者)、回复、共享和plus one的信息、附件列表和相关细节(例如,图像),以及地理位置信息(如果有)
对象的描述引入了加一的概念,有点类似于脸书上的 Like 按钮。当用户喜欢谷歌+上的某个特定内容时,他们可以 +1 它。+1 按钮允许用户在谷歌+上共享内容,它还推荐谷歌搜索上的内容。JSON 文档中的plusoners键提供了特定对象上+1 个反应的详细信息。plusoners.totalItems包含+1 反应的数量,plusoners.selfLink包含一个到+1 列表的 API 链接。类似地,replies和resharers包含相同的关于直接评论和分享的信息。
查看下载活动列表的代码要旨,可以看到出发点仍然是apiclient.discovery.build()函数构建服务对象。以下步骤将活动集合存储在activity_feed对象中。为了查询activity_feed对象,我们将使用其list()功能,而不是search(),因为我们正在检索完整的活动列表(至少达到可用结果的最大数量)。该功能需要两个强制参数:collection和userId。而collection只能接受public作为值,userId参数是我们正在检索其活动的用户/页面的标识符。该参数可以是唯一数字标识符或谷歌+处理程序字符串的形式。我们也可以通过maxResults作为可选参数,在本例中设置为100。这是单个 API 调用可以检索的项目数(默认为20,最大为100)。
代码还展示了如何使用这个 API 进行分页。查询在while循环中执行,该循环用检索到的结果数更新计数器(循环到通过--max-results指定的值),并检查下一页结果是否存在。list_next()函数以当前查询和当前结果列表为参数,通过更新查询对象来处理分页。如果下一页结果不可用(即用户/页面发布的帖子没有超过已经检索到的帖子),该功能将返回None。
笔记上的文本分析和 TF-IDF
在讨论了如何下载给定页面或用户的笔记和活动列表后,我们将把重点转移到内容的文本分析上。
对于给定用户发布的每个帖子,我们希望提取最有趣的关键词,这些关键词可以用来总结帖子本身。
虽然这在直觉上是一个简单的练习,但有一些微妙之处需要考虑。在实际方面,我们可以很容易地观察到,每个帖子的内容并不总是一段干净的文本,事实上,HTML 标签可以包含在内容中。在进行计算之前,我们需要提取干净的文本。虽然 Google+ API 返回的 JSON 对象结构清晰,但内容本身不一定是格式良好的结构化文档。幸运的是,有一个不错的 Python 包来拯救。美汤其实是能够解析 HTML 和 XML 文档,包括格式错误的标记。它与 Python 3 兼容,可以以通常的方式从 CheeseShop 安装。在我们的虚拟环境中,使用以下命令:
$ pip install beautifulsoup4
这将安装版本 4。图书馆。从版本 3 的过渡。到版本 4。*已经看到了一些重要的变化,因此这里显示的示例与旧版本的库不兼容。
下一个重要的问题是我们如何定义关键词的重要性?
这个问题可以从不同的方向解决,重要性的定义可以根据应用而改变。在我们的案例中,我们将使用基于统计的方法,其中关键字的重要性由它在文档中的存在和我们正在分析的文档集合给出。
提议的方法被称为TF-IDF(https://en.wikipedia.org/wiki/Tf%E2%80%93idf),这是基于术语频率的两个分数( TF 和 IDF 的组合。第 3 章、Twitter 上的用户、关注者和社区,在 Twitter 上简要介绍了 TF-IDF 使用 scikit-learn 库提供的现成实现。一般来说,使用现有的实现是一个好主意,尤其是如果它来自高质量的库,比如 scikit-learn。在本节中,我们将提出一个定制的实现,这样我们就可以展示 TF-IDF 的细节,让读者更好地理解这个框架。
TF-IDF 背后的动机相当简单:如果一个词在文档中出现的频率很高,那么它是表示文档的一个很好的候选词,但是在整个集合中很少出现。这两个属性通过两个分数来反映:术语频率 ( TF ),文档内术语的局部频率,以及逆文档频率 ( IDF ),在整个文档集合上计算。
IDF 于 1972 年在信息检索研究的背景下由该领域的先驱之一凯伦·斯帕克-琼斯(https://en.wikipedia.org/wiki/Karen_Sp%C3%A4rck_Jones)提出。作为一种启发,它展示了术语特异性和齐夫定律(https://en.wikipedia.org/wiki/Zipf%27s_law)之间的联系。
型式
齐夫定律和齐夫安分布
Zipf 定律是一个经验定律,指的是不同科学领域的许多类型的数据可以用一个 Zipfian(即长尾)分布来近似。在第 2 章、# miningtwetter-Hashtags、Topics 和 Time Series 中,我们已经观察到 Packt Publishing 在推文中使用的词汇遵循这种分布。关于齐夫定律的更多细节,请参考第 2 章、# miningtwetter-Hashtags、Topics 和 Time Series 。
从数学的角度来看,多年来已经提出了 TF 和 IDF 的不同变体。
TF 最常见的两种选择是简单地考虑文档中某个单词的原始频率,或者根据文档中的单词数量对其进行归一化(也就是说,对观察文档中某个单词的概率的频繁解释)。
在 IDF 方面,传统的定义是log(N/n),其中N是文档的总数,而n是包含给定术语的文档数。对于每个文档中出现的术语,这将导致 IDF 值为零(即log(1))。出于这个原因,以色列国防军可能的正常化之一是1+log(N/n)。
图 5.8 提供了 TF-IDF 背后的直觉及其与 Zipf 定律的联系的可视化表示——过于频繁或过于罕见的词语不具有代表性:

图 5.8:单词的分布及其重要性
常用词包括终止词,例如,冠词、连词和命题。一般来说,它们本身没有任何特定的含义,所以可以忽略(即删除)。另一方面,非常罕见的词包括错别字和过于专业的术语。根据应用程序的不同,有必要对数据集进行探索,以更好地理解停止单词移除是否是一个好主意。
gplus_activities_keywords.py脚本从给定的 JSON Lines 文件中读取活动列表,为每个活动的文本内容计算每个单词的 TF-IDF,然后显示每个帖子旁边的顶级关键词。
注
代码使用 NLTK 库来执行一些文本处理操作,例如标记化和停止单词删除。在第 1 章、社交媒体、社交数据和 Python 中,我们看到了如何下载执行其中一些操作所需的附加 NLTK 包,特别是用于令牌化的punkt包。
gplus_activities_keywords.py脚本如下:
# Chap05/gplus_activities_keywords.py
import os
import json
from argparse import ArgumentParser
from collections import defaultdict
from collections import Counter
from operator import itemgetter
from math import log
from string import punctuation
from bs4 import BeautifulSoup
from nltk.tokenize import word_tokenize
from nltk.corpus import stopwords
我们将定义一个包含常见英语停止词和标点符号的停止词列表:
punct = list(punctuation)
all_stopwords = stopwords.words('english') + punct
def get_parser():
parser = ArgumentParser()
parser.add_argument('--file',
'-f',
required=True,
help='The .jsonl file of the activities')
parser.add_argument('--keywords',
type=int,
default=3,
help='N of words to extract for each post')
return parser
预处理步骤由preprocess()函数处理,该函数协调规范化、数据清理(使用clean_html()辅助函数)和标记化:
def clean_html(html):
soup = BeautifulSoup(html, "html.parser")
text = soup.get_text(" ", strip=True)
text = text.replace('\xa0', ' ')
text = text.replace('\ufeff', ' ')
text = ' '.join(text.split())
return text
def preprocess(text, stop=all_stopwords, normalize=True):
if normalize:
text = text.lower()
text = clean_html(text)
tokens = word_tokenize(text)
return [tok for tok in tokens if tok not in stop]
以下函数处理基于单词的统计。make_idf()创建集合范围内的 IDF 分数,get_keywords()可以使用该分数计算每个文档的 TF-IDF 分数,目的是提取感兴趣的关键词:
def make_idf(corpus):
df = defaultdict(int)
for doc in corpus:
terms = set(doc)
for term in terms:
df[term] += 1
idf = {}
for term, term_df in df.items():
idf[term] = 1 + log(len(corpus) / term_df)
return idf
def get_keywords(doc, idf, normalize=False):
tf = Counter(doc)
if normalize:
tf = {term: tf_value/len(doc)
for term, tf_value in tf.items()}
tfidf = {term: tf_value*idf[term]
for term, tf_value in tf.items()}
return sorted(tfidf.items(),
key=itemgetter(1),
reverse=True)
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
with open(args.file) as f:
posts = []
for line in f:
activity = json.loads(line)
posts.append(preprocess(activity['object']['content']))
idf = make_idf(posts)
for i, post in enumerate(posts):
keywords = get_keywords(post, idf)
print("----------")
print("Content: {}".format(post))
print("Keywords: {}".format(keywords[:args.keywords]))
像往常一样,我们将利用ArgumentParser来读取命令行参数。具体来说,--file用于传递文件名,--keywords是一个可选参数,用于指定我们要观察的每个文档的关键词数量(默认为3)。
例如,该脚本可以按如下方式运行:
$ python gplus_activities_keywords.py --file
activities_+LarryPage.jsonl --keywords 5
该命令将为用户+LarryPage 读取活动的.jsonl文件(假设我们之前下载了它),并将显示每个活动的前五个关键词。
例如,拉里·佩奇的一篇文章如下:
----------
Content: ['fun', 'day', 'kiteboarding', 'alaska', 'pretty', 'cold', 'gusty', 'ago']
Keywords: [('alaska', 5.983606621708336), ('gusty', 5.983606621708336), ('kiteboarding', 5.983606621708336), ('cold', 4.884994333040227), ('pretty', 3.9041650800285006)]
如我们所见,帖子显示为术语列表,而关键字显示为元组列表,每个元组包含术语本身和 TF-IDF 分数。
从全文到令牌列表的转换由preprocess()函数处理,该函数也使用clean_html()函数作为助手。
预处理包括以下步骤:
- 文本规范化(即降低音量)
- HTML 清理/剥离
- 标记化
- 停止单词删除
preprocess()函数只需要一个强制参数,我们可以对要删除的停止词列表进行个性化设置(如果不想执行此步骤,则传递一个空列表),也可以通过设置两个关键字参数stop和normalize来关闭下限。
文本规范化用于将仅因使用的大小写而不同的单词组合在一起,例如,和将通过下取过程映射到中。Lowercasing 对很多应用程序都有意义,但在某些情况下却没有帮助,例如 us (代词)对us(美国的首字母缩写)的情况。重要的是要记住,降级是不可逆的,所以如果我们需要在应用程序的后面参考原始文本,我们也需要继续下去。**
**HTML 清理是用几行代码执行的,利用了美丽的汤。一旦用html.parser作为第二个参数实例化了BeautifulSoup对象,它就能够处理格式错误的 HTML。get_text()函数完全符合预期:它只获取文本,而不包含 HTML。空白作为这个函数的第一个参数,用来替换剥离的 HTML 标记。这在以下情况下很有用:
... some sentence<br />The beginning of the next one
在这种情况下,仅仅剥离断线标签就会创建sentenceThe标记,这显然不是一个真正的单词。用空格替换标签解决了这个问题。
clean_html()函数还用几个字符串替换了空白的 Unicode 字符。最后,如果文本中存在多个连续的空格,则通过拆分文本并重新连接它们,将它们规范化为单个空格(例如,' '.join(text.split()))。
令牌化是将字符串分解成单个令牌的过程,由 NLTK 通过其word_tokenize()函数来处理。
最后,通过简单的列表理解来执行停止词移除,其中对照stop列表检查每个标记。来自 NLTK 的英文停止词列表和来自string.punctuation的标点符号列表组合使用作为默认停止词列表。
TF-IDF 实现由两个函数处理:make_idf()和get_keywords()。由于 IDF 是一个集合范围的统计数据,我们将首先计算它。corpus参数是一个列表列表,也就是一个已经由preprocess()函数处理的标记化文档列表。
首先,我们将使用df字典计算文档频率。这个统计只是一个术语出现的文档数量,也就是说,我们不考虑重复。对于语料库中的每个文档,我们将在一个集合中铸造文档。这样,每个文档只考虑一次术语。其次,我们将迭代新构建的df字典的项目,我们将使用它来计算 IDF 分数,然后由函数返回。在 IDF 的这个版本中,我们将使用前面讨论过的+1 规范化。
此时,我们准备计算每个文档中每个术语的 TF-IDF 分数。迭代所有帖子,我们将调用get_keywords()函数,该函数接受两个参数:文档(令牌列表)和之前构建的 IDF 字典。第三个可选参数是激活文档长度归一化的标志,它有效地将 TF 转换为观察文档中某个术语的概率,即P(t|d)。
get_keywords()函数首先通过collections.Counter构建原始 TF,本质上是一个字典。如果需要文档长度标准化,tf字典通过字典理解来重建,将每个原始 TF 的值除以文档中的术语总数(即文档长度)。
TF-IDF 分数的计算方法是将每个术语的 TF 与 IDF 相乘。再一次,字典理解被用来达到目的。
最后,该函数返回带有相关 TF-IDF 分数的术语列表,按分数排序。使用key=itemgetter(1)调用sorted()函数,使用 TF-IDF 分数(元组中的第二项)应用排序,并使用reverse=True以降序显示值。
用 n-grams 捕捉短语
TF-IDF 提供了关于给定文档中关键字重要性的简单统计解释。使用这个模型,每个单词都被单独考虑,单词的顺序并不重要。事实上,单词是按顺序使用的,通常一系列单词在句子结构中充当一个单一的单位(这在语言学中也称为成分)。
给定一个标记化的文档(即一系列标记),我们将 n-grams 称为从上述文档中提取的一系列n相邻术语。有了n=1,我们还在考虑单项式(也叫 unigrams)。如果值大于 1,我们将考虑任意长度的序列。典型的例子包括二元模型(带n=2)和三元模型(带n=3,但是任何序列长度都是可能的。
NLTK 提供了在给定令牌输入列表的情况下快速计算 n 克列表的工具。特别是处理 n-gram 生成的功能是nltk.util.ngrams:
>>> from nltk.util import ngrams
>>> s = 'the quick brown fox jumped over the lazy dog'.split()
>>> s
['the', 'quick', 'brown', 'fox', 'jumped', 'over', 'the', 'lazy', 'dog']
>>> list(ngrams(s, 4))
[('the', 'quick', 'brown', 'fox'), ('quick', 'brown', 'fox', 'jumped'), ('brown', 'fox', 'jumped', 'over'), ('fox', 'jumped', 'over', 'the'), ('jumped', 'over', 'the', 'lazy'), ('over', 'the', 'lazy', 'dog')]
如您所见,该函数接受两个参数:一系列标记和一个数字。ngrams()返回的值是一个生成器对象,所以为了在提示符下可视化,我们将其转换为列表,但是它可以直接使用,例如在列表理解中。
NLTK 还提供了两个有用的快捷方式- nltk.bigrams()和nltk.trigrams()-分别用n=2和n=3简单调用ngrams()函数。
作为对感兴趣的读者的练习,我们将以以下问题结束本节:
- 我们能否修改本节中提出的代码,以便能够捕获有趣的二元模型或三元模型,而不仅仅是单个关键字?
- 捕捉 n 克时,停字去除的效果如何?
总结
在这一章中,我们看了谷歌+应用编程接口。我们还讨论了如何在谷歌开发者控制台上注册一个项目,以及如何为我们的项目启用所需的应用编程接口。
这一章从一个例子开始,展示了如何使用谷歌+应用编程接口执行搜索。然后在这个例子的基础上进行讨论,以便将应用编程接口的使用嵌入到网络应用程序中。Flask 是一个用于网络开发的微型框架,只需几行代码就可以启动并运行,我们利用它构建了一个网络图形用户界面来显示搜索会话的结果。
下一步是分析用户或页面的文本注释。在下载用户活动并以 JSON Lines 格式存储它们之后,我们讨论了 TF-IDF 的一些细节,作为从一段文本中提取有趣关键词的统计方法。
在下一章中,我们将把注意力转移到问题回答领域,我们将通过接近堆栈溢出应用编程接口来做到这一点。**
六、StackExchange 问答
这一章是关于堆 StackExchange、问答网络和更广泛的问答主题。
在本章中,我们将讨论以下主题:
- 如何创建与堆 StackExchange 应用编程接口交互的应用程序
- 如何在 Stack Exchange 上搜索用户和问题
- 如何处理离线处理的数据转储
- 用于文本分类的监督机器学习方法
- 如何使用机器学习预测问题标签
- 如何在实时应用程序中嵌入机器学习模型
问答
寻找特定信息需求的答案是网络的主要用途之一。随着时间的推移,技术不断发展,互联网用户也一直在改变他们的在线行为。
如今人们在互联网上寻找信息的方式与 15-20 年前大不相同。在早期,寻找答案主要意味着使用搜索引擎。对 90 年代末一个流行搜索引擎的查询日志的研究显示,在那个年代,典型的搜索非常短(平均 2.4 个词)。
近年来,我们开始经历从短的基于关键词的搜索查询到长的会话查询(或者应该说,问题)的转变。换句话说,搜索引擎已经从关键词匹配转向自然语言处理 ( NLP )。例如,图 6.1 显示了谷歌如何尝试自动完成用户的查询/问题。系统使用来自流行查询的统计数据来预测用户打算如何完成问题:

图 6.1:谷歌查询自动完成的一个例子
自然语言的用户界面为最终用户提供了更自然的体验。更高级的例子是嵌入在我们智能手机中的智能个人助理——谷歌的“现在”、苹果的“Siri”或微软的“Cortana”等应用程序允许用户搜索网络、获得关于餐馆的推荐或安排约会。
提问只是故事的一部分,因为最重要的部分是找到这个问题的答案。图 6.2 显示了回答查询的谷歌结果页面的顶部:why is python called python。当谷歌将查询解释为对话式查询时,即一个适当的问题,而不是一般的信息查询时,所谓的回答框被激活。该框包含一个网页片段,谷歌认为这是给定问题的答案。页面其余部分,图中未显示,包含传统的搜索引擎结果页面 ( SERP ):

图 6.2:对话查询结果的答案框示例
自动问答 ( QA )的研究不仅限于搜索引擎领域。人工智能领域的一项广受欢迎的成就以 IBM Watson 为代表,这是一个由 IBM 开发的问答系统,以 IBM 首任 CEO Thomas Watson(https://en . Wikipedia . org/wiki/Watson _(computer))命名。当机器在危险游戏中与人类冠军竞争并获胜时,它变得流行起来!,一个受欢迎的美国游戏节目,以智力竞赛为特色,参赛者必须在得到一些与答案相关的线索后找到正确的问题。当竞争正在逆转这个过程时,也就是说,你需要为给定的答案找到问题,解决问题所必需的技术可以双向运行。
虽然搜索引擎和智能个人助理正在改善用户在网上查找信息的方式,但寻找答案的迫切需求也推动了质量保证网站的兴起(参见https://en.wikipedia.org/wiki/Comparison_of_Q%26A_sites)。最受欢迎的质量保证网络之一是堆 StackExchange(http://stackexchange.com)。最著名的是 Stack Overflow(http://stackoverflow.com),它的特色是对广泛的编程相关主题的问答,现在的网络由几十个主题网站组成,从技术到科学,从商业到娱乐。Stack Exchange 网站的其他例子包括电影&电视(针对电影和电视节目爱好者)、经验丰富的建议(针对专业和业余厨师)、英语语言&用法(针对语言学家、词源学家和英语语言爱好者)和学术(针对学者和高等教育入学者)。单个网站的列表很长,主题也相当多样(http://stackexchange.com/sites)。
Stack Exchange 成功的原因之一可能是社区策划的高质量内容。问题和答案实际上是由用户投票决定的,这样顶级的答案就可以上升到顶级。通过使用网站获得的信誉积分机制,使我们能够识别社区中最活跃的成员及其专业领域。事实上,当用户的答案获得向上(或向下)的投票时,以及当他们提议内容编辑以提高网站质量时,用户可以获得(或失去)分数。该系统的游戏化还包括一系列的徽章,用户可以通过他们的贡献获得这些徽章。
为了保持内容的高质量,重复问题和低质量问题通常会被搁置,由版主审核,最终被编辑或关闭。
Stack Exchange 中没有社交网络服务的一些主要功能。例如,不可能直接与其他用户联系(例如,脸书和推特上的朋友或追随者关系),也不能与其他用户进行私人对话。随着人们交流知识和协作,Stack Exchange 作为社交媒体平台的效果仍然很明显。
下一节介绍堆 StackExchange 应用编程接口。
开始使用堆 StackExchange 应用编程接口
StackExchange 应用编程接口(https://api.stackexchange.com/docs)提供了对所有 StackExchange 网站的编程访问。打算使用该应用编程接口的第三方应用程序应该在http://stackapps.com注册,以便获得用于每天批准更多请求的请求密钥。注册的应用程序还可以执行经过身份验证的 API 调用,即代表经过身份验证的用户与 Stack Exchange 进行交互。注册过程相当简单,如图 6.3 所示-所有需要的细节是一个申请名称和描述:

图 6.3:在 stackapps.com 注册的应用程序
注册我们的应用程序的直接效果是 API 允许的每天请求数量增加。需要注意的是,速率限制不仅是每天设置的,也是为了防止泛洪(例如,如果同一 IP 每秒发送 30 个以上的请求,这些请求将被丢弃)。文件(https://api.stackexchange.com/docs/throttle)中描述了限速申请的细节。
注册应用程序后,我们必须记下堆 StackExchange 提供的密钥。而客户端 ID 和客户端密码必须用于 OAuth 过程,以防我们需要执行认证呼叫,关键是我们使用什么来享受增加的请求配额。重要的是要注意,如文档中所详述的,客户端 ID 和密钥不是秘密;而客户秘密,顾名思义,是不应该被传播的敏感信息。
通过使用环境变量,我们可以按照与前面章节相同的过程配置应用程序密钥的使用:
$ export STACK_KEY="your-application-key"
在开始以编程方式与应用编程接口交互之前,我们需要的下一个工具是应用编程接口客户端。目前主要有一个(非官方的)Python StackExchange API 客户端叫做Py-StackExchange(https://github.com/lucjon/Py-StackExchange)。客户端只需通过简单的接口将应用编程接口端点绑定到 Python 方法。
从我们的虚拟环境中,我们可以通过 CheeseShop 按照通常的方法安装 Py-StackExchange:
$ pip install py-stackexchange
为了测试客户端并展示其核心类,我们可以使用 Python 交互式外壳并观察一些关于 Stack Overflow 网站的通用统计数据:
>>> import os
>>> import json
>>> from stackexchange import Site
>>> from stackexchange import StackOverflow
>>> api_key = os.environ.get('STACK_KEY')
>>> so = Site(StackOverflow, api_key)
>>> info = so.info()
>>> print(info.total_questions)
10963403
>>> print(info.questions_per_minute)
2.78
>>> print(info.total_comments)
53184453
>>> print(json.dumps(info.json, indent=4))
{
"total_votes": 75841653,
"badges_per_minute": 4.25,
"total_answers": 17891858,
"total_questions": 10963403,
"_params_": {
"body": "false",
"site": "stackoverflow.com",
"comments": "false"
},
"api_revision": "2016.1.27.19039",
"new_active_users": 35,
"total_accepted": 6102860,
"total_comments": 53184453,
"answers_per_minute": 4.54,
"total_users": 5126460,
"total_badges": 16758151,
"total_unanswered": 2940974,
"questions_per_minute": 2.78
}
与 API 交互的核心类是stackexchange.Site,可以用两个参数进行实例化:第一个是我们感兴趣的 Stack Exchange 网站的类定义(本例中为stackexchange.StackOverflow),第二个是(可选的)应用键。一旦定义了Site对象,我们就可以使用它的方法来访问应用编程接口。
在本例中,我们将使用info()方法访问/info端点(https://api.stackexchange.com/docs/info)。返回的对象有许多属性。在本例中,我们将打印total_questions、questions_per_minute和total_comments的值。属性的完整列表在info.__dict__字典中可见,更一般地说,info 对象也有一个info.json属性,它包含来自 API 的原始响应,其中所有前述属性都以 JSON 格式显示。
搜索标记问题
在介绍了与堆 StackExchange 应用编程接口的基本交互之后,我们将使用 Python 客户端来搜索问题。具体来说,我们将使用问题标签作为关键字的过滤器。
以下代码执行搜索:
# Chap06/stack_search_keyword.py
import os
import json
from argparse import ArgumentParser
from stackexchange import Site
from stackexchange import StackOverflow
def get_parser():
parser = ArgumentParser()
parser.add_argument('--tags')
parser.add_argument('--n', type=int, default=20)
return parser
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
my_key = os.environ.get('STACK_KEY')
so = Site(StackOverflow, my_key)
questions = so.questions(tagged=args.tags, pagesize=20)[:args.n]
for i, item in enumerate(questions):
print("{}) {} by {}".format(i,
item.title,
item.owner.display_name))
代码使用ArgumentParser从命令行获取一些参数。特别是,--tags选项用于传递我们正在搜索的标签列表,用分号分隔。脚本给出的结果数量可以使用--n标志定义(默认为20问题)。
例如,我们可以按如下方式运行脚本:
$ python stack_search_keyword.py --tags "python;nosql" --n 10
标签列表的双引号是必需的,因为分号是 Bash 这样的外壳上的命令分隔符,所以该行将被解释为两个独立的命令(第二个命令从nosql开始)。通过使用双引号,python;nosql字符串被解释为单个字符串,整个行被解释为单个命令,正如我们所期望的那样。
标签列表通过questions()方法的tagged参数传递给应用编程接口。该参数用于布尔AND查询,这意味着检索到的问题都用python和nosql标记。
型式
布尔查询
questions()方法实现了/questions端点(https://api.stackexchange.com/docs/questions,其使用多个标签作为AND约束。调用此方法的输出是包含所有指定标记的问题列表。
search()方法改为实现/search端点(https://api.stackexchange.com/docs/search,这显示了相反的行为,因为多个标签被插入到一个OR约束中。调用此方法的输出是包含任何指定标记的问题列表。
需要注意的一个重要细节是,Py-StackExchange 客户端经常使用惰性列表来最小化幕后执行的 API 调用量。这意味着像questions()这样的方法不返回列表,而是返回结果集的包装器。通过这种结果集的直接迭代将尝试访问所有与查询匹配的项目(如果不小心,这将违背惰性列表的目的),而不仅仅是给定页面大小的列表。
出于这个原因,我们使用slice运算符来显式调用给定数量的结果(通过命令行用--n参数指定,因此可用作args.n)。
型式
切片切片 Python 列表
slice运算符是一个强大的工具,但是对于经验较少的 Python 程序员来说,语法可能会有些混乱。以下示例总结了它的主要用途:
# pick items from start to end-1
array[start:end]
# pick items from start to the rest of the array
array[start:]
# pick items from the beginning to end-1
array[:end]
# get a copy of the whole array
array[:]
# pick items from start to not past end, by step
array[start:end:step]
# get the last item
array[-1]
# get the last n items
array[-n:]
# get everything except the n items
array[:-n]
例如,考虑以下情况:
>>> data = ['one', 'two', 'three', 'four', 'five']
>>> data[2:4]
['three', 'four']
>>> data[2:]
['three', 'four', 'five']
>>> data[:4]
['one', 'two', 'three', 'four']
>>> newdata = data[:]
>>> newdata
['one', 'two', 'three', 'four', 'five']
>>> data[1:5:2]
['two', 'four']
>>> data[-1]
'five'
>>> data[-1:]
['five']
>>> data[:-2]
['one', 'two', 'three']
需要记住的一个重要方面是索引从零开始,所以data[0]是第一项,data[1]是第二项,以此类推。
如前所示运行的stack_search_keyword.py脚本的输出是与 Python 和 NoSQL 相关的 10 个问题的标题序列,格式如下:
n) Question title by User
单个问题(通过item变量在for循环中访问)被包装到stackexchange.model.Question模型类中,具有title或tags等属性。每个问题也通过owner属性链接到提问用户的模型。在示例中,我们访问问题的title和用户的display_name。
结果集按上次活动日期排序,这意味着具有最近活动(例如,具有新答案)的问题会首先显示。
使用questions()方法的sort属性可以影响这种行为,该属性可以采用以下任何值:
activity(默认):首先显示最近的活动creation:首先显示最近的创建日期votes:这个先显示最高分hot:这使用了热点问题标签的公式week:这使用了周问题标签的公式month:这使用了月问标签的公式
搜索用户
在搜索问题之后,在本节中,我们将讨论如何搜索特定用户。搜索过程与用于检索问题的过程非常相似。下面的例子建立在前一个例子的基础上,我们扩展了ArgumentParser的使用来个性化一些搜索选项:
# Chap06/stack_search_user.py
import os
import json
from argparse import ArgumentParser
from argparse import ArgumentTypeError
from stackexchange import Site
from stackexchange import StackOverflow
以下功能用于验证通过ArgumentParser传递的参数。如果该值无效,它们将引发异常以停止脚本的执行:
def check_sort_value(value):
valid_sort_values = [
'reputation',
'creation',
'name',
'modified'
]
if value not in valid_sort_values:
raise ArgumentTypeError("Invalid sort value")
return value
def check_order_value(value):
valid_order_values = ['asc', 'desc']
if value not in valid_order_values:
raise ArgumentTypeError("Invalid order value")
return value
check_sort_value()和check_order_value()功能可以在ArgumentParser定义中用作相关参数的type:
def get_parser():
parser = ArgumentParser()
parser.add_argument('--name')
parser.add_argument('--sort',
default='reputation',
type=check_sort_value)
parser.add_argument('--order',
default='desc',
type=check_order_value)
parser.add_argument('--n', type=int, default=20)
return parser
实现用户搜索的脚本的主要逻辑相当简单:
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
my_key = os.environ.get('STACK_KEY')
so = Site(StackOverflow, my_key)
users = so.users(inname=args.name,
sort=args.sort,
order=args.order)
users = users[:args.n]
for i, user in enumerate(users):
print("{}) {}, reputation {}, joined {}".format(i,
user.display_name,
user.reputation,
user.creation_date))
stack_search_user.py脚本像往常一样使用ArgumentParser来捕获命令行参数。与以前的使用不同的是一些选项的个性化数据类型,如--sort和--order。在深入了解ArgumentParser数据类型的细节之前,让我们来看一个使用它们的例子。
假设我们在寻找堆栈交易所的创始人(杰夫·阿特伍德和乔尔·斯波尔斯基)。仅使用给定名称发送查询可以按如下方式执行:
$ python stack_search_user.py --name joel
前面命令的输出类似于以下内容:
0) Joel Coehoorn, reputation 231567, joined 2008-08-26 14:24:14
1) Joel Etherton, reputation 27947, joined 2010-01-14 15:39:24
2) Joel Martinez, reputation 26381, joined 2008-09-09 14:41:43
3) Joel Spolsky, reputation 25248, joined 2008-07-31 15:22:31
# (snip)
Site.users()方法的默认行为,实现/users端点(https://api.stackexchange.com/docs/users)按照信誉点降序返回用户列表。
有趣的是,Stack Overflow 的创始人之一并不是这个结果集中口碑最高的用户。我们想知道他是否有最早的注册日期。我们只需要向搜索中添加几个参数,如下所示:
$ python stack_search_user.py --name joel --sort creation --order asc
现在输出不同了,不出所料,Joel Spolsky是注册日期最早的 Joel:
0) Joel Spolsky, reputation 25248, joined 2008-07-31 15:22:31
1) Joel Lucsy, reputation 6003, joined 2008-08-07 13:58:41
2) Joel Meador, reputation 1923, joined 2008-08-19 17:34:45
3) JoelB, reputation 84, joined 2008-08-24 00:05:44
4) Joel Coehoorn, reputation 231567, joined 2008-08-26 14:24:14
# (snip)
本例中提出的ArgumentParser的使用利用了这样一个事实,即我们可以个性化各个参数的数据类型。通常,传递给add_argument()函数的type参数使用内置的数据类型(例如int或bool,但是在我们的例子中,我们可以更进一步,扩展这个行为来执行一些基本的验证。
我们面临的问题是传递到/users端点的sort和order参数只接受有限的一组字符串。check_sort_value()和check_order_value()功能只是确认给定值在接受值列表中。如果不是这种情况,这些功能将引发ArgumentTypeError异常,该异常将被ArgumentParser捕获并用于向用户显示消息。例如,使用以下命令:
$ python stack_search_user.py --name joel --sort FOOBAR --order asc
使用--sort参数将产生以下输出:
usage: stack_search_user.py [-h] [--name NAME] [--sort SORT] [--order ORDER]
[--n N]
stack_search_user.py: error: argument --sort: FOOBAR is an invalid sort value
在没有实现个性化数据类型的情况下,用错误的参数调用脚本将导致 API 返回一个错误,该错误将被 Py-StackExchange 客户端捕获,并显示为带有相关回溯的StackExchangeError:
Traceback (most recent call last):
File "stack_search_user.py", line 48, in <module>
# (long traceback)
stackexchange.core.StackExchangeError: 400 [bad_parameter]: sort
换句话说,个性化数据类型允许我们处理错误,并向用户显示更好的消息。
处理堆 StackExchange 数据转储
堆 StackExchange 网络还提供其数据的完整转储,可通过互联网档案库(https://archive.org/details/stackexchange)下载。数据在 7Z 可用,这是一种高压缩比的压缩数据格式(http://www.7-zip.org)。为了读取和提取这种格式,必须下载适用于 Windows 的 7-zip 实用程序,或者适用于 Linux/Unix 和 macOS 的其中一个端口。
在编写时,堆栈溢出的数据转储是作为单独的压缩文件提供的,每个文件代表其数据集中的一个实体或表。例如,stackoverflow.com-Posts.7z文件包含帖子表的转储(即问题和答案)。2016 年发布的该文件的第一个版本的大小约为 7.9 GB,未压缩时产生 39 GB 的文件(大约比压缩版本大五倍)。所有其他堆 StackExchange 网站都有一个小得多的数据转储,它们为所有的表提供一个文件。
比如影视爱好者就此话题进行问答的影视网站(http://movies.stackexchange.com的数据,可以下载为单个movies.stackexchange.com.7z文件。一旦解压缩,将生成八个文件(每个实体一个),如下表所示:
不同网站的不同转储的命名约定是相同的,例如,对于每个网站,website_4.com文件名应该包含帖子列表。
转储文件的内容以 XML 格式表示。文件的结构始终遵循以下模板:
<?xml version="1.0" encoding="utf-8"?>
<entity>
<row [attributes] />
<!-- more rows -->
</entity>
第一行是标准的 XML 声明。XML 文档的根元素是实体类型(例如,posts、users等)。然后,特定实体的每个条目由一个<row>元素表示,该元素有许多属性。例如,posts实体的一个小片段如下所示:
<?xml version="1.0" encoding="utf-8"?>
<posts>
<row Id="1" PostTypeId="1" ... />
<row Id="2" PostTypeId="2" ... />
<row Id="3" PostTypeId="1" ... />
<!-- more rows -->
</posts>
由于posts元素用于表示问题和答案,因此属性可能因行而异。
下表显示了用于表示问题的<row>元素的最重要属性的摘要:
类似地,下表显示了用于表示答案的<row>元素的主要属性列表:
在本书中,我们主要使用 JSON Lines 格式的数据,因为它提供了一种方便的表示方式。
下面提出的stack_xml2json.py脚本用于展示从数据转储的 XML 到更熟悉的 JSON Lines 格式的转换。还会执行一些基本的数据清理。
虽然 Python 对标准库附带的 XML 提供了相当好的支持,但需要考虑的一个有趣的包是lxml(http://lxml . de)。该库为低级 C 库提供了 Python 绑定: libxml2 和 libxslt ,它们将极致的性能和特性的完整性与典型的精心设计的 Python 库的易用性相结合。
唯一潜在的缺点是安装过程,以防 libxml2 和 libxslt 库不顺利,这是必需的依赖项。程序是通常的,使用pip,就像我们对所有先前安装的软件包所做的那样:
$ pip install lxml
文档附带了关于 C 库首选版本的详细讨论(http://lxml.de/installation.html),以及一些优化。根据平台和系统配置,有许多细节可能会导致安装出错。完整的故障排除超出了本书的范围,但是网络提供了大量关于这个主题的材料(堆栈溢出本身就是一个突出的信息来源)。
幸运的是,lxml 已经被设计成尽可能与 ElementTree 包(Python 标准库的一部分)兼容,所以安装 lxml 并不是遵循本节的关键。建议是尝试一下安装,如果事情变得太复杂,不用太担心就踢回去成为一个具体的选择。事实上,如果缺少 lxml,脚本将返回到 ElementTree:
# Chap06/stack_xml2json.py
import json
from argparse import ArgumentParser
from bs4 import BeautifulSoup
try:
from lxml import etree
except ImportError:
# lxml not installed, fall back to ElementTree
import xml.etree.ElementTree as etree
def get_parser():
parser = ArgumentParser()
parser.add_argument('--xml')
parser.add_argument('--json')
parser.add_argument('--clean-post',
default=False,
action='store_true')
return parser
def clean_post(doc):
try:
doc['Tags'] = doc['Tags'].replace('><', ' ')
doc['Tags'] = doc['Tags'].replace('<', '')
doc['Tags'] = doc['Tags'].replace('>', '')
except KeyError:
pass
soup = BeautifulSoup(doc['Body'], 'html.parser')
doc['Body'] = soup.get_text(" ", strip=True)
return doc
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
xmldoc = etree.parse(args.xml)
posts = xmldoc.getroot()
with open(args.json, 'w') as fout:
for row in posts:
doc = dict(row.attrib)
if args.clean_post:
doc = clean_post(doc)
fout.write("{}\n".format(json.dumps(doc)))
lxml 的导入包含在捕获ImportError的try/except块中。当我们试图导入 Python 路径中不存在的包/模块时,会引发这种类型的异常。
stack_xml2json.py脚本再次使用ArgumentParser捕获命令行参数。脚本可以使用可选的--clean-post标志,因此我们可以区分posts实体和所有其他实体,其中posts实体需要本节后面描述的一些特殊处理。
例如,要将电影和电视数据集的标签文件转换为 JSON,我们可以使用以下命令:
$ python stack_xml2json.py --xml movies.stackexchange_5.com --json
movies.tags.jsonl
这将创建movies.tags.jsonl文件,其中每个标签都表示为一行上的 JSON 文档。使用 Bash 提示符,我们可以简单地检查文件:
$ wc -l movies.tags.jsonl
前面的命令计算文件中的行数,从而计算标签数:
2218 movies.tags.jsonl
如果我们想检查第一个 JSON 文档,也就是文件的第一行,我们使用以下命令:
$ head -1 movies.tags.jsonl
这将产生以下输出:
{"Count": "108", "WikiPostId": "279", "Id": "1", "TagName": "comedy", "ExcerptPostId": "280"}
该脚本使用lxml.etree将 XML 文档解析为xmldoc变量,该变量以ElementTree实例的形式保存文档的树形表示。为了访问树中的特定对象,我们使用getroot()方法作为入口点,开始遍历行。该方法返回一个存储在posts变量中的Element对象。如前所述,数据转储中的每个文件都有类似的结构,根元素保存实体名称(例如,<tags>、<posts>等)。
为了访问给定实体的单个条目(即行),我们可以使用常规的for循环迭代根元素。这些行也是Element类的实例。从 XML 到 JSON 的转换需要将元素属性转储到字典中;这是通过简单地将row.attrib对象转换成dict来实现的,以便使其成为 JSON 可序列化的。该字典可以通过json.dumps()转储到输出文件中。
如前所述,posts实体需要特殊处理。当我们使用脚本将帖子转换为 JSON 时,我们需要额外的--clean-post参数:
$ python stack_xml2json.py --xml movies.stackexchange_4.com --json
movies.posts.jsonl --clean-post
该命令产生movies.posts.jsonl文件。附加标志调用clean_post()函数对包含单个帖子的字典执行一些数据清理。具体来说,需要一些清理的属性是Tags(针对问题)和Body(针对问题和答案)。
Tags属性是一个采用<tag1><tag2>...<tagN>格式的字符串,因此带角度的括号字符被用作各个标签的分隔符。通过去掉括号并在标签名之间添加一个空格,我们简化了以后检索标签名的过程。例如,一个电影问题可以被标记为喜剧和浪漫。在清理之前,Tags属性的值在这种情况下会是<comedy><romance>,清理之后会转化为comedy romance。替换件封装在捕获KeyError的try/except块中;当文档中不存在Tags键时,也就是我们在处理一个答案时(只有问题才有标签),就会引发这个异常。
下面的步骤包括使用美丽的汤从正文中提取文本。事实上,偶尔正文会包含一些段落格式的 HTML 代码,我们不需要分析问题的文本内容。《美丽的汤》中的get_text()方法将 HTML 代码剥离出来,只返回我们需要的文本。
问题标签的文本分类
这部分是关于监督学习。我们将为问题分配标签的问题定义为文本分类问题,并将其应用于来自 Stack Exchange 的问题数据集。
在介绍文本分类的细节之前,我们先来考虑一下影视 StackExchange 网站的以下问题(问题标题和正文已经合并):
《豪斯医生》有什么插曲,他雇了一个女人装死来忽悠团队?我记得一个(据说已经死了的)女人醒来,向豪斯击掌。这是哪一集的?”
上一个问题是关于热门电视剧《T4》中某一集的细节。如前所述,堆 StackExchange 上的问题用标签标记,目的是快速识别问题的主题。用户给这个问题分配的标签是house和identify-this-episode,第一个是对电视剧本身的引用,第二个描述问题的性质。有人可能会说,仅仅用一个标签来标记这个问题(也就是说,简单地使用house)就足以描述它的主题了。同时,我们观察到两个标签并不互斥,因此多个标签可以帮助更好地表示问题。
虽然前面描述的为文档分配标签的过程(目的是更好地理解文档的主题)在直觉上看起来很简单,但这是为类分配项目(真实对象、人、国家、概念等)这一长期存在的问题的一个特殊情况。分类作为一个研究领域横跨多个学科,从哲学到计算机科学到商业管理。在这本书里,分类是作为一个机器学习问题来处理的。
监督学习和文本分类
监督学习是从标记的训练数据中推断函数的机器学习领域。分类作为监督学习的一种特殊情况,其目的是根据类别已知的训练项目集,为新项目分配正确的类别(标签)。
分类的实际例子包括:
- 垃圾邮件过滤:判断新邮件是否为垃圾邮件
- 语言识别:自动检测文本的语言
- 体裁识别:自动检测文本的体裁/主题
这些例子描述了分类的不同变体:
- 垃圾邮件过滤任务是二进制分类的一种情况,因为只有两种可能的类别,垃圾邮件或非垃圾邮件,必须选择其中一种。这也是单标签分类的情况,因为给定的文档可以用一个且只有一个标签来描述。
- 语言识别有不同的方法。假设文档只用一种语言编写,语言识别也是一个单一标签的问题。从类的数量来看,可以作为二元分类问题来处理(例如,这个文档是不是用英文写的?),但它可能最好被描述为一个多类问题,也就是说,有多个大于 2 的类,并且文档可以被分配给其中的任何一个。
- 最后,体裁识别(或主题分类)通常被视为一个多类别和多标签的问题:潜在类别的数量大于两个,同一文档可以被分配到多个类别(如 House 、 M.D. 问题示例)。
作为监督学习的一种特殊情况,分类需要训练数据,即已经分配了正确标签的数据实例。分类器分两个阶段运行,如图 6.4所示,学习(或训练)步骤和预测步骤:
*
图 6.4:监督学习的一般框架
在学习阶段,训练数据被提供给机器学习算法。因此,算法的输入是一系列对(item, labels),其中已知给定项目的正确标签。这个过程构建了分类器模型,然后用它来预测新的不可见数据的标签。在这个阶段,输入是一个新项目,输出是算法分配给该项目的标签。
在建立分类模型的过程中,机器学习算法为了学习如何对文档进行分类,会关注一些元素。
在机器学习的背景下,表示文档(更一般地说,对象)的一种常见方式是使用 n 维向量,向量的每个元素被称为特征。以一种信息丰富的方式从原始数据构建这样的向量的过程通常被称为特征提取或特征工程(当过程中涉及专家知识时,后一个短语被更频繁地使用)。
从实用的角度来看,选择正确的特征和正确的方式来表示它们可以对机器学习算法的性能产生巨大的影响。通过使用一组明显的特性,通常可以获得相当不错的性能。一个迭代的试错过程,其中不同的方法按照一些直觉被测试,通常是在开发的早期阶段发生的事情。在文本分析的上下文中,出发点通常是单词包方法,其中考虑了单词频率。
为了阐明从文档到矢量的发展,让我们考虑下面的例子,其中两个文档的样本语料库以原始和矢量的形式显示:
| **原始文件** | | | 文件 1 | 约翰是一名程序员。 | | 文件 2 | 约翰喜欢编码。他也喜欢 Python。 | | **矢量化文档** | | | 文件 1 | [1, 1, 1, 1, 0, 0, 0, 0, 0] | | 文件 2 | [1, 0, 0, 0, 2, 1, 1, 1, 1] | | 特征 | [约翰,是,程序员,喜欢编码,他,也是,Python] |示例中每个文档的矢量化版本包含九个元素。这是用于表示向量的维数,即整个语料库中不同单词的数量。虽然这种表示没有真正考虑到原始文档中的语法和单词顺序,但是向量中元素的顺序很重要。例如,每个向量中的第一个项目代表单词 John ,第二个项目是,第五个项目喜欢等等。
每个单词在这里用它的原始频率来表示,也就是说,用它的计数来表示。其他常见的方法包括二进制表示(所有非零计数都设置为 1,因此它仅表示文档中的单词存在),或者一些更复杂的统计数据,例如 TF-IDF 的版本。
从高级角度来看,从文档创建向量的任务看起来很简单,但它涉及一些不平凡的操作,包括文本的标记化和规范化。
幸运的是, scikit-learn 拯救了我们,因为它提供了一些工具来用最少的努力产生文档向量。CountVectorizer和TfidfVectorizer类是我们正在研究的器具。它们都属于feature_extraction.text子包,因此可以使用以下代码导入:
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.feature_extraction.text import TfidfVectorizer
具体来说,CountVectorizer处理原始频率和二进制表示(默认情况下,binary属性设置为False,而TfidfVectorizer将原始频率转换为 TF、TF-IDF 或归一化 TF-IDF。两个向量器的行为都可能受到传递给构造函数的许多参数的影响。
这是一个使用TfidfVectorizer类的例子:
>>> from sklearn.feature_extraction.text import TfidfVectorizer
>>> corpus = [
... "Peter is a programmer.",
... "Peter likes coding. He also likes Python"
... ]
>>> vectorizer = TfidfVectorizer()
>>> vectors = vectorizer.fit_transform(corpus)
>>> vectors
<2x8 sparse matrix of type '<class 'numpy.float64'>'
with 9 stored elements in Compressed Sparse Row format>
>>> vectors[0]
<1x8 sparse matrix of type '<class 'numpy.float64'>'
with 3 stored elements in Compressed Sparse Row format>
>>> vectors[1]
<1x8 sparse matrix of type '<class 'numpy.float64'>'
with 6 stored elements in Compressed Sparse Row format>
>>> print(vectors)
(0, 6) 0.631667201738
(0, 3) 0.631667201738
(0, 5) 0.449436416524
(1, 7) 0.342871259411
(1, 0) 0.342871259411
(1, 2) 0.342871259411
(1, 1) 0.342871259411
(1, 4) 0.685742518822
(1, 5) 0.243955725
>>> vectorizer.get_feature_names()
['also', 'coding', 'he', 'is', 'likes', 'peter', 'programmer', 'python']
我们可以观察到,默认情况下,矢量器会按照预期执行标记化,但是单词a不在特征列表中(事实上,矢量是八维的,而不是前面介绍的九维的)。
这是因为默认标记化是使用正则表达式执行的,该表达式捕获两个或更多字母数字字符的所有标记,将标点符号视为类似于空格的标记分隔符。默认情况下,标记被规范化为小写。
影响TfidfVectorizer的一些有趣属性如下:
tokenizer:这是一个覆盖标记化步骤的可调用函数。ngram_range:这是一个元组(min_n, max_n)来考虑 n-grams,而不仅仅是单个单词作为代币。stop_words:这是要移除的停止词的明确列表(默认情况下,不执行停止词移除)。lowercase:这是一个布尔值,默认为True。min_df:这定义了最小文档频率截止阈值;它可以是一个 int(例如,只有当它出现在 5 个或更多文档中时,5 才会包含一个标记)或一个在[0.0,1.0]范围内的 float 来表示文档的比例。默认设置为1,所以没有阈值。max_df:定义最大文档频率截止阈值;像min_df一样,可以是 int,也可以是 float。默认情况下,它被设置为1.0(因此是 100%的文档),这意味着没有阈值。binary:这是一个布尔值,默认为False。当True时,所有非零项计数都被设置为1,这意味着 TF-IDF 的 TF 分量是二进制的,但最终值仍然受 IDF 的影响。use_idf:这是一个布尔值,默认为True。当False时,不启用 IDF 加权。smooth_idf:这是一个布尔值,默认为True。启用文件频率的加一平滑,防止除以零。sublinear_tf:这是一个布尔值,默认为False。True时,应用亚线性 TF 缩放,即使用 1+log(TF) 替换 TF。
以下部分描述了 scikit-learn 提供的三种常用分类方法。
分类算法
在本节中,我们将简要概述可用于文本分类的三种常见机器学习方法:朴素贝叶斯 ( NB )、k-最近邻( k-NN )和支持向量机 ( SVM )。目的不是要深入挖掘算法的细节,而是简单地总结一些 scikit-learn 中已经可用并且可以直接使用的工具。值得看看 scikit-learn 文档,因为该工具支持丰富的算法集(对于监督学习算法:http://scikit-learn.org/stable/supervised_learning.html)。
重要的是要记住,不同的算法可以用不同的数据显示不同的性能水平,因此在开发分类系统的过程中,测试不同的实现是明智的。scikit-learn 提供的界面让我们的生活变得相当容易,因为通常我们可以通过简单地改变一两行代码来交换算法。
朴素贝叶斯
我们从朴素贝叶斯开始,这是一个基于贝叶斯定理(https://en.wikipedia.org/wiki/Bayes%27_theorem)的分类器家族,具有特征之间的(朴素)独立性假设。
贝叶斯定理(也称为贝叶斯定律或贝叶斯规则)描述了事件的条件概率:

等式左侧的项是观察事件 A 的概率,假设我们已经观察到事件 B 。
将这个等式放在上下文中,让我们考虑垃圾邮件过滤的例子:

该等式显示了如何计算将垃圾邮件标签分配给包含单词“钱”的(新的、未看到的)文档的概率。就条件概率而言,这个问题经常被表述为:假设我们观察到单词“钱”,给这个文档分配标签 Spam 的概率是多少?
使用训练数据,可以在训练步骤期间计算等式右侧的三个概率。
由于分类器不使用单个单词/特征来给文档分配特定的类,让我们考虑以下一般情况:

最后一个等式描述了将一个 c 类分配给一个观察到的文档 d 的概率。根据独立性假设,该文件被描述为一系列独立的术语, t 。由于特征(以及它们的概率)是独立的,概率 P(d|c) 可以写成每个术语 P(t|c) 的乘积 t ,在文档中为 d 。
细心的读者可能已经意识到,最后一个等式的最后一部分,我们将文档表示为一系列独立的术语,没有分母。这只是一个优化,因为 P(d) 在不同的班级中是相同的,因此不需要为了计算和比较单个班级的分数而计算它。因此,等式的最后一部分使用了一个正比的符号,而不是等价的符号。
就数学背景而言,这已经够了,因为我们不想忽略这本书的实际方面。鼓励感兴趣的读者挖掘更深层次的细节(参考塞巴斯蒂安·拉什卡、帕克特出版的 Python 机器学习)。
在 scikit-learn 中,naive_bayes子包支持朴素贝叶斯方法,例如,文本分类中常用的多项式 NB 分类器由MultinomialNB类实现:
from sklearn.naive_bayes import MultinomialNB
k-最近邻
k-NN 算法(https://en.wikipedia.org/wiki/K-nearest_neighbors_algorithm)是一种用于分类和回归的方法。算法的输入由特征空间中最接近的k训练样本组成,即最相似的k文档。该算法的输出是通过其邻居的多数投票获得的分配给特定文档的类成员资格。
在训练阶段,表示训练集中文档的多维向量简单地与它们的标签一起存储。在预测阶段,未看到的文档用于查询其k最相似的文档,其中k是用户定义的常数。在这些k文档中最常见的类(最近的邻居)被分配给看不见的文档。
k-NN 属于非参数方法的类别,因为算法的决策直接由数据(即文档向量)驱动,而不是由在数据上估计的参数驱动(如朴素贝叶斯的情况,其中估计的概率用于分配类别)。
算法背后的直觉相当简单,但仍有两个未解决的问题:
- 如何计算向量之间的距离?
- 如何选择最佳
k?
第一个问题有不同的有效答案。常见的选择是欧几里得距离(https://en.wikipedia.org/wiki/Euclidean_distance)。scikit-learn 中的默认实现使用闵可夫斯基距离,当使用 scikit-learn 中的预定义参数时,该距离相当于欧几里德距离。
相反,第二个问题的答案有点复杂,它可能会因数据而异。选择小的k意味着算法受噪声影响更大。为k选择一个大值会使算法的计算成本更高。一种常见的方法是简单地选择特征数量的平方根n,即k = sqrt(n);然而,需要进行实验。
分类 k-NN 的实现可在 scikit-learn 的neighbors子包中获得,如下所示:
from sklearn.neighbors import KneighborsClassifier
KneighborsClassifier类接受许多参数,包括k和distance。有关不同距离度量的可用选项的详细信息,请参见sklearn.neighbors.DistanceMetric课程的文档。
支持向量机
支持向量机(SVM)(https://en.wikipedia.org/wiki/Support_vector_machine)是用于分类和回归的监督学习模型和相关算法。
SVM 应用了一种二元分类方法,这意味着看不见的文档被分配到两个可能的类别之一。该方法可以扩展到多类分类,或者使用一对所有方法,其中给定的类与一个包罗万象的类进行比较,或者使用一对一方法,其中考虑所有可能的对或类。在一对全方法中,建立n分类器,其中n是类的总数。具有最高输出功能的类是分配给文档的类。在一对一方法中,每个分类器为所选类增加一个计数器。最后,得票最多的班级被分配到文档。
众所周知,SVM 分类器家族在文本分类任务中是有效的,其中数据通常在高维空间中表示。在维数大于样本数的情况下,分类器仍然可以有效。如果特征的数量远大于样本的数量(也就是说,训练集中的文档很少),该方法可能会失去其有效性。
与其他算法一样,scikit-learn 中提供了一个实现(通过svm子包),如下所示:
from sklearn.svm import LinearSVC
LinearSVC类实现了线性内核,这是内核函数的选项之一。粗略地说,核函数可以看作是一个用来计算一对样本之间相似度的相似度函数。scikit-learn 库提供了通用内核,也可以采用自定义实现。可以用kernel='linear'参数实例化SVC类;虽然这个分类器也是基于线性内核的,但是引擎盖下的实现是不同的,因为SVC是基于libSVM,一个 SVM 的通用库,而LinearSVC是基于liblinear,一个为线性内核优化的更高效的实现。
评价
在讨论了不同的分类算法之后,本节试图解决一个有趣的问题:我们如何选择最佳算法?
在分类的上下文中,有四种不同的类别来定义分类器的结果:
- 该单据属于类 C ,系统将其分配给类 C ( 真正,或 TP
- 该单据不属于类 C ,但系统将其分配给类 C ( 假阳性,或 FP
- 该单据属于类 C ,但系统没有将其分配给类 C ( 假阴性,或 FN
- 该单据不属于类 C ,系统不将其分配给类 C ( 真负,或 TN
这些结果通常用一个叫做混淆矩阵的表格来表示。对于二进制分类,混淆矩阵如下所示:
| | **预测标签:C** | **预测标签:不是 C** | | **正确标签:C** | 东帝汶的网络域名代号 | 【数学】函数 | | **正确标签:不是 C** | 冰点 | 长吨 |混淆矩阵可以扩展到多类分类的情况。一旦我们得到了测试集中所有样本的结果,我们就可以计算不同的性能指标。其中包括:
- 准确率:分类器多久正确一次?(总磷+总氮)/总计
- 误分类率:分类器多长时间错一次? (FP+FN) /合计
- 精度:给一个类 C 赋值时,分类器多久正确一次? TP /丙类预测数
- 召回:在 C 中,哪些部分的文件被正确识别?TP/C 中的标签数量
所有这些指标都提供了一个介于 0 和 1 之间的数字,可以解释为一个比例。精度和召回率通常被合并成一个值,称为调和平均值(也称为 F-score 或 F1)。
在多类别分类的背景下,不同类别的 F 分数可以用不同的方式进行平均。通常,这些方法被称为微观 F1,其中每个决策在平均值中被赋予相等的权重,以及宏观 F1,其中每个类在平均值中被赋予相等的权重(自动文本分类中的机器学习、法布里吉奥·塞巴斯蒂安尼、 2002 )。
到目前为止,我们在评估方面所描述的假设是,训练数据和测试数据(我们运行评估所针对的数据集)之间存在明显的分离。一些著名的数据集带有关于训练/测试分割的清晰注释,因此研究人员可以复制结果。有时,这种分裂并不明显,所以我们需要保留一部分带注释的数据进行测试。小的测试集意味着评估可能不准确,而大的测试集意味着训练阶段的数据较少。
这个问题的一个可能的解决方案叫做交叉验证,最常见的方法叫做 k 重验证。其思想是每次使用不同的训练/测试分割重复评估多次,然后报告汇总分数(例如,平均值)。
例如,通过十倍验证,我们可以将数据集分成十个不同的子集。迭代地,我们将选择这些折叠中的一个作为测试集,而剩余的折叠被合并到训练集中。一旦评估在单个文件夹上运行,我们将把结果放在一边,继续下一个迭代。第二个折叠现在成为测试集,而其他九个折叠用作训练数据。第 10 次迭代后,我们可以对所有的分数进行平均,得到一个单一的结果。k 折叠的常见方法有十倍(90/10 列车/测试分割)和五倍(80/20 列车/测试分割)。使用交叉验证的主要优点是,即使对于小数据集,我们对分数准确性的信心也得到提高。
对 StackExchange 数据进行文本分类
在对分类、相关算法和评估进行了长时间的介绍之后,本节将我们所讨论的内容应用到电影和电视网站的问题集中。
当我们使用标记名来表示类时,我们将准备数据集,以避免太罕见的类,因此很难捕获。假设我们将阈值设置为 10,也就是说,出现不到十次的标签将被忽略。这个阈值是任意的,我们可以用不同的数字进行实验。此外,帖子文件包含问题和答案,但这里我们只考虑问题(可从PostTypeId="1"属性中识别),因此我们可以丢弃所有答案。
以下脚本读取带有标签和帖子的 JSON Lines 文件,并生成一个 JSON Lines 文件,该文件只包含根据我们的阈值足够频繁的标签:
# Chap06/stack_classification_prepare_dataset.py
import os
import json
from argparse import ArgumentParser
def get_parser():
parser = ArgumentParser()
parser.add_argument('--posts-file')
parser.add_argument('--tags-file')
parser.add_argument('--output')
parser.add_argument('--min-df', type=int, default=10)
return parser
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
valid_tags = []
with open(args.tags_file, 'r') as f:
for line in f:
tag = json.loads(line)
if int(tag['Count']) >= args.min_df:
valid_tags.append(tag['TagName'])
with open(args.posts_file, 'r') as fin, open(args.output, 'w') as fout:
for line in fin:
doc = json.loads(line)
if doc['PostTypeId'] == '1':
doc_tags = doc['Tags'].split(' ')
tags_to_store = [tag for tag in doc_tags
if tag in valid_tags]
if tags_to_store:
doc['Tags'] = ' '.join(tags_to_store)
fout.write("{}\n".format(json.dumps(doc)))
该脚本假设我们已经如前所述将标签和帖子文件从 XML 转换为 JSON 行。
我们可以从命令行运行脚本,如下所示:
$ python stack_classification_prepare_dataset.py \
--tags-file movies.tags.jsonl \
--posts-file movies.posts.jsonl \
--output movies.questions4classification.jsonl \
--min-df 10
脚本movies.questions4classification.jsonl生成的文件只包含标签频繁的问题。我们可以看到与原始帖子文件在大小上的差异:
$ wc -l movies.posts.jsonl
28146 movies.posts.jsonl
$ wc -l movies.questions4classification.jsonl
8413 movies.questions4classification.jsonl
我们现在准备在电影和电视数据集上进行实验。在关于评估的讨论之后,我们将执行十倍交叉验证,这意味着问题语料库将被分成十个相等的折叠,分类任务将运行十次,使用 90%的数据进行训练,剩余的 10%进行测试,每次使用不同的折叠进行测试:
# Chap06/stack_classification_predict_tags.py
import json
from argparse import ArgumentParser
from nltk.corpus import stopwords
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.svm import LinearSVC
from sklearn.multiclass import OneVsRestClassifier
from sklearn.metrics import f1_score
from sklearn.preprocessing import MultiLabelBinarizer
from sklearn.cross_validation import cross_val_score
import numpy as np
def get_parser():
parser = ArgumentParser()
parser.add_argument('--questions')
parser.add_argument('--max-df', default=1.0, type=float)
parser.add_argument('--min-df', default=1, type=int)
return parser
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
stop_list = stopwords.words('english')
all_questions = []
all_labels = []
with open(args.questions, 'r') as f:
for line in f:
doc = json.loads(line)
question = "{} {}".format(doc['Title'], doc['Body'])
all_questions.append(question)
all_labels.append(doc['Tags'].split(' '))
vectorizer = TfidfVectorizer(min_df=args.min_df,
stop_words=stop_list,
max_df=args.max_df)
X = vectorizer.fit_transform(all_questions)
mlb = MultiLabelBinarizer()
y = mlb.fit_transform(all_labels)
classifier = OneVsRestClassifier(LinearSVC())
scores = cross_val_score(classifier,
X,
y=y,
cv=10,
scoring='f1_micro')
print("Average F1: {}".format(np.mean(scores)))
stack_classification_predict_tags.py脚本通过ArgumentParser接受三个参数。第一个,--questions,是唯一强制的,因为我们需要指定带有问题的.jsonl文件。另外两个参数--min-df和--max-df可以用来影响分类器在构建特征向量方面的行为。这两个参数的默认值不影响特征提取的过程,因为限制被设置为min_df=1和max_df=1.0,这意味着所有的特征(单词)都将被包括在内。
我们可以使用以下命令运行脚本:
$ python stack_classification_predict_tags.py \
--questions movies.questions4classification.jsonl
这将产生以下输出:
Average F1: 0.6271980062798452
深入脚本的细节,在从命令行解析参数之后,我们将使用 NLTK 将停止词列表加载到stop_list变量中。这是常用英语单词(大约 130 个单词,包括冠词、连词、代词等)的默认列表。下一步是读取输入文件,将每个 JSON 文档加载到内存中,并创建将被输入到分类器中的数据结构。特别是,我们将通过连接每个问题的标题和正文来创建all_questions变量中所有文档的列表。同时,我们将跟踪all_labels变量中的原始标签(即分类器的标签/类)。
对于特征提取步骤,我们将创建TfidfVectorizer的实例,将停止词列表以及最小文档频率和最大文档频率作为参数传递给构造器。向量器将为分类器创建特征向量,从原始文本开始,将每个单词转换为对应于给定单词的 TF-IDF 的数值。矢量中不包括停止字。同样,频率超出所需范围的单词将被删除。
从原始文本到矢量的转换通过矢量器的fit_transform()方法执行。
由于我们正在执行的任务是多标签的,为了正确地将所有标签映射成二进制向量,我们需要执行一个额外的步骤。这是必需的,因为分类器需要一个二进制向量列表,而不是一个类名列表作为输入。为了理解MultiLabelBinarizer如何执行这个转换,我们可以检查以下代码片段:
>>> from sklearn.preprocessing import MultiLabelBinarizer
>>> mlb = MultiLabelBinarizer()
>>> labels = [['house', 'drama'], ['star-wars'], ['drama', 'uk']]
>>> y = mlb.fit_transform(labels)
>>> y
array([[1, 1, 0, 0],
[0, 0, 1, 0],
[1, 0, 0, 1]])
>>> mlb.classes_
array(['drama', 'house', 'star-wars', 'uk'], dtype=object)
此时,语料库和标签的格式可以被分类器理解。关于命名的一个注意事项:语料库使用大写X,标签使用小写y简单来说就是机器学习中常见的约定,这在机器学习教材中经常可以找到。如果您不喜欢代码中的单字母变量,当然可以重命名它们。
对于分类器,我们将选择LinearSVC,具有线性核的支持向量机分类器。鼓励感兴趣的读者尝试各种分类器。由于任务是多标签的,我们将选择由OneVsRestClassifier类实现的一对一方法,该方法将实际分类器的实例作为第一个参数,并使用它来执行前面部分中描述的所有分类任务。
最后一步是分类本身。如前所述,当我们想要执行交叉验证时,我们不直接使用分类器对象,而是将其与矢量化数据、标签和一些其他参数一起传递给cross_val_score()函数。该函数的目的是根据用cv=10参数声明的 k 折叠策略迭代数据(这意味着我们使用十次折叠),执行分类,并最终显示所选评估指标的分数(在示例中,我们使用微平均 F1 分数)。
交叉验证函数的返回值是分数的 NumPy 数组,每个折叠一个。通过使用numpy.mean()函数,我们可以汇总这些分数并显示它们的算术平均值。
值得注意的是,最终的分数,大概是0.62,本身并没有好坏。它告诉我们,分类系统还远远不够完善,但在现阶段,我们还没有一个清晰的画面来说明是否有提高性能的空间,以及万一这个空间在哪里。分数没有告诉我们分类器是否表现不好或者任务是否太难达到更好的表现。聚合分数是比较不同分类器行为的一种方式,或者是使用不同参数的同一分类器的不同运行。**
**为了提供一个额外的例子,我们可以简单地重新运行脚本,指定一个额外的参数来过滤掉所有非常罕见的特性:
$ python stack_classification_predict_tags.py \
--questions movies.questions4classification.jsonl \
--min-df 5
通过将最小文档频率设置为5,一个简单地在特征频率分布中切割长尾的任意数字,我们将获得以下输出:
Average F1: 0.635184845312832
这个例子表明,配置中的一个简单的调整可以产生不同的(幸运的是,在这种情况下更好)结果。
在实时应用中嵌入分类器
上一节已经用学术思维方式处理了分类问题:从一组预先标记的文档中,我们运行了一个批处理实验,执行交叉验证以获得一个评估指标。如果我们只想用不同的分类器进行实验,调整特征提取过程,并了解哪种分类器和哪种配置最适合给定的数据集,这当然非常有趣。
本节向前推进了一步,讨论了将分类器(或者更一般地说,机器学习模型)嵌入到可以与用户实时交互的应用程序中的一些简单步骤。这种应用程序的流行例子包括搜索引擎、推荐系统、垃圾邮件过滤器和许多其他智能应用程序,我们可能每天都在使用它们,而没有意识到它们是如何由机器学习技术驱动的。
我们将在本节中构建的应用程序执行以下步骤:
- 训练分类器(学习步骤,离线执行)
- 将分类器保存在磁盘上,以便可以从实时应用程序中加载
- 将分类器嵌入到与用户交互的应用程序中(预测步骤,实时执行)
前两个步骤由以下脚本执行:
# Chap06/stack_classification_save_model.py
import json
import pickle
from datetime import datetime
from argparse import ArgumentParser
from nltk.corpus import stopwords
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.svm import LinearSVC
from sklearn.multiclass import OneVsRestClassifier
from sklearn.preprocessing import MultiLabelBinarizer
def get_parser():
parser = ArgumentParser()
parser.add_argument('--questions')
parser.add_argument('--output')
parser.add_argument('--max-df', default=1.0, type=float)
parser.add_argument('--min-df', default=1, type=int)
return parser
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
stop_list = stopwords.words('english')
all_questions = []
all_labels = []
with open(args.questions, 'r') as f:
for line in f:
doc = json.loads(line)
question = "{} {}".format(doc['Title'], doc['Body'])
all_questions.append(question)
all_labels.append(doc['Tags'].split(' '))
vectorizer = TfidfVectorizer(min_df=args.min_df,
stop_words=stop_list,
max_df=args.max_df)
X = vectorizer.fit_transform(all_questions)
mlb = MultiLabelBinarizer()
y = mlb.fit_transform(all_labels)
classifier = OneVsRestClassifier(LinearSVC())
classifier.fit(X, y)
model_to_save = {
'classifier': classifier,
'vectorizer': vectorizer,
'mlb': mlb,
'created_at': datetime.today().isoformat()
}
with open(args.output, 'wb') as f:
pickle.dump(model_to_save, f)
该代码与上一节中执行的离线分类非常相似。主要区别在于,在训练分类器之后,我们不使用它来预测任何事情,而是将其存储在文件中。
输出文件在wb模式下打开,这意味着文件指针将对该文件具有写访问权限,并且该文件将是二进制的,而不是纯文本的。pickle 包处理复杂 Python 对象到字节码的序列化,该字节码可以转储到文件中。
该模块有一个非常类似于json包的接口,具有简单的load()和dump()函数,用于执行 Python 对象和文件之间的转换。我们不仅需要分类器,还需要向量器和MultiLabelBinarizer的实例。为此,我们使用model_to_save变量,一个 Python 字典来包含所有必要的数据。本词典的created_at键包含 ISO 8601 格式的当前系统日期,以防我们需要跟踪分类器的不同版本,或者我们有新数据并决定重新训练它。
我们可以使用以下命令执行该脚本:
$ python stack_classification_save_model.py \
--questions movies.questions4classification.jsonl \
--min-df 5 \
--output questions-svm-classifier.pickle
过了一会儿,创建了questions-svm-classifier.pickle文件,它可以被其他应用程序使用。
为了简单起见,我们将从命令行实现一个基本接口。同样的方法可以很容易地与 web 应用程序集成(例如,使用 Flask ,就像我们在上一章中所做的那样),以便通过 web 界面向用户提供预测功能。
stack_classification_user_input.py脚本实现面向用户的应用:
# Chap06/stack_classification_user_input.py
import sys
import json
import pickle
from argparse import ArgumentParser
def get_parser():
parser = ArgumentParser()
parser.add_argument('--model')
return parser
def exit():
print("Goodbye.")
sys.exit()
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
with open(args.model, 'rb') as f:
model = pickle.load(f)
classifier = model['classifier']
vectorizer = model['vectorizer']
mlb = model['mlb']
while True:
print("Type your question, or type "exit" to quit.")
user_input = input('> ')
if user_input == 'exit':
exit()
else:
X = vectorizer.transform([user_input])
print("Question: {}".format(user_input))
prediction = classifier.predict(X)
labels = mlb.inverse_transform(prediction)[0]
labels = ', '.join(labels)
if labels:
print("Predicted labels: {}".format(labels))
else:
print("No label available for this question")
脚本使用ArgumentParser读取命令行参数--model,该参数用于传递之前序列化的 pickle 文件。
该脚本使用前面描述的 pickle 接口加载 pickle 文件。该文件是一个二进制文件,因此它是在rb模式下打开的(r表示读取,而b表示二进制)。这允许我们访问之前生成的分类器、矢量器和多标签二进制器。
然后,应用程序进入一个无限的while True循环,用于不断地向用户请求一些输入。如果我们运行以下命令:
$ python stack_classification_user_input.py \
--model questions-svm-classifier.pickle
事实上,我们得到了以下提示:
Type your question, or type "exit" to quit.
>
一旦我们输入一些输入,input()函数会将其读入user_input变量。这用于检查用户是否键入了单词exit,这会导致应用程序退出。否则,将对用户键入的任何文本进行分类。
预测步骤需要将输入转换成特征向量。这是矢量器执行的工作。需要注意的是vectorizer.transform()函数以列表(或可迭代)作为参数,而不是直接以user_input字符串作为参数。一旦输入被矢量化,就可以将其传递给分类器的predict()功能,并执行分类。
然后,分类的结果以可读的格式返回给用户。事实上,这个阶段的预测是一个 NumPy 数组,它代表有效标签列表上的二进制掩码。MultiLabelBinarizer负责在训练阶段产生该掩码,具有使用inverse_transform()功能将二进制掩码映射回原始标签的能力。
这个调用的结果是一个元组列表。准确地说,因为我们只传递了一个要分类的文档,所以列表只有一个元组(因此在逆变换期间可以访问[0]索引)。
在向用户显示结果之前,标签元组被连接成一个字符串,除非是空的(在这种情况下,会显示一个自定义消息)。
现在我们可以看到分类器在起作用:
$ python stack_classification_user_input.py \
--model questions-svm-classifier.pickle
Type your question, or type "exit" to quit.
> What's up with Gandalf and Frodo lately? They haven't been in the Shire for a while...
Question: What's up with Gandalf and Frodo lately? They haven't been in the Shire for a while...
Predicted labels: plot-explanation, the-lord-of-the-rings
Type your question, or type "exit" to quit.
> What's the title of the latest Star Wars movie?
Question: What's the title of the latest Star Wars movie?
Predicted labels: identify-this-movie, star-wars, title
Type your question, or type "exit" to quit.
> How old is Tom Cruise?
Question: How old is Tom Cruise?
No label available for this question
Type your question, or type "exit" to quit.
> How old is Brad Pitt?
Question: How old is Brad Pitt?
Predicted labels: character, plot-explanation
Type your question, or type "exit" to quit.
> exit
Goodbye.
调用脚本后,我们被鼓励输入我们的问题。突出显示的代码显示了用户输入与应用程序响应混合的示例。
非常有趣的是,分类器可以从《指环王》(佛罗多、甘道夫和夏尔)中提取一些名字,并在没有提到标题的情况下正确标记问题。出于某种原因,这个问题也被解释为对情节解释的请求。更准确地说,问题的特征使得分类器将该文档标记为属于情节解释类。
第二个输入包含短语星球大战,所以这个标签的识别正如预期的那样简单。问题还要求一个标题,所以有两个额外的类名,title和identify-this-movie,与问题相关联。到目前为止,这个分类似乎相当准确。
通过最后两个问题,我们可以看到分类器并不总是完美的。当问及汤姆·克鲁斯时,分类器不会将任何标签与问题相关联。这很可能是由于训练数据中缺少有趣的特征,即单词汤姆和克鲁斯。布拉德·皮特稍微好一点,他的问题与character(有点夸张)和plot-explanation(完全不正确)标签有关。
该示例以用户输入exit结束,这将导致应用程序退出。
前面几节的实验表明,分类器的精度远非完美。通过这个实时应用程序,我们已经测试了第一手的分类器功能。虽然它很容易被一个模糊的输入,或者仅仅是看不见的单词所欺骗,但是这一部分的核心信息是这样一个事实,即我们不局限于像学术一样的实验来测试我们的模型。将机器学习模型集成到适当的面向用户的应用程序中几乎是一项简单的任务,尤其是使用 Python 优雅而简单的界面。
实时分类的应用数不胜数:
- 情感分析,自动识别用户在评论中表达的观点并在线展示
- 垃圾邮件过滤阻止或过滤潜在的垃圾邮件
- 亵渎检测,用于识别在论坛上发布令人讨厌的消息的用户,并自动保留他们的消息
邀请感兴趣的读者重复使用他们在第 5 章、谷歌+ 上的主题分析中所学的关于 Flask 的知识,并将其扩展到这个上下文中,以便构建一个简单的网络应用程序,该应用程序加载一个机器学习模型,并使用该模型通过网络界面实时对用户问题进行分类。
总结
本章介绍了问答应用程序,这是网络最流行的用途之一。堆 StackExchange 网络和面向程序员的堆栈溢出网站的流行是由社区策划的高质量材料推动的。在本章中,我们讨论了如何与堆 StackExchange 应用编程接口交互,以及如何使用堆 StackExchange 的数据转储从堆 StackExchange 访问整个数据集。
本章的第二部分介绍了分类的任务和相关的监督机器学习方法。堆 StackExchange 中标记数据的可用性为构建预测模型提供了机会。本章中提出的用例是预测标签将被用作问题标签,但是这些技术可以应用于各种应用。这一章的最后一部分扩展了讨论,展示了机器学习模型如何容易地集成到面向用户的实时应用程序中。
下一章集中讨论博客,特别是自然语言处理技术的应用。***
七、博客、RSS、维基百科和自然语言处理
本章重点介绍自然语言处理领域自然语言处理 ( 自然语言处理)。在深入了解 NLP 的细节之前,我们将分析一些从 Web 下载文本数据的选项。
在本章中,我们将讨论以下主题:
- 如何与 WordPress.com 和博主互动
- 网页提要格式(RSS 和 Atom)及其使用
- 如何以 JSON 格式存储来自博客的数据
- 如何与维基百科应用编程接口交互来搜索实体信息
- 自然语言处理的核心概念,特别是关于文本预处理
- 如何处理文本数据以识别文本中提到的实体
博客和 NLP
博客(博客的简称)如今是网络的重要组成部分,也是一个极具吸引力的社交媒体平台。博客被公司、专业人士和业余爱好者用来接触观众,推广产品和服务,或者只是讨论一个有趣的话题。由于网络出版工具和服务的丰富,使得非技术用户很容易发布他们的内容,建立个人博客只需几分钟。
从数据挖掘者的角度来看,博客是实践文本挖掘的完美平台。本章主要关注两个主题:如何从博客中获取文本数据,以及如何对这些数据应用自然语言处理。
虽然自然语言处理是一个复杂的领域,不仅仅是一本入门的书,我们正在采取务实的方法,用实际的例子介绍基本理论。
从博客和网站获取数据
考虑到有大量有趣文章的网站,找到文本数据来挖掘应该不是一个大问题。手动地,一次保存一篇文章显然不能很好地扩展,所以在这一节中,我们将讨论一些机会来自动化从网站获取数据的过程。
首先,我们将讨论两个流行的免费博客服务,WordPress.com 和博主,它们提供了一个方便的应用编程接口来与他们的平台交互。其次,我们将介绍 RSS 和 Atom 网络标准,许多博客和新闻出版商使用这些标准以一种易于电脑阅读的格式广播他们的内容。最后,我们将简要讨论更多可能的选择,例如连接到维基百科,或者在没有其他选择的情况下,将网页抓取作为最后手段。
使用 WordPress.com 空气污染指数
WordPress 是一家博客和网络托管提供商,由开源 WordPress 软件提供支持。该服务为注册用户提供免费博客托管,以及付费升级和额外付费服务。对于只想阅读和评论博客文章的用户,不需要注册,除非博客所有者另有规定(这意味着博客可以被其所有者标记为私有)。
型式
WordPress.com 和 WordPress.org
新的 WordPress 用户经常被这种差异所迷惑。开源 WordPress 软件是通过 WordPress 开发和发布的;您可以下载软件的副本,并将其安装在自己的服务器上。另一方面,WordPress.com 是一家主机提供商,为不想自己安装软件和处理服务器配置方面问题的用户提供现成的解决方案。WordPress.com 博客的域名通常是blog-name.wordpress.com形式,除非博客所有者为域名付费。
本节中讨论的 API 是由 WordPress.com 提供的,因此我们可以通过它访问的数据是由该提供商托管的数据。
Python 客户端对 WordPress.com 应用编程接口的支持并不是特别完善。这给我们留下了直接与 API 交互的选项(https://developer.wordpress.com/docs/api),或者换句话说,编写我们自己的客户端。
幸运的是,这项任务所需的努力并不是压倒性的,而请求 Python 库通过提供一个简单的接口来处理 HTTP 端,让我们的生活稍微轻松了一点。我们将使用它来设置对 WordPress.com API 的一系列调用,目的是从特定的域下载一些博客文章。
第一步是在我们的虚拟环境中安装库:
$ pip install requests
第二步是查看文档,尤其是/sites/$site/posts端点的文档。
以下脚本定义了get_posts()函数,该函数旨在查询 WordPress.com 应用编程接口并根据需要处理分页:
# Chap07/blogs_wp_get_posts.py
import json
from argparse import ArgumentParser
import requests
API_BASE_URL = 'https://public-api.wordpress.com/rest/v1.1'
def get_parser():
parser = ArgumentParser()
parser.add_argument('--domain')
parser.add_argument('--posts', type=int, default=20)
parser.add_argument('--output')
return parser
def get_posts(domain, n_posts=20):
url = "{}/sites/{}/posts".format(API_BASE_URL, domain)
next_page = None
posts = []
while len(posts) <= n_posts:
payload = {'page_handle': next_page}
response = requests.get(url, params=payload)
response_data = response.json()
for post in response_data['posts']:
posts.append(post)
next_page = response_data['meta'].get('next_page', None)
if not next_page:
break
return posts[:n_posts]
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
posts = get_posts(args.domain, args.posts)
with open(args.output, 'w') as f:
for i, post in enumerate(posts):
f.write(json.dumps(post)+"\n")
脚本使用ArgumentParser接受命令行参数。它需要两个强制参数:--domain设置我们从中检索数据的博客,--output设置 JSON Lines 文件的名称。可选地,我们可以提供一个--posts参数,默认设置为20,来决定我们从给定的域中检索的帖子数量。
域是给定博客的完整网址,例如your-blog-name.wordpress.com。
例如,我们可以使用以下命令运行脚本:
$ python blogs_wp_get_posts.py \
--domain marcobonzanini.com \
--output posts.marcobonzanini.com.jsonl \
--posts 100
几秒钟后,脚本生成一个每行一篇文章的 JSON Lines 文件,该文件以 JSON 格式表示。
在描述输出的格式之前,我们先详细分析一下get_posts()函数,这是我们脚本的核心。该函数接受两个参数:博客的domain和我们想要检索的帖子数量n_posts,默认为20。
首先,该函数为相关的 API 端点定义了 URL,并初始化了next_page变量,这对于遍历多页结果非常有用。默认情况下,应用编程接口每页返回 20 个结果(20 篇博客文章),因此如果博客包含的内容超过这个数量,我们将需要遍历几个页面。
该函数的核心是while循环,用于一次检索一页结果。使用 GET 方法调用 API 端点,它接受文档中定义的几个参数。page_handle参数是我们可以用来指定我们感兴趣的结果页面。在第一次迭代时,这个变量的值是None,所以检索从头开始。使用response.json()方法以 JSON 格式返回的响应数据包含一个帖子列表response_data['posts'],以及一些关于请求的元数据,存储在response_data['meta']中。如果下一页结果可用,元数据字典将包含一个next_page键。如果该键不存在,我们将设置为None。
循环要么在没有next_page时停止,这意味着我们已经下载了所有可用的帖子,要么在我们已经达到足够的帖子时停止,如n_posts中所声明的。最后,我们将返回帖子的切片列表。如果期望的帖子数量n_posts不是页面大小的倍数,切片是必要的。例如,如果我们指定 30 作为期望的帖子数量,脚本将下载两个页面,每个页面有 20 个帖子,因此需要切片来删除最后 10 个没有被请求的条目。
每个 post 对象由下表中列出的属性定义:
| **属性** | **描述** | | `ID` | 这是博客中帖子的标识 | | `URL` | 这是文章的完整网址 | | `attachment_count` | 这是附件(例如,媒体文件等)的数量 | | `attachments` | 这是附件对象的列表 | | `author` | 这是作者的简介对象,包括全名、登录名、头像、gravatar 简介 URL 等等 | | `categories` | 这是分配给此帖子的类别列表 | | `content` | 这是帖子的全部内容,包括 HTML 标记 | | `date` | 这是 ISO 8601 格式的出版日期 | | `discussion` | 这是关于评论、pings 等的信息 | | `excerpt` | 这是对帖子的简短总结,通常是前几个句子 | | `feature_image` | 这是要素图像的网址(如果有) | | `global_ID` | 这是这个帖子的全局 ID | | `guid` | 这是博客域和帖子标识组合成一个有效的网址 | | `i_like` | 这将通知登录用户是否喜欢这篇文章 | | `is_following` | 这将通知登录用户是否在关注博客 | | `is_reblogged` | 这将通知登录用户是否已重新登录此帖子 | | `like_count` | 这是喜欢这篇文章的用户数量 | | `meta` | 这是来自 API 的元数据,例如,用于自动 API 发现的超媒体链接,等等 | | `modified` | 这是 ISO 8601 格式的最后一次修改日期 | | `short_url` | 这是社交媒体和移动分享的短 URL ( [http://wp.me](http://wp.me) | | `site_ID` | 这是博客的 ID | | `slug` | 这是这篇文章的要点 | | `status` | 这是此帖子的当前状态,例如,已发布、草稿、待定等 | | `sticky` | 这将告知帖子是否有粘性(无论发布日期如何,都显示在顶部) | | `tags` | 这是与帖子相关的标签列表 | | `title` | 这是帖子的标题 |我们可以看到,博客文章的结构比内容复杂得多,但就本章而言,我们将主要关注文本内容,包括标题和摘录。
使用博主 API
Blogger 是另一种流行的博客发布服务,早在 2003 年就被谷歌收购了。Blogger 托管的博客一般都在blogspot.com的一个子域,例如your-blog-name.blogspot.com。特定国家/地区域也适用于多个国家/地区(例如,英国的blogspot.co.uk,这意味着来自特定国家/地区的用户会以透明的方式被重定向到指定的域。
作为谷歌的一部分,账户管理是集中的,这意味着一旦用户注册了他们的谷歌账户,他们就可以自动访问博客。博主 API 也是如此;作为开发人员,我们可以通过谷歌开发人员控制台(https://console.developers.google.com/start)访问它,就像我们在第 5 章、关于谷歌+ 的主题分析中为谷歌+所做的那样,我们已经看到了创建一个需要访问(任何)谷歌 API 的项目的过程。我们可以重用同一个项目,并从开发人员控制台启用博客应用编程接口,或者我们可以从头开始创建一个新项目。这些步骤可以参考第五章、谷歌+ 上的话题分析中给出的描述。不管怎样,重要的部分是为我们的项目启用博主应用编程接口,否则我们将无法编程访问它。
在处理谷歌 Plus 应用编程接口时,我们还讨论了访问密钥的创建,特别是服务器密钥,这也是我们访问博客应用编程接口所需的。如果我们按照从第 5 章、谷歌+ 上的主题分析开始的步骤,我们应该已经有了一个可用的服务器密钥,可以存储为环境变量:
$ export GOOGLE_API_KEY="your-api-key-here"
就客户端库而言,我们使用的客户端库与我们在第 5 章、谷歌+ 上的主题分析中使用的客户端库相同,因为它是所有谷歌应用编程接口的通用接口。如果我们还没有安装,我们可以使用pip将其添加到我们的虚拟环境中:
$ pip install google-api-python-client
一旦环境都设置好了,我们就可以开始编写与 Blogger API 交互的脚本,以便从给定的博客下载一些帖子:
# Chap07/blogs_blogger_get_posts.py
import os
import json
from argparse import ArgumentParser
from apiclient.discovery import build
def get_parser():
parser = ArgumentParser()
parser.add_argument('--url')
parser.add_argument('--posts', type=int, default=20)
parser.add_argument('--output')
return parser
class BloggerClient(object):
def __init__(self, api_key):
self.service = build('blogger',
'v3',
developerKey=api_key)
def get_posts(self, blog_url, n_posts):
blog_service = self.service.blogs()
blog = blog_service.getByUrl(url=blog_url).execute()
posts = self.service.posts()
request = posts.list(blogId=blog['id'])
all_posts = []
while request and len(all_posts) <= n_posts:
posts_doc = request.execute()
try:
for post in posts_doc['items']:
all_posts.append(post)
except KeyError:
break
request = posts.list_next(request, posts_doc)
return all_posts[:n_posts]
if __name__ == '__main__':
api_key = os.environ.get('GOOGLE_API_KEY')
parser = get_parser()
args = parser.parse_args()
blogger = BloggerClient(api_key)
posts = blogger.get_posts(args.url, args.posts)
with open(args.output, 'w') as f:
for post in posts:
f.write(json.dumps(post)+"\n")
协调与所有谷歌应用编程接口交互的谷歌应用编程接口客户端有一个服务构建器的概念,一种用于构建服务对象的工厂方法,我们将使用它来查询应用编程接口。为了提供一个易于使用的接口,我们将创建一个BloggerClient类,处理持有 API 键、设置服务对象和提供get_posts()函数,非常类似于我们为 WordPress.com API 定义的函数。
该脚本像往常一样使用ArgumentParser,可以通过三个参数从命令行调用,如下所示:
$ python blogs_blogger_get_posts.py \
--url http://googleresearch.blogspot.co.uk \
--posts 50 \
--output posts.googleresearch.jsonl
类似于 WordPress.com 的例子,我们使用--posts和--output参数分别定义期望的帖子数量和输出文件的名称。与前面的例子不同,--url参数要求给定的字符串是博客的完整网址,而不仅仅是域名(这意味着它包括首字母http://)。使用前面的命令,几分钟后,我们将从谷歌研究博客获得最后 50 篇文章。
深入查看BloggerClient类,构造函数只接受一个参数,这是在 Google Developers Console 上注册我们的应用程序时获得的开发者的 API 密钥。用于设置具有build()工厂功能的service对象。值得注意的是,我们使用的是 API 的第三个版本v3,所以在参考文档时,这个细节很重要。
get_posts()方法做艰苦的工作。布局非常类似于为 WordPress.com 应用编程接口定义的get_posts()函数,有两个参数,博客网址和期望的文章数量。
与 WordPress.com 应用编程接口不同,博客应用编程接口首先需要一个博客对象,所以我们需要将网址转换成数字标识。这是通过使用self.service.blogs()创建的对象的getByUrl()方法来执行的。
另一个有趣的区别是,对大多数服务函数的调用不会直接触发 API 调用,类似于延迟评估的函数,因此我们需要显式调用一个execute()方法来执行 API 请求并获得响应。
request.execute()调用创建posts_doc对象,该对象可能包含也可能不包含items键(帖子列表)。将其包装到try/except块中可以确保,如果某个特定的调用没有帖子,迭代会随着中断调用而停止,而不会产生任何错误。
一旦达到期望的帖子数量,或者没有其他页面可用,就使用切片技术返回帖子列表。
Blogger API 返回的 JSON 文档比 WordPress.com 的文档简单,但是所有的关键属性都存在。下表总结了每个帖子的主要属性:
| **属性** | **描述** | | `author` | 这是代表文章作者的对象,带有显示名称、用户标识、个人资料图像和网址 | | `blog` | 这是表示博客本身的对象(例如,带有标识) | | `content` | 这是帖子的完整内容,包括 HTML 格式 | | `id` | 这是帖子的 ID | | `labels` | 这是与帖子相关联的标签列表 | | `published` | 这是 ISO 8601 格式的出版日期 | | `replies` | 这是代表帖子上的评论/回复的对象 | | `selfLink` | 这是基于 API 的帖子链接 | | `title` | 这是帖子的标题 | | `updated` | 这是上次以 ISO 8601 格式更新的日期 | | `url` | 这是文章的网址 |同样在这种情况下,有比帖子内容更有趣的信息,但文本数据是我们在这一章的重点。
解析 RSS 和 Atom 提要
许多博客,一般来说,许多网站,以标准格式提供内容,这些内容不是供最终用户在屏幕上可视化的,而是供第三方出版商消费的。丰富站点摘要 ( RSS )、(https://en.wikipedia.org/wiki/RSS)和Atom(https://en . Wikipedia . org/wiki/Atom _(standard))就是这种情况,这是两种基于 XML 的格式,我们可以使用它们从实现此功能的网站快速访问各种信息。
RSS 和 Atom 属于通常称为 web feeds 的格式家族,通常用于为用户提供常用的更新内容。提要提供商联合提要,这意味着用户可以通过应用程序订阅特定的提要,并在发布新内容时接收更新。包括邮件阅读器在内的一些应用程序提供了将 web 提要集成到其工作流中的功能。
在挖掘博客和文章的背景下,提要非常有趣,因为它们为以机器可读格式提供内容的给定网站提供了单一入口点。
例如,英国广播公司 ( 英国广播公司)提供各种各样的新闻提要,这些提要按照世界新闻、技术、体育等一般主题进行分组。例如,顶级新闻的提要可在http://feeds.bbci.co.uk/news/rss.xml获得(并且在浏览器中可读)。
Python 对读取网络提要的支持非常简单。我们需要做的就是安装 feedparser 库,它负责下载 feed 并将原始的 XML 解析成 Python 对象。
首先,我们需要将库安装到我们的虚拟环境中:
$ pip install feedparser
以下脚本用于从给定的网址下载提要,并以常用的 JSON Lines 格式保存新闻条目:
# Chap07/blogs_rss_get_posts.py
import json
from argparse import ArgumentParser
import feedparser
def get_parser():
parser = ArgumentParser()
parser.add_argument('--rss-url')
parser.add_argument('--json')
return parser
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
feed = feedparser.parse(args.rss_url)
if feed.entries:
with open(args.json, 'w') as f:
for item in feed.entries:
f.write(json.dumps(item)+"\n")
该脚本采用通过ArgumentParser定义的两个参数:--rss-url用于传递提要的 URL,而--json用于指定我们用来以 JSON Lines 格式保存提要的文件名。
例如,为了从 BBC 顶级新闻下载 RSS 提要,我们可以使用以下命令:
$ python blogs_rss_get_posts.py \
--rss-url http://feeds.bbci.co.uk/news/rss.xml \
--json rss.bbc.jsonl
这个剧本不言自明。这项艰苦的工作是由 feedparser 库的parse()函数完成的。该函数识别格式并将 XML 解析为一个对象,该对象包含一个entries属性(新闻项目列表)。通过迭代这个列表,我们可以简单地将单个项目转储为我们想要的 JSON Lines 格式,并且只需几行代码就可以完成这项工作。
这些新闻条目的有趣属性如下:
id:这是新闻条目的 URLpublished:这是出版日期title:这是新闻的标题summary:这是新闻的简短总结
从维基百科获取数据
作为最受欢迎的网站和参考作品之一,维基百科可能不需要任何扩展的展示。2015 年底,英文维基百科的文章达到了 500 万篇左右,不同语言的文章总数超过了 3800 万篇。
维基百科通过应用编程接口提供对其内容的访问。还定期提供完整的数据转储。
几个项目为维基百科应用编程接口提供了 Python 包装。一个特别有用的实现是由一个名为维基百科的库提供的。从我们的虚拟环境中,我们可以用通常的方式安装它:
$ pip install wikipedia
这个库的接口很简单;因此,正如其文档所说,我们可以专注于使用维基百科的数据,而不是获取数据。
除了访问页面的全部内容,维基百科应用编程接口还提供搜索和摘要等功能。让我们考虑以下示例:
>>> import wikipedia
# Access a Wikipedia page
>>> packt = wikipedia.page('Packt')
>>> packt.title
'Packt'
>>> packt.url
'https://en.wikipedia.org/wiki/Packt'
# Getting the summary of a page
>>> wikipedia.summary('Packt')
"Packt, pronounced Packed, is a print on demand publishing company based in Birmingham and Mumbai." # longer description
在前面的例子中,我们直接访问关于 Packt Publishing 的页面,知道名称Packt是唯一的。
当我们不确定我们感兴趣的实体的精确页面句柄时,维基百科应用编程接口的搜索和歧义消除功能是有用的。
例如,术语London可以与至少两个城市(一个在英国,另一个在加拿大)和几个实体或事件(例如,伦敦塔、伦敦大火等)相关联:
>>> wikipedia.search('London')
['London', 'List of bus routes in London', 'Tower of London', 'London, Ontario', 'SE postcode area', 'List of public art in London', 'Bateaux London', 'London Assembly', 'Great Fire of London', 'N postcode area']
如果出现拼写错误,结果会出乎意料:
>>> wikipedia.search('Londn')
['Ralph Venning', 'Gladys Reynell', 'GestiFute', 'Delphi in the Ottoman period']
上述问题的解决方法是向维基百科寻求一些建议:
>>> wikipedia.suggest('Londn')
'london'
由于我们已经介绍了一个关于伦敦的例子,我们还会注意到这个实体的摘要特别长。如果我们需要一个更短的总结,我们可以向 API 指定句子的数量,如图图 7.1 :

图 7.1:简短的总结
最后,让我们考虑一个触发歧义消除问题的同名例子。当访问维基百科页面时,如果一个特定的名称引用了许多不同的实体,而没有一个实体是明确的候选实体,则会显示一个歧义消除页面。
从 API 的角度来看,结果是一个例外,如下所示:
>>> wikipedia.summary('Mercury')
Traceback (most recent call last):
# long error
wikipedia.exceptions.DisambiguationError: "Mercury" may refer to:
Mercury (element)
Mercury (planet)
Mercury (mythology)
# ... long list of "Mercury" suggestions
为了避免歧义消除的问题,我们可以将请求包装在一个try/except块中:
>>> try:
... wikipedia.summary('Mercury')
... except wikipedia.exceptions.DisambiguationError as e:
... print("Many options available: {}".format(e.options))
...
Many options available: ['Mercury (element)', 'Mercury (planet)', 'Mercury (mythology)', ...] # long list
访问维基百科可以用来下载完整的文章,但这不是唯一有用的应用程序。我们还将看到如何从一段文本中提取名词和命名实体。在这种情况下,我们可以通过增加命名实体提取的结果来利用维基百科,借助简短的摘要来解释所识别的实体是什么。
关于刮网的几句话
网页抓取是从网站上自动下载和提取信息的过程。网页抓取软件基本上模拟了人类在网站上浏览(和保存)信息,目的是获取大量数据或执行一些需要自动交互的任务。
构建一个网络抓取器(也称为网络爬虫或简单的机器人)有时可以解决网络服务不提供 API 或提要来下载数据的情况。
虽然网页抓取器是一个合法的应用程序,但重要的是要记住,自动抓取可能会明显违背您打算抓取的特定网站的使用条款。此外,还有一些道德和法律问题(可能因国家而异)需要考虑,如下所示:
- 我们有权访问和下载数据吗?
- 我们是不是让网站服务器超载了?
第一个问题通常由网站的使用条款来回答。第二个问题更难回答。原则上,如果我们与网站进行大量交互(例如,在很短的时间内访问许多页面),我们肯定超出了用户会话的模拟范围,并且可能导致网站性能下降。如果我们连接的网站无法处理大量请求,这甚至可能构成拒绝服务攻击。
考虑到可用数据的丰富性,以及以某种易于访问的方式提供此类数据的服务的数量,构建一个 web 刮板应该是这些特定情况下不可用 API 的最后手段。
这个话题相当复杂。定制的解决方案可以用诸如请求和美人汤之类的库来构建,尽管有一个特别为这份工作量身定制的工具——Scrapy(http://scrapy.org)。由于本主题超出了本书的范围,对本主题感兴趣的读者可以找到各种出版物,例如:Python 网页抓取、by 理查德·劳森、 2015 、 Packt Publishing 。
自然语言处理基础
本节试图触及自然语言处理复杂领域的表面。前面几章已经提到了处理文本数据(例如,标记化)所必需的一些基础知识,但没有过多讨论细节。在这里,我们将尝试更深入地了解这门学科的基本知识。由于它的复杂性和许多方面,我们采取了务实的方法,只抓理论基础的表面,而倾向于实际例子。
文本预处理
任何自然语言处理系统的一个重要部分是预处理管道。在我们可以对一段文本执行任何有趣的任务之前,我们必须首先将其转换为有用的表示形式。
在前面的章节中,我们已经对文本数据进行了一些分析,但没有深入挖掘文本预处理的细节,而是使用了实用的常用工具。在这一节中,我们将重点介绍一些常见的预处理步骤,并讨论它们在构建自然语言处理系统的大环境中的作用。
句子边界检测
给定一段文本(换句话说,一个字符串),将它分解成句子列表的任务称为句子边界检测(也称句末检测或简称为句子标记化)。
句子是语言单位,它有意义地和语法地组合单词来表达一个完整的思想。从语言学(对语言的科学研究)的角度来看,一次彻底的讨论来定义一个句子的所有可能的方面将花费几页的时间。从实用主义和建立自然语言处理系统的角度来看,语言单位的这个一般概念已经足够开始了。
值得注意的是,虽然句子可以孤立地有意义,但有时它们需要成为更大背景的一部分(例如,一个段落或部分)。这是因为一个句子中的一些词可能指的是其他句子中表达的概念。让我们考虑一下这个例子,伊丽莎白二世是英国的女王。她 1926 年出生于伦敦,1952 年开始执政。
这篇简单的课文包含两个句子。第二句使用代词she**her。如果把句子单独来看,这两个词并不能告诉我们这个句子的主语是谁。从上下文来看,人类读者很容易将这两个词与前一句的主题伊丽莎白二世联系起来。这个挑战被称为回指解析,并且已经被认为是一个非常困难的自然语言处理问题。一般来说,链接到其他句子的句子通常使用本地上下文,这意味着链接指向前一个/下一个句子,而不是文本中很远的一个句子。在某些应用程序中,识别更大上下文的边界(例如,文本的段落和部分)可能很有用,但句子是通常的起点。
实际上,拆分句子似乎不是一个大问题,因为我们可以使用标点符号(例如句号、感叹号等)来识别句子边界。这种过于简单化会导致一些错误,如下例所示:
>>> text = "Mr. Cameron is the Prime Minister of the UK. He serves since 2010."
>>> sentences = text.split('.')
>>> sentences
['Mr', ' Cameron is the Prime Minister of the UK', ' He serves since 2010', '']
我们可以看到,将每个点解释为句号会将短语卡梅隆先生分成两部分,留给我们的是无意义的句子先生(以及一个空的最后一句话)。可以提供许多其他有问题的例子,主要是关于缩写(例如,博士、博士、美国等等)。
幸运的是,自然语言工具包 ( NLTK )提供了一个简单的解决方法,如下所示:
>>> from nltk.tokenize import sent_tokenize
>>> sentences = sent_tokenize(text)
>>> sentences
['Mr. Cameron is the Prime Minister of the UK.', 'He serves since 2010.']
这些句子现在被正确地识别出来了。这个例子非常简单,会有 NLTK 不执行正确句子识别的情况。总的来说,该库开箱即用,性能良好,因此我们可以将其用于各种应用程序,而无需过多担心。
单词标记化
给定一个句子(即一个字符串),将其分解成一个单词列表的任务称为单词标记化,有时也简称为标记化。在这种情况下,单词通常被称为代币。
一般来说,对于大多数欧洲语言,包括英语,标记化似乎是一项直观的任务,因为单个单词由空格分隔。这种过于简单化很快就暴露了它的缺点:
>>> s = "This sentence is short, nice and to the point."
>>> wrong_tokens = s.split()
>>> wrong_tokens
['This', 'sentence', 'is', 'short,', 'nice', 'and', 'to', 'the', 'point.']
标记short,和point.(注意后面的标点符号)显然是不正确的:标记化失败,因为分割空白没有考虑标点符号。构建一个考虑到语言所有细微差别的标记器可能会很棘手。对于一些使用大量复合词的欧洲语言(如德语或芬兰语)来说,事情变得更加复杂,例如,两个或多个单词连接在一起形成一个更长的单词,其含义可能与单独的单个组成部分的原始含义相似或不同。更大的挑战来自于一些亚洲语言,比如汉语,它的标准写法是字与字之间没有空格。处理特定语言时可能需要专门的库,例如,揭巴(https://github.com/fxsjy/jieba)是一个专门用于中文分词的 Python 库。
读者不需要担心语言处理的这些方面,因为我们关注的是英语。此外,NLTK 软件包已经配备了标准英语:
>>> from nltk.tokenize import word_tokenize
>>> s = "This sentence is short, nice and to the point."
>>> correct_tokens = word_tokenize(s)
>>> correct_tokens
['This', 'sentence', 'is', 'short', ',', 'nice', 'and', 'to', 'the', 'point', '.']
标记也包含在输出中,因为它们被认为是标记,可以在以后用于处理。
从这个例子中我们可以看到,单个单词被正确识别。标点符号也被包含在输出中,因为它们被认为是标记,并且在以后的处理中会很有用。NLTK 中的 word_tokenize()函数需要安装一个 punkt 包,它是一个 NLTK 资源。如果此函数引发异常,请参考第 1 章“社交媒体、社交数据和 Python”,其中提供了解决此问题的配置。
词性标注
一旦我们有了一个单词序列,我们就可以给每个单词分配一个语法类别。这个过程叫做词性 ( 词性 ) 标注,在不同的文本分析任务中有着有用的应用。
常见的语法类别包括名词、动词或形容词,每个类别都由不同的标记符号标识。不同的标记器可以基于不同的标记集,这意味着可用标记的列表可能因标记器而异,但是各个标记器的输出也可能相当不同。默认情况下,NLTK 库使用来自佩恩树库项目(https://www.cis.upenn.edu/~treebank)的标签集。
有关可用标签及其含义的完整列表,我们可以参考在线帮助:
>>> nltk.help.upenn_tagset()
这将产生一个长输出,包括所有的标签,它们的描述,和一些例子。最常见的标签有NN(普通名词)、NNP(专有名词)、JJ(形容词)和V*(几个以 V 开头的标签,表示不同时态的动词)。
为了将令牌列表与相关标签相关联,我们可以使用nltk.pos_tag()函数提供的简单界面。NLTK 提供了几种词性标记器实现。在工具包的最新版本中,默认标记器是平均感知器。如第一章、社交媒体、社交数据、Python 所述,NLTK 中的部分机型需要通过nltk.download()界面下载额外数据;这也是图 7.2 中所示的平均感知器模型的情况:

图 7.2:NLTK 下载界面突出显示了用于词性标注的平均感知器模型
在我们确定安装了相关的模型之后,我们可以在一个样例句子上测试pos_tag()功能:
>>> from nltk import pos_tag
>>> tokens = word_tokenize("This sentence is short, nice and to the point")
>>> pos_tag(tokens)
[('This', 'DT'), ('sentence', 'NN'), ('is', 'VBZ'), ('short', 'JJ'), (',', ','), ('nice', 'JJ'), ('and', 'CC'), ('to', 'TO'), ('the', 'DT'), ('point', 'NN')]
pos_tag()函数的输出是元组列表,其中每个元组都是(token, tag)格式。比如第一个令牌This,是行列式(DT);第二个 token,sentence,是普通名词(NN);等等。
词性标注的一个常见问题是,虽然研究人员对基本类别有一些共识,但没有一套正确的标签,因此使用不同的标签会产生不同的结果。由于自然语言的模糊性和细微差别,同一个单词可以根据上下文进行不同的标记;事实上,英语中的许多单词可以被分为不同的类别。例如,像鱼、散步和风景这样的词可以是动词,也可以是名词。
词性标注通常被用作达到目的的一种手段。特别是,利用 POS 标签信息至少在两种情况下是有用的:当找到单词的原始引理时(参见单词规范化部分)和当试图从非结构化文本中提取结构化信息时(参见本章后面的信息提取部分)。
单词规范化
标记化和词性标注是许多应用程序通用的基本预处理步骤。根据任务的不同,我们可能需要应用一些对整个应用程序的准确性有用的附加步骤。
在这里,我们将使用短语单词规范化作为总括术语来捕获我们希望对单个术语执行的所有附加操作。
病例归一化
为了强调文本规范化的必要性,我们从下面这个简单的例子开始:
>>> "president" == "President"
False
虽然前面的内容对大多数程序员来说可能是显而易见的,因为president和President实际上是两个不同的字符串,但是从语言学的角度来看,我们经常需要将这两个单词视为相同的。例如,当我们计算频率时,我们不想为两个相同的单词分别计数。
满足这一需求的一种方法是执行大小写转换,将整个文本映射为小写或大写:
>>> "president".lower() == "President".lower()
True
在这个例子中,我们将通过 lowercasing 转换所有的单词,以便原始术语可以被视为相同的。
重要的是要记住,这种转变是不可逆的。如果我们用较低版本覆盖文本,原始文本将无法复制。在许多应用程序中,我们可能需要向用户显示原始文本,同时对规范化文本进行一些处理。在这种情况下,保留原始文本的副本并将其视为不可变数据非常重要。
还值得注意的是,文本规范化并不总是能带来预期的结果。一个经典的例子是美国、美国的首字母缩略词,一旦正常化就映射成美国,一个客观的人称代词。一些尝试和错误可能是必要的,以抓住一些其他的边缘情况。
堵塞
在英语和其他语言中,有些词略有不同,但意义相同,例如 fish 、fish、fish。将这些单词映射到一个普通的概念类中,在某些情况下会很有帮助,因为我们感兴趣的是匹配单词的相关度,而不是它们的精确拼写。
这个映射过程叫做词干,一个词的根(基)形叫做词干。词干的一种常见方法是后缀剥离,这是一种去除单词尾部字母直到达到词根形式的方法。仍被广泛使用的后缀剥离算法的一个经典例子是波特斯特梅尔(一种后缀剥离算法,M.F .波特,1980)。
NLTK 实现了波特斯特梅尔和各种其他词干分析器,如下所示:
>>> from nltk.stem import PorterStemmer
>>> stemmer = PorterStemmer()
>>> stemmer.stem('fish')
'fish'
>>> stemmer.stem('fishes')
'fish'
>>> stemmer.stem('fishing')
'fish'
词干,像案例规范化一样,是不可逆的。如果我们处理词干,最好保留原文的副本,以防我们需要复制它。
与大小写规范化不同,词干也依赖于语言。另一个词干分析器是雪球词干分析器,它支持多种语言,因此如果我们处理多语言数据,它会很有用:
>>> from nltk.stem import SnowballStemmer
>>> SnowballStemmer.languages
('danish', 'dutch', 'english', 'finnish', 'french', 'german', 'hungarian', 'italian', 'norwegian', 'porter', 'portuguese', 'romanian', 'russian', 'spanish', 'swedish')
>>> stemmer = SnowballStemmer('italian')
>>> stemmer.stem('pesce') # fish (noun)
'pesc
>>> stemmer.stem('pesci') # fishes
'pesc'
>>> stemmer.stem('pescare') # fishing
'pesc'
最后要记住的一个细节是,词干并不总是一个恰当的词,而只是一个词的词根。
引理化
与词干相似,引理化也将一个词的不同屈折形式组合在一起,这样它们就可以作为同一个词来分析。
与词干不同,这个过程更复杂,需要一些额外的知识,例如与每个单词相关联的正确的词性标签来引理。引理化的输出称为引理,实际上是一个专有词。简单的后缀剥离方法对引理化不起作用,因为,例如,一些不规则动词形式与它们的引理有完全不同的形态。例如,围棋、围棋、围棋和围棋应该都映射到围棋中,但是词干分析器无法识别围棋的引理:
>>> from nltk.stem import PorterStemmer
>>> stemmer = PorterStemmer()
>>> stemmer.stem('go')
'go'
>>> stemmer.stem('went')
'went'
NLTK 中的引理化是通过使用 WordNet(https://wordnet.princeton.edu)来实现的,WordNet 是一种将英语单词分组为同义词集(也称为同义词集)的词汇资源,并提供与单词相关的定义和其他信息:
>>> from nltk.stem import WordNetLemmatizer
>>> lemmatizer = WordNetLemmatizer()
>>> lemmatizer.lemmatize('go', pos='v')
'go'
>>> lemmatizer.lemmatize('went')
'went'
>>> lemmatizer.lemmatize('went', pos='v')
'go'
lemmatize()函数接受第二个可选参数,即 POS 标签。如果没有 POS 标签信息,引理器很可能会失败,如下所示:
>>> lemmatizer.lemmatize('am')
'am'
>>> lemmatizer.lemmatize('am', pos='v')
'be'
>>> lemmatizer.lemmatize('is')
'is'
>>> lemmatizer.lemmatize('is', pos='v')
'be'
>>> lemmatizer.lemmatize('are')
'are'
>>> lemmatizer.lemmatize('are', pos='v')
'be'
基于 WordNet 的引理器的可用位置标签被分组到宏类别中:形容词(a)、名词(n)、动词(v)和副词(r)。
停止单词删除
停止词是不特别承载内容的术语,至少在孤立使用时不会,例如冠词和连词,但在大多数自然语言中非常常用。信息检索领域的早期研究(例如,Luhn,1958)表明,一个集合中最重要的单词不是最频繁的(也不是最罕见的)。这方面工作的进一步发展表明,如何在不损失重要内容的情况下删除这些词是可能的。在磁盘空间和内存昂贵得多的年代,删除停止字的好处是减少了数据的整体大小,因此在从文档集合中构建索引时可以节省一些空间。
如今,在太字节便宜很多的情况下,磁盘空间的动机比以前弱了很多,但仍然存在一个问题:去掉不感兴趣的字是否对手头的具体应用仍然有利。
很容易找到反例,在反例中,停止词删除是有害的。想象一下,一个搜索引擎不索引停止词。我们如何找到关于“谁”的网页(一个英国摇滚乐队)或莎士比亚的著名诗句“T2 生存还是毁灭:这是个问题?”乐队名称和诗句中的所有术语(除了问题)都是常见的英语终止词。停止词删除不受欢迎的例子可能数不胜数,现代搜索引擎,如谷歌或必应,确实会在索引中保留停止词。
去除不感兴趣术语的另一种方法是频率分析。通过观察一个单词的全局文档频率(它出现在文档中的比例),我们可以定义一个任意的阈值来切断过于频繁或过于罕见的术语。这也在 scikit-learn 这样的库中实现,其中不同向量器的构造函数(例如TfidfVectorizer类)允许定义参数,例如max_df和min_df。
在特定领域的集合中,有些术语比普通英语中的术语更常见。例如,在电影评论的集合中,术语电影或演员可能出现在几乎每个文档中。尽管这些术语本身并不是终止词(事实上,它们确实有一些含义,甚至是孤立的),人们可能会倾向于这样看待它们。因为词语的频率而自动删除它们会带来更多的问题:如何捕捉语言的某些方面,比如烂片或者了不起的演员?
在讨论了这些例子之后,这个关于停止词删除这样一个简单问题的长时间研究可以总结如下:停止词删除是否有益高度依赖于应用程序。重要的是要测试不同的选项,并评估我们的算法在有和没有这个预处理步骤的情况下的表现。
网上有几个常用英语单词的列表,所以想出一个自定义列表是很简单的。NLTK 已经配备了自己的停止词列表,如下例所示:
>>> from nltk.corpus import stopwords
>>> stop_list = stopwords.words('english')
>>> len(stop_list)
127
>>> stop_list[:10] # first 10 words
['i', 'me', 'my', 'myself', 'we', 'our', 'ours', 'ourselves', 'you', 'your']
同义词映射
我们将在本节中讨论的最后一个规范化步骤是将一组同义词映射到一个项目中的机会。动机与大小写规范化非常相似,唯一的区别是同义词不是完全相同的单词,因此对于这些根据上下文具有不同含义的术语,映射可能是不明确的。
一种简单的方法可以利用受控词汇,即提供映射的词汇资源。在 Python 中,它采用字典的形式:
>>> synonyms = {'big': 'large', 'purchase': 'buy'}
在示例中,我们可以使用synonyms将单词big翻译成large,将单词purchase翻译成buy。替换就像在字典中访问所需的键一样简单,例如,synonyms['big']将返回large。字典也有一个get(key, default=None)方法,它试图访问一个特定的键。该方法接受第二个参数,如果找不到键,该参数将用作默认值:
>>> text = "I want to purchase a book on Big Data".lower().split()
>>> text
['i', 'want', 'to', 'purchase', 'a', 'book', 'on', 'big', 'data']
>>> new_text = [synonyms.get(word, word) for word in text]
>>> new_text
['i', 'want', 'to', 'buy', 'a', 'book', 'on', 'large', 'data']
前面的片段使用列表理解来遍历text列表。这个列表中的每个word然后被用来从synonyms字典中检索一个潜在的同义词。如果同义词不可用,使用带有第二个参数的get()方法将阻止任何KeyError并保持原始单词在输出中的原样。
像往常一样,在处理自然语言时,最简单的情况也会产生歧义。一方面,将购买映射为购买似乎非常明智,但鉴于前面的例子,存在一个问题,即大数据到底意味着什么。这里的问题是,我们没有考虑使用大这个词的背景,相反,它不应该被孤立地考虑,而是作为短语大数据的一部分。除了这个特例之外,在自然语言中,我们很容易找到无数具有多重含义的单词的例子(https://en.wikipedia.org/wiki/Polysemy)。
像前面提到的 WordNet 这样的资源提供了关于一个单词的所有潜在语义组的丰富信息。为了突出这个问题如何不容易用简单的字典来表示,让我们考虑下面的例子:
>>> from nltk.corpus import wordnet
>>> syns = wordnet.synsets('big', pos='a')
>>> for syn in syns:
... print(syn.lemma_names())
...
['large', 'big']
['big']
['bad', 'big']
['big']
['big', 'large', 'prominent']
['big', 'heavy']
['boastful', 'braggart', 'bragging', 'braggy', 'big', 'cock-a-hoop', 'crowing', 'self-aggrandizing', 'self-aggrandising']
['big', 'swelled', 'vainglorious']
['adult', 'big', 'full-grown', 'fully_grown', 'grown', 'grownup']
['big']
['big', 'large', 'magnanimous']
['big', 'bighearted', 'bounteous', 'bountiful', 'freehanded', 'handsome', 'giving', 'liberal', 'openhanded']
['big', 'enceinte', 'expectant', 'gravid', 'great', 'large', 'heavy', 'with_child']
正如我们所看到的,单词big被用在大约十几个不同的 synsets 中。虽然它们都以某种方式围绕着“大 T2”的概念,但语言的细微差别要求恰当地捕捉这种多样性。
换句话说,这不是一个容易的问题。词义消歧是一个活跃的研究领域,对多种语言相关应用产生了影响。明确指定的数据域上的一些应用程序将受益于小的受控词汇表的使用,但是对于普通英语的一般情况,应该小心处理同义词。
信息提取
自然语言处理最有趣和最困难的方面之一是从非结构化文本中提取结构化信息的任务。这个过程一般称为信息抽取,它包括各种子任务,最流行的可能是命名实体识别 ( NER )。
NER 的目的是在一段文字中识别对实体的提及,例如个人或公司,并将它们分配到正确的标签。常见的实体类型包括人员、组织、位置、数值、时间表达式和货币。例如,让我们考虑以下文本:
“福特汽车公司(通常简称福特)是一家美国跨国汽车制造商,总部位于底特律郊区密歇根州迪尔伯恩。它由亨利·福特创立,并于 1903 年 6 月 16 日注册成立。”
前面的片段(取自福特汽车公司的维基百科页面)包含了许多对命名实体的引用。下表总结了这些内容:
| **实体引用** | **实体类型** | | 福特汽车公司 | 组织 | | 密歇根州迪尔伯恩 | 位置 | | 底特律 | 位置 | | 亨利福特 | 人 | | 1903 年 6 月 16 日 | 时间 |从提取结构化信息的角度来看,同一段文本还描述了实体之间的一些关系,如下所示:
| **主题** | **关系** | **物体** | | 福特汽车公司 | 位于 | 底特律 | | 福特汽车公司 | 创建者 | 亨利福特 |通常可以从文本中推断出一些额外的关系。例如,如果福特是一家位于底特律的美国公司,我们可以推断底特律位于美国。
型式
知识表示和推理
福特的例子被简化以提供更清晰的展示。重要的是要记住,表示知识本身是一个复杂的主题和研究领域,它涉及几个学科,包括人工智能、信息检索、自然语言处理、数据库和语义网。
虽然存在表示知识的标准(例如,RDF 格式),但是当我们设计知识存储库的结构时,理解应用程序的业务领域以及我们将如何使用这些知识是很重要的。
一般来说,信息提取系统的体系结构遵循图 7.3 中描述的结构:

图 7.3:信息提取系统概述
第一个构建块简单地标记为预处理,但是它可以包括几个步骤,如文本预处理部分所述。输入被假定为原始文本,因此它是一个字符串。这个假设意味着我们已经从相关的数据格式中提取了文本(例如,从一个 JSON 文件中,从一个数据库条目中,等等)。预处理阶段的精确输出取决于所选择的步骤。出于提取实体的目的,不执行停止词移除、词干和规范化,因为改变词的原始序列可能会排除多术语实体的正确识别。例如,如果短语美国银行转换为美国银行,就不可能识别正确的实体。由于这个原因,通常在这个阶段只应用标记化和词性标注,因此输出由元组列表(term, pos_tag)形成。
这个管道的第二步是实体的识别。从标记列表中,实体被分段,并根据它们的类型进行标记。根据应用的不同,我们可能只对专有名词感兴趣,或者我们也可能希望包含不定名词或名词短语。这个阶段的输出是一个简短且不重叠的短语的组块列表,这些短语可以被组织在树中。分块也称为浅层解析或部分解析,与完整解析相反,完整解析的目的是生成完整的解析树。
最后一步是识别实体之间的关系。关系是有趣的,因为它们允许我们推理出文中提到的实体。这一步往往是最难的一步,因为处理语言的难度很大。它还取决于前面步骤的输出质量,例如,如果实体提取器遗漏了一个实体,那么所有相关的关系也将被遗漏。最终输出可以表示为元组列表(subject, relation, object),如福特汽车示例中所述,或者可以对其进行定制以更好地适应应用需求。
NLTK 的现成实现允许我们非常容易地执行命名实体提取。nltk.chunk.ne_chunk()函数采用(term, pos_tag)格式的元组列表,并返回组块树。如果识别正确,每个命名实体都与相关的实体类型(如人员、位置等)相关联。该函数还接受第二个可选参数binary,默认为False。当True时,该参数关闭实体类型识别,因此实体被简单地标记为NE(命名实体),并且它们的类型被省略。
下面的例子使用维基百科的应用编程接口把所有的东西放在一起,得到被识别实体的简短摘要。
我们将照常定义进口和ArgumentParser定义:
# Chap07/blogs_entities.py
from argparse import ArgumentParser
from nltk.tokenize import word_tokenize
from nltk import pos_tag
from nltk.chunk import ne_chunk
import wikipedia
def get_parser():
parser = ArgumentParser()
parser.add_argument('--entity')
return parser
然后,我们将定义一个函数,该函数迭代一系列带有位置标记的标记,并返回一系列名词短语。名词短语是用相同的名词相关标记(例如NN或NNP)标记的标记序列。名词列表可以与 NLTK 识别的实际命名实体进行比较:
def get_noun_phrases(pos_tagged_tokens):
all_nouns = []
previous_pos = None
current_chunk = []
for (token, pos) in pos_tagged_tokens:
if pos.startswith('NN'):
if pos == previous_pos:
current_chunk.append(token)
else:
if current_chunk:
all_nouns.append((' '.join(current_chunk),
previous_pos))
current_chunk = [token]
else:
if current_chunk:
all_nouns.append((' '.join(current_chunk),
previous_pos))
current_chunk = []
previous_pos = pos
if current_chunk:
all_nouns.append((' '.join(current_chunk), pos))
return all_nouns
然后,我们将定义一个函数,该函数获取一个块树和一个实体类型,并根据指定的实体类型从给定的树返回实体列表:
def get_entities(tree, entity_type):
for ne in tree.subtrees():
if ne.label() == entity_type:
tokens = [t[0] for t in ne.leaves()]
yield ' '.join(tokens)
最后,脚本的核心逻辑:我们将在维基百科上查询特定的实体,检索它的简短摘要。然后对摘要进行标记和位置标记。名词短语和命名实体在简短摘要中标识。然后,每个命名实体旁边都会显示一个简短的摘要(同样来自维基百科):
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
entity = wikipedia.summary(args.entity, sentences=2)
tokens = word_tokenize(entity)
tagged_tokens = pos_tag(tokens)
chunks = ne_chunk(tagged_tokens, binary=True)
print("-----")
print("Description of {}".format(args.entity))
print(entity)
print("-----")
print("Noun phrases in description:")
for noun in get_noun_phrases(tagged_tokens):
print(noun[0]) # tuple (noun, pos_tag)
print("-----")
print("Named entities in description:")
for ne in get_entities(chunks, entity_type='NE'):
summary = wikipedia.summary(ne, sentences=1)
print("{}: {}".format(ne, summary))
例如,我们可以使用以下命令运行前面的脚本:
$ python blogs_entities.py --entity London
脚本的输出部分显示在图 7.4 中:

图 7.4:脚本的输出
识别出的名词短语完整列表见图 7.5 :

图 7.5:识别的名词短语
在这种情况下,只有术语Standing被错误地标记为名词,可能是因为大写S,因为它是句子开头的单词。是否所有的名词都有意思(例如standing、settlement、millennia)值得怀疑,但是所有的名词都有。另一方面,已识别的命名实体列表似乎相当不错:London、England、United Kingdom、River Thames、Great Britain和London。
总结
在这一章中,我们介绍了自然语言处理领域,这是一个复杂的研究领域,具有许多挑战和机遇。
这一章的第一部分着重于如何从网络上获取文本数据。鉴于大量的文本数据,博客是文本挖掘的天然候选者。在处理了两个最受欢迎的免费博客平台,WordPress.com 和博主之后,我们通过引入网络提要的 XML 标准来概括这个问题,特别是 RSS 和 Atom。鉴于维基百科在网络上的强大影响力,或许在许多互联网用户的日常生活中,它也值得在关于文本内容的讨论中被提及。我们看到了如何通过使用可用的库或快速实现我们自己的功能来轻松地与 Python 中的所有这些服务进行交互。
本章的第二部分是关于自然语言处理的。我们已经在整本书中介绍了一些 NLP 概念,但这是我们第一次花时间提供更正式的介绍。我们描述了一个从原始文本到命名实体识别的 NLP 管道,遍历所有必要的预处理步骤,使高级应用成为可能。
下一章将关注其他社交媒体 API,以便在数据挖掘可能性方面提供更广阔的视野。
八、挖掘所有数据!
本章对现有的一些社交媒体 API 进行了展望。特别是,我们将讨论以下主题:
- 如何从 YouTube 上挖掘视频
- 如何从 GitHub 中挖掘开源项目
- 如何从 Yelp 中挖掘本地业务
- 如何使用请求库调用任何基于网络的应用编程接口
- 如何将您的请求调用打包到自定义客户端中
很多社交 API
前面的每一章都聚焦于近年来流行的特定社交媒体平台。幸运的是,故事还没有结束。许多平台提供社交网络功能,以及一个很好的应用编程接口来挖掘数据。另一方面,用详尽的例子和有趣的用例提供所有可能的 API 的全面描述将远远超出本书的范围。
为了提供思考的食粮,本章涉及社交媒体挖掘的两个方面。首先,我们将浏览一些有趣的 API,以便搜索或挖掘复杂的实体,如视频、开源项目或本地企业。其次,我们将讨论如果某个特定的应用编程接口没有一个好的 Python 客户端,该怎么办。
挖掘 YouTube 上的视频
YouTube(http://youtube.com)如今大概不需要太多介绍,是全球访问量最大的网站之一(2016 年 3 月在 Alexa 排名中排名第二)。视频分享服务的特点是内容广泛,事实上,它是由一系列不同的作者制作和分享的,从业余视频博主到大公司。YouTube 的注册用户可以上传、评价和评论视频。查看和共享不需要用户注册个人资料。
YouTube 在 2006 年被谷歌收购,所以今天他们是更大的谷歌平台的一部分。在第五章、谷歌+ 话题分析、第七章、博客、RSS、维基百科、自然语言处理中,我们已经介绍了谷歌的其他服务,尤其是 Google+和 Blogger。YouTube 提供了三种不同的 API 来将您的应用程序与 YouTube 平台集成:YouTube 数据、YouTube 分析和 YouTube 报告。我们将重点关注第一个从 YouTube 检索和挖掘数据的网站,因为另外两个是为内容创作者量身定制的。
访问 YouTube Data API 的第一步和我们已经看到的非常相似,所以鼓励读者看一下第五章、在 Google+ 上的话题分析,来完成访问 Google 开发者控制台(https://console.developers.google.com)的程序。如果您已经在谷歌开发者控制台上注册了您的证书,您只需要启用 YouTube 数据应用编程接口,如图 8.1所示:

图 8.1:从谷歌开发者控制台启用 YouTube 数据应用编程接口
从谷歌开发者控制台的凭证选项卡,我们可以重用从第 5 章、谷歌+ 主题分析中讨论的谷歌+项目中获得的 API 密钥。一旦凭据被整理出来,我们就可以将我们的谷歌应用编程接口密钥存储为环境变量:
$ export GOOGLE_API_KEY="your-api-key"
我们还将重用用于其他谷歌服务的相同 Python 客户端,可以通过 CheeseShop 安装:
$ pip install google-api-python-client
作为复习,我们将提醒用户,我们刚刚安装的包是以googleapiclient的形式提供的,但它也被简单地别名为apiclient(在代码示例中使用)。
使用配额制度对原料药进行限速。每个应用程序的每日上限为 1,000,000 台。对不同端点的不同调用将会有不同的配额成本,但是一百万个单位的速率限制远远超出了我们开始使用和实验该 API 所需要的。使用情况和配额可以在谷歌开发者控制台中看到。
与 YouTube 数据应用编程接口交互的第一个例子是通用搜索应用程序。youtube_search_video.py脚本实现了对search.list应用编程接口端点(https://developers.google.com/youtube/v3/docs/search/list)的调用。对该端点的单次调用有 100 个单位的配额成本:
# Chap08/youtube_search_video.py
import os
import json
from argparse import ArgumentParser
from apiclient.discovery import build
def get_parser():
parser = ArgumentParser()
parser.add_argument('--query')
parser.add_argument('--n', type=int, default=10)
return parser
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
api_key = os.environ.get('GOOGLE_API_KEY')
service = build('youtube',
'v3',
developerKey=api_key)
search_feed = service.search()
search_query = search_feed.list(q=args.query,
part="id,snippet",
maxResults=args.n)
search_response = search_query.execute()
print(json.dumps(search_response, indent=4))
如前所述,该脚本使用ArgumentParser方法来解析命令行参数。--query参数允许我们为 API 调用传递查询字符串,而--n参数(可选,默认为10)用于定义我们想要检索的结果数量。
与 YouTube API 交互的核心由service对象处理,通过调用apiclient.discovery.build()函数来实例化,类似于我们为 Google+和 Blogger 所做的。这个函数的两个位置参数是服务名(youtube)和应用编程接口版本(v3)。第三个关键字参数是我们定义为环境变量的 API 关键字。实例化service对象后,我们可以调用其search()函数来构建搜索提要对象。这是允许我们调用list()函数的对象,定义了实际的 API 调用。
list()函数只接受基于关键字的参数。q参数是我们要传递给 API 的查询。part参数允许我们定义一个逗号分隔的属性列表,API 响应应该包括这些属性。最后,我们可以执行对应用编程接口的调用,这将产生一个响应对象(即 Python 字典),为了简单起见,我们使用json.dumps()函数来打印它。
search_response字典有几个一级属性,如下所示:
pageInfo,包括resultsPerPage和totalResultsitems:这是搜索结果列表kind:这是结果对象的类型(本例中为youtube#searchListResponse)etagregionCode:这是两个字母的国家代码,例如GBnextPageToken:这是一个构建对下一页结果调用的令牌
在items列表中,每一项都是一个搜索结果。我们有项目的标识(可以是视频、频道或播放列表)和包含几个细节的片段。
对于视频,一些有趣的细节包括:
channelId:这是视频创作者的 IDchannelTitle:这是视频创作者的名字title:这是视频的标题description:这是视频的文字描述publishedAt:这是一个 ISO 8601 格式的日期字符串
可以扩展基本查询来定制搜索请求的结果。例如,来自youtube_search_video.py脚本的查询不仅检索视频,还检索播放列表或频道。如果我们想将结果限制在特定类型的对象上,我们可以使用list()函数中的type参数,该参数取一个可能的值:video、playlist和channel。此外,我们可以使用order属性影响结果的排序方式。该属性采用几个可接受的值之一:
date:这将按照相反的时间顺序对结果进行排序(最近的结果将排在第一位)rating:这将从最高到最低评级对结果进行排序relevance:这是根据结果与查询的相关性对结果进行排序的默认选项title:这将按照标题的字母顺序对结果进行排序videoCount:这将按上传视频的降序对频道进行排序viewCount:这将从最高到最低的浏览量对视频进行排序
最后,我们还可以使用publishedBefore和publishedAfter参数将搜索限制在特定的出版日期范围内。
让我们考虑以下示例,以便将这些信息放在一起,并为搜索 API 端点构建一个自定义查询。假设我们有兴趣按照相关性的顺序检索 2016 年 1 月发布的视频。对list()函数的调用可以重构如下:
search_query = search_feed.list(
q=args.query,
part="id,snippet",
maxResults=args.n,
type='video',
publishedAfter='2016-01-01T00:00:00Z',
publishedBefore='2016-02-01T00:00:00Z')
publishedAfter和publishedBefore参数期望日期和时间根据 RFC 3339 进行格式化。
为了处理更多任意数量的搜索结果,我们需要实现分页机制。实现这一目标的最简单方法是将对搜索端点的调用包装到一个自定义函数中,该函数在不同的页面上迭代,直到达到所需的结果数量。
下面的类使用为视频搜索定制的方法实现了一个自定义的 YouTube 客户端:
class YoutubeClient(object):
def __init__(self, api_key):
self.service = build('youtube',
'v3',
developerKey=api_key)
def search_video(self, query, n_results):
search = self.service.search()
request = search.list(q=query,
part="id,snippet",
maxResults=n_results,
type='video')
all_results = []
while request and len(all_results) <= n_results:
response = request.execute()
try:
for video in response['items']:
all_results.append(video)
except KeyError:
break
request = search.list_next(request, response)
return all_results[:n_results]
初始化时,YoutubeClient类需要api_key,用于调用apiclient.discovery.build()方法和设置服务。
搜索逻辑的核心在search_video()方法中实现,该方法采用两个参数:查询和所需数量的结果。我们将首先像之前一样使用list()方法设置request对象。while循环检查request对象是否不是None,我们还没有达到结果数。查询的执行检索response对象中的结果,如前所述,该对象是一个字典。视频的数据列在response['items']中,附在all_results列表中。循环的最后一个操作是调用list_next()方法,该方法覆盖request对象,使其准备好检索下一页结果。
YoutubeClient类可以如下使用:
# Chap08/youtube_search_video_pagination.py
import os
import json
from argparse import ArgumentParser
from apiclient.discovery import build
def get_parser():
parser = ArgumentParser()
parser.add_argument('--query')
parser.add_argument('--n', type=int, default=50)
parser.add_argument('--output')
return parser
class YoutubeClient(object):
# as defined in the previous snippet
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
api_key = os.environ.get('GOOGLE_API_KEY')
youtube = YoutubeClient(api_key)
videos = youtube.search_video(args.query, args.n)
with open(args.output, 'w') as f:
for video in videos:
f.write(json.dumps(video)+"\n")
可以通过以下示例调用该脚本:
$ python youtube_search_video_pagination.py \
--query python \
--n 50 \
--output videos.jsonl
执行前面的命令将生成videos.jsonl文件,该文件包含与查询python相关的50视频的数据。该文件以 JSON Lines 格式保存,正如我们在前面所有章节中看到的那样。
本节展示了一些与 YouTube 数据应用编程接口交互的例子。这些模式与谷歌+和博客应用编程接口非常相似,因此一旦理解了谷歌服务的方法,就可以很容易地将其翻译成其他服务。
在 GitHub 上挖掘开源软件
GitHub(https://github.com)是 Git 存储库的托管服务。虽然私有存储库是付费计划的关键特性之一,但该服务以托管许多开源服务而闻名。除了 Git 的源代码管理功能之外,GitHub 还提供了一系列功能,使得开源项目的管理变得更加容易(例如,bug 跟踪、wikis、特性请求等等)。
型式
源代码管理软件
源代码控制系统(也称为版本控制或修订控制)是软件管理中最重要的工具之一,因为它跟踪开发中的软件是如何发展的。这一方面经常被新手或独立开发人员所忽视,但是在团队或独立完成复杂项目时,这一点至关重要。在这些优势中,有可能回滚不需要的更改(例如,存储和恢复软件的不同版本),以及与团队成员的高效协作。Git 最初由 Linus Torvalds(Linux 的作者)开发,是最流行的版本控制工具之一。了解版本控制系统的基础知识对新手开发者、分析师和研究人员都是有益的。
GitHub 通过一个 API(https://developer.github.com/v3)提供对他们数据的访问,使用的是在他们的平台上注册一个应用的通用机制。只有在访问仅对经过身份验证的用户可用的私有信息时,才需要应用程序身份验证。
API 有一些严格的速率限制。未经身份验证的呼叫被限制在每小时 60 个,这是一个相当低的数字。实施身份验证后,该限制升级为每小时 5,000 次。搜索应用编程接口也有自定义速率限制规则(如果未经身份验证,每分钟 10 个请求,如果经过身份验证,每分钟 30 个请求)。身份验证可以通过以下三种不同的方式执行:
- 基本用户名/密码验证
- oauth 2 记号
- 通过 Oauth2 客户端标识和客户端密码
基本身份验证需要将实际的用户名和密码发送到由 HTTP 标准定义的应用编程接口端点。另一方面,通过 Oauth2 令牌进行身份验证需要以编程方式获取令牌(https://developer . github . com/v3/oauth _ authorizations/# create-a-new-authorization),然后通过标头或 URL 参数将其发送到 API 端点。最后一个选项是将客户端标识和客户端密码传递给应用编程接口。这些细节可以通过在 GitHub 平台(https://github.com/settings/applications/new)注册应用程序获得。图 8.2 显示了创建新申请的登记表:

图 8.2:在 GitHub 中注册一个新应用程序
注册应用程序后,我们可以将凭据存储为环境变量:
$ export GITHUB_CLIENT_ID="your-client-id"
$ export GITHUB_CLIENT_SECRET="your-client-secret"
现在一切都设置好了,我们准备好与 GitHub API 交互了。有几个 Python 库可以作为客户端,都是第三方的,也就是 GitHub 不正式支持(https://developer.github.com/libraries)。我们在本节中为示例选择的库是 PyGithub(https://github.com/PyGithub/PyGithub,但是如果读者愿意,我们鼓励他们测试其他库。
要从我们的虚拟环境安装库,请使用以下命令:
$ pip install PyGithub
以下脚本可用于查找特定的用户名,以便获得关于其配置文件和 GitHub 存储库的一些基本信息:
# Chap08/github_get_user.py
import os
from argparse import ArgumentParser
from github import Github
from github.GithubException import UnknownObjectException
def get_parser():
parser = ArgumentParser()
parser.add_argument('--user')
parser.add_argument('--get-repos', action='store_true',
default=False)
return parser
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
client_id = os.environ['GITHUB_CLIENT_ID']
client_secret = os.environ['GITHUB_CLIENT_SECRET']
g = Github(client_id=client_id, client_secret=client_secret)
try:
user = g.get_user(args.user)
print("Username: {}".format(args.user))
print("Full name: {}".format(user.name))
print("Location: {}".format(user.location))
print("Number of repos: {}".format(user.public_repos))
if args.get_repos:
repos = user.get_repos()
for repo in repos:
print("Repo: {} ({} stars)".format(repo.name,
repo.stargazers_count))
except UnknownObjectException:
print("User not found")
该脚本使用ArgumentParser从命令行捕获参数:--user用于传递要搜索的用户名,而可选的--get-repos参数是一个布尔标志,表示我们是否希望在输出中包含用户存储库的列表(请注意,为了简单起见,不包含分页)。
可以使用以下命令运行该脚本:
$ python github_get_user.py --user bonzanini --get-repos
这将产生以下输出:
Username: bonzanini
Full name: Marco Bonzanini
Location: London, UK
Number of repos: 9
Repo: bonzanini.github.io (1 stars)
Repo: Book-SocialMediaMiningPython (3 stars)
# more repositories ...
Github.get_user()函数假设我们已经知道我们正在寻找的确切用户名。
下表列出了用户对象的一些最有趣的属性:
| **属性名称** | **描述** | | `avatar_url` | 这是头像图片的网址 | | `bio` | 这是用户的简短传记 | | `blog` | 这是用户的博客 | | `company` | 这是用户的公司 | | `created_at` | 这是配置文件的创建日期 | | `email` | 这是用户的电子邮件 | | `followers` | 这是用户的追随者数量 | | `followers_url` | 这是检索用户关注者列表的网址 | | `following` | 这是用户遵循的配置文件数量 | | `following_url` | 这是检索用户使用的配置文件列表的网址 | | `location` | 这是用户的地理位置 | | `login` | 这是登录名 | | `name` | 这是全名 | | `public_repos` | 这是公共存储库的数量 | | `public_gists` | 这是公共 gists 的数量 | | `repos_url` | 这是检索存储库的网址 |使用精确的用户名检索用户配置文件后,我们可以查看搜索用户的选项。GitHub API 提供了一个由Github.search_users()函数访问的端点,它允许我们指定一个查询和一些排序和排序参数。以下脚本实现了搜索:
# Chap08/github_search_user.py
import os
from argparse import ArgumentParser
from argparse import ArgumentTypeError
from github import Github
def get_parser():
parser = ArgumentParser()
parser.add_argument('--query')
parser.add_argument('--sort',
default='followers',
type=check_sort_value)
parser.add_argument('--order',
default='desc',
type=check_order_value)
parser.add_argument('--n', default=5, type=int)
return parser
def check_sort_value(value):
valid_sort_values = ['followers', 'joined', 'repositories']
if value not in valid_sort_values:
raise ArgumentTypeError('"{}" is an invalid value for
"sort"'.format(value))
return value
def check_order_value(value):
valid_order_values = ['asc', 'desc']
if value not in valid_order_values:
raise ArgumentTypeError('"{}" is an invalid value for
"order"'.format(value))
return value
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
client_id = os.environ['GITHUB_CLIENT_ID']
client_secret = os.environ['GITHUB_CLIENT_SECRET']
g = Github(client_id=client_id, client_secret=client_secret)
users = g.search_users(args.query,
sort=args.sort,
order=args.order)
for i, u in enumerate(users[:args.n]):
print("{}) {} ({}) with {} repos ".format(i+1, u.login,
u.name, u.public_repos))
脚本使用ArgumentParser从命令行解析参数。搜索查询可以通过--query选项传递。该应用编程接口还接受两个参数,允许定制排序和排序。作为--sort和--order参数实现的这两个值只接受特定的值,因此我们必须为它们中的每一个指定特定的类型。更准确地说,check_sort_value()和check_order_value()助手函数实现了以下逻辑:如果给定值是有效的,它将被返回并传递给应用编程接口,否则将引发ArgumentTypeError异常。最后,ArgumentParser还接受一个--n参数来指定所需的结果数量(默认为5)。
可以使用以下命令运行该脚本:
$ python github_search_user.py \
--query [your query here] \
--sort followers \
--order desc
输出是五个(默认)最受欢迎用户的列表,也就是说,拥有最多关注者的用户。
可以实现一个非常类似的脚本来搜索流行的存储库。实现如以下脚本所示:
# Chap08/github_search_repos.py
import os
from argparse import ArgumentParser
from argparse import ArgumentTypeError
from github import Github
def get_parser():
parser = ArgumentParser()
parser.add_argument('--query')
parser.add_argument('--sort',
default='stars',
type=check_sort_value)
parser.add_argument('--order',
default='desc',
type=check_order_value)
parser.add_argument('--n', default=5, type=int)
return parser
def check_sort_value(value):
valid_sort_values = ['stars', 'forks', 'updated']
if value not in valid_sort_values:
raise ArgumentTypeError('"{}" is an invalid value for
"sort"'.format(value))
return value
def check_order_value(value):
valid_order_values = ['asc', 'desc']
if value not in valid_order_values:
raise ArgumentTypeError('"{}" is an invalid value for
"order"'.format(value))
return value
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
client_id = os.environ['GITHUB_CLIENT_ID']
client_secret = os.environ['GITHUB_CLIENT_SECRET']
g = Github(client_id=client_id, client_secret=client_secret)
repos = g.search_repositories(args.query,
sort=args.sort,
order=args.order)
for i, r in enumerate(repos[:args.n]):
print("{}) {} by {} ({} stars)".format(i+1, r.name,
r.owner.name, r.stargazers_count))
github_search_repos.py脚本与前一个非常相似,因为它也使用了带有自定义类型的ArgumentParser来解析--sort和--order参数。为了搜索存储库,它使用Github.search_repositories()方法,该方法接受一个查询和一些基于关键字的参数。
可以使用以下命令运行该脚本:
$ python github_search_repos.py \
--query python \
--sort stars \
--order desc
python查询发布了一些有趣的结果:
1) oh-my-zsh by Robby Russell (36163 stars)
2) jQuery-File-Upload by Sebastian Tschan (23381 stars)
3) awesome-python by Vinta (20093 stars)
4) requests by Kenneth Reitz (18616 stars)
5) scrapy by Scrapy project (13652 stars)
虽然列表中有一些流行的 Python 项目(例如请求和 Scrapy 库),但前两个结果似乎与 Python 无关。这是因为默认情况下调用搜索 API 会针对标题/描述字段发出查询,所以这个特定的调用捕获了一些在其描述中提到关键字python的存储库,但是它们并没有在 Python 中实现。
我们可以重构搜索,以便只检索用特定语言编写的存储库,这些存储库使用该语言作为主要语言。唯一需要更改的代码是使用language限定符调用Github.search_repositories(),如下所示:
repos = g.search_repositories("language:{}".format(args.query),
sort=args.sort,
order=args.order)
在查询中做了这一微小的更改后,结果看起来大不相同:
1) httpie by Jakub RoztoÄil (22130 stars)
2) awesome-python by Vinta (20093 stars)
3) thef*** by Vladimir Iakovlev (19868 stars)
4) flask by The Pallets Projects (19824 stars)
5) django by Django (19044 stars)
此时,所有的结果都是主要用 Python 实现的项目。
下表总结了存储库对象的主要属性:
| **属性名称** | **描述** | | `name` | 这是出现在 URL 中的回购的名称 | | `full_name` | 这是回购的完整名称(即`user_name` / `repo_name`) | | `owner` | 这是代表拥有项目的用户的对象 | | `id` | 这是回购的数字标识 | | `stargazers_count` | 这是观星者的数量 | | `description` | 这是对回购的文字描述 | | `created_at` | 这是创造时间的`datetime`对象 | | `updated_at` | 这是上次更新的`datetime`对象 | | `open_issues_count` | 这是未解决问题的数量 | | `language` | 这是回购的主要语言 | | `languages_url` | 这是检索其他语言信息的网址 | | `homepage` | 这是项目主页的网址 |有许多关于特定存储库的属性和细节。原料药的文件(https://developer.github.com/v3/repos)提供了详尽的描述。
本节展示了一些从 GitHub API 中检索数据的例子。潜在的应用包括通过提取最常用的编程语言来理解编程语言的分析,例如,用特定语言编写的项目的最高数量、每种语言最活跃的项目、在给定时间范围内打开的问题的最高数量等等。
在 Yelp 上挖掘当地企业
Yelp 是一项在线服务,提供针对当地企业的众包评论,如酒吧和餐馆(https://www.yelp.com)。月访问量 1.35 亿,内容以社区驱动为主。
Yelp 提供了三个 API,可用于搜索本地企业的数据并与之交互。这三个应用编程接口根据其用途进行分组。搜索应用编程接口是基于关键词的搜索的入口点。业务应用编程接口用于查找特定业务的信息。电话搜索应用编程接口用于通过电话号码搜索业务。
Yelp API 的速率限制为每天 25,000 次调用。
本节描述了设置应用程序的基本步骤,该应用程序在 Yelp 数据库中搜索特定的关键字。
对 API 的访问受令牌保护。在 Yelp 上注册帐户后,访问任何 API 的第一步是在开发者网站(https://www.yelp.com/developers)上获取令牌。图 8.3 显示了我们访问 API 需要填写的表单。在本例中,您的网站网址字段指向本地主机:

图 8.3:对 Yelp 应用编程接口的访问
完成第一步后,我们将获得四个不同的访问密钥。这些字符串应该受到保护,永远不要共享。访问密钥被称为消费者密钥、消费者秘密、令牌和令牌秘密。这些字符串用于调用应用编程接口之前必需的 OAuth 身份验证过程。
按照整本书使用的约定,我们可以将这些配置细节存储为环境变量:
$ export YELP_CONSUMER_KEY="your-consumer-key"
$ export YELP_CONSUMER_SECRET="your-consumer-secret"
$ export YELP_TOKEN="your-token"
$ export YELP_TOKEN_SECRET="your-token-secret"
为了以编程方式访问该应用编程接口,我们需要安装官方 Python 客户端。在我们的虚拟环境中,使用以下命令:
$ pip install yelp
下面的脚本yelp_client.py定义了一个函数,我们将使用它来设置对 API 的调用:
# Chap08/yelp_client.py
import os
from yelp.client import Client
from yelp.oauth1_authenticator import Oauth1Authenticator
def get_yelp_client():
auth = Oauth1Authenticator(
consumer_key=os.environ['YELP_CONSUMER_KEY'],
consumer_secret=os.environ['YELP_CONSUMER_SECRET'],
token=os.environ['YELP_TOKEN'],
token_secret=os.environ['YELP_TOKEN_SECRET']
)
client = Client(auth)
return client
一旦一切就绪,我们就可以开始寻找本地企业了。yelp_search_business.py脚本使用提供的位置和关键字调用搜索应用编程接口:
# Chap08/yelp_search_business.py
from argparse import ArgumentParser
from yelp_client import get_yelp_client
def get_parser():
parser = ArgumentParser()
parser.add_argument('--location')
parser.add_argument('--search')
parser.add_argument('--language', default='en')
return parser
if __name__ == '__main__':
client = get_yelp_client()
parser = get_parser()
args = parser.parse_args()
params = {
'term': args.search,
'lang': args.language,
'limit': 20,
'sort': 2
}
response = client.search(args.location, **params)
for business in response.businesses:
address = ', '.join(business.location.address)
categories = ', '.join([cat[0] for cat in
business.categories])
print("{} ({}, {}); rated {}; categories
{}".format(business.name,
address,
business.location.postal_code,
business.rating,
categories))
一个ArgumentParser对象用于解析通过命令行传递的参数。特别是,两个参数是强制性的:--location,它是城市或当地社区的名称,以及--search,它允许我们为搜索传递特定的关键词。还可以提供可选的--language参数来指定输出的期望语言(尤其是评论)。这个参数的值必须是一个语言代码(例如,英语的en、法语的fr、德语的de等等)。我们还将提供另外两个可选参数。--limit参数是我们想要的结果数量(默认为 20),而--sort参数定义了结果的排序方式(0 表示最佳匹配,1 表示距离,2 表示最高评分)。
在脚本的主要部分,我们将设置 Yelp 客户端和解析器。然后,我们将定义一个传递给 Yelp 客户端的参数字典。这些参数包括通过命令行定义的参数。
可以使用以下命令调用该脚本:
$ python yelp_search_business.py \
--location London \
--search breakfast \
--limit 5
输出是打印在控制台上的本地企业列表(本例中为五家):
Friends of Ours (61 Pitfield Street, N1 6BU); rated 4.5; categories Cafes, Breakfast & Brunch, Coffee & Tea
Maison D'être (154 Canonbury Road, N1 2UP); rated 4.5; categories Coffee & Tea, Breakfast & Brunch
Dishoom (5 Stable Street, N1C 4AB); rated 4.5; categories Indian, Breakfast & Brunch
E Pellicci (332 Bethnal Green Road, E2 0AG); rated 4.5; categories Italian, Cafes, Breakfast & Brunch
Alchemy (8 Ludgate Broadway, EC4V 6DU); rated 4.5; categories Coffee & Tea, Cafes
当搜索多个关键字时,查询必须用双引号括起来,以便能够正确解释,如下所示:
$ python yelp_search_business.py \
--location "San Francisco" \
--search "craft beer" \
--limit 5
API 返回的location对象有几个属性。下表总结了最有趣的问题:
location对象提供以下属性:
address (list),仅包含地址字段display_address (list),格式为显示,包括十字街道、城市、州代码等city (string)state_code (string),ISO 3166-2 州的商业代码postal_code (string)country_code (string),该业务的 ISO 3166-1 国家代码cross_streets (string),十字街头的商业(美国)neighborhoods (list)、小区信息为业务coordinates.latitude (number)coordinates.longitude (number)
除了搜索应用编程接口之外,Yelp 还提供了一个业务应用编程接口,专门用于检索给定业务的信息。该应用编程接口假设您已经有了该业务的特定标识。
以下脚本显示了如何使用 Python 客户端访问业务应用编程接口:
# Chap08/yelp_get_business.py
from argparse import ArgumentParser
from yelp_client import get_yelp_client
def get_parser():
parser = ArgumentParser()
parser.add_argument('--id')
parser.add_argument('--language', default='en')
return parser
if __name__ == '__main__':
client = get_yelp_client()
parser = get_parser()
args = parser.parse_args()
params = {
'lang': args.language
}
response = client.get_business(args.id, **params)
business = response.business
print("Review count: {}".format(business.review_count))
for review in business.reviews:
print("{} (by {})".format(review.excerpt, review.user.name))
业务应用编程接口的一个主要限制是没有完整地提供给定业务的评审列表。事实上,只给出了一个评论。这个单独的审查是业务应用编程接口提供的唯一一个搜索应用编程接口没有显示的附加信息,因此在这种情况下没有太多的数据挖掘空间。
有趣的是,Yelp 为学术研究提供了一个数据集,该数据集会不时更新和丰富(https://www.yelp.com/dataset_challenge)。数据集包括关于企业、用户、评论、图片和用户之间联系的详细信息。这些数据通常可用于自然语言处理、图形挖掘和机器学习的应用。数据集还与一项挑战相关,即学生因其提交的技术质量而获得奖励。
构建自定义 Python 客户端
在整本书中,我们通过 Python 库访问了不同的社交媒体平台,这些平台要么由社交媒体服务器本身正式支持,要么由第三方提供。
这一部分回答了这个问题:如果他们没有为他们的应用编程接口提供 Python 客户端(也没有非官方的库)会怎么样?
HTTP 做得简单
为了实现对所需应用编程接口的调用,推荐的简单 HTTP 交互库是请求。在我们的虚拟环境中,使用以下命令:
$ pip install requests
请求库提供了一个简单的接口来执行 HTTP 调用。为了测试这个库,我们将为httpbin.org服务实现一个简单的客户端,这是一个网络服务,提供基本的请求/响应交互,非常适合我们的教育目的。
httpbin.org网页(http://httpbin.org/)解释了各种端点及其含义。为了展示请求库的使用,我们将实现一个HttpBinClient类,该类具有调用一些基本端点的方法:
class HttpBinClient(object):
base_url = 'http://httpbin.org'
def get_ip(self):
response = requests.get("{}/ip".format(self.base_url))
my_ip = response.json()['origin']
return my_ip
def get_user_agent(self):
response = requests.get("{}/user-agent".format(self.base_url))
user_agent = response.json()['user-agent']
return user_agent
def get_headers(self):
response = requests.get("{}/headers".format(self.base_url))
headers = HeadersModel(response.json())
return headers
客户端有一个base_url类变量,用来存储要调用的基础 URL。get_ip()、get_user_agent()和get_headers()方法的实现分别调用/ip、/user-agent和/headers端点,使用base_url变量构建完整的网址。
对于这三种方法,与请求库的交互非常相似。使用requests.get()调用端点,向端点发送 HTTP GET 请求并返回响应对象。这个对象有一些属性,如status_code(存储响应状态)和text(存储原始响应数据),以及我们将用来将 JSON 响应加载到字典中的json()方法。
虽然 IP 和用户代理是简单的字符串,但是 headers 对象有点复杂,因为有几个属性。因此,响应被包装到一个自定义的HeadersModel对象中,定义如下:
class HeadersModel(object):
def __init__(self, data):
self.host = data['headers']['Host']
self.user_agent = data['headers']['User-Agent']
self.accept = data['headers']['Accept']
如果我们将前面的类保存在一个名为httpbin_client.py的文件中,我们可以使用示例应用程序中的自定义客户端,如下所示:
>>> from httpbin_client import HttpBinClient
>>> client = HttpBinClient()
>>> print(client.get_user_agent())
python-requests/2.8.1
>>> h = client.get_headers()
>>> print(h.host)
httpbin.org
网络应用编程接口的简单性和所提出的例子不应该误导读者认为我们只是给一个简单的任务增加了一层复杂性。事实上,我们可以使用请求库直接与应用程序中的 web API 进行交互。一方面,对于这个特定的例子来说,这可能是真的,但另一方面,社交媒体 API 在本质上是极其动态的。添加新的端点,更改响应格式,并不断开发新功能。例如,第 4 章、脸书上的帖子、页面和用户交互被起草后,脸书引入了新的 reactions 功能,这也引发了他们 API 响应的变化。
换句话说,定制的 Python 客户机增加了一层抽象,使我们能够专注于客户机的接口,而不是 web API。从应用的角度来看,我们会重点调用客户端的方法,比如HttpBinClient.get_headers(),隐藏/headers API 端点如何在这样的方法内部发送响应的细节。如果来自网络应用编程接口的响应格式将来会发生变化,我们所需要做的就是更新客户端以反映新的变化,但是由于这种额外的抽象层,我们所有与网络应用编程接口交互的应用程序都不会受到影响。
httpbin.org服务实际上并不复杂,因此社交媒体 API 客户端的真正实现可能并不那么简单,但总而言之,这就是它的要点:用抽象层隐藏实现细节。这是我们到目前为止采用的所有 Python 客户端都在做的事情。
型式
HTTP 状态码
为了更好地理解 HTTP 交互是如何工作的,我们必须提到响应状态代码的重要性。在底层,来自 HTTP 服务器的每个响应都以一个数字状态代码打开,后面跟一个描述状态代码背后动机的原因短语。例如,当我们将浏览器指向一个特定的网址时,网络服务器可以回复一个状态代码200,后跟原因短语OK。然后,响应包括我们请求的实际网页,浏览器可以显示该网页。网络服务器的另一个选择是用状态代码404来响应,后面跟着原因短语Not Found。浏览器可以理解这个响应,并向用户显示适当的错误消息。
Web APIs 与前面描述的简单交互没有什么不同。根据请求,服务器可以发送成功的响应(状态代码2xx)或错误(客户端错误的状态代码4xx,服务器错误的状态代码5xx)。客户端实现应该能够正确地解释网络应用编程接口提供的不同状态代码,并做出相应的行为。还值得一提的是,一些服务引入了非标准的状态码,比如推特的错误码420、增强你的冷静,当客户端发送太多请求时使用,必须有速率限制。
状态代码及其含义的完整列表可在https://en.wikipedia.org/wiki/List_of_HTTP_status_codes获得,并附有简化说明,但正式文件包含在相应的 RFC 文件中。
考虑到开源库的可用性,从零开始实现客户端的需求在今天应该是相当罕见的。如果是这种情况,读者现在应该准备开始了。
总结
本章讨论了在社交媒体上挖掘数据的更多选项。除了通常流行的社交网络,如推特、脸书和谷歌+,许多其他平台也提供访问其数据的应用编程接口。
当 Python 客户端不可用于特定平台时,我们可以实现一个定制客户端,利用请求库的简单性。定制客户端的实现起初似乎增加了不必要的复杂性,但它提供了一个重要的抽象层,从长远来看,这在设计方面是有回报的。
除了定制客户端实现的说明之外,我们还研究了一些其他流行的服务来挖掘复杂的对象。我们从 YouTube 上检索了关于视频的数据,从 GitHub 上检索了开源项目的数据,从 Yelp 上检索了餐馆等本地企业的数据。
在这本书里,还有很多社交媒体平台没有被探索,因为提供一个全面的讨论是不现实的,但我希望读者能很好地体会到存在的许多可能性。
下一章是这本书的最后一章。它提供了一个关于语义网的讨论,并为读者理解语义标记数据的重要性提供了一个前景。
九、链接数据和语义网
本章概述了语义网和相关技术。在本章中,我们将讨论以下主题:
- 讨论语义网作为数据网的基础
- 讨论微格式、链接数据和 RDF
- 从数据库中挖掘语义关系
- 从维基百科中挖掘地理信息
- 将地理信息绘制到谷歌地图中
数据之网
致力于开发网络标准的国际组织万维网联盟 ( W3C )为语义网提出了一个简单的定义(https://www.w3.org/standards/semanticweb/data,2016 年 4 月检索到:
“语义网是数据网”
两者,术语语义网和这个定义,都是由网络发明者蒂姆·伯纳斯·李爵士(https://en.wikipedia.org/wiki/Tim_Berners-Lee)自己创造的,他也是 W3C 的主任。当他谈到他最伟大的发明时,他经常强调它的社会含义,以及网络如何更像是一种社会创造而不是一种技术创造(编织网络、蒂姆·伯纳斯·李、 1999 )。
如果我们简要分析一下网络作为一个社交平台的演变,它的前景会变得更加清晰。在新千年的第一个十年里流行起来的关键词(或流行语)之一是 Web 2.0 ,它诞生于 20 世纪 90 年代末,但后来被蒂姆·奥莱利普及。这个术语暗示了一个新版本的网络,但它并不涉及任何特定的技术更新或规范的变化,而是关注从旧的静态网络 1.0 到以用户为中心的理解网络和丰富用户体验的方法的逐渐演变。
从 Web 1.0 到 Web 2.0 的演变可以用一个词来概括——协作。蒂姆·伯纳斯·李不同意这种区别,因为 Web 2.0 中使用的技术和标准与旧 Web 1.0 中使用的技术和标准基本相同。此外,自从网络出现以来,协作和连接人们就是最初的目标。尽管有这种不同的观点,网络 2.0 这个术语——不管是不是行话——已经成为我们词汇的一部分,经常与社交网络这个术语结合在一起(更复杂的是,社交网络有时也被称为网络 2。十)。
那么我们把语义网放在哪里呢?根据网络的创造者,这是进化尺度上的又一步,也就是网络 3.0。
网络的自然进化是成为一个数据网络。对这一进程至关重要的是数据模型和数据表示,它们允许以机器可理解的方式共享知识。使用以一致和语义格式共享的数据,机器可以利用这些信息,并支持用户的复杂信息需求以及决策。
万维网上文档的主流格式是 HTML。这种格式是一种用于描述文档结构的标记约定,它结合了文本、图像或视频等多媒体对象以及文档之间的链接。
HTML 允许内容管理者在文档的标题中指定一些元数据。此类信息不一定用于显示目的,因为浏览器通常只显示文档的正文。元数据标签包括作者姓名、版权信息、文档的简短描述以及描述文档的关键词。所有这些细节都可以通过计算机来解释,从而对文件进行分类。HTML 在指定更复杂的信息方面有所欠缺。
例如,使用 HTML 元数据,我们可以描述一个文档是关于伍迪·艾伦的,但是语言不支持对更复杂的概念进行歧义消除,例如,该文档是关于伍迪·艾伦扮演的电影中的一个角色,还是伍迪·艾伦导演的电影,或者是其他人导演的关于伍迪·艾伦的纪录片?因为 HTML 的目的是描述文档的结构,所以它可能不是在这个粒度级别上表示知识的最佳工具。
另一方面,语义网技术允许我们更进一步,描述那些根据实体、属性以及它们之间的关系来表征我们的世界的概念。例如,艾伦从影记录的一部分如下表所示:
| **艺人** | **角色** | **电影** | | 伍迪·艾伦 | 导演 | 曼哈顿 | | 伍迪·艾伦 | 行动者 | 曼哈顿 | | 伍迪·艾伦 | 导演 | 匹配点 |该表明确显示了名为艺术家和电影的实体类型之间的联系。表示这种结构化知识是语义网技术的领域。
本节的其余部分提供了与语义网和知识表示主题相关的主要概念和术语的概述。它介绍了旨在回答类似伍迪·艾伦例子中的复杂查询的技术。
语义网词汇
本节简要介绍和概括了语义技术中常用的一些基本词汇。
标记语言:这是一个用来标注文档的系统。给定一段文本,或者更一般地说,一段数据,标记语言允许内容管理者根据标记语言的特定含义来标记文本(数据)块。我们已经遇到的一种著名的标记语言是 HTML。标记语言可以包括表示性注释和描述性注释。前者与文档的显示方式有关,而后者提供了带注释数据的描述。在 HTML 的情况下,表示和描述标签都是可用的。例如,<b>或<i>标签代表一种风格,内容设计者可以使用它们来声明特定的文本必须以粗体或斜体显示。另一方面,<strong>和<em>标签提供了特定的语义,因为它们指示一段文本应该以某种方式分别被提供为强的或强调的。在普通浏览器中,<b>和<strong>都以粗体显示,就像<i>和<em>都以斜体显示一样。盲人用户不会从想象一个大胆的风格中受益,但是将一个短语标记为<strong>可以让屏幕阅读器理解该短语本身应该如何阅读。
语义标注:如前所述,事物的呈现方式和理解方式是有区别的。语义标记是描述所呈现信息的意义,而不是它的外观。在 HTML 和 XHTML 中,虽然不建议使用表示标记,但并不明确反对使用它们。HTML5 已经向语义标记迈进了一步,引入了一些语义标签,如<article>或<section>。与此同时,<b>、<i>等表象标签被保留了一个精确的含义(也就是说,在文体上不同于普通散文,不表达任何特别的重要性)。
本体:语义网的支柱之一就是本体。它们是特定业务领域实体的类型、属性和关系的正式命名和定义。本体的例子包括 WordNet(https://wordnet.princeton.edu/),一种词汇资源,其中术语根据其含义被分类成概念,以及 SNOMED CT(http://www.ihtsdo.org/snomed-ct),一种医学词汇。
维基百科显示了来自 WordNet(https://en.wikipedia.org/wiki/WordNet)的一个小摘录:
dog, domestic dog, Canis familiaris
=> canine, canid
=> carnivore
=> placental, placental mammal, eutherian, eutherian mammal
=> mammal
=> vertebrate, craniate
=> chordate
=> animal, animate being, beast, brute, creature, fauna
=> ...
在这个例子中,代表单词dog的一个意思,我们可以欣赏用来代表这一知识的细节水平:狗不仅仅是一种动物,它还根据属于特定的生物家族(犬科动物)或目(食肉动物)来分类。
本体可以使用语义标记来描述,例如,网络本体语言 ( OWL 、https://en.wikipedia.org/wiki/Web_Ontology_Language)允许内容策展人表示知识。构建本体的挑战包括要表示的领域的广阔性,以及围绕人类知识和作为传递知识的载体的自然语言的固有的模糊性和不确定性。手动构建本体需要对被建模的领域有深入的理解。描述和表示知识自哲学诞生之初就一直是人类的基本问题之一,因此我们可以将语义网本体视为哲学本体的一种实际应用。
分类学:比本体更窄,分类法是指知识的层次化表示。换句话说,它们用于对定义明确的实体类进行分类,定义是一个,而在类之间有一个关系。本体和分类法之间的主要区别在于,本体模拟了更多种类的关系。
Folksonomy :这也叫社会分类学。构建大众分类法的实践指的是社会标签。用户可以使用特定的标签来标记一段内容,因为这种标签是对特定类别的解释。结果是一种分类法的民主化,这导致以用户感知的方式显示信息,而没有叠加的刚性结构。硬币的另一面是,野外的大众分类法可能完全没有条理,更难使用。它也可能代表一种特定的趋势,而不是对所描述的业务领域的深刻理解。
在社交媒体的背景下,推特给出了一个大众分类法的经典例子。使用标签,用户可以给他们的推文贴上属于特定主题的标签。这样,其他用户可以搜索特定的主题或关注某个事件。大众分类法的特点在推特标签的使用中非常明显——它们是由发布推文的用户选择的,它们没有层次,没有正式的组织,只有那些被认为有意义的东西才能被人群接受,才能成为潮流(民主化)。
推理推理:在逻辑领域,推理是从已知(或假设)为真的事实中推导出逻辑结论的过程。在这种情况下经常出现的另一个术语是推理。看待这种二分法的一种方式是将推理视为目标,将推理视为实现。
一个经典的推论例子是苏格拉底三段论。让我们考虑以下事实(被认为是真实的断言):
- 每个人都是凡人
- 苏格拉底是个男人
从这些前提出发,一个推理系统应该能够回答这样一个问题:苏格拉底是凡人吗?惊心动魄的答案是是的。
音节的一般情况如下:
- 每一个 A 就是 B
- C 是 A
- 所以 C 就是 B
这里 A 、 B 、 C 可以是类别,也可以是个人。三段论是演绎推理的经典例子。总的来说,动词从、从进行推理,或者对进行推理,在我们的上下文中几乎是同义词,因为它们都需要从一些已知的(真实的)事实中产生新的知识。
鉴于前面关于苏格拉底和其他人的知识库,为了回答这样的问题,柏拉图是否凡人?,我们需要考虑下一段。
封闭世界和开放世界假设:推理系统可以包含关于宇宙的封闭世界或开放世界假设。这种差异对于从有限的已知事实中得出结论至关重要。在基于封闭世界假设的系统中,每个未知的事实都被认为是错误的。另一方面,基于开放世界假设的系统假设未知的事实可能是真的,也可能不是真的,所以它们不会强迫对未知的解释是假的。通过这种方式,他们可以处理不完整的知识,换句话说,只部分指定或随着时间的推移而完善的知识库。
比如经典的逻辑编程语言 Prolog(来源于《逻辑学》中的编程)就是基于闭世界假设。关于苏格拉底的知识库用序言语法表示如下:
mortal(X) :- man(X).
man(socrates).
柏拉图是否是凡人这个问题可以用不来回答。这是因为系统没有任何关于柏拉图的知识,所以无法推断出任何进一步的事实。
封闭世界和开放世界假设之间的二元论也对不同知识库的融合方式产生了影响。如果两个知识库提供相反的事实,基于封闭世界假设的系统会触发错误,因为系统无法处理这种类型的不一致。另一方面,基于开放世界假设的系统将试图通过产生更多的知识并试图找到一种方法对不一致进行排序来从不一致中恢复。一种方法是给事实分配概率:简单来说,两个相互矛盾的事实可能有 50%是真的。
作为封闭世界假设的一个例子,我们可以考虑一个航空订票数据库。系统有关于特定域的完整信息。这就导致它以一种清晰的方式回答了一些问题:如果在办理登机手续时分配了座位,并且某个乘客预订了航班,但没有分配座位,我们可以推断该乘客还没有办理登机手续,并发送电子邮件提醒该乘客在线办理登机手续的选项。
另一方面,当系统没有给定领域的完整信息时,开放世界假设适用。例如,一个在线发布职位空缺广告的基于网络的平台知道给定职位的具体位置和申请人的所在地。如果一家公司为他们在纽约的办公室发布了一个职位广告,并且他们收到了一个来自欧洲的专业人士的申请,系统不能仅仅通过使用本节中描述的知识来推断该专业人士是否有权在美国工作。由于这个原因,这样的系统可能需要明确地询问申请人这个问题,然后他们才能继续申请。
逻辑和逻辑编程是广阔的研究领域,因此不可能在一小段时间内提炼出理解该主题所有方面所必需的所有知识。为了本章的目的,我们仅限于提及,一般来说,在网络上,假设知识库不完整会更安全,因为网络是一个信息不完整的系统。其实像资源描述框架 ( RDF )这样的框架都是基于开放世界的假设。
微格式
微格式(http://microformats.org)扩展了 HTML,允许作者指定关于个人、组织、事件和几乎任何不同类型对象的机器可读语义注释。它们本质上是惯例,为内容创作者提供了在网页中嵌入明确的结构化数据的机会。最新的发展被纳入微格式 2(http://microformats.org/wiki/microformats2)。
有趣的微格式包括以下示例:
-
h-card:代表人员、组织和相关联系信息 -
h-event:表示事件信息,如地点、开始时间等 -
h-geo:表示地理坐标,与h-card``h-event配合使用 -
XHTML 朋友圈 ( XFN ):用于通过超链接表示人际关系
-
h-resume:在网上发布简历和履历,包括学历、经验和技能 -
h-product:描述品牌、价格或描述等产品相关数据 -
h-recipe:这代表网络上的食谱,包括配料、数量和说明 -
h-review:发布任何项目的评论(例如引用h-card、h-event、h-geo或h-product,包括评级信息和描述
下面的代码片段包括一个用于通过超链接表示人际关系的 XFN 标记示例:
<div>
<a href="http://marcobonzanini.com" rel="me">Marco<a>
<a href="http://example.org/Peter" rel="friend met">Peter<a>
<a href="http://example.org/Mary" rel="friend met">Mary<a>
<a href="http://example.org/John" rel="friend">John<a>
</div>
XFN 通过rel属性增加了常规的 HTML。一些 HTML 链接已经根据人与人之间的关系进行了标注。第一个链接被标记为me,这意味着该链接与文档的作者相关。其他链接使用friend和met类进行标记,后者表示有人见过面(而不是仅在线连接)。其他类包括,例如,spouse、child、parent、acquaintance或co-worker。
以下片段包括一个用于表示伦敦地理坐标的h-geo标记示例:
<p class="h-geo">
<span class="p-latitude">51.50722</span>,
<span class="p-longitude">-0.12750</span>
</p>
虽然h-geo是这种微格式的最新版本,但旧版本(简称geo)仍然被广泛使用。前面的例子可以用旧格式改写如下:
<div class="geo">
<span class="latitude">51.50722</span>,
<span class="longitude">-0.12750</span>
</div>
如果这些微格式的例子看起来很容易理解,那是因为微格式意味着简单。微格式中的微代表了他们非常专注于一个非常特定的领域的特征。单个来说,微格式规范解决了受限领域中定义非常明确的问题(例如描述人际关系或提供地理坐标),仅此而已。微格式的这一方面也使它们具有高度的可组合性——一个复杂的文档可以采用几种微格式以不同的方式丰富所提供的信息。此外,一些微格式可以嵌入其他微格式以提供更丰富的数据— h-card可以包括h-geo、h-event可以包括h-card和h-geo等等。
链接数据和开放数据
术语链接数据是指使用 W3C 标准在网络上发布结构化数据的一套原则,其方式有利于专门的算法来利用数据之间的联系。
由于语义网不仅仅是把数据放在网上,而且是允许人们(和机器)利用这些数据并探索它,蒂姆·伯纳斯·李提出了一套规则来促进链接数据的发布,并走向他所定义的链接开放数据,即通过开放许可获得的链接数据(https://www.w3.org/DesignIssues/LinkedData.html)。
类似于通过超链接链接在一起的文档网络,数据网络也基于发布在网络上的文档并链接在一起。另一方面,数据之网是关于最通用形式的数据,而不仅仅是关于文档。描述任意事物的格式选择是 RDF 而不是(X)HTML。
Berners-Lee 建议的四个规则可用作构建链接数据的实施指南,如下所示:
- 用 URIs 作为事物的名称
- 使用 HTTP URIs,这样人们就可以查找这些名字
- 当有人查找 URI 时,使用标准(RDF*,SPARQL)提供有用的信息
- 包括到其他 URIs 的链接,这样他们可以发现更多的东西
这些基本原则提供了一组期望,内容发布者应该满足这些期望才能生成链接数据。
为了鼓励人们接受链接数据,已经开发了一个五星评级系统,以便人们能够自我评估他们的链接数据,并了解链接开放数据的重要性。这对于与政府相关的数据所有者来说尤其如此,这是一种迈向透明的总体努力。五星评级体系描述如下:
- 一星:这可以在网上获得(任何格式),但是需要开放许可才能成为开放数据
- 双星:这是机器可读的结构化数据(例如,在 Excel 中代替表格的图像扫描)
- 三星级:此为二星级,加非专有格式(例如 CSV 代替 Excel)
- 四星:这个和前面所有的评级一样,但是它也使用了 W3C (RDF 和 SPARQL)的开放标准来识别事物,这样人们就可以指向你的东西
- 五星:这个和前面所有的评分一样,但是它也把你的数据和别人的数据联系起来,提供上下文
达到五星后,数据集的下一步是提供额外的元数据(关于数据的数据),如果是政府数据,则分别在美国、英国和欧盟的主要数据目录中列出,如https://www.data.gov/、https://data.gov.uk/或http://data.europa.eu/euodp/en/data。
很明显,链接数据是语义网的一个基本概念。实现链接数据的数据集的一个著名例子是 DBpedia(http://dbpedia.org),这是一个旨在从维基百科上发布的信息中提取结构化知识的项目。
资源描述框架
资源描述框架 ( RDF )来自 W3C 规范家族,最初设计为元数据模型,用于结构化知识建模(https://en . Wikipedia . org/wiki/Resource _ Description _ Framework)。
类似于经典的概念建模框架,如数据库设计中流行的实体关系模型,RDF 基于声明关于资源的语句的思想。这种语句被表示为三元组(也称为三元组),因为它们有三个基本组成部分:主语、谓语和宾语。
以下是以主谓宾三元组表示的语句示例:
(Rome, capital_of, Italy)
(Madrid, capital_of, Spain)
(Peter, friend_of, Mary)
(Mary, friend_of, Peter)
(Mary, lives_in, Rome)
(Peter, lives_in, Madrid)
前面的语法是任意的,没有遵循特定的 RDF 规范,但是它作为一个例子来理解数据模型的简单性。基于三元组的存储语句的方法允许表示任何种类的知识。RDF 语句的集合可以自然地看作一个图(https://en.wikipedia.org/wiki/Graph_theory),特别是标记有向多重图;标记是因为节点(资源)和边(谓词)与信息相关联,例如边的名称;定向是因为主谓宾关系有明确的方向;多重图因为相同节点之间的多个平行边是可能的,这意味着两个相同的资源可以彼此处于几种不同的关系中,所有这些关系都具有不同的语义。
图 9.1 允许我们以面向图的方式可视化前面例子中表达的知识:

图 9.1:基于三重知识的图形可视化
知识表示的下一层是 RDF 何时开始,因为它允许我们为三元组定义一些额外的结构。名为rdf:type的谓词允许我们声明某些对象属于某种类型。除此之外,RDF Schema 或 RDFS(https://en.wikipedia.org/wiki/RDF_Schema)允许我们定义类和类之间的关系。一种相关的技术 OWL 也允许我们表达类之间的关系,但是它提供的关于数据模型的信息在约束和注释方面更丰富。
RDF 可以被序列化(即导出)成许多不同的格式。虽然 XML 可能是最著名的方法,但也有其他选择,如 N3(https://en.wikipedia.org/wiki/Notation3)。这是为了澄清 RDF 是一种用三元组表示知识的方式,而不仅仅是一种文件格式。
JSON-LD
几乎在前面的所有章节中,我们都已经遇到了 JSON 格式,并欣赏它的灵活性。JSON 和链接数据之间的连接是通过 JSON-LD 格式(http://json-ld.org)来实现的,这是一种基于 JSON 的轻量级数据格式,因此对于人和机器来说都很容易读写。
当链接来自不同数据源的数据时,我们可能会面临一个模糊问题。让我们考虑以下两个代表一个人的 JSON 文档:
/* Document 1 */
{
"name": "Marco",
"homepage": "http://marcobonzanini.com"
}
/* Document 2 */
{
"name": "mb123",
"homepage": "http://marcobonzanini.com"
}
我们会注意到模式是相同的:两个文档都有name和homepage属性。两个数据源可能使用具有不同含义的相同属性名,这一事实产生了歧义。事实上,第一个文档似乎使用name作为人名,而第二个文档使用name作为登录名。
我们有一种感觉,这两个文档指的是同一个实体,因为homepage属性的值是相同的,但是没有进一步的信息,我们无法解决这个歧义。
JSON-LD 引入了一个叫做上下文的简单概念。当使用上下文时,我们能够通过利用一个我们已经熟悉的概念——网址来解决像这样的模糊情况。
让我们考虑以下摘自 JSON-LD 网站的片段:
{
"@context": "http://json-ld.org/contexts/person.jsonld",
"@id": "http://dbpedia.org/resource/John_Lennon",
"name": "John Lennon",
"born": "1940-10-09",
"spouse": "http://dbpedia.org/resource/Cynthia_Lennon"
}
文档使用@context属性作为其自身有效属性及其含义列表的引用。这样,我们期望在文档中看到什么样的属性以及它们的含义就不会有任何歧义。
前面的例子还介绍了 JSON-LD 中使用的另一个特殊属性,@id。此属性用作全局标识符。当引用同一个实体时,不同的应用程序可以使用全局标识符。这样,来自多个数据源的数据可以被消除歧义并链接在一起。例如,一个应用程序可以采用为一个人定义的标准模式(http://json-ld.org/contexts/person.jsonld)并用附加属性对其进行扩充。
上下文(属性的列表和含义)和全局识别实体的方式是链接数据的基础,JSON-LD 格式以简单优雅的方式解决了这个问题。
Schema.org
2011 年作为一些主要搜索引擎公司(谷歌、必应和雅虎!后来 Yandex 加入了他们),这个计划是一个协作努力,为要在网页上使用的结构化标记数据创建一组共享的模式。
该提案是关于使用schema.org(http://schema.org/)词汇以及微格式、基于 RDF 的格式或 JSON-LD 来用关于网站本身的元数据来扩充网站内容。使用建议的模式,内容管理者可以用额外的知识来标记他们的网站,这有助于搜索引擎和其他解析器更好地理解内容。
关于组织和个人的模式被用来影响系统,例如谷歌的知识图(Knowledge Graph),这是一个知识库,每当特定实体是搜索的主题时,它都会用语义信息增强谷歌的搜索引擎结果。
从数据库中挖掘关系
DBpedia 是最著名的链接数据源之一。它以维基百科为基础,通过实体之间的语义联系来扩充流行的维基百科的内容。来自 DBpedia 的结构化信息可以通过网络使用一种类似 SQL 的语言来访问,这种语言被称为 SPARQL,一种 RDF 的语义查询语言。
在 Python 中,虽然我们可以选择使用 SPARQL 查询数据库,但是我们可以利用 RDFLib 包,这是一个用于处理 RDF 的库。
从我们的虚拟环境中,我们可以使用pip进行安装:
$ pip install rdflib
考虑到语义网主题的复杂性,我们更喜欢挖掘一个例子,这样您就可以有 DBpedia 功能的味道,同时,获得如何使用 RDFLib 包的概述。
rdf_summarize_entity.py脚本查找给定的实体,并尝试向用户输出其摘要:
# Chap09/rdf_summarize_entity.py
from argparse import ArgumentParser
import rdflib
def get_parser():
parser = ArgumentParser()
parser.add_argument('--entity')
return parser
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
entity_url = 'http://dbpedia.org/resource/{}'.format(args.entity)
g = rdflib.Graph()
g.parse(entity_url)
disambiguate_url = 'http://dbpedia.org/ontology/wikiPageDisambiguates'
query = (rdflib.URIRef(entity_url),
rdflib.URIRef(disambiguate_url),
None)
disambiguate = list(g.triples(query))
if len(disambiguate) > 1:
print("The resource {}:".format(entity_url))
for subj, pred, obj in disambiguate:
print('... may refer to: {}'.format(obj))
else:
query = (rdflib.URIRef(entity_url),
rdflib.URIRef('http://dbpedia.org/ontology/abstract'),
None)
abstract = list(g.triples(query))
for subj, pred, obj in abstract:
if obj.language == 'en':
print(obj)
剧本照常使用ArgumentParser。--entity参数用于将实体名称传递给脚本。该脚本按照dbpedia.org/resource/<entity-name>模式在 DBpedia 上建立实体 URL。该网址最初用于使用rdflib.Graph类构建 RDF 图。
下一步是寻找任何歧义消除,或者换句话说,检查给定的名称是否可能引用多个实体。这是通过使用实体 URL 作为主题、wikiPageDisambiguates关系作为谓词和一个空对象来查询图中的三元组来完成的。为了构建主语、谓语和宾语,我们需要使用rdflib.URIRef类,该类将实体/关系的网址作为唯一的参数。作为三重元素之一的None对象被triples()方法用作任何可能值的占位符(本质上,它是一个通配符)。
如果找到歧义消除,列表将打印出来给用户。否则,将查找实体,特别是使用triples()方法检索其摘要,并留下None对象。一个给定的实体可以有多个摘要,因为内容可以用不同的语言提供。当我们迭代结果时,我们将只打印英文版本(标有en)。
例如,我们可以使用脚本来总结 Python 语言的描述:
$ python rdf_summarize_entity.py \
--entity "Python"
仅仅用Python这个词在维基百科上搜索,并不能直接导致我们所期待的结果,因为这个词本身就是模糊的。事实上,有几个实体可能与查询词匹配,因此脚本将打印出完整的列表。以下是输出的简短片段:
The resource http://dbpedia.org/resource/Python:
... may refer to: http://dbpedia.org/resource/Python_(film)
... may refer to: http://dbpedia.org/resource/
Python_(Coney_Island,_Cincinnati,_Ohio)
... may refer to: http://dbpedia.org/resource/Python_(genus)
... may refer to: http://dbpedia.org/resource/Python_(programming_language)
# (more disambiguations)
当我们将精确的实体标识为Python_(programming_language)时,我们可以使用正确的实体名称重新运行脚本:
$ python rdf_summarize_entity.py \
--entity "Python_(programming_language)"
这一次,输出正是我们想要的。输出的简短片段如下:
Python is a widely used general-purpose, high-level programming language. Its design philosophy emphasizes code readability, (snip)
这个例子展示了如何将基于三元组的 RDF 的高级概念模型与 Pythonic 接口相结合是一项相对简单的任务。
挖掘地理坐标
如前所述,geo和h-geo是发布地理信息的微格式。在阅读一本关于社交媒体的书时,人们可能会问地理元数据是否属于社交数据的描述。在考虑地理数据时,要记住的核心思想是地理信息可以在许多应用中得到利用。例如,用户可能希望对企业执行特定于位置的搜索(如在 Yelp 中),或者查找从特定位置拍摄的照片。更一般地说,每个人都在某个地方,或者在寻找某个地方。地理元数据使应用程序能够满足特定于位置的定制需求。在社交和移动数据时代,这些定制为应用程序开发人员打开了许多新的视野。
回到我们的语义标记数据之旅,本节描述如何使用维基百科从网页中提取地理元数据。提醒一下,经典的geo微格式标记如下:
<p class="geo">
<span class="latitude">51.50722</span>,
<span class="longitude">-0.12750</span>
</p>
较新的h-geo格式也非常相似,类名有一些变化。一些维基百科模板使用稍微不同的格式,如下所示:
<span class="geo">51.50722; -0.12750</span>
为了从维基百科页面中提取这些信息,基本上需要执行两个步骤,检索页面本身和解析 HTML 代码,寻找所需的类名。以下部分详细描述了该过程。
从维基百科中提取地理数据
从网页中提取地理数据的过程很容易从头开始,例如,使用我们已经讨论过的库,如请求和美汤。尽管很简单,但有一个 Python 包专门解决这个特殊问题,叫做 mf2py ,它提供了一个简单的界面来快速解析给定网址的网络资源。该库还提供了一些帮助函数,用于将解析后的数据从 Python 字典移动到 JSON 字符串。
从我们的虚拟环境中,我们可以像往常一样使用pip安装mf2py:
$ pip install mf2py
该库提供了三种方法来解析语义标记的数据,因为我们可以将一段内容作为字符串、文件指针或网址传递给parse()函数。下面的示例显示了如何解析带有语义标记的字符串:
>>> import mf2py
>>> content = '<span class="geo">51.50722; -0.12750</span>'
>>> obj = mf2py.parse(doc=content)
obj变量现在包含解析内容的字典。我们可以通过将它作为 JSON 字符串转储来检查它的内容,以便进行漂亮的打印:
>>> import json
>>> print(json.dumps(obj, indent=2))
{
"items": [
{
"type": [
"h-geo"
],
"properties": {
"name": [
"51.50722; -0.12750"
]
}
}
],
"rels": {},
"rel-urls": {}
}
如果我们想将文件指针传递给解析函数,过程非常相似,如下所示:
>>> with open('some_content.xml') as fp:
... obj = mf2py.parse(doc=fp)
另一方面,如果我们想向解析函数传递一个 URL,我们需要使用url参数而不是doc参数,如下所示:
>>> obj = mf2py.parse(url='http://example.com/your-url')
mf2py 库的解析功能基于美丽的汤。在没有显式指定解析器的情况下调用美丽的汤时,库将尝试猜测可用的最佳选项。例如,如果我们已经安装了 lxml (正如我们在第 6 章、堆 StackExchange 问答中所做的那样),这将是所选择的选项。调用parse()函数将会触发一个警告:
UserWarning: No parser was explicitly specified, so I'm using the best available HTML parser for this system ("lxml"). This usually isn't a problem, but if you run this code on another system, or in a different virtual environment, it may use a different parser and behave differently.
To get rid of this warning, change this:
BeautifulSoup([your markup])
to this:
BeautifulSoup([your markup], "lxml")
这个消息是由美丽的汤提出的,但是我们没有直接与这个库交互,所以关于如何修复这个问题的建议可能会令人困惑。抑制警告的解决方法是将所需的解析器名称显式传递给parse()函数,如下所示:
>>> obj = mf2py.parse(doc=content, html_parser='lxml')
在长时间介绍 mf2py 库之后,我们可以将事情放在上下文中,并尝试从维基百科页面解析地理信息。
micro_geo_wiki.py脚本将维基百科的网址作为输入,并显示一个带有相关坐标的地点列表,如果在内容中发现任何地理标记:
# Chap09/micro_geo_wiki.py
from argparse import ArgumentParser
import mf2py
def get_parser():
parser = ArgumentParser()
parser.add_argument('--url')
return parser
def get_geo(doc):
coords = []
for d in doc['items']:
try:
data = {
'name': d['properties']['name'][0],
'geo': d['properties']['geo'][0]['value']
}
coords.append(data)
except (IndexError, KeyError):
pass
return coords
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
doc = mf2py.parse(url=args.url)
coords = get_geo(doc)
for item in coords:
print(item)
脚本使用ArgumentParser从命令行获取输入。--url参数用于传递我们要解析的页面的 URL。
然后脚本调用mf2py.parse()函数,传递这样的网址作为参数,获得带有微格式信息的字典。
提取地理坐标的逻辑核心由get_geo()函数处理,该函数将解析后的字典作为输入,并返回字典列表作为输出。输出中的每个字典都有两个键:name(地名)和geo(坐标)。
我们可以观察正在运行的脚本,例如,使用以下命令:
$ python micro_geo_wiki.py \
--url "https://en.wikipedia.org/wiki/London"
运行此命令将产生以下输出:
{'name': 'London', 'geo': '51.50722; -0.12750'}
不出所料,在维基百科关于伦敦的页面上,我们找到的唯一坐标是以伦敦市为中心的坐标。
维基百科还提供了许多包含地点列表的页面,如下所示:
$ python micro_geo_wiki.py --url \
"https://en.wikipedia.org/wiki/List_of_United_States_cities_by_population"
运行此命令将产生一个长输出(超过 300 行),其中包含几个美国城市的地理信息。以下是输出的简短片段:
{'geo': '40.6643; -73.9385', 'name': '1 New York City'}
{'geo': '34.0194; -118.4108', 'name': '2 Los Angeles'}
{'geo': '41.8376; -87.6818', 'name': '3 Chicago'}
# (snip)
这个例子证明了从网页中提取地理数据并没有什么特别困难的。mf2py 库的简单性允许我们只用几行代码就能完成任务。
下面的部分更进一步:一旦我们有了一些地理信息,我们可以用它做什么?绘制地图似乎是一个简单的答案,所以我们将利用谷歌地图来可视化我们的地理数据。
在谷歌地图上绘制地理数据
谷歌地图(https://www.google.com/maps)是一项非常受欢迎的服务,可能不需要太多介绍。在其众多功能中,它提供了创建带有兴趣点列表的自定义地图的可能性。
一种自动创建地图并保证不同应用程序之间互操作性的方法是使用一种通用格式来共享坐标。在本节中,我们将讨论使用锁眼标记语言(KML)(https://en.wikipedia.org/wiki/Keyhole_Markup_Language)作为以谷歌地图识别的格式导出数据的方式。KML 格式是一种用二维和三维地图表达地理数据的 XML 符号。最初是为另一个谷歌产品谷歌地球开发的,现在被用于各种应用。
下面的代码片段显示了一个 KML 文档的例子,用来表示伦敦在地图上的位置:
<?xml version="1.0" encoding="UTF-8"?>
<kml >
<Document>
<Placemark>
<name>London</name>
<description>London</description>
<Point>
<coordinates>-0.12750,51.50722</coordinates>
</Point>
</Placemark>
</Document>
</kml>
我们注意到coordinates标签的值。虽然从维基百科提取的地理数据遵循latitude; longitude格式,但 KML 标记使用longitude,latitude,altitude-海拔是可选的,如果省略,则假设为零,如前例所示。
为了在谷歌地图上可视化地理坐标列表,我们的中间任务是以 KML 格式生成这样的列表。在 Python 中,有一个名为 PyKML 的库简化了这个过程。
从我们的虚拟环境中,我们可以像往常一样使用pip安装库:
$ pip install pykml
以下micro_geo2kml.py脚本扩展了之前从维基百科提取的地理数据,以便输出 KML 格式的文件。
注
该脚本试图使用 lxml 库来处理字符串的 xml 序列化。如果库不存在,它将返回到元素树库,这是 Python 标准库的一部分。关于 lxml 库的详细信息,请参考第 6 章、StackExchange 问答。
# Chap09/micro_geo2kml.py
from argparse import ArgumentParser
import mf2py
from pykml.factory import KML_ElementMaker as KML
try:
from lxml import etree
except ImportError:
import xml.etree.ElementTree as etree
def get_parser():
parser = ArgumentParser()
parser.add_argument('--url')
parser.add_argument('--output')
parser.add_argument('--n', default=20)
return parser
def get_geo(doc):
coords = []
for d in doc['items']:
try:
data = {
'name': d['properties']['name'][0],
'geo': d['properties']['geo'][0]['value']
}
coords.append(data)
except (IndexError, KeyError):
pass
return coords
if __name__ == '__main__':
parser = get_parser()
args = parser.parse_args()
doc = mf2py.parse(url=args.url)
coords = get_geo(doc)
folder = KML.Folder()
for item in coords[:args.n]:
lat, lon = item['geo'].split('; ')
place_coords = ','.join([lon, lat])
place = KML.Placemark(
KML.name(item['name']),
KML.Point(KML.coordinates(place_coords))
)
folder.append(place)
with open(args.output, 'w') as fout:
xml = etree.tostring(folder,
pretty_print=True).decode('utf8')
fout.write(xml)
脚本使用ArgumentParser从命令行解析三个参数:--url用于获取要解析的网页,--output用于指定用于转储地理信息的 KML 文件的名称,可选的--n参数默认为20,用于指定我们要在地图中包含多少个地方。
在主模块中,我们将解析一个给定的页面,并像以前一样使用get_geo()函数获取地理坐标。这个函数的返回值是一个字典列表,每个字典都有name和geo作为键。在 KML 术语中,一个地方被称为地标,而一个地方列表被称为文件夹。换句话说,脚本必须将 Python 字典列表翻译成地标文件夹。
该任务通过将folder变量初始化为空的KML.Folder对象来执行。当我们遍历带有地理信息的字典列表时,我们将为列表中的每个条目建立一个KML.Placemark对象。地标由一个KML.name和KML.Point组成,是持有地理坐标的物体。
为了使用 KML 格式构建坐标,我们需要拆分坐标字符串,最初采用latitude; longitude格式,然后交换两个值,并用逗号替换分号,这样存储在place_coords变量中的最终字符串就采用了longitude,latitude格式。
建立地标列表后,最后一步是将对象转储到文件中的 XML 表示中。这是使用tostring()方法执行的,该方法返回一个bytes对象,因此需要在写入文件之前进行解码。
我们可以使用以下示例运行脚本:
$ python micro_geo2kml.py \
--url "https://en.wikipedia.org /wiki/
List_of_United_States_cities_by_population" \
--output us_cities.kml \
--n 20
地理数据现在保存在us_cities.kml文件中,可以加载到谷歌地图中以生成定制地图。
从谷歌地图菜单中,我们可以选择您的地点作为我们最喜欢的地点列表,然后选择地图作为自定义地图列表。图 9.2 显示带有定制地图列表的菜单:

图 9.2:谷歌地图中定制地图的列表
一旦我们点击创建地图,我们就有机会从一个文件中导入数据,该文件可以是电子表格、CSV 或 KML 文件。当我们选择用我们的脚本生成的us_cities.kml文件时,结果显示在图 9.3 中:

图 9.3:谷歌地图上显示的美国城市人口列表
每个地标都由地图上的一个大头针标识,单击一个大头针将突出显示其详细信息,在本例中是名称和坐标。
总结
在这一章的开头,我们提到了网络的创造者蒂姆·伯纳斯·李的观点。将网络视为一种社会创造而非技术创造是一种软化社会和语义数据边界的观点。
我们还描述了语义网的更大图景,以及多年来提出的不同技术如何结合在一起,并提供了实现这一愿景的机会。尽管在过去的 15-20 年里,关于语义网的炒作和希望相当高,但蒂姆·伯纳斯·李的愿景还不是无处不在的。
由 W3C、政府和其他私人公司(如schema.org)推动的社区努力试图朝着这个方向大力推进。虽然怀疑论者有大量的材料来讨论为什么语义网的承诺还没有完全实现,但我们更愿意关注我们目前拥有的机会。
社交媒体生态系统的当前趋势表明,社交和语义数据共享深层联系。这本书探讨了从几个社交媒体平台挖掘数据的主题,利用了它们的应用编程接口提供的机会,并使用了一些最流行的 Python 工具进行数据挖掘和数据分析。虽然一本书只能触及主题的表面,但我们希望,在这一点上,您可以欣赏社交媒体环境中为数据挖掘从业者和学者提供的所有机会。


浙公网安备 33010602011771号