集成学习踩坑记录

那个风控项目的数据特点很典型:

  • 样本不平衡:正负样本 1:20,严重偏斜
  • 特征缺失:30 多个字段里有 10 个经常为空
  • 噪声点多:用户填写的地址、职业随意性很大

为什么用集成

先说说场景。那个风控项目的数据特点很典型:

  • 样本不平衡:正负样本 1:20,严重偏斜
  • 特征缺失:30 多个字段里有 10 个经常为空
  • 噪声点多:用户填写的地址、职业随意性很大

单模型的表现:

模型AUC准确率训练时间
逻辑回归0.790.812s
随机森林0.840.8215s
XGBoost0.850.838s

每个模型都差一点。用集成不是为"装逼",是因为单个基模型在某些模式下会系统性失灵——逻辑回归对非线性特征处理不好,树模型又容易过拟合。

集成的核心思路是:让不同的模型"犯错的方向不一样",然后用投票或加权把它们纠回来。就像让一群人看同一张图,有的人只看轮廓,有的人抠细节,合起来反而能看清全貌。

Bagging:并行投票这条路

Bagging(Bootstrap Aggregating)是最直白的集成方式:用 bootstrap 抽样训练多个独立模型,然后投票。随机森林就是 Bagging 的经典代表。

基础实现

先用 scikit-learn 的 BaggingClassifier 试了一下:

from sklearn.ensemble import BaggingClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, roc_auc_score
import pandas as pd

# 读取数据(假设已经做了预处理)
df = pd.read_csv('/home/liqinsi/data/risk_control_data.csv')
X = df.drop('label', axis=1)
y = df['label']

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)

# 基模型用决策树
base_clf = DecisionTreeClassifier(max_depth=5, min_samples_split=10)

# Bagging 集成
bagging_clf = BaggingClassifier(
    estimator=base_clf,
    n_estimators=100,
    max_samples=0.8,
    max_features=0.8,
    random_state=42,
    n_jobs=-1
)

bagging_clf.fit(X_train, y_train)
y_pred = bagging_clf.predict(X_test)
y_proba = bagging_clf.predict_proba(X_test)[:, 1]

print(f"Accuracy: {accuracy_score(y_test, y_pred):.4f}")
print(f"AUC: {roc_auc_score(y_test, y_proba):.4f}")

跑出来的结果是准确率 85.3%,AUC 0.876。比单树模型(0.835)确实提升了。

踩的第一个坑:基模型没控制好

第一次跑的时候没限制 max_depth,每个决策树都长到 20 层以上,结果 Bagging 的表现居然比单树还差。后来想明白了:Bagging 抑制的是方差,但如果每个基模型都已经过拟合(高方差),集成只是把过拟合叠加了一遍。

调整基模型参数后,再观察 Bagging 的表现:

# 对比不同基模型深度
for depth in [3, 5, 8, 12, None]:
    base = DecisionTreeClassifier(max_depth=depth, random_state=42)
    bagging = BaggingClassifier(
        estimator=base,
        n_estimators=100,
        max_samples=0.8,
        random_state=42,
        n_jobs=-1
    )
    bagging.fit(X_train, y_train)
    auc = roc_auc_score(y_test, bagging.predict_proba(X_test)[:, 1])
    print(f"Depth {depth}: AUC = {auc:.4f}")

输出:

Depth 3: AUC = 0.842
Depth 5: AUC = 0.876
Depth 8: AUC = 0.871
Depth 12: AUC = 0.865
Depth None: AUC = 0.848

深度 5 时效果最好。这个点不是"调参经验",而是观察:基模型不能太简单(欠拟合),也不能太复杂(过拟合),得让它"有信息量但又没把数据背下来"。

随机森林的更优版本

后来直接用了 RandomForestClassifier,它在 Bagging 基础上又加了特征随机性(每次分裂只考虑部分特征):

from sklearn.ensemble import RandomForestClassifier

rf_clf = RandomForestClassifier(
    n_estimators=200,
    max_depth=6,
    min_samples_split=10,
    min_samples_leaf=5,
    max_features='sqrt',
    random_state=42,
    n_jobs=-1,
    class_weight='balanced'  # 处理样本不平衡
)

