9.5 XGBoost

文献:XGBoost: A Scalable Tree Boosting System

官方文档

9.5.1 原理

  1. 基础思想

采用boosting的思想,串行训练多个弱学习器,当前弱学习器从上一个弱学习器的残差中进行学习,最后加权综合各个弱学习器,即\(\hat y_i=\phi(x_i)=\sum_{k=1}^Kf_k(x_i)\)

  1. 目标函数

\[ L(\phi) = \sum_{i=1}^n L(y_i, \hat y_i) + \sum_{k=1}^K \Omega(f_k) \]

\(L(\cdot)\)表示损失函数,用于度量\(y_i\)\(\hat y_i\)之间的差异,回归任务可以为均方误差,分类任务可以为交叉熵。

\(\Omega(f_k)\)表示对第k棵树复杂度的惩罚,用于防止过拟合,定义为\(\Omega(f) = \gamma T+\frac{1}{2}\lambda ||w||^2\),其中\(T\)为叶子节点数,\(w\)为叶子权重(即对应叶子节点的输出值),\(\gamma\)\(\lambda\)为惩罚系数(超参数)。

  1. 计算损失函数

与梯度提升树只利用梯度信息不一样,XGBoost还利用二阶泰勒展开(利用了黑塞矩阵的信息)来更为精准地近似损失函数。

当模型训练到第t棵树时,我们需要最小化下面的损失函数

\[ \begin{aligned} L^{(t)}&=\sum_{i=1}^n L(y_i, \hat y_i^{(t)})+\Omega (f_t) \\ &=\sum_{i=1}^n L(y_i, \hat y_i^{(t-1)}+f_t(x_i))+\Omega (f_t) \\ &\approx \sum_{i=1}^n [L(y_i, \hat y_i^{(t-1)})+g_if_t(x_i)+\frac{1}{2}h_if_t^2(x_i)]+\Omega (f_t) \\ & \propto \sum_{i=1}^n [g_if_t(x_i)+\frac{1}{2}h_if_t^2(x_i)]+\Omega (f_t) \end{aligned} \]

  • 由于前t-1棵树的结构已经确定,因此\(\sum_{i=1}^{t-1}\Omega (f_i)\)也随之确定,即为常数

  • \(g_i\)\(h_i\)分别表示损失函数的一阶导和二阶导

  • \(L(y_i, \hat y_i^{(t-1)})\)为常数

\(I_j=\{i|q(x_i)=j\}\),表示落在叶子节点\(j\)上的观测集合,其中\(q(\cdot)\)表示从样本到叶子节点的映射,即为树的结构。则有

\[ \begin{aligned} \tilde L^{(t)} &= \sum_{i=1}^n [g_if_t(x_i)+\frac{1}{2}h_if_t^2(x_i)]+\gamma T + \frac{1}{2}\lambda \sum_{j=1}^T w_j^2 \\ &= \sum_{j=1}^T[(\sum_{i \in I_j}g_i)w_j + \frac{1}{2}(\sum_{i \in I_j}h_i + \lambda)w_j^2] + \gamma T \end{aligned} \]

引入\(I_j\)的目的就是为了把\(f_t(x_i)\)转化为对应叶子节点的预测值

之后便可将\(\tilde L^{(t)}\)视作关于\(w_j\)的二次函数,故最优权重\(w_j^*\)

\[ w_j^* = -\frac{\sum_{i \in I_j}g_i}{\sum_{i \in I_j}h_i + \lambda} \]

对应的目标函数最小值为

\[ \tilde L^{(t)}(q)=-\frac{1}{2}\sum_{j=1}^T \frac{(\sum_{i \in I_j}g_i)^2}{\sum_{i \in I_j} h_i + \lambda} + \gamma T \]

因此,可根据\(\tilde L^{(t)}(q)\)来判断当前树模型的好坏,值越小,结构越好。

  1. 树的分裂

我们没办法遍历各种树的结构,因此采用贪心算法进行分裂,记\(I_L\)\(I_R\)分别为分裂后左右叶子节点的样本集合,则损失函数减少量为

\[ L_{split} = \frac{1}{2}[\frac{(\sum_{i \in I_L}g_i)^2}{\sum_{i \in I_L} h_i + \lambda} + \frac{(\sum_{i \in I_R}g_i)^2}{\sum_{i \in I_R} h_i + \lambda} - \frac{(\sum_{i \in I_j}g_i)^2}{\sum_{i \in I_j} h_i + \lambda}] - \gamma \]

注意符号,\(L_{split}\)是分裂前的损失函数值减去分裂后的损失函数值,\(L_{split}\)越大,越倾向于分裂

此处的\(\gamma\)同样用于惩罚,能够使得差值达到一定程度时才选择分裂。

  1. 收缩与列采样

