梯度提升中的分类特征支持#

在此示例中,我们将比较 HistGradientBoostingRegressor 在不同分类特征编码策略下的训练时间和预测性能。具体而言,我们评估了以下策略:

为此,我们使用 Ames Iowa 房价数据集,该数据集由数值特征和分类特征组成,目标是房屋销售价格。

请参阅直方图梯度提升树中的特征,以了解展示 HistGradientBoostingRegressor 其他功能的示例。

请参阅目标编码器与其他编码器的比较,了解在高基数分类特征存在时各种编码策略的对比。

# Authors: The scikit-learn developers
# SPDX-License-Identifier: BSD-3-Clause

加载 Ames 房价数据集#

首先,我们将 Ames 房价数据作为 pandas 数据框加载。特征分为分类特征或数值特征。

from sklearn.datasets import fetch_openml

X, y = fetch_openml(data_id=42165, as_frame=True, return_X_y=True)

# Select only a subset of features of X to make the example faster to run
categorical_columns_subset = [
    "BldgType",
    "GarageFinish",
    "LotConfig",
    "Functional",
    "MasVnrType",
    "HouseStyle",
    "FireplaceQu",
    "ExterCond",
    "ExterQual",
    "PoolQC",
]

numerical_columns_subset = [
    "3SsnPorch",
    "Fireplaces",
    "BsmtHalfBath",
    "HalfBath",
    "GarageCars",
    "TotRmsAbvGrd",
    "BsmtFinSF1",
    "BsmtFinSF2",
    "GrLivArea",
    "ScreenPorch",
]

X = X[categorical_columns_subset + numerical_columns_subset]
X[categorical_columns_subset] = X[categorical_columns_subset].astype("category")

categorical_columns = X.select_dtypes(include="category").columns
n_categorical_features = len(categorical_columns)
n_numerical_features = X.select_dtypes(include="number").shape[1]

print(f"Number of samples: {X.shape[0]}")
print(f"Number of features: {X.shape[1]}")
print(f"Number of categorical features: {n_categorical_features}")
print(f"Number of numerical features: {n_numerical_features}")
Number of samples: 1460
Number of features: 20
Number of categorical features: 10
Number of numerical features: 10

丢弃分类特征的梯度提升估计器#

作为基准,我们创建一个丢弃分类特征的估计器。

from sklearn.compose import make_column_selector, make_column_transformer
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.pipeline import make_pipeline

dropper = make_column_transformer(
    ("drop", make_column_selector(dtype_include="category")), remainder="passthrough"
)
hist_dropped = make_pipeline(dropper, HistGradientBoostingRegressor(random_state=42))
hist_dropped
Pipeline(steps=[('columntransformer',
                 ColumnTransformer(remainder='passthrough',
                                   transformers=[('drop', 'drop',
                                                  <sklearn.compose._column_transformer.make_column_selector object at 0x78416405aba0>)])),
                ('histgradientboostingregressor',
                 HistGradientBoostingRegressor(random_state=42))])
在 Jupyter 环境中,请重新运行此单元格以显示 HTML 表示形式或信任 notebook。
在 GitHub 上,HTML 表示形式无法渲染,请尝试使用 nbviewer.org 加载此页面。


采用独热编码的梯度提升估计器#

接下来,我们创建一个流水线来对分类特征进行独热编码,同时让剩余特征通过 "passthrough" 保持不变。

from sklearn.preprocessing import OneHotEncoder

one_hot_encoder = make_column_transformer(
    (
        OneHotEncoder(sparse_output=False, handle_unknown="ignore"),
        make_column_selector(dtype_include="category"),
    ),
    remainder="passthrough",
)

hist_one_hot = make_pipeline(
    one_hot_encoder, HistGradientBoostingRegressor(random_state=42)
)
hist_one_hot
Pipeline(steps=[('columntransformer',
                 ColumnTransformer(remainder='passthrough',
                                   transformers=[('onehotencoder',
                                                  OneHotEncoder(handle_unknown='ignore',
                                                                sparse_output=False),
                                                  <sklearn.compose._column_transformer.make_column_selector object at 0x78414c239810>)])),
                ('histgradientboostingregressor',
                 HistGradientBoostingRegressor(random_state=42))])
在 Jupyter 环境中,请重新运行此单元格以显示 HTML 表示形式或信任 notebook。
在 GitHub 上,HTML 表示形式无法渲染,请尝试使用 nbviewer.org 加载此页面。


采用序数编码的梯度提升估计器#

接下来,我们创建一个将分类特征视为有序数量的流水线,即类别被编码为 0, 1, 2 等,并被视为连续特征。

import numpy as np

from sklearn.preprocessing import OrdinalEncoder

ordinal_encoder = make_column_transformer(
    (
        OrdinalEncoder(handle_unknown="use_encoded_value", unknown_value=np.nan),
        make_column_selector(dtype_include="category"),
    ),
    remainder="passthrough",
)