rf_clf.fit(X_train, y_train)
y_pred = rf_clf.predict(X_test)
y_proba = rf_clf.predict_proba(X_test)[:, 1]

print(f"Random Forest - Accuracy: {accuracy_score(y_test, y_pred):.4f}")
print(f"Random Forest - AUC: {roc_auc_score(y_test, y_proba):.4f}")

这次 AUC 拉到了 0.884,比手写 Bagging 又提了一点。随机森林的好处是调参相对少、对脏数据容忍度高,适合"先上来看看效果"的阶段。

Boosting:串行纠错

Bagging 的思路是"并行投票",Boosting 走的是"串行纠错"路线:后一个模型专门学前一个模型没学对的东西。XGBoost、LightGBM、CatBoost 都是这条路的代表。

从 AdaBoost 开始

先试了下 AdaBoostClassifier,理解原理最快:

from sklearn.ensemble import AdaBoostClassifier

ada_clf = AdaBoostClassifier(
    estimator=DecisionTreeClassifier(max_depth=3),
    n_estimators=100,
    learning_rate=0.1,
    random_state=42
)

ada_clf.fit(X_train, y_train)
y_pred = ada_clf.predict(X_test)
y_proba = ada_clf.predict_proba(X_test)[:, 1]

print(f"AdaBoost - Accuracy: {accuracy_score(y_test, y_pred):.4f}")
print(f"AdaBoost - AUC: {roc_auc_score(y_test, y_proba):.4f}")

AdaBoost 的表现一般(AUC 0.842),但它的过程很有观察价值:可以看到每个基模型的权重变化,理解哪些样本一直被误判。这对后续分析 hard cases 有帮助。

XGBoost 的实际效果

真正上线的是 XGBoost,它用二阶梯度信息更新,且支持正则化:

import xgboost as xgb

# 转换成 DMatrix 格式(XGBoost 的专用数据结构)
dtrain = xgb.DMatrix(X_train, label=y_train)
dtest = xgb.DMatrix(X_test, label=y_test)

params = {
    'objective': 'binary:logistic',
    'eval_metric': 'auc',
    'max_depth': 6,
    'eta': 0.1,  # 学习率
    'subsample': 0.8,
    'colsample_bytree': 0.8,
    'min_child_weight': 5,
    'gamma': 0.1,
    'lambda': 1.5,  # L2 正则化
    'alpha': 0.5,   # L1 正则化
    'scale_pos_weight': 20,  # 处理样本不平衡
    'seed': 42
}

watchlist = [(dtrain, 'train'), (dtest, 'eval')]

# 训练
xgb_model = xgb.train(
    params,
    dtrain,
    num_boost_round=500,
    evals=watchlist,
    early_stopping_rounds=30,
    verbose_eval=50
)

# 预测
y_proba = xgb_model.predict(dtest)
y_pred = (y_proba > 0.5).astype(int)

print(f"XGBoost - Accuracy: {accuracy_score(y_test, y_pred):.4f}")
print(f"XGBoost - AUC: {roc_auc_score(y_test, y_proba):.4f}")

XGBoost 的 AUC 达到了 0.891,比随机森林又提了 0.007。别小看这 0.007,在金融场景里可能对应几百万的风险金额。

踩的第二个坑:early_stopping 设置太激进

一开始 early_stopping_rounds 设成了 10,结果模型在第 80 轮就停了,AUC 只有 0.875。后来改成 30,让模型多跑几轮再判断是否真的收敛。

Boosting 的特点是迭代次数不能太少(欠拟合),也不能太多(过拟合)。early_stopping 就是帮你找到这个平衡点,但阈值得根据数据规模和噪声水平调整。对于 10 万样本、特征脏的数据,30 轮的观察窗口是比较稳的选择。

模型融合:把不同思路组合起来

单一种类的集成(纯 Bagging 或纯 Boosting)有时不够。比如随机森林擅长处理缺失值和噪声,XGBoost 擅长捕捉非线性关系,把它们融合起来,效果可能更好。

简单平均投票

最直接的是把几个模型预测值取平均:

from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler

# 训练不同模型
rf_clf.fit(X_train, y_train)
xgb_model = xgb.train(params, dtrain, num_boost_round=500, evals=watchlist, early_stopping_rounds=30, verbose_eval=False)
lr_clf = LogisticRegression(max_iter=1000, class_weight='balanced')
lr_clf.fit(X_train, y_train)

