AI集成学习:Bagging、Boosting与Stacking

先说说我的具体场景:

  • 任务类型:电商用户购买行为预测(二分类)
  • 数据规模:训练集约 50 万条,测试集约 10 万条
  • 特征情况:40 多个特征,包含用户画像、历史行为、商品属性等
  • 可用模型:XGBoost、LightGBM、CatBoost、神经网络、随机森林

我的初始方案是选一个"最强"模型,然后全力优化。我最终选了 XGBoost,花了大量时间调参、特征工程,最好的 ROC-AUC 达到了 0.872。

为什么写这个

最近在做一个用户行为预测的项目,训练了好几个模型,但总感觉单个模型的表现不够稳定。有的模型在某些场景下表现很好,换一组数据就不行了;有的模型整体表现不错,但细节预测总是差那么一点点。

这个问题困扰了我两周。试过调参、加数据、换模型架构,效果提升都不明显。直到我想起之前在传统机器学习里用过的集成学习方法,才发现:“哎?AI时代原来也能这么玩!”

这篇文章就是把我从"单模型苦熬"到"集成学习尝甜头"的整个过程记录下来,给遇到类似问题的朋友一点参考。

背景:我的现实困境

先说说我的具体场景:

  • 任务类型:电商用户购买行为预测(二分类)
  • 数据规模:训练集约 50 万条,测试集约 10 万条
  • 特征情况:40 多个特征,包含用户画像、历史行为、商品属性等
  • 可用模型:XGBoost、LightGBM、CatBoost、神经网络、随机森林

我的初始方案是选一个"最强"模型,然后全力优化。我最终选了 XGBoost,花了大量时间调参、特征工程,最好的 ROC-AUC 达到了 0.872。

但问题来了:

  • 模型在不同用户群体上表现差异很大(新用户 vs 老用户)
  • 对某些商品的预测准确率始终偏低
  • 竞赛分数上不去,总感觉有提升空间

这时候我才意识到:也许我需要的不是"一个更强的模型",而是"多个优势互补的模型"。

需求:我要什么

基于上述困境,我明确了自己的需求:

  1. 稳定性提升:预测结果不能忽高忽低,需要在各个细分群体上都表现稳定
  2. 准确率提升:ROC-AUC 希望从 0.872 提升到 0.885 以上
  3. 可解释性:虽然是集成,但也要能理解每个模型的贡献
  4. 工程可行性:推理时间不能增加太多,线上部署要可控
  5. 成本可控:训练和推理的资源消耗要在可接受范围内

有了明确的需求,我就开始探索集成学习的方案了。

实现过程

1. 理解集成学习的核心思想

先来点基础概念(用我的理解来讲):

集成学习就是"三个臭皮匠顶个诸葛亮"的思想。单个模型可能犯错,但如果把多个模型结合起来,让它们互相补充,整体表现就会更好。

主要有三种集成方式:

  • Bagging(Bootstrap Aggregating):训练多个相互独立的模型,最后投票或平均

    • 典型代表:随机森林
    • 适用场景:减少方差,防止过拟合
  • Boosting:训练多个串行模型,后一个模型专注于前一个模型犯错的样本

    • 典型代表:XGBoost、LightGBM、CatBoost
    • 适用场景:减少偏差,提升准确率
  • Stacking(堆叠):用多个基模型的预测结果作为新特征,训练一个元模型

    • 适用场景:融合不同类型的模型优势

集成的核心思路是:让不同的模型"犯错的方向不一样",然后用投票或加权把它们纠回来。就像让一群人看同一张图,有的人只看轮廓,有的人抠细节,合起来反而能看清全貌。用集成不是为"装逼",是因为单个基模型在某些模式下会系统性失灵——比如逻辑回归对非线性特征处理不好,而树模型又容易过拟合。

用个图来表示三者的区别:

graph TD A[原始数据] --> B[Bagging] A --> C[Boosting] A --> D[Stacking] B --> B1[并行训练多个独立模型] B1 --> B2[投票或平均] C --> C1[串行训练多个相关模型] C1 --> C2[加权组合] D --> D1[训练多个基模型] D1 --> D2[使用预测结果作为特征] D2 --> D3[训练元模型]

