FastAI 之书(面向程序员的 FastAI)(四)(2)https://developer.aliyun.com/article/1483408
然而,底层项目都是数字:
to.items.head(3)
state | ProductGroup | Drive_System | Enclosure | |
0 | 1 | 6 | 0 | 3 |
1 | 33 | 6 | 0 | 3 |
2 | 32 | 3 | 0 | 6 |
将分类列转换为数字是通过简单地用数字替换每个唯一级别来完成的。与级别相关联的数字是按照它们在列中出现的顺序连续选择的,因此在转换后的分类列中,数字没有特定的含义。唯一的例外是,如果您首先将列转换为 Pandas 有序类别(就像我们之前为ProductSize
所做的那样),那么您选择的排序将被使用。我们可以通过查看classes
属性来查看映射:
to.classes['ProductSize']
(#7) ['#na#','Large','Large / Medium','Medium','Small','Mini','Compact']
由于处理数据到这一点需要一分钟左右的时间,我们应该保存它,这样以后我们可以继续从这里继续工作,而不必重新运行之前的步骤。fastai 提供了一个使用 Python 的pickle系统保存几乎任何 Python 对象的save
方法:
(path/'to.pkl').save(to)
以后要读回来,您将键入:
to = (path/'to.pkl').load()
现在所有这些预处理都完成了,我们准备创建一个决策树。
创建决策树
首先,我们定义我们的自变量和因变量:
xs,y = to.train.xs,to.train.y valid_xs,valid_y = to.valid.xs,to.valid.y
现在我们的数据都是数字的,没有缺失值,我们可以创建一个决策树:
m = DecisionTreeRegressor(max_leaf_nodes=4) m.fit(xs, y);
为了简单起见,我们告诉 sklearn 只创建了四个叶节点。要查看它学到了什么,我们可以显示决策树:
draw_tree(m, xs, size=7, leaves_parallel=True, precision=2)
理解这幅图片是理解决策树的最好方法之一,所以我们将从顶部开始,逐步解释每个部分。
顶部节点代表初始模型,在进行任何分割之前,所有数据都在一个组中。这是最简单的模型。这是在不问任何问题的情况下得到的结果,将始终预测值为整个数据集的平均值。在这种情况下,我们可以看到它预测销售价格的对数值为 10.1。它给出了均方误差为 0.48。这个值的平方根是 0.69。(请记住,除非您看到m_rmse
,或者均方根误差,否则您看到的值是在取平方根之前的,因此它只是差异的平方的平均值。)我们还可以看到在这个组中有 404,710 条拍卖记录,这是我们训练集的总大小。这里显示的最后一部分信息是找到的最佳分割的决策标准,即基于coupler_system
列进行分割。
向下移动并向左移动,这个节点告诉我们,在coupler_system
小于 0.5 的设备拍卖记录中有 360,847 条。这个组中我们的因变量的平均值是 10.21。从初始模型向下移动并向右移动,我们来到了coupler_system
大于 0.5 的记录。
底部行包含我们的叶节点:没有答案出现的节点,因为没有更多问题需要回答。在这一行的最右边是包含coupler_system
大于 0.5 的记录的节点。平均值为 9.21,因此我们可以看到决策树算法确实找到了一个单一的二进制决策,将高价值与低价值的拍卖结果分开。仅询问coupler_system
预测的平均值为 9.21,而不是 10.1。
在第一个决策点后返回到顶部节点后,我们可以看到已经进行了第二个二进制决策分割,基于询问YearMade
是否小于或等于 1991.5。对于这个条件为真的组(请记住,这是根据coupler_system
和YearMade
进行的两个二进制决策),平均值为 9.97,在这个组中有 155,724 条拍卖记录。对于这个条件为假的拍卖组,平均值为 10.4,有 205,123 条记录。因此,我们可以看到决策树算法成功地将我们更昂贵的拍卖记录分成了两组,这两组在价值上有显著差异。
我们可以使用 Terence Parr 强大的dtreeviz 库显示相同的信息:
samp_idx = np.random.permutation(len(y))[:500] dtreeviz(m, xs.iloc[samp_idx], y.iloc[samp_idx], xs.columns, dep_var, fontname='DejaVu Sans', scale=1.6, label_fontsize=10, orientation='LR')
这显示了每个分割点数据分布的图表。我们可以清楚地看到我们的YearMade
数据存在问题:显然有一些在 1000 年制造的推土机!很可能,这只是一个缺失值代码(在数据中没有出现的值,用作占位符的值,用于在值缺失的情况下)。对于建模目的,1000 是可以的,但正如你所看到的,这个异常值使得我们感兴趣的数值更难以可视化。所以,让我们用 1950 年替换它:
xs.loc[xs['YearMade']<1900, 'YearMade'] = 1950 valid_xs.loc[valid_xs['YearMade']<1900, 'YearMade'] = 1950
这个改变使得树的可视化中的分割更加清晰,尽管这并没有在模型结果上有任何显著的改变。这是决策树对数据问题有多么弹性的一个很好的例子!
m = DecisionTreeRegressor(max_leaf_nodes=4).fit(xs, y) dtreeviz(m, xs.iloc[samp_idx], y.iloc[samp_idx], xs.columns, dep_var, fontname='DejaVu Sans', scale=1.6, label_fontsize=10, orientation='LR')
现在让决策树算法构建一个更大的树。在这里,我们没有传递任何停止标准,比如max_leaf_nodes
:
m = DecisionTreeRegressor() m.fit(xs, y);
我们将创建一个小函数来检查我们模型的均方根误差(m_rmse
),因为比赛是根据这个来评判的:
def r_mse(pred,y): return round(math.sqrt(((pred-y)**2).mean()), 6) def m_rmse(m, xs, y): return r_mse(m.predict(xs), y)
m_rmse(m, xs, y)
0.0
所以,我们的模型完美了,对吧?不要那么快……记住,我们真的需要检查验证集,以确保我们没有过拟合:
m_rmse(m, valid_xs, valid_y)
0.337727
哎呀——看起来我们可能过拟合得很严重。原因如下:
m.get_n_leaves(), len(xs)
(340909, 404710)
我们的叶节点数几乎和数据点一样多!这似乎有点过于热情。事实上,sklearn 的默认设置允许它继续分裂节点,直到每个叶节点只包含一个项目。让我们改变停止规则,告诉 sklearn 确保每个叶节点至少包含 25 个拍卖记录:
m = DecisionTreeRegressor(min_samples_leaf=25) m.fit(to.train.xs, to.train.y) m_rmse(m, xs, y), m_rmse(m, valid_xs, valid_y)
(0.248562, 0.32368)
看起来好多了。让我们再次检查叶节点的数量:
m.get_n_leaves()
12397
更加合理!
Alexis 说
对于一个叶节点比数据项更多的过拟合决策树,这是我的直觉。考虑一下“二十个问题”游戏。在那个游戏中,选择者秘密想象一个物体(比如,“我们的电视机”),猜测者可以提出 20 个是或否的问题来猜测物体是什么(比如“它比一个面包盒大吗?”)。猜测者并不是在尝试预测一个数值,而只是在识别所有可想象物体集合中的特定物体。当你的决策树的叶节点多于域中可能的物体时,它本质上是一个训练有素的猜测者。它已经学会了识别训练集中特定数据项所需的问题序列,并且只是通过描述该项的值来“预测”。这是一种记忆训练集的方式,即过拟合。
构建决策树是创建数据模型的好方法。它非常灵活,因为它可以清楚地处理变量之间的非线性关系和交互作用。但我们可以看到在如何泛化(通过创建小树可以实现)和在训练集上的准确性(通过使用大树可以实现)之间存在一个基本的妥协。
那么我们如何兼顾两全呢?我们将在处理一个重要的遗漏细节之后向您展示:如何处理分类变量。
分类变量
在前一章中,当使用深度学习网络时,我们通过独热编码处理分类变量,并将其馈送到嵌入层。嵌入层帮助模型发现这些变量不同级别的含义(分类变量的级别没有固有含义,除非我们使用 Pandas 手动指定一个排序)。在决策树中,我们没有嵌入层,那么这些未处理的分类变量如何在决策树中发挥作用呢?例如,像产品代码这样的东西如何使用?
简短的答案是:它就是有效!想象一种情况,其中一个产品代码在拍卖中比其他任何产品代码都要昂贵得多。在这种情况下,任何二元分割都将导致该产品代码位于某个组中,而该组将比其他组更昂贵。因此,我们简单的决策树构建算法将选择该分割。稍后,在训练过程中,算法将能够进一步分割包含昂贵产品代码的子组,随着时间的推移,树将聚焦于那一个昂贵的产品。
还可以使用一位编码来替换单个分类变量,其中每一列代表变量的一个可能级别。Pandas 有一个get_dummies
方法可以做到这一点。
然而,实际上并没有证据表明这种方法会改善最终结果。因此,我们通常会尽可能避免使用它,因为它确实会使您的数据集更难处理。在 2019 年,这个问题在 Marvin Wright 和 Inke König 的论文“Splitting on Categorical Predictors in Random Forests”中得到了探讨:
对于名义预测器,标准方法是考虑所有 2^(k − 1) − 1 个k预测类别的 2-分区。然而,这种指数关系会产生大量需要评估的潜在分割,增加了计算复杂性并限制了大多数实现中可能的类别数量。对于二元分类和回归,已经证明按照每个分割中的预测类别进行排序会导致与标准方法完全相同的分割。这减少了计算复杂性,因为对于具有k个类别的名义预测器,只需要考虑k − 1 个分割。
现在您了解了决策树的工作原理,是时候尝试那种最佳的解决方案了:随机森林。
随机森林
1994 年,伯克利大学教授 Leo Breiman 在退休一年后发表了一份名为“Bagging Predictors”的小型技术报告,这个报告成为现代机器学习中最有影响力的想法之一。报告开始说:
Bagging 预测器是一种生成预测器的多个版本并使用这些版本来获得聚合预测器的方法。聚合平均了这些版本……通过对学习集进行自助复制并将其用作新的学习集来形成多个版本。测试表明,bagging 可以显著提高准确性。关键因素是预测方法的不稳定性。如果扰动学习集可以导致构建的预测器发生显著变化,那么 bagging 可以提高准确性。
这是 Breiman 提出的程序:
- 随机选择数据的子集(即“学习集的自助复制”)。
- 使用这个子集训练模型。
- 保存该模型,然后返回到步骤 1 几次。
- 这将为您提供多个经过训练的模型。要进行预测,请使用所有模型进行预测,然后取每个模型预测的平均值。
这个过程被称为bagging。它基于一个深刻而重要的观点:尽管在数据子集上训练的每个模型会比在完整数据集上训练的模型产生更多错误,但这些错误不会相互关联。不同的模型会产生不同的错误。因此,这些错误的平均值为零!因此,如果我们取所有模型预测的平均值,我们应该得到一个预测,随着模型数量的增加,它会越来越接近正确答案。这是一个非凡的结果——这意味着我们可以通过多次在不同随机数据子集上训练它来改进几乎任何类型的机器学习算法的准确性,并对其预测进行平均。
2001 年,Breiman 继续展示了这种建模方法,当应用于决策树构建算法时,特别强大。他甚至比仅仅随机选择每个模型训练的行更进一步,还在每棵决策树的每个分裂点随机选择了一部分列。他将这种方法称为随机森林。今天,它可能是最广泛使用和实际重要的机器学习方法。
实质上,随机森林是一个模型,它平均了大量决策树的预测结果,这些决策树是通过随机变化各种参数生成的,这些参数指定了用于训练树和其他树参数的数据。Bagging 是一种特定的集成方法,或者将多个模型的结果组合在一起。为了看看它在实践中是如何工作的,让我们开始创建我们自己的随机森林!
创建随机森林
我们可以像创建决策树一样创建随机森林,只是现在我们还指定了指示森林中应该有多少树,如何对数据项(行)进行子集化以及如何对字段(列)进行子集化的参数。
在下面的函数定义中,n_estimators
定义了我们想要的树的数量,max_samples
定义了每棵树训练时要抽样的行数,max_features
定义了在每个分裂点抽样的列数(其中0.5
表示“取一半的总列数”)。我们还可以指定何时停止分裂树节点,有效地限制树的深度,通过包含我们在前一节中使用的相同min_samples_leaf
参数。最后,我们传递n_jobs=-1
告诉 sklearn 使用所有 CPU 并行构建树。通过创建一个小函数,我们可以更快地尝试本章其余部分的变化:
def rf(xs, y, n_estimators=40, max_samples=200_000, max_features=0.5, min_samples_leaf=5, **kwargs): return RandomForestRegressor(n_jobs=-1, n_estimators=n_estimators, max_samples=max_samples, max_features=max_features, min_samples_leaf=min_samples_leaf, oob_score=True).fit(xs, y)
m = rf(xs, y);
我们的验证 RMSE 现在比我们上次使用DecisionTreeRegressor
生成的结果要好得多,后者只使用了所有可用数据生成了一棵树:
m_rmse(m, xs, y), m_rmse(m, valid_xs, valid_y)
(0.170896, 0.233502)
随机森林最重要的特性之一是它对超参数选择不太敏感,比如max_features
。您可以将n_estimators
设置为尽可能高的数字,以便训练更多的树,树越多,模型就越准确。max_samples
通常可以保持默认值,除非您有超过 200,000 个数据点,在这种情况下,将其设置为 200,000 将使其在准确性上有很小影响的情况下更快地训练。max_features=0.5
和min_samples_leaf=4
通常效果很好,尽管 sklearn 的默认值也很好。
sklearn 文档展示了一个例子,展示了不同max_features
选择的效果,以及树的数量增加。在图中,蓝色曲线使用最少的特征,绿色曲线使用最多的特征(使用所有特征)。正如您在图 9-7 中所看到的,使用较少特征但具有更多树的模型具有最低的错误结果。
图 9-7. 基于最大特征和树的数量的错误(来源:https://oreil.ly/E0Och)
为了查看n_estimators
的影响,让我们从森林中的每棵树获取预测结果(这些在estimators_
属性中):
preds = np.stack([t.predict(valid_xs) for t in m.estimators_])
如您所见,preds.mean(0)
给出了与我们的随机森林相同的结果:
r_mse(preds.mean(0), valid_y)
0.233502
让我们看看随着树的数量增加,RMSE 会发生什么变化。如您所见,大约在 30 棵树后,改进水平就会显著减少:
plt.plot([r_mse(preds[:i+1].mean(0), valid_y) for i in range(40)]);
我们在验证集上的表现比在训练集上差。但这是因为我们过拟合了,还是因为验证集涵盖了不同的时间段,或者两者都有?根据我们已经看到的信息,我们无法确定。然而,随机森林有一个非常聪明的技巧叫做袋外(OOB)误差,可以帮助我们解决这个问题(以及更多!)。
袋外误差
回想一下,在随机森林中,每棵树都是在训练数据的不同子集上训练的。OOB 错误是一种通过在计算行的错误时仅包括那些行未包含在训练中的树来测量训练数据集中的预测错误的方法。这使我们能够看到模型是否过拟合,而无需单独的验证集。
Alexis 说
我对此的直觉是,由于每棵树都是在不同的随机选择的行子集上训练的,因此袋外错误有点像想象每棵树因此也有自己的验证集。该验证集只是未被选中用于该树训练的行。
这在我们只有少量训练数据的情况下特别有益,因为它使我们能够看到我们的模型是否在不移除物品以创建验证集的情况下泛化。OOB 预测可在oob_prediction_
属性中找到。请注意,我们将它们与训练标签进行比较,因为这是在使用训练集的树上计算的:
r_mse(m.oob_prediction_, y)
0.210686
我们可以看到我们的 OOB 错误远低于验证集错误。这意味着除了正常的泛化错误之外,还有其他原因导致了该错误。我们将在本章后面讨论这些原因。
这是解释我们模型预测的一种方式——现在让我们更专注于这些。
模型解释
对于表格数据,模型解释尤为重要。对于给定的模型,我们最有兴趣的是以下内容:
- 我们对使用特定数据行进行的预测有多自信?
- 对于使用特定数据行进行预测,最重要的因素是什么,它们如何影响该预测?
- 哪些列是最强的预测因子,哪些可以忽略?
- 哪些列在预测目的上实际上是多余的?
- 当我们改变这些列时,预测会如何变化?
正如我们将看到的,随机森林特别适合回答这些问题。让我们从第一个问题开始!
用于预测置信度的树方差
我们看到模型如何平均每棵树的预测以获得整体预测——也就是说,一个值的估计。但是我们如何知道估计的置信度?一种简单的方法是使用树之间预测的标准差,而不仅仅是均值。这告诉我们预测的相对置信度。一般来说,我们会更谨慎地使用树给出非常不同结果的行的结果(更高的标准差),而不是在树更一致的情况下使用结果(更低的标准差)。
在"创建随机森林"中,我们看到如何使用 Python 列表推导来对验证集进行预测,对森林中的每棵树都这样做:
preds = np.stack([t.predict(valid_xs) for t in m.estimators_])
preds.shape
(40, 7988)
现在我们对验证集中的每棵树和每个拍卖都有一个预测(40 棵树和 7,988 个拍卖)。
使用这种方法,我们可以获得每个拍卖的所有树的预测的标准差:
preds_std = preds.std(0)
以下是前五个拍卖的预测的标准差——也就是验证集的前五行:
preds_std[:5]
array([0.21529149, 0.10351274, 0.08901878, 0.28374773, 0.11977206])
正如您所看到的,预测的置信度差异很大。对于一些拍卖,标准差较低,因为树是一致的。对于其他拍卖,标准差较高,因为树不一致。这是在生产环境中会有用的信息;例如,如果您使用此模型来决定在拍卖中对哪些物品进行竞标,低置信度的预测可能会导致您在竞标之前更仔细地查看物品。
特征重要性
仅仅知道一个模型能够做出准确的预测通常是不够的,我们还想知道它是如何做出预测的。特征重要性给了我们这种洞察力。我们可以直接从 sklearn 的随机森林中获取这些信息,方法是查看feature_importances_
属性。这里有一个简单的函数,我们可以用它将它们放入一个 DataFrame 并对它们进行排序:
def rf_feat_importance(m, df): return pd.DataFrame({'cols':df.columns, 'imp':m.feature_importances_} ).sort_values('imp', ascending=False)
我们模型的特征重要性显示,前几个最重要的列的重要性得分比其余的要高得多,其中(不出所料)YearMade
和ProductSize
位于列表的顶部:
fi = rf_feat_importance(m, xs) fi[:10]
cols | imp | |
69 | YearMade | 0.182890 |
6 | ProductSize | 0.127268 |
30 | Coupler_System | 0.117698 |
7 | fiProductClassDesc | 0.069939 |
66 | ModelID | 0.057263 |
77 | saleElapsed | 0.050113 |
32 | Hydraulics_Flow | 0.047091 |
3 | fiSecondaryDesc | 0.041225 |
31 | Grouser_Tracks | 0.031988 |
1 | fiModelDesc | 0.031838 |
特征重要性的图表显示了相对重要性更清晰:
def plot_fi(fi): return fi.plot('cols', 'imp', 'barh', figsize=(12,7), legend=False) plot_fi(fi[:30]);
这些重要性是如何计算的相当简单而优雅。特征重要性算法循环遍历每棵树,然后递归地探索每个分支。在每个分支,它查看用于该分割的特征是什么,以及模型由于该分割而改善了多少。该改善(按该组中的行数加权)被添加到该特征的重要性分数中。这些分数在所有树的所有分支中求和,最后对分数进行归一化,使它们总和为 1。
去除低重要性变量
看起来我们可以通过去除低重要性的变量来使用列的子集,并且仍然能够获得良好的结果。让我们尝试只保留那些具有特征重要性大于 0.005 的列:
to_keep = fi[fi.imp>0.005].cols len(to_keep)
21
我们可以使用列的这个子集重新训练我们的模型:
xs_imp = xs[to_keep] valid_xs_imp = valid_xs[to_keep]
m = rf(xs_imp, y)
这里是结果:
m_rmse(m, xs_imp, y), m_rmse(m, valid_xs_imp, valid_y)
(0.181208, 0.232323)
我们的准确率大致相同,但我们有更少的列需要研究:
len(xs.columns), len(xs_imp.columns)
(78, 21)
我们发现,通常改进模型的第一步是简化它——78 列对我们来说太多了,我们无法深入研究它们!此外,在实践中,通常更简单、更易解释的模型更容易推出和维护。
这也使得我们的特征重要性图更容易解释。让我们再次看一下:
plot_fi(rf_feat_importance(m, xs_imp));
使这个更难解释的一点是,似乎有一些含义非常相似的变量:例如,ProductGroup
和ProductGroupDesc
。让我们尝试去除任何冗余特征。
去除冗余特征
让我们从这里开始:
cluster_columns(xs_imp)
在这个图表中,最相似的列对是在树的左侧远离“根”处早期合并在一起的。毫不奇怪,ProductGroup
和ProductGroupDesc
字段很早就合并了,saleYear
和saleElapsed
,以及fiModelDesc
和fiBaseModel
也是如此。它们可能是如此密切相关,以至于它们实际上是彼此的同义词。
确定相似性
最相似的对是通过计算秩相关性来找到的,这意味着所有的值都被它们的秩(在列内的第一、第二、第三等)替换,然后计算相关性。(如果你愿意,可以跳过这个细节,因为它在本书中不会再次出现!)
让我们尝试删除一些这些密切相关特征,看看模型是否可以简化而不影响准确性。首先,我们创建一个快速训练随机森林并返回 OOB 分数的函数,通过使用较低的max_samples
和较高的min_samples_leaf
。OOB 分数是由 sklearn 返回的一个数字,范围在 1.0(完美模型)和 0.0(随机模型)之间。(在统计学中被称为R²,尽管这些细节对于这个解释并不重要。)我们不需要它非常准确——我们只是要用它来比较不同的模型,基于删除一些可能冗余的列:
def get_oob(df): m = RandomForestRegressor(n_estimators=40, min_samples_leaf=15, max_samples=50000, max_features=0.5, n_jobs=-1, oob_score=True) m.fit(df, y) return m.oob_score_
这是我们的基线:
get_oob(xs_imp)
0.8771039618198545
现在我们尝试逐个删除我们可能冗余的变量:
{c:get_oob(xs_imp.drop(c, axis=1)) for c in ( 'saleYear', 'saleElapsed', 'ProductGroupDesc','ProductGroup', 'fiModelDesc', 'fiBaseModel', 'Hydraulics_Flow','Grouser_Tracks', 'Coupler_System')}
{'saleYear': 0.8759666979317242, 'saleElapsed': 0.8728423449081594, 'ProductGroupDesc': 0.877877012281002, 'ProductGroup': 0.8772503407182847, 'fiModelDesc': 0.8756415073829513, 'fiBaseModel': 0.8765165299438019, 'Hydraulics_Flow': 0.8778545895742573, 'Grouser_Tracks': 0.8773718142788077, 'Coupler_System': 0.8778016988955392}
现在让我们尝试删除多个变量。我们将从我们之前注意到的紧密对齐的一对中的每个变量中删除一个。让我们看看这样做会发生什么:
to_drop = ['saleYear', 'ProductGroupDesc', 'fiBaseModel', 'Grouser_Tracks'] get_oob(xs_imp.drop(to_drop, axis=1))
0.8739605718147015
看起来不错!这与拥有所有字段的模型相比几乎没有差别。让我们创建没有这些列的数据框,并保存它们:
xs_final = xs_imp.drop(to_drop, axis=1) valid_xs_final = valid_xs_imp.drop(to_drop, axis=1)
(path/'xs_final.pkl').save(xs_final) (path/'valid_xs_final.pkl').save(valid_xs_final)
我们可以稍后重新加载它们:
xs_final = (path/'xs_final.pkl').load() valid_xs_final = (path/'valid_xs_final.pkl').load()
现在我们可以再次检查我们的 RMSE,以确认准确性没有发生实质性变化:
m = rf(xs_final, y) m_rmse(m, xs_final, y), m_rmse(m, valid_xs_final, valid_y)
(0.183263, 0.233846)
通过专注于最重要的变量并删除一些冗余的变量,我们大大简化了我们的模型。现在,让我们看看这些变量如何影响我们的预测,使用部分依赖图。
FastAI 之书(面向程序员的 FastAI)(四)(4)https://developer.aliyun.com/article/1483410