# 获取预测概率
rf_proba = rf_clf.predict_proba(X_test)[:, 1]
xgb_proba = xgb_model.predict(dtest)
lr_proba = lr_clf.predict_proba(X_test)[:, 1]

# 简单平均
ensemble_proba = (rf_proba + xgb_proba + lr_proba) / 3
ensemble_pred = (ensemble_proba > 0.5).astype(int)

print(f"Ensemble (Average) - Accuracy: {accuracy_score(y_test, ensemble_pred):.4f}")
print(f"Ensemble (Average) - AUC: {roc_auc_score(y_test, ensemble_proba):.4f}")

简单平均的 AUC 是 0.895,比单独 XGBoost 又提了一点。

用 Stacker 学习权重

平均投票假设每个模型等权重,但实际中它们的可靠程度不同。可以用另一个模型(meta-learner)来学习权重:

import numpy as np
from sklearn.model_selection import KFold

# 用 K-Fold 生成训练集的 meta 特征(避免过拟合)
kf = KFold(n_splits=5, shuffle=True, random_state=42)
meta_train = np.zeros((X_train.shape[0], 3))
meta_test = np.zeros((X_test.shape[0], 3))

for i, (train_idx, val_idx) in enumerate(kf.split(X_train)):
    X_tr, X_val = X_train.iloc[train_idx], X_train.iloc[val_idx]
    y_tr, y_val = y_train.iloc[train_idx], y_train.iloc[val_idx]

    # 训练三个基模型
    rf = RandomForestClassifier(max_depth=6, n_estimators=100, random_state=42, n_jobs=-1)
    rf.fit(X_tr, y_tr)
    meta_train[val_idx, 0] = rf.predict_proba(X_val)[:, 1]

    dtrain_fold = xgb.DMatrix(X_tr, label=y_tr)
    dval_fold = xgb.DMatrix(X_val)
    xgb_fold = xgb.train(params, dtrain_fold, num_boost_round=200, verbose_eval=False)
    meta_train[val_idx, 1] = xgb_fold.predict(dval_fold)

    lr = LogisticRegression(max_iter=1000, class_weight='balanced')
    lr.fit(X_tr, y_tr)
    meta_train[val_idx, 2] = lr.predict_proba(X_val)[:, 1]

    # 在测试集上预测
    meta_test[:, 0] += rf.predict_proba(X_test)[:, 1]
    meta_test[:, 1] += xgb_fold.predict(dtest)
    meta_test[:, 2] += lr.predict_proba(X_test)[:, 1]

meta_test /= 5  # 取平均

# 用 meta-learner 学习权重
meta_learner = LogisticRegression()
meta_learner.fit(meta_train, y_train)

final_proba = meta_learner.predict_proba(meta_test)[:, 1]
final_pred = (final_proba > 0.5).astype(int)

print(f"Stacking - Accuracy: {accuracy_score(y_test, final_pred):.4f}")
print(f"Stacking - AUC: {roc_auc_score(y_test, final_proba):.4f}")
print(f"Meta-learner weights: {meta_learner.coef_}")

Stacking 的 AUC 达到了 0.902,是所有方法里最高的。meta-learner 的权重大概是 [0.3, 0.5, 0.2],说明 XGBoost 的预测最可靠,逻辑回归最弱,但三个都有贡献。

踩的第三个坑:过拟合 meta-learner

一开始直接用测试集的概率训练 meta-learner,结果训练集 AUC 0.99,测试集只有 0.88。后来才反应过来:这实际上是把测试集的信息"偷"过来了,导致无法泛化。

用 K-Fold 生成 meta 特征是标准做法,保证 meta-learner 从未见过的数据是分布相似的。这个过程要多花点时间,但能防止伪高收益。

性能优化与工程实践

模型效果好了,但上线时还有一堆工程问题要解决。这里整理下遇到的坑和应对。

训练速度优化

XGBoost 训练 500 轮大约要 2 分钟,生产环境重训时有点慢。优化方向:

  1. 调大 n_jobs:CPU 核数多时可以并行构造特征
  2. hist 树方法:比 exact 快几倍
  3. 减少迭代次数 + 提大学习率:比如 num_boost_round=200, eta=0.3