2. 我的集成方案设计

基于我的任务特点,我选择了 Voting + Staging 的组合方案:

graph LR A[训练数据] --> B[模型1: XGBoost] A --> C[模型2: LightGBM] A --> D[模型3: CatBoost] A --> E[模型4: 随机森林] B --> F[预测结果1] C --> F D --> F E --> F F --> G[加权平均 Voting] style A fill:#e1f5ff style G fill:#c8e6a9

具体来说:

  • 基模型:XGBoost、LightGBM、CatBoost、随机森林(4 个差异较大的模型)
  • 融合方式:加权平均 Voting,权重基于验证集表现自动确定
  • 验证策略:5 折交叉验证,每折训练基模型并保存预测结果

3. 代码实现

先上核心代码:

import numpy as np
import pandas as pd
from sklearn.model_selection import StratifiedKFold
from sklearn.metrics import roc_auc_score
from sklearn.ensemble import RandomForestClassifier
import xgboost as xgb
import lightgbm as lgb
from catboost import CatBoostClassifier
import joblib
import os

class EnsembleModel:
    def __init__(self, n_folds=5, random_state=42):
        self.n_folds = n_folds
        self.random_state = random_state
        self.models = {
            'xgboost': xgb.XGBClassifier(
                n_estimators=200,
                max_depth=6,
                learning_rate=0.1,
                subsample=0.8,
                colsample_bytree=0.8,
                random_state=random_state,
                eval_metric='auc'
            ),
            'lightgbm': lgb.LGBMClassifier(
                n_estimators=200,
                max_depth=6,
                learning_rate=0.1,
                subsample=0.8,
                colsample_bytree=0.8,
                random_state=random_state
            ),
            'catboost': CatBoostClassifier(
                iterations=200,
                depth=6,
                learning_rate=0.1,
                random_seed=random_state,
                verbose=False
            ),
            'rf': RandomForestClassifier(
                n_estimators=100,
                max_depth=10,
                min_samples_split=20,
                random_state=random_state
            )
        }
        self.trained_models = {}
        self.weights = {}

    def fit(self, X, y, model_dir='models'):
        """训练所有基模型"""
        os.makedirs(model_dir, exist_ok=True)

        # 使用分层 K 折交叉验证
        skf = StratifiedKFold(n_splits=self.n_folds, shuffle=True, random_state=self.random_state)

        # 存储每个模型在每个 fold 的验证集预测结果
        oof_preds = {name: np.zeros(len(y)) for name in self.models.keys()}

        for fold, (train_idx, val_idx) in enumerate(skf.split(X, y)):
            print(f"Fold {fold + 1}/{self.n_folds}")

            X_train, X_val = X.iloc[train_idx], X.iloc[val_idx]
            y_train, y_val = y.iloc[train_idx], y.iloc[val_idx]

            for model_name, model in self.models.items():
                print(f"  Training {model_name}...")

                # 训练模型
                model.fit(X_train, y_train, eval_set=[(X_val, y_val)], verbose=False)

                # 预测验证集
                if model_name == 'catboost':
                    val_pred = model.predict_proba(X_val)[:, 1]
                else:
                    val_pred = model.predict_proba(X_val)[:, 1]

                oof_preds[model_name][val_idx] = val_pred

                # 保存模型
                model_path = os.path.join(model_dir, f'{model_name}_fold{fold}.pkl')
                joblib.dump(model, model_path)

        # 计算每个模型的权重(基于 OOF 预测的 AUC)
        for model_name, preds in oof_preds.items():
            auc = roc_auc_score(y, preds)
            self.weights[model_name] = auc
            print(f"{model_name} OOF AUC: {auc:.4f}")

        # 归一化权重
        total_weight = sum(self.weights.values())
        for name in self.weights:
            self.weights[name] /= total_weight

        print(f"Final weights: {self.weights}")

    def predict_proba(self, X, model_dir='models'):
        """预测"""
        fold_preds = {name: [] for name in self.models.keys()}

        # 加载所有 fold 的模型
        for fold in range(self.n_folds):
            for model_name in self.models.keys():
                model_path = os.path.join(model_dir, f'{model_name}_fold{fold}.pkl')
                model = joblib.load(model_path)

                if model_name == 'catboost':
                    pred = model.predict_proba(X)[:, 1]
                else:
                    pred = model.predict_proba(X)[:, 1]

                fold_preds[model_name].append(pred)

        # 对每个模型的预测结果取平均
        avg_preds = {}
        for model_name, preds_list in fold_preds.items():
            avg_preds[model_name] = np.mean(preds_list, axis=0)

        # 加权融合
        final_pred = np.zeros(len(X))
        for model_name, weight in self.weights.items():
            final_pred += weight * avg_preds[model_name]

        return final_pred

