表格数据的机器学习(二)
原文:
zh.annas-archive.org/md5/7c92abc8663909ba4bdf951baf02d310译者:飞龙
第五章:决策树和梯度提升
本章涵盖
-
决策树及其集成
-
梯度提升决策树
-
Scikit-learn 的梯度提升决策树选项
-
XGBoost 算法及其创新
-
LightGBM 算法的工作原理
到目前为止,我们已经探讨了基于线性模型的机器学习算法,因为它们可以处理来自只有几行几列的数据集的表格问题,并找到一种方法来扩展到有数百万行和许多列的问题。此外,线性模型训练和获取预测的速度快。此外,它们相对容易理解、解释和调整。线性模型还有助于我们理解本书中将要构建的许多概念,例如 L1 和 L2 正则化和梯度下降。
本章将讨论一种不同的经典机器学习算法:决策树。决策树是随机森林和提升等集成模型的基础。我们将特别关注一种机器学习集成算法——梯度提升,以及其实现 eXtreme Gradient Boosting (XGBoost)和 Light Gradient Boosted Machines (LightGBM),它们被认为是表格数据的最佳解决方案。
5.1 树方法简介
树模型是一系列不同种类的集成算法,由于性能良好和数据预处理要求低,因此是处理表格数据的首选方法。集成算法是一组机器学习模型,它们共同贡献于单个预测。所有基于树的集成模型都是基于决策树,这是一种自 20 世纪 60 年代以来流行的算法。决策树背后的基本思想,无论它们是用于分类还是回归,都是你可以将训练集分割成子集,在这些子集中,你的预测更有利,因为有一个占主导地位的目标类(在分类问题中)或者目标值的变异性降低(即它们都非常接近;这指的是回归问题)。
图 5.1 展示了构成决策树的关键元素方案。决策树试图解决的问题是根据腿和眼睛的数量对动物进行分类。你从树的根开始,这对应于你拥有的整个数据集,并设置一个分割条件。条件通常是真/假——所谓的二分分割。尽管如此,一些决策树的变体允许在同一个节点应用多个条件,从而产生多个分割,每个分割基于特征的不同值或标签来决定。每个分支都导向另一个节点,在那里可以应用新的条件,或者导向一个终端节点,该节点用于基于终止于此的实例进行预测。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/ml-tbl-dt/img/CH05_F01_Ryan2.png
图 5.1 构成决策树的关键元素,如根、分支和叶子,根据腿和眼睛的数量对动物进行分类
分割是基于算法在特征中进行的故意搜索,以及在特征内部,其观察到的值。在分类问题中,决策树算法寻找最佳特征和特征值组合,将数据分割成具有同质目标的子集。在分类问题中,子集中目标的同质性通常使用熵、信息增益或基尼不纯度等标准来衡量:
-
熵衡量子集中标签分布的无序度或随机度。
-
信息增益,从熵中衍生出来,衡量通过基于特定特征的分割数据来减少关于数据类别标签的不确定性。
-
基尼不纯度衡量在子集中随机选择一个元素被随机标记为子集中标签分布的概率。
如果决策树用于回归,它将采用与分类不同的分割标准。在回归中,目标是分割数据以最小化结果均方误差、平均绝对误差或简单地每个子集中目标变量的方差。在训练过程中,会自动选择最佳特征,决策树的大部分计算都是为了确定最佳特征分割。然而,一旦树构建完成,预测新数据的类别标签或目标值相对快速且直接,涉及从根开始遍历树,根据有限特征集的值结束于叶子。
决策树易于计算,也相对容易可视化。它们不需要缩放或建模非线性,或者以其他方式转换你的特征或输出目标,因为它们可以单独考虑其分布的单个部分来近似目标与预测变量之间的任何非线性关系。基本上,它们是将曲线切割成部分,使得每一部分看起来像一条线。另一方面,决策树容易过拟合,最终导致过多的分割来拟合你正在工作的训练数据。随着时间的推移,已经设计了不同的策略来避免过拟合:
-
限制树中的分割数量
-
在构建后,通过剪枝分割节点来减少它们的过拟合
图 5.2 展示了决策树的不同视角。图 5.1 基于两个特征将树可视化为一个图,而图 5.2 则从数据本身分区的角度来可视化决策树。树的每个分割在图表中都是一条线,对于这个树,有七条垂直线(因此是 x 轴上特征的二进制条件的结果)和三条水平线(因此是 y 轴上的特征),总共 10 个分割。你可以认为决策树是成功的,因为每个类别都很好地分离到其分区中(每个分区最终都是一个终端节点)。然而,通过观察,也变得明显的是,某些分区只是为了适应空间中某个位置上的示例而被划分出来。有几个分区只包含一个单独的案例。任何新的示例如果与训练分布不完全匹配(一个过拟合的情况),都有可能被错误分类。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/ml-tbl-dt/img/CH05_F02_Ryan2.png
图 5.2 如何将一个完全成长的决策树分支也解释为一系列数据集分割
图 5.3 展示了使用更少的分割来可视化相同的问题——每个特征两个分割。你可以通过向后剪枝之前的树分割,移除包含训练样本过少的那些分割,或者你通过一开始就限制树的成长来实现这一点——例如,通过施加最大分割数。如果你使用更少的分区,树可能不会像以前那样完美地拟合训练数据。然而,一个更简单的方法提供了更多的信心,即新实例很可能会被正确分类,因为解决方案明确依赖于训练集中的单个点。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/ml-tbl-dt/img/CH05_F03_Ryan2.png
图 5.3 通过剪枝或限制其增长获得的一个更简单的决策树处理的问题
从欠拟合和过拟合的角度考虑这个算法,它是一个高方差算法,因为其复杂性总是倾向于超过给定的问题和数据应有的复杂度。通过调整很难找到它的最佳点。实际上,使用决策树实现更准确预测的最好方法不是作为单一模型,而是作为模型集合的一部分。在接下来的小节中,我们将探讨集合方法,如 bagging、随机森林和基于决策树的梯度提升,这是一种高级方法。
在本章中,我们将回到 Airbnb 纽约市数据集,以说明核心梯度提升决策树实现以及该技术是如何工作的。以下列表中的代码重新审视了之前用来展示其他经典机器学习算法的数据和一些关键函数和类。
列表 5.1 重新审视 Airbnb 纽约市数据集
import numpy as np
import pandas as pd
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
data = pd.read_csv("./AB_NYC_2019.csv")
excluding_list = ['price', 'id', 'latitude', 'longitude', 'host_id',
'last_review', 'name', 'host_name'] ①
low_card_categorical = ['neighbourhood_group',
_ _ 'room_type'] ②
high_card_categorical = ['neighbourhood'] ③
continuous = ['minimum_nights', 'number_of_reviews', 'reviews_per_month',
'calculated_host_listings_count', 'availability_365']
target_mean = (
(data["price"] > data["price"].mean())
.astype(int)) ④
target_median = (
(data["price"] > data["price"].median())
.astype(int)) ⑤
target_multiclass = pd.qcut(
data["price"], q=5, labels=False) ⑥
target_regression = data["price"] ⑦
categorical_onehot_encoding = OneHotEncoder(handle_unknown='ignore')
categorical_ord_encoding =
OrdinalEncoder(handle_unknown="use_encoded_value", unknown_value=np.nan)
numeric_passthrough = SimpleImputer(strategy=”constant", fill_value=0)
column_transform = ColumnTransformer(
[('low_card_categories',
categorical_onehot_encoding,
low_card_categorical),
('high_card_categories',
categorical_ord_encoding,
high_card_categorical),
('numeric',
numeric_passthrough,
continuous),
],
remainder='drop',
verbose_feature_names_out=False,
sparse_threshold=0.0) ⑧
① 需要从数据处理中排除的特征列表
② 需要独热编码的低基数分类特征列表
③ 需要按顺序编码的高基数分类特征列表
④ 创建一个二元目标,表示价格是否高于平均值(不平衡的二元目标)
⑤ 创建一个二元目标,表示价格是否高于中位数(平衡的二元目标)
⑥ 通过将价格划分为五个类别来创建一个多类目标
⑦ 将回归目标设置为价格列
⑧ 创建一个列转换器,对不同的特征组应用不同的转换
代码使用 pandas 库读取包含 2019 年纽约市 Airbnb 列表数据的 CSV 文件。然后定义了几个列表,将数据的特征分类到不同的类型:
-
excluding_list—应从分析中排除的特征列表,例如唯一标识符和文本特征 -
low_card_categorical—具有低基数(少量唯一值)的分类特征子集,将进行独热编码 -
high_card_categorical—具有高基数(许多唯一值)的分类特征子集,将使用序数编码进行编码 -
continuous—一个连续数值特征的列表,这些特征将被标准化以进行分析
然后,代码根据数据的“价格”特征创建几个目标变量:
-
target_mean—一个二元变量,表示价格是否高于所有列表的平均价格 -
target_median—一个二元变量,表示价格是否高于所有列表的中位数价格 -
target_multiclass—一个基于价格分布分位数划分的五个类别的变量 -
target_regression—实际的价格值,这些值将被用于回归分析
所有这些目标使我们能够处理不同的回归和分类问题,从而测试机器学习算法。在本章中,我们将始终使用target_median,但你可以通过在代码中做小的改动来实验所有其他目标。
接下来,代码设置了几种转换器,以预处理本章分析中的数据:
-
categorical_onehot_encoding—用于低基数分类特征的独热编码转换器 -
categorical_ord_encoding—用于高基数分类特征的序数编码转换器 -
numeric_passthrough—一个简单传递连续数值特征的转换器
最后,代码设置了一个ColumnTransformer对象,该对象将根据特征类型对每个特征子集应用适当的转换器。它对低基数分类特征应用独热编码,并将连续数值特征传递通过。转换器被设置为丢弃未在转换步骤中明确包含的任何特征,并输出简洁的特征名称。sparse_threshold参数设置为零,以确保转换器始终返回密集数组。
列表 5.2 展示了如何将标准决策树模型应用于我们的示例问题。正如在上一章中看到的示例一样,我们从 Scikit-learn 库中导入必要的模块,定义一个基于准确率的自定义评分指标,并设置五折交叉验证策略。然后我们定义一个名为column_transform的列转换器,它负责数据预处理。它包括
-
使用函数
categorical_onehot_encoding对特定的低基数分类列进行分类变量转换 -
使用函数
numeric_passthrough对连续变量进行数值特征传递 -
丢弃任何剩余未处理的列(
remainder='drop') -
设置一些选项,如抑制详细特征名称和未应用稀疏矩阵表示
在这一点上,使用交叉验证测试结合列转换器和决策树分类器模型的管道,返回准确度分数以及平均拟合时间和评分时间。
在交叉验证过程和数据管道的底层,数据集在训练过程中被决策树分类器根据特征的一个分割值多次分割。这个过程可以从算法上解释为“贪婪”,因为决策树在每一步都选择具有最佳分割的特征,而不考虑其他替代方案是否可能带来更好的结果。尽管这种方法很简单,但决策树是有效的机器学习算法。这个过程会一直进行,直到没有更多的分割可以改善训练,如下面的列表所示。
列表 5.2 一个决策树分类器
from sklearn.tree import DecisionTreeClassifier
from sklearn.pipeline import Pipeline
from sklearn.metrics import make_scorer, accuracy_score
from sklearn.model_selection import KFold, cross_validate
accuracy = make_scorer(accuracy_score)
cv = KFold(5, shuffle=True, random_state=0)
column_transform = ColumnTransformer(
[('categories', categorical_onehot_encoding, low_card_categorical),
('numeric', numeric_passthrough, continuous)], ①
remainder='drop',
verbose_feature_names_out=False,
sparse_threshold=0.0)
model = DecisionTreeClassifier(random_state=0) ②
model_pipeline = Pipeline(
[('processing', column_transform),
('modeling', model)]) ③
cv_scores = cross_validate(estimator=model_pipeline,
X=data,
y=target_median,
scoring=accuracy,
cv=cv,
return_train_score=True,
return_estimator=True) ④
mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"{mean_cv:0.3f} ({std_cv:0.3f})",
f"fit: {fit_time:0.2f}",
f"secs pred: {score_time:0.2f} secs") ⑤
① 创建一个列转换器,对分类和数值特征应用不同的转换
② 决策树分类器的一个实例
③ 依次应用列转换和决策树模型的管道
④ 使用定义的管道进行五折交叉验证,计算准确度分数,并返回附加信息
⑤ 打印交叉验证准确度分数的均值和标准差
我们在准确度方面获得的结果是
0.761 (0.005) fit: 0.22 secs pred: 0.01 secs
与其他机器学习算法的先前实验结果进行比较后,结果可能会更好。我们可以确定这一点,因为决策树已经过拟合,最终构建了太多的分支。我们可以通过尝试和错误来限制其增长以获得更好的性能(你必须声明max_depth参数来这样做)。然而,还有更好的方法可以从这个算法中获得改进的结果。在下一小节中,我们将检查这些方法中的第一个,它基于基于示例和使用的特征的变体构建的多个决策树。
5.1.1 折叠和采样
我们已经检查了所有基于决策树的单一学习算法。同类型的集成算法是下一步可以帮助你在你的问题上实现更多预测能力的步骤。这个想法是直观的:如果一个单一算法可以在某个水平上表现,使用多个模型或链式连接它们的见解(这样可以使一个从另一个的结果和错误中学习)应该会产生更好的结果。有两种核心的集成策略:
-
平均法—通过平均多个模型的预测来获得预测。模型构建方式的不同,例如通过粘贴、袋装、随机子空间和随机补丁(我们将在本节中看到),会导致不同的结果。这种类型集成模型的最好例子是随机森林算法,它是基于类似随机补丁的方法构建的。
-
提升法—预测是通过链式模型的加权平均来构建的,这些模型是依次建立在先前模型结果之上的。提升算法的最好例子是梯度提升机,如 XGBoost 和 LightGBM。
在接下来的子节中,我们将探讨随机森林。在深入研究随机森林算法之前,有必要花一些时间在其他平均方法上,这不仅因为随机补丁方法建立在它们之上,而且因为它们指出了在需要减少估计的方差时始终值得应用于表格数据的解决方案,从而获得更可靠的预测,无论你希望在你的数据上使用哪种机器学习模型。
粘贴是首先要考虑的方法。随机森林算法的创造者 Leo Breiman 建议,粘贴包括创建一组不同的模型,这些模型是在通过不重复抽样的子样本上训练的,这些子样本是从你的训练数据中获得的。在回归问题的情况下,通过平均来汇总模型的预测,在分类任务的情况下,通过多数投票来汇总。
粘贴的优点是
-
通过仅部分增加偏差来减少预测的方差,从而提高结果,偏差是衡量模型预测值与真实值之间距离的指标。
-
预测结果更稳健,受异常值影响较小。
-
减少训练时需要学习的数据量,从而减少内存需求。
缺点是
-
减少可用的数据量,这会增加偏差,因为有可能通过采样排除数据分布中的重要部分。
-
非常计算密集,具有复杂的算法
最后一个缺点取决于你的时间限制或可用资源。从历史上看,建议使用弱模型(即由于它们的简单性而非常快就能训练的机器学习模型,如线性回归或 k 最近邻模型)应用平均方法。从业者观察到,结合多个弱模型可以击败单个更复杂算法的结果。然而,弱模型通常存在高偏差问题,通过子采样,你只在其估计中引入了一些方差,但它们的偏差问题基本上没有改变。使用平均方法的主要优势在于,它通过以略微增加的偏差为代价来减少估计的方差。由于弱模型本质上携带大量的偏差,它们可能无法实现与应用于更复杂模型相同的方法的可比结果。在需要通过减少估计的方差来获得更显著改进的情况下,使用平均策略可以与更复杂的模型更有效地结合。
Bagging,也被 Leo Breimar 提出作为一种更好的解决方案,与粘贴不同,因为你从子采样切换到自助采样。自助采样包括多次从数据样本中带替换地采样,以近似统计量的总体分布。自助采样是一种常用的统计技术,它允许我们估计相对于从我们的样本中抽取的潜在数据总体,统计量的变异性与不确定性。通过使用从可用样本中获取的信息,通过多次重采样来模拟原始总体行为,自助采样模拟了总体的行为,而无需显式了解其统计分布。
在机器学习中使用自助采样的原因是为了估计模型性能的不确定性或评估统计量的分布。此外,自助采样有助于创建更多样化的原始数据集变体,用于训练和集成目的。这是基于观察,如果所使用的模型的预测较少相关(即更多样化),则平均多个模型可以减少方差。子采样创建多样化的数据集进行训练。然而,它有局限性,因为如果你进行积极的子采样——例如,选择不到原始数据的 50%——你往往会引入偏差。
相比之下,如果你以更有限的方式进行子采样,例如使用 90%的数据,得到的子样本将倾向于相关。相反,自助法更有效,因为平均而言,你会在每个自助法中使用大约 63.2%的原始数据。有关此类计算比例的详细统计解释,请参阅详细交叉验证答案mng.bz/zZ0w。此外,带替换的采样往往会产生模仿原始数据分布的结果。自助法创建了一组更多样化的数据集来学习,从而产生一组更多样化的预测,可以更有效地进行集成,减少方差。
事实上,由于在平均过程中我们正在构建一个预测分布,并将分布的中心作为我们的预测,平均预测越接近随机分布,分布中心受模型(如过拟合)收集到的数据问题的影响就越小。
相比之下,通过随机子空间,由 T. Ho [“The Random Subspace Method for Constructing Decision Forests,” Pattern Analysis and Machine Intelligence, 20(8), 832-844, 1998]引入,采样仅限于特征。这是因为用于集成的模型是决策树,这种模型通过仅使用每个模型的一部分特征来显著降低估计的高方差。改进的结果是因为在特征子样本上训练的模型往往会产生不相关的预测——所有的决策树都会过拟合数据,但相对于彼此而言,方式不同。
最后,通过随机补丁 [G. Louppe 和 P. Geurts, “Ensembles on Random Patches,” in Machine Learning and Knowledge Discovery in Databases (2012): 346–361],同时使用样本和特征的采样,以实现更多不相关的预测,从而可以更有效地进行平均。
粘贴、Bagging、随机子空间和随机补丁都可以使用 Scikit-learn 的 Bagging 函数实现。通过以下参数,可以控制BaggingClassifier对分类任务和BaggingRegressor对回归任务的训练数据的行为:
-
bootstrap -
max_sample -
max_features
通过根据每种平均方法的规范组合它们,你可以获得我们描述的所有四种平均策略(见表 5.1)。
表 5.1 Bagging 和采样策略
| 平均策略 | 数据会发生什么 | BaggingClassifier/BaggingRegressor 的参数 |
|---|---|---|
| Pasting | 使用不替换方式对训练示例进行采样 | bootstrap = False max_samples < 1.0 max_features = 1.0 |
| Bagging | 使用替换方式(自助法)对训练示例进行采样 | bootstrap = True max_samples = 1.0 max_features = 1.0 |
| 随机子空间 | 采样特征(不替换) | bootstrap = False max_samples = 1.0 max_features < 1.0 |
| 随机补丁 | 在不替换的情况下采样训练示例和特征 | bootstrap = False max_samples < 1.0 max_features < 1.0 |
通过在参数估计器中输入所需的 Scikit-learn 模型类,您可以决定用于构建集成所使用的算法。默认情况下是决策树,但您可以选择您更喜欢弱或强模型。在以下示例中,我们应用了一个 Bagged 分类器,将决策树模型的数量设置为 300。以下列表显示了所有模型共同贡献以改善低性能,正如我们从列表 5.2 中看到的那样,决策树往往会产生这种问题的低性能。
列表 5.3 基于 Bagged 树的分类器
from sklearn.ensemble import BaggingClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import accuracy_score
accuracy = make_scorer(accuracy_score)
cv = KFold(5, shuffle=True, random_state=0)
model = BaggingClassifier(
estimator=DecisionTreeClassifier(), ①
n_estimators=300,
bootstrap=True, ②
max_samples=1.0, ③
max_features=1.0, ④
random_state=0)
column_transform = ColumnTransformer(
[('categories', categorical_onehot_encoding, low_card_categorical),
('numeric', numeric_passthrough, continuous)],
remainder='drop',
verbose_feature_names_out=False,
sparse_threshold=0.0) ⑤
model_pipeline = Pipeline(
[('processing', column_transform),
('modeling', model)]) ⑥
cv_scores = cross_validate(estimator=model_pipeline,
X=data,
y=target_median,
scoring=accuracy,
cv=cv,
return_train_score=True,
return_estimator=True) ⑦
mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"{mean_cv:0.3f} ({std_cv:0.3f})",
f"fit: {fit_time:0.2f}",
f"secs pred: {score_time:0.2f} secs") ⑧
① 基于决策树创建 BaggingClassifier 集成模型
② 为 BaggingClassifier 设置自助采样
③ 为 BaggingClassifier 设置不进行特征采样
④ 为 BaggingClassifier 设置不进行数据采样
⑤ 应用不同转换到分类和数值特征的列转换器
⑥ 依次应用列转换和 Bagging 分类器模型的管道
⑦ 使用定义的管道进行五折交叉验证并计算准确度分数
⑧ 打印交叉验证准确度分数的均值和标准差
结果需要更多一些时间,但看起来很有希望,但它们仍然不足以与我们的基于支持向量机和逻辑回归的先前解决方案竞争:
0.809 (0.004) fit: 37.93 secs pred: 0.83 secs
在下一小节中,我们通过重新审视随机森林来进一步探讨集成,随机森林利用 Bagging 中的随机补丁是有充分理由的。
5.1.2 使用随机森林进行预测
随机森林的工作原理与 Bagging 类似。然而,它同时应用随机补丁(在不替换的情况下采样训练示例和特征):在训练每个模型之前对样本进行自助采样,并在建模期间对特征进行子采样。由于随机森林集成中使用的基本算法是由二分分割构建的决策树,因此在采样一组特征作为分割本身的潜在候选时,特征采样发生在每个树的分割处。
允许集成中的每个决策树生长到其极限可能会导致数据过拟合和估计的高方差;采用自助抽样和特征抽样可能有助于缓解这些问题。自助抽样确保模型在来自同一分布的不同数据样本上训练,而在每个分割处的特征抽样保证了不同的树结构。这种组合有助于生成一组彼此相当不同的模型。不同的模型产生非常不同的预测(因此,我们可以说它们的预测相当不相关),这对于平均技术来说是一个巨大的优势,因为当集成到一个单一的预测向量时,结果将更加可靠和准确。
图 5.4 展示了随机森林的工作原理。该图说明了具有两个类别的数据集的二元分类问题。数据集使用多棵决策树建模,采用自助抽样和特征抽样技术。这些技术导致数据集的不同分区,如图中顶部部分所示,由三个示例结果表示。这些树以不同的方式划分数据集空间,展示了它们分割策略的变异性。为了简化表示,只显示了两个特征,从而更清楚地理解这个过程。
最后,当所有结果通过多数投票合并在一起时,即选择出现频率更高的分类作为预测类别,随机森林将提供由所有树的结果得出的更好预测。这如图中底部部分所示,其中不同的阴影表示在特定分区中一个或另一个类的普遍性。多数投票中的类之间的最终边界以黑色多边形线表示。当使用多棵树时,这条线甚至可以更平滑,类似于曲线。如果给集成足够多的模型,集成方法可以近似任何曲线。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/ml-tbl-dt/img/CH05_F04_Ryan2.png
图 5.4 随机森林如何通过多数投票结合其决策树的不同数据分区来得出结果
该算法最初由 Leo Breiman 和 Adele Cutler(mng.bz/0Qlp)设计,尽管商业上受到保护,但该算法已被开源——因此其实现有许多不同的名称。除了更好的预测外,随机森林还开辟了更多有趣的可能性,因为你可以使用该算法来确定特征重要性并测量数据集中案例相似度的大小。
在列表 5.4 的例子中,我们测试了随机森林在 Airbnb NYC 数据集上如何处理我们的分类问题。在应用决策树时,除了算法外,与我们的标准数据处理没有区别。One-hot 编码将低类别特征转换为二进制,而数值特征保持不变。
列表 5.4 随机森林分类器
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score
accuracy = make_scorer(accuracy_score)
cv = KFold(5, shuffle=True, random_state=0)
model = RandomForestClassifier(n_estimators=300,
min_samples_leaf=3,
random_state=0) ①
column_transform = ColumnTransformer(
[('categories', categorical_onehot_encoding, low_card_categorical),
('numeric', numeric_passthrough, continuous)],
remainder='drop',
verbose_feature_names_out=False,
sparse_threshold=0.0) ②
model_pipeline = Pipeline(
[('processing', column_transform),
('modeling', model)]) ③
cv_scores = cross_validate(estimator=model_pipeline,
X=data,
y=target_median,
scoring=accuracy,
cv=cv,
return_train_score=True,
return_estimator=True) ④
mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"{mean_cv:0.3f} ({std_cv:0.3f})",
f"fit: {fit_time:0.2f}",
f"secs pred: {score_time:0.2f} secs") ⑤
① 具有 300 个估计器和在叶子节点上的最小样本数设置为 3 的 RandomForestClassifier
② 一种对分类特征和数值特征应用不同转换的列转换器
③ 依次应用列转换和随机森林分类器模型的管道
④ 使用定义的管道进行五折交叉验证并计算准确度分数
⑤ 打印交叉验证准确度分数的均值和标准差
运行脚本后,你将获得以下结果,这实际上是本章中针对此问题的最佳性能:
0.826 (0.004) fit: 12.29 secs pred: 0.68 secs
随机森林获得良好结果的关键在于明智地选择其少数超参数。尽管随机森林算法在默认参数下表现良好,无需多想,但微调它将带来更好的结果。首先,算法的目的是减少估计的方差,这通过设置足够高的n_estimators数量来实现。原理是,如果你有很多树,你就有结果分布,如果结果是随机抽取的,由于大数定律,你会有回归到平均值(最佳预测)的效果。通常,示例的重新抽样和考虑分割的特征的抽样足以使森林中的树足够不同,可以被认为是“随机抽取”。然而,你需要足够的抽取次数才能有适当的回归到平均值。
你需要一些测试来微调你将构建多少棵树,因为总有一个最佳点:在达到一定数量的树之后,你不会获得任何更多的改进,有时性能的下降反而会出现。此外,算法构建过多的树会增加其计算成本,训练和推理所需的时间将更多。然而,无论你训练多少棵树,如果你的方差在随机森林模型的默认设置下开始很高,你几乎无法减少它。在这里,方差和偏差之间的权衡就出现了;也就是说,你可以通过一些偏差来换取一些方差,这意味着你正在过度拟合数据。
你可以通过以下调整来为随机森林设置适当的偏差:
-
通过设置
max_features参数来降低在寻找最佳分割时考虑的特征数量 -
通过设置
max_depth参数来设置每个树的最大分割数,从而限制其增长到预定义的某个程度 -
通过设置
min_samples_leaf参数(数值大于 1)来设置树终端叶子中的最小示例数,从而限制其增长
在下一节中,我们探讨极端随机树(ERT),这是随机森林的一个变体,当数据更大且噪声较多时非常有用。
5.1.3 退回到极端随机树
ERT(在 Scikit-learn 中也称为 extra-trees)是一种更随机的随机森林算法。原因是集成中单个树进行分割时候选者的选择。在随机森林中,算法为每个分割采样其候选者,然后从候选者中选择最佳特征。相反,ERT 中要分割的特征不是在可能的候选者中进行评估,而是随机选择。之后,算法在随机选择的特征中评估最佳的分割点。这有一些后果。首先,由于生成的树更加不相关,ERT 的预测方差更小——但代价是更高的偏差。随机分割特征对预测的准确性有影响。其次,ERT 在计算上更高效,因为它不需要测试特征集,而是每次只测试一个特征以找到最佳的分割。所有这些特性使 ERT 最适合处理
-
高维数据 因为它将比任何其他决策树集成算法更快地分割特征
-
噪声数据 因为随机特征和样本选择过程可以帮助减少噪声数据点的影响,使模型对极端值更加鲁棒
-
不平衡数据 因为,由于随机特征选择,数据少数子集的信号不会系统地排除,以利于数据多数子集
以下列表通过替换列表 5.4 中的随机森林来测试 ERT,在列表 5.4 中,你使用 Airbnb NYC 数据集构建了一个模型,以确定列表价格是否高于或低于中位数。
列表 5.5 ERTs 分类器
from sklearn.ensemble import ExtraTreesClassifier
accuracy = make_scorer(accuracy_score)
cv = KFold(5, shuffle=True, random_state=0)
model = ExtraTreesClassifier(n_estimators=300,
min_samples_leaf=3,
random_state=0) ①
column_transform = ColumnTransformer(
[('categories', categorical_onehot_encoding, low_card_categorical),
('numeric', numeric_passthrough, continuous)],
remainder='drop',
verbose_feature_names_out=False,
sparse_threshold=0.0) ②
model_pipeline = Pipeline(
[('processing', column_transform),
('modeling', model)]) ③
cv_scores = cross_validate(estimator=model_pipeline,
X=data,
y=target_median,
scoring=accuracy,
cv=cv,
return_train_score=True,
return_estimator=True) ④
mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"{mean_cv:0.3f} ({std_cv:0.3f})",
f"fit: {fit_time:0.2f}",
f"secs pred: {score_time:0.2f} secs") ⑤
① 一个具有 300 个估计器和在叶节点上的最小样本数设置为 3 的 ExtraTreesClassifier
② 一个列转换器,对分类和数值特征应用不同的转换
③ 一个管道,按顺序应用列转换和随机森林分类器模型
④ 使用定义的管道进行五折交叉验证并计算准确度分数
⑤ 打印交叉验证中准确度分数的平均值和标准差
你获得的结果比使用随机森林略好一些:
0.823 (0.004) fit: 4.99 secs pred: 0.42 secs
如果你运行这个示例,你会看到,在给定相同的 dataset 和构建相同数量的树的情况下,使用 ETR 训练比使用随机森林要快得多。当你的 dataset 更大(更多案例)甚至更宽(更多特征)时,ETR 成为一个有趣的替代方案,因为它节省了大量选择分割特征的时间,因为它是随机决定的。相比之下,随机森林算法必须在选择中寻找最佳特征。
当许多共线性噪声特征与目标相关时,随机决定分割是一个巨大的优势。该算法避免像由寻找最佳拟合目标的特征驱动的算法那样选择相同的信号。此外,您还可以通过观察 ETR 的特征分割的工作动态,将其视为另一种以方差与偏差进行交易的方式。对于算法来说,随机分割是一个限制,因为它会导致生成的树集非常不相关。
在下一节中,我们通过考察梯度提升来完善我们对基于树的集成方法的概述。这种略有不同的集成方法通常比袋装或随机补丁对表格数据问题更有效。
5.2 梯度提升
在近年来,梯度提升决策树(GBDT)已牢固地确立了自己在表格数据问题上的尖端方法地位。GBDT 通常被认为是在多个领域广泛问题上的最先进机器学习方法,包括多类分类、广告点击预测和搜索引擎排名。当应用于标准表格问题时,您可以期望 GBDT 的表现优于神经网络、支持向量机、随机森林和袋装集成。
首先,GBDT 处理异构特征的能力以及它在选择损失函数和评估指标方面的灵活性,使该算法最适合表格数据预测建模任务。总的来说,GBDT 为表格数据问题提供了以下好处:
-
通过适当的超参数调整,它可以在所有其他技术中实现最佳性能。
-
不需要对特征进行缩放或其他单调变换。
-
它自动捕捉数据中的非线性关系。
-
它对异常值和噪声数据具有鲁棒性。
-
它自动处理缺失数据。
-
它自动选择最佳特征,并可以报告它们的重要性。
所有这些特性都取决于算法的工作方式,结合决策树序列进行梯度下降优化。实际上,在梯度提升中,从常数开始,您按顺序向集成中添加树模型,每个模型都以前一个模型的误差为依据进行校正,类似于梯度下降优化。梯度提升代表了原始提升方法的演变。在 Adaboost 等模型中使用原始提升时,您只需对基于前一个模型残差的模型进行平均。
在 Adaboost 中,算法对一系列弱学习器进行拟合,任何持续击败随机猜测的机器学习算法(有关如何选择弱学习器的解释,请参阅mng.bz/KG9P)。然后,它对错误预测赋予更多权重,对正确预测赋予较少权重。加权有助于算法更多地关注难以预测的观测值。在分类中通过多数投票或在回归中通过预测的平均值后,这个过程通过多次修正而结束。
相比之下,在梯度提升中,你依赖于双重优化:首先是单个树根据其优化函数努力减少误差的优化,然后是总体优化,涉及计算提升模型的求和误差,其形式模仿梯度下降,其中你逐渐修正模型的预测。由于你还有一个基于第二层的优化,即基于整个集成过程误差的优化,因此梯度提升比之前看到的树集成更灵活,因为它允许在计算模型预测求和与预期结果差异时使用任意损失函数。
图 5.5 可视化地表示了添加新树后训练误差如何降低。每一棵树都参与梯度下降风格的优化,有助于预测前一棵树残差误差的修正。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/ml-tbl-dt/img/CH05_F05_Ryan2.png
图 5.5 梯度下降法与提升树的工作原理
如果梯度下降在优化中提供最优结果和灵活性,那么使用决策树作为基学习(如所见,集成并不限于决策树)提供了各种优势。这是因为它自动选择所需的特征。它不需要指定函数形式(如回归中的公式)、缩放或特征与目标之间的线性关系。
在下一节中,在看到具体实现(Scikit-learn、XGBoost、LightGBM)之前,我们将尝试构建我们自己的简单梯度提升实现,以了解如何使用这个强大的算法。
5.2.1 梯度提升法的工作原理
所有 GBDT 的实现都提供了一系列超参数,需要设置以在您试图解决的数据问题上获得最佳结果。弄清楚每个设置的作用是一个挑战,而且对自动调整过程保持无知并将任务留给它并不会帮助太多,因为您将面临挑战告诉调整算法要调整什么以及如何调整。
根据我们的经验,写下简单的实现是理解算法工作原理的最佳方式,并找出超参数如何与预测性能和结果相关。列表 5.6 显示了一个 GradientBoosting 类,它可以解决任何二元分类问题,例如我们作为示例处理的 Airbnb NYC 数据集,使用梯度下降过程的两参数和 Scikit-learn 提供的决策树模型的参数。
代码创建了一个 GradientBoosting 类,它包含用于拟合、预测概率和预测类的方法。内部,它将拟合的决策树序列存储在一个列表中,可以从那里按顺序访问以重建以下求和公式:
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/ml-tbl-dt/img/CH05_F05_Ryan2-eqs-0x.png
在公式中,
-
H(X) 是应用于预测变量 X 的梯度提升模型
-
M 对应于使用的树估计器的数量
-
ν 代表学习率
-
w^m 代替表示来自先前树的校正,这些校正需要被预测
-
h^m 符号指的是第 m 个决策树
有趣的是,梯度提升树总是回归树(即使是对于分类问题)——因此我们选择使用 Scikit-learn 的 DecisionTreeRegressor。这也解释了为什么 GBDT 在预测概率方面比其他基于树的集成模型更好:梯度提升树直接对类别概率的对数几率进行回归,从而以一种与逻辑回归不太不同的方式优化。另一方面,像随机森林这样的算法是针对纯度指标进行优化的,它们通过计算终端节点中一个类的比例来估计概率,这并不是真正的概率估计。一般来说,GBDT 输出的概率是正确的,并且很少需要后续的概率校准,这是一种后处理步骤,用于调整预测概率以提高其在概率估计至关重要的应用中的准确性和可靠性,例如医疗诊断(例如,疾病检测)、欺诈检测或信用风险评估。
在我们的代码实现中,我们允许传递任何参数给 DecisionTreeRegressor(见 mng.bz/9YQx),尽管最有用的是与树发展复杂度相关的参数,例如 max_depth(固定树的深度最大值),或 min_samples_split 和 min_samples_leaf(分别表示分割内部节点或成为叶节点所需的最小样本数)。
每个树回归器的角色是提供一个 w 向量,其中包含要加到先前估计中的学习率加权的学习修正。每个 w 向量都依赖于前一个,因为它是由一个训练在梯度上的树回归器产生的,这些梯度是必要的,以纠正估计以匹配真实的分类标签。链式向量 w 类似于一系列梯度修正——最初很大,然后越来越精细,趋向于最优输出预测。这种梯度下降与我们在第四章中介绍的梯度下降优化过程完全相似。此外,通过更改基于其梯度计算的成本函数,您可以要求 GBDT 优化不同的损失函数。
列表 5.6 构建梯度提升分类器
from sklearn.tree import DecisionTreeRegressor
import numpy as np
class GradientBoosting():
def __init__(self, learning_rate=0.1, n_estimators=10, **params):
self.learning_rate = learning_rate
self.n_estimators = n_estimators
self.params = params
self.trees = list()
def sigmoid(self, x):
x = np.clip(x, -100, 100)
return 1 / (1 + np.exp(-x)) ①
def logit(self, x, eps=1e-6):
xp = np.clip(x, eps, 1-eps)
return np.log(xp / (1 - xp)) ②
def gradient(self, y_true, y_pred):
gradient = y_pred - y_true ③
return gradient
def fit(self, X, y):
self.init = self.logit(np.mean(y)) ④
y_pred = self.init * np.ones((X.shape[0],))
for k in range(self.n_estimators):
gradient = self.gradient(self.logit(y), y_pred)
tree = DecisionTreeRegressor(**self.params)
tree.fit(X, -gradient) ⑤
self.trees.append(tree)
y_pred += (
self.learning_rate * tree.predict(X)
) ⑥
def predict_proba(self, X):
y_pred = self.init * np.ones((X.shape[0],))
for tree in self.trees:
y_pred += (
self.learning_rate * tree.predict(X)
) ⑦
return self.sigmoid(y_pred)
def predict(self, X, threshold=0.5):
proba = self.predict_proba(X)
return np.where(proba >= threshold, 1, 0)
① 用于概率转换的 Sigmoid 函数实现,将 logits 转换回概率
② 用于将概率转换为 logits 的 Logit 函数实现
③ 计算损失函数(负对数似然)相对于预测的梯度
④ 使用目标值的对数变换均值初始化模型
⑤ 将决策树回归器拟合到对数似然转换目标的负梯度
⑥ 使用学习率因子更新拟合的树的输出预测值
⑦ 预测需要累积来自所有树的预测
正如我们在应用于线性模型的梯度下降中看到的,您依赖于使过程随机化以避免优化陷入次优解,这是通过在训练每个决策树之前采样行或列来实现的。此外,您使用提前停止来防止 GBDT 顺序使用过多的决策树并过度适应训练数据。我们将在下一章中演示提前停止。
现在我们已经解释了我们的 GradientBoosting 类的内部工作原理,我们现在可以对其进行实验。我们将使用 Airbnb 纽约数据集,并首先将其分为训练集和测试集。这需要创建两个行索引列表——一个用于训练集,一个用于测试集——使用 Scikit-learn 函数 train_test_split (mng.bz/jp1z)。我们实例化我们的 GradientBoosting 类,它需要一个学习率为 0.1 和 300 个决策树,最大深度为四个分支,终端叶子节点至少有三个示例。在通过处理数值和分类特征转换训练数据后,我们拟合模型,预测测试集,并评估结果。
列表 5.7 测试我们的梯度提升类
from sklearn.model_selection import train_test_split
train, test = train_test_split(range(len(data)), test_size=0.2,
random_state=0) ①
cls = GradientBoosting(n_estimators=300,
learning_rate=0.1,
max_depth=4,
min_samples_leaf=3,
random_state=0) ②
X = column_transform.fit_transform(data.iloc[train]) ③
y = target_median[train] ④
cls.fit(X, y)
Xt = column_transform.transform(data.iloc[test]) ⑤
yt = target_median[test] ⑥
preds = cls.predict(Xt)
score = accuracy_score(y_true=yt, y_pred=preds) ⑦
print(f"Accuracy: {score:0.5f}") ⑧
① 使用固定随机种子将数据集索引分割为训练集和测试集
② 使用指定的超参数初始化 GradientBoosting 模型
③ 将列转换应用于训练数据
④ 提取与训练数据对应的目标值
⑤ 将相同的列转换应用于测试数据
⑥ 提取与测试数据对应的目标值
⑦ 通过比较预测标签与实际测试标签来计算准确度得分
⑧ 打印计算出的准确度得分
在我们的测试集上的评估准确度是
Accuracy: 0.82503
这是一个非常好的结果,表明即使是我们基本的实现也能在我们的数据上做得很好。在下一节中,我们将调查获得的结果,并观察 GBDT 模型的一个关键特征,这个特征使它们区别于其他决策树集成。
5.2.2 使用梯度提升进行外推
在我们从头开始实现的 GBDT 中,我们可以通过预测相同的训练集来可视化模型如何拟合数据。图 5.6 中所示的可视化,由列表 5.8 中的小代码片段创建,是一个归一化密度直方图。在归一化密度直方图中,每个柱子的高度代表落在特定区间内的数据点的相对频率,直方图下的总面积等于 1。结果描绘了一个值分布,主要偏向 0-1 边界的极端,表明模型在分类示例时非常果断。
列表 5.8 绘制梯度提升预测概率图
import matplotlib.pyplot as plt
proba = cls.predict_proba(Xt) ①
plt.figure(figsize=(8, 6))
plt.hist(proba,
bins=30,
density=True,
color='blue',
alpha=0.7) ②
plt.xlabel('Predicted Probabilities')
plt.ylabel('Density')
plt.title('Histogram of Predicted Probabilities')
plt.grid(True)
plt.show()
① 使用训练模型为测试数据生成预测概率
② 创建具有指定区间和归一化密度的预测概率直方图
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/ml-tbl-dt/img/CH05_F06_Ryan2.png
图 5.6 描述梯度提升分类拟合概率的直方图,展示了模型在大多数情况下如何强烈决定它们是正还是负
我们的实现底层使用回归损失,即平方损失,其梯度等于将概率转换为 logit 的残差。关于 logit 的定义,请参阅 mng.bz/W214。
概率 p 的 logit 计算如下
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/ml-tbl-dt/img/CH05_F06_Ryan2-eqs-1x.png
这种定义的优势在于 logit 函数将概率映射到对数几率尺度,这是一个无界的尺度,范围从负无穷大到正无穷大,使我们能够将我们的问题视为回归问题。
这意味着在每次迭代中,梯度提升算法都会将回归模型拟合到损失函数相对于 logit 值的梯度,这对应于真实目标值的 logit 与当前预测(以 logit 表示)之间的差异。这种方法允许算法通过调整预测以沿着损失函数的最陡下降方向迭代改进预测,并最终通过 logit 的逆函数 sigmoid,得到一个在 0 到 1 之间有界的 logit 预测。sigmoid 是一种数学函数,它将输入映射到 0 到 1 之间的值,提供一条平滑且连续的曲线。
sigmoid 的公式为
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/ml-tbl-dt/img/CH05_F06_Ryan2-eqs-2x.png
其中
-
σ(x)表示将 sigmoid 函数应用于输入值 x。
-
exp(–x)是指数函数,其中 exp 表示欧拉数(约等于 2.71828)的–x 次幂。
-
1 + exp(–x)是分母,这确保了 sigmoid 函数的输出始终为正。
-
1 / (1 + exp(–x))表示分母的倒数,结果是 sigmoid 函数的输出值。
它在机器学习和统计模型中常用,用于将 logit 预测转换为概率。
如果我们将问题视为回归问题会怎样?在列表 5.9 中,我们通过构建我们的GradientBoosting类并覆盖 fit 和 predict 方法(通过移除 logit 和 sigmoid 转换)来定义一个GradientBoostingRegression类。
列表 5.9 测试梯度提升回归类
class GradientBoostingRegression(GradientBoosting):
def fit(self, X, y):
self.init = np.mean(y) ①
y_pred = self.init * np.ones((X.shape[0],))
for k in range(self.n_estimators):
gradient = self.gradient(y, y_pred)
tree = DecisionTreeRegressor(**self.params)
tree.fit(X, -gradient) ②
self.trees.append(tree)
y_pred += (
self.learning_rate * tree.predict(X)
) ③
def predict(self, X):
y_pred = self.init * np.ones((X.shape[0],))
for tree in self.trees:
y_pred += (
self.learning_rate * tree.predict(X)
) ④
return y_pred
reg = GradientBoostingRegression(n_estimators=300,
learning_rate=0.1,
max_depth=4,
min_samples_leaf=3,
random_state=0)
reg.fit(X, y)
proba = reg.predict(Xt)
plt.figure(figsize=(8, 6))
plt.hist(proba,
bins=10,
density=True,
color='blue',
alpha=0.7) ⑤
plt.xlabel('Predicted Probabilities')
plt.ylabel('Density')
plt.title('Histogram of Predicted Probabilities')
plt.grid(True)
plt.show()
① 使用 y 的均值初始化预测
② 将树拟合到负梯度
③ 使用学习率缩放树预测更新预测
④ 预测回溯需要累积所有树的预测。
⑤ 绘制回归预测概率的直方图
当运行列表 5.9 中的代码时,它将生成拟合预测的直方图,如图 5.7 所示。图 5.7 显示了拟合概率如何超过 0-1 边界。与基于特征的加权组合的线性回归一样,基于链式序列模型结果的加权组合梯度提升可以超出学习目标的范围。与其他基于决策树的集成(如随机森林)相比,这种外推是不可能的。回归中的决策树无法预测超出训练中看到的值,因为预测是基于训练子样本的均值。GBDT(梯度提升决策树)的外推潜力,基于它们是加性集成的事实,是它们在时间序列中成功的基础,在时间序列中,你外推可能非常不同于过去的未来结果。
然而,需要注意的是,GBDTs 的外推能力不能像使用线性模型那样延伸得那么远。在时间序列预测场景中,如果预测的值远远超出了你为训练提供的目标值,例如在异常值的情况下,外推将受到限制,无法进行正确的估计。在这种情况下,直接将输入数据与预测关联的线性模型可能更合适。线性模型能够处理完全未见的异常数据点的极端预测。为了在这种情况下提供决策树作为基学习器的替代方案,许多 GBDT 实现提供了通过简单集成线性模型(如 XGBoost 实现)或应用分段线性梯度提升树来实现线性提升,其中线性模型建立在决策树的终端节点上(如 LightGBM 实现)。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/ml-tbl-dt/img/CH05_F07_Ryan2.png
图 5.7 使用梯度提升回归模型拟合概率的直方图,其中一些概率超过了 0-1 边界
此外,GBDTs 在时间序列问题中的优势在于它们在预测信息选择上的自动性,设置的超参数非常少。你所需要做的就是拥有足够的示例,至少在数千个数据点的范围内,并对时间序列特征进行一些仔细的工程化设计,例如不同时间跨度的滞后值和移动平均。对于较短的序列,经典的时间序列方法,如 ARIMA 或指数平滑,仍然是推荐的选择。对于如层次结构序列等复杂问题,GBDTs 甚至可以超越为时间序列数据专门设计的最复杂的深度学习架构。例如,GBDTs 在解决超市网络中的问题方面表现出色,在这些网络中既销售慢销品也销售快销品。
最近在 Kaggle 上举行的 M5 预测竞赛中,GBDTs 在时间序列分析中的优势得到了明确的展示(github.com/Mcompetitions/M5-methods),在该竞赛中,LightGBM 算法提出的解决方案优于为预测层次结构序列任务设计的深度学习架构,如 DeepAR (arxiv.org/abs/1704.04110)或 NBEATS (arxiv.org/abs/1905.10437)。在 Tim Januschowski 等人撰写的论文“使用树进行预测”中,可以找到对竞赛和基于树的方法在时间序列分析实践中的成功和普遍性的清晰而深入的分析。[国际预测杂志* 38.4 (2022): 1473–1481: mng.bz/8O4Z]。
5.2.3 解释梯度提升的有效性
今天,尽管在图像和文本识别与生成方面取得了显著成果,但神经网络在表格数据上的性能并不匹配梯度提升解决方案(如 XGBoost 和 LightGBM)。实践者和数据科学竞赛的参与者都倾向于这些解决方案。例如,参见关于表格竞赛的“竞争性机器学习状态”报告,mlcontests.com/tabular-data/。但梯度提升决策树(GBDTs)相对于深度神经网络(DNNs)的优势究竟来自何处?从我们构建梯度提升分类器的经验来看,我们可以欣赏到该算法如何将梯度下降与异构数据的决策树的灵活性相结合。这足以解释为什么 GBDTs 在表格数据上如此有效吗?
由 Leo Grinsztajn、Edouard Oyallon 和 Gael Varoquaux 撰写的《为什么基于树的方法在典型的表格数据上仍然优于深度学习?》(第三十六届神经信息处理系统会议数据集和基准测试轨道,2022 年:hal.science/hal-03723551v2/document)是一篇近期的研究,试图揭示深度学习架构和梯度提升决策树的不同性能。研究表明,基于树的方法在表格数据上实现良好预测方面优于深度学习方法(即使是现代架构)。作者明确关注区分表格数据与仅具有连续特征的集合(我们可以称之为同质表格数据集)的列异质性,并使用 45 个公开数据集定义了一个标准基准。他们只考虑了大约 10,000 个样本的数据,包括不同类型的列,包括具有不同单位的数值特征和分类特征,因为这被认为是表格数据集的典型情况。
尝试了各种深度学习模型,包括多层感知器(MLPs)、ResNets、SAINT 和 FTtransformer,但发现基于树的方法在更少的超参数调整下表现出更好的性能。即使只考虑数值特征,基于树的方法也优于深度学习方法。当考虑到拟合时间时,这种优势更为明显,尽管所使用的硬件(包括 GPU)也影响了结果。在大数据集上,这两种方法之间的差距更小,而大数据集对于表格数据来说并不典型。
作者还研究了表格数据特征,这些特征解释了基于树和深度学习方法之间的性能差异。他们发现,在特征空间中平滑结果缩小了差距,因为深度架构难以处理不规则模式,而平滑性不会影响树模型。移除无信息特征对于类似 MLP(多层感知器)的神经网络架构缩小差距更为明显。然而,只有在将数据应用随机旋转之后,深度架构才优于树模型。
随机旋转是指在将数据集的输入特征馈送到机器学习模型之前,对这些特征应用一个随机旋转矩阵。这个旋转矩阵是一个方阵,它保持向量的长度和它们之间的角度,确保旋转后的数据与原始数据等效。随机旋转在机器学习中用于各种目的,包括增强集成方法的多样性、提高模型的鲁棒性,以及解决计算机视觉和量子化学等任务中的旋转不变性问题。然而,这种完全可逆的技术往往会使基于树的算法中预测变量与目标之间的关系变得模糊,而深度学习模型则不受影响,这得益于它们强大的学习能力,能够学习到应用的旋转。
这个结果并不一定表明 DNNs(深度神经网络)的优势,而更可能是 GBDTs 的局限性。深度架构具有旋转不变性,这意味着它们可以检测旋转信号,如在图像识别中,某些图像可以无论其方向如何都能被识别。相比之下,GBDTs 不具有旋转不变性,只能检测始终以相同方式定向的信号,因为它们基于分割规则进行操作。因此,对数据进行任何类型的旋转,如主成分分析或奇异值分解,都可能对 GBDTs 产生不利影响。不受旋转影响的 DNNs 在这些情况下可以迎头赶上。
目前,这项研究加强了我们对于 GBDTs(梯度提升决策树)及其感知优势的经验:
-
即使在中等规模的数据集(1,000–5,000 个案例)上也能表现良好,但根据我们的经验,在 10,000 到 100,000 个样本的情况下,其表现优于其他算法
-
倾向于在本质上异质的数据集上表现出色
-
对目标数据中的噪声和不规则性具有鲁棒性
-
由于其自动特征选择过程,可以过滤掉噪声或不相关的特征
除了这里提到的优势之外,还应注意的是,在某些场景下,GBDTs(梯度提升决策树)通常比 DNNs(深度神经网络)更受欢迎。一个原因是 GBDTs 需要更少的数据预处理,这使得它们更高效且易于实现。此外,在目标函数方面,GBDTs 与 DNNs 一样灵活。在两种情况下,都有许多可供选择,这在具有复杂优化目标的领域中特别有用。GBDTs 的另一个好处是,它们提供了更多控制决策树规则构建的方式,为用户提供了一定的透明度和可解释性。最后,GBDTs 在大多数情况下比 DNNs 训练得更快,并且根据它们的复杂性,它们也可以在合理的时间内进行预测,这在实时应用或时间敏感的任务中可能是一个关键因素。
现在你已经了解了梯度提升背后的基本概念及其在解决表格数据问题上的有效性,与深度学习相比,下一节将探讨一些其实现,从 Scikit-learn 提供的实现开始。
5.3 Scikit-learn 中的提升
Scikit-learn 为回归和分类任务提供了梯度提升算法。这些算法可以通过 GradientBoostingClassifier (mng.bz/Ea9o) 和 GradientBoostingRegressor (mng.bz/N1VN) 类分别访问。
Scikit-learn 对梯度提升的实现是数据科学领域 Python 用户最早可用的选项之一。这个实现与 1999 年 Jerome Friedman 提出的算法原始提案非常相似[“Greedy Function Approximation: A Gradient Boosting Machine,” Annals of Statistics (2001): 1189–1232]。让我们在接下来的代码列表中看看实现的效果,我们将对 Airbnb NY 数据集上的分类器性能进行交叉验证,以预测列表价格是否高于或低于中位数。
列表 5.10 Scikit-learn 梯度提升分类器
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import accuracy_score
accuracy = make_scorer(accuracy_score)
cv = KFold(5, shuffle=True, random_state=0)
model = GradientBoostingClassifier(
n_estimators=300,
learning_rate=0.1,
max_depth=4,
min_samples_leaf=3,
random_state=0
) ①
model_pipeline = Pipeline(
[('processing', column_transform),
('modeling', model)]) ②
cv_scores = cross_validate(
estimator=model_pipeline,
X=data,
y=target_median,
scoring=accuracy,
cv=cv,
return_train_score=True,
return_estimator=True
) ③
mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"{mean_cv:0.3f} ({std_cv:0.3f})",
f"fit: {fit_time:0.2f}",
f"secs pred: {score_time:0.2f} secs") ④
① 具有指定超参数的 GradientBoostingClassifier 模型
② 首先使用 column_transform 进行数据处理,然后拟合模型的管道
③ 使用定义的管道进行五折交叉验证,计算准确度分数,并返回额外信息
④ 打印交叉验证中准确度分数的平均值和标准差
使用之前使用的相同参数集,我们获得的效果略好于我们实现中获得的:
0.826 (0.004) fit: 16.48 secs pred: 0.07 secs
Scikit-learn 实现的一个显著特点是训练过程可能需要相当长的时间,瓶颈可以归因于决策树,这是 Scikit-learn 本身使用的唯一支持构建序列集成模型。作为交换,你在参数上拥有一些灵活性,并且可以控制 GBDT 使用单个决策树的方式。例如,Scikit-learn 的梯度提升允许你
-
定义
init函数。在我们的实现中,我们使用平均值作为第一个估计器。在这里,你可以使用任何你想要的估计器作为起点。由于梯度提升基于梯度下降,而梯度下降优化过程对起点敏感,因此在解决更复杂的数据问题时,这可能是一个优势。 -
通过训练具有指数损失的
GradientBoostingClassifier(loss="exponential")将算法回退到 Adaboost,这是启发序列集成的原始算法。 -
详细控制所使用的决策树的复杂性,这意味着你可以通过诸如
-
min_samples_split用于分裂内部节点所需的最小样本数 -
min_sample_leaf对于成为叶子节点所需的最小样本数 -
min_weight_fraction_leaf对于在叶子节点所需的输入样本总权重中加权分数的最小值 -
max_depth对于树的深度 -
将
min_impurity_decrease作为不纯度降低的阈值,用于决定是否分裂或停止树的生长 -
max_leaf_nodes作为停止生长树之前可以达到的最大最终节点数
-
-
如果仅仅控制决策树的生长还不够,一旦它们生长出来,你可以使用
ccp_alpha参数来减少它们的复杂性。此参数通过移除未通过复杂性测试的节点来从最终节点回退树(有关详细信息,请参阅mng.bz/DM9n)。 -
对行(
subsample)和列(max_features)进行子采样,这是一种特别有效的减少过拟合并提高训练模型泛化能力的方法。
此外,该实现还提供了对稀疏数据和支持早停的支持,这是一种防止 GBDT 和神经网络过拟合的程序,将在下一章中详细讨论。
此外,在提供的输出方面,此版本提供的支持相当令人印象深刻,使其成为理解并解释为什么你的 GBDT 模型做出某些预测的完美工具。例如,你可以访问所有使用的决策树,并要求从集成树中预测的原始值,无论是作为整体(decision_function 方法)还是作为一系列步骤(staged_decision_function 方法)。
最近,由于 XGBoost、LightGBM 和 Scikit-learn 的 HistGradientBoosting 提供的更快、更高效的解决方案,这种实现已被从业者较少使用。然而,如果您想控制梯度提升过程的某些方面,它仍然是一个有趣的选择。在下一节中,我们将探讨 XGBoost,并确定它如何成为解决您的表格数据问题的更强大选择。
5.3.1 应用提前停止以避免过拟合
原始 Scikit-learn 类梯度提升的展示提供了一个机会,介绍一个可以帮助控制过拟合的程序。该程序是提前停止,这是一种最初用于梯度下降的方法,用于限制在优化系数的进一步调整不会带来增强或导致解决方案泛化不良时迭代的数量。该方法也已被用于训练神经网络。在梯度提升中,它将梯度下降作为其优化过程的一部分,该方法可以帮助解决相同的问题:限制添加的决策树模型数量以减少计算负担并避免可能的过拟合。
提前停止的工作步骤如下:
-
将训练数据集的一部分留出以形成验证集。
-
在训练过程的每次迭代中,使用验证集评估产生的部分模型。
-
记录并比较部分模型在验证集上的性能与之前的结果。
-
如果模型的表现没有提高,算法会增加其自上次改进以来的迭代计数(通常称为耐心)。否则,它将重置计数。
-
如果在一定的迭代次数内没有改进,则停止训练过程。
-
否则,训练过程将继续进行另一轮迭代,除非所有指定的待提升决策树都已完成。此时,训练过程将停止。
图 5.8 展示了这个过程在流程图中的表示。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/ml-tbl-dt/img/CH05_F08_Ryan2.png
图 5.8 描述了 GBDT 中提前停止如何工作的流程图。
图 5.9 从验证指标的角度展示了完全相同的过程,该指标可以是损失或其他您希望获得最佳结果的指标。随着迭代的进行,观察到训练误差通常会降低。另一方面,验证误差在算法开始过拟合之前往往有一个最佳点。提前停止有助于捕捉验证误差的增加,监控验证误差的动态变化,让您能够回溯到任何过拟合发生之前的迭代。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/ml-tbl-dt/img/CH05_F09_Ryan2.png
图 5.9 展示了在多次迭代使用 GBDT 时,验证误差和训练误差通常如何表现。
在具有早停法的交叉验证过程中,针对每个折叠训练的模型可能会找到不同的停止点。当在整个数据集上训练时,您仍然可以依赖使用基于验证样本的早停法。因此,您只需设置一个高迭代次数,并观察训练何时停止。否则,您可以使用基于交叉验证中观察到的停止迭代次数的固定迭代次数。在这种情况下,您可以通过计算交叉验证中看到的所有停止点的平均值或中位数来确定要使用的提升树数量。然而,没有固定的规则,您可以选择使用第二大的值来实现激进的停止策略,或者使用第二小的值来实现保守的停止策略。
此外,考虑到训练数据比交叉验证期间更多,因此可以考虑增加提升树的数量。作为一个一般性指南,可以通过将提升树的数量增加一个百分比来达到这个目的,这个百分比等于交叉验证中使用的折叠数目的倒数。然而,由于没有一种适合所有情况的解决方案,可能需要进行实验以找到最适合您问题的最佳方法。
例如,我们再次运行之前的代码,这次设置更高的基本估计器数量以及两个参数,validation_fraction 和 n_iter_no_change,这些参数激活了早停过程。参数 validation_fraction 确定了用于验证的训练数据比例,并且仅在 n_iter_no_change 设置为一个整数时有效,该整数表示在停止过程之前,在验证集上测试模型时,应该经过多少次迭代而没有改进。
列表 5.11 使用 GradientBoostingClassifier 应用早停法
model = GradientBoostingClassifier(
n_estimators=1000, ①
learning_rate=0.1,
validation_fraction=0.2, ②
n_iter_no_change=10, ③
max_depth=4,
min_samples_leaf=3,
random_state=0
)
model_pipeline = Pipeline(
[('processing', column_transform),
('modeling', model)])
cv_scores = cross_validate(estimator=model_pipeline,
X=data,
y=target_median,
scoring=accuracy,
cv=cv,
return_train_score=True,
return_estimator=True)
mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"{mean_cv:0.3f} ({std_cv:0.3f})",
f"fit: {fit_time:0.2f} secs pred: {score_time:0.2f} secs")
iters = [cv_scores["estimator"][i].named_steps["modeling"].n_estimators_
for i in range(5)] ④
print(iters) ⑤
① 将迭代次数从之前的 300 提高到 1,000 的 GradientBoostingClassifier 模型
② 作为验证分数,GradientBoostingClassifier 使用 20% 的训练数据用于验证。
③ 当验证集上没有改进时,GradientBoostingClassifier 的训练将在 10 次迭代后停止。
④ 提取每个折叠估计器在训练期间使用的估计器数量
⑤ 打印每个折叠估计器的估计器数量列表
输出是
0.826 (0.005) fit: 6.24 secs pred: 0.04 secs
[145, 109, 115, 163, 159]
由于交叉验证折叠的平均迭代次数为 268 次迭代,根据经验法则,在训练阶段使用所有可用数据时,我们建议将迭代次数增加 20%,固定为 322 次迭代。
在接下来的章节中,我们将介绍梯度提升的新实现,例如 XGBoost 和 LightGBM。我们还将展示如何使用它们实现早停法。
5.4 使用 XGBoost
XGBoost 在 Kaggle 竞赛中取得成功后获得了关注,例如在希格斯玻色子机器学习挑战赛(www.kaggle.com/c/higgs-boson)中,XGBoost 在竞赛论坛中被提出作为一种比 Scikit-learn 的梯度提升更快速、更准确的解决方案。自那时起,XGBoost 已被广泛应用于许多其他数据科学竞赛中,证明了其有效性和 Kaggle 竞赛作为介绍颠覆性能基准的创新的好场所的作用。Keras 是另一个在 Kaggle 竞赛中取得成功后被广泛采用的创新例子。在撰写本文时,XGBoost 软件包已更新并达到了 2.0.3 版本的里程碑,这是我们在这本书中使用的版本。
XGBoost 最初是由陈天奇作为研究项目提出的,后来在 Carlos Guestrin 的贡献下进一步发展,XGBoost 是一个作为开源软件提供的梯度提升框架。值得注意的是,与由微软赞助的 LightGBM 和其他由谷歌赞助的 Yggdrasil 决策森林(见mng.bz/lYV6)等倡议不同,XGBoost 保持了完全独立,由分布式(深度)机器学习共同社区(dmlc.github.io)维护。
随着时间的推移,该框架经历了重大改进,现在提供了用于分布式处理和并行化的高级功能,使其能够处理大规模数据集。同时,XGBoost 也得到了广泛的应用,目前可在包括 C/C++、Python 和 R 在内的多种编程语言中使用。此外,该框架在多个数据科学平台上得到支持,如 H2O.ai 和 Apache Spark。
作为数据科学用户,你将立即注意到这个框架的几个关键特性,包括
-
处理各种输入数据类型的能力
-
支持自定义目标函数和评估函数
-
自动处理缺失值
-
简单支持 GPU 训练
-
适应单调性和特征交互约束
-
优化独立计算机上的多核和缓存
从系统性能的角度来看,显著的特点包括
-
网络并行训练,允许在机器集群上实现分布式计算
-
在构建树的过程中利用所有可用的 CPU 核心以实现并行化
-
处理不适合内存的大型数据集时的离核计算
然而,将“极端”XGBoost 算法与其他算法区分开来的是其创新的优化算法细节,包括被称为牛顿下降的梯度下降的变体、正则化项,以及其独特的特征分割和稀疏数据处理方法。以下部分简要总结了这些推动 XGBoost 性能的突破性技术。
现在,让我们尝试使用 Airbnb NYC 数据集上的分类任务来运行这个算法。在这种情况下,使用 XGBoost 的XGBClassifier。对于回归问题,你可以使用XGBRegressor类。然而,首先,你需要在你的系统上安装 XGBoost。要安装 XGBoost,你可以直接使用 pip 安装:
pip install XGBoost
或者使用 conda 来完成这项工作:
conda install -c conda-forge py-XGBoost
安装命令应该为你完成所有必要的步骤;如果可能的话,在你的系统上安装算法的 CPU 和 GPU 版本。有关安装过程和详细说明,请参阅mng.bz/BXd0。
在以下列表中,我们复制了我们之前使用 Scikit-learn 的 GradientBoostingClassifier 所采用的方法:我们增强 300 棵树,将决策树的深度限制为四个级别,并接受至少三个示例的节点。
列表 5.12 XGBoost 分类器
from XGBoost import XGBClassifier
accuracy = make_scorer(accuracy_score)
cv = KFold(5, shuffle=True, random_state=0)
xgb = XGBClassifier(booster='gbtree', ①
objective='reg:logistic', ②
n_estimators=300,
max_depth=4,
min_child_weight=3) ③
model_pipeline = Pipeline(
[('processing', column_transform),
('XGBoost', xgb)])
cv_scores = cross_validate(estimator=model_pipeline,
X=data,
y=target_median,
scoring=accuracy,
cv=cv,
return_train_score=True,
return_estimator=True)
mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"{mean_cv:0.3f} ({std_cv:0.3f})",
f"fit: {fit_time:0.2f}",
f"secs pred: {score_time:0.2f} secs") ④
① 创建一个具有指定超参数的 XGBClassifier 模型,包括增强器类型
② 学习目标,相当于 Scikit-learn 的损失
③ min_child_weight相当于 Scikit-learn 的min_samples_leaf。
④ 打印交叉验证测试分数的均值和标准差
获得的结果是目前为止的最佳结果,而且训练拟合只是之前 Scikit-learn 实现所需时间的很小一部分:
0.826 (0.004) fit: 0.84 secs pred: 0.05 secs
在下一小节中,我们将探讨运行此示例所使用的核心参数,并讨论在成功运行任何使用 XGBoost 的表格数据项目时,你需要算法提供的众多参数中的哪些(参见mng.bz/dX6N以获取完整列表)。
5.4.1 XGBoost 的关键参数
让我们回顾一下我们在列表 5.12 中之前示例中决定的特定选项,从n_estimators参数开始,该参数指定了构建集成中涉及的决策树数量,也是我们之前在本章中讨论的梯度下降过程的一部分。
XGBoost 中的n_estimators参数决定了用于生成输出的决策树数量。在标准的表格问题中,此参数的常用值介于 10 到 10,000 之间。虽然增加此值可以通过涉及更多的弱学习器来提高预测性能,但它也可能减慢训练时间。值得注意的是,存在一个理想数量的树,它可以在未见过的数据上的预测任务中最大化性能,而找到这个最佳点取决于其他 XGBoost 参数,例如学习率。为了实现高性能的 XGBoost 模型,根据手头的问题选择适当的树的数量,同时正确设置其他参数,包括学习率,是非常重要的。
与 Scikit-learn 仅限于决策树不同,XGBoost 通过其增强器参数提出了更多的选择:
-
gbtree—决策树,正如你在梯度提升中所期望的那样 -
gblinear—线性回归模型 -
dart——决策树,但优化过程更加规范化
gblinear提升器产生了一系列链式线性模型的和。由于线性组合的和是线性的,您最终会为每个使用的特征得到一个系数,类似于线性模型。您可以使用.coef方法访问这些系数。这是一种以可解释性为重点的 GBDT 模型拟合方法,因为模型可以简化为线性组合,这与您直接拟合的线性组合不同,因为使用了不同的复杂度惩罚和优化方法。最显著的区别是,您不能像线性回归或广义线性模型产生的系数那样解释系数。此外,gblinear提升器生成的截距的解释与经典线性模型不同,因为它受到学习率和提升器使用的初始估计的影响。
dart提升器与其他提升器不同,因为它结合了基于梯度下降的优化与类似于 dropout 的方法,dropout 是一种在深度学习中使用的技巧。由加州大学伯克利分校的研究员和微软研究员在 Rashmi Korlakai Vinayak 和 Ran Gilad-Bachrach 发表的论文“DART: Dropouts Meet Multiple Additive Regression Trees”中提出,该论文发表在《人工智能与统计》(Artificial Intelligence and Statistics. PMLR, 2015)。DART 专注于由于每个决策树的估计依赖于前一个决策树而导致的过拟合问题。研究人员随后从深度学习中的 dropout 理念中汲取灵感,其中 dropout 掩码随机且部分地清除了神经网络层。神经网络不能总是依赖于特定层中的某些信号来确定下一层的权重。在 DART 中,与所有先前构建的树的残差之和相比,梯度不是通过计算得到的。相反,算法在每次迭代中随机选择先前树的一个子集,并将它们的叶子节点按 1/k 的比例缩放,其中 k 是丢弃的树的数量。
gblinear和dart是唯一可用的替代提升器。例如,没有提升器可以模仿随机森林(如另一个 GBDT 实现 LightGBM 中那样)。然而,尽管XGBClassifier和XGBRegression尚未支持随机森林提升器,您可以通过调整 XGBoost 参数和函数来获得类似的结果:
-
使用
num_parallel_tree参数并将其设置为大于 1 的数字。在优化的每一步中,梯度估计不是来自单个决策树,而是来自决策树的集成袋,从而创建了一个提升随机森林模型。在某些情况下,这种方法可能比梯度提升方法提供更好的结果,因为它将以增加的计算成本为代价来减少估计的方差。 -
使用
XGBRFClassifier或XGBRFRegressor,这两个类来自 XGBoost,实现了随机森林方法。这些类仍然是实验性的。更多详情请参阅mng.bz/rKVB,但请注意,与 Scikit-learn 提供的随机森林算法存在一些差异,因为 XGBoost 计算了一个由二阶导数组成的矩阵,称为 Hessian(有关数学定义,请参阅brilliant.org/wiki/hessian-matrix/),用于加权梯度,并且它没有自助能力。因此,你的结果可能会有所不同。
对于损失函数,由参数objective控制,我们选择了reg:logistic,但也可以选择binary:logistic,两者都与二分类中的 log-loss 相当。在 XGBoost 中,损失函数被组织成六个类别:
-
reg用于回归问题(但其中也包括逻辑回归选项)。 -
binary用于二分类问题。 -
multi用于多分类。 -
count用于计数数据——即离散事件。 -
survival用于生存分析,这是一种统计分析技术,用于分析感兴趣事件发生的时间数据,例如机械部件的故障。它考虑了截尾情况,即研究中的某些个体感兴趣的事件尚未发生。 -
rank用于排名问题,例如估计一个网站在结果中应该有的排名。
除了泊松分布,用于建模事件的频率外,XGBoost 还提供了reg:gamma和reg:tweedie,优化用于保险索赔金额建模的两个分布,如第四章在讨论广义线性模型时所述。
各种目标函数的存在展示了 XGBoost 在不同领域可能具有的多种可能应用。关于损失函数的全面概述,请参阅mng.bz/dX6N。损失函数在梯度提升中至关重要,因为它们定义了优化目标。相比之下,评估指标在梯度提升中不用于优化梯度下降。然而,它们在监控训练过程、优化特征选择、超参数优化甚至启用早期停止以停止不再提供益处的训练中起着至关重要的作用。XGBoost 中与 Scikit-learn 的min_samples_leaf等效的是min_child_weight。这两个参数都控制决策树叶子节点所需的最小样本数。因此,它们通过限制生成的树的深度来正则化决策树。然而,由于min_child_weight指的是子节点中所需的 Hessian 权重的最小总和,而min_samples_leaf指的是叶子中所需的最小样本数,因此这两个参数在 XGBoost 和 Scikit-learn 中的使用方式不同,因此它们并不完全可比。
作为一般规则,min_child_weight影响单个决策树的构建方式,该参数的值越大,生成的树就越保守。通常要测试的值范围从 0(表示没有限制叶子节点的大小)到 10。在 2015 年纽约市数据科学学院的一次演讲中,标题为“赢得数据科学竞赛”,前 Kaggle 顶级竞争者 Owen Zhang 建议通过将 3 除以要预测的数据中稀有事件的百分比来计算此参数的最佳值。例如,按照这个经验法则,由于我们的类别是 50%/50%分割,理想值应该是 3/0.5,结果为 6。
我们在示例中没有使用的其他重要 XGBoost 参数如下:
-
学习率,也称为eta,是 XGBoost 中的一个参数,它决定了模型学习的速率。较低的学习率允许模型以更慢的速度但更精确地收敛,这可能导致更好的预测准确性。然而,这将导致迭代次数更多,训练时间更长。另一方面,设置值过高可以加快过程,但会导致模型性能更差,因为当学习参数过高时,优化会超过其目标,就像梯度下降中发生的那样。 -
alpha和lambda分别是 L1 和 L2 正则化器。它们都贡献于避免 XGBoost 梯度下降优化部分中的过拟合。 -
XGBoost 中的
max_depth参数控制算法的复杂度。如果此值设置得太低,模型可能无法识别出许多模式(称为欠拟合)。然而,如果设置得太高,模型可能会变得过于复杂,并识别出对新数据不具良好泛化能力的模式(称为过拟合)。理想情况下,此值应在 1 到 16 之间。 -
XGBoost 中的
gamma,或称min_split_loss参数,是一个介于 0 到无穷大的正则化参数,将此值设置得更高会增加正则化的强度,从而降低过拟合的风险,但如果值过大,可能会导致欠拟合。此外,此参数还控制着决策树的结果复杂度。我们建议从 0 或低值开始,然后在设置完所有其他参数后测试增加此值。 -
XGBoost 中的
colsample_bytree参数控制训练过程中给定树使用的特征或预测器的总数比例。将此值设置为小于 1 意味着每个树可能使用不同的特征子集进行预测,这可能会降低过拟合的风险或减少对单个特征的过度依赖。它还通过不在每个树中使用所有特征来提高训练速度。此参数的允许值范围在 0 到 1 之间。 -
XGBoost 中的
subsample参数控制训练过程中给定树使用的实例数比例。类似于colsample_bytree,此参数可以帮助减少过拟合并提高训练时间。通过为每个树使用案例的一部分,模型可以在数据中识别出更通用的模式。subsample的默认值为 1.0,这意味着每个树都使用所有实例。
在许多情况下,你可能只需要 XGBoost 提供的或在此讨论的一些参数来满足你的项目需求。简单地调整learning_rate,设置优化步数,并将min_child_weight设置为防止梯度提升过程中单个决策树过拟合,通常就足够了。此外,设置objective、max_depth、colsample_bytree和subsample参数可能会带来好处,但调整大量其他可用参数不太可能带来显著的改进。这一点不仅适用于 XGBoost,也适用于梯度提升的不同实现。
接下来,我们将解释是什么使得 XGBoost 在计算和预测方面表现更优。
5.4.2 XGBoost 的工作原理
如 Tianqi Chen 和 Carlos Guestrin 在 2016 年发表的论文“Xgboost: A Scalable Tree Boosting System”中所述(《第 22 届 ACM SIGKDD 国际知识发现和数据挖掘会议论文集》,2016 年),XGBoost 卓越的性能归功于几个在其他实现中不存在的创新:
-
并行学习列块
-
二阶近似以加快优化速度
-
改进的分割查找算法
-
稀疏感知的分割查找
列块是并行学习中使用的一种技术,涉及将数据集划分为列块或特征子集。这允许在多个处理器上并行训练,显著减少整体训练时间。当您训练 XGBoost 模型并查找指向多个不同核心使用的 CPU 利用率时,您可以看到它的实际效果。XGBoost 不能像其他集成模型(如随机森林)那样同时使用多个核心训练多个模型,因为梯度提升是一个序列模型,每个模型都是在另一个模型的结果之后训练的。相反,XGBoost 的每个单独模型的训练过程被分配到多个核心,以提高效率和速度。
目前,XGBoost 可以通过两个不同的 API 在 Python 中使用:原生 API 和 Scikit-learn API。在这本书中,我们将仅使用 Scikit-learn API,因为它在最佳建模实践方面的优势,以及能够轻松利用 Scikit-learn 库中各种工具的额外好处,如模型选择和管道,正如第四章所述。
当使用原生 API 时,用户需要将他们的数据转换为DMatrix,这是一个针对内存效率和训练速度优化的 XGBoost 内部数据结构(mng.bz/VVxP)。使用 DMatrix 格式使得列块技术成为可能。然而,当使用 Scikit-learn API 时,用户可以将他们的数据作为 pandas DataFrame 或 Numpy 数组输入,无需显式转换为 DMatrix 格式。这是因为 XGBoost 在底层执行转换,使过程更加流畅。因此,可以安全地选择最适合您偏好的 API,因为这两个 API 提供相同的性能,只是在一些参数、默认值和选项上有所不同。
为了加快优化速度,采用包含二阶导数(从一阶导数派生的梯度)的二阶近似,基于更全面的根查找技术,即牛顿法。在最小化的上下文中,我们通常将牛顿法称为牛顿下降而不是梯度下降。列表 5.13 展示了它作为一个新的类实现,即NewtonianGradientBoosting类,它继承自原始的 GradientBoosting 类,并对其现有方法和属性进行了一些添加和修改。特别是,我们添加了 Hessian 计算以平衡梯度步骤,以加速收敛,并添加了一个正则化项以防止过拟合。
列表 5.13 XGBoost 的工作原理
class NewtonianGradientBoosting(GradientBoosting): ①
"""the Newton-Raphson method is used to update the predictions"""
reg_lambda = 0.25 ②
def hessian(self, y_true, y_pred):
hessian = np.ones_like(y_true) ③
return hessian
def fit(self, X, y):
self.init = self.logit(np.mean(y))
y_pred = self.init * np.ones((X.shape[0],))
for k in range(self.n_estimators):
gradient = self.gradient(self.logit(y), y_pred)
hessian = self.hessian(self.logit(y), y_pred)
tree = DecisionTreeRegressor(**self.params)
tree.fit(
X,
-gradient / (
hessian + self.reg_lambda
)
) ④
self.trees.append(tree)
y_pred += self.learning_rate * tree.predict(X)
cls = NewtonianGradientBoosting(n_estimators=300,
learning_rate=0.1,
max_depth=4,
min_samples_leaf=3,
random_state=0) ⑤
cls.fit(X, y) ⑥
preds = cls.predict(Xt)
score = accuracy_score(y_true=yt, y_pred=preds)
print(f"Accuracy: {score:0.5f}") ⑦
① 定义一个新的类 NewtonianGradientBoosting 作为 GradientBoosting 的子类
② 设置正则化参数 reg_lambda
③ 使用全为 1 的常数 Hessian 矩阵初始化
④ 通过将负梯度除以 Hessian 和正则化参数之和来拟合决策树
⑤ 创建一个具有指定超参数的 NewtonianGradientBoosting 类实例
⑥ 将 NewtonianGradientBoosting 模型拟合到训练数据
⑦ 使用拟合的模型预测目标值,并计算准确度评分以进行评估
结果的准确性略好于我们从原始 GradientBoosting 类中获得的结果:
Accuracy: 0.82514
在我们这个例子中,Hessian 矩阵可能并不特别有用,因为它对于所有数据都是相同的,这是因为我们使用的目标函数类型:平方误差。然而,在优化其他目标函数的上下文中,Hessian 矩阵提供了关于函数曲率的信息,这可以用来确定函数的方向和变化率。直观上,你可以推断出,随着曲率的增大,Hessian 的值也会增大,这会减少梯度的作用,起到学习率制动的作用。相反,较小的曲率会导致学习率的加速。使用 Hessian 的信息,你可以为每个训练样本获得一个自适应的学习率。然而,作为副作用,计算二阶导数通常可能很复杂或难以处理,需要大量的计算。确定二阶导数的解析表达式和数值方法需要大量的计算努力。在下一章,我们将提供有关如何通过计算梯度和大阵的解析和数值方法来构建自定义目标函数的更多信息。
XGBoost 使用的 Newton 优化中也扮演着正则化项的角色,这些项在 Hessian 中汇总并进一步降低目标——即由基学习器估计的调整。XGBoost 还借鉴了梯度下降的另一个想法,即采用正则化,如我们在示例中实现的那样,以及 L1 正则化。额外的正则化项有助于平滑最终学习的权重,并通过直接修改 Newton 下降步骤来避免过拟合。因此,考虑如何调整 L1 和 L2 值(在 XGBoost 和 LightGBM 实现中称为 lambda 和 alpha)作为重要的超参数,以改善优化结果并减少过拟合非常重要。这些正则化值确保 Newton 下降在优化过程中采取较小的步骤。
在下一节中,我们将继续通过检查分割查找算法对算法提供的加速性能的贡献,来探索 XGBoost 引入的新功能。
5.4.3 使用直方图分割加速
梯度提升基于二叉树,通过将数据分区以在结果分割中获得比原始集合更好的优化目标度量。由于梯度提升将所有特征视为数值,它有独特的方式来决定如何分区。为了找到用于分割的特征和分割规则,二叉树决策应遍历所有特征,对每个特征进行排序,并评估每个分割点。最终,决策树应选择导致相对于目标有更好改进的特征及其分割点。
随着大数据集的出现,决策树中的分割过程对基于序列模型的原始 GBDT 架构提出了严重的可扩展性和计算问题。从计算角度来看,GBDT 的主要成本在于学习决策树,而学习决策树中最耗时的工作是找到最佳分割点。
持续寻找最佳分割点需要相当长的时间,这使得在大量特征和实例上进行训练时算法变得非常耗时。直方图分割通过用直方图的分割点来替换每个特征的值,以总结其值,从而有助于减少时间。列表 5.14 模拟了在我们的数据问题上的分割搜索。为此,我们定义了一个目标函数和一个分割函数,这两个函数既可以作为原始决策树分割算法运行,也可以通过基于直方图的更快分割运行。
列表 5.14 直方图分割
import numpy as np
def gini_impurity(y):
_, counts = np.unique(y, return_counts=True)
probs = counts / len(y)
return 1 - np.sum(probs**2) ①
def histogram_split(x, y, use_histogram, n_bins=256):
if use_histogram:
hist, thresholds = np.histogram(
x, bins=n_bins, density=False
) ②
else:
thresholds = np.unique(x) ③
best_score = -1
best_threshold = None ④
for threshold in thresholds: ⑤
left_mask = x <= threshold
right_mask = x > threshold
left_y = y[left_mask]
right_y = y[right_mask] ⑥
score = (
gini_impurity(left_y) * len(left_y)
+ gini_impurity(right_y) * len(right_y)
) ⑦
if score > best_score: q ⑧
best_threshold = threshold
best_score = score
return best_threshold, best_score ⑨
① 计算并返回标签集 y 的基尼不纯度的函数
② 如果 use_histogram 为 true,则计算所选特征的直方图
③ 如果 use_histogram 为 false,则仅枚举特征中的所有唯一值
④ 初始化最佳得分和阈值
⑤ 遍历所有可能的阈值
⑥ 根据 selected threshold 将 y 分割成左右子集
⑦ 计算左右子集的基尼不纯度得分
⑧ 如果当前分割的基尼不纯度得分高于先前最佳分割,则更新最佳得分和阈值
⑨ 返回最佳阈值及其对应的基尼不纯度得分
在列表 5.14 中的代码中,在定义评分函数和基尼不纯度之后,我们定义了一个函数,该函数选择一个特征并枚举其潜在分割的值以进行评估。如果我们使用基本方法,则考虑所有唯一值。相反,使用直方图方法,计算一个 256 个分箱的直方图,我们使用分隔值以作为潜在分割候选者的分箱来探索。如果我们的特征有超过 256 个唯一值,使用直方图将节省我们在迭代所有分割候选者并使用评分函数评估它们时的大量时间。
现在我们已经解释了示例函数的工作原理,我们准备进行测试。我们决定在预测宿主是否位于高价或低价范围内的分类任务中,最优地分割纬度。由于纬度特征有许多独特的值可以作为分割候选者,因为曼哈顿是一个长而窄的南北岛屿,房地产价值随着纬度变化,因此这应该是一个困难的任务,因为我们预计会有许多不同的纬度需要与目标进行比较。
在我们的第一次测试中,我们试图仅通过评估特征呈现的所有唯一值来找到最佳分割:
%%time
histogram_split(x=data.latitude, y=target_median, use_histogram=False)
CPU times: user 46.9 s, sys: 10.1 ms, total: 46.9 s
Wall time: 46.9 s
(40.91306, 24447.475447387256)
在我们的第二次测试中,我们依赖于评估基于特征的 256 个箱直方图找到的分割点:
%%time
histogram_split(
x=data.latitude,
y=target_median,
use_histogram=True,
n_bins=256
)
CPU times: user 563 ms, sys: 0 ns, total: 563 ms
Wall time: 562 ms
(40.91306, 24447.475447387256)
在直方图分割的底层,我们发现分组,其中变量的值被分组到离散的箱中,每个箱被分配一个唯一的整数以保持箱之间的顺序。分组也常被称为 k-箱,其中名称中的 k 指的是将数值变量重新排列成多少组,它用于直方图绘图,其中你可以声明 k 的值或自动设置它以总结和表示你的数据分布。
加速不仅是因为要评估的分割点数量较少,这些分割点可以并行测试,从而使用多核架构,而且还因为直方图是基于整数的结构,比连续值向量处理得更快。
XGBoost 使用一种算法来计算最佳分割,该算法基于对值进行预排序和直方图的用法。预排序分割的工作原理如下:
-
对于每个节点,枚举特征
-
对于每个特征,按其值对实例进行排序
-
使用线性扫描和直方图,确定特征的最好分割并计算信息增益
-
在所有特征及其最佳分割中挑选最佳解决方案
XGBoost 还有其他概念改进:传统的分割查找算法用exact表示,作为tree_method参数的值。加权分位数草图,在 XGBoost API 中称为approx,是 XGBoost 独有的特性。这种分割查找技术利用近似和利用梯度统计信息衍生出的信息。通过使用分位数,该方法在候选者中定义潜在的分割点。值得注意的是,分位数是加权的,以优先选择能够减轻高梯度、减少重大预测错误的候选者。
使用直方图的加权分位数草图现在作为tree_method="hist"可用,自 2.0.0 版本发布以来,这是默认方法。相比之下,approx树方法为每个迭代生成一组新的箱,而hist方法则重用多个迭代中的箱。
算法的另一个特性与 DMatrices 中的数据存储有关。树学习的最耗时部分是对数据进行排序。为了减少排序成本,我们提出将数据存储在内存单元中:一个块。这允许我们线性扫描预排序条目并并行处理,从而为我们提供了一个高效的并行算法用于分割查找。
在 LightGBM 中成功实现直方图聚合后,XGBoost 采用了它。直方图聚合也是HistGradientBoosting的主要特性,这是 Scikit-learn 基于直方图的梯度提升,我们将在 LightGBM 之后介绍。
5.4.4 对 XGBoost 应用早期停止
5.3.1 节说明了早期停止如何在 Scikit-learn 的梯度提升中工作。XGBoost 也支持早期停止。您可以通过在实例化 XGBClassifier 或 XGBRegressor 模型时添加一些参数来指定早期停止:
-
early_stopping_rounds—这是在停止训练之前等待验证分数没有改进的轮数。如果您将其设置为正整数,则当验证集的性能在该轮数内没有改进时,训练将停止。 -
eval_metric—这是用于早期停止的评估指标。默认情况下,XGBoost 使用rmse作为回归的均方根误差和error作为分类的准确率。不过,您也可以从长长的列表(可在mng.bz/xK2W)中指定任何其他指标,以及指定您自己的指标(将在下一章关于高级机器学习主题的章节中讨论)。
除了设置这些参数外,在拟合时,您还必须指定一个包含其目标的样本,用于监控评估指标。这是通过parameter eval_set完成的,它包含一个包含所有验证样本及其响应的元组列表。在我们的例子中,我们只使用一个验证集。尽管如此,如果有多个样本需要监控,XGBoost 将只考虑数据响应的最后元组用于停止目的。
在列表 5.15 中,我们通过将数据分为训练集和测试集来复制我们之前实验过的相同方法。然而,为了正确监控评估指标,我们进一步将训练集分割以从中提取验证集。
列表 5.15 对 XGBoost 应用早期停止
train, test = train_test_split(
range(len(data)),
test_size=0.2,
random_state=0
) ①
train, validation = train_test_split(
train,
test_size=0.2,
random_state=0
) ②
xgb = XGBClassifier(booster='gbtree',
objective='reg:logistic',
n_estimators=1000,
max_depth=4,
min_child_weight=3,
early_stopping_rounds=100, ③
eval_metric='error') ④
X = column_transform.fit_transform(data.iloc[train])
y = target_median[train]
Xv = column_transform.transform(data.iloc[validation])
yv = target_median[validation]
xgb.fit(X, y, eval_set=[(Xv, yv)], verbose=False) ⑤
Xt = column_transform.transform(data.iloc[test])
yt = target_median[test]
preds = xgb.predict(Xt)
score = accuracy_score(y_true=yt, y_pred=preds)
print(f"Accuracy: {score:0.5f}") ⑥
① 使用固定的随机种子将数据索引分为训练集和测试集
② 使用相同的随机种子进一步将训练集分为训练集和验证集
③ 初始化一个具有 100 轮早期停止耐心的 XGBoost 分类器
④ 使用’error’参数,相当于准确率,作为评估指标
⑤ 将 XGBoost 分类器拟合到训练数据 X 和标签 y,以及验证数据 Xv 和 yv 的性能
⑥ 在将预测标签与真实标签比较后打印准确率得分
训练完成后,我们成功获得了这个准确度指标,它在我们的先前交叉验证结果中表现略逊一筹,因为它是在更少的示例上训练得到的——也就是说,是可用数据的 64%,因为我们保留了 20%用于测试和 16%用于验证:
Accuracy: 0.82657
在训练过程中,评估指标会不断被检查,如果迭代次数没有超过由early_stopping_rounds指定的次数,拟合过程将被终止。最佳迭代会被自动记录并在预测时使用。因此,你无需对模型做任何事情。如果你需要验证停止前的迭代次数,可以通过查询模型的best_iteration属性来获取。在我们的例子中,xgb.best_iteration返回 200。
5.5 LightGBM 简介
LightGBM 首次在 2017 年一篇题为“LightGBM: A Highly Efficient Gradient Boosting Decision Tree”的论文中被介绍,该论文由微软的 Guolin Ke 及其团队撰写(mng.bz/AQdz)。最近,该软件包达到了 4.3.0 版本,这是我们在这本书中测试的版本。根据作者的说法,LightGBM 中的“light”一词强调了该算法比传统的梯度提升决策树训练更快、内存使用更低。论文通过在多个公共数据集上的实验,证明了该算法的有效性及其通过超过 20 倍的速度加快传统梯度提升决策树的训练过程,同时保持几乎相同的准确率。LightGBM 作为开源软件在 GitHub 上提供(github.com/microsoft/LightGBM/),迅速在数据科学家和机器学习从业者中获得了人气。
在理论上,LightGBM 与 XGBoost 有许多相似的特征,例如支持缺失值、原生处理分类变量、GPU 训练、网络并行训练以及单调性约束。我们将在下一章中详细介绍这些内容。此外,LightGBM 还支持稀疏数据。然而,它的主要优势在于速度,因为它在许多任务上比 XGBoost 快得多,这使得它在 Kaggle 竞赛和实际应用中都变得非常流行。Kaggle 社区很快注意到了 LightGBM,并将其与已经流行的 XGBoost 一起纳入他们的竞赛作品中。实际上,跟踪数据科学竞赛场景的网站 mlcontests.com 在 2022 年报告称,LightGBM 已经成为竞赛获胜者的首选工具,在受欢迎程度上超过了 XGBoost。令人印象深刻的是,25%的表格问题解决方案都是基于 LightGBM 的。虽然 LightGBM 在数据科学从业者中取得了相当的成功,但 XGBoost 在整体上仍然更受欢迎。例如,XGBoost 仓库在 GitHub 上的星标比 LightGBM 仓库多得多。
LightGBM 是一个跨平台的机器学习库,适用于 Windows、Linux 和 MacOS。可以使用 pip 或 conda 等工具进行安装,或从源代码构建(请参阅完整的安装指南mng.bz/ZlEP)。它的使用语法与 Scikit-learn 类似,使得熟悉 Scikit-learn 的用户可以轻松过渡到 LightGBM。在优化梯度下降时,LightGBM 遵循 XGBoost 的步伐,通过使用牛顿-拉夫森更新来控制梯度下降,这涉及到将梯度除以海森矩阵。GitHub 上 Guolin Ke 的回答确认了这一点(请参阅github.com/microsoft/LightGBM/issues/5233)。
让我们在之前使用 ScikitLearn 的 GradientBoosting 和 XGBoost 考察的相同问题上测试这个算法。
列表 5.16 LightGBM 分类器
from lightgbm import LGBMClassifier
accuracy = make_scorer(accuracy_score)
cv = KFold(5, shuffle=True, random_state=0)
lgbm = LGBMClassifier(boosting_type='gbdt', ①
n_estimators=300,
max_depth=-1,
min_child_samples=3,
force_col_wise=True, ②
verbosity=0)
model_pipeline = Pipeline(
[('processing', column_transform),
('lightgbm', lgbm)]) ③
cv_scores = cross_validate(estimator=model_pipeline,
X=data,
y=target_median,
scoring=accuracy,
cv=cv,
return_train_score=True,
return_estimator=True) ④
mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"CV Accuracy {mean_cv:0.3f} ({std_cv:0.3f})",
f"fit: {fit_time:0.2f}",
f"secs pred: {score_time:0.2f} secs") ⑤
① 初始化一个具有估计器数量、最大树深度和最小子样本数量的 LGBMClassifier
② 强制按列构建直方图
③ 创建一个包含列转换步骤和 LGBMClassifier 步骤的模型管道
④ 使用模型管道执行五折交叉验证,并使用准确率评分
⑤ 打印交叉验证期间获得的测试平均分数和标准差
以下是在准确率、训练和预测时间方面的令人印象深刻的结果:
0.826 (0.004) fit: 1.16 secs pred: 0.16 secs
与 XGBoost 类似,LightGBM 使用n_estimators、learning_rate、lambda_l1和lambda_l2(L1 和 L2 正则化)等参数来控制梯度下降。帮助 LightGBM 控制其复杂性的最重要的参数如下:
-
max_depth—此参数控制集成中每个树的最大深度。较高的值会增加模型的复杂度,使其更容易过拟合。如果设置为-1,则表示不对树的生长设置限制。 -
num_leaves—此参数指定树中最大叶子节点数,因此也决定了模型的复杂度。为了避免过拟合,应将其设置为小于2**(max_depth)。 -
min_data_in_leaf—此参数控制每个叶子节点中必须存在的最小样本数。较高的值可以防止树生长过深和过拟合,但如果设置得太高,也可能导致欠拟合。默认值为 20。我们建议尝试较低的值,例如 10,然后进行测试,将值增加到 300。
参数feature_fraction和bagging_fraction控制 LightGBM 从特征和示例中采样的方式:
-
feature_fraction—此参数控制每个分割时考虑的特征比例。类似于 XGBoost 中的colsample_bytree参数,它可以通过防止模型过度依赖任何特征来帮助减少过拟合。 -
bagging_fraction—此参数控制每个树使用的数据的分数。类似于 XGBoost 中的 subsample 参数,它可以通过从数据中随机采样来帮助减少过拟合并提高训练速度。 -
bagging_freq—此参数在 XGBoost 中不存在,它决定了 bagging 应该应用的频率。当设置为 0 时,即使指定了bagging_fraction,也会关闭 bagging 示例。值为 n 表示每 n 次迭代进行一次 bagging。例如,值为 2 表示每两次(一半的时间)进行一次 bagged 迭代。
与 LightGBM 在训练过程中的执行方式相关,verbosity控制训练过程中的输出信息量,而force_col_wise表示在树构建期间基于列构建特征分割的直方图。LightGBM 可以按列或按行构建直方图。按列构建直方图通常更快,但可能需要更多内存,特别是对于具有大量列的数据集。按行构建直方图较慢,但处理具有大量列的数据集时可能更节省内存。LightGBM 将自动选择为数据集构建直方图的最佳方法。然而,您也可以通过设置force_col_wise或force_row_wise参数来强制 LightGBM 使用特定方法。
对于 XGBoost,LightGBM 也可以通过指定boosting参数来使用不同的基础学习器:
-
gbdt—默认选项,使用决策树作为基础学习器 -
rf—实现了随机森林算法 -
dart—实现了“Dropouts meet Multiple Additive Regression Trees”算法
此外,将参数linear_tree设置为 true,因为你正在使用默认的boosting=gbdt,将拟合一个分段线性梯度提升树——即终端节点具有线性模型的决策树。这是一个折衷方案,它同时使用决策树的非线性学习能力和线性模型对未见、异常案例的推演能力。
在下一节中,我们将仔细检查区分 LightGBM 和 XGBoost 的所有创新。
5.5.1 LightGBM 如何生长树
让我们检查区分 LightGBM 和 XGBoost 的每个特征,从 LightGBM 如何生长决策树开始。与 XGBoost 按层次增加树(也称为深度优先)不同,LightGBM 按叶节点增长树(也称为最佳优先)。这意味着算法选择提供最大增益的叶节点,然后进一步分割它,直到不再有利可图。相比之下,层次方法同时分割同一深度的所有节点。
总结来说,在 XGBoost 的层向增长方法中,算法将所有树叶生长到同一水平。然后它同时将它们分割,这可能会导致许多无意义的叶子节点,这些节点对最终预测的贡献不大。相比之下,LightGBM 的叶向增长方法在每一步都分割具有最大损失减少的叶子,从而产生更少的叶子节点但准确率更高。叶向方法允许 LightGBM 只关注对目标变量影响最大的重要特征。这意味着算法可以通过更少的分割和更少的树快速收敛到最优解。
图 5.10 展示了两种方法的表示:左侧是层向方法,右侧是叶向方法,两者都限制最多有四个终端节点。这两种方法在决定应用哪些规则以及如何分割数据方面采取了完全不同的路径。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/ml-tbl-dt/img/CH05_F10_Ryan2.png
图 5.10 层向(左侧)和叶向(右侧)树增长的不同
需要指出的是,如果你允许两棵树使用相同的数据生长,一棵使用叶向方法,另一棵使用完全的层向方法,它们将定义相同的终端叶子和预测。区别在于它们的构建方式,叶向方法在首先分割提供最大信息增益的节点方面更为激进。
这意味着在基于达到一定数量的终端节点或树分割的特定深度应用停止规则时,叶向和层向方法会有所不同。在这种情况下,叶向方法可能导致更小的树、更快的训练时间和更高的准确率,但也伴随着过拟合风险增加。为了控制树叶的深度增长并解决过拟合问题,你可以在 LightGBM 中控制最大深度参数。
5.5.2 通过独家特征捆绑和基于梯度的单侧采样获得速度
为了进一步减少训练时间,梯度提升和其他许多机器学习算法的基本策略是减少处理示例的数量。减少处理值数量的最简单方法是使用随机抽样(即行减少)和/或降维技术,如列抽样或主成分分析(即列减少)。尽管抽样可以在数据存在噪声的情况下提高准确性,但过度抽样可能会损害训练过程并降低预测性能。在 LightGBM 中,基于梯度的单侧抽样算法(GOSS)决定了抽样的方式和程度。降维技术依赖于识别数据中的冗余,并通过线性组合(通常是加权求和)将它们结合起来。然而,线性组合可能会破坏数据中的非线性关系。通过丢弃罕见信号进行降维可能会导致模型精度降低,如果数据问题的成功解决依赖于这些弱信号。在 LightGBM 中,降维是通过独家特征捆绑(EFB)来处理的,这是一种在不丢失信息的情况下减少列维度的方法。
让我们先解释 LightGBM 中的两个主要速度提升,从 EFB 的工作原理开始。EFB 是一种技术,它有效地减少了特征数量,同时不损害数据完整性。当广泛使用 one-hot 编码和二进制特征时,许多特征变得稀疏,值少而零多。你可以通过求和这些特征并编码一些值来保留所有非零值而不会丢失。LightGBM 通过将这些特征分组到独家特征捆绑(Exclusive Feature Bundles)中来优化计算和数据维度,确保预测准确性得到保持。
图 5.11 展示了如何有效地将两个特征捆绑在一起。该解决方案涉及仅在特征 A 非零值时添加特征 B 到特征 A,使用特征 A 中存在的最大值。这种组合特征将保留原始特征的顺序,因为特征 A 的值与特征 B 的值是分开的,并将位于值分布的不同部分。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/ml-tbl-dt/img/CH05_F11_Ryan2.png
图 5.11 演示了 EFB 在结合两个特征时的工作原理
寻找捆绑独家特征的最佳方式是一个复杂的问题,被归类为 NP-hard。然而,根据 Guolin Ke 及其团队撰写的 LightGBM 论文,贪婪算法可以通过自动捆绑许多特征来提供一个良好的近似。特征捆绑算法按顺序工作,选择具有最少重叠值的特征并将它们捆绑在一起。如果它找到另一个具有最小重叠的特征,它将继续捆绑。否则,它开始一个新的捆绑,直到找不到更多的捆绑为止。停止规则由两个特征之间的冲突程度提供。如果它们的冲突多于某个 gamma 阈值,则无法创建捆绑,如果没有更好的候选者,整个过程可能停止。尽管从这个贪婪过程中产生的捆绑不保证是最佳的,但该算法在合理的时间内提供了一个可接受的解决方案。
论文中提出的其他性能改进是 GOSS。正如我们提到的,如果 EFB 旨在减少列维度,GOSS 则通过有效采样行来工作,而不带偏见。
GOSS 基于以下观察:某些数据实例不太可能为找到分割点提供有用的信息。搜索精心选择的训练集子集可以节省计算时间,而不会影响预测精度。此外,在梯度提升决策树中,算法在为每个数据实例的梯度进行优化时隐式指定数据实例的权重。确定权重对于计算先前估计的纠正至关重要,但这也可以用于采样可能更有趣学习的数据实例。
GOSS 估计具有更大梯度的数据示例对信息增益的贡献更大。专注于具有更大梯度的示例,并忽略其中一部分具有较小梯度的示例,应该可以减少处理的数据实例数量,同时仍然优化算法以进行预测。GOSS 的流程如下:
-
GOSS 首先根据梯度绝对值对数据示例进行排序。
-
它选择顶部 a × 100%的数据示例。
-
它从剩余数据中随机抽取 b × 100%的数据示例。
-
它使用 1 的权重对顶部数据示例进行训练,并使用(1 – a) / b 的权重对随机抽取的数据示例进行训练。
最终加权是必要的,以保持数据集的原始数据分布,并避免其表示中的任何不希望的变化。
GOSS 可以加速梯度提升决策树的训练,尤其是在处理大型数据集和复杂树时。原始论文的作者证明了与传统方法相比,GOSS 的采样近似误差对于大型数据集来说变得可以忽略不计。在我们使用 GOSS 的经验中,最佳情况下,你得到的结果与标准 LightGBM 训练相似。尽管如此,速度提升是显著的,这使得 GOSS 在寻找正确的超参数或选择与你的问题最相关的特征时,是一个更快实验的好选择。
与我们提出的其他加速方法不同,GOSS 不是默认使用的:你必须指定你想要使用它。
5.5.3 将早期停止应用于 LightGBM
LightGBM 支持早期停止,控制它的参数与 XGBoost 实现中使用的参数相似。在列表 5.17 的例子中,我们使用 LightGBM 进行训练,并在训练阶段使用测试集来评估算法的性能。如果在测试集上 100 次迭代内没有性能提升,算法将停止训练过程。它选择迄今为止在测试集上性能最高的迭代轮次。
列表 5.17 将早期停止应用于 LightGBM
from lightgbm import LGBMClassifier, log_evaluation
train, test = train_test_split(range(len(data)), test_size=0.2,
random_state=0) ①
train, validation = train_test_split(
train,
test_size=0.2,
random_state=0
) ②
lgbm = LGBMClassifier(boosting_type='gbdt',
early_stopping_round=150,
n_estimators=1000,
max_depth=-1,
min_child_samples=3,
force_col_wise=True,
verbosity=0) ③
X = column_transform.fit_transform(data.iloc[train])
y = target_median[train]
Xv = column_transform.transform(data.iloc[validation])
yv = target_median[validation]
lgbm.fit(X, y, eval_set=[(Xv, yv)], ④
eval_metric='accuracy', ⑤
callbacks=[log_evaluation(period=0)]) ⑥
Xt = column_transform.transform(data.iloc[test])
yt = target_median[test]
preds = lgbm.predict(Xt)
score = accuracy_score(y_true=yt, y_pred=preds)
print(f"Test accuracy: {score:0.5f}") ⑦
① 使用固定的随机种子将数据索引分割为训练集和测试集
② 使用相同的随机种子进一步将训练集分割为训练集和验证集
③ 使用估计器数量、最大深度和最小子样本数初始化 LightGBM 分类器
④ 将 LightGBM 分类器拟合到训练数据 X 和标签 y,以及在验证数据 Xv 和 yv 上的性能
⑤ 将准确度设置为评估指标
⑥ 设置一个回调以抑制评估(周期=0)
⑦ 在比较预测标签与真实标签后打印准确度分数
即使在这种情况下,结果也受到我们只训练可用数据的 64%这一事实的惩罚:
Accuracy: 0.82585
然而,与 XGBoost 实现相比,你可以在代码中注意到一些细微的差异。eval_metric使用不同的名称(你可以在mng.bz/RVZK上检查)并且,为了在训练期间抑制评估的打印,你不需要像在 XGBoost 中那样使用 verbose 参数;相反,你必须指定一个回调函数(log_evaluation),该函数必须在拟合时在回调函数列表中声明。
最近,早期停止也被实现为一个回调函数(见mng.bz/2yK0)。在模型实例化期间保持早期停止轮次的声明只是为了保持与 XGBoost 的 API 兼容性。如果你将早期停止作为回调使用,你将更有控制权来决定 LightGBM 停止训练的方式:
-
first_metric_only允许你指示是否只使用第一个指标进行早期停止或使用你指定的任何指标。 -
min_delta表示保持训练的最小指标改进,通常设置为零(任何改进),但可以提高到对集成增长施加更严格的控制。
在前面的例子中,你只需从 LGBMClassifier 实例化中删除early_stopping_rounds,并将适当的回调添加到 fit 方法中的回调列表中,即可获得相同的结果:
early_stopping(
stopping_rounds=150,
first_metric_only=True,
verbose=False,
min_delta=0.0
)
无论你使用什么方法,导致最佳验证分数的迭代索引都将存储在模型的best_iteration属性中,并且当预测时将自动使用该迭代。
5.5.4 使 XGBoost 模仿 LightGBM
自从 LightGBM 及其不平衡决策树的出色应用引入以来,XGBoost 也开始支持除了其原始的层策略之外,还支持叶策略。在 XGBoost 中,原始的层策略被称为depthwise,而叶策略被称为lossguide。通过使用grow_policy参数设置一个或另一个,你可以让 XGBoost 表现得像 LightGBM 一样。此外,XGBoost 的作者建议,在使用 lossguide 增长策略时,应设置以下参数以避免过拟合:
-
max_leaves——设置要添加的最大节点数,并且仅与 lossguide 策略相关。 -
max_depth——设置树的最大深度。如果grow_policy设置为depthwise,则max_depth的行为与往常一样。然而,如果grow_policy设置为lossguide,则max_depth可以设置为零,表示没有深度限制。
顺便说一下,你也有相同的参数在 LightGBM 中使用,用于相同的目的(max_leaves是别名——即参数num_leaves的另一个工作名称)。
5.5.5 LightGBM 如何启发 Scikit-learn
在 Scikit-learn 0.21 版本中,添加了两种基于梯度提升树的创新实现:HistGradientBoostingClassifier和HistGradientBoostingRegressor,灵感来自 LightGBM。你可能想知道,如果当前的 LightGBM 和 XGBoost 版本可以提供你需要的一切来开发基于梯度提升的最佳性能表格解决方案,你为什么要费心于这种新的实现。它们还确保与 Scikit-learn API 完全兼容。花时间看看它是值得的,因为基于直方图的实现,尽管现在还在进行中,预计将取代原始实现,提供对学习过程和决策树构建的相同控制。此外,它在某些特定应用中甚至比 XGBoost 和 LightGBM 表现出更好的预测性能。因此,对于你的特定问题进行测试可能是值得的。
与原始的 Scikit-learn 梯度提升实现相比,新的基于直方图的实现具有以下新特性:
-
分箱
-
多核(初始实现是单核)
-
不支持稀疏数据
-
内置对缺失值的支持
-
单调性和交互约束
-
原生分类变量
目前,在谈论差异时,新的基于直方图的实现中不支持稀疏数据。因此,如果您的数据在稀疏矩阵中,您应该首先将数据矩阵密集化。此外,一些典型的 GradientBoostingClassifier 和 GradientBoostingRegressor 特征仍需要支持——例如,一些损失函数。
在 API 方面,大多数参数与 GradientBoosting Classifier 和 GradientBoostingRegressor 相同。一个例外是 max_iter 参数,它取代了 n_estimators。以下列表显示了将 HistGradientBoostingClassifier 应用于我们的分类问题,并使用 Airbnb NYC 数据集对高于中值市场价值的列表进行分类的示例。
列表 5.18 新的 Scikit-learn 的直方图梯度提升
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import accuracy_score
accuracy = make_scorer(accuracy_score)
cv = KFold(5, shuffle=True, random_state=0)
model = HistGradientBoostingClassifier(learning_rate=0.1,
max_iter=300,
max_depth=4,
min_samples_leaf=3,
random_state=0) ①
model_pipeline = Pipeline(
[('processing', column_transform),
('modeling', model)]) ②
cv_scores = cross_validate(estimator=model_pipeline,
X=data,
y=target_median,
scoring=accuracy,
cv=cv,
return_train_score=True,
return_estimator=True) ③
mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"{mean_cv:0.3f} ({std_cv:0.3f})",
f"fit: {fit_time:0.2f}",
f"secs pred: {score_time:0.2f} secs") ④
① 使用提升算法的特定超参数初始化 HistGradientBoostingClassifier
② 创建一个结合数据预处理(column_transform)和模型的模型管道
③ 在模型管道上执行五折交叉验证,返回分数和训练估计器
④ 打印交叉验证准确度分数的均值和标准差
结果是
0.827 (0.005) fit: 1.71 secs pred: 0.13 secs
与我们之前使用 XGBoost 和 LightGBM 的示例相比,这个示例在使用的命令和 max_iter 参数上有所不同,以 n_estimators 代替了通常的 max_iter。此外,Scikit-learn 的新提升算法是一个直方图算法。您只需设置 max_bins 参数来改变初始默认值 255(因为 1 被保留用于缺失情况,所以是 256 个箱子)。
该算法仍在开发中,并且缺乏对稀疏数据的支持。这意味着在存在许多 one-hot 编码特征的情况下,无论您如何准备数据,它都无法像 XGBoost 或 LightGBM 那样快速运行。
摘要
-
集成算法通过使用多个模型或将它们链接在一起来提高单个模型的预测能力:
-
集成算法通常基于决策树。
-
有两种核心集成策略:平均和提升。
-
平均策略,如随机森林,倾向于减少预测的方差,同时仅略微增加偏差。
-
粘贴是一种平均方法,涉及创建一组不同的模型,这些模型在数据的子样本上训练,并将预测结果汇总在一起。
-
Bagging 与平均类似,但使用 bootstrapping 而不是子采样。
-
平均方法可能计算密集,并通过采样排除数据分布的重要部分来增加偏差。
-
-
随机森林是一种集成学习算法,通过在建模过程中使用 bootstrapping 样本和子采样特征来结合决策树(随机补丁):
-
它创建了一组彼此不同的模型,并产生更可靠和准确的预测。
-
它可以用来确定特征重要性并测量数据集中案例的相似性。
-
算法需要对其少数超参数进行微调,如使用的树的数量,并通过设置用于分裂的最大特征数、树的最大深度和终端分支的最小大小来调整偏差-方差权衡。
-
如果树的数量设置得太高,计算成本可能会很高。
-
-
ERT(极端随机树)是随机森林算法的一种变体:
-
它在每个决策树节点随机选择特征进行分裂,导致方差较小(因为树更多样化),但偏差更大(随机化牺牲了一些决策树的预测精度,导致更高的偏差)。
-
对于具有许多共线性和噪声特征的的大型数据集,它计算效率更高且更有用。
-
通过使生成的树集相关性较低来降低方差。
-
-
GBDT 是一种高度有效的机器学习方法,用于处理表格数据问题。它已成为多个领域的领先方法,包括多类分类、广告点击预测和搜索引擎排名。与其他方法,如神经网络、支持向量机、随机森林和 bagging 集成相比,GBDT 通常在标准表格问题中表现更好。
-
梯度提升之所以有效,是因为它结合了梯度下降,这是一种典型的线性模型和神经网络的优化过程,以及基于先前决策树总和的梯度训练的决策树。
-
Scikit-learn 为回归和分类任务提供了梯度提升算法的最早选项之一。最近,原始算法被一个基于直方图的更快版本所取代,该版本仍在开发中。
-
XGBoost 是一种梯度提升决策树算法,在 Kaggle 的希格斯玻色子机器学习挑战赛中成功应用后获得了流行。它基于基于牛顿下降的更复杂优化,并提供了以下优势:
-
处理各种输入数据类型的能力
-
支持自定义目标函数和评估函数
-
自动处理缺失值
-
简单支持 GPU 训练
-
适应单调性和特征交互约束
-
独立计算机上的多核心和缓存优化
-
-
LightGBM 是一种高效的梯度提升决策树算法,由微软的 Guolin Ke 及其团队在 2017 年的一篇论文中提出。该算法旨在比传统的梯度提升决策树更快、更节省内存,这在多个公共数据集的实验中得到了证明。LightGBM 算法通过其叶节点分裂策略和 EFB 实现了这一点。
第六章:高级特征处理方法
本章涵盖
-
使用更高级的方法处理特征
-
选择有用的特征以创建更轻便、更易于理解的模型
-
优化超参数以使您的模型在性能上更加出色
-
掌握梯度提升决策树的具体特性和选项
我们现在已经讨论了决策树,它们的特性,它们的局限性,以及所有它们的集成模型,无论是基于随机重采样的,如随机森林,还是基于提升的,如梯度提升。由于提升解决方案被认为是表格数据建模的当前最佳状态,我们详细解释了它是如何工作的以及如何优化其预测。特别是,我们介绍了几种可靠的梯度提升实现,XGBoost 和 LightGBM,它们正在证明是处理表格数据的科学家所能获得的最佳解决方案。
本章将涉及有关经典机器学习的更一般主题。然而,我们将专注于梯度提升决策树(GBDTs),特别是 XGBoost。在本章中,我们将讨论更高级的特征处理方法,例如多元缺失值插补、将高基数分类特征转换为简单数值的目标编码,以及根据它们与目标的关系来确定如何转换或细化特征的一般方法。我们将提出几种方法来减少特征数量到基本要素,并根据可用的计算资源和您选择的模型来优化超参数。然后,本章将以仅与 GBDTs 相关的先进方法和选项部分结束。
6.1 特征处理
在处理现实世界的表格数据集时,您可能会遇到各种问题,如果我们不调整技术以应对数据的现实情况,我们之前讨论的所有方法都将产生不理想的结果。在这里,我们将考虑一些这样的问题,例如以最智能的方式处理缺失值,转换具有大量唯一值的分类特征,以及找到在训练模型后重新处理特征以挤出更多性能的方法。这当然不是详尽的列表,但它应该能帮助您练习发现问题和规划适当的方案。
如前一章所述,为了解释和举例,我们再次将依赖于 Airbnb 纽约市数据集来展示处理表格数据问题中最具挑战性的任务的实用示例。以下列表回顾了我们将再次在本章中使用的数据和一些关键函数和类。
列表 6.1 回顾 Airbnb 纽约市数据集
import numpy as np
import pandas as pd
from sklearn.preprocessing import (
StandardScaler,
OneHotEncoder,
OrdinalEncoder
)
from sklearn.impute import SimpleImputer
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
data = pd.read_csv("./AB_NYC_2019.csv")
excluding_list = ['price', 'id', 'latitude', 'longitude', 'host_id',
'last_review', 'name', 'host_name'] ①
low_card_categorical = [
'neighbourhood_group',
'room_type'
] ②
high_card_categorical = ['neighbourhood'] ③
continuous = [
'minimum_nights',
'number_of_reviews',
'reviews_per_month',
'calculated_host_listings_count',
'availability_365'
] ④
target_mean = (
(data["price"] > data["price"].mean())
.astype(int)
) ⑤
target_median = (
(data["price"] > data["price"].median())
.astype(int)
) ⑥
target_multiclass = pd.qcut(
data["price"], q=5, labels=False
) ⑦
target_regression = data["price"] ⑧
categorical_onehot_encoding = OneHotEncoder(handle_unknown='ignore')
categorical_ord_encoding =
OrdinalEncoder(handle_unknown="use_encoded_value", unknown_value=np.nan)
numeric_standardization = Pipeline([('StandardScaler', StandardScaler()),
('Imputer',
SimpleImputer(strategy="constant", fill_value=0))])
column_transform = ColumnTransformer(
[
('low_card_categories',
categorical_onehot_encoding,
low_card_categorical),
('high_card_categories',
categorical_ord_encoding,
high_card_categorical),
('numeric',
numeric_standardization,
continuous)
],
remainder='drop',
verbose_feature_names_out=True,
sparse_threshold=0.0) ⑨
lm_column_transform = ColumnTransformer(
[
('low_card_categories',
categorical_onehot_encoding,
low_card_categorical),
('numeric',
numeric_standardization,
continuous)
],
remainder='drop',
verbose_feature_names_out=True,
sparse_threshold=0.0) ⑩
① 特征处理中排除的列列表
② 需要一元编码的低基数分类列列表
③ 需要顺序编码的高基数分类列列表
④ 列出连续特征列
⑤ 创建一个二元目标,指示价格是否高于平均值(不平衡的二元目标)
⑥ 创建一个二元目标,指示价格是否高于中位数(平衡的二元目标)
⑦ 通过将价格分箱为五个类别来创建多类目标
⑧ 将回归的目标设置为价格列
⑨ 创建一个列转换器,对不同的特征组应用不同的转换
⑩ 创建一个适合线性模型的列转换器
我们参考前一章中提供的代码解释的所有细节。唯一的补充是一个专门为线性模型设计的列转换器。这个转换器仅通过执行独热编码处理低基数分类特征,而将高基数分类特征排除在外。
6.1.1 多变量缺失数据填充
在你的表格数据集中存在缺失数据是一个阻碍问题,因为除了 GBDTs 解决方案,如 XGBoost、LightGBM 和 Scikit-learn 的 HistGradientBoosting 之外,经典的机器学习算法没有对缺失值的原生支持。此外,即使你选择的 GBDTs 算法可以处理缺失值,正如下一节所解释的,你仍然可能发现直接填充缺失值更有效,因为你可以在事先检查每个特征或特定案例是如何处理的。
在第二章中,我们讨论了简单的填充方法,例如使用平均值或中位数,以及构建缺失指示器的有用性,从而使得算法更容易发现存在的缺失模式。本节将提供更多关于这些技术和多元填充的细节。
首先,除非缺失案例依赖于未观测变量,例如你无法访问的特征,否则缺失数据可以归类为
-
完全随机缺失(MCAR)——在这种情况下,数据缺失与观测到的和未观测到的变量无关。缺失发生在数据集的随机位置。
-
随机缺失(MAR)——MAR 假设观测变量,而不是未观测变量,可以解释缺失。换句话说,缺失的概率完全取决于观测数据。
当缺失案例依赖于缺失数据的未观测值时,你陷入了“缺失非随机”(MNAR)的情况,这需要相当专业的处理,但这本书不是主题。然而,假设你理解某些缺失非随机缺失数据背后的机制,例如当你没有在人口普查中获得关于太富有人(由于隐私)或太穷的人(由于普遍缺乏访问)的信息时。在这种情况下,你可以尝试收集一些暗示他们财富的新特征,以添加到你的数据集中,并回到“随机缺失”(MAR)的情况。
通常,你经常会遇到缺失数据是 MCAR 或 MAR 的情况。在这两种情况下,除了使用与 MCAR 完美配合的期望值进行简单填充外,你还可以通过多元填充更好地重建缺失数据。多元填充是一种使用数据集中预测变量之间的相关性来填充缺失值的方法。它涉及构建一系列模型,根据变量之间的关系来估计缺失值。在此方法中,每个模型将具有缺失值的特征视为目标变量(通过仅对其已知值进行建模),并使用剩余的特征作为预测变量。然后,使用得到的模型来确定用哪些值替换目标中的缺失值。你可以设置算法如何循环特征进行填充。你通常使用默认设置,从缺失数据较少的特征开始,逐步过渡到缺失值较多的特征,这是首选且最有效的方法。
为了处理预测变量中的缺失值,首先使用简单均值或其他基本填充方法进行初步填充。然后,通过多次迭代,通过结合填充模型的成果来细化初始估计。这种迭代过程持续进行,直到填充值达到稳定状态,进一步的迭代不会导致显著变化。Scikit-learn 通过IterativeImputer实现多元填充(mng.bz/MDZQ)。受 R MICE 包(通过链式方程进行多元填充:mng.bz/avEj)的启发,它允许进行多元填充和多次填充,这是统计学和社会科学中常见的方法,在这种方法中,你得到的不是单个填充值,而是一系列可能的替代值的分布。通过将sample_posterior参数设置为 True 并使用不同的随机种子多次运行IterativeImputer,可以实现多次填充。
然而,在数据科学中的表格数据应用中,多元填充是首选的选择,因为它允许基于单一但精确的估计来构建模型。在我们的例子中,我们取 Airbnb NYC 数据集的连续特征,并随机删除 5%的数据,从而模拟 MCAR 情况。之后,我们运行SimpleImputer,用均值和IterativeImputer替换缺失值。最后,我们使用平均绝对误差(MAE)比较每种方法重建的特征与原始值。
列表 6.2 多元填充
from sklearn.experimental import (
enable_iterative_imputer
) ①
from sklearn.impute import SimpleImputer, IterativeImputer
from sklearn.ensemble import RandomForestRegressor
Xm = data[continuous].copy() ②
missing_percentage = 0.05
np.random.seed(0)
mask = np.random.rand(*Xm.shape) < missing_percentage ③
Xm[mask] = np.nan
simple_imputer = SimpleImputer()
Xm_si = simple_imputer.fit_transform(Xm) ④
rf = RandomForestRegressor(random_state=0, n_jobs=-1) ⑤
multivariate_imputer = IterativeImputer(
estimator=rf,
max_iter=1,
tol=0.01
) ⑥
Xm_mi = multivariate_imputer.fit_transform(Xm) ⑦
mae = pd.DataFrame(
{
"simple": np.mean(
np.abs(data[continuous] - Xm_si), axis=0
),
"multivariate": np.mean(
np.abs(data[continuous] - Xm_mi), axis=0
)
},
index = continuous
) ⑧
print(mae)
① 导入 IterativeImputer,它在 Scikit-learn 中仍然是实验性的,并且正在改进中
② 创建连续特征数据的副本
③ 创建一个掩码以随机标记缺失值
④ 使用具有均值填充策略的 SimpleImputer 实例
⑤ 实例化 RandomForestRegressor 进行迭代填充
⑥ 创建一个具有最大迭代次数 max_iter 和容忍度 tol 作为停止标准的 IterativeImputer 实例
⑦ 使用迭代插补来插补缺失数据
⑧ 计算插补数据和原始数据的平均绝对误差(MAE)
命令 print(mae) 提供的结果是一个表格,它比较了简单插补和多变量插补方法:
Simple Multivariate
minimum_nights 0.347355 0.260156
number_of_reviews 1.327776 0.858506
reviews_per_month 0.057980 0.036876
calculated_host_listings_count 0.579423 0.368567
availability_365 6.025748 4.62264
比较结果表明,多变量方法,特别是 IterativeImputer,在单次迭代后始终产生比简单插补方法更低的 MAE 值。这表明 IterativeImputer 在用更少的错误替换缺失值方面更有效。为了获得更好的估计,你可以将 max_iter 增加到更高的数值,并让算法根据容忍度值(用于检查结果是否稳定的容忍度阈值)来决定是否提前停止。增加 max_iter 将导致更长的插补时间,因为作为一个插补模型,我们正在使用随机森林算法。随机森林通常是处理多变量估计(在 R 社区中称为 MissForest 的方法)的最有效方式:rpubs.com/lmorgan95/MissForest。然而,你可以通过简单地替换 IterativeImputer 中的 estimator 来选择基于线性模型或 k 近邻的更快方法:
-
BayesianRidge—简单地使用
BayesianRidge() -
RandomForestRegressor—对于随机树回归的森林,你可以设置
n_estimators、max_depth和max_features来创建更浅的树,从而加速插补过程,例如RandomForestRegressor(n_estimators=30, max_depth=6, max_samples=0.5) -
Nystroem + Ridge—一个通过组合不同的 Scikit-learn 命令(
make_pipeline(Nystroem(kernel="polynomial", degree=2, random_state=0), Ridge(alpha=1e3)))来扩展二次多项式核和正则化线性回归的管道 -
KNeighborsRegressor—一个 k 近邻插补方法,你可以决定要考虑的邻居数量,例如
KNeighbors-Regressor(n_neighbors=5)
你使用的估计器将影响你获得的结果的质量和计算时间。作为起点,BayesianRidge 是默认选择,也是最快的。如果你有更多时间,RandomForestRegressor 将为你提供更好的估计。通过联合输入多个变量,IterativeImputer 以更多的计算和编写代码为代价,更准确地捕捉变量之间的依赖关系。对于简单直接、即插即用的解决方案,一些 GBDT 实现提供了处理缺失值的原生支持,我们将在下一节中了解到这一点。
6.1.2 使用 GBDT 处理缺失数据
XGBoost 和 LightGBM 算法(以及 Scikit-learn 的 HistGradientBoosting)通过在每个分割点将缺失值分配给最大化损失函数最小值的那个分支来相似地处理缺失值。XGBoost 通过其稀疏度感知分割查找算法引入了这项技术,该算法在数据缺失时提供了一个默认的方向,无论是由于缺失还是存储在只保留非零值的稀疏矩阵中。
因此,别忘了 XGBoost 会将稀疏矩阵中的零视为缺失值,并应用其特定的算法来处理缺失数据。因此,一方面,当你分析具有高基数分类变量的 one-hot 编码矩阵时,将其创建为稀疏矩阵可能会很方便,因为这将节省你大量的内存和计算。另一方面,你可能会注意到,如果你分析的数据以密集矩阵或稀疏矩阵的形式表示,XGBoost 返回的模型可能完全不同。
差异在于当 XGBoost 遇到缺失示例时会发生什么。在训练过程中,算法在每个分割点学习,具有缺失值的样本应该根据结果增益分配到左分支或右分支。在做出预测时,具有缺失值的样本将相应地分配到适当的子节点。这允许算法根据特征值的缺失模式进行分割,如果它是预测性的。如果在训练过程中给定特征没有缺失值,则具有缺失值的样本将被分配到具有最多样本的子节点。
你可以使用缺失参数来指定 XGBoost 将考虑为缺失的值。此参数默认设置为 NaN,但你可以选择任何你想要的值。
关于 XGBoost 的另一个重要事项是,使用线性模型作为基学习者的gblinear增强器将缺失值视为零。假设你标准化了你的数值特征,正如在线性模型中常用那样。在这种情况下,gblinear增强器将缺失值视为该特征的平均值,因为平均数会取标准化变量中的零值。
LightGBM 采用类似的方法(见github.com/microsoft/LightGBM/issues/2921),使用特定的参数:
-
LightGBM 默认启用处理缺失值的功能。通过设置
use_missing=false来关闭它。 -
LightGBM 默认使用 NA(NaN)来表示缺失值。通过设置
zero_as_missing=true将其更改为使用零。 -
当
zero_as_missing=false(默认值)时,稀疏矩阵(和 LightSVM)中的未记录值被视为零。 -
当
zero_as_missing=true时,NA 和零(包括稀疏矩阵[和 LightSVM]中的未记录值)被视为缺失。
这种处理缺失数据的方法在平均情况下效果很好,特别是如果你的数据是 MCAR(完全随机缺失)。这意味着缺失实例的模式是完全随机的,并且与任何其他特征或隐藏的潜在过程无关。当缺失与其他特征值相关,但与特征本身的值无关时,情况就不同了,这就是 MAR(相关缺失)。在 NMAR(系统缺失)的情况下,存在与特征本身和其他特征相关的缺失值的系统模式。在 MAR 和 NMAR 的情况下,最佳解决方案是尝试通过其他方式尝试填充这些值,因为 XGBoost 和 LightGBM 的缺失数据策略可能表现出性能不足。
然而,对于缺失数据的填充,也有一些替代方案。例如,你可以创建缺失数据指示器,这些是二进制特征,其值对应于变量中缺失实例。如果数据不是完全随机缺失,缺失数据指示器可能非常有价值,并且它们可以与任何经典机器学习算法一起工作。另一个与决策树相关的流行解决方案是将缺失值分配给数据集中任何变量都没有使用的极端值(通常是负极端值)。如果你使用的是精确分割,而不是基于直方图的分割,其中值被归入桶中,那么用极端值替换缺失数据可以证明是一个高效且简单的方法。
6.1.3 目标编码
分类特征,通常在数据集中以字符串的形式表示,可以通过不同的策略有效地处理。我们已经在第二章和第四章中提到了独热编码。与独热编码一样,所有其他用于分类特征的策略,无论其基数是高还是低,都需要进行编码,这是一种将数据数值化并转换为适合机器学习算法的合适格式的程序。尽管有一些相似之处,但编码不应与嵌入混淆,嵌入是一种将高维数据(如文本或图像)降低到较低维空间的过程,同时保留原始数据的一些特征或关系。嵌入通常通过基于神经网络的模型学习,并在我们的书中简要介绍。
Scikit learn 包提供了一些编码解决方案:
-
OneHotEncoder—用于独热编码(即将每个唯一的字符串值转换为二进制特征),这是我们迄今为止使用的解决方案 -
OrdinalEncoder—用于顺序编码(即,将特征中的字符串值转换为有序的数值;还有一个LabelEncoder,它的工作方式相同,主要用于将分类目标转换为数值)
通常,一热编码对于线性模型和基于树的模型都适用,有序编码对于更复杂的基于树的模型,如随机森林和 GBDTs,也适用,因为树可以递归地根据类别特征进行分割,并最终找到一组对预测有用的分区。然而,当使用一热编码或有序编码时,高基数类别会引发问题。高基数是线性模型和基于树的模型的弱点。当进行一热编码时,高基数类别会产生稀疏矩阵,由于内存限制,这些矩阵不能轻易转换为密集矩阵。此外,具有许多分支级别的决策树可能需要帮助将有序编码的高基数类别特征分割成对预测有意义的分区。
没有一个普遍固定的标准来声明何时一个类别具有高基数,因为这还取决于你的数据集有多少行以及你的计算机内存可以处理多少个一热编码的特征。然而,高基数类别特征通常包括 ID、邮编以及具有许多唯一值的产品或地理名称。例如,一个合理的阈值可能是超过 512,但根据数据集可能更低。根据经验法则,一个特征中的类别数量不应超过数据集总行数的 5%–10%,对于较小的数据集来说,512 可能过高。在这种情况下,标准做法,尤其是来自像 Kaggle 这样的数据科学竞赛,建议求助于目标编码(也称为均值编码)。
目标编码首次在 Micci-Barreca 的论文中提出,“用于分类和预测问题中高基数属性的预处理方案”(ACM SIGKDD Explorations Newsletter 3.1,2001),目标编码简单地将类别特征中的值转换为它们对应的预期目标值。如果你的问题是回归,目标编码将使用与数据集中该值相对应的平均目标值,对于分类问题:条件概率或优势比。这个过程,当数据集中该类别示例较少时,可能会给模型带来过拟合的风险,可以通过使用该类别预期值(目标的后验概率)与所有数据集的平均预期值(所有训练数据中目标的前验概率)之间的加权平均来减轻这种风险。
目标编码在 category-encoders 包中可用(mng.bz/gave),这是一个与 Scikit-learn 兼容的项目,作为目标 TargetEncoder 类(mng.bz/5glq),您可以通过在 shell 中运行pip install category_encoders命令来安装它。在 TargetEncoder 类中,您必须指定一个平滑参数(应固定在零以上的值)以在目标的后验概率和整个训练数据中的先验概率之间进行平衡。您数据中最佳的平滑参数必须通过实验来找到,或者您可以依赖另一个类似的编码器,James Steiner 编码器,它根据您想要编码的类别的条件方差猜测平滑预期目标值的最优方式(mng.bz/5glq)。James Stenier 编码器对您的数据做出了更强的假设。您必须决定通过模型参数(对于回归问题,建议使用“独立”,对于分类问题,使用“二元”)来估计条件方差的不同方法。尽管如此,它还是让您免于像超参数一样尝试不同的混合阈值。
在我们的例子中,我们使用neighborhood特征,它有超过 200 个唯一值,以及将纬度和经度坐标映射到 100 x 100 网格空间后的坐标。映射返回一个具有超过 2,000 个不同值的特征,毫无疑问,它是一个高基数分类特征。在列表 6.3 中,我们首先对纬度和经度进行分箱,然后通过将它们相加以产生每个纬度和经度分箱组合的唯一代码来组合它们。分箱是通过将特征的最小值和最大值之间的范围分成相等的部分来获得的。此外,代码片段对两个不同的特征执行分箱,为每个特征生成整数值集合。一个特征的值乘以一个大于另一个特征最大值的 10 的幂,这确保了当将两组值相加时,总是获得一个唯一值,无论相加的具体值是什么。
列表 6.3 创建高基数分类特征
def bin_2_cat(feature, bins=100):
min_value = feature.min()
bin_size = (feature.max() - min_value) / bins
bin_values = (feature - min_value) / bin_size
return bin_values.astype(int) ①
data['coordinates'] = (
bin_2_cat(data['latitude']) * 1000
+ bin_2_cat(data['longitude']
) ②
high_card_categorical += ['coordinates']
print(data[high_card_categorical].nunique()) ③
① 函数将数值数据转换为分类箱
② 将纬度和经度转换为分类坐标
③ 打印高基数分类特征中的唯一值数量
代码片段以检查高基数特征中每个特征的唯一值数量结束:
neighbourhood 221
coordinates 2259
考虑到有两个分类特征被认为是高基数,我们可以在我们的预处理管道中添加 category-encoders 的TargetEncoder。
列表 6.4 在管道中使用目标编码
from category_encoders.target_encoder import TargetEncoder
from XGBoost import XGBClassifier
from sklearn.model_selection import KFold, cross_validate
from sklearn.metrics import accuracy_score, make_scorer
target_encoder = TargetEncoder(cols=high_card_categorical, ①
smoothing=0.5) ②
accuracy = make_scorer(accuracy_score)
cv = KFold(5, shuffle=True, random_state=0)
xgb = XGBClassifier(booster='gbtree',
objective='reg:logistic',
n_estimators=300,
max_depth=4,
min_child_weight=3) ③
column_transform = ColumnTransformer(
[
('low_card_categories',
categorical_onehot_encoding,
low_card_categorical),
('high_card_categories',
target_encoder,
high_card_categorical),
('numeric',
numeric_standardization,
continuous)
],
remainder='drop',
verbose_feature_names_out=True,
sparse_threshold=0.0) ④
model_pipeline = Pipeline(
[('processing', column_transform),
('model', xgb)]) ⑤
cv_scores = cross_validate(estimator=model_pipeline,
X=data,
y=target_median,
scoring=accuracy,
cv=cv,
return_train_score=True,
return_estimator=True) ⑥
mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"{mean_cv:0.3f} ({std_cv:0.3f})",
f"fit: {fit_time:0.2f}",
f"secs pred: {score_time:0.2f} secs") ⑦
① 为高基数分类特征初始化 TargetEncoder
② 平滑值以融合先验和后验概率
③ 使用特定超参数初始化 XGBoost 分类器
④ 定义 ColumnTransformer 以使用 TargetEncoder 对高基数分类特征进行预处理
⑤ 创建一个结合预处理和建模的管道
⑥ 执行五折交叉验证并获取评估指标
⑦ 从交叉验证中打印出平均准确率、拟合时间和预测时间
当执行时,代码过程将运行 XGBoost 的结果,并额外帮助处理高基数分类特征的问题。结果指向准确率的一点点提升。在本章的后面部分,我们将调查在检查可解释性时目标编码贡献的权重:
0.840 (0.004) fit: 4.52 secs pred: 0.06 secs
尽管目标编码是一个方便的程序,因为它可以快速将任何分类特征转换为数值特征,但在这样做的时候,你必须注意保留数据中的所有重要信息。目标编码使得对特征之间任何交互的进一步建模成为不可能。比如说,如果你正在处理一个广告响应数据集,其中包含许多网站和广告格式的点击结果。如果你对两个特征进行编码,将两个可能具有数千个值的高的基数分类特征转换,你可能会轻松地创建任何类型的经典模型。然而,在编码之后,你的模型,无论是线性的还是基于树的,将无法理解编码特征之间任何可能的交互。在这种情况下,解决方案是在事先创建一个新特征,结合这两个高基数分类特征,然后对它们的组合进行目标编码。
因此,对于其他工具,我们也应该考虑这种高级编码技术的利弊。根据我们的经验,在求助于目标编码之前,对于经典机器学习算法和梯度提升算法处理高基数分类特征,有几个选项:
-
直接删除有问题的分类特征
-
使用 OneHotEncoder
-
使用 OrdinalEncoder 并将类别视为有序等距的数量
-
使用 OrdinalEncoder 并依赖梯度提升直方图算法的本地类别支持
-
作为最后的手段使用目标编码
删除特征只被考虑在少数情况下。然而,我们在第二章中已经提到,你可以如何利用 Cramer 的 V 相关度度量来评估一个名义特征如何有助于预测目标。
当面对高基数分类特征时,对于线性模型来说,选择 one-hot 编码几乎是必要的。当处理其他模型,如决策树及其集成时,可能存在更合适的方案。这是因为 one-hot 编码为分类特征的每个分类值创建了一个额外的特征。这导致树模型在拟合过程中必须考虑的分割点数量增加。因此,使用 one-hot 编码的数据需要在决策树中具有更多的深度,以实现等效的分割,这可以通过使用处理分类特征的不同方式通过单个分割点实现。
对于有序编码器,分类被编码为 0,1,2 等等,将它们视为连续特征。虽然这种方法对于线性模型来说可能会误导,但它对于决策树来说非常有效。决策树可以根据目标变量的关系准确地对数据进行分割,根据分类分离。这在 XGBoost 中发生,它将所有特征视为数值,连续特征。
如果我们决定使用对分类特征的本地支持,这个选项在 LightGBM 以及由 H2O.ai 库提供的 XGBoost 版本中都是可用的(mng.bz/6e75)。本地分类支持使得这些模型能够更有效地处理分类特征,而无需将它们转换为数值。在这种情况下,由于本地处理需要排序分类,我们预计在使用本地处理分类特征相对于将分类视为有序数时,算法会稍微慢一些。在本地分类支持中,一个特征的分类排序是基于每个分类的关联目标方差。一旦排序完成,该特征就可以用作连续的数值属性。
6.1.4 转换数值数据
决策树可以自动处理数据中的非线性性和交互作用。这是因为它们可以在任何点上将变量分割成两部分,然后反复进一步分割。这种特性在处理数据中的微妙和深层交互时特别有用,但有一个前提,因为决策树是相当粗糙的近似器。从精确建模数据中的复杂关系的角度来看,具有足够示例的神经网络是更好的近似器。
图 6.1 展示了如何通过决策树集成来近似非线性函数。结果是通过对空间进行递归分割的一系列 if-then-else 决策规则构建的近似。然而,数据中的噪声可能导致空间某些部分的不准确。相比之下,具有与袋装决策树中使用的树相同数量的节点的神经网络可以提供更平滑、更准确的曲线估计。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/ml-tbl-dt/img/CH06_F01_Ryan2.png
图 6.1 随机数据集上神经网络与袋装树集成预测的比较,数据集包含有噪声的正弦函数
由于 GBDT(梯度提升决策树)也是基于决策树的,它可能在用二分分裂来塑造非线性函数时遇到类似的困难。因此,当使用 GBDT,并且你知道特定的非线性或交互时,通过使用向线性形式转换、分箱或离散化以及特征之间的预计算交互来明确地定义它们,对你是有益的。对于非线性性,转换有助于减少分裂的数量。此外,事先计算特定的交互也可以减少在更好的分裂点发生的分裂数量。
然而,在应用这些转换之前,你需要了解你的数据。线性性和非线性性,即使与目标没有关系,也可以在通过部分依赖图(PDP)完成训练数据的拟合后轻松发现。这种模型无关的图表技术解释了特征和目标是如何通过训练的模型相互关联的。
PDPs(部分依赖图)显示了目标输出如何根据特定的输入特征变化,同时忽略其他输入特征的影响。换句话说,它展示了如果我们为特定输入特征的每个数据点设置一个特定的值,我们将得到的平均预期预测。分析背后的假设是,我们通过 PDP 表示的输入与其他特征是独立的。在这种条件下,PDP 表示输入特征如何直接影响目标。然而,在实践中,这个假设通常被违反,这意味着我们正在检查的输入特征通常并不完全独立于其他特征。因此,图表通常显示了目标值如何随着输入特征值的改变而变化,同时也反映了模型中其他特征的总体影响。
在列表 6.5 中,我们探讨了 PDPs 的可能用途和局限性。针对我们在 Airbnb NYC 数据集上训练的 XGBoost 模型,我们展示了我们的目标如何根据我们的数值特征变化,试图发现任何非线性或其他模型数据的特征。四个生成的图表使用 matplotlib 轴绘制,并进行分析。
列表 6.5 部分依赖图
from XGBoost import XGBClassifier
import matplotlib.pyplot as plt
from sklearn.inspection import PartialDependenceDisplay
xgb = XGBClassifier(booster='gbtree',
objective='reg:logistic',
n_estimators=300,
max_depth=4,
min_child_weight=3)
model_pipeline = Pipeline(
[('processing', column_transform),
('XGBoost', xgb)]) ①
model_pipeline.fit(X=data, y=target_median)
fig, axes = plt.subplots(
nrows=2,
ncols=2,
figsize=(8, 4)
) ②
fig.subplots_adjust(hspace=0.4, wspace=0.2)
PartialDependenceDisplay.from_estimator(
model_pipeline,
X=data,
kind='average', ③
features=[
'minimum_nights',
'number_of_reviews',
'calculated_host_listings_count',
'availability_365'
], ④
ax=axes
)
for ax in axes.flatten():
ax.axhline(y=0.5, color='red', linestyle='--') ⑤
plt.show()
① 创建一个结合数据处理和 XGBoost 分类器的模型管道
② 创建一个 2×2 的子图布局
③ 创建平均效应的部分依赖图
④ 指定用于图表的特征列表
⑤ 在每个子图上添加一条 y=0.5 的红色虚线,作为解释的参考线
图 6.2 显示了四个图表。虚线标记了一个(大于或等于 0.5)和零(小于 0.5)的分类阈值。实线描述了 x 轴上的特征值与 y 轴上的目标概率之间的关系。x 轴上的刻度标记指出特征的分布十分位数,暗示着值密集(刻度彼此相邻)的区域和值稀疏(刻度彼此远离)的区域。值稀疏的区域在估计上不太可靠。例如,minimum_nights和calculated_host_listings_count显示非线性模式,而number_of_reviews和availability_365则表现出稳定的振荡。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/ml-tbl-dt/img/CH06_F02_Ryan2.png
图 6.2 数字特征的 PDP 面板
在得到这样的结果后,你可以尝试通过试错法使用变换函数来评估minimum_nights和calculated_host_listings_count,例如
-
平方或立方变换
-
平方根或立方根
-
对数或指数变换
-
切线、正弦和余弦变换
-
逆变换、平方逆变换、立方逆变换、平方根逆变换、立方根逆变换
-
对数逆变换、指数逆变换、切线逆变换、正弦逆变换、余弦逆变换
然而,在匆忙进行变换测试之前,重要的是要验证获得的 PDP 平均曲线是否代表该特征在所有情况下的行为。你可以使用个体条件期望(ICE)图来验证这一点。ICE 图是 PDP 曲线的单个组成部分。你可以通过稍微修改之前的代码来获得 ICE 图。
列表 6.6 ICE 图
import matplotlib.pyplot as plt
from sklearn.inspection import PartialDependenceDisplay
fig, axes = plt.subplots(nrows=2, ncols=2, figsize=(8, 4))
fig.subplots_adjust(hspace=0.4, wspace=0.2)
PartialDependenceDisplay.from_estimator(model_pipeline,
X=data,
kind='both', ①
subsample=30, ②
features=['minimum_nights',
'number_of_reviews',
'calculated_host_listings_count',
'availability_365'],
ax=axes)
for ax in axes.flatten():
ax.axhline(y=0.5, color='red', linestyle='--')
ax.legend().set_visible(False)
plt.show()
① 创建一个部分依赖图,显示个体和平均效应
② 使用 30%的数据随机子集进行绘图以提高效率
运行代码后,你可以检查结果,如图 6.3 所示。你可以看到与之前相同的 PDP 平均曲线,用虚线表示,以及从样本中随机抽取的 30 条曲线。假设你可以验证这些抽样曲线是聚集在一起的,大致复制了平均曲线的形状。在这种情况下,你可以确认平均 PDP 曲线代表了特征相对于目标的行为。否则,就像我们的例子一样,如果单条曲线看起来不同且分散,其他特征由于共线性或交互作用而在某种程度上调节了特征与特征的关系,你无法从变换特征中获得太多好处。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/ml-tbl-dt/img/CH06_F03_Ryan2.png
图 6.3 数字特征的 ICE 图面板
到目前为止,我们只是使用了 PDP(部分依赖图)来处理数值特征。然而,在通过独热编码进行编码后,你也可以将它们应用于二进制和分类特征。在这种情况下,你首先必须使用独立的函数partial_dependence计算曲线值,然后以条形图(用于 PDP 平均曲线)或箱线图(用于 PDP 和 ICE 曲线一起)的形式表示获得的价值。在以下列表中,我们提取了必要的值,并为neighbourhood_group的单个级别创建了一个箱线图表示。
列表 6.7 二进制特征的 PDP 图
from sklearn.inspection import partial_dependence ①
import matplotlib.pyplot as plt
pd_ice = partial_dependence(model_pipeline, X=data,
features=['neighbourhood_group'],
kind='both')
fig = plt.figure(figsize=(8, 5))
ax = fig.add_subplot(1, 1, 1)
labels = np.ravel(pd_ice['values'])
plt.boxplot(
pd_ice["individual"].squeeze(),
labels=labels
) ②
ax.axhline(y=0.5, color='red', linestyle='--') ③
plt.show()
① 导入计算曲线值的部分依赖函数
② 创建单个 ICE 曲线的箱线图
③ 在每个子图中添加一条红色虚线,y = 0.5,作为解释的参考线
图 6.4 显示了结果,提供了关于曼哈顿公寓位置通常与更高价格相关联的见解。根据模型,其他位置与较低价格相关联。然而,布鲁克林显示出最大的变异性,有时价格较高,类似于曼哈顿,这显然取决于与公寓的确切位置或特征相关的其他因素。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/ml-tbl-dt/img/CH06_F04_Ryan2.png
图 6.4 对于每个二进制特征,PDP 获得的相关目标值的箱线图
与数值特征一样,PDP 曲线也提供了关于如何增强你的模型的有用见解。例如,它们可以用来聚合表现相同的分类特征的级别——在我们的例子中,布朗克斯、斯塔滕岛,也许还有皇后区。
PDP(部分依赖图)向我们展示了基于我们感兴趣的输入特征我们可以期待的目标输出。它们还帮助我们理解目标响应与感兴趣输入特征之间的关系,无论是线性的还是非线性的。通过观察分析绘制的曲线形状,我们还可以找出可以使其线性化的转换。当将特征元组作为PartialDependenceDisplay函数的features参数提供时,该函数将输出一个等高线图,显示两个特定特征的联合效应。以这种方式发现交互是漫长而繁琐的,尤其是如果你有很多特征要探索。一个解决方案是自动发现潜在的交互,然后使用 PDP 联合图表进行测试。通过使用 XGBoost Feature Interactions Reshaped(XGBFIR;github.com/limexp/xgbfir)这样的项目,自动检测交互是直截了当的。以下列表显示了在命令行中通过pip install xgbfir安装包后可以运行的示例。
列表 6.8 通过 XGBFIR 发现交互
import xgbfir
xgbfir.saveXgbFI(
model_pipeline['XGBoost'],
feature_names=(
model_pipeline['processing']
.get_feature_names_out()
),
OutputXlsxFile='fir.xlsx') ①
fir = pd.read_excel('fir.xlsx', sheet_name='Interaction Depth 1') ②
result = fir[["Interaction", "Gain"]].sort_values(by="Gain",
ascending=False).head(10).round(2) ③
for index, row in result.iterrows():
print(f"{row['Interaction']}")
PartialDependenceDisplay.from_estimator(
model_pipeline,
X=data,
kind='average',
features=[(
'minimum_nights',
'calculated_host_listings_count')]) ④
① 使用 xgbfir 生成报告并将其保存到 Excel 文件中
② 读取之前步骤中创建的 Excel 文件
③ 从特征交互报告中提取并按分裂增益排序“交互”和“增益”列
④ 为“minimum_nights”和“calculated_host_listings_count”这两个特征生成部分依赖图
代码将打印一系列交互。如果你使用线性模型,应该测试 XGBFIR 返回的每个交互,因为它们可能会提高你的模型性能。如果你使用决策树,可以忽略涉及二进制特征的交互,只关注数值特征。一个例子是minimum_nights和calculated_host_listings_count之间的交互。图 6.5 显示了将它们与特定值结合如何与正目标响应强烈相关。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/ml-tbl-dt/img/CH06_F05_Ryan2.png
图 6.5 两个数值特征的联合部分依赖图
在这种情况下,通过乘法组合数值特征将优化你的 GDBT 模型的速度更快、更有效。
6.2 选择特征
特征选择并不总是必要的。尽管如此,当需要时,它在识别现有特征集中对训练最有价值的特征方面发挥着至关重要的作用,无论这些特征是否直接来自数据提取,还是你特征工程工作的产物。通过采用有效的特征选择技术,你可以精确地识别并保留对机器学习过程贡献显著的最重要的特征。
在第二章的第 2.2.3 节中,我们讨论了根据你对问题的了解和探索性数据分析来避免收集无关和冗余特征。在随后的章节中,我们讨论了处理无关和冗余特征的机器学习算法。
在经典机器学习中,我们有一大批算法,包括线性模型系列,它们特别容易受到无关和冗余特征的影响,这会降低性能和准确性。被认为无关的信息和无用的噪声特征,因为它们与学习任务的靶子缺乏有意义的关联,可能会给线性模型带来重大挑战。这是由于特征值与靶子之间可能存在随机对齐的可能性,这可能会误导算法并赋予这些特征过多的重视。线性模型利用所有提供的特征,这使得它们特别容易受到噪声特征的影响,因为噪声特征越多,结果就会越差。基于决策树的集成方法则较少受到无关和冗余特征的影响,因为它们会自动选择使用哪些特征并忽略其他特征。这种情况也适用于深度学习。然而,当处理表格数据中的噪声或不相关特征时,深度学习可能不如决策树集成方法鲁棒。在这种情况下,为了获得最佳性能,需要大量数据,以及仔细选择架构,例如使用 dropout、正则化或批量归一化层,以及调整学习率。
特征选择对经典机器学习算法,如线性模型,有益。然而,在基于决策树集成和深度学习架构的情况下,它同样有价值,不应忽视的是,由于处理列数减少,它使得机器学习过程更快。通过在训练前选择特征,这些复杂算法可以通过提炼最相关和最有信息量的特征,并使对模型所捕获的潜在模式和关系的理解更加清晰,从而实现提高清晰度和易于解释。这种简化增强了可解释性,并促进了算法决策过程的沟通。
在以下章节中,我们讨论和测试了一些解决方案,这些解决方案可以独立或顺序使用,以选择仅对解决您的表格数据问题至关重要的特征,并取得最佳结果。我们讨论了确定相关特征(所有相关集)的算法,这些特征可能导致冗余但有用的特征集,以及选择特征最小子集(非冗余集)的算法,这些子集产生的模型与相关特征集相当,但增加了由于特征数量减少而提高的可解释性优势。
6.2.1 线性模型的稳定性选择
稳定性选择基于这样的观点:如果你使用变量选择程序,由于过程本身的变异性,你不会总是得到相同的结果,因为子采样或自助法可能会改变数据。例如,如果你在线性模型中使用 L1 正则化进行特征选择,你可能会发现不同的样本可能会返回不同的非零系数,尤其是对于高度相关的特征。
正如我们所讨论的,L1 正则化惩罚导致系数估计的稀疏性。它是通过向损失函数添加一个惩罚项来实现的,即系数绝对值的总和。这样的惩罚项对系数绝对值的总和施加了约束,促使某些系数变为正好为零。因此,L1 正则化可以通过将某些系数缩小到零并排除相应的特征来有效地选择特征。在高度相关的特征存在的情况下,由于它们对目标变量的贡献相似,L1 正则化可能难以选择一组独特的特征。在这里,机会在某种程度上起着作用,即某些特征根据你在样本中的数据得到非零系数。然而,这可以成为我们的优势。
通过数据采样引入随机性,稳定性选择旨在识别在多个子集中始终出现的重要特征,这表明它们的鲁棒性,并减少随机或噪声选择特征的可能性。稳定性选择将提供一组有用的特征,而不是最小的一组。通过排除不重要的特征,稳定性选择确保识别出所有相关特征,因此它是一个完美的算法,用于减少特征数量的第一步。
如 Meinshausen 和 Büehlmann 在论文中提出(arxiv.org/abs/0809.2932),稳定性选择在一段时间内已被作为 Scikit-learn 的一部分提供,并在 Scikit-learn 兼容的项目中维护。我们可以使用 Scikit-learn 的BaggingClassifier和具有 L1 正则化的LogisticRegression来轻松复制其过程,以解决分类问题。您还可以为回归问题采用相同的代码,使用BaggingRegressor和 L1 回归类Lasso。
在我们的实现中,我们对 L1 逻辑回归进行了一系列 C 值的测试,以对抗 bootstrap 重采样。该过程创建了一系列逻辑回归系数,我们可以对它们求和、平均或计算它们与零不同的次数。鉴于我们使用的是二进制和连续特征的混合,我们发现计算与变量相关的系数的绝对值超过阈值的次数更有用。因此,我们可以最终认为,大多数情况下,倾向于具有相关系数的特征是相关的,这可能会影响最终的预测。
列表 6.9 稳定性选择
import numpy as np
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import BaggingClassifier
lambda_grid=np.logspace(-4, -1, 10) ①
sparse_coef = list()
for modeling_c in lambda_grid:
estimator = LogisticRegression(
solver='liblinear',
penalty='l1',
C=modeling_c
) ②
model = BaggingClassifier(
estimator,
n_estimators=100,
bootstrap=True
) ③
model_pipeline = Pipeline(
[('processing', lm_column_transform),
('standardize', StandardScaler()), ④
('modeling', model)])
model_pipeline.fit(data, target_median)
sparse_coef += [estimator.coef_.ravel() for estimator in
model_pipeline["modeling"].estimators_]
epsilon = 1e-2 ⑤
threshold = 0.5 ⑥
non_zero = (np.abs(sparse_coef) > epsilon).mean(axis=0)
feature_names = model_pipeline["processing"].get_feature_names_out()
print(non_zero)
print(feature_names[non_zero > threshold])
① 使用对数尺度生成一个 lambda 值的网格,用于 L1 正则化
② 创建一个具有 L1(Lasso)惩罚的逻辑回归估计器
③ 创建一个使用逻辑回归估计器作为其基础模型的 BaggingClassifier
④ 数据处理标准化后,所有系数均可比较,无论其规模如何
⑤ 将一个小的值作为 epsilon 设置为一个阈值
⑥ 设置一个阈值值以选择显著的系数
输出突出了相关系数的分布和选定的特征:
[0.635 0\. 0.9 0.7 0.592 1\. 0\. 0.6 0.593 0.444 0.6 0.506 0.7 ]
['low_card_categories__neighbourhood_group_Bronx'
'low_card_categories__neighbourhood_group_Manhattan'
'low_card_categories__neighbourhood_group_Queens'
'low_card_categories__neighbourhood_group_Staten Island'
'low_card_categories__room_type_Entire home/apt'
'low_card_categories__room_type_Shared room' 'numeric__minimum_nights'
'numeric__reviews_per_month' 'numeric__calculated_host_listings_count'
'Numeric__availability_365']
稳定性选择提供了一些优势。它可以处理高维数据,通过引入随机性来避免过拟合,并提供一个考虑选择过程稳定性的特征重要性度量。它通常用于具有大量特征的复杂应用中,如基因组学、文本挖掘或图像分析。另一方面,选择算法仅限于使用 L1 正则化的经典机器学习算法,并返回一组系数,这些系数是我们之前讨论过的:逻辑回归和 Lasso 回归。您可以通过使用特征重要性(许多集成模型估计特征重要性)来扩展稳定性选择的概念,例如 Scikit-learn 中的 SelectFromModel 命令 (mng.bz/oKej),但事情会变得复杂,因为您需要弄清楚什么使重要性估计相关,以及使用什么选择阈值。在下一节中,我们将回顾特征重要性是如何工作的,并介绍 Boruta。使用一个可靠的自动特征选择过程,该算法可以确定决策树集成(如随机森林或梯度提升)的相关特征。
6.2.2 影子特征与 Boruta
Boruta 是一种智能过程,通过依赖模型内部参数(如线性模型中的系数或基于增益的重要性值,例如在决策树及其集成中)来确定特征在机器学习问题中的相关性。它首次发表在 Miron B. Kursa 和 Witold R. Rudnicki 的《使用 Boruta 包进行特征选择》[《统计软件杂志》36 (2010): 1-13]一文中;欲获取文章副本,请参阅 www.jstatsoft.org/article/view/v036i11。
虽然 Boruta 具有创新性,但它与稳定性选择有许多相似之处。它只能与基于决策树的集成一起使用。为了测量特征的相关性,就像在稳定性选择中一样,我们寻找非零系数。在 Boruta 中,我们计算特征重要性超过影子特征获得的最重要性的次数。我们称之为“击中”。影子特征是特征自身的随机版本(基本上是打乱顺序的特征),鉴于它们是随机的,应该仅通过偶然获得任何重要性。如果任何特征不能超过影子特征相同的 Importance,则不能认为它比任何随机值序列更具预测性。
在 Boruta 中,通过将击中次数转换为二项分布来确定选择的阈值,通常是一个非零系数在稳定性选择中的最小出现次数。如果击中次数可以证明特征始终优于任何随机结构,则根据分布保留或删除特征测试的显著性阈值。
列表 6.10 展示了一个使用 Boruta 在 Airbnb 纽约数据集上对 XGBoost 分类选择所有相关特征的示例。在 BorutaPy 实现(github.com/scikit-learn-contrib/boruta_py)中,Boruta 有一些限制,因为除了只能与基于树的模型(如随机森林或梯度提升,无论实现方式如何)一起工作之外,它还不能与管道一起工作。因此,我们首先必须转换数据,然后在训练最终模型时对转换后的特征运行 Boruta。Boruta 的关键参数包括估计器——即你想要使用的模型、集成中的决策树数量以及 n_estimators 超参数,该参数可以留空、设置为整数或设置为“auto”,此时树的数量将根据数据集的大小来决定。Boruta 的其他重要参数包括 max_iter,测试轮数,通常设置为 100,以及二项式检验的 alpha 阈值,该阈值可以从 0.05 增加以允许保留更多特征,或减少以丢弃更多特征。
列表 6.10 Boruta 选择
from XGBoost import XGBClassifier
from boruta import BorutaPy
xgb = XGBClassifier(booster='gbtree',
objective='reg:logistic',
n_estimators=300,
max_depth=4,
min_child_weight=3)
X = column_transform.fit_transform(data, target_median) ①
boruta_selector = BorutaPy(estimator=xgb, n_estimators='auto', verbose=2) ②
boruta_selector.fit(X, target_median) ③
selected_features = boruta_selector.support_ ④
selected_data = column_transform.get_feature_names_out()[selected_features]
print(selected_data)
① 转换输入数据,执行任何必要的预处理步骤
② 使用 XGBoost 分类器初始化一个 BorutaPy 特征选择对象
③ 调整 Boruta 特征选择器
④ 获取由 Boruta 特征选择器确定的所选特征的布尔掩码
经过几次迭代后,你应该只得到一个被丢弃为与问题不相关的特征的结论:
Iteration: 50 / 100
Confirmed: 13
Tentative: 0
Rejected: 1
['low_card_categories__neighbourhood_group_Bronx'
'low_card_categories__neighbourhood_group_Brooklyn'
'low_card_categories__neighbourhood_group_Manhattan'
'low_card_categories__neighbourhood_group_Queens'
'low_card_categories__room_type_Entire home/apt'
'low_card_categories__room_type_Private room'
'low_card_categories__room_type_Shared room'
'high_card_categories__neighbourhood' 'numeric__minimum_nights'
'numeric__number_of_reviews' 'numeric__reviews_per_month'
'numeric__calculated_host_listings_count' 'numeric__availability_365']
可以使用 LightGBM 作为预测器执行相同的程序,而不是 XGBoost:
from lightgbm import LGBMClassifier
lgbm = LGBMClassifier(boosting_type='gbdt',
n_estimators=300,
max_depth=4,
min_child_samples=3)
boruta_selector = BorutaPy(estimator=lgbm, n_estimators='auto', verbose=2) ①
boruta_selector.fit(X, target_median)
selected_features = boruta_selector.support_
selected_data = column_transform.get_feature_names_out()[selected_features]
print(selected_data)
① 使用提供的 LightGBM 分类器初始化一个 BorutaPy 特征选择对象
结果在仅 9 次迭代后达到,这次我们有一个增加的被拒绝特征数量:
Iteration: 9 / 100
Confirmed: 8
Tentative: 0
Rejected: 6
['low_card_categories__neighbourhood_group_Manhattan'
'low_card_categories__room_type_Entire home/apt'
'high_card_categories__neighbourhood' 'numeric__minimum_nights'
'numeric__number_of_reviews' 'numeric__reviews_per_month'
'numeric__calculated_host_listings_count' 'numeric__availability_365']
LightGBM 不仅收敛速度更快,而且其分割方式允许在这个问题中创建一个性能良好的模型,其特征数量比 XGBoost 少得多。
在我们的示例中,我们在所有可用数据上进行了训练。尽管如此,你仍然可以使用 Boruta,即使在交叉验证循环中,你可以在所有折叠中使用所有选定的特征或仅使用至少在折叠中至少被选中一定次数的特征来巩固数据集的结果。
6.2.3 前向和后向选择
Boruta 的一个局限性是它选择了你问题的所有相关特征,但不是必要的特征。这意味着你最终可能得到一个包含冗余和高度相关特征的列表,这些特征可以被缩短选择。在应用 Boruta 之后,我们建议回到序列特征选择过程,如 Scikit-learn 函数SequentialFeatureSelector中实现的那样。此过程通过正向选择添加或通过反向消除根据它们在预测中的性能以贪婪的方式从你的选择中添加或删除特征——也就是说,总是根据交叉验证分数选择最佳性能的选择,无论是添加还是丢弃。该技术依赖于学习算法及其目标函数。因此,其选择将始终在最佳可能的选择之中。由于它是一个贪婪过程,总是存在选择局部最优集的风险。
序列选择是一种非常有效的减少需要处理的特征数量的方法。然而,它相当耗时,因为算法必须在每一轮评估所有候选者。在正向过程中,随着你继续进行,这会变得越来越慢,因为尽管每一轮评估的候选者数量减少,但使用的特征数量增加会减慢训练速度。然而,在反向过程中,你开始较慢,并在丢弃一定数量的特征后倾向于加速。如果你从许多特征开始评估且训练非常缓慢,反向过程可能不切实际。
作为该过程的停止规则,你可以设置一定数量的特征,或者你可以让选择算法找出添加或删除特征不再对预测带来改进的点。容忍度阈值有助于给算法提供一定的自由度来决定是否继续:容忍度越大,算法在其操作中继续的可能性就越大,即使添加或删除特征在某种程度上降低了性能。
在列表 6.11 中,我们对在 Airbnb 纽约数据集上训练的 XGBoost 模型应用了正向选择。选择算法被设置为自由确定要添加的正确特征数量,并且低容忍度(在 0 到 1 的准确度度量上设置为 0.0001)应该在其预测性能开始下降的第一个迹象时停止。
列表 6.11 正向选择
from sklearn.feature_selection import SequentialFeatureSelector
from sklearn.metrics import accuracy_score, make_scorer
from XGBoost import XGBClassifier
xgb = XGBClassifier(booster='gbtree',
objective='reg:logistic',
n_estimators=300,
max_depth=4,
min_child_weight=3)
cv = KFold(5, shuffle=True, random_state=0) ①
accuracy = make_scorer(accuracy_score) ②
X = column_transform.fit_transform(data, target_median)
selector = SequentialFeatureSelector(
estimator=xgb,
n_features_to_select="auto",
tol=0.0001, ③
direction="forward", ④
scoring=accuracy,
cv=cv
)
selector.fit(X, target_median)
selected_features = selector.support_ ⑤
selected_data = column_transform.get_feature_names_out()[selected_features]
print(selected_data)
① 使用五个折点初始化一个 KFold 交叉验证分割对象
② 为特征选择过程创建一个评分函数
③ 设置序列特征选择器用于在搜索过程中确定收敛的容忍度值
④ 指定特征选择的方向(在本例中为“正向”)
⑤ 获取所选特征的布尔掩码
获得的结果指出需要使用六个特征:三个二元特征,一个高基数分类特征,以及两个数值特征:
['low_card_categories__neighbourhood_group_Bronx'
'low_card_categories__room_type_Entire home/apt'
'low_card_categories__room_type_Shared room'
'high_card_categories__neighbourhood' 'numeric__minimum_nights'
'numeric__number_of_reviews' 'numeric__reviews_per_month'
'numeric__calculated_host_listings_count' 'numeric__availability_365']
我们可以通过运行以下命令以反向方式复制实验:
selector = SequentialFeatureSelector(
estimator=xgb,
n_features_to_select="auto",
tol=0.0001,
direction="backward", ①
scoring=accuracy,
cv=cv
)
selector.fit(X, target_median)
selected_features = selector.support_
selected_data = column_transform.get_feature_names_out()[selected_features]
print(selected_data)
① 指定特征选择的方向(在这种情况下是“向后”)
结果选择由九个特征组成,其中许多已经在正向选择的结果集中出现过:
['low_card_categories__neighbourhood_group_Bronx'
'low_card_categories__neighbourhood_group_Manhattan'
'low_card_categories__neighbourhood_group_Queens'
'low_card_categories__neighbourhood_group_Staten Island'
'low_card_categories__room_type_Entire home/apt'
'low_card_categories__room_type_Shared room'
'high_card_categories__neighbourhood' 'numeric__minimum_nights'
'numeric__number_of_reviews' 'numeric__reviews_per_month'
'numeric__calculated_host_listings_count' 'numeric__availability_365']
根据我们自己的经验,选择正向或反向选择取决于你可能需要承担的风险,即从所选集中遗漏一些稍微重要的特征。使用正向添加,你可以确保只保留基本特征,但风险遗漏一些边际相关的特征。使用反向消除,你可以确保所有关键特征都在集合中,允许一些冗余。
除了选择正向或反向过程之外,顺序选择可以帮助你在训练和预测中更快地构建模型,并且由于涉及的特征数量有限,它将更容易解释和维护。
6.3 优化超参数
特征工程可以提高你从经典机器学习模型中获得的结果。创建新的特征可以揭示数据中模型由于局限性而无法把握的潜在模式和关系。通过移除对于问题无用的和冗余的特征,特征选择可以提高你的模型结果,从而减少数据中的噪声和虚假信号。最后,通过优化超参数,你可以获得另一个性能提升,并让你的经典机器学习模型在处理表格数据问题时更加出色。
如第四章所述,超参数是所有机器学习算法幕后工作的设置,决定了它们可以如何具体工作。从抽象的角度来看,每个机器学习算法可能提供有限的、但仍然很宽的范围的功能形式——即你可以用数学方式将预测变量与结果相关联的方式。直接从盒子里出来,机器学习算法可能更少或更多符合你特定机器学习问题所需的功能形式。
例如,如果你正在使用梯度提升算法来解决分类问题,可能默认的迭代次数或其树的生长方式并不符合问题的要求。你可能需要比默认值更少或更多的迭代和树生长。通过恰当地设置其超参数,你可以找到与你的问题更好地配合的最佳设置。
然而,这不仅仅是调整算法提供的所有许多旋钮直到得到你期望的结果的问题。有时旋钮太多,无法一起测试,即使你设法测试了足够多的旋钮,如果操作不当,可能会导致数据过拟合,并且相反,得到更差的结果。在定义一个或多个评估指标之后,你需要一个系统性的方法:
-
定义一个包含你想要探索的超参数及其要测试的值边界的搜索空间
-
建立一个适当的交叉验证方案,以确保你发现的是一个可以推广到你所拥有数据的解决方案
-
选择一个搜索算法,通过适当的策略,可以在更短的时间内以更低的成本(例如,从计算的角度来看)找到你需要的解决方案
在以下小节中,根据不同的搜索策略,我们讨论了如何调整我们迄今为止所看到的某些经典机器学习算法的方法。
6.3.1 系统性搜索
网格搜索通过所有超参数值的可能组合。对于你想要测试的每个超参数,你选择一个值序列,并彻底迭代它们的所有组合。最后,你选择返回最佳结果的组合。
在列表 6.12 中,我们将其应用于逻辑回归模型,帮助选择正则化的类型以及 L1 和 L2 正则化值的设置。代码中最重要的一部分是搜索网格,它是一个包含一个或多个字典的列表。每个字典是一个搜索空间,一个与值生成器列表关联的超参数序列(字典的键),这些值是你想要测试的可能值(字典的值)。在所有优化方法中,结构一个或多个搜索空间是一种常见的做法,无论它们是否来自 Scikit-learn。只需注意超参数的名称是如何以model__name_of_the_hyperparameter的形式制定的,因为我们正在优化一个管道,并解决管道内部和模型参数。我们将在下一小节中对此进行更多解释。
列表 6.12 网格搜索
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
from sklearn.model_selection import KFold, GridSearchCV
from sklearn.metrics import make_scorer
accuracy = make_scorer(accuracy_score)
cv = KFold(5, shuffle=True, random_state=0)
model = LogisticRegression(solver="saga", max_iter=5_000)
model_pipeline = Pipeline(
[('processing', lm_column_transform),
('model', model)])
search_grid = [
{"model__penalty": [None]},
{"model__penalty": ["l1", "l2"], "model__C": np.logspace(-4, 4, 10)},
{"model__penalty": ["elasticnet"], "model__C": np.logspace(-4, 4, 10),
"model__l1_ratio": [.1, .3, .5, .7, .9, .95, .99]},
] ①
search_func = GridSearchCV(estimator=model_pipeline, ②
param_grid=search_grid,
scoring=accuracy,
n_jobs=-1,
cv=cv)
search_func.fit(X=data, y=target_median)
print (search_func.best_params_) ③
print (search_func.best_score_) ④
① 一个字典列表,指定逻辑回归模型的超参数搜索网格
② 使用定义的搜索网格初始化一个 GridSearchCV 对象
③ 打印网格搜索找到的最佳超参数
④ 打印模型在网格搜索过程中找到的最佳超参数所达到的最佳得分
测试所有组合后,网格搜索过程返回最佳的超参数组合就是根本不使用任何惩罚。它返回支持其报告的最佳交叉验证得分:
{'model__penalty': None}
0.8210860006135597
当你的超参数很少时,网格搜索是有效的;它们取离散值,并且你可以并行化内存中的测试操作,因为你的数据集不是太大。
首先,组合越多,你必须进行的测试就越多,你需要花费更长的时间和更多的计算。如果你需要测试许多超参数,并且怀疑其中一些对正确调整你的算法无关紧要,这可能会成为一个严重的问题。当你将一个超参数添加到网格搜索中时,你必须让所有其他超参数通过它循环,这可能会在测试的超参数无关紧要时变成浪费能量。
此外,如果一个参数取连续值,你必须决定如何将其连续的搜索空间转换为离散的。通常,这是通过将值的连续体均匀地划分为离散值来完成的,但这样做而没有了解算法相对于该超参数及其值的行为,其值可能再次变成在测试值上浪费多次计算,而这些测试值无法提高算法性能。
需要考虑的最后一个方面是使用多个核心并并行化它们的操作。网格搜索完全不知道每个测试的结果。结果只有在最后才会排名,你只能得到最佳结果。因此,如果你的算法自然地工作在单个核心上,网格搜索是可行的。然而,如果你的算法使用多个线程和核心,例如随机森林或 XGBoost,那么你必须在算法以全速运行和优化过程并行化以加快速度之间进行权衡。通常,最佳选择是使用并行运行来推动算法更快地运行。无论你决定利用算法的并行化能力还是搜索过程的并行化能力,当与多核算法一起工作时,网格搜索都不是性能最佳的选择。
根据我们的经验和网格搜索策略的局限性,我们认为它最适合测试线性模型,因为它们易于并行化,并且参数有限,通常以布尔值或离散值的形式出现。
6.3.2 使用随机试验
使用网格搜索时的重要限制包括:
-
你需要将连续的超参数离散化。
-
如果一个超参数与问题无关,你将浪费很多试验,因为它们测试了无关特征的搜索空间。
由于这些原因,随机采样搜索空间的想法在机器学习社区中根深蒂固。正如 James Bergstra 和 Yoshua Bengio 在论文“随机搜索用于超参数优化”中所述(*《机器学习研究杂志》;mng.bz/nRg8),当有多个超参数且你不知道它们如何影响结果或如何协同工作时,随机搜索优化成为标准优化方法。
在我们的例子中,我们使用 XGBoost 分类器重新审视我们的分类问题。XGBoost 与其他梯度提升实现一样,具有几个可以被认为是重要的超参数,您应该尝试测试它们以检查您的模型性能是否可以改进。在示例中,我们还使事情变得更加复杂,因为我们通过将 XGBoost 模型包装到管道中来操作,因此需要特定的方式来处理超参数。由于管道中的每个元素都有一个名称,您必须通过管道中元素的名称、两个下划线和超参数的名称来指定管道中的每个参数。例如,在我们的例子中,XGBoost 位于名为“xgb”的管道部分中。要指定 XGBoost 的n_estimators超参数,只需在搜索空间中使用标签xgb__n_estimators即可。这个想法是展示如何在不测试所有可能影响模型预测性能的选择的情况下优化模型及其管道。
列表 6.13 随机搜索
from sklearn.utils.fixes import loguniform
from sklearn.model_selection import KFold, RandomizedSearchCV
from sklearn.metrics import accuracy_score
from sklearn.metrics import make_scorer
from XGBoost import XGBClassifier
accuracy = make_scorer(accuracy_score)
cv = KFold(5, shuffle=True, random_state=0)
xgb = XGBClassifier(booster='gbtree', objective='reg:logistic')
model_pipeline = Pipeline(
[('processing', column_transform), ('xgb', xgb)]
) ①
search_dict = { ②
'xgb__n_estimators': np.arange(100, 2000, 100),
'xgb__learning_rate': loguniform(0.01, 1),
'xgb__max_depth': np.arange(1, 8),
'xgb__subsample': np.arange(0.1, 0.9, 0.05),
'xgb__colsample_bytree': np.arange(0.1, 0.9, 0.05),
'xgb__reg_lambda': loguniform(1e-9, 100),
'xgb__reg_alpha': loguniform(1e-9, 100)
}
search_func = RandomizedSearchCV(estimator=model_pipeline,
param_distributions=search_dict,
n_iter=60, ③
scoring=accuracy,
n_jobs=1, ④
cv=cv,
random_state=0)
search_func.fit(X=data, y=target_median)
print (search_func.best_params_) ⑤
print (search_func.best_score_) ⑥
① 创建一个结合数据预处理和 XGBoost 分类器的管道
② 包含各种超参数及其搜索空间的字典,用于 RandomizedSearchCV
③ 指定随机搜索过程的迭代次数
④ 指定用于搜索的并行作业数量
⑤ 打印由 RandomizedSearchCV 找到的最佳超参数
⑥ 打印在随机搜索过程中使用最佳超参数获得的最佳分数
过了一段时间(在 Google Colab 实例中代码运行大约需要一小时),我们得到了最佳参数集和通过交叉验证得到的分数:
{'xgb__colsample_bytree': 0.3500000000000001,
'xgb__learning_rate': 0.020045491299569684,
'xgb__max_depth': 6,
'xgb__n_estimators': 1800,
'xgb__reg_alpha': 3.437821898520205e-08,
'xgb__reg_lambda': 0.021708909914764426,
'xgb__subsample': 0.1}
0.8399836384088353
尽管随机搜索优化简单且依赖于随机性,但它确实有效,并且在许多情况下提供了最佳的优化。许多 AutoML 系统在需要调整许多超参数时依赖于这种优化策略(例如,参见 D. Golovinb 等人于 2017 年发表的“Google Vizier:一个黑盒优化服务”,mng.bz/8OrZ)。与网格搜索相比,当您有一组有限的超参数预期会产生影响,以及一组有限的值要测试时,随机搜索在您有太多值要调整且没有先验知识了解它们如何工作时效果最佳。
你所需要做的就是依靠足够的随机测试,以便出现良好的组合,这可能不会花费很长时间。根据我们的经验,30 到 60 次随机抽取通常足以进行良好的优化。随机搜索优化的一个优点是它适用于复杂问题,并且不受无关超参数的影响。相关参数的数量决定了你可以多快找到一个好的解决方案。该算法也适用于在不同计算机或实例上的并行搜索(你从所有结果中选择最佳结果)。然而,这个积极点也有局限性,因为测试是独立的,它们不会相互告知结果。
6.3.3 减少计算负担
网格搜索和随机搜索都不利用先前实验的结果。网格搜索严格遵循预定义的程序,而随机搜索进行一系列独立的测试。在这两种情况下,先前结果在搜索过程中都没有被考虑或使用。连续减半,这两种策略的包装器,可以利用知道先前结果的优势。想法就像一个锦标赛,你首先进行多轮比赛,投入少量资源来测试不同的超参数值。然后,随着你前进并淘汰表现不佳的值,你将更多资源投入到剩余值的彻底测试中。通常,你最初稀释然后后来集中的资源是训练样本的数量。更多的例子意味着来自超参数测试的某些结果,但这需要更多的计算能力。
在 Scikit-learn 中作为HalvingGridSearchCV和HalvingRandomSearchCV提供,在列表 6.14 中,我们测试了随机搜索变体,以验证我们是否可以在一小部分时间内获得类似的优化结果。如前所述,我们使用样本数量作为稀缺资源进行优化,仅使用初始的 30%。此外,我们指示算法从 20 个初始候选者开始,并在每一轮中将候选者数量减少到原来的三分之一(从 20 减少到 6,再减少到 2)。
列表 6.14 减半随机搜索
from sklearn.experimental import (
enable_halving_search_cv
) ①
from sklearn.model_selection import HalvingRandomSearchCV
search_func = HalvingRandomSearchCV(
estimator=model_pipeline,
param_distributions=search_dict,
resource='n_samples', ②
n_candidates=20, ③
factor=3, ④
min_resources=int(len(data) * 0.3), ⑤
max_resources=len(data), ⑥
scoring=accuracy,
n_jobs=1,
cv=cv,
random_state=0
)
search_func.fit(X=data, y=target_median)
print (search_func.best_params_)
print (search_func.best_score_)
① 启用实验性的 HalvingRandomSearchCV 模块
② 指定用于减半的资源是样本数量
③ 设置在第一次迭代中将采样和评估的候选者数量
④ 确定每次迭代中候选者数量减少的因子
⑤ 设置在减半过程中将使用的最小资源(样本)数量
⑥ 设置在减半过程中将使用的最大资源(样本)数量
以下是在所需时间的一小部分内获得的结果(在 Google Colab 中,该过程大约需要 10 分钟):
{'xgb__colsample_bytree': 0.6500000000000001,
'xgb__learning_rate': 0.02714215181104359,
'xgb__max_depth': 7,
'xgb__n_estimators': 400,
'xgb__reg_alpha': 3.281921389446602,
'xgb__reg_lambda': 0.00039687940902191534,
'xgb__subsample': 0.8000000000000002}
0.8398409090909091
在我们使用这种优化策略的经验中,策略是设置初始轮次,使其能够捕捉到一些好的超参数。因此,拥有尽可能多的候选者以最低的资源运行是非常重要的,尽管这不会太低以至于影响优化的结果。如果减少因子参数,那么至少 1,000 个起始样本应该足够好。这决定了每个后续迭代选择的候选者比例从三个变为两个,从而进行更多的轮次。
6.3.4 使用贝叶斯方法扩展搜索
另一种做出明智选择的优化策略是贝叶斯优化。这一策略由 Snoek、Larochelle 和 Adams 在论文“Practical Bayesian Optimization of Machine Learning Algorithms”(arxiv.org/abs/1206.2944)中提出,其背后的理念是通过构建模型来理解模型超参数的工作方式。该算法通过优化一个代理函数,即代理函数,来提高算法的性能。当然,代理函数会根据优化中的机器学习模型的目标函数的反馈进行更新。然而,贝叶斯优化算法的决策完全基于代理函数。
尤其是另一种交替探索与利用的策略:获取函数。获取函数根据代理函数报告了探索特定参数组合的潜力有多大以及不确定性有多大。探索意味着尝试从未尝试过的参数组合,这发生在存在很多不确定性和因此希望至少尝试改进代理函数的搜索空间区域时。相反,当获取函数确保算法在尝试特定一组超参数时可以提高性能时,就会发生利用。
正如名字中的“贝叶斯”所暗示的,以及我们对贝叶斯优化内部工作原理的简要描述,这个过程受到先验期望的影响,并在微调周期中通过后验观察进行修正。在这个过程中,代理函数不过是我们模型的一个模型。通常,高斯过程被选为代理函数的模型。尽管如此,还有其他替代方案,例如使用随机森林或树结构 Parzen 估计器等树算法,这些是多变量分布,能够描述我们模型中超参数的行为。Scikit-optimize (scikit-optimize.github.io/stable/) 或 KerasTuner (keras.io/keras_tuner/) 等包使用高斯过程,Scikit-optimize 还能够在使用树集成的同时,KerasTuner 则使用多臂老虎机。Optuna,由日本人工智能研发公司 Preferred Networks 开发的优化框架,则使用树结构 Parzen 估计器。Optuna 最初于 2019 年 5 月作为一个开源项目发布,由于其简单性、多功能性和与 TensorFlow、PyTorch 和 Scikit-learn 等流行机器学习库的集成,在 Python 机器学习社区中特别受欢迎。
在我们的例子中,我们使用 Optuna 来改进我们的 XGBoost 分类器。当使用 Optuna 时,你只需设置一个研究并为其提供运行参数,例如试验次数n_trials以及如果你想要最小化或最大化目标函数的方向参数。在幕后,所有繁重的工作都是由目标函数完成的,你定义的目标函数返回一个评估结果。目标函数期望只有一个输入参数,即试验,这是 Optuna 提供的。通过试验参数,你定义要测试的超参数的值。然后,你只需按照你的喜好进行测试,因为是否应用交叉验证、对样本进行简单测试或其他任何事情,完全取决于你在目标函数内部的决定。这种灵活性还允许你运行复杂的优化,其中某些超参数被使用或依赖于其他超参数及其值。你需要编写你想要的程序代码。
列表 6.15 使用 Optuna 进行贝叶斯搜索
import optuna
from XGBoost import XGBClassifier
from sklearn.model_selection import cross_validate
def objective(trial):
params = {
'n_estimators': trial.suggest_int('n_estimators', 100, 2000),
'learning_rate': trial.suggest_float(
'learning_rate', 0.01, 1.0, log=True
),
'subsample': trial.suggest_float('subsample', 0.1, 1.0),
'colsample_bytree': trial.suggest_float(
'colsample_bytree', 0.1, 1.0
),
'max_depth': trial.suggest_int('max_depth', 1, 7),
'min_child_weight': trial.suggest_int('min_child_weight', 1, 7),
'reg_lambda': trial.suggest_float(
'reg_lambda', 1e-9, 100.0, log=True
),
'reg_alpha': trial.suggest_float(
'reg_alpha', 1e-9, 100.0, log=True
),
} ①
xgb = XGBClassifier(
booster='gbtree',
objective='reg:logistic',
**params
) ②
model_pipeline = Pipeline(
[('processing', column_transform), ('xgb', xgb)]
)
accuracy = make_scorer(accuracy_score)
cv = KFold(5, shuffle=True, random_state=0)
cv_scores = cross_validate(estimator=model_pipeline,
X=data,
y=target_median,
scoring=accuracy,
cv=cv) ③
cv_accuracy = np.mean(cv_scores['test_score'])
return cv_accuracy ④
study = optuna.create_study(direction="maximize") ⑤
study.optimize(objective, n_trials=60) ⑥
print(study.best_value) ⑦
print(study.best_params) ⑧
① 定义 Optuna 超参数搜索空间的字典
② 创建一个由 Optuna 建议超参数的 XGBoost 分类器
③ 使用超参数执行交叉验证以评估模型的性能
④ 一个作为优化目标值的函数,通过返回交叉验证的平均准确率得分
⑤ 创建一个 Optuna 研究对象,目标是最大化目标函数
⑥ 使用定义的目标函数和最多 60 次试验启动优化过程
⑦ 打印目标函数的最佳实现值
⑧ 打印 Optuna 找到的最佳超参数
在 Google Colab 实例上,这个过程可能需要长达两小时,但就超参数优化而言,结果无疑是同类中你能获得的最佳:
{'n_estimators': 1434,
'learning_rate': 0.013268588739778429,
'subsample': 0.782534239551612,
'colsample_bytree': 0.9427647573058971,
'max_depth': 7,
'min_child_weight': 2,
'reg_lambda': 2.3123673571345327e-06,
'reg_alpha': 1.8176941971395193e-05}
0.8419879333265161
作为 Optuna 提供的额外功能,通过在之前的代码中添加几个简单的修改,你可以将你的研究存储在项目数据库中,并在任何时间重新启动优化。如果在创建研究时声明了研究的名称和目标数据库,Optuna 可以将其优化过程与 SQLite 集成:
sqlite_db = "sqlite:///sqlite.db" ①
study_name = "optimize_XGBoost" ②
study = optuna.create_study(storage=sqlite_db, study_name=study_name,
direction="maximize", load_if_exists=True) ③
study.optimize(objective, n_trials=60)
print(study.best_params)
print(study.best_value)
① 定义 Optuna 将存储研究相关信息的 SQLite 数据库的路径
② 为 Optuna 研究提供一个名称
③ 创建一个 Optuna 研究对象并将其连接到 SQLite 数据库
关于 SQLite 存储数据库的指定,sqlite:// 是一个统一资源标识符(URI)方案,用于指定连接到 SQLite 数据库的协议或机制。在 URI 方案中,sqlite:// 表示数据库连接将通过 SQLite 数据库引擎建立。当使用此 URI 方案时,sqlite://+ 部分后面跟着 SQLite 数据库文件的路径。在你的例子中,sqlite:///sqlite.db 指定 SQLite 数据库文件名为 sqlite.db,并且位于当前目录。sqlite: 后面的三个斜杠(///)是可选的,表示路径是相对于当前目录的。
一旦研究完成,你还可以获得有关迭代结果的实用可视化,并在后续运行相同的搜索中获得有价值的见解。例如,你可以探索优化历史记录,检查你是否已经达到了优化的平台期,或者继续进行更多迭代是否可取:
fig = optuna.visualization.plot_optimization_history(study)
fig.show()
图 6.6 展示了我们的优化过程。经过几次迭代后,优化达到了一个良好的结果,但随后在剩余的可用迭代中进展缓慢。在这种情况下,进一步优化进展的可能性很小,因为此时的任何收益都将微乎其微。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/ml-tbl-dt/img/CH06_F06_Ryan2.png
图 6.6 展示了优化结果的历史记录
另一个有用的图表描绘了超参数如何决定最终的优化设置:
fig = optuna.visualization.plot_param_importances(study)
fig.show()
图 6.7 展示了我们优化 XGBoost 算法估计的重要性。结果显示,max_depth 超参数占据主导地位,同时某种程度上也受到子样本值的影响。这样的结果表明,算法对树的深度很敏感,并且增加深度会显著影响优化结果。这可能表明数据中包含复杂的模式,需要更深的树来捕捉,而优化中找到的七这个最佳点标志着算法开始过拟合的点。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/ml-tbl-dt/img/CH06_F07_Ryan2.png
图 6.7 Optuna 优化过程中超参数估计重要性的图表
理解为什么你的 XGBoost(或 LightGBM)在特定条件下表现更好,这因问题而异。然而,能够理解原因,向他人(如利益相关者)解释原因,并采取措施调整你的数据或优化设置,确实是 Optuna 相较于其他优化方法提供的一项宝贵功能。
在完成优化技术的全景图示后,我们面临的情况是,你可能不想设置任何复杂的东西来让你的机器学习算法工作,但你又需要一些指导,了解如何通过试错快速调整。
6.3.5 手动设置超参数
尽管之前描述的优化策略效率很高,但你可能不会惊讶地读到我们知道许多从业者仍然通过直觉和试错来调整他们模型的设置。这种程序在实验阶段似乎特别有根据,当你试图让一切合理工作,寻找改进解决方案的各种迭代方法时。因此,彻底的优化是在处理和实验迭代完成后留下的。
本书附录提供了对迄今为止涵盖的机器学习算法关键参数的全面指南。我们首先从线性模型开始,如线性或逻辑回归,由于参数数量有限且离散化容易,它们可以通过网格搜索有效地调整。一个表格涵盖了随机森林和极端随机树,因为它们具有相似的超参数,都是基于相同的自助集成方法。
关于 GBDTs,根据具体实现,我们有不同的超参数集。为了您的方便,我们选择了最关键的几个。您可以根据建议的范围手动或自动优化它们。指南从 HistGradientBoosting 开始,然后涵盖 XGBoost 和 LightGBM。重要的是要注意,XGBoost 有一组更大的相关超参数(你可以在mng.bz/6e7e找到完整列表)。最后,我们还包括了 LightGBM 的超参数列表,它与 XGBoost 略有不同(你可以在mng.bz/vK8q找到完整列表)。这份全面的指南将帮助您有效地调整机器学习算法,并根据特定的超参数设置优化它们的性能。
至于手动调整 GBDT,模型通常在默认设置下表现最差,因此你应该了解一些行业技巧。让我们从 Jerome Friedman 于 1999 年发表的一篇题为“贪婪函数逼近:梯度提升机”的论文开始。在这篇论文中,Friedman 讨论了树的数量和学习率之间的权衡。观察到较低的学习率往往会导致更高的最优树的数量。此外,当增加模型中决策树的最大深度时,建议降低学习率。这种预防措施是因为更深的树引入了更多的复杂性,可能导致过拟合。过拟合发生在模型过度定制于训练数据,在未见过的数据上表现不佳时。通过同时降低学习率,可以减轻这种风险。这是因为较低的学习率意味着模型更新更小、更谨慎。这种渐进的学习过程允许进行更精细的调整,帮助模型在捕捉复杂关系和避免过拟合之间取得更好的平衡。
另一个关于在 GBDT 中手动调整参数的宝贵资源是 Owen Zhang 在 2015 年向纽约市数据科学学院发表的题为“赢得数据科学竞赛”的演讲。Owen 之前是 Kaggle 的顶尖竞争者,提供了一些有趣的建议:
-
根据数据集大小(通常在 100 到 1,000 之间)决定使用的树的数量,并在优化过程中保持固定。更倾向于使用较少的树而不是更多的树。
-
在 2 到 10 之间除以树的数量范围内测试学习率。因此,对于 1,000 棵树,测试学习率在 0.002 到 0.01 的区间内。
-
在 0.5、0.75、1.0 的值上测试行采样。
-
在 0.4、0.6、0.8、1.0 的值上测试列采样。
-
在 4、6、8、10 的值上测试最大树深度。
-
将最小叶子权重/计数调整为相对于预测的最稀疏类百分比的平方根的 3 倍的大约比例。因此,如果需要预测的类在数据中的覆盖率为 10%,则应将最小叶子权重/计数设置为约 9。这个数字是通过将 3 除以 0.1 的平方根(因为 10%的覆盖率为小数 0.1)计算得出的。
在总结部分,我们继续探讨一些想法和技巧,以在解决表格数据问题时更好地掌握 GBDT。
6.4 精通梯度提升
在讨论了梯度提升的工作原理及其实现之后,我们以关于如何最佳使用梯度提升、理解其内部工作原理以及加快训练和预测速度的建议来结束本章。
6.4.1 在 XGBoost 和 LightGBM 之间做出选择
当考虑使用梯度提升来解决您的数据问题时,XGBoost 和 LightGBM(以及 HistGradientBoosting)是直方图梯度提升机的最流行和性能最高的实现之一。尽管它们如此强大,但根据我们的经验,您永远不能事先选择 XGBoost 或 LightGBM,或者一般地偏好 GBDT 相对于其他经典或深度学习解决方案,因为机器学习中的“没有免费午餐定理”:没有一种通用的学习算法对所有可能的问题都表现最佳。因此,声称“XGBoost 是您需要的所有东西”对于表格数据问题来说确实是一个吸引人的说法,但它可能并不总是适合您特定的数据问题或情况。GBDT 通常倾向于在表格数据问题上优于其他解决方案。因此,从它们开始,但不仅限于它们,是一个不错的选择。回到具体的实现,虽然始终建议在您的数据上测试任何算法并做出自己的决定,但在决定是否首先尝试一个实现而不是另一个时,还有一些其他标准要考虑。我们已经根据我们的经验验证了它们。它们总结在表 6.1 中。
表 6.1 使用 GBDT 时考虑的标准
| 类型 | 描述 |
|---|---|
| 数据量 | XGBoost 对所有表格问题都表现良好;由于 LightGBM 的叶状分割方法可以创建更深的树,因此在使用较小的数据集时,它更容易过拟合。 |
| 可扩展性 | XGBoost 的可扩展性和 GPU 就绪性更强;LightGBM 面临更多挑战。 |
| 实验速度 | 在 CPU 上,LightGBM 无疑比 XGBoost 快。 |
大量数据的可用性是首先要考虑的标准。LightGBM 使用叶状(垂直)增长,这可能导致过拟合。对可用数据进行过拟合的趋势很好地解释了该算法在 Kaggle 竞赛中的成功。因此,当您有大量可用数据时,LightGBM 表现得更好。相比之下,XGBoost 在较小的数据样本上构建的模型比 LightGBM 更稳健。
另一个要考虑的标准是您是否有权访问多个 GPU 和强大的 CPU,或者只能有限地访问计算资源。如果您有大量资源,XGBoost 的可扩展性更强,使其成为在机构或商业环境中使用的更好选择。然而,如果您更倾向于专注于实验和特征工程,并且无法访问 GPU,那么由于 LightGBM 的训练时间更快,它更有意义。您可以使用节省下来的训练时间来提高最终模型的稳健性。如果您资源有限,例如只有一台独立的计算机,您应该考虑 XGBoost 的训练时间会随着样本大小的增加而线性增加,而 LightGBM 所需的训练时间则小得多。
6.4.2 探索树结构
如前所述,GBDTs 是复杂的算法,并非无法解释或无法复制的。你只需要以更高效的方式重现它们所组成的各种决策树,并将它们结合起来以获得快速的预测。XGboost 和 LightGBM 都允许探索和提取它们的模型结构。在列表 6.16 中,我们采取了一些步骤来展示这一点。在将 XGBoost 简单解决方案导出到 JSON 文件后,我们使用深度优先搜索策略在其结构内部导航,就像在图中一样。在深度优先搜索中,算法在回溯之前尽可能深入地探索每个分支。
仔细查看列表 6.16 中的代码,你可以在traverse_xgb_tree函数中注意到,代码通过首先遍历左子树(tree['children'][0])然后遍历右子树(tree['children'][1])来递归地探索树。这从递归调用traverse_xgb_tree(tree['children'][0])和traverse_xgb_tree(tree['children'][1])中可以明显看出。
列表 6.16 提取 XGBoost 树结构
import json
import matplotlib.pyplot as plt
from XGBoost import XGBClassifier, plot_tree
from collections import namedtuple
xgb = XGBClassifier(booster='gbtree',
objective='reg:logistic',
n_estimators=10,
max_depth=3) ①
model_pipeline = Pipeline(
[('processing', column_transform),
('XGBoost', xgb)]) ②
model_pipeline.fit(X=data, y=target_median)
model = model_pipeline["XGBoost"]
tree_info = model.get_booster().dump_model(
"xgb_model.json",
with_stats=True,
dump_format="json"
) ③
fig, ax = plt.subplots(figsize=(12, 15), dpi=300)
ax = plot_tree(
model, num_trees=0, ax=ax, rankdir='LR
) ④
plt.show()
with open("xgb_model.json", "r") as f:
json_model = json.loads(f.read()) ⑤
print(f"Number of trees: {len(json_model)}")
tree_structure = json_model[0] ⑥
Split = namedtuple("SplitNode", "feature origin gain count threshold")
Leaf = namedtuple("LeafNode", "index origin count")
def extract_xgb_node_info(tree):
return [tree['split'], tree['origin'], tree['gain'],
tree['cover'], tree['split_condition']] ⑦
def extract_xgb_leaf_info(tree):
return (
[tree['nodeid'],
tree['origin'],
tree['cover']
]
) ⑧
def traverse_xgb_tree(tree): ⑨
if not 'origin' in tree:
tree['origin'] = "="
if not 'children' in tree:
return [[Leaf(*extract_xgb_leaf_info(tree))]]
left_branch = tree['children'][0]
right_branch = tree['children'][1]
left_branch['origin'] = '<'
right_branch['origin'] = '>='
left_paths = traverse_xgb_tree(left_branch)
right_paths = traverse_xgb_tree(right_branch)
node_info = [Split(*extract_xgb_node_info(tree))]
return [node_info + path for path in left_paths + right_paths]
paths = traverse_xgb_tree(tree_structure)
print(f"Number of paths on tree: {len(paths)}")
print("Path 0:", paths[0])
① 创建一个限制为 10 个估计量和三个级别的树的 XGBoost 分类器
② 从管道中提取 XGBoost 模型
③ 将 XGBoost 模型的信息(增强器)导出到一个 JSON 文件中
④ 创建集成中第一棵树的图表
⑤ 从磁盘检索包含模型信息的 JSON 结构
⑥ 打印模型中的树的数量并提取第一棵树的结构
⑦ 函数从树结构中的分割节点提取各种信息
⑧ 函数从树结构中的叶节点提取信息
⑨ 函数递归遍历树结构以提取路径
该代码训练一个 XGBoost 模型,保存其树结构,将结构处理成可读的方式,并将结果呈现给用户:
Number of trees: 10
Number of paths on tree: 8
Path 0: [SplitNode(
feature='f5',
origin='=',
gain=19998.9316,
count=12223.75,
threshold=0.5),
SplitNode(
feature='f2',
origin='<',
gain=965.524414,
count=5871.5,
threshold=0.5
),
SplitNode(
feature='f13',
origin='<',
gain=66.1962891,
count=3756,
threshold=1.88965869
),
LeafNode(
index=7,
origin='<',
count=3528)
]
图 6.8 将获得的输出与 XGBoost 包本身提供的完整树的图形表示进行了比较。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/ml-tbl-dt/img/CH06_F08_Ryan2.png
图 6.8 XGBoost 的plot_tree输出
在模型中构建的 10 棵树中,代码展示了第一棵树,并在从样本到预测叶节点的 8 条不同路径中,表示了第一条路径。从视觉上看,这条路径是最左侧的。路径由一系列不同的节点组成。代码报告了使用的特征名称、从上一个节点(在 XGBoost 中,小分支总是代表左分支,而大分支等于右分支)的分割分支起源、分割阈值、相对于目标函数的增益以及根据数据集的分割导致的样本减少。所有这些信息都允许你完美地复制 XGBoost 模型中每棵树的结果。
我们也可以从 LightGBM 中提取相同的树结构,尽管方法略有不同,因为 LightGBM 包遵循一些略微不同的约定。例如,XGBoost 总是先在左边的阈值上分割;而 LightGBM 则相反,对于每个节点,使用减号或大于等于和阈值定义一个规则,如果规则为真则在左边分割,如果为假则在右边分割。
列表 6.17 提取 LightGBM 树结构
from lightgbm import LGBMClassifier, plot_tree
lgbm = LGBMClassifier(boosting_type='gbdt',
n_estimators=10,
max_depth=3)
model_pipeline = Pipeline(
[('processing', column_transform),
('lightgbm', lgbm)])
model_pipeline.fit(X=data, y=target_median)
model = model_pipeline["lightgbm"]
tree_info = model._Booster.dump_model()["tree_info"] ①
tree_structure = tree_info[0]['tree_structure'] ②
plot_tree(
booster=model._Booster,
tree_index=0,
dpi=600
) ③
Split = namedtuple(
"SplitNode",
"feature origin decision_type threshold gain count"
)
Leaf = namedtuple("LeafNode", "index origin count value")
def extract_lgbm_node_info(tree): ④
return [tree['split_feature'], tree['origin'], tree['decision_type'],
tree['threshold'], tree['split_gain'], tree['internal_count']]
def extract_lgbm_leaf_info(tree): ⑤
return [
tree['leaf_index'],
tree['origin'],
tree['leaf_count'],
tree['leaf_value']
]
def traverse_lgbm_tree(tree): ⑥
if not 'origin' in tree:
tree['origin'] = ""
if not 'left_child' in tree and not 'right_child' in tree:
return [[Leaf(*extract_lgbm_leaf_info(tree))]]
left_branch = tree['left_child']
right_branch = tree['right_child']
left_branch['origin'] = 'yes'
right_branch['origin'] = 'no'
left_paths = traverse_lgbm_tree(left_branch)
right_paths = traverse_lgbm_tree(right_branch)
node_info = [Split(*extract_lgbm_node_info(tree))]
return [node_info + path for path in left_paths + right_paths]
paths = traverse_lgbm_tree(tree_structure)
print(paths[0])
① 从 LightGBM 模型增强器中提取树信息
② 从树信息中提取第一个树的结构
③ 使用 plot_tree 函数绘制集成中的第一个树
④ 从 LightGBM 树结构中的分割节点提取各种信息的函数
⑤ 从 LightGBM 树结构中的叶节点提取信息的函数
⑥ 递归遍历 LightGBM 树结构以提取路径的函数
本探索报告的结果报告了从集成中第一个决策树的结构:
[SplitNode(
feature=5,
origin='',
decision_type='<=',
threshold=1.0000000180025095e-35,
gain=20002.19921875,
count=48895),
SplitNode(
feature=2,
origin='yes',
decision_type='<=',
threshold=1.0000000180025095e-35,
gain=967.0560302734375,
count=23486),
SplitNode(
feature=13,
origin='yes',
decision_type='<=',
threshold=1.8896587976897459,
gain=67.53350067138672,
count=15024),
LeafNode(
index=0,
origin='yes',
count=14112,
value=-0.16892421857257725)
]
图 6.9 显示了plot_tree函数绘制的整个树,这次是从 LightGBM 包中。
https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/ml-tbl-dt/img/CH06_F09_Ryan2.png
图 6.9 LightGBM 的plot_tree输出
树从左到右水平绘制。我们可以检查代码返回的路径是最高路径,以叶节点 0 结束。
6.4.3 通过 GBDT 和编译加速
当案例数量或可用特征很多时,即使是较快的 LightGBM 也可能需要很长时间来训练此类数据上的模型。在训练时,你可以通过减小参数subsample的值来减少处理的案例和特征,以限制每个决策树中涉及的案例数量,以及参数colsample_bytree来限制在树分割时考虑的特征数量,从而克服长时间的等待。然而,减少案例或特征可能不是从你的模型中获得最佳结果的最佳选择。另一种选择是使用 GPU,因为它们广泛用于深度学习模型。GPU 可以加速训练操作,特别是与 XGBoost 一起,以及在 LightGBM 模型中,虽然程度较小但仍然显著。
使用 XGBoost,从建模的角度来看,使用你的 GPU 相当简单:你只需将tree_method参数的值指定为"gpu_hist"。然而,在新 2.0.0 版本中,这种方法已被弃用,用户现在可以通过device参数指定使用的设备。你可以将其设置为"cpu"以让 XGBoost 在 CPU 上执行,或者设置为device="cuda"以及device="gpu"以使其在 CUDA 支持的 GPU 上运行,目前这是唯一的选择,但将来将支持更多 GPU 类型。如果你有多个 GPU,你可以指定它们的序号来选择特定的一个;例如,device="cuda:1"将在你的第二个 GPU 设备上执行。
为了使 XGBoost 运行,您至少需要安装 CUDA 11.00 以及具有 5.0 计算能力的 GPU。如果您有更多的 GPU 可用,您可以通过gpu_id参数指定使用哪一个,该参数代表 CUDA 运行时报告的 GPU 设备序号(如果您只有一个 GPU,通常设置为 0)。这样,XGBoost 将决策树的生长移动到 GPU 内存和处理器中,从而获得相关的操作速度,特别是特征直方图,如 Mitchell 和 Frank 在论文“Using GPU Computing to Accelerate the XGBoost Algorithm”中所述(peerj.com/articles/cs-127/)。
一旦 GPU 训练了一个模型,它就可以在具有 GPU 的机器上进行预测。您只需设置predictor参数为gpu_predictor或如果您想使用 CPU,则设置为cpu_predictor。当您需要计算模型可解释性的 SHAP 值和 SHAP 交互值时,选择 GPU 作为预测器参数也可以加快速度:
model.set_param({"predictor": "gpu_predictor"})
shap_values = model.predict(X, pred_contribs=True)
shap_interaction_values = model.predict(X, pred_interactions=True)
虽然使用 GPU 与 XGBoost 一起使用很简单,但与 LightGBM 一起使用就变得有点复杂。LightGBM 没有 GPU 运行的选项,而是需要为其编译一个特殊版本。根据您的操作系统(Windows、Linux/Ubuntu、MacOS),编译可能更具挑战性。对于 POSIX 系统,请参阅mng.bz/nRg5的说明,对于 Windows 系统,请参阅mng.bz/vK8p的说明。然而,如果您已按照mng.bz/4aJg中的说明准备好所有先决条件,您可以直接在 shell 或命令提示符中使用 pip install 指令进行安装:
pip install lightgbm --install-option=--gpu
一切安装完成后,您需要将参数device设置为gpu。不过,不要期待惊人的性能提升。正如 LightGBM 作者所述(见mng.bz/vK8p),在大型和密集数据集上可以获得最佳结果,因为不高效的数据周转会导致在处理小型数据集时产生延迟。此外,为直方图算法设置更少的 bins 数量将使 GPU 与 LightGBM 更有效地工作。建议将max_bin=15和单精度,gpu_use_dp=false设置为最佳性能。
GPU 对于加速训练非常有用,但在预测时还有更多选项。正如我们在上一节中看到的,由于树结构如此容易获得,一些特定项目已经可以使用这种信息来重建预测树,使用性能更好的编程语言,如 C、JAVA 或 LLVM,这些语言可以将你的模型转换为纯汇编代码。这样的树编译项目旨在实现快速预测和更容易部署。例如,Treelite (github.com/dmlc/treelite) 可以读取由 XGBoost、LightGBM 甚至 Scikit-learn 生成的模型,还有 lleaves (github.com/siboehm/lleaves),这是一个仅针对 LightGBM 的项目。
从 Treelite 开始,这个项目致力于成为决策树森林的通用模型交换和序列化格式。它将你的 GBDT 编译成 C 或 Java,依赖性尽可能少,因此你可以轻松地将它部署到任何系统。为了进行测试,你必须在命令行中安装几个包:pip install tl2cgen treelite treelite_runtime。
列表 6.18 Treelite 加速 XGBoost 预测
import treelite
import treelite_runtime
import tl2cgen
xgb = XGBClassifier(booster='gbtree',
objective='reg:logistic',
n_estimators=10,
max_depth=3)
model_pipeline = Pipeline(
[('processing', column_transform),
('XGBoost', xgb)])
model_pipeline.fit(X=data, y=target_median)
model = model_pipeline["XGBoost"]
model.save_model("./xgb_model.json") ①
treelite_model = treelite.Model.load("./xgb_model.json",
model_format="XGBoost_json") ②
tl2cgen.generate_c_code(treelite_model, dirpath="./",
params={"parallel_comp": 4})
tl2cgen.export_lib(treelite_model, toolchain="gcc",
libpath="./xgb_model.so", ③
params={"parallel_comp": 4})
predictor = tl2cgen.Predictor("./xgb_model.so")
X = model_pipeline["processing"].transform(data) ④
dmat = tl2cgen.DMatrix(X) ⑤
predictor.predict(dmat)
① 将 XGBoost 模型保存到 JSON 文件
② 从 JSON 文件加载 Treelite 格式的 XGBoost 模型
③ 从 Treelite 模型生成 C 代码并将其导出为共享库
④ 使用管道中定义的预处理步骤转换输入数据
⑤ 从转换后的数据创建与导出 Treelite 模型兼容的 Treelite DMatrix
结果是一个编译后的模型,在 Python 脚本中可以以更快的速度返回预测。预测器必须在转换之前进行转换,因为管道不是编译的一部分。只有模型是。此外,你还需要在将数据发送到编译模型之前将其转换为 DMatrix 格式,这是 XGBoost 的本地数据格式。
由 Simon Boehm 开发,lleaves 通过基于可以从 LightGBM 模型输出的文本树结构使用 LLVM 编译到汇编,承诺实现 x10 的速度提升。通过在命令行上使用pip install leaves指令安装包后,你可以按照以下步骤获得加速。
列表 6.19 lleaves加速 LightGBM 预测
import lleaves
lgbm = LGBMClassifier(boosting_type='gbdt',
n_estimators=10,
max_depth=3)
model_pipeline = Pipeline(
[('processing', column_transform),
('lightgbm', lgbm)])
model_pipeline.fit(X=data, y=target_median)
model = model_pipeline["lightgbm"]
model.booster_.save_model('lgb_model.txt') ①
llvm_model = lleaves.Model(model_file="lgb_model.txt") ②
llvm_model.compile() ③
X = model_pipeline["processing"].transform(data) ④
llvm_model.predict(X)
① 将 LightGBM 模型保存到文本文件
② 使用 lleaves 库加载 LightGBM 模型
③ 将加载的 LightGBM 模型编译成 LLVM 表示
④ 使用管道中定义的预处理步骤转换输入数据
在这个例子中,模型被编译,可以在 Python 脚本中以更快的速度进行预测。从一般的角度来看,尽管lleaves仅限于 LightGBM,但它是一个需要用户设置和指定更少的编译解决方案,从而实现更简单、更直接的使用。
摘要
-
在处理问题中,缺失数据是其中最棘手的问题之一。如果你的数据是 MCR 或只是 MAR(因为缺失模式与其他特征相关),多元插补可以使用数据集中预测变量的相关性来插补缺失值。
-
XGBoost 和 LightGBM 算法自动处理缺失数据,通过将它们分配到每个分割中损失函数最小化的那一侧。
-
当一个分类特征由于许多标签而呈现高基数时,你可以使用目标编码,这在 Kaggle 竞赛中变得流行。目标编码是一种将分类特征中的值转换为它们对应的预期目标值的方法。
-
PDP 是一种模型无关的图表技术,它通过你所训练的模型解释特征和目标之间的关系。它是有益的,因为它可以帮助你更好地建模预测特征和目标之间的关系,如果你注意到它是非线性且复杂的。
-
XGBoost,得益于 XGBFIR 等包,可以告诉你预测特征之间最重要的交互。
-
通过采用有效的特征选择技术,你可以确定并保留对机器学习过程贡献显著的最重要的特征。处理特征选择的标准技术是基于 L1 正则化的稳定性选择(用于线性模型)、迭代选择和 Boruta(用于树集成):
-
基于 L1 正则化,稳定性选择旨在识别在多个子集中始终出现为重要的特征,这表明它们的鲁棒性,并减少随机或噪声选择特征的可能性。
-
Boruta 是一种通过依赖于模型内部参数(如线性模型中的系数或基于增益的重要性值,如决策树及其集成)来确定特征在机器学习问题中是否相关的程序。
-
通过前向选择或后向消除,迭代选择添加或删除特征,基于它们在预测中的性能,以贪婪的方式从你的选择中提取特征,只留下对预测至关重要的特征。
-
-
通过优化超参数,你可以给你的经典机器学习模型带来另一个性能提升。除了手动设置超参数外,根据你正在工作的模型,网格搜索、随机搜索、连续减半和贝叶斯优化是数据科学社区中流行的优化方法:
-
网格搜索通过遍历所有可能的超参数值组合来简单工作。对于你想要测试的每个超参数,你选择一个值序列,并彻底迭代它们的所有组合。
-
随机搜索优化通过从搜索空间中随机抽取值来决定要测试的值。如果你对超参数了解不多,如果有很多超参数,以及如果某些参数无关紧要但你不知道是哪些,这种技术特别有效。
-
连续减半是之前讨论策略的包装器。它作为一个超参数集之间的锦标赛工作,首先,它们使用少量计算资源进行测试。然后,只有最好的部分进一步使用更多资源进行测试。最后,将只剩下一组幸存的超参数。
-
贝叶斯优化通过有信息搜索来寻找最佳的超参数集。它基于对超参数在数据问题上的工作原理的先验知识,构建了超参数行为的模型。然后,它设置一系列实验来进一步探索并完善其内部模型,利用之前的试验,并验证解决方案的实际性能。
-
-
XGBoost 和 LightGBM 都有特定的设置和选项,这在其他机器学习算法中并不常见,例如提取和表示它们的内部结构以及通过 GPU 使用和编译来加速它们的执行。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)