调优 XGBoost 超参数与 Ray Tune#

XGBoost 目前是最受欢迎的机器学习算法之一。它在众多任务上表现优异,并且是许多 Kaggle 比赛成功的关键。

XGBoost

本教程将为您快速介绍 XGBoost,展示如何训练 XGBoost 模型,并指导您如何使用 Tune 优化 XGBoost 参数以获得最佳性能。我们将探讨以下主题:

备注

要运行本教程,您需要安装以下内容:

$ pip install xgboost

什么是 XGBoost#

XGBoost 是 eXtreme Gradient Boosting 的缩写。在内部,XGBoost 使用 决策树。与其仅训练一棵大型决策树,XGBoost 和其他相关算法则训练许多小型决策树。其背后的直觉是,尽管单棵决策树可能不够准确且容易受到高方差的影响,结合大量这些弱学习器的输出实际上可以形成强学习器,从而提高预测准确性并减少方差。

单一学习和集成学习

单棵决策树(左侧)可能能够在二元分类任务中达到 70% 的准确度。通过结合几棵小型决策树的输出,一个集成学习器(右侧)可能最终达到更高的 90% 的准确度。#

增强算法从单棵小型决策树开始,并评估其对给定示例的预测效果。在构建下一棵树时,之前被错误分类的样本有更高的概率被用来生成该树。这非常有用,因为这样可以避免对容易分类的样本过拟合,而是试图构建能够分类困难实例的模型。请查看 此处以获得对汇聚和增强算法更全面的介绍

增强算法有很多种。它们的核心非常相似。XGBoost 使用二阶导数来找到最大化 增益损失 的倒数)的分裂——这就是它的名称。在实际应用中,使用 XGBoost 与其他增强算法相比并没有缺点——事实上,它通常表现出最佳性能。

训练一个简单的 XGBoost 分类器#

让我们首先看看如何训练一个简单的 XGBoost 分类器。我们将使用 sklearn 数据集收录的 breast_cancer 数据集。该数据集是一个二元分类数据集。给定 30 个不同的输入特征,我们的任务是学习识别有乳腺癌和没有乳腺癌的受试者。

以下是训练简单 XGBoost 模型的完整代码:

import sklearn.datasets
import sklearn.metrics
from sklearn.model_selection import train_test_split
import xgboost as xgb


def train_breast_cancer(config):
    # 加载数据集
    data, labels = sklearn.datasets.load_breast_cancer(return_X_y=True)
    # 分为训练集和测试集
    train_x, test_x, train_y, test_y = train_test_split(data, labels, test_size=0.25)
    # 构建XGBoost的输入矩阵
    train_set = xgb.DMatrix(train_x, label=train_y)
    test_set = xgb.DMatrix(test_x, label=test_y)
    # 训练分类器
    results = {}
    bst = xgb.train(
        config,
        train_set,
        evals=[(test_set, "eval")],
        evals_result=results,
        verbose_eval=False,
    )
    return results


results = train_breast_cancer(
    {"objective": "binary:logistic", "eval_metric": ["logloss", "error"]}
)
accuracy = 1.0 - results["eval"]["error"][-1]
print(f"Accuracy: {accuracy:.4f}")
Accuracy: 0.9720

如您所见,这段代码相当简单。首先,加载数据集并将其分为测试训练集。使用xgb.train()训练XGBoost模型。XGBoost会自动在测试集上评估我们指定的指标。对于我们的情况,它计算对数损失和预测错误,即错误分类示例的百分比。要计算准确性,我们只需从1.0中减去错误率。在这个简单的例子中,大多数运行结果的准确性超过了0.90

也许您已经注意到我们传递给XGBoost算法的config参数。这个参数是一个dict,您可以在其中指定XGBoost算法的参数。在这个简单的例子中,我们传递的唯一参数是objectiveeval_metric参数。值binary:logistic告诉XGBoost我们旨在为二分类任务训练一个逻辑回归模型。您可以在XGBoost文档中找到所有有效目标的概述。

