Sklearn-和-Python-科学工具集的机器学习实用指南-全-

Sklearn 和 Python 科学工具集的机器学习实用指南(全)

原文:annas-archive.org/md5/ec14cdde5f82b4b7e0113bdbb2bbe4c7

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

你可能已经看到过 《哈佛商业评论》 将数据科学描述为 21^(世纪) 的 最性感职业。你也可能经常在新闻中看到 机器学习人工智能 等词汇的出现。你渴望尽快加入这个机器学习数据科学家的行列。或者,也许你已经在这个领域工作,但希望将自己的职业生涯提升到新的高度。你想了解更多关于基础统计学和数学理论的知识,并希望利用实践中最常用的工具——scikit-learn,来应用这些新知识。

本书就是为你准备的。它从机器学习的概念和基础知识讲起,理论与应用之间保持平衡。每一章都涵盖了不同的算法,并展示了如何利用它们解决实际问题。你还将通过实际示例学习各种关键的监督学习和无监督学习算法。无论是基于实例的学习算法、贝叶斯估计、深度神经网络、基于树的集成方法,还是推荐系统,你都会深入理解其理论,并学会何时将其应用于实际问题。

本书不仅限于介绍 scikit-learn,还将帮助你为工具箱增加更多的工具。你将通过诸如 pandas、Matplotlib、imbalanced-learn 和 scikit-surprise 等工具增强 scikit-learn 的功能。到本书结束时,你将能够将这些工具结合起来,采取数据驱动的方法提供端到端的机器学习解决方案。

第一章:本书的读者对象

本书适合那些希望掌握机器学习算法的理论和实践,并理解如何将其应用于实际问题的机器学习数据科学家。要求具备 Python 的工作知识,并对基础的数学和统计概念有一定理解。然而,本书将引导你逐步了解新的概念,适合新手和经验丰富的数据科学家。

本书内容

第一章,机器学习简介,将向你介绍不同的机器学习范式,并使用来自行业的例子来说明。你还将学习如何使用数据来评估你构建的模型。

第二章,使用树做决策,将解释决策树的工作原理,并教你如何使用它们进行分类和回归。你还将学习如何从你构建的树中推导出业务规则。

第三章,用线性方程做决策,将介绍线性回归。在理解其运作方式后,我们将学习相关的模型,如岭回归、套索回归和逻辑回归。本章还将为理解后续章节中的神经网络打下基础。

第四章,准备你的数据,将讲解如何使用插补功能处理缺失数据。接着,我们将使用 scikit-learn 以及一个名为 categorical-encoding 的外部库,来为我们接下来在本书中使用的算法准备分类数据。

第五章,使用最近邻进行图像处理,将解释 k 近邻算法及其超参数。我们还将学习如何为最近邻分类器准备图像。

第六章,使用朴素贝叶斯进行文本分类,将教你如何将文本数据转换为数字,并使用机器学习算法进行分类。我们还将学习如何处理同义词和高维数据的问题。

第七章,神经网络——深度学习来袭,将深入讲解如何使用神经网络进行分类和回归。我们还将学习数据缩放技术,因为这是加速收敛的必要条件。

第八章,集成方法——当单个模型不够时,将讲解如何通过将多个算法组合成集成模型来减少偏差或方差。我们还将学习不同的集成方法,从装袋法到提升法,并了解何时使用它们。

第九章,Y 和 X 同样重要,将教你如何构建多标签分类器。我们还将学习如何强制模型输出之间的依赖关系,并通过校准使分类器的概率更可靠。

第十章,不平衡学习——连 1%都没中的彩票,将介绍如何使用不平衡学习助手库,并探讨过采样和欠采样的不同方法。我们还将学习如何在集成模型中使用这些采样方法。

第十一章,聚类——理解无标签数据,将介绍聚类作为一种无监督学习算法,用于理解无标签数据。

第十二章,异常检测——发现数据中的离群值,将探讨不同类型的异常检测算法。

第十三章,推荐系统——了解它们的偏好,将教你如何构建推荐系统并将其部署到生产环境中。

为了充分利用本书

你需要在计算机上安装 Python 3.x。建议设置虚拟环境以安装所需的库。你可以选择使用 Python 的venv模块、Anaconda 提供的虚拟环境,或者你喜欢的任何其他选项。我将使用pip来安装书中所需的库,但最终是否使用conda或其他替代方法由你决定。

在 第一章《机器学习简介》中,我将解释你需要安装的基本库,以便开始学习。我将向你展示如何安装这些库,并使用这里测试过的相同版本,这样我们可以在整个书籍过程中保持一致。每当我们在后续章节中需要安装其他库时,我也会解释如何进行设置。

我使用 Jupyter Notebooks 运行本书中的代码并展示配套图表。我建议你也访问 Project Jupyter 网站并安装 Jupyter Notebook 或 Jupyter Lab。这个设置通常在运行实验代码时推荐使用。它帮助你将代码拆分成小块,分别迭代每一部分,并将生成的图表与代码一起展示。当你需要编写生产代码时,可以使用你最喜欢的集成开发环境IDE)代替。

除了所需的软件,你有时还需要下载额外的数据集。每当需要时,我会提供所需数据集的链接,并给出逐步说明,告诉你如何下载和预处理它们。

我编写了整本书,并在一台配有 16GB 内存的 MacBook Pro 上运行其代码。我预计这里的代码可以在任何其他操作系统上运行,无论是 Microsoft Windows 还是任何不同的 Linux 发行版。机器学习算法更常见的瓶颈是内存限制,而不是 CPU 限制。然而,对于本书中使用的大部分代码和数据集,我预计内存较小的计算机仍然可以正常工作。

如果你使用的是本书的电子版,我们建议你自己输入代码或通过 GitHub 仓库访问代码(下节提供链接)。这样做可以帮助你避免与复制粘贴代码相关的潜在错误。

下载示例代码文件

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

你可以按照以下步骤下载代码文件:

  1. 登录或注册 www.packt.com

  2. 选择“支持”标签。

  3. 点击“代码下载”。

  4. 在搜索框中输入书名,并按照屏幕上的指示操作。

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

  • Windows 系统的 WinRAR/7-Zip

  • Mac 系统的 Zipeg/iZip/UnRarX

  • Linux 系统的 7-Zip/PeaZip

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-Machine-Learning-with-scikit-learn-and-Scientific-Python-Toolkits。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还提供了其他代码包,这些代码包来自我们丰富的书籍和视频目录,可以在github.com/PacktPublishing/ 上查看!

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在此处下载:static.packt-cdn.com/downloads/9781838826048_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这里有一个例子:"我们将使用其fit_transform变量和transform方法。"

代码块设置如下:

import numpy as np
import scipy as sp
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt

任何命令行输入或输出都写成以下格式:

          $ pip install jupyter

          $ pip install matplotlib

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的字词会以如下方式显示在文本中:"对于线性模型和K-最近邻KNN)算法,建议使用独热编码。"

警告或重要提示显示如下。

提示和技巧显示如下。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请在消息主题中提及书名,并通过电子邮件联系我们,地址为customercare@packtpub.com

勘误表:尽管我们已尽一切努力确保内容准确性,但错误仍可能发生。如果您在本书中发现错误,请向我们报告。请访问www.packtpub.com/support/errata,选择您的书籍,点击勘误提交表单链接并输入详细信息。

盗版:如果您在互联网上发现我们作品的任何形式的非法副本,我们将不胜感激您提供地址或网站名称。请联系我们,提供材料链接至copyright@packt.com

如果您有兴趣成为作者:如果您在某个专题上有专业知识,并且有意写作或为书籍做出贡献,请访问authors.packtpub.com

评论

请留下您的评价。阅读并使用本书后,为什么不在您购买书籍的网站上留下评论呢?潜在的读者可以通过您的公正意见做出购买决策,我们在 Packt 可以了解您对我们产品的看法,而我们的作者也能看到您对他们书籍的反馈。谢谢!

关于 Packt 的更多信息,请访问 packt.com

第一部分:监督学习

监督学习迄今为止是商业中最常用的机器学习范式。它是实现自动化手动任务的关键。本节内容包括了可用于监督学习的不同算法,你将学习何时使用每种算法。我们还将尝试展示不同类型的数据,从表格数据到文本数据和图像数据。

本章节包括以下内容:

第一章:机器学习介绍

机器学习无处不在。当您预订航班机票时,算法决定您将支付的价格。当您申请贷款时,机器学习可能决定您是否能获得贷款。当您滚动查看 Facebook 的时间线时,它会选择向您展示哪些广告。机器学习还在您的 Google 搜索结果中起着重要作用。它整理您的电子邮件收件箱并过滤垃圾邮件,在您申请工作时,招聘人员查看您的简历之前,它也会进行审阅,而且最近它还开始在 Siri 等虚拟助手形式中扮演您的个人助理的角色。

在这本书中,我们将学习机器学习的理论和实践。我们将了解何时以及如何应用它。首先,我们将高层次介绍机器学习的工作原理。然后,您将能够区分不同的机器学习范式,并知道何时使用每种范式。接下来,您将了解模型开发生命周期以及从业者解决问题所采取的不同步骤。最后,我们将向您介绍 scikit-learn,并了解为什么它是许多从业者的事实标准工具。

这是本书第一章将涵盖的主题列表:

  • 理解机器学习

  • 模型开发生命周期

  • scikit-learn 简介

  • 安装您需要的软件包

理解机器学习

您可能想知道机器是如何学习的。为了回答这个问题,让我们来看一个虚构公司的例子。太空穿梭公司有几辆太空车可供租赁。他们每天收到来自想要去火星旅行的客户的申请。他们不确定这些客户是否会归还车辆 —— 也许他们会决定继续在火星上生活,永远不会回来。更糟糕的是,一些客户可能是糟糕的飞行员,在途中会坠毁他们的车辆。因此,公司决定聘请穿梭机租赁审批官员,他们的工作是审核申请并决定谁值得搭乘穿梭机。然而,随着业务的扩展,他们需要制定穿梭机审批流程。

一家传统的班车公司会先制定业务规则,并雇佣初级员工执行这些规则。例如,如果你是外星人,那么抱歉,你不能从我们这里租借班车。如果你是人类,并且有孩子在地球上上学,那么你完全可以租借我们的班车。如你所见,这些规则过于宽泛。那么,喜欢住在地球并且只想去火星度假一小会儿的外星人呢?为了制定更好的业务政策,公司开始雇佣分析师。他们的工作是浏览历史数据,尝试制定详细的规则或业务逻辑。这些分析师能制定非常详细的规则。如果你是外星人,其中一位父母来自海王星,年龄在 0.1 到 0.2 海王星年之间,并且你有三到四个孩子,其中一个孩子的 DNA 至少 80%是人类,那么你可以租借班车。为了能够制定合适的规则,分析师还需要一种方法来衡量这些业务逻辑的优劣。例如,如果应用某些规则,班车的回收率是多少?他们使用历史数据来评估这些指标,只有这样,我们才能说这些规则确实是从数据中学习出来的。

机器学习几乎以相同的方式工作。你想要利用历史数据来制定一些业务逻辑(一个算法),以优化某种衡量逻辑好坏的指标(目标函数或损失函数)。在本书中,我们将学习许多机器学习算法;它们在表示业务逻辑的方式、使用的目标函数以及利用的优化技术上各不相同,目标是达到一个最大化(或有时最小化)目标函数的模型。就像前面示例中的分析师一样,你应该选择一个尽可能接近你的业务目标的目标函数。每当你听到有人说数据科学家应该对他们的业务有很好的理解时,重要的一部分就是他们选择一个好的目标函数以及评估他们所建立模型的方法。在我的例子中,我迅速选择了“退还的班车百分比”作为我的目标。

但是如果你仔细想想,这真的是班车公司收入的准确一对一映射吗?通过允许一次旅行获得的收入是否等于失去一辆班车的成本?此外,拒绝一次旅行可能还会让公司接到愤怒的客户服务电话,并导致负面口碑传播。在选择目标函数之前,你必须对这些情况有足够的了解。

最后,使用机器学习的一个关键好处是,它能够在大量的业务逻辑案例中进行迭代,直到达到最佳目标函数,而不像我们太空飞行器公司中的分析师那样,受限于规则的局限,无法深入。机器学习方法也是自动化的,意味着它在每次新数据到来时都会更新业务逻辑。这两个方面使得它具有可扩展性、更加精准,并且能够适应变化。

机器学习算法的类型

“社会在变化,每次都通过一个学习算法。”

– Pedro Domingos

在本书中,我们将介绍机器学习的两大主流范式——监督学习和无监督学习。这两种范式各自有一些子分支,我们将在下一节讨论。虽然本书中不涉及,但强化学习也将在下一节简单介绍:

让我们再次使用我们的虚构太空飞行器公司来解释不同机器学习范式之间的差异。

监督学习

还记得学校里那些美好的时光吗?老师给你提供了练习题,并在最后给出正确答案来验证你是否做得好?然后,在考试时,你就得独立完成。这基本上就是监督学习的原理。假设我们的虚构太空飞行器公司想要预测旅行者是否会归还他们的太空飞行器。幸运的是,公司以前与许多旅行者合作过,他们已经知道哪些旅行者归还了飞行器,哪些没有。可以把这些数据想象成一个电子表格,其中每一列都包含一些关于旅行者的信息——他们的财务状况、孩子的数量、是否是人类或外星人,甚至可能包括他们的年龄(当然是以海王星年为单位)。机器学习专家称这些列为特征。除此之外,还有一列用于记录旅行者是否归还了飞行器的历史数据,我们称这列为标签目标列。在学习阶段,我们使用特征和目标来构建一个模型。算法在学习的目标是最小化其预测值与实际目标之间的差异,这种差异被称为误差。一旦模型构建完成并且误差最小化,我们就可以用它来对新的数据点进行预测。对于新旅行者,我们只知道他们的特征,但我们会使用刚构建的模型来预测他们对应的目标。简而言之,目标数据在我们历史数据中的存在,使得这个过程是监督学习。

分类与回归

有监督学习进一步细分为分类和回归。在只有少数预定义标签需要预测的情况下,我们使用分类器——例如,回报不回报,或者人类火星人金星人。如果我们要预测的是一个广泛范围的数值——比如说,一个旅行者返回需要多少年——那么这就是一个回归问题,因为这些数值可以是从 1 年或 2 年到 3 年、5 个月、7 天等任意值。

有监督学习评估

由于它们的差异,我们用来评估这些分类器的度量通常与我们在回归中使用的度量不同:

  • 分类器评估度量:假设我们使用分类器来判断一个旅行者是否会返回。那么,对于那些分类器预测会返回的旅行者,我们希望衡量其中有多少实际返回。我们称这个度量为精度。另外,对于所有实际返回的旅行者,我们希望衡量其中有多少被分类器正确预测为返回。我们称这个度量为召回率。精度和召回率可以针对每个类别进行计算——也就是说,我们还可以计算未返回旅行者的精度和召回率。

准确率是另一个常用的、有时被滥用的度量标准。对于我们历史数据中的每一个案例,我们都知道旅行者是否实际返回(实际值),并且我们还可以生成预测他们是否会返回的结果。准确率计算预测与实际值匹配的百分比。正如你所看到的,它被称为不考虑类别,因此当类别高度不平衡时,它有时可能会产生误导。在我们的业务示例中,假设 99%的旅行者实际返回。我们可以构建一个虚拟分类器,预测每个旅行者都会返回;它 99%的时间是准确的。然而,这个 99%的准确率并不能告诉我们太多,特别是当你知道在这些案例中,未返回旅行者的召回率是 0%时。正如我们将在本书后面看到的,每个度量标准都有其优缺点,且一个度量标准的好坏取决于它与我们的业务目标的接近程度。我们还将学习其他度量标准,例如F[1] 分数AUC对数损失

  • 回归模型评估度量:如果我们使用回归模型来预测旅行者将停留多久,那么我们需要确定回归模型预测的数字与现实之间的差距。假设对于三个用户,回归模型预期他们分别停留 6 年、9 年和 20 年,而他们实际分别停留了 5 年、10 年和 26 年。一个解决方案是计算预测与现实之间差异的平均值——即 6-5、9-10 和 20-25 的平均值,所以 1、-1 和-6 的平均值为-2。这个计算的一个问题是 1 和-1 相互抵消。如果你仔细想想,1 和-1 都是模型所犯的错误,符号在这里可能并不重要。

所以,我们需要使用平均绝对误差MAE)来代替。这计算的是差异的绝对值的平均数——例如,1、1 和 6 的平均值是 2.67。现在这更有意义了,但如果我们能容忍 1 年的差异,而不是 6 年的差异呢?那么,我们可以使用均方误差MSE)来计算差异平方的平均数——例如,1、1 和 36 的平均值是 12.67。显然,每个度量方法也有其优缺点。此外,我们还可以使用这些指标的不同变体,如中位数绝对误差或最大误差。此外,有时你的业务目标可能决定了其他的度量标准。比如,我们希望在模型预测一个旅行者会比预测他早 1 年到达时,预测他晚 1 年到达的频率是前者的两倍——那么,你能想到什么度量标准来衡量这个呢?

在实践中,分类问题和回归问题的界限有时会变得模糊。以旅行者返回的年数为例,你仍然可以选择将范围分成 1-5 年、5-10 年和 10 年以上。然后,你就变成了一个分类问题需要解决。相反,分类器会返回概率和它们预测的目标。在一个用户是否会返回的问题中,从二分类器的角度来看,预测值 60% 和 95% 是一样的,但分类器对于第二种情况比第一种情况更有信心。虽然这依然是一个分类问题,但我们可以使用Brier 分数来评估我们的分类器,这实际上是MSE的伪装。关于 Brier 分数的更多内容将在第九章中讲解,Y 和 X 一样重要。大多数时候,是否是分类问题或回归问题是明确的,但始终保持警觉,如果需要的话,随时可以重新定义你的问题。

无监督学习

生活并不总是像我们在学校时那样提供正确答案。我们曾被告知,太空旅行者喜欢和志同道合的乘客一起旅行。我们已经了解了很多关于旅行者的信息,但当然没有旅行者会说顺便提一下,我是 A 型、B 型或 C 型旅行者。因此,为了对客户进行分组,我们使用了一种叫做聚类的无监督学习方法。聚类算法试图形成组,并将我们的旅行者分配到这些组中,而我们并未告诉它们可能存在哪些组。无监督学习没有明确的目标,但这并不意味着我们无法评估我们的聚类算法。我们希望同一聚类的成员相似,但也希望它们与相邻聚类的成员有所不同。轮廓系数基本上衡量的就是这一点。在本书后面,我们还会遇到其他用于聚类的评估指标,如Davies-Bouldin 指数Calinski-Harabasz 指数

强化学习

强化学习超出了本书的范围,并且在scikit-learn中并未实现。不过,我会在这里简要介绍一下它。在我们看过的监督学习示例中,我们将每个旅行者视为独立个体。如果我们想知道哪些旅行者最早归还他们的航天器,那么我们的目标就是挑选出最适合的旅行者。但如果仔细想想,一个旅行者的行为也会影响其他旅行者的体验。我们只允许航天器在太空中停留最长 20 年。然而,我们并未探索允许某些旅行者停留更久,或者对其他旅行者实施更严格租期的影响。强化学习就是解决这一问题的答案,其关键在于探索与利用。

与其单独处理每个动作,我们可能希望探索次优动作,以便达到整体最优的行动集合。强化学习被应用于机器人学,其中机器人有一个目标,且只能通过一系列步骤来实现——2 步向右,5 步向前,依此类推。我们不能单独判断右步或左步哪一个更好;必须找到完整的序列才能达到最佳结果。强化学习也被广泛应用于游戏和推荐引擎中。如果 Netflix 仅仅向用户推荐最符合他们口味的内容,用户的主页上可能只会显示《星际大战》系列电影。此时,强化学习需要探索次优匹配,以丰富用户的整体体验。

模型开发生命周期

当被要求用机器学习解决问题时,数据科学家通常通过一系列步骤来实现目标。在本节中,我们将讨论这些迭代步骤。

理解问题

“所有模型都是错误的,但有些模型是有用的。”

——乔治·博克斯

开发模型时,首先要做的是深入理解你要解决的问题。这不仅仅涉及理解你在解决什么问题,还包括为什么要解决它、你期望产生什么影响,以及你要与之比较的新解决方案目前已有的解决方案是什么。我理解 Box 所说的“所有模型都是错误的”这句话的意思是,模型只是通过建模现实的一个或多个角度来近似现实。通过理解你要解决的问题,你可以决定需要建模哪些现实角度,以及哪些角度可以忽略。

你还需要充分理解问题,以决定如何拆分数据进行训练和评估(关于这一点会在下一节中详细讨论)。然后,你可以决定使用什么样的模型。这个问题适合使用监督学习还是无监督学习?我们是否更适合使用分类算法还是回归算法?什么样的分类算法最适合我们?线性模型是否足以近似我们的现实?我们是需要最精确的模型,还是一个能够轻松向用户和业务相关者解释的模型?

这里可以进行最小化的探索性数据分析,检查是否有标签,并检查标签的基数(如果有的话),以决定你是否在处理分类问题或回归问题。任何进一步的数据分析最好等到数据集拆分为训练集和测试集后再进行。限制高级数据分析只在训练集上进行非常重要,以确保你的模型的泛化能力。

最后,我们需要理解我们将模型与什么进行比较。我们需要改进的当前基准是什么?如果已经有了业务规则,那么我们的模型在解决当前问题时必须优于这些规则。为了能够决定模型在解决问题上的优越性,我们需要使用评估指标——这些指标必须适合我们的模型,并且尽可能符合我们的业务需求。如果我们的目标是增加收入,那么我们的指标应该能有效估算模型使用后的收入增长,相对于当前的状况。如果我们的目标是增加重复购买,而不管收入如何,那么其他指标可能更合适。

拆分我们的数据

正如我们在监督学习中所看到的那样,我们在一组数据上训练模型,其中给出了正确的答案(标签)。然而,学习仅仅是问题的一半。我们还希望能够判断我们构建的模型在未来的数据上是否能做得很好。我们无法预测未来,但我们可以利用我们已有的数据来评估我们的模型。

我们通过将数据分成不同部分来实现这一目标。我们使用其中一部分数据来训练模型(训练集),然后使用另一部分来评估模型(测试集)。由于我们希望测试集尽可能接近未来的数据,因此在划分数据时,需要注意以下两个关键点:

  • 找到最佳的数据划分方式

  • 确保训练集和测试集是分开的

找到最佳的数据划分方式

假设你的用户数据是按国家字母顺序排序的。如果你仅选择前N条记录用于训练,剩下的用于测试,那么你最终将会训练一个只包含某些国家用户的数据模型,而无法让它学习来自其他国家(比如赞比亚和津巴布韦)的用户数据。因此,一个常见的解决方案是先对数据进行随机化再进行划分。然而,随机划分并不总是最佳选择。例如,假设我们想要建立一个模型,预测未来几年的股票价格或气候变化现象。为了确保我们的系统能够捕捉到诸如全球变暖等时间趋势,我们需要根据时间划分数据。我们可以在早期的数据上进行训练,看看模型是否能在预测更近期数据时表现良好。

有时,我们只是预测稀有事件。例如,支付系统中欺诈案件的发生率可能只有 0.1%。如果你随机划分数据,可能会遇到运气不佳的情况,导致训练集中大部分欺诈案件,而测试集中几乎没有,反之亦然。因此,对于高度不平衡的数据,建议使用分层抽样。分层抽样确保你的目标变量在训练集和测试集中的分布大致相同。

分层抽样策略用于确保我们的人群中不同子群体在样本中都有体现。如果我的数据集由 99%的男性和 1%的女性组成,随机抽样可能会导致样本中全是男性。因此,你应该先将男性和女性人群分开,然后从每个群体中抽取样本,最后将它们合并,确保最终样本中男性和女性都有代表。为了确保训练集和测试集中的所有类别标签都能被代表,我们在这里也应用了相同的概念。在本书的后续章节中,我们将使用train_test_split()函数来划分数据。该函数默认使用类别标签对样本进行分层。

确保训练集和测试集是分开的

新的数据科学家常犯的一个常见错误是前瞻性偏差(look-ahead bias)。我们使用测试数据集来模拟我们未来将看到的数据,但通常,测试数据集包含的是我们只能在时间过去之后才知道的信息。以我们的太空飞行器例子为例;我们可能有两列数据——一列表示飞行器是否会返回,另一列表示飞行器将花多长时间返回。如果我们要构建一个分类器来预测飞行器是否会返回,我们将使用前一列作为目标,但绝不会将后一列用作特征。我们只能在飞行器实际返回后才知道它在太空中停留了多久。这个例子看起来很简单,但相信我,前瞻性偏差是一个非常常见的错误,尤其是在处理不如这个例子明显的情况时。

除了训练,你还需要从数据中学习以便对其进行预处理。例如,假设你希望不是以厘米为单位的用户身高,而是希望有一个特征来表示用户的身高是高于还是低于中位数。为了做到这一点,你需要遍历数据并计算中位数。现在,由于我们所学的任何东西必须来自于训练集本身,因此我们还需要从训练集中学习这个中位数,而不是从整个数据集中学习。幸运的是,在 scikit-learn 的所有数据预处理函数中,fit()predict()transform() 函数都有单独的方法。这确保了从数据中学到的任何东西(通过 fit() 方法)只会从训练数据集中学到,然后可以通过 predict() 和/或 transform() 方法应用到测试集上。

开发集

在开发模型时,我们需要尝试模型的多种配置,以决定哪种配置能够提供最佳结果。为了做到这一点,我们通常会进一步将训练数据集拆分成训练集和开发集。拥有这两个新的子集可以让我们在对其中一个子集进行训练时尝试不同的配置,并评估这些配置变化对另一个子集的影响。一旦我们找到最佳配置,我们就会在测试集上使用最终配置来评估模型。在第二章《使用树做决策》中,我们将实际操作这一过程。请注意,我将交替使用 模型配置超参数 这两个术语。

评估我们的模型

评估模型的性能对于选择最适合的算法以及估计模型在现实生活中的表现至关重要。正如 Box 所说,一个错误的模型仍然可以有用。以一个网络初创公司为例。它们进行了一次广告活动,每次展示广告获得 1 美元的收入,而他们知道每 100 个观看者中,只有一个人注册并购买价值 50 美元的商品。换句话说,他们必须花费 100 美元才能赚取 50 美元。显然,这对他们的业务来说是一个糟糕的投资回报率 (ROI) 。现在,假设你为他们创建了一个可以帮助他们挑选目标用户的模型,但你新建的模型只有 10% 的正确率。在这种情况下,10% 的准确率是好是坏呢?当然,这个模型 90% 的时间都是错误的,听起来好像是一个很糟糕的模型,但如果我们现在计算 ROI,那么他们每花费 100 美元,就能赚取 500 美元。嗯,我一定会付钱给你,来为我构建这个虽然很错误,但却非常有用的模型!

scikit-learn 提供了大量评估指标,我们将在本书中使用这些指标来评估我们构建的模型。但请记住,只有在你真正理解你解决的问题及其商业影响的情况下,评估指标才有用。

在生产环境中部署并进行监控

许多数据科学家选择使用 Python 而不是 R 来进行机器学习的主要原因之一,是 Python 使得将代码投入生产变得更加容易。Python 有许多 Web 框架可以用来构建 API,并将机器学习模型部署到后台。它也得到了所有云服务提供商的支持。我认为,开发模型的团队也应该负责将其部署到生产环境中。用一种语言构建模型,然后让另一个团队将其转换为另一种语言,这种做法容易出错。当然,在大型公司或由于其他实现限制的情况下,由一个人或团队负责构建和部署模型可能并不可行。

然而,让两个团队保持紧密联系,并确保开发模型的团队仍然能够理解生产代码至关重要,这有助于最小化由于开发代码和生产代码不一致而导致的错误。

我们尽量避免在训练模型时出现前瞻性偏差。我们希望数据在模型训练完成后不会发生变化,并且我们希望代码是无错误的。然而,我们无法保证这一切。我们可能忽视了这样一个事实,即用户的信用评分是在他们进行首次购买后才添加到数据库中的。我们可能不知道,开发人员决定在保存时将库存重量从英镑改为使用公制系统,而在训练模型时,它是以英镑为单位的。因此,记录模型所做的所有预测非常重要,以便能够在实际环境中监控模型的表现,并将其与测试集的表现进行比较。你还可以每次重新训练模型时记录测试集的表现,或跟踪目标分布的变化。

迭代

通常,当你部署一个模型时,你最终会得到更多的数据。此外,当你的模型部署到生产环境中时,其性能并不一定能够保持不变。这可能是由于某些实现问题或评估过程中的错误。这两点意味着你解决方案的第一个版本总是可以改进的。从简单的解决方案开始(可以通过迭代来改进)是敏捷编程的一个重要概念,也是机器学习中的核心概念。

这一整个过程,从理解问题到监控解决方案的持续改进,需要那些能够帮助我们快速、高效迭代的工具。在接下来的部分中,我们将介绍 scikit-learn,并解释为什么许多机器学习从业者认为它是处理该任务的正确工具。

何时使用机器学习

“几乎任何正常人可以在不到 1 秒钟内完成的事情,我们现在都可以通过 AI 来自动化。”

– Andrew Ng

在进入下一部分之前有一个额外的说明,当你面对一个问题时,必须决定是否适合使用机器学习。Andrew Ng 的 1 秒规则是一个很好的启发式方法,帮助你评估基于机器学习的解决方案是否可行。背后的主要原因是计算机擅长发现模式。它们在识别重复模式并根据这些模式进行操作方面,远胜于人类。

一旦它们一次又一次地识别出相同的模式,就很容易将这些模式编码成每次都作出相同的决策。以同样的方式,计算机也擅长战术。1908 年,Richard Teichmann 曾指出,一局棋的 99%是基于战术的。也许这就是为什么自 1997 年以来计算机一直战胜人类下棋的原因。如果我们相信 Teichmann 的说法,那么剩下的 1%就是战略。与战术不同,战略是人类战胜机器的领域。如果你要解决的问题可以表述为一组战术,那就用机器学习,让人类来做战略决策。最终,我们大多数日常决策都是战术性的。此外,一个人的战略往往是另一个人的战术。

scikit-learn 简介

既然你已经拿起了这本书,你大概不需要我来说服你为什么机器学习很重要。然而,你可能仍然对为什么特别使用 scikit-learn 有所疑虑。你可能在日常新闻中更常遇到像 TensorFlow、PyTorch 和 Spark 这样的名字,而不是 scikit-learn。那么,让我来说服你为什么我更偏爱后者。

它与 Python 数据生态系统兼容性好

scikit-learn 是一个构建在 NumPy、SciPy 和 Matplotlib 之上的 Python 工具包。这些选择意味着它很好地融入了你日常的数据处理流程。作为一名数据科学家,Python 很可能是你首选的编程语言,因为它既适合离线分析,也适合实时实现。你还会使用像 pandas 这样的工具从数据库中加载数据,它允许你对数据进行大量转换。由于 pandas 和 scikit-learn 都是基于 NumPy 构建的,因此它们相互兼容得很好。Matplotlib 是 Python 的 事实标准 数据可视化工具,这意味着你可以利用其强大的数据可视化功能来探索数据并揭示模型的细节。

由于它是一个开源工具,并且在社区中被广泛使用,许多其他数据工具采用了与 scikit-learn 几乎相同的接口。许多这样的工具建立在相同的科学 Python 库之上,它们统称为 SciKits(即 SciPy****Toolkits 的缩写)——因此,scikit-learn 中的 scikit 前缀就来源于此。例如,scikit-image 是一个用于图像处理的库,而 categorical-encodingimbalanced-learn 是两个单独的数据预处理库,它们作为 scikit-learn 的附加组件构建。

在本书中,我们将使用这些工具,你会发现当使用 scikit-learn 时,将这些不同的工具集成到工作流程中是多么容易。

成为 Python 数据生态系统中的关键角色,是 scikit-learn 成为 事实标准 机器学习工具集的原因。这就是你最有可能用来完成工作申请任务的工具,也是你参加 Kaggle 竞赛并解决大多数专业日常机器学习问题时使用的工具。

实践级别的抽象

scikit-learn 实现了大量的机器学习、数据处理和模型选择算法。这些实现足够抽象,因此你在切换算法时只需进行少量更改。这是一个关键特性,因为在开发模型时,你需要快速地在不同算法之间进行迭代,以选择最适合你问题的算法。话虽如此,这种抽象并不会使你对算法的配置失去控制。换句话说,你仍然完全掌握你的超参数和设置。

何时不使用 scikit-learn

很可能,不使用 scikit-learn 的原因将包括深度学习或规模的组合。scikit-learn 对神经网络的实现有限。与 scikit-learn 不同,TensorFlow 和 PyTorch 允许你使用自定义架构,并支持 GPU 以应对大规模训练。scikit-learn 的所有实现都在内存中运行,并且仅限于单台机器。我认为超过 90%的企业规模符合这些限制。数据科学家仍然能够在足够大的机器上将数据加载到内存中,这得益于云计算选项。他们可以巧妙地设计解决方法来应对扩展问题,但如果这些限制变得无法应对,他们将需要其他工具来解决问题。

目前正在开发一些解决方案,使 scikit-learn 能够扩展到多台机器,如 Dask。许多 scikit-learn 算法允许使用joblib进行并行执行,joblib本身提供了基于线程和进程的并行性。Dask 通过提供一个替代的joblib后端,可以将这些基于joblib的算法扩展到集群中。

安装所需的包

现在是安装我们在本书中需要的包的时候了,但首先,确保你的计算机上安装了 Python。在本书中,我们将使用 Python 3.6 版本。如果你的计算机安装的是 Python 2.x 版本,你应该将 Python 升级到 3.6 或更高版本。我将向你展示如何使用pip安装所需的包,pip是 Python 的事实上的包管理系统。如果你使用其他包管理系统,比如 Anaconda,你可以在线轻松找到每个包的等效安装命令。

要安装scikit-learn,请运行以下命令:

          $ pip install --upgrade scikit-learn==0.22

我将在这里使用0.22版本的scikit-learn。你可以在pip命令中添加--user开关,将安装限制在自己的目录中。如果你没有管理员权限,或者不想全局安装这些库,这一点非常重要。此外,我更倾向于为每个项目创建一个虚拟环境,并将该项目所需的所有库安装到该环境中。你可以查看 Anaconda 的文档或 Python 的venv模块,了解如何创建虚拟环境。

除了 scikit-learn,我们还需要安装pandas。我将在下一节中简要介绍pandas,但现在,你可以使用以下命令来安装它:

          $ pip install --upgrade pandas==0.25.3

可选地,你可能需要安装Jupyter。Jupyter 笔记本允许你在浏览器中编写代码,并按照你希望的顺序运行部分代码。这使得它非常适合实验和尝试不同的参数,而无需每次都重新运行整个代码。你还可以借助 Matplotlib 在笔记本中绘制图表。使用以下命令来安装 Jupyter 和 Matplotlib:

          $ pip install jupyter

          $ pip install matplotlib

要启动您的 Jupyter 服务器,可以在终端中运行jupyter notebook,然后在浏览器中访问http://localhost:8888/

我们将在本书后面使用其他库。我宁愿在需要时向您介绍它们,并向您展示如何安装每一个。

pandas 介绍

pandas是一个开源库,为 Python 编程语言提供数据分析工具。如果这个定义对您来说不是很清楚,那么您可以将pandas视为 Python 对电子表格的响应。我决定专门介绍pandas,因为您将使用它来创建和加载本书中要使用的数据。您还将使用pandas来分析和可视化数据,并在应用机器学习算法之前修改其列的值。

pandas中,表被称为 DataFrame。如果您是 R 程序员,那么这个名字对您来说应该很熟悉。现在,让我们从创建一些多边形名称和每个多边形边数的 DataFrame 开始:

# It's customary to call pandas pd when importing it
import pandas as pd

polygons_data_frame = pd.DataFrame(
    {
         'Name': ['Triangle', 'Quadrilateral', 'Pentagon', 'Hexagon'],
         'Sides': [3, 4, 5, 6],
     }
)

您可以使用head方法打印您新创建的 DataFrame 的前N行:

polygons_data_frame.head(3)

在这里,您可以看到 DataFrame 的前三行。除了我们指定的列之外,pandas还添加了一个默认索引:

由于我们在 Python 中编程,因此在创建 DataFrame 时,我们还可以使用语言的内置函数或甚至使用我们的自定义函数。在这里,我们将使用range生成器,而不是手动输入所有可能的边数:

polygons = {
    'Name': [
        'Triangle', 'Quadrilateral', 'Pentagon', 'Hexagon', 'Heptagon', 'Octagon', 'Nonagon', 'Decagon', 'Hendecagon', 'Dodecagon', 'Tridecagon', 'Tetradecagon'
     ],
     # Range parameters are the start, the end of the range and the step
     'Sides': range(3, 15, 1), 
}
polygons_data_frame = pd.DataFrame(polygons)

您还可以按列对 DataFrame 进行排序。在这里,我们将按字母顺序按多边形名称对其进行排序,然后打印前五个多边形:

polygons_data_frame.sort_values('Name').head(5)

这一次,我们可以看到 DataFrame 按多边形名称按字母顺序排序后的前五行:

特征工程是通过操作现有数据来派生新特征的艺术。这是pandas擅长的事情。在下面的例子中,我们正在创建一个新列Name 长度,并添加每个多边形名称的字符长度:

polygons_data_frame[
   'Length of Name'
] = polygons_data_frame['Name'].str.len()

我们使用str来访问字符串函数,以便将它们应用到Name列的值上。然后,我们使用字符串的len方法。实现相同结果的另一种方法是使用apply()函数。如果在列上调用apply(),您可以访问列中的值。然后,您可以在那里应用任何 Python 内置或自定义函数。以下是如何使用apply()函数的两个示例。

示例 1 如下所示:

polygons_data_frame[
   'Length of Name'
] = polygons_data_frame['Name'].apply(len)

示例 2 如下所示:

polygons_data_frame[
   'Length of Name'
] = polygons_data_frame['Name'].apply(lambda n: len(n))

apply() 方法的好处在于它允许你在任何地方运行自定义代码,这是在进行复杂特征工程时经常需要使用的功能。尽管如此,使用 apply() 方法运行的代码并不像第一个示例中的代码那样经过优化。这是灵活性与性能之间明显的权衡案例,你应该注意到这一点。

最后,我们可以使用 pandas 和 Matplotlib 提供的绘图功能来查看多边形边数与其名称长度之间是否存在任何相关性:

# We use the DataFrame's plot method here, 
# where we specify that this is a scatter plot
# and also specify which columns to use for x and y
polygons_data_frame.plot(
    title='Sides vs Length of Name',
    kind='scatter',
    x='Sides',
    y='Length of Name',
)

运行上述代码后,将显示以下散点图:

散点图通常用于查看两个特征之间的相关性。在以下图中,没有明显的相关性可见。

Python 的科学计算生态系统惯例

在本书中,我将使用 pandas、NumPy、SciPy、Matplotlib 和 Seaborn。每当你看到 npsppdsnsplt 前缀时,你应该假设我在代码之前运行了以下导入语句:

import numpy as np
import scipy as sp
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt

这是将科学计算生态系统导入 Python 的事实上方式。如果你的电脑上缺少其中任何库,以下是如何使用 pip 安装它们的方法:

          $ pip install --upgrade numpy==1.17.3

          $ pip install --upgrade scipy==1.3.1

          $ pip install --upgrade pandas==0.25.3

          $ pip install --upgrade scikit-learn==0.22

          $ pip install --upgrade matplotlib==3.1.2

          $ pip install --upgrade seaborn==0.9.0

通常情况下,你不需要为每个库指定版本;运行 pip install numpy 将只安装库的最新稳定版本。尽管如此,锁定版本对于可复现性是个好习惯。当在不同机器上运行相同代码时,它确保相同的结果。

本书中使用的代码是在 Jupyter 笔记本中编写的。我建议你在你的机器上也这样做。总体而言,在任何其他环境中,代码应该在打印和显示结果时以很少的更改顺利运行。如果在你的 Jupyter 笔记本中未显示图形,你可能需要在任何一个单元格的开头运行以下行至少一次:

          %matplotlib inline

此外,在许多机器学习任务中,随机性是非常常见的。我们可能需要创建随机数据来与我们的算法一起使用。我们还可能会随机将这些数据分割为训练集和测试集。算法本身可能会使用随机值进行初始化。有一些技巧可以通过使用伪随机数确保我们所有人都得到完全相同的结果。有时候需要使用这些技巧,但其他时候,确保我们得到稍有不同的结果会更好,以便让你了解事情并非总是确定性的,以及如何找到处理潜在不确定性的方法。稍后详述。

总结

掌握机器学习是一种现今广泛应用的理想技能,无论是在商业还是学术领域。然而,仅仅理解其理论只能带你走得那么远,因为从业者还需要理解他们的工具,以自给自足并有能力。

在这一章中,我们首先进行了机器学习的高层次介绍,并学习了何时使用每种类型的机器学习;从分类和回归到聚类和强化学习。然后,我们了解了 scikit-learn,以及为什么实践者在解决监督和无监督学习问题时推荐使用它。为了使本书自给自足,我们还涵盖了数据操作的基础知识,特别是为那些之前没有使用过 pandas 和 Matplotlib 的读者准备的。在接下来的章节中,我们将继续将对机器学习基本理论的理解与使用 scikit-learn 的更多实际示例相结合。

本书的前两部分将涉及监督学习算法。第一部分将涵盖基础算法以及一些机器学习的基础知识,如数据拆分和预处理。然后,我们将进入第二部分,讨论更高级的话题。第三部分也是最后一部分,将涵盖无监督学习以及如异常检测和推荐引擎等主题。

为了确保本书是一本实用的指南,我确保在每一章中都提供了示例。我也不想将数据准备与模型创建分开。虽然像数据拆分、特征选择、数据缩放和模型评估这样的主题是必须了解的关键概念,但我们通常将它们作为整体解决方案的一部分来处理。我还认为这些概念最好在正确的上下文中理解。这就是为什么在每一章中,我会覆盖一个主要的算法,并通过一些示例来阐明其他相关概念。

这意味着,你可以决定是从头到尾阅读本书,还是将其作为参考书,在需要时直接跳到你想了解的算法。然而,我建议你浏览所有章节,即使你已经了解其中涵盖的算法,或者目前不需要了解它们。

我希望你现在已经准备好进入下一章,我们将从决策树开始,学习如何使用它们解决不同的分类和回归问题。

进一步阅读

如需了解本章相关的更多信息,请参考以下链接:

第二章:使用树做决策

在这一章,我们将从查看我们的第一个监督学习算法——决策树开始。决策树算法多功能且易于理解。它被广泛使用,并且是我们在本书后续将遇到的许多高级算法的构建模块。在这一章中,我们将学习如何训练一个决策树,并将其应用于分类或回归问题。我们还将了解它的学习过程的细节,以便知道如何设置不同的超参数。此外,我们将使用一个现实世界的数据集,将我们在这里学到的内容付诸实践。我们将首先获取并准备数据,并将我们的算法应用于数据。在此过程中,我们还将尝试理解一些关键的机器学习概念,如交叉验证和模型评估指标。在本章结束时,你将对以下主题有非常好的理解:

  • 理解决策树

  • 决策树是如何学习的?

  • 获取更可靠的分数

  • 调整超参数以提高准确性

  • 可视化树的决策边界

  • 构建决策树回归器

理解决策树

我选择从决策树开始这本书,因为我注意到大多数新手机器学习从业者在两个领域中有之前的经验——软件开发或统计与数学。决策树在概念上可以类似于软件开发人员习惯的一些概念,例如嵌套的if-else条件和二叉搜索树。至于统计学家,忍耐一下——很快,当我们进入线性模型这一章时,你们会感到非常熟悉。

什么是决策树?

我认为解释决策树是什么的最佳方式是通过展示它们在训练后生成的规则。幸运的是,我们可以访问这些规则并将其打印出来。以下是决策树规则的一个例子:

Shall I take an umbrella with me?
|--- Chance of Rainy <= 0.6
|    |--- UV Index <= 7.0
|    |    |--- class: False
|    |--- UV Index >  7.0
|    |    |--- class: True
|--- Chance of Rainy >  0.6
|    |--- class: True

正如你所看到的,这基本上是一组条件。如果降雨的概率高于0.6(60%),那么我需要带伞。如果低于0.6,那么就取决于紫外线指数。如果紫外线指数高于7,那么需要带伞;否则,我没有伞也没关系。现在,你可能会想 好吧,几个嵌套的if-else条件就能解决这个问题。 没错,但这里的主要区别是我并没有自己编写这些条件。算法在处理以下数据后,自动学会了这些前提条件:

当然,对于这个简单的案例,任何人都可以手动查看数据并得出相同的条件。然而,在处理更大的数据集时,我们需要编程的条件数目会随着列数和每列中的值的增多而迅速增长。在这种规模下,手动完成相同的工作是不可行的,因此需要一个可以从数据中学习条件的算法。

另一方面,也可以将构建的树映射回嵌套的if-else条件。这意味着你可以使用 Python 从数据中构建一棵树,然后将底层的条件导出,以便在其他语言中实现,甚至可以将它们放入Microsoft Excel中。

Iris 分类

scikit-learn 内置了许多数据集,我们可以用来测试新的算法。其中一个数据集是 Iris 数据集。Iris 是一种有 260 到 300 个物种的开花植物属,具有显眼的花朵。然而,在我们的数据集中,只包含三种物种——SetosaVersicolorVirginica。数据集中的每个样本都有每株植物的萼片和花瓣的长度和宽度(特征),以及它是 Setosa、Versicolor 还是 Virginica(目标)。我们的任务是根据植物的萼片和花瓣尺寸来识别其物种。显然,这是一个分类问题。由于数据中提供了目标,因此这是一个监督学习问题。此外,这是一个分类问题,因为我们有有限的预定义值(三种物种)。

加载 Iris 数据集

现在让我们开始加载数据集:

  1. 我们从 scikit-learn 导入数据集模块,然后将 Iris 数据加载到一个变量中,我们也称之为iris
from sklearn import datasets
import pandas as pd
iris = datasets.load_iris()
  1. 使用dir,我们可以查看数据集提供了哪些方法和属性:
dir(iris)

我们得到了一些方法的列表,包括DESCRdatafeature_namesfilenametargettarget_names

数据创建者提供每个数据的描述是非常贴心的,我们可以通过DESCR访问它们。然而,真实世界中的数据往往没有这么周到。通常,我们需要与数据的生产者进行沟通,才能理解每个值的含义,或者至少通过一些描述性统计来理解数据,然后再使用它。

*3. 现在,让我们打印出 Iris 数据集的描述:

print(iris.DESCR)

现在查看描述并尝试思考一下从中得到的一些主要结论。我稍后会列出我的结论:

.. _iris_dataset:
 Iris plants dataset
 --------------------
Data Set Characteristics:
 :Number of Instances: 150 (50 in each of three classes)
  :Number of Attributes: 4 numeric, predictive attributes and the class
   :Attribute Information:        
- sepal length in cm
- sepal width in cm
- petal length in cm
- petal width in cm
- class:
    - Iris-Setosa
    - Iris-Versicolor
    - Iris-Virginica
:Summary Statistics:
    ============== ==== ==== ======= ===== ====================
                Min  Max   Mean    SD     Class   Correlation
    ============== ==== ==== ======= ===== ====================
sepal length:   4.3  7.9   5.84   0.83    0.7826
sepal width:    2.0  4.4   3.05   0.43   -0.4194
petal length:   1.0  6.9   3.76   1.76    0.9490  (high!)
petal length:   1.0  6.9   3.76   1.76    0.9490  (high!)
petal width:    0.1  2.5   1.20   0.76    0.9565  (high!)
    ============== ==== ==== ======= ===== ====================
:Missing Attribute Values: None
:Class Distribution: 33.3% for each of 3 classes.

:Creator: R.A. Fisher

这个描述包含了一些有用的信息,我认为以下几点最为有趣:

  • 数据集由 150 行(或 150 个样本)组成。这是一个相当小的数据集。稍后,我们将看到如何在评估模型时处理这个事实。

  • 类别标签或目标有三个值 —— Iris-SetosaIris-VersicolorIris-Virginica。一些分类算法只能处理两个类别标签;我们称它们为二元分类器。幸运的是,决策树算法可以处理多于两个类别,所以这次我们没有问题。

  • 数据是平衡的;每个类别有 50 个样本。这是我们在训练和评估模型时需要牢记的一点。

  • 我们有四个特征 —— sepal lengthsepal widthpetal lengthpetal width —— 所有四个特征都是数值型的。在 第三章数据准备,我们将学习如何处理非数值型数据。

  • 没有缺失的属性值。换句话说,我们的样本中没有空值。在本书的后续部分,如果遇到缺失值,我们将学习如何处理它们。

  • 花瓣尺寸与类别值的相关性比萼片尺寸更高。我希望我们从未看到这条信息。了解数据是有用的,但问题在于这种相关性是针对整个数据集计算的。理想情况下,我们只会为我们的训练数据计算它。无论如何,现在让我们暂时忽略这些信息,稍后再用它进行健全性检查。

  1. 现在是时候将所有数据集信息放入一个 DataFrame 中了。

feature_names 方法返回我们特征的名称,而 data 方法以 NumPy 数组的形式返回它们的值。同样,target 变量以零、一和二的形式返回目标的值,而 target_names 则将 012 映射到 Iris-SetosaIris-VersicolorIris-Virginica

NumPy 数组在处理上是高效的,但它们不允许列具有名称。我发现列名在调试过程中非常有用。在这里,我认为 pandas 的 DataFrame 更加合适,因为我们可以使用列名将特征和目标组合到一个 DataFrame 中。

在这里,我们可以看到使用 iris.data[:8] 得到的前八行数据:

array([[5.1, 3.5, 1.4, 0.2], [4.9, 3\. , 1.4, 0.2], [4.7, 3.2, 1.3, 0.2], [4.6, 3.1, 1.5, 0.2], [5\. , 3.6, 1.4, 0.2], [5.4, 3.9, 1.7, 0.4], [4.6, 3.4, 1.4, 0.3], [5\. , 3.4, 1.5, 0.2]])

以下代码使用 datafeature_namestarget 方法将所有数据集信息合并到一个 DataFrame 中,并相应地分配其列名:

df = pd.DataFrame(
    iris.data,
    columns=iris.feature_names
)

df['target'] = pd.Series(
 iris.target
)

scikit-learn 的版本 0.23 及更高版本支持将数据集直接加载为 pandas 的 DataFrame。您可以在 datasets.load_iris 及其类似的数据加载方法中设置 as_frame=True 来实现这一点。然而,在写作时,这本书尚未测试过此功能,因为版本 0.22 是最稳定的版本。

  1. target 列现在包含类别 ID。然而,为了更清晰起见,我们还可以创建一个名为 target_names 的新列,将我们的数值目标值映射到类别名称:
df['target_names'] = df['target'].apply(lambda y: iris.target_names[y])
  1. 最后,让我们打印六行样本来看看我们新创建的 DataFrame 是什么样子的。在 Jupyter notebook 或 Jupyter lab 中运行以下代码将直接打印 DataFrame 的内容;否则,你需要用print语句将代码包裹起来。我假设在所有后续的代码示例中都使用 Jupyter notebook 环境:
# print(df.sample(n=6))
df.sample(n=6)

这给我带来了以下随机样本:

样本方法随机选择了六行来展示。这意味着每次运行相同的代码时,你将得到一组不同的行。有时,我们需要每次运行相同的代码时得到相同的随机结果。那么,我们就使用一个具有预设种子的伪随机数生成器。一个用相同种子初始化的伪随机数生成器每次运行时都会产生相同的结果。

所以,将random_state参数设置为42,如下所示:

df.sample(n=6, random_state=42) 

你将得到与之前展示的完全相同的行。

数据分割

让我们将刚刚创建的 DataFrame 分成两部分——70%的记录(即 105 条记录)应进入训练集,而 30%(45 条记录)应进入测试集。选择 70/30 的比例目前是任意的。我们将使用 scikit-learn 提供的train_test_split()函数,并指定test_size0.3

from sklearn.model_selection import train_test_split
df_train, df_test = train_test_split(df, test_size=0.3)

我们可以使用df_train.shape[0]df_test.shape[0]来检查新创建的 DataFrame 中有多少行。我们还可以使用df_train.columnsdf_test.columns列出新 DataFrame 的列名。它们都有相同的六列:

  • sepal length (cm)

  • sepal width (cm)

  • petal length (cm)

  • petal width (cm)

  • target

  • target_names

前四列是我们的特征,而第五列是我们的目标(或标签)。第六列目前不需要。直观地说,你可以说我们将数据在垂直方向上分成了训练集和测试集。通常,将我们的 DataFrame 在水平方向上进一步分成两部分是有意义的——一部分是特征,通常我们称之为x,另一部分是目标,通常称之为y。在本书的剩余部分,我们将继续使用这种xy的命名约定。

有些人喜欢用大写的X来表示二维数组(或 DataFrame),而用小写字母y表示一维数组(或系列)。我发现坚持使用单一大小写更为实用。

如你所知,iris中的feature_names方法包含与我们的特征相对应的列名列表。我们将使用这些信息,以及target标签,来创建我们的xy集合,如下所示:

x_train = df_train[iris.feature_names]
x_test = df_test[iris.feature_names]

y_train = df_train['target']
y_test = df_test['target']

训练模型并用于预测

为了更好地理解一切是如何运作的,我们现在将使用算法的默认配置进行训练。稍后在本章中,我将解释决策树算法的详细信息及如何配置它们。

我们首先需要导入DecisionTreeClassifier,然后创建它的实例,代码如下:

from sklearn.tree import DecisionTreeClassifier

# It is common to call the classifier instance clf
clf = DecisionTreeClassifier()

训练的一个常用同义词是拟合。它是指算法如何利用训练数据(xy)来学习其参数。所有的 scikit-learn 模型都实现了一个fit()方法,它接收x_trainy_trainDecisionTreeClassifier也不例外:

clf.fit(x_train, y_train)

通过调用fit()方法,clf实例被训练并准备好用于预测。接着我们在x_test上调用predict()方法:

# If y_test is our truth, then let's call our predictions y_test_pred
y_test_pred = clf.predict(x_test)

在预测时,我们通常不知道特征(x)的实际目标值(y)。这就是为什么我们在这里只提供predict()方法,并且传入x_test。在这个特定的情况下,我们恰好知道y_test;然而,为了演示,我们暂时假装不知道它,稍后再用它进行评估。由于我们的实际目标是y_test,我们将预测结果称为y_test_pred,并稍后进行比较。

评估我们的预测

由于我们有了y_test_predict,现在我们只需要将它与y_test进行比较,以检查我们的预测效果如何。如果你记得上一章,评估分类器有多种指标,比如precisionrecallaccuracy。鸢尾花数据集是一个平衡数据集,每个类别的实例数相同。因此,在这里使用准确率作为评估指标是合适的。

计算准确率,结果如下,得分为0.91

from sklearn.metrics import accuracy_score
accuracy_score(y_test, y_test_pred)

你的得分与我的不同吗?别担心。在获取更可靠的得分部分,我将解释为什么这里计算的准确率分数可能会有所不同。

恭喜你!你刚刚训练了你的第一个监督学习算法。从现在开始,本书中所有我们将使用的算法都有类似的接口:

  • fit()方法接收你的训练数据的xy部分。

  • predict()方法只接收x并返回预测的y

哪些特征更重要?

现在我们可以问自己,模型在决定鸢尾花种类时,认为哪些特征更有用? 幸运的是,DecisionTreeClassifier有一个名为feature_importances_的方法,它会在分类器拟合后计算,并评估每个特征对模型决策的重要性。在以下代码片段中,我们将创建一个 DataFrame,将特征名称和它们的重要性放在一起,然后按重要性对特征进行排序:

pd.DataFrame(
  {
    'feature_names': iris.feature_names,
    'feature_importances': clf.feature_importances_
  }
).sort_values(
  'feature_importances', ascending=False
).set_index('feature_names')

这是我们得到的输出:

正如你会记得的,当我们打印数据集描述时,花瓣的长度和宽度值开始与目标变量高度相关。它们在这里也有很高的特征重要性分数,这验证了描述中的说法。

显示内部树的决策

我们还可以使用以下代码片段打印学习到的树的内部结构:

from sklearn.tree import export_text
print(
  export_text(clf, feature_names=iris.feature_names, spacing=3, decimals=1)
) 

这将打印以下文本:

|--- petal width (cm) <= 0.8
| |--- class: 0
|--- petal width (cm) > 0.8
| |--- petal width (cm) <= 1.8
| | |--- petal length (cm) <= 5.3
| | | |--- sepal length (cm) <= 5.0
| | | | |--- class: 2
| | | |--- sepal length (cm) > 5.0
| | | | |--- class: 1
| | |--- petal length (cm) > 5.3
| | | |--- class: 2
| |--- petal width (cm) > 1.8
| | |--- class: 2

如果你打印出完整的数据集描述,你会注意到在最后,它写着以下内容:

一个类别可以与其他两个类别线性分开;后者不能彼此线性分开。

这意味着一个类别比其他两个类别更容易被分开,而其他两个类别则更难相互分开。现在,看看内部树的结构。你可能会注意到,在第一步中,它决定将花瓣宽度小于或等于0.8的样本归类为类别0Setosa)。然后,对于花瓣宽度大于0.8的样本,树继续分支,试图区分类别12VersicolorVirginica)。一般来说,类别之间分离越困难,分支就越深。

决策树是如何学习的?

是时候了解决策树是如何学习的,以便配置它们。在我们刚刚打印的内部结构中,树决定使用0.8的花瓣宽度作为其初始分割决策。这是因为决策树试图使用以下技术构建尽可能小的树。

它遍历所有特征,试图找到一个特征(此处为花瓣宽度)和该特征中的一个值(此处为0.8),这样如果我们将所有训练数据分成两部分(一个部分是花瓣宽度 ≤ 0.8,另一个部分是花瓣宽度 > 0.8),我们就能得到最纯净的分割。换句话说,它试图找到一个条件,在这个条件下,我们可以尽可能地将类别分开。然后,对于每一边,它迭代地使用相同的技术进一步分割数据。

分割标准

如果我们只有两个类别,理想的分割应该将一个类别的成员放在一侧,另一个类别的成员放在另一侧。在我们的例子中,我们成功地将类别0的成员放在一侧,将类别12的成员放在另一侧。显然,我们并不总是能得到如此纯净的分割。正如我们在树的其他分支中看到的那样,每一侧总是混合了类别12的样本。

话虽如此,我们需要一种衡量纯度的方法。我们需要一个标准来判断哪个分割比另一个更纯净。scikit-learn为分类器纯度提供了两个标准——ginientropy——其中gini是默认选项。对于决策树回归,还有其他标准,我们稍后会接触到。

防止过拟合

“如果你追求完美,你将永远不会满足。”

– 列夫·托尔斯泰

在第一次分裂后,树继续尝试区分剩下的类别;即VersicolorVirginica鸢尾花。然而,我们真的确定我们的训练数据足够详细,能够解释区分这两类的所有细微差别吗?难道所有这些分支不是在引导算法学习一些仅存在于训练数据中的特征,而当面对未来数据时,它们并不会很好地泛化吗?让树生长过多会导致所谓的过拟合。树会尽力完美拟合训练数据,却忽视了未来可能遇到的数据可能会有所不同。为了防止过拟合,可以使用以下设置来限制树的生长:

  • max_depth:这是树可以达到的最大深度。较小的数字意味着树会更早停止分枝。将其设置为None意味着树会继续生长,直到所有叶节点都纯净,或直到所有叶节点包含的样本数少于min_samples_split

  • min_samples_split:在一个层级中,允许进一步分裂所需的最小样本数。更高的数字意味着树会更早停止分枝。

  • min_samples_leaf允许成为叶节点的层级中所需的最小样本数。叶节点是没有进一步分裂的节点,是做出决策的地方。更高的数字可能会对模型产生平滑效果,尤其是在回归模型中。

检查过拟合的一个快速方法是比较分类器在测试集上的准确度与在训练集上的准确度。如果训练集的得分明显高于测试集的得分,那就是过拟合的迹象。在这种情况下,推荐使用一个较小且修剪过的树。

如果在训练时没有设置max_depth来限制树的生长,那么在树构建后,你也可以修剪这棵树。有兴趣的读者可以查看决策树的cost_complexity_pruning_path()方法,了解如何使用它来修剪已经生长的树。

预测

在训练过程结束时,那些不再分裂的节点被称为叶节点。在叶节点内,我们可能有五个样本——其中四个来自类别1,一个来自类别2,没有来自类别0。然后,在预测时,如果一个样本最终落入相同的叶节点,我们可以轻松判断该新样本属于类别1,因为这个叶节点中的训练样本中有 4:1 的比例来自类别1,而其他两个类别的样本较少。

当我们在测试集上进行预测时,我们可以评估分类器的准确度与我们在测试集中的实际标签之间的差异。然而,我们划分数据的方式可能会影响我们得到的分数的可靠性。在接下来的部分中,我们将看到如何获得更可靠的分数。

获取更可靠的分数

鸢尾花数据集是一个只有 150 个样本的小型数据集。当我们将其随机拆分为训练集和测试集时,测试集中最终有 45 个实例。由于样本量如此之小,我们可能会在目标的分布上看到一些变化。例如,当我随机拆分数据时,我在测试集中得到了 13 个类0的样本,以及从另外两个类中各得到 16 个样本。考虑到在这个特定数据集中,预测类0比其他两个类更容易,我们可以推测,如果我运气好一些,在测试集中有更多类0的样本,我的得分就会更高。此外,决策树对数据变化非常敏感,每次轻微变化训练数据时,你可能得到一棵完全不同的树。

现在该做什么以获得更可靠的评分

统计学家会说:让我们多次运行整个数据拆分、训练和预测的过程,并得到每次获得的不同准确度分数的分布。以下代码正是实现了这一点,迭代了 100 次:

import pandas as pd

from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import accuracy_score

# A list to store the score from each iteration
accuracy_scores = []

在导入所需模块并定义一个accuracy_scores列表来存储每次迭代的得分后,就该编写一个for循环来重新拆分数据,并在每次迭代时重新计算分类器的准确度:

for _ in range(100):

    # At each iteration we freshly split our data
    df_train, df_test = train_test_split(df, test_size=0.3) 
    x_train = df_train[iris.feature_names]
    x_test = df_test[iris.feature_names]

    y_train = df_train['target']
    y_test = df_test['target']

    # We then create a new classifier
    clf = DecisionTreeClassifier()

    # And use it for training and prediction
    clf.fit(x_train, y_train)
    y_pred = clf.predict(x_test)

    # Finally, we append the score to our list
    accuracy_scores.append(round(accuracy_score(y_test, y_pred), 3))

# Better convert accuracy_scores from a list into a series
# Pandas series provides statistical methods to use later
accuracy_scores = pd.Series(accuracy_scores)

以下代码片段让我们通过箱型图绘制准确度的分布:

accuracy_scores.plot(
    title='Distribution of classifier accuracy',
    kind='box',
)

print(
    'Average Score: {:.3} [5th percentile: {:.3} & 95th percentile: {:.3}]'.format(
        accuracy_scores.mean(),
        accuracy_scores.quantile(.05),
        accuracy_scores.quantile(.95),
    )
)

这将为我们提供以下准确度的图形分析。由于训练集和测试集的随机拆分以及决策树的随机初始设置,你的结果可能会略有不同。几乎所有的 scikit-learn 模块都支持一个伪随机数生成器,可以通过random_state超参数进行初始化。这可以用来确保代码的可重复性。然而,我这次故意忽略了它,以展示模型结果如何因运行而异,并强调通过迭代估计模型误差分布的重要性:

箱型图在展示分布方面非常有效。与其只有一个数字,我们现在得到了对分类器性能的最佳和最差情况的估计。

如果在任何时候无法访问 NumPy,你仍然可以使用 Python 内置的statistics模块提供的mean()stdev()方法计算样本的均值和标准差。该模块还提供了计算几何平均数、调和平均数、中位数和分位数的功能。

ShuffleSplit

生成不同的训练和测试拆分被称为交叉验证。这帮助我们更可靠地估计模型的准确性。我们在上一节中所做的就是一种叫做重复随机子抽样验证(Monte Carlo 交叉验证)的交叉验证策略。

在概率论中,大数法则指出,如果我们多次重复相同的实验,得到的结果的平均值应该接近预期结果。蒙特卡罗方法利用随机采样来不断重复实验,从而根据大数法则获得更好的结果估计。蒙特卡罗方法的实现得益于计算机的存在,在这里我们使用相同的方法来重复训练/测试数据拆分,以便获得更好的模型准确性估计。

scikit-learn 的 ShuffleSplit 模块提供了执行蒙特卡罗交叉验证的功能。我们无需自己拆分数据,ShuffleSplit 会为我们提供用于拆分数据的索引列表。在接下来的代码中,我们将使用 DataFrame 的 loc() 方法和 ShuffleSplit 提供的索引来随机拆分数据集,生成 100 对训练集和测试集:

import pandas as pd

from sklearn.model_selection import ShuffleSplit
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import accuracy_score

accuracy_scores = []

# Create a shuffle split instance
rs = ShuffleSplit(n_splits=100, test_size=0.3)

# We now get 100 pairs of indices 
for train_index, test_index in rs.split(df):

 x_train = df.loc[train_index, iris.feature_names]
 x_test = df.loc[test_index, iris.feature_names]

 y_train = df.loc[train_index, 'target']
 y_test = df.loc[test_index, 'target']

 clf = DecisionTreeClassifier()

 clf.fit(x_train, y_train)
 y_pred = clf.predict(x_test)

 accuracy_scores.append(round(accuracy_score(y_test, y_pred), 3))

accuracy_scores = pd.Series(accuracy_scores)

或者,我们可以通过使用 scikit-learn 的cross_validate功能进一步简化之前的代码。这一次,我们甚至不需要自己将数据拆分为训练集和测试集。我们将 xy 的值传递给 cross_validate,并将 ShuffleSplit 实例传递给它以供内部使用,进行数据拆分。我们还将传递分类器并指定要使用的评分标准。完成后,它将返回一个包含计算出的测试集分数的列表:

**```py
import pandas as pd

from sklearn.model_selection import ShuffleSplit
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import cross_validate

clf = DecisionTreeClassifier()
rs = ShuffleSplit(n_splits=100, test_size=0.3)

x = df[iris.feature_names]
y = df['target']

cv_results = cross_validate(
clf, x, y, cv=rs, scoring='accuracy'
)

accuracy_scores = pd.Series(cv_results['test_score'])


我们现在可以绘制结果的准确性分数序列,得到与之前相同的箱型图。当处理小数据集时,推荐使用交叉验证,因为一组准确性分数能比单次实验后计算出的单个分数更好地帮助我们理解分类器的性能。

# 调整超参数以提高准确性

现在我们已经学会了如何使用 `ShuffleSplit` 交叉验证方法更可靠地评估模型的准确性,接下来是检验我们之前的假设:更小的树是否更准确?

以下是我们将在接下来的子章节中进行的操作:

1.  将数据拆分为训练集和测试集。

1.  现在将测试集放在一边。

1.  使用不同的 `max_depth` 值限制决策树的生长。

1.  对于每个 `max_depth` 设置,我们将使用 `ShuffleSplit` 交叉验证方法在训练集上获取分类器的准确性估计。

1.  一旦我们决定了要使用的 `max_depth` 值,我们将最后一次在整个训练集上训练算法,并在测试集上进行预测。

## 拆分数据

这里是将数据拆分为训练集和测试集的常用代码:

```py
from sklearn.model_selection import train_test_split

df_train, df_test = train_test_split(df, test_size=0.25)

x_train = df_train[iris.feature_names]
x_test = df_test[iris.feature_names]

y_train = df_train['target']
y_test = df_test['target']

尝试不同的超参数值

如果我们允许之前的树无限生长,我们会得到一个深度为4的树。你可以通过调用clf.get_depth()来检查树的深度,一旦它被训练好。所以,尝试任何大于4max_depth值是没有意义的。在这里,我们将循环遍历从14的最大深度,并使用ShuffleSplit来获取分类器的准确度:

import pandas as pd
from sklearn.model_selection import ShuffleSplit
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import cross_validate

for max_depth in [1, 2, 3, 4]:

    # We initialize a new classifier each iteration with different max_depth
    clf = DecisionTreeClassifier(max_depth=max_depth)
    # We also initialize our shuffle splitter
    rs = ShuffleSplit(n_splits=20, test_size=0.25)

    cv_results = cross_validate(
        clf, x_train, y_train, cv=rs, scoring='accuracy'
    )
    accuracy_scores = pd.Series(cv_results['test_score'])

print(
        '@ max_depth = {}: accuracy_scores: {}~{}'.format(
            max_depth, 
            accuracy_scores.quantile(.1).round(3), 
            accuracy_scores.quantile(.9).round(3)
        )
    )

我们像之前一样调用了cross_validate()方法,传入了分类器的实例和ShuffleSplit实例。我们还将评估分数定义为accuracy。最后,我们打印出每次迭代得到的得分。在下一节中,我们将更详细地查看打印的值。

比较准确度得分

由于我们有每次迭代的得分列表,我们可以计算它们的平均值,或者像我们这里做的那样,打印它们的第 10 百分位和第 90 百分位,以了解每个max_depth设置下的准确度范围。

运行前面的代码给出了以下结果:

@ max_depth = 1: accuracy_scores: 0.532~0.646
@ max_depth = 2: accuracy_scores: 0.925~1.0
@ max_depth = 3: accuracy_scores: 0.929~1.0
@ max_depth = 4: accuracy_scores: 0.929~1.0

我现在确定的一点是,单层树(通常称为 stub)的准确度不如深层树。换句话说,仅根据花瓣宽度是否小于0.8来做出决策是不够的。允许树进一步生长会提高准确度,但我看不出深度为234的树之间有太大的差异。我得出结论,与我之前的猜测相反,在这里我们不必过于担心过拟合问题。

在这里,我们尝试了不同的单一参数值,max_depth。因此,简单地对其不同值使用for循环是可行的。在后续章节中,我们将学习当需要同时调整多个超参数以找到最佳准确度组合时该如何处理。

最后,你可以再次使用整个训练集和一个max_depth值,例如3来训练你的模型。然后,使用训练好的模型预测测试集的类别,以评估最终模型。这次我不会再赘述代码部分,因为你完全可以自己轻松完成。

除了打印分类器的决策和其准确度的描述性统计数据外,查看分类器的决策边界也是非常有用的。将这些边界与数据样本进行映射有助于我们理解为什么分类器会做出某些错误决策。在下一节中,我们将检查我们在鸢尾花数据集上得到的决策边界。

可视化树的决策边界

为了能够为问题选择正确的算法,理解算法如何做出决策是非常重要的。正如我们现在已经知道的,决策树一次选择一个特征,并试图根据这个特征来划分数据。然而,能够可视化这些决策也同样重要。让我先绘制我们的类别与特征的关系图,然后再进一步解释:

当树决定以0.8的花瓣宽度将数据分割时,可以将其视为在右侧图表上画一条水平线,值为0.8。然后,每一次后续的分割,树将继续使用水平和垂直线的组合进一步划分空间。了解这一点后,你就不应该期待算法使用曲线或 45 度的线来分隔类别。

绘制树训练后决策边界的一个技巧是使用等高线图。为了简化,假设我们只有两个特征——花瓣长度和花瓣宽度。我们接着生成这两个特征的几乎所有可能值,并预测新假设数据的类别标签。然后,我们使用这些预测创建等高线图,以查看类别之间的边界。以下函数,由哥德堡大学的理查德·约翰松(Richard Johansson)创建,正是完成这个工作的:

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

def plot_decision_boundary(clf, x, y):

 feature_names = x.columns
 x, y = x.values, y.values

 x_min, x_max = x[:,0].min(), x[:,0].max()
 y_min, y_max = x[:,1].min(), x[:,1].max()

 step = 0.02

 xx, yy = np.meshgrid(
 np.arange(x_min, x_max, step),
 np.arange(y_min, y_max, step)
 )
 Z = clf.predict(np.c_[xx.ravel(), yy.ravel()])
 Z = Z.reshape(xx.shape)

 plt.figure(figsize=(12,8))
 plt.contourf(xx, yy, Z, cmap='Paired_r', alpha=0.25)
 plt.contour(xx, yy, Z, colors='k', linewidths=0.7)
 plt.scatter(x[:,0], x[:,1], c=y, edgecolors='k')
 plt.title("Tree's Decision Boundaries")
 plt.xlabel(feature_names[0])
 plt.ylabel(feature_names[1])

这次,我们将仅使用两个特征训练分类器,然后使用新训练的模型调用前面的函数:

x = df[['petal width (cm)', 'petal length (cm)']]
y = df['target']

clf = DecisionTreeClassifier(max_depth=3)
clf.fit(x, y)

plot_decision_boundary(clf, x, y)

理查德·约翰松的函数将等高线图叠加到我们的样本上,从而生成以下图表:

通过查看决策边界以及数据样本,你可以更好地判断一个算法是否适合当前的问题。

特征工程

“每个人将自己视野的边界当作世界的边界。”

– 阿图尔·叔本华

通过查看花瓣长度和宽度与类别分布之间的关系,你可能会想:如果决策树也能绘制 40 度的边界呢?40 度的边界是不是比那些水平和垂直的拼图更合适呢? 不幸的是,决策树做不到这一点,但我们暂时放下算法,转而思考数据本身。怎么样,如果我们创建一个新的轴,让类别边界改变它们的方向呢?

让我们创建两个新列——花瓣长度 x 宽度 (cm)萼片长度 x 宽度 (cm)——看看类别分布会是什么样子:

df['petal length x width (cm)'] = df['petal length (cm)'] * df['petal width (cm)']
df['sepal length x width (cm)'] = df['sepal length (cm)'] * df['sepal width (cm)']

以下代码将绘制类别与新生成特征之间的关系:

fig, ax = plt.subplots(1, 1, figsize=(12, 6));

h_label = 'petal length x width (cm)'
v_label = 'sepal length x width (cm)'

for c in df['target'].value_counts().index.tolist():
    df[df['target'] == c].plot(
        title='Class distribution vs the newly derived features',
        kind='scatter',
x=h_label,
y=v_label,
color=['r', 'g', 'b'][c], # Each class different color
marker=f'${c}$', # Use class id as marker
s=64,
        alpha=0.5,
        ax=ax,
    )

fig.show()

运行这段代码将生成以下图表:

这个新的投影看起来更好,它使数据在垂直方向上更加可分离。不过,结果还是要看实际效果。所以,我们训练两个分类器——一个使用原始特征,另一个使用新生成的特征——来看看结果如何。

他们的准确率如何比较。以下代码将执行 500 次迭代,每次随机分割数据,然后训练两个模型,每个模型使用不同的特征集,并存储每次迭代得到的准确率:

features_orig = iris.feature_names
features_new = ['petal length x width (cm)', 'sepal length x width (cm)']

accuracy_scores_orig = []
accuracy_scores_new = []

for _ in range(500):

    df_train, df_test = train_test_split(df, test_size=0.3)

x_train_orig = df_train[features_orig]
x_test_orig = df_test[features_orig]

x_train_new = df_train[features_new]
x_test_new = df_test[features_new]

     y_train = df_train['target']
y_test = df_test['target']

clf_orig = DecisionTreeClassifier(max_depth=2)
clf_new = DecisionTreeClassifier(max_depth=2)

     clf_orig.fit(x_train_orig, y_train)
clf_new.fit(x_train_new, y_train)

y_pred_orig = clf_orig.predict(x_test_orig)
y_pred_new = clf_new.predict(x_test_new)

accuracy_scores_orig.append(round(accuracy_score(y_test, y_pred_orig), 
                                       3))
accuracy_scores_new.append(round(accuracy_score(y_test, y_pred_new), 
                                      3))

accuracy_scores_orig = pd.Series(accuracy_scores_orig)
accuracy_scores_new = pd.Series(accuracy_scores_new)

然后,我们可以使用箱线图来比较两个分类器的准确率:

fig, axs = plt.subplots(1, 2, figsize=(16, 6), sharey=True);

accuracy_scores_orig.plot(
    title='Distribution of classifier accuracy [Original Features]',
    kind='box',
grid=True,
ax=axs[0]
)

accuracy_scores_new.plot(
title='Distribution of classifier accuracy [New Features]',
kind='box',
grid=True,
ax=axs[1]
)

fig.show()

在这里,我们将顶部的图表并排放置,以便相互比较:

显然,所得到的特征有所帮助。它的准确度平均更高(0.96对比0.93),并且它的下限也更高。

构建决策树回归器

决策树回归器的工作方式与其分类器版本类似。该算法递归地使用一个特征进行数据分割。最终,我们会得到叶节点——即没有进一步分裂的节点。对于分类器来说,如果在训练时,一个叶节点有三个属于类别A的实例和一个属于类别B的实例,那么在预测时,如果一个实例落入该叶节点,分类器会判定它属于多数类别(类别A)。对于回归器来说,如果在训练时,一个叶节点有三个值12108,那么在预测时,如果一个实例落入该叶节点,回归器会预测它的值为10(即训练时三个值的平均值)。

事实上,选择平均值并不总是最佳的情况。它实际上取决于所使用的分裂标准。在下一节中,我们将通过一个例子来更详细地了解这一点。

预测人们的身高

假设我们有两个群体。群体1中,女性的平均身高为 155 厘米,标准差为4,男性的平均身高为 175 厘米,标准差为5。群体 2 中,女性的平均身高为 165 厘米,标准差为15,男性的平均身高为 185 厘米,标准差为12。我们决定从每个群体中各取 200 名男性和 200 名女性。为了模拟这一点,我们可以使用 NumPy 提供的一个函数,从正态(高斯)分布中抽取随机样本。

这里是生成随机样本的代码:

# It's customary to call numpy np
import numpy as np

# We need 200 samples from each
n = 200

# From each population we get 200 male and 200 female samples
height_pop1_f = np.random.normal(loc=155, scale=4, size=n)
height_pop1_m = np.random.normal(loc=175, scale=5, size=n)
height_pop2_f = np.random.normal(loc=165, scale=15, size=n)
height_pop2_m = np.random.normal(loc=185, scale=12, size=n)

此时,我们实际上并不关心每个样本来自哪个群体。因此,我们将使用concatenate将所有男性和所有女性合并在一起:

# We group all females together and all males together
height_f = np.concatenate([height_pop1_f, height_pop2_f])
height_m = np.concatenate([height_pop1_m, height_pop2_m])

然后,我们将这些数据放入一个 DataFrame(df_height)中,以便更容易处理。在这里,我们还将女性标记为1,男性标记为2

df_height = pd.DataFrame(
    {
        'Gender': [1 for i in range(height_f.size)] + 
                   [2 for i in range(height_m.size)],
        'Height': np.concatenate((height_f, height_m))
    }
)

让我们用直方图绘制我们虚构的数据,以查看每个性别的身高分布:

fig, ax = plt.subplots(1, 1, figsize=(10, 5))

df_height[df_height['Gender'] == 1]['Height'].plot(
    label='Female', kind='hist', 
    bins=10, alpha=0.7, ax=ax
)
df_height[df_height['Gender'] == 2]['Height'].plot(
    label='Male', kind='hist', 
    bins=10, alpha=0.7, ax=ax
)

ax.legend()

fig.show()

上面的代码给我们生成了以下图表:

如你所见,得到的分布并不对称。尽管正态分布是对称的,但这些人工分布是由两个子分布组合而成。我们可以使用这行代码查看它们的均值和中位数不相等:

df_height.groupby('Gender')[['Height']].agg([np.mean, np.median]).round(1)

这里是每个群体的均值和中位数身高:

现在,我们想要通过一个特征——性别来预测人们的身高。因此,我们将数据分为训练集和测试集,并创建我们的xy集,具体如下:

df_train, df_test = train_test_split(df_height, test_size=0.3)
x_train, x_test = df_train[['Gender']], df_test[['Gender']]
y_train, y_test = df_train['Height'], df_test['Height']

请记住,在分类问题中,决策树使用ginientropy来决定训练过程中每一步的最佳划分。这些标准的目标是找到一个划分,使得结果的两个子组尽可能纯净。在回归问题中,我们的目标不同。我们希望每个组的成员目标值尽可能接近它们所做出的预测值。scikit-learn 实现了两种标准来达到这个目标:

  • 均方误差 (MSE 或 L2):假设划分后,我们得到三组样本,其目标值为558。我们计算这三个数字的均值(6)。然后,我们计算每个样本与计算得到的均值之间的平方差——114。接着,我们计算这些平方差的均值,即2

  • 平均绝对误差 (MAE 或 L1):假设划分后,我们得到三组样本,其目标值为558。我们计算这三个数字的中位数(5)。然后,我们计算每个样本与计算得到的中位数之间的绝对差值——003。接着,我们计算这些绝对差值的均值,即1

在训练时,对于每一个可能的划分,决策树会计算每个预期子组的 L1 或 L2 值。然后,在这一阶段,选择具有最小 L1 或 L2 值的划分。由于 L1 对异常值具有鲁棒性,因此有时会优先选择 L1。需要注意的另一个重要区别是,L1 在计算时使用中位数,而 L2 则使用均值。

如果在训练时,我们看到 10 个样本具有几乎相同的特征,但目标值不同,它们可能最终会被分到同一个叶节点中。现在,如果我们在构建回归模型时使用 L1 作为划分标准,那么如果我们在预测时得到一个特征与这 10 个训练样本相同的样本,我们应该期望该预测值接近这 10 个训练样本目标值的中位数。同样,如果使用 L2 来构建回归模型,我们应该期望新样本的预测值接近这 10 个训练样本目标值的均值。

现在让我们比较划分标准对身高数据集的影响:

from sklearn.tree import export_text
from sklearn.tree import DecisionTreeRegressor

for criterion in ['mse', 'mae']:
    rgrsr = DecisionTreeRegressor(criterion=criterion)
    rgrsr.fit(x_train, y_train)

    print(f'criterion={criterion}:\n')
    print(export_text(rgrsr, feature_names=['Gender'], spacing=3, decimals=1))

根据选择的标准,我们得到以下两棵树:

criterion=mse:

|--- Gender <= 1.5
|    |--- value: [160.2]
|--- Gender > 1.5
|    |--- value: [180.8]

criterion=mae:

|--- Gender <= 1.5
|    |--- value: [157.5]
|--- Gender > 1.5
|    |--- value: [178.6]

正如预期的那样,当使用 MSE 时,预测值接近每个性别的均值,而使用 MAE 时,预测值接近中位数。

当然,我们的数据集中只有一个二元特征——性别。这就是为什么我们得到了一棵非常浅的树,只有一个分裂(一个存根)。实际上,在这种情况下,我们甚至不需要训练决策树;我们完全可以直接计算男性和女性的平均身高,并将其作为预期值来使用。由这样一个浅层树做出的决策被称为偏倚决策。如果我们允许每个个体使用更多的信息来表达自己,而不仅仅是性别,那么我们将能够为每个个体做出更准确的预测。

最后,就像分类树一样,我们也有相同的控制参数,例如max_depthmin_samples_splitmin_samples_leaf用于控制回归树的生长。

回归模型的评估

相同的 MSE 和 MAE 分数也可以用来评估回归模型的准确性。我们使用它们将回归模型的预测与测试集中的实际目标进行比较。以下是预测并评估预测结果的代码:

from sklearn.metrics import mean_squared_error, mean_absolute_error

y_test_pred = rgrsr.predict(x_test)
print('MSE:', mean_squared_error(y_test, y_test_pred))
print('MAE:', mean_absolute_error(y_test, y_test_pred))

使用均方误差(MSE)作为分裂标准时,我们得到的 MSE 为117.2,MAE 为8.2,而使用绝对误差(MAE)作为分裂标准时,MSE 为123.3,MAE 为7.8。显然,使用 MAE 作为分裂标准在测试时给出了更低的 MAE,反之亦然。换句话说,如果你的目标是基于某个指标减少预测误差,建议在训练时使用相同的指标来生长决策树。

设置样本权重

无论是决策树分类器还是回归器,都允许我们通过设置训练样本的权重,来对个别样本赋予更多或更少的权重。这是许多估计器的共同特性,决策树也不例外。为了查看样本权重的效果,我们将给身高超过 150 厘米的用户赋予 10 倍的权重,与其他用户进行对比:

rgrsr = DecisionTreeRegressor(criterion='mse')
sample_weight = y_train.apply(lambda h: 10 if h > 150 else 1)
rgrsr.fit(x_train, y_train, sample_weight=sample_weight)

反过来,我们也可以通过修改sample_weight计算,为身高 150 厘米及以下的用户赋予更多的权重,如下所示:

sample_weight = y_train.apply(lambda h: 10 if h <= 150 else 1)

通过使用export_text()函数,正如我们在前一节中所做的,我们可以显示结果树。我们可以看到sample_weight如何影响它们的最终结构:

**```py
Emphasis on "below 150":

|--- Gender <= 1.5
| |--- value: [150.7]
|--- Gender > 1.5
| |--- value: [179.2]

Emphasis on "above 150":

|--- Gender <= 1.5
| |--- value: [162.4]
|--- Gender > 1.5
| |--- value: [180.2]


默认情况下,所有样本被赋予相同的权重。对单个样本赋予不同的权重在处理不平衡数据或不平衡的商业决策时非常有用;也许你可以更容忍对新客户延迟发货,而对忠实客户则不能。在[第八章](https://cdp.packtpub.com/hands_on_machine_learning_with_scikit_learn/wp-admin/post.php?post=30&action=edit)中,*集成方法——当一个模型不够时*,我们将看到样本权重是 AdaBoost 算法学习的核心部分。

# 总结

决策树是直观的算法,能够执行分类和回归任务。它们允许用户打印出决策规则,这对于向业务人员和非技术人员传达你所做的决策非常有利。此外,决策树易于配置,因为它们的超参数数量有限。在训练决策树时,你需要做出的两个主要决定是:选择你的划分标准以及如何控制树的生长,以在*过拟合*和*欠拟合*之间取得良好的平衡。你对树的决策边界局限性的理解,在决定算法是否足够适应当前问题时至关重要。

在本章中,我们了解了决策树如何学习,并使用它们对一个著名的数据集进行分类。我们还学习了不同的评估指标,以及数据的大小如何影响我们对模型准确性的信心。接着,我们学习了如何使用不同的数据分割策略来应对评估中的不确定性。我们看到如何调整算法的超参数,以在过拟合和欠拟合之间取得良好的平衡。最后,我们在获得的知识基础上,构建了决策树回归器,并学习了划分标准的选择如何影响我们的预测结果。

我希望本章能为你提供一个良好的 scikit-learn 和其一致接口的介绍。有了这些知识,我们可以继续研究下一个算法,看看它与决策树算法有何不同。在下一章中,我们将学习线性模型。这组算法可以追溯到 18 世纪,它至今仍然是最常用的算法之一。


# 第三章:使用线性方程做决策

最小二乘回归分析方法可以追溯到 18 世纪卡尔·弗里德里希·高斯的时代。两个多世纪以来,许多算法基于它或在某种形式上受到它的启发。这些线性模型可能是今天回归和分类中最常用的算法。我们将从本章开始,首先看一下基本的最小二乘算法,然后随着章节的深入,我们将介绍更高级的算法。

以下是本章涵盖的主题列表:

+   理解线性模型

+   预测波士顿的房价

+   对回归器进行正则化

+   寻找回归区间

+   额外的线性回归器

+   使用逻辑回归进行分类

+   额外的线性分类器

# 理解线性模型

为了能够很好地解释线性模型,我想从一个例子开始,在这个例子中,解决方案可以通过线性方程组来求解——这是我们在 12 岁左右上学时学到的一项技术。然后,我们将看到为什么这种技术并不总是适用于现实生活中的问题,因此需要线性回归模型。接着,我们将把回归模型应用于一个现实中的回归问题,并在此过程中学习如何改进我们的解决方案。

## 线性方程

"数学是人类精神最美丽和最强大的创造。"

– 斯特凡·巴纳赫

在这个例子中,我们有五个乘客,他们乘坐了出租车旅行。这里记录了每辆出租车行驶的距离(以公里为单位)以及每次旅行结束时计价器上显示的费用:

![](https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/c515257d-2dbc-4faa-b78a-7ec497bd5bb9.png)

我们知道,出租车计价器通常会从一定的起始费用开始,然后根据每公里的行驶距离收取固定费用。我们可以用以下方程来建模计价器:

![](https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/c2a865a5-da87-469e-84c3-a3412c7f67fd.png)

在这里,*A*是计价器的起始值,*B*是每公里增加的费用。我们还知道,对于两个未知数——*A*和*B*——我们只需要两个数据样本就可以确定*A*是`5`,*B*是`2.5`。我们还可以用*A*和*B*的值绘制公式,如下所示:

![](https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/284965ec-b7d8-4136-9c86-c73ed11ee969.png)

我们还知道,蓝线会在*y*轴上与*A*(`5`)相交。因此,我们将*A*称为**截距**。我们还知道,直线的斜率等于*B*(`2.5`)。

乘客们并不总是带有零钱,所以他们有时会将计价器上显示的金额四舍五入,加上小费给司机。这是每位乘客最终支付的金额数据:

![](https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/67a8b31e-d936-4d2a-8237-a3561d53129b.png)

在我们加入小费后,很明显,行驶距离与支付金额之间的关系不再是线性的。右侧的图表显示,无法通过一条直线来捕捉这种关系:

![](https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/95527f92-ecc4-40f7-ab82-cdb9aae56698.png)

我们现在知道,之前的解方程方法在此时不再适用。然而,我们可以看出,仍然存在一条线,能够在某种程度上近似这个关系。在接下来的部分,我们将使用线性回归算法来找到这个近似值。

## 线性回归

算法的核心是目标。我们之前的目标是找到一条通过图中所有点的直线。我们已经看到,如果这些点之间不存在线性关系,那么这个目标是无法实现的。因此,我们将使用线性回归算法,因为它有不同的目标。线性回归算法试图找到一条线,使得估计点与实际点之间的平方误差的均值最小。从视觉上看,在下面的图中,我们希望找到一条虚线,使得所有垂直线的平方长度的平均值最小:

![](https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/7cc116de-9a1e-436d-b53d-8a1da2aafe7f.png)

这里用来找到一条最小化**均方误差**(**MSE**)的线性回归方法被称为普通最小二乘法。通常,线性回归就意味着普通最小二乘法。然而,在本章中,我将使用`LinearRegression`(作为一个词)来指代 scikit-learn 实现的普通最小二乘法,而将*线性回归*(作为两个词)保留用来指代线性回归的通用概念,无论是使用普通最小二乘法方法还是其他方法。

普通最小二乘法方法已有两个世纪的历史,它使用简单的数学来估算参数。这也是为什么一些人认为这个算法实际上不是机器学习算法的原因。就个人而言,我在分类什么是机器学习、什么不是时,采取了更加宽松的方式。只要算法能从数据中自动学习,并且我们用这些数据来评估它,那么在我看来,它就属于机器学习范畴。

### 估算支付给出租车司机的金额

现在我们已经了解了线性回归的工作原理,接下来让我们看看如何估算支付给出租车司机的金额。

1.  让我们使用 scikit-learn 构建一个回归模型来估算支付给出租车司机的金额:

```py
from sklearn.linear_model import LinearRegression

# Initialize and train the model
reg = LinearRegression()
reg.fit(df_taxi[['Kilometres']], df_taxi['Paid (incl. tips)'])

# Make predictions
df_taxi['Paid (Predicted)'] = reg.predict(df_taxi[['Kilometres']])

很明显,scikit-learn 具有一致的接口。我们使用了与前一章节相同的fit()predict()方法,只不过这次使用的是LinearRegression对象。

这次我们只有一个特征Kilometres,然而fit()predict()方法期望的是一个二维的ax,这就是为什么我们将Kilometres放入了一个额外的方括号中——df_taxi[['Kilometres']]

  1. 我们将预测结果放在同一个数据框架中的Paid (Predicted)列下。然后,我们可以使用以下代码绘制实际值与估算值的对比图:
fig, axs = plt.subplots(1, 2, figsize=(16, 5))

df_taxi.set_index('Kilometres')['Meter'].plot(
   title='Meter', kind='line', ax=axs[0]
)

df_taxi.set_index('Kilometres')['Paid (incl. tips)'].plot(
title='Paid (incl. tips)', label='actual', kind='line',  ax=axs[1]
)
df_taxi.set_index('Kilometres')['Paid (Predicted)'].plot(
    title='Paid (incl. tips)', label='estimated', kind='line', ax=axs[1]
)

fig.show()

我删去了代码中的格式部分,以保持简洁和直接。以下是最终结果:

  1. 一旦线性模型训练完成,您可以使用intercept_coef_参数来获取其截距和系数。因此,我们可以使用以下代码片段来创建估计直线的线性方程:
print(
    'Amount Paid = {:.1f} + {:.1f} * Distance'.format(
        reg.intercept_, reg.coef_[0], 
    )
) 

然后打印出以下方程:

获取线性方程的参数在某些情况下非常有用,尤其是当您想要在 scikit-learn 中构建一个模型,然后在其他语言中使用它,甚至是在您最喜欢的电子表格软件中使用它时。了解系数还有助于我们理解模型为什么做出某些决策。更多内容将在本章后面详细讨论。

在软件中,函数和方法的输入被称为参数。在机器学习中,模型学习到的权重也被称为参数。在设置模型时,我们将其配置传递给__init__方法。因此,为了避免任何混淆,模型的配置被称为超参数。

预测波士顿的房价

现在我们已经了解了线性回归的工作原理,接下来我们将研究一个真实的数据集,展示一个更实际的用例。

波士顿数据集是一个小型数据集,表示波士顿市的房价。它包含 506 个样本和 13 个特征。我们可以将数据加载到一个 DataFrame 中,如下所示:

from sklearn.datasets import load_boston

boston = load_boston()

df_dataset = pd.DataFrame(
    boston.data,
    columns=boston.feature_names,
)
df_dataset['target'] = boston.target

数据探索

确保数据中没有任何空值非常重要;否则,scikit-learn 会报错。在这里,我将统计每一列中的空值总和,然后对其求和。如果得到的是0,那么我就会很高兴:

df_dataset.isnull().sum().sum() # Luckily, the result is zero

对于回归问题,最重要的是理解目标变量的分布。如果目标变量的范围在110之间,而我们训练模型后得到的平均绝对误差为5,那么在这个情况下,我们可以判断误差较大。

然而,对于一个目标值在500,0001,000,000之间的情况,相同的误差是可以忽略不计的。当您想要可视化分布时,直方图是您的好帮手。除了目标的分布,我们还可以绘制每个特征的均值:

fig, axs = plt.subplots(1, 2, figsize=(16, 8))

df_dataset['target'].plot(
    title='Distribution of target prices', kind='hist', ax=axs[0]
)
df_dataset[boston.feature_names].mean().plot(
    title='Mean of features', kind='bar', ax=axs[1]
)

fig.show()

这为我们提供了以下图表:

在前面的图表中,我们观察到:

  • 价格范围在550之间。显然,这些并非真实价格,可能是归一化后的值,但现在这并不重要。

  • 此外,从直方图中我们可以看出,大多数价格都低于35。我们可以使用以下代码片段,看到 90%的价格都低于34.8

df_dataset['target'].describe(percentiles=[.9, .95, .99])

您可以始终深入进行数据探索,但这次我们就到此为止。

数据划分

对于小型数据集,建议为测试预留足够的数据。因此,我们将数据划分为 60%的训练数据和 40%的测试数据,使用train_test_split函数:

from sklearn.model_selection import train_test_split

df_train, df_test = train_test_split(df_dataset, test_size=0.4)

x_train = df_train[boston.feature_names]
x_test = df_test[boston.feature_names]
y_train = df_train['target']
y_test = df_test['target']

一旦你拥有了训练集和测试集,就将它们进一步拆分为x集和y集。然后,我们就可以进入下一步。

计算基准

目标值的分布让我们了解了我们能容忍的误差水平。然而,比较我们的最终模型与某些基准总是有用的。如果我们从事房地产行业,并且由人类代理估算房价,那么我们很可能会被期望建立一个比人类代理更准确的模型。然而,由于我们无法获得实际估算值来与我们的模型进行比较,因此我们可以自己提出一个基准。房屋的均价是22.5。如果我们建立一个虚拟模型,无论输入什么数据都返回均价,那么它就会成为一个合理的基准。

请记住,22.5的值是针对整个数据集计算的,但因为我们假装只能访问训练数据,所以只计算训练集的均值是有意义的。为了节省我们的精力,scikit-learn 提供了虚拟回归器,可以为我们完成所有这些工作。

在这里,我们将创建一个虚拟回归器,并用它来计算测试集的基准预测值:

from sklearn.dummy import DummyRegressor

baselin = DummyRegressor(strategy='mean')
baselin.fit(x_train, y_train)

y_test_baselin = baselin.predict(x_test)

我们可以使用其他策略,比如找到中位数(第 50^(th) 分位数)或任何其他N^(th) 分位数。请记住,对于相同的数据,使用均值作为估算值相比于使用中位数时,会得到更低的均方误差(MSE)。相反,中位数会得到更低的平均绝对误差MAE)。我们希望我们的模型在 MAE 和 MSE 两方面都能超越基准。

训练线性回归器

基准模型的代码和实际模型几乎一模一样,不是吗?这就是 scikit-learn API 的优点。意味着当我们决定尝试不同的算法,比如上一章的决策树算法时,我们只需要更改几行代码。无论如何,下面是线性回归器的代码:

from sklearn.linear_model import LinearRegression

reg = LinearRegression()
reg.fit(x_train, y_train)

y_test_pred = reg.predict(x_test)

我们暂时会坚持默认配置。

评估模型的准确性

在回归中,有三种常用的指标:MAEMSE。首先让我们编写计算这三个指标并打印结果的代码:

from sklearn.metrics import r2_score
from sklearn.metrics import mean_absolute_error
from sklearn.metrics import mean_squared_error

print(
    'R2 Regressor = {:.2f} vs Baseline = {:.2f}'.format(
        r2_score(y_test, y_test_pred), 
        r2_score(y_test, y_test_baselin)
     )
)
print(
    'MAE Regressor = {:.2f} vs Baseline = {:.2f}'.format(
        mean_absolute_error(y_test, y_test_pred), 
        mean_absolute_error(y_test, y_test_baselin)
    )
)
print(
    'MSE Regressor = {:.2f} vs Baseline = {:.2f}'.format(
        mean_squared_error(y_test, y_test_pred), 
        mean_squared_error(y_test, y_test_baselin)
    )
)

下面是我们得到的结果:

R2 Regressor = 0.74 vs Baseline = -0.00
MAE Regressor = 3.19 vs Baseline = 6.29
MSE Regressor = 19.70 vs Baseline = 76.11

到现在为止,你应该已经知道如何计算MAEMSE了。只需要记住,MSEMAE对异常值更敏感。这就是为什么基准的均值估算得分较差的原因。至于,让我们看一下它的公式:

下面是前面公式的解释:

  • 分子可能让你想起了MSE。我们基本上计算所有预测值与对应实际值之间的平方差。

  • 至于分母,我们使用实际值的均值作为伪估算值。

  • 基本上,这个指标告诉我们,和使用目标均值作为估算值相比,我们的预测有多么准确。

  • 1的 R²分数是我们能得到的最佳结果,0的分数意味着我们与一个仅依赖均值作为估计的有偏模型相比没有提供任何附加价值。

  • 一个负分数意味着我们应该把模型扔进垃圾桶,改用目标的均值作为预测。

  • 显然,在基线模型中,我们已经使用目标的均值作为预测。因此,它的 R²分数是0

对于MAEMSE,它们的值越小,模型就越好。相反,对于,它的值越高,模型就越好。在 scikit-learn 中,那些值越高表示结果越好的度量函数名称以_score结尾,而以_error_loss结尾的函数则是值越小,越好。

现在,如果我们比较得分,就会发现我们的模型在所有三项得分中都优于基线得分。恭喜!

显示特征系数

我们知道线性模型会将每个特征乘以一个特定的系数,然后将这些乘积的和作为最终预测结果。我们可以在模型训练后使用回归器的coef_方法打印这些系数:

df_feature_importance = pd.DataFrame(
    {
        'Features': x_train.columns,
        'Coeff': reg.coef_,
        'ABS(Coeff)': abs(reg.coef_),
    }
).set_index('Features').sort_values('Coeff', ascending=False)

如我们在这些结果中看到的,某些系数是正的,其他的是负的。正系数意味着特征与目标正相关,反之亦然。我还添加了系数绝对值的另一列:

在前面的截图中,观察到如下情况:

  • 理想情况下,每个系数的值应该告诉我们每个特征的重要性。绝对值越高,不管符号如何,都表示特征越重要。

  • 然而,我在这里犯了一个错误。如果你查看数据,你会注意到NOX的最大值是0.87,而TAX的最大值是711。这意味着如果NOX只有微不足道的重要性,它的系数仍然会很高,以平衡它的较小值;而对于TAX,它的系数会始终相对较小,因为特征本身的值较高。

  • 所以,我们需要对特征进行缩放,以保持它们在可比较的范围内。在接下来的章节中,我们将看到如何对特征进行缩放。

为了更有意义的系数进行缩放

scikit-learn 有多种缩放器。我们现在将使用MinMaxScaler。使用其默认配置时,它会将所有特征的值压缩到01之间。该缩放器需要先进行拟合,以了解特征的范围。拟合应该仅在训练X数据集上进行。然后,我们使用缩放器的transform函数对训练集和测试集的X数据进行缩放:

from sklearn.linear_model import LinearRegression
from sklearn.preprocessing import MinMaxScaler

scaler = MinMaxScaler()
reg = LinearRegression()

scaler.fit(x_train)
x_train_scaled = scaler.transform(x_train)
x_test_scaled = scaler.transform(x_test)

reg.fit(x_train_scaled, y_train)
y_test_pred = reg.predict(x_test_scaled)

这里有一行简化代码,它用于拟合一个数据集并进行转换。换句话说,以下未注释的行代替了两行注释的代码:

# scaler.fit(x_train)
# x_train_scaled = scaler.transform(x_train)
x_train_scaled = scaler.fit_transform(x_train)

从现在开始,我们将经常使用fit_transform()函数,视需要而定。

如果你想要有意义的系数,缩放特征非常重要。更进一步,缩放有助于基于梯度的求解器更快地收敛(稍后会详细说明)。除了缩放,你还应该确保没有高度相关的特征,这样可以获得更有意义的系数,并使线性回归模型更稳定。

现在我们已经对特征进行了缩放并重新训练了模型,我们可以再次打印特征及其系数:

请注意,NOX 现在比之前更不重要了。

添加多项式特征

现在我们知道了最重要的特征,我们可以将目标与这些特征进行绘图,看看它们与目标之间的相关性:

在前面的截图中,观察到以下情况:

  • 这些图看起来似乎并不完全是线性的,线性模型无法捕捉到这种非线性。

  • 虽然我们不能将线性模型转变为非线性模型,但我们可以通过数据转换来实现。

  • 这样想:如果 y 的函数,我们可以使用一个非线性模型——一个能够捕捉 xy 之间二次关系的模型——或者我们可以直接计算 并将其提供给线性模型,而不是 x。此外,线性回归算法无法捕捉特征交互。

  • 当前模型无法捕捉多个特征之间的交互。

多项式变换可以解决非线性和特征交互问题。给定原始数据,scikit-learn 的多项式变换器将把特征转化为更高维度(例如,它会为每个特征添加平方值和立方值)。此外,它还会将每对特征(或三元组)之间的乘积添加进去。PolynomialFeatures 的工作方式类似于我们在本章前面使用的缩放器。我们将使用其 fit_transform 变量和 transform() 方法,如下所示:

from sklearn.preprocessing import PolynomialFeatures

poly = PolynomialFeatures(degree=3)
x_train_poly = poly.fit_transform(x_train)
x_test_poly = poly.transform(x_test)

为了获得二次和三次特征转换,我们将 degree 参数设置为 3

PolynomialFeatures 有一个令人烦恼的地方,它没有保留 DataFrame 的列名。它将特征名替换为 x0x1x2 等。然而,凭借我们的 Python 技能,我们可以恢复列名。我们就用以下代码块来实现这一点:

feature_translator = [
    (f'x{i}', feature) for i, feature in enumerate(x_train.columns, 0)
]

def translate_feature_names(s):
    for key, val in feature_translator:
        s = s.replace(key, val)
    return s

poly_features = [
    translate_feature_names(f) for f in poly.get_feature_names()
]

x_train_poly = pd.DataFrame(x_train_poly, columns=poly_features)
x_test_poly = pd.DataFrame(x_test_poly, columns=poly_features)

现在我们可以使用新派生的多项式特征,而不是原始特征。

使用派生特征拟合线性回归模型

"当我六岁时,我妹妹只有我一半大。现在我 60 岁,我妹妹多大了?"

这是在互联网上找到的一个谜题。如果你的答案是 30,那么你忘记为线性回归模型拟合截距了。

现在,我们准备使用带有新转换特征的线性回归器。需要记住的一点是,PolynomialFeatures转换器会添加一个额外的列,所有值都是1。训练后,这一列得到的系数相当于截距。因此,我们这次训练回归器时,将通过设置fit_intercept=False来避免拟合截距:

from sklearn.linear_model import LinearRegression

reg = LinearRegression(fit_intercept=False)
reg.fit(x_train_poly, y_train)

y_test_pred = reg.predict(x_test_poly)

最后,当我们打印MAEMSE结果时,迎来了一些不太愉快的惊讶:

R2 Regressor = -84.887 vs Baseline = -0.0
MAE Regressor = 37.529 vs Baseline = 6.2
MSE Regressor = 6536.975 vs Baseline = 78.1

回归器的表现比之前差得多,甚至比基准模型还要差。多项式特征究竟对我们的模型做了什么?

普通最小二乘回归算法的一个主要问题是它在面对高度相关的特征(多重共线性)时效果不好。

多项式特征转换的“厨房水槽”方法——我们添加特征、它们的平方值和立方值,以及特征对和三重对的乘积——很可能会给我们带来多个相关的特征。多重共线性会损害模型的表现。此外,如果你打印x_train_poly的形状,你会看到它有 303 个样本和 560 个特征。这是另一个问题,称为“维度灾难”。

维度灾难是指当你的特征数远超过样本数时的问题。如果你把数据框想象成一个矩形,特征是矩形的底边,样本是矩形的高度,你总是希望矩形的高度远大于底边。假设有两列二进制特征——x1x2。它们可以有四种可能的值组合——(0, 0)(0, 1)(1, 0)(1, 1)。同样,对于n列,它们可以有2^n种组合。正如你所看到的,随着特征数的增加,可能性数量呈指数增长。为了使监督学习算法有效工作,它需要足够的样本来覆盖所有这些可能性中的合理数量。当我们有非二进制特征时(如本例所示),这个问题更为严重。

幸运的是,两个世纪的时间足够让人们找到这两个问题的解决方案。正则化就是我们在下一部分将要深入探讨的解决方案。

正则化回归器

“用更多做本可以用更少做的事是徒劳的。”

——奥卡姆的威廉

最初,我们的目标是最小化回归器的 MSE 值。后来我们发现,特征过多是一个问题。这就是为什么我们需要一个新的目标。我们仍然需要最小化回归器的 MSE 值,但同时我们还需要激励模型忽略无用的特征。这个目标的第二部分,就是正则化的作用。

常用于正则化线性回归的两种算法是LassoRidge。Lasso 使得模型的系数更少——也就是说,它将尽可能多的系数设为0——而 Ridge 则推动模型的系数尽可能小。Lasso 使用一种叫做 L1 的正则化形式,它惩罚系数的绝对值,而 Ridge 使用 L2,它惩罚系数的平方值。这两种算法都有一个超参数(alpha),用来控制系数的正则化程度。将 alpha 设为0意味着没有任何正则化,这就回到了普通最小二乘回归。较大的 alpha 值指定更强的正则化,而我们将从 alpha 的默认值开始,稍后再看看如何正确设置它。

普通最小二乘法算法中使用的标准方法在这里不起作用。现在,我们有了一个目标函数,旨在最小化系数的大小,同时最小化预测器的 MSE 值。因此,使用求解器来找到能够最小化新目标函数的最佳系数。我们将在本章稍后进一步讨论求解器。

训练 Lasso 回归器

训练 Lasso 与训练其他模型没有区别。与我们在前一节中所做的类似,我们将在这里将fit_intercept设置为False

from sklearn.linear_model import Ridge, Lasso

reg = Lasso(fit_intercept=False)
reg.fit(x_train_poly, y_train)

y_test_pred = reg.predict(x_test_poly)

一旦完成,我们可以打印 R²、MAE 和 MSE:

R2 Regressor = 0.787 vs Baseline = -0.0
MAE Regressor = 2.381 vs Baseline = 6.2
MSE Regressor = 16.227 vs Baseline = 78.

我们不仅修复了多项式特征引入的问题,而且还比原始线性回归器有了更好的表现。MAE值为2.4,相比之前的3.6MSE16.2,相比之前的25.80.79,相比之前的0.73

现在我们已经看到了应用正则化后的 promising results,接下来是时候看看如何为正则化参数设置一个最佳值。

寻找最佳正则化参数

理想情况下,在将数据拆分为训练集和测试集之后,我们会将训练集进一步拆分为N个折叠。然后,我们会列出我们想要测试的所有 alpha 值,并逐一循环进行测试。每次迭代时,我们将应用N-fold 交叉验证,找出能够产生最小误差的 alpha 值。幸运的是,scikit-learn 有一个叫做LassoCV的模块(CV代表交叉验证)。在这里,我们将使用这个模块,利用五折交叉验证来找到最佳的 alpha 值:

from sklearn.linear_model import LassoCV

# Make a list of 50 values between 0.000001 & 1,000,000
alphas = np.logspace(-6, 6, 50)

# We will do 5-fold cross validation
reg = LassoCV(alphas=alphas, fit_intercept=False, cv=5)
reg.fit(x_train_poly, y_train)

y_train_pred = reg.predict(x_train_poly)
y_test_pred = reg.predict(x_test_poly)

一旦完成,我们可以使用模型进行预测。你可能想预测训练集和测试集,并查看模型是否在训练集上出现过拟合。我们还可以打印选择的 alpha 值,如下所示:

print(f"LassoCV: Chosen alpha = {reg.alpha_}")

我得到了1151.4alpha值。

此外,我们还可以看到,对于每个 alpha 值,五个折叠中的MSE值是多少。我们可以通过mse_path_访问这些信息。

由于每个 alpha 值对应五个MSE值,我们可以绘制这五个值的平均值,并绘制围绕平均值的置信区间。

置信区间用于展示观察数据可能取值的预期范围。95%的置信区间意味着我们期望 95%的值落在这个范围内。较宽的置信区间意味着数据可能取值的范围较大,而较窄的置信区间则意味着我们几乎可以准确地预测数据会取什么值。

95%的置信区间计算如下:

这里,标准误差等于标准差除以样本数量的平方根(![],因为我们这里有五个折数)。

这里的置信区间公式并不是 100%准确。从统计学角度来看,当处理小样本且其基本方差未知时,应该使用 t 分布而非 z 分布。因此,鉴于这里的折数较小,1.96 的系数应当用 t 分布表中更准确的值来替代,其中自由度由折数推断得出。

以下代码片段计算并绘制了 MSE 与 alpha 的置信区间:

  1. 我们首先计算返回的MSE值的描述性统计数据:
# n_folds equals to 5 here
n_folds = reg.mse_path_.shape[1]

# Calculate the mean and standard error for MSEs
mse_mean = reg.mse_path_.mean(axis=1)
mse_std = reg.mse_path_.std(axis=1)
# Std Error = Std Deviation / SQRT(number of samples)
mse_std_error = mse_std / np.sqrt(n_folds)
  1. 然后,我们将计算结果放入数据框中,并使用默认的折线图进行绘制:
fig, ax = plt.subplots(1, 1, figsize=(16, 8))

# We multiply by 1.96 for a 95% Confidence Interval
pd.DataFrame(
    {
        'alpha': reg.alphas_,
        'Mean MSE': mse_mean,
        'Upper Bound MSE': mse_mean + 1.96 * mse_std_error,
        'Lower Bound MSE': mse_mean - 1.96 * mse_std_error,
    }
).set_index('alpha')[
    ['Mean MSE', 'Upper Bound MSE', 'Lower Bound MSE']
].plot(
    title='Regularization plot (MSE vs alpha)', 
    marker='.', logx=True, ax=ax
)

# Color the confidence interval 
plt.fill_between(
    reg.alphas_, 
    mse_mean + 1.96 * mse_std_error, 
    mse_mean - 1.96 * mse_std_error, 
)

# Print a vertical line for the chosen alpha
ax.axvline(reg.alpha_, linestyle='--', color='k')
ax.set_xlabel('Alpha')
ax.set_ylabel('Mean Squared Error')

这是前面代码的输出:

在选择的 alpha 值下,MSE 值最小。此时,置信区间也更窄,这反映了对预期的MSE结果更高的信心。

最后,将模型的 alpha 值设置为建议值,并使用它对测试数据进行预测,得出了以下结果:

基准 线性回归 Lasso(Alpha = 1151.4)
0.00 0.73 0.83
MAE 7.20 3.56 2.76
MSE 96.62 25.76 16.31

显然,正则化解决了由维度灾难引起的问题。此外,我们通过交叉验证找到了最佳的正则化参数。我们绘制了误差的置信区间,以可视化 alpha 对回归器的影响。我在本节讨论置信区间的内容,激发了我将下一节专门用于回归区间的写作。

查找回归区间

“探索未知需要容忍不确定性。”

– 布莱恩·格林

我们无法总是保证得到准确的模型。有时,我们的数据本身就很嘈杂,无法使用回归模型进行建模。在这些情况下,能够量化我们估计结果的可信度非常重要。通常,回归模型会做出点预测。这些是目标值(通常是均值)在每个 x 值下的预期值 (y)。贝叶斯岭回归模型通常也会返回预期值,但它还会返回每个 x 值下目标值 (y) 的标准差。

为了演示这一点,我们来创建一个带噪声的数据集,其中

import numpy as np
import pandas as pd

df_noisy = pd.DataFrame(
    {
        'x': np.random.random_integers(0, 30, size=150),
        'noise': np.random.normal(loc=0.0, scale=5.0, size=150)
    }
)

df_noisy['y'] = df_noisy['x'] + df_noisy['noise']

然后,我们可以将其绘制成散点图:

df_noisy.plot(
    kind='scatter', x='x', y='y'
)

绘制结果数据框将给我们以下图形:

现在,让我们在相同的数据上训练两个回归模型——LinearRegressionBayesianRidge。这里我将坚持使用默认的贝叶斯岭回归超参数值:

from sklearn.linear_model import LinearRegression
from sklearn.linear_model import BayesianRidge

lr = LinearRegression()
br = BayesianRidge()

lr.fit(df_noisy[['x']], df_noisy['y'])
df_noisy['y_lr_pred'] = lr.predict(df_noisy[['x']])

br.fit(df_noisy[['x']], df_noisy['y'])
df_noisy['y_br_pred'], df_noisy['y_br_std'] = br.predict(df_noisy[['x']], return_std=True)

注意,贝叶斯岭回归模型在预测时会返回两个值。

贝叶斯线性回归与前面提到的算法在看待其系数的方式上有所不同。对于我们到目前为止看到的所有算法,每个系数在训练后都取一个单一的值,但对于贝叶斯模型,系数实际上是一个分布,具有估计的均值和标准差。系数是通过一个先验分布进行初始化的,然后通过训练数据更新,最终通过贝叶斯定理达到后验分布。贝叶斯岭回归模型是一个正则化的贝叶斯回归模型。

这两个模型的预测结果非常相似。然而,我们可以使用返回的标准差来计算我们预期大多数未来数据落入的范围。以下代码片段绘制了这两个模型及其预测的图形:

fig, axs = plt.subplots(1, 3, figsize=(16, 6), sharex=True, sharey=True)

# We plot the data 3 times
df_noisy.sort_values('x').plot(
    title='Data', kind='scatter', x='x', y='y', ax=axs[0]
)
df_noisy.sort_values('x').plot(
    kind='scatter', x='x', y='y', ax=axs[1], marker='o', alpha=0.25
)
df_noisy.sort_values('x').plot(
    kind='scatter', x='x', y='y', ax=axs[2], marker='o', alpha=0.25
)

# Here we plot the Linear Regression predictions
df_noisy.sort_values('x').plot(
    title='LinearRegression', kind='scatter', x='x', y='y_lr_pred', 
    ax=axs[1], marker='o', color='k', label='Predictions'
)

# Here we plot the Bayesian Ridge predictions
df_noisy.sort_values('x').plot(
    title='BayesianRidge', kind='scatter', x='x', y='y_br_pred', 
    ax=axs[2], marker='o', color='k', label='Predictions'
)

# Here we plot the range around the expected values
# We multiply by 1.96 for a 95% Confidence Interval
axs[2].fill_between(
    df_noisy.sort_values('x')['x'], 
    df_noisy.sort_values('x')['y_br_pred'] - 1.96 * 
                df_noisy.sort_values('x')['y_br_std'], 
    df_noisy.sort_values('x')['y_br_pred'] + 1.96 * 
                df_noisy.sort_values('x')['y_br_std'],
    color="k", alpha=0.2, label="Predictions +/- 1.96 * Std Dev"
)

fig.show()

运行前面的代码会给我们以下图形。在BayesianRidge的案例中,阴影区域显示了我们预期 95%的目标值会落在其中:

回归区间在我们想量化不确定性时非常有用。在第八章集成方法——当一个模型不够用时,我们将重新讨论回归区间。

了解更多的线性回归模型

在继续学习线性分类器之前,理应将以下几种额外的线性回归算法加入到你的工具箱中:

  • Elastic-net 使用 L1 和 L2 正则化技术的混合,其中 l1_ratio 控制两者的混合比例。这在你希望学习一个稀疏模型,其中只有少数权重为非零(如 lasso)的情况下非常有用,同时又能保持 ridge 正则化的优点。

  • 随机样本一致性RANSAC)在数据存在离群点时非常有用。它试图将离群点与内点样本分开。然后,它仅对内点样本拟合模型。

  • 最小角回归LARS)在处理高维数据时非常有用——也就是当特征数量与样本数量相比显著较多时。你可以尝试将其应用到我们之前看到的多项式特征示例中,看看它的表现如何。

让我们继续进入书中的下一个章节,你将学习如何使用逻辑回归来分类数据。

使用逻辑回归进行分类

“你可以通过一个人的答案看出他是否聪明。你可以通过一个人的问题看出他是否智慧。”

– 纳吉布·马赫福兹

有一天,在面试时,面试官问:“那么告诉我,逻辑回归是分类算法还是回归算法?” 对此的简短回答是它是分类算法,但更长且更有趣的回答需要对逻辑函数有很好的理解。然后,问题可能会完全改变其意义。

理解逻辑函数

逻辑函数是 S 型(s形)函数的一种,它的表达式如下:

别让这个方程吓到你。真正重要的是这个函数的视觉效果。幸运的是,我们可以用计算机生成一系列θ的值——比如在-1010之间。然后,我们可以将这些值代入公式,并绘制出对应的y值与θ值的关系图,正如我们在以下代码中所做的:

import numpy as np
import pandas as pd

fig, ax = plt.subplots(1, 1, figsize=(16, 8))

theta = np.arange(-10, 10, 0.05)
y = 1 / (1 + np.exp(-1 * theta))

pd.DataFrame(
    {
        'theta': theta,
        'y': y
    }
).plot(
    title='Logistic Function', 
    kind='scatter', x='theta', y='y', 
    ax=ax
)

fig.show()

运行此代码将生成以下图表:

逻辑函数中需要注意的两个关键特征如下:

  • y 仅在01之间变化。当θ趋近于正无穷时,y趋近于1;当θ趋近于负无穷时,y趋近于0

  • 当θ为0时,y的值为0.5

将逻辑函数代入线性模型

“概率不仅仅是对骰子上的赔率或更复杂的变种进行计算;它是接受我们知识中不确定性的存在,并发展出应对我们无知的方法。”

– 纳西姆·尼古拉斯·塔勒布

对于一个包含两个特征的线性模型,x[1]x[2],我们可以有一个截距和两个系数。我们将它们称为。那么,线性回归方程将如下所示:

另外,我们也可以将前面方程右侧的部分代入逻辑函数,替代。这将得到以下的y方程:

在这种情况下,x值的变化将使得y01之间波动。x与其系数的乘积的较高值会使得y接近1,较低值会使其接近0。我们也知道,概率的值介于01之间。因此,将y解释为给定x的情况下,y属于某一类的概率是有意义的。如果我们不想处理概率,我们可以直接指定 ;那么,我们的样本就属于类别 1,否则属于类别 0。

这是对逻辑回归工作原理的简要介绍。它是一个分类器,但被称为回归,因为它基本上是一个回归器,返回一个介于01之间的值,我们将其解释为概率。

要训练逻辑回归模型,我们需要一个目标函数,以及一个求解器,用来寻找最优的系数以最小化这个函数。在接下来的章节中,我们将更详细地讲解这些内容。

目标函数

在训练阶段,算法会遍历数据,尝试找到能够最小化预定义目标(损失)函数的系数。在逻辑回归的情况下,我们尝试最小化的损失函数被称为对数损失。它通过以下公式来衡量预测概率(p)与实际类别标签(y)之间的差距:

-log(p) if y == 1 else -log(1 - p)

数学家使用一种相当难看的方式来表达这个公式,因为他们缺少if-else条件。所以,我选择在这里显示 Python 形式,便于理解。开个玩笑,数学公式在你了解其信息论根源后会变得非常优美,但这不是我们现在要讨论的内容。

正则化

此外,scikit-learn 实现的逻辑回归算法默认使用正则化。开箱即用时,它使用 L2 正则化(如岭回归器),但它也可以使用 L1(如 Lasso)或 L1 和 L2 的混合(如 Elastic-Net)。

求解器

最后,我们如何找到最优的系数来最小化我们的损失函数呢?一个天真的方法是尝试所有可能的系数组合,直到找到最小损失。然而,由于考虑到无限的组合,全面搜索是不可行的,因此求解器的作用就是高效地搜索最优系数。scikit-learn 实现了大约六种求解器。

求解器的选择以及所使用的正则化方法是配置逻辑回归算法时需要做出的两个主要决策。在接下来的章节中,我们将讨论如何以及何时选择每一个。

配置逻辑回归分类器

在谈论求解器之前,让我们先了解一些常用的超参数:

  • fit_intercept:通常,除了每个特征的系数外,方程中还有一个常数截距。然而,有些情况下你可能不需要截距,例如,当你确定当所有 x 的值为 0 时,y 的值应该是 0.5。另一个情况是当你的数据已经有一个常数列,所有值都设为 1。这种情况通常发生在数据的早期处理阶段,比如在多项式处理器的情况下。此时,constant 列的系数将被解释为截距。线性回归算法中也有类似的配置。

  • max_iter:为了让求解器找到最佳系数,它会多次遍历训练数据。这些迭代也称为周期(epochs)。通常会设置迭代次数的上限,以防止过拟合。与之前解释的 lasso 和 ridge 回归器使用的超参数相同。

  • tol:这是另一种停止求解器过多迭代的方法。如果将其设置为较高的值,意味着只接受相邻两次迭代之间的较大改进;否则,求解器将停止。相反,较低的值将使求解器继续迭代更多次,直到达到 max_iter

  • penalty:选择要使用的正则化技术。可以是 L1、L2、弹性网(elastic-net)或无正则化(none)。正则化有助于防止过拟合,因此当特征较多时,使用正则化非常重要。当 max_itertol 设置为较高值时,它还可以减轻过拟合的效果。

  • Calpha:这些是用于设置正则化强度的参数。由于我们在此使用两种不同的逻辑回归算法实现,因此需要了解这两种实现使用了不同的参数(Calpha)。alpha 基本上是 C 的倒数—()。这意味着,较小的 C 值表示较强的正则化,而对于 alpha,则需要较大的值来表示较强的正则化。

  • l1_ratio:当使用 L1 和 L2 的混合时,例如弹性网(elastic-net),此值指定 L1 与 L2 的权重比例。

以下是我们可以使用的一些求解器:

  • liblinear该求解器在 LogisticRegression 中实现,推荐用于较小的数据集。它支持 L1 和 L2 正则化,但如果想使用弹性网,或者如果不想使用正则化,则无法使用此求解器。

*** sagsaga:这些求解器在LogisticRegressionRidgeClassifier中实现,对于较大的数据集,它们运行更快。然而,你需要对特征进行缩放,才能使其收敛。我们在本章早些时候使用了MinMaxScaler来缩放特征。现在,这不仅仅是为了更有意义的系数,也是为了让求解器更早地找到解决方案。saga支持四种惩罚选项。* lbfgs:**此求解器在LogisticRegression中实现。它支持 L2 惩罚或根本不使用正则化。****

***** 随机梯度下降SGD):SGD 有专门的实现——SGDClassifierSGDRegressor。这与LogisticRegression不同,后者的重点是通过优化单一的损失函数——对数损失来进行逻辑回归。SGDClassifier的重点是 SGD 求解器本身,这意味着相同的分类器可以使用不同的损失函数。如果将loss设置为log,那么它就是一个逻辑回归模型。然而,将loss设置为hingeperceptron,则分别变成支持向量机SVM)或感知机。这是另外两种线性分类器。

梯度下降是一种优化算法,旨在通过迭代地沿着最陡下降的方向移动来找到函数的局部最小值。最陡下降的方向通过微积分求得,因此称之为梯度。如果你将目标(损失)函数想象成一条曲线,梯度下降算法会盲目地选择曲线上的一个随机点,并利用该点的梯度作为指导,逐步向局部最小值移动。通常,损失函数选择为凸函数,这样它的局部最小值也就是全局最小值。在随机梯度下降的版本中,估算器的权重在每个训练样本上都会更新,而不是对整个训练数据计算梯度。梯度下降的更多细节内容可以参考第七章,《神经网络——深度学习来临》。** **## 使用逻辑回归对鸢尾花数据集进行分类

我们将把鸢尾花数据集加载到数据框中。以下代码块与在第二章《使用树做决策》中使用的代码类似,用于加载数据集:

from sklearn import datasets
iris = datasets.load_iris()

df = pd.DataFrame(
    iris.data,
    columns=iris.feature_names
)

df['target'] = pd.Series(
    iris.target
)

然后,我们将使用cross_validate通过六折交叉验证来评估LogisticRegression算法的准确性,具体如下:

from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_validate

num_folds = 6

clf = LogisticRegression(
    solver='lbfgs', multi_class='multinomial', max_iter=1000
)
accuracy_scores = cross_validate(
    clf, df[iris.feature_names], df['target'], 
    cv=num_folds, scoring=['accuracy']
)

accuracy_mean = pd.Series(accuracy_scores['test_accuracy']).mean()
accuracy_std = pd.Series(accuracy_scores['test_accuracy']).std()
accuracy_sterror = accuracy_std / np.sqrt(num_folds)

print(
     'Logistic Regression: Accuracy ({}-fold): {:.2f} ~ {:.2f}'.format(
         num_folds,
         (accuracy_mean - 1.96 * accuracy_sterror),
         (accuracy_mean + 1.96 * accuracy_sterror),
    )
)

运行前面的代码将给我们一组准确率得分,并且其 95%的置信区间在0.951.00之间。运行相同的代码进行决策树分类器训练时,得到的置信区间在0.930.99之间。

由于我们这里有三个类别,因此为每个类别边界计算的系数与其他类别是分开的。在我们再次训练逻辑回归算法且不使用cross_validate包装器之后,我们可以通过coef_访问系数。我们也可以通过intercept_访问截距。

在下一段代码中,我将使用字典推导式。在 Python 中,创建[0, 1, 2, 3]列表的一种方法是使用[i for i in range(4)]列表推导式。这基本上执行循环来填充列表。同样,['x' for i in range(4)]列表推导式将创建['x', 'x', 'x', 'x']列表。字典推导式以相同的方式工作。例如,{str(i): i for i in range(4)}这一行代码将创建{'0': 0, '1': 1, '2': 2, '3': 3}字典。

以下代码将系数放入数据框中。它基本上创建了一个字典,字典的键是类别 ID,并将每个 ID 映射到其相应系数的列表。一旦字典创建完成,我们将其转换为数据框,并在显示之前将截距添加到数据框中:

# We need to fit the model again before getting its coefficients
clf.fit(df[iris.feature_names], df['target'])

# We use dictionary comprehension instead of a for-loop
df_coef = pd.DataFrame(
    {
        f'Coef [Class {class_id}]': clf.coef_[class_id]
        for class_id in range(clf.coef_.shape[0])
    },
    index=iris.feature_names
)
df_coef.loc['intercept', :] = clf.intercept_

在训练之前,别忘了对特征进行缩放。然后,你应该得到一个看起来像这样的系数数据框:

上面截图中的表格显示了以下内容:

  • 从第一行可以看出,花萼长度的增加与类别 1 和类别 2 的相关性高于其他类别,这是基于类别 1 和类别 2 系数的正号。

  • 这里使用线性模型意味着类别边界不会像决策树那样局限于水平和垂直线,而是会呈现线性形态。

为了更好地理解这一点,在下一部分中,我们将绘制逻辑回归分类器的决策边界,并将其与决策树的边界进行比较。

理解分类器的决策边界

通过可视化决策边界,我们可以理解模型为什么做出某些决策。以下是绘制这些边界的步骤:

  1. 我们首先创建一个函数,该函数接受分类器的对象和数据样本,然后为特定的分类器和数据绘制决策边界:
def plot_decision_boundary(clf, x, y, ax, title):

   cmap='Paired_r' 
   feature_names = x.columns
   x, y = x.values, y.values

   x_min, x_max = x[:,0].min(), x[:,0].max()
   y_min, y_max = x[:,1].min(), x[:,1].max()

   step = 0.02

   xx, yy = np.meshgrid(
      np.arange(x_min, x_max, step),
      np.arange(y_min, y_max, step)
   )
   Z = clf.predict(np.c_[xx.ravel(), yy.ravel()])
   Z = Z.reshape(xx.shape)

   ax.contourf(xx, yy, Z, cmap=cmap, alpha=0.25)
   ax.contour(xx, yy, Z, colors='k', linewidths=0.7)
   ax.scatter(x[:,0], x[:,1], c=y, edgecolors='k')
   ax.set_title(title)
   ax.set_xlabel(feature_names[0])
   ax.set_ylabel(feature_names[1])
  1. 然后,我们将数据分为训练集和测试集:
from sklearn.model_selection import train_test_split
df_train, df_test = train_test_split(df, test_size=0.3, random_state=22)
  1. 为了方便可视化,我们将使用两个特征。在下面的代码中,我们将训练一个逻辑回归模型和一个决策树模型,然后在相同数据上训练后比较它们的决策边界:
from sklearn.metrics import accuracy_score
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier

fig, axs = plt.subplots(1, 2, figsize=(12, 6))

two_features = ['petal width (cm)', 'petal length (cm)']

clf_lr = LogisticRegression()
clf_lr.fit(df_train[two_features], df_train['target'])
accuracy = accuracy_score(
    df_test['target'], 
    clf_lr.predict(df_test[two_features])
)
plot_decision_boundary(
    clf_lr, df_test[two_features], df_test['target'], ax=axs[0], 
    title=f'Logistic Regression Classifier\nAccuracy: {accuracy:.2%}'
)

clf_dt = DecisionTreeClassifier(max_depth=3)
clf_dt.fit(df_train[two_features], df_train['target'])
accuracy = accuracy_score(
    df_test['target'], 
    clf_dt.predict(df_test[two_features])
)
plot_decision_boundary(
    clf_dt, df_test[two_features], df_test['target'], ax=axs[1], 
    title=f'Decision Tree Classifier\nAccuracy: {accuracy:.2%}'
)

fig.show()

运行此代码将给我们以下图表:

在上面的图表中,观察到以下内容:

  • 这次,当只使用两个特征时,逻辑回归模型的表现并不好。然而,我们关心的是边界的形状。

  • 很明显,左侧的边界不像右侧那样是水平和垂直的线。虽然右侧的边界可以由多个线段组成,但左侧的边界只能由连续的线组成。

了解额外的线性分类器

在结束本章之前,有必要强调一些额外的线性分类算法:

  • SGD是一种多功能求解器。如前所述,它可以执行逻辑回归分类,以及 SVM 和感知机分类,这取决于使用的损失函数。它还允许进行正则化惩罚。

  • ride分类器将类别标签转换为1-1,并将问题视为回归任务。它还能够很好地处理非二分类任务。由于其设计,它使用不同的求解器,因此在处理大量类别时,它可能会更快地学习,值得尝试。

  • 线性支持向量分类LinearSVC)是另一个线性模型。与对数损失不同,它使用hinge函数,旨在找到类别边界,使得每个类别的样本尽可能远离边界。这与支持向量机(SVM)不同。与线性模型相反,SVM 是一种非线性算法,因为它采用了所谓的核技巧。SVM 不再像几十年前那样广泛使用,且超出了本书的范围。

总结

线性模型无处不在。它们的简单性以及提供的功能——例如正则化——使得它们在实践者中非常受欢迎。它们还与神经网络共享许多概念,这意味着理解它们将有助于你在后续章节的学习。

线性通常不是限制因素,只要我们能通过特征转换发挥创意。此外,在更高维度下,线性假设可能比我们想象的更常见。这就是为什么建议总是从线性模型开始,然后再决定是否需要选择更高级的模型。

话虽如此,有时确实很难确定线性模型的最佳配置或决定使用哪种求解器。在本章中,我们学习了如何使用交叉验证来微调模型的超参数。我们还了解了不同的超参数和求解器,并获得了何时使用每一个的提示。

到目前为止,在我们处理的前两章中的所有数据集上,我们很幸运数据格式是正确的。我们只处理了没有缺失值的数值数据。然而,在实际场景中,这种情况很少见。

在下一章,我们将学习更多关于数据预处理的内容,以便我们能够无缝地继续处理更多的数据集和更高级的算法。

第四章:准备数据

在前一章中,我们处理的是干净的数据,其中所有值都可以使用,所有列都有数值,当面对过多特征时,我们有正则化技术作为支持。在现实生活中,数据往往不像你期望的那样干净。有时候,即使是干净的数据,也可能会以某种方式进行预处理,以使我们的机器学习算法更容易处理。在本章中,我们将学习以下数据预处理技术:

  • 填充缺失值

  • 编码非数值型列

  • 改变数据分布

  • 通过特征选择减少特征数量

  • 将数据投影到新维度

填充缺失值

“在没有数据之前,理论化是一个重大错误。”

– 夏洛克·福尔摩斯

为了模拟现实生活中数据缺失的情况,我们将创建一个数据集,记录人的体重与身高的关系。然后,我们将随机删除height列中 75%的值,并将它们设置为NaN

df = pd.DataFrame(
    {
        'gender': np.random.binomial(1, .6, 100),
        'height': np.random.normal(0, 10, 100), 
        'noise': np.random.normal(0, 2, 100), 
    }
)

df['height'] = df['height'] + df['gender'].apply(
    lambda g: 150 if g else 180
)
df['height (with 75% NaN)'] = df['height'].apply(
    lambda x: x if np.random.binomial(1, .25, 1)[0] else np.nan
)
df['weight'] = df['height'] + df['noise'] - 110

我们在这里使用了一个带有底层二项/伯努利分布的随机数生成器来决定每个样本是否会被删除。该分布的n值设置为1——也就是伯努利分布——而p值设置为0.25——也就是说,每个样本有 25%的机会保留。当生成器返回的值为0时,该样本被设置为NaN。正如你所看到的,由于随机生成器的性质,最终NaN值的百分比可能会略高或略低于 75%。

**这是我们刚刚创建的 DataFrame 的前四行。这里只显示了有缺失值的height列和体重:

我们还可以使用以下代码来检查每一列缺失值的百分比:

df.isnull().mean()

当我运行前一行时,77%的值是缺失的。请注意,由于使用了随机数生成器,您可能得到的缺失值比例与我这里得到的有所不同。

到目前为止我们看到的所有回归器都无法接受包含所有NaN值的数据。因此,我们需要将这些缺失值转换为某些值。决定用什么值来填补缺失值是数据填充过程的任务。

有不同类型的填充技术。我们将在这里尝试它们,并观察它们对我们体重估计的影响。请记住,我们恰好知道原始的height数据没有任何缺失值,而且我们知道使用岭回归器对原始数据进行回归会得到3.4的 MSE 值。现在就暂时将这个信息作为参考。

将缺失值设置为 0

一种简单的方法是将所有缺失值设置为0。以下代码将使我们的数据再次可用:

df['height (75% zero imputed)'] = df['height (with 75% NaN)'].fillna(0)

在新填充的列上拟合岭回归器将得到365的 MSE 值:

from sklearn.linear_model import Ridge
from sklearn.metrics import mean_squared_error

reg = Ridge()
x, y = df[['height (75% zero imputed)']], df['weight']
reg.fit(x, y)
mean_squared_error(y, reg.predict(x))

尽管我们能够使用回归器,但其误差与我们的参考场景相比仍然很大。为了理解零填充的效果,让我们绘制填充后的数据,并使用回归器的系数来查看训练后创建的线条类型。让我们还绘制原始数据进行比较。我相信生成以下图表的代码对您来说现在已经很简单了,所以我会跳过它:

到目前为止,我们已经知道线性模型只能将连续直线拟合到数据上(或在更高维度情况下的超平面)。我们还知道0不是任何人的合理身高。尽管如此,在零填充的情况下,我们引入了一堆身高为0、体重在1090左右的值。这显然让我们的回归器感到困惑,正如我们在右侧图表中所看到的。

非线性回归器(例如决策树)将能够比其线性对应更好地处理这个问题。实际上,对于基于树的模型,我建议您尝试将x中的缺失值替换为数据中不存在的值。例如,在这种情况下,您可以尝试将身高设置为-1

将缺失值设置为均值

统计均值的另一个名称是期望值。这是因为均值充当数据的有偏估计。话虽如此,用列的均值值替换缺失值听起来是一个合理的想法。

在本章中,我正在整个数据集上拟合一个回归器。我不关心将数据拆分为训练集和测试集,因为我主要关心回归器在填充后的行为。尽管如此,在现实生活中,您只需了解训练集的均值,并使用它来填补训练集和测试集中的缺失值。

scikit-learn 的SimpleImputer功能使得可以从训练集中找出均值并将其用于填补训练集和测试集。它通过我们喜爱的fit()transform()方法来实现。但在这里我们将坚持一步fit_transform()函数,因为我们只有一个数据集:

from sklearn.impute import SimpleImputer
imp = SimpleImputer(missing_values=np.nan, strategy='mean')
df['height (75% mean imputed)'] = imp.fit_transform(
    df[['height (with 75% NaN)']]
)[:, 0]

这里我们需要填补一个单列,这就是为什么我在填补后使用[:, 0]来访问其值。

岭回归器将给我们一个 MSE 值为302。为了理解这种改进来自哪里,让我们绘制模型的决策并与零填充前进行比较:

显然,模型的决策现在更有意义了。您可以看到虚线与实际未填充数据点重合。

除了使用均值作为策略外,该算法还可以找到训练数据的中位数。如果您的数据存在异常值,中位数通常是一个更好的选择。在非数值特征的情况下,您应该选择most_frequent选项作为策略。

使用有依据的估算填充缺失值

对所有缺失值使用相同的值可能并不理想。例如,我们知道数据中包含男性和女性样本,每个子样本的平均身高不同。IterativeImputer() 方法是一种可以利用相邻特征来估算某个特征缺失值的算法。在这里,我们使用性别信息来推断填充缺失身高时应使用的值:

# We need to enable the module first since it is an experimental one 
from sklearn.experimental import enable_iterative_imputer
from sklearn.impute import IterativeImputer
imp = IterativeImputer(missing_values=np.nan)
df['height (75% iterative imputed)'] = imp.fit_transform(
    df[['height (with 75% NaN)', 'gender']]
)[:, 0]

现在我们有了两个用于填充的数据值:

这次 MSE 值为 96。这个策略显然是胜者。

这里只有一个特征有缺失值。在多个特征的情况下,IterativeImputer() 方法会遍历所有特征。它使用除一个特征外的所有特征通过回归预测剩余特征的缺失值。完成所有特征遍历后,它可能会多次重复此过程,直到值收敛。该方法有一些参数可以决定使用哪种回归算法、遍历特征时的顺序以及允许的最大迭代次数。显然,对于较大的数据集和更多缺失特征,计算开销可能较大。此外,IterativeImputer() 的实现仍处于实验阶段,其 API 未来可能会发生变化。

拥有过多缺失值的列对我们的估算几乎没有信息价值。我们可以尽力填充这些缺失值;然而,有时放弃整列并完全不使用它,尤其是当大多数值都缺失时,是最好的选择。

编码非数值列

"每一次解码都是一次编码。"

– 大卫·洛奇(David Lodge)

非数值数据是算法实现无法处理的另一个问题。除了核心的 scikit-learn 实现之外,scikit-learn-contrib 还列出了多个附加项目。这些项目为我们的数据工具库提供了额外的工具,以下是它们如何描述自己的:

"scikit-learn-contrib 是一个 GitHub 组织,旨在汇集高质量的 scikit-learn 兼容项目。它还提供了一个模板,用于建立新的 scikit-learn 兼容项目。"

我们将在这里使用一个项目——category_encoders。它允许我们将非数值数据编码成不同的形式。首先,我们将使用 pip 安装这个库,命令如下:

          pip install category_encoders

在深入了解不同的编码策略之前,让我们首先创建一个虚拟数据集来进行实验:

df = pd.DataFrame({
    'Size': np.random.choice(['XS', 'S', 'M', 'L', 'XL', 'XXL'], 10),
    'Brand': np.random.choice(['Nike', 'Puma', 'Adidas', 'Le Coq', 'Reebok'], 10),
})

然后我们将其分成两个相等的部分:

from sklearn.model_selection import train_test_split
df_train, df_test = train_test_split(df, test_size=0.5)

请记住,核心的 scikit-learn 库实现了我们将在此看到的两种编码器——preprocessing.OneHotEncoderpreprocessing.OrdinalEncoder。不过,我更喜欢 category_encoders 的实现,因为它更丰富、更灵活。

现在,让我们进入第一个也是最流行的编码策略——独热编码(one-hot encoding)。

独热编码

一热编码,也叫虚拟编码,是处理类别特征的最常见方法。如果你有一列包含redgreenblue值的数据,那么将它们转换为三列——is_redis_greenis_blue——并根据需要填充这些列的值为 1 和 0,看起来是很合乎逻辑的。

以下是使用OneHotEncoder解码数据集的代码:

from category_encoders.one_hot import OneHotEncoder
encoder = OneHotEncoder(use_cat_names=True, handle_unknown='return_nan')
x_train = encoder.fit_transform(df_train)
x_test = encoder.transform(df_test)

我设置了use_cat_names=True,以在分配列名时使用编码后的值。handle_unknown参数告诉编码器如何处理测试集中在训练集中不存在的值。例如,我们的训练集中没有XSS尺码的衣物,也没有Adidas品牌的衣物。这就是为什么测试集中的这些记录会被转换为NaN

你仍然需要填补那些NaN值。否则,我们可以通过将handle_unknown设置为value来将这些值设置为0

一热编码推荐用于线性模型和K-最近邻KNN)算法。尽管如此,由于某一列可能会扩展成过多列,并且其中一些列可能是相互依赖的,因此建议在这里使用正则化或特征选择。我们将在本章后面进一步探讨特征选择,KNN 算法将在本书后面讨论。

序数编码

根据你的使用场景,你可能需要以反映顺序的方式对类别值进行编码。如果我要使用这些数据来预测物品的需求量,那么我知道,物品尺码越大,并不意味着需求量越高。因此,对于这些尺码,一热编码仍然适用。然而,如果我们要预测每件衣物所需的材料量,那么我们需要以某种方式对尺码进行编码,意味着XL需要比L更多的材料。在这种情况下,我们关心这些值的顺序,因此我们使用OrdinalEncoder,如下所示:

from category_encoders.ordinal import OrdinalEncoder

oencoder = OrdinalEncoder(
  mapping= [
    {
      'col': 'Size', 
      'mapping': {'XS': 1, 'S': 2, 'M': 3, 'L': 4, 'XL': 5}
    }
  ]
)

df_train.loc[
  :, 'Size [Ordinal Encoded]'
] = oencoder.fit_transform(
  df_train['Size']
)['Size'].values
df_test.loc[
  :, 'Size [Ordinal Encoded]'
] = oencoder.transform(
  df_test['Size']
)['Size'].values

请注意,我们必须手动指定映射。我们希望将XS编码为1S编码为2,依此类推。因此,我们得到了以下的 DataFrame:

这次,编码后的数据只占用了一列,而训练集中缺失的值被编码为-1

这种编码方法推荐用于非线性模型,例如决策树。至于线性模型,它们可能会将XL(编码为5)解释为XS(编码为1)的五倍。因此,对于线性模型,一热编码仍然是首选。此外,手动设置有意义的映射可能会非常耗时。

目标编码

在有监督学习场景中,编码类别特征的一种显而易见的方法是基于目标值进行编码。假设我们要估计一件衣物的价格。我们可以将品牌名称替换为我们训练数据集中相同品牌所有物品的平均价格。然而,这里有一个明显的问题。假设某个品牌在我们的训练集中只出现一次或两次。不能保证这几次出现能够很好地代表该品牌的价格。换句话说,单纯使用目标值可能会导致过度拟合,最终模型在处理新数据时可能无法很好地泛化。这就是为什么 category_encoders 库提供了多种目标编码变体的原因;它们都有相同的基本目标,但每种方法都有不同的处理上述过度拟合问题的方式。以下是一些这些实现的示例:

  • 留一法交叉验证

  • 目标编码器

  • CatBoost 编码器

  • M 估计器

留一法可能是列出的方法中最著名的一种。在训练数据中,它将原始数据中的类别值替换为所有具有相同类别值但不包括该特定原始数据行的其他行的目标值均值。对于测试数据,它只使用从训练数据中学习到的每个类别值对应的目标均值。此外,编码器还有一个名为 sigma 的参数,允许您向学习到的均值添加噪声,以防止过度拟合。

同质化列的尺度

不同的数值列可能具有不同的尺度。一列的年龄可能在十位数,而它的薪资通常在千位数。如我们之前所见,将不同的列调整到相似的尺度在某些情况下是有帮助的。以下是一些建议进行尺度调整的情况:

  • 它可以帮助梯度下降法的求解器更快地收敛。

  • 它是 KNN 和主成分分析PCA)等算法所必需的。

  • 在训练估计器时,它将特征放置在一个可比的尺度上,这有助于对比它们的学习系数。

在接下来的章节中,我们将探讨最常用的标准化器。

标准标准化器

它通过将特征的均值设置为 0,标准差设置为 1,将特征转换为正态分布。此操作如下,首先从每个值中减去该列的均值,然后将结果除以该列的标准差:

标准化器的实现可以如下使用:

from sklearn.preprocessing import StandardScaler

scaler = StandardScaler()
x_train_scaled = scaler.fit_transform(x_train)
x_test_scaled = scaler.transform(x_test)

一旦拟合完成,您还可以通过 mean_var_ 属性查找训练数据中每一列的均值和方差。在存在异常值的情况下,标准化器无法保证特征尺度的平衡。

MinMax 标准化器

这会将特征压缩到一个特定的范围,通常是在01之间。如果你需要使用不同的范围,可以通过feature_range参数来设置。这个标准化方法的工作方式如下:

from sklearn.preprocessing import MinMaxScaler

scaler = MinMaxScaler(feature_range=(0,1))
x_train_scaled = scaler.fit_transform(x_train)
x_test_scaled = scaler.transform(x_test)

一旦拟合,你还可以通过data_min_data_max_属性找出训练数据中每一列的最小值和最大值。由于所有样本都被限制在预定范围内,异常值可能会迫使正常值被压缩到该范围的一个小子集内。

**## RobustScaler

这与标准缩放器相似,但使用数据分位数来增强对异常值对均值和标准差影响的鲁棒性。如果数据中存在异常值,建议使用这个方法,使用方式如下:

from sklearn.preprocessing import RobustScaler

scaler = RobustScaler()
x_train_scaled = scaler.fit_transform(x_train)
x_test_scaled = scaler.transform(x_test)

还有其他的标准化方法;不过,我这里只涵盖了最常用的标准化方法。在本书中,我们将使用上述标准化方法。所有标准化方法都有一个inverse_transform()方法,所以如果需要,你可以恢复特征的原始尺度。此外,如果你无法一次性将所有训练数据加载到内存中,或者数据是按批次来的,那么你可以在每一批次上调用标准化方法的partial_fit()方法,而不是对整个数据集一次性调用fit()方法。

选择最有用的特征

“更多的数据,比如在过马路时注意周围人们的眼睛颜色,可能会让你错过那辆大卡车。”

– 纳西姆·尼古拉斯·塔勒布

在前面的章节中,我们已经看到,特征过多可能会降低模型的表现。所谓的维度诅咒可能会对算法的准确性产生负面影响,特别是当训练样本不足时。此外,这也可能导致更多的训练时间和更高的计算需求。幸运的是,我们也学会了如何对我们的线性模型进行正则化,或是限制决策树的生长,以应对特征过多的影响。然而,有时我们可能会使用一些无法进行正则化的模型。此外,我们可能仍然需要去除一些无意义的特征,以减少算法的训练时间和计算需求。在这些情况下,特征选择作为第一步是明智的选择。

根据我们处理的是标记数据还是未标记数据,我们可以选择不同的特征选择方法。此外,一些方法比其他方法计算开销更大,有些方法能带来更准确的结果。在接下来的部分中,我们将看到如何使用这些不同的方法,并且为了演示这一点,我们将加载 scikit-learn 的wine数据集:

from sklearn import datasets

wine = datasets.load_wine()
df = pd.DataFrame(
    wine.data,
    columns=wine.feature_names
)
df['target'] = pd.Series(
    wine.target
)

然后,我们像平常一样拆分数据:

from sklearn.model_selection import train_test_split
df_train, df_test = train_test_split(df, test_size=0.4)

x_train = df_train[wine.feature_names]
x_test = df_test[wine.feature_names]

y_train = df_train['target']
y_test = df_test['target']

wine数据集有 13 个特征,通常用于分类任务。在接下来的部分中,我们将探索哪些特征比其他特征更不重要。

VarianceThreshold

如果你还记得,当我们使用PolynomialFeatures转换器时,它添加了一列,所有的值都被设置为1。此外,像独热编码这样的类别编码器,可能会导致几乎所有值都为0的列。在现实场景中,通常也会有某些列,列中的数据完全相同或几乎相同。方差是衡量数据集变异量的最直观方法,因此VarianceThreshold允许我们为每个特征设置最小的方差阈值。在以下代码中,我们将方差阈值设置为0,然后它会遍历训练集,学习哪些特征应该保留:

from sklearn.feature_selection import VarianceThreshold
vt = VarianceThreshold(threshold=0)
vt.fit(x_train)

和我们其他所有模块一样,这个模块也提供了常见的fit()transform()fit_transform()方法。然而,我更倾向于不在这里使用它们,因为我们已经给我们的列命名了,而transform()函数不会尊重我们所赋予的名称。因此,我更喜欢使用另一种方法叫做get_support()。这个方法返回一个布尔值列表,任何False值对应的列应该被移除,基于我们设置的阈值。以下是我如何使用pandas库的iloc函数移除不必要的特征:

x_train = x_train.iloc[:, vt.get_support()]
x_test = x_test.iloc[:, vt.get_support()]

我们还可以打印特征名并根据它们的方差进行排序,如下所示:

pd.DataFrame(
    {
        'Feature': wine.feature_names,
        'Variance': vt.variances_,
    }
).sort_values(
    'Variance', ascending=True
)

这将给我们以下表格:

我们可以看到,我们的特征没有零方差;因此,没有特征会被移除。你可以决定使用更高的阈值——例如,将阈值设置为0.05,这将移除nonflavanoid_phenols特征。然而,让我列出这个模块的主要优缺点,帮助你决定何时以及如何使用它:

  • 与我们接下来会看到的其他特征选择方法不同,这个方法在选择特征时不使用数据标签。在处理无标签数据时,特别是在无监督学习场景中,这非常有用。

  • 它不依赖标签的特性也意味着,一个低方差的特征可能仍然与我们的标签高度相关,去除它可能会是一个错误。

  • 方差和均值一样,依赖于数据的尺度。一个从110的数字列表的方差为8.25,而10, 20, 30,...100的列表的方差为825.0。我们可以从proline的方差中清楚地看到这一点。这使得表格中的数字不可比,并且很难选择正确的阈值。一个思路是,在计算方差之前对数据进行缩放。然而,记住你不能使用StandardScaler,因为它故意统一了所有特征的方差。所以,我认为在这里使用MinMaxScaler更有意义。

总结来说,我发现方差阈值对于去除零方差特征非常方便。至于剩余的特征,我会让下一个特征选择算法来处理它们,特别是在处理标记数据时。

过滤器

现在我们的数据有标签了,因此利用每个特征与标签之间的相关性来决定哪些特征对我们的模型更有用是合乎逻辑的。这类特征选择算法处理每个独立的特征,并根据与标签的关系来衡量其有用性;这种算法叫做 filters。换句话说,算法对 x 中的每一列使用某种度量来评估它在预测 y 时的有效性。有效的列被保留,而其他列则被移除。衡量有效性的方式就是区分不同筛选器的关键。为了更清楚地说明,我将重点讨论两个筛选器,因为每个筛选器都根植于不同的科学领域,理解它们有助于为未来的概念打下良好的基础。这两个概念是 ANOVA (F 值)互信息

f-regression 和 f-classif

如其名称所示,f_regression 用于回归任务中的特征选择。f_classif 是它的分类兄弟。f_regression 根植于统计学领域。它在 scikit-learn 中的实现使用皮尔逊相关系数来计算 xy 中每列之间的相关性。结果会转换为 F 值和 P 值,但我们先不谈这个转换,因为相关系数才是关键。我们首先从每列的所有值中减去均值,这与我们在 StandardScaler 中所做的类似,但不需要将值除以标准差。接着,我们使用以下公式计算相关系数:

由于已减去均值,当一个实例高于其列的均值时,xy 的值为正,而当其低于均值时,则为负。因此,这个方程会被最大化,使得每当 x 高于平均值时,y 也高于平均值,而每当 x 低于平均值时,y 也随之下降。这个方程的最大值是1。因此,我们可以说 xy 完全相关。当 xy 顽固地朝相反方向变化时,即呈负相关,方程值为-1。零结果意味着 xy 不相关(即独立或正交)。

通常,统计学家会以不同的方式书写这个方程。通常会将* x y 减去均值的事实写成方程的一部分。然后,分子显然是协方差,分母是两个方差的乘积。然而,我故意选择不遵循统计学惯例,以便我们的自然语言处理的朋友们在意识到这与余弦相似度的方程完全相同时,能够感到熟悉。在这种情况下, x y 被视为向量,分子是它们的点积,分母是它们的模长的乘积。因此,当它们之间的角度为0(余弦0 = 1)时,两个向量是完全相关的(方向相同)。相反,当它们彼此垂直时,它们是独立的,因此被称为正交。这种可视化解释的一个要点是,这个度量只考虑了 x y *之间的线性关系。

对于分类问题,会执行单因素方差分析(ANOVA)测试。它比较不同类别标签之间的方差与每个类别内部的方差。与回归分析类似,它衡量特征与类别标签之间的线性依赖关系。

现在先不谈太多理论;让我们使用 f_classif 来选择数据集中最有用的特征:

from sklearn.feature_selection import f_classif
f, p = f_classif(x_train, y_train)

让我们暂时将结果中的fp值放到一边。在解释特征选择的互信息方法之后,我们将使用这些值来对比这两种方法。

互信息

这种方法起源于一个不同的科学领域,叫做信息理论。该领域由克劳德·香农(Claude Shannon)提出,旨在解决与信号处理和数据压缩相关的问题。当我们发送一个由零和一组成的消息时,我们可能知道这个消息的确切内容,但我们能否真正量化这个消息所携带的信息量呢?香农通过借用热力学中的概念来解决这个问题。进一步发展出来的是互信息的概念。它量化了通过观察一个变量时,获得关于另一个变量的信息量。互信息的公式如下:

在解析这个方程之前,请记住以下几点:

  • P(x)x 取某个特定值的概率,P(y) 也是 y 取某个特定值的概率。

  • P(x, y) 被称为联合概率,它表示 xy 同时取特定一对值的概率。

  • P(x, y) 只有在 xy 独立时才等于 P(x) * P(y)。否则,根据 xy 的正相关或负相关关系,它的值会大于或小于它们的乘积。

双重求和和方程的第一部分,P(x, y),是我们计算所有可能的 xy 值的加权平均值的方法。我们关心的是对数部分,它被称为点对点互信息。如果 xy 是独立的,那么这个分数等于 1,它的对数为 0。换句话说,当这两个变量不相关时,结果为 0。否则,结果的符号指示 xy 是正相关还是负相关。

下面是我们如何计算每个特征的互信息系数:

from sklearn.feature_selection import mutual_info_classif
mi = mutual_info_classif(x_train, y_train)

与皮尔逊相关系数不同,互信息能够捕捉任何类型的相关性,无论其是否是线性的。

比较并使用不同的过滤器

现在,我们来将互信息得分与 F 值进行比较。为此,我们将两者放入一个 DataFrame,并使用 pandas 的样式功能在 DataFrame 内绘制柱状图,如下所示:

pd.DataFrame(
  {
    'Feature': wine.feature_names,
    'F': f,
    'MI': mi,
  }
).sort_values(
  'MI', ascending=False
).style.bar(
  subset=['F', 'MI'], color='grey'
)

这为我们提供了以下的 DataFrame:

如你所见,它们在特征重要性排序上大体一致,但有时仍会有所不同。我使用这两种方法分别选择了四个最重要的特征,然后比较了 逻辑回归 分类器与决策树分类器在每种特征选择方法下的准确性。以下是训练集的结果:

如你所见,这两种选择方法分别对两种分类器的效果不同。似乎f_classif更适用于线性模型,因为它具有线性特性,而非线性模型则更倾向于捕捉非线性相关性的算法。然而,我并未找到任何文献来确认这一猜测的普遍性。

不难看出,两个度量之间有一个潜在的共同主题。分子计算的是一些变量内的信息——协方差、点积或联合概率;分母计算的是变量间的信息的乘积——方差、范数或概率。这个主题将在未来的不同话题中继续出现。有一天,我们可能会使用余弦相似度来比较两篇文档;另一天,我们可能会使用互信息来评估聚类算法。

同时评估多个特征

本章 Filters 部分所展示的特征选择方法也被认为是单变量特征选择方法,因为它们会在决定是否保留一个特征之前,单独检查每一个特征。这可能会导致以下两种问题之一:

  • 如果两个特征高度相关,我们只希望保留其中一个。然而,由于单变量特征选择的特性,它们仍然会同时被选择。

  • 如果两个特征本身并不非常有用,但它们的组合却有用,那么它们仍然会被移除,因为单变量特征选择方法的工作方式就是如此。

为了处理这些问题,我们可以决定使用以下解决方案之一:

  • 使用估计器进行特征选择:通常,回归器和分类器会在训练后给特征赋值,表示它们的重要性。因此,我们可以使用估计器的系数(或特征重要性)来添加或移除我们初始特征集中的特征。scikit-learn 的递归特征消除RFE)算法从初始特征集开始。然后,它通过每次迭代使用训练模型的系数逐步移除特征。SelectFromModel算法是一种元转换器,可以利用正则化模型来移除系数为零或接近零的特征。

  • 使用内置特征选择的估计器:换句话说,这意味着使用正则化估计器,如 Lasso,其中特征选择是估计器目标的一部分。

总结来说,像使用方差阈值和滤波器这种方法执行起来比较快,但在特征相关性和交互作用方面有其缺点。计算开销更大的方法,如包装法,能够解决这些问题,但容易发生过拟合。

如果你问我关于特征选择的建议,个人来说,我的首选方法是在去除零方差特征后进行正则化,除非我处理的是大量特征,在这种情况下,训练整个特征集不可行。对于这种情况,我会使用单变量特征选择方法,同时小心去除那些可能有用的特征。之后,我仍然会使用正则化模型来处理任何多重共线性问题。

最终,验证一切的标准在于实际效果,通过反复试验和错误得到的实证结果可能会超越我的建议。此外,除了提高最终模型的准确性,特征选择仍然可以用来理解手头的数据。特征重要性评分仍然可以用于指导商业决策。例如,如果我们的标签表示用户是否会流失,我们可以提出一个假设,认为得分最高的特征对流失率的影响最大。然后,我们可以通过调整产品的相关部分来进行实验,看看是否能够减少流失率。

总结

从事与数据相关的职业需要有应对不完美情况的倾向。处理缺失值是我们无法忽视的一步。因此,我们从学习不同的数据填补方法开始这一章。此外,适用于某一任务的数据可能不适用于另一个任务。这就是为什么我们学习了特征编码以及如何将类别数据和顺序数据转换为适合机器学习需求的形式。为了帮助算法表现得更好,我们可能需要重新调整数值特征的尺度。因此,我们学习了三种缩放方法。最后,数据过多可能会成为模型的诅咒,因此特征选择是应对维度灾难的一个有效方法,常与正则化一起使用。

贯穿这一章的一个主要主题是简单快速的方法与更为深思熟虑且计算开销大的方法之间的权衡,这些方法可能会导致过拟合。知道该使用哪些方法需要了解它们背后的理论,同时也需要有实验和迭代的意愿。因此,我决定在必要时深入探讨理论背景,这不仅有助于你明智地选择方法,还能让你未来能够提出自己的方法。

既然我们已经掌握了主要的数据预处理工具,接下来就可以进入下一个算法——KNN。****

第五章:使用最近邻进行图像处理

在本章及随后的章节中,我们将采取不同的方法。最近邻算法将在这里担任辅助角色,而图像处理将是本章的主要内容。我们将从加载图像开始,并使用 Python 将它们表示为适合机器学习算法处理的格式。我们将使用最近邻算法进行分类和回归。我们还将学习如何将图像中的信息压缩到更小的空间中。这里解释的许多概念是可转移的,并且可以通过稍微调整后用于其他算法。稍后,在第七章神经网络——深度学习的到来中,我们将基于在这里获得的知识,继续使用神经网络进行图像处理。在本章中,我们将涵盖以下主题:

  • 最近邻

  • 加载和显示图像

  • 图像分类

  • 使用自定义距离

  • 使用最近邻进行回归

  • 降维我们的图像数据

最近邻

“我们通过示例和直接经验来学习,因为口头指导的充分性是有限的。”

– 马尔科姆·格拉德威尔

好像马尔科姆·格拉德威尔在前述引用中解释 K 近邻算法;我们只需要将“口头指导”替换为“数学方程”。像线性模型这样的情况中,训练数据用于学习一个数学方程来模拟数据。一旦模型被学习,我们可以轻松地将训练数据搁置一旁。在最近邻算法中,数据本身就是模型。每当遇到一个新的数据样本时,我们将其与训练数据集进行比较。我们定位到训练集中与新样本最近的 K 个样本,然后使用这些 K 个样本的类别标签为新样本分配标签。

这里有几点需要注意:

  • 训练的概念在这里并不存在。与其他算法不同,在其他算法中,训练时间取决于训练数据的数量,而在最近邻算法中,计算成本大部分花费在预测时的最近邻计算上。

  • 最近关于最近邻算法的大部分研究都集中在寻找在预测时快速搜索训练数据的最佳方法。

  • 最近意味着什么?在本章中,我们将学习用于比较不同数据点之间距离的不同度量方法。两个数据点是否接近彼此,取决于使用的距离度量标准。

  • K是什么?我们可以将一个新数据点与训练集中的 1、2、3 或 50 个样本进行比较。我们决定比较的样本数量就是K,我们将看到不同的K值如何影响算法的行为。

在使用最近邻算法进行图像分类之前,我们需要先学习如何处理图像。在接下来的章节中,我们将加载并展示机器学习和图像处理领域中最常用的图像数据集之一。

在查找一个样本的最近邻时,可以将其与所有其他训练样本进行比较。这是一种简单的暴力方法,当训练数据集规模增大时,效果并不好。对于更大的数据集,一种更高效的方法是将训练样本存储在一个特定的数据结构中,该数据结构经过优化以便于搜索。K-D 树和球树是两种可用的数据结构。这两种数据结构通过leaf_size参数进行调整。当其值接近训练集的大小时,K-D 树和球树就变成了暴力搜索。相反,将叶子大小设置为1会在遍历树时引入大量开销。默认的叶子大小为30,对于许多样本大小来说,这是一个不错的折中值。

加载并显示图像

“照片是二维的。我在四维空间中工作。”

– Tino Sehgal

当被问到图像的维度时,摄影师、画家、插画家以及几乎地球上所有人都会认为图像是二维的物体。只有机器学习从业者会从不同的角度看待图像。对我们来说,黑白图像中的每个像素都是一个单独的维度。随着彩色图像的出现,维度会进一步增加,但那是后话。我们将每个像素视为一个单独的维度,以便我们能够将每个像素及其值当作定义图像的独特特征,与其他像素(特征)一起处理。所以,和Tino Sehgal不同,我们有时会处理 4000 维。

修改后的国家标准与技术研究院MNIST)数据集是一个手写数字的集合,通常用于图像处理。由于其受欢迎程度,它被包含在scikit-learn中,我们可以像通常加载其他数据集一样加载它:

from sklearn.datasets import load_digits
digits = load_digits()

这个数据集包含从09的数字。我们可以通过以下方式访问它们的目标(标签):

digits['target']
# Output: array([0, 1, 2, ..., 8, 9, 8])

类似地,我们可以加载像素值,如下所示:

digits['data']
# Output: 
# array([[ 0., 0., 5., ..., 0., 0., 0.], 
#  [ 0., 0., 0., ..., 10., 0., 0.], 
#  ..., 
#  [ 0., 0., 2., ..., 12., 0., 0.], 
#  [ 0., 0., 10., ..., 12., 1., 0.]])

每一行是一个图像,每一个整数是一个像素值。在这个数据集中,像素值的范围在016之间。数据集的形状(digits['data'].shape)是1,797 x 64。换句话说,我们有 1,797 张方形的图片,每张图片有 64 个像素(宽度 = 高度 = 8)。

知道了这些信息后,我们可以创建以下函数来显示图像。它接受一个 64 个值的数组,并将其重塑成一个 8 行 8 列的二维数组。它还使用图像的对应目标值,在数字上方显示。matplotlib的坐标轴(ax)被传入,这样我们就可以在其上显示图像:

def display_img(img, target, ax):
    img = img.reshape((8, 8))
    ax.imshow(img, cmap='gray')
    ax.set_title(f'Digit: {str(target)}')
    ax.grid(False)

我们现在可以使用刚才创建的函数来显示数据集中的前八个数字:

fig, axs = plt.subplots(1, 8, figsize=(15, 10))

for i in range(8):
    display_img(digits['data'][i], digits['target'][i], axs[i])

fig.show()

数字显示如下:

能够显示数字是一个很好的第一步。接下来,我们需要将它们转换为我们通常的训练和测试格式。这次,我们希望将每张图片保留为一行,因此不需要将其重塑为8 x 8矩阵:

from sklearn.model_selection import train_test_split
x, y = digits['data'], digits['target']
x_train, x_test, y_train, y_test = train_test_split(x, y)

到此为止,数据已经准备好用于图像分类算法。通过学习在给定一堆像素时预测目标,我们已经离让计算机理解手写文本更近了一步。

图像分类

现在我们已经准备好了数据,可以使用最近邻分类器来预测数字,如下所示:

from sklearn.neighbors import KNeighborsClassifier

clf = KNeighborsClassifier(n_neighbors=11, metric='manhattan')
clf.fit(x_train, y_train)
y_test_pred = clf.predict(x_test)

对于这个例子,我将n_neighbors设置为11metric设置为manhattan,意味着在预测时,我们将每个新样本与 11 个最接近的训练样本进行比较,使用曼哈顿距离来评估它们的接近程度。稍后会详细讲解这些参数。该模型在测试集上的预测准确率为 96.4%。这听起来可能很合理,但很抱歉告诉你,这对于这个特定的数据集来说并不是一个很棒的得分。无论如何,我们继续深入分析模型的表现。

使用混淆矩阵理解模型的错误

当处理具有 10 个类别标签的数据集时,单一的准确率得分只能告诉我们一些信息。为了更好地理解哪些数字比其他数字更难猜测,我们可以打印出模型的混淆矩阵。这是一个方阵,其中实际标签作为行显示,预测标签作为列显示。然后,每个单元格中的数字表示落入该单元格的测试实例。让我现在创建它,很快你就能看得更清楚。plot_confusion_matrix函数需要分类器实例,以及测试的xy值,才能显示矩阵:

from sklearn.metrics import plot_confusion_matrix
plot_confusion_matrix(clf, x_test, y_test, cmap='Greys')

一旦调用,该函数会在内部对测试数据运行模型,并显示以下矩阵:

理想情况下,所有单元格应为零,除了对角线上的单元格。落入对角线单元格意味着样本被正确标记。然而,这里只有少数几个非零单元格。位于第 8 行和第 1 列交点的四个样本表明,我们的模型将四个样本分类为1,而它们的实际标签是8。很可能,它们是看起来像 1 的过于瘦弱的 8。对于其余的非对角线非零单元格,也可以得出相同的结论。

选择合适的度量标准

我们使用的图像只是数字列表(向量)。距离度量决定了一个图像是否接近另一个图像。这同样适用于非图像数据,其中距离度量用于决定一个样本是否接近另一个样本。两种常用的度量标准是曼哈顿距离和欧几里得距离:

名称 曼哈顿(L1 范数) 欧几里得(L2 范数)
公式

很可能,曼哈顿距离的公式会让你想起平均绝对误差和 L1 正则化,而欧几里得距离则类似于均方误差和 L2 正则化。这种相似性很好地提醒我们,许多概念都来源于共同的思想:

对于曼哈顿距离,A 和 C 之间的距离是通过从 A 到 D,再从 D 到 C 来计算的。它得名于纽约的曼哈顿岛,因为那里有着分块的景观。对于欧几里得距离,A 和 C 之间的距离是通过两点之间的对角线来计算的。这两种度量有一个广义的形式,叫做闵可夫斯基距离,其公式如下:

设置p1时,我们得到曼哈顿距离,设置为2时可以得到欧几里得距离。我相信你现在可以看出,L1 和 L2 范数中的12来自哪里。为了能够比较不同p值的结果,我们可以运行以下代码。在这里,我们计算了两点之间的闵可夫斯基距离——(1, 2)(4, 6)——对于不同p值的情况:

from sklearn.neighbors import DistanceMetric

points = pd.DataFrame(
    [[1, 2], [4, 6]], columns=['x1', 'x2']
)

d = [
  (p, DistanceMetric.get_metric('minkowski', p=p).pairwise(points)[0][-1])
  for p in [1, 2, 10, 50, 100]
]

绘制结果可以显示出闵可夫斯基距离如何随p变化:

显然,闵可夫斯基距离随着p的增加而减小。对于p = 1,距离为7,即(4 - 1) + (6 - 2),而对于p = 2,距离为5,即(9 + 16)的平方根。对于更大的p值,计算出的距离接近4,也就是(6 - 2)。换句话说,随着p趋近于无穷大,距离就是所有坐标轴上点间跨度的最大值,这就是所谓的切比雪夫距离。

度量一词用来描述符合以下标准的距离度量:

它不能是负值:,并且它是对称的:

从一个点到它自身的距离是 0。它遵循以下三角不等式准则:

另一种常见的度量是余弦距离,其公式如下:

与欧几里得距离不同,余弦距离对尺度不敏感。我认为通过以下示例展示两者的区别会更好。

这里,我们取一个数字并将每个像素值乘以2

现在,我们来计算原始图像和强化图像之间的距离:

from sklearn.metrics.pairwise import (
    euclidean_distances, 
    manhattan_distances, 
    cosine_distances
)

d0 = manhattan_distances(
 [1.0 * digits['data'][0], 2.0 * digits['data'][0]]
)[0,1]

d1 = euclidean_distances(
 [1.0 * digits['data'][0], 2.0 * digits['data'][0]]
)[0,1]

d2 = cosine_distances(
 [1.0 * digits['data'][0], 2.0 * digits['data'][0]]
)[0,1]

运行上述代码给我们每个距离的值——曼哈顿距离 = 294,欧氏距离 = 55.41,余弦距离 = 0。如预期,余弦距离不关心我们用来乘以像素的常数,并且它将两个相同图像的版本视为一样。另外两个度量标准则认为这两个版本之间有更大的距离。

设置正确的 K

在选择度量标准同样重要的是知道在做决定时要听取多少个邻居的意见。你不希望询问太少的邻居,因为他们可能了解不足。你也不希望问每个人,因为远距离的邻居可能对手头的样本了解不多。正式地说,基于过少邻居做出的决定会引入方差,因为数据的轻微变化会导致不同的邻域和不同的结果。相反,基于过多邻居做出的决定是有偏的,因为它对邻域之间的差异不太敏感。请记住这一点。在这里,我使用了不同K设置的模型,并绘制了结果准确度:

偏差-方差权衡的概念将贯穿本书始终。在选择方向时,通常在训练集较小时选择使用有偏模型。如果没有足够的数据进行学习,高方差模型会过拟合。最偏差的模型是当K设置为训练样本数时。然后,所有新数据点将得到相同的预测,并被分配给与多数类相同的标签。相反,当我们有足够的数据时,较小半径内的少数最近邻是更好的选择,因为它们更有可能属于与我们新样本相同的类。

现在,我们有两个超参数需要设置:邻居数量和距离度量。在接下来的部分,我们将使用网格搜索来找到这些参数的最佳值。

使用 GridSearchCV 进行超参数调整

GridSearchCV是一种遍历所有可能的超参数组合并使用交叉验证来选择最佳超参数的方法。对于每个超参数组合,我们并不想仅限于一个准确度得分。为了更好地理解每个组合的估算器准确性,我们使用 K 折交叉验证。然后,数据会被分割成若干折,在每次迭代中,除了一个折用于训练外,剩下的折用于测试。这个超参数调优方法对所有可能的参数组合进行穷举搜索,因此使用了Grid前缀。在下面的代码中,我们给GridSearchCV传入一个包含所有需要遍历的参数值的 Python 字典,以及我们想要调优的估算器。我们还指定了将数据划分成的折数,然后调用网格搜索的fit方法并传入训练数据。请记住,从测试数据集中学习任何内容是一个不好的做法,测试集应该暂时被保留。以下是实现这一过程的代码:

from sklearn.model_selection import GridSearchCV
from sklearn.neighbors import KNeighborsClassifier

parameters = {
    'metric':('manhattan','euclidean', 'cosine'), 
    'n_neighbors': range(1, 21)
}

knn = KNeighborsClassifier()
gscv = GridSearchCV(knn, param_grid=parameters, scoring='accuracy')

gscv.fit(x_train, y_train)

完成后,我们可以通过gscv.best_params_显示通过GridSearchCV找到的最佳参数。我们还可以通过gscv.best_score_显示使用所选参数时得到的准确度。在这里,选择了euclidean距离作为metric,并将n_neighbors设置为3。在使用所选超参数时,我还得到了 98.7%的准确度得分。

我们现在可以使用得到的分类器对测试集进行预测:

from sklearn.metrics import accuracy_score

y_test_pred = gscv.predict(x_test)
accuracy_score(y_test, y_test_pred)

这让我在测试集上的准确度达到了 98.0%。幸运的是,网格搜索帮助我们通过选择最佳超参数来提高了估算器的准确度。

GridSearchCV在我们需要搜索过多的超参数并且每个超参数有太多值时,会变得计算上非常昂贵。面对这种问题时,RandomizedSearchCV可能是一个替代的解决方案,因为它在搜索过程中会随机选择超参数值。两种超参数调优算法默认都使用分类器的accuracy得分和回归器的R^(2)得分。我们可以覆盖默认设置,指定不同的度量标准来选择最佳配置。

使用自定义距离

这里的数字是以白色像素写在黑色背景上的。如果数字是用黑色像素写在白色背景上,我想没有人会有问题识别这个数字。对于计算机算法来说,情况则有些不同。让我们像往常一样训练分类器,看看当颜色反转时,它是否会遇到任何问题。我们将从训练原始图像开始:

clf = KNeighborsClassifier(n_neighbors=3, metric='euclidean')
clf.fit(x_train, y_train)
y_train_pred = clf.predict(x_train)

然后,我们创建了刚刚用于训练的反转数据版本:

x_train_inv = x_train.max() - x_train 

最近邻实现有一个叫做kneighbors的方法。给定一个样本,它会返回训练集中与该样本最接近的 K 个样本及其与给定样本的距离。我们将给这个方法传递一个反转的样本,并观察它会将哪些样本视为邻居:

img_inv = x_train_inv[0]

fig, axs = plt.subplots(1, 8, figsize=(14, 5))

display_img(img_inv, y_train[0], axs[0])

_, kneighbors_index_inv = clf.kneighbors(
    [x_train_inv[0]], 
    n_neighbors=7, 
    return_distance=True
)

for i, neighbor_index in enumerate(kneighbors_index_inv[0], 1):
    display_img(
        x_train[neighbor_index], 
        y_train[neighbor_index], 
        axs[i]
    )

为了让事情更清晰,我运行了代码两次——一次使用原始样本及其七个邻居,另一次使用反转样本及其邻居。两次运行的输出结果如下所示。正如你所看到的,与我们人类不同,算法在处理颜色反转的对抗样本时完全混淆了:

如果你想一想,根据我们使用的距离度量,一个样本及其反转版本之间不应该相差太远。虽然我们从视觉上将它们视为同一个样本,但模型却将它们视为天壤之别。话虽如此,很显然我们需要找到一种不同的方式来评估距离。由于像素的值在016之间变化,在反转样本中,所有的 16 都变成了 0,15 变成了 1,以此类推。因此,一种比较样本之间像素与016之间中点(即8)距离的度量可以帮助我们解决这里的问题。下面是如何创建这种自定义距离的方法。我们将这种新距离称为contrast_distance

from sklearn.metrics.pairwise import euclidean_distances

def contrast_distance(x1, x2):
    _x1, _x2 = np.abs(8 - x1), np.abs(8 - x2)
    d = euclidean_distances([_x1], [_x2])
    return d[0][0]

一旦定义完毕,我们可以在分类器中使用自定义度量,如下所示:

clf = KNeighborsClassifier(n_neighbors=3, metric=contrast_distance)
clf.fit(x_train, y_train)

经过这个调整后,反转对模型不再造成困扰。对于原始样本和反转样本,我们得到了相同的 89.3%准确率。我们还可以根据新的度量标准打印出七个最近邻,验证新模型已经更聪明,并且不再歧视黑色数字:

编写自定义距离时需要记住的一件事是,它们不像内置的度量那样优化,因此在预测时运行算法将会更耗费计算资源。

使用最近邻回归

到头来,我们在 MNIST 数据集中预测的目标只是 0 到 9 之间的数字。所以,我们可以改用回归算法来解决同样的问题。在这种情况下,我们的预测不再是整数,而是浮动值。训练回归器与训练分类器没有太大区别:

from sklearn.neighbors import KNeighborsRegressor
clf = KNeighborsRegressor(n_neighbors=3, metric='euclidean')
clf.fit(x_train, y_train)
y_test_pred = clf.predict(x_test)

这里是一些错误的预测结果:

第一项的三个最近邻分别是335。因此,回归器使用它们的平均值(3.67)作为预测结果。第二项和第三项的邻居分别是8, 9, 87, 9, 7。记得如果你想用分类器的评估指标来评估这个模型,应该将这些预测四舍五入并转换成整数。

更多的邻域算法

我想在进入下一部分之前,快速介绍一些 K 近邻算法的其他变种。这些算法虽然不太常用,但它们也有自己的优点和某些缺点。

半径邻居

与 K 近邻算法不同,后者允许一定数量的邻居进行投票,而在半径邻居算法中,所有在一定半径内的邻居都会参与投票过程。通过设置预定义的半径,稀疏区域的决策将基于比密集区域更少的邻居进行。这在处理不平衡类别时可能非常有用。此外,通过使用哈弗辛公式作为我们的度量标准,我们可以使用此算法向用户推荐附近的场所或加油站。通过指定算法的weights参数,半径邻居和 K 近邻都可以给予距离较近的数据点比远离的数据点更多的投票权。

最近质心分类器

正如我们所看到的,K 近邻算法将测试样本与训练集中的所有样本进行比较。这种全面搜索导致模型在预测时变得更慢。为了解决这个问题,最近中心分类器将每个类别的所有训练样本总结为一个伪样本,这个伪样本代表了该类别。这个伪样本被称为质心,因为它通常通过计算该类别中每个特征的平均值来创建。在预测时,测试样本会与所有质心进行比较,并根据与其最接近的质心所属的类别进行分类。

在下一部分,我们将使用质心算法进行训练和预测,但现在,我们将用它来生成新的数字,仅仅是为了好玩。算法的训练过程如下:

from sklearn.neighbors import NearestCentroid
clf = NearestCentroid(metric='euclidean')
clf.fit(x_train, y_train)

学到的质心存储在centroids_中。以下代码显示这些质心以及类别标签:

fig, axs = plt.subplots(1, len(clf.classes_), figsize=(15, 5))

for i, (centroid, label) in enumerate(zip(clf.centroids_, clf.classes_)):
    display_img(centroid, label, axs[i])

fig.show()

生成的数字如下所示:

这些数字在我们的数据集中并不存在。它们只是每个类别中所有样本的组合。

最近质心分类器相当简单,我相信你可以通过几行代码从头实现它。不过,它的准确度在 MNIST 数据集上不如最近邻算法。质心算法在自然语言处理领域中更为常见,在那里它更为人知的是 Rocchio(发音类似于“we will rock you”)。

最后,质心算法还有一个超参数,叫做shrink_threshold。当设置时,这可以帮助去除无关特征。

降低我们图像数据的维度

之前,我们意识到图像的维度等于图像中的像素数量。因此,我们无法将我们的 43 维 MNIST 数据集可视化。确实,我们可以单独展示每个数字,但无法看到每个图像在特征空间中的位置。这对于理解分类器的决策边界非常重要。此外,估计器的内存需求随着训练数据中特征数量的增加而增长。因此,我们需要一种方法来减少数据中特征的数量,以解决上述问题。

在这一节中,我们将介绍两种降维算法:主成分分析PCA)和邻域成分分析NCA)。在解释这些方法后,我们将使用它们来可视化 MNIST 数据集,并生成额外的样本以加入我们的训练集。最后,我们还将使用特征选择算法,从图像中去除无信息的像素。

主成分分析

"一张好照片是知道站在哪里。"

– 安塞尔·亚当斯

假设我们有以下两个特征的数据集——x1x2

你可以使用以下代码片段生成一个之前的数据框,记住,由于其随机性,数字在你的机器上可能会有所不同:

df = pd.DataFrame(
    {
        'x1': np.random.normal(loc=10.0, scale=5.0, size=8),
        'noise': np.random.normal(loc=0.0, scale=1.0, size=8),
    }
)

df['x2'] = 3 * df['x1'] + df['noise'] 

当我们绘制数据时,我们会发现x1x2呈现出如下的形式:

如果你愿意,可以把头偏向左边。现在,想象一下我们没有x1x2轴,而是有一个通过数据的对角线轴。那条轴是否足以表示我们的数据呢?这样,我们就将其从一个二维数据集降维到一个一维数据集。这正是 PCA 试图实现的目标。

这个新轴有一个主要特点——轴上点与点之间的距离大于它们在x1x2轴上的距离。记住,三角形的斜边总是大于其他两边中的任何一边。总之,PCA 试图找到一组新的轴(主成分),使得数据的方差最大化。

就像我们在第四章中讨论的相关系数方程一样,准备数据,PCA 也需要数据进行中心化。对于每一列,我们将该列的均值从每个值中减去。我们可以使用with_std=False的标准化缩放器来实现这一点。以下是如何计算 PCA 并将我们的数据转换为新维度的过程:

from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA

scaler = StandardScaler(with_std=False)
x = scaler.fit_transform(df[['x1', 'x2']])

pca = PCA(n_components=1)
x_new = pca.fit_transform(x)

结果的x_new值是一个单列数据框,而不是两个。我们也可以通过pca.components_访问新创建的组件。在这里,我将新组件与原始数据一起绘制出来:

如你所见,我们能够使用 PCA 算法将特征的数量从两个减少到一个。由于点并没有完全落在直线上,仅使用一个成分会丢失一些信息。这些信息存储在我们没有提取的第二个成分中。你可以将数据转换为从一个到原始特征数目的任何数量的成分。成分根据它们所包含的信息量降序排列。因此,忽略后续成分可能有助于去除任何噪声和不太有用的信息。数据经过转换后,也可以进行反向转换(逆变换)。只有在保留所有成分的情况下,经过这两步操作得到的数据才与原始数据匹配;否则,我们可以仅限于前几个(主要)成分来去噪数据。

在 PCA 假设中,特征空间中方差最大的方向预计携带比方差较小的方向更多的信息。这个假设在某些情况下可能成立,但并不总是成立。请记住,在 PCA 中,目标变量不被使用,只有特征变量。这使得它更适合无标签数据。

邻域成分分析

在最近邻算法中,距离度量的选择至关重要,但通常是通过经验设定的。我们在本章前面使用了 K 折交叉验证来决定哪种距离度量更适合我们的任务。这个过程可能比较耗时,这也促使许多研究人员寻找更好的解决方案。NCA 的主要目标是通过梯度下降从数据中学习距离度量。它尝试学习的距离通常用一个方阵表示。对于N个样本,我们有 个样本对需要比较,因此是方阵。然而,这个矩阵可以被限制为一个矩形矩阵,,其中小n是比N小的数字,表示降维后的成分。这些降维后的成分是 NCA 的基础构建块。

最近邻算法属于一种称为基于实例的学习器的学习类别。我们使用训练集的实例来做出决策。因此,承载实例之间距离的矩阵是其中的重要部分。这个矩阵激发了许多研究人员对此进行研究。例如,从数据中学习距离是 NCA 和大边际最近邻的研究内容;其他研究人员将这个矩阵转换到更高维空间——例如,使用核技巧——还有一些研究人员尝试通过正则化将特征选择嵌入到基于实例的学习器中。

在下一部分,我们将通过使用 PCA 和 NCA 算法将 MNIST 数据集绘制到二维图形中,来直观地比较这两种降维方法。

将 PCA 与 NCA 进行比较

我们将通过将数据投影到更小的空间中来减少数据的维度。除了随机投影,我们还将使用 PCANCA。我们将首先导入所需的模型,并将这三种算法放入一个 Python 字典中,以便后续循环使用:

from sklearn.preprocessing import StandardScaler
from sklearn.random_projection import SparseRandomProjection
from sklearn.decomposition import PCA
from sklearn.neighbors import NeighborhoodComponentsAnalysis

methods = {
    'Rand': SparseRandomProjection(n_components=2),
    'PCA': PCA(n_components=2),
    'NCA': NeighborhoodComponentsAnalysis(n_components=2, init='random'),
}

然后,我们将并排绘制三种算法的三个图表,如下所示:

fig, axs = plt.subplots(1, 3, figsize=(15, 5))

for i, (method_name, method_obj) in enumerate(methods.items()):

    scaler = StandardScaler(with_std=False)
    x_train_scaled = scaler.fit_transform(x_train)

    method_obj.fit(x_train_scaled, y_train)
    x_train_2d = method_obj.transform(x_train_scaled)

    for target in set(y_train):
        pd.DataFrame(
            x_train_2d[
                y_train == target
            ], columns=['y', 'x']
        ).sample(n=20).plot(
            kind='scatter', x='x', y='y', 
            marker=f'${target}$', s=64, ax=axs[i]
        )
        axs[i].set_title(f'{method_name} MNIST')

在应用 PCA 之前,数据必须进行中心化。这时我们使用了 StandardScaler 来实现。其他算法本身应该不在乎是否进行中心化。运行代码后,我们得到以下图表:

PCA 和 NCA 在将相同的数字聚集在一起方面比随机投影表现得更好。除了视觉分析,我们还可以在降维后的数据上运行最近邻算法,判断哪种变换更能代表数据。我们可以使用与之前类似的代码,并将 for 循环中的内容替换为以下两段代码:

  1. 首先,我们需要对数据进行缩放和转换:
from sklearn.preprocessing import StandardScaler
from sklearn.preprocessing import MinMaxScaler

scaler = StandardScaler(with_std=False)

x_train_scaled = scaler.fit_transform(x_train)
x_test_scaled = scaler.fit_transform(x_test)

method_obj.fit(x_train_scaled, y_train)
x_train_2d = method_obj.transform(x_train_scaled)
x_test_2d = method_obj.transform(x_test_scaled)

scaler = MinMaxScaler()
x_train_scaled = scaler.fit_transform(x_train_2d)
x_test_scaled = scaler.transform(x_test_2d)
  1. 然后,我们使用交叉验证来设置最佳超参数:
from sklearn.neighbors import KNeighborsClassifier 
from sklearn.model_selection import GridSearchCV 
from sklearn.metrics import accuracy_score

parameters = {'metric':('manhattan','euclidean'), 'n_neighbors': range(3, 9)}

knn = KNeighborsClassifier()
clf = GridSearchCV(knn, param_grid=parameters, scoring='accuracy', cv=5)

clf.fit(x_train_scaled, y_train)
y_test_pred = clf.predict(x_test_scaled)

print(
    'MNIST test accuracy score: {:.1%} [k={}, metric={} - {}]'.format(
        accuracy_score(y_test, y_test_pred), 
        clf.best_params_['n_neighbors'], 
        clf.best_params_['metric'], 
        method_name
    )
)

由于这次我们不需要可视化数据,可以将主成分数设置为 6。这样我们得到以下的准确率。请记住,由于数据的随机拆分和估计器的初始值不同,你的结果可能会有所不同:

投影 准确率
稀疏随机投影 73%
PCA 93%
NCA 95%

在 PCA 中,不需要类标签。我只是为了保持一致性,在之前的代码中传递了它们,但算法实际上是忽略了这些标签。相比之下,在 NCA 中,算法是会使用类标签的。

选择最具信息量的主成分

在拟合 PCA 后,explained_variance_ratio_ 包含了每个选择的主成分所解释的方差比例。根据主成分假设,较高的比例应反映更多的信息。我们可以将这些信息放入数据框中,如下所示:

df_explained_variance_ratio = pd.DataFrame(
    [
        (component, explained_variance_ratio) 
        for component, explained_variance_ratio in enumerate(pca.explained_variance_ratio_[:32], 1)
    ], columns=['component', 'explained_variance_ratio']
)

然后,绘制图表以得到如下图表。我相信你现在应该已经习惯了通过条形图绘制数据了:

从图表中可以看出,从第八个主成分开始,剩下的主成分携带的信息量不足 5%。

我们还可以循环不同的 n_components 值,然后在降维后的数据上训练模型,观察随着主成分数量的变化,准确率如何变化。我更信任这种方法,而不是依赖解释方差,因为它不依赖于主成分假设,并且将特征降维算法和分类器作为一个整体来评估。这一次,我将使用一个不同的算法:最近质心。

使用 PCA 的质心分类器

在下面的代码中,我们将尝试使用不同数量的主成分每次使用质心算法。请不要忘记在每次迭代中对特征进行缩放和转换,并记住将生成的 x 值存储在 x_train_embedx_test_embed 中。我在这里使用了 StandardScaler,以及 PCA 的 transform 方法来转换缩放后的数据:

from sklearn.neighbors import NearestCentroid

scores = []
for n_components in range(1, 33, 1):

    # Scale and transform the features as before 
    clf = NearestCentroid(shrink_threshold=0.01)
    clf.fit(x_train_embed, y_train)
    y_test_pred = clf.predict(x_test_embed)

scores.append([n_components, accuracy_score(y_test, y_test_pred)])

绘制分数图表如下所示:

当我们在这个数据集上使用质心算法时,我们大致可以看出超过 15 个成分不会增加太多价值。通过交叉验证的帮助,我们可以选择能够提供最佳结果的确切成分数量。

从其成分恢复原始图像

一旦图像被降至其主成分,也可以将其恢复回来,如下所示。

  1. 首先,在使用 PCA 前,您必须对数据进行缩放:
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler(with_std=False)
x_train_scaled = scaler.fit_transform(x_train)
x_test_scaled = scaler.transform(x_test)

缩放后,您可以使用 32 个主成分来转换您的数据,如下所示。

  1. 然后,您可以使用 inverse_transform 方法在转换后恢复原始数据:
from sklearn.decomposition import PCA
embedder = PCA(n_components=32)
embedder.fit(x_train, y_train)

x_train_embed = embedder.transform(x_train_scaled)
x_test_embed = embedder.transform(x_test_scaled)

x_train_restored = embedder.inverse_transform(x_train_embed) 
x_test_restored = embedder.inverse_transform(x_test_embed)
  1. 为了保持原始图像和恢复图像在同一比例上,我们可以使用 MinMaxScaler,如下所示:
iscaler = MinMaxScaler((x_train.min(), x_train.max()))
x_train_restored = iscaler.fit_transform(x_train_restored) 
x_test_restored = iscaler.fit_transform(x_test_restored)

这里,您可以看到一些数字与它们自身之间的比较,删除了不重要的成分。这些恢复的原始数据版本对分类器可能很有用,可以用它们替代训练和测试集,或者将它们作为训练集的附加样本:

  1. 最后,我在最近邻分类器中使用了 x_train_embedx_test_embed 替代了原始特征。我每次尝试了不同数量的 PCA 成分。以下图表中较暗的条形显示了能够产生最高准确度得分的 PCA 成分数量:

PCA 不仅帮助我们减少了特征数量和预测时间,同时还帮助我们获得了 98.9% 的得分。

查找最具信息量的像素

由于几乎所有数字都位于图像的中心,我们可以直觉地推断图像右侧和左侧的像素不包含有价值的信息。为了验证我们的直觉,我们将使用第四章中的特征选择算法,数据准备,来决定哪些像素最重要。在这里,我们可以使用互信息算法返回一个像素列表及其对应的重要性:

from sklearn.feature_selection import mutual_info_classif
mi = mutual_info_classif(x_train, y_train)

然后,我们使用前述信息去除了 75% 的像素:

percent_to_remove = 75
mi_threshold = np.quantile(mi, 0.01 * percent_to_remove)
informative_pixels = (mi >= mi_threshold).reshape((8, 8))

plt.imshow(informative_pixels, cmap='Greys')
plt.title(f'Pixels kept when {percent_to_remove}% removed')

在下图中,标记为黑色的像素是最具信息量的,其余的像素则是互信息算法认为不太重要的 75% 像素:

正如预期的那样,边缘处的像素信息量较少。既然我们已经识别出这些信息量较少的像素,我们可以通过移除这些像素来减少数据中的特征数量,具体如下:

from sklearn.feature_selection import SelectPercentile
percent_to_keep = 100 - percent_to_remove
selector = SelectPercentile(mutual_info_classif, percentile=percent_to_keep)

x_train_mi = selector.fit_transform(x_train, y_train)
x_test_mi = selector.transform(x_test)

在减少特征后的数据上训练分类器,我们得到了 94%的准确率。考虑到最近邻算法的复杂度以及其预测时间随着特征数量的增加而增长,我们可以理解一个略微不那么精确,但仅使用25%数据的算法的价值。

总结

图像在我们日常生活中无处不在。机器人需要计算机视觉来理解其周围环境。社交媒体上的大多数帖子都包含图片。手写文件需要图像处理才能被机器处理。这些以及更多的应用案例正是为什么图像处理成为机器学习从业者必须掌握的一项基本技能。在本章中,我们学习了如何加载图像并理解其像素。我们还学习了如何对图像进行分类,并通过降维来改善可视化效果和进一步的处理。

我们使用了最近邻算法进行图像分类和回归。这个算法允许我们在需要时插入自己的度量标准。我们还了解了其他算法,如半径邻居和最近质心。理解这些算法背后的概念及其差异在机器学习领域无处不在。稍后,我们将看到聚类和异常检测算法是如何借鉴这里讨论的概念的。除了这里讨论的主要算法,像距离度量和降维等概念也广泛存在。

由于图像处理的重要性,我们不会就此止步,因为我们将在第七章中进一步扩展这里获得的知识,神经网络——深度学习的到来,在那里我们将使用人工神经网络进行图像分类。

第六章:使用朴素贝叶斯分类器进行文本分类

“语言是一个自由创造的过程;它的规律和原则是固定的,但这些生成原则的运用方式是自由且无限变化的。甚至单词的解释和使用也涉及自由创造的过程。”

– 诺姆·乔姆斯基

并非所有信息都存在于表格中。从维基百科到社交媒体,成千上万的文字信息需要我们的计算机进行处理和提取。处理文本数据的机器学习子领域有着如文本挖掘自然语言处理NLP)等不同的名称。这些名称反映了该领域从多个学科继承而来。一方面,我们有计算机科学和统计学,另一方面,我们有语言学。我认为,在该领域初期,语言学的影响较大,但随着发展,实践者们更倾向于使用数学和统计工具,因为它们需要较少的人工干预,并且不需要人工将语言规则编入算法中:

“每次我解雇一个语言学家,我们的语音识别系统性能都会提升。”

– 弗雷德·杰里内克

话虽如此,了解事物随着时间的进展是如何发展的,避免直接跳入前沿解决方案,这一点至关重要。这使我们能够在意识到权衡取舍的基础上明智地选择工具。因此,我们将从处理文本数据开始,并以算法能够理解的格式呈现数据。这个预处理阶段对下游算法的性能有着重要影响。因此,我会确保阐明每种方法的优缺点。一旦数据准备好,我们将使用朴素贝叶斯分类器根据用户发送给多个航空公司服务的消息,检测不同 Twitter 用户的情感。

本章将涉及以下主题:

  • 将句子拆分成词元

  • 词元归一化

  • 使用词袋模型表示词元

  • 使用 n-gram 模型表示词元

  • 使用 Word2Vec 表示词元

  • 使用朴素贝叶斯分类器进行文本分类

将句子拆分成词元

“一个字接一个字,形成了力量。”

– 玛格丽特·阿特伍德

到目前为止,我们处理的数据要么是带有列作为特征的表格数据,要么是带有像素作为特征的图像数据。而在文本的情况下,问题变得不那么明确。我们应该使用句子、单词,还是字符作为特征?句子非常具体。例如,两篇维基百科文章中出现完全相同的句子的可能性非常小。因此,如果我们将句子作为特征,最终会得到大量的特征,这些特征的泛化能力较差。

另一方面,字符是有限的。例如,英语中只有 26 个字母。这种小的变化可能限制了单个字符携带足够信息的能力,无法让下游算法提取出有效的特征。因此,单词通常作为大多数任务的特征。

本章稍后我们会看到,尽管可以得到相当具体的标记,但现在让我们暂时仅把单词作为特征。最后,我们并不想局限于字典中的单词;Twitter 标签、数字和 URL 也可以从文本中提取并作为特征。因此,我们更倾向于使用 token 而不是 word 这个术语,因为 token 更为通用。将文本流分割成标记的过程称为分词,我们将在下一节中学习这个过程。

使用字符串分割进行分词

不同的分词方法会导致不同的结果。为了演示这些差异,让我们以以下三行文本为例,看看如何对它们进行分词。

在这里,我将文本行作为字符串写入并放入一个列表中:

lines = [
    'How to tokenize?\nLike a boss.',
    'Google is accessible via http://www.google.com',
    '1000 new followers! #TwitterFamous',
]

一种明显的方法是使用 Python 内置的 split() 方法,如下所示:

for line in lines:
    print(line.split())

当没有提供参数时,split() 会根据空格来分割字符串。因此,我们得到以下输出:

['How', 'to', 'tokenize?', 'Like', 'a', 'boss.']
['Google', 'is', 'accessible', 'via', 'http://www.google.com']
['1000', 'new', 'followers!', '#TwitterFamous']

你可能注意到,标点符号被保留为标记的一部分。问号被保留在 tokenize 的末尾,句号也附着在 boss 后面。井号标签由两个单词组成,但由于它们之间没有空格,它被作为一个整体标记保留,并带有前导的井号符号。

使用正则表达式进行分词

我们还可以使用正则表达式将字母和数字序列视为标记,并相应地分割我们的句子。这里使用的模式 "\w+" 表示任何一个或多个字母数字字符或下划线的序列。编译我们的模式会得到一个正则表达式对象,我们可以用它来进行匹配。最后,我们遍历每一行并使用正则表达式对象将其拆分为标记:

import re
_token_pattern = r"\w+"
token_pattern = re.compile(_token_pattern)

for line in lines:
    print(token_pattern.findall(line))

这将给出以下输出:

['How', 'to', 'tokenize', 'Like', 'a', 'boss']
['Google', 'is', 'accessible', 'via', 'http', 'www', 'google', 'com']
['1000', 'new', 'followers', 'TwitterFamous']

现在,标点符号已被去除,但 URL 被分割成了四个标记。

Scikit-learn 默认使用正则表达式进行分词。然而,r"(?u)\b\w\w+\b" 这个模式被用来代替 r"\w+"。这个模式会忽略所有标点符号和短于两个字母的单词。因此,"a" 这个词会被省略。你仍然可以通过提供自定义模式来覆盖默认模式。

使用占位符进行分词前的处理

为了解决前面的问题,我们可以决定在对句子进行分词之前,将数字、URL 和标签(hashtags)替换为占位符。如果我们不在意区分它们的内容,这样做是有用的。对我来说,URL 可能只是一个 URL,无论它指向哪里。以下函数将输入转换为小写字母,然后将找到的任何 URL 替换为_url_占位符。类似地,它将标签和数字转换为相应的占位符。最后,输入根据空白字符进行分割,并返回结果的词元:

_token_pattern = r"\w+"
token_pattern = re.compile(_token_pattern)

def tokenizer(line):
    line = line.lower()
    line = re.sub(r'http[s]?://[\w\/\-\.\?]+','_url_', line)
    line = re.sub(r'#\w+', '_hashtag_', line)
    line = re.sub(r'\d+','_num_', line)
    return token_pattern.findall(line)

for line in lines:
    print(tokenizer(line))

这给我们带来了以下输出:

['how', 'to', 'tokenize', 'like', 'a', 'boss']
['google', 'is', 'accessible', 'via', '_url_']
['_num_', 'new', 'followers', '_hashtag_']

如你所见,新的占位符告诉我们第二个句子中存在一个 URL,但它并不关心该 URL 指向哪里。如果我们有另一个包含不同 URL 的句子,它也会得到相同的占位符。数字和标签也是一样的。

根据你的使用情况,如果你的标签包含你不想丢失的信息,这种方法可能并不理想。同样,这是一个你必须根据具体应用做出的权衡。通常,你可以直观地判断哪种技术更适合当前问题,但有时,评估经过多次分词技术后的模型,可能是唯一判断哪种方法更合适的方式。最后,在实际应用中,你可能会使用NLTKspaCy等库来对文本进行分词。它们已经在后台实现了必要的正则表达式。在本章稍后的部分,我们将使用 spaCy。

请注意,我在处理句子之前将其转换为小写字母。这被称为归一化。如果没有归一化,首字母大写的单词和它的小写版本会被视为两个不同的词元。这不是理想的,因为Boyboy在概念上是相同的,因此通常需要进行归一化。Scikit-learn 默认会将输入文本转换为小写字母。

将文本向量化为矩阵

在文本挖掘中,一个数据集通常被称为语料库。其中的每个数据样本通常被称为文档。文档由词元组成,一组不同的词元被称为词汇表。将这些信息放入矩阵中称为向量化。在接下来的章节中,我们将看到我们可以获得的不同类型的向量化方法。

向量空间模型

我们仍然缺少我们心爱的特征矩阵,在这些矩阵中,我们期望每个词元(token)有自己的列,每个文档由单独的一行表示。这种文本数据的表示方式被称为向量空间模型。从线性代数的角度来看,这种表示中的文档被视为向量(行),而不同的词项是该空间的维度(列),因此称为向量空间模型。在下一节中,我们将学习如何将文档向量化。

词袋模型

我们需要将文档转换为标记,并将它们放入向量空间模型中。此处可以使用CountVectorizer对文档进行标记化并将其放入所需的矩阵中。在这里,我们将使用我们在上一节创建的分词器。像往常一样,我们导入并初始化CountVectorizer,然后使用其fit_transform方法来转换我们的文档。我们还指定希望使用我们在上一节构建的分词器:

from sklearn.feature_extraction.text import CountVectorizer
vec = CountVectorizer(lowercase=True, tokenizer=tokenizer)
x = vec.fit_transform(lines)

返回的矩阵中大部分单元格都是零。为了节省空间,它被保存为稀疏矩阵;然而,我们可以使用其todense()方法将其转换为稠密矩阵。向量化器保存了遇到的词汇表,可以使用get_feature_names()来检索。通过这些信息,我们可以将x转换为 DataFrame,如下所示:

pd.DataFrame(
    x.todense(), 
    columns=vec.get_feature_names()
)

这给了我们以下矩阵:

每个单元格包含每个标记在每个文档中出现的次数。然而,词汇表没有遵循任何顺序;因此,从这个矩阵中无法判断每个文档中标记的顺序。

不同的句子,相同的表示

取这两句话,它们有相反的意思:

flight_delayed_lines = [
    'Flight was delayed, I am not happy',
    'Flight was not delayed, I am happy'
]

如果我们使用计数向量化器来表示它们,我们将得到以下矩阵:

如你所见,句子中标记的顺序丢失了。这就是为什么这种方法被称为词袋模型(bag of words)——结果就像一个袋子,单词只是被放入其中,没有任何顺序。显然,这使得无法分辨哪一个人是开心的,哪一个不是。为了解决这个问题,我们可能需要使用n-grams,正如我们将在下一节中所做的那样。

N-grams

与其将每个术语视为一个标记,我们可以将每两个连续术语的组合视为一个单独的标记。我们要做的就是将CountVectorizer中的ngram_range设置为(2,2),如下所示:

from sklearn.feature_extraction.text import CountVectorizer
vec = CountVectorizer(ngram_range=(2,2))
x = vec.fit_transform(flight_delayed_lines)

使用与上一节相似的代码,我们可以将结果的x放入 DataFrame 中并得到以下矩阵:

现在我们可以知道谁是开心的,谁不是。当使用词对时,这被称为大 ram(bigrams)。我们还可以使用 3-gram(由三个连续单词组成),4-gram 或任何其他数量的 gram。将ngram_range设置为(1,1)将使我们回到原始表示形式,其中每个单独的单词是一个标记,这就是单 gram(unigrams)。我们还可以通过将ngram_range设置为(1,2)来混合单 gram 和大 gram。简而言之,这个范围告诉分词器用于我们 n-gram 的最小值和最大值n

如果你将n设置为一个较高的值——比如 8——这意味着八个单词的序列将被当作标记。那么,你认为一个包含八个单词的序列在你的数据集中出现的概率有多大?大概率是它只会在训练集中出现一次,而在测试集中从未出现过。这就是为什么n通常设置为 2 到 3 之间的数值,并且有时会使用一些 unigram 来捕捉稀有词汇。

使用字符代替单词

到目前为止,单词一直是我们文本宇宙中的原子。然而,有些情况可能需要我们基于字符来进行文档的标记化。在单词边界不明确的情况下,比如标签和 URL,使用字符作为标记可能会有所帮助。自然语言的字符频率通常不同。字母e是英语中使用最频繁的字符,字符组合如theron也非常常见。其他语言,如法语和荷兰语,也有不同的字符频率。如果我们的目标是基于语言来分类文档,使用字符而不是单词可能会派上用场。

同样的CountVectorizer可以帮助我们将文档标记化为字符。我们还可以将其与n-grams设置结合,以获取单词中的子序列,如下所示:

from sklearn.feature_extraction.text import CountVectorizer
vec = CountVectorizer(analyzer='char', ngram_range=(4,4))
x = vec.fit_transform(flight_delayed_lines)

我们可以像之前一样将结果x放入 DataFrame 中,从而得到如下矩阵:

现在我们所有的标记都由四个字符组成。如你所见,空格也被视作字符。使用字符时,通常会选择更高的n值。

使用 TF-IDF 捕捉重要词汇

我们在这里借鉴的另一个学科是信息检索领域。它是负责运行搜索引擎算法的领域,比如 Google、Bing 和 DuckDuckGo。

现在,看看下面这段引文:

“从语言学的角度来看,你真的不能对‘一个节目就是一个节目’这一概念提出太多反对意见。”

– 沃尔特·贝克尔

linguisticthat这两个词在前述引用中都出现过一次。然而,如果我们在互联网上搜索这段引文,我们只会关注linguistic这个词,而不是that这个词。我们知道,尽管它只出现了一次,和that出现的次数一样多,但它更为重要。show这个词出现了三次。从计数向量化器的角度来看,它应该比linguistic包含更多的信息。我猜你也不同意向量化器的看法。这些问题从根本上来说是词频-逆文档频率TF-IDF)的存在原因。IDF 部分不仅涉及根据单词在某个文档中出现的频率来加权单词的值,还会在这些单词在其他文档中非常常见时对它们的权重进行折扣。that这个词在其他文档中如此常见,以至于它不应该像linguistic一样被赋予那么高的权重。此外,IDF 使用对数尺度来更好地表示一个词根据它在文档中的频率所携带的信息。*

*我们使用以下三个文档来演示 TF-IDF 是如何工作的:

lines_fruits = [
    'I like apples',
    'I like oranges',
    'I like pears',
]

TfidfVectorizer的接口与CountVectorizer几乎完全相同:

from sklearn.feature_extraction.text import TfidfVectorizer
vec = TfidfVectorizer(token_pattern=r'\w+')
x = vec.fit_transform(lines_fruits)

这是两种向量化器输出的并排比较:

如你所见,与CountVectorizer不同,TfidfVectorizer并没有对所有单词进行平等对待。相比于其他出现在所有三句话中的不太有信息量的词,更多的强调被放在了水果名称上。

CountVectorizerTfidfVectorizer都有一个名为stop_words的参数。它可以用来指定需要忽略的词元。你可以提供自己的不太有信息量的词列表,例如aanthe。你也可以提供english关键字来指定英语中常见的停用词。话虽如此,需要注意的是,一些词对于某个任务来说可能有信息量,但对另一个任务则可能没有。此外,IDF 通常会自动完成你需要它做的工作,并且给非信息性词语赋予较低的权重。这就是为什么我通常不手动去除停用词,而是尝试使用TfidfVectorizer、特征选择和正则化优先的方法。******

*******除了它的原始用途,TfidfVectorizer通常作为文本分类的预处理步骤。然而,当需要对较长的文档进行分类时,它通常能给出不错的结果。对于较短的文档,它可能会产生嘈杂的转化,建议在这种情况下尝试使用CountVectorizer

在一个基础的搜索引擎中,当有人输入查询时,它会通过 TF-IDF 转换为与所有待搜索文档存在于同一向量空间中的形式。一旦查询和文档作为向量存在于同一空间中,就可以使用简单的距离度量方法,如余弦距离,来查找与查询最接近的文档。现代搜索引擎在这个基础概念上有所变化,但这是构建信息检索理解的良好基础。

使用词嵌入表示意义

由于文档是由词元组成的,它们的向量表示基本上是包含的词元向量之和。正如我们之前看到的,I like apples文档通过CountVectorizer被表示为向量[1,1,1,0,0]:

从这种表示方式出发,我们还可以推断出Ilikeapplesoranges分别由以下四个五维向量表示:[0,1,0,0,0],[0,0,1,0,0],[1,0,0,0,0]和[0,0,0,1,0]。我们有一个五维空间,基于我们五个词的词汇表。每个词在一个维度上的值为 1,其他四个维度上的值为 0。从线性代数的角度来看,所有五个词是正交的(垂直的)。然而,applespearsoranges都是水果,在概念上它们有一定的相似性,但这种相似性并没有被这个模型捕捉到。因此,我们理想的做法是使用相互接近的向量来表示它们,而不是这些正交的向量。顺便提一下,TfidfVectorizer也存在类似问题 这促使研究人员提出了更好的表示方法,而词嵌入如今成为自然语言处理领域的热门技术,因为它比传统的向量化方法更好地捕捉了意义。在下一节中,我们将了解一种流行的嵌入技术——Word2Vec。

Word2Vec

不深入细节,Word2Vec 使用神经网络从上下文中预测单词,也就是说,从单词的周围词汇中进行预测。通过这种方式,它学习了更好的单词表示,并且这些表示包含了它们所代表的单词的意义。与前面提到的向量化方法不同,单词表示的维度与我们词汇表的大小没有直接关系。我们可以选择嵌入向量的长度。一旦每个单词被表示为一个向量,文档的表示通常是所有单词向量的和。平均值也是一个替代选择,而不是求和。

由于我们向量的大小与我们处理的文档的词汇量无关,研究人员可以重新使用未专门为他们特定问题训练的预训练 Word2Vec 模型。这种重新使用预训练模型的能力被称为迁移学习。一些研究人员可以使用昂贵的机器在大量文档上训练嵌入,并发布得到的向量供全世界使用。然后,下次我们处理特定的自然语言处理任务时,我们所需要做的就是获取这些向量并用它们来表示我们新的文档。spaCy (spacy.io/)是一个开源软件库,提供了不同语言的词向量。

在接下来的几行代码中,我们将安装 spaCy,下载它的语言模型数据,并使用它将单词转换为向量:

  1. 要使用 spaCy,我们可以安装这个库并通过运行以下命令在终端中下载其英语预训练模型:
          pip install spacy

          python -m spacy download en_core_web_lg

  1. 然后,我们可以将下载的向量分配给我们的五个单词,如下所示:
import spacy
nlp = spacy.load('en_core_web_lg')

terms = ['I', 'like', 'apples', 'oranges', 'pears']
vectors = [
    nlp(term).vector.tolist() for term in terms
]
  1. 这是苹果的表示:
# pd.Series(vectors[terms.index('apples')]).rename('apples')

0     -0.633400
1      0.189810
2     -0.535440
3     -0.526580
         ...   
296   -0.238810
297   -1.178400
298    0.255040
299    0.611710
Name: apples, Length: 300, dtype: float64

我曾承诺你,苹果橙子的表示不会像CountVectorizer那样正交。然而,使用 300 个维度时,我很难直观地证明这一点。幸运的是,我们已经学会了如何计算两个向量之间的余弦角度。正交向量之间的角度应该是 90°,其余弦值为 0。而两个方向完全相同的向量之间的零角度的余弦值为 1。

在这里,我们计算了来自 spaCy 的五个向量之间的余弦相似度。我使用了一些 pandas 和 seaborn 的样式,使数字更清晰:

import seaborn as sns
from sklearn.metrics.pairwise import cosine_similarity

cm = sns.light_palette("Gray", as_cmap=True)

pd.DataFrame(
    cosine_similarity(vectors),
    index=terms, columns=terms,
).round(3).style.background_gradient(cmap=cm)

然后,我在下面的 DataFrame 中展示了结果:

显然,新的表示方法理解到水果名称之间的相似度远高于它们与像Ilike这样的词的相似度。它还认为苹果非常相似,而橙子则不然。

你可能注意到,Word2Vec 存在与一元词相同的问题;词语的编码并没有太多关注它们的上下文。在句子“I will read a book”和“I will book a flight”中,单词“book”的表示是一样的。这就是为什么像语言模型嵌入ELMo)、双向编码器表示从变换器BERT)以及 OpenAI 最近的GPT-3等新技术现在越来越受欢迎的原因,因为它们尊重词语的上下文。我预计它们很快会被更多的库所采用,供大家轻松使用。

嵌入概念现在被各地的机器学习从业者回收并重新利用。除了在自然语言处理中的应用外,它还用于特征降维和推荐系统。例如,每当顾客将商品添加到在线购物车时,如果我们将购物车视为一个句子,将商品视为单词,那么我们就得到了商品的嵌入 (Item2Vec) 。这些商品的新表示可以轻松地插入到下游分类器或推荐系统中。

在进入文本分类之前,我们需要先停下来花一些时间了解我们将要使用的分类器——朴素贝叶斯分类器

理解朴素贝叶斯

朴素贝叶斯分类器通常用于文本数据的分类。在接下来的部分中,我们将看到其不同的变种,并学习如何配置它们的参数。但首先,为了理解朴素贝叶斯分类器,我们需要先了解托马斯·贝叶斯在 18 世纪发表的贝叶斯定理。

贝叶斯规则

讨论分类器时,我们可以使用条件概率P(y|x)来描述某个样本属于某个类别的概率。这是给定其特征x的情况下,样本属于类别y的概率。管道符号(|)是我们用来表示条件概率的符号,即给定x的情况下的y。贝叶斯规则可以用以下公式将这种条件概率表达为P(x|y)P(x)P(y)

通常,我们忽略方程中的分母部分,将其转换为如下比例:

一个类别的概率,P(y),称为先验概率。它基本上是所有训练样本中属于某一类别的样本数。条件概率,P(x|y),称为似然度。它是我们从训练样本中计算出来的。一旦这两个概率在训练时已知,我们就可以利用它们来预测新样本属于某一类别的概率,即预测时的P(y|x),也称为后验概率。计算方程中的似然度部分并不像我们预期的那么简单。因此,在接下来的部分中,我们将讨论为了简化这一计算,我们可以做出哪些假设。

朴素地计算似然度

一个数据样本由多个特征构成,这意味着在实际应用中,P(x|y)中的x部分由x[1]x[2]x[3]、.... x[k]构成,其中k是特征的数量。因此,条件概率可以表示为P(x[1], x[2], x[3], .... x[k]|y)。实际上,这意味着我们需要为x的所有可能组合计算该条件概率。这么做的主要缺点是我们模型的泛化能力不足。

让我们通过以下的玩具示例来澄清这一点:

文本 文本是否表明作者喜欢水果?
我喜欢苹果
我喜欢橙子
我讨厌梨

如果前面的表格是我们的训练数据,第一个样本的似然概率,P(x|y),就是给定目标时,看到三个词喜欢苹果一起出现的概率。同理,第二个样本的概率是给定目标时,看到三个词喜欢橙子一起出现的概率。第三个样本也是如此,只不过目标是而不是。现在,假设我们给定一个新样本,我讨厌苹果。问题是,我们之前从未见过这三个词一起出现。你可能会说:“但是我们以前见过每个单独的词,只是分开出现!”这是正确的,但我们的公式只关心词的组合。它无法从每个单独的特征中学到任何东西。

你可能记得在第四章中,准备你的数据P(x[1], x[2], x[3], .... x[k]|y) 只有在 x[1], x[2], x[3], .... x[k] 互相独立时,才能表示为 P(x[1]|y) P(x[2]|y)x[3]* .. * P(x[k]|y)*。它们的独立性并非我们可以确定的,但我们仍然做出了这个朴素的假设,以使模型更具普适性。由于这一假设,并且由于我们处理的是独立的单词,现在我们可以了解关于短语我讨厌苹果的一些信息,尽管我们以前从未见过它。这种虽然朴素但有用的独立性假设,正是给分类器命名时加上“朴素”前缀的原因。

朴素贝叶斯实现

在 scikit-learn 中,有多种朴素贝叶斯实现方式。

  • 多项式朴素贝叶斯分类器是文本分类中最常用的实现。它的实现方式与我们在前一节看到的最为相似。

  • 伯努利朴素贝叶斯分类器假设特征是二元的。在伯努利版本中,我们关注的不是每个文档中某个词出现的次数,而是该词是否存在。计算似然的方式明确惩罚文档中未出现的词汇,在一些数据集上,尤其是短文档的数据集上,它可能表现得更好。

高斯朴素贝叶斯用于连续特征。它假设特征呈正态分布,并使用最大似然估计计算似然概率。该实现适用于文本分析之外的其他情况。*

此外,你还可以在 scikit-learn 用户指南中阅读关于另外两个实现——互补朴素贝叶斯类别朴素贝叶斯**的内容,链接为(scikit-learn.org/stable/modules/naive_bayes.html)。

加性平滑

当在预测过程中出现训练时未见过的词汇时,我们将其概率设置为 0。这听起来很合乎逻辑,但鉴于我们天真的假设,这其实是一个有问题的决定。由于P(x[1], x[2], x[3], .... x[k]|y) 等于 P(x[1]|y) P(x[2]|y)P(x[3]|y) .. * P(x[k]|y),*将任何词汇的条件概率设为零,将导致整个 P(x[1], x[2], x[3], .... x[k]|y) 被设置为零。为了避免这个问题,我们假设每个类别中都加入了一份包含所有词汇的文档。从概念上讲,这个新的假设性文档将从我们已见过的词汇中分配一部分概率质量,并将其重新分配给未见过的词汇。alpha 参数控制我们希望重新分配给未见过的词汇的概率质量。将 alpha 设置为 1 被称为拉普拉斯平滑,而将其设置为介于 0 和 1 之间的值则称为利德斯通平滑

我发现在计算比率时,经常使用拉普拉斯平滑。除了防止我们出现除以零的情况外,它还帮助处理不确定性。让我通过以下两个例子进一步解释:

  • 例子 1:10,000 人看到了一个链接,其中 9,000 人点击了它。显然,我们可以估算点击率为 90%。

  • 例子 2:如果我们的数据中只有一个人,而且这个人看到了链接并点击了它,我们能有足够的信心说点击率是 100%吗?

在前面的例子中,如果我们假设有两个额外的用户,其中只有一个点击了链接,那么第一个例子的点击率将变为 9,001/10,002,仍然接近 90%。然而,在第二个例子中,我们将用 2 除以 3,这将得到 60%,而不是之前计算出的 100%。拉普拉斯平滑和利德斯通平滑可以与贝叶斯的思维方式联系起来。这两个用户,其中 50%的人点击了链接,代表了我们的先验信念。最初,我们了解的信息很少,所以我们假设点击率为 50%。现在,在第一个例子中,我们有足够的数据来推翻这个先验信念,而在第二个例子中,较少的数据点只能稍微调整先验。

现在先不谈理论 – 让我们用到目前为止学到的所有内容,来判断一些评论者是否对他们的观影体验感到满意。

使用朴素贝叶斯分类器进行文本分类

在本节中,我们将获取一组句子,并根据用户的情感对其进行分类。我们要判断该句子是带有积极情感还是消极情感。Dimitrios Kotzias 等人 为他们的研究论文《从群体到个体标签,利用深度特征》创建了这个数据集。他们从三个不同的网站收集了一组随机句子,并将每个句子标记为 1(积极情感)或 0(消极情感)。

数据集中总共有 2,745 个句子。在接下来的部分中,我们将下载数据集、预处理数据,并对其中的句子进行分类。

下载数据

你可以直接打开浏览器,将 CSV 文件下载到本地文件夹,并使用 pandas 将文件加载到数据框中。然而,我更喜欢使用 Python 来下载文件,而不是使用浏览器。我这么做不是因为我是极客,而是为了确保我的整个过程的可重现性,将其编写成代码。任何人都可以运行我的 Python 代码并得到相同的结果,而不需要阅读糟糕的文档文件,找到压缩文件的链接,并按照指示获取数据。

以下是下载所需数据的步骤:

  1. 首先,让我们创建一个文件夹来存储下载的数据。以下代码检查所需文件夹是否存在。如果不存在,它会在当前工作目录中创建该文件夹:
import os

data_dir = f'{os.getcwd()}/data'

if not os.path.exists(data_dir):
    os.mkdir(data_dir)
  1. 然后我们需要使用pip安装requests库,因为我们将使用它来下载数据:
          pip install requests

  1. 然后,我们按照以下方式下载压缩数据:
import requests

url = 'https://archive.ics.uci.edu/ml/machine-learning-databases/00331/sentiment%20labelled%20sentences.zip'

response = requests.get(url)
  1. 现在,我们可以解压数据并将其存储到刚创建的数据文件夹中。我们将使用zipfile模块来解压数据。ZipFile方法期望读取一个文件对象。因此,我们使用BytesIO将响应内容转换为类似文件的对象。然后,我们将 zip 文件的内容提取到我们的文件夹中,如下所示:
import zipfile

from io import BytesIO

with zipfile.ZipFile(file=BytesIO(response.content), mode='r') as compressed_file:
    compressed_file.extractall(data_dir)
  1. 现在,我们的数据已经写入到数据文件夹中的 3 个独立文件中,我们可以将这 3 个文件分别加载到 3 个数据框中。然后,我们可以将这 3 个数据框合并成一个单一的数据框,如下所示:
df_list = []

for csv_file in ['imdb_labelled.txt', 'yelp_labelled.txt', 'amazon_cells_labelled.txt']:

    csv_file_with_path = f'{data_dir}/sentiment labelled sentences/{csv_file}'
    temp_df = pd.read_csv(
        csv_file_with_path, 
        sep="\t", header=0, 
        names=['text', 'sentiment']
    ) 
    df_list.append(temp_df)

df = pd.concat(df_list)
  1. 我们可以使用以下代码显示情感标签的分布:
explode = [0.05, 0.05]
colors = ['#777777', '#111111']
df['sentiment'].value_counts().plot(
    kind='pie', colors=colors, explode=explode
)

如我们所见,两个类别大致相等。在进行任何分类任务之前,检查类别的分布是一种好习惯:

  1. 我们还可以使用以下代码显示一些示例句子,调整 pandas 的设置以显示更多字符:
pd.options.display.max_colwidth = 90
df[['text', 'sentiment']].sample(5, random_state=42)

我将random_state设置为一个任意值,以确保我们得到相同的样本,如下所示:

准备数据

现在,我们需要为分类器准备数据以供使用:

  1. 像我们通常做的那样,我们首先将数据框分割为训练集和测试集。我将 40%的数据集用于测试,并将random_state设置为一个任意值,以确保我们都获得相同的随机分割:
from sklearn.model_selection import train_test_split
df_train, df_test = train_test_split(df, test_size=0.4, random_state=42)
  1. 然后,我们从情感列中获取标签,如下所示:
y_train = df_train['sentiment']
y_test = df_test['sentiment']
  1. 对于文本特征,让我们使用CountVectorizer来转换它们。我们将包括一元组、二元组和三元组。我们还可以通过将min_df设置为3来忽略稀有词,这样就可以排除出现在少于三个文档中的单词。这是去除拼写错误和噪声标记的一个有效做法。最后,我们可以去掉字母的重音并将其转换为ASCII
from sklearn.feature_extraction.text import CountVectorizer

vec = CountVectorizer(ngram_range=(1,3), min_df=3, strip_accents='ascii')
x_train = vec.fit_transform(df_train['text'])
x_test = vec.transform(df_test['text'])
  1. 最后,我们可以使用朴素贝叶斯分类器来分类我们的数据。我们为模型设置fit_prior=True,使其使用训练数据中类别标签的分布作为先验:
from sklearn.naive_bayes import MultinomialNB
clf = MultinomialNB(fit_prior=True)
clf.fit(x_train, y_train)
y_test_pred = clf.predict(x_test)

这次,我们的传统准确度得分可能不足以提供足够的信息。我们希望知道每个类别的准确性。此外,根据我们的使用案例,我们可能需要判断模型是否能够识别所有的负面推文,即使这样做的代价是错误地分类了一些正面推文。为了获得这些信息,我们需要使用精度召回率得分。

精度、召回率和 F1 得分

在被分配到正类的样本中,实际上为正类的百分比就是该类别的精度。对于正面推文,分类器正确预测为正面的推文百分比就是该类别的召回率。如你所见,精度和召回率是按类别计算的。以下是我们如何用真实正例和假正例正式表达精度得分

召回率得分是通过真实正例和假负例来表示的

为了将前两个得分汇总为一个数字,可以使用F[1]得分。它通过以下公式将精度和召回率得分结合起来:

在这里,我们计算了我们的分类器的三项上述度量:

p, r, f, s = precision_recall_fscore_support(y_test, y_test_pred)

为了更清楚地说明,我将结果度量放入以下表格中。请记住,支持度仅仅是每个类别中的样本数量:

由于两个类别的大小几乎相等,因此得分是相等的。如果类别不平衡,通常会看到某个类别的精度或召回率高于另一个类别。

由于这些度量是按类别标签计算的,我们还可以获得它们的宏观平均值。在此示例中,宏观平均精度得分将是0.810.77的平均值,即0.79。另一方面,微观平均是基于总体的真实正例、假正例和假负例样本数量来计算这些得分的。

流水线

在前几章中,我们使用网格搜索来找到估计器的最佳超参数。现在,我们有多个东西要同时优化。一方面,我们想优化朴素贝叶斯的超参数,另一方面,我们还想优化预处理步骤中使用的向量化器的参数。由于网格搜索只期望一个对象,scikit-learn 提供了一个pipeline封装器,我们可以在其中将多个转换器和估计器组合成一个。

顾名思义,管道是由一系列顺序步骤组成的。在这里,我们从CountVectorizer开始,MultinomialNB作为第二步也是最后一步:

from sklearn.pipeline import Pipeline
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.naive_bayes import MultinomialNB

pipe = Pipeline(steps=[
    ('CountVectorizer', CountVectorizer()), 
    ('MultinomialNB', MultinomialNB())]
)

除了最后一步的对象外,其他所有对象都应该是transformers,即它们应具有fittransformfit_transform方法。最后一步的对象应为estimator,意味着它应该具有fitpredict方法。你还可以构建自定义的转换器和估计器,并在管道中使用,只要它们具有预期的方法。

现在我们的管道已经准备好,我们可以将其插入GridSearchCV中,以寻找最佳超参数。

针对不同得分的优化

“你所衡量的,便是你所管理的。”

——彼得·德鲁克

当我们之前使用GridSearchCV时,我们没有指定要优化的超参数的度量标准。默认情况下使用分类器的准确度。或者,你也可以选择优化超参数的精确度得分或召回率得分。在这里,我们将设置网格搜索以优化宏精确度得分。

我们从设置要搜索的不同超参数开始。由于我们在这里使用管道,因此我们需要为每个超参数添加步骤名称的前缀,以便管道将参数分配给正确的步骤:

param_grid = {
    'CountVectorizer__ngram_range': [(1,1), (1,2), (1,3)],
    'MultinomialNB__alpha': [0.1, 1],
    'MultinomialNB__fit_prior': [True, False],
}

默认情况下,贝叶斯规则中的先验P(y)是根据每个类别中的样本数设置的。然而,我们可以通过设置fit_prior=False将其设置为所有类别的常量。

在这里,我们运行GridSearchCV,并告诉它我们最关心的是精确度:

from sklearn.model_selection import GridSearchCV
search = GridSearchCV(pipe, param_grid, scoring='precision_macro', n_jobs=-1)
search.fit(df_train['text'], y_train)
print(search.best_params_)

这为我们提供了以下超参数:

  • ngram_range: (1, 3)

  • alpha: 1

  • fit_prior: False

我们得到了 80.5%的宏精确度和 80.5%的宏召回率。

由于类别分布平衡,预计先验不会增加太多价值。我们还获得了相似的精确度和召回率得分。因此,现在重新运行网格搜索以优化召回率没有意义,我们无论如何都会得到相同的结果。然而,当处理高度不平衡的类别时,事情可能会有所不同,这时你可能希望通过牺牲其他类别的效果来最大化某一类别的召回率。

在下一节中,我们将使用词嵌入来表示我们的标记。让我们看看这种迁移学习方法是否能帮助我们的分类器表现得更好。

创建自定义转换器

在结束本章之前,我们还可以基于Word2Vec嵌入创建一个自定义变换器,并在我们的分类管道中使用它,而不是使用CountVectorizer。为了能够在管道中使用我们的自定义变换器,我们需要确保它具备fittransformfit_transform方法。

这是我们新的变换器,我们将其命名为WordEmbeddingVectorizer

import spacy

class WordEmbeddingVectorizer:

    def __init__(self, language_model='en_core_web_md'):
        self.nlp = spacy.load(language_model)

    def fit(self):
        pass

    def transform(self, x, y=None):
        return pd.Series(x).apply(
            lambda doc: self.nlp(doc).vector.tolist()
        ).values.tolist()

    def fit_transform(self, x, y=None):
        return self.transform(x)

这里的fit方法是无效的——它什么也不做,因为我们使用的是 spaCy 的预训练模型。我们可以按照以下方式使用新创建的变换器:

vec = WordEmbeddingVectorizer()
x_train_w2v = vec.transform(df_train['text'])

我们可以使用这个变换器与其他分类器一起使用,而不仅仅是朴素贝叶斯分类器,例如LogisticRegressionMulti-layer Perceptron

pandas 中的apply函数可能会很慢,尤其是在处理大量数据时。我喜欢使用一个叫做tqdm的库,它可以让我将apply()方法替换为progress_apply(),这样在运行时就会显示进度条。导入库后,你只需运行tqdm.pandas();这会将progress_apply()方法添加到 pandas 的 Series 和 DataFrame 对象中。顺便说一句,tqdm这个词在阿拉伯语中意味着进度

总结

就我个人而言,我发现自然语言处理领域非常令人兴奋。我们人类的绝大多数知识都包含在书籍、文档和网页中。了解如何借助机器学习自动提取这些信息并组织它们,对我们的科学进步和自动化事业至关重要。这就是为什么多个科学领域,如信息检索、统计学和语言学,相互借鉴思想并试图从不同角度解决同一个问题。在本章中,我们也借鉴了这些领域的思想,并学习了如何将文本数据表示为适合机器学习算法的格式。我们还了解了 scikit-learn 提供的工具,以帮助构建和优化端到端解决方案。我们还遇到了转移学习等概念,并能够无缝地将 spaCy 的语言模型集成到 scikit-learn 中。

从下一章开始,我们将处理一些稍微高级的话题。在下一章中,我们将学习人工神经网络(多层感知机)。这是当今非常热门的话题,理解其主要概念对于任何想深入学习深度学习的人来说都很有帮助。由于神经网络通常用于图像处理,我们将借此机会,在第五章《最近邻图像处理》中继续扩展我们的图像处理知识。

第二部分:高级监督学习

本节包含如何处理不平衡数据,以及如何优化算法以实现实际的偏差/方差折衷的相关信息。它还深入探讨了更高级的算法,如人工神经网络和集成方法。

本节包含以下章节:

  • 第七章神经网络——深度学习来袭

  • 第八章, 集成方法——当一个模型不足以应对

  • 第九章Y 与 X 同样重要

  • 第十章不平衡学习——连 1%都不能中彩票

第七章:神经网络 – 深度学习的到来

阅读新闻文章或遇到一些误用深度学习这一术语来代替机器学习的情况并不罕见。这是因为深度学习作为机器学习的一个子领域,已经在解决许多以前无法解决的图像处理和自然语言处理问题上取得了巨大的成功。这种成功使得许多人将这个子领域与其父领域混淆。

深度学习这一术语指的是深度人工神经网络ANNs)。后者的概念有多种形式和形态。在本章中,我们将讨论一种前馈神经网络的子集,称为多层感知器MLP)。它是最常用的类型之一,并由 scikit-learn 实现。顾名思义,它由多层组成,且它是一个前馈网络,因为其层之间没有循环连接。层数越多,网络就越深。这些深度网络可以有多种形式,如MLP卷积神经网络CNNs)或长短期记忆网络LSTM)。后两者并未由 scikit-learn 实现,但这并不会妨碍我们讨论 CNN 的基本概念,并使用科学 Python 生态系统中的工具手动模拟它们。

在本章中,我们将讨论以下主题:

  • 了解 MLP

  • 分类衣物

  • 解开卷积的谜团

  • MLP 回归器

了解 MLP

当学习一种新的算法时,你可能会因为超参数的数量而感到灰心,并且发现很难决定从哪里开始。因此,我建议我们从回答以下两个问题开始:

  • 该算法的架构是如何设计的?

  • 该算法是如何训练的?

在接下来的章节中,我们将逐一回答这两个问题,并了解相应的超参数。

理解算法的架构

幸运的是,我们在第三章中获得的关于线性模型的知识,利用线性方程做决策,将在这里给我们一个良好的开端。简而言之,线性模型可以在以下图示中概述:

每个输入特征(x[i])都会乘以一个权重(w[i]),这些乘积的总和就是模型的输出(y)。此外,我们有时还会添加一个额外的偏置(阈值)及其权重。然而,线性模型的一个主要问题是它们本质上是线性的(显然!)。此外,每个特征都有自己的权重,而不考虑它的邻居。这种简单的架构使得模型无法捕捉到特征之间的任何交互。因此,你可以将更多的层堆叠在一起,如下所示:

这听起来像是一个潜在的解决方案;然而,根据简单的数学推导,这些乘法和加法组合仍然可以简化为一个线性方程。就好像所有这些层根本没有任何效果一样。因此,为了达到预期效果,我们希望在每次加法后应用非线性变换。这些非线性变换被称为激活函数,它们将模型转化为非线性模型。让我们看看它们如何融入模型中,然后我会进一步解释:

该模型有一个包含两个隐藏节点的单一隐藏层,如框内所示。在实际应用中,你可能会有多个隐藏层和多个节点。前述的激活函数应用于隐藏节点的输出。这里,我们使用了修正线性单元ReLU)作为激活函数;对于负值,它返回0,而对正值则保持不变。除了relu函数,identitylogistictanh激活函数也支持用于隐藏层,并且可以通过activation超参数进行设置。以下是这四种激活函数的表现形式:

如前所述,由于identity函数不会对其输入进行任何非线性变换,因此很少使用,因为它最终会将模型简化为一个线性模型。它还存在着梯度恒定的问题,这对用于训练的梯度下降算法帮助不大。因此,relu函数通常是一个不错的非线性替代方案。它是当前的默认设置,也是一个不错的首选;logistictanh激活函数则是下一个可选方案。

输出层也有其自己的激活函数,但它起着不同的作用。如果你还记得第三章,《使用线性方程做决策》,我们使用logistic函数将线性回归转变为分类器——也就是逻辑回归。输出的激活函数在这里起着完全相同的作用。下面列出了可能的输出激活函数及其对应的应用场景:

  • Identity 函数:在使用MLPRegressor进行回归时设置

  • Logistic 函数:在使用MLPClassifier进行二分类时设置

  • Softmax 函数在使用MLPClassifier区分三类或更多类别时设置

我们不手动设置输出激活函数;它们会根据是否使用MLPRegressorMLPClassifier以及后者用于分类的类别数自动选择。

如果我们看一下网络架构,很明显,另一个需要设置的重要超参数是隐藏层的数量以及每层的节点数。这个设置通过hidden_layer_sizes超参数来完成,它接受元组类型的值。为了实现前面图中的架构——也就是一个隐藏层,包含两个节点——我们将hidden_layer_sizes设置为2。将其设置为(10, 10, 5)则表示有三个隐藏层,前两个层每层包含 10 个节点,而第三层包含 5 个节点。

*## 训练神经网络

"心理学家告诉我们,要从经验中学习,必须具备两个要素:频繁的练习和即时的反馈。"

– 理查德·塞勒

大量研究人员的时间花费在改进他们神经网络的训练上。这也反映在与训练算法相关的超参数数量上。为了更好地理解这些超参数,我们需要研究以下的训练工作流程:

  1. 获取训练样本的子集。

  2. 将数据通过网络,进行预测。

  3. 通过比较实际值和预测值来计算训练损失。

  4. 使用计算出的损失来更新网络权重。

  5. 返回到步骤 1获取更多样本,如果所有样本都已使用完,则反复遍历训练数据,直到训练过程收敛。

逐步执行这些步骤,你可以看到在第一阶段需要设置训练子集的大小。这就是batch_size参数所设置的内容。正如我们稍后会看到的,你可以从一次使用一个样本,到一次使用整个训练集,再到介于两者之间的任何方式。第一步和第二步是直接的,但第三步要求我们知道应该使用哪种损失函数。至于可用的损失函数,当使用 scikit-learn 时,我们没有太多选择。在进行分类时,对数损失函数会自动为我们选择,而均方误差则是回归任务的默认选择。第四步是最棘手的部分,需要设置最多的超参数。我们计算损失函数相对于网络权重的梯度。

这个梯度告诉我们应该朝哪个方向移动,以减少损失函数。换句话说,我们利用梯度更新权重,希望通过迭代降低损失函数至最小值。负责这一操作的逻辑被称为求解器(solver)。不过,求解器值得单独一节,稍后会详细介绍。最后,我们多次遍历训练数据的次数被称为“迭代次数”(epochs),它通过max_iter超参数来设置。如果模型停止学习,我们也可以决定提前停止(early_stopping)。validation_fractionn_iter_no_changetol这些超参数帮助我们决定何时停止训练。更多关于它们如何工作的内容将在下一节讨论。

配置求解器。

计算损失函数(也称为成本函数或目标函数)后,我们需要找到能够最小化损失函数的最优网络权重。在第三章的线性模型中,使用线性方程做决策,损失函数被选择为凸函数。正如下面的图形所示,凸函数有一个最小值,这个最小值既是全局最小值也是局部最小值。这使得在优化该函数时,求解器的工作变得简单。对于非线性神经网络,损失函数通常是非凸的,这就需要在训练过程中更加小心,因此在这里给予求解器更多的关注:

MLP 的支持求解器可以分为有限记忆Broyden–Fletcher–Goldfarb–Shanno(LBFGS)和梯度下降随机梯度下降SGD)和Adam)。在这两种变体中,我们希望从损失函数中随机选择一个点,计算其斜率(梯度),并使用它来确定下一步应该朝哪个方向移动。请记住,在实际情况中,我们处理的维度远远超过这里展示的二维图形。此外,我们通常无法像现在这样看到整个图形:

*** LBFGS算法同时使用斜率(一阶导数)和斜率变化率(二阶导数),这有助于提供更好的覆盖;然而,它在训练数据规模较大时表现不佳。训练可能非常缓慢,因此推荐在数据集较小的情况下使用该算法,除非有更强大的并行计算机来帮助解决。

  • 梯度下降算法仅依赖于一阶导数。因此,需要更多的努力来帮助它有效地移动。计算出的梯度与learning_rate结合。这控制了每次计算梯度后,它的移动步长。移动过快可能会导致超过最小值并错过局部最小值,而移动过慢可能导致算法无法及时收敛。我们从由learning_rate_init定义的速率开始。如果我们设置learning_rate='constant',初始速率将在整个训练过程中保持不变。否则,我们可以设置速率在每一步中减少(按比例缩放),或者仅在模型无法再继续学习时才减少(自适应)。

*** 梯度下降可以使用整个训练数据集计算梯度,使用每次一个样本(sgd),或者以小批量的方式消耗数据(小批量梯度下降)。这些选择由batch_size控制。如果数据集无法完全加载到内存中,可能会阻止我们一次性使用整个数据集,而使用小批量可能会导致损失函数波动。我们将在接下来的部分中实际看到这种效果。* 学习率的问题在于它不能适应曲线的形状,特别是我们这里只使用了一阶导数。我们希望根据当前曲线的陡峭程度来控制学习速度。使学习过程更智能的一个显著调整是动量的概念。它根据当前和以前的更新来调整学习过程。sgd求解器默认启用动量,并且其大小可以通过momentum超参数进行设置。adam求解器将这一概念结合,并与为每个网络权重计算独立学习率的能力结合在一起。它通过beta_1beta_2来参数化。通常它们的默认值分别为0.90.999。由于adam求解器相比sgd求解器需要更少的调整工作,因此它是默认的求解器。然而,如果正确调整,sgd求解器也可以收敛到更好的解。* 最后,决定何时停止训练过程是另一个重要的决策。我们会多次遍历数据,直到达到max_iter设置的上限。然而,如果我们认为学习进展不足,可以提前停止。我们通过tol定义多少学习是足够的,然后可以立即停止训练过程,或者再给它一些机会(n_iter_no_change),然后决定是否停止。此外,我们可以将训练集的一部分单独留出(validation_fraction),用来更好地评估我们的学习过程。然后,如果我们设置early_stopping = True,训练过程将在验证集的改进未达到tol阈值并且已达到n_iter_no_change个周期时停止。****

****现在我们对事情如何运作有了一个高层次的了解,我认为最好的前进方式是将所有这些超参数付诸实践,并观察它们在真实数据上的效果。在接下来的部分中,我们将加载一个图像数据集,并利用它来进一步了解前述的超参数。

分类服装项

在本节中,我们将根据衣物图像对服装项进行分类。我们将使用 Zalando 发布的一个数据集。Zalando 是一家总部位于柏林的电子商务网站。他们发布了一个包含 70,000 张服装图片的数据集,并附有相应标签。每个服装项都属于以下 10 个标签之一:

{ 0: 'T-shirt/top ', 1: 'Trouser  ', 2: 'Pullover  ', 3: 'Dress  ', 4: 'Coat  ', 5: 'Sandal  ', 6: 'Shirt  ', 7: 'Sneaker  ', 8: 'Bag  ', 9: 'Ankle boot' }

该数据已发布在 OpenML 平台上,因此我们可以通过 scikit-learn 中的内置下载器轻松下载它。

下载 Fashion-MNIST 数据集

OpenML 平台上的每个数据集都有一个特定的 ID。我们可以将这个 ID 传递给fetch_openml()来下载所需的数据集,代码如下:

from sklearn.datasets import fetch_openml
fashion_mnist = fetch_openml(data_id=40996) 

类别标签以数字形式给出。为了提取它们的名称,我们可以从描述中解析出以下内容:

labels_s = '0 T-shirt/top \n1 Trouser \n2 Pullover \n3 Dress \n4 Coat \n5 Sandal \n6 Shirt \n7 Sneaker \n8 Bag \n9 Ankle boot'

fashion_label_translation = {
    int(k): v for k, v in [
        item.split(maxsplit=1) for item in labels_s.split('\n')
    ]
}

def translate_label(y, translation=fashion_label_translation):
    return pd.Series(y).apply(lambda y: translation[int(y)]).values

我们还可以创建一个类似于我们在第五章中创建的函数,使用最近邻的图像处理,来显示数据集中的图片:

def display_fashion(img, target, ax):

    if len(img.shape):
        w = int(np.sqrt(img.shape[0]))
        img = img.reshape((w, w))

    ax.imshow(img, cmap='Greys')
    ax.set_title(f'{target}')
    ax.grid(False)

上述函数除了matplotlib坐标轴外,还期望接收一张图片和一个目标标签来显示该图片。我们将在接下来的章节中看到如何使用它。

准备分类数据

在开发模型并优化其超参数时,你需要多次运行模型。因此,建议你先使用较小的数据集以减少训练时间。一旦达到可接受的模型效果,就可以添加更多数据并进行最终的超参数调优。稍后,我们将看到如何判断手头的数据是否足够,以及是否需要更多样本;但现在,让我们先使用一个包含 10,000 张图片的子集。

我故意避免在从原始数据集进行采样时以及将采样数据拆分为训练集和测试集时设置任何随机状态。由于没有设置随机状态,你应该期望最终结果在每次运行中有所不同。我做出这个选择是因为我的主要目标是专注于底层概念,而不希望你过于纠结最终结果。最终,你在现实场景中处理的数据会因问题的不同而有所不同,我们在前面的章节中已经学会了如何通过交叉验证更好地理解模型性能的边界。所以,在这一章中,和本书中的许多其他章节一样,不必太担心提到的模型的准确率、系数或学习行为与你的结果有所不同。

我们将使用train_test_split()函数两次。最初,我们将用它进行采样。之后,我们将再次使用它来执行将数据拆分为训练集和测试集的任务:

from sklearn.model_selection import train_test_split

fashion_mnist_sample = {}

fashion_mnist_sample['data'], _, fashion_mnist_sample['target'], _ = train_test_split(
    fashion_mnist['data'], fashion_mnist['target'], train_size=10000
)

x, y = fashion_mnist_sample['data'], fashion_mnist_sample['target']
x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.2)

这里的像素值在0255之间。通常,这样是可以的;然而,我们将要使用的求解器在数据落在更紧凑的范围内时收敛得更好。MinMaxScaler将帮助我们实现这一点,如下所示,而StandardScaler也是一个选择:

from sklearn.preprocessing import MinMaxScaler

scaler = MinMaxScaler()

x_train = scaler.fit_transform(x_train)
x_test = scaler.transform(x_test)

我们现在可以使用我们在上一节中创建的函数,将数字标签转换为名称:

translation = fashion_label_translation
y_train_translated = translate_label(y_train, translation=translation)
y_test_translated = translate_label(y_test, translation=translation)

如果你的原始标签是字符串格式,可以使用LabelEncoder将其转换为数值:

from sklearn.preprocessing import LabelEncoder

le = LabelEncoder()
y_train_encoded = le.fit_transform(y_train_translated)
y_test_encoded = le.transform(y_test_translated)

最后,让我们使用以下代码查看这些图片的样子:

import random 

fig, axs = plt.subplots(1, 10, figsize=(16, 12))

for i in range(10):
    rand = random.choice(range(x_train.shape[0]))
    display_fashion(x_train[rand], y_train_translated[rand], axs[i])

fig.show()

在这里,我们看到 10 张随机图片及其标签。我们循环显示 10 张随机图片,并使用我们之前创建的显示函数将它们并排展示:

现在数据已经准备好,接下来是时候看看超参数在实践中的效果了。

体验超参数的效果

在神经网络训练完成后,你可以检查它的权重(coefs_)、截距(intercepts_)以及损失函数的最终值(loss_)。另外一个有用的信息是每次迭代后的计算损失(loss_curve_)。这一损失曲线对于学习过程非常有帮助。

在这里,我们训练了一个神经网络,包含两个隐藏层,每个层有 100 个节点,并将最大迭代次数设置为500。目前,我们将其他所有超参数保持默认值:

from sklearn.neural_network import MLPClassifier
clf = MLPClassifier(hidden_layer_sizes=(100, 100), max_iter=500)
clf.fit(x_train, y_train_encoded)
y_test_pred = clf.predict(x_test)

网络训练完成后,我们可以使用以下代码绘制损失曲线:

pd.Series(clf.loss_curve_).plot(
    title=f'Loss Curve; stopped after {clf.n_iter_} epochs'
)

这将给我们如下图:

尽管算法被告知最多继续学习500个周期,但它在第 107^(次)周期后停止了。n_iter_no_change的默认值是10个周期。这意味着,自第 97^(次)周期以来,学习率没有足够改善,因此网络在 10 个周期后停了下来。请记住,默认情况下early_stoppingFalse,这意味着这个决策是在不考虑默认设置的10%验证集的情况下做出的。如果我们希望使用验证集来决定是否提前停止,我们应该将early_stopping设置为True

学习得不太快也不太慢

如前所述,损失函数(J)相对于权重(w)的梯度被用来更新网络的权重。更新是按照以下方程进行的,其中lr是学习率:

你可能会想,为什么需要学习率?为什么不直接通过设置lr = 1来使用梯度呢?在这一节中,我们将通过观察学习率对训练过程的影响来回答这个问题。

MLP 估算器中的另一个隐藏的宝藏是validation_scores_。像loss_curve_一样,这个参数也没有文档说明,并且其接口可能在未来的版本中发生变化。在MLPClassifier中,validation_scores_跟踪分类器在验证集上的准确度,而在MLPRegressor中,它跟踪回归器的 R²得分。

我们将使用验证得分(validation_scores_)来查看不同学习率的效果。由于这些得分只有在early_stopping设置为True时才会存储,而且我们不想提前停止,所以我们还将n_iter_no_change设置为与max_iter相同的值,以取消提前停止的效果。

默认的学习率是0.001,并且在训练过程中默认保持不变。在这里,我们将选择一个更小的训练数据子集——1,000 个样本——并尝试从0.00011的不同学习率:

from sklearn.neural_network import MLPClassifier

learning_rate_init_options = [1, 0.1, 0.01, 0.001, 0.0001]

fig, axs = plt.subplots(1, len(learning_rate_init_options), figsize=(15, 5), sharex=True, sharey=True)

for i, learning_rate_init in enumerate(learning_rate_init_options):

    print(f'{learning_rate_init} ', end='')

    clf = MLPClassifier(
        hidden_layer_sizes=(500, ), 
        learning_rate='constant',
        learning_rate_init=learning_rate_init,
        validation_fraction=0.2,
        early_stopping=True, 
        n_iter_no_change=120,
        max_iter=120, 
        solver='sgd',
        batch_size=25,
        verbose=0,
    )

    clf.fit(x_train[:1000,:], y_train_encoded[:1000])

    pd.Series(clf.validation_scores_).plot(
        title=f'learning_rate={learning_rate_init}', 
        kind='line', 
        color='k',
        ax=axs[i]
    )

fig.show()

以下图表比较了不同学习率下验证得分的进展。为了简洁起见,格式化坐标轴的代码被省略:

正如我们所看到的,当将学习率设置为1时,网络无法学习,准确度停留在约 10%。这是因为较大的步伐更新权重导致梯度下降过度,错过了局部最小值。理想情况下,我们希望梯度下降能够在曲线上智慧地移动;它不应该急于求成,错过最优解。另一方面,我们可以看到,学习率非常低的0.0001导致网络训练时间过长。显然,120轮训练不够,因此需要更多的轮次。在这个例子中,学习率为0.01看起来是一个不错的平衡。

学习率的概念通常在迭代方法中使用,以防止过度跳跃。它可能有不同的名称和不同的解释,但本质上它起到相同的作用。例如,在强化学习领域,贝尔曼方程中的折扣因子可能类似于这里的学习率。

选择合适的批量大小

在处理大量训练数据时,你不希望在计算梯度时一次性使用所有数据,尤其是当无法将这些数据完全加载到内存时。使用数据的小子集是我们可以配置的选项。在这里,我们将尝试不同的批量大小,同时保持其他设置不变。请记住,当batch_size设置为1时,模型会非常慢,因为它在每个训练实例后都更新一次权重:

from sklearn.neural_network import MLPClassifier

batch_sizes = [1, 10, 100, 1500]

fig, axs = plt.subplots(1, len(batch_sizes), figsize=(15, 5), sharex=True, sharey=True)

for i, batch_size in enumerate(batch_sizes):

    print(f'{batch_size} ', end='')

    clf = MLPClassifier(
        hidden_layer_sizes=(500, ), 
        learning_rate='constant',
        learning_rate_init=0.001, 
        momentum=0,
        max_iter=250, 
        early_stopping=True,
        n_iter_no_change=250,
        solver='sgd',
        batch_size=batch_size,
        verbose=0,
    )

    clf.fit(x_train[:1500,:], y_train_encoded[:1500])

    pd.Series(clf.validation_scores_).plot( 
        title=f'batch_size={batch_size}',
        color='k',
        kind='line', 
        ax=axs[i]
    )

fig.show()

这张图给我们提供了四种批量大小设置及其效果的可视化比较。为了简洁起见,部分格式化代码被省略:

你可以看到,为什么小批量梯度下降在实践中成为了常态,不仅是因为内存限制,还因为较小的批次帮助我们的模型在此处更好地学习。尽管小批次大小下验证得分的波动较大,最终的结果还是达到了预期。另一方面,将batch_size设置为1会减慢学习过程。

到目前为止,我们已经调整了多个超参数,并见证了它们对训练过程的影响。除了这些超参数,还有两个问题仍然需要回答:

  • 多少训练样本足够?

  • 多少轮训练足够?

检查是否需要更多的训练样本

我们希望比较当使用整个训练样本(100%)时,使用 75%、50%、25%、10%和 5%的效果。learning_curve函数在这种比较中很有用。它使用交叉验证来计算不同样本量下的平均训练和测试分数。在这里,我们将定义不同的采样比例,并指定需要三折交叉验证:

from sklearn.model_selection import learning_curve

train_sizes = [1, 0.75, 0.5, 0.25, 0.1, 0.05]

train_sizes, train_scores, test_scores = learning_curve(
    MLPClassifier(
        hidden_layer_sizes=(100, 100), 
        solver='adam',
        early_stopping=False
    ), 
    x_train, y_train_encoded,
    train_sizes=train_sizes,
    scoring="precision_macro",
    cv=3,
    verbose=2,
    n_jobs=-1
)

完成后,我们可以使用以下代码绘制训练和测试分数随着样本量增加的进展:

df_learning_curve = pd.DataFrame(
    {
        'train_sizes': train_sizes,
        'train_scores': train_scores.mean(axis=1),
        'test_scores': test_scores.mean(axis=1)
    }
).set_index('train_sizes')

df_learning_curve['train_scores'].plot(
    title='Learning Curves', ls=':',
)

df_learning_curve['test_scores'].plot(
    title='Learning Curves', ls='-',
)

结果图表显示了随着更多训练数据的增加,分类器准确度的提升。注意到训练分数保持不变,而测试分数才是我们真正关心的,它在一定数据量后似乎趋于饱和:

在本章早些时候,我们从原始的 70,000 张图片中抽取了 10,000 张样本。然后将其拆分为 8,000 张用于训练,2,000 张用于测试。从学习曲线图中我们可以看到,实际上可以选择一个更小的训练集。在 2,000 张样本之后,额外的样本并没有带来太大的价值。

通常,我们希望使用尽可能多的数据样本来训练我们的模型。然而,在调整模型超参数时,我们需要做出妥协,使用较小的样本来加速开发过程。一旦完成这些步骤,就建议在整个数据集上训练最终模型。

检查是否需要更多的训练轮次

这一次,我们将使用validation_curve函数。它的工作原理类似于learning_curve函数,但它比较的是不同的超参数设置,而不是不同的训练样本量。在这里,我们将看到使用不同max_iter值的效果:

from sklearn.model_selection import validation_curve

max_iter_range = [5, 10, 25, 50, 75, 100, 150]

train_scores, test_scores = validation_curve(
    MLPClassifier(
        hidden_layer_sizes=(100, 100), 
        solver='adam',
        early_stopping=False
    ), 
    x_train, y_train_encoded,
    param_name="max_iter", param_range=max_iter_range,
    scoring="precision_macro",
    cv=3,
    verbose=2,
    n_jobs=-1
)

通过训练和测试分数,我们可以像在上一节中一样绘制它们,从而得到以下图表:

在这个示例中,我们可以看到,测试分数大约在25轮后停止提高。训练分数在此之后继续提升,直到达到 100%,这是过拟合的表现。实际上,我们可能不需要这个图表,因为我们使用early_stoppingtoln_iter_no_change超参数来停止训练过程,一旦学习足够并且避免过拟合。

选择最佳的架构和超参数

到目前为止,我们还没有讨论网络架构。我们应该有多少层,每层应该有多少节点?我们也没有比较不同的激活函数。正如你所看到的,有许多超参数可以选择。在本书之前的部分,我们提到过一些工具,如GridSearchCVRandomizedSearchCV,它们帮助你选择最佳超参数。这些仍然是很好的工具,但如果我们决定使用它们来调节每个参数的所有可能值,它们可能会太慢。如果我们在使用过多的训练样本或进行太多的训练轮次时,它们也可能变得过于缓慢。

我们在前面部分看到的工具应该能帮助我们通过排除一些超参数范围,在一个稍微小一点的“大堆”中找到我们的“针”。它们还将允许我们坚持使用更小的数据集并缩短训练时间。然后,我们可以有效地使用GridSearchCVRandomizedSearchCV来微调我们的神经网络。

在可能的情况下,建议使用并行化。GridSearchCV和**RandomizedSearchCV允许我们利用机器上的不同处理器同时训练多个模型。我们可以通过n_jobs设置来实现这一点。这意味着,通过使用处理器数量较多的机器,你可以显著加速超参数调优过程。至于数据量,考虑到我们将执行 k 折交叉验证,并且训练数据会被进一步划分,我们应该增加比前一部分估算的数据量更多的数据。现在,话不多说,让我们使用GridSearchCV来调优我们的网络:

**```py
from sklearn.model_selection import GridSearchCV

param_grid = {
'hidden_layer_sizes': [(50,), (50, 50), (100, 50), (100, 100), (500, 100), (500, 100, 100)],
'activation': ['logistic', 'tanh', 'relu'],
'learning_rate_init': [0.01, 0.001],
'solver': ['sgd', 'adam'],
}

gs = GridSearchCV(
estimator=MLPClassifier(
max_iter=50,
batch_size=50,
early_stopping=True,
),
param_grid=param_grid,
cv=4,
verbose=2,
n_jobs=-1
)

gs.fit(x_train[:2500,:], y_train_encoded[:2500])


它在四个 CPU 上运行了 14 分钟,选择了以下超参数:

+   **激活函数**:`relu`

+   **隐藏层大小**:`(500, 100)`

+   **初始学习率**:`0.01`

+   **优化器**:`adam`

所选模型在测试集上达到了**85.6%**的**微 F 得分**。通过使用`precision_recall_fscore_support`函数,你可以更详细地看到哪些类别比其他类别更容易预测:

![](https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/f4e18add-f143-458a-893f-b185ce43422a.png)

理想情况下,我们应该使用整个训练集重新训练,但我现在先跳过这一部分。最终,开发一个最佳的神经网络通常被看作是艺术与科学的结合。然而,了解你的超参数及其效果的衡量方式应该让这一过程变得简单明了。然后,像`GridSearchCV`和`RandomizedSearchCV`这样的工具可以帮助你自动化部分过程。自动化在很多情况下优于技巧。

在进入下一个话题之前,我想稍微离题一下,给你展示如何构建自己的激活函数。

## 添加你自己的激活函数

许多激活函数的一个常见问题是梯度消失问题。如果你观察 `logistic` 和 `tanh` 激活函数的曲线,你会发现对于高正值和负值,曲线几乎是水平的。这意味着在这些高值下,曲线的梯度几乎是常数。这会阻碍学习过程。`relu` 激活函数尝试解决这个问题,但它仅解决了正值部分的问题,未能处理负值部分。这促使研究人员不断提出不同的激活函数。在这里,我们将把**ReLU**激活函数与其修改版**Leaky ReLU**进行对比:

![](https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/18e43923-6e2d-415a-b2a7-bc78953f2730.png)

正如你在**Leaky ReLU**的示例中看到的,负值部分的线条不再是常数,而是以一个小的速率递减。为了添加**Leaky ReLU**,我需要查找 scikit-learn 中 `relu` 函数的构建方式,并毫不犹豫地修改代码以满足我的需求。基本上有两种方法可以构建。第一种方法用于前向传播路径,并仅将激活函数应用于其输入;第二种方法则将激活函数的导数应用于计算得到的误差。以下是我稍作修改以便简洁的 `relu` 的两个现有方法:

```py
def relu(X):
    return np.clip(X, 0, np.finfo(X.dtype).max)

definplace_relu_derivative(Z, delta):
    delta[Z==0] =0

在第一种方法中,使用了 NumPy 的 clip() 方法将负值设置为 0。由于 clip 方法需要设置上下界,因此代码中的难懂部分是获取该数据类型的最大值,将其作为上界。第二种方法获取激活函数的输出(Z)以及计算得到的误差(delta)。它应该将误差乘以激活输出的梯度。然而,对于这种特定的激活函数,正值的梯度为 1,负值的梯度为 0。因此,对于负值,误差被设置为 0,即当 relu 返回 0 时,误差就被设置为 0

leaky_relu 保持正值不变,并将负值乘以一个小的值 0.01。现在,我们只需使用这些信息来构建新的方法:

leaky_relu_slope = 0.01

def leaky_relu(X):
    X_min = leaky_relu_slope * np.array(X)
    return np.clip(X, X_min, np.finfo(X.dtype).max)

def inplace_leaky_relu_derivative(Z, delta):
    delta[Z < 0] = leaky_relu_slope * delta[Z < 0]

回顾一下,leaky_relu 在正值时的斜率为 1,而在负值时的斜率为 leaky_relu_slope 常量。因此,我们将 Z 为负值的部分的增量乘以 leaky_relu_slope。现在,在使用我们新的方法之前,我们需要将它们注入到 scikit-learn 的代码库中,具体如下:

from sklearn.neural_network._base import ACTIVATIONS, DERIVATIVES

ACTIVATIONS['leaky_relu'] = leaky_relu
DERIVATIVES['leaky_relu'] = inplace_leaky_relu_derivative

然后,你可以像最初就有 MLPClassifier 一样直接使用它:

clf = MLPClassifier(activation='leaky_relu')

像这样黑客攻击库迫使我们去阅读其源代码,并更好地理解它。它也展示了开源的价值,让你不再受限于现有的代码。接下来的部分,我们将继续进行黑客攻击,构建我们自己的卷积层。

解开卷积的复杂性

"深入观察大自然,你将更好地理解一切"

– 阿尔伯特·爱因斯坦

关于使用神经网络进行图像分类的章节,不能不提到卷积神经网络(CNN)。尽管 scikit-learn 并未实现卷积层,但我们仍然可以理解这一概念,并了解它是如何工作的。

让我们从以下的5 x 5 图像开始,看看如何将卷积层应用于它:

x_example = array(
    [[0, 0, 0, 0, 0],
     [0, 0, 0, 0, 0],
     [0, 0, 1, 1, 0],
     [0, 0, 1, 1, 0],
     [0, 0, 0, 0, 0]]
)

在自然语言处理领域,词语通常作为字符与整个句子之间的中介来进行特征提取。在这张图中,也许较小的块比单独的像素更适合作为信息单元。本节的目标是寻找表示这些小的2 x 23 x 3N x N 块的方法。我们可以从平均值作为总结开始。我们基本上可以通过将每个像素乘以 1,然后将总和除以 9,来计算每个3 x 3 块的平均值;这个块中有 9 个像素。对于边缘上的像素,因为它们在所有方向上没有邻居,我们可以假装在图像周围有一个额外的 1 像素边框,所有像素都设置为 0。通过这样做,我们得到另一个5 x 5 的数组。

这种操作被称为卷积,而SciPy提供了一种实现卷积的方法。3 x 3 的全 1 矩阵也被称为卷积核或权重。在这里,我们指定全 1 的卷积核,并在后面进行 9 的除法。我们还指定需要一个全零的边框,通过将mode设置为constantcval设置为0,正如您在以下代码中所看到的那样:

from scipy import ndimage

kernel = [[1,1,1],[1,1,1],[1,1,1]] 
x_example_convolve = ndimage.convolve(x_example, kernel, mode='constant', cval=0)
x_example_convolve = x_example_convolve / 9 

这是原始图像与卷积输出之间的对比:

计算平均值给我们带来了模糊的原始图像版本,所以下次当你需要模糊图像时,你知道该怎么做。将每个像素乘以某个权重并计算这些乘积的总和听起来像是一个线性模型。此外,我们可以将平均值看作是线性模型,其中所有权重都设置为 。因此,你可以说我们正在为图像的每个块构建迷你线性模型。记住这个类比,但现在,我们必须手动设置模型的权重。

虽然每个块使用的线性模型与其他块完全相同,但没有什么可以阻止每个块内的像素被不同的权重相乘。事实上,不同的卷积核和不同的权重会产生不同的效果。在下一节中,我们将看到不同卷积核对我们Fashion-MNIST数据集的影响。

通过卷积提取特征

与其逐个处理图像,我们可以调整代码一次性对多张图像进行卷积。我们的 Fashion-MNIST 数据集中的图像是平铺的,因此我们需要将它们重新调整为28 x 28 像素的格式。然后,我们使用给定的卷积核进行卷积,最后,确保所有像素值都在01之间,使用我们最喜爱的MinMaxScaler参数:

from scipy import ndimage
from sklearn.preprocessing import MinMaxScaler

def convolve(x, kernel=[[1,1,1],[1,1,1],[1,1,1]]):
    w = int(np.sqrt(x.shape[1]))
    x = ndimage.convolve(
        x.reshape((x.shape[0], w, w)), [kernel], 
        mode='constant', cval=0.0
    ) 
    x = x.reshape(x.shape[0], x.shape[1]*x.shape[2]) 
    return MinMaxScaler().fit_transform(x)

接下来,我们可以将其作为我们的训练和测试数据,如下所示:

sharpen_kernel = [[0,-1,0], [-1,5,-1], [0,-1,0]]
x_train_conv = convolve(x_train, sharpen_kernel)
x_test_conv = convolve(x_test, sharpen_kernel)

这里有几个卷积核:第一个用于锐化图像,接着是一个用于强调垂直边缘的卷积核,而最后一个则强调水平边缘:

  • 锐化[[0,-1,0], [-1,5,-1], [0,-1,0]]

  • 垂直边缘[[-1,0,1], [-2,0,2], [-1,0,1]]

  • 水平边缘[[-1,-2,-1], [0,0,0], [1,2,1]]

将这些卷积核传给我们刚刚创建的卷积函数将得到以下效果:

你可以在互联网上找到更多的卷积核,或者你也可以尝试自己定义,看看它们的效果。卷积核也是直观的;锐化卷积核显然更重视中央像素而非其周围的像素。

每个不同的卷积变换从我们的图像中捕获了特定的信息。因此,我们可以将它们看作一个特征工程层,从中提取特征供分类器使用。然而,随着我们将更多的卷积变换添加到数据中,数据的大小会不断增长。在下一部分,我们将讨论如何处理这个问题。

通过最大池化来减少数据的维度

理想情况下,我们希望将前几个卷积变换的输出输入到我们的神经网络中。然而,如果我们的图像由 784 个像素构成,那么仅仅连接三个卷积函数的输出将会产生 2,352 个特征,784 x 3。这将会减慢我们的训练过程,而且正如我们在本书前面所学到的,特征越多并不总是越好。

要将图像缩小为原来大小的四分之一——即宽度和高度各缩小一半——你可以将图像划分为多个2 x 2的补丁,然后在每个补丁中取最大值来表示整个补丁。这正是最大池化的作用。为了实现它,我们需要在计算机终端通过pip安装另一个名为scikit-image的库:

pipinstallscikit-image

然后,我们可以创建我们的最大池化函数,如下所示:


from skimage.measure import block_reduce
from sklearn.preprocessing import MinMaxScaler

def maxpool(x, size=(2,2)):
    w = int(np.sqrt(x.shape[1]))
    x = np.array([block_reduce(img.reshape((w, w)), block_size=(size[0], size[1]), func=np.max) for img in x])
    x = x.reshape(x.shape[0], x.shape[1]*x.shape[2]) 
    return MinMaxScaler().fit_transform(x)

然后,我们可以将它应用到其中一个卷积的输出上,具体如下:

x_train_maxpool = maxpool(x_train_conv, size=(5,5))
x_test_maxpool = maxpool(x_test_conv, size=(5,5))

5 x 5的补丁上应用最大池化将把数据的大小从28 x 28缩小到6 x 6,即原始大小的不到 5%。

将一切整合在一起

FeatureUnion管道可以在 scikit-learn 中将多个转换器的输出组合起来。换句话说,如果 scikit-learn 有可以对图像进行卷积并对这些卷积输出进行最大池化的转换器,那么你就可以将多个转换器的输出结合起来,每个转换器都使用不同的卷积核。幸运的是,我们可以自己构建这个转换器,并通过FeatureUnion将它们的输出结合起来。我们只需要让它们提供 fit、transform 和 fit_transform 方法,如下所示:

class ConvolutionTransformer:

    def __init__(self, kernel=[], max_pool=False, max_pool_size=(2,2)):
        self.kernel = kernel
        self.max_pool = max_pool
        self.max_pool_size = max_pool_size

    def fit(self, x):
        return x

    def transform(self, x, y=None):
        x = convolve(x, self.kernel)
        if self.max_pool:
            x = maxpool(x, self.max_pool_size)
        return x

    def fit_transform(self, x, y=None):
        x = self.fit(x)
        return self.transform(x)

你可以在初始化步骤中指定使用的卷积核。你还可以通过将max_pool设置为False来跳过最大池化部分。这里,我们定义了三个卷积核,并在对每个4 x 4图像块进行池化时,组合它们的输出:

kernels = [
    ('Sharpen', [[0,-1,0], [-1,5,-1], [0,-1,0]]),
    ('V-Edge', [[-1,0,1], [-2,0,2], [-1,0,1]]),
    ('H-Edge', [[-1,-2,-1], [0,0,0], [1,2,1]]),
]

from sklearn.pipeline import FeatureUnion

funion = FeatureUnion(
    [
        (kernel[0], ConvolutionTransformer(kernel=kernel[1], max_pool=True, max_pool_size=(4,4)))
        for kernel in kernels
    ]
)

x_train_convs = funion.fit_transform(x_train)
x_test_convs = funion.fit_transform(x_test)

然后,我们可以将FeatureUnion管道的输出传递到我们的神经网络中,如下所示:

from sklearn.neural_network import MLPClassifier

mlp = MLPClassifier(
    hidden_layer_sizes=(500, 300),
    activation='relu',
    learning_rate_init=0.01,
    solver='adam',
    max_iter=80,
    batch_size=50,
    early_stopping=True,
)

mlp.fit(x_train_convs, y_train)
y_test_predict = mlp.predict(x_test_convs)

该网络达到了微 F 值79%。你可以尝试添加更多的卷积核并调整网络的超参数,看看我们是否能比没有卷积层时获得更好的得分。

我们必须手动设置卷积层的核权重。然后,我们显示它们的输出,以查看它们是否直观合理,并希望它们在使用时能提高我们模型的表现。这听起来不像是一个真正的数据驱动方法。理想情况下,你希望权重能够从数据中学习。这正是实际的卷积神经网络(CNN)所做的。我建议你了解 TensorFlow 和 PyTorch,它们提供了 CNN 的实现。如果你能将它们的准确度与我们这里构建的模型进行比较,那将非常好。

MLP 回归器

除了MLPClassifier,还有它的回归兄弟MLPRegressor。这两者共享几乎相同的接口。它们之间的主要区别在于每个使用的损失函数和输出层的激活函数。回归器优化平方损失,最后一层由恒等函数激活。所有其他超参数相同,包括隐藏层的四种激活选项。

两个估算器都有一个partial_fit()方法。你可以在估算器已经拟合后,获取额外的训练数据时,使用它来更新模型。在MLPRegressor中,score()计算的是回归器的R*²,而分类器的准确度则由MLPClassifier计算。

****# 总结

我们现在已经对人工神经网络(ANNs)及其底层技术有了很好的理解。我推荐使用像 TensorFlow 和 PyTorch 这样的库来实现更复杂的架构,并且可以在 GPU 上扩展训练过程。不过,你已经有了很好的起步。这里讨论的大部分概念可以转移到任何其他库上。你将使用相似的激活函数和求解器,以及这里讨论的大部分其他超参数。scikit-learn 的实现仍然适用于原型开发以及我们想要超越线性模型的情况,且不需要太多隐藏层。

此外,像梯度下降这样的求解器在机器学习领域是如此普遍,理解它们的概念对于理解其他不是神经网络的算法也很有帮助。我们之前看到梯度下降如何用于训练线性回归器、逻辑回归器以及支持向量机。我们还将在下一章中使用它们与梯度提升算法。

无论你使用什么算法,像学习率以及如何估计所需训练数据量等概念都是非常有用的。得益于 scikit-learn 提供的有用工具,这些概念得以轻松应用。即使在我并非在构建机器学习解决方案时,我有时也会使用 scikit-learn 的工具。

如果人工神经网络(ANNs)和深度学习是媒体的鸦片,那么集成算法就是大多数从业者在解决任何商业问题或在 Kaggle 上争夺$10,000 奖金时的“面包和黄油”。

在下一章中,我们将学习不同的集成方法及其理论背景,然后亲自动手调优它们的超参数。****************

第八章:集成方法——当一个模型不足以应对时

在前面的三章中,我们看到神经网络如何直接或间接地帮助解决自然语言理解和图像处理问题。这是因为神经网络已被证明能够很好地处理同质数据;即,如果所有输入特征属于同一类——像素、单词、字符等。另一方面,当涉及到异质数据时,集成方法被认为能够发挥优势。它们非常适合处理异质数据——例如,一列包含用户的年龄,另一列包含他们的收入,第三列包含他们的居住城市。

你可以将集成估计器视为元估计器;它们由多个其他估计器的实例组成。它们组合底层估计器的方式决定了不同集成方法之间的差异——例如,袋装法提升法。在本章中,我们将详细探讨这些方法,并理解它们的理论基础。我们还将学习如何诊断自己的模型,理解它们为何做出某些决策。

一如既往,我还希望借此机会在剖析每个单独的算法时,顺便阐明一些常见的机器学习概念。在本章中,我们将看到如何利用分类器的概率和回归范围来处理估计器的不确定性。

本章将讨论以下内容:

  • 集成方法的动机

  • 平均法/袋装集成方法

  • 提升集成方法

  • 回归范围

  • ROC 曲线

  • 曲线下的面积

  • 投票法与堆叠集成方法

回答为什么选择集成方法的问题?

集成方法背后的主要思想是将多个估计器结合起来,使它们的预测效果比单一估计器更好。然而,你不应该仅仅期待多个估计器的简单结合就能带来更好的结果。多个估计器的预测组合如果犯了完全相同的错误,其结果会与每个单独的估计器一样错误。因此,考虑如何减轻单个估计器所犯的错误是非常有帮助的。为此,我们需要回顾一下我们熟悉的偏差-方差二分法。很少有机器学习的老师比这对概念更能帮助我们了。

如果你还记得第二章《用树做决策》,当我们允许决策树尽可能生长时,它们往往会像手套一样拟合训练数据,但无法很好地推广到新的数据点。我们称之为过拟合,在线性模型和少量最近邻的情况下也看到了相同的行为。相反,严格限制树的生长,限制线性模型中的特征数量,或者要求太多邻居投票,都会导致模型偏向并且拟合不足。因此,我们必须在偏差-方差和拟合不足-过拟合的对立之间找到最佳平衡。

在接下来的章节中,我们将采取一种不同的方法。我们将把偏差-方差的对立看作一个连续的尺度,从这个尺度的一端开始,并利用集成的概念向另一端推进。在下一节中,我们将从高方差估计器开始,通过平均它们的结果来减少它们的方差。随后,我们将从另一端开始,利用提升的概念来减少估计器的偏差。

通过平均结合多个估计器

"为了从多个证据源中提取最有用的信息,你应该始终尝试使这些来源彼此独立。"

–丹尼尔·卡尼曼

如果单棵完全生长的决策树发生过拟合,并且在最近邻算法中,增加投票者数量产生相反的效果,那么为什么不将这两个概念结合起来呢?与其拥有一棵树,不如拥有一片森林,将其中每棵树的预测结果结合起来。然而,我们并不希望森林中的所有树都是相同的;我们希望它们尽可能多样化。袋装和随机森林元估计器就是最常见的例子。为了实现多样性,它们确保每个单独的估计器都在训练数据的随机子集上进行训练——因此在随机森林中有了随机这个前缀。每次抽取随机样本时,可以进行有放回抽样(自助法)或无放回抽样(粘贴法)。术语袋装代表自助法聚合,因为估计器在抽样时是有放回的。此外,为了实现更多的多样性,集成方法还可以确保每棵树看到的训练特征是随机选择的子集。

这两种集成方法默认使用决策树估计器,但袋装集成方法可以重新配置为使用其他任何估计器。理想情况下,我们希望使用高方差估计器。各个估计器做出的决策通过投票或平均来结合。

提升多个有偏估计器

"如果我看得比别人更远,那是因为我站在巨人的肩膀上。"

–艾萨克·牛顿

与完全生长的树相比,浅层树往往会产生偏差。提升一个偏差估计器通常通过AdaBoost梯度提升来实现。AdaBoost 元估计器从一个弱估计器或偏差估计器开始,然后每一个后续估计器都从前一个估计器的错误中学习。我们在第二章《使用决策树做决策》中看到过,我们可以给每个训练样本分配不同的权重,从而让估计器对某些样本给予更多关注。 在AdaBoost中,前一个估计器所做的错误预测会赋予更多的权重,以便后续的估计器能够更加关注这些错误。

梯度提升元估计器采用了稍微不同的方法。它从一个偏差估计器开始,计算其损失函数,然后构建每个后续估计器以最小化前一个估计器的损失函数。正如我们之前所看到的,梯度下降法在迭代最小化损失函数时非常有用,这也是梯度提升算法名称中“梯度”前缀的由来。

由于这两种集成方法都是迭代性质的,它们都有一个学习率来控制学习速度,并确保在收敛时不会错过局部最小值。像自助法算法一样,AdaBoost 并不局限于使用决策树作为基本估计器。

现在我们对不同的集成方法有了一个大致了解,接下来可以使用真实的数据来演示它们在实际中的应用。这里描述的每个集成方法都可以用于分类和回归。分类器和回归器的超参数对于每个集成都几乎是相同的。因此,我将选择一个回归问题来演示每个算法,并简要展示随机森林和梯度提升算法的分类能力,因为它们是最常用的集成方法。

在下一节中,我们将下载由加利福尼亚大学欧文分校UCI)准备的数据集。它包含了 201 个不同汽车的样本以及它们的价格。我们将在后面的章节中使用该数据集通过回归预测汽车价格。

下载 UCI 汽车数据集

汽车数据集由 Jeffrey C. Schlimmer 创建并发布在 UCI 的机器学习库中。它包含了 201 辆汽车的信息以及它们的价格。特征名称缺失,不过我可以从数据集的描述中找到它们(archive.ics.uci.edu/ml/machine-learning-databases/autos/imports-85.names)。因此,我们可以先查看 URL 和特征名称,如下所示:

url = 'http://archive.ics.uci.edu/ml/machine-learning-databases/autos/imports-85.data'

header = [
    'symboling',
    'normalized-losses',
    'make',
    # ... some list items are omitted for brevity 
    'highway-mpg',
    'price',

]

然后,我们使用以下代码来下载我们的数据。

df = pd.read_csv(url, names=header, na_values='?')

在数据集描述中提到,缺失值被替换为问号。为了让代码更符合 Python 风格,我们将na_values设置为'?',用 NumPy 的不是数字NaN)替换这些问号。

接下来,我们可以进行探索性数据分析EDA),检查缺失值的百分比,并查看如何处理它们。

处理缺失值

现在,我们可以检查哪些列缺失值最多:

cols_with_missing = df.isnull().sum()
cols_with_missing[
    cols_with_missing > 0
]

这为我们提供了以下列表:

normalized-losses    41
num-of-doors          2
bore                  4
stroke                4
horsepower            2
peak-rpm              2
price                 4

由于价格是我们的目标值,我们可以忽略那些价格未知的四条记录:

df = df[~df['price'].isnull()]

对于剩余的特征,我认为我们可以删除normalized-losses列,因为其中有 41 个值是缺失的。稍后,我们将使用数据插补技术处理其他缺失值较少的列。你可以使用以下代码删除normalized-losses列:

df.drop(labels=['normalized-losses'], axis=1, inplace=True)

此时,我们已经有了一个包含所有必要特征及其名称的数据框。接下来,我们想将数据拆分为训练集和测试集,然后准备特征。不同的特征类型需要不同的准备工作。你可能需要分别缩放数值特征并编码类别特征。因此,能够区分数值型特征和类别型特征是一个很好的实践。

区分数值特征和类别特征

在这里,我们将创建一个字典,分别列出数值型和类别型特征。我们还将这两者合并为一个列表,并提供目标列的名称,如以下代码所示:

features = {
    'categorical': [
        'make', 'fuel-type', 'aspiration', 'num-of-doors', 
        'body-style', 'drive-wheels', 'engine-location', 
        'engine-type', 'num-of-cylinders', 'fuel-system',

    ],
    'numerical': [
        'symboling', 'wheel-base', 'length', 'width', 'height', 
        'curb-weight', 'engine-size', 'bore', 'stroke', 
        'compression-ratio', 'horsepower', 'peak-rpm', 
        'city-mpg', 'highway-mpg', 
    ],
}

features['all'] = features['categorical'] + features['numerical']

target = 'price'

通过这样做,你可以以不同的方式处理列。此外,为了保持理智并避免将来打印过多的零,我将价格重新缩放为千元,如下所示:

df[target] = df[target].astype(np.float64) / 1000

你也可以单独显示某些特征。在这里,我们打印一个随机样本,仅显示类别特征:

 df[features['categorical']].sample(n=3, random_state=42)

这里是生成的行。我将random_state设置为42,确保我们得到相同的随机行:

所有其他转换,如缩放、插补和编码,都应该在拆分数据集为训练集和测试集之后进行。这样,我们可以确保没有信息从测试集泄漏到训练样本中。

将数据拆分为训练集和测试集

在这里,我们保留 25%的数据用于测试,其余的用于训练:

from sklearn.model_selection import train_test_split
df_train, df_test = train_test_split(df, test_size=0.25, random_state=22)

然后,我们可以使用前面部分的信息创建我们的xy值:

x_train = df_train[features['all']]
x_test = df_test[features['all']]

y_train = df_train[target]
y_test = df_test[target]

和往常一样,对于回归任务,了解目标值的分布是很有用的:

y_train.plot(
    title="Distribution of Car Prices (in 1000's)",
    kind='hist', 
)

直方图通常是理解分布的一个好选择,如下图所示:

我们可能会稍后回到这个分布,来将回归模型的平均误差放到合理的范围内。此外,你还可以使用这个范围进行合理性检查。例如,如果你知道所有看到的价格都在 5,000 到 45,000 之间,你可能会决定在将模型投入生产时,如果模型返回的价格远离这个范围,就触发警报。

填充缺失值并编码类别特征

在启用我们的集成方法之前,我们需要确保数据中没有空值。我们将使用来自第四章《准备数据》的SimpleImputer函数,用每列中最常见的值来替换缺失值:

from sklearn.impute import SimpleImputer
imp = SimpleImputer(missing_values=np.nan, strategy='most_frequent')

x_train = imp.fit_transform(x_train)
x_test = imp.transform(x_test)

你可能已经看到我多次抱怨 scikit-learn 的转换器,它们不尊重列名,并且坚持将输入数据框转换为 NumPy 数组。为了不再抱怨,我通过使用以下ColumnNamesKeeper类来解决我的痛点。每当我将它包装在转换器周围时,它会确保所有的数据框都保持不变:

class ColumnNamesKeeper:

    def __init__(self, transformer):
        self._columns = None
        self.transformer = transformer

    def fit(self, x, y=None):
        self._columns = x.columns
        self.transformer.fit(x)

    def transform(self, x, y=None):
        x = self.transformer.transform(x)
        return pd.DataFrame(x, columns=self._columns)

    def fit_transform(self, x, y=None):
        self.fit(x, y)
        return self.transform(x)

如你所见,它主要在调用fit方法时保存列名。然后,我们可以使用保存的列名在变换步骤后重新创建数据框。

ColumnNamesKeeper的代码可以通过继承sklearn.base.BaseEstimatorsklearn.base.TransformerMixin来进一步简化。如果你愿意编写更符合 scikit-learn 风格的转换器,可以查看该库内置转换器的源代码。

现在,我可以再次调用SimpleImputer,同时保持x_trainx_test作为数据框:

from sklearn.impute import SimpleImputer

imp = ColumnNamesKeeper(
    SimpleImputer(missing_values=np.nan, strategy='most_frequent')
)

x_train = imp.fit_transform(x_train)
x_test = imp.transform(x_test)

我们在第四章《准备数据》中学到,OrdinalEncoder推荐用于基于树的算法,此外还适用于任何其他非线性算法。category_encoders库不会改变列名,因此我们这次可以直接使用OrdinalEncoder,无需使用ColumnNamesKeeper。在以下代码片段中,我们还指定了要编码的列(类别列)和保持不变的列(其余列):

**```py
from category_encoders.ordinal import OrdinalEncoder
enc = OrdinalEncoder(
cols=features['categorical'],
handle_unknown='value'
)
x_train = enc.fit_transform(x_train)
x_test = enc.transform(x_test)


除了`OrdinalEncoder`,你还可以测试第四章《准备数据》中提到的目标编码器*。它们同样适用于本章中解释的算法。在接下来的部分,我们将使用随机森林算法来处理我们刚准备好的数据。

# 使用随机森林进行回归

随机森林算法将是我们首先要处理的集成方法。它是一个容易理解的算法,具有直接的超参数设置。尽管如此,我们通常的做法是先使用默认值训练算法,如下所示,然后再解释其超参数:

```py
from sklearn.ensemble import RandomForestRegressor
rgr = RandomForestRegressor(n_jobs=-1)
rgr.fit(x_train, y_train)
y_test_pred = rgr.predict(x_test)

由于每棵树相互独立,我将n_jobs设置为-1,以利用多个处理器并行训练树木。一旦它们训练完成并获得预测结果,我们可以打印出以下的准确度指标:

from sklearn.metrics import (
    mean_squared_error, mean_absolute_error, median_absolute_error, r2_score
)

print(
    'R2: {:.2f}, MSE: {:.2f}, RMSE: {:.2f}, MAE {:.2f}'.format(
        r2_score(y_test, y_test_pred),
        mean_squared_error(y_test, y_test_pred),
        np.sqrt(mean_squared_error(y_test, y_test_pred)),
        mean_absolute_error(y_test, y_test_pred),
    )
)

这将打印出以下分数:

# R2: 0.90, MSE: 4.54, RMSE: 2.13, MAE 1.35

平均汽车价格为 13,400。因此,平均绝对误差MAE)为1.35是合理的。至于均方误差MSE),将其平方根作为度量单位比较更为合适,以与 MAE 保持一致。简而言之,鉴于高 R²分数和较低的误差,算法在默认值下表现良好。此外,你可以绘制误差图,进一步了解模型的表现:

df_pred = pd.DataFrame(
    {
        'actuals': y_test,
        'predictions': y_test_pred,
    }
)

df_pred['error'] = np.abs(y_test - y_test_pred)

fig, axs = plt.subplots(1, 2, figsize=(16, 5), sharey=False)

df_pred.plot(
    title='Actuals vs Predictions',
    kind='scatter',
    x='actuals',
    y='predictions',
    ax=axs[0],
)

df_pred['error'].plot(
    title='Distribution of Error',
    kind='hist',
    ax=axs[1],
)

fig.show()

为了保持代码简洁,我省略了一些格式化行。最后,我们得到以下图表:

通过绘制预测值与实际值的对比图,我们可以确保模型不会系统性地高估或低估。这一点通过左侧散点的 45 度斜率得到了体现。散点斜率较低会系统性地反映低估。如果散点在一条直线上的分布,意味着模型没有遗漏任何非线性因素。右侧的直方图显示大多数误差低于 2,000。了解未来可以预期的平均误差和最大误差是很有帮助的。

检查树木数量的影响

默认情况下,每棵树都会在训练数据的随机样本上进行训练。这是通过将bootstrap超参数设置为True实现的。在自助采样中,某些样本可能会在训练中被使用多次,而其他样本可能根本没有被使用。

max_samples设置为None时,每棵树都会在一个随机样本上训练,样本的大小等于整个训练数据的大小。你可以将max_samples设置为小于 1 的比例,这样每棵树就会在一个更小的随机子样本上训练。同样,我们可以将max_features设置为小于 1 的比例,以确保每棵树使用可用特征的随机子集。这些参数有助于让每棵树具有自己的“个性”,并确保森林的多样性。更正式地说,这些参数增加了每棵树的方差。因此,建议尽可能增加树木的数量,以减少我们刚刚引入的方差。

在这里,我们比较了三片森林,每片森林中树木的数量不同:

mae = []
n_estimators_options = [5, 500, 5000]

for n_estimators in n_estimators_options:

    rgr = RandomForestRegressor(
        n_estimators=n_estimators,
        bootstrap=True,
        max_features=0.75,
        max_samples=0.75,
        n_jobs=-1,
    )

    rgr.fit(x_train, y_train)
    y_test_pred = rgr.predict(x_test)
    mae.append(mean_absolute_error(y_test, y_test_pred))

然后,我们可以绘制每个森林的 MAE,以查看增加树木数量的优点:

显然,我们刚刚遇到了需要调优的新的超参数集bootstrapmax_featuresmax_samples。因此,进行交叉验证来调整这些超参数是有意义的。

理解每个训练特征的影响

一旦随机森林训练完成,我们可以列出训练特征及其重要性。通常情况下,我们通过使用列名和feature_importances_属性将结果放入数据框中,如下所示:

df_feature_importances = pd.DataFrame(
    {
        'Feature': x_train.columns,
        'Importance': rgr.feature_importances_,
    }
).sort_values(
    'Importance', ascending=False
)

这是生成的数据框:

与线性模型不同,这里的所有值都是正的。这是因为这些值仅显示每个特征的重要性,无论它们与目标的正负相关性如何。这对于决策树以及基于树的集成模型来说是常见的。因此,我们可以使用部分依赖图PDPs)来展示目标与不同特征之间的关系。在这里,我们仅针对按重要性排名前六的特征绘制图表:

from sklearn.inspection import plot_partial_dependence

fig, ax = plt.subplots(1, 1, figsize=(15, 7), sharey=False)

top_features = df_feature_importances['Feature'].head(6)

plot_partial_dependence(
    rgr, x_train, 
    features=top_features,
    n_cols=3, 
    n_jobs=-1,
    line_kw={'color': 'k'},
    ax=ax
) 

ax.set_title('Partial Dependence')

fig.show()

结果图表更易于阅读,特别是当目标与特征之间的关系是非线性时:

现在我们可以看出,具有更大引擎、更多马力和每加仑油耗更少的汽车往往更昂贵。

PDP 不仅对集成方法有用,对于任何其他复杂的非线性模型也很有用。尽管神经网络对每一层都有系数,但 PDP 对于理解整个网络至关重要。此外,您还可以通过将特征列表作为元组列表传递,每个元组中有一对特征,来理解不同特征对之间的相互作用。

使用随机森林进行分类

为了演示随机森林分类器,我们将使用一个合成数据集。我们首先使用内置的make_hastie_10_2类创建数据集:

from sklearn.datasets import make_hastie_10_2
x, y = make_hastie_10_2(n_samples=6000, random_state=42)

上述代码片段创建了一个随机数据集。我将random_state设置为一个固定的数,以确保我们获得相同的随机数据。现在,我们可以将生成的数据分为训练集和测试集:

from sklearn.model_selection import train_test_split
x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.25, random_state=42)

接下来,为了评估分类器,我们将在下一节介绍一个名为接收者操作特征曲线ROC)的新概念。

ROC 曲线

"概率是建立在部分知识上的期望。对事件发生影响的所有情况的完全了解会把期望转变为确定性,并且不会留下概率理论的需求或空间。"

– 乔治·布尔(布尔数据类型以他命名)

在分类问题中,分类器会为每个样本分配一个概率值,以反映该样本属于某一类别的可能性。我们通过分类器的predict_proba()方法获得这些概率值。predict()方法通常是predict_proba()方法的封装。在二分类问题中,如果样本属于某个类别的概率超过 50%,则将其分配到该类别。实际上,我们可能并不总是希望遵循这个 50%的阈值,尤其是因为不同的阈值通常会改变真正阳性率(TPRs)和假阳性率(FPRs)在每个类别中的表现。因此,你可以选择不同的阈值来优化所需的 TPR。

最好的方法来决定哪个阈值适合你的需求是使用 ROC 曲线。这有助于我们看到每个阈值下的 TPR 和 FPR。为了创建这个曲线,我们将使用我们刚创建的合成数据集来训练我们的随机森林分类器,但这次我们会获取分类器的概率值:

from sklearn.ensemble import RandomForestClassifier

clf = RandomForestClassifier(
    n_estimators=100,
    oob_score=True,
    n_jobs=-1,
)

clf.fit(x_train, y_train)
y_pred_proba = clf.predict_proba(x_test)[:,1]

然后,我们可以按以下方式计算每个阈值的 TPR 和 FPR:

from sklearn.metrics import roc_curve
fpr, tpr, thr = roc_curve(y_test, y_pred_proba)

让我们停下来稍作解释,看看 TPR 和 FPR 是什么意思:

  • TPR,也叫做召回率敏感度,计算方法是真正阳性(TP)案例的数量除以所有正类案例的数量;即,!,其中FN是被错误分类为负类的正类案例(假阴性)。

  • 真正阴性率(TNR),也叫做特异度,计算方法是真正阴性(TN)案例的数量除以所有负类案例的数量;即,!,其中FP是被错误分类为正类的负类案例(假阳性)。

  • FPR定义为 1 减去 TNR,也就是!

  • 假阴性率(FNR)定义为 1 减去 TPR;即,!

现在,我们可以将我们计算出的 TPR 和 FPR 放入以下表格中:

比表格更好的是,我们可以使用以下代码将其绘制成图表:

pd.DataFrame(
    {'FPR': fpr, 'TPR': tpr}
).set_index('FPR')['TPR'].plot(
    title=f'Receiver Operating Characteristic (ROC)',
    label='Random Forest Classifier',
    kind='line',
)

为了简洁起见,我省略了图表的样式代码。我还添加了一个 45°的线条和曲线下面积(AUC),稍后我会解释这个概念:

一个随机将每个样本分配到某个类别的分类器,其 ROC 曲线将像虚线的 45 度线一样。任何在此基础上的改进都会使曲线更向上凸起。显然,随机森林的 ROC 曲线优于随机猜测。一个最佳分类器将触及图表的左上角。因此,AUC 可以用来反映分类器的好坏。0.5以上的区域比随机猜测好,1.0是最佳值。我们通常期待的 AUC 值在0.51.0之间。在这里,我们得到了0.94的 AUC。AUC 可以使用以下代码来计算:

from sklearn.metrics import auc
auc_values = auc(fpr, tpr)

我们还可以使用 ROC 和 AUC 来比较两个分类器。在这里,我训练了bootstrap超参数设置为True的随机森林分类器,并将其与bootstrap设置为False时的相同分类器进行了比较:

难怪bootstrap超参数默认设置为True——它能提供更好的结果。现在,你已经看到如何使用随机森林算法来解决分类和回归问题。在下一节中,我们将解释一个类似的集成方法:包外集成方法。

使用包外回归器

我们将回到汽车数据集,因为这次我们将使用包外回归器。包外元估计器与随机森林非常相似。它由多个估计器构成,每个估计器都在数据的随机子集上进行训练,使用自助采样方法。这里的关键区别是,虽然默认情况下使用决策树作为基本估计器,但也可以使用任何其他估计器。出于好奇,这次我们将K-最近邻KNN)回归器作为我们的基本估计器。然而,我们需要准备数据,以适应新回归器的需求。

准备数值和类别特征的混合

在使用基于距离的算法(如 KNN)时,建议将所有特征放在相同的尺度上。否则,具有更大量级的特征对距离度量的影响将会掩盖其他特征的影响。由于我们这里有数值和类别特征的混合,因此我们需要创建两个并行的管道,分别准备每个特征集。

这是我们管道的顶层视图:

在这里,我们首先构建管道中的四个变换器:ImputerScalerOneHotEncoder。我们还将它们包装在ColumnNamesKeeper中,这是我们在本章前面创建的:

from sklearn.impute import SimpleImputer
from category_encoders.one_hot import OneHotEncoder
from sklearn.preprocessing import MinMaxScaler
from sklearn.pipeline import Pipeline

numerical_mputer = ColumnNamesKeeper(
    SimpleImputer(
        missing_values=np.nan, 
        strategy='median'
    )
)

categorical_mputer = ColumnNamesKeeper(
    SimpleImputer(
        missing_values=np.nan, 
        strategy='most_frequent'
    )
)

minmax_scaler = ColumnNamesKeeper(
    MinMaxScaler()
) 

onehot_encoder = OneHotEncoder(
    cols=features['categorical'],
    handle_unknown='value'
)

然后,我们将它们放入两个并行的管道中:

numerical_pipeline = Pipeline(
    [
        ('numerical_mputer', numerical_mputer), 
        ('minmax_scaler', minmax_scaler)
    ]
)

categorical_pipeline = Pipeline(
    [
        ('categorical_mputer', categorical_mputer), 
        ('onehot_encoder', onehot_encoder)
    ]
)

最后,我们将训练集和测试集的管道输出连接起来:

x_train_knn = pd.concat(
    [
        numerical_pipeline.fit_transform(df_train[features['numerical']]), 
        categorical_pipeline.fit_transform(df_train[features['categorical']]),
    ],
    axis=1
)

x_test_knn = pd.concat(
    [
        numerical_pipeline.transform(df_test[features['numerical']]), 
        categorical_pipeline.transform(df_test[features['categorical']]),
    ],
    axis=1
)

此时,我们准备好构建我们的包外 KNN。

使用包外元估计器结合 KNN 估计器

BaggingRegressor 有一个 base_estimator 超参数,你可以在其中设置你想要使用的估算器。这里,KNeighborsRegressor 与一个单一邻居一起使用。由于我们是通过聚合多个估算器来减少它们的方差,因此一开始就使用高方差的估算器是合理的,因此这里邻居的数量较少:

from sklearn.ensemble import BaggingRegressor
from sklearn.neighbors import KNeighborsRegressor

rgr = BaggingRegressor(
    base_estimator=KNeighborsRegressor(
        n_neighbors=1
    ),
    n_estimators=400,
)

rgr.fit(x_train_knn, df_train[target])
y_test_pred = rgr.predict(x_test_knn)

这个新设置给我们带来了 1.8 的 MAE。我们可以在这里停下来,或者我们可以决定通过调整一系列超参数来改进集成的性能。

首先,我们可以尝试不同的估算器,而不是 KNN,每个估算器都有自己的超参数。然后,Bagging 集成也有自己的超参数。我们可以通过 n_estimators 来更改估算器的数量。然后,我们可以通过 max_samples 来决定是否对每个估算器使用整个训练集或其随机子集。同样,我们也可以通过 max_features 来选择每个估算器使用的列的随机子集。是否对行和列使用自助抽样,可以通过 bootstrapbootstrap_features 超参数分别来决定。

最后,由于每个估算器都是单独训练的,我们可以使用具有大量 CPU 的机器,并通过将 n_jobs 设置为 -1 来并行化训练过程。

现在我们已经体验了两种平均集成方法,是时候检查它们的提升法对应方法了。我们将从梯度提升集成开始,然后转到 AdaBoost 集成。

使用梯度提升法预测汽车价格

如果我被困在一个荒岛上,只能带一个算法,我一定会选择梯度提升集成!它已经在许多分类和回归问题中证明了非常有效。我们将使用与之前章节相同的汽车数据。该集成的分类器和回归器版本共享完全相同的超参数,唯一不同的是它们使用的损失函数。这意味着我们在这里学到的所有知识都将在我们决定使用梯度提升集成进行分类时派上用场。

与我们迄今看到的平均集成方法不同,提升法集成是迭代地构建估算器的。从初始集成中学到的知识被用于构建后继的估算器。这是提升法集成的主要缺点,无法实现并行化。将并行化放在一边,这种迭代的特性要求设置一个学习率。这有助于梯度下降算法更容易地达到损失函数的最小值。这里,我们使用 500 棵树,每棵树最多 3 个节点,并且学习率为 0.01。此外,这里使用的是最小二乘法LS)损失,类似于均方误差(MSE)。稍后会详细介绍可用的损失函数:

from sklearn.ensemble import GradientBoostingRegressor

rgr = GradientBoostingRegressor(
    n_estimators=1000, learning_rate=0.01, max_depth=3, loss='ls'
)

rgr.fit(x_train, y_train)
y_test_pred = rgr.predict(x_test)

这个新算法在测试集上的表现如下:

# R2: 0.92, MSE: 3.93, RMSE: 1.98, MAE: 1.42

如你所见,这个设置相比随机森林给出了更低的 MSE,而随机森林则有更好的 MAE。梯度提升回归器还可以使用另一种损失函数——最小绝对偏差LAD),这类似于 MAE。LAD 在处理异常值时可能会有所帮助,并且有时能减少模型在测试集上的 MAE 表现。然而,它并没有改善当前数据集的 MAE 表现。我们还有一个百分位数(分位数)损失函数,但在深入了解支持的损失函数之前,我们需要先学会如何诊断学习过程。**

这里需要设置的主要超参数包括树的数量、树的深度、学习率和损失函数。根据经验法则,应该设定更高的树的数量和较低的学习率。正如我们稍后会看到的,这两个超参数是相互反比的。控制树的深度完全取决于你的数据。一般来说,我们需要使用较浅的树,并通过提升法增强它们的效果。然而,树的深度控制着我们希望捕捉到的特征交互的数量。在一个树桩(只有一个分裂的树)中,每次只能学习一个特征。较深的树则类似于嵌套的if条件,每次有更多的特征参与其中。我通常会从max_depth设定为大约35开始,并在后续调整。

绘制学习偏差图

随着每次添加估计器,我们预计算法会学习得更多,损失也会减少。然而,在某个时刻,额外的估计器将继续对训练数据进行过拟合,而对测试数据的改进不大。

为了更清楚地了解情况,我们需要将每次添加的估计器计算出的损失绘制出来,分别针对训练集和测试集。对于训练损失,梯度提升元估计器会将其保存在loss_属性中。对于测试损失,我们可以使用元估计器的staged_predict()方法。该方法可以用于给定数据集,在每次中间迭代时进行预测。

由于我们有多种损失函数可以选择,梯度提升还提供了一个loss_()方法,根据所用的损失函数计算损失。在这里,我们创建了一个新函数,用于计算每次迭代的训练和测试误差,并将它们放入数据框中:

def calculate_deviance(estimator, x_test, y_test):

    train_errors = estimator.train_score_
    test_errors = [
        estimator.loss_(y_test, y_pred_staged) 
        for y_pred_staged in estimator.staged_predict(x_test)
    ]

    return pd.DataFrame(
        {
            'n_estimators': range(1, estimator.estimators_.shape[0]+1),
            'train_error': train_errors,
            'test_error': test_errors,
        }
    ).set_index('n_estimators')

由于我们这里将使用最小二乘损失(LS loss),你可以简单地用mean_squared_error()方法替代estimator.loss_(),得到完全相同的结果。但为了代码的更高灵活性和可重用性,我们保留estimator.loss_()函数。

接下来,我们像往常一样训练我们的梯度提升回归模型:

from sklearn.ensemble import GradientBoostingRegressor

rgr = GradientBoostingRegressor(n_estimators=250, learning_rate=0.02, loss='ls')
rgr.fit(x_train, y_train)

然后,我们使用训练好的模型和测试集,绘制训练和测试的学习偏差:

fig, ax = plt.subplots(1, 1, figsize=(16, 5), sharey=False)

df_deviance = calculate_deviance(rgr, x_test, y_test)

df_deviance['train_error'].plot(
    kind='line', color='k', linestyle=':', ax=ax
)

df_deviance['test_error'].plot(
    kind='line', color='k', linestyle='-', ax=ax
)

fig.show()

运行代码会得到如下图表:

这张图的美妙之处在于,它告诉我们测试集上的改进在大约120个估计器后停止了,尽管训练集上的改进持续不断;也就是说,它开始过拟合了。此外,我们可以通过这张图理解所选学习率的效果,就像我们在第七章,《神经网络 - 深度学习来临》中所做的那样。

比较学习率设置

这次,我们不会只训练一个模型,而是训练三个梯度提升回归模型,每个模型使用不同的学习率。然后,我们将并排绘制每个模型的偏差图,如下所示:

与其他基于梯度下降的模型一样,高学习率会导致估计器过度调整,错过局部最小值。我们可以在第一张图中看到这一点,尽管有连续的迭代,但没有看到改进。第二和第三张图中的学习率看起来合理。相比之下,第三张图中的学习率似乎对于模型在 500 次迭代内收敛来说太慢了。你可以决定增加第三个模型的估计器数量,让它能够收敛。

我们从袋装集成方法中学到,通过为每个估计器使用一个随机训练样本,可以帮助减少过拟合。在下一节中,我们将看看相同的方法是否也能帮助提升集成方法。

使用不同的样本大小

我们一直在为每次迭代使用整个训练集。这一次,我们将训练三个梯度提升回归模型,每个模型使用不同的子样本大小,并像之前一样绘制它们的偏差图。我们将使用固定的学习率0.01,并使用 LAD作为我们的损失函数,如下所示:

在第一张图中,每次迭代都会使用整个训练样本。因此,训练损失不像其他两张图那样波动。然而,第二个模型中使用的采样方法使其尽管损失图较为噪声,仍然达到了更好的测试得分。第三个模型的情况也类似,但最终误差略大。

提前停止并调整学习率

n_iter_no_change超参数用于在一定数量的迭代后停止训练过程,前提是验证得分没有得到足够的改进。用于验证的子集,validation_fraction,用于计算验证得分。tol超参数用于决定我们认为多少改进才算足够。

**梯度提升算法中的 fit 方法接受一个回调函数,该函数会在每次迭代后被调用。它还可以用于设置基于自定义条件的训练停止条件。此外,它还可以用于监控或进行其他自定义设置。该回调函数接受三个参数:当前迭代的顺序(n)、梯度提升实例(estimator)以及它的设置(params)。为了演示这个回调函数是如何工作的,我们构建了一个函数,在每 10 次迭代时将学习率更改为0.01,其余迭代保持为0.1,如下所示:

def lr_changer(n, estimator, params):
    if n % 10:
        estimator.learning_rate = 0.01
    else:
        estimator.learning_rate = 0.1
    return False

然后,我们使用lr_changer函数,如下所示:

from sklearn.ensemble import GradientBoostingRegressor
rgr = GradientBoostingRegressor(n_estimators=50, learning_rate=0.01, loss='ls')
rgr.fit(x_train, y_train, monitor=lr_changer)

现在,如果像我们通常做的那样打印偏差,我们会看到每隔第 10^(th) 次迭代后,由于学习率的变化,计算的损失值会跳跃:

我刚才做的事情几乎没有什么实际用途,但它展示了你手头的可能性。例如,你可以借鉴神经网络中求解器的自适应学习率和动量等思想,并通过此回调函数将其融入到这里。

回归范围

“我尽量做一个现实主义者,而不是悲观主义者或乐观主义者。”

–尤瓦尔·诺亚·哈拉里

梯度提升回归为我们提供的最后一个宝贵资源是回归范围。这对于量化预测的不确定性非常有用。

我们尽力让我们的预测与实际数据完全一致。然而,我们的数据可能仍然是嘈杂的,或者使用的特征可能并未捕捉到完整的真相。请看下面的例子:

x[1] x[2] y
0 0 10
1 1 50
0 0 20
0 0 22

考虑一个新的样本,x[1] = 0 且 x[2] = 0。我们已经有三个具有相同特征的训练样本,那么这个新样本的预测 y 值是多少呢?如果在训练过程中使用平方损失函数,则预测的目标将接近17.3,即三个相应目标(102022)的均值。现在,如果使用 MAE(平均绝对误差)的话,预测的目标会更接近22,即三个相应目标的中位数(50^(th) 百分位)。而不是 50^(th) 百分位,我们可以在使用分位数损失函数时使用其他任何百分位数。因此,为了实现回归范围,我们可以使用两个回归器,分别用两个不同的分位数作为我们范围的上下限。

*尽管回归范围在数据维度无关的情况下有效,但页面格式迫使我们用一个二维示例来提供更清晰的展示。以下代码创建了 400 个样本以供使用:

x_sample = np.arange(-10, 10, 0.05)
y_sample = np.random.normal(loc=0, scale=25, size=x_sample.shape[0]) 
y_sample *= x_sample 

pd_random_samples = pd.DataFrame(
    {
        'x': x_sample,
        'y': y_sample
    }
)

这里是生成的 yx 值的散点图:

现在,我们可以训练两个回归模型,使用第 10 百分位数和第 90 百分位数作为我们的范围边界,并绘制这些回归边界,以及我们的散点数据点:

from sklearn.ensemble import GradientBoostingRegressor

fig, ax = plt.subplots(1, 1, figsize=(12, 8), sharey=False)

pd_random_samples.plot(
    title='Regression Ranges [10th & 90th Quantiles]', 
    kind='scatter', x='x', y='y', color='k', alpha=0.95, ax=ax
)

for quantile in [0.1, 0.9]:

    rgr = GradientBoostingRegressor(n_estimators=10, loss='quantile', alpha=quantile)
    rgr.fit(pd_random_samples[['x']], pd_random_samples['y'])
    pd_random_samples[f'pred_q{quantile}'] = rgr.predict(pd_random_samples[['x']])

    pd_random_samples.plot(
        kind='line', x='x', y=f'pred_q{quantile}', 
        linestyle='-', alpha=0.75, color='k', ax=ax
    )

ax.legend(ncol=1, fontsize='x-large', shadow=True)

fig.show()

我们可以看到,大部分数据点落在了范围内。理想情况下,我们希望 80%的数据点落在90-100的范围内:

我们现在可以使用相同的策略来预测汽车价格:

from sklearn.ensemble import GradientBoostingRegressor

rgr_min = GradientBoostingRegressor(n_estimators=50, loss='quantile', alpha=0.25)
rgr_max = GradientBoostingRegressor(n_estimators=50, loss='quantile', alpha=0.75)

rgr_min.fit(x_train, y_train, monitor=lr_changer)
rgr_max.fit(x_train, y_train, monitor=lr_changer)

y_test_pred_min = rgr_min.predict(x_test)
y_test_pred_max = rgr_max.predict(x_test)

df_pred_range = pd.DataFrame(
    {
        'Actuals': y_test,
        'Pred_min': y_test_pred_min,
        'Pred_max': y_test_pred_max,
    }
)

然后,我们可以检查测试集中的多少百分比数据点落在回归范围内:

df_pred_range['Actuals in Range?'] = df_pred_range.apply(
    lambda row: 1 if row['Actuals'] >= row['Pred_min'] and row['Actuals'] <= row['Pred_max'] else 0, axis=1
)

计算df_pred_range['Actuals in Range?']的平均值为0.49,这个值非常接近我们预期的0.5。显然,根据我们的使用场景,我们可以使用更宽或更窄的范围。如果你的模型将用于帮助车主出售汽车,你可能需要给出合理的范围,因为告诉某人他们可以以$5 到$30,000 之间的任何价格出售汽车,虽然很准确,但并没有多大帮助。有时候,一个不那么精确但有用的模型,比一个准确却无用的模型要好。

另一个如今使用较少的提升算法是 AdaBoost 算法。为了完整性,我们将在下一节简要探讨它。

使用 AdaBoost 集成方法

在 AdaBoost 集成中,每次迭代中所犯的错误被用来调整训练样本的权重,以便用于后续迭代。与提升元估计器一样,这种方法也可以使用其他任何估计器,而不仅限于默认使用的决策树。这里,我们使用默认的估计器在汽车数据集上进行训练:

from sklearn.ensemble import AdaBoostRegressor

rgr = AdaBoostRegressor(n_estimators=100)
rgr.fit(x_train, y_train)
y_test_pred = rgr.predict(x_test)

AdaBoost 元估计器也有一个staged_predict方法,允许我们在每次迭代后绘制训练或测试损失的改善情况。以下是绘制测试误差的代码:

pd.DataFrame(
    [
        (n, mean_squared_error(y_test, y_pred_staged))
        for n, y_pred_staged in enumerate(rgr.staged_predict(x_test), 1)
    ],
    columns=['n', 'Test Error']
).set_index('n').plot()

fig.show()

这是每次迭代后计算损失的图表:

与其他集成方法一样,我们添加的估计器越多,模型的准确度就越高。一旦我们开始过拟合,就应该停止。因此,拥有一个验证样本对于确定何时停止非常重要。这里我使用了测试集进行演示,但在实际应用中,测试样本应该保持单独,并使用验证集来代替。

探索更多的集成方法

目前为止,我们已经看过的主要集成技术就是这些。接下来的一些技术也值得了解,并且在一些特殊情况下可能会有用。

投票集成方法

有时,我们有多个优秀的估计器,每个估计器都有自己的错误。我们的目标不是减小它们的偏差或方差,而是结合它们的预测,希望它们不会犯同样的错误。在这种情况下,可以使用VotingClassifierVotingRegressor。你可以通过调整weights超参数,给某些估计器更高的优先权。VotingClassifier有不同的投票策略,取决于是否使用预测的类别标签,或者是否应该使用预测的概率。

堆叠集成

与其投票,你可以通过增加一个额外的估计器,将多个估计器的预测结果结合起来,作为其输入。这个策略被称为堆叠。最终估计器的输入可以仅限于先前估计器的预测,或者可以是它们的预测与原始训练数据的结合。为了避免过拟合,最终估计器通常使用交叉验证进行训练。

随机树嵌入

我们已经看到树能够捕捉数据中的非线性特征。因此,如果我们仍然希望使用更简单的算法,我们可以仅使用树来转换数据,并将预测交给简单的算法来完成。在构建树时,每个数据点都会落入其中一个叶节点。因此,叶节点的 ID 可以用来表示不同的数据点。如果我们构建多个树,那么每个数据点就可以通过它在每棵树中所落叶节点的 ID 来表示。这些叶节点 ID 可以作为新的特征,输入到更简单的估计器中。这种嵌入方法对于特征压缩非常有用,并且允许线性模型捕捉数据中的非线性特征。

在这里,我们使用无监督的RandomTreesEmbedding方法来转换我们的汽车特征,然后在Ridge回归中使用转换后的特征:

from sklearn.ensemble import RandomTreesEmbedding
from sklearn.linear_model import Ridge
from sklearn.pipeline import make_pipeline

rgr = make_pipeline(RandomTreesEmbedding(), Ridge())
rgr.fit(x_train, y_train)
y_test_pred = rgr.predict(x_test)

print(f'MSE: {mean_squared_error(y_test, y_test_pred)}')

从前面的代码块中,我们可以观察到以下几点:

  • 这种方法不限于RandomTreesEmbedding

  • 梯度提升树也可以用于转换数据,供下游估计器使用。

  • GradientBoostingRegressorGradientBoostingClassifier都有一个apply函数,可用于特征转换。

总结

在本章中,我们了解了算法如何从以集成的形式组装中受益。我们学习了这些集成如何缓解偏差与方差的权衡。

在处理异构数据时,梯度提升和随机森林算法是我进行分类和回归时的首选。由于它们依赖于树结构,它们不需要任何复杂的数据预处理。它们能够处理非线性数据并捕捉特征之间的交互。最重要的是,它们的超参数调整非常简单。

每种方法中的估计器越多越好,你不需要过于担心它们会过拟合。至于梯度提升方法,如果你能承受更多的树木,可以选择较低的学习率。除了这些超参数外,每个算法中树的深度应该通过反复试验和交叉验证来调优。由于这两种算法来自偏差-方差谱的不同端点,你可以最初选择拥有大树的森林,并在之后进行修剪。相反,你也可以从浅层树开始,并依赖你的梯度提升元估计器来增强它们。

到目前为止,在本书中我们每次只预测一个目标。比如说,我们预测了汽车的价格,仅此而已。在下一章,我们将看到如何一次性预测多个目标。此外,当我们的目标是使用分类器给出的概率时,拥有一个校准过的分类器至关重要。如果我们能得到可信的概率,我们就能更好地评估我们的风险。因此,校准分类器将是下一章将要讨论的另一个话题。

第九章:Y 与 X 同样重要

我们给予输入特征很多关注,即我们的x。我们使用算法对它们进行缩放、从中选择,并工程化地添加新的特征。尽管如此,我们也应当同样关注目标变量,即y。有时,缩放你的目标可以帮助你使用更简单的模型。而有时候,你可能需要一次预测多个目标。那时,了解你的目标的分布及其相互依赖关系是至关重要的。在本章中,我们将重点讨论目标以及如何处理它们。

在本章中,我们将涵盖以下主题:

  • 缩放你的回归目标

  • 估计多个回归目标

  • 处理复合分类目标

  • 校准分类器的概率

  • 计算 K 的精确度

缩放你的回归目标

在回归问题中,有时对目标进行缩放可以节省时间,并允许我们为当前问题使用更简单的模型。在本节中,我们将看到如何通过改变目标的尺度来简化估计器的工作。

在以下示例中,目标与输入之间的关系是非线性的。因此,线性模型不能提供最佳结果。我们可以使用非线性算法、转换特征或转换目标。在这三种选择中,转换目标有时可能是最简单的。请注意,我们这里只有一个特征,但在处理多个特征时,首先考虑转换目标是有意义的。

以下图表显示了单一特征x与因变量y之间的关系:

在你我之间,以下代码用于生成数据,但为了学习的目的,我们可以假装目前不知道yx之间的关系:

x = np.random.uniform(low=5, high=20, size=100)
e = np.random.normal(loc=0, scale=0.5, size=100)
y = (x + e) ** 3

一维输入(x)在520之间均匀分布。yx之间的关系是立方的,并且向x添加了少量正态分布的噪声。

在拆分数据之前,我们需要将x从向量转换为矩阵,如下所示:

from sklearn.model_selection import train_test_split
x = x.reshape((x.shape[0],1))
x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.25)

现在,如果我们将数据拆分为训练集和测试集,并运行岭回归,我们将得到一个平均绝对误差MAE)为559。由于数据是随机生成的,你的结果可能有所不同。我们能做得更好吗?

请记住,在本章中提到的大多数示例中,你最终得到的结果可能与我的有所不同。我在生成和拆分数据时选择不使用随机状态,因为我的主要目标是解释概念,而不是关注最终的结果和运行代码时的准确度分数。

让我们创建一个简单的变换器,根据给定的 power 来转换目标。当 power 设置为 1 时,不对目标进行任何变换;否则,目标会被提升到给定的幂次。我们的变换器有一个互补的 inverse_transform() 方法,将目标重新转换回原始尺度:

class YTransformer:

    def __init__(self, power=1):
        self.power = power

    def fit(self, x, y):
        pass

    def transform(self, x, y):
        return x, np.power(y, self.power)

    def inverse_transform(self, x, y):
        return x, np.power(y, 1/self.power)

    def fit_transform(self, x, y):
        return self.transform(x, y)

现在,我们可以尝试不同的幂次设置,并循环遍历不同的变换,直到找到给出最佳结果的变换:

from sklearn.linear_model import Ridge
from sklearn.metrics import mean_absolute_error
from sklearn.metrics import r2_score

for power in [1, 1/2, 1/3, 1/4, 1/5]:

    yt = YTransformer(power)
    _, y_train_t = yt.fit_transform(None, y_train)
    _, y_test_t = yt.transform(None, y_test)

    rgs = Ridge()

    rgs.fit(x_train, y_train_t)
    y_pred_t = rgs.predict(x_test)

    _, y_pred = yt.inverse_transform(None, y_pred_t)

    print(
        'Transformed y^{:.2f}: MAE={:.0f}, R2={:.2f}'.format(
            power,
            mean_absolute_error(y_test, y_pred),
            r2_score(y_test, y_pred),
        )
    )

将预测值转换回原始值是至关重要的。否则,计算的误差指标将无法进行比较,因为不同的幂次设置会导致不同的数据尺度。

因此,inverse_transform() 方法在预测步骤后使用。在我的随机生成的数据上运行代码得到了以下结果:

Transformed y¹.00: MAE=559, R2=0.89
Transformed y⁰.50: MAE=214, R2=0.98
Transformed y⁰.33: MAE=210, R2=0.97
Transformed y⁰.25: MAE=243, R2=0.96
Transformed y⁰.20: MAE=276, R2=0.95

如预期的那样,当使用正确的变换时,最低的误差和最高的 被实现,这正是当幂次设置为 时。

对数变换、指数变换和平方根变换是统计学家最常用的变换。当执行预测任务时,特别是在使用线性模型时,使用这些变换是有意义的。

对数变换仅对正值有效。Log(0) 是未定义的,对负数取对数会得到虚数值。因此,对数变换通常应用于处理非负目标。为了确保我们不会遇到 log(0),一个技巧是,在转换目标之前,先给所有目标值加 1,然后在将预测结果反向转换后再减去 1。同样,对于平方根变换,我们也需要确保一开始没有负目标值。

与其一次处理一个目标,我们有时可能希望一次预测多个目标。当多个回归任务使用相同特征时,将它们合并为一个模型可以简化代码。如果你的目标是相互依赖的,推荐使用这种方法。在下一部分,我们将看到如何一次性估计多个回归目标。

估算多个回归目标

在你的在线业务中,你可能想估算用户在下个月、下个季度和明年的生命周期价值。你可以为这三个单独的估算构建三个不同的回归模型。然而,当这三个估算使用完全相同的特征时,构建一个具有三个输出的回归器会更为实用。在下一部分,我们将看到如何构建一个多输出回归器,然后我们将学习如何使用回归链在这些估算之间注入相互依赖关系。

构建一个多输出回归器

一些回归器允许我们一次预测多个目标。例如,岭回归器允许给定二维目标。换句话说,y不再是单维数组,而是可以作为矩阵给定,其中每列代表一个不同的目标。对于只允许单一目标的其他回归器,我们可能需要使用多输出回归器元估算器。

为了演示这个元估算器,我将使用make_regression辅助函数来创建一个我们可以调整的数据集:

from sklearn.datasets import make_regression

x, y = make_regression(
    n_samples=500, n_features=8, n_informative=8, n_targets=3, noise=30.0
)

在这里,我们创建500个样本,具有 8 个特征和 3 个目标;即返回的xy的形状分别为(5008)和(5003)。我们还可以为特征和目标指定不同的名称,然后按如下方式将数据拆分为训练集和测试集:

feature_names = [f'Feature # {i}' for i in range(x.shape[1])]
target_names = [f'Target # {i}' for i in range(y.shape[1])]

from sklearn.model_selection import train_test_split
x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.25)

由于SGDRegressor不支持多目标,因此以下代码将抛出一个值错误,抱怨输入的形状不正确:

from sklearn.linear_model import SGDRegressor

rgr = SGDRegressor()
rgr.fit(x_train, y_train)

因此,我们必须将MultiOutputRegressor包裹在SGDRegressor周围才能使其工作:

from sklearn.multioutput import MultiOutputRegressor
from sklearn.linear_model import SGDRegressor

rgr = MultiOutputRegressor(
    estimator=SGDRegressor(), 
    n_jobs=-1
)
rgr.fit(x_train, y_train)
y_pred = rgr.predict(x_test)

我们现在可以将预测结果输出到数据框中:

df_pred = pd.DataFrame(y_pred, columns=target_names)

同时,检查每个目标的前几次预测。以下是我在这里得到的预测示例。请记住,您可能会得到不同的结果:

我们还可以分别打印每个目标的模型表现:

from sklearn.metrics import mean_absolute_error
from sklearn.metrics import r2_score

for t in range(y_train.shape[1]):
    print(
        'Target # {}: MAE={:.2f}, R2={:.2f}'.format(
            t,
            mean_absolute_error(y_test[t], y_pred[t]),
            r2_score(y_test[t], y_pred[t]),
        )
    )

在某些情况下,知道一个目标可能有助于了解其他目标。在前面提到的生命周期价值估算示例中,预测下一个月的结果对季度和年度预测非常有帮助。为了将一个目标的预测作为输入传递给连续的回归器,我们需要使用回归器链元估算器。

链接多个回归器

在前一节的数据集中,我们无法确定生成的目标是否相互依赖。现在,假设第二个目标依赖于第一个目标,第三个目标依赖于前两个目标。我们稍后将验证这些假设。为了引入这些相互依赖关系,我们将使用RegressorChain并指定假设的依赖关系顺序。order列表中的 ID 顺序指定列表中的每个 ID 依赖于前面的 ID。使用正则化回归器是有意义的。正则化是必要的,用于忽略目标之间不存在的任何假定依赖关系。

以下是创建回归器链的代码:

from sklearn.multioutput import RegressorChain
from sklearn.linear_model import Ridge

rgr = RegressorChain(
    base_estimator=Ridge(
        alpha=1
    ), 
    order=[0,1,2],
)
rgr.fit(x_train, y_train)
y_pred = rgr.predict(x_test)

测试集的表现几乎与使用MultiOutputRegressor时的表现相同。看起来链式方法并没有帮助当前的数据集。我们可以显示每个Ridge回归器在训练后的系数。第一个估算器只使用输入特征,而后面的估算器则为输入特征以及之前的目标分配系数。以下是如何显示链中第三个估算器的系数:

pd.DataFrame(
    zip(
        rgr.estimators_[-1].coef_, 
        feature_names + target_names
    ),
    columns=['Coeff', 'Feature']
)[
    ['Feature', 'Coeff']
].style.bar(
    subset=['Coeff'], align='mid', color='#AAAAAA'
)

从计算出的系数来看,我们可以看到链中的第三个估算器几乎忽略了前两个目标。由于这些目标是独立的,链中的每个估算器仅使用输入特征。尽管你在运行代码时得到的系数可能有所不同,但由于目标的独立性,分配给前两个目标的系数仍然是微不足道的:

在目标之间存在依赖关系的情况下,我们期望目标会分配更大的系数。在实际应用中,我们可能会尝试不同的order超参数组合,直到找到最佳性能为止。

与回归问题一样,分类器也可以处理多个目标。然而,单个目标可以是二元的,或者具有两个以上的值。这为分类问题增添了更多细节。在下一部分,我们将学习如何构建分类器来满足复合目标的需求。

处理复合分类目标

与回归器类似,分类器也可以有多个目标。此外,由于目标是离散的,单个目标可以具有两个或更多的值。为了区分不同的情况,机器学习实践者提出了以下术语:

  • 多类

  • 多标签(和多输出)

以下矩阵总结了上述术语。我将通过一个示例进一步说明,并在本章后续内容中也会详细阐述多标签和多输出术语之间的细微区别:

想象一个场景,你需要根据图片中是否包含猫来进行分类。在这种情况下,需要一个二元分类器,也就是说,目标值要么是零,要么是一个。当问题涉及判断图片中是否有猫、狗或人类时,目标的种类数就超出了二个,此时问题就被表述为多类分类问题。

图片中也可能包含多个对象。一个图片可能只包含一只猫,而另一个图片则同时包含人类和猫。在多标签设置中,我们将构建一组二元分类器:一个用于判断图片中是否有猫,另一个用于判断是否有狗,再有一个用于判断是否有人类。为了在不同目标之间注入相互依赖关系,你可能希望一次性预测所有同时出现的标签。在这种情况下,通常会使用“多输出”这一术语。

此外,你可以使用一组二分类器来解决多类问题。与其判断图片里是否有猫、狗或人类,不如设置一个分类器判断是否有猫,一个分类器判断是否有狗,另一个判断是否有人类。这对于模型的可解释性很有用,因为每个分类器的系数可以映射到单一类别。在接下来的章节中,我们将使用一对多策略将多类问题转换为一组二分类问题。

将多类问题转换为一组二分类器

我们不必局限于多类问题。我们可以简单地将手头的多类问题转换为一组二分类问题。

在这里,我们构建了一个包含 5000 个样本、15 个特征和 1 个标签(具有 4 个可能值)的数据集:

from sklearn.datasets import make_classification

x, y = make_classification(
    n_samples=5000, n_features=15, n_informative=8, n_redundant=2, 
    n_classes=4, class_sep=0.5, 
)

在通常的方式划分数据后,并保留 25%用于测试,我们可以在LogisticRegression之上应用一对多策略。顾名思义,它是一个元估计器,构建多个分类器来判断每个样本是否属于某个类别,最终将所有的决策结合起来:

from sklearn.linear_model import LogisticRegression
from sklearn.multiclass import OneVsRestClassifier
from sklearn.metrics import accuracy_score

clf = OneVsRestClassifier(
    estimator=LogisticRegression(solver='saga')
)
clf.fit(x_train, y_train)
y_pred = clf.predict(x_test)

我使用了 saga 求解器,因为它对较大数据集收敛更快。一对多策略给我带来了0.43的准确率。我们可以通过estimators方法访问元估计器使用的底层二分类器,然后可以揭示每个底层二分类器为每个特征学习的系数。**

**另一种策略是一对一。它为每一对类别构建独立的分类器,使用方法如下:

from sklearn.linear_model import LogisticRegression
from sklearn.multiclass import OneVsOneClassifier

clf = OneVsOneClassifier(
    estimator=LogisticRegression(solver='saga')
)
clf.fit(x_train, y_train)
y_pred = clf.predict(x_test)

accuracy_score(y_test, y_pred)

一对一策略给我带来了0.44的可比准确率。我们可以看到,当处理大量类别时,之前的两种策略可能无法很好地扩展。OutputCodeClassifier是一种更具可扩展性的解决方案。通过将其code_size超参数设置为小于 1 的值,它可以将标签编码为更密集的表示。较低的code_size将提高其计算性能,但会以牺牲准确性和可解释性为代价。**

**通常,一对多是最常用的策略,如果你的目标是为每个类别分离系数,它是一个很好的起点。

为了确保所有类别的返回概率加起来为 1,一对多策略通过将概率除以其总和来规范化这些概率。另一种概率规范化的方法是Softmax()函数。它将每个概率的指数除以所有概率指数的总和。Softmax()函数也用于多项式逻辑回归,而不是Logistic()函数,使其作为多类分类器运作,而无需使用一对多一对一策略。

估计多个分类目标

MultiOutputRegressor 一样,MultiOutputClassifier 是一个元估算器,允许底层估算器处理多个输出。

让我们创建一个新的数据集,看看如何使用 MultiOutputClassifier

from sklearn.datasets import make_multilabel_classification

x, y = make_multilabel_classification(
    n_samples=500, n_features=8, n_classes=3, n_labels=2
)

这里首先需要注意的是,n_classesn_labels 这两个术语在 make_multilabel_classification 辅助函数中具有误导性。前面的设置创建了 500 个样本,包含 3 个二元目标。我们可以通过打印返回的 xy 的形状,以及 y 的基数来确认这一点:

x.shape, y.shape # ((500, 8), (500, 3))
np.unique(y) # array([0, 1])

然后,我们强制第三个标签完全依赖于第一个标签。我们稍后会利用这个事实:

y[:,-1] = y[:,0]    

在像往常一样划分数据集,并将 25% 用于测试之后,我们会注意到 GradientBoostingClassifier 无法处理我们所拥有的三个目标。一些分类器能够在没有外部帮助的情况下处理多个目标。然而,MultiOutputClassifier 估算器是我们这次决定使用的分类器所必需的:

from sklearn.multioutput import MultiOutputClassifier
from sklearn.ensemble import GradientBoostingClassifier

clf = MultiOutputClassifier(
    estimator=GradientBoostingClassifier(
        n_estimators=500,
        learning_rate=0.01,
        subsample=0.8,
    ),
    n_jobs=-1
)
clf.fit(x_train, y_train)
y_pred_multioutput = clf.predict(x_test)

我们已经知道,第一个和第三个目标是相关的。因此,ClassifierChain 可能是一个很好的替代选择,可以尝试代替 MultiOutputClassifier 估算器。然后,我们可以使用它的 order 超参数来指定目标的依赖关系,如下所示:

from sklearn.multioutput import ClassifierChain
from sklearn.ensemble import GradientBoostingClassifier

clf = ClassifierChain(
    base_estimator=GradientBoostingClassifier(
        n_estimators=500,
        learning_rate=0.01,
        subsample=0.8,
    ),
    order=[0,1,2]
)
clf.fit(x_train, y_train)
y_pred_chain = clf.predict(x_test)

现在,如果我们像之前对 RegressorChain 所做的那样,显示第三个估算器的系数,我们可以看到它只是复制了对第一个目标所做的预测,并直接使用这些预测。因此,除了分配给第一个目标的系数外,所有系数都被设置为零,如下所示:

如你所见,每当我们希望使用的估算器不支持多个目标时,我们都能得到覆盖。我们还可以告诉我们的估算器在预测下一个目标时应使用哪些目标。

在许多现实生活中的场景中,我们更关心分类器预测的概率,而不是它的二元决策。一个良好校准的分类器会产生可靠的概率,这在风险计算中至关重要,并有助于实现更高的精度。

在接下来的部分中,我们将看到如何校准我们的分类器,特别是当它们的估计概率默认情况下不可靠时。

校准分类器的概率

“每个企业和每个产品都有风险。你无法回避它。”

– 李·艾科卡

假设我们想要预测某人是否会感染病毒性疾病。然后我们可以构建一个分类器来预测他们是否会感染该病毒。然而,当可能感染的人群比例过低时,分类器的二分类预测可能不够精确。因此,在这种不确定性和有限资源的情况下,我们可能只想将那些感染概率超过 90%的人隔离起来。分类器的预测概率听起来是一个很好的估算来源。然而,只有当我们预测为某一类别且其概率超过 90%的样本中,90%(9 个中有 9 个)最终确实属于该类别时,这个概率才能被认为是可靠的。同样,对于 80%以上的概率,最终 80%的样本也应该属于该类别。换句话说,对于一个完美校准的模型,我们在绘制目标类别样本百分比与分类器预测概率之间的关系时,应该得到一条 45°的直线:

一些模型通常已经经过良好的校准,例如逻辑回归分类器。另一些模型则需要我们在使用之前对其概率进行校准。为了演示这一点,我们将创建一个以下的二分类数据集,包含 50,000 个样本和15个特征。我使用了较低的class_sep值,以确保这两个类别不容易分开:

from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split

x, y = make_classification(
    n_samples=50000, n_features=15, n_informative=5, n_redundant=10, 
    n_classes=2, class_sep=0.001
)

x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.25)

然后我训练了一个高斯朴素贝叶斯分类器,并存储了正类的预测概率。由于其天真的假设,朴素贝叶斯分类器通常会返回不可靠的概率,正如我们在第六章《使用朴素贝叶斯分类文本》中讨论的那样。由于我们处理的是连续特征,因此这里使用GaussianNB分类器:

from sklearn.naive_bayes import GaussianNB

clf = GaussianNB()
clf.fit(x_train, y_train)
y_pred_proba = clf.predict_proba(x_test)[:,-1]

Scikit-learn 提供了绘制分类器校准曲线的工具。它将估计的概率划分为多个区间,并计算每个区间中属于正类的样本比例。在以下代码片段中,我们将区间数设置为10,并使用计算出的概率来创建校准曲线:

from sklearn.calibration import calibration_curve

fraction_of_positives, mean_predicted_value = calibration_curve(
    y_test, y_pred_proba, n_bins=10
)

fig, ax = plt.subplots(1, 1, figsize=(10, 8))

ax.plot(
    mean_predicted_value, fraction_of_positives, "--", 
    label='Uncalibrated GaussianNB', color='k'
)

fig.show()

我为了简洁起见,省略了负责图形格式化的代码部分。运行代码后,我得到了以下的曲线:

如你所见,模型远未经过校准。因此,我们可以使用CalibratedClassifierCV来调整其概率:

from sklearn.calibration import CalibratedClassifierCV
from sklearn.naive_bayes import GaussianNB

clf_calib = CalibratedClassifierCV(GaussianNB(), cv=3, method='isotonic')
clf_calib.fit(x_train, y_train)
y_pred_calib = clf_calib.predict(x_test)
y_pred_proba_calib = clf_calib.predict_proba(x_test)[:,-1]

在下图中,我们可以看到CalibratedClassifierCV对模型的影响,其中新的概率估算更加可靠:**

**

CalibratedClassifierCV使用两种校准方法:sigmoid()isotonic()方法。推荐在小数据集上使用sigmoid()方法,因为isotonic()方法容易过拟合。此外,校准应在与模型拟合时使用的不同数据上进行。CalibratedClassifierCV允许我们进行交叉验证,将用于拟合基础估计器的数据与用于校准的数据分开。在之前的代码中使用了三折交叉验证。

如果线性回归旨在最小化平方误差,并假设目标y与特征x之间的关系为由y = f(x)表示的线性方程,那么等距回归则有不同的假设,旨在最小化平方误差。它假设f(x)是一个非线性但单调的函数。换句话说,它随着x的增大要么持续增加,要么持续减少。等距回归的这种单调性特征使其适用于概率校准。**

除了校准图,Brier 得分是检查模型是否校准的好方法。它基本上计算了预测概率与实际目标之间的均方误差(MSE)。因此,较低的 Brier 得分反映出更可靠的概率。

在下一节中,我们将学习如何使用分类器对预测结果进行排序,然后如何评估这个排序。

计算 k 时的精度

在上一节中关于病毒感染的示例中,您的隔离能力可能仅限于例如 500 名患者。在这种情况下,您会希望根据预测概率,尽可能多的阳性病例出现在前 500 名患者中。换句话说,我们不太关心模型的整体精度,因为我们只关心它在前k样本中的精度。

我们可以使用以下代码计算前k样本的精度:

def precision_at_k_score(y_true, y_pred_proba, k=1000, pos_label=1):
    topk = [
        y_true_ == pos_label 
        for y_true_, y_pred_proba_ 
        in sorted(
            zip(y_true, y_pred_proba), 
            key=lambda y: y[1], 
            reverse=True
        )[:k]
    ]
    return sum(topk) / len(topk)

如果您不太喜欢函数式编程范式,那么让我详细解释一下代码。zip()方法将两个列表合并,并返回一个元组列表。列表中的第一个元组将包含y_true的第一个项以及y_pred_proba的第一个项。第二个元组将包含它们的第二个项,以此类推。然后,我根据元组的第二个元素,即y_pred_proba,对元组列表按降序进行排序(reverse=True)。接着,我取排序后的前k个元组,并将它们的y_true部分与pos_label参数进行比较。pos_label参数允许我决定基于哪个标签进行精度计算。最后,我计算了在topk中实际属于pos_label类的元素所占的比例。

现在,我们可以计算未校准的GaussianNB分类器在前 500 个预测中的精度:

precision_at_k_score(y_test, y_pred_proba, k=500)

这为我们提供了前500个样本的82%精度,相比之下,所有正类样本的总体精度为62%。再次提醒,你的结果可能与我的不同。

k 精度指标是处理不平衡数据或难以分离的类别时非常有用的工具,尤其是当你只关心模型在前几个预测中的准确度时。它允许你调整模型,以捕捉最重要的样本。我敢打赌,谷歌比起你在第 80 页看到的搜索结果,更关心你在第一页看到的结果。而如果我只有足够的钱购买 20 只股票,我希望模型能正确预测前 20 只股票的走势,对于第 100 只股票的准确性我倒不太关心。

总结

在处理分类或回归问题时,我们通常会首先考虑我们应该在模型中包含哪些特征。然而,解决方案的关键往往在于目标值。正如我们在本章所看到的,重新缩放我们的回归目标可以帮助我们使用更简单的模型。此外,校准我们分类器给出的概率可以迅速提高我们的准确度,并帮助我们量化不确定性。我们还学会了通过编写一个单一的估计器来同时预测多个输出,从而处理多个目标。这有助于简化我们的代码,并使得估计器可以利用从一个标签中学到的知识来预测其他标签。

在现实生活中的分类问题中,类别不平衡是常见的。当检测欺诈事件时,你的数据中大多数通常是非欺诈案例。同样,对于诸如谁会点击你的广告,谁会订阅你的新闻通讯等问题,通常是少数类对你来说更为重要。

在下一章,我们将看到如何通过修改训练数据来让分类器更容易处理不平衡的数据集。**********

第十章:不平衡学习 - 连 1% 的人都未能中彩票

在你的类别平衡的情况下,更多的是例外而不是规则。在我们将遇到的大多数有趣的问题中,类别极不平衡。幸运的是,网络支付的一小部分是欺诈的,就像人口中少数人感染罕见疾病一样。相反,少数竞争者中彩票,你的熟人中少数成为你的密友。这就是为什么我们通常对捕捉这些罕见案例感兴趣的原因。

在本章中,我们将学习如何处理不平衡的类别。我们将从给训练样本分配不同权重开始,以减轻类别不平衡问题。此后,我们将学习其他技术,如欠采样和过采样。我们将看到这些技术在实践中的效果。我们还将学习如何将集成学习与重采样等概念结合起来,并引入新的评分来验证我们的学习器是否符合我们的需求。

本章将涵盖以下主题:

  • 重新加权训练样本

  • 随机过采样

  • 随机欠采样

  • 将采样与集成结合使用

  • 平等机会分数

让我们开始吧!

获取点击预测数据集

通常,看到广告并点击的人只占很小一部分。换句话说,在这种情况下,正类样本的百分比可能只有 1%甚至更少。这使得预测点击率CTR)变得困难,因为训练数据极度不平衡。在本节中,我们将使用来自知识发现数据库KDD)杯赛的高度不平衡数据集。

KDD 杯是由 ACM 知识发现与数据挖掘特别兴趣小组每年组织的比赛。2012 年,他们发布了一个数据集,用于预测搜索引擎中显示的广告是否会被用户点击。经修改后的数据已发布在 OpenML 平台上(www.openml.org/d/1220)。修改后数据集的 CTR 为 16.8%。这是我们的正类。我们也可以称之为少数类,因为大多数情况下广告未被点击。

在这里,我们将下载数据并将其放入 DataFrame 中,如下所示:

from sklearn.datasets import fetch_openml
data = fetch_openml(data_id=1220)

df = pd.DataFrame(
    data['data'],
    columns=data['feature_names']
).astype(float)

df['target'] = pd.Series(data['target']).astype(int) 

我们可以使用以下代码显示数据集的5个随机行:

df.sample(n=5, random_state=42)

如果我们将random_state设为相同的值,我们可以确保得到相同的随机行。在《银河系漫游指南》中,道格拉斯·亚当斯认为数字42是生命、宇宙和一切的终极问题的答案。因此,我们将在本章节中始终将random_state设为42。这是我们的五行样本:

关于这些数据,我们需要记住两件事:

  • 如前所述,类别不平衡。你可以通过运行df['target'].mean()来检查这一点,这将返回16.8%

  • 尽管所有特征都是数值型的,但显然,所有以id后缀结尾的特征应该作为分类特征来处理。例如,ad_id与 CTR 之间的关系并不预期是线性的,因此在使用线性模型时,我们可能需要使用one-hot 编码器对这些特征进行编码。然而,由于这些特征具有高基数,one-hot 编码策略会导致产生过多的特征,使我们的分类器难以处理。因此,我们需要想出另一种可扩展的解决方案。现在,让我们学习如何检查每个特征的基数:

for feature in data['feature_names']:
    print(
       'Cardinality of {}: {:,}'.format(
            feature, df[feature].value_counts().shape[0]
        )
    )

这将给我们以下结果:

Cardinality of impression: 99
Cardinality of ad_id: 19,228
Cardinality of advertiser_id: 6,064
Cardinality of depth: 3
Cardinality of position: 3
Cardinality of keyword_id: 19,803
Cardinality of title_id: 25,321
Cardinality of description_id: 22,381
Cardinality of user_id: 30,114

最后,我们将把数据转换成x_trainx_testy_trainy_test数据集,如下所示:

from sklearn.model_selection import train_test_split
x, y = df[data['feature_names']], df['target']
x_train, x_test, y_train, y_test = train_test_split(
    x, y, test_size=0.25, random_state=42
)

在本节中,我们下载了必要的数据并将其添加到 DataFrame 中。在下一节中,我们将安装imbalanced-learn库。

安装 imbalanced-learn 库

由于类别不平衡,我们需要重新采样训练数据或应用不同的技术以获得更好的分类结果。因此,我们将在这里依赖imbalanced-learn库。该项目由Fernando Nogueira于 2014 年启动,现提供多种重采样数据技术,以及用于评估不平衡分类问题的度量标准。该库的接口与 scikit-learn 兼容。

你可以通过在终端中运行以下命令来使用pip下载该库:

          pip install -U imbalanced-learn

现在,你可以在代码中导入并使用它的不同模块,正如我们在接下来的章节中所看到的。该库提供的度量标准之一是几何均值分数。在第八章,集成方法 – 当一个模型不足够时,我们了解了真正例率TPR),即灵敏度,以及假正例率FPR),并用它们绘制了曲线下面积。我们还学习了真负例率TNR),即特异度,它基本上是 1 减去 FPR。几何均值分数,对于二分类问题来说,是灵敏度(TPR)和特异度(TNR)乘积的平方根。通过结合这两个度量标准,我们试图在考虑类别不平衡的情况下,最大化每个类别的准确性。geometric_mean_score的接口与其他 scikit-learn 度量标准类似。它接受真实值和预测值,并返回计算出的分数,如下所示:****

****```py
from imblearn.metrics import geometric_mean_score
geometric_mean_score(y_true, y_pred)


在本章中,我们将使用这个度量标准,除了精确度和召回率分数外。

在下一节中,我们将调整训练样本的权重,看看这是否有助于处理类别不平衡问题。

# 预测 CTR

我们已经准备好了数据并安装了`imbalanced-learn`库。现在,我们可以开始构建我们的分类器了。正如我们之前提到的,由于类别特征的高基数,传统的独热编码技术并不适合大规模应用。在[第八章](https://cdp.packtpub.com/hands_on_machine_learning_with_scikit_learn/wp-admin/post.php?post=30&action=edit),《集成方法——当一个模型不足以应对时》,我们简要介绍了**随机树嵌入**作为一种特征转换技术。它是完全随机树的集成,每个数据样本将根据它落在每棵树的叶子节点来表示。这里,我们将构建一个管道,将数据转换为随机树嵌入并进行缩放。最后,使用**逻辑回归**分类器来预测是否发生了点击:

```py
from sklearn.preprocessing import MaxAbsScaler
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomTreesEmbedding
from sklearn.pipeline import Pipeline
from sklearn.metrics import precision_score, recall_score
from imblearn.metrics import geometric_mean_score

def predict_and_evalutate(x_train, y_train, x_test, y_test, sample_weight=None, title='Unweighted'):

    clf = Pipeline(
        [
            ('Embedder', RandomTreesEmbedding(n_estimators=10, max_leaf_nodes=20, random_state=42)), 
            ('Scaler', MaxAbsScaler()),
            ('Classifier', LogisticRegression(solver='saga', max_iter=1000, random_state=42))
        ]
    )
    clf.fit(x_train, y_train, Classifier__sample_weight=sample_weight)
    y_test_pred = clf.predict(x_test)

    print(
        'Precision: {:.02%}, Recall: {:.02%}; G-mean: {:.02%} @ {}'.format(
            precision_score(y_test, y_test_pred),
            recall_score(y_test, y_test_pred),
            geometric_mean_score(y_test, y_test_pred),
            title
        )
    )

    return clf

我们将整个过程封装到一个函数中,这样我们就可以在本章后面重复使用它。predict_and_evalutate()函数接受 x 和 y,以及样本权重。我们稍后会使用样本权重,但现在可以忽略它们。一旦预测完成,函数还会打印不同的得分,并返回使用的管道实例。

我们可以像下面这样使用我们刚刚创建的函数:

clf = predict_and_evalutate(x_train, y_train, x_test, y_test) 

默认情况下,计算出的精确度和召回率是针对正类的。前面的代码给出了0.3%的召回率,62.5%的精确度,以及5.45%的几何均值得分。召回率低于1%,这意味着分类器将无法捕捉到绝大多数正类/少数类样本。这是处理不平衡数据时常见的情况。解决方法之一是为少数类样本分配更多的权重。这就像是要求分类器更加关注这些样本,因为我们关心的是捕捉到它们,尽管它们相对稀少。在下一节中,我们将看到样本加权对分类器的影响。

对训练样本进行不同的加权

多数类样本的数量大约是少数类样本的五倍。你可以通过运行以下代码来验证这一点:

(1 - y_train.mean()) / y_train.mean() 

因此,将少数类样本的权重设置为其他样本的五倍是有意义的。我们可以使用前一节中的predict_and_evalutate()函数,并调整样本权重,具体如下:

sample_weight = (1 * (y_train == 0)) + (5 * (y_train == 1))
clf = predict_and_evalutate(
    x_train, y_train, x_test, y_test, 
    sample_weight=sample_weight
)

现在,召回率跳升到13.4%,但精确度下降至24.8%。几何均值得分从5.5%降至34%,这得益于新的权重设置。

predict_and_evalutate()函数返回使用的管道实例。我们可以通过clf[-1]获取管道的最后一个组件,即逻辑回归分类器。然后,我们可以访问分配给每个特征的分类器系数。在嵌入步骤中,我们可能最终会得到多达 200 个特征;10 个估算器×最多 20 个叶节点。以下函数打印出最后九个特征及其系数以及截距:

def calculate_feature_coeff(clf):
    return pd.DataFrame(
        {
            'Features': [
                f'EmbFeature{e}' 
                for e in range(len(clf[-1].coef_[0]))
            ] + ['Intercept'],
            'Coeff': list(
                clf[-1].coef_[0]
            ) + [clf[-1].intercept_[0]]
        }

    ).set_index('Features').tail(10)

calculate_feature_coeff(clf).round(2)的输出也可以四舍五入到两位小数,如下所示:

现在,让我们并排比较三种加权策略。权重为 1 时,少数类和多数类的权重相同。然后,我们将少数类的权重设置为多数类的两倍,再设置为五倍,如下所示:

df_coef_list = []
weight_options = [1, 2, 5]

for w in weight_options:

    print(f'\nMinority Class (Positive Class) Weight = Weight x {w}')
    sample_weight = (1 * (y_train == 0)) + (w * (y_train == 1))
    clf = predict_and_evalutate(
        x_train, y_train, x_test, y_test, 
        sample_weight=sample_weight
    )
    df_coef = calculate_feature_coeff(clf)
    df_coef = df_coef.rename(columns={'Coeff': f'Coeff [w={w}]'})
    df_coef_list.append(df_coef)

这给我们带来了以下结果:

很容易看到加权如何影响精度和召回率。就好像其中一个总是在牺牲另一个的情况下改进。这种行为是由于移动了分类器的边界。如我们所知,类别边界是由不同特征的系数以及截距定义的。我敢打赌你很想看到这三种先前模型的系数并排显示。幸运的是,我们已经将系数保存在df_coef_list中,以便我们可以使用以下代码片段显示它们:

pd.concat(df_coef_list, axis=1).round(2).style.bar(
    subset=[f'Coeff [w={w}]' for w in weight_options], 
    color='#999',
    align='zero'
)

这给我们带来了三种分类器之间的以下视觉比较:

特征的系数确实发生了轻微变化,但截距的变化更为显著。总之,加权最影响截距,并因此移动了类别边界。

如果预测的概率超过50%,则将样本分类为正类成员。截距的变化(在其他系数不变的情况下)相当于改变概率阈值,使其高于或低于50%。如果加权只影响截距,我们可能会建议尝试不同的概率阈值,直到获得期望的精度-召回平衡。为了检查加权是否提供了超出仅改变截距的额外好处,我们必须检查接收者操作特征ROC)曲线下面积。

加权对 ROC 的影响

加权是否改善了 ROC 曲线下面积?为了回答这个问题,让我们从创建一个显示 ROC 曲线并打印曲线下面积AUC)的函数开始:

from sklearn.metrics import roc_curve, auc

def plot_roc_curve(y, y_proba, ax, label):
    fpr, tpr, thr = roc_curve(y, y_proba)
    auc_value = auc(fpr, tpr)
    pd.DataFrame(
        {
            'FPR': fpr,
            'TPR': tpr
        }
    ).set_index('FPR')['TPR'].plot(
        label=label + f'; AUC = {auc_value:.3f}',
        kind='line',
        xlim=(0,1),
        ylim=(0,1),
        color='k',
        ax=ax
    )
    return (fpr, tpr, auc_value)

现在,我们可以循环遍历三种加权选项,并渲染它们相应的曲线,如下所示:

from sklearn.metrics import roc_curve, auc

fig, ax = plt.subplots(1, 1, figsize=(15, 8), sharey=False)

ax.plot(
    [0, 1], [0, 1], 
    linestyle='--', 
    lw=2, color='k',
    label='Chance', alpha=.8
)

for w in weight_options:

    sample_weight = (1 * (y_train == 0)) + (w * (y_train == 1))

    clf = Pipeline(
        [
            ('Embedder', RandomTreesEmbedding(n_estimators=20, max_leaf_nodes=20, random_state=42)), 
            ('Scaler', MaxAbsScaler()),
            ('Classifier', LogisticRegression(solver='lbfgs', max_iter=2000, random_state=42))
        ]
    )
    clf.fit(x_train, y_train, Classifier__sample_weight=sample_weight)
    y_test_pred_proba = clf.predict_proba(x_test)[:,1]

    plot_roc_curve(
        y_test, y_test_pred_proba, 
        label=f'\nMinority Class Weight = Weight x {w}',
        ax=ax
    ) 

ax.set_title('Receiver Operating Characteristic (ROC)')
ax.set_xlabel('False Positive Rate')
ax.set_ylabel('True Positive Rate')

ax.legend(ncol=1, fontsize='large', shadow=True)

fig.show() 

这三条曲线在这里展示:

ROC 曲线旨在显示不同概率阈值下的真正率(TPR)与假正率(FPR)之间的权衡。如果 ROC 曲线下的面积对于三种加权策略大致相同,那么加权除了改变分类器的截距外,并没有提供太多价值。因此,是否要以牺牲精度为代价来提高召回率,就取决于我们是否想重新加权训练样本,或者尝试不同的分类决策概率阈值。

除了样本加权之外,我们还可以重新采样训练数据,以便在一个更加平衡的数据集上进行训练。在下一节中,我们将看到imbalanced-learn库提供的不同采样技术。

训练数据的采样

"这不是否认。我只是对我接受的现实有选择性。"

  • 比尔·沃特森

如果机器学习模型是人类,它们可能会认为目的证明手段是合理的。当 99%的训练数据属于同一类时,它们的目标是优化目标函数。如果它们专注于正确处理那一类,也不能怪它们,因为它为解决方案贡献了 99%的数据。在上一节中,我们通过给少数类或多类更多的权重来尝试改变这种行为。另一种策略可能是从多数类中移除一些样本,或向少数类中添加新样本,直到两个类达到平衡。

对多数类进行下采样

"真理,就像黄金,不是通过它的增长得到的,而是通过洗净其中所有非黄金的部分来获得的。"

  • 列夫·托尔斯泰

我们可以随机移除多数类的样本,直到它与少数类的大小相同。在处理非二分类任务时,我们可以从所有类别中移除样本,直到它们都变成与少数类相同的大小。这个技术被称为随机下采样。以下代码展示了如何使用RandomUnderSampler()来对多数类进行下采样:

from imblearn.under_sampling import RandomUnderSampler

rus = RandomUnderSampler()
x_train_resampled, y_train_resampled = rus.fit_resample(x_train, y_train)

与其保持类别平衡,你可以通过设置sampling_strategy超参数来减少类别的不平衡。其值决定了少数类与多数类的最终比例。在下面的示例中,我们保持了多数类的最终大小,使其是少数类的两倍:

from imblearn.under_sampling import RandomUnderSampler

rus = RandomUnderSampler(sampling_strategy=0.5)
x_train_resampled, y_train_resampled = rus.fit_resample(x_train, y_train)

下采样过程不一定是随机的。例如,我们可以使用最近邻算法来移除那些与邻居不一致的样本。EditedNearestNeighbours模块允许你通过其n_neighbors超参数来设置检查邻居的数量,代码如下:

from imblearn.under_sampling import EditedNearestNeighbours

enn = EditedNearestNeighbours(n_neighbors=5)
x_train_resampled, y_train_resampled = enn.fit_resample(x_train, y_train)

之前的技术属于原型选择。在这种情况下,我们从已经存在的样本中选择样本。与原型选择不同,原型生成方法生成新的样本来概括现有样本。ClusterCentroids算法将多数类样本放入聚类中,并使用聚类中心代替原始样本。有关聚类和聚类中心的更多内容,将在第十一章《聚类——理解无标签数据》中提供。

为了比较前述算法,让我们创建一个函数,该函数接收 x 和 y 以及采样器实例,然后训练它们并返回测试集的预测值:

from sklearn.preprocessing import MaxAbsScaler
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomTreesEmbedding
from sklearn.pipeline import Pipeline

def sample_and_predict(x_train, y_train, x_test, y_test, sampler=None):

    if sampler:
        x_train, y_train = sampler.fit_resample(x_train, y_train)

    clf = Pipeline(
        [
            ('Embedder', RandomTreesEmbedding(n_estimators=10, max_leaf_nodes=20, random_state=42)), 
            ('Scaler', MaxAbsScaler()),
            ('Classifier', LogisticRegression(solver='saga', max_iter=1000, random_state=42))
        ]
    )
    clf.fit(x_train, y_train)
    y_test_pred_proba = clf.predict_proba(x_test)[:,1]

    return y_test, y_test_pred_proba

现在,我们可以使用刚刚创建的sample_and_predict()函数,并为以下两种采样技术绘制结果的 ROC 曲线:

from sklearn.metrics import roc_curve, auc
from imblearn.under_sampling import RandomUnderSampler
from imblearn.under_sampling import EditedNearestNeighbours

fig, ax = plt.subplots(1, 1, figsize=(15, 8), sharey=False)

# Original Data

y_test, y_test_pred_proba = sample_and_predict(x_train, y_train, x_test, y_test, sampler=None)
plot_roc_curve(
    y_test, y_test_pred_proba, 
    label='Original Data',
    ax=ax
) 

# RandomUnderSampler

rus = RandomUnderSampler(random_state=42)
y_test, y_test_pred_proba = sample_and_predict(x_train, y_train, x_test, y_test, sampler=rus)

plot_roc_curve(
    y_test, y_test_pred_proba, 
    label='RandomUnderSampler',
    ax=ax
) 

# EditedNearestNeighbours

nc = EditedNearestNeighbours(n_neighbors=5)
y_test, y_test_pred_proba = sample_and_predict(x_train, y_train, x_test, y_test, sampler=nc)

plot_roc_curve(
    y_test, y_test_pred_proba, 
    label='EditedNearestNeighbours',
    ax=ax
) 

ax.legend(ncol=1, fontsize='large', shadow=True)

fig.show()

结果的 ROC 曲线将如下所示:

在这里,我们可以看到与训练原始未采样数据集相比,采样技术对 ROC 曲线下面积的影响。三张图可能太过接近,导致我们难以区分它们,就像这里一样,因此检查最终的 AUC 值更为有意义。

对少数类进行过采样

除了欠采样,我们还可以增加少数类的数据点。RandomOverSampler简单地复制少数类的随机样本,直到它的大小与多数类相同。而SMOTEADASYN则通过插值生成新的合成样本。

在这里,我们将RandomOverSamplerSMOTE过采样算法进行比较:

from sklearn.metrics import roc_curve, auc
from imblearn.over_sampling import RandomOverSampler
from imblearn.over_sampling import SMOTE

fig, ax = plt.subplots(1, 1, figsize=(15, 8), sharey=False)

# RandomOverSampler

ros = RandomOverSampler(random_state=42)
y_test, y_test_pred_proba = sample_and_predict(x_train, y_train, x_test, y_test, sampler=ros)
plot_roc_curve(
    y_test, y_test_pred_proba, 
    label='RandomOverSampler',
    ax=ax
)

# SMOTE 

smote = SMOTE(random_state=42)
y_test, y_test_pred_proba = sample_and_predict(x_train, y_train, x_test, y_test, sampler=smote)
plot_roc_curve(
    y_test, y_test_pred_proba, 
    label='SMOTE',
    ax=ax
) 

ax.legend(ncol=1, fontsize='large', shadow=True)

fig.show()

结果的 ROC 曲线帮助我们比较当前数据集上两种技术的性能:

正如我们所看到的,SMOTE算法在当前数据集上的表现不好,而RandomOverSampler则使曲线向上移动。到目前为止,我们使用的分类器对于我们应用的采样技术是无关的。我们可以简单地移除逻辑回归分类器,并在不更改数据采样代码的情况下插入任何其他分类器。与我们使用的算法不同,数据采样过程是一些集成算法的核心组成部分。在下一节中,我们将学习如何利用这一点,做到两者兼得。

将数据采样与集成方法结合

第八章中,集成方法——当一个模型不足以解决问题时,我们学习了包外算法。它们基本上允许多个估计器从数据集的不同子集进行学习,期望这些多样化的训练子集能够帮助不同的估计器在结合时做出更好的决策。现在我们已经对多数类进行了欠采样,以保持训练数据的平衡,结合这两个思路是很自然的;也就是说,结合包外和欠采样技术。

BalancedBaggingClassifier在不同的随机选择的数据子集上构建多个估计器,在采样过程中,类别是平衡的。同样,BalancedRandomForestClassifier在平衡样本上构建其决策树。以下代码绘制了这两个集成方法的 ROC 曲线:

from imblearn.ensemble import BalancedRandomForestClassifier
from imblearn.ensemble import BalancedBaggingClassifier

fig, ax = plt.subplots(1, 1, figsize=(15, 8), sharey=False)

# BalancedBaggingClassifier

clf = BalancedBaggingClassifier(n_estimators=500, n_jobs=-1, random_state=42)
clf.fit(x_train, y_train)
y_test_pred_proba = clf.predict_proba(x_test)[:,1]

plot_roc_curve(
    y_test, y_test_pred_proba, 
    label='Balanced Bagging Classifier',
    ax=ax
) 

# BalancedRandomForestClassifier

clf = BalancedRandomForestClassifier(n_estimators=500, n_jobs=-1, random_state=42)
clf.fit(x_train, y_train)
y_test_pred_proba = clf.predict_proba(x_test)[:,1]

plot_roc_curve(
    y_test, y_test_pred_proba, 
    label='Balanced Random Forest Classifier',
    ax=ax
) 

fig.show()

出于简洁考虑,某些格式化行已被省略。运行前面的代码会给我们以下图表:

从这一点可以看出,欠采样和集成方法的结合比我们之前的模型取得了更好的效果。

除了包外算法,RUSBoostClassifier将随机欠采样技术与adaBoost分类器相结合。

平等机会得分

到目前为止,我们只关注了类别标签的不平衡。在某些情况下,某个特征的不平衡也可能是一个问题。假设历史上,公司大多数工程师是男性。如果现在你基于现有数据构建一个算法来筛选新申请人,那么这个算法是否会对女性候选人产生歧视?

平等机会得分试图评估模型在某个特征上的依赖程度。简单来说,如果模型的预测和实际目标之间的关系无论该特征的值如何都相同,则认为模型对该特征的不同值给予了平等的机会。形式上,这意味着在实际目标和申请人性别条件下,预测目标的条件概率应该是相同的,无论性别如何。以下方程显示了这些条件概率:

之前的方程只给出了二元结果。因此,我们可以将其转化为一个比率,值在 0 和 1 之间。由于我们不知道哪个性别获得更好的机会,我们使用以下方程取两种可能分数中的最小值:

为了展示这一指标,假设我们有一个基于申请人的IQGender训练的模型。以下代码展示了该模型在测试集上的预测结果,其中真实标签和预测值并排列出:

df_engineers = pd.DataFrame(
    {
        'IQ': [110, 120, 124, 123, 112, 114],
        'Gender': ['M', 'F', 'M', 'F', 'M', 'F'],
        'Is Hired? (True Label)': [0, 1, 1, 1, 1, 0],
        'Is Hired? (Predicted Label)': [1, 0, 1, 1, 1, 0],
    }
)

现在,我们可以创建一个函数来计算等机会得分,代码如下:

def equal_opportunity_score(df, true_label, predicted_label, feature_name, feature_value):
    opportunity_to_value = df[
        (df[true_label] == 1) & (df[feature_name] == feature_value)
    ][predicted_label].mean() / df[
        (df[true_label] == 1) & (df[feature_name] != feature_value)
    ][predicted_label].mean()
    opportunity_to_other_values = 1 / opportunity_to_value
    better_opportunity_to_value = opportunity_to_value > opportunity_to_other_values
    return {
        'Score': min(opportunity_to_value, opportunity_to_other_values),
        f'Better Opportunity to {feature_value}': better_opportunity_to_value
    }

当使用我们的df_engineers数据框时,它将给我们0.5。一个小于 1 的值告诉我们,女性申请者在我们的模型中获得聘用的机会较少:

equal_opportunity_score(
    df=df_engineers, 
    true_label='Is Hired? (True Label)', 
    predicted_label='Is Hired? (Predicted Label)', 
    feature_name='Gender',
    feature_value='F'
)

显然,我们可以完全从模型中排除性别特征,但如果有任何剩余的特征依赖于申请者的性别,那么这个得分仍然很有用。此外,在处理非二元分类器和/或非二元特征时,我们需要调整这个得分。你可以在Moritz Hardt等人的原始论文中更详细地阅读有关此得分的内容。

摘要

在这一章中,我们学习了如何处理类别不平衡问题。这是机器学习中的一个常见问题,其中大部分价值都集中在少数类中。这种现象足够常见,以至于黑天鹅隐喻被用来解释它。当机器学习算法试图盲目地优化其开箱即用的目标函数时,它们通常会忽略这些黑天鹅。因此,我们必须使用诸如样本加权、样本删除和样本生成等技术,迫使算法实现我们的目标。

这是本书关于监督学习算法的最后一章。有一个粗略估计,大约 80%的商业和学术环境中的机器学习问题是监督学习问题,这也是本书约 80%的内容聚焦于这一范式的原因。从下一章开始,我们将开始介绍其他机器学习范式,这是现实生活中大约 20%价值所在。我们将从聚类算法开始,然后继续探讨其他数据也是未标记的情况下的问题。****

第三部分:无监督学习及更多

如果您的数据没有标签,那么本节将帮助您找到理解数据的方法。您将学习如何组织海量数据集,识别其中的异常值,并根据用户的历史行为推断其偏好。

本节包括以下章节:

第十一章:聚类 – 理解无标签数据

聚类是无监督学习方法的代表。它通常是我们在需要为无标签数据添加意义时的首选。在一个电子商务网站中,营销团队可能会要求你将用户划分为几个类别,以便他们能够为每个群体定制信息。如果没有人给这些数百万用户打标签,那么聚类就是你将这些用户分组的唯一方法。当处理大量文档、视频或网页,并且这些内容没有被分配类别,而且你又不愿意求助于Marie Kondo,那么聚类就是你整理这一堆混乱数据的唯一途径。

由于这是我们关于监督学习算法的第一章,我们将首先介绍一些关于聚类的理论背景。然后,我们将研究三种常用的聚类算法,并介绍用于评估这些算法的方法。

在本章中,我们将讨论以下主题:

  • 理解聚类

  • K 均值聚类

  • 聚合聚类

  • DBSCAN

让我们开始吧!

理解聚类

机器学习算法可以看作是优化问题。它们获取数据样本和目标函数,并尝试优化该函数。在监督学习的情况下,目标函数基于所提供的标签。我们试图最小化预测值和实际标签之间的差异。在无监督学习的情况下,由于缺乏标签,情况有所不同。聚类算法本质上是试图将数据样本划分到不同的聚类中,从而最小化聚类内的距离并最大化聚类间的距离。换句话说,我们希望同一聚类中的样本尽可能相似,而来自不同聚类的样本则尽可能不同。

然而,解决这个优化问题有一个显而易见的解决方案。如果我们将每个样本视为其自身的聚类,那么聚类内的距离都为零,而聚类间的距离则为最大值。显然,这不是我们希望从聚类算法中得到的结果。因此,为了避免这个显而易见的解决方案,我们通常会在优化函数中添加约束。例如,我们可能会预定义需要的聚类数量,以确保避免上述显而易见的解决方案。另一个可能的约束是设置每个聚类的最小样本数。在本章讨论不同的聚类算法时,我们将看到这些约束在实际中的应用。

标签的缺失还决定了评估结果聚类好坏的不同度量标准。这就是为什么我决定在这里强调聚类算法的目标函数,因为理解算法的目标有助于更容易理解其评估度量标准。在本章中,我们将遇到几个评估度量标准。

衡量簇内距离的一种方式是计算簇中每个点与簇中心的距离。簇中心的概念你应该已经很熟悉,因为我们在第五章中讨论过最近邻中心算法,图像处理与最近邻。簇中心基本上是簇中所有样本的均值。此外,某些样本与其均值之间的平均欧几里得距离还有一个名字,这是我们在小学时学过的——标准差。相同的距离度量可以用于衡量簇中心之间的差异。

目前,我们准备好探索第一个算法——K 均值。然而,我们需要先创建一些样本数据,这样才能用来演示算法。在接下来的部分,解释完算法之后,我们将创建所需的数据,并使用 K 均值算法进行聚类。

K 均值聚类

“我们都知道自己是独一无二的个体,但我们往往把他人看作是群体的代表。”

  • Deborah Tannen

在上一节中,我们讨论了通过指定所需簇的数量来对目标函数进行约束。这就是K的含义:簇的数量。我们还讨论了簇的中心,因此“均值”这个词也可以理解。算法的工作方式如下:

  1. 它首先随机选择K个点,并将其设置为簇中心。

  2. 然后,它将每个数据点分配给最近的簇中心,形成K个簇。

  3. 然后,它会为新形成的簇计算新的簇中心。

  4. 由于簇中心已经更新,我们需要回到步骤 2,根据更新后的簇中心重新分配样本到新的簇中。然而,如果簇中心没有太大变化,我们就知道算法已经收敛,可以停止。

如你所见,这是一个迭代算法。它会不断迭代直到收敛,但我们可以通过设置其max_iter超参数来限制迭代次数。此外,我们可以通过将tol超参数设置为更大的值来容忍更大的中心移动,从而提前停止。关于初始簇中心的不同选择可能会导致不同的结果。将算法的init超参数设置为k-means++可以确保初始簇中心彼此远离。这通常比随机初始化能得到更好的结果。K*的选择也是通过n_clusters超参数来指定的。为了演示该算法及其超参数的使用,我们先从创建一个示例数据集开始。

*## 创建一个球形数据集

我们通常将聚类数据可视化为圆形的散点数据点。这种形状也称为凸聚类,是算法最容易处理的形状之一。稍后我们将生成更难以聚类的数据集,但现在我们先从简单的 blob 开始。

make_blobs函数帮助我们创建一个 blob 形状的数据集。在这里,我们将样本数量设置为100,并将它们分成四个聚类。每个数据点只有两个特征。这将使我们后续更容易可视化数据。这些聚类有不同的标准差;也就是说,有些聚类比其他聚类更分散。该函数还返回标签。我们将标签放在一边,稍后用于验证我们的算法。最后,我们将xy放入一个 DataFrame,并将其命名为df_blobs

from sklearn.datasets import make_blobs

x, y = make_blobs(n_samples=100, centers=4, n_features=2, cluster_std=[1, 1.5, 2, 2], random_state=7)

df_blobs = pd.DataFrame(
    {
        'x1': x[:,0],
        'x2': x[:,1],
        'y': y
    }
)

为了确保你得到和我一样的数据,请将数据生成函数的random_state参数设置为一个特定的随机种子。数据准备好后,我们需要创建一个函数来可视化这些数据。

可视化我们的示例数据

在本章中,我们将使用以下函数。它接受二维的 xy 标签,并将它们绘制到给定的 Matplotlib 轴 ax 上。在实际场景中,通常不会给出标签,但我们仍然可以将聚类算法预测的标签传递给这个函数。生成的图形会带上一个标题,并显示从给定 y 的基数推断出的聚类数量:

def plot_2d_clusters(x, y, ax):

    y_uniques = pd.Series(y).unique()

    for y_unique_item in y_uniques:
 x[
            y == y_unique_item
        ].plot(
            title=f'{len(y_uniques)} Clusters',
            kind='scatter',
            x='x1', y='x2',
            marker=f'${y_unique_item}$',
            ax=ax,
        )

我们可以按如下方式使用新的plot_2d_clusters()函数:

fig, ax = plt.subplots(1, 1, figsize=(10, 6))
x, y = df_blobs[['x1', 'x2']], df_blobs['y']
plot_2d_clusters(x, y, ax)

这将给我们以下图示:

每个数据点都会根据其给定的标签进行标记。现在,我们将假设这些标签没有被提供,并观察 K-means 算法是否能够预测这些标签。

使用 K-means 进行聚类

现在我们假装没有给定标签,我们该如何确定用于K的值,也就是n_clusters超参数的值呢?我们无法确定。现在我们只好随便选一个数值,稍后我们将学习如何找到n_clusters的最佳值。暂时我们将其设为五。其余的超参数将保持默认值。一旦算法初始化完成,我们可以使用它的fit_predict方法,如下所示:

from sklearn.cluster import KMeans
kmeans = KMeans(n_clusters=2, random_state=7)
x, y = df_blobs[['x1', 'x2']], df_blobs['y']
y_pred = kmeans.fit_predict(x)

请注意,在训练集上进行拟合并在测试集上进行预测的概念在这里通常没有意义。我们通常在同一数据集上进行拟合和预测。我们也不会向fitfit_predict方法传递任何标签。

现在我们已经预测了新的标签,我们可以使用plot_2d_clusters()函数来将我们的预测与原始标签进行比较,如下所示:

fig, axs = plt.subplots(1, 2, figsize=(14, 6))

x, y = df_blobs[['x1', 'x2']], df_blobs['y']
plot_2d_clusters(x, y, axs[0])
plot_2d_clusters(x, y_pred, axs[1])

axs[0].set_title(f'Actuals: {axs[0].get_title()}')
axs[1].set_title(f'KMeans: {axs[1].get_title()}')

我在对应的图形标题前加上了ActualsKMeans两个词。生成的聚类如下截图所示:

由于我们将K设置为五,原来的四个聚类中的一个被拆分成了两个。除此之外,其他聚类的预测结果是合理的。给聚类分配的标签是随意的。原来标签为一的聚类在算法中被称为三。只要聚类的成员完全相同,这一点应该不会让我们困扰。这一点对于聚类评估指标也没有影响。它们通常会考虑到这一事实,并在评估聚类算法时忽略标签名称。

然而,我们如何确定K的值呢?我们别无选择,只能多次运行算法,使用不同数量的聚类并选择最佳结果。在以下的代码片段中,我们正在遍历三个不同的n_clusters值。我们还可以访问最终的质心,这些质心是在算法收敛后为每个聚类计算得出的。查看这些质心有助于理解算法如何将每个数据点分配到它自己的聚类中。代码片段的最后一行使用三角形标记在三个图形中绘制了质心:

from sklearn.cluster import KMeans

n_clusters_options = [2, 4, 6]

fig, axs = plt.subplots(1, len(n_clusters_options), figsize=(16, 6))

for i, n_clusters in enumerate(n_clusters_options):

    x, y = df_blobs[['x1', 'x2']], df_blobs['y']

    kmeans = KMeans(n_clusters=n_clusters, random_state=7)
    y_pred = kmeans.fit_predict(x)

    plot_2d_clusters(x, y_pred, axs[i])

    axs[i].plot(
        kmeans.cluster_centers_[:,0], kmeans.cluster_centers_[:,1], 
        'k^', ms=12, alpha=0.75
    )

这是三个选择的结果,横向排列:

对三个图形进行视觉检查告诉我们,选择四个聚类是正确的选择。不过,我们必须记住,我们这里处理的是二维数据点。如果我们的数据样本包含两个以上的特征,同样的视觉检查就会变得更加困难。在接下来的部分中,我们将学习轮廓系数,并利用它来选择最佳的聚类数,而不依赖于视觉辅助。

轮廓系数

轮廓系数是衡量一个样本与其自身聚类中其他样本相比的相似度的指标。对于每个样本,我们将计算该样本与同一聚类中所有其他样本之间的平均距离。我们称这个平均距离为A。然后,我们计算该样本与最近聚类中所有其他样本之间的平均距离。我们称这个平均距离为B。现在,我们可以定义轮廓系数,如下所示:

现在,我们不再通过视觉检查聚类,而是将遍历多个n_clusters的值,并在每次迭代后存储轮廓系数。如你所见,silhouette_score接受两个参数——数据点(x)和预测的聚类标签(y_pred):

from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score

n_clusters_options = [2, 3, 4, 5, 6, 7, 8]
silhouette_scores = []

for i, n_clusters in enumerate(n_clusters_options):

    x, y = df_blobs[['x1', 'x2']], df_blobs['y']
    kmeans = KMeans(n_clusters=n_clusters, random_state=7)
    y_pred = kmeans.fit_predict(x)

    silhouette_scores.append(silhouette_score(x, y_pred))

我们可以直接选择提供最佳得分的n_clusters值。在这里,我们将计算出的得分放入一个 DataFrame 中,并使用柱状图进行比较:

fig, ax = plt.subplots(1, 1, figsize=(12, 6), sharey=False)

pd.DataFrame(
    {
        'n_clusters': n_clusters_options,
        'silhouette_score': silhouette_scores,
    }
).set_index('n_clusters').plot(
    title='KMeans: Silhouette Score vs # Clusters chosen',
    kind='bar',
    ax=ax
)

结果得分确认了我们最初的决定,四是最适合的聚类数:

除了选择聚类的数量外,算法初始质心的选择也会影响其准确性。错误的选择可能会导致 K-means 算法收敛到一个不理想的局部最小值。在下一节中,我们将看到初始质心如何影响算法的最终决策。

选择初始质心

默认情况下,scikit-learn 的 K-means 实现会选择相互之间距离较远的随机初始质心。它还会尝试多个初始质心,并选择产生最佳结果的那个。话虽如此,我们也可以手动设置初始质心。在以下代码片段中,我们将比较两种初始设置,看看它们对最终结果的影响。然后我们将并排打印这两种结果:

from sklearn.cluster import KMeans

initial_centroid_options = np.array([
    [(-10,5), (0, 5), (10, 0), (-10, 0)],
    [(0,0), (0.1, 0.1), (0, 0), (0.1, 0.1)],
])

fig, axs = plt.subplots(1, 2, figsize=(16, 6))

for i, initial_centroids in enumerate(initial_centroid_options):

    x, y = df_blobs[['x1', 'x2']], df_blobs['y']
    kmeans = KMeans(
       init=initial_centroids, max_iter=500, n_clusters=4, random_state=7
    )
    y_pred = kmeans.fit_predict(x)
    plot_2d_clusters(x, y_pred, axs[i])

    axs[i].plot(
       kmeans.cluster_centers_[:,0], kmeans.cluster_centers_[:,1], 'k^'
    )

以下图表展示了算法收敛后的聚类结果。部分样式代码为了简洁被省略:

显然,第一个初始设置帮助了算法,而第二个设置则导致了不好的结果。因此,我们必须注意算法的初始化,因为它的结果是非确定性的。

在机器学习领域,迁移学习指的是我们需要重新利用在解决一个问题时获得的知识,并将其应用于稍微不同的另一个问题。人类也需要迁移学习。K-means 算法有一个 fit_transform 方法。如果我们的数据(x)由 N 个样本和 M 个特征组成,方法将把它转换成 N 个样本和 K 列。列中的值基于预测的聚类。通常,K 要远小于 N。因此,您可以重新利用 K-means 聚类算法,使其可以作为降维步骤,在将其转换后的输出传递给简单分类器或回归器之前。类似地,在多分类问题中,可以使用聚类算法来减少目标的基数。**

与 K-means 算法相对,层次聚类**是另一种结果是确定性的算法。它不依赖任何初始选择,因为它从不同的角度来解决聚类问题。层次聚类是下一节的主题。

层次聚类

"人口最多的城市不过是荒野的集合。"

  • 奥尔杜斯·赫胥黎

在 K-means 聚类算法中,我们从一开始就有了我们的K个聚类。在每次迭代中,某些样本可能会改变其归属,某些聚类的质心可能会改变,但最终,聚类从一开始就已定义。相反,在凝聚层次聚类中,开始时并不存在聚类。最初,每个样本都属于它自己的聚类。开始时的聚类数量与数据样本的数量相同。然后,我们找到两个最接近的样本,并将它们合并为一个聚类。之后,我们继续迭代,通过合并下一个最接近的两个样本、两个聚类,或者下一个最接近的样本和一个聚类。正如你所看到的,每次迭代时,聚类数量都会减少一个,直到所有样本都加入一个聚类。将所有样本放入一个聚类听起来不太直观。因此,我们可以选择在任何迭代中停止算法,具体取决于我们需要的最终聚类数量。

那么,让我们来学习如何使用凝聚层次聚类算法。要让算法提前终止其聚合任务,你需要通过它的n_clusters超参数告知它我们需要的最终聚类数量。显然,既然我提到算法会合并已关闭的聚类,我们需要深入了解聚类间的距离是如何计算的,但暂时我们可以忽略这一点——稍后我们会讲到。以下是当聚类数量设置为4时,算法的使用方法:

from sklearn.cluster import AgglomerativeClustering

x, y = df_blobs[['x1', 'x2']], df_blobs['y']

agglo = AgglomerativeClustering(n_clusters=4)
y_pred = agglo.fit_predict(x)

由于我们将聚类数量设置为4,预测的y_pred将会有从零到三的值。

事实上,凝聚层次聚类算法并没有在聚类数量为四时停止。它继续合并聚类,并使用内部树结构跟踪哪些聚类是哪些更大聚类的成员。当我们指定只需要四个聚类时,它重新访问这个内部树,并相应地推断聚类的标签。在下一节中,我们将学习如何访问算法的内部层级,并追踪它构建的树。

跟踪凝聚层次聚类的子节点

如前所述,每个样本或聚类都会成为另一个聚类的成员,而这个聚类又成为更大聚类的成员,依此类推。这个层次结构被存储在算法的children_属性中。这个属性的形式是一个列表的列表。外部列表的成员数量等于数据样本数量减去一。每个成员列表由两个数字组成。我们可以列出children_属性的最后五个成员,如下所示:

agglo.children_[-5:]

这将给我们以下列表:

array([[182, 193],
       [188, 192],
       [189, 191],
       [194, 195],
       [196, 197]])

列表中的最后一个元素是树的根节点。它有两个子节点,196197。这些是根节点的子节点的 ID。大于或等于数据样本数量的 ID 是聚类 ID,而较小的 ID 则表示单个样本。如果你从聚类 ID 中减去数据样本的数量,就可以得到子节点列表中的位置,从而获得该聚类的成员。根据这些信息,我们可以构建以下递归函数,它接受一个子节点列表和数据样本的数量,并返回所有聚类及其成员的嵌套树,如下所示:

def get_children(node, n_samples):
    if node[0] >= n_samples:
        child_cluster_id = node[0] - n_samples
        left = get_children(
            agglo.children_[child_cluster_id], 
            n_samples
        )
    else:
        left = node[0]

    if node[1] >= n_samples:
        child_cluster_id = node[1] - n_samples
        right = get_children(
            agglo.children_[child_cluster_id], 
            n_samples
        )
    else:
        right = node[1]

    return [left, right]

我们可以像这样调用我们刚刚创建的函数:

root = agglo.children_[-1]
n_samples = df_blobs.shape[0]
tree = get_children(root, n_samples)

此时,tree[0]tree[1]包含树的左右两侧样本的 ID——这些是两个最大聚类的成员。如果我们的目标是将样本分成四个聚类,而不是两个,我们可以使用tree[0][0]tree[0][1]tree[1][0]tree[1][1]。以下是tree[0][0]的样子:

[[[46, [[25, 73], [21, 66]]], [87, 88]],
 [[[22, 64], [4, [49, 98]]],
  [[19, [55, 72]], [[37, 70], [[[47, 82], [13, [39, 92]]], [2, [8, 35]]]]]]]

这种嵌套性使我们能够设置我们希望聚类的深度,并相应地获取其成员。尽管如此,我们可以使用以下代码将这个列表展平:

def flatten(sub_tree, flat_list):
    if type(sub_tree) is not list:
        flat_list.append(sub_tree)
    else:
        r, l = sub_tree
        flatten(r, flat_list)
        flatten(l, flat_list)

现在,我们可以获取tree[0][0]的成员,如下所示:

flat_list = []
flatten(tree[0][0], flat_list)
print(flat_list)

我们还可以模拟fit_predict的输出,并使用以下代码片段构建我们自己的预测标签。它将为我们构建的树的不同分支中的成员分配从零到三的标签。我们将我们的预测标签命名为y_pred_dash

n_samples = x.shape[0]
y_pred_dash = np.zeros(n_samples)
for i, j, label in [(0,0,0), (0,1,1), (1,0,2), (1,1,3)]:
    flat_list = []
    flatten(tree[i][j], flat_list)
    for sample_index in flat_list:
        y_pred_dash[sample_index] = label

为了确保我们的代码按预期工作,y_pred_dash中的值应该与上一节中的y_pred匹配。然而,tree[0][0]部分的树是否应被分配标签0123并没有明确的规定。我们选择标签是任意的。因此,我们需要一个评分函数来比较这两个预测,同时考虑到标签名称可能会有所不同。这就是调整后的兰德指数(adjusted Rand index)的作用,接下来我们将讨论它。

调整后的兰德指数

调整后的兰德指数在分类中的计算方式与准确率评分非常相似。它计算两个标签列表之间的一致性,但它考虑了准确率评分无法处理的以下问题:

  • 调整后的兰德指数并不关心实际的标签,只要这里的一个聚类的成员和那里聚类的成员是相同的。

  • 与分类不同,我们可能会得到太多的聚类。在极端情况下,如果每个样本都是一个独立的聚类,忽略标签名称的情况下,任何两个聚类列表都会一致。因此,调整后的兰德指数会减小两个聚类偶然一致的可能性。

当两个预测结果匹配时,最佳调整的兰德指数为1。因此,我们可以用它来比较y_pred和我们的y_pred_dash。该得分是对称的,因此在调用评分函数时,参数的顺序并不重要,如下所示:

from sklearn.metrics import adjusted_rand_score
adjusted_rand_score(y_pred, y_pred_dash)

由于我们得到了1的调整兰德指数,我们可以放心,推断子树中簇的成员资格的代码是正确的。

我之前简要提到过,在每次迭代中,算法会合并两个最接近的簇。很容易想象,如何计算两个样本之间的距离。它们基本上是两个点,我们之前已经使用了不同的距离度量,例如欧氏距离和曼哈顿距离。然而,簇并不是一个点。我们应该从哪里开始测量距离呢?是使用簇的质心吗?还是在每个簇中选择一个特定的数据点来计算距离?所有这些选择都可以通过连接超参数来指定。在下一节中,我们将看到它的不同选项。

选择聚类连接

默认情况下,使用欧氏距离来决定哪些簇对彼此最接近。这个默认度量可以通过亲和度超参数进行更改。如果你想了解更多不同的距离度量,例如余弦曼哈顿距离,请参考第五章,最近邻的图像处理。在计算两个簇之间的距离时,连接准则决定了如何测量这些距离,因为簇通常包含不止一个数据点。在完全连接中,使用两个簇中所有数据点之间的最大距离。相反,在单一连接中,使用最小距离。显然,平均连接取所有样本对之间所有距离的平均值。在沃德连接中,如果两个簇的每个数据点与合并簇的质心之间的平均欧氏距离最小,则这两个簇会合并。沃德连接仅能使用欧氏距离。

为了能够比较上述连接方法,我们需要创建一个新的数据集。数据点将以两个同心圆的形式排列。较小的圆嵌套在较大的圆内,就像莱索托和南非一样。make_circles函数指定了生成样本的数量(n_samples)、两个圆之间的距离(factor)以及数据的噪声大小(noise):

from sklearn.datasets import make_circles
x, y = make_circles(n_samples=150, factor=0.5, noise=0.05, random_state=7)
df_circles = pd.DataFrame({'x1': x[:,0], 'x2': x[:,1], 'y': y})

我稍后会显示生成的数据集,但首先,我们先使用凝聚算法对新的数据样本进行聚类。我将运行两次算法:第一次使用完全连接,第二次使用单一连接。这次我将使用曼哈顿距离:

from sklearn.cluster import AgglomerativeClustering

linkage_options = ['complete', 'single']

fig, axs = plt.subplots(1, len(linkage_options) + 1, figsize=(14, 6))

x, y = df_circles[['x1', 'x2']], df_circles['y']

plot_2d_clusters(x, y, axs[0])
axs[0].set_title(f'{axs[0].get_title()}\nActuals')

for i, linkage in enumerate(linkage_options, 1):

    y_pred = AgglomerativeClustering(
        n_clusters=2, affinity='manhattan', linkage=linkage
    ).fit_predict(x)

    plot_2d_clusters(x, y_pred, axs[i])

    axs[i].set_title(f'{axs[i].get_title()}\nAgglomerative\nLinkage= {linkage}')

这是两种连接方法并排显示的结果:

当使用单链聚合时,考虑每对聚类之间的最短距离。这使得它能够识别出数据点排列成的圆形带状区域。完全链聚合考虑聚类之间的最长距离,导致结果偏差较大。显然,单链聚合在这里获得了最佳结果。然而,由于其方差,它容易受到噪声的影响。为了证明这一点,我们可以在将噪声从0.05增加到0.08后,重新生成圆形样本,如下所示:

from sklearn.datasets import make_circles
x, y = make_circles(n_samples=150, factor=0.5, noise=0.08, random_state=7)
df_circles = pd.DataFrame({'x1': x[:,0], 'x2': x[:,1], 'y': y})

在新样本上运行相同的聚类算法将给我们以下结果:

这次噪声数据干扰了我们的单链聚合,而完全链聚合的结果变化不大。在单链聚合中,一个落在两个聚类之间的噪声点可能会导致它们合并。平均链聚合可以看作是单链和完全链聚合标准之间的中间地带。由于这些算法的迭代特性,三种链聚合方法会导致较大的聚类变得更大。这可能导致聚类大小不均。如果必须避免不平衡的聚类,那么应该优先选择沃德链聚合,而不是其他三种链聚合方法。

到目前为止,K-means 和层次聚类算法需要预先定义期望的聚类数量。与 K-means 算法相比,层次聚类计算量大,而 K-means 算法无法处理非凸数据。在下一节中,我们将看到第三种不需要预先定义聚类数量的算法。****

****# DBSCAN

"你永远无法真正理解一个人,除非你从他的角度考虑问题。"

  • 哈珀·李

缩写DBSCAN代表基于密度的噪声应用空间聚类。它将聚类视为高密度区域,之间由低密度区域分隔。这使得它能够处理任何形状的聚类。这与假设聚类为凸形的 K-means 算法不同;即数据簇与质心。DBSCAN算法首先通过识别核心样本来开始。这些是周围至少有 min_samples 点,且距离在 eps (ε) 内的点。最初,一个聚类由其核心样本组成。一旦识别出核心样本,它的邻居也会被检查,并且如果符合核心样本标准,就将其添加到聚类中。接着,聚类将被扩展,以便我们可以将非核心样本添加到其中。这些样本是可以直接从核心样本通过 eps 距离到达的点,但它们本身不是核心样本。一旦所有的聚类被识别出来,包括核心样本和非核心样本,剩余的样本将被视为噪声。

很明显,min_sampleseps超参数在最终预测中起着重要作用。这里,我们将min_samples设置为3并尝试不同的eps设置:**

from sklearn.cluster import DBSCAN

eps_options = [0.1, 1.0, 2.0, 5.0]

fig, axs = plt.subplots(1, len(eps_options) + 1, figsize=(14, 6))

x, y = df_blobs[['x1', 'x2']], df_blobs['y']

plot_2d_clusters(x, y, axs[0])
axs[0].set_title(f'{axs[0].get_title()}\nActuals')

for i, eps in enumerate(eps_options, 1):

    y_pred = DBSCAN(eps=eps, min_samples=3, metric='euclidean').fit_predict(x)

    plot_2d_clusters(x, y_pred, axs[i])
    axs[i].set_title(f'{axs[i].get_title()}\nDBSCAN\neps = {eps}')

对于 blobs 数据集,结果聚类帮助我们识别eps超参数的影响:

**

一个非常小的eps值不允许任何核心样本形成。当eps被设置为0.1时,几乎所有的点都被当作噪声处理。当我们增加eps值时,核心点开始形成。然而,在某个时刻,当eps设置为0.5时,两个簇错误地合并在一起。

同样,min_samples的值可以决定我们的聚类算法是否成功。这里,我们将尝试不同的min_samples来对我们的同心数据点进行处理:**

**```py
from sklearn.cluster import DBSCAN

min_samples_options = [3, 5, 10]

fig, axs = plt.subplots(1, len(min_samples_options) + 1, figsize=(14, 6))

x, y = df_circles[['x1', 'x2']], df_circles['y']

plot_2d_clusters(x, y, axs[0])
axs[0].set_title(f'{axs[0].get_title()}\nActuals')

for i, min_samples in enumerate(min_samples_options, 1):

y_pred = DBSCAN(
    eps=0.25, min_samples=min_samples, metric='euclidean', n_jobs=-1
).fit_predict(x)

plot_2d_clusters(x, y_pred, axs[i])

axs[i].set_title(f'{axs[i].get_title()}\nDBSCAN\nmin_samples = {min_samples}')

在这里,我们可以看到`min_samples`对聚类结果的影响:

![](https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/00022b95-11a4-44a4-9c8e-4de20b8a90a9.png)

再次强调,仔细选择`min_samples`给出了最好的结果。与`eps`不同,`min_samples`值越大,核心样本越难以形成。**

除了上述超参数外,我们还可以更改算法使用的距离度量。通常,`min_samples`取值大于三。将`min_samples`设置为一意味着每个样本将成为自己的簇,而将其设置为二将给出类似于层次聚类算法的结果,但采用单链接法。你可以从将`min_samples`值设置为数据维度的两倍开始;也就是说,设置为特征数量的两倍。然后,如果数据已知是噪声较多的,你可以增加该值,否则可以减少它。至于`eps`,我们可以使用以下**k 距离图**。

在同心数据集上,我们将`min_samples`设置为三。现在,对于每个样本,我们想要查看它的两个邻居有多远。以下代码片段计算每个点与其最接近的两个邻居之间的距离:

```py
from sklearn.neighbors import NearestNeighbors

x = df_circles[['x1', 'x2']]
distances, _ = NearestNeighbors(n_neighbors=2).fit(x).kneighbors()

如果min_samples设置为其他任何数值,我们希望得到与该数值相等的邻居数量,减去一。现在,我们可以关注每个样本的两个邻居中最远的一个,并绘制所有结果的距离,如下所示:

pd.Series(distances[:,-1]).sort_values().reset_index(drop=True).plot() 

结果图形将如下所示:

图表剧烈改变斜率的点为我们提供了eps值的大致估计。在这里,当min_samples设置为三时,eps值为0.2似乎非常合适。此外,我们可以尝试这些两个数值的不同组合,并使用轮廓系数或其他聚类度量来微调我们的超参数。

总结

英国历史学家阿诺德·汤因比曾说过,"没有哪种工具是全能的。" 在本章中,我们使用了三种工具来进行聚类。我们在这里讨论的三种算法从不同的角度处理问题。K-means 聚类算法试图找到总结簇和质心的点,并围绕它们构建聚类。凝聚层次聚类方法则更倾向于自下而上的方式,而 DBSCAN 聚类算法则引入了核心点和密度等新概念。本章是三章关于无监督学习问题的第一章。由于缺乏标签,我们被迫学习了新的评估指标,如调整兰德指数和轮廓系数。

在下一章,我们将处理第二个无监督学习问题:异常检测。幸运的是,本章中讨论的概念,以及第五章中关于最近邻和最近质心算法的内容,将帮助我们在下一章中进一步理解。再一次,我们将得到未标记的数据样本,我们的任务是挑出其中的异常样本。

第十二章:异常检测 – 找出数据中的异常值

检测数据中的异常是机器学习中的一个重复性主题。在第十章,Imbalanced Learning – Not Even 1% Win the Lottery,我们学习了如何在数据中发现这些有趣的少数群体。那时,数据是有标签的,并且之前章节中的分类算法适用于该问题。除了有标签异常检测问题外,还有一些情况下数据是无标签的。

在本章中,我们将学习如何在没有标签的情况下识别数据中的异常值。我们将使用三种不同的算法,并学习无标签异常检测的两个分支。本章将涵盖以下主题:

  • 无标签异常检测

  • 使用基本统计方法检测异常

  • 使用EllipticEnvelope检测异常值

  • 使用局部异常因子LOF)进行异常值和新颖性检测

  • 使用隔离森林检测异常值

无标签异常检测

在本章中,我们将从一些无标签数据开始,我们需要在其中找到异常样本。我们可能只会得到正常数据(inliers),并希望从中学习正常数据的特征。然后,在我们对正常数据拟合一个模型后,给定新的数据,我们需要找出与已知数据不符的异常值(outliers)。这类问题被称为新颖性检测。另一方面,如果我们在一个包含正常数据和异常值的数据集上拟合我们的模型,那么这个问题被称为异常值检测问题。

与其他无标签算法一样,fit方法会忽略任何给定的标签。该方法的接口允许你传入* x * 和 * y *,为了保持一致性,但 * y * 会被简单忽略。在新颖性检测的情况下,首先在没有异常值的数据集上使用fit方法,然后在包含正常数据和异常值的数据上使用算法的predict方法是合乎逻辑的。相反,对于异常值检测问题,通常会同时使用fit方法进行拟合,并通过fit_predict方法进行预测。

在使用任何算法之前,我们需要创建一个样本数据集,以便在本章中使用。我们的数据将包括 1,000 个样本,其中 98%的样本来自特定分布,剩余的 2%来自不同的分布。在下一节中,我们将详细介绍如何创建这个样本数据。

生成样本数据

make_classification函数允许我们指定样本数量和特征数量。我们可以限制信息性特征的数量,并使一些特征冗余——即依赖于信息性特征。我们也可以将一些特征设置为任何信息性或冗余特征的副本。在我们当前的使用案例中,我们将确保所有特征都是信息性的,因为我们将仅限于使用两个特征。由于make_classification函数是用于生成分类问题的数据,它同时返回xy

在构建模型时,我们将忽略y,并只在后续评估中使用它。我们将确保每个类别来自两个不同的分布,通过将n_clusters_per_class设置为2。我们将通过将scale设置为一个单一值,确保两个特征保持相同的尺度。我们还将确保数据是随机洗牌的(shuffle=True),并且没有任何一个类别的样本被标记为另一个类别的成员(flip_y=0)。最后,我们将random_state设置为0,确保在我们的计算机上运行以下代码时获得完全相同的随机数据:

from sklearn.datasets import make_classification

x, y = make_classification(
    n_samples=1000, n_features=2, n_informative=2, n_redundant=0, n_repeated=0, 
    n_classes=2, n_clusters_per_class=2, weights=[0.98, ], class_sep=0.5, 
    scale=1.0, shuffle=True, flip_y=0, random_state=0
)

现在样本数据已经准备好,是时候考虑如何检测其中的离群点了。

使用基本统计学检测异常值

在直接进入 scikit-learn 中现有的算法之前,让我们先思考一些方法来检测异常样本。假设每小时测量你网站的流量,这样你会得到以下数字:

hourly_traffic = [
    120, 123, 124, 119, 196, 
    121, 118, 117, 500, 132
]

看这些数字,500相比其他数值看起来相当高。正式来说,如果假设每小时的流量数据符合正态分布,那么500就更远离其均值或期望值。我们可以通过计算这些数字的均值,并检查那些距离均值超过 2 或 3 个标准差的数值来衡量这一点。类似地,我们也可以计算一个高分位数,并检查哪些数值超过了这个分位数。这里,我们找到了高于 95^(th)百分位数的值:

pd.Series(hourly_traffic) > pd.Series(hourly_traffic).quantile(0.95)

这段代码将给出一个False值的数组,除了倒数第二个值,它对应于500。在打印结果之前,让我们将前面的代码转化为一个估算器,并包含它的fitpredict方法。fit方法计算阈值并保存,而predict方法将新数据与保存的阈值进行比较。我还添加了一个fit_predict方法,它按顺序执行这两个操作。以下是估算器的代码:

class PercentileDetection:

    def __init__(self, percentile=0.9):
        self.percentile = percentile

    def fit(self, x, y=None):
        self.threshold = pd.Series(x).quantile(self.percentile)

    def predict(self, x, y=None):
        return (pd.Series(x) > self.threshold).values

    def fit_predict(self, x, y=None):
        self.fit(x)
        return self.predict(x) 

我们现在可以使用我们新创建的估算器。在以下代码片段中,我们使用 95^(th)百分位数作为我们的估算器。然后,我们将得到的预测结果与原始数据一起放入数据框中。最后,我添加了一些样式逻辑,将离群点所在的行标记为粗体:

outlierd = PercentileDetection(percentile=0.95)
pd.DataFrame(
    {
        'hourly_traffic': hourly_traffic,
        'is_outlier': outlierd.fit_predict(hourly_traffic)
    }
).style.apply(
    lambda row: ['font-weight: bold'] * len(row) 
        if row['is_outlier'] == True 
        else ['font-weight: normal'] * len(row),
    axis=1
)

这是得到的数据框:

我们能将相同的逻辑应用于前一部分的 dataset 吗?当然可以,但我们首先需要弄清楚如何将其应用于多维数据。

使用百分位数处理多维数据

hourly_traffic数据不同,我们使用make_classification函数生成的数据是多维的。这次我们有多个特征需要检查。显然,我们可以分别检查每个特征。以下是检查第一个特征的离群点的代码:

outlierd = PercentileDetection(percentile=0.98)
y_pred = outlierd.fit_predict(x[:,0])

我们也可以对其他特征做同样的事情:

outlierd = PercentileDetection(percentile=0.98)
y_pred = outlierd.fit_predict(x[:,1])

现在,我们得出了两个预测结果。我们可以以一种方式将它们结合起来,如果某个样本相对于任何一个特征是离群点,那么它就被标记为离群点。在下面的代码片段中,我们将调整PercentileDetection估算器来实现这一点:

**```py
class PercentileDetection:

def __init__(self, percentile=0.9):
    self.percentile = percentile

def fit(self, x, y=None):
    self.thresholds = [
        pd.Series(x[:,i]).quantile(self.percentile)
        for i in range(x.shape[1])
    ]

def predict(self, x, y=None):
    return (x > self.thresholds).max(axis=1)

def fit_predict(self, x, y=None):
    self.fit(x)
    return self.predict(x)

现在,我们可以按如下方式使用调整后的估算器:

```py
outlierd = PercentileDetection(percentile=0.98)
y_pred = outlierd.fit_predict(x) 

我们还可以使用之前忽略的标签来计算我们新估算器的精度和召回率。因为我们关心的是标签为1的少数类,所以在以下代码片段中,我们将pos_label设置为1

from sklearn.metrics import precision_score, recall_score

print(
    'Precision: {:.02%}, Recall: {:.02%} [Percentile Detection]'.format(
        precision_score(y, y_pred, pos_label=1),
        recall_score(y, y_pred, pos_label=1),
    )
)

这给出了4%的精度和5%的召回率。你期望更好的结果吗?我也希望如此。也许我们需要绘制数据来理解我们的方法可能存在哪些问题。以下是数据集,其中每个样本根据其标签进行标记:

我们的方法检查每个点,看看它是否在两个轴中的一个上极端。尽管离群点距离内点较远,但仍然有一些内点与离群点的每个点共享相同的水平或垂直位置。换句话说,如果你将点投影到任意一个轴上,你将无法再将离群点与内点区分开来。因此,我们需要一种方法来同时考虑这两个轴。如果我们找到这两个轴的平均点——即我们的数据的中心,然后围绕它绘制一个圆或椭圆?然后,我们可以将任何位于椭圆外的点视为离群点。这个新策略会有效吗?幸运的是,这正是EllipticEnvelope算法的作用。

使用 EllipticEnvelope 检测离群点

“我害怕变得平庸。”

– 泰勒·斯威夫特

EllipticEnvelope算法通过找到数据样本的中心,然后在该中心周围绘制一个椭圆体。椭圆体在每个轴上的半径是通过马哈拉诺比斯距离来衡量的。你可以将马哈拉诺比斯距离视为一种欧氏距离,其单位是每个方向上标准差的数量。绘制椭圆体后,位于椭圆体外的点可以被视为离群点。

多元高斯分布EllipticEnvelope算法的一个关键概念。它是单维高斯分布的推广。如果高斯分布通过单一的均值和方差来定义,那么多元高斯分布则通过均值和协方差的矩阵来定义。然后,多元高斯分布用于绘制一个椭球体,定义什么是正常的,什么是异常值。

下面是我们如何使用EllipticEnvelope算法来检测数据中的异常值,使用该算法的默认设置。请记住,本章所有异常值检测算法的predict方法会返回-1表示异常值,返回1表示内点:

from sklearn.covariance import EllipticEnvelope

ee = EllipticEnvelope(random_state=0)
y_pred = ee.fit_predict(x) == -1

我们可以使用前一节中的完全相同代码来计算预测的精确度和召回率:

from sklearn.metrics import precision_score, recall_score

print(
    'Precision: {:.02%}, Recall: {:.02%} [EllipticEnvelope]'.format(
        precision_score(y, y_pred, pos_label=1),
 recall_score(y, y_pred, pos_label=1),
    )
)

这一次,我们得到了9%的精确度和45%的召回率。这已经比之前的分数更好了,但我们能做得更好吗?嗯,如果你再看一下数据,你会注意到它是非凸的。我们已经知道每个类别中的样本来自多个分布,因此这些点的形状似乎无法完美地拟合一个椭圆。这意味着我们应该使用一种基于局部距离和密度的算法,而不是将所有东西与一个固定的中心点进行比较。局部异常因子LOF)为我们提供了这种特性。如果上一章的k 均值聚类算法属于椭圆包络算法的同一类,那么 LOF 就是 DBSCAN 算法的对应物。

使用 LOF 进行异常值和新颖性检测

“疯狂在个体中是罕见的——但在群体、党派、国家和时代中,它是常态。”

– 弗里德里希·尼采

LOF 与尼采的方式正好相反——它将样本的密度与其邻居的局部密度进行比较。与邻居相比,处于低密度区域的样本被视为异常值。像其他基于邻居的算法一样,我们可以设置参数来指定要考虑的邻居数量(n_neighbors)以及用于查找邻居的距离度量(metricp)。默认情况下,使用的是欧几里得距离——即,metric='minkowski'p=2。有关可用距离度量的更多信息,您可以参考第五章最近邻图像处理。下面是我们如何使用LocalOutlierFactor进行异常值检测,使用 50 个邻居及其默认的距离度量:

from sklearn.neighbors import LocalOutlierFactor

lof = LocalOutlierFactor(n_neighbors=50)
y_pred = lof.fit_predict(x) == -1

精确度和召回率得分现在已经进一步改善了结果。我们得到了26%的精确度和65%的召回率。

就像分类器拥有predict方法以及predict_proba方法一样,离群点检测算法不仅会给出二分类预测,还可以告诉我们它们对于某个样本是否为离群点的置信度。一旦 LOF 算法被拟合,它会将其离群点因子分数存储在negative_outlier_factor_中。如果分数接近-1,则该样本更有可能是离群点。因此,我们可以使用这个分数,将最低的 1%、2%或 10%作为离群点,其余部分视为正常点。以下是不同阈值下的性能指标比较:

from sklearn.metrics import precision_score, recall_score

lof = LocalOutlierFactor(n_neighbors=50)
lof.fit(x)

for quantile in [0.01, 0.02, 0.1]:

    y_pred = lof.negative_outlier_factor_ < np.quantile(
        lof.negative_outlier_factor_, quantile
    ) 

    print(
        'LOF: Precision: {:.02%}, Recall: {:.02%} [Quantile={:.0%}]'.format(
            precision_score(y, y_pred, pos_label=1),
            recall_score(y, y_pred, pos_label=1),
            quantile
        )
    )

以下是不同的精确度和召回率分数:

# LOF: Precision: 80.00%, Recall: 40.00% [Quantile=1%]
# LOF: Precision: 50.00%, Recall: 50.00% [Quantile=2%]
# LOF: Precision: 14.00%, Recall: 70.00% [Quantile=10%]

就像分类器的概率一样,在不同阈值下,精确度和召回率之间存在权衡。这就是你如何微调预测结果以满足需求的方法。如果已知真实标签,你还可以使用negative_outlier_factor_绘制接收器操作特性ROC)曲线或精确度-召回率PR)曲线。

除了用于离群点检测,LOF 算法还可以用于新颖性检测。

**## 使用 LOF 进行新颖性检测

当用于离群点检测时,算法必须在包含正常点和离群点的数据集上进行拟合。而在新颖性检测的情况下,我们需要只在正常点(inliers)上拟合该算法,然后在后续预测中使用被污染的数据集。此外,为了用于新颖性检测,在算法初始化时需要将novelty=True。在这里,我们从数据中去除离群点,并使用得到的子样本x_inliersfit函数进行拟合。然后,我们按照正常流程对原始数据集进行预测:

from sklearn.neighbors import LocalOutlierFactor

x_inliers = x[y==0]

lof = LocalOutlierFactor(n_neighbors=50, novelty=True)
lof.fit(x_inliers)
y_pred = lof.predict(x) == -1

得到的精确度(26.53%)和召回率(65.00%)与我们使用该算法进行离群点检测时差异不大。最终,关于新颖性检测和离群点检测方法的选择是一个策略性的问题。它取决于模型建立时可用的数据,以及这些数据是否包含离群点。

你可能已经知道,我喜欢使用集成方法,所以我很难在没有介绍一个集成算法来进行离群点检测的情况下结束这一章。在下一节中,我们将讨论隔离森林isolation forest)算法。

使用隔离森林检测离群点

在之前的方法中,我们首先定义什么是正常的,然后将任何不符合此标准的样本视为离群点。隔离森林算法采用了不同的方法。由于离群点数量较少且与其他样本差异较大,因此它们更容易从其余样本中隔离出来。因此,当构建随机树森林时,在树的叶节点较早结束的样本——也就是说,它不需要太多分支就能被隔离——更可能是离群点。

作为一种基于树的集成算法,这个算法与其对手共享许多超参数,比如构建随机树的数量(n_estimators)、构建每棵树时使用的样本比例(max_samples)、构建每棵树时考虑的特征比例(max_features)以及是否进行有放回抽样(bootstrap)。你还可以通过将 n_jobs 设置为 -1,利用机器上所有可用的 CPU 并行构建树。在这里,我们将构建一个包含 200 棵树的隔离森林算法,然后用它来预测数据集中的异常值。像本章中的所有其他算法一样,-1 的预测结果表示该样本被视为异常值:

from sklearn.ensemble import IsolationForest

iforest = IsolationForest(n_estimators=200, n_jobs=-1, random_state=10)
y_pred = iforest.fit_predict(x) == -1

得到的精度(6.5%)和召回率(60.0%)值不如之前的方法。显然,LOF 是最适合我们手头数据的算法。由于原始标签可用,我们能够对比这三种算法。实际上,标签通常是不可用的,决定使用哪种算法也变得困难。无标签异常检测评估的领域正在积极研究中,我希望在未来能够看到 scikit-learn 实现可靠的评估指标。

在监督学习的情况下,你可以使用真实标签通过 PR 曲线来评估模型。对于无标签数据,最近的研究者们正在尝试量身定制评估标准,比如超质量Excess-MassEM)和质量体积Mass-VolumeMV)曲线。

总结

到目前为止,在本书中我们使用了监督学习算法来识别异常样本。本章提供了当没有标签时的额外解决方案。这里解释的解决方案源自机器学习的不同领域,如统计学习、最近邻和基于树的集成方法。每种方法都可以表现出色,但也有缺点。我们还学到了,当没有标签时,评估机器学习算法是很棘手的。

本章将处理无标签数据。在上一章中,我们学习了如何聚类数据,接着在这一章我们学习了如何检测其中的异常值。然而,这本书里我们还有一个无监督学习的话题要讨论。下一章我们将讨论与电子商务相关的重要话题——推荐引擎。因为这是本书的最后一章,我还想讨论机器学习模型部署的可能方法。我们将学习如何保存和加载我们的模型,并如何将其部署到应用程序接口APIs)上。

第十三章:推荐系统 – 了解用户的口味

一个外行可能不知道那些控制股票交易所高频交易的复杂机器学习算法。他们也许不了解那些检测在线犯罪和控制外太空任务的算法。然而,他们每天都会与推荐引擎互动。他们是推荐引擎每天为他们挑选亚马逊上的书籍、在 Netflix 上选择他们下一部应该观看的电影、以及影响他们每天阅读新闻文章的见证者。推荐引擎在许多行业中的普及要求采用不同版本的推荐算法。

在本章中,我们将学习推荐系统使用的不同方法。我们将主要使用一个与 scikit-learn 相关的库——Surprise。Surprise 是一个实现不同协同过滤算法的工具包。因此,我们将从学习协同过滤算法和基于内容的过滤算法在推荐引擎中的区别开始。我们还将学习如何将训练好的模型打包,以便其他软件使用而无需重新训练。以下是本章将讨论的主题:

  • 不同的推荐范式

  • 下载 Surprise 和数据集

  • 使用基于 KNN 的算法

  • 使用基准算法

  • 使用奇异值分解

  • 在生产环境中部署机器学习模型

不同的推荐范式

在推荐任务中,你有一组用户与一组物品互动,你的任务是弄清楚哪些物品适合哪些用户。你可能了解每个用户的一些信息:他们住在哪里、他们赚多少钱、他们是通过手机还是平板登录的,等等。类似地,对于一个物品——比如说一部电影——你知道它的类型、制作年份以及它获得了多少项奥斯卡奖。显然,这看起来像一个分类问题。你可以将用户特征与物品特征结合起来,为每个用户-物品对构建一个分类器,然后尝试预测用户是否会喜欢该物品。这种方法被称为基于内容的过滤。顾名思义,它的效果取决于从每个用户和每个物品中提取的内容或特征。在实践中,你可能只知道每个用户的一些基本信息。用户的位置信息或性别可能足以揭示他们的口味。这种方法也很难推广。例如,如果我们决定扩展推荐引擎,推荐电视连续剧。那时奥斯卡奖的数量可能就不相关了,我们可能需要将此特征替换为金球奖提名的数量。如果我们后来将其扩展到音乐呢?因此,考虑采用一种与内容无关的不同方法似乎更为合理。

协同过滤则不太关注用户或物品特征。相反,它假设那些已经对某些物品感兴趣的用户,将来可能对相同的物品也有兴趣。为了给您推荐物品,它基本上是通过招募与您相似的其他用户,并利用他们的决策来向您推荐物品。这里一个明显的问题是冷启动问题。当新用户加入时,很难立刻知道哪些用户与他们相似。此外,对于一个新物品,可能需要一段时间,直到某些用户发现它,系统才有可能将其推荐给其他用户。

由于每种方法都有其局限性,因此可以使用两者的混合方法。在最简单的形式中,我们可以向新用户推荐平台上最受欢迎的物品。一旦这些新用户消费了足够的物品,以便我们了解他们的兴趣,我们就可以开始结合更具协同过滤的方法,为他们量身定制推荐。

在本章中,我们将重点关注协同过滤范式。它是更常见的方法,我们在前面的章节中已经学会了如何构建为基于内容的过滤方法所需的分类器。我们将使用一个名为 Surprise 的库来演示不同的协同过滤算法。在接下来的部分,我们将安装 Surprise 并下载本章其余部分所需的数据。

下载 surprise 库和数据集

Nicolas Hug 创建了 Surprise [surpriselib.com],它实现了我们在这里将使用的一些协同过滤算法。我正在使用该库的 1.1.0 版本。要通过 pip 下载相同版本的库,您可以在终端中运行以下命令:

*```py
pip install -U scikit-surprise==1.1.0


在使用该库之前,我们还需要下载本章使用的数据集。

## 下载 KDD Cup 2012 数据集

我们将使用与[第十章](https://cdp.packtpub.com/hands_on_machine_learning_with_scikit_learn/wp-admin/post.php?post=32&action=edit)*不平衡学习 – 甚至不到 1%的人赢得彩票*中使用的相同数据集。数据发布在 **OpenML** 平台上。它包含了一系列记录。在每条记录中,一个用户看到了在线广告,并且有一列额外的信息说明该用户是否点击了广告。在前面提到的章节中,我们构建了一个分类器来预测用户是否会点击广告。我们在分类器中使用了广告和访问用户提供的特征。在本章中,我们将把这个问题框架化为协同过滤问题。因此,我们将只使用用户和广告的 ID,其他所有特征将被忽略,这次的目标标签将是用户评分。在这里,我们将下载数据并将其放入数据框中:

```py
from sklearn.datasets import fetch_openml

data = fetch_openml(data_id=1220)

df = pd.DataFrame(
    data['data'],
    columns=data['feature_names']
)[['user_id', 'ad_id']].astype(int)

df['user_rating'] = pd.Series(data['target']).astype(int)

我们将所有的列转换为整数。评级列采用二进制值,1表示点击或正向评分。我们可以看到,只有16.8%的记录导致了正向评分。我们可以通过打印user_rating列的均值来验证这一点,如下所示:

df['user_rating'].mean()

我们还可以显示数据集的前四行。在这里,您可以看到用户和广告的 ID 以及给出的评分:

Surprise 库期望数据列的顺序完全按照这个格式。所以,目前不需要进行更多的数据处理。在接下来的章节中,我们将看到如何将这个数据框加载到库中,并将其分割成训练集和测试集。

处理和拆分数据集

从协同过滤的角度来看,两个用户如果对相同的物品给出相同的评分,则认为他们是相似的。在当前的数据格式中很难看到这一点。将数据转换为用户-物品评分矩阵会更好。这个矩阵的每一行表示一个用户,每一列表示一个物品,每个单元格中的值表示该用户给对应物品的评分。我们可以使用 pandaspivot 方法来创建这个矩阵。在这里,我为数据集的前 10 条记录创建了矩阵:

df.head(10).groupby(
    ['user_id', 'ad_id']
).max().reset_index().pivot(
    'user_id', 'ad_id', 'user_rating'
).fillna(0).astype(int)

这是得到的 10 个用户与 10 个物品的矩阵:

使用数据框自己实现这一点并不是最高效的做法。Surprise 库以更高效的方式存储数据。所以,我们将改用该库的 Dataset 模块。在加载数据之前,我们需要指定评分的尺度。在这里,我们将使用 Reader 模块来指定我们的评分是二进制值。然后,我们将使用数据集的 load_from_df 方法加载数据框。该方法需要我们的数据框以及前述的读取器实例:

from surprise.dataset import Dataset
from surprise import Reader

reader = Reader(rating_scale=(0, 1))
dataset = Dataset.load_from_df(df, reader)

协同过滤算法不被认为是监督学习算法,因为缺乏特征和目标等概念。尽管如此,用户会对物品进行评分,我们尝试预测这些评分。这意味着我们仍然可以通过比较实际评分和预测评分来评估我们的算法。这就是为什么通常将数据分割为训练集和测试集,并使用评估指标来检验我们的预测。Surprise 提供了一个类似于 scikit-learn 中 train_test_split 函数的功能。我们将在这里使用它,将数据分割为 75% 的训练集和 25% 的测试集:

from surprise.model_selection import train_test_split
trainset, testset = train_test_split(dataset, test_size=0.25)

除了训练-测试划分外,我们还可以进行K 折交叉验证。我们将使用平均绝对误差MAE)和均方根误差RMSE)来比较预测评分和实际评分。以下代码使用 4 折交叉验证,并打印四个折叠的平均 MAE 和 RMSE。为了方便不同算法的应用,我创建了一个predict_evaluate函数,它接受我们想使用的算法的实例。它还接受整个数据集,并且算法的名称会与结果一起打印出来。然后它使用surprisecross_validate模块来计算期望误差并打印它们的平均值:

**```py
from surprise.model_selection import cross_validate

def predict_evaluate(recsys, dataset, name='Algorithm'):
scores = cross_validate(
recsys, dataset, measures=['RMSE', 'MAE'], cv=4
)
print(
'Testset Avg. MAE: {:.2f} & Avg. RMSE: {:.2f} [{}]'.format(
scores['test_mae'].mean(),
scores['test_rmse'].mean(),
name
)
)


我们将在接下来的章节中使用这个函数。在了解不同算法之前,我们需要创建一个参考算法—一条用来与其他算法进行比较的标准。在下一节中,我们将创建一个给出随机结果的推荐系统。这个系统将是我们之后的参考算法。

## 创建一个随机推荐系统

我们知道 16.8%的记录会导致正向评分。因此,一个随机给 16.8%情况赋予正向评分的推荐系统,似乎是一个很好的参考,能够用来与其他算法进行比较。顺便说一句,我特意避免在这里使用*基准*这个术语,而是使用*参考*这样的词汇,因为这里使用的算法之一被称为*基准*。无论如何,我们可以通过创建一个继承自 Surprise 库中`AlgoBase`类的`RandomRating`类来创建我们的参考算法。库中的所有算法都继承自`AlgoBase`基础类,预计它们都需要实现一个估算方法。

这个方法会针对每一对用户-项目对进行调用,并期望返回该特定用户-项目对的预测评分。由于我们这里返回的是随机评分,我们将使用 NumPy 的`random`模块。在这里,我们将二项分布方法中的`n`设置为 1,这使得它变成了伯努利分布。在类初始化时,赋值给`p`的值指定了返回 1 的概率。默认情况下,50%的用户-项目对会得到`1`的评分,而 50%的用户-项目对会得到`0`的评分。我们将覆盖这个默认值,并在稍后的使用中将其设置为 16.8%。以下是新创建方法的代码:

```py
from surprise import AlgoBase

class RandomRating(AlgoBase):

    def __init__(self, p=0.5):
        self.p = p
        AlgoBase.__init__(self)

    def estimate(self, u, i):
        return np.random.binomial(n=1, p=self.p, size=1)[0]

我们需要将p的默认值更改为16.8%。然后,我们可以将RandomRating实例传递给predict_evaluate,以获得预测误差:

recsys = RandomRating(p=0.168)
predict_evaluate(recsys, dataset, 'RandomRating')

上述代码给出了0.28的平均 MAE 和0.53的平均 RMSE。请记住,我们使用的是 K 折交叉验证。因此,我们计算每一折返回的平均误差的平均值。记住这些误差数字,因为我们预计更先进的算法会给出更低的误差。在接下来的章节中,我们将介绍最基础的协同过滤算法系列,其灵感来源于K-近邻KNN)算法。

使用基于 KNN 的算法

我们已经遇到过足够多的 KNN算法变种,因此它是我们解决推荐问题时的首选算法。在上一节中的用户-项评分矩阵中,每一行代表一个用户,每一列代表一个项。因此,相似的行代表口味相似的用户,相同的列代表喜欢相同项的用户。因此,如果我们想估算用户(u)对项(i)的评分(r[u,i]),我们可以获取与用户(u)最相似的 KNN,找到他们对项(i)的评分,然后计算他们评分的平均值,作为对(r[u,i])的估算。然而,由于某些邻居比其他邻居与用户(u)更相似,我们可能需要使用加权平均值。与用户(u)更相似的邻居给出的评分应该比其他邻居的评分权重大。以下是一个公式,其中相似度得分用于加权用户邻居给出的评分:

我们用术语v来表示u的邻居。因此,r[v,i]是每个邻居给项(i)的评分。相反,我们也可以根据项相似度而不是用户相似度来进行估算。然后,预期的评分(r[u,i])将是用户(u)对其最相似项(i)评分的加权平均值。

你可能在想,我们现在是否可以设置邻居的数量,是否有多个相似度度量可以选择。两个问题的答案都是肯定的。我们稍后会深入探讨算法的超参数,但现在先使用它的默认值。一旦KNNBasic初始化完成,我们可以像在上一节中将RandomRating估算器传递给predict_evaluate函数那样,将其传递给predict_evaluate函数。运行以下代码之前,请确保计算机有足够的内存。

from surprise.prediction_algorithms.knns import KNNBasic
recsys = KNNBasic()
predict_evaluate(recsys, dataset, 'KNNBasic')

这一次我们得到了0.28的平均 MAE 和0.38的平均 RMSE。考虑到RandomRating估算器是在盲目地做随机预测,而KNNBasic是基于用户相似性做决策的,平方误差的改善是预期之中的。

此处使用的数据集中的评分是二进制值。在其他一些场景中,用户可能允许给出 5 星评分,甚至给出从 0 到 100 的分数。在这些场景中,一个用户可能比另一个用户更慷慨地给出分数。我们两个人可能有相同的口味,但对我来说,5 星评分意味着电影非常好,而你自己从不给 5 星评分,你最喜欢的电影顶多获得 4 星评分。KNNWithMeans算法解决了这个问题。它是与KNNBasic几乎相同的算法,不同之处在于它最初会对每个用户给出的评分进行归一化,使得评分可以进行比较。

如前所述,我们可以选择K的数值以及使用的相似度得分。此外,我们还可以决定是否基于用户相似性或物品相似性来进行估计。在这里,我们将邻居数量设置为20,使用余弦相似度,并基于物品相似性来进行估计:

from surprise.prediction_algorithms.knns import KNNBasic

sim_options = {
    'name': 'cosine', 'user_based': False
}
recsys = KNNBasic(k=20, sim_options=sim_options, verbose=False)
predict_evaluate(recsys, dataset, 'KNNBasic')

结果错误比之前更严重。我们得到的平均 MAE 为0.29,平均 RMSE 为0.39。显然,我们需要尝试不同的超参数,直到得到最佳结果。幸运的是,Surprise 提供了一个GridSearchCV助手来调节算法的超参数。我们基本上提供一个超参数值的列表,并指定我们需要用来评估算法的衡量标准。在下面的代码片段中,我们将衡量标准设置为rmsemae。我们使用 4 折交叉验证,并在运行网格搜索时使用机器上的所有可用处理器。你现在可能已经知道,KNN 算法的预测时间较慢。因此,为了加速这一过程,我只在我们的数据集的一个子集上运行了搜索,如下所示:

from surprise.model_selection import GridSearchCV
from surprise.prediction_algorithms.knns import KNNBasic

param_grid = {
    'sim_options': {
        'name':['cosine', 'pearson'],
    },
    'k': [5, 10, 20, 40],
    'verbose': [True],
}

dataset_subset = Dataset.load_from_df(
    df.sample(frac=0.25, random_state=0), reader
)
gscv = GridSearchCV(
    KNNBasic, param_grid, measures=['rmse', 'mae'], 
    cv=4, n_jobs=-1
)
gscv.fit(dataset_subset)

print('Best MAE:', gscv.best_score['mae'].round(2))
print('Best RMSE:', gscv.best_score['rmse'].round(2))
print('Best Params', gscv.best_params['rmse'])

我们得到的平均 MAE 为0.28,平均 RMSE 为0.38。这些结果与使用默认超参数时相同。然而,GridSearchCV选择了K值为20,而默认值为40。它还选择了皮尔逊相关系数作为相似度衡量标准。

KNN 算法较慢,并没有为我们的数据集提供最佳的性能。因此,在下一部分中,我们将尝试使用非实例基础的学习器。

使用基准算法

最近邻算法的简单性是一把双刃剑。一方面,它更容易掌握,但另一方面,它缺乏一个可以在训练过程中优化的目标函数。这也意味着它的大部分计算是在预测时进行的。为了解决这些问题,Yehuda Koren 将推荐问题表述为一个优化任务。然而,对于每个用户-物品对,我们仍然需要估计一个评分(r[u,i])。这次期望的评分是以下三元组的总和:

  • :所有用户对所有物品的总体平均评分

  • b[u]:表示用户(u)与总体平均评分的偏差

  • b[i]:表示项目(i)偏离平均评分的术语

这是期望评分的公式:

对于训练集中的每一对用户-项目,我们知道其实际评分(r[u,i]),现在我们要做的就是找出最优的b[u]b[i]值。我们的目标是最小化实际评分(r[u,i])与上述公式中期望评分(r[u,i])之间的差异。换句话说,我们需要一个求解器在给定训练数据时学习这些项的值。实际上,基准算法尝试最小化实际评分与期望评分之间的平均平方差。它还添加了一个正则化项,用来惩罚(b[u])和(b[i]),以避免过拟合。更多关于正则化概念的理解,请参见第三章,用线性方程做决策

学到的系数(b[u]b[i])是描述每个用户和每个项目的向量。在预测时,如果遇到新用户,b[u]将设置为0。类似地,如果遇到在训练集中未出现过的新项目,b[i]将设置为0

有两个求解器可用于解决此优化问题:随机梯度下降 (SGD) 和 交替最小二乘法 (ALS)。默认使用 ALS。每个求解器都有自己的设置,如最大迭代次数和学习率。此外,您还可以调整正则化参数。

这是使用其默认超参数的模型:

from surprise.prediction_algorithms.baseline_only import BaselineOnly
recsys = BaselineOnly(verbose=False)
predict_evaluate(recsys, dataset, 'BaselineOnly')

这次,我们得到了平均 MAE 为0.27,平均 RMSE 为0.37。同样,GridSearchCV可以用来调整模型的超参数。参数调优部分留给你去尝试。现在,我们进入第三个算法:奇异值分解 (SVD)。

使用奇异值分解

用户-项目评分矩阵通常是一个巨大的矩阵。我们从数据集中得到的矩阵包含 30,114 行和 19,228 列,其中大多数值(99.999%)都是零。这是预期中的情况。假设你拥有一个包含数千部电影的流媒体服务库。用户观看的电影数量很少,因此矩阵中的零值非常多。这种稀疏性带来了另一个问题。如果一个用户观看了电影宿醉:第一部,而另一个用户观看了宿醉:第二部,从矩阵的角度来看,他们看的是两部不同的电影。我们已经知道,协同过滤算法不会使用用户或项目的特征。因此,它无法意识到宿醉的两部作品属于同一系列,更不用说它们都是喜剧片了。为了解决这个问题,我们需要转换我们的用户-项目评分矩阵。我们希望新的矩阵,或者多个矩阵,能够更小并更好地捕捉用户和项目之间的相似性。

SVD是一种用于降维的矩阵分解算法,它与我们在第五章《使用最近邻的图像处理》中讨论的主成分分析PCA)非常相似。与 PCA 中的主成分不同,得到的奇异值捕捉了用户-项目评分矩阵中用户和项目的潜在信息。如果之前的句子不太清楚也不用担心。在接下来的章节中,我们将通过一个例子更好地理解这个算法。

通过 SVD 提取潜在信息

没有什么能比音乐更能体现品味了。让我们来看一下下面这个数据集。在这里,我们有六个用户,每个用户都投票选出了自己喜欢的音乐人:

music_ratings = [('U1', 'Metallica'), ('U1', 'Rammstein'), ('U2', 'Rammstein'), ('U3', 'Tiesto'), ('U3', 'Paul van Dyk'), ('U2', 'Metallica'), ('U4', 'Tiesto'), ('U4', 'Paul van Dyk'), ('U5', 'Metallica'), ('U5', 'Slipknot'), ('U6', 'Tiesto'), ('U6', 'Aly & Fila'), ('U3', 'Aly & Fila')]

我们可以将这些评分放入数据框,并使用数据框的pivot方法将其转换为用户-项目评分矩阵,具体方法如下:

df_music_ratings = pd.DataFrame(music_ratings, columns=['User', 'Artist'])
df_music_ratings['Rating'] = 1

df_music_ratings_pivoted = df_music_ratings.pivot(
    'User', 'Artist', 'Rating'
).fillna(0)

这是得到的矩阵。我使用了pandas样式,将不同的评分用不同的颜色表示,以便清晰区分:

很明显,用户 1、2 和 5 喜欢金属音乐,而用户 3、4 和 6 喜欢迷幻音乐。尽管用户 5 只与用户 1 和 2 共享一个乐队,但我们仍然可以看出这一点。也许我们之所以能够看到这一点,是因为我们了解这些音乐人,并且我们从整体的角度来看待矩阵,而不是专注于单独的用户对。我们可以使用 scikit-learn 的TruncatedSVD函数来降低矩阵的维度,并通过N个组件(单一向量)来表示每个用户和音乐人。以下代码片段计算了带有两个单一向量TruncatedSVD。然后,transform函数返回一个新的矩阵,其中每一行代表六个用户中的一个,每一列对应两个单一向量之一:

from sklearn.decomposition import TruncatedSVD
svd = TruncatedSVD(n_components=2)
svd.fit_transform(df_music_ratings_pivoted).round(2)

再次,我将结果矩阵放入数据框,并使用其样式根据值给单元格上色。以下是实现这一点的代码:

pd.DataFrame(
    svd.fit_transform(df_music_ratings_pivoted),
    index=df_music_ratings_pivoted.index,
    columns=['SV1', 'SV2'], 
).round(2).style.bar(
    subset=['SV1', 'SV2'], align='mid', color='#AAA'
)

这是结果数据框:

你可以将这两个组件视为一种音乐风格。很明显,较小的矩阵能够捕捉到用户在风格上的偏好。用户 1、2 和 5 现在更加接近彼此,用户 3、4 和 6 也是如此,他们彼此之间比原始矩阵中的更为接近。我们将在下一节中使用余弦相似度得分来更清楚地展示这一点。

这里使用的概念也适用于文本数据。诸如searchfindforage等词汇具有相似的意义。因此,TruncatedSVD变换器可以用来将向量空间模型VSM)压缩到一个较低的空间,然后再将其用于有监督或无监督的学习算法。在这种背景下,它被称为潜在语义分析LSA)。

*这种压缩不仅捕捉到了较大矩阵中不明显的潜在信息,还帮助了距离计算。我们已经知道,像 KNN 这样的算法在低维度下效果最好。不要仅仅相信我的话,在下一节中,我们将比较基于原始用户-物品评分矩阵与二维矩阵计算的余弦距离。

比较两个矩阵的相似度度量

我们可以计算所有用户之间的余弦相似度。我们将从原始的用户-物品评分矩阵开始。在计算用户 1、2、3 和 5 的配对余弦相似度后,我们将结果放入数据框并应用一些样式以便于查看:

from sklearn.metrics.pairwise import cosine_similarity

user_ids = ['U1', 'U2', 'U3', 'U5']

pd.DataFrame(
    cosine_similarity(
        df_music_ratings_pivoted.loc[user_ids, :].values
    ),
    index=user_ids,
    columns=user_ids
).round(2).style.bar(
    subset=user_ids, align='mid', color='#AAA'
)

以下是四个用户之间的配对相似度结果:

的确,用户 5 比用户 3 更像用户 1 和用户 2。然而,他们之间的相似度并没有我们预期的那么高。现在我们来通过使用TruncatedSVD来计算相同的相似度:

from sklearn.metrics.pairwise import cosine_similarity
from sklearn.decomposition import TruncatedSVD

user_ids = ['U1', 'U2', 'U3', 'U5']

svd = TruncatedSVD(n_components=2)
df_user_svd = pd.DataFrame(
    svd.fit_transform(df_music_ratings_pivoted),
    index=df_music_ratings_pivoted.index,
    columns=['SV1', 'SV2'], 
)

pd.DataFrame(
    cosine_similarity(
        df_user_svd.loc[user_ids, :].values
    ),
    index=user_ids,
    columns=user_ids
).round(2).style.bar(
    subset=user_ids, align='mid', color='#AAA'
)

新的计算方法这次捕捉了音乐家之间的潜在相似性,并在比较用户时考虑了这一点。以下是新的相似度矩阵:

显然,用户 5 比以前更像用户 1 和用户 2。忽略这里某些零前的负号,这是因为 Python 实现了IEEE电气和电子工程师协会)标准的浮点运算。

自然,我们也可以根据音乐家的风格(单一向量)来表示他们。这种矩阵可以通过svd.components_检索。然后,我们可以计算不同音乐家之间的相似度。这种转换也建议作为稀疏数据聚类的初步步骤。

现在,这个版本的SVD已经清晰了,在实践中,当处理大数据集时,通常会使用更具可扩展性的矩阵分解算法。概率矩阵分解 (**PMF)与观测数量成线性比例,并且在稀疏和不平衡数据集上表现良好。在下一节中,我们将使用 Surprise 的 PMF 实现。

使用 SVD 进行点击预测

我们现在可以使用 Surprise 的SVD算法来预测我们数据集中的点击。让我们从算法的默认参数开始,然后稍后再解释:

from surprise.prediction_algorithms.matrix_factorization import SVD
recsys = SVD()
predict_evaluate(recsys, dataset, 'SVD')

这次,我们得到的平均 MAE 为0.27,平均 RMSE 为0.37。这些结果与之前使用的基准算法类似。事实上,Surprise 的SVD实现是基准算法和SVD的结合。它使用以下公式表示用户-项目评分:

方程的前三项((b[u]b[i])与基准算法相同。第四项表示两个相似矩阵的乘积,这些矩阵与我们从TruncatedSVD得到的矩阵类似。q[i]矩阵将每个项目表示为多个单一向量。类似地,p[u]矩阵将每个用户表示为多个单一向量。项目矩阵被转置,因此上面有一个T字母。然后,算法使用SGD来最小化预期评分与实际评分之间的平方差。与基准模型类似,它还对预期评分的系数(b[u], b[i], q[i],p[u])进行正则化,以避免过拟合。

我们可以忽略方程的基准部分——即通过设置biased=False来移除前面三个系数((b[u]b[i])。使用的单一向量数量由n_factors超参数设置。我们还可以通过n_epochs控制SGD的迭代次数。此外,还有其他超参数用于设置算法的学习率、正则化以及系数的初始值。你可以使用surprise提供的参数调优助手来找到这些参数的最佳组合——即GridSearchCVRandomizedSearchCV

我们对推荐系统及其各种算法的讨论标志着本书中机器学习话题的结束。与这里讨论的其他所有算法一样,只有在将其投入生产环境并供他人使用时,它们才有意义。在下一节中,我们将看到如何部署一个训练好的算法并让其他人使用它。

在生产环境中部署机器学习模型

使用机器学习模型有两种主要模式:

  • 批量预测:在这种模式下,你在一段时间后加载一批数据记录——例如,每晚或每月一次。然后,你对这些数据进行预测。通常,在这里延迟不是问题,你可以将训练和预测代码放入单个批处理作业中。一个例外情况是,如果你需要过于频繁地运行作业,以至于每次作业运行时都没有足够的时间重新训练模型。那么,训练一次模型,将其存储在某处,并在每次进行新的批量预测时加载它是有意义的。

  • 在线 预测:在这个模型中,你的模型通常被部署在应用程序编程接口API)后面。每次调用 API 时,通常会传入一条数据记录,API 需要为这条记录做出预测并返回结果。这里低延迟是至关重要的,通常建议训练模型一次,将其存储在某处,并在每次新的 API 调用时使用预训练模型。

如你所见,在这两种情况下,我们可能需要将模型训练过程中使用的代码与预测时使用的代码分开。无论是监督学习算法还是无监督学习算法,除了编写代码的行数外,拟合的模型还依赖于从数据中学习到的系数和参数。因此,我们需要一种方法来将代码和学习到的参数作为一个单元存储。这个单元可以在训练后保存,并在预测时使用。为了能够将函数或对象存储在文件中或通过互联网共享,我们需要将它们转换为标准格式或协议。这个过程被称为序列化。pickle是 Python 中最常用的序列化协议之一。Python 标准库提供了序列化对象的工具;然而,joblib在处理 NumPy 数组时是一个更高效的选择。为了能够使用这个库,你需要通过pip在终端中运行以下命令来安装它:

          pip
          install
          joblib

安装完成后,你可以使用joblib将任何东西保存到磁盘文件中。例如,在拟合基线算法后,我们可以使用joblib函数的dump方法来存储拟合的对象。该方法除了模型的对象外,还需要提供一个保存对象的文件名。我们通常使用.pkl扩展名来表示pickle文件:

import joblib
from surprise.prediction_algorithms.baseline_only import BaselineOnly

recsys = BaselineOnly()
recsys.fit(trainset)
joblib.dump(recsys, 'recsys.pkl') 

一旦保存到磁盘,任何其他 Python 代码都可以再次加载相同的模型,并立即使用它,而无需重新拟合。在这里,我们加载已序列化的算法,并使用它对测试集进行预测:

from surprise import accuracy
recsys = joblib.load('recsys.pkl') 
predictions = recsys.test(testset)

这里使用了一个surprise估算器,因为这是我们在本章中一直使用的库。然而,任何 Python 对象都可以以相同的方式被序列化并加载。前几章中使用的任何估算器也可以以相同的方式使用。此外,你还可以编写自己的类,实例化它们,并序列化生成的对象。

若要将你的模型部署为 API,你可能需要使用如FlaskCherryPy之类的 Web 框架。开发 Web 应用超出了本书的范围,但一旦你学会如何构建它们,加载已保存的模型应该会很简单。建议在 Web 应用启动时加载已保存的对象。这样,如果每次收到新请求时都重新加载对象,你就不会引入额外的延迟。

摘要

本章标志着本书的结束。我希望到现在为止,这里讨论的所有概念都已经清晰明了。我也希望每个算法的理论背景与实际应用的结合,能够为你提供解决实际生活中不同问题的途径。显然,没有任何一本书能够得出最终结论,未来你会接触到新的算法和工具。不过,Pedro Domingos 将机器学习算法分为五个类别。除了进化算法外,我们已经学习了属于 Domingos 五个类别中的四个类别的算法。因此,我希望这里讨论的各种算法,每种都有其独特的方法,在未来处理任何新的机器学习问题时,能够为你提供坚实的基础。

所有的书籍都是一个不断完善的过程。它们的价值不仅体现在内容上,还包括它们激发的未来讨论所带来的价值。每次你分享基于书籍中获得的知识所构建的内容时,作者都会感到高兴。每次你引用他们的观点,分享更好、更清晰的解释方式,甚至纠正他们的错误时,他们也会同样高兴。我也期待着你为此做出的宝贵贡献。******

posted @ 2025-09-03 10:24  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报