# 快速训练版本
params_fast = params.copy()
params_fast['tree_method'] = 'hist'
params_fast['eta'] = 0.3

xgb_model_fast = xgb.train(
    params_fast,
    dtrain,
    num_boost_round=200,
    evals=watchlist,
    early_stopping_rounds=20,
    verbose_eval=False
)

训练时间降到 30 秒左右,AUC 几乎没变(0.890)。

推理延迟优化

生产环境要求单个预测在 10ms 内完成。Bagging 和 Boosting 都有多个基模型,直接串行跑会超时。解决思路:

  1. 并行预测:用 n_jobs=-1 多线程
  2. 剪枝模型:减少 n_estimators 但调整学习率补偿
  3. 模型蒸馏:用一个大模型教一个小模型

简单并行最实用:

from joblib import Parallel, delayed

def predict_single(model, X):
    return model.predict_proba(X)[:, 1]

# 并行预测多个模型
probabilities = Parallel(n_jobs=4)(
    delayed(predict_single)(clf, X_test) for clf in bagging_clf.estimators_
)
ensemble_proba = np.mean(probabilities, axis=0)

单次预测延迟从 25ms 降到 8ms。

模型监控

集成模型上线后,要盯着两件事:

  1. 基模型的一致性:如果某个基模型的表现突然下降,可能是数据漂移
  2. 预测分布的变化:Boosting 模型对 hard cases 很敏感,它们的占比变化往往意味着问题
# 监控各基模型在验证集上的表现
base_scores = []
for i, estimator in enumerate(bagging_clf.estimators_):
    pred = estimator.predict_proba(X_test)[:, 1]
    auc = roc_auc_score(y_test, pred)
    base_scores.append(auc)

print(f"Base model AUCs: mean={np.mean(base_scores):.4f}, std={np.std(base_scores):.4f}")
print(f"Worst base model: {np.min(base_scores):.4f}, best base model: {np.max(base_scores):.4f}")

如果标准差突然变大,说明基模型不一致了,可能需要重训。

一些判断和取舍

这段时间折腾下来,对集成学习有几个相对稳定的判断:

  1. 集成不是万能药:如果单模型表现已经很好,集成能带来的提升有限。反而是那些"明显有缺陷"的模型,集成后改善空间更大。

  2. Bagging 和 Boosting 选型看数据:Bagging 对噪声、缺失值容忍度高,适合"脏数据";Boosting 能榨干数据里的信息,但容易过拟合,适合"干净数据"。

  3. 工程成本要算进去:Stacking 效果最好,但训练和推理都慢。如果资源有限,随机森林 + XGBoost 的简单平均可能性价比更高。

  4. 解释性会变差:单个树模型还能画出特征重要性,集成后就很难说了。如果业务方要"为什么拒绝这个用户",集成模型会很难解释。

  5. 过拟合要警惕:Stacking 的 meta-learner、Boosting 的迭代次数、Bagging 的基模型数量,调多了都会过拟合。用验证集是唯一靠谱的方式。

结尾

集成学习本质上是"系统思维"在算法上的体现:不指望一个模型解决所有问题,而是让多个各有所长的模型分工合作。它更像工程实践,而不是纯理论。

做这个风控项目时,从单模型的 82% 到集成的 90%,中间走了不少弯路。但回头看,那些踩坑的经历反而更有价值——调参的直觉、对数据特征的理解、对模型边界的把握,都是没法从教科书里直接抄来的。

三个臭皮匠能不能顶个诸葛亮,取决于你怎么组织他们。集成学习这把刀,用好了是利器,用不好就是拖累。关键还是得先搞清楚问题是什么,再选工具,而不是反着来。

可用性说明:本文发布于 2020 年 3 月,距今已超过五年。文中涉及的软件版本、接口、下载地址、命令参数和操作界面可能已经发生变化,部分方案在当前环境下可能失效。请结合官方最新文档核对后再操作,生产环境使用前务必先行验证。

版权声明: 本文首发于 指尖魔法屋-集成学习踩坑记录https://blog.thinkmoon.cn/post/999-ensemble-learning-bagging-boosting-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!