XGBoost超参数#

即使使用默认设置,XGBoost也能够在乳腺癌数据集上获得良好的准确性。然而,就像许多机器学习算法一样,还有许多需要调试的参数,这可能会导致更好的性能。让我们在下文中探索其中的一些。

最大树深度#

请记住,XGBoost内部使用多个决策树模型来进行预测。在训练决策树时,我们需要告诉算法树的最大深度。这个参数被称为树的深度

决策树深度

在这个图中,左侧的树深度为2,右侧的树深度为3。请注意,每增加一层,\(2^{(d-1)}\)个拆分被添加,其中d是树的深度。#

树深度是影响模型复杂性的属性。如果仅允许短树,则模型可能不会非常精确——它们对数据不足拟合。如果允许树的深度过大,则单棵树模型可能会过拟合数据。在实践中,这个参数的合理值通常在26之间。

XGBoost的默认值是3

最小子权重#

当决策树创建新叶子时,它将一个节点的剩余数据分成两个组。如果其中一个组的样本很少,则进一步拆分往往没有意义。这是因为在样本较少时,模型的训练会变得更加困难。

最小子权重

在这个例子中,我们开始时有100个样本。在第一个节点,它们被分别拆分为4个和96个样本。在下一个步骤中,我们的模型可能会发现继续拆分这4个样本没有意义。因此,它只会在右侧继续添加叶子。#

模型用于判断拆分节点是否有意义的参数称为最小子权重。在线性回归的情况下,这仅仅是每个子节点所需的节点的绝对数。在其他目标中,这个值是根据样本的权重来确定的,因此得名。

值越大,树的约束越多,树的深度也会越小。因此,这个参数也会影响模型的复杂性。值可以在0到无穷大之间变化,并且依赖于样本大小。对于我们在乳腺癌数据集中大约500个示例的情况,010之间的值应该是合理的。

XGBoost的默认值是1

子样本大小#

我们添加的每一棵决策树都是在总训练数据集的子样本上训练的。样本的概率根据XGBoost算法进行加权,但我们可以决定要在每棵决策树上训练多少比例的样本。

将这个值设置为0.7意味着我们在每次训练迭代之前随机抽样70%的训练数据集。

XGBoost的默认值是1

学习率 / Eta#

请记住,XGBoost是顺序训练许多决策树,且后来的树更可能是在先前树错误分类的数据上训练的。实际上,这意味着早期树对易分类的样本(即那些容易分类的样本)做出决策,而后来的树对难以分类的样本做出决策。因此,合理假设后来的树的准确性低于早期的树。

为了解决这一事实,XGBoost使用了一个称为Eta的参数,有时称其为学习率。不要将其与梯度下降的学习率混淆!关于随机梯度提升的原始论文这样介绍这个参数:

\[ F_m(x) = F_{m-1}(x) + \eta \cdot \gamma_{lm} \textbf{1}(x \in R_{lm}) \]

这只是一个复杂的方式来表述,当我们训练新的决策树,表示为\(\gamma_{lm} \textbf{1}(x \in R_{lm})\)时,我们希望用一个因子\(\eta\)来减小它对之前的预测\(F_{m-1}(x)\)的影响。

这个参数的典型值在0.010.3之间。

XGBoost的默认值是0.3

提升轮数#

最后,我们可以决定进行多少轮提升,这意味着最终我们训练多少棵决策树。当我们进行大规模的子采样或使用小学习率时,增加提升轮数可能是有意义的。

XGBoost的默认值是10

整合起来#

让我们看看在代码中这看起来怎么样!我们只需调整我们的config字典:

config = {
    "objective": "binary:logistic",
    "eval_metric": ["logloss", "error"],
    "max_depth": 2,
    "min_child_weight": 0,
    "subsample": 0.8,
    "eta": 0.2,
}
results = train_breast_cancer(config)
accuracy = 1.0 - results["eval"]["error"][-1]
print(f"Accuracy: {accuracy:.4f}")
Accuracy: 0.9510

