Python-数据挖掘学习指南-全-

Python 数据挖掘学习指南(全)

原文:annas-archive.org/md5/403522ad77dfa36ee05e0fc0022b1b5e

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

《使用 Python 进行数据挖掘学习》的第二版是针对程序员编写的。它的目的是向广泛的程序员介绍数据挖掘,因为我认为这对于计算机科学领域的所有人来说都至关重要。数据挖掘正迅速成为下一代人工智能系统的基石。即使你发现自己没有构建这些系统,你也会使用它们,与它们交互,并受到它们的指导。理解其背后的过程很重要,这有助于你充分利用它们。

第二版是在第一版的基础上构建的。许多章节和练习是相似的,尽管引入了新的概念,练习的范围也扩大了。那些阅读过第一版的人应该能够快速通过本书,并在途中获取新的知识,并参与额外提出的活动。对于那些是本书的新读者,鼓励他们花时间,做练习,进行实验。如果你有任何疑问,请随时打破代码来理解它,并寻求帮助。

由于这是一本面向程序员的书籍,我们假设你有一些编程知识和 Python 本身的知识。因此,对于 Python 代码本身在做什么的解释很少,除非它是模糊的。

本书涵盖的内容

第一章,数据挖掘入门,介绍了我们将要使用的技术,以及实现两个基本算法以开始学习。

第二章,使用 scikit-learn 进行分类,涵盖了分类,这是数据挖掘的关键形式。你还将了解一些使你的数据挖掘实验更容易执行的结构。

第三章,使用决策树预测体育比赛赢家,介绍了两种新的算法,决策树和随机森林,并使用它通过创建有用的特征来预测体育比赛的赢家。

第四章,使用关联分析推荐电影,探讨了基于以往经验推荐产品的问题,并介绍了 Apriori 算法。

第五章,特征与 scikit-learn 转换器,介绍了可以创建的更多类型的特征,以及如何处理不同的数据集。

第六章,使用朴素贝叶斯进行社交媒体洞察,使用朴素贝叶斯算法自动解析社交媒体网站 Twitter 的基于文本的信息。

第七章,使用图挖掘进行推荐跟踪,应用聚类分析和网络分析来找到在社交媒体上值得关注的良好人选。

第八章,使用神经网络击败 CAPTCHAs,探讨了从图像中提取信息,然后训练神经网络以在那些图像中找到单词和字母。

第九章,作者归属分析,探讨了通过提取基于文本的特征和使用支持向量机来确定给定文档的作者。

第十章,聚类新闻文章,使用 k-means 聚类算法根据内容将新闻文章分组。

第十一章,使用深度神经网络进行图像目标检测,通过应用深度神经网络确定图像中展示的是哪种类型的对象。

第十二章,处理大数据,探讨了将算法应用于大数据的工作流程以及如何从中获得洞察。

附录,下一步,逐章介绍,提供有关如何进一步理解所介绍概念的提示。

您需要这本书

需要一台计算机或访问一台计算机来完成这本书,这应该不会让人感到惊讶。计算机应该是相当现代的,但不需要过于强大。任何从大约 2010 年开始的现代处理器和 4GB 的 RAM 就足够了,您可能还可以在较慢的系统上运行几乎所有的代码。

这里有一个例外,即在最后两章中。在这些章节中,我逐步介绍了使用亚马逊的云服务(AWS)来运行代码。这可能需要您支付一些费用,但优点是比在本地运行代码所需的系统设置要少。如果您不想为这些服务付费,所使用的工具都可以在本地计算机上设置,但您确实需要一个现代系统来运行它。至少需要 2012 年制造的处理器和超过 4GB 的 RAM。

我推荐使用 Ubuntu 操作系统,但代码在 Windows、Mac 或任何其他 Linux 变体上都应该运行良好。尽管如此,您可能需要查阅系统文档来安装一些东西。

在这本书中,我使用 pip 来安装代码,这是一个用于安装 Python 库的命令行工具。另一个选择是使用 Anaconda,您可以在以下网址找到它:continuum.io/downloads

我还使用 Python 3 测试了所有代码。大多数代码示例在 Python 2 上无需更改即可工作。如果您遇到任何问题,并且无法解决,请发送电子邮件,我们可以提供解决方案。

这本书的适用对象

这本书是为那些希望以应用为导向的方式开始数据挖掘的程序员而写的。

如果你之前没有编程经验,我强烈建议你在开始之前至少学习一些基础知识。本书不介绍编程,也不过多地解释如何实际(在代码中)输入指令的实现。话虽如此,一旦你通过了基础知识,你应该能够相当快地回到这本书——你不需要首先成为一个专家程序员!

我强烈建议你有一些 Python 编程经验。如果你没有,请随意开始,但你可能想先看看一些 Python 代码,可能专注于使用 IPython 笔记本的教程。在 IPython 笔记本中编写程序与其他方法(如在一个完整的 IDE 中编写 Java 程序)略有不同。

约定

在本书中,你会发现许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“接下来的几行代码读取链接并将其分配给dataset_filename函数。”

代码块设置如下:

import numpy as np 
dataset_filename = "affinity_dataset.txt" 
X = np.loadtxt(dataset_filename)

任何命令行输入或输出都如下所示:

 $ conda install scikit-learn

新术语重要词汇以粗体显示。你在屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:“为了下载新模块,我们将转到文件 | 设置 | 项目名称 | 项目解释器。”

警告或重要注意事项以如下框的形式出现。

技巧和窍门如下所示。

读者反馈

我们欢迎读者的反馈。告诉我们你对这本书的看法——你喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出你真正能从中获得最大价值的标题。

要向我们发送一般反馈,只需发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书籍的标题。

如果你在某个领域有专业知识,并且你对撰写或参与一本书感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在你已经是 Packt 书籍的骄傲所有者,我们有一些事情可以帮助你从你的购买中获得最大价值。

下载示例代码

你可以从你的账户www.packtpub.com下载本书的示例代码文件。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。

你可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的“支持”选项卡上。

  3. 点击“代码下载与勘误”。

  4. 在搜索框中输入书籍名称。

  5. 选择您想要下载代码文件的书籍。

  6. 从下拉菜单中选择您购买此书的来源。

  7. 点击代码下载。

文件下载完成后,请确保使用最新版本的以下软件解压或提取文件夹:

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Learning-Data-Mining-with-Python-Second-Edition。GitHub 仓库的好处是,任何与代码相关的问题,包括与软件版本更改相关的问题,都将被跟踪,那里的代码将包括来自世界各地读者的更改。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

为了避免缩进问题,请使用代码包在 IDE 中运行代码,而不是直接从 PDF 中复制

错误

尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击错误提交表单链接,并输入您的错误详细信息来报告它们。一旦您的错误得到验证,您的提交将被接受,错误将被上传到我们的网站或添加到该标题的错误部分下的现有错误列表中。

要查看之前提交的错误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在错误部分下。

盗版

互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以追究补救措施。

请通过copyright@packtpub.com与我们联系,并提供疑似盗版材料的链接。

我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。

问题

如果您对本书的任何方面有问题,您可以联系我们的questions@packtpub.com,我们将尽力解决问题。

第一章:数据挖掘入门

我们正在以人类历史上前所未有的规模收集有关我们世界的各种信息。随着这一趋势的发展,我们现在更加重视在日常生活中的使用这些信息。我们期望我们的计算机能够将网页翻译成其他语言,以高精度预测天气,推荐我们喜欢的书籍,以及诊断我们的健康问题。这些期望将在未来不断增长,无论是在应用范围还是效果上。数据挖掘是一种我们可以采用的方法,用于训练计算机通过数据做出决策,并构成了今天许多高科技系统的核心。

Python编程语言因其良好的原因而越来越受欢迎。它为程序员提供了灵活性,拥有许多模块来执行不同的任务,而且 Python 代码通常比其他任何语言都更易于阅读和简洁。有一个庞大且活跃的研究人员、实践者和初学者社区,他们使用 Python 进行数据挖掘。

在本章中,我们将使用 Python 介绍数据挖掘。我们将涵盖以下主题

  • 什么是数据挖掘,我们可以在哪里使用它?

  • 设置一个基于 Python 的环境以进行数据挖掘

  • (亲和力分析的)一个例子,根据购买习惯推荐产品

  • (一个经典的)分类问题的例子,根据植物测量值预测植物种类

介绍数据挖掘

数据挖掘为计算机提供了一种通过数据做出决策的方法。这个决策可以是预测明天的天气,阻止垃圾邮件进入您的收件箱,检测网站的语种,或者在交友网站上找到新的恋情。数据挖掘有许多不同的应用,而且新的应用正在不断被发现。

数据挖掘部分是算法设计、统计学、工程学、优化和计算机科学的结合。然而,结合这些领域的基础技能,我们还需要应用我们在应用数据挖掘的领域的领域知识(专业知识)。领域知识对于从良好结果到卓越结果至关重要。有效地应用数据挖掘通常需要将这些特定领域的知识与算法相结合。

大多数数据挖掘应用都采用相同的高级视图,其中模型从某些数据中学习,并将其应用于其他数据,尽管细节往往变化很大。

数据挖掘应用涉及创建数据集和调整算法,以下步骤将进行解释

  1. 我们通过创建数据集开始我们的数据挖掘过程,描述现实世界的一个方面。数据集包括以下两个方面:
  • 样本:这些是现实世界中的对象,例如一本书、照片、动物、人或任何其他对象。样本也被称为观察、记录或行,以及其他命名约定。

  • 特征:这些是我们数据集中样本的描述或测量。特征可以是长度、特定单词的频率、动物的腿数、创建日期等等。特征也被称为变量、列、属性或协变量,以及其他命名约定。

  1. 下一步是调整数据挖掘算法。每个数据挖掘算法都有参数,这些参数要么在算法内部,要么由用户提供。这种调整使算法能够学习如何对数据进行决策。

作为简单的例子,我们可能希望计算机能够将人们分类为。我们首先收集我们的数据集,其中包括不同人的身高以及他们是否被认为是矮或高:

人员 身高 矮或高?
1 155cm
2 165cm
3 175cm
4 185cm

如上所述,下一步涉及调整我们算法的参数。作为一个简单的算法;如果身高超过x,则该人被认为是高的。否则,他们被认为是矮的。然后我们的训练算法将查看数据并决定x的合适值。对于前面的数据,这个阈值的一个合理值是 170 厘米。算法认为身高超过 170 厘米的人是高的。其他人都被认为是矮的。这样,我们的算法就可以对新的数据进行分类,例如身高为 167 厘米的人,即使我们之前从未见过这样的人。

在前面的数据中,我们有一个明显的特征类型。我们想知道人们是矮还是高,所以我们收集了他们的身高。这个特征工程是数据挖掘中的一个关键问题。在后面的章节中,我们将讨论选择在数据集中收集的良好特征的方法。最终,这一步通常需要一些专业知识或至少一些试错。

在这本书中,我们将通过 Python 介绍数据挖掘。在某些情况下,我们选择代码和流程的清晰性,而不是执行每个任务的最优化方式。这种清晰性有时涉及到跳过一些可以提高算法速度或有效性的细节。

使用 Python 和 Jupyter Notebook

在本节中,我们将介绍安装 Python 以及我们将用于本书大部分内容的Jupyter Notebook。此外,我们还将安装NumPy模块,我们将使用它来进行第一组示例。

Jupyter Notebook 直到最近还被称为 IPython Notebook。你会在项目相关的网络搜索中注意到这个术语。Jupyter 是新的名称,代表着项目范围的扩大,而不仅仅是使用 Python。

安装 Python

Python 编程语言是一种出色、多功能且易于使用的语言。

对于这本书,我们将使用 Python 3.5,该版本可以从 Python 组织的网站www.python.org/downloads/获取。然而,我建议你使用 Anaconda 来安装 Python,你可以从官方网站www.continuum.io/downloads下载。

你将有两个主要版本可供选择,Python 3.5 和 Python 2.7。请记住下载并安装 Python 3.5,这是本书中测试过的版本。按照该网站上的安装说明进行安装。如果你有强烈理由学习 Python 2 版本,那么可以通过下载 Python 2.7 版本来实现。请注意,有些代码可能不会像书中那样工作,可能需要一些解决方案。

在这本书中,我假设你对编程和 Python 本身有一些了解。你不需要成为 Python 的专家就能完成这本书,尽管良好的知识水平会有所帮助。我不会在本书中解释一般的代码结构和语法,除非它与被认为是正常的 Python 编码实践不同。

如果你没有编程经验,我建议你从 Packt Publishing 出版的《Learning Python》这本书开始学习,或者在线可用的《Dive Into Python》这本书,可在www.diveintopython3.net找到。

Python 组织还维护了一份针对 Python 新手的两个在线教程列表:

  • 对于想通过 Python 语言学习编程的非程序员:

wiki.python.org/moin/BeginnersGuide/NonProgrammers

  • 对于已经知道如何编程但需要学习 Python 的程序员:

wiki.python.org/moin/BeginnersGuide/Programmers

Windows 用户需要设置一个环境变量才能从命令行使用 Python,而其他系统通常可以立即执行。我们将在以下步骤中设置它。

  1. 首先,找到你在电脑上安装 Python 3 的位置;默认位置是C:\Python35

  2. 接下来,将此命令输入到命令行(cmd 程序)中:设置环境为PYTHONPATH=%PYTHONPATH%;C:\Python35

如果你的 Python 安装在不同的文件夹中,请记住将C:\Python35进行更改。

一旦你的系统上运行了 Python,你应该能够打开命令提示符,并可以运行以下代码以确保它已正确安装。

    $ python
    Python 3.5.1 (default, Apr 11 2014, 13:05:11)
    [GCC 4.8.2] on Linux
    Type "help", "copyright", "credits" or "license" for more 
      information.
    >>> print("Hello, world!")
Hello, world!
    >>> exit()

注意,我们将使用美元符号($)来表示你需要在终端(在 Windows 上也称为 shell 或cmd)中输入的命令。你不需要输入这个字符(或重新输入屏幕上已经出现的内容)。只需输入剩余的行并按 Enter 键。

在您运行了上述 "Hello, world!" 示例之后,退出程序,然后继续安装一个更高级的环境来运行 Python 代码,即 Jupyter Notebook。

Python 3.5 将包含一个名为 pip 的程序,它是一个包管理器,可以帮助您在系统上安装新的库。您可以通过运行 $ pip freeze 命令来验证 pip 是否在您的系统上工作,该命令会告诉您您在系统上安装了哪些包。Anaconda 还安装了他们的包管理器 conda,您可以使用它。如果不确定,请先使用 conda,如果失败再使用 pip

安装 Jupyter Notebook

Jupyter 是一个 Python 开发平台,其中包含一些用于运行 Python 的工具和环境,它比标准解释器具有更多功能。它包含强大的 Jupyter Notebook,允许您在网页浏览器中编写程序。它还会格式化您的代码,显示输出,并允许您注释脚本。它是探索数据集的出色工具,我们将使用它作为本书代码的主要环境。

要在您的计算机上安装 Jupyter Notebook,您可以在命令行提示符中输入以下内容(不要在 Python 中输入):

    $ conda install jupyter notebook

您不需要管理员权限来安装它,因为 Anaconda 将包存储在用户的目录中。

安装了 Jupyter Notebook 后,您可以使用以下命令启动它:

    $ jupyter notebook

运行此命令将执行两个操作。首先,它将在您刚刚使用的命令提示符中创建一个 Jupyter Notebook 实例(后端)。其次,它将启动您的网页浏览器并连接到此实例,允许您创建一个新的笔记本。它看起来可能像以下截图(其中您需要将 /home/bob 替换为您的当前工作目录):

图片

要停止 Jupyter Notebook 的运行,请打开运行实例的命令提示符(你之前用来运行 jupyter notebook 命令的那个)。然后,按 Ctrl + C,你将收到提示 Shutdown this notebook server (y/[n])?。输入 y 并按 Enter,Jupyter Notebook 将会关闭。

安装 scikit-learn

scikit-learn 包是一个机器学习库,用 Python 编写(但也包含其他语言的代码)。它包含许多算法、数据集、实用工具和框架,用于执行机器学习。Scikit-learn 建立在科学 Python 堆栈之上,包括 NumPySciPy 等库,以提高速度。Scikit-learn 在许多情况下都快速且可扩展,适用于从初学者到高级研究用户的所有技能水平。我们将在第二章 使用 scikit-learn 估算器进行分类中详细介绍 scikit-learn。

要安装scikit-learn,您可以使用随 Python 3 一起提供的conda实用程序,如果您还没有安装,它还会安装NumPySciPy库。以管理员/根权限打开一个终端,并输入以下命令:

    $ conda install scikit-learn

主要的 Linux 发行版用户,如 Ubuntu 或 Red Hat,可能希望从他们的包管理器中安装官方包。

并非所有发行版都有 scikit-learn 的最新版本,所以在安装之前请检查版本。本书所需的最低版本是 0.14。我推荐使用 Anaconda 来为您管理这些,而不是使用系统包管理器进行安装。

想要通过编译源代码安装最新版本或查看更详细的安装说明的用户,可以访问scikit-learn.org/stable/install.html并参考安装 scikit-learn 的官方文档。

一个简单的亲和力分析示例

在本节中,我们将进入我们的第一个示例。数据挖掘的一个常见用途是通过询问购买产品的客户是否希望购买另一个类似的产品来提高销售额。您可以通过亲和力分析执行此分析,亲和力分析是研究事物共存时的情况,即相互关联。

为了重复在统计学课程中教授的臭名昭著的短语,相关性不等于因果关系。这个短语的意思是,亲和力分析的结果不能给出原因。在我们的下一个例子中,我们对产品购买进行亲和力分析。结果显示产品是共同购买的,但并不意味着购买一个产品会导致另一个产品的购买。这种区别很重要,尤其是在确定如何使用结果影响业务流程时,例如。

什么是亲和力分析?

亲和力分析是一种数据挖掘类型,它给出了样本(对象)之间的相似性。这可能是以下内容的相似性:

  • 网站上的用户,以提供多样化的服务或定向广告

  • 商品,以向这些用户销售,提供推荐电影或产品

  • 人类基因,以找到有相同祖先的人

我们可以通过几种方式来衡量亲和力。例如,我们可以记录两个产品一起购买的多频繁。我们还可以记录当一个人购买对象 1 和对象 2 时陈述的准确性。衡量亲和力的其他方法包括计算样本之间的相似性,这些内容我们将在后面的章节中介绍。

产品推荐

将传统业务(如商业)转移到线上时遇到的一个问题是,以前由人类完成的工作需要自动化,以便在线业务可以扩展并与其他现有自动化业务竞争。其中一个例子是向上销售,即向已经购买商品的客户销售额外商品。通过数据挖掘进行自动化的产品推荐是电子商务革命背后的推动力之一,每年将数十亿美元转化为收入。

在这个示例中,我们将关注一个基本的产品推荐服务。我们基于以下想法来设计它:当两个项目历史上一起购买时,它们在未来更有可能一起购买。这种思维方式是许多在线和线下产品推荐服务背后的理念。

对于这类产品推荐算法,一个非常简单的算法是简单地找到任何历史案例,其中用户购买了一个项目,然后推荐用户历史上购买的其他项目。在实践中,像这样的简单算法可以做得很好,至少比随机推荐项目要好。然而,它们可以显著改进,这就是数据挖掘的用武之地。

为了简化编码,我们将一次只考虑两个项目。例如,人们可能在超市同时购买面包和牛奶。在这个早期示例中,我们希望找到以下形式的简单规则:

如果一个人购买了产品 X,那么他们很可能会购买产品 Y

不会涉及多个项目的更复杂规则,例如人们购买香肠和汉堡更有可能购买番茄酱。

使用 NumPy 加载数据集

数据集可以从本书提供的代码包中下载,或从官方 GitHub 仓库下载:

github.com/dataPipelineAU/LearningDataMiningWithPython2

下载此文件并将其保存在你的电脑上,注意数据集的路径。将其放在你将运行代码的目录中是最容易的,但我们可以从电脑上的任何位置加载数据集。

对于这个示例,我建议你在电脑上创建一个新的文件夹来存储你的数据集和代码。从这里,打开你的 Jupyter Notebook,导航到这个文件夹,并创建一个新的笔记本。

我们将要用于这个示例的数据集是一个 NumPy 二维数组,这种格式是本书其余部分大多数示例的基础。这个数组看起来像一张表格,行代表不同的样本,列代表不同的特征。

单元代表特定样本特定特征的值。为了说明,我们可以用以下代码加载数据集:

import numpy as np 
dataset_filename = "affinity_dataset.txt" 
X = np.loadtxt(dataset_filename)

将前面的代码输入到你的(Jupyter)笔记本的第一个单元格中。然后你可以通过按 Shift + Enter 来运行代码(这也会为下一部分的代码添加一个新的单元格)。代码运行后,第一个单元格左侧的方括号将被分配一个递增的数字,让你知道这个单元格已经完成。第一个单元格应该看起来像以下这样:

图片

对于运行时间较长的代码,这里将放置一个星号来表示该代码正在运行或已安排运行。当代码运行完成时(包括如果代码因失败而完成),星号将被一个数字替换。

这个数据集有 100 个样本和五个特征,我们将在后面的代码中需要这些值。让我们使用以下代码提取这些值:

n_samples, n_features = X.shape

如果你选择将数据集存储在 Jupyter Notebooks 所在的目录之外,你需要将dataset_filename的值更改为新位置。

接下来,我们可以展示数据集的一些行,以了解数据。将以下代码行输入下一个单元格并运行它,以打印数据集的前五行:

print(X[:5])

结果将显示在列出的前五笔交易中购买了哪些商品:

[[ 0\.  1\.  0\.  0\.  0.] 
 [ 1\.  1\.  0\.  0\.  0.] 
 [ 0\.  0\.  1\.  0\.  1.] 
 [ 1\.  1\.  0\.  0\.  0.] 
 [ 0\.  0\.  1\.  1\.  1.]]

下载示例代码

你可以从你购买的所有 Packt Publishing 书籍的账户中下载示例代码文件www.packtpub.com。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。我还设置了一个 GitHub 仓库,其中包含代码的实时版本,以及新的修复、更新等。你可以在以下仓库中检索代码和数据集:github.com/dataPipelineAU/LearningDataMiningWithPython2

你可以通过一次查看每一行(水平线)来读取数据集。第一行(0, 1, 0, 0, 0)显示了第一笔交易中购买的商品。每一列(垂直行)代表每种商品。它们分别是面包、牛奶、奶酪、苹果和香蕉。因此,在第一笔交易中,这个人购买了奶酪、苹果和香蕉,但没有购买面包或牛奶。在新的单元格中添加以下行,以便我们将这些特征数字转换为实际单词:

features = ["bread", "milk", "cheese", "apples", "bananas"]

这些特征中的每一个都包含二进制值,仅表示是否购买了商品,而不表示购买的数量。1表示至少购买了这种类型的一种商品,而0表示完全没有购买这种商品。对于现实世界的数据集,使用精确的数字或更大的阈值是必要的。

实现规则的简单排序

我们希望找到类型为如果一个人购买产品 X,那么他们很可能会购买产品 Y的规则。我们可以通过简单地找到两个产品一起购买的所有场合来轻松地创建我们数据集中所有规则的一个列表。然而,然后我们需要一种方法来确定好的规则和不好的规则,以便我们可以选择特定的产品进行推荐。

我们可以用许多方式评估这类规则,我们将关注其中的两种:支持度置信度

支持度是规则在数据集中出现的次数,这通过简单地计算规则有效的样本数量来计算。有时可以通过将总数除以规则前提有效的总次数来归一化,但在这个实现中我们将简单地计算总数。

前提是规则被认为是活跃的要求。结论是规则的输出。对于例子如果一个人买苹果,他们也买香蕉,只有当前提发生时——一个人买了苹果——该规则才是有效的。然后,规则的结论声明这个人会买香蕉。

虽然支持度衡量规则存在的频率,但置信度衡量当它们可以使用时它们的准确性。你可以通过确定规则在前提适用时应用的百分比来计算这个值。我们首先计算规则在我们的数据中应用的次数,然后除以前提(即if语句)出现的样本数量。

作为例子,我们将计算规则如果一个人买苹果,他们也买香蕉的支持度和置信度。

如以下示例所示,我们可以通过检查sample[3]的值来判断某人在交易中是否购买了苹果,其中我们将一个样本分配到矩阵的某一行:

sample = X[2]

同样,我们可以通过查看sample[4]的值是否等于 1(等等)来检查交易中是否购买了香蕉。我们现在可以计算我们的规则在数据集中出现的次数,从而计算出置信度和支持度。

现在我们需要计算数据库中所有规则的这些统计数据。我们将为此创建两个字典,一个用于有效规则,另一个用于无效规则。这个字典的键将是一个元组(前提和结论)。我们将存储索引,而不是实际的特征名称。因此,我们会存储(3 和 4)来表示之前的规则如果一个人买了苹果,他们也会买香蕉。如果前提和结论都给出,则该规则被认为是有效的。而如果前提给出但结论没有给出,则该规则对该样本被认为是无效的。

以下步骤将帮助我们计算所有可能规则的置信度和支持度:

  1. 我们首先设置一些字典来存储结果。我们将使用defaultdict,它会在访问一个尚不存在的键时设置一个默认值。我们记录有效规则的数目、无效规则的数目以及每个前提的出现次数:
from collections import defaultdict 
valid_rules = defaultdict(int) 
invalid_rules = defaultdict(int) 
num_occurences = defaultdict(int)

  1. 接下来,我们在一个大的循环中计算这些值。我们遍历数据集中的每个样本,然后遍历每个特征作为前提。再次遍历每个特征作为可能的结论,映射前提到结论的关系。如果样本包含一个购买了前提和结论的人,我们在valid_rules中记录这个信息。如果他们没有购买结论产品,我们在invalid_rules中记录这个信息。

  2. 对于样本 X 中的每个样本:

for sample in X:
    for premise in range(n_features):
    if sample[premise] == 0: continue
# Record that the premise was bought in another transaction
    num_occurences[premise] += 1
    for conclusion in range(n_features):
    if premise == conclusion: 
# It makes little sense to
    measure if X -> X.
    continue
    if sample[conclusion] == 1:
# This person also bought the conclusion item
    valid_rules[(premise, conclusion)] += 1

如果前提对这个样本是有效的(它有一个值为1),那么我们记录这个信息并检查我们规则的每个结论。我们跳过任何与前提相同的结论——这会给我们规则,如:如果一个人买了苹果,那么他们也买了苹果,这显然对我们帮助不大。

我们现在已经完成了必要的统计计算,现在可以计算每个规则的支持度置信度。和之前一样,支持度只是我们的valid_rules值:

support = valid_rules

我们可以用相同的方式计算置信度,但我们必须遍历每个规则来计算这个值:

confidence = defaultdict(float)
for premise, conclusion in valid_rules.keys():
    rule = (premise, conclusion)
    confidence[rule] = valid_rules[rule] / num_occurences [premise]

我们现在有一个包含每个规则的支持度和置信度的字典。我们可以创建一个函数,以可读的格式打印出这些规则。规则的签名接受前提和结论索引、我们刚刚计算的支持度和置信度字典,以及一个告诉我们features含义的特征数组。然后我们打印出该规则的SupportConfidence

for premise, conclusion in confidence:
    premise_name = features[premise]
    conclusion_name = features[conclusion]
    print("Rule: If a person buys {0} they will also 
          buy{1}".format(premise_name, conclusion_name))
    print(" - Confidence: {0:.3f}".format
          (confidence[(premise,conclusion)]))
    print(" - Support: {0}".format(support
                                   [(premise, 
                                     conclusion)]))
    print("")

我们可以通过以下方式调用代码来测试它——请随意尝试不同的前提和结论:

for premise, conclusion in confidence:
    premise_name = features[premise]
    conclusion_name = features[conclusion]
    print("Rule: If a person buys {0} they will also 
          buy{1}".format(premise_name, conclusion_name))
    print(" - Confidence: {0:.3f}".format
          (confidence[(premise,conclusion)]))
    print(" - Support: {0}".format(support
                                   [(premise, 
                                     conclusion)]))
    print("")

排序以找到最佳规则

现在我们能够计算所有规则的支持度和置信度,我们希望能够找到最佳的规则。为此,我们进行排名并打印出具有最高值的规则。我们可以对支持和置信度值都这样做。

要找到支持度最高的规则,我们首先对支持度字典进行排序。字典默认不支持排序;items()函数给我们一个包含字典中数据的列表。我们可以使用itemgetter类作为我们的键来对这个列表进行排序,这允许我们排序如此类似的嵌套列表。使用itemgetter(1)允许我们根据值进行排序。将reverse=True设置为真,我们可以首先得到最高的值:

from operator import itemgetter 
sorted_support = sorted(support.items(), key=itemgetter(1), reverse=True)

然后,我们可以打印出前五条规则:

sorted_confidence = sorted(confidence.items(), key=itemgetter(1),
                           reverse=True)
for index in range(5):
    print("Rule #{0}".format(index + 1))
    premise, conclusion = sorted_confidence[index][0]
    print_rule(premise, conclusion, support, confidence, features)

结果看起来如下:

Rule #1 
Rule: If a person buys bananas they will also buy milk 
 - Support: 27 
 - Confidence: 0.474 
Rule #2 
Rule: If a person buys milk they will also buy bananas 
 - Support: 27 
 - Confidence: 0.519 
Rule #3 
Rule: If a person buys bananas they will also buy apples 
 - Support: 27 
 - Confidence: 0.474 
Rule #4 
Rule: If a person buys apples they will also buy bananas 
 - Support: 27 
 - Confidence: 0.628 
Rule #5 
Rule: If a person buys apples they will also buy cheese 
 - Support: 22 
 - Confidence: 0.512

同样,我们可以根据置信度打印出最佳规则。首先,计算排序后的置信度列表,然后使用之前相同的方法打印它们。

sorted_confidence = sorted(confidence.items(), key=itemgetter(1),
                           reverse=True)
for index in range(5):
    print("Rule #{0}".format(index + 1))
    premise, conclusion = sorted_confidence[index][0]
    print_rule(premise, conclusion, support, confidence, features)

两条规则在两个列表的顶部附近。第一条是如果一个人买了苹果,他们也会买奶酪,第二条是如果一个人买了奶酪,他们也会买香蕉。商店经理可以使用这样的规则来组织他们的商店。例如,如果本周苹果打折,就在附近放置奶酪的展示。同样,将香蕉和奶酪同时打折几乎没有意义,因为近 66%买奶酪的人可能会买香蕉——我们的促销不会大幅增加香蕉的销量。

Jupyter Notebook 将在笔记本中内联显示图表。然而,有时这并不是默认配置的。要配置 Jupyter Notebook 以内联显示图表,请使用以下代码行:%matplotlib inline

我们可以使用名为 matplotlib 的库来可视化结果。

我们将从展示规则置信度的简单折线图开始,按置信度顺序排列。matplotlib 使得这变得简单——我们只需传入数字,它就会绘制出一个简单但有效的图表:

from matplotlib import pyplot as plt 
plt.plot([confidence[rule[0]] for rule in sorted_confidence])

图片

使用之前的图表,我们可以看到前五条规则有相当高的置信度,但在此之后效果迅速下降。利用这些信息,我们可能会决定只使用前五条规则来驱动商业决策。最终,使用这种探索技术,结果取决于用户。

在这样的例子中,数据挖掘具有强大的探索能力。一个人可以使用数据挖掘技术来探索其数据集中的关系,以发现新的见解。在下一节中,我们将使用数据挖掘来实现不同的目的:预测和分类。

一个简单的分类示例

在亲和力分析示例中,我们寻找了数据集中不同变量之间的相关性。在分类中,我们有一个我们感兴趣的单一变量,我们称之为类别(也称为目标)。在先前的例子中,如果我们对人们如何购买更多苹果感兴趣,我们会探索与苹果相关的规则,并使用这些规则来指导我们的决策。

什么是分类?

分类是数据挖掘应用最广泛的一种,无论是在实际应用还是在研究中。与之前一样,我们有一组代表我们感兴趣分类的对象或事物的样本。我们还有一个新的数组,即类别值。这些类别值为我们提供了样本的分类。以下是一些例子:

  • 通过观察植物的测量值来确定其种类。这里的类别值将是:这是哪种物种?

  • 确定图像中是否包含狗。类别将是:这张图像中是否有狗?

  • 根据特定测试的结果来确定患者是否患有癌症。类别将是:这位患者是否有癌症?

虽然许多先前的例子是二元(是/否)问题,但它们不必是,就像本节中植物物种分类的例子一样。

分类应用的目的是在已知类别的样本集上训练一个模型,然后将该模型应用于具有未知类别的未见样本。例如,我们想在标记为垃圾邮件或非垃圾邮件的过去电子邮件上训练一个垃圾邮件分类器。然后我想使用这个分类器来确定我的下一封电子邮件是否是垃圾邮件,而无需我自己进行分类。

加载数据集和准备数据

我们将要用于此示例的数据集是著名的植物分类的鸢尾花数据库。在此数据集中,我们有 150 个植物样本,每个样本有四个测量值:萼片长度萼片宽度花瓣长度花瓣宽度(所有单位均为厘米)。这个经典数据集(首次使用于 1936 年!)是数据挖掘的经典数据集之一。有三个类别:鸢尾花塞托萨鸢尾花变色鸢尾花维吉尼卡。目标是通过对样本的测量来确定样本属于哪种植物类型。

scikit-learn 库内置了此数据集,使得数据集的加载变得简单:

from sklearn.datasets import load_iris 
dataset = load_iris() 
X = dataset.data 
y = dataset.target

您也可以使用 print(dataset.DESCR) 来查看数据集的概述,包括一些关于特征细节的信息。

本数据集中的特征是连续值,这意味着它们可以取任何范围的值。测量值是这种类型特征的很好例子,其中测量值可以是 1、1.2 或 1.25 等。连续特征的另一个方面是,彼此接近的特征值表示相似性。一个萼片长度为 1.2 厘米的植物就像一个萼片宽度为 1.25 厘米的植物。

相比之下,分类特征。这些特征虽然通常以数字表示,但不能以相同的方式进行比较。在鸢尾花数据集中,类别值是分类特征的例子。类别 0 代表鸢尾花塞托萨;类别 1 代表鸢尾花变色,类别 2 代表鸢尾花维吉尼卡。这里的编号并不意味着鸢尾花塞托萨比鸢尾花变色更相似,尽管类别值更相似。这里的数字代表类别。我们只能说类别是否相同或不同。

还有其他类型的特征,我们将在后面的章节中介绍。这些包括像素强度、词频和 n-gram 分析。

虽然此数据集中的特征是连续的,但我们将在此示例中使用的算法需要分类特征。将连续特征转换为分类特征的过程称为离散化。

一种简单的离散化算法是选择一个阈值,任何低于此阈值的值都被赋予值 0。同时,任何高于此阈值的值都被赋予值 1。对于我们的阈值,我们将计算该特征的平均值(平均值)。首先,我们计算每个特征的平均值:

attribute_means = X.mean(axis=0)

此代码的结果将是一个长度为 4 的数组,这是我们拥有的特征数量。第一个值是第一个特征的值的平均值,依此类推。接下来,我们使用这个结果将我们的数据集从具有连续特征的集合转换为具有离散分类特征的集合:

assert attribute_means.shape == (n_features,)
X_d = np.array(X >= attribute_means, dtype='int')

我们将使用这个新的 X_d 数据集(X 离散化)进行我们的 训练和测试,而不是原始数据集(X)。

实现 OneR 算法

OneR 是一个简单的算法,它通过找到特征值的最高频率类别来预测样本的类别。OneROne Rule 的缩写,表示我们只使用一个规则进行这种分类,通过选择表现最好的特征。虽然一些后续的算法要复杂得多,但这个简单的算法在现实世界的一些数据集中已被证明有良好的性能。

算法首先遍历每个特征的每个值。对于这个值,计算具有该特征值的每个类别的样本数量。记录特征值的最高频率类别和预测的错误。

例如,如果一个特征有两个值,01,我们首先检查所有具有值 0 的样本。对于这个值,我们可能在类别 A 中有 20 个,在类别 B 中有 60 个,以及在类别 C 中有进一步的 20 个。对于这个值最频繁的类别是 B,并且有 40 个实例具有不同的类别。对于这个特征值的预测是 B,错误率为 40,因为有 40 个样本与预测的类别不同。然后,我们对这个特征的值 1 执行相同的程序,然后对其他所有特征值组合执行。

一旦计算了这些组合,我们就通过累加该特征的值的错误来计算每个特征的错误。具有最低总错误的特征被选为 One Rule,然后用于分类其他实例。

在代码中,我们首先创建一个函数,用于计算特定特征值的类别预测和错误。我们有两个必要的导入,defaultdictitemgetter,我们在之前的代码中使用过:

from collections import defaultdict 
from operator import itemgetter

接下来,我们创建一个函数定义,该函数需要数据集、类别、我们感兴趣的特征的索引以及我们正在计算的值。它遍历每个样本,并计算每个特征值对应特定类别的次数。然后,我们选择当前特征/值对的最高频率类别:

def train_feature_value(X, y_true, feature, value):
# Create a simple dictionary to count how frequency they give certain
predictions
 class_counts = defaultdict(int)
# Iterate through each sample and count the frequency of each
class/value pair
 for sample, y in zip(X, y_true):
    if sample[feature] == value: 
        class_counts[y] += 1
# Now get the best one by sorting (highest first) and choosing the
first item
sorted_class_counts = sorted(class_counts.items(), key=itemgetter(1),
                             reverse=True)
most_frequent_class = sorted_class_counts[0][0]
 # The error is the number of samples that do not classify as the most
frequent class
 # *and* have the feature value.
    n_samples = X.shape[1]
    error = sum([class_count for class_value, class_count in
                 class_counts.items()
 if class_value != most_frequent_class])
    return most_frequent_class, error

作为最后一步,我们还计算了这个规则的错误。在 OneR 算法中,任何具有这个特征值的样本都会被预测为最频繁的类别。因此,我们通过累加其他类别的计数(不是最频繁的)来计算错误。这些代表导致错误或分类错误的训练样本。

使用这个函数,我们现在可以通过遍历该特征的值、汇总误差并记录每个值的预测类别来计算整个特征的误差。

该函数需要数据集、类别以及我们感兴趣的属性索引。然后它遍历不同的值,并找到用于此特定属性的、最准确的属性值,正如 OneR 规则:

def train(X, y_true, feature): 
    # Check that variable is a valid number 
    n_samples, n_features = X.shape 
    assert 0 <= feature < n_features 
    # Get all of the unique values that this variable has 
    values = set(X[:,feature]) 
    # Stores the predictors array that is returned 
    predictors = dict() 
    errors = [] 
    for current_value in values: 
        most_frequent_class, error = train_feature_value
        (X, y_true, feature, current_value) 
        predictors[current_value] = most_frequent_class 
        errors.append(error) 
    # Compute the total error of using this feature to classify on 
    total_error = sum(errors) 
    return predictors, total_error

让我们更详细地看看这个函数。

在一些初步测试之后,我们找到给定属性所具有的所有唯一值。下一行的索引查看给定属性的整个列,并将其作为数组返回。然后我们使用 set 函数来找到唯一的值:

    values = set(X[:,feature_index])

接下来,我们创建一个字典来存储预测值。这个字典将以属性值作为键,分类作为值。键为 1.5,值为 2 的条目意味着,当属性值设置为 1.5 时,将其分类为属于类别 2。我们还创建了一个列表来存储每个属性值的误差:

predictors = {} 
    errors = []

作为这个函数的主要部分,我们遍历这个特征的唯一值,并使用之前定义的 train_feature_value 函数来找到给定属性值的最大频率类别和误差。我们按照前面概述的方式存储结果:

最后,我们计算这个规则的总体误差,并返回预测值以及这个值:

total_error = sum(errors)
return predictors, total_error

测试算法

当我们评估前面章节中的亲和力分析算法时,我们的目标是探索当前数据集。与此分类不同,我们想要构建一个模型,通过将其与我们对该问题的了解进行比较,使我们能够对以前未见样本进行分类。

因此,我们将机器学习工作流程分为两个阶段:训练和测试。在训练阶段,我们取数据集的一部分来创建我们的模型。在测试阶段,我们应用这个模型并评估它在数据集上的有效性。由于我们的目标是创建一个可以分类以前未见样本的模型,我们不能使用测试数据来训练模型。如果我们这样做,我们就有可能发生过度拟合

过度拟合是创建一个模型,该模型在训练数据集上分类得很好,但在新样本上表现不佳的问题。解决方案很简单:永远不要使用训练数据来测试你的算法。这个简单规则有一些复杂的变体,我们将在后面的章节中介绍;但,现在,我们可以通过简单地分割我们的数据集为两个小数据集:一个用于训练,一个用于测试来评估我们的 OneR 实现。这个工作流程在本节中给出。

scikit-learn 库包含一个函数可以将数据分割成训练和测试组件:

from sklearn.cross_validation import train_test_split

此函数将数据集根据给定的比例(默认情况下使用数据集的 25% 用于测试)分成两个子数据集。它是随机进行的,这提高了算法在现实世界环境中按预期执行(我们期望数据来自随机分布)的置信度:

Xd_train, Xd_test, y_train, y_test = train_test_split(X_d, y, 
    random_state=14)

我们现在有两个较小的数据集:Xd_train 包含我们的训练数据,Xd_test 包含我们的测试数据。y_trainy_test 给出了这些数据集对应的类别值。

我们还指定了一个 random_state。设置随机状态将在每次输入相同值时给出相同的分割。它看起来是随机的,但使用的算法是确定性的,输出将是一致的。对于这本书,我建议将随机状态设置为与我相同的值,这样你将得到与我相同的结果,允许你验证你的结果。要获得每次运行都变化的真正随机结果,请将 random_state 设置为 None

接下来,我们计算数据集中所有特征的预测器。记住,只使用训练数据来完成这个过程。我们遍历数据集中的所有特征,并使用先前定义的函数来训练预测器和计算错误:

all_predictors = {} 
errors = {} 
for feature_index in range(Xd_train.shape[1]): 
    predictors, total_error = train(Xd_train,
                                    y_train,
                                    feature_index) 
    all_predictors[feature_index] = predictors 
    errors[feature_index] = total_error

接下来,我们通过找到具有最低错误的特征来找到用作我们的 One Rule 的最佳特征:

best_feature, best_error = sorted(errors.items(), key=itemgetter(1))[0]

然后,我们通过存储最佳特征的预测器来创建我们的 model

model = {'feature': best_feature,
         'predictor': all_predictors[best_feature]}

我们的模型是一个字典,它告诉我们应该使用哪个特征来进行我们的 One Rule 以及基于这些值的预测。有了这个模型,我们可以通过找到特定特征的值并使用适当的预测器来预测一个先前未见过的样本的类别。以下代码为给定样本执行此操作:

variable = model['feature'] 
predictor = model['predictor'] 
prediction = predictor[int(sample[variable])]

经常我们希望一次预测多个新样本,我们可以使用以下函数来完成。它只是简单地使用上面的代码,但遍历数据集中的所有样本,为每个样本获取预测:

def predict(X_test, model):
variable = model['feature']
predictor = model['predictor']
y_predicted = np.array([predictor
                        [int(sample[variable])] for sample
                        in X_test])
return y_predicted

对于我们的 testing 数据集,我们通过调用以下函数来获取预测:

y_predicted = predict(Xd_test, model)

然后,我们可以通过将其与已知类别进行比较来计算这个准确率:

accuracy = np.mean(y_predicted == y_test) * 100 
print("The test accuracy is {:.1f}%".format(accuracy))

此算法给出了 65.8% 的准确率,对于一个单一规则来说并不坏!

摘要

在本章中,我们介绍了使用 Python 进行数据挖掘。如果你能运行本节中的代码(注意,完整的代码包含在提供的代码包中),那么你的计算机已经为本书的大部分内容做好了设置。其他 Python 库将在后面的章节中介绍,以执行更专业的任务。

我们使用 Jupyter Notebook 运行我们的代码,这使得我们可以立即查看代码小段的结果。Jupyter Notebook 是一个有用的工具,将在整本书中使用。

我们介绍了一种简单的亲和分析,寻找一起购买的产品。这种类型的探索性分析可以深入了解业务流程、环境或场景。这些类型分析的信息可以帮助业务流程,找到下一个重大的医学突破,或者创造下一个人工智能。

此外,在本章中,我们使用OneR算法提供了一个简单的分类示例。这个简单的算法只是找到最佳特征,并预测在训练数据集中最频繁出现此值的类别。

为了扩展本章的成果,思考一下你将如何实现一个可以同时考虑多个特征/值对的OneR算法变体。尝试实现你的新算法并对其进行评估。记住,要在与训练数据不同的数据集上测试你的算法。否则,你可能会面临数据过拟合的风险。

在接下来的几章中,我们将扩展分类和亲和分析的概念。我们还将介绍 scikit-learn 包中的分类器,并使用它们来进行机器学习,而不是自己编写算法。

第二章:使用 scikit-learn 估计器进行分类

scikit-learn 库是一组数据挖掘算法的集合,用 Python 编写并使用。这个库允许用户轻松尝试不同的算法,以及利用标准工具进行有效的测试和参数搜索。scikit-learn 中包含许多算法和实用工具,包括现代机器学习中常用的许多算法。

在本章中,我们专注于设置一个良好的框架来运行数据挖掘过程。我们将在后续章节中使用这个框架,这些章节将专注于应用和那些情况下使用的技术。

本章介绍的关键概念如下:

  • 估计器: 这是为了执行分类、聚类和回归

  • 转换器: 这是为了执行预处理和数据修改

  • 管道: 这是为了将您的流程组合成一个可重复的格式

scikit-learn 估计器

估计器允许算法的标准化实现和测试,为分类器提供一个通用、轻量级的接口。通过使用此接口,我们可以将这些工具应用于任意分类器,而无需担心算法的工作方式。

估计器必须具有以下两个重要功能:

  • fit(): 此函数执行算法的训练 - 设置内部参数的值。fit()函数接受两个输入,即训练样本数据集和对应于这些样本的类别。

  • predict(): 这是测试样本的类别,我们将其作为唯一输入提供。此函数返回一个包含每个输入测试样本预测的NumPy数组。

大多数 scikit-learn 估计器使用NumPy数组或相关格式作为输入和输出。然而,这仅是一种惯例,并非必须使用该接口。

scikit-learn 中实现了许多估计器,在其他使用相同接口的开源项目中还有更多。我们将使用许多(SVM)、随机森林。我们将使用许多

在后续章节中介绍这些算法。在本章中,我们将使用最近邻算法。

算法。

对于本章,您需要安装一个名为matplotlib的新库。最简单的方法是使用pip3,就像在第一章“数据挖掘入门”中安装 scikit-learn 一样:

**pip3 install matplotlib**

如果您有matplotlib,请查找官方安装说明:

matplotlib 安装指南

最近邻

最近邻算法是我们新的样本。我们选取最相似的样本

并预测这些附近样本中大多数样本的相同类别。这种投票通常只是一个简单的计数,尽管也存在更复杂的方法,如加权投票。

以下图为例,我们希望根据三角形更接近哪个类别(在此处表示为相似对象更靠近)来预测三角形的类别。我们寻找最近的三个邻居,即画圈内的两个钻石和一个正方形。钻石比圆多,因此预测的三角形类别是钻石:

图片

最近邻算法几乎适用于任何数据集——然而,计算所有样本对之间的距离可能计算成本很高。例如,如果数据集中有十个样本,则需要计算 45 个独特的距离。然而,如果有 1000 个样本,则几乎有 500,000 个!存在各种方法来提高这种速度,例如使用树结构进行距离计算。其中一些算法可能相当复杂,但幸运的是,scikit-learn 已经实现了这些算法的版本,使我们能够在更大的数据集上进行分类。由于这些树结构是 scikit-learn 的默认设置,我们不需要进行任何配置即可使用它。

在基于类别的数据集、具有类别特征的情况下,最近邻算法表现不佳,应使用其他算法代替。最近邻算法的问题在于比较类别值差异的困难,这最好留给一个考虑每个特征重要性的算法。可以使用一些距离度量或预处理步骤(如我们在后续章节中使用的独热编码)来比较类别特征。选择正确的算法是数据挖掘中的难题之一,通常,测试一组算法并查看哪个在你的任务上表现最好是最简单的方法。

距离度量

数据挖掘中的一个基本概念是距离。如果我们有两个样本,我们需要回答诸如这两个样本是否比另外两个样本更相似?等问题。回答这些问题对于数据挖掘的结果非常重要。

最常用的距离是欧几里得距离,它是两个对象之间的实际世界距离。如果你要在图上绘制点并使用尺子测量距离,结果将是欧几里得距离。

更正式一点,点 a 和点 b 之间的欧几里得距离是每个特征平方距离之和的平方根。

欧几里得距离直观易懂,但如果某些特征值大于 0(称为稀疏矩阵),则准确性较差。

使用中的其他距离度量还有曼哈顿距离和余弦距离。

曼哈顿距离是每个特征绝对差异之和(不使用平方距离)。

直观地,我们可以将曼哈顿距离想象成车象棋子移动的步数。

(也称为城堡)在如果它被限制为每次移动一个方格的情况下。虽然当一些特征值大于其他特征时,曼哈顿距离确实会受到影响,但如果它被限制为每次移动一个方格,其影响不如欧几里得点那样显著。当一些特征值大于其他特征时,曼哈顿距离确实会受到影响,但其影响不如欧几里得距离那样剧烈。

余弦距离更适合某些特征值大于其他特征,并且数据集中有很多零的情况。

直观地,我们从原点到每个样本画一条线,并测量这些线之间的角度。我们可以在以下图中观察到算法之间的差异:

图片

在这个例子中,每个灰色圆圈与白色圆圈的距离完全相同。在(a)中,距离是欧几里得距离,因此,相似的距离适合围绕一个圆。这个距离可以用尺子来测量。在(b)中,距离是曼哈顿距离,也称为城市街区距离。我们通过跨越行和列来计算距离,就像国际象棋中的车(城堡)移动一样。最后,在(c)中,我们有余弦距离,它是通过计算从样本到向量的线之间的角度来测量的,并忽略线的实际长度。

所选的距离度量可以极大地影响最终性能。

例如,如果你有很多特征,随机样本之间的欧几里得距离会收敛(由于著名的维度诅咒)。在高维空间中,欧几里得距离很难比较样本,因为距离总是几乎相同!

在这种情况下,曼哈顿距离可能更稳定,但如果某些特征值非常大,这可能会掩盖其他特征中的许多相似性。例如,如果特征 A 的值在 1 到 2 之间,而另一个特征 B 的值在 1000 到 2000 之间,在这种情况下,特征 A 不太可能对结果有任何影响。这个问题可以通过归一化来解决,这使得曼哈顿(和欧几里得)距离在不同特征上更加可靠,我们将在本章后面看到。

最后,余弦距离是比较具有许多特征的项目的良好度量,但它丢弃了关于向量长度的某些信息,这在某些应用中是有用的。我们通常会在文本挖掘中使用余弦距离,因为文本挖掘固有的特征数量很大(见第六章,使用朴素贝叶斯进行社交媒体洞察)。

最终,需要一种理论方法来确定哪种距离方法需要,或者需要一种经验评估来查看哪种方法更有效。我更喜欢经验方法,但任何一种方法都可以产生良好的结果。

对于本章,我们将使用欧几里得距离,在后面的章节中使用其他度量标准。如果您想进行实验,请尝试将度量标准设置为曼哈顿距离,看看这对结果有何影响。

加载数据集

数据集 Ionosphere 与高频天线相关。天线的目的是确定电离层中是否存在结构以及上层大气中的区域。我们将具有结构的读取视为良好,而没有结构的读取则被视为不良。本应用的目的是构建一个数据挖掘分类器,以确定图像是良好还是不良。

(图片来源:https://www.flickr.com/photos/geckzilla/16149273389/)

您可以下载此数据集用于不同的数据挖掘应用。访问 archive.ics.uci.edu/ml/datasets/Ionosphere 并点击数据文件夹。将 ionosphere.dataionosphere.names 文件下载到您的计算机上的一个文件夹中。对于本例,我将假设您已将数据集放在主文件夹中名为 Data 的目录下。您可以将数据放在另一个文件夹中,只需确保更新您的数据文件夹(此处以及所有其他章节)。

您的主文件夹位置取决于您的操作系统。对于 Windows,它通常位于 C:DocumentsSettingsusername。对于 Mac 或 Linux 计算机,它通常位于 /home/username。您可以通过在 Jupyter Notebook 中运行以下 Python 代码来获取您的家文件夹:

import os print(os.path.expanduser("~"))

对于数据集中的每一行,都有 35 个值。前 34 个是从 17 个天线(每个天线两个值)测量的。最后一个值是 'g' 或 'b';分别代表良好和不良。

启动 Jupyter Notebook 服务器并创建一个名为 Ionosphere Nearest Neighbors 的新笔记本。首先,我们加载所需的 NumPycsv 库,并设置我们代码中需要的数据文件名。

import numpy as np 
import csv 
data_filename = "data/ionosphere.data"

然后,我们创建 XyNumPy 数组来存储数据集。这些数组的大小来自数据集。如果您不知道未来数据集的大小,请不要担心——我们将在未来的章节中使用其他方法来加载数据集,您不需要事先知道这个大小:

X = np.zeros((351, 34), dtype='float') 
y = np.zeros((351,), dtype='bool')

该数据集是 逗号分隔值CSV)格式,这是数据集常用的格式。我们将使用 csv 模块来加载此文件。导入它并设置一个 csv 读取对象,然后遍历文件,为数据集中的每一行设置 X 中的适当行和 y 中的类别值:

with open(data_filename, 'r') as input_file: 
    reader = csv.reader(input_file) 
    for i, row in enumerate(reader): 
        # Get the data, converting each item to a float 
        data = [float(datum) for datum in row[:-1]] 
        # Set the appropriate row in our dataset 
        X[i] = data 
        # 1 if the class is 'g', 0 otherwise 
        y[i] = row[-1] == 'g'

我们现在有一个包含在 X 中的样本和特征数据集以及相应的 y 类别,正如我们在第一章入门数据挖掘中的分类示例中所做的那样。

首先,尝试将第一章中介绍的 OneR 算法应用于这个数据集。它不会很好用,因为这个数据集中的信息分布在某些特征的关联中。OneR 只对单个特征的值感兴趣,并且不能很好地捕捉更复杂数据集中的信息。其他算法,包括最近邻算法,合并多个特征的信息,使它们适用于更多场景。缺点是它们通常计算起来更昂贵。

向标准工作流程迈进

scikit-learn 的估计器有两个:fit()predict()。我们使用fit()方法来训练算法

在我们的测试集上使用predict()方法。我们使用测试集上的predict()方法来评估它。

  1. 首先,我们需要创建这些训练集和测试集。像以前一样,导入并运行train_test_split函数:
from sklearn.cross_validation import train_test_split 
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=14)

  1. 然后,我们导入nearest neighbor类并为其创建一个实例。现在我们将参数保留为默认值,将在本章后面测试其他值。默认情况下,算法将选择预测测试样本类别的五个最近邻:
from sklearn.neighbors import KNeighborsClassifier estimator = KNeighborsClassifier()

  1. 在创建我们的estimator之后,我们必须将其拟合到我们的训练数据集上。对于nearest neighbor类,这个训练步骤只是记录我们的数据集,使我们能够通过将新数据点与训练数据集进行比较来找到最近邻:
estimator.fit(X_train, y_train)

  1. 我们然后使用测试集训练算法,并使用测试集进行评估:
y_predicted = estimator.predict(X_test) 
accuracy = np.mean(y_test == y_predicted) * 100     
print("The accuracy is {0:.1f}%".format(accuracy))

此模型准确率达到 86.4%,对于一个默认算法来说非常出色,而且只需几行代码!大多数 scikit-learn 默认参数都是经过精心选择的,以便与各种数据集良好地工作。然而,你应该始终根据对应用实验的了解来选择参数。我们将在后面的章节中使用策略来进行这种参数搜索

运行算法

根据我们的测试数据集,之前的结果相当不错。然而,如果我们运气好,选择了一个容易的测试集会怎样?或者,如果它特别麻烦呢?我们可能会因为数据分割的不幸而丢弃一个好的模型。

交叉验证框架是一种解决选择单个测试集问题的方法,并且在数据挖掘中是一种标准的最佳实践方法论。这个过程通过进行许多实验,使用不同的训练和测试分割,但每个测试集中的每个样本只使用一次。程序如下:

  1. 将整个数据集分成几个称为折的部分。

  2. 对于数据集中的每个折,执行以下步骤:

    1. 将该折放在一边作为当前测试集

    2. 在剩余的折上训练算法

    3. 在当前测试集上评估

  3. 报告所有评估分数,包括平均分数。

在这个过程中,每个样本只用于测试集一次,这减少了(但并未消除)选择幸运测试集的可能性。

在整本书中,代码示例在每一章内相互构建。除非文本中另有说明,否则每个章节的代码应输入到同一个 Jupyter Notebook 中。

scikit-learn 库包含几种交叉验证方法。提供了一个执行先前过程的 helper 函数。我们现在可以在我们的 Jupyter Notebook 中导入它:

from sklearn.cross_validation import cross_val_score

通过 cross_val_score 使用一种称为 Stratified K-Fold 的特定方法来创建每个折叠中类比例大致相同的折叠,再次减少选择较差折叠的可能性。Stratified K-Fold 是一个很好的默认选项——我们现在不会去修改它。

接下来,我们使用这个新函数通过交叉验证评估我们的模型:

scores = cross_val_score(estimator, X, y, scoring='accuracy') 
average_accuracy = np.mean(scores) * 100 
print("The average accuracy is {0:.1f}%".format(average_accuracy))

我们的新代码返回了一个稍微谦虚的结果,82.3%,但考虑到我们还没有尝试设置更好的参数,这仍然相当不错。在下一节中,我们将看到如何改变参数以实现更好的结果。

在进行数据挖掘和尝试重复实验时,结果的变化是很自然的。这是由于折叠创建方式的不同以及某些分类算法中固有的随机性。我们可以故意选择通过设置随机状态(我们将在后面的章节中这样做)来精确复制一个实验。在实践中,多次重新运行实验以获得平均结果和所有实验结果(平均值和标准差)的分布(范围)是一个好主意。

设置参数

几乎所有用户可以设置的参数,让算法更多地关注特定的数据集,而不是只适用于一小部分特定的问题。设置这些参数可能相当困难,因为选择好的参数值通常高度依赖于数据集的特征。

最近邻算法有几个参数,但最重要的一个是在预测未见属性类别时使用的最近邻数量。在 -learn 中,这个参数称为 n_neighbors。在下面的图中,我们展示了当这个数字太低时,随机标记的样本可能会引起错误。相反,当它太高时,实际最近邻对结果的影响会降低:

图片

在图(a)的左侧,我们通常会期望将测试样本(三角形)分类为圆形。然而,如果n_neighbors为 1,这个区域中唯一的红色菱形(可能是噪声样本)会导致样本被预测为菱形。在图(b)的右侧,我们通常会期望将测试样本分类为菱形。但是,如果n_neighbors为 7,三个最近的邻居(它们都是菱形)被大量圆形样本所覆盖。最近邻是一个难以解决的问题,因为参数可以产生巨大的差异。幸运的是,大多数时候,具体的参数值不会对最终结果产生很大影响,标准值(通常是 5 或 10)通常足够接近

考虑到这一点,我们可以测试一系列的值,并调查这个参数对性能的影响。如果我们想测试n_neighbors参数的多个值,例如,从 1 到 20 的每个值,我们可以通过设置n_neighbors并观察结果来多次重新运行实验。下面的代码就是这样做的,将值存储在avg_scoresall_scores变量中。

avg_scores = [] 
all_scores = [] 
parameter_values = list(range(1, 21))  # Include 20 
for n_neighbors in parameter_values: 
    estimator = KNeighborsClassifier(n_neighbors=n_neighbors) 
    scores = cross_val_score(estimator, X, y, scoring='accuracy')     avg_scores.append(np.mean(scores))     
all_scores.append(scores)

我们可以绘制n_neighbors值与准确率之间的关系图。首先,我们告诉 Jupyter Notebook 我们希望在笔记本本身中显示inline图:

%matplotlib inline

然后,我们从matplotlib库中导入pyplot并绘制参数值和平均分数:

from matplotlib import pyplot as plt plt.plot(parameter_values,  avg_scores, '-o')

虽然有很大的变异性,但随着邻居数量的增加,图表显示了一个下降趋势。关于变异性,你可以预期在进行此类评估时会有大量的变异性。为了补偿,更新代码以运行 100 次测试,每次测试n_neighbors的每个值。

预处理

在对现实世界对象进行测量时,我们通常可以得到不同范围的特征。例如,如果我们测量动物的特性,我们可能会有几个特征,如下所示:

  • 腿的数量:对于大多数动物来说,这个范围在 0-8 之间,而有些动物更多!更多!更多!

  • 重量:这个范围只在几毫克到一只重达 190,000 千克的蓝鲸之间!

  • 心脏的数量:对于蚯蚓来说,这个范围在零到五之间。

对于基于数学的算法来比较这些特征,尺度、范围和单位之间的差异可能难以解释。如果我们使用上述特征在许多算法中,权重可能是最有影响力的特征,因为只有较大的数字,而与特征的真正有效性无关。

一种可能的策略标准化特征,使它们都具有相同的范围,或者将值转换为如这样的类别。突然之间,特征类型之间的巨大差异对算法的影响减小,可以导致准确率的大幅提高。

预处理还可以用来选择更有效的特征,创建新特征等。scikit-learn 中的预处理是通过Transformer对象完成的,这些对象接受一种形式的数据集,并在数据的一些转换后返回修改后的数据集。这些不必是数值型的,因为转换器也用于提取特征。然而,在本节中,我们将坚持使用预处理。

我们可以通过破坏Ionosphere数据集来展示这个问题的一个例子。虽然这只是一个例子,但许多现实世界的数据集都存在这种形式的问题。

  1. 首先,我们创建数组的副本,以确保我们不改变原始数据集:
X_broken = np.array(X)

  1. 接下来,我们通过将每个第二个特征除以10破坏数据集:
X_broken[:,::2] /= 10

理论上,这不应该对结果有太大影响。毕竟,这些特征的值仍然相对相同。主要问题是尺度发生了变化,奇数特征现在比偶数特征更大。我们可以通过计算准确率来看到这种影响:

estimator = KNeighborsClassifier() 
original_scores = cross_val_score(estimator, X, y,scoring='accuracy') 
print("The original average accuracy for is {0:.1f}%".format(np.mean(original_scores) * 100)) 
broken_scores = cross_val_score(estimator, X_broken, y,   scoring='accuracy') 
print("The 'broken' average accuracy for is   {0:.1f}%".format(np.mean(broken_scores) * 100))

这种测试方法给原始数据集评分为 82.3%,在破坏的数据集上降至 71.5%。我们可以通过将所有特征缩放到01的范围来解决这个问题。

标准预处理

我们将为这次实验执行的预处理称为基于特征的归一化,我们使用 scikit-learn 的MinMaxScaler类来完成。继续使用本章其余部分的 Jupyter Notebook,首先,我们导入这个类:

fromsklearn.preprocessing import MinMaxScaler

这个类将每个特征缩放到01的范围。这个预处理程序将最小值替换为0,最大值替换为1,其他值根据线性映射位于两者之间。

为了应用我们的预处理程序,我们在其上运行transform函数。转换器通常需要先进行训练,就像分类器一样。我们可以通过运行fit_transform函数来合并这些步骤:

X_transformed = MinMaxScaler().fit_transform(X)

在这里,X_transformed将与*X*具有相同的形状。然而,每一列的最大值将是1,最小值是0

以这种方式进行归一化有各种其他形式,这对于其他应用和特征类型是有效的:

  • 使用sklearn.preprocessing.Normalizer确保每个样本的值之和等于 1

  • 使用sklearn.preprocessing.StandardScaler将每个特征强制转换为具有零均值和方差为 1,这是归一化的常用起点

  • 使用sklearn.preprocessing.Binarizer将数值特征转换为二元特征,其中高于阈值的值为 1,低于阈值的值为 0

我们将在后续章节中使用这些预处理器的组合,以及其他类型的Transformers对象。

预处理是数据挖掘流程中的关键步骤,它可能意味着结果的好坏之分。

将所有这些放在一起

现在我们可以通过结合前几节中的代码,使用之前计算出的损坏数据集来创建一个工作流程:

X_transformed = MinMaxScaler().fit_transform(X_broken) 
estimator = KNeighborsClassifier() 
transformed_scores = cross_val_score(estimator, X_transformed, y,    scoring='accuracy') 
print("The average accuracy for is {0:.1f}%".format(np.mean(transformed_scores) * 100))

现在我们恢复了原来的准确率 82.3%,MinMaxScaler导致特征具有相同的尺度,这意味着没有特征仅仅因为值更大而压倒其他特征。虽然最近邻算法可能会被较大的特征所迷惑,但某些算法更好地处理尺度差异。相比之下,有些算法则差得多!

管道

随着实验的增多,操作的复杂性也在增加。我们可能需要分割我们的数据集,二值化特征,执行基于特征的缩放,执行基于样本的缩放,以及许多其他操作。

跟踪这些操作可能会变得相当混乱,并可能导致无法复制结果。问题包括忘记一个步骤,错误地应用转换,或者添加不必要的转换。

另一个问题是对代码的顺序。在前一节中,我们创建了我们的X_transformed数据集,然后为交叉验证创建了一个新的估计器。如果我们有多个步骤,我们就需要在代码中跟踪这些对数据集的更改。

管道(Pipelines)是一种解决这些问题的结构(以及我们将在下一章中看到的其他问题)。管道存储数据挖掘工作流程中的步骤。它们可以接收原始数据,执行所有必要的转换,然后创建预测。这使得我们可以在cross_val_score等函数中使用管道,这些函数期望一个估计器。首先,导入Pipeline对象:

fromsklearn.pipeline import Pipeline

管道接受一个步骤列表作为输入,表示数据挖掘应用的链。最后一个步骤需要是一个估计器,而所有之前的步骤都是转换器。输入数据集被每个转换器所改变,一个步骤的输出成为下一个步骤的输入。最后,我们通过最后一个步骤的估计器对样本进行分类。在我们的管道中,我们有两个步骤:

  1. 使用MinMaxScaler将特征值缩放到 0 到 1

  2. 使用KNeighborsClassifier作为分类算法

我们然后使用元组('name', step)`来表示每个步骤。然后我们可以创建我们的管道:

scaling_pipeline = Pipeline([('scale', MinMaxScaler()), 
                             ('predict', KNeighborsClassifier())])

关键在于元组的列表。第一个元组是我们的缩放步骤,第二个元组是预测步骤。我们给每个步骤起一个名字:第一个我们称之为scale,第二个我们称之为predict,但你可以选择自己的名字。元组的第二部分是实际的Transformerestimator对象。

现在运行这个管道非常简单,使用之前交叉验证的代码:

scores = cross_val_score(scaling_pipeline, X_broken, y, scoring='accuracy') 
print("The pipeline scored an average accuracy for is {0:.1f}%".format(np.mean(transformed_scores) * 100))

这给我们带来了与之前相同的分数(82.3%),这是预期的,因为我们正在运行完全相同的步骤,只是界面有所改进。

在后面的章节中,我们将使用更高级的测试方法,设置管道是确保代码复杂度不会无序增长的好方法。

摘要

在本章中,我们使用了 scikit-learn 的几种方法来构建一个标准的流程来运行和评估数据挖掘模型。我们介绍了最近邻算法,该算法在 scikit-learn 中作为估计器实现。使用这个类相当简单;首先,我们在训练数据上调用fit函数,然后使用predict函数来预测测试样本的类别。

然后,我们通过修复不良的特征缩放来查看预处理。这是通过Transformer对象和MinMaxScaler类来完成的。这些函数也有一个fit方法,然后是转换,它接受一种形式的数据作为输入,并返回一个转换后的数据集作为输出。

为了进一步研究这些转换,尝试用其他提到的转换器替换MinMaxScaler。哪个最有效,为什么会是这样?

scikit-learn 中还存在其他转换器,我们将在本书的后续部分使用,例如 PCA。也尝试一些这些转换器,参考 scikit-learn 的优秀文档scikit-learn.org/stable/modules/preprocessing.html

在下一章中,我们将使用这些概念在一个更大的例子中,使用现实世界的数据预测体育比赛的结果。

第三章:使用决策树预测体育比赛获胜者

在本章中,我们将探讨使用不同于我们之前所见类型的分类算法来预测体育比赛获胜者:决策树。这些算法相对于其他算法有许多优点。其中一个主要优点是它们可由人类阅读,这使得它们可以在人类驱动的决策中应用。通过这种方式,决策树可以用来学习一个程序,如果需要,可以将其交给人类执行。另一个优点是它们可以处理各种特征,包括分类特征,我们将在本章中看到。

本章将涵盖以下主题:

  • 使用 pandas 库加载数据和操作数据

  • 用于分类的决策树

  • 使用随机森林来改进决策树

  • 在数据挖掘中使用真实世界的数据集

  • 创建新特征并在稳健的框架中测试它们

加载数据集

在本章中,我们将探讨预测国家篮球协会NBA)比赛获胜者的问题。NBA 的比赛往往非常接近,有时在最后一刻才能分出胜负,这使得预测获胜者变得相当困难。许多运动都具备这种特征,即(通常)更好的队伍可能在某一天被另一支队伍击败。

对预测获胜者的各种研究表明,体育结果预测的准确性可能存在上限,这个上限取决于运动项目,通常在 70%到 80%之间。目前正在进行大量的体育预测研究,通常是通过数据挖掘或基于统计的方法进行的。

在本章中,我们将探讨一个入门级的篮球比赛预测算法,使用决策树来确定一支队伍是否会赢得某场比赛。不幸的是,它并不像体育博彩机构使用的模型那样盈利,这些模型通常更先进、更复杂,最终也更准确。

收集数据

我们将使用的是 2015-2016 赛季 NBA 的比赛历史数据。网站basketball-reference.com包含从 NBA 和其他联赛收集的大量资源和统计数据。要下载数据集,请执行以下步骤:

  1. 在您的网络浏览器中导航到www.basketball-reference.com/leagues/NBA_2016_games.html

  2. 点击“分享和更多”。

  3. 点击“获取表格为 CSV(适用于 Excel)”。

  4. 将包括标题在内的数据复制到名为basketball.csv的文本文件中。

  5. 对其他月份重复此过程,但不要复制标题。

这将为您提供包含本季 NBA 每场比赛结果的 CSV 文件。您的文件应包含 1316 场比赛和文件中的总行数 1317 行,包括标题行。

CSV 文件是文本文件,其中每行包含一个新行,每个值由逗号分隔(因此得名)。CSV 文件可以通过在文本编辑器中键入并保存为.csv扩展名来手动创建。它们可以在任何可以读取文本文件的程序中打开,也可以在 Excel 中以电子表格的形式打开。Excel(和其他电子表格程序)通常可以将电子表格转换为 CSV 格式。

我们将使用pandas库来加载文件,这是一个用于操作数据的极其有用的库。Python 还包含一个名为csv的内置库,它支持读取和写入 CSV 文件。然而,我们将使用 pandas,它提供了更强大的函数,我们将在本章后面创建新功能时使用。

对于本章,你需要安装 pandas。最简单的方法是使用 Anaconda 的conda安装程序,就像你在第一章“开始数据挖掘安装 scikit-learn”中所做的那样:

$ conda install pandas 如果你在安装 pandas 时遇到困难,请访问项目的网站pandas.pydata.org/getpandas.html,并阅读适用于您系统的安装说明。

使用 pandas 加载数据集

pandas库是一个用于加载数据、管理和操作数据的库。它在幕后处理数据结构,并支持数据分析函数,例如计算平均值和按值分组数据。

在进行多次数据挖掘实验时,你会发现你反复编写许多相同的函数,例如读取文件和提取特征。每次这种重新实现都会带来引入错误的风险。使用像pandas这样的高质量库可以显著减少执行这些函数所需的工作量,并使你更有信心使用经过良好测试的代码来支撑你的程序。

在整本书中,我们将大量使用 pandas,随着内容的展开介绍用例和所需的新函数。

我们可以使用read_csv函数来加载数据集:

import pandas as pd
data_filename = "basketball.csv"
dataset = pd.read_csv(data_filename)

这样做的结果是一个 pandas DataFrame,它有一些有用的函数,我们将在以后使用。查看生成的数据集,我们可以看到一些问题。输入以下内容并运行代码以查看数据集的前五行:

dataset.head(5)

这是输出:

图片

仅用参数读取数据就产生了一个相当可用的数据集,但它有一些问题,我们将在下一节中解决。

清理数据集

在查看输出后,我们可以看到许多问题:

  • 日期只是一个字符串,而不是日期对象

  • 从视觉检查结果来看,标题不完整或不正确

这些问题来自数据,我们可以通过改变数据本身来修复这些问题。然而,在这样做的时候,我们可能会忘记我们采取的步骤或错误地应用它们;也就是说,我们无法复制我们的结果。就像在前面一节中我们使用管道来跟踪我们对数据集所做的转换一样,我们将使用 pandas 对原始数据进行转换。

pandas.read_csv函数有参数可以修复这些问题,我们可以在加载文件时指定。我们还可以在加载文件后更改标题,如下面的代码所示:

dataset = pd.read_csv(data_filename, parse_dates=["Date"]) dataset.columns
        = ["Date", "Start (ET)", "Visitor Team", "VisitorPts", 
           "Home Team", "HomePts", "OT?", "Score Type", "Notes"]

结果显著提高,正如我们可以从打印出的结果数据框中看到:

dataset.head()

输出如下:

图片

即使在像这样精心编制的数据源中,你也需要做一些调整。不同的系统有不同的细微差别,导致数据文件之间并不完全兼容。在首次加载数据集时,始终检查加载的数据(即使它是已知的格式),并检查数据的数据类型。在 pandas 中,可以使用以下代码完成:

print(dataset.dtypes)

现在我们已经将数据集格式化为一致的形式,我们可以计算一个基线,这是一种在给定问题中获得良好准确率的好方法。任何合格的数据挖掘解决方案都应该击败这个基线数字。

对于产品推荐系统,一个好的基线是简单地推荐最受欢迎的产品

对于分类任务,可以是总是预测最频繁的任务,或者应用一个非常简单的分类算法,如OneR

对于我们的数据集,每场比赛有两支队伍:一支主队和一支客队。这个任务的明显基线是 50%,如果我们随机猜测获胜者,这是我们预期的准确率。换句话说,随机选择预测获胜的队伍(随着时间的推移)将导致大约 50%的准确率。然而,凭借一点领域知识,我们可以为这个任务使用更好的基线,我们将在下一节中看到。

提取新特征

现在,我们将通过组合和比较现有数据从该数据集中提取一些特征。首先,我们需要指定我们的类别值,这将给我们的分类算法提供一些比较的依据,以判断其预测是否正确。这可以通过多种方式编码;然而,对于这个应用,如果主队获胜,我们将指定类别为 1,如果客队获胜,则为 0。在篮球中,得分最高的队伍获胜。因此,尽管数据集没有直接指定谁获胜,我们仍然可以轻松地计算出结果。

我们可以通过以下方式指定数据集:

dataset["HomeWin"] = dataset["VisitorPts"] < dataset["HomePts"]

然后将这些值复制到一个 NumPy 数组中,以便稍后用于我们的 scikit-learn 分类器。目前 pandas 和 scikit-learn 之间没有干净的集成,但它们可以通过使用 NumPy 数组很好地一起工作。虽然我们将使用 pandas 提取特征,但我们需要提取值来与 scikit-learn 一起使用:

y_true = dataset["HomeWin"].values

前面的数组现在以 scikit-learn 可以读取的格式存储我们的类别值。

顺便说一句,体育预测更好的基线是预测每场比赛的主队。研究表明,主队在全球几乎所有体育项目中都有优势。这个优势有多大?让我们看看:

dataset["HomeWin"].mean()

最终得到的值,大约为 0.59,表明主队平均赢得 59%的比赛。这比随机机会的 50%要高,并且这是一条适用于大多数体育运动的简单规则。

我们还可以开始创建一些特征,用于我们的数据挖掘输入值(X数组)。虽然有时我们可以直接将原始数据扔进我们的分类器,但我们通常需要从我们的数据中推导出连续的数值或分类特征。

对于我们当前的数据库,我们实际上不能使用现有的特征(以它们当前的形式)来进行预测。在我们需要预测比赛结果之前,我们不知道比赛的分数,因此我们不能将它们用作特征。虽然这听起来可能很明显,但很容易忽略。

我们想要创建的前两个特征,以帮助我们预测哪支队伍会赢,是这两个队伍中的任何一个是否赢得了上一场比赛。这大致可以近似哪支队伍目前表现良好。

我们将通过按顺序遍历行并记录哪支队伍获胜来计算这个特征。当我们到达新行时,我们查看该队伍上次我们看到他们时是否获胜。

我们首先创建一个(默认)字典来存储球队的最后一次结果:

from collections import defaultdict 
won_last = defaultdict(int)

然后,我们在数据集上创建一个新的特征来存储我们新特征的成果:

dataset["HomeLastWin"] = 0
dataset["VisitorLastWin"] = 0

这个字典的键将是球队,值将是他们是否赢得了上一场比赛。然后我们可以遍历所有行,并更新当前行的球队最后结果:

for index, row in dataset.iterrows():
    home_team = row["Home Team"]
    visitor_team = row["Visitor Team"]
    row["HomeLastWin"] = won_last[home_team]
    dataset.set_value(index, "HomeLastWin", won_last[home_team])
    dataset.set_value(index, "VisitorLastWin", won_last[visitor_team])
    won_last[home_team] = int(row["HomeWin"])
    won_last[visitor_team] = 1 - int(row["HomeWin"])

注意,前面的代码依赖于我们的数据集是按时间顺序排列的。我们的数据集是有序的;然而,如果你使用的数据集不是按顺序排列的,你需要将dataset.iterrows()替换为dataset.sort("Date").iterrows()

循环中的最后两行根据哪支队伍赢得了当前比赛,将 1 或 0 更新到我们的字典中。这些信息被用于下一场每支队伍所打的比赛。

在前面的代码运行之后,我们将有两个新的特征:HomeLastWinVisitorLastWin。使用dataset.head(6)查看数据集,以了解一支主队和一支客队最近赢得比赛的例子。使用 pandas 的索引器查看数据集的其他部分:

dataset.ix[1000:1005]

目前,当它们首次出现时,这会给所有团队(包括上一年的冠军!)一个错误值。我们可以使用上一年的数据来改进这个功能,但在这个章节中我们不会这么做。

决策树

决策树是一类监督学习算法,类似于流程图,由一系列节点组成,其中样本的值用于在下一个节点上进行决策。

以下示例很好地说明了决策树是如何成为一类监督学习算法的:

图片

与大多数分类算法一样,使用它们有两个阶段:

  • 第一个阶段是训练阶段,在这个阶段,使用训练数据构建一个树。虽然上一章中的最近邻算法没有训练阶段,但决策树需要这个阶段。这样,最近邻算法是一个懒惰的学习者,只有在需要做出预测时才会进行任何工作。相比之下,决策树,像大多数分类方法一样,是积极的学习者,在训练阶段进行工作,因此在预测阶段需要做的工作更少。

  • 第二个阶段是预测阶段,在这个阶段,使用训练好的树来预测新样本的分类。使用之前的示例树,数据点["is raining", "very windy"]会被归类为恶劣天气

存在许多创建决策树的算法。其中许多算法是迭代的。它们从基本节点开始,决定第一个决策的最佳特征,然后转到每个节点并选择下一个最佳特征,依此类推。当决定进一步扩展树无法获得更多收益时,这个过程会在某个点上停止。

scikit-learn包实现了分类和回归树CART)算法,作为其默认的决策树类,它可以使用分类和连续特征。

决策树中的参数

对于决策树来说,最重要的参数之一是停止标准。当树构建接近完成时,最后的几个决策往往可能是相当随意的,并且只依赖于少量样本来做出决策。使用这样的特定节点可能导致树显著过度拟合训练数据。相反,可以使用停止标准来确保决策树不会达到这种精确度。

而不是使用停止标准,树可以完全创建,然后进行修剪。这个过程会移除对整体过程提供信息不多的节点。这被称为剪枝,结果是一个在新数据集上通常表现更好的模型,因为它没有过度拟合训练数据。

scikit-learn 中的决策树实现提供了一个方法,使用以下选项来停止树的构建:

  • **min_samples_split**:这指定了在决策树中创建新节点所需的样本数量

  • **min_samples_leaf**:这指定了节点必须产生的样本数量,以便它保持不变

第一个决定了是否创建决策节点,而第二个决定了是否保留决策节点。

决策树另一个参数是创建决策的标准。基尼不纯度信息增益是这个参数的两个流行选项:

  • 基尼不纯度:这是衡量决策节点错误预测样本类别的频率的度量

  • 信息增益:这使用基于信息论熵来指示决策节点通过决策获得的额外信息量

这些参数值大致做相同的事情——决定使用哪个规则和值来将节点分割成子节点。这个值本身只是确定分割时使用哪个指标,然而这可能会对最终模型产生重大影响。

使用决策树

我们可以导入DecisionTreeClassifier类,并使用 scikit-learn 创建决策树:

from sklearn.tree import DecisionTreeClassifier
clf = DecisionTreeClassifier(random_state=14)

我们再次使用了 14 作为random_state,并在本书的大部分内容中都会这样做。使用相同的随机种子允许实验的可重复性。然而,在你的实验中,你应该混合随机状态以确保算法的性能不依赖于特定值。

我们现在需要从我们的 pandas 数据框中提取数据集,以便与我们的scikit-learn分类器一起使用。我们通过指定我们希望使用的列并使用数据框视图的值参数来完成此操作。以下代码使用主队和客队最后一场胜利的值创建了一个数据集:

X_previouswins = dataset[["HomeLastWin", "VisitorLastWin"]].values

决策树是估计量,如第二章中介绍的,*使用scikit-learn 估计量进行分类,因此有fitpredict方法。我们还可以使用cross_val_score方法来获取平均分数(如我们之前所做的那样):

from sklearn.cross_validation import cross_val_score
import numpy as np
scores = cross_val_score(clf, X_previouswins, y_true,
scoring='accuracy')
print("Accuracy: {0:.1f}%".format(np.mean(scores) * 100))

这个得分是 59.4%,我们比随机选择要好!然而,我们并没有打败仅仅选择主队的基线。事实上,我们几乎完全一样。我们应该能够做得更好。特征工程是数据挖掘中最困难的任务之一,选择好的特征是获得良好结果的关键——比选择正确的算法更重要!

体育结果预测

我们可以通过尝试其他特征来做得更好。我们有一种测试模型准确性的方法。cross_val_score方法允许我们尝试新的特征。

我们可以使用许多可能的特征,但我们将尝试以下问题:

  • 通常哪个队被认为是更好的?

  • 哪个队赢得了他们上次相遇?

我们还将尝试将原始队伍放入算法中,以检查算法是否可以学习一个模型,该模型检查不同队伍之间的比赛。

整合所有内容

对于第一个特征,我们将创建一个特征,告诉我们主队是否通常比客队更好。为此,我们将从上一个赛季的 NBA 中加载排名(在某些运动中也称为排行榜)。如果一个队在 2015 年的排名高于另一个队,则该队将被认为是更好的。

要获取排行榜数据,请执行以下步骤:

  1. 在您的网络浏览器中导航到 www.basketball-reference.com/leagues/NBA_2015_standings.html

  2. 选择扩展排名以获取整个联盟的单个列表。

  3. 点击导出链接。

  4. 将文本复制并保存在您数据文件夹中的名为 standings.csv 的文本/CSV 文件中。

回到您的 Jupyter Notebook 中,将以下行输入到新的单元格中。您需要确保文件已保存到由 data_folder 变量指向的位置。代码如下:

import os
standings_filename = os.path.join(data_folder, "standings.csv")
standings = pd.read_csv(standings_filename, skiprows=1)

您只需在新的单元格中输入“standings”并运行,就可以查看排行榜。

代码:

standings.head()

输出如下:

图片

接下来,我们使用与之前特征相似的图案创建一个新的特征。我们遍历行,查找主队和客队的排名。代码如下:

dataset["HomeTeamRanksHigher"] = 0
for index, row in dataset.iterrows():
    home_team = row["Home Team"]
    visitor_team = row["Visitor Team"]
    home_rank = standings[standings["Team"] == home_team]["Rk"].values[0]
    visitor_rank = standings[standings["Team"] == visitor_team]["Rk"].values[0]
    row["HomeTeamRanksHigher"] = int(home_rank > visitor_rank)
    dataset.set_value(index, "HomeTeamRanksHigher", int(home_rank < visitor_rank))

接下来,我们使用 cross_val_score 函数测试结果。首先,我们提取数据集:

X_homehigher = dataset[["HomeLastWin", "VisitorLastWin", "HomeTeamRanksHigher"]].values

然后,我们创建一个新的 DecisionTreeClassifier 并运行评估:

clf = DecisionTreeClassifier(random_state=14)
scores = cross_val_score(clf, X_homehigher, y_true, scoring='accuracy')
print("Accuracy: {0:.1f}%".format(np.mean(scores) * 100))

现在这个得分是 60.9%,甚至比我们之前的结果更好,现在比每次只选择主队更好。我们能做得更好吗?

接下来,让我们测试两支球队中哪一支在最近一场比赛中赢得了对方。虽然排名可以给出一些关于谁赢的线索(排名更高的球队更有可能赢),但有时球队对其他球队的表现更好。这有很多原因——例如,一些球队可能有针对特定球队的策略或球员表现非常好。遵循我们之前的模式,我们创建一个字典来存储过去比赛的胜者,并在我们的数据框中创建一个新的特征。代码如下:

last_match_winner = defaultdict(int)
dataset["HomeTeamWonLast"] = 0

for index, row in dataset.iterrows():
    home_team = row["Home Team"]
    visitor_team = row["Visitor Team"]
    teams = tuple(sorted([home_team, visitor_team])) # Sort for a consistent ordering
    # Set in the row, who won the last encounter
    home_team_won_last = 1 if last_match_winner[teams] == row["Home Team"] else 0
    dataset.set_value(index, "HomeTeamWonLast", home_team_won_last)
    # Who won this one?
    winner = row["Home Team"] if row["HomeWin"] else row["Visitor Team"]
    last_match_winner[teams] = winner

这个特征与我们的上一个基于排名的特征非常相似。然而,不是查找排名,这个特征创建了一个名为 teams 的元组,并将之前的结果存储在字典中。当这两支球队再次比赛时,它将重新创建这个元组,并查找之前的结果。我们的代码没有区分主场比赛和客场比赛,这可能是一个有用的改进,值得考虑实施。

接下来,我们需要进行评估。这个过程与之前非常相似,只是我们添加了新的特征到提取的值中:

X_lastwinner = dataset[[ "HomeTeamWonLast", "HomeTeamRanksHigher", "HomeLastWin", "VisitorLastWin",]].values
clf = DecisionTreeClassifier(random_state=14, criterion="entropy")

scores = cross_val_score(clf, X_lastwinner, y_true, scoring='accuracy')

print("Accuracy: {0:.1f}%".format(np.mean(scores) * 100))

这个得分是 62.2%。我们的结果越来越好。

最后,我们将检查如果我们向决策树投入大量数据会发生什么,并看看它是否仍然可以学习到一个有效的模型。我们将把球队输入到树中,并检查决策树是否可以学习到包含这些信息。

虽然决策树能够从分类特征中学习,但scikit-learn中的实现要求这些特征被编码为数字和特征,而不是字符串值。我们可以使用LabelEncoder 转换器将基于字符串的球队名称转换为分配的整数值。代码如下:

from sklearn.preprocessing import LabelEncoder
encoding = LabelEncoder()
encoding.fit(dataset["Home Team"].values)
home_teams = encoding.transform(dataset["Home Team"].values)
visitor_teams = encoding.transform(dataset["Visitor Team"].values)
X_teams = np.vstack([home_teams, visitor_teams]).T

我们应该使用相同的转换器来编码主队和客队。这样,同一个球队作为主队和客队都会得到相同的整数值。虽然这对这个应用程序的性能不是至关重要,但它很重要,而且不做这件事可能会降低未来模型的表现。

这些整数可以输入到决策树中,但它们仍然会被DecisionTreeClassifier解释为连续特征。例如,球队可能被分配从 0 到 16 的整数。算法会将 1 号和 2 号球队视为相似,而 4 号和 10 号球队则非常不同——但这在所有方面都没有意义!所有的球队都是不同的——两个球队要么相同,要么不同!

为了解决这个问题的不一致性,我们使用OneHotEncoder 转换器将这些整数编码成一系列二进制特征。每个二进制特征将代表一个特征的单个值。例如,如果 NBA 球队芝加哥公牛被LabelEncoder分配为整数 7,那么OneHotEncoder返回的第七个特征将为 1,如果球队是芝加哥公牛,而对于所有其他特征/球队则为 0。这是对每个可能的值都这样做,结果得到一个更大的数据集。代码如下:

from sklearn.preprocessing import OneHotEncoder
onehot = OneHotEncoder()
X_teams = onehot.fit_transform(X_teams).todense()

接下来,我们像以前一样在新数据集上运行决策树:

clf = DecisionTreeClassifier(random_state=14)
scores = cross_val_score(clf, X_teams, y_true, scoring='accuracy')
print("Accuracy: {0:.1f}%".format(np.mean(scores) * 100))

这得到了 62.8%的准确率。尽管提供的信息仅仅是参赛球队,但这个分数仍然更好。可能是因为更多的特征没有被决策树正确处理。因此,我们将尝试更改算法,看看是否有所帮助。数据挖掘可能是一个尝试新算法和特征的过程。

随机森林

单个决策树可以学习相当复杂的函数。然而,决策树容易过拟合——学习只适用于特定训练集的规则,并且对新数据泛化不好。

我们可以调整的一种方法是限制它学习的规则数量。例如,我们可以将树的深度限制为只有三层。这样的树将在全局层面上学习分割数据集的最佳规则,但不会学习将数据集分割成高度准确组的特定规则。这种权衡导致的结果是,树可能具有良好的泛化能力,但在训练数据集上的整体性能略差。

为了补偿这一点,我们可以创建许多这些有限的决策树,并要求每个树预测类值。我们可以进行多数投票,并使用那个答案作为我们的整体预测。随机森林就是从这个洞察力发展而来的算法。

上述程序有两个问题。第一个问题是构建决策树在很大程度上是确定的——使用相同的输入每次都会得到相同的结果。我们只有一个训练数据集,这意味着如果我们尝试构建多个树,我们的输入(以及因此的输出)将会相同。我们可以通过选择数据集的随机子样本来解决这个问题,从而有效地创建新的训练集。这个过程被称为袋装法,在数据挖掘的许多情况下可以非常有效。

第二个问题是我们可能会遇到的是,从类似数据创建许多决策树时,用于树中前几个决策节点的特征往往会相似。即使我们选择训练数据的随机子样本,仍然很可能构建的决策树在很大程度上是相同的。为了补偿这一点,我们还选择一个特征的随机子集来执行我们的数据拆分。

然后,我们使用随机选择的样本和(几乎)随机选择的特征来构建随机树。这是一个随机森林,也许不太直观,但这个算法对于许多数据集来说非常有效,几乎不需要调整模型的许多参数。

集成是如何工作的?

随机森林固有的随机性可能会让人感觉我们是在把算法的结果留给运气。然而,我们通过将平均化的好处应用于几乎随机构建的决策树,从而得到一个减少结果方差的算法。

方差是指训练数据集的变化对算法引入的错误。具有高方差(如决策树)的算法会受到训练数据集变化的很大影响。这导致模型存在过度拟合的问题。相比之下,偏差是指算法中的假设引入的错误,而不是与数据集有关的事情,也就是说,如果我们有一个假设所有特征都呈正态分布的算法,那么如果特征不是正态分布的,我们的算法可能会有很高的错误率。

通过分析数据以查看分类器的数据模型是否与实际数据相匹配,可以减少偏差带来的负面影响。

用一个极端的例子来说,一个总是预测为真的分类器,不管输入如何,都有很高的偏差。一个总是随机预测的分类器会有很高的方差。每个分类器都有很高的错误率,但性质不同。

通过平均大量决策树,这种方差大大降低。这至少在正常情况下会导致模型具有更高的整体准确性和更好的预测能力。权衡是时间增加和算法偏差的增加。

通常,集成方法基于预测误差是有效随机的,并且这些误差在各个分类器之间相当不同。通过在许多模型之间平均结果,这些随机误差被抵消,留下真正的预测。我们将在本书的其余部分看到更多集成方法的实际应用。

在随机森林中设置参数

scikit-learn 中的随机森林实现称为RandomForestClassifier,它有许多参数。由于随机森林使用许多DecisionTreeClassifier的实例,它们共享许多相同的参数,例如criterion(基尼不纯度或熵/信息增益)、max_featuresmin_samples_split

在集成过程中使用了一些新的参数:

  • n_estimators:这决定了应该构建多少个决策树。更高的值将运行时间更长,但(可能)会导致更高的准确性。

  • oob_score:如果为真,则使用不在为训练决策树选择的随机子样本中的样本进行方法测试。

  • n_jobs:这指定了在并行训练决策树时使用的核心数。

scikit-learn包使用名为Joblib的库来实现内置的并行化。此参数决定了要使用多少核心。默认情况下,只使用单个核心--如果你有更多核心,你可以增加这个值,或者将其设置为-1 以使用所有核心。

应用随机森林

scikit-learn 中的随机森林使用Estimator接口,允许我们使用几乎与之前完全相同的代码来进行交叉验证:

from sklearn.ensemble import RandomForestClassifier
clf = RandomForestClassifier(random_state=14)
scores = cross_val_score(clf, X_teams, y_true, scoring='accuracy')
print("Accuracy: {0:.1f}%".format(np.mean(scores) * 100))

这通过仅仅交换分类器就带来了 65.3%的即时收益,提高了 2.5 个百分点。

随机森林,通过使用特征子集,应该能够比普通决策树更有效地学习,并且具有更多特征。我们可以通过向算法投入更多特征来测试这一点,看看效果如何:

X_all = np.hstack([X_lastwinner, X_teams])
clf = RandomForestClassifier(random_state=14)
scores = cross_val_score(clf, X_all, y_true, scoring='accuracy')
print("Accuracy: {0:.1f}%".format(np.mean(scores) * 100))

结果是 63.3%,性能下降!一个原因是随机森林固有的随机性,它只选择了一些特征来使用,而不是其他特征。此外,X_teams中的特征比X_lastwinner中的特征要多得多,额外的特征导致使用了更不相关的信息。尽管如此,也不要对百分比的小幅变化过于兴奋,无论是上升还是下降。改变随机状态值对准确性的影响将大于我们刚刚观察到的这些特征集之间微小的差异。相反,你应该运行许多具有不同随机状态的测试,以获得准确度值的平均值和分布的良好感觉。

我们还可以尝试使用GridSearchCV类尝试一些其他参数,正如我们在第二章中介绍的,“使用scikit-learn 估计器进行分类”:

from sklearn.grid_search import GridSearchCV

parameter_space = {
 "max_features": [2, 10, 'auto'],
 "n_estimators": [100, 200],
 "criterion": ["gini", "entropy"],
 "min_samples_leaf": [2, 4, 6],
}

clf = RandomForestClassifier(random_state=14)
grid = GridSearchCV(clf, parameter_space)
grid.fit(X_all, y_true)
print("Accuracy: {0:.1f}%".format(grid.best_score_ * 100))

这使得准确率达到了 67.4%,非常好!

如果我们想查看使用的参数,我们可以打印出网格搜索中找到的最佳模型。代码如下:

print(grid.best_estimator_)

结果显示了最佳得分模型中使用的参数:

RandomForestClassifier(bootstrap=True, class_weight=None, criterion='entropy',
            max_depth=None, max_features=2, max_leaf_nodes=None,
            min_samples_leaf=2, min_samples_split=2,
            min_weight_fraction_leaf=0.0, n_estimators=100, n_jobs=1,
            oob_score=False, random_state=14, verbose=0, warm_start=False)

新特征工程

在之前的几个例子中,我们看到改变特征可以对算法的性能产生相当大的影响。通过我们的小量测试,我们仅从特征中就获得了超过 10%的方差。

您可以通过这样做来创建来自 pandas 中简单函数的特征:

dataset["New Feature"] = feature_creator()

feature_creator函数必须返回数据集中每个样本的特征值列表。一种常见的模式是使用数据集作为参数:

dataset["New Feature"] = feature_creator(dataset)

您可以通过将所有值设置为单个默认值(如下一行中的 0)来更直接地创建这些特征:

dataset["My New Feature"] = 0

然后,您可以遍历数据集,在遍历过程中计算特征。我们使用了

本章中我们使用这种格式创建了许多特征:

for index, row in dataset.iterrows():
    home_team = row["Home Team"]
    visitor_team = row["Visitor Team"]
    # Some calculation here to alter row
    dataset.set_value(index, "FeatureName", feature_value)

请记住,这种模式并不非常高效。如果您要这样做,请一次尝试所有特征。

一种常见的最佳实践是尽可能少地触摸每个样本,最好是只触摸一次。

您可以尝试实现的一些示例特征如下:

  • 每支球队上次比赛以来有多少天了?如果他们在短时间内打了很多比赛,球队可能会感到疲劳。

  • 在过去五场比赛中,每支球队赢了多少场比赛?这将为我们之前提取的HomeLastWinVisitorLastWin特征提供更稳定的形式(并且可以以非常相似的方式提取)。

  • 当球队访问某些其他球队时,他们的记录好吗?例如,一支球队可能在某个特定的体育场表现良好,即使他们是客队。

如果您在提取这些类型的特征时遇到麻烦,请查阅pandas 文档以获取帮助。或者,您也可以尝试在线论坛,如 Stack Overflow 寻求帮助。

更极端的例子可以使用球员数据来估计每支球队的实力,以预测谁会获胜。这些类型的复杂特征每天都在赌徒和体育博彩机构中使用,以预测体育比赛结果并试图从中获利。

摘要

在本章中,我们扩展了 scikit-learn 分类器的使用,以执行分类,并引入了pandas库来管理我们的数据。我们分析了 NBA 篮球比赛结果的真实世界数据,看到了即使是精心整理的数据也会引入的一些问题,并为我们的分析创建了新的特征。

我们看到了良好特征对性能的影响,并使用集成算法,随机森林,来进一步提高准确度。为了将这些概念进一步深化,尝试创建你自己的特征并对其进行测试。哪些特征表现更好?如果你在构思特征方面遇到困难,考虑一下可以包含哪些其他数据集。例如,如果关键球员受伤,这可能会影响特定比赛的结果,导致一支更强的队伍输掉比赛。

在下一章中,我们将扩展我们在第一章中进行的亲和力分析,以创建一个寻找相似书籍的程序。我们将看到如何使用排序算法,并使用近似方法来提高数据挖掘的可扩展性。

第四章:使用亲和力分析推荐电影

在本章中,我们将探讨亲和力分析,该分析用于确定对象何时频繁地一起出现。这通常也被称为市场篮子分析,因为这是一种常见的用例——确定在商店中频繁一起购买的商品。

在第三章,使用决策树预测体育比赛胜者中,我们将对象作为焦点,并使用特征来描述该对象。在本章中,数据具有不同的形式。我们有交易,其中感兴趣的物体(在本章中为电影)以某种方式被用于这些交易中。目标是发现对象何时同时出现。如果我们想找出两部电影是否被同一评论家推荐,我们可以使用亲和力分析。

本章的关键概念如下:

  • 亲和力分析用于产品推荐

  • 使用 Apriori 算法进行特征关联挖掘

  • 推荐系统和固有的挑战

  • 稀疏数据格式及其使用方法

亲和力分析

亲和力分析是确定对象以相似方式使用的任务。在前一章中,我们关注的是对象本身是否相似——在我们的案例中是游戏在本质上是否相似。亲和力分析的数据通常以交易的形式描述。直观地说,这来自商店的交易——通过确定对象何时一起购买,作为向用户推荐他们可能购买的产品的方式。

然而,亲和力分析可以应用于许多不使用这种意义上的交易的流程:

  • 欺诈检测

  • 客户细分

  • 软件优化

  • 产品推荐

亲和力分析通常比分类更具探索性。至少,我们通常只是简单地排名结果并选择前五项推荐(或某个其他数字),而不是期望算法给出一个特定的答案。

此外,我们通常没有我们期望的完整数据集来完成许多分类任务。例如,在电影推荐中,我们有不同人对不同电影的评论。然而,我们几乎不可能让每个评论家都评论我们数据集中的所有电影。这给亲和力分析留下了一个重要且困难的问题。如果一个评论家没有评论一部电影,这是否表明他们不感兴趣(因此不会推荐)或者只是他们还没有评论?

思考数据集中的差距可以导致这样的问题。反过来,这可能导致有助于提高你方法有效性的答案。作为一个初露头角的数据挖掘者,知道你的模型和方法需要改进的地方是创造出色结果的关键。

亲和力分析算法

在第一章《数据挖掘入门》中,我们介绍了一种基本的关联分析方法,它测试了所有可能的规则组合。我们计算了每个规则的置信度和支持度,这反过来又允许我们根据规则进行排序,以找到最佳规则。

然而,这种方法并不高效。我们在第一章《数据挖掘入门》中的数据集只有五个销售项目。我们可以预期即使是小型商店也会有数百个销售项目,而许多在线商店会有数千(甚至数百万!)项目。使用我们之前在第一章《数据挖掘入门》中提到的简单规则创建方法,这些规则计算所需的时间会呈指数增长。随着我们添加更多项目,计算所有规则所需的时间增长得更快。具体来说,可能的总规则数是2n - 1。对于五个项目的数据集,有 31 个可能的规则。对于十个项目,这个数字是 1023。对于仅仅 100 个项目,这个数字有 30 位。即使计算能力的急剧增加也无法跟上在线存储项目数量的增长。因此,我们需要更智能的算法,而不是更努力工作的计算机。

关联分析的经典算法被称为Apriori 算法。它解决了在数据库中创建频繁项集(称为频繁项集)的指数级问题。一旦发现这些频繁项集,创建关联规则就变得简单,我们将在本章后面看到这一点。

Apriori 背后的直觉既简单又巧妙。首先,我们确保规则在数据集中有足够的支持度。定义最小支持度是 Apriori 的关键参数。为了构建频繁项集,我们结合较小的频繁项集。对于项集(A,B)要有至少 30%的支持度,A 和 B 必须在数据库中至少出现 30 次。这一属性也适用于更大的集合。对于一个项集(A,B,C,D)要被认为是频繁的,集合(A,B,C)也必须是频繁的(同样,D 也必须是频繁的)。

这些频繁项集可以构建,而不频繁的可能项集(其中有很多)将永远不会被测试。这在新规则测试中节省了大量的时间,因为频繁项集的数量预计将远少于可能项集的总数。

其他关联分析的示例算法基于这个或类似的概念,包括EclatFP-growth算法。数据挖掘文献中有许多对这些算法的改进,进一步提高了方法的效率。在本章中,我们将重点关注基本的 Apriori 算法。

总体方法

为了进行关联规则挖掘以进行亲和力分析,我们首先使用 Apriori 算法生成频繁项集。接下来,我们通过测试那些频繁项集中前提和结论的组合来创建关联规则(例如,如果一个人推荐了电影 X,他们也会推荐电影 Y)。

  1. 在第一阶段,Apriori 算法需要一个值来表示项集需要达到的最小支持度,才能被认为是频繁的。任何支持度低于这个值的项集都不会被考虑。

将这个最小支持度设置得太低会导致 Apriori 测试更多的项集,从而减慢算法的速度。设置得太高会导致考虑的频繁项集更少。

  1. 在第二阶段,在频繁项集被发现之后,基于它们的置信度来测试关联规则。我们可以选择一个最小的置信度水平,返回的规则数量,或者简单地返回所有规则并让用户决定如何处理它们。

在本章中,我们只返回高于给定置信度水平的规则。因此,我们需要设置我们的最小置信度水平。设置得太低会导致具有高支持度但不太准确的规则。设置得更高将导致只返回更准确的规则,但总体上发现的规则更少。

处理电影推荐问题

产品推荐是一个庞大的产业。在线商店通过推荐其他可能购买的产品来向上销售给客户。做出更好的推荐可以带来更好的销售业绩。当在线购物每年向数百万客户销售时,通过向这些客户销售更多商品,就有大量的潜在利润可赚。

产品推荐,包括电影和书籍,已经研究了许多年;然而,当 Netflix 在 2007 年至 2009 年期间举办 Netflix Prize 时,该领域得到了显著的发展。这次比赛旨在确定是否有人能比 Netflix 目前所做的更好预测用户的电影评分。奖项授予了一个团队,他们的表现比当前解决方案高出 10%以上。虽然这种改进可能看起来并不大,但这样的改进将为 Netflix 在接下来的几年中带来数百万美元的收益,因为更好的电影推荐。

获取数据集

自从 Netflix Prize 启动以来,明尼苏达大学的 Grouplens 研究小组已经发布了几个常用于测试该领域算法的数据集。他们发布了多个电影评分数据集的不同版本,大小不同。有一个版本有 10 万条评论,一个版本有 100 万条评论,还有一个版本有 1000 万条评论。

数据集可以从grouplens.org/datasets/movielens/获取,本章我们将使用的是MovieLens 100K 数据集(包含 10 万条评论)。下载此数据集并将其解压到您的数据文件夹中。启动一个新的 Jupyter Notebook,并输入以下代码:

import os
import pandas as pd
data_folder = os.path.join(os.path.expanduser("~"), "Data", "ml-100k")
ratings_filename = os.path.join(data_folder, "u.data")

确保变量ratings_filename指向解压文件夹中的 u.data 文件。

使用 pandas 加载

MovieLens数据集状况良好;然而,与pandas.read_csv的默认选项相比,我们需要做一些更改。首先,数据是以制表符分隔的,而不是逗号。其次,没有标题行。这意味着文件中的第一行实际上是数据,我们需要手动设置列名。

在加载文件时,我们将分隔符参数设置为制表符,告诉 pandas 不要将第一行作为标题读取(使用header=None),并使用给定的值设置列名。让我们看看以下代码:

all_ratings = pd.read_csv(ratings_filename, delimiter="t", header=None, names
            = ["UserID", "MovieID", "Rating", "Datetime"])

虽然我们本章不会使用它,但您可以使用以下行正确解析日期时间戳。评论的日期对于推荐预测可能是一个重要特征,因为一起评分的电影通常比单独评分的电影有更相似的排名。考虑到这一点可以显著提高模型的效果。

all_ratings["Datetime"] = pd.to_datetime(all_ratings['Datetime'], unit='s')

您可以通过在新的单元格中运行以下代码来查看前几条记录:

all_ratings.head()

结果将类似于以下内容:

UserID MovieID Rating Datetime
0 196 242 3 1997-12-04 15:55:49
1 186 302 3 1998-04-04 19:22:22
2 22 377 1 1997-11-07 07:18:36
3 244 51 2 1997-11-27 05:02:03
4 166 346 1 1998-02-02 05:33:16

稀疏数据格式

此数据集是稀疏格式。每一行可以被视为之前章节中使用的大型特征矩阵中的一个单元格,其中行是用户,列是单独的电影。第一列将是每个用户对第一部电影的评论,第二列将是每个用户对第二部电影的评论,依此类推。

该数据集中大约有 1,000 个用户和 1,700 部电影,这意味着完整的矩阵会相当大(近 200 万条记录)。我们可能会遇到在内存中存储整个矩阵的问题,并且对其进行计算会相当麻烦。然而,这个矩阵具有大多数单元格为空的性质,也就是说,大多数用户对大多数电影没有评论。尽管如此,用户编号 213 对电影编号 675 的评论不存在,以及其他大多数用户和电影的组合也是如此。

这里给出的格式代表完整的矩阵,但以更紧凑的方式呈现。第一行表示用户编号 196 在 1997 年 12 月 4 日对电影编号 242 进行了评分,评分为 3(满分五分)。

任何不在数据库中的用户和电影的组合都被假定为不存在。这节省了大量的空间,与在内存中存储一串零相比。这种格式称为稀疏矩阵格式。一般来说,如果你预计你的数据集中有 60%或更多的数据为空或为零,稀疏格式将占用更少的空间来存储。

在稀疏矩阵上进行计算时,我们通常不会关注我们没有的数据——比较所有的零。我们通常关注我们有的数据,并比较这些数据。

理解 Apriori 算法及其实现

本章的目标是产生以下形式的规则:如果一个人推荐了这组电影,他们也会推荐这部电影。我们还将讨论扩展,其中推荐一组电影的人可能会推荐另一部特定的电影。

要做到这一点,我们首先需要确定一个人是否推荐了一部电影。我们可以通过创建一个新的特征“赞同”,如果该人对电影给出了好评,则为 True:

all_ratings["Favorable"] = all_ratings["Rating"] > 3

我们可以通过查看数据集来查看新功能:

all_ratings[10:15]

用户 ID 电影 ID 评分 日期时间 赞同
10 62 257 2 1997-11-12 22:07:14 False
11 286 1014 5 1997-11-17 15:38:45 True
12 200 222 5 1997-10-05 09:05:40 True
13 210 40 3 1998-03-27 21:59:54 False
14 224 29 3 1998-02-21 23:40:57 False

我们将采样我们的数据集以形成训练数据。这也帮助减少了要搜索的数据集的大小,使 Apriori 算法运行得更快。我们获取了前 200 个用户的所有评论:

ratings = all_ratings[all_ratings['UserID'].isin(range(200))]

接下来,我们可以创建一个只包含样本中好评的评论文本的数据集:

favorable_ratings_mask = ratings["Favorable"]
favorable_ratings = ratings[favorable_ratings_mask]

我们将在用户的好评中搜索我们的项集。因此,我们接下来需要的是每个用户给出的好评电影。我们可以通过按UserID对数据集进行分组并遍历每个组中的电影来计算这一点:

favorable_reviews_by_users = dict((k, frozenset(v.values)) for k, v in favorable_ratings.groupby("UserID")["MovieID"])

在前面的代码中,我们将值存储为frozenset,这样我们可以快速检查用户是否对电影进行了评分。

对于这种类型的操作,集合比列表快得多,我们将在后面的代码中使用它们。

最后,我们可以创建一个DataFrame,告诉我们每部电影被好评的频率:

num_favorable_by_movie = ratings[["MovieID", "Favorable"]].groupby("MovieID").sum()

通过运行以下代码,我们可以看到前五部电影:

num_favorable_by_movie.sort_values(by="Favorable", ascending=False).head()

让我们看看前五部电影列表。我们现在只有 ID,将在本章后面获取它们的标题。

电影 ID 赞同
50 100
100 89
258 83
181 79
174 74

探索 Apriori 算法的基本原理

Apriori 算法是我们亲和力分析方法的一部分,专门处理在数据中寻找频繁项集的问题。Apriori 的基本程序是从先前发现的频繁项集中构建新的候选项集。这些候选集被测试以查看它们是否频繁,然后算法按以下方式迭代:

  1. 通过将每个项目放置在其自己的项目集中来创建初始频繁项目集。在此步骤中仅使用至少具有最小支持度的项目。

  2. 从最近发现的频繁项目集中创建新的候选项目集,通过找到现有频繁项目集的超集。

  3. 所有候选项目集都会被测试以确定它们是否频繁。如果一个候选项目集不是频繁的,则将其丢弃。如果没有从这个步骤中产生新的频繁项目集,则转到最后一步。

  4. 存储新发现的频繁项目集并转到第二步。

  5. 返回所有发现的频繁项目集。

此过程在以下工作流程中概述:

实现 Apriori 算法

在 Apriori 的第一轮迭代中,新发现的项集长度将为 2,因为它们将是第一步中创建的初始项集的超集。在第二轮迭代(应用第四步并返回到第二步之后),新发现的项集长度将为 3。这使我们能够快速识别新发现的项集,正如在第二步中所需的那样。

我们可以在字典中存储发现频繁项目集,其中键是项目集的长度。这允许我们快速访问给定长度的项目集,以及通过以下代码帮助快速访问最近发现的频繁项目集:

frequent_itemsets = {}

我们还需要定义一个项目集被认为是频繁所需的最小支持度。此值基于数据集选择,但尝试不同的值以查看它如何影响结果。尽管如此,我建议每次只改变 10%,因为算法运行所需的时间将显著不同!让我们设置一个最小支持度值:

min_support = 50

要实现 Apriori 算法的第一步,我们为每部电影单独创建一个项目集,并测试该项目集是否频繁。我们使用frozenset因为它们允许我们在稍后执行更快的基于集合的操作,并且它们还可以用作计数字典中的键(普通集合不能)。

让我们看看以下frozenset代码的示例:

frequent_itemsets[1] = dict((frozenset((movie_id,)), row["Favorable"])
 for movie_id, row in num_favorable_by_movie.iterrows()
 if row["Favorable"] > min_support)

为了提高效率,我们将第二步和第三步一起实现,通过创建一个函数来执行这些步骤,该函数接受新发现的频繁项目集,创建超集,然后测试它们是否频繁。首先,我们设置函数以执行这些步骤:

from collections import defaultdict

def find_frequent_itemsets(favorable_reviews_by_users, k_1_itemsets, min_support):
    counts = defaultdict(int)
    for user, reviews in favorable_reviews_by_users.items():
        for itemset in k_1_itemsets:
            if itemset.issubset(reviews):
                for other_reviewed_movie in reviews - itemset:
                    current_superset = itemset | frozenset((other_reviewed_movie,))
                    counts[current_superset] += 1
    return dict([(itemset, frequency) for itemset, frequency in counts.items() if frequency >= min_support])

为了遵循我们尽可能少读取数据的经验法则,我们每次调用此函数时只遍历数据集一次。虽然在这个实现中这不是很重要(与平均计算机相比,我们的数据集相对较小),单次遍历是对于更大应用的良好实践。

让我们详细看看这个函数的核心。我们遍历每个用户,以及之前发现的每个项集,然后检查它是否是当前存储在k_1_itemsets中的评论集的子集(注意,这里的 k_1 意味着k-1)。如果是,这意味着用户已经评论了项集中的每部电影。这是通过itemset.issubset(reviews)这一行完成的。

然后,我们可以遍历用户评论的每部单独的电影(那些尚未在项集中),通过将项集与新电影结合来创建超集,并在我们的计数字典中记录我们看到了这个超集。这些都是这个k值的候选频繁项集。

我们通过测试候选项集是否有足够的支持被认为是频繁的来结束我们的函数,并只返回那些支持超过我们的min_support值的项集。

这个函数构成了我们 Apriori 实现的核心,我们现在创建一个循环,遍历更大算法的步骤,随着k从 1 增加到最大值,存储新的项集。在这个循环中,k 代表即将发现的频繁项集的长度,允许我们通过在我们的频繁项集字典中使用键k-1来访问之前发现的最频繁的项集。我们通过它们的长度创建频繁项集并将它们存储在我们的字典中。让我们看看代码:

for k in range(2, 20):
    # Generate candidates of length k, using the frequent itemsets of length k-1
    # Only store the frequent itemsets
    cur_frequent_itemsets = find_frequent_itemsets(favorable_reviews_by_users,
                                                   frequent_itemsets[k-1], min_support)
    if len(cur_frequent_itemsets) == 0:
        print("Did not find any frequent itemsets of length {}".format(k))
        sys.stdout.flush()
        break
    else:
        print("I found {} frequent itemsets of length {}".format(len(cur_frequent_itemsets), k))
        sys.stdout.flush()
        frequent_itemsets[k] = cur_frequent_itemsets

如果我们找到了频繁项集,我们打印一条消息来让我们知道循环将再次运行。如果没有,我们停止迭代,因为没有频繁项集对于k+1,如果当前k值没有频繁项集,因此我们完成算法。

我们使用sys.stdout.flush()来确保打印输出在代码仍在运行时发生。有时,特别是在某些单元格的大循环中,打印输出可能直到代码完成才发生。以这种方式刷新输出确保打印输出在我们想要的时候发生,而不是当界面决定可以分配时间打印的时候。但是,不要过于频繁地刷新——刷新操作(以及正常的打印)都会带来计算成本,这会减慢程序的速度。

你现在可以运行上述代码。

上述代码返回了大约 2000 个不同长度的频繁项集。你会注意到,随着长度的增加,项集的数量先增加后减少。这是因为可能规则的数目在增加。过了一段时间,大量组合不再有必要的支持被认为是频繁的。这导致数量减少。这种减少是 Apriori 算法的优点。如果我们搜索所有可能的项集(而不仅仅是频繁项集的超集),我们将需要搜索成千上万的项集来查看它们是否频繁。

即使这种缩小没有发生,当发现所有电影的组合规则时,算法将达到绝对结束。因此,Apriori 算法将始终终止。

运行此代码可能需要几分钟,如果你有较旧的硬件,可能需要更长的时间。如果你发现运行任何代码示例有困难,可以考虑使用在线云服务提供商以获得额外的速度。有关使用云进行工作的详细信息,请参阅附录,下一步。

提取关联规则

Apriori 算法完成后,我们将有一个频繁项集的列表。这些不是精确的关联规则,但它们可以很容易地转换为这些规则。频繁项集是一组具有最小支持度的项,而关联规则有一个前提和结论。这两个的数据是相同的。

我们可以通过将项集中的一部电影作为结论,并将其他电影作为前提来从频繁项集中创建一个关联规则。这将形成以下形式的规则:如果一个评论家推荐了前提中的所有电影,他们也会推荐结论电影

对于每个项集,我们可以通过将每部电影设置为结论,将剩余的电影作为前提来生成多个关联规则。

在代码中,我们首先通过遍历每个长度的发现频繁项集,从每个频繁项集中生成所有规则的列表。然后,我们遍历项集中的每一部电影作为结论。

candidate_rules = []
for itemset_length, itemset_counts in frequent_itemsets.items():
    for itemset in itemset_counts.keys():
        for conclusion in itemset:
            premise = itemset - set((conclusion,))
            candidate_rules.append((premise, conclusion))

这将返回一个非常大的候选规则数。我们可以通过打印列表中的前几条规则来查看一些:

print(candidate_rules[:5])

生成的输出显示了获得的规则:

[(frozenset({79}), 258), (frozenset({258}), 79), (frozenset({50}), 64), (frozenset({64}), 50), (frozenset({127}), 181)]

在这些规则中,第一部分(frozenset)是前提中的电影列表,而它后面的数字是结论。在第一种情况下,如果一个评论家推荐了电影 79,他们也很可能推荐电影 258。

接下来,我们计算这些规则中每个规则的置信度。这与第一章《数据挖掘入门》中的操作非常相似,唯一的区别是那些必要的更改,以便使用新的数据格式进行计算。

计算置信度的过程首先是通过创建字典来存储我们看到前提导致结论(规则的正确示例)和它没有发生(规则的错误示例)的次数。然后,我们遍历所有评论和规则,确定规则的前提是否适用,如果适用,结论是否准确。

correct_counts = defaultdict(int)
incorrect_counts = defaultdict(int)
for user, reviews in favorable_reviews_by_users.items():
    for candidate_rule in candidate_rules:
        premise, conclusion = candidate_rule
        if premise.issubset(reviews):
            if conclusion in reviews:
                correct_counts[candidate_rule] += 1
            else:
                incorrect_counts[candidate_rule] += 1

然后,我们通过将正确计数除以规则被看到的总次数来计算每个规则的置信度:

rule_confidence = {candidate_rule:
                    (correct_counts[candidate_rule] / float(correct_counts[candidate_rule] +  
                      incorrect_counts[candidate_rule]))
                  for candidate_rule in candidate_rules}

现在,我们可以通过排序这个置信度字典并打印结果来打印前五条规则:

from operator import itemgetter
sorted_confidence = sorted(rule_confidence.items(), key=itemgetter(1), reverse=True)
for index in range(5):
    print("Rule #{0}".format(index + 1))
    premise, conclusion = sorted_confidence[index][0]
    print("Rule: If a person recommends {0} they will also recommend {1}".format(premise, conclusion))
    print(" - Confidence: {0:.3f}".format(rule_confidence[(premise, conclusion)]))
    print("")

生成的打印输出只显示电影 ID,没有电影名称的帮助并不太有用。数据集附带一个名为 u.items 的文件,该文件存储电影名称及其对应的 MovieID(以及其他信息,如类型)。

我们可以使用 pandas 从这个文件中加载标题。有关文件和类别的更多信息可在随数据集提供的 README 文件中找到。文件中的数据是 CSV 格式,但数据由|符号分隔;它没有标题

并且编码设置很重要。列名在 README 文件中找到。

movie_name_filename = os.path.join(data_folder, "u.item")
movie_name_data = pd.read_csv(movie_name_filename, delimiter="|", header=None,
                              encoding = "mac-roman")
movie_name_data.columns = ["MovieID", "Title", "Release Date", "Video Release", "IMDB", "<UNK>",
                           "Action", "Adventure", "Animation", "Children's", "Comedy", "Crime",
                           "Documentary", "Drama", "Fantasy", "Film-Noir", "Horror", "Musical",   
                           "Mystery", "Romance", "Sci-Fi", "Thriller", "War", "Western"]

获取电影标题是一个重要且常用的步骤,因此将其转换为函数是有意义的。我们将创建一个函数,该函数将从其 MovieID 返回电影标题,从而避免每次都查找的麻烦。让我们看看代码:

def get_movie_name(movie_id):
    title_object = movie_name_data[movie_name_data["MovieID"] == movie_id]["Title"]
    title = title_object.values[0]
    return title

在一个新的 Jupyter Notebook 单元中,我们调整了之前用于打印最佳规则的代码,以包括标题:

for index in range(5):
    print("Rule #{0}".format(index + 1))
    premise, conclusion = sorted_confidence[index][0]
    premise_names = ", ".join(get_movie_name(idx) for idx in premise)
    conclusion_name = get_movie_name(conclusion)
    print("Rule: If a person recommends {0} they will also recommend {1}".format(premise_names, conclusion_name))
    print(" - Confidence: {0:.3f}".format(rule_confidence[(premise, conclusion)]))
    print("")

结果更易于阅读(仍然有一些问题,但现在我们可以忽略它们):

Rule #1
Rule: If a person recommends Shawshank Redemption, The (1994), Silence of the Lambs, The (1991), Pulp Fiction (1994), Star Wars (1977), Twelve Monkeys (1995) they will also recommend Raiders of the Lost Ark (1981)
 - Confidence: 1.000

Rule #2
Rule: If a person recommends Silence of the Lambs, The (1991), Fargo (1996), Empire Strikes Back, The (1980), Fugitive, The (1993), Star Wars (1977), Pulp Fiction (1994) they will also recommend Twelve Monkeys (1995)
 - Confidence: 1.000

Rule #3
Rule: If a person recommends Silence of the Lambs, The (1991), Empire Strikes Back, The (1980), Return of the Jedi (1983), Raiders of the Lost Ark (1981), Twelve Monkeys (1995) they will also recommend Star Wars (1977)
 - Confidence: 1.000

Rule #4
Rule: If a person recommends Shawshank Redemption, The (1994), Silence of the Lambs, The (1991), Fargo (1996), Twelve Monkeys (1995), Empire Strikes Back, The (1980), Star Wars (1977) they will also recommend Raiders of the Lost Ark (1981)
 - Confidence: 1.000

Rule #5
Rule: If a person recommends Shawshank Redemption, The (1994), Toy Story (1995), Twelve Monkeys (1995), Empire Strikes Back, The (1980), Fugitive, The (1993), Star Wars (1977) they will also recommend Return of the Jedi (1983)
 - Confidence: 1.000

评估关联规则

在广义上,我们可以使用与分类相同的概念来评估关联规则。我们使用未用于训练的数据测试集,并根据它们在这个测试集中的性能来评估我们发现的规则。

要做到这一点,我们将计算测试集置信度,即每个规则在测试集中的置信度。在这种情况下,我们不会应用正式的评估指标;我们只是检查规则并寻找好的例子。

正式评估可能包括通过确定用户是否对给定电影给予好评的预测准确性来进行分类准确率。在这种情况下,如下所述,我们将非正式地查看规则以找到那些更可靠的规则:

  1. 首先,我们提取测试数据集,这是我们未在训练集中使用的所有记录。我们使用了前 200 个用户(按 ID 值)作为训练集,我们将使用其余所有用户作为测试数据集。与训练集一样,我们还将获取该数据集中每个用户的正面评论。让我们看看代码:
test_dataset = all_ratings[~all_ratings['UserID'].isin(range(200))]
test_favorable = test_dataset[test_dataset["Favorable"]]
test_favorable_by_users = dict((k, frozenset(v.values)) for k, v in 
                               test_favorable.groupby("UserID")["MovieID"])

  1. 然后,我们计算前提导致结论的正确实例数,就像我们之前做的那样。这里唯一的变化是使用测试数据而不是训练数据。让我们看看代码:
correct_counts = defaultdict(int)
incorrect_counts = defaultdict(int)
for user, reviews in test_favorable_by_users.items():
    for candidate_rule in candidate_rules:
        premise, conclusion = candidate_rule
        if premise.issubset(reviews):
            if conclusion in reviews:
                correct_counts[candidate_rule] += 1
            else:
                incorrect_counts[candidate_rule] += 1

  1. 接下来,我们计算每个规则的置信度,并按此排序。让我们看看代码:
test_confidence = {candidate_rule:
                             (correct_counts[candidate_rule] / float(correct_counts[candidate_rule] + incorrect_counts[candidate_rule]))
                             for candidate_rule in rule_confidence}
sorted_test_confidence = sorted(test_confidence.items(), key=itemgetter(1), reverse=True)

  1. 最后,我们以标题而不是电影 ID 的形式打印出最佳关联规则:
for index in range(10):
    print("Rule #{0}".format(index + 1))
    premise, conclusion = sorted_confidence[index][0]
    premise_names = ", ".join(get_movie_name(idx) for idx in premise)
    conclusion_name = get_movie_name(conclusion)
    print("Rule: If a person recommends {0} they will also recommend {1}".format(premise_names, conclusion_name))
    print(" - Train Confidence: {0:.3f}".format(rule_confidence.get((premise, conclusion), -1)))
    print(" - Test Confidence: {0:.3f}".format(test_confidence.get((premise, conclusion), -1)))
    print("")

现在,我们可以看到哪些规则在新的未见数据中最为适用:

Rule #1
Rule: If a person recommends Shawshank Redemption, The (1994), Silence of the Lambs, The (1991), Pulp Fiction (1994), Star Wars (1977), Twelve Monkeys (1995) they will also recommend Raiders of the Lost Ark (1981)
 - Train Confidence: 1.000
 - Test Confidence: 0.909

Rule #2
Rule: If a person recommends Silence of the Lambs, The (1991), Fargo (1996), Empire Strikes Back, The (1980), Fugitive, The (1993), Star Wars (1977), Pulp Fiction (1994) they will also recommend Twelve Monkeys (1995)
 - Train Confidence: 1.000
 - Test Confidence: 0.609

Rule #3
Rule: If a person recommends Silence of the Lambs, The (1991), Empire Strikes Back, The (1980), Return of the Jedi (1983), Raiders of the Lost Ark (1981), Twelve Monkeys (1995) they will also recommend Star Wars (1977)
 - Train Confidence: 1.000
 - Test Confidence: 0.946

Rule #4
Rule: If a person recommends Shawshank Redemption, The (1994), Silence of the Lambs, The (1991), Fargo (1996), Twelve Monkeys (1995), Empire Strikes Back, The (1980), Star Wars (1977) they will also recommend Raiders of the Lost Ark (1981)
 - Train Confidence: 1.000
 - Test Confidence: 0.971

Rule #5
Rule: If a person recommends Shawshank Redemption, The (1994), Toy Story (1995), Twelve Monkeys (1995), Empire Strikes Back, The (1980), Fugitive, The (1993), Star Wars (1977) they will also recommend Return of the Jedi (1983)
 - Train Confidence: 1.000
 - Test Confidence: 0.900

例如,第二个规则在训练数据中具有完美的置信度,但在测试数据中只有 60%的案例是准确的。前 10 条规则中的许多其他规则在测试数据中具有高置信度,这使得它们成为制定推荐的好规则。

你可能还会注意到,这些电影往往非常受欢迎且是优秀的电影。这为我们提供了一个基准算法,我们可以将其与之比较,即不是尝试进行个性化推荐,而是推荐最受欢迎的电影。尝试实现这个算法——Apriori 算法是否优于它,以及优于多少?另一个基准可能是简单地从同一类型中随机推荐电影。

如果你正在查看其余的规则,其中一些将具有-1 的测试置信度。置信值总是在 0 和 1 之间。这个值表示特定的规则根本未在测试数据集中找到。

摘要

在本章中,我们进行了亲和力分析,以便根据大量评论者推荐电影。我们分两个阶段进行。首先,我们使用 Apriori 算法在数据中找到频繁项集。然后,我们从这些项集中创建关联规则。

由于数据集的大小,使用 Apriori 算法是必要的。在第一章,数据挖掘入门中,我们使用了暴力方法,这种方法在计算那些用于更智能方法的规则所需的时间上呈指数增长。这是数据挖掘中的一种常见模式:对于小数据集,我们可以以暴力方式解决许多问题,但对于大数据集,则需要更智能的算法来应用这些概念。

我们在我们的数据的一个子集上进行了训练,以找到关联规则,然后在这些规则的其余数据——测试集上进行了测试。根据我们之前章节的讨论,我们可以将这个概念扩展到使用交叉验证来更好地评估规则。这将导致对每个规则质量的更稳健的评估。

为了进一步探讨本章的概念,研究哪些电影获得了很高的总体评分(即有很多推荐),但没有足够的规则来向新用户推荐它们。你将如何修改算法来推荐这些电影?

到目前为止,我们所有的数据集都是用特征来描述的。然而,并非所有数据集都是以这种方式预先定义的。在下一章中,我们将探讨 scikit-learn 的转换器(它们在第三章,使用决策树预测体育比赛赢家中介绍过)作为从数据中提取特征的方法。我们将讨论如何实现我们自己的转换器,扩展现有的转换器,以及我们可以使用它们实现的概念。

第五章:特征和 scikit-learn 转换器

我们迄今为止所使用的数据集都是以特征的形式描述的。在前一章中,我们使用了一个以事务为中心的数据集。然而,这最终只是以不同格式表示基于特征的数据的另一种方式。

还有许多其他类型的数据集,包括文本、图像、声音、电影,甚至是真实物体。大多数数据挖掘算法都依赖于具有数值或分类特征。这意味着在我们将这些类型输入数据挖掘算法之前,我们需要一种方法来表示它们。我们称这种表示为模型

在本章中,我们将讨论如何提取数值和分类特征,并在我们有这些特征时选择最佳特征。我们将讨论一些常见的特征提取模式和技巧。适当地选择你的模型对于数据挖掘练习的结果至关重要,比分类算法的选择更为重要。

本章介绍的关键概念包括:

  • 从数据集中提取特征

  • 为你的数据创建模型

  • 创建新特征

  • 选择好的特征

  • 为自定义数据集创建自己的转换器

特征提取

提取特征是数据挖掘中最关键的任务之一,它通常比数据挖掘算法的选择对最终结果的影响更大。不幸的是,没有一成不变的规则来选择能够导致高性能数据挖掘的特征。特征的选择决定了你用来表示数据的模型。

模型创建是数据挖掘科学变得更加像艺术的地方,这也是为什么执行数据挖掘的自动化方法(有几种此类方法)专注于算法选择而不是模型创建。创建好的模型依赖于直觉、领域专业知识、数据挖掘经验、试错,有时还需要一点运气。

在模型中呈现现实

基于我们在本书中迄今为止所做的工作,很容易忘记我们进行数据挖掘的原因是影响现实世界中的对象,而不仅仅是操作一个值矩阵。并非所有数据集都是以特征的形式呈现的。有时,一个数据集可能仅仅是由某个作者所写的所有书籍组成。有时,它可能是 1979 年发布的每部电影的影片。在其他时候,它可能是一个有趣的历史文物的图书馆收藏。

从这些数据集中,我们可能想要执行数据挖掘任务。对于书籍,我们可能想知道作者写了哪些不同的类别。在电影中,我们可能希望看到女性是如何被描绘的。在历史文物中,我们可能想知道它们是来自一个国家还是另一个国家。仅仅将这些原始数据集输入决策树并查看结果是不可能的。

为了让数据挖掘算法在这里帮助我们,我们需要将这些数据表示为特征。特征是创建模型的一种方式,而模型以数据挖掘算法能够理解的方式提供对现实的一种近似。因此,模型只是现实世界某个方面的简化版本。例如,象棋游戏就是历史战争的一种简化模型(以游戏形式)。

选择特征还有另一个优点:它们将现实世界的复杂性简化为更易于管理的模型。

想象一下,要向一个对物品没有任何背景知识的人准确、全面地描述一个现实世界对象需要多少信息。你需要描述其大小、重量、质地、成分、年龄、缺陷、用途、起源等等。

由于现实对象的复杂性超出了当前算法的处理能力,我们使用这些更简单的模型来代替。

这种简化也使我们在数据挖掘应用中的意图更加集中。在后面的章节中,我们将探讨聚类及其至关重要的应用。如果你输入随机特征,你将得到随机的结果。

然而,这种简化也有缺点,因为它减少了细节,或者可能移除了我们希望进行数据挖掘的某些事物的良好指标。

我们应该始终思考如何以模型的形式表示现实。而不仅仅是使用过去使用过的方法,你需要考虑数据挖掘活动的目标。你试图实现什么?在第三章《使用决策树预测体育比赛胜者》中,我们通过思考目标(预测胜者)并使用一些领域知识来提出新特征的想法来创建特征。

并非所有特征都需要是数值或分类的。已经开发出可以直接在文本、图和其他数据结构上工作的算法。不幸的是,这些算法超出了本书的范围。在本书中,以及在你的数据挖掘生涯中,我们主要使用数值或分类特征。

Adult数据集是使用特征来尝试对复杂现实进行建模的一个很好的例子。在这个数据集中,目标是估计某人每年是否赚超过$50,000。

要下载数据集,请导航到archive.ics.uci.edu/ml/datasets/Adult并点击数据文件夹链接。将adult.dataadult.names下载到你的数据文件夹中名为 Adult 的目录下。

这个数据集将一个复杂任务描述为特征。这些特征描述了个人、他们的环境、他们的背景以及他们的生活状况。

为本章打开一个新的 Jupyter Notebook,设置数据文件名,并使用 pandas 加载数据:

import os
import pandas as pd
data_folder = os.path.join(os.path.expanduser("~"), "Data", "Adult")
adult_filename = os.path.join(data_folder, "adult.data")

adult = pd.read_csv(adult_filename, header=None, names=["Age", "Work-Class", "fnlwgt", 
                     "Education", "Education-Num", "Marital-Status", "Occupation",
                     "Relationship", "Race", "Sex", "Capital-gain", "Capital-loss",
                     "Hours-per-week", "Native-Country", "Earnings-Raw"])

大部分代码与前面的章节相同。

不想输入那些标题名称?别忘了你可以从 Packt Publishing 下载代码,或者从本书作者的 GitHub 仓库下载:

github.com/dataPipelineAU/LearningDataMiningWithPython2

成年文件本身在文件末尾包含两个空白行。默认情况下,pandas 将倒数第二个换行符解释为一个空行(但有效)。为了删除它,我们删除任何包含无效数字的行(使用 inplace 只确保影响相同的 Dataframe,而不是创建一个新的 Dataframe):

adult.dropna(how='all', inplace=True)

查看数据集,我们可以从 adult.columns 中看到各种特征:

adult.columns

结果显示了存储在 pandas Index 对象内的每个特征名称:

Index(['Age', 'Work-Class', 'fnlwgt', 'Education', 
'Education-Num', 'Marital-Status', 'Occupation', 'Relationship', 
'Race', 'Sex', 'Capital-gain', 'Capital-loss', 'Hours-per-week', 
'Native-Country', 'Earnings-Raw'], dtype='object')

常见的特征模式

虽然有数百万种创建模型的方法,但不同学科中都有一些常见的模式。然而,选择合适的特征是棘手的,值得考虑一个特征可能如何与最终结果相关。正如一句著名的谚语所说,不要以貌取人——如果你对书中的信息感兴趣,考虑书的尺寸可能并不值得。

一些常用的特征专注于研究现实世界对象的物理属性,例如:

  • 物体的空间属性,如长度、宽度和高度

  • 物体的重量和/或密度

  • 物体或其组件的年龄

  • 物体的类型

  • 物体的质量

其他特征可能依赖于对象的使用或历史:

  • 物体的生产者、出版商或创作者

  • 制造年份

其他特征以对象的部分来描述数据集:

  • 给定子组件的频率,例如一本书中的单词

  • 子组件的数量和/或不同子组件的数量

  • 子组件的平均大小,例如平均句子长度

有序特征使我们能够对相似值进行排序、排序和分组。正如我们在前面的章节中看到的,特征可以是数值的或分类的。

数值特征通常被描述为有序的。例如,三个人,Alice、Bob 和 Charlie,可能有 1.5 米、1.6 米和 1.7 米的身高。我们会说 Alice 和 Bob 在身高上比 Alice 和 Charlie 更相似。

我们在上一个部分加载的 Adult 数据集包含连续的有序特征的例子。例如,每周工作小时数特征跟踪人们每周工作多少小时。某些操作适用于此类特征。包括计算平均值、标准差、最小值和最大值。pandas 中有一个函数可以提供此类类型的一些基本摘要统计信息:

adult["Hours-per-week"].describe()

结果告诉我们关于这个特征的一些信息:

count 32561.000000
mean 40.437456
std 12.347429
min 1.000000
25% 40.000000
50% 40.000000
75% 45.000000
max 99.000000
dtype: float64

这些操作中的一些对于其他特征来说没有意义。例如,计算这些人的教育状态总和是没有意义的。相比之下,计算每个在线商店顾客的订单总数是有意义的。

还有一些特征不是数值的,但仍然是序数的。成年数据集中的教育特征就是这样一个例子。例如,学士学位比完成高中教育有更高的教育地位,而完成高中教育比没有完成高中教育有更高的地位。对这些值计算平均值并不完全合理,但我们可以通过取中位数来创建一个近似值。数据集提供了一个有用的特征,Education-Num,它分配一个基本上等同于完成教育年数的数字。这使得我们可以快速计算中位数:

adult["Education-Num"].median()

结果是 10,即完成高中后的一年。如果我们没有这个,我们可以通过在教育值上创建一个排序来计算中位数。

特征也可以是分类的。例如,一个球可以是网球、板球、足球或其他任何类型的球。分类特征也被称为名义特征。对于名义特征,其值要么相同,要么不同。虽然我们可以根据大小或重量对球进行排序,但仅仅类别本身并不足以比较事物。网球不是板球,它也不是足球。我们可以争论网球在大小上可能更接近板球(比如说),但仅仅类别本身并不能区分这一点——它们要么相同,要么不同。

我们可以使用独热编码将分类特征转换为数值特征,正如我们在第三章中看到的,即使用决策树预测体育比赛胜者。对于上述球类的类别,我们可以创建三个新的二元特征:是否是网球、是否是板球和是否是足球。这个过程就是我们第三章中使用的独热编码,即使用决策树预测体育比赛胜者。对于一个网球,向量将是 [1, 0, 0]。板球的值是 [0, 1, 0],而足球的值是 [0, 0, 1]。这些是二元特征,但许多算法可以将它们用作连续特征。这样做的一个关键原因是可以轻松地进行直接的数值比较(例如计算样本之间的距离)。

成年数据集包含几个分类特征,其中工作类别就是一个例子。虽然我们可以争论某些值可能比其他值有更高的等级(例如,有工作的人可能比没有工作的人有更好的收入),但对于所有值来说这并不合理。例如,为国家政府工作的人并不比在私营部门工作的人更有可能或更不可能有更高的收入。

我们可以使用unique()函数在数据集中查看该特征的唯一值:

adult["Work-Class"].unique()

结果显示了该列的唯一值:

array([' State-gov', ' Self-emp-not-inc', ' Private', ' Federal-gov',
' Local-gov', ' ?', ' Self-emp-inc', ' Without-pay',
' Never-worked', nan], dtype=object)

在前面的数据中存在一些缺失值,但它们不会影响本例中的计算。您也可以使用adult.value_counts()函数查看每个值出现的频率。

在使用新的数据集时,另一个非常有用的步骤是可视化它。以下代码将创建一个群组图,展示教育和工作时间与最终分类(通过颜色标识)之间的关系:

%matplotlib inline
import seaborn as sns
from matplotlib import pyplot as plt
sns.swarmplot(x="Education-Num", y="Hours-per-week", hue="Earnings-Raw", data=adult[::50])

图片

在上面的代码中,我们通过使用adult[::50]数据集索引来采样数据集,以显示每 50 行,设置此为adult将导致显示所有样本,但这可能会使图表难以阅读。

同样,我们可以通过称为离散化的过程将数值特征转换为分类特征,正如我们在第一章“数据挖掘入门”中看到的。我们可以将身高超过 1.7 米的人称为高,身高低于 1.7 米的人称为矮。这给我们一个分类特征(尽管仍然是有序的)。我们在这里会丢失一些数据。例如,两个身高分别为 1.69 米和 1.71 米的人将属于两个不同的类别,并且被我们的算法认为差异很大。相比之下,一个身高 1.2 米的人将被认为与身高 1.69 米的人大致相同!这种细节的丢失是离散化的副作用,这是我们创建模型时需要处理的问题。

在成人数据集中,我们可以创建一个LongHours特征,它告诉我们一个人是否每周工作超过 40 小时。这把我们的连续特征(每周小时数)转换为一个分类特征,如果小时数超过 40 则为 True,否则为 False:

adult["LongHours"] = adult["Hours-per-week"] > 40

创建良好的特征

由于建模的简化,这是我们没有可以简单应用于任何数据集的数据挖掘方法的关键原因。一个优秀的数据挖掘从业者将需要或获得他们在应用数据挖掘领域的领域知识。他们将研究问题、可用数据,并提出一个代表他们试图实现的目标的模型。

例如,一个人的身高特征可能描述一个人的一个方面,比如他们打篮球的能力,但可能无法很好地描述他们的学术表现。如果我们试图预测一个人的成绩,我们可能不会麻烦去测量每个人的身高。

这就是数据挖掘比科学更具艺术性的地方。提取良好的特征是困难的,这也是一个重要且持续的研究课题。选择更好的分类算法可以提高数据挖掘应用的表现,但选择更好的特征通常是一个更好的选择。

在所有数据挖掘应用中,你应该在开始设计寻找目标的方法之前,首先概述你正在寻找的内容。这将决定你希望达到的特征类型,你可以使用的算法类型,以及最终结果中的期望。

特征选择

在初步建模之后,我们通常会有一大批特征可供选择,但我们希望只选择一小部分。有许多可能的原因:

  • 降低复杂性:当特征数量增加时,许多数据挖掘算法需要显著更多的时间和资源。减少特征数量是使算法运行更快或使用更少资源的好方法。

  • 降低噪声:添加额外的特征并不总是导致更好的性能。额外的特征可能会使算法混淆,在训练数据中找到没有实际意义的关联和模式。这在较小的和较大的数据集中都很常见。只选择合适的特征是减少没有实际意义的随机关联的好方法。

  • 创建可读的模型:虽然许多数据挖掘算法乐于为具有数千个特征的模型计算答案,但结果可能对人类来说难以解释。在这些情况下,使用较少的特征并创建一个人类可以理解的模式可能是有价值的。

一些分类算法可以处理前面描述的问题。确保数据正确,并确保特征能够有效地描述你正在建模的数据集,这仍然可以帮助算法。

我们可以进行一些基本的测试,例如确保特征至少是不同的。如果一个特征的所有值都相同,它就不能为我们提供额外的信息来执行我们的数据挖掘。

例如,scikit-learn中的VarianceThreshold转换器将删除任何在值中至少没有最小变异水平的特征。为了展示这是如何工作的,我们首先使用 NumPy 创建一个简单的矩阵:

import numpy as np
X = np.arange(30).reshape((10, 3))

结果是 0 到 29 的数字,分为三列和 10 行。这代表了一个包含 10 个样本和三个特征的合成数据集:

array([[ 0, 1, 2],
[ 3, 4, 5],
[ 6, 7, 8],
[ 9, 10, 11],
[12, 13, 14],
[15, 16, 17],
[18, 19, 20],
[21, 22, 23],
[24, 25, 26],
[27, 28, 29]])

然后,我们将整个第二列/特征设置为值 1:

X[:,1] = 1

结果在第一行和第三行有很多变异,但在第二行没有变异:

array([[ 0, 1, 2],
[ 3, 1, 5],
[ 6, 1, 8],
[ 9, 1, 11],
[12, 1, 14],
[15, 1, 17],
[18, 1, 20],
[21, 1, 23],
[24, 1, 26],
[27, 1, 29]])

我们现在可以创建一个VarianceThreshold转换器并将其应用于我们的数据集:

from sklearn.feature_selection import VarianceThreshold
vt = VarianceThreshold()
Xt = vt.fit_transform(X)

现在,结果Xt没有第二列:

array([[ 0, 2],
[ 3, 5],
[ 6, 8],
[ 9, 11],
[12, 14],
[15, 17],
[18, 20],
[21, 23],
[24, 26],
[27, 29]])

我们可以通过打印vt.variances_属性来观察每列的变异:

print(vt.variances_)

结果显示,虽然第一列和第三列至少包含一些信息,但第二列没有变异:

array([ 74.25, 0\. , 74.25])

当第一次看到数据时,运行这样一个简单明了的测试总是好的。没有变异的特征不会为数据挖掘应用增加任何价值;然而,它们可能会减慢算法的性能并降低其有效性。

选择最佳单个特征

如果我们有多个特征,找到最佳子集的问题是一个困难任务。它与解决数据挖掘问题本身相关,需要多次解决。正如我们在第四章中看到的,使用亲和分析推荐电影,随着特征数量的增加,基于子集的任务呈指数增长。这种所需时间的指数增长也适用于找到最佳特征子集。

解决这个问题的基本方法不是寻找能够良好协同工作的子集,而是仅仅找到最佳的单个特征。这种单变量特征选择根据特征单独表现的好坏给出一个分数。这通常用于分类任务,我们通常测量变量和目标类别之间的某种关联。

scikit-learn 包提供了一系列用于执行单变量特征选择的转换器。它们包括 SelectKBest,它返回性能最好的 k 个特征,以及 SelectPercentile,它返回前 R% 的特征。在这两种情况下,都有多种计算特征质量的方法。

有许多不同的方法来计算单个特征与类别值的相关性有多有效。常用的方法之一是卡方 (χ2) 测试。其他方法包括互信息和熵。

我们可以通过使用 Adult 数据集来观察单变量测试的实际操作。首先,我们从 pandas DataFrame 中提取数据集和类值。我们得到特征的选择:

X = adult[["Age", "Education-Num", "Capital-gain", "Capital-loss", "Hours-per-week"]].values

我们还将通过测试 Earnings-Raw 值是否超过 $50,000 来创建一个目标类数组。如果是,则类为 True。否则,为 False。让我们看看代码:

y = (adult["Earnings-Raw"] == ' >50K').values

接下来,我们使用 chi2 函数和 SelectKBest 转换器创建我们的转换器:

from sklearn.feature_selection import SelectKBest
from sklearn.feature_selection import chi2
transformer = SelectKBest(score_func=chi2, k=3)

运行 fit_transform 将调用 fit 然后使用相同的数据集进行转换。

结果将创建一个新的数据集,只选择最好的三个特征。

让我们看看代码:

Xt_chi2 = transformer.fit_transform(X, y)

结果矩阵现在只包含三个特征。我们还可以获取分数

对于每一列,这使我们能够找出哪些特征被使用了。让我们看看

代码如下:

print(transformer.scores_)

打印的结果给出了这些分数:

[ 8.60061182e+03 2.40142178e+03 8.21924671e+07 1.37214589e+066.47640900e+03]

最大的值是第一列、第三列和第四列,分别对应年龄、资本收益和资本损失特征。基于单变量特征选择,这些是最好的选择特征。

如果您想了解更多关于 Adult 数据集中的特征的信息,请查看随数据集一起提供的 adult.names 文件以及它引用的学术论文。

我们还可以实现其他相关性,例如皮尔逊相关系数。这在 SciPy 中实现,SciPy 是一个用于科学计算的库(scikit-learn 使用它作为基础)。

如果 scikit-learn 在您的计算机上运行,SciPy 也在运行。为了使这个示例工作,您不需要安装任何其他东西。

首先,我们从 SciPy 中导入 pearsonr 函数:

from scipy.stats import pearsonr

上述函数几乎符合在 scikit-learn 的单变量转换器中使用的接口。该函数需要接受两个数组(在我们的例子中是 x 和 y)作为参数,并返回两个数组,每个特征的得分和相应的 p 值。我们之前使用的 chi2 函数只使用了所需的接口,这使得我们可以直接将其传递给 SelectKBest。

SciPy 中的 pearsonr 函数接受两个数组;然而,它接受的 X 数组只有一个维度。我们将编写一个包装函数,使我们能够使用这个函数处理像我们这样的多元数组。让我们看看代码:

def multivariate_pearsonr(X, y):
    scores, pvalues = [], []
    for column in range(X.shape[1]):
        # Compute the Pearson correlation for this column only
        cur_score, cur_p = pearsonr(X[:,column], y)
        # Record both the score and p-value.
        scores.append(abs(cur_score))
        pvalues.append(cur_p)
    return (np.array(scores), np.array(pvalues))

皮尔逊值可能在-1 和 1 之间。1 的值意味着两个变量之间有完美的相关性,而-1 的值意味着完美的负相关性,即一个变量的高值对应另一个变量的低值,反之亦然。这样的特征非常有用。因此,我们在得分数组中存储了绝对值,而不是原始的有符号值。

现在,我们可以像以前一样使用 transformer 类,通过皮尔逊相关系数来对特征进行排序:

transformer = SelectKBest(score_func=multivariate_pearsonr, k=3)
Xt_pearson = transformer.fit_transform(X, y)
print(transformer.scores_)

这会返回一组不同的特征!这样选择的特征是第一列、第二列和第五列:年龄、教育和每周工作小时数。这表明,并没有一个明确的答案来决定哪些是最好的特征——它取决于所使用的指标和所进行的过程。

我们可以通过运行它们通过分类器来查看哪个特征集更好。请注意,结果仅表明对于特定的分类器或特征组合,哪个子集更好——在数据挖掘中,很少有一种方法在所有情况下都严格优于另一种方法!让我们看看代码:

from sklearn.tree import DecisionTreeClassifier
from sklearn.cross_validation import cross_val_score
clf = DecisionTreeClassifier(random_state=14)
scores_chi2 = cross_val_score(clf, Xt_chi2, y, scoring='accuracy')
scores_pearson = cross_val_score(clf, Xt_pearson, y, scoring='accuracy')

print("Chi2 score: {:.3f}".format(scores_chi2.mean()))
print("Pearson score: {:.3f}".format(scores_pearson.mean()))

这里的 chi2 平均值为 0.83,而皮尔逊分数较低,为 0.77。对于这个组合,chi2 返回更好的结果!

记住这个特定数据挖掘活动的目标是值得的:预测财富。通过结合良好的特征和特征选择,我们只需使用一个人的三个特征就能达到 83%的准确率!

特征创建

有时候,仅仅从我们所拥有的特征中选择特征是不够的。我们可以从已有的特征以不同的方式创建特征。我们之前看到的独热编码方法就是这样一个例子。而不是有选项 A、B 和 C 的类别特征,我们会创建三个新的特征:它是 A 吗?它是 B 吗?它是 C 吗?

创建新特征可能看起来是不必要的,并且没有明显的益处——毕竟,信息已经在数据集中,我们只需要使用它。然而,一些算法在特征高度相关或存在冗余特征时可能会遇到困难。它们也可能在存在冗余特征时遇到困难。因此,有各种方法可以从我们已有的特征中创建新特征。

我们将加载一个新的数据集,因此现在是开始一个新的 Jupyter Notebook 的好时机。从 archive.ics.uci.edu/ml/datasets/Internet+Advertisements 下载 Advertisements 数据集并将其保存到您的数据文件夹中。

接下来,我们需要使用 pandas 加载数据集。首先,我们设置数据的文件名,就像往常一样:

import os
import numpy as np
import pandas as pd
data_folder = os.path.join(os.path.expanduser("~"), "Data")
data_filename = os.path.join(data_folder, "Ads", "ad.data")

这个数据集有几个问题,阻止我们轻松地加载数据。您可以通过尝试使用 pd.read_csv 加载数据集来查看这些问题。首先,前几个特征是数值型的,但 pandas 会将它们加载为字符串。为了解决这个问题,我们需要编写一个转换函数,该函数将尝试将字符串转换为数字。否则,我们将得到一个 Not a Number (NaN) - 一个无效值,它是一个特殊值,表示该值无法解释为数字。在其他编程语言中,它类似于 none 或 null。

这个数据集的另一个问题是某些值缺失。这些值在数据集中用字符串 ? 表示。幸运的是,问号不能转换为浮点数,因此我们可以使用相同的概念将它们转换为 NaN。在后续章节中,我们将探讨处理此类缺失值的其他方法。

我们将创建一个函数来完成这个转换。它尝试将数字转换为浮点数,如果失败,则返回 NumPy 的特殊 NaN 值,该值可以存储在浮点数的位置:

def convert_number(x):
    try:
        return float(x)
    except ValueError:
        return np.nan

现在,我们创建一个字典用于转换。我们希望将所有特征转换为浮点数:

converters = {}
for i in range(1558):
    converters[i] = convert_number

此外,我们希望将最后一列,即类别列(列索引 #1558),设置为二进制特征。在 Adult 数据集中,我们为此创建了一个新特征。在加载数据集时,我们将转换该特征:

converters[1558] = lambda x: 1 if x.strip() == "ad." else 0

现在,我们可以使用 read_csv 加载数据集。我们使用 converters 参数将自定义转换传递给 pandas:

ads = pd.read_csv(data_filename, header=None, converters=converters)

结果数据集相当大,有 1,559 个特征和超过 3,000 行。以下是一些特征值,前五个,通过在新的单元格中插入 ads.head() 打印出来:

这个数据集描述了网站上的图像,目标是确定给定的图像是否是广告。

这些数据集中的特征值没有很好地通过其标题来描述。伴随 ad.data 文件的两个文件提供了更多信息:ad.DOCUMENTATIONad.names。前三个特征是图像的高度、宽度和尺寸比。最后一个特征如果是广告则为 1,如果不是则为 0。

其他特征表示 URL、alt 文本或图像标题中是否存在某些单词。这些单词,如赞助商一词,用于确定图像是否可能是广告。许多特征在很大程度上是其他特征的组合,因此这个数据集有很多冗余信息。

在我们的数据集加载到pandas后,我们现在将提取用于分类算法的xy数据。x矩阵将是我们的 DataFrame 中的所有列,除了最后一列。相比之下,y数组将只有最后一列,特征1558.。在那之前,我们通过删除任何包含 NaN 值的行来简化我们的数据集(只是为了本章的目的)。让我们看看代码:

ads.dropna(inplace=True)
X = ads.drop(1558, axis=1).values
y = ads[1558]

由于此命令,删除了 1000 多行,这对于我们的练习来说是可以接受的。对于实际应用,如果你能帮助避免数据丢失,你不想丢弃数据——相反,你可以使用插值或值替换来填充 NaN 值。例如,你可以用该列的平均值替换任何缺失值。

主成分分析

在某些数据集中,特征之间高度相关。例如,在单档的卡丁车中,速度和燃油消耗会高度相关。虽然对于某些应用来说,找到这些相关性可能是有用的,但数据挖掘算法通常不需要冗余信息。

广告数据集具有高度相关的特征,因为许多关键词在 alt 文本和标题中重复。

主成分分析(PCA)算法旨在找到描述数据集所需信息更少的特征组合。它旨在发现主成分,即不相互关联且解释数据信息——特别是方差——的特征。这意味着我们通常可以在更少的特征中捕捉到数据集的大部分信息。

我们应用 PCA 就像应用任何其他转换器一样。它有一个关键参数,即要找到的组件数量。默认情况下,它将产生与原始数据集中特征数量一样多的特征。然而,这些主成分是按顺序排列的——第一个特征解释了数据集中最大的方差,第二个稍微少一些,以此类推。因此,只需找到前几个特征就足以解释数据集的大部分内容。让我们看看代码:

from sklearn.decomposition import PCA
pca = PCA(n_components=5)
Xd = pca.fit_transform(X)

结果矩阵 Xd 只有五个特征。然而,让我们看看每个特征解释的方差量:

np.set_printoptions(precision=3, suppress=True)
pca.explained_variance_ratio_

结果,array([ 0.854, 0.145, 0.001, 0\. , 0\. ]) 显示我们第一个特征解释了数据集中 85.4%的方差,第二个解释了 14.5%,以此类推。到第四个特征时,特征中包含的方差不到百分之一的十分之一。其他 1,553 个特征解释的方差更少(这是一个有序数组)。

使用 PCA 转换数据的缺点是这些特征通常是其他特征的复杂组合。例如,前面代码中的第一个特征以[-0.092, -0.995, -0.024],开始,即用-0.092 乘以原始数据集中的第一个特征,用-0.995 乘以第二个,用-0.024 乘以第三个。这种特征有 1,558 个这样的值,每个原始数据集都有一个(尽管许多是零)。这种特征对人类来说是不可区分的,并且在没有大量使用经验的情况下很难从中获取相关信息。

使用 PCA 可以导致模型不仅近似原始数据集,还可以提高分类任务中的性能:

clf = DecisionTreeClassifier(random_state=14)
scores_reduced = cross_val_score(clf, Xd, y, scoring='accuracy')

得到的分数是 0.9356,这比我们原始模型的分数略高。PCA 并不总是能带来这样的好处,但这种情况比不常见。

我们在这里使用 PCA 来减少数据集中的特征数量。一般来说,你不应该在数据挖掘实验中用它来减少过拟合。原因在于 PCA 没有考虑类别。一个更好的解决方案是使用正则化。有关介绍和代码,请参阅blog.datadive.net/selecting-good-features-part-ii-linear-models-and-regularization/

另一个优点是 PCA 允许你绘制那些你否则难以可视化的数据集。例如,我们可以绘制 PCA 返回的前两个特征。

首先,我们告诉我们的 Notebook 显示内联图表:

%matplotlib inline
from matplotlib import pyplot as plt

接下来,我们获取我们数据集中的所有不同类别(只有两个:是广告还是不是广告):

classes = set(y)

我们还为这些类别中的每一个分配了颜色:

colors = ['red', 'green']

我们使用 zip 同时遍历两个列表,然后提取该类别的所有样本,并用适合该类别的颜色绘制它们:

for cur_class, color in zip(classes, colors):
mask = (y == cur_class)
    plt.scatter(Xd[mask,0], Xd[mask,1], marker='o', color=color, label=int(cur_class))

最后,在循环外部,我们创建一个图例并显示图表,显示每个类别的样本出现在哪里:

plt.legend()
plt.show()

创建你自己的变换器

随着数据集的复杂性和类型的改变,你可能会发现找不到一个现成的特征提取变换器来满足你的需求。我们将在第七章中看到一个例子,使用图挖掘遵循推荐,在那里我们从图中创建新的特征。

变换器类似于一个转换函数。它接收一种形式的数据作为输入,并返回另一种形式的数据作为输出。变换器可以使用某些训练数据集进行训练,并且这些训练好的参数可以用来转换测试数据。

变换器 API 非常简单。它接收特定格式的数据作为输入,并返回另一种格式(可以是与输入相同的格式或不同的格式)的数据作为输出。对程序员的要求不多。

变换器 API

变换器有两个关键功能:

  • fit(): 这个函数接受一个训练数据集作为输入并设置内部参数。

  • transform(): 这个函数执行实际的转换。它可以接受训练数据集,或者格式相同的新的数据集。

fit()transform() 函数都应该接受相同的数据类型作为输入,但 transform() 可以返回不同类型的数据,而 fit() 总是返回 self。

我们将创建一个简单的转换器来展示 API 的实际应用。这个转换器将接受一个 NumPy 数组作为输入,并根据平均值对其进行离散化。任何高于平均值的(训练数据的平均值)将被赋予值 1,任何低于或等于平均值的将被赋予值 0。

我们使用 pandas 对 Adult 数据集进行了类似的转换:我们取每周工作小时数特征,如果每周工作小时数超过 40 小时,就创建一个 LongHours 特征。这个转换器有两个不同之处。首先,代码将符合 scikit-learn API,允许我们在管道中使用它。其次,代码将学习平均值,而不是将其作为固定值(如 LongHours 示例中的 40)。

实现转换器

首先,打开我们用于 Adult 数据集的 Jupyter Notebook。然后,点击 Cell 菜单项并选择 Run All。这将重新运行所有单元格,确保笔记本是最新的。

首先,我们导入 TransformerMixin,这为我们设置了 API。虽然 Python 没有严格的接口(与 Java 等语言相反),但使用这样的 mixin 允许 scikit-learn 确定该类实际上是一个转换器。我们还需要导入一个检查输入是否为有效类型的函数。我们很快就会使用它。

让我们看看代码:

from sklearn.base import TransformerMixin
from sklearn.utils import as_float_array

让我们看一下我们的类的整体结构,然后我们将回顾一些细节:

class MeanDiscrete(TransformerMixin):
    def fit(self, X, y=None):
        X = as_float_array(X)
        self.mean = X.mean(axis=0)
        return self

    def transform(self, X, y=None):
        X = as_float_array(X)
        assert X.shape[1] == self.mean.shape[0]
        return X > self.mean

我们将在 fit 方法中通过计算X.mean(axis=0)来学习每个特征的均值,然后将其存储为对象属性。之后,fit 函数返回 self,符合 API(scikit-learn 使用此功能允许链式调用函数)。

在拟合后,transform 函数接受一个具有相同数量特征的矩阵(通过assert语句确认),并简单地返回给定特征的哪些值高于平均值。

现在我们已经构建了类,我们可以创建这个类的实例,并使用它来转换我们的 X 数组:

mean_discrete = MeanDiscrete()
X_mean = mean_discrete.fit_transform(X)

尝试将这个转换器实现到一个工作流程中,既使用 Pipeline 也使用不使用 Pipeline。你会发现,通过符合转换器 API,它非常简单地在内置的 scikit-learn 转换器对象中替代使用。

单元测试

在创建自己的函数和类时,始终进行单元测试是一个好主意。单元测试旨在测试代码的单个单元。在这种情况下,我们想要测试我们的转换器是否按预期工作。

好的测试应该是可以独立验证的。确认测试合法性的一个好方法是使用另一种计算机语言或方法来执行计算。在这种情况下,我使用了 Excel 来创建一个数据集,然后计算每个单元格的平均值。然后这些值被转移到单元测试中。

单元测试通常也应该小巧且运行速度快。因此,所使用的数据应该尽可能小。我用于创建测试的数据集存储在之前的 Xt 变量中,我们将在测试中重新创建它。这两个特征的均值分别是 13.5 和 15.5。

为了创建我们的单元测试,我们从 NumPy 的测试中导入assert_array_equal函数,该函数检查两个数组是否相等:

from numpy.testing import assert_array_equal

接下来,我们创建我们的函数。重要的是测试的名称必须以 test_ 开头,

因为这个命名约定用于自动查找和运行测试的工具。我们还设置了我们的测试数据:

def test_meandiscrete():
    X_test = np.array([[ 0, 2],
                       [ 3, 5],
                       [ 6, 8],
                       [ 9, 11],
                       [12, 14],
                       [15, 17],
                       [18, 20],
                       [21, 23],
                       [24, 26],
                       [27, 29]])
    # Create an instance of our Transformer
    mean_discrete = MeanDiscrete()
    mean_discrete.fit(X_test)
    # Check that the computed mean is correct
    assert_array_equal(mean_discrete.mean, np.array([13.5, 15.5]))
    # Also test that transform works properly
    X_transformed = mean_discrete.transform(X_test)
    X_expected = np.array([[ 0, 0],
                           [ 0, 0], 
                           [ 0, 0],
                           [ 0, 0],
                           [ 0, 0],
                           [ 1, 1],
                           [ 1, 1],
                           [ 1, 1],
                           [ 1, 1],
                           [ 1, 1]])
    assert_array_equal(X_transformed, X_expected)

我们可以通过直接运行函数本身来执行测试:

test_meandiscrete()

如果没有错误,那么测试就顺利运行了!你可以通过故意更改一些测试以使值不正确,并确认测试失败来验证这一点。记住要改回来,以便测试通过!

如果我们有多个测试,使用像 py.test 或 nose 这样的测试框架来运行我们的测试将是有价值的。使用这样的框架超出了本书的范围,但它们可以管理运行测试、记录失败并向程序员提供反馈,以帮助改进代码。

将所有这些放在一起

现在我们已经测试了一个转换器,是时候将其投入使用了。利用我们迄今为止所学的内容,我们创建了一个 Pipeline,将第一步设置为 MeanDiscrete 转换器,第二步设置为决策树分类器。然后我们运行交叉验证并打印出结果。让我们看看代码:

from sklearn.pipeline import Pipeline
pipeline = Pipeline([('mean_discrete', MeanDiscrete()), ('classifier', DecisionTreeClassifier(random_state=14))])
scores_mean_discrete = cross_val_score(pipeline, X, y, scoring='accuracy')
print("Mean Discrete performance: {0:.3f}".format(scores_mean_discrete.mean()))

结果是 0.917,虽然没有之前好,但对于一个简单的二进制特征模型来说已经非常不错了。

摘要

在本章中,我们探讨了特征和转换器以及它们如何在数据挖掘流程中使用。我们讨论了什么是一个好的特征以及如何从标准集中算法性地选择好的特征。然而,创建好的特征更多的是艺术而非科学,通常需要领域知识和经验。

我们然后使用一个允许我们在 scikit-learn 的辅助函数中使用它的接口创建了自己的转换器。我们将在后面的章节中创建更多的转换器,以便我们可以使用现有函数进行有效的测试。

为了将本章学到的知识进一步深化,我建议您注册到在线数据挖掘竞赛网站 Kaggle.com 并尝试一些竞赛。他们推荐的起点是泰坦尼克号数据集,这可以让您练习本章中特征创建的方面。许多特征都不是数值型的,需要您在应用数据挖掘算法之前将它们转换为数值特征。

在下一章中,我们将在文本文档语料库上使用特征提取。文本有很多转换器和特征类型,每种都有其优缺点。

第六章:使用朴素贝叶斯进行社交媒体洞察

基于文本的文档包含大量信息。例如,书籍、法律文件、社交媒体和电子邮件。从基于文本的文档中提取信息对现代人工智能系统至关重要,例如在搜索引擎、法律人工智能和自动化新闻服务中。

从文本中提取有用特征是一个难题。文本本质上不是数值的,因此必须使用模型来创建可以与数据挖掘算法一起使用的特征。好消息是,有一些简单的模型在这方面做得很好,包括我们将在本章中使用的词袋模型。

在本章中,我们探讨从文本中提取特征以用于数据挖掘应用。本章中我们解决的具体问题是社交媒体中的术语消歧——根据上下文确定一个词的含义。

在本章中,我们将涵盖以下主题:

  • 从社交网络 API 下载数据

  • 用于文本数据的 Transformer 和模型

  • 朴素贝叶斯分类器

  • 使用 JSON 保存和加载数据集

  • 用于特征创建的 NLTK 库

  • F 度量用于评估

消歧

文本数据通常被称为非结构化格式。文本中有很多信息,但它只是在那里;没有标题,没有要求的格式(除了正常的语法规则),松散的语法,以及其他问题阻碍了从文本中轻松提取信息。数据也高度连接,有很多提及和交叉引用——只是没有一种格式使我们能够轻松提取它!即使是看似简单的问题,例如确定一个词是否是名词,也有很多奇怪的边缘情况,使得可靠地完成这项任务变得困难。

我们可以将存储在书中的信息与存储在大数据库中的信息进行比较,以了解差异。在书中,有角色、主题、地点和大量信息。然而,一本书需要被阅读和解读,包括文化背景,才能获得这些信息。相比之下,数据库位于您的服务器上,具有列名和数据类型。所有信息都在那里,提取特定信息所需的解释水平相当低。

关于数据的信息,如其类型或其含义,称为元数据。文本缺乏元数据。一本书也包含一些元数据,以目录和索引的形式存在,但这些部分包含的信息量显著低于数据库。

在处理文本时,术语消歧是一个问题。当一个人使用“银行”这个词时,这是金融信息还是环境信息(如河岸)?这种类型的消歧在许多情况下对人类来说相当容易(尽管仍然存在一些问题),但对于计算机来说则要困难得多。

在本章中,我们将探讨在 Twitter 流中消除 Python 术语歧义的使用。当人们谈论 Python 时,他们可能谈论以下内容:

  • 编程语言 Python

  • 蒙提·派森,这个经典的喜剧团体

  • 蛇类 Python

  • 鞋类 Python

叫做 Python 的东西可能有很多。我们实验的目标是获取提到 Python 的推文,并仅根据推文的内容确定它是否在谈论编程语言。

Twitter 上的消息称为tweet,限制为 140 个字符。推文包括大量元数据,如发布的时间和日期、发布者等。然而,在推文主题方面,这方面的内容并不多。

在本章中,我们将执行一个包含以下步骤的数据挖掘实验:

  1. 从 Twitter 下载一组推文。

  2. 手动分类它们以创建数据集。

  3. 保存数据集,以便我们可以复制我们的研究。

  4. 使用朴素贝叶斯分类器创建一个分类器以执行术语消歧。

从社交网络下载数据

我们首先将从 Twitter 下载一组数据,并使用它来区分垃圾邮件和有用内容。Twitter 提供了一个强大的 API,用于从其服务器收集信息,并且这个 API 对于小规模使用是免费的。然而,如果你开始在商业环境中使用 Twitter 的数据,你需要注意一些条件。

首先,你需要注册一个 Twitter 账号(这是免费的)。如果你还没有账号,请访问twitter.com并注册一个账号。

接下来,你需要确保你每分钟只发出一定数量的请求。这个限制目前是每 15 分钟 15 个请求(这取决于具体的 API)。确保你不违反这个限制可能会很棘手,因此强烈建议你使用库与 Twitter 的 API 进行通信。

如果你正在使用自己的代码(即使用自己的代码进行网络调用)来连接基于 Web 的 API,确保你阅读了关于速率限制的文档,并理解了限制条件。在 Python 中,你可以使用time库在调用之间执行暂停,以确保你不违反限制。

然后,你需要一个密钥来访问 Twitter 的数据。访问twitter.com并登录你的账号。登录后,转到apps.twitter.com/并点击创建新应用。为你的应用创建一个名称和描述,以及一个网站地址。如果你没有网站可以使用,请插入一个占位符。对于这个应用,留空回调 URL 字段——我们不需要它。同意使用条款(如果你同意)并点击创建你的 Twitter 应用。

保持生成的网站打开——你需要这个页面上提供的访问密钥。接下来,我们需要一个可以与 Twitter 通信的库。有许多选择;我喜欢的一个简单地叫做twitter,是官方的 Twitter Python 库。

如果你使用 pip 安装你的包,你可以使用 pip3 install twitter (在命令行中)来安装twitter。在撰写本文时,Anaconda 不包括 twitter,因此你不能使用conda来安装它。如果你使用其他系统或想从源构建,请检查github.com/sixohsix/twitter上的文档。

创建一个新的 Jupyter Notebook 来下载数据。在本章中,我们将为不同的目的创建几个笔记本,因此创建一个文件夹来跟踪它们可能是个好主意。这个第一个笔记本ch6_get_twitter专门用于下载新的 Twitter 数据。

首先,我们导入 twitter 库并设置我们的授权令牌。消费者密钥和消费者密钥将在你的 Twitter 应用页面的“密钥和访问令牌”选项卡上可用。要获取访问令牌,你需要点击同一页面上的“创建我的访问令牌”按钮。将密钥输入以下代码中的适当位置:

import twitter
consumer_key = "<Your Consumer Key Here>"
consumer_secret = "<Your Consumer Secret Here>"
access_token = "<Your Access Token Here>"
access_token_secret = "<Your Access Token Secret Here>"
authorization = twitter.OAuth(access_token, access_token_secret, consumer_key, consumer_secret)

我们将从 Twitter 的搜索功能获取我们的推文。我们将创建一个读取器,使用我们的授权连接到 twitter,然后使用该读取器执行搜索。在笔记本中,我们设置存储推文的文件名:

import os
output_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "python_tweets.json")

接下来,创建一个可以读取 Twitter 的对象。我们使用之前设置的授权对象创建此对象:

t = twitter.Twitter(auth=authorization)

我们然后打开我们的输出文件进行写入。我们以追加模式打开它——这允许我们重新运行脚本以获取更多推文。然后我们使用我们的 Twitter 连接执行对单词 Python 的搜索。我们只想获取我们数据集的状态。此代码获取推文,使用 json 库的 dumps 函数创建字符串表示,然后将它写入文件。然后在推文下方创建一个空白行,这样我们就可以轻松地区分文件中推文的开始和结束:

import json
with open(output_filename, 'a') as output_file:
    search_results = t.search.tweets(q="python", count=100)['statuses']
    for tweet in search_results:
        if 'text' in tweet:
            output_file.write(json.dumps(tweet))
            output_file.write("nn")

在前面的循环中,我们还进行了一个检查,看看推文中是否有文本。Twitter 返回的所有对象并不都是实际的推文(例如,一些回复可能是删除推文的操作)。关键的区别在于是否包含文本作为键,这是我们进行测试的。运行几分钟将导致 100 条推文被添加到输出文件中。

你可以反复运行此脚本以向你的数据集添加更多推文,但请注意,如果你运行得太快(即在 Twitter 返回新推文之前),你可能会在输出文件中得到一些重复的推文。对于我们的初步实验,100 条推文就足够了,但你可能需要回来重新运行此代码,以将数量增加到大约 1000。

加载和分类数据集

在我们收集了一组推文(我们的数据集)之后,我们需要标签来进行分类。我们将通过在 Jupyter Notebook 中设置一个表单来标记数据集,以便我们能够输入标签。我们通过加载上一节中收集的推文,遍历它们,并(手动)提供是否涉及编程语言 Python 的分类来实现这一点。

我们存储的数据集几乎,但并非完全,是JSON格式。JSON 是一种对内容结构要求不多的数据格式,只是对语法要求较多。JSON 背后的理念是数据以可以直接在 JavaScript 中读取的格式存在(因此得名,JavaScript 对象表示法)。JSON 定义了基本对象,如数字、字符串、列表和字典,使其成为存储包含非数值数据的数据集的好格式。如果你的数据集完全由数值组成,你可以使用 NumPy 这样的基于矩阵的格式来节省空间和时间。

我们的数据集与真实 JSON 之间的一个关键区别是我们包含了推文之间的换行符。这样做的原因是允许我们轻松地追加新的推文(实际的 JSON 格式并不容易做到这一点)。我们的格式是一个推文的 JSON 表示,后跟一个换行符,然后是下一个推文,依此类推。

为了解析它,我们可以使用 json 库,但我们必须首先通过换行符分割文件以获取实际的推文对象。设置一个新的 Jupyter Notebook,我称之为 ch6_label_twitter。在其中,我们首先通过遍历文件,在循环中存储推文来加载数据。下面的代码执行了一个基本的检查,以确保推文中确实有文本。如果有,我们使用 json 库来加载推文并将其添加到列表中:

import json
import os

# Input filename
input_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "python_tweets.json")
# Output filename
labels_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "python_classes.json")

tweets = []
with open(input_filename) as inf:
    for line in inf:
        if len(line.strip()) == 0:
            continue
        tweets.append(json.loads(line))

我们现在对手动分类一个项目是否与我们相关感兴趣(在这种情况下,相关意味着指的是编程语言 Python)。我们将使用 Jupyter Notebook 嵌入 HTML 和 JavaScript 与 Python 之间通信的能力来创建一个推文查看器,以便我们能够轻松快速地将推文分类为垃圾邮件或非垃圾邮件。代码将向用户(你)展示一条新的推文并要求标记:它是相关的还是不相关的?然后它会存储输入并展示下一条需要标记的推文。

首先,我们创建一个用于存储标签的列表。这些标签将存储给定推文是否涉及编程语言 Python,这将允许我们的分类器学习如何区分含义。

我们还检查是否有任何已标记的标签并加载它们。这有助于你在标记过程中中途关闭笔记本。此代码将从你停止的地方加载标签。对于此类任务,通常考虑如何在中间保存是很重要的。没有什么比因为你的电脑在你保存标签之前崩溃而丢失一个小时的工作更痛苦了!执行此加载的代码如下:

labels = []
if os.path.exists(labels_filename):
    with open(labels_filename) as inf:
        labels = json.load(inf)

第一次运行此程序时,不会有任何操作。在手动分类一些示例后,您可以保存进度并关闭 Notebook。之后,您可以重新打开 Notebook 并返回到之前的位置。

如果你在分类时犯了一个或两个错误,不必过于担心。如果你犯了很多错误并想重新开始,只需删除 python_classes.json 文件,上述代码将使用一个空的分类集合继续。如果您需要删除所有数据并使用新的推文重新开始,请确保删除(或移动)python_tweets.json 和 python_classes.json 两个文件。否则,这个 Notebook 会变得混乱,将旧数据集的分类结果应用于新推文。

接下来,我们创建一个简单的函数,该函数将返回需要标记的下一个推文。我们可以通过找到第一个尚未标记的推文来确定下一个推文。代码非常直接。我们通过len(labels)确定已标记的推文数量,并从 tweet_sample 列表中获取下一个推文:

def get_next_tweet():
    return tweets[len(labels)]['text']

我们实验的下一步是收集用户(您!)关于哪些推文是关于 Python(编程语言)的,哪些不是的信息。

到目前为止,还没有一种好的、直接的方法在 Jupyter Notebooks 中使用纯 Python 处理如此大量的文本文档来获取交互式反馈。因此,我们将使用一些 JavaScript 和 HTML 来从用户那里获取输入。有许多方法可以做到这一点,下面只是一个例子。

为了获取反馈,我们需要一个 JavaScript 组件来加载下一个推文并显示它。我们还需要一个 HTML 组件来创建显示该推文的 HTML 元素。这里我不会详细介绍代码的细节,只是给出这个一般的工作流程:

  1. 使用load_next_tweet获取需要分类的下一个推文

  2. 使用handle_output将推文展示给用户

  3. 等待用户按下 0 或 1,使用$("input#capture").keypress

  4. 使用set_label将结果存储在 classes 列表中

这会一直进行,直到我们到达列表的末尾(此时会引发 IndexError,表示没有更多的推文可以分类)。以下是代码(记住,您可以从 Packt 或官方 GitHub 仓库获取代码):

%%html
<div name="tweetbox">
 Instructions: Click in text box. Enter a 1 if the tweet is relevant, enter 0 otherwise.<br>
 Tweet: <div id="tweet_text" value="text"></div><br>
 <input type=text id="capture"></input><br>
</div>

<script>
function set_label(label){
 var kernel = IPython.notebook.kernel;
 kernel.execute("labels.append(" + label + ")");
 load_next_tweet();
}

function load_next_tweet(){
 var code_input = "get_next_tweet()";
 var kernel = IPython.notebook.kernel;
 var callbacks = { 'iopub' : {'output' : handle_output}};
 kernel.execute(code_input, callbacks, {silent:false});
}

function handle_output(out){
 console.log(out);
 var res = out.content.data["text/plain"];
 $("div#tweet_text").html(res);
}

$("input#capture").keypress(function(e) {
 console.log(e);
 if(e.which == 48) {
 // 0 pressed
 set_label(0);
 $("input#capture").val("");
 }else if (e.which == 49){
 // 1 pressed
 set_label(1); 
 $("input#capture").val("");
 }
});

load_next_tweet();
</script>

您需要将所有这些代码输入到一个单独的单元中(或从代码包中复制)。它包含混合 HTML 和 JavaScript,用于从您那里获取输入以手动分类推文。如果您需要停止或保存进度,请在下一个单元中运行以下代码。它将保存您的进度(并且不会中断上面的 HTML 代码,可以继续运行):

with open(labels_filename, 'w') as outf:
    json.dump(labels, outf)

从 Twitter 创建可复制的数据集

在数据挖掘中,有很多变量。这些不是数据挖掘算法的参数,而是数据收集的方法、环境设置以及许多其他因素。能够复制您的结果是重要的,因为它使您能够验证或改进您的结果。

在一个数据集上使用算法 X 获得 80%的准确率,在另一个数据集上使用算法 Y 获得 90%的准确率,并不意味着 Y 更好。我们需要能够在相同条件下对同一数据集进行测试,以便能够正确比较。通过运行前面的代码,您将得到与我创建和使用的数据集不同的数据集。主要原因在于,Twitter 将根据您搜索的时间返回与我不同的搜索结果。

即使如此,您对推文的标注可能与我的不同。虽然有些明显的例子表明某个推文与 Python 编程语言相关,但总会有一些灰色区域,标注并不明显。我遇到的一个棘手的灰色区域是那些我无法阅读的非英语语言的推文。在这个特定的情况下,Twitter 的 API 中提供了设置语言的选择,但这些选择也不一定完美。

由于这些因素,在从社交媒体提取的数据库上复制实验是困难的,Twitter 也不例外。Twitter 明确禁止直接共享数据集。一种解决方案是仅共享推文 ID,您可以自由地分享。在本节中,我们首先创建一个可以自由分享的推文 ID 数据集。然后,我们将看到如何从该文件下载原始推文以重新创建原始数据集。首先,我们保存可复制的推文 ID 数据集。

在创建另一个新的 Jupyter Notebook 之后,首先设置文件名,就像之前做标注时一样。这是以同样的方式完成的,但有一个新的文件名,我们可以用来存储可复制的数据集。代码如下:

import os
input_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "python_tweets.json")
labels_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "python_classes.json")
replicable_dataset = os.path.join(os.path.expanduser("~"), "Data", "twitter", "replicable_dataset.json")

我们以与之前笔记本相同的方式加载推文和标签:

import json
tweets = []
with open(input_filename) as inf:
    for line in inf:
        if len(line.strip()) == 0:
            continue
        tweets.append(json.loads(line))
if os.path.exists(labels_filename):
    with open(labels_filename) as inf:
        labels = json.load(inf)

现在我们通过同时遍历推文和标签并保存到列表中来创建一个数据集。这个代码的一个重要副作用是,通过在 zip 函数中首先放置标签,它将只加载足够多的推文以匹配我们创建的标签。换句话说,您可以在部分分类的数据上运行此代码:

dataset = [(tweet['id'], label) for label, tweet in zip(labels, tweets)]

最后,我们将结果保存在我们的文件中:

with open(replicable_dataset, 'w') as outf:
    json.dump(dataset, outf)

现在我们已经保存了推文 ID 和标签,我们可以重新创建原始数据集。如果您想重新创建本章所使用的数据集,它可以在本书附带代码包中找到。加载前面的数据集并不困难,但可能需要一些时间。

开始一个新的 Jupyter Notebook,并将数据集、标签和推文 ID 的文件名设置为之前一样。我已经调整了这里的文件名,以确保您不会覆盖之前收集的数据集,但如果您确实想覆盖,也可以随意更改。

代码如下:

import os
tweet_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "replicable_python_tweets.json")
labels_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "replicable_python_classes.json")
replicable_dataset = os.path.join(os.path.expanduser("~"), "Data", "twitter", "replicable_dataset.json")

接下来,使用 JSON 从文件中加载推文 ID:

import json
with open(replicable_dataset) as inf:
    tweet_ids = json.load(inf)

保存标签非常简单。我们只需遍历这个数据集并提取 ID。我们可以用仅仅两行代码(打开文件并保存推文)轻松做到这一点。然而,我们无法保证我们会得到我们想要的全部推文(例如,一些可能自从收集数据集以来已被改为私密状态)因此标签可能会错误地与数据索引。例如,我在收集数据后仅一天就尝试重新创建数据集,已经有两条推文丢失(它们可能被删除或被用户设置为私密)。因此,只打印出我们需要的标签是很重要的。

要做到这一点,我们首先创建一个空的实际标签列表来存储我们从 Twitter 实际恢复的推文的标签,然后创建一个将推文 ID 映射到标签的字典。代码如下:

actual_labels = []
label_mapping = dict(tweet_ids)

接下来,我们将创建一个 Twitter 服务器来收集所有这些推文。这需要一点时间。导入我们之前使用的 Twitter 库,创建一个授权令牌,并使用该令牌创建 Twitter 对象:

import twitter
consumer_key = "<Your Consumer Key Here>"
consumer_secret = "<Your Consumer Secret Here>"
access_token = "<Your Access Token Here>"
access_token_secret = "<Your Access Token Secret Here>"
authorization = twitter.OAuth(access_token, access_token_secret, consumer_key, consumer_secret)
t = twitter.Twitter(auth=authorization)

接下来,我们将遍历每个推文 ID,并要求 Twitter 恢复原始推文。Twitter API 的一个优点是我们可以一次请求 100 条推文,这大大减少了 API 调用的次数。有趣的是,从 Twitter 的角度来看,无论是获取一条推文还是 100 条推文,只要是一个单一请求,它们的调用次数是相同的。

以下代码将以 100 条推文为一组遍历我们的推文,将 ID 值合并在一起,并为每个推文获取推文信息。

all_ids = [tweet_id for tweet_id, label in tweet_ids]
with open(tweet_filename, 'a') as output_file:
    # We can lookup 100 tweets at a time, which saves time in asking twitter for them
    for start_index in range(0, len(all_ids), 100):
        id_string = ",".join(str(i) for i in all_ids[start_index:start_index+100])
        search_results = t.statuses.lookup(_id=id_string)
        for tweet in search_results:
            if 'text' in tweet:
                # Valid tweet - save to file
                output_file.write(json.dumps(tweet))
                output_file.write("nn")
                actual_labels.append(label_mapping[tweet['id']])

在此代码中,我们接着检查每条推文是否为有效的推文,如果是,就将其保存到我们的文件中。我们的最后一步是保存我们的结果标签:

with open(labels_filename, 'w') as outf:
    json.dump(actual_labels, outf)

文本转换器

现在我们有了我们的数据集,我们将如何对其进行数据挖掘?

基于文本的数据集包括书籍、论文、网站、手稿、编程代码以及其他形式的书面表达。我们迄今为止看到的所有算法都处理数值或分类特征,那么我们如何将我们的文本转换为算法可以处理的形式呢?可以采取许多测量。

例如,平均单词长度和平均句子长度被用来预测文档的可读性。然而,还有很多特征类型,例如单词出现频率,我们现在将研究这些。

词袋模型

其中一个最简单但非常有效的模型是简单地计算数据集中的每个单词。我们创建一个矩阵,其中每一行代表我们数据集中的文档,每一列代表一个单词。单元格的值是该单词在文档中的频率。这被称为词袋模型

这里是来自《指环王,J.R.R. 托尔金》的摘录:

天空下,精灵王的三个戒指,七个在石屋中的矮人领主,九个凡人,注定要死去,一个在黑暗王座上的黑暗领主,在莫多尔的土地上,阴影笼罩。一个戒指统治他们,一个戒指找到他们,一个戒指将他们全部带来,在黑暗中束缚他们。在莫多尔的土地上,阴影笼罩.                                                                  - J.R.R. 托尔金的《指环王》引言

在这个引语中,“the”这个词出现了九次,而“in”,“for”,“to”和“one”每个词各出现了四次。单词“ring”和“of”各出现了三次。

我们可以从这些中创建一个数据集,选择单词的子集并计数频率:

单词 the one ring to
频率 9 4 3 4

要对单个文档中的所有单词执行此操作,我们可以使用Counter类。在计数单词时,通常将所有字母转换为小写,我们在创建字符串时就是这样做的。代码如下:

 s = """Three Rings for the Elven-kings under the sky, Seven for the Dwarf-lords in halls of stone, Nine for Mortal Men, doomed to die, One for the Dark Lord on his dark throne In the Land of Mordor where the Shadows lie. One Ring to rule them all, One Ring to find them, One Ring to bring them all and in the darkness bind them. In the Land of Mordor where the Shadows lie. """.lower()
words = s.split()
from collections import Counter
c = Counter(words)
print(c.most_common(5))

打印c.most_common(5)会给出出现频率最高的前五个单词的列表。由于只给出了五个,并且大量单词都共享第五名的平局,因此处理平局并不好。

词袋模型有三种主要类型,以及许多变化和修改。

  • 第一种是使用原始频率,如前例所示。这和非归一化数据有相同的缺点——由于整体值高(如)the这样的词具有高方差,会掩盖低频(因此低方差)的词,尽管单词the的存在很少有很大的重要性。

  • 第二种模型是使用归一化频率,其中每个文档的总和等于 1。这是一个更好的解决方案,因为文档的长度并不那么重要,但它仍然意味着像“the”这样的词会掩盖低频词。第三种类型是简单地使用二元特征——如果出现,则值为 1,否则为 0。在本章中,我们将使用二进制表示。

  • 另一种(可以说是更受欢迎)的归一化方法是称为词频-逆文档频率tf-idf)。在这个加权方案中,首先将词频归一化到频率,然后除以它在语料库中出现的文档数。我们将在第十章,聚类新闻文章中使用 tf-idf。

n-gram 特征

标准词袋模型的一种变化称为 n-gram 模型。n-gram 模型解决了词袋模型中上下文不足的问题。在词袋模型中,只有单个单词本身被计数。这意味着像United States这样的常见词对,由于它们被视为单个单词,因此失去了在句子中的意义。

有一些算法可以读取一个句子,将其解析成树状结构,并利用这一点来创建非常精确的词语含义表示。不幸的是,这些算法计算成本很高,这使得将它们应用于大型数据集变得困难。

为了补偿这些关于上下文和复杂性的问题,n-gram 模型处于中间地带。它比词袋模型有更多的上下文,而在计算上只稍微昂贵一些。

n-gram 是n个连续、重叠的标记的子序列。在这个实验中,我们使用单词 n-gram,即单词标记的 n-gram。它们的计数方式与词袋模型相同,n-gram 形成一个放入袋中的单词。该数据集中单元格的值是特定 n-gram 在给定文档中出现的频率。

n 的值是一个参数。对于英语来说,将其设置为 2 到 5 之间是一个好的开始,尽管某些应用需要更高的值。n 的值越高,数据集就越稀疏,因为当 n 增加时,相同的 n-gram 出现在多个文档中的可能性就越小。n=1 的结果就是简单的词袋模型。

例如,对于n=3,我们在以下引语中提取了前几个 n-gram:

永远看到生活的光明面

第一个 n-gram(大小为 3)是永远看到,第二个是看到生活的,第三个是生活的光明。正如你所看到的,n-gram 重叠并覆盖了三个单词。单词 n-gram 相对于使用单个单词有优势。这个简单的概念通过考虑其局部环境为单词使用引入了一些上下文,而不需要大量理解语言的计算开销。

使用 n-gram 的一个缺点是矩阵变得更加稀疏——单词 n-gram 不太可能重复出现(尤其是在推文和其他短文档中!)。具体到社交媒体和其他短文档,除非是转发,否则单词 n-gram 不太可能出现在太多的不同推文中。然而,在较长的文档中,单词 n-gram 对于许多应用来说非常有效。对于文本文档的另一种 n-gram 形式是字符 n-gram。话虽如此,你很快就会看到单词 n-gram 在实践中非常有效。

与使用单词集合相比,我们简单地使用字符集合(尽管字符 n-gram 在计算方式上有许多选择!)。这种类型的模型可以帮助识别拼写错误的单词,并为分类提供其他好处。我们将在本章测试字符 n-gram,并在第九章,作者归属中再次看到它们。

其他文本特征

还可以提取其他特征。这些包括句法特征,例如句子中特定单词的使用。词性标注在需要理解文本意义的文本挖掘应用中也非常流行。这类特征类型本书不会涉及。如果您想了解更多,我推荐阅读《Python 3 Text Processing with NLTK 3 Cookbook》,作者:Jacob Perkins,Packt 出版社

在 Python 中处理文本数据有许多库。最广为人知的一个叫做自然语言工具包(NLTK)。scikit-learn 库也有一个 CountVectorizer 类,它执行类似的功能,建议您查看一下(我们将在第九章,作者归属中使用它)。NLTK 在单词分词和词性标注方面有更多功能(即识别哪些词是名词、动词等)。

我们将要使用的库叫做 spaCy。它从头开始设计,旨在为自然语言处理提供快速和可靠的性能。它不如 NLTK 知名,但正迅速增长其受欢迎程度。它也简化了一些决策,但与 NLTK 相比,它的语法稍微复杂一些。

对于生产系统,我推荐使用 spaCy,它的速度比 NLTK 快。NLTK 是为了教学而构建的,而 spaCy 是为了生产而构建的。它们有不同的语法,这意味着将代码从一个库移植到另一个库可能会有困难。如果您不打算尝试不同的自然语言解析器,我建议使用 spaCy。

简单贝叶斯

简单贝叶斯是一个概率模型,不出所料,它基于贝叶斯统计学的朴素解释。尽管有朴素的一面,该方法在许多情况下表现都非常出色。由于朴素的一面,它运行得相当快。它可以用于许多不同特征类型和格式的分类,但我们将专注于本章中的一个:词袋模型中的二元特征。

理解贝叶斯定理

对于我们大多数人来说,当我们学习统计学时,我们是从频率统计学方法开始的。在这个方法中,我们假设数据来自某个分布,我们的目标是确定该分布的参数。然而,这些参数可能(可能不正确地)被认为是固定的。我们使用我们的模型来描述数据,甚至测试以确保数据符合我们的模型。

贝叶斯统计学反而模拟了人们(至少,非频率统计学统计学家)实际推理的方式。我们有一些数据,我们使用这些数据来更新我们对某事发生的可能性的模型。在贝叶斯统计学中,我们使用数据来描述模型,而不是使用模型并用数据来验证它(正如频率统计学的方法)。

应该注意的是,频率统计学和贝叶斯统计学提出和回答的问题略有不同。直接的比较并不总是正确的。

贝叶斯定理计算 P(A|B) 的值。也就是说,知道 B 已经发生,事件 A 发生的概率是多少。在大多数情况下,B 是一个观察事件,例如 昨天下雨了,而 A 是预测今天会下雨。对于数据挖掘,B 通常是指 我们观察到了这个样本,而 A 是 这个样本是否属于这个类别(类别预测)。我们将在下一节中看到如何使用贝叶斯定理进行数据挖掘。

贝叶斯定理的方程如下:

图片

作为例子,我们想要确定包含单词“drugs”的电子邮件是垃圾邮件的概率(因为我们认为这样的推文可能是药品垃圾邮件)。

在这个背景下,A 是这条推文是垃圾邮件的概率。我们可以通过计算训练数据集中垃圾邮件的百分比来直接计算 P(A),这被称为先验信念。如果我们的数据集中每 100 封电子邮件中有 30 封是垃圾邮件,那么 P(A) 是 30/100 或 0.3。

在这个背景下,B 是这条推文包含单词 drugs。同样,我们可以通过计算数据集中包含单词 drugs 的推文百分比来计算 P(B)。如果我们的训练数据集中每 100 封电子邮件中有 10 封包含单词 drugs,那么 P(B) 是 10/100 或 0.1。注意,在计算这个值时,我们并不关心电子邮件是否是垃圾邮件。

P(B|A) 是如果一封电子邮件是垃圾邮件,那么它包含单词“drugs”的概率。这也可以从我们的训练数据集中轻松计算。我们查看我们的训练集以找到垃圾邮件,并计算其中包含单词“drugs”的百分比。在我们的 30 封垃圾邮件中,如果有 6 封包含单词“drugs”,那么 P(B|A) 就计算为 6/30 或 0.2。

从这里,我们使用贝叶斯定理来计算 P(A|B),即包含单词“drugs”的推文是垃圾邮件的概率。使用前面的公式,我们看到结果是 0.6。这表明如果一封电子邮件中包含单词“drugs”,那么它有 60% 的可能是垃圾邮件。

注意前面示例的经验性质——我们直接使用来自我们的训练数据集的证据,而不是来自某种先验分布。相比之下,频率派的观点会依赖于我们创建推文中单词概率的分布来计算类似的方程。

简单贝叶斯算法

回顾我们的贝叶斯定理方程,我们可以使用它来计算给定样本属于给定类别的概率。这使得方程可以用作分类算法。

C 作为给定的类别,以 D 作为数据集中的样本,我们创建了贝叶斯定理所需的元素,随后是朴素贝叶斯。朴素贝叶斯是一种分类算法,它利用贝叶斯定理来计算新数据样本属于特定类别的概率。

P(D) 是给定数据样本的概率。计算这个值可能很困难,因为样本是不同特征之间复杂交互的结果,但幸运的是,它在所有类别中都是恒定的。因此,我们根本不需要计算它,因为在最后一步我们所做的只是比较相对值。

P(D|C) 是数据点属于该类别的概率。由于不同的特征,这也可能很难计算。然而,这就是我们引入朴素贝叶斯算法中朴素部分的地方。我们天真地假设每个特征都是相互独立的。我们不是计算 P(D|C) 的完整概率,而是计算每个特征 D1, D2, D3, ... 等等的概率。然后,我们只是将它们相乘:

P(D|C) = P(D1|C) x P(D2|C).... x P(Dn|C)

这些值对于二元特征相对容易计算;我们只需计算在我们的样本数据集中它等于多少的百分比。

相反,如果我们对这部分进行非朴素贝叶斯版本的实现,我们需要计算每个类别中不同特征之间的相关性。这种计算在最好情况下也是不可行的,没有大量数据或适当的语言分析模型几乎是不可能的。

从这里,算法就很简单了。我们计算每个可能类别的 P(C|D),完全忽略 P(D) 项。然后我们选择概率最高的类别。由于 P(D) 项在各个类别中是一致的,忽略它对最终预测没有影响。

它是如何工作的

例如,假设我们从数据集中的一个样本中获得了以下(二元)特征值:[0, 0, 0, 1]。

我们的训练数据集包含两个类别,其中 75% 的样本属于类别 0,25% 属于类别 1。每个类别的特征值概率如下:

对于类别 0:[0.3, 0.4, 0.4, 0.7]

对于类别 1:[0.7, 0.3, 0.4, 0.9]

这些值可以这样解释:对于特征 1,在类别 0 的样本中有 30% 的情况下其值为 1。在类别 1 的样本中有 70% 的情况下其值为 1。

我们现在可以计算这个样本属于类别 0 的概率。P(C=0) = 0.75,这是类别为 0 的概率。同样,P(D) 对于朴素贝叶斯算法不是必需的,并且简单地从方程中移除。让我们看看计算过程:

P(D|C=0) = P(D1|C=0) x P(D2|C=0) x P(D3|C=0) x P(D4|C=0) 
= 0.3 x 0.6 x 0.6 x 0.7 
= 0.0756

第二和第三个值是 0.6,因为该特征在样本中的值为 0。列出的概率是针对每个特征的值为 1 的情况。因此,0 的概率是其倒数:P(0) = 1 – P(1)

现在,我们可以计算数据点属于这个类别的概率。让我们看看计算过程:

P(C=0|D) = P(C=0) P(D|C=0) = 0.75 * 0.0756 = 0.0567 

现在,我们为类别 1 计算相同的值:

P(D|C=1) = P(D1|C=1) x P(D2|C=1) x P(D3|C=1) x P(D4|C=1)
         = 0.7 x 0.7 x 0.6 x 0.9
         = 0.2646 P(C=1|D) 
         = P(C=1)P(D|C=1)
         = 0.25 * 0.2646
         = 0.06615

通常,P(C=0|D) + P(C=1|D) 应该等于 1。毕竟,那只有两种可能的选择!然而,由于我们没有在我们的方程中包含 P(D) 的计算,概率并不为 1。

由于P(C=1|D)的值大于P(C=0|D),数据点应该被分类为属于类别 1。你可能已经通过查看方程式猜到了这一点;然而,你可能对最终的决定如此接近感到有些惊讶。毕竟,在计算P(D|C)时,类别 1 的概率要高得多。这是因为我们引入了一个先验信念,即大多数样本通常属于类别 0。

如果类别大小相等,得到的概率会有很大不同。尝试将P(C=0)P(C=1)都改为 0.5 以实现类别大小相等,并重新计算结果。

应用朴素贝叶斯

现在,我们将创建一个管道,它接受一条推文并确定它是否相关,仅基于该推文的内容。

为了执行词提取,我们将使用 spaCy,这是一个包含大量用于自然语言分析的工具体库。我们将在未来的章节中也会使用 spaCy。

要在您的计算机上安装 spaCy,请使用 pip 安装该包:pip install spacy

如果不起作用,请参阅spacy.io/上的 spaCy 安装说明,以获取您平台特定的信息。

我们将创建一个管道来提取词特征并使用朴素贝叶斯对推文进行分类。我们的管道有以下步骤:

  • 使用 spaCy 的词标记化将原始文本文档转换成词频字典。

  • 使用 scikit-learn 中的DictVectorizer转换器将这些字典转换成向量矩阵。这是必要的,以便使朴素贝叶斯分类器能够读取第一步中提取的特征值。

  • 按照我们在前几章中看到的方法训练朴素贝叶斯分类器。

我们需要创建另一个名为ch6_classify_twitter的 Notebook(这是本章的最后一个 Notebook!)来进行分类。

提取词频

我们将使用 spaCy 提取我们的词频。我们仍然想在管道中使用它,但 spaCy 不符合我们的转换器接口。我们需要创建一个基本的转换器来完成这项工作,以获得 fit 和 transform 方法,使我们能够在管道中使用它。

首先,设置转换器类。在这个类中我们不需要拟合任何东西,因为这个转换器只是简单地提取文档中的单词。因此,我们的 fit 是一个空函数,除了返回 self,这对于转换器对象来说是为了符合 scikit-learn API。

我们的 transform 稍微复杂一些。我们想要从每个文档中提取每个单词,并记录如果它被发现则为 True。我们在这里只使用二进制特征——如果在文档中则为 True,否则为 False。如果我们想使用频率,我们将设置计数字典,就像我们在过去几个章节中所做的那样。

让我们看看代码:

import spacy
from sklearn.base import TransformerMixin

# Create a spaCy parser
nlp = spacy.load('en')

class BagOfWords(TransformerMixin):
    def fit(self, X, y=None):
        return self

    def transform(self, X):
        results = []
        for document in X:
            row = {}
            for word in list(nlp(document, tag=False, parse=False, entity=False)):
                if len(word.text.strip()): # Ignore words that are just whitespace
                    row[word.text] = True
                    results.append(row)
        return results

结果是一个字典列表,其中第一个字典是第一条 tweet 中的单词列表,依此类推。每个字典都有一个单词作为键,其值为 True 表示该单词被发现。任何不在字典中的单词都将假定它没有出现在 tweet 中。明确指出一个单词的出现为 False 也可以,但这会占用不必要的空间来存储。

将字典转换为矩阵

下一步是将之前构建的字典转换为可以与分类器一起使用的矩阵。这一步通过 scikit-learn 提供的 DictVectorizer 转换器变得非常简单。

DictVectorizer 类简单地将字典列表转换为矩阵。这个矩阵中的特征是每个字典中的键,而值对应于这些特征在每个样本中的出现次数。在代码中创建字典很容易,但许多数据算法实现更喜欢矩阵。这使得 DictVectorizer 成为一个非常有用的类。

在我们的数据集中,每个字典都有单词作为键,并且只有当单词实际上出现在 tweet 中时才会出现。因此,我们的矩阵将每个单词作为特征,如果单词出现在 tweet 中,则单元格中的值为 True。

要使用 DictVectorizer,只需使用以下命令导入:

from sklearn.feature_extraction import DictVectorizer

将所有这些组合在一起

最后,我们需要设置一个分类器,我们在这个章节中使用朴素贝叶斯。由于我们的数据集只包含二元特征,我们使用为二元特征设计的 BernoulliNB 分类器。作为一个分类器,它非常容易使用。与 DictVectorizer 一样,我们只需导入它并将其添加到我们的管道中:

from sklearn.naive_bayes import BernoulliNB

现在是时候将这些部分组合在一起了。在我们的 Jupyter Notebook 中,设置文件名并加载数据集和类别,就像我们之前做的那样。设置 tweets 本身(不是 IDs!)和分配给它们的标签的文件名。代码如下:

import os
input_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "python_tweets.json")
labels_filename = os.path.join(os.path.expanduser("~"), "Data", "twitter", "python_classes.json")

加载 tweets 本身。我们只对 tweets 的内容感兴趣,所以我们提取文本值并只存储那个。代码如下:

import json

tweets = []
with open(input_filename) as inf:
    for line in inf:
        if len(line.strip()) == 0: continue
        tweets.append(json.loads(line)['text'])

with open(labels_filename) as inf:
    labels = json.load(inf)

# Ensure only classified tweets are loaded
tweets = tweets[:len(labels)]

现在,创建一个管道,将之前的组件组合在一起。我们的管道有三个部分:

  1. 我们创建的 NLTKBOW 转换器。

  2. 一个 DictVectorizer 转换器。

  3. 一个 BernoulliNB 分类器。

代码如下:

from sklearn.pipeline import Pipeline

pipeline = Pipeline([('bag-of-words', BagOfWords()), ('vectorizer', DictVectorizer()), ('naive-bayes', BernoulliNB()) ])

我们现在几乎可以运行我们的管道了,我们将使用 cross_val_score 来做,就像我们之前多次做的那样。在我们进行数据挖掘之前,我们将介绍一个比我们之前使用的准确度指标更好的评估指标。正如我们将看到的,当每个类中的样本数量不同时,使用准确度是不够的。

使用 F1 分数进行评估

在选择评估指标时,始终重要的是要考虑该评估指标不适用的情况。在许多情况下,准确率是一个好的评估指标,因为它容易理解且易于计算。然而,它很容易被伪造。换句话说,在许多情况下,你可以创建具有高准确率但效用差的算法。

虽然我们的推文数据集(通常,你的结果可能会有所不同)包含大约 50%与编程相关的和 50%非编程,但许多数据集并不像这样平衡

例如,一个电子邮件垃圾邮件过滤器可能期望看到 80%以上的 incoming 电子邮件是垃圾邮件。一个简单地将所有内容标记为垃圾邮件的过滤器相当无用;然而,它将获得 80%的准确率!

为了解决这个问题,我们可以使用其他评估指标。最常用的一个被称为f1 分数(也称为 f 分数、f 度量或这个术语的许多其他变体)。

F1 分数是基于每个类定义的,并且基于两个概念:精确度和召回率。精确度是所有预测属于特定类别的样本中,实际上属于该类的百分比。召回率是数据集中属于某个类别的样本实际上被标记为属于该类别的百分比。

在我们的应用案例中,我们可以计算两个类(python-programming 和 not python-programming)的值。

我们的精确度计算成为问题:在所有被预测为相关的推文中,实际上有多少是相关的?

同样,召回率成为问题:在数据集中所有相关的推文中,有多少被预测为相关?

在计算了精确度和召回率之后,f1 分数是精确度和召回率的调和平均值:

要在 scikit-learn 方法中使用 f1 分数,只需将 scoring 参数设置为 f1。默认情况下,这将返回标签为 1 的类的 f1 分数。在我们的数据集上运行代码,我们只需使用以下代码行:

from sklearn.cross_validation import cross_val_score
scores = cross_val_score(pipeline, tweets, labels, scoring='f1')
# We then print out the average of the scores:
import numpy as np
print("Score: {:.3f}".format(np.mean(scores)))

结果是 0.684,这意味着我们几乎 70%的时间可以准确地确定使用 Python 的推文是否与编程语言相关。这是使用只有 300 条推文的数据集。

回去收集更多数据,你会发现结果会增加!记住,你的数据集可能不同,因此你的结果也会不同。

更多数据通常意味着更高的准确率,但这并不保证!

从模型中获取有用的特征

你可能会问的一个问题是,确定推文是否相关最佳特征是什么?我们可以从我们的朴素贝叶斯模型中提取这些信息,并找出根据朴素贝叶斯,哪些特征单独是最好的。

首先,我们拟合一个新的模型。虽然cross_val_score给出了不同交叉验证测试数据折叠的分数,但它并不容易给出训练好的模型本身。为此,我们只需用推文拟合我们的管道,创建一个新的模型。代码如下:

 model = pipeline.fit(tweets, labels)

注意,我们在这里并不是真正评估模型,因此我们不需要对训练/测试分割那么小心。然而,在你将这些特征投入实际应用之前,你应该在单独的测试分割上进行评估。我们在这里省略这一步骤是为了清晰起见。

管道通过named_steps属性和步骤的名称(我们在创建管道对象本身时定义了这些名称)提供了对各个步骤的访问。例如,我们可以获取朴素贝叶斯模型:

nb = model.named_steps['naive-bayes']
feature_probabilities = nb.feature_log_prob_

从这个模型中,我们可以提取每个单词的概率。这些存储为对数概率,即 log(P(A|f)),其中 f 是给定的特征。

这些存储为对数概率的原因是因为实际值非常低。例如,第一个值是-3.486,这对应于低于 0.03%的概率。对数概率在涉及此类小概率的计算中使用,因为它们阻止了下溢错误,其中非常小的值被四舍五入为零。鉴于所有概率都是相乘的,单个值为 0 将导致整个答案始终为 0!无论如何,值之间的关系仍然是相同的;值越高,该特征就越有用。

我们可以通过对数概率数组进行排序来获取最有用的特征。我们想要降序排列,所以首先简单地取负值。代码如下:

top_features = np.argsort(-nb.feature_log_prob_[1])[:50]

上述代码只会给出索引,而不是实际的特征值。这并不很有用,因此我们将特征索引映射到实际值。关键是管道中的 DictVectorizer 步骤,它为我们创建了矩阵。幸运的是,这也记录了映射,使我们能够找到与不同列相关联的特征名称。我们可以从管道的这一部分提取特征:

dv = model.named_steps['vectorizer']

从这里,我们可以通过在 DictVectorizer 的feature_names_属性中查找来打印出顶级特征的名称。将以下行输入到一个新单元格中并运行它,以打印出顶级特征列表:

for i, feature_index in enumerate(top_features):
    print(i, dv.feature_names_[feature_index], np.exp(feature_probabilities[1][feature_index]))

前几个特征包括:RT,甚至 Python。根据我们收集的数据,这些很可能是噪声(尽管在编程之外使用冒号并不常见),收集更多数据对于平滑这些问题至关重要。尽管如此,查看列表,我们还是发现了一些更明显的编程特征:

9 for 0.175
14 ) 0.10625
15 ( 0.10625
22 jobs 0.0625
29 Developer 0.05

还有一些其他内容在工作环境中提到了 Python,因此可能是指编程语言(尽管自由职业的蛇类处理者也可能使用类似术语,但在 Twitter 上这些术语不太常见)。

最后一个通常是这样的格式:我们正在寻找这个工作的候选人

检查这些特征给我们带来了很多好处。我们可以训练人们识别这些推文,寻找共同点(这可以提供对主题的见解),或者甚至去除那些没有意义的特征。例如,单词 RT 在这个列表中出现的频率相当高;然而,这是 Twitter 上用于转发(即转发他人的推文)的常见短语。专家可能会决定从列表中删除这个单词,使分类器不太可能受到我们通过小数据集引入的噪声的影响。

摘要

在这一章中,我们探讨了文本挖掘——如何从文本中提取特征,如何使用这些特征,以及扩展这些特征的方法。在这个过程中,我们考虑了将推文置于上下文中——这条推文是否提到了 python,指的是编程语言?我们从基于 Web 的 API 下载了数据,获取了来自流行的微博网站 Twitter 的推文。这给了我们一个数据集,我们使用在 Jupyter Notebook 中直接构建的表格对其进行标记。

我们还研究了实验的可重复性。虽然 Twitter 不允许你将你的数据副本发送给其他人,但它允许你发送推文的 ID。利用这一点,我们创建了保存 ID 并重新创建大部分原始数据集的代码。并非所有推文都被返回;有些在 ID 列表创建和数据集复制的时间间隔内已被删除。

我们使用朴素贝叶斯分类器进行文本分类。这是基于贝叶斯定理的,它使用数据来更新模型,与通常先从模型开始的频率主义方法不同。这允许模型吸收和更新新数据,并纳入先验信念。此外,朴素的部分使得计算频率变得容易,无需处理特征之间的复杂相关性。

我们提取的特征是单词出现频率——这个单词是否出现在这条推文中?这个模型被称为词袋模型。虽然这丢弃了关于单词使用位置的信息,但它仍然在许多数据集上实现了很高的准确率。使用词袋模型和朴素贝叶斯进行整个流程相当稳健。你会发现它在大多数基于文本的任务上都能取得相当好的分数。在你尝试更先进的模型之前,这是一个很好的基线。作为另一个优点,朴素贝叶斯分类器没有需要设置的参数(尽管如果你愿意做一些调整,也有一些参数)。

为了扩展本章所做的工作,首先开始收集更多的数据。你还需要手动对这些数据进行分类,但你可能会发现推文之间存在一些相似性,这可能会使分类变得更容易。例如,有一个研究领域叫做局部敏感哈希(Locality Sensitive Hashes),它可以判断两条推文是否相似。两条相似的推文很可能涉及相同的话题。另一种扩展研究的方法是考虑如何构建一个模型,将推特用户的歷史数据纳入方程中——换句话说,如果用户经常推关于 python 作为编程语言的帖子,那么他们更有可能在未来的推文中使用 python。

在下一章中,我们将探讨如何从另一种类型的数据,即图中提取特征,以便在社交媒体上对关注的人提供建议。

第七章:使用图挖掘进行推荐

图表可以用来表示各种现象。这尤其适用于在线社交网络和物联网IoT)。图挖掘是一个大产业,例如 Facebook 这样的网站就是通过在图上进行的数据分析实验来运行的。

社交媒体网站建立在参与度之上。没有活跃的新闻源或有趣的朋友关注,用户不会参与网站。相比之下,有更多有趣朋友和关注者的用户参与度更高,看到更多广告。这导致网站的收入流更大。

在本章中,我们探讨如何在图上定义相似性,以及如何在数据挖掘环境中使用它们。这同样基于现象模型。我们研究了一些基本的图概念,如子图和连通分量。这导致了对聚类分析的研究,我们将在第十章中更深入地探讨,即第十章聚类新闻文章

本章涵盖的主题包括:

  • 通过聚类数据来寻找模式

  • 从之前的实验中加载数据集

  • 从 Twitter 获取关注者信息

  • 创建图表和网络

  • 为聚类分析寻找子图

加载数据集

在本章中,我们的任务是推荐在线社交网络中的用户,基于共享的连接。我们的逻辑是,如果两个用户有相同的朋友,他们非常相似,值得互相推荐。我们希望我们的推荐具有高价值。我们只能推荐这么多人,否则会变得乏味,因此我们需要找到能够吸引用户的推荐。

要做到这一点,我们使用上一章中的消歧模型来找到只谈论Python 编程语言的用户。在这一章中,我们将一个数据挖掘实验的结果作为另一个数据挖掘实验的输入。一旦我们选定了 Python 程序员,我们就使用他们的友谊来找到高度相似的用户群。两个用户之间的相似性将由他们有多少共同朋友来定义。我们的直觉是,两个人共同的朋友越多,他们成为朋友的可能性就越大(因此应该在我们的社交媒体平台上推荐)。

我们将使用上一章中介绍过的 API,从 Twitter 创建一个小型社交图。我们寻找的数据是感兴趣于类似话题(再次强调,是 Python 编程语言)的用户子集以及他们所有朋友的列表(他们关注的人)。有了这些数据,我们将检查两个用户之间的相似性,基于他们有多少共同朋友。

除了 Twitter 之外,还有许多其他的在线社交网络。我们选择 Twitter 进行这个实验的原因是他们的 API 使得获取这类信息变得相当容易。信息也来自其他网站,如 Facebook、LinkedIn 和 Instagram 等。然而,获取这些信息要困难得多。

要开始收集数据,设置一个新的 Jupyter Notebook 和一个twitter连接实例,就像我们在上一章所做的那样。你可以重用上一章的应用信息或者创建一个新的:

import twitter
consumer_key = "<Your Consumer Key Here>"
consumer_secret = "<Your Consumer Secret Here>"
access_token = "<Your Access Token Here>"
access_token_secret = "<Your Access Token Secret Here>"
authorization = twitter.OAuth(access_token, 
access_token_secret, consumer_key, consumer_secret)
t = twitter.Twitter(auth=authorization, retry=True)

此外,设置文件名。你希望为这次实验使用一个与第六章,“使用朴素贝叶斯进行社交媒体洞察”不同的文件夹,确保你不会覆盖你之前的数据集!

import os 
data_folder = os.path.join(os.path.expanduser("~"), "Data", "twitter")
output_filename = os.path.join(data_folder, "python_tweets.json")

接下来,我们需要一个用户列表。我们将像上一章所做的那样搜索推文,并寻找提到单词python的推文。首先,创建两个列表来存储推文文本和相应的用户。我们稍后会需要用户 ID,所以现在创建一个映射字典。代码如下:

original_users = [] 
tweets = []
user_ids = {}

我们现在将执行对单词 python 的搜索,就像我们在上一章所做的那样,并遍历搜索结果,只保存文本(按照上一章的要求)的推文:

search_results = t.search.tweets(q="python", count=100)['statuses']
for tweet in search_results:
    if 'text' in tweet:
        original_users.append(tweet['user']['screen_name']) 
        user_ids[tweet['user']['screen_name']] = tweet['user']['id']
        tweets.append(tweet['text'])

运行此代码将获取大约 100 条推文,在某些情况下可能稍微少一些。尽管如此,并不是所有这些都与编程语言相关。我们将通过使用上一章中训练的模型来解决这个问题。

使用现有模型进行分类

正如我们在上一章所学到的,提到单词 python 的所有推文并不都会与编程语言相关。为了做到这一点,我们将使用上一章中使用的分类器来获取基于编程语言的推文。我们的分类器并不完美,但它将比仅仅进行搜索有更好的专业化。

在这种情况下,我们只对那些在推文中提到 Python 编程语言的用户感兴趣。我们将使用上一章中的分类器来确定哪些推文与编程语言相关。从那里,我们将只选择那些提到编程语言的用户。

要进行我们更广泛实验的这一部分,我们首先需要保存上一章中的模型。打开我们在上一章制作的 Jupyter Notebook,即我们构建和训练分类器的那个。

如果你已经关闭了它,那么 Jupyter Notebook 将不会记住你所做的一切,你需要再次运行这些单元格。要做到这一点,点击 Notebook 上的单元格菜单并选择运行所有。

在所有单元格都计算完毕后,选择最后的空白单元格。如果你的 Notebook 在最后没有空白单元格,选择最后一个单元格,选择插入菜单,然后选择插入单元格下方选项。

我们将使用joblib库来保存我们的模型并加载它。

joblibscikit-learn 包内含的一个内置外部包。无需额外安装步骤!这个库有保存和加载模型以及进行简单并行处理(这在 scikit-learn 中被大量使用)的工具。

首先,导入库并创建我们模型的输出文件名(确保目录存在,否则它们不会被创建)。我已经把这个模型存储在我的 Models 目录中,但你也可以选择将它们存储在其他地方。代码如下:

from sklearn.externals import joblib
output_filename = os.path.join(os.path.expanduser("~"), "Models", "twitter", "python_context.pkl")

接下来,我们使用 joblib 中的 dump 函数,它的工作方式与 json 库中同名版本类似。我们传递模型本身和输出文件名:

joblib.dump(model, output_filename)

运行此代码将把我们的模型保存到指定的文件名。接下来,回到你上一个小节中创建的新 Jupyter Notebook,并加载此模型。

你需要在这个笔记本中再次设置模型的文件名,通过复制以下代码:

model_filename = os.path.join(os.path.expanduser("~"), "Models", "twitter", "python_context.pkl")

确保文件名是你之前保存模型时使用的那个。接下来,我们需要重新创建我们的 BagOfWords 类,因为它是一个自定义构建的类,不能直接由 joblib 加载。只需从上一章的代码中复制整个 BagOfWords 类及其依赖项:

import spacy
from sklearn.base import TransformerMixin

# Create a spaCy parser
nlp = spacy.load('en')

class BagOfWords(TransformerMixin):
    def fit(self, X, y=None):
        return self

    def transform(self, X):
        results = []
        for document in X:
            row = {}
            for word in list(nlp(document, tag=False, parse=False, entity=False)):
                if len(word.text.strip()): # Ignore words that are just whitespace
                    row[word.text] = True
                    results.append(row)
        return results

在生产环境中,你需要单独在集中文件中开发你的自定义转换器,并将它们导入到笔记本中。这个小技巧简化了工作流程,但你可以自由地通过创建一个通用功能库来集中化重要代码进行实验。

现在加载模型只需要调用 joblibload 函数:

from sklearn.externals import joblib
context_classifier = joblib.load(model_filename)

我们的 context_classifier 与我们在 第六章,“使用朴素贝叶斯进行社交媒体洞察”中看到的笔记本中的模型对象工作方式完全一样。它是一个 Pipeline 的实例,与之前相同的三个步骤(BagOfWordsDictVectorizerBernoulliNB 分类器)。在这个模型上调用 predict 函数会给出我们的推文是否与编程语言相关的预测。代码如下:

y_pred = context_classifier.predict(tweets)

y_pred 中的第 i 项如果是预测为与编程语言相关的第 i 条推文,则将为 1,否则将为 0。从这里,我们可以获取到相关推文及其相关用户:

relevant_tweets = [tweets[i] for i in range(len(tweets)) if y_pred[i] == 1]
relevant_users = [original_users[i] for i in range(len(tweets)) if y_pred[i] == 1]

使用我的数据,这对应着 46 个相关用户。比我们之前的 100 条推文/用户要少一些,但现在我们有了一个构建我们社交网络的基础。我们可以随时添加更多数据以获取更多用户,但 40+ 个用户足以作为第一次遍历。我建议回来,添加更多数据,再次运行代码,看看你获得了什么结果。

从 Twitter 获取关注者信息

在我们的初始用户集之后,我们现在需要获取每个用户的联系人。联系人是指用户正在关注的人。这个 API 叫做 friends/ids,它既有优点也有缺点。好消息是,它可以在单个 API 调用中返回多达 5,000 个联系人 ID。坏消息是,你每 15 分钟只能进行 15 次调用,这意味着你至少需要花 1 分钟每个用户来获取所有关注者——如果他们有超过 5,000 个朋友(这比你想的更常见)。

代码与之前我们使用的 API 代码(获取推文)类似。我们将将其包装成一个函数,因为我们将在接下来的两个部分中使用这段代码。我们的函数接受一个 Twitter 用户的 ID 值,并返回他们的联系人。虽然对一些人来说可能令人惊讶,但许多 Twitter 用户有超过 5,000 个朋友。因此,我们需要使用 Twitter 的分页功能,这允许 Twitter 通过单独的 API 调用返回多页数据。当你向 Twitter 请求信息时,它会给你你的信息,同时还有一个游标,这是一个 Twitter 用来跟踪你的请求的整数。如果没有更多信息,这个游标是 0;否则,你可以使用提供的游标来获取下一页的结果。传递这个游标让 Twitter 继续你的查询,返回下一组数据给你。

在函数中,我们持续循环,直到这个游标不等于 0(因为当它等于 0 时,就没有更多数据可以收集了)。然后我们向用户的关注者发起请求,并将他们添加到我们的列表中。我们这样做是在 try 块中,因为可能发生一些我们可以处理的错误。关注者的 ID 存储在结果字典的 ids 键中。在获取到这些信息后,我们更新游标。它将在下一次循环迭代中使用。最后,我们检查是否有超过 10,000 个朋友。如果是这样,我们就跳出循环。代码如下:

import time

def get_friends(t, user_id):
    friends = []
    cursor = -1
    while cursor != 0: 
        try:
            results = t.friends.ids(user_id= user_id, cursor=cursor, count=5000)
            friends.extend([friend for friend in results['ids']])
            cursor = results['next_cursor'] 
            if len(friends) >= 10000:
                break
        except TypeError as e:
            if results is None:
                print("You probably reached your API limit, waiting for 5 minutes")
                sys.stdout.flush() 
                time.sleep(5*60) # 5 minute wait 
            else: 
                # Some other error happened, so raise the error as normal
                raise e
        except twitter.TwitterHTTPError as e:
            print(e)
            break
        finally:
            # Break regardless -- this stops us going over our API limit
            time.sleep(60)

在这里插入一个警告是值得的。我们正在处理来自互联网的数据,这意味着奇怪的事情会经常发生。我在开发这段代码时遇到的一个问题是,一些用户有很多很多很多的朋友。为了解决这个问题,我们在这里设置了一个安全措施,如果达到 10,000 个用户以上,我们将退出函数。如果你想收集完整的数据集,你可以删除这些行,但请注意,它可能在一个特定的用户上停留很长时间。

上述功能的大部分是错误处理,因为在处理外部 API 时可能会出现很多问题!

最可能发生的错误是我们意外达到 API 限制(虽然我们有一个睡眠来停止它,但在你停止并运行代码之前,它可能发生)。在这种情况下,结果为None,我们的代码将因TypeError而失败。在这种情况下,我们将等待 5 分钟并再次尝试,希望我们已经到达下一个 15 分钟的时间窗口。此时可能还会发生另一个TypeError。如果其中一个发生了,我们将抛出它,并需要单独处理。

可能发生的第二个错误是在 Twitter 端,例如请求一个不存在的用户或其他基于数据的错误,导致TwitterHTTPError(这与 HTTP 404 错误类似的概念)。在这种情况下,不要再尝试这个用户,只需返回我们确实得到的任何关注者(在这种情况下,可能为 0)。

最后,Twitter 只允许我们每 15 分钟请求 15 次关注者信息,所以我们在继续之前将等待 1 分钟。我们这样做是在一个 finally 块中,以确保即使发生错误,它也会发生。

构建网络

现在我们将构建我们的用户网络,其中如果两个用户相互关注,则用户之间有联系。构建这个网络的目的是给我们一个我们可以用来将用户列表分割成组的数据结构。从这些组中,我们然后可以向同一组的人推荐人。从我们的原始用户开始,我们将获取每个用户的关注者并将它们存储在字典中。使用这个概念,我们可以从一组初始用户向外扩展图。

从我们的原始用户开始,我们将获取每个用户的关注者并将它们存储在字典中(在从我们的*user_id*字典中获取用户的 ID 之后):

friends = {} 
for screen_name in relevant_users:
    user_id = user_ids[screen_name]
    friends[user_id] = get_friends(t, user_id)

接下来,我们将移除任何没有朋友的用户。对于这些用户,我们实际上无法以这种方式做出推荐。相反,我们可能需要查看他们的内容或关注他们的人。不过,我们将把这一点排除在本章的范围之外,所以让我们只移除这些用户。代码如下:

friends = {user_id:friends[user_id] 
           for user_id in friends
           if len(friends[user_id]) > 0}

现在我们有 30 到 50 个用户,具体取决于你的初始搜索结果。我们现在将这个数量增加到 150。下面的代码将需要相当长的时间来运行——考虑到 API 的限制,我们只能每分钟获取一个用户的关注者。简单的数学告诉我们,150 个用户将需要 150 分钟,这至少是 2 小时 30 分钟。考虑到我们将花费在获取这些数据上的时间,确保我们只获取好的用户是值得的。

那么,什么样的用户才算好呢?鉴于我们将根据共享联系来寻找推荐,我们将根据共享联系来搜索用户。我们将获取现有用户的联系人,从那些与现有用户联系更紧密的用户开始。为此,我们维护一个计数,记录用户在朋友列表中出现的所有次数。在考虑采样策略时,考虑应用程序的目标是值得考虑的。为此,获取大量类似用户可以使推荐更加适用。

要做到这一点,我们只需遍历我们拥有的所有朋友列表,并计算每次朋友出现的次数。

from collections import defaultdict
def count_friends(friends): 
    friend_count = defaultdict(int)
    for friend_list in friends.values(): 
        for friend in friend_list:
            friend_count[friend] += 1 
    return friend_count

计算我们当前的联系人计数后,我们可以从我们的样本中获得最紧密联系的人(即,从我们现有的列表中获得最多朋友的人)。代码如下:

friend_count = count_friends(friends)
from operator import itemgetter
best_friends = sorted(friend_count, key=friend_count.get, reverse=True)

从这里,我们设置了一个循环,直到我们有了 150 个用户的联系人。然后,我们按拥有他们作为朋友的人数顺序遍历我们最好的朋友,直到找到一个我们尚未检查的用户。然后,我们获取该用户的联系人并更新联系人计数。最后,我们找出我们列表中尚未出现的最紧密联系的用户:

while len(friends) < 150:
    for user_id, count in best_friends:
        if user_id in friends:
            # Already have this user, move to next one
            continue
        friends[user_id] = get_friends(t, user_id) 
        for friend in friends[user_id]: 
            friend_count[friend] += 1
        best_friends = sorted(friend_count.items(), key=itemgetter(1), reverse=True)
        break

代码将循环并继续,直到我们达到 150 个用户。

你可能想将这些值设置得更低,比如 40 或 50 个用户(或者甚至暂时跳过这段代码)。然后,完成本章的代码,感受一下结果是如何工作的。之后,将循环中的用户数量重置为 150,让代码运行几小时,然后回来重新运行后面的代码。

由于收集这些数据可能花费了近 3 个小时,因此将其保存下来是个好主意,以防我们不得不关闭电脑。使用json库,我们可以轻松地将我们的联系人字典保存到文件中:

import json
friends_filename = os.path.join(data_folder, "python_friends.json")
with open(friends_filename, 'w') as outf: 
    json.dump(friends, outf)

如果你需要加载文件,请使用json.load函数:

with open(friends_filename) as inf:
    friends = json.load(inf)

创建一个图

在我们的实验的这个阶段,我们有一个用户及其联系人的列表。这给我们提供了一个图,其中一些用户是其他用户的联系人(尽管不一定反过来)。

是一组节点和边。节点通常是感兴趣的物体——在这个案例中,它们是我们的用户。这个初始图中的边表示用户 A 是用户 B 的联系人。我们称之为有向图,因为节点的顺序很重要。仅仅因为用户 A 是用户 B 的联系人,并不意味着用户 B 也是用户 A 的联系人。下面的示例网络展示了这一点,以及一个与用户 B 是朋友并且反过来也被用户 B 添加为联系人的用户 C:

在 Python 中,用于处理图(包括创建、可视化和计算)的最佳库之一被称为NetworkX

再次强调,你可以使用 Anaconda 安装 NetworkX:conda install networkx

首先,我们使用 NetworkX 创建一个有向图。按照惯例,在导入 NetworkX 时,我们使用缩写 nx(尽管这并非必需)。代码如下:

import networkx as nx 
G = nx.DiGraph()

我们将只可视化我们的关键用户,而不是所有朋友(因为有很多这样的朋友,而且很难可视化)。我们获取主要用户,然后将它们作为节点添加到我们的图中:

main_users = friends.keys() 
G.add_nodes_from(main_users)

接下来我们设置边。如果第二个用户是第一个用户的友人,我们就从用户到另一个用户创建一条边。为此,我们遍历给定用户的全部友人。我们确保这个友人是我们主要用户之一(因为我们目前对可视化其他用户不感兴趣),如果他们是,我们就添加这条边。

for user_id in friends:
    for friend in friends[user_id]:
        if str(friend) in main_users: 
            G.add_edge(user_id, friend) 

我们现在可以使用 NetworkX 的 draw 函数来可视化网络,该函数使用 matplotlib。为了在我们的笔记本中获得图像,我们使用 matplotlib 的 inline 函数,然后调用 draw 函数。代码如下:

 %matplotlib inline 
 nx.draw(G)

结果有点难以理解;它们只显示了节点环,很难从数据集中得出具体的东西。根本不是一张好图:

我们可以通过使用 pyplot 来处理由 NetworkX 用于图形绘制的图像的创建,使图变得更好。导入pyplot,创建一个更大的图,然后调用 NetworkX 的draw函数来增加图像的大小:

from matplotlib import pyplot as plt
plt.figure(3,figsize=(20,20))
nx.draw(G, alpha=0.1, edge_color='b')

通过放大图并添加透明度,现在可以清楚地看到图的轮廓:

在我的图中,有一个主要用户群体,他们彼此之间高度连接,而大多数其他用户几乎没有连接。正如你所见,它在中心部分连接得非常好!

这实际上是我们选择新用户的方法的一个特性——我们选择那些在我们图中已经与许多其他节点相连的用户,因此他们很可能只是使这个群体更大。对于社交网络来说,用户拥有的连接数通常遵循幂律。一小部分用户拥有许多连接,而其他人只有几个。这个图的形状通常被描述为具有长尾

通过放大图的某些部分,你可以开始看到结构。可视化和分析这样的图很困难——我们将在下一节中看到一些使这个过程更容易的工具。

创建相似度图

这个实验的最后一步是根据用户共享的朋友数量推荐用户。如前所述,我们的逻辑是,如果两个用户有相同的朋友,他们非常相似。我们可以基于这个基础向另一个用户推荐一个用户。

因此,我们将从现有的图(其中包含与友谊相关的边)中提取信息,创建一个新的图。节点仍然是用户,但边将是加权边。加权边就是一个具有权重属性的边。逻辑是这样的:较高的权重表示两个节点之间的相似性比较低的权重更高。这取决于上下文。如果权重代表距离,那么较低的权重表示更高的相似性。

对于我们的应用,权重将是连接该边的两个用户之间的相似度(基于他们共享的朋友数量)。这个图还有一个属性,即它不是有向的。这是由于我们的相似度计算,其中用户 A 对用户 B 的相似度与用户 B 对用户 A 的相似度相同。

其他相似度测量是有向的。一个例子是相似用户的比率,即共同朋友数除以用户的总朋友数。在这种情况下,你需要一个有向图。

计算两个列表之间相似度的方法有很多。例如,我们可以计算两个共同的朋友数量。然而,这个度量对于朋友数量较多的人总是更高。相反,我们可以通过除以两个总共有的不同朋友数量来归一化它。这被称为Jaccard 相似度

Jaccard 相似度,总是在 0 和 1 之间,表示两个集合的重叠百分比。正如我们在第二章“使用 scikit-learn 估计量进行分类”中看到的,归一化是数据挖掘练习的重要部分,通常也是一件好事。有些边缘情况你可能不会对数据进行归一化,但默认情况下应该先进行归一化。

要计算 Jaccard 相似度,我们将两个集合交集除以两个集合的并集。这些是集合操作,而我们是有列表,所以我们需要先将朋友列表转换为集合。代码如下:

friends = {user: set(friends[user]) for user in friends}

然后,我们创建一个函数来计算两组朋友列表之间的相似度。代码如下:

def compute_similarity(friends1, friends2):
    return len(friends1 & friends2) / (len(friends1 | friends2)  + 1e-6)

我们在相似度上加上 1e-6(或 0.000001),以确保在两个用户都没有朋友的情况下不会出现除以零的错误。这个值足够小,不会真正影响我们的结果,但足够大,以确保不是零。

从这里,我们可以创建用户之间相似度的加权图。我们将在本章的其余部分大量使用它,所以我们将创建一个函数来执行这个动作。让我们看看阈值参数:

def create_graph(followers, threshold=0): 
    G = nx.Graph()
    for user1 in friends.keys(): 
        for user2 in friends.keys(): 
            if user1 == user2:
                continue
            weight = compute_similarity(friends[user1], friends[user2])
            if weight >= threshold:
                G.add_node(user1) 
                G.add_node(user2)
                G.add_edge(user1, user2, weight=weight)
    return G

现在,我们可以通过调用这个函数来创建一个图。我们从一个没有阈值开始,这意味着所有链接都被创建。代码如下:

G = create_graph(friends)

结果是一个非常强连通的图——所有节点都有边,尽管其中许多边的权重将是 0。我们将通过绘制与边权重成比例的线宽的图来查看边的权重——较粗的线表示较高的权重。

由于节点数量众多,使图更大一些,以便更清晰地感知连接是有意义的:

plt.figure(figsize=(10,10))

我们将要绘制带有权重的边,因此我们需要先绘制节点。NetworkX 使用布局来确定节点和边的位置,基于某些标准。可视化网络是一个非常困难的问题,尤其是随着节点数量的增加。存在各种可视化网络的技术,但它们的工作程度在很大程度上取决于你的数据集、个人偏好和可视化的目标。我发现 spring_layout 工作得相当好,但其他选项,如 circular_layout(如果没有其他选项可用,这是一个很好的默认选项)、random_layout、shell_layout 和 spectral_layout 也存在,并且在其他选项失败的地方有它们的应用。

访问networkx.lanl.gov/reference/drawing.html获取 NetworkX 中布局的更多详细信息。尽管它增加了一些复杂性,但draw_graphviz选项工作得相当好,值得调查以获得更好的可视化效果。在现实世界的应用中,这非常值得考虑。

让我们使用spring_layout进行可视化:

pos = nx.spring_layout(G)

使用我们的pos布局,我们可以然后定位节点:

nx.draw_networkx_nodes(G, pos)

接下来,我们绘制边。为了获取权重,我们遍历图中的边(按特定顺序)并收集权重:

edgewidth = [ d['weight'] for (u,v,d) in G.edges(data=True)]

然后绘制边:

nx.draw_networkx_edges(G, pos, width=edgewidth)

结果将取决于你的数据,但通常会显示一个具有大量节点且连接相当紧密的图,以及一些与其他网络部分连接较差的节点。

与前一个图相比,这个图的不同之处在于,边基于我们的相似性度量来确定节点之间的相似性,而不是基于一个人是否是另一个人的朋友(尽管两者之间有相似之处!)。我们现在可以开始从这个图中提取信息,以便做出我们的推荐。

寻找子图

从我们的相似性函数中,我们可以简单地为每个用户对结果进行排名,返回最相似的用户作为推荐——就像我们在产品推荐中所做的那样。这有效,并且确实是执行此类分析的一种方式。

相反,我们可能想要找到所有用户都彼此相似的集群。我们可以建议这些用户开始组建一个群组,为这个细分市场创建广告,或者甚至直接使用这些集群来进行推荐。找到这些相似用户的集群是一项称为聚类分析的任务。

聚类分析是一个困难的任务,它具有分类任务通常不具备的复杂性。例如,评估分类结果相对容易——我们将我们的结果与真实情况(来自我们的训练集)进行比较,看看我们正确了多少百分比。然而,在聚类分析中,通常没有真实情况。评估通常归结为根据我们对聚类应如何看起来的一些先入为主的观念,来判断聚类是否合理。

聚类分析的一个复杂之处在于,模型不能针对预期结果进行训练以学习——它必须使用基于聚类数学模型的某种近似,而不是用户希望通过分析实现的目标。

由于这些问题,聚类分析更像是探索性工具,而不是预测工具。一些研究和应用使用聚类进行分析,但作为预测模型的有用性取决于分析师选择参数并找到“看起来正确”的图,而不是特定的评估指标。

连接组件

聚类最简单的方法之一是在图中找到连接组件。连接组件是图中通过边连接的一组节点。并非所有节点都需要相互连接才能成为连接组件。然而,为了使两个节点处于同一个连接组件中,必须存在一种方法,可以通过沿着边移动从该组件中的一个节点“旅行”到另一个节点。

连接组件在计算时不会考虑边权重;它们只检查边的存在。因此,接下来的代码将移除任何权重低的边。

NetworkX 有一个用于计算连接组件的函数,我们可以调用我们的图。首先,我们使用我们的create_graph函数创建一个新的图,但这次我们传递一个阈值为 0.1,以只获取权重至少为 0.1 的边,这表明两个节点用户之间有 10%的共同关注者:

G = create_graph(friends, 0.1)

然后,我们使用 NetworkX 在图中找到连接组件:

sub_graphs = nx.connected_component_subgraphs(G)

为了获得图的大小感,我们可以遍历组并打印一些基本信息:

for i, sub_graph in enumerate(sub_graphs):
    n_nodes = len(sub_graph.nodes()) 
    print("Subgraph {0} has {1} nodes".format(i, n_nodes))

结果将告诉你每个连接组件有多大。我的结果有一个包含 62 个用户的大子图和许多小子图,每个小子图有十几个或更少的用户。

我们可以调整阈值来改变连接组件。这是因为更高的阈值连接节点的边更少,因此将会有更小的连接组件,并且它们的数量会更多。我们可以通过运行前面代码中的更高阈值来看到这一点:

G = create_graph(friends, 0.25) 
sub_graphs = nx.connected_component_subgraphs(G) 
for i, sub_graph in enumerate(sub_graphs): 
    n_nodes = len(sub_graph.nodes()) 
    print("Subgraph {0} has {1} nodes".format(i, n_nodes))

上述代码给出了许多更小的子图,数量也更多。我最大的集群至少被分成三个部分,而且没有任何集群的用户数量超过 10 个。以下是一个示例集群,以及该集群内的连接也显示出来。请注意,由于这是一个连通分量,该分量中的节点到图中其他节点的边不存在(至少,当阈值设置为 0.25 时)。

我们可以绘制整个图,用不同的颜色显示每个连通分量。由于这些连通分量之间没有连接,实际上在单个图上绘制这些分量几乎没有意义。这是因为节点和分量的位置是任意的,这可能会使可视化变得混乱。相反,我们可以在单独的子图中分别绘制每个分量。

在新单元格中,获取连通分量及其数量:

sub_graphs = nx.connected_component_subgraphs(G) 
n_subgraphs = nx.number_connected_components(G)

sub_graphs是一个生成器,而不是连通分量的列表。因此,使用nx.number_connected_components来找出有多少连通分量;不要使用len,因为它不工作,因为 NetworkX 存储此信息的方式。这就是为什么我们需要在这里重新计算连通分量。

创建一个新的 pyplot 图,并留出足够的空间来显示所有我们的连通分量。因此,我们允许图的大小随着连通分量的数量增加而增加。

接下来,遍历每个连通分量并为每个分量添加一个子图。add_subplot的参数是子图的行数、列数以及我们感兴趣的子图的索引。我的可视化使用三列,但你可以尝试使用其他值而不是三(只需记得更改两个值):

fig = plt.figure(figsize=(20, (n_subgraphs * 3)))
for i, sub_graph in enumerate(sub_graphs): 
    ax = fig.add_subplot(int(n_subgraphs / 3) + 1, 3, i + 1)
    ax.get_xaxis().set_visible(False) 
    ax.get_yaxis().set_visible(False)
    pos = nx.spring_layout(G) 
    nx.draw_networkx_nodes(G, pos, sub_graph.nodes(), ax=ax, node_size=500) 
    nx.draw_networkx_edges(G, pos, sub_graph.edges(), ax=ax)

结果可视化每个连通分量,让我们对每个连通分量中的节点数量以及它们的连接程度有一个概念。

如果你没有在你的图表上看到任何东西,尝试重新运行该行:

sub_graphs = nx.connected_component_subgraphs(G)

sub_graphs对象是一个生成器,在用过后会被“消耗”。

优化标准

我们找到这些连通分量的算法依赖于阈值参数,该参数决定了是否将边添加到图中。这反过来又直接决定了我们发现的连通分量的数量和大小。从这里,我们可能想要确定一个关于哪个是最佳阈值的观念。这是一个非常主观的问题,没有明确的答案。这是任何聚类分析任务的主要问题。

然而,我们可以确定我们认为一个好的解决方案应该是什么样子,并基于这个想法定义一个度量标准。作为一个一般规则,我们通常希望解决方案是这样的:

  • 同一集群(连通分量)中的样本彼此之间高度相似

  • 不同集群中的样本彼此之间高度不相似

轮廓系数是一个度量这些点的指标。给定一个单独的样本,我们定义轮廓系数如下:

图片

其中a簇内距离或样本簇中其他样本的平均距离,而簇间距离或到下一个最近簇中其他样本的平均距离。

为了计算整体的轮廓系数,我们取每个样本的轮廓系数的平均值。一个提供接近最大值 1 的轮廓系数的聚类意味着簇中的样本彼此相似,并且这些簇分布得很广。接近 0 的值表明簇全部重叠,簇之间几乎没有区别。接近最小值-1 的值表明样本可能位于错误的簇中,也就是说,它们在其他簇中会更好。

使用这个指标,我们希望找到一个解决方案(即阈值的一个值),通过改变阈值参数来最大化轮廓系数。为此,我们创建一个函数,该函数接受阈值作为参数并计算轮廓系数。

然后我们将这个矩阵传递给 SciPy 的优化模块,其中包含用于通过改变一个参数来找到函数最小值的minimize函数。虽然我们感兴趣的是最大化轮廓系数,但 SciPy 没有最大化函数。相反,我们最小化轮廓系数的倒数(这基本上是同一件事)。

scikit-learn 库有一个用于计算轮廓系数的函数,sklearn.metrics.silhouette_score;然而,它没有修复 SciPy 最小化函数所需的函数格式。最小化函数需要将变量参数放在第一位(在我们的情况下,是阈值值),任何参数都放在其后。在我们的例子中,我们需要将朋友字典作为参数传递,以便计算图。

如果没有至少两个节点(为了计算距离),轮廓系数是没有定义的。在这种情况下,我们将问题范围定义为无效。有几种处理方法,但最简单的是返回一个非常差的分数。在我们的例子中,轮廓系数可以取的最小值是-1,我们将返回-99 来表示无效问题。任何有效解决方案的得分都将高于这个值。

下面的函数结合了所有这些问题,它接受一个阈值值和一个朋友列表,并计算轮廓系数。它是通过使用 NetworkX 的to_scipy_sparse_matrix函数从图中构建一个矩阵来做到这一点的。

import numpy as np
from sklearn.metrics import silhouette_score

def compute_silhouette(threshold, friends):
    G = create_graph(friends, threshold=threshold) 
    if len(G.nodes()) < 2:
        return -99
    sub_graphs = nx.connected_component_subgraphs(G)

    if not (2 <= nx.number_connected_components() < len(G.nodes()) - 1): 
        return -99

    label_dict = {}
    for i, sub_graph in enumerate(sub_graphs): 
        for node in sub_graph.nodes(): 
            label_dict[node] = i

    labels = np.array([label_dict[node] for node in G.nodes()])
    X = nx.to_scipy_sparse_matrix(G).todense()
    X = 1 - X
    return silhouette_score(X, labels, metric='precomputed')

对于评估稀疏数据集,我建议您查看 V-Measure 或调整互信息。这两个都在 scikit-learn 中实现,但它们在执行评估时具有非常不同的参数。

在 scikit-learn 中,Silhouette Coefficient 的实现,在撰写本文时,不支持稀疏矩阵。因此,我们需要调用todense函数。通常来说,这并不是一个好主意——稀疏矩阵通常被用于数据通常不应该以密集格式存在的情况。在这种情况下,这将是可行的,因为我们的数据集相对较小;然而,不要尝试在更大的数据集上这么做。

这里发生了两种形式的求逆。第一种是取相似度的逆来计算距离函数;这是必需的,因为 Silhouette Coefficient 只接受距离。第二种是将 Silhouette Coefficient 分数取反,这样我们就可以使用 SciPy 的优化模块进行最小化。

最后,我们创建了一个我们将要最小化的函数。这个函数是compute_silhouette函数的逆,因为我们希望较低的分数更好。我们可以在compute_silhouette函数中这么做——我已经在这里将它们分开以阐明涉及的不同步骤。

def inverted_silhouette(threshold, friends):
    return -compute_silhouette(threshold, friends)

此函数从一个原始函数创建一个新的函数。当调用新函数时,所有相同的参数和关键字都会传递给原始函数,并且返回值会被返回,只是在返回之前,这个返回值会被取反。

现在我们可以进行实际的优化了。我们调用我们定义的逆compute_silhouette函数上的最小化函数:

from scipy.optimize import minimize
result = minimize(inverted_silhouette, 0.1, args=(friends,))

此函数运行起来会花费相当长的时间。我们的图形创建函数并不快,计算 Silhouette Coefficient 的函数也不快。减小maxiter参数的值会导致执行更少的迭代,但我们会面临找到次优解的风险。

运行此函数,我得到了一个阈值为 0.135,返回了 10 个组件。最小化函数返回的分数是-0.192。然而,我们必须记住我们取了它的反。这意味着我们的分数实际上是 0.192。这个值是正的,这表明簇倾向于比不分离得更好(这是好事)。我们可以运行其他模型并检查它是否会产生更好的分数,这意味着簇被更好地分离。

我们可以使用这个结果来推荐用户——如果一个用户在特定的连通组件中,那么我们可以推荐该组件中的其他用户。这个推荐遵循我们使用 Jaccard 相似度来找到用户之间良好连接的做法,我们使用连通组件将它们分成簇,以及我们使用优化技术来找到在这种设置下的最佳模型。

然而,许多用户可能根本不连通,因此我们将使用不同的算法为他们找到簇。我们将在第十章聚类新闻文章*中看到其他聚类分析方法。

摘要

在本章中,我们研究了社交网络中的图以及如何在它们上执行聚类分析。我们还研究了如何通过使用我们在第六章中创建的分类模型(第六章,《使用朴素贝叶斯进行社交媒体洞察》)来保存和加载 scikit-learn 模型。

我们创建了一个来自社交网络 Twitter 的朋友图。然后,我们根据他们的朋友来检查两个用户之间的相似性。拥有更多共同朋友的用户被认为是更相似的,尽管我们通过考虑他们拥有的总朋友数来对此进行归一化。这是一种常用的方法,根据相似用户推断知识(如年龄或一般讨论主题)。我们可以使用这种逻辑为其他人推荐用户——如果他们关注用户 X,而用户 Y 与用户 X 相似,他们可能会喜欢用户 Y。这在许多方面与之前章节中提到的基于交易的相似性相似。

本分析的目标是为用户提供建议,而我们使用聚类分析使我们能够找到相似用户的集群。为此,我们在基于这种相似性度量创建的加权图上找到了连通组件。我们使用了 NetworkX 包来创建图、使用我们的图以及找到这些连通组件。

然后,我们使用了轮廓系数,这是一个评估聚类解决方案好坏的指标。根据簇内距离和簇间距离的概念,较高的分数表示更好的聚类。SciPy 的优化模块被用来找到最大化这个值的解决方案。

在本章中,我们看到了一些对立的概念在行动中的体现。相似性是两个对象之间的度量,其中较高的值表示这些对象之间有更多的相似性。相反,距离是一个度量,其中较低的值表示有更多的相似性。我们看到的另一个对立是损失函数,其中较低的分数被认为是更好的(也就是说,我们损失更少)。它的对立面是得分函数,其中较高的分数被认为是更好的。

为了扩展本章的工作,检查 scikit-learn 中的 V-measure 和调整互信息得分。这些取代了本章中使用的轮廓系数。最大化这些指标得到的集群是否比轮廓系数的集群更好?进一步地,如何判断?通常,聚类分析的问题是你不能客观地判断,可能需要人工干预来选择最佳选项。

在下一章中,我们将看到如何从另一种新型数据——图像中提取特征。我们将讨论如何使用神经网络来识别图像中的数字,并开发一个程序来自动击败 CAPTCHA 图像。

第八章:使用神经网络击败 CAPTCHA

图像对数据挖掘者提出了有趣且困难的挑战。直到最近,在分析图像以提取信息方面只取得了少量进展。然而,最近,例如在自动驾驶汽车方面的进展,在很短的时间内取得了显著的进步。最新的研究提供了可以理解图像的算法,用于商业监控、自动驾驶车辆和人员识别。

图像中包含大量的原始数据,而编码图像的标准方法——像素——本身并不具有太多信息。图像和照片也可能模糊、过于接近目标、过暗、过亮、缩放、裁剪、倾斜,或者任何其他可能导致计算机系统在提取有用信息时出现混乱的问题。神经网络可以将这些低级特征组合成更高级的模式,这些模式更有能力进行泛化和处理这些问题。

在本章中,我们通过使用神经网络预测 CAPTCHA 中的每个字母来提取图像中的文本数据。CAPTCHA 是设计得对人类来说容易解决而对计算机来说难以解决的图像,正如其首字母缩略词:完全自动化的公开图灵测试,用于区分计算机和人类。许多网站使用它们作为注册和评论系统,以阻止自动化程序向其网站发送虚假账户和垃圾评论。

这些测试有助于阻止程序(机器人)使用网站,例如一个旨在自动将新用户注册到网站上的机器人。我们扮演这样的垃圾邮件发送者的角色,试图绕过保护在线论坛发帖的 CAPTCHA 系统。该网站受到 CAPTCHA 的保护,这意味着除非我们通过测试,否则我们无法发帖。

本章涵盖的主题包括:

  • 神经网络

  • 创建我们自己的 CAPTCHA 和字母数据集

  • 用于处理图像数据的 scikit-image 库

  • 从图像中提取基本特征

  • 使用神经网络进行更大规模的分类任务

  • 通过后处理提高性能

  • 人工神经网络

人工神经网络

神经网络是一类最初基于人类大脑工作方式设计的算法。然而,现代的进步通常基于数学而不是生物洞察。神经网络是一组相互连接的神经元。每个神经元是其输入的简单函数,这些输入通过某种函数组合起来生成输出:

图片

定义神经元处理功能的函数可以是任何标准函数,例如输入的线性组合,这被称为激活函数。为了使常用的学习算法能够工作,我们需要激活函数是可导的平滑的。一个常用的激活函数是逻辑函数,其定义如下(k通常简单地取 1,x是输入到神经元的值,L 通常是 1,即函数的最大值):

图片

该图从-6 到+6 的值如下所示。红色线条表示当x为零时值为 0.5,但随着x的增加,函数迅速上升到 1.0,当x减少时,迅速下降到-1.0。

图片

每个神经元都会接收其输入,然后根据这些值计算输出。神经网络可以被视为这些神经元相互连接的集合,它们在数据挖掘应用中可以非常强大。这些神经元的组合、它们如何相互配合以及它们如何组合来学习模型是机器学习中最强大的概念之一。

神经网络简介

对于数据挖掘应用,神经元的排列通常是分层的。第一层被称为输入层,它从数据样本中获取输入。这些神经元的输出被计算,然后传递到下一层的神经元。这被称为前馈神经网络。在本章中,我们将简单地称之为神经网络,因为它们是最常用的类型,也是本章唯一使用的类型。还有其他类型的神经网络,用于不同的应用。我们将在第十一章,使用深度神经网络的图像目标检测中看到另一种类型的网络。

一层的输出成为下一层的输入,一直持续到我们达到最后一层:输出层。这些输出代表了神经网络的预测,作为分类。输入层和输出层之间的任何神经元层都被称为隐藏层,因为它们学习的数据表示对于人类来说不是直观可解释的。大多数神经网络至少有三层,尽管大多数现代应用使用的网络比这多得多。

图片

通常,我们考虑全连接层。每一层的每个神经元的输出都流向下一层的所有神经元。虽然我们定义了全连接网络,但在训练过程中,许多权重将被设置为 0,从而有效地移除了这些链接。此外,许多这些权重可能在训练后仍然保留非常小的值。

除了是神经网络概念上更简单的形式之一外,全连接神经网络在编程上比其他连接模式更简单、更高效。

请参阅第十一章,使用深度神经网络在图像中进行目标检测,了解不同类型神经网络的调查,包括专门用于图像处理的层。

由于神经元的函数通常是逻辑函数,并且神经元与下一层完全连接,因此构建和训练神经网络所需的参数必须是其他因素。

  • 神经网络的第一个因素在构建阶段:神经网络的大小和形状。这包括神经网络有多少层以及每个隐藏层中有多少个神经元(输入和输出层的大小通常由数据集决定)。

  • 神经网络的第二个参数在训练阶段确定:神经元之间连接的权重。当一个神经元连接到另一个神经元时,这个连接有一个相关的权重,该权重会乘以信号(第一个神经元的输出)。如果连接的权重为 0.8,则神经元被激活,并输出值为 1,因此传递给下一个神经元的输入是 0.8。如果第一个神经元未激活且值为 0,则该值保持在 0。

网络大小和训练良好的权重的组合决定了神经网络在分类时的准确性。前一句话中的“适当”一词也不一定意味着更大,因为过大的神经网络可能需要很长时间来训练,并且更容易过拟合训练数据。

权重可以随机设置以开始,但在训练阶段会进行更新。将权重设置为零通常不是一个好主意,因为网络中的所有神经元最初都表现得非常相似!随机设置权重给每个神经元在学习过程中提供了一个不同的角色,这个角色可以通过训练得到改善。

在这种配置下的神经网络是一个分类器,可以用来根据输入预测数据样本的目标,就像我们在前几章中使用过的分类算法一样。但首先,我们需要一个用于训练和测试的数据集。

神经网络是近年来数据挖掘领域最大的进步。这可能会让你想:为什么还要学习其他类型的分类算法呢?虽然神经网络在几乎所有领域都是最先进的(至少,现在是这样),但学习其他分类器的理由是神经网络通常需要更多的数据才能有效工作,并且学习时间较长。如果你没有大数据,你可能会从另一个算法中获得更好的结果。

创建数据集

在本章中,为了使内容更加生动,让我们扮演一下坏人的角色。我们想要创建一个能够击败 CAPTCHAs 的程序,让我们的评论垃圾邮件程序能够在某个人的网站上做广告。需要注意的是,我们的 CAPTCHAs 将比现在网络上使用的那些稍微简单一些,而且垃圾邮件并不是一件很体面的事情。

我们今天扮演坏人,但请不要将其用于现实世界的网站。扮演坏人的一个原因是为了帮助提高我们网站的安全性,通过寻找它的问题。

我们的实验将简化 CAPTCHA,使其仅包含四个字母的单独英语单词,如下面的图像所示:

我们的目标是创建一个程序,可以从这样的图像中恢复单词。为此,我们将使用以下四个步骤:

  1. 将图像分解成单个字母。

  2. 对每个单独的字母进行分类。

  3. 将字母重新组合成一个单词。

  4. 使用词典对单词进行排序,以尝试纠正错误。

我们的 CAPTCHA 破解算法将做出以下假设。首先,单词将是一个完整的、有效的四个字符的英语单词(实际上,我们使用相同的词典来创建和破解 CAPTCHAs)。其次,单词将只包含大写字母。不会使用符号、数字或空格。

我们将使问题比仅仅识别字母稍微困难一些,通过对文本执行剪切变换,以及不同的剪切和缩放率。

绘制基本的 CAPTCHAs

在我们开始对 CAPTCHAs 进行分类之前,我们首先需要一个用于学习的数据集。在本节中,我们将生成自己的数据以进行数据挖掘。

在更现实的应用中,你可能想要使用现有的 CAPTCHA 服务来生成数据,但就本章的目的而言,我们自己的数据就足够了。可能出现的一个问题是,当我们自己创建数据集时,我们会在数据的工作方式上编码我们的假设,然后将这些相同的假设应用到我们的数据挖掘训练中。

我们的目标是绘制一个带有单词的图像,并对其进行剪切变换。我们将使用 PIL 库来绘制我们的 CAPTCHAs,并使用scikit-image库来执行剪切变换。scikit-image库可以以 NumPy 数组格式读取图像,而 PIL 可以导出这种格式,这使得我们可以使用这两个库。

PIL 和 scikit-image 都可以通过 Anaconda 安装。然而,我建议通过其替代品pillow来获取 PIL:

conda install pillow scikit-image

首先,我们导入必要的库和模块。我们按照以下方式导入 NumPy 和图像绘制函数:

import numpy as np 
from PIL import Image, ImageDraw, ImageFont 
from skimage import transform as tf

然后我们创建生成 CAPTCHAs 的基本函数。这个函数接受一个单词和一个剪切值(通常在 0 和 0.5 之间),以返回一个 NumPy 数组格式的图像。我们允许用户设置结果的图像大小,因为我们还将使用这个函数来生成单个字母的训练样本:

def create_captcha(text, shear=0, size=(100, 30), scale=1):
    im = Image.new("L", size, "black")
    draw = ImageDraw.Draw(im)
    font = ImageFont.truetype(r"bretan/Coval-Black.otf", 22) 
    draw.text((0, 0), text, fill=1, font=font)
    image = np.array(im)
    affine_tf = tf.AffineTransform(shear=shear)
    image = tf.warp(image, affine_tf)
    image = image / image.max()
    # Apply scale
    shape = image.shape
    shapex, shapey = (int(shape[0] * scale), int(shape[1] * scale))
    image = tf.resize(image, (shapex, shapey))
    return image

在这个函数中,我们使用 L 格式创建一个新的图像,这意味着只有黑白像素,并创建一个 ImageDraw 类的实例。这允许我们使用 PIL 在这个图像上绘制。然后我们加载字体,绘制文本,并对它执行 scikit-image 的剪切变换。

您可以从 Open Font Library 在以下链接获取我使用的 Coval 字体:

openfontlibrary.org/en/font/bretan

下载 .zip 文件,并将 Coval-Black.otf 文件提取到与您的 Notebook 相同的目录中。

从这里,我们现在可以很容易地生成图像,并使用 pyplot 显示它们。首先,我们使用 matplotlib 图表的内联显示并导入 pyplot。代码如下:

%matplotlib inline
from matplotlib import pyplot as plt
image = create_captcha("GENE", shear=0.5, scale=0.6)
plt.imshow(image, cmap='Greys')

结果是本节开头显示的图像:我们的 CAPTCHA。这里有一些具有不同剪切和缩放值的其他示例:

image = create_captcha("SEND", shear=0.1, scale=1.0)
plt.imshow(image, cmap='Greys')

image = create_captcha("BARK", shear=0.8, scale=1.0)
plt.imshow(image, cmap='Greys')

这里是一个放大到 1.5 倍的变体。虽然它看起来与上面的 BONE 图像相似,但请注意 x 轴和 y 轴的值更大:

image = create_captcha("WOOF", shear=0.25, scale=1.5)
plt.imshow(image, cmap='Greys')

将图像分割成单个字母

我们的 CAPTCHA 是单词。我们不会构建一个可以识别成千上万可能单词的分类器,而是将问题分解成更小的问题:预测字母。

我们的实验使用的是英语,并且全部大写,这意味着对于每个字母,我们都有 26 个类别来预测。如果您在其他语言中尝试这些实验,请记住输出类别的数量将需要改变。

我们算法中击败这些 CAPTCHA 的第一步是分割单词以发现其中的每个字母。为此,我们将创建一个函数来找到图像中连续的黑色像素区域,并将它们作为子图像提取出来。这些就是(或者至少应该是)我们的字母。scikit-image 函数有执行这些操作的工具。

我们的函数将接受一个图像,并返回一个子图像列表,其中每个子图像是图像中原始单词的一个字母。首先,我们需要做的是检测每个字母的位置。为此,我们将使用 scikit-image 中的标签函数,该函数可以找到具有相同值的像素的连通集合。这与我们在 第七章使用图挖掘遵循建议 中的连通组件发现有相似之处。

from skimage.measure import label, regionprops

def segment_image(image):
    # label will find subimages of connected non-black pixels
    labeled_image = label(image>0.2, connectivity=1, background=0)
    subimages = []
    # regionprops splits up the subimages
    for region in regionprops(labeled_image):
        # Extract the subimage
        start_x, start_y, end_x, end_y = region.bbox
        subimages.append(image[start_x:end_x,start_y:end_y])
        if len(subimages) == 0:
            # No subimages found, so return the entire image
            return [image,]
    return subimages

然后,我们可以使用此函数从示例 CAPTCHA 中获取子图像:

subimages = segment_image(image)

我们还可以查看这些子图像:

f, axes = plt.subplots(1, len(subimages), figsize=(10, 3)) 
for i in range(len(subimages)): 
    axes[i].imshow(subimages[i], cmap="gray")

结果看起来可能像这样:

如你所见,我们的图像分割做得相当不错,但结果仍然相当杂乱,有前一个字母的碎片显示。这是可以接受的,甚至更可取。虽然在对具有常规噪声的数据进行训练时会使我们的训练变得更差,但使用具有随机噪声的数据进行训练实际上可以使其变得更好。一个原因是,底层的数据挖掘模型学会了重要的方面,即非噪声部分而不是训练数据集中固有的特定噪声。过多和过少的噪声之间的界限很微妙,这可能会很难正确建模。在验证集上进行测试是确保你的训练正在改进的好方法。

一个重要的注意事项是,此代码在查找字母方面并不一致。较低的剪切值通常会导致准确分割的图像。例如,以下是分割上面 WOOF 示例的代码:

image = create_captcha("WOOF", shear=0.25, scale=1.5)
subimages = segment_image(image)
f, axes = plt.subplots(1, len(subimages), figsize=(10, 3), sharey=True) 
for i in range(len(subimages)): 
    axes[i].imshow(subimages[i], cmap="gray")

相比之下,较高的剪切值无法正确分割。例如,以下是之前提到的 BARK 示例:

注意由方形分割引起的大面积重叠。对于本章代码的一个改进建议是,通过找到非方形分割来改进我们的分割方法。

创建训练数据集

使用我们已定义的函数,我们现在可以创建一个包含不同剪切值的字母数据集。从这些数据中,我们将训练一个神经网络来识别图像中的每个字母。

我们首先设置随机状态和数组,该数组包含我们将随机选择的字母、剪切值和缩放值的选项。这里没有太多惊喜,但如果你之前没有使用过 NumPy 的 arange 函数,它类似于 Python 的 range 函数——只不过这个函数可以与 NumPy 数组一起工作,并且允许步长为浮点数。代码如下:

from sklearn.utils import check_random_state
random_state = check_random_state(14) 
letters = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ") 
shear_values = np.arange(0, 0.5, 0.05)
scale_values = np.arange(0.5, 1.5, 0.1)

然后,我们创建一个函数(用于生成训练数据集中的单个样本),该函数会随机选择一个字母、一个剪切值和一个从可用选项中选择的缩放值。

def generate_sample(random_state=None): 
    random_state = check_random_state(random_state) 
    letter = random_state.choice(letters) 
    shear = random_state.choice(shear_values)
    scale = random_state.choice(scale_values)
    # We use 30,30 as the image size to ensure we get all the text in the image
    return create_captcha(letter, shear=shear, size=(30, 30), scale=scale), letters.index(letter)

我们返回字母的图像,以及表示图像中字母的目标值。我们的类别将为 A 为 0,B 为 1,C 为 2,依此类推。

在函数块外部,我们现在可以调用此代码来生成一个新的样本,然后使用 pyplot 展示它:

image, target = generate_sample(random_state) 
plt.imshow(image, cmap="Greys") 
print("The target for this image is: {0}".format(target))

生成的图像只有一个字母,具有随机的剪切和缩放值。

现在,我们可以通过多次调用此函数来生成所有数据。然后我们将数据放入 NumPy 数组中,因为它们比列表更容易处理。代码如下:

dataset, targets = zip(*(generate_sample(random_state) for i in range(1000))) 
dataset = np.array([tf.resize(segment_image(sample)[0], (20, 20)) for sample in dataset])
dataset = np.array(dataset, dtype='float') 
targets = np.array(targets)

我们的目标是介于 0 和 26 之间的整数,每个代表字母表中的一个字母。神经网络通常不支持单个神经元的多值,而是更喜欢有多个输出,每个输出值为 0 或 1。我们对目标进行 one-hot 编码,为每个样本生成一个有 26 个输出的目标数组,如果该字母可能,则使用接近 1 的值,否则使用接近 0 的值。代码如下:

from sklearn.preprocessing import OneHotEncoder 
onehot = OneHotEncoder() 
y = onehot.fit_transform(targets.reshape(targets.shape[0],1))

从这个输出中,我们知道我们的神经网络输出层将有 26 个神经元。神经网络的目标是根据给定的输入(构成图像的像素)确定要激活哪个神经元。

我们将要使用的库不支持稀疏数组,因此我们需要将我们的稀疏矩阵转换为密集的 NumPy 数组。代码如下:

y = y.todense()
X = dataset.reshape((dataset.shape[0], dataset.shape[1] * dataset.shape[2]))

最后,我们进行训练/测试数据集的划分,以便稍后评估我们的数据:

from sklearn.cross_validation import train_test_split 
X_train, X_test, y_train, y_test = train_test_split(X, y, train_size=0.9)

训练和分类

现在,我们将构建一个神经网络,它将接受图像作为输入并尝试预测图像中包含的是哪个(单个)字母。

我们将使用我们之前创建的单个字母的训练集。数据集本身相当简单。我们有一个 20x20 像素的图像,每个像素为 1(黑色)或 0(白色)。这些代表我们将用作神经网络输入的 400 个特征。输出将是介于 0 和 1 之间的 26 个值,其中较高的值表示关联字母(第一个神经元是 A,第二个是 B,以此类推)是输入图像所代表的字母的可能性更高。

在本章中,我们将使用 scikit-learn 的MLPClassifier作为我们的神经网络。

您需要scikit-learn的较新版本才能使用 MLPClassifier。如果下面的导入语句失败,请在更新 scikit-learn 后重试。您可以使用以下 Anaconda 命令来完成此操作:

conda update scikit-learn

对于其他scikit-learn分类器,我们导入模型类型并创建一个新的模型。下面的构造函数指定我们创建一个包含 100 个节点的隐藏层。输入层和输出层的大小在训练时确定:

from sklearn.neural_network import MLPClassifier
clf = MLPClassifier(hidden_layer_sizes=(100,), random_state=14)

要查看神经网络的内部参数,我们可以使用get_params()函数。这个函数存在于所有的scikit-learn模型中。以下是上述模型的输出。许多这些参数可以提高训练或训练速度。例如,增加学习率可以使模型训练得更快,但可能会错过最佳值:

{'activation': 'relu',
 'alpha': 0.0001,
 'batch_size': 'auto',
 'beta_1': 0.9,
 'beta_2': 0.999,
 'early_stopping': False,
 'epsilon': 1e-08,
 'hidden_layer_sizes': (100,),
 'learning_rate': 'constant',
 'learning_rate_init': 0.001,
 'max_iter': 200,
 'momentum': 0.9,
 'nesterovs_momentum': True,
 'power_t': 0.5,
 'random_state': 14,
 'shuffle': True,
 'solver': 'adam',
 'tol': 0.0001,
 'validation_fraction': 0.1,
 'verbose': False,
 'warm_start': False}

接下来,我们使用标准的 scikit-learn 接口来拟合我们的模型:

clf.fit(X_train, y_train)

我们的网络现在已经学会了每一层之间的权重。我们可以通过检查clf.coefs_来查看这些权重,它是一个连接每一层的 NumPy 数组的列表。例如,从包含 400 个神经元(来自我们每个像素)的输入层到包含 100 个神经元(我们设置的参数)的隐藏层的权重可以通过clf.coefs_[0]获得。此外,隐藏层和输出层(包含 26 个神经元)之间的权重可以通过clf.coefs_[1]获得。这些权重,连同上面的参数,完全定义了我们的训练网络。

现在,我们可以使用这个训练好的网络来预测我们的测试数据集:

y_pred = clf.predict(X_test)

最后,我们评估结果:

from sklearn.metrics import f1_score
f1_score(y_pred=y_pred, y_true=y_test, average='macro')

结果是 0.96,这相当令人印象深刻。这个版本的 F1 分数是基于宏平均,它计算每个类的个体 F1 分数,然后不考虑每个类的大小进行平均。

为了检查这些个体类别的结果,我们可以查看分类报告:

from sklearn.metrics import classification_report
print(classification_report(y_pred=y_pred, y_true=y_test))

我实验的结果如下所示:

             precision    recall  f1-score   support

          0       1.00      1.00      1.00         5
          1       1.00      1.00      1.00         3
          2       1.00      1.00      1.00         3
          3       1.00      1.00      1.00         8
          4       1.00      1.00      1.00         2
          5       1.00      1.00      1.00         4
          6       1.00      1.00      1.00         2
          7       1.00      1.00      1.00         2
          8       1.00      1.00      1.00         7
          9       1.00      1.00      1.00         1
         10       1.00      1.00      1.00         3
         11       1.00      1.00      1.00         4
         12       1.00      0.75      0.86         4
         13       1.00      1.00      1.00         5
         14       1.00      1.00      1.00         4
         15       1.00      1.00      1.00         3
         16       1.00      1.00      1.00         3
         17       1.00      1.00      1.00         7
         18       1.00      1.00      1.00         5
         19       1.00      1.00      1.00         5
         20       1.00      1.00      1.00         3
         21       1.00      1.00      1.00         5
         22       1.00      1.00      1.00         2
         23       1.00      1.00      1.00         4
         24       1.00      1.00      1.00         2
         25       1.00      1.00      1.00         4

avg / total       1.00      0.99      0.99       100

本报告的最终f1-score显示在右下角,倒数第二个数字——0.99。这是微观平均,其中对每个样本计算f1-score,然后计算平均值。这种形式对于相对相似的类别大小更有意义,而宏平均对于不平衡的类别更有意义。

从 API 的角度来看,这很简单,因为scikit-learn隐藏了所有的复杂性。然而,在后台实际上发生了什么?我们如何训练一个神经网络?

反向传播

训练神经网络特别关注以下事项。

  • 第一是网络的大小和形状——有多少层,层的大小以及它们使用的错误函数。虽然存在可以改变其大小和形状的神经网络类型,但最常见的类型,前馈神经网络,很少具有这种能力。相反,其大小在初始化时固定,在本章中是第一层 400 个神经元,隐藏层 100 个,最终层 26 个。通常,形状的训练是元算法的工作,它训练一组神经网络并确定哪个是最有效的,而不仅仅是训练网络本身。

  • 训练神经网络的第二部分是改变神经元之间的权重。在一个标准的神经网络中,一个层的节点通过具有特定权重的边连接到下一层的节点。这些可以随机初始化(尽管存在一些更智能的方法,如自动编码器),但需要调整以允许网络学习训练样本和训练类别之间的关系。

这种调整权重是早期神经网络面临的一个关键问题,在开发出称为反向传播的算法来解决该问题之前。

反向传播backprop)算法是一种将错误归咎于每个神经元的机制。首先,我们考虑神经网络的用法,我们将样本输入到输入层,并观察输出层的哪个神经元被激活,这被称为正向传播。反向传播从输出层反向到输入层,按比例分配错误给网络中的每个权重,这个权重对网络产生的任何错误的影响程度成正比。

变化的量基于两个方面:

  • 神经元激活

  • 激活函数的梯度

第一方面是神经元的激活程度。激活值高的(绝对值)神经元被认为对结果有重大影响,而激活值小的(绝对值)神经元对结果的影响较小。因此,激活值高的神经元周围的权重变化比激活值小的神经元周围的权重变化更大。

权重变化的第二个方面与激活函数的梯度成正比。你使用的许多神经网络将使用相同的激活函数来处理所有神经元,但在很多情况下,为不同层级的神经元(或更少的情况下,同一层级的神经元)使用不同的激活函数是有意义的。激活函数的梯度,结合神经元的激活状态以及分配给该神经元的错误,共同决定了权重变化的大小。

我跳过了反向传播中涉及的数学,因为本书的重点是实际应用。随着你对神经网络的更多使用,了解算法内部发生的事情是有益的。我建议研究反向传播算法的细节,这可以通过一些基本的梯度与导数知识来理解。

预测单词

现在我们已经有了预测单个字母的分类器,我们现在转向我们计划的下一步——预测单词。为此,我们想要预测这些片段中的每个字母,并将这些预测组合起来,以形成给定验证码的预测单词。

我们的功能将接受一个验证码和训练好的神经网络,然后返回预测的单词:

def predict_captcha(captcha_image, neural_network):
    subimages = segment_image(captcha_image)
    # Perform the same transformations we did for our training data
    dataset = np.array([np.resize(subimage, (20, 20)) for subimage in subimages])
    X_test = dataset.reshape((dataset.shape[0], dataset.shape[1] * dataset.shape[2]))
    # Use predict_proba and argmax to get the most likely prediction
    y_pred = neural_network.predict_proba(X_test)
    predictions = np.argmax(y_pred, axis=1)

    # Convert predictions to letters
    predicted_word = str.join("", [letters[prediction] for prediction in predictions])
    return predicted_word

我们现在可以使用以下代码对一个单词进行测试。尝试不同的单词并查看你得到的错误类型,但请记住,我们的神经网络只知道大写字母:

word = "GENE"
captcha = create_captcha(word, shear=0.2) 
print(predict_captcha(captcha, clf))
plt.imshow(captcha, cmap="Greys") 

我们可以将这个方法编码成一个函数,从而更方便地进行预测:

def test_prediction(word, net, shear=0.2, scale=1):
    captcha = create_captcha(word, shear=shear, scale=scale, size=(len(word) * 25, 30))
    prediction = predict_captcha(captcha, net) 
    return word == prediction, word, prediction

返回的结果指定了预测是否正确,原始单词和预测单词。此代码正确预测了单词 GENE,但其他单词预测有误。它的准确度如何?为了测试,我们将创建一个包含大量来自 NLTK 的四字母英语单词的数据集。代码如下:

from nltk.corpus import words

使用 Anaconda 安装 NLTK:conda install nltk

安装后,在使用代码之前,您需要使用以下命令下载语料库:

python -c "import nltk; nltk.download('words')"

这里提到的单词实例实际上是一个语料库对象,因此我们需要在它上面调用words()来从语料库中提取单个单词。我们还将过滤列表,只获取四个字母的单词:

valid_words = set([word.upper() for word in words.words() if len(word) == 4])

我们可以遍历所有的单词,通过简单地计数正确的和错误的预测来查看我们能够正确预测多少:

num_correct = 0 
num_incorrect = 0 
for word in valid_words:
    shear = random_state.choice(shear_values)
    scale = random_state.choice(scale_values) 
    correct, word, prediction = test_prediction(word, clf, shear=shear, scale=scale) 
    if correct: 
        num_correct += 1 
    else: 
        num_incorrect += 1
print("Number correct is {0}".format(num_correct)) 
print("Number incorrect is {0}".format(num_incorrect))

我得到的结果是 3,342 个正确和 2,170 个错误,准确率略高于 62%。从我们原始的每个字母 99%的准确率来看,这是一个很大的下降。发生了什么?

导致这种下降的原因列在这里:

  • 影响的第一个因素是我们的准确率。在其他条件相同的情况下,如果我们有四个字母,每个字母的准确率为 99%,那么我们预计的成功率大约为 96%(在其他条件相同的情况下)连续正确预测四个字母(0.99⁴≈0.96)。单个字母预测中的单个错误会导致预测出错误的单词。

  • 第二个影响是剪切值。我们的数据集在 0 到 0.5 的剪切值之间随机选择。之前的测试使用了 0.2 的剪切值。对于 0 的值,我得到了 75%的准确率;对于 0.5 的剪切值,结果要差得多,只有 2.5%。剪切值越高,性能越低。

  • 第三个影响是单词经常被错误分割。另一个问题是,一些元音字母经常被误判,导致错误率高于上述错误率。

让我们检查这些问题中的第二个,并映射剪切值和性能之间的关系。首先,我们将评估代码转换为一个依赖于给定剪切值的函数:

def evaluation_versus_shear(shear_value):
    num_correct = 0 
    num_incorrect = 0 
    for word in valid_words: 
        scale = random_state.choice(scale_values)
        correct, word, prediction = test_prediction(word, clf, shear=shear_value, scale=scale)
        if correct: 
            num_correct += 1 
        else: 
            num_incorrect += 1
    return num_correct/(num_correct+num_incorrect)

然后,我们取一个剪切值列表,然后使用这个函数评估每个值的准确率。请注意,这段代码将运行一段时间,大约需要 30 分钟,具体取决于您的硬件。

scores = [evaluation_versus_shear(shear) for shear in shear_values]

最后,使用 matplotlib 绘制结果:

plt.plot(shear_values, scores)

你可以看到,当剪切值超过 0.4 时,性能急剧下降。对输入进行归一化将有助于,例如图像旋转和去除输入的剪切。

解决剪切问题的一个令人惊讶的选项是增加具有高剪切值的训练数据量,这可能导致模型学习到更通用的输出。

我们将在下一节探讨使用后处理来提高准确率。

使用字典提高准确率

我们不仅可以返回给定的预测,还可以检查该单词是否实际上存在于我们的字典中。如果存在,那么这就是我们的预测。如果不在字典中,我们可以尝试找到一个与它相似的单词并预测它。请注意,这种策略依赖于我们的假设,即所有 CAPTCHA 单词都将是有效的英语单词,因此这种策略对于随机字符序列是不适用的。这也是为什么一些 CAPTCHA 不使用单词的原因之一。

这里有一个问题——我们如何确定最接近的单词?有许多方法可以做到这一点。例如,我们可以比较单词的长度。长度相似的单词可能被认为更相似。然而,我们通常认为如果单词在相同的位置上有相同的字母,那么它们就是相似的。这就是编辑距离发挥作用的地方。

单词相似度的排名机制

Levenshtein 编辑距离是一种常用的方法,用于比较两个短字符串以查看它们的相似程度。它并不非常可扩展,因此通常不用于非常长的字符串。编辑距离计算从一个单词到另一个单词所需的步骤数。这些步骤可以是以下三种操作之一:

  • 在单词的任何位置插入一个新字母

  • 从单词中删除任何字母

  • 用一个字母替换另一个字母

将第一个单词转换为第二个单词所需的最小操作数被称为距离。较高的值表示单词之间的相似度较低。

这个距离在 NLTK 中作为nltk.metrics.edit_distance提供。我们可以通过仅使用两个字符串来调用它,它返回编辑距离:

from nltk.metrics import edit_distance 
steps = edit_distance("STEP", "STOP") 
print("The number of steps needed is: {0}".format(steps))

当与不同的单词一起使用时,编辑距离相当好地近似了很多人直观上认为相似的单词。编辑距离非常适合测试拼写错误、听写错误和姓名匹配(在这种情况下,你很容易混淆 Marc 和 Mark 的拼写)。

然而,它对我们的情况并不好。我们并不期望字母会被移动,只是单个字母的比较可能会出错。因此,我们将创建一个不同的距离度量,它只是相同位置上错误字母的数量。代码如下:

def compute_distance(prediction, word):
    len_word = min(len(prediction), len(word))
    return len_word - sum([prediction[i] == word[i] for i in range(len_word)])

我们从预测单词的长度(即四个字母)中减去该值,使其成为一个距离度量,其中较低的值表示单词之间有更高的相似度。

将所有内容整合在一起

我们现在可以使用与之前类似的代码来测试我们改进的预测函数。首先,我们定义一个预测函数,它也接受我们的有效单词列表:

from operator import itemgetter 

def improved_prediction(word, net, dictionary, shear=0.2, scale=1.0): 
    captcha = create_captcha(word, shear=shear, scale=scale) 
    prediction = predict_captcha(captcha, net) 

    if prediction not in dictionary:
        distances = sorted([(word, compute_distance(prediction, word)) for word in dictionary],
                           key=itemgetter(1))
        best_word = distances[0] 
        prediction = best_word[0]
    return word == prediction, word, prediction

我们计算我们的预测单词与字典中每个单词之间的距离,并按距离排序(最短优先)。我们测试代码中的更改如下所示:

num_correct = 0 
num_incorrect = 0 
for word in valid_words: 
    shear = random_state.choice(shear_values)
    scale = random_state.choice(scale_values)
    correct, word, prediction = improved_prediction(word, clf, valid_words, shear=shear, scale=scale)
    if correct: 
        num_correct += 1 
    else: 
        num_incorrect += 1
print("Number correct is {0}".format(num_correct)) 
print("Number incorrect is {0}".format(num_incorrect))

上述代码运行需要一段时间(计算所有距离需要一些时间),但最终结果是 3,037 个样本正确,2,476 个样本错误。这相当于提高了近 10 个百分点的准确率!

想要挑战一下吗?更新predict_captcha函数以返回分配给每个字母的概率。默认情况下,每个单词中的字母都选择概率最高的字母。如果这不起作用,可以选择下一个最可能的单词,通过将每个字母的概率相乘来实现。

概述

在本章中,我们处理图像以使用简单的像素值来预测 CAPTCHA 中描绘的字母。我们的 CAPTCHA 有些简化;我们只使用了完整的四个字母的英文单词。实际上,这个问题要困难得多——正如它应该的那样!通过一些改进,使用神经网络和与我们讨论的类似的方法,就有可能解决更难的 CAPTCHA。scikit-image库包含许多从图像中提取形状、改善对比度以及其他有助于图像处理的工具。

我们将预测单词的较大问题简化为一个较小且简单的问题,即预测字母。从这里,我们能够创建一个前馈神经网络来准确预测图像中的哪个字母。在这个阶段,我们的结果非常好,准确率达到 97%。

神经网络只是由神经元简单连接的集合,这些神经元是基本的计算设备,由一个函数组成。然而,当你将它们连接在一起时,它们可以解决极其复杂的问题。神经网络是深度学习的基础,目前是数据挖掘中最有效的领域之一。

尽管我们在预测单个字母时取得了很高的准确性,但在尝试预测整个单词时,性能下降到仅超过 60%。我们通过使用字典,搜索最佳匹配的单词来提高我们的准确性。为此,我们考虑了常用的编辑距离;然而,我们简化了它,因为我们只关注字母上的个别错误,而不是插入或删除。这种改进带来了一些好处,但仍有许多改进可以尝试以进一步提高准确性。

为了进一步探讨本章的概念,研究改变神经网络结构,通过添加更多隐藏层或改变这些层的形状。研究这将对结果产生什么影响。进一步地,尝试创建一个更难的 CAPTCHA——这会降低准确性吗?你能构建一个更复杂的网络来学习它吗?

如 CAPTCHA 示例所示的数据挖掘问题表明,一个初始问题陈述,如“猜这个单词”,可以被分解成可以使用数据挖掘执行的单个子任务。此外,这些子任务可以通过几种不同的方式组合,例如使用外部信息。在本章中,我们将我们的字母预测与有效单词字典相结合,以提供最终响应,比单独的字母预测提供了更好的准确性。

在下一章中,我们将继续进行字符串比较。我们将尝试确定(在一系列作者中)哪位作者撰写了特定的文档——仅使用内容,不使用其他任何信息!

第九章:作者身份归属

作者身份分析是一种文本挖掘任务,旨在仅根据作者的写作内容来识别关于作者的一些方面。这可能包括诸如年龄、性别或背景等特征。在具体的作者身份归属任务中,我们的目标是确定一组作者中哪位作者撰写了特定的文档。这是一个经典的分类任务。在许多方面,作者身份分析任务都是通过标准的数据挖掘方法来执行的,例如交叉验证、特征提取和分类算法。

在本章中,我们将使用作者身份归属问题来整合我们在前几章中开发的数据挖掘方法的部分。我们确定问题并讨论问题的背景和知识。这使得我们可以选择要提取的特征,我们将为此构建一个管道。我们将测试两种不同类型的特征:功能词和字符 n-gram。最后,我们将对结果进行深入分析。我们首先将使用一组书籍数据集,然后是一个混乱的、现实世界的电子邮件语料库。

本章我们将涵盖以下主题:

  • 特征工程以及特征选择如何根据应用而不同

  • 带着特定目标重新审视词袋模型

  • 特征类型和字符 n-gram 模型

  • 支持向量机

  • 清理用于数据挖掘的混乱数据集

将文档归因于作者

作者身份分析有风格学的背景,这是研究作者写作风格的研究。这个概念基于这样一个想法,即每个人学习语言的方式略有不同,通过测量人们写作中的这些细微差别,我们可以仅使用他们写作的内容来区分他们。

作者身份分析在历史上(1990 年之前)一直是通过可重复的手动分析和统计来执行的,这是一个很好的迹象,表明它可以利用数据挖掘进行自动化。现代作者身份分析研究几乎完全是基于数据挖掘的,尽管相当大一部分工作仍然是使用更多手动驱动的分析,如语言风格和风格学。今天在特征工程方面的许多进步都是由风格学的进步驱动的。换句话说,手动分析发现了新的特征,然后这些特征被编码并作为数据挖掘过程的一部分使用。

风格学的一个关键基础特征是作者不变性,即特定作者在其所有文档中都有的特征,但与其他作者不共享。在实践中,这些作者不变性似乎并不存在,因为作者的风格会随时间而变化,但数据挖掘的使用可以使我们接近基于这一原则工作的分类器。

作为一门学科,作者身份分析有许多子问题,其中主要问题如下:

  • 作者身份分析:这是根据写作确定作者的年龄、性别或其他特征。例如,我们可以通过观察他们使用英语的具体方式来检测说英语的人的第一语言。

  • 作者身份验证:这是检查这份文件的作者是否也撰写了另一份文件。这个问题是你通常在法律法庭环境中会考虑的问题。例如,嫌疑人的写作风格(从内容上分析)将被分析,以查看它是否与勒索信相符。

  • 作者身份聚类:这是作者身份验证的扩展,我们使用聚类分析将大量文档分组到簇中,每个簇由同一作者撰写。

然而,最常见的作者身份分析研究形式是作者身份归因,这是一个分类任务,我们试图预测一组作者中哪位撰写了给定的文件。

应用案例

作者身份分析有许多应用案例。许多案例都与验证作者身份、证明共同作者身份/来源或关联社交媒体资料与真实用户等问题相关。

从历史的角度来看,我们可以使用作者身份分析来验证某些文件是否确实是由其声称的作者撰写的。有争议的作者身份索赔包括一些莎士比亚的戏剧、美国建国时期的《联邦党人文集》以及其他历史文献。

单独的作者身份研究不能证明作者身份,但可以为或反对某个理论提供证据,例如是否有人撰写了给定的文件。

例如,我们可以分析莎士比亚的戏剧来确定他的写作风格,然后再测试给定的十四行诗是否确实出自他之手(一些最近的研究表明他的某些作品有多位作者)。

更现代的一个用例是链接社交网络账户。例如,一个恶意在线用户可能在多个在线社交网络上建立账户。能够将它们联系起来,使当局能够追踪特定账户的用户——例如,如果有人在网上骚扰其他用户。

过去使用的一个例子是作为骨干在法庭上提供专家证词,以确定某个人是否撰写了某份文件。例如,嫌疑人可能被指控撰写了一封骚扰他人的电子邮件。使用作者身份分析可以确定那个人实际上是否真的撰写了该文件。另一种基于法庭的使用是解决被盗作者身份的索赔。例如,两位作者可能声称撰写了一本书,作者身份分析可以提供证据,证明哪位作者更有可能是真正的作者。

尽管作者归属分析并非万无一失,但最近的一项研究发现,仅仅要求那些未经训练的人隐藏他们的写作风格,就可以使将文档归因于作者变得更加困难。这项研究还考察了一个框架练习,其中人们被要求以另一个人的风格写作。这种对另一个人风格的模仿证明相当可靠,伪造的文档通常被归因于被模仿的人。

尽管存在这些问题,作者归属分析在越来越多的领域中被证明是有用的,并且是一个有趣的数据挖掘问题进行研究。

作者归属可以用于专家证词,但仅凭它本身很难被归类为硬证据。在使用它来解决正式事项,如作者归属争议之前,请务必咨询律师。

作者归属

作者归属(与作者分析不同)是一个分类任务,其中我们有一组候选作者,每个作者有一组文档,即所谓的训练集,以及一组未知作者身份的文档,通常称为测试集。如果未知作者身份的文档肯定属于候选人之一,我们称之为封闭问题,如下面的图所示:

图片

如果我们不能确定实际作者是否是训练集的一部分,我们称之为开放问题。这种区别不仅限于作者归属——任何实际类别可能不在训练集中的数据挖掘应用都被认为是开放问题,任务就是找到候选作者或选择他们中的任何一个。这如下面的图所示:

图片

在作者归属中,我们通常有两个任务限制。它们如下列出:

  • 首先,我们只使用文档的内容信息——不是关于写作时间、交付、手写风格等方面的元数据。有方法可以结合来自这些不同类型信息的数据模型,但这通常不被认为是作者归属,而更多的是一个数据融合应用。

  • 第二个限制是我们不关注文档的主题;相反,我们寻找更显著的特征,如词汇使用、标点符号和其他基于文本的特征。这里的推理是,一个人可以就许多不同的主题进行写作,因此担心他们写作的主题并不能模拟他们的实际写作风格。查看主题词也可能导致训练数据上的过拟合——我们的模型可能在同一作者的同一主题的文档上进行训练。例如,如果你通过查看这本书来模拟我的写作风格,你可能会得出结论,单词“数据挖掘”是我的写作风格的指示性特征,而实际上我也在其他主题上写作。

从这里,执行作者归属分析的流程与我们在第六章[ea7ae888-e2aa-46b5-ba45-b8c685cc5fe2.xhtml],使用朴素贝叶斯进行社交媒体洞察中开发的流程非常相似。

  1. 首先,我们从我们的文本中提取特征。

  2. 然后,我们对这些特征进行一些特征选择。

  3. 最后,我们训练一个分类算法来拟合模型,然后我们可以使用它来预测文档的类别(在这种情况下,作者)。

在内容分类和作者分类之间有一些区别,主要与使用哪些特征有关,我们将在本章中介绍。根据应用选择特征至关重要。

在深入研究这些问题之前,我们将定义问题的范围并收集一些数据。

获取数据

我们将用于本章第一部分的数据是一套来自 www.gutenberg.orgProject Gutenberg 的书籍,这是一个公共领域文学作品库。我用于这些实验的书籍来自各种作者:

  • 布思·塔金顿(22 部作品)

  • 查尔斯·狄更斯(44 部作品)

  • 伊迪丝·内斯比特(10 部作品)

  • 亚瑟·柯南·道尔(51 部作品)

  • 马克·吐温(29 部作品)

  • 理查德·弗朗西斯·伯顿爵士(11 部作品)

  • 埃米尔·加博里奥(10 部作品)

总共有 177 篇来自 7 位作者的文章,提供了大量的文本供我们使用。文章的完整列表,包括下载链接和自动获取它们的脚本,可以在名为 getdata.py 的代码包中找到。如果运行代码的结果比上面少得多,镜像可能已关闭。请参阅此网站以获取更多镜像 URL 以在脚本中尝试:www.gutenberg.org/MIRRORS.ALL

为了下载这些书籍,我们使用 requests 库将文件下载到我们的数据目录中。

首先,在一个新的 Jupyter Notebook 中,设置数据目录并确保以下代码链接到它:

import os 
import sys 
data_folder = os.path.join(os.path.expanduser("~"), "Data", "books")

接下来,从 Packt 提供的代码包中下载数据包。将文件解压缩到该目录中。书籍文件夹应直接包含每个作者的文件夹。

在查看这些文件后,你会发现其中许多文件相当杂乱——至少从数据分析的角度来看。文件开头有一个很大的 Project Gutenberg 声明。在我们进行分析之前,需要将其删除。

例如,大多数书籍以以下信息开始:

*《Project Gutenberg 电子书:Mugby Junction》,作者:查尔斯·狄更斯等人,由 Jules A. 插画

古德曼 这本电子书对任何地方的任何人都是免费的,并且可以*

几乎没有任何限制。您可以复制它,赠送它或

根据 Project Gutenberg 许可证重新使用

本电子书或在线于 www.gutenberg.org

标题:Mugby Junction

作者:查尔斯·狄更斯

发布日期:2009 年 1 月 28 日 [电子书 #27924]语言:英语

*字符集编码:UTF-8

《古腾堡项目》MUGBY JUNCTION 电子书开始

在这一点之后,书籍的实际文本开始。使用以《古腾堡项目》开始开头的行相当一致,我们将使用这个作为文本开始的线索——任何在此行之前的都将被忽略。

我们可以更改磁盘上的单个文件来移除这些内容。然而,如果我们丢失了数据会怎样?我们会丢失我们的更改,并且可能无法复制这项研究。因此,我们将预处理作为加载文件时执行——这使我们能够确保我们的结果将是可复制的(只要数据源保持不变)。以下代码移除了书籍中的主要噪声源,即古腾堡项目添加到文件中的序言:

def clean_book(document):
    lines = document.split("n")
    start= 0
    end = len(lines)
    for i in range(len(lines)):
        line = lines[i]
        if line.startswith("*** START OF THIS PROJECT GUTENBERG"):
            start = i + 1
        elif line.startswith("*** END OF THIS PROJECT GUTENBERG"):
            end = i - 1
    return "n".join(lines[start:end])

你可能想添加到这个函数中,以移除其他噪声源,例如不一致的格式、页脚信息等。调查文件以检查它们有什么问题。

我们现在可以使用以下函数获取我们的文档和类别,该函数会遍历这些文件夹,加载文本文档,并将分配给作者的编号作为目标类别。

import numpy as np

def load_books_data(folder=data_folder):
    documents = []
    authors = []
    subfolders = [subfolder for subfolder in os.listdir(folder)
                  if os.path.isdir(os.path.join(folder, subfolder))]
    for author_number, subfolder in enumerate(subfolders):
        full_subfolder_path = os.path.join(folder, subfolder)
        for document_name in os.listdir(full_subfolder_path):
            with open(os.path.join(full_subfolder_path, document_name), errors='ignore') as inf:
                documents.append(clean_book(inf.read()))
                authors.append(author_number)
    return documents, np.array(authors, dtype='int')

我们接下来调用这个函数来实际加载书籍:

documents, classes = load_books_data(data_folder)

这个数据集很容易放入内存,因此我们可以一次性加载所有文本。在整个数据集不适合的情况下,更好的解决方案是逐个(或批量)提取每个文档的特征,并将结果值保存到文件或内存矩阵中。

为了了解数据的属性,我通常首先做的事情之一是创建一个简单的文档长度直方图。如果长度相对一致,这通常比文档长度差异很大更容易学习。在这种情况下,文档长度有很大的差异。要查看这一点,首先我们将长度提取到一个列表中:

document_lengths = [len(document) for document in documents]

接下来,我们绘制这些数据。Matplotlib 有一个hist函数可以做到这一点,Seaborn 也可以,它默认产生更美观的图表。

import seaborn as sns
sns.distplot(document_lengths)

生成的图表显示了文档长度的变化:

使用功能词

早期类型的一种特征,并且对于作者身份分析仍然相当有效,是使用词袋模型中的功能词。功能词是本身意义不大的词,但却是创建(英语!)句子所必需的。例如,这个那个这样的词,它们实际上只由它们在句子中的功能定义,而不是它们本身的意义。这与像老虎这样的内容词形成对比,内容词有明确的意义,并在句子中使用时唤起大型猫的形象。

被认为是功能词的词集并不总是显而易见的。一个好的经验法则是选择使用频率最高的词(在所有可能的文档中,而不仅仅是同一作者的文档)。

通常,一个词使用得越频繁,对作者身份分析就越好。相反,一个词使用得越少,对基于内容的文本挖掘就越好,例如在下一章中,我们将探讨不同文档的主题。

此图展示了单词与频率之间的关系:

图片

功能词的使用更多地由作者的决策决定,而不是文档的内容。这使得它们成为区分不同用户作者特征的理想候选词。例如,虽然许多美国人特别注意句子中thatwhich的用法差异,但来自其他国家的人,如澳大利亚人,对此区别不太关心。这意味着一些澳大利亚人可能会几乎只使用一个词或另一个词,而其他人可能会更多地使用which

这种差异,加上成千上万的细微差异,构成了作者身份模型。

计数功能词

我们可以使用在第六章,使用朴素贝叶斯进行社交媒体洞察中使用的 CountVectorizer 类来计数功能词。这个类可以传递一个词汇表,即它将查找的单词集合。如果没有传递词汇表(我们在第六章,使用朴素贝叶斯进行社交媒体洞察的代码中没有传递),那么它将从训练数据集中学习这个词汇表。所有单词都在文档的训练集中(当然,取决于其他参数)。

首先,我们设置我们的功能词词汇表,它只是一个包含每个单词的列表。哪些是功能词,哪些不是,这是一个有争议的问题。我发现以下列表,来自发表的研究,相当不错,是从我自己的研究中获得的,结合了其他研究者的单词列表。请记住,代码包可以从 Packt 出版社(或官方 GitHub 频道)获得,因此您不需要亲自输入:

function_words = ["a", "able", "aboard", "about", "above", "absent", "according" , "accordingly", "across", "after", "against","ahead", "albeit", "all", "along", "alongside", "although", "am", "amid", "amidst", "among", "amongst", "amount", "an", "and", "another", "anti", "any", "anybody", "anyone", "anything", "are", "around", "as", "aside", "astraddle", "astride", "at", "away", "bar", "barring", "be", "because", "been", "before", "behind", "being", "below", "beneath", "beside", "besides", "better", "between", "beyond", "bit", "both", "but", "by", "can", "certain", "circa", "close", "concerning", "consequently", "considering", "could", "couple", "dare", "deal", "despite", "down", "due", "during", "each", "eight", "eighth", "either", "enough", "every", "everybody", "everyone", "everything", "except", "excepting", "excluding", "failing", "few", "fewer", "fifth", "first", "five", "following", "for", "four", "fourth", "from", "front", "given", "good", "great", "had", "half", "have", "he", "heaps", "hence", "her", "hers", "herself", "him", "himself", "his", "however", "i", "if", "in", "including", "inside", "instead", "into", "is", "it", "its", "itself", "keeping", "lack", "less", "like", "little", "loads", "lots", "majority", "many", "masses", "may", "me", "might", "mine", "minority", 
"minus", "more", "most", "much", "must", "my", "myself", "near", "need", "neither", "nevertheless", "next", "nine", "ninth", "no", "nobody", "none", "nor", "nothing", "notwithstanding", "number", "numbers", "of", "off", "on", "once", "one", "onto", "opposite", "or", "other", "ought", "our", "ours", "ourselves", "out", "outside", "over", "part", "past", "pending", "per", "pertaining", "place", "plenty", "plethora", "plus", "quantities", "quantity", "quarter", "regarding", "remainder", "respecting", "rest", "round", "save", "saving", "second", "seven", "seventh", "several","shall", "she", "should", "similar", "since", "six", "sixth", "so", "some", "somebody", "someone", "something", "spite","such", "ten", "tenth", "than", "thanks", "that", "the", "their", "theirs", "them", "themselves", "then", "thence", "therefore", "these", "they", "third", "this", "those","though", "three", "through", "throughout", "thru", "thus", "till", "time", "to", "tons", "top", "toward", "towards", "two", "under", "underneath", "unless", "unlike", "until", "unto", "up", "upon", "us", "used", "various", "versus","via", "view", "wanting", "was", "we", "were", "what", "whatever", "when", "whenever", "where", "whereas", "wherever", "whether", "which", "whichever", "while","whilst", "who", "whoever", "whole", "whom", "whomever", "whose", "will", "with", "within", "without", "would", "yet", "you", "your", "yours", "yourself", "yourselves"]

现在,我们可以设置一个提取器来获取这些功能词的计数。注意将功能词列表作为vocabulary传递给CountVectorizer初始化器。

from sklearn.feature_extraction.text 
import CountVectorizer 
extractor = CountVectorizer(vocabulary=function_words)

对于这组功能词,这些文档中的频率非常高——正如预期的那样。我们可以使用提取器实例通过在数据上拟合并调用transform(或使用fit_transform的快捷方式)来获取这些计数。

extractor.fit(documents)
counts = extractor.transform(documents)

在绘图之前,我们通过除以相关文档长度对这些计数进行了归一化。以下代码执行此操作,得到每个功能词所占的百分比:

normalized_counts = counts.T / np.array(document_lengths)

然后,我们平均所有文档的这些百分比:

averaged_counts = normalized_counts.mean(axis=1)

最后,我们使用 Matplotlib(Seaborn 缺乏此类基本图表的简单接口)来绘制它们。

from matplotlib import pyplot as plt
plt.plot(averaged_counts)

图片

使用功能词进行分类

这里唯一的新颖之处在于使用了支持向量机SVM),我们将在下一节中介绍(现在,只需将其视为一个标准的分类算法)。

接下来,我们导入我们的类。我们导入 SVC 类,这是一个用于分类的 SVM,以及我们之前见过的其他标准工作流程工具:

from sklearn.svm import SVC 
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import Pipeline from sklearn import grid_search

SVMs 有多个参数。正如我所说的,在下一节详细说明之前,我们将在这里盲目地使用一个参数。然后我们使用一个字典来设置我们要搜索的参数。对于kernel参数,我们将尝试linearrbf。对于 C,我们将尝试 1 和 10 的值(这些参数的描述将在下一节中介绍)。然后我们创建一个网格搜索来搜索这些参数的最佳选择:

parameters = {'kernel':('linear', 'rbf'), 'C':[1, 10]}
svr = SVC()
grid = grid_search.GridSearchCV(svr, parameters)

高斯核(如 RBF)仅适用于合理大小的数据集,例如当特征数量少于约 10,000 时。

接下来,我们设置一个管道,使用CountVectorizer(仅使用功能词)进行特征提取步骤,以及使用 SVM 的网格搜索。代码如下:

pipeline1 = Pipeline([('feature_extraction', extractor), ('clf', grid) ])

接下来,应用cross_val_score来获取这个管道的交叉验证分数。结果是 0.811,这意味着我们大约有 80%的预测是正确的。

支持向量机

SVMs 是基于简单直观的想法的分类算法,背后有一些复杂和创新数学。SVMs 通过在两个类别之间简单地画一条分隔线(或在更高维度中的超平面)来进行分类(尽管我们可以使用各种元算法将其扩展到更多类别)。直观的想法是选择最佳的分隔线,而不仅仅是任何特定的线。

假设我们的两个类别可以通过一条线分开,使得线上的任何点属于一个类别,而线下的任何点属于另一个类别。SVMs 找到这条线并使用它进行预测,这与线性回归的工作方式非常相似。然而,SVMs 会找到最佳的分隔线来分隔数据集。在下面的图中,我们有三条分隔数据集的线:蓝色、黑色和绿色。你会说哪个是最好的选择?

直观地,人们通常会选择蓝色线作为最佳选择,因为它以最干净的方式分隔数据。更正式地说,它具有从线到每个类别中任何点的最大距离。找到这条最大分隔线是一个优化问题,基于找到具有最大距离的边缘线。解决这个优化问题是 SVM 训练阶段的主要任务。

解决 SVMs 的方程式超出了本书的范围,但我建议感兴趣的读者去查阅以下推导:

en.wikibooks.org/wiki/Support_Vector_Machines 了解详情。

或者,您可以访问:

docs.opencv.org/doc/tutorials/ml/introduction_to_svm/introduction_to_svm.html

使用支持向量机(SVMs)进行分类

训练模型后,我们得到一条最大边界的线。对新样本的分类简单来说就是询问:它是否位于线上方,还是下方?如果它位于线上方,它被预测为某一类。如果它位于线下方,它被预测为另一类。

对于多类分类,我们创建多个 SVMs——每个都是一个二元分类器。然后我们使用任何一种策略将它们连接起来。一种基本策略是为每个类别创建一个一对一分类器,我们使用两个类别——给定的类别和所有其他样本进行训练。我们对每个类别都这样做,并在新样本上运行每个分类器,从这些分类器中选择最佳匹配。这个过程在大多数 SVM 实现中是自动完成的。

在我们之前的代码中,我们看到了两个参数:C 和核。我们将在下一节中介绍核参数,但C参数是拟合 SVMs 的一个重要参数。C参数与分类器应该努力预测所有训练样本正确性的程度有关,这可能会带来过拟合的风险。选择更高的C值将找到具有较小边界的分离线,旨在正确分类所有训练样本。选择较低的C值将导致具有较大边界的分离线——即使这意味着一些训练样本被错误分类。在这种情况下,较低的C值提供了较低的过拟合风险,但可能会选择一个通常较差的分离线。

SVMs(在其基本形式)的一个局限性是,它们只能分离线性可分的数据。如果数据不是线性可分的会怎样?针对这个问题,我们使用核。

当数据不能线性分离时,诀窍是将它嵌入到更高维的空间中。这意味着,在许多关于细节的手势中,就是向数据集中添加新的特征,直到数据变得线性可分。如果你添加了正确的特征类型,这种线性分离最终总是会发生的。

诀窍在于,我们在寻找最佳分离数据集的直线时,通常会计算样本的内积。给定一个使用点积的函数,我们实际上制造了新的特征,而无需真正定义这些新特征。这被称为核技巧,它很方便,因为我们无论如何也不知道这些特征会是什么。我们现在定义核为一个函数,它本身是数据集中两个样本的函数的点积,而不是基于样本(和虚构的特征)本身。

我们现在可以计算这个点积(或者近似它),然后直接使用这个结果。

常用的核函数有很多。线性核是最直接的,它是两个样本特征向量、权重特征和偏置值的点积。还有一个多项式核,它将点积提升到给定的度(例如,2)。其他包括高斯rbf)和Sigmoidal函数。在我们的前一个代码示例中,我们在线性核和rbf核选项之间进行了测试。

所有这些推导的最终结果是,这些核函数有效地定义了两个样本之间的距离,这个距离用于 SVMs 中新的样本的分类。理论上,可以使用任何距离,尽管它可能不具有使 SVM 训练易于优化的相同特性。

在 scikit-learn 对 SVMs 的实现中,我们可以定义核参数来改变计算中使用的核函数,正如我们在之前的代码示例中看到的。

字符 n-gram

我们看到了如何使用功能词作为特征来预测文档的作者。另一种特征类型是字符 n-gram。n-gram 是一系列n个标记的序列,其中n是一个值(对于文本,通常在 2 到 6 之间)。词 n-gram 已在许多研究中使用,通常与文档的主题相关——如前一章所述。然而,字符 n-gram 已被证明在作者归属方面具有高质量。

通过将文档表示为字符序列,可以在文本文档中找到字符 n-gram。然后从这个序列中提取这些 n-gram,并训练一个模型。为此有几种不同的模型,但一个标准的模型与我们之前使用的词袋模型非常相似。

对于训练语料库中的每个不同的 n-gram,我们为其创建一个特征。一个 n-gram 的例子是<e t>,它代表字母 e,空格,然后是字母 t(尖括号用来表示 n-gram 的开始和结束,但它们本身不是 n-gram 的一部分)。然后我们使用训练文档中每个 n-gram 的频率来训练我们的模型,并使用创建的特征矩阵来训练分类器。

字符 n-gram 的定义方式有很多种。例如,有些应用只选择单词内的字符,忽略空格和标点符号。有些使用这些信息(如本章中的我们的实现)进行分类。最终,这是模型的目的,由数据挖掘者(也就是你!)选择。

字符 n-gram 之所以有效的一个常见理论是,人们更倾向于写他们容易说出的单词,而字符 n-gram(至少当 n 在 2 到 6 之间时)是对音素——我们说单词时发出的声音——的良好近似。从这个意义上说,使用字符 n-gram 近似单词的声音,这近似了你的写作风格。这是创建新特征时的一个常见模式。首先,我们对哪些概念会影响最终结果(作者风格)有一个理论,然后创建特征来近似或衡量这些概念。

字符 n-gram 矩阵的一个关键特征是它是稀疏的,并且随着 n 值的增加,稀疏性会迅速增加。对于 n 值为 2 时,大约 75%的特征矩阵是零。对于 n 值为 5 时,超过 93%是零。尽管如此,这通常比相同类型的单词 n-gram 矩阵稀疏,使用用于基于单词的分类器的分类器时不应引起许多问题。

提取字符 n-gram

我们将使用我们的CountVectorizer类来提取字符 n-gram。为此,我们需要设置分析器参数并指定一个 n 值来提取 n-gram。

scikit-learn 中的实现使用 n-gram 范围,允许你同时提取多个大小的 n-gram。在这个实验中,我们不会深入研究不同的 n 值,所以我们只设置相同的值。要提取大小为 3 的 n-gram,你需要将(3, 3)指定为 n-gram 范围的值。

我们可以重用之前代码中的网格搜索。我们只需要在新的管道中指定新的特征提取器并运行它:

pipeline = Pipeline([('feature_extraction', CountVectorizer(analyzer='char', ngram_range=(3,3))),
                     ('classifier', grid) ]
scores = cross_val_score(pipeline, documents, classes, scoring='f1') 
print("Score: {:.3f}".format(np.mean(scores)))

函数词和字符 n-gram 之间存在大量的隐含重叠,因为函数词中的字符序列更有可能出现。然而,实际特征非常不同,字符 n-gram 可以捕捉到标点符号,这是函数词所不能捕捉的。例如,一个字符 n-gram 包括句子末尾的句号,而基于函数词的方法只会使用前面的单词本身。

Enron 数据集

安然是 20 世纪 90 年代末世界上最大的能源公司之一,报告的年收入超过 1000 亿美元。它拥有超过 20,000 名员工,截至 2000 年,似乎没有任何迹象表明出了大问题。

2001 年,发生了安然丑闻,当时发现安然正在进行系统性的欺诈性会计实践。这种欺诈是故意的,涉及公司广泛,涉及大量资金。在公开发现之后,其股价从 2000 年的 90 多美元降至 2001 年的不到 1 美元。安然随后在一片混乱中申请破产,最终需要超过 5 年才能最终解决。

作为对安然调查的一部分,美国联邦能源监管委员会公开了超过 60 万封电子邮件。从那时起,这个数据集被用于从社交网络分析到欺诈分析的各种研究。它也是一个很好的作者分析数据集,因为我们能够从单个用户的发送文件夹中提取电子邮件。这使得我们能够创建一个比许多先前数据集都要大的数据集。

访问安然数据集

全套的安然电子邮件可在www.cs.cmu.edu/~./enron/找到

整个数据集相当大,以 gzip 压缩格式提供。如果您没有基于 Linux 的机器来解压缩(解压)此文件,请获取替代程序,例如 7-zip (www.7-zip.org/)

下载完整语料库并将其解压缩到您的数据文件夹中。默认情况下,这将解压缩到名为enron_mail_20110402的文件夹中,然后包含一个名为maildir的文件夹。在笔记本中,为安然数据集设置数据文件夹:

enron_data_folder = os.path.join(os.path.expanduser("~"), "Data", "enron_mail_20150507", "maildir")

创建数据集加载器

在寻找作者信息时,我们只希望获取可以归因于特定作者的电子邮件。因此,我们将查看每个用户的已发送文件夹——即他们发送的电子邮件。现在我们可以创建一个函数,该函数将随机选择几位作者,并返回他们发送文件夹中的每封电子邮件。具体来说,我们寻找的是有效载荷——即内容而不是电子邮件本身。为此,我们需要一个电子邮件解析器。代码如下:

from email.parser 
import Parser p = Parser()

我们将稍后使用它从数据文件夹中的电子邮件文件中提取有效载荷。

使用我们的数据加载函数,我们将有很多选项。其中大部分确保我们的数据集相对平衡。一些作者在他们的已发送邮件中可能有数千封电子邮件,而其他人可能只有几十封。我们通过min_docs_author参数限制搜索范围,只包括至少有 10 封电子邮件的作者,并使用max_docs_author参数从每位作者那里获取最多 100 封电子邮件。我们还指定了我们想要获取的作者数量——默认为 10 位,使用num_authors参数。

函数如下。其主要目的是遍历作者,为该作者检索一定数量的电子邮件,并将文档类别信息存储在列表中。我们还存储了作者姓名与其数值类别值之间的映射,这让我们可以在以后检索该信息。

from sklearn.utils import check_random_state

def get_enron_corpus(num_authors=10, data_folder=enron_data_folder, min_docs_author=10,
                     max_docs_author=100, random_state=None):
    random_state = check_random_state(random_state)
    email_addresses = sorted(os.listdir(data_folder))
    # Randomly shuffle the authors. We use random_state here to get a repeatable shuffle
    random_state.shuffle(email_addresses)
    # Setup structures for storing information, including author information
    documents = []
    classes = []
    author_num = 0
    authors = {}  # Maps author numbers to author names
    for user in email_addresses:
        users_email_folder = os.path.join(data_folder, user)
        mail_folders = [os.path.join(users_email_folder, subfolder)
                        for subfolder in os.listdir(users_email_folder)
                        if "sent" in subfolder]
        try:
            authored_emails = [open(os.path.join(mail_folder, email_filename),
                                    encoding='cp1252').read()
                               for mail_folder in mail_folders
                               for email_filename in os.listdir(mail_folder)]
        except IsADirectoryError:
            continue
        if len(authored_emails) < min_docs_author:
            continue
        if len(authored_emails) > max_docs_author:
            authored_emails = authored_emails[:max_docs_author]
        # Parse emails, store the content in documents and add to the classes list
        contents = [p.parsestr(email)._payload for email in authored_emails]
        documents.extend(contents)
        classes.extend([author_num] * len(authored_emails))
        authors[user] = author_num
        author_num += 1
        if author_num >= num_authors or author_num >= len(email_addresses):
            break
     return documents, np.array(classes), authors

我们可能觉得我们排序电子邮件地址,然后又打乱它们,这似乎有些奇怪。os.listdir函数并不总是返回相同的结果,所以我们首先对其进行排序以获得一些稳定性。然后我们使用随机状态进行洗牌,这意味着如果需要,我们的洗牌可以重现过去的结果。

在这个函数外部,我们现在可以通过以下函数调用获取数据集。我们将在这里使用一个随机状态 14(就像在这本书中一样),但你也可以尝试其他值或将它设置为 none,以便每次调用函数时都得到一个随机集:

documents, classes, authors = get_enron_corpus(data_folder=enron_data_folder, random_state=14)

如果你查看数据集,我们还需要进行进一步的前处理。我们的电子邮件相当混乱,但最糟糕的部分(从作者分析的角度来看)是这些电子邮件包含其他作者的文字,以附件回复的形式存在。以下电子邮件,即documents[100]为例:

我想加入这个小组,但我有会议冲突

日期。请记住我明年。

马克·海迪克

电子邮件是一个臭名昭著的混乱格式。例如,回复引用有时(但不总是)以一个>字符开头。其他时候,回复被嵌入到原始消息中。如果你在进行更大规模的数据挖掘,请确保花更多时间清理数据以获得更好的结果。

与书籍数据集一样,我们可以绘制文档长度的直方图,以了解文档长度分布:

document_lengths = [len(document) for document in documents]
sns.distplot(document_lengths)

结果似乎显示出围绕较短文档的强烈分组。虽然这是真的,但它也显示出一些文档非常非常长。这可能会扭曲结果,尤其是如果一些作者倾向于撰写长文档的话。为了补偿这一点,这项工作的一个扩展可能是在训练之前将文档长度标准化到前 500 个字符。

把所有这些都放在一起

我们可以使用现有的参数空间和之前实验中现有的分类器——我们只需要在新数据上重新拟合它。默认情况下,scikit-learn 中的训练是从头开始的——随后的fit()调用将丢弃任何先前信息。

有一种称为在线学习的算法类别,它会用新样本更新训练,而不是每次都重新启动训练。

如前所述,我们可以通过使用cross_val_score来计算我们的分数,并打印结果。代码如下:

scores = cross_val_score(pipeline, documents, classes, scoring='f1') 

print("Score: {:.3f}".format(np.mean(scores)))

结果是 0.683,对于这样一个混乱的数据集来说,这是一个合理的结果。增加更多数据(例如在数据集加载中增加max_docs_author)可以改善这些结果,同样,通过额外的清理提高数据质量也会有所帮助。

评估

通常来说,基于单一数字进行评估从来不是一个好主意。在 f-score 的情况下,通常更稳健的是那些尽管不实用但能给出好分数的技巧。一个例子是准确性。正如我们在上一章所说,一个垃圾邮件分类器可以预测所有内容都是垃圾邮件,并得到超过 80%的准确性,尽管这种解决方案完全无用。因此,通常值得更深入地研究结果。

首先,我们将查看混淆矩阵,就像我们在第八章,使用神经网络战胜 CAPTCHAs中所做的那样。在我们能够这样做之前,我们需要预测一个测试集。之前的代码使用了cross_val_score,它实际上并没有给我们一个可以使用的训练模型。因此,我们需要重新拟合一个。为此,我们需要训练和测试子集:

from sklearn.cross_validation import train_test_split training_documents, 

testing_documents, y_train, y_test = train_test_split(documents, classes, random_state=14)

接下来,我们将管道拟合到我们的训练文档上,并为测试集创建预测:

pipeline.fit(training_documents, y_train) 
y_pred = pipeline.predict(testing_documents)

在这个阶段,你可能想知道最佳参数组合实际上是什么。我们可以很容易地从我们的网格搜索对象(这是我们管道中的分类步骤)中提取出来:

print(pipeline.named_steps['classifier'].best_params_)

结果给出了分类器的所有参数。然而,大多数参数都是我们未更改的默认值。我们确实搜索过的参数是 C 和内核,分别设置为 1 和线性。

现在我们可以创建一个混淆矩阵:

from sklearn.metrics import confusion_matrix
cm = confusion_matrix(y_pred, y_test)
cm = cm / cm.astype(np.float).sum(axis=1)

接下来,我们获取作者的名字,这样我们就可以正确地标记轴。为此,我们使用我们的 Enron 数据集加载的作者字典。代码如下:

sorted_authors = sorted(authors.keys(), key=lambda x:authors[x])

最后,我们使用 matplotlib 展示混淆矩阵。与上一章相比,以下仅突出显示的变化;只需将字母标签替换为本章实验的作者即可:

%matplotlib inline 
from matplotlib import pyplot as plt 
plt.figure(figsize=(10,10))
plt.imshow(cm, cmap='Blues', interpolation='nearest')
tick_marks = np.arange(len(sorted_authors))
plt.xticks(tick_marks, sorted_authors) 
plt.yticks(tick_marks, sorted_authors) 
plt.ylabel('Actual') 
plt.xlabel('Predicted') 
plt.show()

结果如下图所示:

我们可以看到,在大多数情况下,作者被正确预测——存在一条清晰的带有高值的对角线。尽管如此,还有一些大的错误来源(较暗的值较大):例如,用户 rapp-b 的电子邮件通常被预测为来自 reitmeyer-j。

摘要

在本章中,我们探讨了基于文本挖掘的作者归属问题。为了执行此操作,我们分析了两种类型的特征:功能词和字符 n-gram。对于功能词,我们能够使用词袋模型——仅限于我们事先选择的一组词。这给了我们仅那些词的频率。对于字符 n-gram,我们使用了一个非常相似的流程,使用相同的类。然而,我们将分析器更改为查看字符而不是单词。此外,我们使用了 n-gram,它是 n 个连续标记的序列——在我们的情况下是字符。在某些应用中,词 n-gram 也值得测试,因为它们可以提供一种廉价的方式来获取单词使用上下文的信息。

对于分类,我们使用了基于最大边缘优化类之间分离线的 SVMs。线上方的属于一个类别,线下方的属于另一个类别。与其他我们考虑过的分类任务一样,我们有一组样本(在这种情况下,我们的文档)。

我们随后使用了一个非常杂乱的数据库,即安然电子邮件。这个数据库包含了许多人工制品和其他问题。这导致其准确率低于书籍数据库,后者要干净得多。然而,在 10 个可能的作者中,我们能够超过一半的时间选择正确的作者。

为了进一步探讨本章的概念,寻找包含作者信息的新数据库。例如,你能预测博客文章的作者吗?关于推文的作者(你可能能够重复使用第六章,使用朴素贝叶斯进行社交媒体洞察)的数据呢?

在下一章中,我们考虑如果我们没有目标类别时我们能做什么。这被称为无监督学习,这是一个探索性问题而不是预测性问题。我们还将继续处理基于文本的杂乱数据集。

第十章:聚类新闻文章

在大多数早期章节中,我们进行数据挖掘时已知我们正在寻找什么。我们使用目标类别使我们能够在训练阶段了解我们的特征如何模拟那些目标,这使得算法能够设置内部参数以最大化其学习。这种有目标进行训练的学习类型被称为监督学习。在本章中,我们将考虑在没有那些目标的情况下我们做什么。这是无监督学习,它更多的是一种探索性任务。在无监督学习中,我们的目标不是用我们的模型进行分类,而是探索数据以发现洞察。

在本章中,我们将探讨如何通过链接聚合网站提取数据,以展示各种新闻故事,从而对新闻文章进行聚类以发现数据中的趋势和模式。

本章涵盖的关键概念包括:

  • 使用 reddit API 收集有趣的新闻故事

  • 从任意网站获取文本

  • 无监督数据挖掘的聚类分析

  • 从文档中提取主题

  • 在线学习以更新模型而无需重新训练

  • 聚类集成以结合不同的模型

趋势主题发现

在本章中,我们将构建一个系统,该系统可以接收新闻文章的实时流并将其分组,使得组内的文章具有相似的主题。你可以运行这个系统几周(或更长的时间)多次,以查看趋势是如何随时间变化的。

我们的系统将从流行的链接聚合网站(www.reddit.com)开始,该网站存储指向其他网站的链接列表,以及一个用于讨论的评论部分。reddit 上的链接被分为几个链接类别,称为subreddits。有专门针对特定电视节目、搞笑图片和其他许多事物的 subreddits。我们感兴趣的是新闻的 subreddits。在本章中,我们将使用/r/worldnews subreddits,但代码应该适用于任何其他基于文本的 subreddits。

在本章中,我们的目标是下载流行的故事,然后对它们进行聚类,以查看任何主要主题或概念的出现。这将使我们能够了解流行的焦点,而无需手动分析数百个单独的故事。一般过程如下:

  1. 从 reddit 收集最近流行的新闻故事的链接。

  2. 从这些链接下载网页。

  3. 仅从下载的网站中提取新闻故事。

  4. 执行聚类分析以找到故事集群。

  5. 分析那些集群以发现趋势。

使用 Web API 获取数据

我们在之前的几章中已经使用基于 Web 的 API 提取数据。例如,在第七章使用图挖掘遵循推荐中,我们使用了 Twitter 的 API 来提取数据。收集数据是数据挖掘流程中的关键部分,基于 Web 的 API 是收集各种主题数据的一种极好的方式。

当使用基于 Web 的 API 收集数据时,您需要考虑三个因素:授权方法、速率限制和 API 端点。

授权方法允许数据提供者知道谁在收集数据,以确保他们被适当限制速率,并且数据访问可以被追踪。对于大多数网站,一个个人账户通常足以开始收集数据,但某些网站会要求您创建一个正式的开发者账户以获取这种访问权限。

速率限制应用于数据收集,尤其是免费服务。在使用 API 时了解规则非常重要,因为它们可以从网站到网站而变化。Twitter 的 API 限制为每 15 分钟 180 次请求(取决于特定的 API 调用)。Reddit,如我们稍后将要看到的,允许每分钟 30 次请求。其他网站可能实施每日限制,而有些网站则按每秒限制。即使在网站内部,不同的 API 调用之间也可能存在巨大的差异。例如,Google Maps 有更小的限制和不同的 API 限制,每个资源都有不同的每小时请求次数限制。

如果您发现您正在创建一个需要更多请求和更快响应的应用或运行实验,大多数 API 提供商都有商业计划,允许更多的调用。请联系提供商获取更多详情。

API 端点是您用来提取信息的实际 URL。这些 URL 因网站而异。通常,基于 Web 的 API 将遵循 RESTful 接口(即表示状态传输)。RESTful 接口通常使用 HTTP 相同的操作:GETPOSTDELETE是最常见的。例如,为了检索资源的信息,我们可能会使用以下(仅作示例)API 端点:

www.dataprovider.com/api/resource_type/resource_id/

为了获取信息,我们只需向该 URL 发送一个 HTTP GET请求。这将返回有关给定类型和 ID 的资源的信息。大多数 API 遵循这种结构,尽管在实现上存在一些差异。大多数具有 API 的网站都会对其进行适当的文档说明,为您提供可以检索的所有 API 的详细信息。

首先,我们设置连接到服务的参数。为此,您需要 Reddit 的开发者密钥。为了获取这个密钥,请登录到 www.reddit.com/login 网站,然后转到 www.reddit.com/prefs/apps。从这里,点击“您是开发者吗?创建一个应用...”,填写表格,将类型设置为脚本。您将获得客户端 ID 和一个密钥,您可以将它们添加到一个新的 Jupyter Notebook 中:

CLIENT_ID = "<Enter your Client ID here>" 
CLIENT_SECRET = "<Enter your Client Secret here>"

Reddit 还要求您(当您使用他们的 API 时)设置一个包含您用户名的唯一字符串作为用户代理。创建一个唯一标识您的应用程序的用户代理字符串。我使用了书名、第十章,以及版本号 0.1 来创建我的用户代理,但可以是任何您喜欢的字符串。请注意,如果不这样做,可能会导致您的连接被大量限制:

USER_AGENT = "python:<your unique user agent> (by /u/<your reddit username>)"

此外,您还需要使用您的用户名和密码登录 Reddit。如果您还没有,可以免费注册一个新的账户(您不需要用个人信息进行验证)。

您将需要密码来完成下一步,所以在将代码分享给他人之前,请小心移除它。如果您不输入密码,将其设置为 none,您将被提示输入它。

现在让我们创建用户名和密码:

from getpass import getpass
USERNAME = "<your reddit username>" 
PASSWORD = getpass("Enter your reddit password:")

接下来,我们将创建一个函数来记录这些信息。Reddit 登录 API 将返回一个令牌,您可以使用它进行进一步的连接,这将是这个函数的结果。以下代码获取登录 Reddit 所需的必要信息,设置用户代理,然后获取我们可以用于未来请求的访问令牌:

import requests
def login(username, password):
    if password is None:
        password = getpass.getpass("Enter reddit password for user {}: ".format(username))    
    headers = {"User-Agent": USER_AGENT}
    # Setup an auth object with our credentials
    client_auth = requests.auth.HTTPBasicAuth(CLIENT_ID, CLIENT_SECRET)
    # Make a post request to the access_token endpoint
    post_data = {"grant_type": "password", "username": username, "password": password}
    response = requests.post("https://www.reddit.com/api/v1/access_token", auth=client_auth,     
                             data=post_data, headers=headers) 
    return response.json()

我们现在可以调用我们的函数来获取访问令牌:

token = login(USERNAME, PASSWORD)

这个令牌对象只是一个字典,但它包含我们将与未来请求一起传递的 access_token 字符串。它还包含其他信息,例如令牌的作用域(这将是一切)和它过期的时间,例如:

{'access_token': '<semi-random string>', 'expires_in': 3600, 'scope': '*', 'token_type': 'bearer'}

如果您正在创建一个生产级别的应用程序,请确保检查令牌的有效期,并在其过期时刷新它。您也会在尝试进行 API 调用时,如果访问令牌停止工作,知道这种情况已经发生。

Reddit 作为数据源

Reddit 是一个全球数百万用户使用的链接聚合网站,尽管其英文版本以美国为中心。任何用户都可以提交他们发现的有趣网站的链接,并为该链接添加标题。其他用户可以对其进行点赞,表示喜欢该链接,或者点踩,表示不喜欢该链接。得票最高的链接会被移至页面顶部,而得票较低的链接则不会显示。随着时间的推移,根据得票数,较旧的链接会被从首页移除。那些获得点赞的用户会获得称为 karma 的积分,这为提交优质故事提供了激励。

Reddit 还允许非链接内容,称为 self-posts。这些包含提交者输入的标题和一些文本。这些用于提问和开始讨论。在本章中,我们将只考虑基于链接的帖子,而不是基于评论的帖子。

帖子被分为网站的不同部分,称为 subreddit。subreddit 是一系列相关的帖子。当用户向 reddit 提交链接时,他们会选择它进入哪个 subreddit。subreddit 有自己的管理员,并有自己的关于该 subreddit 有效内容的规则。

默认情况下,帖子按热门排序,这是一个帖子年龄、点赞数、踩数以及内容自由度的函数。还有,它只提供最近发布的帖子(因此包含大量垃圾邮件和差评帖子),以及Top,它是在给定时间段内获得最高票数的帖子。在本章中,我们将使用热门,这将给我们带来最近、质量较高的故事(在新中确实有很多低质量的链接)。

使用我们之前创建的令牌,我们现在可以从一个 subreddit 中获取链接集合。为此,我们将使用/r/ API 端点,默认情况下返回热门故事。我们将使用/r/worldnews subreddit:

subreddit = "worldnews"

上一个端点的 URL 让我们可以创建完整的 URL,我们可以通过字符串格式化来设置它:

url = "https://oauth.reddit.com/r/{}".format(subreddit)

接下来,我们需要设置头部信息。这是出于两个原因:允许我们使用我们之前收到的授权令牌,并将用户代理设置为防止我们的请求受到严格限制。代码如下:

headers = {"Authorization": "bearer {}".format(token['access_token']), 
"User-Agent": USER_AGENT}

然后,像之前一样,我们使用 requests 库来发起调用,确保我们设置了头部信息:

response = requests.get(url, headers=headers)

在这个端点上调用json()将返回一个包含 reddit 返回信息的 Python 字典。它将包含给定 subreddit 的前 25 个结果。我们可以通过遍历此响应中的故事来获取标题。故事本身存储在字典的 data 键下。代码如下:

result = response.json()
for story in result['data']['children']: 
    print(story['data']['title'])

获取数据

我们的数据集将包括来自/r/worldnews subreddit 热门列表的帖子。我们在前面的部分中看到了如何连接到 reddit 以及如何下载链接。为了将这些内容整合在一起,我们将创建一个函数,该函数将提取给定 subreddit 中每个项目的标题、链接和分数。

我们将遍历 subreddit,每次获取最多 100 个故事。我们还可以进行分页以获取更多结果。在 reddit 阻止我们之前,我们可以读取大量页面,但我们将将其限制为 5 页。

由于我们的代码将反复调用 API,因此记住对我们的调用进行速率限制非常重要。为此,我们需要 sleep 函数:

from time import sleep

我们的功能将接受一个 subreddit 名称和一个授权令牌。我们还将接受要读取的页数,尽管我们将默认设置为 5:

def get_links(subreddit, token, n_pages=5):
    stories = []
    after = None
    for page_number in range(n_pages):
        # Sleep before making calls to avoid going over the API limit
        sleep(2)
        # Setup headers and make call, just like in the login function
        headers = {"Authorization": "bearer {}".format(token['access_token']), "User-Agent": USER_AGENT} 
        url = "https://oauth.reddit.com/r/{}?limit=100". format(subreddit)
        if after:
            # Append cursor for next page, if we have one
            url += "&after={}".format(after)
        response = requests.get(url, headers=headers)
        result = response.json()
        # Get the new cursor for the next loop
        after = result['data']['after']
        # Add all of the news items to our stories list
        for story in result['data']['children']:
            stories.append((story['data']['title'], story['data']['url'], story['data']['score']))
    return stories

我们在第七章,使用图挖掘遵循建议中看到,分页是如何在 Twitter API 中工作的。我们通过返回的结果获得一个游标,我们将它与我们的请求一起发送。Twitter 将使用这个游标来获取下一页的结果。Reddit API 几乎做了完全相同的事情,只是它调用参数在后面。我们不需要它用于第一页,所以我们最初将其设置为 None。在我们的第一页结果之后,我们将将其设置为有意义的值。

调用故事功能是一个简单的案例,只需传递授权令牌和 subreddit 名称:

stories = get_links("worldnews", token)

返回的结果应包含标题、URL 和 500 个故事,我们将使用这些结果提取实际网站上的文本。以下是我运行脚本时收到的标题样本:

俄罗斯考虑禁止向 2015 年后出生的人出售香烟

瑞士穆斯林女孩必须与男孩一起游泳

报告:俄罗斯在瑞典散布虚假新闻和误导信息 - 根据瑞典国际事务研究所研究人员的一份报告,俄罗斯在过去两年中通过散布虚假信息、宣传和伪造文件协调了一场影响瑞典决策的运动

荷兰所有火车现在都使用风能。荷兰提前一年实现了可再生能源目标

对英国全面监控法律的合法挑战迅速获得众筹

一块约等于特拉华州大小的、厚达 1000 英尺的冰块正在从南极洲断裂

根据对美国在全球范围内打击的分析,2016 年美国平均每天投下 72 枚炸弹——相当于每小时三枚——2016 年美国轰炸了伊拉克、叙利亚、巴基斯坦、阿富汗、利比亚、也门、索马里

德国政府正在调查最近虚假新闻激增的情况,据称俄罗斯试图干预该国今年晚些时候的议会选举

巴西乡村几天内农药杀死超过 1000 万只蜜蜂

欧洲伊斯兰国恐怖袭击的美国受害者家属起诉 Twitter,指控这家社交媒体巨头允许恐怖组织在网上传播

尽管气候变化,全球汽油税下降;石油和天然气行业获得 5000 亿美元补贴;美国上一次新的汽油税是在 1993 年

在“超级大屠杀”的情况下,捷克政府告诉公民武装自己并射击穆斯林恐怖分子

如果美国大使馆迁至耶路撒冷,巴勒斯坦解放组织威胁撤销对以色列的承认

欧洲所有新发现的艾滋病病例中有三分之二仅记录在一个国家——俄罗斯:现在有超过一百万俄罗斯人感染了病毒,预计在下一个十年内这个数字将几乎翻倍

捷克政府告诉其公民如何对抗恐怖分子:自己开枪射击 | 内政部正在推动一项宪法变革,这将允许公民使用枪支对抗恐怖分子

摩洛哥禁止出售布卡

大规模杀手布雷维克在权利上诉案中行纳粹礼

索罗斯集团在特朗普获胜后面临清洗风险,匈牙利变得更加大胆

尼日利亚在反腐行动中清除 5 万名“幽灵员工”

研究发现,酒精广告具有侵略性,与青少年饮酒有关 | 社会

英国政府在法律挑战者背后悄然发起“对自由的攻击”,同时分散人们的注意力 - 上一年年底,《调查权力法案》成为法律,赋予间谍阅读每个人整个互联网历史的能力

俄罗斯储备基金在 2016 年下跌 70%

在雅典发现一名俄罗斯外交官死亡

在喀布尔阿富汗议会的附近发生双爆炸,造成至少 21 人死亡(其中大多数是平民)和 45 人受伤

英镑的下跌加深,货币重新获得可疑的荣誉

世界新闻通常不是最乐观的地方,但它确实能让我们了解世界上正在发生的事情,这个 subreddit 的趋势通常可以表明全球的趋势。

从任意网站提取文本

我们从 Reddit 获得的链接指向由许多不同组织运行的任意网站。为了使其更难,这些页面被设计成由人类阅读,而不是由计算机程序阅读。当试图获取这些结果的实际内容/故事时,这可能会引起问题,因为现代网站在后台有很多活动。JavaScript 库被调用,样式表被应用,广告通过 AJAX 加载,侧边栏中添加了额外的内容,以及进行各种其他操作,使现代网页成为一个复杂的文档。这些功能使现代网络成为它现在这样,但使自动从其中获取良好信息变得困难!

在任意网站上寻找故事

首先,我们将从每个链接下载完整的网页,并将它们存储在我们的数据文件夹中,在 raw 子文件夹下。我们将在稍后处理这些内容以提取有用的信息。这种结果的缓存确保我们在工作时不需要持续下载网站。首先,我们设置数据文件夹路径:

import os 
data_folder = os.path.join(os.path.expanduser("~"), "Data", "websites", "raw")

我们将使用 MD5 散列来为我们的文章创建唯一的文件名,通过散列 URL,我们将导入hashlib来完成这个任务。散列函数是一个将某些输入(在我们的情况下是一个包含标题的字符串)转换为看似随机的字符串的函数。相同的输入将始终返回相同的输出,但略微不同的输入将返回截然不同的输出。从散列值到原始值也是不可能的,这使得它成为一个单向函数。

import hashlib

对于本章的实验,我们将简单地跳过任何失败的网站下载。为了确保我们不会因为这样做而丢失太多信息,我们维护一个简单的错误计数器来记录发生的错误数量。我们将抑制任何可能引起系统问题、阻止下载的错误。如果错误计数器过高,我们可以查看这些错误并尝试修复它们。例如,如果电脑没有互联网访问,所有 500 次下载都会失败,你应该在继续之前修复这个问题!

number_errors = 0

接下来,我们遍历我们的每个故事,下载网站,并将结果保存到文件中:

for title, url, score in stories:
    output_filename = hashlib.md5(url.encode()).hexdigest() 
    fullpath = os.path.join(data_folder, output_filename + ".txt")
    try: 
        response = requests.get(url) 
        data = response.text 
        with open(fullpath, 'w') as outf: 
            outf.write(data)
        print("Successfully completed {}".format(title))
    except Exception as e:
        number_errors += 1
        # You can use this to view the errors, if you are getting too many:
        # raise

如果在获取网站时出现错误,我们简单地跳过这个网站并继续。这段代码可以在大量网站上工作,这对我们的应用来说已经足够好了,因为我们寻找的是一般趋势而不是精确度。

注意,有时你确实关心获取 100%的响应,你应该调整你的代码以适应更多的错误。但是要警告,要创建在互联网数据上可靠工作的代码需要显著增加工作量。获取那些最终 5%到 10%的网站的代码将显著更复杂。

在前面的代码中,我们简单地捕获发生的任何错误,记录错误并继续。

如果你发现错误太多,将 print(e)行改为仅输入 raise。这将导致异常被调用,允许你调试问题。

完成此操作后,我们将在raw子文件夹中有一堆网站。在查看这些页面(在文本编辑器中打开创建的文件)后,你可以看到内容在那里,但还有 HTML、JavaScript、CSS 代码以及其他内容。因为我们只对故事本身感兴趣,所以我们现在需要一种方法从这些不同的网站上提取这些信息。

提取内容

在我们获取原始数据后,我们需要在每个中找到故事。为此有几种复杂的算法,以及一些简单的算法。在这里我们将坚持使用简单的方法,考虑到通常情况下,简单的算法就足够了。这是数据挖掘的一部分——知道何时使用简单的算法来完成工作,以及何时使用更复杂的算法来获得额外的性能。

首先,我们获取raw子文件夹中每个文件名的列表:

filenames = [os.path.join(data_folder, filename) for filename in os.listdir(data_folder)]

接下来,我们创建一个用于存放提取的纯文本版本的输出文件夹:

text_output_folder = os.path.join(os.path.expanduser("~"), "Data", "websites", "textonly")

接下来,我们编写将提取文件文本的代码。我们将使用 lxml 库来解析 HTML 文件,因为它有一个处理一些格式不良表达式的良好 HTML 解析器。代码如下:

from lxml import etree

实际提取文本的代码基于三个步骤:

  1. 我们遍历 HTML 文件中的每个节点并提取其中的文本。

  2. 我们跳过任何 JavaScript、样式或注释的节点,因为这些不太可能包含对我们感兴趣的信息。

  3. 我们确保内容至少有 100 个字符。这是一个很好的基线,但可以通过更精确的结果来改进。

正如我们之前所说的,我们对脚本、样式或注释不感兴趣。因此,我们创建了一个列表来忽略这些类型的节点。任何类型在此列表中的节点都不会被视为包含故事。代码如下:

skip_node_types = ["script", "head", "style", etree.Comment]

我们现在将创建一个函数,将 HTML 文件解析为 lxml etree,然后我们将创建另一个函数来解析这个树,寻找文本。这个第一个函数相当直接;只需打开文件并使用 lxml 库的 HTML 文件解析函数创建一个树。代码如下:

parser = etree.HTMLParser()

def get_text_from_file(filename):
    with open(filename) as inf:
        html_tree = etree.parse(inf, parser) 
    return get_text_from_node(html_tree.getroot())

在该函数的最后一条语句中,我们调用getroot()函数来获取树的根节点,而不是完整的etree。这允许我们编写接受任何节点的文本提取函数,因此可以编写递归函数。

此函数将在任何子节点上调用自身以提取文本,然后返回子节点文本的连接。

如果传递给此函数的节点没有子节点,我们只需从它返回文本。如果没有文本,我们只返回一个空字符串。请注意,我们在这里也检查了我们的第三个条件——文本至少 100 个字符长。

检查文本至少 100 个字符长的代码如下:

def get_text_from_node(node):
    if len(node) == 0: 
        # No children, just return text from this item
        if node.text: 
            return node.text 
        else:
            return ""
    else:
        # This node has children, return the text from it:
        results = (get_text_from_node(child)
                   for child in node
                   if child.tag not in skip_node_types)
    result = str.join("n", (r for r in results if len(r) > 1))
    if len(result) >= 100:
        return result
    else:
        return ""

在这一点上,我们知道该节点有子节点,因此我们将递归地对每个子节点调用此函数,然后在它们返回时将结果连接起来。

返回行内的最终条件阻止了空白行的返回(例如,当一个节点没有子节点且没有文本时)。我们还使用了一个生成器,这使得代码更高效,因为它只在需要时获取文本数据,即最终的返回语句,而不是创建多个子列表。

现在,我们可以通过遍历所有原始 HTML 页面来运行此代码,对每个页面调用文本提取函数,并将结果保存到文本仅子文件夹中:

for filename in os.listdir(data_folder):
    text = get_text_from_file(os.path.join(data_folder, filename)) 
    with open(os.path.join(text_output_folder, filename), 'w') as outf: 
        outf.write(text)

您可以通过打开文本仅子文件夹中的每个文件并检查其内容来手动评估结果。如果您发现结果中有太多非故事内容,尝试增加最小 100 字符的限制。如果您仍然无法获得良好的结果,或者需要为您的应用程序获得更好的结果,请尝试在附录 A,下一步中列出的方法。

新闻文章分组

本章的目的是通过聚类或分组新闻文章来发现新闻文章的趋势。为此,我们将使用 k-means 算法,这是一种经典的机器学习算法,最初于 1957 年开发。

聚类是一种无监督学习技术,我们经常使用聚类算法来探索数据。我们的数据集包含大约 500 个故事,逐一检查这些故事会相当困难。使用聚类使我们能够将相似的故事分组在一起,我们可以独立地探索每个簇的主题。

当我们没有明确的数据目标类别时,我们会使用聚类技术。从这个意义上说,聚类算法在他们的学习中几乎没有方向。它们根据某些函数学习,而不考虑数据的潜在含义。

因此,选择好的特征至关重要。在监督学习中,如果你选择了不好的特征,学习算法可以选择不使用这些特征。例如,支持向量机会对在分类中无用的特征赋予很小的权重。然而,在聚类中,所有特征都会用于最终结果——即使这些特征没有为我们提供我们想要的答案。

在对现实世界数据进行聚类分析时,了解在您的场景中哪些类型的特征将起作用总是一个好主意。在本章中,我们将使用词袋模型。我们正在寻找基于主题的组,因此我们将使用基于主题的特征来建模文档。我们知道这些特征有效,因为其他人已经在我们的问题的监督版本中进行了工作。相比之下,如果我们进行基于作者身份的聚类,我们会使用诸如第九章,作者身份归因实验中找到的特征。

k-means 算法

k-means 聚类算法通过迭代过程找到最能代表数据的质心。算法从一个预定义的质心集合开始,这些质心通常是来自训练数据的数据点。k-means 中的k是要寻找的质心数量以及算法将找到多少个簇。例如,将 k 设置为 3 将在数据集中找到三个簇。

k-means 有两个阶段:分配更新。它们如下所述:

  • 分配步骤中,我们为数据集中的每个样本设置一个标签,将其与最近的质心关联。对于距离质心 1 最近的样本,我们分配标签 1。对于距离质心 2 最近的样本,我们分配标签 2,依此类推,为每个 k 个质心分配标签。这些标签形成了簇,因此我们说每个带有标签 1 的数据点都在簇 1 中(目前是这样,因为随着算法的运行,分配可能会改变)。

  • 更新步骤中,我们针对每个簇计算其质心,即该簇中所有样本的平均值。

算法在分配步骤和更新步骤之间迭代;每次更新步骤发生时,每个质心都会移动一小段距离。这导致分配略有变化,导致在下一个迭代中质心也会略有移动。这会一直重复,直到达到某个停止标准。

常常在迭代一定次数后停止,或者当质心的总移动量非常低时停止。在某些情况下,算法也可以完成,这意味着簇是稳定的——分配不会改变,质心也不会改变。

在下面的图中,对随机创建但包含三个数据集簇的数据集执行了 k-means 算法。星星代表质心的起始位置,这些位置是通过从数据集中随机选择一个样本来随机选择的。经过 k-means 算法的 5 次迭代,质心移动到由三角形表示的位置。

图片

K-means 算法因其数学特性和历史意义而令人着迷。这是一个(大致上)只有一个参数的算法,非常有效且经常使用,即使在其发现 50 多年后也是如此。

scikit-learn 中有一个 k-means 算法,我们是从 scikit-learn 的cluster模块导入的:

from sklearn.cluster import KMeans

我们还导入了CountVectorizer类的近亲TfidfVectorizer。这个向量器根据每个词的出现次数进行加权,具体取决于它在多少个文档中出现,使用以下公式:tf / log(df),其中 tf 是一个词的频率(它在当前文档中出现的次数)和 df 是一个词的文档频率(在我们的语料库中它出现的文档数)。在许多文档中出现的词被赋予较低的权重(通过除以它出现的文档数的对数)。对于许多文本挖掘应用,使用这种类型的加权方案可以非常可靠地提高性能。代码如下:

from sklearn.feature_extraction.text import TfidfVectorizer

然后,我们为我们的分析设置管道。这有两个步骤。第一步是应用我们的向量器,第二步是应用我们的 k-means 算法。代码如下:

from sklearn.pipeline import Pipeline
n_clusters = 10 
pipeline = Pipeline([('feature_extraction', TfidfVectorizer(max_df=0.4)),
                                     ('clusterer', KMeans(n_clusters=n_clusters)) ])

max_df参数设置为低值 0.4,这意味着忽略任何在超过 40%的文档中出现的单词。这个参数对于移除那些本身对主题意义贡献很小的功能词来说是无价的。

移除在超过 40%的文档中出现的任何单词将移除功能词,这使得这种类型的预处理对于我们在第九章,作者归属中看到的工作来说非常无用。

documents = [open(os.path.join(text_output_folder, filename)).read()
             for filename in os.listdir(text_output_folder)]

然后,我们拟合并预测这个管道。到目前为止,这本书中我们已经多次遵循这个过程进行分类任务,但这里有一个区别——我们不将数据集的目标类别提供给拟合函数。这就是使其成为无监督学习任务的原因!代码如下:

pipeline.fit(documents)
labels = pipeline.predict(documents)

标签变量现在包含每个样本的聚类编号。具有相同标签的样本被认为属于同一个聚类。需要注意的是,聚类标签本身没有意义:聚类 1 和 2 与聚类 1 和 3 的相似度没有区别。

我们可以使用Counter类查看每个聚类中放置了多少个样本:

from collections import Counter
c = Counter(labels) 
for cluster_number in range(n_clusters): 
    print("Cluster {} contains {} samples".format(cluster_number, c[cluster_number]))

Cluster 0 contains 1 samples
Cluster 1 contains 2 samples
Cluster 2 contains 439 samples
Cluster 3 contains 1 samples
Cluster 4 contains 2 samples
Cluster 5 contains 3 samples
Cluster 6 contains 27 samples
Cluster 7 contains 2 samples
Cluster 8 contains 12 samples
Cluster 9 contains 1 samples

许多结果(记住你的数据集将与我的大不相同)包括一个包含大多数实例的大聚类,几个中等大小的聚类,以及一些只有一个或两个实例的聚类。这种不平衡在许多聚类应用中相当正常。

评估结果

聚类主要是一种探索性分析,因此很难有效地评估聚类算法的结果。一种直接的方法是根据算法试图学习的标准来评估算法。

如果你有一个测试集,你可以用它来评估聚类效果。更多详情,请访问nlp.standford.edu/IR-book/html/htmledition/evaluation-of-clustering-1.html

在 k-means 算法的情况下,它开发质心时使用的标准是使每个样本到其最近质心的距离最小化。这被称为算法的惯性,可以从任何已经调用 fit 的 KMeans 实例中检索到:

pipeline.named_steps['clusterer'].inertia_

在我的数据集上的结果是 343.94。不幸的是,这个值本身相当没有意义,但我们可以用它来确定我们应该使用多少个聚类。在先前的例子中,我们将n_clusters设置为 10,但这真的是最佳值吗?下面的代码运行 k-means 算法 10 次,每次使用从 2 到 20 的每个n_clusters值,这需要一些时间来完成大量运行。

对于每次运行,它记录结果的惯性。

你可能会注意到以下代码我们没有使用 Pipeline;相反,我们将步骤拆分出来。我们只为每个n_clusters值从我们的文本文档中创建一次 X 矩阵,以(显著地)提高代码的速度。

inertia_scores = [] 
n_cluster_values = list(range(2, 20)) 
for n_clusters in n_cluster_values: 
    cur_inertia_scores = [] 
    X = TfidfVectorizer(max_df=0.4).fit_transform(documents) 
 for i in range(10): 
        km = KMeans(n_clusters=n_clusters).fit(X) 
        cur_inertia_scores.append(km.inertia_) 
    inertia_scores.append(cur_inertia_scores)

inertia_scores变量现在包含从 2 到 20 的每个n_clusters值的惯性分数列表。我们可以绘制这些值,以了解这个值如何与n_clusters相互作用:

%matplotlib inline
from matplotlib import pyplot as plt
inertia_means = np.mean(inertia_scores, axis=1)
inertia_stderr = np.std(inertia_scores, axis=1)
fig = plt.figure(figsize=(40,20))
plt.errorbar(n_cluster_values, inertia_means, inertia_stderr, color='green')
plt.show()

图片

总体来说,随着聚类数量的增加,惯性值应该随着改进的减少而降低,这一点我们可以从这些结果中大致看出。从 6 到 7 之间的值增加仅由于选择质心的随机性,这直接影响了最终结果的好坏。尽管如此,对于我的数据来说(你的结果可能会有所不同),大约 6 个聚类是惯性发生重大改进的最后一次。

在这一点之后,对惯性只有轻微的改进,尽管很难具体说明这种模糊的标准。寻找这种类型的模式被称为肘部规则,因为我们正在寻找图表中的肘部弯曲。一些数据集有更明显的肘部,但这个特征并不保证出现(有些图表可能是平滑的!)

基于这种分析,我们将 n_clusters 设置为 6,然后重新运行算法:

n_clusters = 6 
pipeline = Pipeline([('feature_extraction', TfidfVectorizer(max_df=0.4)),
                     ('clusterer', KMeans(n_clusters=n_clusters)) ])
pipeline.fit(documents) 
labels = pipeline.predict(documents)

从簇中提取主题信息

现在,我们将注意力转向簇,试图发现每个簇中的主题。

我们首先从特征提取步骤中提取术语列表:

terms = pipeline.named_steps['feature_extraction'].get_feature_names()

我们还设置了一个计数器来统计每个类的大小:

c = Counter(labels)

遍历每个簇,我们像以前一样打印簇的大小。

在评估结果时,重要的是要记住簇的大小——一些簇可能只有一个样本,因此不能表明一般趋势。

接下来(仍然在循环中),我们遍历这个簇最重要的术语。为此,我们从质心中取出五个最大的值,这些值是通过找到质心中具有最高值的特征得到的。

for cluster_number in range(n_clusters): 
    print("Cluster {} contains {} samples".format(cluster_number, c[cluster_number]))
    print(" Most important terms")
    centroid = pipeline.named_steps['clusterer'].cluster_centers_[cluster_number]
    most_important = centroid.argsort()
    for i in range(5):
        term_index = most_important[-(i+1)]
        print(" {0}) {1} (score: {2:.4f})".format(i+1, terms[term_index], centroid[term_index]))

结果可以非常指示当前趋势。在我的结果(2017 年 1 月获得)中,簇对应于健康问题、中东紧张局势、韩国紧张局势和俄罗斯事务。这些是当时新闻中频繁出现的主要话题——尽管这些年来几乎没有变化!

你可能会注意到一些没有太多价值的词出现在最上面,例如 你, 她和mr.* 这些功能词对于作者分析来说非常好——正如我们在第九章中看到的,作者归属分析,但它们通常并不适合主题分析。将功能词列表传递到我们上面管道中的 stop_words 参数将会忽略这些词。以下是构建此类管道的更新代码:

function_words = [... list from Chapter 9 ...]

pipeline = Pipeline([('feature_extraction', TfidfVectorizer(max_df=0.4, stop_words=function_words)),
                     ('clusterer', KMeans(n_clusters=n_clusters)) ])

将聚类算法作为转换器使用

作为旁注,关于 k-means 算法(以及任何聚类算法)的一个有趣性质是,你可以用它来进行特征降维。有许多方法可以减少特征数量(或创建新特征以在数据集上嵌入),例如 主成分分析潜在语义索引和许多其他方法。这些算法的一个问题是它们通常需要大量的计算能力。

在前面的例子中,术语列表中有超过 14,000 个条目——这是一个相当大的数据集。我们的 k-means 算法将其转换成仅仅六个簇。然后我们可以通过取每个质心的距离作为特征来创建一个具有更低特征数量的数据集。

要做到这一点,我们在一个 KMeans 实例上调用 transform 函数。我们的管道适合这个目的,因为它在最后有一个 k-means 实例:

X = pipeline.transform(documents)

这是在管道的最后一步调用转换方法,这是一个 k-means 的实例。这导致一个具有六个特征和样本数量与文档长度相同的矩阵。

然后,你可以对结果进行自己的二级聚类,或者如果你有目标值,可以使用它进行分类。这个工作流程的一个可能方法是使用监督数据执行一些特征选择,使用聚类将特征数量减少到更易于管理的数量,然后在一个分类算法(如 SVMs)中使用这些结果。

聚类集成

在第三章,使用决策树预测体育胜者中,我们研究了使用随机森林算法的分类集成,它是由许多低质量的基于树的分类器组成的集成。集成也可以使用聚类算法来完成。这样做的一个关键原因是平滑算法多次运行的结果。正如我们之前看到的,k-means 运行的结果因初始质心的选择而异。通过多次运行算法并合并结果可以减少这种变化。

集成也减少了选择参数对最终结果的影响。大多数聚类算法对算法选择的参数值非常敏感。选择略微不同的参数会导致不同的聚类。

证据累积

作为基本集成,我们首先多次聚类数据并记录每次运行的标签。然后我们在一个新的矩阵中记录每一对样本被聚类的次数。这就是证据累积聚类EAC)算法的本质。

EAC 有两个主要步骤。

  1. 第一步是多次使用低级聚类算法(如 k-means)对数据进行聚类,并记录样本在每个迭代中位于同一聚类的频率。这被存储在一个共关联矩阵中。

  2. 第二步是对结果共关联矩阵进行聚类分析,这使用另一种称为层次聚类的聚类算法来完成。它具有一个有趣的基于图论的性质,因为它在数学上等同于找到一个将所有节点连接起来的树并移除弱连接。

我们可以通过遍历每个标签并记录两个样本具有相同标签的位置,从标签数组创建一个共关联矩阵。我们使用 SciPy 的csr_matrix,这是一种稀疏矩阵:

from scipy.sparse import csr_matrix

我们的功能定义接受一组标签,然后记录每个匹配的行和列。我们在一个列表中完成这些操作。稀疏矩阵通常只是记录非零值位置的列表集合,csr_matrix就是这种类型稀疏矩阵的一个例子。对于具有相同标签的每一对样本,我们在列表中记录这两个样本的位置:

import numpy as np
def create_coassociation_matrix(labels):
    rows = [] 
    cols = []
    unique_labels = set(labels) 
    for label in unique_labels:
        indices = np.where(labels == label)[0]
        for index1 in indices:
            for index2 in indices:
                rows.append(index1)
                cols.append(index2)
    data = np.ones((len(rows),)) 
    return csr_matrix((data, (rows, cols)), dtype='float')

要从标签中获得共关联矩阵,我们只需调用此函数:

C = create_coassociation_matrix(labels)

从这里,我们可以将这些矩阵的多个实例相加。这允许我们将 k-means 多次运行的结果结合起来。打印出C(只需在 Jupyter Notebook 的新单元格中输入 C 并运行)将告诉你有多少单元格中有非零值。在我的情况下,大约一半的单元格中有值,因为我的聚类结果有一个大簇(簇越均匀,非零值的数量越低)。

下一步涉及共关联矩阵的层次聚类。我们将通过在这个矩阵上找到最小生成树并移除权重低于给定阈值的边来完成此操作。

在图论中,生成树是连接图中所有节点的边的集合。最小生成树(MST)只是具有最低总权重的生成树。在我们的应用中,图中的节点是我们数据集中的样本,边权重是这两个样本被聚在一起的次数——即我们的共关联矩阵中的值。

在以下图中,展示了六个节点的图上的 MST。在 MST 中,图上的节点可以多次连接,只要所有节点都连接在一起即可。

图片

为了计算 MST,我们使用 SciPy 的minimum_spanning_tree函数,该函数位于 sparse 包中:

from scipy.sparse.csgraph import minimum_spanning_tree

mst函数可以直接在由我们的共关联函数返回的稀疏矩阵上调用:

mst = minimum_spanning_tree(C)

然而,在我们的共关联矩阵 C 中,较高的值表示经常被聚在一起的样本——这是一个相似度值。相比之下,minimum_spanning_tree将输入视为距离,较高的分数会受到惩罚。因此,我们在共关联矩阵的取反上计算最小生成树:

mst = minimum_spanning_tree(-C)

前一个函数的结果是一个与共关联矩阵大小相同的矩阵(行数和列数与数据集中的样本数量相同),只保留了 MST 中的边,其他所有边都被移除。

然后,我们移除任何权重低于预定义阈值的节点。为此,我们遍历 MST 矩阵中的边,移除任何小于特定值的边。我们无法仅通过在共关联矩阵中迭代一次来测试这一点(值将是 1 或 0,因此没有太多可操作的空间)。因此,我们首先创建额外的标签,创建共关联矩阵,然后将两个矩阵相加。代码如下:

pipeline.fit(documents) 
labels2 = pipeline.predict(documents) 
C2 = create_coassociation_matrix(labels2) 
C_sum = (C + C2) / 2

然后,我们计算 MST 并移除在这两个标签中都没有出现的任何边:

mst = minimum_spanning_tree(-C_sum) 
mst.data[mst.data > -1] = 0

我们想要截断的阈值是任何不在两个聚类中的边——即值为 1 的边。然而,由于我们取反了共关联矩阵,我们也必须取反阈值值。

最后,我们找到所有的连通分量,这仅仅是一种找到在移除低权重边后仍然通过边连接的所有样本的方法。第一个返回值是连通分量的数量(即簇的数量),第二个是每个样本的标签。代码如下:

from scipy.sparse.csgraph import connected_components 
number_of_clusters, labels = connected_components(mst)

在我的数据集中,我获得了八个簇,簇与之前的大致相同。考虑到我们只使用了两次 k-means 迭代,这几乎不足为奇;使用更多的 k-means 迭代(如我们在下一节中做的那样)将导致更大的方差。

工作原理

在 k-means 算法中,每个特征都是无差别地使用的。本质上,所有特征都被假定为处于相同的尺度。我们在第二章使用 scikit-learn 估计器的分类中看到了不缩放特征的问题。结果是 k-means 正在寻找圆形簇,如图所示:

图片

k-means 也可以发现椭圆形簇。分离通常并不那么平滑,但可以通过特征缩放来简化。以下是一个这种形状簇的例子:

图片

如前一个屏幕截图所示,并非所有簇都具有这种形状。蓝色簇是圆形的,是 k-means 非常擅长识别的类型。红色簇是椭圆形。k-means 算法可以通过一些特征缩放识别这种形状的簇。

下面第三个簇甚至不是凸形的——它是一个 k-means 难以发现的奇形怪状,但至少在大多数观察图片的人类看来,它仍然被认为是

图片

聚类分析是一个困难的任务,其中大部分困难仅仅在于试图定义问题。许多人直观地理解它的含义,但试图用精确的术语定义它(对于机器学习来说是必要的)是非常困难的。甚至人们经常对这个词有不同的看法!

EAC 算法通过将特征重新映射到新的空间中工作,本质上是将 k-means 算法的每次运行转化为一个使用与上一节中用于特征减少的 k-means 相同原理的转换器。然而,在这种情况下,我们只使用实际的标签,而不是到每个质心的距离。这是记录在共关联矩阵中的数据。

结果是,EAC 现在只关心事物之间的接近程度,而不是它们在原始特征空间中的位置。仍然存在未缩放特征的问题。特征缩放很重要,无论如何都应该进行(我们在本章中使用了 tf-idf 进行缩放,这导致特征值具有相同的尺度)。

我们在第九章作者归属分析中看到了类似类型的转换,通过在 SVM 中使用核。这些转换非常强大,应该记住用于复杂的数据集。然而,将数据重新映射到新特征空间上的算法不需要太复杂,正如你将在 EAC 算法中看到的那样。

实现

将所有这些放在一起,我们现在可以创建一个符合 scikit-learn 接口的聚类算法,该算法执行 EAC 中的所有步骤。首先,我们使用 scikit-learn 的ClusterMixin创建类的基本结构。

我们的参数包括在第一步中执行 k-means 聚类的数量(用于创建共关联矩阵)、截断的阈值以及每个 k-means 聚类中要找到的聚类数量。我们设置了一个 n_clusters 的范围,以便在我们的 k-means 迭代中获得大量的方差。一般来说,在集成术语中,方差是一件好事;没有它,解决方案可能不会比单个聚类更好(尽管如此,高方差并不是集成将更好的指标)。

我将首先展示完整的类,然后概述每个函数:

from sklearn.base import BaseEstimator, ClusterMixin
class EAC(BaseEstimator, ClusterMixin):
    def __init__(self, n_clusterings=10, cut_threshold=0.5, n_clusters_range=(3, 10)): 
        self.n_clusterings = n_clusterings
        self.cut_threshold = cut_threshold
        self.n_clusters_range = n_clusters_range

    def fit(self, X, y=None):
        C = sum((create_coassociation_matrix(self._single_clustering(X))
                 for i in range(self.n_clusterings)))
        mst = minimum_spanning_tree(-C)
        mst.data[mst.data > -self.cut_threshold] = 0
        mst.eliminate_zeros()
        self.n_components, self.labels_ = connected_components(mst)
        return self

    def _single_clustering(self, X):
        n_clusters = np.random.randint(*self.n_clusters_range)
        km = KMeans(n_clusters=n_clusters)
        return km.fit_predict(X)

    def fit_predict(self, X):
        self.fit(X)
        return self.labels_

fit函数的目标是执行 k-means 聚类多次,合并共关联矩阵,然后通过找到 MST 来分割它,就像我们在之前的 EAC 示例中看到的那样。然后我们使用 k-means 执行我们的低级聚类,并将每个迭代的共关联矩阵结果相加。我们这样做是为了节省内存,只在需要时创建共关联矩阵。在这个生成器的每个迭代中,我们使用我们的数据集创建一个新的单次 k-means 运行,然后为其创建共关联矩阵。我们使用sum将这些值相加。

与之前一样,我们创建最小生成树(MST),移除任何小于给定阈值的边(如前所述,正确地取反值),并找到连通分量。与 scikit-learn 中的任何 fit 函数一样,我们需要返回 self,以便类在管道中有效地工作。

_single_clustering函数被设计用来在我们的数据上执行一次 k-means 迭代,然后返回预测的标签。为此,我们使用 NumPy 的randint函数和我们的n_clusters_range参数随机选择要找到的聚类数量,该参数设置了可能值的范围。然后我们使用 k-means 聚类和预测数据集。这里的返回值将是来自 k-means 的标签。

最后,fit_predict函数简单地调用 fit,然后返回文档的标签。

现在,我们可以通过设置一个与之前相同的管道并使用 EAC(而不是之前作为管道的最终阶段的 KMeans 实例)来运行此代码。代码如下:

pipeline = Pipeline([('feature_extraction', TfidfVectorizer(max_df=0.4)),
                     ('clusterer', EAC()) ])

在线学习

在某些情况下,在我们开始学习之前,我们没有所有需要的训练数据。有时,我们正在等待新数据的到来,也许我们拥有的数据太大而无法放入内存,或者在我们做出预测之后收到了额外的数据。在这种情况下,在线学习是随着时间训练模型的一个选项。

在线学习是新数据到达时对模型的增量更新。支持在线学习的算法可以一次训练一个或几个样本,并在新样本到达时进行更新。相比之下,不支持在线的算法需要一次性访问所有数据。标准的 k-means 算法就是这样,我们在这本书中看到的大多数算法也是如此。

算法的在线版本有一种方法,只需少量样本就可以部分更新其模型。神经网络是算法在线工作的一种标准示例。当神经网络接收到一个新的样本时,网络中的权重会根据学习率进行更新,这个学习率通常是一个非常小的值,如 0.01。这意味着任何单个实例只会对模型产生小的(但希望是改进的)变化。

神经网络也可以批量模式进行训练,其中一次给出一个样本组,并在一个步骤中完成训练。算法在批量模式下通常更快,但使用更多的内存。

在这个思路下,我们可以在单个或小批量样本之后稍微更新 k-means 的中心点。为此,我们在 k-means 算法的更新步骤中对中心点的移动应用一个学习率。假设样本是从总体中随机选择的,中心点应该倾向于移动到它们在标准、离线和 k-means 算法中的位置。

在线学习与基于流的 leaning 相关;然而,有一些重要的区别。在线学习能够在模型中使用过旧样本之后回顾它们,而基于流的机器学习算法通常只进行一次遍历——也就是说,只有一个机会查看每个样本。

实现

scikit-learn 包包含MiniBatchKMeans算法,它允许在线学习。这个类实现了一个 partial_fit 函数,它接受一组样本并更新模型。相比之下,调用 fit()将删除任何以前的训练,并且仅在新的数据上重新拟合模型。

MiniBatchKMeans 遵循 scikit-learn 中其他算法相同的聚类格式,因此创建和使用它与其他算法非常相似。

该算法通过计算它所看到的所有点的流平均来工作。为此,我们只需要跟踪两个值,即所有已看到点的当前总和和已看到点的数量。然后我们可以使用这些信息,结合一组新的点,在更新步骤中计算新的平均值。

因此,我们可以通过使用TfIDFVectorizer从我们的数据集中提取特征来创建矩阵 X,然后从该矩阵中采样以增量更新我们的模型。代码如下:

vec = TfidfVectorizer(max_df=0.4) 
X = vec.fit_transform(documents)

我们首先导入 MiniBatchKMeans 并创建其实例:

from sklearn.cluster import MiniBatchKMeans 
mbkm = MiniBatchKMeans(random_state=14, n_clusters=3)

接下来,我们将从我们的 X 矩阵中随机采样以模拟来自外部源的数据。每次我们获取一些数据时,我们都会更新模型:

batch_size = 10 
for iteration in range(int(X.shape[0] / batch_size)): 
    start = batch_size * iteration 
    end = batch_size * (iteration + 1) 
    mbkm.partial_fit(X[start:end])

然后,我们可以通过要求实例进行预测来获取原始数据集的标签:

labels = mbkm.predict(X)

然而,在这个阶段,我们无法在流水线中这样做,因为TfidfVectorizer不是一个在线算法。为了克服这个问题,我们使用HashingVectorizerHashingVectorizer类是巧妙地使用哈希算法来极大地减少计算词袋模型所需的内存。我们不是记录特征名称,如文档中找到的单词,而是只记录这些名称的哈希值。这使得我们甚至在查看数据集之前就能知道我们的特征,因为它是所有可能的哈希值的集合。这是一个非常大的数字,通常为 2 的 18 次方。使用稀疏矩阵,我们可以非常容易地存储和计算甚至这样大小的矩阵,因为矩阵中的大部分值将是 0。

目前,Pipeline类不允许在在线学习中使用。不同应用中存在一些细微差别,意味着没有一种显而易见的通用方法可以实施。相反,我们可以创建自己的Pipeline子类,这样我们就可以用它来进行在线学习。我们首先从Pipeline派生我们的类,因为我们只需要实现一个函数:

class PartialFitPipeline(Pipeline):
    def partial_fit(self, X, y=None):
        Xt = X
        for name, transform in self.steps[:-1]:
            Xt = transform.transform(Xt)
        return self.steps[-1][1].partial_fit(Xt, y=y)

我们需要实现的唯一函数是partial_fit函数,它首先执行所有转换步骤,然后在最终步骤(应该是分类器或聚类算法)上调用部分拟合。所有其他函数与正常Pipeline类中的函数相同,因此我们通过类继承来引用它们。

现在,我们可以创建一个流水线来在我们的在线学习中使用MiniBatchKMeansHashingVectorizer。除了使用我们新的PartialFitPipelineHashingVectorizer类之外,这个过程与本章其余部分使用的过程相同,只是我们一次只对少量文档进行拟合。代码如下:

from sklearn.feature_extraction.text import HashingVectorizer

pipeline = PartialFitPipeline([('feature_extraction', HashingVectorizer()),
                               ('clusterer', MiniBatchKMeans(random_state=14, n_clusters=3)) ])
batch_size = 10 
for iteration in range(int(len(documents) / batch_size)): 
    start = batch_size * iteration end = batch_size * (iteration + 1)
    pipeline.partial_fit(documents[start:end]) 
labels = pipeline.predict(documents)

这种方法有一些缺点。首先,我们无法轻易找出对每个簇最重要的单词。我们可以通过拟合另一个CountVectorizer并取每个单词的哈希值来解决这个问题。然后我们通过哈希值而不是单词来查找值。这有点繁琐,并且抵消了使用 HashingVectorizer 带来的内存节省。此外,我们无法使用之前使用的max_df参数,因为它需要我们知道特征的含义并随时间计数。

在进行在线训练时,我们也不能使用 tf-idf 权重。虽然可以近似这种方法并应用这种权重,但这种方法仍然很繁琐。HashingVectorizer仍然是一个非常有用的算法,并且是哈希算法的绝佳应用。

摘要

在本章中,我们探讨了聚类,这是一种无监督学习方法。我们使用无监督学习来探索数据,而不是用于分类和预测目的。在本实验中,我们没有为在 Reddit 上找到的新闻条目设置主题,因此无法进行分类。我们使用了 k-means 聚类来将这些新闻故事分组,以找到数据中的共同主题和趋势。

在从 Reddit 获取数据时,我们必须从任意网站提取数据。这是通过寻找大文本段来完成的,而不是使用完整的机器学习方法。对于这项任务,有一些有趣的机器学习方法可能可以改进这些结果。在这本书的附录中,我为每一章列出了超越章节范围并改进结果的方法。这包括对其他信息来源和每一章中方法的更复杂应用的参考。

我们还研究了简单的集成算法 EAC。集成通常是一种处理结果方差的好方法,尤其是当你不知道如何选择好的参数时(这在聚类中总是很困难)。

最后,我们介绍了在线学习。这是通向更大学习练习的门户,包括大数据,这将在本书的最后两章中讨论。这些最后的实验相当大,需要管理数据以及从数据中学习模型。

作为本章工作的扩展,尝试实现 EAC 作为在线学习算法。这不是一个简单任务,需要考虑当算法更新时应该发生什么。另一个扩展是收集更多来自更多数据源(如其他 subreddits 或直接从新闻网站或博客)的数据,并寻找一般趋势。

在下一章中,我们将从无监督学习转向分类。我们将探讨深度学习,这是一种基于复杂神经网络的分类方法。

第十一章:使用深度神经网络在图像中进行对象检测

我们在第八章,使用神经网络战胜 CAPTCHA中使用了基本的神经网络。神经网络的研究正在创造许多领域中最先进和最精确的分类算法。本章介绍的概念与第八章,使用神经网络战胜 CAPTCHA中介绍的概念之间的区别在于复杂性。在本章中,我们将探讨深度神经网络,那些具有许多隐藏层的网络,以及用于处理特定类型信息(如图像)的更复杂的层类型。

这些进步是在计算能力提高的基础上实现的,使我们能够训练更大、更复杂的网络。然而,这些进步远不止于简单地投入更多的计算能力。新的算法和层类型极大地提高了性能,而不仅仅是计算能力。代价是这些新的分类器需要比其他数据挖掘分类器更多的数据来学习。

在本章中,我们将探讨确定图像中代表的是什么对象。像素值将被用作输入,然后神经网络将自动找到有用的像素组合来形成高级特征。这些特征将被用于实际的分类。

总体而言,在本章中,我们将探讨以下内容:

  • 在图像中分类对象

  • 不同的深度神经网络

  • 使用 TensorFlow 和 Keras 库构建和训练神经网络

  • 使用 GPU 提高算法的速度

  • 使用基于云的服务为数据挖掘提供额外的计算能力

图像中的对象分类

计算机视觉正成为未来技术的重要组成部分。例如,我们将在不久的将来能够使用自动驾驶汽车 - 汽车制造商计划在 2017 年发布自动驾驶车型,并且已经部分实现自动驾驶。为了实现这一点,汽车的电脑需要能够看到周围的环境;识别障碍物、其他交通和天气状况;然后利用这些信息规划安全的行程。

虽然我们可以轻松地检测是否有障碍物,例如使用雷达,但了解那个物体是什么也同样重要。如果它是路上的动物,我们可以停车让它离开;如果它是一座建筑,这种策略效果不会很好!

用例

计算机视觉在许多场景中被使用。以下是一些它们的应用非常重要的例子。

  • 在线地图网站,如谷歌地图,出于多种原因使用计算机视觉。其中一个原因是自动模糊他们发现的任何面孔,以给作为其街景功能一部分被拍摄的人提供一些隐私。

  • 面部检测也被广泛应用于许多行业。现代相机自动检测面部,作为提高拍摄照片质量的一种手段(用户最常希望聚焦于可见的面部)。面部检测还可以用于识别。例如,Facebook 自动识别照片中的人,使得轻松标记朋友变得容易。

  • 正如我们之前所述,自动驾驶汽车高度依赖于计算机视觉来识别它们的路径并避开障碍物。计算机视觉是正在解决的关键问题之一,这不仅是在自动驾驶汽车的研究中,不仅是为了消费使用,还包括采矿和其他行业。

  • 其他行业也在使用计算机视觉,包括仓库自动检查货物缺陷。

  • 太空产业也在使用计算机视觉,帮助自动化数据的收集。这对于有效使用航天器至关重要,因为从地球向火星上的漫游车发送信号可能需要很长时间,而且在某些时候(例如,当两颗行星不面对彼此时)是不可能的。随着我们开始更频繁地处理基于太空的车辆,并且从更远的距离处理,提高这些航天器的自主性是绝对必要的,而计算机视觉是这一过程中的关键部分。以下图片展示了 NASA 设计和使用的火星漫游车;它在识别一个陌生、不适宜居住的星球周围环境时,显著地使用了计算机视觉。

图片

应用场景

在本章中,我们将构建一个系统,它将接受图像作为输入,并预测图像中的物体是什么。我们将扮演汽车视觉系统的角色,观察道路上的任何障碍物。图像的形式如下:

图片

这个数据集来自一个流行的数据集,称为 CIFAR-10。它包含 60,000 张宽度为 32 像素、高度为 32 像素的图像,每个像素都有一个红绿蓝(RGB)值。数据集已经被分为训练集和测试集,尽管我们将在完成训练后才会使用测试数据集。

CIFAR-10 数据集可在www.cs.toronto.edu/~kriz/cifar.html下载。

下载 Python 版本,它已经被转换成 NumPy 数组。

打开一个新的 Jupyter Notebook,我们可以看到数据的样子。首先,我们设置数据文件名。我们最初只关注第一批数据,并在最后扩大到整个数据集的大小;

import os
data_folder = os.path.join(os.path.expanduser("~"), "Data", "cifar-10-batches-py") 
batch1_filename = os.path.join(data_folder, "data_batch_1")

接下来,我们创建一个函数来读取存储在批次中的数据。这些批次是使用 pickle 保存的,pickle 是一个用于保存对象的 Python 库。通常,我们只需在文件上调用pickle.load(file)来获取对象。然而,这个数据存在一个小问题:它是在 Python 2 中保存的,但我们需要在 Python 3 中打开它。为了解决这个问题,我们将编码设置为latin(即使我们是以字节模式打开的):

import pickle
# Bugfix thanks to: http://stackoverflow.com/questions/11305790/pickle-incompatability-of-numpy-arrays-between-python-2-and-3 
def unpickle(filename): 
    with open(filename, 'rb') as fo: 
        return pickle.load(fo, encoding='latin1')

使用这个函数,我们现在可以加载批数据集:

batch1 = unpickle(batch1_filename)

这个批次是一个字典,包含实际数据(NumPy 数组)、相应的标签和文件名,以及一个说明它是哪个批次的注释(例如,这是 5 个训练批次中的第 1 个)。

我们可以通过使用批次的索引来提取图片:

image_index = 100 
image = batch1['data'][image_index]

图片数组是一个包含 3072 个条目的 NumPy 数组,范围从 0 到 255。每个值是图像中特定位置的红色、绿色或蓝色强度。

这些图片的格式与 matplotlib 通常使用的格式不同(用于显示图片),因此为了显示图片,我们首先需要重塑数组并旋转矩阵。这对训练我们的神经网络来说并不重要(我们将以适合数据的方式定义我们的网络),但我们确实需要将其转换为 matplotlib 的原因:

image = image.reshape((32,32, 3), order='F') 
import numpy as np 
image = np.rot90(image, -1)

现在我们可以使用 matplotlib 显示图片:

%matplotlib inline

from matplotlib import pyplot as plt 
plt.imshow(image)

结果图像,一艘船,被显示出来:

图片

这张图片的分辨率相当低——它只有 32 像素宽和 32 像素高。尽管如此,大多数人看到这张图片时都会看到一个船。我们能否让计算机做到同样的事情?

你可以更改图片索引来显示不同的图片,从而了解数据集的特性。

本章我们的项目目标是构建一个分类系统,它可以接受这样的图片并预测其中的物体是什么。但在我们这样做之前,我们将绕道学习我们将要使用的分类器:深度神经网络

深度神经网络

我们在第八章,使用神经网络战胜 CAPTCHA中使用的神经网络具有一些出色的理论特性。例如,学习任何映射只需要一个隐藏层(尽管中间层的大小可能需要非常大)。由于这种理论上的完美,神经网络在 20 世纪 70 年代和 80 年代是一个非常活跃的研究领域。然而,一些问题导致它们不再受欢迎,尤其是与其他分类算法(如支持向量机)相比。以下是一些主要问题:

  • 主要问题之一是运行许多神经网络所需的计算能力超过了其他算法,也超过了许多人能够访问的能力。

  • 另一个问题是在训练网络。虽然反向传播算法已经为人所知有一段时间了,但它在大网络中存在问题,需要大量的训练才能使权重稳定。

这些问题在最近得到了解决,导致神经网络再次受到欢迎。计算能力现在比 30 年前更容易获得,算法训练的进步意味着我们现在可以轻松地使用这种能力。

直觉

区分深度神经网络和我们在第八章,使用神经网络击败 CAPTCHA中看到的基本神经网络的方面是尺寸。

当神经网络有两个或更多隐藏层时,它被认为是深度神经网络。在实践中,深度神经网络通常更大,不仅每层的节点数量更多,而且层数也更多。虽然 2005 年中期的某些研究关注了非常大的层数,但更智能的算法正在减少实际所需的层数。

尺寸是一个区分因素,但新的层类型和神经网络结构正帮助创建特定领域的深度神经网络。我们已经看到了由密集层组成的正向神经网络。这意味着我们有一系列按顺序排列的层,其中每一层的每个神经元都与另一层的每个神经元相连。其他类型包括:

  • 卷积神经网络CNN)用于图像分析。在这种情况下,图像的一个小段被作为一个单独的输入,然后这个输入被传递到池化层以组合这些输出。这有助于处理图像的旋转和平移等问题。我们将在这章中使用这些网络。

  • 循环神经网络RNN)用于文本和时间序列分析。在这种情况下,神经网络的前一个状态被记住并用于改变当前输出。想想句子中的前一个词如何修改短语中当前词的输出:美国。最受欢迎的类型之一是 LSTM 循环网络,代表长短期记忆

  • 自编码器,它学习从输入通过一个隐藏层(通常节点较少)回到输入的映射。这找到了输入数据的压缩,并且这个层可以在其他神经网络中重用,从而减少所需的标记训练数据量。

神经网络有更多更多类型。对深度神经网络的应用和理论研究每个月都在发现越来越多的神经网络形式。有些是为通用学习设计的,有些是为特定任务设计的。此外,还有多种方法可以组合层、调整参数以及改变学习策略。例如,dropout 层在训练过程中随机将一些权重减少到零,迫使神经网络的所有部分学习良好的权重。

尽管有所有这些差异,神经网络通常被设计为接受非常基本的特征作为输入——在计算机视觉的情况下,是简单的像素值。随着这些数据在网络上结合并传递,这些基本特征组合成更复杂的特征。有时,这些特征对人类来说意义不大,但它们代表了计算机寻找以进行分类的样本的方面。

深度神经网络的实现

由于这些深度神经网络的大小,实现它们可能相当具有挑战性。一个糟糕的实现将比一个好的实现运行时间更长,并且可能由于内存使用而根本无法运行。

一个神经网络的基本实现可能从创建一个节点类并将这些节点集合到一个层类开始。然后,每个节点通过一个Edge类的实例连接到下一层的节点。这种基于类的实现对于展示网络如何操作是好的,但对于更大的网络来说效率太低。神经网络有太多的动态部分,这种策略效率不高。

相反,大多数神经网络操作都可以表示为矩阵上的数学表达式。一个网络层与下一个网络层之间连接的权重可以表示为一个值矩阵,其中行代表第一层的节点,列代表第二层的节点(有时也使用这个矩阵的转置)。这个值是层与层之间边的权重。一个网络可以定义为这些权重矩阵的集合。除了节点外,我们还在每一层添加一个偏差项,这基本上是一个始终开启并连接到下一层每个神经元的节点。

这种洞察力使我们能够使用矩阵运算来构建、训练和使用神经网络,而不是创建基于类的实现。这些数学运算非常出色,因为已经编写了许多高度优化的代码库,我们可以使用它们以尽可能高效的方式执行这些计算。

我们在第八章,使用神经网络战胜 CAPTCHA中使用的 scikit-learn 实现确实包含了一些构建神经网络的特性,但缺乏该领域的一些最新进展。然而,对于更大和更定制的网络,我们需要一个能给我们更多权力的库。我们将使用Keras库来创建我们的深度神经网络。

在本章中,我们将首先使用 Keras 实现一个基本的神经网络,然后(几乎)复制第八章,使用神经网络战胜 CAPTCHA中的实验,在预测图像中的哪个字母。最后,我们将使用一个更复杂的卷积神经网络在 CIFAR 数据集上执行图像分类,这还将包括在 GPU 上而不是 CPU 上运行以提高性能。

Keras 是用于实现深度神经网络的图计算库的高级接口。图计算库概述了一系列操作,然后稍后计算这些值。这些非常适合矩阵操作,因为它们可以用来表示数据流,将这些数据流分配到多个系统,并执行其他优化。Keras 可以在底层使用两种图计算库中的任意一种。第一种称为Theano,它稍微老一些,但拥有强大的支持(并在本书的第一版中使用过),第二种是 Google 最近发布的TensorFlow,它是许多深度学习背后的库。最终,您可以在本章中使用这两个库中的任意一个。

TensorFlow 简介

TensorFlow 是由 Google 工程师设计的图计算库,并且开始为 Google 在深度学习人工智能方面的许多最新进展提供动力。

图计算库有两个步骤。它们如下列出:

  1. 定义一系列操作序列(或更复杂的图),这些操作接受输入数据,对其进行操作,并将其转换为输出。

  2. 使用步骤 1 中获得的图和给定的输入进行计算。

许多程序员在日常工作中不使用这种类型的编程,但他们中的大多数人与一个相关的系统交互。关系数据库,特别是基于 SQL 的数据库,使用一个类似的概念,称为声明性范式。虽然程序员可能在数据库上定义一个带有WHERE子句的SELECT查询,但数据库会解释它并根据多个因素创建一个优化的查询,例如WHERE子句是否应用于主键,数据存储的格式,以及其他因素。程序员定义他们想要的,系统确定如何实现。

您可以使用 Anaconda 安装 TensorFlow:conda install tensorflow

对于更多选项,Google 有一个详细的安装页面www.tensorflow.org/get_started/os_setup

使用 TensorFlow,我们可以定义许多在标量、数组、矩阵以及其他数学表达式上工作的函数类型。例如,我们可以创建一个计算给定二次方程值的图:

import tensorflow as tf

# Define the parameters of the equation as constant values
a = tf.constant(5.0)
b = tf.constant(4.5)
c = tf.constant(3.0)

# Define the variable x, which lets its value be changed
x = tf.Variable(0., name='x')  # Default of 0.0

# Define the output y, which is an operation on a, b, c and x
y = (a * x ** 2) + (b * x) + c

这个y对象是一个张量对象。它还没有值,因为这还没有被计算。我们所做的只是创建了一个声明如下图的图:

当我们计算 y 时,首先取 x 的平方值并乘以 a,然后加上 b 倍的 x,最后再加上 c 得到结果。

图本身可以通过 TensorFlow 查看。以下是一些在 Jupyter Notebook 中可视化此图的代码,由 StackOverflow 用户 Yaroslav Bulatov 提供(参见此答案:stackoverflow.com/a/38192374/307363):

from IPython.display import clear_output, Image, display, HTML

def strip_consts(graph_def, max_const_size=32):
    """Strip large constant values from graph_def."""
    strip_def = tf.GraphDef()
    for n0 in graph_def.node:
        n = strip_def.node.add() 
        n.MergeFrom(n0)
        if n.op == 'Const':
            tensor = n.attr['value'].tensor
            size = len(tensor.tensor_content)
            if size > max_const_size:
                tensor.tensor_content = "<stripped %d bytes>"%size
    return strip_def

def show_graph(graph_def, max_const_size=32):
    """Visualize TensorFlow graph."""
    if hasattr(graph_def, 'as_graph_def'):
        graph_def = graph_def.as_graph_def()
    strip_def = strip_consts(graph_def, max_const_size=max_const_size)
    code = """
        <script>
          function load() {{
            document.getElementById("{id}").pbtxt = {data};
          }}
        </script>
        <link rel="import" href="https://tensorboard.appspot.com/tf-graph-basic.build.html" onload=load()>
        <div style="height:600px">
          <tf-graph-basic id="{id}"></tf-graph-basic>
        </div>
    """.format(data=repr(str(strip_def)), id='graph'+str(np.random.rand()))

    iframe = """
        <iframe seamless style="width:1200px;height:620px;border:0" srcdoc="{}"></iframe>
    """.format(code.replace('"', '&quot;'))
    display(HTML(iframe))

然后,我们可以在新的单元格中使用此代码进行实际的可视化:

show_graph(tf.get_default_graph().as_graph_def())

结果显示了这些操作如何在有向图中链接。可视化平台称为 TensorBoard,它是 TensorFlow 的一部分:

当我们想要计算 y 的值时,我们需要通过图中的其他节点传递 x 的值,这些节点在上图中被称为 OpNodes,简称 操作节点

到目前为止,我们已经定义了图本身。下一步是计算值。我们可以用多种方式来做这件事,特别是考虑到 x 是一个变量。要使用当前 x 的值来计算 y,我们创建一个 TensorFlow Session 对象,然后让它运行 y:

model = tf.global_variables_initializer()
with tf.Session() as session:
    session.run(model)
    result = session.run(y)
print(result)

第一行初始化变量。TensorFlow 允许你指定操作范围和命名空间。在此阶段,我们只是使用全局命名空间,这个函数是一个方便的快捷方式来正确初始化这个范围,这可以被视为 TensorFlow 编译图所需的步骤。

第二步创建一个新的会话,该会话将运行模型本身。tf.global_variables_initializer() 的结果本身是图上的一个操作,必须执行才能发生。下一行实际上运行变量 y,它计算计算 y 值所需的必要 OpNodes。在我们的例子中,那就是所有节点,但可能更大的图可能不需要计算所有节点 - TensorFlow 将只做足够的工作来得到答案,而不会更多。

如果你收到一个错误,说 global_variables_initializer 未定义,请将其替换为 initialize_all_variables - 接口最近已更改。

打印结果给出了我们的值为 3。

我们还可以执行其他操作,例如更改 x 的值。例如,我们可以创建一个赋值操作,将新值赋给现有的变量。在这个例子中,我们将 x 的值改为 10,然后计算 y,结果为 548。

model = tf.global_variables_initializer()
with tf.Session() as session:
    session.run(model)
    session.run(x.assign(10))
    result = session.run(y)
print(result)

虽然这个简单的例子可能看起来并不比我们用 Python 能做的更强大,但 TensorFlow(和 Theano)提供了大量的分布式选项,用于在多台计算机上计算更大的网络,并为此进行了优化。这两个库还包含额外的工具,用于保存和加载网络,包括值,这使得我们可以保存在这些库中创建的模型。

使用 Keras

TensorFlow 不是一个直接构建神经网络的库。以类似的方式,NumPy 不是一个执行数据挖掘的库;它只是做繁重的工作,通常用于其他库。TensorFlow 包含一个内置库,称为 TensorFlow Learn,用于构建网络和执行数据挖掘。其他库,如 Keras,也是出于这个目的而构建的,并在后端使用 TensorFlow。

Keras 实现了许多现代类型的神经网络层及其构建块。在本章中,我们将使用卷积层,这些层旨在模仿人类视觉的工作方式。它们使用连接的神经元的小集合,只分析输入值的一部分——在这种情况下,是图像。这使得网络能够处理标准的改变,例如处理图像的平移。在基于视觉的实验中,卷积层处理的一个改变示例是图像的平移。

相比之下,传统的神经网络通常连接非常紧密——一个层的所有神经元都连接到下一层的所有神经元。这被称为密集层。

Keras 中神经网络的标准模型是Sequential模型,它通过传递一个层列表来创建。输入(X_train)被提供给第一层,其输出被提供给下一层,依此类推,在一个标准的正向传播配置中。

在 Keras 中构建神经网络比仅使用 TensorFlow 构建要容易得多。除非你对神经网络结构进行高度定制的修改,我强烈建议使用 Keras。

为了展示使用 Keras 进行神经网络的基本方法,我们将实现一个基本网络,基于我们在第一章,数据挖掘入门中看到的 Iris 数据集。Iris 数据集非常适合测试新的算法,即使是复杂的算法,如深度神经网络。

首先,打开一个新的 Jupyter Notebook。我们将在本章的后面回到包含 CIFAR 数据的 Notebook。

接下来,我们加载数据集:

import numpy as np
from sklearn.datasets import load_iris 
iris = load_iris() 
X = iris.data.astype(np.float32) 
y_true = iris.target.astype(np.int32)

当处理像 TensorFlow 这样的库时,最好对数据类型非常明确。虽然 Python 乐于隐式地将一种数值数据类型转换为另一种,但像 TensorFlow 这样的库是围绕底层代码(在这种情况下,是 C++)的包装器。这些库并不能总是转换数值数据类型。

我们当前的输出是一个单维度的分类值数组(0、1 或 2,取决于类别)。神经网络可以以这种格式输出数据,但常规做法是神经网络有n个输出,其中n是类别的数量。因此,我们使用 one-hot 编码将我们的分类 y 转换为 one-hot 编码的y_onehot

from sklearn.preprocessing import OneHotEncoder

y_onehot = OneHotEncoder().fit_transform(y_true.reshape(-1, 1))
y_onehot = y_onehot.astype(np.int64).todense()

然后,我们将数据集分为训练集和测试集:

from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X, y_onehot, random_state=14)

接下来,我们通过创建不同的层来构建我们的网络。我们的数据集包含四个输入变量和三个输出类别。这给出了第一层和最后一层的大小,但不是中间层的大小。对这个数字进行尝试将给出不同的结果,尝试不同的值以查看会发生什么是有价值的。我们将从一个具有以下维度的小型网络开始:

input_layer_size, hidden_layer_size, output_layer_size = 4, 6, 3

接下来,我们创建我们的隐藏层和输出层(输入层是隐式的)。在这个例子中,我们将使用 Dense 层:

from keras.layers import Dense
hidden_layer = Dense(output_dim=hidden_layer_size, input_dim=input_layer_size, activation='relu')
output_layer = Dense(output_layer_size, activation='sigmoid')

我鼓励你尝试调整激活值,看看它如何影响结果。这里的值如果你对问题没有更多信息的话是很好的默认值。也就是说,对于隐藏层使用relu,对于输出层使用sigmoid

然后,我们将层组合成一个 Sequential 模型:

from keras.models import Sequential
model = Sequential(layers=[hidden_layer, output_layer])

从这里开始的一个必要步骤是编译网络,这会创建图。在编译步骤中,我们得到了有关网络如何训练和评估的信息。这里的值定义了神经网络试图训练以减少什么,在下面的例子中,它是输出神经元和它们的期望值之间的均方误差。优化器的选择在很大程度上影响了它执行此操作的效率,通常需要在速度和内存使用之间进行权衡。

model.compile(loss='mean_squared_error',
              optimizer='adam',
              metrics=['accuracy'])

我们随后使用fit函数来训练我们的模型。Keras 模型从fit()函数返回一个历史对象,这使我们能够以细粒度级别查看数据。

history = model.fit(X_train, y_train)

你将得到相当多的输出。神经网络将训练 10 个 epoch,这些是训练周期,包括取训练数据,通过神经网络运行它,更新权重并评估结果。如果你调查历史对象(尝试print(history.history)),你将看到在每个 epoch 之后损失函数的分数(越低越好)。还包括准确度,越高越好。你可能还会注意到它并没有真正改善多少。

我们可以使用matplotlib绘制历史对象:

import seaborn as sns
from matplotlib import pyplot as plt

plt.plot(history.epoch, history.history['loss'])
plt.xlabel("Epoch")
plt.ylabel("Loss")

图片

当训练损失在下降时,下降得并不快。这是神经网络的一个问题——它们训练得很慢。默认情况下,fit 函数只会执行 10 个 epoch,这对于几乎所有应用来说都远远不够。为了看到这一点,使用神经网络预测测试集并运行分类报告:

from sklearn.metrics import classification_report
y_pred = model.predict_classes(X_test)
print(classification_report(y_true=y_test.argmax(axis=1), y_pred=y_pred))

结果相当糟糕,整体 f1 分数为 0.07,分类器仅对所有实例预测类别 2。起初,可能会觉得神经网络并不那么出色,但让我们看看当我们训练 1000 个 epoch 时会发生什么:

history = model.fit(X_train, y_train, nb_epoch=1000, verbose=False)

再次可视化每个 epoch 的损失,当运行像神经网络这样的迭代算法时,这是一个非常有用的可视化,使用上述代码显示了一个非常不同的故事:

图片

最后,我们再次执行分类报告以查看结果:

y_pred = model.predict_classes(X_test)
print(classification_report(y_true=y_test.argmax(axis=1), y_pred=y_pred))

完美。

卷积神经网络

要开始使用 Keras 进行图像分析,我们将重新实现第八章,使用神经网络击败 CAPTCHAs中使用的示例,以预测图像中代表的是哪个字母。我们将重新创建第八章,使用神经网络击败 CAPTCHAs中使用的密集神经网络。首先,我们需要再次在我们的笔记本中输入我们的数据集构建代码。关于此代码的描述,请参阅第八章,使用神经网络击败 CAPTCHAs(请记住更新 Coval 字体的文件位置):

import numpy as np 
from PIL import Image, ImageDraw, ImageFont 
from skimage import transform as tf

def create_captcha(text, shear=0, size=(100, 30), scale=1):
    im = Image.new("L", size, "black")
    draw = ImageDraw.Draw(im)
    font = ImageFont.truetype(r"bretan/Coval-Black.otf", 22) 
    draw.text((0, 0), text, fill=1, font=font)
    image = np.array(im)
    affine_tf = tf.AffineTransform(shear=shear)
    image = tf.warp(image, affine_tf)
    image = image / image.max()
    shape = image.shape
    # Apply scale
    shapex, shapey = (shape[0] * scale, shape[1] * scale)
    image = tf.resize(image, (shapex, shapey))
    return image

from skimage.measure import label, regionprops
from skimage.filters import threshold_otsu
from skimage.morphology import closing, square

def segment_image(image):
    # label will find subimages of connected non-black pixels
    labeled_image = label(image>0.2, connectivity=1, background=0)
    subimages = []
    # regionprops splits up the subimages
    for region in regionprops(labeled_image):
        # Extract the subimage
        start_x, start_y, end_x, end_y = region.bbox
        subimages.append(image[start_x:end_x,start_y:end_y])
    if len(subimages) == 0:
        # No subimages found, so return the entire image
        return [image,]
    return subimages

from sklearn.utils import check_random_state
random_state = check_random_state(14) 
letters = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
assert len(letters) == 26
shear_values = np.arange(0, 0.8, 0.05)
scale_values = np.arange(0.9, 1.1, 0.1)

def generate_sample(random_state=None): 
    random_state = check_random_state(random_state) 
    letter = random_state.choice(letters) 
    shear = random_state.choice(shear_values)
    scale = random_state.choice(scale_values)
    return create_captcha(letter, shear=shear, size=(30, 30), scale=scale), letters.index(letter)

dataset, targets = zip(*(generate_sample(random_state) for i in range(1000)))
dataset = np.array([tf.resize(segment_image(sample)[0], (20, 20)) for sample in dataset])
dataset = np.array(dataset, dtype='float') 
targets = np.array(targets)

from sklearn.preprocessing import OneHotEncoder 
onehot = OneHotEncoder() 
y = onehot.fit_transform(targets.reshape(targets.shape[0],1))
y = y.todense()

X = dataset.reshape((dataset.shape[0], dataset.shape[1] * dataset.shape[2]))

from sklearn.model_selection import train_test_split 
X_train, X_test, y_train, y_test = train_test_split(X, y, train_size=0.9)

在重新运行所有这些代码之后,你将得到一个类似于第八章,使用神经网络击败 CAPTCHAs实验的数据集。接下来,我们不再使用 scikit-learn 来构建我们的神经网络,而是将使用 Keras。

首先,我们创建了两个密集层,并将它们组合在一个顺序模型中。我选择在隐藏层中放置 100 个神经元。

from keras.layers import Dense
from keras.models import Sequential
hidden_layer = Dense(100, input_dim=X_train.shape[1])
output_layer = Dense(y_train.shape[1])
# Create the model
model = Sequential(layers=[hidden_layer, output_layer])
model.compile(loss='mean_squared_error', optimizer='adam', metrics=['accuracy'])

然后,我们拟合模型。和之前一样,你将希望有相当多的周期数。我再次使用了 1000,如果你想得到更好的结果,你可以增加这个数字。

model.fit(X_train, y_train, nb_epoch=1000, verbose=False)
y_pred = model.predict(X_test)

你还可以收集结果的历史对象,就像我们在 Iris 示例中所做的那样,以进一步调查训练情况。

from sklearn.metrics import classification_report
print(classification_report(y_pred=y_pred.argmax(axis=1),
y_true=y_test.argmax(axis=1)))

再次,完美。

至少,在我的机器上是这样的,但你的结果可能会有所不同。

GPU 优化

神经网络可以变得相当大。这对内存使用有一些影响;然而,像稀疏矩阵这样的高效结构意味着我们通常不会遇到在内存中拟合神经网络的问题。

当神经网络变得很大时,主要问题在于它们计算所需的时间非常长。此外,一些数据集和神经网络可能需要运行许多个训练周期才能得到对数据集的良好拟合。

我们在本章中将要训练的神经网络在我的性能相当强大的计算机上每个周期需要超过 8 分钟,我们预计要运行数十个,甚至数百个周期。一些更大的网络可能需要数小时来训练单个周期。为了获得最佳性能,你可能需要考虑数千个训练周期。

神经网络规模的增长会导致训练时间变长。

一个积极因素是,神经网络在其核心中充满了浮点运算。此外,还有大量可以并行执行的操作,因为神经网络训练主要由矩阵运算组成。这些因素意味着在 GPU 上进行计算是一个吸引人的选项,可以加快这一训练过程。

何时使用 GPU 进行计算

GPU 最初是为了渲染显示图形而设计的。这些图形使用矩阵和那些矩阵上的数学方程来表示,然后被转换成我们在屏幕上看到的像素。这个过程涉及到大量的并行计算。虽然现代 CPU 可能有多个核心(你的电脑可能有 2 个、4 个,甚至 16 个或更多!),但 GPU 有数千个专为图形设计的小核心。

CPU 更适合顺序任务,因为核心通常单独运行得更快,像访问计算机内存这样的任务也更有效率。实际上,让 CPU 做重活更容易。几乎每个机器学习库默认都使用 CPU,在使用 GPU 进行计算之前,你需要做额外的工作。好处可能非常显著。

因此,GPU 更适合那些有很多小操作可以在同一时间进行的任务。许多机器学习任务都是这样的,通过使用 GPU 可以带来效率的提升。

让你的代码在 GPU 上运行可能是一个令人沮丧的经历。这很大程度上取决于你有什么类型的 GPU,它的配置如何,你的操作系统,以及你是否准备对你的电脑做一些低级更改。

幸运的是,Keras 会自动使用 GPU 进行操作,如果操作适合,并且可以找到 GPU(如果你使用 TensorFlow 作为后端)。然而,你仍然需要设置你的电脑,以便 Keras 和 TensorFlow 可以找到 GPU。

有三条主要途径可以选择:

  • 第一条途径是查看你的电脑,搜索你的 GPU 和操作系统的工具和驱动程序,探索许多教程中的几个,找到一个适合你情况的教程。这能否成功取决于你的系统是什么样的。尽管如此,这个场景比几年前容易得多,因为现在有更好的工具和驱动程序可以执行 GPU 启用计算。

  • 第二条途径是选择一个系统,找到设置它的良好文档,然后购买一个与之匹配的系统。这样做效果会更好,但可能相当昂贵——在大多数现代计算机中,GPU 是成本最高的部件之一。如果你想要从系统中获得出色的性能,你需要一个非常好的 GPU,这可能会非常昂贵。如果你是一家企业(或者有更多的资金可以花费),你可以购买专门用于深度学习的高端 GPU,并与供应商直接交谈以确保你获得正确的硬件。

  • 第三条途径是使用已经配置好用于此目的的虚拟机。例如,Altoros Systems 已经创建了一个在亚马逊云服务上运行的系统。运行这个系统需要花费一定的费用,但价格远低于新电脑的价格。根据您的位置、您获得的精确系统和您使用的频率,您可能每小时只需花费不到 1 美元,通常还要低得多。如果您在亚马逊云服务中使用 spot 实例,您只需每小时几分钱就可以运行它们(尽管,您需要单独开发可以在 spot 实例上运行的代码)。

如果您无法承担虚拟机的运行成本,我建议您考虑第一条途径,即使用您当前的系统。您也可能能够从经常更新电脑的家庭成员或朋友那里购买到一台不错的二手 GPU(游戏玩家朋友在这方面很棒!)。

在 GPU 上运行我们的代码

我们在本章中将选择第三条途径,基于 Altoros Systems 的基础系统创建一个虚拟机。这个虚拟机将在亚马逊的 EC2 服务上运行。还有许多其他 Web 服务可以使用,每种服务的流程都会略有不同。在本节中,我将概述亚马逊的流程。

如果您想使用自己的计算机并且已经配置好以运行 GPU 启用计算,请自由跳过本节。

您可以了解更多关于如何设置的信息,请参阅aws.amazon.com/marketplace/pp/B01H1VWUOY?qid=1485755720051&sr=0-1&ref_=srh_res_product_title

  1. 首先,请访问 AWS 控制台:console.aws.amazon.com/console/home?region=us-east-1

  2. 使用您的亚马逊账户登录。如果您没有账户,系统将提示您创建一个,您需要创建一个账户才能继续。

  3. 接下来,请访问以下 EC2 服务控制台:console.aws.amazon.com/ec2/v2/home?region=us-east-1.

  4. 点击“启动实例”,并在右上角的下拉菜单中选择 N. California 作为您的位置。

  5. 点击“社区 AMI”,搜索由 Altoros Systems 创建的带有 TensorFlow(GPU)的 Ubuntu x64 AMI。然后,点击“选择”。在下一屏幕上,选择 g2.2xlarge 作为机器类型,然后点击“审查和启动”。在下一屏幕上,点击“启动”。

  6. 在此阶段,您将开始收费,所以请记住,当您完成使用机器时请关闭它们。您可以去 EC2 服务,选择机器,然后停止它。对于未运行的机器,您将不会产生费用。

  7. 您将收到一些有关如何连接到您实例的信息。如果您之前没有使用过 AWS,您可能需要创建一个新的密钥对以安全地连接到您的实例。在这种情况下,给您的密钥对起一个名字,下载 pem 文件,并将其存储在安全的地方——如果丢失,您将无法再次连接到您的实例!

  8. 点击“连接”以获取有关如何使用 pem 文件连接到您的实例的信息。最可能的情况是您将使用以下命令使用 ssh:

ssh -i <certificante_name>.pem ubuntu@<server_ip_address>

设置环境

接下来,我们需要将我们的代码放到机器上。有许多方法可以将此文件放到您的计算机上,但其中一种最简单的方法就是直接复制粘贴内容。

首先,打开我们之前使用的 Jupyter Notebook(在您的计算机上,而不是在亚马逊虚拟机上)。在笔记本本身有一个菜单。点击文件,然后选择下载为。选择 Python 并将其保存到您的计算机。此过程将 Jupyter Notebook 中的代码下载为可以在命令行中运行的 Python 脚本。

打开此文件(在某些系统上,您可能需要右键单击并使用文本编辑器打开)。选择所有内容并将它们复制到您的剪贴板。

在亚马逊虚拟机上,移动到主目录并使用新文件名打开 nano:

$ cd~/

$ nano chapter11script.py

nano 程序将打开,这是一个命令行文本编辑器。

打开此程序,将剪贴板的内容粘贴到该文件中。在某些系统上,您可能需要使用 ssh 程序的文件选项,而不是按 Ctrl+V 粘贴。

在 nano 中,按 Ctrl+O 保存文件到磁盘,然后按 Ctrl+X 退出程序。

您还需要字体文件。最简单的方法是从原始位置重新下载它。为此,请输入以下内容:

$ wget http://openfontlibrary.org/assets/downloads/bretan/680bc56bbeeca95353ede363a3744fdf/bretan.zip

$ sudo apt-get install unzip

$ unzip -p bretan.zip

在虚拟机中,您可以使用以下命令运行程序:

$ python chapter11script.py

程序将像在 Jupyter Notebook 中一样运行,并将结果打印到命令行。

结果应该与之前相同,但实际训练和测试神经网络的速度将快得多。请注意,在其他程序方面,它不会快那么多——我们没有编写 CAPTCHA 数据集创建以使用 GPU,因此我们不会在那里获得加速。

您可能希望关闭亚马逊虚拟机以节省一些费用;我们将在本章末尾使用它来运行我们的主要实验,但首先将在您的计算机上开发代码。

应用

现在,回到您的计算机上,打开我们本章创建的第一个 Jupyter Notebook——我们加载 CIFAR 数据集的那个。在这个主要实验中,我们将使用 CIFAR 数据集,创建一个深度卷积神经网络,然后在我们的基于 GPU 的虚拟机上运行它。

获取数据

首先,我们将我们的 CIFAR 图像和它们一起创建一个数据集。与之前不同,我们将保留像素结构——即在行和列中。首先,将所有批次加载到一个列表中:

import os
import numpy as np 

data_folder = os.path.join(os.path.expanduser("~"), "Data", "cifar-10-batches-py")

batches = [] 
for i in range(1, 6):
    batch_filename = os.path.join(data_folder, "data_batch_{}".format(i))
    batches.append(unpickle(batch_filename)) 
    break

最后的行,即 break,是为了测试代码——这将大大减少训练示例的数量,让你可以快速看到代码是否工作。我会在你测试代码工作后提示你删除这一行。

接下来,通过将这些批次堆叠在一起来创建一个数据集。我们使用 NumPy 的 vstack,这可以想象为向数组的末尾添加行:

X = np.vstack([batch['data'] for batch in batches])

然后,我们将数据集归一化到 0 到 1 的范围,并强制类型为 32 位浮点数(这是 GPU 启用虚拟机可以运行的唯一数据类型):

X = np.array(X) / X.max() 
X = X.astype(np.float32)

然后,我们对类别做同样的处理,除了我们执行一个 hstack,这类似于向数组的末尾添加列。然后我们可以使用 OneHotEncoder 将其转换为 one-hot 数组。这里我将展示一个使用 Keras 中提供的实用函数的替代方法,但结果是一样的:

from keras.utils import np_utils
y = np.hstack(batch['labels'] for batch in batches).flatten()
nb_classes = len(np.unique(y))
y = np_utils.to_categorical(y, nb_classes)

接下来,我们将数据集分为训练集和测试集:

from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)

接下来,我们将数组重塑以保留原始数据结构。原始数据是 32x32 像素的图像,每个像素有 3 个值(红色、绿色和蓝色值)。虽然标准的正向传播神经网络只接受单个输入数据数组(参见 CAPTCHA 示例),但卷积神经网络是为图像设计的,并接受三维图像数据(2-D 图像,以及包含颜色深度的另一个维度)。

X_train = X_train.reshape(-1, 3, 32, 32)
X_test = X_test.reshape(-1, 3, 32, 32)
n_samples, d, h, w = X_train.shape  # Obtain dataset dimensions
# Convert to floats and ensure data is normalised.
X_train = X_train.astype('float32')
X_test = X_test.astype('float32')

现在,我们有一个熟悉的训练和测试数据集,以及每个目标类。我们现在可以构建分类器。

创建神经网络

现在,我们将构建卷积神经网络。我已经进行了一些调整,发现了一个效果很好的布局,但你可以自由地尝试更多层(或更少层)、不同类型和不同大小的层。较小的网络训练更快,但较大的网络可以实现更好的结果。

首先,我们创建神经网络中的层:

from keras.layers import Dense, Flatten, Convolution2D, MaxPooling2D
conv1 = Convolution2D(32, 3, 3, input_shape=(d, h, w), activation='relu')
pool1 = MaxPooling2D()
conv2 = Convolution2D(64, 2, 2, activation='relu')
pool2 = MaxPooling2D()
conv3 = Convolution2D(128, 2, 2, activation='relu')
pool3 = MaxPooling2D()
flatten = Flatten()
hidden4 = Dense(500, activation='relu')
hidden5 = Dense(500, activation='relu')
output = Dense(nb_classes, activation='softmax')
layers = [conv1, pool1,
          conv2, pool2,
          conv3, pool3,
          flatten, hidden4, hidden5,
          output]

我们使用密集层作为最后三层,按照正常的正向传播神经网络,但在那之前,我们使用结合了池化层的卷积层。我们有三组这样的层。

对于每一对 Convolution2D 和 MaxPooling2D 层,发生以下情况:

  1. Convolution2D 网络从输入数据中获取补丁。这些通过一个过滤器传递,这是一个类似于支持向量机使用的核操作符的矩阵变换。过滤器是一个较小的矩阵,大小为 k 乘以 n(在上面的 Convolution2D 初始化器中指定为 3x3),它应用于图像中找到的每个 k 乘以 n 模式。结果是卷积特征。

  2. MaxPooling2D 层从 Convolution2D 层获取结果,并为每个卷积特征找到最大值。

虽然这确实丢弃了很多信息,但这实际上有助于图像检测。如果一个图像中的对象只是向右偏移了几像素,标准的神经网络会认为它是一个全新的图像。相比之下,卷积层会找到它,并报告几乎相同的输出(当然,这取决于广泛的其它因素)。

经过这些层对后,进入网络密集部分的特性是元特性,它们代表了图像的抽象概念,而不是具体特性。通常这些特性是可以可视化的,例如像一条略微向上的线这样的特性。

接下来,我们将这些层组合起来构建我们的神经网络并对其进行训练。这次训练将比之前的训练花费更长的时间。我建议从 10 个 epochs 开始,确保代码能够完全运行,然后再用 100 个 epochs 重新运行。此外,一旦你确认代码可以运行并且得到了预测结果,返回并移除我们在创建数据集时放入的break行(它在批次循环中)。这将允许代码在所有样本上训练,而不仅仅是第一个批次。

model = Sequential(layers=layers)
model.compile(loss='mean_squared_error', optimizer='adam', metrics=['accuracy'])
import tensorflow as tf
history = model.fit(X_train, y_train, nb_epoch=25, verbose=True,
validation_data=(X_test, y_test),batch_size=1000))

最后,我们可以用网络进行预测并评估。

y_pred = model.predict(X_test)
from sklearn.metrics import classification_report
print(classification_report(y_pred=y_pred.argmax(axis=1),
 y_true=y_test.argmax(axis=1)))

运行 100 个 epochs 后,在这个案例中它仍然不是完美的,但仍然是一个非常好的结果。如果你有时间(比如一整夜),尝试运行 1000 个 epochs 的代码。准确率有所提高,但投入的时间回报却在减少。一个(不是那么)好的经验法则是,要减半错误,你需要将训练时间加倍。

将所有这些组合起来

现在我们已经让网络代码运行正常,我们可以在远程机器上用我们的训练数据集来训练它。如果你使用本地机器运行神经网络,你可以跳过这一部分。

我们需要将脚本上传到我们的虚拟机。和之前一样,点击文件|另存为,Python,并将脚本保存在你的电脑上的某个位置。启动并连接到虚拟机,然后像之前一样上传脚本(我给我的脚本命名为chapter11cifar.py——如果你命名不同,只需更新以下代码)。

接下来,我们需要将数据集放在虚拟机上。最简单的方法是进入虚拟机并输入以下命令:

$ wget http://www.cs.toronto.edu/~kriz/cifar-10-python.tar.gz

这将下载数据集。一旦下载完成,你可以通过首先创建该文件夹然后在该文件夹中解压缩数据来将数据提取到数据文件夹中:

$ mkdir Data

$ tar -zxf cifar-10-python.tar.gz -C Data

最后,我们可以用以下方式运行我们的示例:

$ python3 chapter11cifar.py

你首先会注意到速度上的显著提升。在我的家用电脑上,每个 epoch 需要超过 100 秒来运行。在启用 GPU 的虚拟机上,每个 epoch 只需 16 秒!如果我们尝试在我的电脑上运行 100 个 epochs,将近需要三个小时,而在虚拟机上只需 26 分钟。

这种显著的速度提升使得尝试不同的模型变得更快。通常在尝试机器学习算法时,单个算法的计算复杂度并不太重要。一个算法可能只需要几秒钟、几分钟或几小时来运行。如果你只运行一个模型,这个训练时间不太可能很重要——特别是预测,因为大多数机器学习算法的预测都非常快,这也是机器学习模型主要被使用的地方。

然而,当你有很多参数要运行时,你突然需要训练成千上万个具有略微不同参数的模型——突然,这些速度提升变得非常重要。

经过 100 个训练轮次,总共花费了 26 分钟,你将得到最终结果的打印输出:

0.8497

还不错!我们可以增加训练的轮数来进一步提高这个结果,或者我们可以尝试改变参数;也许,更多的隐藏节点、更多的卷积层,或者一个额外的密集层。Keras 还有其他类型的层可以尝试;尽管通常来说,卷积层更适合视觉。

摘要

在本章中,我们探讨了使用深度神经网络,特别是卷积网络,来进行计算机视觉。我们通过 Keras 包来实现,它使用 Tensorflow 或 Theano 作为其计算后端。使用 Keras 的辅助函数构建网络相对简单。

卷积网络是为计算机视觉设计的,所以结果相当准确并不令人惊讶。最终结果显示,计算机视觉确实是使用今天算法和计算能力的一个有效应用。

我们还使用了一个启用 GPU 的虚拟机来极大地加快了过程,我的机器速度提高了近 10 倍。如果你需要额外的计算能力来运行这些算法,云服务提供商的虚拟机可以是一个有效的方法(通常每小时不到一美元)——只是记得用完之后关掉它们!

为了扩展本章的工作,尝试调整网络结构,以进一步提高我们在这里获得的准确性。另一种提高准确性的方法是创建更多数据,无论是通过拍摄自己的照片(较慢)还是通过修改现有的照片(更快)。为了进行修改,你可以翻转图像上下颠倒、旋转、剪切等等。Keras 有一个做这个的函数,非常实用。请参阅keras.io/preprocessing/image/的文档。

值得调查的另一个领域是神经网络结构的变体,包括更多节点、更少节点、更多层等等。还可以尝试不同的激活类型、不同的层类型和不同的组合。

本章的重点是讨论一个非常复杂的算法。卷积网络训练时间较长,并且需要训练许多参数。最终,与数据集的大小相比,数据量较小;尽管这是一个大型数据集,但我们甚至无需使用稀疏矩阵就能将其全部加载到内存中。在下一章中,我们将转向一个更加简单的算法,但数据集规模大得多,无法全部装入内存。这是大数据的基础,也是支撑数据挖掘在许多大型行业(如采矿和社交网络)中应用的基础。

第十二章:与大数据一起工作

数据量正在以指数级增长。今天的系统正在生成和记录有关客户行为、分布式系统、网络分析、传感器以及许多其他来源的信息。虽然当前移动数据的大趋势正在推动当前的增长,但下一个大趋势——物联网(IoT)——将进一步增加增长速度。

这对数据挖掘意味着一种新的思维方式。需要高运行时间的复杂算法需要改进或丢弃,而可以处理更多样本的简单算法越来越受欢迎。例如,虽然支持向量机是优秀的分类器,但某些变体在处理非常大的数据集时难以使用。相比之下,像逻辑回归这样的简单算法在这些情况下更容易管理。

这种复杂性与分布问题只是深度神经网络(DNNs)变得如此受欢迎的原因之一。你可以使用 DNNs 创建非常复杂的模型,但也可以非常容易地将训练这些模型的负载分布到多台计算机上。

在本章中,我们将探讨以下内容:

  • 大数据挑战与应用

  • MapReduce 范式

  • Hadoop MapReduce

  • mrjob,一个在亚马逊 AWS 基础设施上运行 MapReduce 程序的 Python 库

大数据

大数据有什么不同之处?大多数大数据倡导者谈论大数据的四个 V:

  • Volume:我们生成和存储的数据量正在以越来越快的速度增长,对未来的一般预测只表明将进一步增加。今天的多吉字节大小的硬盘将在几年内变成艾字节大小的硬盘,网络吞吐量也将增加。信号与噪声的比例可能非常困难,重要数据可能丢失在非重要数据的大山中。

  • Velocity:虽然与数据量相关,但数据速度也在增加。现代汽车有数百个传感器将数据流式传输到它们的计算机中,而这些传感器提供的信息需要以亚秒级进行分析,以便操作汽车。这不仅仅是找到数据量中的答案;这些答案通常需要迅速得出。在某些情况下,我们甚至没有足够的磁盘空间来存储数据,这意味着我们还需要决定保留哪些数据以供后续分析。

  • Variety:具有明确定义列的优质数据集只是我们今天拥有的数据集的一小部分。考虑一下社交媒体帖子,它可能包含文本、照片、用户提及、点赞、评论、视频、地理信息和其他字段。简单地忽略不适合你模型的数据部分会导致信息丢失,但整合这些信息本身可能非常困难。

  • 真实性:随着数据量的增加,很难确定数据是否被正确收集——是否过时、嘈杂、包含异常值——或者总的来说是否有用。当人类无法可靠地验证数据时,能够信任数据是很困难的。外部数据集越来越多地被合并到内部数据集中,这也引发了更多与数据真实性相关的问题。

这四个主要“V”(其他人还提出了额外的“V”)概述了大数据与仅仅“大量数据”的不同之处。在这些规模下,处理数据的工程问题通常更加困难——更不用说分析问题了。虽然有很多卖假药的人夸大特定产品分析大数据的能力,但很难否认工程挑战和大数据分析潜力。

我们在书中迄今为止使用的算法是将数据集加载到内存中,然后在该内存版本上工作。这在计算速度方面带来了很大的好处(因为使用计算机内存比使用硬盘驱动器快),因为对内存中的数据进行计算比在用它之前加载样本要快得多。此外,内存数据允许我们多次迭代数据,从而提高我们的机器学习模型。

在大数据中,我们不能将数据加载到内存中。在许多方面,这可以作为一个很好的定义,来判断一个问题是否是大数据问题——如果数据可以适合你电脑的内存,那么你就不在处理大数据问题。

当查看你创建的数据时,例如你公司内部应用程序的日志数据,你可能想简单地将其全部放入一个文件中,不进行结构化,以后再使用大数据概念来分析它。最好不要这样做;相反,你应该为你的数据集使用结构化格式。原因是我们刚才概述的四个“V”实际上是需要解决以执行数据分析的问题,而不是需要努力实现的目标!

大数据的应用

大数据在公共和私营部门有许多用例。

人们使用基于大数据的系统最常见的体验是在互联网搜索中,例如 Google。为了运行这些系统,需要在不到一秒钟的时间内对数十亿个网站进行搜索。进行基于文本的基本搜索不足以处理这样的问题。简单地存储所有这些网站的文本就是一个大问题。为了处理查询,需要创建和实施专门针对此应用的新数据结构和数据挖掘方法。

大数据也被用于许多其他科学实验,例如大型强子对撞机,其中一部分在下面的图片中展示。它跨越 27 公里,包含 1.5 亿个传感器,每秒监测数亿次粒子碰撞。这个实验的数据量巨大,每天产生 25 拍字节的数据,经过过滤过程(如果没有过滤,每年将有 1.5 亿拍字节的数据)。对如此大量数据的分析导致了关于我们宇宙的惊人见解,但这已经是一个重大的工程和数据分析挑战。

图片

政府越来越多地使用大数据来追踪人口、企业和与其国家相关的其他方面。追踪数百万人和数十亿次的互动(如商业交易或医疗支出)导致许多政府机构需要大数据分析。

交通管理是全球许多政府关注的重点,他们通过数百万个传感器追踪交通情况,以确定哪些道路最拥堵,并预测新道路对交通水平的影响。这些管理系统将在不久的将来与自动驾驶汽车的数据相连,从而获得更多关于实时交通状况的数据。利用这些数据的城市会发现,他们的交通流动更加顺畅。

大型零售组织正在利用大数据来改善客户体验和降低成本。这包括预测客户需求,以便拥有正确的库存水平,向客户推荐他们可能喜欢购买的产品,并跟踪交易以寻找趋势、模式和潜在的欺诈行为。能够自动创建出色预测的公司可以在较低的成本下实现更高的销售额。

其他大型企业也在利用大数据来自动化其业务的一些方面并改善其产品。这包括利用分析来预测其行业未来的趋势和跟踪外部竞争对手。大型企业还使用分析来管理自己的员工——追踪员工以寻找他们可能离职的迹象,以便在他们这样做之前进行干预。

信息安全领域也在利用大数据来寻找大型网络中的恶意软件感染,通过监控网络流量来实现。这可能包括寻找异常流量模式、恶意软件传播的证据和其他异常情况。高级持续性威胁(APTs)也是一个问题,其中一名有动机的攻击者将他们的代码隐藏在大型网络中,以在长时间内窃取信息或造成损害。寻找 APTs 通常需要法医检查许多计算机,这是一个人类难以有效完成的任务。分析有助于自动化和分析这些法医图像以找到感染。

大数据正在越来越多地应用于各个领域和应用程序,这一趋势很可能会持续下去。

MapReduce

在大数据上进行数据挖掘和一般计算有许多概念。其中最受欢迎的是 MapReduce 模型,它可以用于对任意大型数据集进行一般计算。

MapReduce 起源于谷歌,它是在考虑分布式计算的情况下开发的。它还引入了容错性和可扩展性的改进。MapReduce 的原始研究于 2004 年发表,从那时起,已有成千上万个项目、实现和应用使用了它。

虽然这个概念与许多先前的概念相似,但 MapReduce 已经成为大数据分析的一个基本工具。

MapReduce 作业有两个主要阶段。

  1. 第一步是 Map,通过它我们取一个函数和一个项目列表,并将该函数应用于每个项目。换句话说,我们将每个项目作为函数的输入,并存储该函数调用的结果:

  1. 第二步是 Reduce,在这一步中,我们使用一个函数将 Map 步骤的结果组合起来。对于统计,这可以简单地将所有数字相加。在这个场景中,reduce 函数是一个加法函数,它会将前一个总和与新的结果相加:

在这两个步骤之后,我们将已经转换了我们的数据,并将其减少到最终结果。

MapReduce 作业可以有多个迭代,其中一些只是 Map 作业,一些只是 Reduce 作业,还有一些迭代既有 Map 步骤又有 Reduce 步骤。现在让我们看看一些更具体的例子,首先使用内置的 Python 函数,然后使用特定的 MapReduce 工具。

MapReduce 背后的直觉

MapReduce 有两个主要步骤:Map步骤和Reduce步骤。这些步骤建立在将函数映射到列表和减少结果的函数式编程概念之上。为了解释这个概念,我们将开发代码,它将遍历一个列表的列表,并产生这些列表中所有数字的总和。

在 MapReduce 范式中,还有shufflecombine步骤,我们将在后面看到。

首先,Map 步骤取一个函数并将其应用于列表中的每个元素。返回的结果是一个大小相同的列表,其中包含对每个元素应用函数的结果。

要打开一个新的 Jupyter Notebook,首先创建一个包含数字的列表的列表:

a = [[1,2,1], [3,2], [4,9,1,0,2]]

接下来,我们可以使用求和函数进行map操作。这一步将求和函数应用于a中的每个元素:

sums = map(sum, a)

虽然sums是一个生成器(实际值只有在请求时才会计算),但前面的步骤大约等于以下代码:

sums = []
for sublist in a:
    results = sum(sublist)
    sums.append(results)

reduce步骤稍微复杂一些。它涉及到将一个函数应用于返回结果的每个元素,以及某个起始值。我们从初始值开始,然后应用给定的函数到初始值和第一个值。然后我们将给定的函数应用于结果和下一个值,依此类推

我们首先创建一个函数,该函数接受两个数字并将它们相加。

def add(a, b): 
    return a + b

然后,我们执行 reduce 操作。reduce的签名是:reduce(function, sequence, initial),其中函数在每一步应用于序列。在第一步中,初始值用作第一个值而不是列表的第一个元素:

from functools import reduce 
print(reduce(add, sums, 0))

结果,25,是求和列表中每个值的总和,因此也是原始数组中每个元素的总和。

上述代码类似于以下代码:

initial = 0 
current_result = initial 
for element in sums: 
    current_result = add(current_result, element)

在这个简单的例子中,如果我们的代码不使用 MapReduce 范式,那么代码将会大大简化,但真正的收益来自于计算的分布。例如,如果我们有一百万个子列表,并且每个子列表包含一百万个元素,我们可以在多台计算机上分布这个计算。

为了做到这一点,我们通过分割数据来分配map步骤。对于我们列表中的每个元素,我们将其以及我们函数的描述发送到一台计算机。然后,这台计算机将结果返回到我们的主计算机(即主节点)。

主节点然后将结果发送到一台计算机进行reduce步骤。在我们的例子中,一百万个子列表,我们会将一百万个任务发送到不同的计算机(同一台计算机在完成我们的第一个任务后可能被重复使用)。返回的结果将只是一个包含一百万个数字的单列表,然后我们计算这些数字的总和。

结果是,没有任何计算机需要存储超过一百万个数字,尽管我们的原始数据中包含了一万亿个数字。

单词计数示例

任何实际的 MapReduce 实现都比仅仅使用mapreduce步骤要复杂一些。这两个步骤都是通过键来调用的,这允许数据的分离和值的跟踪。

map 函数接受一个键值对,并返回一个键值对列表。输入和输出的键不一定相互关联。

例如,对于一个执行单词计数的 MapReduce 程序,输入键可能是一个样本文档的 ID 值,而输出键将是给定的单词。输入值将是文档的文本,输出值将是每个单词的频率。我们拆分文档以获取单词,然后产生每个单词、计数对。在这里,单词是键,计数在 MapReduce 术语中是值:

from collections import defaultdict
def map_word_count(document_id, document):
    counts = defaultdict(int) 
    for word in document.split(): 
        counts[word] += 1
    for word in counts: 
        yield (word, counts[word])

如果你有一个非常大的数据集?你可以在遇到新单词时直接执行yield (word, 1),然后在洗牌步骤中合并它们,而不是在 map 步骤中进行计数。你放置的位置取决于你的数据集大小、每份文档的大小、网络容量以及一系列其他因素。大数据是一个巨大的工程问题,为了从系统中获得最佳性能,你需要模拟数据在整个算法中的流动方式。

以单词作为关键字,我们就可以执行一个洗牌步骤,该步骤将每个键的所有值分组:

def shuffle_words(results):
    records = defaultdict(list)
    for results in results_generators: 
        for word, count in results: 
            records[word].append(count)
    for word in records: 
        yield (word, records[word])

最后一步是减少步骤,它接受一个键值对(在这个例子中,值始终是一个列表)并生成一个键值对作为结果。在我们的例子中,键是单词,输入列表是在洗牌步骤中产生的计数列表,输出值是计数的总和:

def reduce_counts(word, list_of_counts): 
    return (word, sum(list_of_counts))

要看到这个动作的实际效果,我们可以使用 scikit-learn 提供的 20 个新闻组数据集。这个数据集不是大数据,但我们可以在这里看到概念的实际应用:

from sklearn.datasets import fetch_20newsgroups 
dataset = fetch_20newsgroups(subset='train') 
documents = dataset.data

然后我们应用我们的映射步骤。我们在这里使用 enumerate 来自动为我们生成文档 ID。虽然它们在这个应用中并不重要,但这些键在其他应用中很重要:

map_results = map(map_word_count, enumerate(documents))

这里实际的结果只是一个生成器;没有产生实际的计数。尽管如此,它是一个发出(单词,计数)对的生成器。

接下来,我们执行洗牌步骤来对这些单词计数进行排序:

shuffle_results = shuffle_words(map_results)

从本质上讲,这是一个 MapReduce 作业;然而,它只在一个线程上运行,这意味着我们没有从 MapReduce 数据格式中获得任何好处。在下一节中,我们将开始使用 Hadoop,一个开源的 MapReduce 提供者,以开始获得这种类型范式的好处。

Hadoop MapReduce

Hadoop 是 Apache 提供的一系列开源工具,包括 MapReduce 的一个实现。在许多情况下,它是许多人所使用的默认实现。该项目由 Apache 集团管理(他们负责同名的著名网络服务器)。

Hadoop 生态系统相当复杂,包含大量工具。我们将使用的主要组件是 Hadoop MapReduce。Hadoop 中包含的其他用于处理大数据的工具如下:

  • Hadoop 分布式文件系统(HDFS):这是一个可以在多台计算机上存储文件的文件系统,旨在在提供高带宽的同时,对硬件故障具有鲁棒性。

  • YARN:这是一种用于调度应用程序和管理计算机集群的方法。

  • Pig:这是一种用于 MapReduce 的高级编程语言。Hadoop MapReduce 是用 Java 实现的,而 Pig 位于 Java 实现之上,允许你用其他语言编写程序——包括 Python。

  • Hive:这是用于管理数据仓库和执行查询的。

  • HBase:这是 Google 的 BigTable 的一个实现,一个分布式数据库。

这些工具都解决了在大数据实验中出现的问题,包括数据分析。

还有基于非 Hadoop 的 MapReduce 实现,以及其他具有类似目标的项目。此外,许多云服务提供商都有基于 MapReduce 的系统。

应用 MapReduce

在这个应用程序中,我们将研究根据作者使用不同词汇来预测作者的性别。我们将使用朴素贝叶斯方法进行此操作,并在 MapReduce 中进行训练。最终的模型不需要 MapReduce,尽管我们可以使用 Map 步骤来这样做——也就是说,在列表中的每个文档上运行预测模型。这是 MapReduce 中数据挖掘的常见 Map 操作,而 reduce 步骤只是组织预测列表,以便可以追溯到原始文档。

我们将使用亚马逊的基础设施来运行我们的应用程序,这样我们可以利用他们的计算资源。

获取数据

我们将要使用的数据是一组标记了年龄、性别、行业(即工作)以及有趣的是,星座的博客。这些数据是在 2004 年 8 月从blogger.com收集的,包含超过 600,000 篇帖子,超过 1.4 亿个单词。每篇博客可能是由一个人写的,尽管我们投入了一些工作来验证这一点(尽管,我们永远不能完全确定)。帖子还与发布日期相匹配,这使得这是一个非常丰富的数据集。

要获取数据,请访问u.cs.biu.ac.il/~koppel/BlogCorpus.htm并点击下载语料库。从那里,将文件解压缩到您的计算机上的一个目录中。

数据集以单个博客对应一个文件的方式组织,文件名表示类别。例如,以下是一个文件名:

1005545.male.25.Engineering.Sagittarius.xml

文件名由点分隔,字段如下:

  • 博主 ID:这是一个简单的 ID 值,用于组织身份。

  • 性别:这是男性或女性,所有博客都被标识为这两种选项之一(此数据集中不包含其他选项)。

  • 年龄:给出了确切的年龄,但故意存在一些间隔。存在的年龄范围在(包含)13-17 岁、23-27 岁和 33-48 岁之间。存在间隔的原因是为了允许将博客分成有间隔的年龄范围,因为将 18 岁年轻人的写作与 19 岁年轻人的写作区分开来相当困难,而且年龄本身可能已经有些过时,可能需要更新到 19 岁。

  • 行业:包括科学、工程、艺术和房地产在内的 40 个不同行业中。还包括 indUnk,表示未知行业。

  • 星座:这是 12 个占星术星座之一。

所有值都是自行报告的,这意味着可能会有错误或不一致,但假设它们大多是可靠的——人们有选择不设置值以保护他们隐私的方式。

单个文件采用伪 XML 格式,包含一个<Blog>标签,然后是一系列<post>标签。每个<post>标签之前都有一个<date>标签。虽然我们可以将其解析为 XML,但由于文件并非完全符合良好格式的 XML,存在一些错误(主要是编码问题),因此按行逐行解析要简单得多。为了读取文件中的帖子,我们可以使用循环遍历行。

我们设置一个测试文件名,以便我们可以看到这个动作:

import os 
filename = os.path.join(os.path.expanduser("~"), "Data", "blogs", "1005545.male.25.Engineering.Sagittarius.xml")

首先,我们创建一个列表,以便我们可以存储每篇帖子:

all_posts = []

然后,我们打开文件进行读取:

with open(filename) as inf:
    post_start = False
    post = []
    for line in inf: 
        line = line.strip()
        if line == "<post>":
            # Found a new post
            post_start = True 
        elif line == "</post>":
            # End of the current post, append to our list of posts and start a new one
            post_start = False
            all_posts.append("n".join(post))
            post = []
        elif post_start:
            # In a current post, add the line to the text of the post
            post.append(line)

如果我们不在当前帖子中,我们只需忽略该行。

然后,我们可以获取每篇帖子的文本:

print(all_posts[0])

我们还可以找出这位作者创建了多少篇帖子:

print(len(all_posts))

朴素贝叶斯预测

现在,我们将使用 mrjob 实现朴素贝叶斯算法,使其能够处理我们的数据集。技术上,我们的版本将是大多数朴素贝叶斯实现的一个简化版本,没有许多您预期的功能,如平滑小值。

mrjob 包

mrjob包允许我们创建可以在亚马逊基础设施上轻松计算的 MapReduce 作业。虽然 mrjob 听起来像是儿童书籍《先生们》系列的一个勤奋的补充,但它代表的是Map Reduce Job

您可以使用以下命令安装 mrjob:pip install ``mrjob

我不得不单独使用conda install -c conda-forge filechunkio安装 filechunkio 包,但这将取决于您的系统设置。还有其他 Anaconda 通道可以安装 mrjob,您可以使用以下命令检查它们:

anaconda search -t conda mrjob

事实上,mrjob 提供了大多数 MapReduce 作业所需的标准功能。它最令人惊叹的功能是您可以编写相同的代码,在本地机器上测试(无需像 Hadoop 这样的重基础设施),然后推送到亚马逊的 EMR 服务或另一个 Hadoop 服务器。

这使得测试代码变得容易得多,尽管它不能神奇地将大问题变小——任何本地测试都使用数据集的子集,而不是整个大数据集。相反,mrjob 为您提供了一个框架,您可以使用小问题进行测试,并更有信心解决方案可以扩展到更大的问题,分布在不同的系统上。

提取博客帖子

我们首先将创建一个 MapReduce 程序,该程序将从每个博客文件中提取每篇帖子,并将它们作为单独的条目存储。由于我们对帖子的作者性别感兴趣,我们还将提取该信息并将其与帖子一起存储。

我们不能在 Jupyter Notebook 中做这件事,所以相反,打开一个 Python IDE 进行开发。如果您没有 Python IDE,可以使用文本编辑器。我推荐 PyCharm,尽管它有一个较大的学习曲线,而且可能对于本章的代码来说有点重。

至少,我推荐使用具有语法高亮和基本变量名补全的 IDE(最后一个有助于轻松找到代码中的错误)。

如果您仍然找不到喜欢的 IDE,您可以在 IPython 笔记本中编写代码,然后点击文件|下载为|Python。将此文件保存到目录中,然后按照我们在第十一章中概述的方式运行它,使用深度学习对图像中的对象进行分类

要做到这一点,我们需要osre库,因为我们将会获取环境变量,我们还将使用正则表达式进行单词分隔:

import os 
import re

然后我们导入 MRJob 类,我们将从我们的 MapReduce 作业中继承它:

from mrjob.job import MRJob

然后,我们创建一个新的类,该类继承自 MRJob。我们将使用与之前类似的循环来从文件中提取博客文章。我们将定义的映射函数将针对每一行工作,这意味着我们必须在映射函数外部跟踪不同的帖子。因此,我们将post_startpost作为类变量,而不是函数内部的变量。然后我们定义我们的映射函数——这个函数从文件中读取一行作为输入,并产生博客文章。这些行保证是从同一个作业文件中按顺序排列的。这允许我们使用上面的类变量来记录当前帖子数据:

class ExtractPosts(MRJob):
    post_start = False 
    post = []

    def mapper(self, key, line):
        filename = os.environ["map_input_file"]
        # split the filename to get the gender (which is the second token)
        gender = filename.split(".")[1]
        line = line.strip()
        if line == "<post>":
            self.post_start = True
        elif line == "</post>":
            self.post_start = False
            yield gender, repr("n".join(self.post))
            self.post = []
        elif self.post_start:
            self.post.append(line)

与我们之前将帖子存储在列表中的做法不同,我们现在产生它们。这允许 mrjob 跟踪输出。我们产生性别和帖子,这样我们就可以记录每个记录匹配的性别。这个函数的其余部分与上面的循环定义方式相同。

最后,在函数和类外部,我们将脚本设置为在从命令行调用时运行此 MapReduce 作业:

if __name__ == '__main__': 
 ExtractPosts.run()

现在,我们可以使用以下 shell 命令运行此 MapReduce 作业。

$ python extract_posts.py <your_data_folder>/blogs/51* 
 --output-dir=<your_data_folder>/blogposts --no-output

提醒一下,您不需要在上面的行中输入$ - 这只是表示这是一个从命令行运行的命令,而不是在 Jupyter 笔记本中。

第一个参数,<your_data_folder>/blogs/51*(只需记住将<your_data_folder>更改为您数据文件夹的完整路径),获取数据样本(所有以 51 开头的文件,这只有 11 个文档)。然后我们将输出目录设置为一个新的文件夹,我们将它放在数据文件夹中,并指定不要输出流数据。如果没有最后一个选项,当运行时,输出数据会显示在命令行上——这对我们来说并不很有帮助,并且会大大减慢计算机的速度。

运行脚本,并且相当快地,每篇博客文章都会被提取并存储在我们的输出文件夹中。这个脚本只在本地计算机上的单个线程上运行,所以我们根本得不到加速,但我们知道代码是运行的。

现在,我们可以在输出文件夹中查看结果。创建了一堆文件,每个文件都包含每篇博客文章,每篇文章之前都标有博客作者的性别。

训练朴素贝叶斯

既然我们已经提取了博客文章,我们就可以在它们上面训练我们的朴素贝叶斯模型。直觉是,我们记录一个词被特定性别写下的概率,并将这些值记录在我们的模型中。为了分类一个新的样本,我们将乘以概率并找到最可能的性别。

这段代码的目的是输出一个文件,列出语料库中的每个单词,以及该单词在每个性别写作中的频率。输出文件看起来可能像这样:

"'ailleurs" {"female": 0.003205128205128205}
"'air" {"female": 0.003205128205128205}
"'an" {"male": 0.0030581039755351682, "female": 0.004273504273504274}
"'angoisse" {"female": 0.003205128205128205}
"'apprendra" {"male": 0.0013047113868622459, "female": 0.0014172668603481887}
"'attendent" {"female": 0.00641025641025641}
"'autistic" {"male": 0.002150537634408602}
"'auto" {"female": 0.003205128205128205}
"'avais" {"female": 0.00641025641025641}
"'avait" {"female": 0.004273504273504274}
"'behind" {"male": 0.0024390243902439024} 
"'bout" {"female": 0.002034152292059272}

第一个值是单词,第二个是一个将性别映射到该性别写作中该单词频率的字典。

在你的 Python IDE 或文本编辑器中打开一个新的文件。我们还需要osre库,以及来自mrjobNumPyMRJob。我们还需要itemgetter,因为我们将会对一个字典进行排序:

import os 
import re 
import numpy as np 
from mrjob.job import MRJob 
from operator import itemgetter

我们还需要MRStep,它概述了 MapReduce 作业中的一个步骤。我们之前的作业只有一个步骤,它被定义为映射函数然后是减少函数。这个作业将会有多个步骤,我们将进行映射、减少,然后再进行映射和减少。直觉与我们在早期章节中使用的管道相同,其中一步的输出是下一步的输入:

from mrjob.step import MRStep

我们然后创建我们的单词搜索正则表达式并编译它,这样我们就可以找到单词边界。这种正则表达式比我们在一些早期章节中使用的简单分割更强大,但如果你在寻找一个更准确的单词分割器,我建议使用 NLTK 或 Spacey,就像我们在第六章,使用朴素贝叶斯进行社交媒体洞察中做的那样:

word_search_re = re.compile(r"[w']+") 

我们为我们的训练定义了一个新的类。我首先会提供一个完整的代码块,然后我们将回到每个部分来回顾它所做的工作:

class NaiveBayesTrainer(MRJob):

    def steps(self):
    return [
            MRStep(mapper=self.extract_words_mapping,
                   reducer=self.reducer_count_words),
            MRStep(reducer=self.compare_words_reducer),
    ]

    def extract_words_mapping(self, key, value):
        tokens = value.split()
        gender = eval(tokens[0])
        blog_post = eval(" ".join(tokens[1:]))
        all_words = word_search_re.findall(blog_post)
        all_words = [word.lower() for word in all_words]
        for word in all_words:
            # Occurence probability
            yield (gender, word), 1\. / len(all_words)

    def reducer_count_words(self, key, counts):
        s = sum(counts)
        gender, word = key #.split(":")
        yield word, (gender, s)

    def compare_words_reducer(self, word, values):
        per_gender = {}
        for value in values:
            gender, s = value
            per_gender[gender] = s
            yield word, per_gender

    def ratio_mapper(self, word, value):
        counts = dict(value)
        sum_of_counts = float(np.mean(counts.values()))
        maximum_score = max(counts.items(), key=itemgetter(1))
        current_ratio = maximum_score[1] / sum_of_counts
        yield None, (word, sum_of_counts, value)

    def sorter_reducer(self, key, values):
        ranked_list = sorted(values, key=itemgetter(1), reverse=True)
        n_printed = 0
        for word, sum_of_counts, scores in ranked_list:
            if n_printed < 20:
                print((n_printed + 1), word, scores)
            n_printed += 1
        yield word, dict(scores)

让我们一步一步地看看这个代码的各个部分:

class NaiveBayesTrainer(MRJob):

我们定义了我们的 MapReduce 作业的步骤。有两个步骤:

第一步将提取单词出现概率。第二步将比较两个性别,并将每个性别的概率输出到我们的输出文件。在每个 MRStep 中,我们定义映射器和减少器函数,这些函数是这个朴素贝叶斯训练器类中的类函数(我们将在下面编写这些函数):

    def steps(self):
        return [
            MRStep(mapper=self.extract_words_mapping,
                   reducer=self.reducer_count_words),
            MRStep(reducer=self.compare_words_reducer),
        ]

第一个函数是第一步的映射器函数。这个函数的目标是取每一篇博客文章,获取该文章中的所有单词,并记录出现次数。我们想要单词的频率,所以我们将返回1 / len(all_words),这样我们就可以在之后对频率值求和。这里的计算并不完全正确——我们需要对文档数量进行归一化。然而,在这个数据集中,类的大小是相同的,所以我们方便地忽略这一点,对最终版本的影响很小。

我们还输出了文章作者的性别,因为我们稍后会需要它:

    def extract_words_mapping(self, key, value):
        tokens = value.split()
        gender = eval(tokens[0])
        blog_post = eval(" ".join(tokens[1:]))
        all_words = word_search_re.findall(blog_post)
        all_words = [word.lower() for word in all_words]
        for word in all_words:
            # Occurence probability
            yield (gender, word), 1\. / len(all_words)

我们在上一段代码中使用了eval来简化从文件中解析博客文章的过程,为了这个示例。这并不推荐。相反,使用 JSON 这样的格式来正确存储和解析文件中的数据。一个可以访问数据集的恶意用户可以将代码插入这些标记中,并让这些代码在您的服务器上运行。

在第一步的 reducer 中,我们为每个性别和单词对的总频率求和。我们还更改了键,使其成为单词,而不是组合,这样当我们使用最终训练好的模型进行搜索时,我们可以按单词搜索(尽管,我们仍然需要输出性别以供以后使用);

    def reducer_count_words(self, key, counts):
        s = sum(counts)
        gender, word = key #.split(":")
        yield word, (gender, s)

最后一步不需要 mapper 函数,这就是为什么我们没有添加一个。数据将直接作为一种类型的身份 mapper 通过。然而,reducer 将合并给定单词下的每个性别的频率,然后输出单词和频率字典。

这为我们朴素贝叶斯实现提供了所需的信息:

    def compare_words_reducer(self, word, values):
        per_gender = {}
        for value in values:
            gender, s = value
            per_gender[gender] = s
            yield word, per_gender

最后,我们将代码设置为在文件作为脚本运行时运行此模型。我们需要将此代码添加到文件中:

if __name__ == '__main__': 
 NaiveBayesTrainer.run()

然后,我们可以运行这个脚本。这个脚本的输入是上一个后提取脚本(如果您愿意,实际上可以将它们作为同一个 MapReduce 作业中的不同步骤)的输出;

$ python nb_train.py <your_data_folder>/blogposts/ 
 --output-dir=<your_data_folder>/models/ --no-output

输出目录是一个文件夹,将存储包含 MapReduce 作业输出的文件,这将是我们运行朴素贝叶斯分类器所需的概率。

将所有内容整合在一起

我们现在可以使用这些概率实际运行朴素贝叶斯分类器。我们将使用 Jupyter Notebook 来完成这项工作,尽管这个处理本身可以被转移到 mrjob 包中,以进行大规模处理。

首先,查看在上一个 MapReduce 作业中指定的models文件夹。如果输出文件多于一个,我们可以通过将它们附加在一起来合并文件,使用models目录内的命令行功能:

cat * > model.txt

如果这样做,您需要更新以下代码,将model.txt作为模型文件名。

回到我们的 Notebook,我们首先导入一些标准导入,这些导入对于我们的脚本来说是必需的:

import os 
import re
import numpy as np 
from collections import defaultdict 
from operator import itemgetter

我们再次重新定义我们的单词搜索正则表达式——如果您在实际应用中这样做,我建议集中化功能。对于训练和测试来说,单词的提取方式必须相同:

word_search_re = re.compile(r"[w']+")

接下来,我们创建一个函数,从给定的文件名中加载我们的模型。模型参数将采用字典的字典形式,其中第一个键是一个单词,内部字典将每个性别映射到一个概率。我们使用defaultdicts,如果值不存在,则返回零;

def load_model(model_filename):
    model = defaultdict(lambda: defaultdict(float))
    with open(model_filename) as inf: 
        for line in inf:
            word, values = line.split(maxsplit=1) 
            word = eval(word) 
            values = eval(values)
            model[word] = values
    return model

这行被分成两个部分,由空格分隔。第一部分是单词本身,第二部分是概率的字典。对于每个部分,我们运行eval以获取实际值,该值在之前的代码中使用repr存储。

接下来,我们加载我们的实际模型。你可能需要更改模型文件名——它将在最后一个 MapReduce 作业的输出目录中;

model_filename = os.path.join(os.path.expanduser("~"), "models", "part-00000") 
model = load_model(model_filename)

例如,我们可以看到在男性与女性之间,单词 i(在 MapReduce 作业中所有单词都转换为小写)的使用差异:

model["i"]["male"], model["i"]["female"]

接下来,我们创建一个函数,该函数可以使用这个模型进行预测。在这个例子中,我们不会使用 scikit-learn 接口,而是创建一个函数。我们的函数接受模型和文档作为参数,并返回最可能的性别:

def nb_predict(model, document):
    probabilities = defaultdict(lambda : 1)
    words = word_search_re.findall(document)
    for word in set(words): 
        probabilities["male"] += np.log(model[word].get("male", 1e-15)) 
        probabilities["female"] += np.log(model[word].get("female", 1e-15))
        most_likely_genders = sorted(probabilities.items(), key=itemgetter(1), reverse=True) 
    return most_likely_genders[0][0]

重要的是要注意,我们使用了np.log来计算概率。朴素贝叶斯模型中的概率通常非常小。对于许多统计值来说,乘以小值是必要的,这可能导致下溢错误,即计算机的精度不够好,整个值变成 0。在这种情况下,这会导致两种性别的似然值都为零,从而导致预测错误。

为了解决这个问题,我们使用对数概率。对于两个值 a 和 b,log(a× b) 等于 log(a) + log(b)。小概率的对数是一个负值,但相对较大。例如,log(0.00001)大约是-11.5。这意味着,而不是乘以实际概率并冒着下溢错误的风险,我们可以相加对数概率,并以相同的方式比较值(数值仍然表示更高的可能性)。

如果你想要从对数概率中获取概率,确保通过使用 e 的幂来撤销对数操作。要将-11.5 转换成概率,取 e^(-11.5),这等于 0.00001(大约)。

使用对数概率的一个问题是它们不能很好地处理零值(尽管,乘以零概率也不能)。这是因为对数(0)是未定义的。在一些朴素贝叶斯实现中,为了解决这个问题,会将所有计数加 1,但还有其他方法可以解决这个问题。这是对数值进行简单平滑的一种形式。在我们的代码中,如果对于给定的性别没有看到这个单词,我们只返回一个非常小的值。

在所有计数上加一是平滑的一种形式。另一种选择是初始化到一个非常小的值,例如 10^(-16)——只要它不是正好是 0!

回到我们的预测函数,我们可以通过复制数据集中的一个帖子来测试这个函数:

new_post = """ Every day should be a half day. Took the afternoon off to hit the dentist, and while I was out I managed to get my oil changed, too. Remember that business with my car dealership this winter? Well, consider this the epilogue. The friendly fellas at the Valvoline Instant Oil Change on Snelling were nice enough to notice that my dipstick was broken, and the metal piece was too far down in its little dipstick tube to pull out. Looks like I'm going to need a magnet. Damn you, Kline Nissan, daaaaaaammmnnn yooouuuu.... Today I let my boss know that I've submitted my Corps application. The news has been greeted by everyone in the company with a level of enthusiasm that really floors me. The back deck has finally been cleared off by the construction company working on the place. This company, for anyone who's interested, consists mainly of one guy who spends his days cursing at his crew of Spanish-speaking laborers. Construction of my deck began around the time Nixon was getting out of office.
"""

然后,我们使用以下代码进行预测:

nb_predict(model, new_post)

结果预测,男性,对于这个例子是正确的。当然,我们永远不会在单个样本上测试一个模型。我们使用了以 51 开头的文件来训练这个模型。样本并不多,所以我们不能期望很高的准确度。

我们应该做的第一件事是在更多样本上训练。我们将测试以 6 或 7 开头的任何文件,并在其余文件上训练。

在命令行和你的数据文件夹(cd <your_data_folder>),其中存在博客文件夹,将博客文件夹复制到一个新文件夹中。

为我们的训练集创建一个文件夹:

mkdir blogs_train

将以 6 或 7 开头的任何文件移动到测试集,从训练集中:

cp blogs/4* blogs_train/ 
cp blogs/8* blogs_train/

然后,为我们的测试集创建一个文件夹:

mkdir blogs_test

将以 6 或 7 开头的任何文件移动到测试集,从训练集中:

cp blogs/6* blogs_test/ 
cp blogs/7* blogs_test/

我们将在训练集中的所有文件上重新运行博客提取。然而,这是一个更适合云基础设施而不是我们系统的大计算量。因此,我们现在将解析工作迁移到亚马逊的基础设施。

在命令行上运行以下命令,就像之前一样。唯一的区别是我们将在不同的输入文件文件夹上训练。在运行以下代码之前,请删除博客文章和模型文件夹中的所有文件:

$ python extract_posts.py ~/Data/blogs_train --output-dir=/home/bob/Data/blogposts_train --no-output

接下来是训练我们的朴素贝叶斯模型。这里的代码运行时间会相当长。可能需要很多很多小时。除非你有一个非常强大的系统,否则你可能想跳过本地运行这一步!如果你想跳过,请转到下一节。

$ python nb_train.py ~/Data/blogposts_train/ --output-dir=/home/bob/models/ --no-output

我们将在测试集中的任何博客文件上进行测试。为了获取这些文件,我们需要提取它们。我们将使用extract_posts.py MapReduce 作业,但将文件存储在单独的文件夹中:

python extract_posts.py ~/Data/blogs_test --output-dir=/home/bob/Data/blogposts_test --no-output

在 Jupyter Notebook 中,我们列出所有输出的测试文件:

testing_folder = os.path.join(os.path.expanduser("~"), "Data", "blogposts_testing") 
testing_filenames = [] 
for filename in os.listdir(testing_folder): 
    testing_filenames.append(os.path.join(testing_folder, filename))

对于这些文件中的每一个,我们提取性别和文档,然后调用预测函数。我们这样做是因为有很多文档,我们不希望使用太多内存。生成器产生实际的性别和预测的性别:

def nb_predict_many(model, input_filename): 
    with open(input_filename) as inf: # remove leading and trailing whitespace 
    for line in inf: 
        tokens = line.split() 
        actual_gender = eval(tokens[0]) 
        blog_post = eval(" ".join(tokens[1:])) 
        yield actual_gender, nb_predict(model, blog_post)

然后,我们在整个数据集上记录预测和实际的性别。这里的预测要么是男性,要么是女性。为了使用 scikit-learn 中的f1_score函数,我们需要将这些转换为 1 和 0。为了做到这一点,我们记录性别为男性时为 0,性别为女性时为 1。为此,我们使用布尔测试,查看性别是否为女性。然后,我们使用 NumPy 将这些布尔值转换为int

y_true = []
y_pred = [] 
for actual_gender, predicted_gender in nb_predict_many(model, testing_filenames[0]):                    
    y_true.append(actual_gender == "female")   
    y_pred.append(predicted_gender == "female") 
    y_true = np.array(y_true, dtype='int') 
    y_pred = np.array(y_pred, dtype='int')

现在,我们使用 scikit-learn 中的 F1 分数来测试这个结果的质量:

from sklearn.metrics import f1_score 
print("f1={:.4f}".format(f1_score(y_true, y_pred, pos_label=None)))

0.78 的结果相当合理。我们可能可以通过使用更多数据来提高这个结果,但为了做到这一点,我们需要迁移到一个更强大的基础设施,它可以处理这些数据。

在亚马逊的 EMR 基础设施上训练

我们将使用亚马逊的弹性映射减少EMR)基础设施来运行我们的解析和模型构建作业。

为了做到这一点,我们首先需要在亚马逊的存储云中创建一个存储桶。为此,通过访问console.aws.amazon.com/s3在您的网络浏览器中打开亚马逊 S3 控制台,并点击创建存储桶。记住存储桶的名称,因为我们稍后会用到它。

右键单击新存储桶并选择属性。然后,更改权限,授予所有人完全访问权限。这通常不是一种好的安全实践,我建议您在完成本章后更改访问权限。您可以使用亚马逊服务的高级权限来授予您的脚本访问权限,并防止第三方查看您的数据。

左键单击存储桶以打开它,然后单击创建文件夹。将文件夹命名为 blogs_train。我们将把我们的训练数据上传到这个文件夹,以便在云上进行处理。

在您的计算机上,我们将使用亚马逊的 AWS CLI,这是亚马逊云上处理的一个命令行界面。

要安装它,请使用以下命令:

sudo pip install awscli

按照以下说明设置此程序的凭证:docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-set-up.html

我们现在想要将我们的数据上传到我们的新存储桶。首先,我们需要创建我们的数据集,即所有不以 6 或 7 开头的博客。虽然还有更优雅的方式来执行这个复制操作,但没有一个是足够跨平台的,值得推荐。相反,只需简单地复制所有文件,然后从训练数据集中删除以 6 或 7 开头的文件:

cp -R ~/Data/blogs ~/Data/blogs_train_large 
rm ~/Data/blogs_train_large/blogs/6* 
rm ~/Data/blogs_train_large/blogs/7*

接下来,将数据上传到您的亚马逊 S3 存储桶。请注意,这将花费一些时间,并且会使用相当多的上传数据(数百万字节)。对于那些互联网连接较慢的用户,在更快连接的地方进行此操作可能值得;

aws s3 cp ~/Data/blogs_train_large/ s3://ch12/blogs_train_large --recursive --exclude "*" 
--include "*.xml"

我们将使用 mrjob 连接到亚马逊的 EMR(弹性映射减少),它为我们处理整个流程;它只需要我们的凭证来完成这项任务。按照pythonhosted.org/mrjob/guides/emr-quickstart.html中的说明,使用您的亚马逊凭证设置 mrjob。

完成此操作后,我们仅对 mrjob 运行进行轻微的修改,以便在亚马逊 EMR 上运行。我们只需告诉 mrjob 使用-r 开关来使用 emr,然后设置我们的 s3 容器作为输入和输出目录。尽管这将运行在亚马逊的基础设施上,但它仍然需要相当长的时间来运行,因为 mrjob 的默认设置使用的是单个低功耗计算机。

$ python extract_posts.py -r emr s3://ch12gender/blogs_train_large/ 
--output-dir=s3://ch12/blogposts_train/ --no-output 
$ python nb_train.py -r emr s3://ch12/blogposts_train/ --output-dir=s3://ch12/model/ --o-output

您将因使用 S3 和 EMR 而付费。这只会花费几美元,但如果您打算继续运行作业或在更大的数据集上执行其他作业,请记住这一点。我运行了大量的作业,总共花费了大约 20 美元。仅运行这些作业应该不到 4 美元。然而,您可以通过访问console.aws.amazon.com/billing/home来检查您的余额并设置价格警报;

blogposts_train 和 model 文件夹的存在并不是必需的——它们将由 EMR 创建。实际上,如果它们存在,你会得到一个错误。如果你要重新运行,只需将这些文件夹的名称更改为新的名称,但请记住将两个命令都改为相同的名称(即第一个命令的输出目录是第二个命令的输入目录)。

如果你感到不耐烦,你可以在一段时间后停止第一个任务,只需使用到目前为止收集到的训练数据。我建议至少让任务运行 15 分钟,可能至少一个小时。但是,你不能停止第二个任务并得到好的结果;第二个任务可能需要比第一个任务长两到三倍的时间。

如果你有能力购买更先进的硬件,mrjob 支持在 Amazon 的基础设施上创建集群,并且也支持使用更强大的计算硬件。你可以在命令行中指定类型和数量来在机器集群上运行一个任务。例如,要使用 16 台 c1.medium 计算机提取文本,请运行以下命令:

$ python extract_posts.py -r emr s3://chapter12/blogs_train_large/blogs/ --output-dir=s3://chapter12/blogposts_train/ --no-output  --instance-type c1.medium --num-core-instances 16

此外,你可以单独创建集群并将任务重新附加到这些集群上。有关此过程的更多信息,请参阅 mrjob 的文档pythonhosted.org/mrjob/guides/emr-advanced.html。请注意,更高级的选项是 mrjob 的高级功能和 Amazon 的 AWS 基础设施的高级功能之间的交互,这意味着你需要研究这两种技术以获得强大的处理能力。请注意,如果你运行更多更强大的硬件实例,你将相应地支付更多费用。

现在,你可以回到 s3 控制台并从你的存储桶中下载输出模型。将其保存在本地后,我们可以回到我们的 Jupyter Notebook 并使用新的模型。我们在此重新输入代码——只有差异被突出显示,只是为了更新到我们的新模型:

ws_model_filename = os.path.join(os.path.expanduser("~"), "models", "aws_model")
aws_model = load_model(aws_model_filename) 
y_true = [] 
y_pred = [] 
for actual_gender, predicted_gender in nb_predict_many(aws_model, testing_filenames[0]):
    y_true.append(actual_gender == "female") 
    y_pred.append(predicted_gender == "female") 
y_true = np.array(y_true, dtype='int') 
y_pred = np.array(y_pred, dtype='int') 
print("f1={:.4f}".format(f1_score(y_true, y_pred, pos_label=None)))

结果更好,达到 0.81。

如果一切按计划进行,你可能想从 Amazon S3 中删除该存储桶——你将为此付费。

摘要

在本章中,我们讨论了在大数据上运行任务。根据大多数标准,我们的数据集实际上相当小——只有几百兆字节。许多工业数据集要大得多,因此需要额外的处理能力来执行计算。此外,我们使用的算法可以根据不同的任务进行优化,以进一步提高可扩展性。

我们的方法从博客文章中提取单词频率,以预测文档作者的性别。我们使用基于 mrjob 的 MapReduce 项目提取博客和单词频率。有了这些提取的数据,我们就可以执行类似朴素贝叶斯(Naive Bayes)的计算来预测新文档的性别。

我们只是触及了 MapReduce 所能做到的一小部分,而且在这个应用中甚至没有充分利用它的全部潜力。为了进一步吸取教训,将预测函数转换为 MapReduce 作业。也就是说,你在 MapReduce 上训练模型以获得一个模型,然后使用 MapReduce 运行模型以获取预测列表。通过在 MapReduce 中执行评估来扩展这一过程,最终结果简单地以 F1 分数的形式返回!

我们可以使用 mrjob 库在本地进行测试,然后自动设置并使用亚马逊的 EMR 云基础设施。您可以使用其他云基础设施,甚至自定义构建的亚马逊 EMR 集群来运行这些 MapReduce 作业,但需要做一些额外的调整才能使它们运行。

第十三章:下一步...

在课程中,有很多未被探索的途径、未提出的选项和未充分研究的主题。在本附录中,我为那些希望进行额外学习并使用 Python 推进数据挖掘的人创建了一系列下一步行动。

本附录旨在学习更多关于数据挖掘的知识。还包括一些扩展所做工作的挑战。其中一些将是小的改进;有些工作会更多——我已经记下了那些比其他任务明显更困难、更复杂的工作。

数据挖掘入门

在本章中,读者可以探索以下途径:

Scikit-learn 教程

URL: scikit-learn.org/stable/tutorial/index.html

scikit-learn 文档中包含了一系列关于数据挖掘的教程。这些教程从基本介绍到玩具数据集,再到最近研究中所用技术的全面教程。这些教程需要花费相当长的时间才能完成——它们非常全面——但学习起来非常值得。

还有大量算法已经实现,以与 scikit-learn 兼容。由于许多原因,这些算法并不总是包含在 scikit-learn 本身中,但许多这些算法的列表维护在 github.com/scikit-learn/scikit-learn/wiki/Third-party-projects-and-code-snippets

扩展 Jupyter Notebook

URL: ipython.org/ipython-doc/1/interactive/public_server.html

Jupyter Notebook 是一个强大的工具。它可以以多种方式扩展,其中之一是创建一个服务器来运行你的笔记本,与你的主要计算机分开。如果你使用的是低功耗的主计算机,如小型笔记本电脑,但手头上有更强大的计算机,这将非常有用。此外,你可以设置节点以执行并行计算。

更多数据集

URL: archive.ics.uci.edu/ml/

互联网上有许多来自不同来源的数据集。这些包括学术、商业和政府数据集。在 UCI ML 图书馆有一个包含良好标签的数据集集合,这是寻找测试你的算法的最佳选择之一。尝试使用 OneR 算法测试这些不同的数据集之一。

其他评估指标

对于其他方法有很多种评估指标。一些值得研究的著名指标包括:

这些指标中的每一个都是针对特定应用开发的。例如,段落评估指标评估将文本文档分割成块时的准确性,允许块边界之间有一定的变化。了解评估指标可以应用在哪里以及不能应用在哪里对于数据挖掘的持续成功至关重要。

更多应用想法

URL:datapipeline.com.au/

如果您在寻找更多数据挖掘应用的想法,特别是针对商业的,请查看我的公司博客。我经常发布有关数据挖掘应用的文章,重点关注商业的实际成果。

使用 scikit-learn 估计器进行分类

最近邻算法的简单实现相当慢——它检查所有点对以找到彼此靠近的点。存在更好的实现,其中一些已经在 scikit-learn 中实现。

与最近邻的扩展性

URL:github.com/jnothman/scikit-learn/tree/pr2532

例如,可以创建一个 kd 树来加快算法(这已经包含在 scikit-learn 中)。

另一种加快此搜索的方法是使用局部敏感哈希,局部敏感哈希(LSH)。这是对 scikit-learn 的提议改进,但在写作时尚未包含在包中。前面的链接提供了一个 scikit-learn 的开发分支,您可以在其中测试数据集上的 LSH。阅读此分支附带的文档,了解如何进行此操作。

要安装它,克隆存储库并按照说明在您的计算机上安装可在scikit-learn.org/stable/install.html找到的 Bleeding Edge 代码。请记住使用存储库的代码而不是官方源。我建议您使用 Anaconda 来尝试 bleeding-edge 包,以免与系统上的其他库发生冲突。

更复杂的管道

URL:scikit-learn.org/stable/modules/pipeline.html#featureunion-composite-feature-spaces

我们在这里使用的管道遵循单一流程——一个步骤的输出是另一个步骤的输入。

管道遵循转换器和估计器接口,这使我们能够在管道中嵌入管道。这对于非常复杂的模型来说是一个有用的结构,但当与特征联合(Feature Unions)结合使用时,它变得非常强大,如前一个链接所示。这允许我们一次提取多种类型的特征,然后将它们组合成一个单一的数据集。更多详情,请参阅此示例:scikit-learn.org/stable/auto_examples/feature_stacker.html

比较分类器

scikit-learn 中有许多现成的分类器。您为特定任务选择的分类器将基于各种因素。您可以通过比较 f1 分数来查看哪种方法更好,并且您可以调查这些分数的偏差,以查看结果是否具有统计学意义。

一个重要因素是它们在相同的数据上进行了训练和测试——也就是说,一个分类器的测试集是所有分类器的测试集。我们使用随机状态确保这一点——这是复制实验的一个重要因素。

自动学习

URL: rhiever.github.io/tpot/

URL: github.com/automl/auto-sklearn

这几乎是一种作弊行为,但这些包会为您调查数据挖掘实验中可能的各种模型。这消除了创建一个工作流程以测试大量参数和分类器类型的需要,并让您可以专注于其他事情,例如特征提取——尽管仍然至关重要,但尚未实现自动化!

通用思路是提取您的特征,然后将结果矩阵传递给这些自动化分类算法(或回归算法)之一。它会为您进行搜索,甚至为您导出最佳模型。在 TPOT 的情况下,它甚至为您提供从头开始创建模型的 Python 代码,而无需在您的服务器上安装 TPOT。

使用决策树预测体育比赛赢家

URL: pandas.pydata.org/pandas-docs/stable/tutorials.html

pandas 库是一个非常好的包——您通常用于数据加载的任何内容,在 pandas 中可能已经实现了。您可以从他们的教程中了解更多信息。

克里斯·莫菲特(Chris Moffitt)也撰写了一篇优秀的博客文章,概述了人们在 Excel 中执行的一些常见任务以及如何在 pandas 中完成这些任务:pbpython.com/excel-pandas-comp.html

您也可以使用 pandas 处理大型数据集;请参阅用户 Jeff 的回答,了解 StackOverflow 问题的广泛概述:stackoverflow.com/a/14268804/307363

由 Brian Connelly 编写的另一个关于 pandas 的优秀教程:bconnelly.net/2013/10/summarizing-data-in-python-with-pandas/

更复杂的功能

URL:www.basketball-reference.com/teams/ORL/2014_roster_status.html

更大的练习!

体育队伍经常在一场比赛和另一场比赛之间发生变化。一个队伍的轻松胜利可能会因为几个最佳球员突然受伤而变成一场艰难的比赛。你也可以从篮球参考网站获取球队名单。例如,2013-2014 赛季奥兰多魔术队的名单可以在前面的链接中找到。所有 NBA 球队的类似数据都可用。

编写代码以整合球队的变化程度,并使用这些信息添加新功能可以显著提高模型。不过,这项任务需要相当多的工作!

Dask

URL:dask.pydata.org/en/latest/

如果你想要增强 pandas 的功能并提高其可扩展性,那么 Dask 就是你的选择。Dask 提供了 NumPy 数组、Pandas DataFrame 和任务调度的并行版本。通常,接口与原始 NumPy 或 Pandas 版本几乎相同。

研究

URL:scholar.google.com.au/

更大的练习!正如你可能想象的那样,在预测 NBA 比赛以及所有体育赛事方面已经进行了大量工作。在谷歌学术搜索中搜索“预测”以找到关于预测你最喜欢的的研究。

使用亲和分析推荐电影

有许多值得调查的基于推荐的数据库,每个数据库都有自己的问题。

新数据集

URL:www2.informatik.uni-freiburg.de/~cziegler/BX/

更大的练习!

有许多值得调查的基于推荐的数据库,每个数据库都有自己的问题。例如,Book-Crossing 数据集包含超过 278,000 个用户和超过一百万个评分。其中一些评分是明确的(用户确实给出了评分),而其他评分则更为隐晦。对这些隐晦评分的加权可能不应该像对明确评分那样高。音乐网站 www.last.fm 已经发布了一个用于音乐推荐的优秀数据集:[http://www.dtic.upf.edu/ocelma/MusicRecommendationDataset/。(http://www.dtic.upf.edu/ocelma/MusicRecommendationDataset/)]

此外,还有一个笑话推荐数据集!请参阅此处:eigentaste.berkeley.edu/dataset/

Eclat 算法

URL:www.borgelt.net/eclat.html

这里实现的 APriori 算法无疑是关联规则挖掘图中最著名的算法,但并不一定是最好的。Eclat 是一种更现代的算法,相对容易实现。

协同过滤

URL:github.com/python-recsys

对于那些想要在推荐引擎方面走得更远的人来说,调查其他推荐格式是必要的,例如协同过滤。这个库提供了一些关于算法和实现的背景信息,以及一些教程。在blogs.gartner.com/martin-kihn/how-to-build-a-recommender-system-in-python/上也有一个很好的概述。

使用 Transformer 提取特征

根据我的看法,以下主题在深入了解使用 Transformer 提取特征时也是相关的

添加噪声

我们讨论了通过去除噪声来提高特征;然而,通过添加噪声,对于某些数据集可以获得更好的性能。原因很简单——这有助于通过迫使分类器稍微泛化其规则来防止过拟合(尽管过多的噪声会使模型过于泛化)。尝试实现一个可以将给定数量的噪声添加到数据集的 Transformer。在 UCI ML 的一些数据集上测试它,看看是否提高了测试集的性能。

Vowpal Wabbit

URL:hunch.net/~vw/

Vowpal Wabbit 是一个很棒的项目,为基于文本的问题提供了非常快速的特征提取。它附带一个 Python 包装器,允许您从 Python 代码中调用它。在大型数据集上测试它。

word2vec

URL:radimrehurek.com/gensim/models/word2vec.html

词嵌入因其在许多文本挖掘任务中表现良好而受到研究和行业的广泛关注,这是有充分理由的:它们在许多文本挖掘任务中表现非常好。它们比词袋模型复杂得多,并创建更大的模型。当您拥有大量数据时,词嵌入是很好的特征,甚至在某些情况下还可以帮助处理更小的数据量。

使用朴素贝叶斯进行社交媒体洞察

在完成使用朴素贝叶斯进行社交媒体洞察后,请考虑以下要点。

垃圾邮件检测

URL:scikit-learn.org/stable/modules/model_evaluation.html#scoring-parameter

使用这里的概念,您可以创建一个能够查看社交媒体帖子并确定其是否为垃圾邮件的垃圾邮件检测方法。通过首先创建垃圾邮件/非垃圾邮件帖子数据集,实现文本挖掘算法,然后评估它们来尝试这个方法。

在垃圾邮件检测中,一个重要的考虑因素是误报/漏报比率。许多人宁愿让几条垃圾邮件消息溜走,也不愿错过一条合法消息,因为过滤器在阻止垃圾邮件时过于激进。为了转换你的方法,你可以使用带有 f1 分数作为评估标准的网格搜索。参见前面的链接,了解如何进行此操作。

自然语言处理和词性标注

URL:www.nltk.org/book/ch05.html

我们在这里使用的技术与其他领域使用的某些语言模型相比相当轻量级。例如,词性标注可以帮助消除词形歧义,从而提高准确性。它随 NLTK 提供。

使用图挖掘发现要关注的账户

在完成本章后,请务必阅读以下内容。

更复杂的算法

URL:www.cs.cornell.edu/home/kleinber/link-pred.pdf更大的练习!

在预测图中的链接方面已经进行了广泛的研究,包括社交网络。例如,David Liben-Nowell 和 Jon Kleinberg 发表了关于这个主题的论文,这将是一个更复杂算法的好起点,之前已经链接过。

NetworkX

URL:networkx.github.io/

如果你打算更多地使用图表和网络,深入研究 NetworkX 包是非常值得你花时间的——可视化选项很棒,算法实现得很好。还有一个名为 SNAP 的库,它也提供了 Python 绑定,网址为snap.stanford.edu/snappy/index.html

使用神经网络击败 CAPTCHA

你可能还会对以下主题感兴趣:

更好(更糟?)的 CAPTCHA

URL:scikit-image.org/docs/dev/auto_examples/applications/plot_geometric.html

更大的练习!

在本例中我们击败的 CAPTCHA 并不像今天通常使用的那么复杂。你可以使用以下多种技术创建更复杂的变体:

深度网络

这些技术可能会欺骗我们当前的实现,因此需要改进以使方法更好。尝试我们使用的深度网络。然而,更大的网络需要更多的数据,所以你可能需要生成比这里所做的那几千个样本更多的样本才能获得良好的性能。生成这些数据集是并行化的好候选——有很多可以独立执行的小任务。

增加数据集大小的良好方法,同样适用于其他数据集,是创建现有图像的变体。将图像上下颠倒,奇怪地裁剪,添加噪声,模糊图像,将一些随机像素变为黑色等等。

强化学习

URL:pybrain.org/docs/tutorial/reinforcement-learning.html

强化学习正在成为数据挖掘下一个大趋势——尽管它已经存在很长时间了!PyBrain 有一些强化学习算法,值得用这个数据集(以及其他数据集)检查。

作者归属

当涉及到作者归属时,确实应该阅读以下主题。

增加样本量

我们使用的安然应用最终只使用了整体数据集的一部分。这个数据集中还有大量其他数据可用。增加作者数量可能会降低准确性,但使用类似的方法,有可能进一步提高准确性,超过这里所达到的水平。使用网格搜索,尝试不同的 n-gram 值和不同的支持向量机参数,以在更多作者上获得更好的性能。

博客数据集

使用的这个数据集,提供了基于作者的分类(每个博客 ID 代表一个单独的作者)。这个数据集也可以使用这种方法进行测试。此外,还有其他可以测试的类别,如性别、年龄、行业和星座——基于作者的方法对这些分类任务有效吗?

本地 n-gram

URL:github.com/robertlayton/authorship_tutorials/blob/master/LNGTutorial.ipynb

另一种分类器形式是本地 n-gram,它涉及为每个作者选择最佳特征,而不是为整个数据集全局选择。我编写了一个关于使用本地 n-gram 进行作者归属的教程,可在前面的链接中找到。

新闻文章聚类

了解一下以下主题不会有任何坏处

聚类评估

聚类算法的评估是一个难题——一方面,我们可以说出好的聚类看起来是什么样子;另一方面,如果我们真的知道这一点,我们应该标记一些实例并使用监督分类器!关于这个主题已经有很多论述。以下是一个关于这个主题的幻灯片,它是一个很好的挑战介绍:www.cs.kent.edu/~jin/DM08/ClusterValidation.pdf

此外,这里有一篇关于这个主题的非常全面的(尽管现在有点过时)论文:web.itu.edu.tr/sgunduz/courses/verimaden/paper/validity_survey.pdf.

scikit-learn 包实现了那些链接中描述的许多度量,这里有一个概述:scikit-learn.org/stable/modules/clustering.html#clustering-performance-evaluation

使用其中的一些,你可以开始评估哪些参数需要使用以获得更好的聚类。使用网格搜索,我们可以找到最大化度量的参数——就像在分类中一样。

时间分析

更大的练习!

我们在这里开发的代码可以在数月内重新运行。通过给每个聚类添加一些标签,您可以跟踪哪些主题随着时间的推移保持活跃,从而获得对世界新闻中讨论内容的纵向视角。为了比较聚类,可以考虑一个指标,例如之前提到的调整互信息得分。看看聚类在一个月后、两个月后、六个月后和一年后的变化。

实时聚类

k-means 算法可以在给定时间框架的离散分析之外,随着时间的推移迭代训练和更新。可以通过多种方式跟踪聚类移动——例如,您可以跟踪每个聚类中哪些单词流行,以及每天质心移动了多少。请记住 API 限制——您可能只需要每隔几个小时检查一次,以保持您的算法更新。

使用深度学习在图像中分类对象

当考虑更深入地研究分类对象时,以下主题也非常重要。

Mahotas

URL: luispedro.org/software/mahotas/

另一个图像处理包是 Mahotas,包括更好、更复杂的图像处理技术,可以帮助实现更高的精度,尽管它们可能带来较高的计算成本。然而,许多图像处理任务都是并行化的良好候选。更多关于图像分类的技术可以在研究文献中找到,这篇综述论文是一个很好的起点:ijarcce.com/upload/january/22-A%20Survey%20on%20Image%20Classification.pdf

其他图像数据集可在rodrigob.github.io/are_we_there_yet/build/classification_datasets_results.html找到。

许多学术和基于行业的来源提供了大量的图像数据集。链接的网站列出了许多数据集和一些在它们上使用的最佳算法。实现一些较好的算法可能需要大量的自定义代码,但回报可能非常值得。

Magenta

URL: github.com/tensorflow/magenta/tree/master/magenta/reviews

此存储库包含一些值得阅读的高质量深度学习论文,以及论文及其技术的深入评论。如果您想深入研究深度学习,请首先查看这些论文,然后再向外扩展。

处理大数据

以下关于大数据的资源可能会有所帮助

Hadoop 课程

Yahoo 和 Google 都提供了关于 Hadoop 的优秀教程,从入门到相当高级的水平。它们没有专门针对 Python 的使用,但学习 Hadoop 概念然后在 Pydoop 或类似库中应用它们可以产生很好的效果。

Yahoo 的教程:developer.yahoo.com/hadoop/tutorial/

Google 的教程:cloud.google.com/hadoop/what-is-hadoop

Pydoop

URL:crs4.github.io/pydoop/tutorial/index.html

Pydoop 是一个用于运行 Hadoop 作业的 Python 库。Pydoop 也可以与 HDFS(Hadoop 文件系统)一起工作,尽管你同样可以在 mrjob 中获取该功能。Pydoop 将为你提供更多控制运行某些作业的能力。

推荐引擎

构建一个大型推荐引擎是测试你大数据技能的好方法。马克·利特温斯基(Mark Litwintschik)的一篇优秀的博客文章介绍了一个使用 Apache Spark(大数据技术)的引擎:tech.marksblogg.com/recommendation-engine-spark-python.html

W.I.L.L

URL:github.com/ironman5366/W.I.L.L

这是一个非常大的项目!

这个开源的个人助理可以成为你的下一个来自钢铁侠的 JARVIS。你可以通过数据挖掘技术添加到这个项目中,使其学会执行你经常需要做的某些任务。这并不容易,但潜在的生产力提升是值得的。

更多资源

以下是一些额外的信息资源:

Kaggle 竞赛

URL:www.kaggle.com/

Kaggle 定期举办数据挖掘竞赛,通常伴有现金奖励。

在 Kaggle 竞赛中测试你的技能是快速学习如何处理真实世界数据挖掘问题的好方法。论坛很棒,共享环境——在竞赛中,你经常会看到排名前十的参赛者发布的代码!

Coursera

URL:www.coursera.org

Coursera 包含许多关于数据挖掘和数据科学的课程。许多课程是专业化的,例如大数据和图像处理。一个很好的入门课程是安德鲁·吴(Andrew Ng)的著名课程:www.coursera.org/learn/machine-learning/

这比这个要高级一些,对于感兴趣的读者来说,这将是一个很好的下一步。

对于神经网络,你可以查看这个课程:www.coursera.org/course/neuralnets

如果你完成了所有这些,可以尝试在www.coursera.org/course/pgm上学习概率图模型课程。

posted @ 2025-09-03 10:21  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报