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 库的线性代数例程
ndimagen维图像包
odr正交距离回归
optimize优化(寻找最小值和根)
signal信号处理
sparse稀疏矩阵
spatial空间数据结构和算法
special特殊数学函数,如贝塞尔函数或雅可比函数
stats统计工具包

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

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

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

读取数据

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

前几行如下所示:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_01_09.jpg

使用 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找到更多关于绘图的教程。

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

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_01_01.jpg

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

现在我们对数据有了初步印象,我们回到最初的问题:我们的服务器能够处理多少的 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")

这将产生以下图表:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_01_02.jpg

看起来前 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

你将得到以下图表:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_01_03.jpg

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

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

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

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_01_04.jpg

有趣的是,我们没有在拟合了 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 周的数据进行训练,而在第二条线中我们使用剩余的数据进行训练。

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_01_05.jpg

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

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

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_01_06.jpg

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

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

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_01_07.jpg

然而,从仅使用第 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

请看以下图表:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_01_08.jpg

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

回答我们最初的问题

最终,我们得出了一个我们认为最能代表底层过程的模型;现在只需要简单地计算出我们的基础设施何时将达到每小时 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 的混合群体。

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_02_01.jpg

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

>>> 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。

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_02_02.jpg

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

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

前一节中讨论的模型是一个简单的模型,它在整个数据集上的准确率达到了 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%剔除,而不是仅仅剔除一个元素。我们会在剔除的折叠上测试这些模型,并计算结果的平均值。

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_02_03.jpg

上图展示了这一过程,使用了五个折叠:数据集被分成五个部分。对于每一个折叠,你会保留其中一个块进行测试,其余四个块用于训练。你可以使用任何数量的折叠。折叠数与计算效率(折叠数越多,需要的计算越多)和结果准确性(折叠数越多,训练数据越接近于整个数据集)之间存在权衡。五个折叠通常是一个不错的折中方案。这意味着你用 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% 的准确率。正如我们在前一部分讨论的那样,交叉验证的准确率低于训练准确率,但这是对模型性能更可靠的估计。

查看决策边界

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

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_02_04.jpg

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

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

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_02_07.jpg

在这个公式中,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%的准确率,这个结果是通过之前显示的五折交叉验证代码来估算的!

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

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_02_05.jpg

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

二分类和多分类

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

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

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

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

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

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

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_02_06.jpg

或者,我们可以构建一个分类树。将可能的标签分成两组,并构建一个分类器,问:“这个例子应该放入左边的箱子还是右边的箱子?”我们可以递归地进行这种分裂,直到我们得到一个单一的标签。前面的图展示了 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 中的出现次数
disk11
format11
how10
hard11
my10
problems01
to10

“在帖子 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 个文档,并将聚类中心设置为这些文档的特征向量。然后,它会遍历所有其他文档,将它们分配到离它们最近的聚类中心。接下来,它会将每个聚类中心移到该类别所有向量的中间位置。这会改变聚类分配。某些文档现在可能更接近另一个聚类。因此,它会更新这些文档的聚类分配。这个过程会一直进行,直到聚类中心的移动幅度小于设定的阈值,认为聚类已经收敛。

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

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_03_01.jpg

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

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_03_02.jpg

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

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_03_03.jpg

箭头表示聚类中心的移动。在这个示例中,经过五次迭代后,聚类中心的移动几乎不再明显(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)]

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

位置相似度帖子摘录
11.038使用 IDE 控制器的引导问题,您好,我有一个多 I/O 卡(IDE 控制器 + 串行/并行接口)和两个软盘驱动器(5 1/4,3 1/2)以及一个连接到它的 Quantum ProDrive 80AT。我能够格式化硬盘,但无法从中启动。我可以从 A: 驱动器启动(哪个磁盘驱动器无关紧要),但如果我从 A: 驱动器中移除磁盘并按下重置开关,A: 驱动器的 LED 灯会继续亮着,而硬盘根本没有被访问。我猜这可能是多 I/O 卡或软盘驱动器设置(跳线配置?)的问题。有谁知道可能是什么原因吗?[…]
21.150从 B 驱动器启动我有一个 5 1/4"的驱动器作为 A 驱动器。我如何让系统从我的 3 1/2" B 驱动器启动?(理想情况下,计算机应该能够从 A 或 B 驱动器启动,按照顺序检查它们是否有可启动的磁盘。但是:如果我必须交换电缆并简单地交换驱动器,以便它无法启动 5 1/4"磁盘,那也可以。另外,boot_b 也不能帮我实现这个目的。[……][……]
31.280IBM 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)

你将得到以下图表:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_04_01.jpg

提示

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

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

在前面的图表中,我们可以看到大约 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 按照我们的预期表现,给每个文档分配了更多的主题:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_04_02.jpg

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

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

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

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

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

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

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_04_03.jpg

我们还可以发现,某些词可能应该被去除(例如,“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% 的词汇分配给了这个话题)。看看下面的截图:

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_04_04.jpg

注意

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

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

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

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_04_05.jpg

最少被提及的话题较难解释,但它的许多高频词与东部国家的机场有关。只有 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 编码文本的完整帖子内容。
OwnerUserIdID这是帖子的唯一标识符。如果值为 1,则表示这是一个 Wiki 问题。
Title字符串这是问题的标题(答案没有该字段)。
AcceptedAnswerIdID这是被接受的答案的 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 中通常出现的各种奇怪情况。

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

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_05_01.jpg

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

训练分类器

我们需要将特征数组与之前定义的标签 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

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

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_05_02.jpg

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

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

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

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

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

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

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

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

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_05_03.jpg

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

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

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

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

帖子链接数文本词数
A220
B025
new123

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

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

决定如何改进

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

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

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

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

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

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

偏差-方差及其权衡

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

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

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

修正高偏差

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

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

修复高方差

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

高偏差或低偏差

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

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

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

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_05_04.jpg

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

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

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_05_05.jpg

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

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

kmean(scores)stddev(scores)
400.628000.03750
100.620000.04111
50.614000.02154

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

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_05_06.jpg

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

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

使用逻辑回归

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

一些数学与小示例

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

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_05_07.jpg

从数学上讲,建模一个具有有限范围的事物总是很困难的,就像我们这里的离散标签 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。

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_05_08.jpg

这意味着我们现在可以将特征的线性组合拟合到https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_05_19.jpg值上(好吧,我们只有一个特征和一个常数,但这很快就会改变)。从某种意义上讲,我们用https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_05_14.jpg替代了第一章中的线性模型,使用 Python 进行机器学习入门,用https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_05_15.jpg(将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_ 展示了第一个系数。

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

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_05_09.jpg

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

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

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

方法平均(得分)标准差(得分)
LogReg C=0.10.646500.03139
LogReg C=1.000.646500.03155
LogReg C=10.000.645500.03102
LogReg C=0.010.638500.01950
40NN0.628000.03750

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

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

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_05_10.jpg

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

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

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

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

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

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

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

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

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_05_17.jpg

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

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_05_18.jpg

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

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_05_12.jpg

那么,如何优化精确度呢?到目前为止,我们总是使用 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))

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

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_05_11.jpg

提示

在图表中,我们还包含了一个更好的分类器性能描述——曲线下的面积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_)来了解特征的影响。特征的系数越大,说明该特征在确定帖子是否优秀时所起的作用越大。因此,负系数告诉我们,对于相应特征的较高值意味着该帖子被分类为不好的信号更强。

https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/bd-ml-sys-py-2e/img/2772OS_05_13.jpg

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

发货!

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

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

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

总结

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

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

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值