原文:
annas-archive.org/md5/ec14cdde5f82b4b7e0113bdbb2bbe4c7
译者:飞龙
前言
你可能已经看到过 《哈佛商业评论》 将数据科学描述为 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 并注册,将文件直接通过邮件发送给你。
你可以按照以下步骤下载代码文件:
-
登录或注册 www.packt.com。
-
选择“支持”标签。
-
点击“代码下载”。
-
在搜索框中输入书名,并按照屏幕上的指示操作。
一旦文件下载完成,请确保使用最新版本的以下软件解压或提取文件:
-
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-encoding
和 imbalanced-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。每当你看到 np
、sp
、pd
、sns
和 plt
前缀时,你应该假设我在代码之前运行了以下导入语句:
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 的更多实际示例相结合。
本书的前两部分将涉及监督学习算法。第一部分将涵盖基础算法以及一些机器学习的基础知识,如数据拆分和预处理。然后,我们将进入第二部分,讨论更高级的话题。第三部分也是最后一部分,将涵盖无监督学习以及如异常检测和推荐引擎等主题。
为了确保本书是一本实用的指南,我确保在每一章中都提供了示例。我也不想将数据准备与模型创建分开。虽然像数据拆分、特征选择、数据缩放和模型评估这样的主题是必须了解的关键概念,但我们通常将它们作为整体解决方案的一部分来处理。我还认为这些概念最好在正确的上下文中理解。这就是为什么在每一章中,我会覆盖一个主要的算法,并通过一些示例来阐明其他相关概念。
这意味着,你可以决定是从头到尾阅读本书,还是将其作为参考书,在需要时直接跳到你想了解的算法。然而,我建议你浏览所有章节,即使你已经了解其中涵盖的算法,或者目前不需要了解它们。
我希望你现在已经准备好进入下一章,我们将从决策树开始,学习如何使用它们解决不同的分类和回归问题。
进一步阅读
如需了解本章相关的更多信息,请参考以下链接:
-
学习 Python 编程 – 第二版,作者 法布里齐奥·罗马诺:
www.packtpub.com/application-development/learn-python-programming-second-edition
-
动手实践 Pandas 数据分析,作者 斯特法妮·莫林:
www.packtpub.com/big-data-and-business-intelligence/hands-data-analysis-pandas
第二章:使用树做决策
在这一章,我们将从查看我们的第一个监督学习算法——决策树开始。决策树算法多功能且易于理解。它被广泛使用,并且是我们在本书后续将遇到的许多高级算法的构建模块。在这一章中,我们将学习如何训练一个决策树,并将其应用于分类或回归问题。我们还将了解它的学习过程的细节,以便知道如何设置不同的超参数。此外,我们将使用一个现实世界的数据集,将我们在这里学到的内容付诸实践。我们将首先获取并准备数据,并将我们的算法应用于数据。在此过程中,我们还将尝试理解一些关键的机器学习概念,如交叉验证和模型评估指标。在本章结束时,你将对以下主题有非常好的理解:
-
理解决策树
-
决策树是如何学习的?
-
获取更可靠的分数
-
调整超参数以提高准确性
-
可视化树的决策边界
-
构建决策树回归器
理解决策树
我选择从决策树开始这本书,因为我注意到大多数新手机器学习从业者在两个领域中有之前的经验——软件开发或统计与数学。决策树在概念上可以类似于软件开发人员习惯的一些概念,例如嵌套的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 个物种的开花植物属,具有显眼的花朵。然而,在我们的数据集中,只包含三种物种——Setosa、Versicolor和Virginica。数据集中的每个样本都有每株植物的萼片和花瓣的长度和宽度(特征),以及它是 Setosa、Versicolor 还是 Virginica(目标)。我们的任务是根据植物的萼片和花瓣尺寸来识别其物种。显然,这是一个分类问题。由于数据中提供了目标,因此这是一个监督学习问题。此外,这是一个分类问题,因为我们有有限的预定义值(三种物种)。
加载 Iris 数据集
现在让我们开始加载数据集:
- 我们从 scikit-learn 导入数据集模块,然后将 Iris 数据加载到一个变量中,我们也称之为
iris
:
from sklearn import datasets
import pandas as pd
iris = datasets.load_iris()
- 使用
dir
,我们可以查看数据集提供了哪些方法和属性:
dir(iris)
我们得到了一些方法的列表,包括DESCR
、data
、feature_names
、filename
、target
和target_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-Setosa
、Iris-Versicolor
和Iris-Virginica
。一些分类算法只能处理两个类别标签;我们称它们为二元分类器。幸运的是,决策树算法可以处理多于两个类别,所以这次我们没有问题。 -
数据是平衡的;每个类别有 50 个样本。这是我们在训练和评估模型时需要牢记的一点。
-
我们有四个特征 ——
sepal length
、sepal width
、petal length
和petal width
—— 所有四个特征都是数值型的。在 第三章,数据准备,我们将学习如何处理非数值型数据。 -
没有缺失的属性值。换句话说,我们的样本中没有空值。在本书的后续部分,如果遇到缺失值,我们将学习如何处理它们。
-
花瓣尺寸与类别值的相关性比萼片尺寸更高。我希望我们从未看到这条信息。了解数据是有用的,但问题在于这种相关性是针对整个数据集计算的。理想情况下,我们只会为我们的训练数据计算它。无论如何,现在让我们暂时忽略这些信息,稍后再用它进行健全性检查。
- 现在是时候将所有数据集信息放入一个 DataFrame 中了。
feature_names
方法返回我们特征的名称,而 data
方法以 NumPy 数组的形式返回它们的值。同样,target
变量以零、一和二的形式返回目标的值,而 target_names
则将 0
、1
和 2
映射到 Iris-Setosa
、Iris-Versicolor
和 Iris-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]])
以下代码使用 data
、feature_names
和 target
方法将所有数据集信息合并到一个 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 是最稳定的版本。
target
列现在包含类别 ID。然而,为了更清晰起见,我们还可以创建一个名为target_names
的新列,将我们的数值目标值映射到类别名称:
df['target_names'] = df['target'].apply(lambda y: iris.target_names[y])
- 最后,让我们打印六行样本来看看我们新创建的 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_size
为0.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.columns
和df_test.columns
列出新 DataFrame 的列名。它们都有相同的六列:
-
sepal length (cm)
-
sepal width (cm)
-
petal length (cm)
-
petal width (cm)
-
target
-
target_names
前四列是我们的特征,而第五列是我们的目标(或标签)。第六列目前不需要。直观地说,你可以说我们将数据在垂直方向上分成了训练集和测试集。通常,将我们的 DataFrame 在水平方向上进一步分成两部分是有意义的——一部分是特征,通常我们称之为x,另一部分是目标,通常称之为y。在本书的剩余部分,我们将继续使用这种x和y的命名约定。
有些人喜欢用大写的X来表示二维数组(或 DataFrame),而用小写字母y表示一维数组(或系列)。我发现坚持使用单一大小写更为实用。
如你所知,iris
中的feature_names
方法包含与我们的特征相对应的列名列表。我们将使用这些信息,以及target
标签,来创建我们的x和y集合,如下所示:
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()
训练的一个常用同义词是拟合。它是指算法如何利用训练数据(x和y)来学习其参数。所有的 scikit-learn 模型都实现了一个fit()
方法,它接收x_train
和y_train
,DecisionTreeClassifier
也不例外:
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
进行比较,以检查我们的预测效果如何。如果你记得上一章,评估分类器有多种指标,比如precision
、recall
和accuracy
。鸢尾花数据集是一个平衡数据集,每个类别的实例数相同。因此,在这里使用准确率作为评估指标是合适的。
计算准确率,结果如下,得分为0.91
:
from sklearn.metrics import accuracy_score
accuracy_score(y_test, y_test_pred)
你的得分与我的不同吗?别担心。在获取更可靠的得分部分,我将解释为什么这里计算的准确率分数可能会有所不同。
恭喜你!你刚刚训练了你的第一个监督学习算法。从现在开始,本书中所有我们将使用的算法都有类似的接口:
-
fit()
方法接收你的训练数据的x和y部分。 -
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
的样本归类为类别0
(Setosa
)。然后,对于花瓣宽度大于0.8
的样本,树继续分支,试图区分类别1
和2
(Versicolor
和Virginica
)。一般来说,类别之间分离越困难,分支就越深。
决策树是如何学习的?
是时候了解决策树是如何学习的,以便配置它们。在我们刚刚打印的内部结构中,树决定使用0.8
的花瓣宽度作为其初始分割决策。这是因为决策树试图使用以下技术构建尽可能小的树。
它遍历所有特征,试图找到一个特征(此处为花瓣宽度
)和该特征中的一个值(此处为0.8
),这样如果我们将所有训练数据分成两部分(一个部分是花瓣宽度 ≤ 0.8
,另一个部分是花瓣宽度 > 0.8
),我们就能得到最纯净的分割。换句话说,它试图找到一个条件,在这个条件下,我们可以尽可能地将类别分开。然后,对于每一边,它迭代地使用相同的技术进一步分割数据。
分割标准
如果我们只有两个类别,理想的分割应该将一个类别的成员放在一侧,另一个类别的成员放在另一侧。在我们的例子中,我们成功地将类别0
的成员放在一侧,将类别1
和2
的成员放在另一侧。显然,我们并不总是能得到如此纯净的分割。正如我们在树的其他分支中看到的那样,每一侧总是混合了类别1
和2
的样本。
话虽如此,我们需要一种衡量纯度的方法。我们需要一个标准来判断哪个分割比另一个更纯净。scikit-learn
为分类器纯度提供了两个标准——gini
和entropy
——其中gini
是默认选项。对于决策树回归,还有其他标准,我们稍后会接触到。
防止过拟合
“如果你追求完美,你将永远不会满足。”
– 列夫·托尔斯泰
在第一次分裂后,树继续尝试区分剩下的类别;即Versicolor
和Virginica
鸢尾花。然而,我们真的确定我们的训练数据足够详细,能够解释区分这两类的所有细微差别吗?难道所有这些分支不是在引导算法学习一些仅存在于训练数据中的特征,而当面对未来数据时,它们并不会很好地泛化吗?让树生长过多会导致所谓的过拟合。树会尽力完美拟合训练数据,却忽视了未来可能遇到的数据可能会有所不同。为了防止过拟合,可以使用以下设置来限制树的生长:
-
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
功能进一步简化之前的代码。这一次,我们甚至不需要自己将数据拆分为训练集和测试集。我们将 x
和 y
的值传递给 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()
来检查树的深度,一旦它被训练好。所以,尝试任何大于4
的max_depth
值是没有意义的。在这里,我们将循环遍历从1
到4
的最大深度,并使用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
来做出决策是不够的。允许树进一步生长会提高准确度,但我看不出深度为2
、3
和4
的树之间有太大的差异。我得出结论,与我之前的猜测相反,在这里我们不必过于担心过拟合问题。
在这里,我们尝试了不同的单一参数值,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
)。对于回归器来说,如果在训练时,一个叶节点有三个值12
、10
和8
,那么在预测时,如果一个实例落入该叶节点,回归器会预测它的值为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)
这里是每个群体的均值和中位数身高:
现在,我们想要通过一个特征——性别来预测人们的身高。因此,我们将数据分为训练集和测试集,并创建我们的x和y集,具体如下:
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']
请记住,在分类问题中,决策树使用gini
或entropy
来决定训练过程中每一步的最佳划分。这些标准的目标是找到一个划分,使得结果的两个子组尽可能纯净。在回归问题中,我们的目标不同。我们希望每个组的成员目标值尽可能接近它们所做出的预测值。scikit-learn 实现了两种标准来达到这个目标:
-
均方误差 (MSE 或 L2):假设划分后,我们得到三组样本,其目标值为
5
、5
和8
。我们计算这三个数字的均值(6
)。然后,我们计算每个样本与计算得到的均值之间的平方差——1
、1
和4
。接着,我们计算这些平方差的均值,即2
。 -
平均绝对误差 (MAE 或 L1):假设划分后,我们得到三组样本,其目标值为
5
、5
和8
。我们计算这三个数字的中位数(5
)。然后,我们计算每个样本与计算得到的中位数之间的绝对差值——0
、0
和3
。接着,我们计算这些绝对差值的均值,即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_depth
、min_samples_split
和min_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-pt2-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/c515257d-2dbc-4faa-b78a-7ec497bd5bb9.png>
我们知道,出租车计价器通常会从一定的起始费用开始,然后根据每公里的行驶距离收取固定费用。我们可以用以下方程来建模计价器:
<https://github.com/OpenDocCN/freelearn-ml-pt2-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-pt2-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-pt2-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/67a8b31e-d936-4d2a-8237-a3561d53129b.png>
在我们加入小费后,很明显,行驶距离与支付金额之间的关系不再是线性的。右侧的图表显示,无法通过一条直线来捕捉这种关系:
<https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/95527f92-ecc4-40f7-ab82-cdb9aae56698.png>
我们现在知道,之前的解方程方法在此时不再适用。然而,我们可以看出,仍然存在一条线,能够在某种程度上近似这个关系。在接下来的部分,我们将使用线性回归算法来找到这个近似值。
## 线性回归
算法的核心是目标。我们之前的目标是找到一条通过图中所有点的直线。我们已经看到,如果这些点之间不存在线性关系,那么这个目标是无法实现的。因此,我们将使用线性回归算法,因为它有不同的目标。线性回归算法试图找到一条线,使得估计点与实际点之间的平方误差的均值最小。从视觉上看,在下面的图中,我们希望找到一条虚线,使得所有垂直线的平方长度的平均值最小:
<https://github.com/OpenDocCN/freelearn-ml-pt2-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']]
。
- 我们将预测结果放在同一个数据框架中的
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()
我删去了代码中的格式部分,以保持简洁和直接。以下是最终结果:
- 一旦线性模型训练完成,您可以使用
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
对于回归问题,最重要的是理解目标变量的分布。如果目标变量的范围在1
到10
之间,而我们训练模型后得到的平均绝对误差为5
,那么在这个情况下,我们可以判断误差较大。
然而,对于一个目标值在500,000
到1,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()
这为我们提供了以下图表:
在前面的图表中,我们观察到:
-
价格范围在
5
到50
之间。显然,这些并非真实价格,可能是归一化后的值,但现在这并不重要。 -
此外,从直方图中我们可以看出,大多数价格都低于
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)
我们暂时会坚持默认配置。
评估模型的准确性
在回归中,有三种常用的指标:R²、MAE和MSE。首先让我们编写计算这三个指标并打印结果的代码:
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
到现在为止,你应该已经知道如何计算MAE和MSE了。只需要记住,MSE比MAE对异常值更敏感。这就是为什么基准的均值估算得分较差的原因。至于R²,让我们看一下它的公式:
下面是前面公式的解释:
-
分子可能让你想起了MSE。我们基本上计算所有预测值与对应实际值之间的平方差。
-
至于分母,我们使用实际值的均值作为伪估算值。
-
基本上,这个指标告诉我们,和使用目标均值作为估算值相比,我们的预测有多么准确。
-
1
的 R²分数是我们能得到的最佳结果,0
的分数意味着我们与一个仅依赖均值作为估计的有偏模型相比没有提供任何附加价值。 -
一个负分数意味着我们应该把模型扔进垃圾桶,改用目标的均值作为预测。
-
显然,在基线模型中,我们已经使用目标的均值作为预测。因此,它的 R²分数是
0
。
对于MAE和MSE,它们的值越小,模型就越好。相反,对于R²,它的值越高,模型就越好。在 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
。使用其默认配置时,它会将所有特征的值压缩到0
和1
之间。该缩放器需要先进行拟合,以了解特征的范围。拟合应该仅在训练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 是 x² 的函数,我们可以使用一个非线性模型——一个能够捕捉 x 和 y 之间二次关系的模型——或者我们可以直接计算 x² 并将其提供给线性模型,而不是 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 的列名。它将特征名替换为 x0
、x1
、x2
等。然而,凭借我们的 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)
最后,当我们打印R²、MAE和MSE结果时,迎来了一些不太愉快的惊讶:
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 个特征。这是另一个问题,称为“维度灾难”。
维度灾难是指当你的特征数远超过样本数时的问题。如果你把数据框想象成一个矩形,特征是矩形的底边,样本是矩形的高度,你总是希望矩形的高度远大于底边。假设有两列二进制特征——x1
和x2
。它们可以有四种可能的值组合——(0, 0)
、(0, 1)
、(1, 0)
和(1, 1)
。同样,对于n列,它们可以有2^n种组合。正如你所看到的,随着特征数的增加,可能性数量呈指数增长。为了使监督学习算法有效工作,它需要足够的样本来覆盖所有这些可能性中的合理数量。当我们有非二进制特征时(如本例所示),这个问题更为严重。
幸运的是,两个世纪的时间足够让人们找到这两个问题的解决方案。正则化就是我们在下一部分将要深入探讨的解决方案。
正则化回归器
“用更多做本可以用更少做的事是徒劳的。”
——奥卡姆的威廉
最初,我们的目标是最小化回归器的 MSE 值。后来我们发现,特征过多是一个问题。这就是为什么我们需要一个新的目标。我们仍然需要最小化回归器的 MSE 值,但同时我们还需要激励模型忽略无用的特征。这个目标的第二部分,就是正则化的作用。
常用于正则化线性回归的两种算法是Lasso和Ridge。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.6
,MSE为16.2
,相比之前的25.8
,R²为0.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.4
的alpha
值。
此外,我们还可以看到,对于每个 alpha 值,五个折叠中的MSE值是多少。我们可以通过mse_path_
访问这些信息。
由于每个 alpha 值对应五个MSE值,我们可以绘制这五个值的平均值,并绘制围绕平均值的置信区间。
置信区间用于展示观察数据可能取值的预期范围。95%的置信区间意味着我们期望 95%的值落在这个范围内。较宽的置信区间意味着数据可能取值的范围较大,而较窄的置信区间则意味着我们几乎可以准确地预测数据会取什么值。
95%的置信区间计算如下:
这里,标准误差等于标准差除以样本数量的平方根(![],因为我们这里有五个折数)。
这里的置信区间公式并不是 100%准确。从统计学角度来看,当处理小样本且其基本方差未知时,应该使用 t 分布而非 z 分布。因此,鉴于这里的折数较小,1.96 的系数应当用 t 分布表中更准确的值来替代,其中自由度由折数推断得出。
以下代码片段计算并绘制了 MSE 与 alpha 的置信区间:
- 我们首先计算返回的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)
- 然后,我们将计算结果放入数据框中,并使用默认的折线图进行绘制:
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) | |
---|---|---|---|
R² | 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) 的标准差。
为了演示这一点,我们来创建一个带噪声的数据集,其中 https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/e6f30f23-e2ed-4ea5-827e-21067cb1c1d8.png:
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'
)
绘制结果数据框将给我们以下图形:
现在,让我们在相同的数据上训练两个回归模型——LinearRegression
和 BayesianRidge
。这里我将坚持使用默认的贝叶斯岭回归超参数值:
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形)函数的一种,它的表达式如下:
别让这个方程吓到你。真正重要的是这个函数的视觉效果。幸运的是,我们可以用计算机生成一系列θ的值——比如在-10
到10
之间。然后,我们可以将这些值代入公式,并绘制出对应的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 仅在
0
和1
之间变化。当θ趋近于正无穷时,y趋近于1
;当θ趋近于负无穷时,y趋近于0
。 -
当θ为
0
时,y
的值为0.5
。
将逻辑函数代入线性模型
“概率不仅仅是对骰子上的赔率或更复杂的变种进行计算;它是接受我们知识中不确定性的存在,并发展出应对我们无知的方法。”
– 纳西姆·尼古拉斯·塔勒布
对于一个包含两个特征的线性模型,x[1] 和 x[2],我们可以有一个截距和两个系数。我们将它们称为https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/7b829d0f-4b60-4173-90b1-ca98ebe6e69d.png、https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/16a7a93c-f405-4f3e-abff-a2a80688aaf3.png 和 https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/a914785a-b7f0-4197-8e3d-62e47bac1a5c.png。那么,线性回归方程将如下所示:
另外,我们也可以将前面方程右侧的部分代入逻辑函数,替代https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/e4173069-bef8-4e29-8c88-10cfc96da625.png。这将得到以下的y方程:
在这种情况下,x值的变化将使得y在0
和1
之间波动。x与其系数的乘积的较高值会使得y接近1
,较低值会使其接近0
。我们也知道,概率的值介于0
和1
之间。因此,将y解释为给定x的情况下,y属于某一类的概率是有意义的。如果我们不想处理概率,我们可以直接指定 https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/d4188933-f93c-4834-9b32-7a83a1377fcd.png;那么,我们的样本就属于类别 1,否则属于类别 0。
这是对逻辑回归工作原理的简要介绍。它是一个分类器,但被称为回归,因为它基本上是一个回归器,返回一个介于0
和1
之间的值,我们将其解释为概率。
要训练逻辑回归模型,我们需要一个目标函数,以及一个求解器,用来寻找最优的系数以最小化这个函数。在接下来的章节中,我们将更详细地讲解这些内容。
目标函数
在训练阶段,算法会遍历数据,尝试找到能够最小化预定义目标(损失)函数的系数。在逻辑回归的情况下,我们尝试最小化的损失函数被称为对数损失。它通过以下公式来衡量预测概率(p)与实际类别标签(y)之间的差距:
-log§ 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_iter
和tol
设置为较高值时,它还可以减轻过拟合的效果。 -
C
或alpha
:这些是用于设置正则化强度的参数。由于我们在此使用两种不同的逻辑回归算法实现,因此需要了解这两种实现使用了不同的参数(C
与alpha
)。alpha
基本上是C
的倒数—(https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/c4f16ce4-15f1-4eed-b539-3895626e3ad4.png)。这意味着,较小的C
值表示较强的正则化,而对于alpha
,则需要较大的值来表示较强的正则化。 -
l1_ratio
:当使用 L1 和 L2 的混合时,例如弹性网(elastic-net),此值指定 L1 与 L2 的权重比例。
以下是我们可以使用的一些求解器:
liblinear
:该求解器在LogisticRegression
中实现,推荐用于较小的数据集。它支持 L1 和 L2 正则化,但如果想使用弹性网,或者如果不想使用正则化,则无法使用此求解器。
*** sag
或saga
:这些求解器在LogisticRegression
和RidgeClassifier
中实现,对于较大的数据集,它们运行更快。然而,你需要对特征进行缩放,才能使其收敛。我们在本章早些时候使用了MinMaxScaler
来缩放特征。现在,这不仅仅是为了更有意义的系数,也是为了让求解器更早地找到解决方案。saga
支持四种惩罚选项。* lbfgs
:此求解器在LogisticRegression
中实现。它支持 L2 惩罚或根本不使用正则化。**
***** 随机梯度下降(SGD):SGD 有专门的实现——SGDClassifier
和SGDRegressor
。这与LogisticRegression
不同,后者的重点是通过优化单一的损失函数——对数损失来进行逻辑回归。SGDClassifier
的重点是 SGD 求解器本身,这意味着相同的分类器可以使用不同的损失函数。如果将loss
设置为log
,那么它就是一个逻辑回归模型。然而,将loss
设置为hinge
或perceptron
,则分别变成支持向量机(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.95
和1.00
之间。运行相同的代码进行决策树分类器训练时,得到的置信区间在0.93
和0.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 系数的正号。
-
这里使用线性模型意味着类别边界不会像决策树那样局限于水平和垂直线,而是会呈现线性形态。
为了更好地理解这一点,在下一部分中,我们将绘制逻辑回归分类器的决策边界,并将其与决策树的边界进行比较。
理解分类器的决策边界
通过可视化决策边界,我们可以理解模型为什么做出某些决策。以下是绘制这些边界的步骤:
- 我们首先创建一个函数,该函数接受分类器的对象和数据样本,然后为特定的分类器和数据绘制决策边界:
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])
- 然后,我们将数据分为训练集和测试集:
from sklearn.model_selection import train_test_split
df_train, df_test = train_test_split(df, test_size=0.3, random_state=22)
- 为了方便可视化,我们将使用两个特征。在下面的代码中,我们将训练一个逻辑回归模型和一个决策树模型,然后在相同数据上训练后比较它们的决策边界:
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
、体重在10
到90
左右的值。这显然让我们的回归器感到困惑,正如我们在右侧图表中所看到的。
非线性回归器(例如决策树)将能够比其线性对应更好地处理这个问题。实际上,对于基于树的模型,我建议您尝试将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.OneHotEncoder
和 preprocessing.OrdinalEncoder
。不过,我更喜欢 category_encoders
的实现,因为它更丰富、更灵活。
现在,让我们进入第一个也是最流行的编码策略——独热编码(one-hot encoding)。
独热编码
一热编码,也叫虚拟编码,是处理类别特征的最常见方法。如果你有一列包含red
、green
和blue
值的数据,那么将它们转换为三列——is_red
、is_green
和is_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
参数告诉编码器如何处理测试集中在训练集中不存在的值。例如,我们的训练集中没有XS
或S
尺码的衣物,也没有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
编码为1
,S
编码为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 标准化器
这会将特征压缩到一个特定的范围,通常是在0
到1
之间。如果你需要使用不同的范围,可以通过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
特征。然而,让我列出这个模块的主要优缺点,帮助你决定何时以及如何使用它:
-
与我们接下来会看到的其他特征选择方法不同,这个方法在选择特征时不使用数据标签。在处理无标签数据时,特别是在无监督学习场景中,这非常有用。
-
它不依赖标签的特性也意味着,一个低方差的特征可能仍然与我们的标签高度相关,去除它可能会是一个错误。
-
方差和均值一样,依赖于数据的尺度。一个从
1
到10
的数字列表的方差为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 中的实现使用皮尔逊相关系数来计算 x 和 y 中每列之间的相关性。结果会转换为 F 值和 P 值,但我们先不谈这个转换,因为相关系数才是关键。我们首先从每列的所有值中减去均值,这与我们在 StandardScaler
中所做的类似,但不需要将值除以标准差。接着,我们使用以下公式计算相关系数:
由于已减去均值,当一个实例高于其列的均值时,x 和 y 的值为正,而当其低于均值时,则为负。因此,这个方程会被最大化,使得每当 x 高于平均值时,y 也高于平均值,而每当 x 低于平均值时,y 也随之下降。这个方程的最大值是1
。因此,我们可以说 x 和 y 完全相关。当 x 和 y 顽固地朝相反方向变化时,即呈负相关,方程值为-1
。零结果意味着 x 和 y 不相关(即独立或正交)。
通常,统计学家会以不同的方式书写这个方程。通常会将* 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)
让我们暂时将结果中的f和p值放到一边。在解释特征选择的互信息方法之后,我们将使用这些值来对比这两种方法。
互信息
这种方法起源于一个不同的科学领域,叫做信息理论。该领域由克劳德·香农(Claude Shannon)提出,旨在解决与信号处理和数据压缩相关的问题。当我们发送一个由零和一组成的消息时,我们可能知道这个消息的确切内容,但我们能否真正量化这个消息所携带的信息量呢?香农通过借用热力学中的熵概念来解决这个问题。进一步发展出来的是互信息的概念。它量化了通过观察一个变量时,获得关于另一个变量的信息量。互信息的公式如下:
在解析这个方程之前,请记住以下几点:
-
P(x) 是 x 取某个特定值的概率,P(y) 也是 y 取某个特定值的概率。
-
P(x, y) 被称为联合概率,它表示 x 和 y 同时取特定一对值的概率。
-
P(x, y) 只有在 x 和 y 独立时才等于 P(x) * P(y)。否则,根据 x 和 y 的正相关或负相关关系,它的值会大于或小于它们的乘积。
双重求和和方程的第一部分,P(x, y),是我们计算所有可能的 x 和 y 值的加权平均值的方法。我们关心的是对数部分,它被称为点对点互信息。如果 x 和 y 是独立的,那么这个分数等于 1
,它的对数为 0
。换句话说,当这两个变量不相关时,结果为 0
。否则,结果的符号指示 x 和 y 是正相关还是负相关。
下面是我们如何计算每个特征的互信息系数:
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。****