除了在目标函数中增加正则项以防止过拟合,还采取收缩与列采样的技术防止过拟合。

引入收缩因子\(\eta\)用于减少每棵树的影响,即\(\eta f_t(x_i)\),从而为后续的树留有改善空间。这里的收缩因子类似学习率。

列采样就是在训练每棵树时随机从所有特征中抽取一个子集用于训练。

  1. 缺失值处理

当某个特征中存在缺失值时,首先删掉所有缺失值对应的观测,将完整的观测按正常操作进行分裂。之后,比较把所有缺失值样本放到左子节点及右子节点的增益大小,将这些缺失值对应的观测分配到能获得更大增益的子节点,并记录分配节点作为默认方向。在测试集上的缺失值则分配到默认方向。

9.5.2 实现

超参数详见官方手册

调参技巧:

  1. 控制模型复杂度(最优先)

模型复杂度过高容易过拟合;过低则欠拟合。
重点调整以下三个参数:

  • max_depth:树的最大深度

    -默认值:6

    • 建议范围:3 ~ 10

    • 越大模型越复杂;过拟合时应减小

  • min_child_weight:每个叶子节点的最小样本权重和(这里的权重指的是二阶梯度)

    • 默认值:1

    • 建议范围:1 ~ 10

    • 越大模型越保守,可防止过拟合

  • gamma (min_split_loss):节点分裂所需的最小损失减少量

    • 默认值:0

    • 建议范围:0 ~ 5

    • 增大 gamma 可减少分裂、抑制过拟合

  1. 学习率与迭代次数

学习率决定每棵树对最终模型的贡献;迭代次数决定累积学习量,两者必须配合使用。

  • eta (learning_rate):学习率

    • 默认值:0.3

    • 建议范围:0.01 ~ 0.2

    • 小 eta + 大 n_estimators = 稳定、效果好,但训练时间长

  • n_estimators:迭代次数(树的数量)

    • 建议范围:500 ~ 5000

    • 通常配合 early_stopping_rounds 使用

  1. 子采样与特征采样(防止过拟合)

通过随机采样样本或特征来增加模型的泛化能力。

  • subsample:每棵树训练使用的样本比例

    • 默认值:1

    • 建议范围:0.6 ~ 0.9

    • 值太低可能欠拟合,太高容易过拟合

  • colsample_bytree:每棵树随机选择的特征比例

    • 默认值:1

    • 建议范围:0.6 ~ 0.9

  1. 正则化参数(进一步防过拟合)

控制叶子权重的惩罚力度,提升模型稳定性。

  • lambda (reg_lambda):L2 正则项(权重平方惩罚)

    • 默认值:1

    • 建议范围:1 ~ 10

    • 增大能提升模型泛化性

  • alpha (reg_alpha):L1 正则项(稀疏化)

    • 默认值:0

    • 建议范围:0 ~ 10

    • 增大能进行特征筛选(部分特征权重归零)

python的xgboost库,示例如下。

import optuna
def obj_fun(trial):
    params = {
        'n_estimators':trial.suggest_int('n_estimators', 100, 300),
        'max_depth':trial.suggest_int('max_depth', 3, 10),
        'learning_rate':trial.suggest_float('learning_rate', 0.01, 0.05, log = True),
        'subsample': trial.suggest_float('subsample', 0.5, 1.0),
        'colsample_bytree': trial.suggest_float('colsample_bytree', 0.5, 1.0),
        'eval_metric': 'auc',
        'random_state': 42
    }
    # from xgboost.callback import EarlyStopping
    # early_stop_callback = EarlyStopping(rounds=50, metric_name='auc', save_best=True)
    # model = XGBClassifier(**params, callbacks=[early_stop_callback])
    model = XGBClassifier(**params, early_stopping_rounds=50)
    
    model.fit(
        X_train, y_train,
        eval_set = [(X_val, y_val)],
        verbose = False
    )
    
    y_pred_prob = model.predict_proba(X_val)[:,1]
    auc = roc_auc_score(y_val, y_pred_prob)
    return auc

study = optuna.create_study(
    direction="maximize",  # 我们希望最大化 AUC
    study_name="xgb_optuna_tuning"
)
study.optimize(obj_fun, n_trials=50, n_jobs=-1)

print("最优参数:", study.best_params)
print("最优 AUC:", study.best_value)

best_params = study.best_params
best_model = XGBClassifier(
    **best_params,
    eval_metric='auc',
    early_stopping_rounds=50,
    random_state=42
)
best_model.fit(X_trainval, y_trainval, verbose=True)
y_pred_prob = best_model.predict_proba(X_test)[:, 1]
auc = roc_auc_score(y_test, y_pred_prob)
print("Test AUC:", auc)