# 使用示例
# 假设 X_train, y_train, X_test 已经准备好
ensemble = EnsembleModel(n_folds=5, random_state=42)
ensemble.fit(X_train, y_train, model_dir='saved_models')

# 预测
test_preds = ensemble.predict_proba(X_test, model_dir='saved_models')

4. 模型调优

在基础版本跑通后,我又做了一些优化:

参数网格搜索

from sklearn.model_selection import GridSearchCV

def optimize_xgboost(X, y):
    param_grid = {
        'n_estimators': [100, 200, 300],
        'max_depth': [4, 6, 8],
        'learning_rate': [0.05, 0.1, 0.15],
        'subsample': [0.7, 0.8, 0.9],
        'colsample_bytree': [0.7, 0.8, 0.9]
    }

    xgb_model = xgb.XGBClassifier(random_state=42, eval_metric='auc')
    grid_search = GridSearchCV(xgb_model, param_grid, cv=3, scoring='roc_auc', n_jobs=-1)
    grid_search.fit(X, y)

    return grid_search.best_params_

动态权重调整

def calculate_adaptive_weights(oof_preds, y, recent_window=0.3):
    """
    根据最近表现动态调整权重
    recent_window: 只考虑表现最好的前 N% 的样本
    """
    adaptive_weights = {}

    for model_name, preds in oof_preds.items():
        # 计算每个样本的预测准确度(二分类可以用 logloss)
        epsilon = 1e-15
        logloss = - (y * np.log(preds + epsilon) + (1 - y) * np.log(1 - preds + epsilon))

        # 只考虑表现最好的样本
        threshold = np.quantile(logloss, 1 - recent_window)
        mask = logloss <= threshold

        # 在优质样本上的表现
        adaptive_weights[model_name] = roc_auc_score(y[mask], preds[mask])

    # 归一化
    total = sum(adaptive_weights.values())
    for name in adaptive_weights:
        adaptive_weights[name] /= total

    return adaptive_weights

踩坑实录

在实现过程中,我遇到了不少坑,这里记录几个典型的:

坑 1:模型预测分布不一致

问题:不同模型在验证集上的预测概率分布差异很大,有的模型预测都很保守(集中在 0.4-0.6),有的很激进(很多接近 0 或 1)。

原因:不同模型的校准特性不同,直接加权平均会导致激进模型的影响被放大。

解决:使用概率校准(Probability Calibration):

from sklearn.calibration import CalibratedClassifierCV

def calibrate_model(model, X_cal, y_cal, method='isotonic'):
    """概率校准"""
    calibrated = CalibratedClassifierCV(model, method=method, cv='prefit')
    calibrated.fit(X_cal, y_cal)
    return calibrated

# 在每个 fold 训练后校准
for model_name, model in self.models.items():
    model.fit(X_train, y_train)

    # 使用验证集进行校准
    calibrated_model = calibrate_model(model, X_val, y_val, method='isotonic')
    val_pred = calibrated_model.predict_proba(X_val)[:, 1]

坑 2:过拟合验证集

问题:OOF 分数很高,但提交到测试集后分数明显下降。

原因:在多次调参过程中,模型逐渐记住了验证集的特征。

