AI集成学习:单一不够用了之后
先说说我的具体场景:
- 任务类型:电商用户购买行为预测(二分类)
- 数据规模:训练集约 50 万条,测试集约 10 万条
- 特征情况:40 多个特征,包含用户画像、历史行为、商品属性等
- 可用模型:XGBoost、LightGBM、CatBoost、神经网络、随机森林
我的初始方案是选一个"最强"模型,然后全力优化。我最终选了 XGBoost,花了大量时间调参、特征工程,最好的 ROC-AUC 达到了 0.872。
为什么写这个
最近在做一个用户行为预测的项目,训练了好几个模型,但总感觉单个模型的表现不够稳定。有的模型在某些场景下表现很好,换一组数据就不行了;有的模型整体表现不错,但细节预测总是差那么一点点。
这个问题困扰了我两周。试过调参、加数据、换模型架构,效果提升都不明显。直到我想起之前在传统机器学习里用过的集成学习方法,才发现:“哎?AI时代原来也能这么玩!”
这篇文章就是把我从"单模型苦熬"到"集成学习尝甜头"的整个过程记录下来,给遇到类似问题的朋友一点参考。
背景:我的现实困境
先说说我的具体场景:
- 任务类型:电商用户购买行为预测(二分类)
- 数据规模:训练集约 50 万条,测试集约 10 万条
- 特征情况:40 多个特征,包含用户画像、历史行为、商品属性等
- 可用模型:XGBoost、LightGBM、CatBoost、神经网络、随机森林
我的初始方案是选一个"最强"模型,然后全力优化。我最终选了 XGBoost,花了大量时间调参、特征工程,最好的 ROC-AUC 达到了 0.872。
但问题来了:
- 模型在不同用户群体上表现差异很大(新用户 vs 老用户)
- 对某些商品的预测准确率始终偏低
- 竞赛分数上不去,总感觉有提升空间
这时候我才意识到:也许我需要的不是"一个更强的模型",而是"多个优势互补的模型"。
需求:我要什么
基于上述困境,我明确了自己的需求:
- 稳定性提升:预测结果不能忽高忽低,需要在各个细分群体上都表现稳定
- 准确率提升:ROC-AUC 希望从 0.872 提升到 0.885 以上
- 可解释性:虽然是集成,但也要能理解每个模型的贡献
- 工程可行性:推理时间不能增加太多,线上部署要可控
- 成本可控:训练和推理的资源消耗要在可接受范围内
有了明确的需求,我就开始探索集成学习的方案了。
实现过程
1. 理解集成学习的核心思想
先来点基础概念(用我的理解来讲):
集成学习就是"三个臭皮匠顶个诸葛亮"的思想。单个模型可能犯错,但如果把多个模型结合起来,让它们互相补充,整体表现就会更好。
主要有三种集成方式:
Bagging(Bootstrap Aggregating):训练多个相互独立的模型,最后投票或平均
- 典型代表:随机森林
- 适用场景:减少方差,防止过拟合
Boosting:训练多个串行模型,后一个模型专注于前一个模型犯错的样本
- 典型代表:XGBoost、LightGBM、CatBoost
- 适用场景:减少偏差,提升准确率
Stacking(堆叠):用多个基模型的预测结果作为新特征,训练一个元模型
- 适用场景:融合不同类型的模型优势
用个图来表示三者的区别:
2. 我的集成方案设计
基于我的任务特点,我选择了 Voting + Staging 的组合方案:
具体来说:
- 基模型: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 分数很高,但提交到测试集后分数明显下降。
原因:在多次调参过程中,模型逐渐记住了验证集的特征。
解决:
- 使用更多折数(5 折 → 10 折)
- 留出一个完全独立的测试集,只在最后评估
- 引入时间验证(如果数据有时间性)
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 个模型都要推理一次。
解决:
- 减少折数:5 折改为 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 | 推理时间(相对) | 训练时间 |
|---|---|---|---|
| 单一 XGBoost | 0.872 | 1× | 1× |
| 单一 LightGBM | 0.869 | 0.8× | 0.7× |
| Voting(等权重) | 0.881 | 4× | 4× |
| Voting(优化权重) | 0.886 | 4× | 4× |
| 蒸馏后模型 | 0.884 | 1× | 4.5× |
从单模型 0.872 到优化权重 Voting 的 0.886,集成学习在 ROC-AUC 上带来了约 1.4 个百分点的稳定提升。

优化权重 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%),因为单模型在这个群体上表现已经很好
- 所有细分群体都有提升,验证了集成学习的稳定性优势
总结与建议
关键收获
集成学习有效:从 0.872 提升到 0.886,虽然提升幅度不算巨大,但在竞赛场景中可能就是关键。
多样性很重要:不同模型的优势互补是集成成功的关键,如果只用同类型的模型(比如都是树模型),效果提升有限。
工程细节决定成败:概率校准、特征预处理、模型保存与加载等细节处理不当,会严重影响最终效果。
推理成本要考虑:集成不是免费的,线上部署时需要平衡效果和效率。
适用场景
适合使用集成学习的情况:
- 单模型已经调优到极限,但仍需要提升
- 数据集足够大,能够支撑多个模型的训练
- 对推理延迟要求不严格
- 竞赛或对准确率要求极高的场景
不太适合的情况:
- 数据量小,训练多个模型容易过拟合
- 对推理延迟要求极高
- 计算资源有限
下一步探索
这次实践让我对集成学习有了更深的理解,后续可以继续探索:
- Stacking 方法:使用神经网络作为元模型,可能会进一步提升效果
- 动态集成:根据输入特征动态调整模型权重
- 在线学习:新数据到来时增量更新模型
- AutoML 集成:自动选择最优的模型组合和超参数
写到最后,我发现自己一开始的"单模型执念"确实有点局限。就像做决策一样,多个视角、多个方案往往比单一思路更能应对复杂情况。集成学习本质上就是一种"集思广益"的方法论,在 AI 时代依然适用。
如果你的项目也遇到了类似瓶颈,不妨试试集成学习。也许你会发现,“三个臭皮匠"真的可以顶个"诸葛亮”。
版权声明: 本文首发于 指尖魔法屋-AI集成学习:单一不够用了之后(https://blog.thinkmoon.cn/post/334-ai-ensemble-learning-single-fusion-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。