hist_ordinal = make_pipeline(
    ordinal_encoder, HistGradientBoostingRegressor(random_state=42)
)
hist_ordinal
Pipeline(steps=[('columntransformer',
                 ColumnTransformer(remainder='passthrough',
                                   transformers=[('ordinalencoder',
                                                  OrdinalEncoder(handle_unknown='use_encoded_value',
                                                                 unknown_value=nan),
                                                  <sklearn.compose._column_transformer.make_column_selector object at 0x78414c238cd0>)])),
                ('histgradientboostingregressor',
                 HistGradientBoostingRegressor(random_state=42))])
在 Jupyter 环境中,请重新运行此单元格以显示 HTML 表示形式或信任 notebook。
在 GitHub 上,HTML 表示形式无法渲染,请尝试使用 nbviewer.org 加载此页面。


采用目标编码的梯度提升估计器#

另一种可能性是使用 TargetEncoder,它使用(训练)目标变量的均值计算出的类别进行编码,计算方式是使用平滑后的 np.mean(y, axis=0),即:

  • 在回归任务中,它使用 y 的均值;

  • 在二分类中,它使用正类比率;

  • 在多分类中,它使用类比率向量(每类一个)。

对于每个类别,它使用 交叉拟合 (cross fitting) 计算这些目标平均值,这意味着训练数据被分成若干折:在每一折中,平均值仅根据数据子集计算,然后应用于保留部分。通过这种方式,每个样本都是使用它不属于的数据的统计信息进行编码的,从而防止了目标信息的泄露。

from sklearn.model_selection import KFold
from sklearn.preprocessing import TargetEncoder

target_encoder = make_column_transformer(
    (
        TargetEncoder(
            target_type="continuous", cv=KFold(shuffle=True, random_state=42)
        ),
        make_column_selector(dtype_include="category"),
    ),
    remainder="passthrough",
)

hist_target = make_pipeline(
    target_encoder, HistGradientBoostingRegressor(random_state=42)
)
hist_target
Pipeline(steps=[('columntransformer',
                 ColumnTransformer(remainder='passthrough',
                                   transformers=[('targetencoder',
                                                  TargetEncoder(cv=KFold(n_splits=5, random_state=42, shuffle=True),
                                                                target_type='continuous'),
                                                  <sklearn.compose._column_transformer.make_column_selector object at 0x78416c6f48a0>)])),
                ('histgradientboostingregressor',
                 HistGradientBoostingRegressor(random_state=42))])
在 Jupyter 环境中,请重新运行此单元格以显示 HTML 表示形式或信任 notebook。
在 GitHub 上,HTML 表示形式无法渲染,请尝试使用 nbviewer.org 加载此页面。


采用原生分类支持的梯度提升估计器#

我们现在创建一个 HistGradientBoostingRegressor 估计器,它可以原生处理分类特征而无需显式编码。可以通过设置 categorical_features="from_dtype"(它会自动检测具有分类数据类型的特征)或者更明确地通过 categorical_features=categorical_columns_subset 来启用此功能。

与之前的编码方法不同,该估计器原生处理分类特征。在每次分裂时,它使用一种启发式算法将此类特征的类别划分为不相交的集合,该算法根据它们对目标变量的影响对类别进行排序,详见 分类特征的分裂查找

虽然序数编码对于低基数特征即使在类别没有自然顺序的情况下也能很好地工作,但随着基数的增加,获得有意义的分裂需要更深的树。原生分类支持通过直接处理无序类别避免了这一点。与独热编码相比,其优势在于无需预处理,且拟合和预测时间更快。

hist_native = HistGradientBoostingRegressor(
    random_state=42, categorical_features="from_dtype"
)
hist_native
HistGradientBoostingRegressor(random_state=42)
在 Jupyter 环境中,请重新运行此单元格以显示 HTML 表示形式或信任 notebook。
在 GitHub 上,HTML 表示形式无法渲染,请尝试使用 nbviewer.org 加载此页面。


模型比较#

这里我们使用 交叉验证 来比较模型在 mean_absolute_percentage_error 和拟合时间方面的性能。在接下来的图中,误差线表示在交叉验证折中计算出的 1 个标准差。

from sklearn.model_selection import cross_validate

common_params = {"cv": 5, "scoring": "neg_mean_absolute_percentage_error", "n_jobs": -1}

dropped_result = cross_validate(hist_dropped, X, y, **common_params)
one_hot_result = cross_validate(hist_one_hot, X, y, **common_params)
ordinal_result = cross_validate(hist_ordinal, X, y, **common_params)
target_result = cross_validate(hist_target, X, y, **common_params)
native_result = cross_validate(hist_native, X, y, **common_params)
results = [
    ("Dropped", dropped_result),
    ("One Hot", one_hot_result),
    ("Ordinal", ordinal_result),
    ("Target", target_result),
    ("Native", native_result),
]
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker


