机器学习的数据清理和探索-全-
机器学习的数据清理和探索(全)
原文:
annas-archive.org/md5/25ad0fee8d118820a9d79ad1484952bd译者:飞龙
前言
研究人员为数据分析准备数据的工作——包括提取、转换、清洗和探索——在机器学习工具日益普及的情况下,其本质并没有发生根本变化。30 年前,当我们为多元分析准备数据时,我们同样关注缺失值、异常值、变量分布的形状以及变量之间的相关性,就像我们现在使用机器学习算法时一样。虽然确实,广泛使用相同的机器学习库(如 scikit-learn、TensorFlow、PyTorch 等)确实鼓励了方法上的更大一致性,但良好的数据清洗和探索实践在很大程度上并未改变。
我们谈论机器学习的方式仍然非常侧重于算法;只需选择正确的模型,随之而来的就是组织变革的洞见。但我们必须为过去几十年中我们一直在进行的数据学习留出空间,其中我们从数据中做出的预测、在数据中建模关系以及我们对数据的清洗和探索都是对话的一部分。确保我们的模型正确与从直方图或混淆矩阵中获取尽可能多的信息一样重要,需要仔细调整超参数。
同样,数据分析师和科学家的工作并不从清洗、探索、预处理、建模到评估这一过程有序推进。我们在过程的每一步都怀有潜在模型的想法,并定期更新我们之前的模型。例如,我们最初可能认为我们将使用逻辑回归来建模特定的二元目标,但当我们看到特征的分布时,我们可能至少需要尝试使用随机森林分类。我们将在整篇文章中讨论建模的影响,即使在解释相对常规的数据清洗任务时也是如此。我们还将探讨在早期过程中使用机器学习工具,以帮助我们识别异常、插补值和选择特征。
这指向了数据分析师和科学家在过去十年中工作流程的另一个变化——对“单一模型”的重视减少,以及对模型构建作为迭代过程的接受度提高。一个项目可能需要多个机器学习算法——例如,主成分分析来降低维度(特征的数量),然后是逻辑回归进行分类。
话虽如此,我们在数据清洗、探索和建模方面的方法有一个关键的区别——随着机器学习工具在我们的工作中扮演越来越重要的角色,我们对预测的重视超过了对底层数据的理解。我们更关心我们的特征(也称为自变量、输入或预测因子)如何预测我们的目标(因变量、输出、响应),而不是特征之间的关系以及我们数据的底层结构。我在本书的前两章中指出了这一点如何改变我们的关注点,即使我们在清洗和探索数据时也是如此。
本书面向的对象
在撰写这本书时,我考虑了多个受众,但我最经常想到的是我的一个好朋友,30 年前她买了一本 Transact-SQL 书,立刻对自己的数据库工作有了极大的信心,最终围绕这些技能建立起了自己的职业生涯。如果有人刚开始作为数据科学家或分析师的职业生涯,通过这本书并获得了与我朋友相似的经历,我将感到非常高兴。最重要的是,我希望你通过阅读这本书后感到满意和兴奋,对你可以做到的事情感到自豪。
我还希望这本书对那些已经从事这类工作一段时间的人来说是一本有用的参考书。在这里,我想象有人打开这本书,自己问自己,在我的逻辑回归模型网格搜索中,应该使用哪些好的值?
为了保持本书的实践性质,书中的每一部分输出都可以通过本书中的代码进行重现。我始终坚持一个规则,即使有时会遇到挑战。除了概念性章节外,每个章节都是从原始下载文件中基本未变的数据开始的。你将在每个章节中从数据文件到模型进行转换。如果你忘记了某个特定对象是如何创建的,你只需要翻回一页或两页就能看到。
对于那些对 pandas 和 NumPy 有一定了解的读者来说,在处理一些代码块时会更加得心应手,同样,对 Python 和 scikit-learn 有一定了解的人也是如此。尽管如此,这些都不是必需的。有些部分你可能需要花更多的时间去仔细阅读。如果你需要关于使用 Python 进行数据工作的额外指导,我认为我的 Python 数据清洗食谱 是一本很好的配套书籍。
本书涵盖的内容
第一章,检查特征和目标的分布,探讨了使用常见的 NumPy 和 pandas 技术来更好地了解数据的属性。我们将生成汇总统计量,如均值、最小值和最大值,以及标准差,并计算缺失值的数量。我们还将创建关键特征的可视化,包括直方图和箱线图,以比仅查看汇总统计量更好地了解每个特征的分布。我们将暗示特征分布对数据转换、编码和缩放以及我们在后续章节中用相同数据进行建模的影响。
第二章,检查特征和目标之间的双变量和多变量关系,专注于可能特征和目标变量之间的相关性。我们将使用 pandas 方法进行双变量分析,并使用 Matplotlib 进行可视化。我们将讨论我们发现的特征工程和建模的影响。我们还在本章中使用多元技术来理解特征之间的关系。
第三章,识别和修复缺失值,将介绍识别每个特征或目标缺失值的技术,以及识别大量特征值缺失的观测值。我们将探讨填充值的方法,例如将值设置为整体均值、给定类别的均值或前向填充。我们还将检查用于填充缺失值的多元技术,并讨论它们何时适用。
第四章,编码、转换和缩放特征,涵盖了各种特征工程技术。我们将使用工具删除冗余或高度相关的特征。我们将探索最常见的编码类型——独热编码、有序编码和哈希编码。我们还将使用转换来改善特征的分布。最后,我们将使用常见的分箱和缩放方法来解决偏斜、峰度和异常值,以及调整范围差异较大的特征。
第五章,特征选择将介绍多种特征选择方法,从过滤器到包装器,再到嵌入式方法。我们将探讨它们如何与分类和连续目标一起工作。对于包装器和嵌入式方法,我们将考虑它们与不同算法结合时的效果。
第六章,为模型评估做准备,将展示我们构建第一个完整的流水线,将数据分为测试集和训练集,并学习如何在没有数据泄露的情况下进行预处理。我们将实现 k 折交叉验证,并更深入地研究评估模型性能的方法。
第七章,线性回归模型,是关于使用许多数据科学家喜爱的老方法——线性回归来构建回归模型的几个章节中的第一个。我们将运行一个经典的线性模型,同时考察使特征空间成为线性模型良好候选者的特性。我们将探讨在必要时如何通过正则化和变换来改进线性模型。我们将研究随机梯度下降作为普通最小二乘法(OLS)优化的替代方案。我们还将学习如何使用网格搜索进行超参数调整。
第八章,支持向量回归,讨论了关键的支持向量机概念以及它们如何应用于回归问题。特别是,我们将考察诸如 epsilon 敏感管和软边界等概念如何为我们提供灵活性,以在数据和领域相关挑战下获得最佳拟合。我们还将首次但肯定不是最后一次探索非常实用的核技巧,它允许我们在不进行变换或增加特征数量的情况下建模非线性关系。
第九章,K 近邻、决策树、随机森林和梯度提升回归,探讨了最流行的非参数回归算法中的一些。我们将讨论每个算法的优点,何时可能想要选择一个而不是另一个,以及可能的建模挑战。这些挑战包括如何通过仔细调整超参数来避免欠拟合和过拟合。
第十章,逻辑回归,是关于使用逻辑回归构建分类模型的几个章节中的第一个,逻辑回归是一种高效且偏差低的算法。我们将仔细检查逻辑回归的假设,并讨论数据集和建模问题中使逻辑回归成为良好选择的属性。我们将使用正则化来解决高方差问题或当我们有许多高度相关的预测因子时。我们将通过多项式逻辑回归将算法扩展到多类问题。我们还将讨论如何首次但肯定不是最后一次处理类别不平衡问题。
第十一章, 决策树和随机森林分类,回到在第九章中介绍的决策树和随机森林算法,这次处理分类问题。这为我们提供了另一个学习如何构建和解释决策树的机会。我们将调整包括树深度在内的关键超参数,以避免过拟合。然后我们将探索随机森林和梯度提升决策树作为决策树的优秀、低方差替代方案。
第十二章, 用于分类的 K 近邻,回到k 近邻(KNNs)来处理二元和多类建模问题。我们将讨论并展示 KNN 的优势——构建无装饰模型的简便性以及需要调整的超参数数量有限。到本章结束时,我们将了解两个问题——如何进行 KNN 以及何时应该考虑它来进行我们的建模。
第十三章, 支持向量机分类,探讨了实现支持向量分类(SVC)的不同策略。我们将使用线性 SVC,当我们的类别是线性可分时,它可以表现得非常好。然后我们将考察如何使用核技巧将 SVC 扩展到类别不是线性可分的情况。最后,我们将使用一对一和一对多分类来处理具有两个以上值的标签。
第十四章, 朴素贝叶斯分类,本章讨论了朴素贝叶斯的基本假设以及该算法如何被用来解决我们已探讨的一些建模挑战,以及一些新的挑战,例如文本分类。我们将考虑何时朴素贝叶斯是一个好的选择,何时则不是。我们还将探讨朴素贝叶斯模型的解释。
第十五章, 主成分分析,考察主成分分析(PCA),包括其工作原理以及我们可能想要使用它的时机。我们将学习如何解释 PCA 创建的成分,包括每个特征如何贡献到每个成分以及解释了多少方差。我们将学习如何可视化成分以及如何在后续分析中使用成分。我们还将考察如何使用核函数进行 PCA 以及何时这可能给我们带来更好的结果。
第十六章,K-Means 和 DBSCAN 聚类,探讨了两种流行的聚类技术,k-means 和基于密度的空间聚类应用噪声(DBSCAN)。我们将讨论每种方法的优点,并培养在何时选择一种聚类算法而不是另一种算法的感觉。我们还将学习如何评估我们的聚类以及如何更改超参数以改进我们的模型。
要充分利用本书
要运行本书中的代码,您需要安装一个科学版的 Python,例如 Anaconda。所有代码都使用 scikit-learn 版本 0.24.2 和 1.0.2 进行了测试。
下载示例代码文件
您可以从 GitHub(github.com/PacktPublishing/Data-Cleaning-and-Exploration-with-Machine-Learning)下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图和图表的彩色图像的 PDF 文件。您可以从这里下载:packt.link/aLE6J。
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“为了学习目的,我们在 GitHub 的chapter08文件夹下提供了两个示例mlruns工件和huggingface缓存文件夹。”
代码块设置如下:
client = boto3.client('sagemaker-runtime') 
response = client.invoke_endpoint(
        EndpointName=app_name, 
        ContentType=content_type,
        Accept=accept,
        Body=payload
        )
当我们希望将您的注意力引到代码块的一个特定部分时,相关的行或项目将以粗体显示:
loaded_model = mlflow.pyfunc.spark_udf(
    spark,
    model_uri=logged_model, 
    result_type=StringType())
任何命令行输入或输出都如下所示:
mlflow models serve -m models:/inference_pipeline_model/6
粗体:表示新术语、重要词汇或屏幕上看到的词汇。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“要执行此单元格中的代码,您只需在右上角的下拉菜单中点击运行单元格。”
小贴士或重要提示
看起来是这样的。
联系我们
欢迎读者反馈。
customercare@packtpub.com并在邮件主题中提及书名。
勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata并填写表格。
copyright@packt.com并附上材料链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了《使用机器学习进行数据清洗和探索》,我们非常想听听您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区都非常重要,并将帮助我们确保我们提供高质量的内容。
第一部分 – 数据清洗和机器学习算法
我尽量避免按顺序思考模型构建过程的各个部分,把自己看作是清理数据,然后是预处理,等等,直到我完成模型验证。我不想把那个过程看作是包含永远结束的阶段。我们在这个节开始时从数据清洗开始,但我希望本节中的章节传达出我们总是在向前看,在清理数据时预测建模挑战;同时,在我们评估模型时,我们通常也会回顾我们所做的数据清洗。
在一定程度上,干净和脏的隐喻隐藏了为后续分析准备数据时的微妙之处。真正的关注点是我们的实例和属性(观察和变量)对感兴趣现象的代表性如何。这总是可以改进的,而且如果不加注意,很容易变得更糟。尽管如此,有一点是可以确定的。在模型构建过程的任何其他部分,我们都无法纠正数据清洗过程中犯下的重要错误。
本书的前三章是关于尽可能准确地获取我们的数据。要做到这一点,我们必须对所有的变量、特征和目标是如何分布的有一个良好的理解。在我们进行任何正式分析之前,我们应该问自己三个问题:1) 我们是否确信我们知道每个感兴趣变量的全部值域和分布形状?2) 我们是否对变量之间的双变量关系有一个良好的了解,即每个变量是如何与其他变量一起变化的?3) 我们尝试解决潜在问题(如异常值和缺失值)的成功率如何?本节中的章节提供了回答这些问题的工具。
本节包括以下章节:
- 
第一章,检查特征和目标的分布 
- 
第二章,检查特征和目标之间的双变量和多变量关系 
- 
第三章,识别和修复缺失值 
第一章:第一章:检查特征和目标的分布
机器学习写作和指导通常是算法导向的。有时,这给人一种印象,我们只需要选择正确的模型,组织变革的见解就会随之而来。但开始机器学习项目的最佳地方是对我们将使用的特征和目标分布的理解。
对于我们作为分析师几十年来一直重视的数据学习,留出空间是非常重要的——研究变量的分布、识别异常值以及检查双变量关系——即使我们越来越关注我们预测的准确性。
我们将在本书的前三章中探索用于此目的的工具,同时考虑对模型构建的影响。
在本章中,我们将使用常见的 NumPy 和 pandas 技术来更好地了解我们数据的属性。在我们进行任何预测分析之前,我们想知道关键特征的分布情况。我们还想知道每个连续特征的集中趋势、形状和分布范围,以及分类特征的每个值的计数。我们将利用非常方便的 NumPy 和 pandas 工具来生成汇总统计信息,例如均值、最小值和最大值,以及标准差。
之后,我们将创建关键特征的可视化,包括直方图和箱线图,以帮助我们更好地了解每个特征的分布,而不仅仅是通过查看汇总统计信息。我们将暗示特征分布对数据转换、编码和缩放以及我们在后续章节中用相同数据进行建模的影响。
具体来说,在本章中,我们将涵盖以下主题:
- 
数据子集 
- 
为分类特征生成频率 
- 
为连续特征生成汇总统计信息 
- 
在单变量分析中识别极端值和异常值 
- 
使用直方图、箱线图和小提琴图来检查连续特征的分布 
技术要求
本章将大量依赖 pandas、NumPy 和 Matplotlib 库,但你不需要对这些库有任何先前的知识。如果你从科学发行版,如 Anaconda 或 WinPython 安装了 Python,那么这些库可能已经安装好了。如果你需要安装其中之一来运行本章中的代码,你可以在终端中运行pip install [package name]。
数据子集
几乎我参与的每一个统计建模项目都需要从分析中移除一些数据。这通常是因为缺失值或异常值。有时,我们限制分析数据集的理论原因。例如,我们拥有从 1600 年以来的天气数据,但我们的分析目标仅涉及 1900 年以来的天气变化。幸运的是,pandas 中的子集工具非常强大且灵活。在本节中,我们将使用美国国家青年纵向调查(NLS)的数据。
注意
青年 NLS 是由美国劳工统计局进行的。这项调查始于 1997 年,当时出生在 1980 年至 1985 年之间的一批个人,每年通过 2017 年进行年度跟踪。对于这个配方,我从调查的数百个数据项中提取了关于成绩、就业、收入和对政府态度的 89 个变量。可以从存储库下载 SPSS、Stata 和 SAS 的单独文件。NLS 数据可在www.nlsinfo.org/investigator/pages/search公开使用。
让我们使用 pandas 开始子集数据:
- 
我们将首先加载 NLS 数据。我们还设置了一个索引: import pandas as pd import numpy as np nls97 = pd.read_csv("data/nls97.csv") nls97.set_index("personid", inplace=True)
- 
让我们从 NLS 数据中选择几个列。以下代码创建了一个新的 DataFrame,其中包含一些人口统计和就业数据。pandas 的一个有用特性是,新的 DataFrame 保留了旧 DataFrame 的索引,如下所示: democols = ['gender','birthyear','maritalstatus', 'weeksworked16','wageincome','highestdegree'] nls97demo = nls97[democols] nls97demo.index.name 'personid'
- 
我们可以使用切片通过位置选择行。 nls97demo[1000:1004]选择从冒号左侧的整数(在这种情况下是1000)指示的行开始,直到但不包括冒号右侧的整数(在这种情况下是1004)指示的行。1000行是第 1,001 行,因为索引是从 0 开始的。由于我们将结果 DataFrame 进行了转置,所以每一行都作为输出中的一个列出现:nls97demo[1000:1004].T personid 195884 195891 195970\ gender Male Male Female birthyear 1981 1980 1982 maritalstatus NaN Never-married Never-married weeksworked16 NaN 53 53 wageincome NaN 14,000 52,000 highestdegree 4.Bachelors 2.High School 4.Bachelors personid 195996 gender Female birthyear 1980 maritalstatus NaN weeksworked16 NaN wageincome NaN highestdegree 3.Associates
- 
我们还可以通过在第二个冒号之后设置步长值来跳过区间内的行。步长的默认值是 1。以下步长的值是 2,这意味着在 1000和1004之间的每隔一行将被选中:nls97demo[1000:1004:2].T personid 195884 195970 gender Male Female birthyear 1981 1982 maritalstatus NaN Never-married weeksworked16 NaN 53 wageincome NaN 52,000 highestdegree 4.Bachelors 4\. Bachelors
- 
如果我们在冒号左侧不包含值,行选择将从第一行开始。请注意,这返回的 DataFrame 与 head方法返回的相同:nls97demo[:3].T personid 100061 100139 100284 gender Female Male Male birthyear 1980 1983 1984 maritalstatus Married Married Never-married weeksworked16 48 53 47 wageincome 12,500 120,000 58,000 highestdegree 2.High School 2\. High School 0.None nls97demo.head(3).T personid 100061 100139 100284 gender Female Male Male birthyear 1980 1983 1984 maritalstatus Married Married Never-married weeksworked16 48 53 47 wageincome 12,500 120,000 58,000 highestdegree 2.High School 2.High School 0\. None
- 
如果我们在冒号左侧使用负数-n,将返回 DataFrame 的最后 n 行。这返回的 DataFrame 与 tail方法返回的相同:nls97demo[-3:].T personid 999543 999698 999963 gender Female Female Female birthyear 1984 1983 1982 maritalstatus Divorced Never-married Married weeksworked16 0 0 53 wageincome NaN NaN 50,000 highestdegree 2.High School 2.High School 4\. Bachelors nls97demo.tail(3).T personid 999543 999698 999963 gender Female Female Female birthyear 1984 1983 1982 maritalstatus Divorced Never-married Married weeksworked16 0 0 53 wageincome NaN NaN 50,000 highestdegree 2.High School 2.High School 4\. Bachelors
- 
我们可以使用 loc访问器通过索引值选择行。回想一下,对于nls97demoDataFrame,索引是personid。我们可以向loc访问器传递一个索引标签列表,例如loc[[195884,195891,195970]],以获取与这些标签相关的行。我们还可以传递索引标签的下限和上限,例如loc[195884:195970],以检索指定的行:nls97demo.loc[[195884,195891,195970]].T personid 195884 195891 195970 gender Male Male Female birthyear 1981 1980 1982 maritalstatus NaN Never-married Never-married weeksworked16 NaN 53 53 wageincome NaN 14,000 52,000 highestdegree 4.Bachelors 2.High School 4.Bachelors nls97demo.loc[195884:195970].T personid 195884 195891 195970 gender Male Male Female birthyear 1981 1980 1982 maritalstatus NaN Never-married Never-married weeksworked16 NaN 53 53 wageincome NaN 14,000 52,000 highestdegree 4.Bachelors 2.High School 4.Bachelors
- 
要按位置选择行,而不是按索引标签,我们可以使用 iloc访问器。我们可以传递一个位置数字列表,例如iloc[[0,1,2]],到访问器以获取那些位置的行。我们可以传递一个范围,例如iloc[0:3],以获取介于下限和上限之间的行,不包括上限所在的行。我们还可以使用iloc访问器来选择最后 n 行。iloc[-3:]选择最后三行:nls97demo.iloc[[0,1,2]].T personid 100061 100139 100284 gender Female Male Male birthyear 1980 1983 1984 maritalstatus Married Married Never-married weeksworked16 48 53 47 wageincome 12,500 120,000 58,000 highestdegree 2.High School 2.High School 0\. None nls97demo.iloc[0:3].T personid 100061 100139 100284 gender Female Male Male birthyear 1980 1983 1984 maritalstatus Married Married Never-married weeksworked16 48 53 47 wageincome 12,500 120,000 58,000 highestdegree 2.High School 2.High School 0\. None nls97demo.iloc[-3:].T personid 999543 999698 999963 gender Female Female Female birthyear 1984 1983 1982 maritalstatus Divorced Never-married Married weeksworked16 0 0 53 wageincome NaN NaN 50,000 highestdegree 2.High School 2.High School 4\. Bachelors
通常,我们需要根据列值或几个列的值来选择行。在 pandas 中,我们可以通过布尔索引来完成此操作。这里,我们向loc访问器或方括号运算符传递布尔值向量(可以是序列)。布尔向量需要与 DataFrame 的索引相同。
- 
让我们尝试使用 NLS DataFrame 上的 nightlyhrssleep列。我们想要一个布尔序列,对于每晚睡眠 6 小时或更少(第 33 个百分位数)的人为True,如果nightlyhrssleep大于 6 或缺失则为False。sleepcheckbool = nls97.nightlyhrssleep<=lowsleepthreshold创建布尔序列。如果我们显示sleepcheckbool的前几个值,我们将看到我们得到了预期的值。我们还可以确认sleepcheckbool的索引等于nls97的索引:nls97.nightlyhrssleep.head() personid 100061 6 100139 8 100284 7 100292 nan 100583 6 Name: nightlyhrssleep, dtype: float64 lowsleepthreshold = nls97.nightlyhrssleep.quantile(0.33) lowsleepthreshold 6.0 sleepcheckbool = nls97.nightlyhrssleep<=lowsleepthreshold sleepcheckbool.head() personid 100061 True 100139 False 100284 False 100292 False 100583 True Name: nightlyhrssleep, dtype: bool sleepcheckbool.index.equals(nls97.index) True
由于sleepcheckbool序列与nls97的索引相同,我们可以直接将其传递给loc访问器以创建一个包含每晚睡眠 6 小时或更少的人的 DataFrame。这里有一些 pandas 的魔法。它为我们处理索引对齐:
lowsleep = nls97.loc[sleepcheckbool]
lowsleep.shape
(3067, 88)
- 
我们本可以在一步中创建数据的 lowsleep子集,这是我们通常会做的,除非我们需要布尔序列用于其他目的:lowsleep = nls97.loc[nls97.nightlyhrssleep<=lowsleepthreshold] lowsleep.shape (3067, 88)
- 
我们可以向 loc访问器传递更复杂的条件并评估多个列的值。例如,我们可以选择nightlyhrssleep小于或等于阈值且childathome(在家居住的儿童数量)大于或等于3的行:lowsleep3pluschildren = \ nls97.loc[(nls97.nightlyhrssleep<=lowsleepthreshold) & (nls97.childathome>=3)] lowsleep3pluschildren.shape (623, 88)
nls97.loc[(nls97.nightlyhrssleep<=lowsleepthreshold) & (nls97.childathome>3)]中的每个条件都放在括号内。如果省略括号,将生成错误。&运算符相当于标准 Python 中的and,意味着必须两个条件都为True,行才能被选中。如果我们想选择如果任一条件为True的行,我们可以使用|表示or。
- 
最后,我们可以同时选择行和列。逗号左边的表达式选择行,而逗号右边的列表选择列: lowsleep3pluschildren = \ nls97.loc[(nls97.nightlyhrssleep<=lowsleepthreshold) & (nls97.childathome>=3), ['nightlyhrssleep','childathome']] lowsleep3pluschildren.shape (623, 2)
在上一两节中,我们使用了三种不同的工具从 pandas DataFrame 中选择列和行:[]括号操作符和两个 pandas 特定访问器loc和iloc。如果你是 pandas 的新手,这可能会有些令人困惑,但经过几个月后,你会清楚地知道在哪种情况下使用哪种工具。如果你带着相当多的 Python 和 NumPy 经验来到 pandas,你可能会发现[]操作符最为熟悉。然而,pandas 文档建议不要在生产代码中使用[]操作符。loc访问器用于通过布尔索引或索引标签选择行,而iloc访问器用于通过行号选择行。
这一部分是关于使用 pandas 选择列和行的简要入门。尽管我们没有对此进行过多详细说明,但涵盖了你需要了解的大部分内容,包括了解本书其余部分中 pandas 特定材料所需的一切。我们将在下一两节中开始应用这些知识,通过为我们的特征创建频率和汇总统计。
生成类别特征的频率
分类别特征可以是名义的或有序的。名义特征,例如性别、物种名称或国家,具有有限的可能值,可以是字符串或数值,但没有内在的数值意义。例如,如果国家用 1 代表阿富汗,2 代表阿尔巴尼亚,以此类推,数据是数值的,但对这些值进行算术运算是没有意义的。
有序特征也有有限的可能值,但与名义特征不同,值的顺序很重要。李克特量表评分(从 1 表示非常不可能到 5 表示非常可能)是一个有序特征的例子。尽管如此,通常不会进行算术运算,因为值之间没有统一和有意义的距离。
在我们开始建模之前,我们希望对可能使用的类别特征的所有可能值进行计数。这通常被称为单向频率分布。幸运的是,pandas 使这变得非常容易。我们可以快速从 pandas DataFrame 中选择列,并使用value_counts方法为每个类别值生成计数:
- 
让我们加载 NLS 数据,创建一个只包含数据前 20 列的 DataFrame,并查看数据类型: nls97 = pd.read_csv("data/nls97.csv") nls97.set_index("personid", inplace=True) nls97abb = nls97.iloc[:,:20] nls97abb.dtypes loc and iloc accessors. The colon to the left of the comma indicates that we want all the rows, while :20 to the right of the comma gets us the first 20 columns.
- 
上一段代码中的所有对象类型列都是类别特征。我们可以使用 value_counts来查看maritalstatus每个值的计数。我们还可以使用dropna=False来让value_counts显示缺失值(NaN):nls97abb.maritalstatus.value_counts(dropna=False) Married 3066 Never-married 2766 NaN 2312 Divorced 663 Separated 154 Widowed 23 Name: maritalstatus, dtype: int64
- 
如果我们只想得到缺失值的数量,我们可以链式调用 isnull和sum方法。isnull在maritalstatus缺失时返回包含True值的布尔序列,否则返回False。然后sum计算True值的数量,因为它将True值解释为 1,将False值解释为 0:nls97abb.maritalstatus.isnull().sum() 2312
- 
你可能已经注意到, maritalstatus的值默认按频率排序。你可以通过排序索引按值进行字母排序。我们可以利用value_counts返回一个以值为索引的序列这一事实来做到这一点:marstatcnt = nls97abb.maritalstatus.value_counts(dropna=False) type(marstatcnt) <class 'pandas.core.series.Series'> marstatcnt.index Index(['Married', 'Never-married', nan, 'Divorced', 'Separated', 'Widowed'], dtype='object')
- 
要对索引进行排序,我们只需调用 sort_index:marstatcnt.sort_index() Divorced 663 Married 3066 Never-married 2766 Separated 154 Widowed 23 NaN 2312 Name: maritalstatus, dtype: int64
- 
当然,我们也可以通过 nls97.maritalstatus.value_counts(dropna=False).sort_index()一步得到相同的结果。我们还可以通过将normalize设置为True来显示比率而不是计数。在下面的代码中,我们可以看到 34%的回应是Married(注意我们没有将dropna设置为True,所以缺失值已被排除):nls97.maritalstatus.\ value_counts(normalize=True, dropna=False).\ sort_index() Divorced 0.07 Married 0.34 Never-married 0.31 Separated 0.02 Widowed 0.00 NaN 0.26 Name: maritalstatus, dtype: float64
- 
当一列有有限数量的值时,pandas 的类别数据类型可以比对象数据类型更有效地存储数据。由于我们已经知道所有对象列都包含类别数据,我们应该将这些列转换为类别数据类型。在下面的代码中,我们创建了一个包含对象列名称的列表, catcols。然后,我们遍历这些列,并使用astype将数据类型更改为category:catcols = nls97abb.select_dtypes(include=["object"]).columns for col in nls97abb[catcols].columns: ... nls97abb[col] = nls97abb[col].astype('category') ... nls97abb[catcols].dtypes gender category maritalstatus category weeklyhrscomputer category weeklyhrstv category highestdegree category govprovidejobs category govpricecontrols category dtype: object
- 
让我们检查我们的类别特征中的缺失值。 gender没有缺失值,highestdegree的缺失值非常少。但govprovidejobs(政府应该提供工作)和govpricecontrols(政府应该控制价格)的绝大多数值都是缺失的。这意味着这些特征可能对大多数建模没有用:nls97abb[catcols].isnull().sum() gender 0 maritalstatus 2312 weeklyhrscomputer 2274 weeklyhrstv 2273 highestdegree 31 govprovidejobs 7151 govpricecontrols 7125 dtype: int64
- 
我们可以通过将 value_counts调用传递给apply来一次生成多个特征的频率。我们可以使用filter来选择我们想要的列——在这种情况下,所有名称中包含*gov*的列。注意,由于我们没有将dropna设置为False,因此已经省略了每个特征的缺失值:nls97abb.filter(like="gov").apply(pd.value_counts, normalize=True) govprovidejobs govpricecontrols 1\. Definitely 0.25 0.54 2\. Probably 0.34 0.33 3\. Probably not 0.25 0.09 4\. Definitely not 0.16 0.04
- 
我们可以在数据的一个子集上使用相同的频率。例如,如果我们只想查看已婚人士对政府角色问题的回答,我们可以通过在 filter之前放置nls97abb[nls97abb.maritalstatus=="Married"]来执行这个子集操作:nls97abb.loc[nls97abb.maritalstatus=="Married"].\ filter(like="gov").\ apply(pd.value_counts, normalize=True) govprovidejobs govpricecontrols 1\. Definitely 0.17 0.46 2\. Probably 0.33 0.38 3\. Probably not 0.31 0.11 4\. Definitely not 0.18 0.05
- 
在这种情况下,由于只有两个 *gov*列,可能更容易执行以下操作:nls97abb.loc[nls97abb.maritalstatus=="Married", ['govprovidejobs','govpricecontrols']].\ apply(pd.value_counts, normalize=True) govprovidejobs govpricecontrols 1\. Definitely 0.17 0.46 2\. Probably 0.33 0.38 3\. Probably not 0.31 0.11 4\. Definitely not 0.18 0.05
尽管如此,使用filter通常会更简单,因为在具有相似名称的特征组上执行相同的清理或探索任务并不罕见。
有时候,我们可能希望将连续或离散特征建模为分类特征。NLS DataFrame 包含 highestgradecompleted。从 5 年级到 6 年级的增长可能不如从 11 年级到 12 年级的增长对目标的影响重要。让我们创建一个二进制特征——即当一个人完成了 12 年级或以上时为 1,如果他们完成的少于这个数则为 0,如果 highestgradecompleted 缺失则为缺失。
- 
不过,我们首先需要做一些清理工作。 highestgradecompleted有两个逻辑缺失值——一个 pandas 识别为缺失的实际 NaN 值和一个调查设计者意图让我们在大多数情况下也视为缺失的 95 值。让我们在继续之前使用replace来修复它:nls97abb.highestgradecompleted.\ replace(95, np.nan, inplace=True)
- 
我们可以使用 NumPy 的 where函数根据highestgradecompleted的值分配highschoolgrad的值。如果highestgradecompleted为空(NaN),我们将NaN分配给我们的新列highschoolgrad。如果highestgradecompleted的值不为空,下一个子句检查值是否小于 12,如果是,则将highschoolgrad设置为 0,否则设置为 1。我们可以通过使用groupby来获取highschoolgrad每个级别的highestgradecompleted的最小值和最大值来确认新列highschoolgrad包含我们想要的值:nls97abb['highschoolgrad'] = \ np.where(nls97abb.highestgradecompleted.isnull(),np.nan, \ np.where(nls97abb.highestgradecompleted<12,0,1)) nls97abb.groupby(['highschoolgrad'], dropna=False) \ ['highestgradecompleted'].agg(['min','max','size']) min max size highschoolgrad 0 5 11 1231 1 12 20 5421 nan nan nan 2332 nls97abb['highschoolgrad'] = \ ... nls97abb['highschoolgrad'].astype('category')
虽然 12 作为将我们的新特征 highschoolgrad 分类为类的阈值在概念上是合理的,但如果我们打算将 highschoolgrad 作为目标使用,这可能会带来一些建模挑战。存在相当大的类别不平衡,highschoolgrad 等于 1 的类别是 0 类的四倍多。我们应该探索使用更多组来表示 highestgradecompleted。
- 
使用 pandas 实现这一点的其中一种方法是使用 qcut函数。我们可以将qcut的q参数设置为6以创建尽可能均匀分布的六个组。现在这些组更接近平衡:nls97abb['highgradegroup'] = \ pd.qcut(nls97abb['highestgradecompleted'], q=6, labels=[1,2,3,4,5,6]) nls97abb.groupby(['highgradegroup'])['highestgradecompleted'].\ agg(['min','max','size']) min max size highgradegroup 1 5 11 1231 2 12 12 1389 3 13 14 1288 4 15 16 1413 5 17 17 388 6 18 20 943 nls97abb['highgradegroup'] = \ nls97abb['highgradegroup'].astype('category')
- 
最后,我发现通常生成所有分类特征的频率并将其保存下来很有帮助,这样我就可以稍后参考。每当我对数据进行一些可能改变这些频率的更改时,我都会重新运行那段代码。以下代码遍历所有数据类型为分类数据的列,并运行 value_counts:freqout = open('views/frequencies.txt', 'w') for col in nls97abb.select_dtypes(include=["category"]): print(col, "----------------------", "frequencies", nls97abb[col].value_counts(dropna=False).sort_index(), "percentages", nls97abb[col].value_counts(normalize=True).\ sort_index(), sep="\n\n", end="\n\n\n", file=freqout) freqout.close()
这些是在您的数据中生成分类特征的单一频率的关键技术。真正的明星是 value_counts 方法。我们可以一次创建一个 Series 的频率使用 value_counts,也可以使用 apply 对多个列进行操作,或者遍历多个列并在每次迭代中调用 value_counts。我们已经在本节中查看了一些示例。接下来,让我们探讨一些检查连续特征分布的技术。
生成连续和离散特征的摘要统计量
获取连续或离散特征的分布感比分类特征要复杂一些。连续特征可以取无限多个值。一个连续特征的例子是体重,因为某人可能重 70 公斤,或者 70.1,或者 70.01。离散特征具有有限个值,例如看到的鸟的数量,或者购买苹果的数量。思考它们之间差异的一种方式是,离散特征通常是已经被计数的东西,而连续特征通常是通过测量、称重或计时来捕捉的。
连续特征通常会被存储为浮点数,除非它们被限制为整数。在这种情况下,它们可能以整数的形式存储。例如,个人的年龄是连续的,但通常会被截断为整数。
对于大多数建模目的,连续特征和离散特征被同等对待。我们不会将年龄建模为分类特征。我们假设年龄在 25 岁到 26 岁之间的间隔与 35 岁到 36 岁之间的间隔具有大致相同的意义,尽管在极端情况下这种假设会失效。人类 1 岁到 2 岁之间的间隔与 71 岁到 72 岁之间的间隔完全不同。数据分析师和科学家通常对连续特征和目标之间的假设线性关系持怀疑态度,尽管当这种关系成立时建模会更容易。
要了解连续特征(或离散特征)的分布,我们必须检查其中心趋势、形状和分布范围。关键摘要统计量包括均值和中位数用于中心趋势,偏度和峰度用于形状,以及范围、四分位数范围、方差和标准差用于分布范围。在本节中,我们将学习如何使用 pandas,辅以SciPy库,来获取这些统计量。我们还将讨论建模的重要影响。
在本节中,我们将使用 COVID-19 数据。数据集包含每个国家的总病例和死亡数,以及截至 2021 年 6 月的人口统计数据。
注意
我们的世界数据在ourworldindata.org/coronavirus-source-data提供 COVID-19 公共使用数据。本节中使用的数据是在 2021 年 7 月 9 日下载的。数据中的列比我包含的要多。我根据国家创建了地区列。
按照以下步骤生成摘要统计量:
- 
让我们将 COVID .csv文件加载到 pandas 中,设置索引,并查看数据。有 221 行和 16 列。我们设置的索引iso_code为每一行包含一个唯一值。我们使用sample来随机查看两个国家,而不是前两个(我们为random_state设置了一个值,以便每次运行代码时都能得到相同的结果):import pandas as pd import numpy as np import scipy.stats as scistat covidtotals = pd.read_csv("data/covidtotals.csv", parse_dates=['lastdate']) covidtotals.set_index("iso_code", inplace=True) covidtotals.shape (221, 16) covidtotals.index.nunique() 221 covidtotals.sample(2, random_state=6).T iso_code ISL CZE lastdate 2021-07-07 2021-07-07 location Iceland Czechia total_cases 6,555 1,668,277 total_deaths 29 30,311 total_cases_mill 19,209 155,783 total_deaths_mill 85 2,830 population 341,250 10,708,982 population_density 3 137 median_age 37 43 gdp_per_capita 46,483 32,606 aged_65_older 14 19 total_tests_thous NaN NaN life_expectancy 83 79 hospital_beds_thous 3 7 diabetes_prevalence 5 7 region Western Europe Western Europe
只需看这两行,我们就可以看到冰岛和捷克在病例和死亡人数方面的显著差异,即使在人口规模方面也是如此。(total_cases_mill 和 total_deaths_mill 分别表示每百万人口中的病例和死亡人数。)数据分析师非常习惯于思考数据中是否还有其他因素可以解释捷克比冰岛病例和死亡人数显著更高的原因。从某种意义上说,我们总是在进行特征选择。
- 
让我们来看看每列的数据类型和空值数量。几乎所有列都是连续的或离散的。我们有关于病例和死亡的数据,分别有 192 行和 185 行。我们必须要做的一个重要数据清洗任务是确定我们能对那些对于我们的目标有缺失值的国家的数据做些什么。我们将在稍后讨论如何处理缺失值: covidtotals.info() <class 'pandas.core.frame.DataFrame'> Index: 221 entries, AFG to ZWE Data columns (total 16 columns): # Column Non-Null Count Dtype --- ------- -------------- -------------- 0 lastdate 221 non-null datetime64[ns] 1 location 221 non-null object 2 total_cases 192 non-null float64 3 total_deaths 185 non-null float64 4 total_cases_mill 192 non-null float64 5 total_deaths_mill 185 non-null float64 6 population 221 non-null float64 7 population_density 206 non-null float64 8 median_age 190 non-null float64 9 gdp_per_capita 193 non-null float64 10 aged_65_older 188 non-null float64 11 total_tests_thous 13 non-null float64 12 life_expectancy 217 non-null float64 13 hospital_beds_thous 170 non-null float64 14 diabetes_prevalence 200 non-null float64 15 region 221 non-null object dtypes: datetime64ns, float64(13), object(2) memory usage: 29.4+ KB
- 
现在,我们已经准备好检查一些特征的分部情况。我们可以通过使用 describe方法来获取我们想要的绝大部分的摘要统计信息。平均值和中位数(50%)是分布中心的良好指标,各有其优势。注意到平均值和中位数之间的显著差异,作为偏斜的指示也是很好的。例如,我们可以看到每百万病例的平均值几乎是中位数的两倍,分别是 36.7 千和 19.5 千。这是一个明显的正偏斜指标。死亡人数每百万的情况也是如此。
病例和死亡人数的四分位距也相当大,两种情况下的第 75 百分位数值大约是第 25 百分位数值的 25 倍。我们可以将这一点与 65 岁及以上人口比例和糖尿病患病率进行比较,其中第 75 百分位数分别是第 25 百分位数的四倍或两倍。我们可以立即得出结论,这两个可能的特征(aged_65_older 和 diabetes_prevalence)必须做大量工作来解释我们目标中的巨大方差:
 keyvars = ['location','total_cases_mill','total_deaths_mill',
...  'aged_65_older','diabetes_prevalence']
 covidkeys = covidtotals[keyvars]
 covidkeys.describe()
total_cases_mill total_deaths_mill aged_65_older diabetes_prevalence
count        192.00       185.00    188.00     200.00
mean      36,649.37       683.14      8.61       8.44
std       41,403.98       861.73      6.12       4.89
min            8.52         0.35      1.14       0.99
25%        2,499.75        43.99      3.50       5.34
50%       19,525.73       293.50      6.22       7.20
75%       64,834.62     1,087.89     13.92      10.61
max      181,466.38     5,876.01     27.05      30.53
- 
我有时发现查看十分位数值有助于更好地了解分布。 quantile方法可以接受一个百分位数值,例如quantile(0.25)表示第 25 百分位数,或者一个列表或元组,例如quantile((0.25,0.5))表示第 25 和第 50 百分位数。在下面的代码中,我们使用 NumPy 的arange函数(np.arange(0.0, 1.1, 0.1)) 生成一个从 0.0 到 1.0,增量是 0.1 的数组。如果我们使用covidkeys.quantile([0.0,0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8,0.9,1.0]),我们会得到相同的结果:covidkeys.quantile(np.arange(0.0, 1.1, 0.1)) total_cases_mill total_deaths_mill aged_65_older diabetes_prevalence 0.00 8.52 0.35 1.14 0.99 0.10 682.13 10.68 2.80 3.30 0.20 1,717.39 30.22 3.16 4.79 0.30 3,241.84 66.27 3.86 5.74 0.40 9,403.58 145.06 4.69 6.70 0.50 19,525.73 293.50 6.22 7.20 0.60 33,636.47 556.43 7.93 8.32 0.70 55,801.33 949.71 11.19 10.08 0.80 74,017.81 1,333.79 14.92 11.62 0.90 94,072.18 1,868.89 18.85 13.75 1.00 181,466.38 5,876.01 27.05 30.53
对于病例、死亡和糖尿病患病率,大部分的范围(最小值和最大值之间的距离)位于分布的最后 10%。这一点对于死亡尤其如此。这暗示了可能存在建模问题,并邀请我们仔细查看异常值,我们将在下一节中这样做。
- 
一些机器学习算法假设我们的特征具有正态分布(也称为高斯分布),它们分布对称(具有低偏度),并且它们的尾部相对正常(既不过度高也不过度低的高斯峰)。我们迄今为止看到的统计数据已经表明,我们的两个可能的目标——即总人口中的总病例数和死亡人数——具有很高的正偏度。让我们通过计算一些特征的偏度和峰度来对此进行更细致的分析。对于高斯分布,我们期望偏度为 0 附近,峰度为 3。 total_deaths_mill的偏度和峰度值值得关注,而total_cases_mill和aged_65_older特征具有过低的峰度(瘦尾巴):covidkeys.skew() total_cases_mill 1.21 total_deaths_mill 2.00 aged_65_older 0.84 diabetes_prevalence 1.52 dtype: float64 covidkeys.kurtosis() total_cases_mill 0.91 total_deaths_mill 6.58 aged_65_older -0.56 diabetes_prevalence 3.31 dtype: float64
- 
我们还可以通过遍历 keyvars列表中的特征并运行scistat.shapiro(covidkeys[var].dropna())来显式测试每个分布的正态性。请注意,我们需要使用dropna删除缺失值以运行测试。p 值小于 0.05 表明我们可以拒绝正态性的零假设,这对于四个特征中的每一个都是如此:for var in keyvars[1:]: stat, p = scistat.shapiro(covidkeys[var].dropna()) print("feature=", var, " p-value=", '{:.6f}'.format(p)) feature= total_cases_mill p-value= 0.000000 feature= total_deaths_mill p-value= 0.000000 feature= aged_65_older p-value= 0.000000 feature= diabetes_prevalence p-value= 0.000000
这些结果应该让我们在考虑参数模型,如线性回归时停下来思考。没有任何分布近似正态分布。然而,这并不是决定性的。这并不像在具有正态分布特征时使用某些模型(比如 k-最近邻)而在不具有时使用非参数模型那么简单。
在做出任何建模决策之前,我们希望进行额外的数据清洗。例如,我们可能决定删除异常值或确定数据转换是合适的。我们将在本书的几个章节中探索转换,例如对数和多项式转换。
本节向您展示了如何使用 pandas 和 SciPy 来了解连续和离散特征的分布情况,包括它们的中心趋势、形状和分布范围。为任何可能包含在我们建模中的特征或目标生成这些统计数据是有意义的。这也指引我们进一步的工作方向,以准备我们的数据进行分析。我们需要识别缺失值和异常值,并找出我们将如何处理它们。我们还应该可视化连续特征的分布。这很少不会带来额外的见解。我们将在下一节学习如何识别异常值,并在下一节创建可视化。
在单变量分析中识别极端值和异常值
异常值可以被视为具有特征值或特征值之间关系非常不寻常的观测值,以至于它们无法解释数据中其他关系。这对于建模很重要,因为我们不能假设异常值将对我们的参数估计产生中性影响。有时,我们的模型会努力构建参数估计,以解释异常观测值中的模式,这可能会损害模型对所有其他观测值的解释或预测能力。如果你曾经花了好几天时间尝试解释一个模型,最终发现一旦移除几个异常值,你的系数和预测就完全改变了,请举手。
我应该快速补充一下,目前还没有关于异常值的统一定义。我提供前面的定义是为了在本书中使用,因为它有助于我们区分异常值,正如我描述的那样,以及极端值。这两个概念之间有相当大的重叠,但许多极端值并不是异常值。这是因为这样的值反映了特征中的自然和可解释的趋势,或者因为它们反映了与数据中观察到的特征之间的相同关系。反之亦然。有些异常值不是极端值。例如,一个目标值可能正好位于分布的中间,但具有相当意外的预测值。
因此,对于我们的建模来说,很难在没有参考多元关系的情况下说一个特定的特征或目标值是异常值。但是,当我们在单变量分析中看到位于中心左侧或右侧的值时,这至少应该引起我们的注意。这应该促使我们进一步调查该值,包括检查其他特征值。我们将在下一章中更详细地探讨多元关系。在这里,以及在下节关于可视化的内容中,我们将专注于在查看单个变量时识别极端值和异常值。
识别极端值的一个良好起点是查看其与分布中间的距离。实现这一目标的一种常见方法是将每个值与四分位数范围(IQR)的距离进行计算,即第一个四分位数值与第三个四分位数值之间的距离。我们通常将任何高于第三四分位数 1.5 倍或低于第一四分位数的值标记出来。我们可以使用这种方法来识别 COVID-19 数据中的异常值。
让我们开始吧:
- 
首先,让我们导入我们将需要的库。除了 pandas 和 NumPy,我们还将使用 Matplotlib 和 statsmodels 来创建图表。我们还将加载 COVID 数据并选择所需的变量: import pandas as pd import numpy as np import matplotlib.pyplot as plt import statsmodels.api as sm covidtotals = pd.read_csv("data/covidtotals.csv") covidtotals.set_index("iso_code", inplace=True) keyvars = ['location','total_cases_mill','total_deaths_mill', 'aged_65_older','diabetes_prevalence','gdp_per_capita'] covidkeys = covidtotals[keyvars]
- 
让我们来看看 total_cases_mill。我们得到第一和第三四分位数,并计算四分位数范围,1.5*(thirdq-firstq)。然后,我们计算它们的阈值以确定高和低极端值,分别是interquartilerange+thirdq和firstq-interquartilerange(如果你熟悉箱线图,你会注意到这是用于箱线图须的相同计算;我们将在下一节中介绍箱线图):thirdq, firstq = covidkeys.total_cases_mill.quantile(0.75), covidkeys.total_cases_mill.quantile(0.25) interquartilerange = 1.5*(thirdq-firstq) extvalhigh, extvallow = interquartilerange+thirdq, firstq-interquartilerange print(extvallow, extvalhigh, sep=" <--> ") -91002.564625 <--> 158336.930375
- 
这个计算表明,任何 total_cases_mill的值,如果高于 158,337,都可以被认为是极端的。我们可以忽略低端的极端值,因为它们将是负数:covidtotals.loc[covidtotals.total_cases_mill>extvalhigh].T iso_code AND MNE SYC lastdate 2021-07-07 2021-07-07 2021-07-07 location Andorra Montenegro Seychelles total_cases 14,021 100,392 16,304 total_deaths 127 1,619 71 total_cases_mill 181,466 159,844 165,792 total_deaths_mill 1,644 2,578 722 population 77,265 628,062 98,340 population_density 164 46 208 median_age NaN 39 36 gdp_per_capita NaN 16,409 26,382 aged_65_older NaN 15 9 total_tests_thous NaN NaN NaN life_expectancy 84 77 73 hospital_beds_thous NaN 4 4 diabetes_prevalence 8 10 11 region Western Eastern East Europe Europe Africa
- 
安道尔、黑山和塞舌尔的所有 total_cases_mill都超过了阈值。这促使我们探索这些国家可能异常的其他方式,以及我们的特征是否能够捕捉到这一点。我们不会在这里深入多元分析,因为我们将那部分内容放在下一章中,但开始思考为什么这些极端值可能或可能没有意义是个好主意。在整个数据集上有一个平均值可能有助于我们:covidtotals.mean() total_cases 963,933 total_deaths 21,631 total_cases_mill 36,649 total_deaths_mill 683 population 35,134,957 population_density 453 median_age 30 gdp_per_capita 19,141 aged_65_older 9 total_tests_thous 535 life_expectancy 73 hospital_beds_thous 3 diabetes_prevalence 8
这三个国家与其他国家的主要区别在于它们的人口非常少。令人惊讶的是,每个国家的人口密度都比平均水平低得多。这与你预期的相反,值得我们在这本书的整个分析中进一步考虑。
IQR 计算的替代方法
使用四分位数范围来识别极端值的另一种方法是使用与平均值几个标准差的位置,比如说 3 个。这种方法的一个缺点是,它比使用四分位数范围更容易受到极端值的影响。
我认为为我的数据中的所有关键目标和特征生成这种分析是有帮助的,因此让我们自动化识别极端值的方法。我们还应该将结果输出到文件中,这样我们就可以在我们需要时使用它们:
- 
让我们定义一个函数, getextremevalues,它遍历我们的 DataFrame(除了包含位置列的第一个列之外的所有列),计算该列的四分位数范围,选择所有高于该列高阈值或低于低阈值的观测值,然后将结果追加到一个新的 DataFrame(dfout)中:def getextremevalues(dfin): dfout = pd.DataFrame(columns=dfin.columns, data=None) for col in dfin.columns[1:]: thirdq, firstq = dfin[col].quantile(0.75), \ dfin[col].quantile(0.25) interquartilerange = 1.5*(thirdq-firstq) extvalhigh, extvallow = \ interquartilerange+thirdq, \ firstq-interquartilerange df = dfin.loc[(dfin[col]>extvalhigh) | (dfin[col]<extvallow)] df = df.assign(varname = col, threshlow = extvallow, threshhigh = extvalhigh) dfout = pd.concat([dfout, df]) return dfout
- 
现在,我们可以将我们的 covidkeysDataFrame 传递给getextremevalues函数,以获取包含每个列的极端值的 DataFrame。然后,我们可以显示每个列的极端值数量,这告诉我们人口中每百万人的总死亡数(total_deaths_mill)有四个极端值。现在,我们可以将数据输出到 Excel 文件中:extremevalues = getextremevalues(covidkeys) extremevalues.varname.value_counts() gdp_per_capita 9 diabetes_prevalence 8 total_deaths_mill 4 total_cases_mill 3 Name: varname, dtype: int64 extremevalues.to_excel("views/extremevaluescases.xlsx")
- 
让我们更仔细地看看每百万极端死亡值。我们可以查询我们刚刚创建的 DataFrame,以获取 total_deaths_mill的threshhigh值,该值为2654。我们还可以获取具有极端值的那些国家的其他关键特征,因为我们已经将那些数据包含在新的 DataFrame 中:extremevalues.loc[extremevalues.varname=="total_deaths_mill", 'threshhigh'][0] 2653.752 extremevalues.loc[extremevalues.varname=="total_deaths_mill", keyvars].sort_values(['total_deaths_mill'], ascending=False) location total_cases_mill total_deaths_mill PER Peru 62,830 5,876 HUN Hungary 83,676 3,105 BIH Bosnia and Herzegovina 62,499 2,947 CZE Czechia 155,783 2,830 _65_older diabetes_prevalence gdp_per_capita PER 7 6 12,237 HUN 19 8 26,778 BIH 17 10 11,714 CZE 19 7 32,606
秘鲁、匈牙利、波斯尼亚和黑塞哥维那以及捷克共和国的total_deaths_mill值超过了极端值阈值。这三个国家中的一个突出特点是,它们 65 岁或以上人口百分比远高于平均水平(该特征的均值为 9,正如我们在先前的表中显示的那样)。尽管这些是死亡极端值,但老年人口百分比与 COVID 死亡之间的关系可能解释了其中很大一部分,并且可以在不过度拟合这些极端案例的情况下做到这一点。我们将在下一章中介绍一些提取这些策略的方法。
到目前为止,我们讨论了异常值和极端值,但没有提及分布形状。我们迄今为止所暗示的是,极端值是一个罕见值——比分布中心附近的值要罕见得多。但是,当特征分布接近正态分布时,这最有意义。另一方面,如果一个特征具有均匀分布,那么一个非常高的值并不比任何其他值罕见。
在实践中,我们会考虑相对于特征分布的极端值或异常值。分位数-分位数(Q-Q)图可以通过允许我们相对于理论分布(正态分布、均匀分布、对数分布或其他)图形化地查看它来提高我们对该分布的感觉:让我们看一下:
- 
让我们创建一个与正态分布相关的每百万总病例的 Q-Q 图。我们可以使用 statsmodels库来完成这个任务:sm.qqplot(covidtotals[['total_cases_mill']]. \ sort_values(['total_cases_mill']).dropna(),line='s') ) plt.show()
这会产生以下图表:

图 1.1 – 每百万总病例的 Q-Q 图
这个 Q-Q 图清楚地表明,各国总病例的分布不是正态分布。我们可以通过数据点与红线偏离的程度来看到这一点。这是一个我们预期来自具有某些正偏斜的分布的 Q-Q 图。这与我们已为总病例特征计算出的摘要统计一致。它进一步强化了我们正在形成的认识,即我们需要对参数模型保持谨慎,我们可能需要考虑的不仅仅是单个或两个异常观测值。
让我们看看一个分布略接近正态分布的特征的 Q-Q 图。在 COVID 数据中没有很好的候选者,所以我们将使用美国国家海洋和大气管理局 2019 年陆地温度的数据。
数据备注
陆地温度数据集包含了来自全球超过 12,000 个站点的 2019 年平均温度读数(摄氏度),尽管大多数站点位于美国。原始数据是从全球历史气候学网络综合数据库中检索的。美国国家海洋和大气管理局已将其公开供公众使用,网址为 www.ncdc.noaa.gov/data-access/land-based-station-data/land-based-datasets/global-historical-climatology-network-monthly-version-2。
- 
首先,让我们将数据加载到 pandas DataFrame 中,并对温度特征 avgtemp运行一些描述性统计。我们必须向正常的describe输出中添加一些百分位数统计,以更好地了解值的范围。avgtemp是每个 12,095 个气象站一年的平均温度。所有站点的平均温度为 11.2 摄氏度。中位数为 10.4。然而,有一些非常低的值,包括 14 个平均温度低于 -25 的气象站。这导致了一中度负偏斜,尽管偏斜和峰度都更接近正态分布的预期:landtemps = pd.read_csv("data/landtemps2019avgs.csv") landtemps.avgtemp.describe(percentiles=[0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95]) count 12,095.0 mean 11.2 std 8.6 min -60.8 5% -0.7 10% 1.7 25% 5.4 50% 10.4 75% 16.9 90% 23.1 95% 27.0 max 33.9 Name: avgtemp, dtype: float64 landtemps.loc[landtemps.avgtemp<-25,'avgtemp'].count() 14 landtemps.avgtemp.skew() -0.2678382583481768 landtemps.avgtemp.kurtosis() 2.1698313707061073
- 
现在,让我们看一下平均温度的 Q-Q 图: sm.qqplot(landtemps.avgtemp.sort_values().dropna(), line='s') plt.tight_layout() plt.show()
这产生了以下图表:

图 1.2 – 平均温度的 Q-Q 图
在大多数范围内,平均温度的分布看起来非常接近正态分布。例外的是极低温度,这导致了一小部分负偏斜。在高端也有一些偏离正态分布的情况,尽管这并不是一个大问题(你可能已经注意到,具有负偏斜的特征的 Q-Q 图具有雨伞状形状,而具有正偏斜的特征,如总病例数,则具有更多碗状形状)。
我们在理解可能特征和目标分布以及识别极端值和异常值的相关努力中取得了良好的开端。当我们构建、改进和解释模型时,这些重要信息对我们来说至关重要。但我们还可以做更多的事情来提高我们对数据的直觉。一个好的下一步是构建关键特征的可视化。
使用直方图、箱线图和提琴图来检查特征分布
我们已经生成了构成直方图或箱线图数据点的许多数字。但当我们看到数据以图形方式表示时,我们往往能更好地理解数据。我们看到观测值围绕着平均值聚集,我们注意到尾部的尺寸,我们看到似乎极端的值。
使用直方图
按以下步骤创建直方图:
- 
在本节中,我们将同时处理 COVID 数据和温度数据。除了我们迄今为止使用的库之外,我们还必须导入 Seaborn,以便比在 Matplotlib 中更容易地创建一些图表: import pandas as pd import numpy as np import matplotlib.pyplot as plt import seaborn as sns landtemps = pd.read_csv("data/landtemps2019avgs.csv") covidtotals = pd.read_csv("data/covidtotals.csv", parse_dates=["lastdate"]) covidtotals.set_index("iso_code", inplace=True)
- 
现在,让我们创建一个简单的直方图。我们可以使用 Matplotlib 的 hist方法创建每百万人口中总病例的直方图。我们还将绘制均值和中位数的线条:plt.hist(covidtotals['total_cases_mill'], bins=7) plt.axvline(covidtotals.total_cases_mill.mean(), color='red', linestyle='dashed', linewidth=1, label='mean') plt.axvline(covidtotals.total_cases_mill.median(), color='black', linestyle='dashed', linewidth=1, label='median') plt.title("Total COVID Cases") plt.xlabel('Cases per Million') plt.ylabel("Number of Countries") plt.legend() plt.show()
这产生了以下图表:

图 1.3 – 总 COVID 病例
这个直方图突出显示的总体分布的一个方面是,大多数国家(192 个中的 100 多个)都在第一个区间内,即每百万人口 0 例到 25,000 例之间。在这里,我们可以看到正偏斜,由于极端高值,均值被拉向右边。这与我们在上一节中使用 Q-Q 图发现的情况一致。
- 
让我们创建一个来自陆地温度数据集的平均温度直方图: plt.hist(landtemps['avgtemp']) plt.axvline(landtemps.avgtemp.mean(), color='red', linestyle='dashed', linewidth=1, label='mean') plt.axvline(landtemps.avgtemp.median(), color='black', linestyle='dashed', linewidth=1, label='median') plt.title("Average Land Temperatures") plt.xlabel('Average Temperature') plt.ylabel("Number of Weather Stations") plt.legend() plt.show()
这产生了以下图表:

图 1.4 – 平均陆地温度
来自陆地温度数据集的平均陆地温度直方图看起来相当不同。除了少数几个高度负值外,这种分布看起来更接近正态分布。在这里,我们可以看到均值和众数非常接近,分布看起来相当对称。
- 
我们应该看一下分布最左端的观测值。它们都在南极洲或加拿大最北部。在这里,我们必须怀疑是否应该将具有如此极端值的观测值包含在我们构建的模型中。然而,仅基于这些结果就做出这样的决定还为时过早。我们将在下一章中回到这个问题,当我们检查用于识别异常值的多变量技术时: landtemps.loc[landtemps.avgtemp<-25,['station','country','avgtemp']].\ ... sort_values(['avgtemp'], ascending=True) station country avgtemp 827 DOME_PLATEAU_DOME_A Antarctica -60.8 830 VOSTOK Antarctica -54.5 837 DOME_FUJI Antarctica -53.4 844 DOME_C_II Antarctica -50.5 853 AMUNDSEN_SCOTT Antarctica -48.4 842 NICO Antarctica -48.4 804 HENRY Antarctica -47.3 838 RELAY_STAT Antarctica -46.1 828 DOME_PLATEAU_EAGLE Antarctica -43.0 819 KOHNENEP9 Antarctica -42.4 1299 FORT_ROSS Canada -30.3 1300 GATESHEAD_ISLAND Canada -28.7 811 BYRD_STATION Antarctica -25.8 816 GILL Antarctica -25.5
同时可视化集中趋势、离散度和异常值的一个极好方法是使用箱线图。
使用箱线图
箱线图显示了四分位距,其中触须代表四分位距的 1.5 倍,以及超出该范围的数据点,这些数据点可以被认为是极端值。如果这个计算看起来很熟悉,那是因为我们之前在本章中用来识别极端值的就是这个计算!让我们开始吧:
- 
我们可以使用 Matplotlib 的 boxplot方法创建每百万人口中总病例的箱线图。我们可以画箭头来表示四分位距(第一四分位数、中位数和第三四分位数)以及极端值阈值。阈值上方的三个圆圈可以被认为是极端值。从四分位距到极端值阈值的线通常被称为触须。通常在四分位距上方和下方都有触须,但在这个情况下,低于第一四分位数阈值的值将是负数:plt.boxplot(covidtotals.total_cases_mill.dropna(), labels=['Total Cases per Million']) plt.annotate('Extreme Value Threshold', xy=(1.05,157000), xytext=(1.15,157000), size=7, arrowprops=dict(facecolor='black', headwidth=2, width=0.5, shrink=0.02)) plt.annotate('3rd quartile', xy=(1.08,64800), xytext=(1.15,64800), size=7, arrowprops=dict(facecolor='black', headwidth=2, width=0.5, shrink=0.02)) plt.annotate('Median', xy=(1.08,19500), xytext=(1.15,19500), size=7, arrowprops=dict(facecolor='black', headwidth=2, width=0.5, shrink=0.02)) plt.annotate('1st quartile', xy=(1.08,2500), xytext=(1.15,2500), size=7, arrowprops=dict(facecolor='black', headwidth=2, width=0.5, shrink=0.02)) plt.title("Boxplot of Total Cases") plt.show()
这产生了以下图表:

图 1.5 – 总病例数的箱线图
仔细观察四分位数范围是有帮助的,特别是中位数在范围内的位置。对于这个箱线图,中位数位于范围的低端。这就是我们在正偏分布中看到的情况。
- 
现在,让我们为平均温度创建一个箱线图。所有的极端值现在都位于分布的低端。不出所料,鉴于我们已经看到的平均温度特征,中位数线比我们之前的箱线图更接近四分位数范围的中心(这次我们不会注释图表——我们上次这样做是为了解释目的): plt.boxplot(landtemps.avgtemp.dropna(), labels=['Boxplot of Average Temperature']) plt.title("Average Temperature") plt.show()
这产生了以下图表:

图 1.6 – 平均温度的箱线图
直方图帮助我们了解分布的分布情况,而箱线图则使识别异常值变得容易。我们可以通过小提琴图在一个图表中很好地了解分布的分布情况和异常值。
使用小提琴图
小提琴图结合了直方图和箱线图,在一个图表中显示。它们显示了四分位数范围、中位数和触须,以及所有值范围内的观测频率。
让我们开始吧:
- 
我们可以使用 Seaborn 为每百万个 COVID 病例和平均温度特征创建 violin 图。我在这里使用 Seaborn 而不是 Matplotlib,因为我更喜欢其默认的 violin 图选项: import seaborn as sns fig = plt.figure() fig.suptitle("Violin Plots of COVID Cases and Land Temperatures") ax1 = plt.subplot(2,1,1) ax1.set_xlabel("Cases per Million") sns.violinplot(data=covidtotals.total_cases_mill, color="lightblue", orient="h") ax1.set_yticklabels([]) ax2 = plt.subplot(2,1,2) ax2.set_xlabel("Average Temperature") sns.violinplot(data=landtemps.avgtemp, color="wheat", orient="h") ax2.set_yticklabels([]) plt.tight_layout() plt.show()
这产生了以下图表:

图 1.7 – COVID 病例和陆地温度的小提琴图
中间带有白色圆点的黑色条形表示四分位数范围,而白色圆点代表中位数。当小提琴图水平时,每个点的高度(即当小提琴图水平时)给出了相对频率。四分位数范围右侧的细黑线表示每百万个案例,平均温度的左右两侧是触须。极端值显示在触须之外的分布部分。
如果我要为数值特征创建一个图表,我会创建一个小提琴图。小提琴图让我能够在一个图表中看到集中趋势、形状和分布。空间不允许在这里展示,但我通常喜欢创建所有连续特征的 violin 图,并将它们保存到 PDF 文件中以供以后参考。
摘要
在本章中,我们探讨了探索数据的一些常用技术。我们学习了如何在需要时检索数据的子集以供我们的分析使用。我们还使用了 pandas 方法来生成关于均值、四分位数范围和偏度等特征的关键统计数据。这让我们对每个特征的分布的中心趋势、分布范围和形状有了更好的理解。这也使我们能够更好地识别异常值。最后,我们使用了 Matplotlib 和 Seaborn 库来创建直方图、箱线图和小提琴图。这为我们提供了关于特征分布的额外见解,例如尾部长度和与正态分布的偏离程度。
可视化是本章讨论的单变量分析工具的绝佳补充。直方图、箱线图和小提琴图显示了每个特征分布的形状和分布范围。从图形上看,它们显示了通过检查一些汇总统计数据可能遗漏的内容,例如分布中的隆起(或隆起)位置以及极端值所在的位置。当我们探索特征与目标之间的双变量和多变量关系时,这些可视化将同样有用,我们将在第二章“检查特征与目标之间的双变量和多变量关系”中进行探讨。
第二章:第二章:检查特征与目标变量之间的双变量和多变量关系
在本章中,我们将探讨可能特征与目标变量之间的相关性。使用交叉表(双向频率)、相关性、散点图和分组箱线图的双变量探索性分析可以揭示建模的关键问题。常见问题包括特征之间高度相关以及特征与目标变量之间的非线性关系。在本章中,我们将使用 pandas 方法进行双变量分析,并使用 Matplotlib 进行可视化。我们还将讨论我们在特征工程和建模方面发现的影响。
我们还将使用多元技术来理解特征之间的关系。这包括依赖一些机器学习算法来识别可能存在问题的观测值。之后,我们将就消除某些观测值以及转换关键特征提供初步建议。
在本章中,我们将涵盖以下主题:
- 
在双变量关系中识别异常值和极端值 
- 
使用散点图查看连续特征之间的双变量关系 
- 
使用分组箱线图查看连续和分类特征之间的双变量关系 
- 
使用线性回归识别具有显著影响的数据点 
- 
使用 K 近邻算法寻找异常值 
- 
使用隔离森林算法寻找异常值 
技术要求
本章将大量依赖 pandas 和 Matplotlib 库,但你不需要对这些库有任何先前的知识。如果你是从科学发行版安装的 Python,例如 Anaconda 或 WinPython,那么这些库可能已经安装好了。我们还将使用 Seaborn 进行一些图形,并使用 statsmodels 库进行一些汇总统计。如果你需要安装任何包,可以从终端窗口或 Windows PowerShell 运行pip install [package name]。本章的代码可以在本书的 GitHub 仓库github.com/PacktPublishing/Data-Cleaning-and-Exploration-with-Machine-Learning中找到。
在双变量关系中识别异常值和极端值
没有一个好的双变量关系感,很难开发出一个可靠的模型。我们不仅关心特定特征与目标变量之间的关系,还关心特征如何一起移动。如果特征高度相关,那么建模它们的独立效应可能变得棘手或没有必要。即使特征只是在某个值域内高度相关,这也可能是一个挑战。
对双变量关系的良好理解对于识别异常值也很重要。一个值可能出乎意料,即使它不是一个极端值。这是因为当第二个特征具有某些值时,某些特征的值可能是不寻常的。当其中一个特征是分类的,而另一个是连续的时,这一点很容易说明。
下面的图表说明了多年来每天鸟类观测的数量,但显示了两个地点不同的分布。一个地点每天的平均观测数为 33,而另一个地点为 52。(这是一个从我的Python 数据清洗食谱中提取的虚构示例。)整体平均数(未显示)为 42。对于每天 58 次观测的值我们应该如何看待?它是异常值吗?这取决于观察的是哪个地点。如果地点 A 一天有 58 次观测,那么 58 将是一个非常高的数字。然而,对于地点 B 来说,58 次观测并不会与该地点的平均值有很大不同:

图 2.1 – 每日鸟类观测
这暗示了一个有用的经验法则:当感兴趣的特征与另一个特征相关时,我们在尝试识别异常值(或任何与该特征相关的建模)时应该考虑这种关系。更精确地表述这一点并将其扩展到两个特征都是连续的情况是有帮助的。如果我们假设特征x和特征y之间存在线性关系,我们可以用熟悉的y = mx + b方程来描述这种关系,其中m是斜率,b是y轴截距。然后,我们可以预期y的值将接近x乘以估计的斜率,加上y轴截距。意外值是那些与这种关系有较大偏差的值,其中y的值远高于或低于根据x的值预测的值。这可以扩展到多个x,或预测变量。
在本节中,我们将学习如何通过考察一个特征与另一个特征之间的关系来识别异常值和意外值。在本章的后续部分,我们将使用多元技术来进一步提高我们的异常值检测。
在本节中,我们将基于各国 COVID-19 病例的数据进行工作。数据集包含每百万人口中的病例和死亡人数。我们将把这两个列都视为可能的靶标。它还包含每个国家的人口统计数据,例如人均 GDP、中位数年龄和糖尿病患病率。让我们开始吧:
注意
Our World in Data 在ourworldindata.org/coronavirus-source-data提供 COVID-19 公共使用数据。本节所使用的数据集是在 2021 年 7 月 9 日下载的。数据中包含的列比我包含的要多。我根据国家创建了region列。
- 
让我们从加载 COVID-19 数据集并查看其结构开始。我们还将导入 Matplotlib 和 Seaborn 库,因为我们将会进行一些可视化: import pandas as pd import matplotlib.pyplot as plt import seaborn as sns covidtotals = pd.read_csv("data/covidtotals.csv") covidtotals.set_index("iso_code", inplace=True) covidtotals.info() <class 'pandas.core.frame.DataFrame'> Index: 221 entries, AFG to ZWE Data columns (total 16 columns): # Column Non-Null Count Dtype -- -------- --------------- ------- 0 lastdate 221 non-null object 1 location 221 non-null object 2 total_cases 192 non-null float64 3 total_deaths 185 non-null float64 4 total_cases_mill 192 non-null float64 5 total_deaths_mill 185 non-null float64 6 population 221 non-null float64 7 population_density 206 non-null float64 8 median_age 190 non-null float64 9 gdp_per_capita 193 non-null float64 10 aged_65_older 188 non-null float64 11 total_tests_thous 13 non-null float64 12 life_expectancy 217 non-null float64 13 hospital_beds_thous 170 non-null float64 14 diabetes_prevalence 200 non-null float64 15 region 221 non-null object dtypes: float64(13), object(3) memory usage: 29.4+ KB
- 
在我们检查双变量关系的过程中,从相关性开始是一个很好的起点。首先,让我们创建一个包含一些关键特征的 DataFrame: totvars = ['location','total_cases_mill', 'total_deaths_mill'] demovars = ['population_density','aged_65_older', 'gdp_per_capita','life_expectancy', 'diabetes_prevalence'] covidkeys = covidtotals.loc[:, totvars + demovars]
- 
现在,我们可以获取这些特征的皮尔逊相关矩阵。病例和每百万死亡之间的正相关系数为 0.71。65 岁或以上的人口百分比与病例和死亡都呈正相关,两者均为 0.53。预期寿命也与每百万病例高度相关。似乎至少有一些与国内生产总值(GDP)每人相关的病例: corrmatrix = covidkeys.corr(method="pearson") corrmatrix total_cases_mill total_deaths_mill\ total_cases_mill 1.00 0.71 total_deaths_mill 0.71 1.00 population_density 0.04 -0.03 aged_65_older 0.53 0.53 gdp_per_capita 0.46 0.22 life_expectancy 0.57 0.46 diabetes_prevalence 0.02 -0.01 population_density aged_65_older gdp_per_capita\ total_cases_mill 0.04 0.53 0.46 total_deaths_mill -0.03 0.53 0.22 population_density 1.00 0.06 0.41 aged_65_older 0.06 1.00 0.49 gdp_per_capita 0.41 0.49 1.00 life_expectancy 0.23 0.73 0.68 diabetes_prevalence 0.01 -0.06 0.12 life_expectancy diabetes_prevalence total_cases_mill 0.57 0.02 total_deaths_mill 0.46 -0.01 population_density 0.23 0.01 aged_65_older 0.73 -0.06 gdp_per_capita 0.68 0.12 life_expectancy 1.00 0.19 diabetes_prevalence 0.19 1.00
值得注意的是,可能特征之间的相关性,例如,预期寿命和人均 GDP(0.68)以及预期寿命和 65 岁或以上人群(0.73)之间的相关性。
- 
将相关矩阵作为热图查看可能会有所帮助。这可以通过将相关矩阵传递给 Seaborn 的 heatmap方法来完成:sns.heatmap(corrmatrix, xticklabels = corrmatrix.columns, yticklabels=corrmatrix.columns, cmap="coolwarm") plt.title('Heat Map of Correlation Matrix') plt.tight_layout() plt.show()
这会创建以下图表:

图 2.2 – COVID 数据的热图,最强的相关性用红色和桃色表示
我们需要关注用较暖色调显示的细胞——在这种情况下,主要是桃色。我发现使用热图有助于我在建模时记住相关性。
注意
本书包含的所有彩色图像都可以下载。请查看本书的前言以获取相应的链接。
- 
让我们更仔细地看看每百万总病例数和每百万死亡数之间的关系。比仅仅通过相关系数更好地理解这一点的一种方法是比较每个的高值和低值,看看它们是如何一起变化的。在下面的代码中,我们使用 qcut方法为病例创建一个具有五个值的分类特征,这些值从非常低到非常高分布得相对均匀:我们为死亡也做了同样的事情:covidkeys['total_cases_q'] = \ pd.qcut(covidkeys['total_cases_mill'], labels=['very low','low','medium','high', 'very high'], q=5, precision=0) covidkeys['total_deaths_q'] = \ pd.qcut(covidkeys['total_deaths_mill'], labels=['very low','low','medium','high', 'very high'], q=5, precision=0)
- 
我们可以使用 crosstab函数查看每个病例五分位数和死亡五分位数的国家数量。正如我们所预期的,大多数国家都在对角线上。有 27 个国家病例和死亡都非常低,有 25 个国家病例和死亡都非常高。有趣的是那些不在对角线上的计数,例如,四个病例非常高但死亡中等的国家,以及一个病例中等但死亡非常高的国家。让我们也看看我们特征的均值,这样我们以后可以参考它们:pd.crosstab(covidkeys.total_cases_q, covidkeys.total_deaths_q) total_deaths_q very low low medium high very high total_cases_q very low 27 7 0 0 0 low 9 24 4 0 0 medium 1 6 23 6 1 high 0 0 6 21 11 very high 0 0 4 10 25 covidkeys.mean() total_cases_mill 36,649 total_deaths_mill 683 population_density 453 aged_65_older 9 gdp_per_capita 19,141 life_expectancy 73 diabetes_prevalence 8
- 
让我们仔细看看远离对角线的国家。四个国家——塞浦路斯、科威特、马尔代夫和卡塔尔——的每百万死亡数低于平均水平,但每百万病例数则远高于平均水平。有趣的是,这四个国家在人口规模上都非常小;其中三个国家的人口密度远低于平均的 453;再次,其中三个国家的 65 岁或以上人口比例远低于平均水平: covidtotals.loc[(covidkeys.total_cases_q=="very high") & (covidkeys.total_deaths_q=="medium")].T iso_code CYP KWT MDV QAT lastdate 2021-07-07 2021-07-07 2021-07-07 2021-07-07 location Cyprus Kuwait Maldives Qatar total_cases 80,588 369,227 74,724 222,918 total_deaths 380 2,059 213 596 total_cases_mill 90,752 86,459 138,239 77,374 total_deaths_mill 428 482 394 207 population 888,005 4,270,563 540,542 2,881,060 population_density 128 232 1,454 227 median_age 37 34 31 32 gdp_per_capita 32,415 65,531 15,184 116,936 aged_65_older 13 2 4 1 total_tests_thous NaN NaN NaN NaN life_expectancy 81 75 79 80 hospital_beds_thous 3 2 NaN 1 diabetes_prevalence 9 16 9 17 region Eastern West South West Europe Asia Asia Asia
- 
让我们仔细看看那些根据病例数预期死亡数较多的国家。对于墨西哥来说,每百万病例数远低于平均水平,而每百万死亡数则相当高于平均水平: covidtotals.loc[(covidkeys. total_cases_q=="medium") & (covidkeys.total_deaths_q=="very high")].T iso_code MEX lastdate 2021-07-07 location Mexico total_cases 2,558,369 total_deaths 234,192 total_cases_mill 19,843 total_deaths_mill 1,816 population 128,932,753 population_density 66 median_age 29 gdp_per_capita 17,336 aged_65_older 7 total_tests_thous NaN life_expectancy 75 hospital_beds_thous 1 diabetes_prevalence 13 region North America
当我们想要了解数据集中双变量关系时,相关系数和热图是一个好的起点。然而,仅用相关系数来可视化连续变量之间的关系可能很困难。这尤其适用于关系不是线性的情况——也就是说,当它基于特征的取值范围而变化时。我们通常可以通过散点图来提高我们对两个特征之间关系的理解。我们将在下一节中这样做。
使用散点图查看连续特征之间的双变量关系
在本节中,我们将学习如何获取数据的散点图。
我们可以使用散点图来获取比仅通过相关系数所能检测到的更完整的两个特征之间的关系图景。这在数据关系随数据范围变化而变化时尤其有用。在本节中,我们将创建一些与上一节中考察的相同特征的散点图。让我们开始吧:
- 
通过数据点绘制回归线是有帮助的。我们可以使用 Seaborn 的 regplot方法来做这件事。让我们再次加载 COVID-19 数据,以及 Matplotlib 和 Seaborn 库,并生成total_cases_mill与total_deaths_mill的散点图:import pandas as pd import numpy as np import matplotlib.pyplot as plt import seaborn as sns covidtotals = pd.read_csv("data/covidtotals.csv") covidtotals.set_index("iso_code", inplace=True) ax = sns.regplot(x="total_cases_mill", y="total_deaths_mill", data=covidtotals) ax.set(xlabel="Cases Per Million", ylabel="Deaths Per Million", title="Total COVID Cases and Deaths by Country") plt.show()
这会产生以下图表:

图 2.3 – 按国家划分的 COVID 病例和死亡总数
回归线是对每百万病例和每百万死亡之间关系的估计。线的斜率表明,我们可以预期每百万死亡数随着每百万病例数增加 1 个单位而增加多少。那些在散点图上显著高于回归线的点应该被更仔细地检查。
- 
死亡率每百万接近 6,000 例,病例率每百万低于 75,000 例的国家显然是异常值。让我们仔细看看: covidtotals.loc[(covidtotals.total_cases_mill<75000) \ & (covidtotals.total_deaths_mill>5500)].T iso_code PER lastdate 2021-07-07 location Peru total_cases 2,071,637 total_deaths 193,743 total_cases_mill 62,830 total_deaths_mill 5,876 population 32,971,846 population_density 25 median_age 29 gdp_per_capita 12,237 aged_65_older 7 total_tests_thous NaN life_expectancy 77 hospital_beds_thous 2 diabetes_prevalence 6 region South America
在这里,我们可以看到异常值国家是秘鲁。秘鲁的确每百万人口病例数高于平均水平,但其每百万死亡人数仍然远高于按病例数预期的数值。如果我们画一条垂直于x轴的线,在 62,830 处,我们可以看到它在大约每百万 1,000 人死亡处与回归线相交,这比秘鲁的 5,876 要少得多。在秘鲁的数据中,除了病例数之外,还有两个非常不同于数据集平均值的数值也引人注目,那就是人口密度和人均 GDP,这两个数值都显著低于平均水平。在这里,我们没有任何特征能帮助我们解释秘鲁的高死亡率。
注意
当创建散点图时,通常会将一个特征或预测变量放在x轴上,将目标变量放在y轴上。如果画了一条回归线,那么它代表的是预测变量增加 1 个单位时目标变量的增加。但散点图也可以用来检查两个预测变量或两个可能的目标变量之间的关系。
回顾我们在第一章中定义的异常值,即检查特征和目标变量的分布,可以认为秘鲁是一个异常值。但我们还需要做更多的工作才能得出这个结论。秘鲁并不是唯一一个在散点图上点远高于或低于回归线的国家。通常来说,调查这些点中的许多点是件好事。让我们来看看:
- 
创建包含大多数关键连续特征的散点图可以帮助我们识别其他可能的异常值,并更好地可视化我们在本章第一部分观察到的相关性。让我们创建 65 岁及以上人口和人均 GDP 与每百万总病例数的散点图: fig, axes = plt.subplots(1,2, sharey=True) sns.regplot(x=covidtotals.aged_65_older, y=covidtotals.total_cases_mill, ax=axes[0]) sns.regplot(x=covidtotals.gdp_per_capita, y=covidtotals.total_cases_mill, ax=axes[1]) axes[0].set_xlabel("Aged 65 or Older") axes[0].set_ylabel("Cases Per Million") axes[1].set_xlabel("GDP Per Capita") axes[1].set_ylabel("") plt.suptitle("Age 65 Plus and GDP with Cases Per Million") plt.tight_layout() fig.subplots_adjust(top=0.92) plt.show()
这会产生以下图表:

图 2.4 – 65 岁及以上人口和人均 GDP 与每百万总病例数
这些散点图显示,一些每百万病例数非常高的国家,其数值接近我们根据人口年龄或 GDP 预期的数值。这些是极端值,但并不一定是我们定义的异常值。
使用散点图可以展示两个特征与目标之间的关系,所有这些都在一个图形中。让我们回到上一章中我们处理过的陆地温度数据,来探讨这一点。
数据注意
陆地温度数据集包含了 2019 年从全球超过 12,000 个气象站读取的平均温度(以摄氏度为单位),尽管大多数站点位于美国。该数据集是从全球历史气候学网络综合数据库中检索的。它已由美国国家海洋和大气管理局在www.ncdc.noaa.gov/data-access/land-based-station-data/land-based-datasets/global-historical-climatology-network-monthly-version-4上提供给公众使用。
- 
我们预计气象站的平均温度会受到纬度和海拔的影响。假设我们之前的分析表明,海拔对温度的影响直到大约 1,000 米才开始显著。我们可以将 landtemps数据框分为低海拔和高海拔站,以 1,000 米为阈值。在下面的代码中,我们可以看到这给我们带来了 9,538 个低海拔站,平均温度为 12.16 摄氏度,以及 2,557 个高海拔站,平均温度为 7.58:landtemps = pd.read_csv("data/landtemps2019avgs.csv") low, high = landtemps.loc[landtemps.elevation<=1000], landtemps.loc[landtemps.elevation>1000] low.shape[0], low.avgtemp.mean() (9538, 12.161417937651676) high.shape[0], high.avgtemp.mean() (2557, 7.58321486951755)
- 
现在,我们可以在一个散点图中可视化海拔和纬度与温度之间的关系: plt.scatter(x="latabs", y="avgtemp", c="blue", data=low) plt.scatter(x="latabs", y="avgtemp", c="red", data=high) plt.legend(('low elevation', 'high elevation')) plt.xlabel("Latitude (N or S)") plt.ylabel("Average Temperature (Celsius)") plt.title("Latitude and Average Temperature in 2019") plt.show()
这产生了以下散点图:

图 2.5 – 2019 年纬度和平均温度
在这里,我们可以看到随着赤道距离(以纬度衡量)的增加,温度逐渐降低。我们还可以看到高海拔气象站(那些带有红色圆点的)通常位于低海拔站下方——也就是说,在相似的纬度上,它们的温度较低。
- 
似乎高海拔和低海拔站点之间的斜率也有至少一些差异。随着纬度的增加,高海拔站点的温度似乎下降得更快。我们可以在散点图上绘制两条回归线——一条用于高海拔站点,一条用于低海拔站点——以获得更清晰的图像。为了简化代码,让我们为低海拔和高海拔站点创建一个分类特征, elevation_group:landtemps['elevation_group'] = np.where(landtemps.elevation<=1000,'low','high') sns.lmplot(x="latabs", y="avgtemp", hue="elevation_group", palette=dict(low="blue", high="red"), legend_out=False, data=landtemps) plt.xlabel("Latitude (N or S)") plt.ylabel("Average Temperature") plt.legend(('low elevation', 'high elevation'), loc='lower left') plt.yticks(np.arange(-60, 40, step=20)) plt.title("Latitude and Average Temperature in 2019") plt.tight_layout() plt.show()
这产生了以下图形:

图 2.6 – 2019 年纬度和平均温度及回归线
在这里,我们可以看到高海拔站点的更陡峭的负斜率。
- 
如果我们想看到一个包含两个连续特征和一个连续目标的散点图,而不是像上一个例子中那样将一个特征强制二分,我们可以利用 Matplotlib 的 3D 功能: fig = plt.figure() plt.suptitle("Latitude, Temperature, and Elevation in 2019") ax = plt.axes(projection='3d') ax.set_xlabel("Elevation") ax.set_ylabel("Latitude") ax.set_zlabel("Avg Temp") ax.scatter3D(landtemps.elevation, landtemps.latabs, landtemps.avgtemp) plt.show()
这产生了以下三维散点图:

图 2.7 – 2019 年的纬度、温度和海拔
散点图是揭示连续特征之间关系的首选可视化工具。我们比仅通过相关系数更能感受到这些关系。然而,如果我们正在检查连续特征和分类特征之间的关系,我们需要一个非常不同的可视化。分组箱线图在这种情况下非常有用。在下一节中,我们将学习如何使用 Matplotlib 创建分组箱线图。
使用分组箱线图查看连续和分类特征之间的双变量关系
分组箱线图是一种被低估的视觉化工具。当我们检查连续和分类特征之间的关系时,它们非常有帮助,因为它们显示了连续特征的分布如何因分类特征的不同值而变化。
我们可以通过回到上一章中我们使用过的国家纵向调查(NLS)数据来探索这一点。NLS 对每位调查受访者有一个观测值,但收集有关教育和就业的年度数据(每年数据被捕获在不同的列中)。
数据备注
如第一章中所述,检查特征和目标分布,青年国家纵向调查由美国劳工统计局进行。可以从相应的存储库下载 SPSS、Stata 和 SAS 的单独文件。NLS 数据可以从www.nlsinfo.org/investigator/pages/search下载。
按照以下步骤创建分组箱线图:
- 
在 NLS DataFrame 的许多列中,有 highestdegree和weeksworked17,分别代表受访者获得的最高学位和 2017 年该人工作的周数。让我们看看获得不同学位的人的工作周数的分布。首先,我们必须定义一个函数gettots来获取我们想要的描述性统计。然后,我们必须使用apply将一个groupby系列对象groupby(['highestdegree'])['weeksworked17']传递给该函数:nls97 = pd.read_csv("data/nls97.csv") nls97.set_index("personid", inplace=True) def gettots(x): out = {} out['min'] = x.min() out['qr1'] = x.quantile(0.25) out['med'] = x.median() out['qr3'] = x.quantile(0.75) out['max'] = x.max() out['count'] = x.count() return pd.Series(out) nls97.groupby(['highestdegree'])['weeksworked17'].\ apply(gettots).unstack() min qr1 med qr3 max count highestdegree 0\. None 0 0 40 52 52 510 1\. GED 0 8 47 52 52 848 2\. High School 0 31 49 52 52 2,665 3\. Associates 0 42 49 52 52 593 4\. Bachelors 0 45 50 52 52 1,342 5\. Masters 0 46 50 52 52 538 6\. PhD 0 46 50 52 52 51 7\. Professional 0 47 50 52 52 97
在这里,我们可以看到没有高中学位的人的工作周数的分布与拥有学士学位或更高学位的人的分布有多么不同。对于没有学位的人,超过 25%的人在一年中工作了 0 周。对于拥有学士学位的人,即使在第 25 百分位的人,一年中也工作了 45 周。四分位距覆盖了没有学位的个人的整个分布(0 到 52),但对于拥有学士学位的个人的分布(45 到 52)只覆盖了一小部分。
我们还应该注意highestdegree的类别不平衡。在硕士学位之后,计数变得相当小,而高中学位的计数几乎是下一个最大组的两倍。在我们使用这些数据进行任何建模之前,我们可能需要合并一些类别。
- 
分组箱线图使分布差异更加明显。让我们用相同的数据创建一些。我们将使用 Seaborn 来绘制此图: import seaborn as sns myplt = sns.boxplot(x='highestdegree', y= 'weeksworked17' , data=nls97, order=sorted(nls97.highestdegree.dropna().unique())) myplt.set_title("Boxplots of Weeks Worked by Highest Degree") myplt.set_xlabel('Highest Degree Attained') myplt.set_ylabel('Weeks Worked 2017') myplt.set_xticklabels(myplt.get_xticklabels(), rotation=60, horizontalalignment='right') plt.tight_layout() plt.show()
这产生了以下图表:

图 2.8 – 按最高学位划分的每周工作箱线图
分组箱线图说明了按学位获得的每周工作时间之间的四分位距的显著差异。在副学士学位水平(美国两年制学院学位)或以上,有低于须股的值,用点表示。在副学士学位以下,箱线图没有识别出任何异常值或极端值。例如,对于没有学位的人来说,0 周工作值不是极端值,但对于拥有副学士学位或更高学位的人来说,则是。
- 
我们还可以使用分组箱线图来展示 COVID-19 病例的分布如何因地区而异。让我们也添加一个 swarmplot 来查看数据点,因为它们并不多: sns.boxplot(x='total_cases_mill', y='region', data=covidtotals) sns.swarmplot(y="region", x="total_cases_mill", data=covidtotals, size=1.5, color=".3", linewidth=0) plt.title("Boxplots of Total Cases Per Million by Region") plt.xlabel("Cases Per Million") plt.ylabel("Region") plt.tight_layout() plt.show()
这产生了以下图表:

图 2.9 – 按地区划分的每百万总病例箱线图
这些分组箱线图显示了每百万个案例的中位数如何因地区而异,从东非和东亚的低端到东欧和西欧的高端。东亚的极端高值低于西欧的第一四分位数。鉴于大多数地区的计数(国家数量)相当小,我们可能应该避免从那以后得出太多结论。
到目前为止,在本章中,我们主要关注特征之间的双变量关系,以及特征与目标之间的那些关系。我们生成的统计和可视化将指导我们将要进行的建模。我们已经开始对可能的特征、它们对目标的影响以及某些特征的分布如何随着另一个特征的值而变化有所了解。
我们将在本章剩余部分探索多元关系。在我们开始建模之前,我们希望对多个特征如何一起移动有所了解。一旦包含其他特征,某些特征是否不再重要?哪些观测值对我们的参数估计的影响大于其他观测值,这对模型拟合有什么影响?同样,哪些观测值与其他观测值不同,因为它们要么具有无效值,要么似乎在捕捉与其他观测值完全不同的现象?我们将在接下来的三个部分中开始回答这些问题。尽管我们不会在构建模型之前得到任何明确的答案,但我们可以通过预测来开始做出困难的建模决策。
使用线性回归来识别具有显著影响的数据点
发现一些观测值对我们的模型、参数估计和预测有出奇高的影响力并不罕见。这可能是或可能不是所希望的。如果这些观测值反映了与数据中的其他部分不同的社会或自然过程,那么具有显著影响力的观测值可能是有害的。例如,假设我们有一个关于飞行动物的飞行距离的数据集,这些动物几乎都是鸟类,除了关于帝王蝶的数据。如果我们使用翅膀结构作为预测迁移距离的因素,那么帝王蝶的数据可能应该被移除。
我们应该回到第一部分中提到的极端值和异常值之间的区别。我们提到,异常值可以被视为具有特征值或特征值之间关系异常的观测值,这些关系在数据中的其他部分无法解释。另一方面,极端值可能反映了特征中的自然且可解释的趋势,或者在整个数据中观察到的特征之间的相同关系。
在具有高影响力的观测值中区分异常值和极端值最为重要。回归分析中影响的一个标准度量是库克距离(Cook's D)。这给出了如果从数据中删除一个观测值,我们的预测将如何变化的度量。
让我们在本节中构建一个相对简单的多元回归模型,使用我们一直在使用的 COVID-19 数据,并为每个观测值生成一个 Cook's D 值:
- 
让我们加载 COVID-19 数据和 Matplotlib 以及 statsmodels 库: import pandas as pd import matplotlib.pyplot as plt import statsmodels.api as sm covidtotals = pd.read_csv("data/covidtotals.csv") covidtotals.set_index("iso_code", inplace=True)
- 
现在,让我们来看看每百万人口中的总病例分布以及一些可能的预测因素: xvars = ['population_density','aged_65_older', 'gdp_per_capita','diabetes_prevalence'] covidtotals[['total_cases_mill'] + xvars].\ quantile(np.arange(0.0,1.05,0.25)) total_cases_mill population_density aged_65_older\ 0.00 8.52 0.14 1.14 0.25 2,499.75 36.52 3.50 0.50 19,525.73 87.25 6.22 0.75 64,834.62 213.54 13.92 1.00 181,466.38 20,546.77 27.05 gdp_per_capita diabetes_prevalence 0.00 661.24 0.99 0.25 3,823.19 5.34 0.50 12,236.71 7.20 0.75 27,216.44 10.61 1.00 116,935.60 30.53
- 
接下来,让我们定义一个函数 getlm,该函数使用 statsmodels 运行线性回归模型并生成影响统计量,包括 Cook's D。此函数接受一个 DataFrame,目标列的名称,以及特征列的名称(通常将目标称为y,将特征称为X)。
我们将使用dropna函数删除任何特征值缺失的观测值。该函数返回估计的系数(包括pvalues),每个观测值的影响度量,以及完整的回归结果(lm):
def getlm(df, ycolname, xcolnames):
  df = df[[ycolname] + xcolnames].dropna()
  y = df[ycolname]
  X = df[xcolnames]
  X = sm.add_constant(X)
  lm = sm.OLS(y, X).fit()
  influence = lm.get_influence().summary_frame()
  coefficients = pd.DataFrame(zip(['constant'] + 
    xcolnames, lm.params, lm.pvalues),
    columns=['features','params','pvalues'])
  return coefficients, influence, lm 
- 
现在,我们可以调用 getlm函数,同时指定每百万人口中的总病例数作为目标,人口密度(每平方英里的人数),65 岁及以上的人口百分比,人均 GDP 和糖尿病患病率作为预测因素。然后,我们可以打印参数估计值。通常,我们希望查看模型的完整摘要,这可以通过lm.summary()生成。这里我们将跳过这一步,以便于理解:coefficients, influence, lm = getlm(covidtotals, 'total_cases_mill', xvars) coefficients features params pvalues 0 constant -1,076.471 0.870 1 population_density -6.906 0.030 2 aged_65_older 2,713.918 0.000 3 gdp_per_capita 0.532 0.001 4 diabetes_prevalence 736.809 0.241
人口密度、65 岁及以上人口和 GDP 的系数在 95%的水平上都是显著的(p 值小于 0.05)。人口密度的结果很有趣,因为我们的双变量分析没有揭示人口密度与每百万病例数之间的关系。系数表明,每平方英里人口增加 1 人,每百万病例数减少 6.9 点。更广泛地说,一旦我们控制了 65 岁或以上人口的比例和人均 GDP,人口密度更高的国家每百万人口病例数会更少。这可能是一个偶然的关系,也可能是一个只能通过多元分析检测到的关系。(也可能是因为人口密度与一个对每百万病例数有更大影响的特征高度相关,但这个特征被遗漏在模型之外。这将给我们一个关于人口密度的有偏系数估计。)
- 我们可以使用我们在调用getlm时创建的影响 DataFrame 来更仔细地查看那些 Cook's D 值高的观测值。定义高 Cook's D 的一种方法是用所有观测值的 Cook's D 平均值的三倍。让我们创建一个包含所有高于该阈值的值的covidtotalsoutliersDataFrame。
有 13 个国家的 Cook's D 值超过了阈值。让我们按 Cook's D 值降序打印出前五个国家。巴林和马尔代夫在病例分布的前四分之一(参见本节之前我们打印的描述性统计)中。它们的人口密度也较高,65 岁或以上的人口比例较低。在其他条件相同的情况下,根据我们的模型关于人口密度与病例之间关系的说法,我们预计这两个国家的每百万病例数会较低。然而,巴林的人均 GDP 非常高,我们的模型告诉我们这与病例数的高发有关。
新加坡和香港的人口密度极高,每百万病例数低于平均水平,尤其是香港。这两个地方单独可能就解释了人口密度系数的方向。它们的人均 GDP 也非常高,这可能会对该系数产生拖累。可能只是我们的模型不应该包括城邦这样的地区:
influencethreshold = 3*influence.cooks_d.mean()
covidtotals = covidtotals.join(influence[['cooks_d']])
covidtotalsoutliers = \
  covidtotals.loc[covidtotals.cooks_d >
  influencethreshold]
covidtotalsoutliers.shape
(13, 17)
covidtotalsoutliers[['location','total_cases_mill', 
  'cooks_d'] + xvars].sort_values(['cooks_d'],
  ascending=False).head()
     location  total_cases_mill   cooks_d    population_density\
iso_code               
BHR  Bahrain        156,793.409     0.230    1,935.907
SGP  Singapore       10,709.116     0.200    7,915.731
HKG  Hong Kong        1,593.307     0.181    7,039.714
JPN  Japan            6,420.871     0.095    347.778
MDV  Maldives       138,239.027     0.069    1,454.433
         aged_65_older  gdp_per_capita  diabetes_prevalence  
iso_code                                                      
BHR              2.372      43,290.705          16.520
SGP             12.922      85,535.383          10.990
HKG             16.303      56,054.920           8.330
JPN             27.049      39,002.223           5.720
MDV              4.120      15,183.616           9.190
- 
那么,让我们看看如果我们移除香港和新加坡,我们的回归模型估计会有什么变化: coefficients, influence, lm2 = \ getlm(covidtotals.drop(['HKG','SGP']), 'total_cases_mill', xvars) coefficients features params pvalues 0 constant -2,864.219 0.653 1 population_density 26.989 0.005 2 aged_65_older 2,669.281 0.000 3 gdp_per_capita 0.553 0.000 4 diabetes_prevalence 319.262 0.605
模型中的重大变化是人口密度系数现在已改变方向。这表明人口密度估计对异常观测值的敏感性,这些观测值的特征和目标值可能无法推广到其他数据。在这种情况下,这可能适用于像香港和新加坡这样的城邦。
使用线性回归生成影响度量是一种非常有用的技术,并且它有一个优点,那就是它相对容易解释,正如我们所看到的。然而,它确实有一个重要的缺点:它假设特征之间存在线性关系,并且特征是正态分布的。这通常并不成立。我们还需要足够理解数据中的关系,以创建标签,将每百万总病例数识别为目标。这也不总是可能的。在接下来的两个部分中,我们将探讨不做出这些假设的异常值检测机器学习算法。
使用 K 近邻算法寻找异常值
当我们拥有未标记数据时,机器学习工具可以帮助我们识别与其他观测值不同的观测值——也就是说,当没有目标或因变量时。即使选择目标和特征相对简单,也可能有助于在不假设特征之间的关系或特征分布的情况下识别异常值。
尽管我们通常使用K 近邻算法(KNN)来处理标记数据,用于分类或回归问题,我们也可以用它来识别异常观测值。这些观测值的特点是它们的价值与最近邻的价值之间差异最大。KNN 是一个非常受欢迎的算法,因为它直观、对数据的结构假设很少,并且相当灵活。KNN 的主要缺点是它不如许多其他方法高效,尤其是像线性回归这样的参数技术。我们将在第九章,K 近邻、决策树、随机森林和梯度提升回归,以及第十二章,K 近邻在分类中的应用中更详细地讨论这些优点。
我们将使用PyOD,即Python 异常值检测,来识别 COVID-19 数据中与其他国家显著不同的国家。PyOD 可以使用多种算法来识别异常值,包括 KNN。让我们开始吧:
- 
首先,我们需要从 PyOD 库中导入 KNN 模块,以及从 sklearn的预处理实用函数中导入StandardScaler。我们还加载了 COVID-19 数据:import pandas as pd from pyod.models.knn import KNN from sklearn.preprocessing import StandardScaler covidtotals = pd.read_csv("data/covidtotals.csv") covidtotals.set_index("iso_code", inplace=True)
接下来,我们需要标准化数据,这在我们的特征范围差异很大时很重要,例如从每百万总病例数和人均 GDP 超过 100,000 到糖尿病患病率和 65 岁及以上人口数不到 20。我们可以使用 scikit-learn 的标准缩放器,它将每个特征值转换为 z 分数,如下所示:

这里, 是第 j 个特征的第 i 个观测值的值,
是第 j 个特征的第 i 个观测值的值, 是特征
是特征 的均值,而
的均值,而 是该特征的标准差。
是该特征的标准差。
- 
我们可以使用缩放器仅针对我们将在模型中包含的特征,然后删除一个或多个特征缺失值的所有观测值: standardizer = StandardScaler() analysisvars =['location', 'total_cases_mill', 'total_deaths_mill','population_density', 'diabetes_prevalence', 'aged_65_older', 'gdp_per_capita'] covidanalysis = covidtotals.loc[:,analysisvars].dropna() covidanalysisstand = standardizer.fit_transform(covidanalysis.iloc[:,1:])
- 
现在,我们可以运行模型并生成预测和异常分数。首先,我们必须将 contamination设置为0.1,以表示我们希望 10%的观测值被识别为异常值。这相当随意,但不是一个坏的开始。在用fit方法运行 KNN 算法后,我们得到预测(异常值为 1,内点值为 0)和异常分数,这是预测的基础(在这种情况下,异常分数最高的前 10%将被预测为 1):clf_name = 'KNN' clf = KNN(contamination=0.1) clf.fit(covidanalysisstand) y_pred = clf.labels_ y_scores = clf.decision_scores_
- 
我们可以将预测和异常分数的两个 NumPy 数组(分别命名为 y_pred和y_scores)合并,并将它们转换为 DataFrame 的列。这使得查看异常分数的范围及其相关的预测变得更容易。有 18 个国家被识别为异常值(这是将contamination设置为0.1的结果)。异常值的异常分数在 1.77 到 9.34 之间,而内点值的分数在 0.11 到 1.74 之间:pred = pd.DataFrame(zip(y_pred, y_scores), columns=['outlier','scores'], index=covidanalysis.index) pred.outlier.value_counts() 0 156 1 18 pred.groupby(['outlier'])[['scores']].\ agg(['min','median','max']) scores min median max outlier 0 0.11 0.84 1.74 1 1.77 2.48 9.34
- 
让我们更仔细地看看异常分数最高的国家: covidanalysis = covidanalysis.join(pred).\ loc[:,analysisvars + ['scores']].\ sort_values(['scores'], ascending=False) covidanalysis.head(10) location total_cases_mill total_deaths_mill\ iso_code … SGP Singapore 10,709.12 6.15 HKG Hong Kong 1,593.31 28.28 PER Peru 62,830.48 5,876.01 QAT Qatar 77,373.61 206.87 BHR Bahrain 156,793.41 803.37 LUX Luxembourg 114,617.81 1,308.36 BRN Brunei 608.02 6.86 KWT Kuwait 86,458.62 482.14 MDV Maldives 138,239.03 394.05 ARE United Arab Emirates 65,125.17 186.75 aged_65_older gdp_per_capita scores iso_code SGP 12.92 85,535.38 9.34 HKG 16.30 56,054.92 8.03 PER 7.15 12,236.71 4.37 QAT 1.31 116,935.60 4.23 BHR 2.37 43,290.71 3.51 LUX 14.31 94,277.96 2.73 BRN 4.59 71,809.25 2.60 KWT 2.35 65,530.54 2.52 MDV 4.12 15,183.62 2.51 ARE 1.14 67,293.48 2.45
在上一节中我们确定的具有高影响力的几个地点,包括新加坡、香港、巴林和马尔代夫,它们的异常分数都很高。这进一步证明我们需要对这些国家的数据进行更仔细的审查。可能存在无效数据,或者有理论上的原因导致它们与其他数据差异很大。
与上一节中的线性模型不同,这里没有定义的目标。在这种情况下,我们包括每百万总病例数和每百万总死亡数。秘鲁在这里被识别为异常值,尽管在线性模型中并不是。这部分是因为秘鲁每百万死亡人数非常高,是数据集中的最高值(我们没有在线性回归模型中使用每百万死亡人数)。
- 
注意到日本并不在这个异常值列表中。让我们看看它的异常分数: covidanalysis.loc['JPN','scores'] 2.03
异常分数是数据集中第 15 高的。与上一节中日本第 4 高的 Cook's D 分数进行比较。
将这些结果与我们可以用 Isolation Forest 进行的类似分析进行比较是很有趣的。我们将在下一节中这样做。
注意
这只是一个简化的例子,展示了我们通常在机器学习项目中采取的方法。这里最重要的遗漏是我们对整个数据集进行分析。正如我们将在第四章“编码、转换和特征缩放”的开始部分讨论的那样,我们希望在早期就将数据分成训练集和测试集。我们将在本书的剩余章节中学习如何在机器学习管道中集成异常值检测。
使用 Isolation Forest 寻找异常值
隔离森林是一种相对较新的机器学习技术,用于识别异常值。它迅速变得流行,部分原因在于其算法被优化以寻找异常值,而不是正常值。它通过连续划分数据来找到异常值,直到数据点被孤立。需要较少划分才能孤立的数据点会获得更高的异常分数。这个过程在系统资源上表现得相当容易。在本节中,我们将学习如何使用它来检测异常的 COVID-19 病例和死亡。
- 
我们可以使用与上一节类似的分析方法,用隔离森林(Isolation Forest)而不是 KNN。让我们首先加载 scikit-learn 的 StandardScaler和IsolationForest模块,以及 COVID-19 数据:import pandas as pd import matplotlib.pyplot as plt from sklearn.preprocessing import StandardScaler from sklearn.ensemble import IsolationForest covidtotals = pd.read_csv("data/covidtotals.csv") covidtotals.set_index("iso_code", inplace=True)
- 
接下来,我们必须标准化数据: analysisvars = ['location','total_cases_mill','total_deaths_mill', 'population_density','aged_65_older','gdp_per_capita'] standardizer = StandardScaler() covidanalysis = covidtotals.loc[:, analysisvars].dropna() covidanalysisstand = standardizer.fit_transform(covidanalysis.iloc[:, 1:])
- 
现在,我们已经准备好运行我们的异常检测模型。 n_estimators参数表示要构建多少棵树。将max_features设置为1.0将使用我们所有的特征。predict方法为我们提供异常预测,对于异常值为-1。这是基于异常分数,我们可以通过decision_function获取:clf=IsolationForest(n_estimators=50, max_samples='auto', contamination=.1, max_features=1.0) clf.fit(covidanalysisstand) covidanalysis['anomaly'] = clf.predict(covidanalysisstand) covidanalysis['scores'] = clf.decision_function(covidanalysisstand) covidanalysis.anomaly.value_counts() 1 156 -1 18 Name: anomaly, dtype: int64inlier, outlier = covidanalysis.loc[covidanalysis.anomaly==1],\ covidanalysis.loc[covidanalysis.anomaly==-1] outlier[['location','total_cases_mill', 'total_deaths_mill', 'scores']].sort_values(['scores']).head(10) location total_cases_mill total_deaths_mill scores iso_code SGP Singapore 10,709.12 6.15 -0.20 HKG Hong Kong 1,593.31 28.28 -0.16 BHR Bahrain 156,793.41 803.37 -0.14 QAT Qatar 77,373.61 206.87 -0.13 PER Peru 62,830.48 5,876.01 -0.12 LUX Luxembourg 114,617.81 1,308.36 -0.09 JPN Japan 6,420.87 117.40 -0.08 MDV Maldives 138,239.03 394.05 -0.07 CZE Czechia 155,782.97 2,830.43 -0.06 MNE Montenegro 159,844.09 2,577.77 -0.03
- 
查看异常值和内属值的可视化很有帮助: fig = plt.figure() ax = plt.axes(projection='3d') ax.set_title('Isolation Forest Anomaly Detection') ax.set_zlabel("Cases Per Million (thous.)") ax.set_xlabel("GDP Per Capita (thous.)") ax.set_ylabel("Aged 65 Plus %") ax.scatter3D(inlier.gdp_per_capita/1000, inlier.aged_65_older, inlier.total_cases_mill/1000, label="inliers", c="blue") ax.scatter3D(outlier.gdp_per_capita/1000, outlier.aged_65_older, outlier.total_cases_mill/1000, label="outliers", c="red") ax.legend() plt.show()
这会产生以下图表:

图 2.10 – 隔离森林异常检测 – 人均 GDP 和每百万人口病例数
尽管我们只能通过这种可视化看到三个维度,但图表确实展示了使异常值成为异常值的一些原因。我们预计随着人均 GDP 和 65 岁及以上人口比例的增加,案例数量也会增加。我们可以看到,异常值偏离了预期的模式,其每百万人口病例数明显高于或低于具有相似 GDP 和 65 岁及以上人口值的其他国家。
摘要
在本章中,我们使用了双变量和多变量统计技术和可视化方法,以更好地理解特征之间的双变量关系。我们研究了常见的统计量,例如皮尔逊相关系数。我们还通过可视化来考察双变量关系,当两个特征都是连续变量时使用散点图,当一个特征是分类变量时使用分组箱线图。本章的最后三节探讨了用于考察关系和识别异常值的多变量技术,包括 KNN 和隔离森林等机器学习算法。
现在我们已经对数据的分布有了很好的了解,我们准备开始构建我们的特征,包括填充缺失值和编码、转换以及缩放我们的变量。这将是下一章的重点。
第三章:第三章:识别和修复缺失值
当我说,很少有看似微小且微不足道的事情像缺失值那样具有如此重大的影响时,我想我代表了许多数据科学家。我们花费大量时间担心缺失值,因为它们可能会对我们的分析产生戏剧性和出人意料的效应。这种情况最有可能发生在缺失值不是随机的情况下——也就是说,当它们与一个特征或目标相关时。例如,假设我们正在进行一项关于收入的纵向研究,但受教育程度较低的人更有可能在每年跳过收入问题。这有可能导致我们对教育参数估计的偏差。
当然,识别缺失值甚至不是战斗的一半。接下来,我们需要决定如何处理它们。我们是否要删除具有一个或多个特征缺失值的任何观测值?我们是否基于样本的统计量,如平均值,来插补一个值?或者我们是否基于更具体的统计量,如某个特定类别的平均值,来分配一个值?我们是否认为对于时间序列或纵向数据,最近的时序值可能最有意义?或者我们应该使用更复杂的多元技术来插补值,比如基于线性回归或k 近邻(KNN)?
所有这些问题的答案都是是的。在某个时候,我们将想要使用这些技术中的每一个。当我们做出关于缺失值插补的最终选择时,我们希望能够回答为什么或为什么不选择所有这些可能性。根据具体情况,每个答案都有意义。
在本章中,我们将探讨识别每个特征或目标缺失值的技巧,以及对于大量特征值缺失的观测值的策略。然后,我们将探讨插补值的策略,例如将值设置为整体平均值、给定类别的平均值或前向填充。我们还将检查用于插补缺失值的多元技术,并讨论它们何时适用。
特别地,在本章中,我们将涵盖以下主题:
- 
识别缺失值 
- 
清理缺失值 
- 
使用回归进行值插补 
- 
使用 KNN 插补 
- 
使用随机森林进行插补 
技术要求
本章将大量依赖 pandas 和 NumPy 库,但你不需要对这些库有任何先前的知识。如果你从科学发行版,如 Anaconda 或 WinPython 安装了 Python,这些库可能已经安装好了。我们还将使用statsmodels库进行线性回归,以及来自sklearn和missingpy的机器学习算法。如果你需要安装这些包中的任何一个,你可以通过在终端窗口或 Windows PowerShell 中运行pip install [package name]来安装。
识别缺失值
由于识别缺失值是分析师工作流程中如此重要的一个部分,我们使用的任何工具都需要使其能够轻松地定期检查这些值。幸运的是,pandas 使得识别缺失值变得相当简单。
我们将处理 weeksworked16 和 weeksworked17,分别代表 2016 年和 2017 年的工作周数。
注意
我们还将再次处理 COVID-19 数据。这个数据集为每个国家提供了一个观测值,指定了总 COVID-19 病例和死亡人数,以及每个国家的某些人口统计数据。
按照以下步骤来识别我们的缺失值:
- 
让我们从加载 NLS 和 COVID-19 数据开始: import pandas as pd import numpy as np nls97 = pd.read_csv("data/nls97b.csv") nls97.set_index("personid", inplace=True) covidtotals = pd.read_csv("data/covidtotals.csv") covidtotals.set_index("iso_code", inplace=True)
- 
接下来,我们计算可能用作特征的列的缺失值数量。我们可以使用 isnull方法来测试每个特征值是否缺失。如果值缺失,则返回True,如果不缺失则返回False。然后,我们可以使用sum来计算True值的数量,因为sum将每个True值视为 1,每个False值视为 0。我们使用axis=0来对每个列的行进行求和:covidtotals.shape (221, 16) demovars = ['population_density','aged_65_older', 'gdp_per_capita', 'life_expectancy', 'diabetes_prevalence'] covidtotals[demovars].isnull().sum(axis=0) population_density 15 aged_65_older 33 gdp_per_capita 28 life_expectancy 4 diabetes_prevalence 21
如我们所见,221 个国家中有 33 个国家的 aged_65_older 有空值。我们几乎对所有国家的 life_expectancy 都有数据。
- 
如果我们想要每行的缺失值数量,我们可以在求和时指定 axis=1。以下代码创建了一个 Series,demovarsmisscnt,包含每个国家人口统计特征的缺失值数量。181 个国家所有特征都有值,11 个国家缺失五个特征中的四个,三个国家缺失所有特征:demovarsmisscnt = covidtotals[demovars].isnull().sum(axis=1) demovarsmisscnt.value_counts().sort_index() 0 181 1 15 2 6 3 5 4 11 5 3 dtype: int64
- 
让我们看看有四个或更多缺失值的几个国家。这些国家的人口统计数据非常少: covidtotals.loc[demovarsmisscnt > = 4, ['location'] + demovars].sample(6, random_state=1).T iso_code FLK NIU MSR\ location Falkland Islands Niue Montserrat population_density NaN NaN NaN aged_65_older NaN NaN NaN gdp_per_capita NaN NaN NaN life_expectancy 81 74 74 diabetes_prevalence NaN NaN NaN iso_code COK SYR GGY location Cook Islands Syria Guernsey population_density NaN NaN NaN aged_65_older NaN NaN NaN gdp_per_capita NaN NaN NaN life_expectancy 76 7 NaN diabetes_prevalence NaN NaN NaN
- 
让我们也检查一下总病例和死亡病例的缺失值。29 个国家在每百万人口中的病例数有缺失值,36 个国家每百万死亡人数有缺失值: totvars = ['location','total_cases_mill','total_deaths_mill'] covidtotals[totvars].isnull().sum(axis=0) location 0 total_cases_mill 29 total_deaths_mill 36 dtype: int64
- 
我们还应该了解哪些国家同时缺失这两个数据。29 个国家同时缺失病例和死亡数据,而我们只有 185 个国家同时拥有这两个数据: totvarsmisscnt = covidtotals[totvars].isnull().sum(axis=1) totvarsmisscnt.value_counts().sort_index() 0 185 1 7 2 29 dtype: int64
有时,我们会有逻辑缺失值,需要将其转换为实际缺失值。这发生在数据集设计者使用有效值作为缺失值的代码时。这些值通常是 9、99 或 999 等数值,基于变量的允许数字位数。或者可能是一个更复杂的编码方案,其中存在不同原因导致的缺失值的代码。例如,在 NLS 数据集中,代码揭示了受访者为什么没有回答某个问题的原因:-3 是无效跳过,-4 是有效跳过,而 -5 是非访谈。
- 
NLS DataFrame 的最后四列包含受访者母亲和父亲完成的最高学历、家庭收入以及受访者出生时母亲的年龄的数据。让我们从受访者母亲完成的最高的学历开始,检查这些列的逻辑缺失值: nlsparents = nls97.iloc[:,-4:] nlsparents.shape (8984, 4) nlsparents.loc[nlsparents.motherhighgrade.between(-5, -1), 'motherhighgrade'].value_counts() -3 523 -4 165 Name: motherhighgrade, dtype: int64
- 
有 523 个无效跳过和 165 个有效跳过。让我们看看这四个特征中至少有一个非响应值的几个个体: nlsparents.loc[nlsparents.apply(lambda x: x.between( -5,-1)).any(axis=1)] motherage parentincome fatherhighgrade motherhighgrade personid 100284 22 50000 12 -3 100931 23 60200 -3 13 101122 25 -4 -3 -3 101414 27 24656 10 -3 101526 -3 79500 -4 -4 ... ... ... ... 999087 -3 121000 -4 16 999103 -3 73180 12 -4 999406 19 -4 17 15 999698 -3 13000 -4 -4 999963 29 -4 12 13 [3831 rows x 4 columns]
- 
对于我们的分析,非响应的原因并不重要。让我们只计算每个特征的非响应数量,无论非响应的原因是什么: nlsparents.apply(lambda x: x.between(-5,-1).sum()) motherage 608 parentincome 2396 fatherhighgrade 1856 motherhighgrade 688 dtype: int64
- 
在使用这些特征进行分析之前,我们应该将这些值设置为 missing。我们可以使用replace将-5 到-1 之间的所有值设置为missing。当我们检查实际缺失值时,我们得到预期的计数:nlsparents.replace(list(range(-5,0)), np.nan, inplace=True) nlsparents.isnull().sum() motherage 608 parentincome 2396 fatherhighgrade 1856 motherhighgrade 688 dtype: int64
本节展示了识别每个特征的缺失值数量以及具有大量缺失值的观测值的一些非常有用的 pandas 技术。我们还学习了如何查找逻辑缺失值并将它们转换为实际缺失值。接下来,我们将首次探讨清理缺失值。
清理缺失值
在本节中,我们将介绍一些处理缺失值的最直接方法。这包括删除存在缺失值的观测值;将样本的汇总统计量,如平均值,分配给缺失值;以及根据数据适当子集的平均值分配值:
- 
让我们加载 NLS 数据并选择一些教育数据: import pandas as pd nls97 = pd.read_csv("data/nls97b.csv") nls97.set_index("personid", inplace=True) schoolrecordlist = ['satverbal','satmath','gpaoverall','gpaenglish', 'gpamath','gpascience','highestdegree', 'highestgradecompleted'] schoolrecord = nls97[schoolrecordlist] schoolrecord.shape (8984, 8)
- 
我们可以使用在前一节中探讨的技术来识别缺失值。 schoolrecord.isnull().sum(axis=0)为我们提供了每个特征的缺失值数量。绝大多数观测值在satverbal上存在缺失值,共有 7,578 个,占 8,984 个观测值中的大部分。只有 31 个观测值在highestdegree上存在缺失值:schoolrecord.isnull().sum(axis=0) satverbal 7578 satmath 7577 gpaoverall 2980 gpaenglish 3186 gpamath 3218 gpascience 3300 highestdegree 31 highestgradecompleted 2321 dtype: int64
- 
我们可以创建一个 Series, misscnt,它指定了每个观测值的缺失特征数量,misscnt = schoolrecord.isnull().sum(axis=1)。946 个观测值在教育数据上有七个缺失值,而 11 个观测值的所有八个特征都缺失:misscnt = schoolrecord.isnull().sum(axis=1) misscnt.value_counts().sort_index() 0 1087 1 312 2 3210 3 1102 4 176 5 101 6 2039 7 946 8 11 dtype: int64
- 
让我们再看看一些具有七个或更多缺失值的观测值。看起来 highestdegree通常是唯一存在的特征,这并不令人惊讶,因为我们已经发现highestdegree很少缺失:schoolrecord.loc[misscnt>=7].head(4).T personid 101705 102061 102648 104627 satverbal NaN NaN NaN NaN satmath NaN NaN NaN NaN gpaoverall NaN NaN NaN NaN gpaenglish NaN NaN NaN NaN gpamath NaN NaN NaN NaN gpascience NaN NaN NaN NaN highestdegree 1.GED 0.None 1.GED 0.None highestgradecompleted NaN NaN NaN NaN
- 
让我们删除在八个特征中至少有七个缺失值的观测值。我们可以通过将 dropna的thresh参数设置为2来实现这一点。这将删除具有少于两个非缺失值的观测值;也就是说,0 个或 1 个非缺失值。使用dropna后,我们得到预期的观测值数量;即,8,984 - 946 - 11 = 8,027:schoolrecord = schoolrecord.dropna(thresh=2) schoolrecord.shape (8027, 8) schoolrecord.isnull().sum(axis=1).value_counts().sort_index() 0 1087 1 312 2 3210 3 1102 4 176 5 101 6 2039 dtype: int64
gpaoverall有相当数量的缺失值——即 2,980 个——尽管我们有三分之二的观测值是有效的((8,984 - 2,980)/8,984)。如果我们能很好地填充缺失值,我们可能能够将其作为特征挽救。这比仅仅删除这些观测值更可取。如果我们能避免,我们不想失去这些数据,尤其是如果缺失gpaoverall的个体在其他方面与我们预测相关的方面有所不同。
- 
最直接的方法是将 gpaoverall的整体平均值分配给缺失值。以下代码使用 pandas Series 的fillna方法将gpaoverall的所有缺失值分配给 Series 的平均值。fillna的第一个参数是你想要为所有缺失值设置的值——在这种情况下,schoolrecord.gpaoverall.mean()。请注意,我们需要记住将inplace参数设置为True以覆盖现有值:schoolrecord.gpaoverall.agg(['mean','std','count']) mean 281.84 std 61.64 count 6,004.00 Name: gpaoverall, dtype: float64 schoolrecord.gpaoverall.fillna( schoolrecord.gpaoverall.mean(), inplace=True) schoolrecord.gpaoverall.isnull().sum() 0 schoolrecord.gpaoverall.agg(['mean','std','count']) mean 281.84 std 53.30 count 8,027.00 Name: gpaoverall, dtype: float64
平均值没有变化。然而,标准差有显著下降,从 61.6 下降到 53.3。这是使用数据集的平均值填充所有缺失值的一个缺点。
- 
NLS 数据中 wageincome也有相当数量的缺失值。以下代码显示有 3,893 个观测值有缺失值:wageincome = nls97.wageincome.copy(deep=True) wageincome.isnull().sum() copy method, setting deep to True. We wouldn't normally do this but, in this case, we don't want to change the values of wageincome in the underlying DataFrame. We have avoided this here because we will demonstrate a different method of imputing values in the next couple of code blocks.
- 
我们与其将 wageincome的平均值分配给缺失值,不如使用另一种常见的值填充技术:我们可以将前一个观测值中的最近非缺失值分配给缺失值。fillna的ffill选项会为我们完成这项工作:wageincome.fillna(method='ffill', inplace=True) wageincome.head().T personid 100061 12,500 100139 120,000 100284 58,000 100292 58,000 100583 30,000 Name: wageincome, dtype: float64 wageincome.isnull().sum() 0 wageincome.agg(['mean','std','count']) mean 49,549.33 std 40,014.34 count 8,984.00 Name: wageincome, dtype: float64
- 
我们可以通过将 fillna的method参数设置为bfill来执行向后填充。这会将缺失值设置为最近的后续值。这会产生以下输出:wageincome = nls97.wageincome.copy(deep=True) wageincome.std() 40677.69679818673 wageincome.fillna(method='bfill', inplace=True) wageincome.head().T personid 100061 12,500 100139 120,000 100284 58,000 100292 30,000 100583 30,000 Name: wageincome, dtype: float64 wageincome.agg(['mean','std','count']) mean 49,419.05 std 41,111.54 count 8,984.00 Name: wageincome, dtype: float64
如果缺失值是随机分布的,那么向前填充或向后填充与使用平均值相比有一个优点:它更有可能近似该特征的非缺失值的分布。请注意,标准差并没有大幅下降。
有时候,基于相似观测值的平均值或中位数来填充值是有意义的;比如说,那些对于相关特征具有相同值的观测值。如果我们正在为特征 X1 填充值,而 X1 与 X2 相关,我们可以利用 X1 和 X2 之间的关系来为 X1 填充一个可能比数据集的平均值更有意义的值。当 X2 是分类变量时,这通常很简单。在这种情况下,我们可以为 X2 的关联值填充 X1 的平均值。
- 
在 NLS DataFrame 中,2017 年的工作周数与获得的最高学位相关。以下代码显示了工作周数的平均值如何随着学位获得而变化。工作周数的平均值是 39,但没有学位的人(28.72)要低得多,而有专业学位的人(47.20)要高得多。在这种情况下,将 28.72 分配给未获得学位的个人缺失的工作周数,而不是 39,可能是一个更好的选择: nls97.weeksworked17.mean() 39.01664167916042 nls97.groupby(['highestdegree'])['weeksworked17' ].mean() highestdegree 0\. None 28.72 1\. GED 34.59 2\. High School 38.15 3\. Associates 40.44 4\. Bachelors 43.57 5\. Masters 45.14 6\. PhD 44.31 7\. Professional 47.20 Name: weeksworked17, dtype: float64
- 
以下代码将缺失工作周数的观测值的平均工作周数分配给具有相同学位获得水平的观测值。我们通过使用 groupby创建一个按highestdegree分组的 DataFrame,即groupby(['highestdegree'])['weeksworked17']来实现这一点。然后,我们在apply中使用fillna来填充这些缺失值,用最高学位组的平均值来填充。请注意,我们确保只对最高学位不缺失的观测值进行插补,~nls97.highestdegree.isnull()。对于既缺失最高学位又缺失工作周数的观测值,我们仍然会有缺失值:nls97.loc[~nls97.highestdegree.isnull(), 'weeksworked17imp'] = nls97.loc[ ~nls97.highestdegree.isnull() ]. groupby(['highestdegree'])['weeksworked17']. apply(lambda group: group.fillna(np.mean(group))) nls97[['weeksworked17imp','weeksworked17', 'highestdegree']].head(10) weeksworked17imp weeksworked17 highestdegree personid 100061 48.00 48.00 2\. High School 100139 52.00 52.00 2\. High School 100284 0.00 0.00 0\. None 100292 43.57 NaN 4\. Bachelors 100583 52.00 52.00 2\. High School 100833 47.00 47.00 2\. High School 100931 52.00 52.00 3\. Associates 101089 52.00 52.00 2\. High School 101122 38.15 NaN 2\. High School 101132 44.00 44.00 0\. None nls97[['weeksworked17imp','weeksworked17']].\ agg(['mean','count']) weeksworked17imp weeksworked17 mean 38.52 39.02 count 8,953.00 6,670.00
这些插补策略——删除缺失值的观测值、分配数据集的平均值或中位数、使用前向或后向填充,或使用相关特征的组均值——对于许多预测分析项目来说都是可行的。当缺失值与目标不相关时,它们效果最好。当这一点成立时,插补值允许我们保留这些观测值的其他信息,而不会对我们的估计产生偏差。
然而,有时情况并非如此,需要更复杂的插补策略。接下来的几节将探讨清理缺失数据的多变量技术。
使用回归插补值
我们在上一个部分结束时,将组均值分配给缺失值,而不是整体样本均值。正如我们讨论的那样,当确定组的特征与具有缺失值的特征相关时,这很有用。使用回归来插补值在概念上与此相似,但我们通常在插补将基于两个或更多特征时使用它。
回归插补用相关特征的回归模型预测的值来替换一个特征的缺失值。这种特定类型的插补被称为确定性回归插补,因为插补值都位于回归线上,没有引入错误或随机性。
这种方法的潜在缺点是它可能会显著降低具有缺失值的特征的方差。我们可以使用随机回归插补来解决这个问题。在本节中,我们将探讨这两种方法。
NLS 数据集中的wageincome特征有几个缺失值。我们可以使用线性回归来插补值。工资收入值是 2016 年报告的 earnings:
- 让我们从再次加载 NLS 数据开始,并检查 wageincome以及可能与wageincome相关的特征是否存在缺失值。我们还会加载statsmodels库。
info 方法告诉我们,对于近 3,000 个观测值,我们缺少 wageincome 的值。其他特征的缺失值较少:
import pandas as pd
import numpy as np
import statsmodels.api as sm
nls97 = pd.read_csv("data/nls97b.csv")
nls97.set_index("personid", inplace=True)
nls97[['wageincome','highestdegree','weeksworked16',
  'parentincome']].info()
<class 'pandas.core.frame.DataFrame'>
Int64Index: 8984 entries, 100061 to 999963
Data columns (total 4 columns):
 #  Column               Non-Null Count       Dtype
--  -------               --------------      -----  
 0wageincome            5091 non-null       float64
 1  highestdegree         8953 non-null       object 
 2  weeksworked16         7068 non-null       float64
 3  parentincome8984 non-null       int64
dtypes: float64(2), int64(1), object(1)
memory usage: 350.9+ KB
- 
让我们将 highestdegree特征转换为数值。这将使我们在本节剩余部分进行的分析更容易:nls97['hdegnum'] = nls97.highestdegree.str[0:1].astype('float') nls97.groupby(['highestdegree','hdegnum']).size() highestdegree hdegnum 0\. None 0 953 1\. GED 1 1146 2\. High School 2 3667 3\. Associates 3 737 4\. Bachelors 4 1673 5\. Masters 5 603 6\. PhD 6 54 7\. Professional 7 120
- 
正如我们已经发现的,我们需要将 parentincome的逻辑缺失值替换为实际缺失值。之后,我们可以进行一些相关性分析。每个特征都与wageincome有关联,特别是hdegnum:nls97.parentincome.replace(list(range(-5,0)), np.nan, inplace=True) nls97[['wageincome','hdegnum','weeksworked16', 'parentincome']].corr() wageincome hdegnum weeksworked16 parentincome wageincome 1.00 0.40 0.18 0.27 hdegnum 0.40 1.00 0.24 0.33 weeksworked16 0.18 0.24 1.00 0.10 parentincome 0.27 0.33 0.10 1.00
- 
我们应该检查对于工资收入存在缺失值的观测值是否在某些重要方面与那些没有缺失值的观测值不同。以下代码显示,这些观测值的学位获得水平、父母收入和工作周数显著较低。这是一个整体均值分配不是最佳选择的情况: nls97['missingwageincome'] = np.where(nls97.wageincome.isnull(),1,0) nls97.groupby(['missingwageincome'])[['hdegnum', 'parentincome', 'weeksworked16']].agg(['mean', 'count']) hdegnum parentincome weeksworked16 mean count mean count mean count missingwageincome 0 2.76 5072 48,409.13 3803 48.21 5052 1 1.95 3881 43,565.87 2785 16.36 2016
- 
让我们尝试回归插补。首先,让我们进一步清理数据。我们可以用平均值替换缺失的 weeksworked16和parentincome值。我们还应该将hdegnum合并到那些获得低于大学学位、拥有大学学位和拥有研究生学位的人群中。我们可以将它们设置为虚拟变量,当它们为False或True时分别具有 0 或 1 的值。这是在回归分析中处理分类数据的一个经过验证的方法,因为它允许我们根据组别估计不同的 y 截距:nls97.weeksworked16.fillna(nls97.weeksworked16.mean(), inplace=True) nls97.parentincome.fillna(nls97.parentincome.mean(), inplace=True) nls97['degltcol'] = np.where(nls97.hdegnum<=2,1,0) nls97['degcol'] = np.where(nls97.hdegnum.between(3,4), 1,0) nls97['degadv'] = np.where(nls97.hdegnum>4,1,0)注意 scikit-learn 具有预处理功能,可以帮助我们完成这类任务。我们将在下一章中介绍其中的一些。 
- 
接下来,我们定义一个函数 getlm,用于使用statsmodels模块运行线性模型。此函数具有目标或因变量名称的参数ycolname和特征或自变量名称的参数xcolnames。大部分工作是由statsmodels的fit方法完成的;即OLS(y, X).fit():def getlm(df, ycolname, xcolnames): df = df[[ycolname] + xcolnames].dropna() y = df[ycolname] X = df[xcolnames] X = sm.add_constant(X) lm = sm.OLS(y, X).fit() coefficients = pd.DataFrame(zip(['constant'] + xcolnames,lm.params, lm.pvalues), columns = [ 'features' , 'params','pvalues']) return coefficients, lm
- 
现在,我们可以使用 getlm函数来获取参数估计和模型摘要。所有系数在 95% 的置信水平上都是正值且显著的,因为它们的pvalues小于 0.05。正如预期的那样,工资收入随着工作周数和父母收入的增加而增加。拥有大学学位与没有大学学位相比,可以增加近 16K 的收入。研究生学位甚至能将收入预测提升更多——比那些低于大学学位的人多近 37K:xvars = ['weeksworked16', 'parentincome', 'degcol', 'degadv'] coefficients, lm = getlm(nls97, 'wageincome', xvars) coefficients features params pvalues 0 constant 7,389.37 0.00 1 weeksworked16 494.07 0.00 2 parentincome 0.18 0.00 3 degcol 15,770.07 0.00 4 degadv 36,737.84 0.00
- 
我们可以使用这个模型来插补工资收入缺失处的值。由于我们的模型包含一个常数,我们需要为预测添加一个常数。我们可以将预测转换为 DataFrame,然后将其与 NLS 数据的其余部分合并。然后,我们可以创建一个新的工资收入特征, wageincomeimp,当工资收入缺失时获取预测值,否则获取原始工资收入值。让我们也看看一些预测,看看它们是否有意义:pred = lm.predict(sm.add_constant(nls97[xvars])). to_frame().rename(columns= {0: 'pred'}) nls97 = nls97.join(pred) nls97['wageincomeimp'] = np.where(nls97.wageincome.isnull(), nls97.pred, nls97.wageincome) pd.options.display.float_format = '{:,.0f}'.format nls97[['wageincomeimp','wageincome'] + xvars].head(10) wageincomeimp wageincome weeksworked16 parentincome degcol degadv personid 100061 12,500 12,500 48 7,400 0 0 100139 120,000 120,000 53 57,000 0 0 100284 58,000 58,000 47 50,000 0 0 100292 36,547 NaN 4 62,760 1 0 100583 30,000 30,000 53 18,500 0 0 100833 39,000 39,000 45 37,000 0 0 100931 56,000 56,000 53 60,200 1 0 101089 36,000 36,000 53 32,307 0 0 101122 35,151 NaN 39 46,362 0 0 101132 0 0 22 2,470 0 0
- 
我们应该查看我们预测的一些汇总统计信息,并将其与实际工资收入值进行比较。插补的工资收入特征的均值低于原始工资收入均值。这并不奇怪,因为我们已经看到,工资收入缺失的个体在正相关特征上的值较低。令人惊讶的是标准差的急剧下降。这是确定性回归插补的一个缺点: nls97[['wageincomeimp','wageincome']]. agg(['count','mean','std']) wageincomeimp wageincome count 8,984 5,091 mean 42,559 49,477 std 33,406 40,678
- 
随机回归插补基于我们模型的残差向预测中添加一个正态分布的错误。我们希望这个错误具有 0 的均值和与残差相同的方差。我们可以使用 NumPy 的正常函数来实现这一点, np.random.normal(0, lm.resid.std(), nls97.shape[0])。lm.resid.std()参数获取我们模型残差的方差。最后一个参数值,nls97.shape[0],表示要创建多少个值;在这种情况下,我们希望为数据中的每一行创建一个值。
我们可以将这些值与我们的数据合并,然后向我们的预测中添加错误,randomadd:
randomadd = np.random.normal(0, lm.resid.std(),
  nls97.shape[0])
randomadddf = pd.DataFrame(randomadd, 
  columns=['randomadd'], index=nls97.index)
nls97 = nls97.join(randomadddf)
nls97['stochasticpred'] = nls97.pred + nls97.randomadd
nls97['wageincomeimpstoc'] =
  np.where(nls97.wageincome.isnull(),
  nls97.stochasticpred, nls97.wageincome)
- 
这应该会增加方差,但不会对均值产生太大影响。让我们来确认这一点: nls97[['wageincomeimpstoc','wageincome']].agg([ 'count','mean','std']) wageincomeimpstoc wageincome count 8,984 5,091 mean 42,517 49,477 std 41,381 40,678
这似乎已经起作用了。我们的随机预测与原始工资收入特征的方差几乎相同。
回归插补是利用我们拥有的所有数据为特征插补值的好方法。它通常优于我们在上一节中检查的插补方法,尤其是在缺失值不是随机的情况下。如果我们使用随机回归插补,我们不会人为地降低我们的方差。
在我们开始使用机器学习进行这项工作之前,这是我们用于插补的多变量方法的首选。现在我们有选择使用 KNN 等算法进行这项任务,在某些情况下,这种方法比回归插补具有优势。与回归插补不同,KNN 插补不假设特征之间存在线性关系,或者那些特征是正态分布的。我们将在下一节中探讨 KNN 插补。
使用 KNN 插补
KNN 是一种流行的机器学习技术,因为它直观、易于运行,并且在特征和观测数不是很多时能产生良好的结果。出于同样的原因,它通常用于插补缺失值。正如其名称所暗示的,KNN 识别出与每个观测值特征最相似的 k 个观测值。当它用于插补缺失值时,KNN 使用最近邻来确定要使用的填充值。
我们可以使用 KNN 插补来完成与上一节回归插补相同的插补:
- 
让我们从导入 scikit-learn 的 KNNImputer并再次加载 NLS 数据开始:import pandas as pd import numpy as np from sklearn.impute import KNNImputer nls97 = pd.read_csv("data/nls97b.csv") nls97.set_index("personid", inplace=True)
- 
接下来,我们必须准备特征。我们将学位获得合并为三个类别 – 大专以下、大学和大学后学位 – 每个类别由不同的虚拟变量表示。我们还必须将父母收入的逻辑缺失值转换为实际缺失值: nls97['hdegnum'] = nls97.highestdegree.str[0:1].astype('float') nls97['degltcol'] = np.where(nls97.hdegnum<=2,1,0) nls97['degcol'] = np.where(nls97.hdegnum.between(3,4),1,0) nls97['degadv'] = np.where(nls97.hdegnum>4,1,0) nls97.parentincome.replace(list(range(-5,0)), np.nan, inplace=True)
- 
让我们创建一个只包含工资收入和一些相关特征的 DataFrame: wagedatalist = ['wageincome','weeksworked16', 'parentincome','degltcol','degcol','degadv'] wagedata = nls97[wagedatalist]
- 
现在,我们已准备好使用 KNN 插补器的 fit_transform方法来获取传递的 DataFramewagedata中所有缺失值的值。fit_transform返回一个包含wagedata中所有非缺失值以及插补值的 NumPy 数组。我们可以使用与wagedata相同的索引将此数组转换为 DataFrame。这将使在下一步中合并数据变得容易。注意 我们将在整本书中使用这种技术,当我们使用 scikit-learn 的 transform和fit_transform方法处理 NumPy 数组时。
我们需要指定用于最近邻数量的值,即 k。我们使用一个经验法则来确定 k – 将观测数的平方根除以 2 (sqrt(N)/2)。在这种情况下,k 为 47:
impKNN = KNNImputer(n_neighbors=47)
newvalues = impKNN.fit_transform(wagedata)
wagedatalistimp = ['wageincomeimp','weeksworked16imp',
  'parentincomeimp','degltcol','degcol','degadv']
wagedataimp = pd.DataFrame(newvalues,
  columns=wagedatalistimp, index=wagedata.index)
- 
现在,我们必须将插补的工资收入和工作周数列与原始 NLS 工资数据合并,并做出一些观察。请注意,使用 KNN 插补时,我们不需要对相关特征的缺失值进行任何预插补(使用回归插补时,我们将工作周数和父母收入设置为数据集的平均值)。但这确实意味着,即使没有很多信息,KNN 插补也会返回一个插补值,例如以下代码块中的 personid的101122:wagedata = wagedata.join(wagedataimp[['wageincomeimp', 'weeksworked16imp']]) wagedata[['wageincome','weeksworked16','parentincome', 'degcol','degadv','wageincomeimp']].head(10) wageincome weeksworked16 parentincome degcol degadv wageincomeimp personid 100061 12,500 48 7,400 0 0 12,500 100139 120,000 53 57,000 0 0 120,000 100284 58,000 47 50,000 0 0 58,000 100292 NaN 4 62,760 1 0 28,029 100583 30,000 53 18,500 0 0 30,000 100833 39,000 45 37,000 0 0 39,000 100931 56,000 53 60,200 1 0 56,000 101089 36,000 53 32,307 0 0 36,000 101122 NaN NaN NaN 0 0 33,977 101132 0 22 2,470 0 0 0
- 
让我们来看看原始特征和插补特征的汇总统计。不出所料,插补工资收入的平均值低于原始平均值。正如我们在上一节中发现的,缺失工资收入的观测值在学位获得、工作周数和父母收入方面较低。我们还在工资收入中失去了一些方差: wagedata[['wageincome','wageincomeimp']].agg(['count', 'mean','std']) wageincome wageincomeimp count 5,091 8,984 mean 49,477 44,781 std 40,678 32,034
KNN 填充时不假设底层数据的分布。在回归填充中,线性回归的标准假设适用——也就是说,特征之间存在线性关系,并且它们是正态分布的。如果情况不是这样,KNN 可能是更好的填充方法。
尽管有这些优点,KNN 填充确实存在局限性。首先,我们必须根据对 k 的一个良好初始假设来调整模型,有时这个假设仅基于我们对数据集大小的了解。KNN 计算成本高,可能不适合非常大的数据集。最后,当要填充的特征与预测特征之间的相关性较弱时,KNN 填充可能表现不佳。作为 KNN 填充的替代方案,随机森林填充可以帮助我们避免 KNN 和回归填充的缺点。我们将在下一节中探讨随机森林填充。
使用随机森林进行填充
随机森林是一种集成学习方法。它使用自助聚合,也称为 bagging,来提高模型精度。它通过重复多次取多棵树的平均值来进行预测,从而得到越来越好的估计。在本节中,我们将使用 MissForest 算法,这是随机森林算法的一个应用,用于寻找缺失值填充。
MissForest 首先填充缺失值的均值或众数(对于连续或分类特征分别适用),然后使用随机森林来预测值。使用这个转换后的数据集,将缺失值替换为初始预测,MissForest 生成新的预测,可能用更好的预测来替换初始预测。MissForest 通常会经历至少四次这样的迭代过程。
运行 MissForest 甚至比使用我们在上一节中使用的 KNN 填充器还要简单。我们将为之前处理过的相同工资收入数据填充值:
- 
让我们先导入 MissForest模块并加载 NLS 数据:import pandas as pd import numpy as np import sys import sklearn.neighbors._base sys.modules['sklearn.neighbors.base'] = sklearn.neighbors._base from missingpy import MissForest nls97 = pd.read_csv("data/nls97b.csv") nls97.set_index("personid", inplace=True)注意 我们需要解决 sklearn.neighbors._base名称冲突的问题,它可以是sklearn.neighbors._base或sklearn.neighbors.base,具体取决于您使用的 scikit-learn 版本。在撰写本文时,MissForest使用的是旧名称。
- 
让我们进行与上一节相同的数据清理: nls97['hdegnum'] = nls97.highestdegree.str[0:1].astype('float') nls97.parentincome.replace(list(range(-5,0)), np.nan, inplace=True) nls97['degltcol'] = np.where(nls97.hdegnum<=2,1,0) nls97['degcol'] = np.where(nls97.hdegnum.between(3,4), 1,0) nls97['degadv'] = np.where(nls97.hdegnum>4,1,0) wagedatalist = ['wageincome','weeksworked16', 'parentincome','degltcol','degcol','degadv'] wagedata = nls97[wagedatalist]
- 
现在,我们已经准备好运行 MissForest。请注意,这个过程与我们使用 KNN 填充器的过程非常相似:imputer = MissForest() newvalues = imputer.fit_transform(wagedata) wagedatalistimp = ['wageincomeimp','weeksworked16imp', 'parentincomeimp','degltcol','degcol','degadv'] wagedataimp = pd.DataFrame(newvalues, columns=wagedatalistimp , index=wagedata.index) Iteration: 0 Iteration: 1 Iteration: 2 Iteration: 3
- 
让我们查看一些填充值和一些汇总统计信息。填充值的均值较低,这是预料之中的,因为我们已经了解到缺失值不是随机分布的,拥有较低学历和较少工作周数的人更有可能缺少工资收入的数据: wagedata = wagedata.join(wagedataimp[['wageincomeimp', 'weeksworked16imp']]) wagedata[['wageincome','weeksworked16','parentincome', 'degcol','degadv','wageincomeimp']].head(10) wageincome weeksworked16 parentincome degcol degadv wageincomeimp personid 100061 12,500 48 7,400 0 0 12,500 100139 120,000 53 57,000 0 0 120,000 100284 58,000 47 50,000 0 0 58,000 100292 NaN 4 62,760 1 0 42,065 100583 30,000 53 18,500 0 0 30,000 100833 39,000 45 37,000 0 0 39,000 100931 56,000 53 60,200 1 0 56,000 101089 36,000 5 32,307 0 0 36,000 101122 NaN NaN NaN 0 0 32,384 101132 0 22 2,470 0 0 0 wagedata[['wageincome','wageincomeimp', 'weeksworked16','weeksworked16imp']].agg(['count', 'mean','std']) wageincome wageincomeimp weeksworked16 weeksworked16imp count 5,091 8,984 7,068 8,984 mean 49,477 43,140 39 37 std 40,678 34,725 21 21
MissForest使用随机森林算法生成高度准确的预测。与 KNN 不同,它不需要用 k 的初始值进行调优。它也比 KNN 计算成本更低。也许最重要的是,随机森林插补对特征之间低或非常高的相关性不太敏感,尽管在本例中这不是一个问题。
摘要
在本章中,我们探讨了缺失值插补最流行的方法,并讨论了每种方法的优缺点。分配一个总体样本均值通常不是一个好的方法,尤其是在缺失值的观测与其他观测在重要方面不同时。我们还可以显著减少我们的方差。前向填充或后向填充允许我们保持数据中的方差,但它在观测的邻近性有意义时效果最好,例如时间序列或纵向数据。在大多数非平凡情况下,我们将希望使用多元技术,例如回归、KNN 或随机森林插补。
到目前为止,我们还没有涉及到数据泄露的重要问题以及如何创建独立的训练和测试数据集。为了避免数据泄露,我们需要在开始特征工程时独立于测试数据工作训练数据。我们将在下一章更详细地研究特征工程。在那里,我们将编码、转换和缩放特征,同时也要小心地将训练数据和测试数据分开。
第二部分 – 预处理、特征选择和采样
任何进行过目标对数变换或特征缩放的人都会深刻体会到分析预处理的重要性。请举手,如果你曾经自信地认为你的模型近似于真理,但后来尝试了一个相当明显的变换,并意识到你的原始模型与真理相差有多远。编码、变换和缩放数据并非花招,尽管有时人们会有这种印象。我们应用这种预处理是因为 1)它使我们更接近捕捉现实世界的过程,2)因为许多机器学习算法在缩放数据上表现更好。
特征选择同样重要。一句好的格言是:永远不要用 N 个特征来构建模型,当 N - 1 个特征就能达到同样的效果。值得记住的是,这比拥有太多特征要复杂得多。有时拥有 3 个特征就太多,而有时拥有 103 个特征则恰到好处。问题实际上在于特征是否过于相关,以至于它们对目标的影响难以分离。当这种情况不成立时,过拟合和不稳定结果的风险会大幅增加。我们在本书的这一部分和随后的部分都特别注意特征选择。
本部分关于模型评估的章节为我们准备在本书其余部分将要进行的工作。我们详细探讨了回归和分类模型的评估,分别针对连续和分类目标。我们还学习了如何构建管道和进行交叉验证。最重要的是,我们学习了数据泄露及其如何避免。
本节包括以下章节:
- 
第四章,特征编码、变换和缩放 
- 
第五章,特征选择 
- 
第六章,为模型评估做准备 
第四章:第四章:编码、转换和缩放特征
本书的前三章重点介绍了数据清洗、探索以及如何识别缺失值和异常值。接下来的几章将深入探讨特征工程,本章将从编码、转换和缩放数据以提高机器学习模型性能的技术开始。
通常,机器学习算法需要以某种形式对变量进行编码。此外,我们的模型在缩放后通常表现更好,这样具有更高变异性的特征就不会压倒优化过程。我们将向您展示如何在使用特征范围差异很大的情况下使用不同的缩放技术。
具体来说,在本章中,我们将探讨以下主要主题:
- 
创建训练数据集并避免数据泄露 
- 
识别要删除的不相关或冗余观察结果 
- 
编码分类特征 
- 
使用中等或高基数编码特征 
- 
转换特征 
- 
分箱特征 
- 
特征缩放 
技术要求
在本章中,我们将与feature-engine和category_encoders包以及sklearn库进行大量工作。您可以使用pip安装这些包,命令为pip install feature-engine,pip install category_encoders,以及pip install scikit-learn。本章中的代码使用了sklearn的 0.24.2 版本,feature-engine的 1.1.2 版本,以及category_encoders的 2.2.2 版本。请注意,无论是pip install feature-engine还是pip install feature_engine都可以工作。
本章的所有代码都可以在 GitHub 上找到,链接为github.com/PacktPublishing/Data-Cleaning-and-Exploration-with-Machine-Learning/tree/main/4.%20PruningEncodingandRescalingFeatures。
创建训练数据集并避免数据泄露
我们模型性能的最大威胁之一是数据泄露。数据泄露发生在我们的模型被训练数据集中没有的数据所告知的情况下。有时,我们无意中用无法仅从训练数据中获取的信息帮助我们的模型训练,最终导致我们对模型准确性的评估过于乐观。
数据科学家并不真的希望这种情况发生,因此有了“泄露”这个术语。这不是一种“不要这样做”的讨论。我们都知道不要这样做。这更像是一种“我应该采取哪些步骤来避免这个问题?”的讨论。实际上,除非我们制定预防措施,否则很容易出现数据泄露。
例如,如果我们有一个特征的缺失值,我们可能会使用整个数据集的平均值来插补这些值。然而,为了验证我们的模型,我们随后将数据分为训练和测试数据集。这样我们就会意外地将来自完整数据集(即全局平均值)的信息引入到训练数据集中。
数据科学家为了避免这种情况采取的一种做法是在分析开始尽可能早地建立单独的训练和测试数据集。在交叉验证等验证技术中,这可能会变得稍微复杂一些,但在接下来的章节中,我们将介绍如何在各种情况下避免数据泄露。
我们可以使用 scikit-learn 为国家纵向青年调查数据创建训练和测试 DataFrame。
注意
国家纵向青年调查(NLS)由美国劳工统计局进行。这项调查始于 1997 年,调查对象为 1980 年至 1985 年间出生的一批人,每年进行一次年度跟踪调查,直至 2017 年。在本节中,我从调查中的数百个数据项中提取了 89 个关于成绩、就业、收入和对政府态度的变量。可以从存储库下载 SPSS、Stata 和 SAS 的单独文件。NLS 数据可以从www.nlsinfo.org/investigator/pages/search下载供公众使用。
让我们开始创建 DataFrame:
- 
首先,我们从 sklearn导入train_test_split模块并加载 NLS 数据:import pandas as pd from sklearn.model_selection import train_test_split nls97 = pd.read_csv("data/nls97b.csv") nls97.set_index("personid", inplace=True)
- 
然后,我们可以为特征( X_train和X_test)和目标(y_train和y_test)创建训练和测试 DataFrame。在本例中,wageincome是目标变量。我们将test_size参数设置为0.3,以保留 30%的观测值用于测试。请注意,我们只将使用 NLS 中的学术能力评估测试(SAT)和平均成绩点(GPA)数据:feature_cols = ['satverbal','satmath','gpascience', 'gpaenglish','gpamath','gpaoverall'] X_train, X_test, y_train, y_test = \ train_test_split(nls97[feature_cols],\ nls97[['wageincome']], test_size=0.3, \ random_state=0)
- 
让我们看看使用 train_test_split创建的训练 DataFrame。我们得到了预期的观测数,6,288,这是 NLS DataFrame 中 8,984 个观测总数的 70%:nls97.shape[0] 8984 X_train.info() <class 'pandas.core.frame.DataFrame'> Int64Index: 6288 entries, 574974 to 370933 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ------- 0 satverbal 1001 non-null float64 1 satmath 1001 non-null float64 2 gpascience 3998 non-null float64 3 gpaenglish 4078 non-null float64 4 gpamath 4056 non-null float64 5 gpaoverall 4223 non-null float64 dtypes: float64(6) memory usage: 343.9 KB y_train.info() <class 'pandas.core.frame.DataFrame'> Int64Index: 6288 entries, 574974 to 370933 Data columns (total 1 columns): # Column Non-Null Count Dtype --- ------ -------------- ------- 0 wageincome 3599 non-null float64 dtypes: float64(1) memory usage: 98.2 KB
- 
此外,让我们看看测试 DataFrame。我们得到了预期的 30%的观测总数: X_test.info() <class 'pandas.core.frame.DataFrame'> Int64Index: 2696 entries, 363170 to 629736 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ------- 0 satverbal 405 non-null float64 1 satmath 406 non-null float64 2 gpascience 1686 non-null float64 3 gpaenglish 1720 non-null float64 4 gpamath 1710 non-null float64 5 gpaoverall 1781 non-null float64 dtypes: float64(6) memory usage: 147.4 KB y_test.info() <class 'pandas.core.frame.DataFrame'> Int64Index: 2696 entries, 363170 to 629736 Data columns (total 1 columns): # Column Non-Null Count Dtype --- ------ -------------- ------- 0 wageincome 1492 non-null float64 dtypes: float64(1) memory usage: 42.1 KB
我们将在本章的其余部分使用 scikit-learn 的test_train_split来创建单独的训练和测试 DataFrame。我们将在第六章 准备模型评估中介绍构建验证测试数据集的更复杂策略。
接下来,我们开始我们的特征工程工作,通过移除明显无用的特征。这是因为它们与另一个特征具有相同的数据,或者响应中没有变化。
移除冗余或无用的特征
在数据清洗和处理的过程中,我们经常会得到不再有意义的数据。也许我们根据单个特征值对数据进行子集划分,尽管现在所有观测值都具有相同的值,我们仍然保留了该特征。或者,对于我们所使用的数据子集,两个特征具有相同的值。理想情况下,我们在数据清洗过程中捕捉到这些冗余。然而,如果我们在这个过程中没有捕捉到它们,我们可以使用开源的feature-engine包来帮助我们。
此外,可能存在高度相关的特征,我们几乎不可能构建一个能够有效使用所有这些特征的模型。feature-engine有一个名为DropCorrelatedFeatures的方法,它使得在特征高度相关时移除特征变得容易。
在本节中,我们将处理陆地温度数据,以及 NLS 数据。请注意,我们在这里只加载波兰的温度数据。
数据备注
陆地温度数据集包含了 2019 年来自全球超过 12,000 个站点的平均温度读数(以摄氏度为单位),尽管大多数站点位于美国。原始数据是从全球历史气候学网络集成数据库中检索的。它已经由美国国家海洋和大气管理局在www.ncdc.noaa.gov/data-access/land-based-station-data/land-based-datasets/global-historical-climatology-network-monthly-version-4上提供给公众使用。
让我们开始移除冗余和无用的特征:
- 
让我们从 feature_engine和sklearn模块中导入所需的模块,并加载波兰的 NLS 数据和温度数据。波兰的数据是从全球 12,000 个气象站的大数据集中提取的。我们使用dropna来删除任何缺失数据的观测值:import pandas as pd import feature_engine.selection as fesel from sklearn.model_selection import train_test_split nls97 = pd.read_csv("data/nls97b.csv") nls97.set_index("personid", inplace=True) ltpoland = pd.read_csv("data/ltpoland.csv") ltpoland.set_index("station", inplace=True) ltpoland.dropna(inplace=True)
- 
接下来,我们创建训练和测试 DataFrame,就像我们在上一节中所做的那样: feature_cols = ['satverbal','satmath','gpascience', 'gpaenglish','gpamath','gpaoverall'] X_train, X_test, y_train, y_test = \ train_test_split(nls97[feature_cols],\ nls97[['wageincome']], test_size=0.3, \ random_state=0)
- 
我们可以使用 pandas 的 corr方法来查看这些特征之间的相关性:X_train.corr() satverbal satmath gpascience gpaenglish \ satverbal 1.000 0.729 0.439 0.444 satmath 0.729 1.000 0.480 0.430 gpascience 0.439 0.480 1.000 0.672 gpaenglish 0.444 0.430 0.672 1.000 gpamath 0.375 0.518 0.606 0.600 gpaoverall 0.421 0.485 0.793 0.844 gpamath gpaoverall satverbal 0.375 0.421 satmath 0.518 0.485 gpascience 0.606 0.793 gpaenglish 0.600 0.844 gpamath 1.000 0.750 gpaoverall 0.750 1.000
在这里,gpaoverall与gpascience、gpaenglish和gpamath高度相关。corr方法默认返回皮尔逊相关系数。当我们假设特征之间存在线性关系时,这是可以的。然而,当这个假设没有意义时,我们应该考虑请求 Spearman 相关系数。我们可以通过将spearman传递给corr方法的参数来实现这一点。
- 
让我们删除与另一个特征相关性高于 0.75 的特征。我们将 0.75 传递给 DropCorrelatedFeatures的threshold参数,表示我们想要使用皮尔逊相关系数,并且我们想要通过将变量设置为None来评估所有特征。我们在训练数据上使用fit方法,然后转换训练和测试数据。info方法显示,结果训练 DataFrame(X_train_tr)除了gpaoverall以外的所有特征,gpaoverall与gpascience和gpaenglish的相关性分别为 0.793 和 0.844(DropCorrelatedFeatures将从左到右进行评估,因此如果gpamath和gpaoverall高度相关,它将删除gpaoverall。如果gpaoverall在gpamath左侧,它将删除gpamath):tr = fesel.DropCorrelatedFeatures(variables=None, method='pearson', threshold=0.75) tr.fit(X_train) X_train_tr = tr.transform(X_train) X_test_tr = tr.transform(X_test) X_train_tr.info() <class 'pandas.core.frame.DataFrame'> Int64Index: 6288 entries, 574974 to 370933 Data columns (total 5 columns): # Column Non-Null Count Dtype --- ------ -------------- ------- 0 satverbal 1001 non-null float64 1 satmath 1001 non-null float64 2 gpascience 3998 non-null float64 3 gpaenglish 4078 non-null float64 4 gpamath 4056 non-null float64 dtypes: float64(5) memory usage: 294.8 KB
通常,我们在决定删除特征之前会更仔细地评估特征。然而,有时特征选择是管道的一部分,我们需要自动化这个过程。这可以通过DropCorrelatedFeatures来实现,因为所有的feature_engine方法都可以被纳入 scikit-learn 管道。
- 
现在,让我们从波兰的陆地温度数据中创建训练和测试 DataFrame。 year的值对所有观测值都是相同的,country的值也是如此。此外,对于每个观测值,latabs的值与latitude相同:feature_cols = ['year','month','latabs', 'latitude','elevation', 'longitude','country'] X_train, X_test, y_train, y_test = \ train_test_split(ltpoland[feature_cols],\ ltpoland[['temperature']], test_size=0.3, \ random_state=0) X_train.sample(5, random_state=99) year month latabs latitude elevation longitude country station SIEDLCE 2019 11 52 52 152 22 Poland OKECIE 2019 6 52 52 110 21 Poland BALICE 2019 1 50 50 241 20 Poland BALICE 2019 7 50 50 241 20 Poland BIALYSTOK 2019 11 53 53 151 23 Poland X_train.year.value_counts() 2019 84 Name: year, dtype: int64 X_train.country.value_counts() Poland 84 Name: country, dtype: int64 (X_train.latitude!=X_train.latabs).sum() 0
- 
让我们删除在整个训练数据集中具有相同值的特征。注意,在转换后删除了 year和country:tr = fesel.DropConstantFeatures() tr.fit(X_train) X_train_tr = tr.transform(X_train) X_test_tr = tr.transform(X_test) X_train_tr.head() month latabs latitude elevation longitude station OKECIE 1 52 52 110 21 LAWICA 8 52 52 94 17 LEBA 11 55 55 2 18 SIEDLCE 10 52 52 152 22 BIALYSTOK 11 53 53 151 23
- 
让我们删除具有与其他特征相同值的特征。在这种情况下,转换删除了 latitude,因为它与latabs具有相同的值:tr = fesel.DropDuplicateFeatures() tr.fit(X_train_tr) X_train_tr = tr.transform(X_train_tr) X_train_tr.head() month latabs elevation longitude station OKECIE 1 52 110 21 LAWICA 8 52 94 17 LEBA 11 55 2 18 SIEDLCE 10 52 152 22 BIALYSTOK 11 53 151 23
这解决了 NLS 数据中我们的特征和波兰陆地温度数据中的一些明显问题。我们从包含其他 GPA 特征的 DataFrame 中删除了gpaoverall,因为它与它们高度相关。此外,我们删除了冗余数据,删除了在整个 DataFrame 中具有相同值的特征以及重复另一个特征值的特征。
本章的其余部分探讨了某些较为混乱的特征工程挑战:编码、转换、分箱和缩放。
对分类特征进行编码
我们可能需要在使用大多数机器学习算法之前对特征进行编码的几个原因。首先,这些算法通常需要数值数据。其次,当一个分类特征是用数字表示时,例如,女性为 1,男性为 2,我们需要对这些值进行编码,以便它们被识别为分类数据。第三,该特征实际上可能是有序的,具有代表某些有意义排名的离散数值。我们的模型需要捕捉这种排名。最后,一个分类特征可能具有大量值(称为高基数),我们可能希望我们的编码能够合并类别。
我们可以使用独热编码来处理具有有限值的特征,例如 15 或更少。在本节中,我们首先将介绍独热编码,然后讨论顺序编码。在下一节中,我们将探讨处理具有高基数类别特征的战略。
独热编码
对特征进行独热编码会为该特征的每个值创建一个二进制向量。因此,如果一个名为 letter 的特征有三个唯一值,A,B 和 C,独热编码会创建三个二进制向量来表示这些值。第一个二进制向量,我们可以称之为 letter_A,当 letter 的值为 A 时为 1,而当它是 B 或 C 时为 0。letter_B 和 letter_C 的编码方式类似。转换后的特征,letter_A,letter_B 和 letter_C,通常被称为虚拟变量。图 4.1 展示了独热编码:

图 4.1 – 类别特征的独热编码
NLS 数据中的许多特征适合进行独热编码。在下面的代码块中,我们将对其中一些特征进行编码:
- 
让我们从导入 feature_engine中的OneHotEncoder模块并加载数据开始。此外,我们还从 scikit-learn 中导入OrdinalEncoder模块,因为我们稍后会使用它:import pandas as pd from feature_engine.encoding import OneHotEncoder from sklearn.preprocessing import OrdinalEncoder from sklearn.model_selection import train_test_split nls97 = pd.read_csv("data/nls97b.csv") nls97.set_index("personid", inplace=True)
- 
接下来,我们为 NLS 数据创建训练和测试 DataFrame: feature_cols =['gender','maritalstatus','colenroct99'] nls97demo = nls97[['wageincome'] + feature_cols].dropna() X_demo_train, X_demo_test, y_demo_train, y_demo_test=\ train_test_split(nls97demo[feature_cols],\ nls97demo[['wageincome']], test_size=0.3, \ random_state=0)
- 
我们用于编码的一个选项是 pandas 的 get_dummies方法。我们可以用它来指示我们想要转换gender和maritalstatus特征。get_dummies为gender和maritalstatus的每个值提供一个虚拟变量。例如,gender有Female和Male的值。get_dummies创建一个特征,gender_Female,当gender为Female时为 1,而当gender为Male时为 0。当gender为Male时,gender_Male为 1 而gender_Female为 0。这是一个经过验证的方法来进行此类编码,并且多年来一直为统计学家提供了良好的服务:pd.get_dummies(X_demo_train, \ columns=['gender','maritalstatus']).head(2).T personid 736081 832734 colenroct99 1.Not enrolled 1.Not enrolled gender_Female 1 0 gender_Male 0 1 maritalstatus_Divorced 0 0 maritalstatus_Married 1 0 maritalstatus_Never-married 0 1 maritalstatus_Separated 0 0 maritalstatus_Widowed 0 0
我们没有保存由 get_dummies 创建的 DataFrame,因为在本节的后面部分,我们将使用不同的技术进行编码。
通常,我们为特征的 k 个唯一值创建 k-1 个虚拟变量。因此,如果 gender 在我们的数据中有两个值,我们只需要创建一个虚拟变量。如果我们知道 gender_Female 的值,我们也知道 gender_Male 的值;因此,后者变量是冗余的。同样,如果我们知道其他 maritalstatus 虚拟变量的值,我们也知道 maritalstatus_Divorced 的值。以这种方式创建冗余被不优雅地称为虚拟变量陷阱。为了避免这个问题,我们从每个组中删除一个虚拟变量。
注意
对于某些机器学习算法,例如线性回归,实际上需要删除一个虚拟变量。在估计线性模型的参数时,矩阵会被求逆。如果我们的模型有截距,并且所有虚拟变量都被包含在内,那么矩阵就无法求逆。
- 
我们可以将 get_dummies的drop_first参数设置为True以从每个组中删除第一个虚拟变量:pd.get_dummies(X_demo_train, \ columns=['gender','maritalstatus'], drop_first=True).head(2).T personid 736081 832734 colenroct99 1\. Not enrolled 1\. Not enrolled gender_Male 0 1 maritalstatus_Married 1 0 maritalstatus_Never-married 0 1 maritalstatus_Separated 0 0 maritalstatus_Widowed 0 0
get_dummies的一个替代方案是sklearn或feature_engine中的 one-hot 编码器。这些 one-hot 编码器有优势,它们可以轻松地集成到机器学习流程中,并且可以将从训练数据集中收集到的信息持久化到测试数据集中。
- 
让我们使用 feature_engine中的OneHotEncoder模块来进行编码。我们将drop_last设置为True以从每个组中删除一个虚拟变量。我们将编码拟合到训练数据,然后转换训练数据和测试数据:ohe = OneHotEncoder(drop_last=True, variables=['gender','maritalstatus']) ohe.fit(X_demo_train) X_demo_train_ohe = ohe.transform(X_demo_train) X_demo_test_ohe = ohe.transform(X_demo_test) X_demo_train_ohe.filter(regex='gen|mar', axis="columns").head(2).T personid 736081 832734 gender_Female 1 0 maritalstatus_Married 1 0 maritalstatus_Never-married 0 1 maritalstatus_Divorced 0 0 maritalstatus_Separated 0 0
这表明 one-hot 编码是准备名义数据供机器学习算法使用的一种相当直接的方法。但如果我们分类特征是有序的而不是名义的,那会怎样?在这种情况下,我们需要使用有序编码。
有序编码
如同在第一章中讨论的,分类特征可以是名义的或有序的。性别和婚姻状况是名义的。它们的值不表示顺序。例如,“未婚”并不比“离婚”的值高。
然而,当一个分类特征是有序的时,我们希望编码能够捕捉到值的排序。例如,如果我们有一个具有低、中、高值的特征,one-hot 编码会丢失这个排序。相反,一个具有低、中、高分别为 1、2、3 的转换特征会更好。我们可以通过有序编码来实现这一点。
NLS 数据集中的大学入学特征可以被认为是有序特征。其值范围从1. 未入学到3. 四年制大学。我们应该使用有序编码来为建模做准备。我们将在下一步做这件事:
- 
我们可以使用 sklearn的OrdinalEncoder模块来对 1999 年的大学入学特征进行编码。首先,让我们看一下编码前的colenroct99的值。这些值是字符串,但存在隐含的顺序:X_demo_train.colenroct99.unique() array(['1\. Not enrolled', '2\. 2-year college ', '3\. 4-year college'], dtype=object) X_demo_train.head() gender maritalstatus colenroct99 personid 736081 Female Married 1\. Not enrolled 832734 Male Never-married 1\. Not enrolled 453537 Male Married 1\. Not enrolled 322059 Female Divorced 1\. Not enrolled 324323 Female Married 2\. 2-year college
- 
我们可以通过将前面的数组传递给 categories参数来告诉OrdinalEncoder模块按相同的顺序对值进行排序。然后,我们可以使用fit_transform来转换大学入学字段colenroct99。(sklearn的OrdinalEncoder模块的fit_transform方法返回一个 NumPy 数组,因此我们需要使用 pandas DataFrame 方法来创建一个 DataFrame。)最后,我们将编码后的特征与训练数据中的其他特征合并:oe = OrdinalEncoder(categories=\ [X_demo_train.colenroct99.unique()]) colenr_enc = \ pd.DataFrame(oe.fit_transform(X_demo_train[['colenroct99']]), columns=['colenroct99'], index=X_demo_train.index) X_demo_train_enc = \ X_demo_train[['gender','maritalstatus']].\ join(colenr_enc)
- 
让我们看看结果 DataFrame 的几个观察结果。此外,我们还应该比较原始大学入学特征计数与转换特征计数: X_demo_train_enc.head() gender maritalstatus colenroct99 personid 736081 Female Married 0 832734 Male Never-married 0 453537 Male Married 0 322059 Female Divorced 0 324323 Female Married 1 X_demo_train.colenroct99.value_counts().sort_index() 1\. Not enrolled 3050 2\. 2-year college 142 3\. 4-year college 350 Name: colenroct99, dtype: int64 X_demo_train_enc.colenroct99.value_counts().sort_index() 0 3050 1 142 2 350 Name: colenroct99, dtype: int64
序列编码将 colenroct99 的初始值替换为从 0 到 2 的数字。现在它以许多机器学习模型可消费的形式存在,并且我们保留了有意义的排名信息。
注意
序列编码适用于非线性模型,如决策树。在线性回归模型中可能没有意义,因为这会假设在整个分布中值之间的距离具有同等意义。在本例中,这会假设从 0 到 1 的增加(即从无入学到 2 年入学)与从 1 到 2 的增加(即从 2 年入学到 4 年入学)是同一件事。
One-hot 编码和序列编码是工程化分类特征的相对直接的方法。当有更多唯一值时,处理分类特征可能更复杂。在下一节中,我们将介绍处理这些特征的一些技术。
对中等或高基数的分类特征进行编码
当我们处理具有许多唯一值的分类特征时,例如 10 个或更多,为每个值创建虚拟变量可能是不切实际的。当基数高,即具有非常多的唯一值时,某些值可能观察到的样本太少,无法为我们提供很多信息。在极端情况下,对于 ID 变量,每个值只有一个观察值。
处理中等或高基数的方法有几个。一种方法是为前 k 个类别创建虚拟变量,并将剩余的值组合到一个 其他 类别中。另一种方法是使用特征哈希,也称为哈希技巧。在本节中,我们将探讨这两种策略。我们将使用 COVID-19 数据集作为示例:
- 
让我们从 COVID-19 数据中创建训练和测试 DataFrame,并导入 feature_engine和category_encoders库:import pandas as pd from feature_engine.encoding import OneHotEncoder from category_encoders.hashing import HashingEncoder from sklearn.model_selection import train_test_split covidtotals = pd.read_csv("data/covidtotals.csv") feature_cols = ['location','population', 'aged_65_older','diabetes_prevalence','region'] covidtotals = covidtotals[['total_cases'] + feature_cols].dropna() X_train, X_test, y_train, y_test = \ train_test_split(covidtotals[feature_cols],\ covidtotals[['total_cases']], test_size=0.3, random_state=0)
特征区域有 16 个唯一值,其中前 6 个的计数为 10 或更多:
X_train.region.value_counts()
Eastern Europe  16
East Asia  12
Western Europe  12
West Africa  11
West Asia  10
East Africa  10
South America  7
South Asia  7
Central Africa  7
Southern Africa  7
Oceania / Aus  6
Caribbean  6
Central Asia  5
North Africa  4
North America  3
Central America  3
Name: region, dtype: int64
- 
我们可以再次使用 feature_engine中的OneHotEncoder模块来编码region特征。这次,我们使用top_categories参数来指示我们只想为前六个类别值创建虚拟变量。任何不属于前六个的值都将为所有虚拟变量设置 0:ohe = OneHotEncoder(top_categories=6, variables=['region']) covidtotals_ohe = ohe.fit_transform(covidtotals) covidtotals_ohe.filter(regex='location|region', axis="columns").sample(5, random_state=99).T 97 173 92 187 104 Location Israel Senegal Indonesia Sri Lanka Kenya region_Eastern Europe 0 0 0 0 0 region_Western Europe 0 0 0 0 0 region_West Africa 0 1 0 0 0 region_East Asia 0 0 1 0 0 region_West Asia 1 0 0 0 0 region_East Africa 0 0 0 0 1
当分类特征具有许多唯一值时,一种替代 one-hot 编码的方法是使用 特征哈希。
特征哈希
特征哈希将大量的唯一特征值映射到更少的虚拟变量。我们可以指定要创建的虚拟变量的数量。然而,可能出现冲突;也就是说,一些特征值可能映射到相同的虚拟变量组合。随着我们减少请求的虚拟变量数量,冲突的数量会增加。
我们可以使用 category_encoders 中的 HashingEncoder 进行特征哈希。我们使用 n_components 来表示我们想要六个虚拟变量(我们在变换之前复制了 region 特征,这样我们就可以将原始值与新的虚拟变量进行比较):
X_train['region2'] = X_train.region
he = HashingEncoder(cols=['region'], n_components=6)
X_train_enc = he.fit_transform(X_train)
X_train_enc.\
 groupby(['col_0','col_1','col_2','col_3','col_4',
   'col_5','region2']).\
    size().reset_index().rename(columns={0:'count'})
  col_0 col_1 col_2 col_3 col_4 col_5 region2         count
0   0     0     0     0     0     1   Caribbean       6
1   0     0     0     0     0     1   Central Africa  7
2   0     0     0     0     0     1   East Africa     10
3   0     0     0     0     0     1   North Africa    4
4   0     0     0     0     1     0   Central America 3
5   0     0     0     0     1     0   Eastern Europe  16
6   0     0     0     0     1     0   North America   3
7   0     0     0     0     1     0   Oceania / Aus   6
8   0     0     0     0     1     0   Southern Africa 7
9   0     0     0     0     1     0   West Asia       10
10  0     0     0     0     1     0   Western Europe  12
11  0     0     0     1     0     0   Central Asia    5
12  0     0     0     1     0     0   East Asia       12
13  0     0     0     1     0     0   South Asia      7
14  0     0     1     0     0     0   West Africa     11
15  1     0     0     0     0     0   South America   7
不幸的是,这给我们带来了大量的冲突。例如,加勒比海、中非、东非和北非都得到了相同的虚拟变量值。在这种情况下,至少使用独热编码并指定类别数量,就像我们在上一节中所做的那样,是一个更好的解决方案。
在前两节中,我们介绍了常见的编码策略:独热编码、顺序编码和特征哈希。我们的大部分分类特征在使用模型之前都需要进行某种形式的编码。然而,有时我们需要以其他方式修改我们的特征,包括变换、分箱和缩放。在接下来的三个部分中,我们将考虑我们可能需要以这种方式修改特征的原因,并探讨实现这些修改的工具。
使用数学变换
有时,我们希望使用不具有高斯分布的特征,而机器学习算法假设我们的特征是以这种方式分布的。当这种情况发生时,我们可能需要改变我们关于使用哪种算法的想法(例如,我们可以选择 KNN 而不是线性回归)或者变换我们的特征,使它们近似于高斯分布。在本节中,我们将介绍几种实现后者的策略:
- 
我们首先从 feature_engine导入变换模块,从sklearn导入train_test_split,从scipy导入stats。此外,我们使用 COVID-19 数据创建训练和测试 DataFrame:import pandas as pd from feature_engine import transformation as vt from sklearn.model_selection import train_test_split import matplotlib.pyplot as plt from scipy import stats covidtotals = pd.read_csv("data/covidtotals.csv") feature_cols = ['location','population', 'aged_65_older','diabetes_prevalence','region'] covidtotals = covidtotals[['total_cases'] + feature_cols].dropna() X_train, X_test, y_train, y_test = \ train_test_split(covidtotals[feature_cols],\ covidtotals[['total_cases']], test_size=0.3, \ random_state=0)
- 
让我们看看按国家划分的病例总数是如何分布的。我们还应该计算偏斜: y_train.total_cases.skew() 6.313169268923333 plt.hist(y_train.total_cases) plt.title("Total COVID Cases (in millions)") plt.xlabel('Cases') plt.ylabel("Number of Countries") plt.show()
这产生了以下直方图:

图 4.2 – COVID 病例总数的直方图
这说明了病例总数的极高偏斜。实际上,它看起来是对数正态分布的,考虑到有大量非常低的值和几个非常高的值,这并不令人惊讶。
注意
有关偏斜和峰度的度量方法更多信息,请参阅第一章,检查特征和目标分布。
- 
让我们尝试对数变换。我们只需要调用 LogTranformer并传递我们想要变换的特征或特征即可:tf = vt.LogTransformer(variables = ['total_cases']) y_train_tf = tf.fit_transform(y_train) y_train_tf.total_cases.skew() -1.3872728024141519 plt.hist(y_train_tf.total_cases) plt.title("Total COVID Cases (log transformation)") plt.xlabel('Cases') plt.ylabel("Number of Countries") plt.show()
这会产生以下直方图:

图 4.3 – 对数变换后的总 COVID 病例数直方图
实际上,对数变换会增加分布下端的变异性,并减少上端的变异性。这会产生一个更对称的分布。这是因为对数函数的斜率对于较小的值比较大的值更陡峭。
- 
这确实是一个很大的改进,但现在有一些负偏斜。也许 Box-Cox 变换会产生更好的结果。让我们试试: tf = vt.BoxCoxTransformer(variables = ['total_cases']) y_train_tf = tf.fit_transform(y_train) y_train_tf.total_cases.skew() 0.07333475786753735 plt.hist(y_train_tf.total_cases) plt.title("Total COVID Cases (Box-Cox transformation)") plt.xlabel('Cases') plt.ylabel("Number of Countries") plt.show()
这会产生以下图表:

图 4.4 – Box-Cox 变换后的总 COVID 病例数直方图
Box-Cox 变换确定一个介于-5 和 5 之间的 lambda 值,该值生成一个与正态分布最接近的分布。它使用以下方程进行变换:

或者

在这里, 是我们的变换特征。为了好玩,让我们看看用于变换
是我们的变换特征。为了好玩,让我们看看用于变换total_cases的 lambda 值:
stats.boxcox(y_train.total_cases)[1]
0.10435377585681517
Box-Cox 变换的 lambda 值为0.104。相比之下,具有高斯分布的特征的 lambda 值为 1.000,这意味着不需要进行变换。
现在我们转换后的总病例特征看起来很好,我们可以用它作为目标来构建模型。此外,我们可以在预测时设置我们的管道以将值恢复到原始缩放。feature_engine有其他一些变换,它们的实现方式类似于对数和 Box-Cox 变换。
特征分箱
有时,我们可能希望将一个连续特征转换为分类特征。从分布的最小值到最大值创建k个等间隔区间的过程称为分箱,或者,不那么友好的术语,离散化。分箱可以解决特征的一些重要问题:偏斜、过度峰度和异常值的存在。
等宽和等频分箱
在 COVID 病例数据中,分箱可能是一个不错的选择。让我们试试(这可能在数据集中的其他变量中也很有用,包括总死亡人数和人口,但我们现在只处理总病例数。total_cases是以下代码中的目标变量,因此它是一个列——y_train DataFrame 上的唯一列):
- 
首先,我们需要从 feature_engine导入EqualFrequencyDiscretiser和EqualWidthDiscretiser。此外,我们还需要从 COVID 数据创建训练集和测试集 DataFrame:import pandas as pd from feature_engine.discretisation import EqualFrequencyDiscretiser as efd from feature_engine.discretisation import EqualWidthDiscretiser as ewd from sklearn.preprocessing import KBinsDiscretizer from sklearn.model_selection import train_test_split covidtotals = pd.read_csv("data/covidtotals.csv") feature_cols = ['location','population', 'aged_65_older','diabetes_prevalence','region'] covidtotals = covidtotals[['total_cases'] + feature_cols].dropna() X_train, X_test, y_train, y_test = \ train_test_split(covidtotals[feature_cols],\ covidtotals[['total_cases']], test_size=0.3, random_state=0)
- 
我们可以使用 pandas 的 qcut方法和其q参数来创建 10 个相对等频的箱子:y_train['total_cases_group'] = pd.qcut(y_train.total_cases, q=10, labels=[0,1,2,3,4,5,6,7,8,9]) y_train.total_cases_group.value_counts().sort_index() 0 13 1 13 2 12 3 13 4 12 5 13 6 12 7 13 8 12 9 13 Name: total_cases_group, dtype: int64
- 
我们可以使用 EqualFrequencyDiscretiser实现相同的功能。首先,我们定义一个函数来运行转换。该函数接受一个feature_engine转换和训练集和测试集 DataFrame。它返回转换后的 DataFrame(定义函数不是必需的,但在这里这样做是有意义的,因为我们稍后会重复这些步骤):def runtransform(bt, dftrain, dftest): bt.fit(dftrain) train_bins = bt.transform(dftrain) test_bins = bt.transform(dftest) return train_bins, test_bins
- 
接下来,我们创建一个 EqualFrequencyDiscretiser转换器,并调用我们刚刚创建的runtransform函数:y_train.drop(['total_cases_group'], axis=1, inplace=True) bintransformer = efd(q=10, variables=['total_cases']) y_train_bins, y_test_bins = runtransform(bintransformer, y_train, y_test) y_train_bins.total_cases.value_counts().sort_index() 0 13 1 13 2 12 3 13 4 12 5 13 6 12 7 13 8 12 9 13 Name: total_cases, dtype: int64
这给我们带来了与qcut相同的结果,但它有一个优点,即更容易将其引入机器学习流程,因为我们使用feature_engine来生成它。等频分箱解决了偏斜和异常值问题。
注意
我们将在本书中详细探讨机器学习流程,从第六章,准备模型评估开始。在这里,关键点是特征引擎转换器可以是包含其他sklearn兼容转换器的流程的一部分,甚至包括我们自己构建的。
- 
EqualWidthDiscretiser的工作方式类似:bintransformer = ewd(bins=10, variables=['total_cases']) y_train_bins, y_test_bins = runtransform(bintransformer, y_train, y_test) y_train_bins.total_cases.value_counts().sort_index() 0 119 1 4 5 1 9 2 Name: total_cases, dtype: int64
这是一个远不如成功的转换。在分箱之前的数据中,几乎所有值都位于分布的底部,因此等宽分箱会产生相同的问题并不令人惊讶。它只产生了 4 个箱子,尽管我们请求了 10 个。
- 
让我们检查每个箱子的范围。在这里,我们可以看到由于分布顶部的观察值数量很少,等宽分箱器甚至无法构建等宽箱子: pd.options.display.float_format = '{:,.0f}'.format y_train_bins = y_train_bins.\ rename(columns={'total_cases':'total_cases_group'}).\ join(y_train) y_train_bins.groupby("total_cases_group")["total_cases"].agg(['min','max']) min max total_cases_group 0 1 3,304,135 1 3,740,567 5,856,682 5 18,909,037 18,909,037 9 30,709,557 33,770,444
尽管在这种情况下,等宽分箱是一个糟糕的选择,但很多时候它是有意义的。当数据分布更均匀或等宽在实质上有意义时,它可能很有用。
K-means 分箱
另一个选项是使用 k-means 聚类来确定箱子。k-means 算法随机选择 k 个数据点作为聚类的中心,然后将其他数据点分配到最近的聚类。计算每个聚类的平均值,并将数据点重新分配到最近的新的聚类。这个过程重复进行,直到找到最佳中心。
当使用 k-means 进行分箱时,同一聚类中的所有数据点将具有相同的序数值:
- 
我们可以使用 scikit-learn 的 KBinsDiscretizer使用 COVID 病例数据创建箱子:kbins = KBinsDiscretizer(n_bins=10, encode='ordinal', strategy='kmeans') y_train_bins = \ pd.DataFrame(kbins.fit_transform(y_train), columns=['total_cases']) y_train_bins.total_cases.value_counts().sort_index() 0 49 1 24 2 23 3 11 4 6 5 6 6 4 7 1 8 1 9 1 Name: total_cases, dtype: int64
- 
让我们比较原始总病例变量的偏斜和峰度与分箱变量的偏斜和峰度。回想一下,对于一个具有高斯分布的变量,我们预计偏斜为 0,峰度接近 3。分箱变量的分布与高斯分布非常接近: y_train.total_cases.agg(['skew','kurtosis']) skew 6.313 kurtosis 41.553 Name: total_cases, dtype: float64 y_train_bins.total_cases.agg(['skew','kurtosis']) skew 1.439 kurtosis 1.923 Name: total_cases, dtype: float64
分箱可以帮助我们解决数据中的偏斜、峰度和异常值。然而,它确实掩盖了特征中的大部分变化,并减少了其解释潜力。通常,某种形式的缩放,如最小-最大或 z 分数,是一个更好的选择。让我们接下来检查特征缩放。
特征缩放
通常,我们想在模型中使用的特征在非常不同的尺度上。简单来说,最小值和最大值之间的距离,或者说范围,在可能的特征之间有很大的变化。例如,在 COVID-19 数据中,总病例特征从 1 到近 3400 万,而 65 岁及以上的人口从 9% 到 27%(数字代表人口百分比)。
特征尺度差异很大会影响许多机器学习算法。例如,KNN 模型通常使用欧几里得距离,范围更大的特征将对模型产生更大的影响。缩放可以解决这个问题。
在本节中,我们将介绍两种流行的缩放方法:最小-最大缩放和标准(或z 分数)缩放。最小-最大缩放将每个值替换为其在范围内的位置。更确切地说,以下情况发生:
 =
 = 
在这里, 是最小-最大分数,
 是最小-最大分数, 是
 是  观测的
 观测的  特征的值,而
 特征的值,而  和
 和  是该
 是该  特征的最小值和最大值。
 特征的最小值和最大值。
标准缩放将特征值标准化到均值为 0 的周围。那些学习过本科统计学的人会将其识别为 z 分数。具体来说,如下所示:

在这里, 是
 是  特征的
 特征的  观测的值,
 观测的值, 是特征
 是特征  的均值,而
 的均值,而  是该特征的标准差。
 是该特征的标准差。
我们可以使用 scikit-learn 的预处理模块来获取最小-最大和标准缩放器:
- 
我们首先导入预处理模块,并从 COVID-19 数据中创建训练和测试 DataFrame: import pandas as pd from sklearn.model_selection import train_test_split from sklearn.preprocessing import MinMaxScaler, StandardScaler, RobustScaler covidtotals = pd.read_csv("data/covidtotals.csv") feature_cols = ['population','total_deaths', 'aged_65_older','diabetes_prevalence'] covidtotals = covidtotals[['total_cases'] + feature_cols].dropna() X_train, X_test, y_train, y_test = \ train_test_split(covidtotals[feature_cols],\ covidtotals[['total_cases']], test_size=0.3, random_state=0)
- 
现在,我们可以运行最小-最大缩放器。 sklearn的fit_transform方法将返回一个numpy数组。我们使用训练 DataFrame 的列和索引将其转换为 pandas DataFrame。注意,现在所有特征现在都有介于 0 和 1 之间的值:scaler = MinMaxScaler() X_train_mms = pd.DataFrame(scaler.fit_transform(X_train), columns=X_train.columns, index=X_train.index) X_train_mms.describe() population total_deaths aged_65_older diabetes_prevalence count 123.00 123.00 123.00 123.00 mean 0.04 0.04 0.30 0.41 std 0.13 0.14 0.24 0.23 min 0.00 0.00 0.00 0.00 25% 0.00 0.00 0.10 0.26 50% 0.01 0.00 0.22 0.37 75% 0.02 0.02 0.51 0.54 max 1.00 1.00 1.00 1.00
- 
我们以相同的方式运行标准缩放器: scaler = StandardScaler() X_train_ss = pd.DataFrame(scaler.fit_transform(X_train), columns=X_train.columns, index=X_train.index) X_train_ss.describe() population total_deaths aged_65_older diabetes_prevalence count 123.00 123.00 123.00 123.00 mean -0.00 -0.00 -0.00 -0.00 std 1.00 1.00 1.00 1.00 min -0.29 -0.32 -1.24 -1.84 25% -0.27 -0.31 -0.84 -0.69 50% -0.24 -0.29 -0.34 -0.18 75% -0.11 -0.18 0.87 0.59 max 7.58 6.75 2.93 2.63
如果我们的数据中有异常值,鲁棒缩放可能是一个不错的选择。鲁棒缩放从变量的每个值中减去中位数,并将该值除以四分位距。因此,每个值如下所示:

在这里, 是
是 特征的值,而
特征的值,而 、
、 和
和 分别是
分别是 特征的均值、第三四分位数和第一四分位数。由于鲁棒缩放不使用均值或方差,因此它对极端值不太敏感。
特征的均值、第三四分位数和第一四分位数。由于鲁棒缩放不使用均值或方差,因此它对极端值不太敏感。
- 
我们可以使用 scikit-learn 的 RobustScaler模块来进行鲁棒缩放:scaler = RobustScaler() X_train_rs = pd.DataFrame( scaler.fit_transform(X_train), columns=X_train.columns, index=X_train.index) X_train_rs.describe() population total_deaths aged_65_older diabetes_prevalence count 123.00 123.00 123.00 123.00 mean 1.47 2.22 0.20 0.14 std 6.24 7.65 0.59 0.79 min -0.35 -0.19 -0.53 -1.30 25% -0.24 -0.15 -0.30 -0.40 50% 0.00 0.00 0.00 0.00 75% 0.76 0.85 0.70 0.60 max 48.59 53.64 1.91 2.20
我们在大多数机器学习算法中使用特征缩放。尽管它不是经常必需的,但它会产生明显更好的结果。最小-最大缩放和标准缩放是流行的缩放技术,但在某些情况下,鲁棒缩放可能是更好的选择。
摘要
在本章中,我们涵盖了广泛的特征工程技术。我们使用了工具来删除冗余或高度相关的特征。我们探讨了最常见的编码类型——独热编码、顺序编码和哈希编码。在此之后,我们使用了转换来改善我们特征的分布。最后,我们使用了常见的分箱和缩放方法来解决偏斜、峰度和异常值,以及调整具有广泛不同范围的特性。
本章中我们讨论的一些技术对于大多数机器学习模型是必需的。我们几乎总是需要为算法编码我们的特征以便正确理解它们。例如,大多数算法无法理解女性或男性值,或者不知道不要将邮编视为有序值。虽然通常不是必需的,但当我们的特征具有非常不同的范围时,缩放通常是一个非常不错的想法。当我们使用假设特征具有高斯分布的算法时,可能需要对特征进行某种形式的转换,以便与该假设保持一致。
现在,我们对特征的分布有了很好的了解,已经填充了缺失值,并在必要时进行了一些特征工程。我们现在准备开始模型构建过程中最有趣和最有意义的一部分——特征选择。
在下一章中,我们将检查关键的特征选择任务,这些任务建立在到目前为止我们所做的特征清洗、探索和工程工作之上。
第五章:第五章: 特征选择
根据你开始数据分析工作和你的个人智力兴趣的不同,你可能会对特征选择这个话题有不同的看法。你可能认为,“嗯,嗯,这是一个重要的主题,但我真的想开始模型构建。”或者,在另一个极端,你可能会认为特征选择是模型构建的核心,并相信一旦你选择了特征,你就已经完成了模型构建的 90%。现在,让我们先达成共识,在我们进行任何严肃的模型指定之前,我们应该花一些时间来理解特征之间的关系——如果我们正在构建监督模型,那么它们与目标之间的关系。
以“少即是多”的态度来处理我们的特征选择工作是有帮助的。如果我们能用更少的特征达到几乎相同的准确度或解释更多的方差,我们应该选择更简单的模型。有时,我们实际上可以用更少的特征获得更好的准确度。这可能会很难理解,甚至对我们这些从构建讲述丰富和复杂故事模型的实践中成长起来的人来说有些令人失望。
但我们在拟合机器学习模型时,对参数估计的关注不如对预测准确性的关注。不必要的特征可能导致过拟合并消耗硬件资源。
有时,我们可能需要花费数月时间来指定模型的特征,即使数据中列的数量有限。例如,在第二章“检查特征与目标之间的双变量和多变量关系”中创建的双变量相关性,给我们一些预期的感觉,但一旦引入其他可能的解释特征,特征的重要性可能会显著变化。该特征可能不再显著,或者相反,只有在包含其他特征时才显著。两个特征可能高度相关,以至于包含两个特征与只包含一个特征相比,提供的额外信息非常有限。
本章将深入探讨适用于各种预测建模任务的特征选择技术。具体来说,我们将探讨以下主题:
- 
为分类模型选择特征 
- 
为回归模型选择特征 
- 
使用正向和反向特征选择 
- 
使用穷举特征选择 
- 
在回归模型中递归消除特征 
- 
在分类模型中递归消除特征 
- 
使用 Boruta 进行特征选择 
- 
使用正则化和其他嵌入式方法 
- 
使用主成分分析 
技术要求
本章中,我们将使用feature_engine、mlxtend和boruta包,以及scikit-learn库。您可以使用pip安装这些包。我选择了一个观测值数量较少的数据集用于本章的工作,因此代码即使在次优工作站上也能正常运行。
注意
在本章中,我们将专门使用美国劳工统计局进行的《青年纵向调查》数据。这项调查始于 1997 年,调查对象为 1980 年至 1985 年间出生的一代人,每年进行一次年度跟踪调查,直至 2017 年。我们将使用教育成就、家庭人口统计、工作周数和工资收入数据。工资收入列代表 2016 年赚取的工资。NLS 数据集可以下载供公众使用,网址为www.nlsinfo.org/investigator/pages/search。
为分类模型选择特征
最直接的特征选择方法是基于每个特征与目标变量的关系。接下来的两个部分将探讨基于特征与目标变量之间的线性或非线性关系来确定最佳k个特征的技术。这些被称为过滤方法。它们有时也被称为单变量方法,因为它们评估特征与目标变量之间的关系,而不考虑其他特征的影响。
当目标变量为分类变量时,我们使用的策略与目标变量为连续变量时有所不同。在本节中,我们将介绍前者,在下一节中介绍后者。
基于分类目标的互信息特征选择
当目标变量为分类变量时,我们可以使用互信息分类或方差分析(ANOVA)测试来选择特征。我们将首先尝试互信息分类,然后进行 ANOVA 比较。
互信息是衡量通过知道另一个变量的值可以获得多少关于变量的信息的度量。在极端情况下,当特征完全独立时,互信息分数为 0。
我们可以使用scikit-learn的SelectKBest类根据互信息分类或其他适当的度量选择具有最高预测强度的k个特征。我们可以使用超参数调整来选择k的值。我们还可以检查所有特征的分数,无论它们是否被识别为k个最佳特征之一,正如我们将在本节中看到的。
让我们先尝试互信息分类来识别与完成学士学位相关的特征。稍后,我们将将其与使用 ANOVA F 值作为选择依据进行比较:
- 
我们首先从 feature_engine导入OneHotEncoder来编码一些数据,并从scikit-learn导入train_test_split来创建训练和测试数据。我们还需要scikit-learn的SelectKBest、mutual_info_classif和f_classif模块来进行特征选择:import pandas as pd from feature_engine.encoding import OneHotEncoder from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.feature_selection import SelectKBest,\ mutual_info_classif, f_classif
- 
我们加载了具有完成学士学位的二进制变量和可能与学位获得相关的特征的数据集: gender特征,并对其他数据进行缩放:nls97compba = pd.read_csv("data/nls97compba.csv") feature_cols = ['gender','satverbal','satmath', 'gpascience', 'gpaenglish','gpamath','gpaoverall', 'motherhighgrade','fatherhighgrade','parentincome'] X_train, X_test, y_train, y_test = \ train_test_split(nls97compba[feature_cols],\ nls97compba[['completedba']], test_size=0.3, random_state=0) ohe = OneHotEncoder(drop_last=True, variables=['gender']) X_train_enc = ohe.fit_transform(X_train) scaler = StandardScaler() standcols = X_train_enc.iloc[:,:-1].columns X_train_enc = \ pd.DataFrame(scaler.fit_transform(X_train_enc[standcols]), columns=standcols, index=X_train_enc.index).\ join(X_train_enc[['gender_Female']])注意 在本章中,我们将对 NLS 数据进行完整案例分析;也就是说,我们将删除任何特征缺失的观测值。这通常不是一个好的方法,尤其是在数据不是随机缺失或一个或多个特征有大量缺失值时尤其有问题。在这种情况下,最好使用我们在第三章中使用的某些方法,识别和修复缺失值。我们将在本章中进行完整案例分析,以使示例尽可能简单。 
- 
现在,我们已经准备好为我们的学士学位完成模型选择特征。一种方法是用互信息分类。为此,我们将 SelectKBest的score_func值设置为mutual_info_classif,并指出我们想要五个最佳特征。然后,我们调用fit并使用get_support方法来获取五个最佳特征:ksel = SelectKBest(score_func=mutual_info_classif, k=5) ksel.fit(X_train_enc, y_train.values.ravel()) selcols = X_train_enc.columns[ksel.get_support()] selcols Index(['satverbal', 'satmath', 'gpascience', 'gpaenglish', 'gpaoverall'], dtype='object')
- 
如果我们还想看到每个特征的得分,我们可以使用 scores_属性,尽管我们需要做一些工作来将得分与特定的特征名称关联起来,并按降序排序:pd.DataFrame({'score': ksel.scores_, 'feature': X_train_enc.columns}, columns=['feature','score']).\ sort_values(['score'], ascending=False) feature score 5 gpaoverall 0.108 1 satmath 0.074 3 gpaenglish 0.072 0 satverbal 0.069 2 gpascience 0.047 4 gpamath 0.038 8 parentincome 0.024 7 fatherhighgrade 0.022 6 motherhighgrade 0.022 9 gender_Female 0.015注意 这是一个随机过程,所以每次运行它时我们都会得到不同的结果。 
为了每次都能得到相同的结果,你可以将一个部分函数传递给score_func:
from functools import partial
SelectKBest(score_func=partial(mutual_info_classif, 
                               random_state=0), k=5) 
- 
我们可以使用使用 get_support创建的selcols数组来创建仅包含重要特征的 DataFrame。(我们也可以使用SelectKBest的transform方法。这将返回所选特征的值作为 NumPy 数组。)X_train_analysis = X_train_enc[selcols] X_train_analysis.dtypes satverbal float64 satmath float64 gpascience float64 gpaenglish float64 gpaoverall float64 dtype: object
这就是我们使用互信息来选择模型中最佳 k 个特征所需做的所有事情。
使用分类目标的特征选择的 ANOVA F 值
或者,我们可以使用方差分析(ANOVA)而不是互信息。方差分析评估每个目标类中特征的平均值差异。当我们假设特征和目标之间存在线性关系,并且我们的特征是正态分布时,这是一个很好的单变量特征选择指标。如果这些假设不成立,互信息分类是一个更好的选择。
让我们尝试使用 ANOVA 进行特征选择。我们可以将SelectKBest的score_func参数设置为f_classif,以便基于 ANOVA 进行选择:
ksel = SelectKBest(score_func=f_classif, k=5)
ksel.fit(X_train_enc, y_train.values.ravel())
selcols = X_train_enc.columns[ksel.get_support()]
selcols
Index(['satverbal', 'satmath', 'gpascience', 'gpaenglish', 'gpaoverall'], dtype='object')
pd.DataFrame({'score': ksel.scores_,
  'feature': X_train_enc.columns},
   columns=['feature','score']).\
   sort_values(['score'], ascending=False)
       feature                score
5      gpaoverall           119.471
3      gpaenglish           108.006
2      gpascience            96.824
1      satmath               84.901
0      satverbal             77.363
4      gpamath               60.930
7      fatherhighgrade       37.481
6      motherhighgrade       29.377
8      parentincome          22.266
9      gender_Female         15.098
这选择了与我们使用互信息时选择的相同特征。显示得分给我们一些关于所选的k值是否合理的指示。例如,第五到第六个最佳特征的得分下降(77-61)比第四到第五个(85-77)的下降更大。然而,从第六到第七个的下降更大(61-37),这表明我们至少应该考虑k的值为 6。
ANOVA 测试和之前我们做的互信息分类没有考虑在多元分析中仅重要的特征。例如,fatherhighgrade可能在具有相似 GPA 或 SAT 分数的个人中很重要。我们将在本章后面使用多元特征选择方法。在下一节中,我们将进行更多单变量特征选择,以探索适合连续目标的特征选择技术。
选择回归模型的特征
scikit-learn的选择模块在构建回归模型时提供了几个选择特征的选择。在这里,我不指线性回归模型。我只是在指具有连续目标的模型)。两个好的选择是基于 F 检验的选择和基于回归的互信息选择。让我们从 F 检验开始。
基于连续目标的特征选择的 F 检验
F 统计量是目标与单个回归器之间线性相关强度的度量。Scikit-learn有一个f_regression评分函数,它返回 F 统计量。我们可以使用它与SelectKBest一起选择基于该统计量的特征。
让我们使用 F 统计量来选择工资模型的特征。我们将在下一节中使用互信息来选择相同目标的特征:
- 
我们首先从 feature_engine导入 one-hot 编码器,从scikit-learn导入train_test_split和SelectKBest。我们还导入f_regression以获取后续的 F 统计量:import pandas as pd import numpy as np from feature_engine.encoding import OneHotEncoder from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.feature_selection import SelectKBest, f_regression
- 
接下来,我们加载 NLS 数据,包括教育成就、家庭收入和工资收入数据: nls97wages = pd.read_csv("data/nls97wages.csv") feature_cols = ['satverbal','satmath','gpascience', 'gpaenglish','gpamath','gpaoverall','gender', 'motherhighgrade','fatherhighgrade','parentincome', 'completedba']
- 
然后,我们创建训练和测试数据框,对 gender特征进行编码,并对训练数据进行缩放。在这种情况下,我们需要对目标进行缩放,因为它是有连续性的:X_train, X_test, y_train, y_test = \ train_test_split(nls97wages[feature_cols],\ nls97wages[['wageincome']], test_size=0.3, random_state=0) ohe = OneHotEncoder(drop_last=True, variables=['gender']) X_train_enc = ohe.fit_transform(X_train) scaler = StandardScaler() standcols = X_train_enc.iloc[:,:-1].columns X_train_enc = \ pd.DataFrame(scaler.fit_transform(X_train_enc[standcols]), columns=standcols, index=X_train_enc.index).\ join(X_train_enc[['gender_Male']]) y_train = \ pd.DataFrame(scaler.fit_transform(y_train), columns=['wageincome'], index=y_train.index)注意 你可能已经注意到我们没有对测试数据进行编码或缩放。我们最终需要这样做以验证我们的模型。我们将在本章后面介绍验证,并在下一章中详细介绍。 
- 
现在,我们已准备好选择特征。我们将 SelectKBest的score_func设置为f_regression,并指出我们想要五个最佳特征。SelectKBest的get_support方法对每个被选中的特征返回True:ksel = SelectKBest(score_func=f_regression, k=5) ksel.fit(X_train_enc, y_train.values.ravel()) selcols = X_train_enc.columns[ksel.get_support()] selcols Index(['satmath', 'gpascience', 'parentincome', 'completedba','gender_Male'], dtype='object')
- 
我们可以使用 scores_属性来查看每个特征的得分:pd.DataFrame({'score': ksel.scores_, 'feature': X_train_enc.columns}, columns=['feature','score']).\ sort_values(['score'], ascending=False) feature score 1 satmath 45 9 completedba 38 10 gender_Male 26 8 parentincome 24 2 gpascience 21 0 satverbal 19 5 gpaoverall 17 4 gpamath 13 3 gpaenglish 10 6 motherhighgrade 9 7 fatherhighgrade 8
F 统计量的缺点是它假设每个特征与目标之间存在线性关系。当这个假设不合理时,我们可以使用互信息进行回归。
对于具有连续目标的特征选择中的互信息
我们还可以使用SelectKBest通过回归中的互信息来选择特征:
- 
我们需要将 SelectKBest的score_func参数设置为mutual_info_regression,但存在一个小问题。为了每次运行特征选择时都能得到相同的结果,我们需要设置一个random_state值。正如我们在前一小节中讨论的,我们可以使用一个部分函数来做到这一点。我们将partial(mutual_info_regression, random_state=0)传递给评分函数。
- 
我们可以运行 fit方法,并使用get_support来获取选定的特征。我们可以使用scores_属性来为每个特征给出分数:from functools import partial ksel = SelectKBest(score_func=\ partial(mutual_info_regression, random_state=0), k=5) ksel.fit(X_train_enc, y_train.values.ravel()) selcols = X_train_enc.columns[ksel.get_support()] selcols Index(['satmath', 'gpascience', 'fatherhighgrade', 'completedba','gender_Male'],dtype='object') pd.DataFrame({'score': ksel.scores_, 'feature': X_train_enc.columns}, columns=['feature','score']).\ sort_values(['score'], ascending=False) feature score 1 satmath 0.101 10 gender_Male 0.074 7 fatherhighgrade 0.047 2 gpascience 0.044 9 completedba 0.044 4 gpamath 0.016 8 parentincome 0.015 6 motherhighgrade 0.012 0 satverbal 0.000 3 gpaenglish 0.000 5 gpaoverall 0.000
我们在回归中的互信息得到了与 F 检验相当相似的结果。parentincome通过 F 检验被选中,而fatherhighgrade通过互信息被选中。否则,选中的特征是相同的。
与 F 检验相比,互信息在回归中的关键优势是它不假设特征与目标之间存在线性关系。如果这个假设被证明是不合理的,互信息是一个更好的方法。(再次强调,评分过程中也存在一些随机性,每个特征的分数可能会在一定范围内波动。)
注意
我们选择k=5以获取五个最佳特征是非常随意的。我们可以通过一些超参数调整使其更加科学。我们将在下一章中介绍调整。
我们迄今为止使用的特征选择方法被称为过滤器方法。它们检查每个特征与目标之间的单变量关系。它们是一个好的起点。类似于我们在前几章中讨论的,在开始检查多元关系之前,拥有相关性的有用性,至少探索过滤器方法是有帮助的。然而,通常我们的模型拟合需要考虑当其他特征也被包含时,哪些特征是重要的,哪些不是。为了做到这一点,我们需要使用包装器或嵌入式方法进行特征选择。我们将在下一节中探讨包装器方法,从前向和后向特征选择开始。
使用前向和后向特征选择
前向和后向特征选择,正如其名称所暗示的,通过逐个添加(或对于后向选择,逐个减去)特征来选择特征,并在每次迭代后评估对模型性能的影响。由于这两种方法都是基于给定的算法来评估性能,因此它们被认为是包装器选择方法。
包装特征选择方法相对于我们之前探索的过滤方法有两个优点。首先,它们在包含其他特征时评估特征的重要性。其次,由于特征是根据其对特定算法性能的贡献来评估的,因此我们能够更好地了解哪些特征最终会起作用。例如,根据我们上一节的结果,satmath似乎是一个重要的特征。但有可能satmath只有在使用特定模型时才重要,比如线性回归,而不是决策树回归等其他模型。包装选择方法可以帮助我们发现这一点。
包装方法的缺点主要在于它们在每次迭代后都会重新训练模型,因此在计算上可能相当昂贵。在本节中,我们将探讨前向和后向特征选择。
使用前向特征选择
前向特征选择首先识别出与目标有显著关系的特征子集,这与过滤方法类似。但它随后评估所有可能的选择特征的组合,以确定与所选算法表现最佳的组合。
我们可以使用前向特征选择来开发一个完成学士学位的模型。由于包装方法要求我们选择一个算法,而这是一个二元目标,因此让我们使用scikit-learn的mlxtend模块中的feature_selection来进行选择特征的迭代:
- 
我们首先导入必要的库: import pandas as pd from feature_engine.encoding import OneHotEncoder from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.ensemble import RandomForestClassifier from mlxtend.feature_selection import SequentialFeatureSelector
- 
然后,我们再次加载 NLS 数据。我们还创建了一个训练 DataFrame,对 gender特征进行编码,并对剩余特征进行标准化:nls97compba = pd.read_csv("data/nls97compba.csv") feature_cols = ['satverbal','satmath','gpascience', 'gpaenglish','gpamath','gpaoverall','gender', 'motherhighgrade','fatherhighgrade','parentincome'] X_train, X_test, y_train, y_test = \ train_test_split(nls97compba[feature_cols],\ nls97compba[['completedba']], test_size=0.3, random_state=0) ohe = OneHotEncoder(drop_last=True, variables=['gender']) X_train_enc = ohe.fit_transform(X_train) scaler = StandardScaler() standcols = X_train_enc.iloc[:,:-1].columns X_train_enc = \ pd.DataFrame(scaler.fit_transform(X_train_enc[standcols]), columns=standcols, index=X_train_enc.index).\ join(X_train_enc[['gender_Female']])
- 
我们创建一个随机森林分类器对象,然后将该对象传递给 mlxtend的特征选择器。我们指出我们想要选择五个特征,并且应该进行前向选择。(我们也可以使用顺序特征选择器进行后向选择。)运行fit后,我们可以使用k_feature_idx_属性来获取所选特征的列表:rfc = RandomForestClassifier(n_estimators=100, n_jobs=-1, random_state=0) sfs = SequentialFeatureSelector(rfc, k_features=5, forward=True, floating=False, verbose=2, scoring='accuracy', cv=5) sfs.fit(X_train_enc, y_train.values.ravel()) selcols = X_train_enc.columns[list(sfs.k_feature_idx_)] selcols Index(['satverbal', 'satmath', 'gpaoverall', 'parentincome', 'gender_Female'], dtype='object')
你可能还记得本章的第一节,我们针对完成学士学位目标的多变量特征选择给出了不同的结果:
Index(['satverbal', 'satmath', 'gpascience',
 'gpaenglish', 'gpaoverall'], dtype='object')
有三个特征——satmath、satverbal和gpaoverall——是相同的。但我们的前向特征选择已经将parentincome和gender_Female识别为比在单变量分析中选择的gpascience和gpaenglish更重要的特征。实际上,gender_Female在早期分析中的得分最低。这些差异可能反映了包装特征选择方法的优点。我们可以识别出除非包含其他特征,否则不重要的特征,并且我们正在评估对特定算法(在这种情况下是随机森林分类)性能的影响。
前向选择的缺点之一是,一旦选择了特征,它就不会被移除,即使随着更多特征的添加,它的重要性可能会下降。(回想一下,前向特征选择是基于该特征对模型的贡献迭代添加特征的。)
让我们看看我们的结果是否随着反向特征选择而变化。
使用反向特征选择
反向特征选择从所有特征开始,并消除最不重要的特征。然后,它使用剩余的特征重复此过程。我们可以使用 mlxtend 的 SequentialFeatureSelector 以与正向选择相同的方式用于反向选择。
我们从 scikit-learn 库实例化了一个 RandomForestClassifier 对象,然后将其传递给 mlxtend 的顺序特征选择器:
rfc = RandomForestClassifier(n_estimators=100, n_jobs=-1, random_state=0)
sfs = SequentialFeatureSelector(rfc, k_features=5,
  forward=False, floating=False, verbose=2,
  scoring='accuracy', cv=5)
sfs.fit(X_train_enc, y_train.values.ravel())
selcols = X_train_enc.columns[list(sfs.k_feature_idx_)]
selcols
Index(['satverbal', 'gpascience', 'gpaenglish',
 'gpaoverall', 'gender_Female'], dtype='object')
也许并不令人惊讶,我们在特征选择上得到了不同的结果。satmath 和 parentincome 不再被选中,而 gpascience 和 gpaenglish 被选中。
反向特征选择与前向特征选择的缺点相反。一旦移除了特征,它就不会被重新评估,即使其重要性可能会随着不同的特征组合而改变。让我们尝试使用穷举特征选择。
使用穷举特征选择
如果你的正向和反向选择的结果没有说服力,而且你不在意在喝咖啡或吃午餐的时候运行模型,你可以尝试穷举特征选择。穷举特征选择会在所有可能的特征组合上训练给定的模型,并选择最佳的特征子集。但这也需要付出代价。正如其名所示,这个过程可能会耗尽系统资源和你的耐心。
让我们为学士学位完成情况的模型使用穷举特征选择:
- 
我们首先加载所需的库,包括来自 scikit-learn的RandomForestClassifier和LogisticRegression模块,以及来自mlxtend的ExhaustiveFeatureSelector。我们还导入了accuracy_score模块,这样我们就可以使用选定的特征来评估模型:import pandas as pd from feature_engine.encoding import OneHotEncoder from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.ensemble import RandomForestClassifier from sklearn.linear_model import LogisticRegression from mlxtend.feature_selection import ExhaustiveFeatureSelector from sklearn.metrics import accuracy_score
- 
接下来,我们加载 NLS 教育达成度数据,并创建训练和测试 DataFrame: nls97compba = pd.read_csv("data/nls97compba.csv") feature_cols = ['satverbal','satmath','gpascience', 'gpaenglish','gpamath','gpaoverall','gender', 'motherhighgrade','fatherhighgrade','parentincome'] X_train, X_test, y_train, y_test = \ train_test_split(nls97compba[feature_cols],\ nls97compba[['completedba']], test_size=0.3, random_state=0)
- 
然后,我们对训练和测试数据进行编码和缩放: ohe = OneHotEncoder(drop_last=True, variables=['gender']) ohe.fit(X_train) X_train_enc, X_test_enc = \ ohe.transform(X_train), ohe.transform(X_test) scaler = StandardScaler() standcols = X_train_enc.iloc[:,:-1].columns scaler.fit(X_train_enc[standcols]) X_train_enc = \ pd.DataFrame(scaler.transform(X_train_enc[standcols]), columns=standcols, index=X_train_enc.index).\ join(X_train_enc[['gender_Female']]) X_test_enc = \ pd.DataFrame(scaler.transform(X_test_enc[standcols]), columns=standcols, index=X_test_enc.index).\ join(X_test_enc[['gender_Female']])
- 
我们创建了一个随机森林分类器对象,并将其传递给 mlxtend的ExhaustiveFeatureSelector。我们告诉特征选择器评估所有一至五个特征的组合,并返回预测学位达成度最高的组合。运行fit后,我们可以使用best_feature_names_属性来获取选定的特征:rfc = RandomForestClassifier(n_estimators=100, max_depth=2,n_jobs=-1, random_state=0) efs = ExhaustiveFeatureSelector(rfc, max_features=5, min_features=1, scoring='accuracy', print_progress=True, cv=5) efs.fit(X_train_enc, y_train.values.ravel()) efs.best_feature_names_ ('satverbal', 'gpascience', 'gpamath', 'gender_Female')
- 
让我们评估这个模型的准确性。我们首先需要将训练和测试数据转换为只包含四个选定的特征。然后,我们可以仅使用这些特征再次拟合随机森林分类器,并生成学士学位完成情况的预测值。然后,我们可以计算我们正确预测目标的时间百分比,这是 67%: X_train_efs = efs.transform(X_train) X_test_efs = efs.transform(X_test) rfc.fit(X_train_efs, y_train.values.ravel()) y_pred = rfc.predict(X_test_efs) confusion = pd.DataFrame(y_pred, columns=['pred'], index=y_test.index).\ join(y_test) confusion.loc[confusion.pred==confusion.completedba].shape[0]\ /confusion.shape[0] 0.6703296703296703
- 
如果我们只使用 scikit-learn 的 accuracy score,我们也会得到相同的答案。(我们在上一步计算它,因为它相当直接,并且让我们更好地理解在这种情况下准确率的含义。)accuracy_score(y_test, y_pred) 0.6703296703296703注意 准确率分数通常用于评估分类模型的性能。在本章中,我们将依赖它,但根据您模型的目的,其他指标可能同样重要或更重要。例如,我们有时更关心灵敏度,即我们的正确阳性预测与实际阳性数量的比率。我们在第六章中详细探讨了分类模型的评估,准备模型评估。 
- 
现在我们尝试使用逻辑模型进行全面特征选择: lr = LogisticRegression(solver='liblinear') efs = ExhaustiveFeatureSelector(lr, max_features=5, min_features=1, scoring='accuracy', print_progress=True, cv=5) efs.fit(X_train_enc, y_train.values.ravel()) efs.best_feature_names_ ('satmath', 'gpascience', 'gpaenglish', 'motherhighgrade', 'gender_Female')
- 
让我们看看逻辑模型的准确率。我们得到了相当相似的准确率分数: X_train_efs = efs.transform(X_train_enc) X_test_efs = efs.transform(X_test_enc) lr.fit(X_train_efs, y_train.values.ravel()) y_pred = lr.predict(X_test_efs) accuracy_score(y_test, y_pred) 0.6923076923076923
- 
逻辑模型的一个关键优势是它训练得更快,这对于全面特征选择来说确实有很大影响。如果我们为每个模型计时(除非你的电脑相当高端或者你不在乎离开电脑一会儿,否则这通常不是一个好主意),我们会看到平均训练时间有显著差异——从随机森林的惊人的 5 分钟到逻辑回归的 4 秒。(当然,这些绝对数字取决于机器。) rfc = RandomForestClassifier(n_estimators=100, max_depth=2, n_jobs=-1, random_state=0) efs = ExhaustiveFeatureSelector(rfc, max_features=5, min_features=1, scoring='accuracy', print_progress=True, cv=5) %timeit efs.fit(X_train_enc, y_train.values.ravel()) 5min 8s ± 3 s per loop (mean ± std. dev. of 7 runs, 1 loop each) lr = LogisticRegression(solver='liblinear') efs = ExhaustiveFeatureSelector(lr, max_features=5, min_features=1, scoring='accuracy', print_progress=True, cv=5) %timeit efs.fit(X_train_enc, y_train.values.ravel()) 4.29 s ± 45.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
如我所述,全面特征选择可以提供关于要选择哪些特征的非常清晰的指导,但这可能对许多项目来说代价太高。实际上,它可能更适合于诊断工作而不是用于机器学习管道。如果一个线性模型是合适的,它可以显著降低计算成本。
前向、后向和全面特征选择等包装方法会消耗系统资源,因为它们需要每次迭代时都进行训练,而选择的算法越难实现,这个问题就越严重。递归特征消除(RFE)在过滤方法的简单性和包装方法提供的信息之间是一种折衷。它与后向特征选择类似,但它在每次迭代中通过基于模型的整体性能而不是重新评估每个特征来简化特征的移除。我们将在下一节中探讨递归特征选择。
在回归模型中递归消除特征
一个流行的包装方法是 RFE。这种方法从所有特征开始,移除权重最低的一个(基于系数或特征重要性度量),然后重复此过程,直到确定最佳拟合模型。当移除一个特征时,它会得到一个反映其移除点的排名。
RFE 可以用于回归模型和分类模型。我们将从在回归模型中使用它开始:
- 
我们导入必要的库,其中三个我们尚未使用:来自 scikit-learn的RFE、RandomForestRegressor和LinearRegression模块:import pandas as pd from feature_engine.encoding import OneHotEncoder from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.feature_selection import RFE from sklearn.ensemble import RandomForestRegressor from sklearn.linear_model import LinearRegression
- 
接下来,我们加载工资的 NLS 数据并创建训练和测试 DataFrame: nls97wages = pd.read_csv("data/nls97wages.csv") feature_cols = ['satverbal','satmath','gpascience', 'gpaenglish','gpamath','gpaoverall','motherhighgrade', 'fatherhighgrade','parentincome','gender','completedba'] X_train, X_test, y_train, y_test = \ train_test_split(nls97wages[feature_cols],\ nls97wages[['weeklywage']], test_size=0.3, random_state=0)
- 
我们需要编码 gender特征并标准化其他特征以及目标(wageincome)。我们不编码或缩放二进制特征completedba:ohe = OneHotEncoder(drop_last=True, variables=['gender']) ohe.fit(X_train) X_train_enc, X_test_enc = \ ohe.transform(X_train), ohe.transform(X_test) scaler = StandardScaler() standcols = feature_cols[:-2] scaler.fit(X_train_enc[standcols]) X_train_enc = \ pd.DataFrame(scaler.transform(X_train_enc[standcols]), columns=standcols, index=X_train_enc.index).\ join(X_train_enc[['gender_Male','completedba']]) X_test_enc = \ pd.DataFrame(scaler.transform(X_test_enc[standcols]), columns=standcols, index=X_test_enc.index).\ join(X_test_enc[['gender_Male','completedba']]) scaler.fit(y_train) y_train, y_test = \ pd.DataFrame(scaler.transform(y_train), columns=['weeklywage'], index=y_train.index),\ pd.DataFrame(scaler.transform(y_test), columns=['weeklywage'], index=y_test.index)
现在,我们准备进行一些递归特征选择。由于 RFE 是一种包装方法,我们需要选择一个算法,该算法将围绕选择进行包装。在这种情况下,回归的随机森林是有意义的。我们正在模拟一个连续的目标,并且不希望假设特征和目标之间存在线性关系。
- 
使用 scikit-learn实现 RFE 比较简单。我们实例化一个 RFE 对象,在过程中指定我们想要的估计器。我们指示RandomForestRegressor。然后我们拟合模型并使用get_support获取选定的特征。我们将max_depth限制为2以避免过拟合:rfr = RandomForestRegressor(max_depth=2) treesel = RFE(estimator=rfr, n_features_to_select=5) treesel.fit(X_train_enc, y_train.values.ravel()) selcols = X_train_enc.columns[treesel.get_support()] selcols Index(['satmath', 'gpaoverall', 'parentincome', 'gender_Male', 'completedba'], dtype='object')
注意,这与使用带有 F 检验的滤波方法(针对工资收入目标)得到的特征列表略有不同。在这里选择了 gpaoverall 和 motherhighgrade,而不是 gender 标志或 gpascience。
- 
我们可以使用 ranking_属性来查看每个被消除的特征何时被移除:pd.DataFrame({'ranking': treesel.ranking_, 'feature': X_train_enc.columns}, columns=['feature','ranking']).\ sort_values(['ranking'], ascending=True) feature ranking 1 satmath 1 5 gpaoverall 1 8 parentincome 1 9 gender_Male 1 10 completedba 1 6 motherhighgrade 2 2 gpascience 3 0 satverbal 4 3 gpaenglish 5 4 gpamath 6 7 fatherhighgrade 7
在第一次交互后移除了 fatherhighgrade,在第二次交互后移除了 gpamath。
- 
让我们运行一些测试统计量。我们仅在随机森林回归器模型上拟合选定的特征。RFE 选择器的 transform方法给我们的是treesel.transform(X_train_enc)中选定的特征。我们可以使用score方法来获取 r 平方值,也称为确定系数。r 平方是我们模型解释的总变异百分比的度量。我们得到了一个非常低的分数,表明我们的模型只解释了很少的变异。(请注意,这是一个随机过程,所以我们每次拟合模型时可能会得到不同的结果。)rfr.fit(treesel.transform(X_train_enc), y_train.values.ravel()) rfr.score(treesel.transform(X_test_enc), y_test) 0.13612629794428466
- 
让我们看看使用带有线性回归模型的 RFE 是否能得到更好的结果。此模型返回与随机森林回归器相同的特征: lr = LinearRegression() lrsel = RFE(estimator=lr, n_features_to_select=5) lrsel.fit(X_train_enc, y_train) selcols = X_train_enc.columns[lrsel.get_support()] selcols Index(['satmath', 'gpaoverall', 'parentincome', 'gender_Male', 'completedba'], dtype='object')
- 
让我们评估线性模型: lr.fit(lrsel.transform(X_train_enc), y_train) lr.score(lrsel.transform(X_test_enc), y_test) 0.17773742846314056
线性模型实际上并不比随机森林模型好多少。这可能是这样一个迹象,即我们可用的特征总体上只捕捉到每周工资变异的一小部分。这是一个重要的提醒,即我们可以识别出几个显著的特征,但仍然有一个解释力有限的模型。(也许这也是一个好消息,即我们的标准化测试分数,甚至我们的学位获得,虽然重要但不是多年后我们工资的决定性因素。)
让我们尝试使用分类模型进行 RFE。
在分类模型中递归消除特征
RFE 也可以是分类问题的一个很好的选择。我们可以使用 RFE 来选择完成学士学位模型的特征。你可能还记得,我们在本章前面使用穷举特征选择来选择该模型的特征。让我们看看使用 RFE 是否能获得更高的准确率或更容易训练的模型:
- 
我们导入本章迄今为止一直在使用的相同库: import pandas as pd from feature_engine.encoding import OneHotEncoder from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.ensemble import RandomForestClassifier from sklearn.feature_selection import RFE from sklearn.metrics import accuracy_score
- 
接下来,我们从 NLS 教育成就数据中创建训练和测试数据: nls97compba = pd.read_csv("data/nls97compba.csv") feature_cols = ['satverbal','satmath','gpascience', 'gpaenglish','gpamath','gpaoverall','gender', 'motherhighgrade','fatherhighgrade','parentincome'] X_train, X_test, y_train, y_test = \ train_test_split(nls97compba[feature_cols],\ nls97compba[['completedba']], test_size=0.3, random_state=0)
- 
然后,我们编码和缩放训练和测试数据: ohe = OneHotEncoder(drop_last=True, variables=['gender']) ohe.fit(X_train) X_train_enc, X_test_enc = \ ohe.transform(X_train), ohe.transform(X_test) scaler = StandardScaler() standcols = X_train_enc.iloc[:,:-1].columns scaler.fit(X_train_enc[standcols]) X_train_enc = \ pd.DataFrame(scaler.transform(X_train_enc[standcols]), columns=standcols, index=X_train_enc.index).\ join(X_train_enc[['gender_Female']]) X_test_enc = \ pd.DataFrame(scaler.transform(X_test_enc[standcols]), columns=standcols, index=X_test_enc.index).\ join(X_test_enc[['gender_Female']])
- 
我们实例化一个随机森林分类器并将其传递给 RFE 选择方法。然后我们可以拟合模型并获取所选特征。 rfc = RandomForestClassifier(n_estimators=100, max_depth=2, n_jobs=-1, random_state=0) treesel = RFE(estimator=rfc, n_features_to_select=5) treesel.fit(X_train_enc, y_train.values.ravel()) selcols = X_train_enc.columns[treesel.get_support()] selcols Index(['satverbal', 'satmath', 'gpascience', 'gpaenglish', 'gpaoverall'], dtype='object')
- 
我们还可以使用 RFE 的 ranking_属性来展示特征的排名:pd.DataFrame({'ranking': treesel.ranking_, 'feature': X_train_enc.columns}, columns=['feature','ranking']).\ sort_values(['ranking'], ascending=True) feature ranking 0 satverbal 1 1 satmath 1 2 gpascience 1 3 gpaenglish 1 5 gpaoverall 1 4 gpamath 2 8 parentincome 3 7 fatherhighgrade 4 6 motherhighgrade 5 9 gender_Female 6
- 
让我们看看使用与我们的基线模型相同的随机森林分类器,使用所选特征的模型的准确率: rfc.fit(treesel.transform(X_train_enc), y_train.values.ravel()) y_pred = rfc.predict(treesel.transform(X_test_enc)) accuracy_score(y_test, y_pred) 0.684981684981685
回想一下,我们使用穷举特征选择获得了 67%的准确率。这里我们得到的准确率大致相同。然而,RFE 的好处是它比穷举特征选择更容易训练。
包装和类似包装特征选择方法中的另一种选择是scikit-learn集成方法。我们将在下一节中使用scikit-learn的随机森林分类器来使用它。
使用 Boruta 进行特征选择
Boruta 包在特征选择方面采用独特的方法,尽管它与包装方法有一些相似之处。对于每个特征,Boruta 创建一个影子特征,它与原始特征具有相同的值范围,但具有打乱后的值。然后它评估原始特征是否比影子特征提供更多信息,逐渐移除提供最少信息的特征。Boruta 在每个迭代中输出已确认、尝试和拒绝的特征。
让我们使用 Boruta 包来选择完成学士学位分类模型的特征(如果你还没有安装 Boruta 包,可以使用pip安装):
- 
我们首先加载必要的库: import pandas as pd from feature_engine.encoding import OneHotEncoder from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.ensemble import RandomForestClassifier from boruta import BorutaPy from sklearn.metrics import accuracy_score
- 
我们再次加载 NLS 教育成就数据并创建训练和测试 DataFrame: nls97compba = pd.read_csv("data/nls97compba.csv") feature_cols = ['satverbal','satmath','gpascience', 'gpaenglish','gpamath','gpaoverall','gender', 'motherhighgrade','fatherhighgrade','parentincome'] X_train, X_test, y_train, y_test = \ train_test_split(nls97compba[feature_cols],\ nls97compba[['completedba']], test_size=0.3, random_state=0)
- 
接下来,我们对训练和测试数据进行编码和缩放: ohe = OneHotEncoder(drop_last=True, variables=['gender']) ohe.fit(X_train) X_train_enc, X_test_enc = \ ohe.transform(X_train), ohe.transform(X_test) scaler = StandardScaler() standcols = X_train_enc.iloc[:,:-1].columns scaler.fit(X_train_enc[standcols]) X_train_enc = \ pd.DataFrame(scaler.transform(X_train_enc[standcols]), columns=standcols, index=X_train_enc.index).\ join(X_train_enc[['gender_Female']]) X_test_enc = \ pd.DataFrame(scaler.transform(X_test_enc[standcols]), columns=standcols, index=X_test_enc.index).\ join(X_test_enc[['gender_Female']])
- 
我们以与运行 RFE 特征选择相同的方式运行 Boruta 特征选择。我们再次使用随机森林作为基线方法。我们实例化一个随机森林分类器并将其传递给 Boruta 的特征选择器。然后我们拟合模型,该模型在 100次迭代后停止,识别出提供信息的9个特征:rfc = RandomForestClassifier(n_estimators=100, max_depth=2, n_jobs=-1, random_state=0) borsel = BorutaPy(rfc, random_state=0, verbose=2) borsel.fit(X_train_enc.values, y_train.values.ravel()) BorutaPy finished running. Iteration: 100 / 100 Confirmed: 9 Tentative: 1 Rejected: 0 selcols = X_train_enc.columns[borsel.support_] selcols Index(['satverbal', 'satmath', 'gpascience', 'gpaenglish', 'gpamath', 'gpaoverall', 'motherhighgrade', 'fatherhighgrade', 'parentincome', 'gender_Female'], dtype='object')
- 
我们可以使用 ranking_属性来查看特征的排名:pd.DataFrame({'ranking': borsel.ranking_, 'feature': X_train_enc.columns}, columns=['feature','ranking']).\ sort_values(['ranking'], ascending=True) feature ranking 0 satverbal 1 1 satmath 1 2 gpascience 1 3 gpaenglish 1 4 gpamath 1 5 gpaoverall 1 6 motherhighgrade 1 7 fatherhighgrade 1 8 parentincome 1 9 gender_Female 2
- 
为了评估模型的准确率,我们仅使用所选特征来拟合随机森林分类器模型。然后我们可以对测试数据进行预测并计算准确率: rfc.fit(borsel.transform(X_train_enc.values), y_train.values.ravel()) y_pred = rfc.predict(borsel.transform(X_test_enc.values)) accuracy_score(y_test, y_pred) 0.684981684981685
Boruta 的吸引力之一在于其对每个特征选择的说服力。如果一个特征被选中,那么它很可能提供了信息,这些信息不是通过排除它的特征组合所捕获的。然而,它在计算上相当昂贵,与穷举特征选择不相上下。它可以帮助我们区分哪些特征是重要的,但可能并不总是适合那些训练速度很重要的流水线。
最后几节展示了包装特征选择方法的某些优点和缺点。在下一节中,我们将探讨嵌入式选择方法。这些方法比过滤器方法提供更多信息,但又不具备包装方法的计算成本。它们通过将特征选择嵌入到训练过程中来实现这一点。我们将使用我们迄今为止所使用的数据来探讨嵌入式方法。
使用正则化和其他嵌入式方法
正则化方法是嵌入式方法。与包装方法一样,嵌入式方法根据给定的算法评估特征。但它们的计算成本并不高。这是因为特征选择已经嵌入到算法中,所以随着模型的训练而发生。
嵌入式模型使用以下过程:
- 
训练一个模型。 
- 
估计每个特征对模型预测的重要性。 
- 
移除重要性低的特征。 
正则化通过向任何模型添加惩罚来约束参数来实现这一点。L1 正则化,也称为lasso 正则化,将回归模型中的某些系数缩小到 0,从而有效地消除了这些特征。
使用 L1 正则化
- 
我们将使用 L1 正则化和逻辑回归来选择学士学位达成模型的特征:我们需要首先导入所需的库,包括我们将首次使用的模块, scikit-learn中的SelectFromModel:import pandas as pd from feature_engine.encoding import OneHotEncoder from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier from sklearn.feature_selection import SelectFromModel from sklearn.metrics import accuracy_score
- 
接下来,我们加载关于教育成就的 NLS 数据: nls97compba = pd.read_csv("data/nls97compba.csv") feature_cols = ['satverbal','satmath','gpascience', 'gpaenglish','gpamath','gpaoverall','gender', 'motherhighgrade','fatherhighgrade','parentincome'] X_train, X_test, y_train, y_test = \ train_test_split(nls97compba[feature_cols],\ nls97compba[['completedba']], test_size=0.3, random_state=0)
- 
然后,我们对训练数据和测试数据进行编码和缩放: ohe = OneHotEncoder(drop_last=True, variables=['gender']) ohe.fit(X_train) X_train_enc, X_test_enc = \ ohe.transform(X_train), ohe.transform(X_test) scaler = StandardScaler() standcols = X_train_enc.iloc[:,:-1].columns scaler.fit(X_train_enc[standcols]) X_train_enc = \ pd.DataFrame(scaler.transform(X_train_enc[standcols]), columns=standcols, index=X_train_enc.index).\ join(X_train_enc[['gender_Female']]) X_test_enc = \ pd.DataFrame(scaler.transform(X_test_enc[standcols]), columns=standcols, index=X_test_enc.index).\ join(X_test_enc[['gender_Female']])
- 
现在,我们准备根据逻辑回归和 L1 惩罚进行特征选择: lr = LogisticRegression(C=1, penalty="l1", solver='liblinear') regsel = SelectFromModel(lr, max_features=5) regsel.fit(X_train_enc, y_train.values.ravel()) selcols = X_train_enc.columns[regsel.get_support()] selcols Index(['satmath', 'gpascience', 'gpaoverall', 'fatherhighgrade', 'gender_Female'], dtype='object')
- 
让我们来评估模型的准确性。我们得到了一个准确率分数为 0.68:lr.fit(regsel.transform(X_train_enc), y_train.values.ravel()) y_pred = lr.predict(regsel.transform(X_test_enc)) accuracy_score(y_test, y_pred) 0.684981684981685
这给我们带来了与学士学位完成的前向特征选择相当相似的结果。在那个例子中,我们使用随机森林分类器作为包装方法。
在这种情况下,Lasso 正则化是特征选择的一个好选择,尤其是当性能是一个关键关注点时。然而,它确实假设特征与目标之间存在线性关系,这可能并不合适。幸运的是,有一些嵌入式特征选择方法不做出这种假设。对于嵌入式模型来说,逻辑回归的一个好替代品是随机森林分类器。我们将使用相同的数据尝试这种方法。
使用随机森林分类器
在本节中,我们将使用随机森林分类器:
- 
我们可以使用 SelectFromModel来使用随机森林分类器而不是逻辑回归:rfc = RandomForestClassifier(n_estimators=100, max_depth=2, n_jobs=-1, random_state=0) rfcsel = SelectFromModel(rfc, max_features=5) rfcsel.fit(X_train_enc, y_train.values.ravel()) selcols = X_train_enc.columns[rfcsel.get_support()] selcols Index(['satverbal', 'gpascience', 'gpaenglish', 'gpaoverall'], dtype='object')
这实际上选择与 lasso 回归非常不同的特征。satmath、fatherhighgrade和gender_Female不再被选中,而satverbal和gpaenglish被选中。这很可能部分是由于线性假设的放宽。
- 
让我们评估随机森林分类器模型的准确性。我们得到了0.67的准确率。这几乎与我们在 lasso 回归中得到的分数相同: rfc.fit(rfcsel.transform(X_train_enc), y_train.values.ravel()) y_pred = rfc.predict(rfcsel.transform(X_test_enc)) accuracy_score(y_test, y_pred) 0.673992673992674
嵌入式方法通常比包装方法 CPU-/GPU 密集度低,但仍然可以产生良好的结果。在本节的学士学位完成模型中,我们得到了与基于穷举特征选择模型相同的准确率。
我们之前讨论的每种方法都有重要的应用场景,正如我们所讨论的。然而,我们还没有真正讨论一个非常具有挑战性的特征选择问题。如果你简单地有太多的特征,其中许多特征在你的模型中独立地解释了某些重要内容,你会怎么做?在这里,“太多”意味着有如此多的特征,以至于模型无法高效地运行,无论是训练还是预测目标值。我们如何在不牺牲模型部分预测能力的情况下减少特征集?在这种情况下,主成分分析(PCA)可能是一个好的方法。我们将在下一节中讨论 PCA。
使用主成分分析
PCA 是一种与之前讨论的任何方法都截然不同的特征选择方法。PCA 允许我们用有限数量的组件替换现有的特征集,每个组件都解释了重要数量的方差。它是通过找到一个捕获最大方差量的组件,然后是一个捕获剩余最大方差量的第二个组件,然后是一个第三个组件,以此类推来做到这一点的。这种方法的一个关键优势是,这些被称为主成分的组件是不相关的。我们在第十五章,主成分分析中详细讨论 PCA。
虽然我在这里将主成分分析(PCA)视为一种特征选择方法,但可能更合适将其视为降维工具。当我们需要限制维度数量而又不希望牺牲太多解释力时,我们使用它来进行特征选择。
让我们再次使用 NLS 数据,并使用 PCA 为学士学位完成模型选择特征:
- 
我们首先加载必要的库。在本章中,我们还没有使用过的模块是 scikit-learn的PCA:import pandas as pd from feature_engine.encoding import OneHotEncoder from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.decomposition import PCA from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import accuracy_score
- 
接下来,我们再次创建训练和测试 DataFrame: nls97compba = pd.read_csv("data/nls97compba.csv") feature_cols = ['satverbal','satmath','gpascience', 'gpaenglish','gpamath','gpaoverall','gender', 'motherhighgrade', 'fatherhighgrade','parentincome'] X_train, X_test, y_train, y_test = \ train_test_split(nls97compba[feature_cols],\ nls97compba[['completedba']], test_size=0.3, random_state=0)
- 
我们需要对数据进行缩放和编码。在 PCA 中,缩放尤其重要: ohe = OneHotEncoder(drop_last=True, variables=['gender']) ohe.fit(X_train) X_train_enc, X_test_enc = \ ohe.transform(X_train), ohe.transform(X_test) scaler = StandardScaler() standcols = X_train_enc.iloc[:,:-1].columns scaler.fit(X_train_enc[standcols]) X_train_enc = \ pd.DataFrame(scaler.transform(X_train_enc[standcols]), columns=standcols, index=X_train_enc.index).\ join(X_train_enc[['gender_Female']]) X_test_enc = \ pd.DataFrame(scaler.transform(X_test_enc[standcols]), columns=standcols, index=X_test_enc.index).\ join(X_test_enc[['gender_Female']])
- 
现在,我们实例化一个 PCA对象并拟合模型:pca = PCA(n_components=5) pca.fit(X_train_enc)
- 
PCA对象的components_属性返回了所有 10 个特征在每个 5 个成分上的得分。对第一个成分贡献最大的特征是得分绝对值最高的那些,在这种情况下,是gpaoverall、gpaenglish和gpascience。对于第二个成分,最重要的特征是motherhighgrade、fatherhighgrade和parentincome。satverbal和satmath驱动第三个成分。
在以下输出中,列0到4是五个主成分:
pd.DataFrame(pca.components_,
  columns=X_train_enc.columns).T
                   0       1      2       3       4
satverbal         -0.34   -0.16  -0.61   -0.02   -0.19
satmath           -0.37   -0.13  -0.56    0.10    0.11
gpascience        -0.40    0.21   0.18    0.03    0.02
gpaenglish        -0.40    0.22   0.18    0.08   -0.19
gpamath           -0.38    0.24   0.12    0.08    0.23
gpaoverall        -0.43    0.25   0.23   -0.04   -0.03
motherhighgrade   -0.19   -0.51   0.24   -0.43   -0.59
fatherhighgrade   -0.20   -0.51   0.18   -0.35    0.70
parentincome      -0.16   -0.46   0.28    0.82   -0.08
gender_Female     -0.02    0.08   0.12   -0.04   -0.11
另一种理解这些得分的方式是,它们表明每个特征对成分的贡献程度。(实际上,如果对每个成分,你将 10 个得分平方然后求和,你会得到一个总和为 1。)
- 
让我们也检查每个成分解释了特征中多少方差。第一个成分单独解释了 46%的方差,第二个成分额外解释了 19%。我们可以使用 NumPy 的 cumsum方法来查看五个成分累积解释了多少特征方差。我们可以用 5 个成分解释 10 个特征中的 87%的方差:pca.explained_variance_ratio_ array([0.46073387, 0.19036089, 0.09295703, 0.07163009, 0.05328056]) np.cumsum(pca.explained_variance_ratio_) array([0.46073387, 0.65109476, 0.74405179, 0.81568188, 0.86896244])
- 
让我们根据这五个主成分来转换测试数据中的特征。这返回了一个只包含五个主成分的 NumPy 数组。我们查看前几行。我们还需要转换测试 DataFrame: X_train_pca = pca.transform(X_train_enc) X_train_pca.shape (634, 5) np.round(X_train_pca[0:6],2) array([[ 2.79, -0.34, 0.41, 1.42, -0.11], [-1.29, 0.79, 1.79, -0.49, -0.01], [-1.04, -0.72, -0.62, -0.91, 0.27], [-0.22, -0.8 , -0.83, -0.75, 0.59], [ 0.11, -0.56, 1.4 , 0.2 , -0.71], [ 0.93, 0.42, -0.68, -0.45, -0.89]]) X_test_pca = pca.transform(X_test_enc)
现在,我们可以使用这些主成分来拟合一个关于学士学位完成情况的模型。让我们运行一个随机森林分类。
- 
我们首先创建一个随机森林分类器对象。然后,我们将带有主成分和目标值的训练数据传递给其 fit方法。我们将带有成分的测试数据传递给分类器的predict方法,然后得到一个准确度分数:rfc = RandomForestClassifier(n_estimators=100, max_depth=2, n_jobs=-1, random_state=0) rfc.fit(X_train_pca, y_train.values.ravel()) y_pred = rfc.predict(X_test_pca) accuracy_score(y_test, y_pred) 0.7032967032967034
当特征选择挑战是我们有高度相关的特征,并且我们希望在不过度减少解释方差的情况下减少维度数量时,PCA 等降维技术可以是一个好的选择。在这个例子中,高中 GPA 特征一起移动,父母的教育水平和收入水平以及 SAT 特征也是如此。它们成为了我们前三个成分的关键特征。(可以认为我们的模型只需要那三个成分,因为它们共同解释了特征变异的 74%。)
根据你的数据和建模目标,PCA(主成分分析)有几种修改方式可能是有用的。这包括处理异常值和正则化的策略。通过使用核函数,PCA 还可以扩展到那些成分不能线性分离的情况。我们将在第十五章**,《主成分分析》*中详细讨论 PCA。
让我们总结一下本章所学的内容。
摘要
在本章中,我们讨论了从过滤方法到包装方法再到嵌入式方法的一系列特征选择方法。我们还看到了它们如何与分类和连续目标一起工作。对于包装和嵌入式方法,我们考虑了它们如何与不同的算法一起工作。
过滤方法运行和解释都非常简单,且对系统资源的影响较小。然而,它们在评估每个特征时并没有考虑其他特征。而且,它们也没有告诉我们这种评估可能会因所使用的算法而有所不同。包装方法没有这些限制,但计算成本较高。嵌入式方法通常是一个很好的折衷方案,它们根据多元关系和给定的算法选择特征,而不像包装方法那样对系统资源造成过多负担。我们还探讨了如何通过降维方法 PCA 来改进我们的特征选择。
你可能也注意到了,我在本章中稍微提到了一点模型验证。我们将在下一章更详细地介绍模型验证。
第六章:第六章: 准备模型评估
在开始运行模型之前,思考如何评估模型性能是一个好主意。一种常见的技术是将数据分为训练集和测试集。我们在早期阶段就做这件事,以避免所谓的数据泄露;也就是说,基于原本打算用于模型评估的数据进行分析。在本章中,我们将探讨创建训练集的方法,包括如何确保训练数据具有代表性。我们还将探讨交叉验证策略,如K 折,它解决了使用静态训练/测试分割的一些局限性。我们还将开始更仔细地评估模型性能。
你可能会想知道为什么我们在详细讨论任何算法之前讨论模型评估。这是因为有一个实际考虑。我们倾向于在具有相似目的的算法中使用相同的指标和评估技术。在评估分类模型时,我们检查准确率和敏感性,在检查回归模型时,我们检查平均绝对误差和 R 平方。我们对所有监督学习模型进行交叉验证。因此,我们将在以下章节中多次重复介绍这些策略。你甚至可能会在概念稍后重新引入时回到这些页面。
除了这些实际考虑因素之外,当我们不将数据提取、数据清洗、探索性分析、特征工程和预处理、模型指定和模型评估视为离散的、顺序的任务时,我们的建模工作会得到改善。如果你只构建了 6 个月的机器学习模型,或者超过 30 年,你可能会欣赏到这种严格的顺序与数据科学家的工作流程不一致。我们总是在准备模型验证,并且总是在清理数据。这是好事。当我们整合这些任务时,我们会做得更好;当我们选择特征时继续审查我们的数据清洗,以及当我们计算精确度或均方根误差后回顾双变量相关或散点图时。
我们还将花费相当多的时间构建这些概念的可视化。在处理分类问题时,养成查看混淆矩阵和累积准确率轮廓的习惯是一个好主意,而在处理连续目标时,则查看残差图。这同样会在后续章节中对我们大有裨益。
具体来说,在本章中,我们将涵盖以下主题:
- 
测量二分类的准确率、敏感性、特异性和精确度 
- 
检查二分类的 CAP、ROC 和精确度-敏感性曲线 
- 
评估多分类模型 
- 
评估回归模型 
- 
使用 K 折交叉验证 
- 
使用管道预处理数据 
技术要求
在本章中,我们将使用feature_engine和matplotlib库,以及 scikit-learn 库。您可以使用pip安装这些包。本章的代码文件可以在本书的 GitHub 仓库中找到,网址为github.com/PacktPublishing/Data-Cleaning-and-Exploration-with-Machine-Learning。
测量二元分类的准确率、灵敏度、特异度和精确度
在评估分类模型时,我们通常想知道我们正确的情况有多频繁。在二元目标的情况下——目标有两个可能的分类值——我们计算准确率为预测正确分类的次数与观察总数之比。
但是,根据分类问题,准确率可能不是最重要的性能指标。也许我们愿意接受更多的假阳性,以换取能够识别更多真正正面的模型,即使这意味着较低的准确率。这可能适用于预测患有乳腺癌、安全漏洞或桥梁结构损坏的可能性模型。在这些情况下,我们可能更强调灵敏度(识别正案例的倾向)而不是准确率。
另一方面,我们可能希望有一个模型能够以高可靠性识别出负面案例,即使这意味着它不能很好地识别正面案例。特异度是模型识别出的所有负面案例的百分比。
精确度,即预测为正的预测值实际上是正的百分比,是另一个重要的度量。对于某些应用,限制假阳性可能很重要,即使这意味着我们必须容忍较低的灵敏度。一个苹果种植者,使用图像识别来识别坏苹果,可能更倾向于选择高精确度的模型,而不是更灵敏的模型,不希望不必要地丢弃苹果。
这可以通过查看混淆矩阵来更清楚地说明:

图 6.1 – 混淆矩阵
混淆矩阵帮助我们理解准确率、灵敏度、特异性和精确度。准确率是指我们的预测正确的观察值的百分比。这可以更精确地表述如下:

灵敏度是指我们正确预测正面的次数除以正面的总数。回顾一下混淆矩阵,确认实际的正值可以是预测正值(TP)或预测负值(FN)。灵敏度也被称为召回率或真正率:

特异度是指我们正确预测负值的次数除以实际的负值总数(TN + FP)。特异度也被称为真正率:

精确度是指我们正确预测正值(TP)的次数除以预测的正值总数:

当存在类别不平衡时,如准确率和灵敏度这样的指标可能会给我们关于模型性能的非常不同的估计。一个极端的例子将说明这一点。黑猩猩有时会尝试白蚁捕捞,把一根棍子插进蚁丘里,希望能捕获几只白蚁。这只有偶尔会成功。我不是灵长类学家,但我们可以将成功的捕捞尝试建模为使用棍子的大小、年份和时间以及黑猩猩年龄的函数。在我们的测试数据中,捕捞尝试只有 2%的时间会成功。(这些数据是为了演示而编造的。)
让我们再假设我们构建了一个成功白蚁捕捞的分类模型,其灵敏度为 50%。因此,如果我们测试数据中有 100 次捕捞尝试,我们只会正确预测其中一次成功的尝试。还有一个假阳性,即我们的模型在捕捞失败时预测了成功的捕捞。这给我们以下混淆矩阵:

图 6.2 – 成功白蚁捕捞的混淆矩阵
注意,我们得到了一个非常高的准确率 98%——即(97+1)/ 100。我们得到了高准确率和低灵敏度,因为大部分捕捞尝试都是负的,这很容易预测。一个总是预测失败的模型也会有一个 98%的准确率。
现在,让我们用真实数据来查看这些模型评估指标。我们可以通过实验一个k 最近邻(KNN)模型来预测学士学位的获得,并评估其准确率、灵敏度、特异性和精确率:
- 
我们将首先加载用于编码和标准化数据的库,以及用于创建训练和测试数据框(DataFrames)的库。我们还将加载 scikit-learn 的 KNN 分类器和 metrics库:import pandas as pd import numpy as np from feature_engine.encoding import OneHotEncoder from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.neighbors import KNeighborsClassifier import sklearn.metrics as skmet import matplotlib.pyplot as plt
- 
现在,我们可以创建训练和测试数据框,并对数据进行编码和缩放: nls97compba = pd.read_csv("data/nls97compba.csv") feature_cols = ['satverbal','satmath','gpaoverall', 'parentincome','gender'] X_train, X_test, y_train, y_test = \ train_test_split(nls97compba[feature_cols],\ nls97compba[['completedba']], test_size=0.3, random_state=0) ohe = OneHotEncoder(drop_last=True, variables=['gender']) ohe.fit(X_train) X_train_enc, X_test_enc = \ ohe.transform(X_train), ohe.transform(X_test) scaler = StandardScaler() standcols = X_train_enc.iloc[:,:-1].columns scaler.fit(X_train_enc[standcols]) X_train_enc = \ pd.DataFrame(scaler.transform(X_train_enc[standcols]), columns=standcols, index=X_train_enc.index).\ join(X_train_enc[['gender_Female']]) X_test_enc = \ pd.DataFrame(scaler.transform(X_test_enc[standcols]), columns=standcols, index=X_test_enc.index).\ join(X_test_enc[['gender_Female']])
- 
让我们创建一个 KNN 分类模型。我们不会太在意如何指定它,因为我们只想关注本节中的评估指标。我们将使用 feature_cols中列出的所有特征。我们使用 KNN 分类器的预测方法从测试数据中生成预测:knn = KNeighborsClassifier(n_neighbors = 5) knn.fit(X_train_enc, y_train.values.ravel()) pred = knn.predict(X_test_enc)
- 
我们可以使用 scikit-learn 绘制混淆矩阵。我们将传递测试数据中的实际值( y_test)和预测值到confusion_matrix方法:cm = skmet.confusion_matrix(y_test, pred, labels=knn.classes_) cmplot = skmet.ConfusionMatrixDisplay( confusion_matrix=cm, display_labels=['Negative', 'Positive']) cmplot.plot() cmplot.ax_.set(title='Confusion Matrix', xlabel='Predicted Value', ylabel='Actual Value')
这生成了以下图表:

图 6.3 – 实际值和预测值的混淆矩阵
- 
我们也可以只返回真正的负值、假阳性、假阴性和真正阳性的计数: tn, fp, fn, tp = skmet.confusion_matrix( y_test.values.ravel(), pred).ravel() tn, fp, fn, tp (53, 63, 31, 126)
- 
我们现在有了计算准确率、灵敏度、特异性和精确率所需的所有信息: accuracy = (tp + tn) / pred.shape[0] accuracy 0.6556776556776557 sensitivity = tp / (tp + fn) sensitivity 0.802547770700637 specificity = tn / (tn+fp) specificity 0.45689655172413796 precision = tp / (tp + fp) precision 0.6666666666666666
这个模型相对精度较低,但灵敏度略好;也就是说,它更好地识别了测试数据中完成学士学位的人,而不是正确识别所有学位完成者和未完成者的整体情况。如果我们回顾混淆矩阵,我们会看到有相当数量的假阳性,因为我们的模型预测测试数据中有 63 个人将会有学士学位,而实际上并没有。
- 
我们还可以使用 scikit-learn 提供的便捷方法直接生成这些统计数据: skmet.accuracy_score(y_test.values.ravel(), pred) 0.6556776556776557 skmet.recall_score(y_test.values.ravel(), pred) 0.802547770700637 skmet.precision_score(y_test.values.ravel(), pred) 0.6666666666666666
仅为了比较,让我们尝试使用随机森林分类器,看看是否能得到更好的结果。
- 
让我们将随机森林分类器拟合到相同的数据,并再次调用 confusion_matrix:rfc = RandomForestClassifier(n_estimators=100, max_depth=2, n_jobs=-1, random_state=0) rfc.fit(X_train_enc, y_train.values.ravel()) pred = rfc.predict(X_test_enc) tn, fp, fn, tp = skmet.confusion_matrix( y_test.values.ravel(), pred).ravel() tn, fp, fn, tp (49, 67, 17, 140) accuracy = (tp + tn) / pred.shape[0] accuracy 0.6923076923076923 sensitivity = tp / (tp + fn) sensitivity 0.89171974522293 specificity = tn / (tn+fp) specificity 0.4224137931034483 precision = tp / (tp + fp) precision 0.6763285024154589
第二个模型比第一个模型显著减少了假阴性,并增加了真阳性。它不太可能预测测试数据中的个体没有完成学士学位,而当个人完成了学士学位时,更有可能预测这个人有学士学位。较低的 FP 和较高的 TP 的主要影响是灵敏度显著提高。第二个模型有 89%的时间识别出实际正值,而第一个模型只有 80%。
我们在本节中讨论的措施——准确性、灵敏度、特异性和精确度——在评估分类模型时都值得一看。但是,例如,在精度和灵敏度之间,我们有时可能会面临难以权衡的情况。数据科学家在构建分类模型时,会依赖几种标准的可视化方法来提高我们对这些权衡的认识。我们将在下一节中探讨这些可视化方法。
检查二元分类的 CAP、ROC 和精度-灵敏度曲线
可视化二元分类模型性能的方法有很多。一种相对直接的可视化方法是累积准确率曲线(CAP),它显示了我们的模型识别正类(或积极案例)的能力。它显示了 X 轴上的累积案例和 Y 轴上的累积积极结果。CAP 曲线是了解我们的模型在区分正类观察方面做得如何的好方法。(在讨论二元分类模型时,我将交替使用正类和积极这两个术语。)
接收者操作特征(ROC)曲线说明了在调整分类正值的阈值时,模型灵敏度(能够识别正值)与假阳性率之间的权衡。同样,精度-灵敏度曲线显示了在调整阈值时,我们积极预测的可靠性(它们的精度)与灵敏度(我们的模型识别实际正值的能)力之间的关系。
构建 CAP 曲线
让我们从学士学位完成 KNN 模型的 CAP 曲线开始。让我们也将其与决策树模型进行比较。同样,我们在这里不会进行太多的特征选择。上一章详细介绍了特征选择。
除了我们模型的曲线外,CAP 曲线还有用于比较的随机模型和完美模型的图表。随机模型除了提供正值的整体分布信息外,没有其他信息。完美模型精确地预测正值。为了说明这些图表是如何绘制的,我们将从一个假设的例子开始。想象一下,你从一副洗好的牌中抽取前六张牌。你创建一个表格,其中一列是累积牌总数,下一列是红牌的数量。它可能看起来像这样:

图 6.4 – 玩牌样本
我们可以根据我们对红牌数量的了解绘制一个随机模型。随机模型只有两个点,(0,0)和(6,3),但这就足够了。
完美模型图表需要更多的解释。如果我们的模型完美预测红牌,并且按预测降序排列,我们会得到图 6.5。累积 in-class count 与牌的数量相匹配,直到红牌耗尽,在这个例子中是 3 张。使用完美模型的累积 in-class total 图表将有两个斜率;在达到 in-class total 之前等于 1,之后为 0:

图 6.5 – 玩牌样本
现在我们已经足够了解如何绘制随机模型和完美模型。完美模型将有三点:(0,0),(in-class count, in-class count),和(number of cards, in-class count)。在这种情况下,in-class count 是3,卡片数量是6:
numobs = 6
inclasscnt = 3
plt.yticks([1,2,3])
plt.plot([0, numobs], [0, inclasscnt], c = 'b', label = 'Random Model')
plt.plot([0, inclasscnt, numobs], [0, inclasscnt, inclasscnt], c = 'grey', linewidth = 2, label = 'Perfect Model')
plt.title("Cumulative Accuracy Profile")
plt.xlabel("Total Cards")
plt.ylabel("In-class (Red) Cards")
这会产生以下图表:

图 6.6 – 使用玩牌数据的 CAP
要理解完美模型相对于随机模型的改进,可以考虑随机模型在中间点预测多少红牌 – 那就是说,3 张牌。在那个点上,随机模型会预测 1.5 张红牌。然而,完美模型会预测 3 张。(记住,我们已经按预测顺序降序排列了牌。)
使用虚构数据构建了随机模型和完美模型的图表后,让我们用我们的学士学位完成数据试试:
- 
首先,我们必须导入与上一节相同的模块: import pandas as pd import numpy as np from feature_engine.encoding import OneHotEncoder from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.neighbors import KNeighborsClassifier from sklearn.ensemble import RandomForestClassifier import sklearn.metrics as skmet import matplotlib.pyplot as plt import seaborn as sb
- 
然后,我们加载、编码和缩放 NLS 学士学位数据: nls97compba = pd.read_csv("data/nls97compba.csv") feature_cols = ['satverbal','satmath','gpaoverall', 'parentincome','gender'] X_train, X_test, y_train, y_test = \ train_test_split(nls97compba[feature_cols],\ nls97compba[['completedba']], test_size=0.3, random_state=0) ohe = OneHotEncoder(drop_last=True, variables=['gender']) ohe.fit(X_train) X_train_enc, X_test_enc = \ ohe.transform(X_train), ohe.transform(X_test) scaler = StandardScaler() standcols = X_train_enc.iloc[:,:-1].columns scaler.fit(X_train_enc[standcols]) X_train_enc = \ pd.DataFrame(scaler.transform(X_train_enc[standcols]), columns=standcols, index=X_train_enc.index).\ join(X_train_enc[['gender_Female']]) X_test_enc = \ pd.DataFrame(scaler.transform(X_test_enc[standcols]), columns=standcols, index=X_test_enc.index).\ join(X_test_enc[['gender_Female']])
- 
接下来,我们创建 KNeighborsClassifier和RandomForestClassifier实例:knn = KNeighborsClassifier(n_neighbors = 5) rfc = RandomForestClassifier(n_estimators=100, max_depth=2, n_jobs=-1, random_state=0)
我们现在可以开始绘制我们的 CAP 曲线了。我们将首先绘制一个随机模型,然后是一个完美模型。这些模型不使用任何信息(除了正值的整体分布)并且提供完美信息。
- 我们计算测试数据中的观测数和正值的数量。我们将使用(0,0)和(观测数,类内计数)来绘制随机模型线。对于完美模型,我们将从(0,0)到(类内计数,类内计数)绘制一条线,因为该模型可以完美地区分类内值(它永远不会出错)。在该点右侧是平的,因为再也没有更多的正值可以找到。
我们还将绘制一条垂直线在中间,以及与随机模型线相交的水平线。这将在以后更有用:
numobs = y_test.shape[0]
inclasscnt = y_test.iloc[:,0].sum()
plt.plot([0, numobs], [0, inclasscnt], c = 'b', label = 'Random Model')
plt.plot([0, inclasscnt, numobs], [0, inclasscnt, inclasscnt], c = 'grey', linewidth = 2, label = 'Perfect Model')
plt.axvline(numobs/2, color='black', linestyle='dashed', linewidth=1)
plt.axhline(numobs/2, color='black', linestyle='dashed', linewidth=1)
plt.title("Cumulative Accuracy Profile")
plt.xlabel("Total Observations")
plt.ylabel("In-class Observations")
plt.legend()
这产生了以下图表:

图 6.7 – 仅使用随机和完美模型的 CAP
- 接下来,我们定义一个函数来绘制我们传递给它的模型的 CAP 曲线。我们将使用predict_proba方法来获取一个数组,该数组包含测试数据中每个观测值在类内(在这种情况下,已完成学士学位)的概率。然后,我们将创建一个包含这些概率和实际目标值的 DataFrame,按概率降序排序,并计算正实际目标值的累计总和。
我们还将得到中间观测值的累计值,并在该点绘制一条水平线。最后,我们将绘制一条线,其 x 值为从 0 到观测数的数组,y 值为累计的类内总数:
def addplot(model, X, Xtest, y, modelname, linecolor):
  model.fit(X, y.values.ravel())
  probs = model.predict_proba(Xtest)[:, 1]
  probdf = pd.DataFrame(zip(probs, y_test.values.ravel()),
    columns=(['prob','inclass']))
  probdf.loc[-1] = [0,0]
  probdf = probdf.sort_values(['prob','inclass'],
    ascending=False).\
    assign(inclasscum = lambda x: x.inclass.cumsum())
  inclassmidpoint = \
    probdf.iloc[int(probdf.shape[0]/2)].inclasscum
  plt.axhline(inclassmidpoint, color=linecolor,
    linestyle='dashed', linewidth=1)
  plt.plot(np.arange(0, probdf.shape[0]),
    probdf.inclasscum, c = linecolor,
    label = modelname, linewidth = 4)
- 
现在,让我们使用相同的数据运行 KNN 和随机森林分类器模型的函数: addplot(knn, X_train_enc, X_test_enc, y_train, 'KNN', 'red') addplot(rfc, X_train_enc, X_test_enc, y_train, 'Random Forest', 'green') plt.legend()
这更新了我们的早期图表:

图 6.8 – 使用 KNN 和随机森林模型的 CAP 更新
毫不奇怪,CAP 曲线显示我们的 KNN 和随机森林模型比随机猜测要好,但不如完美模型好。问题是,分别有多好和多差。水平线给我们一些想法。一个完美模型会在 138 个观测值中正确识别出 138 个正值。(回想一下,观测值是按最高可能性为正的顺序排序的。)随机模型会识别出 70 个(线未显示),而 KNN 和随机森林模型分别会识别出 102 和 103 个。我们的两个模型在区分正值方面与完美模型一样好,分别是 74%和 75%。在 70%到 80%之间被认为是好的模型;高于这个百分比的模型非常好,而低于这个百分比的模型较差。
绘制接收者操作特征(ROC)曲线
ROC 曲线说明了在调整阈值时,假阳性率与真阳性率(也称为灵敏度)之间的权衡。在进一步讨论之前,我们应该讨论假阳性率。它是模型错误地将实际负例(真负例加上假阳性)识别为正例的百分比:

在这里,你可以看到假阳性率与特异性之间的关系,这是在本章开头讨论过的。差异是分子。特异性是我们模型正确地将实际负例识别为负例的百分比:

我们还可以将假阳性率与灵敏度进行比较,灵敏度是实际正例(真阳性加上假阴性)的百分比,我们模型正确地将它们识别为正例:

我们通常面临灵敏度和假阳性率之间的权衡。我们希望我们的模型能够识别大量实际正例,但我们不希望假阳性率过高。什么是“过高”取决于你的上下文。
当区分负例和正例变得更加困难时,灵敏度和假阳性率之间的权衡就变得更加复杂。当我们绘制预测概率时,我们可以通过我们的学士学位完成模型看到这一点:
- 
首先,让我们再次调整我们的随机森林分类器并生成预测和预测概率。我们会看到,当预测概率大于 0.500时,该模型预测这个人将完成学士学位:rfc.fit(X_train_enc, y_train.values.ravel()) pred = rfc.predict(X_test_enc) pred_probs = rfc.predict_proba(X_test_enc)[:, 1] probdf = pd.DataFrame(zip( pred_probs, pred, y_test.values.ravel()), columns=(['prob','pred','actual'])) probdf.groupby(['pred'])['prob'].agg(['min','max']) min max pred 0.000 0.305 0.500 1.000 0.502 0.883
- 
将这些概率的分布与实际类别值进行比较是有帮助的。我们可以使用密度图来完成这项工作: sb.kdeplot(probdf.loc[probdf.actual==1].prob, shade=True, color='red', label="Completed BA") sb.kdeplot(probdf.loc[probdf.actual==0].prob, shade=True, color='green', label="Did Not Complete") plt.axvline(0.5, color='black', linestyle='dashed', linewidth=1) plt.axvline(0.65, color='black', linestyle='dashed', linewidth=1) plt.title("Predicted Probability Distribution") plt.legend(loc="upper left")
这会产生以下图表:

图 6.9 – 在课内和课外观察的密度图
在这里,我们可以看到我们的模型在区分实际正例和负例方面有些困难,因为课内和课外重叠的部分相当多。阈值为 0.500(左侧虚线)会产生很多假阳性,因为课外观察分布(那些没有完成学士学位的人)中有相当一部分预测概率大于 0.500。如果我们提高阈值,比如到 0.650,我们会得到更多的假阴性,因为许多课内观察的概率低于 0.65。
- 基于测试数据和随机森林模型,构建 ROC 曲线很容易。roc_curve方法返回不同阈值(ths)下的假阳性率(fpr)和灵敏度(真阳性率,tpr)。
首先,让我们通过阈值绘制单独的假阳性率和灵敏度线:
fpr, tpr, ths = skmet.roc_curve(y_test, pred_probs)
ths = ths[1:]
fpr = fpr[1:]
tpr = tpr[1:]
fig, ax = plt.subplots()
ax.plot(ths, fpr, label="False Positive Rate")
ax.plot(ths, tpr, label="Sensitivity")
ax.set_title('False Positive Rate and Sensitivity by Threshold')
ax.set_xlabel('Threshold')
ax.set_ylabel('False Positive Rate and Sensitivity')
ax.legend()
这会产生以下图表:

图 6.10 – 假阳性率和灵敏度线
在这里,我们可以看到提高阈值将提高(降低)我们的假阳性率,但也会降低我们的灵敏度。
- 
现在,让我们绘制相关的 ROC 曲线,该曲线绘制了每个阈值下的假阳性率与灵敏度: fig, ax = plt.subplots() ax.plot(fpr, tpr, linewidth=4, color="black") ax.set_title('ROC curve') ax.set_xlabel('False Positive Rate') ax.set_ylabel('Sensitivity')
这会产生以下图表:

图 6.11 – 带有假阳性率和灵敏度的 ROC 曲线
ROC 曲线表明,假阳性率和灵敏度之间的权衡在假阳性率约为 0.5 或更高时相当陡峭。让我们看看这对随机森林模型预测中使用的 0.5 阈值意味着什么。
- 
让我们从阈值数组中选择一个接近 0.5 的索引,以及一个接近 0.4 和 0.6 的索引进行比较。然后,我们将为那些索引绘制垂直线表示假阳性率,以及为那些索引绘制水平线表示灵敏度值: tholdind = np.where((ths>0.499) & (ths<0.501))[0][0] tholdindlow = np.where((ths>0.397) & (ths<0.404))[0][0] tholdindhigh = np.where((ths>0.599) & (ths<0.601))[0][0] plt.vlines((fpr[tholdindlow],fpr[tholdind], fpr[tholdindhigh]), 0, 1, linestyles ="dashed", colors =["green","blue","purple"]) plt.hlines((tpr[tholdindlow],tpr[tholdind], tpr[tholdindhigh]), 0, 1, linestyles ="dashed", colors =["green","blue","purple"])
这更新了我们的图表:

图 6.12 – 带有阈值线的 ROC 曲线
这说明了在 0.5 阈值(蓝色虚线)下,预测中假阳性率和灵敏度之间的权衡。ROC 曲线在 0.5 以上的阈值处斜率非常小,例如 0.6 阈值(绿色虚线)。因此,将阈值从 0.6 降低到 0.5 会导致假阳性率显著降低(从超过 0.8 降低到低于 0.6),但灵敏度降低不多。然而,通过将阈值从 0.5 降低到 0.4(从蓝色到紫色线),假阳性率(降低)将导致灵敏度显著下降。它从近 90%下降到略高于 70%。
绘制精度-灵敏度曲线
当调整阈值时,检查精度和灵敏度之间的关系通常很有帮助。记住,精度告诉我们当我们预测一个正值时,我们正确的时间百分比:

我们可以通过提高将值分类为正的阈值来提高精度。然而,这可能会意味着灵敏度的降低。随着我们提高预测正值时正确的时间(精度),我们将减少我们能够识别的正值数量(灵敏度)。精度-灵敏度曲线,通常称为精度-召回曲线,说明了这种权衡。
在绘制精度-灵敏度曲线之前,让我们先看看分别针对阈值绘制的精度和灵敏度线:
- 
我们可以使用 precision_recall_curve方法获得精度-灵敏度曲线的点。我们移除最高阈值值的一些不规则性,这有时可能发生:prec, sens, ths = skmet.precision_recall_curve(y_test, pred_probs) prec = prec[1:-10] sens = sens[1:-10] ths = ths[:-10] fig, ax = plt.subplots() ax.plot(ths, prec, label='Precision') ax.plot(ths, sens, label='Sensitivity') ax.set_title('Precision and Sensitivity by Threshold') ax.set_xlabel('Threshold') ax.set_ylabel('Precision and Sensitivity') ax.set_xlim(0.3,0.9) ax.legend()
这会产生以下图表:

图 6.13 – 精确度和灵敏度线
在这里,我们可以看到,当阈值高于 0.5 时,灵敏度下降更为陡峭。这种下降并没有在 0.6 阈值以上带来多少精确度的改进。
- 
现在,让我们绘制灵敏度与精确度之间的关系,以查看精确度-灵敏度曲线: fig, ax = plt.subplots() ax.plot(sens, prec) ax.set_title('Precision-Sensitivity Curve') ax.set_xlabel('Sensitivity') ax.set_ylabel('Precision') plt.yticks(np.arange(0.2, 0.9, 0.2))
这生成了以下图表:

图 6.14 – 精确度-灵敏度曲线
精确度-灵敏度曲线反映了这样一个事实:对于这个特定的模型,灵敏度对阈值的反应比精确度更敏感。这意味着我们可以将阈值降低到 0.5 以下,以获得更高的灵敏度,而不会显著降低精确度。
注意
阈值的选择部分是判断和领域知识的问题,并且在存在显著类别不平衡的情况下主要是一个问题。然而,在第十章**,逻辑回归中,我们将探讨如何计算一个最优阈值。
本节以及上一节演示了如何评估二元分类模型。它们表明模型评估不仅仅是一个简单的赞成或反对的过程。它更像是在做蛋糕时品尝面糊。我们对模型规格做出良好的初始假设,并使用模型评估过程进行改进。这通常涉及准确度、灵敏度、特异性和精确度之间的权衡,以及抵制一刀切建议的建模决策。这些决策在很大程度上取决于特定领域,并且是专业判断的问题。
本节中的讨论以及大多数技术同样适用于多类建模。我们将在下一节讨论如何评估多类模型。
评估多类模型
我们用来评估二元分类模型的所有相同原则也适用于多类模型评估。计算混淆矩阵同样重要,尽管它更难解释。我们还需要检查一些相互竞争的指标,如精确度和灵敏度。这也比二元分类更复杂。
再次,我们将使用 NLS 学位完成数据。在这种情况下,我们将目标从学士学位完成与否更改为高中完成、学士学位完成和研究生学位完成:
- 
我们将首先加载必要的库。这些库与前面两节中使用的相同: import pandas as pd import numpy as np from feature_engine.encoding import OneHotEncoder from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.neighbors import KNeighborsClassifier import sklearn.metrics as skmet import matplotlib.pyplot as plt
- 
接下来,我们将加载 NLS 学位达成数据,创建训练和测试数据框,并对数据进行编码和缩放: nls97degreelevel = pd.read_csv("data/nls97degreelevel.csv") feature_cols = ['satverbal','satmath','gpaoverall', 'parentincome','gender'] X_train, X_test, y_train, y_test = \ train_test_split(nls97degreelevel[feature_cols],\ nls97degreelevel[['degreelevel']], test_size=0.3, random_state=0) ohe = OneHotEncoder(drop_last=True, variables=['gender']) ohe.fit(X_train) X_train_enc, X_test_enc = \ ohe.transform(X_train), ohe.transform(X_test) scaler = StandardScaler() standcols = X_train_enc.iloc[:,:-1].columns scaler.fit(X_train_enc[standcols]) X_train_enc = \ pd.DataFrame(scaler.transform(X_train_enc[standcols]), columns=standcols, index=X_train_enc.index).\ join(X_train_enc[['gender_Female']]) X_test_enc = \ pd.DataFrame(scaler.transform(X_test_enc[standcols]), columns=standcols, index=X_test_enc.index).\ join(X_test_enc[['gender_Female']])
- 
现在,我们将运行一个 KNN 模型,并为每个学位级别类别预测值: knn = KNeighborsClassifier(n_neighbors = 5) knn.fit(X_train_enc, y_train.values.ravel()) pred = knn.predict(X_test_enc) pred_probs = knn.predict_proba(X_test_enc)[:, 1]
- 
我们可以使用这些预测来生成一个混淆矩阵: cm = skmet.confusion_matrix(y_test, pred) cmplot = skmet.ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=['High School', 'Bachelor','Post-Graduate']) cmplot.plot() cmplot.ax_.set(title='Confusion Matrix', xlabel='Predicted Value', ylabel='Actual Value')
这生成了以下图表:

图 6.15 – 具有多类目标的混淆矩阵
可以手动计算评估指标。精度是我们预测中实际属于类内的百分比。因此,对于我们的高中预测,它是 48 / (48 + 38 + 8) = 0.51。高中类别的灵敏度——即我们模型预测的实际高中值的百分比——是 48 / (48 + 19 + 5) = 0.67。然而,这相当繁琐。幸运的是,scikit-learn 可以为我们完成这项工作。
- 
我们可以通过调用 classification_report方法来获取这些统计数据,传递实际和预测值(记住召回率和灵敏度是相同的度量):print(skmet.classification_report(y_test, pred, target_names=['High School', 'Bachelor', 'Post-Graduate'])) precision recall f1-score support High School 0.51 0.67 0.58 72 Bachelor 0.51 0.49 0.50 92 Post-Graduate 0.42 0.24 0.30 42 accuracy 0.50 206 macro avg 0.48 0.46 0.46 206 weighted avg 0.49 0.50 0.49 206
除了按类别计算的精度和灵敏度比率之外,我们还会得到一些其他统计数据。F1 分数是精度和灵敏度的调和平均值。

在这里,p 代表精度,而 s 代表灵敏度。
要获得类别的平均精度、灵敏度和 F1 分数,我们可以使用简单的平均(宏平均)或调整类别大小的加权平均。使用加权平均,我们得到精度、灵敏度和 F1 分数分别为 0.49、0.50 和 0.49。(由于这里的类别相对平衡,宏平均和加权平均之间没有太大差异。)
这演示了如何将我们讨论的二分类模型评估指标扩展到多类评估。虽然实现起来更困难,但相同的概念和技术同样适用。
到目前为止,我们关注的是指标和可视化,以帮助我们评估分类模型。我们尚未检查评估回归模型的指标。这些指标可能比分类指标更为直接。我们将在下一节中讨论它们。
评估回归模型
回归模型评估的指标通常基于目标变量的实际值和模型预测值之间的距离。最常见的指标——均方误差、均方根误差、平均绝对误差和 R 平方——都追踪我们的预测如何成功地捕捉目标变量的变化。
实际值和我们的预测值之间的距离被称为残差或误差。均方误差(MSE)是残差平方的平均值:

在这里, 是第 i 次观察的实际目标变量值,而
 是第 i 次观察的实际目标变量值,而 是我们对目标值的预测。由于预测值高于实际值,残差被平方以处理负值。为了使我们的测量值返回到一个更有意义的尺度,我们通常使用均方误差(MSE)的平方根。这被称为均方根误差(RMSE)。
 是我们对目标值的预测。由于预测值高于实际值,残差被平方以处理负值。为了使我们的测量值返回到一个更有意义的尺度,我们通常使用均方误差(MSE)的平方根。这被称为均方根误差(RMSE)。
由于平方,均方误差(MSE)将对较大的残差进行更多的惩罚,而不是较小的残差。例如,如果我们对五个观测值进行预测,其中一个残差为 25,其他四个残差为 0,我们将得到均方误差为 (0+0+0+0+625)/5 = 125。然而,如果所有五个观测值的残差都是 5,均方误差将是 (25+25+25+25+25)/5 = 25。
将残差的平方作为替代方案是取它们的绝对值。这给我们带来了平均绝对误差:

R-squared,也称为确定系数,是我们模型捕获目标变量变化的估计比例。我们平方残差,就像我们在计算均方误差(MSE)时做的那样,并将其除以每个实际目标值与其样本均值之间的偏差。这给我们带来了仍然未解释的变异,我们从 1 中减去它以得到解释的变异:


幸运的是,scikit-learn 使得生成这些统计数据变得容易。在本节中,我们将构建一个关于陆地温度的线性回归模型,并使用这些统计数据来评估它。我们将使用来自美国国家海洋和大气管理局 2019 年气象站平均年度温度、海拔和纬度的数据。
注意
陆地温度数据集包含了 2019 年来自全球超过 12,000 个站点的平均温度读数(以摄氏度为单位),尽管大多数站点位于美国。原始数据是从全球历史气候学网络综合数据库中检索的。它已由美国国家海洋和大气管理局在www.ncdc.noaa.gov/data-access/land-based-station-data/land-based-datasets/global-historical-climatology-network-monthly-version-4上提供给公众使用。
让我们开始构建一个线性回归模型:
- 
我们将首先加载所需的库和陆地温度数据。我们还将创建训练和测试数据框: import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.linear_model import LinearRegression import sklearn.metrics as skmet import matplotlib.pyplot as plt landtemps = pd.read_csv("data/landtemps2019avgs.csv") feature_cols = ['latabs','elevation'] X_train, X_test, y_train, y_test = \ train_test_split(landtemps[feature_cols],\ landtemps[['avgtemp']], test_size=0.3, random_state=0)注意 latabs特征是纬度的值,不带北或南指示符;因此,埃及开罗大约在北纬 30 度,巴西的波尔图阿雷格里大约在南纬 30 度,它们具有相同的值。
- 
现在,我们缩放我们的数据: scaler = StandardScaler() scaler.fit(X_train) X_train = \ pd.DataFrame(scaler.transform(X_train), columns=feature_cols, index=X_train.index) X_test = \ pd.DataFrame(scaler.transform(X_test), columns=feature_cols, index=X_test.index) scaler.fit(y_train) y_train, y_test = \ pd.DataFrame(scaler.transform(y_train), columns=['avgtemp'], index=y_train.index),\ pd.DataFrame(scaler.transform(y_test), columns=['avgtemp'], index=y_test.index)
- 
接下来,我们实例化一个 scikit-learn LinearRegression对象,并在训练数据上拟合一个模型。我们的目标是年度平均温度 (avgtemp),而特征是纬度 (latabs) 和elevation。coef_属性给我们每个特征的系数:lr = LinearRegression() lr.fit(X_train, y_train) np.column_stack((lr.coef_.ravel(), X_test.columns.values)) array([[-0.8538957537748768, 'latabs'], [-0.3058979822791853, 'elevation']], dtype=object)
latabs系数的解释是,标准化平均年温度每增加一个标准差,将下降 0.85。(LinearRegression模块不返回 p 值,这是系数估计的统计显著性的度量。你可以使用statsmodels来查看普通最小二乘模型的完整摘要。)
- 
现在,我们可以获取预测值。让我们也将测试数据中的特征和目标与返回的 NumPy 数组结合起来。然后,我们可以通过从实际值( avgtemp)中减去预测值来计算残差。尽管存在轻微的负偏斜和过度的峰度,但残差看起来还不错:pred = lr.predict(X_test) preddf = pd.DataFrame(pred, columns=['prediction'], index=X_test.index).join(X_test).join(y_test) preddf['resid'] = preddf.avgtemp-preddf.prediction preddf.resid.agg(['mean','median','skew','kurtosis']) mean -0.021 median 0.032 skew -0.641 kurtosis 6.816 Name: resid, dtype: float64
值得注意的是,在本书中,我们大多数时候在处理回归模型时都会以这种方式生成预测值和计算残差。如果你对前面代码块中我们刚刚做的事情感到有些不清楚,再次回顾一下可能是个好主意。
- 
我们应该绘制残差图,以更好地了解它们的分布。 Plt.hist(preddf.resid, color="blue") plt.axvline(preddf.resid.mean(), color='red', linestyle='dashed', linewidth=1) plt.title("Histogram of Residuals for Temperature Model") plt.xlabel("Residuals") plt.ylabel("Frequency")
这会产生以下图表:

图 6.16 – 线性回归模型的残差直方图
这看起来并不太糟糕,但我们有更多的正残差,在我们预测的测试数据中的温度低于实际温度的情况下,比负残差更多。
- 
通过残差绘制我们的预测可能让我们更好地理解正在发生的情况: plt.scatter(preddf.prediction, preddf.resid, color="blue") plt.axhline(0, color='red', linestyle='dashed', linewidth=1) plt.title("Scatterplot of Predictions and Residuals") plt.xlabel("Predicted Temperature") plt.ylabel("Residuals")
这会产生以下图表:

图 6.17 – 线性回归模型的预测残差散点图
这看起来并不糟糕。残差在 0 附近随机波动。然而,在 1 到 2 个标准差之间的预测值更有可能过低(具有正残差),而不是过高。超过 2,预测值总是过高(它们具有负残差)。这个模型线性假设可能并不合理。我们应该探索我们在第四章中讨论的一些转换,或者尝试一个非参数模型,如 KNN 回归。
也可能极端值在相当大的程度上拉动了我们的系数。一个不错的下一步可能是移除异常值,正如我们在第一章的识别极端值和异常值部分所讨论的,检查特征和目标的分布。然而,我们在这里不会这么做。
- 
让我们看看一些评估指标。这可以通过 scikit-learn 的 metrics库轻松完成。我们可以调用相同的函数来获取 RMSE 作为 MSE。我们只需要将平方参数设置为False:mse = skmet.mean_squared_error(y_test, pred) mse 0.18906346144036693 rmse = skmet.mean_squared_error(y_test, pred, squared=False) rmse 0.4348142838504353 mae = skmet.mean_absolute_error(y_test, pred) mae 0.318307379728143 r2 = skmet.r2_score(y_test, pred) r2 0.8162525715296725
标准差以下 0.2 的均方误差(MSE)和标准差以下 0.3 的绝对误差(MAE)看起来相当不错,尤其是对于这样一个稀疏模型。R-squared 超过 80%也是相当有希望的。
- 
让我们看看如果我们使用 KNN 模型会得到什么结果: knn = KNeighborsRegressor(n_neighbors=5) knn.fit(X_train, y_train) pred = knn.predict(X_test) mae = skmet.mean_absolute_error(y_test, pred) mae 0.2501829988751876 r2 = skmet.r2_score(y_test, pred) r2 0.8631113217183314
这个模型实际上在 MAE 和 R-squared 方面都有所改进。
- 
我们也应该再次审视残差: preddf = pd.DataFrame(pred, columns=['prediction'], index=X_test.index).join(X_test).join(y_test) preddf['resid'] = preddf.avgtemp-preddf.prediction plt.scatter(preddf.prediction, preddf.resid, color="blue") plt.axhline(0, color='red', linestyle='dashed', linewidth=1) plt.title("Scatterplot of Predictions and Residuals with KNN Model") plt.xlabel("Predicted Temperature") plt.ylabel("Residuals") plt.show()
这会产生以下图表:

图 6.18 – KNN 模型的预测残差散点图
这个残差图表看起来也好多了。在目标分布的任何部分,我们都不太可能过度预测或低估。
本节介绍了评估回归模型的关键措施及其解释方法。同时,还展示了如何通过可视化,尤其是模型残差的可视化,来提高这种解释的准确性。
然而,到目前为止,我们在使用回归和分类度量时都受到了我们构建的训练和测试数据框的限制。如果,出于某种原因,测试数据在某些方面不寻常呢?更普遍地说,我们基于什么结论认为我们的评估措施是准确的?如果我们使用 K 折交叉验证,我们可以更有信心地使用这些措施,我们将在下一节中介绍。
使用 K 折交叉验证
到目前为止,我们已经保留了 30%的数据用于验证。这不是一个坏策略。它防止我们在训练模型时提前查看测试数据。然而,这种方法并没有充分利用所有可用数据,无论是用于训练还是测试。如果我们使用 K 折交叉验证,我们就可以使用所有数据,同时避免数据泄露。也许这听起来太好了,但事实并非如此,这并不是因为一个巧妙的小技巧。
K 折交叉验证在 K 个折(或部分)中的所有但一个上训练我们的模型,留出一个用于测试。这重复k次,每次排除一个不同的折用于测试。性能指标是基于 K 个折的平均分数。
在开始之前,我们还需要再次考虑数据泄露的可能性。如果我们对我们将用于训练模型的所有数据进行缩放,然后将其分成折,我们将在训练中使用所有折的信息。为了避免这种情况,我们需要在每个迭代中仅对训练折进行缩放,以及进行任何其他预处理。虽然我们可以手动完成这项工作,但 scikit-learn 的pipeline库可以为我们做很多这项工作。我们将在本节中介绍如何使用管道进行交叉验证。
让我们尝试使用 K 折交叉验证评估上一节中指定的两个模型。同时,我们也来看看随机森林回归器可能表现如何:
- 
除了我们迄今为止已经使用的库之外,我们还需要 scikit-learn 的 make_pipeline、cross_validate和Kfold库:import pandas as pd from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LinearRegression from sklearn.neighbors import KNeighborsRegressor from sklearn.ensemble import RandomForestRegressor from sklearn.pipeline import make_pipeline from sklearn.model_selection import cross_validate from sklearn.model_selection import KFold
- 
我们再次加载陆地温度数据并创建训练和测试 DataFrame。我们仍然想留出一些数据用于最终验证,但这次,我们只留出 10%。我们将使用剩余的 90%进行训练和测试: landtemps = pd.read_csv("data/landtemps2019avgs.csv") feature_cols = ['latabs','elevation'] X_train, X_test, y_train, y_test = \ train_test_split(landtemps[feature_cols],\ landtemps[['avgtemp']],test_size=0.1,random_state=0)
- 
现在,我们创建一个 KFold对象,并指出我们想要五个折,并且数据要打乱(如果数据尚未随机排序,打乱数据是一个好主意):kf = Kfold(n_splits=5, shuffle=True, random_state=0)
- 
接下来,我们定义一个函数来创建一个管道。然后,该函数运行 cross_validate,它接受管道和我们之前创建的KFold对象:def getscores(model): pipeline = make_pipeline(StandardScaler(), model) scores = cross_validate(pipeline, X=X_train, y=y_train, cv=kf, scoring=['r2'], n_jobs=1) scorelist.append(dict(model=str(model), fit_time=scores['fit_time'].mean(), r2=scores['test_r2'].mean()))
- 
现在,我们准备调用线性回归、随机森林回归和 KNN 回归模型的 getscores函数:scorelist = [] getscores(LinearRegression()) getscores(RandomForestRegressor(max_depth=2)) getscores(KNeighborsRegressor(n_neighbors=5))
- 
我们可以将 scorelist列表打印出来查看我们的结果:scorelist [{'model': 'LinearRegression()', 'fit_time': 0.004968833923339844, 'r2': 0.8181125031214872}, {'model': 'RandomForestRegressor(max_depth=2)', 'fit_time': 0.28124608993530276, 'r2': 0.7122492698889024}, {'model': 'KNeighborsRegressor()', 'fit_time': 0.006945991516113281, 'r2': 0.8686733636724104}]
根据 R-squared 值,KNN 回归模型比线性回归或随机森林回归模型表现更好。随机森林回归模型也有一个显著的缺点,那就是它的拟合时间要长得多。
使用管道预处理数据
在上一节中,我们只是触及了 scikit-learn 管道可以做的事情的表面。我们经常需要将所有的预处理和特征工程折叠到一个管道中,包括缩放、编码以及处理异常值和缺失值。这可能很复杂,因为不同的特征可能需要不同的处理。我们可能需要用数值特征的中间值和分类特征的众数来填充缺失值。我们可能还需要转换我们的目标变量。我们将在本节中探讨如何做到这一点。
按照以下步骤:
- 
我们将首先加载本章中已经使用过的库。然后,我们将添加 ColumnTransformer和TransformedTargetRegressor类。我们将使用这些类分别转换我们的特征和目标:import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LinearRegression from sklearn.impute import SimpleImputer from sklearn.pipeline import make_pipeline from feature_engine.encoding import OneHotEncoder from sklearn.impute import KNNImputer from sklearn.model_selection import cross_validate, KFold import sklearn.metrics as skmet from sklearn.compose import ColumnTransformer from sklearn.compose import TransformedTargetRegressor
- 
列转换器非常灵活。我们甚至可以使用我们自定义的预处理函数。以下代码块从 helperfunctions子文件夹中的preprocfunc模块导入OutlierTrans类:import os import sys sys.path.append(os.getcwd() + "/helperfunctions") from preprocfunc import OutlierTrans
- 
OutlierTrans类通过距离四分位数范围来识别极端值。这是我们演示过的技术,见第三章,识别和修复缺失值。
要在 scikit-learn 管道中工作,我们的类必须具有 fit 和 transform 方法。我们还需要继承BaseEstimator和TransformerMixin类。
在这个类中,几乎所有操作都在transform方法中完成。任何超过第三四分位数 1.5 倍或低于第一四分位数的值都被分配为缺失值:
class OutlierTrans(BaseEstimator,TransformerMixin):
  def __init__(self,threshold=1.5):
    self.threshold = threshold
  def fit(self,X,y=None):
    return self
  def transform(self,X,y=None):
    Xnew = X.copy()
    for col in Xnew.columns:
      thirdq, firstq = Xnew[col].quantile(0.75),\
        Xnew[col].quantile(0.25)
      inlierrange = self.threshold*(thirdq-firstq)
      outlierhigh, outlierlow = inlierrange+thirdq,\
        firstq-inlierrange
      Xnew.loc[(Xnew[col]>outlierhigh) | \
        (Xnew[col]<outlierlow),col] = np.nan
    return Xnew.values                                 
我们的OutlierTrans类可以在我们的管道中以与我们在上一节中使用StandardScaler相同的方式使用。我们将在稍后这样做。
- 现在,我们已经准备好加载需要处理的数据。在本节中,我们将使用 NLS 每周工资数据。每周工资将是我们的目标,我们将使用高中 GPA、母亲和父亲最高学历、家庭收入、性别以及个人是否完成学士学位作为特征。
我们将创建一个特征列表,以不同的方式处理这些特征。这将在我们指导管道对数值、分类和二进制特征执行不同操作时很有帮助:
nls97wages = pd.read_csv("data/nls97wagesb.csv")
nls97wages.set_index("personid", inplace=True)
nls97wages.dropna(subset=['wageincome'], inplace=True)
nls97wages.loc[nls97wages.motherhighgrade==95,
  'motherhighgrade'] = np.nan
nls97wages.loc[nls97wages.fatherhighgrade==95,
  'fatherhighgrade'] = np.nan
num_cols = ['gpascience','gpaenglish','gpamath','gpaoverall',
  'motherhighgrade','fatherhighgrade','parentincome']
cat_cols = ['gender']
bin_cols = ['completedba']
target = nls97wages[['wageincome']]
features = nls97wages[num_cols + cat_cols + bin_cols]
X_train, X_test, y_train, y_test =  \
  train_test_split(features,\
  target, test_size=0.2, random_state=0)
- 
让我们看看一些描述性统计。一些变量有超过一千个缺失值( gpascience、gpaenglish、gpamath、gpaoverall和parentincome):nls97wages[['wageincome'] + num_cols].agg(['count','min','median','max']).T count min median max wageincome 5,091 0 40,000 235,884 gpascience 3,521 0 284 424 gpaenglish 3,558 0 288 418 gpamath 3,549 0 280 419 gpaoverall 3,653 42 292 411 motherhighgrade 4,734 1 12 20 fatherhighgrade 4,173 1 12 29 parentincome 3,803 -48,100 40,045 246,474
- 
现在,我们可以设置一个列转换器。首先,我们将为处理数值数据( standtrans)、分类数据和二进制数据创建管道。
对于数值数据,我们希望将异常值视为缺失。在这里,我们将2这个值传递给OutlierTrans的阈值参数,表示我们希望将高于或低于该范围两倍四分位距的值设置为缺失。记住,默认值是 1.5,所以我们相对保守一些。
然后,我们将创建一个ColumnTransformer对象,将其传递给刚刚创建的三个管道,并指明使用哪个管道来处理哪些特征:
standtrans = make_pipeline(OutlierTrans(2),
  StandardScaler())
cattrans = make_pipeline(SimpleImputer(strategy="most_frequent"),
  OneHotEncoder(drop_last=True))
bintrans = make_pipeline(SimpleImputer(strategy="most_frequent"))
coltrans = ColumnTransformer(
  transformers=[
    ("stand", standtrans, num_cols),
    ("cat", cattrans, ['gender']),
    ("bin", bintrans, ['completedba'])
  ]
)
- 现在,我们可以将列转换器添加到包含我们想要运行的线性模型的管道中。我们将向管道中添加 KNN 插补来处理缺失值。
我们还需要对目标进行缩放,这在我们管道中无法完成。我们将使用 scikit-learn 的TransformedTargetRegressor来完成这个任务。我们将刚刚创建的管道传递给目标回归器的regressor参数:
lr = LinearRegression()
pipe1 = make_pipeline(coltrans,
  KNNImputer(n_neighbors=5), lr)
ttr=TransformedTargetRegressor(regressor=pipe1,
  transformer=StandardScaler())
- 
让我们使用这个管道进行 K 折交叉验证。我们可以通过目标回归器 ttr将我们的管道传递给cross_validate函数:kf = KFold(n_splits=10, shuffle=True, random_state=0) scores = cross_validate(ttr, X=X_train, y=y_train, cv=kf, scoring=('r2', 'neg_mean_absolute_error'), n_jobs=1) print("Mean Absolute Error: %.2f, R-squared: %.2f" % (scores['test_neg_mean_absolute_error'].mean(), scores['test_r2'].mean())) Mean Absolute Error: -23781.32, R-squared: 0.20
虽然这些分数并不理想,但这并不是这次练习的真正目的。关键要点是我们通常希望将大部分预处理工作整合到管道中。这是避免数据泄露的最佳方式。列转换器是一个极其灵活的工具,允许我们对不同的特征应用不同的转换。
摘要
本章介绍了关键模型评估指标和技术,以便在本书的剩余章节中广泛使用和扩展时,它们将变得熟悉。我们研究了分类和回归模型评估的非常不同的方法。我们还探讨了如何使用可视化来改进我们对预测的分析。最后,我们使用管道和交叉验证来获取模型性能的可靠估计。
我希望这一章也给了你一个机会去适应这本书接下来的一般方法。尽管在接下来的章节中我们将讨论大量算法,但我们仍将继续探讨我们在前几章中讨论的预处理问题。当然,我们将讨论每个算法的核心概念。但是,以真正的动手实践方式,我们还将处理现实世界数据的杂乱无章。每一章将从相对原始的数据开始,到特征工程,再到模型指定和模型评估,高度依赖 scikit-learn 的管道来整合所有内容。
在接下来的几章中,我们将讨论回归算法——那些允许我们建模连续目标的算法。我们将探讨一些最受欢迎的回归算法——线性回归、支持向量回归、K 最近邻回归和决策树回归。我们还将考虑对回归模型进行修改,以解决欠拟合和过拟合问题,包括非线性变换和正则化。
第三部分 - 使用监督学习建模连续目标
本书最后十章介绍了广泛的各种机器学习算法,用于预测连续或分类目标,或者在没有目标的情况下。我们在本章探索连续目标的模型。
这些章节的一个持续主题是,找到最佳模型部分是关于平衡方差和偏差。当我们的模型对训练数据拟合得太好时,它们可能没有我们需要的那么具有可推广性。在这种情况下,它们可能具有低偏差但高方差。在这些章节中,我们检查的每个算法,我们都讨论了实现这种平衡的策略。这些策略从线性回归和支持向量回归模型的正则化,到 k 近邻中的 k 值,再到决策树的最大深度。
我们也有机会练习在第六章**,准备模型评估中使用的预处理、特征选择和模型评估策略。本节中我们讨论的每个算法都需要不同的预处理以获得最佳结果。例如,特征缩放对于支持向量回归很重要,但对于决策树回归通常不是。我们可能会使用多项式变换与线性回归模型,但在决策树中这也会是不必要的。我们在这部分的每一章中考虑这些选择。
本节包括以下章节:
- 
第七章, 线性回归模型 
- 
第八章, 支持向量回归 
- 
第九章, K 近邻、决策树、随机森林和梯度提升回归 
第七章:第七章: 线性回归模型
线性回归可能是最著名的机器学习算法,其起源至少可以追溯到 200 年前的统计学习。如果你在大学里学习了统计学、计量经济学或心理测量学课程,你很可能已经接触到了线性回归,即使你在机器学习在本科课程中教授之前就已经上了这门课。实际上,许多社会和物理现象都可以成功地被建模为预测变量线性组合的函数。尽管如此,线性回归对机器学习来说仍然非常有用,就像这些年来对统计学习一样,不过,在机器学习中,我们更关心预测而不是参数值。
假设我们的特征和目标具有某些特性,线性回归是建模连续目标的一个非常好的选择。在本章中,我们将讨论线性回归模型的假设,并使用与这些假设大部分一致的数据构建模型。然而,我们还将探索替代方法,例如非线性回归,当这些假设不成立时我们会使用它。我们将通过查看解决过拟合可能性的技术来结束本章,例如 lasso 回归。
在本章中,我们将涵盖以下主题:
- 
关键概念 
- 
线性回归与梯度下降 
- 
使用经典线性回归 
- 
使用 lasso 回归 
- 
使用非线性回归 
- 
使用梯度下降进行回归 
技术要求
在本章中,我们将坚持使用大多数 Python 科学发行版中可用的库——NumPy、pandas 和 scikit-learn。本章的代码可以在本书的 GitHub 仓库中找到,网址为github.com/PacktPublishing/Data-Cleaning-and-Exploration-with-Machine-Learning。
关键概念
经常从事预测建模的分析师通常会构建数十个,甚至数百个线性回归模型。如果你像我一样,在 20 世纪 80 年代末为一家大型会计师事务所工作,并且从事预测工作,你可能每天都会花整天时间指定线性模型。你会运行所有可能的独立变量排列和因变量变换,并勤奋地寻找异方差性(残差中的非恒定方差)或多重共线性(高度相关的特征)的证据。但最重要的是,你努力识别关键预测变量,并解决任何参数估计(你的系数或权重)中的偏差。
线性回归模型的关键假设
那些努力的大部分至今仍然适用,尽管现在对预测准确性的重视程度超过了参数估计。我们现在担心过度拟合,而 30 年前我们并没有这样做。当线性回归模型的假设被违反时,我们也更有可能寻求替代方案。这些假设如下:
- 
特征(自变量)与目标(因变量)之间存在线性关系 
- 
偶然误差(实际值与预测值之间的差异)是正态分布的 
- 
偶然误差在观测之间是独立的 
- 
偶然误差的方差是恒定的 
在现实世界中,这些假设中有一个或多个被违反并不罕见。特征与目标之间的关系通常是线性的。特征的影响可能在该特征的范围内变化。任何熟悉“厨房里的人太多”这个表达的人可能都会欣赏到,第五个厨师带来的边际生产力的增加可能不如第二个或第三个厨师那么大。
我们的偶然误差有时不是正态分布的。这表明我们的模型在目标的一些范围内可能不够准确。例如,在目标范围的中间部分(比如第 25 到第 75 百分位数)可能会有较小的偶然误差,而在两端可能会有较大的偶然误差。这可能是由于与目标的关系是非线性的。
偶然误差可能不独立的原因有几个。这在时间序列数据中通常是这种情况。对于一个日股价模型,相邻天的偶然误差可能是相关的。这被称为自相关。这也可能是纵向或重复测量数据的问题。例如,我们可能有 20 个不同教室中 600 名学生的考试成绩,或者 100 人的年度工资收入。如果我们的模型未能考虑到某些特征在群体中不存在变化——在这些例子中是教室决定和个人决定的特征——那么我们的偶然误差就不会是独立的。
最后,我们的偶然误差在不同特征的不同范围内可能会有更大的变异性。如果我们正在预测全球气象站的温度,并且纬度是我们使用的特征之一,那么在较高的纬度值上可能会有更大的偶然误差。这被称为异方差性。这也可能表明我们的模型遗漏了重要的预测因子。
除了这四个关键假设之外,线性回归的另一个常见挑战是特征之间的高度相关性。这被称为多重共线性。正如我们在第五章中讨论的[特征选择],当我们的模型难以隔离特定特征的独立影响,因为它与另一个特征变化很大时,我们可能会增加过拟合的风险。对于那些花费数周时间构建模型的人来说,这将是熟悉的,其中系数随着每个新规格的提出而大幅变动。
当违反这些假设中的一个或多个时,我们仍然可以使用传统的回归模型。然而,我们可能需要以某种方式转换数据。我们将在本章中讨论识别这些假设违反的技术、这些违反对模型性能的影响以及解决这些问题的可能方法。
线性回归和普通最小二乘法
线性回归中最常见的估计技术是普通最小二乘法(OLS)。OLS 选择系数,使得实际目标值与预测值之间平方距离之和最小。更精确地说,OLS 最小化以下:

在这里, 是第 i 个观测的实际值,
是第 i 个观测的实际值, 是预测值。正如我们讨论过的,实际目标值与预测目标值之间的差异,
是预测值。正如我们讨论过的,实际目标值与预测目标值之间的差异, ,被称为残差。
,被称为残差。
从图形上看,普通最小二乘法(OLS)通过我们的数据拟合一条线,使得数据点到这条线的垂直距离最小。以下图表展示了一个具有一个特征(称为简单线性回归)的模型,使用的是虚构的数据点。每个数据点到回归线的垂直距离是残差,可以是正数或负数:

图 7.1 – 普通最小二乘法回归线
这条线, ,给出了每个x值的y预测值。它等于估计的截距,
,给出了每个x值的y预测值。它等于估计的截距, ,加上特征估计系数乘以特征值,
,加上特征估计系数乘以特征值, 。这就是 OLS 线。任何其他穿过数据的直线都会导致平方残差之和更高。这可以扩展到多线性回归模型 – 即具有一个以上特征的模型:
。这就是 OLS 线。任何其他穿过数据的直线都会导致平方残差之和更高。这可以扩展到多线性回归模型 – 即具有一个以上特征的模型:

在这里,y 是目标,每个 x 是一个特征,每个  是一个系数(或截距),n 是特征的数量,ɛ 是一个误差项。每个系数是从关联特征 1 单位变化引起的目标估计变化。这是一个很好的地方来注意到系数在整个特征的范围上是恒定的;也就是说,特征从 0 到 1 的增加被认为对目标的影响与从 999 到 1000 的影响相同。然而,这并不总是有道理。在本章的后面,我们将讨论当特征与目标之间的关系不是线性的情况下如何使用变换。
 是一个系数(或截距),n 是特征的数量,ɛ 是一个误差项。每个系数是从关联特征 1 单位变化引起的目标估计变化。这是一个很好的地方来注意到系数在整个特征的范围上是恒定的;也就是说,特征从 0 到 1 的增加被认为对目标的影响与从 999 到 1000 的影响相同。然而,这并不总是有道理。在本章的后面,我们将讨论当特征与目标之间的关系不是线性的情况下如何使用变换。
线性回归的一个重要优点是它不像其他监督回归算法那样计算量大。当线性回归表现良好,基于我们之前章节讨论的指标时,它是一个好的选择。这尤其适用于你有大量数据需要训练或你的业务流程不允许大量时间用于模型训练的情况。算法的效率还可以使其使用更资源密集的特征选择技术成为可能,例如我们讨论过的包装方法,这在第五章 特征选择中提到。正如我们看到的,你可能不想使用决策树回归器的穷举特征选择。然而,对于线性回归模型来说,这可能是完全可行的。
线性回归和梯度下降
我们可以使用梯度下降而不是普通最小二乘法来估计我们的线性回归参数。梯度下降通过迭代可能的系数值来找到那些使残差平方和最小的值。它从随机的系数值开始,并计算该迭代的平方误差总和。然后,它为系数生成新的值,这些值比上一步的残差更小。当我们使用梯度下降时,我们指定一个学习率。学习率决定了每一步残差改进的量。
当处理非常大的数据集时,梯度下降通常是一个不错的选择。如果整个数据集无法装入你的机器内存,它可能就是唯一的选择。在下一节中,我们将使用 OLS 和梯度下降来估计我们的参数。
使用经典线性回归
在本节中,我们将指定一个相当直接的线性模型。我们将使用它来根据几个国家的经济和政治指标预测隐含的汽油税。但在我们指定模型之前,我们需要完成本书前几章讨论的预处理任务。
对我们的回归模型进行数据预处理
在本章中,我们将使用管道来预处理我们的数据,并在本书的其余部分也是如此。我们需要在数据缺失的地方填充值,识别和处理异常值,以及编码和缩放我们的数据。我们还需要以避免数据泄露并清理训练数据而不提前查看测试数据的方式来做这些。正如我们在第六章中看到的,准备模型评估,scikit-learn 的管道可以帮助我们完成这些任务。
我们将使用的数据集包含每个国家的隐含汽油税和一些可能的预测因子,包括人均国民收入、政府债务、燃料收入依赖性、汽车使用范围以及民主过程和政府有效性的衡量指标。
注意
这个关于各国隐含汽油税的数据集可以在哈佛数据共享平台dataverse.harvard.edu/dataset.xhtml?persistentId=doi:10.7910/DVN/RX4JGK上供公众使用。它由Paasha Mahdavi、Cesar B. Martinez-Alvarez和Michael L. Ross编制。隐含汽油税是根据每升汽油的世界基准价格和当地价格之间的差异来计算的。当地价格高于基准价格表示征税。当基准价格更高时,它可以被视为补贴。我们将使用每个国家的 2014 年数据进行分析。
让我们先对数据进行预处理:
- 
首先,我们加载了许多我们在上一章中使用的库。然而,我们还需要两个新的库来构建我们的数据管道—— ColumnTransformer和TransformedTargetRegressor。这些库允许我们构建一个对数值和分类特征进行不同预处理的管道,并且还可以转换我们的目标:import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LinearRegression from sklearn.impute import SimpleImputer from sklearn.pipeline import make_pipeline from sklearn.compose import ColumnTransformer from sklearn.compose import TransformedTargetRegressor from sklearn.feature_selection import RFE from sklearn.impute import KNNImputer from sklearn.model_selection import cross_validate, KFold import sklearn.metrics as skmet import matplotlib.pyplot as plt
- 
我们可以通过添加自己的类来扩展 scikit-learn 管道的功能。让我们添加一个名为 OutlierTrans的类来处理极端值。
要将这个类包含在管道中,它必须继承自BaseEstimator类。我们还必须继承自TransformerMixin,尽管还有其他可能性。我们的类需要fit和transform方法。我们可以在transform方法中放置将极端值赋为缺失值的代码。
但在我们能够使用我们的类之前,我们需要导入它。为了导入它,我们需要追加helperfunctions子文件夹,因为那里是我们放置包含我们的类的preprocfunc模块的地方:
import os
import sys
sys.path.append(os.getcwd() + “/helperfunctions”)
from preprocfunc import OutlierTrans
这导入了OutlierTrans类,我们可以将其添加到我们创建的管道中:
class OutlierTrans(BaseEstimator,TransformerMixin):
  def __init__(self,threshold=1.5):
    self.threshold = threshold
  def fit(self,X,y=None):
    return self
  def transform(self,X,y=None):
    Xnew = X.copy()
    for col in Xnew.columns:
      thirdq, firstq = Xnew[col].quantile(0.75),\
        Xnew[col].quantile(0.25)
      interquartilerange = self.threshold*(thirdq-firstq)
      outlierhigh, outlierlow = interquartilerange+thirdq,\
        firstq-interquartilerange
      Xnew.loc[(Xnew[col]>outlierhigh) | \
        (Xnew[col]<outlierlow),col] = np.nan
    return Xnew.values
OutlierTrans类使用一个相当标准的单变量方法来识别异常值。它计算每个特征的四分位数范围(IQR),然后将任何高于第三四分位数 1.5 倍或低于第一四分位数 1.5 倍以上的值设置为缺失。如果我们想更保守一些,可以将阈值改为其他值,例如 2.0。(我们在第一章,检查特征和目标的分布中讨论了识别异常值的技术。)
- 
接下来,我们加载 2014 年的汽油税数据。有 154 行——数据框中每个国家一行。一些特征有一些缺失值,但只有 motorization_rate有两位数的缺失。motorization_rate是每人的汽车数量:fftaxrate14 = pd.read_csv(“data/fossilfueltaxrate14.csv”) fftaxrate14.set_index(‘countrycode’, inplace=True) fftaxrate14.info() <class ‘pandas.core.frame.DataFrame’> Index: 154 entries, AFG to ZWE Data columns (total 19 columns): # Column Non-Null Count Dtype -------------- ----- 0 country 154 non-null object 1 region 154 non-null object 2 region_wb 154 non-null object 3 year 154 non-null int64 4 gas_tax_imp 154 non-null float64 5 bmgap_diesel_spotprice_la 146 non-null float64 6 fuel_income_dependence 152 non-null float64 7 national_income_per_cap 152 non-null float64 8 VAT_Rate 151 non-null float64 9 gov_debt_per_gdp 139 non-null float64 10 polity 151 non-null float64 11 democracy_polity 151 non-null float64 12 autocracy_polity 151 non-null float64 13 goveffect 154 non-null float64 14 democracy_index 152 non-null float64 15 democracy 154 non-null int64 16 nat_oil_comp 152 non-null float64 17 nat_oil_comp_state 152 non-null float64 18 motorization_rate 127 non-null float64 dtypes: float64(14), int64(2), object(3) memory usage: 24.1+ KB
- 
让我们将特征分为数值特征和二进制特征。我们将 motorization_rate放入一个特殊类别,因为我们预计需要比其他特征做更多的工作:num_cols = [‘fuel_income_dependence’, ’national_income_per_cap’, ‘VAT_Rate’, ‘gov_debt_per_gdp’, ’polity’, ’goveffect’, ‘democracy_index’] dummy_cols = ‘democracy_polity’,’autocracy_polity’, ‘democracy’,’nat_oil_comp’,’nat_oil_comp_state’] spec_cols = [‘motorization_rate’]
- 
我们应该查看一些数值特征和目标的描述性统计。我们的目标 gas_tax_imp的中位数为 0.52。请注意,一些特征的范围非常不同。超过一半的国家polity得分为 7 或更高;10 是可能的最高polity得分,意味着最民主。大多数国家的政府有效性值为负。democracy_index与polity是非常相似的一个度量,尽管有更多的变化:fftaxrate14[[‘gas_tax_imp’] + num_cols + spec_cols].\ agg([‘count’,’min’,’median’,’max’]).T count min median max gas_tax_imp 154 -0.80 0.52 1.73 fuel_income_dependence 152 0.00 0.14 34.43 national_income_per_cap 152 260.00 6,050.00 104,540.00 VAT_Rate 151 0.00 16.50 27.00 gov_debt_per_gdp 139 0.55 39.30 194.76 polity 151 -10.00 7.00 10.00 goveffect 154 -2.04 -0.15 2.18 democracy_index 152 0.03 0.57 0.93 motorization_rate 127 0.00 0.20 0.81
- 
让我们再看看二进制特征的分布。我们必须将 normalize设置为True以生成比率而不是计数。democracy_polity和autocracy_polity特征是polity特征的二进制版本;非常高的polity得分得到democracy_polity值为 1,而非常低的polity得分得到autocracy_polity值为 1。同样,democracy是一个虚拟特征,用于那些democracy_index值较高的国家。有趣的是,近一半的国家(0.46)拥有国家石油公司,而几乎四分之一(0.23)拥有国有国家石油公司:fftaxrate14[dummy_cols].apply(pd.value_counts, normalize=True).T 0 1 democracy_polity 0.41 0.59 autocracy_polity 0.89 0.11 democracy 0.42 0.58 nat_oil_comp 0.54 0.46 nat_oil_comp_state 0.77 0.23
所有这些都看起来相当不错。然而,我们需要对几个特征的缺失值做一些工作。我们还需要做一些缩放,但不需要进行任何编码,因为我们可以直接使用二进制特征。一些特征是相关的,因此我们需要进行一些特征消除。
- 
我们通过创建训练和测试数据框开始预处理。我们将只为测试保留 20%: target = fftaxrate14[[‘gas_tax_imp’]] features = fftaxrate14[num_cols + dummy_cols + spec_cols] X_train, X_test, y_train, y_test = \ train_test_split(features,\ target, test_size=0.2, random_state=0)
- 
我们需要构建一个包含列转换的管道,以便我们可以对数值数据和分类数据进行不同的预处理。我们将为 num_cols中的所有数值列构建一个管道,命名为standtrans。首先,我们想要将异常值设置为缺失值。我们将异常值定义为超过第三四分位数两倍以上的值,或者低于第一四分位数的值。我们将使用SimpleImputer将缺失值设置为该特征的中间值。
我们不希望对dummy_cols中的二元特征进行缩放,但我们确实想要使用SimpleImputer将每个分类特征的缺失值设置为最频繁的值。
我们不会为motorization_rate使用SimpleImputer。记住,motorization_rate不在num_cols列表中——它在spec_cols列表中。我们为spec_cols中的特征设置了一个特殊的管道,spectrans。我们将使用motorization_rate值:
standtrans = make_pipeline(OutlierTrans(2), 
  SimpleImputer(strategy=”median”), StandardScaler())
cattrans = make_pipeline(
  SimpleImputer(strategy=”most_frequent”))
spectrans = make_pipeline(OutlierTrans(2), 
  StandardScaler())
coltrans = ColumnTransformer(
  transformers=[
    (“stand”, standtrans, num_cols),
    (“cat”, cattrans, dummy_cols),
    (“spec”, spectrans, spec_cols)
  ]
)
这设置了我们在汽油税数据上想要进行的所有预处理。要进行转换,我们只需要调用列转换器的fit方法。然而,我们目前不会这样做,因为我们还想要将特征选择添加到管道中,并使其运行线性回归。我们将在接下来的几个步骤中完成这些操作。
运行和评估我们的线性模型
我们将使用递归特征消除(RFE)来选择模型的特征。RFE 具有包装特征选择方法的优点——它根据选定的算法评估特征,并在评估中考虑多元关系。然而,它也可能在计算上很昂贵。由于我们没有很多特征或观测值,在这种情况下这并不是一个大问题。
在选择特征后,我们运行线性回归模型并查看我们的预测。让我们开始吧:
- 首先,我们创建线性回归和递归特征消除实例,并将它们添加到管道中。我们还创建了一个TransformedTargetRegressor对象,因为我们仍然需要转换目标。我们将我们的管道传递给TransformedTargetRegressor的回归器参数。
现在,我们可以调用目标回归器的fit方法。之后,管道的rfe步骤的support_属性将给我们提供选定的特征。同样,我们可以通过获取linearregression步骤的coef_值来获取系数。关键在于通过引用ttr.regressor我们能够访问到管道:
lr = LinearRegression()
rfe = RFE(estimator=lr, n_features_to_select=7)
pipe1 = make_pipeline(coltrans, 
  KNNImputer(n_neighbors=5), rfe, lr)
ttr=TransformedTargetRegressor(regressor=pipe1,
  transformer=StandardScaler())
ttr.fit(X_train, y_train)
selcols = X_train.columns[ttr.regressor_.named_steps[‘rfe’].support_]
coefs = ttr.regressor_.named_steps[‘linearregression’].coef_
np.column_stack((coefs.ravel(),selcols))
array([[0.44753064726665703, ‘VAT_Rate’],
       [0.12368913577287821, ‘gov_debt_per_gdp’],
       [0.17926454403985687, ‘goveffect’],
       [-0.22100930246392841, ‘autocracy_polity’],
       [-0.15726572731003752, ‘nat_oil_comp’],
       [-0.7013454686632653, ‘nat_oil_comp_state’],
       [0.13855012574945422, ‘motorization_rate’]], dtype=object)
我们的特征选择确定了增值税率、政府债务、政府有效性指标(goveffect)、国家是否属于威权主义类别、是否有国家石油公司以及是否为国有企业,以及机动化率作为前七个特征。特征的数目是一个超参数的例子,我们在这里选择七个是相当随意的。我们将在下一节讨论超参数调整的技术。
注意到在数据集中的几个威权/民主措施中,似乎最重要的是威权虚拟变量,对于polity得分非常低的国家的值为 1。它被估计对汽油税有负面影响;也就是说,会减少它们。
- 
让我们来看看预测值和残差。我们可以将测试数据中的特征传递给 transformer 的/pipeline 的 predict方法来生成预测值。存在一点正偏斜和整体偏差;残差总体上是负的:pred = ttr.predict(X_test) preddf = pd.DataFrame(pred, columns=[‘prediction’], index=X_test.index).join(X_test).join(y_test) preddf[‘resid’] = preddf.gas_tax_imp-preddf.prediction preddf.resid.agg([‘mean’,’median’,’skew’,’kurtosis’]) mean -0.09 median -0.13 skew 0.61 kurtosis 0.04 Name: resid, dtype: float64
- 
让我们也生成一些整体模型评估统计信息。我们得到平均绝对误差为 0.23。考虑到汽油税价格的中间值为0.52,这不是一个很好的平均误差。然而,r-squared 值还不错:print(“Mean Absolute Error: %.2f, R-squared: %.2f” % (skmet.mean_absolute_error(y_test, pred), skmet.r2_score(y_test, pred))) Mean Absolute Error: 0.23, R-squared: 0.75
- 
通常查看残差的图表是有帮助的。让我们也画一条代表残差平均值的红色虚线: plt.hist(preddf.resid, color=”blue”, bins=np.arange(-0.5,1.0,0.25)) plt.axvline(preddf.resid.mean(), color=’red’, linestyle=’dashed’, linewidth=1) plt.title(“Histogram of Residuals for Gax Tax Model”) plt.xlabel(“Residuals”) plt.ylabel(“Frequency”) plt.xlim() plt.show()
这产生了以下图表:

图 7.2 – 汽油税模型残差
这个图表显示了正偏斜。此外,我们的模型更有可能高估汽油税而不是低估它。(当预测值大于实际目标值时,残差为负。)
- 
让我们也看看预测值与残差的散点图。让我们在Y轴上画一条代表 0 的红色虚线: plt.scatter(preddf.prediction, preddf.resid, color=”blue”) plt.axhline(0, color=’red’, linestyle=’dashed’, linewidth=1) plt.title(“Scatterplot of Predictions and Residuals”) plt.xlabel(“Predicted Gax Tax”) plt.ylabel(“Residuals”) plt.show()
这产生了以下图表:

图 7.3 – 预测值与残差的散点图
在这里,高估发生在预测值的整个范围内,但没有低估(正残差)出现在预测值低于 0 或高于 1 的情况下。这应该让我们对我们的线性假设产生一些怀疑。
提高我们的模型评估
我们到目前为止评估模型的一个问题是,我们没有充分利用数据。我们只在大约 80%的数据上进行了训练。我们的指标也相当依赖于测试数据是否代表我们想要预测的真实世界。然而,可能并不总是如此。正如前一章所讨论的,我们可以通过 k 折交叉验证来提高我们的机会。
由于我们一直在使用 pipelines 进行我们的分析,我们已经为 k 折交叉验证做了很多工作。回想一下前一章,k 折模型评估将我们的数据分成 k 个相等的部分。其中一部分被指定为测试,其余部分用于训练。这重复 k 次,每次使用不同的折进行测试。
让我们尝试使用我们的线性回归模型进行 k 折交叉验证:
- 
我们将首先创建新的训练和测试 DataFrames,留下 10%用于后续验证。虽然保留数据用于验证不是必须的,但保留一小部分数据总是一个好主意: X_train, X_test, y_train, y_test = \ train_test_split(features,\ target, test_size=0.1, random_state=1)
- 
我们还需要实例化 KFold和LinearRegression对象:kf = KFold(n_splits=3, shuffle=True, random_state=0)
- 
现在,我们已经准备好运行我们的 k 折交叉验证。我们表示我们想要每个分割的 r-squared 和平均绝对误差。"cross_validate"自动为我们提供每个折叠的拟合和评分时间: scores = cross_validate(ttr, X=X_train, y=y_train, cv=kf, scoring=(‘r2’, ‘neg_mean_absolute_error’), n_jobs=1) print(“Mean Absolute Error: %.2f, R-squared: %.2f” % (scores[‘test_neg_mean_absolute_error’].mean(), scores[‘test_r2’].mean())) Mean Absolute Error: -0.25, R-squared: 0.62
这些分数并不十分令人印象深刻。我们没有解释掉我们希望解释的那么多方差。R-squared 分数在三个折叠中平均约为 0.62。这部分的理由是每个折叠的测试 DataFrame 相当小,每个大约有 40 个观测值。尽管如此,我们应该探索对经典线性回归方法的修改,例如正则化和非线性回归。
正则化的一个优点是,我们可能在不需要经过计算成本高昂的特征选择过程的情况下获得类似的结果。正则化还可以帮助我们避免过拟合。在下一节中,我们将使用相同的数据探索 lasso 回归。我们还将研究非线性回归策略。
使用 lasso 回归
OLS 的关键特征是它以最小的偏差产生参数估计。然而,OLS 估计可能比我们想要的方差更高。当我们使用经典线性回归模型时,我们需要小心过拟合。减少过拟合可能性的一个策略是使用正则化。正则化还可能允许我们将特征选择和模型训练结合起来。这对于具有大量特征或观测值的数据集可能很重要。
与 OLS 最小化均方误差不同,正则化技术寻求最小误差和减少特征数量。我们在本节中探讨的 lasso 回归使用 L1 正则化,它惩罚系数的绝对值。岭回归类似。它使用 L2 正则化,惩罚系数的平方值。弹性网络回归使用 L1 和 L2 正则化。
再次强调,我们将使用上一节中的汽油税数据:
- 
我们将首先导入与上一节相同的库,除了我们将导入 Lasso模块而不是linearregression模块:import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.linear_model import Lasso from sklearn.impute import SimpleImputer from sklearn.pipeline import make_pipeline from sklearn.compose import ColumnTransformer from sklearn.compose import TransformedTargetRegressor from sklearn.model_selection import cross_validate, KFold import sklearn.metrics as skmet import matplotlib.pyplot as plt
- 
我们还需要我们创建的 OutlierTrans类:import os import sys sys.path.append(os.getcwd() + “/helperfunctions”) from preprocfunc import OutlierTrans
- 
现在,让我们加载汽油税数据并创建测试和训练 DataFrame: fftaxrate14 = pd.read_csv(“data/fossilfueltaxrate14.csv”) fftaxrate14.set_index(‘countrycode’, inplace=True) num_cols = [‘fuel_income_dependence’,’national_income_per_cap’, ‘VAT_Rate’, ‘gov_debt_per_gdp’,’polity’,’goveffect’, ‘democracy_index’] dummy_cols = [‘democracy_polity’,’autocracy_polity’, ‘democracy’,’nat_oil_comp’,’nat_oil_comp_state’] spec_cols = [‘motorization_rate’] target = fftaxrate14[[‘gas_tax_imp’]] features = fftaxrate14[num_cols + dummy_cols + spec_cols] X_train, X_test, y_train, y_test = \ train_test_split(features,\ target, test_size=0.2, random_state=0)
- 
我们还需要设置列转换: standtrans = make_pipeline( OutlierTrans(2), SimpleImputer(strategy=”median”), StandardScaler()) cattrans = make_pipeline(SimpleImputer(strategy=”most_frequent”)) spectrans = make_pipeline(OutlierTrans(2), StandardScaler()) coltrans = ColumnTransformer( transformers=[ (“stand”, standtrans, num_cols), (“cat”, cattrans, dummy_cols), (“spec”, spectrans, spec_cols) ] )
- 
现在,我们已经准备好拟合我们的模型。我们将从一个相当保守的 alpha 值 0.1 开始。alpha 值越高,我们的系数惩罚就越大。在 0 时,我们得到与线性回归相同的结果。除了列转换和 lasso 回归之外,我们的管道还使用 KNN 插补缺失值。我们还将使用目标转换器来缩放汽油税目标。在我们拟合之前,我们将刚刚创建的管道传递给目标转换器的回归器参数: lasso = Lasso(alpha=0.1,fit_intercept=False) pipe1 = make_pipeline(coltrans, KNNImputer(n_neighbors=5), lasso) ttr=TransformedTargetRegressor(regressor=pipe1,transformer=StandardScaler()) ttr.fit(X_train, y_train)
- 
让我们看看 lasso 回归的系数。如果我们将它们与上一节中线性回归的系数进行比较,我们会注意到我们最终选择了相同的特征。那些在递归特征选择中被消除的特征,在很大程度上与 lasso 回归中得到接近零值的特征相同: coefs = ttr.regressor_[‘lasso’].coef_ np.column_stack((coefs.ravel(), num_cols + dummy_cols + spec_cols)) array([[‘-0.0026505240129231175’, ‘fuel_income_dependence’], [‘0.0’, ‘national_income_per_cap’], [‘0.43472262042825915’, ‘VAT_Rate’], [‘0.10927136643326674’, ‘gov_debt_per_gdp’], [‘0.006825858127837494’, ‘polity’], [‘0.15823493727828816’, ‘goveffect’], [‘0.09622123660935211’, ‘democracy_index’], [‘0.0’, ‘democracy_polity’], [‘-0.0’, ‘autocracy_polity’], [‘0.0’, ‘democracy’], [‘-0.0’, ‘nat_oil_comp’], [‘-0.2199638245781246’, ‘nat_oil_comp_state’], [‘0.016680304258453165’, ‘motorization_rate’]], dtype=’<U32’)
- 
让我们看看这个模型的预测值和残差。残差看起来相当不错,几乎没有偏差,也没有很大的偏斜: pred = ttr.predict(X_test) preddf = pd.DataFrame(pred, columns=[‘prediction’], index=X_test.index).join(X_test).join(y_test) preddf[‘resid’] = preddf.gas_tax_imp-preddf.prediction preddf.resid.agg([‘mean’,’median’,’skew’,’kurtosis’]) mean -0.06 median -0.07 skew 0.33 kurtosis 0.10 Name: resid, dtype: float64
- 
让我们也生成平均绝对误差和 r 平方。这些分数并不令人印象深刻。r 平方低于线性回归,但平均绝对误差大致相同: print(“Mean Absolute Error: %.2f, R-squared: %.2f” % (skmet.mean_absolute_error(y_test, pred), skmet.r2_score(y_test, pred))) Mean Absolute Error: 0.24, R-squared: 0.68
- 
我们应该查看残差直方图。残差的分布与线性回归模型相当相似: plt.hist(preddf.resid, color=”blue”, bins=np.arange(-0.5,1.0,0.25)) plt.axvline(preddf.resid.mean(), color=’red’, linestyle=’dashed’, linewidth=1) plt.title(“Histogram of Residuals for Gax Tax Model”) plt.xlabel(“Residuals”) plt.ylabel(“Frequency”) plt.show()
这产生了以下图表:

图 7.4 – 汽油税模型残差
- 
让我们也看看预测值与残差的散点图。我们的模型可能在较低范围内高估,在较高范围内低估。这与线性模型不同,线性模型在两个极端都持续高估: plt.scatter(preddf.prediction, preddf.resid, color=”blue”) plt.axhline(0, color=’red’, linestyle=’dashed’, linewidth=1) plt.title(“Scatterplot of Predictions and Residuals”) plt.xlabel(“Predicted Gax Tax”) plt.ylabel(“Residuals”) plt.show()
这产生了以下图表:

图 7.5 – 预测值与残差的散点图
- 
我们将通过在模型上执行 k 折交叉验证来结束。这些分数低于线性回归模型的分数,但接近: X_train, X_test, y_train, y_test = \ train_test_split(features,\ target, test_size=0.1, random_state=22) kf = KFold(n_splits=4, shuffle=True, random_state=0) scores = cross_validate(ttr, X=X_train, y=y_train, cv=kf, scoring=(‘r2’, ‘neg_mean_absolute_error’), n_jobs=1) print(“Mean Absolute Error: %.2f, R-squared: %.2f” % (scores[‘test_neg_mean_absolute_error’].mean(), scores[‘test_r2’].mean())) Mean Absolute Error: -0.27, R-squared: 0.57
这给我们提供了一个模型,它并不比我们的原始模型更好,但它至少更有效地处理了特征选择过程。也有可能如果我们尝试不同的 alpha 超参数值,我们可能会得到更好的结果。为什么不试试 0.05 或 1.0 呢?我们将在接下来的两个步骤中尝试回答这个问题。
使用网格搜索调整超参数
确定超参数的最佳值,例如前一个例子中的 alpha 值,被称为超参数调整。scikit-learn 中用于超参数调整的一个工具是 GridSearchCV。CV 后缀表示交叉验证。
使用 GridSearchCV 非常简单。如果我们已经有了管道,就像我们在这个例子中做的那样,我们将它传递给一个 GridSearchCV 对象,以及一个参数字典。GridSearchCV 将尝试所有参数组合,并返回最佳组合。让我们在我们的 lasso 回归模型上试一试:
- 
首先,我们将实例化一个 lasso对象,并创建一个包含要调整的超参数的字典。这个字典lasso_params表示我们想要尝试 0.05 和 0.9 之间的所有 alpha 值,以 0.5 的间隔。我们无法为字典键选择任何想要的名称。regressor__lasso__alpha是基于管道中步骤的名称。注意,我们正在使用双下划线。单下划线将返回错误:lasso = Lasso() lasso_params = {‘regressor__lasso__alpha’: np.arange(0.05, 1, 0.05)}
- 
现在,我们可以运行网格搜索。我们将传递管道,在这个案例中是一个 TransformedTargetRegressor,以及字典到GridSearchCV。best_params_属性表明最佳 alpha 值为0.05。当我们使用该值时,我们得到一个 r-squared 值为0.60:gs = GridSearchCV(ttr,param_grid=lasso_params, cv=5) gs.fit(X_train, y_train) gs.best_params_ {‘regressor__lasso__alpha’: 0.05} gs.best_score_ 0.6028804486340877
Lasso 回归模型在平均绝对误差和 r-squared 方面接近线性模型,但并不完全一样。Lasso 回归的一个优点是,在训练我们的模型之前,我们不需要进行单独的特征选择步骤。(回想一下,对于包装特征选择方法,模型需要在特征选择期间以及之后进行训练,正如我们在 第五章 中讨论的,特征选择。)
使用非线性回归
线性回归假设特征与目标之间的关系在特征的范围内是恒定的。你可能还记得,我们在本章开头讨论的简单线性回归方程为每个特征有一个斜率估计:
![图片 B17978_07_010.jpg]
在这里,y 是目标,每个 x 是一个特征,每个 β 是一个系数(或截距)。如果特征与目标之间的真实关系是非线性的,我们的模型可能表现不佳。
幸运的是,当我们无法假设特征与目标之间存在线性关系时,我们仍然可以很好地利用 OLS。我们可以使用与上一节相同的线性回归算法,但使用特征的多项式变换。这被称为多项式回归。
我们给特征添加一个幂来运行多项式回归。这给我们以下方程:
![图片 B17978_07_011.jpg]
下面的图比较了线性回归与多项式回归的预测值。多项式曲线似乎比线性回归线更好地拟合了虚构的数据点:

图 7.6 – 多项式方程曲线和线性方程线的示意图
在本节中,我们将对全球气象站平均年温度的线性模型和非线性模型进行实验。我们将使用纬度和海拔作为特征。首先,我们将使用多元线性回归来预测温度,然后尝试使用多项式变换的模型。按照以下步骤进行:
- 
我们将首先导入必要的库。如果你一直在本章中工作,这些库将很熟悉: import pandas as pd from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler, PolynomialFeatures from sklearn.linear_model import LinearRegression from sklearn.pipeline import make_pipeline from sklearn.model_selection import cross_validate from sklearn.model_selection import KFold from sklearn.impute import KNNImputer import matplotlib.pyplot as plt
- 
我们还需要导入包含我们用于识别异常值类别的模块: import os import sys sys.path.append(os.getcwd() + “/helperfunctions”) from preprocfunc import OutlierTrans
- 
我们加载陆地温度数据,识别我们想要的特征,并生成一些描述性统计。对于海拔高度有一些缺失值,对于平均年温度有一些极端的负值。目标和特征值的范围差异很大,所以我们可能需要缩放我们的数据: landtemps = pd.read_csv(“data/landtempsb2019avgs.csv”) landtemps.set_index(‘locationid’, inplace=True) feature_cols = [‘latabs’,’elevation’] landtemps[[‘avgtemp’] + feature_cols].\ agg([‘count’,’min’,’median’,’max’]).T count min median max avgtemp 12,095 -60.82 10.45 33.93 latabs 12,095 0.02 40.67 90.00 elevation 12,088 -350.00 271.30 4,701.00
- 
接下来,我们创建训练和测试数据框: X_train, X_test, y_train, y_test = \ train_test_split(landtemps[feature_cols],\ landtemps[[‘avgtemp’]], test_size=0.1, random_state=0)
- 
现在,我们构建一个管道来处理我们的预处理 – 将异常值设置为缺失,对所有缺失值进行 KNN 插补,并对特征进行缩放 – 然后运行线性模型。我们进行 10 折交叉验证,得到平均 r-squared 为 0.79,平均绝对误差为-2.8: lr = LinearRegression() knnimp = KNNImputer(n_neighbors=45) pipe1 = make_pipeline(OutlierTrans(3),knnimp, StandardScaler(), lr) ttr=TransformedTargetRegressor(regressor=pipe1, transformer=StandardScaler()) kf = KFold(n_splits=10, shuffle=True, random_state=0) scores = cross_validate(ttr, X=X_train, y=y_train, cv=kf, scoring=(‘r2’, ‘neg_mean_absolute_error’), n_jobs=1) scores[‘test_r2’].mean(), scores[‘test_neg_mean_absolute_error’].mean() (0.7933471824738406, -2.8047627785750913)
注意,我们在识别异常值方面非常保守。我们传递了一个阈值为 3,这意味着一个值需要比四分位数范围高或低三倍以上。显然,我们通常会更多地考虑异常值的识别。在这里,我们只演示了在决定这样做是有意义之后,如何在管道中处理异常值。
- 
让我们看看预测值和残差。总体上几乎没有偏差(残差的平均值是 0),但有一些负偏斜: ttr.fit(X_train, y_train) pred = ttr.predict(X_test) preddf = pd.DataFrame(pred, columns=[‘prediction’], index=X_test.index).join(X_test).join(y_test) preddf.resid.agg([‘mean’,’median’,’skew’,’kurtosis’]) mean 0.00 median 0.50 skew -1.13 kurtosis 3.48 Name: resid, dtype: float64
- 
如果我们创建残差的直方图,很容易看到这种偏斜。有一些极端的负残差 – 即我们过度预测平均温度的次数: plt.hist(preddf.resid, color=”blue”) plt.axvline(preddf.resid.mean(), color=’red’, linestyle=’dashed’, linewidth=1) plt.title(“Histogram of Residuals for Linear Model of Temperature”) plt.xlabel(“Residuals”) plt.ylabel(“Frequency”) plt.show()
这会产生以下图表:

图 7.7 – 温度模型残差
- 
也可以通过绘制预测值与残差的关系图来有所帮助: plt.scatter(preddf.prediction, preddf.resid, color=”blue”) plt.axhline(0, color=’red’, linestyle=’dashed’, linewidth=1) plt.title(“Scatterplot of Predictions and Residuals”) plt.xlabel(“Predicted Temperature”) plt.ylabel(“Residuals”) plt.xlim(-20,40) plt.ylim(-27,10) plt.show()
这会产生以下图表:

图 7.8 – 预测值与残差的散点图
我们的模型在预测大约 28 摄氏度以上的所有预测值时都会过度预测。它也可能会在预测 18 到 28 之间的值时低估。
让我们看看多项式回归是否能得到更好的结果:
- 
首先,我们将创建一个 PolynomialFeatures对象,其degree为4,并进行拟合。我们可以通过传递原始特征名称给get_feature_names方法来获取变换后返回的列名。每个特征的二次、三次和四次幂值被创建,以及变量之间的交互效应(例如latabs*elevation)。在这里我们不需要运行拟合,因为那将在管道中发生。我们只是在这里做这个来了解它是如何工作的:polytrans = PolynomialFeatures(degree=4, include_bias=False) polytrans.fit(X_train.dropna()) featurenames = polytrans.get_feature_names(feature_cols) featurenames [‘latabs’, ‘elevation’, ‘latabs²’, ‘latabs elevation’, ‘elevation²’, ‘latabs³’, ‘latabs² elevation’, ‘latabs elevation²’, ‘elevation³’, ‘latabs⁴’, ‘latabs³ elevation’, ‘latabs² elevation²’, ‘latabs elevation³’, ‘elevation⁴’]
- 
接下来,我们将为多项式回归创建一个管道。这个管道基本上与线性回归相同,只是在 KNN 插补之后插入多项式变换步骤: pipe2 = make_pipeline(OutlierTrans(3), knnimp, polytrans, StandardScaler(), lr) ttr2 = TransformedTargetRegressor(regressor=pipe2,\ transformer=StandardScaler())
- 
现在,让我们基于多项式模型创建预测值和残差。与线性模型相比,残差中的偏斜度要小一些: ttr2.fit(X_train, y_train) pred = ttr2.predict(X_test) preddf = pd.DataFrame(pred, columns=[‘prediction’], index=X_test.index).join(X_test).join(y_test) preddf[‘resid’] = preddf.avgtemp-preddf.prediction preddf.resid.agg([‘mean’,’median’,’skew’,’kurtosis’]) mean 0.01 median 0.20 skew -0.98 kurtosis 3.34 Name: resid, dtype: float64
- 
我们应该看一下残差的直方图: plt.hist(preddf.resid, color=”blue”) plt.axvline(preddf.resid.mean(), color=’red’, linestyle=’dashed’, linewidth=1) plt.title(“Histogram of Residuals for Temperature Model”) plt.xlabel(“Residuals”) plt.ylabel(“Frequency”) plt.show()
这产生了以下图表:

图 7.9 – 温度模型残差
- 
让我们也做一个预测值与残差的散点图。这些看起来比线性模型的残差要好一些,尤其是在预测的上限范围内: plt.scatter(preddf.prediction, preddf.resid, color=”blue”) plt.axhline(0, color=’red’, linestyle=’dashed’, linewidth=1) plt.title(“Scatterplot of Predictions and Residuals”) plt.xlabel(“Predicted Temperature”) plt.ylabel(“Residuals”) plt.xlim(-20,40) plt.ylim(-27,10) plt.show()
这产生了以下图表:

图 7.10 – 预测值与残差的散点图
- 
让我们再次进行 k 折交叉验证,并取各折的平均 r-squared 值。与线性模型相比,r-squared 和平均绝对误差都有所提高: scores = cross_validate(ttr2, X=X_train, y=y_train, cv=kf, scoring=(‘r2’, ‘neg_mean_absolute_error’), n_jobs=1) scores[‘test_r2’].mean(), scores[‘test_neg_mean_absolute_error’].mean() (0.8323274036342788, -2.4035803290965507)
多项式变换提高了我们的整体结果,尤其是在预测变量的某些范围内。高温下的残差明显较低。当我们的残差表明特征和目标之间可能存在非线性关系时,尝试多项式变换通常是一个好主意。
梯度下降回归
梯度下降可以是有序最小二乘法优化线性模型损失函数的一个很好的替代方案。这在处理非常大的数据集时尤其正确。在本节中,我们将使用梯度下降和陆地温度数据集,主要为了演示如何使用它,并给我们另一个机会探索穷举网格搜索。让我们开始吧:
- 
首先,我们将加载我们迄今为止一直在使用的相同库,以及来自 scikit-learn 的随机梯度下降回归器: import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.linear_model import SGDRegressor from sklearn.compose import TransformedTargetRegressor from sklearn.pipeline import make_pipeline from sklearn.impute import KNNImputer from sklearn.model_selection import GridSearchCV import os import sys sys.path.append(os.getcwd() + “/helperfunctions”) from preprocfunc import OutlierTrans
- 
然后,我们将再次加载陆地温度数据集并创建训练和测试数据框: landtemps = pd.read_csv(“data/landtempsb2019avgs.csv”) landtemps.set_index(‘locationid’, inplace=True) feature_cols = [‘latabs’,’elevation’] X_train, X_test, y_train, y_test = \ train_test_split(landtemps[feature_cols],\ landtemps[[‘avgtemp’]], test_size=0.1, random_state=0)
- 
接下来,我们将设置一个管道来处理异常值,在运行梯度下降之前填充缺失值并缩放数据: knnimp = KNNImputer(n_neighbors=45) sgdr = SGDRegressor() pipe1 = make_pipeline(OutlierTrans(3),knnimp,StandardScaler(), sgdr) ttr=TransformedTargetRegressor(regressor=pipe1,transformer=StandardScaler())
- 
现在,我们需要创建一个字典来指示我们想要调整的超参数和要尝试的值。我们想要尝试 alpha、损失函数、epsilon 和惩罚的值。我们将为字典中的每个键添加前缀 regressor__sgdregressor__,因为随机梯度下降回归器可以在管道中找到。
alpha参数决定了惩罚的大小。默认值是0.0001。我们可以选择 L1、L2 或弹性网络正则化。我们将选择huber和epsilon_insensitive作为要包含在搜索中的损失函数。默认损失函数是squared_error,但这只会给我们普通的有序最小二乘法。
huber损失函数对异常值不如 OLS 敏感。它的敏感度取决于我们指定的 epsilon 的值。使用epsilon_insensitive损失函数,给定范围(epsilon)内的错误不会受到惩罚(我们将在下一章构建具有 epsilon-insensitive 管的模型,我们将检查支持向量回归):
sgdr_params = {
 ‘regressor__sgdregressor__alpha’: 10.0 ** -np.arange(1, 7),
 ‘regressor__sgdregressor__loss’: [‘huber’,’epsilon_insensitive’],
 ‘regressor__sgdregressor__penalty’: [‘l2’, ‘l1’, ‘elasticnet’],
 ‘regressor__sgdregressor__epsilon’: np.arange(0.1, 1.6, 0.1)
}
- 
现在,我们已经准备好运行一个全面的网格搜索。最佳参数是 alpha 为 0.001,epsilon 为1.3,损失函数为huber,正则化为弹性网络:gs = GridSearchCV(ttr,param_grid=sgdr_params, cv=5, scoring=”r2”) gs.fit(X_train, y_train) gs.best_params_ {‘regressor__sgdregressor__alpha’: 0.001, ‘regressor__sgdregressor__epsilon’: 1.3000000000000003, ‘regressor__sgdregressor__loss’: ‘huber’, ‘regressor__sgdregressor__penalty’: ‘elasticnet’} gs.best_score_ 0.7941051735846133
- 
我通常发现查看一些其他网格搜索迭代的超参数很有帮助。具有弹性网络或 L2 正则化的 Huber 损失模型表现最佳: Results = \ pd.DataFrame(gs.cv_results_[‘mean_test_score’], \ columns=[‘meanscore’]).\ join(pd.DataFrame(gs.cv_results_[‘params’])).\ sort_values([‘meanscore’], ascending=False) results.head(3).T 254 252 534 meanscore 0.794105 0.794011 0.794009 regressor__sgdregressor__alpha 0.001000 0.001000 0.000001 regressor__sgdregressor__epsilon 1.300000 1.300000 1.500000 regressor__sgdregressor__loss huber huber huber regressor__sgdregressor__penalty elasticnet l2 l2
随机梯度下降是一种通用的优化方法,可以应用于许多机器学习问题。它通常非常高效,正如我们在这里所看到的。我们能够相当快地运行一个关于惩罚、惩罚大小、epsilon 和损失函数的全面网格搜索。
摘要
本章使我们能够探索一个非常著名的机器学习算法:线性回归。我们考察了使特征空间成为线性模型良好候选者的特性。我们还探讨了在必要时如何通过正则化和变换来改进线性模型。然后,我们研究了随机梯度下降作为 OLS 优化的替代方案。我们还学习了如何将我们自己的类添加到管道中以及如何进行超参数调整。
在下一章中,我们将探索支持向量回归。
第八章:第八章:支持向量回归
支持向量回归(SVR)在线性回归模型的假设不成立时可以是一个极佳的选择,例如当我们的特征与目标之间的关系过于复杂,无法用权重的线性组合来描述时。甚至更好,SVR 允许我们无需扩展特征空间来建模这种复杂性。
支持向量机识别出最大化两个类别之间边界的超平面。支持向量是距离边界最近的数据点,它们支持这个边界,如果可以这样表达的话。这证明它在回归建模中与在分类中一样有用。SVR 找到包含最多数据点的超平面。我们将在本章的第一节中讨论它是如何工作的。
与普通最小二乘回归不同,SVR 不是最小化平方残差的和,而是在一个可接受的误差范围内最小化系数。像岭回归和 Lasso 回归一样,这可以减少模型方差和过拟合的风险。SVR 在处理中小型数据集时效果最佳。
该算法也非常灵活,允许我们指定可接受的误差范围,使用核来建模非线性关系,并调整超参数以获得最佳的偏差-方差权衡。我们将在本章中展示这一点。
在本章中,我们将涵盖以下主题:
- 
SVR 的关键概念 
- 
基于线性模型的支持向量回归 
- 
使用核进行非线性 SVR 
技术要求
在本章中,我们将使用 scikit-learn 和matplotlib库。您可以使用pip安装这些包。
SVR 的关键概念
我们将从这个部分开始讨论支持向量机在分类中的应用。在这里我们不会深入细节,将支持向量分类的详细讨论留给第十三章,支持向量机分类。但首先从支持向量机在分类中的应用开始,将很好地过渡到 SVR 的解释。
正如我在本章开头讨论的那样,支持向量机找到最大化类别之间边界的超平面。当只有两个特征存在时,这个超平面仅仅是一条线。考虑以下示例图:

图 8.1 – 基于两个特征的支持向量机分类
图中的两个类别,用红色圆圈和蓝色正方形表示,可以使用两个特征,x1 和 x2,进行线性可分。粗体线是决策边界。它是每个类别离边界数据点最远的线,或者说是最大边界。这些点被称为支持向量。
由于前一个图中的数据是线性可分的,我们可以无问题地使用所谓的硬间隔分类;也就是说,我们可以对每个类别的所有观测值位于决策边界正确一侧的要求非常严格。但如果我们数据点的样子像下面这个图所示的呢?

图 8.2 – 具有软间隔的支持向量机分类
这些数据点不是线性可分的。在这种情况下,我们可以选择软间隔分类并忽略异常值红色圆圈。
我们将在第十三章“支持向量机分类”中更详细地讨论支持向量分类,但这也说明了支持向量机的一些关键概念。这些概念可以很好地应用于涉及连续目标值的模型。这被称为支持向量回归或SVR。
在构建 SVR 模型时,我们决定可接受的预测误差量,ɛ。在一个特征模型中,预测值  在 ɛ 范围内的误差不会被惩罚。这有时被称为 epsilon-insensitive tube。SVR 最小化系数,使得所有数据点都落在该范围内。这在下图中得到说明:
 在 ɛ 范围内的误差不会被惩罚。这有时被称为 epsilon-insensitive tube。SVR 最小化系数,使得所有数据点都落在该范围内。这在下图中得到说明:

图 8.3 – 具有可接受误差范围的 SVR
更精确地说,SVR 在满足误差 ε 不超过给定量的约束条件下,最小化系数的平方。
它在满足  的约束条件下最小化
 的约束条件下最小化  ,其中
,其中  是权重(或系数)向量,
 是权重(或系数)向量, 是实际目标值与预测值的差,
 是实际目标值与预测值的差, 是可接受的误差量。
 是可接受的误差量。
当然,期望所有数据点都落在期望范围内是不合理的。但我们仍然可以寻求最小化这种偏差。让我们用 ξ 表示偏离边缘的距离。这给我们一个新的目标函数。
我们在满足  的约束条件下最小化
 的约束条件下最小化  ,其中 C 是一个超参数,表示模型对边缘外误差的容忍度。C 的值为 0 表示它根本不容忍那些大误差。这等价于原始目标函数:
,其中 C 是一个超参数,表示模型对边缘外误差的容忍度。C 的值为 0 表示它根本不容忍那些大误差。这等价于原始目标函数:

图 8.4 – 具有超出可接受范围的点的 SVR
在这里,我们可以看到 SVR 的几个优点。有时,我们的误差不超过一定量比选择一个具有最低绝对误差的模型更重要。如果我们经常略微偏离但很少大幅偏离,可能比我们经常准确但偶尔严重偏离更重要。由于这种方法也最小化了我们的权重,它具有与正则化相同的优点,我们减少了过拟合的可能性。
非线性 SVR 和核技巧
我们尚未完全解决 SVR 中线性可分性的问题。为了简单起见,我们将回到一个涉及两个特征的分类问题。让我们看看两个特征与分类目标的关系图。目标有两个可能的值,由点和正方形表示。x1 和 x2 是数值,具有负值:

图 8.5 – 使用两个特征无法线性分离的类别标签
在这种情况下,我们该如何识别类别之间的边界呢?通常情况下,在更高的维度上可以识别出边界。在这个例子中,我们可以使用多项式变换,如下面的图表所示:

图 8.6 – 使用多项式变换建立边界
现在有一个第三维度,它是 x1 和 x2 平方的和。所有的点都高于平方。这与我们在上一章中使用多项式变换来指定非线性回归模型的方式相似。
这种方法的缺点之一是我们可能会迅速拥有太多特征,导致模型无法良好地表现。这时,核技巧就非常实用了。SVR 可以通过使用核函数隐式地扩展特征空间,而不实际创建更多特征。这是通过创建一个值向量来完成的,这些值可以用来拟合非线性边界。
虽然这允许我们拟合如前述图表中所示的一个假设的多项式变换,但 SVR 中最常用的核函数是径向基函数(RBF)。RBF 之所以受欢迎,是因为它比其他常见的核函数更快,并且因为它的伽马参数使其非常灵活。我们将在本章的最后部分探讨如何使用它。
但现在,让我们从一个相对简单的线性模型开始,看看 SVR 的实际应用。
线性模型的 SVR
我们通常有足够的领域知识,可以采取比仅仅最小化训练数据中的预测误差更细致的方法。利用这些知识,我们可能允许模型接受更多的偏差,当少量的偏差在实质上并不重要时,以减少方差。在使用 SVR 时,我们可以调整超参数,如 epsilon(可接受的误差范围)和C(调整该范围之外错误的容忍度),以改善模型的表现。
如果线性模型可以在你的数据上表现良好,线性 SVR 可能是一个不错的选择。我们可以使用 scikit-learn 的LinearSVR类构建一个线性 SVR 模型。让我们尝试使用我们在上一章中使用的汽油税数据创建一个线性 SVR 模型:
- 
我们需要使用与上一章相同的许多库来创建训练和测试 DataFrame,并预处理数据。我们还需要从 scikit-learn 和 scipy 中分别导入 LinearSVR和uniform模块:import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.svm import LinearSVR from scipy.stats import uniform from sklearn.impute import SimpleImputer from sklearn.pipeline import make_pipeline from sklearn.compose import ColumnTransformer from sklearn.compose import TransformedTargetRegressor from sklearn.impute import KNNImputer from sklearn.model_selection import cross_validate, \ KFold, GridSearchCV, RandomizedSearchCV import sklearn.metrics as skmet import matplotlib.pyplot as plt
- 
我们还需要导入 OutlierTrans类,这是我们首先在第七章中讨论的,线性回归模型,用于处理异常值:import os import sys sys.path.append(os.getcwd() + "/helperfunctions") from preprocfunc import OutlierTrans
- 
接下来,我们加载汽油税数据并创建训练和测试 DataFrame。我们创建了数值和二进制特征的列表,以及一个单独的 motorization_rate列表。正如我们在上一章查看数据时所见,我们需要对motorization_rate进行一些额外的预处理。
这个数据集包含了 2014 年每个国家的汽油税数据,以及燃料收入依赖性和衡量民主制度强度的指标:polity、democracy_polity和autocracy_polity。democracy_polity是一个二进制polity变量,对于polity得分高的国家取值为 1。autocracy_polity对于polity得分低的国家取值为 1。polity特征是衡量一个国家民主程度的一个指标:
fftaxrate14 = pd.read_csv("data/fossilfueltaxrate14.csv")
fftaxrate14.set_index('countrycode', inplace=True)
num_cols = ['fuel_income_dependence',
  'national_income_per_cap', 'VAT_Rate',  
  'gov_debt_per_gdp', 'polity','goveffect',
  'democracy_index']
dummy_cols = 'democracy_polity','autocracy_polity',
  'democracy', 'nat_oil_comp','nat_oil_comp_state']
spec_cols = ['motorization_rate']
target = fftaxrate14[['gas_tax_imp']]
features = fftaxrate14[num_cols + dummy_cols + spec_cols]
X_train, X_test, y_train, y_test =  \
  train_test_split(features,\
    target, test_size=0.2, random_state=0)
- 
让我们看看训练数据的摘要统计。我们需要标准化数据,因为数据范围差异很大,SVR 在标准化数据上表现更好。注意, motorization_rate有很多缺失值。我们可能想在这个特征上做得比简单的插补更好。对于虚拟列,我们有相当多的非缺失计数:X_train.shape (123, 13) X_train[num_cols + spec_cols].\ agg(['count','min','median','max']).T count min median max fuel_income_dependence 121 0.00 0.10 34.23 national_income_per_cap 121 260.00 6,110.00 104,540.00 VAT_Rate 121 0.00 16.00 27.00 gov_debt_per_gdp 112 1.56 38.45 194.76 polity 121 -10.00 6.00 10.00 goveffect 123 -2.04 -0.10 2.18 democracy_index 121 0.03 0.54 0.93 motorization_rate 100 0.00 0.20 0.81 X_train[dummy_cols].apply(pd.value_counts, normalize=True).T 0.00 1.00 democracy_polity 0.42 0.58 autocracy_polity 0.88 0.12 democracy 0.41 0.59 nat_oil_comp 0.54 0.46 nat_oil_comp_state 0.76 0.24 X_train[dummy_cols].count() democracy_polity 121 autocracy_polity 121 democracy 123 nat_oil_comp 121 nat_oil_comp_state 121
- 
我们需要构建一个列转换器来处理不同的数据类型。我们可以使用 SimpleImputer来处理分类特征和数值特征,除了motorization_rate。我们将在稍后使用 KNN 插补来处理motorization_rate特征:standtrans = make_pipeline(OutlierTrans(2), SimpleImputer(strategy="median"), StandardScaler()) cattrans = make_pipeline(SimpleImputer(strategy="most_frequent")) spectrans = make_pipeline(OutlierTrans(2), StandardScaler()) coltrans = ColumnTransformer( transformers=[ ("stand", standtrans, num_cols), ("cat", cattrans, dummy_cols), ("spec", spectrans, spec_cols) ] )
- 
现在,我们已经准备好拟合我们的线性 SVR 模型。我们将为 epsilon选择0.2的值。这意味着我们对实际值 0.2 标准差范围内的任何误差都感到满意(我们使用TransformedTargetRegressor来标准化目标)。我们将把C——决定模型对 epsilon 之外值容忍度的超参数——保留在其默认值 1.0。
在我们拟合模型之前,我们仍然需要处理motorization_rate的缺失值。我们将在列转换之后将 KNN 填充器添加到管道中。由于motorization_rate将是列转换后唯一的具有缺失值的特征,KNN 填充器只会改变该特征的价值。
我们需要使用目标转换器,因为列转换器只会改变特征,而不会改变目标。我们将把刚刚创建的管道传递给目标转换器的regressor参数以进行特征转换,并指出我们只想对目标进行标准缩放。
注意,线性 SVR 的默认损失函数是 L1,但我们可以选择 L2:
svr = LinearSVR(epsilon=0.2, max_iter=10000, 
  random_state=0)
pipe1 = make_pipeline(coltrans, 
  KNNImputer(n_neighbors=5), svr)
ttr=TransformedTargetRegressor(regressor=pipe1,
  transformer=StandardScaler())
ttr.fit(X_train, y_train)
- 
我们可以使用 ttr.regressor_来访问管道中的所有元素,包括linearsvr对象。这就是我们如何获得coef_属性。与 0 显著不同的系数是VAT_Rate以及专制和国家级石油公司虚拟变量。我们的模型估计,在其他条件相同的情况下,增值税率和汽油税之间存在正相关关系。它估计拥有专制或国家级石油公司与汽油税之间存在负相关关系:coefs = ttr.regressor_['linearsvr'].coef_ np.column_stack((coefs.ravel(), num_cols + dummy_cols + spec_cols)) array([['-0.03040694175014407', 'fuel_income_dependence'], ['0.10549935644031803', 'national_income_per_cap'], ['0.49519936241642026', 'VAT_Rate'], ['0.0857845735264331', 'gov_debt_per_gdp'], ['0.018198547504343885', 'polity'], ['0.12656984468734492', 'goveffect'], ['-0.09889163752261303', 'democracy_index'], ['-0.036584519840546594', 'democracy_polity'], ['-0.5446613604546718', 'autocracy_polity'], ['0.033234557366924815', 'democracy'], ['-0.2048732386478349', 'nat_oil_comp'], ['-0.6142887840649164', 'nat_oil_comp_state'], ['0.14488410358761755', 'motorization_rate']], dtype='<U32')
注意,我们在这里没有进行任何特征选择。相反,我们依赖于 L1 正则化将特征系数推向接近 0。如果我们有更多的特征,或者我们更关心计算时间,那么仔细思考我们的特征选择策略就很重要了。
- 
让我们在该模型上进行一些交叉验证。平均绝对误差和 r-squared 值并不理想,这当然受到了小样本量的影响: kf = KFold(n_splits=3, shuffle=True, random_state=0) ttr.fit(X_train, y_train) scores = cross_validate(ttr, X=X_train, y=y_train, cv=kf, scoring=('r2', 'neg_mean_absolute_error'), n_jobs=1) print("Mean Absolute Error: %.2f, R-squared: %.2f" % (scores['test_neg_mean_absolute_error'].mean(), scores['test_r2'].mean())) Mean Absolute Error: -0.26, R-squared: 0.57
我们还没有进行任何超参数调整。我们不知道我们的epsilon和C的值是否是模型的最佳值。因此,我们需要进行网格搜索来尝试不同的超参数值。我们将从穷举网格搜索开始,这通常不切实际(除非你有一个性能相当高的机器,否则我建议不要运行接下来的几个步骤)。在穷举网格搜索之后,我们将进行随机网格搜索,这通常对系统资源的影响要小得多。
- 我们将首先创建一个没有指定epsilon超参数的LinearSVR对象,并重新创建管道。然后,我们将创建一个字典svr_params,其中包含用于检查epsilon和C的值,分别称为regressor_linearsvr_epsilon和regressor_linearsvr_C。
记住我们从前一章的网格搜索中提到的,键的名称必须与我们的管道步骤相对应。在这个例子中,我们可以通过转换目标对象的regressor属性来访问我们的管道。管道中有一个linearsvr对象,具有epsilon和C的属性。
我们将svr_params字典传递给GridSearchCV对象,并指示我们希望评分基于 r-squared(如果我们想基于平均绝对误差进行评分,我们也可以这样做)。
然后,我们将运行网格搜索对象的fit方法。我应该重复之前提到的警告,你可能不希望运行穷举网格搜索,除非你使用的是高性能机器,或者你不在乎在去拿一杯咖啡的时候让它运行。请注意,在我的机器上每次运行大约需要 26 秒:
svr = LinearSVR(max_iter=100000, random_state=0)
pipe1 = make_pipeline(coltrans, 
  KNNImputer(n_neighbors=5), svr)
ttr=TransformedTargetRegressor(regressor=pipe1,
  transformer=StandardScaler())
svr_params = {
  'regressor__linearsvr__epsilon': np.arange(0.1, 1.6, 0.1),
  'regressor__linearsvr__C': np.arange(0.1, 1.6, 0.1)
}
gs = GridSearchCV(ttr,param_grid=svr_params, cv=3, 
  scoring='r2')
%timeit gs.fit(X_train, y_train)
26.2 s ± 50.7 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
- 
现在,我们可以使用网格搜索的 best_params_属性来获取与最高分数相关的超参数。我们可以通过best_scores_属性查看这些参数的分数。这告诉我们,我们以 0.1 的C值和 0.2 的epsilon值获得了最高的 r-squared 值,即 0.6:gs.best_params_ {'regressor__linearsvr__C': 0.1, 'regressor__linearsvr__epsilon': 0.2} gs.best_score_ 0.599751107082899
了解为我们的超参数选择哪些值是很好的。然而,穷举网格搜索在计算上相当昂贵。让我们尝试随机搜索。
- 
我们将指示 epsilon和C的随机值应来自介于 0 和 1.5 之间的均匀分布。然后,我们将该字典传递给RandomizedSearchCV对象。这比穷举网格搜索快得多——每次迭代略超过 1 秒。这给我们提供了比穷举网格搜索更高的epsilon和C值——即,分别为 0.23 和 0.7。然而,r-squared 值略低:svr_params = { 'regressor__linearsvr__epsilon': uniform(loc=0, scale=1.5), 'regressor__linearsvr__C': uniform(loc=0, scale=1.5) } rs = RandomizedSearchCV(ttr, svr_params, cv=3, scoring='r2') %timeit rs.fit(X_train, y_train) 1.21 s ± 24.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) rs.best_params_ {'regressor__linearsvr__C': 0.23062453444814285, 'regressor__linearsvr__epsilon': 0.6976844872643301} rs.best_score_ 0.5785452537781279
- 
让我们查看基于随机网格搜索最佳模型的预测。随机网格搜索对象的 predict方法可以为我们生成这些预测:pred = rs.predict(X_test) preddf = pd.DataFrame(pred, columns=['prediction'], index=X_test.index).join(X_test).join(y_test) preddf['resid'] = preddf.gas_tax_imp-preddf.prediction
- 
现在,让我们看看我们残差的分布: plt.hist(preddf.resid, color="blue", bins=np.arange(-0.5,1.0,0.25)) plt.axvline(preddf.resid.mean(), color='red', linestyle='dashed', linewidth=1) plt.title("Histogram of Residuals for Gas Tax Model") plt.xlabel("Residuals") plt.ylabel("Frequency") plt.xlim() plt.show()
这产生了以下图表:

图 8.7 – 加油税线性 SVR 模型的残差分布
在这里,存在一点偏差(整体上有些过度预测)和一些正偏斜。
- 
让我们再查看一下预测值与残差之间的散点图: plt.scatter(preddf.prediction, preddf.resid, color="blue") plt.axhline(0, color='red', linestyle='dashed', linewidth=1) plt.title("Scatterplot of Predictions and Residuals") plt.xlabel("Predicted Gas Tax") plt.ylabel("Residuals") plt.show()
这产生了以下图表:

图 8.8 – 加油税线性 SVR 模型的预测值与残差的散点图
这些残差是有问题的。我们在预测值的低和高范围内总是过度预测(预测值高于实际值)。这不是我们想要的,也许在提醒我们存在未考虑的非线性关系。
当我们的数据是线性可分时,线性支持向量回归(SVR)可以是一个高效的选择。它可以用在许多我们本会使用线性回归或带有正则化的线性回归的相同情况下。它的相对效率意味着我们对于使用包含超过 10,000 个观测值的数据集并不像使用非线性 SVR 那样担忧。然而,当线性可分性不可能时,我们应该探索非线性模型。
使用核函数进行非线性 SVR
回想一下本章开头我们讨论的内容,我们可以使用核函数来拟合一个非线性ε敏感管。在本节中,我们将使用我们在上一章中使用过的土地温度数据运行非线性 SVR。但首先,我们将使用相同的数据构建一个线性 SVR 以进行比较。
我们将把气象站的平均温度建模为纬度和海拔的函数。按照以下步骤进行:
- 
我们将首先加载熟悉的库。唯一的新类是来自 scikit-learn 的 SVR:import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.svm import LinearSVR, SVR from scipy.stats import uniform from sklearn.impute import SimpleImputer from sklearn.pipeline import make_pipeline from sklearn.compose import TransformedTargetRegressor from sklearn.impute import KNNImputer from sklearn.model_selection import RandomizedSearchCV import sklearn.metrics as skmet import matplotlib.pyplot as plt import os import sys sys.path.append(os.getcwd() + "/helperfunctions") from preprocfunc import OutlierTrans
- 
接下来,我们将加载土地温度数据并创建训练和测试数据框。我们还将查看一些描述性统计。海拔高度有多个缺失值,两个特征的范围差异很大。还有一些异常低的平均温度: landtemps = pd.read_csv("data/landtempsb2019avgs.csv") landtemps.set_index('locationid', inplace=True) feature_cols = ['latabs','elevation'] landtemps[['avgtemp'] + feature_cols].\ agg(['count','min','median','max']).T count min median max avgtemp 12,095 -61 10 34 latabs 12,095 0 41 90 elevation 12,088 -350 271 4,701 X_train, X_test, y_train, y_test = \ train_test_split(landtemps[feature_cols],\ landtemps[['avgtemp']], test_size=0.1, random_state=0)
- 
让我们从平均温度的线性 SVR 模型开始。我们可以对处理异常值保持相当保守,只有当四分位数范围超过三倍时,才将它们设置为缺失值。(我们在第七章,线性回归模型)中创建了 OutlierTrans类。)我们将使用 KNN 插补缺失的海拔值并缩放数据。记住,我们需要使用目标转换器来缩放目标变量。
正如我们在上一节中所做的那样,我们将使用一个字典,svr_params,来表示我们想要从均匀分布中采样超参数的值——即epsilon和C。我们将把这个字典传递给RandomizedSearchCV对象。
在运行fit之后,我们可以得到epsilon和C的最佳参数,以及最佳模型的平均绝对误差。平均绝对误差相当不错,大约为 2.8 度:
svr = LinearSVR(epsilon=1.0, max_iter=100000)
knnimp = KNNImputer(n_neighbors=45)
pipe1 = make_pipeline(OutlierTrans(3), knnimp, StandardScaler(), svr)
ttr=TransformedTargetRegressor(regressor=pipe1,
  transformer=StandardScaler())
svr_params = {
 'regressor__linearsvr__epsilon': uniform(loc=0, scale=1.5),
 'regressor__linearsvr__C': uniform(loc=0, scale=20)
}
rs = RandomizedSearchCV(ttr, svr_params, cv=10, scoring='neg_mean_absolute_error')
rs.fit(X_train, y_train)
rs.best_params_
{'regressor__linearsvr__C': 15.07662849482442,
 'regressor__linearsvr__epsilon': 0.06750238486004034}
rs.best_score_
-2.769283402595076
- 
让我们看看预测结果: pred = rs.predict(X_test) preddf = pd.DataFrame(pred, columns=['prediction'], index=X_test.index).join(X_test).join(y_test) preddf['resid'] = preddf.avgtemp-preddf.prediction plt.scatter(preddf.prediction, preddf.resid, color="blue") plt.axhline(0, color='red', linestyle='dashed', linewidth=1) plt.title("Scatterplot of Predictions and Residuals") plt.xlabel("Predicted Gas Tax") plt.ylabel("Residuals") plt.show()
这产生了以下图表:

图 8.9 – 土地温度线性 SVR 模型的预测值和残差散点图
在预测值的上限范围内有相当多的过度预测。我们通常在预测的汽油税值 15 到 25 度之间低估值。也许我们可以通过非线性模型来提高拟合度。
- 
运行非线性 SVR 不需要做太多改变。我们只需要创建一个 SVR对象并选择一个核函数。通常选择rbf。(除非你使用的是良好的硬件,或者你不在乎暂时做其他事情然后回来获取结果,否则你可能不想在你的机器上拟合这个模型。)看看下面的代码:svr = SVR(kernel='rbf') pipe1 = make_pipeline(OutlierTrans(3), knnimp, StandardScaler(), svr) ttr=TransformedTargetRegressor(regressor=pipe1, transformer=StandardScaler()) svr_params = { 'regressor__svr__epsilon': uniform(loc=0, scale=5), 'regressor__svr__C': uniform(loc=0, scale=20), 'regressor__svr__gamma': uniform(loc=0, scale=100) } rs = RandomizedSearchCV(ttr, svr_params, cv=10, scoring='neg_mean_absolute_error') rs.fit(X_train, y_train) rs.best_params_ {'regressor__svr__C': 5.3715128489311255, 'regressor__svr__epsilon': 0.03997496426101643, 'regressor__svr__gamma': 53.867632383007994} rs.best_score_ -2.1319240416548775
在平均绝对误差方面有明显的改进。在这里,我们可以看到gamma和 C 超参数为我们做了很多工作。如果我们对平均偏差 2 度左右没有异议,这个模型就能达到这个目标。
在第十三章**,支持向量机分类中,我们详细讨论了 gamma 和 C 超参数。我们还探讨了除了 rbf 核以外的其他核函数。
- 
让我们再次查看残差,看看我们的误差分布是否有问题,就像我们的线性模型那样: pred = rs.predict(X_test) preddf = pd.DataFrame(pred, columns=['prediction'], index=X_test.index).join(X_test).join(y_test) preddf['resid'] = preddf.avgtemp-preddf.prediction plt.scatter(preddf.prediction, preddf.resid, color="blue") plt.axhline(0, color='red', linestyle='dashed', linewidth=1) plt.title("Scatterplot of Predictions and Residuals") plt.xlabel("Predicted Gas Tax") plt.ylabel("Residuals") plt.show()
这产生了以下图表:

图 8.10 – 非线性 SVR 模型预测值和残差的散点图
这些残差看起来比线性模型的残差要好得多。
这说明了使用核函数如何在不增加特征空间的情况下增加我们模型的复杂性。通过使用rbf核并调整 C 和gamma超参数,我们解决了线性模型中看到的欠拟合问题。这是非线性 SVR 的一个巨大优点。缺点,正如我们之前看到的,是对系统资源的需求很大。包含 12,000 个观测值的数据库是非线性 SVR 可以轻松处理的极限,尤其是在进行最佳超参数的网格搜索时。
摘要
本章中的示例展示了 SVR 的一些优点。该算法允许我们调整超参数来解决欠拟合或过拟合问题。这可以在不增加特征数量的情况下完成。与线性回归等方法相比,SVR 对异常值也不那么敏感。
当我们可以用线性 SVR 构建一个好的模型时,这是一个完全合理的选择。它比非线性模型训练得快得多。然而,我们通常可以通过非线性 SVR 来提高性能,就像我们在本章的最后部分看到的那样。
这一讨论引出了我们在下一章将要探讨的内容,我们将探讨两种流行的非参数回归算法:k 近邻和决策树回归。这两个算法对特征和目标分布几乎没有假设。与 SVR 类似,它们可以在不增加特征空间的情况下捕捉数据中的复杂关系。
第九章:第九章:K 近邻、决策树、随机森林和梯度提升回归
对于支持向量机而言,K 近邻和决策树模型最著名的用途是分类模型。然而,它们也可以用于回归,并且相对于经典的线性回归,它们具有一些优势。K 近邻和决策树可以很好地处理非线性,并且不需要对特征的高斯分布做出任何假设。此外,通过调整K 近邻(KNN)的k值或决策树的最大深度,我们可以避免对训练数据拟合得太精确。
这使我们回到了前两章的主题——如何在不过度拟合的情况下增加模型复杂度,包括考虑非线性。我们已经看到,允许一些偏差可以减少方差,并给我们提供更可靠的模型性能估计。我们将继续在本章中探索这种平衡。
具体来说,我们将涵盖以下主要内容:
- 
K 近邻回归的关键概念 
- 
K 近邻回归 
- 
决策树和随机森林回归的关键概念 
- 
决策树和随机森林回归 
- 
使用梯度提升回归 
技术要求
在本章中,我们将使用 scikit-learn 和matplotlib库。我们还将使用 XGBoost。你可以使用pip来安装这些包。
K 近邻回归的关键概念
KNN 算法的部分吸引力在于它相当直接且易于解释。对于每个需要预测目标的观测,KNN 会找到k个训练观测,其特征与该观测最相似。当目标是分类时,KNN 会选择k个训练观测中最频繁的目标值。(我们通常在分类问题中选择一个奇数作为k,以避免平局。)
当目标是数值时,KNN 会给出k个训练观测的目标平均值。通过训练观测,我指的是那些具有已知目标值的观测。KNN 实际上并不进行真正的训练,因为它被称为懒惰学习器。我将在本节稍后详细讨论这一点。
图 9.1展示了使用 K 近邻进行分类,其中k的值为 1 和 3:当k为 1 时,我们的新观测将被分配红色标签。当k为 3 时,它将被分配蓝色标签:

图 9.1 – K 近邻,k 值为 1 和 3
但我们所说的相似或最近的观测意味着什么?有几种方法可以衡量相似度,但一个常见的衡量标准是欧几里得距离。欧几里得距离是两点之间平方差的和。这可能会让你想起勾股定理。从点 a 到点 b 的欧几里得距离如下:

曼哈顿距离是欧几里得距离的一个合理替代方案。从点 a 到点 b 的曼哈顿距离如下:

曼哈顿距离有时也被称为出租车距离。这是因为它反映了两个点在网格路径上的距离。图 9.2 展示了曼哈顿距离,并将其与欧几里得距离进行了比较:

图 9.2 – 欧几里得和曼哈顿距离度量
当特征在类型或尺度上非常不同时,使用曼哈顿距离可以获得更好的结果。然而,我们可以将距离度量的选择视为一个经验问题;也就是说,我们可以尝试两者(或其他距离度量),并看看哪个给我们提供了性能最好的模型。我们将在下一节通过网格搜索来演示这一点。
如你所怀疑的,KNN 模型的敏感性取决于 k 的选择。k 的值较低时,模型会试图识别观测值之间的细微差别。当然,在 k 值非常低的情况下,过拟合的风险很大。但在 k 值较高时,我们的模型可能不够灵活。我们再次面临方差-偏差权衡。较低的 k 值导致偏差较小而方差较大,而较高的 k 值则相反。
对于 k 的选择没有明确的答案。一个很好的经验法则是从观测值的平方根开始。然而,正如我们对距离度量的处理一样,我们应该测试模型在不同 k 值下的性能。
如我之前提到的,K 近邻是一种懒惰学习算法。在训练时间不进行任何计算。学习主要发生在测试期间。这有一些缺点。当数据中有许多实例或维度时,KNN 可能不是一个好的选择,而且预测速度很重要。它也往往在稀疏数据(即具有许多 0 值的数据集)的情况下表现不佳。
K 近邻是非参数算法。不对底层数据的属性做出假设,例如线性或正态分布的特征。当线性模型不起作用时,它通常能给出相当不错的结果。我们将在下一节构建 KNN 回归模型。
K 近邻回归
如前所述,当普通最小二乘法的假设不成立,且观测值和维度数量较少时,K 近邻算法可以成为线性回归的一个很好的替代方案。它也非常容易指定,因此即使我们最终模型中不使用它,它也可以用于诊断目的。
在本节中,我们将使用 KNN 构建一个国家层面的女性与男性收入比模型。我们将基于劳动力参与率、教育成就、青少年出生频率和最高级别的女性政治参与率。这是一个很好的数据集进行实验,因为小样本量和特征空间意味着它不太可能消耗你的系统资源。特征数量较少也使得它更容易解释。缺点是可能难以找到显著的结果。话虽如此,让我们看看我们发现了什么。
注意
我们将在本章中一直使用收入差距数据集。该数据集由联合国开发计划署在www.kaggle.com/datasets/undp/human-development上提供供公众使用。每个国家都有一个记录,包含按性别划分的 2015 年的综合就业、收入和教育数据。
让我们开始构建我们的模型:
- 
首先,我们必须导入我们在前两章中使用的一些相同的 sklearn库。我们还必须导入KNeighborsRegressor和来自第五章的老朋友——特征选择——即SelectFromModel。我们将使用SelectFromModel来将特征选择添加到我们将构建的管道中:import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.impute import SimpleImputer from sklearn.pipeline import make_pipeline from sklearn.model_selection import RandomizedSearchCV from sklearn.neighbors import KNeighborsRegressor from sklearn.linear_model import LinearRegression from sklearn.feature_selection import SelectFromModel import seaborn as sns import matplotlib.pyplot as plt
- 
我们还需要在第七章的线性回归模型中创建的 OutlierTrans类。我们将使用它根据四分位数范围来识别异常值,正如我们在第三章的识别和修复缺失值中首先讨论的那样:import os import sys sys.path.append(os.getcwd() + "/helperfunctions") from preprocfunc import OutlierTrans
- 
接下来,我们必须加载收入数据。我们还需要构建一个女性与男性收入比、教育年限比、劳动力参与比和人类发展指数比的序列。这些指标中的任何一项的值较低都表明男性可能具有优势,假设这些特征与女性与男性收入比之间存在正相关关系。例如,我们预计收入比会提高——也就是说,接近 1.0——当劳动力参与比接近 1.0 时——也就是说,当女性的劳动力参与率等于男性的时候。 
- 
我们必须删除目标 incomeratio缺失的行:un_income_gap = pd.read_csv("data/un_income_gap.csv") un_income_gap.set_index('country', inplace=True) un_income_gap['incomeratio'] = \ un_income_gap.femaleincomepercapita / \ un_income_gap.maleincomepercapita un_income_gap['educratio'] = \ un_income_gap.femaleyearseducation / \ un_income_gap.maleyearseducation un_income_gap['laborforcepartratio'] = \ un_income_gap.femalelaborforceparticipation / \ un_income_gap.malelaborforceparticipation un_income_gap['humandevratio'] = \ un_income_gap.femalehumandevelopment / \ un_income_gap.malehumandevelopment un_income_gap.dropna(subset=['incomeratio'], inplace=True)
- 
让我们看看一些数据行: num_cols = ['educratio','laborforcepartratio', 'humandevratio','genderinequality','maternalmortality', 'adolescentbirthrate','femaleperparliament', 'incomepercapita'] gap_sub = un_income_gap[['incomeratio'] + num_cols] gap_sub.head() incomeratio educratio laborforcepartratio humandevratio\ country Norway 0.78 1.02 0.89 1.00 Australia 0.66 1.02 0.82 0.98 Switzerland 0.64 0.88 0.83 0.95 Denmark 0.70 1.01 0.88 0.98 Netherlands 0.48 0.95 0.83 0.95 genderinequality maternalmortality adolescentbirthrate\ country Norway 0.07 4.00 7.80 Australia 0.11 6.00 12.10 Switzerland 0.03 6.00 1.90 Denmark 0.05 5.00 5.10 Netherlands 0.06 6.00 6.20 femaleperparliament incomepercapita country Norway 39.60 64992 Australia 30.50 42261 Switzerland 28.50 56431 Denmark 38.00 44025 Netherlands 36.90 45435
- 
让我们也看看一些描述性统计: gap_sub.\ agg(['count','min','median','max']).T count min median max incomeratio 177.00 0.16 0.60 0.93 educratio 169.00 0.24 0.93 1.35 laborforcepartratio 177.00 0.19 0.75 1.04 humandevratio 161.00 0.60 0.95 1.03 genderinequality 155.00 0.02 0.39 0.74 maternalmortality 174.00 1.00 60.00 1,100.00 adolescentbirthrate 177.00 0.60 40.90 204.80 femaleperparliament 174.00 0.00 19.35 57.50 incomepercapita 177.00 581.00 10,512.00 123,124.00
我们有 177 个观测值和目标变量incomeratio。一些特征,如humandevratio和genderinequality,有超过 15 个缺失值。我们将在那里需要填充一些合理的值。我们还需要进行一些缩放,因为一些特征的范围与其他特征非常不同,从一端的incomeratio和incomepercapita到另一端的educratio和humandevratio。
注意
数据集有妇女和男人的单独人类发展指数。该指数是健康、知识获取和生活水平的衡量标准。我们之前计算的humandevratio特征将妇女的得分除以男人的得分。genderinequality特征是那些对妇女有不成比例影响的国家的健康和劳动力市场政策的指数。femaleperparliament是最高国家立法机构中女性所占的百分比。
- 
我们还应该查看特征与目标之间的相关性热图。在我们建模时,记住更高的相关性(无论是负的还是正的)是一个好主意。更高的正相关用较暖的颜色表示。 laborforcepartratio、humandevratio和maternalmortality都与我们的目标正相关,后者有些令人惊讶。humandevratio和laborforcepartratio也是相关的,因此我们的模型可能难以区分每个特征的影响。一些特征选择将帮助我们弄清楚哪个特征更重要。(我们需要使用包装器或嵌入式特征选择方法来很好地揭示这一点。我们将在第五章,特征选择)中详细讨论这些方法。)看看以下代码:corrmatrix = gap_sub.corr(method="pearson") corrmatrix sns.heatmap(corrmatrix, xticklabels=corrmatrix.columns, yticklabels=corrmatrix.columns, cmap="coolwarm") plt.title('Heat Map of Correlation Matrix') plt.tight_layout() plt.show()
这会产生以下图表:

图 9.3 – 相关系数矩阵
- 
接下来,我们必须设置训练和测试数据框: X_train, X_test, y_train, y_test = \ train_test_split(gap_sub[num_cols],\ gap_sub[['incomeratio']], test_size=0.2, random_state=0)
现在,我们已经准备好设置 KNN 回归模型。我们还将构建一个管道来处理异常值,基于每个特征的中间值进行插补,缩放特征,并使用 scikit-learn 的SelectFromModel进行一些特征选择:
- 
我们将使用线性回归进行特征选择,但我们可以选择任何会返回特征重要性值的算法。我们将特征重要性阈值设置为平均特征重要性的 80%。平均值是默认值。我们的选择在这里相当随意,但我喜欢保留那些低于平均特征重要性水平的特征的想法,当然还有那些重要性更高的特征: knnreg = KNeighborsRegressor() feature_sel = SelectFromModel(LinearRegression(), threshold="0.8*mean") pipe1 = make_pipeline(OutlierTrans(3), \ SimpleImputer(strategy="median"), StandardScaler(), \ feature_sel, knnreg)
- 
现在,我们已经准备好进行网格搜索以找到最佳参数。首先,我们将创建一个字典, knnreg_params,以表明我们希望 KNN 模型从 3 到 19 选择k的值,跳过偶数。我们还希望网格搜索找到最佳的距离度量 – 欧几里得、曼哈顿或闵可夫斯基:knnreg_params = { 'kneighborsregressor__n_neighbors': \ np.arange(3, 21, 2), 'kneighborsregressor__metric': \ ['euclidean','manhattan','minkowski'] }
- 
我们将把这些参数传递给 RandomizedSearchCV对象,然后拟合模型。我们可以使用RandomizedSearchCV的best_params_属性来查看特征选择和 KNN 回归选择的超参数。这些结果表明,对于 KNN 的k最佳超参数值是 11,对于距离度量是曼哈顿:
最佳模型具有-0.05 的负均方误差。考虑到样本量小,这相当不错。它小于incomeratio的中位数值的 10%,而incomeratio的中位数值为 0.6:
rs = RandomizedSearchCV(pipe1, knnreg_params, cv=4, n_iter=20, \
  scoring='neg_mean_absolute_error', random_state=1)
rs.fit(X_train, y_train)
rs.best_params_
{'kneighborsregressor__n_neighbors': 11,
 'kneighborsregressor__metric': 'manhattan'}
rs.best_score_
-0.05419731104389228
- 
让我们来看看在管道的特征选择步骤中选定的特征。只选择了两个特征 – laborforcepartratio和humandevratio。请注意,这一步不是运行我们的模型的必要步骤。它只是帮助我们解释它:selected = rs.best_estimator_['selectfrommodel'].get_support() np.array(num_cols)[selected] array(['laborforcepartratio', 'humandevratio'], dtype='<U19')
- 
如果你使用的是scikit-learn 1.0 或更高版本,这会容易一些。在这种情况下,你可以使用 get_feature_names_out方法:rs.best_estimator_['selectfrommodel'].\ get_feature_names_out(np.array(num_cols)) array(['laborforcepartratio', 'humandevratio'], dtype=object)
- 
我们还应该看看一些其他的前几名结果。有一个使用 euclidean距离的模型,其表现几乎与最佳模型一样好:results = \ pd.DataFrame(rs.cv_results_['mean_test_score'], \ columns=['meanscore']).\ join(pd.DataFrame(rs.cv_results_['params'])).\ sort_values(['meanscore'], ascending=False) results.head(3).T 13 1 3 Meanscore -0.05 -0.05 -0.05 regressor__kneighborsregressor__n_neighbors 11 13 9 regressor__kneighborsregressor__metric manhattan manhattan euclidean
- 
让我们看看这个模型的残差。我们可以使用 RandomizedSearchCV对象的predict方法在测试数据上生成预测。残差在 0 附近很好地平衡。有一点负偏斜,但这也不算坏。峰度低,但我们对此没有太多尾巴的情况感到满意。这很可能反映了异常值残差不多:pred = rs.predict(X_test) preddf = pd.DataFrame(pred, columns=['prediction'], index=X_test.index).join(X_test).join(y_test) preddf['resid'] = preddf.incomeratio-preddf.prediction preddf.resid.agg(['mean','median','skew','kurtosis']) mean -0.01 median -0.01 skew -0.61 kurtosis 0.23 Name: resid, dtype: float64
- 
让我们绘制残差图: plt.hist(preddf.resid, color="blue") plt.axvline(preddf.resid.mean(), color='red', linestyle='dashed', linewidth=1) plt.title("Histogram of Residuals for Gax Tax Model") plt.xlabel("Residuals") plt.ylabel("Frequency") plt.xlim() plt.show()
这产生了以下图表:

图 9.4 – 使用 KNN 回归的收入的比率模型的残差
当我们绘制残差时,它们看起来也很不错。然而,有几个国家,我们的预测误差超过了 0.1。在这两种情况下,我们都高估了。 (虚线红色线是平均残差量。)
- 
让我们也看看散点图。在这里,我们可以看到两个大的高估值位于预测范围的两端。总的来说,残差在预测的收入比率范围内相对恒定。我们可能只是想对两个异常值做些处理: plt.scatter(preddf.prediction, preddf.resid, color="blue") plt.axhline(0, color='red', linestyle='dashed', linewidth=1) plt.title("Scatterplot of Predictions and Residuals") plt.xlabel("Predicted Income Gap") plt.ylabel("Residuals") plt.show()
这产生了以下图表:

图 9.5 – 使用 KNN 回归的收入的比率模型的预测值和残差的散点图
我们应该更仔细地看看那些残差较高的国家。我们的模型在预测阿富汗或荷兰的收入比率方面做得不好,在两种情况下都高估了很多。回想一下,我们的特征选择步骤给我们提供了一个只有两个预测因子(laborforcepartratio 和 humandevratio)的模型。
对于阿富汗,劳动力参与率(女性相对于男性的参与率)非常接近最低的 0.19,而人类发展比率处于最低水平。这仍然不能使我们接近预测非常低的收入比率(女性相对于男性的收入),这个比率也是最低的。
对于荷兰来说,劳动力参与率 0.83 比中位数 0.75 高出不少,但人类发展比率正好是中位数。这就是为什么我们的模型预测的收入比率略高于 0.6 的中位数。荷兰的实际收入比率出人意料地低:
preddf.loc[np.abs(preddf.resid)>=0.1,
  ['incomeratio', 'prediction', 'resid', 
   'laborforcepartratio', 'humandevratio']].T
country                     Afghanistan    Netherlands
incomeratio                  0.16           0.48
prediction                   0.32           0.65
resid                       -0.16          -0.17
laborforcepartratio          0.20           0.83
humandevratio                0.60           0.95
在这里,我们可以看到 KNN 回归的一些优点。我们可以在不花费大量时间指定模型的情况下,对难以建模的数据获得不错的预测。除了某些插补和缩放之外,我们没有进行任何转换或创建交互效应。我们也不必担心非线性。KNN 回归可以很好地处理这一点。
但这种方法可能扩展得不是很好。在这个例子中,一个懒惰的学习者是可以的。然而,对于更工业级的工作,我们通常需要转向一个具有许多 KNN 优点但没有一些缺点的算法。我们将在本章的剩余部分探讨决策树和随机森林回归。
决策树和随机森林回归的关键概念
决策树是一个非常有用的机器学习工具。它们具有与 KNN 相似的一些优点——它们是非参数的,易于解释,并且可以处理广泛的数据——但没有一些限制。
决策树根据数据集中观测值的特征值对观测值进行分组。这是通过一系列二元决策来完成的,从根节点的一个初始分割开始,以每个分组的叶子节点结束。所有沿着从根节点到该叶子的分支具有相同值或相同值范围的观测值,都会得到相同的预测值。当目标是数值时,这就是该叶子节点训练观测值的平均目标值。图 9.6展示了这一点:

图 9.6 – 每晚睡眠小时数的决策树模型
这是一个基于每周工作时间、孩子数量、家庭中其他成年人数量以及个人是否在学校注册的每周睡眠小时数的个人模型。(这些结果基于假设数据。)根节点基于每周工作时间,将数据分为工作时间超过 30 小时和 30 小时或以下的观察结果。括号中的数字是该节点达到的训练数据百分比。60%的观察结果工作时间超过 30 小时。在树的左侧,我们的模型进一步通过孩子数量和家中其他成年人的数量来分割数据。在树的另一侧,代表工作时间少于或等于 30 小时的观察结果,唯一的额外分割是通过学校注册情况。
我现在意识到,并非所有读者都能看到这个颜色。我们可以从每个叶子节点向上导航树,描述树是如何分割数据的。15%的观测值在家中没有其他成年人,有超过 1 个孩子,每周工作时间超过 30 小时。这些观测值的平均每晚睡眠时间为 4.5 小时。这将是对具有相同特征的新观测值的预测值。
你可能想知道决策树算法是如何选择数值特征的阈值。例如,为什么每周工作时间大于 30 小时或孩子数量大于 1 小时?算法从根节点开始,在每个级别上选择分割,以最小化平方误差之和。更精确地说,选择的分割可以最小化:

你可能已经注意到了与线性回归优化的相似之处。但是,决策树回归相对于线性回归有几个优点。决策树可以用来模拟线性关系和非线性关系,而无需我们修改特征。我们还可以使用决策树避免特征缩放,因为算法可以处理我们特征中的非常不同的范围。
决策树的主要缺点是它们的方差很高。根据我们数据的特点,每次拟合决策树时,我们可能会得到一个非常不同的模型。我们可以使用集成方法,如袋装法或随机森林,来解决这个问题。
使用随机森林回归
随机森林,可能不出所料,是决策树的集合。但这并不能区分随机森林和自助聚合,通常称为袋装法。袋装法通常用于减少具有高方差的机器学习算法的方差,如决策树。在袋装法中,我们从数据集中生成随机样本。然后,我们在每个样本上运行我们的模型,例如决策树回归,并对预测进行平均。
然而,使用袋装法生成的样本可能相关,并且产生的决策树可能有很多相似之处。这种情况在只有少数特征可以解释大部分变化时更为可能。随机森林通过限制每个树可以选定的特征数量来解决此问题。一个很好的经验法则是将可用特征的数量除以 3,以确定每个决策树每个分割要使用的特征数量。例如,如果有 21 个特征,我们会在每个分割中使用 7 个。我们将在下一节中构建决策树和随机森林回归模型。
决策树和随机森林回归
在本节中,我们将使用决策树和随机森林构建一个回归模型,使用与本章前面相同的工作收入差距数据。 我们还将使用调整来识别给我们带来最佳性能模型的超参数,就像我们在 KNN 回归中所做的那样。 让我们开始吧:
- 
我们必须加载与 KNN 回归相同的许多库,以及来自 scikit-learn 的 DecisionTreeRegressor和RandomForestRegressor:import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.impute import SimpleImputer from sklearn.pipeline import make_pipeline from sklearn.model_selection import RandomizedSearchCV from sklearn.tree import DecisionTreeRegressor, plot_tree from sklearn.ensemble import RandomForestRegressor from sklearn.linear_model import LinearRegression from sklearn.feature_selection import SelectFromModel
- 
我们还必须导入我们用于处理异常值的类: import os import sys sys.path.append(os.getcwd() + "/helperfunctions") from preprocfunc import OutlierTrans
- 
我们必须加载之前使用过的相同收入差距数据,并创建测试和训练数据框: un_income_gap = pd.read_csv("data/un_income_gap.csv") un_income_gap.set_index('country', inplace=True) un_income_gap['incomeratio'] = \ un_income_gap.femaleincomepercapita / \ un_income_gap.maleincomepercapita un_income_gap['educratio'] = \ un_income_gap.femaleyearseducation / \ un_income_gap.maleyearseducation un_income_gap['laborforcepartratio'] = \ un_income_gap.femalelaborforceparticipation / \ un_income_gap.malelaborforceparticipation un_income_gap['humandevratio'] = \ un_income_gap.femalehumandevelopment / \ un_income_gap.malehumandevelopment un_income_gap.dropna(subset=['incomeratio'], inplace=True) num_cols = ['educratio','laborforcepartratio', 'humandevratio', 'genderinequality', 'maternalmortality', 'adolescentbirthrate', 'femaleperparliament', 'incomepercapita'] gap_sub = un_income_gap[['incomeratio'] + num_cols] X_train, X_test, y_train, y_test = \ train_test_split(gap_sub[num_cols],\ gap_sub[['incomeratio']], test_size=0.2, random_state=0)
让我们从相对简单的决策树开始 – 一个没有太多层级的树。 一个简单的树可以很容易地在一页上展示。
带有解释的决策树示例
在我们构建决策树回归器之前,让我们先看看一个最大深度设置为低值的快速示例。 随着深度的增加,决策树解释和绘图变得更加困难。 让我们开始吧:
- 
我们首先实例化一个决策树回归器,限制深度为三,并要求每个叶节点至少有五个观测值。 我们创建一个仅预处理数据的管道,并将结果 NumPy 数组 X_train_imp传递给决策树回归器的fit方法:dtreg_example = DecisionTreeRegressor( min_samples_leaf=5, max_depth=3) pipe0 = make_pipeline(OutlierTrans(3), SimpleImputer(strategy="median")) X_train_imp = pipe0.fit_transform(X_train) dtreg_example.fit(X_train_imp, y_train) plot_tree(dtreg_example, feature_names=X_train.columns, label="root", fontsize=10)
这生成了以下图表:

图 9.7 – 最大深度为 3 的决策树示例
我们不会遍历这棵树上的所有节点。 通过描述通往几个叶节点的路径,我们可以了解如何解释决策树回归图的一般方法:
- 解释劳动力参与率 <= 0.307 的叶节点:
根节点分割是基于劳动力参与率小于或等于 0.601 的。 (回想一下,劳动力参与率是女性参与率与男性参与率的比率。) 有 34 个国家属于这一类别。 (分割测试的真实值在左侧。虚假值在右侧。) 之后还有另一个基于劳动力参与率的分割,这次分割点为 0.378。 有 13 个国家的值小于或等于这个值。 最后,我们到达了劳动力参与率小于或等于 0.307 的最左侧的叶节点。 有六个国家的劳动力参与率如此之低。 这六个国家的平均收入比率为 0.197。 然后,我们的决策树回归器将预测劳动力参与率小于或等于 0.307 的测试实例的收入比率为 0.197。
- 解释劳动力参与率在 0.601 到 0.811 之间,且 humandevratio <= 0.968 的叶节点:
有 107 个国家的劳动力参与率大于 0.601。这显示在树的右侧。当劳动力参与率小于或等于 0.811 时,还有一个二进制分割,进一步基于人类发展比率小于或等于 0.968 进行分割。这带我们到一个有 31 个国家的叶子节点,这些国家的人类发展比率小于或等于 0.968,劳动力参与率小于或等于 0.811,但大于 0.601。决策树回归器将预测这 31 个国家的收入比率平均值,为 0.556,对于所有测试实例,其人类发展比率和劳动力参与率的值都在这些范围内。
有趣的是,我们还没有进行任何特征选择,但这次构建决策树模型的初步尝试已经表明,仅用两个特征就可以预测收入比率:laborforcepartratio和humandevratio。
尽管这个模型的简单性使得它非常容易解释,但我们还没有完成寻找最佳超参数所需的工作。让我们接下来做这件事。
构建和解释我们的实际模型
按照以下步骤进行:
- 
首先,我们实例化一个新的决策树回归器并创建一个使用它的管道。我们还为一些超参数创建了一个字典——也就是说,对于最大树深度和每个叶子的最小样本数(观察值)。请注意,我们不需要对我们的特征或目标进行缩放,因为在决策树中这不是必要的: dtreg = DecisionTreeRegressor() feature_sel = SelectFromModel(LinearRegression(), threshold="0.8*mean") pipe1 = make_pipeline(OutlierTrans(3), SimpleImputer(strategy="median"), feature_sel, dtreg) dtreg_params={ "decisiontreeregressor__max_depth": np.arange(2, 20), "decisiontreeregressor__min_samples_leaf": np.arange(5, 11) }
- 
接下来,我们必须根据上一步的字典设置一个随机搜索。我们的决策树的最佳参数是 5 个最小样本和 9 的最大深度: rs = RandomizedSearchCV(pipe1, dtreg_params, cv=4, n_iter=20, scoring='neg_mean_absolute_error', random_state=1) rs.fit(X_train, y_train.values.ravel()) rs.best_params_ {'decisiontreeregressor__min_samples_leaf': 5, 'decisiontreeregressor__max_depth': 9} rs.best_score_ -0.05268976358459662
正如我们在上一节中讨论的,决策树在回归方面具有许多 KNN 的优点。它们容易解释,并且对底层数据没有太多假设。然而,决策树仍然可以与大型数据集合理地工作。决策树的一个不那么重要但仍然有用的优点是,它们不需要特征缩放。
但决策树确实有高方差。通常值得牺牲决策树的可解释性以换取相关方法,例如随机森林,这可以显著减少方差。我们在上一节中概念性地讨论了随机森林算法。我们将在下一节中使用收入差距数据尝试它。
随机森林回归
回想一下,随机森林可以被视为具有袋装法的决策树;它们通过减少样本之间的相关性来改进袋装法。这听起来很复杂,但它的实现与决策树一样简单。让我们看看:
- 
我们将首先实例化一个随机森林回归器并为超参数创建一个字典。我们还将为预处理和回归器创建一个管道: rfreg = RandomForestRegressor() rfreg_params = { 'randomforestregressor__max_depth': np.arange(2, 20), 'randomforestregressor__max_features': ['auto', 'sqrt'], 'randomforestregressor__min_samples_leaf': np.arange(5, 11) } pipe2 = make_pipeline(OutlierTrans(3), SimpleImputer(strategy="median"), feature_sel, rfreg)
- 
我们将把管道和超参数字典传递给 RandomizedSearchCV对象以运行网格搜索。在得分方面有轻微的改进:rs = RandomizedSearchCV(pipe2, rfreg_params, cv=4, n_iter=20, scoring='neg_mean_absolute_error', random_state=1) rs.fit(X_train, y_train.values.ravel()) rs.best_params_ {'randomforestregressor__min_samples_leaf': 5, 'randomforestregressor__max_features': 'auto', 'randomforestregressor__max_depth': 9} rs.best_score_ -0.04930503752638253
- 
让我们看看残差: pred = rs.predict(X_test) preddf = pd.DataFrame(pred, columns=['prediction'], index=X_test.index).join(X_test).join(y_test) preddf['resid'] = preddf.incomegap-preddf.prediction plt.hist(preddf.resid, color="blue", bins=5) plt.axvline(preddf.resid.mean(), color='red', linestyle='dashed', linewidth=1) plt.title("Histogram of Residuals for Income Gap") plt.xlabel("Residuals") plt.ylabel("Frequency") plt.xlim() plt.show()
这会产生以下图表:

图 9.8 – 随机森林模型在收入比率上的残差直方图
- 
让我们也看看残差与预测的散点图: plt.scatter(preddf.prediction, preddf.resid, color="blue") plt.axhline(0, color='red', linestyle='dashed', linewidth=1) plt.title("Scatterplot of Predictions and Residuals") plt.xlabel("Predicted Income Gap") plt.ylabel("Residuals") plt.show()
这会产生以下图表:

图 9.9 – 随机森林模型在收入比率上的预测与残差散点图
- 
让我们仔细看看一个显著的异常值,我们在这里严重高估了: preddf.loc[np.abs(preddf.resid)>=0.12, ['incomeratio','prediction','resid', 'laborforcepartratio', 'humandevratio']].T country Netherlands incomeratio 0.48 prediction 0.66 resid -0.18 laborforcepartratio 0.83 humandevratio 0.95
我们在荷兰仍然有困难,但残差的相对均匀分布表明这是异常的。实际上,这是一个好消息,从我们预测新实例收入比率的能力来看,表明我们的模型并没有过于努力地拟合这个不寻常的案例。
使用梯度提升回归
我们有时可以通过使用梯度提升来改进随机森林模型。与随机森林类似,梯度提升是一种集成方法,它结合了学习者,通常是树。但与随机森林不同,每棵树都是根据前一棵树的错误来学习的。这可以显著提高我们建模复杂性的能力。
虽然梯度提升不太容易过拟合,但我们必须比随机森林模型更加小心地进行超参数调整。我们可以减慢学习率,也称为收缩。我们还可以调整估计器的数量(树的数量)。学习率的选择影响所需估计器的数量。通常,如果我们减慢学习率,我们的模型将需要更多的估计器。
有几种工具可以用于实现梯度提升。我们将使用其中的两个:来自 scikit-learn 的梯度提升回归和 XGBoost。
在本节中,我们将处理房价数据。我们将尝试根据房屋及其附近房屋的特征,预测华盛顿州金斯县的房价。
注意
该数据集关于金斯县的房价可以由公众在www.kaggle.com/datasets/harlfoxem/housesalesprediction下载。它包含多个卧室、浴室和楼层,房屋和地块的平方英尺,房屋状况,15 个最近房屋的平方英尺,以及更多作为特征。
让我们开始构建模型:
- 
我们将首先导入所需的模块。其中两个新模块来自 XGBoost,分别是 GradientBoostingRegressor和XGBRegressor:import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.impute import SimpleImputer from sklearn.pipeline import make_pipeline from sklearn.preprocessing import OneHotEncoder from sklearn.preprocessing import MinMaxScaler from sklearn.compose import ColumnTransformer from sklearn.model_selection import RandomizedSearchCV from sklearn.ensemble import GradientBoostingRegressor from xgboost import XGBRegressor from sklearn.linear_model import LinearRegression from sklearn.feature_selection import SelectFromModel import matplotlib.pyplot as plt from scipy.stats import randint from scipy.stats import uniform import os import sys sys.path.append(os.getcwd() + "/helperfunctions") from preprocfunc import OutlierTrans
- 
让我们加载房价数据并查看一些实例: housing = pd.read_csv("data/kc_house_data.csv") housing.set_index('id', inplace=True) num_cols = ['bedrooms', 'bathrooms', 'sqft_living', 'sqft_lot', 'floors', 'view', 'condition', 'sqft_above', 'sqft_basement', 'yr_built', 'yr_renovated', 'sqft_living15', 'sqft_lot15'] cat_cols = ['waterfront'] housing[['price'] + num_cols + cat_cols].\ head(3).T id 7129300520 6414100192 5631500400 price 221,900 538,000 180,000 bedrooms 3 3 2 bathrooms 1 2 1 sqft_living 1,180 2,570 770 sqft_lot 5,650 7,242 10,000 floors 1 2 1 view 0 0 0 condition 3 3 3 sqft_above 1,180 2,170 770 sqft_basement 0 400 0 yr_built 1,955 1,951 1,933 yr_renovated 0 1,991 0 sqft_living15 1,340 1,690 2,720 sqft_lot15 5,650 7,639 8,062 waterfront 0 0 0
- 
我们还应该查看一些描述性统计。我们没有缺失值。我们的目标变量 price有一些极端值,不出所料。这可能会在建模中引起问题。我们还需要处理一些特征的极端值:housing[['price'] + num_cols].\ agg(['count','min','median','max']).T count min median max price 21,613 75,000 450,000 7,700,000 bedrooms 21,613 0 3 33 bathrooms 21,613 0 2 8 sqft_living 21,613 290 1,910 13,540 sqft_lot 21,613 520 7,618 1,651,359 floors 21,613 1 2 4 view 21,613 0 0 4 condition 21,613 1 3 5 sqft_above 21,613 290 1,560 9,410 sqft_basement 21,613 0 0 4,820 yr_built 21,613 1,900 1,975 2,015 yr_renovated 21,613 0 0 2,015 sqft_living15 21,613 399 1,840 6,210 sqft_lot15 21,613 651 7,620 871,200
- 
让我们创建一个房价直方图: plt.hist(housing.price/1000) plt.title("Housing Price (in thousands)") plt.xlabel('Price') plt.ylabel("Frequency") plt.show()
这生成了以下图表:

图 9.10 – 房价直方图
- 
如果我们使用目标变量的对数变换来进行建模,可能会更有运气,正如我们在第四章**,编码、转换和缩放特征中尝试的那样,使用 COVID 总病例数据。 housing['price_log'] = np.log(housing['price']) plt.hist(housing.price_log) plt.title("Housing Price Log") plt.xlabel('Price Log') plt.ylabel("Frequency") plt.show()
这产生了以下图表:

图 9.11 – 房价对数直方图
- 
这看起来更好。让我们看看价格和价格对数的偏度和峰度。对数看起来有很大的改进: housing[['price','price_log']].agg(['kurtosis','skew']) price price_log kurtosis 34.59 0.69 skew 4.02 0.43
- 
我们还应该查看一些相关性。居住面积平方英尺、地面以上面积平方英尺、最近 15 个家庭的居住面积平方英尺以及浴室数量是与价格最相关的特征。居住面积平方英尺和地面以上居住面积平方英尺高度相关。我们可能需要在模型中在这两者之间做出选择: corrmatrix = housing[['price_log'] + num_cols].\ corr(method="pearson") sns.heatmap(corrmatrix, xticklabels=corrmatrix.columns, yticklabels=corrmatrix.columns, cmap="coolwarm") plt.title('Heat Map of Correlation Matrix') plt.tight_layout() plt.show()
这产生了以下图表:

图 9.12 – 房地产特征的相关矩阵
- 
接下来,我们创建训练和测试数据框: target = housing[['price_log']] features = housing[num_cols + cat_cols] X_train, X_test, y_train, y_test = \ train_test_split(features,\ target, test_size=0.2, random_state=0)
- 
我们还需要设置我们的列转换。对于所有数值特征,即除了 waterfront之外的所有特征,我们将检查极端值,然后缩放数据:ohe = OneHotEncoder(drop='first', sparse=False) standtrans = make_pipeline(OutlierTrans(2), SimpleImputer(strategy="median"), MinMaxScaler()) cattrans = make_pipeline(ohe) coltrans = ColumnTransformer( transformers=[ ("stand", standtrans, num_cols), ("cat", cattrans, cat_cols) ] )
- 
现在,我们已准备好设置预处理和模型的管道。我们将实例化一个 GradientBoostingRegressor对象并设置特征选择。我们还将创建一个超参数字典,用于我们在下一步中进行的随机网格搜索:gbr = GradientBoostingRegressor(random_state=0) feature_sel = SelectFromModel(LinearRegression(), threshold="0.6*mean") gbr_params = { 'gradientboostingregressor__learning_rate': uniform(loc=0.01, scale=0.5), 'gradientboostingregressor__n_estimators': randint(500, 2000), 'gradientboostingregressor__max_depth': randint(2, 20), 'gradientboostingregressor__min_samples_leaf': randint(5, 11) } pipe1 = make_pipeline(coltrans, feature_sel, gbr)
- 
现在,我们已准备好运行随机网格搜索。考虑到 price_log的平均值约为 13,我们得到了相当不错的均方误差分数:rs1 = RandomizedSearchCV(pipe1, gbr_params, cv=5, n_iter=20, scoring='neg_mean_squared_error', random_state=0) rs1.fit(X_train, y_train.values.ravel()) rs1.best_params_ {'gradientboostingregressor__learning_rate': 0.118275177212, 'gradientboostingregressor__max_depth': 2, 'gradientboostingregressor__min_samples_leaf': 5, 'gradientboostingregressor__n_estimators': 1577} rs1.best_score_ -0.10695077555421204 y_test.mean() price_log 13.03 dtype: float64
- 
不幸的是,平均拟合时间相当长: print("fit time: %.3f, score time: %.3f" % (np.mean(rs1.cv_results_['mean_fit_time']),\ np.mean(rs1.cv_results_['mean_score_time']))) fit time: 35.695, score time: 0.152
- 
让我们尝试使用 XGBoost: xgb = XGBRegressor() xgb_params = { 'xgbregressor__learning_rate': uniform(loc=0.01, scale=0.5), 'xgbregressor__n_estimators': randint(500, 2000), 'xgbregressor__max_depth': randint(2, 20) } pipe2 = make_pipeline(coltrans, feature_sel, xgb)
- 
我们没有获得更好的分数,但平均拟合时间和分数时间有了显著提高: rs2 = RandomizedSearchCV(pipe2, xgb_params, cv=5, n_iter=20, scoring='neg_mean_squared_error', random_state=0) rs2.fit(X_train, y_train.values.ravel()) rs2.best_params_ {'xgbregressor__learning_rate': 0.019394900218177573, 'xgbregressor__max_depth': 7, 'xgbregressor__n_estimators': 1256} rs2.best_score_ -0.10574300757906044 print("fit time: %.3f, score time: %.3f" % (np.mean(rs2.cv_results_['mean_fit_time']),\ np.mean(rs2.cv_results_['mean_score_time']))) fit time: 3.931, score time: 0.046
XGBoost 由于许多原因已经成为一个非常受欢迎的梯度提升工具,其中一些你已经在本例中看到了。它可以快速产生非常好的结果,而且模型指定很少。我们确实需要仔细调整我们的超参数以获得首选的方差-偏差权衡,但这也适用于其他梯度提升工具,正如我们所看到的。
摘要
在本章中,我们探讨了最受欢迎的一些非参数回归算法:K 最近邻算法、决策树和随机森林。使用这些算法构建的模型可以表现良好,但也存在一些限制。我们讨论了这些技术的优缺点,包括维度和观测限制,以及对于 KNN 模型所需训练时间的担忧。我们讨论了决策树的关键挑战,即高方差,以及随机森林模型如何解决这一问题。我们还探讨了梯度提升回归树,并讨论了它们的一些优点。我们继续提高我们在超参数调整方面的技能,因为每个算法都需要一种不同的策略。
在接下来的几章中,我们将讨论监督学习算法,其中目标变量是分类的。首先,我们将从可能最熟悉的分类算法——逻辑回归开始讨论。
第四部分 - 使用监督学习建模二分类和多类目标
对于预测分类目标,有许多高性能算法。在本部分,我们将考察最流行的分类算法。我们还将考虑为什么根据我们的数据和领域知识,我们可能会选择一种算法而不是其他算法。
我们对分类模型中的欠拟合和过拟合的关注程度与之前部分中对回归模型的关注程度一样。当特征与目标之间的关系复杂时,我们需要使用能够捕捉这种复杂性的算法。但往往存在非同小可的过拟合风险。在本部分章节中,我们将讨论建模复杂性而不出现过拟合的策略。这通常涉及对逻辑回归模型进行某种形式的正则化、对决策树的树深度进行限制,以及调整支持向量分类中边缘违规的容忍度。
如果我们试图建模复杂性而不出现过拟合,我们必须准备好花大量时间进行超参数调整。在这些章节中,我们肯定会在这方面花费相当多的时间。与此相关,我们也会在交叉验证、生成和解释评估指标方面变得非常熟练。在接下来的五个章节中,我们将讨论准确率、精确度、敏感度和特异性。我们还将非常习惯于查看混淆矩阵。
我们还将探讨如何将这些算法扩展到多类目标。对于 k-最近邻和决策树来说,这是直截了当的,但对于逻辑回归和支持向量回归算法则需要扩展。这些内容将在本章中介绍。
本节包括以下章节:
- 
第十章, 逻辑回归 
- 
第十一章, 决策树和随机森林分类 
- 
第十二章, K-最近邻分类 
- 
第十三章, 支持向量机分类 
- 
第十四章, 朴素贝叶斯分类 
第十章:第十章:逻辑回归
在本章和接下来的几章中,我们将探讨分类模型。这些模型涉及具有两个或多个类别值的目标,例如学生是否会通过一门课程,或者顾客在只有鸡肉、牛肉和豆腐三种选择的情况下会选择哪一种。对于这类分类问题,存在几种机器学习算法。在本章中,我们将查看其中一些最受欢迎的算法。
逻辑回归已经用于构建具有二元目标的模型数十年了。传统上,它被用来生成独立变量或变量对二元结果概率影响估计。由于我们的重点是预测,而不是每个特征的影响,我们还将探讨正则化技术,如 lasso 回归。这些技术可以提高我们的分类预测准确性。我们还将检查预测多类别目标(当存在超过两个可能的目标值时)的策略。
在本章中,我们将涵盖以下主题:
- 
逻辑回归的关键概念 
- 
使用逻辑回归进行二元分类 
- 
使用逻辑回归进行正则化 
- 
多项式逻辑回归 
技术要求
在本章中,我们将坚持使用在大多数 Python 科学发行版中可用的库:pandas、NumPy 和 scikit-learn。本章中的所有代码都可以在 scikit-learn 版本 0.24.2 和 1.0.2 上正常运行。
逻辑回归的关键概念
如果你熟悉线性回归,或者阅读了本书的第七章,线性回归模型,你可能会预见到我们将在本章讨论的一些问题——正则化、回归器的线性关系和正态分布的残差。如果你过去构建过监督机器学习模型,或者阅读过本书的最后一章,那么你很可能预见到我们将花一些时间讨论偏差-方差权衡以及它如何影响我们选择模型。
我记得 35 年前在一门大学课程中第一次接触到逻辑回归。在本科教科书中,它通常几乎被呈现为线性回归的一个特例;也就是说,具有二元因变量的线性回归,并伴随一些变换以保持预测值在 0 到 1 之间。
它确实与数值目标变量的线性回归有许多相似之处。逻辑回归相对容易训练和解释。线性回归和逻辑回归的优化技术都是高效的,可以生成低偏差的预测器。
同样地,与线性回归一样,逻辑回归也是基于分配给每个特征的权重来预测目标。但为了将预测概率约束在 0 到 1 之间,我们使用 sigmoid 函数。这个函数将任何值映射到 0 到 1 之间的值:

当x趋近于无穷大时, 趋近于 1。当x趋近于负无穷大时,
趋近于 1。当x趋近于负无穷大时, 趋近于 0。
趋近于 0。
下面的图示说明了 sigmoid 函数:

图 10.1 – Sigmoid 函数
我们可以将线性回归的熟悉方程 代入 sigmoid 函数来预测类成员的概率:
代入 sigmoid 函数来预测类成员的概率:

在这里, 是二元情况下类成员的预测概率。系数(β)可以转换为优势比以进行解释,如下所示:
是二元情况下类成员的预测概率。系数(β)可以转换为优势比以进行解释,如下所示:

在这里,r是优势比,β是系数。特征值增加 1 个单位会乘以类成员的优势比 。同样,对于二元特征,一个真值有
。同样,对于二元特征,一个真值有 倍类成员的优势比,而一个假值也有相同特征的优势比,其他条件相同。
倍类成员的优势比,而一个假值也有相同特征的优势比,其他条件相同。
逻辑回归作为分类问题的算法具有几个优点。特征可以是二元的、分类的或数值的,且不需要服从正态分布。目标变量可以具有超过两个的可能值,正如我们稍后将要讨论的,它可以是无序的或有序的。另一个关键优点是,特征与目标之间的关系不假设是线性的。
这里的命名有点令人困惑。为什么我们使用回归算法来解决分类问题?好吧,逻辑回归预测类成员的概率。我们应用决策规则来预测这些概率。对于二元目标,默认阈值通常是 0.5;预测概率大于或等于 0.5 的实例被赋予正类或 1 或 True;那些小于 0.5 的实例被分配 0 或 False。
逻辑回归的扩展
在本章中,我们将考虑逻辑回归的两个关键扩展。我们将探讨多类模型——即目标值超过两个的模型。我们还将检查逻辑模型的正则化以改善(减少)方差。
在构建多类模型时,多项式逻辑回归(MLR)是一个流行的选择。使用 MLR,预测概率分布是一个多项式概率分布。我们可以用 softmax 函数替换我们用于二元分类器的方程:

在这里, 。这为每个类别标签j计算一个概率,其中k是类别的数量。
。这为每个类别标签j计算一个概率,其中k是类别的数量。
当我们拥有超过两个类别时,一对余(OVR)逻辑回归是多项式逻辑回归的一个替代方案。这种逻辑回归的扩展将多类别问题转化为二分类问题,估计类别成员资格相对于所有其他类别的成员资格的概率。这里的关键假设是每个类别的成员资格是独立的。在本章的例子中,我们将使用 MLR。它相较于 OVR 的一个优点是预测概率更加可靠。
如前所述,逻辑回归与线性回归有一些相同的挑战,包括我们的预测的低偏差伴随着高方差。当几个特征高度相关时,这更有可能成为一个问题。幸运的是,我们可以通过正则化来解决这个问题,就像我们在第七章,“线性回归模型”中看到的那样。
正则化会给损失函数添加一个惩罚项。我们仍然寻求最小化误差,但同时也约束参数的大小。L1正则化,也称为 lasso 回归,惩罚权重(或系数)的绝对值:

在这里,p是特征的数量,λ决定了正则化的强度。L2正则化,也称为岭回归,惩罚权重(或系数)的平方值:

L1 和 L2 正则化都将权重推向 0,尽管 L1 正则化更有可能导致稀疏模型。在 scikit-learn 中,我们使用C参数来调整λ的值,其中C只是λ的倒数:

我们可以通过弹性网络回归在 L1 和 L2 之间取得平衡。在弹性网络回归中,我们调整 L1 比率。0.5 的值表示 L1 和 L2 同等使用。我们可以使用超参数调整来选择 L1 比率的最佳值。
正则化可能导致具有更低方差模型的产生,当我们对预测的关注超过对系数的关注时,这是一个很好的权衡。
在构建具有正则化的模型之前,我们将构建一个相当简单的具有二元目标的逻辑模型。我们还将花大量时间评估该模型。这将是本书中我们将构建的第一个分类模型,并且模型评估对于这些模型与回归模型看起来非常不同。
逻辑回归的二分类
当目标为二元时,逻辑回归常用于建模健康结果,例如,一个人是否患有疾病。在本节中,我们将通过一个例子来展示这一点。我们将构建一个模型,根据个人的吸烟和饮酒习惯、健康特征(包括 BMI、哮喘、糖尿病和皮肤癌)以及年龄等个人特征来预测一个人是否会患有心脏病。
注意
在本章中,我们将专门使用可在www.kaggle.com/datasets/kamilpytlak/personal-key-indicators-of-heart-disease公开下载的心脏病数据。这个数据集来源于 2020 年美国疾病控制与预防中心超过 40 万个人的数据。数据列包括受访者是否曾经患有心脏病、体重指数、是否吸烟、大量饮酒、年龄、糖尿病和肾病。在本节中,我们将使用 30,000 个个体样本以加快处理速度,但完整的数据集可以在本书 GitHub 仓库的同一文件夹中找到。
在本章中,我们将比以前章节进行更多的预处理。我们将把大部分工作整合到我们的管道中。这将使将来重用此代码更容易,并减少数据泄露的可能性。请按照以下步骤操作:
- 
我们将首先导入我们在过去几章中使用的相同库。我们还将导入 LogisticRegression和metrics模块。我们将使用 scikit-learn 的metrics模块来评估本书这一部分中的每个分类模型。除了matplotlib用于可视化外,我们还将使用seaborn:import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.preprocessing import OneHotEncoder from sklearn.pipeline import make_pipeline from sklearn.impute import SimpleImputer from sklearn.compose import ColumnTransformer from sklearn.model_selection import StratifiedKFold from sklearn.feature_selection import RFECV from sklearn.linear_model import LogisticRegression import sklearn.metrics as skmet import matplotlib.pyplot as plt import seaborn as sns
- 
我们还需要几个自定义类来处理预处理。我们已经看到了 OutlierTrans类。在这里,我们添加了两个新的类——MakeOrdinal和ReplaceVals:import os import sys sys.path.append(os.getcwd() + "/helperfunctions") from preprocfunc import OutlierTrans,\ MakeOrdinal, ReplaceVals
MakeOrdinal 类接受一个字符特征并根据字母数字排序分配数值。例如,一个有三个可能值(不好、一般、好)的特征将被转换为一个序数特征,其值分别为 0、1 和 2。
请记住,scikit-learn 管道转换器必须具有 fit 和 transform 方法,并且必须继承自 BaseEstimator。它们通常也继承自 TransformerMixin,尽管还有其他选项。
MakeOrdinal 类中的所有操作都在 transform 方法中完成。我们遍历通过列转换器传递给它的所有列。对于每一列,我们找到所有唯一的值并按字母数字顺序排序,将唯一的值存储在我们命名为 cats 的 NumPy 数组中。然后,我们使用 lambda 函数和 NumPy 的 where 方法来找到与每个特征值关联的 cats 的索引:
class MakeOrdinal(BaseEstimator,TransformerMixin):
  def fit(self,X,y=None):
    return self
  def transform(self,X,y=None):
    Xnew = X.copy()
    for col in Xnew.columns:
      cats = np.sort(Xnew[col].unique())
      Xnew[col] = Xnew.\
        apply(lambda x: int(np.where(cats==\
        x[col])[0]), axis=1)
    return Xnew.values
当字母数字顺序与一个有意义的顺序相匹配时,MakeOrdinal 将正常工作,就像前面的例子一样。当这不是真的时,我们可以使用 ReplaceVals 来分配适当的序数值。这个类根据传递给它的字典替换任何特征中的值。
我们本可以使用 pandas 的 replace 方法而不将其放入管道中,但这样更容易将我们的重新编码与其他管道步骤(如特征缩放)集成:
class ReplaceVals(BaseEstimator,TransformerMixin):
  def __init__(self,repdict):
    self.repdict = repdict
  def fit(self,X,y=None):
    return self
  def transform(self,X,y=None):
    Xnew = X.copy().replace(self.repdict)
return Xnew.values
如果你现在不完全理解我们将如何使用这些类,请不要担心。当我们将它们添加到列转换中时,一切都会变得清晰。
- 接下来,我们将加载心脏病数据并查看几行。一些字符串特征在概念上是二元的,例如alcoholdrinkingheavy,当一个人是重度饮酒者时为Yes,否则为No。在运行模型之前,我们需要对这些特征进行编码。
agecategory特征是表示年龄区间的字符数据。我们需要将这个特征转换为数值:
healthinfo = pd.read_csv("data/healthinfo.csv")
healthinfo.set_index("personid", inplace=True)
healthinfo.head(2).T
personid                    299391       252786
heartdisease                Yes          No
bmi                         28.48        25.24
smoking                     Yes          Yes
alcoholdrinkingheavy        No           No
stroke                      No           No
physicalhealthbaddays       7            0
mentalhealthbaddays         0            2
walkingdifficult            No           No
gender                      Male         Female
agecategory                 70-74        65-69
ethnicity                   White        White
diabetic    No, borderline diabetes      No
physicalactivity            Yes          Yes
genhealth                   Good         Very good
sleeptimenightly            8            8
asthma                      No           No
kidneydisease               No           No
skincancer                  No           Yes
- 
让我们看看 DataFrame 的大小以及有多少缺失值。有 30,000 个实例,但 18 个数据列中没有任何缺失值。这太好了。当我们构建管道时,我们不必担心这一点: healthinfo.shape (30000, 18) healthinfo.isnull().sum() heartdisease 0 bmi 0 smoking 0 alcoholdrinkingheavy 0 stroke 0 physicalhealthbaddays 0 mentalhealthbaddays 0 walkingdifficult 0 gender 0 agecategory 0 ethnicity 0 diabetic 0 physicalactivity 0 genhealth 0 sleeptimenightly 0 asthma 0 kidneydisease 0 skincancer 0 dtype: int64
- 
让我们将 heartdisease变量,也就是我们的目标变量,转换为0和1变量。这将减少我们以后需要担心的事情。立即要注意的一件事是,目标变量的值非常不平衡。我们观察结果中不到 10%的人患有心脏病。这当然是好消息,但也给我们的建模带来了一些挑战,我们需要处理这些挑战:healthinfo.heartdisease.value_counts() No 27467 Yes 2533 Name: heartdisease, dtype: int64 healthinfo['heartdisease'] = \ np.where(healthinfo.heartdisease=='No',0,1).\ astype('int') healthinfo.heartdisease.value_counts() 0 27467 1 2533 Name: heartdisease, dtype: int64
- 
我们应该根据我们将要对它们进行的预处理来组织我们的特征。我们将对数值特征进行缩放,并对分类特征进行独热编码。我们希望将当前为字符串的 agecategory和genhealth特征转换为有序特征。
我们需要对diabetic特征进行特定的清理。有些人表示没有,但他们处于边缘状态。为了我们的目的,我们将它们视为no。有些人怀孕期间只有糖尿病。我们将它们视为yes。对于genhealth和diabetic,我们将设置一个字典,以指示特征值应该如何替换。我们将在管道的ReplaceVals转换器中使用该字典:
num_cols = ['bmi','physicalhealthbaddays',
   'mentalhealthbaddays','sleeptimenightly']
binary_cols = ['smoking','alcoholdrinkingheavy',
  'stroke','walkingdifficult','physicalactivity',
  'asthma','kidneydisease','skincancer']
cat_cols = ['gender','ethnicity']
spec_cols1 = ['agecategory']
spec_cols2 = ['genhealth']
spec_cols3 = ['diabetic']
rep_dict = {
  'genhealth': {'Poor':0,'Fair':1,'Good':2,
    'Very good':3,'Excellent':4},
  'diabetic': {'No':0,
    'No, borderline diabetes':0,'Yes':1,
    'Yes (during pregnancy)':1}           
}
- 
我们应该查看一些二元特征以及其他分类特征的频率。很大比例的个人(42%)报告说他们是吸烟者。14%的人报告说他们走路有困难: healthinfo[binary_cols].\ apply(pd.value_counts, normalize=True).T No Yes smoking 0.58 0.42 alcoholdrinkingheavy 0.93 0.07 stroke 0.96 0.04 walkingdifficult 0.86 0.14 physicalactivity 0.23 0.77 asthma 0.87 0.13 kidneydisease 0.96 0.04 skincancer 0.91 0.09
- 
让我们也看看其他分类特征的频率。男性和女性的数量几乎相等。大多数人报告他们的健康状况非常好或非常好: for col in healthinfo[cat_cols + ['genhealth','diabetic']].columns: print(col, "----------------------", healthinfo[col].value_counts(normalize=True).\ sort_index(), sep="\n", end="\n\n")
这会产生以下输出:
gender
----------------------
Female   0.52
Male     0.48
Name: gender, dtype: float64
ethnicity
----------------------
American Indian/Alaskan Native   0.02
Asian                            0.03
Black                            0.07
Hispanic                         0.09
Other                            0.03
White                            0.77
Name: ethnicity, dtype: float64
genhealth
----------------------
Excellent   0.21
Fair        0.11
Good        0.29
Poor        0.04
Very good   0.36
Name: genhealth, dtype: float64
diabetic
----------------------
No                        0.84
No, borderline diabetes   0.02
Yes                       0.13
Yes (during pregnancy)    0.01
Name: diabetic, dtype: float64
- 
我们还应该查看一些数值特征的描述性统计。对于不良的身体健康和心理健康天数的中位数都是 0;也就是说,至少一半的观察结果报告没有不良的身体健康天数,至少一半报告没有不良的心理健康天数: healthinfo[num_cols].\ agg(['count','min','median','max']).T count min median max bmi 30,000 12 27 92 physicalhealthbaddays 30,000 0 0 30 mentalhealthbaddays 30,000 0 0 30 sleeptimenightly 30,000 1 7 24
我们需要进行一些缩放。我们还需要对分类特征进行编码。数值特征也有一些极端值。sleeptimenightly的值为 24 似乎不太可能!处理它们可能是个好主意。
- 
现在,我们已经准备好构建我们的流水线。让我们创建训练和测试的 DataFrame: X_train, X_test, y_train, y_test = \ train_test_split(healthinfo[num_cols + binary_cols + cat_cols + spec_cols1 + spec_cols2 + spec_cols3],\ healthinfo[['heartdisease']], test_size=0.2, random_state=0)
- 
接下来,我们将设置列转换。我们将创建一个 one-hot 编码器实例,我们将使用它来处理所有分类特征。对于数值列,我们将使用 OutlierTrans对象去除极端值,然后填充中位数。
我们将使用MakeOrdinal转换器将agecategory特征转换为有序特征,并使用ReplaceVals转换器对genhealth和diabetic特征进行编码。
我们将在下一步将列转换添加到我们的流水线中:
ohe = OneHotEncoder(drop='first', sparse=False)
standtrans = make_pipeline(OutlierTrans(3),
  SimpleImputer(strategy="median"),
  StandardScaler())
spectrans1 = make_pipeline(MakeOrdinal(),
  StandardScaler())
spectrans2 = make_pipeline(ReplaceVals(rep_dict),
  StandardScaler())
spectrans3 = make_pipeline(ReplaceVals(rep_dict))
bintrans = make_pipeline(ohe)
cattrans = make_pipeline(ohe)
coltrans = ColumnTransformer(
  transformers=[
    ("stand", standtrans, num_cols),
    ("spec1", spectrans1, spec_cols1),
    ("spec2", spectrans2, spec_cols2),
    ("spec3", spectrans3, spec_cols3),
    ("bin", bintrans, binary_cols),
    ("cat", cattrans, cat_cols),
  ]
)
- 现在,我们已经准备好设置和调整我们的流水线。首先,我们将实例化逻辑回归和分层 k 折对象,我们将使用递归特征消除。回想一下,递归特征消除需要一个估计器。我们使用分层 k 折来确保每个折叠中目标值的分布大致相同。
现在,我们必须为我们的模型创建另一个逻辑回归实例。我们将class_weight参数设置为balanced。这应该会提高模型处理类别不平衡的能力。然后,我们将列转换、递归特征消除和逻辑回归实例添加到我们的流水线中,然后对其进行调整:
lrsel = LogisticRegression(random_state=1, 
  max_iter=1000)
kf = StratifiedKFold(n_splits=5, shuffle=True)
rfecv = RFECV(estimator=lrsel, cv=kf)
lr = LogisticRegression(random_state=1,
  class_weight='balanced', max_iter=1000)
pipe1 = make_pipeline(coltrans, rfecv, lr)
pipe1.fit(X_train, y_train.values.ravel())
- 
在调整后,我们需要做一些工作来恢复流水线中的列名。我们可以使用 bin转换器的 one-hot 编码器的get_feature_names方法和cat转换器的get_feature_names方法。这为我们提供了编码后的二元和分类特征的列名。数值特征的名称保持不变。我们将在后面使用这些特征名:new_binary_cols = \ pipe1.named_steps['columntransformer'].\ named_transformers_['bin'].\ named_steps['onehotencoder'].\ get_feature_names(binary_cols) new_cat_cols = \ pipe1.named_steps['columntransformer'].\ named_transformers_['cat'].\ named_steps['onehotencoder'].\ get_feature_names(cat_cols) new_cols = np.concatenate((np.array(num_cols + spec_cols1 + spec_cols2 + spec_cols3), new_binary_cols, new_cat_cols)) new_cols array(['bmi', 'physicalhealthbaddays', 'mentalhealthbaddays', 'sleeptimenightly', 'agecategory', 'genhealth', 'diabetic', 'smoking_Yes', 'alcoholdrinkingheavy_Yes', 'stroke_Yes', 'walkingdifficult_Yes', 'physicalactivity_Yes', 'asthma_Yes', 'kidneydisease_Yes', 'skincancer_Yes', 'gender_Male', 'ethnicity_Asian', 'ethnicity_Black', 'ethnicity_Hispanic', 'ethnicity_Other', 'ethnicity_White'], dtype=object)
- 
现在,让我们看看递归特征消除的结果。我们可以使用 rfecv对象的ranking_属性来获取每个特征的排名。那些排名为1的特征将被选入我们的模型。
如果我们使用rfecv对象的get_support方法或support_属性代替ranking_属性,我们只会得到那些将在我们的模型中使用的特点——即那些排名为 1 的特点。我们将在下一步做这个:
rankinglabs = \
 np.column_stack((pipe1.named_steps['rfecv'].ranking_,
 new_cols))
pd.DataFrame(rankinglabs,
 columns=['rank','feature']).\
 sort_values(['rank','feature']).\
 set_index("rank")
                       feature
rank                          
1                  agecategory
1     alcoholdrinkingheavy_Yes
1                   asthma_Yes
1                     diabetic
1              ethnicity_Asian
1              ethnicity_Other
1              ethnicity_White
1                  gender_Male
1                    genhealth
1            kidneydisease_Yes
1                  smoking_Yes
1                   stroke_Yes
1         walkingdifficult_Yes
2           ethnicity_Hispanic
3               skincancer_Yes
4                          bmi
5        physicalhealthbaddays
6             sleeptimenightly
7          mentalhealthbaddays
8         physicalactivity_Yes
9              ethnicity_Black
- 我们可以从逻辑回归的系数中获取优势比。回想一下,优势比是指数化的系数。有 13 个系数,这是有意义的,因为我们之前学习到有 13 个特征得到了 1 的排名。
我们将使用rfecv步骤的get_support方法来获取所选特征的名称,并创建一个包含这些名称和优势比(oddswithlabs)的 NumPy 数组。然后我们创建一个 pandas DataFrame,并按优势比降序排序。
毫不奇怪,那些曾经中风的人和老年人患心脏病的可能性要大得多。如果个人曾经中风,他们在其他条件相同的情况下患心脏病的几率是三倍。另一方面,随着年龄类别的增加,患心脏病的几率增加 2.88 倍。另一方面,随着总体健康状况的提高,患心脏病的几率大约减少一半(57%);比如说,从“一般”到“良好”。令人惊讶的是,在控制其他条件的情况下,大量饮酒与心脏病几率降低有关:
oddsratios = np.exp(pipe1.\
  named_steps['logisticregression'].coef_)
oddsratios.shape
(1, 13)
selcols = new_cols[pipe1.\
  named_steps['rfecv'].get_support()]
oddswithlabs = np.column_stack((oddsratios.\
  ravel(), selcols))
pd.DataFrame(oddswithlabs, 
  columns=['odds','feature']).\
  sort_values(['odds'], ascending=False).\
  set_index('odds')
                        feature
odds                          
3.01                stroke_Yes
2.88               agecategory
2.12               gender_Male
1.97         kidneydisease_Yes
1.75                  diabetic
1.55               smoking_Yes
1.52                asthma_Yes
1.30      walkingdifficult_Yes
1.27           ethnicity_Other
1.22           ethnicity_White
0.72           ethnicity_Asian
0.61  alcoholdrinkingheavy_Yes
0.57                 genhealth
现在我们已经拟合了逻辑回归模型,我们准备对其进行评估。在下一节中,我们将花时间探讨各种性能指标,包括准确率和灵敏度。我们将使用我们在第六章,“准备模型评估”中介绍的一些概念。
评估逻辑回归模型
一个分类模型性能的最直观的衡量标准是其准确率——也就是说,我们的预测有多正确。然而,在某些情况下,我们可能至少和准确率一样关心灵敏度——即我们正确预测的阳性案例的百分比;我们甚至可能愿意牺牲一点准确率来提高灵敏度。疾病预测模型通常属于这一类。但是,每当存在类别不平衡时,准确率和灵敏度等指标可能会给我们提供关于模型性能的非常不同的估计。
除了关注准确率或灵敏度之外,我们还可能担心我们的模型的特异性或精确度。我们可能希望有一个模型能够以高可靠性识别出负面案例,即使这意味着它不能很好地识别正面案例。特异性是模型识别出的所有负面案例所占的百分比。
精确度,即预测为正面的案例中实际为正面的比例,是另一个重要的衡量指标。对于某些应用来说,限制误报非常重要,即使这意味着我们必须容忍较低的灵敏度。一个使用图像识别来识别坏苹果的苹果种植者可能更倾向于选择一个高精确度的模型,而不是一个更灵敏的模型,不希望不必要地丢弃苹果。
通过查看混淆矩阵可以使这一点更加清晰:

图 10.2 – 二元目标按预测值预测的实际值混淆矩阵
混淆矩阵帮助我们理解准确率、灵敏度、特异性和精确度。准确率是我们预测正确的观察值的百分比。这可以更精确地表述如下:

敏感性是指我们正确预测正值的次数除以正值的总数。再次查看混淆矩阵可能会有所帮助,以确认实际正值可以是预测正值(TP)或预测负值(FN)。敏感性也被称为召回率或真正阳性率:

特异性是指我们正确预测负值的次数(TN)除以实际负值总数(TN + FP)。特异性也被称为真正阴性率:

精确度是指我们正确预测正值的次数(TP)除以预测的正值总数:

我们在第六章中更详细地介绍了这些概念,准备模型评估。在本节中,我们将检查心脏病逻辑回归模型的准确性、敏感性、特异性和精确度:
- 
我们可以使用上一节中拟合的管道的 predict方法来从我们的逻辑回归中生成预测。然后,我们可以生成一个混淆矩阵:pred = pipe1.predict(X_test) cm = skmet.confusion_matrix(y_test, pred) cmplot = skmet.ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=['Negative', 'Positive']) cmplot.plot() cmplot.ax_.set(title='Heart Disease Prediction Confusion Matrix', xlabel='Predicted Value', ylabel='Actual Value')
这产生了以下图表:

图 10.3 – 心脏病预测混淆矩阵
这里首先要注意的是,大部分动作都在左上角,我们在测试数据中正确预测了实际负值。这将大大有助于我们的准确性。尽管如此,我们还是有相当数量的假阳性。当没有心脏病时,我们预测心脏病 1,430 次(在 5,506 个负实例中)。我们似乎在识别正性心脏病实例方面做得还不错,正确分类了 392 个实例(在 494 个正实例中)。
- 
让我们计算准确性、敏感性、特异性和精确度。总体准确性并不高,为 74%。敏感性相当不错,为 79%。(当然,敏感性的好坏取决于领域和判断。对于像心脏病这样的领域,我们可能希望它更高。)这可以在以下代码中看到: tn, fp, fn, tp = skmet.confusion_matrix(y_test.values.ravel(), pred).ravel() tn, fp, fn, tp (4076, 1430, 102, 392) accuracy = (tp + tn) / pred.shape[0] accuracy 0.7446666666666667 sensitivity = tp / (tp + fn) sensitivity 0.7935222672064778 specificity = tn / (tn+fp) specificity 0.7402833272793317 precision = tp / (tp + fp) precision 0.21514818880351264
- 
我们可以使用 metrics模块以更直接的方式来进行这些计算(我在上一步中选择了更迂回的方法来展示计算过程):print("accuracy: %.2f, sensitivity: %.2f, specificity: %.2f, precision: %.2f" % (skmet.accuracy_score(y_test.values.ravel(), pred), skmet.recall_score(y_test.values.ravel(), pred), skmet.recall_score(y_test.values.ravel(), pred, pos_label=0), skmet.precision_score(y_test.values.ravel(), pred))) accuracy: 0.74, sensitivity: 0.79, specificity: 0.74, precision: 0.22
我们模型的最大问题是精确度非常低——即 22%。这是由于大量的假阳性。我们的模型预测正性的大多数情况下都是错误的。
除了我们已经计算出的四个指标之外,获取假阳性率也可能很有帮助。假阳性率是指我们的模型在实际值为负时预测为正的倾向:

- 
让我们计算假阳性率: falsepositiverate = fp / (tn + fp) falsepositiverate 0.25971667272066834
因此,26%的时间,当一个人没有心脏病时,我们预测他有心脏病。虽然我们当然希望限制假阳性的数量,但这通常意味着牺牲一些敏感性。我们将在本节后面演示为什么这是真的。
- 
我们应该仔细查看模型生成的预测概率。在这里,正类预测的阈值是 0.5,这在逻辑回归中通常是默认值。(回想一下,逻辑回归预测的是类成员的概率。我们需要一个伴随的决策规则,比如 0.5 阈值,来预测类别。)这可以在以下代码中看到: pred_probs = pipe1.predict_proba(X_test)[:, 1] probdf = \ pd.DataFrame(zip(pred_probs, pred, y_test.values.ravel()), columns=(['prob','pred','actual'])) probdf.groupby(['pred'])['prob'].\ agg(['min','max','count']) min max count pred 0 0.01 0.50 4178 1 0.50 0.99 1822
- 
我们可以使用核密度估计(KDE)图来可视化这些概率。我们还可以看到不同的决策规则可能如何影响我们的预测。例如,我们可以将阈值从 0.5 移动到 0.25。乍一看,这有一些优点。两个可能阈值之间的区域比没有心脏病病例的心脏病病例要多一些。我们将得到虚线之间的棕色区域,正确预测心脏病,而不会在 0.5 阈值下这样做。这比线之间的绿色区域更大,在 0.5 阈值下,我们将一些真正的阴性预测变成了 0.25 阈值下的假阳性: sns.kdeplot(probdf.loc[probdf.actual==1].prob, shade=True, color='red',label="Heart Disease") sns.kdeplot(probdf.loc[probdf.actual==0].prob, shade=True,color='green',label="No Heart Disease") plt.axvline(0.25, color='black', linestyle='dashed', linewidth=1) plt.axvline(0.5, color='black', linestyle='dashed', linewidth=1) plt.title("Predicted Probability Distribution") plt.legend(loc="upper left")
这会产生以下图表:

图 10.4 – 预测的心脏病概率分布
让我们比之前更仔细地考虑精确度和敏感性之间的权衡。记住,精确度是我们预测正类值时正确率的比率。敏感性,也称为召回率或真正阳性率,是我们识别实际正例为正例的比率。
- 
我们可以如下绘制精确度和敏感性曲线: prec, sens, ths = skmet.precision_recall_curve(y_test, pred_probs) sens = sens[1:-20] prec = prec[1:-20] ths = ths[:-20] fig, ax = plt.subplots() ax.plot(ths, prec, label='Precision') ax.plot(ths, sens, label='Sensitivity') ax.set_title('Precision and Sensitivity by Threshold') ax.set_xlabel('Threshold') ax.set_ylabel('Precision and Sensitivity') ax.legend()
这会产生以下图表:

图 10.5 – 阈值值下的精确度和敏感性
当阈值超过 0.2 时,敏感性的下降比精确度的增加更为明显。
- 
通常,查看假阳性率与敏感性率也是很有帮助的。假阳性率是我们模型在实际值为负时预测为正的倾向。了解这种关系的一种方法是通过 ROC 曲线: fpr, tpr, ths = skmet.roc_curve(y_test, pred_probs) ths = ths[1:] fpr = fpr[1:] tpr = tpr[1:] fig, ax = plt.subplots() ax.plot(fpr, tpr, linewidth=4, color="black") ax.set_title('ROC curve') ax.set_xlabel('False Positive Rate') ax.set_ylabel('Sensitivity')
这会产生以下图表:

图 10.6 – ROC 曲线
在这里,我们可以看到,随着假阳性率的增加,我们获得的敏感性增加越来越少。超过 0.5 的假阳性率,几乎没有回报。
- 
可能还有助于仅通过阈值绘制假阳性率和敏感性: fig, ax = plt.subplots() ax.plot(ths, fpr, label="False Positive Rate") ax.plot(ths, tpr, label="Sensitivity") ax.set_title('False Positive Rate and Sensitivity by Threshold') ax.set_xlabel('Threshold') ax.set_ylabel('False Positive Rate and Sensitivity') ax.legend()
这会产生以下图表:

图 10.7 – 灵敏度和假阳性率
这里,我们可以看到,当我们把阈值降低到 0.25 以下时,假阳性率比灵敏度增加得更快。
这最后两个可视化暗示了找到最佳阈值值——即在灵敏度和假阳性率之间有最佳权衡的值;至少在数学上,忽略领域知识。
- 
我们将计算 argmax函数。我们想要这个索引处的阈值值。根据这个计算,最佳阈值是 0.46,这与默认值并不太不同:jthresh = ths[np.argmax(tpr – fpr)] jthresh 0.45946882675453804
- 
我们可以根据这个替代阈值重新做混淆矩阵: pred2 = np.where(pred_probs>=jthresh,1,0) cm = skmet.confusion_matrix(y_test, pred2) cmplot = skmet.ConfusionMatrixDisplay( confusion_matrix=cm, display_labels=['Negative', 'Positive']) cmplot.plot() cmplot.ax_.set( title='Heart Disease Prediction Confusion Matrix', xlabel='Predicted Value', ylabel='Actual Value')
这产生了以下图表:

图 10.8 – 心脏病预测混淆矩阵
- 
这给我们带来了灵敏度的微小提升: skmet.recall_score(y_test.values.ravel(), pred) 0.7935222672064778 skmet.recall_score(y_test.values.ravel(), pred2) 0.8380566801619433
这里要说明的不是我们应该随意更改阈值。这通常是一个坏主意。但我们应该记住两点。首先,当我们有一个高度不平衡的类别时,0.5 的阈值可能没有意义。其次,这是依赖领域知识的一个重要地方。对于某些分类问题,假阳性远不如假阴性重要。
在本节中,我们关注了灵敏度、精确度和假阳性率作为模型性能的度量。这部分是因为空间限制,也因为这个特定目标的问题——不平衡的类别和可能对灵敏度的偏好。在接下来的几章中,我们将强调其他度量,如准确性和特异性,在其他我们将构建的模型中。在本章的其余部分,我们将探讨逻辑回归的几个扩展,包括正则化和多项式逻辑回归。
使用逻辑回归进行正则化
如果你已经阅读了第七章《线性回归模型》并阅读了本章的第一节,你对正则化的工作原理已经有了很好的了解。我们向估计器添加一个惩罚,以最小化我们的参数估计。这个惩罚的大小通常基于模型性能的度量来调整。我们将在本节中探讨这一点。按照以下步骤进行:
- 
我们将加载与上一节中使用的相同模块,以及我们需要进行必要超参数调整的模块。我们将使用 RandomizedSearchCV和uniform来找到我们惩罚强度的最佳值:import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.preprocessing import OneHotEncoder from sklearn.pipeline import make_pipeline from sklearn.impute import SimpleImputer from sklearn.compose import ColumnTransformer from sklearn.model_selection import RepeatedStratifiedKFold from sklearn.linear_model import LogisticRegression from sklearn.model_selection import RandomizedSearchCV from scipy.stats import uniform import os import sys sys.path.append(os.getcwd() + "/helperfunctions") from preprocfunc import OutlierTrans,\ MakeOrdinal, ReplaceVals
- 
接下来,我们将加载心脏病数据并进行一些处理: healthinfo = pd.read_csv("data/healthinfosample.csv") healthinfo.set_index("personid", inplace=True) healthinfo['heartdisease'] = \ np.where(healthinfo.heartdisease=='No',0,1).\ astype('int')
- 
接下来,我们将组织我们的特征,以便于我们在接下来的几个步骤中进行的列转换: num_cols = ['bmi','physicalhealthbaddays', 'mentalhealthbaddays','sleeptimenightly'] binary_cols = ['smoking','alcoholdrinkingheavy', 'stroke','walkingdifficult','physicalactivity', 'asthma','kidneydisease','skincancer'] cat_cols = ['gender','ethnicity'] spec_cols1 = ['agecategory'] spec_cols2 = ['genhealth'] spec_cols3 = ['diabetic'] rep_dict = { 'genhealth': {'Poor':0,'Fair':1,'Good':2, 'Very good':3,'Excellent':4}, 'diabetic': {'No':0, 'No, borderline diabetes':0,'Yes':1, 'Yes (during pregnancy)':1} }
- 
现在,我们必须创建测试和训练数据框: X_train, X_test, y_train, y_test = \ train_test_split(healthinfo[num_cols + binary_cols + cat_cols + spec_cols1 + spec_cols2 + spec_cols3],\ healthinfo[['heartdisease']], test_size=0.2, random_state=0)
- 
然后,我们必须设置列转换: ohe = OneHotEncoder(drop='first', sparse=False) standtrans = make_pipeline(OutlierTrans(3), SimpleImputer(strategy="median"), StandardScaler()) spectrans1 = make_pipeline(MakeOrdinal(), StandardScaler()) spectrans2 = make_pipeline(ReplaceVals(rep_dict), StandardScaler()) spectrans3 = make_pipeline(ReplaceVals(rep_dict)) bintrans = make_pipeline(ohe) cattrans = make_pipeline(ohe) coltrans = ColumnTransformer( transformers=[ ("stand", standtrans, num_cols), ("spec1", spectrans1, spec_cols1), ("spec2", spectrans2, spec_cols2), ("spec3", spectrans3, spec_cols3), ("bin", bintrans, binary_cols), ("cat", cattrans, cat_cols), ] )
- 
现在,我们已经准备好运行我们的模型了。我们将实例化逻辑回归和重复分层 k 折对象。然后,我们将创建一个包含之前步骤中的列转换和逻辑回归的管道。 
之后,我们将为超参数创建一个字典列表,而不是像在这本书中之前所做的那样只创建一个字典。这是因为并非所有超参数都能一起工作。例如,我们不能使用newton-cg求解器与 L1 惩罚一起使用。字典键名前缀的logisticregression__(注意双下划线)表示我们希望将这些值传递到管道的逻辑回归步骤。
我们将设置随机网格搜索的n_iter参数为20,以便它采样超参数 20 次。每次,网格搜索都将从列出的一个字典中选择超参数。我们将指示我们希望网格搜索的评分基于 ROC 曲线下的面积:
lr = LogisticRegression(random_state=1, class_weight='balanced', max_iter=1000)
kf = RepeatedStratifiedKFold(n_splits=7, n_repeats=3, random_state=0)
pipe1 = make_pipeline(coltrans, lr)
reg_params = [
  {
    'logisticregression__solver': ['liblinear'],
    'logisticregression__penalty': ['l1','l2'],
    'logisticregression__C': uniform(loc=0, scale=10)
  },
  {
    'logisticregression__solver': ['newton-cg'],
    'logisticregression__penalty': ['l2'],
    'logisticregression__C': uniform(loc=0, scale=10)
  },
  {
    'logisticregression__solver': ['saga'],
    'logisticregression__penalty': ['elasticnet'],
    'logisticregression__l1_ratio': uniform(loc=0, scale=1),   
    'logisticregression__C': uniform(loc=0, scale=10)
  }
]
rs = RandomizedSearchCV(pipe1, reg_params, cv=kf, 
  n_iter=20, scoring='roc_auc')
rs.fit(X_train, y_train.values.ravel())
- 
在拟合搜索后, best_params属性给出了与最高分数相关的参数。弹性网络回归,其 L1 比率更接近 L1 而不是 L2,表现最佳:rs.best_params_ {'logisticregression__C': 0.6918282397356423, 'logisticregression__l1_ratio': 0.758705704020254, 'logisticregression__penalty': 'elasticnet', 'logisticregression__solver': 'saga'} rs.best_score_ 0.8410275986723489
- 
让我们看看网格搜索的一些其他高分。最好的三个模型得分几乎相同。一个使用弹性网络回归,另一个使用 L1,另一个使用 L2。 
网格搜索的cv_results_字典为我们提供了关于尝试过的 20 个模型的大量信息。该字典中的params列表结构有些复杂,因为某些键在某些迭代中不存在,例如L1_ratio。我们可以使用json_normalize来简化结构:
results = \
  pd.DataFrame(rs.cv_results_['mean_test_score'], \
    columns=['meanscore']).\
  join(pd.json_normalize(rs.cv_results_['params'])).\
  sort_values(['meanscore'], ascending=False)
results.head(3).T
                              15          4      12
meanscore                     0.841       0.841  0.841
logisticregression__C         0.692       1.235  0.914
logisticregression__l1_ratio  0.759       NaN    NaN
logisticregression__penalty   elasticnet  l1     l2
logisticregression__solver  saga  liblinear  liblinear
- 
让我们看看混淆矩阵: pred = rs.predict(X_test) cm = skmet.confusion_matrix(y_test, pred) cmplot = \ skmet.ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=['Negative', 'Positive']) cmplot.plot() cmplot.ax_.\ set(title='Heart Disease Prediction Confusion Matrix', xlabel='Predicted Value', ylabel='Actual Value')
这生成了以下图表:

图 10.9 – 心脏病预测混淆矩阵
- 
让我们也看看一些度量指标。我们的分数与没有正则化的模型基本没有变化: print("accuracy: %.2f, sensitivity: %.2f, specificity: %.2f, precision: %.2f" % (skmet.accuracy_score(y_test.values.ravel(), pred), skmet.recall_score(y_test.values.ravel(), pred), skmet.recall_score(y_test.values.ravel(), pred, pos_label=0), skmet.precision_score(y_test.values.ravel(), pred))) accuracy: 0.74, sensitivity: 0.79, specificity: 0.74, precision: 0.21
尽管正则化并没有明显提高我们模型的表现,但很多时候它确实做到了。在使用 L1 正则化时,也不必过于担心特征选择,因为不太重要的特征的权重将会是 0。
尽管我们还没有解决如何处理目标值超过两个的可能值的模型,尽管上一两节的几乎所有讨论也适用于多类模型。在下一节中,我们将学习如何使用多项式逻辑回归来建模多类目标。
多项式逻辑回归
如果逻辑回归只适用于二元分类问题,那么它就不会那么有用。幸运的是,当我们的目标值超过两个时,我们可以使用多项式逻辑回归。
在本节中,我们将处理关于空气和工艺温度、扭矩和旋转速度作为机器故障函数的数据。
注意
这个关于机器故障的数据集可在www.kaggle.com/datasets/shivamb/machine-predictive-maintenance-classification公开使用。有 10,000 个观测值,12 个特征,以及两个可能的目标。一个是二元的——也就是说,机器故障或未故障。另一个是故障类型。这个数据集中的实例是合成的,由一个旨在模仿机器故障率和原因的过程生成。
让我们学习如何使用多项式逻辑回归来建模机器故障:
- 
首先,我们将导入现在熟悉的库。我们还将导入 cross_validate,我们首次在第六章准备模型评估中使用它,以帮助我们评估我们的模型:import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.preprocessing import OneHotEncoder from sklearn.pipeline import make_pipeline from sklearn.impute import SimpleImputer from sklearn.compose import ColumnTransformer from sklearn.model_selection import RepeatedStratifiedKFold from sklearn.linear_model import LogisticRegression from sklearn.model_selection import cross_validate import os import sys sys.path.append(os.getcwd() + "/helperfunctions") from preprocfunc import OutlierTrans
- 
我们将加载机器故障数据并查看其结构。我们没有缺失数据。这是个好消息: machinefailuretype = pd.read_csv("data/machinefailuretype.csv") machinefailuretype.info() <class 'pandas.core.frame.DataFrame'> RangeIndex: 10000 entries, 0 to 9999 Data columns (total 10 columns): # Column Non-Null Count Dtype --- ------ -------------- ---- 0 udi 10000 non-null int64 1 product 10000 non-null object 2 machinetype 10000 non-null object 3 airtemp 10000 non-null float64 4 processtemperature 10000 non-null float64 5 rotationalspeed 10000 non-null int64 6 torque 10000 non-null float64 7 toolwear 10000 non-null int64 8 fail 10000 non-null int64 9 failtype 10000 non-null object dtypes: float64(3), int64(4), object(3) memory usage: 781.4+ KB
- 
让我们查看几行。 machinetype的值为L、M和H。这些值分别是低、中、高质机器的代理:machinefailuretype.head() udi product machinetype airtemp processtemperature\ 0 1 M14860 M 298 309 1 2 L47181 L 298 309 2 3 L47182 L 298 308 3 4 L47183 L 298 309 4 5 L47184 L 298 309 Rotationalspeed torque toolwear fail failtype 0 1551 43 0 0 No Failure 1 1408 46 3 0 No Failure 2 1498 49 5 0 No Failure 3 1433 40 7 0 No Failure 4 1408 40 9 0 No Failure
- 
我们还应该生成一些频率: machinefailuretype.failtype.value_counts(dropna=False).sort_index() Heat Dissipation Failure 112 No Failure 9652 Overstrain Failure 78 Power Failure 95 Random Failures 18 Tool Wear Failure 45 Name: failtype, dtype: int64 machinefailuretype.machinetype.\ value_counts(dropna=False).sort_index() H 1003 L 6000 M 2997 Name: machinetype, dtype: int64
- 
让我们将 failtype值合并,并为它们创建数字代码。由于随机故障的计数很低,我们将随机故障和工具磨损故障合并:def setcode(typetext): if (typetext=="No Failure"): typecode = 1 elif (typetext=="Heat Dissipation Failure"): typecode = 2 elif (typetext=="Power Failure"): typecode = 3 elif (typetext=="Overstrain Failure"): typecode = 4 else: typecode = 5 return typecode machinefailuretype["failtypecode"] = \ machinefailuretype.apply(lambda x: setcode(x.failtype), axis=1)
- 
我们应该确认 failtypecode是否按我们的意图工作:machinefailuretype.groupby(['failtypecode','failtype']).size().\ reset_index() failtypecode failtype 0 0 1 No Failure 9652 1 2 Heat Dissipation Failure 112 2 3 Power Failure 95 3 4 Overstrain Failure 78 4 5 Random Failures 18 5 5 Tool Wear Failure 45
- 
让我们也获取一些描述性统计信息: num_cols = ['airtemp','processtemperature','rotationalspeed', 'torque','toolwear'] cat_cols = ['machinetype'] machinefailuretype[num_cols].agg(['min','median','max']).T min median max airtemp 295 300 304 processtemperature 306 310 314 rotationalspeed 1,168 1,503 2,886 torque 4 40 77 toolwear 0 108 253
- 
现在,让我们创建测试和训练数据框。我们还将设置列转换: X_train, X_test, y_train, y_test = \ train_test_split(machinefailuretype[num_cols + cat_cols], machinefailuretype[['failtypecode']], test_size=0.2, random_state=0) ohe = OneHotEncoder(drop='first', sparse=False) standtrans = make_pipeline(OutlierTrans(3), SimpleImputer(strategy="median"), StandardScaler()) cattrans = make_pipeline(ohe) coltrans = ColumnTransformer( transformers=[ ("stand", standtrans, num_cols), ("cat", cattrans, cat_cols), ] )
- 
现在,让我们设置一个包含我们的列转换和多项逻辑回归模型的管道。当我们实例化逻辑回归时,只需将 multi_class属性设置为多项式即可:lr = LogisticRegression(random_state=0, multi_class='multinomial', solver='lbfgs', max_iter=1000) kf = RepeatedStratifiedKFold(n_splits=10, n_repeats=5, random_state=0) pipe1 = make_pipeline(coltrans, lr)
- 
现在,我们可以生成一个混淆矩阵: cm = skmet.confusion_matrix(y_test, pipe1.fit(X_train, y_train.values.ravel()).\ predict(X_test)) cmplot = \ skmet.ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=['None', 'Heat','Power','Overstrain','Other']) cmplot.plot() cmplot.ax_.\ set(title='Machine Failure Type Confusion Matrix', xlabel='Predicted Value', ylabel='Actual Value')
这会产生以下图表:

图 10.10 – 预测机器故障类型的混淆矩阵
混淆矩阵显示,当存在故障时,我们的模型在预测故障类型方面做得并不好,尤其是电力故障或其他故障。
- 我们可以使用cross_validate来评估这个模型。我们主要得到准确率、精确率和敏感度(召回率)的优异成绩。然而,这是误导性的。当类别如此不平衡(几乎所有实例都是无故障)时,加权分数会受到包含几乎所有值的类别的严重影响。我们的模型能够可靠地正确预测无故障。
如果我们查看f1_macro分数(回忆一下第六章,准备模型评估,其中f1是精确率和敏感性的调和平均值),我们会看到,除了无故障类别之外,我们的模型在其它类别上表现并不好。(macro分数只是一个简单的平均值。)
我们本可以使用分类报告在这里,就像我们在第六章,“准备模型评估”中做的那样,但我有时发现生成我需要的统计数据很有帮助:
scores = cross_validate(
  pipe1, X_train, y_train.values.ravel(), \
  scoring=['accuracy', 'precision_weighted',
           'recall_weighted', 'f1_macro',
           'f1_weighted'], 
  cv=kf, n_jobs=-1)
accuracy, precision, sensitivity, f1_macro, f1_weighted = \
  np.mean(scores['test_accuracy']),\
  np.mean(scores['test_precision_weighted']),\
  np.mean(scores['test_recall_weighted']),\
  np.mean(scores['test_f1_macro']),\
  np.mean(scores['test_f1_weighted'])
accuracy, precision, sensitivity, f1_macro, f1_weighted
(0.9716499999999999,
 0.9541025493784612,
 0.9716499999999999,
 0.3820938909478524,
 0.9611411229222823)
在本节中,我们探讨了如何构建多项逻辑回归模型。这种方法无论目标变量是名义变量还是有序变量都适用。在这种情况下,它是名义变量。我们还看到了如何将用于具有二元目标的逻辑回归模型中的模型评估方法进行扩展。我们回顾了当我们有超过两个类别时如何解释混淆矩阵和评分指标。
摘要
逻辑回归多年来一直是我预测分类目标的首选工具。它是一个高效且偏差低的算法。一些其缺点,如高方差和难以处理高度相关的预测因子,可以通过正则化和特征选择来解决。我们在本章中探讨了如何做到这一点。我们还考察了如何处理不平衡类别,以及这些目标对建模和结果解释的含义。
在下一章中,我们将探讨分类中一个非常流行的逻辑回归替代方案——决策树。我们会看到决策树具有许多优点,使得它们在需要建模复杂性时成为一个特别好的选择,而不必像使用逻辑回归那样过多地担心我们的特征是如何指定的。
第十一章:第十一章:决策树和随机森林分类
决策树和随机森林是非常流行的分类模型。这部分的理由是它们易于训练和解释。它们也非常灵活。我们可以建模复杂性,而无需必然增加特征空间或转换特征。我们甚至不需要对多类问题应用算法做任何特殊处理,这是我们在逻辑回归中必须做的。
另一方面,决策树可能不如其他分类模型稳定,对训练数据中的微小变化相当敏感。当存在显著的类别不平衡(一个类别的观测值比另一个类别多得多)时,决策树也可能存在偏差。幸运的是,这些问题可以通过诸如袋装法来减少方差和过采样来处理不平衡的技术来解决。我们将在本章中探讨这些技术。
在本章中,我们将涵盖以下主题:
- 
关键概念 
- 
决策树模型 
- 
实现随机森林 
- 
实现梯度提升 
技术要求
除了我们迄今为止一直在使用的 scikit-learn 模块之外,我们还将使用来自 Imbalanced-learn 的 SMOTENC。我们将使用 SMOTENC 来解决类别不平衡问题。可以使用pip install -U imbalanced-learn命令安装 Imbalanced-learn 库。本章中的所有代码都使用 scikit-learn 版本 0.24.2 和 1.0.2 进行了测试。
关键概念
决策树是一个非常有用的机器学习工具。它们是非参数的,易于解释,并且可以处理各种类型的数据。不假设特征与目标之间关系的线性,也不假设误差项的正态性。甚至不需要对数据进行缩放。决策树还经常能够很好地捕捉预测变量和目标之间的复杂关系。
决策树算法的灵活性和其建模数据中复杂和未预见的关联的能力,归功于用于分割数据的递归分割过程。决策树根据其特征值对观测值进行分组。这是通过一系列二进制决策来完成的,从根节点处的初始分割开始,以每个分组的叶子节点结束。每个分割都是基于提供关于目标最多信息的特征和特征值。更精确地说,分割的选择基于它是否产生最低的 Gini 不纯度得分。我们将在稍后更详细地讨论 Gini 不纯度。
沿着从根节点到叶子的分支,具有相同值或相同值范围的全部新观测值,都会得到相同的预测目标值。当目标是分类时,这就是该叶子节点训练观测值中目标的最频繁值。
以下图表提供了一个相当直接的决策树示例,其中包含虚构的数据和大学完成情况模型的结果。对于这个决策树,通过高中学业成绩将那些成绩在 3.0 或以下和成绩高于 3.0 的人分开,发现与其他可用特征以及其他阈值相比,这种初始分割会导致最低的不纯度。因此,高中学业成绩是我们的根节点,也称为深度 0:

图 11.1 – 完成大学教育的决策树
根节点处的二分分割导致树左侧的观测值占 45%,右侧的占 55%。在深度 1,两侧都有基于父母收入的二分分割,尽管阈值不同;左侧为$80k,右侧为$45k。对于高中学业成绩大于 3 且父母收入高于$80k 的情况,没有更多的分割。在这里,我们得到了毕业的预测。这是一个叶子节点。
我们可以从每个叶子节点向上导航树,描述树是如何分割数据的,就像我们对父母收入高于$80k 和高中学业成绩高于 3.0 的个人所做的那样。例如,决策树预测那些从学校获得低水平支持、父母收入超过$45k 和高中学业成绩低于或等于 3 的个人不会毕业。
那么,决策树算法是如何施展这种魔法的?它是如何选择特征和阈值或类值的?为什么是父母收入大于$80k 或$45k?为什么对于父母收入低于或等于$45k 的情况,在深度 2(分割 3)处收到补助,而对于其他叶子节点的深度 2 则是学生支持水平?甚至有一个叶子节点在深度 2 都没有进一步的分割。
一种衡量二分分割对类信息提供的信息的方法是它帮助我们区分类内和类外成员资格的程度。我们经常使用基尼不纯度计算来进行这种评估,尽管有时也使用熵。基尼不纯度统计量告诉我们每个节点上类成员资格分割得有多好。这可以在以下公式中看到:

在这里, 是属于k类和m个类的概率,而m是类的数量。如果一个节点上的类成员资格相等,那么基尼不纯度为 0.5。当完全纯净时,它为 0。
是属于k类和m个类的概率,而m是类的数量。如果一个节点上的类成员资格相等,那么基尼不纯度为 0.5。当完全纯净时,它为 0。
尝试手动计算 Gini 不纯度可能会有所帮助,以更好地理解它是如何工作的。我们可以为以下图中显示的非常简单的决策树做这件事。这里只有两个叶节点——一个是为高中 GPA 大于 3 的个人,另一个是为高中 GPA 小于或等于 3 的个人。 (再次强调,这些计数是为了说明目的而编造的。我们假设图 11.1中使用的百分比是基于 100 人中的计数。)请看以下图表:

图 11.2 - 具有一个分割和 Gini 不纯度计算的决策树
根据这个模型,对于 GPA 高的个人,Graduated会被预测,因为其中大多数,45 人中的 40 人,都毕业了。Gini 不纯度相对较低,这是好的。我们可以使用前面的公式计算该节点的 Gini 不纯度:

我们的模型会预测高中 GPA 小于或等于 3 的个人未毕业,因为这些人中的大多数都没有从大学毕业。然而,这里的纯度要低得多。该节点的 Gini 不纯度值如下:

决策树算法计算从给定点开始的所有可能分割的 Gini 不纯度值的加权总和,并选择得分最低的分割。如果使用熵而不是 Gini 不纯度,算法将遵循类似的过程。在本章中,我们将使用 scikit-learn 的分类和回归树(CART)算法来构建决策树。该工具默认使用 Gini 不纯度,尽管我们可以让它使用熵。
决策树是我们所说的贪婪学习器。算法选择在当前级别给出最佳 Gini 不纯度或熵得分的分割。它不会检查该选择如何影响随后可用的分割,也不会基于该信息重新考虑当前级别的选择。这使得算法比其他情况下更有效率,但它可能不会提供全局最优解。
决策树的主要缺点是它们的方差高。它们可能会过度拟合训练数据中的异常观测值,因此在新数据上表现不佳。根据我们数据的特点,我们每次拟合决策树时都可能得到一个非常不同的模型。我们可以使用集成方法,如 bagging 或随机森林,来解决这个问题。
使用随机森林进行分类
随机森林,可能不会令人惊讶,是一系列决策树的集合。但这并不能区分随机森林和自助聚合(通常称为 bagging)。Bagging 通常用于减少具有高方差的机器学习算法(如决策树)的方差。使用 bagging,我们从数据集中生成随机样本,比如说 100 个。然后,我们在每个样本上运行我们的模型,例如决策树分类器,并对预测进行平均。
然而,使用 bagging 生成的样本可能存在相关性,并且产生的决策树可能有很多相似之处。这种情况在只有少数特征可以解释大部分变化时更为可能。随机森林通过限制每个分割可以选定的特征数量来解决这一问题。对于决策树分类模型的一个好的经验法则是取可用特征数的平方根来确定要使用的特征数。例如,如果有 25 个特征,我们会在每个分割中使用 5 个。
让我们更精确地描述构建随机森林所涉及的步骤:
- 
从训练数据中随机采样实例(样本具有与原始数据集相同的观测数。) 
- 
随机选择样本中的特征(每次选择的好数量是可用特征总数的平方根。) 
- 
从步骤 2中随机选择的特征中确定一个特征,分割会导致具有最大纯度的节点。 
- 
内部循环:重复执行步骤 2和步骤 3,直到构建出一个决策树。 
- 
外部循环:重复所有步骤,包括内部循环,直到创建出所需数量的树。所有树的结果由投票决定;也就是说,基于所有树对于给定特征值的最高频率类别标签来预测类别。 
这个过程的另一个有趣副作用是它为我们生成了测试数据。所谓的自助抽样过程——即带替换的抽样——会导致许多实例被排除在一个或多个树之外,通常多达三分之一。这些实例,被称为袋外样本,可以用来评估模型。
基于许多不相关的决策树进行分类预测,对方差(降低它!)有积极的影响,这是你可以预期的。随机森林模型通常比决策树模型更具泛化能力。它们不太容易过拟合,也不太可能被异常数据所影响。但这也带来了一定的代价。构建一百个或更多的决策树比只构建一个需要更多的系统资源。我们也失去了决策树易于解释的优点;解释每个特征的重要性变得更加困难。
使用梯度提升决策树
从概念上讲,梯度提升决策树与随机森林相似。它们依赖于多个决策树来提高模型性能。但它们是顺序执行的,每个树都从前一个树学习。每个新的树都从前一个迭代的残差开始工作。
梯度提升决策树的学习速率由ɑ超参数决定。你可能想知道为什么我们不希望我们的模型尽可能快地学习。更快的学习速率更有效率,对系统资源的消耗也更少。然而,我们可以通过降低学习速率构建一个更具泛化能力的模型。过度拟合的风险更小。最佳学习速率最终是一个经验问题。我们需要进行一些超参数调整来找到它。我们将在本章的最后部分进行这项工作。
正如随机森林的情况一样,我们可以通过进行二元分类问题的步骤来提高我们对梯度提升的直觉:
- 
根据样本中目标变量的平均值对目标进行初步预测。对于二元目标,这是一个比例。将此预测分配给所有观测值。(我们在这里使用类别成员概率的对数。) 
- 
计算每个实例的残差,对于类内实例,将是 1 减去初始预测,对于类外实例,是 0 减去预测,或-预测。 
- 
构建一个决策树来预测残差。 
- 
基于决策树模型生成新的预测。 
- 
根据新的预测(按学习率缩放)调整每个实例的先前预测。如前所述,我们使用学习率是因为我们不希望预测移动得太快。 
- 
如果未达到最大树的数量或残差非常小,则回退到步骤 3。 
虽然这是梯度提升工作原理的简化解释,但它确实为我们提供了算法所做工作的良好感觉。希望这也有助于你理解为什么梯度提升变得如此受欢迎。该算法反复调整以适应先前错误,但这样做相对高效,并且比单独的决策树有更小的过度拟合风险。
在本章的其余部分,我们将讨论决策树、随机森林和梯度提升的示例。我们将讨论如何调整超参数以及如何评估这些模型。我们还将讨论每种方法的优缺点。
决策树模型
在本章中,我们将再次使用心脏病数据。这将是一个很好的方法来比较我们的逻辑回归模型的结果与决策树等非参数模型的结果。按照以下步骤进行:
- 
首先,我们加载到目前为止一直在使用的相同库。新的模块是来自 scikit-learn 的 DecisionTreeClassifier和来自 Imbalance Learn 的SMOTENC,这将帮助我们处理不平衡数据:import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import OneHotEncoder from imblearn.pipeline import make_pipeline from sklearn.compose import ColumnTransformer from sklearn.model_selection import RandomizedSearchCV from imblearn.over_sampling import SMOTENC from sklearn.tree import DecisionTreeClassifier, plot_tree from scipy.stats import randint import sklearn.metrics as skmet import os import sys sys.path.append(os.getcwd() + "/helperfunctions") from preprocfunc import MakeOrdinal,\ ReplaceVals
- 
让我们加载心脏病数据。我们将把我们的目标 heartdisease转换为0和1的整数。我们不会重复之前章节中从数据中生成的频率和描述性统计信息。如果你没有完成第十章,逻辑回归,快速查看它们可能是有帮助的:healthinfo = pd.read_csv("data/healthinfosample.csv") healthinfo.set_index("personid", inplace=True) healthinfo.heartdisease.value_counts() No 27467 Yes 2533 Name: heartdisease, dtype: int64 healthinfo['heartdisease'] = \ np.where(healthinfo.heartdisease=='No',0,1).\ astype('int') healthinfo.heartdisease.value_counts() 0 27467 1 2533 Name: heartdisease, dtype: int64
注意类别不平衡。不到 10%的观察结果患有心脏病。我们将在模型中处理这个问题。
- 
让我们再看看年龄类别特征的值,因为它有点不寻常。它包含年龄范围的字符数据。我们将使用我们加载的 MakeOrdinal类将其转换为有序特征。例如,18-24的值将给我们一个0的值,而50-54的值在转换后将变为6:healthinfo.agecategory.value_counts().\ sort_index().reset_index() index agecategory 0 18-24 1973 1 25-29 1637 2 30-34 1688 3 35-39 1938 4 40-44 2007 5 45-49 2109 6 50-54 2402 7 55-59 2789 8 60-64 3122 9 65-69 3191 10 70-74 2953 11 75-79 2004 12 80 or older 2187
- 
我们应该按数据类型组织我们的特征,这样将使一些任务更容易。我们还将设置一个字典来重新编码 genhealth和diabetic特征:num_cols = ['bmi','physicalhealthbaddays', 'mentalhealthbaddays','sleeptimenightly'] binary_cols = ['smoking','alcoholdrinkingheavy', 'stroke','walkingdifficult','physicalactivity', 'asthma','kidneydisease','skincancer'] cat_cols = ['gender','ethnicity'] spec_cols1 = ['agecategory'] spec_cols2 = ['genhealth','diabetic'] rep_dict = { 'genhealth': {'Poor':0,'Fair':1,'Good':2, 'Very good':3,'Excellent':4}, 'diabetic': {'No':0, 'No, borderline diabetes':0,'Yes':1, 'Yes (during pregnancy)':1} }
- 
现在,让我们创建训练和测试数据框: X_train, X_test, y_train, y_test = \ train_test_split(healthinfo[num_cols + binary_cols + cat_cols + spec_cols1 + spec_cols2],\ healthinfo[['heartdisease']], test_size=0.2, random_state=0)
- 
接下来,我们将设置列转换。我们将使用我们的自定义类将 agecategory特征编码为有序,并将genhealth和diabetic的特征的字符值替换为数值。
我们不会转换数值列,因为在使用决策树时通常不需要缩放这些特征。我们也不会担心异常值,因为决策树对它们的敏感性低于逻辑回归。我们将设置remainder为passthrough,以便转换器将剩余的列(数值列)原样通过:
ohe = OneHotEncoder(drop='first', sparse=False)
spectrans1 = make_pipeline(MakeOrdinal())
spectrans2 = make_pipeline(ReplaceVals(rep_dict))
bintrans = make_pipeline(ohe)
cattrans = make_pipeline(ohe)
coltrans = ColumnTransformer(
  transformers=[
    ("bin", bintrans, binary_cols),
    ("cat", cattrans, cat_cols),
    ("spec1", spectrans1, spec_cols1),
    ("spec2", spectrans2, spec_cols2),
  ],
    remainder = 'passthrough'
)
- 
在运行模型之前,我们需要做一些工作。正如你将在下一步看到的,我们需要知道单变量编码器将返回多少个特征。同时,我们也应该获取新的特征名称。我们稍后还需要它们。(我们只需要对数据的一个小随机样本进行列转换器拟合即可。)看看下面的代码: coltrans.fit(X_train.sample(1000)) new_binary_cols = \ coltrans.\ named_transformers_['bin'].\ named_steps['onehotencoder'].\ get_feature_names(binary_cols) new_cat_cols = \ coltrans.\ named_transformers_['cat'].\ named_steps['onehotencoder'].\ get_feature_names(cat_cols)
- 
让我们查看特征名称: new_cols = np.concatenate((new_binary_cols, new_cat_cols, np.array(spec_cols1 + spec_cols2 + num_cols))) new_cols array(['smoking_Yes', 'alcoholdrinkingheavy_Yes', 'stroke_Yes', 'walkingdifficult_Yes', 'physicalactivity_Yes', 'asthma_Yes', 'kidneydisease_Yes', 'skincancer_Yes', 'gender_Male', 'ethnicity_Asian', 'ethnicity_Black', 'ethnicity_Hispanic', 'ethnicity_Other', 'ethnicity_White', 'agecategory', 'genhealth', 'diabetic', 'bmi', 'physicalhealthbaddays', 'mentalhealthbaddays', 'sleeptimenightly'], dtype=object)
- 
在我们拟合决策树之前,我们需要处理我们的不平衡数据集。我们可以使用来自 Imbalanced-learn 的 SMOTENC模块来对心脏病类别进行过采样。这将生成足够的心脏病类别的代表性实例,以平衡类别成员资格。
接下来,我们必须实例化一个决策树分类器,并指出叶子节点需要至少有五个观察结果,并且树的深度不能超过两个。然后,我们将使用列转换器转换训练数据并拟合模型。
我们将在本节稍后进行一些超参数调整。现在,我们只想生成一个易于解释和可视化的决策树。
SMOTENC需要知道哪些列是分类的。当我们设置列转换器时,我们首先编码二进制列,然后是分类列。因此,二进制列数加上分类列数给出了那些列索引的终点。然后,我们必须传递一个范围,从 0 开始,到分类列数结束,到SMOTENC的categorical_features参数。
现在,我们可以创建一个包含列转换、过采样和决策树分类器的管道,并对其进行拟合:
catcolscnt = new_binary_cols.shape[0] + \
  new_cat_cols.shape[0]
smotenc = \
  SMOTENC(categorical_features=np.arange(0,catcolscnt),
  random_state=0)
dtc_example = DecisionTreeClassifier(
  min_samples_leaf=5, max_depth=2)
pipe0 = make_pipeline(coltrans, smotenc, dtc_example)
pipe0.fit(X_train, y_train.values.ravel())
注意
当我们担心我们的模型在捕捉一个类别的变化方面做得不好,因为我们有太多少的该类实例,相对于一个或多个其他类别时,过采样可以是一个好的选择。过采样会复制该类别的实例。
合成少数过采样技术(SMOTE)是一个使用 KNN 来复制实例的算法。本章中 SMOTE 的实现来自 Imbalanced-learn,特别是 SMOTENC,它可以处理分类数据。
当类不平衡比这个数据集更严重时,通常会进行过采样,比如 100 到 1。尽管如此,我认为在本章中演示如何使用 SMOTE 和类似工具是有帮助的。
- 
运行拟合后,我们可以查看哪些特征被识别为重要。 agecategory、genhealth和diabetic是此简单模型中的重要特征:feature_imp = \ pipe0.named_steps['decisiontreeclassifier'].\ tree_.compute_feature_importances(normalize=False) feature_impgt0 = feature_imp>0 feature_implabs = np.column_stack((feature_imp.\ ravel(), new_cols)) feature_implabs[feature_impgt0] array([[0.10241844433036575, 'agecategory'], [0.04956947743193013, 'genhealth'], [0.012777650193266089, 'diabetic']], dtype=object)
- 
接下来,我们可以生成决策树的图形: plot_tree(pipe0.named_steps['decisiontreeclassifier'], feature_names=new_cols, class_names=['No Disease','Disease'], fontsize=10)
这会产生以下图形:

图 11.3 – 以心脏病为目标的心脏病决策树示例
初始的二分分割,在根节点(也称为深度 0),是基于agecategory是否小于或等于6。 (回想一下,agecategory最初是一个字符特征。编码后的初始值50-54得到6的值。) 如果根节点语句为真,它将导致下一级节点向左。如果语句为假,它将导致下一级节点向右。样本数是该节点到达的观察数。因此,diabetic<=0.001(即非糖尿病患者)节点上的样本值12576反映了从父节点中得到的语句为真的实例数;也就是说,有12576个实例的年龄类别值小于或等于6。
每个节点内的value列表给我们提供了训练数据中每个类别的实例。在这种情况下,第一个值是无疾病观察值的计数。第二个值是有心脏病观察值的计数。例如,在diabetic<=0.001节点上,有10781个无疾病观察值和1795个疾病观察值。
决策树在叶子模式下预测最频繁的类别。因此,这个模型会预测 54 岁或以下且不是糖尿病患者的个体没有疾病(agecategory<=6和diabetic<=0.001)。在训练数据中,该组有10142个无疾病观察结果,990个疾病观察结果。这给我们一个具有非常好的基尼不纯度0.162的叶子节点。
如果一个人有糖尿病,即使他们 54 岁或以下,我们的模型也会预测心脏病。然而,这种预测并不那么确定。基尼不纯度为0.493。相比之下,预测 54 岁以上(agecategory<=6.001为假)且健康状况不佳(genhealth<=3.0)的个体的疾病,其基尼不纯度显著较低。
我们的模型预测 54 岁以上且一般健康状况等于4的人没有疾病。(当我们进行列转换时,我们将一般健康状况值Excellent编码为4。)然而,较差的基尼不纯度分数表明,我们的模型并不完全有信心做出那个预测。
- 
让我们看看这个模型的某些指标。该模型有不错的敏感性,但不是很好,大约 70%的时间预测有心脏病。当我们做出积极预测时,精确度相当低。只有 19%的时间我们做出的积极预测是正确的: pred = pipe0.predict(X_test) print("accuracy: %.2f, sensitivity: %.2f, specificity: %.2f, precision: %.2f" % (skmet.accuracy_score(y_test.values.ravel(), pred), skmet.recall_score(y_test.values.ravel(), pred), skmet.recall_score(y_test.values.ravel(), pred, pos_label=0), skmet.precision_score(y_test.values.ravel(), pred))) accuracy: 0.72, sensitivity: 0.70, specificity: 0.73, precision: 0.19
这个模型在超参数方面做出了一些相当随意的决定,将最大深度设置为2,并将叶子的最小样本数设置为5。我们应该探索这些超参数的替代值,以获得性能更好的模型。让我们进行随机网格搜索以找到这些值。
- 
让我们设置一个包含列转换、过采样和决策树分类器的管道。我们还将创建一个包含最小叶子大小和最大树深度超参数范围的字典。请注意,对于每个字典键, decisiontreeclassifier后面有两个下划线:dtc = DecisionTreeClassifier(random_state=0) pipe1 = make_pipeline(coltrans, smotenc, dtc) dtc_params = { 'decisiontreeclassifier__min_samples_leaf': randint(100, 1200), 'decisiontreeclassifier__max_depth': randint(2, 11) }
- 
现在,我们可以运行随机网格搜索。我们将运行 20 次迭代以测试我们超参数的多个值: rs = RandomizedSearchCV(pipe1, dtc_params, cv=5, n_iter=20, scoring="roc_auc") rs.fit(X_train, y_train.values.ravel()) rs.best_params_ {'decisiontreeclassifier__max_depth': 9, 'decisiontreeclassifier__min_samples_leaf': 954} rs.best_score_ 0.7964540832005679
- 
让我们看看每次迭代的得分: results = \ pd.DataFrame(rs.cv_results_['mean_test_score'], \ columns=['meanscore']).\ join(pd.DataFrame(rs.cv_results_['params'])).\ sort_values(['meanscore'], ascending=False).\ rename(columns=\ {'decisiontreeclassifier__max_depth':'maxdepth', 'decisiontreeclassifier__min_samples_leaf':\ 'samples'})
这产生了以下输出。表现最好的模型与我们之前构建的模型相比,max_depth有显著增加。我们的模型在叶子中每个叶子的最小实例数也更高:
    meanscore  maxdepth  samples
15      0.796         9      954
13      0.796         8      988
4       0.795         7      439
19      0.795         9      919
12      0.794         9      856
3       0.794         9      510
2       0.794         9     1038
5       0.793         8      575
0       0.793        10     1152
10      0.793         7     1080
6       0.793         6     1013
8       0.793        10      431
17      0.793         6      896
14      0.792         6      545
16      0.784         5      180
1       0.778         4      366
11      0.775         4      286
9       0.773         4      138
18      0.768         3      358
7       0.765         3      907 
- 
让我们生成一个混淆矩阵: pred2 = rs.predict(X_test) cm = skmet.confusion_matrix(y_test, pred2) cmplot = \ skmet.ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=['Negative', 'Positive']) cmplot.plot() cmplot.ax_.\ set(title='Heart Disease Prediction Confusion Matrix', xlabel='Predicted Value', ylabel='Actual Value')
这产生了以下图表:

图 11.4 – 决策树模型的冠心病混淆矩阵
这张图你可能立刻注意到的一个问题是我们的精确度有多低。绝大多数时间我们预测为阳性,我们都是错误的。
- 
让我们查看准确率、灵敏度、特异性和精确度分数,看看模型在没有超参数调整的情况下,这些指标是否有很大改进。我们在灵敏度上表现明显更差,现在为 63%,而之前为 70%。然而,我们在特异性上做得稍微好一些。我们现在在测试数据上正确预测负面的概率为 79%,而之前的模型为 73%: print("accuracy: %.2f, sensitivity: %.2f, specificity: %.2f, precision: %.2f" % (skmet.accuracy_score(y_test.values.ravel(), pred2), skmet.recall_score(y_test.values.ravel(), pred2), skmet.recall_score(y_test.values.ravel(), pred2, pos_label=0), skmet.precision_score(y_test.values.ravel(), pred2))) accuracy: 0.77, sensitivity: 0.63, specificity: 0.79, precision: 0.21
决策树是分类模型的良好起点。它们对底层数据几乎没有假设,并且不需要太多的预处理。注意,在这个例子中我们没有进行任何缩放或异常值检测,因为在决策树中这通常不是必要的。我们还得到一个相当容易理解或解释的模型,只要我们限制深度数量。
我们通常可以通过随机森林来提高我们的决策树模型的性能,原因我们在本章开头已经讨论过。一个关键的原因是,当使用随机森林而不是决策树时,方差会降低。
在下一节中,我们将探讨随机森林。
实现随机森林
让我们尝试使用随机森林来提高我们的心脏病模型:
- 
首先,让我们加载与上一节相同的库,但这次我们将导入随机森林分类器: import pandas as pd import numpy as np from imblearn.pipeline import make_pipeline from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import RandomizedSearchCV from scipy.stats import randint import sklearn.metrics as skmet import os import sys sys.path.append(os.getcwd() + "/helperfunctions") import healthinfo as hi
我们还加载了healthinfo模块;它加载健康信息数据并执行我们的预处理。这里没有太多花哨的东西。我们之前步骤中使用的预处理代码只是复制到了当前工作目录的helperfunctions子文件夹中。
- 
现在,让我们获取由 healthinfo模块处理过的数据,以便我们可以使用它来为我们的随机森林分类器:X_train = hi.X_train X_test = hi.X_test y_train = hi.y_train y_test = hi.y_test
- 
让我们实例化一个随机森林分类器并为网格搜索创建一个管道。我们还将创建一个用于搜索的超参数字典。除了我们用于决策树的 max_depth和min_samples_leaf超参数外,随机森林的一个重要超参数是n_estimators。这表示用于该搜索迭代的树的数量。我们还将添加entropy作为标准,除了我们迄今为止使用的gini:rfc = RandomForestClassifier(random_state=0) pipe1 = make_pipeline(hi.coltrans, hi.smotenc, rfc) rfc_params = { 'randomforestclassifier__min_samples_leaf': randint(100, 1200), 'randomforestclassifier__max_depth': randint(2, 11), 'randomforestclassifier__n_estimators': randint(100, 3000), 'randomforestclassifier__criterion': ['gini','entropy'] } rs = RandomizedSearchCV(pipe1, rfc_params, cv=5, n_iter=20, scoring="roc_auc") rs.fit(X_train, y_train.values.ravel())
- 
我们可以使用随机网格搜索对象的 best_params_和best_score_属性来找到最佳参数和相应的分数。最佳模型有1023棵树和最大深度为9。
在这里,我们可以看到roc_auc分数相对于上一节中的决策树模型有所提高:
rs.best_params_
{'randomforestclassifier__criterion': 'gini',
 'randomforestclassifier__max_depth': 9,
 'randomforestclassifier__min_samples_leaf': 667,
 'randomforestclassifier__n_estimators': 1023}
rs.best_score_
0.8210934290375318
- 
随机森林的结果比单个决策树的结果更难以解释,但一个好的起点是查看特征重要性。前三个特征与我们在决策树中看到的是相同的——即 agecategory、genhealth和diabetic:feature_imp = \ rs.best_estimator_['randomforestclassifier'].\ feature_importances_ feature_implabs = np.column_stack((feature_imp.\ ravel(), hi.new_cols)) pd.DataFrame(feature_implabs, columns=['importance','feature']).\ sort_values(['importance'], ascending=False) importance feature 14 0.321 agecategory 15 0.269 genhealth 16 0.159 diabetic 13 0.058 ethnicity_White 0 0.053 smoking_Yes 18 0.033 physicalhealthbaddays 8 0.027 gender_Male 3 0.024 walkingdifficult_Yes 20 0.019 sleeptimenightly 11 0.010 ethnicity_Hispanic 17 0.007 bmi 19 0.007 mentalhealthbaddays 1 0.007 alcoholdrinkingheavy_Yes 5 0.003 asthma_Yes 10 0.002 ethnicity_Black 4 0.001 physicalactivity_Yes 7 0.001 skincancer_Yes 2 0.000 stroke_Yes 6 0.000 kidneydisease_Yes 9 0.000 ethnicity_Asian 12 0.000 ethnicity_Other
- 
让我们看看一些指标。与先前的模型相比,敏感性有所提高,但在其他任何指标上都没有太大变化。我们保持了相同的相对良好的特异性分数。总体而言,这是我们迄今为止最好的模型: print("accuracy: %.2f, sensitivity: %.2f, specificity: %.2f, precision: %.2f" % (skmet.accuracy_score(y_test.values.ravel(), pred), skmet.recall_score(y_test.values.ravel(), pred), skmet.recall_score(y_test.values.ravel(), pred, pos_label=0), skmet.precision_score(y_test.values.ravel(), pred))) accuracy: 0.77, sensitivity: 0.69, specificity: 0.78, precision: 0.22
我们可能仍然能够提高我们模型性能指标。我们至少应该尝试一些梯度提升。正如我们在本章的使用梯度提升决策树部分所讨论的,梯度提升决策树有时可能比随机森林产生更好的模型。这是因为每个树都是从先前树的错误中学习的。
实现梯度提升
在本节中,我们将尝试使用梯度提升来改进我们的随机森林模型。我们必须注意的一个问题是过拟合,这在梯度提升决策树中可能比在随机森林中更成问题。这是因为随机森林的树不会从其他树中学习,而梯度提升中,每个树都是基于先前树的学习的。我们在这里选择的超参数至关重要。让我们开始吧:
- 
我们将首先导入必要的库。我们将使用与随机森林相同的模块,但我们将从 ensemble导入GradientBoostingClassifier而不是RandomForestClassifier:import pandas as pd import numpy as np from imblearn.pipeline import make_pipeline from sklearn.model_selection import RandomizedSearchCV from sklearn.ensemble import GradientBoostingClassifier import sklearn.metrics as skmet from scipy.stats import uniform from scipy.stats import randint import matplotlib.pyplot as plt import os import sys sys.path.append(os.getcwd() + "/helperfunctions") import healthinfo as hi
- 
现在,让我们获取由 healthinfo模块处理过的数据,用于我们的随机森林分类器:X_train = hi.X_train X_test = hi.X_test y_train = hi.y_train y_test = hi.y_test
- 
接下来,我们将实例化一个梯度提升分类器实例,并将其添加到一个管道中,包括我们用于预处理健康数据的步骤(这些步骤在我们导入的模块中)。 
我们将创建一个包含梯度提升分类器超参数的字典。这些包括熟悉的每个叶子的最小样本数、最大深度以及估计器的数量超参数。我们还将添加用于检查学习率的值:
gbc = GradientBoostingClassifier(random_state=0)
pipe1 = make_pipeline(hi.coltrans, hi.smotenc, gbc)
gbc_params = {
 'gradientboostingclassifier__min_samples_leaf':
     randint(100, 1200),
 'gradientboostingclassifier__max_depth':
     randint(2, 20),
 'gradientboostingclassifier__learning_rate':
     uniform(loc=0.02, scale=0.25),
 'gradientboostingclassifier__n_estimators':
     randint(100, 1200)
}
- 
现在,我们准备进行网格搜索。(注意,这可能在您的机器上运行需要一些时间。)在我们运行的七次迭代中,最佳模型的学习率为 0.25,估计器或树的数量为308。它有一个相当不错的roc_auc分数为0.82:rs = RandomizedSearchCV(pipe1, gbc_params, cv=5, n_iter=7, scoring="roc_auc") rs.fit(X_train, y_train.values.ravel()) rs.best_params_ {'gradientboostingclassifier__learning_rate': 0.2528, 'gradientboostingclassifier__max_depth': 3, 'gradientboostingclassifier__min_samples_leaf': 565, 'gradientboostingclassifier__n_estimators': 308} rs.best_score_ 0.8162378796382679
- 
让我们看看特征重要性: feature_imp = pd.Series(rs.\ best_estimator_['gradientboostingclassifier'].\ feature_importances_, index=hi.new_cols) feature_imp.loc[feature_imp>0.01].\ plot(kind='barh') plt.tight_layout() plt.title('Gradient Boosting Feature Importance')
这会产生以下图表:

图 11.5 – 梯度提升特征重要性
- 
让我们看看一些指标。有趣的是,我们得到了出色的准确性和特异性,但敏感性却非常糟糕。这可能是因为过拟合: pred = rs.predict(X_test) print("accuracy: %.2f, sensitivity: %.2f, specificity: %.2f, precision: %.2f" % (skmet.accuracy_score(y_test.values.ravel(), pred), skmet.recall_score(y_test.values.ravel(), pred), skmet.recall_score(y_test.values.ravel(), pred, pos_label=0), skmet.precision_score(y_test.values.ravel(), pred))) accuracy: 0.91, sensitivity: 0.19, specificity: 0.97, precision: 0.40
虽然我们的结果参差不齐,但梯度提升决策树通常是一个很好的分类模型选择。这尤其适用于我们正在建模特征与目标之间的复杂关系时。当决策树是一个合适的选择,但我们担心决策树模型的高方差时,梯度提升决策树至少与随机森林一样是一个好的选择。
现在,让我们总结一下本章学到的内容。
摘要
本章探讨了如何使用决策树来解决分类问题。尽管本章中的所有示例都涉及二元目标,但我们使用的算法也可以处理多类问题。与从逻辑回归到多项式逻辑回归的转变不同,当我们的目标值超过两个时,为了有效地使用这些算法,我们通常不需要做出太多改变。
我们探讨了两种处理决策树高方差的方法。一种方法是使用随机森林,这是一种袋装方法。这将减少我们预测中的方差。另一种方法是使用梯度提升决策树。提升可以帮助我们捕捉数据中的非常复杂的关系,但存在非平凡的过拟合风险。考虑到这一点,调整我们的超参数尤为重要。
在下一章中,我们将探讨另一个著名的分类算法:K 最近邻算法。
第十二章:第十二章:用于分类的 K-最近邻
K-最近邻 (KNN) 是当观察或特征不多,且预测类别成员不需要非常高效时,用于分类模型的一个很好的选择。它是一个懒惰学习器,因此比其他分类算法更快地拟合,但在对新观察进行分类时则慢得多。它也可能在极端情况下产生不太准确的预测,但通过适当地调整 k 可以改进这一点。我们将在本章开发的模型中仔细考虑这些选择。
KNN 可能是我们能选择的最为直接的非参数算法之一,使其成为一个良好的诊断工具。不需要对特征的分布或特征与目标之间的关系做出任何假设。没有很多超参数需要调整,而且两个关键的超参数——最近邻和距离度量——都很容易理解。
KNN 可以成功用于二元和多类问题,而无需对算法进行任何扩展。
在本章中,我们将涵盖以下主题:
- 
KNN 的关键概念 
- 
二元分类的 KNN 
- 
多类分类的 KNN 
技术要求
除了常用的 scikit-learn 库之外,我们还需要 imblearn(不平衡学习)库来运行本章中的代码。这个库帮助我们处理显著的类别不平衡。imblearn 可以通过 pip install imbalanced-learn 安装,或者如果你使用 Anaconda,可以通过 conda install -c conda-forge imbalanced-learn 安装。所有代码都已使用 scikit-learn 版本 0.24.2 和 1.0.2 进行测试。
KNN 的关键概念
KNN 可能是我们将在本书中讨论的最直观的算法。其思想是找到 k 个属性最相似的实例,其中这种相似性对目标很重要。最后一个条款是一个重要但可能显然的限定条件。我们关注与目标值相关的属性之间的相似性。
对于每个需要预测目标的观察,KNN 会找到与该观察的特征最相似的 k 个训练观察。当目标是分类时,KNN 会选择 k 个训练观察中目标的最频繁值。(我们通常选择奇数个 k 以避免分类问题中的平局。)
我所说的通过 训练 观察到的,是指那些具有已知目标值的观察。KNN 由于是一个懒惰学习器,所以不需要进行真正的训练。我将在本节中更详细地讨论这一点。
以下图表说明了使用 KNN 进行分类,其中 k 的值为 1 和 3。当 k=1 时,我们会预测新的观察值 X 将属于圆形类别。当 k=3 时,它将被分配到正方形类别:

图 12.1 – k 的值为 1 和 3 的 KNN
但我们所说的相似或最近的实例是什么意思呢?有几种方法可以衡量相似性,但最常用的度量是欧几里得距离。欧几里得距离是两点之间平方差的和。这可能会让你想起勾股定理。从点 a 到点 b 的欧几里得距离如下:

欧几里得距离的一个合理的替代方案是曼哈顿距离。从点 a 到点 b 的曼哈顿距离如下:

scikit-learn 中的默认距离度量是闵可夫斯基距离。从点 a 到点 b 的闵可夫斯基距离如下:

注意到当 p 为 1 时,它与曼哈顿距离相同。当 p 为 2 时,它与欧几里得距离相同。
曼哈顿距离有时被称为出租车距离。这是因为它反映了两个点在网格路径上的距离。以下图表说明了曼哈顿距离并将其与欧几里得距离进行了比较:

图 12.2 – 欧几里得和曼哈顿距离度量
使用曼哈顿距离可以在特征类型或尺度差异很大时产生更好的结果。然而,我们可以将距离度量的选择视为一个经验问题;也就是说,我们可以尝试两者(或其他的距离度量)并看看哪个给我们带来性能最好的模型。我们将在下一节通过网格搜索来演示这一点。
如你所怀疑的那样,KNN 模型对 k 的选择很敏感。较低的 k 值会导致一个试图识别观察之间细微差异的模型。在非常低的 k 值时,存在过度拟合的实质性风险。但在 k 值较高时,我们的模型可能不够灵活。我们再次面临方差-偏差权衡。较低的 k 值导致偏差较少而方差较多,而较高的值则相反。
对于 k 的选择没有明确的答案。但一个好的经验法则是使用观察数的平方根。然而,就像我们对距离度量所做的那样,我们应该测试模型在不同 k 值下的性能。KNN 是一种非参数算法。不对底层数据的属性做出假设,例如线性或正态分布的特征。这使得 KNN 非常灵活。它可以用来模拟特征与目标之间的各种关系。
如前所述,KNN 是一种懒惰学习算法。在训练时间不进行任何计算。学习仅在测试时发生。这有其优点和缺点。当数据中有许多实例或维度时,它可能不是一个好的选择,而且预测速度很重要。KNN 也往往在稀疏数据上表现不佳,例如包含许多 0 值的数据集。
在下一节中,我们将使用 KNN 构建一个二分类模型,然后在下一节构建几个多分类模型。
KNN 用于二分类
KNN 算法与决策树算法有一些相同的优点。不需要满足关于特征或残差的分布的先验假设。它是我们试图在前两章中构建的心脏病模型的一个合适的算法。数据集不是很大(30,000 个观测值)并且没有太多特征。
注意
心脏病数据集可在www.kaggle.com/datasets/kamilpytlak/personal-key-indicators-of-heart-disease公开下载。它来源于 2020 年美国疾病控制与预防中心对超过 40 万人的调查数据。我已经从这个数据集中随机抽取了 30,000 个观测值用于本节的分析。数据列包括受访者是否曾经患有心脏病、体重指数、吸烟史、大量饮酒、年龄、糖尿病和肾病。
让我们开始构建我们的模型:
- 
首先,我们必须加载我们在过去几章中使用的一些相同的库。我们还将加载 KneighborsClassifier:import pandas as pd import numpy as np from imblearn.pipeline import make_pipeline from sklearn.model_selection import RandomizedSearchCV,\ RepeatedStratifiedKFold from sklearn.neighbors import KNeighborsClassifier from sklearn.feature_selection import SelectKBest, chi2 from scipy.stats import randint import sklearn.metrics as skmet from sklearn.model_selection import cross_validate import os import sys sys.path.append(os.getcwd() + "/helperfunctions") import healthinfo as hi
healthinfo模块包含了我们在第十章,逻辑回归中使用的所有代码,用于加载健康信息数据并进行预处理。这里没有必要重复这些步骤。如果你还没有阅读第十章,逻辑回归,至少浏览一下该章节的第二部分的代码可能会有所帮助。这将让你更好地了解特征。
- 
现在,让我们获取由 healthinfo模块处理过的数据并显示特征名称:X_train = hi.X_train X_test = hi.X_test y_train = hi.y_train y_test = hi.y_test new_cols = hi.new_cols new_cols array(['smoking_Yes', 'alcoholdrinkingheavy_Yes', 'stroke_Yes', 'walkingdifficult_Yes', 'physicalactivity_Yes', 'asthma_Yes', 'kidneydisease_Yes', 'skincancer_Yes', 'gender_Male', 'ethnicity_Asian', 'ethnicity_Black', 'ethnicity_Hispanic', 'ethnicity_Other', 'ethnicity_White', 'agecategory', 'genhealth', 'diabetic', 'bmi', 'physicalhealthbaddays', 'mentalhealthbaddays', 'sleeptimenightly'], dtype=object)
- 
我们可以使用 K 折交叉验证来评估这个模型。我们已经在第六章,准备模型评估中讨论了 K 折交叉验证。我们将指定我们想要重复 10 次 10 个分割。默认值分别是 5和10。
我们模型的精确度,即我们预测心脏病时的正确率,异常低,为0.17。灵敏度,即存在心脏病时预测心脏病的比率,也较低,为0.56:
knn_example = KNeighborsClassifier(n_neighbors=5, n_jobs=-1)
kf = RepeatedStratifiedKFold(n_splits=10, n_repeats=10, random_state=0)
pipe0 = make_pipeline(hi.coltrans, hi.smotenc, knn_example)
scores = cross_validate(pipe0, X_train,
  y_train.values.ravel(), \
  scoring=['accuracy','precision','recall','f1'], \
  cv=kf, n_jobs=-1)
print("accuracy: %.2f, sensitivity: %.2f, precision: %.2f, f1: %.2f"  %
  (np.mean(scores['test_accuracy']),\
  np.mean(scores['test_recall']),\
  np.mean(scores['test_precision']),\
  np.mean(scores['test_f1'])))
accuracy: 0.73, sensitivity: 0.56, precision: 0.17, f1: 0.26
- 
我们可以通过一些超参数调整来提高我们模型的性能。让我们为几个邻居和距离度量创建一个字典。我们还将尝试使用我们的 filter方法选择不同数量的特征:knn = KNeighborsClassifier(n_jobs=-1) pipe1 = make_pipeline(hi.coltrans, hi.smotenc, SelectKBest(score_func=chi2), knn) knn_params = { 'selectkbest__k': randint(1, len(new_cols)), 'kneighborsclassifier__n_neighbors': randint(5, 300), 'kneighborsclassifier__metric': ['euclidean','manhattan','minkowski'] }
- 
我们将在网格搜索中的评分将基于接收者操作特征曲线(ROC 曲线)下的面积。我们在第六章,准备模型评估中介绍了 ROC 曲线: rs = RandomizedSearchCV(pipe1, knn_params, cv=5, scoring="roc_auc") rs.fit(X_train, y_train.values.ravel())
- 
我们可以使用随机网格搜索的最佳估计器属性从 selectkbest获取选定的特征:selected = rs.best_estimator_['selectkbest'].\ get_support() selected.sum() 11 new_cols[selected] array(['smoking_Yes', 'alcoholdrinkingheavy_Yes', 'walkingdifficult_Yes', 'ethnicity_Black', 'ethnicity_Hispanic', 'agecategory', 'genhealth', 'diabetic', 'bmi', 'physicalhealthbaddays','mentalhealthbaddays'], dtype=object)
- 
我们还可以查看最佳参数和最佳得分。11 个特征(17 个特征中的 11 个)被选中,正如我们在上一步中看到的。一个k( n_neighbors)为254和曼哈顿距离度量是得分最高的模型的另一个超参数:rs.best_params_ {'kneighborsclassifier__metric': 'manhattan', 'kneighborsclassifier__n_neighbors': 251, 'selectkbest__k': 11} rs.best_score_ 0.8030553205304845
- 
让我们看看这个模型的更多指标。我们在敏感性方面做得很好,但其他指标并不好: pred = rs.predict(X_test) print("accuracy: %.2f, sensitivity: %.2f, specificity: %.2f, precision: %.2f" % (skmet.accuracy_score(y_test.values.ravel(), pred), skmet.recall_score(y_test.values.ravel(), pred), skmet.recall_score(y_test.values.ravel(), pred, pos_label=0), skmet.precision_score(y_test.values.ravel(), pred))) accuracy: 0.67, sensitivity: 0.82, specificity: 0.66, precision: 0.18
- 
我们还应该绘制混淆矩阵。为此,我们可以查看相对较好的敏感性。在这里,我们正确地将大多数实际阳性识别为阳性。然而,这是以许多假阳性为代价的。我们可以从上一步的精确度得分中看到这一点。大多数时候我们预测阳性,我们都是错误的: cm = skmet.confusion_matrix(y_test, pred) cmplot = skmet.ConfusionMatrixDisplay( confusion_matrix=cm, display_labels=['Negative', 'Positive']) cmplot.plot() cmplot.ax_.set(title='Heart Disease Prediction Confusion Matrix', xlabel='Predicted Value', ylabel='Actual Value')
这会产生以下图表:

图 12.3 – 超参数调整后的心脏病混淆矩阵
在本节中,你学习了如何使用具有二进制目标的 KNN。我们可以遵循非常相似的步骤来使用 KNN 进行多类分类问题。
KNN 多类分类
构建 KNN 多类模型相当简单,因为它不需要对算法进行特殊扩展,例如将逻辑回归应用于具有两个以上值的目标所需的扩展。我们可以通过使用我们在第十章,逻辑回归中的多项式逻辑回归部分使用的相同机器故障数据来看到这一点。
注意
这份关于机器故障的数据集可在www.kaggle.com/datasets/shivamb/machine-predictive-maintenance-classification公开使用。有 10,000 个观测值,12 个特征,以及两个可能的靶标。一个是二进制靶标,指定机器是否故障。另一个包含故障类型。该数据集中的实例是合成的,由一个旨在模仿机器故障率和原因的过程生成。
让我们构建我们的机器故障类型模型:
- 
首先,让我们加载现在熟悉的模块: import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import OneHotEncoder, MinMaxScaler from imblearn.pipeline import make_pipeline from sklearn.impute import SimpleImputer from sklearn.compose import ColumnTransformer from sklearn.model_selection import RandomizedSearchCV from sklearn.neighbors import KNeighborsClassifier from imblearn.over_sampling import SMOTENC from sklearn.feature_selection import SelectKBest, chi2 import sklearn.metrics as skmet import os import sys sys.path.append(os.getcwd() + "/helperfunctions") from preprocfunc import OutlierTrans
- 
让我们加载机器故障数据并查看其结构。共有 10,000 个观测值,没有缺失数据。数据包括分类数据和数值数据的组合: machinefailuretype = pd.read_csv("data/machinefailuretype.csv") machinefailuretype.info() <class 'pandas.core.frame.DataFrame'> RangeIndex: 10000 entries, 0 to 9999 Data columns (total 10 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 udi 10000 non-null int64 1 product 10000 non-null object 2 machinetype 10000 non-null object 3 airtemp 10000 non-null float64 4 processtemperature 10000 non-null float64 5 rotationalspeed 10000 non-null int64 6 torque 10000 non-null float64 7 toolwear 10000 non-null int64 8 fail 10000 non-null int64 9 failtype 10000 non-null object dtypes: float64(3), int64(4), object(3) memory usage: 781.4+ KB
- 
让我们也看看一些观测值: machinefailuretype.head() udi product machinetype airtemp processtemperature\ 0 1 M14860 M 298 309 1 2 L47181 L 298 309 2 3 L47182 L 298 308 3 4 L47183 L 298 309 4 5 L47184 L 298 309 Rotationalspeed Torque toolwear fail failtype 0 1551 43 0 0 No Failure 1 1408 46 3 0 No Failure 2 1498 49 5 0 No Failure 3 1433 40 7 0 No Failure 4 1408 40 9 0 No Failure
- 
我们还应该对分类特征进行一些频率分析。绝大多数观测值,97%,没有出现故障。这种相当明显的类别不平衡可能很难建模。有三种机器类型——高质量、低质量和中等质量: machinefailuretype.failtype.value_counts(dropna=False).sort_index() Heat Dissipation Failure 112 No Failure 9652 Overstrain Failure 78 Power Failure 95 Random Failures 18 Tool Wear Failure 45 Name: failtype, dtype: int64 machinefailuretype.machinetype.\ value_counts(dropna=False).sort_index() H 1003 L 6000 M 2997 Name: machinetype, dtype: int64
- 
让我们合并一些 failtype值并检查我们的工作。首先,我们将定义一个函数setcode,将故障类型文本映射到故障类型代码。我们将随机分配故障和工具磨损故障到代码5,用于其他故障:def setcode(typetext): if (typetext=="No Failure"): typecode = 1 elif (typetext=="Heat Dissipation Failure"): typecode = 2 elif (typetext=="Power Failure"): typecode = 3 elif (typetext=="Overstrain Failure"): typecode = 4 else: typecode = 5 return typecode machinefailuretype["failtypecode"] = \ machinefailuretype.apply(lambda x: setcode(x.failtype), axis=1) machinefailuretype.groupby(['failtypecode','failtype']).size().\ reset_index() failtypecode failtype 0 0 1 No Failure 9652 1 2 Heat Dissipation Failure 112 2 3 Power Failure 95 3 4 Overstrain Failure 78 4 5 Random Failures 18 5 5 Tool Wear Failure 45
- 
我们应该查看我们数值特征的描述性统计: num_cols = ['airtemp', 'processtemperature', 'rotationalspeed', 'torque', 'toolwear'] cat_cols = ['machinetype'] machinefailuretype[num_cols].agg(['min','median','max']).T min median max airtemp 295 300 304 processtemperature 306 310 314 rotationalspeed 1,168 1,503 2,886 torque 4 40 77 toolwear 0 108 253
- 
现在,我们准备创建训练和测试数据框。我们将使用我们刚刚创建的故障类型代码作为我们的目标: X_train, X_test, y_train, y_test = \ train_test_split(machinefailuretype[num_cols + cat_cols],\ machinefailuretype[['failtypecode']], test_size=0.2, random_state=0)
- 
现在,让我们设置列转换。对于数值特征,我们将将异常值设置为中位数,然后缩放数据。我们将使用最小-最大缩放,这将返回从 0 到 1 的值( MinMaxScaler的默认值)。我们使用这个缩放器,而不是标准缩放器,以避免负值。我们稍后将使用的特征选择方法selectkbest不能与负值一起使用:ohe = OneHotEncoder(drop='first', sparse=False) cattrans = make_pipeline(ohe) standtrans = make_pipeline( OutlierTrans(3),SimpleImputer(strategy="median"), MinMaxScaler()) coltrans = ColumnTransformer( transformers=[ ("cat", cattrans, cat_cols), ("stand", standtrans, num_cols), ] )
- 
让我们也看看编码后的列。我们需要在过度采样之前做这件事,因为 SMOTENC模块需要分类特征的列索引。我们进行过度采样是为了处理显著的类别不平衡。我们已在第十一章中更详细地讨论了这一点,决策树和随机森林分类:coltrans.fit(X_train.sample(1000)) new_cat_cols = \ coltrans.\ named_transformers_['cat'].\ named_steps['onehotencoder'].\ get_feature_names(cat_cols) new_cols = np.concatenate((new_cat_cols, np.array(num_cols))) print(new_cols) ['machinetype_L' 'machinetype_M' 'airtemp' 'processtemperature' 'rotationalspeed' 'torque' 'toolwear']
- 
接下来,我们将为我们的模型设置一个管道。该管道将执行列转换,使用 SMOTENC进行过度采样,使用selectkbest进行特征选择,然后运行 KNN 模型。记住,我们必须将分类特征的列索引传递给SMOTENC,以便它能够正确运行:catcolscnt = new_cat_cols.shape[0] smotenc = SMOTENC(categorical_features=np.arange(0,catcolscnt), random_state=0) knn = KNeighborsClassifier(n_jobs=-1) pipe1 = make_pipeline(coltrans, smotenc, SelectKBest(score_func=chi2), knn)
- 
现在,我们准备拟合我们的模型。我们将进行随机网格搜索,以确定 KNN 的最佳值和距离度量。我们还将搜索特征选择的最佳k值: knn_params = { 'selectkbest__k': np.arange(1, len(new_cols)), 'kneighborsclassifier__n_neighbors': np.arange(5, 175, 2), 'kneighborsclassifier__metric': ['euclidean','manhattan','minkowski'] } rs = RandomizedSearchCV(pipe1, knn_params, cv=5, scoring="roc_auc_ovr_weighted") rs.fit(X_train, y_train.values.ravel())
- 
让我们看看网格搜索发现了什么。除了 processtemperature之外的所有特征都值得保留在模型中。KNN 的最佳值和距离度量分别是125和minkowski。基于 ROC 曲线下的面积的最佳分数是0.9:selected = rs.best_estimator_['selectkbest'].get_support() selected.sum() 6 new_cols[selected] array(['machinetype_L', 'machinetype_M', 'airtemp', 'rotationalspeed', 'torque', 'toolwear'], dtype=object) rs.best_params_ {'selectkbest__k': 6, 'kneighborsclassifier__n_neighbors': 125, 'kneighborsclassifier__metric': 'minkowski'} rs.best_score_ 0.899426752716227
- 
让我们看看一个混淆矩阵。查看第一行,我们可以看到当没有发生故障时,发现了相当数量的故障。然而,我们的模型正确地识别了大多数实际的热量、功率和过载故障。这可能不是一个可怕的精确度和敏感度权衡。根据问题,我们可能接受大量假阳性,以在我们的模型中获得可接受的敏感度水平: pred = rs.predict(X_test) cm = skmet.confusion_matrix(y_test, pred) cmplot = skmet.ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=['None', 'Heat','Power','Overstrain','Other']) cmplot.plot() cmplot.ax_.set(title='Machine Failure Type Confusion Matrix', xlabel='Predicted Value', ylabel='Actual Value')
这产生了以下图表:

图 12.4 – 超参数调整后机器故障类型的混淆矩阵
- 
我们还应该查看一个分类报告。你可能还记得从第六章,为模型评估做准备,宏平均是简单地在类别间取平均。在这里,我们更感兴趣的是加权平均。加权 F1 分数为 0.81并不差。记住,F1 是精确率和敏感度的调和平均数:print(skmet.classification_report(y_test, pred, target_names=\ ['None', 'Heat','Power','Overstrain','Other'])) Precision recall f1-score support None 0.99 0.71 0.83 1927 Heat 0.11 0.90 0.20 21 Power 0.15 0.61 0.24 18 Overstrain 0.36 0.76 0.49 21 Other 0.01 0.31 0.02 13 accuracy 0.71 2000 macro avg 0.33 0.66 0.36 2000 weighted avg 0.96 0.71 0.81 2000
机器故障类型的类别不平衡使得建模特别困难。尽管如此,我们的 KNN 模型表现相对较好,假设大量假阳性不是问题。在这种情况下,一个假阳性可能不像一个假阴性那样成问题。它可能只是需要对看似有故障风险的机器进行更多检查。如果我们将其与实际机器故障的惊讶相比,偏向于敏感度而不是精确度可能是合适的。
让我们在另一个多类问题上尝试 KNN。
字母识别的 KNN
我们可以采取与预测机器故障时使用字母识别相同的策略。只要我们有能够很好地区分字母的特征,KNN 就是该模型的合理选择。我们将在本节尝试这种方法。
注意
在本节中,我们将使用字母识别数据。这些数据可在archive-beta.ics.uci.edu/ml/datasets/letter+recognition公开使用。有 26 个字母(全部为大写)和 20 种不同的字体。16 个不同的特征捕捉每个字母的不同属性。
让我们构建模型:
- 
首先,我们将加载我们已经使用过的相同库: import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.model_selection import StratifiedKFold, \ GridSearchCV from sklearn.neighbors import KNeighborsClassifier import sklearn.metrics as skmet from scipy.stats import randint
- 
现在,我们将加载数据并查看前几个实例。有 20,000 个观测值和 17 列。 letter是我们的目标:letterrecognition = pd.read_csv("data/letterrecognition.csv") letterrecognition.shape (20000, 17) letterrecognition.head().T 0 1 2 3 4 letter T I D N G xbox 2 5 4 7 2 ybox 8 12 11 11 1 width 3 3 6 6 3 height 5 7 8 6 1 onpixels 1 2 6 3 1 xbar 8 10 10 5 8 ybar 13 5 6 9 6 x2bar 0 5 2 4 6 y2bar 6 4 6 6 6 xybar 6 13 10 4 6 x2ybar 10 3 3 4 5 xy2bar 8 9 7 10 9 x-ege 0 2 3 6 1 xegvy 8 8 7 10 7 y-ege 0 4 3 2 5 yegvx 8 10 9 8 10
- 
现在,让我们创建训练和测试数据框: X_train, X_test, y_train, y_test = \ train_test_split(letterrecognition.iloc[:,1:],\ letterrecognition.iloc[:,0:1], test_size=0.2, random_state=0)
- 
接下来,让我们实例化一个 KNN 实例。我们还将设置分层 K 折交叉验证和超参数的字典。我们将寻找k( n_neighbors)和距离度量的最佳超参数:knn = KNeighborsClassifier(n_jobs=-1) kf = StratifiedKFold(n_splits=5, shuffle=True, random_state=0) knn_params = { 'n_neighbors': np.arange(3, 41, 2), 'metric': ['euclidean','manhattan','minkowski'] }
- 
现在,我们已经准备好进行彻底的网格搜索。在这里我们进行彻底搜索是因为我们没有很多超参数需要检查。表现最好的距离度量是欧几里得距离。最近邻的k值是 3。这个模型使我们几乎达到 95%的准确率:gs = GridSearchCV(knn, knn_params, cv=kf, scoring='accuracy') gs.fit(X_train, y_train.values.ravel()) gs.best_params_ {'metric': 'euclidean', 'n_neighbors': 3} gs.best_score_ 0.9470625
- 
让我们生成预测并绘制一个混淆矩阵: pred = gs.best_estimator_.predict(X_test) letters = np.sort(letterrecognition.letter.unique()) cm = skmet.confusion_matrix(y_test, pred) cmplot = \ skmet.ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=letters) cmplot.plot() cmplot.ax_.set(title='Letters', xlabel='Predicted Value', ylabel='Actual Value')
这会产生以下图表:

图 12.5 – 字母预测的混淆矩阵
让我们快速总结本章所学的内容。
摘要
本章展示了使用 KNN 进行二分类或多分类分类是多么容易。由于 KNN 不对正态性或线性做出假设,因此它可以在逻辑回归可能不会产生最佳结果的情况下使用。这种灵活性确实带来了过拟合的真实风险,因此在选择k时必须谨慎。我们还在本章探讨了如何调整二分类和多分类模型的超参数。最后,当我们在乎预测速度或处理大型数据集时,KNN 并不是一个很好的选择。在上一章中我们探讨的决策树或随机森林分类,在这些情况下通常是一个更好的选择。
另一个非常好的选择是支持向量分类。我们将在下一章探讨支持向量分类。
第十三章:第十三章:支持向量机分类
支持向量分类模型和 k 最近邻模型之间有一些相似之处。它们都是直观且灵活的。然而,由于算法的性质,支持向量分类比 k 最近邻有更好的可扩展性。与逻辑回归不同,它可以很容易地处理非线性模型。使用支持向量机进行分类的策略和问题与我们讨论的相似,见第八章,支持向量回归,当时我们使用支持向量机进行回归。
支持向量分类(SVC)的一个关键优势是它赋予我们减少模型复杂度的能力,同时不增加我们的特征空间。但它也提供了多个我们可以调整的杠杆,以限制过拟合的可能性。我们可以选择线性模型,或从几个非线性核中选择。我们可以使用正则化参数,就像我们在逻辑回归中所做的那样。通过扩展,我们还可以使用这些相同的技巧来构建多类模型。
在本章中,我们将探讨以下主题:
- 
SVC 的关键概念 
- 
线性 SVC 模型 
- 
非线性 SVM 分类模型 
- 
多类分类的 SVM 
技术要求
在本章中,我们将坚持使用 pandas、NumPy 和 scikit-learn 库。本章中的所有代码都使用 scikit-learn 版本 0.24.2 和 1.0.2 进行了测试。显示决策边界的代码需要 scikit-learn 版本 1.1.1 或更高版本。
SVC 的关键概念
我们可以使用支持向量机(SVMs)来找到一条线或曲线,通过类别来分离实例。当类别可以通过一条线来区分时,它们被称为线性可分。
然而,正如我们在图 13.1中可以看到的,可能有许多可能的线性分类器。每条线都成功地使用两个特征 x1 和 x2 来区分由点表示的两个类别,关键的区别在于这些线如何对新的实例进行分类,这些新实例由透明矩形表示。使用离正方形最近的线会将透明矩形分类为点。使用其他两条线中的任何一条都会将其分类为正方形。

图 13.1 – 三种可能的线性分类器
当线性判别器非常接近训练实例时,就像图 13.2中的两条线一样,就有更大的风险将新实例分类错误。我们希望得到一个能够给出最大类别间间隔的线;一个离每个类别的边界数据点最远的线。那就是图 13.1中的中间线,但在图 13.2中可以看得更清楚:

图 13.2 – SVM 分类和最大间隔
粗体线分割了最大间隔,被称为决策边界。每个类别的边界数据点被称为支持向量。
我们使用支持向量机来寻找类别之间具有最大间隔的线性判别式。它是通过找到一个可以最大化的间隔的方程来实现的,其中间隔是数据点到分离超平面的距离。在具有两个特征的情况下,如图 13.2所示,该超平面只是一条线。然而,这可以推广到具有更多维度的特征空间。
对于像图 13.2中的数据点,我们可以使用所谓的硬间隔分类而不会出现问题;也就是说,我们可以对每个类别的所有观察值在决策边界的正确一侧非常严格。但如果我们数据点的样子像图 13.3中的那些呢?在这里,有一个方形非常接近点。硬间隔分类器是左侧的线,给我们非常小的间隔。

图 13.3 – 带有硬间隔和软间隔的支持向量机
如果我们使用软间隔分类,则得到右侧的线。软间隔分类放宽了所有实例都必须正确分离的约束。正如图 13.3中的数据所示,允许训练数据中有少量错误分类可以给我们更大的间隔。我们忽略偏离的方形,得到由软间隔线表示的决策边界。
约束放宽的程度由C超参数决定。C的值越大,对间隔违规的惩罚就越大。不出所料,具有较大C值的模型更容易过拟合。图 13.4说明了间隔如何随着C值的改变而变化。在C = 1时,错误分类的惩罚较低,给我们比C为 100 时更大的间隔。然而,即使在C为 100 的情况下,仍然会发生一些间隔违规。

图 13.4 – 不同 C 值下的软间隔
在实际操作中,我们几乎总是用软间隔构建我们的 SVC 模型。scikit-learn 中C的默认值是 1。
非线性支持向量机和核技巧
我们尚未完全解决 SVC 的线性可分性问题。为了简单起见,回到一个涉及两个特征的分类问题是有帮助的。假设两个特征与分类目标的关系图看起来像图 13.5中的插图。目标有两个可能的值,由点和正方形表示。x1 和 x2 是数值,具有负值。

图 13.5 – 使用两个特征无法线性分离的类别标签
在这种情况下,我们如何识别类之间的边界?通常情况下,在更高的维度中可以识别出边界。在这个例子中,我们可以使用图 13.6 中所示的多项式变换:

图 13.6 – 使用多项式变换建立边界
现在有一个第三维度,它是 x1 和 x2 平方和的总和。点都高于平方。这与我们使用多项式变换进行线性回归的方式相似。
这种方法的缺点之一是我们可能会迅速拥有太多特征,以至于模型无法良好地执行。这就是核技巧大显身手的地方。SVC 可以使用核函数隐式地扩展特征空间,而不实际创建更多特征。这是通过创建一个可以用来拟合非线性边界的值向量来实现的。
虽然这允许我们拟合一个类似于图 13.6 中假设的假设多项式变换,但 SVC 中最常用的核函数是径向基函数(RBF)。RBF 之所以受欢迎,是因为它比其他常见的核函数更快,并且可以使用伽马超参数提供额外的灵活性。RBF 核的方程如下:

在这里, 和
 和  是数据点。伽马值,
 是数据点。伽马值, ,决定了每个点的影响力大小。当伽马值较高时,点必须非常接近才能被分组在一起。在伽马值非常高的情况下,我们开始看到点的岛屿。
,决定了每个点的影响力大小。当伽马值较高时,点必须非常接近才能被分组在一起。在伽马值非常高的情况下,我们开始看到点的岛屿。
当然,伽马值或C的高值取决于我们的数据。一个好的方法是,在大量建模之前,创建不同伽马值和C值的决策边界可视化。这将让我们了解在不同的超参数值下,我们是否过度拟合或欠拟合。在本章中,我们将绘制不同伽马值和C值的决策边界。
SVC 的多类分类
到目前为止,我们关于支持向量机(SVC)的所有讨论都集中在二元分类上。幸运的是,适用于二元分类支持向量机的所有关键概念也适用于我们的目标值超过两个的可能值时的分类。我们将多类问题建模为一对一或一对余问题,从而将其转化为二元分类问题。

图 13.7 – 多类 SVC 选项
在三类示例中,一对一分类很容易说明,如图 13.7 的左侧所示。每个类别与每个其他类别之间估计一个决策边界。例如,虚线是点类与正方形类之间的决策边界。实线是点与椭圆形之间的决策边界。
在一对一分类中,每个类别与不属于该类别的实例之间构建一个决策边界。这如图 13.7 的右侧所示。实线是点与不是点(即正方形或椭圆形)的实例之间的决策边界。虚线和双线分别是正方形与剩余实例和椭圆形与剩余实例之间的决策边界。
我们可以使用一对一或一对一分类来构建线性和非线性 SVC 模型。我们还可以指定C的值来构建软边界。然而,使用这些技术中的每一个构建更多的决策边界需要比二分类 SVC 更多的计算资源。如果我们有大量的观察结果、许多特征和多个参数需要调整,我们可能需要非常好的系统资源才能及时获得结果。
三类示例隐藏了一个关于一对一和一对一分类器不同之处。对于三个类别,它们使用相同数量的分类器(三个),但随着一对一的增加,分类器的数量相对迅速地增加。在一对一分类中,分类器的数量始终等于类别值的数量,而在一对一分类中,它等于以下:

在这里,S是分类器的数量,N是目标的目标值(类别值)的基数。因此,基数是 4 时,一对一分类需要 4 个分类器,而一对一分类使用 6 个。
我们在本章的最后部分探讨了多类 SVC 模型,但让我们从一个相对简单的线性模型开始,看看 SVC 的实际应用。在为 SVC 模型进行预处理时,有两个需要注意的事项。首先,SVC 对特征的规模很敏感,因此在我们拟合模型之前需要解决这一点。其次,如果我们使用硬边界或高C值,异常值可能会对我们的模型产生很大的影响。
线性 SVC 模型
我们通常可以通过使用线性 SVC 模型获得良好的结果。当我们有超过两个特征时,没有简单的方法来可视化我们的数据是否线性可分。我们通常根据超参数调整来决定是线性还是非线性。在本节中,我们将假设我们可以通过线性模型和软边界获得良好的性能。
在本节中,我们将处理关于美国职业篮球联赛(NBA)比赛的数据。数据集包含了从 2017/2018 赛季到 2020/2021 赛季每场 NBA 比赛的统计数据。这包括主队、主队是否获胜、客队、客队和主队的投篮命中率、失误、篮板和助攻,以及许多其他指标。
注意
NBA 比赛数据可在www.kaggle.com/datasets/wyattowalsh/basketball供公众下载。该数据集从 1946/1947 赛季的 NBA 赛季开始。它使用nba_api从nba.com获取统计数据。该 API 可在github.com/swar/nba_api找到。
让我们构建一个线性 SVC 模型:
- 
我们首先加载熟悉的库。唯一的新模块是 LinearSVC和DecisionBoundaryDisplay。我们将使用DecisionBoundaryDisplay来显示线性模型的边界:import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import OneHotEncoder, StandardScaler from sklearn.svm import LinearSVC from scipy.stats import uniform from sklearn.impute import SimpleImputer from sklearn.pipeline import make_pipeline from sklearn.compose import ColumnTransformer from sklearn.feature_selection import RFECV from sklearn.inspection import DecisionBoundaryDisplay from sklearn.model_selection import cross_validate, \ RandomizedSearchCV, RepeatedStratifiedKFold import sklearn.metrics as skmet import seaborn as sns import os import sys sys.path.append(os.getcwd() + "/helperfunctions") from preprocfunc import OutlierTrans
- 
我们已经准备好加载 NBA 比赛数据。我们只需做一些清理工作。少数观测值的目标 WL_HOME(主队是否获胜)有缺失值。我们移除这些观测值。我们将WL_HOME特征转换为0和1特征。
在这里,类别不平衡的问题并不大。这将在以后为我们节省一些时间:
nbagames = pd.read_csv("data/nbagames2017plus.csv", parse_dates=['GAME_DATE'])
nbagames = \
  nbagames.loc[nbagames.WL_HOME.isin(['W','L'])]
nbagames.shape
(4568, 149)
nbagames['WL_HOME'] = \
  np.where(nbagames.WL_HOME=='L',0,1).astype('int')
nbagames.WL_HOME.value_counts(dropna=False)
1    2586
0    1982
Name: WL_HOME, dtype: int64
- 
让我们按数据类型组织我们的特征: num_cols = ['FG_PCT_HOME','FTA_HOME','FG3_PCT_HOME', 'FTM_HOME','FT_PCT_HOME','OREB_HOME','DREB_HOME', 'REB_HOME','AST_HOME','STL_HOME','BLK_HOME', 'TOV_HOME','FG_PCT_AWAY','FTA_AWAY','FG3_PCT_AWAY', 'FT_PCT_AWAY','OREB_AWAY','DREB_AWAY','REB_AWAY', 'AST_AWAY','STL_AWAY','BLK_AWAY','TOV_AWAY'] cat_cols = ['SEASON']
- 
让我们看看一些描述性统计。 (为了节省空间,我已经从打印输出中省略了一些特征。)我们需要缩放这些特征,因为它们的范围差异很大。没有缺失值,但当我们为极端值分配缺失值时,我们将生成一些缺失值: nbagames[['WL_HOME'] + num_cols].agg(['count','min','median','max']).T count min median max WL_HOME 4,568 0.00 1.00 1.00 FG_PCT_HOME 4,568 0.27 0.47 0.65 FTA_HOME 4,568 1.00 22.00 64.00 FG3_PCT_HOME 4,568 0.06 0.36 0.84 FTM_HOME 4,568 1.00 17.00 44.00 FT_PCT_HOME 4,568 0.14 0.78 1.00 OREB_HOME 4,568 1.00 10.00 25.00 DREB_HOME 4,568 18.00 35.00 55.00 REB_HOME 4,568 22.00 45.00 70.00 AST_HOME 4,568 10.00 24.00 50.00 ......... FT_PCT_AWAY 4,568 0.26 0.78 1.00 OREB_AWAY 4,568 0.00 10.00 26.00 DREB_AWAY 4,568 18.00 34.00 56.00 REB_AWAY 4,568 22.00 44.00 71.00 AST_AWAY 4,568 9.00 24.00 46.00 STL_AWAY 4,568 0.00 8.00 19.00 BLK_AWAY 4,568 0.00 5.00 15.00 TOV_AWAY 4,568 3.00 14.00 30.00
- 
我们还应该回顾一下特征的相关性: corrmatrix = nbagames[['WL_HOME'] + \ num_cols].corr(method="pearson") sns.heatmap(corrmatrix, xticklabels=corrmatrix.columns, yticklabels=corrmatrix.columns, cmap="coolwarm") plt.title('Heat Map of Correlation Matrix') plt.tight_layout() plt.show()
这产生了以下图表:

图 13.8 – NBA 比赛统计数据相关性热图
一些特征与目标相关,包括主队的投篮命中率(FG_PCT_HOME)和主队的防守篮板球(DREB_HOME)。
特征之间也存在相关性。例如,主队的投篮命中率(FG_PCT_HOME)和主队的 3 分投篮命中率(FG3_PCT_HOME)呈正相关,这并不令人意外。此外,主队的篮板球(REB_HOME)和防守篮板球(DREB_HOME)可能过于紧密地相关,以至于任何模型都无法分离它们的影响。
- 
接下来,我们创建训练和测试数据框: X_train, X_test, y_train, y_test = \ train_test_split(nbagames[num_cols + cat_cols],\ nbagames[['WL_HOME']], test_size=0.2, random_state=0)
- 
我们需要设置列转换。对于数值列,我们检查异常值并缩放数据。我们将一个分类特征 SEASON进行独热编码。我们将在网格搜索中使用这些转换:ohe = OneHotEncoder(drop='first', sparse=False) cattrans = make_pipeline(ohe) standtrans = make_pipeline(OutlierTrans(2), SimpleImputer(strategy="median"), StandardScaler()) coltrans = ColumnTransformer( transformers=[ ("cat", cattrans, cat_cols), ("stand", standtrans, num_cols) ] )
- 
在构建我们的模型之前,让我们看看一个线性 SVC 模型的决策边界。我们基于与目标相关的两个特征来设置边界:主队的投篮命中率( FG_PCT_HOME)和主队的防守篮板(DREB_HOME)。
我们创建了一个函数dispbound,它将使用DecisionBoundaryDisplay模块来显示边界。这个模块在 scikit-learn 版本 1.1.1 或更高版本中可用。DecisionBoundaryDisplay需要一个模型来拟合,两个特征和目标值:
pipe0 = make_pipeline(OutlierTrans(2),
  SimpleImputer(strategy="median"), StandardScaler())
X_train_enc = pipe0.\
  fit_transform(X_train[['FG_PCT_HOME','DREB_HOME']])
def dispbound(model, X, xvarnames, y, title):
  dispfit = model.fit(X,y)
  disp = DecisionBoundaryDisplay.from_estimator(
    dispfit, X, response_method="predict",
    xlabel=xvarnames[0], ylabel=xvarnames[1],
    alpha=0.5,
  )
  scatter = disp.ax_.scatter(X[:,0], X[:,1],
    c=y, edgecolor="k")
  disp.ax_.set_title(title)
  legend1 = disp.ax_.legend(*scatter.legend_elements(),
    loc="lower left", title="Home Win")
  disp.ax_.add_artist(legend1)
dispbound(LinearSVC(max_iter=1000000,loss='hinge'),
  X_train_enc, ['FG_PCT_HOME','DREB_HOME'],
  y_train.values.ravel(),
  'Linear SVC Decision Boundary')
这产生了以下图表:

图 13.9 – 双特征线性 SVC 模型的决策边界
我们只使用两个特征就得到了一个相当不错的线性边界。这是个好消息,但让我们构建一个更精心设计的模型。
- 
为了构建我们的模型,我们首先实例化一个线性 SVC 对象并设置递归特征消除。然后我们将列转换、特征选择和线性 SVC 添加到管道中并拟合它: svc = LinearSVC(max_iter=1000000, loss='hinge', random_state=0) rfecv = RFECV(estimator=svc, cv=5) pipe1 = make_pipeline(coltrans, rfecv, svc) pipe1.fit(X_train, y_train.values.ravel())
- 
让我们看看从我们的递归特征消除中选择了哪些特征。我们首先需要获取一元编码后的列名。然后我们可以使用 rfecv对象的get_support方法来获取选定的特征。(如果你使用的是 scikit-learn 版本 1 或更高版本,你会得到一个关于get_feature_names的弃用警告。不过,你可以使用get_feature_names_out代替,尽管这不会与 scikit-learn 的早期版本兼容。)new_cat_cols = \ pipe1.named_steps['columntransformer'].\ named_transformers_['cat'].\ named_steps['onehotencoder'].\ get_feature_names(cat_cols) new_cols = np.concatenate((new_cat_cols, np.array(num_cols))) sel_cols = new_cols[pipe1['rfecv'].get_support()] np.set_printoptions(linewidth=55) sel_cols array(['SEASON_2018', 'SEASON_2019', 'SEASON_2020', 'FG_PCT_HOME', 'FTA_HOME', 'FG3_PCT_HOME', 'FTM_HOME', 'FT_PCT_HOME', 'OREB_HOME', 'DREB_HOME', 'REB_HOME', 'AST_HOME', 'TOV_HOME', 'FG_PCT_AWAY', 'FTA_AWAY', 'FG3_PCT_AWAY', 'FT_PCT_AWAY', 'OREB_AWAY', 'DREB_AWAY', 'REB_AWAY', 'AST_AWAY', 'BLK_AWAY', 'TOV_AWAY'], dtype=object)
- 
我们应该看看系数。对于每个选定的列的系数可以通过 linearsvc对象的coef_属性来访问。也许并不令人惊讶,主队的投篮命中率(FG_PCT_HOME)和客队的投篮命中率(FG_PCT_AWAY)是主队获胜的最重要正负预测因子。接下来最重要的特征是客队和主队的失误次数:pd.Series(pipe1['linearsvc'].\ coef_[0], index=sel_cols).\ sort_values(ascending=False) FG_PCT_HOME 2.21 TOV_AWAY 1.20 REB_HOME 1.19 FTM_HOME 0.95 FG3_PCT_HOME 0.94 FT_PCT_HOME 0.31 AST_HOME 0.25 OREB_HOME 0.18 DREB_AWAY 0.11 SEASON_2018 0.10 FTA_HOME -0.05 BLK_AWAY -0.07 SEASON_2019 -0.11 SEASON_2020 -0.19 AST_AWAY -0.44 OREB_AWAY -0.47 DREB_HOME -0.49 FT_PCT_AWAY -0.53 REB_AWAY -0.63 FG3_PCT_AWAY -0.80 FTA_AWAY -0.81 TOV_HOME -1.19 FG_PCT_AWAY -1.91 dtype: float64
- 
让我们看看预测结果。我们的模型在预测主队获胜方面做得很好: pred = pipe1.predict(X_test) print("accuracy: %.2f, sensitivity: %.2f, specificity: %.2f, precision: %.2f" % (skmet.accuracy_score(y_test.values.ravel(), pred), skmet.recall_score(y_test.values.ravel(), pred), skmet.recall_score(y_test.values.ravel(), pred, pos_label=0), skmet.precision_score(y_test.values.ravel(), pred))) accuracy: 0.93, sensitivity: 0.95, specificity: 0.92, precision: 0.93
- 
我们应该通过进行交叉验证来确认这些指标不是偶然的。我们使用重复的有放回分层 k 折来进行验证,这意味着我们想要 7 个折和 10 次迭代。我们得到的结果与之前步骤中的结果几乎相同: kf = RepeatedStratifiedKFold(n_splits=7,n_repeats=10,\ random_state=0) scores = cross_validate(pipe1, X_train, \ y_train.values.ravel(), \ scoring=['accuracy','precision','recall','f1'], \ cv=kf, n_jobs=-1) print("accuracy: %.2f, precision: %.2f, sensitivity: %.2f, f1: %.2f" % (np.mean(scores['test_accuracy']),\ np.mean(scores['test_precision']),\ np.mean(scores['test_recall']),\ np.mean(scores['test_f1']))) accuracy: 0.93, precision: 0.93, sensitivity: 0.95, f1: 0.94
- 
到目前为止,我们一直在使用 C的默认值1。我们可以尝试使用随机网格搜索来识别一个更好的C值:svc_params = { 'linearsvc__C': uniform(loc=0, scale=100) } rs = RandomizedSearchCV(pipe1, svc_params, cv=10, scoring='accuracy', n_iter=20, random_state=0) rs.fit(X_train, y_train.values.ravel()) rs.best_params_ {'linearsvc__C': 54.88135039273247} rs.best_score_ 0.9315809566584325
最佳的C值是 2.02,最佳的准确度得分是 0.9316。
- 
让我们仔细看看 20 次网格搜索中每次的得分。每个得分是 10 个折的准确度得分的平均值。实际上,无论 C值如何,我们得到的分数都差不多:results = \ pd.DataFrame(rs.cv_results_['mean_test_score'], \ columns=['meanscore']).\ join(pd.DataFrame(rs.cv_results_['params'])).\ sort_values(['meanscore'], ascending=False) results meanscore linearsvc__C 0 0.93 54.88 8 0.93 96.37 18 0.93 77.82 17 0.93 83.26 13 0.93 92.56 12 0.93 56.80 11 0.93 52.89 1 0.93 71.52 10 0.93 79.17 7 0.93 89.18 6 0.93 43.76 5 0.93 64.59 3 0.93 54.49 2 0.93 60.28 19 0.93 87.00 9 0.93 38.34 4 0.93 42.37 14 0.93 7.10 15 0.93 8.71 16 0.93 2.02
- 
让我们现在看看一些预测结果。我们的模型在各个方面都做得很好,但并没有比初始模型做得更好: pred = rs.predict(X_test) print("accuracy: %.2f, sensitivity: %.2f, specificity: %.2f, precision: %.2f" % (skmet.accuracy_score(y_test.values.ravel(), pred), skmet.recall_score(y_test.values.ravel(), pred), skmet.recall_score(y_test.values.ravel(), pred, pos_label=0), skmet.precision_score(y_test.values.ravel(), pred))) accuracy: 0.93, sensitivity: 0.95, specificity: 0.92, precision: 0.93
- 
让我们也看看一个混淆矩阵: cm = skmet.confusion_matrix(y_test, pred) cmplot = \ skmet.ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=['Loss', 'Won']) cmplot.plot() cmplot.ax_.set(title='Home Team Win Confusion Matrix', xlabel='Predicted Value', ylabel='Actual Value')
这产生了以下图表:

图 13.10 – 主队胜负的混淆矩阵
我们的模型在很大程度上正确预测了主队的胜负。调整C的值并没有带来太大的变化,因为我们几乎无论C值如何都能获得相同的准确率。
注意
你可能已经注意到,我们在处理 NBA 比赛数据时比在之前章节中处理的心脏病和机器故障数据时更频繁地使用准确率指标。我们更关注那个数据的敏感性。这有两个原因。首先,当类别几乎平衡时,准确率是一个更有说服力的度量标准,正如我们在第六章“准备模型评估”中详细讨论的那样。其次,在预测心脏病和机器功率故障时,我们倾向于敏感性,因为那些领域中的假阴性成本高于假阳性。而对于预测 NBA 比赛,则没有这样的偏见。
线性 SVC 模型的一个优点是它们很容易解释。我们能够查看系数,这有助于我们理解模型并与其他人沟通我们预测的基础。尽管如此,确认我们不会使用非线性模型获得更好的结果也是有帮助的。我们将在下一节中这样做。
非线性 SVM 分类模型
虽然非线性 SVC 在概念上比线性 SVC 更复杂,正如我们在本章第一节中看到的,使用 scikit-learn 运行非线性模型相对简单。与线性模型的主要区别是我们需要进行相当多的超参数调整。我们必须指定C、gamma的值以及我们想要使用的核函数。
虽然有一些理论上的理由可以假设对于特定的建模挑战,某些超参数值可能比其他值更有效,但我们通常通过经验方法(即超参数调整)来解决这个问题。我们将在本节中使用与上一节相同的 NBA 比赛数据来尝试这样做:
- 
我们加载了上一节中使用的相同库。我们还导入了 LogisticRegression模块。我们稍后将会使用该模块与特征选择包装器方法结合:import pandas as pd import numpy as np from sklearn.preprocessing import MinMaxScaler from sklearn.pipeline import make_pipeline from sklearn.svm import SVC from sklearn.linear_model import LogisticRegression from scipy.stats import uniform from sklearn.feature_selection import RFECV from sklearn.impute import SimpleImputer from scipy.stats import randint from sklearn.model_selection import RandomizedSearchCV import sklearn.metrics as skmet import os import sys sys.path.append(os.getcwd() + "/helperfunctions") from preprocfunc import OutlierTrans
- 
我们导入了 nbagames模块,其中包含加载和预处理 NBA 比赛数据的代码。这仅仅是我们在上一节中运行以准备建模数据的代码的副本。在这里没有必要重复那些步骤。
我们还导入了上一节中使用的dispbound函数来显示决策边界。我们将那段代码复制到了当前目录下的helperfunctions子目录中,文件名为displayfunc.py:
import nbagames as ng
from displayfunc import dispbound
- 
我们使用 nbagames模块来获取训练和测试数据:X_train = ng.X_train X_test = ng.X_test y_train = ng.y_train y_test = ng.y_test
- 
在构建模型之前,让我们看看具有两个特征(主队的投篮命中率 FG_PCT_HOME和主队的防守篮板DREB_HOME)的几个不同核的决策边界。我们首先使用rbf核,并使用不同的gamma和C值:pipe0 = make_pipeline(OutlierTrans(2), SimpleImputer(strategy="median"), StandardScaler()) X_train_enc = \ pipe0.fit_transform(X_train[['FG_PCT_HOME', 'DREB_HOME']]) dispbound(SVC(kernel='rbf', gamma=30, C=1), X_train_enc,['FG_PCT_HOME','DREB_HOME'], y_train.values.ravel(), "SVC with rbf kernel-gamma=30, C=1")
以几种不同的方式运行此操作会产生以下图表:

图 13.11 – 使用 rbf 核和不同 gamma 和 C 值的决策边界
在 gamma 和 C 的值接近默认值时,我们看到决策边界有一些弯曲,以适应损失类中的几个偏离的点。这些是主队尽管有很高的防守篮板总数却输掉比赛的情况。使用 rbf 核,其中两个这样的实例现在被正确分类。还有一些主队投篮命中率很高但防守篮板很低的实例,现在也被正确分类。然而,与上一节中的线性模型相比,我们的预测整体上并没有太大变化。
但如果我们增加 C 或 gamma 的值,这种变化会显著。回想一下,C 的较高值会增加误分类的惩罚。这导致边界围绕实例旋转。
将 gamma 增加到 30 会导致严重的过度拟合。gamma 的高值意味着数据点必须非常接近才能被分组在一起。这导致决策边界紧密地与少数实例相关联,有时甚至只有一个实例。
- 
我们还可以展示多项式核的边界。我们将保持默认的 C值,以关注改变度数的影响:dispbound(SVC(kernel='poly', degree=7), X_train_enc, ['FG_PCT_HOME','DREB_HOME'], y_train.values.ravel(), "SVC with polynomial kernel - degree=7")
以几种不同的方式运行此操作会产生以下图表:

图 13.12 – 使用多项式核和不同度数的决策边界
我们可以看到在较高度数级别上决策边界的某些弯曲,以处理几个不寻常的实例。这里并没有过度拟合,但我们的预测也没有真正得到很大改善。
这至少暗示了当我们构建模型时可以期待什么。我们应该尝试一些非线性模型,但有很大可能性它们不会比我们在上一节中使用的线性模型带来太多改进。
- 
现在,我们已准备好设置我们将用于非线性 SVC 的管道。我们的管道将执行列转换和递归特征消除。我们使用逻辑回归进行特征选择: rfecv = RFECV(estimator=LogisticRegression()) svc = SVC() pipe1 = make_pipeline(ng.coltrans, rfecv, svc)
- 
我们创建一个字典用于我们的超参数调整。这个字典的结构与我们用于此目的的其他字典略有不同。这是因为某些超参数只能与某些其他超参数一起使用。例如, gamma不能与线性核一起使用:svc_params = [ { 'svc__kernel': ['rbf'], 'svc__C': uniform(loc=0, scale=20), 'svc__gamma': uniform(loc=0, scale=100) }, { 'svc__kernel': ['poly'], 'svc__degree': randint(1, 5), 'svc__C': uniform(loc=0, scale=20), 'svc__gamma': uniform(loc=0, scale=100) }, { 'svc__kernel': ['linear','sigmoid'], 'svc__C': uniform(loc=0, scale=20) } ]注意 你可能已经注意到我们将使用的一个核是线性的,并想知道这与我们在上一节中使用的线性 SVC 模块有何不同。 LinearSVC通常会更快地收敛,尤其是在大型数据集上。它不使用核技巧。我们可能也会得到不同的结果,因为优化在几个方面都不同。
- 
现在我们已经准备好拟合一个 SVC 模型。最佳模型实际上是一个线性核的模型: rs = RandomizedSearchCV(pipe1, svc_params, cv=5, scoring='accuracy', n_iter=10, n_jobs=-1, verbose=5, random_state=0) rs.fit(X_train, y_train.values.ravel()) rs.best_params_ {'svc__C': 1.1342595463488636, 'svc__kernel': 'linear'} rs.best_score_ 0.9299405955437289
- 
让我们更仔细地看看选定的超参数和相关的准确率分数。我们可以从网格对象的 cv_results_字典中获取params列表中的 20 个随机选择的超参数组合。我们也可以从同一个字典中获取平均测试分数。
我们按准确率分数降序排序。线性核优于多项式核和rbf核,尽管在3、4和5度上并不比多项式核显著更好。rbf核的表现尤其糟糕:
results = \
  pd.DataFrame(rs.cv_results_['mean_test_score'], \
    columns=['meanscore']).\
  join(pd.json_normalize(rs.cv_results_['params'])).\
  sort_values(['meanscore'], ascending=False)
results
            C          gamma     kernel     degree
meanscore                              
0.93        1.13       NaN       linear     NaN
0.89        1.42       64.82     poly       3.00
0.89        9.55       NaN       sigmoid    NaN
0.89        11.36      NaN       sigmoid    NaN
0.89        2.87       75.86     poly       5.00
0.64        12.47      43.76     poly       4.00
0.64        15.61      72.06     poly       4.00
0.57        11.86      84.43     rbf        NaN
0.57        16.65      77.82     rbf        NaN
0.57        19.57      79.92     rbf        NaN
注意
我们使用 pandas 的json_normalize方法来处理我们从params列表中提取的有些混乱的超参数组合。这是因为不同的超参数取决于所使用的核。这意味着params列表中的 20 个字典将具有不同的键。例如,多项式核将具有度数的值。线性核和rbf核则没有。
- 
我们可以通过 best_estimator_属性访问支持向量。有 625 个支持向量支撑着决策边界:rs.best_estimator_['svc'].\ support_vectors_.shape (625, 18)
- 
最后,我们可以看一下预测结果。不出所料,我们没有比上一节中运行的线性 SVC 模型得到更好的结果。我说不出所料,因为最佳模型被发现是一个线性核的模型: pred = rs.predict(X_test) print("accuracy: %.2f, sensitivity: %.2f, specificity: %.2f, precision: %.2f" % (skmet.accuracy_score(y_test.values.ravel(), pred), skmet.recall_score(y_test.values.ravel(), pred), skmet.recall_score(y_test.values.ravel(), pred, pos_label=0), skmet.precision_score(y_test.values.ravel(), pred))) accuracy: 0.93, sensitivity: 0.94, specificity: 0.91, precision: 0.93
虽然我们没有改进上一节中的模型,但尝试一些非线性模型仍然是一项值得的练习。事实上,我们通常就是这样发现我们是否有可以成功线性分离的数据。这通常很难可视化,所以我们依赖于超参数调整来告诉我们哪个核最适合我们的数据。
本节和上一节展示了使用 SVM 进行二类分类的关键技术。我们迄今为止所做的大部分内容也适用于多类分类。在下一节中,当我们的目标值超过两个时,我们将探讨 SVC 建模策略。
多类分类的 SVM
当我们进行多类分类时,所有我们在使用 SVC 进行二类分类时遇到的问题都适用。我们需要确定类别是否线性可分,如果不是,哪个核将产生最佳结果。正如本章第一节所讨论的,我们还需要决定这种分类是否最好建模为一对一或一对多。一对一找到将每个类别与其他每个类别分开的决策边界。一对多找到将每个类别与所有其他实例区分开的决策边界。我们在本节中尝试这两种方法。
我们将使用我们在前几章中使用过的机器故障数据。
注意
这个关于机器故障的数据集可以在www.kaggle.com/datasets/shivamb/machine-predictive-maintenance-classification上公开使用。有 10,000 个观测值,12 个特征,以及两个可能的目标。一个是二元的:机器故障或未故障。另一个是故障类型。这个数据集中的实例是合成的,由一个旨在模拟机器故障率和原因的过程生成。
让我们构建一个多类 SVC 模型:
- 
我们首先加载本章中一直在使用的相同库: import pandas as pd from sklearn.model_selection import train_test_split from sklearn.preprocessing import OneHotEncoder, MinMaxScaler from sklearn.pipeline import make_pipeline from sklearn.svm import SVC from scipy.stats import uniform from sklearn.impute import SimpleImputer from sklearn.compose import ColumnTransformer from sklearn.model_selection import RandomizedSearchCV import sklearn.metrics as skmet import os import sys sys.path.append(os.getcwd() + "/helperfunctions") from preprocfunc import OutlierTrans
- 
我们将加载机器故障类型数据集并查看其结构。这里有字符和数值数据的混合。没有缺失值: machinefailuretype = pd.read_csv("data/machinefailuretype.csv") machinefailuretype.info() <class 'pandas.core.frame.DataFrame'> RangeIndex: 10000 entries, 0 to 9999 Data columns (total 10 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 udi 10000 non-null int64 1 product 10000 non-null object 2 machinetype 10000 non-null object 3 airtemp 10000 non-null float64 4 processtemperature 10000 non-null float64 5 rotationalspeed 10000 non-null int64 6 torque 10000 non-null float64 7 toolwear 10000 non-null int64 8 fail 10000 non-null int64 9 failtype 10000 non-null object dtypes: float64(3), int64(4), object(3) memory usage: 781.4+ KB
- 
让我们看看一些观测值: machinefailuretype.head() udi product machinetype airtemp processtemperature\ 0 1 M14860 M 298 309 1 2 L47181 L 298 309 2 3 L47182 L 298 308 3 4 L47183 L 298 309 4 5 L47184 L 298 309 rotationalspeed torque toolwear fail failtype 0 1551 43 0 0 No Failure 1 1408 46 3 0 No Failure 2 1498 49 5 0 No Failure 3 1433 40 7 0 No Failure 4 1408 40 9 0 No Failure
- 
让我们也看看目标值的分布。我们有显著的类别不平衡,所以我们需要以某种方式处理这个问题: machinefailuretype.failtype.\ value_counts(dropna=False).sort_index() Heat Dissipation Failure 112 No Failure 9652 Overstrain Failure 78 Power Failure 95 Random Failures 18 Tool Wear Failure 45 Name: failtype, dtype: int64
- 
我们可以通过为故障类型创建一个数字代码来节省一些麻烦,我们将使用这个数字代码而不是字符值。我们不需要将其放入管道中,因为我们没有在转换中引入任何数据泄露: def setcode(typetext): if (typetext=="No Failure"): typecode = 1 elif (typetext=="Heat Dissipation Failure"): typecode = 2 elif (typetext=="Power Failure"): typecode = 3 elif (typetext=="Overstrain Failure"): typecode = 4 else: typecode = 5 return typecode machinefailuretype["failtypecode"] = \ machinefailuretype.apply(lambda x: setcode(x.failtype), axis=1)
- 
我们还应该查看一些描述性统计。我们需要对特征进行缩放: num_cols = ['airtemp','processtemperature', 'rotationalspeed','torque','toolwear'] cat_cols = ['machinetype'] machinefailuretype[num_cols].agg(['min','median','max']).T min median max airtemp 295.30 300.10 304.50 processtemperature 305.70 310.10 313.80 rotationalspeed 1,168.00 1,503.00 2,886.00 torque 3.80 40.10 76.60 toolwear 0.00 108.00 253.00
- 
现在让我们创建训练和测试数据框。我们还应该使用 stratify参数来确保训练和测试数据中目标值的分布均匀:X_train, X_test, y_train, y_test = \ train_test_split(machinefailuretype[num_cols + cat_cols],\ machinefailuretype[['failtypecode']],\ stratify=machinefailuretype[['failtypecode']], \ test_size=0.2, random_state=0)
- 
我们设置了需要运行的列转换。对于数值列,我们将异常值设置为中位数,然后缩放值。我们对一个分类特征 machinetype进行了一元编码。它有H、M和L值,分别代表高质量、中质量和低质量:ohe = OneHotEncoder(drop='first', sparse=False) cattrans = make_pipeline(ohe) standtrans = make_pipeline(OutlierTrans(3), SimpleImputer(strategy="median"), MinMaxScaler()) coltrans = ColumnTransformer( transformers=[ ("cat", cattrans, cat_cols), ("stand", standtrans, num_cols), ] )
- 
接下来,我们设置一个包含列转换和 SVC 实例的管道。我们将 class_weight参数设置为balanced以处理类别不平衡。这会应用一个与目标类别频率成反比的权重:svc = SVC(class_weight='balanced', probability=True) pipe1 = make_pipeline(coltrans, svc)
在这种情况下,我们只有少量特征,所以我们不会担心特征选择。(我们可能仍然会担心高度相关的特征,但在这个数据集中这不是一个问题。)
- 
我们创建了一个字典,包含用于网格搜索的超参数组合。这基本上与我们在上一节中使用的字典相同,只是我们添加了一个决策函数形状键。这将导致网格搜索尝试一对一( ovo)和一对多(ovr)分类:svc_params = [ { 'svc__kernel': ['rbf'], 'svc__C': uniform(loc=0, scale=20), 'svc__gamma': uniform(loc=0, scale=100), 'svc__decision_function_shape': ['ovr','ovo'] }, { 'svc__kernel': ['poly'], 'svc__degree': np.arange(0,6), 'svc__C': uniform(loc=0, scale=20), 'svc__gamma': uniform(loc=0, scale=100), 'svc__decision_function_shape': ['ovr','ovo'] }, { 'svc__kernel': ['linear','sigmoid'], 'svc__C': uniform(loc=0, scale=20), 'svc__decision_function_shape': ['ovr','ovo'] } ]
- 
现在我们已经准备好运行随机网格搜索。我们将基于 ROC 曲线下的面积来评分。最佳超参数包括一对一决策函数和 rbf核:rs = RandomizedSearchCV(pipe1, svc_params, cv=7, scoring="roc_auc_ovr", n_iter=10) rs.fit(X_train, y_train.values.ravel()) rs.best_params_ {'svc__C': 5.609789456747942, 'svc__decision_function_shape': 'ovo', 'svc__gamma': 27.73459801111866, 'svc__kernel': 'rbf'} rs.best_score_ 0.9187636814475847
- 
让我们看看每次迭代的分数。除了我们在上一步中看到的最佳模型外,还有几个其他超参数组合的分数几乎一样高。使用线性核的一对多几乎与表现最好的模型一样好: results = \ pd.DataFrame(rs.cv_results_['mean_test_score'], \ columns=['meanscore']).\ join(pd.json_normalize(rs.cv_results_['params'])).\ sort_values(['meanscore'], ascending=False) results meanscore svc__C svc__decision_function_shape svc__gamma svc__kernel 7 0.92 5.61 ovo 27.73 rbf 5 0.91 9.43 ovr NaN linear 3 0.91 5.40 ovr NaN linear 0 0.90 19.84 ovr 28.70 rbf 8 0.87 5.34 ovo 93.87 rbf 6 0.86 8.05 ovr 80.57 rbf 9 0.86 4.41 ovo 66.66 rbf 1 0.86 3.21 ovr 85.35 rbf 4 0.85 0.01 ovo 38.24 rbf 2 0.66 7.61 ovr NaN sigmoid
- 
我们应该看一下混淆矩阵: pred = rs.predict(X_test) cm = skmet.confusion_matrix(y_test, pred) cmplot = skmet.ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=['None', 'Heat','Power','Overstrain','Other']) cmplot.plot() cmplot.ax_.set(title='Machine Failure Type Confusion Matrix', xlabel='Predicted Value', ylabel='Actual Value')
这产生了以下图表:

图 13.13 – 预测机器故障类型的混淆矩阵
- 
让我们也做一个分类报告。尽管我们的模型在预测热和过载故障方面做得相当不错,但对于大多数类别,我们并没有获得很高的敏感性分数: print(skmet.classification_report(y_test, pred, target_names=['None', 'Heat','Power', 'Overstrain', 'Other'])) precision recall f1-score support None 0.99 0.97 0.98 1930 Heat 0.50 0.91 0.65 22 Power 0.60 0.47 0.53 19 Overstrain 0.65 0.81 0.72 16 Other 0.06 0.15 0.09 13 accuracy 0.96 2000 macro avg 0.56 0.66 0.59 2000 weighted avg 0.97 0.96 0.96 2000
当建模目标,如具有高类别不平衡的机器故障类型时,我们通常更关心除了准确性之外的指标。这部分取决于我们的领域知识。避免假阴性可能比避免假阳性更重要。过早地对机器进行彻底检查肯定比过晚进行更好。
96%到 97%的加权精确度、召回率(敏感性)和 f1 分数并不能很好地反映我们模型的表现。它们主要反映了类别不平衡很大,以及预测没有机器故障非常容易的事实。远低于宏观平均值(这些只是类别间的简单平均值)表明,我们的模型在预测某些类型的机器故障方面存在困难。
这个例子说明了将 SVC 扩展到具有多于两个值的目标的模型相对容易。我们可以指定是否想要使用一对一或一对多分类。当类别数量超过三个时,一对一方法可能会更快,因为将训练更少的分类器。
摘要
在本章中,我们探讨了实现 SVC 的不同策略。我们使用了线性 SVC(不使用核),当我们的类别是线性可分时,它可以表现得非常好。然后我们检查了如何使用核技巧将 SVC 扩展到类别不可分的情况。最后,我们使用一对一和一对多分类来处理多于两个值的目标。
SVC 是一种特别有用的二分类和多分类技术。它能够处理特征与目标之间的简单和复杂关系。对于几乎所有的监督学习问题,SVMs 至少都应该被考虑。然而,它在处理非常大的数据集时效率并不高。
在下一章中,我们将探讨另一种流行且灵活的分类算法,朴素贝叶斯。
第十四章:第十四章:朴素贝叶斯分类
本章中,我们将探讨在哪些情况下朴素贝叶斯可能比我们迄今为止考察的某些分类器更有效率。朴素贝叶斯是一个非常直观且易于实现的分类器。假设我们的特征是独立的,我们甚至可能比逻辑回归得到更好的性能,尤其是如果我们不使用后者进行正则化的话。
本章中,我们将讨论朴素贝叶斯的基本假设以及算法如何用于解决我们已探索的一些建模挑战,以及一些新的挑战,如文本分类。我们将考虑何时朴素贝叶斯是一个好的选择,何时不是。我们还将检查朴素贝叶斯模型的解释。
本章我们将涵盖以下主题:
- 
关键概念 
- 
朴素贝叶斯分类模型 
- 
朴素贝叶斯用于文本分类 
技术要求
本章中,我们将主要使用 pandas、NumPy 和 scikit-learn 库。唯一的例外是 imbalanced-learn 库,可以使用 pip install imbalanced-learn 安装。本章中的所有代码都使用 scikit-learn 版本 0.24.2 和 1.0.2 进行了测试。
关键概念
朴素贝叶斯分类器使用贝叶斯定理来预测类别成员资格。贝叶斯定理描述了事件发生的概率与给定新、相关数据的事件发生概率之间的关系。给定新数据的事件的概率称为后验概率。在新的数据之前发生事件的概率适当地称为先验概率。
贝叶斯定理给出了以下方程:

后验概率(给定新数据的事件的概率)等于数据给定事件的概率,乘以事件的先验概率,除以新数据的概率。
稍微不那么口语化地,这通常如下所示:

在这里,A 是一个事件,例如类别成员资格,而 B 是新信息。当应用于分类时,我们得到以下方程:

在这里, 是给定实例的特征后实例属于该类别的概率,而
 是给定实例的特征后实例属于该类别的概率,而  是给定类别成员资格的特征概率。P(y) 是类别成员资格的概率,而
 是给定类别成员资格的特征概率。P(y) 是类别成员资格的概率,而  是特征值的概率。因此,后验概率,
 是特征值的概率。因此,后验概率, ,等于给定类别成员资格的特征值概率,乘以类别成员资格的概率,除以特征值的概率。
,等于给定类别成员资格的特征值概率,乘以类别成员资格的概率,除以特征值的概率。
这里的假设是特征之间相互独立。这就是给这个方法带来朴素这个形容词的原因。然而,作为一个实际问题,特征独立性并不是使用朴素贝叶斯获得良好结果所必需的。
简单贝叶斯可以处理数值或分类特征。当我们主要拥有数值特征时,我们通常使用高斯贝叶斯。正如其名所示,高斯贝叶斯假设特征值的条件概率 遵循正态分布。然后可以使用每个类中特征的标准差和均值相对简单地计算出
遵循正态分布。然后可以使用每个类中特征的标准差和均值相对简单地计算出 。
。
当我们的特征是离散的或计数时,我们可以使用多项式贝叶斯。更普遍地说,当特征值的条件概率遵循多项式分布时,它效果很好。多项式贝叶斯的一个常见应用是与使用词袋方法的文本分类。在词袋中,特征是文档中每个词的计数。我们可以应用贝叶斯定理来估计类成员的概率:

在这里, 是给定一个词频向量W时属于k类的概率。我们将在本章的最后部分充分利用这一点。
是给定一个词频向量W时属于k类的概率。我们将在本章的最后部分充分利用这一点。
简单贝叶斯适用于相当广泛的文本分类任务。它在情感分析、垃圾邮件检测和新闻故事分类等方面都有应用,仅举几个例子。
简单贝叶斯是一种既适用于训练又适用于预测的高效算法,并且通常表现良好。它相当可扩展,可以很好地处理大量实例和特征。它也非常容易解释。当模型复杂性对于良好的预测不是必需的时候,算法表现最佳。即使简单贝叶斯不太可能是产生最少偏差的方法,它也经常用于诊断目的,或者检查不同算法的结果。
我们在上一章中使用过的 NBA 数据可能是用简单贝叶斯建模的好候选。我们将在下一节中探讨这一点。
简单贝叶斯分类模型
简单贝叶斯的一个吸引力在于,即使数据量很大,你也能快速获得不错的结果。在系统资源上,拟合和预测都相对容易。另一个优点是,可以捕捉相对复杂的关系,而无需转换特征空间或进行大量的超参数调整。我们可以用我们在上一章中使用的 NBA 数据来证明这一点。
在本节中,我们将使用关于国家篮球协会(NBA)比赛的数据。该数据集包含了从 2017/2018 赛季到 2020/2021 赛季每场 NBA 比赛的统计数据。这包括主队;主队是否获胜;客队;客队和主队的投篮命中率;两队的失误、篮板和助攻;以及其他一些指标。
注意
NBA 比赛数据可以通过以下链接由公众下载:www.kaggle.com/datasets/wyattowalsh/basketball。这个数据集包含从 1946/1947 赛季开始的比赛数据。它使用nba_api从nba.com获取统计数据。该 API 可在github.com/swar/nba_api找到。
让我们使用朴素贝叶斯构建一个分类模型:
- 
我们将加载我们在过去几章中使用过的相同库: import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import OneHotEncoder, StandardScaler from sklearn.impute import SimpleImputer from sklearn.pipeline import make_pipeline from sklearn.compose import ColumnTransformer from sklearn.feature_selection import RFE from sklearn.naive_bayes import GaussianNB from sklearn.linear_model import LogisticRegression from sklearn.model_selection import cross_validate, \ RandomizedSearchCV, RepeatedStratifiedKFold import sklearn.metrics as skmet import os import sys sys.path.append(os.getcwd() + "/helperfunctions") from preprocfunc import OutlierTrans
- 
接下来,我们将加载 NBA 比赛数据。在这里我们需要进行一些数据清理。一些观测值在主队是否获胜( WL_HOME)方面有缺失值。我们将删除这些值,因为那将是我们的目标。我们还将WL_HOME转换为整数。请注意,没有太多的类别不平衡,我们不需要采取激进的措施来处理它:nbagames = pd.read_csv("data/nbagames2017plus.csv", parse_dates=['GAME_DATE']) nbagames = nbagames.loc[nbagames.WL_HOME.isin(['W','L'])] nbagames.shape (4568, 149) nbagames['WL_HOME'] = \ np.where(nbagames.WL_HOME=='L',0,1).astype('int') nbagames.WL_HOME.value_counts(dropna=False) 1 2586 0 1982 Name: WL_HOME, dtype: int64
- 
现在,让我们创建训练和测试数据框,按数值和分类特征组织它们。我们还应该生成一些描述性统计。由于我们在上一章中已经做了,所以这里不再重复;然而,回顾那些数字可能有助于为建模阶段做好准备: num_cols = ['FG_PCT_HOME','FTA_HOME','FG3_PCT_HOME', 'FTM_HOME','FT_PCT_HOME','OREB_HOME','DREB_HOME', 'REB_HOME','AST_HOME','STL_HOME','BLK_HOME', 'TOV_HOME', 'FG_PCT_AWAY','FTA_AWAY','FG3_PCT_AWAY', 'FT_PCT_AWAY','OREB_AWAY','DREB_AWAY','REB_AWAY', 'AST_AWAY','STL_AWAY','BLK_AWAY','TOV_AWAY'] cat_cols = ['TEAM_ABBREVIATION_HOME','SEASON'] X_train, X_test, y_train, y_test = \ train_test_split(nbagames[num_cols + cat_cols],\ nbagames[['WL_HOME']], test_size=0.2,random_state=0)
- 
现在,我们需要设置列转换。我们将处理数值特征的异常值,将这些值和任何缺失值分配给中位数。然后,我们将使用标准缩放器。我们将为分类特征设置独热编码: ohe = OneHotEncoder(drop='first', sparse=False) cattrans = make_pipeline(ohe) standtrans = make_pipeline(OutlierTrans(2), SimpleImputer(strategy="median"), StandardScaler()) coltrans = ColumnTransformer( transformers=[ ("cat", cattrans, cat_cols), ("stand", standtrans, num_cols) ] )
- 
现在,我们已经准备好运行一个朴素贝叶斯分类器。我们将在列转换和一些递归特征消除之后,将高斯朴素贝叶斯实例添加到一个管道中: nb = GaussianNB() rfe = RFE(estimator=LogisticRegression(), n_features_to_select=15) pipe1 = make_pipeline(coltrans, rfe, nb)
- 
让我们用 K 折交叉验证来评估这个模型。我们得到了不错的分数,虽然不如上一章中支持向量分类的分数好: kf = RepeatedStratifiedKFold(n_splits=7,n_repeats=10,\ random_state=0) scores = cross_validate(pipe1, X_train, \ y_train.values.ravel(), \ scoring=['accuracy','precision','recall','f1'], \ cv=kf, n_jobs=-1) print("accuracy: %.2f, precision: %.2f, sensitivity: %.2f, f1: %.2f" % (np.mean(scores['test_accuracy']),\ np.mean(scores['test_precision']),\ np.mean(scores['test_recall']),\ np.mean(scores['test_f1']))) accuracy: 0.81, precision: 0.84, sensitivity: 0.83, f1: 0.83
- 
对于高斯朴素贝叶斯,我们只有一个超参数需要担心调整。我们可以通过 var_smoothing超参数确定使用多少平滑度。我们可以进行随机网格搜索以找出最佳值。
var_smoothing超参数决定了添加到方差中的量,这将导致模型对接近平均值实例的依赖性降低:
nb_params = {
    'gaussiannb__var_smoothing': np.logspace(0,-9, num=100)
}
rs = RandomizedSearchCV(pipe1, nb_params, cv=kf, \
  scoring='accuracy')
rs.fit(X_train, y_train.values.ravel())
- 
我们得到了更好的准确性: rs.best_params_ {'gaussiannb__var_smoothing': 0.657933224657568} rs.best_score_ 0.8608648056923919
- 
我们还应该查看不同迭代的结果。正如我们所见,较大的平滑值表现更好: results = \ pd.DataFrame(rs.cv_results_['mean_test_score'], \ columns=['meanscore']).\ join(pd.DataFrame(rs.cv_results_['params'])).\ sort_values(['meanscore'], ascending=False) results meanscore gaussiannb__var_smoothing 2 0.86086 0.65793 1 0.85118 0.03511 9 0.81341 0.00152 5 0.81212 0.00043 7 0.81180 0.00019 8 0.81169 0.00002 3 0.81152 0.00000 6 0.81152 0.00000 0 0.81149 0.00000 4 0.81149 0.00000
- 
我们还可以查看每次迭代的平均拟合和评分时间: print("fit time: %.3f, score time: %.3f" % (np.mean(rs.cv_results_['mean_fit_time']),\ np.mean(rs.cv_results_['mean_score_time']))) fit time: 0.660, score time: 0.029
- 
让我们看看最佳模型的预测结果。除了提高准确性外,敏感性也有所提高,从 0.83提升到0.92:pred = rs.predict(X_test) print("accuracy: %.2f, sensitivity: %.2f, \ specificity: %.2f, precision: %.2f" % (skmet.accuracy_score(y_test.values.ravel(), pred), skmet.recall_score(y_test.values.ravel(), pred), skmet.recall_score(y_test.values.ravel(), pred, \ pos_label=0), skmet.precision_score(y_test.values.ravel(), pred))) accuracy: 0.86, sensitivity: 0.92, specificity: 0.79, precision: 0.83
- 
同时查看一个混淆矩阵来更好地了解模型的表现也是一个好主意: cm = skmet.confusion_matrix(y_test, pred) cmplot = skmet.ConfusionMatrixDisplay( confusion_matrix=cm, display_labels=['Loss', 'Won']) cmplot.plot() cmplot.ax_.set(title='Home Team Win Confusion Matrix', xlabel='Predicted Value', ylabel='Actual Value')
这产生了以下图表:

图 14.1 – 基于高斯朴素贝叶斯模型的主队获胜混淆矩阵
虽然这还不错,但仍然不如我们之前章节中的支持向量模型好。特别是,我们希望在预测损失方面做得更好。这也在我们之前步骤中看到的相对较低的0.79特异性得分中得到了反映。记住,特异性是我们正确预测实际负值的负值比率。
另一方面,拟合和评分运行得相当快。我们也不需要做太多的超参数调整。朴素贝叶斯在建模二元或多类目标时通常是一个好的起点。
朴素贝叶斯已经成为文本分类中更受欢迎的选项。我们将在下一节中使用它。
文本分类的朴素贝叶斯
也许令人惊讶的是,一个基于计算条件概率的算法对文本分类是有用的。但这与一个关键简化假设相当直接。让我们假设我们的文档可以通过文档中每个单词的计数很好地表示,不考虑单词顺序或语法。这被称为词袋。词袋与分类目标之间的关系——比如说,垃圾邮件/非垃圾邮件或正面/负面——可以用多项式朴素贝叶斯成功建模。
在本节中,我们将使用短信数据。我们将使用的数据集包含垃圾邮件和非垃圾邮件的标签。
注意
该短信数据集可以通过www.kaggle.com/datasets/team-ai/spam-text-message-classification供公众下载。它包含两列:短信文本和垃圾邮件或非垃圾邮件(ham)标签。
让我们用朴素贝叶斯进行一些文本分类:
- 
我们将需要几个我们在这本书中尚未使用的模块。我们将导入 MultinomialNB,这是我们构建多项式朴素贝叶斯模型所需的。我们还需要CountVectorizer来创建词袋。我们将导入SMOTE模块来处理类别不平衡。请注意,我们将使用一个imbalanced-learn管道而不是一个scikit-learn管道。这是因为我们将在我们的管道中使用SMOTE:import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from imblearn.pipeline import make_pipeline from imblearn.over_sampling import SMOTE from sklearn.naive_bayes import MultinomialNB from sklearn.feature_extraction.text import CountVectorizer import sklearn.metrics as skmet注意 在本节中,我们使用 SMOTE进行过采样;也就是说,我们将复制代表性不足的类别的实例。当我们担心我们的模型在捕捉一个类别的变化方面做得不好,因为我们相对于一个或多个其他类别的实例太少时,过采样可以是一个好的选择。过采样会复制该类别的实例。
- 
接下来,我们将加载短信数据集。我们将把我们的目标转换为整数变量,并确认它按预期工作。注意显著的类别不平衡。让我们查看前几行,以更好地了解数据: spamtext = pd.read_csv("data/spamtext.csv") spamtext['spam'] = np.where(spamtext.category=='spam',1,0) spamtext.groupby(['spam','category']).size() spam category 0 ham 4824 1 spam 747 dtype: int64 spamtext.head() category message spam 0 ham Go until jurong point, crazy.. 0 1 ham Ok lar... Joking wif u oni... 0 2 spam Free entry in 2 a wkly comp to win... 1 3 ham U dun say so early hor... U c already..0 4 ham Nah I don't think he goes to usf, .. 0
- 
现在,我们创建训练和测试数据框。我们将使用 stratify参数来确保训练和测试数据中目标值的分布相等。
我们还将实例化一个CountVectorizer对象来创建我们后面的词袋。我们指出我们想要忽略一些单词,因为它们不提供有用的信息。我们本可以创建一个停用词列表,但在这里,我们将利用 scikit-learn 的英文停用词列表:
X_train, X_test, y_train, y_test =  \
  train_test_split(spamtext[['message']],\
  spamtext[['spam']], test_size=0.2,\
  stratify=spamtext[['spam']], random_state=0)
countvectorizer = CountVectorizer(analyzer='word', \
  stop_words='english')
- 让我们看看向量器是如何与我们的数据中的几个观察结果一起工作的。为了便于查看,我们只会从包含少于 50 个字符的消息中提取信息。
使用向量器,我们为每个观察结果中使用的所有非停用词获取计数。例如,like在第一条消息中使用了一次,而在第二条消息中一次也没有使用。这给like在转换数据中的第一个观察结果赋予了一个值为1,而在第二个观察结果中赋予了一个值为0。
我们不会在我们的模型中使用这一步骤中的任何内容。我们这样做只是为了说明目的:
smallsample = \
  X_train.loc[X_train.message.str.len()<50].\
    sample(2, random_state=35)
smallsample
                                        message
2079                I can take you at like noon
5393  I dont know exactly could you ask chechi.
ourvec = \
    pd.DataFrame(countvectorizer.\
    fit_transform(smallsample.values.ravel()).\
    toarray(),\
    columns=countvectorizer.get_feature_names())
ourvec
    ask   chechi  dont   exactly  know  like  noon
0    0    0       0      0        0     1     1
1    1    1       1      1        1     0     0
- 
现在,让我们实例化一个 MultinomialNB对象并将其添加到管道中。我们将使用SMOTE进行过采样以处理类别不平衡:nb = MultinomialNB() smote = SMOTE(random_state=0) pipe1 = make_pipeline(countvectorizer, smote, nb) pipe1.fit(X_train.values.ravel(), y_train.values.ravel())
- 
现在,让我们看看一些预测结果。我们得到了令人印象深刻的0.97准确率和同样好的特异性。这种出色的特异性表明我们没有许多误报。相对较低的反应性表明我们没有捕捉到一些正例(垃圾邮件),尽管我们仍然做得相当不错: pred = pipe1.predict(X_test.values.ravel()) print("accuracy: %.2f, sensitivity: %.2f, specificity: %.2f, precision: %.2f" % (skmet.accuracy_score(y_test.values.ravel(), pred), skmet.recall_score(y_test.values.ravel(), pred), skmet.recall_score(y_test.values.ravel(), pred, pos_label=0), skmet.precision_score(y_test.values.ravel(), pred))) accuracy: 0.97, sensitivity: 0.87, specificity: 0.98, precision: 0.87
- 
使用混淆矩阵可视化我们模型的表现是有帮助的: cm = skmet.confusion_matrix(y_test, pred) cmplot = skmet.ConfusionMatrixDisplay( confusion_matrix=cm, \ display_labels=['Not Spam', 'Spam']) cmplot.plot() cmplot.ax_.set( title='Spam Prediction Confusion Matrix', xlabel='Predicted Value', ylabel='Actual Value')
这会产生以下图表:

图 14.2 – 使用多项式朴素贝叶斯进行垃圾邮件预测
朴素贝叶斯在构建文本分类模型时可以产生很好的结果。指标通常非常好且非常高效。这是一个非常直接的二元分类问题。然而,朴素贝叶斯也可以在多类文本分类问题中有效。该算法可以以与我们在这里使用多类目标相同的方式应用。
摘要
朴素贝叶斯是一个很好的算法,可以添加到我们的常规工具包中,用于解决分类问题。它并不总是产生最少偏差的预测方法。然而,另一方面也是真的。过拟合的风险较小,尤其是在处理连续特征时。它也非常高效,能够很好地扩展到大量观察和大量特征空间。
本书接下来的两章将探讨无监督学习算法——那些我们没有预测目标的情况。在下一章中,我们将研究主成分分析,然后在下一章中研究 K-means 聚类。
第五部分 – 使用无监督学习进行聚类和降维
本书的后两章探讨了无监督学习模型。这些模型中没有预测的目标。即使没有目标,我们也可以从数据中获得许多见解。使用主成分分析(PCA)进行降维可以让我们用比原始特征数量更少的组件来捕捉特征的变化。
使用主成分分析(PCA)创建的组件可以用于可视化,或者用于识别那些虽然重要但无法被每个特征很好地捕捉到的过程。当我们需要在监督学习模型中减少特征空间时,也可以使用 PCA。我们将在下一章中演示如何创建和评估 PCA。
聚类帮助我们根据实例之间的相似性将实例分组,这些实例与其他任何组中的实例相比有更多的共同点。这通常揭示了其他情况下不明显的关系。在本章中,我们将探讨两种流行的聚类算法,K-means 和 DBSCAN。当我们能够找到适合我们模型的正确超参数值时,聚类效果会很好——对于 k-means 是簇的数量(k),对于 DBSCAN 是 epsilon 的值,它决定了簇中核心实例周围半径的大小。我们将在本书的最后一章中讨论如何为这些聚类算法选择最佳的超参数值。
本节包括以下章节:
- 
第十五章,主成分分析 
- 
第十六章,K-Means 和 DBSCAN 聚类 
第十五章:第十五章: 主成分分析
维度降低是机器学习中的重要概念/策略之一。有时它与特征选择等同,但这是对维度降低过于狭隘的看法。我们的模型通常必须处理过多的特征,其中一些特征正在捕获相同的信息。不解决这一问题会大大增加过拟合或不稳定结果的风险。但放弃我们的一些特征并不是我们工具箱中唯一的工具。特征提取策略,如主成分分析(PCA),通常可以产生良好的结果。
我们可以使用 PCA 在不损失显著预测能力的情况下降低数据集的维度(特征数量)。通常,捕捉数据中大部分方差所需的主成分数量小于特征数量,通常要少得多。
这些成分可以用在我们的回归或分类模型中,而不是初始特征。这不仅能够加快我们的模型学习速度,还可能降低我们估计的方差。这种特征提取策略的关键缺点是,新特征通常更难以解释。当我们有分类特征时,PCA 也不是一个好的选择。
我们将通过首先检查每个成分是如何构建的来发展我们对 PCA 工作原理的理解。我们将构建一个 PCA,解释结果,然后在一个分类模型中使用这些结果。最后,当我们的成分可能不是线性可分时,我们将使用核来改进 PCA。
具体来说,在本章中我们将探讨以下主题:
- 
PCA 的关键概念 
- 
使用 PCA 进行特征提取 
- 
使用 PCA 的核 
技术要求
在本章中,我们将主要使用 pandas、NumPy 和 scikit-learn 库。所有代码都使用 scikit-learn 版本 0.24.2 和 1.0.2 进行了测试。
本章的代码可以从 GitHub 仓库下载:github.com/PacktPublishing/Data-Cleaning-and-Exploration-with-Machine-Learning。
PCA 的关键概念
PCA 产生多个特征组合,每个组合都是一个成分。它识别出一个成分,该成分捕捉到最大的方差量,然后是一个第二个成分,该成分捕捉到剩余的最大方差量,接着是第三个成分,以此类推,直到达到我们指定的停止点。这个停止点可以是基于成分的数量、解释的变异百分比或领域知识。
主成分的一个非常有用的特性是它们是相互正交的。这意味着它们是不相关的,这对建模来说是个好消息。图 15.1展示了由特征 x1 和 x2 构建的两个成分。最大方差由PC1捕获,剩余的最大方差由PC2捕获。(图中的数据点是虚构的。)请注意,这两个向量是正交的(垂直的)。

图 15.1 – 具有两个特征的 PCA 示意图
那些做过大量因子分析的人可能已经得到了一个大致的概念,即使这是你们第一次探索 PCA。主成分与因子并没有很大差别,尽管它们在概念和数学上存在一些差异。在 PCA 中,分析的是所有的方差。而因子分析只分析变量之间的共享方差。在因子分析中,未观察到的因子被认为是导致了观察到的变量。在 PCA 中不需要对潜在的、未观察到的力量做出任何假设。
那么,这个计算上的魔法是如何实现的呢?主成分可以通过以下步骤计算得出:
- 
标准化你的数据。 
- 
计算你的变量的协方差矩阵。 
- 
计算协方差矩阵的特征向量和特征值。 
- 
按特征值降序排列特征向量。第一个特征向量是主成分 1,第二个是主成分 2,依此类推。 
完全理解这些步骤并不是理解本章其余部分讨论的必要条件。我们将让 scikit-learn 为我们完成这项工作。尽管如此,如果你计算了一个非常小的数据子集(两三列和几行)的协方差矩阵,然后计算该矩阵的特征分解,这可能会提高你的直觉。一个相对简单的方法来实验构建成分,同时仍然具有说明性,是使用 NumPy 线性代数函数(numpy.linalg)。这里的关键点是推导主成分的计算有多么简单。
PCA 被用于许多机器学习任务。它可以用于调整图像大小,分析金融数据,或用于推荐系统。本质上,它可能是一个在有许多特征且许多特征相关的情况下适用的好选择。
你们中的一些人无疑已经注意到,我在没有特别指出的情况下,提到了 PCA 构建特征线性组合。当线性可分性不可行时,比如我们在支持向量机中遇到的情况,我们该怎么办?好吧,结果是我们依赖的支持向量机的核技巧也适用于 PCA。在本章中,我们将探讨如何实现核 PCA。然而,我们将从一个相对简单的 PCA 示例开始。
使用 PCA 进行特征提取
PCA 可用于在准备随后运行的模型之前进行降维。尽管 PCA 严格来说不是一个特征选择工具,但我们可以以与我们在第五章中运行的包装特征选择方法相同的方式运行它,即特征选择。经过一些预处理(如处理异常值)后,我们生成组件,然后我们可以将它们用作我们的新特征。有时我们实际上并不在模型中使用这些组件。相反,我们主要生成它们来帮助我们更好地可视化数据。
为了说明 PCA 的使用,我们将与国家篮球协会(NBA)比赛的有关数据一起工作。该数据集包含了从 2017/2018 赛季到 2020/2021 赛季每场 NBA 比赛的统计数据。这包括主队;主队是否获胜;客队;客队和主队的投篮命中率;两队的失误、篮板和助攻;以及其他一些指标。
注意
NBA 比赛数据可在www.kaggle.com/datasets/wyattowalsh/basketball供公众下载。该数据集从 1946/1947 赛季的 NBA 赛季开始。它使用nba_api从 nba.com 获取统计数据。该 API 可在github.com/swar/nba_api找到。
让我们在模型中使用 PCA:
- 
我们首先加载所需的库。您在之前的章节中已经看到了所有这些库,除了 scikit-learn 的 PCA模块:import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline from sklearn.impute import SimpleImputer from sklearn.linear_model import LogisticRegression from sklearn.decomposition import PCA from sklearn.model_selection import RandomizedSearchCV from scipy.stats import uniform from scipy.stats import randint import os import sys sys.path.append(os.getcwd() + “/helperfunctions”) from preprocfunc import OutlierTrans
- 
接下来,我们加载 NBA 数据并进行一些清理。有几个实例没有主队是否获胜的值, WL_HOME,所以我们删除了它们。WL_HOME将是我们的目标。我们将在构建我们的组件之后尝试对其进行建模。请注意,主队大多数时候都会获胜,但类别不平衡并不严重:nbagames = pd.read_csv(“data/nbagames2017plus.csv”, parse_dates=[‘GAME_DATE’]) nbagames = nbagames.loc[nbagames.WL_HOME.isin([‘W’,’L’])] nbagames.shape (4568, 149) nbagames[‘WL_HOME’] = \ np.where(nbagames.WL_HOME==’L’,0,1).astype(‘int’) nbagames.WL_HOME.value_counts(dropna=False) 1 2586 0 1982 Name: WL_HOME, dtype: int64
- 
我们应该查看一些描述性统计: num_cols = [‘FG_PCT_HOME’,’FTA_HOME’,’FG3_PCT_HOME’, ‘FTM_HOME’,’FT_PCT_HOME’,’OREB_HOME’,’DREB_HOME’, ‘REB_HOME’,’AST_HOME’,’STL_HOME’,’BLK_HOME’, ‘TOV_HOME’, ‘FG_PCT_AWAY’,’FTA_AWAY’,’FG3_PCT_AWAY’, ‘FT_PCT_AWAY’,’OREB_AWAY’,’DREB_AWAY’,’REB_AWAY’, ‘AST_AWAY’,’STL_AWAY’,’BLK_AWAY’,’TOV_AWAY’] nbagames[[‘WL_HOME’] + num_cols].agg([‘count’,’min’,’median’,’max’]).T
这产生了以下输出。没有缺失值,但我们的特征范围差异很大。我们需要进行一些缩放:
                count       min     median    max
WL_HOME         4,568.00    0.00    1.00      1.00
FG_PCT_HOME     4,568.00    0.27    0.47      0.65
FTA_HOME        4,568.00    1.00    22.00     64.00
FG3_PCT_HOME    4,568.00    0.06    0.36      0.84
FTM_HOME        4,568.00    1.00    17.00     44.00
FT_PCT_HOME     4,568.00    0.14    0.78      1.00
OREB_HOME       4,568.00    1.00    10.00     25.00
DREB_HOME       4,568.00    18.00   35.00     55.00
REB_HOME        4,568.00    22.00   45.00     70.00
AST_HOME        4,568.00    10.00   24.00     50.00
STL_HOME        4,568.00    0.00    7.00      22.00
BLK_HOME        4,568.00    0.00    5.00      20.00
TOV_HOME        4,568.00    1.00    14.00     29.00
FG_PCT_AWAY     4,568.00    0.28    0.46      0.67
FTA_AWAY        4,568.00    3.00    22.00     54.00
FG3_PCT_AWAY    4,568.00    0.08    0.36      0.78
FT_PCT_AWAY     4,568.00    0.26    0.78      1.00
OREB_AWAY       4,568.00    0.00    10.00     26.00
DREB_AWAY       4,568.00    18.00   34.00     56.00
REB_AWAY        4,568.00    22.00   44.00     71.00
AST_AWAY        4,568.00    9.00    24.00     46.00
STL_AWAY        4,568.00    0.00    8.00      19.00
BLK_AWAY        4,568.00    0.00    5.00      15.00
TOV_AWAY        4,568.00    3.00    14.00     30.00
- 
让我们也检查我们的特征是如何相关的: corrmatrix = nbagames[[‘WL_HOME’] + num_cols].\ corr(method=”pearson”) sns.heatmap(corrmatrix, xticklabels=corrmatrix.columns, yticklabels=corrmatrix.columns, cmap=”coolwarm”) plt.title(‘Heat Map of Correlation Matrix’) plt.tight_layout() plt.show()
这产生了以下图表:

图 15.2 – NBA 特征的散点图
许多特征与正或负相关显著。例如,主队的投篮命中率(射门)(FG_PCT_HOME)和主队的 3 分投篮命中率(FG3_PCT_HOME)正相关,这并不令人惊讶。此外,主队的篮板(REB_HOME)和防守篮板(DREB_HOME)可能过于紧密地相关,以至于任何模型都无法分离它们的影响。
这个数据集可能是 PCA 的良好候选者。尽管一些特征高度相关,但我们仍然会通过删除一些特征而丢失信息。PCA 至少提供了处理相关性而不丢失这些信息的机会。
- 
现在我们创建训练和测试数据框: X_train, X_test, y_train, y_test = \ train_test_split(nbagames[num_cols],\ nbagames[[‘WL_HOME’]],test_size=0.2, random_state=0)
- 
现在,我们准备创建这些成分。我们有些任意地指出我们想要七个成分。(稍后,我们将使用超参数调整来选择成分的数量。)我们在运行 PCA 之前设置我们的管道进行一些预处理: pca = PCA(n_components=7) pipe1 = make_pipeline(OutlierTrans(2), SimpleImputer(strategy=”median”), StandardScaler(), pca) pipe1.fit(X_train)
- 
现在,我们可以使用 pca对象的components_属性。这返回了所有 23 个特征在每个七个成分上的得分:components = pd.DataFrame(pipe1[‘pca’].components_, columns=num_cols) components.T.to_excel(‘views/components.xlsx’)
这生成了以下电子表格:

图 15.3 – NBA 特征的成分图
每个特征在每个成分中解释了部分方差。(如果对每个成分,你对 23 个得分中的每一个进行平方然后求和,你得到总和为 1。)如果你想了解哪些特征真正驱动了成分,寻找那些具有最大绝对值的特征。对于成分 1,主队的投篮命中率(FG_PCT_HOME)是最重要的,其次是客队的篮板球数(REB_AWAY)。
回想一下本章开头我们讨论的内容,每个成分试图捕捉在之前成分或成分之后剩余的方差。
- 
让我们展示前三个成分最重要的五个特征。第一个成分似乎主要关于主队的投篮命中率以及每个队的篮板球。第二个成分看起来并没有太大区别,但第三个成分是由主队做出的投篮和尝试( FTM_HOME和FTA_HOME)以及失误(TOV_HOME和TOV_AWAY)驱动的:components.pc1.abs().nlargest(5) FG_PCT_HOME 0.38 REB_AWAY 0.37 DREB_AWAY 0.34 REB_HOME 0.33 FG_PCT_AWAY 0.30 Name: pc1, dtype: float64 components.pc2.abs().nlargest(5) DREB_HOME 0.38 FG_PCT_AWAY 0.37 DREB_AWAY 0.32 REB_HOME 0.32 FG_PCT_HOME 0.29 Name: pc2, dtype: float64 components.pc3.abs().nlargest(5) FTM_HOME 0.55 FTA_HOME 0.53 TOV_HOME 0.30 STL_AWAY 0.27 TOV_AWAY 0.26 Name: pc3, dtype: float64
- 
我们可以使用 pca对象的explained_variance_ratio_属性来检查每个成分捕获了多少方差。第一个成分解释了特征方差的 14.5%。第二个成分解释了另一个 13.4%。如果我们使用 NumPy 的cumsum方法,我们可以看到七个成分总共解释了约 65%的方差。
因此,仍然存在相当多的方差。我们可能想要在构建任何模型时使用更多的成分:
np.set_printoptions(precision=3)
pipe1[‘pca’].explained_variance_ratio_
array([0.145, 0.134, 0.095, 0.086, 0.079, 0.059, 0.054])
np.cumsum(pipe1[‘pca’].explained_variance_ratio_)
array([0.145, 0.279, 0.374, 0.46 , 0.539, 0.598, 0.652])
- 我们可以将前两个主成分绘制出来,看看它们能多好地分离主队的胜负。我们可以使用管道的transform方法创建一个包含主成分的数据框,并将其与目标数据框连接起来。
我们使用 Seaborn 的scatterplot的便捷hue属性来显示胜负情况。前两个主成分在仅占特征总方差约 28%的情况下,还算不错地分离了胜负:
X_train_pca = pd.DataFrame(pipe1.transform(X_train),
  columns=components.columns, index=X_train.index).join(y_train)
sns.scatterplot(x=X_train_pca.pc1, y=X_train_pca.pc2, hue=X_train_pca.WL_HOME)
plt.title(“Scatterplot of First and Second Components”)
plt.xlabel(“Principal Component 1”)
plt.ylabel(“Principal Component 2”)
plt.show()
这生成了以下图表:

图 15.4 – 第一和第二个主成分的胜负散点图
- 
让我们使用主成分来预测主队是否会获胜。我们只需在我们的管道中添加一个逻辑回归即可。我们还进行网格搜索以找到最佳超参数值: lr = LogisticRegression() pipe2 = make_pipeline(OutlierTrans(2), SimpleImputer(strategy=”median”), StandardScaler(), pca, lr) lr_params = { “pca__n_components”: randint(3, 20), “logisticregression__C”: uniform(loc=0, scale=10) } rs = RandomizedSearchCV(pipe2, lr_params, cv=4, n_iter=40, scoring=’accuracy’, random_state=1) rs.fit(X_train, y_train.values.ravel())
- 
现在,我们可以查看最佳参数和得分。正如我们之前一步所怀疑的那样,网格搜索表明我们的逻辑回归模型在更多组件的情况下表现更好。我们得到了一个非常高的分数。 
我们在第十章**中详细讨论了超参数C*,即逻辑回归:
rs.best_params_
{‘logisticregression__C’: 6.865009276815837, ‘pca__n_components’: 19}
rs.best_score_
0.9258345296842831
本节展示了如何从我们的数据集中生成主成分以及如何解释这些成分。我们还探讨了如何在模型中使用主成分而不是初始特征。但我们假设主成分可以很好地描述为特征的线性组合。这通常并不是情况。在下一节中,我们将使用核 PCA 来处理非线性关系。
使用 PCA 核
对于某些数据,无法构建出线性可分的主成分。在建模之前,这可能实际上不容易可视化。幸运的是,我们有工具可以使用来确定将产生最佳结果的核,包括线性核。使用线性核的核 PCA 应该与标准 PCA 的表现相似。
在本节中,我们将使用核 PCA 对劳动力参与率、教育成就、青少年出生频率以及国家层面的性别政治参与数据等特征进行特征提取。
注意
该性别在教育成果和劳动力成果方面的差异数据集由联合国开发计划署在www.kaggle.com/datasets/undp/human-development提供,供公众使用。每个国家有一个记录,包括 2015 年按性别汇总的就业、收入和教育数据。
让我们开始构建模型:
- 
我们将导入我们一直在使用的相同库以及 scikit-learn 的 KernelPCA模块。我们还将导入RandomForestRegressor模块:import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import MinMaxScaler from sklearn.pipeline import make_pipeline from sklearn.impute import SimpleImputer from sklearn.decomposition import KernelPCA from sklearn.ensemble import RandomForestRegressor from sklearn.model_selection import RandomizedSearchCV import seaborn as sns import matplotlib.pyplot as plt import os import sys sys.path.append(os.getcwd() + “/helperfunctions”) from preprocfunc import OutlierTrans
- 
我们根据性别加载教育和劳动力成果数据。我们构建了女性与男性收入比、教育年限比、劳动力参与比和人类发展指数比的时间序列: un_income_gap = pd.read_csv(“data/un_income_gap.csv”) un_income_gap.set_index(‘country’, inplace=True) un_income_gap[‘incomeratio’] = \ un_income_gap.femaleincomepercapita / \ un_income_gap.maleincomepercapita un_income_gap[‘educratio’] = \ un_income_gap.femaleyearseducation / \ un_income_gap.maleyearseducation un_income_gap[‘laborforcepartratio’] = \ un_income_gap.femalelaborforceparticipation / \ un_income_gap.malelaborforceparticipation un_income_gap[‘humandevratio’] = \ un_income_gap.femalehumandevelopment / \ un_income_gap.malehumandevelopment un_income_gap.dropna(subset=[‘incomeratio’], inplace=True)
- 
让我们查看一些描述性统计。有一些缺失值,尤其是对于 genderinequality和humandevratio。一些特征的范围比其他特征大得多:num_cols = [‘educratio’,’laborforcepartratio’, ‘humandevratio’,’genderinequality’, ‘maternalmortality’,’adolescentbirthrate’, ‘femaleperparliament’,’incomepercapita’] gap_sub = un_income_gap[[‘incomeratio’] + num_cols] gap_sub.\ agg([‘count’,’min’,’median’,’max’]).T count min median max incomeratio 177.00 0.16 0.60 0.93 educratio 169.00 0.24 0.93 1.35 laborforcepartratio 177.00 0.19 0.75 1.04 humandevratio 161.00 0.60 0.95 1.03 genderinequality 155.00 0.02 0.39 0.74 maternalmortality 174.00 1.00 60.00 1,100.00 adolescentbirthrate 177.00 0.60 40.90 204.80 femaleperparliament 174.00 0.00 19.35 57.50 incomepercapita 177.00 581.00 10,512.00 123,124.00
- 
我们还应该查看一些相关性: corrmatrix = gap_sub.corr(method=”pearson”) sns.heatmap(corrmatrix, xticklabels=corrmatrix.columns, yticklabels=corrmatrix.columns, cmap=”coolwarm”) plt.title(‘Heat Map of Correlation Matrix’) plt.tight_layout() plt.show()
这产生了以下图表:

图 15.5 – NBA 比赛数据的相关矩阵
humandevratio和educratio高度相关,同样genderinequality和adolescentbirthrate也高度相关。我们可以看到educratio和maternalmortality高度负相关。考虑到这些特征的高度相关性,构建一个表现良好的模型可能会有困难。然而,我们可能能够通过核 PCA 来降低维度。
- 
我们创建了训练和测试 DataFrame: X_train, X_test, y_train, y_test = \ train_test_split(gap_sub[num_cols],\ gap_sub[[‘incomeratio’]], test_size=0.2, random_state=0)
- 
现在,我们已经准备好实例化 KernelPCA和RandomForestRegressor对象。我们将它们都添加到管道中。我们还创建了一个包含核 PCA 和随机森林回归器的超参数的字典。
字典为成分数量、gamma 以及与核 PCA 一起使用的核提供了一系列超参数值。对于不使用 gamma 的核,这些值被忽略。请注意,核的一个选项是线性核。
我们在第八章**,支持向量回归和第十三章**,支持向量机分类中更详细地讨论了 gamma:
rfreg = RandomForestRegressor()
kpca = KernelPCA()
pipe1 = make_pipeline(OutlierTrans(2),
  SimpleImputer(strategy=”median”), MinMaxScaler(),
  kpca, rfreg)
rfreg_params = {
 ‘kernelpca__n_components’:
    randint(2, 9),
 ‘kernelpca__gamma’:
     np.linspace(0.03, 0.3, 10),
 ‘kernelpca__kernel’:
     [‘linear’, ‘poly’, ‘rbf’, 
      ‘sigmoid’, ‘cosine’],
 ‘randomforestregressor__max_depth’:
     randint(2, 20),
 ‘randomforestregressor__min_samples_leaf’:
     randint(5, 11)
}
- 
现在,让我们使用这些超参数值进行随机网格搜索。对于随机森林回归器,给我们带来最佳性能的 PCA 核是多项式。我们得到了一个很好的均方误差平方,大约是均方误差的 10%大小: rs = RandomizedSearchCV(pipe1, rfreg_params, cv=4, n_iter=40, scoring=’neg_mean_absolute_error’, random_state=1) rs.fit(X_train, y_train.values.ravel()) rs.best_params_ {‘kernelpca__gamma’: 0.12000000000000001, ‘kernelpca__kernel’: ‘poly’, ‘kernelpca__n_components’: 4, ‘randomforestregressor__max_depth’: 18, ‘randomforestregressor__min_samples_leaf’: 5} rs.best_score_ -0.06630618838886537
- 
让我们来看看其他表现最好的模型。一个具有 rbf核和一个具有 sigmoid 核的模型几乎表现相同。表现第二和第三好的模型比表现最好的模型有更多的主成分:results = \ pd.DataFrame(rs.cv_results_[‘mean_test_score’], \ columns=[‘meanscore’]).\ join(pd.DataFrame(rs.cv_results_[‘params’])).\ sort_values([‘meanscore’], ascending=False) results.iloc[1:3].T 39 0 meanscore -0.067 -0.070 kernelpca__gamma 0.240 0.180 kernelpca__kernel rbf sigmoid kernelpca__n_components 6 6 randomforestregressor__max_depth 12 10 randomforestregressor__min_samples_leaf 5 6
核 PCA 是一种相对容易实现的降维选项。当我们有许多高度相关的特征,而这些特征可能不是线性可分的时候,它最为有用,而且预测的解释并不重要。
摘要
本章探讨了主成分分析,包括其工作原理以及我们可能想要使用它的时机。我们学习了如何检查 PCA 创建的成分,包括每个特征对每个成分的贡献以及解释了多少方差。我们还讨论了如何可视化成分以及如何在后续分析中使用成分。此外,我们还考察了如何使用核 PCA 以及何时这可能会给我们带来更好的结果。
在下一章中,我们将探讨另一种无监督学习技术,即 k-means 聚类。
第十六章:第十六章:K-Means 和 DBSCAN 聚类
数据聚类使我们能够将未标记的数据组织成具有更多共同点的观察组,这些共同点比组外的观察点更多。聚类有许多令人惊讶的应用,无论是作为机器学习管道的最终模型,还是作为其他模型的输入。这包括市场研究、图像处理和文档分类。我们有时也使用聚类来改进探索性数据分析或创建更有意义的可视化。
K-means 和基于密度的应用噪声聚类(DBSCAN),就像主成分分析(PCA)一样,是无监督学习算法。没有标签可以用作预测的基础。算法的目的是根据特征识别出相互关联的实例。彼此靠近且与其他实例距离较远的实例可以被认为是处于一个簇中。有几种方法可以衡量邻近程度。基于划分的聚类,如 k-means,和基于密度的聚类,如 DBSCAN,是两种更受欢迎的方法。我们将在本章中探讨这些方法。
具体来说,我们将讨论以下主题:
- 
k-means 和 DBSCAN 聚类的关键概念 
- 
实现 k-means 聚类 
- 
实现 DBSCAN 聚类 
技术要求
在本章中,我们将主要使用 pandas、NumPy 和 scikit-learn 库。
k-means 和 DBSCAN 聚类的关键概念
在 k-means 聚类中,我们识别k个簇,每个簇都有一个中心,或质心。质心是使它与簇中其他数据点的总平方距离最小的点。
一个使用虚构数据的例子应该能有所帮助。图 16.1中的数据点似乎在三个簇中。(通常并不那么容易可视化簇的数量,k。)

图 16.1 – 具有三个可识别簇的数据点
我们执行以下步骤来构建簇:
- 
将一个随机点分配为每个簇的中心。 
- 
计算每个点到每个簇中心的距离。 
- 
根据数据点到中心点的邻近程度将数据点分配到簇中。这三个步骤在图 16.2中进行了说明。带有X的点是被随机选择的簇中心(将k设置为 3)。比其他簇中心点更接近簇中心点的数据点被分配到该簇。 

图 16.2 – 随机分配为簇中心的点
- 为新簇计算一个新的中心点。这如图 16.3 所示。

图 16.3 – 新的聚类中心计算
- 重复步骤 2 到 4,直到中心的变化不大。
K-means 聚类是一种非常流行的聚类算法,原因有几个。它相当直观,通常也相当快。然而,它确实有一些缺点。它将每个数据点都处理为聚类的一部分,因此聚类可能会被极端值拉扯。它还假设聚类将具有球形形状。
无监督模型的评估不如监督模型清晰,因为我们没有目标来比较我们的预测。聚类模型的一个相当常见的指标是轮廓分数。轮廓分数是所有实例的平均轮廓系数。轮廓系数如下:

这里, 是第 i 个实例到下一个最近簇的所有实例的平均距离,而
是第 i 个实例到下一个最近簇的所有实例的平均距离,而 是到分配簇的实例的平均距离。这个系数的范围从-1 到 1,分数接近 1 意味着实例很好地位于分配的簇内。
是到分配簇的实例的平均距离。这个系数的范围从-1 到 1,分数接近 1 意味着实例很好地位于分配的簇内。
评估我们的聚类的一个另一个指标是惯性分数。这是每个实例与其质心之间平方距离的总和。随着我们增加聚类数量,这个距离会减小,但最终增加聚类数量会带来边际收益的递减。通常使用肘图来可视化 k 值与惯性分数的变化。这个图被称为肘图,因为随着 k 的增加,斜率会接近 0,接近到它类似于一个肘部。这如图图 16.4所示。在这种情况下,我们会选择一个接近肘部的 k 值。

图 16.4 – 惯性和 k 的肘图
评估聚类模型时,经常使用的另一个指标是Rand 指数。Rand 指数告诉我们两个聚类如何频繁地将相同的簇分配给实例。Rand 指数的值将在 0 到 1 之间。我们通常使用调整后的 Rand 指数,它纠正了相似度计算中的偶然性。调整后的 Rand 指数的值有时可能是负数。
DBSCAN采用了一种不同的聚类方法。对于每个实例,它计算该实例指定距离内的实例数量。所有在ɛ距离内的实例都被认为是该实例的ɛ-邻域。当ɛ-邻域中的实例数量等于或超过我们指定的最小样本值时,该实例被认为是核心实例,ɛ-邻域被认为是聚类。任何与另一个实例距离超过ɛ的实例被认为是噪声。这如图图 16.5所示。

图 16.5 – 最小样本数=五的 DBSCAN 聚类
这种基于密度的方法有几个优点。簇不需要是球形的,它们可以采取任何形状。虽然我们不需要猜测簇的数量,但我们需要提供一个ɛ的值。异常值只是被解释为噪声,因此不会影响簇。(这一点暗示了 DBSCAN 的另一个有用应用:识别异常。)
我们将在本章后面使用 DBSCAN 进行聚类。首先,我们将检查如何使用 k-means 进行聚类,包括如何选择一个好的 k 值。
实现 k-means 聚类
我们可以使用与我们在前几章中开发的监督学习模型相同的某些数据来使用 k-means。区别在于我们不再有一个预测的目标。相反,我们感兴趣的是某些实例是如何聚集在一起的。想想典型的中学午餐休息时间人们如何分组,你就能得到一个大致的概念。
我们还需要做很多与监督学习模型相同的预处理工作。我们将在本节开始这部分。我们将处理关于女性和男性之间的收入差距、劳动力参与率、教育成就、青少年出生频率以及女性在最高级别参与政治的数据。
注意
收入差距数据集由联合国开发计划署在www.kaggle.com/datasets/undp/human-development上提供供公众使用。每个国家都有一个记录,包含 2015 年按性别汇总的就业、收入和教育数据。
让我们构建一个 k-means 聚类模型:
- 
我们加载了熟悉的库。我们还加载了 KMeans和silhouette_score模块。回想一下,轮廓分数通常用于评估我们的模型在聚类方面做得有多好。我们还加载了rand_score,这将允许我们计算不同聚类之间的相似性指数:import pandas as pd from sklearn.preprocessing import MinMaxScaler from sklearn.pipeline import make_pipeline from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score from sklearn.metrics.cluster import rand_score from sklearn.impute import KNNImputer import seaborn as sns import matplotlib.pyplot as plt
- 
接下来,我们加载收入差距数据: un_income_gap = pd.read_csv("data/un_income_gap.csv") un_income_gap.set_index('country', inplace=True) un_income_gap['incomeratio'] = \ un_income_gap.femaleincomepercapita / \ un_income_gap.maleincomepercapita un_income_gap['educratio'] = \ un_income_gap.femaleyearseducation / \ un_income_gap.maleyearseducation un_income_gap['laborforcepartratio'] = \ un_income_gap.femalelaborforceparticipation / \ un_income_gap.malelaborforceparticipation un_income_gap['humandevratio'] = \ un_income_gap.femalehumandevelopment / \ un_income_gap.malehumandevelopment
- 
让我们看看一些描述性统计: num_cols = ['educratio','laborforcepartratio','humandevratio', 'genderinequality','maternalmortality','incomeratio', 'adolescentbirthrate', 'femaleperparliament', 'incomepercapita'] gap = un_income_gap[num_cols] gap.agg(['count','min','median','max']).T count min median max educratio 170.00 0.24 0.93 1.35 laborforcepartratio 177.00 0.19 0.75 1.04 humandevratio 161.00 0.60 0.95 1.03 genderinequality 155.00 0.02 0.39 0.74 maternalmortality 178.00 1.00 64.00 1,100.00 incomeratio 177.00 0.16 0.60 0.93 adolescentbirthrate 183.00 0.60 40.90 204.80 femaleperparliament 185.00 0.00 19.60 57.50 incomepercapita 188.00 581.00 10,667.00 23,124.00
- 
我们还应该查看一些相关性。教育比率(女性教育水平与男性教育水平的比率)和人类发展比率高度相关,性别不平等和青少年出生率也是如此,以及收入比率和劳动力参与率: corrmatrix = gap.corr(method="pearson") sns.heatmap(corrmatrix, xticklabels=corrmatrix.columns, yticklabels=corrmatrix.columns, cmap="coolwarm") plt.title('Heat Map of Correlation Matrix') plt.tight_layout() plt.show()
这会产生以下图表:

图 16.6 – 相关矩阵的热图
- 
在运行我们的模型之前,我们需要对数据进行缩放。我们还使用KNN 插补来处理缺失值: pipe1 = make_pipeline(MinMaxScaler(), KNNImputer(n_neighbors=5)) gap_enc = pd.DataFrame(pipe1.fit_transform(gap), columns=num_cols, index=gap.index)
- 
现在,我们已经准备好运行 k-means 聚类。我们为簇的数量指定一个值。 
在拟合模型后,我们可以生成一个影子分数。我们的影子分数并不高。这表明我们的聚类之间并没有很远。稍后,我们将看看是否可以通过更多或更少的聚类来获得更好的分数:
kmeans = KMeans(n_clusters=3, random_state=0)
kmeans.fit(gap_enc)
KMeans(n_clusters=3, random_state=0)
silhouette_score(gap_enc, kmeans.labels_)
0.3311928353317411
- 
让我们更仔细地观察聚类。我们可以使用 labels_属性来获取聚类:gap_enc['cluster'] = kmeans.labels_ gap_enc.cluster.value_counts().sort_index() 0 40 1 100 2 48 Name: cluster, dtype: int64
- 
我们本可以使用 fit_predict方法来获取聚类,如下所示:pred = pd.Series(kmeans.fit_predict(gap_enc)) pred.value_counts().sort_index() 0 40 1 100 2 48 dtype: int64
- 
有助于检查聚类在特征值方面的差异。聚类 0 的国家在孕产妇死亡率和青少年出生率方面比其他聚类的国家要高得多。聚类 1 的国家孕产妇死亡率非常低,人均收入很高。聚类 2 的国家劳动力参与率(女性劳动力参与率与男性劳动力参与率的比率)和收入比率非常低。回想一下,我们已经对数据进行归一化处理: gap_cluster = gap_enc.join(cluster) gap_cluster[['cluster'] + num_cols].groupby(['cluster']).mean().T cluster 0 1 2 educratio 0.36 0.66 0.54 laborforcepartratio 0.80 0.67 0.32 humandevratio 0.62 0.87 0.68 genderinequality 0.79 0.32 0.62 maternalmortality 0.44 0.04 0.11 incomeratio 0.71 0.60 0.29 adolescentbirthrate 0.51 0.15 0.20 femaleperparliament 0.33 0.43 0.24 incomepercapita 0.02 0.19 0.12
- 
我们可以使用 cluster_centers_属性来获取每个聚类的中心。由于我们使用了九个特征进行聚类,因此有九个值代表三个聚类的中心:centers = kmeans.cluster_centers_ centers.shape (3, 9) np.set_printoptions(precision=2) centers array([[0.36, 0.8 , 0.62, 0.79, 0.44, 0.71, 0.51, 0.33, 0.02], [0.66, 0.67, 0.87, 0.32, 0.04, 0.6 , 0.15, 0.43, 0.19], [0.54, 0.32, 0.68, 0.62, 0.11, 0.29, 0.2 , 0.24, 0.12]])
- 
我们通过一些特征绘制聚类,以及中心。我们将该聚类的数字放置在该聚类的质心处: fig = plt.figure() plt.suptitle("Cluster for each Country") ax = plt.axes(projection='3d') ax.set_xlabel("Maternal Mortality") ax.set_ylabel("Adolescent Birth Rate") ax.set_zlabel("Income Ratio") ax.scatter3D(gap_cluster.maternalmortality, gap_cluster.adolescentbirthrate, gap_cluster.incomeratio, c=gap_cluster.cluster, cmap="brg") for j in range(3): ax.text(centers2[j, num_cols.index('maternalmortality')], centers2[j, num_cols.index('adolescentbirthrate')], centers2[j, num_cols.index('incomeratio')], c='black', s=j, fontsize=20, fontweight=800) plt.tight_layout() plt.show()
这产生了以下图形:

图 16.7 – 三个聚类的 3D 散点图
我们可以看到,聚类 0 的国家孕产妇死亡率和青少年出生率较高。聚类 0 国家的收入比率较低。
- 到目前为止,我们假设用于我们模型的最佳聚类数量是三个。让我们构建一个五聚类模型,看看那些结果如何。
影子分数从三聚类模型中下降。这可能表明至少有一些聚类非常接近:
gap_enc = gap_enc[num_cols]
kmeans2 = KMeans(n_clusters=5, random_state=0)
kmeans2.fit(gap_enc)
silhouette_score(gap_enc, kmeans2.labels_)
0.2871811434351394
gap_enc['cluster2'] = kmeans2.labels_
gap_enc.cluster2.value_counts().sort_index()
0    21
1    40
2    48
3    16
4    63
Name: cluster2, dtype: int64
- 
让我们绘制新的聚类,以更好地了解它们的位置: fig = plt.figure() plt.suptitle("Cluster for each Country") ax = plt.axes(projection='3d') ax.set_xlabel("Maternal Mortality") ax.set_ylabel("Adolescent Birth Rate") ax.set_zlabel("Income Ratio") ax.scatter3D(gap_cluster.maternalmortality, gap_cluster.adolescentbirthrate, gap_cluster.incomeratio, c=gap_cluster.cluster2, cmap="brg") for j in range(5): ax.text(centers2[j, num_cols.index('maternalmortality')], centers2[j, num_cols.index('adolescentbirthrate')], centers2[j, num_cols.index('incomeratio')], c='black', s=j, fontsize=20, fontweight=800) plt.tight_layout() plt.show()
这产生了以下图形:

图 16.8 – 五个聚类的 3D 散点图
- 
我们可以使用一个称为 Rand 指数的统计量来衡量聚类之间的相似性: rand_score(kmeans.labels_, kmeans2.labels_) 0.7439412902491751
- 
我们尝试了三聚类和五聚类模型,但那些是否是好的选择?让我们查看一系列k值的分数: gap_enc = gap_enc[num_cols] iner_scores = [] sil_scores = [] for j in range(2,20): kmeans=KMeans(n_clusters=j, random_state=0) kmeans.fit(gap_enc) iner_scores.append(kmeans.inertia_) sil_scores.append(silhouette_score(gap_enc, kmeans.labels_))
- 
让我们绘制惯性分数与肘图: plt.title('Elbow Plot') plt.xlabel('k') plt.ylabel('Inertia') plt.plot(range(2,20),iner_scores)
这产生了以下图形:

图 16.9 – 惯性分数的肘图
- 
我们还创建了一个影子分数的图形: plt.title('Silhouette Score') plt.xlabel('k') plt.ylabel('Silhouette Score') plt.plot(range(2,20),sil_scores)
这产生了以下图形:

图 16.10 – 影子分数图
肘图表明,大约 6 或 7 的k值会是最优的。当k值超过这个值时,惯性开始减少。轮廓分数图表明k值更小,因为在那之后轮廓分数急剧下降。
K-means 聚类帮助我们理解了关于收入、教育和就业方面的男女差距数据。我们现在可以看到某些特征是如何相互关联的,这是之前简单的相关性分析所没有揭示的。然而,这很大程度上假设我们的聚类具有球形形状,我们不得不做一些工作来确认我们的k值是最好的。在 DBSCAN 聚类中,我们不会遇到任何相同的问题,所以我们将尝试在下一节中这样做。
实现 DBSCAN 聚类
DBSCAN 是一种非常灵活的聚类方法。我们只需要指定一个值用于ɛ,也称为eps。正如我们之前讨论的,ɛ值决定了实例周围的ɛ-邻域的大小。最小样本超参数表示围绕一个实例需要多少个实例才能将其视为核心实例。
注意
我们使用 DBSCAN 聚类我们在上一节中使用的相同收入差距数据。
让我们构建一个 DBSCAN 聚类模型:
- 
我们首先加载熟悉的库,以及 DBSCAN模块:import pandas as pd from sklearn.preprocessing import MinMaxScaler from sklearn.pipeline import make_pipeline from sklearn.cluster import DBSCAN from sklearn.impute import KNNImputer from sklearn.metrics import silhouette_score import matplotlib.pyplot as plt import os import sys sys.path.append(os.getcwd() + "/helperfunctions")
- 
我们导入代码来加载和预处理我们在上一节中使用的工资收入数据。由于该代码没有变化,这里不需要重复: import incomegap as ig gap = ig.gap num_cols = ig.num_cols
- 
我们现在准备好预处理数据并拟合一个 DBSCAN 模型。我们在这里选择ɛ值为 0.35 主要是通过试错。我们也可以遍历一系列ɛ值并比较轮廓分数: pipe1 = make_pipeline(MinMaxScaler(), KNNImputer(n_neighbors=5)) gap_enc = pd.DataFrame(pipe1.fit_transform(gap), columns=num_cols, index=gap.index) dbscan = DBSCAN(eps=0.35, min_samples=5) dbscan.fit(gap_enc) silhouette_score(gap_enc, dbscan.labels_) 0.31106297603736455
- 
我们可以使用 labels_属性来查看聚类。我们有 17 个噪声实例,那些聚类为-1 的实例。其余的观测值在一个或两个聚类中:gap_enc['cluster'] = dbscan.labels_ gap_enc.cluster.value_counts().sort_index() -1 17 0 139 1 32 Name: cluster, dtype: int64 gap_enc = \ gap_enc.loc[gap_enc.cluster!=-1]
- 
让我们更仔细地看看哪些特征与每个聚类相关联。聚类 1 的国家在 maternalmortality、adolescentbirthrate和genderinequality方面与聚类 0 的国家非常不同。这些特征在 k-means 聚类中也很重要,但 DBSCAN 中聚类数量更少,绝大多数实例都落入一个聚类中:gap_enc[['cluster'] + num_cols].\ groupby(['cluster']).mean().T cluster 0 1 educratio 0.63 0.35 laborforcepartratio 0.57 0.82 humandevratio 0.82 0.62 genderinequality 0.40 0.79 maternalmortality 0.05 0.45 incomeratio 0.51 0.71 adolescentbirthrate 0.16 0.50 femaleperparliament 0.36 0.30 incomepercapita 0.16 0.02
- 
让我们可视化聚类: fig = plt.figure() plt.suptitle("Cluster for each Country") ax = plt.axes(projection='3d') ax.set_xlabel("Maternal Mortality") ax.set_ylabel("Adolescent Birth Rate") ax.set_zlabel("Gender Inequality") ax.scatter3D(gap_cluster.maternalmortality, gap_cluster.adolescentbirthrate, gap_cluster.genderinequality, c=gap_cluster.cluster, cmap="brg") plt.tight_layout() plt.show()
这产生了以下图表:

图 16.11 – 每个国家的聚类 3D 散点图
DBSCAN 是聚类的一个优秀工具,尤其是当我们的数据特征意味着 k-means 聚类不是一个好选择时;例如,当聚类不是球形时。它还有的优点是不受异常值的影响。
摘要
我们有时需要将具有相似特征的实例组织成组。即使没有预测目标,这也有用。我们可以使用为可视化创建的聚类,就像我们在本章中所做的那样。由于聚类易于解释,我们可以利用它们来假设为什么某些特征会一起移动。我们还可以在后续分析中使用聚类结果。
本章探讨了两种流行的聚类技术:k-means 和 DBSCAN。这两种技术都直观、高效,并且能够可靠地处理聚类。

 
                    
                
 
                
            
         浙公网安备 33010602011771号
浙公网安备 33010602011771号