其余部分保持不变。请注意,我们在这里不调整 num_boost_rounds。结果应显示出高于 90% 的高准确率。

调整配置参数#

XGBoost 的默认参数已经能够获得良好的准确率,甚至我们在上一节中的猜测结果也应该会得到高于 90% 的准确率。然而,我们的猜测仅仅是猜测。通常我们并不知道哪种参数组合实际上会在机器学习任务中产生最佳结果。

不幸的是,我们可以尝试的超参数组合是无限的。我们应该将 max_depth=3subsample=0.8 结合起来,还是与 subsample=0.9 结合?其他参数怎么样?

这就是超参数调整发挥作用的地方。通过使用像 Ray Tune 这样的调优库,我们可以尝试超参数的组合。使用复杂的搜索策略,这些参数可以被选中,从而很可能获得良好的结果(避免耗时的 穷举搜索)。此外,表现不佳的试验可以被预先停止,以减少计算资源的浪费。最后,Ray Tune 还负责并行训练这些运行,大幅提高搜索速度。

让我们从一个基本示例开始,看看如何使用 Tune。我们只需对代码块进行一些更改:

import sklearn.datasets
import sklearn.metrics

from ray import train, tune


def train_breast_cancer(config):
    # 加载数据集
    data, labels = sklearn.datasets.load_breast_cancer(return_X_y=True)
    # 分为训练集和测试集
    train_x, test_x, train_y, test_y = train_test_split(data, labels, test_size=0.25)
    # 构建XGBoost的输入矩阵
    train_set = xgb.DMatrix(train_x, label=train_y)
    test_set = xgb.DMatrix(test_x, label=test_y)
    # 训练分类器
    results = {}
    xgb.train(
        config,
        train_set,
        evals=[(test_set, "eval")],
        evals_result=results,
        verbose_eval=False,
    )
    # 回报预测准确性
    accuracy = 1.0 - results["eval"]["error"][-1]
    train.report({"mean_accuracy": accuracy, "done": True})


config = {
    "objective": "binary:logistic",
    "eval_metric": ["logloss", "error"],
    "max_depth": tune.randint(1, 9),
    "min_child_weight": tune.choice([1, 2, 3]),
    "subsample": tune.uniform(0.5, 1.0),
    "eta": tune.loguniform(1e-4, 1e-1),
}
tuner = tune.Tuner(
    train_breast_cancer,
    tune_config=tune.TuneConfig(num_samples=10),
    param_space=config,
)
results = tuner.fit()

如您所见,实际训练函数中的更改很小。我们使用 session.report() 将准确率值报告回 Tune,而不是返回准确率值。我们的 config 字典仅稍作更改。我们不再传递硬编码的参数,而是告诉 Tune 从一系列有效选项中选择值。我们这里有许多选项,所有这些都在 Tune 文档 中进行了说明。

简单解释一下,这些选项的作用:

  • tune.randint(min, max) 会在 minmax 之间选择一个随机整数值。请注意,max 为排除值,因此不会被采样。

  • tune.choice([a, b, c]) 会随机选择列表中的一个项。每个项被采样的几率相同。

  • tune.uniform(min, max) 会在 minmax 之间采样一个浮点数。请注意,max 在这里也是排除值。

  • tune.loguniform(min, max, base=10) 会在 minmax 之间采样一个浮点数,但首先会对这些边界进行对数变换。因此,这使得从不同数量级的值中采样变得容易。

我们传递给 TuneConfig()num_samples=10 选项表示我们从这个搜索空间中采样 10 个不同的超参数配置。