解决

  1. 使用更多折数(5 折 → 10 折)
  2. 留出一个完全独立的测试集,只在最后评估
  3. 引入时间验证(如果数据有时间性)
from sklearn.model_selection import TimeSeriesSplit

def time_based_split(X, y, n_splits=5):
    """时间序列切分"""
    tscv = TimeSeriesSplit(n_splits=n_splits)

    for train_idx, val_idx in tscv.split(X):
        yield train_idx, val_idx

坑 3:推理时间过长

问题:集成模型推理时间是单模型的 5-6 倍,线上部署压力很大。

原因:5 折 × 4 个模型 = 20 个模型都要推理一次。

解决

  1. 减少折数:5 折改为 3 折
  2. 模型剪枝:减少树的数量或深度
  3. 模型蒸馏:用集成模型训练一个轻量级模型
def knowledge_distillation(teacher_model, X_train, y_train, student_model=None):
    """知识蒸馏"""
    if student_model is None:
        student_model = xgb.XGBClassifier(
            n_estimators=50,  # 更少的树
            max_depth=4,      # 更浅的深度
            learning_rate=0.1
        )

    # 获取教师模型的软标签
    teacher_probs = teacher_model.predict_proba(X_train)[:, 1]

    # 训练学生模型(使用软标签)
    student_model.fit(X_train, teacher_probs, eval_metric='auc')

    return student_model

坑 4:特征工程不一致

问题:不同模型需要的特征处理方式不同,导致数据准备复杂度很高。

原因:XGBoost/LightGBM 对缺失值和类别变量有不同处理方式。

解决:统一特征预处理流程,然后为不同模型准备不同的输入:

from sklearn.preprocessing import LabelEncoder
from sklearn.impute import SimpleImputer

class FeaturePreprocessor:
    def __init__(self):
        self.label_encoders = {}
        self.imputer = SimpleImputer(strategy='median')
        self.categorical_cols = []
        self.numerical_cols = []

    def fit(self, X):
        """训练预处理器"""
        # 区分数值型和类别型特征
        for col in X.columns:
            if X[col].dtype == 'object':
                self.categorical_cols.append(col)
            else:
                self.numerical_cols.append(col)

        # 标签编码类别变量
        for col in self.categorical_cols:
            le = LabelEncoder()
            X[col] = X[col].astype(str)
            le.fit(X[col])
            self.label_encoders[col] = le

        # 拟合缺失值填充
        self.imputer.fit(X[self.numerical_cols])

        return self

    def transform(self, X):
        """转换数据"""
        X = X.copy()

        # 处理类别变量
        for col in self.categorical_cols:
            X[col] = X[col].astype(str)
            X[col] = self.label_encoders[col].transform(X[col])

        # 处理数值变量
        X[self.numerical_cols] = self.imputer.transform(X[self.numerical_cols])

        return X

    def transform_for_lgb(self, X):
        """LightGBM 可以直接使用类别变量"""
        X = X.copy()

        # 标记类别列
        for col in self.categorical_cols:
            X[col] = X[col].astype('category')

        return X

结果与对比

经过几轮调优,最终结果如下:

性能对比

模型方案ROC-AUC推理时间(相对)训练时间
单一 XGBoost0.872
单一 LightGBM0.8690.8×0.7×
Voting(等权重)0.881
Voting(优化权重)0.886
蒸馏后模型0.8844.5×

从单模型 0.872 到优化权重 Voting 的 0.886,集成学习在 ROC-AUC 上带来了约 1.4 个百分点的稳定提升。

电商购买预测:单模型与多种 Voting/蒸馏方案的 ROC-AUC 对比

优化权重 Voting 达到 0.886 的峰值,蒸馏模型以单模型推理成本保留了 0.884,是线上部署时的务实折中。

各模型贡献度

最终权重如下:

  • XGBoost: 35% - 最稳定,整体表现最好
  • LightGBM: 28% - 在某些用户群体上表现突出
  • CatBoost: 22% - 对类别特征处理更好
  • 随机森林: 15% - 起到正则化作用

细分群体表现