def plot_performance_tradeoff(results, title):
    fig, ax = plt.subplots()
    markers = ["s", "o", "^", "x", "D"]

    for idx, (name, result) in enumerate(results):
        test_error = -result["test_score"]
        mean_fit_time = np.mean(result["fit_time"])
        mean_score = np.mean(test_error)
        std_fit_time = np.std(result["fit_time"])
        std_score = np.std(test_error)

        ax.scatter(
            result["fit_time"],
            test_error,
            label=name,
            marker=markers[idx],
        )
        ax.scatter(
            mean_fit_time,
            mean_score,
            color="k",
            marker=markers[idx],
        )
        ax.errorbar(
            x=mean_fit_time,
            y=mean_score,
            yerr=std_score,
            c="k",
            capsize=2,
        )
        ax.errorbar(
            x=mean_fit_time,
            y=mean_score,
            xerr=std_fit_time,
            c="k",
            capsize=2,
        )

    ax.set_xscale("log")

    nticks = 7
    x0, x1 = np.log10(ax.get_xlim())
    ticks = np.logspace(x0, x1, nticks)
    ax.set_xticks(ticks)
    ax.xaxis.set_major_formatter(ticker.FormatStrFormatter("%1.1e"))
    ax.minorticks_off()

    ax.annotate(
        "  best\nmodels",
        xy=(0.04, 0.04),
        xycoords="axes fraction",
        xytext=(0.09, 0.14),
        textcoords="axes fraction",
        arrowprops=dict(arrowstyle="->", lw=1.5),
    )
    ax.set_xlabel("Time to fit (seconds)")
    ax.set_ylabel("Mean Absolute Percentage Error")
    ax.set_title(title)
    ax.legend()
    plt.show()


plot_performance_tradeoff(results, "Gradient Boosting on Ames Housing")
Gradient Boosting on Ames Housing

在上图中,“最佳模型”是那些更靠近左下角的模型,如箭头所示。这些模型对应于更快的拟合和更低的误差。

使用独热编码数据的模型最慢。这是预料之中的,因为独热编码为每个分类特征的每个类别值创建了一个额外的特征,大大增加了训练期间分裂候选点的数量。理论上,我们预计原生分类处理会比将类别视为有序数量(“序数”)略慢,因为原生处理需要 对类别进行排序。然而,当类别数量较少时,拟合时间应该很接近,这在实践中可能并不总是得到体现。

使用 TargetEncoder 时的拟合时间取决于交叉拟合参数 cv,因为增加分割会带来计算成本。

在预测性能方面,丢弃分类特征会导致性能最差。使用分类特征的四种模型具有相当的错误率,原生处理略有优势。

限制分割数量#

通常,人们可以预期独热编码数据会产生较差的预测,特别是在限制树深度或节点数量时:对于独热编码数据,需要更多的分裂点(即更深的深度),才能恢复出在原生处理中通过一个分裂点即可获得的分裂效果。

当类别被视为序数时也是如此:如果类别是 A..F,且最佳分裂是 ACF - BDE,则独热编码模型需要 3 个分裂点(左节点中每个类别一个),而非原生的序数模型需要 4 个分裂:1 个分裂用于分离 A,1 个分裂用于分离 F,2 个分裂用于将 CBCDE 中分离出来。

模型性能在实践中差异有多大,取决于数据集和树的灵活性。

为了说明这一点,让我们在欠拟合模型中重新运行相同的分析,我们通过限制树的总数和每棵树的深度,人工限制了分裂的总数。

for pipe in (hist_dropped, hist_one_hot, hist_ordinal, hist_target, hist_native):
    if pipe is hist_native:
        # The native model does not use a pipeline so, we can set the parameters
        # directly.
        pipe.set_params(max_depth=3, max_iter=15)
    else:
        pipe.set_params(
            histgradientboostingregressor__max_depth=3,
            histgradientboostingregressor__max_iter=15,
        )

dropped_result = cross_validate(hist_dropped, X, y, **common_params)
one_hot_result = cross_validate(hist_one_hot, X, y, **common_params)
ordinal_result = cross_validate(hist_ordinal, X, y, **common_params)
target_result = cross_validate(hist_target, X, y, **common_params)
native_result = cross_validate(hist_native, X, y, **common_params)
results_underfit = [
    ("Dropped", dropped_result),
    ("One Hot", one_hot_result),
    ("Ordinal", ordinal_result),
    ("Target", target_result),
    ("Native", native_result),
]
plot_performance_tradeoff(
    results_underfit, "Gradient Boosting on Ames Housing (few and shallow trees)"
)
Gradient Boosting on Ames Housing (few and shallow trees)

这些欠拟合模型的结果证实了我们之前的直觉:当分裂预算受到限制时,原生分类处理策略表现最好。这三种显式编码策略(独热、序数和目标编码)导致的错误比估计器的原生处理稍大,但仍然优于仅丢弃分类特征的基准模型。

脚本运行总时间:(0 分钟 6.756 秒)

相关示例

比较目标编码器与其他编码器

比较目标编码器与其他编码器

具有混合类型的列转换器

具有混合类型的列转换器

时间相关特征工程

时间相关特征工程

目标编码器的内部交叉拟合

目标编码器的内部交叉拟合

由 Sphinx-Gallery 生成的图库