我们训练运行的输出可能看起来如下:

 试验次数: 10/10 (10 已终止)
 +---------------------------------+------------+-------+-------------+-------------+--------------------+-------------+----------+--------+------------------+
 | 试验名称                        | 状态      | 位置  |         eta |   max_depth |   min_child_weight |   subsample |      acc |   迭代 |   总时间 ()    |
 |---------------------------------+------------+-------+-------------+-------------+--------------------+-------------+----------+--------+------------------|
 | train_breast_cancer_b63aa_00000 | 已终止   |       | 0.000117625 |           2 |                  2 |    0.616347 | 0.916084 |      1 |        0.0306492 |
 | train_breast_cancer_b63aa_00001 | 已终止   |       | 0.0382954   |           8 |                  2 |    0.581549 | 0.937063 |      1 |        0.0357082 |
 | train_breast_cancer_b63aa_00002 | 已终止   |       | 0.000217926 |           1 |                  3 |    0.528428 | 0.874126 |      1 |        0.0264609 |
 | train_breast_cancer_b63aa_00003 | 已终止   |       | 0.000120929 |           8 |                  1 |    0.634508 | 0.958042 |      1 |        0.036406  |
 | train_breast_cancer_b63aa_00004 | 已终止   |       | 0.00839715  |           5 |                  1 |    0.730624 | 0.958042 |      1 |        0.0389378 |
 | train_breast_cancer_b63aa_00005 | 已终止   |       | 0.000732948 |           8 |                  2 |    0.915863 | 0.958042 |      1 |        0.0382841 |
 | train_breast_cancer_b63aa_00006 | 已终止   |       | 0.000856226 |           4 |                  1 |    0.645209 | 0.916084 |      1 |        0.0357089 |
 | train_breast_cancer_b63aa_00007 | 已终止   |       | 0.00769908  |           7 |                  1 |    0.729443 | 0.909091 |      1 |        0.0390737 |
 | train_breast_cancer_b63aa_00008 | 已终止   |       | 0.00186339  |           5 |                  3 |    0.595744 | 0.944056 |      1 |        0.0343912 |
 | train_breast_cancer_b63aa_00009 | 已终止   |       | 0.000950272 |           3 |                  2 |    0.835504 | 0.965035 |      1 |        0.0348201 |
 +---------------------------------+------------+-------+-------------+-------------+--------------------+-------------+----------+--------+------------------+

我们找到的最佳配置使用了 eta=0.000950272max_depth=3min_child_weight=2subsample=0.835504,并达到了 0.965035 的准确率。

早期停止#

目前,Tune 采样 10 个不同的超参数配置,并对它们都进行全量训练 XGBoost。在我们的小示例中,训练速度非常快。然而,如果训练耗时较长,会有大量的计算资源花费在最终会表现不佳的试验上,例如低准确率的情况。如果我们能尽早识别这些试验并停止它们,那就不会浪费任何资源。

这就是 Tune 的 Schedulers 发挥作用的地方。Tune 的 TrialScheduler 负责启动和停止试验。Tune 实现了多种不同的调度程序,每种调度程序的描述都可以在 Tune 文档 中找到。在我们的示例中,我们将使用 AsyncHyperBandSchedulerASHAScheduler

该调度程序的基本理念是:我们采样若干超参数配置。每个配置训练特定数量的迭代。经过这些迭代后,保留表现最佳的超参数。这些超参数是根据某种损失指标选择的,通常是评估损失。这个过程会重复,直到我们得到最佳配置。

ASHAScheduler 需要了解三个信息:

  1. 应使用哪个指标来识别表现不佳的试验?

  2. 该指标应该是最大化还是最小化?

  3. 每个试验的训练迭代次数是多少?

还有更多参数在 文档 中进行了说明。

最后,我们需要将损失指标报告给 Tune。我们通过一个 XGBoost 接受的 Callback 来实现这一点,它在每个评估轮后被调用。Ray Tune 附带了 两个 XGBoost 回调 可供我们使用。TuneReportCallback 只是将评估指标报告回 Tune。TuneReportCheckpointCallback 还在每次评估轮后保存检查点。在这个示例中,我们将只使用后者,以便稍后能够检索保存的模型。