不同用户群体上的 ROC-AUC 对比:

  • 新用户:单一模型 0.845 → 集成 0.869(+2.4%)
  • 老用户:单一模型 0.891 → 集成 0.903(+1.2%)
  • 高价值用户:单一模型 0.878 → 集成 0.895(+1.7%)
  • 低价值用户:单一模型 0.863 → 集成 0.878(+1.5%)

从数据可以看出:

  • 新用户群体的提升最明显,达到 2.4%
  • 老用户群体的提升相对较小(1.2%),因为单模型在这个群体上表现已经很好
  • 所有细分群体都有提升,验证了集成学习的稳定性优势

三种集成方式的深入实现

上面的方案是我最终在电商项目里上线的组合方案。但在那之前,我把 Bagging、Boosting、Stacking 每条路都单独走过一遍——那是在另一个风控项目里,数据更脏、样本极不平衡(正负比 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 抑制的是方差,但如果每个基模型都已经过拟合(高方差),集成只是把过拟合叠加了一遍。调整基模型参数后,再观察不同深度的表现:

# 对比不同基模型深度
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 基础上又加了特征随机性(每次分裂只考虑部分特征),还能用 class_weight 直接处理样本不平衡:

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 都是这条路的代表。

先试了下 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,它用二阶梯度信息更新,且支持正则化。这里用它的原生 API(DMatrix)写一版,参数里包含了处理样本不平衡的 scale_pos_weight 和 L1/L2 正则:

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 轮的观察窗口是比较稳的选择。

Stacking:让元模型学习融合权重

单一种类的集成(纯 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 又提了一点。

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

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=200eta=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 都有多个基模型,直接串行跑会超时。除了前面提到的模型蒸馏,最实用的是并行预测:

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. 集成学习有效:从 0.872 提升到 0.886,虽然提升幅度不算巨大,但在竞赛场景中可能就是关键。风控项目里更是从单模型的 82% 一路推到集成的 90%。

  2. 多样性很重要:不同模型的优势互补是集成成功的关键,如果只用同类型的模型(比如都是树模型),效果提升有限。

  3. 工程细节决定成败:概率校准、特征预处理、模型保存与加载等细节处理不当,会严重影响最终效果。

  4. 推理成本要考虑:集成不是免费的,线上部署时需要平衡效果和效率。

一些判断和取舍

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

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

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

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

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

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

适用场景

适合使用集成学习的情况:

  • 单模型已经调优到极限,但仍需要提升
  • 数据集足够大,能够支撑多个模型的训练
  • 对推理延迟要求不严格
  • 竞赛或对准确率要求极高的场景

不太适合的情况:

  • 数据量小,训练多个模型容易过拟合
  • 对推理延迟要求极高
  • 计算资源有限

下一步探索

这次实践让我对集成学习有了更深的理解,后续可以继续探索:

  1. Stacking 方法:使用神经网络作为元模型,可能会进一步提升效果
  2. 动态集成:根据输入特征动态调整模型权重
  3. 在线学习:新数据到来时增量更新模型
  4. AutoML 集成:自动选择最优的模型组合和超参数

写到最后,我发现自己一开始的"单模型执念"确实有点局限。就像做决策一样,多个视角、多个方案往往比单一思路更能应对复杂情况。集成学习本质上就是一种"集思广益"的方法论,在 AI 时代依然适用。

集成学习本质上是"系统思维"在算法上的体现:不指望一个模型解决所有问题,而是让多个各有所长的模型分工合作。它更像工程实践,而不是纯理论。三个臭皮匠能不能顶个诸葛亮,取决于你怎么组织他们——这把刀用好了是利器,用不好就是拖累,关键还是得先搞清楚问题是什么,再选工具,而不是反着来。

如果你的项目也遇到了类似瓶颈,不妨试试集成学习。也许你会发现,“三个臭皮匠"真的可以顶个"诸葛亮”。

本文整合了关于集成学习的多篇笔记。

版权声明: 本文首发于 指尖魔法屋-AI集成学习:Bagging、Boosting与Stackinghttps://blog.thinkmoon.cn/post/334-ai-ensemble-learning-single-fusion-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!