构建-Python-机器学习系统-全-
构建 Python 机器学习系统(全)
零、前言
机器学习允许模型或系统在没有明确编程的情况下进行学习。您将看到如何使用现有的最佳库支持,包括 scikit-learn、TensorFlow 和许多其他库支持,来构建高效、智能的系统。
这本书是给谁的
用 Python 构建机器学习系统面向数据科学家、机器学习开发人员以及希望学习如何构建日益复杂的机器学习系统的 Python 开发人员。您将使用 Python 的机器学习功能来开发有效的解决方案。需要具备 Python 编程的相关知识。
这本书涵盖了什么
第一章、Python 机器学习入门,用一个很简单的例子介绍了机器学习和 TensorFlow 的基本思想。尽管它很简单,但它会给我们带来过度拟合的风险。
第二章用真实世界的例子进行分类,使用真实的数据通过训练计算机能够区分不同类别的花来探索分类。
第三章、回归,解释如何用回归处理数据,这是一个至今仍有意义的经典话题。您还将学习高级回归技术,如套索和弹性网。
第 4 章、分类 I–检测不良答案,演示了如何使用偏差方差权衡来调试机器学习模型,尽管这一章主要是关于使用逻辑回归来确定用户对问题的答案是好还是坏。
第 5 章、降维探讨了还有哪些方法可以帮助我们缩小数据的规模,使其可以被我们的机器学习算法咀嚼。
第 6 章、聚类-寻找相关帖子,通过应用它来寻找相似的帖子而不真正理解它们,展示了单词包方法是多么强大。
第 7 章、推荐,构建基于客户产品评级的推荐系统。我们还将看到如何从购物数据中构建推荐,而不需要评级数据(用户并不总是提供评级数据)。
第八章、人工神经网络和深度学习,讲述了 CNN 和 RNN 使用 TensorFlow 的基本原理和例子。
第 9 章、分类二–情绪分析,解释了朴素贝叶斯是如何工作的,以及如何用它来对推文进行分类,看它们是正面的还是负面的。
第 10 章、主题建模超越了将每个帖子分配给单个集群,而是将帖子分配给多个主题,因为真实文本可以处理多个主题。
第 11 章、分类三–音乐流派分类,设定了有人搅乱了我们庞大的音乐收藏的场景,我们创造秩序的唯一希望就是让机器学习者对我们的歌曲进行分类。事实证明,有时候相信别人的专业知识来自己创建功能会更好。这一章还包括了语音到文本的转换。
第 12 章、计算机视觉演示了如何通过从数据中提取特征,在处理图像的特定上下文中应用分类。我们还将看到这些方法如何被调整以在一个集合中找到相似的图像,以及 CNN 和 GAN 使用 TensorFlow 的应用。
第 13 章、强化学习,涵盖了强化学习和深 Q 网络在雅达利游戏玩法上的基础知识。
第 14 章、更大的数据探讨了利用多核或计算集群处理更大数据的一些方法。它还引入了云计算(使用亚马逊网络服务作为我们的云提供商)。
充分利用这本书
这本书假设你知道 Python 以及如何使用easy_install
或pip
安装库。我们不依赖任何高等数学,如微积分或矩阵代数。
我们在整本书中使用了以下版本,但是您应该可以使用任何更新的版本:
- Python 3.5
- NumPy 1.13.3
- SciPy 1.0 版
- sci kit-学习最新版本
All examples are available as Jupyter notebooks in our code bundle (https://github.com/PacktPublishing/Building-Machine-Learning-Systems-with-Python-Third-edition).
下载示例代码文件
你可以从你在www.packtpub.com的账户下载这本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册将文件直接通过电子邮件发送给您。
您可以按照以下步骤下载代码文件:
- 登录或注册www.packtpub.com。
- 选择“支持”选项卡。
- 点击代码下载和勘误表。
- 在搜索框中输入图书的名称,并按照屏幕指示进行操作。
下载文件后,请确保使用最新版本的解压缩文件夹:
- 视窗系统的 WinRAR/7-Zip
- zipeg/izp/un ARX for MAC
- 适用于 Linux 的 7-Zip/PeaZip
这本书的代码包也在 GitHub 上托管于https://GitHub . com/PacktPublishing/Building-Machine-Learning-system-with-Python-第三版。如果代码有更新,它将在现有的 GitHub 存储库中更新。
我们还有来自丰富的图书和视频目录的其他代码包,可在【https://github.com/PacktPublishing/】获得。看看他们!
下载彩色图像
我们还提供了一个 PDF 文件,其中包含本书中使用的截图/图表的彩色图像。你可以在这里下载:https://www . packtpub . com/sites/default/files/downloads/buildingmachinelearningsystems with pythirdietting _ color images . pdf。
使用的约定
本书通篇使用了许多文本约定。
CodeInText
:表示文本中的码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入和推特句柄。这里有一个例子:“如果你还没有 Jupyter,只需用pip install jupyter
安装,然后用 Jupyter Notebook 运行。”
代码块设置如下:
from sklearn.datasets import load_boston
boston = load_boston()
任何命令行输入或输出都编写如下:
>>> import numpy >>> numpy.version.full_version
1.13.3
粗体:表示一个新的术语、一个重要的单词或者你在屏幕上看到的单词。例如,菜单或对话框中的单词像这样出现在文本中。这里有一个例子:“我们选择启动实例,这将导致以下屏幕,要求我们选择要使用的操作系统。”
Warnings or important notes appear like this. Tips and tricks appear like this.
取得联系
我们随时欢迎读者的反馈。
综合反馈:发邮件feedback@packtpub.com
并在邮件主题中提及书名。如果您对本书的任何方面有疑问,请发电子邮件至questions@packtpub.com
。
勘误表:虽然我们已经尽了最大的努力来保证内容的准确性,但是错误还是会发生。如果你在这本书里发现了一个错误,如果你能向我们报告,我们将不胜感激。请访问www.packtpub.com/submit-errata,选择您的图书,点击勘误表提交链接,并输入详细信息。
盗版:如果您在互联网上遇到任何形式的我们作品的非法拷贝,如果您能提供我们的位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com
联系我们,并提供材料链接。
如果你有兴趣成为一名作者:如果有一个你有专长的话题,你有兴趣写或者投稿一本书,请访问authors.packtpub.com。
复习
请留下评论。一旦你阅读并使用了这本书,为什么不在你购买它的网站上留下评论呢?然后,潜在的读者可以看到并使用您不带偏见的意见来做出购买决定,我们在 Packt 可以了解您对我们产品的看法,我们的作者可以看到您对他们的书的反馈。谢谢大家!
更多关于 Packt 的信息,请访问packtpub.com。
一、Python 机器学习入门
机器学习教机器学会自己执行任务。就这么简单。细节带来了复杂性,这很可能是你阅读这本书的原因。
也许你数据太多,见识太少。也许你希望,通过使用机器学习算法,你可以解决这个挑战,所以你开始挖掘算法。但也许过了一段时间,你会感到困惑:到底应该选择无数种算法中的哪一种?
或者,也许你只是对机器学习更感兴趣,并且你已经阅读关于它的博客和文章有一段时间了。一切似乎都很神奇和酷,所以你开始了你的探索,并把一些数据输入决策树或支持向量机。然而,在你成功地将这些应用于其他数据之后,也许你会想:整个设置是正确的吗?你得到最佳结果了吗?你怎么知道没有更好的算法?或者你的数据是否正确?
欢迎加入俱乐部!我们所有的作者都曾处于这样的阶段,寻找讲述机器学习理论教科书背后故事的信息。事实证明,这些信息大多是黑色艺术,通常不会在标准教科书中教授。所以,从某种意义上说,我们写这本书是为了年轻的自己。这本书不仅快速介绍了机器学习,还教授了我们在该领域职业生涯中学到的经验。我们希望它也能让你更顺利地进入计算机科学中最令人兴奋的领域之一。
机器学习和 Python——梦之队
机器学习的目标是通过给机器(软件)提供几个例子(即如何做或不做任务的例子)来教会它们执行任务。让我们假设每天早上当你打开你的电脑时,你执行相同的任务移动电子邮件,这样只有属于相同主题的电子邮件才会出现在同一个文件夹中。过一段时间后,你可能会觉得无聊,并考虑自动化这项工作。一种方法是开始分析你的大脑,写下你在整理邮件时大脑处理的所有规则和决定。然而,这将是相当麻烦的,并且总是不完美的。虽然你会错过一些规则,但你会超越其他规则。一种更好、更经得起未来考验的方法是,通过选择一组电子邮件元信息和正文/文件夹名称对,并让算法得出最佳规则集,来自动化这一过程。这些配对将是你的训练数据,结果规则集(也称为模型)可以应用到你还没有看到的未来电子邮件中。这是最简单形式的机器学习。
当然,机器学习本身并不是一个全新的领域。恰恰相反:它近年来的成功可以归因于它使用坚如磐石的技术和来自其他成功领域(如统计学)的见解的务实方式。在这些领域,目的是让我们人类深入了解数据——例如,通过更多地了解数据中潜在的模式和关系。随着你越来越多地阅读机器学习的成功应用(你已经查看了www.kaggle.com,是吗?),你会看到应用统计学是机器学习专家中的一个常见领域。
正如您将在后面看到的,想出一个像样的机器学习方法的过程从来都不容易。相反,你会发现自己在分析中来来回回,在不同的机器学习算法集上尝试不同版本的输入数据。正是这种探索性让 Python 非常适合自己。作为一种解释的高级编程语言,Python 似乎就是为尝试不同事物的过程而设计的。更重要的是,它甚至做得这么快。当然,它比 C 或许多其他本地编译的编程语言慢。尽管如此,有了无数用 C 语言编写的易于使用的库,你不必为了敏捷而牺牲速度。
这本书会教你什么,它不会教你什么
这本书将给你一个广泛的概述,什么类型的学习算法目前最常用于机器学习的不同领域,以及在应用它们时要注意什么。然而,从我们自己的经验来看,我们知道做酷的事情——也就是说,使用和调整机器学习算法,比如支持向量机、最近邻搜索或它们的集成——只会消耗一个好的机器学习专家做同样事情的总时间的一小部分。查看下面的典型工作流程,我们可以看到大部分时间将花在相当平凡的任务上:
- 读取数据并进行清理
- 探索和理解输入数据
- 分析如何最好地将数据呈现给学习算法
- 选择正确的模型和学习算法
- 正确测量性能
当谈到探索和理解输入数据时,我们需要使用一些统计学和基础数学。然而,当你这样做的时候,你会发现那些在你的数学课上看起来很枯燥的话题,当你用它们来看有趣的数据时,实际上是非常令人兴奋的。
当你读入数据时,旅程就开始了。当您必须回答诸如“我如何处理无效或缺失的值?”,您将会看到,与其说这是一门精确的科学,不如说这是一门艺术,是一门非常有益的艺术,因为正确地完成这一部分将使您的数据对更多的机器学习算法开放,从而增加成功的可能性。
当数据准备好并在程序的数据结构中等待时,你会想要对你正在工作的动物有一个真实的感觉。你有足够的数据来回答你的问题吗?如果没有,你可能需要考虑其他方法来获得更多。也许你甚至有太多的数据。在这种情况下,您可能需要考虑如何最好地提取样本。
通常,您不会将数据直接输入到机器学习算法中。相反,你会发现你可以在训练前提炼部分数据。通常,机器学习算法会提高你的性能。你甚至会发现,一个有精确数据的简单算法通常比一个有原始数据的非常复杂的算法要好。机器学习工作流程的这一部分被称为特征工程,大多数情况下,这是一个非常令人兴奋和有益的挑战。你会立即看到你之前创造性和智慧努力的结果。
因此,选择正确的学习算法并不仅仅是对你工具箱中的三四个进行射击(还会有更多;你会看到的)。它更像是一个权衡不同性能和功能需求的深思熟虑的过程。你需要快速的结果并且愿意牺牲质量吗?还是你更愿意花更多的时间去获得最好的结果?你对未来的数据有明确的想法吗,还是应该在这方面保守一点?
最后,对于有抱负的机器学习者来说,测量性能是这个过程中最有潜在陷阱的部分。有一些容易避免的错误,比如用你训练过的相同数据测试你的方法。但是也有更困难的方法,比如使用不平衡的训练数据。同样,数据是决定你的事业是失败还是成功的部分。
我们看到,只有第四点涉及花哨的算法。尽管如此,我们希望这本书能让你相信,其他四项任务并不只是简单的家务活,而是同样令人兴奋。我们希望,到本书结束时,你将真正爱上数据,而不是学习算法。
为此,我们不会用各种机器学习算法的理论方面来压倒你,因为在这方面已经有了优秀的书籍(你会在附录中找到指针)。相反,我们将尝试在各个章节中为您提供对基本方法的理解,这足以让您有一个想法并能够迈出第一步。因此,这本书绝不是机器学习的权威指南——它更像是一个入门工具包。我们希望它能点燃你的好奇心,让你渴望更多地了解这个有趣的领域。
在本章的剩余部分,我们将设置并了解 NumPy 和 SciPy 的基本 Python 库,然后使用 scikit-learn 训练我们的第一个机器学习算法。在此期间,我们将介绍将在整本书中使用的基本机器学习概念。接下来的章节将更详细地介绍前面描述的五个步骤,重点介绍使用不同应用场景的 Python 机器学习的不同方面。
如何最好地阅读这本书
虽然我们已经尝试提供传达本书思想所需的所有代码,但我们不想用重复的代码片段让您厌烦。相反,我们创建了独立的 Jupyter 笔记本(http://Jupyter。 org )可以通过 Git 从https://github . com/PacktPublishing/Building-Machine-Learning-Systems-with-Python-第三版 找到。
如果还没有 Jupyter,只需用pip install jupyter
安装,然后用jupyter notebook
运行即可。它提供了更丰富的体验;例如,它直接将图表集成到其中。一旦你克隆了这本书代码的 Git 库,你可以简单地点击移动 + 进入。另外,您会发现它有交互式小部件,可以让您玩代码:
卡住了怎么办
我们试图传达每一个必要的想法,以复制本书的步骤。尽管如此,还是会有你被卡住的情况。原因可能从简单的错别字到包装版本的奇怪组合,再到理解上的问题。
有许多不同的方法获得帮助。很可能,您的问题已经在以下优秀的问答网站中提出并得到解决:
- http://stats.stackexchange.com:这个 Q &一个网站被命名为 Cross Validated,类似于 MetaOptimize,但是更专注于统计问题。
- http://stackoverflow.com:这个 Q & A 站点和上一个很像,但是更广泛的关注一般的编程主题。例如,它包含关于我们将在本书中使用的一些包的更多问题,例如 SciPy 或 Matplotlib。
- https://freenode.net/:这是专注于机器学习话题的 IRC 频道。这是一个小型但非常活跃和有用的机器学习专家社区。
如开头所述,这本书旨在帮助您快速开始机器学习之旅。因此,我们强烈建议您建立自己的机器学习相关博客列表,并定期查看。这是了解什么有效,什么无效的最好方法。
我们唯一想在这里强调的博客(尽管附录中还有更多)是http://blog.kaggle.com,该公司的博客,Kaggle,主办机器学习比赛。通常,他们鼓励竞赛的获胜者写下他们是如何接近竞赛的,哪些策略不起作用,以及他们是如何达成获胜策略的。即使你不读别的东西,这也是必须的。
入门指南
假设你已经安装了 Python(至少最近的 3 个应该没问题),我们需要安装 NumPy 和 SciPy 进行数值运算,以及 Matplotlib 进行可视化。
NumPy、SciPy、Matplotlib 和 TensorFlow 简介
在我们讨论具体的机器学习算法之前,我们必须先讨论如何最好地存储我们将要咀嚼的数据。这一点很重要,因为如果最先进的学习算法永远无法完成,它也不会对我们有任何帮助。这可能仅仅是因为访问数据的过程太慢,或者它的表示迫使操作系统整天交换数据。此外,与 C 或 Fortran 相比,Python 是一种解释语言(尽管是一种高度优化的语言),对于许多数值较大的算法来说速度较慢。所以我们可能会问,为什么地球上有这么多科学家和公司把他们的财富押在 Python 上,即使是在高度计算密集型的领域。
答案是,在 Python 中,以 C 或 Fortran 扩展的形式将数字处理任务卸载到底层是非常容易的,这正是 NumPy 和 SciPy 所做的(参见https://scipy.org)。NumPy 提供了高度优化的多维数组的支持,多维数组是大多数最先进算法的基本数据结构。SciPy 使用这些数组来提供一组快速的数字配方。matplotlib(http://matplotlib.org)可能是使用 Python 绘制高质量图形最方便、功能最丰富的库。最后,TensorFlow 是 Python 的主要神经网络包之一(我们将在后续章节中解释这个包是关于什么的)。
安装 Python
幸运的是,对于所有主要的操作系统——也就是 Windows、Mac 和 Linux——都有针对 NumPy、SciPy、Matplotlib 和 TensorFlow 的目标安装程序。如果您不确定安装过程,您可能需要安装 Anaconda Python 发行版(您可以通过 https:/ / www 访问该发行版)。 蟒蛇。 com/ 下载,由 SciPy 创始投稿人 Travis Oliphant 维护开发。幸运的是,Anaconda 已经完全兼容 Python 3——我们将在本书中使用的 Python 版本。
The main Anaconda channel comes with three flavors of TensorFlow (use the Intel channel at your own risk, that is an older version of TensorFlow). The main flavor, tensorflow
, is compiled for all platforms and runs on the CPU. If you have a Haswell CPU or a more recent Intel one, you can use the tensorflow-mkl
package. Finally, if you have an Nvidia GPU with a compute capability of 3.0 or higher, you can use tensorflow-gpu
.
使用 NumPy 高效地咀嚼数据,使用 SciPy 智能地咀嚼数据
让我们快速浏览一些基本的 NumPy 示例,然后看看 SciPy 在其基础上提供了什么。在路上,我们会用神奇的matplotlib
包进行绘图。
要获得深入的解释,您可能想看一下 NumPy 在 https://docs.scipy.org/doc/numpy/user/quickstart.html 提供的一些更有趣的例子。
你也会发现帕克特出版社的伊万·伊德里斯的 NumPy 初学者指南-第二版非常有价值。其他教程风格指南可在 http:/ / www 上找到。 scipy- 讲座。 org ,官方 SciPy 教程可以在http://docs.scipy.org/doc/scipy/reference/tutorial找到。
In this book, we will use NumPy in version 1.13.3 and SciPy in version 1.0.0.
学习 NumPy
所以,让我们导入 NumPy 并玩一会儿。为此,我们需要启动 Python 交互式外壳:
>>> import numpy >>> numpy.version.full_version
1.13.3
由于我们不想污染我们的名称空间,我们当然不应该使用下面的代码:
>>> 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]])
注意,这里c
和a
是完全独立的副本。
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])
由于这是一个常见的用例,因此有一个特殊的clip
函数,通过一个函数调用来裁剪一个区间两端的值:
>>> a.clip(0,4)
array([0, 1, 4, 3, 4, 4])
处理不存在的值
当预处理我们刚刚从文本文件中读入的数据时,NumPy 的索引功能就派上了用场。很可能,这将包含无效值,我们将使用numpy.NAN
标记为非实数,如以下代码所示:
>>> # let's pretend we have read this from a text file:
>>> c = np.array([1, 2, np.NAN, 3, 4])
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.571072 sec
Naive NumPy: 1.621358 sec
Good NumPy: 0.035686 sec
我们可以从这段代码中得出两个有趣的结论。首先,仅仅使用 NumPy 作为数据存储(天真的 NumPy)需要更长的时间,这令人惊讶,因为它看起来应该快得多,因为它是作为 C 扩展编写的。处理时间增加的一个原因是从 Python 本身访问单个元素的成本相当高。只有当我们能够在优化的扩展代码中应用算法时,我们才能提高速度。另一个观察是相当惊人的:使用 NumPy 的dot()
功能,它做的完全一样,让我们的速度快了 44 倍以上。总之,在我们将要实现的每一个算法中,我们应该始终关注如何将 Python 中各个元素上的循环转移到一些高度优化的 NumPy 或 SciPy 扩展函数中。
然而,这种速度是有代价的。使用 NumPy 数组,我们不再拥有 Python 列表难以置信的灵活性,它基本上可以容纳任何东西。NumPy 数组总是只有一种数据类型:
>>> a = np.array([1,2,3])
>>> a.dtype
dtype('int32')
如果我们尝试使用不同类型的元素,例如下面代码中显示的元素,NumPy 将尽最大努力将其更正为最合理的常见数据类型:
>>> np.array([1, "stringy"])
array(['1', 'stringy'], dtype='<U11')
>>> np.array([1, "stringy", {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
1.0.0
>>> scipy.dot is numpy.dot
True
不同的算法被分组到以下工具箱中:
| SciPy 套装 | 功能 |
| cluster
| 层次聚类(cluster.hierarchy
)矢量量化/K 均值(cluster.vq
) |
| constants
| 物理和数学常数转换方法 |
| fftpack
| 离散傅里叶变换算法 |
| integrate
| 集成例程 |
| interpolate
| 插值(线性、三次等) |
| io
| 数据输入和输出 |
| linalg
| 使用优化的BLAS
和LAPACK
库的线性代数例程 |
| ndimage
| n 维图像包 |
| odr
| 正交距离回归 |
| optimize
| 优化(寻找最小值和根) |
| signal
| 信号处理 |
| sparse
| 稀疏矩阵 |
| spatial
| 空间数据结构和算法 |
| special
| 特殊的数学函数,如贝塞尔函数或雅可比函数 |
| stats
| 统计工具包 |
与我们的目标最相关的工具箱是scipy.stats
、scipy.interpolate
、scipy.cluster
和scipy.signal
。为了简洁起见,我们将简要探讨stats
包的一些特性,并在个别章节中解释其他特性。
机器学习基础
在机器学习中,我们正在做的是问一个问题并回答它。从我们拥有的样本中,我们创建了一个问题,这是模型的学习方面。回答这个问题需要使用新样本的模型。
问问题
如果工作流涉及预处理特征,然后是模型训练,最后是模型使用,那么预处理特征步骤可以与我们提问时所做的假设相联系。例如,问题可以是,“知道猫有两只耳朵、两只眼睛、一个鼻子、一张嘴和胡须,这些是猫的形象吗?”
我们这里的假设与如何对图像进行预处理以获得耳朵、眼睛、鼻子、嘴巴和胡须的数量有关。这些数据将在训练过程中输入模型,以便我们得到答案。
获取答案
一旦模型被训练,我们就使用相同的特征来得到我们的答案。当然,有了我们之前问的问题,如果我们喂猫的图像,我们会得到一个肯定的答案。但是如果我们以老虎、狮子或狗的形象进食,我们也会得到肯定的识别。所以我们问的问题不是,“这些是猫的图像吗?”,但真的,“这些是猫的形象,知道猫有两只耳朵,两只眼睛,一个鼻子,一张嘴,还有胡须吗?”。我们对猫的定义是错误的,导致我们得出错误的答案。
这就是诀窍和实践很重要的地方。一旦理解了这一要点,任何人都可以设计出正确的模型来回答你被问到的问题。
我们机器学习的第一个(微小的)应用
让我们把手弄脏,看看我们假设的网络初创公司 MLaaS,它销售通过 HTTP 提供机器学习算法的服务。随着我们公司越来越成功,对更好的基础设施的需求也在增加,这样我们就可以成功地服务所有传入的 web 请求。我们不想分配太多的资源,因为那太昂贵了。另一方面,如果我们没有保留足够的资源来服务所有传入的请求,我们将会赔钱。现在,问题是,我们何时才能达到当前基础架构的极限,我们估计该基础架构每小时的容量约为 100,000 个请求?我们想提前知道何时我们必须请求云中的额外服务器来成功地服务所有传入的请求,而无需为未使用的请求付费。
读入数据
我们已经收集了上个月的网络统计数据,并将其汇总到一个名为ch01/data/web_traffic.tsv
( .tsv
的文件中,因为它包含以制表符分隔的值)。它们被存储为每小时的点击次数。每一行都包含该小时以及该小时内的网页点击量。时间是连续列出的。
使用 SciPy 的genfromtxt()
,我们可以使用以下代码轻松读取数据:
>>> data = np.genfromtxt("web_traffic.tsv", delimiter="\t")
我们必须指定制表符作为分隔符,以便正确确定列。快速检查表明我们已经正确读取了数据:
>>> print(data[:10])
[[ 1.00000000e+00 2.27333105e+03]
[ 2.00000000e+00 1.65725549e+03]
[ 3.00000000e+00 nan]
[ 4.00000000e+00 1.36684644e+03]
[ 5.00000000e+00 1.48923438e+03]
[ 6.00000000e+00 1.33802002e+03]
[ 7.00000000e+00 1.88464734e+03]
[ 8.00000000e+00 2.28475415e+03]
[ 9.00000000e+00 1.33581091e+03]
[ 1.00000000e+01 1.02583240e+03]]
>>> print(data.shape)
(743, 2)
如你所见,我们有2
维度的743
数据点。
预处理和清理数据
SciPy 更方便的是将维度分成两个向量,每个向量的大小为743
个数据点。第一个向量*x*
将包含小时数,另一个向量*y*
将包含特定小时内的网页点击量。这种拆分是使用 SciPy 的特殊索引表示法完成的,通过这种方法,我们可以单独选择列:
x = data[:,0] y = data[:,1]
从 SciPy 数组中选择数据的方式还有很多。查看https://docs.scipy.org/doc/numpy/user/quickstart.html了解更多关于索引、切片和迭代的细节。
一个警告是我们在*y*
中仍然有一些包含无效值的值,比如nan
。问题是我们能拿他们怎么办。让我们通过运行以下代码来检查多少小时包含无效数据:
>>> np.sum(np.isnan(y))
8
如您所见,我们在743
条目中只缺少8
,因此我们可以删除它们。请记住,我们可以用另一个数组来索引 SciPy 数组。Sp.isnan(y)
短语返回一个布尔数组,指示一个条目是否是数字。使用~
,我们在逻辑上否定该数组,以便我们只选择那些来自*x*
和*y*
的元素,其中 y 包含有效数字:
>>> x = x[~np.isnan(y)] >>> y = y[~np.isnan(y)]
为了获得数据的第一印象,让我们使用matplotlib
在散点图中绘制数据。Matplotlib 包含pyplot
包,试图模仿 MATLAB 的界面,这是一个非常方便易用的界面,可以在下面的代码中看到:
import matplotlib.pyplot as plt
def plot_web_traffic(x, y, models=None):
'''
Plot the web traffic (y) over time (x).
If models is given, it is expected to be a list of fitted models,
which will be plotted as well (used later in this chapter).
'''
plt.figure(figsize=(12,6)) # width and height of the plot in inches
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(5)],
['week %i' %(w+1) for w in range(5)])
if models:
colors = ['g', 'k', 'b', 'm', 'r']
linestyles = ['-', '-.', '--', ':', '-']
mx = sp.linspace(0, x[-1], 1000)
for model, style, color in zip(models, linestyles, colors):
plt.plot(mx, model(mx), linestyle=style, linewidth=2, c=color)
plt.legend(["d=%i" % m.order for m in models], loc="upper left")
plt.autoscale(tight=True)
plt.grid()
这里的主要命令是plt.scatter(x, y, s=10)
,它以 y 为单位绘制了 x 中各天的网络流量。当 s=10 时,我们将设置线宽。然后我们稍微修饰一下图表(标题、标签、网格等等),最后我们提供了向其中添加额外模型的可能性。
你可以在http://matplotlib.org/users/pyplot_tutorial.html找到更多关于绘图的教程。
您可以通过以下方式运行此功能:
>>> plot_web_traffic(x, y)
我们将看到如果您在 Jupyter 笔记本会话中运行以下命令会发生什么:
>>> %matplotlib inline
在笔记本的一个单元格中,Jupyter 将使用以下代码自动以内嵌方式显示生成的图形:
>>> plot_web_traffic(x, y)
如果您在一个普通的命令外壳中,您必须将图形保存到磁盘,然后用图像查看器显示它:
>>> plt.savefig("web_traffic.png"))
在生成的图表中,我们可以看到,虽然前几周的流量大致保持不变,但最后一周的流量却急剧增加:
选择正确的模型和学习算法
现在我们对数据有了第一印象,我们回到最初的问题:我们的服务器能够处理传入的 web 流量多长时间?要回答这个问题,我们必须做到以下几点:
- 找到噪声数据点背后的真实模型
- 使用该模型来找到我们的基础设施不能再处理负载并且必须扩展的时间点
在我们建立第一个模型之前
当我们谈论模型时,你可以认为它们是复杂现实的简化理论近似。因此,总会有一些劣势,也称为近似误差。这个错误将指导我们在众多选择中选择正确的型号。我们将此误差计算为模型预测与真实数据的平方距离;例如,对于学习的模型函数f
,误差计算如下:
def error(f, x, y):
return np.sum((f(x)-y)**2)
向量*x*
和*y*
包含我们之前提取的网络统计数据。这就是 NumPy 向量化函数的美妙之处,我们在这里利用f(x)
。假设训练好的模型取一个向量,并将结果作为相同大小的向量再次返回,这样我们就可以用它来计算 y 的差值。
从一条简单的直线开始
让我们假设基础模型是一条直线。接下来的挑战是如何最好地将这条线放入图表中,使其产生最小的近似误差。SciPy 的polyfit()
函数正是这么做的。给定数据x
和y
以及多项式的期望阶数(直线的阶数为1
,它会找到使前面定义的误差函数最小的模型函数:
fp1 = np.polyfit(x, y, 1)
polyfit()
功能返回拟合的Model
功能的参数,fp1
:
>>> print("Model parameters: %s" % fp1)
Model parameters: [ 2.59619213 989.02487106]
这意味着最佳直线拟合是以下函数:
f(x) = 2.59619213 * x + 989.02487106
然后我们使用poly1d()
从model
参数创建一个model
函数:
>>> f1 = np.poly1d(fp1)
>>> print(error(f1, x, y))
317389767.34
我们现在可以使用f1()
来绘制我们的第一个训练模型。我们已经实现了plot_web_traffic
的方式,让我们可以轻松地添加额外的模型来绘图。此外,我们传递了一个模型列表,其中我们目前只有一个:
plot_web_traffic(x, y, [f1])
这将产生以下图:
看起来前四周并没有那么远,尽管我们可以清楚地看到,我们最初假设的基础模型是一条直线是有问题的。还有319,531,507.008
的误差到底有多好或者有多坏?
误差的绝对值很少单独使用。然而,在比较两个竞争模型时,我们可以利用它们的误差来判断哪一个更好。尽管我们的第一个模型显然不是我们要使用的模型,但它在工作流中有着非常重要的作用。我们将把它作为我们的基线,直到我们找到更好的基线。无论我们将来提出什么模型,我们都将与当前的基线进行比较。
走向更复杂的模型
现在让我们拟合一个更复杂的模型,一个 2 次多项式,看看它是否更好地理解我们的数据:
>>> f2p = np.polyfit(x, y, 2)
>>> print(f2p)
[ 1.05605675e-02 -5.29774287e+00 1.98466917e+03]
>>> f2 = np.poly1d(f2p)
>>> print(error(f2, x, y))
181347660.764
借助plot_web_traffic(x, y, [f1, f2])
我们可以看到一个二级函数如何对我们的网络流量数据进行建模:
误差为181,347,660.764
,几乎是直线模型误差的一半。这很好,但不幸的是这是有代价的:我们现在有了一个更复杂的函数,这意味着我们在polyfit()
内部还有一个参数需要调整。拟合多项式如下:
f(x) = 0.0105605675 * x**2 - 5.29774287 * x + 1984.66917
所以,如果更多的复杂性带来更好的结果,为什么不增加更多的复杂性呢?让我们试试 3 度、10 度和 100 度:
有趣的是,我们没有看到已经用 100 度拟合的多项式的 d = 100 ,而是 d = 53 。这与我们在安装 100 度时得到的警告有关:
RankWarning: Polyfit may be poorly conditioned
这意味着,由于数值误差,polyfit
无法确定 100 度的良好拟合。相反,它认为 53 就足够了。
似乎曲线越复杂,越能更好地捕捉拟合数据。这些错误似乎讲述了同样的故事:
>>> print("Errors for the complete data set:")
>>> for f in [f1, f2, f3, f10, f100]:
... print("td=%i: %f" % (f.order, error(f, x, y)))
...
完整数据集的错误如下:
d=1: 319,531,507.008126
d=2: 181,347,660.764236
d=3: 140,576,460.879141
d=10: 123,426,935.754101
d=53: 110,768,263.808878
然而,仔细观察拟合的曲线,我们开始怀疑它们是否也捕捉到了生成该数据的真实过程。换句话说,我们的模型是否正确地代表了访问我们网站的客户的潜在群体行为?查看次数为10
和53
的多项式,我们会看到剧烈振荡的行为。这些模型似乎太符合数据了。以至于这个图表现在不仅捕捉到了潜在的过程,还捕捉到了噪音。这叫超配。
此时,我们有以下选择:
- 选择一个拟合的多项式模型
- 切换到另一个更复杂的模型类
- 对数据进行不同的思考,然后重新开始
在五个拟合的模型中,一阶模型显然过于简单,10 阶和 53 阶模型显然过度拟合。似乎只有二阶和三阶模型与数据相符。然而,如果我们在两个边界推断它们,我们会看到它们变得狂暴。
转到更复杂的班级似乎也不是正确的方法。哪些论点支持哪个阶级?此时,我们意识到我们可能还没有完全理解我们的数据。
退后一步继续前进——再看一下我们的数据
所以,我们退后一步,再看看数据。似乎在第 3 周和第 4 周之间有一个拐点。让我们分离数据,以周3.5
为分离点训练两条线:
>>> inflection = int(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 度的模式似乎并不期待我们初创企业的光明未来。他们如此努力地试图对给定的数据进行正确的建模,以至于他们显然没有必要进行进一步的推断。这叫做过度拟合。
另一方面,低学位模型似乎不能很好地捕捉数据。这叫做下配。
所以,让我们公平地对待 2 级及以上的模型,看看如果我们只让它们符合上周的数据,它们会如何表现。毕竟,我们相信上周比之前的数据更能说明未来。结果可以在下面的迷幻图中看到,它进一步显示了过度拟合的问题有多糟糕:
请参见以下命令:
>>> fb1 = np.poly1d(np.polyfit(xb, yb, 1))
>>> fb2 = np.poly1d(np.polyfit(xb, yb, 2))
>>> fb3 = np.poly1d(np.polyfit(xb, yb, 3))
>>> fb10 = np.poly1d(np.polyfit(xb, yb, 10))
>>> fb100 = np.poly1d(np.polyfit(xb, yb, 100))
>>> print("Errors for only the time after inflection point")
>>> for f in [fb1, fb2, fb3, fb10, fb100]:
... print("td=%i: %f" % (f.order, error(f, xb, yb)))
>>> plot_web_traffic(x, y, [fb1, fb2, fb3, fb10, fb100],
... mx=np.linspace(0, 6 * 7 * 24, 100),
... ymax=10000)
下表显示了拐点后的误差和时间:
| 错误 | 拐点后的时间 |
| d = 1
| 22140590.598233
|
| d = 2
| 19764355.660080
|
| d = 3
| 19762196.404203
|
| d = 10:
| 18942545.482218
|
| d = 53:
| 18293880.824253
|
尽管如此,从仅在第 3.5 周及以后的数据上训练时模型的误差来看,我们仍然应该选择最复杂的一个(注意,我们也计算仅在拐点后出现的数据点上训练时的误差)。
培训和测试
如果我们只有一些未来的数据可以用来衡量我们的模型,那么我们应该能够只根据最终的近似误差来判断我们的模型选择。
虽然我们不能展望未来,但我们可以也应该通过保留一部分数据来模拟类似的效果。例如,让我们移除一定比例的数据,并在剩下的数据上进行训练。然后,我们使用保留的数据来计算误差。由于模型是在不知道持有数据的情况下训练的,我们应该对模型未来的行为有一个更真实的了解。
仅在拐点之后的时间训练的模型的测试误差现在显示了完全不同的情况:
d=1: 6492812.705336
d=2: 5008335.504620
d=3: 5006519.831510
d=10: 5440767.696731
d=53: 5369417.148129
看看下面的情节:
看起来具有 2 度和 3 度的模型具有最低的测试误差,这是当使用模型在训练期间没有看到的数据进行测量时显示的误差。这给了我们希望,当未来的数据到来时,我们不会得到坏的惊喜。然而,我们还没有完全完成。
我们将在下一个图中看到为什么我们不能简单地选择误差最小的模型:
拥有 3 级的模型并没有预见到我们会有每小时 10 万次点击的未来。所以我们坚持 2 级。
回答我们最初的问题
我们终于找到了一些我们认为最能代表底层流程的模型。现在,只需找出我们的基础架构何时会达到每小时 100,000 个请求就可以了。我们必须计算我们的模型函数何时达到 100,000 的值。因为两个模型(2 度和 3 度)非常接近,我们将为两个模型都这样做。
对于 2 次多项式,我们可以简单地计算出函数的倒数,并计算出它的值为 100,000。当然,我们希望有一种方法可以容易地应用于任何模型函数。
这可以通过从多项式中减去 100,000,得到另一个多项式,并找到它的根来实现。当提供带有x0
参数的初始起始位置时,SciPy 的optimize
模块具有实现这一点的fsolve
功能。由于我们的输入数据文件中的每个条目对应一个小时,并且我们有其中的743
,因此我们将开始位置设置为之后的某个值。让fbt2
成为次数2
的获胜多项式:
>>> fbt2 = np.poly1d(np.polyfit(xb[train], yb[train], 2))
>>> print("fbt2(x)= n%s" % fbt2)
fbt2(x)=
2
0.05404 x - 50.39 x + 1.262e+04
>>> print("fbt2(x)-100,000= n%s" % (fbt2-100000))
fbt2(x)-100,000=
2
0.05404 x - 50.39 x - 8.738e+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])
100,000 hits/hour expected at week 10.836350
预计每周10.836350
会有 10 万次点击/小时,所以我们的模型告诉我们,考虑到当前的用户行为和我们初创企业的牵引力,我们还需要几周时间才能达到容量阈值。
当然,我们的预测存在一定的不确定性。为了获得真实的情况,我们可以引入更复杂的统计数据,以找到我们在越来越深入地展望未来时可以预期的差异。
还有我们无法精确建模的用户和底层用户行为动态。然而,在这一点上,我们对当前的预测没有意见,因为它足以回答我们最初的问题,即何时必须增加系统的容量。如果我们密切监控我们的网络流量,我们将及时看到何时我们必须分配新的资源。
摘要
恭喜你!您刚刚学到了两件重要的事情,其中最重要的是,作为一名典型的机器学习操作员,您将花费大部分时间来理解和提炼数据——这正是我们刚刚在第一个微小的机器学习示例中所做的。我们希望这个例子能帮助你开始将你的精神焦点从算法转移到数据上。
然后,你学会了拥有正确的实验设置是多么重要,不要把训练和测试混为一谈是至关重要的。诚然,多项式拟合的使用并不是机器学习世界中最酷的事情;我们之所以选择它,是为了当我们传达前面提到的两个最重要的信息时,您不会被一些闪亮算法的冷静分散注意力。
那么,让我们继续进入第二章、用真实世界的例子进行分类,我们正在讨论分类的话题。现在,我们将把这些概念应用于一种非常具体但非常重要的数据类型,即文本。
二、使用真实例子分类
本章的题目是分类。在机器学习的这种设置中,您向系统提供您感兴趣的不同对象类的示例,然后要求它推广到该类未知的新示例。这看似抽象,但你可能已经将这种形式的机器学习作为一种消费,即使你没有意识到这一点:你的电子邮件系统很可能有自动检测垃圾邮件的能力。也就是说,系统将分析所有收到的电子邮件,并将它们标记为垃圾邮件或非垃圾邮件。通常,您(最终用户)能够手动标记电子邮件是否为垃圾邮件,以提高其垃圾邮件检测能力。这正是我们所说的分类:您提供垃圾邮件和非垃圾邮件的示例,然后使用自动系统对收到的电子邮件进行分类。这是最重要的机器学习模式之一,也是本章的主题。
处理电子邮件等文本需要一套特定的技术和技能,我们将在本书后面讨论这些。目前,我们将使用更小、更容易处理的数据集。本章的示例问题是:机器能根据图像区分花卉种类吗?我们将使用两个数据集,其中记录了花卉形态的测量结果以及几个标本的物种。
我们将探索这些小数据集,以便专注于高级概念。本章的重要内容如下:
- 什么是分类
- scikit-learn 如何用于分类,哪个分类器是大多数问题的好解决方案
- 如何严格评价一个分类器,避免自欺欺人
虹膜数据集
Iris 数据集是 20 世纪 30 年代的经典数据集;这是统计分类的第一个现代例子。
该数据集是几种鸢尾花形态测量的集合。这些测量将使我们能够区分多种花卉。如今,物种是通过它们的 DNA 指纹来识别的,但是在 20 世纪 30 年代,DNA 在遗传学中的作用还没有被发现。
测量了每种植物的以下四个属性:
- 萼片长度
- 萼片宽度
- 花瓣长度
- 花瓣宽度
一般来说,我们称我们用来描述数据的单个数值测量为特征。这些特征可以从中间数据直接测量或计算。
该数据集有四个特征。此外,对于每种植物,物种都被记录下来。我们想要解决的问题是:“给定这些例子,如果我们在田野里看到一朵新的花,我们能从它的测量中很好地预测它的物种吗?
这就是分类的问题:给定标注的例子,我们能不能设计一个规则,以后应用到其他例子?
在这本书的后面,我们将研究处理文本的问题。目前,Iris 数据集很好地服务于我们的目的。它很小(150 个例子,每个例子有四个特征),可以很容易地可视化和操作。
可视化是很好的第一步
本书后面的数据集将增长到数千个特征。在我们的起始示例中只有四个要素,我们可以轻松地在单个页面上绘制所有二维投影并构建预测,然后可以将其扩展到具有更多要素的大型数据集。正如我们在第 3 章、回归中所看到的,可视化在分析的初始探索阶段非常出色,因为它们允许您了解问题的一般特征,以及捕捉数据收集早期出现的问题。
下图中的每个子图显示了投射到两个维度中的所有点。外围组(三角形)为濑户鸢尾属植物,中心组(圆形)为杂色鸢尾属植物,以 x 标记绘制处女座鸢尾。我们可以看到有两大集团。一种是濑户鸢尾,另一种是杂色鸢尾和弗吉尼亚鸢尾的混合物:
以下是加载数据集的代码(您可以在在线存储库中找到绘图代码):
from sklearn.datasets import load_iris
data = load_iris()
features = data.data
feature_names = data.feature_names
target = data.target
target_names = data.target_names
labels = target_names[target]
使用 scikit-learn 进行分类
Python 是一种优秀的机器学习语言,因为它有优秀的库。特别是 scikit-learn 已经成为包括分类在内的许多机器学习任务的标准库。我们将在本节和其他分类器中使用它的决策树实现。幸运的是,scikit-learn 中的分类器遵循相同的 API,因此很容易从一个转换到另一个。这些对象有以下两种基本方法:
fit(features, labels)
:这是学习步骤,拟合模型的参数。它以一个类似列表的带有特征的对象和另一个带有标签的对象作为参数。predict(features)
:该方法只能在拟合后调用,并返回一个或多个输入的预测。
建立我们的第一个分类模型
如果目标是将三种类型的花分开,我们可以通过查看数据立即提出一些建议。例如,花瓣长度似乎能够独立地将濑户鸢尾与其他两个花卉物种分开。
直观来说,我们可以在脑海中建立一个简单的模型:如果花瓣宽度小于约1
,那么这就是一朵鸢尾花;否则要么是弗吉尼亚鸢尾,要么是杂色鸢尾。机器学习是当我们编写代码时自动寻找这种类型的分离。
将濑户鸢尾与其他两个物种区分开来的问题很容易解决。然而,我们不能立即确定区分弗吉尼亚鸢尾和杂色鸢尾的最佳切口。我们甚至可以看到,我们永远不会用一个简单的规则来实现完美的分离,比如,如果特征 X 高于某个值,那么 A,否则 b。
我们可以尝试在决策树中组合多个规则。这是最简单的分类模型之一,也是最早为机器学习提出的模型之一。它的另一个优点是模型可以简单地解释。
使用 scikit-learn,很容易学习决策树:
from sklearn import tree
tr = tree.DecisionTreeClassifier(min_samples_leaf=10)
tr.fit(features, labels)
就这样。可视化树需要我们首先以点格式将其写入文件,然后显示它:
import graphviz
tree.export_graphviz(tr, feature_names=feature_names, round-ed=True, out_file='decision.dot')
graphviz.Source(open('decision.dot').read())
我们可以看到,第一次拆分是花瓣宽度,结果是两个节点,一个节点是所有样本都是第一类的(用[50,0,0]
表示),其余的是数据([0,50,50]
)。
这个模型有多好?我们可以通过将它应用于数据(使用predict
方法)并查看它与输入的匹配程度来尝试它:
prediction = tr.predict(features)
print("Accuracy: {:.1%}".format(np.mean(prediction == labels)))
这会打印出精度:96.0
百分比。
评估-提供数据和交叉验证
上一节讨论的模型是一个简单的模型;它实现了整个数据的百分之96.0
精度。然而,这一评价几乎肯定过于乐观。我们使用数据来定义树的外观,然后使用相同的数据来评估模型。当然,该模型在这个数据集上会表现很好,因为它已经过优化,在这个数据集上表现很好。推理是循环的。
我们真正想做的是评估模型推广到新实例的能力。我们应该在算法在训练中没有看到的情况下测量它的性能。因此,我们将进行更严格的评估,并使用保留的数据。为此,我们将数据分成两组:在一组中,我们将训练模型,在另一组中,我们将测试我们在训练中保留的模型。完整的代码是对前面介绍的代码的改编,可在在线支持存储库中找到。其输出如下:
Training accuracy was 96.0%.
Testing accuracy was 94.7%.
训练数据(整个数据的子集)的结果与之前相同。然而,需要注意的是,测试数据的结果低于训练误差的结果。在这种情况下,差异很小,但可以大得多。当使用复杂模型时,在训练中有可能获得 100%的准确性,并且在测试中做得不比随机猜测好!虽然这可能会让没有经验的机器学习者感到惊讶,但预计测试精度将低于训练精度。
要理解为什么,考虑一下决策树是如何工作的:它定义了不同特征的一系列阈值。有时可能非常清楚阈值应该在哪里,但在某些区域,即使是一个数据点也可以改变阈值并上下移动。
The accuracy on the training data, the training accuracy, is almost always an overly optimistic estimate of how well your algorithm is doing. We should always measure and report the testing accuracy, which is the accuracy on a collection of examples that were not used for training.
我们刚才所做的一个可能的问题是,我们只使用了一半的数据进行训练。也许使用更多的训练数据会更好。另一方面,如果我们留给测试的数据太少,误差估计将在很少的例子上进行。理想情况下,我们希望将所有数据用于培训,也将所有数据用于测试,这是不可能的。
我们可以通过一种叫做交叉验证的方法来很好地近似这个不可能的理想。交叉验证的一种简单形式是省去一个交叉验证。我们将从训练数据中拿出一个例子,学习一个没有这个例子的模型,然后测试这个模型对这个例子的分类是否正确。
然后对数据集中的所有元素重复该过程:
predictions = []
for i in range(len(features)):
train_features = np.delete(features, i, axis=0)
train_labels = np.delete(labels, i, axis=0)
tr.fit(train_features, train_labels)
predictions.append(tr.predict([features[i]]))
predictions = np.array(predictions)
在这个循环结束时,我们将对所有示例测试一系列模型,并将获得最终的平均结果。当使用交叉验证时,不存在循环性问题,因为每个例子都是在没有考虑数据点的模型上测试的。因此,交叉验证的估计是对模型推广到新数据的可靠估计。
省略交叉验证的主要问题是,我们现在被迫多次执行更多的工作。事实上,您必须为每一个示例学习一个全新的模型,并且这个成本会随着数据集的增长而增加。
通过使用 k 倍交叉验证,我们可以以很小的成本获得省略的大部分好处,其中 k 代表一个小数字。例如,为了执行五重交叉验证,我们将数据分成五组,即所谓的五重。
然后你学习五种模式。每次,你都会在训练数据中留下一个折叠。生成的代码将类似于本节前面给出的代码,但是我们保留了 20%的数据,而不是只有一个元素。我们在左侧折叠上测试每个模型,并对结果进行平均:
上图说明了五个块的这个过程:数据集被分成五部分。对于每一个折叠,你拿出其中一个积木进行测试,并在另外四个积木上进行训练。你可以使用任意数量的折叠。计算效率(折叠越多,需要的计算就越多)和精确结果(折叠越多,就越接近使用整个数据进行训练)之间存在权衡。五倍往往是一个很好的妥协。这相当于用 80%的数据进行训练,应该已经接近使用所有数据得到的结果。如果你的数据很少,你甚至可以考虑使用 10 或 20 倍。在一个极端的情况下,如果你有和数据点一样多的折叠,你只是简单地执行了省略交叉验证。另一方面,如果计算时间是一个问题,并且你有更多的数据,两三倍可能是更合适的选择。
生成折叠时,您需要小心保持它们的平衡。例如,如果一个文件夹中的所有示例都来自同一个类,则结果将不具有代表性。我们将不详细讨论如何做到这一点,因为 scikit-learn 机器学习包将为您处理它们。以下是如何使用 scikit-learn 执行五重交叉验证:
from sklearn import model_selection
predictions = model_selection.cross_val_predict(
tr,
features,
labels,
cv=model_selection.LeaveOneOut())
print(np.mean(predictions == labels))
我们现在已经生成了几个模型,而不是一个。那么,我们为新数据返回和使用什么最终模型呢?最简单的解决方案是现在在所有的训练数据上训练一个单一的整体模型。交叉验证循环为您提供了该模型推广程度的估计。
A cross-validation schedule allows you to use all your data to estimate whether your methods are doing well. At the end of the cross-validation loop, you can then use all your data to train a final model.
尽管在机器学习作为一个领域起步时,它没有得到正确的认识,但如今,甚至讨论分类系统的训练精度都被视为一个非常糟糕的迹象。这是因为结果可能会非常误导人,甚至仅仅呈现它们就标志着你是机器学习的新手。我们总是希望测量和比较保留数据集的误差或使用交叉验证方案估计的误差。
如何衡量和比较分类器
我们如何决定哪个分类器是最好的?我们很少找到完美的解决方案,永远不会出错的模型,所以我们需要决定使用哪一个。我们以前使用过精度,但有时优化会更好,这样模型会产生更少的特定类型的错误。例如,在垃圾邮件过滤中,删除一封好邮件可能比错误地让一封坏邮件通过更糟糕。在这种情况下,我们可能希望选择一个在扔掉电子邮件方面保守的模式,而不是一个总体上犯错最少的模式。我们可以从收益(我们希望最大化)或损失(我们希望最小化)的角度来讨论这些问题。它们是等价的,但有时一个比另一个更方便,你会读到讨论最小化损失或最大化收益的文章。
在医学环境中,假阴性和假阳性并不等同。一个假阴性(当一个测试的结果返回阴性,但那是假的)可能会导致病人没有接受严重疾病的治疗。一个假阳性(当检测结果呈阳性时,即使患者实际上并没有那种疾病)可能会导致额外的检测来确认这种或不必要的治疗(这仍然会有成本,包括治疗的副作用,但通常没有错过诊断那么严重)。因此,根据具体的设置,不同的权衡是有意义的。在一个极端的情况下,如果疾病是致命的,并且治疗费用低廉,副作用很小,那么你就要尽可能减少假阴性。
What the gain/cost function should be is always dependent on the exact problem you are working on. When we present a general-purpose algorithm, we often focus on minimizing the number of mistakes, achieving the highest accuracy. However, if some mistakes are costlier than others, it might be better to accept a lower overall accuracy to minimize the overall costs.
更复杂的数据集和最近邻分类器
我们现在来看一个稍微复杂一点的数据集。这将包括引入新的分类算法和一些其他想法。
了解种子数据集
我们现在看另一个农业数据集,它仍然很小,但是已经太大了,不能像我们使用 Iris 数据集那样在一个页面上详尽地绘制出来。这个数据集由小麦种子的测量值组成。存在以下七个特征:
- A 区
- 周长 P
- 紧密度 C = 4πA/P
- 内核长度
- 内核宽度
- 偏度系数
- 仁沟长度
与三个小麦品种相对应的有三类:加拿大小麦、科马小麦和罗莎小麦。如前所述,目标是能够根据这些形态学测量对物种进行分类。与 20 世纪 30 年代收集的 Iris 数据集不同,这是一个非常新的数据集,其特征是根据数字图像自动计算的。
这就是图像模式识别的实现方式:你可以拍摄数字形式的图像,从中计算出一些相关的特征,并使用一个通用的分类系统。在第 12 章计算机视觉中,我们将通过这个问题的计算机视觉方面进行工作,并计算图像中的特征。目前,我们将使用赋予我们的特性。
UCI Machine Learning Dataset Repository:
The University of California at Irvine (UCI) maintains an online repository of machine learning datasets (at the time of writing, they list 233 datasets). Both the Iris and the seeds datasets used in this chapter were taken from there. The repository is available online at http://archive.ics.uci.edu/ml/.
特征和特征工程
这些特征的一个有趣的方面是紧凑性特征实际上不是一个新的度量,而是前面两个特征的函数:面积和周长。导出新的组合特征通常非常有用。尝试创建新特征一般称为特征工程。它有时被认为不如算法迷人,但它通常对性能更重要(在精选特征上的简单算法比在不太好的特征上的花哨算法性能更好)。
在这种情况下,最初的研究人员计算了紧密度,这是形状的典型特征。它有时也被称为圆形。该特性对于两个内核具有相同的值,其中一个内核是另一个内核的两倍大,但是具有相同的形状。但是,与拉长的内核(当特征接近于零时)相比,非常圆的内核(当特征接近于 1 时)将具有不同的值。
一个好的特性的目标是同时随重要的东西(期望的输出)而变化,而不随不重要的东西而变化。例如,紧凑性不是在大小上变化,而是在形状上变化。在实践中,可能很难完美地实现这两个目标,但我们希望接近这个理想。
你将需要使用背景知识来设计好的功能。幸运的是,对于许多问题领域来说,已经有大量可能的特性和特性类型可以构建。就图像而言,前面提到的所有特征都是典型的,计算机视觉库将为您计算它们。在基于文本的问题中,也有可以混合搭配的标准解决方案(我们也会在第 4 章、分类 I -检测不良答案中看到这一点)。在可能的情况下,你应该利用你对问题的了解来设计一个特定的特性,或者从文献中选择更适用于手头数据的特性。
甚至在你有数据之前,你必须决定哪些数据值得收集。然后,你把你所有的特征交给机器来评估和计算最好的分类器。
一个自然的问题是,我们能否自动选择好的特征。这个问题被称为特征选择。针对这个问题已经提出了许多方法,但在实践中,非常简单的想法效果最好。对于我们目前正在探索的小问题,使用特征选择是没有意义的,但是如果你有成千上万个特征,那么扔掉其中的大部分可能会使剩下的过程更快。
最近邻分类
为了使用这个数据集,我们将引入一个新的分类器:最近邻分类器。最近邻分类器非常简单。对新元素进行分类时,会查看训练数据。对于离它最近的物体,它最近的邻居。然后,它返回其标签作为答案。请注意,该模型在其训练数据上表现完美!对于每个点,它最近的邻居就是它自己,因此它的标签完全匹配(除非两个具有不同标签的示例具有完全相同的特征值,这将表明您正在使用的特征描述性不强)。因此,使用交叉验证协议来测试分类是至关重要的。
最近邻法可以概括为不看单个邻居,而是看多个邻居,并且可以在邻居中进行投票。这使得该方法比异常值或错误标记的数据更稳健。
为了使用 scikit-learn 的最近邻分类实现,我们首先从sklearn.neighbors
子模块导入KneighborsClassifier
对象:
from sklearn.neighbors import KNeighborsClassifier
我们现在可以实例化一个classifier
对象。在构造器中,我们指定要考虑的neighbors
的数量,如下所示:
knn = KNeighborsClassifier(n_neighbors=1)
如果我们不指定邻居的数量,它默认为5
,这通常是一个很好的分类选择,但是我们坚持使用1
,因为它非常容易思考(在在线存储库中,您可以使用这些参数来玩)。
我们将使用交叉验证(当然)来查看我们的数据。scikit-learn 模块也使这变得简单:
kf = model_selection.KFold(n_splits=5, shuffle=False)
means = []
for training,testing in kf.split(features):
# We learn a model for this fold with `fit` and then apply it to the
# testing data with `predict`:
knn.fit(features[training], target[training])
prediction = knn.predict(features[testing])
# np.mean on an array of booleans returns fraction
# of correct decisions for this fold:
curmean = np.mean(prediction == target[testing])
means.append(curmean)
print('Mean accuracy: {:.1%}'.format(np.mean(means)))
使用五倍交叉验证,对于这个数据集,使用这个算法,我们获得了 83.8%的准确率。正如我们在前面部分中讨论的,交叉验证精度低于训练精度,但这是对模型性能更可信的估计
。
查看决策边界
我们现在将检查决策边界。为了在纸上画出这些,我们将简化并只看两个维度:
knn.fit(features[:, [0,2]], target)
我们将通过1000
点调用特征值网格上的预测(T0):
y0, y1 = features[:, 2].min() * .9, features[:, 2].max() * 1.1
x0, x1 = features[:, 0].min() * .9, features[:, 0].max() * 1.1
X = np.linspace(x0, x1, 1000)
Y = np.linspace(y0, y1, 1000)
X, Y = np.meshgrid(X, Y)
C = knn.predict(np.vstack([X.ravel(), Y.ravel()]).T).reshape(X.shape)
现在,我们绘制决策边界:
cmap = ListedColormap([(1., 1., 1.), (.2, .2, .2), (.6, .6, .6)])
fig,ax = plt.subplots()
ax.scatter(features[:, 0], features[:, 2], c=target, cmap=cmap)
for lab, ma in zip(range(3), "Do^"):
ax.plot(features[target == lab, 0], features[
target == lab, 2], ma, c=(1., 1., 1.), ms=6)
ax.set_xlim(x0, x1)
ax.set_ylim(y0, y1)
ax.set_xlabel(feature_names[0])
ax.set_ylabel(feature_names[2])
ax.pcolormesh(X, Y, C, cmap=cmap)
结果是这样的:
加拿大的例子显示为钻石,科马种子为圆形,罗莎种子为三角形。它们各自的区域显示为白色、黑色和灰色。你可能想知道为什么这些区域如此水平,几乎奇怪的是。问题是 x 轴(面积)范围从 10 到 22 ,而 y 轴(密实度)范围从 0.75 到 1.0。这意味着 x 的微小变化实际上比 y 的微小变化要大得多。所以,当我们计算点与点之间的距离时,我们在很大程度上只考虑了 x 轴。这也是一个很好的例子,说明为什么可视化我们的数据并寻找危险信号或惊喜是一个好主意。
如果你学过物理(还记得你的课),你可能已经注意到我们一直在总结长度、面积和无量纲量,混合我们的单位(这是你永远不想在物理系统中做的事情)。我们需要将所有特征标准化到一个共同的尺度。这个问题有很多解决办法;一个简单的方法是标准化为 z 分数。一个值的 z 分数是它离平均值有多远,以标准差为单位。归结起来就是这个操作:
f ' = ( f - µ)/σ
该公式中, f 为旧特征值, f' 为归一化特征值,T5】为特征均值, σ 为标准差。和 σ 都是从训练数据中估计出来的。与原始值无关,在 z 评分后,零值对应于训练平均值,正值高于平均值,负值低于平均值。
scikit-learn 模块使得将这种规范化作为预处理步骤非常容易。我们将使用一个转换管道:第一个元素进行转换,第二个元素进行分类。我们首先导入管道和要素缩放类,如下所示:
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
现在,我们可以将它们结合起来:
clf = KNeighborsClassifier(n_neighbors=1)
clf = Pipeline([('norm', StandardScaler()), ('knn', classifier)])
管道构造器获取一个对列表(str,clf)
。每对对应于管道中的一个步骤:第一个元素是命名该步骤的字符串,而第二个元素是执行转换的对象。对象的高级用法使用这些名称来指代不同的步骤。
归一化后,每个特征都以相同的单位表示(技术上,每个特征现在都是无量纲的;它没有单位),我们可以更自信地混合维度。事实上,如果我们现在运行最近邻分类器,我们将获得 86%的准确率,使用前面显示的相同的五倍交叉验证代码进行估计!
再从两个维度来看决策空间:
界限现在不同了,你可以看到两个维度对结果都有影响。在完整的数据集中,一切都发生在一个七维空间,这很难想象,但同样的原理适用;虽然一些维度在原始数据中占主导地位,但在标准化之后,它们都被赋予了相同的重要性。
使用哪个分类器
到目前为止,我们已经研究了两个经典的分类器,即决策树和最近邻分类器。Scikit-learn 支持更多,但它不支持学术文献中提出的所有内容。因此,人们可能会想:我应该用哪一个?了解所有这些有什么重要的吗?
在许多情况下,数据集的知识可以帮助您决定哪个分类器的结构最符合您的问题。然而,曼努埃尔·费尔南德斯-德尔加多和他的同事们有一项非常好的研究,题为我们需要数百个分类器来解决现实世界的分类问题吗?这是一项可读性很强、非常注重实际的研究,作者得出结论,实际上有一个分类器很可能是大多数问题的最佳(或接近最佳)分类器,即随机森林。
什么是随机森林?顾名思义,森林是树木的集合。在这种情况下,决策树的集合。我们如何从单个数据集中获得许多树?如果你试着多次调用我们之前用过的方法,你会发现你每次都会得到完全一样的树。诀窍是用数据集的不同随机变量多次调用该方法。特别是,每次,我们都会获取数据集的一部分和要素的一部分。因此,每次都有不同的树。在分类的时候,所有的树投票,最终决定达成。有许多不同的参数决定所有次要的细节,但只有一个是相关的,即您使用的树的数量。一般来说,构建的树越多,需要的内存就越多,但是分类的准确性也会提高(达到最佳性能的平稳状态)。scikit-learn 中的默认值是 10 棵树。除非数据集非常大,以致内存使用成为问题,否则增加该值通常是有利的:
from sklearn import ensemble
rf = ensemble.RandomForestClassifier(n_estimators=100)
predict = model_selection.cross_val_predict(rf, features, target)
print("RF accuracy: {:.1%}".format(np.mean(predict == target)))
在这个数据集上,结果约为 86%(运行时可能略有不同,因为它们是随机森林)。
随机森林的另一大优势是,由于它们基于决策树,最终它们只基于特征阈值执行二元决策。因此,当特征被放大或缩小时,它们是不变的。
摘要
分类意味着从示例中进行归纳,以构建一个将对象分配给预定义类的模型(即,一个可以自动应用于新的未分类对象的规则)。它是机器学习的基本工具之一,我们将在接下来的章节中看到更多这样的例子。
在某种程度上,这是一个非常抽象和理论化的章节,因为我们用简单的例子介绍了一般的概念。我们对 Iris 数据集进行了一些操作。这是一个小数据集。然而,它有一个优势,那就是我们能够绘制所有的数据,并详细了解我们在做什么。当我们继续研究具有许多维度和成千上万个例子的问题时,这种东西将会丢失。我们在这里获得的见解仍然有效。
您还了解到,训练误差是对模型性能的误导性、过于乐观的估计。相反,我们必须根据未用于培训的测试数据对其进行评估。为了不在测试中浪费太多的例子,交叉验证计划可以让我们两全其美(以更多的计算为代价)。
最后,我们讨论了通常最好的现成分类器,随机森林。使用非常灵活的分类系统很简单(几乎不需要对数据进行预处理),并且在各种各样的问题中获得非常高的性能。
第三章、回归,我们将深入 sci kit-learn——神奇的机器学习工具包——概述不同类型的学习,向您展示特征工程之美。
三、回归
你可能已经在高中数学课上学习了回归。你学的具体方法大概就是所谓的普通最小二乘 ( OLS )回归。这项有 200 年历史的技术计算速度很快,可以用于许多现实世界的问题。本章将从回顾它开始,并向您展示如何在 scikit-learn 中使用它。
然而,对于某些问题,这种方法是不够的。当我们有很多特征时尤其如此,当我们的特征多于数据点时,这种方法就完全失败了。在这种情况下,我们需要更先进的方法。这些方法非常现代,在过去的 20 年里有了重大发展。它们被命名为套索、脊或弹性绳。我们将详细讨论这些问题。它们也在 scikit-learn 中提供。在本章中,我们将学习以下内容:
- 如何在 scikit-learn 中使用不同形式的线性回归
- 适当的交叉验证的重要性,特别是当我们有很多特性时
- 何时以及如何使用两层交叉验证来设置超参数
回归预测房价
让我们从一个简单的问题开始,即预测波士顿的房价。问题如下:我们被赋予几个人口和地理属性,例如犯罪率或附近的师生比。目标是预测一个特定地区的房子的中值。与分类的情况一样,我们有一些训练数据,希望构建一个可以推广到其他数据的模型。
这是 scikit-learn 附带的内置数据集之一,因此很容易将数据加载到内存中:
from sklearn.datasets import load_boston
boston = load_boston()
boston
对象包含多个属性;具体来说,boston.data
包含输入数据,boston.target
包含以千美元为单位的房价。
我们将从一个简单的一维回归开始,试图在一个单一的属性上回归价格:附近每个住宅的平均房间数。我们可以使用你可能在高中时第一次看到的标准最小二乘回归方法。
我们的第一次尝试是这样的:
from sklearn.linear_model import LinearRegression
lr = LinearRegression()
我们从sklearn.linear_model
模块导入LinearRegression
并构建一个LinearRegression
对象。这个对象的行为类似于我们之前使用的 scikit-learn 中的分类器对象。
我们将使用的功能存储在位置5
。目前,我们只使用一个功能;在本章的后面,我们将使用它们。有关数据的详细信息,您可以咨询boston.DESCR
和boston.feature_names
。boston.target
属性包含平均房价(我们的目标变量):
# Feature 5 is the number of rooms.
x = boston.data[:,5]
y = boston.target
x = np.transpose(np.atleast_2d(x))
lr.fit(x, y)
这个代码块中唯一不明显的一行是对np.atleast_2d
的调用,它将x
从一维数组转换为二维数组。这种转换是必要的,因为fit
方法期望二维数组作为其第一个参数。最后,为了正确计算维度,我们需要转置这个数组。
请注意,我们正在对LinearRegression
对象调用名为fit
和predict
的方法,就像我们对分类器对象所做的那样,尽管我们现在正在执行回归。API 中的这种规律性是 scikit-learn 的良好特性之一。
我们可以很容易地绘制出fit
:
from matplotlib import pyplot as plt
fig,ax = plt.subplots()
ax.scatter(x, y)
xmin = x.min()
xmax = x.max()
ax.plot([xmin, xmax],
[lr.predict(xmin), lr.predict(xmax)],
'-', lw=2, color="#f9a602")
ax.set_xlabel("Average number of rooms (RM)")
ax.set_ylabel("House Price")
参考下图:
上图显示了所有的点(如点)和我们的拟合(实线)。我们可以看到,视觉上看起来不错,除了一些异常值。
然而,理想情况下,我们希望从数量上衡量这种匹配的好坏。为了能够比较替代方法,这一点至关重要。为此,我们可以测量我们的预测与真实值的接近程度。对于该任务,我们可以使用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): 43.6
这个值有时可能很难解释,最好取平方根,得到均方根误差 ( RMSE ):
rmse = np.sqrt(mse)
print("RMSE (of training data): {:.3}".format(rmse))
RMSE (of training data): 6.6
使用RMSE
的一个优点是,我们可以通过将其乘以 2 来快速获得非常粗略的误差估计。在我们的情况下,我们可以预计估计价格与实际价格最多相差 13,000 美元。
Root mean squared error and prediction:
Root mean squared error corresponds approximately to an estimate of the standard deviation. Since most data is at most two standard deviations from the mean, we can double our RMSE
to obtain a rough confident interval. This is only completely valid if the errors are normally distributed, but it is often roughly correct even if they are not.
像6.6
这样的数字,没有领域知识还是很难马上理解。这是一个好的预测吗?回答这个问题的一个可能方法是将其与最简单的基线,常数模型进行比较。如果我们对输入一无所知,我们能做的最好的事情就是预测输出永远是y
的平均值。然后,我们可以将该模型的均方误差与零模型的均方误差进行比较。这一思想在决定系数中被形式化,定义如下:
在这个公式中, y i 用指数 i 表示元素的值,而是通过回归模型得到的同一元素的估计值。最后,
是 y 的平均值,代表总是返回相同值的空模型。这与首先计算均方误差与输出方差的比值,最后考虑 1 减去该比值大致相同。这样,完美模型的得分为 1,而空模型的得分为零。请注意,有可能获得一个负的分数,这意味着模型是如此之差,以至于使用平均值作为预测会更好。
使用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.48
这一衡量标准也被称为r2
分数。如果您正在使用线性回归并评估训练数据的误差,那么它确实对应于相关系数 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)
fig,ax = plt.subplots()
ax.scatter(p, y)
ax.xlabel('Predicted price')
ax.ylabel('Actual price')
ax.plot([y.min(), y.max()], [[y.min()], [y.max()]], ':')
最后一条线画了一条对角线,对应于完全一致;一个没有误差的模型意味着所有的点都位于这条对角线上。这有助于可视化:
回归的交叉验证
当我们引入分类时,我们强调了交叉验证对于检查预测质量的重要性。执行回归时,并不总是这样做。事实上,到目前为止,我们只讨论了本章中的训练错误。
如果你想自信地推断概括能力,这是一个错误。然而,由于普通最小二乘是一个非常简单的模型,这通常不是一个非常严重的错误。换句话说,过度拟合的量很小。我们仍然应该根据经验来测试这一点,这可以通过 scikit-learn 轻松实现。
我们将使用Kfold
类构建一个五重交叉验证循环,测试线性回归的泛化能力:
from sklearn.model_selection import KFold, cross_val_predict
kf = KFold(n_splits=5)
p = cross_val_predict(lr, x, y, cv=kf)
rmse_cv = np.sqrt(mean_squared_error(p, y))
print('RMSE on 5-fold CV: {:.2}'.format(rmse_cv))
RMSE on 5-fold CV: 6.1
通过交叉验证,我们得到了更保守的估计(即误差更大):6.1
。就像在分类的情况下一样,交叉验证估计是一个更好的估计,表明我们可以在看不见的数据上进行预测。
普通最小二乘法学习时间快,返回简单模型,预测时间快。然而,我们现在将看到更先进的方法,以及为什么它们有时更可取。
惩罚或正则化回归
本节介绍惩罚回归,也称为正则化或惩罚回归,一类重要的回归模型。
在普通回归中,返回的拟合是训练数据上的最佳拟合。这会导致过度装配。惩罚意味着我们对参数值的过度自信进行惩罚。因此,为了有一个更简单的模型,我们接受稍微差一点的拟合。
另一种思考方式是认为默认情况下输入变量和输出预测之间没有关系。当我们有数据时,我们会改变这种观点,但增加一个惩罚意味着我们需要更多的数据来说服我们这是一种强有力的关系。
Penalized regression is about trade-offs:
Penalized regression is another example of the bias-variance trade-off. When using a penalty, we get a worse fit in the training data, as we are adding bias. On the other hand, we reduce the variance and tend to avoid over-fitting. Therefore, the overall result might generalize better to unseen (test) data.
L1 和 L2 的点球
我们现在详细探讨这些想法。不关心某些数学方面的读者可以直接跳到下一节,学习如何在 scikit-learn 中使用正则回归。
总的来说,问题是我们得到了训练数据的矩阵 X (行是观察值,每列是不同的特征),以及输出值的向量 y 。目标是获得一个权重向量,我们称之为 b* 。普通最小二乘回归由以下公式给出:
也就是说,我们找到向量 b ,它最小化到目标的平方距离 y 。在这些方程中,我们忽略了设置截距的问题,假设训练数据已经过预处理,因此 y 的平均值为零。
增加惩罚或正则化意味着我们不仅考虑训练数据的最佳拟合,还考虑向量是如何组成的。有两种类型的处罚通常用于回归:L1 和 L2 处罚。L1 惩罚意味着我们用系数绝对值的和来惩罚回归,而 L2 惩罚是用平方和来惩罚。
当我们添加一个 L1 惩罚,而不是前面的等式,我们改为优化以下内容:
这里,我们试图同时使误差变小,但也使系数的值变小(以绝对值计)。使用 L2 惩罚意味着我们使用以下公式:
区别相当微妙:我们现在用系数的平方而不是绝对值来惩罚。然而,结果的差异是巨大的。
Ridge, Lasso, and ElasticNets:
These penalized models often go by rather interesting names. The L1 penalized model is often called the Lasso, while an L2 penalized one is known as Ridge regression. When using both, we call this an ElasticNet
model.
套索和脊线导致的系数都小于未启用的回归(绝对值较小,忽略符号)。然而,套索还有一个额外的属性:它导致许多系数被设置为零!这意味着最终模型甚至不使用它的一些输入特征;模型为稀疏。这通常是一个非常理想的属性,因为模型在单个步骤中执行特征选择和 r 分离。
你会注意到,每当我们增加一个惩罚时,我们也会增加一个权重 α ,它决定了我们想要多少惩罚。当 α 接近于零时,我们非常接近于未未化回归(其实如果你把 α 设置为零,你就简单的执行 OLS),而当 α 较大时,我们有一个和未化非常不一样的模型。
山脊模型比较古老,因为套索很难用纸和笔计算。然而,有了现代计算机,我们可以像使用 Ridge 一样轻松地使用 Lasso,甚至可以将它们组合起来形成松紧带。一ElasticNet
有两个惩罚,一个是绝对值惩罚,一个是平方惩罚,它求解如下方程:
这个公式是前面两个公式的组合,有两个参数,α1T3α2T7】。在本章的后面,我们将讨论如何为参数选择一个好的值。**
在 scikit-learn 中使用套索或弹性网
让我们修改前面的例子来使用弹性线。使用 scikit-learn,很容易将ElasticNet
回归器替换为我们之前的最小二乘回归器:
from sklearn.linear_model import Lasso
las = Lasso(alpha=0.5)
现在我们使用las
,而之前我们使用lr
。这是唯一需要的改变。结果正是我们所期望的。使用Lasso
时,训练数据上的R2
减少到0.71
(之前是0.74
,但是交叉验证拟合现在是0.59
(与线性回归的0.56
相反)。为了获得更好的泛化能力,我们对训练数据进行了较大误差的处理。
可视化套索路径
使用 scikit-learn,我们可以轻松地可视化正则化参数(alphas
)的值发生变化时会发生什么。我们将再次使用波士顿数据,但现在我们将使用Lasso regression
对象:
las = Lasso()
alphas = np.logspace(-5, 2, 1000)
alphas, coefs, _= las.path(x, y,
alphas=alphas)
对于 alphas 中的每个值,Lasso
对象上的path
方法返回用该参数值解决Lasso
问题的系数。因为结果随 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())
这导致了下面的图(我们省略了添加轴标签和标题的简单代码):
在该图中, x 轴显示从左到右正则化的减少量(α正在减少)。每一行显示不同的系数如何随着α的变化而变化。该图显示,当使用非常强的正则化(左侧,非常高的α)时,最佳解决方案是使所有值都恰好为零。随着正则化变弱,一个接一个地,不同系数的值首先上升,然后稳定。在某个时候,它们都趋于平稳,因为我们可能已经接近未实现的解决方案。
p 大于 N 的场景
这一节的标题有点行话,你现在就要学会。在 20 世纪 90 年代,首先是在生物医学领域,然后是在网络上,问题开始出现在 P 大于 N 的地方。这意味着特征的数量 P 大于例子的数量 N(这些字母是这些概念的常规统计简写)。
例如,如果您的输入是一组书面文档,一种简单的方法是将字典中每个可能的单词视为一个特征,并在这些特征上进行回归(我们将在后面自己解决一个这样的问题)。在英语中,你有超过 20,000 个单词(这是如果你执行一些词干,只考虑普通单词;如果您跳过这个预处理步骤,它会增加十倍以上)。如果你只有几百个或者几千个例子,你会比例子有更多的特点。
在这种情况下,由于特征的数量大于示例的数量,所以有可能在训练数据上具有完美的拟合。这是一个数学事实,与你的数据无关。实际上,你在解一个线性方程组,方程比变量少。你可以找到一组训练误差为零的回归系数(事实上,你可以找到不止一个完美的解)。
然而,这是一个主要问题,零训练误差并不意味着你的解决方案会很好地推广。事实上,它可能概括得很差。在前面的例子中,正则化可以给你一点额外的提升,但是现在它绝对是一个有意义的结果所必需的。
基于文本文档的示例
我们现在来看一个来自卡耐基梅隆大学诺亚·史密斯教授研究小组的研究。这项研究是基于对美国公司向美国证券交易委员会提交的所谓 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.0024
print('R2 on training: {:.2}'.format(r2_score(target, pred)))
R2 on training: 1.0
由于舍入误差,均方根误差不完全为零,但非常接近。决定系数为1.0
。也就是说,线性模型报告了对其训练数据的完美预测。这是我们所期待的。
然而,当我们使用交叉验证时(代码与我们之前在 Boston 示例中使用的非常相似),我们得到了非常不同的东西:0.75
的RMSE
,这对应于-0.42
的负确定系数!这意味着如果我们总是预测-3.5
的平均值,我们会比使用回归模型时做得更好!这是典型的 P 大于 N 的情况。
Training and generalization error:
When the number of features is greater than the number of examples, you always get zero training errors with OLS, except perhaps for issues due to rounding off. However, this is rarely a sign that your model will do well in terms of generalization. In fact, you may get zero training errors and have a completely useless model.
自然的解决方法是使用正则化来抵消过拟合。将惩罚参数设置为0.1
后,我们可以尝试与ElasticNet
学习者进行相同的交叉验证:
from sklearn.linear_model import ElasticNet
met = ElasticNet(alpha=0.1)
met.fit(data, target)
pred = met.predict(data)
print('[EN 0.1] RMSE on training: {:.2}'.format(np.sqrt(mean_squared_error(target, pred))))
[EN 0.1] RMSE on training: 0.4
print('[EN 0.1] R2 on training: {:.2}'.format(r2_score(target, pred)))
[EN 0.1] R2 on training: 0.61
因此,我们在训练数据上得到更差的结果。然而,我们更希望这些结果能很好地概括:
kf = Kfold(n_splits=5)
pred = cross_val_predict(met, data, target, cv=kf)
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
print('[EN 0.1] R2 on testing (5 fold): {:.2}'.format(r2_score(target, pred)))
[EN 0.1] R2 on testing (5 fold): 0.61
确实如此!与 OLS 的情况不同,使用ElasticNet
,交叉验证的结果与训练数据相同。
然而,这个解决方案有一个问题,那就是阿尔法的选择。当使用默认值(1.0
)时,结果会大不相同(甚至更糟)。
在这种情况下,我们作弊,因为作者之前尝试了一些值,看看哪些值会给出好的结果。这是无效的,并且会导致对信心的高估(我们正在查看测试数据,以决定使用哪些参数值,以及我们永远不应该使用哪些参数值)。下一节将解释如何正确地做到这一点,以及 scikit-learn 如何支持这一点。
以有原则的方式设置超参数
在前面的例子中,我们将惩罚参数设置为0.1
。我们也可以将它设置为0.7
或23.9
。自然,结果每次都会有所不同。如果我们选择一个过大的值,我们就会得到不足。在极端情况下,学习系统将只返回每个等于零的系数。如果我们选择一个太小的值,我们就非常接近 OLS,这个值会过度拟合并且泛化能力很差(正如我们之前看到的)。
我们如何选择一个好的价值?这是机器学习中的一个普遍问题:为我们的学习方法设置参数。一个通用的解决方案是使用交叉验证。我们挑选一组可能的值,然后使用交叉验证来选择哪一个是最好的。这执行更多的计算(如果我们使用五倍,则执行五倍以上),但总是适用且无偏见的。
不过,我们必须小心。为了获得泛化的无偏估计,必须使用两级交叉验证:顶层是估计系统的泛化能力,而第二层是得到好的参数。也就是说,我们将数据分成例如五份。我们从第一个折叠开始,然后学习其他四个折叠。现在,为了选择参数,我们再次将它们分成五个折叠。一旦我们设置了参数,我们就在第一次折叠时进行测试。现在我们再重复四次:
上图显示了如何将单个训练文件夹分解为子文件夹。我们需要对所有其他折叠重复这个过程。在这种情况下,我们看到的是五个外折叠和五个内折叠,但没有理由使用相同数量的外折叠和内折叠;你可以使用任何你想要的号码,只要你保持折叠分开。
这导致了大量的计算,但为了正确地做事,这是必要的。问题是,如果您使用任何数据点来对您的模型做出任何决定(包括设置哪些参数),您就不能再使用相同的数据点来测试您的模型的泛化能力。这是一个微妙的点,可能不会立即显而易见。事实上,仍然有很多机器学习的用户误解了这一点,高估了他们的系统做得有多好,因为他们没有正确地执行交叉验证!
幸运的是,scikit-learn 使做正确的事情变得非常容易;它提供了名为LassoCV
、RidgeCV
和ElasticNetCV
的类,所有这些类都封装了一个内部交叉验证循环来优化必要的参数(因此在类名的末尾有字母CV
)。除了我们不需要为 alpha 指定任何值之外,代码几乎与前面的代码完全相同:
from sklearn.linear_model import ElasticNetCV
met = ElasticNetCV()
kf = KFold(n_splits=5)
p = cross_val_predict(met, data, target, cv=kf)
r2_cv = r2_score(target, p)
print("R2 ElasticNetCV: {:.2}".format(r2_cv))
R2 ElasticNetCV: 0.65
这会导致大量的计算,因此,根据您的计算机的速度,您可能希望在等待时冲泡一些咖啡或茶。通过利用多个处理器,您可以获得更好的性能。这是 scikit-learn 的一个内置特性,通过使用ElasticNetCV
构造函数的n_jobs
参数可以非常简单地访问它。要使用四个中央处理器,请使用以下代码:
met = ElasticNetCV(n_jobs=4)
将n_jobs
参数设置为-1
以使用所有可用的 CPU:
met = ElasticNetCV(n_jobs=-1)
你可能想知道为什么,如果弹性有两个惩罚——L1 和 L2 惩罚——我们只需要为阿尔法设置一个值。事实上,这两个值是通过分别指定 alpha 和l1_ratio
变量来指定的。然后α1T5αT8】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
时)。因此,我们探索了各种不同的选择。
Because of its flexibility and its ability to use multiple CPUs, ElasticNetCV
is an excellent default solution for regression problems when you don't have any particular reason to prefer one type of model over the rest. In very simple problems, use ordinary least squares.
综上所述,我们现在可以在这个大数据集上可视化预测与实际拟合:
l1_ratio = [.01, .05, .25, .5, .75, .95, .99]
met = ElasticNetCV(l1_ratio=l1_ratio, n_jobs=-1)
pred = cross_val_predict(met, data, target, cv=kf)
fig, ax = plt.subplots()
ax.scatter(pred, y)
ax.plot([pred.min(), pred.max()], [pred.min(), pred.max()])
这将产生以下图:
我们可以看到,在数值范围的底端,预测不太匹配。这可能是因为目标范围的这一端的元素少了很多(这只会影响一小部分数据点)。
用张量流回归
我们将在未来的章节中深入讨论张量流,但是正则化线性回归可以用它来实现,所以了解张量流的工作原理是个好主意。
Details on how TensorFlow is structured will be tackled in Chapter 8, Artificial Neural Networks and Deep Learning. Some of its scaffolding may seem odd, and there will be lots of magic numbers. Still, we will progressively use more of it for some small examples.
让我们尝试在这个实验中使用波士顿数据集。
import tensorflow as tf
TensorFlow 要求您为它处理的所有元素创建符号。这些可以是变量或占位符。前者是张量流将改变的符号,而占位符是张量流从外部强加的。
对于回归,我们需要两个占位符,一个用于输入特征,一个用于我们想要匹配的输出。我们还需要两个变量,一个是斜率,一个是截距。与线性回归相反,我们必须为相同的功能编写更多的代码:
X = tf.placeholder(shape=[None, 1], dtype=tf.float32, name="X")
Y = tf.placeholder(shape=[None, 1], dtype=tf.float32, name="y")
A = tf.Variable(tf.random_normal(shape=[1, 1]), name="A")
b = tf.Variable(tf.random_normal(shape=[1, 1]), name="b")
这两个占位符的形状为[None, 1]
。这意味着它们沿着一个轴具有动态大小,并且在最快的维度上具有 1 的大小(就存储器布局而言)。这两个变量是完全静态的,维度为[1, 1]
,表示单个元素。它们都将由张量流按照随机变量(均值为0
方差为1
的高斯)初始化。
符号的类型可以通过使用dtype
来设置,或者对于变量,可以从initial_value
的类型来推断。在本例中,它将始终是一个浮点值。
All symbols can have a name and many TensorFlow functions take a name
argument. It is good practice to give clear names, as TensorFlow errors will display them. If they are not set, TensorFlow will create new default names that can be difficult to decipher.
现在所有的符号都创建好了,我们现在可以创建loss
函数了。我们首先创建预测,然后将它与地面真实值进行比较:
model_output = tf.matmul(X, A) + b
loss = tf.reduce_mean(tf.square(Y - model_output))
预测的乘法好像转置了,这是由于X
的定义方式:确实转置了!这允许model_output
具有动态的第一维度。
我们现在可以用梯度下降最小化这个cost
函数。首先,我们创建张量流对象:
grad_step = 5e-7
my_opt = tf.train.GradientDescentOptimizer(grad_step)
train_step = my_opt.minimize(loss)
The gradient step is a crucial aspect of all TensorFlow objects. We will explore this further later; the important aspect is to know that this step depends on the data and the cost
function used. There are other optimizers available in TensorFlow; gradient descent is the simplest and one of the most adapted to this case.
我们还需要一些变量:
batch_size = 50
n_epochs = 20000
steps = 100
批量表示我们一次要计算多少元素的损失。这也是占位符输入数据的维度,也是我们在优化过程中预测的输出维度。
Epochs 是我们遍历所有训练数据以优化模型的次数。最后,步骤只是我们显示我们优化的loss
功能信息的频率。
现在我们可以进行最后一步,让 TensorFlow 释放我们拥有的函数和数据:
loss_vec = []
with tf.Session() as sess:
sess.run(tf.global_variables_initializer())
for epoch in range(n_epochs):
permut = np.random.permutation(len(x))
for j in range(0, len(x), batch_size):
batch = permut[j:j+batch_size]
Xs = x[batch]
Ys = y[batch]
sess.run(train_step, feed_dict={X: Xs, Y: Ys})
temp_loss = sess.run(loss, feed_dict={X: x, Y: y})
loss_vec.append(temp_loss)
if epoch % steps == 0:
(A_, b_) = sess.run([A, b])
print('Epoch #%i A = %s b = %s' % (epoch, np.transpose(A_), b_))
print('Loss = %.8f' % temp_loss)
print("")
prediction = sess.run(model_output, feed_dict={X: trX, Y: trY})
mse = mean_squared_error(y, prediction)
print("Mean squared error (on training data): {:.3}".format(mse))
rmse = np.sqrt(mse)
print('RMSE (on training data): %f' % rmse)
r2 = r2_score(y, prediction)
print("R2 (on training data): %.2f" % r2)
我们首先创建一个张量流会话。这将使我们能够在调用sess.run
时使用这些符号。第一个参数是要调用的函数或要调用的函数列表(它们的结果将是这个调用的返回),我们必须传递一个字典,feed_dict
。该字典将占位符映射到实际数据,因此维度必须匹配。
会话中的第一个调用根据我们在声明变量时指定的内容初始化所有变量。然后我们有两个循环,一个关于时代,一个关于批量。
对于每个时期,我们定义训练数据的排列。这使数据的顺序随机化。这一点很重要,尤其是对于神经网络来说,这样他们就不会有偏见,所以他们可以一致地学习所有的数据。如果批次大小等于训练数据的大小,那么我们就不需要随机化数据,当我们只有少量数据样本时,通常就是这种情况。对于大型数据集,我们必须使用批处理。每批都将在train_step
功能中输入,变量将被优化。
在每个时期之后,我们保存所有训练数据的损失,用于显示目的。我们还每隔几个时期显示变量的状态,以监控和检查优化的状态。
最后,我们用我们的模型显示预测输出的均方误差以及r2
分数。
当然,这个loss
函数的解是解析已知的,所以我们修改一下:
beta = 0.005
regularizer = tf.nn.l2_loss(A)
loss = loss + beta * regularizer
然后,让我们运行完整的优化,以获得拉索的结果。我们可以看到 TensorFlow 在那里并没有真正发光。它非常慢,需要非常多的迭代才能得到与 scikit-learn 所能得到的结果相差甚远的结果。
让我们看一下仅针对此数据集使用要素 5 时运行的一小部分:
Epoch #9400 A = [[ 8.60801601]] b = [[-31.74242401]]
Loss = 43.75216293
Epoch #9500 A = [[ 8.57831573]] b = [[-31.81438446]]
Loss = 43.92549133
Epoch #9600 A = [[ 8.67326164]] b = [[-31.88376808]]
Loss = 43.69957733
Epoch #9700 A = [[ 8.75835037]] b = [[-31.94364548]]
Loss = 43.97978973
Epoch #9800 A = [[ 8.70185089]] b = [[-32.03764343]]
Loss = 43.69329453
Epoch #9900 A = [[ 8.66107273]] b = [[-32.10965347]]
Loss = 43.74081802
Mean squared error (on training data): 1.17e+02
RMSE (on training data): 10.8221888258
R2 (on training data): -0.39
以下是loss
函数的行为:
以下是仅使用第五个功能时的结果:
摘要
在这一章中,我们从书中最古老的技巧开始:普通最小二乘回归。尽管已经有几个世纪的历史,但有时它仍然是回归的最佳解决方案。然而,我们也看到了更现代的方法,避免过度拟合,并能给我们更好的结果,尤其是当我们有大量的功能时。我们使用了脊,套索和弹性线;这些是最先进的回归方法。
我们再次看到了依赖训练误差来估计泛化的危险:它可能是一个过于乐观的估计,以至于我们的模型没有训练误差,但我们知道它是完全无用的。当思考这些问题时,我们被引导到两级交叉验证,这是该领域许多人尚未完全内化的一个重要领域。
在本章中,我们能够依靠 scikit-learn 来支持我们想要执行的所有操作,包括实现正确交叉验证的简单方法。带有用于参数优化的内部交叉验证循环(如 scikit-learn by ElasticNetCV
中所实现的)的 ElasticNets 可能会成为您回归的默认方法。我们还看到了 TensorFlow 在回归中的使用(因此该包不限于神经网络计算)。
使用替代方案的一个原因是当您对稀疏解决方案感兴趣时。在这种情况下,纯套索解决方案更合适,因为它会将许多系数设置为零。它还允许您从数据中发现少量变量,这些变量对输出很重要。除了拥有一个好的回归模型之外,了解这些的身份本身可能也很有趣。
第 4 章分类 I–检测不良答案,查看当您的数据没有预定义的分类类别时如何继续。
四、分类一——检测不良答案
问答网站所有者面临的一个持续挑战是保持发布内容的质量水平。StackOverflow 等网站做出了相当大的努力来鼓励具有不同可能性的用户对内容进行评分,并提供徽章和奖励积分,以便将更多的精力花在构思问题或设计可能的答案上。
一个特别成功的激励是提问者能够将他们问题的一个答案标记为被接受的答案(有激励让提问者这样标记答案)。这将导致标记答案的作者获得更高的分数。
当用户输入答案时,立即看到他们的答案有多好不是很有用吗?这意味着该网站将持续评估用户正在进行的回答,并提供关于该回答是否有改进空间的反馈。这将鼓励用户投入更多的精力来编写答案(例如提供一个代码示例,甚至包括一个图像),从而改进整个系统。
让我们建立这样的机制!在本章中,我们将涵盖以下主题:
- 获取和预处理原始数据
- 创建第一最近邻分类器
- 研究如何提高分类器的性能
- 从最近邻回归到逻辑回归
- 学习精度和召回率,以更好地理解分类器的性能
- 思考运输它的必要步骤
绘制我们的路线图
因为我们将使用非常嘈杂的真实数据构建一个系统,所以这一章不适合胆小的人,因为我们不会得到达到 100%准确率的分类器的黄金解;通常,即使是人类也不同意一个答案是好是坏(看看 StackOverflow 的一些评论就知道了)。我们会发现,有些挑战,比如本章中的挑战,是如此的艰难,以至于我们不得不一路调整我们最初的目标。我们将从前几章中学习的最近邻方法开始,找出为什么它对本章中的任务不是很好,切换到逻辑回归,并得出一个解决方案,该解决方案将获得足够好的预测质量,但依赖于较小部分的答案。最后,我们将花一些时间来研究如何提取获胜者,并将其部署到目标系统上。
学习分类经典答案
在分类中,我们希望为给定的数据实例找到相应的类,有时也称为标签。为了实现这一点,我们需要回答两个问题:
- 我们应该如何表示数据实例?
- 我们的分类器应该具备哪种模型或结构?
调整实例
最简单的形式是,在我们的例子中,数据实例是答案文本本身,标签是一个二进制值,指示询问者是否接受这个文本作为答案。然而,对于大多数机器学习算法来说,原始文本是非常不方便处理的表示。他们想要数字。我们的任务是从原始文本中提取有用的特征,然后机器学习算法可以使用这些特征来学习合适的标签。
调整分类器
一旦我们找到或收集了足够多的(文本、标签)对,我们就可以训练一个分类器。对于分类器的底层结构,我们有各种各样的可能性,每种可能性都有优点和缺点。仅举一些比较突出的选择,有逻辑回归、决策树、支持向量机和朴素贝叶斯。在本章中,我们将对比基于实例的方法从第 2 章、分类与真实世界的例子,最近邻,与基于模型的逻辑回归。
正在获取数据
对我们来说幸运的是,StackOverflow 背后的团队提供了 StackExchange 宇宙背后的大部分数据,StackOverflow 根据cc-by-sa
许可证属于该宇宙。在写这本书的时候,最新的数据转储可以在https://archive.org/download/stackexchange找到。它包含 StackExchange 家族中所有 Q & A 站点的数据转储。对于 StackOverflow,你会发现多个文件,其中我们只需要stackoverflow.com-Posts.7z
文件,11.3 GB。
下载并提取后,我们有大约 59 GB 的 XML 格式的数据,在根标签帖子中包含所有问题和答案作为单独的行标签:
<?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="<p>IANAL, but <a href="http://support.apple.com/kb/HT2931" rel="nofollow">this</a> indicates to me that you cannot use the loops in your application:</p><blockquote><p>...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."</p><p>So don't worry, you can make
commercial music with GarageBand, you just can't distribute the loops as loops.</p> </blockquote> " OwnerUserId="203568" LastActivityDate="2011-01-01T00:01:03.387" CommentCount="1" />
…
</posts>
请参考下表:
名称 | 类型 | 描述 |
---|---|---|
Id |
整数 | 这是帖子的唯一标识符。 |
PostTypeId |
整数 | 这描述了帖子的类别。我们感兴趣的价值观如下: |
- 1:问题
- 2:回答
其他值将被忽略。 |
| ParentId
| 整数 | 这是此答案所属问题的唯一标识符。问题缺少,这种情况下我们设置为-1
。 |
| CreationDate
| 日期时间 | 这是提交日期。 |
| Score
| 整数 | 这是帖子的分数。 |
| ViewCount
| 整数或空 | 这是这篇文章的用户浏览量。 |
| Body
| 线 | 这是以编码的超文本标记语言文本形式发布的完整帖子。 |
| OwnerUserId
| 身份 | 这是海报的唯一标识符。如果1
,那么就是维基问题。 |
| Title
| 线 | 这是问题的标题(缺少答案)。 |
| AcceptedAnswerId
| 身份 | 这是已接受答案的标识(缺少答案)。 |
| CommentCount
| 整数 | 这是帖子的评论数。 |
Normally, we try to stick to the Python style guides for variable naming. In this chapter, we will use the names in the XML fomat so they are easier to follow. For example, we will have ParentId
instead of parent_id
.
将数据精简为可咀嚼的块
我们将需要训练许多变体,直到我们到达最终的分类器。鉴于目前的数据,我们将被以下因素大大拖慢:
- Post-it 存储属性,这可能是我们不需要的。
- 它存储为 XML,这不是解析速度最快的格式。
- 转储包含可追溯到 2011 年的帖子。仅限于 2017 年,我们最终仍将有超过 600 万个职位,这应该足够了。
预选和处理属性
我们当然可以去掉那些我们认为无助于分类器区分好答案和不好答案的属性。但我们在这里必须谨慎。虽然有些功能不会直接影响分类,但仍有必要保留:
- 例如
PostTypeId
属性是区分问题和答案所必需的。它不会被挑选作为一个功能,但我们需要它来过滤数据。 CreationDate
确定发布问题和发布单个答案之间的时间跨度可能很有意思。然而,在本章中,我们将忽略它。- 分数是社区评价的重要指标。
ViewCount
相比之下,很可能对我们的任务没有任何用处。即使它有助于分类器区分好与坏,我们在提交答案时也不会有这些信息。所以我们忽略它。Body
属性显然包含了最重要的信息。由于它是 HTML 编码的,我们将不得不将其解码为纯文本。OwnerUserId
只有当我们考虑到依赖于用户的特性时才是有用的,而我们不会这样做。- 这里也忽略了 Title 属性,尽管它可以添加一些关于该问题的更多信息。
CommentCount
也是忽略。类似于ViewCount
,它可以帮助分类器处理暂时存在的帖子(更多评论=更模糊的帖子?).然而,在发布答案时,这对分类器没有帮助。AcceptedAnswerId
类似于 Score,是一个帖子质量的指标。然而,随着时间的推移,这是一个可能会过时的信号。想象一下,一个用户发布了一个问题,收到了几个答案,将其中一个标记为已接受,然后忘记了它。多年后,更多的用户已经阅读了问题,也将已经阅读了答案,其中一些答案在提问者接受答案时并不存在。所以结果可能是得分最高的答案不是被接受的答案。既然已经有分数了,就忽略录取信息。
可以说,为了加快处理速度,我们将使用lxml
模块解析 XML 文件,然后输出两个文件。在一个文件中,我们将存储一个字典,该字典将帖子的 Id 值映射到它的其他数据,除了 JSON 格式的 Text,这样我们就可以很容易地读取它,并将其保存在元字典的内存中。例如,帖子的分数位于meta[post_id]['Score']
。我们将对本章中创建的新功能进行同样的操作。
然后,我们将实际的帖子存储在另一个以制表符分隔的文件中,其中第一列是 Id,第二列是 Text,我们可以通过以下方法轻松地读取:
def fetch_posts(fn):
for line in open(fn, "r"):
post_id, text = line.split("\t")
yield int(post_id), text.strip()
我们将这两个文件称为:
>>> import os
>>> fn_sample = os.path.join('data', "sample.tsv")
>>> fn_sample_meta = os.path.join('data', "sample-meta.json")
为了简洁起见,请查看 Jupyter 笔记本上的代码。
定义什么是好的答案
在我们可以训练一个分类器来区分好的和坏的答案之前,我们必须创建训练数据。到目前为止,我们只有一堆数据。我们仍然需要定义标签。
当然,我们可以简单地把每个问题最好和最差的答案作为正面和负面的例子。然而,我们如何处理只有好答案的问题,比如,一个有两个,另一个有四个点?难道我们真的应该因为恰好是分数较低的答案,就把两分的答案当成反面教材吗?或者假设我们只有两个否定的答案,一个得分为-2
,另一个得分为-4
。显然,我们不能以-2
的回答为正面例子。
因此,我们将寻找至少有一个得分高于0
的答案和至少一个得分为负的答案,并扔掉那些不符合这一标准的答案。如果我们获取所有剩余的数据,我们将不得不在每一步等待相当长的时间,因此我们进一步筛选到 10,000 个问题。从这些答案中,我们将选择得分最高的答案作为肯定的例子,得分最低的答案作为否定的例子,这导致我们的训练集有 20,000 个答案。
如前所述,在本章中(以及在 Jupyter 笔记本中),我们将维护一个元字典,它将答案 id 映射到特征,其中得分为 1(我们将在此过程中设计更多的特征)。因此,我们可以创建如下标签:
>>> all_answers = [a for a,v in meta.items() if v['ParentId']!=-1]
>>> Y = np.asarray([meta[aid]['Score'] > 0 for aid in all_answers])
>>> print(np.unique(Y, return_counts=True))
(array([False, True], dtype=bool), array([10000, 10000], dtype=int64))
创建我们的第一个分类器
先从第二章、用真实例子分类简单美观的最近邻法说起。虽然它不如其他方法先进,但它非常强大:因为它不是基于模型的,所以它可以学习几乎任何数据。但是这种美有一个明显的缺点,我们很快就会发现(正因为如此,我们不得不将前一句中的 learn 大写)。
设计特征
如前所述,我们将使用文本和分数特征来训练我们的分类器。文本的问题是分类器不能很好地处理字符串。我们必须把它转换成一个或多个数字。那么,从帖子中提取哪些统计数据可能有用呢?让我们从 HTML 链接的数量开始,假设好的帖子中有链接的几率更高。
我们可以用正则表达式做到这一点。下面捕获所有以http://
开头的 HTML 链接标签(暂时忽略其他协议):
import re
link_match = re.compile('<a href="http://.*?".*?>(.*?)</a>',
re.MULTILINE | re.DOTALL)
但是,我们不想计算属于代码块一部分的链接。例如,如果一篇文章解释了requests Python
模块的用法,它很可能也包含 URL。这意味着我们必须遍历所有的代码块,计算其中的链接数,然后从链接总数中减去它们。这可以通过另一个匹配<pre>
标记的正则表达式来实现,该标记在 StackExchange 站点上用于标记代码:
code_match = re.compile('<pre>(.*?)</pre>',
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
For production systems, we would not want to parse HTML content with regular expressions. Instead, we should rely on excellent libraries such as BeautifulSoup
, which does a marvelous job of robustly handling all the weird things that typically occur in everyday HTML.
有了这些,我们可以为每个答案生成一个特征,并将其存储在 meta 中。但是在我们训练分类器之前,让我们看看我们将用什么来训练它。我们可以通过新功能的频率分布获得第一印象。这可以通过绘制数据中每个值出现频率的百分比来实现:
import matplotlib.pyplot as plt
X = np.asarray([[meta[aid]['LinkCount']] for aid in all_answers])
plt.figure(figsize=(5,4), dpi=300)
plt.title('LinkCount')
plt.xlabel('Value')
plt.ylabel('Occurrence')
n, bins, patches = plt.hist(X, normed=1,
bins=range(max(X.ravel())-min(X.ravel())),
alpha=0.75)
plt.grid(True)
参考下图:
由于大多数帖子根本没有链接,我们现在知道这个特性不会成为一个好的分类器。不管怎样,让我们试一试,初步估计一下我们的位置。
训练分类器
我们必须将特征数组和先前定义的Y
标签一起传递给kNN
学习器,以获得分类器:
from sklearn.neighbors import KNeighborsClassifier
X = np.asarray([extract_features_from_body(text) for post_id, text in
fetch_posts(fn_sample) if post_id in all_answers])
knn = KNeighborsClassifier()
knn.fit(X, Y)
使用标准参数,我们只是给我们的数据拟合了一个5NN
(意思是带有k=5
的NN
)。为什么是5NN
?以我们目前对数据的了解,我们真的不知道正确的 k 应该是什么。一旦我们有了更多的洞察力,我们就会对如何设定k
有更好的想法。
测量分类器的性能
我们必须清楚我们想要测量什么。最简单的方法是简单地计算测试集的平均预测质量。这将导致错误预测的值介于 0 和完美预测的值介于 1 之间。
现在让我们使用精度作为预测质量,scikit-learn 使用knn.score()
为我们方便地计算。但是正如我们在第 2 章、用现实世界的例子进行分类时所学的,我们不会只做一次,而是在这里使用来自sklearn.model_selection
的现成的 KFold 类进行交叉验证。最后,我们将对每个折叠的测试集的分数进行平均,并使用标准偏差查看其变化程度:
from sklearn.neighbors import KNeighborsClassifier
from sklearn.model_selection import KFoldscores = []
N_FOLDS = 10
cv = KFold(n_splits=N_FOLDS, shuffle=True, random_state=0)
for train, test in cv.split(X, Y):
X_train, y_train = X[train], Y[train]
X_test, y_test = X[test], Y[test]
clf =KNeighborsClassifier()
clf.fit(X_train, Y)_train)
scores.append(clf.score(X_test, y_test))
print("Mean(scores)=%.5f\tStddev(scores)=%.5f"\
%(np.mean(scores), np.std(scores)))
以下是输出:
Mean(scores)=0.50170 Stddev(scores)=0.01243
现在,这还远远不能使用。准确率只有 50%,就像扔硬币一样。显然,一篇文章中的链接数量并不能很好地反映一篇文章的质量。所以,我们可以说这个特性没有太大的辨别能力——至少对于有k=5
的 kNN 来说没有。
设计更多功能
除了使用超链接的数量作为帖子质量的代表,代码行的数量也可能是另一个很好的指标。至少,这是一个很好的指标,表明帖子的作者有兴趣回答这个问题。我们可以找到嵌入在<pre>...</pre>
标签中的代码。一旦我们提取了它,我们应该计算帖子中正常单词的数量:
# we will use regular expression to remove HTML tags
tag_match = re.compile('<[^>]*>', re.MULTILINE | re.DOTALL)
whitespace_match = re.compile(r'\s+', re.MULTILINE | re.DOTALL)
def extract_features_from_body(s):
num_code_lines = 0
link_count_in_code = 0
# remove source code and count how many lines the post has
code_free_s = s
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_in_code
html_free_s = tag_match.sub(' ', code_free_s)
text = html_free_s
for link in links:
if link.lower().startswith('http://'):
text = text.replace(link, ' ')
text = whitespace_match.sub(' ', text)
num_text_tokens = text.count(' ')
return num_text_tokens, num_code_lines, link_count
看到这一点,我们注意到,至少一篇帖子中的字数显示出更高的可变性:
因为我们有多个功能,所以我们标准化了它们的价值:
scores = []
for train, test in cv.split(X, Y):
clf = make_pipeline(StandardScaler(), KNeighborsClassifier())
clf.fit(X[train], Y[train])
scores.append(clf.score(X[test], Y[test]))
print("Mean(scores)=%.5f\tStddev(scores)=%.5f"%(np.mean(scores), np.std(scores)))
在更大的特征空间上训练可以大大提高准确性:
Mean(scores)=0.60070 Stddev(scores)=0.00759
但是,这仍然意味着我们会把大约 10 个错误中的 4 个归类。至少我们走的方向是对的。更多的特征导致更高的精度,这导致我们添加更多的特征。因此,让我们通过更多功能来扩展功能空间:
AvgSentLen
:这是衡量一个句子的平均字数。也许有一种模式,特别好的帖子不会用过长的句子让读者的大脑超载AvgWordLen
:与AvgSentLen
类似,该功能测量一篇帖子的平均字数NumAllCaps
:这衡量的是大写的字数,被认为是文风不佳NumExclams
:这是测量感叹号的数量
我们将使用 NLTK 来方便地确定句子和单词的边界,计算特征,并立即将它们附加到已经包含其他特征的元词典中:
import nltk
def add_sentence_features(m):
for pid, text in fetch_posts(fn_sample):
if not text:
for feat in ['AvgSentLen', 'AvgWordLen',
'NumAllCaps', 'NumExclams']:
m[pid][feat] = 0
else:
sent_lens = [len(nltk.word_tokenize(sent)) for sent in
nltk.sent_tokenize(text)]
m[pid]['AvgSentLen'] = np.mean(sent_lens)
text_tokens = nltk.word_tokenize(text)
m[pid]['AvgWordLen'] = np.mean([len(w) for w in text_tokens])
m[pid]['NumAllCaps'] = np.sum([word.isupper() \
for word in text_tokens])
m[pid]['NumExclams'] = text.count('!')
add_sentence_features(meta)
以下图表显示了平均句子和单词长度的值分布,以及大写单词和感叹号的数量:
有了这四个额外的特性,我们现在有七个特性来代表各个帖子。让我们看看我们如何进步:
Mean(scores)=0.60225 Stddev(scores)=0.00729
这很有趣。我们又增加了四个功能,但没有得到任何回报。这怎么可能呢?
为了理解这一点,我们必须提醒自己kNN
是如何工作的。我们的5NN
分类器通过计算前面提到的七个特征来确定新帖子的类别— LinkCount
、NumTextTokens
、NumCodeLines
、AvgSentLen
、AvgWordLen
、NumAllCaps
和NumExclams
—然后找到最接近的五个其他帖子。新帖子的类别就是最近帖子的大多数类别。最近的帖子是通过计算欧几里德距离来确定的(因为我们没有指定它,所以分类器是用默认的p=2
初始化的,这是闵可夫斯基距离中的参数)。这意味着所有七个特征都被相似地对待。
决定如何提高性能
为了改进这一点,我们基本上有以下选择:
- 多补充数据:可能只是学习算法的数据不够;添加更多的训练数据应该会有所帮助。
- 玩转模型复杂性:也许模型不够复杂?还是可能已经太复杂了?在这种情况下,我们可以减少 k,以便考虑更少的最近邻,从而更好地预测非平滑数据。或者我们可以增加它来达到相反的效果。
- 修改特征空间:也许我们没有合适的特征集?我们可能遗漏了帖子的一些重要方面。或者,我们是否应该删除一些当前的特征,以防某些特征与其他特征混淆?
- 改变模型:也许 kNN 不太适合我们的用例;也许它永远无法实现良好的预测性能,无论我们允许它有多复杂,特征空间有多复杂。
困在这一点上,人们经常试图通过随机选择其中一个选项并以不特定的顺序进行尝试来提高当前的性能,希望偶然找到黄金配置。我们可以在这里做同样的事情,但这肯定需要比做出明智决定更长的时间。让我们走知情路线,为此我们需要引入偏差-方差权衡。
偏差、差异及其权衡
在第 1 章、Python 机器学习入门中,我们尝试拟合由 d 维参数控制的不同复杂度的多项式来拟合数据。我们意识到一个二维多项式,一条直线,不能很好地拟合示例数据,因为数据本质上不是线性的。无论我们的拟合过程有多复杂,我们的二维模型都将一切视为一条直线。我们了解到它对手头的数据太有偏见,称之为拟合不足。
我们对维度进行了一点研究,发现 100 维多项式非常适合训练它的数据(当时我们不知道训练测试拆分)。然而,我们很快发现它太合适了。我们意识到它的过度拟合非常严重,以至于用不同的数据点样本,我们会得到完全不同的 100 维多项式。这就是为什么人们也可以说模型对于给定的数据具有太高的方差。
这些是大多数机器学习问题的极端情况。理想情况下,我们希望同时具有低偏差和低方差。但是,我们处在一个糟糕的世界,必须在它们之间进行权衡。如果我们改进一个,另一个可能会变得更糟。
固定高偏置
让我们现在假设我们遭受高偏差。在这种情况下,增加更多的训练数据显然没有帮助。此外,删除功能肯定不会有帮助,因为我们的模型已经过于简单了。
我们唯一的可能是获得更多的特性,使模型更加复杂,或者改变模型本身。
固定高方差
相反,如果我们遭受高方差,这意味着我们的模型对于数据来说太复杂了。在这种情况下,我们只能尝试获取更多的数据或降低复杂性。这将意味着增加 k 以便考虑更多的邻居,或者删除一些特征。
偏高还是偏低?
为了找出我们的问题是什么,我们必须绘制不同数据大小上的训练和测试误差,然后检查训练和测试之间的差距是否正在缩小。
高偏差通常表现为测试误差在开始时减少一点,但随后随着训练误差随着数据集大小的增加而接近,测试误差稳定在一个非常高的值。两条曲线之间有很大的差距,说明差异很大。
为 5NN 绘制不同数据集大小的误差显示了训练误差和测试误差之间的巨大差距,暗示了一个高方差问题:
由于测试误差不会随着数据的增加而减少,我们不得不重新考虑模型。我们当然可以通过增加 k 或简化特征空间来降低模型的复杂性。
如下图所示,对于只有LinkCount
和NumTextTokens
的简化特征空间,减少特征空间没有帮助:
对于其他较小的特征集,我们得到了类似的图。不管我们采用什么样的特征子集,图形看起来都是相似的。
降低模型复杂度的另一种方法是增加 k,这导致更平滑的决策边界。再次接受所有功能的培训,我们确实看到了积极的影响:
| k | 平均值(分数) | stddev(分数) |
| five | 0.6022 | 0.0073 |
| Ten | 0.6191 | 0.0096 |
| Forty | 0.6425 | 0.0104 |
但这还不够,还以较低的分类-运行时性能为代价。以上表中平均精度最高的 k=40 为例。要对新帖子进行分类,我们需要找到离其他帖子最近的 40 来决定新帖子是否是好帖子:
显然,在我们的场景中使用最近邻似乎有问题。它还有另一个真正的缺点。随着时间的推移,我们的系统中会有越来越多的帖子。由于最近邻方法是一种基于实例的方法,我们将不得不在系统中存储所有帖子。我们得到的越多,预测就会越慢——鉴于如此低的性能,这绝对不是我们愿意付出的代价。这与基于模型的方法不同,基于模型的方法试图从数据中导出模型。
现在我们有足够的理由放弃最近邻法,在分类世界中寻找更好的地方。当然,我们永远不会知道是否有一个我们只是碰巧没有想到的黄金特征。但是现在,让我们转向另一种分类方法,这种方法在基于文本的分类场景中非常有效。
使用逻辑回归
与其名称相反,逻辑回归是一种分类方法。当涉及到基于文本的分类时,这是一个非常强大的工具;它通过首先对逻辑函数进行回归来实现这一点,因此得名。
一点数学知识和一个小例子
为了对逻辑回归的工作方式有一个初步的了解,让我们首先看一下下面的例子,其中我们有人工特征值,X
,用相应的类,0
或1
绘制:
from scipy.stats import norm
np.random.seed(3) # for reproducibility
NUM_PER_CLASS = 40
X_log = np.hstack((norm.rvs(2, size=NUM_PER_CLASS, scale=2),
norm.rvs(8, size=NUM_PER_CLASS, scale=3)))
y_log = np.hstack((np.zeros(NUM_PER_CLASS),
np.ones(NUM_PER_CLASS))).astype(int)
plt.xlim((-5, 20))
plt.scatter(X_log, y_log, c=np.array(['blue', 'red'])[y_log], s=10)
plt.xlabel("feature value")
plt.ylabel("class")
参考下图:
我们可以看到,数据非常嘈杂,以至于类在 1 到 6 之间的特征值范围内重叠。因此,最好不要直接对离散类建模,而是对某个特征值属于类1
、 P(X) 的概率建模。一旦我们拥有这样一个模型,我们就可以预测等级1
如果 P(X) > 0.5 ,否则等级为 0。
从数学上讲,总是很难对有限范围的东西进行建模,就像我们这里的离散标签 0 和 1 一样。因此,我们稍微调整一下概率,使它们始终保持在 0 和 1 之间。为此,我们需要比值比和它的对数。
假设一个特征属于类1
, P(y=1) = 0.9 的概率为 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 机器学习入门、
(用 log(赔率)代替 y)。
我们可以为解决这个,这样我们就有了
。
我们只需找到正确的系数,这样公式就能给出数据集内所有(xi,π)对的最低误差,但这将由 scikit-learn 完成。
拟合后,公式将给出属于类1
的每个新数据点x
的概率:
>>> 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, max_iter=100, multi_class='ovr', n_jobs=1, penalty='l2', random_state=None, solver='liblinear', tol=0.0001, verbose=0, warm_start=False)
>>> clf.fit(X_log, y_log)
>>> 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_ special
字段公开了第一个系数。
如果我们绘制拟合模型,我们会发现它在给定数据的情况下非常有意义:
X_range = np.arange(-5, 20, 0.1)
plt.figure(figsize=(10, 4), dpi=300)
plt.xlim((-5, 20))
plt.scatter(X_log, y_log, c=np.array(['blue', 'red'])[y_log], s=5)
# we use ravel() to get rid of the additional axis
plt.plot(X_range, lr_model(clf, X_range).ravel(), c='green')
plt.plot(X_range, np.ones(X_range.shape[0]) * 0.5, "--")
plt.xlabel("feature value")
plt.ylabel("class")
plt.grid(True)
参考下图:
逻辑回归在分类后问题中的应用
诚然,上一节中的例子是为了展示逻辑回归的美妙之处而创建的。它在真实的噪声数据上表现如何?
将它与作为基线的最佳最近邻分类器(k=40)
进行比较,我们看到它不会改变很多情况:
| 方法 | 平均值(分数) | stddev(分数) |
| LogReg C=0.001
| 0.6369 | 0.0097 |
| LogReg C=0.01
| 0.6390 | 0.0109 |
| LogReg C=0.1
| 0.6382 | 0.0097 |
| LogReg C=1.00
| 0.6380 | 0.0099 |
| LogReg C=10.00
| 0.6380 | 0.0097 |
| 40NN
| 0.6425 | 0.0104 |
我们已经显示了C
正则化参数的不同值的准确性。有了它,我们可以控制模型的复杂性,类似于最近邻法的k
参数。C
值越小,模型复杂度越低。
快速查看我们的最佳候选人之一C=0.01,
的偏差-方差图显示,我们的模型具有高偏差-测试和训练-误差曲线,接近但保持在不可接受的高值。这表明当前特征空间的逻辑回归拟合不足,无法学习正确捕捉数据的模型:
那么,现在怎么办?我们切换了模型,并根据我们当前的知识状态尽可能地对其进行了调整,但我们仍然没有可接受的分类器。我们通过切换获得的唯一好处是,我们现在有了一个可以根据数据进行扩展的模型,因为它不需要存储所有的实例。
越来越多的,似乎要么数据对于这个任务来说太嘈杂,要么我们的特征集仍然不够合适,不足以正确区分类别。
寻找准确性的背后——准确性和召回率
让我们退后一步,再次思考我们正在努力实现的目标。事实上,我们并不需要一个能完美预测好答案和坏答案的分类器,因为我们直到现在都是用准确度来衡量的。如果我们可以调整分类器,使其特别擅长预测某个类别,我们就可以相应地调整用户的反馈。例如,如果我们有一个分类器,当它预测一个答案是坏的时,它总是正确的,那么在分类器检测到答案是坏的之前,我们不会给出任何反馈。相反,如果分类器在预测答案是好的方面超过了,我们可以在开始时向用户显示有用的评论,并在分类器说答案是好的时将其删除。
要弄清楚我们处于哪种情况,我们必须了解如何测量精度和召回率。为了理解这一点,我们必须研究下表中描述的四种不同的分类结果:
例如,如果分类器预测一个实例是正的,并且该实例确实是正的,那么这就是一个真正的正实例。另一方面,如果分类器错误地分类了那个实例,说它是负的,而实际上它是正的,那么这个实例就被称为假负的。
当我们预测一篇文章是好是坏,但不一定两者都预测时,我们想要的是有很高的成功率。也就是说,我们想要尽可能多的真实积极因素。这就是精准捕捉到的:
相反,如果我们的目标是发现尽可能多的好或坏的答案,我们会对回忆更感兴趣:
参考下图:
那么,我们现在如何优化精度呢?到目前为止,我们一直用 0.5 作为判断一个答案好不好的门槛。我们现在可以做的是计算 TP、FP 和 FN 的数量,同时在 0 和 1 之间改变阈值。有了这些计数,我们就可以绘制精确度超过回忆的曲线。
metrics
模块中方便的precision_recall_curve()
功能为我们完成所有计算:
>>> from sklearn.metrics import precision_recall_curve
>>> # X_test would come from KFold’s train/test split
>>> precision, recall, thresholds = precision_recall_curve(y_test,
clf.predict(X_test))
以可接受的性能预测一个类别并不总是意味着分类器在预测另一个类别时也是可接受的。这可以在以下两个图中看到,其中我们绘制了分类不良(左图)和良好(右图)答案的精度/召回曲线:
在图表中,我们还包括了对分类器性能的更好描述,曲线下的区域( AUC )。它可以理解为分类器的平均精度,是比较不同分类器的好方法。
预测好答案表明,我们可以在 20%的召回率下获得 80%的准确率,而当我们想对差答案实现 80%的预测时,我们的召回率只有不到 10%。
让我们找出我们需要的门槛。当我们在不同的折叠上训练许多分类器时(记住,我们在几页后重复KFold()
,我们需要检索既不太差也不太好的分类器,以便获得真实的视图。让我们称之为中等克隆:
>>> medium = np.argsort(scores)[ len(scores) // 2)]
>>> thresholds = np.hstack(([0],thresholds[medium]))
>>> for precision in np.arange(0.77, 0.8, 0.01):
... thresh_idx = precisions >= precision
P=0.77 R=0.25 thresh=0.62
P=0.78 R=0.23 thresh=0.65
P=0.79 R=0.21 thresh=0.66
P=0.80 R=0.13 thresh=0.74
在0.66
设置阈值,我们看到当我们接受 21%的低召回率时,我们在检测好答案时仍然可以达到 79%的准确率。这意味着我们只能发现三分之一的好答案。但是从我们设法发现的三分之一的好答案中,我们可以合理地确定它们确实是好的。对于其余的,我们可以礼貌地显示关于如何改进答案的额外提示。
精简分类器
观察单个特性的实际贡献总是值得的。对于逻辑回归,我们可以直接取学习的系数(clf.coef_)
得到特征影响的印象:
我们看到NumCodeLines
、LinkCount
、AvgWordLen
、NumTextTokens
对判断一个帖子是否好的正面影响最大,而AvgWordLen
、LinkCount
、NumCodeLines
在这方面也有发言权,但少了很多。这意味着越详细越有可能导致分类成为一个好答案。
另一边,我们有NumAllCaps
,NumExclams
有负权重一。这意味着,一个答案喊得越多,就越不可能得到好评。
然后我们有AvgSentLe
n 特征,这似乎对检测好答案没有太大帮助。我们可以轻松地删除该功能并保留。然而,仅仅从系数的相同分类性能量值,我们不能立即导出特征的重要性,因为我们在原始特征上训练分类器,原始特征没有被归一化。
运送它!
假设我们想将这个分类器集成到我们的站点中。在前面所有的例子中,我们总是只对 90%的可用数据进行训练,因为我们使用了另外 10%的数据进行测试。让我们假设数据是我们所有的。在这种情况下,我们应该在所有数据上重新训练分类器:
>>> C_best = 0.01 # determined above
>>> clf = LogisticRegression(C=C_best)
>>> clf.fit(X, Y) # now trainining an all data without cross-validation
>>> print(clf.coef_)
[[ 0.24937413 0.00777857 0.0097297 0.00061647 0.02354386 -0.03715787 -0.03406846]]
最后,我们应该存储训练好的分类器,因为我们肯定不希望每次启动分类服务时都重新训练它。相反,我们可以简单地在训练后序列化分类器,然后在该站点上反序列化:
>>> import pickle
>>> pickle.dump(clf, open("logreg.dat", "w"))
>>> clf = pickle.load(open("logreg.dat", "r"))
>>> print(clf.coef_) # showing that we indeed got the same classifier again
[[ 0.24937413 0.00777857 0.0097297 0.00061647 0.02354386 -0.03715787 -0.03406846]]
恭喜你,分类器现在可以像刚刚训练过一样使用了。我们现在可以使用分类器的predict_proba()
来计算一个答案是好答案的概率。我们将使用0.66
的阈值,这将在 21%的召回率下产生 79%的精度,正如我们之前确定的:
>>> good_thresh = 0.66
让我们来看看两个人工帖子的功能,以展示它是如何工作的:
>>> # Remember that the features are in this order:
>>> # LinkCount, NumCodeLines, NumTextTokens, AvgSentLen, AvgWordLen,
>>> # NumAllCaps, NumExclams
>>> good_post = (2, 1, 100, 5, 4, 1, 0)
>>> poor_post = (1, 0, 10, 5, 6, 5, 4)
>>> proba = clf.predict_proba([good_post, poor_post])
>>> print(proba) # print probabilities (poor, good) per post
array([[ 0.30127876, 0.69872124],
[ 0.62934963, 0.37065037]])
>>> print(proba >= good_thresh)
array([[False, True],
[False, False]], dtype=bool)
正如预期的那样,我们设法检测到第一篇文章是好的,但不能说第二篇文章有什么问题,这就是为什么我们会展示一个好的、激励性的信息来指导作者改进文章。
利用张量流进行分类
神经网络也可以用来对数据进行分类。与前面的分类器一样,它们可以生成属于某个类的概率,因此,我们可以使用我们想要的阈值来获得我们需要的精度。
这个例子将是我们第一次真正深入神经网络。就像前面的例子一样,我们将使用占位符,但是我们将使用标准的 Tensorflow 函数来创建变量,而不是显式设置变量。
就像以前一样,我们将使用相同的数据和我们当前的所有功能:
X = np.asarray([get_features(aid, ['LinkCount', 'NumCodeLines',
'NumTextTokens', 'AvgSentLen',
'AvgWordLen', 'NumAllCaps',
'NumExclams']) for aid in all_answers])
Y = np.asarray([meta[aid]['Score'] > 0 for aid in all_answers])
当然,这里的一个练习是通过使用更少的特征来复制以前的结果,并看看这个神经网络如何能够区分好帖子和坏帖子。
神经网络和大脑不一样。当现实中不存在这种东西时,我们显式地创建层(稍后将详细介绍这一点,但这是理解我们如何创建简单的神经网络所必需的)。将我们想要创建的图层分解是一种很好的做法,因此,例如,我们将创建两种类型的图层:一种用于密集图层,这意味着它们将所有输入连接到所有输出,另一种用于只有一个输出单元的输出图层:
import tensorflow as tf
def create_dense(x, n_units, name, alpha=0.2):
# Hidden layer
h = tf.layers.dense(x, n_units, activation=tf.nn.leaky_relu, name=name)
return h
def create_output(x):
# Output layer
h = tf.layers.dense(x, 1, activation=tf.nn.sigmoid, name="Output")
return h
该输出单元通过sigmoid
激活创建。这意味着在- inf
和+inf
之间创建值的内部tf.matmult
被输入到一个函数中,该函数将这些值映射到区间[0, 1]
。0
和1
对于输出是无法实现的,所以我们在训练神经网络的时候,要把这个保存在记忆中。因此,对于我们训练中的目标概率,我们改变输出以适应这种不可能:
Y = Y.astype(np.float32)[:, None]
bce_ceil = 1e-5
Y = Y * (1 - 2 * bce_ceil) + bce_ceil
现在,我们可以分割我们的数据:
from sklearn.model_selection import train_test_split
X_train, X_test, Y_train, Y_test = train_test_split(X, Y, train_size=0.8)
让我们从设置我们通常的超级参数开始:
n_epochs = 500
batch_size = 1000
steps = 10
layer1_size = 5
如果我们使用所有七个特征,我们的神经网络构建如下:
X_tf = tf.placeholder(tf.float32, (None, 7), name="Input")
Y_ref_tf = tf.placeholder(tf.float32, (None, 1), name="Target_output")
h1 = create_dense(X_tf, layer1_size, name="Layer1")
Y_tf = create_output(h1)
loss = tf.reduce_mean(tf.square(Y_ref_tf - Y_tf))
grad_speed = .01
my_opt = tf.train.GradientDescentOptimizer(grad_speed)
train_step = my_opt.minimize(loss)
梯度步长现在远远大于回归示例中的步长。我们可以使用更小的步长,但这需要更多的步长来实现我们的loss
函数的局部最小值。
我们现在可以训练我们的神经网络,非常类似于我们在第 2 章、中所做的,用真实世界的例子进行分类。唯一不同的是,最后,我们还运行了神经网络内部的测试数据:
with tf.Session() as sess:
sess.run(tf.global_variables_initializer())
loss_vec = []
for epoch in range(n_epochs):
permut = np.random.permutation(len(X_train))
for j in range(0, len(X_train), batch_size):
batch = permut[j:j+batch_size]
Xs = X_train[batch]
Ys = Y_train[batch]
sess.run(train_step, feed_dict={X_tf: Xs, Y_ref_tf: Ys})
temp_loss = sess.run(loss, feed_dict={X_tf: X_train, Y_ref_tf: Y_train})
loss_vec.append(temp_loss)
if epoch % steps == steps - 1:
print('Epoch #%i loss = %s' % (epoch, temp_loss))
predict_train = sess.run(Y_tf, feed_dict={X_tf: X_train})
predict_test = sess.run(Y_tf, feed_dict={X_tf: X_test})
现在,我们扔掉了我们训练的神经网络,这就是为什么我们也在同一个会话中使用测试数据。我们将在第 8 章、人工神经网络和深度学习中看到如何保存和重用模型。
当然,我们也可以展示优化器的表现:
plt.plot(loss_vec, 'k-')
plt.title('Loss per Epoch)
plt.xlabel(Epoc')
plt.ylabel('Loss')
参考下图:
每一代人的损失在不同的时期会有很大的不同。grad_speed
是改变这个图形最重要的参数。它的值是收敛速度和稳定性之间的折衷,我建议您尝试不同的值,看看这个函数在不同的值和不同的运行中是如何表现的。
如果我们查看训练分数和测试分数,我们可以看到我们的结果与前面的最佳分类器相匹配:
from sklearn.metrics import accuracy_score
score = accuracy_score(Y_train > .5, predict_train > .5)
print("Score (on training data): %.2f" % score)
score = accuracy_score(Y_test > .5, predict_test > .5)
print("Score (on testing data): %.2f" % score)
这将输出:
Score (on training data): 0.65
Score (on testing data): 0.65
这是一个回到超参数的好时机,尤其是中间或隐藏层的大小和修改节点的数量。降低它会降低分类器的行为吗?是增加还是改善?再加一个中间层怎么样?它的神经元数量有什么影响?
sklearn
的一个很好的特性是丰富的支持功能和教程。这是混淆矩阵教程中的一个函数,有助于可视化分类器的质量:
def plot_confusion_matrix(cm, classes,
normalize=False,
title='Confusion matrix',
cmap=plt.cm.Blues):
"""
This function prints and plots the confusion matrix.
Normalization can be applied by setting `normalize=True`.
"""
import itertools
if normalize:
cm = cm.astype('float') / cm.sum(axis=1)[:, np.newaxis]
print("Normalized confusion matrix")
else:
print('Confusion matrix, without normalization')
print(cm)
plt.imshow(cm, interpolation='nearest', cmap=cmap)
plt.title(title)
plt.colorbar()
tick_marks = np.arange(len(classes))
plt.xticks(tick_marks, classes, rotation=45)
plt.yticks(tick_marks, classes)
fmt = '.2f' if normalize else 'd'
thresh = cm.max() / 2.
for i, j in itertools.product(range(cm.shape[0]), range(cm.shape[1])):
plt.text(j, i, format(cm[i, j], fmt),
horizontalalignment="center",
color="white" if cm[i, j] > thresh else "black")
plt.tight_layout()
plt.ylabel('True label')
plt.xlabel('Predicted label')
我们现在可以在.5
使用它和一个阈值来查看这个分类器在训练和测试数据上的行为:
class_names = ["Poor", "Good"]
from sklearn import metrics
print(metrics.classification_report(Y_train > .5, predict_train > .5, target_names=class_names))
plot_confusion_matrix(metrics.confusion_matrix(Y_train > .5, pre-dict_train > .5), classes=class_names,title='Confusion matrix, without normaliza-tion')
plt.show()
print(metrics.classification_report(Y_test > .5, predict_test > .5, target_names=class_names))
plot_confusion_matrix(metrics.confusion_matrix(Y_test > .5, pre-dict_test > .5), classes=class_names,title='Confusion matrix, without normaliza-tion')
这将输出:
precision recall f1-score support
Poor 0.63 0.73 0.67 8035
Good 0.67 0.57 0.62 7965
参考下图:
请参见以下数据:
avg / total 0.65 0.65 0.65 16000
precision recall f1-score support
Poor 0.62 0.73 0.67 1965
Good 0.68 0.57 0.62 2035
avg / total 0.65 0.65 0.65 4000
当从训练数据转移到测试数据时,看到分类器的稳定性是非常有趣的。在这两种情况下,我们也可以看到仍然有很多错误的分类,重点是被标记为坏帖子的好帖子(这可能比相反的好!).
摘要
我们成功了!从一个非常嘈杂的数据集,我们建立了两个分类器,解决了我们的部分目标。当然,我们必须务实,并根据可实现的目标调整我们的初始目标。但是在途中,我们了解了最近邻和逻辑回归的优缺点,并了解了神经网络的简单分类。我们学习了如何提取特征,如LinkCount
、NumTextTokens
、NumCodeLines
、AvgSentLen
、AvgWordLen
、NumAllCaps
和NumExclams
,以及如何分析它们对分类器性能的影响。
但更有价值的是,我们学到了调试表现不佳的分类器的有效方法。这将有助于我们在未来更快地生产出可用的系统。
在研究了最近邻和逻辑回归之后,在第 5 章、降维中,我们将熟悉另一个简单但强大的分类算法:朴素贝叶斯。在此过程中,我们还将从 scikit-learn 了解一些更方便的工具。
五、降维
垃圾输入,垃圾输出——在本书中,我们将在将机器学习方法应用于数据时看到这种模式。回顾过去,我们可以看到,最有趣的机器学习挑战总是涉及某种特征工程,我们试图利用对问题的洞察来精心设计模型有望获得的附加特征。
在这一章中,我们将朝着与降维相反的方向前进,去掉不相关或冗余的特征。删除特征起初可能看起来是反直觉的,因为更多的信息似乎总是比更少的信息更好。同样,即使我们的数据集中有冗余特征,学习算法难道不能快速计算出来并将其权重设置为0
?事实上,有充分的理由尽可能缩小尺寸:
- 多余的特征会激怒或误导学习者。并非所有机器学习方法都是如此(例如,支持向量机喜欢高维空间)。然而,大多数模型感觉更安全,尺寸更小
- 反对高维特征空间的另一个观点是,更多的特征意味着更多的参数需要调整,并且过拟合的风险更高
- 我们为解决任务而检索的数据可能只是人为的高维,而真实的维度可能很小
- 更少的维度等于更快的训练等于更多的参数变化,在相同的时间范围内尝试等于更好的最终结果
- 缩小尺寸更有利于可视化。如果我们想要可视化数据,我们将被限制在二维或三维
在这一章中,我们将向您展示如何清除数据中的垃圾,同时保留其中真正有价值的部分。
绘制我们的路线图
降维可以大致分为特征选择和特征投影方法。到目前为止,当我们发明、分析、然后可能丢弃一些特征时,我们已经在几乎每一章中采用了某种特征选择。在本章中,我们将介绍一些使用统计方法的方法,即相关性和互信息,以便能够在广阔的特征空间中做到这一点。特征投影试图将原始特征空间转换为低维特征空间。当我们无法使用选择方法去除特征,但是我们仍然有太多的特征需要学习时,这尤其有用。我们将使用主成分分析 ( 主成分分析)、线性判别分析 ( LDA )和多维标度 ( MDS )来演示这一点。
选择功能
如果我们想对我们的机器学习算法好一点,我们会为它提供彼此不依赖的特性,但这些特性高度依赖于要预测的值。这意味着每个特征都添加了显著的信息。删除任何功能都会导致性能下降。
如果我们只有少数特征,我们可以绘制一个散点图矩阵(每个特征对组合一个散点图)。然后就可以很容易地发现特征之间的关系。对于每一个表现出明显依赖性的特征对,我们会考虑是否应该删除其中一个,或者更好地从两个特征中设计一个更新、更干净的特征。
然而,大多数时候,我们有很多功能可以选择。试想一下分类任务,我们有一袋单词来对答案的质量进行分类,这将需要 1,000 x 1,000 的散点图(使用 1000 个单词的词汇)。在这种情况下,我们需要一种更自动化的方法来检测重叠的特征并解决它们。我们将在下面的小节中介绍两种通用的方法。
使用过滤器检测冗余特征
过滤器试图独立于任何后来使用的机器学习方法来清理特征空间。他们依靠统计方法来找出哪些特征是多余的或不相关的。在冗余特征的情况下,过滤器只为每个冗余特征组保留一个。不相关的功能将被简单地删除。一般来说,过滤器的工作方式如下图所示:
首先,我们使用仅考虑训练数据的统计信息过滤掉冗余的特征。然后,我们检查剩余的特征在分类标签时是否有用。
相互关系
利用相关性,我们可以很容易地看到特征对之间的线性关系。在下图中,我们可以看到不同程度的相关性,以及绘制为虚线的潜在线性相关性(拟合的一维多项式)。借助于scipy.stat
的pearsonr()
函数,使用共同的皮尔逊相关系数(皮尔逊值)计算各个图顶部的相关系数 Cor (X 1 ,X 2 ) 。
给定两个大小相等的数据序列,它返回相关系数值和 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.83661493668227427)
在第一种情况下,我们有一个明确的迹象表明,这两个系列是相关的。在第二种情况下,我们仍然有一个明显非零的值。
但是p-值0.84
告诉我们相关系数不显著,不要太在意。在下图中具有高相关系数的前三种情况下,我们可能会想抛出XT5】1 或 X 2 ,因为它们似乎传达了相似的信息,如果不是相同的话:
然而,在最后一种情况下,我们应该保留这两个特性。在我们的应用中,这个决定当然是由这个 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%的相同概率。这种不确定性可以通过克劳德·香农的信息熵来衡量:
在我们的公平硬币案例中,我们有两种情况:让 X 0 为正面的情况,让 X 1 为带有的反面的情况。
因此,它的结论如下:
For convenience, we can also use scipy.stats.entropy([0.5, 0.5], base=2)
. We set the base parameter to 2
to get the same result as earlier. Otherwise, the function will use the natural logarithm via np.log()
. In general, the base does not matter (as long as you use it consistently).
现在,想象一下,我们事先知道硬币实际上并不公平,在翻转后,头部有 60%的机会出现:
H(X) = -0.6 。日志2T4【0.6】-0.4。log 2 (0.4)=0.97
我们可以看到这种情况不太不确定。不确定性会随着我们远离*0.5*
而降低,达到 0 的极值,无论是 0 百分比还是 100 百分比的头部出现概率,如下图所示:
我们现在将修改熵 H(X) ,将它应用于两个特征,而不是一个特征,这样当我们了解 Y 时,它可以测量从 X 中消除了多少不确定性。然后,我们可以了解一个特性如何减少另一个特性的不确定性。
例如,没有任何关于天气的进一步信息,我们完全不确定外面是否在下雨。如果我们现在知道外面的草是湿的,那么不确定性就降低了(我们仍然需要检查洒水器是否已经打开)。
更正式地说,相互信息的定义如下:
这看起来有点吓人,但实际上只不过是总和和乘积。例如,p()
的计算可以通过对特征值进行宁滨运算,然后计算每个箱中值的分数来完成。在下面的图中,我们将箱的数量设置为十。
为了将互信息限制在[0,1]
的区间内,我们必须将其除以它们相加的个体熵,这给了我们以下归一化互信息:
代码如下:
def normalized_mutual_info(x, y, bins=10):
counts_xy, bins_x, bins_y = np.histogram2d(x, y, bins=(bins, bins))
counts_x, bins = np.histogram(x, bins=bins)
counts_y, bins = np.histogram(y, bins=bins)
counts_xy += 1 # add-one smoothing as we have
counts_x += 1 # seen in the previous chapters
counts_y += 1
P_xy = counts_xy / np.sum(counts_xy)
P_x = counts_x / np.sum(counts_x)
P_y = counts_y / np.sum(counts_y)
I_xy = np.sum(P_xy * np.log2(P_xy / (P_x.reshape(-1, 1) * P_y)))
return I_xy / (entropy(counts_x) + entropy(counts_y))
相互信息的好处是,与相关性不同,它不仅仅关注线性关系,正如我们在下图中看到的:
正如我们所看到的,相互信息能够表明线性关系的强度。下图显示它也适用于平方关系:
因此,我们需要做的是计算所有特征对的归一化互信息。对于每一对过高的值(我们必须确定这意味着什么),我们将丢弃其中一个。在回归的情况下,我们可以删除与期望结果值相互信息太少的特征。
这可能适用于少量的功能。然而,在某些时候,这个过程可能非常昂贵,因为计算量随着特征数量的增加而呈二次增长。
过滤器的另一个巨大缺点是,它们会丢弃在孤立情况下似乎没有用的功能。更多的时候,有一些特征看起来完全独立于目标变量,但是当它们结合在一起时,就会摇摆不定。为了保存这些,我们需要包装纸。
向模型询问使用包装器的特性
虽然过滤器可以极大地帮助摆脱无用的功能,但它们只能到此为止。在所有的过滤之后,可能仍然有一些特征在它们之间是独立的,并且显示出与结果变量的某种程度的依赖,但是从模型的角度来看,这些特征是完全无用的。试想以下描述XOR
功能的数据。就个体而言,无论是 A 还是 B 都不会表现出任何依赖 Y 的迹象,而合在一起,它们显然会表现出:
| A | B | Y |
| 0
| 0
| 0
|
| 0
| 1
| 1
|
| 1
| 0
| 1
|
| 1
| 1
| 0
|
那么,为什么不要求模型本身对个体特征进行投票呢?这就是 scikit 包装器所做的,正如我们在下面的流程图中看到的:
在这里,我们将特征重要性的计算推到了模型训练过程中。不幸的是(但可以理解),特征重要性不是以二进制来确定的,而是以排名值来确定的,所以我们仍然需要指定在哪里进行切割,我们愿意接受特征的哪一部分,以及我们想要放弃哪一部分。
回到 scikit-learn,我们在sklearn.feature_selection
包中找到了各种优秀的包装类。这个领域真正的主力是RFE
,代表递归特征消除。它需要一个估计器和所需数量的特征作为参数,然后用各种特征集训练估计器,只要它找到了足够小的特征子集。RFE
实例本身假装是一个估计量,从而实际上包装了所提供的估计量。
在下面的例子中,我们将使用数据集方便的make_classification()
函数创建一个 100 个样本的人工分类问题。它让我们指定创建 10 个特征,其中只有三个是真正有价值的,以解决分类问题:
>>> 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()
>>> selector = RFE(clf, n_features_to_select=3)
>>> selector = selector.fit(X, y)
>>> print(selector.support_)
[False False True False False False True True False False]
>>> print(selector.ranking_)
[5 4 1 2 6 7 1 1 8 3]
当然,现实场景中的问题是,我们如何知道n_features_to_select
的正确值?事实是,我们不能。然而,大多数时候,我们可以使用数据样本,并使用不同的设置来玩它,以快速获得对正确棒球场的感觉。
好的一点是,我们不用那么精确地使用包装器。让我们尝试一下n_features_to_select
的不同值,看看support_
和ranking_
是如何变化的:
| **n_features_**``**to_select**
| **support_**
| **ranking_**
|
| one | [False False False False False False False True False False]
| [ 7 6 3 4 8 9 2 1 10 5]
|
| Two | [False False False False False False True True False False]
| [6 5 2 3 7 8 1 1 9 4]
|
| three | [False False True False False False True True False False]
| [5 4 1 2 6 7 1 1 8 3]
|
| four | [False False True True False False True True False False]
| [4 3 1 1 5 6 1 1 7 2]
|
| five | [False False True True False False True True False True]
| [3 2 1 1 4 5 1 1 6 1]
|
| six | [False True True True False False True True False True]
| [2 1 1 1 3 4 1 1 5 1]
|
| seven | [ True True True True False False True True False True]
| [1 1 1 1 2 3 1 1 4 1]
|
| eight | [ True True True True True False True True False True]
| [1 1 1 1 1 2 1 1 3 1]
|
| nine | [ True True True True True True True True False True]
| [1 1 1 1 1 1 1 1 2 1]
|
| Ten | [ True True True True True True True True True True]
| [1 1 1 1 1 1 1 1 1 1]
|
我们可以看到结果非常稳定。在请求较小特征集时使用的特征在允许更多特征进入时继续被选择。最后,当我们走错路时,我们依靠我们的火车/测试设备分裂来警告我们。
其他特征选择方法
阅读机器学习文献时,你会发现其他几种特征选择方法。有些甚至看起来不像是特征选择方法,因为它们嵌入在学习过程中(不要与前面提到的包装器混淆)。例如,决策树的核心深处有一个特征选择机制。其他学习方法采用某种惩罚模型复杂性的正则化,因此将学习过程推向仍然简单的良好表现的模型。他们通过将影响较小的特征的重要性降低到零,然后放弃它们(L1 正则化)来做到这一点。
通常,机器学习方法的力量在很大程度上要归功于它们植入的特征选择方法。
特征投影
在某个时候,在我们删除了多余的特征并丢弃了不相关的特征之后,我们仍然会经常发现我们有太多的特征。无论我们使用什么样的学习方法,它们都表现不佳,并且,考虑到巨大的特征空间,我们理解它们实际上不能做得更好。我们必须摆脱特征,即使常识告诉我们它们是有价值的。另一种需要降低特征维数的情况是,当我们想要可视化数据时,特征选择没有太大帮助。然后,我们需要在最后最多有三个维度来提供任何有意义的图形。
输入要素投影方法。他们重组特征空间,使其更容易被模型访问,或者简单地将维度减少到两到三个,这样我们就可以直观地显示依赖关系。
同样,我们可以区分特征投影方法是线性的还是非线性的。此外,正如之前在选择特征部分所看到的,我们将为每种类型提供一种方法(主成分分析作为多维标度的线性和非线性版本)。尽管它们广为人知并被广泛使用,但它们只是可用的更有趣和更强大的特征投影方法中的一部分。
主成分分析
主成分分析 ( PCA )如果想减少特征数量,又不知道用哪种特征投影方法,往往是首先要尝试的。主成分分析是有限的,因为它是一种线性方法,但很有可能它已经走得足够远,让你的模型学得足够好。再加上它提供的强大的数学属性,它找到变换后的特征空间的速度,以及它后来能够在原始特征和变换后的特征之间进行变换的速度,我们几乎可以保证它也将成为您经常使用的机器学习工具之一。
总而言之,给定原始特征空间,PCA 在具有以下属性的低维空间中找到其自身的线性投影:
- 保守方差被最大化
- 最终的重建误差(当试图从变换后的特征返回到原始特征时)被最小化
由于主成分分析只是对输入数据进行转换,因此它可以应用于分类和回归问题。在本节中,我们将使用分类任务来讨论该方法。
绘制主成分分析
主成分分析涉及很多线性代数,我们不想深入讨论。然而,基本算法的过程可以很容易地描述如下:
- 通过减去平均值使数据居中
- 计算协方差矩阵
- 计算协方差矩阵的特征向量
如果我们从 N 个特征开始,那么算法将返回一个具有 N 个维度的变换特征空间(到目前为止我们什么都没有得到)。然而,这个算法的好处是,特征值表明有多少方差是由相应的特征向量描述的。
让我们假设我们从 N = 1000 个特征开始,并且我们知道我们的模型在多于 20 个特征的情况下不能很好地工作。然后,我们简单地选择具有最高特征值的 20 特征向量。
应用主成分分析
让我们考虑下面的人工数据集,它在下面的左图中可视化:
>>> 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
Scikit-learn 在其decomposition
包中提供了PCA
类。在这个例子中,我们可以清楚地看到,一个维度应该足以描述数据。我们可以使用n_components
参数来指定:
>>> from sklearn import linear_model, decomposition, datasets
>>> pca = decomposition.PCA(n_components=1)
同样,在这里,我们可以使用pca
(或其fit_transform()
组合)的fit()
和transform()
方法来分析数据,并将其投影到变换后的特征空间中:
>>> Xtrans = pca.fit_transform(X)
正如我们所指定的,Xtrans
只包含一个维度。您可以在前面的右手图中看到结果。在这种情况下,结果甚至是线性可分的。我们甚至不需要复杂的分类器来区分这两个类。
为了理解重建误差,我们可以看看我们在转换中保留的数据的方差:
>>> print(pca.explained_variance_ratio_)
>>> [ 0.96393127]
这意味着,从两个维度到一个维度后,我们仍然剩下 96%的方差。
当然,事情并不总是这么简单。通常情况下,我们不知道预先建议多少维度。在这种情况下,我们在初始化PCA
时不指定n_components
参数,让它计算完整的变换。拟合数据后,explained_variance_ratio_
包含一组递减顺序的比值:第一个值是描述最高方差方向的基向量的比值,第二个值是第二个最高方差方向的比值,依此类推。在绘制完这个数组后,我们很快就能感觉到我们需要多少个组件:图表肘部之前的组件数量通常是一个很好的猜测。
Plots displaying the explained variance over the number of components are called scree plots. A nice example of combining a scree plot with a grid search to find the best setting for the classification problem can be found at http://scikit-learn.org/stable/auto_examples/plot_digits_pipe.html.
主成分分析的局限性和线性判别分析的帮助
作为一种线性方法,当我们面对具有非线性关系的数据时,主成分分析当然有其局限性。我们在这里不做详细介绍,但是可以说 PCA 有一些扩展,例如内核 PCA,引入了非线性变换,这样我们仍然可以使用 PCA 方法。
主成分分析的另一个有趣的缺点是当它被应用于特殊的分类问题时。如果我们把 good = (x1 > 5) | (x2 > 5) 换成 good = x1 > x2 来模拟这样的特殊情况,就可以很快看出问题,如下图所示:
这里,类不是按照方差最高的轴分布,而是按照方差第二高的轴分布。显然,主成分分析完全失败了。由于我们没有向 PCA 提供任何关于类别标签的提示,所以它不能做得更好。
线性判别分析 ( LDA )来到这里救援。这是一种试图最大化属于不同类的点的距离,同时最小化同一类的点的距离的方法。我们不会给出关于底层理论如何工作的更多细节,只是简单介绍一下如何使用它,如下面的代码所示:
>>> from sklearn import lda
>>> lda_inst = lda.LDA(n_components=1)
>>> Xtrans = lda_inst.fit_transform(X, good)
仅此而已。请注意,与前面的主成分分析示例相反,我们为fit_transform()
方法提供了类标签。因此,主成分分析是一种无监督的特征投影方法,而线性判别分析是一种有监督的方法。结果如预期:
那么,为什么要考虑 PCA 呢?为什么不干脆用 LDA?没那么简单。随着类别数量的增加和每个类别样本的减少,LDA 看起来不再那么好了。此外,主成分分析似乎不像线性判别分析那样对不同的训练集敏感。所以,当我们不得不建议使用哪种方法时,我们只能说这取决于情况。
多维标度
主成分分析试图对保留的方差进行优化,而多维缩放 ( MDS )则试图在减少维度时尽可能保留相对距离。当我们拥有高维数据集并希望获得视觉印象时,这非常有用。
MDS 不在乎数据点本身;相反,它对数据点对之间的差异感兴趣,并将其解释为距离。它获取维度 k 的所有 N 数据点,并使用距离函数 d 0 计算距离矩阵,该距离函数测量原始特征空间中的(大部分时间,欧几里德)距离:
现在,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
。
结果可以在下面两张图表中看到。三角形和圆形靠得很近,而星星离得很远:
让我们来看看稍微复杂一点的 Iris 数据集。我们后面会用它来对比 LDA 和 PCA。Iris 数据集包含每朵花的四个属性。使用前面的代码,我们将它投影到三维空间,同时尽可能保持单个花之间的相对距离。在前面的例子中,我们没有指定任何度量,所以MDS
将默认为欧几里德。这意味着根据四种属性不同的花在 MDS 尺度的三维空间中也应该很远,相似的花现在应该几乎在一起,如下图所示:
相反,用主成分分析将维度减少到三维和二维,我们看到属于同一类别的花的预期更大的传播:
当然,使用 MDS 需要了解单个特征的单位;也许我们使用的特征无法用欧几里得度量来比较。例如,一个分类变量,即使被编码为整数(0 =圆,
1 =星,2 =三角形,等等),也不能用欧几里德度量来比较(一个圆是否更接近于星而不是三角形?).
然而,一旦我们意识到这个问题,MDS 是一个有用的工具,它揭示了我们的数据中的相似性,否则在原始特征空间中很难看到这些相似性。
深入观察 MDS,我们意识到它不是一个单一的算法,而是一系列不同的算法,我们只使用了其中的一个。PCA 也是如此。另外,如果你意识到 PCA 和 MDS 都不能解决你的问题,只要看看 scikit-learn 工具包中的许多其他学习和嵌入算法就可以了。
然而,在你被许多不同的算法淹没之前,最好从最简单的算法开始,看看你能走多远。然后,采取下一个更复杂的,并继续从那里。
用于降维的自动编码器或神经网络
十几年前,神经网络降维的主要工具是科霍宁地图,或自组织地图 ( SOM )。它们是神经网络,可以将数据映射到离散的嵌入 1D 的空间中。从那时起,有了更快的计算机,现在有可能使用深度学习来创建嵌入式空间。
诀窍是有一个比输入层节点少的中间层和一个必须再现输入层的输出层。这个中间层上的数据会给出嵌入空间中的坐标。
如果我们使用没有特定激活函数的规则密集层,我们得到从输入到嵌入层到输出层的线性函数。不止一层到嵌入层不会改变训练的结果,因此,我们得到线性嵌入,例如 PCA(没有在嵌入层中具有正交基的约束)。
给稠密层增加一个非线性激活函数将使得能够在数据中找到流形,而不仅仅是超平面。与 Isomap 等工具试图匹配数据之间的距离(这是 MDS 的变体,试图匹配近似测地线距离而不是欧几里德距离)或拉普拉斯特征映射(试图匹配数据之间的相似性)相反,自动编码器不知道我们试图保留什么——它们只会试图再现我们在输入端提供的任何东西。
Neural networks can extract features from data, as we will see in the TensorFlow chapter, but we will keep things simple here by using a dataset that is features-only.
我们将考虑的数据集是瑞士卷。它是流形中使用的最著名的数据集之一,因为它是一个非线性数据集,人眼很容易理解,但这种包装足以使算法很难正确描述它:
import numpy as np
max = 4
def generate_swissroll(n):
"""
Generates data for the swissroll
Returns the parameter space, the swissroll
"""
orig = np.random.random((2, n)) * max
return (orig.T, np.array((orig[1] * np.cos(orig[1]),
orig[1] * np.sin(orig[1]),
orig[0])).T)
def color_from_parameters(params):
"""
Defines a color scheme for the swissroll
"""
return np.array((params[:,0], params[:,1], max - params[:,1])).T / max
从这些函数中,我们可以生成新数据以及一个颜色代码,该代码允许我们检查嵌入的数据是否与我们使用的原始参数匹配,如下图所示:
现在是时候考虑我们将使用的架构了。我们将从输入层开始,输入层将使用两个层在网络内部馈送数据,这两个层将完成将输入数据展开到具有两个层的嵌入层的繁重工作。为了重建瑞士卷,我们将在三单元输出层结束之前使用另一个密集层。为了创建非线性,每个层(输入除外)将使用leaky_relu
激活。安排如下图所示:
让我们创建脚手架:
import tensorflow as tf
def tf_create_variables():
swissroll_tf = tf.placeholder(tf.float32, (None, 3), name="swissroll")
return swissroll_tf
def tf_create_dense_layer(x, size):
return tf.layers.dense(x, size, activation=tf.nn.leaky_relu,
kernel_initializer=tf.contrib.layers.xavier_initializer())
这一次,自动编码器将被封装在一个类中。构造器将创建变量,train
方法将运行优化,并创建一些显示图像。
当我们构建层时,我们保存嵌入层变量,因为这个变量是我们想要用来获取嵌入空间中新样本的参数的变量:
class Autoencoder(object):
def __init__(self, swissroll, swissroll_test, nb_intermediate,
learning_rate):
self.swissroll = swissroll
self.swissroll_test = swissroll_test
self.swissroll_tf = tf_create_variables()
intermediate_input = tf_create_dense_layer(self.swissroll_tf,
nb_intermediate)
intermediate_input = tf_create_dense_layer(intermediate_input,
nb_intermediate)
self.encoded = tf_create_dense_layer(intermediate_input, 2)
intermediate_output = tf_create_dense_layer(self.encoded,
nb_intermediate)
self.output = tf_create_dense_layer(intermediate_output, 3)
self.meansq = tf.reduce_mean(tf.squared_difference(
self.output, self.swissroll_tf))
self.train_step = tf.train
.GradientDescentOptimizer(learning_rate)
.minimize(self.meansq)
def train(self, display, n_epochs, batch_size, **kwargs):
n = len(self.swissroll)
with tf.Session() as sess:
sess.run(tf.global_variables_initializer())
for i in range(n_epochs):
permut = np.random.permutation(n)
for j in range(0, n, batch_size):
samples = permut[j:j+batch_size]
batch = self.swissroll[samples]
sess.run(self.train_step,
feed_dict={self.swissroll_tf: batch})
if i % step == step - 1:
print("Epoch :%i\n Loss %f" %\
(i, sess.run(self.meansq,
feed_dict={self.swissroll_tf: self.swissroll})))
error = sess.run(self.meansq,
feed_dict={self.swissroll_tf: self.swissroll})
error_test = sess.run(self.meansq,
feed_dict={self.swissroll_tf: self.swissroll_test})
if display:
pred = sess.run(self.encoded,
feed_dict={self.swissroll_tf : self.swissroll})
pred = np.asarray(pred)
recons = sess.run(self.output,
feed_dict={self.swissroll_tf : self.swissroll})
recons = np.asarray(recons)
recons_test = sess.run(self.output,
feed_dict={self.swissroll_tf : self.swissroll_test})
recons_test = np.asarray(recons_test)
print("Embedded manifold")
plot_2d(pred, colors)
save_png("swissroll_embedded")
plt.show()
print("Reconstructed manifold")
plot_3d(recons, colors)
save_png("swissroll_reconstructed")
plt.show()
print("Reconstructed test manifold")
plot_3d(recons_test, kwargs['colors_test'])
save_png("swissroll_test")
plt.show()
return error, error_test
我们可以运行这个自动编码器,并检查它是否也适用于新数据:
n = 5000
n_epochs = 2000
batch_size = 100
nb_intermediate = 20
learning_rate = 0.05
step = 100
params, swissroll = generate_swissroll(n)
params_test, swissroll_test = generate_swissroll(n)
colors = color_from_parameters(params)
colors_test = color_from_parameters(params_test)
model = Autoencoder(swissroll, swissroll_test,
nb_intermediate, learning_rate)
error, error_test = model.train(True, n_epochs, batch_size,
colors=colors, test=swissroll_test,
colors_test = colors_test)
…
Epoch :1599
Loss 0.001498
Epoch :1699
Loss 0.001008
Epoch :1799
Loss 0.000870
Epoch :1899
Loss 0.000952
Epoch :1999
Loss 0.000830
训练数据的嵌入空间很好,并且尊重我们用来生成swissroll
的配色方案。我们可以在下图中看到它的表示:
这里有趣的一点是,参数空间并不直接链接到我们用来创建数据的参数。幅度不同,每次新跑都会得到一个新的嵌入空间。我们可以在均方成本函数中添加正则化,就像我们在回归一章中所做的那样。
关键一点是检查输出数据是否与输入数据匹配。我们看到损失很低。测试数据还显示重建误差较低,但目视检查有时是一件好事。下图显示了图形表示:
我们可以看到,与最初的瑞士卷相比,有一些颠簸和不连续。在重建过程中添加第二层将有助于减少这种情况;我们在这里这样做不是为了表明我们不必为自动编码器使用对称神经网络。
摘要
在本章中,您了解到,有时,您可以使用特征选择方法来删除完整的特征。我们还看到,在某些情况下,这是不够的,我们必须采用特征投影方法来揭示我们数据中的真实和低维结构,希望模型使用起来更容易。
当然,我们只是触及了大量可用降维方法的表面。尽管如此,我们希望我们能让你对这整个领域感兴趣,因为还有很多其他方法等着你去发现。归根结底,特征选择和投影是一门艺术,就像选择正确的学习方法或训练模型一样。
在第 6 章、聚类–寻找相关帖子中,我们将介绍聚类,这是一种无监督的学习技术。我们将使用它来查找给定文本的类似新闻帖子。
六、聚类——查找相关帖子
直到现在,我们一直认为训练是学习一个把一些数据映射到一些标签的函数。对于本章中的任务,我们可能没有可以用来学习分类模型的标签。例如,这可能是因为它们太贵而无法收集。试想一下,如果获得数百万个标签的唯一方法是让人类手动注释这些标签,成本会有多大。在那种情况下我们能做什么?
我们在数据本身中找到一些模式。这就是我们在这一章要做的,我们再次考虑问答网站的挑战。当用户浏览我们的网站时,也许是因为他们在搜索特定的信息,搜索引擎很可能会给他们一个特定的答案。如果给出的答案不是他们想要的,网站应该给出(至少)相关的答案,这样他们就可以很快看到还有哪些其他答案,并希望留在我们的网站上。
天真的方法是简单地获取帖子,计算它与所有其他帖子的相似度,并将最相似的帖子作为链接显示在页面上。这将很快变得非常昂贵。相反,我们需要一种快速找到所有相关帖子的方法。
在本章中,我们将通过对从文本中提取的特征进行聚类来实现这个目标。聚类是一种排列项目的方法,使得相似的项目在一个聚类中,而不相似的项目在不同的聚类中。我们必须首先解决的棘手问题是如何将文本转化为我们可以用来计算相似度的东西。有了这样的相似性度量,我们将继续研究如何利用它来快速获得包含相似帖子的集群。一旦到了那里,我们只需要检查那些也属于那个集群的文档。为了实现这一点,我们将向您介绍奇妙的scikit
库,它附带了各种机器学习方法,我们也将在后面的章节中使用。
衡量职位的相关性
从机器学习的角度来看,原始文本毫无用处。如果我们设法把它转换成有意义的数字,我们就可以把它输入到我们的机器学习算法中,比如聚类。对于更普通的文本操作也是如此,比如相似性度量。
怎么不做呢
一种文本相似性度量是莱文斯坦距离,也称为编辑距离。假设我们有两个词,machine 和 mchiene。它们之间的相似性可以表示为将一个单词变成另一个单词所需的最小编辑集。在这种情况下,编辑距离将是两个,因为我们必须在m
之后添加一个a
,并删除第一个e
。然而,这种算法相当昂贵,因为它受到第一个单词的长度乘以第二个单词的长度的限制。
看看我们的帖子,我们可以通过将整个单词视为字符并在单词级别上执行编辑距离计算来作弊。假设我们有两个帖子叫做,如何格式化我的硬盘,以及硬盘格式化问题(为了简单起见,让我们假设这个帖子只包含标题)。我们将需要五个编辑距离,因为删除,如何,到,格式,我的,然后在最后添加格式和问题。因此,人们可以将两个帖子之间的差异表示为必须添加或删除的单词数量,以便一个文本变形为另一个文本。虽然我们可以加快整个方法的速度,但是时间复杂度保持不变。
但即使它足够快,还有另一个问题。在之前的帖子中,word format 占了两个编辑距离,因为先删除它,再添加它。因此,我们的距离似乎不够稳固,不足以考虑单词重排。
怎么做
比编辑距离更稳健的是所谓的包词法。它忽略了单词的顺序,简单地使用单词计数作为它们的基础。对于帖子中的每个单词,它的出现都被计算并记录在一个向量中。不出意外,这一步也叫矢量化。向量通常很大,因为它包含的元素和整个数据集中出现的单词一样多。前面提到的两个示例帖子将具有以下字数:
| 字 | 1 号岗位发生的事件 | 岗位 2 发生情况 |
| 唱片 | one | one |
| 格式 | one | one |
| 怎么 | one | Zero |
| 困难的 | one | one |
| 我的 | one | Zero |
| 问题 | Zero | one |
| 到 | one | Zero |
第 2 篇文章中出现的列和第 1 篇文章中出现的列现在可以被视为向量。我们可以简单地计算所有帖子的向量之间的欧几里得距离,并取最近的一个(太慢了,正如我们之前发现的)。因此,我们可以在后面的聚类步骤中使用它们作为特征向量,具体过程如下:
- 从每个帖子中提取显著特征,并将其存储为每个帖子的向量
- 对向量进行聚类
- 确定有问题的帖子的群
- 从这个集群中,获取一些与所讨论的帖子具有不同相似性的帖子。这将增加多样性
但是在我们到达那里之前还有一些工作要做。在我们做这项工作之前,我们需要一些数据。
预处理–相似性以相似数量的常用词来衡量
正如我们之前看到的,单词包方法既快速又健壮。然而,这并非没有挑战。让我们直接进入它们。
将原始文本转换为单词包
我们不需要编写自定义代码来计算单词,并将这些计数表示为向量。Scikit 的CountVectorizer
方法,工作效率高,而且界面非常方便:
>>> 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, 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
决定的。例如,它将交叉验证分为交叉验证和验证。这个过程也称为标记化。
现在让我们忽略其他参数,考虑下面两个示例主题行:
>>> content = ["How to format my hard disk",
" Hard disk format problems "]
我们现在可以将这个主题行列表放入我们的矢量器的fit_transform()
功能中,该功能完成所有困难的矢量化工作:
>>> X = vectorizer.fit_transform(content)
>>> vectorizer.get_feature_names()
['disk', 'format', 'hard', 'how', 'my', 'problems', 'to']
矢量器检测到七个单词,我们可以分别获取它们的计数:
>>> print(X.toarray().transpose())
[[1 1]
[1 1]
[1 1]
[1 0]
[1 0]
[0 1]
[1 0]]
这意味着第一句包含除了问题之外的所有单词,而第二句包含除了 how、my 和 to 之外的所有单词。事实上,这些列与我们在上表中看到的相同。从X
中,我们可以提取一个特征向量,我们将使用它来比较两个文档。
我们将首先从一个天真的方法开始,指出一些我们必须考虑的预处理特性。让我们选择一个随机的帖子,然后为它创建计数向量。然后,我们将它的距离与所有计数向量进行比较,并获取最小的一个。
数词
让我们来玩玩具数据集,由以下帖子组成:
| 发布文件名 | 发文内容 |
| 01.txt
| 这是一个关于机器学习的玩具帖子。实际上,它包含的有趣的东西并不多 |
| 02.txt
| 成像数据库会变得很庞大 |
| 03.txt
| 大多数成像数据库会永久保存图像 |
| 04.txt
| 成像数据库存储图像 |
| 05.txt
| 成像数据库存储图像 |
在这个帖子数据集中,我们希望为短帖子成像数据库找到最相似的帖子。
假设帖子位于"data/toy"
目录下(请查看 Jupyter 笔记本),我们可以用它来喂养CountVectorizer
:
>>> from pathlib import Path # for easy path management
>>> TOY_DIR = Path('data/toy')
>>> posts = []
>>> for fn in TOY_DIR.iterdir():
... with open(fn, 'r') as f:
... posts.append(f.read())
...
>>> 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())
['about', 'actually', 'capabilities', 'contains', 'data', 'databases', 'images', 'imaging', 'interesting', 'is', 'it', 'learning', 'machine', 'most', 'much', 'not', 'permanently', 'post', 'provide', 'save', 'storage', 'store', 'stuff', 'this', 'toy']
现在我们可以向量化我们的新帖子:
>>> new_post = "imaging databases"
>>> new_post_vec = vectorizer.transform([new_post])
注意transform
方法返回的计数向量是稀疏的,这是合适的格式,因为数据本身也是稀疏的。也就是说,每个向量不会为每个单词存储一个计数值,因为大多数计数值都为零(帖子不包含该单词)。相反,它使用了内存效率更高的实现方式coo_matrix
(用于坐标)。例如,我们的新帖子实际上只包含两个元素:
>>> 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
def dist_raw(v1, v2):
delta = v1-v2
return scipy.linalg.norm(delta.toarray())
norm()
函数计算欧几里德范数(最短距离)。这只是一个显而易见的第一选择,还有很多更有趣的方法来计算距离。只需看看 Python 论文源代码中两个列表或集合之间的论文距离系数,其中莫里斯·凌很好地呈现了 35 个不同的列表或集合。
有了dist_raw
,我们只需要遍历所有帖子,记住最近的一个即可。由于我们将在整本书中使用它,让我们定义一个便利函数,该函数以矢量化的形式获取当前数据集和新帖子以及距离函数,并打印出距离函数工作情况的分析:
def best_post(X, new_vec, dist_func):
best_doc = None
best_dist = float('inf') # infinite value as a starting point
best_i = None
for i, post in enumerate(posts):
if post == new_post:
continue
post_vec = X.getrow(i)
d = dist_func(post_vec, new_vec)
print("=== Post %i with dist=%.2f:n '%s'" % (i, d, post))
if d < best_dist:
best_dist = d
best_i = i
print("n==> Best post is %i with dist=%.2f" % (best_i, best_dist))
当我们执行为best_post(X_train, new_post_vec, dist_raw)
时,我们可以在输出中看到这些帖子以及它们各自到新帖子的距离:
=== 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
恭喜,我们有了第一个相似性度量。Post 0
和我们的新帖子最不一样。可以理解的是,它与新帖子没有一个共同的词。我们也可以理解Post 1
和新帖很像,但不是赢家,因为它比新帖没有包含的Post 3
多了一个字。
然而看着Post 3
和Post 4
,画面就没那么清晰了。Post 4
同Post 3
重复三次。所以,它也应该和Post 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 / scipy.linalg.norm(v1.toarray())
v2_normalized = v2 / scipy.linalg.norm(v2.toarray())
delta = v1_normalized - v2_normalized
return scipy.linalg.norm(delta.toarray())
当使用best_post(X_train, new_post_vec, dist_norm)
执行时,这导致以下相似性测量:
=== 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》。在新帖子里没有的词中,我们有最多的,保存,图像,和永久的。他们对这个职位的整体重要性大不相同。像大多数这样的词经常出现在各种不同的上下文中,被称为停止词。它们携带的信息不多,因此不应该像图像等词汇那样被高度重视,因为这些词汇在不同的语境中并不经常出现。最好的选择是删除所有频繁出现的单词,因为它们无法帮助我们区分不同的文本。这些词被称为停止词。
由于这是文本处理中常见的步骤,因此CountVectorizer
中有一个简单的参数来实现:
>>> vect_engl = CountVectorizer(min_df=1, stop_words='english')
如果你清楚地知道你想删除什么类型的停止词,你也可以传递一个列表。将stop_words
设置为english
将使用一组 318 个英语停止词。要找出哪些,可以使用get_stop_words()
:
>>> sorted(vect_engl.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']
新单词列表轻了七个单词:
>>> X_train_engl = vect_engl.fit_transform(posts)
>>> num_samples_engl, num_features_engl = X_train_engl.shape
>>> print(vect_engl.get_feature_names())
['actually', 'capabilities', 'contains', 'data', 'databases', 'images', 'imaging', 'interesting', 'learning', 'machine', 'permanently', 'post', 'provide', 'save', 'storage', 'store', 'stuff', 'toy']
在丢弃停止词之后,我们得出以下相似性度量:
>>> best_post(X_train_engl, new_post_vec_engl, dist_norm)
=== 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
Post 2
现在和Post 1
不相上下。但是,由于我们的岗位很短,仅用于演示目的,总体上没有太大变化。当我们查看真实世界的数据时,这将变得至关重要。
堵塞物
还有一件事没说。我们把不同变体中相似的词算作不同的词。例如,Post 2 包含成像和图像。把它们算在一起是有意义的。毕竟,他们指的是同一个概念。
我们需要一个将单词简化为特定词干的函数。默认情况下,Scikit 不包含词干分析器。有了自然语言工具包 ( NLTK ,我们可以下载一个免费的软件工具包,它提供了一个词干分析器,我们可以很容易地插入CountVectorizer
。
安装和使用 NLTK
NLTK 是一个简单的pip install nltk
之外。
要检查安装是否成功,请打开 Python 解释器并键入:
>>> import nltk
You will find a very nice tutorial on NLTK in the book *Python 3 Text Processing with NLTK 3 Cookbook *by Jacob Perkins, published by Packt Publishing.
To play around a little bit with a stemmer, you can visit the web page http://text-processing.com/demo/stem/.
NLTK 自带不同的词干。这是必要的,因为每种语言都有一套不同的词干规则。对于英语,我们可以取SnowballStemmer
:
>>> import nltk.stem
>>> s = nltk.stem.SnowballStemmer('english')
>>> s.stem("graphics")
'graphic'
>>> s.stem("imaging")
'imag'
>>> s.stem("image")
'imag'
>>> s.stem("imagination")
'imagin'
>>> s.stem("imagine")
'imagin'
The stemming does not necessarily have to result in valid English words.
它也适用于动词:
>>> s.stem("buys")
'buy'
>>> s.stem("buying")
'buy'
这意味着它大部分时间都有效:
>>> s.stem("bought")
'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))
vect_engl_stem = StemmedCountVectorizer(min_df=1, stop_words='english')
这将为每个帖子执行以下过程:
- 将预处理步骤中的原始帖子小写(在父类中完成)。
- 在标记化步骤中提取所有单个单词(在父类中完成)。
- 将每个单词转换成词干版本(在我们的
build_analyzer
中完成)。
因此,我们现在少了一个功能,因为图像和成像合二为一:
['actual', 'capabl', 'contain', 'data', 'databas', 'imag', 'interest', 'learn', 'machin', 'perman', 'post', 'provid', 'save', 'storag', 'store', 'stuff', 'toy']
在我们的帖子上运行我们的新词干向量器,我们看到折叠的图像和图片揭示了实际上,Post 2
是与我们的新帖子最相似的帖子,因为它包含了两次概念图片:
=== 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
停止使用类固醇
既然我们已经有了一个合理的方法来从一篇嘈杂的文本帖子中提取一个紧凑的向量,让我们后退一步,思考一下特征值实际上意味着什么。
特征值只是计算文章中出现的术语。我们默默地假设一个术语的较高值也意味着该术语对给定的职位更重要。但是,举例来说,主题这个词呢,它自然出现在每一篇文章中(主题:...)?好的,我们也可以通过max_df
参数告诉CountVectorizer
将其删除。例如,我们可以将其设置为0.9
,这样所有帖子中超过 90%的单词都将被忽略。但是出现在 89%的帖子中的单词呢?我们愿意把max_df
设多低?问题是,无论我们如何设定,总会有这样一个问题:有些术语比其他术语更具歧视性。
这只能通过计算每个帖子的词条频率来解决,此外,还要对许多帖子中出现的词条频率进行折扣。换句话说,如果某个术语经常出现在某个特定的岗位上,而很少出现在其他地方,那么我们就希望该术语在某个给定的值中具有较高的值。
这正是术语频率-逆文档频率 ( TF-IDF )的作用。TF 代表计算部分,而 IDF 在折扣中考虑了因素。一个天真的实现看起来像这样:
def tfidf(term, doc, corpus):
tf = doc.count(term) / len(doc)
idf = np.log(float(len(corpus)) / (len([d for d in corpus if term in d])))
tf_idf = tf * idf
print("term='%s' doc=%-17s tf=%.2f idf=%.2f tf*idf=%.2f"%
(term, doc, tf, idf, tf_idf))
return tf_idf
您可以看到,我们不仅对术语进行了简单的计数,还按照文档长度对计数进行了标准化。这样,较长的文档不会比较短的文档有不公平的优势。当然,为了快速计算,我们会将 IDF 计算移出函数,因为它对所有文档都是相同的值。
对于以下文档,D
,由三个已经标记化的文档组成,我们可以看到这些术语是如何被区别对待的,尽管每个文档出现的频率都相同:
>>> a, abb, abc = ["a"], ["a", "b", "b"], ["a", "b", "c"]
>>> D = [a, abb, abc]
>>> print("=> tfidf=%.2f" % tfidf("a", a, D))
term='a' doc=['a'] tf=1.00 idf=0.00
=> tfidf=0.00
>>> print("=> tfidf=%.2f" % tfidf("a", abb, D))
term='a' doc=['a', 'b', 'b'] tf=0.33 idf=0.00
=> tfidf=0.00
>>> print("=> tfidf=%.2f" % tfidf("a", abc, D))
term='a' doc=['a', 'b', 'c'] tf=0.33 idf=0.00
=> tfidf=0.00
>>> print("=> tfidf=%.2f" % tfidf("b", abb, D))
term='b' doc=['a', 'b', 'b'] tf=0.67 idf=0.41
=> tfidf=0.27
>>> print("=> tfidf=%.2f" % tfidf("b", abc, D))
term='b' doc=['a', 'b', 'c'] tf=0.33 idf=0.41
=> tfidf=0.14
>>> print("=> tfidf=%.2f" % tfidf("c", abc, D))
term='c' doc=['a', 'b', 'c'] tf=0.33 idf=1.10
=> tfidf=0.37
我们看到a
对于任何文件都没有意义,因为它无处不在。b
这个词对于文件abb
比abc
更重要,因为它在那里出现了两次。
实际上,要处理的角落案例比前面的例子要多。多亏了 scikit,我们不必去想它们,因为它们已经很好地封装在TfidfVectorizer
中了,而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))
vect_tfidf = StemmedTfidfVectorizer(stop_words='english')
生成的文档向量将不再包含计数。相反,它们将包含每个术语的单个 TF-IDF 值。
我们的成就和目标
我们当前的文本预处理阶段包括以下步骤:
- 首先,标记文本
- 接下来是扔掉那些经常出现的对检测相关帖子没有任何帮助的词
- 扔掉那些很少出现的单词,这样它们就很少有机会出现在未来的帖子中
- 数着剩下的单词
- 最后,从计数中计算 TF-IDF 值,考虑整个文本语料库
我们可以再次祝贺自己。通过这个过程,我们能够将一堆有噪声的文本转换成特征值的简洁表示。
但是,尽管单词包方法及其扩展简单而强大,但它也有一些缺点,我们应该意识到:
- 不覆盖字关系:用前面提到的矢量化方法,文字、车撞墙、墙撞车,都会有相同的特征向量
- 它没有正确捕捉否定:比如文本,我会吃冰淇淋,我不会吃冰淇淋,通过它们的特征向量看起来非常相似,虽然它们包含完全相反的意思。然而,这个问题可以很容易地通过不仅计算单个单词(也称为 unigrams),而且考虑二元模型(单词对)或三元模型(连续三个单词)来缓解
- 对于拼错的单词它完全失败了:虽然对我们来说很明显,数据库和 databas 传达了相同的意思,但是我们的方法会将它们视为完全不同的单词
为了简洁起见,让我们继续使用当前的方法,我们现在可以使用它来高效地构建集群。
使聚集
最后,我们有了我们的向量,我们相信它在足够的程度上捕捉了帖子。毫不奇怪,有许多方法可以将它们组合在一起。对聚类算法进行分类的一种方法是区分平面聚类和层次聚类。
平面聚类将帖子分成一组聚类,而不将聚类相互关联。目标只是想出一个分区,使得一个集群中的所有帖子彼此最相似,而与所有其他集群中的帖子不相似。许多平面聚类算法要求预先指定聚类的数量。
在分层聚类中,不必指定聚类的数量。相反,层次聚类创建了聚类的层次结构。当相似的帖子被分组到一个集群中时,相似的集群再次被分组到一个超级集群中。例如,在凝聚聚类方法中,这是递归进行的,直到只剩下一个包含所有内容的聚类。在这个层次结构中,人们可以在事后选择期望的集群数量。然而,这是以较低的效率为代价的。
Scikit 在sklearn.cluster
包中提供了广泛的聚类方法。您可以在http://scikit-learn.org/stable/modules/clustering.html快速了解它们各自的优缺点。
在接下来的部分中,我们将使用平面聚类方法 K-means。
k 均值
K-means 是应用最广泛的平面聚类算法。在用期望数量的簇num_clusters
初始化它之后,它保持所谓的簇形心的数量。最初,它会选择任何num_clusters
帖子,并将质心设置为它们的特征向量。然后它将遍历所有其他帖子,并为它们分配最近的质心作为它们当前的簇。接下来,它会将每个质心移动到该特定类的所有向量的中间。这当然会改变集群分配。一些哨所现在更靠近另一个集群。因此,它将更新那些已更改帖子的分配。只要质心有相当大的移动,就可以做到这一点。在一些迭代之后,移动将下降到阈值以下,我们认为聚类是收敛的。
让我们用一个只包含两个单词的帖子的玩具例子来演示一下。下图中的每个点代表一个文档:
在运行一次 K-means 迭代后,即以任意两个向量为起点,将标签分配给其余的向量,并将聚类中心更新为该聚类中所有点的中心点,我们得到以下聚类:
由于群集中心已移动,我们必须重新分配群集标签并重新计算群集中心。迭代 2 之后,我们得到以下聚类:
箭头显示了集群中心的移动。经过十次迭代。如下例截图所示,集群中心不再明显移动(scikit 的容差阈值默认为 0.0001):
聚类稳定后,我们只需要记下聚类中心和它们的聚类号。对于每个新的文档,我们必须向量化,并与所有集群中心进行比较。与我们的新帖子向量距离最小的聚类中心属于我们将分配给新帖子的聚类。
获取测试数据来评估我们的想法
为了测试聚类,让我们远离玩具文本示例,找到一个类似于我们未来期望的数据的数据集,这样我们就可以测试我们的方法。出于我们的目的,我们需要关于已经分组在一起的技术主题的文档,以便我们可以在稍后将其应用于我们希望收到的帖子时检查我们的算法是否如预期那样工作。
机器学习中的一个标准数据集是20newsgroup
数据集,它包含来自 20 个不同新闻组的 18,826 篇文章。这些小组的话题中有技术性的,如comp.sys.mac.hardware
或sci.crypt
,也有更多与政治和宗教相关的,如talk.politics.guns
或soc.religion.christian
。我们将仅限于技术组。如果我们假设每个新闻组是一个集群,我们可以很好地测试我们寻找相关帖子的方法是否有效。
The dataset can be downloaded from http://people.csail.mit.edu/jrennie/20Newsgroups.
为方便起见,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')
>>> 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
我们现在有了一个3529
帖子池,并为每个帖子提取了一个4712
维度的特征向量。这就是 K-means 作为输入的内容。本章我们将把集群大小固定在50
上,希望你有足够的好奇心尝试不同的值作为练习:
>>> num_clusters = 50
>>> from sklearn.cluster import KMeans
>>> km = KMeans(n_clusters=num_clusters, n_init=1, verbose=1, random_state=3)
>>> km.fit(vectorized)
就这样。我们提供了一个随机状态,这样你就可以得到相同的结果。在现实应用程序中,您不会这样做。拟合后,我们可以得到km
成员的聚类信息。对于每个已经拟合的矢量化帖子,在km.labels_
中有一个对应的整数标签:
>>> print("km.labels_=%s" % km.labels_)
km.labels_=[48 23 31 ..., 6 2 22]
>>> print("km.labels_.shape=%s" % km.labels_.shape)
km.labels_.shape=3529
可以通过km.cluster_centers_
访问集群中心。
在下一节中,我们将看到如何使用km.predict
将集群分配给新到达的帖子。
解决我们最初的挑战
现在,我们将把所有的东西放在一起,并为我们分配给new_post
变量的以下新帖子演示我们的系统:
new_post = '''
Disk drive problems. Hi, I have a problem with my hard disk.
After 1 year it is working only sporadically now.
I tried to format it, but now it doesn't boot any more.
Any ideas? Thanks. '''
正如你之前所学,在你预测
它的标签之前,你首先必须向量化这篇文章:
>>> 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 = scipy.linalg.norm((new_post_vec - vectorized[i]).toarray())
... similar.append((dist, train_data.data[i]))
>>> similar = sorted(similar)
>>> print("Count similar: %i" % len(similar))
Count similar: 56
我们在帖子的群中找到了56
个帖子。为了让用户快速了解什么样的类似帖子可用,我们现在可以展示最相似的帖子(show_at_1
)和两个不太相似但仍然相关的帖子,它们都来自同一个集群:
>>> show_at_1 = similar[0]
>>> show_at_2 = similar[len(similar) // 10]
>>> show_at_3 = similar[len(similar) // 2]
下表显示了帖子及其相似度值:
| 位置 | 相似度 | 节选自帖子 |
| one | One point zero three eight | 集成开发环境控制器的启动问题嗨,我有一个多输入/输出卡(集成开发环境控制器+串行/并行接口)和两个软驱(5 1/4,3 1/2)以及一个连接到它的量子驱动器 80AT。我可以格式化硬盘,但无法从它启动。我可以从驱动器 A 引导:(哪个磁盘驱动器不重要)但是如果我从驱动器 A 中取出磁盘并按下重置开关,驱动器 A:的 LED 会继续发光,硬盘根本无法访问。我猜这一定是多 I/O 卡或软盘驱动器设置的问题(跳线配置?)有人知道这可能是什么原因吗。[...] |
| Two | One point one five | 从 B 驱动器启动我有一个 5 1/4 英寸的驱动器作为驱动器 a。如何让系统从我的 3 1/2 英寸驱动器 B 启动?(最理想的情况是,计算机能够从 A 或 B 引导,检查它们以获得可引导磁盘。但是,如果我必须切换电缆,只需切换驱动器,这样它就无法引导 5 个 1/4 英寸的磁盘,这没关系。另外,boot_b 对我来说也没用。[...][...] |
| three | One point two eight | IBM PS/1 vs TEAC FD 你好,我已经尝试了我们的国家新闻集团,但没有成功。我试图用一个普通的 TEAC 驱动器替换一个朋友 PS/1-PC 中的原始 IBM 软盘。我已经确定了针脚 3 (5V)和 6 (12V)上的电源,短路了针脚 6 (5.25 英寸/3.5 英寸开关),并在针脚 8、26、28、30 和 34 上插入了上拉电阻(2K2)。电脑不会抱怨 FD 不见了,但是 FD 的灯一直亮着。驱动器旋转正常。当我插入磁盘,但无法访问它时。TEAC 在普通电脑上运行良好。有没有我漏掉的点?[...][...] |
有趣的是,帖子反映了相似性度量分数。第一篇文章包含了我们新文章中所有的关键词。第二个也是围绕着引导问题,但是是关于软盘而不是硬盘。最后,第三个问题既不是关于硬盘,也不是关于引导问题。尽管如此,所有的帖子,我们会说,属于同一个领域的新帖子。
再看看噪音
我们不应该期望完美的聚类,因为来自同一个新闻组(例如,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.UKnSubject: test....(sorry)nOrganization: The University of Birmingham, United KingdomnLines: 1nNNTP-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_df
和max_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(%-10s) = %.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
因此,具有最高辨别能力的术语birmingham
和kingdom
显然与计算机图形无关,IDF 分数较低的术语也是如此。可以理解的是,来自不同新闻组的帖子将聚集在一起。
然而,对于我们的目标来说,这没什么大不了的,因为我们只对减少我们必须与新帖子进行比较的帖子数量感兴趣。毕竟,我们的训练数据来自的特定新闻组并没有什么特别的兴趣。
调整参数
其他参数呢?例如,我们可以调整集群的数量,或者使用矢量器的max_features
参数(你应该试试!).此外,我们可以使用不同的集群中心初始化。然后还有比 K-means 本身更令人兴奋的替代品。例如,有一些聚类方法允许您使用不同的相似性度量,如余弦相似性、皮尔逊相似性或雅克卡相似性。一个让你兴奋的地方。
但是在你去那里之前,你必须更好地定义你真正的意思。Scikit 有一个完整的包,专门用于这个定义。这个包叫做sklearn.metrics
,它还包含了一系列不同的度量来衡量聚类质量。也许这应该是现在要做的第一件事——直接进入度量包的来源。
摘要
这是一个艰难的旅程——我们讨论了聚类前的预处理,以及一个可以将有噪声的文本转换成有意义的简洁矢量表示的解决方案,我们可以对其进行聚类。如果我们看看我们必须做些什么才能最终实现集群化,这是整个任务的一半以上。但是在路上,我们学到了很多关于文本处理的知识,以及简单的计数如何让你在嘈杂的真实数据中走得更远。
不过,由于 scikit 及其强大的包装,这一旅程变得更加顺畅。还有更多要探索的。在这一章中,我们只是触及了它能力的表面。在第七章、推荐中,我们会构建一个推荐系统,我们会看到它更多的力量。
七、推荐
推荐已经成为在线服务和商业的主要内容之一。这种类型的自动化系统可以为每个用户提供个性化的建议列表(无论是要购买的产品列表、要使用的功能还是新的连接)。在本章中,我们将看到自动推荐生成系统的基本工作方式。基于消费者输入生成推荐的领域通常被称为协作过滤,因为用户通过系统协作来为彼此找到最佳项目。
在本章的第一部分,我们将看到如何利用消费者过去的产品评级来预测新的评级。我们从一些有用的想法开始,然后把它们结合起来。当组合它们时,我们使用回归来学习组合它们的最佳方式。这也将允许我们探索机器学习中的一个通用概念:集成学习。
在本章的第二部分,我们将看看一个不同的学习方法建议:篮子分析。与我们有数字评级的情况不同,在购物篮分析设置中,我们所拥有的只是关于购物篮的信息,也就是说,哪些物品是一起购买的。目标是了解推荐。你可能已经看过类似这样的推荐,网购买 X 的人也买了 Y、。我们将开发自己的类似功能。总之,本章将涵盖以下内容:
- 通过预测产品评级建立推荐系统的不同方法。
- 堆叠作为一种组合多种预测的方式。这是一种结合机器学习方法的通用技术。
- 购物篮分析和关联规则挖掘,仅根据一起消费的项目来构建预测。
评级预测和建议
如果你在过去 10 年中使用过任何在线购物系统,你可能已经看到过推荐。有些像亚马逊,买了 X 的客户也买了 Y ,功能。这些将在篮子分析部分讨论。其他推荐是基于预测产品的评级,例如电影。
基于过去产品评级的学习推荐问题因网飞奖而出名,这是网飞发起的一项耗资百万美元的机器学习公开挑战。网飞是一家电影流媒体公司。这项服务的一个显著特点是,它让用户可以选择对他们看过的电影进行评分。然后,网飞利用这些评分向其客户推荐其他电影。在这个机器学习问题中,你不仅有关于用户看了哪些电影的信息,还有关于用户如何评价它们的信息。
2006 年,网飞在其数据库中提供了大量电影的客户评级,以应对公开挑战。目标是改进他们内部的评级预测算法。谁能赢 10%或更多,谁就能赢得 100 万美元。2009 年,一个名为 BellKor's 务实混乱的国际团队能够打破这一纪录并获奖。他们这样做的 20 分钟前,另一个团队,合奏团,也通过了 10%的分数——一个持续了几年的比赛令人兴奋的照片结束。
Machine learning in the real world:
Much has been written about the Netflix Prize, and you may learn a lot by reading up on it. The techniques that won were a mixture of advanced machine learning and a lot of work put into preprocessing the data. For example, some users like to rate everything very highly, while others are always more negative; if you do not account for this in preprocessing, your model will suffer. Other normalizations were also necessary for a good result, bearing in mind factors such as the film's age and how many ratings it received. Good algorithms are a good thing, but you always need to get your hands dirty and tune your methods to the properties of the data you have in front of you. Preprocessing and normalizing the data is often the most time-consuming part of the machine-learning process. However, this is also the place where one can have the biggest impact on the final performance of the system.
关于网飞奖,首先要注意的是它有多难。粗略地说,网飞使用的内部系统比完全没有推荐要好 10%(也就是说,给每部电影分配所有用户的平均值)。目标是在此基础上再提高 10%。总的来说,获胜的系统只比没有个性化好 20%。然而,实现这一目标花费了大量的时间和精力,尽管 20%似乎不多,但结果是一个在实践中有用的系统。
不幸的是,由于法律原因,该数据集不再可用。虽然数据集是匿名的,但有人担心可能会发现谁是客户,并泄露电影租赁的私人细节。然而,我们可以使用具有相似特征的学术数据集。这些数据来自明尼苏达大学的研究实验室 GroupLens。
如何解决网飞式的收视率预测问题?我们将研究两种不同的方法:邻域方法和回归方法。我们还将看到如何结合这些方法来获得一个单一的预测。
分为培训和测试
在高层次上,将数据集拆分为训练和测试数据,以便获得系统性能的原则性估计,其执行方式与我们在前面章节中看到的方式相同:我们获取一定比例的数据点(我们将使用 10%),并将其保留用于测试;其余的将用于训练。
但是,因为在这种情况下数据的结构不同,所以代码也不同。在我们探索的一些模型中,当我们传输数据时,留出 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)
我们现在使用标准random
模块选择要测试的指数:
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。我们甚至可以从 scikit-learn 的TransformerMixin
类中派生出一个fit_transform
方法:
from sklearn.base import TransformerMixin
class NormalizePositive(TransformerMixin):
我们要选择正常化的轴。默认情况下,我们沿着第一个轴进行标准化,但有时沿着第二个轴进行标准化会很有用。这遵循了许多其他 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
方法,顾名思义,它结合了fit
和transform
操作:
def fit_transform(self, features):
return self.fit(features).transform(features)
我们定义的方法(fit
、transform
、transform_inverse
和fit_transform
)与sklearn.preprocessing
模块中定义的对象相同。在接下来的部分中,我们将首先归一化输入,生成归一化预测,最后应用逆变换来获得最终预测。
推荐的邻域方法
邻域概念可以通过两种方式实现:用户邻域或电影邻域。用户社区基于一个非常简单的概念:了解用户对电影的评价,找到与他们最相似的用户,并查看他们的评价。我们暂时只考虑用户邻居。在本节的最后,我们将讨论如何修改代码来计算电影邻居。
我们现在将探索的一个有趣的技术是只看每个用户给哪些电影评分,甚至不看给了什么评分。即使有一个二元矩阵,当用户评价一部电影时,我们有一个等于 1 的条目,当他们不评价时,我们有一个等于 0 的条目,我们也可以做出有用的预测。事后看来,这是完全有道理的——我们不会完全随机地选择要看的电影,而是选择那些我们已经期望喜欢的电影。我们也不会随机选择给哪些电影评分,但可能只会给那些我们感觉最强烈的电影评分(当然,也有例外,但平均来说,这可能是真的)。
我们可以将矩阵的值可视化为图像,其中每个评级都被描绘为一个小方块。黑色代表没有评级,灰色级别代表评级值。
可视化数据的代码非常简单(您可以对其进行调整,以显示比本书中更大的矩阵部分),如以下代码所示:
from matplotlib import pyplot as plt
# Build an instance of the object we defined previously
norm = NormalizePositive(axis=1)
binary = (train > 0)
train = norm.fit_transform(train)
# plot just 200x200 area for space reasons
fix, ax = plt.subplots()
ax.imshow(binary[:200, :200], interpolation='nearest')
下面的截图是这段代码的输出:
我们可以看到矩阵是稀疏的——大多数正方形是黑色的。我们还可以看到,一些用户对电影的评分比其他人高得多,一些电影的评分比其他电影高得多。
我们现在要用这个二元矩阵来预测电影的收视率。一般算法的计算(伪代码)如下:
- 对于每个用户,根据接近程度对其他用户进行排名。对于这一步,我们将使用二进制矩阵,并使用相关性作为接近度的度量(将二进制矩阵解释为 0 和 1 允许我们执行这一计算)。
- 当我们需要估计一个用户(电影对)的评分时,我们会查看所有给该电影评分的用户,并将他们分成两组:最相似的一半和最不相似的一半。然后,我们使用最相似的一半的平均值作为预测。
我们可以使用scipy.spatial.distance.pdist
函数来获取所有用户之间的距离,作为一个矩阵。该函数返回相关距离,该距离通过反转相关值来转换相关值,因此较大的数字意味着它们不太相似。数学上,相关距离为 1-r ,其中 r 为相关值。代码如下:
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
。这些是最像它的用户。我们使用以下代码选择这些neighbors
:
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[n_u, m]
# Only use valid entries:
revs = revs[binary[n_u, 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)
我们可以使用我们在讨论回归时了解到的相同指标(第 2 章、用真实世界的例子进行分类)。回想一下 r 评分范围从0
(预测不比基线好)到1
(预测完美)。为了方便起见,我们经常用百分比来表示(从0
到100
):
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%
前面的代码为用户neighbors
计算结果。也就是说,当试图对用户-电影对进行预测时,它会查看对同一部电影进行评分的相似用户,并对他们进行平均。我们可以用同样的代码通过变换输入矩阵来计算电影neighbors
。也就是说,现在我们将寻找由同一用户评分的类似电影,并对它们的评分进行平均。
在在线代码库中,推荐代码被包装在一个名为predict_positive_nn
的函数中,所以我们可以用转置矩阵来调用它,最后转置结果:
predicted = predict_positive_nn(train.T).T
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%
我们可以看到结果并没有那么不同。
建议的回归方法
邻域的一种替代方法是将推荐表述为回归问题,并应用我们在第 6 章、聚类-查找相关帖子中学习的方法。
我们首先考虑为什么这个问题不适合分类公式。我们当然可以尝试学习一个五级模型,对每个可能的电影等级使用一个等级。然而,这种方法有两个问题:
- 不同的可能错误完全不同。例如,把一部 5 星电影误认为 4 星电影并不像把一部 5 星电影误认为 1 星电影那样严重
- 中间值有意义。即使我们的输入只是整数值,说预测是 4.3 也是完全有意义的。我们可以看到,这是一个不同于 3.5 的预测,即使它们都四舍五入到 4
这两个因素加在一起意味着分类并不适合这个问题。回归框架更适合。
对于一个基本的方法,我们再次有两个选择:我们可以构建特定于电影或特定于用户的模型。在我们的案例中,我们将首先构建用户特定的模型。这意味着,对于每个用户,我们将用户评价的电影作为我们的目标变量。输入是其他用户的评分。我们假设这将为与我们的用户相似的用户提供高价值(或者为喜欢我们的用户不喜欢的相同电影的用户提供负价值)。
设置train
和test
矩阵的方法与之前相同(包括运行标准化步骤)。因此,我们直接跳到学习步骤:
- 首先,我们实例化一个
regression
对象如下(回想一下,在第 2 章、用真实世界的例子分类中,我们已经得出结论,具有自动参数搜索的弹性网是一个很好的通用回归方法):
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%
如前所述,我们可以通过使用转置矩阵来修改这段代码以执行电影回归(有关这方面的示例,请参见伴随代码库)。
结合多种方法
我们现在将上述方法结合成一个单一的预测。从直觉上看,这似乎是一个好主意,但我们如何在实践中做到这一点?也许首先想到的是我们可以对预测进行平均。这可能会给出不错的结果,但没有理由认为所有估计的预测都应该被同等对待。可能是其中一个比其他的好。
我们可以尝试加权平均,将每个预测乘以给定的权重,然后将所有预测相加。但是,我们如何找到最佳重量呢?我们当然是从数据中学习的!
Ensemble learning:
We are using a general technique in machine learning that is not just applicable in regression: ensemble learning. We learn an ensemble (that is, a set) of predictors. Then, we combine them to obtain a single output. What is interesting is that we can see each prediction as being a new feature, and we are now just combining features based on training data, which is what we have been doing all along. Note that we are doing this for regression here, but the same reasoning is applicable to classification: you learn several classifiers, then a master classifier, which takes the output of all of them and gives a final prediction. Different forms of ensemble learning differ in how you combine the base predictors.
为了结合这些方法,我们将使用一种称为堆叠学习的技术。想法是你学习一组预测器,然后你使用这些预测器的输出作为另一个预测器的特征。您甚至可以有几个层,其中每个层通过使用前一层的输出作为其预测的特征来学习。请看下图:
为了适合这个组合模型,我们将训练数据分成两部分。或者,我们可以使用交叉验证(最初的堆叠学习模型是这样工作的)。然而,在这种情况下,我们有足够的数据,通过留出一些来获得良好的估计。
就像我们在拟合超参数时一样,我们需要两层训练/测试分割:第一层,更高级别的分割,然后在训练分割内部,第二层分割可以适合堆叠的学习者。这类似于我们在使用内部交叉验证循环查找超参数值时如何使用多级交叉验证:
train,test = get_train_test(random_state=12)
# Now split the training again into two subgroups
tr_train,tr_test = load_ml100k.get_train_test(train)
tr_predicted0 = predict_positive_nn(tr_train)
tr_predicted1 = predict_positive_nn(tr_train.T).T
tr_predicted2 = predict_regression(tr_train)
tr_predicted3 = predict_regression(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],
]).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(),
]).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%
堆叠学习的结果比任何单一的方法都要好。很典型的情况是,组合方法是获得少量性能提升的简单方法,但结果并不惊天动地。
通过灵活地组合多种方法,我们可以简单地尝试任何我们想要的想法,方法是将它添加到学习者的组合中,并让系统将其折叠到预测中。例如,我们可以替换最近邻码中的邻域准则。
然而,我们必须小心不要过度填充数据集。事实上,如果我们随机尝试太多的东西,其中一些会在特定的数据集上运行良好,但不会一概而论。即使我们在分割数据,我们也没有严格地交叉验证我们的设计决策。为了有一个好的估计,如果数据丰富,你应该保留一部分数据不动,直到你有一个最终的模型即将投入生产。然后,在这些数据上测试你的模型将会给你一个不偏不倚的预测,告诉你它在现实世界中会有多好的表现。
Of course, collaborative filtering also works with neural networks, but don't forget to keep validation data available for the testing—or, more precisely, validating—your ensemble model.
篮子分析
到目前为止,当你对用户喜欢一个产品的程度进行数字评分时,我们所研究的方法效果很好。这种类型的信息并不总是可用的,因为它需要消费者的主动行为。
篮子分析是学习推荐的一种替代模式。在这种模式下,我们的数据只包含一起购买的物品;它不包含任何关于单个项目是否被欣赏的信息。即使用户有时会购买他们后悔的商品,平均来说,知道他们的购买会给你足够的信息来建立好的推荐。获得这些数据往往比评级数据更容易,因为许多用户不会提供评级,而购物篮数据是作为购物的副作用产生的。下面的截图向您展示了亚马逊网站上托尔斯泰经典著作《战争与和平》的网页片段,展示了使用这些结果的常见方式:
当然,这种学习模式不仅适用于购物篮。它适用于任何一种情况,在这种情况下,您将一组对象放在一起,需要推荐另一组对象。例如,向用户推荐额外的收件人写电子邮件是由 Gmail 完成的,并且可以使用类似的技术来实现(我们不知道 Gmail 内部使用哪些方法;也许他们结合了多种技术,就像我们之前做的那样)。或者,我们可以使用这些方法开发一个应用程序,根据您的浏览历史推荐要访问的网页。即使我们正在处理采购,将客户的所有采购组合到一个篮子中也是有意义的,无论这些项目是一起购买的还是在单独的交易中购买的。这取决于业务环境,但请记住,这些技术是灵活的,在许多情况下都是有用的。
Beer and diapers:
One of the stories that is often mentioned in the context of basket analysis is the diapers and beer story. When supermarkets first started to look at their data, they found that diapers were often bought together with beer. Supposedly, it was the father who would go out to the supermarket to buy diapers and would then pick up some beer as well. There has been much discussion of whether this is true or just an urban myth. In this case, it seems that it is true. In the early 1990s, Osco Drug discovered that, in the early evening, beer and diapers were bought together, and it did surprise the managers who had, until then, never considered these two products to be similar. What is not true is that this led the store to move the beer display closer to the diaper section. Also, we have no idea whether it was really true that fathers were buying beer and diapers together more than mothers (or grandparents).
获得有用的预测
不仅仅是顾客买了 X 也买了 Y ,尽管这是很多在线零售商的推荐用语(见前面给出的 Amazon.com 截图);一个真正的系统不能这样工作。为什么不呢?因为这样的系统会被频繁购买的商品所迷惑,并且会简单地推荐没有任何个性化的流行商品。
例如,在一家超市,许多顾客每次购物都会买面包,或者几乎每次都买(为了论证,我们假设 50%的访问以购买面包结束)。所以,如果你专注于任何特定的物品,比如洗碗机肥皂,看看经常用洗碗机肥皂买的东西,你可能会发现面包经常用洗碗机肥皂买。事实上,只是偶然的机会,假设 50%的情况下,当有人购买洗碗机肥皂时,他们会购买面包。然而,面包经常和其他东西一起买,只是因为人们经常买面包。
我们真正要找的是购买了 X 的客户比没有购买 X 的普通客户更有可能购买 Y 。如果你买洗碗机肥皂,你可能会买面包,但不会超过基线。同样,一家书店,不管你已经买了哪些书,只要简单地推荐畅销书,就不能很好地个性化推荐。
分析超市购物筐
举个例子,我们来看看比利时一家超市的匿名交易构成的dataset
。这个dataset
是由哈塞尔特大学的汤姆·布里斯提供的。出于隐私考虑,数据已被匿名化,因此我们对每个产品只有一个数字,因此每个篮子都由一组数字组成。数据文件可从几个在线来源获得(包括本书的配套网站)。
我们首先加载数据集并查看一些统计数据(这总是一个好主意):
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
我们可以看到下表中总结的结果计数:
| 购买次数 | 产品数量 |
| 就一次 | Two thousand two hundred and twenty-four |
| 2 或 3 | Two thousand four hundred and thirty-eight |
| 4 至 7 岁 | Two thousand five hundred and eight |
| 8 至 15 岁 | Two thousand two hundred and fifty-one |
| 16 岁至 31 岁 | Two thousand one hundred and eighty-two |
| 32 至 63 岁 | One thousand nine hundred and forty |
| 64 至 127 | One thousand five hundred and twenty-three |
| 128 至 511 | One thousand two hundred and twenty-five |
| 512 或更多 | One hundred and seventy-nine |
有很多产品只被买过几次。例如,33%的产品被购买四次或更少。然而,这仅占购买量的 1%。这种很多产品只被少量购买的现象,有时被贴上长尾的标签,随着互联网使得小众商品的库存和销售变得更加便宜,这种现象才变得更加突出。为了能够为这些产品提供建议,我们需要更多的数据。
有一些篮子分析算法的开源实现,但是没有一个能很好地与 scikit-learn 或我们一直在使用的任何其他包集成。因此,我们将自己实现一个经典算法。这个算法叫做 Apriori 算法,有点老(由 Rakesh Agrawal 和 Ramakrishnan Srikant 在 1994 年发表),但它仍然有效(算法当然永远不会停止工作;他们只是被更好的想法所取代)。
Apriori 算法获取一组集合(即您的购物篮),并将非常频繁的集合作为子集返回(即一起成为许多购物篮一部分的项目)。
该算法使用自下而上的方法工作:从最小的候选(由单个元素组成的候选)开始,逐步积累,一次添加一个元素。该算法取一组篮子和应该考虑的最小输入(一个我们称之为minsupport
的参数)。第一步是考虑所有的篮子,只有一个元件,支撑最小。然后,以各种可能的方式将它们结合起来,构建二元篮子。这些被过滤,以便只保留那些支持最少的。然后,考虑所有可能的三元篮,保留那些具有最小支撑的,以此类推。Apriori 的诀窍是,当构建一个更大的篮子时,它只需要考虑那些由更小的集合构建的篮子。
下图给出了算法的示意图:
我们现在用代码实现这个算法。我们需要定义我们寻求的最低支持:
minsupport = 100
支持是一组产品一起购买的次数。
Apriori 的目标是找到高支持度的项目集。从逻辑上讲,任何具有超过最小支持的项目集只能由本身至少具有最小支持的项目组成:
valid = set(k for k,v in counts.items()
if (v >= minsupport))
我们最初的itemsets
是单线态(具有单一元素的集合)。特别是,所有至少得到最低限度支持的单身者都很频繁itemsets
:
itemsets = [frozenset([v]) for v in valid]
我们需要使用以下代码设置一个索引来加快计算速度:
baskets = defaultdict(set)
for i, ds in enumerate(dataset):
for ell in ds:
baskets[ell].add(i)
也就是说,baskets[i]
包含数据集中出现i
的所有元素的索引。现在,我们的循环如下:
itemsets = [frozenset([v]) for v in valid]
freqsets = []
for i in range(16):
print(i)
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)
candidates = set()
for elem in c:
candidates.update(baskets[elem])
support_c = sum(1 for d in candidates \
if dataset[d].issuperset(c))
if support_c > minsupport:
nextsets.append(c)
freqsets.extend(nextsets)
itemsets = nextsets
if not len(itemsets):
break
print("Finished!")
Finished!
Apriori 算法返回频繁的itemsets
,即出现在某个阈值以上的篮子(由代码中的minsupport
变量给出)。
关联规则挖掘
频繁项集本身并不是很有用。下一步是建立关联规则。因为这个最终目标,篮网分析的整个领域有时被称为关联规则挖掘。
关联规则是一种类型的陈述,如果 X ,那么Y—例如,如果客户购买了战争与和平,那么他们将购买安娜·卡列尼娜。注意规则不是确定性的(并不是所有买 X 的客户都会买 Y ,但总是拼出来就比较麻烦了:如果一个客户买了 X ,他们比基线更有可能买Y;因此,我们说如果 X ,那么 Y ,但我们指的是概率意义上的。
有趣的是,前因和结论都可能包含多个对象:购买了 X 、 Y 、 Z 的客户也购买了 A 、 B 、 C 。多个前因可能会让你做出比单个项目更具体的预测。
只要尝试所有可能的 X 暗示 Y 的组合,你就可以从一个频繁的集合中得到一个规则。很容易生成许多这样的规则。然而,你只想要有价值的规则。因此,我们需要衡量一个规则的价值。一种常用的测量方法叫做升力。升力是通过应用规则获得的概率和基线之间的比率,如下式所示:
在上式中, P(Y) 是包含 Y 的所有交易的分数,而 P(Y|X) 是包含 Y 的交易的分数,因为它们还包含 X 。使用电梯有助于避免推荐畅销书的问题;对于一本畅销书来说, 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))
下表显示了一些结果。计数是指仅包含结果(即购买该产品的基本价格)的交易数量、前因中的所有项目以及前因和后果中的所有项目:
| 先行 | 结果 | 后续计数 | 先行计数 | 先行和后续计数 | 抬起 |
| 1378, 1379, 1380
| One thousand two hundred and sixty-nine | 279 人(0.3%) | Eighty | Fifty-seven | Two hundred and twenty-five |
| 48, 41, 976
| One hundred and seventeen | 1026 人(1.1%) | One hundred and twenty-two | Fifty-one | Thirty-five |
| 48, 41, 1,6011
| Sixteen thousand and ten | 1316 人(1.5%) | One hundred and sixty-five | One hundred and fifty-nine | Sixty-four |
例如,我们可以看到,在 80 笔交易中,1378、1379 和 1380 是一起购买的。其中 57 也包括 1269,所以估计的条件概率是 57/80 ≈ 71 百分比。相比之下,所有交易中只有 0.3%包含 1269,这让我们提升了 255。
为了能够做出相对可靠的推断,需要在这些计数中有相当数量的事务,这就是为什么我们必须首先选择频繁项集。如果我们从一个不经常出现的项目集中生成规则,那么数量将非常少;因此,相对值将毫无意义(或者受到非常大的误差线的影响)。
请注意,从这个数据集中发现了更多的关联规则:该算法发现了 1030 个规则(要求支持至少 80 个篮子,最小提升量为 5)。与现在的网络相比,这仍然是一个很小的数据集。对于包含数百万个事务的数据集,您可以预期生成数千个规则,甚至数百万个。
然而,对于每个客户或产品,在任何给定的时间,只有几个规则是相关的。所以每个客户只收到少量的推荐。
更高级的篮子分析
现在有其他算法比 Apriori 运行得更快。我们之前看到的代码很简单,对我们来说已经足够好了,因为我们只有大约 100,000 个事务。如果我们有几百万,也许值得使用更快的算法。不过,请注意,学习关联规则通常可以在离线状态下完成,在离线状态下,效率并不是很重要。
您还可以使用一些方法来处理时间信息,从而产生考虑到您购买顺序的规则。举个例子,有人为一个大型聚会购买用品,可能会回来拿垃圾袋。第一次去的时候提议用垃圾袋可能是有道理的。然而,向每个购买垃圾袋的人提议派对用品是没有意义的。
摘要
在本章中,我们从使用回归进行评级预测开始。我们看到了两种不同的方法,然后通过学习一组权重将它们组合成一个预测。这种集成学习技术——特别是堆叠学习——是一种可以在许多情况下使用的通用技术,而不仅仅是用于回归。它允许你组合不同的想法,即使它们的内部机制完全不同——你可以组合它们的最终输出。
在这一章的后半部分,我们转换了话题,看了另一种产生推荐的模式:购物篮分析,或关联规则挖掘。在这种模式下,我们试图发现购买 X 的客户可能对 Y 感兴趣的形式的(概率)关联规则。这利用了仅从销售中生成的数据,而不需要用户对项目进行数字评分。这在 scikit-learn 中暂时没有,所以我们编写了自己的代码。
如果你正在使用关联规则挖掘,那么你需要注意不要简单地向每个用户推荐畅销书(否则,个性化的意义何在?).为了做到这一点,我们学习了测量规则相对于基线的值,使用了一种称为规则提升的度量。
在第八章、人工神经网络和深度学习中,我们将最终用 TensorFlow 深入学习。我们将学习它的应用编程接口,然后继续学习卷积网络(以及它们如何彻底改变图像处理),然后学习递归网络。
八、人工神经网络与深度学习
神经网络正在引领当前的机器学习趋势。无论是 Tensorflow、Keras、CNTK、PyTorch、Caffee 还是其他任何包,它们目前都取得了其他算法很少取得的成果,尤其是在图像处理等领域。随着快速计算机和大数据的出现,20 世纪 70 年代设计的神经网络算法现在可以使用了。即使在十年前,最大的问题是你需要大量的训练数据,而这些数据是不可用的,同时,即使你有足够的数据,训练模型所需的时间也太多了。这个问题现在差不多解决了。
这些年的主要改进是神经网络架构。用于更新神经网络的反向传播算法与以前大致相同,但是结构已经有了许多改进,例如卷积层而不是密集层,或者,长短期记忆 ( LSTM )用于规则递归层。
下面是我们将遵循的计划:首先深入了解 TensorFlow 及其 API,然后我们将它应用于卷积神经网络进行图像处理,最后我们将处理递归神经网络(特别是被称为 LSTM 的味道)进行图像处理和文本处理。
谈论机器学习速度主要是关于神经网络速度。为什么呢?因为神经网络基本上是矩阵乘法和并行数学函数——图形处理器非常擅长的模块。
使用张量流
我们已经看到了一些使用 TensorFlow 的例子,现在是时候更多地了解它是如何工作的了。
首先,这个名字来源于这样一个事实,即张量流使用张量(二维以上的矩阵)进行所有计算。所有函数都作用于这些对象,返回张量或行为类似张量的运算,并为所有对象定义新的名称。名字的第二部分来自张量之间数据流动的基础图。
神经网络的灵感来自于大脑的工作方式,但它并不像神经网络使用的模型那样工作。是的,每个神经元都与许多其他神经元相连,但输出不是输入乘以转换矩阵加上激活函数内部反馈的偏置的乘积。此外,神经网络具有层次(深度学习指的是具有多个所谓的隐藏层的神经网络,意味着既没有输入也没有输出),其体系结构具有严格的进展。大脑在各处都有联系并持续进化,而神经网络对于给定的输入和给定的时刻总是有稳定的输出(直到我们得到一个新的节拍,正如我们将在循环网络中看到的)。
现在让我们深入了解一下 TensorFlow 图形应用编程接口。
TensorFlow API
最好的开始方式是看一下编程环境:
我们显然对 Python 堆栈感兴趣,我们将主要关注层和度量。数据集很有趣,但其中许多来自外部贡献,有些是针对移除的。scikit learn API 被认为是更面向未来的,所以我们就不看了。
估计器是更高层次的 API,但是它们没有 scikit 学习到的那样发达。当我们开发新的网络时,能够调试它们并检查它们肠道内的信息在中间的应用编程接口中比在顶部的应用编程接口中更容易,尽管所有张量都有名字的事实使得在估计器应用编程接口之外获得这些信息成为可能。
很多在线教程仍然直接使用较低的 API,我们在我们的回归示例中通过直接调用tf.matmult
来使用它。我们认为使用中级或高级应用编程接口比其他应用编程接口更好,即使它们有时看起来更灵活,更接近您认为的需求。
图形
正如我们从张量流的定义中看到的,图是张量流的核心。默认图包含对象(占位符、变量或常量)之间的结构以及这些对象的类型(变量例如是可训练变量,所有可训练变量都可以通过调用tf.trainable_variables()
来检索)。
可以通过使用with
构造来更改默认图形:
g = tf.Graph()
with g.as_default():
c = tf.constant("Node in g")
因此,每次我们调用 TensorFlow 函数时,我们都会向默认图中添加节点(无论我们是否在一个块中)。一张图表本身没有任何作用。当我们创建新图层、使用度量或创建占位符时,实际上不会执行任何操作。我们唯一要做的就是在图上添加节点。
一旦我们有了一个有趣的图,我们需要在所谓的会话中执行。这是 TensorFlow 实际执行代码的唯一地方,也是可以从图中检索值的唯一地方。
Just as for the graph, variables, placeholders, and so on will be put on the best possible device. This device will be the CPU for all platforms, at the time of writing, that's Linux for HIP-capable AMD GPUs or nVidia GPU for Linux and Windows.
可以使用以下命令将它们固定在特定设备上:
with tf.device("/device:CPU:0"):
图形的不同部分使用相同的名称有时很有趣。为了实现这一点,我们可以使用name_scope
为名称加上路径前缀。当然,它们可以递归使用:
var = tf.constant([0, 1, 2, 3])
with tf.name_scope("section"):
mean = tf.reduce_mean(var)
会议
是时候多学一点关于会话的知识了。正如我们前面看到的,TensorFlow 只执行会话中的操作。
会话最简单的用法如下:
with tf.Session() as sess:
sess.run(tf.global_variables_initializer())
sess.run([mean], feed_dict={})
这将通过在会话中调用变量的初始值来初始化变量。在神经网络中,我们不能让所有变量都以零值开始,尤其是不同层中的权重(有些,如偏差,可以初始化为 0)。初始值设定项是最重要的,要么直接为显式变量设置,要么在调用中级函数时是隐式的(参见 TensorFlow 中可用的不同初始值设定项,并在我们所有的示例中使用它们)。
TensorFlow 还使用一种外部方式来获取报告,称为摘要。它们生活在tf.summary
模块中,可以跟踪张量,并对它们进行预处理:
tf.summary.histogram(var)
tf.summary.scalar('mean', tf.reduce_mean(var))
然后,所有这些摘要报告都可以在会话运行期间写入和检索,并由一个特殊对象保存:
merged = tf.summary.merge_all()
writer = tf.summary.FileWriter(path/to/log-directory)
with tf.Session() as sess:
summary, _ = sess.run([merged, train_step], feed_dict={})
writer.add_summary(summary, i)
Tensorboard is a tool provided with TensorFlow that allows us to display these summaries. It can be launched with tensorboard --logdir=path/to/log-directory
.
如果我们不想直接使用会话,可以使用估计器类。
从一个估计量,我们可以调用它的方法训练,它把一个数据生成器和我们想要运行的步骤数作为一个参数。例如,这可能是:
def input_fn():
features = {'SepalLength': np.array([6.4, 5.0]),
'SepalWidth': np.array([2.8, 2.3]),
'PetalLength': np.array([5.6, 3.3]),
'PetalWidth': np.array([2.2, 1.0])}
labels = np.array([2, 1])
return features, labels
estimator.train(input_fn=input_fn , steps=STEPS)
同样,我们可以使用 test 从模型中获得结果:
estimator.test(input_fn=input_train)
如果你想使用比张量流已经提供的简单估算器更多的东西,我们建议遵循张量流网站上的教程:https://www.tensorflow.org/get_started/custom_estimators。
有用的操作
在所有之前的张量流模型中,我们遇到了在张量流中创建层的函数。有几层或多或少是不可避免的。
第一个是tf.dense
,将所有输入连接到一个新的图层。我们在自动编码器示例中看到了它们,它们将张量(变量,占位符...)然后units
输出单位的数量。默认情况下,它也有偏差,这意味着图层计算inputs * weights + bias
。
我们后面会看到的另一个重要的层是conv2d
。它计算图像上的卷积,这一次需要filters
来指示输出图层中的节点数量。这就是卷积神经网络的定义。以下是卷积的常用公式:
The standard name for the tensor of coefficients in the convolution is called a kernel.
让我们看看其他几层:
dropout
会在训练阶段随机放一些权重到零。这在复杂的深度学习网络中非常重要,以防止它过度拟合。我们稍后还会看到。max_pooling2d
是卷积层非常重要的补充。它选择二维形状上输入的最大值。还有一个一维版本,在密集层之后工作。
所有层都有一个activation
参数。这种激活将线性运算转化为非线性运算。让我们看看tf.nn
模块中最有用的:
正如我们之前看到的,scikit learn 提供了许多计算精度、曲线等的指标。TensorFlow 在tf.metrics
模块中提供了类似的操作。
保存和恢复神经网络
有两种方法可以存储训练好的神经网络以备将来使用,然后恢复它。我们将在卷积神经网络的例子中看到它们实现了这一点。
第一个住在tf.train
。它是用以下语句创建的:
saver = tf.train.Saver(max_to_keep=10)
然后每个训练步骤都可以保存为:
saver.save(sess, './classifier', global_step=step)
这里保存了完整的图形,但可能只保存了其中的一部分。我们在这里保存所有内容,并且只保留最后 10 次保存,并且我们用我们所在的步骤来后缀保存的名称。
假设我们用saver.save(sess, './classifier-final')
保存了最后的训练步骤。我们知道,我们首先必须恢复图形状态:
new_saver = tf.train.import_meta_graph("classifier-final.meta")
这没有恢复变量状态,为此我们不得不调用:
new_saver.restore(sess, tf.train.latest_checkpoint('./'))
Be aware that only the graph is restored. If you have Python variables pointing to nodes in this graph, you need to restore them before you can use them. This is true for placeholders and operations.
我们还必须恢复一些变量:
graph = tf.get_default_graph()
training_tf = graph.get_tensor_by_name('is_training:0')
这也是对所有张量(占位符、运算等)使用专有名称的一个很好的理由,因为我们需要在恢复图形时使用它们的名称来再次获得对它们的引用。
另一种机制建立在这种机制的基础上,功能更强大,但我们将展示模仿简单机制的基本用法。我们首先创建通常被称为builder
的东西:
builder = tf.saved_model.builder.SavedModelBuilder(export_dir)
The export_dir
folder is created by the builder here. If it already exists, you have to remove it before creating a new saved model.
现在训练结束后,我们可以称之为拯救网络的状态:
builder.add_meta_graph_and_variables(sess, [tf.saved_model.tag_constants.TRAINING])
显然,我们可以在这个对象中保存多个网络,具有更多的属性,但是,在我们的例子中,我们只需要调用一个函数来恢复状态:
tf.saved_model.loader.load(sess, [tf.saved_model.tag_constants.TRAINING], export_dir)
训练神经网络
我们没怎么讨论过训练神经网络。基本上所有的优化都是梯度下降,问题是我们要用什么步长,要不要考虑之前的梯度?
当计算一个梯度时,还有一个问题,我们是只对一个新样本这样做,还是同时对多个样本(批次)这样做。基本上,我们几乎从不一次只输入一个样本(随着批次大小的变化,所有占位符都有一个设置为None
的第一维,表示它将是动态的)。
这也要求创建一个特殊的层batch_normalization
,它可以缩放梯度(向上或向下,这样层可以以有意义的方式更新,因此批量大小在这里很重要),并且在一些网络架构中,它将是强制性的。这一层还有两个学习参数,即均值和标准差。如果它们不重要,可以实现一个更简单的批处理标准化层,并将在第 12 章、计算机视觉中的示例中使用。
我们之前用的优化器是GradientDescentOptimizer
。这是一个简单的梯度下降与固定的步骤。这是非常脆弱的,因为步长严重依赖于数据集和我们使用的模型。
另一个非常重要的是AdamOptimizer
。它是目前最有效的优化器之一,因为它基于前一个缩放新的梯度(试图模仿成本函数减少的牛顿方法的 hessian 缩放)。
另一个值得一提的是RMSPropOptimizer
。这里,额外的技巧是动量。动量表示新渐变在新渐变之上使用了先前渐变的一部分。
The size of the gradient step, or learning rate, is crucial. The selection of an adequate for it often requires some know-how. The rate must be small enough so that the optimizations makes the network better, but big enough to have efficient first iterations. The improvement of the training is supposed to be fast for the first iterations and then improve slowly (it is globally fitting an often requires some know-how. The rate must be small enough so that the optimizations makes the network better, but big enough to have efficient first iterations. The improvement of the training is supposed to be fast for the first iterations and then improve slowly (it is globally fitting an e-t curve).
To avoid over-generalization, it is sometimes advised to stop optimization early (called early stopping), when the improvements are slow. In this context, using collaborative filtering can also achieve better results.
Additional information can be found at http://ruder.io/optimizing-gradient-descent/.
卷积神经网络
不到十年前,神经网络在图像处理方面还不是最好的。除了数据和中央处理器的能力,原因是研究人员使用了密集的层。当堆叠几个层和连接几千个像素到一千个隐藏单元的密集层时,我们最终得到了一个非凸成本函数来优化,该函数有数百万个参数。
因此,维度的诅咒是一个非常大的问题,即使是最大的数据库也可能不够。但是让我们回到引言。机器学习不仅仅是训练一个模型,它也是关于特征处理的。在图像处理中,人们使用许多不同的工具来从图像中提取特征,但是所有这些预处理工作流的一个常见工具是过滤。
现在,让我们回到神经网络。如果我们能把这些过滤器植入神经网络呢?那么问题就是要知道哪些过滤器是最好的。这就是卷积网络的用武之地:卷积层创建特征,然后密集层完成它们的工作(分类、回归等等)。
不像密集层那样有数百万个系数,我们创建一个输出像素的图像,每个像素有固定数量的单位。然后,这些单元中的每一个都有固定数量的权重,并且它们对于输出图像中的所有像素都是相同的。当我们在输出图像中从一个像素移动到另一个像素时,我们也在输入图像中以相同的方式移动连接(可能是一个步幅):
所以一个 conv2d 图层的权重有一个维度[kernel_size_1, kernel_size_2, filters]
维度的核,如果考虑权重的数量,这个核是非常小的!这远远少于一千个数字,而不是超过一百万个。这是可以训练的,甚至可以通过查看这些权重来查看哪些特征与我们提出的问题相关。我们应该能够看到像梯度滤波器、索贝尔滤波器或者看曲线的滤波器这样简单的东西。
既然我们有了所有这些零件,我们就可以把它们放在一起了。我们将再次尝试使用手数字数据集,并将这些图像分为 10 类(每个数字一类)。我们还将保存训练好的模型,并用前面看到的两种方法进行恢复。
让我们从一些简单的导入和一些超参数开始:
import tensorflow as tf
import numpy as np
from sklearn.model_selection import train_test_split
n_epochs = 10
learning_rate = 0.0002
batch_size = 128
export_dir = "data/classifier-mnist"
image_shape = [28,28,1]
step = 1000
dim_W3 = 1024
dim_W2 = 128
dim_W1 = 64
dropout_rate = 0.1
我们将训练神经网络 10 个时期(因此我们将通过完整的训练数据集10
次),我们将使用0.0002
的学习率,128
的批次大小(因此我们将一次用128
图像训练模型),然后我们将使用64
卷积滤波器,然后是128
滤波器,最后是最后一层中的1024
节点,在最后 10 个节点之前,这将给出我们的分类结果。最后,1,024 节点层还将有一个速率为0.1
的丢弃部分,这意味着在训练期间,我们将始终在该层任意将 102 节点输出设置为 0:
from sklearn.datasets import fetch_mldata
mnist = fetch_mldata('MNIST original')
mnist.data.shape = (-1, 28, 28)
mnist.data = mnist.data.astype(np.float32).reshape( [-1, 28, 28, 1]) / 255.
mnist.num_examples = len(mnist.data)
mnist.labels = mnist.target.astype(np.int64)
X_train, X_test, y_train, y_test = train_test_split(mnist.data, mnist.labels, test_size=(1\. / 7.))
我们现在获得数据,重塑它,改变它的类型,并保留 60,000 张图像用于培训,10,000 张图像用于测试。标签将为int64
,因为这是我们将用于自定义检查功能的标签。我们不需要在一个热数组中转换标签,因为张量流已经有了一个处理这个问题的函数。无需添加超出要求的处理!
Why a four-dimension matrix? The first dimension, -1
, is our batch size, and it will be dynamic. The second two dimensions are for the width and height of our image. The final one is the number of input channels, here just 1
.
让我们创建我们的卷积神经网络类:
class CNN():
def __init__(
self,
image_shape=(28,28,1)
dim_W3=1024,
dim_W2=128,
dim_W1=64,
classes=10
):
self.image_shape = image_shape
self.dim_W3 = dim_W3
self.dim_W2 = dim_W2
self.dim_W1 = dim_W1
self.classes = classes
我们为我们的CNN
创建一个类,并在本地保存一些我们之前设置的参数:
def create_conv2d(self, input, filters, kernel_size, name):
layer = tf.layers.conv2d(
inputs=input,
filters=filters,
kernel_size=kernel_size,
activation=tf.nn.leaky_relu,
name="Conv2d_" + name,
padding="same")
return layer
这种方法使用我们之前看到的参数,以及filters
和kernel_size
来创建卷积层。我们将输出激活设置为一个泄漏的relu
,因为它为这些情况给出了很好的结果。
The padding
parameter can be same
or precise
. The second option relates to the convolution equation. When we don't want to have partial convolutions (on the edges of the image), this is the option we want to use.
def create_maxpool(self, input, name):
layer = tf.layers.max_pooling2d(
inputs=input,
pool_size=[2,2],
strides=2,
name="MaxPool_" + name)
return layer
最大池层也非常简单。我们希望在 2x2 像素范围内获得最大值,输出大小将是原始图像在所有方向上除以2
(因此步幅等于2
):
def create_dropout(self, input, name, is_training):
layer = tf.layers.dropout(
inputs=input,
rate=dropout_rate,
name="DropOut_" + name,
training=is_training)
return layer
我们在这个例子中介绍的脱落层有一个额外的参数,一个名为is_training
的占位符。当我们测试数据时(或当我们在训练后使用模型时),停用该层将非常重要:
def create_dense(self, input, units, name, is_training):
layer = tf.layers.dense(
inputs=input,
units=units,
name="Dense" + name,
)
layer = tf.layers.batch_normalization(
inputs=layer,
momentum=0,
epsilon=1e-8,
training=is_training,
name="BatchNorm_" + name,
)
layer = tf.nn.leaky_relu(layer, name="LRELU_" + name)
return layer
我们的致密层比普通层更复杂。我们在激活之前添加了一个batch_normalization
步骤,它将根据批次大小缩放我们的梯度。那里还有一个使用动量的选项,使得优化类似于RMSProp
:
def discriminate(self, image, training):
h1 = self.create_conv2d(image, self.dim_W3, 5, "Layer1”)
h1 = self.create_maxpool(h1, "Layer1")
h2 = self.create_conv2d(h1, self.dim_W2, 5, "Layer2")
h2 = self.create_maxpool(h2, "Layer2")
h2 = tf.reshape(h2, (-1, self.dim_W2 * 7 * 7))
h3 = self.create_dense(h2, self.dim_W1, "Layer3", train-ing)
h3 = self.create_dropout(h3, "Layer3", training)
h4 = self.create_dense(h3, self.classes, "Layer4”, train-ing)
return h4
现在我们已经有了网络的所有独立模块,我们可以将它们放在一起。所以会是:
让我们开始创建我们的模型:
def build_model(self):
image = tf.placeholder(tf.float32,
[None]+self.image_shape, name="image")
Y = tf.placeholder(tf.int64, [None], name="label")
training = tf.placeholder(tf.bool, name="is_training")
probabilities = self.discriminate(image, training)
cost = tf.reduce_mean(
tf.nn.sparse_softmax_cross_entropy_with_logits(labels=Y,
logits=probabilities))
accuracy = tf.reduce_mean(
tf.cast(tf.equal(tf.argmax(probabilities, axis=1), Y),
tf.float32), name=" accuracy")
return image, Y, cost, accuracy, probabilities, training
为输入图像添加一个占位符,为标签和训练添加另一个占位符,我们现在使用sparse_softmax_cross_entropy_with_logits
成本函数,它将单值labels
数组和名为logits
的张量(密集层的输出)作为参数。当我们一次只有一个活动标签时,这个功能非常好(例如,它非常适合分类,但不适用于图像注释)。
现在是时候使用这个新类了:
cnn_model = CNN(
image_shape=image_shape,
dim_W1=dim_W1,
dim_W2=dim_W2,
dim_W3=dim_W3,
)
image_tf, Y_tf, cost_tf, accuracy_tf, output_tf, training_tf =
cnn_model.build_model()
train_step = tf.train.AdamOptimizer(learning_rate,
beta1=0.5).minimize(cost_tf)
saver = tf.train.Saver(max_to_keep=10)
builder = tf.saved_model.builder.SavedModelBuilder(export_dir)
我们用它来实例化我们的优化器(这里是Adam
),并借此机会构建我们的模型序列化器:
accuracy_vec = []
with tf.Session() as sess:
sess.run(tf.global_variables_initializer())
for epoch in range(n_epochs):
permut = np.random.permutation(len(X_train))
print("epoch: %i" % epoch)
for j in range(0, len(X_train), batch_size):
if j % step == 0:
print(" batch: %i" % j)
batch = permut[j:j+batch_size]
Xs = X_train[batch]
Ys = y_train[batch]
sess.run(train_step,
feed_dict={
training_tf: True,
Y_tf: Ys,
image_tf: Xs
})
if j % step == 0:
temp_cost, temp_prec = sess.run([cost_tf, accura-cy_tf],
feed_dict={
training_tf: False,
Y_tf: Ys,
image_tf: Xs
})
print(" cost: %f\n prec: %f" % (temp_cost, temp_prec))
saver.save(sess, './classifier', global_step=epoch)
saver.save(sess, './classifier-final')
builder.add_meta_graph_and_variables(sess,
[tf.saved_model.tag_constants.TRAINING])
builder.save()
Epoch #-1
train accuracy = 0.068963
test accuracy = 0.071796
Result for the 10 first training images: [0 8 9 9 7 6 3 5 1 3]
Reference for the 10 first training images: [9 8 4 4 9 8 1 8 2 5]
epoch: 0
batch: 0
cost: 1.319493
prec: 0.687500
batch: 16000
cost: 0.452003
prec: 1.000000
batch: 32000
cost: 0.383446
prec: 1.000000
batch: 48000
cost: 0.392471
prec: 0.992188
Epoch #0
train accuracy = 0.991166
test accuracy = 0.986650
#...
Epoch #9
train accuracy = 0.999833
test accuracy = 0.991693
Result for the 10 first training images: [9 8 4 4 9 3 1 8 2 5]
Reference for the 10 first training images: [9 8 4 4 9 8 1 8 2 5]
这是我们在前面的例子中遵循的通常模式;我们刚刚添加了中间层的保存程序。请注意,构建器要求在会话结束后进行最后一次save()
调用。
不经过任何训练,算法的准确率在 1/10 左右,这是随机网络会达到的效果。经过 10 个时代,我们的训练和测试精度接近 1。让我们看看训练和测试错误是如何随之演变的:
from matplotlib import pyplot as plt
accuracy = np.array(accuracy_vec)
plt.semilogy(1 - accuracy[:,0], 'k-', label="train")
plt.semilogy(1 - accuracy[:,1], 'r-', label="test")
plt.title('Classification error per Epoch')
plt.xlabel('Epoch')
plt.ylabel('Classification error')
plt.legend()
plt.show()
参考下图:
显然,更多的训练时期会降低训练误差,但在少数时期后,测试误差(泛化能力)不会进化。这意味着没有必要在这上面花更多的时间。但也许改变一些参数会有所帮助?还是不同的激活功能?
当我们保存训练好的网络时,我们可以用两种方法恢复它:
tf.reset_default_graph()
new_saver = tf.train.import_meta_graph("classifier-final.meta")
with tf.Session() as sess:
new_saver.restore(sess, tf.train.latest_checkpoint('./'))
graph = tf.get_default_graph()
training_tf = graph.get_tensor_by_name('is_training:0')
Y_tf = graph.get_tensor_by_name('label:0')
image_tf = graph.get_tensor_by_name('image:0')
accuracy_tf = graph.get_tensor_by_name('accuracy:0')
output_tf = graph.get_tensor_by_name('LeakyRELU_Layer4/Maximum:0')
show_train(sess, 0) # Function defined in the support notebook
INFO:tensorflow:Restoring parameters from ./classifier-final
Epoch #0
train accuracy = 0.999833
test accuracy = 0.991693
Result for the 10 first training images: [9 8 4 4 9 3 1 8 2 5]
Reference for the 10 first training images: [9 8 4 4 9 8 1 8 2 5]
而第二种方法:
tf.reset_default_graph()
with tf.Session() as sess:
tf.saved_model.loader.load(sess, [tf.saved_model.tag_constants.TRAINING], export_dir)
graph = tf.get_default_graph()
training_tf = graph.get_tensor_by_name('is_training:0')
Y_tf = graph.get_tensor_by_name('label:0')
image_tf = graph.get_tensor_by_name('image:0')
accuracy_tf = graph.get_tensor_by_name('accuracy:0')
output_tf = graph.get_tensor_by_name('LeakyRELU_Layer4/Maximum:0')
show_train(sess, 0)
INFO:tensorflow:Restoring parameters from b'data/classifier-mnist/variables/variables'
Epoch #0
train accuracy = 0.999833
test accuracy = 0.991693
Result for the 10 first training images: [9 8 4 4 9 3 1 8 2 5]
Reference for the 10 first training images: [9 8 4 4 9 8 1 8 2 5]
它们都返回了与我们在训练后得到的相同的训练和测试错误,所以我们可以重用它来进行额外的分类。
现在是时候解决另一种类型的网络,递归神经网络。
递归神经网络
我们之前看到的所有网络都有一层向另一层提供数据,并且没有环路。循环网络自身循环,因此发生的情况是输出的新值也依赖于节点过去的内部状态及其输入。这可以总结为下图:
理论上,这些网络是可以训练的,但这是一项艰巨的任务,尤其是在文本预测中,当一个新单词可能依赖于早已消失的其他单词时(想想天空中的云,其中预测的单词 sky 依赖于过去三个单词的云)。
More information on this problem can be found by looking up "vanishing gradient in recurrent neural networks" on your favorite search engine.
因此,开发了没有这些问题的其他体系结构。最主要的一个叫 LSTM。这个自相矛盾的名字反映了它的工作原理。首先,它有两种内部状态,如下图所示:
Image adapted from: http://colah.github.io/posts/2015-08-Understanding-LSTMs/
内部状态是我们设置的输入和内部状态的非线性的混合。这方面有一些进展,但对于我们这里的应用来说已经足够好了。
如果我们把它和金融中使用的隐马尔可夫模型(AR(n)或更复杂的滤波器)进行比较,这个模型是非线性的。就像卷积层一样,LSTM 层将从输入信号中提取特征,然后密集层将做出最终决定(在我们的示例中是分类)。
LSTM 预测文本
我们对 LSTM 的第一个测试将是文本预测。我们的网络会学习一个短语中的下一个单词,就像我们在学校背一首诗一样。在这里,我们只是把它训练在一首小诗上,但是如果这样的网络是训练在一个作家的全文上,有更多的容量(所以有更多的层次,可能更大的层次),它可以学习他们的风格,像作家一样写作。
让我们存储我们的寓言:
text="""A slave named Androcles once escaped from his master and fled to the forest. As he was wandering about there he came upon a Lion lying down moaning and groaning. At first he turned to flee, but finding that the Lion did not pursue him, he turned back and went up to him.
As he came near, the Lion put out his paw, which was all swollen and bleeding, and Androcles found that a huge thorn had got into it, and was causing all the pain. He pulled out the thorn and bound up the paw of the Lion, who was soon able to rise and lick the hand of Androcles like a dog. Then the Lion took Androcles to his cave, and every day used to bring him meat from which to live.
But shortly afterwards both Androcles and the Lion were captured, and the slave was sentenced to be thrown to the Lion, after the latter had been kept without food for several days. The Emperor and all his Court came to see the spectacle, and Androcles was led out into the middle of the arena. Soon the Lion was let loose from his den, and rushed bounding and roaring towards his victim.
But as soon as he came near to Androcles he recognised his friend, and fawned upon him, and licked his hands like a friendly dog. The Emperor, surprised at this, summoned Androcles to him, who told him the whole story. Whereupon the slave was pardoned and freed, and the Lion let loose to his native forest."""
我们可以去掉标点符号并标记它:
training_data = text.lower().replace(",", "").replace(".", "").split()
我们现在可以通过索引单词,然后创建单词和整数之间的映射,将标记(或单词)转换为整数,反之亦然(也称为单词包)。我们还可以通过将表示文本的令牌数组转换为整数数组来获得一些时间,整数是映射到单词的索引:
def build_dataset(words):
count = list(set(words))
dictionary = dict()
for word, _ in count:
dictionary[word] = len(dictionary)
reverse_dictionary = dict(zip(dictionary.values(), diction-ary.keys()))
return dictionary, reverse_dictionary
dictionary, reverse_dictionary = build_dataset(training_data)
training_data_args = [dictionary[word] for word in training_data]
我们的主层RNN
不是 TensorFlow 的一部分,而是contrib
包的一部分。创建它需要多行,但它是不言自明的。我们最终得到了一个密集的层,其输出节点与令牌一样多:
import tensorflow as tf
tf.reset_default_graph()
from tensorflow.contrib import rnn
def RNN(x):
# Generate a n_input-element sequence of inputs
# (eg. [had] [a] [general] -> [20] [6] [33])
x = tf.split(x,n_input,1)
# 1-layer LSTM with n_hidden units.
rnn_cell = rnn.BasicLSTMCell(n_hidden)
# generate prediction
outputs, states = rnn.static_rnn(rnn_cell, x, dtype=tf.float32)
# there are n_input outputs but we only want the last output
return tf.layers.dense(inputs = outputs[-1], units = vocab_size)
x = tf.placeholder(tf.float32, [None, n_input])
y = tf.placeholder(tf.int64, [None])
让我们添加我们的hyper
参数:
import random
import numpy as np
vocab_size = len(dictionary)
# Parameters
learning_rate = 0.001
training_iters = 50000
display_step = 1000
# number of inputs (past words that we use)
n_input = 3
# number of units in the RNN cell
n_hidden = 512
我们准备好创建网络和我们的优化cost
功能:
pred = RNN(x)
cost = tf.reduce_mean(
tf.nn.sparse_softmax_cross_entropy_with_logits(logits=pred,
labels=y))
optimizer = tf.train.RMSPropOptimizer(
learning_rate=learning_rate).minimize(cost)
correct_pred = tf.equal(tf.argmax(pred,1), y)
accuracy = tf.reduce_mean(tf.cast(correct_pred, tf.float32))
我们开始训练吧:
with tf.Session() as session:
session.run(tf.global_variables_initializer())
step = 0
offset = random.randint(0,n_input+1)
end_offset = n_input + 1
acc_total = 0
loss_total = 0
while step < training_iters:
# Batch with just one sample. Add some randomness on se-lection process.
if offset > (len(training_data)-end_offset):
offset = random.randint(0, n_input+1)
symbols_in_keys = [ [training_data_args[i]]
for i in range(offset, offset+n_input) ]
symbols_in_keys = np.reshape(np.array(symbols_in_keys),
[1, n_input])
symbols_out_onehot = [training_data_args[offset+n_input]]
_, acc, loss, onehot_pred = session.run(
[optimizer, accu-racy, cost, pred],
feed_dict={x: sym-bols_in_keys, y: symbols_out_onehot})
loss_total += loss
acc_total += acc
if (step+1) % display_step == 0:
print(("Iter= %i , Average Loss= %.6f," +
" Average Accuracy= %.2f%%") %
(step+1, loss_total/display_step,
100*acc_total/display_step))
acc_total = 0
loss_total = 0
symbols_in = [training_data[i]
for i in range(offset, offset + n_input)]
symbols_out = training_data[offset + n_input]
symbols_out_pred = reverse_dictionary[
np.argmax(onehot_pred, axis=1)[0]]
print("%s - [%s] vs [%s]" %
(symbols_in, symbols_out, symbols_out_pred))
step += 1
offset += (n_input+1)
Iter= 1000 , Average Loss= 4.034577, Average Accuracy= 11.50%
['shortly', 'afterwards', 'both'] - [androcles] vs [to]
Iter= 2000 , Average Loss= 3.143990, Average Accuracy= 21.10%
['he', 'came', 'upon'] - [a] vs [he]
Iter= 3000 , Average Loss= 2.145266, Average Accuracy= 44.10%
['and', 'the', 'slave'] - [was] vs [was]
…
Iter= 48000 , Average Loss= 0.442764, Average Accuracy= 87.90%
['causing', 'all', 'the'] - [pain] vs [pain]
Iter= 49000 , Average Loss= 0.507615, Average Accuracy= 85.20%
['recognised', 'his', 'friend'] - [and] vs [and]
Iter= 50000 , Average Loss= 0.427877, Average Accuracy= 87.10%
['of', 'androcles', 'like'] - [a] vs [a]
准确性远非很好,但对于一个小的试验来说,它已经相当有趣了。通过使用pred
功能,我们可以要求网络基于前面会话中的三个输入单词生成一个新单词:
symbols_in_keys = [ [‘causing’], [‘all’], [‘the’]]
symbols_in_keys =
np.reshape(np.array(symbols_in_keys), [1, n_input])
onehot_pred = session.run(pred, feed_dict={x: sym-bols_in_keys})
print(“Estimate is: %s” %
reverse_dictionary[np.argmax(onehot_pred, axis=1)[0]])
Estimate is: pain
一个有趣的问题是,如果有更多的训练时间会发生什么?如果我们将中间层改为使用多个 LSTM 层,会发生什么?
递归神经网络不仅仅用于文本或金融。它们也可以用于图像识别。
图像处理 LSTM
假设我们想要执行手写识别。不时地,我们会得到一个新的数据列。是信的结尾吗?如果有,是哪一个?是一句话的结尾吗?是标点吗?所有这些问题都可以用一个循环网络来回答。
对于我们的测试示例,我们将返回到我们的 10 位数数据集,并使用 LSTMs 而不是卷积层。
我们使用类似的超参数:
import tensorflow as tf
from tensorflow.contrib import rnn
# rows of 28 pixels
n_input=28
# unrolled through 28 time steps (our images are (28,28))
time_steps=28
# hidden LSTM units
num_units=128
# learning rate for adam
learning_rate=0.001
n_classes=10
batch_size=128
n_epochs = 10
step = 100
设置训练和测试数据几乎类似于我们的 CNN 示例,除了我们重塑图像的方式:
import os
import numpy as np
from sklearn.datasets import fetch_mldata
from sklearn.model_selection import train_test_split
mnist = fetch_mldata('MNIST original')
mnist.data = mnist.data.astype(np.float32).reshape(
[-1, time_steps, n_input]) / 255.
mnist.num_examples = len(mnist.data)
mnist.labels = mnist.target.astype(np.int8)
X_train, X_test, y_train, y_test = train_test_split(
mnist.data, mnist.labels, test_size=(1\. / 7.))
让我们快速建立我们的网络及其支架:
x = tf.placeholder(tf.float32, [None,time_steps, n_input])
y = tf.placeholder(tf.int64, [None])
# processing the input tensor from [batch_size, n_steps,n_input]
# to "time_steps" number of [batch_size, n_input] tensors
input = tf.unstack(x, time_steps,1)
lstm_layer = rnn.BasicLSTMCell(num_units, forget_bias=True)
outputs, _ = rnn.static_rnn(lstm_layer, input,dtype=tf.float32)
prediction = tf.layers.dense(inputs=outputs[-1], units = n_classes)
loss = tf.reduce_mean(tf.nn.sparse_softmax_cross_entropy_with_logits(
logits=prediction, labels=y))
opt = tf.train.AdamOptimizer(learning_rate=learning_rate).minimize(loss)
correct_prediction = tf.equal(tf.argmax(prediction,1), y)
accuracy = tf.reduce_mean(tf.cast(correct_prediction, tf.float32))
我们现在准备训练:
with tf.Session() as sess:
sess.run(tf.global_variables_initializer())
for epoch in range(n_epochs):
permut = np.random.permutation(len(X_train))
print("epoch: %i" % epoch)
for j in range(0, len(X_train), batch_size):
if j % step == 0:
print(" batch: %i" % j)
batch = permut[j:j+batch_size]
Xs = X_train[batch]
Ys = y_train[batch]
sess.run(opt, feed_dict={x: Xs, y: Ys})
if j % step == 0:
acc=sess.run(accuracy,feed_dict={x:Xs,y:Ys})
los=sess.run(loss,feed_dict={x:Xs,y:Ys})
print(" accuracy %f" % acc)
print(" loss %f" % los)
print("")
epoch: 0
batch: 0
accuracy 0.195312
loss 2.275624
batch: 3200
accuracy 0.484375
loss 1.514501
…
batch: 54400
accuracy 0.992188
loss 0.022468
batch: 57600
accuracy 1.000000
loss 0.007411
我们在这里也获得了相当高的准确度,但是我们将让读者检查测试样本的准确度。
摘要
使用张量流总是遵循类似的模式。设置您的输入、图表和您想要优化的功能。训练您的模型,保存它,并在您的应用程序中重用它。因为它有很多功能,所以很少有 TensorFlow 做不到的事情。在未来的章节中,我们还将探索其他类型的网络。
在书中的这一点上,我们已经看到了机器学习的主要模式:分类。在接下来的两章中,我们将研究用于两种特定数据的技术:音乐和图像。我们的第一个目标是建立一个音乐类型分类器。
九、分类二——情感分析
对于公司来说,密切监控公众对关键事件的接受是至关重要的,例如产品发布或新闻发布。随着用户生成的内容在推特上的实时访问和容易访问,现在可以对推文进行情感分类。有时也被称为意见挖掘,这是一个活跃的研究领域,几个公司已经在销售这种服务。由于这表明显然存在市场,我们有动力使用上一章构建的分类肌肉来构建我们自己的本土情感分类器。
绘制我们的路线图
由于推特的每条消息的大小限制,对推文的情绪分析尤其困难。这导致了一种特殊的句法,创造性的缩写,以及很少格式良好的句子。典型的方法是分析句子,汇总每个段落的情感信息,然后计算文档的整体情感,这种方法在这里不起作用。
显然,我们不会试图构建最先进的情感分类器。相反,我们希望执行以下操作:
- 以此场景为载体,引入另一种分类算法朴素贝叶斯
- 解释词性 ( 词性)标注是如何工作的,以及它如何帮助我们
- 展示 scikit-learn 工具箱中更多有用的技巧
获取推特数据
自然,我们需要描述情感的推文和相应的标签。在这一章中,我们将使用来自 Niek Sanders 的语料库,他出色地完成了手动将 5000 多条推文标记为积极、消极或中立的工作,并授予我们在这一章中使用它的权限。
为了遵守推特的服务条款,我们不会提供任何来自推特的数据,也不会在本章中显示任何真实的推文。相反,我们可以使用桑德的手标数据,其中包含推文 id 和他们的手标情绪。我们将使用推特的应用编程接口逐个获取相应的推文。为了不让你太厌烦,只需执行相应 Jupyter 笔记本的第一部分,这将启动下载过程。为了很好地使用推特的服务器,下载 5000 多条推特的所有数据需要相当长的时间,这意味着立即开始是个好主意。
数据自带四个情感标签,由load_sanders_data()
返回:
>>> X_orig, Y_orig = load_sanders_data()
>>> classes = np.unique(Y_orig)
>>> for c in classes: print("#%s: %i" % (c, sum(Y_orig == c)))
#irrelevant: 437
#negative: 448
#neutral: 1801
#positive: 391
在load_sanders_data()
里面,我们是把无关的、中性的标签一起当作中性的对待,把所有非英文的推文都去掉,结果推文 3077 条。
如果你在这里得到不同的计数,这是因为,在此期间,推文可能已经被删除或设置为私人的。在这种情况下,您可能还会得到与接下来几节中显示的数字和图表略有不同的数字和图表。
引入朴素贝叶斯分类器
天真的贝叶斯可能是最优雅的机器学习算法之一,具有实用价值。尽管它的名字,但当你看它的分类表现时,它并不那么幼稚。事实证明,它对不相关的特性相当稳健,但它会善意地忽略这些特性。它学得很快,预测也一样。它不需要大量存储空间。那么,为什么叫幼稚呢?
添加天真是为了解释天真贝叶斯优化工作所需的一个假设。假设特征是不相关的。然而,现实世界的应用程序很少会出现这种情况。然而,它在实践中仍然返回非常好的准确性,即使当独立性假设不成立时。
了解贝叶斯定理
本质上,朴素贝叶斯分类只不过是跟踪哪个特征为哪个类别提供证据。特征的设计方式决定了用来学习的模型。所谓伯努利模型只关心布尔特征;一个词在一条推文中出现一次还是多次并不重要。相比之下,多项式模型使用字数作为特征。为了简单起见,我们将使用伯努利模型来解释如何使用朴素贝叶斯进行情感分析。然后我们将使用多项式模型来建立和调整我们的真实世界分类器。
让我们假设以下变量的含义,我们将用来解释朴素贝叶斯:
| 变量 | 表示 |
| c
| 这是推文的类别(正面或负面——对于这个解释,我们忽略中性标签) |
| F<sub>1</sub>
| “棒极了”这个词在推文中至少出现过一次 |
| F<sub>2</sub>
| 疯狂这个词在推文中至少出现过一次 |
在训练中,我们学习了朴素贝叶斯模型,这是当我们已经知道特征和
时
类的概率。这个概率写成
。
由于我们不能直接估计,我们应用了一个技巧,这个技巧被贝叶斯发现:
如果我们将替换为“可怕”和“疯狂”的概率,并将
视为我们的
类,我们会得到一个关系,帮助我们稍后检索属于指定类的数据实例的概率:
这允许我们通过其他概率来表达:
我们也可以这样描述:
在先和证据很容易确定:
是不知道数据的
类的先验概率。我们可以通过简单地计算属于该特定类的所有训练数据实例的分数来估计这个数量。
是特征
和
的证据或概率
棘手的部分是可能性的计算。如果我们知道数据实例的类是
,那么这个值描述了看到
和
特征值的可能性。要估计这一点,我们需要做一些思考。
天真
从概率论中,我们还知道以下关系:
然而,这本身并没有多大帮助,因为我们用另一个难题(估计)来处理一个难题(估计
)。
然而,如果我们天真地假设和
相互独立,
简化为
,我们可以这样写:
把所有的东西放在一起,我们得到了以下非常容易管理的公式:
有趣的是,尽管在我们有心情的时候简单地调整我们的假设在理论上是不正确的,但在这种情况下,它在现实世界的应用中被证明是非常有效的。
用朴素贝叶斯分类
给定一条新的推文,剩下的唯一部分就是计算概率:
然后选择类作为概率较高的。
至于两个类,分母是一样的,我们可以简单的忽略它,不改变胜者类。
然而,请注意,我们不再计算任何真实的概率。相反,我们正在估计哪一类更有可能给出证据。这是朴素贝叶斯如此稳健的另一个原因:它对真实概率不感兴趣,只对信息感兴趣,哪个类更有可能。简而言之,我们可以这样写:
简单来说就是我们在计算 ( pos 和 neg 的所有类在 argmax 之后的部分,并返回得到最高值的类。
但是,对于下面的例子,让我们坚持真实概率,做一些计算,看看朴素贝叶斯是如何工作的。为了简单起见,我们将假设推特只允许前面提到的两个词,可怕和疯狂,并且我们已经手动分类了一些推文:
推文 | 级 |
---|---|
可怕的 | 正面推文 |
可怕的 | 正面推文 |
可怕的疯狂 | 正面推文 |
疯狂的 | 正面推文 |
疯狂的 | 负面推文 |
疯狂的 | 负面推文 |
在这个例子中,我们在正面和负面的推文中都有疯狂的推文,以模仿你在现实世界中经常会发现的一些歧义(例如,对足球疯狂对疯狂的白痴)。
在这种情况下,我们总共有六条推文,其中四条是积极的,两条是消极的,这导致了以下优先事项:
这意味着,在对推文本身一无所知的情况下,假设推文是正面的是明智的。
我们仍然缺少和
的计算,这是两个特征
和
的概率,在
类中有条件。
这是通过我们已经看到具体特征的推文数量除以已经用类标记的推文数量来计算的。假设我们想知道在推文中看到 awesome 发生的概率,知道它的类是正的,我们将有:
这是因为在四条积极的推文中,有三条包含了“棒极了”这个词。显然,在正面推文中没有令人敬畏的可能性是相反的:
同样,对于其余部分(省略一个词没有出现在推文中的情况):
为了完整起见,我们还将计算证据,以便我们可以在下面的示例推文中看到真实概率。对于和
两个具体数值,我们可以计算出如下证据:
这导致以下值:
现在我们有了对新推文进行分类的所有数据。剩下的唯一工作就是解析这条推文,分析它的特点:
| 推文 | |
| 类概率 | 分类 |
| 可怕的 | one | Zero | | 积极的 |
| 疯狂的 | Zero | one | | 否定的;消极的;负面的;负的 |
| 可怕的疯狂 | one | one | | 积极的 |
目前为止,一切顺利。琐碎推文的分类似乎给推文分配了正确的标签。然而,问题仍然是,我们应该如何对待训练语料库中没有出现的单词。毕竟,有了前面的公式,新单词总会被赋予零的概率。
解释看不见的单词和其他奇怪的东西
当我们早先计算概率时,我们实际上欺骗了自己。我们不是在计算真实的概率,而只是通过分数进行粗略的近似。我们假设训练语料库会告诉我们真实概率的全部真相。它没有。一个只有六条推文的语料库显然不能给我们所有关于每一条被写过的推文的信息。例如,肯定有包含文字的推文。只是我们从未见过他们。显然,我们的近似值非常粗略,我们应该考虑到这一点。这在实践中经常用所谓的加一平滑来完成。
Add-one smoothing is sometimes also referred to as additive smoothing or Laplace smoothing. Note that Laplace smoothing has nothing to do with Laplacian smoothing, which is related to the smoothing of polygon meshes. If we do not smooth by 1
but by an adjustable parameter, alpha>0
, it is called Lidstone smoothing.
这是一种非常简单的技术,可以将一个要素添加到所有要素实例中。它有一个潜在的假设,即使我们没有在整个语料库中看到一个给定的单词,我们的推文样本仍然有可能不包括这个单词。因此,通过加一平滑,我们假装我们比实际看到的更多地看到了每一个事件。这意味着我们现在不用计算,而是做
。
为什么分母要加 2?因为我们有两个特点:牛逼和疯狂的发生。因为我们为每个特征加 1,我们必须确保最终结果还是一个概率。事实上,我们得到 1 作为总概率:
算术欠流核算
还有另一个路障。实际上,我们处理的概率比我们在玩具例子中处理的概率小得多。通常情况下,我们也有许多不仅仅是两个特征,它们是相互相乘的。这将很快导致 NumPy 提供的浮点精度不再满足要求:
>>> import numpy as np
>>> # tell numpy to print out more digits (default is 8)
>>> np.set_printoptions(precision=20)
>>> 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)
为了缓解这种情况,人们可以转而使用数学库,比如允许任意精度的mpmath
(http://mpmath.org)。然而,它们的速度不足以作为 NumPy 的替代品。
幸运的是,有一个更好的方法来解决这个问题,这与我们可能还记得的学校里的一段美好关系有关(也称为 logsum-trick):
如果我们将此公式应用于我们的案例,我们会得到以下结果:
由于概率在 0 和 1 之间的区间内,概率的对数在-和 0 之间的区间内。不要为此烦恼。更高的数字仍然是正确类别的一个更强有力的指标——只是它们现在是负数:
但是有一个警告:我们实际上没有公式命名者的日志(分数的顶部)。我们只有概率的乘积。幸运的是,在我们的例子中,我们对概率的实际值不感兴趣。我们只是想知道哪个类的后验概率最高。我们很幸运,因为如果我们发现以下情况:
那么我们也将永远拥有:
。
快速看一下前面的图表,可以发现曲线是严格单调递增的,也就是说,当我们从左向右时,它总是向上的。让我们把这个放在前面提到的公式中:
这将最终检索出两个特性的公式,这两个特性将为我们提供最好的类,也是我们将在实践中看到的真实数据:
当然,如果只有两个特性,我们将不会非常成功,所以让我们重写它,以允许任意数量的特性:
我们在这里,准备使用 scikit-learn 工具包中的第一个分类器。
如前所述,我们刚刚学习了朴素贝叶斯的伯努利模型。除了布尔特征,我们还可以使用单词出现的次数,也称为多项式模型。由于这提供了更多的信息,并且通常还会带来更好的性能,因此我们将把它用于我们的真实数据。但是,请注意,基础公式会有一些变化。然而,不用担心,因为朴素贝叶斯的工作原理仍然是一样的。
创建我们的第一个分类器并对其进行调整
天真的贝叶斯分类器存在于sklearn.naive_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_orig=="positive", Y_orig =="negative")
>>> # now use that index to filter the data and the labels
>>> X = X_orig [pos_neg_idx]
>>> Y = Y_orig [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(params=None):
tfidf_ngrams = TfidfVectorizer(ngram_range=(1, 3),
analyzer="word", binary=False)
clf = MultinomialNB()
pipeline = Pipeline([('tfidf', tfidf_ngrams), ('clf', clf)])
if params:
pipeline.set_params(**params)
return pipeline
由create_ngram_model()
返回的Pipeline
实例现在可以用于拟合和预测,就像我们有一个正常的分类器一样。稍后,我们将传递一个参数字典作为params
,这将帮助我们创建自定义管道。
由于我们没有那么多数据,我们应该进行交叉验证。但是,这次我们不会使用KFold
,它将数据划分为连续的折叠;相反,我们将使用ShuffleSplit
。它为我们打乱了数据,但并不阻止同一数据实例被多次折叠。然后,对于每个折叠,我们跟踪精度-召回曲线下的区域,以确保准确性。
为了让我们的实验保持敏捷,让我们把所有的东西都包装在一个train_model()
函数中,该函数将一个函数作为创建分类器的参数:
from sklearn.metrics import precision_recall_curve, auc
from sklearn. model_selection import ShuffleSplit
def train_model(clf_factory, X, Y):
# setting random_state to get deterministic behavior
cv = ShuffleSplit(n_splits=10, test_size=0.3,
random_state=0)
scores = []
pr_scores = []
for train, test in cv.split(X, Y):
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.mean(pr_scores))
print("Mean acc=%.3ftMean P/R AUC=%.3f" % summary)
把所有东西放在一起,我们可以训练我们的第一个模型:
>>> X_orig, Y_orig = load_sanders_data()
>>> pos_neg_idx = np.logical_or(Y_orig =="positive", Y_orig =="negative")
>>> X = X_orig[pos_neg_idx]
>>> Y = Y_orig [pos_neg_idx]
>>> Y = Y_orig =="positive"
>>> train_model(create_ngram_model, X, Y)
Mean acc=0.777 Mean P/R AUC=0.885
我们首次尝试在向量化的 TF-IDF 三元组特征上使用朴素贝叶斯,得到了 77.7%的准确率和 88.5%的平均市盈率 AUC。查看中位数的市盈率图表(表现与平均值最相似的列车/测试分割),它显示了比我们在上一章中看到的图表更令人鼓舞的行为。请注意,0.90 的曲线的 AUC 与 0.885 的平均 P/R 略有不同,因为该曲线取自训练运行的中位数,而平均 P/R AUC 是所有 AUC 分数的平均值。同样的原理也适用于后续图像:
首先,结果相当令人鼓舞。当我们意识到 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
return Y.astype(int)
请注意,我们现在谈论的是两种不同的积极因素。推文的情绪可以是积极的,这要与训练数据的类别区分开来。例如,如果我们想知道如何区分有情绪的推文和中性的推文,我们可以做:
>>> X = X_orig
>>> Y = tweak_labels(Y_orig, ["positive", "negative"])
在Y
中,我们现在有1
(积极类)用于所有积极或消极的推文,还有0
(消极类)用于中性和不相关的推文:
>>> train_model(create_ngram_model, X, Y)
Mean acc=0.734 Mean P/R AUC=0.661
看看下面的情节:
不出所料,市盈率 AUC 大幅下降,目前仅为 66%。准确性仍然很高,但这只是因为我们有一个高度不平衡的数据集。在总共 3077 条推文中,只有 839 条是正面或负面的,约占 27%。这意味着,如果我们创建一个分类器,总是将推文分类为不包含任何情绪,我们将已经有 73%的准确率。如果训练和测试数据不平衡,这是另一个总是看精度和回忆的原因。
那么,天真的贝叶斯分类器将如何对积极的推文和其他推文进行分类,以及对消极的推文和其他推文进行分类?一个字:差:
== Pos vs. rest ==
Mean acc=0.87 Mean P/R AUC=0.305
== Neg vs. rest ==
Mean acc=0.852 Mean P/R AUC=0.49
如果你问我的话,我觉得很难用。查看以下图表中的市盈率曲线,我们也将发现没有可用的精度/召回权衡,正如我们在上一章中所做的那样:
调整分类器的参数
当然,我们还没有充分探索当前的设置,应该进行更多的调查。大致有两个区域可以玩旋钮:TfidfVectorizer
和MultinomialNB
。由于我们没有真正的直觉,我们应该探索哪个领域,让我们尝试扫描超参数。
我们将首先看到TfidfVectorizer
参数:
- 使用不同的 ngrams 设置:
- unigrams (1,1)
- 单图和双图(1,2)
- 单项式、二元式和三元式(1,3)
- 玩
min_df
:1
或2
- 使用
use_idf
和smooth_idf
:False
或True
探索以色列国防军在 TF-以色列国防军中的影响 - 通过将
stop_words
设置为english
或None
是否删除停止词 - 是否使用字数的对数(
sublinear_tf
) - 通过将
binary
设置为True
或False
,是跟踪字数还是简单地跟踪单词是否出现
现在我们将看到MultinomialNB
分类器:
- 通过设置
alpha
使用哪种平滑方法 - 加一或拉普拉斯平滑:
1
- 丽德斯通平滑:
0.01, 0.05, 0.1, or 0.5
- 无平滑:
0
一个简单的方法是为所有这些合理的探索值训练一个分类器,同时保持其他参数不变并检查分类器的结果。由于我们不知道这些参数是否会相互影响,要做好这一点,我们需要为所有参数值的每个可能组合训练一个分类器。显然,这对我们来说太繁琐了。
因为这种参数探索在机器学习任务中经常出现,scikit-learn 有一个专门的类,叫做GridSearchCV
。它需要一个估计器(带有类分类器接口的实例),在我们的例子中是Pipeline
实例,以及一个带有潜在值的参数字典。
GridSearchCV
期望字典的关键字遵循某种格式,以便能够设置正确估计器的参数。格式如下:
<estimator>__<subestimator>__...__<param_name>
例如,如果我们想要为TfidfVectorizer
(在Pipeline
描述中被命名为tfidf
的ngram_range
参数指定想要探索的值,我们将不得不说:
param_grid={"tfidf__ngram_range"=[(1, 1), (1, 2), (1, 3)]}
这将告诉GridSearchCV
尝试将三元组的单值作为TfidfVectorizer
的ngram_range
参数的参数值。
然后,它用所有可能的参数值组合训练估计量。我们确保它使用ShuffleSplit
对训练数据的随机样本进行训练,这将生成随机训练/测试拆分的迭代器。最后,它以成员变量best_estimator_
的形式提供了最佳估计量。
当我们想要将返回的最佳分类器与我们当前的最佳分类器进行比较时,我们需要以同样的方式对其进行评估。因此,我们可以使用cv
参数传递ShuffleSplit
实例(因此,GridSearchCV
中的CV
)。
最后缺少的部分是定义GridSearchCV
应该如何确定最佳估计量。这可以通过使用make_scorer
辅助函数向scoring
参数提供所需的评分函数来实现。我们可以自己写一个,也可以从sklearn.metrics
包里挑一个。我们当然不应该拿metric.accuracy
来说事,因为我们的阶级不平衡(我们包含情感的推文比中立的少得多)。相反,我们希望对这两个类、带有情绪的推文和没有正面或负面意见的推文都有很好的精度和召回率。结合了精确度和召回率的一个度量是 F-measure ,它被实现为metrics.f1_score
:
将所有内容放在一起后,我们得到以下代码:
from sklearn. model_selection import GridSearchCV
from sklearn.metrics import make_scorer, f1_score
def grid_search_model(clf_factory, X, Y):
cv = ShuffleSplit(n_splits=10, test_size=0.3, random_state=0)
param_grid = dict(tfidf__ngram_range=[(1, 1), (1, 2), (1, 3)],
tfidf__min_df=[1, 2],
tfidf__stop_words=[None, "english"],
tfidf__smooth_idf=[False, True],
tfidf__use_idf=[False, True],
tfidf__sublinear_tf=[False, True],
tfidf__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,
scoring=make_scorer(f1_score), verbose=10)
grid_search.fit(X, Y)
return grid_search.best_estimator_
执行此操作时,我们必须保持耐心:
print("== Pos/neg vs. irrelevant/neutral ==")
X = X_orig
Y = tweak_labels(Y_orig, ["positive", "negative"])
clf = grid_search_model(create_ngram_model, X, Y)
print(clf)
由于我们刚刚请求了一个参数,扫描参数组合,每个参数组合被训练 10 次:
... waiting some 20 minutes ...
Pipeline(memory=None,
steps=[('tfidf', TfidfVectorizer(analyzer='word', binary=True,
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, 2), norm='l2', preprocessor=None,
smooth_idf=False, vocabulary=None)),
('clf', MultinomialNB(alpha=0.01, class_prior=None, fit_prior=True))])
为了能够将这些数字与我们之前的方法进行比较,我们将创建一个best_params
字典,然后将其传递给分类器工厂,然后运行与之前相同的代码,对 10 倍 CV 拆分进行训练并输出平均分数:
best_params = dict(all__tfidf__ngram_range=(1, 2),
all__tfidf__min_df=1,
all__tfidf__stop_words=None,
all__tfidf__smooth_idf=False,
all__tfidf__use_idf=False,
all__tfidf__sublinear_tf=True,
all__tfidf__binary=False,
clf__alpha=0.01,
)
print("== Pos/neg vs. irrelevant/neutral ==")
X = X_orig
Y = tweak_labels(Y_orig, ["positive", "negative"])
train_model(lambda: create_ngram_model(best_params), X, Y)
结果如下:
== Pos/neg vs. irrelevant/neutral ==
Mean acc=0.791 Mean P/R AUC=0.681
最佳估计器确实将市盈率 AUC 从 65.8%提高到 68.1%,其设置如前面的代码所示。
此外,如果我们用我们刚刚发现的参数配置矢量器和分类器,那么正面推文对其余推文和负面推文对其余推文的破坏性结果会得到改善。只有阳性和阴性分类显示出稍差的性能:
看看下面的情节:
事实上,市盈率曲线看起来要好得多(注意,曲线来自折叠分类器的中间,因此,AUC 值略有差异)。然而,我们可能仍然不会使用这些分类器。是时候做些完全不同的事情了...
清理推文
新的约束导致新的形式。在这方面,推特也不例外。因为文本必须适合 280 个字符,人们自然会开发新的语言快捷方式,用更少的字符说出相同的内容。到目前为止,我们忽略了所有不同的表情符号和缩写。让我们看看考虑到这一点,我们能提高多少。为了这个努力,我们必须提供我们自己的preprocessor()
到TfidfVectorizer
。
首先,我们在字典中定义一系列常见的表情符号及其替换。虽然我们可以找到更明显的替代词,但我们会用明显的肯定或否定的词来帮助分类器:
emo_repl = {
# positive emoticons
"<3": " good ",
":d": " good ", # :D in lower case
":dd": " good ", # :DD in lower case
"8)": " good ",
":-)": " good ",
":)": " good ",
";)": " good ",
"(-:": " good ",
"(:": " good ",
# negative emoticons:
":/": " bad ",
":>": " 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"brb": "are",
r"bub": "you",
r"bhahab": "ha",
r"bhahahab": "ha",
r"bdon'tb": "do not",
r"bdoesn'tb": "does not",
r"bdidn'tb": "did not",
r"bhasn'tb": "has not",
r"bhaven'tb": "have not",
r"bhadn'tb": "had not",
r"bwon'tb": "will not",
r"bwouldn'tb": "would not",
r"bcan'tb": "can not",
r"bcannotb": "can not",
}
def create_ngram_model_emoji(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")
clf = MultinomialNB()
pipeline = Pipeline([('tfidf', tfidf_ngrams), ('clf', clf)])
if params:
pipeline.set_params(**params)
return pipeline
请记住,我们创建了一个参数字典,我们发现最好使用GridSearchCV
。我们将把它传递给create_ngram_model_emoji
,这样我们的新模型将在我们已经发现的基础上进行改进。由于train_model
需要一个分类器工厂,因为它会被一次又一次地实例化,我们使用 Python 的lambda
创建工厂:
print("== Pos/neg vs. irrelevant/neutral ==")
X = X_orig
Y = tweak_labels(Y_orig, ["positive", "negative"])
train_model(lambda: create_ngram_model_emoji(best_params), X, Y)
当然,这里可以使用更多的缩写。但是已经有了这个有限的集合,我们得到了大约半个点的积极与消极的改善,以及情绪与无情绪的改善。复制前面的表格并为新方法填写数字,我们看到它为每个分类器都批准了一点:
考虑到单词类型
到目前为止,我们的希望是,简单地使用相互独立的单词和单词包的方法就足够了。然而,仅仅从我们的直觉来看,我们知道中性推文可能包含更高比例的名词,而正面或负面推文更丰富多彩,需要更多的形容词和动词。如果我们也使用推文的语言信息呢?如果我们能找出一条推文中有多少单词是名词、动词、形容词等等,分类器可能也会考虑到这一点。
确定单词类型
这就是词性标注,或者说词性标注的意义所在。POS tagger 分析一个句子,用词类标记每个单词,例如,book 这个词是名词(这是一本好书)还是动词(能不能请你订航班?).
你可能已经猜到 NLTK 也会在这个领域发挥作用。事实上,它很容易打包成各种解析器和标记器。我们将使用的词性标注器nltk.pos_tag()
实际上是一个成熟的分类器,它是使用佩恩树库项目中手动标注的句子训练的。它将单词标记列表作为输入,并输出元组列表,其中每个元素包含原始句子的部分及其词性标记:
>>> 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'), ('?', '.')]
POS 标签缩写取自佩恩树库(改编自http://www.anc.org/OANC/penn.html):
POS 标签 | 描述 | 例 |
---|---|---|
CC |
并列连词 | 或者 |
CD |
基数 | 第二 |
DT |
限定词 | 这 |
EX |
那里存在 | 有有 |
FW |
外来词 | 幼儿园 |
IN |
介词/从属连词 | 在,在,像 |
JJ |
形容词 | 凉爽的 |
JJR |
形容词,比较的 | 冷却器 |
JJS |
形容词,最高级 | 最凉快的 |
LS |
列表制作人 | 1) |
MD |
情态的 | 可能,威尔 |
NN |
名词,单数或复数 | 书 |
NNS |
名词复数 | 书 |
NNP |
专有名词,单数 | 肖恩 |
NNPS |
专有名词,复数 | 海盗 |
PDT |
前限定词 | 两个男孩 |
POS |
所有格结尾 | 朋友的 |
PRP |
人称代名词 | 我,他,它 |
`PRP | POS 标签 | 描述 |
--- | --- | --- |
CC |
并列连词 | 或者 |
CD |
基数 | 第二 |
DT |
限定词 | 这 |
EX |
那里存在 | 有有 |
FW |
外来词 | 幼儿园 |
IN |
介词/从属连词 | 在,在,像 |
JJ |
形容词 | 凉爽的 |
JJR |
形容词,比较的 | 冷却器 |
JJS |
形容词,最高级 | 最凉快的 |
LS |
列表制作人 | 1) |
MD |
情态的 | 可能,威尔 |
NN |
名词,单数或复数 | 书 |
NNS |
名词复数 | 书 |
NNP |
专有名词,单数 | 肖恩 |
NNPS |
专有名词,复数 | 海盗 |
PDT |
前限定词 | 两个男孩 |
POS |
所有格结尾 | 朋友的 |
PRP |
人称代名词 | 我,他,它 |
所有格代名词 | 我的,他的 | |
RB |
副词 | 然而,通常,自然地,在这里,很好 |
RBR |
副词,比较的 | 较好的 |
RBS |
副词,最高级 | 最好的 |
RP |
颗粒 | 放弃放弃 |
TO |
到 | 去去,去他 |
UH |
感叹词 | 嗯嗯嗯 |
VB |
动词,基本形式 | 拿 |
VBD |
动词,过去式 | 拿 |
VBG |
动词、动名词/现在分词 | 拿 |
VBN |
动词,过去分词 | 拿 |
VBP |
动词,唱。存在,非 3d | 拿 |
VBZ |
动词,第三人称歌唱。礼物 | 接受 |
WDT |
wh-确定 | 哪个 |
WP |
wh 代词 | 谁,什么 |
WP$ |
所有格 wh 代词 | 谁的 |
WRB |
wh-abverb | 何时何地 |
有了这些标签,从pos_tag()
的输出中过滤出想要的标签是非常容易的。我们只需计算所有标签以NN
开头的名词、VB
开头的动词、
开头的形容词和RB
开头的副词。
使用 SentiWordNet 成功作弊
虽然语言信息,如前一节所述,很可能会对我们有所帮助,但我们可以用一些更好的东西来收获它:SentiWordNet(http://SentiWordNet . isti . CNR . it)。简单来说就是一个 13 MB 的文件,我们要从网站下载,解压,放入 Jupyter 笔记本的数据目录;它给大多数英语单词赋予了一个正值和负值。这意味着对于每个同义词集,它都记录了积极和消极的情感值。一些例子如下:
POS | ID | 姿势得分 | 否定得分 | 同步组术语 | 描述 |
---|---|---|---|---|---|
a |
00311354 | Zero point two five | Zero point one two five | 勤奋好学#1 | 以小心和努力为特点的;努力修理电视机 |
a |
00311663 | Zero | Zero point five | 粗心的#1 | 以缺乏关注、考虑、深谋远虑或彻底为特点的;不小心... |
n |
03563710 | Zero | Zero | 植入物#1 | 永久放置在组织中的假体 |
v |
00362128 | Zero | Zero | 扭结#2 曲线#5 卷曲#1 | 形成卷曲、曲线或扭结;雪茄烟雾在天花板上袅袅上升 |
有了 POS 一栏的信息,我们就能区分名词书和动词书了。PosScore
和NegScore
一起会帮助我们确定这个词的中性,就是 1-poss core-neggle。SynsetTerms
列出集合中所有同义词。我们可以放心地忽略ID
和Description
列。
synset 术语附加了一个数字,因为有些术语在不同的 synset 中出现多次。例如,innovate 传达了两种不同的意思,这也导致了不同的分数:
| POS | ID | 姿势得分 | 否定得分 | 同步组术语 | 描述 |
| v
| 01636859 | Zero point three seven five | Zero | 幻想#2 幻想 | 在头脑中描绘;他幻想着理想的妻子 |
| v
| 01637368 | Zero | Zero point one two five | 幻想#1 幻想#1 幻想 | 沉溺于幻想;当他说他计划创办自己的公司时,他是在幻想 |
为了找出应该采用哪种 synsets,我们需要真正理解推文的含义,这超出了本章的范围。专注于这一挑战的研究领域被称为词义消歧。
对于我们的任务,我们采取简单的路线,简单地对找到一个术语的所有合成集进行平均。对于幻想来说,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
然而,在组合特征器上的训练和测试有点令人失望。相对于其余部分,我们在积极方面提高了 1%,但在其他方面却有所下降:
有了这些结果,如果我们不能在市盈率 AUC 中获得一个重要的职位,我们可能不想为昂贵得多的 SentiWord 方法付出代价。相反,我们可能会选择 GridCV + Cleaning 方法,首先使用确定推文是否包含情感的分类器(pos/neg 对无关/中性),然后在确实包含情感的情况下,使用肯定对否定的分类器来确定实际的情感。
摘要
恭喜你坚持到最后!我们一起学习了朴素贝叶斯是如何工作的,以及为什么它一点也不幼稚。特别是对于训练集,我们没有足够的数据来学习类概率空间中的所有小生境,朴素贝叶斯在推广方面做得很好。我们学会了如何将它应用到推文中,清理粗糙的推文文本有很大帮助。最后,我们意识到一点点欺骗是可以的(只有在我们完成了应尽的工作之后)。然而,由于我们意识到昂贵得多的分类器并没有奖励我们一个改进得多的分类器,我们回到了便宜的分类器。
在第 10 章、主题建模中,我们将学习如何使用潜在狄利克雷分配(也称为主题建模)从文档中提取主题。这将有助于我们通过分析所涵盖主题的相似程度来比较文档。
十、主题建模
在第 6 章、聚类-查找相关帖子中,我们使用聚类对文本文档进行了分组。这是一个非常有用的工具,但并不总是最好的。聚类结果是每个文本恰好属于一个聚类。这本书是关于机器学习和 Python 的。它应该与其他 Python 相关的作品还是与机器相关的作品分组?在实体书店,我们需要选择一个单一的地方来存放这本书。然而,在一家互联网商店,答案是这本书是关于机器学习和 Python 的,这本书应该列在这两个部分。当然,这并不意味着这本书会在所有章节中列出。我们不会把这本书和其他烘焙书籍放在一起。
在本章中,我们将学习不将文档聚集成完全独立的组,而是允许每个文档引用几个主题的方法。这些主题将从文本文档集合中自动识别。这些文档可以是整本书或较短的文本,如博客文章、新闻故事或电子邮件。
我们还希望能够推断出这样一个事实,即这些文件可能有对它们至关重要的主题,而只是顺便提及其他主题。这本书经常提到绘图,但它不像机器学习那样是一个中心话题。这意味着文档中的主题是它们的核心,而其他主题则是次要的。处理这些问题的机器学习子领域称为主题建模,是本章的主题。特别是,我们将了解以下内容:
- 有哪些主题模型,特别是关于潜在狄利克雷分配 ( LDA )
- 如何使用
gensim
包构建主题模型 - 主题模型如何作为不同应用程序的中间表示有用
- 我们如何建立整个英语维基百科的主题模型
潜在狄利克雷分配
不幸的是,机器学习中有两种方法,首字母是 LDA:潜在狄利克雷分配,这是一种主题建模方法,以及线性判别分析,这是一种分类方法。它们完全不相关,除了首字母 LDA 可以指任何一个。在某些情况下,这可能会令人困惑。scikit-learn 工具有一个子模块sklearn.lda
,实现线性判别分析。目前,scikit-learn 没有实现潜在的 Dirichlet 分配。
第一个话题model
我们要看的是潜狄利克雷分配。LDA 背后的数学思想相当复杂,这里就不赘述了。
对于那些感兴趣并且足够冒险的人,维基百科提供了这些算法背后的所有等式:http://en.wikipedia.org/wiki/Latent_Dirichlet_allocation。
但是,我们可以从高层次直观地理解 LDA 背后的思想。LDA 属于一类被称为生成模型的模型,因为它们有一种寓言解释了数据是如何生成的。这个生成性的故事是对现实的简化,当然是为了让机器学习更容易。在 LDA 寓言中,我们首先通过给单词分配概率权重来创建主题。每个主题将为不同的单词分配不同的权重。例如,一个 Python 主题会给单词变量分配高概率,给喝醉的单词分配低概率。当我们希望生成一个新文档时,我们首先选择它将使用的主题,然后从这些主题中混合单词。
例如,假设我们只有三个书籍讨论的主题:
- 机器学习
- 计算机编程语言
- 烘烤
对于每个主题,我们都有一个与之相关的单词列表。这本书将前两个主题混合在一起,也许各占 50%。混合物不需要相等;也可以是 70/30 分割。当我们生成实际的文本时,我们一个字一个字地生成;首先我们决定这个词来自哪个话题。这是基于主题权重的随机决定。一旦选择了一个主题,我们就从该主题的单词列表中生成一个单词。准确地说,我们用题目给出的概率选择一个英语单词。同一个单词可以由多个主题生成。例如,重量是机器学习和烘焙中的常用词(尽管含义不同)。
在这个模型中,单词的顺序并不重要。这是一个包字模型,我们在上一章已经看到了。这是对语言的粗略简化,但它通常足够好,因为仅仅知道文档中使用了哪些单词及其频率就足以做出机器学习决策。
在现实世界中,我们不知道主题是什么。我们的任务是收集一些文本,并对这个寓言进行逆向工程,以便发现有哪些主题,同时找出每个文档使用哪些主题。
构建主题模型
不幸的是,scikit-learn 没有实现潜在的 Dirichlet 分配。因此,我们将使用 Python 中的gensim
包。Gensim 是由英国的机器学习研究员和顾问 radim řehůřek 开发的。
作为输入数据,我们将使用来自美联社 ( 美联社)的新闻报道集合。这是一个用于文本建模研究的标准数据集,在一些关于主题模型的初始工作中使用过。下载数据后,我们可以通过运行以下代码来加载它:
import gensim
from gensim import corpora, models
corpus = corpora.BleiCorpus('./data/ap/ap.dat', './data/ap/vocab.txt')
corpus
变量保存所有文本文档,并以易于处理的格式加载它们。我们现在可以建立一个主题model
,使用这个对象作为输入:
model = models.ldamodel.LdaModel(
corpus,
num_topics=100,
id2word=corpus.id2word)
该单个构造函数调用将统计推断corpus
中存在哪些主题。我们可以通过多种方式探索最终的模型。我们可以看到使用model[doc]
语法的topics
文档列表,如下例所示:
doc = corpus.docbyoffset(0)
topics = model[doc]
print(topics)
[(3, 0.023607255776894751),
(13, 0.11679936618551275),
(19, 0.075935855202707139),
....
(92, 0.10781541687001292)]
结果几乎肯定会在我们的电脑上看起来不同!学习算法使用了一些随机数,每次在同一个输入数据上学习一个新的题目model
,结果都不一样。重要的是,如果您的数据表现良好,模型的一些定性属性将在不同的运行中保持稳定。例如,如果您正在使用主题来比较文档,就像我们在这里所做的那样,那么相似性应该是健壮的,并且只有轻微的变化。另一方面,不同主题的顺序也会完全不同。
结果的格式是一个配对列表:(topic_index, topic_weight)
。我们可以看到每个文档只使用了几个主题(在前面的例子中,主题0
、1
、2
没有权重;这些话题的权重是0
。主题model
是一个稀疏模型,因为虽然有很多可能的主题,但是对于每个文档,只使用了其中的几个。严格来说,这不是真的,因为在 LDA 模型中,所有主题都有非零概率,但其中一些主题的概率非常小,我们可以将其四舍五入为零,作为一个很好的近似值。
我们可以通过绘制每个文档涉及的主题数量的直方图来进一步探讨这一点:
num_topics_used = [len(model[doc]) for doc in corpus]
fig,ax = plt.subplots()
ax.hist(num_topics_used)
你会得到如下图:
Sparsity means that while you may have large matrices and vectors, in principle, most of the values are zero (or so small that we can round them to zero as a good approximation). Therefore, only a few things are relevant at any given time.
Often problems that seem too big to solve are actually feasible because the data is sparse. For example, even though any web page can link to any other web page, the graph of links is actually very sparse as each web page will link to a very tiny fraction of all other web pages.
在上图中,我们可以或多或少地看到大多数文档涉及大约 10 个主题。
这在很大程度上是由于所用参数的值,即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 不同的主题。如果您将该值设置得更低,您将观察到相反的情况(从在线存储库中下载代码将允许您使用这些值)。
这些话题是什么?从技术上讲,正如我们前面讨论的,它们是单词的多项式分布,这意味着它们为词汇表中的每个单词分配了一个概率。概率较高的单词比概率较低的单词更容易与该主题相关联:
我们的大脑不太擅长用概率分布进行推理,但我们可以很容易地理解一系列单词。因此,通常用权重最高的单词列表来总结主题。
在下表中,我们显示了前十个主题:
| 题号 | 话题 |
| one | 着装军事苏联总统新州卡卢奇上尉州领导人立场政府 |
| Two | 科赫赞比亚卢萨卡一党橙色科赫一党政府市长新政治 |
| three | 人权土耳其侵犯皇家汤普森威胁新州写道花园总统 |
| four | 比尔雇员实验莱文税收联邦措施立法参议院主席告密者赞助 |
| five | 俄亥俄州 7 月干旱耶稣灾难百分之哈特福德密西西比州北部河谷作物弗吉尼亚州 |
| six | 美国百分之十亿年总统世界各国人民i
布什新闻 |
| seven | b
休斯宣誓书美国盎司平方英尺护理延迟指控不切实际的布什 |
| eight | 尤特·杜卡基斯布什公约农业补贴乌拉圭百分比秘书长i
告诉 |
| nine | 克什米尔政府人民斯利那加印度甩市两查谟克什米尔集团莫斯利巴基斯坦 |
| Ten | 工人越南爱尔兰工资移民百分比议价最后一个岛警察赫顿i
|
虽然乍一看令人望而生畏,但当通读单词列表时,我们可以清楚地看到,主题不仅仅是随机的单词,相反,这些是逻辑组。我们也可以看到,这些话题指的是比较老的新闻条目,从苏联还存在,戈尔巴乔夫还是***的时候开始。我们还可以将主题表示为单词云,使更有可能的单词变大。例如,这是一个关于警察的主题的可视化:
我们还可以看到,有些词可能应该删除,因为它们信息不多;这些都是空话。在构建主题建模时,过滤掉停止词可能会很有用,否则你可能会得到一个完全由停止词组成的主题。我们可能还希望将文本预处理为词干,以便规范化复数和动词形式。这个过程在第 6 章、聚类-查找相关帖子中有介绍,具体可以参考。如果你感兴趣,可以从本书的配套网站下载代码,尝试所有这些变体来绘制不同的图片。
按主题比较文档
主题本身对于构建上一个截图中显示的那种带有单词的小短文非常有用。这些可视化可以用于浏览大量文档。例如,网站可以将不同的主题显示为不同的单词云,允许用户点击查看文档。事实上,它们只是以这种方式被用来分析大量的文档集合。
然而,话题往往只是达到另一个目的的中间工具。现在,我们已经估计了每个文档中有多少来自每个主题,我们可以在主题空间中比较这些文档。这仅仅意味着,如果两个文档谈论相同的主题,我们就说它们是相似的,而不是逐字比较。
这可能非常强大,因为两个共享很少单词的文本文档实际上可能指的是同一个主题!他们可能只是使用不同的结构来引用它(例如,一个文档可能会提到英国,而另一个文档会使用缩写 UK)。
Topic models are good on their own to build visualizations and explore data. They are also very useful as an intermediate step in many other tasks.
在这一点上,我们可以通过使用主题来定义相似性,来重复寻找与输入查询最相似的帖子的任务。之前我们通过直接比较两个文档的单词向量来比较它们,现在我们可以通过比较它们的主题向量来比较两个文档。
为此,我们将把文档投影到主题空间。也就是说,我们希望有一个总结文档的主题向量。这是 第五章降维中讨论的类型的降维的另一个例子。在这里,我们向您展示主题模型是如何准确地用于这一目的的;一旦为每个文档计算了主题,我们就可以对主题向量执行操作,而忘记原始单词。如果主题有意义,它们可能比生字更有信息。此外,这可能带来计算上的优势,因为比较主题权重向量比输入词汇表(包含数千个术语)大的向量要快得多。
使用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
distances = distance.squareform(distance.pdist(topics))
现在,我们将使用最后一个小技巧;我们将把distance
矩阵的对角线元素设置为无穷大,以确保它看起来比任何其他元素都大:
for ti in range(len(topics)):
distances[ti,ti] = np.inf
我们完了!对于每个文档,我们可以很容易地查找最近的元素(这是一种最近邻分类器):
def closest_to(doc_id):
return distances[doc_id].argmin()
This will not work if we have not set the diagonal elements to a large value: the function will always return the same element as it is the one most similar to itself (except in the weird case where two elements have exactly the same topic distribution, which is very rare unless they are exactly the same).
例如,这里有一个可能的查询文档(它是我们集合中的第二个文档):
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
的文档,我们将为整个英语维基百科建立一个主题模型。这需要几个小时,但只需一台笔记本电脑就可以完成!有了一组机器,我们可以让它运行得更快,但我们将在后面的章节中研究这种处理环境。
首先,我们从http://dumps.wikimedia.org下载整个维基百科转储。这是一个大文件(目前超过 14 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)
这又需要几个小时。您将在控制台上看到进度,这可以指示您还需要等待多长时间。
一旦完成,我们可以将主题model
保存到一个文件中,这样我们就不必重做它了:
model.save('wiki_lda.pkl')
如果您退出会话并稍后返回,您可以使用以下命令再次加载模型(当然是在适当的导入之后):
model = gensim.models.ldamodel.LdaModel.load('wiki_lda.pkl')
model
对象可以用来探索文档集合并构建topics
矩阵,就像我们之前所做的那样。
我们可以看到,这仍然是一个稀疏的模型,即使我们比以前有更多的文档(在我们写这篇文章时超过 400 万):
lens = (topics > 0).sum(axis=1)
print('Mean number of topics mentioned: {0:.4}'.format(np.mean(lens)))
print('Percentage of articles mentioning <10 topics: {0:.1%}'.format(
np.mean(lens <= 10)))
Mean number of topics mentioned: 6.244
Percentage of articles mentioning <10 topics: 95.1%
因此,平均文件提到6.244
话题和95.1
的百分比提到10
或更少的话题。
我们可以问维基百科上谈论最多的话题是什么。我们将首先计算每个主题的总权重(通过汇总所有文档的权重),然后检索与权重最高的主题相对应的单词。这是使用以下代码执行的:
weights = topics.sum(axis=0)
words = model.show_topic(weights.argmax(), 64)
使用与我们之前构建可视化相同的工具,我们可以看到谈论最多的主题与音乐相关,并且是一个非常连贯的主题。整整 18%的维基百科页面与这个主题部分相关(维基百科中所有单词的 5.5%被分配到这个主题)。请看下面的截图:
These plots and numbers were obtained when the book was being written. As Wikipedia keeps changing, your results will be different. We expect that the trends will be similar, but the details may vary.
或者,我们可以看看最少被谈论的话题:
words = model.show_topic(weights.argmin(), 64)
参考以下截图:
最少被提及的话题很难解释,但它的许多关键词指的是非洲的一些地方。只有 2.1%的文档涉及到它,它只代表了 0.1%的单词。
选择主题数量
到目前为止,在本章中,我们使用了固定数量的主题进行分析,即 100 个。这是一个纯粹任意的数字;我们可以使用 20 或 200 个主题。幸运的是,对于许多用途来说,这个数字并不重要。如果你打算只使用主题作为中间步骤,就像我们之前在寻找类似帖子时所做的那样,系统的最终行为很少对模型中使用的主题的确切数量非常敏感。这意味着,只要你使用足够多的主题,无论你使用 100 个主题还是 200 个主题,这个过程产生的推荐都不会有很大的不同。然而,100 通常是一个足够好的数字(而 20 对于一般的文本文档集合来说太少了),但是如果我们有更多的文档,我们本可以使用更多的。
设置alpha
值也是如此。虽然玩弄它可以改变话题,但就这种变化而言,最终结果还是很稳健的。自然,这取决于你的数据的确切性质,应该根据经验进行测试,以确保结果确实是稳定的。
Topic modeling is often an end toward a goal. In that case, it is not always very important which parameter values are used exactly. A different number of topics or values for parameters such as alpha
will result in systems whose end results are almost identical in their final results.
另一方面,如果你打算直接探索这些主题,或者构建一个可视化工具来展示它们,你可能应该尝试一些值,看看哪个给你最有用或最有吸引力的结果。还有一些统计概念,如困惑,可以用来确定一系列模型中哪一个最适合数据,从而做出更明智的决定。
或者,有几种方法可以根据数据集自动确定主题的数量。一个流行的模型叫做分层狄利克雷过程 ( HDP )。同样,其背后的完整数学模型很复杂,超出了本书的范围。然而,我们可以告诉你的是,不是像在 LDA 生成方法中那样首先固定主题,而是主题本身与数据一起生成,一次一个。每当作者创建一个新文档时,他们可以选择使用已经存在的主题或者创建一个全新的主题。当创建了更多的主题时,创建新主题而不是重用现有主题的可能性就会降低,但可能性总是存在的。
这意味着我们拥有的文档越多,我们最终得到的主题也就越多。这是那些一开始不直观,但经过思考后完全有意义的陈述之一。我们正在对文档进行分组,我们拥有的示例越多,就越能分解它们。如果我们只有几篇新闻文章的例子,那么体育将是一个话题。然而,随着我们拥有的越来越多,我们开始把它分解成单独的形式:曲棍球、足球等等。因为我们有了更多的数据,我们可以开始区分细微差别,关于单个团队甚至单个球员的文章。人也是如此。在一个由许多不同背景的人组成的小组里,有几个电脑人,你可能会把他们放在一起;在稍微大一点的小组中,你将为程序员和系统管理员举行单独的聚会;而在现实世界中,我们甚至为 Python 和 Ruby 程序员举办了不同的聚会。
HDP 在gensim
有售。使用它是微不足道的。为了修改我们为 LDA 编写的代码,我们只需要将对gensim.models.ldamodel.LdaModel
的调用替换为对HdpModel
构造函数的调用,如下所示:
hdp = gensim.models.hdpmodel.HdpModel(mm, id2word)
就是这样(只是计算时间长一点——没有免费午餐)。现在,我们可以像使用 LDA 模型一样使用这个模型,只是我们不需要指定主题的数量。
摘要
在本章中,我们讨论了主题建模。主题建模比聚类更灵活,因为这些方法允许每个文档在多个组中部分呈现。为了探索这些方法,我们使用了一个新的包,gensim
。
主题建模最初是为文本开发的,在文本的情况下更容易理解,但是在第 12 章、计算机视觉中,我们将看到这些技术如何应用于图像。主题模型在现代计算机视觉研究中非常重要。事实上,与前几章不同,这一章非常接近机器学习算法研究的前沿。最初的 LDA 算法发表在 2003 年的一份科学杂志上,但是gensim
用来处理维基百科的方法直到 2010 年才被开发出来,而 HDP 算法是从 2011 年开始的。研究还在继续,你可以找到很多名字很棒的变体和模式,比如印度自助餐流程(不要和中餐厅流程混淆,中餐厅流程是不同的模式)或者弹球分配(弹球是日本游戏的一种,是吃角子老虎和弹球的交叉)。
我们现在已经经历了一些主要的机器学习模式:分类、聚类和主题建模。
在第 11 章、分类三-音乐流派分类中,我们回到分类,但这次我们将探索先进的算法和方法。
十一、分类三——音乐类型分类
到目前为止,我们很幸运,每个训练数据实例都可以很容易地用特征值向量来描述。例如,在 Iris 数据集中,花由包含花的某些方面的长度和宽度值的向量表示。在基于文本的示例中,我们可以将文本转换为单词包表示,并手动创建自己的特征来捕捉文本的某些方面。
这一章会有所不同,当我们试图根据歌曲的流派来分类时。例如,我们如何表现一首三分钟长的歌曲?我们应该把它的 MP3 表现的个别位?可能不会,因为像文本一样对待它并创建一个类似于一包声音片段的东西肯定会非常复杂。不知何故,我们将不得不把一首歌转换成能充分描述它的价值矢量。
绘制我们的路线图
本章将向您展示我们如何在超出我们舒适范围的领域中提出一个合适的分类器。首先,我们将不得不使用基于声音的功能,这比我们目前使用的基于文本的功能要复杂得多。然后我们将学习如何处理比以前更多的课程。此外,我们将了解衡量分类绩效的新方法。
让我们假设一个场景,出于某种原因,我们在硬盘上发现了一堆随机命名的 MP3 文件,这些文件被假设包含音乐。我们的任务是根据音乐类型将它们分类到不同的文件夹中,如爵士乐、古典音乐、乡村音乐、流行音乐、摇滚音乐和金属音乐。
获取音乐数据
我们将使用 GTZAN 数据集,该数据集经常用于音乐流派分类任务的基准测试。它被组织成 10 个不同的流派,为了简单起见,我们将只使用其中的 6 个:古典、爵士、乡村、流行、摇滚和金属。数据集包含每个流派 100 首歌曲的前 30 秒。我们可以从http://opihi.cs.uvic.ca/sound/genres.tar.gz下载数据集。
我们可以直接用 Python 下载和提取它,这很好,尤其是如果你使用的是 Windows,它没有附带 tarball 解压程序。
在整个 Jupyter 笔记本中,我们将利用优秀的pathlib
库,该库自 3.4 版本以来就是 Python 的一部分。它允许简单的路径和文件操作:
from pathlib import Path
DATA_DIR = "data"
if not Path(DATA_DIR).exists():
os.mkdir(DATA_DIR)
import urllib.request
genre_fn = 'http://opihi.cs.uvic.ca/sound/genres.tar.gz'
# The division operator of Path instances is overloaded to behave
# like os.path.join(), which makes it very convenient to use.
urllib.request.urlretrieve(genre_fn, Path(DATA_DIR) / 'gen-res.tar.gz')
现在我们已经下载了它,我们使用tarfile
模块提取它:
import tarfile
cwd = os.getcwd()
os.chdir(DATA_DIR)
try:
f = tarfile.open('genres.tar.gz', 'r:gz')
try:
f.extractall()
finally:
f.close()
finally:
os.chdir(cwd)
转换为 WAV 格式
果然,如果我们想在我们的私人 MP3 收藏上测试我们的分类器,我们将无法提取太多的意义。这是因为 MP3 是一种有损音乐压缩格式,它会剪切掉人耳无法感知的部分。这很适合存储,因为有了 MP3,你可以在你的设备上存储 10 倍多的歌曲。然而,对于我们的努力来说,情况并不那么好。对于分类,我们使用 WAV 文件会更容易,因为它们可以被scipy.io.wavfile
包直接读取。因此,如果我们想使用我们的分类器,我们必须转换我们的 MP3 文件。
如果附近没有转换工具,您可能想查看 SoX:http://sox.sourceforge.net。它声称是声音处理的瑞士军刀,我们同意这个大胆的说法。
然而,GTZAN 数据集附带的音乐文件不是 MP3 格式,而是 AU 格式,这意味着我们必须一个文件一个文件地转换它。以下片段是 Jupyter 笔记本中可能出现的一个巧妙技巧:它方便地允许我们运行系统命令,例如 Python 环境中的sox
声音转换器。我们只需在命令行前面加上感叹号(!
)并使用花括号传递 Python 表达式:
GENRE_DIR = Path(DATA_DIR) / 'genres'
# You need to adapt the SOX_PATH accordingly on your system
SOX_PATH = r'C:\Program Files (x86)\sox-14-4-2'
for au_fn in Path(GENRE_DIR).glob('**/*.au'):
print(au_fn)
!"{SOX_PATH}/sox.exe" {au_fn} {au_fn.with_suffix('.wav')}
当然,所有这些都可以在普通的 Linux 或 Windows 外壳中完成,但是需要更多的外壳专业知识。
我们所有的音乐文件都是 WAV 格式的一个优点是它可以被 SciPy 工具包直接读取:
>>> sample_rate, X = scipy.io.wavfile.read(wave_filename)
X
现在包含样本,sample_rate
是获取样本的速率。让我们利用这些信息来浏览一些音乐文件,以了解数据是什么样子的。
看音乐
快速了解不同流派的歌曲“外观”的一个非常方便的方法是为一个流派中的一组歌曲绘制一个声谱图。声谱图是歌曲中出现的频率的视觉表示。它在 x 轴上显示指定时间间隔内 y 轴上频率的强度。在下面的声谱图中,这意味着在歌曲的特定时间窗口中,颜色越亮,频率越强。
Matplotlib 提供了方便的specgram()
功能,为我们执行大部分的幕后计算和绘图:
>>> import scipy.io.wavfile
>>> 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), cmap='hot')
我们刚刚读入的 WAV 文件是以22050
Hz 的速率采样的,并且包含661794
个样本。
如果我们现在为不同的 WAV 文件绘制这前 30 秒的声谱图,我们可以看到相同流派的歌曲之间存在共性,如下图所示:
只要看一眼图像,我们就能立即看到例如金属和古典歌曲之间的光谱差异。虽然金属歌曲在大部分频谱上一直具有高强度(它们充满活力!),古典歌曲呈现出更加多样化的格局。
应该可以训练出一个分类器,它至少能以足够高的准确度区分金属和古典歌曲。不过,乡村和摇滚等其他类型的组合可能会构成更大的挑战。这对我们来说似乎是一个真正的挑战,因为我们不仅需要辨别两个类别,还需要辨别六个类别。我们需要能够合理地区分它们。
将音乐分解成正弦波成分
我们的计划是从原始样本读数(之前存储在X
中)中提取单个频率强度,并将其输入分类器。这些频率强度可以通过应用快速傅立叶变换 ( 快速傅立叶变换)来提取,快速傅立叶变换将波信号转换成其频率分量的系数。由于快速傅立叶变换背后的理论超出了本章的范围,让我们只看一个例子来了解它实现了什么。稍后,我们将把它当作一个黑盒特征提取器。
比如我们生成两个 WAV 文件,sine_a.wav
和sine_b.wav
,分别包含 400 Hz 和 3000Hz 正弦波的声音。前面提到的瑞士军刀sox
,是在命令行上实现这一点的一种方法(或者通过在前面加上感叹号直接从 Jupyter 获得):
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 秒。在下面的图像中,我们可以看到正弦波的快速傅立叶变换。毫不奇怪,我们在相应的正弦波下面看到400
Hz 和3000
Hz 的尖峰。
现在,让我们将两者混合,使 400 赫兹的声音只有 3000 赫兹声音的一半:
sox --combine mix --volume 1 sine_b.wav --volume 0.5 sine_a.wav
sine_mix.wav
我们在组合声音的快速傅立叶变换图中看到两个尖峰,其中 3000 赫兹的尖峰几乎是 400 赫兹的两倍:
对于真正的音乐,我们很快会发现 FFT 看起来并不像前面的例子中那么漂亮:
使用快速傅立叶变换构建我们的第一个分类器
我们现在可以使用 FFT 创建歌曲的音乐指纹。如果我们对几首歌曲这样做,并手动分配它们相应的流派作为标签,我们就有了可以输入到第一个分类器中的训练数据。
增加实验灵活性
在我们深入分类器训练之前,让我们考虑一下实验敏捷性。虽然我们在快速傅立叶变换中有“快速”这个词,但它比我们基于文本的章节中的功能创建要慢得多。因为我们还处于实验阶段,我们可能会考虑如何加快整个特征创建过程。
当然,每次运行分类器时,每个文件的快速傅立叶变换的创建都是相同的。因此,我们可以缓存它并读取缓存的 FFT 表示,而不是完整的 WAV 文件。我们用create_fft()
函数来做,该函数反过来使用scipy.fft()
来创建快速傅立叶变换。为了简单(和速度!),让我们将本例中 FFT 组件的数量固定为前 1000 个。以我们目前的知识,我们不知道这些对于音乐类型分类是否是最重要的,只知道它们在前面的快速傅立叶变换例子中显示了最高的强度。如果我们以后想使用更多或更少的快速傅立叶变换组件,我们必须为每个声音文件重新创建快速傅立叶变换表示:
import numpy as np
import scipy
def create_fft(fn):
sample_rate, X = scipy.io.wavfile.read(fn)
fft_features = abs(scipy.fft(X)[:1000])
np.save(Path(fn).with_suffix('.fft'), fft_features)
for wav_fn in Path(GENRE_DIR).glob('**/*.wav'):
create_fft(wav_fn)
我们使用 NumPy 的save()
函数保存数据,该函数总是将.npy
附加到文件名中。我们只需要为训练或预测所需的每个 WAV 文件做一次。
对应的 FFT 读取功能为read_fft()
:
def read_fft(genre_list, base_dir=GENRE_DIR):
X = []
y = []
for label, genre in enumerate(genre_list):
genre_dir = Path(base_dir) / genre
for fn in genre_dir.glob("*.fft.npy"):
fft_features = np.load(fn)
X.append(fft_features[:1000])
y.append(label)
return np.array(X), np.array(y)
在我们的加扰music
目录中,我们期待以下音乐流派:
GENRES = ["classical", "jazz", "country", "pop", "rock", "metal"]
训练分类器
让我们使用逻辑回归分类器,它已经在第 9 章、分类二–情感分析中为我们提供了很好的服务:
from sklearn.linear_model.logistic import LogisticRegression
def create_model():
return LogisticRegression()
仅提一个令人惊讶的方面:当第一次从二进制转换到多类分类时的准确率评估。在二进制分类问题中,我们知道 50%的准确率是最坏的情况,因为这可以通过随机猜测来实现。在多类设置中,50%已经很好了。以我们的六个流派为例,随机猜测的结果只有 16.7%(假设班级规模相等)。
完整的培训过程如下所示:
from collections import defaultdict
from sklearn.metrics import precision_recall_curve, roc_curve, \
confusion_matrix
from sklearn.metrics import auc
from sklearn.model_selection import ShuffleSplit
def train_model(clf_factory, X, Y):
labels = np.unique(Y)
cv = ShuffleSplit(n_splits=1, test_size=0.3, random_state=0)
train_errors = []
test_errors = []
scores = []
pr_scores = defaultdict(list)
precisions = defaultdict(list)
recalls = defaultdict(list)
thresholds = defaultdict(list)
roc_scores = defaultdict(list)
tprs = defaultdict(list)
fprs = defaultdict(list)
clfs = [] # used to later get the median
cms = []
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)
clfs.append(clf)
train_score = clf.score(X_train, y_train)
test_score = clf.score(X_test, y_test)
scores.append(test_score)
train_errors.append(1 - train_score)
test_errors.append(1 - test_score)
y_pred = clf.predict(X_test)
cm = confusion_matrix(y_test, y_pred) # will be explained soon
cms.append(cm)
for label in labels:
y_label_test = np.asarray(y_test == label, dtype=int)
proba = clf.predict_proba(X_test)
proba_label = proba[:, label]
precision, recall, pr_thresholds = preci-sion_recall_curve(
y_label_test, proba_label)
pr_scores[label].append(auc(recall, precision))
precisions[label].append(precision)
recalls[label].append(recall)
thresholds[label].append(pr_thresholds)
fpr, tpr, roc_thresholds = roc_curve(y_label_test,
pro-ba_label)
roc_scores[label].append(auc(fpr, tpr))
tprs[label].append(tpr)
fprs[label].append(fpr)
all_pr_scores = np.asarray(pr_scores.values()).flatten()
summary = (np.mean(scores), np.std(scores),
np.mean(all_pr_scores), np.std(all_pr_scores))
print("%.3f\t%.3f\t%.3f\t%.3f\t" % summary)
return np.mean(train_errors), np.mean(test_errors), np.asarray(cms)
整个训练调用如下:
X, Y = read_fft(GENRES)
train_avg, test_avg, cms = train_model(create_model, X, Y)
在多类问题中使用混淆矩阵来测量精确度
对于多类问题,我们不应该只对如何正确地对体裁进行分类感兴趣。我们还应该调查哪些体裁我们彼此混淆。这可以通过适当命名的混淆矩阵来完成,您可能已经注意到这是培训过程的一部分:
>>> cm = confusion_matrix(y_test, y_pred)
如果我们打印出混淆矩阵,我们会看到如下内容:
[[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 as plt
def plot_confusion_matrix(cm, genre_list, name, title):
plt.clf()
plt.matshow(cm, fignum=False, cmap='Blues', vmin=0, vmax=1.0)
ax = plt.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)
ax.tick_params(axis='both', which='both', bottom='off', left='off')
plt.title(title)
plt.colorbar()
plt.grid(False)
plt.show()
plt.xlabel('Predicted class')
plt.ylabel('True class')
plt.grid(False)
创建混淆矩阵时,一定要选择一个颜色顺序合适的颜色映射图(matshow()
的cmap
参数),这样就可以立即看到较浅或较深的颜色意味着什么。特别不推荐这类图形是彩虹色地图,例如,matplotlib
实例默认 jet 甚至成对的彩色地图。
最终的图表如下所示:
对于一个完美的分类器,我们希望左上角到右下角有一个对角的深色方块,其余区域有浅色。在上图中,我们立即看到我们基于快速傅立叶变换的分类器远非完美。它只能正确预测古典歌曲(暗方)。例如,对于岩石,大多数时候它更喜欢标签金属。
显然,使用快速傅立叶变换为我们指出了正确的方向(古典流派没有那么糟糕),但不足以获得一个像样的分类器。当然,我们可以使用快速傅立叶变换组件的数量(固定为 1000)。但是在我们深入研究参数调整之前,我们应该先做研究。在那里,我们发现快速傅立叶变换确实是流派分类的一个不错的特征——它只是不够精炼。很快,我们将看到如何通过使用它的处理版本来提高我们的分类性能。
然而,在此之前,我们将学习另一种测量分类性能的方法。
使用接收器-操作器特性测量分类器性能的另一种方法
我们已经了解到,测量精度不足以真正评估分类器。相反,我们依靠精确-回忆(P/R)曲线来更深入地理解我们的分类器是如何工作的。
有一个 P/R 曲线的姊妹曲线,称为接收器-操作者-特征 ( ROC ),它测量分类器性能的相似方面,但提供了分类性能的另一个视图。关键的区别在于,P/R 曲线更适合于正类比负类有趣得多的任务,或者正例数比负例数少得多的任务。信息检索和欺诈检测是典型的应用领域。另一方面,ROC 曲线更好地描述了分类器的总体表现。
为了更好地理解差异,让我们考虑一下先前训练的分类器在正确分类乡村歌曲方面的性能,如下图所示:
在左边,我们看到了市盈率曲线。对于一个理想的分类器,我们会让曲线从左上角直接到右上角,然后到右下角,从而产生 1.0 的曲线下面积 ( AUC )。
右图描绘了相应的 ROC 曲线。它绘制了真阳性率 ( TPR )与假阳性率 ( FPR )的关系图。这里,理想的分类器会有一条从左下角到左上角,然后到右上角的曲线。随机分类器将是从左下角到右上角的直线,如虚线所示,其 AUC 为 0.5。因此,我们不能将市盈率曲线的 AUC 与 ROC 曲线的 AUC 进行比较。
与曲线无关,当在同一数据集上比较两个不同的分类器时,我们总是可以安全地假设一个分类器的 P/R 曲线的较高 AUC 也意味着相应 ROC 曲线的较高 AUC,反之亦然。因此,我们从不费心去产生两者。关于这一点的更多信息可以在 Davis 和 Goadrich 的非常有见地的论文精确-回忆和 ROC 曲线之间的关系中找到(ICML,2006)。
下表总结了市盈率和 ROC 曲线之间的差异:
| | x 轴 | y 轴 |
| 损益 | |
|
| 皇家对空观察队 | |
|
查看“ x 和 y 轴的定义,我们看到 ROC 曲线 y 轴的 TPR 与 P/R 图 x 轴的 Recall 相同。
FPR 测量了被错误归类为阳性的真实阴性样本的比例,范围从完美情况下的0
(无false
阳性)到1
(均为false
阳性)。
接下来,让我们使用 ROC 曲线来衡量我们的分类器的性能,以获得更好的感觉。我们的多类问题的唯一挑战是 ROC 和 P/R 曲线都假设一个二元分类问题。因此,出于我们的目的,让我们为每个流派创建一个图表,显示分类器是如何执行一对一分类的:
from sklearn.metrics import roc_curve
y_pred = clf.predict(X_test)for label in labels:
y_label_test = np.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 图(同样,完整的代码,请遵循随附的 Jupyter 笔记本)。正如我们已经发现的,我们的第一个版本的分类器只在古典歌曲上表现良好。然而,从单个 ROC 曲线来看,我们确实在其他大部分流派中表现不佳。只有爵士乐和乡村音乐提供了一些希望。其余流派显然不可用:
利用 mel 频率倒谱系数提高分类性能
我们已经了解到,快速傅立叶变换为我们指明了正确的方向,但其本身不足以最终得出一个分类器,成功地将我们的歌曲加扰目录组织成单个流派目录。我们需要一个更先进的版本。
在这一点上,我们必须做更多的研究。其他人过去可能也遇到过类似的挑战,并且已经找到了可能对我们有帮助的方法。事实上,甚至还有一个由国际音乐信息检索协会组织的年度音乐流派分类会议。显然,自动音乐流派分类 ( AMGC )是音乐信息检索的一个既定子领域。浏览一些 AMGC 论文,我们可以看到有一堆针对自动体裁分类的工作可能会帮助我们。
一种似乎在许多情况下成功应用的技术叫做 mel 频率倒频谱 ( MFC )系数。MFC 对声音的功率谱进行编码,功率谱是声音包含的每个频率的功率。它被计算为信号频谱对数的傅里叶变换。如果听起来太复杂,只需记住倒谱这个名字来源于谱,前四个字符颠倒即可。MFC 已成功应用于语音和说话人识别。让我们看看它是否也适用于我们。我们很幸运,因为其他人已经确切地需要这个,并且发布了它的实现作为python_speech_features
模块的一部分。我们可以用pip
轻松安装。之后,我们可以调用mfcc()
函数,计算 MFC 系数,如下所示:
>>> from python_speech_features import mfcc
>>> fn = Path(GENRE_DIR) / 'jazz' / 'jazz.00000.wav'
>>> sample_rate, X = scipy.io.wavfile.read(fn)
>>> ceps = mfcc(X)
>>> print(ceps.shape)
(4135, 13)
ceps
包含歌曲的每个4135
帧的13
系数(默认为mfcc()
的num_ceps
参数)。获取所有的数据会让我们的分类器不堪重负。相反,我们可以做的是对所有帧的每个系数取平均值。假设每首歌的开头和结尾可能没有中间部分那么特定于流派,我们也忽略了第一个和最后 10%:
>>> num_ceps = ceps.shape[0]
>>> np.mean(ceps[int(num_ceps*0.1):int(num_ceps*0.9)], axis=0)
array([ 16.43787597, 7.44767565, -13.48062285, -7.49451887,
-8.14466849, -4.79407047, -5.53101133, -5.42776074,
-8.69278344, -6.41223865, -3.01527269, -2.75974429, -3.61836327])
果然,我们将使用的基准数据集只包含每首歌的前 30 秒,所以我们不需要剪掉最后 10%。我们无论如何都会这样做,这样我们的代码也可以在其他数据集上工作,这些数据集很可能不会被截断。
与我们使用 FFT 的工作类似,我们也希望缓存曾经生成的 MFCC 特征并读取它们,而不是每次训练分类器时都重新创建它们。
这将导致以下代码:
def create_ceps(fn):
sample_rate, X = scipy.io.wavfile.read(fn)
np.save(Path(fn).with_suffix('.ceps'), mfcc(X))
for wav_fn in Path(GENRE_DIR).glob('**/*.wav'):
create_fft(wav_fn)
def read_ceps(genre_list, base_dir=GENRE_DIR):
X = []
y = []
for label, genre in enumerate(genre_list):
genre_dir = Path(base_dir) / genre
for fn in genre_dir.glob("*.ceps.npy"):
ceps = np.load(fn)
num_ceps = len(ceps)
X.append(np.mean(ceps[int(num_ceps / 10):int(num_ceps * 9 / 10)], axis=0))
y.append(label)
return np.array(X), np.array(y)
使用每首歌曲仅使用 13 个特征的分类器,我们得到了以下有希望的结果:
所有流派的分类性能都有所提高。古典和金属的 AUC 几乎都在 1.0。事实上,以下情节中的混乱矩阵现在看起来好多了。我们可以清楚地看到对角线,表明分类器在大多数情况下能够正确地对流派进行分类。这个分类器实际上非常适用于解决我们的初始任务:
如果我们想改进这一点,这个混淆矩阵会很快告诉我们应该关注什么:非对角线位置上的非白点。例如,我们有一个更黑暗的地方,我们错误地将摇滚歌曲贴上了爵士的标签,这种可能性很大。为了解决这个问题,我们可能需要更深入地研究歌曲,提取一些东西,比如鼓的模式和类似的特定流派的特征。然后,在浏览 ISMIR 论文的同时,我们还阅读了关于听觉滤波器组时间包络 ( AFTE )特征的文章,这些特征在某些情况下似乎优于 MFCC 特征。也许我们也应该看看他们?
好的一点是,只有配备了 ROC 曲线和混淆矩阵,我们才可以自由地在特征提取器方面引入其他专家的知识,而不必完全了解他们的内部工作方式。我们的测量工具总是会告诉我们什么时候方向是对的,什么时候该改变。当然,作为一个渴望学习的机器学习者,我们总会有一种感觉,在我们的特征提取器的黑匣子里,某个地方埋藏着一个令人兴奋的算法,就等着我们被理解。
使用张量流的音乐分类
我们可以用我们的特征输入张量流吗?当然可以!但是让我们试着利用这个机会实现另外两个目标:
- 我们将使 TensforFlow 分类器的行为类似于
sklearn
分类器,以便在所有兼容函数中重用。 - 即使神经网络可以提取任何特征,它们仍然需要被设计和训练来提取它们。在这个例子中,从原始声音文件开始,我们将向您展示,获得比倒谱系数更好的结果是不够的。
但是让我们切入正题,设置我们的超参数:
import tensorflow as tf
import numpy as np
n_epochs = 50
learning_rate = 0.01
batch_size = 128
step = 32
dropout_rate = 0.2
signal_size = 1000
signal_shape = [signal_size,1]
我们从 600 个样本开始,但为了向训练中添加更多数据,我们将把文件分成几个块:
def read_wav(genre_list, multiplicity=1, base_dir=GENRE_DIR):
X = []
y = []
for label, genre in enumerate(genre_list):
genre_dir = Path(base_dir) / genre
for fn in genre_dir.glob("*.wav"):
sample_rate, new_X = scipy.io.wavfile.read(fn)
for i in range(multiplicity):
X.append(new_X[i*signal_size:(i+1)*signal_size])
y.append(label)
return np.array(X).reshape((-1, signal_size, 1)), np.array(y)
从每个文件中,我们将获得 20 个较短的样本:
from sklearn.model_selection import train_test_split
X, Y = read_wav(GENRES, 20)
classes = len(GENRES)
X_train, X_test, Y_train, Y_test = train_test_split(X, Y,
test_size=(1\. / 6.))
我们的网络将非常类似于图像分类网络。我们将使用 1D 层代替 2D 卷积层。我们还将添加一个获取池大小的参数。以前的网络使用小的 2D 池,我们可能不得不使用更大的池。
我们还将使用脱落层。由于样本不多,我们不得不避免网络泛化不良。这将帮助我们实现这一目标:
class CNN():
def __init__(
self,
signal_shape=[1000,1],
dim_W1=64,
dim_W2=32,
dim_W3=16,
classes=6,
kernel_size=5,
pool_size=16
):
self.signal_shape = signal_shape
self.dim_W1 = dim_W1
self.dim_W2 = dim_W2
self.dim_W3 = dim_W3
self.classes = classes
self.kernel_size = kernel_size
self.pool_size = pool_size
def build_model(self):
image = tf.placeholder(tf.float32, [None]+self.signal_shape, name="signal")
Y = tf.placeholder(tf.int64, [None], name="label")
probabilities = self.discriminate(image, training)
cost = tf.reduce_mean(tf.nn.sparse_softmax_cross_entropy_with_logits(labels=Y,
logits=probabilities))
accuracy = tf.reduce_mean(tf.cast(tf.equal(tf.argmax(probabilities, ax-is=1),
Y), tf.float32), name="accuracy")
return image, Y, cost, accuracy, probabilities
在这里,我们重用我们在第 8 章、人工神经网络和深度学习中看到的sparse_softmax_cross_entropy_with_logits cost-helper
函数。提醒一下,它将整数目标与图层进行比较,以便具有最大值的节点与该目标匹配:
def create_conv1d(self, input, filters, kernel_size, name):
layer = tf.layers.conv1d(
inputs=input,
filters=filters,
kernel_size=kernel_size,
activation=tf.nn.leaky_relu,
name="Conv1d_" + name,
padding="same")
return layer
def create_maxpool(self, input, name):
layer = tf.layers.max_pooling1d(
inputs=input,
pool_size=[self.pool_size],
strides=self.pool_size,
name="MaxPool_" + name)
return layer
def create_dropout(self, input, name, is_training):
layer = tf.layers.dropout(
inputs=input,
rate=dropout_rate,
name="DropOut_" + name,
training=is_training)
return layer
def create_dense(self, input, units, name):
layer = tf.layers.dense(
inputs=input,
units=units,
name="Dense" + name,
)
layer = tf.layers.batch_normalization(
inputs=layer,
momentum=0,
epsilon=1e-8,
training=True,
name="BatchNorm_" + name,
)
layer = tf.nn.leaky_relu(layer, name="LeakyRELU_" + name)
return layer
def discriminate(self, signal, training):
h1 = self.create_conv1d(signal, self.dim_W3, self.kernel_size, "Layer1")
h1 = self.create_maxpool(h1, "Layer1")
h2 = self.create_conv1d(h1, self.dim_W2, self.kernel_size, "Layer2")
h2 = self.create_maxpool(h2, "Layer2")
h2 = tf.reshape(h2, (-1, self.dim_W2 * h2.shape[1]))
h3 = self.create_dense(h2, self.dim_W1, "Layer3")
h3 = self.create_dropout(h3, "Layer3", training)
h4 = self.create_dense(h3, self.classes, "Layer4")
return h4
如前所述,可以将我们的网络封装在一个sklearn
对象BaseEstimator
中。这些估计器有一组从构造函数中提取的参数,正如我们在这里看到的:
from sklearn.base import BaseEstimator
class Classifier(BaseEstimator):
def __init__(self,
signal_shape=[1000,1],
dim_W1=64,
dim_W2=32,
dim_W3=16,
classes=6,
kernel_size=5,
pool_size=16):
self.signal_shape=signal_shape
self.dim_W1=dim_W1
self.dim_W2=dim_W2
self.dim_W3=dim_W3
self.classes=classes
self.kernel_size=kernel_size
self.pool_size=pool_size
fit
方法是创建和训练模型的方法。这里我们还保存了网络和变量的状态:
def fit(self, X, y):
tf.reset_default_graph()
print("Fitting (W1=%i) (W2=%i) (W3=%i) (kernel=%i) (pool=%i)"
% (self.dim_W1, self.dim_W2, self.dim_W3, self.kernel_size, self.pool_size))
cnn_model = CNN(
signal_shape=self.signal_shape,
dim_W1=self.dim_W1,
dim_W2=self.dim_W2,
dim_W3=self.dim_W3,
classes=self.classes,
kernel_size=self.kernel_size,
pool_size=self.pool_size
)
signal_tf, Y_tf, cost_tf, accuracy_tf, output_tf = cnn_model.build_model()
train_step = tf.train.AdamOptimizer(learning_rate, be-ta1=0.5).minimize(cost_tf)
saver = tf.train.Saver()
with tf.Session() as sess:
sess.run(tf.global_variables_initializer())
for epoch in range(n_epochs):
permut = np.random.permutation(len(X_train))
for j in range(0, len(X_train), batch_size):
batch = permut[j:j+batch_size]
Xs = X_train[batch]
Ys = Y_train[batch]
sess.run(train_step,
feed_dict={
Y_tf: Ys,
signal_tf: Xs
})
saver.save(sess, './classifier')
return self
然后在predict
方法中恢复它们,我们将使用训练好的网络对新数据进行分类:
def predict(self, X):
tf.reset_default_graph()
new_saver = tf.train.import_meta_graph("classifier.meta")
with tf.Session() as sess:
new_saver.restore(sess, tf.train.latest_checkpoint('./'))
graph = tf.get_default_graph()
training_tf = graph.get_tensor_by_name('is_training:0')
signal_tf = graph.get_tensor_by_name('signal:0')
output_tf = graph.get_tensor_by_name('LeakyRELU_Layer4/Maximum:0')
predict = sess.run(output_tf,
feed_dict={
training_tf: False,
signal_tf: X
})
return np.argmax(predict, axis=1)
我们还没有在这个估算器中创建一个score
方法,但是我们可以使用sklearn
API 从predict
方法中创建一个。
现在我们将使用这个估计量,并进行网格搜索,以找到一组合适的超参数。由于我们没有很多样本,我们将在每个卷积层以及密集层上仅使用少数几个单元。我们还想提取更好的过滤器。毕竟,声音通常需要更大的过滤器来提取有意义的特征:
from sklearn.model_selection import GridSearchCV
from sklearn.metrics import accuracy_score, make_scorer
param_grid = {
"dim_W1": [4, 8, 16],
"dim_W2": [4, 8, 16],
"dim_W3": [4, 8, 16],
"kernel_size":[7, 11, 15],
"pool_size":[8, 12, 16],
}
cv = GridSearchCV(Classifier(), param_grid, scor-ing=make_scorer(accuracy_score), cv=6)
cv.fit(X, Y)
print(cv.best_params_)
{'dim_W1': 4, 'dim_W2': 4, 'dim_W3': 16, 'kernel_size': 15, 'pool_size': 12}
现在我们已经花了几个小时找到了一组足够的超参数,我们可以用它来检查混淆矩阵:
clf = Classifier(**cv.best_params_)
clf.fit(X_train, Y_train)
Y_train_predict = clf.predict(X_train)
Y_test_predict = clf.predict(X_test)
from sklearn.metrics import confusion_matrix
cm = confusion_matrix(Y_train, Y_train_predict)
plot_confusion_matrix(cm / np.sum(cm, axis=0), GENRES, "CNN",
"Confusion matrix of a CNN based classifier (train)")
cm = confusion_matrix(Y_test, Y_test_predict)
plot_confusion_matrix(cm / np.sum(cm, axis=0), GENRES, "CNN",
"Confusion matrix of a CNN based classifier (test)")
请参考以下图表:
即使有最好的参数,这也表明了我们之前所说的:你也需要有足够的特性。你可以用电脑来训练一个更好的网络,但是你也需要更多的数据。
在倒频谱特征上尝试此网络(或具有不同层配置的网络)。能达到更好的分类吗?它能匹配我们之前创建的最佳LogisticRegression
分类器吗?来看看吧!
摘要
在这一章中,当我们构建音乐类型分类器时,我们走出了舒适区。由于对音乐理论了解不深,一开始我们没有训练出一个分类器,用 FFT 以合理的精度预测歌曲的音乐流派。但是,然后,我们创建了一个使用 MFC 特性显示真正可用性能的分类器。
在这两种情况下,我们使用的特征,我们只了解知道如何和在哪里把它们放在我们的分类器设置。第一次失败,第二次成功。它们之间的区别在于,在第二种情况下,我们依赖于该领域专家创建的功能。
这是完全可以的。如果我们主要对结果感兴趣,我们有时只需要走捷径——我们只需要确保这些捷径来自特定领域的专家。因为我们已经学会了如何在这个新的多类分类问题中正确衡量性能,所以我们自信地走了这些捷径。
在第 12 章、计算机视觉中,将看一下图像处理、特征表示、CNN 和 GAN。*
十二、计算机视觉
图像分析和计算机视觉在工业和科学应用中一直很重要。随着具有强大摄像头和互联网连接的手机的普及,现在图像越来越多地由消费者生成。因此,有机会利用计算机视觉在新的环境中提供更好的用户体验。
在本章中,我们将了解如何将您在本书其余部分中学习的几种技术应用于这种特定类型的数据。特别是,我们将学习如何使用mahotas
计算机视觉包从图像中提取特征。然后,这些特征可以用作我们在其他章节中研究的相同分类方法的输入。我们将把这些技术应用于公开的照片数据集。我们还将看到如何使用相同的特征来寻找相似的图像。我们还将学习如何使用本地功能。这些是相对通用的,并且在许多任务中获得非常好的结果(尽管它们具有较高的计算成本)。
最后,在本章的最后,我们将使用 Tensorflow 基于现有数据集生成新图像。特别是,在本章中,我们将执行以下操作:
- 了解如何将图像表示为 NumPy 数组并对其进行操作
- 了解如何将图像表示为一组小特征,以便在这种数据类型上使用标准分类和聚类方法
- 学习如何使用视觉单词来生成另一种类型的特征
- 了解如何生成与现有图像相似的新图像
介绍图像处理
从计算机的角度来看,图像是像素值的大矩形阵列。我们的目标是处理一个或多个图像,并为我们的应用程序做出决定。
根据设置,它可能是一个分类问题,一个聚类问题,或者我们在书中看到的任何其他问题类别。
第一步是从磁盘加载图像,图像通常以特定于图像的格式存储,如巴布亚新几内亚或 JPEG,前者是无损压缩格式,后者是有损压缩,是针对照片的视觉评估而优化的。然后,我们可能希望对图像进行预处理(例如,针对光照变化对图像进行归一化)。
加载和显示图像
为了操纵图像,我们将使用一个名为mahotas
的包。您可以通过蟒蛇获取mahotas
,并在httpT4T6】上阅读其手册://maho tas . read docs .T8】io。Mahotas 是一个开源包(它拥有麻省理工学院的许可,因此可以在任何项目中使用),由本书的作者之一开发。它基于 NumPy。因此,您迄今为止获得的 NumPy 知识可以用于图像处理。还有其他的图像包,比如scikit-image(skipage)n 维图像(ndi image)模块在 SciPy 中,以及 OpenCV 的 Python 绑定。所有这些都与 NumPy 数组一起工作,因此您甚至可以混合和匹配来自不同包的功能来构建一个组合管道。
我们首先用mh
缩写导入mahotas
,我们将在本章中使用该缩写,如下所示:
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 can use a variety of different input/output backends. Unfortunately, none of them can load all image formats that exist (there are hundreds, with several variations of each). However, the loading of PNG and JPEG images is supported by all of them. We will focus on these common formats and refer you to the mahotas
documentation on how to read uncommon formats.
我们可以使用matplotlib
在屏幕上显示图像,我们已经使用过几次的plotting
库如下:
from matplotlib import pyplot as plt
fig,ax = plt.subplots()
ax.imshow(image)
如下面的截图所示,这段代码使用第一个维度是高度,第二个维度是宽度的约定来显示图像。它也能正确处理彩色图像。当使用 Python 进行数值计算时,我们受益于整个生态系统的良好合作:mahotas
与 NumPy 数组一起工作,这些数组可以用 matplotlib 显示。稍后,我们将从图像中计算特征,以便与 scikit-learn 一起使用:
阈值化
我们从一些简单的图像处理操作开始这一章。这些不会使用机器学习,但目标是证明图像可以作为数组进行操作。当我们引入新特性时,这将很有用。
阈值化是一个非常简单的操作:我们将某个阈值以上的所有像素值转换为1
,将其以下的所有像素值转换为0
(或者使用布尔运算,将其转换为True
和False
)。阈值处理中的重要问题是选择一个好的值作为阈值限制。Mahotas 实现了一些从图像中选择阈值的方法。我们将使用一种以其发明者命名的方法Otsu
。第一个必要的步骤是将图像转换为灰度,在mahotas.colors
子模块中rgb2gray
。
除了rgb2gray
,我们还可以通过调用image.mean(2)
来获得红色、绿色和蓝色通道的平均值。然而,结果会不一样,因为rgb2gray
对不同的颜色使用不同的权重来给出主观上更令人愉悦的结果。我们的眼睛对三种基本颜色不太敏感:
image = mh.colors.rgb2grey(image, dtype=np.uint8)
fig,ax = plt.subplots()
ax.imshow(image) # Display the image
默认情况下,matplotlib 会将此单通道图像显示为假彩色图像,使用红色表示高值,蓝色表示低值。对于自然图像,灰度更合适。您可以通过以下方式选择它:
plt.gray()
现在图像以灰度显示。请注意,只有解释和显示像素值的方式发生了变化,图像数据未被触及。我们可以通过计算阈值来继续我们的处理。阈值化是两类聚类的一种形式,对于这一任务有几种方法。Otsu
就是这样一种thresholding
方法,它试图找到两组紧凑的像素组,高于阈值和低于阈值的像素组:
thresh = mh.thresholding.otsu(image)
print('Otsu threshold is {}.'.format(thresh))
Otsu threshold is 138.
fig,ax = plt.subplots()
ax.imshow(image > thresh)
该方法应用于上一张图像时,发现threshold
为138
,将地面与上方天空分开,如下图截图所示:
高斯模糊
模糊你的图像可能看起来很奇怪,但它通常有助于减少噪音,这有助于进一步的处理。有了mahotas
,就只是一个函数调用:
im16 = mh.gaussian_filter(image, 16)
请注意,我们没有将灰度图像转换为无符号整数;我们只是按原样使用了浮点结果。gaussian_filter
函数的第二个参数是过滤器的大小(过滤器的标准偏差)。较大的值会导致更多的模糊,如下图所示:
我们可以使用前面的截图和带有Otsu
的threshold
(使用前面的代码)。现在,边界更加平滑,没有锯齿边缘,如下图所示:
聚焦中心
最后一个例子向您展示了如何将 NumPy 运算符与一点点过滤混合在一起,以获得一个有趣的结果。我们从森林中一条小路的照片开始:
im = mh.imread('forest')
要分割红色、绿色和蓝色通道,我们使用以下代码。NumPy transpose
方法改变多维数组中轴的顺序:
r,g,b = im.transpose(2,0,1)
现在,我们分别过滤这三个通道,并用mh.as_rgb
从其中构建一个合成图像。该函数采用三个二维数组,执行对比度拉伸,使每个数组成为一个 8 位整数数组,然后堆叠它们,返回一个彩色 RGB 图像:
r24 = mh.gaussian_filter(r, 24.)
g24 = mh.gaussian_filter(g, 24.)
b24 = mh.gaussian_filter(b, 24.)
im24 = mh.as_rgb(r24, g24, b24)
现在,我们将两幅图像从中心向边缘混合。首先,我们需要构建一个权重数组W
,它将在每个像素包含一个归一化值,即它到中心的距离:
h, w = r.shape # height and width
Y, X = np.mgrid[:h,:w]
我们使用了np.mgrid
对象,该对象返回大小为(h, w)
的数组,其值分别对应于 y 和 x 坐标。接下来的步骤如下:
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)*im24)
最后,我们可以将两幅图像合并,使中心清晰聚焦,边缘更加柔和:
基本图像分类
我们将从专为本书收集的一个小数据集开始。它有三类:建筑物、自然场景(风景)和文字图片。每个类别有 30 张图片,它们都是用手机摄像头拍摄的,构图很少。这些图片类似于那些没有经过摄影训练的用户上传到现代网站的图片。该数据集可在伴随代码库中找到。在本章的后面,我们将看到一个更大的数据集,其中有更多的图像和更难分类的类别。
对图像进行分类时,我们从一个大的矩形数字数组(像素值)开始。如今,数百万像素很常见。我们可以尝试将所有这些数字作为特征输入到学习算法中。这不是一个很好的主意,除非你有很多数据。这是因为每个像素(甚至每个小像素组)与最终结果的关系非常间接。此外,拥有数百万像素,但仅作为少量示例图像,会导致非常困难的统计学习问题。这是我们在第三章、回归中讨论的 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
函数返回一个 4×13 的数组。第一维指的是计算要素的四个可能方向(垂直、水平、对角线和反对角线)。如果我们对方向不特别感兴趣,我们可以使用所有方向的平均值(在前面的代码中显示为haralick_features_mean
)。否则,我们可以单独使用所有功能(使用haralick_features_all
)。这个决定应该由数据集的属性来决定。在我们的例子中,我们认为水平和垂直方向应该分开。因此,我们将使用haralick_features_all
。
在mahotas
中还实现了一些其他的特性集。线性二进制模式是另一种基于纹理的特征集,它对光照变化非常鲁棒。还有其他类型的功能,包括本地功能,我们将在本章后面讨论。
有了这些特性,我们使用标准的分类方法,如logistic regression
,如下所示:
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 model_selection
cv = model_selection.LeaveOneOut()
scores = model_selection.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
这使得像素值的范围从零到三,这给了我们总共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
,它计算对数(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 = model_selection.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))
我们将只绘制数据的子集(每 10 个元素),这样查询将在顶部,返回的最近邻居在底部,如下面的代码所示:
fig, axes = plt.subplots(2, 9)
for ci,i in enumerate(range(0,90,10)):
query = images[i]
dists_query = dists[i]
closest = dists_query.argsort()
# closest[0] is same as the query image, so pick next closest
closest = closest[1]
result = images[closest]
query = mh.imread(query)
result = mh.imread(result)
axes[0, ci].imshow(query)
axes[1, ci].imshow(result)
结果显示在下面的截图中(顶部图像是查询图像,底部图像是返回的结果):
很明显,该系统并不完美,但可以找到至少在视觉上与查询相似的图像。除了一种情况之外,找到的图像都来自与查询相同的类。
分类较难的数据集
之前的数据集是一个易于使用纹理特征进行分类的数据集。事实上,从商业角度来看,许多有趣的问题都相对容易。然而,有时我们可能会面临一个更棘手的问题,需要更好、更现代的技术来获得好的结果。
我们现在将测试一个公共数据集,它具有相同的结构:几张照片被分成少量的类。课程有动物、汽车、交通和自然景观。
与我们之前讨论的三类问题相比,这些类更难区分。自然场景、建筑和文本具有非常不同的纹理。然而,在这个数据集中,纹理和颜色不是图像类的清晰标记。以下是动物类的一个例子:
这是汽车课的另一个例子:
两个对象都是在自然背景下,对象内部有大而平滑的区域。这是一个比以前的数据集更难的问题,因此我们需要使用更高级的方法。第一个改进是使用稍微强大一点的分类器。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)])
数据在数据集中不是以随机的顺序组织的:相似的图像靠得很近。因此,我们使用一个交叉验证计划来考虑被打乱的数据,这样每个文件夹都有一个更具代表性的训练集,如下面的代码所示:
cv = model_selection.KFold(n_splits=5,
shuffle=True, random_state=123)
scores = model_selection.cross_val_score(
clf, ifeatures, labels, cv=cv)
print('Accuracy: {:.1%}'.format(scores.mean()))
Accuracy: 73.4%
这对于四个类来说还不错,但是我们现在将看看我们是否可以通过使用一组不同的特性做得更好。事实上,我们将看到,我们需要将这些特性与其他方法结合起来,以获得最佳的可能结果。
局部特征表示
与我们之前使用的特征不同,局部特征是在图像的一个小区域上计算的。Mahotas 支持名为加速鲁棒特征 ( SURF )的计算类型特征。这些特征被设计为对旋转或照明变化具有鲁棒性(也就是说,它们仅在照明变化时轻微改变它们的值)。
使用这些功能时,我们必须决定在哪里计算它们。通常使用三种可能性:
- 随便地
- 在网格中
- 检测图像的感兴趣区域(一种称为关键点检测或兴趣点检测的技术)
所有这些都是有效的,并将在适当的情况下产生良好的结果。Mahotas 支持这三种方法。如果您有理由预期您的兴趣点将对应于图像中的重要区域,则使用兴趣点检测效果最佳。
我们将使用interest point
方法。用mahotas
计算特征很简单:导入正确的子模块,调用surf.surf
函数如下:
descriptors = surf.surf(im, descriptor_only=True)
descriptors_only=True
标志意味着我们只对局部特征本身感兴趣,而对它们的像素位置、大小或方向不感兴趣(单词descriptor
经常用来指代这些局部特征)。或者,我们可以使用dense sampling
方法,使用如下的surf.dense
功能:
from mahotas.features import surf
descriptors = surf.dense(im, spacing=16)
这将返回在相距 24 像素的点上计算的描述符的值。由于点的位置是固定的,所以关于兴趣点的元信息不是很有趣,并且默认不返回。在任一情况下,结果(描述符)都是 nx 64 数组,其中 n 是采样的点数。点数取决于图像的大小、内容以及传递给函数的参数。在这个例子中,我们使用默认设置,每个图像获得几百个描述符。
我们不能将这些描述符直接输入到支持向量机、逻辑回归机或类似的分类系统中。为了使用来自图像的描述符,有几种解决方案。我们可以对它们进行平均,但是这样做的结果不是很好,因为它们会丢弃所有特定于位置的信息。在这种情况下,我们将只有另一个基于边缘测量的全局特征集。
我们这里要用到的解决方案就是包字模型。它于 2004 年首次出版,但这是一个明显的后知后觉的想法;实现起来非常简单,取得了很好的效果。
在处理图像的时候说字可能会显得很奇怪。如果你认为你没有书面文字,很容易区分,而是口头音频,这可能更容易理解。现在,每次说一个单词,听起来都会略有不同,不同的说话人会有自己的发音。因此,一个单词的波形不会在每次说的时候都一样。然而,通过在这些波形上使用聚类,我们可以希望恢复大部分结构,以便给定单词的所有实例都在同一聚类中。即使过程不完美(也不会完美),我们仍然可以谈论将波形分组为单词。
我们对图像数据执行相同的操作:我们将所有图像中看起来相似的区域聚集在一起,并将这些视觉单词称为。
The number of words used does not usually have a big impact on the final performance of the algorithm. Naturally, if the number is extremely small (10 or 20, when you have a few thousand images), then the overall system will not perform well. Similarly, if you have too many words (many more than the number of images, for example), the system will also not perform well. However, in between these two extremes, there is often a very large plateau, where you can choose the number of words without a big impact on the result. As a rule of thumb, using a value such as 256
, 512
, or 1,024
if you have many images should give you a good result.
我们将从计算以下特征开始:
alldescriptors = []
for im in images:
im = mh.imread(im, as_grey=True)
im = im.astype(np.uint8)
alldescriptors.append((surf.surf(im, descriptor_only=True))
# get all descriptors into a single array
concatenated = np.concatenate(alldescriptors)
现在,我们使用 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.bincount(c, minlength=256))
# build single array and convert to float
sfeatures = np.array(sfeatures, dtype=float)
这个循环的最终结果是sfeatures[fi, fj]
是图像fi
包含元素fj
的次数。用np.histogram
函数可以更快地计算出同样的结果,但是要让参数恰到好处有点棘手。我们将结果转换为浮点,因为我们不需要整数算术(及其舍入语义)。
结果是,每个图像现在由相同大小的单个特征阵列表示(在我们的例子中,簇的数量是 256)。因此,我们可以使用如下标准分类方法:
scores = model_selection.cross_val_score(
clf, sfeatures, labels, cv=cv)
print('Accuracy: {:.1%}'.format(scores.mean()))
Accuracy: 62.4%
这比以前更糟糕了!我们一无所获吗?
事实上,我们有,因为我们可以将所有特征组合在一起以获得百分之76.7
的准确度,如下所示:
allfeatures = np.hstack([ifeatures, sfeatures]) scores = model_selection.cross_val_score( clf, allfeatures, labels, cv=cv) print('Accuracy: {:.1%}'.format(scores.mean()))
Accuracy: 76.7%
这是我们拥有的最好的结果,比任何单一的特征集都好。这是由于局部 SURF 特征足够不同,以向我们之前拥有的全局图像特征添加新信息,并改善组合结果。
敌对网络下的图像生成
生成性对抗网络 ( GANs )是一种新的、潮流的网络类型。他们的主要吸引力是生殖方面。这意味着我们可以训练一个网络来生成一个类似于引用的新数据样本。
几年前,研究人员使用深度信念网络 ( DBN )来完成这项任务,它由一个可见层和一组内部层组成,最终会反复出现。训练这样的网络相当困难,所以人们考虑新的架构。
进入我们的 GAN。我们如何训练网络来生成类似于参考的样本?首先,我们需要设计一个发电机网络。通常,我们需要一组随机变量,这些变量将被输入到一组密集的conv2d_transpose
层中。后者与conv2d
层相反,从看起来像卷积输出的输入到看起来像卷积输入的输出。
现在,为了训练这个网络,我们使用对抗性部分。诀窍是训练另一个网络,即鉴别器,以检测样本是真实样本还是生成样本。一次迭代将训练鉴别器以增强其鉴别能力,之后的迭代将训练生成器以获得更接近真实图像的图像。
让我们尝试生成逼真的手写数字;我们将重用我们以前的 CNN 分类器的部分内容。我们需要在那里添加一个生成器,并改变图层,以考虑到将生成我们的图像的额外随机输入。
让我们从一些辅助函数开始:我们的cost
函数的匹配辅助函数,以及将新生成的样本写入磁盘并在训练期间显示的函数。我们还为批处理规范化创建了自己的层,以简化底层计算:
import tensorflow as tf
import numpy as np
def match(logits, labels):
logits = tf.clip_by_value(logits, 1e-7, 1\. - 1e-7)
return tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits_v2(
logits=logits, labels=labels))
We use the non-sparse version of the cost
function helper we used before, softmax_cross_entropy_with_logits_v2
. We could have used the sparse version as well, but at the cost of additional code. As there is only one output value, this is simple to handle.
让我们解释批处理规范化层。我们计算输入张量的平均值和标准差。然后,我们对这个矩阵进行归一化(这样,矩阵的平均值为0
,标准差为1
)。我们明确处理了 2D 矩阵和 4D 矩阵,因为我们需要明确处理坐标轴的差异:
def batchnormalize(X, eps=1e-8, g=None, b=None):
if X.get_shape().ndims == 4:
mean = tf.reduce_mean(X, [0,1,2])
std = tf.reduce_mean( tf.square(X-mean), [0,1,2] )
X = (X-mean) / tf.sqrt(std+eps)
if g is not None and b is not None:
g = tf.reshape(g, [1,1,1,-1])
b = tf.reshape(b, [1,1,1,-1])
X = X*g + b
elif X.get_shape().ndims == 2:
mean = tf.reduce_mean(X, 0)
std = tf.reduce_mean(tf.square(X-mean), 0)
X = (X-mean) / tf.sqrt(std+eps)
if g is not None and b is not None:
g = tf.reshape(g, [1,-1])
b = tf.reshape(b, [1,-1])
X = X*g + b
else:
raise NotImplementedError
return X
def save_visualization(X, nh_nw, save_path='./sample.jpg'):
from imageio import imwrite
from matplotlib import pyplot as plt
h,w = X.shape[1], X.shape[2]
img = np.zeros((h * nh_nw[0], w * nh_nw[1], 3))
for n,x in enumerate(X):
j = n // nh_nw[1]
i = n % nh_nw[1]
img[j*h:j*h+h, i*w:i*w+w, :] = x
img = img.astype(np.uint8)
imwrite(save_path, img)
plt.imshow(img)
plt.show()
As we are using the sigmoid mapping
function for probabilities, we need to remove values 0
and 1
from the mapping (as they map from infinity). We do that by adding 1e-7
to 0
and subtracting it from 1
.
我们现在可以创建我们的类,创建我们的模型。构造器将有额外的新参数,类的数量Y
,以及随机状态的大小Z
:
class DCGAN():
def __init__(
self,
image_shape=[28,28,1],
dim_z=100,
dim_y=10,
dim_W1=1024,
dim_W2=128,
dim_W3=64,
dim_channel=1,
):
self.image_shape = image_shape
self.dim_z = dim_z
self.dim_y = dim_y
self.dim_W1 = dim_W1
self.dim_W2 = dim_W2
self.dim_W3 = dim_W3
self.dim_channel = dim_channel
现在,这是我们为生成器创建的新的特殊层,它将创建良好的图像——我们之前谈到的卷积转置层:
def create_conv2d_transpose(self, input, filters, kernel_size, name, with_batch_norm):
layer = tf.layers.conv2d_transpose(
inputs=input,
filters=filters,
kernel_size=kernel_size,
strides=[2,2],
name="Conv2d_transpose_" + name,
padding="SAME")
if with_batch_norm:
layer = batchnormalize(layer)
layer = tf.nn.relu(layer, name="RELU_" + name)
return layer
我们的鉴别器应该返回真实图像的0
和1
之间的概率。为了实现这一点并允许生成器创建各种类型的图像,我们将图像以及它们在每一层上的类驱动到鉴别器中:
def discriminate(self, image, Y, reuse=False):
with tf.variable_scope('discriminate', reuse=reuse):
Y = tf.one_hot(Y, dim_y)
yb = tf.reshape(Y, tf.stack([-1, 1, 1, self.dim_y]))
image = tf.concat(axis=3, values=
[image, yb*tf.ones([1, 28, 28, self.dim_y])])
h1 = self.create_conv2d(image, self.dim_W3, 5, "Lay-er1", True)
h1 = tf.concat(axis=3, values=
[h1, yb*tf.ones([1, 14, 14, self.dim_y])])
h2 = self.create_conv2d(h1, self.dim_W2, 5, "Layer2", True)
h2 = tf.reshape(h2, tf.stack([-1, 7*7*128]))
h2 = tf.concat(axis=1, values=[h2, Y])
h3 = self.create_dense(h2, self.dim_W1, "Layer3", True)
h3 = tf.concat(axis=1, values=[h3, Y])
h4 = self.create_dense(h3, 1, "Layer4", True)
return h4
正如我们之前所说的,生成器做相反的事情,从我们的类变量和随机状态到最终生成的值在0
和1
之间的图像:
def generate(self, Z, Y, reuse=False):
with tf.variable_scope('generate', reuse=reuse):
Y = tf.one_hot(Y, dim_y)
yb = tf.reshape(Y, tf.stack([-1, 1, 1, self.dim_y]))
Z = tf.concat(axis=1, values=[Z,Y])
h1 = self.create_dense(Z, self.dim_W1, "Layer1", False)
h1 = tf.concat(axis=1, values=[h1, Y])
h2 = self.create_dense(h1, self.dim_W2*7*7, "Layer2", False)
h2 = tf.reshape(h2, tf.stack([-1,7,7,self.dim_W2]))
h2 = tf.concat(axis=3, values=
[h2, yb*tf.ones([1, 7, 7, self.dim_y])])
h3 = self.create_conv2d_transpose(h2, self.dim_W3, 5, "Layer3", True)
h3 = tf.concat(axis=3, values=
[h3, yb*tf.ones([1, 14,14,self.dim_y])] )
h4 = self.create_conv2d_transpose(
h3, self.dim_channel, 7, "Layer4", False)
x = tf.nn.sigmoid(h4)
return x
现在是组装零件的时候了。我们为生成器以及真实图像输入创建占位符。然后我们创建我们的图像生成器(我们将使用它来显示我们生成的图像),然后创建我们的鉴别器。诀窍就在这里。我们同时创造了两个。一个用于实像,应返回1
;另一个将被输入生成的图像并返回0
。由于两者共享相同的权重,我们为第二个鉴别器传递重用标志。
我们试图为鉴别器步骤优化的成本是两个鉴别器和生成器的差异之和。如前所述,我们优化了生成器,以在鉴别器上获得一个1
:
def build_model(self):
Z = tf.placeholder(tf.float32, [None, self.dim_z])
Y = tf.placeholder(tf.int64, [None])
image_real = tf.placeholder(tf.float32, [None]+self.image_shape)
image_gen = self.generate(Z, Y)
raw_real = self.discriminate(image_real, Y, False)
raw_gen = self.discriminate(image_gen, Y, True)
discrim_cost_real = match(raw_real, tf.ones_like(raw_real))
discrim_cost_gen = match(raw_gen, tf.zeros_like(raw_gen))
discrim_cost = discrim_cost_real + discrim_cost_gen
gen_cost = match( raw_gen, tf.ones_like(raw_gen) )
return Z, Y, is_training, image_real, image_gen, dis-crim_cost, gen_cost
我们现在可以开始用超参数建立图表:
n_epochs = 10
learning_rate = 0.0002
batch_size = 1024
image_shape = [28,28,1]
dim_z = 10
dim_y = 10
dim_W1 = 1024
dim_W2 = 128
dim_W3 = 64
dim_channel = 1
visualize_dim=196
step = 200
和以前一样,我们阅读了 MNIST 数据集:
from sklearn.datasets import fetch_mldata
mnist = fetch_mldata('MNIST original')
mnist.data.shape = (-1, 28, 28)
mnist.data = mnist.data.astype(np.float32).reshape( [-1, 28, 28, 1]) / 255.
mnist.num_examples = len(mnist.data)
我们创建我们的图表。我们将变量分成两个列表(因为它们有一个前缀),每个列表都有一个 Adam 优化器。我们还为样本创建变量,以检查我们的生成器是否已经开始生成可识别的图像:
from sklearn.datasets import fetch_mldata
mnist = fetch_mldata('MNIST original')
mnist.data.shape = (-1, 28, 28)
mnist.data = mnist.data.astype(np.float32).reshape( [-1, 28, 28, 1]) / 255.
mnist.num_examples = len(mnist.data)
tf.reset_default_graph()
dcgan_model = DCGAN(
image_shape=image_shape,
dim_z=dim_z,
dim_W1=dim_W1,
dim_W2=dim_W2,
dim_W3=dim_W3,
)
Z_tf, Y_tf, iimage_tf, image_tf_sample, d_cost_tf, g_cost_tf, = dcgan_model.build_model()
discrim_vars = list(filter(lambda x: x.name.startswith('discriminate'),
tf.trainable_variables()))
gen_vars = list(filter(lambda x: x.name.startswith('generate'), tf.trainable_variables()))
train_op_discrim = tf.train.AdamOptimizer(learning_rate, beta1=0.5).minimize(
d_cost_tf, var_list=discrim_vars)
train_op_gen = tf.train.AdamOptimizer(learning_rate, beta1=0.5).minimize(
g_cost_tf, var_list=gen_vars)
Z_np_sample = np.random.uniform(-1, 1, size=(visualize_dim,dim_z))
Y_np_sample = np.random.randint(10, size=[visualize_dim])
在我们的会话中,我们首先使用 Tensorflow 将标签转换为单向编码,然后使用与之前网络相同的模式。我们为每批生成的图像生成随机数,然后按照计划优化鉴别器和生成器:
with tf.Session() as sess:
mnist.target = tf.one_hot(mnist.target.astype(np.int8), dim_y).eval()
Y_np_sample = tf.one_hot(Y_np_sample, dim_y).eval()
sess.run(tf.global_variables_initializer())
for epoch in range(n_epochs):
permut = np.random.permutation(mnist.num_examples)
trX = mnist.data[permut]
trY = mnist.target[permut]
Z = np.random.uniform(-1, 1, size=[mnist.num_examples, dim_z]).astype(np.float32)
print("epoch: %i" % epoch)
for j in range(0, mnist.num_examples, batch_size):
if j % step == 0:
print(" batch: %i" % j)
batch = permut[j:j+batch_size]
Xs = trX[batch]
Ys = trY[batch]
Zs = Z[batch]
sess.run(train_op_discrim,
feed_dict={
Z_tf:Zs,
Y_tf:Ys,
image_tf:Xs,
})
sess.run(train_op_gen,
feed_dict={
Z_tf:Zs,
Y_tf:Ys,
})
if j % step == 0:
generated_samples = sess.run(
image_tf_sample,
feed_dict={
Z_tf:Z_np_sample,
Y_tf:Y_np_sample,
})
generated_samples = generated_samples * 255
save_visualization(generated_samples, (7,28),
save_path='./sample_%03d_%04d.jpg' %
(epoch, j / step))
epoch: 0
batch: 0
…
epoch: 3
batch: 0
epoch: 9
batch: 64000
我们可以看到非常早期的类似手指的形状。它们的进化方式非常有趣。它们从光滑到松脆再到嘈杂(对于大量的时代)。这是可以理解的,因为这些网络没有融合。因为它们是对抗性的,每次一个人学会了对另一个人有效的技巧,另一个人就会反击。例如,如果图像不够平滑,鉴别器可以对这个差异进行鉴别,如果生成器生成平滑图像,则鉴别器将继续处理其他差异。问题是发电机会忘记过去已知的把戏,所以没有办法停在有意义的点上!
摘要
我们学习了在机器学习环境中处理图像的经典的基于特征的方法;通过将一百万个像素转换成几个数字特征,我们能够直接使用逻辑回归分类器。我们在其他章节中学到的所有技术突然变得直接适用于图像问题。我们看到了一个使用图像特征在数据集中查找相似图像的例子。
我们还学习了如何使用单词包模型中的局部特征进行分类。这是一种非常现代的计算机视觉方法,并取得了良好的效果,同时对于图像的许多不相关方面(例如照明,甚至同一图像中的不均匀照明)足够鲁棒。我们还将聚类作为分类中一个有用的中间步骤,而不是目的本身。
我们关注的是mahotas
,这是 Python 中主要的计算机视觉库之一。还有一些同样得到很好的维护。Skimage 在精神上是相似的,但是有一套不同的特征。OpenCV 是一个非常好的带有 Python 接口的 C++库。所有这些都可以与 NumPy 数组一起工作,您可以混合和匹配不同库中的函数来构建复杂的计算机视觉管道。
我们还尝试了一种用 Tensorflow(可用于非图像域)生成类似图像的新方法,目前流行的网络类型名为 GAN。
在第 13 章、强化学习中,我们将探讨强化学习这一深度学习的热门话题。我们将看到如何让神经网络在不告诉它任何事情的情况下学习一套规则。
十三、强化学习
Deepmind 在 2017 年创造了世界上最好的围棋选手。他们是如何做到这一点的?当然是深度学习,但更确切地说是强化学习。
深蓝用传统游戏分析击败人类棋手。它将建立一个可能结果的树,并使用不同的策略(像 alpha/beta 一样,但适应象棋可能结果的空间)对其进行修剪。但是这在围棋中是不可能的,在 Deepmind 创造出他们的网络和训练方法之前,围棋永远无法被计算机解决。因为没有训练,网络就没有用!
在本章中,我们将执行以下操作:
- 看看不同类型的强化学习
- 探究问题学习的概念
- 通过表格和神经网络估计
Q
函数 - 让网络玩一个雅达利游戏
强化学习的类型
强化学习是无监督学习空间的一部分。它的目标是让一个模型表现得越来越好,但是我们没有基本的事实,例如,一组有标签的数据来训练我们的模型。我们唯一能做的就是使用网络,如果网络得到了一个好的结果,那么我们就用它来用反向传播来增强我们的模型。否则,我们再试试。
我们也可以在金融中使用这种方法来优化投资组合;这也可以用于机器人。过去人们用遗传算法训练一个行走机器人,但现在我们也可以用强化学习来完成这个任务!
现在我们有了神经网络可以拯救人类。让我们看看过去几年中受到关注的几种主要网络类型。
政策和价值网络
我们可以从解决围棋开始。围棋是一个有几千年历史的简单游戏。这是一个信息充分的两人游戏,意思是两个玩家面对面,没有隐藏的知识;所有的东西都包含在棋盘上(例如,与扑克等纸牌游戏相反)。在每个回合中,玩家将他们的一块石头(第一个玩家是白色的,第二个是黑色的)放在棋盘上,在这个过程中可能会改变其他石头的颜色,游戏以拥有最多颜色石头的人结束。
问题是棋盘很大,19 x 19 个方块,这意味着在开始时你有一大堆可能的选择。哪一个导致赢得比赛?
对于国际象棋来说,这个问题是在没有神经网络的情况下解决的。可能的移动只有一个子集,一台快速的计算机现在可以分析所有这些移动直到给定的深度。在这个分析树的叶子上,在国际象棋中可以知道我们是更接近胜利还是处于失败的边缘。对于围棋来说,这是不可能的。
进入深度学习。对于围棋,我们仍然需要分析不同的可能棋步,但我们不会试图像在国际象棋中那样详尽无遗;我们将改为使用蒙特卡罗树搜索 ( MCTS )。这意味着我们将画一个随机的统一数字,从这个数字我们将玩一个移动。我们这样做是为了提前几步,然后我们评估我们是否更接近胜利。
但是正如我们之前看到的,我们无法衡量这是 Go,那么我们如何选择一个移动进行搜索,如何决定我们是赢还是输?这就是为什么我们有两个网络。政策网络将为下一步行动提供概率,价值网络将提供一个价值——要么它认为我们赢了,要么我们输了。
结合在一起,就有可能产生一套可能的招式,加上它们成功的几率,我们就可以发挥出来。在游戏的最后,我们使用信息来加强两个网络。
后来,新的 AlphaGo Zero 将这些网络合并在一起。不再有政策和价值网络,它在玩法上变得比最初的 AlphaGo 好得多。所以我们不需要对这些问题进行二分法,因为设计一个两者都做的架构是可能的。
q 网络
事实上,在《围棋》之前,Deepmind 就开始为自己成名了。他们用所谓的 Q 网络来解决雅达利游戏。这是一组简单的游戏,玩家在每个阶段最多只能玩 10 步。
有了这些网络,目标是估计一个长期的奖励函数(如点数),以及哪一步将最大化它。通过在开始时提供足够的选项,网络将逐步学习如何玩得越来越好。奖励功能如下:
Q(s,a) = r + γ(最大值(Q(s ',a'))
r 为奖励, γ 为贴现因子(未来收益不如眼前奖励重要) s 为游戏当前状态, a 为我们可以采取的行动。
当然,它在不断学习的同时,也在不断遗忘,网络也将不得不接受过去的训练。用一个比喻来说,它最后会跑而不会走,这是相当没用的。
擅长游戏
在本章的剩余部分,我们将使用带有gym
包的 Q 游戏。它为玩不同类型的游戏提供了一个标准的应用编程接口,所以它是我们想要向您展示的完美测试案例。
一个小例子
Anaconda 不发货这个包,所以要通过pip
安装:
>>> pip install gym[atari]
We won't use the Atari part of the gym
, but it will be required for the breakout game.
从这个包中,我们可以为不同的游戏创建一个环境,如下所示:
env = gym.make('FrozenLake-v0')
这为文字游戏FrozenLake
创造了新的环境。它由四个四个字符串组成,从S
开始,到G
结束,目标。但是在通往这个目标的路上有洞(H
),最终在那里结束会让你输掉比赛:
SFFF
FHFH
FFFH
HFFG
从环境中,我们可以得到观察空间的大小,env.observation_space.n
,这里是16
(玩家所在的地方),动作空间的大小env.action_space.n
,这里是4
。
由于这是一个小玩具示例,我们可以创建Q(s, a)
的估计:
# Inspired by https://github.com/tensorlayer/tensorlayer/
# blob/master/example/tutorial_frozenlake_q_table.py
Q = np.zeros((env.observation_space.n, env.action_space.n))
# Set learning hyperparameters
lr = .8
y = .95
num_episodes = 2000
# Let's run!
for i in range(num_episodes):
# Reset environment and get first new observation (top left)
s = env.reset()
# Do 100 iterations to update the table
for i in range(100):
# Choose an action by picking the max of the table
# + additional random noise ponderated by the episode
a = np.argmax(Q[s,:]
+ np.random.randn(1, env.action_space.n) / (i + 1))
# Get new state and reward from environment after chosen step
s1, r, d,_ = env.step(a)
# Update Q-Table with new knowledge
Q[s,a] = Q[s,a] + lr*(r + y*np.max(Q[s1,:]) - Q[s,a])
s = s1
if d == True:
break
我们现在可以显示表格Q
的内容:
[[0.18118924 0.18976168 0.19044738 0.18260069]
[0.03811294 0.19398589 0.18619181 0.18624451]
[0.16266812 0.13309552 0.14401865 0.11183018]
[0.02533285 0.12890984 0.02641699 0.15121063]
[0.20015578 0.00201834 0.00902377 0.03619787]
[0\. 0\. 0\. 0\. ]
[0.1294778 0.04845176 0.03590482 0.13001683]
[0\. 0\. 0\. 0\. ]
[0.02543623 0.05444387 0.01170018 0.19347353]
[0.06137181 0.43637431 0.00372395 0.00830249]
[0.25205174 0.00709722 0.00908675 0.00296389]
[0\. 0\. 0\. 0\. ]
[0\. 0\. 0\. 0\. ]
[0\. 0.15032826 0.43034276 0.09982157]
[0\. 0.86241133 0\. 0\. ]
[0\. 0\. 0\. 0\. ]]
我们可以在一些行中看到所有有0
的条目;这些是洞和最后的目标阶段。从第一步开始,我们可以使用这些行给出的概率(在标准化之后)通过这个表进入下一步。
当然,这不是网络,所以我们用 Tensorflow 做一个网络学习这个表。
使用张量流进行文本游戏
让我们考虑一下我们这里需要的建筑类型。我们把游戏的状态作为输入,我们想要四个值中的一个作为输出。游戏很简单,有一个最优策略,一个从开始到目标的独特路径。这意味着网络可以非常简单,只有一层和线性输出:
inputs = tf.placeholder(shape=[None, 16], dtype=tf.float32, name="input")
Qout = tf.layers.dense(
inputs=inputs,
units=4,
use_bias=False,
name="dense",
kernel_initializer=
tf.random_uniform_initializer(minval=0, maxval=.0125)
)
predict = tf.argmax(Qout, 1)
# Our optimizer will try to optimize
nextQ = tf.placeholder(shape=[None, 4], dtype=tf.float32, name="target")
loss = tf.reduce_sum(tf.square(nextQ - Qout))
trainer = tf.train.GradientDescentOptimizer(learning_rate=learning_rate)
updateModel = trainer.minimize(loss)
对于我们的训练,我们需要重新引入新的选项,就像我们之前在表中的随机性一样。为了实现这一点,对于每 10 个预测,我们随机抽样一个动作(这被称为ε-贪婪策略,稍后我们将在雅达利游戏中重用它的一个变体)。否则,我们计算实际的Q
值,并训练我们的网络以匹配该结果(更新密集层权重):
# To keep track of our games and our results
with tf.Session() as sess:
sess.run(tf.global_variables_initializer())
for i in range(num_episodes):
s = env.reset()
for j in range(100):
a, targetQ = sess.run([predict, Qout],
feed_dict={inputs:np.identity(16)[s:s+1]})
# We randomly choose a new state
# that we may have not encountered before
if np.random.rand(1) < e:
a[0] = env.action_space.sample()
s1, r, d, _ = env.step(a[0])
# Obtain the Q' values by feeding
# the new state through our network
Q1 = sess.run(Qout,
feed_dict={inputs:np.identity(16)[s1:s1 + 1]})
# Obtain maxQ' and set our target value for chosen action.
targetQ[0, a[0]] = r + y*np.max(Q1)
# Train our network using target and predicted Q values
sess.run(updateModel,
feed_dict={inputs:np.identity(16)[s:s+1], nextQ:targetQ})
s = s1
if d == True:
# Reduce chance of random action as we train the model.
e = 1 / ((i // 50) + 10)
break
使用这种策略,该层获得了大约 40%的成功,但是这个值是有偏差的。如果我们绘制奖励的演变图(20 集以上的平均时间),网络会随着时间的推移而显著改善:
存活时间也是如此:
我们可以看到,当网络开始变得更擅长奖励时,它也设法让玩家活得更久。不幸的是,网络在这项任务上仍然不是最好的。一个人只需走八步就能完成游戏。
我们现在可以对雅达利游戏使用类似的策略。
玩突破
雅达利游戏有几种玩法。首先是互动方式。我们可以使用内存视图,也可以使用显示的图像(总是相同的)。除此之外,游戏名称末尾的-v?
表示该步骤是否重复以及重复的频率。breakout
的v0
表示在我们要求一个新的步骤之前,该步骤被执行了两次、三次或四次。对于v4
,它确定性地跳过四帧。
我们可以从一个空的、简单的breakout
游戏开始:
# Import the gym module
import gym
# Create a breakout environment
env = gym.make('BreakoutDeterministic-v4')
# Reset it, returns the starting frame
frame = env.reset()
# Render
env.render()
is_done = False
while not is_done:
# Perform a random action, returns the new frame, reward and whether the game is over
frame, reward, is_done, _ = env.step(env.action_space.sample())
# Render
env.render()
我们现在唯一需要修改的是如何为游戏获得新的一步。嗯,我们需要的不仅仅是这些:首先我们需要训练一个模型!
让我们看看上下文。我们可以从环境中获取图像(它们是 160 x 210 像素),考虑到我们将需要大量以前的图像,这个大小可能太大,无法放在一台计算机上。例如,我们可以在两个方向上各掉一个像素,所以这就是preprocess
将要实现的。我们还将添加两个函数来转置我们的内部状态。原因是我们有一个通道的 84 x 105 的图像,但是我们需要使用过去的图像来知道球向哪个方向移动。为了实现这一点,我们动态地转置这个状态,得到一个图像 84 x 105 x 状态 _ 长度:
import gym
import os
import six
import numpy as np
import tensorflow as tf
import random
from collections import deque , namedtuple
Transition = namedtuple("Transition",
["state", "action", "re-ward", "next_state", "done"])
def to_grayscale(img):
return np.mean(img, axis=2).astype(np.uint8)
def downsample(img):
return img[::2, ::2]
def preprocess(img):
return to_grayscale(downsample(img))[None,:,:]
def adapt_state(state):
return [np.float32(np.transpose(state, (2, 1, 0)) / 255.0)]
def adapt_batch_state(state):
return np.transpose(np.array(state), (0, 3, 2, 1)) / 255.0
def get_initial_state(frame):
processed_frame = preprocess(frame)
state = [processed_frame for _ in range(state_length)]
return np.concatenate(state)
Although we can make all the Atari games work with the network we are building, there is one issue. We are just taking every other pixel in each direction. But what happens if we are playing space invaders with a one-pixel-width missile? There is a 50/50 chance that we will die without seeing the missile!
To make this better, we could use skimage.rescale
instead. For breakout, we don't need it, so this is left as an exercise for the reader.
我们现在要写一组超参数,以及游戏的一些常数,比如环境的名称和图像的大小:
env_name = "Breakout-v4"
width = 80 # Resized frame width
height = 105 # Resized frame height
我们需要对网络进行很长时间的训练,所以我们来玩12000
游戏。为了预测新的动作,我们将使用过去的4
图像:
n_episodes = 12000 # Number of runs for the agent
state_length = 4 # Number of most frames we input to the network
我们还需要设置Q
功能的参数:
gamma = 0.99 # Discount factor
在开始时,我们希望经常测试一个随机动作(向左或向右进行突破)。然后在训练过程中,我们会逐步去除随机性(这是我们的ε-贪婪策略)。每次运行网络时,我们都会考虑这一步,因此让我们将这一随机因素减少 100 多万步:
# During all these steps, we progressively lower epsilon
exploration_steps = 1000000
initial_epsilon = 1.0 # Initial value of epsilon in epsilon-greedy
final_epsilon = 0.1 # Final value of epsilon in epsilon-greedy
我们需要填写我们的动作集合,所以一开始我们不训练,我们只是让游戏用随机的动作玩。这将是我们的初始训练集,随着时间的推移,我们将把所有的游戏添加到这套训练集中。当它遇到400000
元素时,我们开始抛弃旧的、更随机的训练状态:
# Number of steps to populate the replay memory before training starts
initial_random_search = 20000
replay_memory_size = 400000 # Number of states we keep for training
batch_size = 32 # Batch size
network_update_interval = 10000 # The frequency with which the target network is updated
我们会用RMSProp
来训练我们的网络,所以我们用momentum
设置了一个很低的学习率:
learning_rate = 0.00025 # Learning rate used by RMSProp
momentum = 0.95 # momentum used by RMSProp
# Constant added to the squared gradient in the denominator
# of the RMSProp update
min_gradient = 0.01
最后,我们将存储经过时间训练的网络(带有一些检查点,以便我们可以在一些部分训练的状态下重新开始训练),并将一些信息存储到 Tensorboard,例如我们发现的奖励和游戏的长度:
network_path = 'saved_networks/' + env_name
tensorboard_path = 'summary/' + env_name
save_interval = 300000 # The frequency with which the network is saved
我们现在可以创建我们的网络类。我们将为每个网络创建一个实例。是的,我们需要两个网络——一个是估计下一步要采取的行动,另一个是估计Q
值或目标。我们将不时更新行动网络(此处命名为q_estimator
)到目标评估者(命名为target_estimator
):
class Estimator():
"""Q-Value Estimator neural network.
This network is used for both the Q-Network and the Target Network.
"""
def __init__(self, env, scope="estimator", summar-ies_dir=None):
self.scope = scope
self.num_actions = env.action_space.n
self.epsilon = initial_epsilon
self.epsilon_step =
(initial_epsilon - final_epsilon) / exploration_steps
# Writes Tensorboard summaries to disk
self.summary_writer = None
with tf.variable_scope(scope):
# Build the graph
self.build_model()
if summaries_dir:
summary_dir = os.path.join(summaries_dir,
"summaries_%s" % scope)
if not os.path.exists(summary_dir):
os.makedirs(summary_dir)
self.summary_writer = tf.summary.FileWriter(summary_dir)
def build_model(self):
"""
Builds the Tensorflow graph.
"""
self.X = tf.placeholder(shape=[None, width, height, state_length],
dtype=tf.float32, name="X")
# The TD target value
self.y = tf.placeholder(shape=[None], dtype=tf.float32, name="y")
# Integer id of which action was selected
self.actions = tf.placeholder(shape=[None], dtype=tf.int32, name="actions")
model = tf.keras.Sequential()
model.add(tf.keras.layers.Convolution2D(filters=32, kernel_size=8,
strides=(4, 4), activation='relu',
input_shape=(width, height, state_length), name="Layer1"))
model.add(tf.keras.layers.Convolution2D(filters=64, kernel_size=4,
strides=(2, 2), activation='relu', name="Layer2"))
model.add(tf.keras.layers.Convolution2D(filters=64, kernel_size=3,
strides=(1, 1), activation='relu', name="Layer3"))
model.add(tf.keras.layers.Flatten(name="Flatten"))
model.add(tf.keras.layers.Dense(512, activation='relu',
name="Layer4"))
model.add(tf.keras.layers.Dense(self.num_actions, name="Output"))
self.predictions = model(self.X)
a_one_hot = tf.one_hot(self.actions, self.num_actions, 1.0, 0.0)
q_value = tf.reduce_sum(tf.multiply(self.predictions, a_one_hot),
reduction_indices=1)
# Calculate the loss
self.losses = tf.squared_difference(self.y, q_value)
self.loss = tf.reduce_mean(self.losses)
# Optimizer Parameters from original paper
self.optimizer = tf.train.RMSPropOptimizer(learning_rate,
momentum=momentum, epsilon=min_gradient)
self.train_op = self.optimizer.minimize(self.loss,
global_step=tf.train.get_global_step())
# Summaries for Tensorboard
self.summaries = tf.summary.merge([
tf.summary.scalar("loss", self.loss),
tf.summary.histogram("loss_hist", self.losses),
tf.summary.histogram("q_values_hist", self.predictions),
tf.summary.scalar("max_q_value",
tf.reduce_max(self.predictions))
])
在这种情况下,我们使用keras
来构建我们的网络。它堆叠了三个卷积层(没有最大池层,尽管我们确实丢弃了一些节点以减少参数数量)和两个密集层。他们都使用了relu
激活以后。
Note thatkeras
is a high-level interface. In this example, we use Sequential
which means that each layer connects to the previous one. It is then built by passing a placeholder to the model and getting an output tensor.
有了网络,我们现在可以创建一个cost
函数,并将其馈送给优化器。我们还增加了一些汇总报表来检查Q
或损失值的分布:
def predict(self, sess, s):
return sess.run(self.predictions, { self.X: s })
def update(self, sess, s, a, y):
feed_dict = { self.X: s, self.y: y, self.actions: a }
summaries, global_step, _, loss = sess.run(
[self.summaries, tf.train.get_global_step(), self.train_op, self.loss], feed_dict)
if self.summary_writer:
self.summary_writer.add_summary(summaries, glob-al_step)
return loss
def get_action(self, sess, state):
if self.epsilon >= random.random():
action = random.randrange(self.num_actions)
else:
action = np.argmax(self.predict(sess, adapt_state(state)))
# Decay epsilon over time
if self.epsilon > final_epsilon:
self.epsilon -= self.epsilon_step
return action
def get_trained_action(self, state):
action = np.argmax(self.predict(sess, adapt_state(state)))
return action
我们添加了一个方法来包装预测,因为我们将在几个地方使用它——首先是一个更新方法,它将实际训练这个估计器。我们还有两种方法来检索一个动作,要么使用ε-贪婪策略,要么不使用(训练后):
def copy_model_parameters(estimator1, estimator2):
"""
Copies the model parameters of one estimator to another.
Args:
estimator1: Estimator to copy the paramters from
estimator2: Estimator to copy the parameters to
"""
e1_params = [t for t in tf.trainable_variables()
if t.name.startswith(estimator1.scope)]
e1_params = sorted(e1_params, key=lambda v: v.name)
e2_params = [t for t in tf.trainable_variables()
if t.name.startswith(estimator2.scope)]
e2_params = sorted(e2_params, key=lambda v: v.name)
update_ops = []
for e1_v, e2_v in zip(e1_params, e2_params):
op = e2_v.assign(e1_v)
update_ops.append(op)
return update_ops
这是我们的函数,我们将调用它来从一个估计值更新另一个估计值。这将创建一组操作,我们将在后面的会话中运行这些操作:
def create_memory(env):
# Populate the replay memory with initial experience
replay_memory = deque()
frame = env.reset()
state = get_initial_state(frame)
for i in range(replay_memory_init_size):
action = np.random.choice(np.arange(env.action_space.n))
frame, reward, done, _ = env.step(action)
next_state = np.append(state[1:, :, :], preprocess(frame), axis=0)
replay_memory.append(
Transition(state, action, reward, next_state, done))
if done:
frame = env.reset()
state = get_initial_state(frame)
else:
state = next_state
return replay_memory
这个函数创建一个空的重放内存。这是必须的,这样游戏才能学到东西。没有这组初始状态,我们就无法训练网络。所以我们只是玩了一会儿随机移动,希望它能让我们的网络获得一些第一手的游戏知识。当然,我们也有我们的 epsilon-greedy 策略,稍后会给游戏增加新的招式。这也将对我们有很大帮助:
def setup_summary():
with tf.variable_scope("episode"):
episode_total_reward = tf.Variable(0., name="EpisodeTotalReward")
tf.summary.scalar('Total Reward', episode_total_reward)
episode_avg_max_q = tf.Variable(0., name="EpisodeAvgMaxQ")
tf.summary.scalar('Average Max Q', episode_avg_max_q)
episode_duration = tf.Variable(0., name="EpisodeDuration")
tf.summary.scalar('Duration', episode_duration)
episode_avg_loss = tf.Variable(0., name="EpisodeAverageLoss")
tf.summary.scalar('Average Loss', episode_avg_loss)
summary_vars = [episode_total_reward, episode_avg_max_q,
episode_duration, episode_avg_loss]
summary_placeholders =
[tf.placeholder(tf.float32) for _ in range(len(summary_vars))]
update_ops = [sum-mary_vars[i].assign(summary_placeholders[i])
for i in range(len(summary_vars))]
summary_op = tf.summary.merge_all(scope="episode")
return summary_placeholders, update_ops, summary_op
我们在这里定义了所有我们希望在张量板中显示的变量,这些变量位于来自估计器的直方图之上。
During the training, use tensorboard --logdir=summary
to visualize the evolution of the training and the performance of your network.
我们可以通过设置环境、评估器和帮助功能来开始我们的主要训练循环:
if __name__ == "__main__":
from tqdm import tqdm
env = gym.make(env_name)
tf.reset_default_graph()
# Create a global step variable
global_step = tf.Variable(0, name='global_step', traina-ble=False)
# Create estimators
q_estimator = Estimator(env, scope="q",
summaries_dir=tensorboard_path)
target_estimator = Estimator(env, scope="target_q")
copy_model = copy_model_parameters(q_estimator, tar-get_estimator)
summary_placeholders, update_ops, summary_op = setup_summary()
replay_memory = create_memory(env)
如果我们的保存位置中存储有以前的版本,我们可以启动 Tensorflow 会话并恢复网络:
with tf.Session() as sess:
sess.run(tf.global_variables_initializer())
saver = tf.train.Saver()
# Load a previous checkpoint if we find one
latest_checkpoint = tf.train.latest_checkpoint(network_path)
if latest_checkpoint:
print("Loading model checkpoint %s...\n" % lat-est_checkpoint)
saver.restore(sess, latest_checkpoint)
total_t = sess.run(tf.train.get_global_step())
从这里,我们可以开始玩游戏。如果需要,我们首先保存网络,然后设置游戏状态:
for episode in tqdm(range(n_episodes)):
if total_t % save_interval == 0:
# Save the current checkpoint
saver.save(tf.get_default_session(), network_path)
frame = env.reset()
state = get_initial_state(frame)
total_reward = 0
total_loss = 0
total_q_max = 0
我们在这个游戏中不断迭代,采取一个动作,并将这个动作的状态保存在我们的重放记忆中。这样,当网络学得更好玩的时候,我们也把这些更好的招式保存下来,以后学得更好:
for duration in itertools.count():
# Maybe update the target estimator
if total_t % network_update_interval == 0:
sess.run(copy_model)
action = q_estimator.get_action(sess, state)
frame, reward, terminal, _ = env.step(action)
processed_frame = preprocess(frame)
next_state = np.append(state[1:, :, :], processed_frame, axis=0)
reward = np.clip(reward, -1, 1)
replay_memory.append(
Transition(state, action, reward, next_state, terminal))
if len(replay_memory) > replay_memory_size:
replay_memory.popleft()
我们从我们的重放记忆中获得一组状态,用奖励,用动作,来估计我们的Q
值。一旦我们有了这个,我们就优化网络来增强它的行为。现在,我们可以根据目标 Q 网络更新我们的网络,以发挥更好的作用:
samples = random.sample(replay_memory, batch_size)
states_batch, action_batch, reward_batch, next_states_batch, done_batch =
map(np.array, zip(*samples))
# Calculate q values and targets (Double DQN)
adapted_state = adapt_batch_state(next_states_batch)
q_values_next = q_estimator.predict(sess, adapted_state)
best_actions = np.argmax(q_values_next, axis=1)
q_values_next_target = tar-get_estimator.predict(sess, adapted_state)
targets_batch = reward_batch + np.invert(done_batch).astype(np.float32) *
gamma * q_values_next_target[np.arange(batch_size), best_actions]
# Perform gradient descent update
states_batch = adapt_batch_state(states_batch)
loss = q_estimator.update(sess, states_batch, action_batch, targets_batch)
total_q_max += np.max(q_values_next)
total_loss += loss
total_t += 1
total_reward += reward
if terminal:
break
游戏结束后,我们将变量保存到 Tensorboard 中,并捕获游戏结束时的截图:
stats = [total_reward, total_q_max / duration, dura-tion, total_loss / duration]
for i in range(len(stats)):
sess.run(update_ops[i], feed_dict={
summary_placeholders[i]: float(stats[i])
})
summary_str = sess.run(summary_op, )
q_estimator.summary_writer.add_summary(summary_str, episode)
env.env.ale.saveScreenPNG(six.b('%s/test_image_%05i.png' % (CHART_DIR, episode)))
我们可以用这个最后的循环在我们的 12000 场比赛中训练我们的网络。对于每次迭代,我们从训练好的网络中获得一个新的动作(从许多随机的动作开始),然后训练我们的网络。
下面是前面代码的张量板图示例:
时间长了,我们可以看到平均Q
慢慢提升,虽然奖励保持在低位。我们可以看到训练后的网络稍微好一点,但还是需要很多比赛才能好!
On top of the graphs displaying the evolution of the training, Tensorboard also provides a view of the graph that supports our network. This is very useful to check that it is correct and fits what we designed. It shows for instance the different weights that we use for a certain cost. If they are reused, this will be very clear from the graph.
这是 Tensorboard 中的另一个视图,我们称之为summary_writer.add_graph(sess.graph)
:
The next step is now to try different hyper parameters. After all, we don't know if the network will end up learning the game. For instance, adding more episodes will enhance the ability to train, but what would changing the epsilon-greedy strategy do? Or the memory size? Or simply the batch size?
摘要
我们在这里看到,强化学习是一个非常强大的工具,可以在我们没有基本事实或最优策略的数据上训练模型。这仍然需要很多时间才能在单个处理器上实现,尤其是在没有 GPU 的情况下。
在第 14 章、更大的数据中,我们将看到如何将云的力量用于更复杂的模型。
十四、更大的数据
说什么是大数据并不容易。我们将采用一个可操作的定义:当数据大到难以处理时,我们称之为大数据。在某些情况下,这可能意味着数十亿字节的数据或数万亿笔交易:无法装入单个硬盘的数据。在其他情况下,它可能会小一百倍,但仍然很难使用。
为什么数据本身会成为一个问题?虽然计算机速度越来越快,内存越来越多,但数据量也在增长。事实上,数据的增长速度超过了计算速度,很少有算法会随着输入数据的大小一起线性扩展;这意味着数据的增长速度超过了我们处理数据的能力。
我们将首先建立在前几章的一些经验的基础上,使用我们可以称之为中等数据设置的东西(不是很大的数据,但也不是很小)。为此,我们将使用一个名为jug
的包,它允许我们执行以下任务:
- 将您的管道分成多个任务
- 缓存(记忆)中间结果
- 利用多核,包括网格上的多台计算机
下一步是转向真正的大数据,我们将看到如何将云用于计算目的。特别是,您将了解亚马逊网络服务基础设施。在本节中,我们将介绍另一个名为cfncluster
的 Python 包来管理集群。
了解大数据
“大数据”一词并不意味着特定数量的数据,无论是在示例数量上,还是在数据占用的千兆字节、万亿字节或千兆字节数量上。这意味着数据的增长速度超过了处理能力。这意味着以下几点:
- 过去运行良好的一些方法和技术现在需要重做或替换,因为它们不能很好地适应输入数据的新大小
- 算法不能假设所有的输入数据都能存储在内存中
- 管理数据本身成为一项主要任务
- 使用计算机集群或多核机器成为一种必要,而不是一种奢侈
本章将集中讨论这个难题的最后一块:如何使用多核(在同一台机器上或在不同的机器上)来加速和组织计算。这在其他中型数据任务中也很有用。
使用 jug 将您的管道分解成任务
通常,我们有一个简单的管道:我们预处理初始数据,计算特征,然后用结果特征调用机器学习算法。
Jug 是本书作者之一路易斯·佩德罗·科埃略开发的一个包。它是开源的(使用自由的麻省理工学院许可证),在许多领域都很有用,但它是专门针对数据分析问题而设计的。它同时解决了几个问题,例如:
- 它可以将结果存储到磁盘(或数据库)中,这意味着如果你要求它计算你以前已经计算过的东西,结果将从磁盘中读取。
- 它可以在一个集群中使用多个内核甚至多台计算机。Jug 还被设计为在批处理计算环境中运行良好,该环境使用排队系统,如便携式批处理系统 ( PBS )、负载共享设施 ( LSF )或网格引擎。当我们构建在线集群并向其分派作业时,将在本章的后半部分使用它。
jug 中的任务介绍
任务是 jug 的基本构件。任务由函数及其参数值组成。考虑这个简单的例子:
def double(x):
return 2*x
在本章中,代码示例通常必须在脚本文件中键入。应该在 shell 中键入的命令将以$
作为前缀来指示。
一个任务可以用参数3
来调用double
。另一个任务是用论点642.34
呼叫double
。使用 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
开始,得到如下截图所示的输出:
现在,我们同时启动两个进程(使用&
操作符,这是后台启动进程的传统 Unix 方式):
$ jug execute &
$ jug execute &
现在,我们再次运行jug status
:
我们可以看到两个初始的双运算符同时运行。大约 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 是这个生态系统的另一部分,所有的东西都在一起工作。
我们现在来回顾一下第 12 章、计算机视觉。在那一章中,我们学习了如何计算图像的特征。请记住,基本管道由以下特性组成:
- 正在加载图像文件
- 计算功能
- 结合这些特征
- 标准化特征
- 创建分类器
我们打算重做这个练习,但这次用的是 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
模块是来自第 12 章、计算机视觉的模块。
We write functions that take the filename as input instead of the image array. Using the full images would also work, of course, but this is a small optimization. A filename is a string, which is small if it gets written to the backend. It's also very fast to compute a hash if needed. It also ensures that the images are only loaded by the processes that need them.
- 我们可以在任何功能上使用
TaskGenerator
。即使对于我们没有编写的函数也是如此,例如np.array
、np.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 model_selection
clf = Pipeline([('preproc', StandardScaler()),
('classifier', LogisticRegression())])
cv = model_selection.LeaveOneOut()
scores = model_selection.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
重用部分结果
例如,假设您想要添加一个新功能(甚至一组功能)。正如我们在第 12 章、计算机视觉中看到的,通过改变特征计算代码,这是很容易做到的。然而,这将意味着再次重新计算所有的特性,这是很浪费的,尤其是如果你想快速测试新的特性和技术。
我们现在添加一组特征,即另一种称为线性二进制模式的纹理特征。这在 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)
我们称之为accuracy
具有这些更新的特性:
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
:这将删除记忆缓存中的任何额外文件。这是一个垃圾收集操作
There are other, more advanced features, which allow you to look at values that have been computed inside the jugfile
. Read up on features such as barriers in the jug documentation online at http://jug.rtfd.org.
使用亚马逊网络服务
当你有大量数据和大量计算要执行时,你可能会开始渴望更多的计算能力。亚马逊(http://aws.amazon.com)允许你按小时出租计算能力。因此,您可以获得大量计算能力,而不必承诺购买大量机器(包括管理基础架构的成本)。这个市场还有其他竞争对手,但亚马逊是最大的玩家,所以我们在这里简单介绍一下。
亚马逊网络服务 ( AWS )是一大套服务。我们将只关注弹性计算云 ( EC2 )服务。该服务为您提供虚拟机和磁盘空间,可以快速分配和释放。
有三种使用模式。第一种是保留模式,根据这种模式,您可以预付费用以获得更便宜的每小时访问、固定的每小时费率和可变费率,这取决于整个计算市场(当需求较少时,成本较低;当有更多的需求时,价格就会上涨。
除了这种通用系统之外,还有几种不同成本的机器,从单核到具有大量内存甚至图形处理单元的多核系统。我们稍后会看到,您还可以获得几台更便宜的机器,并为自己构建一个虚拟集群。你也可以选择得到一个 Linux 或者 Windows 服务器(Linux 稍微便宜一点)。在这一章中,我们将在 Linux 上处理我们的例子,但是大部分信息对 Windows 机器也是有效的。
测试时,可以在自由层使用单机。这可以让你玩转系统,习惯界面,等等。请注意,这台机器包含一个缓慢的中央处理器。
这些资源可以通过网络界面进行管理。但是,也可以通过编程来实现这一点,并编写脚本来分配虚拟机、格式化硬盘以及通过 web 界面执行所有可能的操作。事实上,虽然 web 界面变化非常频繁(而且我们在书中展示的一些截图在付印时可能已经过时),但编程界面更加稳定,并且自服务推出以来,总体架构保持稳定。
对 AWS 服务的访问是通过传统的用户名/密码系统进行的,尽管亚马逊称用户名为访问密钥,密码为密钥。他们这样做可能是为了将它与您用来访问 web 界面的用户名/密码分开。事实上,您可以创建任意多的访问/密钥对,并赋予它们不同的权限。这对于一个更大的团队来说很有帮助,在这个团队中,可以访问整个 web 面板的高级用户可以为权限更少的开发人员创建其他密钥。
Amazon.com has several regions. These correspond to physical regions of the world: West Coast US, East Coast US, several Asian locations, a South American one, and two European ones. If you are transferring data, it's best to keep it close to where you will be transferring to and from. Additionally, keep in mind that if you are handling user information, there may be regulatory issues regarding transfer to another jurisdiction. In this case, do check with an informed counsel on the implications of transferring data about European customers to the US or any other similar transfer.
AWS 是一个非常大的主题,有各种各样的书籍专门涵盖它。本章的目的是让您对 AWS 的可用性和可能性有一个总体印象。本着这本书的实践精神,我们通过举例来做到这一点,但我们不会穷尽所有的可能性。
创建您的第一台虚拟机
第一步是去http://aws.amazon.com/创建账户。这些步骤类似于任何其他在线服务。一台机器是免费的,但要获得更多,你需要一张信用卡。在这个例子中,我们将使用几台机器,所以如果你想运行它,它可能会花费你几美元。如果你还没有准备好取出信用卡,你当然可以阅读这一章来了解 AWS 提供了什么,而不必通过例子。然后,您可以就是否注册做出更明智的决定:
- 一旦您注册了 AWS 并登录,您将被带到控制台。在这里,您将看到 AWS 提供的许多服务,如下图所示(这是本书撰写时显示的面板。亚马逊会定期进行细微的更改,因此您可能会看到与我们在书中呈现的略有不同的内容):
- 您必须首先使用身份和访问管理服务创建用户。添加一个用户,在截图中称为
aws_ml
,并为其分配编程访问权限:
- 我们为用户创建了一个组(在下面的截图中称为
EC2_FULL
),并赋予其 AmazonEC2FullAccess 权限。分配正确的权限非常重要,否则后续步骤将失败,并出现权限错误:
- 最后,您必须复制信息,即访问密钥。您只需下载 CSV 文件并保存即可。同样,如果不保存此信息,后续步骤将失败:
- 现在,我们回到控制台,这一次,我们选择并单击 EC2(计算列中的顶部元素)。我们现在看到了 EC2 管理控制台,如下图所示:
- 在右上角,您可以选择您的地区(参见亚马逊地区信息框)。请注意,您将只看到您所选地区的信息。因此,如果您错误地选择了错误的区域(或者让机器在多个区域中运行),您的机器可能不会出现(这似乎是使用 EC2 web 管理控制台的常见陷阱)。
- 用 EC2 的说法,正在运行的服务器被称为实例。我们选择启动实例,这将导致以下屏幕,要求我们选择要使用的操作系统:
- 选择 Amazon Linux 选项(如果您熟悉其他提供的 Linux 发行版之一,如 Red Hat、SUSE 或 Ubuntu,也可以选择其中一个,但配置会略有不同)。现在您已经选择了软件,您将需要选择硬件。在下一个屏幕上,您将被要求选择要使用的机器类型:
- 我们将从
t2.micro
型的一个实例开始(该t1.micro
型是一个更老的,甚至功能更弱的机器)。这是最小的机器,而且是免费的。继续点击“下一步”,接受所有默认值,直到屏幕上出现一个密钥对:
- 我们将为密钥对选择名称
awskeys
。然后选中创建新的密钥对。命名密钥对文件awskeys.pem
。下载并保存这个文件到安全的地方!这是安全外壳 ( SSH )密钥,可以让你登录你的云机。接受剩余的默认值,您的实例将启动。 - 现在,您需要等待几分钟,让实例启动。最终,该实例将以绿色显示,状态为正在运行:
- 在前面的截图中,您应该会看到可用于登录实例的公共 IP,如下所示:
$ ssh -i awskeys.pem ec2-user@54.93.165.5
- 因此,我们将调用
ssh
命令,并将之前下载的密钥文件作为身份传递给它(使用-i
选项)。我们以用户ec2-user
的身份登录到 IP 地址为54.93.165.5
的机器上。当然,这个地址在你的情况下会有所不同。如果您为实例选择另一个分发版本,用户名也可能会改变。在这种情况下,请尝试登录为root
、ubuntu
(对于 Ubuntu 发行版)或fedora
(对于 Fedora 发行版)。 - 最后,如果您运行的是 Unix 风格的操作系统(包括 macOS),您可能需要调用以下命令来调整其权限:
$ chmod 600 awskeys.pem
这仅设置当前用户的读/写权限。否则 SSH 会给你一个丑陋的警告。
- 现在你应该可以登录你的机器了。如果一切正常,您应该会看到横幅,如下图所示:
这是一个普通的 Linux 盒子,你有sudo
权限:你可以以超级用户的身份运行任何命令,只要在它前面加上sudo
。您可以运行它推荐的update
命令来使您的机器达到速度。
在亚马逊 Linux 上安装 Python 包
如果您喜欢另一个发行版,您可以使用您对该发行版的了解来安装 Python、NumPy 和其他发行版。在这里,我们将在标准的亚马逊发行版上进行:
- 我们首先安装几个基本的 Python 包,如下所示:
$ curl -O https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh
$ chmod +x ./Miniconda3-latest-Linux-x86_64.sh
$ bash ./Miniconda3-latest-Linux-x86_64.sh
- 现在,按照基本说明,按照指示设置
PATH
变量:
$ export PATH=/home/ec2-user/miniconda3/bin:$PATH
- 现在,我们可以创建一个新的环境,我们称之为
py3.6
(因为它是 Python 3.6 环境)并激活它:
$ conda create -n py3.6 python=3.6 numpy scikit-learn
$ source activate py3.6
- 要安装
mahotas
和jug
,我们添加conda-forge
通道(这样做一般是个好主意;它有许多维护良好的软件包):
$ conda config --add channels conda-forge
$ conda install mahotas imread jug git
在我们的云机器上运行 jug
我们现在可以通过将代码存储库克隆到您的机器上来下载这本书的数据和代码:
$ git clone \
https://github.com/PacktPublishing/Building-Machine-Learning-Systems-with-Python-Third-edition
$ cd BuildingMachineLearningSystemsWithPython
$ cd ch14
最后,我们运行以下命令:
$ jug execute
这很好,但是我们要等很长时间才能得到结果。我们的自由层机器(类型t2.micro
)速度不是很快,只有一个处理器。所以,我们将升级我们的机器!
我们返回到 EC2 控制台,右键单击正在运行的实例以获得弹出菜单。我们需要首先停止实例。这是相当于关机的虚拟机。你可以随时停止你的机器。在这一点上,你停止支付他们。请注意,您仍在使用磁盘空间,这也有成本,单独计费。您可以终止实例,这也会破坏磁盘。这意味着您会丢失保存在机器上的任何信息。
机器停止后,“更改实例类型”选项变得可用。现在,我们可以选择一个更强大的实例,例如,一个有八个内核的c5.xlarge
实例。机器仍然关闭,因此您需要再次启动它(相当于启动)。
AWS offers several instance types at different price points. As this information is constantly being revised as more powerful options are introduced and prices change (generally, getting cheaper), we cannot give you many details in the book, but you can find the most up-to-date information on Amazon's website.
我们需要等待实例重新启动。一旦它有了,就像我们以前做的那样查找它的 IP 地址。当您更改实例运行类型时,您的实例将获得分配给它的新地址。
You can assign a fixed IP to an instance using Amazon.com's Elastic IPs functionality, which you will find on the left-hand side of the EC2 console. This is useful if you find yourself creating and modifying instances very often. There is a small cost associated with this feature.
使用8
内核,您可以同时运行8
jug 进程,如以下代码所示:
$ # the loop below runs 8 times
$ for counter in $(seq 8); do
> jug execute &
> done
使用jug status
检查这八个作业实际上是否正在运行。完成工作后(现在应该会很快),您可以停止机器,并再次将其降级到t2.micro
实例以节省资金。微观实例
可以免费使用(在一定范围内),而我们使用的c5.xlarge
每小时收费 0.170 美元(截至 2018 年 6 月—查看 AWS 网站了解最新信息)。
使用 cfncluster 自动生成集群
正如我们刚刚了解到的,我们可以使用 web 界面来生成机器,但是它很快就会变得乏味并且容易出错。幸运的是,亚马逊有一个 API。这意味着我们可以自动编写脚本来执行前面讨论的所有操作。更好的是,其他人已经开发了工具,可以用来机械化和自动化许多你想用 AWS 执行的过程。
亚马逊自己为自己的基础设施提供了许多命令行工具。对于集群供应,该工具称为cfncluster
。如果您正在使用conda
,您可以通过以下方式进行安装:
$ conda install cfncluster
您可以在本地机器上运行这个:它将使用亚马逊应用编程接口。
第一步是在您的网络浏览器中返回到 AWS 控制台,并向您的 AWS 用户添加管理员访问权限。这是一种蛮力方法;它赋予用户所有管理权限,虽然它在了解 AWS 时很有用,但不建议在生产中使用:
现在,我们使用 VPC 服务在 AWS 上创建了一个新的虚拟私有云。选择所有默认选项:
$ cfncluster configure
从列出的选项中选择。选择正确的密钥很重要(我们之前已经生成了)。这将在~/.cfncluster/config
中生成一个配置文件。目前,我们将使用所有的默认值,但这是您以后可以更改它们以满足您的需求的地方。
Keys, keys, and more keys:
There are three completely different types of keys that are important when dealing with AWS. First, there is a standard username/password combination, which you use to log in to the website. Second, there is the SSH key system, which is a public/private key system implemented with files; with your public key file, you can log in to remote machines. Third, there is the AWS access key/secret key system, which is just a form of username/password that allows you to have multiple users on the same account (including adding different permissions to each one, but we will not cover these advanced features in this book). To look up our access/secret keys, we go back to the AWS Console, click on our name in the top right, and select Security Credentials. Now, at the bottom of the screen, we should see our access key, which may look something like this: AAKIIT7HHF6IUSN3OCAA
.
我们现在可以创建集群:
$ cfncluster create public
这可能需要几分钟时间。这将为我们的集群分配两个计算节点和一个主节点(这些是默认值;您可以在cfncluster
配置文件中更改它们)。过程完成后,您应该会看到输出:
**Output:"MasterPublicIP"="52.86.118.172" ** **
Output:"MasterPrivateIP"="172.30.2.146"
Output:"GangliaPublicURL"="http://52.86.118.172/ganglia/"
Output:"GangliaPrivateURL"="****http://172.30.2.146/ganglia/****"**
注意打印出来的主节点的 IP 地址(本例中为52.86.118.172
)。如果您忘记了,可以使用以下命令再次查找:
$ cfncluster status public
所有这些节点都有相同的文件系统,因此我们在主节点上创建的任何内容也将被工作节点看到。这也意味着我们可以在这些集群上使用 jug。
这些集群可以按照您的意愿使用,但是它们配备了作业队列引擎,这使得它们非常适合批量处理。使用它们的过程很简单:
-
您登录到主节点。
-
你在主机上准备你的脚本(或者更好的是,事先准备好)。
-
您向队列提交作业。作业可以是任何 Unix 命令。调度程序会找到空闲节点并运行您的作业。
-
你等待工作完成。
-
您在主节点上读取结果。你现在也可以杀死所有的从节点来省钱。无论如何,当你不再需要系统时,不要让它一直运行!否则,这将花费你(美元和美分)。
如前所述,cfncluster
为其集群提供批量排队系统;您编写一个脚本来执行您的操作,将它放在队列中,它将在任何可用的节点中运行。
和以前一样,我们使用密钥登录主节点:
$ ssh -i awskeys.pem ec2-user@52.86.118.172
像以前一样设置 miniconda(代码库中的 setup— aws.txt
文件包含所有必要的命令)。我们可以使用与以前相同的jugfile
系统,只是现在,我们不再直接在主机上运行它,而是在集群上调度它:
- 首先,编写一个非常简单的包装脚本,如下所示:
#!/usr/bin/env bash
export PATH=$HOME/miniconda3/bin:$PATH
source activate py3.6
jug execute jugfile.py
- 称之为
run-jugfile.sh
,使用chmod +x run-jugfile.sh
赋予其可执行权限。现在,我们可以使用以下命令在群集上安排作业:
$ qsub -cwd ./run-jugfile.sh
这将创建两个作业,每个作业都将运行run-jugfile.sh
脚本,我们简单地称之为 jug。你仍然可以随心所欲地使用大师。特别是,您可以随时运行jug status
并查看计算状态。事实上,jug 正是在这样的环境下开发的,所以它在其中工作得非常好。
- 最终,计算会结束。此时,我们需要首先保存我们的结果。然后,我们可以杀死所有的节点。我们创建一个目录
~/results
,并在那里复制我们的结果:
# mkdir ~/results
# cp results.image.txt ~/results
- 现在,注销集群并返回到我们的工作机:
# exit
- 现在我们回到了原来的 AWS 机器或您的本地计算机(注意下面代码示例中的
$
符号):
**$ scp -i awskeys.pem -pr** **ec2-user@****52.86.118.172****:****results .**
- 最后,我们应该杀死所有节点来省钱,如下所示:
$ cfncluster stop public
Stopping the cluster will destroy the compute nodes, but keep the master node running as well as the disk space. This reduces costs to a minimum, but to really destroy all
$ cfncluster delete public
Terminating will really destroy the filesystem and all your results. In our case, we have copied the final results to safety manually. Another possibility is to have the cluster write to a filesystem, which is not allocated and destroyed by cfncluster
, but is available to you on a regular instance; in fact, the flexibility of these tools is immense. However, these advanced manipulations cannot all fit in this chapter.
摘要
我们研究了如何使用 jug,一个小的 Python 框架,以一种利用多核或多台机器的方式来管理计算。虽然这个框架是通用的,但它是专门为满足作者(也是本书的作者)的数据分析需求而构建的。因此,它有几个方面使其适合 Python 机器学习环境的其余部分。
您还了解了 AWS 和亚马逊云。使用云计算通常比建立内部计算能力更有效地利用资源。如果你的需求不是一成不变的,而是在不断变化的,那就更是如此。此外cfncluster
甚至允许集群在你启动更多作业时自动增长,在它们终止时自动收缩。
这是这本书的结尾。我们已经走了很长的路。您学习了如何执行分类和聚类。您学习了降维和主题建模,以便理解大型数据集。最后,我们看了一些具体的应用(如音乐流派分类和计算机视觉)。对于实现,我们依赖于 Python。这种语言在 NumPy 的基础上构建了一个日益扩展的数值计算包生态系统。只要有可能,我们就依赖 scikit-learn,但在必要时使用其他包。由于它们都使用相同的基本数据结构(NumPy 多维数组),因此可以无缝地混合不同包的功能。本书中使用的所有包都是开源的,可以在任何项目中使用。
自然,我们没有涵盖每一个机器学习主题。在附录中,我们提供了一些其他资源,这些资源将帮助感兴趣的读者了解更多关于机器学习的知识。
十五、从哪里了解更多关于机器学习的信息
我们的书已经写完了。现在让我们花点时间看看还有哪些对我们的读者有用的东西。
有很多很棒的资源可以用来学习更多关于机器学习的知识——太多了,这里就不一一介绍了。因此,以下仅代表作者认为在撰写本文时最好的一小部分有偏见的资源样本。
在线课程
吴恩达是斯坦福大学的一名教授,他在 Coursera(http://www.coursera.org)开设了一门在线机器学习课程。这是免费的,但可能意味着大量的时间投资。
书
这本书侧重于机器学习的实用方面。我们没有展示算法背后的思想,也没有展示证明其合理性的理论。如果你对机器学习的那个方面感兴趣,我们推荐模式识别和机器学习,作者克里斯托弗·毕肖普。这是该领域的经典介绍性文本。它将教会你我们在这本书里使用的大多数算法的本质。
如果你想超越入门,学习所有血淋淋的数学细节,凯文·墨菲的《机器学习:概率视角》是一个很好的选择(www.cs.ubc.ca/~murphyk/MLbook)。它非常新(2012 年出版),包含了 ML 研究的前沿。这本 1100 页的书也可以作为参考,因为很少机器学习被遗漏了。
具体到深度学习,你可能想看伊恩·古德费勒等人(http://www.deeplearningbook.org)的深度学习。这本书更多的是在理论方面,但仍然非常容易理解。它的网络版是免费的,但有些书是值得投资的。
博客
这里有一个显然不是详尽的博客列表,从事机器学习的人可能会感兴趣:
- 交叉验证:http://stats.stackexchange.com(好吧,其实不是博客,而是问答网站。然而,答案往往如此之好,以至于它们也可以作为博客文章发表。)
- 机器学习理论:http://hunch.net。平均速度是每月一个帖子,非常实用,总是令人惊讶的方法
- Edwin Chen 的博客: http://blog.echen.me 。平均速度是每月一篇文章,涵盖更多的应用主题
- 机器学习:http://www.machinedlearnings.com。平均速度是每月一篇文章,涵盖更多的应用主题
- 流动数据:http://flowingdata.com。平均速度是每天一篇文章,文章以统计数据为中心
- 简单统计:http://simplystatistics.org。每月几篇文章,专注于统计和大数据
- 统计建模、因果推理和社会科学:http://andrewgelman.com。每天一篇文章,当作者使用统计数据指出流行媒体的缺陷时,通常会很有趣
数据源
如果你想玩玩算法,你可以从加州大学欧文分校的机器学习库中获得许多数据集(T2 UCI 大学)。你可以在http://archive.ics.uci.edu/ml找到。
变得有竞争力
了解更多机器学习的一个好方法是尝试一个竞赛!卡格尔(http://www.kaggle.com)是一个 ML 比赛的市场,在介绍中已经提到了。在网站上,你会发现几个不同的比赛,有不同的结构,经常是现金奖励。
受监督的学习竞赛几乎总是遵循这种形式:你(和所有其他竞争对手)可以访问有标签的培训数据和测试数据(没有标签)。你的任务是提交测试数据的预测。当比赛结束时,谁的准确度最高谁就赢了。奖品从荣誉到现金不等。
当然,赢得一些东西是好的,但是你可以通过参与获得很多有用的经验。因此,比赛结束后,当参与者开始在论坛上分享他们的方法时,你必须保持关注。很多时候,获胜并不是开发一个新的算法,而是巧妙地进行预处理、归一化,并结合现有的方法。
所有这些都被忽略了
我们没有涵盖 Python 可用的所有机器学习包。鉴于空间有限,我们选择专注于 scikit-learn。但是,还有其他选择,我们在此列出其中一些:
- 熊猫(https://pandas.pydata.org)–如果你决定一生只爱上一个 Python 包,就选这个吧!它在 NumPy 之上提供了一个便利层,极大地加快了常见任务的速度,例如交互式数据预处理。
- 当然还有其他所有令人兴奋的深度学习工具包,比如 CNTK ( http://cntk.ai )、py torch(https://pytorch.org/)、MXNet(https://mxnet.apache.org/)、Chainer(https://chainer.org/)、DSSTNE(https://github.com/amzn/amazon-dsstne)或者 DyNet(https://github.com/clab/dynet)。
- keras(https://keras.io/),这是一个位于 TensorFlow 和 CNTK 之上的便捷库。通常,人们从“真正快速试用”版本的 Keras 开始,却发现它已经足够好了。
- MissingNo(https://github.com/ResidentMario/missingno),这是一个 Python 模块,专门用于分析和修剪缺失值。
- 机器学习工具包(Milk)(http://luispedro.org/software/milk)–这个包是由本书的作者之一开发的,涵盖了 scikit-learn 中没有包括的一些算法和技术。
摘要
我们现在真的到了尽头。我们希望您喜欢这本书,并觉得自己已经准备好开始自己的机器学习冒险。
我们也希望你明白仔细测试你的方法的重要性。特别是,我们希望你已经理解了使用正确的交叉验证方法和不报告训练测试结果的重要性,这是对你的方法有多好的过度夸大的估计。