eval_metrics 配置设置中的这些参数随后会通过回调自动报告给 Tune。在这里,原始错误将被报告,而不是准确率。为了显示最佳的准确率,我们将在稍后对其进行反转。

我们还将加载最佳的检查点模型,以便可以用于预测。最佳模型是根据我们传递给 TunerConfig()metricmode 参数选择的。

import sklearn.datasets
import sklearn.metrics
from ray.tune.schedulers import ASHAScheduler
from sklearn.model_selection import train_test_split
import xgboost as xgb

from ray import tune
from ray.tune.integration.xgboost import TuneReportCheckpointCallback


def train_breast_cancer(config: dict):
    # 这是一个简单的训练函数,用于传递给Tune。
    # 加载数据集
    data, labels = sklearn.datasets.load_breast_cancer(return_X_y=True)
    # 分为训练集和测试集
    train_x, test_x, train_y, test_y = train_test_split(data, labels, test_size=0.25)
    # 构建XGBoost的输入矩阵
    train_set = xgb.DMatrix(train_x, label=train_y)
    test_set = xgb.DMatrix(test_x, label=test_y)
    # 使用Tune回调训练分类器
    xgb.train(
        config,
        train_set,
        evals=[(test_set, "eval")],
        verbose_eval=False,
        # `TuneReportCheckpointCallback` 定义了检查点的保存频率和格式。
        callbacks=[TuneReportCheckpointCallback(frequency=1)],
    )


def get_best_model_checkpoint(results):
    best_result = results.get_best_result()

    # `TuneReportCheckpointCallback` 提供了一个辅助方法来检索
    # 从检查点加载模型。
    best_bst = TuneReportCheckpointCallback.get_model(best_result.checkpoint)

    accuracy = 1.0 - best_result.metrics["eval-error"]
    print(f"Best model parameters: {best_result.config}")
    print(f"Best model total accuracy: {accuracy:.4f}")
    return best_bst


def tune_xgboost(smoke_test=False):
    search_space = {
        # 你可以将常量与搜索空间对象混合使用。
        "objective": "binary:logistic",
        "eval_metric": ["logloss", "error"],
        "max_depth": tune.randint(1, 9),
        "min_child_weight": tune.choice([1, 2, 3]),
        "subsample": tune.uniform(0.5, 1.0),
        "eta": tune.loguniform(1e-4, 1e-1),
    }
    # 这将能够积极地提前停止不良试验。
    scheduler = ASHAScheduler(
        max_t=10, grace_period=1, reduction_factor=2  # 10次训练迭代
    )

    tuner = tune.Tuner(
        train_breast_cancer,
        tune_config=tune.TuneConfig(
            metric="eval-logloss",
            mode="min",
            scheduler=scheduler,
            num_samples=1 if smoke_test else 10,
        ),
        param_space=search_space,
    )
    results = tuner.fit()
    return results


results = tune_xgboost(smoke_test=True)

# 加载最佳模型检查点。
best_bst = get_best_model_checkpoint(results)

# 你现在可以进行进一步的预测了。
# best_bst.predict(...)

