Python-机器学习系统构建指南第二版-全-

Python 机器学习系统构建指南第二版(全)

原文:annas-archive.org/md5/1799e60b9ee143f87b98f9fc0705a0c9

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

可以说,你能拿到这本书(或在电子书阅读器上看到它)是一次幸运的巧合。毕竟,每年都会印刷出数百万本书,供数百万读者阅读。然后,你阅读了这本书。也可以说,一些机器学习算法在将你引向这本书——或将这本书引向你方面,发挥了作用。而我们,作者,很高兴你愿意了解更多关于“如何”和“为什么”的内容。

本书的大部分内容将覆盖如何。数据必须如何处理,才能使机器学习算法能够最大化利用它?面对一个问题,应该如何选择合适的算法?

我们偶尔也会讨论为什么。为什么正确度量很重要?为什么在特定场景下,一个算法优于另一个算法?

我们知道,要成为该领域的专家,仍有许多东西需要学习。毕竟,我们只涵盖了一些如何,以及为什么的一小部分。但最终,我们希望这本书的内容能帮助你尽快上手并运行。

本书所涉及的内容

第一章,Python 机器学习入门,通过一个非常简单的例子介绍了机器学习的基本概念。尽管它很简单,但它将通过过拟合的风险来挑战我们。

第二章,使用真实世界示例进行分类,通过真实数据学习分类,训练计算机能够区分不同种类的花朵。

第三章,聚类 – 查找相关帖子,讲解了词袋模型的强大,当我们应用它来查找相似的帖子时,不需要真正“理解”它们。

第四章,主题建模,超越了将每个帖子分配到单一簇的做法,将它们分配到多个主题,因为真实的文本可以涉及多个主题。

第五章,分类 – 检测差劲的答案,讲解了如何通过偏差-方差权衡来调试机器学习模型,尽管本章主要讲解如何使用逻辑回归来判断用户回答问题的好坏。

第六章,分类 II – 情感分析,解释了朴素贝叶斯如何工作,以及如何使用它来分类推文,以判断它们是积极的还是消极的。

第七章,回归,讲解了如何使用经典的回归主题来处理数据,这在今天仍然具有相关性。你还将学习高级回归技术,如 Lasso 回归和 ElasticNets。

第八章, 推荐系统,根据顾客的产品评分构建推荐系统。我们还将看到如何仅凭购物数据(而不需要评分数据,用户并不总是提供评分)来构建推荐系统。

第九章, 分类 – 音乐流派分类,让我们假装某人把我们庞大的音乐收藏搞乱了,而我们唯一能做的就是让机器学习模型来分类我们的歌曲。事实证明,有时候依赖他人的专业知识比自己创建特征要好。

第十章, 计算机视觉,讲解如何在处理图像时应用分类,通过从数据中提取特征。我们还将看到如何将这些方法适应于在一个集合中找到相似的图像。

第十一章, 降维,教我们如何使用其他方法帮助我们减少数据的维度,使其能够被我们的机器学习算法处理。

第十二章, 更大的数据,探索了一些通过利用多个核心或计算集群来处理更大数据的方法。我们还介绍了如何使用云计算(以 Amazon Web Services 作为我们的云服务提供商)。

附录, 更多机器学习学习资源,列出了许多可以深入学习机器学习的精彩资源。

本书所需的内容

本书假设你了解 Python 以及如何使用 easy_install 或 pip 安装库。我们不依赖任何高级数学知识,如微积分或矩阵代数。

本书中使用的是以下版本,但如果你使用更高版本也没问题:

  • Python 2.7(所有代码也兼容版本 3.3 和 3.4)

  • NumPy 1.8.1

  • SciPy 0.13

  • scikit-learn 0.14.0

本书适用对象

本书适用于希望学习如何使用开源库进行机器学习的 Python 程序员。我们将通过基于现实案例的基本机器学习模式进行讲解。

本书还适用于那些希望开始使用 Python 构建系统的机器学习者。Python 是一个灵活的语言,适用于快速原型开发,而底层算法都是用优化过的 C 或 C++编写的。因此,生成的代码足够快速和健壮,能够用于生产环境。

约定

在本书中,你将看到一些文本样式,它们用来区分不同类型的信息。以下是这些样式的一些示例,以及它们的含义解释。

书中出现的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号会像这样显示:“我们然后使用 poly1d() 从模型参数创建模型函数。”

一段代码如下所示:

[aws info]
AWS_ACCESS_KEY_ID =  AAKIIT7HHF6IUSN3OCAA
AWS_SECRET_ACCESS_KEY = <your secret key>

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

>>> import numpy
>>> numpy.version.full_version
1.8.1

新术语重要单词 用粗体显示。例如,您在屏幕上看到的、在菜单或对话框中的文字,会像这样出现在文本中:“一旦机器停止,更改实例类型选项将变为可用。”

注意

警告或重要说明会以这样的框框形式出现。

提示

提示和技巧会像这样显示。

读者反馈

我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或可能不喜欢的地方。读者反馈对我们来说非常重要,它帮助我们开发出真正能让您受益的书籍。

要向我们发送一般反馈,请直接发送电子邮件至 <feedback@packtpub.com>,并在邮件主题中提及书籍标题。如果您在某个领域有专业知识,并且有兴趣参与撰写或贡献书籍,请参阅我们的作者指南 www.packtpub.com/authors

客户支持

现在您是一本 Packt 书籍的自豪拥有者,我们为您提供了多种方式,帮助您最大限度地利用您的购买。

下载示例代码

您可以从您的账户中下载示例代码文件,访问 www.packtpub.com,下载您购买的所有 Packt 出版书籍的示例代码。如果您从其他地方购买了本书,您可以访问 www.packtpub.com/support,注册后,我们会将文件直接通过电子邮件发送给您。

本书的代码也可以在 GitHub 上找到,链接为 github.com/luispedro/BuildingMachineLearningSystemsWithPython。该仓库会保持更新,以便包含勘误和任何必要的更新,例如针对新版 Python 或书中使用的包的更新。

勘误

尽管我们已尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书籍中发现错误——可能是文本中的错误或代码中的错误——我们将非常感激您能向我们报告。通过这样做,您不仅能帮助其他读者避免困扰,还能帮助我们改进后续版本的书籍。如果您发现任何勘误,请访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表格链接,并填写勘误的详细信息。一旦您的勘误被验证,您的提交将被接受,勘误将被上传到我们的网站,或添加到该书勘误部分的现有勘误列表中。

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

另一个很好的方法是访问www.TwoToReal.com,在这里作者们会尽力提供支持并回答您的所有问题。

盗版

互联网上的版权材料盗版问题是一个长期存在的问题,涵盖了所有媒体。我们在 Packt 非常重视版权和许可证的保护。如果您在互联网上发现我们作品的任何非法复制形式,请立即向我们提供该地址或网站名称,以便我们采取措施。

如发现盗版内容,请通过<copyright@packtpub.com>与我们联系,并提供涉嫌盗版材料的链接。

我们感谢您帮助保护我们的作者以及我们向您提供有价值内容的能力。

问题

如果您在书籍的任何方面遇到问题,可以通过<questions@packtpub.com>与我们联系,我们将尽力解决您的问题。

第一章. Python 机器学习入门

机器学习教会机器自主完成任务。就这么简单。复杂性体现在细节上,而这很可能是你正在阅读本书的原因。

或许你有太多的数据,却缺乏足够的洞察力。你希望通过使用机器学习算法来解决这个挑战,于是你开始深入研究这些算法。但经过一段时间后,你感到困惑:你到底应该选择哪一种成千上万的算法?

或者,也许你对机器学习总体感兴趣,一段时间以来一直在阅读相关的博客和文章。一切看起来都像是魔法和酷炫的东西,于是你开始了探索,并将一些玩具数据输入到决策树或支持向量机中。然而,在成功应用它到一些其他数据后,你开始疑惑:整个设置是正确的吗?你得到了最优的结果吗?你怎么知道是否没有更好的算法?或者你的数据是正确的吗?

欢迎加入这个俱乐部!我们(作者)也曾处于那样的阶段,寻找一些能讲述机器学习理论教材背后故事的信息。事实证明,很多这些信息是“黑魔法”,通常不会在标准教科书中教授。所以从某种意义上来说,我们写这本书是为了我们年轻时的自己。这本书不仅提供了机器学习的快速入门介绍,还教会了我们一路上学到的经验教训。我们希望它能为你顺利进入计算机科学中最激动人心的领域之一提供帮助。

机器学习和 Python —— 一个梦幻组合

机器学习的目标是通过提供一些示例(如何做或不做任务),来教会机器(软件)执行任务。假设每天早晨,当你打开电脑时,你都会做同样的任务,将电子邮件进行整理,以便只有属于同一主题的电子邮件会出现在同一个文件夹中。经过一段时间后,你可能会感到厌烦,并想要自动化这个繁琐的工作。一个方法是开始分析你的大脑,并写下你在整理电子邮件时处理的所有规则。然而,这会相当繁琐,并且永远不完美。在这个过程中,你会漏掉一些规则,或者过度指定其他规则。一个更好且更具未来适应性的方式是通过选择一组电子邮件元信息和正文/文件夹名称对来自动化这个过程,然后让一个算法得出最佳规则集。这些对就是你的训练数据,最终得到的规则集(也称为模型)可以应用于我们未曾见过的未来电子邮件。这就是最简单形式的机器学习。

当然,机器学习(通常也称为数据挖掘或预测分析)本身并不是一个全新的领域。恰恰相反,它近年来的成功可以归因于将其他成功领域(如统计学)中的扎实技术和见解应用到实际中的务实方法。在统计学中,目的是帮助我们人类从数据中获取洞见,例如,通过了解潜在的模式和关系。随着你阅读越来越多关于机器学习成功应用的内容(你已经查看了www.kaggle.com,对吧?),你会发现应用统计学在机器学习专家中是一个常见的领域。

正如你稍后会看到的,提出一个合适的机器学习方法的过程从来不是一个瀑布式的过程。相反,你会看到自己在分析中来回反复,不断尝试不同版本的输入数据和多种机器学习算法。正是这种探索性的特点使得 Python 成为完美的选择。作为一种解释型高级编程语言,Python 看起来就是为这一过程而设计的,用于不断尝试不同的方式。更重要的是,它的执行速度也相当快。确实,它比 C 或其他类似的静态类型编程语言慢,但由于有大量易于使用的库(许多都是用 C 编写的),你无需为灵活性牺牲速度。

本书将教你什么(以及不教你什么)

本书将为你提供一个广泛的概述,介绍当前在机器学习各个领域中最常用的学习算法类型,以及在应用它们时需要注意的地方。然而,从我们的经验来看,我们知道,做一些“酷”的事情,也就是使用和调整像支持向量机、最近邻搜索或其集成算法等机器学习算法,只会消耗一个优秀机器学习专家时间的一小部分。通过观察下面的典型工作流程,我们看到大部分时间会花费在一些相对平凡的任务上:

  • 读取数据并进行清洗

  • 探索和理解输入数据

  • 分析如何最好地将数据呈现给学习算法

  • 选择正确的模型和学习算法

  • 正确衡量性能

当谈到探索和理解输入数据时,我们将需要一些统计学和基础数学知识。然而,你会发现,在应用这些知识时,那些在数学课上看似枯燥的内容,实际上在用来观察有趣数据时会变得非常令人兴奋。

旅程从读取数据开始。当你需要回答如何处理无效或缺失值等问题时,你会发现这更像是一门艺术,而非精确的科学。这是一个非常有意义的过程,因为做对这一部分将使你的数据可以被更多的机器学习算法使用,从而提高成功的可能性。

当数据已经准备好并存在于程序的数据结构中时,你会希望对正在处理的数据有一个更直观的了解。你有足够的数据来回答你的问题吗?如果没有,你可能需要考虑其他方法来获得更多数据。你是否拥有过多的数据?那你可能需要考虑如何从中提取一个合适的样本。

通常,你不会直接将数据输入到机器学习算法中。相反,你会发现你可以在训练之前对数据的某些部分进行优化。很多时候,机器学习算法会通过提升性能来回报你。你甚至会发现,经过优化的数据所使用的简单算法通常比使用原始数据的复杂算法表现更好。机器学习工作流中的这一部分被称为特征工程,它通常是一个非常令人兴奋且有回报的挑战。你会立刻看到创意和智慧所带来的成果。

选择正确的学习算法并不仅仅是从你工具箱中选择三四个算法进行比较(你将会看到更多的选择)。这更像是一个深思熟虑的过程,需要权衡不同的性能和功能需求。你是否需要快速的结果并愿意牺牲质量?还是你宁愿花更多时间以获得尽可能最好的结果?你是否对未来的数据有明确的想法,还是应该在这方面保持更保守一些?

最后,衡量性能是大多数机器学习新手容易犯错的地方。有一些错误是简单的,比如用训练数据来测试你的方法。但也有更复杂的错误,尤其是当你有不平衡的训练数据时。同样,数据是决定你尝试是否成功的关键部分。

我们看到只有第四点涉及到复杂的算法。尽管如此,我们希望这本书能够说服你,其他四个任务不仅仅是日常琐事,它们同样可以令人兴奋。我们的希望是,到书的最后,你会真正爱上数据,而不仅仅是学习算法。

为此,我们不会给你带来过多的理论性内容,关于各种机器学习算法的优秀书籍已经涵盖了这些内容(你可以在附录中找到相关书目)。相反,我们将尽力在每个章节中提供对基本方法的直观理解——只需让你了解基本概念,并能够迈出第一步。因此,这本书绝非机器学习的终极指南。它更像是一个入门工具包。我们希望它能激发你的好奇心,让你迫不及待地去学习更多关于这个有趣领域的知识。

在本章的其余部分,我们将设置并了解基本的 Python 库 NumPy 和 SciPy,然后使用 scikit-learn 训练我们的第一个机器学习模型。在这个过程中,我们将介绍一些基本的机器学习概念,这些概念将在整本书中使用。接下来的章节将通过前面提到的五个步骤,详细介绍使用 Python 进行机器学习的不同方面,并结合不同的应用场景。

当你遇到困境时该怎么办

我们尽力传达书中每个步骤所需的思想。然而,仍然会有一些情况让你卡住。原因可能从简单的拼写错误、奇怪的包版本组合到理解问题不一而足。

在这种情况下,有很多不同的方式可以获得帮助。很可能,你的问题已经在以下优秀的问答网站中提出并得到解决:

metaoptimize.com/qa:这个问答网站专注于机器学习话题。几乎每个问题都包含来自机器学习专家的超出平均水平的回答。即使你没有任何问题,偶尔去浏览一下,阅读一些回答也是一个好习惯。

stats.stackexchange.com:这个问答网站名为 Cross Validated,类似于 MetaOptimize,但更侧重于统计学问题。

stackoverflow.com:这个问答网站和前面提到的类似,但它的焦点更广,涵盖了通用的编程话题。例如,它包含了我们在本书中将使用的一些包的问题,比如 SciPy 或 matplotlib。

freenode.net/ 上的 #machinelearning:这是一个专注于机器学习话题的 IRC 频道。它是一个规模较小但非常活跃且乐于助人的机器学习专家社区。

www.TwoToReal.com:这是作者们创建的即时问答网站,旨在帮助你解决不适合前面所列类别的主题。如果你发布问题,作者中的一位如果在线,将会立即收到消息,并与你进行在线聊天。

如同一开始所述,本书试图帮助你快速入门机器学习。因此,我们强烈鼓励你建立自己的机器学习相关博客列表,并定期查看。这是了解什么有效、什么无效的最佳方式。

我们在这里想特别提到的唯一一个博客(更多内容见附录)是 blog.kaggle.com,这是 Kaggle 公司的博客,Kaggle 正在进行机器学习竞赛。通常,他们会鼓励竞赛的获胜者写下他们是如何接近竞赛的,哪些策略行不通,以及他们是如何得出获胜策略的。即使你不阅读其他任何内容,这也是必读的。

开始

假设你已经安装了 Python(至少是 2.7 及更新版本应该没问题),接下来我们需要安装 NumPy 和 SciPy 进行数值运算,以及安装 matplotlib 用于可视化。

NumPy、SciPy 和 matplotlib 简介

在我们讨论具体的机器学习算法之前,必须先讨论如何最好地存储我们需要处理的数据。这很重要,因为即使是最先进的学习算法,如果永远无法完成,也对我们没有任何帮助。这可能是因为数据访问速度过慢,或者它的表示形式迫使操作系统整天进行交换。再加上 Python 是解释型语言(尽管它是高度优化的),在许多数值计算密集型算法中,相比于 C 或 FORTRAN,它运行较慢。那么,我们不禁要问,为什么那么多科学家和公司在高度计算密集的领域依然押注 Python 呢?

答案是,在 Python 中,将数字运算任务转交给低层的 C 或 FORTRAN 扩展是非常容易的。而这正是 NumPy 和 SciPy 的作用所在(scipy.org/Download)。在这个配合下,NumPy 提供了高度优化的多维数组支持,这些数组是大多数先进算法的基本数据结构。SciPy 利用这些数组提供了一组快速的数值算法。最后,matplotlib(matplotlib.org/)可能是使用 Python 绘制高质量图形最便捷且功能最丰富的库。

安装 Python

幸运的是,对于所有主要的操作系统——即 Windows、Mac 和 Linux——都有针对 NumPy、SciPy 和 matplotlib 的专用安装包。如果你不确定安装过程,可以考虑安装 Anaconda Python 发行版(可以通过store.continuum.io/cshop/anaconda/访问),该发行版由 SciPy 的创始贡献者 Travis Oliphant 主导。Anaconda 与其他发行版(如 Enthought Canopy,下载地址:www.enthought.com/downloads/)或 Python(x,y)(访问地址:code.google.com/p/pythonxy/wiki/Downloads)的不同之处在于,Anaconda 已经完全兼容 Python 3——这是我们将在本书中使用的 Python 版本。

使用 NumPy 高效处理数据,并使用 SciPy 智能处理

让我们快速浏览一些基本的 NumPy 示例,然后看看 SciPy 在其基础上提供了什么。在这个过程中,我们将借助精彩的 Matplotlib 包进行绘图,迈出第一步。

如果需要深入了解,你可能想看看 NumPy 提供的一些更有趣的示例,访问www.scipy.org/Tentative_NumPy_Tutorial

你还会发现 NumPy 初学者指南 - 第二版Ivan Idris,由 Packt Publishing 出版,非常有价值。更多的教程风格指南可以在 scipy-lectures.github.com 找到,官方的 SciPy 教程请访问 docs.scipy.org/doc/scipy/reference/tutorial

注意

本书中,我们将使用版本 1.8.1 的 NumPy 和版本 0.14.0 的 SciPy。

学习 NumPy

所以让我们导入 NumPy 并稍微玩一下它。为此,我们需要启动 Python 交互式 shell:

>>> import numpy
>>> numpy.version.full_version
1.8.1

由于我们不想污染我们的命名空间,当然不应该使用以下代码:

>>> from numpy import *

因为例如,numpy.array 可能会与标准 Python 中包含的数组包发生冲突。因此,我们将使用以下方便的快捷方式:

>>> import numpy as np
>>> a = np.array([0,1,2,3,4,5])
>>> a
array([0, 1, 2, 3, 4, 5])
>>> a.ndim
1
>>> a.shape
(6,)

所以,我们刚刚创建了一个数组,就像我们在 Python 中创建一个列表一样。然而,NumPy 数组有额外的形状信息。在这个例子中,它是一个包含六个元素的一维数组,到目前为止没什么意外。

我们现在可以将这个数组转换为一个二维矩阵:

>>> b = a.reshape((3,2))
>>> b
array([[0, 1],
 [2, 3],
 [4, 5]])
>>> b.ndim
2
>>> b.shape
(3, 2)

有趣的事情发生在我们意识到 NumPy 包的优化程度时。例如,执行这一步可以尽可能避免复制:

>>> b[1][0] = 77
>>> b
array([[ 0,  1],
 [77,  3],
 [ 4,  5]])
>>> a
array([ 0,  1, 77,  3,  4,  5])

在这个例子中,我们将 b 中的值 2 修改为 77,并立即看到 a 中也反映了相同的变化。请记住,任何时候你需要一个真正的副本时,可以随时执行:

>>> c = a.reshape((3,2)).copy()
>>> c
array([[ 0,  1],
 [77,  3],
 [ 4,  5]])
>>> c[0][0] = -99
>>> a
array([ 0,  1, 77,  3,  4,  5])
>>> c
array([[-99,   1],
 [ 77,   3],
 [  4,   5]])

请注意,ca 是完全独立的副本。

NumPy 数组的另一个大优点是操作会传播到各个元素。例如,乘以一个 NumPy 数组将生成一个与原数组大小相同的新数组,所有元素都被相乘:

>>> d = np.array([1,2,3,4,5])
>>> d*2
array([ 2,  4,  6,  8, 10])

类似地,对于其他操作:

>>> d**2
array([ 1,  4,  9, 16, 25])

将其与普通的 Python 列表进行对比:

>>> [1,2,3,4,5]*2
[1, 2, 3, 4, 5, 1, 2, 3, 4, 5]
>>> [1,2,3,4,5]**2
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for ** or pow(): 'list' and 'int'

当然,使用 NumPy 数组时,我们牺牲了 Python 列表所提供的灵活性。像添加或移除这样的简单操作对于 NumPy 数组来说稍显复杂。幸运的是,我们手头有两者,并且可以根据实际任务使用合适的工具。

索引

NumPy 的一大优势来自于其数组的多种访问方式。

除了常规的列表索引外,它还允许你使用数组本身作为索引,方法是执行:

>>> a[np.array([2,3,4])]
array([77,  3,  4])

结合条件也会传播到各个元素这一事实,我们获得了一种非常方便的方式来访问数据:

>>> a>4
array([False, False,  True, False, False,  True], dtype=bool)
>>> a[a>4]
array([77,  5])

通过执行以下命令,可以用来修剪异常值:

>>> a[a>4] = 4
>>> a
array([0, 1, 4, 3, 4, 4])

由于这是一个常见的用例,因此有一个专门的裁剪函数来处理它,可以通过一次函数调用将值限制在区间的两端:

>>> a.clip(0,4)
array([0, 1, 4, 3, 4, 4])

处理不存在的值

NumPy 强大的索引功能在处理我们刚从文本文件中读取的数据时非常有用。通常,这些数据会包含无效值,我们可以使用 numpy.NAN 来标记它们为非真实数字:

>>> c = np.array([1, 2, np.NAN, 3, 4]) # let's pretend we have read this from a text file
>>> c
array([  1.,   2.,  nan,   3.,   4.])
>>> np.isnan(c)
array([False, False,  True, False, False], dtype=bool)
>>> c[~np.isnan(c)]
array([ 1.,  2.,  3.,  4.])
>>> np.mean(c[~np.isnan(c)])
2.5

比较运行时间

让我们比较一下 NumPy 和普通 Python 列表的运行时行为。在以下代码中,我们将计算从 1 到 1000 的所有平方数之和,并查看需要多少时间。我们执行 10,000 次,并报告总时间,以确保我们的测量足够准确。

import timeit
normal_py_sec = timeit.timeit('sum(x*x for x in range(1000))',
 number=10000)
naive_np_sec = timeit.timeit(
 'sum(na*na)',
 setup="import numpy as np; na=np.arange(1000)",
 number=10000)
good_np_sec = timeit.timeit(
 'na.dot(na)',
 setup="import numpy as np; na=np.arange(1000)",
 number=10000)

print("Normal Python: %f sec" % normal_py_sec)
print("Naive NumPy: %f sec" % naive_np_sec)
print("Good NumPy: %f sec" % good_np_sec)

Normal Python: 1.050749 sec
Naive NumPy: 3.962259 sec
Good NumPy: 0.040481 sec

我们做出了两个有趣的观察。首先,仅仅将 NumPy 作为数据存储(朴素的 NumPy)就花费了 3.5 倍的时间,这令人惊讶,因为我们原以为它应该更快,因为它是作为 C 扩展写的。一个原因是从 Python 本身访问单个元素是非常昂贵的。只有当我们能够在优化过的扩展代码中应用算法时,才会获得速度的提升。另一个观察是相当令人震惊的:使用 NumPy 的dot()函数,尽管它做的是完全相同的事情,却让我们的速度提高了 25 倍以上。总之,在我们即将实现的每一个算法中,我们都应该始终检查如何将 Python 中的单个元素循环转移到一些高度优化的 NumPy 或 SciPy 扩展函数中。

然而,这种速度是有代价的。使用 NumPy 数组时,我们不再拥有 Python 列表那种几乎可以存储任何东西的极大灵活性。NumPy 数组始终只有一种数据类型。

>>> a = np.array([1,2,3])
>>> a.dtype
dtype('int64')

如果我们尝试使用不同类型的元素,如以下代码所示,NumPy 会尽力将它们转换为最合理的公共数据类型:

>>> np.array([1, "stringy"])
array(['1', 'stringy'], dtype='<U7')
>>> np.array([1, "stringy", set([1,2,3])])
array([1, stringy, {1, 2, 3}], dtype=object)

学习 SciPy

在 NumPy 高效数据结构的基础上,SciPy 提供了大量针对这些数组工作的算法。无论你从当前的数值计算书籍中挑选出哪种数值密集型算法,你很可能会以某种方式在 SciPy 中找到它的支持。无论是矩阵操作、线性代数、优化、聚类、空间操作,甚至是快速傅里叶变换,工具箱已经很充实。因此,在开始实现一个数值算法之前,养成检查scipy模块的好习惯。

为了方便,NumPy 的完整命名空间也可以通过 SciPy 访问。所以,从现在开始,我们将通过 SciPy 命名空间使用 NumPy 的工具。你可以通过比较任何基本函数的函数引用轻松检查这一点,例如:

>>> import scipy, numpy
>>> scipy.version.full_version
0.14.0
>>> scipy.dot is numpy.dot
True

这些多样化的算法被分组到以下工具箱中:

SciPy 包 功能
cluster
  • 层次聚类(cluster.hierarchy

  • 向量量化 / k-means (cluster.vq)

|

constants
  • 物理和数学常数

  • 转换方法

|

fftpack 离散傅里叶变换算法
integrate 积分例程
interpolate 插值(线性插值、三次插值等)
io 数据输入和输出
linalg 使用优化过的 BLAS 和 LAPACK 库的线性代数例程
ndimage n维图像包
odr 正交距离回归
optimize 优化(寻找最小值和根)
signal 信号处理
sparse 稀疏矩阵
spatial 空间数据结构和算法
special 特殊数学函数,如贝塞尔函数或雅可比函数
stats 统计工具包

对我们工作最感兴趣的工具包是scipy.statsscipy.interpolatescipy.clusterscipy.signal。为了简洁起见,我们将简要探索一下 stats 包的一些功能,其余的将在各个章节中介绍。

我们的第一个(微小的)机器学习应用

让我们动手操作,看看我们的假设性网络初创公司 MLaaS,该公司通过 HTTP 提供机器学习算法服务。随着公司成功的不断增加,需求也在增长,需要更好的基础设施来成功地处理所有的网络请求。我们不希望分配过多资源,因为那样成本过高。另一方面,如果我们没有预留足够的资源来处理所有的请求,我们将会亏损。那么,问题来了,我们什么时候会达到当前基础设施的限制,我们预计这个限制是每小时 100,000 个请求。我们希望提前知道何时需要在云端申请更多的服务器,以便在不为未使用的资源付费的情况下,成功地处理所有传入请求。

读取数据

我们已经收集了过去一个月的网络统计数据,并将其汇总在ch01/data/web_traffic.tsv中(.tsv因为它包含制表符分隔的值)。它们按每小时的点击次数存储。每行包含连续的小时和该小时的网页点击次数。

前几行如下所示:

读取数据

使用 SciPy 的genfromtxt(),我们可以轻松地读取数据,代码如下:

>>> import scipy as sp
>>> data = sp.genfromtxt("web_traffic.tsv", delimiter="\t")

我们必须指定制表符作为分隔符,以便正确地确定各列。

快速检查显示我们已经正确读取了数据:

>>> print(data[:10])
[[  1.00000000e+00   2.27200000e+03]
 [  2.00000000e+00              nan]
 [  3.00000000e+00   1.38600000e+03]
 [  4.00000000e+00   1.36500000e+03]
 [  5.00000000e+00   1.48800000e+03]
 [  6.00000000e+00   1.33700000e+03]
 [  7.00000000e+00   1.88300000e+03]
 [  8.00000000e+00   2.28300000e+03]
 [  9.00000000e+00   1.33500000e+03]
 [  1.00000000e+01   1.02500000e+03]]
>>> print(data.shape)
(743, 2)

如你所见,我们有 743 个数据点,包含两个维度。

数据预处理和清理

对 SciPy 来说,将维度分成两个大小为 743 的向量更加方便。第一个向量x包含小时,另一个向量y包含该小时的网页点击数。这个拆分是通过 SciPy 的特殊索引表示法完成的,利用该方法我们可以单独选择列:

x = data[:,0]
y = data[:,1]

从 SciPy 数组中选择数据有很多方法。有关索引、切片和迭代的更多细节,请查看www.scipy.org/Tentative_NumPy_Tutorial

一个警告是,我们的y中仍然包含一些无效值,nan。问题是我们该如何处理这些值。让我们通过运行以下代码来检查有多少小时包含无效数据:

>>> sp.sum(sp.isnan(y))
8

如你所见,我们只有 743 个数据项中的 8 个缺失,因此我们可以去掉它们。记住,我们可以用另一个数组来索引一个 SciPy 数组。Sp.isnan(y)返回一个布尔数组,指示某个数据项是否为数字。通过使用~,我们可以逻辑取反这个数组,从而只选择那些y中包含有效数字的xy元素:

>>> x = x[~sp.isnan(y)]
>>> y = y[~sp.isnan(y)]

为了获得数据的初步印象,我们使用 matplotlib 绘制数据的散点图。matplotlib 包含了 pyplot 包,它试图模仿 MATLAB 的界面,正如你在以下代码中看到的,这是一种非常方便且易于使用的接口:

>>> import matplotlib.pyplot as plt
>>> # plot the (x,y) points with dots of size 10
>>> plt.scatter(x, y, s=10)
>>> plt.title("Web traffic over the last month")
>>> plt.xlabel("Time")
>>> plt.ylabel("Hits/hour")
>>> plt.xticks([w*7*24 for w in range(10)],
 ['week %i' % w for w in range(10)])
>>> plt.autoscale(tight=True)
>>> # draw a slightly opaque, dashed grid
>>> plt.grid(True, linestyle='-', color='0.75')
>>> plt.show()

注意

你可以在matplotlib.org/users/pyplot_tutorial.html找到更多关于绘图的教程。

在结果图表中,我们可以看到,在前几周流量大致保持不变,而最后一周显示了急剧增加:

预处理和清洗数据

选择合适的模型和学习算法

现在我们对数据有了初步印象,我们回到最初的问题:我们的服务器能够处理多少的 Web 流量?为了回答这个问题,我们需要做以下几点:

  1. 找出噪声数据点背后的真实模型。

  2. 接下来,使用该模型推测未来,找出我们需要扩展基础设施的时间点。

在构建我们的第一个模型之前……

当我们谈论模型时,你可以将它们视为复杂现实的简化理论近似。作为这样的一种模型,总是涉及到某种程度的不足,也叫做近似误差。这个误差将引导我们在众多选择中选出合适的模型。这个误差将通过计算模型预测与真实数据之间的平方距离来计算;例如,对于一个学习过的模型函数f,误差的计算如下:

def error(f, x, y):
 return sp.sum((f(x)-y)**2)

向量xy包含了我们之前提取的 Web 统计数据。这正是我们在这里利用 SciPy 的矢量化函数f(x)的美妙之处。假设训练过的模型接受一个向量并返回相同大小的结果向量,这样我们就可以用它来计算与y的差异。

从一条简单的直线开始

假设我们假设潜在的模型是一条直线。那么,挑战在于如何将这条直线最佳地放入图表中,以使得近似误差最小。SciPy 的polyfit()函数正是用来做这个的。给定数据xy以及所需的多项式阶数(直线是阶数为 1),它会找到一个模型函数,最小化先前定义的误差函数:

fp1, residuals, rank, sv, rcond = sp.polyfit(x, y, 1, full=True)

polyfit()函数返回拟合模型函数的参数fp1。通过设置full=True,我们还可以获得拟合过程的额外背景信息。其中,只有残差是我们关心的,它正是近似的误差:

>>> print("Model parameters: %s" % fp1)
Model parameters: [   2.59619213  989.02487106]
>>> print(residuals)
[  3.17389767e+08]

这意味着最好的直线拟合是以下函数:

f(x) = 2.59619213 * x + 989.02487106.

然后我们使用poly1d()从模型参数创建一个模型函数:

>>> f1 = sp.poly1d(fp1)
>>> print(error(f1, x, y))
317389767.34

我们使用了full=True来获取更多的拟合过程细节。通常我们不需要这样做,在这种情况下,只会返回模型参数。

现在我们可以使用f1()来绘制我们训练的第一个模型。除了前面绘图的指令外,我们只需添加以下代码:

fx = sp.linspace(0,x[-1], 1000) # generate X-values for plotting
plt.plot(fx, f1(fx), linewidth=4)
plt.legend(["d=%i" % f1.order], loc="upper left")

这将产生以下图表:

从简单的直线开始

看起来前 4 周的预测误差并不大,尽管我们明显看到最初假设潜在模型是直线的假设存在问题。那么,317,389,767.34 的误差到底有多大呢?

误差的绝对值通常单独使用意义不大。然而,在比较两个竞争模型时,我们可以使用它们的误差来判断哪个模型更好。尽管我们的第一个模型显然不是我们会使用的,但它在工作流程中具有非常重要的作用。在我们找到一个更好的模型之前,它将作为我们的基准。未来我们提出的任何新模型,都将与当前的基准模型进行比较。

向更高级的内容迈进

现在让我们拟合一个更复杂的模型,一个 2 次方的多项式,看看它是否能更好地理解我们的数据:

>>> f2p = sp.polyfit(x, y, 2)
>>> print(f2p)
array([  1.05322215e-02,  -5.26545650e+00,   1.97476082e+03])
>>> f2 = sp.poly1d(f2p)
>>> print(error(f2, x, y))
179983507.878

你将得到以下图表:

向更高级的内容迈进

误差为 179,983,507.878,几乎是直线模型误差的一半。这是好的,但不幸的是,这也有一个代价:我们现在拥有了一个更复杂的函数,这意味着我们在polyfit()中需要调整更多的参数。拟合的多项式如下:

f(x) = 0.0105322215 * x**2  - 5.26545650 * x + 1974.76082

所以,如果增加复杂度能带来更好的结果,为什么不进一步增加复杂度呢?让我们尝试 3 次方、10 次方和 100 次方的情况。

向更高级的内容迈进

有趣的是,我们没有在拟合了 100 次方的多项式中看到d=53。相反,我们在控制台上看到了大量的警告:

RankWarning: Polyfit may be poorly conditioned

这意味着由于数值误差,polyfit 无法以 100 次方确定一个好的拟合。相反,它认为 53 次方已经足够好了。

看起来曲线捕捉并改进拟合数据的能力随着其复杂度的增加而增强。错误也似乎讲述了同样的故事:

Error d=1: 317,389,767.339778
Error d=2: 179,983,507.878179
Error d=3: 139,350,144.031725
Error d=10: 121,942,326.363461
Error d=53: 109,318,004.475556

然而,仔细观察拟合曲线后,我们开始怀疑它们是否也捕捉到了生成这些数据的真实过程。换句话说,我们的模型是否正确地表示了客户访问我们网站时的潜在行为?查看 10 次方和 53 次方的多项式,我们看到的行为是剧烈波动的。似乎模型过度拟合了数据,甚至不仅捕捉到了潜在的过程,还包括了噪声。这种现象称为过拟合

在这一点上,我们有以下选择:

  • 选择拟合的多项式模型之一。

  • 切换到另一种更复杂的模型类别。样条曲线?

  • 以不同的角度重新思考数据并重新开始。

在五个拟合模型中,一阶模型显然过于简单,而 10 阶和 53 阶的模型显然是过拟合的。只有二阶和三阶模型似乎在某种程度上与数据匹配。然而,如果我们在两个边界进行外推,就会看到它们变得异常。

切换到更复杂的类别似乎也不是正确的选择。有哪些理由支持选择哪种类别?此时,我们意识到我们可能还没有完全理解我们的数据。

回退再前进——重新审视我们的数据

因此,我们回退并再次审视数据。看起来在第 3 周和第 4 周之间存在一个拐点。那么让我们分离数据并使用第 3.5 周作为分割点来训练两条线:

inflection = 3.5*7*24 # calculate the inflection point in hours
xa = x[:inflection] # data before the inflection point
ya = y[:inflection]
xb = x[inflection:] # data after
yb = y[inflection:]

fa = sp.poly1d(sp.polyfit(xa, ya, 1))
fb = sp.poly1d(sp.polyfit(xb, yb, 1))

fa_error = error(fa, xa, ya)
fb_error = error(fb, xb, yb)
print("Error inflection=%f" % (fa_error + fb_error))
Error inflection=132950348.197616

从第一条线开始,我们使用第 3 周的数据进行训练,而在第二条线中我们使用剩余的数据进行训练。

回退再前进——重新审视我们的数据

显然,这两条线的组合比我们之前所拟合的任何模型更能符合数据。但即便如此,组合误差仍然高于高阶多项式。我们能信任最终的误差吗?

换个角度问,为什么我们更相信只在数据最后一周拟合的直线,而不是任何更复杂的模型?这是因为我们假设它能更好地捕捉未来的数据。如果我们将模型预测到未来,我们可以看到我们是否正确(d=1再次是我们最初的直线)。

回退再前进——重新审视我们的数据

10 阶和 53 阶的模型似乎并不看好我们创业公司的未来。它们为了正确拟合给定的数据付出了极大的努力,结果显然无法用于外推。这就是所谓的过拟合。另一方面,低阶模型似乎无法充分捕捉数据的特征。这就是所谓的欠拟合

所以让我们对二阶及以上的模型保持公正,尝试仅将它们拟合到最后一周的数据。毕竟,我们认为最后一周比之前的数据更能反映未来。结果可以在下面这张充满迷幻色彩的图表中看到,它进一步展示了过拟合问题有多严重。

回退再前进——重新审视我们的数据

然而,从仅使用第 3.5 周及之后的数据进行训练时模型的误差来看,我们仍然应该选择最复杂的模型(注意,我们也只计算了拐点之后的误差):

Error d=1:   22,143,941.107618
Error d=2:   19,768,846.989176
Error d=3:   19,766,452.361027
Error d=10:  18,949,339.348539
Error d=53:  18,300,702.038119

训练与测试

如果我们有一些来自未来的数据可以用来评估我们的模型,那么我们应该仅根据由此产生的逼近误差来判断我们的模型选择。

虽然我们无法预测未来,但我们可以并且应该通过保留部分数据来模拟类似的效果。比如,去除一定比例的数据,并在剩余数据上进行训练。然后,我们使用保留的数据计算误差。由于模型在训练时未看到这些保留的数据,因此我们应该能够更真实地了解模型在未来的表现。

仅在拐点后时间段内训练的模型的测试误差现在显示出完全不同的图景:

Error d=1: 6397694.386394
Error d=2: 6010775.401243
Error d=3: 6047678.658525
Error d=10: 7037551.009519
Error d=53: 7052400.001761

请看以下图表:

训练与测试

看起来我们终于有了明确的结果:二次函数模型具有最低的测试误差,这是指使用模型在训练过程中未见过的数据进行测量时的误差。这给了我们希望,未来的数据到来时我们不会遇到不好的惊讶。

回答我们最初的问题

最终,我们得出了一个我们认为最能代表底层过程的模型;现在只需要简单地计算出我们的基础设施何时将达到每小时 100,000 次请求。我们需要计算何时我们的模型函数值会达到 100,000。

既然我们有一个二次多项式,我们可以简单地计算函数的反函数,并在 100,000 时计算它的值。当然,我们希望有一种适用于任何模型函数的方法。

这可以通过从多项式中减去 100,000 来实现,结果得到另一个多项式,并找到它的根。SciPy 的optimize模块有一个fsolve函数,可以通过提供初始起始位置参数x0来实现这一目标。由于我们输入数据文件中的每个条目对应一个小时,总共有 743 个小时,因此我们将起始位置设置为该时间段之后的某个值。让fbt2成为二次多项式模型。

>>> fbt2 = sp.poly1d(sp.polyfit(xb[train], yb[train], 2))
>>> print("fbt2(x)= \n%s" % fbt2)
fbt2(x)=
 2
0.086 x - 94.02 x + 2.744e+04
>>> print("fbt2(x)-100,000= \n%s" % (fbt2-100000))
fbt2(x)-100,000=
 2
0.086 x - 94.02 x - 7.256e+04
>>> from scipy.optimize import fsolve
>>> reached_max = fsolve(fbt2-100000, x0=800)/(7*24)
>>> print("100,000 hits/hour expected at week %f" % reached_max[0])

预计在第 9.616071 周时,每小时将达到 100,000 次点击。因此,我们的模型告诉我们,鉴于当前的用户行为和我们初创公司的发展势头,再过一个月我们将达到容量阈值。

当然,我们的预测存在一定的不确定性。为了获得更真实的预测结果,可以引入更复杂的统计方法,以找出我们在未来的预测中需要预期的方差。

然后,还有用户和底层用户行为的动态,这是我们无法准确建模的。然而,在这一点上,我们对当前的预测是满意的。毕竟,我们现在可以准备所有耗时的操作。如果我们紧密监控网站流量,我们将及时看到何时需要分配新资源。

总结

恭喜你!你刚刚学到了两个重要的东西,其中最重要的一点是,作为一个典型的机器学习操作员,你将花费大部分时间来理解和优化数据——正是我们在第一个小型机器学习示例中所做的。我们希望这个示例能帮助你开始将注意力从算法转向数据。接着,你学到了正确的实验设置有多么重要,且避免混淆训练和测试数据是至关重要的。

诚然,使用多项式拟合在机器学习领域并不是最炫酷的事情。我们选择它是为了在传达我们之前总结的两个最重要的信息时,不让你被某些闪亮算法的酷炫分散注意力。

接下来,让我们进入下一章,在其中我们将深入探讨 scikit-learn 这一神奇的机器学习工具包,概述不同类型的学习,并展示特征工程的美妙。

第二章:使用现实世界示例进行分类

本章的主题是分类。即使你没有意识到,你可能已经作为消费者使用过这种形式的机器学习。如果你有任何现代的电子邮件系统,它可能具有自动检测垃圾邮件的能力。也就是说,系统将分析所有传入的电子邮件,并将其标记为垃圾邮件或非垃圾邮件。通常,你作为最终用户,可以手动标记电子邮件为垃圾邮件或非垃圾邮件,以提高系统的垃圾邮件检测能力。这是一种机器学习形式,系统通过分析两种类型的消息示例:垃圾邮件和正常邮件(“非垃圾邮件”邮件的典型术语),并使用这些示例自动分类传入的电子邮件。

分类的一般方法是使用每个类别的一组示例来学习可以应用于新示例的规则。这是机器学习中最重要的模式之一,也是本章的主题。

处理如电子邮件这样的文本需要一套特定的技术和技能,我们将在下一章讨论这些内容。目前,我们将使用一个较小、易于处理的数据集。本章的示例问题是,“机器能否根据图像区分花卉物种?”我们将使用两个数据集,其中记录了花卉形态学的测量值以及几个样本的物种信息。

我们将使用一些简单的算法来探索这些小数据集。最初,我们将自己编写分类代码,以便理解概念,但我们会在有可能的情况下迅速切换到使用 scikit-learn。目标是首先理解分类的基本原理,然后进步到使用最先进的实现。

鸢尾花数据集

鸢尾花数据集是一个经典的 1930 年代数据集;它是统计分类的第一个现代示例之一。

数据集是几种鸢尾花形态学测量的集合。这些测量将使我们能够区分花卉的多个物种。如今,物种是通过 DNA 指纹来识别的,但在 20 世纪 30 年代,DNA 在遗传学中的作用尚未被发现。

以下是每个植物的四个测量属性:

  • 花萼长度

  • 花萼宽度

  • 花瓣长度

  • 花瓣宽度

通常,我们将用来描述数据的单个数值测量称为特征。这些特征可以直接测量或从中间数据计算得到。

这个数据集有四个特征。此外,每个植物的物种也被记录。我们想要解决的问题是,“给定这些示例,如果我们在田野中看到一朵新花,我们能从它的测量数据中准确预测它的物种吗?”

这是监督学习分类问题:给定标记的样本,我们能否设计一个规则,之后可以应用于其他样本?一个现代读者更为熟悉的例子是垃圾邮件过滤,用户可以将电子邮件标记为垃圾邮件,系统则利用这些标记以及非垃圾邮件来判断一封新收到的邮件是否是垃圾邮件。

在本书后续章节中,我们将研究与文本相关的问题(从下一章开始)。目前,鸢尾花数据集很好地服务了我们的目的。它很小(150 个样本,每个样本四个特征),且可以轻松可视化和操作。

可视化是一个很好的第一步

数据集在本书后续章节中将扩展到成千上万的特征。在我们从一个包含四个特征的简单示例开始时,我们可以轻松地在单一页面上绘制所有二维投影。我们将在这个小示例上建立直觉,之后可以将其扩展到包含更多特征的大型数据集。正如我们在上一章中所见,数据可视化在分析的初期探索阶段非常有用,它能帮助我们了解问题的总体特征,并及早发现数据收集过程中出现的问题。

下图中的每个子图展示了所有点投影到两个维度中的情况。外部群体(三角形)是鸢尾花 Setosa,而鸢尾花 Versicolor 位于中心(圆形),鸢尾花 Virginica 则用x标记。我们可以看到,这里有两个大群体:一个是鸢尾花 Setosa,另一个是鸢尾花 Versicolor 和鸢尾花 Virginica 的混合群体。

可视化是一个很好的第一步

在下面的代码片段中,我们展示了加载数据并生成图表的代码:

>>> from matplotlib import pyplot as plt
>>> import numpy as np

>>> # We load the data with load_iris from sklearn
>>> from sklearn.datasets import load_iris
>>> data = load_iris()

>>> # load_iris returns an object with several fields
>>> features = data.data
>>> feature_names = data.feature_names
>>> target = data.target
>>> target_names = data.target_names

>>> for t in range(3):
...    if t == 0:
...        c = 'r'
...        marker = '>'
...    elif t == 1:
...        c = 'g'
...        marker = 'o'
...    elif t == 2:
...        c = 'b'
...        marker = 'x'
...    plt.scatter(features[target == t,0],
...                features[target == t,1],
...                marker=marker,
...                c=c)

构建我们的第一个分类模型

如果目标是将三种花卉分开,我们仅通过查看数据就可以立即做出一些建议。例如,花瓣长度似乎可以单独将鸢尾花 Setosa 与其他两种花卉区分开。我们可以编写一些代码来发现分割点的位置:

>>> # We use NumPy fancy indexing to get an array of strings:
>>> labels = target_names[target]

>>> # The petal length is the feature at position 2
>>> plength = features[:, 2]

>>> # Build an array of booleans:
>>> is_setosa = (labels == 'setosa')

>>> # This is the important step:
>>> max_setosa = plength[is_setosa].max()
>>> min_non_setosa = plength[~is_setosa].min()
>>> print('Maximum of setosa: {0}.'.format(max_setosa))
Maximum of setosa: 1.9.

>>> print('Minimum of others: {0}.'.format(min_non_setosa))
Minimum of others: 3.0.

因此,我们可以构建一个简单的模型:如果花瓣长度小于 2,那么这是一朵鸢尾花 Setosa;否则它要么是鸢尾花 Virginica,要么是鸢尾花 Versicolor。这是我们的第一个模型,它表现得非常好,因为它能够在没有任何错误的情况下将鸢尾花 Setosa 从其他两种花卉中分开。在这种情况下,我们实际上并没有进行机器学习,而是自己查看了数据,寻找类别之间的分离。机器学习发生在我们编写代码自动寻找这种分离的时刻。

区分 Iris Setosa 和其他两个物种的问题非常简单。然而,我们不能立即看到区分 Iris Virginica 和 Iris Versicolor 的最佳阈值是什么。我们甚至可以看到,使用这些特征我们永远无法实现完美的分割。然而,我们可以寻找最好的可能分割,即犯错最少的分割。为此,我们将进行一些计算。

我们首先仅选择非 Setosa 的特征和标签:

>>> # ~ is the boolean negation operator
>>> features = features[~is_setosa]
>>> labels = labels[~is_setosa]
>>> # Build a new target variable, is_virginica
>>> is_virginica = (labels == 'virginica')

在这里,我们大量使用了 NumPy 对数组的操作。is_setosa 数组是一个布尔数组,我们用它来选择其他两个数组 featureslabels 的一个子集。最后,我们通过对标签进行相等比较,构建了一个新的布尔数组 virginica

现在,我们遍历所有可能的特征和阈值,看看哪个能带来更好的准确率。准确率简单地是模型正确分类的示例的比例。

>>> # Initialize best_acc to impossibly low value
>>> best_acc = -1.0
>>> for fi in range(features.shape[1]):
...  # We are going to test all possible thresholds
...  thresh = features[:,fi]
...  for t in thresh:
...    # Get the vector for feature `fi`
...    feature_i = features[:, fi]
...    # apply threshold `t`
...    pred = (feature_i > t)
...    acc = (pred == is_virginica).mean()
...    rev_acc = (pred == ~is_virginica).mean()
...    if rev_acc > acc:
...        reverse = True
...        acc = rev_acc
...    else:
...        reverse = False
...
...    if acc > best_acc:
...      best_acc = acc
...      best_fi = fi
...      best_t = t
...      best_reverse = reverse

我们需要测试每个特征和每个值的两种类型的阈值:我们测试一个大于阈值和反向比较。这就是为什么我们在前面的代码中需要 rev_acc 变量;它保存了反向比较的准确率。

最后几行选择最佳模型。首先,我们将预测值 pred 与实际标签 is_virginica 进行比较。通过计算比较的均值的小技巧,我们可以得到正确结果的比例,即准确率。在 for 循环的末尾,所有可能的特征的所有可能阈值都已被测试,变量 best_fibest_tbest_reverse 保存了我们的模型。这就是我们所需的所有信息,能够对一个新的、未知的对象进行分类,也就是说,给它分配一个类别。以下代码正是实现了这个方法:

def is_virginica_test(fi, t, reverse, example):
 "Apply threshold model to a new example"
 test = example[fi] > t
 if reverse:
 test = not test
 return test

这个模型是什么样子的?如果我们在整个数据上运行代码,识别为最佳的模型通过在花瓣宽度上进行分割来做出决策。理解这一过程的一种方式是可视化决策边界。也就是说,我们可以看到哪些特征值会导致一个决策与另一个决策的区别,并准确地看到边界在哪里。在以下截图中,我们看到两个区域:一个是白色的,另一个是灰色阴影的。任何落在白色区域的 datapoint 将被分类为 Iris Virginica,而任何落在阴影区域的点将被分类为 Iris Versicolor。

构建我们的第一个分类模型

在阈值模型中,决策边界将始终是与其中一个轴平行的直线。前面截图中的图表显示了决策边界和两个区域,其中的点被分类为白色或灰色。它还显示了(作为虚线)一个替代阈值,这个阈值将获得完全相同的准确率。我们的方法选择了它看到的第一个阈值,但这是一个任意选择。

评估 – 数据持出和交叉验证

前一节中讨论的模型是一个简单的模型,它在整个数据集上的准确率达到了 94%。然而,这种评估可能过于乐观。我们使用数据来定义阈值,然后用相同的数据来评估模型。当然,模型在这个数据集上会表现得比我们尝试过的其他任何方法都要好。这种推理是循环的。

我们真正想做的是估计模型对新实例的泛化能力。我们应该衡量算法在训练时没有见过的实例上的表现。因此,我们将进行更严格的评估并使用保留数据。为此,我们将把数据分成两组:一组用来训练模型,另一组用来测试我们从训练中保留的数据。完整的代码是对之前展示的代码的改编,可以在在线支持仓库中找到。其输出如下:

Training accuracy was 96.0%.
Testing accuracy was 90.0% (N = 50).

在训练数据上的结果(训练数据是整个数据的一个子集)显然比之前更好。然而,值得注意的是,测试数据上的结果低于训练误差。虽然这可能会让没有经验的机器学习者感到惊讶,但测试精度低于训练精度是可以预期的。要理解为什么,请回顾一下显示决策边界的图表。想象一下,如果有些接近边界的例子不存在,或者两条线之间的某个例子缺失,会发生什么情况。很容易想象,边界会稍微向右或向左移动,从而将它们放置在边界的错误一侧。

提示

在训练数据上的准确度,即训练准确度,几乎总是过于乐观地估计了算法的表现。我们应该始终测量并报告测试准确度,即在没有用于训练的例子上计算的准确度。

随着模型变得越来越复杂,这些概念将变得越来越重要。在这个例子中,训练数据和测试数据上测量的准确度差异并不大。而使用复杂模型时,可能在训练时达到 100%的准确度,却在测试时表现不比随机猜测好!

我们之前做的一个可能存在的问题是,保留部分数据用于测试,这意味着我们只使用了一半的数据进行训练。也许使用更多的训练数据会更好。另一方面,如果我们留下的数据用于测试太少,错误估计就会基于非常少量的例子来进行。理想情况下,我们希望将所有数据用于训练,并将所有数据用于测试,但这是不可能的。

我们可以通过一种叫做交叉验证的方法,较好地接近这一不可能的理想。交叉验证的一种简单形式是留一交叉验证。我们将从训练数据中取出一个样本,学习一个不包含该样本的模型,然后测试该模型是否能正确分类该样本。这个过程会对数据集中的所有元素重复进行。

以下代码正是实现这种类型的交叉验证:

>>> correct = 0.0
>>> for ei in range(len(features)):
 # select all but the one at position `ei`:
 training = np.ones(len(features), bool)
 training[ei] = False
 testing = ~training
 model = fit_model(features[training], is_virginica[training])
 predictions = predict(model, features[testing])
 correct += np.sum(predictions == is_virginica[testing])
>>> acc = correct/float(len(features))
>>> print('Accuracy: {0:.1%}'.format(acc))
Accuracy: 87.0%

在这个循环结束时,我们将会在所有样本上测试一系列模型,并获得最终的平均结果。在使用交叉验证时,不会出现循环问题,因为每个样本都在没有考虑该数据点的模型上进行测试。因此,交叉验证估计是一个可靠的估计,可以反映模型在新数据上的泛化能力。

留一交叉验证的主要问题在于,我们现在不得不进行更多的工作。事实上,你必须为每个样本学习一个全新的模型,随着数据集的增大,这个成本也会增加。

我们可以通过使用 x 折交叉验证,在成本较低的情况下获得大部分的留一交叉验证的好处,其中x代表一个较小的数字。例如,为了执行五折交叉验证,我们将数据分成五组,也就是所谓的五折。

然后你会学习五个模型:每次你都会将其中一个折叠从训练数据中剔除。结果代码将与本节前面给出的代码相似,但我们将把数据中的 20%剔除,而不是仅仅剔除一个元素。我们会在剔除的折叠上测试这些模型,并计算结果的平均值。

评估 – 剔除数据与交叉验证

上图展示了这一过程,使用了五个折叠:数据集被分成五个部分。对于每一个折叠,你会保留其中一个块进行测试,其余四个块用于训练。你可以使用任何数量的折叠。折叠数与计算效率(折叠数越多,需要的计算越多)和结果准确性(折叠数越多,训练数据越接近于整个数据集)之间存在权衡。五个折叠通常是一个不错的折中方案。这意味着你用 80%的数据进行训练,这已经接近于使用全部数据的效果。如果你的数据很少,甚至可以考虑使用 10 折或 20 折。在极端情况下,如果折叠数等于数据点数,你就只是在执行留一交叉验证。另一方面,如果计算时间是个问题,并且你有更多的数据,2 折或 3 折可能是更合适的选择。

在生成折叠时,你需要小心保持它们的平衡。例如,如果一个折叠中的所有样本都来自同一类别,那么结果将不具代表性。我们不会详细讲解如何做到这一点,因为机器学习库 scikit-learn 会为你处理这些问题。

现在,我们生成了多个模型,而不仅仅是一个。那么,“我们应该返回哪个最终模型来处理新数据?”最简单的解决方案是,在所有训练数据上训练一个整体的单一模型。交叉验证循环给出了这个模型应该如何推广的估计。

提示

交叉验证安排允许你使用所有数据来估算你的方法是否有效。在交叉验证循环结束时,你可以使用所有数据来训练最终模型。

尽管在机器学习作为一个领域刚起步时,这一点并没有被充分认识到,但如今,讨论分类系统的训练准确率被视为一种非常糟糕的迹象。因为结果可能会非常具有误导性,甚至仅仅展示这些结果就会让你看起来像是机器学习的新手。我们总是希望衡量并比较保留数据集上的误差或使用交叉验证方案估算的误差。

构建更复杂的分类器

在上一节中,我们使用了一个非常简单的模型:对单一特征进行阈值判断。是否还有其他类型的系统?当然有!很多其他类型。在本书中,你将看到许多其他类型的模型,我们甚至不会涵盖所有现有的模型。

从更高的抽象层次来思考这个问题,“一个分类模型由什么组成?”我们可以将其分为三部分:

  • 模型的结构:模型究竟是如何做出决策的?在这种情况下,决策完全依赖于某个特征是否高于或低于某个阈值。除了最简单的问题,这种方法过于简化。

  • 搜索过程:我们如何找到需要使用的模型?在我们的案例中,我们尝试了每一种特征和阈值的可能组合。你可以很容易地想象,随着模型变得更加复杂,数据集变得更大,尝试所有组合变得几乎不可能,我们不得不使用近似解决方案。在其他情况下,我们需要使用先进的优化方法来找到一个好的解决方案(幸运的是,scikit-learn 已经为你实现了这些方法,所以即使它们背后的代码非常先进,使用起来也很简单)。

  • 增益或损失函数:我们如何决定应该返回哪些测试过的可能性?我们很少能找到完美的解决方案,即永远不会出错的模型,因此我们需要决定使用哪一个。我们使用了准确率,但有时更好的做法是优化,使得模型在特定类型的错误上减少。比如在垃圾邮件过滤中,删除一封好邮件可能比错误地让一封坏邮件通过更糟糕。在这种情况下,我们可能希望选择一个在丢弃邮件时较为保守的模型,而不是那个只做最少错误的模型。我们可以通过增益(我们希望最大化)或损失(我们希望最小化)来讨论这些问题。它们是等效的,但有时一个比另一个更方便。

我们可以通过调整分类器的这三个方面来创建不同的系统。简单的阈值是机器学习库中最简单的模型之一,并且仅在问题非常简单时有效,例如在鸢尾花数据集上。在下一节中,我们将处理一个更复杂的分类任务,需要更复杂的结构。

在我们的案例中,我们优化了阈值以最小化错误数量。或者,我们可能会有不同的损失函数。某些类型的错误可能比其他错误更昂贵。在医疗环境中,假阴性和假阳性并不等价。假阴性(当测试结果为阴性,但实际上是错误的)可能导致患者没有接受严重疾病的治疗。假阳性(当测试结果为阳性,而患者实际上并没有这种疾病)可能会导致额外的检查以确认或不必要的治疗(这些治疗仍然可能带来成本,包括治疗的副作用,但通常不如错过诊断那么严重)。因此,根据具体环境,不同的权衡是合理的。在一个极端情况下,如果疾病是致命的,而且治疗便宜且副作用很小,那么你希望尽可能减少假阴性。

小贴士

增益/成本函数的选择总是依赖于你所处理的具体问题。当我们提出通用算法时,我们通常关注最小化错误数量,达到最高的准确度。然而,如果某些错误的成本高于其他错误,那么接受较低的整体准确度可能更好,以最小化整体成本。

一个更复杂的数据集和一个更复杂的分类器

现在我们将看一个稍微复杂一点的数据集。这将为引入一种新的分类算法和其他一些想法提供动机。

了解种子数据集

我们现在来看另一个农业数据集,尽管它仍然很小,但已经足够大,不再像鸢尾花数据集那样可以在一页上完全绘制。这个数据集包含了小麦种子的测量数据。数据集中有七个特征,具体如下:

  • 区域 A

  • 周长 P

  • 紧凑度 C = 4πA/P²

  • 核长度

  • 核宽度

  • 不对称系数

  • 核槽长度

有三个类别,分别对应三种小麦品种:加拿大小麦、Koma 小麦和 Rosa 小麦。如前所述,目标是根据这些形态学测量值来分类物种。与 1930 年代收集的鸢尾花数据集不同,这是一个非常新的数据集,其特征是通过数字图像自动计算得出的。

这是如何实现图像模式识别的:你可以获取数字形式的图像,从中计算出一些相关特征,并使用一个通用的分类系统。在第十章,计算机视觉,我们将通过解决这个问题的计算机视觉部分来计算图像中的特征。现在,我们将使用已给出的特征。

注意

UCI 机器学习数据集仓库

加利福尼亚大学欧文分校(UCI)维护着一个在线机器学习数据集仓库(在写本文时,他们列出了 233 个数据集)。本章中使用的 Iris 数据集和 Seeds 数据集都来源于此。

该仓库可以在线访问:archive.ics.uci.edu/ml/

特征与特征工程

这些特征的一个有趣方面是,紧凑度特征实际上不是一种新的度量,而是之前两个特征——面积和周长——的函数。推导新的组合特征通常非常有用。尝试创建新特征通常被称为特征工程。它有时被认为不如算法引人注目,但它往往对性能影响更大(在精心挑选的特征上应用一个简单的算法,会比在不太好的特征上使用一个复杂的算法表现得更好)。

在这种情况下,原始研究人员计算了紧凑度,这是一个典型的形状特征。它有时也被称为圆度。对于两个内核,它们的形状相同,但一个是另一个的两倍大,紧凑度特征的值是相同的。然而,对于非常圆的内核(当该特征接近 1 时),与形状拉长的内核(当该特征接近 0 时)相比,它将有不同的值。

一个好特征的目标是既要随着重要因素(期望的输出)变化,又要在不重要的因素上保持不变。例如,紧凑度不随大小变化,但随形状变化。在实践中,可能很难完美地同时达到这两个目标,但我们希望尽可能接近这个理想。

你需要使用背景知识来设计良好的特征。幸运的是,对于许多问题领域,已经有大量的文献提供了可用的特征和特征类型,你可以在此基础上进行构建。对于图像,所有之前提到的特征都是典型的,计算机视觉库会为你计算它们。在基于文本的问题中,也有标准的解决方案,你可以将它们混合搭配(我们将在下一章中也会看到)。在可能的情况下,你应该利用你对问题的了解来设计特定的特征,或者选择文献中哪些特征更适用于手头的数据。

即使在你还没有数据之前,你也必须决定哪些数据值得收集。然后,你将所有特征交给机器进行评估,并计算出最佳的分类器。

一个自然的问题是,我们是否可以自动选择好的特征。这个问题被称为特征选择。已经提出了许多方法来解决这个问题,但实际上非常简单的思路效果最好。对于我们目前探索的小问题,使用特征选择没有意义,但如果你有成千上万的特征,那么去掉大部分特征可能会使后续的处理速度更快。

最近邻分类

对于这个数据集,我们将引入一个新的分类器:最近邻分类器。最近邻分类器非常简单。在对一个新元素进行分类时,它会查看训练数据中与其最接近的对象,即最近邻。然后,它会返回该对象的标签作为答案。请注意,这个模型在训练数据上表现完美!对于每一个点,它的最近邻就是它自己,因此它的标签完全匹配(除非两个不同标签的示例具有完全相同的特征值,这将表明你使用的特征不是很具描述性)。因此,使用交叉验证协议来测试分类是至关重要的。

最近邻方法可以推广到不仅仅看单个邻居,而是看多个邻居,并在这些邻居中进行投票。这使得该方法对异常值或标签错误的数据更加健壮。

使用 scikit-learn 进行分类

我们一直在使用手写的分类代码,但 Python 由于其出色的库,是机器学习的非常合适的语言。特别是,scikit-learn 已经成为许多机器学习任务(包括分类)的标准库。在本节中,我们将使用它实现的最近邻分类方法。

scikit-learn 分类 API 是围绕分类器对象组织的。这些对象有以下两个基本方法:

  • fit(features, labels):这是学习步骤,拟合模型的参数。

  • predict(features):该方法只有在调用 fit 之后才能使用,并且返回一个或多个输入的预测结果。

下面是我们如何使用其实现的 k-最近邻方法来处理我们的数据。我们从 sklearn.neighbors 子模块中导入 KneighborsClassifier 对象,开始:

>>> from sklearn.neighbors import KNeighborsClassifier

scikit-learn 模块以 sklearn 导入(有时你也会发现 scikit-learn 使用这个简短的名字而不是全名)。所有 sklearn 的功能都在子模块中,如 sklearn.neighbors

现在我们可以实例化一个分类器对象。在构造函数中,我们指定要考虑的邻居数量,如下所示:

>>> classifier = KNeighborsClassifier(n_neighbors=1)

如果我们没有指定邻居数量,默认值为 5,这是分类中通常很好的选择。

我们将使用交叉验证(当然)来查看我们的数据。scikit-learn 模块也使这变得很容易:

>>> from sklearn.cross_validation import KFold

>>> kf = KFold(len(features), n_folds=5, shuffle=True)
>>> # `means` will be a list of mean accuracies (one entry per fold)
>>> means = []
>>> for training,testing in kf:
...    # We fit a model for this fold, then apply it to the
...    # testing data with `predict`:
...    classifier.fit(features[training], labels[training])
...    prediction = classifier.predict(features[testing])
...
...    # np.mean on an array of booleans returns fraction
...    # of correct decisions for this fold:
...    curmean = np.mean(prediction == labels[testing])
...    means.append(curmean)
>>> print("Mean accuracy: {:.1%}".format(np.mean(means)))
Mean accuracy: 90.5%

使用五折交叉验证,对于这个数据集,使用这个算法,我们获得了 90.5% 的准确率。正如我们在前一部分讨论的那样,交叉验证的准确率低于训练准确率,但这是对模型性能更可靠的估计。

查看决策边界

现在,我们将考察决策边界。为了在纸上绘制这些边界,我们将简化问题,只考虑二维情况。请看以下图表:

查看决策边界

加拿大样本以菱形表示,Koma 种子以圆形表示,Rosa 种子以三角形表示。它们各自的区域分别用白色、黑色和灰色表示。你可能会想,为什么这些区域如此水平,几乎是奇怪的水平。问题在于,x 轴(面积)的范围是从 10 到 22,而 y 轴(紧凑度)的范围是从 0.75 到 1.0。也就是说,x 轴的微小变化实际上要比 y 轴的微小变化大得多。因此,当我们计算点与点之间的距离时,大部分情况下,我们只考虑了 x 轴。这也是为什么将数据可视化并寻找潜在问题或惊讶的一个好例子。

如果你学过物理(并且记得你的课),你可能已经注意到,我们之前在求和长度、面积和无量纲量时混淆了单位(这是在物理系统中绝对不应该做的事情)。我们需要将所有特征归一化到一个统一的尺度。对此问题有许多解决方法;一个简单的解决方法是 标准化为 z 分数。一个值的 z 分数是它与均值的偏差,单位是标准差。其操作如下:

查看决策边界

在这个公式中,f 是原始特征值,f' 是归一化后的特征值,µ 是特征的均值,σ 是标准差。µσ 都是从训练数据中估算出来的。无论原始值是什么,经过 z 评分后,值为零表示训练均值,正值表示高于均值,负值表示低于均值。

scikit-learn 模块使得将这种归一化作为预处理步骤变得非常简单。我们将使用一个转换流水线:第一个元素将进行转换,第二个元素将进行分类。我们首先按如下方式导入流水线和特征缩放类:

>>> from sklearn.pipeline import Pipeline
>>> from sklearn.preprocessing import StandardScaler

现在,我们可以将它们结合起来。

>>> classifier = KNeighborsClassifier(n_neighbors=1)
>>> classifier = Pipeline([('norm', StandardScaler()),
...         ('knn', classifier)])

Pipeline 构造函数接受一个由 (str, clf) 组成的配对列表。每一对都对应流水线中的一步:第一个元素是命名步骤的字符串,而第二个元素是执行转换的对象。该对象的高级用法使用这些名称来引用不同的步骤。

经过归一化处理后,每个特征都处于相同的单位(从技术上讲,每个特征现在是无量纲的;它没有单位),因此我们可以更自信地混合不同的维度。事实上,如果我们现在运行最近邻分类器,我们可以获得 93%的准确率,这个结果是通过之前显示的五折交叉验证代码来估算的!

再次查看二维中的决策空间:

查看决策边界

现在边界发生了变化,你可以看到两个维度对结果都有影响。在完整的数据集中,一切都发生在一个七维空间中,这很难可视化,但相同的原理仍然适用;尽管原始数据中某些维度占主导地位,但经过归一化后,它们都被赋予了相同的重要性。

二分类和多分类

我们使用的第一个分类器是阈值分类器,它是一个简单的二分类器。其结果是一个类别或另一个类别,因为一个点要么在阈值以上,要么不在。我们使用的第二个分类器是最近邻分类器,它是一个自然的多类别分类器,其输出可以是多个类别中的一个。

定义一个简单的二分类方法通常比解决多类别问题的方法更简单。然而,我们可以将任何多类别问题简化为一系列的二元决策。这正是我们在之前的 Iris 数据集中所做的,以一种无序的方式:我们观察到很容易将其中一个初始类别分开,并专注于另外两个,从而将问题简化为两个二元决策:

  1. 它是 Iris Setosa 吗(是或不是)?

  2. 如果没有,检查它是否是 Iris Virginica(是或不是)。

当然,我们希望将这种推理交给计算机来处理。像往常一样,针对这种多类别的简化方法有多种解决方案。

最简单的方法是使用一系列的一对其他分类器。对于每个可能的标签ℓ,我们构建一个分类器,类型是这是ℓ还是其他什么? 当应用规则时,正好有一个分类器会说是的,我们就得到了我们的解决方案。不幸的是,这并不总是发生,所以我们需要决定如何处理多个积极回答或没有积极回答的情况。

二分类和多分类

或者,我们可以构建一个分类树。将可能的标签分成两组,并构建一个分类器,问:“这个例子应该放入左边的箱子还是右边的箱子?”我们可以递归地进行这种分裂,直到我们得到一个单一的标签。前面的图展示了 Iris 数据集的推理树。每个菱形代表一个二分类器。可以想象,我们可以将这棵树做得更大,涵盖更多的决策。这意味着,任何可以用于二分类的分类器,都可以简单地调整来处理任意数量的类别。

有很多其他方法可以将二元方法转化为多类方法。没有一种方法在所有情况下都明显优于其他方法。scikit-learn 模块在 sklearn.multiclass 子模块中实现了几种这样的方法。

提示

一些分类器是二元系统,而许多现实生活中的问题本质上是多类的。几种简单的协议将多类问题简化为一系列二元决策,并允许我们将二元模型应用于多类问题。这意味着看似仅适用于二元数据的方法,可以以极小的额外努力应用于多类数据。

总结

分类是从示例中进行泛化,构建模型(即一个可以自动应用于新的、未分类对象的规则)。它是机器学习中的基本工具之一,我们将在接下来的章节中看到更多这样的例子。

从某种意义上说,这一章是非常理论性的,因为我们用简单的例子介绍了通用概念。我们使用鸢尾花数据集进行了几次操作。这个数据集很小。然而,它的优势在于我们能够绘制出它的图像,并详细看到我们所做的事情。这一点在我们转向处理多维度和数千个样本的问题时将丧失。我们在这里获得的直觉依然有效。

你还学到了训练误差是对模型表现的误导性、过于乐观的估计。我们必须改为在未用于训练的测试数据上评估模型。为了避免在测试中浪费太多样本,交叉验证调度可以让我们兼得两全其美(代价是更多的计算量)。

我们还看到了特征工程的问题。特征并不是预先为你定义好的,选择和设计特征是设计机器学习管道的一个重要部分。事实上,这通常是你能在准确性上获得最多改进的领域,因为更好的数据胜过更复杂的方法。接下来的章节将通过文本分类、音乐流派识别和计算机视觉等具体实例,提供这些特定设置的示例。

下一章将讨论当你的数据没有预定义分类时,如何进行分类。

第三章:聚类——寻找相关帖子

在上一章中,你学会了如何找到单个数据点的类别或类别。通过一小部分带有相应类别的训练数据,你学到了一个模型,我们现在可以用它来分类未来的数据项。我们称这种方法为监督学习,因为学习过程是由老师引导的;在我们这里,老师表现为正确的分类。

现在假设我们没有那些标签来学习分类模型。例如,可能是因为收集这些标签的成本过高。试想一下,如果获得数百万个标签的唯一方式是让人类手动分类,那会有多么昂贵。那我们该如何应对这种情况呢?

当然,我们无法学习一个分类模型。然而,我们可以在数据本身中找到某些模式。也就是说,让数据自我描述。这就是我们在本章要做的事情,我们将面临一个问答网站的挑战。当用户浏览我们的网站时,可能是因为他们在寻找特定信息,搜索引擎最有可能将他们指向一个特定的答案。如果所呈现的答案不是他们想要的,网站应该至少提供相关答案,让用户能够快速看到其他可用的答案,并希望能够停留在我们的网站上。

一个天真的方法是直接拿帖子,计算它与所有其他帖子的相似度,并将最相似的前 n 个帖子作为链接显示在页面上。很快,这将变得非常昂贵。相反,我们需要一种方法,能够快速找到所有相关的帖子。

在本章中,我们将通过聚类来实现这一目标。这是一种将项目排列在一起的方法,使得相似的项目在同一个簇中,而不同的项目则在不同的簇中。我们首先要解决的棘手问题是如何将文本转换为能够计算相似度的形式。有了这样的相似度测量后,我们将继续探讨如何利用它快速找到包含相似帖子的小组。一旦找到了,我们只需要检查那些也属于该小组的文档。为了实现这一目标,我们将向你介绍神奇的 SciKit 库,它提供了多种机器学习方法,我们将在接下来的章节中使用这些方法。

测量帖子之间的相关性

从机器学习的角度来看,原始文本是无用的。只有当我们能够将其转换为有意义的数字时,才能将其输入到机器学习算法中,比如聚类。这对于文本的更常见操作,如相似度测量,亦是如此。

如何避免这么做

一种文本相似度度量方法是 Levenshtein 距离,也叫编辑距离。假设我们有两个单词,“machine”和“mchiene”。它们之间的相似度可以通过将一个单词转换成另一个单词所需的最少编辑次数来表示。在这种情况下,编辑距离是 2,因为我们需要在“m”后面添加一个“a”,并删除第一个“e”。然而,这个算法的代价比较高,因为它的时间复杂度是第一个单词的长度乘以第二个单词的长度。

查看我们的帖子,我们可以通过将整个单词视为字符,并在单词层面上进行编辑距离计算来“作弊”。假设我们有两个帖子(为了简单起见,我们集中关注以下标题):“How to format my hard disk”和“Hard disk format problems”,由于删除“how”,“to”,“format”,“my”,然后在最后添加“format”和“problems”,我们需要编辑距离为 5。因此,可以将两个帖子之间的差异表示为需要添加或删除的单词数量,以便一个文本转变为另一个文本。尽管我们可以大大加速整体方法,但时间复杂度保持不变。

但即使速度足够快,还是存在另一个问题。在前面的帖子中,“format”一词的编辑距离为 2,因为它首先被删除,然后又被添加。因此,我们的距离度量似乎还不够稳健,无法考虑单词顺序的变化。

如何实现

比编辑距离更为稳健的方法是所谓的词袋模型。它完全忽略了单词的顺序,仅仅通过单词的计数来作为基础。对于每个帖子中的单词,它的出现次数会被计数并记录在一个向量中。不出所料,这一步也叫做向量化。这个向量通常非常庞大,因为它包含了整个数据集中出现的单词数量。例如,考虑两个帖子及其单词计数如下:

Word 在帖子 1 中的出现次数 在帖子 2 中的出现次数
disk 1 1
format 1 1
how 1 0
hard 1 1
my 1 0
problems 0 1
to 1 0

“在帖子 1 中的出现次数”和“在帖子 2 中的出现次数”这两列现在可以视为简单的向量。我们可以直接计算所有帖子向量之间的欧几里得距离,并取最近的一个(但这太慢了,正如我们之前发现的那样)。因此,我们可以根据以下步骤将它们作为聚类步骤中的特征向量使用:

  1. 从每个帖子中提取显著特征,并将其存储为每个帖子的向量。

  2. 然后对这些向量进行聚类计算。

  3. 确定相关帖子的聚类。

  4. 从这个聚类中提取一些与目标帖子相似度不同的帖子。这将增加多样性。

但在我们进入下一步之前,还需要做一些准备工作。在我们开始这项工作之前,我们需要一些数据来处理。

预处理 – 相似度通过相同单词的数量来衡量

如我们之前所见,词袋方法既快速又稳健。但它也并非没有挑战。让我们直接深入探讨这些挑战。

将原始文本转换为词袋

我们不需要编写自定义代码来计数单词并将这些计数表示为向量。SciKit 的 CountVectorizer 方法不仅高效完成这项工作,而且界面也非常便捷。SciKit 的函数和类是通过 sklearn 包导入的:

>>> from sklearn.feature_extraction.text import CountVectorizer
>>> vectorizer = CountVectorizer(min_df=1)

min_df 参数决定了 CountVectorizer 如何处理少见词(最小文档频率)。如果设置为整数,所有出现频率低于该值的词汇将被丢弃。如果设置为小数,则所有在整体数据集中出现频率低于该小数的词汇将被丢弃。max_df 参数以类似的方式工作。如果我们打印实例,我们可以看到 SciKit 提供的其他参数及其默认值:

>>> print(vectorizer)CountVectorizer(analyzer='word', binary=False, charset=None,
 charset_error=None, decode_error='strict',
 dtype=<class 'numpy.int64'>, encoding='utf-8', input='content',
 lowercase=True, max_df=1.0, max_features=None, min_df=1,
 ngram_range=(1, 1), preprocessor=None, stop_words=None,
 strip_accents=None, token_pattern='(?u)\\b\\w\\w+\\b',
 tokenizer=None, vocabulary=None)

我们看到,正如预期的那样,计数是按单词级别进行的(analyzer=word),并且单词是通过正则表达式模式 token_pattern 来确定的。例如,它会将“cross-validated”拆分为“cross”和“validated”。暂时忽略其他参数,我们考虑以下两个示例主题行:

>>> content = ["How to format my hard disk", " Hard disk format problems "]

我们现在可以将这个主题行列表传入我们向量化器的fit_transform()函数,它会完成所有复杂的向量化工作。

>>> X = vectorizer.fit_transform(content)
>>> vectorizer.get_feature_names()[u'disk', u'format', u'hard', u'how', u'my', u'problems', u'to']

向量化器已经检测到七个词汇,我们可以单独获取它们的计数:

>>> print(X.toarray().transpose())
[[1 1]
 [1 1]
 [1 1]
 [1 0]
 [1 0]
 [0 1]
 [1 0]]

这意味着第一句包含了除了“problems”之外的所有单词,而第二句包含了除了“how”、“my”和“to”之外的所有单词。事实上,这些正是我们在前面表格中看到的相同列。从X中,我们可以提取出一个特征向量,用来比较两个文档之间的差异。

我们将首先使用一种天真的方法,指出一些我们必须考虑的预处理特性。然后我们选择一个随机帖子,为它创建计数向量。接着我们将比较它与所有计数向量的距离,并提取出距离最小的那个帖子。

计数单词

让我们玩玩这个由以下帖子组成的玩具数据集:

帖子文件名 帖子内容
01.txt 这是一个关于机器学习的玩具帖子。实际上,它并没有太多有趣的内容。
02.txt 成像数据库可能非常庞大。
03.txt 大多数成像数据库会永久保存图像。
04.txt 成像数据库存储图像。
05.txt 成像数据库存储图像。成像数据库存储图像。成像数据库存储图像。

在这个帖子数据集中,我们希望找到与短帖子“成像数据库”最相似的帖子。

假设帖子位于目录 DIR 中,我们可以将它传入 CountVectorizer

>>> posts = [open(os.path.join(DIR, f)).read() for f in os.listdir(DIR)]
>>> from sklearn.feature_extraction.text import CountVectorizer
>>> vectorizer = CountVectorizer(min_df=1)

我们需要通知向量化器有关完整数据集的信息,以便它提前知道哪些词汇是预期的:

>>> X_train = vectorizer.fit_transform(posts)
>>> num_samples, num_features = X_train.shape
>>> print("#samples: %d, #features: %d" % (num_samples, num_features))
#samples: 5, #features: 25

不出所料,我们有五篇帖子,总共有 25 个不同的单词。以下是已被标记的单词,将被计数:

>>> print(vectorizer.get_feature_names())
[u'about', u'actually', u'capabilities', u'contains', u'data', u'databases', u'images', u'imaging', u'interesting', u'is', u'it', u'learning', u'machine', u'most', u'much', u'not', u'permanently', u'post', u'provide', u'save', u'storage', u'store', u'stuff', u'this', u'toy']

现在我们可以将新帖子向量化了。

>>> new_post = "imaging databases"
>>> new_post_vec = vectorizer.transform([new_post])

请注意,transform方法返回的计数向量是稀疏的。也就是说,每个向量不会为每个单词存储一个计数值,因为大多数计数值都是零(该帖子不包含该单词)。相反,它使用了更节省内存的实现coo_matrix("COOrdinate")。例如,我们的新帖子实际上只包含两个元素:

>>> print(new_post_vec)
 (0, 7)  1
 (0, 5)  1

通过其toarray()成员,我们可以再次访问完整的ndarray

>>> print(new_post_vec.toarray())
[[0 0 0 0 0 1 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]]

如果我们想将其用作相似性计算的向量,我们需要使用整个数组。对于相似性测量(最简单的计算方法),我们计算新帖子和所有旧帖子之间计数向量的欧几里得距离:

>>> import scipy as sp
>>> def dist_raw(v1, v2):
...     delta = v1-v2
...     return sp.linalg.norm(delta.toarray())

norm()函数计算欧几里得范数(最短距离)。这只是一个明显的首选,实际上有许多更有趣的方式来计算距离。你可以看看论文《Distance Coefficients between Two Lists or Sets》在《The Python Papers Source Codes》中,Maurice Ling 精妙地展示了 35 种不同的计算方法。

使用dist_raw,我们只需遍历所有帖子并记住最接近的一个:

>>> import sys
>>> best_doc = None
>>> best_dist = sys.maxint
>>> best_i = None
>>> for i, post in enumerate(num_samples):
...     if post == new_post:
...         continue
...     post_vec = X_train.getrow(i)
...     d = dist_raw(post_vec, new_post_vec)
...     print("=== Post %i with dist=%.2f: %s"%(i, d, post))
...     if d<best_dist:
...         best_dist = d
...         best_i = i
>>> print("Best post is %i with dist=%.2f"%(best_i, best_dist))

=== Post 0 with dist=4.00: This is a toy post about machine learning. Actually, it contains not much interesting stuff.
=== Post 1 with dist=1.73: Imaging databases provide storage capabilities.
=== Post 2 with dist=2.00: Most imaging databases save images permanently.
=== Post 3 with dist=1.41: Imaging databases store data.
=== Post 4 with dist=5.10: Imaging databases store data. Imaging databases store data. Imaging databases store data.
Best post is 3 with dist=1.41

恭喜,我们已经得到了第一次相似性测量结果。帖子 0 与我们新帖子的相似性最小。可以理解的是,它与新帖子没有一个共同的单词。我们也可以理解,帖子 1 与新帖子非常相似,但并不是最相似的,因为它包含了一个比帖子 3 多的单词,而该单词在新帖子中并不存在。

然而,看看帖子 3 和帖子 4,情况就不那么清晰了。帖子 4 是帖子 3 的三倍复制。因此,它与新帖子的相似性应该与帖子 3 相同。

打印相应的特征向量可以解释为什么:

>>> print(X_train.getrow(3).toarray())
[[0 0 0 0 1 1 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0]]
>>> print(X_train.getrow(4).toarray())
[[0 0 0 0 3 3 0 3 0 0 0 0 0 0 0 0 0 0 0 0 0 3 0 0 0]]

显然,仅使用原始单词的计数太简单了。我们必须将它们归一化,以获得单位长度的向量。

归一化单词计数向量

我们需要扩展dist_raw,以便计算向量距离时不使用原始向量,而是使用归一化后的向量:

>>> def dist_norm(v1, v2):
...    v1_normalized = v1/sp.linalg.norm(v1.toarray())
...    v2_normalized = v2/sp.linalg.norm(v2.toarray())
...    delta = v1_normalized - v2_normalized
...    return sp.linalg.norm(delta.toarray())

这导致了以下的相似性测量结果:

=== Post 0 with dist=1.41: This is a toy post about machine learning. Actually, it contains not much interesting stuff.
=== Post 1 with dist=0.86: Imaging databases provide storage capabilities.
=== Post 2 with dist=0.92: Most imaging databases save images permanently.
=== Post 3 with dist=0.77: Imaging databases store data.
=== Post 4 with dist=0.77: Imaging databases store data. Imaging databases store data. Imaging databases store data.
Best post is 3 with dist=0.77

现在看起来好多了。帖子 3 和帖子 4 被计算为相等的相似度。有人可能会争辩说如此重复的内容是否能让读者感到愉悦,但从计算帖子中单词数量的角度来看,这似乎是正确的。

删除不太重要的单词

让我们再看看帖子 2。它与新帖子中的不同单词有“most”、“save”、“images”和“permanently”。这些词在整体重要性上其实是相当不同的。“most”这样的词在各种不同的语境中出现得非常频繁,被称为停用词。它们并不包含太多信息,因此不应像“images”这样的词那样被赋予同等重要性,因为“images”并不经常出现在不同的语境中。最佳的做法是移除那些频繁出现、不帮助区分不同文本的词。这些词被称为停用词。

由于这是文本处理中的常见步骤,CountVectorizer 中有一个简单的参数可以实现这一点:

>>> vectorizer = CountVectorizer(min_df=1, stop_words='english')

如果您清楚想要移除哪些停用词,您也可以传递一个词表。将 stop_words 设置为 english 将使用 318 个英语停用词的集合。要查看这些停用词,您可以使用 get_stop_words()

>>> sorted(vectorizer.get_stop_words())[0:20]
['a', 'about', 'above', 'across', 'after', 'afterwards', 'again', 'against', 'all', 'almost', 'alone', 'along', 'already', 'also', 'although', 'always', 'am', 'among', 'amongst', 'amoungst']

新的单词列表减少了七个单词:

[u'actually', u'capabilities', u'contains', u'data', u'databases', u'images', u'imaging', u'interesting', u'learning', u'machine', u'permanently', u'post', u'provide', u'save', u'storage', u'store', u'stuff', u'toy']

没有停用词后,我们得到了以下的相似度度量:

=== Post 0 with dist=1.41: This is a toy post about machine learning. Actually, it contains not much interesting stuff.
=== Post 1 with dist=0.86: Imaging databases provide storage capabilities.
=== Post 2 with dist=0.86: Most imaging databases save images permanently.
=== Post 3 with dist=0.77: Imaging databases store data.
=== Post 4 with dist=0.77: Imaging databases store data. Imaging databases store data. Imaging databases store data.
Best post is 3 with dist=0.77

现在帖子 2 与帖子 1 相当。然而,由于我们的帖子为了演示目的保持简短,它们的变化并不大。它将在我们查看实际数据时变得至关重要。

词干提取

还有一件事没有完成。我们将不同形式的相似单词计为不同的单词。例如,帖子 2 包含了 "imaging" 和 "images"。将它们计为相同的词是有意义的。毕竟,它们指的是相同的概念。

我们需要一个将单词还原为其特定词干的函数。SciKit 默认不包含词干提取器。通过 自然语言工具包NLTK),我们可以下载一个免费的软件工具包,提供一个可以轻松集成到 CountVectorizer 中的词干提取器。

安装和使用 NLTK

如何在您的操作系统上安装 NLTK 的详细说明可以在 nltk.org/install.html 上找到。不幸的是,它目前还没有正式支持 Python 3,这意味着 pip 安装也无法使用。然而,我们可以从 www.nltk.org/nltk3-alpha/ 下载该软件包,在解压后使用 Python 的 setup.py 安装进行手动安装。

要检查您的安装是否成功,请打开 Python 解释器并输入:

>>> import nltk

注释

您可以在《Python 3 Text Processing with NLTK 3 Cookbook》一书中找到关于 NLTK 的一个非常好的教程,作者是 Jacob Perkins,由 Packt Publishing 出版。为了稍微体验一下词干提取器,您可以访问网页 text-processing.com/demo/stem/

NLTK 提供了不同的词干提取器。这是必要的,因为每种语言都有不同的词干提取规则。对于英语,我们可以使用 SnowballStemmer

>>> import nltk.stem
>>> s = nltk.stem.SnowballStemmer('english')
>>> s.stem("graphics")
u'graphic'
>>> s.stem("imaging")
u'imag'
>>> s.stem("image")
u'imag'
>>> s.stem("imagination")
u'imagin'
>>> s.stem("imagine")
u'imagin'

注释

请注意,词干提取不一定会产生有效的英语单词。

它也适用于动词:

>>> s.stem("buys")
u'buy'
>>> s.stem("buying")
u'buy'

这意味着它大多数时候都能正常工作:

>>> s.stem("bought")
u'bought'

使用 NLTK 的词干提取器扩展向量器

在将帖子输入到CountVectorizer之前,我们需要进行词干提取。该类提供了几个钩子,允许我们自定义该阶段的预处理和分词。预处理器和分词器可以作为构造函数中的参数进行设置。我们不希望将词干提取器放入其中,因为那样我们就必须自己进行分词和归一化处理。相反,我们重写build_analyzer方法:

>>> import nltk.stem
>>> english_stemmer = nltk.stem.SnowballStemmer('english'))
>>> class StemmedCountVectorizer(CountVectorizer):
...     def build_analyzer(self):
...         analyzer = super(StemmedCountVectorizer, self).build_analyzer()
...         return lambda doc: (english_stemmer.stem(w) for w in analyzer(doc))
>>> vectorizer = StemmedCountVectorizer(min_df=1, stop_words='english')

这将对每篇帖子执行以下过程:

  1. 第一步是在预处理步骤中将原始帖子转换为小写(由父类完成)。

  2. 在分词步骤中提取所有单独的词汇(由父类完成)。

  3. 最终的结果是将每个单词转换为其词干形式。

结果是我们现在少了一个特征,因为“images”和“imaging”合并为一个特征。现在,特征名称集如下所示:

[u'actual', u'capabl', u'contain', u'data', u'databas', u'imag', u'interest', u'learn', u'machin', u'perman', u'post', u'provid', u'save', u'storag', u'store', u'stuff', u'toy']

在我们的新词干提取向量器对帖子进行处理时,我们看到将“imaging”和“images”合并后,实际上帖子 2 与我们的新帖子最为相似,因为它包含了“imag”这个概念两次:

=== Post 0 with dist=1.41: This is a toy post about machine learning. Actually, it contains not much interesting stuff.
=== Post 1 with dist=0.86: Imaging databases provide storage capabilities.

=== Post 2 with dist=0.63: Most imaging databases save images permanently.
=== Post 3 with dist=0.77: Imaging databases store data.
=== Post 4 with dist=0.77: Imaging databases store data. Imaging databases store data. Imaging databases store data.
Best post is 2 with dist=0.63

停用词强化版

现在我们有了一种合理的方式来从嘈杂的文本帖子中提取紧凑的向量,让我们退一步思考一下这些特征值实际上意味着什么。

特征值仅仅是计数帖子中术语的出现次数。我们默认假设某个术语的值越高,意味着该术语对给定帖子越重要。但是,像“subject”这样的词怎么处理呢?它在每篇帖子中都自然出现。好吧,我们可以告诉CountVectorizer通过它的max_df参数来删除它。例如,我们可以将其设置为0.9,这样所有出现在 90%以上帖子中的词将始终被忽略。但是,像出现在 89%帖子中的词呢?我们应该将max_df设置得多低呢?问题在于,无论我们怎么设置,总会有一些术语比其他术语更具区分性。

这只能通过对每篇帖子的术语频率进行计数来解决,并且要对出现在许多帖子中的术语进行折扣处理。换句话说,如果某个术语在特定帖子中出现得很频繁,而在其他地方很少出现,我们希望它的值较高。

这正是词频–逆文档频率TF-IDF)所做的。TF 代表计数部分,而 IDF 考虑了折扣。一个简单的实现可能如下所示:

>>> import scipy as sp
>>> def tfidf(term, doc, corpus):
...     tf = doc.count(term) / len(doc)
...     num_docs_with_term = len([d for d in corpus if term in d])
...     idf = sp.log(len(corpus) / num_docs_with_term)
...     return tf * idf

你会看到我们不仅仅是计数术语,还通过文档长度对计数进行了归一化。这样,较长的文档就不会相对于较短的文档占有不公平的优势。

对于以下文档D,它由三篇已经分词的文档组成,我们可以看到术语是如何被不同对待的,尽管它们在每篇文档中出现的频率相同:

>>> a, abb, abc = ["a"], ["a", "b", "b"], ["a", "b", "c"]
>>> D = [a, abb, abc]
>>> print(tfidf("a", a, D))
0.0
>>> print(tfidf("a", abb, D))
0.0
>>> print(tfidf("a", abc, D))
0.0
>>> print(tfidf("b", abb, D))
0.270310072072
>>> print(tfidf("a", abc, D))
0.0
>>> print(tfidf("b", abc, D))
0.135155036036
>>> print(tfidf("c", abc, D))
0.366204096223

我们看到,a 对任何文档没有意义,因为它在所有地方都出现。b 这个术语对文档 abb 更重要,因为它在那里出现了两次,而在 abc 中只有一次。

事实上,需要处理的特殊情况比前面的例子更多。感谢 SciKit,我们不必再考虑它们,因为它们已经被很好地封装在 TfidfVectorizer 中,该类继承自 CountVectorizer。当然,我们不想忘记使用词干提取器:

>>> from sklearn.feature_extraction.text import TfidfVectorizer
>>> class StemmedTfidfVectorizer(TfidfVectorizer):
...     def build_analyzer(self):
...         analyzer = super(TfidfVectorizer,
 self).build_analyzer()
...         return lambda doc: (
 english_stemmer.stem(w) for w in analyzer(doc))
>>> vectorizer = StemmedTfidfVectorizer(min_df=1,
 stop_words='english', decode_error='ignore')

结果得到的文档向量将不再包含计数信息,而是包含每个术语的单独 TF-IDF 值。

我们的成就和目标

我们当前的文本预处理阶段包括以下步骤:

  1. 首先,对文本进行标记化处理。

  2. 接下来是丢弃那些出现过于频繁的单词,因为它们对检测相关帖子没有任何帮助。

  3. 丢弃那些出现频率极低的单词,这些单词几乎不会出现在未来的帖子中。

  4. 计算剩余单词的频率。

  5. 最后,从计数中计算 TF-IDF 值,考虑整个文本语料库。

再次祝贺我们自己。通过这个过程,我们能够将一堆杂乱无章的文本转换为简洁的特征值表示。

但是,尽管词袋方法及其扩展既简单又强大,但它也有一些缺点,我们应该意识到:

  • 它没有覆盖单词之间的关系:使用上述的向量化方法,文本 "Car hits wall" 和 "Wall hits car" 将具有相同的特征向量。

  • 它无法正确捕捉否定:例如,文本 "I will eat ice cream" 和 "I will not eat ice cream" 在它们的特征向量上看起来非常相似,尽管它们传达了完全相反的意思。然而,这个问题可以通过不仅统计单个词汇(也称为“单元词”),还考虑二元词组(词对)或三元词组(三个连续的词)来轻松解决。

  • 它在处理拼写错误的单词时完全失败:虽然对我们这些人类读者来说,“database”和“databas”传达的是相同的意义,但我们的处理方法会将它们视为完全不同的单词。

为了简洁起见,我们仍然坚持使用当前的方法,利用它可以高效地构建簇。

聚类

最后,我们得到了向量,我们认为它们足以捕捉帖子内容。不出所料,有许多方法可以将它们进行分组。大多数聚类算法可以归纳为两种方法:平面聚类和层次聚类。

平面聚类将帖子分为一组簇,而不考虑簇之间的关系。目标仅仅是找到一种划分方式,使得同一簇中的所有帖子彼此最为相似,同时与其他簇中的帖子差异较大。许多平面聚类算法要求在开始时就指定簇的数量。

在层次聚类中,聚类的数量不需要事先指定。相反,层次聚类会创建一个聚类的层级结构。相似的文档会被分到一个聚类中,而相似的聚类又会被分到一个超聚类中。这是递归进行的,直到只剩下一个包含所有内容的聚类。在这个层级结构中,我们可以在事后选择所需的聚类数量。然而,这样做的代价是效率较低。

SciKit 提供了 sklearn.cluster 包中多种聚类方法。你可以在scikit-learn.org/dev/modules/clustering.html中快速了解它们的优缺点。

在接下来的章节中,我们将使用平面聚类方法 K-means,并尝试调整聚类数量。

K-means

K-means 是最广泛使用的平面聚类算法。在初始化时设定所需的聚类数量 num_clusters,它会维持该数量的所谓聚类中心。最初,它会随机选择 num_clusters 个文档,并将聚类中心设置为这些文档的特征向量。然后,它会遍历所有其他文档,将它们分配到离它们最近的聚类中心。接下来,它会将每个聚类中心移到该类别所有向量的中间位置。这会改变聚类分配。某些文档现在可能更接近另一个聚类。因此,它会更新这些文档的聚类分配。这个过程会一直进行,直到聚类中心的移动幅度小于设定的阈值,认为聚类已经收敛。

我们通过一个简单的示例来演示,每个示例只包含两个词。下图中的每个点代表一个文档:

K-means

在执行一次 K-means 迭代后,即选择任意两个向量作为起始点,给其他点分配标签,并更新聚类中心使其成为该类中所有点的中心点,我们得到了以下的聚类结果:

K-means

因为聚类中心发生了移动,我们需要重新分配聚类标签,并重新计算聚类中心。在第二次迭代后,我们得到了以下的聚类结果:

K-means

箭头表示聚类中心的移动。在这个示例中,经过五次迭代后,聚类中心的移动几乎不再明显(SciKit 默认的容忍阈值是 0.0001)。

聚类完成后,我们只需要记录聚类中心及其身份。每当有新的文档进入时,我们需要将其向量化并与所有聚类中心进行比较。与新文档向量距离最小的聚类中心所属的聚类即为我们为新文档分配的聚类。

获取测试数据以评估我们的想法

为了测试聚类,我们不再使用简单的文本示例,而是寻找一个与我们未来预期的数据类似的数据集,以便测试我们的方法。为了这个目的,我们需要一些已经按技术主题分组的文档,这样我们就可以检查我们算法在后续应用到我们期望接收到的帖子时是否能够如预期那样工作。

机器学习中的一个标准数据集是20newsgroup数据集,包含来自 20 个不同新闻组的 18,826 篇帖子。组内的主题包括技术类的comp.sys.mac.hardwaresci.crypt,以及与政治或宗教相关的主题,如talk.politics.gunssoc.religion.christian。我们将只关注技术类新闻组。如果我们将每个新闻组视为一个聚类,那么可以很好地测试我们寻找相关帖子的方式是否有效。

数据集可以从people.csail.mit.edu/jrennie/20Newsgroups下载。更方便的方式是从 MLComp 下载,地址是mlcomp.org/datasets/379(需要免费注册)。SciKit 已经为该数据集提供了自定义加载器,并提供了非常便捷的数据加载选项。

数据集以 ZIP 文件dataset-379-20news-18828_WJQIG.zip的形式提供,我们需要解压这个文件,得到包含数据集的379目录。我们还需要告诉 SciKit 该数据目录所在的路径。该目录包含一个元数据文件和三个子目录testtrainrawtesttrain目录将整个数据集分成 60%的训练集和 40%的测试集。如果你选择这种方式,你要么需要设置环境变量MLCOMP_DATASETS_HOME,要么在加载数据集时使用mlcomp_root参数直接指定路径。

注意

mlcomp.org是一个用于比较各种数据集上的机器学习程序的网站。它有两个用途:帮助你找到合适的数据集来调整你的机器学习程序,以及探索其他人如何使用特定的数据集。例如,你可以查看其他人的算法在特定数据集上的表现,并与他们进行比较。

为了方便,sklearn.datasets模块还包含fetch_20newsgroups函数,它会自动在后台下载数据:

>>> import sklearn.datasets
>>> all_data = sklearn.datasets.fetch_20newsgroups(subset='all')
>>> print(len(all_data.filenames))
18846
>>> print(all_data.target_names)
['alt.atheism', 'comp.graphics', 'comp.os.ms-windows.misc', 'comp.sys.ibm.pc.hardware', 'comp.sys.mac.hardware', 'comp.windows.x', 'misc.forsale', 'rec.autos', 'rec.motorcycles', 'rec.sport.baseball', 'rec.sport.hockey', 'sci.crypt', 'sci.electronics', 'sci.med', 'sci.space', 'soc.religion.christian', 'talk.politics.guns', 'talk.politics.mideast', 'talk.politics.misc', 'talk.religion.misc']

我们可以在训练集和测试集之间进行选择:

>>> train_data = sklearn.datasets.fetch_20newsgroups(subset='train', categories=groups)
>>> print(len(train_data.filenames))
11314
>>> test_data = sklearn.datasets.fetch_20newsgroups(subset='test')
>>> print(len(test_data.filenames))
7532

为了简化实验周期,我们将只选择一些新闻组。我们可以通过categories参数来实现这一点:

>>> groups = ['comp.graphics', 'comp.os.ms-windows.misc', 'comp.sys.ibm.pc.hardware', 'comp.sys.mac.hardware', 'comp.windows.x', 'sci.space']
>>> train_data = sklearn.datasets.fetch_20newsgroups(subset='train', categories=groups)
>>> print(len(train_data.filenames))
3529

>>> test_data = sklearn.datasets.fetch_20newsgroups(subset='test', categories=groups)
>>> print(len(test_data.filenames))
2349

聚类帖子

你可能已经注意到一件事——真实数据是噪声重重的。新组数据集也不例外,它甚至包含无效字符,可能导致UnicodeDecodeError错误。

我们需要告诉向量化器忽略它们:

>>> vectorizer = StemmedTfidfVectorizer(min_df=10, max_df=0.5,
...              stop_words='english', decode_error='ignore')
>>> vectorized = vectorizer.fit_transform(train_data.data)
>>> num_samples, num_features = vectorized.shape
>>> print("#samples: %d, #features: %d" % (num_samples, num_features))
#samples: 3529, #features: 4712

我们现在有一个包含 3,529 篇帖子的数据池,并为每篇帖子提取了一个 4,712 维的特征向量。这就是 K-means 所需要的输入。我们将本章的聚类大小固定为 50,希望你足够好奇,能尝试不同的值作为练习。

>>> num_clusters = 50
>>> from sklearn.cluster import KMeans
>>> km = KMeans(n_clusters=num_clusters, init='random', n_init=1,
verbose=1, random_state=3)
>>> km.fit(vectorized)

就这样。我们提供了一个随机状态,目的是让你能够得到相同的结果。在实际应用中,你不会这样做。拟合之后,我们可以从 km 的成员中获取聚类信息。对于每一个已经拟合的向量化帖子,在 km.labels_ 中都有一个对应的整数标签:

>>> print(km.labels_)
[48 23 31 ...,  6  2 22]
>>> print(km.labels_.shape)
3529

可以通过 km.cluster_centers_ 访问聚类中心。

在下一节中,我们将看到如何使用 km.predict 为新到的帖子分配聚类。

解决我们初始挑战

现在我们将把所有内容整合在一起,并为以下我们分配给 new_post 变量的新帖子演示我们的系统:

"磁盘驱动器问题。你好,我的硬盘有问题。

用了一年后,现在它只能间歇性工作。

我试图格式化它,但现在它无法再启动了。

有什么想法吗?谢谢。"

正如你之前学到的,你首先需要将这个帖子向量化,然后才能预测它的标签:

>>> new_post_vec = vectorizer.transform([new_post])
>>> new_post_label = km.predict(new_post_vec)[0]

现在我们有了聚类结果,我们不需要将new_post_vec与所有帖子向量进行比较。相反,我们可以只关注同一聚类中的帖子。让我们获取它们在原始数据集中的索引:

>>> similar_indices = (km.labels_==new_post_label).nonzero()[0]

括号中的比较结果是一个布尔数组,nonzero 将该数组转换为一个较小的数组,包含 True 元素的索引。

使用 similar_indices,我们只需构建一个包含帖子及其相似度得分的列表:

>>> similar = []
>>> for i in similar_indices:
...    dist = sp.linalg.norm((new_post_vec - vectorized[i]).toarray())
...    similar.append((dist, dataset.data[i]))
>>> similar = sorted(similar)
>>> print(len(similar))
131

我们在我们帖子所在的聚类中找到了 131 篇帖子。为了让用户快速了解可用的相似帖子,我们现在可以展示最相似的帖子(show_at_1),以及两个相对较少相似但仍然相关的帖子——它们都来自同一聚类。

>>> show_at_1 = similar[0]
>>> show_at_2 = similar[int(len(similar)/10)]
>>> show_at_3 = similar[int(len(similar)/2)]

以下表格显示了帖子及其相似度值:

位置 相似度 帖子摘录
1 1.038 使用 IDE 控制器的引导问题,您好,我有一个多 I/O 卡(IDE 控制器 + 串行/并行接口)和两个软盘驱动器(5 1/4,3 1/2)以及一个连接到它的 Quantum ProDrive 80AT。我能够格式化硬盘,但无法从中启动。我可以从 A: 驱动器启动(哪个磁盘驱动器无关紧要),但如果我从 A: 驱动器中移除磁盘并按下重置开关,A: 驱动器的 LED 灯会继续亮着,而硬盘根本没有被访问。我猜这可能是多 I/O 卡或软盘驱动器设置(跳线配置?)的问题。有谁知道可能是什么原因吗?[…]
2 1.150 从 B 驱动器启动我有一个 5 1/4"的驱动器作为 A 驱动器。我如何让系统从我的 3 1/2" B 驱动器启动?(理想情况下,计算机应该能够从 A 或 B 驱动器启动,按照顺序检查它们是否有可启动的磁盘。但是:如果我必须交换电缆并简单地交换驱动器,以便它无法启动 5 1/4"磁盘,那也可以。另外,boot_b 也不能帮我实现这个目的。[……][……]
3 1.280 IBM PS/1 与 TEAC FD 大家好,我已经尝试过我们的国家新闻组但没有成功。我试图用普通的 TEAC 驱动器替换我朋友在 PS/1-PC 中使用的 IBM 软盘驱动器。我已经确定了电源供应在 3 号引脚(5V)和 6 号引脚(12V)的位置,将 6 号引脚(5.25"/3.5"切换)短接,并在 8、26、28、30 和 34 号引脚上插入了上拉电阻(2K2)。计算机没有抱怨缺少软盘,但软盘的指示灯一直亮着。当我插入磁盘时,驱动器正常启动,但我无法访问它。TEAC 在普通 PC 中工作正常。我是否漏掉了什么?[……][……]

有趣的是,帖子如何反映相似度测量分数。第一篇帖子包含了我们新帖子的所有突出词汇。第二篇帖子也围绕启动问题展开,但涉及的是软盘而不是硬盘。最后,第三篇既不是关于硬盘的,也不是关于启动问题的。不过,在所有帖子中,我们会说它们属于与新帖子相同的领域。

再看噪声

我们不应期待完美的聚类,也就是说,来自同一新闻组(例如,comp.graphics)的帖子也会被聚在一起。一个例子能让我们快速了解我们可能会遇到的噪声。为了简化起见,我们将关注其中一个较短的帖子:

>>> post_group = zip(train_data.data, train_data.target)
>>> all = [(len(post[0]), post[0], train_data.target_names[post[1]]) for post in post_group]
>>> graphics = sorted([post for post in all if post[2]=='comp.graphics'])
>>> print(graphics[5])
(245, 'From: SITUNAYA@IBM3090.BHAM.AC.UK\nSubject: test....(sorry)\nOrganization: The University of Birmingham, United Kingdom\nLines: 1\nNNTP-Posting-Host: ibm3090.bham.ac.uk<…snip…>', 'comp.graphics')

就这个帖子而言,考虑到预处理步骤后剩下的文字,根本没有明显的迹象表明它属于comp.graphics

>>> noise_post = graphics[5][1]
>>> analyzer = vectorizer.build_analyzer()
>>> print(list(analyzer(noise_post)))
['situnaya', 'ibm3090', 'bham', 'ac', 'uk', 'subject', 'test', 'sorri', 'organ', 'univers', 'birmingham', 'unit', 'kingdom', 'line', 'nntp', 'post', 'host', 'ibm3090', 'bham', 'ac', 'uk']

这仅仅是在分词、转换为小写和去除停用词之后。如果我们还减去那些将通过min_dfmax_df在稍后的fit_transform中过滤掉的词汇,情况就会变得更糟:

>>> useful = set(analyzer(noise_post)).intersection(vectorizer.get_feature_names())
>>> print(sorted(useful))
['ac', 'birmingham', 'host', 'kingdom', 'nntp', 'sorri', 'test', 'uk', 'unit', 'univers']

更重要的是,大多数词汇在其他帖子中也频繁出现,我们可以通过 IDF 得分来检查这一点。记住,TF-IDF 值越高,术语对于给定帖子的区分度就越高。由于 IDF 在这里是一个乘法因子,它的低值意味着它在一般情况下并不具有很大的价值。

>>> for term in sorted(useful):
...     print('IDF(%s)=%.2f'%(term, vectorizer._tfidf.idf_[vectorizer.vocabulary_[term]]))
IDF(ac)=3.51
IDF(birmingham)=6.77
IDF(host)=1.74
IDF(kingdom)=6.68
IDF(nntp)=1.77
IDF(sorri)=4.14
IDF(test)=3.83
IDF(uk)=3.70
IDF(unit)=4.42
IDF(univers)=1.91

因此,具有最高区分度的术语birminghamkingdom显然与计算机图形学并不相关,IDF 得分较低的术语也是如此。可以理解,不同新闻组的帖子将被聚类在一起。

然而,对于我们的目标来说,这并不是什么大问题,因为我们只对减少我们必须与之比较的新帖子的数量感兴趣。毕竟,我们训练数据来源的特定新闻组并不特别重要。

调整参数

那么其他的参数呢?我们能调整它们以获得更好的结果吗?

当然。我们当然可以调整聚类的数量,或者调试向量化器的max_features参数(你应该试试这个!)。另外,我们还可以尝试不同的聚类中心初始化方式。除此之外,还有比 K-means 更令人兴奋的替代方法。例如,有些聚类方法甚至允许你使用不同的相似度度量,比如余弦相似度、皮尔逊相关系数或杰卡德相似系数。这是一个值得你探索的有趣领域。

但在你深入之前,你需要定义一下“更好”到底意味着什么。SciKit 为这个定义提供了一个完整的包。这个包叫做sklearn.metrics,它也包含了各种用于衡量聚类质量的度量指标,也许这是你现在应该去的第一个地方。直接去查看这些度量包的源代码吧。

总结

这一路从预处理到聚类,再到能够将嘈杂的文本转换为有意义且简洁的向量表示以便进行聚类,确实很不容易。如果我们看看为了最终能够聚类所做的努力,这几乎占据了整个任务的一半。但在这个过程中,我们学到了很多关于文本处理的知识,以及如何通过简单的计数在嘈杂的现实世界数据中取得很大进展。

不过,由于 SciKit 及其强大的包,这个过程变得更加顺利了。并且,还有更多值得探索的内容。在本章中,我们只是粗略地触及了它的能力。接下来的章节中,我们将进一步了解它的强大功能。

第四章:主题建模

在上一章中,我们使用聚类方法对文本文档进行了分组。这是一个非常有用的工具,但并不总是最好的。聚类将每个文本分配到一个唯一的簇中。这本书是关于机器学习和 Python 的。它应该与其他 Python 相关书籍分在一起,还是与机器学习相关书籍分在一起?在实体书店中,我们需要为这本书提供一个固定的存放位置。然而,在互联网书店中,答案是这本书既关于机器学习,也关于 Python,所以这本书应该列在在线书店的两个类别中。当然,这并不意味着这本书会出现在所有类别中。我们不会把这本书列在烘焙类书籍中。

本章中,我们将学习一些方法,这些方法不会将文档完全分为独立的组,而是允许每个文档涉及多个主题。这些主题将自动从一组文本文档中识别出来。这些文档可以是整本书,或者是较短的文本片段,如博客文章、新闻故事或电子邮件。

我们还希望能够推断出这些文档可能有些主题是其核心内容,而仅仅是提及其他主题。例如,这本书偶尔提到绘图,但它不是像机器学习那样的核心主题。这意味着文档有些主题是其核心内容,其他则是较为外围的内容。处理这些问题的机器学习子领域被称为主题建模,也是本章的主题。

潜在狄利克雷分配

LDA 和 LDA——不幸的是,在机器学习中有两个以 LDA 为首字母缩写的方法:潜在狄利克雷分配(Latent Dirichlet Allocation,LDA),一种主题建模方法,以及线性判别分析(Linear Discriminant Analysis,LDA),一种分类方法。它们完全不相关,除了 LDA 的首字母可以指代这两者之外。在某些情况下,这可能会造成混淆。scikit-learn 工具包有一个子模块 sklearn.lda,用于实现线性判别分析。目前,scikit-learn 并没有实现潜在狄利克雷分配。

我们将要看的主题模型是潜在狄利克雷分配LDA)。LDA 背后的数学原理相当复杂,我们在这里不会详细讨论。

对于那些有兴趣并且足够冒险的人,维基百科将提供所有这些算法背后的方程式:en.wikipedia.org/wiki/Latent_Dirichlet_allocation

然而,我们可以在高层次上直观地理解 LDA 背后的思想。LDA 属于一种叫做生成模型的模型类别,因为它们有一个类似寓言的故事,解释了数据是如何生成的。当然,这个生成的故事是对现实的简化,以便让机器学习更容易。在 LDA 的寓言中,我们首先通过为单词分配概率权重来创建主题。每个主题会为不同的单词分配不同的权重。例如,Python 主题会为单词“variable”分配较高的概率,为单词“inebriated”分配较低的概率。当我们希望生成一个新文档时,首先选择它将使用的主题,然后从这些主题中混合单词。

例如,假设我们只有三种书籍讨论的主题:

  • 机器学习

  • Python

  • 烘焙

对于每个主题,我们都有一个与之相关的单词列表。这本书将是前两个主题的混合,可能每个占 50%。混合比例不一定要相等,它也可以是 70/30 的分配。在生成实际文本时,我们是一个一个单词生成的;首先决定这个单词将来自哪个主题。这是基于主题权重的随机决定。一旦选择了一个主题,我们就从该主题的单词列表中生成一个单词。准确来说,我们会根据该主题给定的概率选择一个英文单词。

在这个模型中,单词的顺序不重要。这是一个 词袋模型,正如我们在上一章中看到的那样。它是对语言的粗略简化,但通常足够有效,因为仅仅知道文档中使用了哪些单词及其频率,就足以做出机器学习决策。

在现实世界中,我们不知道主题是什么。我们的任务是获取一组文本,并反向工程这个寓言,以发现有哪些主题,并同时弄清楚每个文档使用了哪些主题。

构建主题模型

不幸的是,scikit-learn 不支持潜在狄利克雷分配(LDA)。因此,我们将使用 Python 中的 gensim 包。Gensim 由 Radim Řehůřek 开发,他是英国的机器学习研究员和顾问。我们必须首先安装它。可以通过运行以下命令来实现:

pip install gensim

作为输入数据,我们将使用来自 美联社 (AP) 的一组新闻报道。这是文本建模研究的标准数据集,在一些最初的主题模型研究中被使用。下载数据后,我们可以通过运行以下代码加载它:

>>> from gensim import corpora, models
>>> corpus = corpora.BleiCorpus('./data/ap/ap.dat',
    './data/ap/vocab.txt')

corpus 变量保存所有文本文档,并已将它们加载为易于处理的格式。我们现在可以使用这个对象作为输入构建主题模型:

>>> model = models.ldamodel.LdaModel(
 corpus,
 num_topics=100,
 id2word=corpus.id2word)

这个单一的构造函数调用将统计推断出语料库中存在的主题。我们可以通过多种方式来探索生成的模型。我们可以使用model[doc]语法查看文档涉及的主题列表,如下所示的示例:

 >>> doc = corpus.docbyoffset(0)
 >>> topics = model[doc]
 >>> print(topics)
[(3, 0.023607255776894751),
 (13, 0.11679936618551275),
 (19, 0.075935855202707139),
....
 (92, 0.10781541687001292)]

结果在我们的计算机上几乎肯定会有所不同!学习算法使用了一些随机数,每次你在相同的输入数据上学习新的主题模型时,结果都会不同。如果数据表现得比较规范,模型的一些定性属性会在不同的运行中保持稳定。例如,如果你使用主题来比较文档,就像我们在这里做的那样,那么相似性应该是稳健的,只会稍微变化。另一方面,不同主题的顺序将完全不同。

结果的格式是一个由对组成的列表:(topic_index, topic_weight)。我们可以看到,每个文档仅使用了少数几个主题(在前面的示例中,主题 0、1 和 2 的权重为零;这些主题的权重为 0)。主题模型是一个稀疏模型,虽然有很多可能的主题,但对于每个文档,只使用其中的少数几个。这并不完全准确,因为所有主题在 LDA 模型中都有非零概率,但其中一些概率非常小,我们可以将其四舍五入为零,作为一个较好的近似值。

我们可以通过绘制每个文档涉及的主题数量的直方图来进一步探索这一点:

>>> num_topics_used = [len(model[doc]) for doc in corpus]
>>> plt.hist(num_topics_used)

你将得到以下图表:

构建主题模型

提示

稀疏性意味着尽管你可能有大的矩阵和向量,但原则上,大多数值是零(或非常小,以至于我们可以将它们四舍五入为零,作为一个较好的近似)。因此,任何给定时刻,只有少数几件事是相关的。

经常看起来无法解决的问题实际上是可行的,因为数据是稀疏的。例如,尽管任何网页都可以链接到其他任何网页,但链接图实际上是非常稀疏的,因为每个网页只会链接到所有其他网页的极小一部分。

在前面的图表中,我们可以看到大约 150 篇文档涉及 5 个主题,而大多数文档涉及大约 10 到 12 个主题。没有任何文档讨论超过 20 个不同的主题。

在很大程度上,这是由于所使用参数的值,特别是alpha参数。alpha的确切含义有些抽象,但较大的alpha值将导致每个文档涉及更多的主题。

Alpha 需要大于零,但通常设定为较小的值,通常小于 1。alpha的值越小,每个文档预期讨论的主题就越少。默认情况下,gensim 会将alpha设置为1/num_topics,但你可以通过在LdaModel构造函数中显式传递它作为参数来设置它,如下所示:

>>> model = models.ldamodel.LdaModel(
 corpus,
 num_topics=100,
 id2word=corpus.id2word,
 alpha=1)

在这种情况下,这是一个比默认值更大的 alpha 值,这应该会导致每个文档包含更多的主题。正如我们在接下来的合并直方图中看到的那样,gensim 按照我们的预期表现,给每个文档分配了更多的主题:

构建主题模型

现在,我们可以在前面的直方图中看到,许多文档涉及 20 到 25 个不同的主题。如果你设置一个较低的值,你将看到相反的情况(从在线仓库下载代码将允许你调整这些值)。

这些是什么主题?从技术上讲,正如我们之前讨论过的,它们是关于单词的多项式分布,这意味着它们为词汇表中的每个单词分配一个概率。高概率的单词与该主题的关联性大于低概率的单词。

我们的大脑并不擅长处理概率分布的推理,但我们能够轻松理解一系列单词。因此,通常通过列出最重要的单词来总结主题。

在下表中,我们展示了前十个主题:

主题编号 主题
1 穿着军装的苏联总统新国家领袖立场政府
2 科赫赞比亚卢萨卡一党橙色科赫党我政府市长新政治
3 人权土耳其虐待皇家汤普森威胁新国家写的花园总统
4 法案雇员实验莱文税收联邦措施立法参议院总统举报人赞助
5 俄亥俄州七月干旱耶稣灾难百分比哈特福德密西西比作物北部山谷弗吉尼亚
6 联合百分比十亿年总统世界年美国人民我布什新闻
7 b hughes 宣誓书声明联合盎司平方英尺护理延迟被指控不现实布什
8 约特杜卡基斯布什大会农场补贴乌拉圭百分比秘书长我告诉
9 克什米尔政府人民斯里那加印度倾倒城市两座查谟克什米尔集团穆斯林巴基斯坦
10 工人越南爱尔兰工资移民百分比谈判最后岛屿警察赫顿

尽管乍一看令人望而生畏,但当我们浏览这些单词列表时,我们可以清楚地看到这些主题并非随机的单词,而是逻辑分组。我们还可以看到,这些主题与苏联仍然存在且戈尔巴乔夫是其***时的旧新闻相关。我们还可以将这些主题表示为词云,使得高频词更加突出。例如,这是一个涉及中东和政治的主题的可视化:

构建主题模型

我们还可以发现,某些词可能应该被去除(例如,“I”),因为它们并不提供太多信息,它们是停用词。在构建主题模型时,过滤停用词是非常有用的,否则,你可能会得到一个完全由停用词组成的主题。我们也可能希望对文本进行预处理,提取词干,以标准化复数形式和动词形式。这个过程在上一章已经讲过,您可以参考那一章获取详细信息。如果你有兴趣,可以从本书的配套网站下载代码,尝试这些不同的变体来绘制不同的图像。

注意

构建像前面那样的词云可以通过几种不同的软件完成。对于本章中的图形,我们使用了一个基于 Python 的工具叫做 pytagcloud。这个包需要安装一些依赖项,并且与机器学习没有直接关系,因此我们在正文中不会讨论它;然而,我们将所有的代码都放在了在线代码库中,供大家生成本章中的图形。

按主题比较文档

主题本身就可以用来构建像前面截图中所展示的那种小型文字片段。这些可视化可以用来浏览大量文档。例如,一个网站可以展示不同的主题作为不同的词云,让用户点击以进入相关文档。事实上,词云就是以这种方式被用来分析大量文档的。

然而,主题通常只是通向另一个目标的中间工具。现在我们已经估算了每个文档中每个主题的占比,我们可以在主题空间中比较文档。这意味着,和逐字比较不同,我们认为两篇文档如果讨论的是相同的主题,那么它们就相似。

这非常强大,因为两篇共享很少相同单词的文档,实际上可能指的是相同的主题!它们可能只是用不同的表达方式提到相同的主题(例如,一篇文档可能写的是“美国总统”,而另一篇则用“巴拉克·奥巴马”)。

注意

主题模型本身就可以用来构建可视化并探索数据。它们也作为许多其他任务中的中间步骤非常有用。

到目前为止,我们可以重新进行上一章中的练习,通过使用主题来定义相似性,查找与输入查询最相似的帖子。此前,我们是通过直接比较文档的词向量来进行比较,现在我们可以通过比较它们的主题向量来进行比较。

为此,我们将把文档投影到主题空间。也就是说,我们希望得到一个主题向量,用来总结文档。如何执行这些类型的降维通常是一个重要任务,我们有一个专门的章节来讨论这个任务。暂时,我们只展示如何使用主题模型来完成这一任务;一旦为每个文档计算出主题,我们可以在其主题向量上执行操作,而不再考虑原始单词。如果主题有意义,它们可能比原始单词更具信息性。此外,这样做还可能带来计算上的优势,因为比较 100 个主题权重向量要比比较包含数千个术语的词汇向量更快。

使用 gensim,我们已经看到如何计算语料库中所有文档对应的主题。现在,我们将为所有文档计算这些主题,并将其存储在 NumPy 数组中,然后计算所有成对距离:

>>> from gensim import matutils
>>> topics = matutils.corpus2dense(model[corpus], num_terms=model.num_topics)

现在,topics是一个主题矩阵。我们可以使用 SciPy 中的pdist函数来计算所有的成对距离。也就是说,通过一次函数调用,我们可以计算出所有sum((topics[ti] – topics[tj])**2)的值:

>>> from scipy.spatial import distance
>>> pairwise = distance.squareform(distance.pdist(topics))

现在,我们将使用最后一个小技巧;我们将distance矩阵的对角元素设置为一个较大的值(它只需要大于矩阵中其他值即可):

>>> largest = pairwise.max()
>>> for ti in range(len(topics)):
...     pairwise[ti,ti] = largest+1

完成了!对于每个文档,我们可以轻松查找最相似的元素(这是一种邻近分类器):

 >>> def closest_to(doc_id):
 ...    return pairwise[doc_id].argmin()

注意

请注意,如果我们没有将对角元素设置为较大值,这将不起作用:该函数将始终返回相同的元素,因为它与自己最为相似(除非出现非常罕见的情况,即两个元素的主题分布完全相同,通常只有在它们完全相同的情况下才会发生)。

例如,下面是一个可能的查询文档(它是我们集合中的第二个文档):

From: geb@cs.pitt.edu (Gordon Banks)
Subject: Re: request for information on "essential tremor" and Indrol?

In article <1q1tbnINNnfn@life.ai.mit.edu> sundar@ai.mit.edu writes:

Essential tremor is a progressive hereditary tremor that gets worse
when the patient tries to use the effected member.  All limbs, vocal
cords, and head can be involved.  Inderal is a beta-blocker and
is usually effective in diminishing the tremor.  Alcohol and mysoline
are also effective, but alcohol is too toxic to use as a treatment.
--
------------------------------------------------------------------
----------
Gordon Banks  N3JXP      | "Skepticism is the chastity of the intellect, and
geb@cadre.dsl.pitt.edu   |  it is shameful to surrender it too soon."
  ----------------------------------------------------------------
------------

如果我们请求与closest_to(1)最相似的文档,我们会得到以下文档作为结果:

From: geb@cs.pitt.edu (Gordon Banks)
Subject: Re: High Prolactin

In article <93088.112203JER4@psuvm.psu.edu> JER4@psuvm.psu.edu (John E. Rodway) writes:
>Any comments on the use of the drug Parlodel for high prolactin in the blood?
>

It can suppress secretion of prolactin.  Is useful in cases of galactorrhea.
Some adenomas of the pituitary secret too much.

--
------------------------------------------------------------------
----------
Gordon Banks  N3JXP      | "Skepticism is the chastity of the intellect, and
geb@cadre.dsl.pitt.edu   |  it is shameful to surrender it too soon."

系统返回了同一作者讨论药物的帖子。

建模整个维基百科

虽然最初的 LDA 实现可能比较慢,限制了它们在小型文档集合中的使用,但现代算法在处理非常大的数据集时表现良好。根据 gensim 的文档,我们将为整个英文维基百科构建一个主题模型。这需要几个小时,但即使是笔记本电脑也能完成!如果使用集群计算机,我们可以大大加快速度,不过这类处理环境将在后续章节中讨论。

首先,我们从dumps.wikimedia.org下载整个维基百科的数据库文件。这个文件很大(目前超过 10 GB),因此可能需要一些时间,除非你的互联网连接非常快。然后,我们将使用 gensim 工具对其进行索引:

python -m gensim.scripts.make_wiki \
 enwiki-latest-pages-articles.xml.bz2 wiki_en_output

请在命令行中运行上一行,而不是在 Python shell 中运行。几个小时后,索引将保存在同一目录中。此时,我们可以构建最终的话题模型。这个过程与我们之前在小型 AP 数据集上的操作完全相同。我们首先导入一些包:

>>> import logging, gensim

现在,我们设置日志记录,使用标准的 Python 日志模块(gensim 用于打印状态消息)。这一步并非严格必要,但有更多的输出可以帮助我们了解发生了什么:

>>> logging.basicConfig(
 format='%(asctime)s : %(levelname)s : %(message)s',
 level=logging.INFO)

现在我们加载预处理后的数据:

>>> id2word = gensim.corpora.Dictionary.load_from_text(
 'wiki_en_output_wordids.txt')
>>> mm = gensim.corpora.MmCorpus('wiki_en_output_tfidf.mm')

最后,我们像之前一样构建 LDA 模型:

>>> model = gensim.models.ldamodel.LdaModel(
 corpus=mm,
 id2word=id2word,
 num_topics=100,
 update_every=1,
 chunksize=10000,
 passes=1)

这将再次花费几个小时。你将在控制台上看到进度,这可以给你一个大致的等待时间。

一旦完成,我们可以将话题模型保存到文件中,这样就不必重新执行它:

 >>> model.save('wiki_lda.pkl')

如果你退出会话并稍后再回来,你可以使用以下命令重新加载模型(自然地,首先要进行适当的导入):

 >>> model = gensim.models.ldamodel.LdaModel.load('wiki_lda.pkl')

model 对象可用于探索文档集合,并像我们之前一样构建 topics 矩阵。

我们可以看到,即使我们拥有比之前更多的文档(目前超过 400 万),这仍然是一个稀疏模型:

 >>> lens = (topics > 0).sum(axis=0)
 >>> print(np.mean(lens))
 6.41
 >>> print(np.mean(lens <= 10))
 0.941

因此,平均每个文档提到了 6.4 个话题,其中 94% 的文档提到 10 个或更少的话题。

我们可以询问维基百科中最常被提及的话题是什么。我们将首先计算每个话题的总权重(通过将所有文档中的权重加总),然后检索与最具权重话题相关的词语。此操作使用以下代码执行:

>>> weights = topics.sum(axis=0)
>>> words = model.show_topic(weights.argmax(), 64)

使用与之前相同的工具构建可视化,我们可以看到最常提及的话题与音乐相关,并且是一个非常连贯的话题。18% 的维基百科页面与这个话题部分相关(维基百科中 5.5% 的词汇分配给了这个话题)。看看下面的截图:

建模整个维基百科

注意

这些图表和数据是在书籍编写时获得的。由于维基百科在不断变化,你的结果可能会有所不同。我们预计趋势会相似,但细节可能会有所不同。

或者,我们可以查看最少被提及的话题:

 >>> words = model.show_topic(weights.argmin(), 64)

建模整个维基百科

最少被提及的话题较难解释,但它的许多高频词与东部国家的机场有关。只有 1.6% 的文档涉及到它,它仅占 0.1% 的词汇。

选择话题的数量

到目前为止,在本章中,我们使用了固定数量的主题进行分析,即 100 个。这是一个完全任意的数字,我们也可以选择使用 20 个或 200 个主题。幸运的是,对于许多应用场景来说,这个数字其实并不重要。如果你只是将这些主题作为一个中间步骤,正如我们之前在查找相似帖子时所做的那样,模型中使用的具体主题数量通常对系统的最终行为影响不大。这意味着,只要使用足够数量的主题,无论是 100 个主题还是 200 个主题,从该过程中得出的推荐结果不会有太大差异;100 个主题通常已经足够(而 20 个主题对于一般的文本文档集合来说太少)。设置alpha值也是如此。虽然调整它会改变主题,但最终结果对这种变化具有较强的鲁棒性。

提示

主题建模通常是为达成某个目标。这样一来,具体使用哪些参数值并不总是特别重要。不同的主题数量或alpha等参数值将导致系统的最终结果几乎完全相同。

另一方面,如果你打算直接探索主题,或者构建一个能够展示主题的可视化工具,那么你应该尝试不同的值,看看哪个值能为你提供最有用或最吸引人的结果。

另外,有一些方法可以根据数据集自动确定主题的数量。一个流行的模型叫做层次狄利克雷过程。同样,这背后的完整数学模型很复杂,超出了本书的讨论范围。不过,我们可以讲一个简化的故事:与 LDA 生成模型中预先固定主题的做法不同,层次狄利克雷过程中的主题是随着数据逐一生成的。每当作者开始写一篇新文档时,他们可以选择使用已有的主题,或者创建一个全新的主题。当已经创建了更多主题时,创建新主题的概率会下降,因为更倾向于复用已有的主题,但这种可能性始终存在。

这意味着我们拥有的文档越多,最终得到的主题也会越多。这是一个起初难以理解的陈述,但经过反思后,它完全合理。我们是在将文档分组,文档越多,我们能够划分得越细。如果我们只有少数新闻文章的例子,那么“体育”可能就是一个主题。然而,当我们有更多的文章时,我们开始将其拆分成不同的子类别:“冰球”、“足球”,等等。随着数据量的增多,我们甚至能区分细微的差别,像是关于特定球队或球员的文章。对人群也是如此。在一个背景差异较大的群体中,如果有几个“计算机人士”,你可能会将他们放在一起;如果是稍微大一点的群体,你会把程序员和系统管理员分开;而在现实世界中,我们甚至有不同的聚会,专门为 Python 和 Ruby 程序员提供。

层次狄利克雷过程HDP)在 gensim 中可用。使用它非常简单。为了适应我们为 LDA 编写的代码,我们只需将对gensim.models.ldamodel.LdaModel的调用替换为对HdpModel构造函数的调用,代码如下:

 >>> hdp = gensim.models.hdpmodel.HdpModel(mm, id2word)

就是这样(不过它需要更长的计算时间——没有免费的午餐)。现在,我们可以像使用 LDA 模型一样使用这个模型,区别在于我们不需要指定主题的数量。

总结

在本章中,我们讨论了主题建模。主题建模比聚类更灵活,因为这些方法允许每个文档部分地存在于多个组中。为了探索这些方法,我们使用了一个新包——gensim。

主题建模最初是在文本情况下开发的,并且更容易理解,但在计算机视觉一章中,我们将看到这些技术如何也能应用于图像。主题模型在现代计算机视觉研究中非常重要。事实上,与前几章不同,本章非常接近机器学习算法研究的前沿。原始的 LDA 算法发表于 2003 年的科学期刊,但 gensim 处理维基百科的能力是在 2010 年才开发出来的,而 HDP 算法则来自 2011 年。研究仍在继续,你可以找到许多变种和模型,它们有着一些很有趣的名字,比如印度自助餐过程(不要与中国餐馆过程混淆,后者是一个不同的模型),或者八金球分配(八金球是一种日本游戏,介于老丨虎丨机和弹球之间)。

我们现在已经走过了一些主要的机器学习模式:分类、聚类和主题建模。

在下一章中,我们将回到分类问题,但这次我们将探索高级算法和方法。

第五章:分类 – 检测差答案

现在我们能够从文本中提取有用的特征,我们可以开始挑战使用真实数据构建分类器。让我们回到我们在第三章中的虚拟网站,聚类 – 查找相关帖子,用户可以提交问题并获得答案。

对于那些问答网站的拥有者来说,保持发布内容的质量水平一直是一个持续的挑战。像 StackOverflow 这样的站点付出了巨大努力,鼓励用户通过多种方式为内容评分,并提供徽章和奖励积分,以鼓励用户在雕琢问题或编写可能的答案时付出更多精力。

一个特别成功的激励措施是提问者可以将他们问题的一个答案标记为已接受答案(同样,提问者标记答案时也会有激励措施)。这将为被标记答案的作者带来更多的积分。

对用户来说,能否在他输入答案时立即看到答案的好坏并不是非常有用?这意味着,网站会不断评估他的正在编写的答案,并提供反馈,指出答案是否显示出某些不好的迹象。这将鼓励用户在写答案时付出更多努力(提供代码示例?包括图片?),从而改善整个系统。

让我们在本章中构建这样的机制。

绘制我们的路线图

由于我们将使用非常嘈杂的真实数据构建一个系统,本章并不适合心智脆弱的人,因为我们不会得到一个能够达到 100%准确度的分类器的黄金解决方案;事实上,甚至人类有时也会不同意一个答案是否好(看看 StackOverflow 上一些评论就知道了)。相反,我们会发现,像这样的某些问题非常困难,以至于我们不得不在过程中调整我们的初步目标。但在这个过程中,我们将从最近邻方法开始,发现它在这个任务中并不好,然后切换到逻辑回归,并得到一个能够实现足够好预测质量的解决方案,尽管它只在一小部分答案上有效。最后,我们将花一些时间研究如何提取获胜者,并将其部署到目标系统上。

学习分类有价值的答案

在分类中,我们希望为给定的数据实例找到相应的类别,有时也称为标签。为了能够实现这一目标,我们需要回答两个问题:

  • 我们应如何表示数据实例?

  • 我们的分类器应具备什么样的模型或结构?

调整实例

在其最简单的形式下,在我们的案例中,数据实例是答案的文本,标签将是一个二进制值,表示提问者是否接受此文本作为答案。然而,原始文本对大多数机器学习算法来说是非常不方便的表示方式。它们需要数字化的数据。而我们的任务就是从原始文本中提取有用的特征,机器学习算法可以利用这些特征来学习正确的标签。

调整分类器

一旦我们找到了或收集了足够的(文本,标签)对,就可以训练一个分类器。对于分类器的底层结构,我们有很多种选择,每种都有优缺点。仅举几个更为突出的选择,包括逻辑回归、决策树、支持向量机(SVM)和朴素贝叶斯。在本章中,我们将对比上一章中的基于实例的方法——最近邻,与基于模型的逻辑回归。

获取数据

幸运的是,StackOverflow 背后的团队提供了 StackExchange 宇宙中大部分的数据,而 StackOverflow 属于这个宇宙,这些数据可以在 cc-wiki 许可下使用。在写本书时,最新的数据转储可以在archive.org/details/stackexchange找到。它包含了 StackExchange 系列所有问答站点的数据转储。对于 StackOverflow,你会找到多个文件,我们只需要其中的 stackoverflow.com-Posts.7z 文件,大小为 5.2 GB。

下载并解压后,我们有大约 26 GB 的 XML 格式数据,包含所有问题和答案,作为 root 标签下的各个 row 标签:

<?xml version="1.0" encoding="utf-8"?>
<posts>
...
 <row Id="4572748" PostTypeId="2" ParentId="4568987" CreationDate="2011-01-01T00:01:03.387" Score="4" ViewCount="" Body="&lt;p&gt;IANAL, but &lt;a href=&quot;http://support.apple.com/kb/HT2931&quot; rel=&quot;nofollow&quot;&gt;this&lt;/a&gt; indicates to me that you cannot use the loops in your application:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;...however, individual audio loops may
  not be commercially or otherwise
  distributed on a standalone basis, nor
  may they be repackaged in whole or in
  part as audio samples, sound effects
  or music beds.&quot;&lt;/p&gt;

  &lt;p&gt;So don't worry, you can make
  commercial music with GarageBand, you
  just can't distribute the loops as
  loops.&lt;/p&gt;
&lt;/blockquote&gt;
" OwnerUserId="203568" LastActivityDate="2011-01-01T00:01:03.387" CommentCount="1" />
…
</posts>

名称 类型 描述
Id 整数 这是唯一的标识符。

| PostTypeId | 整数 | 这是帖子的类别描述。对我们感兴趣的值如下:

  • 问题

  • 答案

其他值将被忽略。 |

ParentId 整数 这是该答案所属问题的唯一标识符(问题没有该字段)。
CreationDate 日期时间 这是提交日期。
Score 整数 这是该帖子的得分。
ViewCount 整数或空 这是该帖子被用户查看的次数。
Body 字符串 这是作为 HTML 编码文本的完整帖子内容。
OwnerUserId ID 这是帖子的唯一标识符。如果值为 1,则表示这是一个 Wiki 问题。
Title 字符串 这是问题的标题(答案没有该字段)。
AcceptedAnswerId ID 这是被接受的答案的 ID(答案没有该字段)。
CommentCount 整数 这是该帖子评论的数量。

将数据精简成易于处理的块

为了加速我们的实验阶段,我们不应该尝试在庞大的 XML 文件上评估我们的分类思路。相反,我们应该考虑如何将其裁剪,使得在保留足够代表性快照的同时,能够快速测试我们的思路。如果我们将 XML 过滤为例如 2012 年创建的 row 标签,我们仍然会得到超过 600 万个帖子(2,323,184 个问题和 4,055,999 个回答),这些足够我们目前挑选训练数据了。我们也不想在 XML 格式上进行操作,因为这会拖慢速度。格式越简单越好。这就是为什么我们使用 Python 的 cElementTree 解析剩余的 XML 并将其写出为制表符分隔的文件。

属性的预选择和处理

为了进一步减少数据量,我们当然可以删除那些我们认为对分类器区分好答案和差答案没有帮助的属性。但我们必须小心。虽然一些特征不会直接影响分类,它们仍然是必须保留的。

PostTypeId 属性,例如,用于区分问题和回答。它不会被选中作为特征,但我们需要它来过滤数据。

CreationDate 可能对确定提问和各个回答之间的时间跨度很有用,所以我们保留它。Score 作为社区评价的指标,当然也很重要。

相反,ViewCount 很可能对我们的任务没有任何帮助。即使它能帮助分类器区分好答案和差答案,我们在答案提交时也没有这个信息。舍弃它!

Body 属性显然包含了最重要的信息。由于它是编码的 HTML,我们需要将其解码为纯文本。

OwnerUserId 只有在我们考虑用户相关特征时才有用,而我们并不打算这样做。虽然我们在这里舍弃它,但我们鼓励你使用它来构建一个更好的分类器(也许可以与 stackoverflow.com-Users.7z 结合使用)。

Title 属性在这里被忽略,尽管它可能为问题提供更多的信息。

CommentCount 也被忽略。与 ViewCount 类似,它可能有助于分类器处理那些已经存在一段时间的帖子(更多评论 = 更模糊的帖子?)。然而,它不会在答案发布时对分类器产生帮助。

AcceptedAnswerId 类似于 Score,都是帖子质量的指示器。由于我们会按答案访问它,因此我们不会保留这个属性,而是创建一个新的属性 IsAccepted,对于答案它是 0 或 1,对于问题则被忽略(ParentId=-1)。

最终我们得到以下格式:

Id <TAB> ParentId <TAB> IsAccepted <TAB> TimeToAnswer <TAB> Score <TAB> Text

关于具体的解析细节,请参考so_xml_to_tsv.pychoose_instance.py。简单来说,为了加速处理,我们将数据分为两个文件:在meta.json中,我们存储一个字典,将帖子的Id值映射到除Text外的其他数据,并以 JSON 格式存储,这样我们就可以以正确的格式读取它。例如,帖子的得分将存储在meta[Id]['Score']中。在data.tsv中,我们存储IdText值,可以通过以下方法轻松读取:

 def fetch_posts():
 for line in open("data.tsv", "r"):
 post_id, text = line.split("\t")
 yield int(post_id), text.strip()

定义什么是好答案

在我们能够训练分类器来区分好答案和坏答案之前,我们必须先创建训练数据。到目前为止,我们只有一堆数据。我们还需要做的是定义标签。

当然,我们可以简单地使用IsAccepted属性作为标签。毕竟,它标记了回答问题的答案。然而,这只是提问者的看法。自然,提问者希望快速得到答案,并接受第一个最好的答案。如果随着时间推移,更多的答案被提交,其中一些可能比已经接受的答案更好。然而,提问者很少回去修改自己的选择。所以我们最终会得到许多已经接受的答案,其得分并不是最高的。

在另一个极端,我们可以简单地始终取每个问题中得分最好和最差的答案作为正例和负例。然而,对于那些只有好答案的问题,我们该怎么办呢?比如,一个得两分,另一个得四分。我们是否真的应该把得两分的答案当作负例,仅仅因为它是得分较低的答案?

我们应该在这些极端之间找到一个平衡。如果我们把所有得分高于零的答案作为正例,所有得分为零或更低的答案作为负例,我们最终会得到相当合理的标签:

>>> all_answers = [q for q,v in meta.items() if v['ParentId']!=-1]
>>> Y = np.asarray([meta[answerId]['Score']>0 for answerId in all_answers])

创建我们的第一个分类器

让我们从上一章的简单而美丽的最近邻方法开始。虽然它不如其他方法先进,但它非常强大:由于它不是基于模型的,它可以学习几乎任何数据。但这种美丽也伴随着一个明显的缺点,我们很快就会发现。

从 kNN 开始

这次我们不自己实现,而是从sklearn工具包中取用。分类器位于sklearn.neighbors中。让我们从一个简单的 2-近邻分类器开始:

>>> from sklearn import neighbors
>>> knn = neighbors.KNeighborsClassifier(n_neighbors=2)
>>> print(knn)
KNeighborsClassifier(algorithm='auto', leaf_size=30, metric='minkowski', n_neighbors=2, p=2, weights='uniform')

它提供与sklearn中所有其他估计器相同的接口:我们使用fit()来训练它,然后可以使用predict()来预测新数据实例的类别:

>>> knn.fit([[1],[2],[3],[4],[5],[6]], [0,0,0,1,1,1])
>>> knn.predict(1.5)
array([0])
>>> knn.predict(37)
array([1])
>>> knn.predict(3)
array([0])

为了获得类别概率,我们可以使用predict_proba()。在这个只有两个类别01的案例中,它将返回一个包含两个元素的数组:

>>> knn.predict_proba(1.5)
array([[ 1.,  0.]])
>>> knn.predict_proba(37)
array([[ 0.,  1.]])
>>> knn.predict_proba(3.5)
array([[ 0.5,  0.5]])

特征工程

那么,我们可以向分类器提供什么样的特征呢?我们认为什么特征具有最强的区分能力?

TimeToAnswer 已经存在于我们的 meta 字典中,但它单独使用可能不会提供太多价值。然后还有 Text,但以原始形式我们不能将其传递给分类器,因为特征必须是数值形式。我们将不得不做一些脏活(也很有趣!)从中提取特征。

我们可以做的是检查答案中 HTML 链接的数量,作为质量的代理指标。我们的假设是,答案中的超链接越多,表示答案质量越好,从而更有可能被点赞。当然,我们只想统计普通文本中的链接,而不是代码示例中的链接:

import re
code_match = re.compile('<pre>(.*?)</pre>',
 re.MULTILINE | re.DOTALL)
link_match = re.compile('<a href="http://.*?".*?>(.*?)</a>', 
 re.MULTILINE | re.DOTALL)
tag_match = re.compile('<[^>]*>', 
 re.MULTILINE | re.DOTALL)

def extract_features_from_body(s):
 link_count_in_code = 0
 # count links in code to later subtract them 
 for match_str in code_match.findall(s):
 link_count_in_code += len(link_match.findall(match_str))

 return len(link_match.findall(s)) – link_count_in_code

提示

对于生产系统,我们不希望使用正则表达式解析 HTML 内容。相反,我们应该依赖像 BeautifulSoup 这样优秀的库,它能够非常稳健地处理日常 HTML 中通常出现的各种奇怪情况。

有了这个基础,我们可以为每个答案生成一个特征。但在训练分类器之前,先看看我们将用什么来训练它。我们可以通过绘制新特征的频率分布来获得初步印象。这可以通过绘制每个值在数据中出现的百分比来完成。请查看以下图表:

工程特征

由于大多数帖子根本没有链接,我们现在知道仅凭这个特征无法构建一个好的分类器。尽管如此,我们仍然可以尝试它,先做一个初步估计,看看我们处于什么位置。

训练分类器

我们需要将特征数组与之前定义的标签 Y 一起传递给 kNN 学习器,以获得分类器:

X = np.asarray([extract_features_from_body(text) for post_id, text in
                fetch_posts() if post_id in all_answers])
knn = neighbors.KNeighborsClassifier()
knn.fit(X, Y)

使用标准参数,我们刚刚为我们的数据拟合了一个 5NN(即 k=5 的 NN)。为什么是 5NN?嗯,基于我们对数据的当前了解,我们真的不知道正确的 k 应该是多少。一旦我们有了更多的洞察力,就能更好地确定 k 的值。

测量分类器的性能

我们需要明确我们想要测量的内容。最简单的做法是计算测试集上的平均预测质量。这将产生一个介于 0(完全错误的预测)和 1(完美预测)之间的值。准确度可以通过 knn.score() 获得。

但正如我们在前一章中学到的,我们不仅要做一次,而是使用交叉验证,通过 sklearn.cross_validation 中现成的 KFold 类来实现。最后,我们将对每一折的测试集分数进行平均,并使用标准差来看它的变化:

from sklearn.cross_validation import KFold
scores = []

cv = KFold(n=len(X), k=10, indices=True)

for train, test in cv:
 X_train, y_train = X[train], Y[train]
 X_test, y_test = X[test], Y[test]
 clf = neighbors.KNeighborsClassifier()
 clf.fit(X, Y)
 scores.append(clf.score(X_test, y_test))

print("Mean(scores)=%.5f\tStddev(scores)=%.5f"\
 %(np.mean(scores), np.std(scores)))

这是输出结果:

Mean(scores)=0.50250    Stddev(scores)=0.055591

现在,这远远不能使用。只有 55% 的准确率,它与抛硬币的效果差不多。显然,帖子中的链接数量不是衡量帖子质量的一个好指标。所以,我们可以说,这个特征没有太多的区分能力——至少对于 k=5 的 kNN 来说是这样。

设计更多特征

除了使用超链接数量作为帖子质量的代理外,代码行数也可能是另一个不错的指标。至少它是一个很好的指示,说明帖子作者有兴趣回答问题。我们可以在<pre>…</pre>标签中找到嵌入的代码。一旦提取出来,我们应该在忽略代码行的情况下统计帖子的单词数:

def extract_features_from_body(s):
 num_code_lines = 0
    link_count_in_code = 0
 code_free_s = s

 # remove source code and count how many lines
 for match_str in code_match.findall(s):
 num_code_lines += match_str.count('\n')
 code_free_s = code_match.sub("", code_free_s)

 # Sometimes source code contains links, 
 # which we don't want to count
 link_count_in_code += len(link_match.findall(match_str))

 links = link_match.findall(s)
 link_count = len(links)
 link_count -= link_count_in_code
 html_free_s = re.sub(" +", " ", 
 tag_match.sub('',  code_free_s)).replace("\n", "")
 link_free_s = html_free_s

 # remove links from text before counting words
 for link in links:
 if link.lower().startswith("http://"):
 link_free_s = link_free_s.replace(link,'')

 num_text_tokens = html_free_s.count(" ")

 return num_text_tokens, num_code_lines, link_count

看着这些,我们注意到至少帖子的单词数量表现出更高的变异性:

设计更多特征

在更大的特征空间上训练能显著提高准确性:

Mean(scores)=0.59800    Stddev(scores)=0.02600

但即便如此,这仍然意味着我们大约会将 10 个帖子中的 4 个分类错。至少我们朝着正确的方向前进了。更多的特征带来了更高的准确性,这促使我们添加更多的特征。因此,让我们通过更多特征来扩展特征空间:

  • AvgSentLen:这个特征衡量的是一个句子的平均单词数。也许有一个规律是,特别好的帖子不会用过长的句子让读者大脑过载?

  • AvgWordLen:类似于AvgSentLen,这个特征衡量的是帖子中单词的平均字符数。

  • NumAllCaps:这个特征衡量的是帖子中以大写字母书写的单词数量,这通常被认为是糟糕的写作风格。

  • NumExclams:这个特征衡量的是感叹号的数量。

以下图表显示了平均句子和单词长度、以及大写字母单词和感叹号数量的值分布:

设计更多特征

有了这四个额外的特征,我们现在有七个特征来表示单个帖子。让我们看看我们的进展:

Mean(scores)=0.61400    Stddev(scores)= 0.02154

嗯,这很有意思。我们添加了四个新特征,却没有得到任何回报。怎么会这样呢?

要理解这一点,我们需要提醒自己 kNN 是如何工作的。我们的 5NN 分类器通过计算上述七个特征——LinkCountNumTextTokensNumCodeLinesAvgSentLenAvgWordLenNumAllCapsNumExclams——然后找到五个最接近的其他帖子。新帖子的类别就是这些最接近帖子类别中的多数。最近的帖子是通过计算欧氏距离来确定的(由于我们没有指定,分类器是使用默认的p=2参数初始化的,这是明可夫斯基距离中的参数)。这意味着所有七个特征被视为类似的。kNN 并没有真正学会,例如,NumTextTokens虽然有用,但远不如NumLinks重要。让我们考虑以下两个帖子 A 和 B,它们仅在以下特征上有所不同,并与新帖子进行比较:

帖子 链接数 文本词数
A 2 20
B 0 25
new 1 23

尽管我们认为链接比纯文本提供更多的价值,但帖子 B 会被认为与新帖子更相似,而不是帖子 A。

显然,kNN 在正确使用现有数据方面遇到了困难。

决定如何改进

为了改进这一点,我们基本上有以下几个选择:

  • 增加更多数据:也许学习算法的数据量不足,我们应该简单地增加更多的训练数据?

  • 调整模型复杂度:也许模型还不够复杂?或者它已经太复杂了?在这种情况下,我们可以减少k,使其考虑更少的最近邻,从而更好地预测不平滑的数据。或者我们可以增加k,以达到相反的效果。

  • 修改特征空间:也许我们没有合适的特征集?我们可以,例如,改变当前特征的尺度,或者设计更多的新特征。或者我们是否应该去除一些当前的特征,以防某些特征相互重复?

  • 改变模型:也许 kNN 在我们的用例中通常并不适用,因此无论我们如何允许其复杂化,如何提升特征空间,它都永远无法实现良好的预测性能?

在现实生活中,通常人们会尝试通过随机选择这些选项之一并按无特定顺序尝试它们来改善当前的性能,希望能偶然找到最佳配置。我们也可以这么做,但这肯定会比做出有根据的决策花费更长时间。让我们采取有根据的方法,为此我们需要引入偏差-方差权衡。

偏差-方差及其权衡

在第一章,开始使用 Python 机器学习中,我们尝试了用不同复杂度的多项式,通过维度参数d来拟合数据。我们意识到,二维多项式,一个直线,并不能很好地拟合示例数据,因为数据并非线性。无论我们如何精细化拟合过程,我们的二维模型都会将所有数据视为一条直线。我们说它对现有数据有过高的偏差。它是欠拟合的。

我们对维度进行了些许尝试,发现 100 维的多项式实际上很好地拟合了它所训练的数据(当时我们并不了解训练集-测试集拆分)。然而,我们很快发现它拟合得太好了。我们意识到它严重过拟合,以至于用不同的数据样本,我们会得到完全不同的 100 维多项式。我们说这个模型对于给定数据的方差太高,或者说它过拟合了。

这些是我们大多数机器学习问题所处的极端情况之间的两种极端。理想情况下,我们希望能够同时拥有低偏差和低方差。但我们处于一个不完美的世界,必须在二者之间做出权衡。如果我们改善其中一个,另一个可能会变得更差。

修正高偏差

现在假设我们遭遇高偏差。在这种情况下,增加更多的训练数据显然没有帮助。此外,去除特征肯定也没有帮助,因为我们的模型已经过于简单化。

在这种情况下,我们唯一的选择是获取更多特征、使模型更复杂,或者更换模型。

修复高方差

反之,如果我们遇到高方差,意味着我们的模型对于数据过于复杂。在这种情况下,我们只能尝试获取更多的数据或减少模型的复杂性。这意味着要增加k,让更多的邻居参与计算,或者去除一些特征。

高偏差或低偏差

要找出我们真正的问题所在,我们只需将训练和测试误差随着数据集大小绘制出来。

高偏差通常表现为测试误差在开始时略有下降,但随着训练数据集大小的增加,误差最终会稳定在一个很高的值。高方差则通过两条曲线之间的巨大差距来识别。

绘制不同数据集大小下 5NN 的误差图,显示训练误差和测试误差之间存在较大差距,暗示了一个高方差问题:

高偏差或低偏差

看着图表,我们立刻看到增加更多的训练数据没有帮助,因为对应于测试误差的虚线似乎保持在 0.4 以上。我们唯一的选择是降低复杂性,方法是增加k或减少特征空间。

在这里,减少特征空间没有帮助。我们可以通过将简化后的特征空间(仅包含LinkCountNumTextTokens)绘制成图来轻松确认这一点:

高偏差或低偏差

对于其他较小的特征集,我们得到的图形相似。无论我们选择哪个特征子集,图形看起来都差不多。

至少通过增加k来减少模型复杂性显示了一些积极的影响:

k mean(scores) stddev(scores)
40 0.62800 0.03750
10 0.62000 0.04111
5 0.61400 0.02154

但这还不够,并且也会导致较低的分类运行时性能。例如,以k=40为例,在这个情况下,我们有非常低的测试误差。要对一个新帖子进行分类,我们需要找到与该新帖子最接近的 40 个帖子,以决定这个新帖子是否是好帖子:

高偏差或低偏差

显然,似乎是使用最近邻方法在我们的场景中出现了问题。它还有另一个真正的缺点。随着时间的推移,系统中会加入越来越多的帖子。由于最近邻方法是基于实例的,我们必须在系统中存储所有的帖子。获取的数据越多,预测的速度就会变得越慢。这与基于模型的方法不同,后者试图从数据中推导出一个模型。

到这里,我们已经有足够的理由放弃最近邻方法,去寻找分类世界中更好的方法。当然,我们永远无法知道是否有我们没有想到的那个黄金特征。但现在,让我们继续研究另一种在文本分类场景中表现优秀的分类方法。

使用逻辑回归

与其名称相反,逻辑回归是一种分类方法。在文本分类中,它是一种非常强大的方法;它通过首先对逻辑函数进行回归,从而实现这一点,这也是其名称的由来。

一些数学与小示例

为了初步理解逻辑回归的工作原理,让我们首先看一下下面的示例,在该示例中,我们有人工特征值X,并与相应的类别 0 或 1 进行绘制。如我们所见,数据有噪声,因此在 1 到 6 的特征值范围内,类别是重叠的。因此,最好不是直接对离散类别进行建模,而是建模特征值属于类别 1 的概率,P(X)。一旦我们拥有了这样的模型,我们就可以在P(X)>0.5时预测类别 1,反之则预测类别 0。

一些数学与小示例

从数学上讲,建模一个具有有限范围的事物总是很困难的,就像我们这里的离散标签 0 和 1 一样。然而,我们可以稍微调整概率,使其始终保持在 0 和 1 之间。为此,我们需要赔率比率及其对数。

假设某个特征属于类别 1 的概率为 0.9,P(y=1) = 0.9。那么赔率比率为P(y=1)/P(y=0) = 0.9/0.1 = 9。我们可以说,这个特征属于类别 1 的机会是 9:1。如果P(y=0.5),我们将有 1:1 的机会,该实例属于类别 1。赔率比率的下限是 0,但可以趋向无限大(下图中的左图)。如果我们现在取其对数,就可以将所有概率从 0 到 1 映射到从负无穷到正无穷的完整范围(下图中的右图)。好处是,我们仍然保持了较高概率导致较高对数赔率的关系,只是不再局限于 0 和 1。

一些数学与小示例

这意味着我们现在可以将特征的线性组合拟合到一些数学与小示例值上(好吧,我们只有一个特征和一个常数,但这很快就会改变)。从某种意义上讲,我们用一些数学与小示例替代了第一章中的线性模型,使用 Python 进行机器学习入门,用一些数学与小示例(将y替换为log(odds))。

我们可以解出 p[i],这样我们就得到了!一些带有小示例的数学公式。

我们只需要找到合适的系数,使得公式对于数据集中的所有(x[i], p[i])对能够给出最低的误差,而这将通过 scikit-learn 来完成。

拟合后,公式将为每个新的数据点 x 计算属于类别 1 的概率:

>>> from sklearn.linear_model import LogisticRegression
>>> clf = LogisticRegression()
>>> print(clf)
LogisticRegression(C=1.0, class_weight=None, dual=False, fit_intercept=True, intercept_scaling=1, penalty=l2, tol=0.0001)
>>> clf.fit(X, y)
>>> print(np.exp(clf.intercept_), np.exp(clf.coef_.ravel()))
[ 0.09437188] [ 1.80094112]
>>> def lr_model(clf, X):
...     return 1 / (1 + np.exp(-(clf.intercept_ + clf.coef_*X)))
>>> print("P(x=-1)=%.2f\tP(x=7)=%.2f"%(lr_model(clf, -1), lr_model(clf, 7)))
P(x=-1)=0.05    P(x=7)=0.85

你可能已经注意到,scikit-learn 通过特殊字段 intercept_ 展示了第一个系数。

如果我们绘制拟合的模型,我们会看到,考虑到数据,模型完全有意义:

一些带有小示例的数学公式

将逻辑回归应用于我们的帖子分类问题

毋庸置疑,上一节中的示例是为了展示逻辑回归的美妙。它在真实的、噪声较大的数据上表现如何?

与最佳最近邻分类器(k=40)作为基准进行比较,我们可以看到它的表现稍微好一些,但也不会有太大变化。

方法 平均(得分) 标准差(得分)
LogReg C=0.1 0.64650 0.03139
LogReg C=1.00 0.64650 0.03155
LogReg C=10.00 0.64550 0.03102
LogReg C=0.01 0.63850 0.01950
40NN 0.62800 0.03750

我们已经展示了不同正则化参数 C 值下的准确度。通过它,我们可以控制模型的复杂性,类似于最近邻方法中的参数 k。较小的 C 值会对模型复杂性进行更多的惩罚。

快速查看我们最佳候选之一(C=0.1)的偏差-方差图表,我们发现模型具有较高的偏差——测试和训练误差曲线接近,但都保持在不可接受的高值。这表明,在当前特征空间下,逻辑回归存在欠拟合,无法学习出能够正确捕捉数据的模型:

将逻辑回归应用于我们的帖子分类问题

那么接下来怎么办呢?我们更换了模型,并尽我们当前的知识调整了它,但仍然没有得到一个可接受的分类器。

越来越多的迹象表明,要么数据对于这个任务来说太嘈杂,要么我们的特征集仍然不足以足够好地区分类别。

探讨准确率背后的精确度和召回率

让我们退后一步,再次思考我们在这里试图实现的目标。实际上,我们并不需要一个能够完美预测好坏答案的分类器,至少我们用准确率来衡量时并不需要。如果我们能够调优分类器,使其在预测某一类时特别准确,我们就可以根据用户的反馈进行相应的调整。例如,如果我们有一个分类器,每次预测答案是坏的时都非常准确,那么在分类器检测到答案为坏之前,我们将不给予任何反馈。相反,如果分类器在预测答案为好时特别准确,我们可以在一开始给用户显示有帮助的评论,并在分类器确认答案是好时将这些评论移除。

要了解我们当前的情况,我们需要理解如何衡量精确度和召回率。为了理解这一点,我们需要查看下表中描述的四种不同的分类结果:

被分类为
正类 负类
--- ---
实际情况是 正类
负类 假阳性(FP)

例如,如果分类器预测某个实例为正,而该实例在现实中确实为正,那么这是一个真正的正例。如果分类器错误地将该实例分类为负,而实际上它是正的,那么这个实例就是一个假阴性。

我们希望在预测某个帖子是好是坏时能够有较高的成功率,但不一定要求两者都正确。也就是说,我们希望尽可能多地获得真正的正例。这就是精确度所衡量的内容:

看准确率背后的内容 - 精确度与召回率

如果我们的目标是尽可能多地检测出好的或坏的答案,我们可能会更关注召回率:

看准确率背后的内容 - 精确度与召回率

在下面的图表中,精确度是右侧圆的交集部分的比例,而召回率则是左侧圆的交集部分的比例:

看准确率背后的内容 - 精确度与召回率

那么,如何优化精确度呢?到目前为止,我们总是使用 0.5 作为阈值来判断一个答案是好是坏。我们现在可以做的是在该阈值从 0 到 1 之间变化时,计算 TP、FP 和 FN 的数量。然后,基于这些计数,我们可以绘制精确度与召回率的关系曲线。

来自 metrics 模块的便捷函数 precision_recall_curve() 可以为我们完成所有的计算:

>>> from sklearn.metrics import precision_recall_curve
>>> precision, recall, thresholds = precision_recall_curve(y_test,
    clf.predict(X_test))

预测某一类的表现良好并不总意味着分类器在预测另一类时也能达到同样的水平。以下两个图表展示了这种现象,我们分别为分类坏(左图)和好(右图)答案绘制了精确度/召回率曲线:

看准确率背后的内容 - 精确度与召回率

提示

在图表中,我们还包含了一个更好的分类器性能描述——曲线下的面积AUC)。它可以理解为分类器的平均精度,是比较不同分类器的一个很好的方法。

我们看到,在预测不良答案(左图)时,我们基本上可以忽略。精度降到非常低的召回率,并保持在不可接受的 60%。

然而,预测正确答案表明,当召回率接近 40%时,我们可以获得超过 80%的精度。让我们找出达到该结果所需的阈值。由于我们在不同的折叠上训练了许多分类器(记住,我们在前几页中使用了KFold()),我们需要检索那个既不差也不太好的分类器,以便获得现实的视角。我们称之为中等克隆:

>>> medium = np.argsort(scores)[int(len(scores) / 2)]
>>> thresholds = np.hstack(([0],thresholds[medium]))
>>> idx80 = precisions>=0.8
>>> print("P=%.2f R=%.2f thresh=%.2f" % (precision[idx80][0], recall[idx80][0], threshold[idx80][0]))
P=0.80 R=0.37 thresh=0.59

将阈值设置为0.59时,我们看到在接受 37%的低召回率时,仍然可以在检测到优秀答案时达到 80%的精度。这意味着我们将仅检测出三分之一的优秀答案。但对于我们能够检测出的那三分之一的优秀答案,我们可以合理地确定它们确实是优秀的。对于其余的答案,我们可以礼貌地提供如何改进答案的一些额外提示。

要在预测过程中应用此阈值,我们必须使用predict_proba(),该方法返回每个类别的概率,而不是返回类别本身的predict()

>>> thresh80 = threshold[idx80][0]
>>> probs_for_good = clf.predict_proba(answer_features)[:,1]
>>> answer_class = probs_for_good>thresh80

我们可以使用classification_report来确认我们处于期望的精度/召回范围内:

>>> from sklearn.metrics import classification_report
>>> print(classification_report(y_test, clf.predict_proba [:,1]>0.63, target_names=['not accepted', 'accepted']))

 precision    recall  f1-score   support
not accepted         0.59      0.85      0.70       101
accepted             0.73      0.40      0.52        99
avg / total          0.66      0.63      0.61       200

提示

请注意,使用阈值并不能保证我们总是能够超过上述所确定的精度和召回值以及其阈值。

精简分类器

总是值得查看各个特征的实际贡献。对于逻辑回归,我们可以直接使用已学习的系数(clf.coef_)来了解特征的影响。特征的系数越大,说明该特征在确定帖子是否优秀时所起的作用越大。因此,负系数告诉我们,对于相应特征的较高值意味着该帖子被分类为不好的信号更强。

精简分类器

我们看到LinkCountAvgWordLenNumAllCapsNumExclams对整体分类决策影响最大,而NumImages(这是我们刚才为了演示目的偷偷加入的特征)和AvgSentLen的作用较小。虽然整体特征重要性直观上是有道理的,但令人惊讶的是NumImages几乎被忽略了。通常,包含图片的答案总是被评价为高质量。但实际上,答案中很少有图片。因此,尽管从原则上讲,这是一个非常强大的特征,但由于它太稀疏,无法提供任何价值。我们可以轻松地删除该特征并保持相同的分类性能。

发货!

假设我们想将这个分类器集成到我们的网站中。我们绝对不希望每次启动分类服务时都重新训练分类器。相反,我们可以在训练后将分类器序列化,然后在网站上进行反序列化:

>>> import pickle
>>> pickle.dump(clf, open("logreg.dat", "w"))
>>> clf = pickle.load(open("logreg.dat", "r"))

恭喜,现在分类器已经可以像刚训练完一样投入使用了。

总结

我们做到了!对于一个非常嘈杂的数据集,我们构建了一个符合我们目标部分的分类器。当然,我们必须务实地调整最初的目标,使其变得可实现。但在这个过程中,我们了解了最近邻算法和逻辑回归的优缺点。我们学会了如何提取特征,如LinkCountNumTextTokensNumCodeLinesAvgSentLenAvgWordLenNumAllCapsNumExclamsNumImages,并分析它们对分类器性能的影响。

但更有价值的是,我们学会了一种明智的方法来调试表现不佳的分类器。这将帮助我们在未来更快速地构建出可用的系统。

在研究了最近邻算法和逻辑回归之后,在下一章中,我们将熟悉另一个简单而强大的分类算法:朴素贝叶斯。同时,我们还将学习一些来自 scikit-learn 的更方便的工具。

第六章:分类 II – 情感分析

对于公司而言,密切监控重大事件的公众反应至关重要,如产品发布或新闻稿。借助 Twitter 的实时访问和用户生成内容的易获取性,现在可以对推文进行情感分类。情感分析有时也称为意见挖掘,它是一个活跃的研究领域,许多公司已经在销售此类服务。由于这表明市场显然存在,我们有动力使用在上一章中构建的分类技术,来构建我们自己的情感分类器。

绘制我们的路线图

推文的情感分析特别困难,因为 Twitter 对字符数的限制为 140 个字符。这导致了特殊的语法、创造性的缩写,并且句子通常不完整。分析句子的典型方法是汇总段落中的情感信息,然后计算文档的整体情感,这种方法在这里行不通。

很显然,我们并不会尝试构建一个最先进的情感分类器。相反,我们的目标是:

  • 使用这个场景作为引入另一个分类算法 朴素贝叶斯 的载体

  • 解释 词性标注 (POS) 如何工作以及它如何帮助我们

  • 展示一些来自 scikit-learn 工具箱的其他小技巧,这些技巧不时会派上用场

获取 Twitter 数据

自然地,我们需要推文及其相应的标签,以判断一条推文是包含积极、消极还是中性情感。在本章中,我们将使用 Niek Sanders 提供的语料库,他手动标注了超过 5,000 条推文,并已授权我们在本章中使用这些数据。

为了遵守 Twitter 的服务条款,我们不会提供任何来自 Twitter 的数据,也不会展示任何真实的推文。相反,我们可以使用 Sanders 的手动标注数据,其中包含推文 ID 及其手动标注的情感,并使用他的脚本 install.py 获取相应的 Twitter 数据。由于该脚本与 Twitter 服务器兼容,因此下载超过 5,000 条推文的数据需要相当长的时间。所以,最好立刻开始运行它。

数据包含四个情感标签:

>>> X, Y = load_sanders_data()
>>> classes = np.unique(Y)
>>> for c in classes: print("#%s: %i" % (c, sum(Y==c)))
#irrelevant: 490
#negative: 487
#neutral: 1952
#positive: 433

load_sanders_data() 函数中,我们将不相关和中性标签一起处理为中性,并删除所有非英语推文,最终得到 3,362 条推文。

如果你在这里获得不同的计数,那是因为在此期间,推文可能被删除或设置为私人状态。在这种情况下,你也可能会看到与接下来的章节所展示的数字和图表略有不同。

介绍朴素贝叶斯分类器

朴素贝叶斯可能是最优雅的机器学习算法之一,并且具有实际应用价值。尽管它的名字里有“朴素”二字,但从它的分类表现来看,它并不那么“朴素”。它对无关特征表现出强大的鲁棒性,能够巧妙地忽略它们。它学习速度快,预测也同样迅速。它不需要大量存储。那么,为什么它被称为“朴素”呢?

朴素一词是为了表示朴素贝叶斯所依赖的一个假设,这个假设是特征之间相互独立。实际上,特征之间很少完全独立,这也是现实世界应用中的常见问题。然而,即便假设不成立,朴素贝叶斯在实践中仍能提供很高的准确性。

了解贝叶斯定理

朴素贝叶斯分类的核心不过是记录每个特征给出哪一类的证据。特征的设计决定了使用哪种模型进行学习。所谓的伯努利模型只关注布尔特征:一个单词是否在推文中出现一次或多次并不重要。相反,多项式模型使用单词计数作为特征。为了简化起见,我们将使用伯努利模型来解释如何使用朴素贝叶斯进行情感分析。接下来,我们将使用多项式模型来设置和调优我们的实际分类器。

让我们假设以下变量含义,用以解释朴素贝叶斯:

变量 含义
了解贝叶斯定理 这是推文的类别(正面或负面)
了解贝叶斯定理 单词“awesome”至少出现在推文中一次
了解贝叶斯定理 单词“crazy”至少出现在推文中一次

在训练过程中,我们学习了朴素贝叶斯模型,这是在已知特征的情况下,某个类别的概率!了解贝叶斯定理。这个概率可以写作!了解贝叶斯定理。

由于我们无法直接估计!了解贝叶斯定理,我们采用了一个技巧,这是贝叶斯发现的:

了解贝叶斯定理

如果我们将!了解贝叶斯定理替换为“awesome”和“crazy”两个单词的概率,并将!了解贝叶斯定理视为我们的类别!了解贝叶斯定理,我们可以得到这个关系,它帮助我们后来推导数据实例属于指定类别的概率:

了解贝叶斯定理

这使得我们可以通过其他概率来表达 了解贝叶斯定理

了解贝叶斯定理

我们也可以将其描述为:

了解贝叶斯定理

先验证据 容易确定:

  • 了解贝叶斯定理 是类别 了解贝叶斯定理 的先验概率,在不知道数据的情况下。我们可以通过简单地计算所有训练数据实例中属于该类别的比例来估计这个量。

  • 了解贝叶斯定理 是特征的证据或概率 了解贝叶斯定理了解贝叶斯定理

难点在于似然度的计算 了解贝叶斯定理。它是描述在已知数据实例的类别是 了解贝叶斯定理 的情况下,看到特征值 了解贝叶斯定理了解贝叶斯定理 的可能性。要估计这个,我们需要做一些思考。

天真假设

从概率理论中,我们还知道以下关系:

天真假设

然而,单靠这一点并没有太大帮助,因为我们用另一个困难问题(估计 天真假设)来处理一个问题(估计 天真假设)。

然而,如果我们天真地假设 天真假设天真假设 彼此独立,那么 天真假设 简化为 天真假设,我们可以将其写为:

天真假设

将一切合并在一起,我们得到一个相当简洁的公式:

天真假设

有趣的是,尽管从理论上讲,在我们有心情时随意调整假设并不正确,但在这种情况下,实际应用中它的效果出奇地好。

使用朴素贝叶斯进行分类

给定一个新的推文,剩下的唯一任务就是简单地计算概率:

使用朴素贝叶斯进行分类使用朴素贝叶斯进行分类

然后选择概率较高的类别 使用朴素贝叶斯进行分类

由于对于两个类别,分母 使用朴素贝叶斯进行分类 是相同的,我们可以忽略它而不改变最终类别。

然而,请注意,我们现在不再计算任何真实的概率。相反,我们在估算给定证据下,哪个类别更有可能。这也是朴素贝叶斯如此稳健的另一个原因:它更关心的是哪个类别更有可能,而不是实际的概率。简而言之,我们可以写:

使用朴素贝叶斯分类

这只是说明我们正在计算argmax之后的部分,针对所有类别使用朴素贝叶斯分类(在我们的例子中是posneg),并返回得到最高值的类别。

但是,对于以下示例,我们将坚持使用真实的概率并进行一些计算,以便看看朴素贝叶斯是如何工作的。为了简化起见,我们将假设 Twitter 只允许使用之前提到的两个单词:“awesome”和“crazy”,并且我们已经手动分类了一些推文:

推文 类别
awesome 正面推文
awesome 正面推文
awesome crazy 正面推文
crazy 正面推文
crazy 负面推文
crazy 负面推文

在这个例子中,我们将“crazy”这条推文同时放在正面和负面推文中,以模拟现实世界中经常会遇到的一些模糊情况(例如,“热衷足球”与“疯狂的傻瓜”)。

在这个例子中,我们共有六条推文,其中四条是正面的,二条是负面的,得出的先验概率如下:

使用朴素贝叶斯分类使用朴素贝叶斯分类

这意味着,在不了解推文本身的任何信息的情况下,假设这条推文是正面推文是明智的。

仍然缺少的部分是计算使用朴素贝叶斯分类使用朴素贝叶斯分类,这些是条件概率,分别针对两个特征使用朴素贝叶斯分类使用朴素贝叶斯分类,并且是基于类别使用朴素贝叶斯分类计算的。

这是通过计算我们见过具体特征的推文数,再除以已被标记为类别使用朴素贝叶斯分类的推文总数来得出的。假设我们想知道在已知推文类别为正面的情况下,出现“awesome”的概率,我们将得到:

使用朴素贝叶斯分类

因为四条正面推文中有三条包含“awesome”这个词。显然,正面推文中不包含“awesome”的概率就是它的逆:

使用朴素贝叶斯分类

类似地,对于其余情况(省略没有出现该词的推文):

使用朴素贝叶斯分类使用朴素贝叶斯分类使用朴素贝叶斯分类

为了完整性,我们还将计算证据,以便在接下来的示例推文中看到实际概率。对于使用朴素贝叶斯进行分类使用朴素贝叶斯进行分类这两个具体值,我们可以按如下方式计算证据:

使用朴素贝叶斯进行分类使用朴素贝叶斯进行分类

这导致了以下值:

使用朴素贝叶斯进行分类使用朴素贝叶斯进行分类使用朴素贝叶斯进行分类

现在我们有了所有的数据来分类新的推文。剩下的工作就是解析推文并提取特征:

推文 使用朴素贝叶斯进行分类 使用朴素贝叶斯进行分类 类别概率 分类
"极棒" 1 0 使用朴素贝叶斯进行分类使用朴素贝叶斯进行分类 正面
"疯狂" 0 1 使用朴素贝叶斯进行分类使用朴素贝叶斯进行分类 负面
"极棒 疯狂" 1 1 使用朴素贝叶斯进行分类使用朴素贝叶斯进行分类 正面

到目前为止,一切顺利。对于简单的推文分类,似乎能够给推文分配正确的标签。然而,问题仍然是,我们应该如何处理那些在训练语料库中没有出现过的词?毕竟,使用前面的公式,新词总是会被分配零的概率。

考虑到未见过的词和其他异常情况

当我们之前计算概率时,实际上我们是在自欺欺人。我们并没有计算真正的概率,而只是通过分数来进行粗略的估算。我们假设训练语料库会告诉我们关于真实概率的全部真相,但事实并非如此。仅仅六条推文的语料库显然不能告诉我们所有关于曾经写过的推文的信息。例如,肯定有包含“文本”一词的推文,只是我们从未见过它们。显然,我们的估算非常粗略,我们应该对此加以考虑。在实践中,这通常通过所谓的加一平滑来实现。

提示

加一平滑有时也被称为加性平滑拉普拉斯平滑。注意,拉普拉斯平滑与拉普拉斯算子平滑无关,后者是与多边形网格的平滑相关的。如果我们不是通过1来平滑,而是通过可调参数alpha<0来平滑,那就叫做 Lidstone 平滑。

这是一种非常简单的技术,它为所有特征出现次数加 1。其基本假设是,即使我们在整个语料库中没有见过某个单词,也有可能是我们的推文样本刚好没有包含这个单词。所以,通过加一平滑,我们假装每个出现的单词比实际出现的次数多见了一次。这意味着,我们现在计算的不是计算未见过的单词和其他异常情况,而是计算未见过的单词和其他异常情况

为什么我们在分母中加 2?因为我们有两个特征:“awesome”和“crazy”的出现。由于每个特征加 1,我们必须确保最终结果仍然是一个概率。事实上,我们得到的总概率为 1:

计算未见过的单词和其他异常情况

计算算术下溢

还有一个障碍。实际上,我们处理的概率要比在玩具示例中遇到的要小得多。通常,我们也有比仅仅两个特征更多的特征,而这些特征需要相互相乘。这将很快导致 NumPy 提供的精度不再足够:

>>> import numpy as np
>>> np.set_printoptions(precision=20) # tell numpy to print out more digits (default is 8)
>>> np.array([2.48E-324])
array([ 4.94065645841246544177e-324])
>>> np.array([2.47E-324])
array([ 0.])

那么,2.47E-324 这样的数字到底有多可能出现呢?为了回答这个问题,我们只需要想象一个条件概率为 0.0001,然后将其中 65 个概率相乘(意味着我们有 65 个低概率特征值),你就会遇到算术下溢:

>>> x = 0.00001
>>> x**64 # still fine
1e-320
>>> x**65 # ouch
0.0

Python 中的浮点数通常使用 C 中的 double 类型实现。要检查你的平台是否是这样,你可以通过以下方式进行确认:

>>> import sys
>>> sys.float_info
sys.float_info(max=1.7976931348623157e+308, max_exp=1024, max_10_exp=308, min=2.2250738585072014e-308, min_exp=-1021, min_10_exp=-307, dig=15, mant_dig=53, epsilon=2.220446049250313e-16, radix=2, rounds=1)

为了缓解这个问题,可以切换到诸如mpmathcode.google.com/p/mpmath/)这样的数学库,它们支持任意精度。然而,它们的速度不够快,无法替代 NumPy。

幸运的是,有一个更好的方法可以解决这个问题,这与我们可能还记得的一个学校里的好关系有关:

计算算术下溢

如果我们将其应用到我们的案例中,我们得到如下结果:

计算算术下溢

由于概率位于 0 和 1 之间,概率的对数则位于-∞和 0 之间。别为这个困扰。较大的数值仍然是正确类别的更强指示——只是现在它们变成了负数。

计算算术下溢

但有一个注意事项:公式的分子部分(即分数上方的部分)实际上并没有对数。我们只有概率的乘积。幸运的是,在我们的案例中,我们并不关心概率的实际值。我们只是想知道哪个类别具有最高的后验概率。幸运的是,如果我们发现处理算术下溢,那么我们也总是能得到处理算术下溢

快速查看前面的图表可以发现曲线是单调递增的,即从左到右时,曲线永远不会下降。所以让我们把这个带入前面提到的公式:

处理算术下溢

这将最终得到适用于两个特征的公式,它将为我们提供最佳类别,适用于我们在实践中看到的实际数据:

处理算术下溢

当然,只有两个特征的话,我们不会非常成功,所以,让我们重写代码以允许任意数量的特征:

处理算术下溢

就这样,我们准备好使用来自 scikit-learn 工具包的第一个分类器。

如前所述,我们刚刚学习了 Naïve Bayes 的伯努利模型。与布尔特征不同,我们也可以使用单词出现次数,即多项式模型。由于这个方法提供了更多的信息,通常也能带来更好的性能,因此我们会在实际数据中使用这个模型。不过,注意的是,底层的公式会有一些变化。不过,不用担心,Naïve Bayes 的基本原理依然不变。

创建我们的第一个分类器并进行调优

Naïve Bayes 分类器位于 sklearn.naive_bayes 包中。这里有不同种类的 Naïve Bayes 分类器:

  • GaussianNB:这个分类器假设特征呈正态分布(高斯分布)。它的一个应用场景可能是根据身高和体宽来判断性别。在我们的例子中,我们有推文文本,从中提取词频。这些显然不是高斯分布的。

  • MultinomialNB:这个分类器假设特征是出现次数,这正是我们接下来使用的情况,因为我们将在推文中使用词频作为特征。实际上,这个分类器也能很好地处理 TF-IDF 向量。

  • BernoulliNB:这个分类器与 MultinomialNB 类似,但更适合处理二进制单词出现与否,而非词频。

由于我们将主要关注词频,因此对于我们的目的来说,MultinomialNB 分类器最为合适。

先解决一个简单的问题

正如我们在查看推文数据时所看到的,推文不仅仅是正面或负面的。实际上,大多数推文不包含任何情感,而是中立或无关的,包含例如原始信息(例如,“新书:构建机器学习……http://link”)。这导致了四个类别。为了避免任务过于复杂,现在我们只关注正面和负面的推文。

>>> # first create a Boolean list having true for tweets
>>> # that are either positive or negative
>>> pos_neg_idx = np.logical_or(Y=="positive", Y=="negative")

>>> # now use that index to filter the data and the labels
>>> X = X[pos_neg_idx]
>>> Y = Y[pos_neg_idx]

>>> # finally convert the labels themselves into Boolean
>>> Y = Y=="positive"

现在,我们在X中有原始推文文本,在Y中有二元分类,0表示负面推文,1表示正面推文。

我们刚刚提到过,我们将使用单词出现次数作为特征。但我们不会直接使用它们的原始形式,而是使用我们的强力工具TfidfVectorizer,将原始推文文本转换为 TF-IDF 特征值,然后将其与标签一起用于训练我们的第一个分类器。为了方便,我们将使用Pipeline类,它允许我们将向量化器和分类器连接在一起,并提供相同的接口:

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.naive_bayes import MultinomialNB
from sklearn.pipeline import Pipeline

def create_ngram_model():
 tfidf_ngrams = TfidfVectorizer(ngram_range=(1, 3), analyzer="word", binary=False)
 clf = MultinomialNB()
 return Pipeline([('vect', tfidf_ngrams), ('clf', clf)])

create_ngram_model()返回的Pipeline实例现在可以像普通分类器一样用于拟合和预测。

由于我们没有那么多数据,应该进行交叉验证。然而,这一次,我们不会使用KFold(它将数据划分为连续的折叠),而是使用ShuffleSplit。它会将数据打乱,但不会阻止相同的数据实例出现在多个折叠中。对于每个折叠,我们会跟踪精准率-召回率曲线下的面积和准确度。

为了保持实验的灵活性,我们将一切封装在一个train_model()函数中,该函数接受一个创建分类器的函数作为参数。

from sklearn.metrics import precision_recall_curve, auc
from sklearn.cross_validation import ShuffleSplit

def train_model(clf_factory, X, Y):
 # setting random_state to get deterministic behavior
 cv = ShuffleSplit(n=len(X), n_iter=10, test_size=0.3, random_state=0)

 scores = []
 pr_scores = []

 for train, test in cv:
 X_train, y_train = X[train], Y[train]
 X_test, y_test = X[test], Y[test]

 clf = clf_factory()
 clf.fit(X_train, y_train)

 train_score = clf.score(X_train, y_train)
 test_score = clf.score(X_test, y_test)

 scores.append(test_score)
 proba = clf.predict_proba(X_test)

 precision, recall, pr_thresholds = precision_recall_curve(y_test, proba[:,1])

 pr_scores.append(auc(recall, precision))

 summary = (np.mean(scores), np.std(scores),
np.mean(pr_scores), np.std(pr_scores))
 print("%.3f\t%.3f\t%.3f\t%.3f" % summary)

将所有内容组合起来,我们可以训练我们的第一个模型:

>>> X, Y = load_sanders_data()
>>> pos_neg_idx = np.logical_or(Y=="positive", Y=="negative")
>>> X = X[pos_neg_idx]
>>> Y = Y[pos_neg_idx]
>>> Y = Y=="positive"
>>> train_model(create_ngram_model, X, Y)
0.788   0.024   0.882   0.036

使用朴素贝叶斯和向量化的 TF-IDF 三元组特征,我们的第一次尝试得到了 78.8%的准确率和 88.2%的平均 P/R AUC。当我们查看中位数的 P/R 图表(即表现最接近平均水平的训练/测试拆分)时,它表现出比我们在上一章看到的图表更加令人鼓舞的行为。

首先解决简单问题

一开始,结果相当令人鼓舞。当我们意识到在情感分类任务中,100%的准确率可能永远无法实现时,结果就变得更加令人印象深刻。对于某些推文,甚至人类在分类标签上也往往达不到一致。

使用所有类别

我们再次简化了任务,因为我们只使用了正面或负面的推文。这意味着,我们假设有一个完美的分类器,首先对推文是否包含情感进行分类,然后将结果传递给我们的朴素贝叶斯分类器。

那么,如果我们还对推文是否包含任何情感进行分类,我们的表现如何呢?为了找出答案,我们首先编写一个便捷函数,返回一个修改后的类别数组,提供我们希望解释为正面的情感列表:

def tweak_labels(Y, pos_sent_list):
 pos = Y==pos_sent_list[0]
 for sent_label in pos_sent_list[1:]:
 pos |= Y==sent_label

 Y = np.zeros(Y.shape[0])
 Y[pos] = 1
 Y = Y.astype(int)

return Y

请注意,我们现在谈论的是两个不同的正面情感。推文的情感可以是正面的,这与训练数据的类别不同。例如,如果我们想要了解我们如何区分具有情感的推文和中立推文,我们可以这样做:

>>> Y = tweak_labels(Y, ["positive", "negative"])

Y中,对于所有正面或负面的推文,我们现在有1(正类),对于中立和无关的推文,我们有0(负类)。

>>> train_model(create_ngram_model, X, Y, plot=True)
0.750   0.012   0.659   0.023

看一下以下的图表:

使用所有类别

如预期的那样,P/R AUC 显著下降,目前只有 66%。准确率仍然很高,但这仅仅是因为我们有一个高度不平衡的数据集。在 3,362 条推文中,只有 920 条是正面或负面的,占约 27%。这意味着,如果我们创建一个总是将推文分类为不包含任何情感的分类器,我们就已经有 73%的准确率了。这是另一个在训练和测试数据不平衡时,总是查看精度和召回率的案例。

那么,朴素贝叶斯分类器在将正面推文与其他推文以及负面推文与其他推文进行分类时表现如何呢?一句话:差。

== Pos vs. rest ==
0.873   0.009   0.305   0.026
== Neg vs. rest ==
0.861   0.006   0.497   0.026

如果你问我,这几乎是不可用的。看下面的 P/R 曲线,我们也会发现没有可用的精度/召回率权衡,就像我们在上一章做的那样:

使用所有类别使用所有类别

调整分类器的参数

当然,我们还没有充分探索当前的设置,应该进行更多的调查。大致有两个领域,我们可以调整参数:TfidfVectorizerMultinomialNB。由于我们对哪个领域的探索没有直觉,让我们尝试分配参数值。

我们将首先查看TfidfVectorizer参数:

  • 使用不同的 NGram 设置:

    • 单字(1,1)

    • 单字和双字(1,2)

    • 单字、双字和三字(1,3)

  • 调整min_df:1 或 2

  • 使用use_idfsmooth_idf探索 IDF 在 TF-IDF 中的影响:FalseTrue

  • 是否移除停用词,通过将stop_words设置为englishNone

  • 是否使用词频的对数值(sublinear_tf

  • 是否跟踪词频,还是仅跟踪词是否出现,通过将binary设置为TrueFalse

现在我们将查看MultinomialNB分类器:

  • 通过设置alpha来选择平滑方法:

    • 加一平滑或拉普拉斯平滑:1

    • Lidstone 平滑:0.01,0.05,0.1,或 0.5

    • 无平滑:0

一种简单的方法是为所有合理的探索值训练一个分类器,同时保持其他参数不变并检查分类器的结果。由于我们不知道这些参数是否相互影响,正确的方法是为所有可能的参数值组合训练一个分类器。显然,这对我们来说太繁琐了。

因为这种参数探索在机器学习任务中经常发生,scikit-learn 为此提供了一个专门的类,叫做GridSearchCV。它接受一个估算器(具有分类器类似接口的实例),在我们这个案例中将是Pipeline实例,并且接受一个包含参数及其潜在值的字典。

GridSearchCV期望字典的键遵循特定的格式,以便能够为正确的估算器设置参数。格式如下:

<estimator>__<subestimator>__...__<param_name>

例如,如果我们想指定要探索的TfidfVectorizer(在Pipeline描述中称为vect)的min_df参数的期望值,我们需要这样说:

param_grid={"vect__ngram_range"=[(1, 1), (1, 2), (1, 3)]}

这将告诉GridSearchCV尝试将一元组到三元组作为TfidfVectorizerngram_range参数的候选值。

然后,它使用所有可能的参数值组合训练估算器。在这里,我们确保它在训练数据的随机样本上进行训练,使用ShuffleSplit,它生成随机的训练/测试分割迭代器。最后,它提供最佳估算器,作为成员变量best_estimator_

由于我们希望将返回的最佳分类器与当前的最佳分类器进行比较,我们需要以相同的方式评估它。因此,我们可以通过cv参数传递ShuffleSplit实例(因此,GridSearchCV中的CV)。

最后一块缺失的部分是定义GridSearchCV应如何确定最佳估算器。这可以通过提供期望的评分函数来完成(惊讶!)传递给score_func参数。我们可以自己编写一个,或者从sklearn.metrics包中选择一个。我们肯定不应该使用metric.accuracy,因为我们的数据存在类别不平衡(包含情感的推文远少于中立推文)。相反,我们希望对两个类别都有良好的精度和召回率,即具有情感的推文和没有正面或负面观点的推文。一种结合精度和召回率的评价指标是所谓的F 值,它在metrics.f1_score中实现:

Tuning the classifier's parameters

将所有内容整合后,我们得到以下代码:

from sklearn.grid_search import GridSearchCV
from sklearn.metrics import f1_score

def grid_search_model(clf_factory, X, Y):
 cv = ShuffleSplit(
 n=len(X), n_iter=10, test_size=0.3,random_state=0)

 param_grid = dict(vect__ngram_range=[(1, 1), (1, 2), (1, 3)],
 vect__min_df=[1, 2],
 vect__stop_words=[None, "english"],
 vect__smooth_idf=[False, True],
 vect__use_idf=[False, True],
 vect__sublinear_tf=[False, True],
 vect__binary=[False, True],
 clf__alpha=[0, 0.01, 0.05, 0.1, 0.5, 1],
 )

 grid_search = GridSearchCV(clf_factory(),
 param_grid=param_grid,
 cv=cv,
 score_func=f1_score,
 verbose=10)
 grid_search.fit(X, Y) 

 return grid_search.best_estimator_

执行此操作时我们需要耐心:

clf = grid_search_model(create_ngram_model, X, Y)
print(clf)

由于我们刚刚请求了一个参数,覆盖![调整分类器的参数]的参数组合,每种组合都将在 10 折交叉验证中进行训练:

... waiting some hours  ...
Pipeline(clf=MultinomialNB(
alpha=0.01, class_weight=None, fit_prior=True),
clf__alpha=0.01, 
clf__class_weight=None, 
clf__fit_prior=True,
vect=TfidfVectorizer(
analyzer=word, binary=False,
 charset=utf-8, charset_error=strict, 
dtype=<type 'long'>,input=content,
lowercase=True, max_df=1.0,
max_features=None, max_n=None,
min_df=1, min_n=None, ngram_range=(1, 2),
norm=l2, preprocessor=None, smooth_idf=False,
stop_words=None,strip_accents=None, 
sublinear_tf=True,token_pattern=(?u)\b\w\w+\b,
token_processor=None, tokenizer=None, 
use_idf=False, vocabulary=None),
vect__analyzer=word, vect__binary=False, 
vect__charset=utf-8,
vect__charset_error=strict, 
vect__dtype=<type 'long'>,
vect__input=content, vect__lowercase=True, 
vect__max_df=1.0,vect__max_features=None, 
vect__max_n=None, vect__min_df=1,
vect__min_n=None, vect__ngram_range=(1, 2), 
vect__norm=l2, vect__preprocessor=None, 
vect__smooth_idf=False, vect__stop_words=None, 
vect__strip_accents=None, vect__sublinear_tf=True,
vect__token_pattern=(?u)\b\w\w+\b,
vect__token_processor=None, vect__tokenizer=None,
vect__use_idf=False, vect__vocabulary=None)
0.795  0.007  0.702  0.028

最佳估算器确实将 P/R AUC 提高了近 3.3 个百分点,现在为 70.2,设置如前面代码所示。

此外,针对正面推文与其他推文的比较,负面推文与其他推文的比较,若我们配置向量化器和分类器使用刚刚发现的参数,结果会得到显著改善:

== Pos vs. rest ==
0.889   0.010   0.509   0.041
== Neg vs. rest ==
0.886   0.007   0.615   0.035

看一下以下图表:

Tuning the classifier's parametersTuning the classifier's parameters

的确,P/R 曲线看起来好多了(注意,这些图是来自于不同折分类器的中位数,因此 AUC 值略有差异)。不过,我们可能仍然不会使用这些分类器。是时候换点新的东西了…

清洗推文

新的约束导致了新的形式。Twitter 在这方面也不例外。因为文本必须适应 140 个字符,人们自然会发展出新的语言简写,以用更少的字符表达相同的意思。到目前为止,我们忽略了各种各样的表情符号和缩写。让我们看看在考虑到这些因素后,我们能做出多大的改进。为了这个目标,我们将需要提供我们自己的preprocessor()来处理TfidfVectorizer

首先,我们在字典中定义一系列常见的表情符号及其替换词。虽然我们可以找到更多不同的替换词,但我们选择明显的正面或负面词汇来帮助分类器:

emo_repl = {
 # positive emoticons
 "&lt;3": " good ",
 ":d": " good ", # :D in lower case
 ":dd": " good ", # :DD in lower case
 "8)": " good ",
 ":-)": " good ",
 ":)": " good ",
 ";)": " good ",
 "(-:": " good ",
 "(:": " good ",

 # negative emoticons:
 ":/": " bad ",
 ":&gt;": " sad ",
 ":')": " sad ",
 ":-(": " bad ",
 ":(": " bad ",
 ":S": " bad ",
 ":-S": " bad ",
 }

# make sure that e.g. :dd is replaced before :d
emo_repl_order = [k for (k_len,k) in reversed(sorted([(len(k),k) for k in emo_repl.keys()]))]

然后,我们将缩写定义为正则表达式,并给出它们的扩展(\b表示单词边界):

re_repl = {
r"\br\b": "are",
r"\bu\b": "you",
r"\bhaha\b": "ha",
r"\bhahaha\b": "ha",
r"\bdon't\b": "do not",
r"\bdoesn't\b": "does not",
r"\bdidn't\b": "did not",
r"\bhasn't\b": "has not",
r"\bhaven't\b": "have not",
r"\bhadn't\b": "had not",
r"\bwon't\b": "will not",
r"\bwouldn't\b": "would not",
r"\bcan't\b": "can not",
r"\bcannot\b": "can not",
 }

def create_ngram_model(params=None):
 def preprocessor(tweet):
 tweet = tweet.lower()
 for k in emo_repl_order:
 tweet = tweet.replace(k, emo_repl[k])
 for r, repl in re_repl.items():
 tweet = re.sub(r, repl, tweet)

 return tweet

 tfidf_ngrams = TfidfVectorizer(preprocessor=preprocessor,
analyzer="word")
 # ...

当然,这里可以使用更多的缩写。但仅凭这一有限的集合,我们已经能在情感与非情感的分类上提高了半个点,目前准确率为 70.7%:

== Pos vs. neg ==
0.808   0.024   0.885   0.029
== Pos/neg vs. irrelevant/neutral ==
0.793   0.010   0.685   0.024
== Pos vs. rest ==
0.890   0.011   0.517   0.041
== Neg vs. rest ==
0.886   0.006   0.624   0.033

考虑单词类型

到目前为止,我们的期望是仅仅使用单词本身(采用袋装词汇方法)就足够了。然而,仅凭我们的直觉,可能中立的推文包含更多名词,而积极或消极的推文则更加多彩,包含更多形容词和动词。那么,如果我们也利用推文中的语言学信息呢?如果我们能找出推文中有多少单词是名词、动词、形容词等,分类器也许能够将这一信息纳入考虑。

确定单词类型

这就是词性标注,或 POS 标注的核心。POS 标注器解析一个完整的句子,目的是将其排列成依存树,其中每个节点对应一个单词,父子关系决定了该单词依赖哪个单词。通过这棵树,它可以做出更为明智的决策,例如判断“book”是名词(“这是一本好书。”)还是动词(“你能帮我订机票吗?”)。

你可能已经猜到,NLTK 在这个领域也将发挥作用。的确,它提供了各种各样的解析器和标注器。我们将使用的 POS 标注器nltk.pos_tag()实际上是一个完整的分类器,经过使用 Penn Treebank 项目中的人工标注句子训练而成(www.cis.upenn.edu/~treebank)。它的输入是一个单词标记列表,输出是一个包含元组的列表,每个元组包含原句的部分内容及其词性标注。

>>> import nltk
>>> nltk.pos_tag(nltk.word_tokenize("This is a good book."))
[('This', 'DT'), ('is', 'VBZ'), ('a', 'DT'), ('good', 'JJ'), ('book', 'NN'), ('.', '.')]
>>> nltk.pos_tag(nltk.word_tokenize("Could you please book the flight?"))
[('Could', 'MD'), ('you', 'PRP'), ('please', 'VB'), ('book', 'NN'), ('the', 'DT'), ('flight', 'NN'), ('?', '.')]

词性标注缩写来自于 Penn Treebank(改编自www.anc.org/OANC/penn.html):

POS 标签 描述 示例
CC 并列连词 或者
CD 基数词 2,第二
DT 限定词
EX 存在性 there 那里
FW 外来词 幼儿园
IN 介词/从属连词 在,的,像
JJ 形容词
JJR 形容词,比较级 更酷
JJS 形容词,最高级 最酷的
LS 列表标记 1)
MD 情态动词 可以,将
NN 名词,单数或不可数名词
NNS 名词复数 书籍
NNP 专有名词,单数 Sean
NNPS 专有名词,复数 维京人
PDT 预定限定词 两个男孩
POS 所有格结尾 朋友的
PRP 人称代词 我,他,它
PRP$ 所有格代词 我的,他的
RB 副词 然而,通常,当然,这里,好
RBR 副词,比较级 更好
RBS 副词,最高级 最好
RP 小品词
TO 去, 他那里
UH 感叹词 嗯嗯
VB 动词,基本形式
VBD 动词,过去时
VBG 动词,动名词/现在分词
VBN 动词,过去分词 已拿
VBP 动词,单数现在时,非第三人称
VBZ 动词,第三人称单数现在时
WDT 疑问限定词 哪个
WP 疑问代词 谁,什么
WP$ 所有格疑问代词 谁的
WRB 疑问副词 哪里,什么时候

通过这些标签,从pos_tag()的输出中过滤出所需的标签是相当容易的。我们只需数出所有标签以NN开头的名词,VB开头的动词,JJ开头的形容词,和RB开头的副词。

使用 SentiWordNet 成功作弊

虽然前面提到的语言学信息最有可能帮助我们,但我们可以做得更好来收获它:SentiWordNet (sentiwordnet.isti.cnr.it)。简单来说,它是一个 13MB 的文件,为大多数英语单词分配了正负值。更复杂的说,对于每个同义词集合,它记录了正面和负面的情感值。以下是一些例子:

POS ID PosScore NegScore 同义词集合 描述
a 00311354 0.25 0.125 勤奋的#1 以细心和努力为特征;"做了一个勤奋的尝试来修理电视机"
a 00311663 0 0.5 粗心#1 特征是缺乏关注、考虑、预见或彻底性;不小心…
n 03563710 0 0 移植物#1 永久性地植入组织的假体
v 00362128 0 0 kink#2 曲线#5 卷曲#1 形成弯曲、曲线或扭结;"雪茄烟雾在天花板上卷曲"

利用POS列中的信息,我们能够区分名词"book"和动词"book"。PosScoreNegScore一起帮助我们确定单词的中立性,即 1-PosScore-NegScore。SynsetTerms列出该集合中所有的同义词。对于我们的任务来说,我们可以忽略IDDescription列。

同义词集术语后面会有一个数字,因为有些词会出现在多个同义词集中。例如,"fantasize"表达了两种截然不同的意思,因此会导致不同的分数:

POS ID PosScore NegScore SynsetTerms Description
v 01636859 0.375 0 fantasize#2 fantasise#2 在脑海中描绘;"他在幻想理想的妻子"
v 01637368 0 0.125 fantasy#1 fantasize#1 fantasise#1 沉溺于幻想;"他说他计划创办自己的公司时就是在幻想"

为了找出应该使用哪些同义词集,我们需要真正理解推文的含义,这超出了本章的范围。专注于这一挑战的研究领域叫做词义消歧。在我们的任务中,我们走捷径,简单地计算所有同义词集中的分数平均值,某个词项出现在这些同义词集中。例如,"fantasize"的PosScore为 0.1875,NegScore为 0.0625。

以下函数load_sent_word_net()会为我们完成所有这些工作,并返回一个字典,其中键是形如词性/词的字符串,例如 n/implant,值为正负分数:

import csv, collections

def load_sent_word_net():
 # making our life easier by using a dictionary that
 # automatically creates an empty list whenever we access
 # a not yet existing key
 sent_scores = collections.defaultdict(list)

 with open(os.path.join(DATA_DIR, SentiWordNet_3.0.0_20130122.txt"), "r") as csvfile:
 reader = csv.reader(csvfile, delimiter='\t',
quotechar='"')
 for line in reader:
 if line[0].startswith("#"):
 continue
 if len(line)==1:
 continue

 POS, ID, PosScore, NegScore, SynsetTerms, Gloss = line
 if len(POS)==0 or len(ID)==0:
 continue
 for term in SynsetTerms.split(" "):
 # drop number at the end of every term
 term = term.split("#")[0] 
 term = term.replace("-", " ").replace("_", " ")
 key = "%s/%s"%(POS, term.split("#")[0])
 sent_scores[key].append((float(PosScore), 
float(NegScore)))

 for key, value in sent_scores.items():
 sent_scores[key] = np.mean(value, axis=0)

 return sent_scores

我们的第一个估算器

现在,我们已经准备好创建我们自己的第一个特征提取器。最方便的方式是从BaseEstimator继承。它要求我们实现以下三个方法:

  • get_feature_names(): 该函数返回一个字符串列表,表示我们将在transform()中返回的特征。

  • fit(document, y=None): 由于我们没有实现分类器,因此可以忽略这一点,直接返回 self。

  • transform(documents): 该方法返回numpy.array(),包含形状为(len(documents), len(get_feature_names))的数组。也就是说,对于documents中的每个文档,它必须为get_feature_names()中的每个特征名称返回一个值。

下面是实现:

sent_word_net = load_sent_word_net()

class LinguisticVectorizer(BaseEstimator):
 def get_feature_names(self):
 return np.array(['sent_neut', 'sent_pos', 'sent_neg',
 'nouns', 'adjectives', 'verbs', 'adverbs',
 'allcaps', 'exclamation', 'question', 'hashtag', 'mentioning'])

 # we don't fit here but need to return the reference
 # so that it can be used like fit(d).transform(d)
 def fit(self, documents, y=None):
 return self

 def _get_sentiments(self, d):
 sent = tuple(d.split())
 tagged = nltk.pos_tag(sent)

 pos_vals = []
 neg_vals = []

 nouns = 0.
 adjectives = 0.
 verbs = 0.
 adverbs = 0.

 for w,t in tagged:
 p, n = 0,0
 sent_pos_type = None
 if t.startswith("NN"):
 sent_pos_type = "n"
 nouns += 1
 elif t.startswith("JJ"):
 sent_pos_type = "a"
 adjectives += 1
 elif t.startswith("VB"):
 sent_pos_type = "v"
 verbs += 1
 elif t.startswith("RB"):
 sent_pos_type = "r"
 adverbs += 1

 if sent_pos_type is not None:
 sent_word = "%s/%s" % (sent_pos_type, w)

 if sent_word in sent_word_net:
 p,n = sent_word_net[sent_word]

 pos_vals.append(p)
 neg_vals.append(n)

 l = len(sent)
 avg_pos_val = np.mean(pos_vals)
 avg_neg_val = np.mean(neg_vals)
 return [1-avg_pos_val-avg_neg_val, avg_pos_val, avg_neg_val,
nouns/l, adjectives/l, verbs/l, adverbs/l]

 def transform(self, documents):
 obj_val, pos_val, neg_val, nouns, adjectives, \
verbs, adverbs = np.array([self._get_sentiments(d) \
for d in documents]).T

 allcaps = []
 exclamation = []
 question = []
 hashtag = []
 mentioning = []

 for d in documents:
 allcaps.append(np.sum([t.isupper() \
 for t in d.split() if len(t)>2]))

 exclamation.append(d.count("!"))
 question.append(d.count("?"))
 hashtag.append(d.count("#"))
 mentioning.append(d.count("@"))

 result = np.array([obj_val, pos_val, neg_val, nouns, adjectives, verbs, adverbs, allcaps, exclamation, question, 
hashtag, mentioning]).T

 return result

整合所有内容

然而,仅仅使用这些语言特征而不考虑单词本身,是无法带我们走得很远的。因此,我们必须将TfidfVectorizer参数与语言特征结合起来。可以使用 scikit-learn 的FeatureUnion类来实现这一点。它的初始化方式与Pipeline相同;但是,FeatureUnion不同于按顺序评估估算器并将每个估算器的输出传递给下一个估算器,它是并行处理的,并在之后将输出向量合并。

def create_union_model(params=None):
 def preprocessor(tweet):
 tweet = tweet.lower()

 for k in emo_repl_order:
 tweet = tweet.replace(k, emo_repl[k])
 for r, repl in re_repl.items():
 tweet = re.sub(r, repl, tweet)

 return tweet.replace("-", " ").replace("_", " ")

 tfidf_ngrams = TfidfVectorizer(preprocessor=preprocessor, analyzer="word")
 ling_stats = LinguisticVectorizer()
 all_features = FeatureUnion([('ling', ling_stats), ('tfidf', tfidf_ngrams)])
 clf = MultinomialNB()
 pipeline = Pipeline([('all', all_features), ('clf', clf)])

 if params:
 pipeline.set_params(**params)

 return pipeline

在结合特征提取器上进行训练和测试,可以使正负样本的平均 P/R AUC 提高 0.4 个百分点:

== Pos vs. neg ==
0.810   0.023   0.890   0.025
== Pos/neg vs. irrelevant/neutral ==
0.791   0.007   0.691   0.022
== Pos vs. rest ==
0.890   0.011   0.529   0.035
== Neg vs. rest ==
0.883   0.007   0.617   0.033
time spent: 214.12578797340393

根据这些结果,我们可能不想使用正向对比休息和负向对比休息的分类器,而是首先使用一个分类器来确定推文是否包含情感(正向/负向与无关/中立),然后,如果包含情感,再使用正向与负向分类器来确定具体情感。

总结

恭喜你坚持到最后!我们一起学习了朴素贝叶斯如何工作,以及它为什么并不那么“天真”。尤其是在训练集数据不足以学习类别概率空间中的所有细分时,朴素贝叶斯在泛化方面表现出色。我们学习了如何将其应用于推文,并且清理粗糙的推文文本帮助很大。最后,我们意识到适当的“作弊”(前提是我们已经做了足够的工作)是可以接受的,尤其是当它带来分类器性能的提升时,正如我们在使用SentiWordNet时所经历的那样。

在下一章中,我们将讨论回归分析。

第七章:回归

你可能在高中数学课上学过回归分析。你学到的具体方法可能就是所谓的普通最小二乘OLS)回归。这个已有 200 年历史的技术计算速度很快,并且可以应用于许多现实世界的问题。本章将首先回顾这一方法,并展示它是如何在 scikit-learn 中实现的。

然而,对于某些问题,这种方法是不够的。特别是当我们拥有许多特征时,这种方法就显得不够用了,尤其是在特征数比数据点数更多的情况下,这种方法完全无法工作。在这些情况下,我们需要更先进的方法。这些方法非常现代,过去十年取得了重大进展,名字如 Lasso、Ridge 或 ElasticNets。我们将详细介绍这些方法,它们也可以在 scikit-learn 中使用。

使用回归预测房价

我们从一个简单的问题开始——预测波士顿的房价;这是一个可以使用公开数据集的问题。我们得到了一些人口统计学和地理信息,如犯罪率或邻里的师生比例。目标是预测某个地区房屋的中位数价值。像往常一样,我们有一些训练数据,答案是已知的。

这是 scikit-learn 自带的内置数据集之一,因此非常容易将数据加载到内存中:

>>> from sklearn.datasets import load_boston
>>> boston = load_boston()

boston 对象包含多个属性;特别地,boston.data 包含输入数据,boston.target 包含房价。

我们将从一个简单的一维回归开始,尝试用一个特征来回归房价,这个特征是每个住宅区域的平均房间数,存储在位置 5(你可以查阅 boston.DESCRboston.feature_names 以获取数据的详细信息):

>>> from matplotlib import pyplot as plt
>>> plt.scatter(boston.data[:,5], boston.target, color='r')

boston.target 属性包含了平均房价(我们的目标变量)。我们可以使用你可能在高中时学到的标准最小二乘回归。我们第一次尝试的代码如下:

>>> from sklearn.linear_model import LinearRegression
>>> lr = LinearRegression()

我们从 sklearn.linear_model 模块导入 LinearRegression 并构造一个 LinearRegression 对象。这个对象的行为与我们之前使用的 scikit-learn 分类器对象类似。

>>> x = boston.data[:,5]
>>> y = boston.target
>>> x = np.transpose(np.atleast_2d(x))
>>> lr.fit(x, y)
>>> y_predicted = lr.predict(x)

这段代码中唯一不显而易见的行是对 np.atleast_2d 的调用,它将 x 从一维数组转换为二维数组。这个转换是必要的,因为 fit 方法期望其第一个参数是一个二维数组。最后,为了确保维度正确,我们需要对这个数组进行转置。

请注意,我们在 LinearRegression 对象上调用了名为 fitpredict 的方法,就像之前使用分类器对象时一样,尽管现在我们执行的是回归操作。这种 API 的一致性是 scikit-learn 的一个优点。

使用回归预测房价

上图展示了所有的点(以点表示)和我们的拟合曲线(实线)。我们可以看到,视觉效果很好,除了少数几个离群点。

然而,从理想角度来看,我们希望定量衡量拟合的好坏。这对于能够比较不同方法非常关键。为此,我们可以测量预测值与真实值之间的接近程度。为此,我们可以使用sklearn.metrics模块中的mean_squared_error函数:

>>> from sklearn.metrics import mean_squared_error

该函数有两个参数,真实值和预测值,如下所示:

>>> mse = mean_squared_error(y, lr.predict(x))
>>> print("Mean squared error (of training data): {:.3}".format(mse))
Mean squared error (of training data): 58.4

这个值有时可能很难解读,最好取其平方根,得到均方根误差RMSE):

>>> rmse = np.sqrt(mse)
>>> print("RMSE (of training data): {:.3}".format(rmse))
RMSE (of training data): 6.6

使用 RMSE 的一个优势是,我们可以通过将其乘以二,快速获得误差的粗略估计。在我们的案例中,我们可以预计估算的价格与实际价格之间的差异最多为 1.3 万美元。

提示

均方根误差与预测

均方根误差大致对应于标准差的估计。由于大多数数据距离均值最多两个标准差,因此我们可以将 RMSE 翻倍来获得一个粗略的置信区间。如果误差符合正态分布,这种方法完全有效,但即使误差不是正态分布,它通常也大致正确。

像 6.6 这样的数字仍然很难直观理解。这是一个好的预测吗?回答这个问题的一种可能方法是将其与最简单的基准模型——常数模型进行比较。如果我们对输入一无所知,最好的方法就是预测输出始终是y的平均值。然后,我们可以将这个模型的均方误差与零模型的均方误差进行比较。这个思想在决定系数中得到了形式化定义,其计算公式如下:

使用回归预测房价

在这个公式中,y[i]表示索引为i的元素的值,而使用回归预测房价是回归模型为同一元素提供的估计值。最后,使用回归预测房价y的均值,代表着零模型,即始终返回相同值的模型。这个公式大致等同于首先计算均方误差与输出方差的比率,最后计算 1 减去这个比率。这样,完美的模型得分为 1,而零模型得分为 0。请注意,可能会得到负分数,这意味着模型非常糟糕,以至于使用均值作为预测值更好。

决定系数可以使用sklearn.metrics模块中的r2_score来获得:

>>> from sklearn.metrics import r2_score
>>> r2 = r2_score(y, lr.predict(x))
>>> print("R2 (on training data): {:.2}".format(r2))
R2 (on training data): 0.31

这个指标也叫做 R²得分。如果你使用线性回归并在训练数据上评估误差,那么它确实对应于相关系数 R 的平方。然而,这个指标更为通用,正如我们讨论的那样,可能会返回一个负值。

计算决定系数的另一种方法是使用LinearRegression对象的score方法:

>>> r2 = lr.score(x,y)

多维回归

到目前为止,我们只使用了一个变量进行预测,即每个住宅的房间数。接下来,我们将使用所有数据来拟合模型,使用多维回归。我们现在尝试基于多个输入预测一个单一的输出(平均房价)。

代码看起来和之前非常相似。实际上,它现在更简单了,因为我们可以直接将boston.data的值传递给fit方法:

>>> x = boston.data
>>> y = boston.target
>>> lr.fit(x, y)

使用所有输入变量,均方根误差仅为 4.7,对应的决定系数为 0.74。这比我们之前的结果要好,说明额外的变量确实有帮助。我们不再能像之前那样轻松显示回归线,因为我们有一个 14 维的回归超平面,而不是一条单独的直线。

然而,我们可以绘制预测值与实际值的对比图。代码如下:

>>> p = lr.predict(x)
>>> plt.scatter(p, y)
>>> plt.xlabel('Predicted price')
>>> plt.ylabel('Actual price')
>>> plt.plot([y.min(), y.max()], [[y.min()], [y.max()]])

最后一行绘制了一条对角线,表示完美一致的情况。这有助于可视化。结果如下面的图所示,其中实线表示对角线(如果预测与真实值完全一致,所有点都会落在这条线上):

多维回归

回归问题的交叉验证

如果你还记得我们首次介绍分类时,强调了交叉验证在检验预测质量中的重要性。在回归中,这并不总是做的。事实上,到目前为止,我们在本章中只讨论了训练误差。如果你想自信地推断模型的泛化能力,这是一个错误。因为普通最小二乘法是一个非常简单的模型,这通常不是一个非常严重的错误。换句话说,过拟合的程度较轻。然而,我们仍然应该通过经验来测试这一点,这可以通过 scikit-learn 轻松实现。

我们将使用Kfold类来构建一个 5 折交叉验证循环,测试线性回归的泛化能力:

>>> from sklearn.cross_validation import Kfold
>>> kf = KFold(len(x), n_folds=5)
>>> p = np.zeros_like(y)
>>> for train,test in kf:
...    lr.fit(x[train], y[train])
...    p[test] = lr.predict(x[test])
>>> rmse_cv = np.sqrt(mean_squared_error(p, y))
>>> print('RMSE on 5-fold CV: {:.2}'.format(rmse_cv))
RMSE on 5-fold CV: 5.6

使用交叉验证,我们得到一个更为保守的估计(即,误差更大):5.6。与分类问题一样,交叉验证的估计是我们如何将模型泛化到未见数据的更好估计。

普通最小二乘法在学习阶段很快,并且返回一个简单的模型,在预测时也非常快。因此,它通常应该是回归问题中你首先尝试的模型。然而,我们现在将看到更先进的方法,并了解为什么有时它们更为优越。

惩罚或正则化回归

本节介绍了惩罚回归,也叫做正则化回归,它是回归模型的一个重要类别。

在普通回归中,返回的拟合结果是训练数据上的最佳拟合。这可能导致过拟合。惩罚意味着我们为参数值的过度自信添加惩罚。因此,我们接受稍差的拟合,以便拥有一个更简单的模型。

另一种思考方式是,将默认设定为输入变量与输出预测之间没有关系。当我们拥有数据时,我们会改变这一观点,但添加惩罚意味着我们需要更多数据来说服我们,这确实是一个强关系。

提示

惩罚回归是关于权衡的

惩罚回归是偏差-方差权衡的另一个例子。在使用惩罚时,训练数据的拟合度会变差,因为我们引入了偏差。另一方面,我们减少了方差,倾向于避免过拟合。因此,整体结果可能更好地推广到未见过的(测试)数据。

L1 和 L2 惩罚

现在我们将详细探讨这些思想。对于不关心某些数学方面的读者,可以直接跳到下一节,了解如何在 scikit-learn 中使用正则化回归。

一般而言,问题在于我们给定了一个训练数据矩阵 X(行是观测值,每列是不同的特征),以及一个输出值向量 y。目标是得到一个权重向量,我们将其称为 b。普通最小二乘回归由以下公式给出:

L1 和 L2 惩罚

也就是说,我们找到一个向量 b,使其最小化与目标 y 的平方距离。在这些方程中,我们忽略了设置截距的问题,假设训练数据已经预处理,使得 y 的均值为零。

添加惩罚项或正则化意味着我们不仅考虑训练数据的最佳拟合,还考虑向量L1 和 L2 惩罚的组成。回归中通常使用两种类型的惩罚:L1 惩罚和 L2 惩罚。L1 惩罚意味着我们通过系数的绝对值之和来惩罚回归,而 L2 惩罚则是通过系数平方和来惩罚。

当我们添加 L1 惩罚时,我们不再使用之前的方程,而是优化以下方程:

L1 和 L2 惩罚

在这里,我们试图同时使误差变小,同时也使系数的值(绝对值)变小。使用 L2 惩罚意味着我们使用以下公式:

L1 和 L2 惩罚

差异相当微妙:我们现在通过系数的平方来惩罚,而不是它们的绝对值。然而,结果的差异却是显著的。

提示

岭回归、套索回归和弹性网回归

这些带惩罚的模型通常都有一些非常有趣的名字。L1 惩罚模型通常被称为Lasso,而 L2 惩罚模型则被称为岭回归。当同时使用这两者时,我们称之为ElasticNet模型。

Lasso 和 Ridge 都会比无惩罚回归产生更小的系数(绝对值较小,忽略符号)。然而,Lasso 还有一个额外的特性,即它会将许多系数设为零!这意味着最终模型甚至不使用它的一些输入特征,模型是稀疏的。这一特性通常是非常受欢迎的,因为模型在单一步骤中既进行特征选择,又进行回归

你会注意到,每当我们添加一个惩罚时,我们也会添加一个权重 α,它控制着我们希望的惩罚程度。当α接近零时,我们非常接近于无惩罚回归(事实上,如果你将α设置为零,你将仅执行普通最小二乘法 (OLS)),而当α较大时,我们得到的模型与无惩罚模型非常不同。

岭回归模型较为传统,因为 Lasso 用纸笔计算比较困难。然而,随着现代计算机的发展,我们可以像使用 Ridge 一样轻松地使用 Lasso,甚至可以将它们结合起来形成 ElasticNets。ElasticNet 有两个惩罚项,一个用于绝对值,另一个用于平方项,它解决以下方程:

L1 和 L2 惩罚

这个公式是前两个公式的组合,包含了两个参数,α[1]α[2]。在本章稍后,我们将讨论如何为这些参数选择合适的值。

在 scikit-learn 中使用 Lasso 或 ElasticNet

让我们调整之前的例子,使用 ElasticNets。在 scikit-learn 中,替换成 ElasticNet 回归器非常简单,和之前使用最小二乘法回归器一样:

>>> from sklearn.linear_model import ElasticNet, Lasso
>>> en = ElasticNet(alpha=0.5)

现在,我们使用的是en,而之前我们使用的是lr。这是唯一需要改变的地方。结果正是我们预期的那样。训练误差增加到了 5.0(之前是 4.6),但交叉验证误差下降到了 5.4(之前是 5.6)。我们在训练数据上牺牲了较大的误差,以获得更好的泛化能力。我们本可以使用相同的代码,尝试通过Lasso类应用 L1 惩罚,或者使用Ridge类应用 L2 惩罚。

可视化 Lasso 路径

使用 scikit-learn,我们可以轻松地可视化当正则化参数(alpha)变化时所发生的情况。我们将再次使用波士顿数据集,但这次我们将使用Lasso回归对象:

>>> las = Lasso(normalize=1)
>>> alphas = np.logspace(-5, 2, 1000)
>>> alphas, coefs, _= las.path(x, y, alphas=alphas)

对于每个 alpha 值,path 方法在 Lasso 对象上返回能够解决该参数值下 Lasso 问题的系数。由于结果随着 alpha 的变化而平滑变化,因此可以非常高效地计算。

可视化这个路径的典型方法是绘制当 alpha 减小时系数的变化值。你可以按如下方式进行:

>>> fig,ax = plt.subplots()
>>> ax.plot(alphas, coefs.T)
>>> # Set log scale
>>> ax.set_xscale('log')
>>> # Make alpha decrease from left to right
>>> ax.set_xlim(alphas.max(), alphas.min())

这将产生如下图所示的结果(我们省略了添加轴标签和标题的简单代码):

可视化 Lasso 路径

在这个图中,x轴显示了从左到右逐渐减弱的正则化(alpha 逐渐减小)。每条线显示了不同系数随着 alpha 变化的情况。图表显示,当使用非常强的正则化时(左侧,alpha 非常高),最佳解决方案是所有值都为零。随着正则化的减弱,各个系数的值会一个接一个地首先急剧增加,然后稳定下来。到了某个点,它们都会趋于平稳,因为我们可能已经接近未惩罚的解。

P 大于 N 的情境

本节标题是一些内部术语,你现在将会学习这些。自 1990 年代起,首先在生物医学领域,然后在互联网领域,出现了 P 大于 N 的问题。这意味着特征的数量 P 大于样本的数量 N(这些字母是这些概念的常用统计缩写)。这些问题被称为P 大于 N问题。

例如,如果你的输入是一组书面文档,一种简单的方法是将字典中的每个可能单词视为一个特征,并基于这些特征进行回归(稍后我们会亲自处理类似问题)。在英语中,你有超过 2 万个单词(如果进行词干化并只考虑常见单词的话;如果跳过这个预处理步骤,单词数是它的十倍还多)。如果你只有几百或几千个样本,你将会有更多的特征而非样本。

在这种情况下,由于特征的数量大于样本的数量,因此可能会在训练数据上实现完美拟合。这是一个数学事实,与数据本身无关。实际上,你是在解决一个线性方程组,其中方程数量少于变量数量。你可以找到一组回归系数,训练误差为零(实际上,你可以找到多个完美解,无限多个)。

然而,且这是一个重大问题,零训练误差并不意味着你的解决方案会很好地泛化。事实上,它可能泛化得非常差。虽然早期的正则化可能给你一些额外的提升,但现在它是得到有意义结果的绝对必要条件。

基于文本文档的示例

我们现在转向一个来自卡内基梅隆大学诺亚·史密斯教授研究小组的研究示例。这项研究基于挖掘公司向美国证券交易委员会SEC)提交的所谓 10-K 报告。这项申报是法律要求所有上市公司进行的。该研究的目标是基于这份公开信息预测公司股票未来的波动性。在训练数据中,我们实际上使用的是已经知道结果的历史数据。

有 16,087 个可用示例。这些特征已经为我们预处理过,表示不同的单词,总共 150,360 个。因此,我们有的特征比示例多得多,几乎是它们的十倍。在引言中提到,普通最小二乘法在这些情况下失败,我们现在通过盲目应用它来看到原因。

数据集可以通过多个来源获得 SVMLight 格式,包括本书的配套网站。这是一个 scikit-learn 可以读取的格式。正如名字所示,SVMLight 是一个支持向量机实现,也可以通过 scikit-learn 使用;目前,我们只关心文件格式:

>>> from sklearn.datasets import load_svmlight_file
>>> data,target = load_svmlight_file('E2006.train')

在前面的代码中,数据是一个稀疏矩阵(即大多数条目为零,因此只保存非零条目在内存中),而目标是一个简单的一维向量。我们可以首先查看目标的一些属性:

>>> print('Min target value: {}'.format(target.min()))
Min target value: -7.89957807347
>>> print('Max target value: {}'.format(target.max()))
Max target value: -0.51940952694
>>> print('Mean target value: {}'.format(target.mean()))
Mean target value: -3.51405313669
>>> print('Std. dev. target: {}'.format(target.std()))
Std. dev. target: 0.632278353911

所以,我们可以看到数据位于-7.9 和-0.5 之间。现在我们对数据有了大致的了解,我们可以检查使用 OLS 进行预测时会发生什么。请注意,我们可以使用与之前在波士顿示例中完全相同的类和方法:

>>> from sklearn.linear_model import LinearRegression
>>> lr = LinearRegression()
>>> lr.fit(data,target)
>>> pred = lr.predict(data)
>>> rmse_train = np.sqrt(mean_squared_error(target, pred))
>>> print('RMSE on training: {:.2}'.format(rmse_train))
RMSE on training: 0.0025
>>> print('R2 on training: {:.2}'.format(r2_score(target, pred)))
R2 on training: 1.0

均方根误差由于四舍五入误差不是完全为零,但它非常接近。决定系数为1.0。也就是说,线性模型在其训练数据上报告了完美的预测结果。

当我们使用交叉验证时(代码与我们之前在波士顿示例中使用的非常相似),我们得到的结果非常不同:RMSE 为 0.75,决定系数为负值-0.42。这意味着如果我们总是“预测”均值-3.5,我们比使用回归模型效果更好!

提示

训练和泛化误差

当特征的数量大于示例的数量时,使用 OLS 总是会得到零训练误差,除非是由于四舍五入问题。然而,这很少是模型在泛化方面表现良好的标志。事实上,你可能会得到零训练误差,却拥有一个完全无用的模型。

自然的解决方案是使用正则化来抵消过拟合。我们可以尝试使用 ElasticNet 学习器的相同交叉验证循环,并将惩罚参数设置为0.1

>>> from sklearn.linear_model import ElasticNet
>>> met = ElasticNet(alpha=0.1)

>>> kf = KFold(len(target), n_folds=5)
>>> pred = np.zeros_like(target)
>>> for train, test in kf:
...    met.fit(data[train], target[train])
...    pred[test] = met.predict(data[test])

>>> # Compute RMSE
>>> rmse = np.sqrt(mean_squared_error(target, pred))
>>> print('[EN 0.1] RMSE on testing (5 fold): {:.2}'.format(rmse))
[EN 0.1] RMSE on testing (5 fold): 0.4

>>> # Compute Coefficient of determination
>>> r2 = r2_score(target, pred)
>>> print('[EN 0.1] R2 on testing (5 fold): {:.2}'.format(r2))
[EN 0.1] R2 on testing (5 fold): 0.61

现在,我们得到0.4的 RMSE 和0.61的 R2,远比仅预测均值要好。不过,这个解决方案有一个问题,那就是 alpha 的选择。当使用默认值(1.0)时,结果非常不同(而且更差)。

在这种情况下,我们作弊了,因为作者之前尝试了几个值,看看哪些值能给出好的结果。这是无效的,并且可能导致对信心的高估(我们正在查看测试数据来决定使用哪些参数值,而哪些应该永远不使用)。下一节将解释如何正确地做这件事,以及 scikit-learn 如何支持这一点。

以原则化的方式设置超参数

在前面的例子中,我们将惩罚参数设置为0.1。我们同样也可以将其设置为 0.7 或 23.9。显然,结果会因每次而异。如果我们选择一个过大的值,我们会出现欠拟合。在极端情况下,学习系统将返回所有系数为零的结果。如果我们选择一个过小的值,我们就非常接近普通最小二乘法(OLS),这会导致过拟合且泛化能力差(正如我们之前所看到的)。

我们如何选择一个合适的值呢?这是机器学习中的一个普遍问题:为我们的学习方法设置参数。一个通用的解决方案是使用交叉验证。我们选择一组可能的值,然后使用交叉验证来选择最优值。这需要更多的计算(如果我们使用五个子集,计算量是五倍),但始终适用且没有偏差。

然而,我们必须小心。为了获得泛化的估计,我们需要使用两级交叉验证:一级是估计泛化能力,二级是获得好的参数。也就是说,我们将数据分成例如五个子集。我们首先保留第一个子集,并在其他四个子集上进行学习。接下来,我们将这四个子集再次分成五个子集,用以选择参数。一旦设置好参数,我们在第一个子集上进行测试。然后,我们重复这一过程另外四次:

以规范的方式设置超参数

上图展示了如何将单一的训练子集分割成子子集。我们需要对所有其他子集重复这一过程。在这个例子中,我们有五个外部子集和五个内部子集,但并不一定非要使用相同数量的外部和内部子集,只要确保每个子集之间是分开的,你可以选择任何数量。

这会导致大量的计算,但为了正确地执行,确实是必要的。问题在于,如果你使用一部分数据来对模型做出任何决策(包括选择设置哪些参数),那么你就已经污染了这部分数据,无法再用它来测试模型的泛化能力。这是一个微妙的点,可能并不容易立刻察觉。事实上,许多机器学习用户仍然犯这个错误,过高估计他们系统的表现,因为他们没有正确地执行交叉验证!

幸运的是,scikit-learn 使得做对的事情变得非常容易;它提供了名为LassoCVRidgeCVElasticNetCV的类,这些类都封装了一个内部交叉验证循环,用于优化必要的参数。代码几乎和之前的一样,唯一不同的是我们不需要为 alpha 指定任何值:

>>> from sklearn.linear_model import ElasticNetCV
>>> met = ElasticNetCV()
>>> kf = KFold(len(target), n_folds=5)
>>> p = np.zeros_like(target)
>>> for train,test in kf:
...    met.fit(data[train],target[train])
...    p[test] = met.predict(data[test])
>>> r2_cv = r2_score(target, p)
>>> print("R2 ElasticNetCV: {:.2}".format(r2_cv))
R2 ElasticNetCV: 0.65

这会导致大量计算,因此在等待时你可能想要喝杯咖啡(取决于你的计算机速度有多快)。通过利用多个处理器,你可能会获得更好的性能。这是 scikit-learn 的内置功能,可以通过将n_jobs参数传递给ElasticNetCV构造函数来轻松访问。要使用四个 CPU,可以使用以下代码:

>>> met = ElasticNetCV(n_jobs=4)

n_jobs参数设置为-1以使用所有可用的 CPU:

>>> met = ElasticNetCV(n_jobs=-1)

也许你会想知道,如果 ElasticNets 有两个惩罚项,L1 和 L2 惩罚项,为什么我们只需要设置一个 alpha 值。实际上,通过分别指定 alpha 和 l1_ratio 变量(拼写为 ell-1-underscore-ratio),可以指定这两个值。然后,α1 和 α2 设置如下(其中 ρ 代表 l1_ratio):

以原则方式设置超参数以原则方式设置超参数

直观地说,alpha 设置了总体正则化的量,而l1_ratio设置了不同类型正则化(L1 和 L2)之间的权衡。

我们可以要求ElasticNetCV对象测试不同的l1_ratio值,如下所示的代码:

>>> l1_ratio=[.01, .05, .25, .5, .75, .95, .99]
>>> met = ElasticNetCV(
 l1_ratio=l1_ratio,
 n_jobs=-1)

这组l1_ratio值在文档中被推荐使用。它将测试几乎类似于 Ridge(当l1_ratio为 0.01 或 0.05 时)以及几乎类似于 Lasso(当l1_ratio为 0.95 或 0.99 时)的模型。因此,我们探索了各种不同选项的完整范围。

由于其灵活性和能够使用多个 CPU 的能力,当你没有任何特定原因偏好一种模型而不是其他模型时,ElasticNetCV是回归问题的一个出色的默认解决方案。

将所有这些放在一起,我们现在可以在这个大数据集上可视化预测与真实拟合:

>>> l1_ratio = [.01, .05, .25, .5, .75, .95, .99]
>>> met = ElasticNetCV(
 l1_ratio=l1_ratio,
 n_jobs=-1)
>>> p = np.zeros_like(target)
>>> for train,test in kf:
...     met.fit(data[train],target[train])
...    p[test] = met.predict(data[test])
>>> plt.scatter(p, y)
>>> # Add diagonal line for reference
>>> # (represents perfect agreement)
>>> plt.plot([p.min(), p.max()], [p.min(), p.max()])

这导致以下图表:

以原则方式设置超参数

我们可以看到,在值范围的底端,预测结果并不很好匹配。这可能是因为在目标范围的这一端元素较少(这也意味着这只影响了少数数据点)。

最后一点:使用内部交叉验证循环来设置参数的方法在 scikit-learn 中也是可用的,可以使用网格搜索。实际上,我们在上一章中已经使用过了。

摘要

在本章中,我们从最古老的技巧开始,普通最小二乘回归。尽管有几个世纪的历史,但它仍然经常是回归问题的最佳解决方案。然而,我们也看到了更现代的方法,避免过拟合,并且在具有大量特征时可以给出更好的结果。我们使用了 Ridge、Lasso 和 ElasticNets;这些是回归问题的最先进方法。

我们再次看到了依赖训练误差来估计泛化能力的危险:它可能会给出过于乐观的估计,甚至使我们的模型在训练误差上为零,但我们知道它完全没有用。在思考这些问题时,我们引入了二级交叉验证,这是一个重要的概念,许多领域的从业者仍未完全理解。

在本章中,我们能够依赖 scikit-learn 来支持我们想要执行的所有操作,包括一种简便的方法来实现正确的交叉验证。带有内部交叉验证循环的 ElasticNet(用于参数优化,scikit-learn 中由ElasticNetCV实现)可能应该成为你回归分析的默认方法。

使用替代方法的一个原因是当你对稀疏解感兴趣时。在这种情况下,纯 Lasso 解更为合适,因为它会将许多系数设为零。它还会让你从数据中发现少数几个对输出至关重要的变量。了解这些变量的身份本身可能就很有趣,除了获得一个优秀的回归模型之外。

在下一章,我们将讨论推荐系统,这是另一个机器学习问题。我们首先的方法是使用回归来预测消费者产品评分。然后,我们将看到生成推荐的替代模型。

第八章:推荐系统

推荐系统已经成为在线服务和电商的基础之一。这种自动化系统可以为每个用户提供个性化的建议列表(无论是购买的产品、使用的功能还是新的社交连接)。在本章中,我们将看到自动化推荐生成系统的基本工作原理。基于消费者输入的推荐领域通常被称为协同过滤,因为用户通过系统进行协作,帮助彼此找到最佳产品。

在本章的第一部分,我们将看到如何利用消费者过去的产品评分来预测新的评分。我们从一些有用的想法开始,然后将它们结合在一起。在结合时,我们使用回归分析来学习它们可以如何最优地组合。这也将让我们探讨机器学习中的一个通用概念:集成学习。

在本章的第二部分,我们将探讨一种不同的推荐学习方法:购物篮分析。与我们拥有数字评分的情况不同,在购物篮分析中,我们仅拥有关于购物篮的信息,也就是说,哪些商品是一起购买的。目标是学习如何进行推荐。你可能已经在在线购物中见过类似“购买 X 的人也购买了 Y”这样的功能。我们将开发出一个类似的功能。

评分预测和推荐

如果你在过去 10 年里使用过任何在线购物系统,你可能已经见过这些推荐。有些类似于亚马逊的“购买 X 的客户也购买了 Y”。这些将在本章的购物篮分析部分中进一步探讨。其他推荐则基于预测产品的评分,比如电影的评分。

基于过去产品评分学习推荐的问题由 Netflix 大奖而闻名,Netflix 大奖是 Netflix 举办的百万美元机器学习公开挑战赛。Netflix(在美国和英国非常知名,并正在进行国际扩展)是一家电影租赁公司。传统上,你会收到邮寄来的 DVD;最近,Netflix 专注于在线电影和电视节目的流媒体播放。从一开始,Netflix 的一个独特之处在于它允许用户对看过的电影进行评分。Netflix 随后使用这些评分向用户推荐其他电影。在这个机器学习问题中,你不仅知道用户看了哪些电影,还知道用户如何评分。

2006 年,Netflix 将其数据库中大量客户对电影的评分数据公开,举行了一场公开挑战。目标是改进 Netflix 内部的评分预测算法。任何能够将其提升 10%或更多的人将赢得 100 万美元的奖金。2009 年,一个名为 BellKor's Pragmatic Chaos 的国际团队成功突破了这个标准,并获得了奖金。他们是在另一个团队 The Ensemble 的前 20 分钟成功做到的,并且同样突破了 10%的改进——这场持续了数年的竞赛最终以一个激动人心的结局落下帷幕。

提示

现实世界中的机器学习

关于 Netflix 奖已经有很多相关的讨论,你可以通过阅读相关资料了解更多。获奖的技术是先进的机器学习方法与大量数据预处理工作相结合的结果。例如,有些用户倾向于给所有电影打很高的分,而有些用户总是给出较低的评价;如果你在预处理阶段不考虑这一点,你的模型就会受到影响。为了获得好的结果,还需要进行其他的归一化处理:例如,电影的上映年份以及它收到的评价数量。好的算法很重要,但你始终需要“亲自动手”,根据你手头数据的特性调整你的方法。数据的预处理和归一化往往是机器学习过程中最耗时的部分。然而,这也是对系统最终表现产生最大影响的地方。

关于 Netflix 奖,首先要注意的是它的难度。大致来说,Netflix 使用的内部系统比没有推荐的系统(即每个电影仅给所有用户的平均值)好 10%左右。目标只是要在此基础上再提升 10%。最终,获奖系统比没有个性化的系统好大约 20%。然而,要实现这个目标,付出了巨大的时间和努力。尽管 20%的提升看起来并不多,但最终的结果是一个在实际中非常有用的系统。

不幸的是,出于法律原因,这个数据集目前已经无法获取。尽管数据是匿名的,但人们担心可能会揭示出客户的身份以及电影租赁的私人信息。不过,我们可以使用一个具有类似特征的学术数据集。这些数据来自 GroupLens,这是明尼苏达大学的一个研究实验室。

我们如何解决类似 Netflix 的评分预测问题呢?我们将看到两种不同的方法,邻域方法和回归方法。我们还会看到如何将这两种方法结合起来,得出一个统一的预测结果。

划分训练集和测试集

从高层次上看,将数据集划分为训练数据和测试数据,以便获得系统性能的原则性估计,方法与之前的章节相同:我们将取一定比例的数据点(我们将使用 10%)并将其保留用于测试;其余数据用于训练。然而,由于在此上下文中数据的结构不同,因此代码也有所不同。第一步是从磁盘加载数据,我们使用以下函数:

def load():
 import numpy as np
 from scipy import sparse

 data = np.loadtxt('data/ml-100k/u.data')
 ij = data[:, :2]
 ij -= 1  # original data is in 1-based system
 values = data[:, 2]
 reviews = sparse.csc_matrix((values, ij.T)).astype(float)
 return reviews.toarray()

请注意,这个矩阵中的零条目表示缺失的评分。

>>> reviews = load()
>>> U,M = np.where(reviews)

现在,我们使用标准的随机模块选择要测试的索引:

>>> import random
>>> test_idxs = np.array(random.sample(range(len(U)), len(U)//10))

现在,我们构建 train 矩阵,它类似于 reviews,但将测试条目的值设为零:

>>> train = reviews.copy()
>>> train[U[test_idxs], M[test_idxs]] = 0

最后,test 矩阵只包含测试值:

>>> test = np.zeros_like(reviews)
>>> test[U[test_idxs], M[test_idxs]] = reviews[U[test_idxs], M[test_idxs]]

从现在起,我们将开始处理训练数据,并尝试预测数据集中所有缺失的条目。也就是说,我们将编写代码,为每个(用户,电影)对分配一个推荐。

对训练数据进行标准化

正如我们之前讨论的那样,最好的做法是对数据进行标准化,以去除明显的电影或用户特定的效应。我们将使用一种非常简单的标准化方法,之前也使用过:转换为 z 分数。

不幸的是,我们不能简单地使用 scikit-learn 的标准化对象,因为我们必须处理数据中的缺失值(即,并非所有电影都被所有用户评分)。因此,我们希望通过实际存在的值的均值和标准差进行标准化。

我们将编写自己的类,它忽略缺失值。这个类将遵循 scikit-learn 的预处理 API:

class NormalizePositive(object):

我们希望选择标准化的轴。默认情况下,我们沿第一个轴进行标准化,但有时沿第二个轴进行标准化也会很有用。这遵循了许多其他与 NumPy 相关的函数的约定:

 def __init__(self, axis=0):
 self.axis = axis

最重要的方法是 fit 方法。在我们的实现中,我们计算的是非零值的均值和标准差。请记住,零值表示“缺失值”:

 def fit(self, features, y=None):

如果轴为 1,我们将在转置数组上进行操作,如下所示:

 if self.axis == 1:
 features = features.T
 #  count features that are greater than zero in axis 0:
 binary = (features > 0)
 count0 = binary.sum(axis=0)

 # to avoid division by zero, set zero counts to one:
 count0[count0 == 0] = 1.

 # computing the mean is easy:
 self.mean = features.sum(axis=0)/count0

 # only consider differences where binary is True:
 diff = (features - self.mean) * binary
 diff **= 2
 # regularize the estimate of std by adding 0.1
 self.std = np.sqrt(0.1 + diff.sum(axis=0)/count0)
 return self

我们将 0.1 加到标准差的直接估计中,以避免在样本很少且所有样本可能完全相同的情况下低估标准差的值。所用的确切值对最终结果影响不大,但我们需要避免除以零的情况。

transform 方法需要维护二进制结构,如下所示:

 def transform(self, features):
 if self.axis == 1:
 features = features.T
 binary = (features > 0)
 features = features - self.mean
 features /= self.std
 features *= binary
 if self.axis == 1:
 features = features.T
 return features

请注意,当轴为 1 时,我们如何处理输入矩阵的转置,然后再将其转换回来,以便返回值与输入矩阵的形状相同。inverse_transform 方法执行逆操作以进行如这里所示的转换:

 def inverse_transform(self, features, copy=True):
 if copy:
 features = features.copy()
 if self.axis == 1:
 features = features.T
 features *= self.std
 features += self.mean
 if self.axis == 1:
 features = features.T
 return features

最后,我们添加了 fit_transform 方法,顾名思义,它结合了 fittransform 两个操作:

 def fit_transform(self, features):
 return self.fit(features).transform(features)

我们定义的方法(fittransformtransform_inversefit_transform)与 sklearn.preprocessing 模块中定义的对象相同。在接下来的章节中,我们将首先规范化输入,生成规范化的预测值,最后应用逆变换以获得最终预测结果。

一种基于邻域的推荐方法

邻域概念可以通过两种方式实现:用户邻域或电影邻域。用户邻域基于一个非常简单的概念:要知道一个用户如何评分电影,找到与他们最相似的用户,并查看他们的评分。我们暂时只考虑用户邻域。在本节结束时,我们将讨论如何调整代码来计算电影邻域。

我们现在要探讨的一种有趣技巧是,仅仅查看每个用户评分过的电影,即使不查看他们给出的评分。即使我们有一个二元矩阵,其中用户评分的电影用 1 表示,未评分的电影用 0 表示,我们仍然可以做出有用的预测。事后看来,这完全是合理的;我们并不是随机选择电影观看,而是选择那些我们已经有一定喜好预期的电影。我们也不是随机选择要评分的电影,而是可能只会评分那些我们感受最强烈的电影(当然,也有例外,但平均来看,这大概是对的)。

我们可以将矩阵的值可视化为一张图像,每个评分表示为一个小方块。黑色表示没有评分,灰度级表示评分值。

可视化数据的代码非常简单(你可以调整它来显示矩阵的更多部分,而不仅仅是本书中能显示的部分),如下所示:

>>> from matplotlib import pyplot as plt
>>> # Build an instance of the object we defined above
>>> norm = NormalizePositive(axis=1)
>>> binary = (train > 0)
>>> train = norm.fit_transform(train)
>>> # plot just 200x200 area for space reasons
>>> plt.imshow(binary[:200, :200], interpolation='nearest')

以下截图是该代码的输出:

一种基于邻域的推荐方法

我们可以看到矩阵是稀疏的——大部分格子是黑色的。我们也可以看到,有些用户评分的电影比其他用户多,而有些电影比其他电影有更多的评分。

我们现在将使用这个二元矩阵来预测电影评分。一般的算法将是(伪代码)如下:

  1. 对于每个用户,按相似度对其他所有用户进行排序。此步骤中,我们将使用二元矩阵,并使用相关性作为相似度的度量(将二元矩阵视为零和一使得我们可以进行这种计算)。

  2. 当我们需要估计一个(用户,电影)对的评分时,我们会查看所有评分过该电影的用户,并将他们分为两组:最相似的那一半和最不相似的那一半。然后我们使用最相似那一半的平均值作为预测。

我们可以使用scipy.spatial.distance.pdist函数来获取所有用户之间的距离矩阵。这个函数返回相关距离,它通过反转相关值来转化,使得更大的数值表示不那么相似。从数学上讲,相关距离是A neighborhood approach to recommendations,其中A neighborhood approach to recommendations是相关值。代码如下:

>>> from scipy.spatial import distance
>>> # compute all pair-wise distances:
>>> dists = distance.pdist(binary, 'correlation')
>>> # Convert to square form, so that dists[i,j]
>>> # is distance between binary[i] and binary[j]:
>>> dists = distance.squareform(dists)

我们可以使用这个矩阵来选择每个用户的最近邻居。这些用户是最相似的用户。

>>> neighbors = dists.argsort(axis=1)

现在,我们遍历所有用户以估算所有输入的预测值:

>>> # We are going to fill this matrix with results
>>> filled = train.copy()
>>> for u in range(filled.shape[0]):
...     # n_u is neighbors of user
...     n_u = neighbors[u, 1:]
...     # t_u is training data

...     for m in range(filled.shape[1]):
...         # get relevant reviews in order!
...         revs = [train[neigh, m]
...                    for neigh in n_u
...                         if binary  [neigh, m]]
...         if len(revs):
...             # n is the number of reviews for this movie
...             n = len(revs)
...             # consider half of the reviews plus one
...             n //= 2
...             n += 1
...             revs = revs[:n]
...             filled[u,m] = np.mean(revs )

上述代码片段中的关键部分是通过正确的值进行索引,以选择已经对电影评分的邻居。然后,我们选择距离用户最近的那一半(在rev[:n]这一行中)并对它们求平均。由于一些电影有很多评论,而其他电影的评论非常少,因此很难为所有情况找到一个统一的用户数量。选择可用数据的那一半是更通用的方法。

要得到最终结果,我们需要将预测值反归一化,具体如下:

>>> predicted = norm.inverse_transform(filled)

我们可以使用上一章学到的相同度量方法:

>>> from sklearn import metrics
>>> r2 = metrics.r2_score(test[test > 0], predicted[test > 0])
>>> print('R2 score (binary neighbors): {:.1%}'.format(r2))
R2 score (binary neighbors): 29.5%

上述代码计算了用户邻居的结果,但我们可以通过简单地转置输入矩阵来计算电影邻居。事实上,代码计算的是输入矩阵的行的邻居。

所以我们可以重新运行以下代码,只需在顶部插入以下行:

>>> reviews = reviews.T
>>> # use same code as before …
>>> r2 = metrics.r2_score(test[test > 0], predicted[test > 0])
>>> print('R2 score (binary movie neighbors): {:.1%}'.format(r2))
R2 score (binary movie neighbors): 29.8%

因此我们可以看到,结果并没有那么不同。

在本书的代码库中,邻居代码已被封装成一个简单的函数,便于重用。

一种回归方法推荐系统

邻居的替代方案是将推荐问题表述为回归问题,并应用我们在上一章学到的方法。

我们还考虑为什么这个问题不适合分类模型。我们当然可以尝试学习一个五类模型,每个类别对应一种可能的电影评分。这个方法有两个问题:

  • 不同的错误类型是完全不同的。例如,把一部 5 星电影误认为 4 星电影并不是那么严重的错误,而把 5 星电影误认为 1 星电影就严重得多。

  • 中间值是有意义的。即使我们的输入只有整数值,说预测值是 4.3 也是完全有意义的。我们可以看到,这与 3.5 的预测是不同的,尽管它们都四舍五入到 4。

这两个因素共同表明,分类方法不适合这个问题。回归框架更加适合。

对于基本方法,我们有两种选择:我们可以构建电影特定的模型或用户特定的模型。在我们的例子中,我们将首先构建用户特定的模型。这意味着,对于每个用户,我们将用户已评分的电影作为目标变量。输入是其他用户的评分。我们假设,这样做将为与我们用户相似的用户赋予较高的值(或者为喜欢我们用户不喜欢的电影的用户赋予负值)。

设置traintest矩阵与之前一样(包括执行标准化步骤)。因此,我们直接进入学习步骤。首先,我们按如下方式实例化一个回归器:

>>> reg = ElasticNetCV(alphas=[
 0.0125, 0.025, 0.05, .125, .25, .5, 1., 2., 4.])

我们构建一个数据矩阵,矩阵中包含每一对(用户,电影)的评分。我们将其初始化为训练数据的副本:

>>> filled = train.copy()

现在,我们遍历所有用户,每次仅基于该用户提供的数据学习回归模型:

>>> for u in range(train.shape[0]):
...     curtrain = np.delete(train, u, axis=0)
...     # binary records whether this rating is present
...     bu = binary[u]
...     # fit the current user based on everybody else
...     reg.fit(curtrain[:,bu].T, train[u, bu])
...     # Fill in all the missing ratings
...     filled[u, ~bu] = reg.predict(curtrain[:,~bu].T)

评估该方法可以像以前一样进行:

>>> predicted = norm.inverse_transform(filled)
>>> r2 = metrics.r2_score(test[test > 0], predicted[test > 0])
>>> print('R2 score (user regression): {:.1%}'.format(r2))
R2 score (user regression): 32.3%

与之前一样,我们可以调整这段代码,使用转置矩阵执行电影回归。

结合多种方法

我们现在将上述方法结合在一起,进行单一预测。直观上看,这个想法是好的,但我们如何在实践中做到这一点呢?也许,第一个想到的办法是我们可以对预测结果进行平均。这可能会给出不错的结果,但并没有理由认为所有的预测结果应该被同等对待。可能某些预测比其他预测更好。

我们可以尝试加权平均,在求和之前将每个预测值乘以一个给定的权重。然而,我们如何找到最佳的权重呢?当然,我们是通过从数据中学习它们!

提示

集成学习

我们正在使用一种机器学习中的通用技术,这种技术不仅适用于回归问题:集成学习。我们学习一个预测器的集成(即一组预测器)。然后,我们将它们组合起来得到一个单一的输出。有趣的是,我们可以将每一个预测视为一个新的特征,我们现在只是根据训练数据组合特征,这正是我们一直在做的事情。请注意,这里我们是在做回归,但相同的思路也适用于分类问题:你学习多个分类器,然后使用一个主分类器,该分类器接受所有分类器的输出并给出最终预测。不同形式的集成学习在于如何组合基础预测器。

为了结合这些方法,我们将使用一种叫做堆叠学习的技术。其思路是,你先学习一组预测器,然后将这些预测器的输出作为另一个预测器的特征。你甚至可以有多层,每一层都通过使用上一层的输出作为其预测的特征来进行学习。看看下面的图示:

结合多种方法

为了拟合这个组合模型,我们将训练数据分成两部分。或者,我们可以使用交叉验证(原始的堆叠学习模型就是这样工作的)。然而,在这种情况下,我们有足够的数据,通过留出一部分数据来获得良好的估计。

就像在调整超参数时一样,我们需要两个层次的训练/测试划分:一个更高层次的划分,然后在训练划分内部,进行第二次划分,以便拟合堆叠学习器,如下所示:

>>> train,test = load_ml100k.get_train_test(random_state=12)
>>> # Now split the training again into two subgroups
>>> tr_train,tr_test = load_ml100k.get_train_test(train, random_state=34)
>>> # Call all the methods we previously defined:
>>> # these have been implemented as functions:
>>> tr_predicted0 = regression.predict(tr_train)
>>> tr_predicted1 = regression.predict(tr_train.T).T
>>> tr_predicted2 = corrneighbours.predict(tr_train)
>>> tr_predicted3 = corrneighbours.predict(tr_train.T).T
>>> tr_predicted4 = norm.predict(tr_train)
>>> tr_predicted5 = norm.predict(tr_train.T).T

>>> # Now assemble these predictions into a single array:
>>> stack_tr = np.array([
...     tr_predicted0[tr_test > 0],
...     tr_predicted1[tr_test > 0],
...     tr_predicted2[tr_test > 0],
...     tr_predicted3[tr_test > 0],
...     tr_predicted4[tr_test > 0],
...     tr_predicted5[tr_test > 0],
...     ]).T

>>> # Fit a simple linear regression
>>> lr = linear_model.LinearRegression()
>>> lr.fit(stack_tr, tr_test[tr_test > 0])

现在,我们将整个过程应用于测试集并进行评估:

>>> stack_te = np.array([
...     tr_predicted0.ravel(),
...     tr_predicted1.ravel(),
...     tr_predicted2.ravel(),
...     tr_predicted3.ravel(),
...     tr_predicted4.ravel(),
...     tr_predicted5.ravel(),
...     ]).T
>>> predicted = lr.predict(stack_te).reshape(train.shape)

评估与之前相同:

>>> r2 = metrics.r2_score(test[test > 0], predicted[test > 0])
>>> print('R2 stacked: {:.2%}'.format(r2))
R2 stacked: 33.15%

堆叠学习的结果比任何单一方法的结果都要好。将方法结合起来通常是一种简单的方式,可以获得小幅的性能提升,但结果并不会引起轰动。

通过灵活地组合多种方法,我们可以简单地通过将任何想法添加到学习器的混合中,并让系统将其折入预测中来尝试任何我们希望的想法。例如,我们可以替换最近邻代码中的邻域标准。

然而,我们必须小心不要让数据集过拟合。事实上,如果我们随意尝试太多东西,其中一些可能在这个数据集上效果很好,但无法推广到其他数据。尽管我们正在划分数据集,但我们并没有严格地进行交叉验证我们的设计决策。为了获得良好的估计,并且如果数据量充足,你应该将一部分数据留着,直到你有一个即将投入生产的最终模型。然后,在这个保留的数据上测试你的模型,可以给你一个无偏的预测,了解它在现实世界中的表现如何。

购物篮分析

到目前为止,我们讨论的方法在你拥有用户对产品喜好程度的数字评分时效果很好。但这种信息并不总是可用,因为它需要消费者的主动行为。

购物篮分析是一种推荐学习的替代模式。在这种模式下,我们的数据仅包括哪些商品是一起购买的;它不包含任何关于单个商品是否被喜好的信息。即使用户有时购买了他们后悔的商品,平均而言,知道他们的购买记录也足以帮助你构建良好的推荐系统。获取这类数据通常比评分数据更容易,因为许多用户不会提供评分,而购物篮数据则是购物的副作用。以下截图展示了亚马逊网站上托尔斯泰经典小说《战争与和平》的页面片段,演示了如何使用这些结果的常见方式:

购物篮分析

这种学习模式不仅仅适用于实际的购物篮,自然也可以应用于任何有一组对象并且需要推荐其他对象的场景。例如,Gmail 会向正在写电子邮件的用户推荐额外的收件人,类似的技术可以应用于这种推荐(我们并不知道 Gmail 内部使用了什么技术;也许,他们像我们之前所做的那样,结合了多种技术)。或者,我们也可以使用这些方法开发一个应用程序,根据用户的浏览历史推荐访问的网页。即使我们处理的是购物,也有可能将顾客的所有购买合并成一个购物篮,而不考虑这些商品是否是一起购买的或是在不同交易中购买的。这取决于商业环境,但请记住,这些技术是灵活的,可以在许多不同的场合中发挥作用。

注意

啤酒和尿布。关于购物篮分析,常常提到的一个故事是尿布和啤酒的故事。这个故事说的是,当超市开始分析他们的数据时,他们发现尿布常常和啤酒一起购买。可以假设是父亲去超市买尿布时顺便也买了啤酒。关于这个故事是否真实,还是仅仅一个都市传说,存在很多讨论。在这种情况下,似乎是事实。1990 年代初,Osco Drug 确实发现,在傍晚时分,啤酒和尿布经常一起购买,这让当时的经理们感到非常惊讶,因为他们之前从未认为这两种产品有任何相似性。事实并非如此的是,这并没有导致商店将啤酒陈列架移到尿布区域附近。而且,我们也不知道是否真的是父亲比母亲(或祖父母)更多地购买啤酒和尿布。

获取有用的预测

这不仅仅是“购买了 X 的顾客也购买了 Y”,尽管许多在线零售商是这样表述的(参见之前给出的亚马逊截图);一个真正的系统不能这样运作。为什么不行?因为这样的系统会被非常频繁购买的商品所欺骗,推荐的只是那些流行的商品,而没有任何个性化的推荐。

例如,在超市中,许多顾客每次购物时都会购买面包,或者购买的时间非常接近(为了说明问题,我们假设有 50%的购物包含面包)。因此,如果你关注任何特定商品,比如洗碗液,并查看与洗碗液一起常常购买的商品,你可能会发现面包常常与洗碗液一起购买。事实上,仅仅通过随机机会,每次有人购买洗碗液时,50%的几率他们也会购买面包。然而,面包与其他商品一起购买是因为每个人购买面包的频率都非常高。

我们真正寻找的是“购买了 X 的顾客,比那些没有购买 X 的普通顾客更有可能购买 Y”。如果你购买了洗碗机洗涤剂,你可能会购买面包,但不会比基准更频繁。类似地,一家书店如果只是推荐畅销书,而不考虑你已经购买的书籍,那就不算是很好的个性化推荐。

分析超市购物篮

作为例子,我们将看一个包含比利时超市匿名交易的数据集。这个数据集由哈瑟尔特大学的 Tom Brijs 提供。由于隐私问题,数据已经被匿名化,因此我们只有每个产品的编号,购物篮是由一组编号组成。该数据文件可以从多个在线来源获得(包括本书的伴随网站)。

我们首先加载数据集并查看一些统计信息(这总是个好主意):

>>> from collections import defaultdict
>>> from itertools import chain

>>> # File is downloaded as a compressed file
>>> import gzip
>>> # file format is a line per transaction
>>> # of the form '12 34 342 5...'
>>> dataset = [[int(tok) for tok in line.strip().split()]
...         for line in gzip.open('retail.dat.gz')]
>>> # It is more convenient to work with sets
>>> dataset = [set(d) for d in dataset]
>>> # count how often each product was purchased:
>>> counts = defaultdict(int)
>>> for elem in chain(*dataset):
...     counts[elem] += 1

我们可以在以下表格中看到总结的结果:

购买次数 产品数量
仅购买一次 2,224
2 或 3 2,438
4 到 7 2,508
8 到 15 2,251
16 到 31 2,182
32 到 63 1,940
64 到 127 1,523
128 到 511 1,225
512 次或更多 179

有许多产品只被购买了少数几次。例如,33%的产品购买次数为四次或更少。然而,这仅占所有购买的 1%。这种许多产品仅被购买少数次的现象有时被称为长尾现象,随着互联网使得库存和销售小众商品变得更加便宜,这一现象变得更加显著。为了能够为这些产品提供推荐,我们需要更多的数据。

有一些开源的购物篮分析算法实现,但没有一个与 scikit-learn 或我们一直在使用的其他软件包很好地集成。因此,我们将自己实现一个经典的算法。这个算法叫做 Apriori 算法,虽然它有点老(由 Rakesh Agrawal 和 Ramakrishnan Srikant 于 1994 年发布),但它仍然有效(算法当然永远有效,只是会被更好的想法所取代)。

从形式上讲,Apriori 算法接收一个集合(即你的购物篮),并返回作为子集非常频繁的集合(即共同出现在许多购物篮中的商品)。

该算法采用自下而上的方法:从最小的候选项集(由一个单独的元素组成)开始,逐步构建,每次添加一个元素。正式来说,算法接受一个购物篮集和应考虑的最小输入(我们称之为 minsupport)。第一步是考虑所有仅包含一个元素且具有最小支持度的购物篮。然后,这些项集以所有可能的方式组合,构建出二元素购物篮。接着,筛选出仅保留那些具有最小支持度的项集。然后,考虑所有可能的三元素购物篮,并保留那些具有最小支持度的项集,如此继续。Apriori 的技巧在于,当构建一个更大的购物篮时,它只需要考虑由更小的集合构成的购物篮

以下图示展示了该算法的示意图:

分析超市购物篮

现在我们将在代码中实现这个算法。我们需要定义我们寻找的最小支持度:

>>> minsupport = 80

支持度是指一组产品一起被购买的次数。Apriori 的目标是找到具有高支持度的项集。从逻辑上讲,任何支持度高于最小支持度的项集只能由那些本身至少具有最小支持度的项组成:

>>> valid = set(k for k,v in counts.items()
...           if (v >= minsupport))

我们的初始项集是单一项集(包含单个元素的集合)。特别地,所有至少具有最小支持度的单一项集都是频繁项集:

>>>  itemsets = [frozenset([v]) for v in valid]

现在,我们的循环如下所示:

>>> freqsets = []
>>> for i in range(16):
...     nextsets = []
...     tested = set()
...     for it in itemsets:
...         for v in valid:
...             if v not in it:
...                 # Create a new candidate set by adding v to it
...                 c = (it | frozenset([v]))
...                 # check If we have tested it already
...                 if c in tested:
...                     continue
...                 tested.add(c)
...
...                 # Count support by looping over dataset
...                 # This step is slow.
...                 # Check `apriori.py` for a better implementation.
...                 support_c = sum(1 for d in dataset if d.issuperset(c))
...                 if support_c > minsupport:
...                     nextsets.append(c)
...     freqsets.extend(nextsets)
...     itemsets = nextsets
...     if not len(itemsets):
...         break
>>> print("Finished!")
Finished!

这个方法是正确的,但比较慢。一个更好的实现有更多的基础设施来避免遍历所有数据集来获取计数(support_c)。特别地,我们跟踪哪些购物篮包含哪些频繁项集。这样可以加速循环,但也使代码更难理解。因此,我们在这里不展示它。像往常一样,您可以在本书的配套网站上找到这两种实现。网站上的代码也被封装成一个函数,可以应用于其他数据集。

Apriori 算法返回频繁项集,即那些出现频率超过某一阈值(由代码中的minsupport变量给定)的购物篮。

关联规则挖掘

频繁项集本身并不是很有用。下一步是构建关联规则。由于这个最终目标,整个购物篮分析领域有时被称为关联规则挖掘。

关联规则是一种“如果 X,则 Y”的语句,例如,“如果顾客购买了《战争与和平》,那么他们将购买《安娜·卡列尼娜》”。请注意,这条规则并不是确定性的(并不是所有购买 X 的顾客都会购买 Y),但每次都要这样表达是很繁琐的:“如果顾客购买了 X,他购买 Y 的可能性比基线高”;因此,我们说“如果 X,则 Y”,但我们是从概率的角度来理解这句话。

有趣的是,前件和结论可能都包含多个对象:购买了 X、Y 和 Z 的顾客也购买了 A、B 和 C。多个前件可能使你能够做出比单个项目更具体的预测。

你可以通过尝试所有可能的 X 蕴含 Y 的组合,从频繁项集生成规则。生成这些规则很容易。然而,你只希望得到有价值的规则。因此,我们需要衡量规则的价值。一个常用的度量叫做提升度。提升度是应用规则所得到的概率与基准概率之间的比值,公式如下:

关联规则挖掘

在上述公式中,P(Y)表示包含 Y 的所有交易所占的比例,而 P(Y|X)表示在交易包含 X 的前提下,包含 Y 的交易所占的比例。使用提升度可以避免推荐畅销书的问题;对于畅销书,P(Y)和 P(Y|X)都将较大。因此,提升度会接近 1,规则将被视为无关紧要。实际上,我们希望提升度的值至少为 10,甚至可能达到 100。

请参考以下代码:

>>> minlift = 5.0
>>> nr_transactions = float(len(dataset))
>>> for itemset in freqsets:
...       for item in itemset:
...         consequent = frozenset([item])
...         antecedent = itemset-consequent
...         base = 0.0
...         # acount: antecedent count
...         acount = 0.0
... 
...         # ccount : consequent count
...         ccount = 0.0
...         for d in dataset:
...           if item in d: base += 1
...           if d.issuperset(itemset): ccount += 1
...           if d.issuperset(antecedent): acount += 1
...         base /= nr_transactions
...         p_y_given_x = ccount/acount
...         lift = p_y_given_x / base
...         if lift > minlift:
...             print('Rule {0} ->  {1} has lift {2}'
...                   .format(antecedent, consequent,lift))

以下表格展示了部分结果。计数是指包含仅后件(即该商品被购买的基本比率)、前件中的所有项以及前件和后件中的所有项的交易数量。

前件 后件 后件计数 前件计数 前件和后件计数 提升度
1,378, 1,379, 1,380 1,269 279(0.3%) 80 57 225
48, 41, 976 117 1026(1.1%) 122 51 35
48, 41, 1,6011 16,010 1316(1.5%) 165 159 64

例如,我们可以看到,有 80 笔交易中购买了 1,378、1,379 和 1,380 三个商品。在这些交易中,57 笔也包含了 1,269,因此估算的条件概率为 57/80 ≈ 71%。与所有交易中只有 0.3%包含 1,269 这一事实相比,这给我们带来了 255 的提升度。

必须在这些计数中拥有足够数量的交易,以便能够做出相对可靠的推断,这就是为什么我们必须首先选择频繁项集的原因。如果我们从不频繁项集生成规则,计数会非常小;因此,相关值将毫无意义(或者会受到非常大的误差范围的影响)。

请注意,从这个数据集中发现了更多的关联规则:该算法发现了 1,030 条规则(要求支持至少 80 个购物篮,并且最小提升度为 5)。与如今互联网所能处理的数据集相比,这仍然是一个较小的数据集。对于包含数百万笔交易的数据集,你可以预计会生成成千上万的规则,甚至是数百万条规则。

然而,对于每个客户或每个产品,只有少数几个规则在任何给定时刻是相关的。因此,每个客户只会收到少量推荐。

更高级的购物篮分析

现在有一些比 Apriori 更快的购物篮分析算法。我们之前看到的代码比较简单,足够满足我们的需求,因为我们只有大约 10 万个交易记录。如果我们有数百万条交易记录,可能值得使用更快的算法。不过需要注意的是,学习关联规则通常可以离线进行,在这种情况下效率不是那么大的问题。

还有一些方法可以处理时间序列信息,从而得出考虑到购买顺序的规则。例如,假设某人购买了大量派对用品后,可能会回来购买垃圾袋。因此,在第一次访问时推荐垃圾袋可能是有意义的。然而,向所有购买垃圾袋的人推荐派对用品就不太合适了。

总结

本章中,我们从使用回归进行评分预测开始。我们看到了一些不同的方式来实现这一目标,然后通过学习一组权重将它们结合成一个单一的预测。这种技术,特别是堆叠学习,作为一种集成学习方法,是一种可以在许多情况下使用的通用技术,不仅仅适用于回归。它允许你结合不同的思路,即使它们的内部机制完全不同;你也可以将它们的最终输出结合起来。

在本章的后半部分,我们转变了方向,研究了另一种产生推荐的模式:购物篮分析或关联规则挖掘。在这种模式下,我们试图发现“购买 X 的客户很可能对 Y 感兴趣”的(概率性)关联规则。这利用了仅从销售数据生成的数据,而无需用户对商品进行数值评分。目前,scikit-learn 中没有这个功能,所以我们编写了自己的代码。

关联规则挖掘需要小心,不能只是向每个用户推荐畅销书(否则,个性化推荐还有什么意义?)。为了做到这一点,我们学习了如何通过称为“规则提升度”的度量,衡量规则相对于基线的价值。

到目前为止,我们已经看到了机器学习的主要模式:分类。在接下来的两章中,我们将探索用于两种特定数据类型的技术,即音乐和图像。我们的第一个目标是构建一个音乐类型分类器。

第九章:分类 – 音乐流派分类

到目前为止,我们的训练数据实例每个都可以通过一组特征值向量轻松描述。例如,在 Iris 数据集中,花卉是通过包含花的某些部分长度和宽度值的向量表示的。在基于文本的示例中,我们可以将文本转化为词袋表示,并手动创建自己的特征来捕捉文本中的某些方面。

在本章中,当我们尝试根据流派对歌曲进行分类时,情况将有所不同。例如,我们应该如何表示一首三分钟的歌曲呢?我们是否应该取 MP3 表示中的每个单独的比特?可能不是,因为将其视为文本并创建类似于“声音片段袋”的东西肯定会太复杂。然而,我们仍然必须将一首歌曲转换为一系列足够描述它的值。

绘制我们的路线图

本章将展示如何在一个超出我们舒适区的领域中构建一个合理的分类器。首先,我们将不得不使用基于声音的特征,这比我们之前使用的基于文本的特征复杂得多。接着,我们将学习如何处理多分类问题,而目前为止我们只遇到过二分类问题。此外,我们还将了解一些新的分类性能衡量方法。

假设我们遇到一个场景,在某种原因下,我们的硬盘中出现了一堆随机命名的 MP3 文件,假设这些文件包含音乐。我们的任务是根据音乐流派将它们分类到不同的文件夹中,比如爵士、古典、乡村、流行、摇滚和金属。

获取音乐数据

我们将使用 GTZAN 数据集,该数据集常用于基准测试音乐流派分类任务。它包含 10 个不同的流派,我们为了简单起见,只使用其中的 6 个:古典音乐、爵士乐、乡村音乐、流行音乐、摇滚乐和金属乐。该数据集包含每个流派 100 首歌曲的前 30 秒。我们可以从opihi.cs.uvic.ca/sound/genres.tar.gz下载数据集。

提示

这些音轨以 22,050 赫兹(每秒 22,050 次采样)单声道的 WAV 格式录制。

转换为 WAV 格式

事实证明,如果我们以后想要在私人 MP3 收藏中测试我们的分类器,我们可能无法提取太多有意义的信息。这是因为 MP3 是一种有损的音乐压缩格式,它会去除人耳无法感知的部分。这种格式对存储非常友好,因为使用 MP3,你可以在设备上存储更多的歌曲。但对于我们的任务来说,这就不太合适了。为了分类,我们使用 WAV 文件会更简单一些,因为它们可以被scipy.io.wavfile包直接读取。因此,如果我们希望使用分类器处理 MP3 文件,我们就必须将它们转换成 WAV 格式。

提示

如果你附近没有转换工具,你可以查看 sox.sourceforge.net上的 SoX。它号称是声音处理的瑞士军刀,我们也同意这个大胆的说法。

将所有音乐文件保存为 WAV 格式的一个优点是,SciPy 工具包可以直接读取它:

>>> sample_rate, X = scipy.io.wavfile.read(wave_filename)

X现在包含了样本,而sample_rate是它们被采样的速率。让我们利用这些信息,快速查看一些音乐文件,初步了解数据的样子。

查看音乐

获取不同风格歌曲“外观”的一种非常方便的方法是为一个风格的歌曲集绘制频谱图。频谱图是歌曲中频率的可视化表示。它在y轴上显示频率的强度,在x轴上显示特定时间间隔的强度。也就是说,颜色越深,某一时间窗口内该频率的强度越大。

Matplotlib 提供了一个方便的函数specgram(),它为我们执行了大部分底层计算和绘图工作:

>>> import scipy
>>> from matplotlib.pyplot import specgram
>>> sample_rate, X = scipy.io.wavfile.read(wave_filename)
>>> print sample_rate, X.shape
22050, (661794,)
>>> specgram(X, Fs=sample_rate, xextent=(0,30))

我们刚刚读取的 WAV 文件的采样率为 22,050 Hz,包含 661,794 个样本。

如果我们现在为这些前 30 秒的不同 WAV 文件绘制频谱图,我们可以看到同一类型歌曲之间的共性,如下图所示:

Looking at music

仅从图像中,我们立刻能看出金属和古典歌曲在频谱上的差异。例如,金属歌曲在大部分频率范围内始终具有较高的强度(它们很有活力!),而古典歌曲则在时间上展示出更为多样化的模式。

应该可以训练一个分类器,以足够高的准确率区分至少金属与古典歌曲。然而,像乡村与摇滚这种其他音乐风格对比可能会更具挑战性。这对我们来说看起来像是一个真正的挑战,因为我们不仅需要区分两类,还要区分六类。我们需要能够合理地区分所有这些类别。

将音乐分解成正弦波分量

我们的计划是从原始样本数据(之前存储在X中的)中提取各个频率强度,并将它们输入分类器。这些频率强度可以通过应用所谓的快速傅里叶变换FFT)来提取。由于傅里叶变换的理论超出了本章的范围,我们只看一个例子,直观地理解它的作用。稍后,我们将把它作为一个黑箱特征提取器。

例如,我们可以生成两个 WAV 文件,sine_a.wavsine_b.wav,它们分别包含 400 Hz 和 3,000 Hz 的正弦波声音。前面提到的“瑞士军刀”SoX,就是实现这一目标的一种方法:

$ sox --null -r 22050 sine_a.wav synth 0.2 sine 400
$ sox --null -r 22050 sine_b.wav synth 0.2 sine 3000

在接下来的图表中,我们绘制了它们的前 0.008 秒。下面我们可以看到正弦波的 FFT。毫不奇怪,我们在对应的正弦波下看到 400 Hz 和 3,000 Hz 的尖峰。

现在,让我们将两者混合,将 400 Hz 的声音音量设置为 3,000 Hz 的音量的一半:

$ sox --combine mix --volume 1 sine_b.wav --volume 0.5 sine_a.wav sine_mix.wav

我们在合成声音的 FFT 图中看到两个尖峰,其中 3,000 Hz 的尖峰几乎是 400 Hz 的两倍大小。

将音乐分解为正弦波组件

对于真实的音乐,我们很快发现 FFT 不像前面的玩具示例那样漂亮:

将音乐分解为正弦波组件

使用 FFT 构建我们的第一个分类器

然而,我们现在可以使用 FFT 创建歌曲的某种音乐指纹。如果我们对几首歌曲这样做,并手动分配相应的音乐类型标签,我们就得到了可以输入到我们第一个分类器的训练数据。

提高实验灵活性

在深入分类器训练之前,让我们先思考一下实验的灵活性。尽管 FFT 中有“快”这个词,但它比我们在基于文本的章节中创建特征的速度要慢得多。而且由于我们仍处于实验阶段,我们可能想考虑如何加速整个特征创建过程。

当然,每次运行分类器时,创建每个文件的 FFT 都会是相同的。因此,我们可以缓存它,并读取缓存的 FFT 表示,而不是完整的 WAV 文件。我们通过create_fft()函数来实现这一点,后者又使用scipy.fft()来创建 FFT。为了简单起见(以及提高速度!),我们在这个示例中将 FFT 组件的数量固定为前 1,000 个。根据我们当前的知识,我们不知道这些是否是与音乐类型分类最相关的组件——只知道它们在前面的 FFT 示例中显示了最高的强度。如果以后我们想使用更多或更少的 FFT 组件,当然需要为每个音频文件重新创建 FFT 表示。

import os
import scipy

def create_fft(fn):
 sample_rate, X = scipy.io.wavfile.read(fn)
 fft_features = abs(scipy.fft(X)[:1000])
 base_fn, ext = os.path.splitext(fn)
 data_fn = base_fn + ".fft"
 scipy.save(data_fn, fft_features)

我们使用 NumPy 的save()函数保存数据,该函数总是将.npy附加到文件名。每个 WAV 文件只需为训练或预测做一次此操作。

对应的 FFT 读取函数是read_fft()

import glob

def read_fft(genre_list, base_dir=GENRE_DIR):
 X = []
 y = []

 for label, genre in enumerate(genre_list):
 genre_dir = os.path.join(base_dir, genre, "*.fft.npy")
 file_list = glob.glob(genre_dir)

 for fn in file_list:
 fft_features = scipy.load(fn)

 X.append(fft_features[:1000])
 y.append(label)

 return np.array(X), np.array(y)

在我们的音乐目录中,我们预期以下音乐类型:

genre_list = ["classical", "jazz", "country", "pop", "rock", "metal"]

训练分类器

让我们使用逻辑回归分类器,它已经在第六章中为我们提供了很好的效果,分类 II - 情感分析。增加的难度是,我们现在面临的是一个多类分类问题,而之前我们只需要区分两类。

需要提到的一个令人惊讶的方面是,从二分类问题切换到多分类问题时准确率的评估。在二分类问题中,我们已经了解到 50%的准确率是最差的情况,因为这个结果仅仅是通过随机猜测就能达到的。而在多分类设置下,50%的准确率可能已经很不错了。例如,在我们的六个类别中,随机猜测的结果大约只有 16.7%的准确率(假设类别大小相同)。

使用混淆矩阵来衡量多类问题中的准确性

在多类问题中,我们不仅要关注我们能多好地正确分类不同的类型。此外,我们还应该注意哪些类别之间我们存在混淆。这可以通过所谓的混淆矩阵来实现,如下所示:

>>> from sklearn.metrics import confusion_matrix
>>> cm = confusion_matrix(y_test, y_pred)
>>> print(cm)
[[26  1  2  0  0  2]
 [ 4  7  5  0  5  3]
 [ 1  2 14  2  8  3]
 [ 5  4  7  3  7  5]
 [ 0  0 10  2 10 12]
 [ 1  0  4  0 13 12]]

该图展示了分类器对每个类别的测试集标签分布。对角线代表正确分类的结果。由于我们有六个类别,因此矩阵是六行六列。矩阵的第一行表示,对于 31 个古典音乐(第一行的总和),分类器预测了 26 个属于古典音乐,1 个属于爵士音乐,2 个属于乡村音乐,2 个属于金属音乐。对角线显示了正确分类的结果。在第一行中,我们看到,在(26+1+2+2)=31 首歌曲中,26 首被正确分类为古典音乐,5 首被误分类。实际上,这并不算太差。第二行则更为令人失望:在 24 首爵士歌曲中,只有 7 首被正确分类——也就是说,准确率只有 29%。

当然,我们遵循前几章中的训练/测试集拆分设置,因此我们实际上需要记录每个交叉验证折中的混淆矩阵。之后,我们需要对其进行平均和归一化处理,以便将结果的范围控制在 0(完全失败)和 1(全部分类正确)之间。

图形化的可视化通常比 NumPy 数组更易于阅读。matplotlib 的matshow()函数是我们的好朋友:

from matplotlib import pylab

def plot_confusion_matrix(cm, genre_list, name, title):
 pylab.clf()
 pylab.matshow(cm, fignum=False, cmap='Blues', 
 vmin=0, vmax=1.0)

 ax = pylab.axes()    ax.set_xticks(range(len(genre_list)))
 ax.set_xticklabels(genre_list)
 ax.xaxis.set_ticks_position("bottom")
 ax.set_yticks(range(len(genre_list)))
 ax.set_yticklabels(genre_list)

 pylab.title(title)
 pylab.colorbar()
 pylab.grid(False)
 pylab.xlabel('Predicted class')
 pylab.ylabel('True class')
 pylab.grid(False)

 pylab.show()

提示

创建混淆矩阵时,请确保选择一个适当的颜色映射(matshow()cmap参数),使得颜色的深浅变化能够立即显现其含义。尤其不推荐使用这些类型的图表的彩虹色图,比如 matplotlib 的默认jet色图,甚至Paired色图。

最终的图表看起来如下所示:

使用混淆矩阵来衡量多类问题中的准确性

对于一个完美的分类器,我们期望从左上角到右下角呈现一条深色的对角线,其他区域则为浅色。在之前的图表中,我们可以立即看到我们的基于 FFT 的分类器离完美还有很大距离。它只正确预测了古典音乐(深色方块)。例如,对于摇滚音乐,它大多数时间都将标签预测为金属音乐。

显然,使用 FFT 点指向了正确的方向(古典音乐类别并不那么糟糕),但这还不足以得到一个不错的分类器。当然,我们可以调整 FFT 组件的数量(固定为 1,000)。但是在深入调整参数之前,我们应该先进行一些研究。结果表明,FFT 确实是一个不错的特征用于类别分类——只是它的精度还不够高。很快,我们将看到如何通过使用经过处理的 FFT 版本来提高分类性能。

然而,在我们进行这个分析之前,我们将学习另一种衡量分类性能的方法。

使用接收器操作特征(ROC)来衡量分类器性能的另一种方式

我们已经了解到,仅仅通过衡量准确率不足以真正评估一个分类器。相反,我们依赖于精准率-召回率P/R)曲线来深入理解分类器的性能。

P/R 曲线有一个姐妹曲线,叫做接收器操作特征ROC),它衡量分类器性能的相似方面,但提供了另一种分类性能的视角。两者的关键区别在于,P/R 曲线更适合于正类比负类更为重要,或者正类样本远少于负类样本的任务。信息检索和欺诈检测是典型的应用领域。另一方面,ROC 曲线则更好地展示了分类器整体表现。

为了更好地理解这些差异,假设我们来看一下先前训练的分类器在正确分类乡村歌曲方面的表现,如下图所示:

使用接收器操作特征来衡量分类器性能的另一种方式

在左侧,我们可以看到 P/R 曲线。对于一个理想的分类器,我们希望看到的曲线是从左上角直接到右上角,再到右下角,从而形成一个面积(AUC)为 1.0 的曲线。

右侧的图展示了相应的 ROC 曲线。它绘制了真正例率与假正例率之间的关系。在这里,一个理想的分类器曲线应该从左下角走到左上角,然后到达右上角。而一个随机分类器则表现为从左下角到右上角的直线,如虚线所示,AUC 为 0.5。因此,我们不能将 P/R 曲线的 AUC 与 ROC 曲线的 AUC 进行比较。

独立于曲线,在比较同一数据集上两个不同分类器时,我们总是可以安全地假设,一个分类器的 P/R 曲线的 AUC 较高,也意味着其对应的 ROC 曲线的 AUC 较高,反之亦然。因此,我们通常不会生成两者。关于这一点的更多信息可以在 Davis 和 Goadrich(ICML,2006)撰写的非常有见地的论文《精准率-召回率与 ROC 曲线的关系》中找到。

以下表格总结了 P/R 曲线与 ROC 曲线之间的差异:

x 轴 y 轴
P/R 使用接收者操作特征衡量分类器性能的另一种方法 使用接收者操作特征衡量分类器性能的另一种方法
ROC 使用接收者操作特征衡量分类器性能的另一种方法 使用接收者操作特征衡量分类器性能的另一种方法

看着这两条曲线的* x 轴和 y 轴的定义,我们可以看到,ROC 曲线的 y 轴上的真正阳性率与 P/R 图的 x *轴上的召回率是相同的。

假阳性率衡量被错误识别为阳性的真正负例的比例,在完美的情况下为 0(没有假阳性),否则为 1。与此对比,精准度则关注完全相反的内容,即我们正确分类为阳性的真正例的比例。

以后,让我们使用 ROC 曲线来衡量分类器的性能,以便更好地感知其效果。我们多类问题的唯一挑战是,ROC 和 P/R 曲线假设的是二分类问题。因此,为了我们的目的,我们将为每个音乐类型创建一张图表,展示分类器在一对其余类别分类中的表现:

from sklearn.metrics import roc_curve

y_pred = clf.predict(X_test)

for label in labels:
 y_label_test = scipy.asarray(y_test==label, dtype=int)
 proba = clf.predict_proba(X_test)
 proba_label = proba[:,label] 

 # calculate false and true positive rates as well as the
 # ROC thresholds
 fpr, tpr, roc_thres = roc_curve(y_label_test, proba_label)

 # plot tpr over fpr ...

结果是以下六个 ROC 图。如我们已经发现的,我们的第一个版本的分类器只对古典歌曲表现良好。然而,查看个别的 ROC 曲线告诉我们,大部分其他类型的表现确实不佳。只有爵士乐和乡村音乐带来了一些希望。其余的类型显然无法使用。

使用接收者操作特征衡量分类器性能的另一种方法

使用梅尔频率倒谱系数提高分类性能

我们已经了解到,FFT 指向了正确的方向,但它本身不足以最终得到一个成功的分类器,能够将我们乱序的、包含多种音乐类型的歌曲目录整理到各个单独的类型目录中。我们需要一个稍微更高级的版本。

此时,承认我们需要做更多的研究总是明智的。其他人可能曾面临类似的挑战,并且已经找到了新的方法,可能也能帮助我们。事实上,甚至每年都有一场专注于音乐类型分类的会议,由国际音乐信息检索学会ISMIR)组织。显然,自动音乐类型分类AMGC)已经成为音乐信息检索的一个成熟子领域。快速浏览一些 AMGC 的论文,我们看到有很多针对自动类型分类的工作,可能会帮助我们。

在许多研究中似乎成功应用的一个技术叫做梅尔频率倒谱系数(Mel Frequency Cepstral Coefficients)。梅尔频率倒谱MFC)编码了声音的功率谱,即声音包含的每个频率的功率。它是通过对信号谱的对数进行傅里叶变换来计算的。如果这听起来太复杂,简单记住,“倒谱”这个名字源于“谱”(spectrum)一词的前四个字母倒过来。MFC 已被成功应用于语音和说话人识别。我们来看看它是否也能在我们的案例中发挥作用。

我们正处于一个幸运的情况,因为别人已经恰好需要这个并且发布了一个实现,叫做 Talkbox SciKit。我们可以从pypi.python.org/pypi/scikits.talkbox安装它。之后,我们可以调用mfcc()函数来计算 MFC 系数,方法如下:

>>> from scikits.talkbox.features import mfcc
>>> sample_rate, X = scipy.io.wavfile.read(fn)
>>> ceps, mspec, spec = mfcc(X)
>>> print(ceps.shape)
(4135, 13)

我们希望输入到分类器中的数据存储在ceps中,它包含了每个歌名为fn的歌曲的 4,135 帧的 13 个系数(mfcc()函数的nceps参数的默认值)。如果直接使用所有数据,会使分类器过载。相反,我们可以对每个系数在所有帧上进行平均。假设每首歌的开始和结束部分可能不如中间部分具有明显的音乐类型特征,我们也忽略了前后各 10%的数据。

x = np.mean(ceps[int(num_ceps*0.1):int(num_ceps*0.9)], axis=0)

不出所料,我们将使用的基准数据集只包含每首歌的前 30 秒,因此我们不需要剪掉最后 10%。不过我们还是这么做了,以确保我们的代码可以在其他可能没有截断的数据集上运行。

类似于我们使用 FFT 的工作,我们当然也希望缓存一次生成的 MFCC 特征,并在每次训练分类器时读取它们,而不是每次都重新生成。

这导致了以下代码:

def write_ceps(ceps, fn):
 base_fn, ext = os.path.splitext(fn)
 data_fn = base_fn + ".ceps"
 np.save(data_fn, ceps)
 print("Written to %s" % data_fn)

def create_ceps(fn):
 sample_rate, X = scipy.io.wavfile.read(fn)
 ceps, mspec, spec = mfcc(X)
 write_ceps(ceps, fn)

def read_ceps(genre_list, base_dir=GENRE_DIR):
 X, y = [], []
 for label, genre in enumerate(genre_list):
 for fn in glob.glob(os.path.join(
 base_dir, genre, "*.ceps.npy")):
 ceps = np.load(fn)
 num_ceps = len(ceps)
 X.append(np.mean(
 ceps[int(num_ceps*0.1):int(num_ceps*0.9)], axis=0))
 y.append(label)

 return np.array(X), np.array(y)

我们通过一个每首歌只使用 13 个特征的分类器得到了以下有希望的结果:

通过梅尔频率倒谱系数提高分类性能

所有音乐类型的分类表现都有所提升。古典音乐和金属音乐的 AUC 几乎达到了 1.0。实际上,下面的混淆矩阵现在看起来好多了。我们可以清楚地看到对角线,显示出分类器在大多数情况下能够正确地分类各个音乐类型。这个分类器实际上相当适用于解决我们的初始任务。

通过梅尔频率倒谱系数提高分类性能

如果我们想在这方面有所改进,这个混淆矩阵会迅速告诉我们需要关注的地方:非对角线位置的非白色区域。例如,我们在一个较暗的区域中错误地将摇滚歌曲标记为爵士乐,且这种错误有相当大的概率。要解决这个问题,我们可能需要更深入地研究这些歌曲,提取诸如鼓点模式和类似的音乐风格特征。然后——在浏览 ISMIR 论文时——我们还读到了一种名为听觉滤波器带时域包络AFTE)的特征,似乎在某些情况下优于 MFCC 特征。或许我们也该看看它们?

好消息是,只要配备了 ROC 曲线和混淆矩阵,我们可以随时借用其他专家在特征提取器方面的知识,而无需完全理解它们的内部工作原理。我们的测量工具总是会告诉我们,何时方向正确,何时需要改变。当然,作为一个渴望学习的机器学习者,我们总会有一种模糊的感觉,觉得在特征提取器的黑箱中埋藏着一个激动人心的算法,正等着我们去理解。

总结

在本章中,我们在构建音乐类型分类器时完全走出了舒适区。由于对音乐理论没有深入理解,最初我们未能训练出一个合理准确地预测歌曲音乐类型的分类器。通过快速傅里叶变换(FFT)进行尝试失败。但随后,我们使用 MFC 特征创建了一个表现出真正可用性能的分类器。

在这两种情况下,我们使用的特征我们只是了解得足够多,知道如何以及在哪里将它们放入分类器设置中。一个失败了,另一个成功了。它们之间的区别在于,在第二种情况下,我们依赖的是领域专家创建的特征。

这完全没问题。如果我们主要关心结果,有时候我们只是需要走捷径——我们只需要确保这些捷径来自于特定领域的专家。而且,因为我们学会了如何在这个新的多类别分类问题中正确地衡量性能,所以我们能够充满信心地走这些捷径。

在下一章,我们将探讨如何将你在本书其余部分学到的技术应用到这种特定类型的数据中。我们将学习如何使用 mahotas 计算机视觉包,通过传统的图像处理功能来预处理图像。

第十章:计算机视觉

图像分析和计算机视觉一直在工业和科学应用中占有重要地位。随着拥有强大相机和互联网连接的手机的普及,图像现在越来越多地由消费者生成。因此,利用计算机视觉为新情境中的用户体验提供更好的服务,成为了一个机会。

在本章中,我们将学习如何将你在本书其余部分学到的技术应用到这种特定类型的数据上。具体来说,我们将学习如何使用 mahotas 计算机视觉包从图像中提取特征。这些特征可以作为我们在其他章节中学习的分类方法的输入。我们将这些技术应用于公开可用的照片数据集。我们还将看到这些相同的特征如何用于另一个问题,即寻找相似图像的问题。

最后,在本章的结尾,我们将学习如何使用局部特征。这些方法相对较新(其中第一种取得最新成果的方法,即尺度不变特征变换SIFT),是在 1999 年提出的),并在许多任务中取得了非常好的效果。

介绍图像处理

从计算机的角度来看,图像是一个由像素值组成的大型矩形数组。我们的目标是处理这个图像,并为我们的应用程序做出决策。

第一步是从磁盘加载图像,图像通常以 PNG 或 JPEG 等图像特定格式存储,前者是一种无损压缩格式,后者是一种有损压缩格式,优化了对照片的视觉评估。然后,我们可能希望对图像进行预处理(例如,对其进行归一化,以适应光照变化)。

本章将以分类问题为驱动。我们希望能够学习一个支持向量机(或其他)分类器,能够从图像中进行训练。因此,我们将使用一个中间表示,在应用机器学习之前,从图像中提取数值特征。

加载和显示图像

为了操作图像,我们将使用一个名为 mahotas 的软件包。你可以从pypi.python.org/pypi/mahotas获取 mahotas,并在mahotas.readthedocs.org阅读其手册。Mahotas 是一个开源软件包(MIT 许可,因此可以在任何项目中使用),由本书的作者之一开发。幸运的是,它是基于 NumPy 的。你迄今为止学到的 NumPy 知识可以用于图像处理。还有其他图像处理软件包,例如 scikit-image(skimage)、SciPy 中的 ndimage(n 维图像)模块和 OpenCV 的 Python 绑定。所有这些都可以原生支持 NumPy 数组,因此你甚至可以将来自不同软件包的功能混合使用,构建一个综合的处理管道。

我们首先导入 mahotas 库,并使用mh作为缩写,这将在本章中一直使用,如下所示:

>>> import mahotas as mh

现在,我们可以使用imread来加载图像文件,如下所示:

>>> image = mh.imread('scene00.jpg')

scene00.jpg文件(该文件包含在本书附带的代码仓库数据集中)是一个高度为h、宽度为w的彩色图像;该图像将是一个形状为(h, w, 3)的数组。第一个维度是高度,第二个是宽度,第三个是红/绿/蓝。其他系统将宽度放在第一个维度,但这是所有基于 NumPy 的包使用的惯例。数组的类型通常是np.uint8(无符号 8 位整数)。这些是您的相机拍摄的图像,或者是您的显示器能够完全显示的图像。

一些专业设备,常用于科学和技术应用中,可以拍摄具有更高位深度的图像(即对亮度变化更加敏感)。这种设备中,12 位或 16 位是常见的位深。Mahotas 可以处理所有这些类型的图像,包括浮点图像。在许多计算中,即使原始数据是无符号整数,转换为浮点数仍然有助于简化处理舍入和溢出问题。

注意

Mahotas 支持多种不同的输入/输出后端。遗憾的是,它们没有一个能够加载所有存在的图像格式(存在数百种格式,并且每种格式都有多个变体)。然而,PNG 和 JPEG 格式的图像是所有后端都支持的。我们将重点介绍这些常见格式,并参考 mahotas 文档,了解如何读取不常见的格式。

我们可以使用 matplotlib 来显示图像,这个我们已经多次使用过的绘图库,如下所示:

>>> from matplotlib import pyplot as plt
>>> plt.imshow(image)
>>> plt.show()

如下所示,这段代码展示了图像,遵循的惯例是第一个维度为高度,第二个维度为宽度。它也正确处理彩色图像。当使用 Python 进行数值计算时,我们可以受益于整个生态系统的良好协同工作:mahotas 与 NumPy 数组兼容,这些数组可以通过 matplotlib 显示;稍后我们将从图像中计算特征,以便与 scikit-learn 一起使用。

加载和显示图像

阈值处理

阈值处理是一种非常简单的操作:我们将所有高于某个阈值的像素值转换为1,将低于该阈值的像素值转换为0(或者使用布尔值,将其转换为TrueFalse)。阈值处理中的一个重要问题是选择一个合适的阈值作为分界线。Mahotas 实现了一些从图像中选择阈值的方法。其中一种方法叫做Otsu,以其发明者命名。第一个必要的步骤是使用mahotas.colors子模块中的rgb2gray将图像转换为灰度图像。

我们也可以使用红色、绿色和蓝色通道的平均值来代替 rgb2gray,方法是调用 image.mean(2)。然而,结果会有所不同,因为 rgb2gray 为不同的颜色使用了不同的权重,以得到更为主观上更令人愉悦的结果。我们的眼睛对三种基本颜色的敏感度并不相同。

>>> image = mh.colors.rgb2grey(image, dtype=np.uint8)
>>> plt.imshow(image) # Display the image

默认情况下,matplotlib 会将这张单通道图像显示为伪彩色图像,使用红色表示高值,蓝色表示低值。对于自然图像,灰度图像更加合适。你可以通过以下方式选择灰度显示:

>>> plt.gray()

现在图像已显示为灰度图像。请注意,仅仅是像素值的解释和显示方式发生了变化,图像数据本身没有被改变。我们可以继续处理,通过计算阈值:

>>> thresh = mh.thresholding.otsu(image)
>>> print('Otsu threshold is {}.'.format(thresh))
Otsu threshold is 138.
>>> plt.imshow(image > thresh)

当应用到之前的截图时,这种方法找到了阈值为 138,它将地面与上方的天空分开,如下图所示:

阈值化

高斯模糊

对图像进行模糊处理可能看起来很奇怪,但它通常能减少噪声,这有助于进一步的处理。使用 mahotas,它只需要一个函数调用:

>>> im16 = mh.gaussian_filter(image, 16)

注意,我们并没有将灰度图像转换为无符号整数:我们只是直接使用了浮动点结果。gaussian_filter 函数的第二个参数是滤波器的大小(即滤波器的标准差)。较大的值会导致更多的模糊,如下图所示:

高斯模糊

我们可以使用左侧的截图并结合 Otsu 阈值方法(使用之前相同的代码)。现在,边界更加平滑,没有了锯齿状边缘,如下图所示:

高斯模糊

让中心对焦

最后的示例展示了如何将 NumPy 运算符与一些简单的滤波操作结合,得到有趣的效果。我们从 Lena 图像开始,将其分割成颜色通道:

>>> im = mh.demos.load('lena')

这是一张年轻女性的图像,经常用于图像处理演示。它在下图中展示:

让中心对焦

为了分割红色、绿色和蓝色通道,我们使用以下代码:

>>> r,g,b = im.transpose(2,0,1)

现在,我们分别对三个通道进行滤波,并通过 mh.as_rgb 将它们组合成一张图像。此函数接受三个二维数组,进行对比度拉伸使每个数组成为 8 位整数数组,然后将它们堆叠,返回一张彩色 RGB 图像:

>>> r12 = mh.gaussian_filter(r, 12.)
>>> g12 = mh.gaussian_filter(g, 12.)
>>> b12 = mh.gaussian_filter(b, 12.)
>>> im12 = mh.as_rgb(r12, g12, b12)

现在,我们将两张图像从中心到边缘进行混合。首先,我们需要构建一个权重数组 W,它在每个像素处包含一个归一化值,即该像素到中心的距离:

>>> h, w = r.shape # height and width
>>> Y, X = np.mgrid[:h,:w]

我们使用了 np.mgrid 对象,它返回大小为 (h, w) 的数组,值对应于 yx 坐标。接下来的步骤如下:

>>> Y = Y - h/2\. # center at h/2
>>> Y = Y / Y.max() # normalize to -1 .. +1

>>> X = X - w/2.
>>> X = X / X.max()

现在我们使用一个高斯函数,给中心区域赋予一个高值:

>>> C = np.exp(-2.*(X**2+ Y**2))

>>> # Normalize again to 0..1
>>> C = C - C.min()
>>> C = C / C.ptp()
>>> C = C[:,:,None] # This adds a dummy third dimension to C

请注意,所有这些操作都是使用 NumPy 数组而不是 mahotas 特定的方法来完成的。最后,我们可以将两张图片结合起来,使得中心部分聚焦清晰,而边缘部分则更柔和:

>>> ringed = mh.stretch(im*C + (1-C)*im12)

聚焦中心

基本图像分类

我们将从一个专门为本书收集的小型数据集开始。该数据集包含三个类别:建筑物、自然场景(风景)和文本图片。每个类别有 30 张图片,所有图片均使用手机摄像头拍摄,构图简单。图像与那些没有摄影训练的用户上传到现代网站上的图片类似。这个数据集可以通过本书的网站或 GitHub 代码库获得。在本章后面,我们将介绍一个更难的数据集,包含更多的图片和类别。

在进行图像分类时,我们从一个包含大量数字(像素值)的矩阵开始。现在,百万级像素已是常见情况。我们可以尝试将所有这些数字作为特征输入学习算法,但这并不是一个很好的主意。原因是,每个像素(甚至每小组像素)与最终结果之间的关系非常间接。此外,如果像素数有百万,但样本图像的数量很少,这将导致一个非常困难的统计学习问题。这是我们在第七章《回归》中讨论的 P 大于 N 类型问题的极端形式。相反,一个好的方法是从图像中计算特征,并利用这些特征进行分类。

话虽如此,我还是要指出,实际上,有一些方法是可以直接从像素值中工作并计算特征的。这些方法有自己的特征计算子模块,甚至可能尝试自动学习合适的特征。这些方法是当前研究的主题,通常在非常大的数据集(数百万张图片)上效果最佳。

我们之前使用了一个场景类别的例子。以下是文本类和建筑类的示例:

基本图像分类

从图像中计算特征

使用 mahotas 时,从图像中计算特征非常简单。它有一个名为mahotas.features的子模块,提供了特征计算函数。

一个常用的纹理特征集是 Haralick 特征。和许多图像处理方法一样,这个名字来源于其发明者。这些特征是基于纹理的:它们能区分平滑的图像和有图案的图像,并且能区分不同的图案。使用 mahotas 时,计算这些特征非常简单,方法如下:

>>> haralick_features = mh.features.haralick(image)
>>> haralick_features_mean = np.mean(haralick_features, axis=0)
>>> haralick_features_all = np.ravel(haralick_features)

mh.features.haralick函数返回一个 4x13 的数组。第一维表示计算特征的四个可能方向(垂直、水平、对角线和反对角线)。如果我们对方向没有特别兴趣,我们可以使用所有方向的平均值(如前面的代码中的haralick_features_mean)。否则,我们可以分别使用所有特征(使用haralick_features_all)。这个决策应根据数据集的属性来决定。在我们的案例中,我们推测水平和垂直方向应该分别保留。因此,我们将使用haralick_features_all

mahotas 中还实现了一些其他的特征集。线性二进制模式是另一种基于纹理的特征集,它对光照变化非常稳健。还有其他类型的特征,包括局部特征,我们将在本章后面讨论。

使用这些特征时,我们采用标准的分类方法,如逻辑回归,具体如下:

>>> from glob import glob
>>> images = glob('SimpleImageDataset/*.jpg')
>>> features = []
>>> labels = []
>>> for im in images:
...   labels.append(im[:-len('00.jpg')])
...   im = mh.imread(im)
...   im = mh.colors.rgb2gray(im, dtype=np.uint8)
...   features.append(mh.features.haralick(im).ravel())

>>> features = np.array(features)
>>> labels = np.array(labels)

这三个类别有非常不同的纹理。建筑物有锐利的边缘和大块区域,颜色相似(像素值很少完全相同,但变化很小)。文本由许多锐利的暗-亮过渡组成,黑色小区域在白色背景中。自然场景有更平滑的变化,带有类似分形的过渡。因此,基于纹理的分类器预计会表现良好。

作为分类器,我们将使用逻辑回归分类器,并对特征进行如下预处理:

>>> from sklearn.pipeline import Pipeline
>>> from sklearn.preprocessing import StandardScaler
>>> from sklearn.linear_model import LogisticRegression
>>> clf = Pipeline([('preproc', StandardScaler()),
 ('classifier', LogisticRegression())])

由于我们的数据集较小,我们可以使用如下的留一法回归:

>>> from sklearn import cross_validation
>>> cv = cross_validation.LeaveOneOut(len(images))
>>> scores = cross_validation.cross_val_score(
...     clf, features, labels, cv=cv)
>>> print('Accuracy: {:.1%}'.format(scores.mean()))
Accuracy: 81.1%

对于三个类别,81%的准确率不错(随机猜测的准确率大约为 33%)。然而,通过编写我们自己的特征,我们可以做得更好。

编写你自己的特征

特征没有什么神奇之处。它只是我们从图像中计算出的一个数字。文献中已经定义了几种特征集。这些特征集通常有一个额外的优势,即它们被设计和研究为对许多不重要的因素具有不变性。例如,线性二进制模式对将所有像素值乘以一个数字或将一个常数加到所有像素值上完全不变。这使得这个特征集对图像的光照变化具有稳健性。

然而,也有可能你的特定使用案例将从一些特别设计的功能中受益。

mahotas 中没有提供的一种简单功能是颜色直方图。幸运的是,这个功能很容易实现。颜色直方图将颜色空间划分为一组区间,然后计算每个区间内有多少个像素。

图像采用 RGB 格式,即每个像素有三个值:R 表示红色,G 表示绿色,B 表示蓝色。由于这些组件每个都是 8 位值,总共有 1700 万种不同的颜色。我们将通过将颜色分组到箱子中,将这个数字减少到仅 64 种颜色。我们将编写一个函数来封装这个算法,如下所示:

def chist(im):

为了对颜色进行分箱,我们首先将图像除以 64,按如下方式向下取整像素值:

 im = im // 64

这使得像素值的范围从 0 到 3,总共产生了 64 种不同的颜色。

按照以下方式分离红色、绿色和蓝色通道:

 r,g,b = im.transpose((2,0,1))
 pixels = 1 * r + 4 * b + 16 * g
 hist = np.bincount(pixels.ravel(), minlength=64)
 hist = hist.astype(float)

转换为对数尺度,如下代码片段所示。这并非严格必要,但能够产生更好的特征。我们使用 np.log1p,它计算 log(h+1)。这确保了零值保持为零值(从数学角度看,零的对数是未定义的,如果你尝试计算,NumPy 会给出警告)。

 hist = np.log1p(hist)
 return hist

我们可以很容易地将之前的处理代码调整为使用我们编写的函数:

>>> features = []
>>> for im in images:
...   image = mh.imread(im)
...   features.append(chist(im))

使用我们之前使用的相同交叉验证代码,我们获得了 90% 的准确率。然而,最佳结果来自于将所有特征结合在一起,我们可以按如下方式实现:

>>> features = []
>>> for im in images:
...   imcolor = mh.imread(im)
...   im = mh.colors.rgb2gray(imcolor, dtype=np.uint8)
...   features.append(np.concatenate([
...           mh.features.haralick(im).ravel(),
...           chist(imcolor),
...       ]))

通过使用所有这些特征,我们获得了 95.6% 的准确率,如以下代码片段所示:

>>> scores = cross_validation.cross_val_score(
...     clf, features, labels, cv=cv)
>>> print('Accuracy: {:.1%}'.format(scores.mean()))
Accuracy: 95.6%

这是一个完美的例子,说明了好算法是容易的部分。你总是可以使用 scikit-learn 中的最先进的分类实现。真正的秘密和附加价值通常体现在特征设计和工程上。这就是你对数据集的知识变得非常有价值的地方。

使用特征来查找相似图像

通过用相对较少的特征表示图像的基本概念,不仅可以用于分类。例如,我们还可以用它来查找与给定查询图像相似的图像(就像我们之前在文本文档中做的那样)。

我们将计算与之前相同的特征,唯一不同的是:我们将忽略图像的边缘区域。原因是由于构图的业余性质,图像的边缘常常包含无关的元素。当特征在整个图像上计算时,这些元素会被考虑在内。通过简单地忽略它们,我们可以得到稍微更好的特征。在监督学习示例中,这并不那么重要,因为学习算法会学习哪些特征更具信息量并相应地加权。当以无监督的方式工作时,我们需要更加小心,确保我们的特征能够捕捉到数据中重要的元素。这在循环中实现如下:

>>> features = []
>>> for im in images:
...   imcolor = mh.imread(im)
...   # ignore everything in the 200 pixels closest to the borders
...   imcolor = imcolor[200:-200, 200:-200]
...   im = mh.colors.rgb2gray(imcolor, dtype=np.uint8)
...   features.append(np.concatenate([
...           mh.features.haralick(im).ravel(),
...           chist(imcolor),
...       ]))

我们现在标准化特征并计算距离矩阵,如下所示:

>>> sc = StandardScaler()
>>> features = sc.fit_transform(features)
>>> from scipy.spatial import distance
>>> dists = distance.squareform(distance.pdist(features))

我们将仅绘制数据的一个子集(每第十个元素),这样查询图像会显示在顶部,返回的“最近邻”图像显示在底部,如下所示:

>>> fig, axes = plt.subplots(2, 9)
>>> for ci,i in enumerate(range(0,90,10)):
...     left = images[i]
...     dists_left = dists[i]
...     right = dists_left.argsort()
...     # right[0] is same as left[i], so pick next closest
...     right = right[1]
...     right = images[right]
...     left = mh.imread(left)
...     right = mh.imread(right)
...     axes[0, ci].imshow(left)
...     axes[1, ci].imshow(right)

结果如以下截图所示:

使用特征查找相似图像

很明显,系统并不完美,但至少可以找到与查询在视觉上相似的图像。在除了一个案例外,找到的图像都来自与查询相同的类别。

分类更困难的数据集

前一个数据集是一个使用纹理特征进行分类的简单数据集。事实上,从商业角度来看,许多有趣的问题相对简单。然而,有时我们可能会面临一个更困难的问题,需要更好、更现代的技术来获得良好的结果。

我们现在将测试一个公共数据集,它具有相同的结构:若干张照片分为少数几个类别。这些类别是动物、汽车、交通工具和自然场景。

与我们之前讨论的三类问题相比,这些类别更难区分。自然场景、建筑物和文本的纹理差异很大。然而,在这个数据集中,纹理和颜色不再是图像类别的明显标记。以下是来自动物类的一个例子:

Classifying a harder dataset

这是来自汽车类别的另一个例子:

Classifying a harder dataset

这两个对象背景都是自然背景,并且对象内部有较大的平滑区域。这是比简单数据集更具挑战性的问题,因此我们需要使用更先进的方法。第一个改进是使用一个稍微更强大的分类器。scikit-learn 提供的逻辑回归是一个带惩罚项的逻辑回归,包含一个可调参数C。默认情况下,C = 1.0,但这可能不是最佳选择。我们可以使用网格搜索来找到这个参数的最佳值,具体方法如下:

>>> from sklearn.grid_search import GridSearchCV
>>> C_range = 10.0 ** np.arange(-4, 3)
>>> grid = GridSearchCV(LogisticRegression(), param_grid={'C' : C_range})
>>> clf = Pipeline([('preproc', StandardScaler()),
...                ('classifier', grid)])

数据集中的数据并不是按随机顺序组织的:相似的图像彼此靠近。因此,我们使用交叉验证策略,考虑数据已被洗牌,这样每个折叠(fold)都有一个更具代表性的训练集,如下所示:

>>> cv = cross_validation.KFold(len(features), 5,
...                      shuffle=True, random_state=123)
>>> scores = cross_validation.cross_val_score(
...    clf, features, labels, cv=cv)
>>> print('Accuracy: {:.1%}'.format(scores.mean()))
Accuracy: 72.1%

对于四个类别来说,这还算不错,但我们现在将看看是否能通过使用不同的特征集来做得更好。事实上,我们将看到,结合这些特征与其他方法,才能获得最好的结果。

局部特征表示

计算机视觉领域的一个相对较新的发展是基于局部特征的方法。局部特征是在图像的小区域内计算的,与我们之前考虑的在整个图像上计算的特征不同。Mahotas 支持计算一种这种特征,加速稳健特征SURF)。还有其他几种,最著名的是 SIFT 的原始提案。这些特征旨在对旋转或光照变化具有鲁棒性(即,它们在光照变化时仅略微改变其值)。

使用这些特征时,我们必须决定在哪里计算它们。常用的三种计算位置如下:

  • 随机地

  • 在网格中

  • 检测图像中的有趣区域(这是一种被称为关键点检测或兴趣点检测的技术)

所有这些方法都是有效的,在合适的情况下,会得到很好的结果。Mahotas 支持这三种方法。如果你有理由认为你的兴趣点将对应于图像中重要区域,那么使用兴趣点检测效果最好。

我们将使用兴趣点方法。使用 mahotas 计算特征非常简单:导入正确的子模块并调用 surf.surf 函数,如下所示:

>>> from mahotas.features import surf
>>> image = mh.demos.load('lena')
>>> image = mh.colors.rgb2gray(im, dtype=np.uint8)
>>> descriptors = surf.surf(image, descriptor_only=True)

descriptors_only=True 标志意味着我们只关心描述符本身,而不是它们的像素位置、大小或方向。或者,我们也可以使用密集采样方法,使用 surf.dense 函数如下所示:

>>> from mahotas.features import surf
>>> descriptors = surf.dense(image, spacing=16)

这返回计算在相距 16 像素的点上的描述符值。由于点的位置是固定的,兴趣点的元信息并不十分有趣,默认情况下不会返回。在任何情况下,结果(描述符)是一个 n×64 的数组,其中 n 是采样点的数量。点的数量取决于图像的大小、内容以及你传递给函数的参数。在这个例子中,我们使用的是默认设置,每张图像得到几百个描述符。

我们不能直接将这些描述符输入到支持向量机、逻辑回归器或类似的分类系统中。为了使用来自图像的描述符,有几种解决方案。我们可以直接对它们求平均,但这样做的结果并不好,因为这样丢弃了所有关于位置的特定信息。在那种情况下,我们只会得到一个基于边缘度量的全局特征集。

我们将在这里使用的解决方案是词袋模型,这是一种非常新的思想。它首次于 2004 年以这种形式发布。这是一个事后看非常显而易见的想法:它非常简单实现,并且能够取得非常好的结果。

在处理图像时,说到单词可能会显得有些奇怪。如果你理解为你没有写出那些彼此容易区分的单词,而是口头发出的音频,可能更容易理解。现在,每次说出一个单词时,它的发音会略有不同,而且不同的发言者会有自己的发音方式。因此,一个单词的波形每次说出来时都不会完全相同。然而,通过对这些波形进行聚类,我们可以期望恢复大部分结构,使得给定单词的所有实例都在同一个聚类中。即使过程不完美(而且确实不会完美),我们仍然可以谈论将波形分组成单词。

我们对图像数据执行相同的操作:我们将所有图像中看起来相似的区域聚类在一起,并将这些区域称为视觉词汇

注意

使用的词数通常对算法的最终性能影响不大。自然,如果词数非常少(例如 10 或 20,当你拥有几千张图像时),那么整体系统的表现会很差。同样,如果你有太多的词(例如,比图像的数量多得多),系统的表现也会不佳。然而,在这两者之间,通常会有一个较大的平稳区间,在这个区间内,你可以选择词数而不会对结果产生太大影响。作为经验法则,如果你有很多图像,使用 256、512 或 1024 这样的值应该能给你一个不错的结果。

我们将通过以下方式开始计算特征:

>>> alldescriptors = []
>>> for im in images:
...   im = mh.imread(im, as_grey=True)
...   im = im.astype(np.uint8)
...   alldescriptors.append(surf.dense(image, spacing=16))
>>> # get all descriptors into a single array
>>> concatenated = np.concatenate(alldescriptors)
>>> print('Number of descriptors: {}'.format(
...        len(concatenated)))
Number of descriptors: 2489031

这导致了超过 200 万个局部描述符。现在,我们使用 k-means 聚类来获取质心。我们可以使用所有描述符,但为了提高速度,我们将使用较小的样本,如下所示:

>>> # use only every 64th vector
>>> concatenated = concatenated[::64]
>>> from sklearn.cluster import KMeans
>>> k = 256
>>> km = KMeans(k)
>>> km.fit(concatenated)

完成此操作后(这需要一些时间),km对象包含有关质心的信息。我们现在回到描述符,并按以下方式构建特征向量:

>>> sfeatures = []
>>> for d in alldescriptors:
...   c = km.predict(d)
...   sfeatures.append(
...       np.array([np.sum(c == ci) for ci in range(k)])
...   )
>>> # build single array and convert to float
>>> sfeatures = np.array(sfeatures, dtype=float)

这个循环的最终结果是,sfeatures[fi, fj]表示图像fi中包含元素fj的次数。虽然使用np.histogram函数可以更快地计算这个值,但正确设置参数会有些棘手。我们将结果转换为浮点数,因为我们不想使用整数运算(及其舍入语义)。

结果是,每个图像现在都由一个大小相同的特征数组表示(在我们的例子中是 256 个聚类)。因此,我们可以按以下方式使用我们的标准分类方法:

>>> scores = cross_validation.cross_val_score(
...    clf, sfeatures, labels, cv=cv)
>>> print('Accuracy: {:.1%}'.format(scores.mean()))
Accuracy: 62.6%

结果比之前更差!我们什么也没得到吗?

事实上,我们有,因为我们可以将所有特征结合起来,达到 76.1%的准确率,具体如下:

>>> combined = np.hstack([features, features])
>>> scores = cross_validation.cross_val_score(
...    clf, combined, labels, cv=cv)
>>> print('Accuracy: {:.1%}'.format(scores.mean()))
Accuracy: 76.1%

这是我们获得的最佳结果,优于任何单一的特征集。之所以如此,是因为局部的 SURF 特征与我们之前的全局图像特征有足够的区别,能够提供新的信息,从而提升了综合结果。

总结

我们学习了在机器学习环境中处理图像的经典特征方法:通过将数百万个像素转换为少量数值特征,我们能够直接使用逻辑回归分类器。我们在其他章节中学到的所有技术突然变得可以直接应用于图像问题。我们在使用图像特征查找数据集中相似图像时,看到了一个例子。

我们还学习了如何在词袋模型中使用局部特征进行分类。这是一种非常现代的计算机视觉方法,在提高分类精度的同时,对图像中的许多无关因素(如光照,甚至同一图像中的不均匀光照)具有很强的鲁棒性。我们还使用了聚类作为分类中的一个有用中间步骤,而不是仅仅作为最终目标。

我们专注于 mahotas,它是 Python 中主要的计算机视觉库之一。还有其他同样维护良好的库。Skimage(scikit-image)在精神上类似,但有不同的功能集。OpenCV 是一个非常优秀的 C++库,具有 Python 接口。这些库都可以与 NumPy 数组配合使用,你可以混合搭配不同库中的函数,构建复杂的计算机视觉管道。

在下一章中,你将学习一种不同形式的机器学习:降维。如我们在前几章中所见,包括在本章中使用图像时,计算上生成许多特征是非常容易的。然而,通常我们希望通过减少特征数量来提高速度和可视化,或者改善我们的结果。在下一章中,我们将看到如何实现这一目标。

第十一章:降维

垃圾进,垃圾出——在本书中,我们看到,当将机器学习方法应用于训练数据时,这一模式同样成立。回顾过去,我们意识到,最有趣的机器学习挑战总是涉及某种特征工程,在这些挑战中,我们尝试利用对问题的洞察力,精心设计额外的特征,期望机器学习器能够识别并利用这些特征。

在本章中,我们将走向相反的方向,进行降维,去除那些无关或冗余的特征。移除特征乍一看似乎是反直觉的,因为更多的信息通常应该比更少的信息更好。此外,即使我们的数据集中有冗余特征,难道学习算法不会迅速识别并将它们的权重设为 0 吗?以下是一些仍然适用于尽可能减少维度的合理理由:

  • 多余的特征可能会干扰或误导学习器。并非所有机器学习方法都会出现这种情况(例如,支持向量机喜欢高维空间)。然而,大多数模型在较少的维度下感觉更安全。

  • 另一个反对高维特征空间的理由是,更多的特征意味着更多的参数需要调整,也增加了过拟合的风险。

  • 我们为了解决任务而检索的数据可能具有人工高维性,而实际的维度可能很小。

  • 更少的维度 = 更快的训练 = 在相同的时间框架内可以尝试更多的参数变化 = 更好的最终结果。

  • 可视化——如果我们想要可视化数据,我们只能在二维或三维中进行。

因此,在这里我们将展示如何去除数据中的垃圾,同时保留其中真正有价值的部分。

绘制我们的路线图

降维大致可以分为特征选择和特征提取方法。在本书的几乎每一章中,我们都使用了一种特征选择方法,无论是在发明、分析还是可能丢弃某些特征时。在本章中,我们将介绍一些利用统计方法的方式,即相关性和互信息,来实现这一目标,尤其是在特征空间非常大的情况下。特征提取试图将原始特征空间转换为低维特征空间。这在我们无法通过选择方法去除特征,但仍然拥有过多特征以供学习器使用时尤其有用。我们将通过主成分分析PCA)、线性判别分析LDA)和多维尺度分析MDS)来演示这一点。

特征选择

如果我们想对机器学习算法友好,我们应该提供那些彼此独立、但与待预测值高度相关的特征。这意味着每个特征都提供了显著的信息。移除任何特征都会导致性能下降。

如果我们只有少数几个特征,可以绘制一个散点图矩阵(每对特征组合画一个散点图)。特征之间的关系可以很容易地被发现。对于每一对明显依赖的特征,我们就会考虑是否应该去除其中一个,或者更好地设计一个新的、更干净的特征。

然而,大多数情况下,我们需要从多个特征中进行选择。想一想分类任务中,我们有一个词袋来分类答案的质量,这将需要一个 1000×1000 的散点图。在这种情况下,我们需要一种更自动化的方法来检测重叠特征并解决它们。我们将在以下小节中介绍两种常见的方法,即过滤器和包装器。

使用过滤器检测冗余特征

过滤器试图独立于任何后续使用的机器学习方法清理特征集。它们依赖统计方法来查找哪些特征是冗余的或无关的。在冗余特征的情况下,它只保留每个冗余特征组中的一个。无关的特征则会被直接移除。一般来说,过滤器按以下工作流程进行:

使用过滤器检测冗余特征

相关性

使用相关性,我们可以轻松看到特征对之间的线性关系。在以下图表中,我们可以看到不同程度的相关性,并且有一个潜在的线性依赖关系通过红色虚线(拟合的 1 维多项式)显示出来。每个单独图表顶部的相关系数 相关性 是通过 scipy.stat 库的 pearsonr() 函数计算的常见 Pearson 相关系数(Pearson r 值)。

给定两个相同大小的数据系列,它返回相关系数值和 p 值的元组。p 值描述了数据系列是否可能由一个不相关的系统生成。换句话说,p 值越高,我们就越不应该信任相关系数:

>>> from scipy.stats import pearsonr
>>> pearsonr([1,2,3], [1,2,3.1])
>>> (0.99962228516121843, 0.017498096813278487)
>>> pearsonr([1,2,3], [1,20,6])
>>> (0.25383654128340477, 0.83661493668227405)

在第一个案例中,我们明显看到两个系列是相关的。在第二个案例中,我们仍然看到一个明显非零的 相关性 值。

然而,0.84 的 p 值告诉我们,相关系数并不显著,我们不应过于关注它。请查看以下图表:

相关性

在前三个具有高相关系数的案例中,我们可能会希望抛弃 相关性相关性,因为它们似乎传递了相似的,甚至是相同的信息。

然而,在最后一个案例中,我们应该保留两个特征。在我们的应用中,当然,这个决定将由 p 值来驱动。

尽管在前面的例子中表现得很好,现实通常对我们并不友好。基于相关性的特征选择的一个大缺点是,它只能检测线性关系(即可以用直线建模的关系)。如果我们在非线性数据上使用相关性,就会发现问题。在以下的例子中,我们有一个二次关系:

相关性

尽管人眼可以立刻看到 X[1]与 X[2]之间的关系,除了右下角的图表外,相关系数却看不出这种关系。显然,相关性对于检测线性关系很有用,但对于其他类型的关系却无能为力。有时候,应用简单的变换就能获得线性关系。例如,在前面的图中,如果我们把 X[2]绘制在 X[1]的平方上,我们会得到一个较高的相关系数。然而,正常数据并不总是能提供这样的机会。

幸运的是,对于非线性关系,互信息来拯救我们。

互信息

在进行特征选择时,我们不应像在上一节中那样关注关系的类型(线性关系)。相反,我们应该从一个特征提供多少信息的角度来思考(前提是我们已经拥有另一个特征)。

为了理解这一点,我们假设要使用house_size(房屋面积)、number_of_levels(楼层数)和avg_rent_price(平均租金)特征集来训练一个分类器,该分类器判断房屋是否有电梯。在这个例子中,我们直观地认为,知道了house_size后,我们不再需要知道number_of_levels,因为楼层数在某种程度上包含了冗余信息。而对于avg_rent_price,情况不同,因为我们无法仅通过房屋的大小或楼层数来推断租金。因此,最好只保留其中一个特征,外加租金的平均值。

互信息通过计算两个特征之间有多少信息是共享的,来形式化上述推理。然而,与相关性不同,互信息不依赖于数据的顺序,而是依赖于数据的分布。为了理解它是如何工作的,我们需要稍微了解一下信息熵。

假设我们有一个公平的硬币。在我们抛掷硬币之前,关于它是正面还是反面我们将面临最大的未知,因为正反两面的概率各为 50%。这种不确定性可以通过 Claude Shannon 的信息熵来度量:

互信息

在我们公平硬币的例子中,有两种情况:让互信息代表正面,互信息代表反面,互信息

因此,得出结论:

互信息

提示

为了方便起见,我们也可以使用scipy.stats.entropy([0.5, 0.5], base=2)。我们将 base 参数设置为2,以便得到与之前相同的结果。否则,函数将使用自然对数(通过np.log())。一般来说,基数无关紧要(只要你始终如一地使用它)。

现在,假设我们事先知道这枚硬币其实并不公平,抛掷后正面朝上的概率是 60%:

互信息

我们看到这种情况的不确定性较小。随着我们离 0.5 越来越远,不确定性会减少,达到极端值 0,即正面朝上的概率为 0%或 100%,如以下图表所示:

互信息

我们将通过将熵应用于两个特征而非一个来修改熵!互信息,从而衡量当我们了解 Y 时,X 的不确定性减少了多少。然后,我们可以捕捉到一个特征如何减少另一个特征的不确定性。

例如,在没有任何关于天气的进一步信息的情况下,我们完全不确定外面是否在下雨。如果我们现在得知外面的草地是湿的,那么不确定性就减少了(我们仍然需要检查洒水器是否开启了)。

更正式地说,互信息被定义为:

互信息

这看起来有点让人害怕,但其实不过是求和和乘积。例如,计算互信息是通过对特征值进行分箱,然后计算每个箱中值的比例。在以下图表中,我们将箱的数量设置为十个。

为了将互信息限制在区间[0,1]内,我们必须将其除以它们各自的熵之和,这样就得到了归一化互信息:

互信息

互信息的一个优点是,与相关性不同,它不仅仅关注线性关系,正如我们在以下图表中所看到的:

互信息

如我们所见,互信息能够指示线性关系的强度。下图展示了它同样适用于平方关系:

互信息

所以,我们需要做的是计算所有特征对的归一化互信息。对于每对具有过高值的特征(我们需要确定这意味着什么),我们将删除其中一个。在回归的情况下,我们可以删除与目标结果值互信息过低的特征。

这可能适用于特征集不太大的情况。然而,到了某个阶段,这个过程可能会非常昂贵,因为计算量会呈二次方增长(因为我们在计算特征对之间的互信息)。

过滤器的另一个重大缺点是,它们会丢弃那些在单独使用时似乎没有用的特征。实际上,往往有一些特征看起来与目标变量完全独立,但当它们结合起来时,却非常有效。为了保留这些特征,我们需要使用包装器。

使用包装器向模型询问特征

尽管过滤器可以在去除无用特征方面起到很大作用,但它们的效果也是有限的。经过所有的过滤后,仍然可能会有一些特征彼此独立,并且与结果变量之间有某种程度的依赖性,但从模型的角度来看,它们仍然是完全无用的。试想一下下面描述异或(XOR)函数的数据。单独来看,AB 都不会显示出与 Y 之间的任何依赖性,而它们一起时却明显存在依赖关系:

A B Y
0 0 0
0 1 1
1 0 1
1 1 0

那么,为什么不直接让模型本身来为每个特征投票呢?这就是包装器的作用,正如我们在以下过程图中所看到的那样:

使用包装器向模型询问特征

在这里,我们将特征重要性的计算推送到了模型训练过程中。不幸的是(但可以理解),特征重要性并不是以二进制方式确定的,而是以排名值的形式给出的。因此,我们仍然需要指定切割点,决定我们愿意保留哪些特征,哪些特征我们想要丢弃?

回到 scikit-learn,我们可以在 sklearn.feature_selection 包中找到多种优秀的包装器类。在这个领域中,一个非常强大的工具是 RFE,即递归特征消除。它接收一个估算器和要保留的特征数量作为参数,然后用各种特征集训练估算器,直到它找到一个足够小的特征子集。RFE 实例本身看起来就像一个估算器,实际上就是包装了提供的估算器。

在以下示例中,我们使用数据集方便的 make_classification() 函数创建了一个人工分类问题,包含 100 个样本。该函数允许我们指定创建 10 个特征,其中只有 3 个特征对于解决分类问题是非常有价值的:

>>> from sklearn.feature_selection import RFE
>>> from sklearn.linear_model import LogisticRegression
>>> from sklearn.datasets import make_classification
>>> X,y = make_classification(n_samples=100, n_features=10, n_informative=3, random_state=0)
>>> clf = LogisticRegression()
>>> clf.fit(X, y)
>>> selector = RFE(clf, n_features_to_select=3)
>>> selector = selector.fit(X, y)
>>> print(selector.support_)
[False  True False  True False False False False  True False]
>>> print(selector.ranking_)
[4 1 3 1 8 5 7 6 1 2]

现实世界中的问题当然是,如何知道 n_features_to_select 的正确值呢?事实上,我们无法得知这个值。然而,大多数时候我们可以利用数据的一个样本,并通过不同的设置进行尝试,快速感受出大致的合适范围。

好消息是,我们在使用包装器时不必非常精确。让我们尝试不同的 n_features_to_select 值,看看 support_ranking_ 是如何变化的:

n_features_to_select support_ ranking_
1 [False False False True False False False False False False] [ 6 3 5 1 10 7 9 8 2 4]
2 [False False False True False False False False True False] [5 2 4 1 9 6 8 7 1 3]
3 [假 真 假 真 假 假 假 假 真 假] [4 1 3 1 8 5 7 6 1 2]
4 [假 真 假 真 假 假 假 假 真 真] [3 1 2 1 7 4 6 5 1 1]
5 [假 真 真 真 假 假 假 假 真 真] [2 1 1 1 6 3 5 4 1 1]
6 [ 真 真 真 真 假 假 假 假 真 真] [1 1 1 1 5 2 4 3 1 1]
7 [ 真 真 真 真 假 真 假 假 真 真] [1 1 1 1 4 1 3 2 1 1]
8 [ 真 真 真 真 假 真 假 真 真 真] [1 1 1 1 3 1 2 1 1 1]
9 [ 真 真 真 真 假 真 真 真 真 真] [1 1 1 1 2 1 1 1 1 1]
10 [ 真 真 真 真 真 真 真 真 真 真] [1 1 1 1 1 1 1 1 1 1]

我们看到结果非常稳定。当请求较小特征集时使用的特征,在允许更多特征进入时依然会被选择。最终,我们依赖于训练/测试集的划分,提醒我们何时走偏。

其他特征选择方法

在阅读机器学习文献时,你会发现还有其他几种特征选择方法。有些方法甚至看起来不像特征选择方法,因为它们被嵌入到学习过程中(不要与前面提到的包装器方法混淆)。例如,决策树在其核心中深深植入了特征选择机制。其他学习方法则采用某种正则化,惩罚模型复杂度,从而推动学习过程向着表现良好的“简单”模型发展。它们通过将影响较小的特征重要性降为零,然后将其丢弃(L1 正则化)来实现这一点。

所以,要小心!通常,机器学习方法的强大功能很大程度上要归功于它们植入的特征选择方法。

特征提取

在某些时候,在我们移除冗余特征并丢弃不相关特征后,通常仍然会发现特征过多。无论使用什么学习方法,它们的表现都很差,并且考虑到庞大的特征空间,我们理解它们实际上做得更好是不可行的。我们意识到必须“割肉”,必须剔除那些所有常识告诉我们它们是有价值的特征。另一个需要减少维度而特征选择方法帮助不大的情况是当我们想要可视化数据时。此时,我们需要最终只有最多三维,才能提供任何有意义的图表。

引入特征提取方法。它们重构特征空间,使其对模型更易接入,或简单地将维度降低到二或三,以便我们可以通过可视化显示依赖关系。

同样地,我们可以将特征提取方法区分为线性方法和非线性方法。另外,正如在 选择特征 部分中所见,我们将为每种类型介绍一种方法(主成分分析作为线性方法,以及多维尺度法的非线性版本)。尽管它们被广泛知晓和使用,但它们仅是许多更有趣且强大的特征提取方法的代表。

关于主成分分析

主成分分析PCA)通常是你想要减少特征数量且不知道使用哪种特征提取方法时首先要尝试的内容。PCA 是一种线性方法,因此有其局限性,但它可能已经足够使你的模型学习得足够好。加上它所提供的强大数学性质以及它在找到变换后的特征空间的速度,以及之后在原始特征和变换特征之间的转换速度;我们几乎可以保证,它也会成为你常用的机器学习工具之一。

总结而言,给定原始特征空间,PCA 会在一个低维空间中找到其线性投影,并具有以下特性:

  • 最大化保留的方差。

  • 最小化最终的重构误差(即试图从变换后的特征恢复到原始特征时的误差)。

由于 PCA 只是对输入数据进行变换,它可以同时应用于分类和回归问题。在本节中,我们将使用分类任务来讨论这一方法。

绘制 PCA

PCA 涉及很多线性代数知识,我们不打算深入探讨。然而,基本的算法可以简单地描述如下:

  1. 通过从数据中减去均值来对数据进行中心化。

  2. 计算协方差矩阵。

  3. 计算协方差矩阵的特征向量。

如果我们从 绘制 PCA 特征开始,那么算法将返回一个变换后的特征空间,依然具有 绘制 PCA 维度(到目前为止我们并没有获得任何新东西)。然而,这个算法的优点在于,特征值表示对应特征向量所描述的方差量。

假设我们从 绘制 PCA 特征开始,并且我们知道模型在超过 绘制 PCA 特征时表现不好。那么,我们只需选择具有最大特征值的 绘制 PCA 特征向量。

应用 PCA

让我们考虑以下人工数据集,并在左侧的图表中进行可视化:

>>> x1 = np.arange(0, 10, .2)
>>> x2 = x1+np.random.normal(loc=0, scale=1, size=len(x1))
>>> X = np.c_[(x1, x2)]
>>> good = (x1>5) | (x2>5) # some arbitrary classes
>>> bad = ~good # to make the example look good

应用 PCA

Scikit-learn 提供了 PCA 类在其分解包中。在这个例子中,我们可以清楚地看到,使用一个维度就足够描述数据。我们可以通过 n_components 参数来指定这一点:

>>> from sklearn import linear_model, decomposition, datasets
>>> pca = decomposition.PCA(n_components=1)

此外,在这里我们可以使用pcafit()transform()方法(或其fit_transform()组合)来分析数据,并将其投影到变换后的特征空间:

>>> Xtrans = pca.fit_transform(X)

如我们所指定,Xtrans仅包含一个维度。你可以在前面的右侧图中看到结果。在这种情况下,结果甚至是线性可分的。我们甚至不需要复杂的分类器来区分这两个类别。

为了理解重构误差,我们可以查看在变换中保留下来的数据的方差:

>>> print(pca.explained_variance_ratio_)
>>> [ 0.96393127]

这意味着,在从二维降到一维之后,我们仍然保留了 96%的方差。

当然,这并不总是如此简单。通常情况下,我们并不知道预先应该选择多少个维度。在这种情况下,我们在初始化PCA时不指定n_components参数,让它计算完整的变换。拟合数据后,explained_variance_ratio_包含一个按降序排列的比率数组:第一个值是描述最大方差方向的基向量的比率,第二个值是描述第二大方差方向的比率,依此类推。绘制这个数组后,我们很快就能感觉出需要多少个主成分:图表上肘部之前的成分数通常是一个不错的估计。

提示

显示每个主成分方差解释度的图称为碎石图。将碎石图与网格搜索结合以找到分类问题的最佳设置的一个很好的例子可以在scikit-learn.sourceforge.net/stable/auto_examples/plot_digits_pipe.html找到。

PCA 的局限性以及 LDA 如何提供帮助

作为一种线性方法,PCA 在面对具有非线性关系的数据时,当然也有其局限性。我们在这里不深入讨论,但可以简单地说,PCA 有一些扩展方法,例如核 PCA,它引入了非线性变换,使我们仍然可以使用 PCA 方法。

PCA 的另一个有趣的弱点是,当它应用于特殊的分类问题时,我们将在这里讨论这一点。让我们将good = (x1 > 5) | (x2 > 5)替换为good = x1 > x2来模拟这种特殊情况,并且我们很快就能看到问题所在:

PCA 的局限性以及 LDA 如何提供帮助

在这里,类别并不是按照方差最大的轴进行分布,而是按照第二大方差的轴分布。显然,PCA 在这种情况下失效了。由于我们没有为 PCA 提供任何关于类别标签的提示,它无法做得更好。

线性判别分析LDA)在这里派上了用场。它是一种方法,旨在最大化不同类别之间点的距离,同时最小化同一类别点之间的距离。我们不会详细说明底层理论的具体工作原理,只提供一个如何使用它的简要教程:

>>> from sklearn import lda
>>> lda_inst = lda.LDA(n_components=1)
>>> Xtrans = lda_inst.fit_transform(X, good)

就这些。请注意,与之前的 PCA 示例不同,我们将类别标签提供给了fit_transform()方法。因此,PCA 是一种无监督特征提取方法,而 LDA 是有监督的。结果如预期所示:

PCA 的局限性及 LDA 如何提供帮助

那么,为什么还要考虑 PCA,而不是直接使用 LDA 呢?其实,事情并没有那么简单。随着类别数量的增加和每个类别样本的减少,LDA 的效果就不那么理想了。此外,PCA 似乎对不同训练集的敏感度不如 LDA。因此,当我们需要建议使用哪种方法时,我们只能明确地说“取决于具体情况”。

多维尺度法

尽管 PCA 试图通过优化保持方差,多维尺度法MDS)则尽可能保留相对距离,以减少维度。这在我们处理高维数据集并希望获得可视化印象时非常有用。

MDS 不关心数据点本身,而是关注数据点对之间的异质性,并将其解释为距离。因此,MDS 算法首先做的事情是,取所有多维尺度法的维度多维尺度法的数据点,并使用距离函数多维尺度法计算距离矩阵,该函数衡量原始特征空间中的(大多数情况下是欧几里得)距离:

多维尺度法

现在,MDS 尝试将各个数据点放置到低维空间中,以使该空间中的新距离尽可能接近原始空间中的距离。由于 MDS 通常用于可视化,低维度的选择通常是二维或三维。

让我们看一下以下简单的数据,包含三个五维空间中的数据点。两个数据点较为接近,另一个则非常不同,我们想要在三维和二维中可视化这一点:

>>> X = np.c_[np.ones(5), 2 * np.ones(5), 10 * np.ones(5)].T
>>> print(X)
[[  1\.   1\.   1\.   1\.   1.]
 [  2\.   2\.   2\.   2\.   2.]
 [ 10\.  10\.  10\.  10\.  10.]]

使用 scikit-learn 的manifold包中的MDS类,我们首先指定希望将X转换为三维欧几里得空间:

>>> from sklearn import manifold
>>> mds = manifold.MDS(n_components=3)
>>> Xtrans = mds.fit_transform(X)

为了在二维中可视化,我们需要相应地设置n_components

结果可以在以下两张图中看到。三角形和圆形点非常接近,而星形点则远离它们:

多维尺度法

让我们来看一下稍微复杂一些的鸢尾花数据集。稍后我们将用它来对比 LDA 和 PCA。鸢尾花数据集每朵花包含四个属性。使用之前的代码,我们将其投影到三维空间,同时尽可能保持个别花朵之间的相对距离。在之前的例子中,我们没有指定任何度量,因此MDS将默认使用欧几里得距离。这意味着,依据四个属性“不同”的花朵,应该在 MDS 缩放后的三维空间中保持较远的距离,而相似的花朵则应该聚集在一起,正如下图所示:

多维缩放

将 PCA 用于将维度减少到三维和二维后,我们可以看到同一类别的花朵在图中呈现出预期的更大分布,如下图所示:

多维缩放

当然,使用 MDS 需要理解各个特征的单位;也许我们使用的特征无法通过欧几里得度量来比较。例如,一个类别变量,即使它被编码为整数(0=圆形,1=星形,2=三角形,等等),也无法通过欧几里得距离来比较(圆形与星形比与三角形更接近吗?)。

然而,一旦我们意识到这个问题,MDS 就是一个有用的工具,它能够揭示数据中的相似性,这在原始特征空间中是难以察觉的。

更深入地看 MDS,我们意识到它并不是单一的算法,而是不同算法的一家族,而我们只使用了其中一个。PCA 也是如此。此外,如果你发现 PCA 或 MDS 都无法解决你的问题,可以查看 scikit-learn 工具包中提供的许多其他流形学习算法。

然而,在你被众多不同算法淹没之前,最好的方法是从最简单的一个开始,看看它能带你多远。然后,再尝试下一个更复杂的算法,并从那里继续。

总结

你学到了有时可以通过特征选择方法去除完整的特征。我们也看到,在某些情况下,这还不够,我们必须使用特征提取方法来揭示数据中的真实和低维结构,希望模型能够更轻松地处理它。

当然,我们只是触及了可用的维度减少方法这一巨大领域的表面。尽管如此,我们希望能激发你对这个领域的兴趣,因为还有许多其他方法等待你去发掘。最终,特征选择和提取是一门艺术,就像选择合适的学习方法或训练模型一样。

下一章将介绍 Jug 的使用,这是一个小型的 Python 框架,用于以多核或多机器的方式管理计算。你还将了解 AWS,亚马逊云服务。

第十二章. 更大的数据

很难界定什么是大数据。我们将采用一种操作性定义:当数据变得庞大到难以处理时,我们称之为大数据。在某些领域,这可能意味着拍字节级的数据或万亿级的交易数据:这些数据无法完全存储在单个硬盘中。在其他情况下,它可能只有一百倍小,但仍然难以处理。

为什么数据本身成为了一个问题?虽然计算机的处理速度不断提高,内存也在增加,但数据的规模也在不断增长。事实上,数据的增长速度快于计算速度,而且很少有算法能够随着输入数据规模的增加而线性扩展——综合来看,这意味着数据的增长速度超过了我们处理它的能力。

我们将首先基于前几章的一些经验,处理我们可以称之为中等数据规模的问题(不是大数据,但也不算小)。为此,我们将使用一个叫做jug的包,它使我们能够执行以下任务:

  • 将你的管道分解为任务

  • 缓存(缓存)中间结果

  • 利用多个核心,包括网格上的多台计算机

下一步是转向真正的大数据,我们将看到如何使用云计算。特别地,你将了解 Amazon Web Services 基础设施。在这一部分,我们介绍了另一个名为 StarCluster 的 Python 包来管理集群。

学习大数据

"大数据"这一表达并不意味着特定数量的数据,无论是数据样本的数量,还是数据占用的千兆字节、太字节或拍字节的数量。它意味着数据的增长速度快于处理能力的提升。这意味着以下几点:

  • 过去有效的方法和技术现在需要重新做或替换,因为它们无法很好地扩展到新的输入数据规模。

  • 算法不能假设所有输入数据都能装入内存。

  • 管理数据本身成为了一项主要任务

  • 使用计算机集群或多核机器已经不再是奢侈,而是必要。

本章将重点讲解这一拼图的最后一块:如何利用多个核心(无论是在同一台机器上还是在不同机器上)来加速和组织你的计算。这对于其他中等规模的数据任务也会有帮助。

使用 jug 将你的管道分解为任务

我们通常有一个简单的管道:我们对初始数据进行预处理,计算特征,然后用计算出的特征调用机器学习算法。

Jug 是由本书作者之一 Luis Pedro Coelho 开发的一个包。它是开源的(使用自由的 MIT 许可),在许多领域都能派上用场,但它是专门围绕数据分析问题设计的。它同时解决了多个问题,例如:

  • 它可以将结果缓存到磁盘(或数据库),这意味着如果你要求它计算已经计算过的内容,结果将直接从磁盘读取。

  • 它可以使用多个核心,甚至在集群中的多台计算机上运行。Jug 的设计也非常适合批处理计算环境,这些环境使用排队系统,如PBSPortable Batch System)、LSFLoad Sharing Facility)或Grid Engine。这将在本章后半部分使用,我们将构建在线集群并向它们分配任务。

jug 中的任务简介

任务是 jug 的基本构建块。一个任务由一个函数和其参数值组成。考虑这个简单的例子:

def double(x):
 return 2*x

在本章中,代码示例通常需要键入到脚本文件中。因此,它们不会显示>>>标记。应该在 shell 中输入的命令会以美元符号($)作为前缀。

一个任务可以是“调用 double,参数为 3”。另一个任务可以是“调用 double,参数为 642.34”。使用 jug,我们可以按如下方式构建这些任务:

from jug import Task
t1 = Task(double, 3)
t2 = Task(double, 642.34)

将其保存为名为jugfile.py的文件(这只是一个常规的 Python 文件)。现在,我们可以运行jug execute来执行这些任务。这是你在命令行中输入的,而不是在 Python 提示符下输入的,因此我们用美元符号($)标记它:

$ jug execute

你还会收到一些关于任务的反馈(jug 会显示两个名为double的任务被执行)。再次运行jug execute,它会告诉你它什么也没做!其实它不需要做任何事情。在这种情况下,我们并没有获得太多收益,但如果任务计算时间很长,这个缓存将非常有用。

你可能会注意到,在硬盘上出现了一个名为jugfile.jugdata的新目录,并且里面有一些奇怪命名的文件。这是记忆化缓存。如果你删除它,jug execute将会重新运行所有任务。

通常,区分纯函数和更一般的函数是有益的,纯函数只是接受输入并返回结果,而一般函数可以执行一些动作(例如从文件中读取、写入文件、访问全局变量、修改其参数,或任何该语言允许的操作)。一些编程语言,比如 Haskell,甚至有语法方法来区分纯函数和不纯函数。

使用 jug 时,你的任务不需要完全纯粹。甚至建议你使用任务来读取数据或写入结果。然而,访问和修改全局变量将不能很好地工作:任务可能在不同的处理器上以任何顺序执行。全局常量除外,但即使是常量,也可能会干扰记忆化系统(如果值在执行过程中发生变化)。类似地,你不应该修改输入值。jug 有一个调试模式(使用jug execute --debug),虽然它会减慢计算速度,但如果你犯了类似的错误,它会给你有用的错误信息。

上面的代码是有效的,但有些繁琐。你总是在重复使用Task(function, argument)这个构造。利用一些 Python 技巧,我们可以使代码看起来更加自然,如下所示:

from jug import TaskGenerator
from time import sleep

@TaskGenerator
def double(x):
 sleep(4)
 return 2*x

@TaskGenerator
def add(a, b):
 return a + b

@TaskGenerator
def print_final_result(oname, value):
 with open(oname, 'w') as output:
 output.write('Final result: {}\n'.format(value))

y = double(2)
z = double(y)

y2 = double(7)
z2 = double(y2)
print_final_result('output.txt', add(z,z2))

除了使用TaskGenerator,前面的代码实际上可以是一个标准的 Python 文件!然而,使用TaskGenerator,它实际上创建了一系列任务,现在可以以一种利用多个处理器的方式来运行它。在幕后,装饰器将你的函数转化为在调用时不执行,而是创建一个Task对象。我们还利用了可以将任务传递给其他任务的事实,这会生成一个依赖关系。

你可能已经注意到,我们在前面的代码中添加了一些sleep(4)调用。这是为了模拟运行一个长时间的计算。否则,这个示例运行得非常快,根本没有使用多个处理器的必要。

我们通过运行jug status来开始,结果如下图所示:

任务在 jug 中的介绍

现在,我们同时启动两个进程(在后台使用&操作符):

$ jug execute &
$ jug execute &

现在,我们再次运行jug status

任务在 jug 中的介绍

我们可以看到,两个初始的双重操作符正在同时运行。大约 8 秒后,整个过程将完成,output.txt 文件将被写入。

顺便提一下,如果你的文件名不是jugfile.py,你就需要在命令行上显式指定它。例如,如果你的文件名是analysis.py,你应该运行以下命令:

$ jug execute analysis.py

这就是不使用jugfile.py名称的唯一缺点。所以,尽管使用更有意义的名称吧。

查看引擎盖下

jug 是如何工作的?从基本层面上来说,它非常简单。一个Task是一个函数加上它的参数。它的参数可以是值,也可以是其他任务。如果一个任务依赖于其他任务,那么这两个任务之间就有了依赖关系(并且第二个任务在第一个任务的结果可用之前无法运行)。

基于此,jug 会递归地计算每个任务的哈希值。这个哈希值编码了整个计算过程,以获得结果。当你运行jug execute时,对于每个任务,会有一个小循环执行以下流程图中展示的逻辑:

查看引擎盖下

默认的后端将文件写入磁盘(在这个名为jugfile.jugdata/的有趣目录中)。另外,还有一个后端可用,使用 Redis 数据库。通过适当的锁定,jug 会处理这个问题,这也允许多个处理器执行任务;每个进程将独立查看所有任务,并运行那些尚未执行的任务,然后将它们写回共享的后端。这在同一台机器(使用多核处理器)或多台机器上都可以运行,只要它们都能访问相同的后端(例如,使用网络磁盘或 Redis 数据库)。在本章的后半部分,我们将讨论计算机集群,但现在我们先专注于多个核心。

你也可以理解为什么它能够记忆中间结果。如果后端已经有某个任务的结果,它就不会再执行。如果你更改了任务,即使是微小的更改(例如修改了某个参数),其哈希值也会改变。因此,任务会被重新执行。此外,所有依赖于该任务的任务也会改变它们的哈希值,并且会被重新执行。

使用 jug 进行数据分析

Jug 是一个通用框架,但它非常适合中等规模的数据分析。在开发分析管道时,最好让中间结果自动保存。如果你之前已经计算过预处理步骤,并且只是在更改计算的特征,你就不希望重新计算预处理步骤。如果你已经计算过特征,但希望尝试将一些新的特征组合起来,你也不希望重新计算所有其他特征。

Jug 还特别优化了与 NumPy 数组的协作。每当你的任务返回或接收 NumPy 数组时,你就利用了这种优化。Jug 是这个生态系统中的另一部分,所有内容都在一起工作。

现在,我们回顾一下第十章,计算机视觉。在那一章中,我们学习了如何计算图像的特征。记住,基本管道包括以下特征:

  • 加载图像文件

  • 计算特征

  • 合并这些特征

  • 归一化特征

  • 创建分类器

我们将重新进行这个练习,但这次将使用 jug。这个版本的优势在于,现在可以在不需要重新计算整个管道的情况下,添加新的特征或分类器。

我们首先进行以下几个导入:

from jug import TaskGenerator
import mahotas as mh
from glob import glob

现在,我们定义第一个任务生成器和特征计算函数:

@TaskGenerator
def compute_texture(im):
 from features import texture
 imc = mh.imread(im)
 return texture(mh.colors.rgb2gray(imc))

@TaskGenerator
def chist_file(fname):
 from features import chist
 im = mh.imread(fname)
 return chist(im)

我们导入的features模块来自第十章,计算机视觉

注意

我们编写的函数将文件名作为输入,而不是图像数组。当然,使用完整的图像也是可行的,但这是一个小的优化。文件名是字符串,在写入后端时相对较小。如果需要计算哈希值,也非常快速。此外,这样可以确保只有需要图像的进程才会加载它们。

我们可以在任何函数上使用TaskGenerator。即使是我们没有编写的函数,例如np.arraynp.hstack,或者下面的命令,这也成立:

import numpy as np
to_array = TaskGenerator(np.array)
hstack = TaskGenerator(np.hstack)

haralicks = []
chists = []
labels = []

# Change this variable to point to
# the location of the dataset on disk
basedir = '../SimpleImageDataset/'
# Use glob to get all the images
images = glob('{}/*.jpg'.format(basedir))

for fname in sorted(images):
 haralicks.append(compute_texture(fname))
 chists.append(chist_file(fname))
 # The class is encoded in the filename as xxxx00.jpg
 labels.append(fname[:-len('00.jpg')])

haralicks = to_array(haralicks)
chists = to_array(chists)
labels = to_array(labels)

使用 jug 的一个小不便之处是,我们必须始终编写函数将结果输出到文件,如前面的示例所示。这是使用 jug 的额外便利性所付出的一个小代价。

@TaskGenerator
def accuracy(features, labels):
 from sklearn.linear_model import LogisticRegression
 from sklearn.pipeline import Pipeline
 from sklearn.preprocessing import StandardScaler
 from sklearn import cross_validation

 clf = Pipeline([('preproc', StandardScaler()),
 ('classifier', LogisticRegression())])
 cv = cross_validation.LeaveOneOut(len(features))
 scores = cross_validation.cross_val_score(
 clf, features, labels, cv=cv)
 return scores.mean()

请注意,我们仅在此函数内部导入sklearn。这是一个小优化。这样,只有在真正需要时,sklearn才会被导入:

scores_base = accuracy(haralicks, labels)
scores_chist = accuracy(chists, labels)

combined = hstack([chists, haralicks])
scores_combined  = accuracy(combined, labels)

最后,我们编写并调用一个函数来输出所有结果。它期望其参数是一个包含算法名称和结果的对列表:

@TaskGenerator
def print_results(scores):
 with open('results.image.txt', 'w') as output:
 for k,v in scores:
 output.write('Accuracy [{}]: {:.1%}\n'.format(
 k, v.mean()))

print_results([
 ('base', scores_base),
 ('chists', scores_chist),
 ('combined' , scores_combined),
 ])

就是这样。现在,在 shell 中运行以下命令,通过 jug 运行这个管道:

$ jug execute image-classification.py

复用部分结果

例如,假设你想添加一个新功能(甚至是一组功能)。正如我们在第十章中看到的,计算机视觉,通过更改特征计算代码是很容易做到的。然而,这将意味着需要重新计算所有特征,这样是浪费的,尤其是当你想快速测试新特征和技术时。

我们现在添加一组特征,也就是另一种叫做线性二值模式的纹理特征。这在 mahotas 中已实现;我们只需要调用一个函数,但我们将其封装在 TaskGenerator 中:

@TaskGenerator
def compute_lbp(fname):
 from mahotas.features import lbp
 imc = mh.imread(fname)
 im = mh.colors.rgb2grey(imc)
 # The parameters 'radius' and 'points' are set to typical values
 # check the documentation for their exact meaning
 return lbp(im, radius=8, points=6)

我们替换了之前的循环,增加了一个额外的函数调用:

lbps = []
for fname in sorted(images):
 # the rest of the loop as before
 lbps.append(compute_lbp(fname))
lbps = to_array(lbps)

我们使用这些较新的特征来计算准确度:

scores_lbps = accuracy(lbps, labels)
combined_all = hstack([chists, haralicks, lbps])
scores_combined_all = accuracy(combined_all, labels)

print_results([
 ('base', scores_base),
 ('chists', scores_chist),
 ('lbps', scores_lbps),
 ('combined' , scores_combined),
 ('combined_all' , scores_combined_all),
 ])

现在,当你再次运行 jug execute 时,新的特征将被计算出来,但旧的特征将从缓存中加载。这就是 jug 强大的地方。它确保你始终得到你想要的结果,同时避免不必要地重新计算缓存的结果。你还会看到,添加这组特征集改善了之前的方法。

本章未能提及 jug 的所有功能,但以下是我们未在正文中涵盖的一些可能感兴趣的功能总结:

  • jug invalidate:这声明给定函数的所有结果应被视为无效,并需要重新计算。这还将重新计算任何依赖于(即使是间接依赖)无效结果的下游计算。

  • jug status --cache:如果 jug status 运行时间过长,你可以使用 --cache 标志来缓存状态并加快速度。请注意,这不会检测 jugfile 的任何更改,但你可以随时使用 --cache --clear 来清除缓存并重新开始。

  • jug cleanup:这将删除备忘缓存中的所有额外文件。这是一个垃圾回收操作。

注意

还有其他更高级的功能,允许你查看在 jugfile 内部已计算的值。请阅读 jug 文档中的有关屏障等功能,网址为 jug.rtfd.org

使用亚马逊 Web 服务

当你有大量数据和大量计算需要执行时,你可能会开始渴望更多的计算能力。亚马逊(aws.amazon.com)允许你按小时租用计算能力。这样,你可以在不需要提前购买大量机器(包括管理基础设施成本)的情况下,访问大量的计算能力。市场上还有其他竞争者,但亚马逊是最大的玩家,因此我们在此简单介绍。

亚马逊网络服务AWS)是一个大型服务集。我们将只关注弹性计算云EC2)服务。此服务为您提供虚拟机和磁盘空间,可以快速分配和释放。

使用有三种模式。第一种是预留模式,您通过预付费获得更便宜的每小时访问费用,固定的每小时费用,以及一个变化的费用,这取决于整体计算市场(当需求较少时,费用较低;当需求较多时,价格会上涨)。

在这个通用系统的基础上,有几种不同类型的机器可供选择,成本各异,从单核到具有大量内存或甚至图形处理单元(GPU)的多核系统。稍后我们会看到,您还可以获取几台较便宜的机器并构建一个虚拟集群。您还可以选择获取 Linux 或 Windows 服务器(Linux 略便宜)。在本章中,我们将在 Linux 上操作示例,但大部分信息对于 Windows 机器同样适用。

对于测试,您可以使用免费层中的单台机器。这允许您操作系统,熟悉界面等。请注意,这台机器的 CPU 较慢。

资源可以通过网页界面进行管理。但是,也可以通过编程方式进行管理,并编写脚本来分配虚拟机、格式化硬盘并执行所有通过网页界面可能执行的操作。事实上,虽然网页界面变化频繁(本书中展示的一些截图可能在出版时已经过时),但编程接口更加稳定,且自服务推出以来,整体架构保持稳定。

访问 AWS 服务通过传统的用户名/密码系统进行,尽管亚马逊将用户名称为访问密钥,密码称为秘密密钥。他们这样做可能是为了将其与用于访问网页界面的用户名/密码分开。事实上,您可以创建任意多的访问/秘密密钥对,并为其分配不同的权限。这对于较大的团队非常有帮助,团队中的高级用户可以访问完整的网页面板,进而为权限较少的开发人员创建其他密钥。

注意

亚马逊公司有多个区域。这些区域对应世界上的物理位置:美国西海岸、美国东海岸、几个亚洲位置、一个南美位置以及两个欧洲位置。如果您要传输数据,最好将数据保持在接收和发送的地方附近。此外,请记住,如果您处理用户信息,可能会有关于将数据传输到另一个司法管辖区的监管问题。在这种情况下,请咨询一位知情的律师,了解将关于欧洲客户的数据传输到美国或其他类似的转移所涉及的法律问题。

亚马逊云服务是一个非常庞大的话题,专门覆盖 AWS 的书籍有很多。本章的目的是让你对 AWS 所提供的服务和可能实现的功能有一个整体印象。本书的实践精神就是通过示例来实现这一目标,但我们并不会涵盖所有可能性。

创建你的第一个虚拟机

第一步是访问aws.amazon.com/并创建一个账户。这些步骤与任何其他在线服务相似。一个机器是免费的,但如果你需要更多的机器,你将需要一张信用卡。在本示例中,我们将使用几台机器,因此如果你想跟着做,可能会花费你一些费用。如果你还不准备提供信用卡,你当然可以先阅读这一章,了解 AWS 提供的服务,而不需要亲自操作示例。然后你可以做出更有信息的决定,看看是否注册。

一旦你注册 AWS 并登录,你将进入控制台。在这里,你将看到 AWS 提供的众多服务,如下图所示:

创建你的第一个虚拟机

我们选择并点击EC2(左侧栏中最顶部的元素——这是本书写作时显示的面板。亚马逊会定期进行小的更改,所以你看到的可能与我们书中的稍有不同)。现在我们看到了 EC2 管理控制台,如下图所示:

创建你的第一个虚拟机

在右上角,你可以选择你的区域(请参见亚马逊区域信息框)。注意,你只会看到你当前选择的区域的信息。因此,如果你错误地选择了错误的区域(或者有多个区域的机器在运行),你的机器可能不会出现(这似乎是使用 EC2 网页管理控制台时常见的陷阱)。

在 EC2 术语中,正在运行的服务器称为实例。我们选择启动实例,这将进入下一个屏幕,要求我们选择要使用的操作系统:

创建你的第一个虚拟机

选择Amazon Linux选项(如果你熟悉其他提供的 Linux 发行版,比如 Red Hat、SUSE 或 Ubuntu,你也可以选择其中一个,但配置会有所不同)。现在你已经选择了软件,接下来需要选择硬件。在下一个屏幕中,你将被要求选择要使用的机器类型:

创建你的第一个虚拟机

我们将从一个t2.micro类型的实例开始(t1.micro类型是较老的、性能更弱的机器)。这是最小的机器,并且是免费的。不断点击下一步并接受所有默认设置,直到你看到提到密钥对的屏幕:

创建你的第一个虚拟机

我们将为密钥对选择 awskeys 这个名字。然后勾选 Create a new key pair。将密钥对文件命名为 awskeys.pem。下载并将此文件保存在一个安全的位置!这是 SSH(安全外壳)密钥,允许您登录到云端机器。接受其余默认设置,您的实例将会启动。

现在,您需要等待几分钟,直到您的实例启动完成。最终,实例将显示为绿色,并且状态为 running

创建您的第一个虚拟机

在前面的截图中,您应该能看到公共 IP 地址,可以用来登录实例,具体如下:

$ ssh -i awskeys.pem ec2-user@54.93.165.5

因此,我们将调用 ssh 命令,并将之前下载的密钥文件作为身份验证文件传递给它(使用 -i 选项)。我们作为 ec2-user 用户,登录到 IP 地址为 54.93.165.5 的机器。这个地址在您的情况下当然会有所不同。如果您为实例选择了其他发行版,用户名也可能会变化。在这种情况下,您可以尝试以 rootubuntu(对于 Ubuntu 发行版)或 fedora(对于 Fedora 发行版)登录。

最后,如果您正在运行 Unix 风格的操作系统(包括 macOS),您可能需要通过以下命令调整其权限:

$ chmod 600 awskeys.pem

这仅为当前用户设置读写权限。否则,SSH 会给您一个丑陋的警告。

现在,您应该能够登录到您的机器。如果一切正常,您应该能看到如下面截图所示的横幅:

创建您的第一个虚拟机

这是一个常规的 Linux 机器,您拥有 sudo 权限:通过在命令前加上 sudo,您可以以超级用户身份运行任何命令。您可以运行系统推荐的 update 命令来让您的机器保持最新状态。

在 Amazon Linux 上安装 Python 包

如果您更喜欢其他发行版,您可以利用您对该发行版的了解来安装 Python、NumPy 等包。在这里,我们将在标准的 Amazon 发行版上进行操作。我们首先安装几个基本的 Python 包,具体如下:

$ sudo yum -y install python-devel \
 python-pip numpy scipy python-matplotlib

为了编译 mahotas,我们还需要一个 C++ 编译器:

$ sudo yum -y install gcc-c++

最后,我们安装 git,以确保能够获取到本书的最新代码:

$ sudo yum -y install git

在该系统中,pip 被安装为 python-pip。为了方便起见,我们将使用 pip 升级它自身。然后,我们将使用 pip 安装必要的包,具体如下:

$ sudo pip-python install -U pip
$ sudo pip install scikit-learn jug mahotas

此时,您可以使用 pip 安装任何其他您希望安装的包。

在我们的云机器上运行 jug

我们现在可以使用以下命令序列来下载本书的数据和代码:

$ git clone \
https://github.com/luispedro/BuildingMachineLearningSystemsWithPython
$ cd BuildingMachineLearningSystemsWithPython
$ cd ch12

最后,我们运行以下命令:

$ jug execute

这可以正常工作,但我们将不得不等待很长时间才能看到结果。我们的免费级别机器(t2.micro 类型)速度较慢,且仅有一个处理器。因此,我们将 升级我们的机器

我们返回到 EC2 控制台,右键单击正在运行的实例以获得弹出菜单。我们需要首先停止该实例。这相当于关闭虚拟机。你可以随时停止你的机器,停止后就不再为其付费。请注意,你仍在使用磁盘空间,这部分会单独计费。你也可以终止实例,这样会销毁磁盘,丢失机器上保存的所有信息。

一旦机器停止,更改实例类型 选项将变得可用。现在,我们可以选择一个更强大的实例,例如具有八个核心的 c1.xlarge 实例。机器仍然是关闭的,所以你需要重新启动它(相当于虚拟机的开机)。

提示

AWS 提供了几种不同价格的实例类型。由于这些信息会随着更强大的选项引入和价格变化(通常是变便宜)而不断更新,我们无法在书中提供太多细节,但你可以在 Amazon 网站上找到最新的信息。

我们需要等待实例重新启动。一旦它恢复,按照之前的方式查找其 IP 地址。当你更改实例类型时,实例会被分配一个新的地址。

提示

你可以使用 Amazon.com 的弹性 IP 功能为实例分配固定 IP,弹性 IP 在 EC2 控制台的左侧可以找到。如果你经常创建和修改实例,这会非常有用。使用该功能会有少量费用。

拥有八个核心,你可以同时运行八个 jug 进程,如下面的代码所示:

$ # the loop below runs 8 times
$ for counter in $(seq 8); do
>     jug execute &
> done

使用 jug status 检查这八个任务是否正在运行。任务完成后(这应该会很快),你可以停止机器并将其降级回 t2.micro 实例以节省费用。微型实例可以免费使用(在某些限制范围内),而我们使用的 c1.xlarge 实例则需要每小时 0.064 美元(截至 2015 年 2 月—请查阅 AWS 网站获取最新信息)。

使用 StarCluster 自动生成集群

正如我们刚刚学到的,我们可以使用 Web 界面创建机器,但这很快变得乏味且容易出错。幸运的是,Amazon 提供了一个 API。这意味着我们可以编写脚本,自动执行我们之前讨论的所有操作。更好的是,其他人已经开发了工具,可以用来机制化并自动化你想要在 AWS 上执行的许多流程。

麻省理工学院的一个小组开发了这样一个工具,名为 StarCluster。它恰好是一个 Python 包,所以你可以使用 Python 工具按照如下方式安装它:

$ sudo pip install starcluster

你可以从 Amazon 机器或本地机器上运行此操作。两种选择都可以使用。

我们需要指定集群的配置。我们通过编辑配置文件来实现这一点。我们通过运行以下命令生成模板配置文件:

$ starcluster help

然后选择在~/.starcluster/config中生成配置文件的选项。完成后,我们将手动编辑它。

提示

密钥,密钥,再来一点密钥

在处理 AWS 时,有三种完全不同类型的密钥非常重要。首先是标准的用户名/密码组合,用于登录网站。其次是 SSH 密钥系统,它是一个通过文件实现的公钥/私钥系统;通过公钥文件,您可以登录远程机器。第三是 AWS 访问密钥/秘密密钥系统,它只是一个用户名/密码的形式,允许您在同一账户中拥有多个用户(包括为每个用户添加不同的权限,但本书不涉及这些高级功能)。

要查找我们的访问/密钥,返回 AWS 控制台,点击右上角的用户名,选择安全凭证。现在在屏幕底部应该会看到我们的访问密钥,它可能类似于AAKIIT7HHF6IUSN3OCAA,我们将在本章中使用它作为示例。

现在,编辑配置文件。这是一个标准的.ini文件:一个文本文件,其中每个部分以括号中的名称开始,选项则以name=value格式指定。第一个部分是aws info部分,您需要将密钥复制并粘贴到这里:

[aws info]
AWS_ACCESS_KEY_ID =  AAKIIT7HHF6IUSN3OCAA
AWS_SECRET_ACCESS_KEY = <your secret key>

接下来是有趣的部分,即定义集群。StarCluster 允许您根据需要定义任意多个集群。初始文件中有一个名为 smallcluster 的集群。它在cluster smallcluster部分中定义。我们将编辑它,使其如下所示:

[cluster smallcluster]
KEYNAME = mykey
CLUSTER_SIZE = 16

这将把节点数从默认的两个节点更改为 16 个。我们还可以指定每个节点的实例类型,以及初始映像是什么(记住,映像用于初始化虚拟硬盘,它定义了您将运行的操作系统和已安装的软件)。StarCluster 有一些预定义的映像,但您也可以自己创建。

我们需要使用以下命令创建一个新的 SSH 密钥:

$ starcluster createkey mykey -o ~/.ssh/mykey.rsa

现在我们已经配置了一个 16 节点的集群并设置了密钥,让我们试试:

$ starcluster start smallcluster

这可能需要几分钟,因为它会分配 17 台新机器。为什么是 17 台,而不是我们的集群只有 16 个节点?StarCluster 始终会创建一个主节点。所有这些节点都共享相同的文件系统,因此我们在主节点上创建的任何内容都将被工作节点看到。这也意味着我们可以在这些集群上使用 jug。

这些集群可以按您的需求使用,但它们预先配备了一个作业队列引擎,这使得它们非常适合批处理。使用它们的过程非常简单:

  1. 您登录到主节点。

  2. 您在主节点上准备脚本(或者更好的是,提前准备好它们)。

  3. 您将作业提交到队列中。作业可以是任何 Unix 命令。调度程序将寻找空闲节点并运行您的作业。

  4. 您等待作业完成。

  5. 您可以在主节点上读取结果。您现在也可以终止所有从节点以节省费用。无论如何,在您不再需要时不要让系统持续运行!否则,这将花费您(以美元和美分)。

在登录到集群之前,我们将数据复制到其中(请记住,我们之前已经将存储库克隆到BuildingMachineLearningSystemsWithPython):

$ dir=BuildingMachineLearningSystemsWithPython
$ starcluster put smallcluster $dir $dir

我们使用$dir变量使命令行适应单行。我们可以用一条命令登录到主节点:

$ starcluster sshmaster smallcluster

我们也可以查找生成的机器地址,并像之前那样使用ssh命令,但使用上述命令,无论地址是什么,StarCluster 都会在幕后为我们处理它。

正如我们之前所说,StarCluster 为其集群提供了批处理队列系统;您编写一个脚本来执行操作,将其放入队列,它将在任何可用节点上运行。

此时,我们需要再次安装一些包。幸运的是,StarCluster 已经完成了一半的工作。如果这是一个真实的项目,我们将设置一个脚本来为我们执行所有的初始化工作。StarCluster 可以做到这一点。由于这是一个教程,我们只需再次运行安装步骤:

$ pip install jug mahotas scikit-learn

我们可以像以前一样使用相同的jugfile系统,只是现在,不再直接在主节点上运行,而是在集群上进行调度。

首先,编写一个非常简单的包装脚本如下:

#!/usr/bin/env bash
jug execute jugfile.py

将其命名为run-jugfile.sh并使用chmod +x run-jugfile.sh赋予它可执行权限。现在,我们可以使用以下命令在集群上安排十六个作业:

$ for c in $(seq 16); do
>    qsub -cwd run-jugfile.sh
> done

这将创建 16 个作业,每个作业将运行run-jugfile.sh脚本,我们简称为 jug。您仍然可以按照自己的意愿使用主节点。特别是,您随时可以运行jug status来查看计算的状态。事实上,jug 就是在这样的环境中开发的,因此在这种环境中非常有效。

最终,计算将完成。此时,我们需要首先保存结果。然后,我们可以终止所有节点。我们在~/results目录下创建一个目录,并将结果复制到此处:

# mkdir ~/results
# cp results.image.txt ~/results

现在,从集群注销回到我们的工作机器:

# exit

现在,我们回到我们的 AWS 机器(请注意下一个代码示例中的$符号)。首先,我们使用starcluster get命令将结果复制回这台计算机(这是我们之前使用put命令的镜像):

$ starcluster get smallcluster results results

最后,为了节省费用,我们应该关闭所有节点如下:

$ starcluster stop smallcluster
$ starcluster terminate smallcluster

注意

请注意,终止操作将真正销毁文件系统和所有结果。在我们的情况下,我们已经手动将最终结果复制到了安全位置。另一个可能性是让集群写入一个不被 StarCluster 分配和销毁的文件系统,但在常规实例上对您可用;事实上,这些工具的灵活性是巨大的。然而,这些高级操作不可能全部在本章中展示。

StarCluster 在 star.mit.edu/cluster/ 上有出色的在线文档,你应该阅读以了解更多关于该工具的所有可能性。我们这里只展示了其功能的很小一部分,并且只使用了默认设置。

总结

我们展示了如何使用 jug,一个小型 Python 框架来管理计算,以便利用多个核心或多台机器。虽然这个框架是通用的,但它是专门为其作者(本书的另一位作者)解决数据分析需求而构建的。因此,它有多个方面使其适配 Python 机器学习环境。

你还了解了 AWS,即亚马逊云。使用云计算通常比建设内部计算能力更有效,尤其是在需求不稳定且不断变化的情况下。StarCluster 甚至允许集群在你启动更多任务时自动扩展,在任务终止时自动缩减。

本书结束了。我们已经走过了很长的路。你学习了如何在标注数据时进行分类,在未标注数据时进行聚类。你了解了降维和主题建模,以便理解大数据集。接着,我们看了一些具体的应用(例如音乐流派分类和计算机视觉)。在实现中,我们依赖于 Python 语言。该语言拥有一个越来越庞大的数值计算包生态,建立在 NumPy 基础上。只要可能,我们依赖于 scikit-learn,但在必要时也会使用其他包。由于这些包都使用相同的基本数据结构(NumPy 多维数组),因此可以无缝地混合不同包的功能。本书中使用的所有包都是开源的,可以用于任何项目。

自然地,我们并没有涵盖所有机器学习话题。在附录中,我们提供了一些其他资源的指引,帮助感兴趣的读者进一步学习机器学习。

附录 A. 如何进一步学习机器学习

我们已接近本书结尾,现在花点时间看看其他可能对读者有用的资源。

网上有许多极好的资源可以用来进一步学习机器学习——多到我们无法在这里全部覆盖。因此,以下列表只能代表一小部分,并且是作者在写作时认为最好的资源,可能带有一定的偏向性。

在线课程

Andrew Ng 是斯坦福大学的教授,他在 Coursera (www.coursera.org) 上开设了一门机器学习的在线课程,作为一门大型开放在线课程。这是免费的,但可能需要投入大量时间。

书籍

本书侧重于机器学习的实际应用方面。我们没有呈现算法背后的思考过程,也没有讲解理论依据。如果你对机器学习的这一方面感兴趣,我们推荐 Christopher Bishop 的《模式识别与机器学习》。这是该领域的经典入门书籍,将教你大多数本书中使用的算法的细节。

如果你想深入了解所有复杂的数学细节,那么 Kevin P. Murphy 的《机器学习:一种概率视角》是一个很好的选择 (www.cs.ubc.ca/~murphyk/MLbook)。这本书出版于 2012 年,非常新颖,涵盖了机器学习研究的前沿。其 1100 页内容也可以作为参考书,因为几乎没有遗漏任何机器学习的内容。

问答网站

MetaOptimize (metaoptimize.com/qa) 是一个机器学习问答网站,许多非常有经验的研究者和从业者在此互动。

Cross Validated (stats.stackexchange.com) 是一个通用的统计学问答网站,经常有关于机器学习的问题。

正如书中开头提到的,如果你对书中的某些部分有疑问,可以随时在 TwoToReal (www.twotoreal.com) 提问。我们尽量尽快回答并提供帮助。

博客

这里列出的是一份显然不完全的博客列表,适合从事机器学习工作的人阅读:

  • 机器学习理论: hunch.net

    平均发布频率约为每月一篇,内容更具理论性,提供了额外的大脑挑战。

  • 实践中的文本与数据挖掘: textanddatamining.blogspot.de

    平均发布频率为每月一篇,内容非常实用,提供总是令人惊讶的独特方法。

  • Edwin Chen's Blog: blog.echen.me

    平均发布频率为每月一篇,内容涵盖更多实际应用话题。

  • Machined Learnings: www.machinedlearnings.com

    平均发布频率为每月一篇,内容涵盖更多实际应用话题。

  • FlowingData: flowingdata.com

    平均发布频率为每天一篇,文章内容更多围绕统计学展开。

  • Simply Statistics: simplystatistics.org

    每月发布几篇文章,内容侧重于统计学和大数据。

  • 统计建模、因果推断与社会科学: andrewgelman.com

    每天发布一篇文章,内容通常有趣,作者通过统计数据指出流行媒体的缺陷。

数据来源

如果你想尝试各种算法,可以从加州大学尔湾分校(UCI)的机器学习库中获取许多数据集。你可以在archive.ics.uci.edu/ml找到它。

变得具有竞争力

了解更多关于机器学习的绝佳方式是尝试一个竞赛!Kaggle (www.kaggle.com) 是一个机器学习竞赛的市场,在介绍中已经提到过。在这个网站上,你会发现多个不同结构和通常带有现金奖励的竞赛。

监督学习竞赛几乎总是遵循以下格式:你(以及每位其他参赛者)都可以访问带标签的训练数据和无标签的测试数据。你的任务是提交对测试数据的预测。当竞赛结束时,准确率最高的人获胜。奖品从荣耀到现金不等。

当然,赢得一些东西很好,但只要参与,你就可以获得许多有用的经验。因此,在竞赛结束后,你必须继续关注,因为参与者开始在论坛上分享他们的方法。大多数情况下,获胜并不是关于开发新算法,而是巧妙地预处理、归一化和组合现有方法。

所有被遗漏的内容

我们没有涵盖 Python 中所有可用的机器学习包。考虑到空间有限,我们选择专注于 scikit-learn。但是,还有其他选择,我们在这里列出了一些:

  • MDP 工具包 (mdp-toolkit.sourceforge.net):用于数据处理的模块化工具包

  • PyBrain (pybrain.org):基于 Python 的强化学习、人工智能和神经网络库

  • 机器学习工具包(Milk)(luispedro.org/software/milk):这个包由本书的一位作者开发,涵盖了一些 scikit-learn 中未包含的算法和技术。

  • Pattern (www.clips.ua.ac.be/pattern):一个结合了网页挖掘、自然语言处理和机器学习的包,具有 Google、Twitter 和 Wikipedia 的包装 API。

一个更通用的资源是mloss.org,这是一个开源机器学习软件库。像这样的库通常情况下,质量从优秀且维护良好的软件到一次性项目然后被抛弃的项目不等。如果你的问题非常具体,而通用包没有解决方案,这可能值得一看。

摘要

现在我们真的到了尽头。希望你喜欢这本书,并且感觉已经准备好开始自己的机器学习冒险了。

我们也希望你理解仔细测试方法的重要性。特别是,使用正确的交叉验证方法的重要性,以及不要报告训练测试结果,因为这些结果通常是对你方法真正效果的过高估计。

posted @ 2025-09-03 10:19  绝不原创的飞龙  阅读(21)  评论(0)    收藏  举报