Python 数据科学项目第二版(二)
原文:
annas-archive.org/md5/c89da1393a37db56d0a53ed5ccaa3f00译者:飞龙
第三章:3. 逻辑回归及特征探索的详细内容
概述
本章将教你如何快速高效地评估特征,以便了解哪些特征可能对机器学习模型最为重要。一旦我们掌握了这一点,我们将深入探讨逻辑回归的内部工作原理,让你能够继续在这一基础技术上迈向精通之路。阅读完本章后,你将能够制作多特征与响应变量的相关性图,并将逻辑回归解读为线性模型。
引言
在上一章中,我们使用 scikit-learn 开发了几个示例机器学习模型,以便熟悉它的工作原理。然而,我们使用的特征EDUCATION和LIMIT_BAL并不是以系统化的方式选择的。
在本章中,我们将开始开发评估特征在建模中有效性的方法。这将使你能够快速浏览所有候选特征,从而大致了解哪些特征可能是最重要的。对于最有潜力的特征,我们将看到如何创建视觉总结,以便作为有用的沟通工具。
接下来,我们将详细研究逻辑回归。我们将了解为什么逻辑回归被认为是一个线性模型,即使其公式中涉及了一些非线性函数。我们将学习什么是决策边界,并看到由于其线性特性,逻辑回归的决策边界可能会使得准确分类响应变量变得困难。在这个过程中,我们将通过使用列表推导和编写函数,更加熟悉 Python。
检查特征与响应变量之间的关系
为了准确预测响应变量,好的特征是必要的。我们需要那些与响应变量有明确联系的特征。到目前为止,我们已经通过计算特征和响应变量的groupby/mean值,或在模型中使用单独的特征并检查性能,来检查几个特征与响应变量之间的关系。然而,我们还没有系统地探讨所有特征与响应变量的关系。现在我们将进行这项工作,并开始利用我们在探索特征和确保数据质量时所付出的努力。
一种快速了解所有特征与响应变量之间关系,以及特征间相互关系的流行方法是使用相关性图。我们将首先为案例研究数据创建一个相关性图,然后讨论如何解读它,并提供一些数学细节。
为了创建相关性图,我们需要的输入包括我们计划探索的所有特征以及响应变量。由于我们将使用 DataFrame 中的大部分列名,获取适当列名列表的快速方法是在 Python 中从所有列名开始,然后移除那些我们不需要的列名。作为初步步骤,我们为本章启动一个新的笔记本,并加载第一章、数据探索与清理中的包和清理后的数据,代码如下:
import numpy as np #numerical computation
import pandas as pd #data wrangling
import matplotlib.pyplot as plt #plotting package
#Next line helps with rendering plots
%matplotlib inline
import matplotlib as mpl #add'l plotting functionality
import seaborn as sns #a fancy plotting package
mpl.rcParams['figure.dpi'] = 400 #high res figures
df = pd.read_csv('../../Data/Chapter_1_cleaned_data.csv')
注
您的清理数据文件的路径可能不同,取决于您在第一章、数据探索与清理中保存的位置。本节中提供的代码和输出也可以在参考笔记本中找到:packt.link/pMvWa。
注意,这个笔记本的开始与上一章的笔记本非常相似,唯一不同的是我们还导入了Seaborn包,它基于Matplotlib提供了许多方便的绘图功能。现在,让我们列出 DataFrame 中的所有列,并查看前五行和后五行:
图 3.1:获取列名列表
回想一下,由于伦理问题,我们不能使用gender变量,并且我们了解到PAY_2、PAY_3、…、PAY_6是错误的,应当忽略。此外,我们不会检查从EDUCATION变量创建的独热编码,因为这些列中的信息已经包含在原始特征中,至少以某种形式存在。我们将直接使用EDUCATION特征。最后,使用ID作为特征没有意义,因为它仅仅是一个唯一的账户标识符,与响应变量无关。我们需要列出那些既不是特征也不是响应变量的列名,并将它们从我们的分析中排除:
items_to_remove = ['ID', 'SEX',\
'PAY_2', 'PAY_3', 'PAY_4', 'PAY_5', 'PAY_6',\
'EDUCATION_CAT',\
'graduate school', 'high school', 'none',\
'others', 'university']
为了拥有一个仅包含我们将使用的特征和响应的列名列表,我们需要从当前的features_response列表中删除items_to_remove中的列名。在 Python 中,有几种方法可以做到这一点。我们将利用这个机会学习一种特定的构建列表的方式,叫做列表推导式。当人们谈论某些构造是Pythonic或符合 Python 语言习惯时,列表推导式通常是其中之一。
什么是列表推导式?从概念上讲,它与for循环基本相同。然而,列表推导式使得可以将原本可能需要多行代码的for循环,用一行代码来实现。由于 Python 内置的优化,列表推导式通常比for循环稍微更快。虽然在这里这可能不会节省我们太多时间,但这是一个很好机会来熟悉它们。以下是一个列表推导式的例子:
图 3.2:列表推导示例
就是这么简单。
我们还可以使用其他子句来使列表推导更加灵活。例如,我们可以用它们重新赋值 features_response 变量,创建一个包含所有不在我们希望删除的字符串列表中的内容的列表:
图 3.3:使用列表推导修剪列名
在列表推导中使用 if 和 not in 是相当直观的。结构如列表推导的易读性是 Python 流行的原因之一。
注意
Python 文档(docs.python.org/3/tutorial/datastructures.html)将列表推导定义为如下内容:
“列表推导由包含一个表达式的括号组成,后面跟着一个 for 子句,然后是零个或多个 for 或 if 子句。”
因此,列表推导可以让你通过更少的代码来完成任务,而且通常非常易读和易理解。
Pearson 相关性
现在我们准备创建我们的相关性图。相关性图的基础是对这些列调用 .corr() 方法。在我们计算这个时,注意到在 pandas 中可用的相关性类型是线性相关性,也称为Pearson 相关性。Pearson 相关性用于衡量两个变量之间线性关系的强度和方向(即,正向或负向):
图 3.4:相关性矩阵的前五行和列
在创建相关性矩阵后,注意到行和列名是相同的。然后,对于所有特征对之间的每一个可能的比较,以及所有特征和响应之间的比较,虽然在这里的前五行和列中我们还看不到响应,但每个比较都有一个数值。这个数值被称为这两列之间的相关性。所有的相关性都在 -1 和 1 之间;一列与自身的相关性为 1(即相关性矩阵的对角线),并且存在重复:每个比较都会出现两次,因为原始 DataFrame 中的每个列名都会同时作为行和列出现在相关性矩阵中。在进一步讨论相关性之前,我们将使用 Seaborn 绘制一个漂亮的图表。以下是绘图代码,后面是输出(如果你是在黑白模式下阅读,请参阅 GitHub 上的笔记本中的彩色图形;在这里这是必要的 - packt.link/pMvWa):
sns.heatmap(corr,
xticklabels=corr.columns.values,
yticklabels=corr.columns.values,
center=0)
你应该看到以下输出:
图 3.5:Seaborn 中相关性图的热力图
Seaborn 的 heatmap 功能能够清晰地可视化相关矩阵,参照图 3.5右侧的颜色标尺,这个功能叫做 sns.heatmap。除了矩阵外,我们还为 x 和 y 轴提供了刻度标签,这些刻度标签分别代表特征和响应名称,并且指出颜色条的中心应该是 0,这样正相关和负相关就能分别以红色和蓝色区分开来。
注意
如果你正在阅读这本书的印刷版,你可以通过访问以下链接下载并浏览本章部分图像的彩色版本:packt.link/veMmT。
这个图表告诉我们什么?从整体来看,如果两个特征或一个特征与响应之间的相关性非常强,那么你可以说它们之间存在很强的关联。与响应变量高度相关的特征将是预测中很好的特征。这个强相关性可以是正相关也可以是负相关;我们稍后会解释两者的区别。
为了查看与响应变量的相关性,我们可以查看底部行,或者等价地,最后一列。在这里我们看到,PAY_1 特征可能是与响应变量最强相关的特征。我们还可以看到许多特征彼此之间高度相关,特别是 BILL_AMT 特征。我们将在下一章讨论彼此相关的特征的重要性;对于某些模型(例如逻辑回归)来说,这一点非常重要,因为这些模型假设特征之间存在相关性。目前,我们可以观察到,PAY_1 很可能是我们模型中最好的、最具预测力的特征。另一个看起来可能重要的特征是 LIMIT_BAL,它与响应变量呈负相关。根据你的观察能力,只有这两个特征在图 3.5的底部行中看起来有颜色(即不同于黑色,表示 0 相关性)。
线性相关性数学
从数学角度来说,什么是线性相关性?如果你学过基础统计学,你可能已经对线性相关性有所了解。线性相关性与线性回归非常相似。对于两列数据,X 和 Y,线性相关性 ρ(希腊字母“rho”)定义如下:
图 3.6:线性相关性方程
这个方程描述了 [-1, 1]。因为皮尔逊相关性已经调整了数据的均值和标准差,所以数据的实际值并不像 X 和 Y 之间的关系那么重要。较强的线性相关性越接近 1 或 -1。如果 X 和 Y 之间没有线性关系,相关性将接近 0。
值得注意的是,尽管数据科学从业者在此背景下经常使用皮尔逊相关性,但对于二元响应变量,它并不严格适用,就像我们在案例研究问题中所遇到的那样。从技术上讲,皮尔逊相关性在其他一些限制条件下,仅适用于连续数据,例如我们在第二章中使用的数据——Scikit-Learn 和模型评估入门。然而,皮尔逊相关性仍然可以快速提供特征潜在有用性的初步了解。它也方便地可以在如 pandas 等软件库中找到。
在数据科学领域,通常会发现某些广泛使用的技术可能被应用于违反其正式统计假设的数据。了解分析方法背后的正式假设是很重要的。事实上,这些假设的知识可能会在数据科学职位的面试中进行考察。然而,在实际操作中,只要某项技术能帮助我们理解问题并找到有效的解决方案,它仍然可以是一个有价值的工具。
话虽如此,线性相关性并不是所有特征预测能力的有效衡量标准。特别是,它只关注线性关系。稍微转移我们的焦点,假设我们正在处理一个回归问题,看看以下例子,并讨论你预期的线性相关性是什么。注意,数据的 x 轴和 y 轴上的值没有标签;这是因为数据的位置(均值)和标准差(尺度)并不影响皮尔逊相关性,只有变量之间的关系会影响相关性,这可以通过将它们一起绘制出来来辨别:
图 3.7:示例变量之间关系的散点图
对于示例 A和B,根据前面给出的公式,这些数据集的实际皮尔逊相关性分别为 0.96 和 -0.97。从图表上看,很明显,接近 1 或 -1 的相关性为我们提供了关于这些变量之间关系的有用见解。对于示例 C,相关性为 0.06。接近 0 的相关性看起来有效地表明这里没有关联:Y 的值似乎与 X 的值没有太大关系。然而,在示例 D中,变量之间显然存在某种关系。但线性相关性实际上比前一个例子低,为 0.02。在这里,X 和 Y 在较小的尺度上“共同变化”,但在计算线性相关性时,这种关系会被所有样本的平均值所抵消。
注意
生成本节及前一节中所示图表的代码可以在此找到:packt.link/XrUJU。
最终,任何你选择的汇总统计量(如相关系数)都只是汇总。它可能会隐藏重要的细节。因此,通常最好通过可视化来检查特征和响应之间的关系。这可能会占用页面上的大量空间,因此我们不会在案例研究中对所有特征进行演示。然而,pandas 和 Seaborn 都提供了创建散点图矩阵的功能。散点图矩阵类似于相关图,但它实际上显示了所有数据,以所有特征和响应变量的散点图网格的形式。这使你能够以简洁的格式直接检查数据。由于这可能包含大量数据和图表,你可能需要对数据进行降采样,并查看较少的特征,以便函数能够高效运行。
F 检验
虽然皮尔逊相关系数在理论上对于连续响应变量是有效的,但案例研究数据中的二元响应变量可以视为分类数据,只有两个类别:0 和 1。在我们可以进行的不同类型的检验中,用于查看特征是否与分类响应相关的是 f_classif 和 f_regression。
我们将使用候选特征对案例研究数据进行 ANOVA F 检验。你将看到输出包括 F 统计量以及p 值。我们该如何解释这些输出?我们将重点关注 p 值,原因将在练习中解释清楚。p 值是一个在多种统计测量中都很有用的概念。例如,虽然我们没有检查它们,但之前为相关矩阵计算的每个皮尔逊相关系数都有一个相应的 p 值。对于线性回归系数、逻辑回归系数及其他测量值,也有类似的 p 值概念。
在 F 检验的背景下,p 值回答了这个问题:“对于正类样本,特征的平均值与负类样本的平均值相同的可能性有多大?”如果数据表明某个特征在正负类样本之间的平均值非常不同,那么以下情况将成立:
-
这些平均值相同的可能性将非常低(低 p 值)。
-
这个特征可能是我们模型中的一个好特征,因为它有助于我们区分正负类样本。
在接下来的练习中,请牢记以下几点。
练习 3.01:F 检验和单变量特征选择
在本次练习中,我们将使用 F 检验来检查特征和响应变量之间的关系。我们将使用这种方法进行所谓的单变量特征选择:逐一检验特征与响应变量的关系,看看哪些特征具有预测能力。请按以下步骤完成练习:
注:
这个练习的 Jupyter 笔记本可以在这里找到:packt.link/ZDPYf。该笔记本还包含加载清洗过的数据和导入必要库的先决步骤。在执行本练习的第一步之前,应该先执行这些步骤。
-
进行 ANOVA F 检验的第一步是将特征和响应分离为 NumPy 数组,利用我们创建的列表以及 pandas 中的整数索引:
X = df[features_response].iloc[:,:-1].values y = df[features_response].iloc[:,-1].values print(X.shape, y.shape)输出应该显示特征和响应的形状:
(26664, 17) (26664, )有 17 个特征,并且特征和响应数组的样本数与预期相同。
-
导入
f_classif函数并传入特征和响应:from sklearn.feature_selection import f_classif [f_stat, f_p_value] = f_classif(X, y)f_classif有两个输出:F 统计量和p 值,用于比较每个特征与响应变量之间的关系。我们可以创建一个新的 DataFrame,包含特征名称以及这些输出,以便于检查。我们展示了按 p 值升序排序的 DataFrame。 -
使用以下代码创建一个包含特征名称、F 统计量和 p 值的 DataFrame,并按 p 值排序显示:
f_test_df = pd.DataFrame({'Feature':features_response[:-1], 'F statistic':f_stat, 'p value':f_p_value}) f_test_df.sort_values('p value')输出应如下所示:
图 3.8:ANOVA F 检验结果
注意,每当 p 值降低时,F 统计量会增加,因此这些列中的信息在特征排名方面是相同的。
从 F 统计量和 p 值的 DataFrame 中得出的结论与我们在相关性图中观察到的相似:
PAY_1和LIMIT_BAL似乎是最有用的特征。它们的 p 值最小,表示这些特征的平均值是SelectPercentile类。还要注意,选择前*k*个特征(其中k是您指定的任何数字)有类似的类,称为SelectKBest。在这里,我们演示如何选择前 20%的特征。 -
要根据 F 检验选择前 20%的特征,首先导入
SelectPercentile类:from sklearn.feature_selection import SelectPercentile -
实例化该类的一个对象,表示我们希望使用与本练习中已经考虑过的相同特征选择标准——ANOVA F 检验,并且我们希望选择前 20%的特征:
selector = SelectPercentile(f_classif, percentile=20) -
使用
.fit方法对我们的特征和响应数据进行拟合,类似于模型拟合的方式:selector.fit(X, y)输出应该如下所示:
SelectPercentile(percentile=20)有几种方法可以直接访问所选特征,你可以在 scikit-learn 文档中了解(即
.transform方法,或与.fit_transform在同一步骤中使用)。然而,这些方法会返回 NumPy 数组,它们不会告诉你已选择的特征名称,只会给出特征的值。为此,你可以使用特征选择器对象的.get_support方法,它将返回所选特征数组的列索引。 -
将所选特征的索引捕获到一个名为
best_feature_ix的数组中:best_feature_ix = selector.get_support() best_feature_ix输出应如下所示,表示一个逻辑索引,可与特征名称数组及其对应的值一起使用,前提是它们与传递给
SelectPercentile的特征数组的顺序一致:array([ True, False, False, False, True, False, False, False, False, False, False, True, True, False, False, False, False]) -
可以通过索引
features_response列表中的:-1,获取除最后一个元素(name响应变量)外的所有特征名称:features = features_response[:-1] -
使用我们在第 7 步中创建的索引数组,通过列表推导式和
features列表,查找所选特征名称,如下所示:best_features = [features[counter] for counter in range(len(features)) if best_feature_ix[counter]] best_features输出应如下所示:
['LIMIT_BAL', 'PAY_1', 'PAY_AMT1', 'PAY_AMT2']在这段代码中,列表推导式通过
features数组中的元素数量(len(features))和counter循环递增,使用best_feature_ix布尔数组表示已选择的特征,在if语句中测试每个特征是否被选择,如果是,则捕获该特征的名称。所选特征与我们的 F 检验结果数据框的前四行一致,因此特征选择按预期工作。尽管从严格意义上讲不必两种方式都做,因为它们都会导致相同的结果,但检查你的工作是一个好习惯,尤其是在你学习新概念时。你应该意识到,使用像
SelectPercentile这样的便捷方法时,你无法看到 F 统计量或 p 值。然而,在某些情况下,使用这些方法可能更方便,因为 p 值的排名作用可能不是特别重要。
F 检验的细节:与两类 t 检验的等价性及注意事项
当我们使用 F 检验来查看仅两个组之间的均值差异时,正如我们在案例研究的二分类问题中所做的那样,实际上我们执行的检验会简化为t 检验。F 检验可以扩展到三个或更多组,因此对于多分类问题非常有用。t 检验仅比较两个样本组之间的均值,以查看这些均值之间的差异是否具有统计显著性。
虽然 F 检验在这里满足了我们的单变量特征选择目的,但仍有一些注意事项需要牢记。回到正式统计假设的概念,对于 F 检验,这些假设包括数据为 y,从矩阵 X 中提取了多个潜在特征,我们进行了统计学中所称的多重比较。简而言之,这意味着通过反复比较多个特征与同一响应的关系,我们找到“好特征”的机会会因为纯粹的随机机会而增加。然而,这些特征可能无法推广到新的数据。有针对多重比较的统计修正,即调整 p 值以考虑这一点。
即使我们没有遵循与这些方法相关的所有统计规则,我们仍然可以从中获得有用的结果。当 p 值是最终关注的量时,多重比较的修正会更为重要,例如在进行统计推断时。在这里,p 值只是对特征列表进行排序的一种手段。如果对 p 值进行了多重比较修正,排序的顺序不会发生变化。
除了了解哪些特征可能对建模有用外,深入理解重要特征也是很有必要的。因此,我们将在下一个练习中对这些特征进行详细的图形化探索。稍后我们还会查看其他特征选择方法,这些方法不作我们在此介绍的假设,并且与我们将要构建的预测模型更加直接集成。
假设和下一步
根据我们的单变量特征探索,和响应变量关联最强的特征是 PAY_1。这是否有意义?PAY_1的解释是什么?PAY_1是账户在最近一个月的付款状态。正如我们在最初的数据探索中所学到的,有些值表示账户状态良好:-2 表示未使用账户,-1 表示余额已全额支付,0 表示至少已支付最低金额。另一方面,正整数值表示延迟付款的月份数。上个月延迟付款的账户可以视为违约账户。实际上,这意味着该特征捕捉到了响应变量的历史值。像这样的特征非常重要,因为几乎任何机器学习问题中最好的预测因子之一就是关于你要预测的同一事物的历史数据(即响应变量)。这应该是合理的:曾经违约过的人可能是再次违约的最高风险群体。
LIMIT_BAL,账户的信用额度如何?考虑到信用额度的分配方式,我们的客户很可能在决定他们的信用额度时评估了借款人的风险。风险更高的客户应该被给予较低的限额,这样债权人的风险就较小了。因此,我们可能期望看到LIMIT_BAL较低值的账户的违约概率较高。
我们从我们的单变量特征选择练习中学到了什么?我们对我们模型中最重要的特征有了一个大致的了解。并且从相关矩阵中,我们对它们与响应变量的关系有了一些想法。然而,知道我们所使用的测试的限制是个好主意,最好将这些特征可视化,以更仔细地观察特征和响应变量之间的关系。我们还开始对这些特征发展了假设:我们为什么认为它们很重要?现在,通过可视化特征和响应变量之间的关系,我们可以确定我们的想法是否与数据中所见的相符。
这些假设和可视化通常是向客户展示结果的重要部分,客户可能对模型的工作方式感兴趣,而不仅仅是它能工作这个事实。
练习 3.02:可视化特征与响应变量之间的关系
在这个练习中,您将进一步了解本书中早期使用的 Matplotlib 绘图函数。您将学习如何自定义图形以更好地回答数据中的特定问题。随着您进行这些分析,您将创建关于PAY_1和LIMIT_BAL特征如何与响应变量相关的深刻可视化,这可能会支持您对这些特征形成的假设。这将通过更加熟悉 Matplotlib 应用程序编程接口(API)来完成,换句话说,您用来与 Matplotlib 交互的语法。执行以下步骤完成本练习:
注意
在开始本练习的第一步之前,请确保已导入必要的库并加载了正确的数据框架。您可以参考以下笔记本获取先决步骤以及此练习的代码:packt.link/DOrZ9。
-
使用 pandas 的
.mean()计算整个数据集中响应变量的违约率基线:overall_default_rate = df['default payment next month'].mean() overall_default_rate这个练习的输出应该如下所示:
0.2217971797179718如何有效地可视化
PAY_1特征不同值的违约率?回想一下我们之前的观察,这个特征有点像混合型的类别和数值型特征。由于独特值的数量相对较少,我们选择以典型的类别特征方式来绘制它。在 第一章,《数据探索与清洗》中,我们在数据探索中使用了
value_counts来查看这个特征的分布,之后我们学习了如何通过groupby和mean来查看EDUCATION特征的情况。groupby和mean也是一个很好的方式来可视化不同支付状态下的违约率。 -
使用这段代码来创建一个
groupby/mean聚合:group_by_pay_mean_y = df.groupby('PAY_1').agg({'下个月违约支付': np.mean})
group_by_pay_mean_y输出应该如下所示:
图 3.9:按 PAY_1 特征分组的响应变量的均值
看着这些值,你可能已经能够辨别出趋势了。我们直接开始绘制它们。我们将一步步进行,并介绍一些新的概念。你应该把从 步骤 3 到 步骤 6 的所有代码放在一个代码单元格中。
在 Matplotlib 中,每个图表都存在于一个坐标轴(axes)和一个
figure窗口中。通过创建axes和figure对象,你可以直接访问并修改它们的属性,包括坐标轴标签和坐标轴上的其他注释。 -
使用以下代码在一个名为
axes的变量中创建一个axes对象:axes = plt.axes() -
将整体违约率绘制为一条红色的水平线。
Matplotlib 使这变得简单;你只需要通过
axhline函数来指示该直线的 y 截距。注意,现在我们不是从plt调用这个函数,而是作为axes对象的方法来调用:axes.axhline(overall_default_rate, color='red')现在,我们希望在这条线的基础上,绘制每个
PAY_1值组内的违约率。 -
使用我们创建的分组数据的 DataFrame 的
plot方法。指定在线条图中包括一个'x'标记,不要有legend实例(我们稍后会创建它),并且该图的 父轴 应该是我们当前使用的轴(否则,pandas 会擦除已经存在的内容并创建新的轴):group_by_pay_mean_y.plot(marker='x', legend=False, ax=axes)这就是我们要绘制的所有数据。
-
设置 y 轴标签,并创建一个
legend实例(有许多控制图例外观的选项,但一种简单的方法是提供一个字符串列表,表示按添加到轴上的顺序排列的图形元素的标签):axes.set_ylabel('Proportion of credit defaults') axes.legend(['Entire dataset', 'Groups of PAY_1']) -
执行从 步骤 3 到 步骤 6 的所有代码,结果应该是以下图表:
图 3.10:数据集中的信用违约率
我们对支付状态的可视化揭示了一个明确的,并且可能是预期的故事:那些曾经违约的人实际上更可能再次违约。保持良好状态的账户的违约率远低于整体违约率,后者我们之前知道大约是 22%。然而,根据这一点,超过 30% 上个月处于违约状态的账户下个月仍然会处于违约状态。这是一个很好的可视化,值得与我们的业务合作伙伴分享,因为它展示了我们模型中可能是最重要的特征之一的影响。
现在,我们将注意力转向排名第二与目标变量关联最强的特征:
LIMIT_BAL。这是一个具有许多唯一值的数值特征。对于分类问题,查看此类特征的一个好方法是将多个直方图绘制在同一坐标轴上,为不同类别使用不同的颜色。作为区分类别的一种方式,我们可以使用逻辑数组从 DataFrame 中索引它们。 -
使用此代码为正类和负类样本创建逻辑掩码:
pos_mask = y == 1 neg_mask = y == 0为了创建我们的双重直方图,我们将再创建一个
axes对象,然后调用.hist方法分别为正类和负类直方图绘制两次。我们提供一些额外的关键字参数:第一个直方图将有黑色边缘和白色条形,而第二个将使用alpha来创建透明度,这样我们就可以看到两个直方图在重叠的地方。得到直方图后,我们旋转 x 轴刻度标签,使它们更易读,并创建一些其他自解释的注释。 -
使用以下代码来创建具有上述属性的双重直方图:
axes = plt.axes() axes.hist(df.loc[neg_mask, 'LIMIT_BAL'],\ edgecolor='black', color='white') axes.hist(df.loc[pos_mask, 'LIMIT_BAL'],\ alpha=0.5, edgecolor=None, color='black') axes.tick_params(axis='x', labelrotation=45) axes.set_xlabel('Credit limit (NT$)') axes.set_ylabel('Number of accounts') axes.legend(['Not defaulted', 'Defaulted']) axes.set_title('Credit limits by response variable')图表应该如下所示:
df['LIMIT_BAL'].max() -
使用以下代码创建并显示直方图的箱子边缘:
bin_edges = list(range(0,850000,50000)) print(bin_edges)输出应该如下所示:
[0, 50000, 100000, 150000, 200000, 250000, 300000, 350000, 40000, 450000, 500000, 550000, 600000, 650000, 700000, 750000, 800000]归一化直方图的绘图代码与之前类似,但有几个关键的变化:使用
bins关键字来定义箱子边缘的位置,density=True用于归一化直方图,并对绘图注释进行了一些更改。最复杂的部分是我们需要调整np.round,因为浮点数运算可能会有轻微的误差。 -
运行此代码以生成归一化的直方图:
mpl.rcParams['figure.dpi'] = 400 axes = plt.axes() axes.hist( df.loc[neg_mask, 'LIMIT_BAL'], bins=bin_edges, density=True, edgecolor='black', color='white') axes.hist( df.loc[pos_mask, 'LIMIT_BAL'], bins=bin_edges, density=True, alpha=0.5, edgecolor=None, color='black') axes.tick_params(axis='x', labelrotation=45) axes.set_xlabel('Credit limit (NT$)') axes.set_ylabel('Proportion of accounts') y_ticks = axes.get_yticks() axes.set_yticklabels(np.round(y_ticks*50000,2)) axes.legend(['Not defaulted', 'Defaulted']) axes.set_title('Normalized distributions of '\ 'credit limits by response variable')图表应该如下所示:
图 3.12:归一化的双重直方图
你可以看到,Matplotlib 中的图表是高度可定制的。为了查看你可以从 Matplotlib 坐标轴中获取和设置的所有不同内容,可以查看这里:matplotlib.org/stable/api/axes_api.html。
我们从这个图表中能学到什么?看起来,违约的账户往往具有较高比例的低信用额度。信用额度低于新台币 150,000 元的账户更有可能违约,而对于信用额度高于这个数额的账户则相反。我们应该问自己,这是否有意义?我们的假设是,客户会给风险较高的账户设置较低的信用额度。这种直觉与我们在此观察到的低信用额度账户中违约者的较高比例相符。
根据模型构建的进展,如果我们在本次练习中检查的特征如我们预期的那样对预测建模非常重要,那么将这些图表展示给客户作为我们工作成果的一部分是很好的。这样可以让客户了解模型如何工作,以及对他们数据的洞察。
本节的一个重要学习点是,制作有效的视觉展示需要大量时间。最好在项目工作流程中预留一些时间用于此项工作。令人信服的视觉效果是值得付出努力的,因为它们应该能够迅速且有效地将重要的发现传达给客户。与其在制作材料时加入大量文字,视觉效果通常是更好的选择。定量概念的视觉传达是数据科学的核心技能。
单变量特征选择:它能做什么,不能做什么
在这一章中,我们学习了逐一查看特征以判断它们是否具有预测能力的技巧。这是一个良好的第一步,如果你已经有了对结果变量具有较强预测能力的特征,你可能不需要花太多时间考虑其他特征,便可以进行建模。然而,单变量特征选择也有其缺点,特别是它没有考虑特征之间的相互作用。例如,如果信用违约率特别高的是那些既有某种教育水平又有一定范围的信用额度的人群怎么办?
此外,我们在这里使用的方法仅能捕捉特征的线性效应。如果某个特征在经历某种变换(如多项式或对数变换,或者分箱(离散化))后能更好地预测,单变量特征选择的线性方法可能就不太有效。相互作用和变换是特征工程的例子,或者说,在这些情况下通过现有特征创建新特征。线性特征选择方法的不足可以通过非线性建模技术来弥补,包括决策树及其相关方法,我们将在后续进行讨论。但从简单关系入手,寻找那些可以通过线性方法实现的单变量特征选择,依然具有价值,而且这种方法非常迅速。
使用 Python 函数语法理解逻辑回归和 Sigmoid 函数
在这一部分,我们将全面揭开逻辑回归的“黑箱”:我们将全面理解它是如何工作的。我们将从介绍一个新的编程概念:函数开始。同时,我们将学习一个数学函数——sigmoid 函数,它在逻辑回归中起着关键作用。
从最基本的角度来看,计算机编程中的函数是一段接受输入并产生输出的代码。你在本书中已经使用了函数:这些函数是由别人编写的。每次你使用类似于这样的语法:output = do_something_to(input)时,你实际上就是在使用一个函数。例如,NumPy 有一个函数可以用来计算输入的均值:
np.mean([1, 2, 3, 4, 5])
3.0
函数抽象了正在执行的操作,以便在我们的示例中,每次需要计算均值时,你不需要看到所有执行此操作的代码行。对于许多常见的数学函数,像 NumPy 这样的包中已经有了预定义的版本。你无需“发明轮子”。流行包中的实现之所以流行,可能是因为人们花了时间思考如何以最有效的方式创建它们。因此,使用它们是明智的。然而,由于我们使用的所有包都是开源的,如果你有兴趣查看我们使用的库中函数的实现,你可以查看它们的代码。
现在,为了说明,我们通过编写自己的算术平均数函数来学习 Python 函数语法。Python 中的函数语法类似于for或if块,其中函数体是缩进的,函数声明后面跟着一个冒号。下面是一个计算均值的函数代码:
def my_mean(input_argument):
output = sum(input_argument)/len(input_argument)
return(output)
在你执行了包含此定义的代码单元后,函数在笔记本中的其他代码单元中可以使用。举个例子:
my_mean([1, 2, 3, 4, 5])
3.0
定义函数的第一部分,如这里所示,是以def开始一行代码,后面跟一个空格,然后是你想为函数命名的名称。接下来是括号,括号内指定函数的参数名称。参数是输入变量的名称,这些名称是函数体内部的:作为参数定义的变量在函数被调用(使用)时可用,但在函数外部不可用。可以有多个参数;它们之间用逗号分隔。括号后跟冒号。
函数体是缩进的,可以包含对输入进行操作的任何代码。操作完成后,最后一行应以return开头,并包含输出变量,如果有多个输出变量,它们之间用逗号分隔。在这段非常简单的函数介绍中,我们省略了许多细节,但这些是你开始使用函数时需要掌握的基本部分。
函数的威力在于它的使用。注意,我们定义了函数后,在一个单独的代码块中,我们可以通过给定的名称 调用 它,它会对我们 传递 的输入进行操作。就像我们把所有代码复制粘贴到这个新位置一样,但看起来比实际复制粘贴要整洁得多。如果你需要多次使用相同的代码,函数可以大大减少代码的整体长度。
作为一个简短的补充说明,你可以选择明确指定输入参数的名称,这样在有多个输入时会更清晰:
my_mean(input_argument=[1, 2, 3])
2.0
现在我们已经熟悉了 Python 函数的基础知识,接下来我们将讨论一个对逻辑回归非常重要的数学函数,叫做 Sigmoid 函数。这个函数也可以称为 逻辑函数。Sigmoid 的定义如下:
图 3.13:Sigmoid 函数
我们将分解这个函数的不同部分。正如你所看到的,sigmoid 函数涉及到 exp,它会自动将 e 提供给输入指数。如果你查看文档,你会看到这个过程叫做取“指数”,这听起来有点模糊。但通常理解的是,这里指数的底数是 e。一般来说,如果你想在 Python 中计算指数,比如 23(“2 的三次方”),语法是两个星号:2**3,例如结果为 8。
考虑如何将输入传递给 np.exp 函数。由于 NumPy 的实现是 向量化 的,这个函数可以接受单个数字,也可以接受数组或矩阵作为输入。为了说明单个参数,我们计算了 1 的指数,这显示了 e 的近似值,以及 e0,它当然等于 1,就像任何底数的零次方一样:
np.exp(1)
2.718281828459045
np.exp(0)
1.0
为了说明 np.exp 的向量化实现,我们使用 NumPy 的 linspace 函数创建一个数字数组。这个函数输入一个范围的起始和结束点(包括这两个点),以及该范围内你希望包含的值的数量,以创建一个等间距的数组。这个函数的作用与 Python 的 range 类似,但还可以生成小数值:
图 3.14:使用 np.linspace 创建数组
由于 np.exp 是向量化的,它会一次性高效地计算整个数组的指数。这里是计算我们 X_exp 数组的指数并检查前五个值的代码和输出:
图 3.15:NumPy 的 exp 函数
练习 3.03:绘制 Sigmoid 函数
在本练习中,我们将使用先前创建的 X_exp 和 Y_exp,绘制指数函数在区间 [-4, 4] 上的图形。你需要先运行 图 3.14 和 图 3.15 中的所有代码,以便为本练习提供这些变量。接下来,我们将定义一个 sigmoid 函数,绘制它的图形,并考虑它与指数函数的关系。执行以下步骤以完成此练习:
注意
在开始本练习的第一步之前,请确保你已导入必要的库。导入库的代码以及练习其余步骤的代码可以在这里找到:packt.link/Uq012。
-
使用此代码绘制指数函数:
plt.plot(X_exp, Y_exp) plt.title('Plot of $e^X$')图形应该是这样的:
图 3.16:绘制指数函数
注意,在图表标题中,我们利用了一种叫做
^的语法。还要注意在 图 3.16 中,许多紧密间隔的点创造出平滑曲线的效果,但实际上它是由离散点通过线段连接而成的图形。
我们可以从指数函数中观察到什么?
它永远不会是负数:当 X 趋近于负无穷时,Y 趋近于 0。
随着 X 的增加,Y 起初增长缓慢,但很快就“爆炸”了。这就是人们所说的“指数增长”来表示快速增长的含义。
你如何从指数函数的角度理解 sigmoid?
首先,sigmoid 涉及 e-X,而不是 eX。e-X 的图形只是 eX 关于 y 轴的反射。这可以很容易地绘制出来,并在图表标题中使用大括号标注多字符的上标。
-
运行此代码以查看 e-X 的图形:
Y_exp = np.exp(-X_exp) plt.plot(X_exp, Y_exp) plt.title('Plot of $e^{-X}$')输出应该是这样的:
图 3.17:exp(-X) 的图
现在,在 sigmoid 函数中,e-X 位于分母,且加上了 1。分子是 1。那么,当 X 趋近于负无穷时,sigmoid 会发生什么呢?我们知道 e-X 会“爆炸”,变得非常大。总的来说,分母变得非常大,分数接近于 0。那么,当 X 增加到正无穷时,会怎样呢?我们可以看到 e-X 会变得非常接近于 0。所以,在这种情况下,sigmoid 函数大约等于 1/1 = 1。这应该给你一个直觉,sigmoid 函数始终保持在 0 和 1 之间。现在让我们在 Python 中实现一个 sigmoid 函数,并用它绘制一个图形,看看现实与这个直觉如何匹配。
-
定义一个 sigmoid 函数,如下所示:
def sigmoid(X): Y = 1 / (1 + np.exp(-X)) return Y -
扩大 x 值的范围以绘制 sigmoid 图。使用以下代码:
X_sig = np.linspace(-7,7,141) Y_sig = sigmoid(X_sig) plt.plot(X_sig,Y_sig) plt.yticks(np.linspace(0,1,11)) plt.grid() plt.title('The sigmoid function')图形应该是这样的:
图 3.18:一个 sigmoid 函数图
这个图与我们预期的结果一致。此外,我们可以看到sigmoid(0) = 0.5。那么,sigmoid 函数有什么特别之处?这个函数的输出严格地限制在 0 和 1 之间。对于一个应该预测概率的函数来说,这个特性非常好,因为概率值也必须在 0 和 1 之间。技术上,概率值可以恰好等于 0 和 1,而 sigmoid 函数永远不会是。但 sigmoid 函数的值可以接近 0 或 1,这在实际中并不是一个限制。
回顾一下,我们将逻辑回归描述为生成预测的类别概率,而不是直接预测类别成员资格。这使得逻辑回归的实现更加灵活,可以选择阈值概率。sigmoid 函数是这些预测概率的来源。稍后,我们将看到不同特征是如何用于计算预测概率的。
函数的作用域
当你开始使用函数时,你应该对sigmoid函数的概念有所了解,我们在函数内部创建了一个变量Y。在函数内部创建的变量与在函数外部创建的变量不同。它们在函数被调用时会在函数内部有效地创建和销毁。这些变量在使用sigmoid函数后被称为Y变量:
图 3.19:Y 变量不在笔记本的作用域内
Y变量不在笔记本的全局作用域中。然而,在函数外部创建的全局变量可以在函数的局部作用域内使用,即使它们没有作为参数传递给函数。在这里,我们展示了如何在函数外部创建一个全局变量,然后在函数内部访问它。这个函数实际上没有接受任何参数,但如你所见,它可以使用全局变量的值来生成输出:
图 3.20:全局变量在函数的局部作用域中可用
注意
更多关于作用域的细节
变量的作用域可能会让人感到困惑,但当你开始更高级地使用函数时,了解作用域是非常有益的。虽然本书中并不要求了解这些内容,但你可能希望在这里了解更多关于 Python 中变量作用域的深度知识:nbviewer.jupyter.org/github/rasbt/python_reference/blob/master/tutorials/scope_resolution_legb_rule.ipynb。
sigmoid 曲线在科学应用中的应用
除了在逻辑回归中是基础,S 型曲线还广泛应用于各种领域。在生物学中,它们可以用来描述生物体的生长过程,首先是缓慢开始,然后进入快速阶段,最后以平滑的方式逐渐趋于稳定,直到最终大小达到。S 型曲线也可以用来描述人口增长,其轨迹类似,快速增长后会减慢,直到环境的承载能力达到。
为什么逻辑回归被认为是线性模型?
我们之前提到,逻辑回归被认为是第一章《数据探索与清洗》中EDUCATION特征的groupby/mean,以及本章中PAY_1特征的groupby/mean,用来查看这些特征值之间的违约率是否表现出线性趋势。虽然这是一种快速近似判断这些特征是否“线性”的好方法,但在这里我们将形式化为什么逻辑回归是线性模型的概念。
如果计算预测所使用的特征变换是特征的线性组合,则该模型被认为是线性的。线性组合的可能性是每个特征可以乘以一个数值常数,这些项可以相加,并且可以添加一个额外的常数。例如,在一个简单的包含两个特征的模型中,X1 和 X2,线性组合的形式如下:
图 3.21:X1 和 X2 的线性组合
常数𝜃i 可以是任何数值,正数、负数或零,i = 0, 1, 和 2(尽管如果某个系数为 0,这将从线性组合中移除一个特征)。一个常见的线性变换的例子是单变量的直线方程 y = mx + b,如在第二章《Scikit-Learn 入门与模型评估》中讨论的那样。在这种情况下,𝜃o = b,𝜃1 = m。𝜃o 被称为线性组合的截距,这在代数中应该是熟悉的概念。
在线性变换中“禁止”哪些操作?除了刚才描述的内容之外,任何其他数学表达式,如下所示:
-
将特征与自身相乘;例如,X12 或 X13。这被称为多项式项。
-
将特征相乘;例如,X1X2。这被称为交互作用。
-
对特征应用非线性变换;例如,对数和平方根。
-
其他复杂的数学函数。
-
"如果...那么..."类型的语句。例如,"如果 X1 > a,那么 y = b"。
然而,虽然这些变换不是线性组合的基本形式,但它们可以通过特征工程添加到线性模型中,例如定义一个新特征,X3 = X12。
之前我们学过,逻辑回归的预测结果是概率形式,它们是通过 sigmoid 函数得出的。再看这里,我们可以清楚地看到这个函数是非线性的:
图 3.22:非线性 sigmoid 函数
那么,为什么逻辑回归被认为是线性模型呢?事实证明,答案在于 sigmoid 方程的另一种表述形式,即logit函数。我们可以通过求解 sigmoid 函数的X来推导出logit函数;换句话说,就是找到 sigmoid 函数的逆函数。首先,我们将 sigmoid 设为p,我们将其解释为观察到正类的概率,然后按如下方式求解X:
图 3.23:求解 X
在这里,我们利用了幂和对数的一些法则来求解X。你可能还会看到logit以以下形式表示:
图 3.24:logit 函数
在这个表达式中,logit函数也被称为对数几率,因为它是几率比的自然对数,p/q。几率比在赌博世界中可能比较常见,例如“a队战胜b队的几率是 2 比 1”。
一般来说,我们在这些操作中所称的大写X可以代表所有特征的线性组合。例如,在我们这个简单的包含两个特征的案例中,X = 𝜃o + 𝜃1X1 + 𝜃2X2。逻辑回归被认为是线性模型,因为在考虑响应变量为对数几率时,包含在X中的特征实际上仅受限于线性组合。这是与 sigmoid 方程相比的一种问题表述方式。
将各部分组合在一起,特征X1、X2、…、Xj 在 sigmoid 方程版逻辑回归中是这样的:
图 3.25:sigmoid 版逻辑回归
但在对数几率版本中它们看起来是这样的,这也是为什么逻辑回归被称为线性模型:
图 3.26:对数几率版逻辑回归
因此,从这种角度看待逻辑回归时,理想情况下,逻辑回归模型的特征应该是在响应变量的对数几率上是线性的。我们将在接下来的练习中看到这一点是什么意思。
逻辑回归是统计模型的一个更广泛类别,称为广义线性模型(GLMs)。广义线性模型与普通线性回归的基本概念有关,普通线性回归可能只有一个特征(即 最佳拟合线,y = mx + b,对于单个特征 x)或多个特征,称为多元线性回归。广义线性模型与线性回归之间的数学联系是连接函数。逻辑回归的连接函数是我们刚刚学习的对数几率函数。
练习 3.04:检查特征在逻辑回归中的适用性
在 练习 3.02,可视化特征与响应变量之间的关系 中,我们绘制了可能是模型中最重要特征之一的 PAY_1 特征的 groupby/mean。通过按 PAY_1 的值对样本进行分组,并查看响应变量的平均值,我们实际上是在查看每个组内的违约概率,p。
在本练习中,我们将评估PAY_1在逻辑回归中的适用性。我们通过检查这些组内的违约对数赔率来判断响应变量是否在对数赔率中是线性的,正如逻辑回归所正式假设的那样。完成以下步骤以完成本练习:
注意
在开始本练习的第 1 步之前,确保已经导入了必要的库。你可以参考以下笔记本中的先决步骤:packt.link/gtpF9。
-
确认你仍然可以访问 练习 3.02,可视化特征与响应变量之间的关系 中的变量,在笔记本中查看
PAY_1不同值下响应变量的平均值的 DataFrame,使用以下代码:group_by_pay_mean_y输出应该如下所示:
图 3.27:按 PAY_1 值分组的违约率作为违约概率
-
从这些组中提取响应变量的平均值,并将其放入一个变量
p中,表示违约的概率:p = group_by_pay_mean_y['default payment next month'].values -
创建一个不违约的概率
q。由于这是一个二元问题,且所有结果的概率和始终为 1,因此很容易计算出q。同时打印出p和q的值以确认:q = 1-p print(p) print(q)输出应该如下所示:
图 3.28:从 p 计算 q
-
使用 NumPy 的自然对数函数从
p和q计算赔率比和对数赔率:odds_ratio = p/q log_odds = np.log(odds_ratio) log_odds输出应该如下所示:
图 3.29:赔率比和对数赔率
-
为了绘制对数几率与特征值的关系,我们可以从包含
groupby/mean的 DataFrame 的索引中获取特征值。你可以这样显示索引:group_by_pay_mean_y.index这应该会产生以下输出:
Int64Index([-2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8], dtype='int64', name='PAY_1') -
创建一个类似我们已经做过的图表,展示对数几率与特征值之间的关系。以下是代码:
plt.plot(group_by_pay_mean_y.index, log_odds, '-x') plt.ylabel('Log odds of default') plt.xlabel('Values of PAY_1')图表应该如下所示:
图 3.30:PAY_1 值的违约对数几率
我们可以从这个图中看到,响应变量的对数几率与PAY_1特征之间的关系,与我们在练习 3.02中绘制的违约率与该特征之间的关系几乎没有什么不同,可视化特征与响应变量之间的关系。因此,如果“违约率”是一个更易于向业务伙伴传达的概念,那么它可能更合适。然而,在理解逻辑回归的工作原理方面,这个图正好展示了假定为线性的内容。
直线拟合是否是此数据的良好模型?
看起来在这个图中,画一条“最佳拟合线”应该是从左下角到右上角的。同时,这些数据似乎并不会导致一个真正的线性过程。我们可以通过一种方式来看待这些数据,即-2、-1 和 0 的值似乎落在了一个与其他值不同的对数几率范围内。PAY_1 = 1处于一个中间值,而其余的值则较大。也许基于此变量的工程特征,或者以不同方式编码-2、-1 和 0 所代表的类别,会对建模更有效。随着我们继续使用逻辑回归建模这些数据,并在本书后面介绍其他方法时,记住这一点。
从逻辑回归系数到使用 Sigmoid 的预测
在下一个练习之前,我们先来看一下逻辑回归的系数是如何用来计算预测概率的,最终为响应变量的类别做出预测的。
回忆一下,逻辑回归根据 Sigmoid 方程预测类成员的概率。在具有截距的两个特征的情况下,方程如下:
图 3.31:Sigmoid 函数预测两个特征的类成员概率
当你使用训练数据调用 scikit-learn 中逻辑回归模型对象的.fit方法时,𝜃0、𝜃1 和𝜃2 参数(截距和系数)是从这些有标签的训练数据中估计出来的。实际上,scikit-learn 会计算出如何选择𝜃0、𝜃1 和𝜃2 的值,以便尽可能准确地分类尽可能多的训练数据点。我们将在下一章中深入了解这个过程是如何运作的。
当你调用 .predict 时,scikit-learn 会根据拟合的参数值和 sigmoid 方程计算预测的概率。给定的样本将在 p ≥ 0.5 时被分类为正类,否则为负类。
我们知道,sigmoid 方程的图像如下所示,我们可以通过将 X = 𝜃0 + 𝜃1X1 + 𝜃2X2 代入 图 3.31 中的方程来进行连接:
图 3.32:预测与真实类别一起绘制
请注意,如果 X = 𝜃o + 𝜃1X1 + 𝜃2X2 ≥ 0 时,在 x 轴上,则预测的概率为 p ≥ 0.5,该样本将被分类为正类。否则,p < 0.5,样本将被分类为负类。我们可以利用这个观察结果,计算出一个线性条件来进行正向预测,基于 X1 和 X2 特征,使用系数和截距。解这个不等式,得到类似于 y = mx + b 形式的线性不等式:X2 ≥ -(**𝜃1*/**𝜃2)X1 - (**𝜃o/**𝜃2)*。
这将帮助在以下练习中看到逻辑回归的线性决策边界在 X1*-X*2 特征空间 中的表现。
我们现在已经从理论和数学的角度了解了为什么逻辑回归被认为是一个线性模型。我们还检查了单个特征,并考虑了线性假设是否合理。理解线性假设也很重要,这涉及到我们可以期望逻辑回归具有多大的灵活性和强大性。我们将在接下来的练习中探讨这一点。
练习 3.05:逻辑回归的线性决策边界
在这个练习中,我们展示了二分类问题的决策边界的概念。我们使用合成数据创建一个清晰的示例,展示逻辑回归的决策边界与训练样本的对比。我们首先随机生成两个特征,X1 和 X2。由于有两个特征,我们可以说这个问题的数据是二维的,这使得可视化变得容易。我们在这里展示的概念可以推广到更多特征的情况,比如你在工作中可能会遇到的真实世界数据集;然而,决策边界在高维空间中更难以可视化。
执行以下步骤以完成练习:
注意
在开始此练习的第 1 步之前,请确保你已导入必要的库。你可以参考以下笔记本,获取前置步骤:packt.link/35ge1。
-
使用以下代码生成特征:
from numpy.random import default_rng rg = default_rng(4) X_1_pos = rg.uniform(low=1, high=7, size=(20,1)) print(X_1_pos[0:3]) X_1_neg = rg.uniform(low=3, high=10, size=(20,1)) print(X_1_neg[0:3]) X_2_pos = rg.uniform(low=1, high=7, size=(20,1)) print(X_2_pos[0:3]) X_2_neg = rg.uniform(low=3, high=10, size=(20,1)) print(X_2_neg[0:3])你不需要过于担心我们选择这些值的原因;我们稍后绘制的图形应该能够让你理解。不过要注意,我们同时分配了真实类别,通过定义哪些点(
X1, X2)将属于正类和负类,来进行这一操作。这样,我们就得到了每个类别各 20 个样本,总共 40 个样本,并且每个样本有两个特征。我们展示了正类和负类每个特征的前三个值。输出应为以下内容:
图 3.33:生成二分类问题的合成数据
-
绘制这些数据,将正样本用红色方块表示,负样本用蓝色 x 形表示。绘图代码如下:
plt.scatter(X_1_pos, X_2_pos, color='red', marker='s') plt.scatter(X_1_neg, X_2_neg, color='blue', marker='x') plt.xlabel(‚$X_1$') plt.ylabel(‚$X_2$') plt.legend(['Positive class', 'Negative class'])结果应该如下所示:
图 3.34:生成二分类问题的合成数据
为了将我们的合成特征与 scikit-learn 一起使用,我们需要将它们组装成一个矩阵。我们使用 NumPy 的
block函数来创建一个 40x2 的矩阵。因为总共有 40 个样本,所以会有 40 行,且每个样本有两个特征,因此有 2 列。我们会将正类样本的特征放在前 20 行,负类样本的特征放在后 20 行。 -
创建一个 40x2 的矩阵,然后显示其形状和前三行:
X = np.block([[X_1_pos, X_2_pos], [X_1_neg, X_2_neg]]) print(X.shape) print(X[0:3])输出应该如下所示:
(40, 2) [[6.65833663 5.15531227] [4.06796532 5.6237829 ] [6.85746223 2.14473103]]我们还需要一个响应变量来与这些特征配合使用。我们已经知道它们是如何定义的,但我们还需要一个
y数组来告诉 scikit-learn。 -
创建一个垂直堆叠(
vstack)的 20 个 1 和 20 个 0,以匹配我们特征的排列方式,并重新调整形状以符合 scikit-learn 的要求。代码如下:y = np.vstack((np.ones((20,1)), np.zeros((20,1)))).reshape(40,) print(y[0:5]) print(y[-5:])你将获得以下输出:
[1\. 1\. 1\. 1\. 1.] [0\. 0\. 0\. 0\. 0.]目前,我们准备使用 scikit-learn 来拟合一个逻辑回归模型。我们将使用所有数据作为训练数据,并观察线性模型如何拟合这些数据。接下来的几个步骤应该与你在前几章学习的如何实例化模型类并拟合模型的内容相似。
-
首先,使用以下代码导入模型类:
from sklearn.linear_model import LogisticRegression -
现在实例化模型,指定使用
liblinear求解器,并使用以下代码显示模型对象:example_lr = LogisticRegression(solver='liblinear') example_lr输出应该如下所示:
LogisticRegression(solver='liblinear')我们将在 第四章《偏差-方差权衡》中讨论 scikit-learn 中不同求解器的使用方法,但现在我们将使用这个求解器。
-
现在在合成数据上训练模型:
example_lr.fit(X, y)对我们用于模型训练的相同样本应用
.predict方法。然后,为了将这些预测添加到图中,我们将根据预测值是 1 还是 0,创建两个索引列表以与数组一起使用。看看你是否能理解我们是如何使用列表推导式,包括if语句,来完成这一步的。 -
使用此代码获取预测值,并将其分离为正类和负类的索引。显示正类预测的索引作为检查:
y_pred = example_lr.predict(X) positive_indices = [counter for counter in range(len(y_pred)) if y_pred[counter]==1] negative_indices = [counter for counter in range(len(y_pred)) if y_pred[counter]==0] positive_indices输出应该如下所示:
[2, 3, 4, 5, 6, 7, 9, 11, 13, 15, 16, 17, 18, 19, 26, 34, 36]从正预测的索引,我们可以已经看出,并非训练数据中的每个样本都被正确分类:正样本是前 20 个样本,但这里有超出这个范围的索引。你可能已经猜到,线性决策边界无法完美地对这些数据进行分类,因为观察它后会得出这个结论。现在,让我们将这些预测显示在图上,形式为每个数据点周围的方框和圆圈,分别按照正负预测进行着色:红色表示正类,蓝色表示负类。
你可以比较内层符号的颜色和形状(数据的真实标签)与外层符号(预测标签),以查看哪些点被正确分类,哪些点被错误分类。
-
这是绘图代码:
plt.scatter(X_1_pos, X_2_pos, color='red', marker='s') plt.scatter(X_1_neg, X_2_neg, color='blue', marker='x') plt.scatter(X[positive_indices,0], X[positive_indices,1], s=150, marker='s', edgecolors='red', facecolors='none') plt.scatter(X[negative_indices,0], X[negative_indices,1], s=150, marker='o', edgecolors='blue', facecolors='none') plt.xlabel('$X_1$') plt.ylabel('$X_2$') plt.legend(['Positive class', 'Negative class',\ 'Positive predictions', 'Negative predictions'])绘图应该如下所示:
图 3.35:预测值和真实类别一起绘制
从图中可以看出,分类器在靠近线性决策边界位置的数据点上表现不佳;其中一些可能会出现在边界的另一侧。我们怎么做才能找出并可视化决策边界的实际位置呢?从前一节中,我们知道可以通过不等式 X2 ≥ -(**𝜃1*/**𝜃2)X1 - (**𝜃0/*𝜃2) 来获取逻辑回归的决策边界,在二维特征空间中。既然我们已经拟合了模型,就可以检索 𝜃1 和 𝜃2 的系数,以及 𝜃0 的截距,将这些值代入方程并绘制图表。
-
使用此代码从拟合的模型中获取系数并打印它们:
theta_1 = example_lr.coef_[0][0] theta_2 = example_lr.coef_[0][1] print(theta_1, theta_2)输出应该如下所示:
-0.16472042583006558 -0.25675185949979507 -
使用此代码获取截距:
theta_0 = example_lr.intercept_现在使用系数和截距来定义线性决策边界。这个边界捕捉了不等式的分界线,X2 ≥ -(**𝜃1*/**𝜃2)X1 - (**𝜃0/**𝜃2)*:
X_1_decision_boundary = np.array([0, 10]) X_2_decision_boundary = -(theta_1/theta_2)*X_1_decision_boundary\ - (theta_0/theta_2)总结最后几步,在使用
.coef_和.intercept_方法来获取 𝜃1 和 𝜃2 的模型系数以及 𝜃0 的截距之后,我们使用这些值根据我们描述的决策边界方程创建了一条由两点定义的线。 -
使用以下代码绘制决策边界,并做一些调整以分配正确的标签用于图例,同时将图例移动到图外的某个位置(
loc),避免图表拥挤:pos_true = plt.scatter(X_1_pos, X_2_pos, color='red', marker='s', label='Positive class') neg_true = plt.scatter(X_1_neg, X_2_neg, color='blue', marker='x', label='Negative class') pos_pred = plt.scatter(X[positive_indices,0], X[positive_indices,1], s=150, marker='s', edgecolors='red', facecolors='none', label='Positive predictions') neg_pred = plt.scatter(X[negative_indices,0], X[negative_indices,1], s=150, marker='o', edgecolors='blue', facecolors='none', label='Negative predictions') dec = plt.plot(X_1_decision_boundary, X_2_decision_boundary, 'k-', label='Decision boundary') plt.xlabel('$X_1$') plt.ylabel('$X_2$') plt.legend(loc=[0.25, 1.05])你将获得以下图形:
图 3.36:逻辑回归的真实类别、预测类别和决策边界
决策边界的位置与你原本预想的有何不同?
你能看出线性决策边界永远无法完美分类这些数据吗?
为了绕过这个问题,我们可以从现有特征中创建工程特征,例如多项式或交互特征,以便在逻辑回归中允许更复杂的非线性决策边界。或者,我们可以使用非线性模型,如随机森林,后者也能实现这一点,稍后我们会看到。
最后需要注意的是,由于只有两个特征,这个例子可以很容易地在二维中进行可视化。通常,决策边界可以通过超平面来描述,它是直线在多维空间中的推广。然而,线性决策边界的限制性质仍然是超平面的一个因素。
活动 3.01:拟合逻辑回归模型并直接使用系数
在这个活动中,我们将训练一个逻辑回归模型,使用在单变量特征探索中发现的两个最重要的特征,并学习如何使用拟合模型中的系数手动实现逻辑回归。这将展示如何在没有 scikit-learn 环境的计算环境中使用逻辑回归,但可以计算 sigmoid 函数所需的数学函数。成功完成该活动后,你应该会发现,使用 scikit-learn 预测和手动预测计算的 ROC AUC 值应该是相同的:大约为 0.63。
执行以下步骤完成活动:
-
创建一个训练/测试划分(80/20),以
PAY_1和LIMIT_BAL作为特征。 -
导入
LogisticRegression,使用默认选项,但将求解器设置为'liblinear'。 -
在训练数据上训练,并使用测试数据获取预测类别及类别概率。
-
从训练好的模型中提取系数和截距,并手动计算预测概率。你需要向特征中添加一列 1,以便与截距相乘。
-
使用
0.5的阈值,手动计算预测类别。与 scikit-learn 输出的类别预测进行比较。 -
使用 scikit-learn 的预测概率和手动预测的概率计算 ROC AUC,并进行比较。
注意
包含此活动代码的 Jupyter 笔记本可以在这里找到:
packt.link/4FHec。该笔记本仅包含 Python 代码及相应输出。完整的逐步解决方案可以通过此链接找到。
总结
在本章中,我们学习了如何逐一探索特征,使用包括皮尔逊相关系数和 ANOVA F 检验在内的单变量特征选择方法。虽然以这种方式查看特征并不总是能揭示完整的故事,因为可能会忽略特征间的重要交互作用,但这通常是一个有用的步骤。理解最具预测性的特征与响应变量之间的关系,并围绕它们创建有效的可视化,是向客户传达你的发现的好方法。我们使用了定制的图形,比如使用Matplotlib创建的重叠直方图,来可视化最重要的特征。
然后我们开始深入描述逻辑回归是如何工作的,探讨了如sigmoid函数、对数几率和线性决策边界等话题。虽然逻辑回归是最简单的分类模型之一,且通常不如其他方法强大,但它是应用最广泛的模型之一,并且是深度神经网络等更复杂分类模型的基础。因此,详细理解逻辑回归将帮助你在探索机器学习的更高级话题时提供帮助。而在某些情况下,简单的逻辑回归可能是唯一需要的模型。综合考虑所有因素,满足要求的最简单模型可能就是最好的模型。
如果你掌握了本章和下一章的内容,你将为在工作中使用逻辑回归做好充分准备。在下一章,我们将在这里学到的基础上进一步探讨,了解如何估计逻辑回归的系数,以及如何在特征数量较多的情况下有效使用逻辑回归,并用于特征选择。
第四章:4. 偏差-方差权衡
概述
本章将介绍逻辑回归的剩余部分,包括调用.fit训练模型时发生的事情,以及在使用此建模技术时应该注意的统计假设。你将学习如何在逻辑回归中使用 L1 和 L2 正则化来防止过拟合,并了解如何使用交叉验证实践来决定正则化的强度。阅读本章后,你将能够在工作中使用逻辑回归,并在模型拟合过程中使用正则化,以利用偏差-方差权衡并提高模型在未见数据上的表现。
引言
本章将介绍上一章中剩余的逻辑回归细节。除了能够使用 scikit-learn 拟合逻辑回归模型外,你还将深入了解梯度下降过程,这与 scikit-learn 中用于完成模型拟合的“幕后”过程类似。最后,我们将通过熟悉这种方法的正式统计假设,完成对逻辑回归模型的讨论。
我们通过探讨如何扩展逻辑回归模型以解决过拟合问题,开始探索机器学习中基础概念——过拟合、欠拟合和偏差-方差权衡。在回顾用于缓解过拟合的正则化方法的数学细节后,你将学到一种调优正则化超参数的实用方法:交叉验证。通过正则化方法和一些简单的特征工程,你将理解如何改进过拟合和欠拟合的模型。
虽然本章我们主要关注逻辑回归,但过拟合、欠拟合、正则化以及偏差-方差权衡的概念几乎适用于机器学习中所有监督学习建模技术。
估计逻辑回归的系数和截距
在上一章,我们学习了逻辑回归模型的系数(每个系数对应一个特定的特征)以及截距,这些值是在调用 scikit-learn 中逻辑回归模型的.fit方法时,使用训练数据来确定的。这些数值被称为模型的参数,而找到最佳参数值的过程称为参数估计。一旦参数确定,逻辑回归模型就基本完成了:只需要这些数值,我们就可以在任何可以执行常见数学函数的环境中使用逻辑回归模型。
显然,参数估计过程是非常重要的,因为正是通过这个过程,我们能够从数据中构建预测模型。那么,参数估计是如何工作的呢?要理解这一点,第一步是熟悉代价函数的概念。代价函数是一种衡量模型预测与数据完美描述之间距离的方式。模型预测与实际数据之间的差异越大,代价函数返回的“代价”就越大。
对于回归问题,这是一个直观的概念:预测值与真实值之间的差异可以用作代价,通过某种变换(例如取绝对值或平方)将代价值转换为正数,再对所有训练样本进行平均。
对于分类问题,特别是在拟合逻辑回归模型时,一个典型的代价函数是对数损失函数,也叫交叉熵损失。这是 scikit-learn 在拟合逻辑回归时使用的代价函数,经过修改:
图 4.1:对数损失函数
这里有n个训练样本,yi 是第 i 个样本的真实标签(0 或 1),pi 是第 i 个样本标签为 1 的预测概率,log 是自然对数。对所有训练样本求和的符号(即大写的希腊字母 sigma)和除以 n,用于对所有训练样本的代价函数进行平均。考虑到这一点,看看下面的自然对数函数图像,并思考这个代价函数的解释:
图 4.2:区间 (0, 1) 上的自然对数
为了理解对数损失代价函数是如何工作的,考虑一个样本,其中真实标签为 1,即 y = 1,因此代价函数的第二部分,(1 - yi*)log(1 - pi),将完全等于 0,不会影响结果。此时,代价函数的值为 -yilog(pi) = -log(pi)*,因为 yi = 1。因此,该样本的代价就是预测概率的自然对数的负值。现在,由于该样本的真实标签为 1,考虑代价函数应该如何表现。我们期望,对于接近 1 的预测概率,代价函数会很小,表示预测值与真实值接近时的误差很小。对于接近 0 的预测,代价会更大,因为代价函数应当随着预测错误的增大而增大。
从图 4.2中的自然对数图中,我们可以看到,对于更接近 0 的p值,自然对数的值越来越负。这意味着成本函数将变得越来越大,因此,分类一个具有非常低概率的正样本的成本相对较高,这正是我们所期望的。相反,如果预测的概率更接近 1,则图形表明成本将接近 0——再次,这与一个“更正确”预测的期望一致。因此,成本函数在正样本的情况下表现如预期。对于标签为 0 的样本,也可以做类似的观察。
现在我们已经了解了对数损失成本函数在逻辑回归中的工作原理。但这与系数和截距的确定有什么关系呢?我们将在下一节学习。
注意
生成本节中展示的图表的代码可以在这里找到:packt.link/NeF8P。
梯度下降寻找最优参数值
使用对数损失成本找到逻辑回归模型的参数值(系数和截距)的问题,归结为 scikit-learn 中逻辑回归模型的.fit方法的问题。找到具有最低成本的参数集有不同的解决技术,您可以在实例化模型类时使用solver关键字选择您想要使用的技术。所有这些方法都略有不同,但它们都基于梯度下降的概念。
梯度下降过程从solver关键字开始。然而,对于像深度神经网络这样的更高级机器学习算法,选择参数的初始猜测需要更多的关注。
为了说明问题,我们考虑一个只需要估计一个参数的情况。我们将观察一个假设的成本函数(y = f(x) = x2 – 2x)的值,并设计一个梯度下降过程来找到使成本y最小的参数值x。在这里,我们选择一些x值,创建一个返回成本函数值的函数,并观察在这个参数范围内成本函数的值。
执行此操作的代码如下:
X_poly = np.linspace(-3,5,81)
print(X_poly[:5], '...', X_poly[-5:])
这是打印语句的输出:
[-3\. -2.9 -2.8 -2.7 -2.6] ... [4.6 4.7 4.8 4.9 5\. ]
剩余的代码片段如下:
def cost_function(X):
return X * (X-2)
y_poly = cost_function(X_poly)
plt.plot(X_poly, y_poly)
plt.xlabel('Parameter value')
plt.ylabel('Cost function')
plt.title('Error surface')
结果图应如下所示:
图 4.3:成本函数图
注意
在之前的代码片段中,我们假设您已经导入了必要的库。您可以参考以下笔记本,获取包括前述代码片段导入语句的完整代码:packt.link/A4VyF。
查看 误差面(在 图 4.3 中),这是代价函数在一系列参数值上的图像,显而易见,哪个参数值将导致代价函数的最低值:x = 1。实际上,利用一些微积分,你可以通过将导数设置为零并求解 x,轻松确认 x = 1 是最小值。然而,通常来说,并不是所有问题都能如此简单地解决。在需要使用梯度下降的情况下,我们并不总是知道整个误差面的形状。相反,在我们选择了参数的初始猜测值之后,我们只能知道在该点周围区域内误差面的方向。
梯度下降是一种迭代算法;从初始猜测值开始,我们尝试找到一个新的猜测值,使代价函数降低,并继续进行,直到找到一个好的解决方案。我们试图在误差面上“下坡”,但我们只能根据当前猜测值附近的误差面形状知道该朝哪个方向移动以及在该方向上走多远。从数学角度看,我们只知道当前猜测值的参数处的 导数(在多维情况下称为 梯度)。如果你没有学习过微积分,可以把梯度理解为告诉你哪个方向是下坡,以及从你站立的地方山坡有多陡。我们利用这些信息在减少误差的方向上“迈出一步”。我们决定走多大的步伐取决于 学习率。由于梯度朝着误差减少的方向减小,我们希望朝梯度的负方向迈步。
这些概念可以通过以下方程进行形式化。为了从当前猜测值 xold 获得新猜测值 xnew,其中 f'(xold*)* 是当前猜测值处代价函数的导数(即梯度):
图 4.4:从当前猜测值获取新猜测值的方程
在下图中,我们可以看到从 x = 4.5 开始进行梯度下降过程的结果,学习率为 0.75,然后通过优化 x 使代价函数达到最小值:
图 4.5:梯度下降路径
梯度下降也适用于更高维的空间;换句话说,适用于多个参数。然而,你只能在单一图表中可视化最多二维的误差面(即在三维图中同时展示两个参数)。
在描述了梯度下降的工作原理后,让我们进行一个练习,实现梯度下降算法,并扩展本节的例子。
注意
用于生成本节所呈现图表的代码可以在这里找到:packt.link/NeF8P。如果你正在阅读本书的印刷版,你可以通过访问以下链接下载并浏览本章一些图像的彩色版本:packt.link/FAXBM
练习 4.01:使用梯度下降最小化代价函数
在本练习中,我们的任务是找到一组最佳参数,以最小化以下假设的代价函数:y = f(x) = x2 – 2x。为此,我们将采用前面部分描述的梯度下降方法。执行以下步骤以完成练习:
注意
在开始本练习之前,请确保你已执行了导入必要库和加载清理后的数据框架的先决步骤。有关这些步骤以及本练习的代码,你可以在packt.link/NeF8P找到。
-
创建一个返回代价函数值的函数,并查看在一系列参数下代价函数的值。你可以使用以下代码来做到这一点(注意,这部分代码重复了前面的部分):
X_poly = np.linspace(-3,5,81) print(X_poly[:5], '...', X_poly[-5:]) def cost_function(X): return X * (X-2) y_poly = cost_function(X_poly) plt.plot(X_poly, y_poly) plt.xlabel('Parameter value') plt.ylabel('Cost function') plt.title('Error surface')你将获得以下的代价函数图:
图 4.6:代价函数图
-
创建一个函数来求梯度值。这是代价函数的解析导数。使用此函数来计算在 x = 4.5 时的梯度,然后将其与学习率结合,找到梯度下降过程的下一步:
def gradient(X): return (2*X) - 2 x_start = 4.5 learning_rate = 0.75 x_next = x_start - gradient(x_start)*learning_rate x_next -0.75这是 x = 4.5 后的下一个梯度下降步骤。
-
使用以下代码绘制梯度下降路径,从起点到下一个点:
plt.plot(X_poly, y_poly) plt.plot([x_start, x_next], [cost_function(x_start), cost_function(x_next)], '-o') plt.xlabel('Parameter value') plt.ylabel('Cost function') plt.legend(['Error surface', 'Gradient descent path'])你将获得以下输出:
图 4.7:第一次梯度下降路径步骤
在这里,看起来我们似乎朝着正确的方向迈出了第一步。然而,很明显我们已经越过了我们想要到达的位置。可能是我们的学习率过大,因此我们采取了过大的步伐。虽然调节学习率是加速收敛到最优解的好方法,但在这个例子中,我们可以继续演示过程的其余部分。这里看起来我们可能还需要再迈几步。实际上,梯度下降会一直进行,直到步伐变得非常小,或者代价函数的变化变得非常小(你可以通过使用
tol参数在 scikit-learn 的逻辑回归中指定多小),这表示我们已经接近一个好的解——也就是max_iter。 -
通过使用以下代码片段执行 14 次迭代,以便向代价函数的局部最小值收敛(请注意,
iterations = 15,但在调用range()时不包括终点):iterations = 15 x_path = np.empty(iterations,) x_path[0] = x_start for iteration_count in range(1,iterations): derivative = gradient(x_path[iteration_count-1]) x_path[iteration_count] = x_path[iteration_count-1] \ - (derivative*learning_rate) x_path你将获得以下输出:
array([ 4.5 , -0.75 , 1.875 , 0.5625 , 1.21875 , 0.890625 , 1.0546875 , 0.97265625, 1.01367188, 0.99316406, 1.00341797, 0.99829102, 1.00085449, 0.99957275, 1.00021362])这个
for循环将连续的估计值存储在x_path数组中,使用当前估计值计算导数并找到下一个估计值。从梯度下降过程的结果值来看,我们似乎已经非常接近(1.00021362)最优解 1。 -
使用以下代码绘制梯度下降路径:
plt.plot(X_poly, y_poly) plt.plot(x_path, cost_function(x_path), '-o') plt.xlabel('Parameter value') plt.ylabel('Cost function') plt.legend(['Error surface', 'Gradient descent path'])你将获得以下输出:
图 4.8:梯度下降路径
我们鼓励你重复之前的过程,尝试不同的学习率,看看它们如何影响梯度下降路径。选择合适的学习率,可以非常快速地收敛到一个高度准确的解。虽然在不同的机器学习应用中,学习率的选择很重要,但对于逻辑回归来说,这个问题通常比较容易解决,在 scikit-learn 中你不需要特别选择学习率。
当你尝试不同的学习率时,是否注意到当学习率大于 1 时发生了什么?在这种情况下,我们朝着减少误差的方向迈出的步伐过大,实际上会导致更高的误差。这个问题可能会自我加剧,甚至导致梯度下降过程远离最小误差区域。另一方面,如果步长太小,找到理想的解可能需要非常长的时间。
逻辑回归的假设
由于它是一个经典的统计模型,类似于我们已经考察过的 F 检验和皮尔逊相关性,逻辑回归对数据有一些假设。虽然不必严格遵循每一个假设,但了解它们是很有帮助的。这样,如果逻辑回归模型表现不佳,你可以尝试调查并找出原因,利用你对逻辑回归所期望的理想情况的理解。你可能会在不同的资源中看到略有不同的假设列表,然而这里列出的假设是被广泛接受的。
特征在对数几率中是线性的
我们在上一章第三章中学习了这个假设,逻辑回归与特征探索的详细信息。逻辑回归是一个线性模型,所以只要特征能够有效描述对数几率中的线性趋势,它就能很好地工作。特别地,逻辑回归无法捕捉特征之间的交互作用、多项式特征或特征的离散化。你可以将这些指定为“新特征”——即使它们可能是由现有特征衍生出来的。
记住上一章提到的,从单变量特征探索中,PAY_1特征在对数几率中并不是线性的。
特征之间没有多重共线性
多重共线性意味着特征之间存在相关性。这个假设最严重的违反情况是特征之间完全相关,例如一个特征与另一个特征完全相同,或者一个特征等于另一个特征乘以常数。我们可以使用我们已经熟悉的相关性图来调查特征的相关性,这个图也在单变量特征选择中出现过。以下是上一章的相关性图:
图 4.9: 特征与响应的相关性图
我们可以从相关性图中看到完美相关的样子:由于每个特征和响应变量与其自身的相关性为 1,我们可以看到 1 的相关性是浅色的奶油色。从颜色条中,我们可以知道没有-1 的相关性。
注意
包含本节中代码和相应图表的 Jupyter 笔记本可以在此找到:packt.link/UOEMp。
在我们的案例研究数据中,最明显的相关预测变量是BILL_AMT特征。直观来看,账单在同一个账户的每个月可能会相似。例如,可能有一个账户通常保持零余额,或者有一个账户存在大量余额,且需要较长时间才能还清。BILL_AMT特征之间是否存在完全相关?从图 4.9来看,似乎没有。所以,虽然这些特征可能没有提供太多独立的信息,但我们目前不会出于担心多重共线性的原因而删除它们。
观察值的独立性
这是经典统计模型中的一个常见假设,包括线性回归。在这里,假设观察值(或样本)是独立的。这个假设在案例研究数据中是否合理?我们需要与客户确认,数据集中的同一个人是否可以拥有多个信用账户,并根据这种情况的普遍性来决定如何处理。假设我们已经被告知,在我们的数据中,每个信用账户都属于唯一的人,因此我们可以假设在这一点上观察值是独立的。
在不同的数据领域中,观察值独立性的一些常见违反情况如下:
-
空间自相关的观察值;例如,在自然现象中,如土壤类型,其中地理上彼此接近的观察值可能相似。
-
时间自相关的观察值,通常出现在时间序列数据中。在时间序列数据中,通常假设当前时刻的观察值与最近的时刻(们)相关。
然而,这些问题与我们的案例研究数据无关。
无异常值
异常值是指特征(或响应)的值与大多数数据的差异非常大,或者在其他方面有所不同。对于特征值的异常值,更恰当的术语是高杠杆点,因为“异常值”通常用于描述响应变量。然而,在我们的二分类问题中,不可能有响应变量的异常值,因为它只能取值 0 或 1。在实际应用中,您可能会看到这两个术语都用于描述特征。
为了理解为什么这些类型的点通常会对线性模型产生不利影响,请看这个包含 100 个点的合成线性数据以及由线性回归得到的最佳拟合线:
图 4.10:“表现良好”的线性数据和回归拟合
在这里,模型直观上看似与数据拟合得很好。然而,如果加入一个异常值特征值会怎样呢?为了说明这一点,我们添加了一个点,其 x 值与大多数观测值非常不同,而 y 值与其他观测值处于相似范围。然后,我们展示了结果回归线:
图 4.11:显示当包含异常值时会发生什么的图表
由于存在一个高杠杆点,所有数据的回归模型拟合不再很好地代表大部分数据。这展示了单个数据点对线性模型的潜在影响,特别是当该点似乎与其余数据的趋势不一致时。
处理异常值有很多方法。但一个更根本的问题是:“这样的数据现实吗?”如果数据看起来不太对,可以询问客户这些异常值是否可信。如果不可信,应该将它们排除。但如果它们代表有效的数据,则应使用非线性模型或其他方法。
在我们的案例研究数据中,在特征探索过程中绘制的直方图中并没有观察到异常值。因此,我们没有这个顾虑。
你应该包含多少个特征?
这不完全是一个假设,更像是构建模型的指导原则。没有明确的定律说明在逻辑回归模型中应该包含多少个特征。然而,一个常见的经验法则是“10 的法则”,即每出现 10 次最稀有的结果类别,就可以在模型中添加 1 个特征。例如,在一个包含 100 个样本的二分类逻辑回归问题中,如果类别平衡是 20%的正样本和 80%的负样本,那么正样本总数只有 20 个,因此模型中应该仅使用 2 个特征。此外,还建议采用“20 的法则”,它对包含的特征数量设定了更严格的限制(在我们的例子中为 1 个特征)。
另一个需要考虑的点是,对于二进制特征(例如由独热编码产生的特征),即该特征有多少样本会有正值。如果该特征非常不平衡,换句话说,包含 1 或 0 的样本非常少,那么将其纳入模型可能没有意义。
对于案例研究数据,我们很幸运拥有相对较多的样本和较为平衡的特征,因此这些问题并不显著。
注意
本节中呈现的绘图代码可以在此处找到:packt.link/SnX3y。
正则化的动机:偏差-方差权衡
我们可以通过使用一种强大的概念——收缩或正则化,来扩展我们所学的基本逻辑回归模型。实际上,到目前为止,您在 scikit-learn 中拟合的每一个逻辑回归模型都使用了一定量的正则化。这是因为正则化是逻辑回归模型对象中的默认选项。不过,直到现在,我们一直忽视了它。
当你对这些概念有更深入的了解时,你还会熟悉一些机器学习中的基础概念:过拟合、欠拟合和偏差-方差权衡。如果一个模型在训练数据上的表现(例如,ROC AUC)远远好于在保留的测试集上的表现,那么这个模型被认为是对训练数据进行了过拟合。换句话说,在训练集上的良好表现并不能推广到未见过的测试集。我们在第二章,Scikit-Learn 简介与模型评估中开始讨论这些概念,当时我们区分了模型训练分数和测试分数。
当一个模型对训练数据发生过拟合时,它被认为具有较高的方差。换句话说,训练数据中存在的任何变异性,模型都学得非常好——实际上,学得太好了。这将在较高的训练得分中得到体现。然而,当这样的模型用于对新的、未见过的数据进行预测时,其表现较差。以下情况下,过拟合的可能性更大:
-
可用的特征数量相较于样本数量非常庞大。尤其是,可能存在如此多的特征,以至于直接检查所有特征变得繁琐,就像我们在案例研究数据中能够做到的那样。
-
使用了更复杂的模型,即比逻辑回归更复杂的模型。这些包括梯度提升集成模型或神经网络等。
在这种情况下,模型有机会在模型拟合过程中开发出关于特征与响应变量之间关系的更复杂的假设,从而使过拟合的可能性增加。
相反,如果一个模型无法很好地拟合训练数据,这就是所谓的欠拟合,模型被认为具有较高的偏差。
我们可以通过在一些假设数据上拟合多项式模型,来检查欠拟合、过拟合和理想模型之间的区别:
图 4.12:包含欠拟合、过拟合和理想模型的二次数据
在图 4.12中,我们可以看到,包含过少特征(在这种情况下,是仅有两个特征的y线性模型,一个斜率和一个截距)显然不是对数据的良好表示。这被称为欠拟合模型。然而,如果我们包含过多特征,即许多高次多项式项,比如x²、x³、x⁴、…… x¹⁰,虽然可以几乎完美地拟合训练数据,但这不一定是好事。当我们观察过拟合模型在训练数据点之间的结果时,尤其是在可能需要进行新预测的地方,我们可以看到模型不稳定,并且可能无法为未出现在训练集中的数据提供可靠的预测。我们仅凭对特征与响应变量之间关系的直观理解,就能看出这一点,这种理解来自于对数据的可视化。
注意
生成本节中展示的图表的代码可以在此找到:packt.link/SnX3y。
本例中的合成数据是通过二次(即二次方)多项式生成的。知道这一点后,我们可以通过将二次多项式拟合到训练数据上,轻松找到理想模型,如图 4.12所示。
然而,通常情况下,我们无法提前知道理想模型的公式。因此,我们需要通过比较训练和测试得分,来评估模型是否存在过拟合或欠拟合的情况。
在某些情况下,引入一些偏差到模型训练过程中是可取的,特别是当这样做可以减少过拟合,并提高模型在新数据(即未见过的数据)上的表现时。通过这种方式,可能可以利用偏差-方差权衡来改善模型。我们可以使用正则化方法来实现这一点。此外,我们也可以将这些方法用于变量选择,作为建模过程的一部分。使用预测模型来选择变量,是我们之前探讨的单变量特征选择方法的替代方案。在接下来的练习中,我们将开始实验这些概念。
练习 4.02:生成和建模合成分类数据
在本练习中,我们将通过使用合成数据集来观察过拟合现象。假设你现在面临一个二分类数据集,包含许多候选特征(200 个),而你没有时间逐一检查它们。可能其中一些特征是高度相关的,或者以其他方式相互关联。然而,特征的数量如此之多,可能会使得有效地探索每个特征变得困难。此外,数据集的样本数量相对较少:只有 1,000 个样本。我们将通过使用 scikit-learn 提供的一个功能来生成这个具有挑战性的数据集,该功能允许你创建合成数据集,用于进行此类概念性探索。请按照以下步骤完成练习:
注意
在开始本练习之前,请确保你已经执行了导入必要库的前提步骤。这些步骤及本练习的代码可以在 packt.link/mIMsT 找到。
-
使用以下代码导入
make_classification、train_test_split、LogisticRegression和roc_auc_score类:from sklearn.datasets import make_classification from sklearn.model_selection import train_test_split from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score请注意,我们从 scikit-learn 导入了几个熟悉的类,另外还导入了一个我们之前没有见过的新类:
make_classification。这个类的功能正如其名所示——它用于生成分类问题的数据。通过使用各种关键字参数,你可以指定要包含多少样本和特征,以及响应变量将有多少个类别。还有一系列其他选项,可以有效控制问题的“难易程度”。注意
更多信息,请参考
scikit-learn.org/stable/modules/generated/sklearn.datasets.make_classification.html。简单来说,我们在这里选择了使问题相对容易解决的选项,但也加入了一些复杂因素。换句话说,我们期望模型表现良好,但我们需要付出一点努力才能实现这一点。 -
生成一个包含两个变量的数据集,
x_synthetic和y_synthetic。x_synthetic包含 200 个候选特征,y_synthetic包含响应变量,每个包含 1,000 个样本。使用以下代码:X_synthetic, y_synthetic = make_classification( n_samples=1000, n_features=200, n_informative=3, n_redundant=10, n_repeated=0, n_classes=2, n_clusters_per_class=2, weights=None, flip_y=0.01, class_sep=0.8, hypercube=True, shift=0.0, scale=1.0, shuffle=True, random_state=24) -
使用以下代码检查数据集的形状以及响应变量的类别比例:
print(X_synthetic.shape, y_synthetic.shape) print(np.mean(y_synthetic))你将获得以下输出:
(1000, 200) (1000,) 0.501检查输出形状后,注意到我们生成了一个几乎完美平衡的数据集:类别平衡接近 50/50。还需要注意的是,我们已生成所有特征,使它们具有相同的
shift和scale——即均值为 0,标准差为 1。确保特征在相同的尺度上,或者说具有大致相同的取值范围,是使用正则化方法的关键点——稍后我们将看到为什么。如果原始数据集中的特征尺度差异较大,建议对其进行归一化,以确保它们处于相同的尺度上。Scikit-learn 提供了简便的方法来实现这一点,我们将在本章末的活动中学习。 -
使用以下代码将前几个特征绘制为直方图,以显示它们的取值范围相同:
for plot_index in range(4): plt.subplot(2, 2, plot_index+1) plt.hist(X_synthetic[:, plot_index]) plt.title('Histogram for feature {}'.format(plot_index+1)) plt.tight_layout()您将得到以下输出:
图 4.13:200 个合成特征中的前 4 个特征的直方图
由于我们生成了这个数据集,因此无需直接检查所有 200 个特征来确保它们在相同的尺度上。那么,这个数据集可能存在哪些问题呢?由于响应变量的类别比例已经平衡,因此我们无需进行欠采样、过采样或使用其他对不平衡数据有帮助的方法。那么特征之间以及特征与响应变量之间的关系呢?这些关系有很多,直接调查它们是一个挑战。根据我们的经验法则(即每 10 个稀有类别样本对应 1 个特征),200 个特征过多。我们在最稀有类别中有 500 个观察值,所以根据这个规则,我们不应该有超过 50 个特征。特征数量过多可能会导致模型训练过程过拟合。接下来,我们将开始学习如何在 scikit-learn 的逻辑回归中使用选项来防止这种情况发生。
-
使用 80/20 的比例将数据拆分为训练集和测试集,然后使用以下代码实例化一个逻辑回归模型对象:
X_syn_train, X_syn_test, y_syn_train, y_syn_test = \ train_test_split(X_synthetic, y_synthetic,\ test_size=0.2, random_state=24) lr_syn = LogisticRegression(solver='liblinear', penalty='l1', C=1000, random_state=1) lr_syn.fit(X_syn_train, y_syn_train)请注意,我们在逻辑回归模型中指定了一些新的选项,这是我们之前未关注的。首先,我们将
penalty参数设置为l1。这意味着我们将使用C参数,值为 1,000。根据 scikit-learn 文档(scikit-learn.org/stable/modules/generated/sklearn.linear_model.LogisticRegression.html),C是“正则化强度的倒数”。这意味着较大的C值对应较少的正则化。通过选择相对较大的数值,如 1,000,我们使用的是相对较少的正则化。C的默认值是 1。所以,我们这里实际上并没有使用太多正则化,而只是熟悉如何使用这些选项。最后,我们使用liblinear求解器,这是我们以前使用过的。尽管我们这里使用的是经过缩放的数据(所有特征的均值为 0,标准差为 1),但值得注意的是,在我们可用的各种求解器选项中,
liblinear是“对未缩放数据具有鲁棒性的”。另外要注意的是,liblinear是唯一支持 L1 惩罚的两个求解器选项之一,另一个选项是saga。注意
您可以在
scikit-learn.org/stable/modules/linear_model.html#logistic-regression上了解更多关于可用求解器的信息。 -
使用以下代码在训练数据上拟合逻辑回归模型:
lr_syn.fit(X_syn_train, y_syn_train)这是输出结果:
LogisticRegression(C=1000, penalty='l1', random_state=1, \ solver='liblinear') -
使用以下代码计算训练得分,首先获取预测概率,然后得到 ROC AUC:
y_syn_train_predict_proba = lr_syn.predict_proba(X_syn_train) roc_auc_score(y_syn_train, y_syn_train_predict_proba[:,1])输出应如下所示:
0.9420000000000001 -
使用与计算训练得分相似的方法计算测试得分:
y_syn_test_predict_proba = lr_syn.predict_proba(X_syn_test) roc_auc_score(y_syn_test, y_syn_test_predict_proba[:,1])输出应如下所示:
0.8075807580758075从这些结果来看,很明显,逻辑回归模型已经过拟合数据。也就是说,训练数据上的 ROC AUC 得分远高于测试数据上的得分。
Lasso (L1) 和 Ridge (L2) 正则化
在将正则化应用于逻辑回归模型之前,我们先花点时间理解什么是正则化以及它是如何工作的。在 scikit-learn 中,正则化逻辑回归模型的两种方式分别叫做penalty = 'l1'或'l2'。它们被称为“惩罚”,因为正则化的作用是增加惩罚或成本,以防止逻辑回归模型中系数的值过大。
正如我们已经学到的,逻辑回归模型中的系数描述了响应变量的对数几率与每个特征之间的关系。因此,如果某个系数值特别大,那么该特征的微小变化将在预测中产生较大的影响。当模型正在拟合并学习特征与响应变量之间的关系时,模型可能开始学习数据中的噪声。我们之前在图 4.12中看到过这一点:如果在拟合模型时可用的特征很多,并且没有对它们系数值施加限制,那么模型拟合过程可能会试图发现特征与响应变量之间的关系,这些关系无法推广到新数据。这样,模型就会变得更适应现实世界中不完美数据中的不可预测的随机噪声。不幸的是,这只会提高模型对训练数据的预测能力,而这并不是我们的最终目标。因此,我们应该努力从模型中剔除这些虚假的关系。
Lasso 和岭回归正则化使用不同的数学公式来实现这一目标。这些方法通过对模型拟合时使用的成本函数进行修改来工作,我们之前介绍过这个函数是对数损失函数。Lasso 正则化使用的是所谓的1-范数(因此也叫 L1):
图 4.14:带 Lasso 惩罚的对数损失方程
1-范数,即图 4.14中方程的第一项,实际上是* m 个不同特征系数绝对值的和。使用绝对值是因为无论系数是正向还是负向过大,都可能导致过拟合。那么,这个成本函数与我们之前看到的对数损失函数相比,有什么不同呢?嗯,现在有一个C*因子,它乘以了对数损失函数前面分数的部分。
这是“正则化强度的倒数”,正如 scikit-learn 文档中所描述的(scikit-learn.org/stable/modules/generated/sklearn.linear_model.LogisticRegression.html)。由于这个因子位于计算预测误差的成本函数项前面,而不是正则化项前面,因此增大它会让预测误差在成本函数中变得更加重要,而正则化则变得不那么重要。简而言之,在 scikit-learn 实现中,C 值越大,正则化越少。
L2 或岭回归正则化类似于 L1 正则化,不同之处在于,岭回归使用的是系数的平方和,而不是绝对值之和,这个平方和被称为2-范数:
图 4.15:带脊回归惩罚的对数损失方程
请注意,如果你查看 scikit-learn 文档中的逻辑回归成本函数,具体形式与这里使用的不同,但总体思路是相似的。此外,在你熟悉了套索(lasso)和脊回归(ridge)惩罚的概念之后,你应该知道还有一种叫做 弹性网(elastic-net) 的额外正则化方法,它是套索和脊回归的结合。
为什么正则化有两种不同的公式?
可能其中一个方法会提供更好的样本外表现,因此你可能希望同时测试这两种方法。这些方法之间还有另一个关键差异:L1 惩罚除了执行正则化外,还进行特征选择。它通过在正则化过程中将某些系数值设置为零,从而有效地从模型中去除这些特征。L2 正则化则是将系数值变小,但不会完全消除它们。并非所有的求解器选项都支持 L1 和 L2 正则化,因此你需要为你想使用的正则化技术选择合适的求解器。
注意
为什么 L1 正则化会去除特征,而 L2 不会的数学原理超出了本书的范围。然而,关于这个话题的更深入解释以及进一步的阅读,我们推荐一本非常易读(且免费的)资源——Gareth James 等人编著的《统计学习导论》。特别是,参见修订版第七印刷的 第 222 页,上面有一幅有助于理解 L1 和 L2 正则化差异的图示。
截距和正则化
我们并没有过多讨论截距,除了提到我们已经通过线性模型估计了它们,以及与每个特征相关的系数。那么,应该使用截距吗?答案可能是肯定的,直到你对线性模型有了更深入的理解,并确信在特定情况下不需要使用它。然而,确实存在这样的情况,例如在一个特征和响应变量都已归一化为零均值的线性回归模型中。
截距与任何特定特征无关。因此,对其进行正则化没有太大意义,因为它不应该有助于过拟合。请注意,在 L1 的正则化惩罚项中,求和从 j = 1 开始,同样在 L2 中,我们跳过了 σ0,这就是截距项。
这是理想的情况:不对截距进行正则化。然而,scikit-learn 中的一些求解器,如 liblinear,实际上会对截距进行正则化。你可以通过提供一个 intercept_scaling 选项来对抗这一效应。我们在这里没有展示这一点,因为虽然从理论上讲,这样做是不正确的,但在实践中,正则化截距通常对模型的预测质量影响不大。
缩放与正则化
如前一个练习所述,最佳实践是LIMIT_BAL在我们的数据集中远大于其他特征,比如PAY_1,实际上,可能希望为PAY_1的系数赋予较大的值,而为LIMIT_BAL的系数赋予较小的值,从而使它们在特征和系数的线性组合中对模型预测的影响处于相同的尺度。通过在使用正则化之前对所有特征进行标准化,可以避免因尺度差异而引发的此类复杂问题。
事实上,缩放数据可能也是必要的,这取决于你使用的求解器。scikit-learn 中可用的不同梯度下降变体可能无法有效处理未缩放的数据。
选择合适求解器的重要性
如我们所了解的,scikit-learn 中可用的不同逻辑回归求解器在以下方面有不同的表现:
-
它们是否支持 L1 和 L2 正则化
-
它们如何在正则化过程中处理截距
-
它们如何处理未缩放的数据
注意
还有其他的区别。一个有用的表格比较了这些和其他特性,可以参考
scikit-learn.org/stable/modules/linear_model.html#logistic-regression。你可以使用这个表格来决定哪个求解器最适合你的问题。
总结这一部分内容,我们学习了 lasso 和 ridge 正则化的数学基础。这些方法通过将系数值收缩到接近 0 来工作,在 lasso 的情况下,还会将某些系数精确地设为 0,从而执行特征选择。你可以想象,在我们图 4.12中的过拟合例子中,如果复杂的过拟合模型将一些系数收缩到接近 0,它将更像理想模型,而理想模型的系数较少。
这里是一个正则化回归模型的图示,使用与过拟合模型相同的高阶多项式特征,但加上了脊岭惩罚:
图 4.16:一个过拟合模型和使用相同特征的正则化模型
正则化后的模型看起来类似于理想模型,展示了正则化纠正过拟合的能力。然而,需要注意的是,正则化模型不应推荐用于外推。在此,我们可以看到正则化模型在图 4.16的右侧开始增加。这个增加应该被视为可疑,因为训练数据中没有任何迹象表明这是可以预期的。这是不推荐对超出训练数据范围的模型预测进行外推的一般观点的一个例子。然而,从图 4.16可以清楚地看到,即使我们没有关于生成这些合成数据的模型的知识(因为在现实世界的预测建模工作中,我们通常没有数据生成过程的知识),我们仍然可以使用正则化来减少在有大量候选特征时的过拟合影响。
模型与特征选择
L1 正则化是一种使用模型(如逻辑回归)进行特征选择的方法。其他方法包括从候选特征池中进行前向或后向逐步选择。这些方法背后的高层次思想如下:在前向选择的情况下,特征一个一个地添加到模型中,并观察外样本性能的变化。在每次迭代时,都会考虑将所有候选池中的特征添加到模型中,并选择能够最大化外样本性能提升的特征。当添加更多特征不再改善模型性能时,就不需要再从候选特征中添加更多特征。在后向选择的情况下,首先从模型中开始使用所有特征,并确定应该删除哪个特征:删除后对外样本性能影响最小的特征。你可以继续按这种方式删除特征,直到性能开始显著下降。
注意
本节中展示的生成图表的代码可以在此找到:packt.link/aUBMb。
交叉验证:选择正则化参数
到目前为止,你可能会怀疑我们是否可以使用正则化来减少在尝试对练习 4.02中的合成数据建模时观察到的过拟合现象,生成与建模合成分类数据。问题是,我们该如何选择正则化参数C呢?C是一个模型的超参数示例。超参数与在训练模型时估计的参数不同,例如逻辑回归的系数和截距。超参数不像参数那样通过自动化程序估计,而是由用户直接输入作为关键字参数,通常在实例化模型类时进行输入。那么,我们如何知道应该选择什么值呢?
超参数比参数更难估算。这是因为数据科学家需要决定最佳值,而不是让优化算法来寻找它。然而,程序化选择超参数值是可能的,这可以被视为一种优化过程。从实际角度看,在正则化参数C的情况下,最常见的做法是,使用特定的C值在一组数据上拟合模型,确定模型的训练性能,然后在另一组数据上评估out-of-sample性能。
我们已经熟悉使用模型训练集和测试集的概念。然而,这里有一个关键的区别;例如,如果我们多次使用测试集,以查看不同C值的效果,会发生什么?
你可能会想到,在第一次使用未见过的测试集来评估特定值的out-of-sample性能后,它就不再是“未见过”的测试集了。虽然在估算模型参数(即系数和截距)时仅使用了训练数据,但现在测试数据被用来估算超参数C。实际上,测试数据已经变成了额外的训练数据,因为它用于寻找超参数的最佳值。
因此,通常将数据分为三部分:训练集、测试集和验证集。验证集有多个用途:
估算超参数
验证集可以反复使用,以评估不同超参数值的out-of-sample性能,从而选择超参数。
不同模型的比较
除了为模型找到超参数值外,验证集还可以用来估算不同模型的out-of-sample性能;例如,如果我们想将逻辑回归与随机森林进行比较。
注意
数据管理最佳实践
作为数据科学家,如何划分数据以进行不同的预测建模任务是你的责任。在理想情况下,你应该保留一部分数据用于流程的最后阶段,即在你已经选择了模型超参数并确定了最佳模型之后。这未见过的测试集被保留到最后一步,可以用来评估你模型构建工作的最终结果,查看最终模型如何泛化到新的未见数据。在保留测试集时,最好确保特征和响应的特性与其余数据相似。换句话说,类别比例应该相同,特征的分布应该相似。这样,测试数据就能代表你用来构建模型的数据。
虽然模型验证是一个好习惯,但它引发了一个问题:我们为训练集、验证集和测试集选择的特定拆分,是否对我们跟踪的结果有任何影响。例如,也许特征和响应变量之间的关系在我们保留的未见测试集或验证集与训练集之间略有不同。要消除所有此类变异几乎是不可能的,但我们可以使用交叉验证的方法,以避免对某一特定数据拆分过度依赖。
Scikit-learn 提供了便捷的函数来促进交叉验证分析。这些函数与我们已经使用的 train_test_split 起到类似的作用,尽管默认行为有些不同。现在让我们来熟悉它们。首先,导入这两个类:
from sklearn.model_selection import StratifiedKFold
from sklearn.model_selection import KFold
类似于 train_test_split,我们需要指定数据集用于训练和测试的比例。然而,在交叉验证中(特别是我们刚刚导入的类中实现的k 折交叉验证),我们不直接指定比例,而是简单地指明我们希望有多少个折叠——即“k 折”。这里的想法是,数据将被分成k个相等的部分。例如,如果我们指定 4 个折叠,那么每个折叠将包含 25%的数据。这些折叠将在四个单独的模型训练实例中作为测试数据,而每个折叠的其余 75%将用于训练模型。在此过程中,每个数据点总共会作为训练数据使用 k - 1 次,而仅作为测试数据使用一次。
在实例化该类时,我们指定了折数、是否在拆分数据前进行洗牌,以及是否设置随机种子,以确保在不同运行中得到可重复的结果:
n_folds = 4
k_folds = KFold(n_splits=n_folds, shuffle=False)
这里,我们实例化了一个具有四个折叠且没有洗牌的对象。我们使用返回的对象(我们称之为 k_folds)的方法是将我们希望用于交叉验证的特征数据和响应数据传递给该对象的 .split 方法。这会输出 X_syn_train 和 y_syn_train,我们可以像这样遍历这些拆分:
for train_index, test_index in k_folds_iterator.split(X_syn_train,
y_syn_train):
迭代器将返回 X_syn_train 和 y_syn_train 的行索引,我们可以用这些索引来获取数据。在这个 for 循环内部,我们可以编写代码,使用这些索引反复选择数据进行模型训练和测试,使用不同的数据子集。通过这种方式,我们可以获得一个稳健的模型外表现指标,当使用某一特定超参数值时,然后重复整个过程,使用另一个超参数值。因此,交叉验证循环可能会嵌套在一个外部的超参数值循环中。我们将在下面的练习中演示这一点。
不过,首先,这些拆分看起来是什么样子的?如果我们只是将train_index和test_index的索引用不同的颜色绘制出来,我们将得到如下图所示的效果:
图 4.17:没有打乱的四折 k 折训练/测试拆分
在这里,我们可以看到,按照我们为KFold类指定的选项,程序简单地将数据的前 25%(按行的顺序)作为第一个测试折,然后将下一个 25%的数据作为第二个折,依此类推。但如果我们想要分层抽样折呢?换句话说,如果我们希望确保每个折中的响应变量的类别比例相等呢?虽然train_test_split允许通过关键字参数实现这个选项,但有一个独立的StratifiedKFold类,它为交叉验证实现了这个功能。我们可以通过以下方式来说明分层拆分的效果:
k_folds = StratifiedKFold(n_splits=n_folds, shuffle=False)
图 4.18:分层 k 折训练/测试拆分
在图 4.18中,我们可以看到不同折之间已经进行了一定程度的“打乱”。程序根据需要在折之间移动样本,以确保每个折中的类别比例相等。
那么,如果我们想要将数据打乱,以便从每个测试折中选择整个索引范围的样本,该怎么办呢?首先,为什么我们想这么做?嗯,对于我们为这个问题创建的合成数据,我们可以确定数据是没有特定顺序的。然而,在许多实际情况下,我们收到的数据可能以某种方式进行了排序。
例如,数据的行可能是按账户创建日期排序的,或按其他逻辑排序的。因此,在拆分数据之前先打乱数据可能是个好主意。这样,任何可能被用于排序的特征,应该在每个折中都能保持一致。否则,不同折中的数据可能会有不同的特征,可能导致特征与响应之间的关系不同。
这可能导致模型在不同折之间的表现不均衡。为了在数据集的所有行索引中“打乱”折,只需要将shuffle参数设置为True:
k_folds = StratifiedKFold(n_splits=n_folds, shuffle=True,
random_state=1)
图 4.19:带有打乱的分层 k 折训练/测试拆分
通过打乱,测试折会随机且均匀地分布在输入数据的索引上。
K 折交叉验证是数据科学中广泛使用的一种方法。然而,选择使用多少折数取决于手头的特定数据集。使用较小的折数意味着每个折中的训练数据量相对较小。因此,这增加了模型过拟合的机会,因为模型通常在更多数据的训练下效果更好。建议尝试几种不同的折数,看看 k 折测试分数的均值和变异性如何变化。常见的折数范围通常是从 4 或 5 到 10。
在数据集非常小的情况下,可能需要在交叉验证折中尽可能多地使用数据进行训练。在这种情况下,可以使用一种叫做留一法交叉验证(LOOCV)的方法。在 LOOCV 中,每个折的测试集由一个单一的样本组成。换句话说,折数将与训练数据中的样本数量相同。在每次迭代中,模型会在除一个样本外的所有样本上进行训练,并对该样本进行预测。然后,可以根据这些预测来构建准确度或其他性能指标。
与创建测试集相关的其他问题,如为那些需要使用过去的观测值来预测未来事件的问题选择超时测试集,也同样适用于交叉验证。
在练习 4.02,生成和建模合成分类数据中,我们看到对训练数据拟合逻辑回归导致了过拟合。事实上,测试分数(ROC AUC = 0.81)明显低于训练分数(ROC AUC = 0.94)。我们实际上使用了非常少或没有正则化,因为我们将正则化参数C设置为一个相对较大的值(1,000)。现在我们将看到当我们在一个较宽的范围内调整C时会发生什么。
注意
本节中呈现的生成图形的代码可以在这里找到:packt.link/37Zks。
练习 4.03:减少合成数据分类问题中的过拟合
本练习是练习 4.02,生成和建模合成分类数据的延续。在这里,我们将使用交叉验证程序来找到超参数C的一个合适值。我们将通过仅使用训练数据来完成此任务,将测试数据保留到模型构建完成后再使用。请做好准备——这将是一个较长的练习——但它将展示一个通用过程,您可以将其应用于许多不同类型的机器学习模型,因此,花费时间完成它是非常值得的。按照以下步骤完成此练习:
注意
在开始此练习之前,您需要执行一些先决步骤,这些步骤可以在以下笔记本中找到,并附有此练习的代码:packt.link/JqbsW。
-
调整正则化参数 C 的值,使其范围从 C = 1000 到 C = 0.001。你可以使用以下代码片段来实现这一点。
首先,定义指数,它们将是 10 的幂次方,如下所示:
C_val_exponents = np.linspace(3,-3,13) C_val_exponents以下是前面代码的输出:
array([ 3\. , 2.5, 2\. , 1.5, 1\. , 0.5, 0\. , -0.5, -1\. , -1.5, -2\. , -2.5, -3\. ])现在,按 10 的幂次方调整 C 值,如下所示:
C_vals = np.float(10)**C_val_exponents C_vals以下是前面代码的输出:
array([1.00000000e+03, 3.16227766e+02, 1.00000000e+02, 3.16227766e+01, 1.00000000e+01, 3.16227766e+00, 1.00000000e+00, 3.16227766e-01, 1.00000000e-01, 3.16227766e-02, 1.00000000e-02, 3.16227766e-03, 1.00000000e-03])通常,最好通过 10 的幂次方来调整正则化参数,或者使用类似的策略,因为训练模型可能需要大量时间,特别是在使用 k 折交叉验证时。这能让你更好地了解不同的C值如何影响偏差-方差权衡,而无需训练大量模型。除了 10 的整数次方,我们还包括 log10 坐标轴上大约位于中间的点。如果在这些相对间隔较大的值之间似乎有一些有趣的行为,你可以在可能值的较小范围内添加更多细化的 C 值。
-
导入
roc_curve类:from sklearn.metrics import roc_curve我们将继续使用 ROC AUC 分数来评估、训练和测试性能。现在,我们有几个不同的 C 值要尝试,并且有多个折(在这个例子中是四个)进行交叉验证,我们将需要存储每个折和每个 C 值对应的训练和测试分数。
-
定义一个函数,该函数接受
k_folds交叉验证分割器、C 值数组(C_vals)、模型对象(model)、特征和响应变量(X和Y)作为输入,以通过 k 折交叉验证探索不同的正则化量。使用以下代码:def cross_val_C_search(k_folds, C_vals, model, X, Y):注意
我们在此步骤中开始的函数将返回 ROC AUC 和 ROC 曲线数据。返回块将在后续步骤中编写。现在,你可以按照原样编写上述代码,因为我们将在练习过程中定义
k_folds、C_vals、model、X和Y。 -
在这个函数块内,创建一个 NumPy 数组来保存模型性能数据,数组的维度为
n_folds×len(C_vals):n_folds = k_folds.n_splits cv_train_roc_auc = np.empty((n_folds, len(C_vals))) cv_test_roc_auc = np.empty((n_folds, len(C_vals)))接下来,我们将把与每个测试 ROC AUC 分数相关联的真正阳性率、假阳性率和阈值存储在一个列表的列表中。
注意
这是存储所有模型性能信息的一种方便方式,因为 Python 中的列表可以包含任何类型的数据,包括另一个列表。在这里,列表的列表中的每个内层列表项将是一个元组,包含每个折叠的 TPR、FPR 和阈值数组,对于每个C值。元组是 Python 中的有序集合数据类型,类似于列表,但与列表不同的是它们是不可变的:一旦元组创建,元组中的项不能更改。当一个函数返回多个值时,像 scikit-learn 的 roc_curve 函数,这些值可以输出到一个单一的变量中,这个变量将是一个包含这些值的元组。这种存储结果的方式,在我们稍后访问这些数组以进行检查时,应该更为明显。
-
使用
[[]]和*len(C_vals)创建一个空列表,如下所示:cv_test_roc = [[]]*len(C_vals)使用
*len(C_vals)表示每个C值应该有一个包含指标(TPR、FPR、阈值)元组的列表。我们已经在前一节中学习了如何在交叉验证中遍历不同的折叠。接下来我们需要做的是编写一个外部循环,其中嵌套交叉验证循环。
-
为每个C值创建一个外部循环来训练和测试每个 k 折:
for c_val_counter in range(len(C_vals)): #Set the C value for the model object model.C = C_vals[c_val_counter] #Count folds for each value of C fold_counter = 0我们可以重用已经有的相同模型对象,并在每次循环中设置一个新的C值。在C值的循环中,我们运行交叉验证循环。我们从为每个拆分生成训练和测试数据的行索引开始。
-
获取每个折叠的训练和测试索引:
for train_index, test_index in k_folds.split(X, Y): -
使用以下代码索引特征和响应变量,以获取该折叠的训练和测试数据:
X_cv_train, X_cv_test = X[train_index], X[test_index] y_cv_train, y_cv_test = Y[train_index], Y[test_index]然后使用当前折叠的训练数据来训练模型。
-
在训练数据上拟合模型,如下所示:
model.fit(X_cv_train, y_cv_train)这将有效地“重置”模型,从之前的系数和截距中恢复,反映出在这组新数据上的训练。
然后获得训练和测试的 ROC AUC 分数,以及与测试数据相关的 TPR、FPR 和阈值数组。
-
获取训练 ROC AUC 分数:
y_cv_train_predict_proba = model.predict_proba(X_cv_train) cv_train_roc_auc[fold_counter, c_val_counter] = \ roc_auc_score(y_cv_train, y_cv_train_predict_proba[:,1]) -
获取测试 ROC AUC 分数:
y_cv_test_predict_proba = model.predict_proba(X_cv_test) cv_test_roc_auc[fold_counter, c_val_counter] = \ roc_auc_score(y_cv_test, y_cv_test_predict_proba[:,1]) -
使用以下代码获取每个折叠的测试 ROC 曲线:
this_fold_roc = roc_curve(y_cv_test, y_cv_test_predict_proba[:,1]) cv_test_roc[c_val_counter].append(this_fold_roc)我们将使用一个折叠计数器来跟踪递增的折叠,在交叉验证循环之外,打印状态更新到标准输出。每当执行长时间的计算过程时,定期打印作业的状态是个好主意,这样你可以监控进展并确认一切正常工作。这个交叉验证过程在你的笔记本电脑上可能只需要几秒钟,但对于较长的任务,这样做尤其令人放心。
-
使用以下代码递增折叠计数器:
fold_counter += 1 -
编写以下代码以显示每个C值的执行进度:
print('Done with C = {}'.format(lr_syn.C)) -
编写代码以返回 ROC AUC 和 ROC 曲线数据并完成函数:
return cv_train_roc_auc, cv_test_roc_auc, cv_test_roc请注意,我们将继续使用之前展示的四折拆分,但鼓励你尝试使用不同数量的折来比较效果。
我们在前面的步骤中已经覆盖了很多内容。你可能想花几分钟时间和你的同学一起复习一下,以确保你理解每个部分。运行这个函数相对简单。这就是设计良好的函数的魅力——所有复杂的部分都被抽象化了,允许你专注于如何使用它。
-
运行我们设计的函数来检查交叉验证的性能,使用我们之前定义的C值,并使用我们在上一个练习中使用的模型和数据。使用以下代码:
cv_train_roc_auc, cv_test_roc_auc, cv_test_roc = \ cross_val_C_search(k_folds, C_vals, lr_syn, X_syn_train, y_syn_train)当你运行此代码时,你应该会看到以下输出,随着每个C值的交叉验证完成,输出会出现在代码单元格下方:
Done with C = 1000.0 Done with C = 316.22776601683796 Done with C = 100.0 Done with C = 31.622776601683793 Done with C = 10.0 Done with C = 3.1622776601683795 Done with C = 1.0 Done with C = 0.31622776601683794 Done with C = 0.1 Done with C = 0.03162277660168379 Done with C = 0.01 Done with C = 0.0031622776601683794 Done with C = 0.001那么,交叉验证的结果是什么样的呢?有几种方法可以查看这个结果。单独查看每一折的性能是很有用的,这样你可以看到结果的变化程度。
这告诉你数据的不同子集作为测试集的表现,从而大致了解你可以从未见过的测试集期望的表现范围。我们在这里感兴趣的是,是否能够通过正则化来缓解我们所看到的过拟合问题。我们知道使用C = 1,000导致了过拟合——我们通过比较训练和测试分数得知这一点。但对于我们尝试的其他C值呢?一个很好的可视化方法是将训练和测试分数绘制在y 轴上,将C值绘制在x 轴上。
-
使用以下代码,循环遍历每一折,以单独查看它们的结果:
for this_fold in range(k_folds.n_splits): plt.plot(C_val_exponents, cv_train_roc_auc[this_fold], '-o',\ color=cmap(this_fold),\ label='Training fold {}'.format(this_fold+1)) plt.plot(C_val_exponents, cv_test_roc_auc[this_fold], '-x',\ color=cmap(this_fold),\ label='Testing fold {}'.format(this_fold+1)) plt.ylabel('ROC AUC') plt.xlabel('log$_{10}$(C)') plt.legend(loc = [1.1, 0.2]) plt.title('Cross validation scores for each fold')你将获得以下输出:
图 4.20:每一折和 C 值的训练和测试得分
我们可以看到,对于交叉验证的每一折,随着C值的减小,训练性能也在下降。然而,与此同时,测试性能却在增加。对于某些折和C的值,测试的 ROC AUC 分数实际上超过了训练数据的分数,而对于其他情况,这两个指标则趋向于接近。在所有情况下,我们可以说,10^-1.5 和 10^-2 的C值在测试性能上表现相似,明显高于C = 10³的测试性能。因此,似乎正则化成功解决了我们的过拟合问题。
那么C的较低值呢?对于低于 10-2 的值,ROC AUC 指标突然下降到 0.5。正如您所知,这个值意味着分类模型基本上是无用的,性能不比抛硬币好。当探索正则化如何影响系数值时,鼓励您稍后检查这一点;然而,当应用了如此多的 L1 正则化以至于所有模型系数都收缩到 0 时,就会发生这种情况。显然,这样的模型对我们没有用,因为它们不包含关于特征和响应变量之间关系的任何信息。
查看每个 k 折分割的训练和测试性能有助于了解当模型在新的未见数据上得分时可能预期的模型性能的变化。但为了总结 k 折过程的结果,一个常见的方法是对每个正在考虑的超参数值的性能指标进行折叠平均。我们将在下一步中执行此操作。
-
使用以下代码绘制每个C值的训练和测试 ROC AUC 分数的平均值:
plt.plot(C_val_exponents, np.mean(cv_train_roc_auc, axis=0), \ '-o', label='Average training score') plt.plot(C_val_exponents, np.mean(cv_test_roc_auc, axis=0), \ '-x', label='Average testing score') plt.ylabel('ROC AUC') plt.xlabel('log$_{10}$(C)') plt.legend() plt.title('Cross validation scores averaged over all folds')图 4.21:跨交叉验证折叠的平均训练和测试分数
从这个图中可以看出,C = 10-1.5 和10-2 是最佳的C值。这里几乎没有过拟合,因为平均训练和测试分数几乎相同。您可以搜索更精细的C值网格(即C = 10-1.1*、10-1.2 等),以更精确地定位C值。然而,从我们的图表中,我们可以看到C = 10-1.5 或C = 10-2 可能是很好的解决方案。我们将继续使用C = 10-1.5。
检查 ROC AUC 的摘要指标是了解模型性能的快速方法。然而,对于任何真实的业务应用程序,您通常需要选择一个特定的阈值,该阈值与特定的真正和假正率相对应。这些将需要使用分类器来做出所需的“是”或“否”决定,在我们的案例研究中,这是关于账户是否会违约的预测。因此,查看交叉验证的不同折叠中的 ROC 曲线是有用的。为了方便起见,前面的函数已经被设计为返回每个测试折叠和C值的真正和假正率以及阈值,在
cv_test_roc列表的列表中。首先,我们需要找到对应于我们选择的C值10-1.5 的外部列表的索引。要实现这一点,我们可以简单地查看我们的C值列表并手动计数,但最好通过编程方式找到布尔数组的非零元素的索引来进行操作,如下一步所示。
-
使用布尔数组找到C = 10-1.5 的索引,并使用以下代码将其转换为整数数据类型:
best_C_val_bool = C_val_exponents == -1.5 best_C_val_bool.astype(int)以下是前面代码的输出:
array([0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0]) -
使用
nonzero函数将布尔数组的整数版本转换为单个整数索引,代码如下:best_C_val_ix = np.nonzero(best_C_val_bool.astype(int)) best_C_val_ix[0][0]以下是前面代码的输出:
9我们现在已经成功找到了我们希望使用的C值。
-
访问真正阳性和假阳性率,以绘制每个折叠的 ROC 曲线:
for this_fold in range(k_folds_n_splits): fpr = cv_test_roc[best_C_val_ix[0][0]][this_fold][0] tpr = cv_test_roc[best_C_val_ix[0][0]][this_fold][1] plt.plot(fpr, tpr, label='Fold {}'.format(this_fold+1)) plt.xlabel('False positive rate') plt.ylabel('True positive rate') plt.title('ROC curves for each fold at C = $10^{-1.5}$') plt.legend()你将得到以下输出:
图 4.22:每个折叠的 ROC 曲线
看起来 ROC 曲线存在相当大的变异性。例如,如果出于某种原因,我们想将假阳性率限制在 40%,那么从图中可以看出,我们可能能够实现的真正阳性率大约在 60%到 80%之间。你可以通过检查我们绘制的数组来找到精确值。这给你一个关于在新数据上部署模型时,性能波动的预期情况。通常,训练数据越多,交叉验证的折叠之间的变异性就越小,因此这也可能是一个收集更多数据的好主意,尤其是当训练折叠之间的变异性似乎不可接受地高时。你可能还希望尝试使用不同数量的折叠进行此过程,以查看结果变异性对折叠之间的影响。
虽然通常我们会尝试在我们的合成数据问题上使用其他模型,例如随机森林或支持向量机,但如果我们假设在交叉验证中,逻辑回归证明是最好的模型,我们将决定将其作为最终选择。当最终模型被选定后,可以使用所有训练数据来拟合该模型,使用在交叉验证中选择的超参数。最好在模型拟合时使用尽可能多的数据,因为通常情况下,模型在训练时使用更多的数据效果更好。
-
在我们的合成问题中,使用所有训练数据训练逻辑回归,并比较训练和测试分数,使用以下步骤所示的保留测试集。
注意
这是模型选择过程中的最后一步。只有在你选择好模型和超参数之后,才能使用未见过的测试集,否则它就不再是“未见过”的。
-
设置C值,并使用以下代码在所有训练数据上训练模型:
lr_syn.C = 10**(-1.5) lr_syn.fit(X_syn_train, y_syn_train)以下是前面代码的输出:
LogisticRegression(C=0.03162277660168379, penalty='l1', \ random_state=1, solver='liblinear')) -
使用以下代码获取训练数据的预测概率和 ROC AUC 分数:
y_syn_train_predict_proba = lr_syn.predict_proba(X_syn_train) roc_auc_score(y_syn_train, y_syn_train_predict_proba[:,1])以下是前面代码的输出:
0.8802812499999999 -
使用以下代码获取测试数据的预测概率和 ROC AUC 分数:
y_syn_test_predict_proba = lr_syn.predict_proba(X_syn_test) roc_auc_score(y_syn_test, y_syn_test_predict_proba[:,1])以下是前面代码的输出:
0.8847884788478848在这里,我们可以看到,通过使用正则化,模型的训练分数和测试分数相似,表明过拟合问题已大大减轻。训练分数较低,因为我们在模型中引入了偏差,牺牲了方差。然而,这没关系,因为最重要的测试分数较高。样本外测试分数才是预测能力的关键。建议您通过打印我们之前绘制的数组中的值,检查这些训练分数和测试分数是否与交叉验证过程中的结果相似;您应该发现它们是相似的。
注意
在一个实际项目中,在将这个模型交付给客户用于生产使用之前,您可能希望在所有提供的数据上训练模型,包括未见过的测试集。这遵循了一个想法,即模型看到的数据越多,实际表现可能越好。然而,一些从业者更喜欢只使用经过测试的模型,这意味着您只会交付在训练数据上训练的模型,而不包括测试集。
我们知道,L1 正则化通过减少逻辑回归系数的大小(即绝对值)来工作。它还可以将一些系数设置为零,从而执行特征选择。在下一步,我们将确定有多少个系数被设置为零。
-
使用以下代码访问训练模型的系数,并确定有多少个系数不等于零(
!= 0):sum((lr_syn.coef_ != 0)[0])输出应如下所示:
2这段代码对一个布尔数组求和,表示非零系数的位置,因此显示模型中有多少个系数没有被 L1 正则化设置为零。在 200 个特征中,只有 2 个被选择了!
-
使用以下代码检查截距的值:
lr_syn.intercept_输出应如下所示:
array([0.])这表明截距被正则化为 0。
在这个练习中,我们完成了几个目标。我们使用了 k 折交叉验证过程来调整正则化超参数。我们看到了正则化在减少过拟合方面的强大作用,并且在逻辑回归中的 L1 正则化情况下,还能进行特征选择。
许多机器学习算法提供某种类型的特征选择功能。许多算法还需要调整超参数。这里的函数通过循环超参数并执行交叉验证,提供了一个强大的概念,可以推广到其他模型。Scikit-learn 提供了简化这个过程的功能;特别是,sklearn.model_selection.GridSearchCV过程,它对超参数进行网格搜索并应用交叉验证。当需要调整多个超参数时,网格搜索非常有帮助,因为它可以查看您指定的不同超参数范围的所有组合。随机网格搜索可以通过随机选择较少的组合来加速这一过程,尤其是在全面的网格搜索过于耗时的情况下。一旦您熟悉了这里展示的概念,建议您通过使用像这些方便的函数来简化工作流程。
Scikit-Learn 中逻辑回归的选项
我们已经使用并讨论了在实例化或调整LogisticRegression模型类的超参数时,您可能提供的大部分选项。在这里,我们列出了所有选项,并提供了一些关于它们使用的通用建议:
图 4.23:Scikit-learn 中逻辑回归模型的完整选项列表
如果您对使用哪个选项进行逻辑回归感到疑惑,我们建议您参考 scikit-learn 文档以获取进一步的指导(scikit-learn.org/stable/modules/linear_model.html#logistic-regression)。一些选项,例如正则化参数C,或正则化惩罚的选择,需要通过交叉验证过程来探索。在这里,正如许多数据科学决策一样,没有一种通用的方法适用于所有数据集。查看使用哪些选项最适合给定数据集的最佳方法是尝试其中的几个,并查看哪一个在样本外表现最好。交叉验证为您提供了一种稳健的方式来做到这一点。
在 Scikit-Learn 中的缩放数据、管道和交互特征
缩放数据
与我们之前处理的合成数据相比,案例研究数据相对较大。如果我们想使用 L1 正则化,那么根据官方文档(scikit-learn.org/stable/modules/linear_model.html#logistic-regression),我们应该使用 saga 解算器。然而,这个解算器对未缩放的数据集不具备鲁棒性。因此,我们需要确保对数据进行缩放。每当进行正则化时,这也是一个好主意,这样所有特征就处于相同的尺度,并且在正则化过程中会受到同等的惩罚。确保所有特征具有相同尺度的一个简单方法是将它们都通过一个变换过程,即减去最小值并除以最小值到最大值的范围。这将把每个特征转换为使其最小值为 0,最大值为 1。为了实例化一个执行这一过程的 MinMaxScaler 缩放器,我们可以使用以下代码:
from sklearn.preprocessing import MinMaxScaler
min_max_sc = MinMaxScaler()
管道
以前,我们在交叉验证循环中使用了逻辑回归模型。然而,现在我们对数据进行了缩放,新的考虑因素是什么?缩放实际上是通过训练数据的最小值和最大值来“学习”的。之后,逻辑回归模型将基于由模型训练数据的极值缩放过的数据进行训练。然而,我们无法知道新数据(未见数据)的最小值和最大值。因此,按照使交叉验证成为评估未见数据模型性能的有效指标的理念,我们需要在每个交叉验证折叠中使用训练数据的最小值和最大值,以便在该折叠中对测试数据进行缩放,然后再对测试数据进行预测。Scikit-learn 提供了便捷的功能来结合多个训练和测试步骤,以应对这种情况:Pipeline。我们的管道将包括两个步骤:缩放器和逻辑回归模型。这两个步骤可以都在训练数据上进行拟合,然后用于对测试数据进行预测。拟合管道的过程在代码中作为一个单一步骤执行,因此从这个角度看,管道的所有部分都是一次性拟合的。以下是如何实例化一个Pipeline:
from sklearn.pipeline import Pipeline
scale_lr_pipeline = Pipeline(steps=[('scaler', min_max_sc), \
('model', lr)])
交互特征
考虑到案例研究数据,你认为一个包含所有可能特征的逻辑回归模型会过拟合还是欠拟合?你可以从经验法则的角度来考虑,例如“10 法则”,以及我们拥有的特征数(17 个)与样本数(26,664 个)之间的关系。或者,你也可以回顾我们迄今为止在这个数据上所做的所有工作。例如,我们已经有机会对所有特征进行可视化,并确保它们是合理的。由于特征相对较少,并且由于我们通过数据探索工作对它们的质量有较高的信心,我们的情况与本章中使用合成数据的练习不同,后者有大量特征,但我们对其了解较少。因此,可能目前我们的案例研究数据过拟合问题不太明显,正则化的好处可能也不会显著。
实际上,使用仅有的 17 个特征,我们可能会出现欠拟合。应对这种情况的一种策略是进行特征工程。我们讨论过的一些简单特征工程技术包括交互特征和多项式特征。考虑到某些数据的编码方式,多项式特征可能没有意义;例如,-12 = 1*,这对于PAY_1可能并不合理。然而,我们可能希望尝试创建交互特征,以捕捉特征之间的关系。PolynomialFeatures可以用来仅创建交互特征,而不包括多项式特征。示例代码如下:
make_interactions = PolynomialFeatures(degree=2, \
interaction_only=True, \
include_bias=False)
这里,degree表示多项式特征的阶数,interaction_only是布尔值(将其设置为True表示仅创建交互特征),include_bias也是布尔值,它会向模型添加截距项(默认值为False,这里是正确的,因为逻辑回归模型会自动添加截距)。
活动 4.01:使用案例研究数据进行交叉验证和特征工程
在本活动中,我们将应用本章中学到的交叉验证和正则化知识到案例研究数据中。我们将进行基础的特征工程。为了为案例研究数据的正则化逻辑回归模型估计参数,由于该数据集比我们之前使用的合成数据集大,因此我们将使用saga求解器。为了使用此求解器,并出于正则化的目的,我们需要使用 scikit-learn 中的Pipeline类。完成活动后,你应当能够得到使用交互特征的改进版交叉验证测试表现,具体如下图所示:
图 4.24:改进的模型测试表现
执行以下步骤以完成活动:
-
从案例研究数据的数据框中选择特征。
你可以使用我们在本章中已经创建的特征名称列表,但一定要确保不包括响应变量,因为它是一个非常好的(但完全不适当的)特征!
-
使用随机种子 24 进行训练/测试集划分。
我们将继续使用这个并将此测试数据保留为未见过的测试集。通过指定随机种子,我们可以轻松创建包含其他建模方法的独立笔记本,并使用相同的训练数据。
-
实例化
MinMaxScaler来缩放数据。 -
使用
saga求解器、L1 惩罚并将max_iter设置为1000来实例化一个逻辑回归模型,因为我们希望求解器有足够的迭代次数来找到一个良好的解。 -
导入
Pipeline类,并使用'scaler'和'model'作为步骤名称,分别创建一个包含缩放器和逻辑回归模型的流水线。 -
使用
get_params和set_params方法查看每个流水线阶段的参数,并进行更改。 -
创建一个较小范围的C值以进行交叉验证测试,因为这些模型在使用比我们之前练习更多数据时,训练和测试将花费更长时间;我们推荐的C值为 C = [102*, 10, 1, 10*-1*, 10*-2*, 10*-3*]。
-
创建一个新的
cross_val_C_search函数版本,名为cross_val_C_search_pipe。这个函数将不再使用model参数,而是接受一个pipeline参数。函数内部的更改将是通过在流水线中使用set_params(model__C = <value you want to test>)来设置C值,替换fit和predict_proba方法中的模型为流水线,并通过pipeline.get_params()['model__C']访问C值,以打印状态更新。 -
像之前的练习一样运行这个函数,但使用新的C值范围、你创建的流水线,以及来自案例研究数据训练集的特征和响应变量。
你可能会看到关于求解器不收敛的警告,可能出现在此处或后续步骤中;你可以尝试使用
tol或max_iter选项来实现收敛,尽管使用max_iter = 1000获得的结果可能已经足够。 -
绘制每个C值在各折交叉验证中的平均训练和测试 ROC AUC。
-
为案例研究数据创建交互特征,并确认新特征的数量是合理的。
-
重复交叉验证过程,并观察在使用交互特征时模型的表现。
注意,由于特征数量较多,这将需要更多时间,但可能不超过 10 分钟。那么,交互特征是否改善了平均交叉验证测试性能?正则化有用吗?
注意
包含此活动的 Python 代码的 Jupyter notebook 可以在
packt.link/ohGgX找到。此活动的详细逐步解决方案可以通过此链接查看。
总结
在本章中,我们介绍了逻辑回归的最终细节,并继续学习如何使用scikit-learn拟合逻辑回归模型。通过了解代价函数的概念,我们对模型拟合过程有了更多的了解,代价函数通过梯度下降过程来最小化,从而在模型拟合过程中估计参数。
我们还通过引入欠拟合和过拟合的概念,了解到正则化的必要性。为了减少过拟合,我们了解了如何调整代价函数,通过 L1 或 L2 惩罚对逻辑回归模型的系数进行正则化。我们使用交叉验证来选择正则化的程度,通过调整正则化超参数来进行选择。为了减少欠拟合,我们还学习了如何通过交互特征进行一些简单的特征工程,来处理案例研究数据。
我们现在已经熟悉了一些机器学习中最重要的概念。到目前为止,我们仅使用了一个非常基础的分类模型:逻辑回归。然而,随着你逐步扩展所掌握的模型工具箱,你会发现过拟合和欠拟合的概念、偏差-方差权衡以及超参数调优将一次又一次地出现。这些概念,以及我们在本章中编写的交叉验证函数的便捷scikit-learn实现,将帮助我们在探索更先进的预测方法时提供支持。
在下一章,我们将学习决策树,这是一种完全不同于逻辑回归的预测模型类型,以及基于决策树的随机森林。然而,我们将使用在本章中学到的相同概念——交叉验证和超参数搜索——来调优这些模型。
第五章:5. 决策树与随机森林
概述
在本章中,我们将重点介绍近年来在数据科学中风靡一时的另一类机器学习模型:基于树的模型。在本章中,在单独学习决策树后,你将学习由多棵树组成的模型(即随机森林),它们如何改善单棵树所产生的过拟合问题。读完本章后,你将能够为机器学习训练决策树、可视化训练好的决策树,并训练随机森林并可视化结果。
引言
在过去的两章中,我们已经深入理解了逻辑回归的工作原理,并且已经积累了大量使用 Python 中的 scikit-learn 包来创建逻辑回归模型的经验。
在本章中,我们将介绍一种强大的预测模型,这种模型与逻辑回归模型采用完全不同的方法:决策树。决策树及其基础上的模型是目前可用于一般机器学习应用的最具表现力的模型之一。使用树状过程进行决策的概念简单明了,因此,决策树模型易于理解。然而,决策树的一个常见批评是它们容易对训练数据过拟合。为了解决这个问题,研究人员开发了集成方法,如随机森林,通过将多棵决策树结合在一起,协同工作,做出比任何单棵树更好的预测。
我们将看到,决策树和随机森林可以提升案例研究数据的预测建模质量,超越我们目前使用逻辑回归所取得的成果。
决策树
决策树及其基础上的机器学习模型,特别是随机森林和梯度提升树,与广义线性模型(GLM),如逻辑回归,是根本不同的模型类型。GLM 源自经典统计学理论,这些理论有着悠久的历史。线性回归背后的数学最初由勒让德和高斯在 19 世纪初提出。因此,正态分布也被称为高斯分布。
相比之下,虽然使用树状过程进行决策的想法相对简单,决策树作为数学模型的流行是在最近才兴起的。我们目前用于制定决策树的数学方法是在 1980 年代发布的。之所以出现这种较新的发展,是因为用于生长决策树的方法依赖于计算能力——即快速处理大量数字的能力。如今我们理所当然地拥有这种能力,但在数学历史上,直到近代才广泛可用。
那么,决策树是什么意思呢?我们可以通过一个实际的例子来说明基本概念。假设你正在考虑是否在某一天外出。你做决定时唯一依赖的信息是天气,特别是阳光是否明媚以及气温有多暖和。如果是晴天,你对凉爽气温的耐受性会提高,只要气温至少为 10°C,你就会外出。
然而,如果是阴天,你需要稍微温暖一些的气温,并且只有当气温达到 15°C 或更高时,你才会外出。你的决策过程可以通过以下树状图表示:
图 5.1:根据天气决定是否外出的决策树
正如你所看到的,决策树具有直观的结构,并模拟了人类可能做出逻辑决策的方式。因此,它们是一个高度可解释的数学模型类型,这在某些受众中可能是一个特别理想的特性。例如,数据科学项目的客户可能特别关注如何清晰地理解一个模型是如何工作的。只要其性能足够,决策树是满足这一要求的好方法。
决策树术语及其与机器学习的关系
看图中的图 5.1,我们可以开始熟悉一些决策树的术语。因为在第一层基于云层条件,第二层基于气温做出决策,所以我们说这棵决策树的深度为二。这里,第二层的两个节点都是基于气温做出的决策,但在同一层次内,决策的种类可能不同;例如,如果不是晴天,我们也可以根据是否下雨来做决定。
在机器学习的背景下,用于在节点处做决策(换句话说,分裂节点)的量是特征。在图 5.1中的示例中,特征包括是否晴天的二元分类特征和温度的连续特征。虽然我们在树的给定分支中只展示了每个特征被使用一次,但同一个特征也可以在一个分支中被多次使用。例如,我们可能选择在阳光明媚的日子里,温度至少为 10 °C 时外出,但如果温度超过 40 °C,就不出去了——那太热了!在这种情况下,图 5.1中的节点 4 将根据“温度是否大于 40 °C?”这一条件进行分裂,如果答案是“是”,结果是“待在室内”,如果答案是“否”,则结果是“外出”,这意味着温度在 10 °C 到 40 °C 之间。因此,决策树能够捕捉特征的非线性效应,而不是假设温度越高,我们越可能外出的一种线性关系,无论温度有多高。
考虑树通常是如何表示的,例如在图 5.1中。分支基于二元决策向下生长,这些二元决策可以将节点分裂成两个子节点。这些二元决策可以被视为“如果,那么”的规则。换句话说,如果某个条件满足,就做这个,否则做别的事情。我们示例树中的决策类似于机器学习中的响应变量的概念。如果我们为信用违约的案例研究问题做一个决策树,决策将会是预测二元响应值,即“此账户违约”或“此账户不违约”。回答二元是/否问题的树被称为分类树。然而,决策树非常多功能,也可以用于多类分类和回归问题。
树的最底层节点被称为叶子,或叶节点。在我们的示例中,叶子是最终的决策,即是否外出或待在室内。我们的树上有四个叶子,尽管你可以想象,如果树的深度只有一层,其中的决策仅基于云层情况,那么将会有两个叶子;在图 5.1中,节点 2 和节点 3 将是叶节点,分别以“外出”和“待在室内”作为决策。
在我们的示例中,每个层次上的每个节点都被分裂了。在严格意义上,这并非必要,因为你可能会选择在任何阳光明媚的日子外出,无论温度如何。在这种情况下,节点 2 将不会被分裂,因此该分支会在第一层次以“是”的决策结束。然而,在阴天的情况下,你的决策可能会涉及温度,这意味着该分支可以扩展到更深的层次。如果每个节点在最终层次之前都被分裂,考虑一下随着层数增加,叶子数量增长的速度。
例如,如果我们将图 5.1中的决策树再向下生长一个额外的层级,或许增加一个风速特征,以便考虑四种云层条件和温度的风寒效应会发生什么情况。现在作为叶子的四个节点,编号从四到七的节点图 5.1,将会基于每种情况的风速被拆分成两个更多的叶节点。然后,叶节点将变为4 × 2 = 8个。一般来说,应该清楚的是,在一个有 n 层的树中,若每个最终层之前的节点都被拆分,那么将会有2n个叶节点。考虑到这一点是很重要的,因为最大深度是你可以为决策树分类器设置的超参数之一。接下来我们将在以下练习中探讨这一点。
练习 5.01:在 Scikit-Learn 中使用决策树
在本练习中,我们将使用案例研究数据来生长一棵决策树,其中我们指定最大深度。我们还将使用一些便捷的功能来可视化决策树,使用的是 graphviz 包。请按以下步骤完成练习:
注意
本练习的 Jupyter notebook 可在 packt.link/IUt7d 找到。在开始练习之前,请确保你已按照前言中的说明设置好环境并导入必要的库。
-
加载我们一直在使用的几个包,并额外加载一个包
graphviz,以便我们可以可视化决策树:import numpy as np #numerical computation import pandas as pd #data wrangling import matplotlib.pyplot as plt #plotting package #Next line helps with rendering plots %matplotlib inline import matplotlib as mpl #add'l plotting functionality mpl.rcParams['figure.dpi'] = 400 #high res figures import graphviz #to visualize decision trees -
加载清理后的案例研究数据:
df = pd.read_csv('../Data/Chapter_1_cleaned_data.csv')注意
清理后的数据的位置可能因你保存数据的位置而有所不同。
-
获取数据框的列名列表:
features_response = df.columns.tolist() -
创建一个列出要移除的不是特征或响应变量的列的列表:
items_to_remove = ['ID', 'SEX', 'PAY_2', 'PAY_3',\ 'PAY_4', 'PAY_5', 'PAY_6',\ 'EDUCATION_CAT', 'graduate school',\ 'high school', 'none',\ 'others', 'university'] -
使用列表推导式从我们的特征列表和响应变量中移除这些列名:
features_response = [item for item in features_response if item not in items_to_remove] features_response这应该输出特征列表和响应变量:
['LIMIT_BAL', 'EDUCATION', 'MARRIAGE', 'AGE', 'PAY_1', 'BILL_AMT1', 'BILL_AMT2', 'BILL_AMT3', 'BILL_AMT4', 'BILL_AMT5', 'BILL_AMT6', 'PAY_AMT1', 'PAY_AMT2', 'PAY_AMT3', 'PAY_AMT4', 'PAY_AMT5', 'PAY_AMT6', 'default payment next month']现在特征列表已准备好。接下来,我们将从 scikit-learn 导入一些库。我们需要进行训练/测试集拆分,这是我们已经熟悉的操作。我们还需要导入决策树功能。
-
运行以下代码从 scikit-learn 进行导入:
from sklearn.model_selection import train_test_split from sklearn import treescikit-learn 的
tree库包含与决策树相关的类。 -
使用我们在全书中使用的相同随机种子,将数据拆分为训练集和测试集:
X_train, X_test, y_train, y_test = \ train_test_split(df[features_response[:-1]].values, df['default payment next month'].values, test_size=0.2, random_state=24)在这里,我们使用列表中的所有元素(除了最后一个)来获取特征名称,而不是响应变量:
features_response[:-1]。我们用它从 DataFrame 中选择列,然后使用.values方法检索它们的值。我们对响应变量也做类似的操作,但直接指定列名。在进行训练/测试数据划分时,我们使用与之前相同的随机种子以及相同的划分比例。这样,我们可以直接将本章所做的工作与以前的结果进行比较。此外,我们继续保留与模型开发过程中的“未见测试集”相同的数据集。现在我们准备实例化决策树类。
-
通过将
max_depth参数设置为2来实例化决策树类:dt = tree.DecisionTreeClassifier(max_depth=2)我们使用了
DecisionTreeClassifier类,因为这是一个分类问题。由于我们指定了max_depth=2,当我们使用案例研究数据生长决策树时,树的最大深度将为2。现在让我们训练这个模型。 -
使用以下代码拟合决策树模型并生长树:
dt.fit(X_train, y_train)这应显示以下输出:
DecisionTreeClassifier(max_depth=2)现在我们已经拟合了这个决策树模型,我们可以使用
graphviz包来显示树的图形表示。 -
使用以下代码将训练好的模型导出为
graphviz包可以读取的格式:dot_data = tree.export_graphviz(dt, out_file=None, filled=True, rounded=True, feature_names=\ features_response[:-1], proportion=True, class_names=[ 'Not defaulted', 'Defaulted'])在这里,我们为
.export_graphviz方法提供了多个选项。首先,我们需要指定要绘制的训练模型,即dt。接下来,我们说不需要输出文件:out_file=None。相反,我们提供了dot_data变量来保存此方法的输出。其余选项设置如下:filled=True:每个节点将填充颜色。rounded=True:节点将呈现圆角边缘,而不是矩形。feature_names=features_response[:-1]:我们列表中的特征名称将被使用,而不是像X[0]这样的通用名称。proportion=True:每个节点中训练样本的比例将显示(我们稍后会进一步讨论)。class_names=['Not defaulted', 'Defaulted']:每个节点将显示预测类的名称。这个方法的输出是什么?
如果检查
dot_data的内容,你会发现它是一个长文本字符串。graphviz包可以解析这个文本字符串并创建可视化效果。 -
使用
graphviz包的.Source方法从dot_data创建图像并显示它:graph = graphviz.Source(dot_data) graph输出应如下所示:
图 5.2:来自 graphviz 的决策树图
决策树的图形表示应直接呈现在你的 Jupyter 笔记本中,如图 5.2所示。
注意
或者,你可以通过为
out_file关键字参数提供文件路径,将.export_graphviz的输出保存到磁盘。例如,要将这个输出文件转换为图像文件,如.png文件,以便在演示中使用,你可以在命令行运行以下代码,并根据需要替换文件名:$ dot -Tpng <exported_file_name> -o <image_file_name_you_want>.png。关于
.export_graphviz选项的更多细节,你应参考 scikit-learn 文档(scikit-learn.org/stable/modules/generated/sklearn.tree.export_graphviz.html)。图 5.2中的可视化包含了许多有关决策树训练和如何使用它进行预测的信息。我们稍后会更详细地讨论训练过程,但简而言之,训练决策树的过程是从树顶部初始节点的所有训练样本开始,然后根据第一个节点中的
PAY_1 <= 1.5将这些样本分成两组。所有
PAY_1特征值小于或等于1.5的样本将在此布尔条件下表示为True。如图 5.2所示,这些样本会根据旁边写着True的箭头被排序到树的左侧。正如你在图表中看到的,每个被拆分的节点包含拆分标准的第一行文本。下一行与
gini有关,我们稍后会讨论。下一行包含每个节点中样本比例的信息。在顶部节点,我们从所有样本(
samples = 100.0%)开始。第一次拆分后,89.5%的样本被排序到左侧节点,剩余的 10.5%进入右侧节点。这些信息直接显示在可视化中,反映了如何使用训练数据来创建树。让我们通过检查训练数据来确认这一点。 -
要确认训练样本中
PAY_1特征小于或等于1.5的比例,首先识别该特征在features_response[:-1]特征名称列表中的索引:features_response[:-1].index('PAY_1')这段代码应输出如下内容:
4 -
现在,观察训练数据的形状:
X_train.shape这应为你提供以下输出:
(21331, 17)要确认决策树第一次拆分后的样本比例,我们需要知道满足
PAY_1特征布尔条件的样本比例,这些样本被用于进行此拆分。为此,我们可以使用训练数据中PAY_1特征的索引,这对应于特征名称列表中的索引,并使用训练数据中的样本数量,这个数量是我们从.shape观察到的行数。 -
使用此代码确认决策树第一次拆分后的样本比例:
(X_train[:,4] <= 1.5).sum()/X_train.shape[0]输出应如下所示:
0.8946134733486475通过对训练数据中与
PAY_1特征对应的列应用逻辑条件,然后计算满足该条件的样本数量,再除以样本总数,我们将其转换为比例。我们可以看到,从训练数据直接计算出的比例与图 5.2中第一次分裂后的左节点显示的比例相等。在第一次分裂之后,第一层中每个节点包含的样本会再次被分裂。随着进一步的分裂,树枝后续层级中任何给定节点的训练数据所占比例会越来越小,这一点可以在图 5.2中看到。
现在我们要解释节点中其余文本行的含义,这些节点出现在图 5.2中。以
value开头的行给出了每个节点中样本的响应变量类别比例。例如,在顶部节点中,我们看到value = [0.777, 0.223]。这些只是整体训练集的类别比例,你可以在下一步中验证这些比例。 -
使用以下代码计算训练集中的类别比例:
y_train.mean()输出应如下所示:
0.223102526838873这等同于在顶部节点中
value后面那对数字的第二个数;第一个数字就是减去该数字后的结果,换句话说,就是负类训练样本的比例。在每个后续节点中,都会显示该节点中样本的类别比例。类别比例也决定了节点的颜色:负类比例高于正类的节点为橙色,较深的橙色表示比例越高,而正类比例较高的节点则采用类似的蓝色配色方案。最后,以
class开头的行表示如果某个节点是叶节点,决策树如何根据给定的节点进行预测。分类的决策树通过确定样本根据特征值会被划分到哪个叶节点,然后预测该叶节点中大多数训练样本的类别来进行预测。这一策略意味着,树结构和叶节点中的类别比例是做出预测所需的信息。例如,如果我们没有进行任何分裂,且只能在不知道其他信息的情况下,仅凭整体训练数据的类别比例做出预测,那么我们将选择多数类别。由于大多数人不会违约,顶部节点的类别为
未违约。然而,深层节点中的类别比例不同,导致不同的预测。scikit-learn 是如何决定树的结构的呢?我们将在接下来的部分讨论训练过程。
max_depth 的重要性
回想一下,在本练习中我们指定的唯一超参数是max_depth,也就是在模型训练过程中决策树可以生长的最大深度。事实证明,这是最重要的超参数之一。如果没有对深度进行限制,树将继续生长,直到其他由其他超参数指定的限制起作用。这可能导致非常深的树,并且节点数量非常多。例如,考虑一棵深度为 20 的树,它可能有多少叶节点呢?这将是220个叶节点,超过 100 万个!我们甚至有足够的训练样本来将所有这些节点填充吗?在这种情况下,我们没有。显然,使用这些训练数据生长这样的树是不可能的,因为在最终层之前的每个节点都会被分裂。然而,如果我们移除max_depth限制并重新运行本练习的模型训练,观察效果:
](tos-cn-i-73owjymdk6/f32a822a25f64480894e00aa5adc1f86)
图 5.3:没有最大深度限制的决策树的一部分
这里展示了一个使用默认选项生成的决策树的一部分,默认选项包括max_depth=None,意味着树的深度没有限制。整个树大约是这里展示部分的两倍宽。树的节点非常多,以至于它们只作为非常小的橙色或蓝色斑点出现;每个节点的具体解释并不重要,因为我们只是想说明树的规模可能会非常大。可以清楚地看出,如果没有超参数来控制树的生长过程,可能会生成极其庞大且复杂的树。
训练决策树:节点不纯度
到此为止,你应该已经了解了决策树是如何利用特征进行预测的,以及叶节点中训练样本的类别分布。现在,我们将学习决策树是如何训练的。训练过程涉及选择特征来对节点进行分裂,并决定分裂的阈值,例如在前面练习中的树的第一次分裂是PAY_1 <= 1.5。从计算角度来看,这意味着每个节点中的样本必须根据每个特征的值进行排序,以考虑分裂,并且在排序后的特征值之间的每对连续值都会被考虑作为潜在的分裂点。所有特征都可以被考虑,或者如我们稍后将要学习的那样,仅考虑一部分特征。
在训练过程中,如何决定分裂?
由于预测方法是选择叶节点的多数类,因此我们希望找到主要来自某一类的叶节点;选择多数类将是更准确的预测,节点越接近只包含某一类,其预测越准确。在理想情况下,训练数据可以被划分,使得每个叶节点完全包含正类或完全包含负类样本。然后,我们就可以高信心地认为,一旦新样本被分配到其中一个节点,它将是正类或负类。然而,在实践中,这种情况很少发生,几乎不会发生。然而,这说明了训练决策树的目标——也就是做出分裂,使得分裂后的两个节点具有更高的 纯度,换句话说,更接近只包含正类或负类样本。
在实践中,决策树实际上是使用纯度的逆,即 节点不纯度 进行训练的。这是衡量节点中训练样本距离完全属于某一类的程度的一个指标,类似于代价函数的概念,表示给定解决方案与理论上完美解决方案的差距。节点不纯度的最直观概念是 误分类率。采用广泛使用的符号(例如, scikit-learn.org/stable/modules/tree.html)表示每个节点中属于某一类的样本比例,我们可以定义 pmk 为第 m 个节点中属于第 k 类的样本比例。在二分类问题中,只有两类:k = 0 和 k = 1。对于给定的节点 m,误分类率就是该节点中较少见类别的比例,因为当该节点中的多数类作为预测类别时,所有这些样本都会被误分类。
让我们将误分类率可视化,作为开始思考决策树训练方式的一种方法。在程序中,我们使用 NumPy 的 linspace 函数考虑负类 k = 0 在节点 m 中可能的类比例 pm0,范围从 0.01 到 0.99:
pm0 = np.linspace(0.01,0.99,99)
pm1 = 1 - pm0
然后,这个节点的正类比例是 1 减去 pm0:
图 5.4:计算节点 m0 的正类比例的公式
现在,这个节点的误分类率将是 pm0 和 pm1 之间较小的类比例。我们可以使用 NumPy 的 minimum 函数来找到两个形状相同的数组中对应元素的较小值:
misclassification_rate = np.minimum(pm0, pm1)
误分类率与负类可能的类比例绘制出来是什么样的?
我们可以使用以下代码绘制这个图:
mpl.rcParams['figure.dpi'] = 400
plt.plot(pm0, misclassification_rate,
label='Misclassification rate')
plt.xlabel('$p_{m0}$')
plt.legend()
你应该得到这个图:
图 5.5:节点的误分类率
现在,很明显,负类的类分数pm0 越接近 0 或 1,误分类率就越低。那么在构建决策树时,如何利用这些信息呢?考虑一下可能遵循的过程。
每次在构建决策树时进行节点划分时,都会创建两个新节点。由于这两个新节点的预测值只是多数类,因此一个重要的目标是减少误分类率。因此,我们需要找到一个特征和该特征的一个值作为切分点,使得在所有类别上取平均后,两个新节点的误分类率尽可能低。这与实际训练决策树时使用的过程非常接近。
继续讨论最小化误分类率的思路,决策树训练算法通过考虑所有特征进行节点划分,尽管如果你将max_features超参数设置为少于特征总数的值,算法可能只会考虑一个随机选择的特征子集。稍后我们将讨论为什么要这么做。在任何情况下,算法会考虑每个候选特征的所有可能阈值,并选择那个能使得不纯度最低的阈值,不纯度的计算方式是通过加权每个节点的样本数量,计算两个新节点的平均不纯度。节点划分过程如图 5.6所示。该过程会一直重复,直到树的停止准则(如max_depth)达到:
图 5.6:如何选择特征和阈值来划分节点
虽然误分类率是一个直观的衡量不纯度的指标,但实际上还有更好的指标可以用来在模型训练过程中找到最佳分裂。scikit-learn 提供了两种可供选择的计算不纯度的方法,你可以通过criterion关键字参数来指定,分别是基尼不纯度和交叉熵。在这里,我们将从数学上描述这些方法,并展示它们与误分类率的比较。
基尼不纯度通过以下公式计算节点m的不纯度:
图 5.7:计算基尼不纯度的公式
在这里,求和是对所有类别进行的。在二分类问题中,只有两个类别,我们可以像下面这样编写程序:
gini = (pm0*(1-pm0)) + (pm1*(1-pm1))
交叉熵通过以下公式计算:
图 5.8:计算交叉熵的公式
使用这段代码,我们可以计算交叉熵:
cross_ent = -1*((pm0*np.log(pm0)) + (pm1*np.log(pm1)))
为了将 Gini 不纯度和交叉熵添加到我们的误分类率图中并查看它们的比较,我们只需要在绘制误分类率后添加以下代码行:
mpl.rcParams['figure.dpi'] = 400
plt.plot(pm0, misclassification_rate,\
label='Misclassification rate')
plt.plot(pm0, gini, label='Gini impurity')
plt.plot(pm0, cross_ent, label='Cross entropy')
plt.xlabel('$p_{m0}$')
plt.legend()
最终的图形应如下所示:
图 5.9:误分类率、Gini 不纯度和交叉熵
注意
如果你正在阅读本书的纸质版,你可以通过访问以下链接下载并浏览本章中某些图像的彩色版本:
与误分类率类似,Gini 不纯度和交叉熵在类别比例为 0.5 时最大,随着节点变得更加纯净——换句话说,当它们包含的类别比例更高时——它们会逐渐降低。然而,Gini 不纯度在某些类别比例区域比误分类率变化得更陡峭,这使得它能够更有效地找到最佳分裂点。交叉熵看起来变化得更加陡峭。那么,哪一个更适合你的工作呢?这是一个在所有数据集上都没有明确答案的问题。你应该在交叉验证超参数的过程中同时考虑这两种不纯度度量,以确定最合适的一个。需要注意的是,在 scikit-learn 中,Gini 不纯度可以通过 criterion 参数使用 'gini' 字符串来指定,而交叉熵则简单地称为 'entropy'。
用于第一次分裂的特征:与单变量特征选择和交互的关系
我们可以根据图 5.2所示的小树开始了解不同特征对决策树模型的重要性。注意,PAY_1是第一次分裂时选择的特征。这意味着它是在减少包含所有训练样本的节点不纯度方面表现最好的特征。回想我们在第三章“逻辑回归和特征探索的细节”中的单变量特征选择经验,其中PAY_1是通过 F 检验选出的最佳特征。因此,考虑到我们之前的分析,PAY_1出现在决策树的第一次分裂中是合理的。
在树的第二层,PAY_1上有另一个分裂,同时也有在BILL_AMT_1上的分裂。BILL_AMT_1在单变量特征选择中并没有列为重要特征。然而,可能是BILL_AMT_1与PAY_1之间存在一个重要的交互作用,而这种交互作用在单变量方法中无法发现。特别是,从决策树选择的分裂来看,似乎那些PAY_1值为 2 或更大的账户,并且BILL_AMT_1大于 568 的账户,尤其容易违约。PAY_1和BILL_AMT_1的这种组合效应是一种交互作用,这也可能是我们通过在前一章的活动中包含交互项来改善逻辑回归性能的原因。
训练决策树:一种贪婪算法
没有保证通过前述过程训练得到的决策树是找到最低不纯度叶节点的最佳决策树。这是因为训练决策树所使用的算法是一种所谓的贪婪算法。在这种情况下,这意味着在每次分裂节点的机会中,算法都会寻找当前时刻最佳的分裂,而不会考虑后续分裂机会受到影响的事实。
例如,考虑以下假设场景:案例研究的训练数据的最佳初始分裂涉及PAY_1,正如我们在图 5.2中所看到的。但是,如果我们改为在BILL_AMT_1上进行分裂,然后在下一级做PAY_1的后续分裂呢?即使初始在BILL_AMT_1上的分裂不是最优的,最终结果可能会更好,如果树是这样生长的。如果存在这样的解决方案,算法是无法找到的,因为它只考虑每个节点的最佳分裂,而不考虑未来可能的分裂。
我们仍然使用贪心的树生长算法的原因是,考虑所有可能的分裂方式需要的时间相当长,这样才能找到真正最优的树。尽管决策树训练过程中有这一缺陷,但仍然有方法可以减少贪心算法可能带来的不利影响。你可以将splitter关键字参数设置为random,以便在每个节点选择一个随机特征进行分裂,而不是寻找最优的分裂。默认值是best,它会搜索所有特征以找到最佳分裂。另一个我们已经讨论过的选项是,通过max_features关键字限制在每次分裂时将搜索的特征数量。最后,你还可以使用决策树的集成方法,如随机森林,我们稍后会介绍。请注意,所有这些选项除了可能避免贪心算法的副作用外,也是解决决策树常被批评的过拟合问题的选项。
训练决策树:不同的停止标准与其他选项
我们已经回顾了使用max_depth参数来限制树的生长深度。然而,scikit-learn 中还有几个其他可选项。这些选项主要与叶子节点中样本的数量相关,或者进一步分裂节点时如何减少不纯度。如前所述,树的生长深度可能会受到数据集大小的限制。如果分裂过程不再找到具有显著更高纯度的节点,那么继续加深树的深度可能没有意义。
我们在这里总结了你可以提供给DecisionTreeClassifier类的所有关键字参数,适用于 scikit-learn:
图 5.10:scikit-learn 中决策树分类器的完整选项列表
使用决策树:优势与预测概率
尽管决策树在概念上很简单,但它们具有多个实际优势。
无需缩放特征
考虑我们为什么需要对特征进行缩放以应用逻辑回归。一个原因是,对于一些基于梯度下降的解决算法,特征必须在相同的尺度上,以便快速找到成本函数的最小值。另一个原因是,当我们使用 L1 或 L2 正则化来惩罚系数时,所有特征必须在相同的尺度上,这样才能均等地对它们进行惩罚。而对于决策树,节点分裂算法会单独考虑每个特征,因此特征是否在相同尺度上并不重要。
非线性关系与交互作用
因为决策树中的每个后续分裂都是在先前分裂产生的训练样本子集上进行的,所以决策树能够描述单个特征的复杂非线性关系,以及特征之间的交互作用。回想我们之前在首次分裂所使用的特征:与单变量特征选择和交互作用的关联部分中的讨论。另外,作为一个假设的例子,考虑以下分类的合成数据集:
图 5.11:一个示例分类数据集,类别以红色和蓝色显示(如果是黑白阅读,请参阅 GitHub 仓库获取该图的彩色版本;蓝色点位于内圆圈内)
我们从第三章,逻辑回归和特征探索的详细信息中了解到,逻辑回归具有线性决策边界。那么,你认为逻辑回归如何处理像图 5.11中展示的数据集呢?你会在哪里画一条线来分隔蓝色和红色类别?应该很明显,在没有额外工程特征的情况下,逻辑回归不太可能是这个数据的好分类器。现在想一想决策树的“如果,那么”的规则,它可以与图 5.11中X和Y轴上表示的特征一起使用。你认为决策树对这组数据有效吗?
在这里,我们在背景中绘制了这两个模型的类别成员预测概率,使用红色和蓝色表示:
图 5.12:决策树和逻辑回归预测
在图 5.12中,两个模型的预测概率已经上色,深红色表示红色类别的较高预测概率,深蓝色表示蓝色类别的较高预测概率。我们可以看到,决策树能够将蓝色类别从红色点的中间圈分隔出来。这是因为,通过在节点分裂过程中使用X和Y坐标的阈值,决策树可以在数学上模拟蓝色和红色类别的位置依赖于X和Y坐标(交互作用),并且每个类别的可能性不是X或Y的线性增减函数(非线性)。因此,决策树方法能够正确分类大多数数据。
注意
生成图 5.11和图 5.12的代码可以在参考笔记本中找到:packt.link/9W4WN。
然而,逻辑回归具有线性决策边界,这将是背景中最浅蓝色和红色区域之间的直线。逻辑回归的决策边界穿过数据的中间,并未提供一个有效的分类器。这展示了决策树“开箱即用”的强大功能,而无需工程化非线性或交互特征。
预测概率
我们知道逻辑回归输出的是概率。然而,决策树是根据叶节点中的多数类来做出预测的。那么,像图 5.12中所示的预测概率从哪里来呢?实际上,决策树在 scikit-learn 中确实提供了.predict_proba方法来计算预测概率。该概率基于用于给定预测的叶节点中多数类的比例。例如,如果一个叶节点中 75%的样本属于正类,那么该节点的预测将是正类,预测的概率将是 0.75。来自决策树的预测概率在统计上不如广义线性模型的预测概率严谨,但它们仍然被广泛用于通过变化分类阈值来衡量模型性能的方法,如 ROC 曲线或精确度-召回曲线。
注意
在这里,我们专注于分类决策树,因为案例研究的性质。然而,决策树也可以用于回归,这使它成为一种多功能的方法。决策树的生长过程对于回归和分类是类似的,唯一的区别是,回归树不是寻求减少节点的不纯度,而是寻求最小化其他指标,如均方误差(MSE)或平均绝对误差(MAE),其中节点的预测可能是该节点中样本的平均值或中位数。
更便捷的交叉验证方法
在第四章,偏差-方差权衡中,我们通过编写自己的函数来进行交叉验证,深入理解了交叉验证的概念,使用KFold类来生成训练和测试索引。这对于全面理解这个过程如何工作非常有帮助。然而,scikit-learn 提供了一个便捷的类,可以为我们做更多繁重的工作:GridSearchCV。GridSearchCV可以作为输入,接受我们想要寻找最优超参数的模型,如决策树或逻辑回归,以及我们希望进行交叉验证的超参数“网格”。例如,在逻辑回归中,我们可能希望获得不同正则化参数C值下,所有折叠的平均交叉验证得分。在决策树中,我们可能希望探索不同的树深度。
你也可以一次性搜索多个参数,例如,如果我们想尝试不同的树深度和不同数量的max_features来考虑每个节点的分裂。
GridSearchCV执行的是所谓的“穷举式网格搜索”,遍历我们提供的所有可能的参数组合。这意味着,如果我们为每个超参数提供五个不同的值,那么交叉验证过程将运行 5 x 5 = 25 次。如果你在搜索许多超参数的多个值时,交叉验证的运行次数会迅速增加。在这种情况下,你可能会想使用RandomizedSearchCV,它会从你提供的网格中搜索超参数组合的一个随机子集。
GridSearchCV通过简化交叉验证过程,可以加速你的工作。你应该已经了解了前一章中交叉验证的概念,因此我们直接列出GridSearchCV可用的所有选项。
在接下来的练习中,我们将通过实际操作使用GridSearchCV,结合案例研究数据,来搜索决策树分类器的超参数。以下是GridSearchCV的选项:
图 5.13:GridSearchCV 的选项
在接下来的练习中,我们将利用均值的标准误差来创建误差条。我们将对模型性能指标在测试折中的平均值进行计算,误差条将帮助我们可视化模型性能在各个折中的变化程度。
平均值的标准误差也被称为样本均值的抽样分布的标准差。这个名字很长,但概念并不复杂。其背后的思想是,我们希望为模型性能度量创建误差条的总体,代表了从一个理论上较大的类似样本群体中抽取样本的一种可能方式,例如如果有更多数据可用并用它进行更多的测试折叠。如果我们能从较大的总体中进行反复抽样,每次抽样事件会导致略微不同的均值(样本均值)。通过反复抽样事件构建这些均值的分布(样本均值的抽样分布)可以让我们知道这个抽样分布的方差,这将作为样本均值的不确定性度量。事实证明,这个方差(我们称之为,其中
表示这是样本均值的方差)取决于我们样本中的观察次数(n):它与样本大小成反比,但也与更大、未观察的总体方差
成正比。如果您在处理样本均值的标准差,简单地对两边取平方根:
。虽然我们不知道
的真实值,因为我们无法观察到理论总体,但我们可以通过观察到的测试折叠的总体方差来估计它。
这是统计学中的一个关键概念,称为中心极限定理。
练习 5.02:为决策树寻找最优超参数
在本练习中,我们将使用GridSearchCV来调优决策树模型的超参数。您将学习一种使用 scikit-learn 搜索不同超参数的便捷方法。请执行以下步骤来完成练习:
注意
在开始本练习之前,您需要导入必要的包并加载已清理的数据框。您可以参考以下 Jupyter notebook 来了解前提步骤:packt.link/SKuoB。
-
使用以下代码导入
GridSearchCV类:from sklearn.model_selection import GridSearchCV下一步是定义我们希望使用交叉验证进行搜索的超参数。我们将通过
max_depth参数找到树的最佳最大深度。深度较大的树会有更多的节点分裂,这些分裂使用特征将训练集划分为越来越小的子空间。虽然我们无法预先知道最佳的最大深度,但在考虑用于网格搜索的参数范围时,考虑一些极限情况是有帮助的。我们知道,1 是最小深度,由只有一个分割的树构成。至于最大深度,你可以考虑你的训练数据中有多少个样本,或者更适当地,在这种情况下,考虑交叉验证每次分割时,训练折叠中有多少个样本。我们将像上一章一样执行 4 折交叉验证。那么,每个训练折叠中将有多少样本,这与树的深度有什么关系?
-
使用以下代码查找训练数据中的样本数量:
X_train.shape输出应如下所示:
(21331, 17)在 21,331 个训练样本和 4 折交叉验证的情况下,每个训练折叠中将有三分之四的样本,即大约 16,000 个样本。
max_depth超参数。我们将探索从 1 到 12 的深度范围。 -
定义一个字典,键为超参数名称,值为我们想要在交叉验证中搜索的该超参数的值列表:
params = {'max_depth':[1, 2, 4, 6, 8, 10, 12]}在这种情况下,我们只搜索一个超参数。然而,你可以定义一个字典,包含多个键值对,来同时搜索多个超参数。
-
如果你在一个笔记本中运行本章的所有练习,可以重用之前的决策树对象
dt。如果没有,你需要为超参数搜索创建一个决策树对象:dt = tree.DecisionTreeClassifier()现在我们想要实例化
GridSearchCV类。 -
使用这些选项实例化
GridSearchCV类:cv = GridSearchCV(dt, param_grid=params, scoring='roc_auc', n_jobs=None, refit=True, cv=4, verbose=1, pre_dispatch=None, error_score=np.nan, return_train_score=True)请注意,我们使用的是 ROC AUC 指标(
scoring='roc_auc'),进行 4 折交叉验证(cv=4),并计算训练分数(return_train_score=True)来评估偏差-方差权衡。一旦交叉验证对象被定义,我们可以像使用模型对象一样,简单地对其使用
.fit方法。这基本上封装了我们在上一章中编写的交叉验证循环的所有功能。 -
使用以下代码执行 4 折交叉验证,搜索最优的最大深度:
cv.fit(X_train, y_train)输出应如下所示:
图 5.14:交叉验证拟合输出
我们指定的所有选项都会作为输出打印出来。此外,还有一些关于执行了多少次交叉验证拟合的输出信息。我们有 4 个折叠和 7 个超参数,意味着执行了 4 x 7 = 28 次拟合。还显示了这所花费的时间。你可以通过
verbose关键字参数控制从该过程中获得的输出量;较大的数字意味着更多的输出。现在是时候查看交叉验证过程的结果了。在已拟合的
GridSearchCV对象上,有一个方法是.cv_results_。这是一个字典,字典的键是结果的名称,值是结果本身。例如,mean_test_score键包含了每个超参数的平均测试得分。你可以通过在代码单元中运行cv.cv_results_来直接查看这个输出。然而,这样查看输出不太方便。具有这种结构的字典可以直接用于创建 pandas DataFrame,这样查看结果会稍微容易一些。 -
运行以下代码来创建并查看一个 pandas DataFrame,该 DataFrame 显示了交叉验证的结果:
cv_results_df = pd.DataFrame(cv.cv_results_) cv_results_df输出应如下所示:
](tos-cn-i-73owjymdk6/c1ee692296c9423d87fd7f73916286b6)
图 5.15:交叉验证结果 DataFrame 的前几列
DataFrame 中每一行代表网格中每一组超参数的组合。由于我们这里只搜索一个超参数,因此每一行代表我们搜索的七个值之一。你可以看到每一行的输出信息,包括每一折训练(拟合)和测试(评分)所用时间的均值和标准差,单位为秒。搜索的超参数值也会显示出来。在图 5.16中,我们可以看到第一折(索引 0)的测试数据的 ROC AUC 分数。那么结果 DataFrame 中其余的列包含了什么内容呢?
-
使用以下代码查看结果 DataFrame 中剩余列的名称:
cv_results_df.columns输出应如下所示:
Index(['mean_fit_time', 'std_fit_time',\ 'mean_score_time', 'std_score_time',\ 'param_max_depth', 'params',\ 'split0_test_score', 'split1_test_score',\ 'split2_test_score', 'split3_test_score',\ 'mean_test_score', 'std_test_score',\ 'rank_test_score', 'split0_train_score',\ 'split1_train_score', 'split2_train_score',\ 'split3_train_score', 'mean_train_score',\ 'std_train_score'], dtype='object')交叉验证结果 DataFrame 中的列包括每一折的测试得分、它们的平均值和标准差,以及训练得分的相同信息。
一般来说,“最佳”超参数组合是具有最高平均测试得分的组合。这是对使用这些超参数拟合的模型,在新数据上评分时可能表现如何的估计。我们绘制一张图,展示平均测试得分如何随
max_depth超参数变化。我们还将在同一张图上展示平均训练得分,以查看随着我们允许在模型拟合过程中生长更深、更复杂的树,偏差和方差是如何变化的。我们将 4 折的训练和测试得分的标准误差作为误差条,使用 Matplotlib 的
errorbar函数。这将显示得分在各折之间的变异情况。 -
执行以下代码,创建一个关于每个
max_depth值的训练和测试得分的误差条图,这些值是在交叉验证中进行检查的:ax = plt.axes() ax.errorbar(cv_results_df['param_max_depth'], cv_results_df['mean_train_score'], yerr=cv_results_df['std_train_score']/np.sqrt(4), label='Mean $\pm$ 1 SE training scores') ax.errorbar(cv_results_df['param_max_depth'], cv_results_df['mean_test_score'], yerr=cv_results_df['std_test_score']/np.sqrt(4), label='Mean $\pm$ 1 SE testing scores') ax.legend() plt.xlabel('max_depth') plt.ylabel('ROC AUC')该图应如下所示:
](tos-cn-i-73owjymdk6/c1ee692296c9423d87fd7f73916286b6)
图 5.16:跨四折的训练和测试得分的误差条图
请注意,标准误差是通过标准差除以折叠数量的平方根来计算的。训练和测试评分的标准误差以垂直线的形式显示在每个尝试过的max_depth值处;平均分数上下的距离表示 1 个标准误差。在制作误差条图时,最好确保误差测量的单位与y轴的单位相同。在本例中,它们是相同的,因为标准误差的单位与底层数据相同,而不是像方差那样的平方单位。
错误条表示评分在不同折叠之间的变动情况。如果各个折叠之间的变异性很大,这表明数据在不同折叠之间的性质存在差异,从而影响了我们模型的拟合能力。这可能是个问题,因为这意味着我们可能没有足够的数据来训练一个能在新数据上稳定表现的模型。然而,在我们这里,折叠之间的变异性并不大,因此这不是问题。
那么,不同max_depth值下训练和测试评分的总体趋势如何呢?我们可以看到,随着树的深度越来越大,模型对训练数据的拟合越来越好。如前所述,如果我们把树长得足够深,使得每个叶节点只有一个训练样本,我们将会创建一个非常针对训练数据的模型。事实上,它将完美地拟合训练数据。我们可以说,这样的模型具有极高的方差。
但是,训练集上的表现不一定会转化到测试集上。在图 5.16中,我们可以明显看到,增加max_depth只会在某个点之前提高测试评分,而超过该点后,树的深度增加反而导致测试表现下降。这是另一个例子,说明我们如何利用偏差-方差权衡来创建更好的预测模型——类似于我们使用正则化的逻辑回归。较浅的树具有更高的偏差,因为它们无法很好地拟合训练数据。但这是可以接受的,因为如果我们接受一定的偏差,我们将在测试数据上获得更好的表现,而测试数据才是我们最终关心的指标。
在这种情况下,我们选择max_depth = 6。你也可以通过尝试 2 到 12 之间的每个整数来进行更彻底的搜索,而不是像我们这里那样每次跳 2 个值。一般来说,最好尽可能深入地探索参数空间,直到你有的计算时间为止。在本例中,这将导致相同的结果。
模型比较
到目前为止,我们已经对案例研究数据进行了几种不同机器学习模型的 4 折交叉验证。那么,我们的表现如何呢?到目前为止,我们的最佳表现是什么?在上一章中,我们使用逻辑回归得到了平均测试 ROC AUC 为 0.718,通过在逻辑回归中工程化交互特征得到了 0.740。而在这里,使用决策树,我们可以达到 0.745。所以,我们在模型性能上有所提升。现在,让我们探索另一种基于决策树的模型,看看是否能够进一步提高性能。
随机森林:决策树的集成
正如我们在前面的练习中看到的,决策树容易出现过拟合问题。这是它们使用的主要批评之一,尽管它们具有高度的可解释性。然而,我们通过限制树的最大深度,能够在一定程度上限制这种过拟合。
基于决策树的概念,机器学习研究人员利用多棵树作为更复杂过程的基础,最终形成了一些最强大且最广泛使用的预测模型。在本章中,我们将重点介绍决策树的随机森林。随机森林是所谓的集成模型的例子,因为它们是通过组合其他更简单的模型形成的。通过结合多个模型的预测,可以改善任何给定模型的缺陷。这有时被称为将多个弱学习者结合成一个强学习者。
一旦你理解了决策树,随机森林背后的概念就非常简单。那是因为随机森林只是许多决策树的集成;这种集成中的所有模型都具有相同的数学形式。那么,一个随机森林中会包括多少个决策树模型呢?这是构建随机森林模型时需要指定的超参数之一,n_estimators。一般来说,树木越多越好。随着树木数量的增加,整个集成的方差将减少。这应该导致随机森林模型对新数据的泛化能力更强,反映在测试分数的提高上。然而,在某个点之后,增加树木的数量将不再显著提高模型的性能。
那么,随机森林是如何减少影响决策树的高方差(过拟合)问题的呢?这个问题的答案在于森林中不同树的不同之处。树之间的差异主要有两种方式,其中一种我们已经熟悉:
-
每次划分时考虑的特征数量
-
用于生长不同树的训练样本
每次划分时考虑的特征数量
我们已经熟悉了DecisionTreeClassifier类中的这个选项:max_features。在之前使用该类时,我们将max_features保持在默认值None,这意味着每次分割时会考虑所有特征。通过使用所有特征来拟合训练数据,可能会导致过拟合。通过限制每次分割时考虑的特征数量,随机森林中的某些决策树可能会找到更好的分割点。这是因为,尽管它们仍在贪心地寻找最佳分割,但它们是在有限的特征选择下进行的。这可能会使得某些分割在树的后续部分变得可能,而如果在每次分割时都搜索所有特征,可能就无法找到这些分割点。
在 scikit-learn 中的RandomForestClassifier类中有一个max_features选项,就像在DecisionTreeClassifier类中一样,这两个选项是类似的。然而,对于随机森林,默认设置是'auto',这意味着算法每次分割时只会搜索可能特征数量的平方根的随机选择。例如,从总共有 9 个可能特征中,随机选择√9 = 3 个特征。由于森林中的每棵树在生长过程中可能会选择不同的特征随机分割,因此森林中的树木不会完全相同。
用于生成不同树木的样本
随机森林中的树木彼此之间的另一种区别是它们通常使用不同的训练样本进行生长。为此,需要使用一种叫做自助抽样(bootstrapping)的统计方法,这意味着从原始数据中生成新的合成数据集。合成数据集通过从原始数据集中随机选择样本来创建,并允许重复选择。这里的“重复选择”意味着,如果我们选择了某个样本,我们将继续考虑该样本用于选择,也就是说,它在被采样后会被“替换”到原始数据集中。合成数据集中的样本数量与原始数据集中的样本数量相同,但由于替换机制,一些样本可能会重复,而另一些样本则可能完全不在其中。
使用随机抽样创建合成数据集并分别对其进行训练的过程称为袋装法(bagging),即自助聚合(bootstrapped aggregation)的简称。事实上,袋装法可以与任何机器学习模型一起使用,而不仅仅是决策树,scikit-learn 提供了该功能,用于分类问题(BaggingClassifier)和回归问题(BaggingRegressor)。对于随机森林来说,袋装法默认开启,且bootstrap选项被设置为True。但是,如果你希望森林中的所有树都使用全部训练数据进行生长,可以将该选项设置为False。
现在你应该对随机森林有了一个较好的理解。正如你所见,如果你已经熟悉决策树,那么理解随机森林并不涉及太多额外的知识。这个事实的体现是,scikit-learn中的RandomForestClassifier类的超参数大多数与DecisionTreeClassifier类的超参数相同。
除了我们之前讨论过的n_estimators和bootstrap,还有两个新选项,它们超出了决策树的可用选项:
-
oob_score,一个bool值:此选项控制是否计算 OOB 分数,True表示计算,False(默认值)表示不计算。 -
warm_start,一个bool值:默认值为False——如果将其设置为True,则重新使用相同的随机森林模型对象会在已生成的森林中添加额外的树。 -
max_samples,一个int或float值:控制在使用自助法程序训练每棵树时使用多少样本。默认值是使用与原始数据集相同的样本数。
其他类型的集成模型
随着我们现在所知道的,随机森林是袋装集成的一种示例。另一种集成方法是提升集成。提升的一般思路是使用一系列相同类型的新模型,并且将它们训练在前一个模型的错误上。通过这种方式,后续模型学习到早期模型未能解决的问题,并对这些错误进行修正。提升在决策树中获得了成功的应用,并且在scikit-learn和另一个流行的 Python 库 XGBoost 中都可以使用。我们将在下一章讨论提升。
堆叠集成方法是一种较为高级的集成方法,在这种方法中,集成中的不同模型(估计器)不需要像在袋装法和提升法中那样是相同类型的。例如,你可以通过随机森林和逻辑回归来构建一个堆叠集成。集成中不同成员的预测会通过另一个模型(堆叠器)结合起来进行最终预测,该模型将堆叠模型的预测作为特征来考虑。
随机森林:预测与可解释性
由于随机森林只是决策树的集合,因此,必须以某种方式将所有这些树的预测结合起来,以得出随机森林的预测。
在模型训练后,分类树将接受一个输入样本并生成一个预测类别,例如,在我们的案例研究问题中,预测信用账户是否会违约。将这些树的预测组合成森林的最终预测的一种直观方法是采用多数投票。也就是说,无论所有树的最常见预测是什么,它就成为该样本的森林预测。这是最初描述随机森林的文献中采用的方法(scikit-learn.org/stable/modules/ensemble.html#forest)。然而,scikit-learn 使用的是一种稍有不同的方法:将每个类别的预测概率相加,然后选择具有最高概率和的类别。这比仅仅选择预测类别捕获了更多来自每棵树的信息。
随机森林的可解释性
决策树的主要优势之一是它能够直观地看到每个单独预测的生成过程。你可以通过一系列“如果, 那么”的规则追溯任何样本的决策路径,准确知道它是如何得出该预测的。相反,假设你有一个包含 1,000 棵树的随机森林,这意味着有 1,000 组这样的规则,它们比单一规则更难以向人类传达!
尽管如此,仍然有多种方法可以用来理解随机森林是如何做出预测的。一种简单的方法是观察特征重要性,这种方法可以在 scikit-learn 中使用。随机森林的特征重要性是衡量在生成森林中的树木时,每个特征的有用程度。这种有用性是通过结合每个特征在训练样本中被用于分裂的比例,以及由此导致的节点杂质降低来度量的。
由于特征重要性计算,可以通过该计算按特征在随机森林模型中的影响力对特征进行排序,因此,随机森林还可以用于特征选择。
练习 5.03:拟合随机森林
在这个练习中,我们将通过使用随机森林模型并在案例研究中的训练数据上进行交叉验证,来扩展我们在决策树方面的工作。我们将观察增加森林中树木数量的效果,并检查可以使用随机森林模型计算的特征重要性。请执行以下步骤来完成此练习:
注意
本练习的 Jupyter 笔记本可以在packt.link/VSz2T找到。此笔记本包含了导入必要库和加载清洗后的数据框的前提步骤。请在开始本练习之前执行这些步骤。
-
按如下方式导入随机森林分类器模型类:
from sklearn.ensemble import RandomForestClassifier -
使用这些选项实例化类:
rf = RandomForestClassifier(n_estimators=10,\ criterion='gini',\ max_depth=3,\ min_samples_split=2,\ min_samples_leaf=1,\ min_weight_fraction_leaf=0.0,\ max_features='auto',\ max_leaf_nodes=None,\ min_impurity_decrease=0.0,\ min_impurity_split=None,\ bootstrap=True,\ oob_score=False,\ n_jobs=None, random_state=4,\ verbose=0,\ warm_start=False,\ class_weight=None)对于这个练习,我们主要使用默认选项。但请注意,我们将设置
max_depth = 3。在这里,我们只探索使用不同数量的树的效果,因此选择相对较浅的树以便更短的运行时间。要找到最佳模型性能,通常会尝试更多的树和更深的树。我们还为了保证运行结果的一致性设置了
random_state。 -
创建参数网格以便在范围从 10 到 100 的树中搜索:
rf_params_ex = {'n_estimators':list(range(10,110,10))}我们使用 Python 的
range()函数创建所需整数值的迭代器,然后使用list()将其转换为列表。 -
使用先前步骤中的参数网格实例化随机森林模型的网格搜索交叉验证对象。否则,您可以使用与决策树交叉验证相同的选项:
cv_rf_ex = GridSearchCV(rf, param_grid=rf_params_ex, scoring='roc_auc', n_jobs=None, refit=True, cv=4, verbose=1, pre_dispatch=None, error_score=np.nan, return_train_score=True) -
将交叉验证对象拟合如下:
cv_rf_ex.fit(X_train, y_train)拟合过程应该输出以下内容:
图 5.17:随机森林在不同数量的树上的交叉验证输出
你可能已经注意到,尽管我们只在 10 个超参数值上进行交叉验证,与前一练习中决策树所检验的 7 个值相比,这次的交叉验证花费的时间明显更长。考虑一下我们在这种情况下生成了多少棵树。对于最后一个超参数
n_estimators = 100,我们在所有交叉验证的拆分中总共生成了 400 棵树。我们刚才尝试的各种树的模型拟合时间是多长?通过使用更多的树,我们在交叉验证测试性能方面获得了多大的提升?这些都是通过绘图来检查的好方法。首先,我们将交叉验证结果提取到一个 pandas DataFrame 中,就像以前做过的那样。
-
将交叉验证结果放入 pandas DataFrame 中:
cv_rf_ex_results_df = pd.DataFrame(cv_rf_ex.cv_results_)您可以在附带的 Jupyter 笔记本中查看整个 DataFrame。这里,我们直接转向创建感兴趣的量的图形。我们将制作一条线图,其中包含每个超参数的平均拟合时间(包含在
mean_fit_time列中的符号),以及一个测试分数的误差条形图,这些我们已经为决策树做过。两个图都将根据* x *轴上的树数量进行绘制。 -
创建两个子图,分别显示平均训练时间和平均测试分数及标准误差:
fig, axs = plt.subplots(nrows=1, ncols=2, figsize=(6, 3)) axs[0].plot(cv_rf_ex_results_df['param_n_estimators'], cv_rf_ex_results_df['mean_fit_time'], '-o') axs[0].set_xlabel('Number of trees') axs[0].set_ylabel('Mean fit time (seconds)') axs[1].errorbar(cv_rf_ex_results_df['param_n_estimators'], cv_rf_ex_results_df['mean_test_score'], yerr=cv_rf_ex_results_df['std_test_score']/np.sqrt(4)) axs[1].set_xlabel('Number of trees') axs[1].set_ylabel('Mean testing ROC AUC $\pm$ 1 SE ') plt.tight_layout()在这里,我们使用
plt.subplots一次创建了一个 figure 内的两个轴,配置为一行两列。然后,我们通过对返回的axs轴数组进行索引来访问轴对象,以便创建绘图。输出应该类似于此图:
图 5.18:不同数量树木的森林的平均拟合时间和测试分数
注
由于平台差异或设置了不同的随机种子,你的结果可能会有所不同。
关于这些可视化,还有几个要注意的点。首先,我们可以看到,通过使用随机森林,我们在交叉验证测试折叠上的模型表现超过了我们之前的任何尝试。虽然我们还没有调整随机森林的超参数以获得最佳的模型性能,但这是一个有前景的结果,表明随机森林将是我们建模工作中一个有价值的补充。
然而,随着这些更高模型测试分数的提升,请注意,折叠之间的变异性也比我们在决策树中看到的更大;这种变异性在模型测试分数的标准误差中表现得更加明显。虽然这表明使用该模型时,模型性能的范围可能更广,但我们建议直接在 Jupyter notebook 中的 pandas DataFrame 中查看各个折叠的模型测试分数。你会看到,即使是个别折叠的最低分数,仍然高于决策树的平均测试分数,这表明使用随机森林会更好。
那么,我们用这个可视化来探讨的其他问题如何呢?我们感兴趣的是查看拟合不同数量树木的随机森林模型需要多长时间,以及使用更多树木时模型性能的提升。图 5.18 左侧的小图显示,随着树木数量的增加,训练时间呈现出相当线性的增长。这可能是可以预期的;通过增加更多树木,我们实际上是在增加训练过程中需要进行的计算量。
但是,考虑到模型性能的提升,这额外的计算时间是否值得呢?图 5.18 右侧的小图显示,超过大约 20 棵树后,添加更多树木并不一定能可靠地提高测试性能。虽然拥有 50 棵树的模型得分最高,但添加更多树木实际上使测试分数略有下降,这表明 50 棵树的 ROC AUC 增益可能只是由于随机性,因为理论上增加更多树木应该提高模型性能。基于这个推理,如果我们将
max_depth = 3作为限制,我们可能会选择 20 棵或许 50 棵树的森林继续进行。但在本章最后的活动中,我们将更全面地探索这个参数空间。最后,请注意,我们没有在这里显示训练集的 ROC AUC 指标。如果你绘制这些或在结果数据框中查看,你会发现训练分数高于测试分数,这表明某些过拟合现象正在发生。尽管如此,仍然可以确定这个随机森林模型的交叉验证测试分数比我们观察到的任何其他模型的分数要高。基于这个结果,我们很可能会选择此时的随机森林模型。
为了更深入地了解我们可以通过拟合的交叉验证对象访问哪些内容,让我们看看最佳超参数和特征重要性。
-
使用以下代码查看交叉验证中最好的超参数:
cv_rf_ex.best_params_这应该是输出结果:
{'n_estimators': 50}这里,best 仅仅指的是那些导致最高平均模型测试分数的超参数。
-
运行此代码以创建特征名称和重要性的数据框,然后显示按重要性排序的横向条形图:
feat_imp_df = pd.DataFrame({ 'Importance':cv_rf_ex.best_estimator_.feature_importances_ }, index=features_response[:-1]) feat_imp_df.sort_values('Importance', ascending=True).plot.barh()图表应该是这样的:
图 5.19:来自随机森林的特征重要性
在这段代码中,我们创建了一个包含特征重要性的字典,并将它与特征名称作为索引一起使用,以创建一个数据框。特征重要性来自拟合后的交叉验证对象的best_estimator_方法,所以它指的是具有最高平均测试分数的模型(换句话说,就是具有 50 棵树的模型)。这是一种访问随机森林模型对象的方法,该对象已经在所有训练数据上进行了训练,并使用了交叉验证网格搜索找到的最佳超参数。feature_importances_是可以在已拟合的随机森林模型上使用的方法。
在访问了所有这些属性后,我们将它们绘制在一个横向条形图上,这是一种方便查看特征重要性的方法。请注意,来自随机森林的前五个最重要特征与第三章中通过 ANOVA F 检验选择的前五个特征相同,尽管它们的顺序有所不同。这是不同方法之间的一种良好确认。
棋盘图
在继续进行活动之前,我们展示了一种在 Matplotlib 中进行可视化的技术。绘制一个二维网格,并在其上用彩色方块或其他形状表示数据,当你想展示数据的三个维度时,这种方法很有用。这里,颜色表示第三维度。例如,您可能想要在两个超参数的网格上可视化模型的测试分数,就像我们将在活动 5.01中做的那样,使用随机森林的交叉验证网格搜索。
过程的第一步是创建 x 和 y 坐标的网格。可以使用 NumPy 的 meshgrid 函数来完成这项工作。该函数接受一维的 x 和 y 坐标数组,并创建所有可能的坐标对的网格。网格中的点将是棋盘图中每个方块的角落。下面是一个 4 x 4 彩色块网格的代码示例。由于我们指定了角落,所以我们需要一个 5 x 5 的点网格。我们还展示了 x 和 y 坐标的数组:
xx_example, yy_example = np.meshgrid(range(5), range(5))
print(xx_example)
print(yy_example)
输出如下:
[[0 1 2 3 4]
[0 1 2 3 4]
[0 1 2 3 4]
[0 1 2 3 4]
[0 1 2 3 4]]
[[0 0 0 0 0]
[1 1 1 1 1]
[2 2 2 2 2]
[3 3 3 3 3]
[4 4 4 4 4]]
用于在此网格上绘制的数据应该具有 4 x 4 的形状。我们创建了一个包含从 1 到 16 的整数的一维数组,并将其重塑为一个二维的 4 x 4 网格:
z_example = np.arange(1,17).reshape(4,4)
z_example
这将输出以下内容:
array([[ 1, 2, 3, 4],
[ 5, 6, 7, 8],
[ 9, 10, 11, 12],
[13, 14, 15, 16]])
我们可以使用以下代码在 xx_example, yy_example 网格上绘制 z_example 数据。请注意,我们使用 pcolormesh 来生成图表,并采用 jet 色图,这会给出彩虹色的颜色刻度。我们还添加了一个 colorbar,它需要传入 pcolor_ex 对象(由 pcolormesh 返回)作为参数,这样颜色刻度的解释就会清晰:
ax = plt.axes()
pcolor_ex = ax.pcolormesh(xx_example, yy_example, z_example,
cmap=plt.cm.jet)
plt.colorbar(pcolor_ex, label='Color scale')
ax.set_xlabel('X coordinate')
ax.set_ylabel('Y coordinate')
图 5.20:连续整数的 pcolormesh 图
活动 5.01:使用随机森林进行交叉验证网格搜索
在这个活动中,你将对随机森林模型在案例研究数据上进行网格搜索,搜索的超参数包括森林中的树木数量(n_estimators)和树的最大深度(max_depth)。然后,你将创建一个可视化,展示你搜索过的超参数网格的平均测试得分。请按照以下步骤完成此活动:
-
创建一个字典,表示将要搜索的
max_depth和n_estimators超参数的网格。包括深度 3、6、9 和 12,以及树木数量 10、50、100 和 200。将其他超参数保持为默认值。 -
使用本章前面提到的相同选项实例化一个
GridSearchCV对象,但使用步骤 1 中创建的超参数字典。设置verbose=2,以查看每次拟合过程的输出。你可以重用我们之前使用的随机森林模型对象rf,或者创建一个新的。 -
在训练数据上拟合
GridSearchCV对象。 -
将网格搜索的结果放入 pandas DataFrame 中。
-
创建一个
pcolormesh可视化,显示每个超参数组合的平均测试得分。你应该得到类似于以下的可视化效果:图 5.21:随机森林在具有两个超参数的网格上的交叉验证结果
-
确定使用哪组超参数。
注意
包含此活动的 Python 代码的 Jupyter 笔记本可以在
packt.link/D0OBc找到。此活动的详细分步解决方案可以通过这个链接找到。
总结
在本章中,我们学习了如何使用决策树和由多个决策树组成的集成模型——随机森林。通过使用这些简单构思的模型,我们能够比使用逻辑回归做出更好的预测,这从交叉验证的 ROC AUC 分数中可以看出。这种情况在许多实际问题中都适用。决策树对于很多可能影响逻辑回归模型性能的潜在问题具有较强的鲁棒性,例如特征与响应变量之间的非线性关系,以及特征之间复杂交互的存在。
尽管单棵决策树容易出现过拟合问题,但随机森林集成方法已被证明能够减少这一高方差问题。随机森林通过训练多棵树来构建。通过仅在部分可用训练集上训练每棵树(自助聚合或袋 ging),并且在每个节点分裂时只考虑减少的特征数量,树的集成可以减少方差,同时增加单棵树的偏差。
现在我们已经尝试了几种不同的机器学习方法来建模案例数据,发现有些方法效果更好;例如,经过调优的超参数的随机森林提供了最高的平均交叉验证 ROC AUC 分数 0.776,正如我们在活动 5中看到的,使用随机森林进行交叉验证网格搜索。
在下一章中,我们将学习另一种集成方法——梯度提升,它通常与决策树结合使用。梯度提升在所有机器学习模型中为二分类问题提供了一些最佳性能。我们还将学习一种强大的方法,通过SHapely 加法解释(SHAP)值来解释和解读梯度提升树集成的预测。