我们的运行输出可能如下所示:

 试验次数:10/10(10 已终止)
 +---------------------------------+------------+-------+-------------+-------------+--------------------+-------------+--------+------------------+----------------+--------------+
 | 试验名称                       | 状态       | 位置  |         eta |   最大深度 |   最小子权重      |   子样本    |   迭代 |   总时间 (s)     |   评估-logloss  |   评估-error |
 |---------------------------------+------------+-------+-------------+-------------+--------------------+-------------+--------+------------------+----------------+--------------|
 | train_breast_cancer_ba275_00000 | 已终止     |       | 0.00205087  |           2 |                  1 |    0.898391 |     10 |        0.380619  |       0.678039 |     0.090909 |
 | train_breast_cancer_ba275_00001 | 已终止     |       | 0.000183834 |           4 |                  3 |    0.924939 |      1 |        0.0228798 |       0.693009 |     0.111888 |
 | train_breast_cancer_ba275_00002 | 已终止     |       | 0.0242721   |           7 |                  2 |    0.501551 |     10 |        0.376154  |       0.54472  |     0.06993  |
 | train_breast_cancer_ba275_00003 | 已终止     |       | 0.000449692 |           5 |                  3 |    0.890212 |      1 |        0.0234981 |       0.692811 |     0.090909 |
 | train_breast_cancer_ba275_00004 | 已终止     |       | 0.000376393 |           7 |                  2 |    0.883609 |      1 |        0.0231569 |       0.692847 |     0.062937 |
 | train_breast_cancer_ba275_00005 | 已终止     |       | 0.00231942  |           3 |                  3 |    0.877464 |      2 |        0.104867  |       0.689541 |     0.083916 |
 | train_breast_cancer_ba275_00006 | 已终止     |       | 0.000542326 |           1 |                  2 |    0.578584 |      1 |        0.0213971 |       0.692765 |     0.083916 |
 | train_breast_cancer_ba275_00007 | 已终止     |       | 0.0016801   |           1 |                  2 |    0.975302 |      1 |        0.02226   |       0.691999 |     0.083916 |
 | train_breast_cancer_ba275_00008 | 已终止     |       | 0.000595756 |           8 |                  3 |    0.58429  |      1 |        0.0221152 |       0.692657 |     0.06993  |
 | train_breast_cancer_ba275_00009 | 已终止     |       | 0.000357845 |           8 |                  1 |    0.637776 |      1 |        0.022635  |       0.692859 |     0.090909 |
 +---------------------------------+------------+-------+-------------+-------------+--------------------+-------------+--------+------------------+----------------+--------------+


 最佳模型参数:{'objective': 'binary:logistic', 'eval_metric': ['logloss', 'error'], 'max_depth': 7, 'min_child_weight': 2, 'subsample': 0.5015513240240503, 'eta': 0.024272050872920895}
 最佳模型总准确率:0.9301

正如你所看到的,大多数试验仅在几个迭代后就停止了。只有两个最有希望的试验运行了完整的10个迭代。

你还可以确保所有可用资源都被使用,因为调度器终止试验,释放它们。这可以通过ResourceChangingScheduler来实现。这里可以找到一个示例: XGBoost 动态资源示例

使用分数GPU#

你通常可以通过使用GPU以及CPU来加速训练。然而,你通常没有足够的GPU与正在运行的试验数量相匹配。例如,如果你并行运行10个Tune试验,通常没有10个独立的GPU可用。

Tune支持分数GPU。这意味着每个任务被分配一个GPU内存的部分进行训练。对于10个任务,这可能看起来像这样:

config = {
    "objective": "binary:logistic",
    "eval_metric": ["logloss", "error"],
    "tree_method": "gpu_hist",
    "max_depth": tune.randint(1, 9),
    "min_child_weight": tune.choice([1, 2, 3]),
    "subsample": tune.uniform(0.5, 1.0),
    "eta": tune.loguniform(1e-4, 1e-1),
}

tuner = tune.Tuner(
    tune.with_resources(train_breast_cancer, resources={"cpu": 1, "gpu": 0.1}),
    tune_config=tune.TuneConfig(num_samples=10),
    param_space=config,
)
results = tuner.fit()

每个任务因此使用可用GPU内存的10%。您还需告诉XGBoost使用gpu_hist树方法,以便它知道应该使用GPU。

结论#

您现在应该对如何训练XGBoost模型以及如何调整超参数以获得最佳结果有基本的理解。在我们这个简单的例子中,调整参数对准确度没有显著影响。但在更大的应用中,智能的超参数调整可以决定一个模型是否完全没有学习能力,和一个在所有其他模型中表现最好的模型之间的差异。

更多XGBoost示例#

  • XGBoost 动态资源示例: 使用基于类的API和ResourceChangingScheduler训练基本的XGBoost模型,确保所有资源始终被使用。

了解更多#