一次让我失眠的调参经历
2024年初,我接了一个银行信贷风控项目。数据是典型的类别不平衡二分类——12万样本,9万负样本,3万正样本,47个特征,其中12个是类别特征(比如职业编码、地域编码),35个是数值特征。
我当时的做法很粗暴:直接把三个模型用默认参数跑了一遍。结果让我怀疑人生——LightGBM的AUC是0.734,CatBoost是0.718,XGBoost只有0.687,还没我用逻辑回归跑出的0.71高。更离谱的是LightGBM在训练集上表现完美(AUC 0.997),在验证集上一塌糊涂,这是典型的过拟合。
后来花了整整一周做调参,把三套模型的AUC分别提到了0.871、0.868和0.853。我把这三套参数的调参笔记、踩过的坑、过程性数据和可复现的代码全部整理出来。这篇文章不是给你一个「最优参数推荐」,而是告诉你每个参数背后到底在干什么。
三个模型的核心差异必须知道
不搞懂原理,你调参就是瞎猜。但原理部分我压缩到最精简,只说跟调参直接相关的内容。
XGBoost:Level-wise逐层分裂
XGBoost预排序(Pre-sorted)的算法会在每个节点枚举所有可能的特征分裂点,然后按层生长决策树。好处是能精确找到最优分割点,缺点是需要频繁扫描数据,内存占用高,训练速度慢。
它支持两种分裂策略:exact(精确贪心)和approx(近似直方图)。近似策略会把连续特征分桶,减少候选分裂点数量。
调参核心就是控制树的复杂度。深度、叶子节点最小样本数、学习率、子采样比例——这四个是灵魂。
LightGBM:Histogram直方图 + Leaf-wise叶子分裂
LightGBM把连续特征离散化为K个bin(默认255),统计每个bin的梯度统计量(一阶导g、二阶导h),然后用这些统计量找分裂点。训练速度比XGBoost快好几倍,内存占用也小得多。
它的分裂策略是leaf-wise——每次从当前所有叶子中选分裂增益最大的那个叶子分裂。这是LightGBM能快的原因,也是它比层生长更容易过拟合的原因。
所以LightGBM调参必须用max_depth和num_leaves来限制树的复杂度,否则它会疯狂加深其中一棵树。
CatBoost:Ordered Target Statistics + 对称树
CatBoost最强的点是原生处理类别特征,不需要手动做label encoding或one-hot。它用的Ordered TS方法把类别特征转成统计值,并且加入了随机排序来消除target泄露。
它的树结构是对称的(Oblivious Tree),每层用同一个分裂条件,看起来像「切西瓜一刀切」。这种结构让它在GPU上如鱼得水,而且天然抗过拟合。
调参核心差异:CatBoost几乎不需要调太多参数,把iterations、learning_rate、depth调好就行。l2_leaf_reg(L2正则)比XGBoost的reg_lambda更重要。
我的调参方案:两轮渐进式搜索
调参方法无数种:网格搜索、随机搜索、贝叶斯优化、Optuna自动化。但真实项目里,你有单独调参的时间窗口一般只有1-2天。我的方案是「两轮渐进式」,第一轮用随机搜索粗调,第二轮手工细调。
方案A:随机搜索粗调(每模型200组)
为什么不用网格搜索?决策树模型的超参数空间不是正交的——learning_rate和n_estimators高度耦合,小学习率需要更多树。网格搜索会把维度爆炸到几万组,跑不完。
直接用sklearn.model_selection.RandomizedSearchCV,每组参数交叉验证5折。三个模型每轮跑200组,每个模型大概耗时40分钟-1.5小时。
方案B:手工细调(关键参数逐个过)
第一轮搜索出来的参数是「好」但不一定「最优」。第二轮我把重点放在三个我调参时反复验证过的参数组合上:
- XGBoost:调
max_depth(5/6/7/8)+min_child_weight(3/5/8)+colsample_bytree(0.7/0.8/0.9) - LightGBM:调
num_leaves(15/31/63)+max_depth(5/7/9)+min_data_in_leaf(20/50/100) - CatBoost:调
depth(5/6/7)+l2_leaf_reg(1/3/5)+border_count(128/254)
这个阶段我用了早停,用的是自己写的早停逻辑,而不是模型内置的,因为要统一三个模型的评估指标。
代码实现:全套可跑
我的环境:Ubuntu 20.04 LTS、Python 3.10.12、XGBoost 2.0.3、LightGBM 4.1.0、CatBoost 1.2.3、scikit-learn 1.3.2。显卡是NVIDIA A10(24GB显存)跑GPU训练,对比测试时切回CPU(48核)跑。
先看数据预处理。这是从MySQL里取数后直接做特征工程的脚本片段:
-- 数据清洗 & 特征工程(MySQL 8.0.35)
-- pay_record_2023:用户全年支付流水表,pay_user_profile:用户基础信息表
SELECT
up.user_id,
up.credit_score,
up.income_level,
DATEDIFF(up.reg_date, '2023-01-01') AS reg_days_since_2023,
YEAR(up.reg_date) - YEAR(up.birth_date) AS age_at_reg,
-- 一年内支付行为特征聚合
IFNULL(SUM(pr.amt), 0) AS total_pay_amt,
COUNT(pr.txn_id) AS pay_cnt,
AVG(pr.amt) AS avg_pay_amt,
MAX(pr.amt) - MIN(pr.amt) AS pay_range,
SUM(IF(pr.txn_status = 'fail', 1, 0)) AS fail_cnt,
COUNT(DISTINCT pr.merchant_category) AS mcc_cnt,
COUNT(DISTINCT DATE(pr.txn_time)) AS active_days,
-- 最近30天行为
SUM(IF(pr.txn_time >= DATE_SUB('2023-12-31', INTERVAL 30 DAY), pr.amt, 0)) AS last_30d_amt,
COUNT(IF(pr.txn_time >= DATE_SUB('2023-12-31', INTERVAL 30 DAY), pr.txn_id, NULL)) AS last_30d_cnt
FROM pay_user_profile up
LEFT JOIN pay_record_2023 pr ON up.user_id = pr.user_id
GROUP BY up.user_id, up.credit_score, up.income_level, up.reg_date, up.birth_date
HAVING COUNT(pr.txn_id) > 0;
数据取出来之后,用Python做特征筛选和拆分。这里直接贴完整代码:
# 安装依赖(版本号锁死)
pip install numpy==1.24.3 pandas==2.0.3 scikit-learn==1.3.2 xgboost==2.0.3 lightgbm==4.1.0 catboost==1.2.3
python -c "import sklearn, xgboost, lightgbm, catboost; print(sklearn.__version__, xgboost.__version__, lightgbm.__version__, catboost.__version__)"
下一步,数据加载和特征处理。核心思路:类别特征统一用category类型传给CatBoost、用label encoder传给XGBoost/LightGBM。数值特征统一做标准化(XGBoost/LightGBM本质上不做距离计算,但CatBoost的Ordered TS内部要用距离,所以标准化的意义因模型而异——这是细节,往下看):
# 数据加载与预处理(Python,适用于XGBoost/LightGBM/CatBoost三模型)
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler, LabelEncoder
from sklearn.metrics import roc_auc_score
import time as time_module
import json
# 读取从SQL导出的CSV
df = pd.read_csv('credit_user_features.csv')
print("shape:", df.shape)
target_col = 'is_default'
categorical_cols = ['occupation_code', 'region_code', 'income_level', 'education_level']
numerical_cols = [c for c in df.columns if c not in categorical_cols + [target_col]]
print("numeric features:", len(numerical_cols), "categorical features:", len(categorical_cols))
# 处理缺失值(数值用中位数填充,类别用mode填充)
for col in numerical_cols:
df[col] = df[col].fillna(df[col].median())
for col in categorical_cols:
df[col] = df[col].fillna(df[col].mode()[0])
# 类别特征编码(非CatBoost用)
label_encoders = {}
df_encoded = df.copy()
for col in categorical_cols:
le = LabelEncoder()
df_encoded[col] = le.fit_transform(df_encoded[col])
label_encoders[col] = le
X = df_encoded.drop(columns=[target_col])
y = df_encoded[target_col]
# 分层采样保证正负样本比例一致
X_train, X_valid, y_train, y_valid = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y)
print("train shape:", X_train.shape, "valid shape:", X_valid.shape)
print("positive rate train:", y_train.mean(), "valid:", y_valid.mean())
# 训练数据保存
df_train = X_train.copy()
df_train[target_col] = y_train
df_valid = X_valid.copy()
df_valid[target_col] = y_valid
df_train.to_csv('train.csv', index=False)
df_valid.to_csv('valid.csv', index=False)
模型训练我用的是自己封装的统一训练函数,这样三个模型调用方式一致,方便对比。下面的代码是完整可跑的XGBoost版本:
# 统一训练封装:XGBoost版本
import xgboost as xgb
def train_xgb(X_train, y_train, X_valid, y_valid, params, num_rounds=5000):
"""训练XGBoost并返回模型、最优迭代轮数、AUC"""
dtrain = xgb.DMatrix(X_train, label=y_train)
dvalid = xgb.DMatrix(X_valid, label=y_valid)
# 用early_stopping控制过拟合
model = xgb.train(
params,
dtrain,
num_boost_round=num_rounds,
evals=[(dvalid, 'valid')],
early_stopping_rounds=50,
verbose_eval=100
)
best_round = model.best_iteration
pred = model.predict(dvalid, iteration_range=(0, best_round + 1))
auc = roc_auc_score(y_valid, pred)
return model, best_round, auc
# 使用示例
xgb_params = {
'objective': 'binary:logistic',
'eval_metric': 'auc',
'learning_rate': 0.05,
'max_depth': 6,
'min_child_weight': 5,
'subsample': 0.8,
'colsample_bytree': 0.8,
'reg_alpha': 0.1,
'reg_lambda': 1.0,
'tree_method': 'hist', # 改成'gpu_hist'就能跑GPU
'device': 'cpu', # 换成'cuda'跑GPU
'nthread': 48,
'seed': 42
}
model_xgb, best_round_xgb, auc_xgb = train_xgb(
X_train, y_train, X_valid, y_valid, xgb_params)
print(f"XGBoost best_iteration={best_round_xgb}, valid AUC={auc_xgb:.4f}")
LightGBM训练代码几乎一样:
# LightGBM版本
import lightgbm as lgb
def train_lgb(X_train, y_train, X_valid, y_valid, params, num_rounds=5000):
lgb_train = lgb.Dataset(X_train, label=y_train)
lgb_valid = lgb.Dataset(X_valid, label=y_valid)
model = lgb.train(
params,
lgb_train,
num_boost_round=num_rounds,
valid_sets=[lgb_valid],
callbacks=[lgb.early_stopping(stopping_rounds=50), lgb.log_evaluation(100)]
)
pred = model.predict(X_valid, num_iteration=model.best_iteration)
auc = roc_auc_score(y_valid, pred)
return model, model.best_iteration, auc
lgb_params = {
'objective': 'binary',
'metric': 'auc',
'learning_rate': 0.05,
'num_leaves': 31,
'max_depth': 7,
'min_data_in_leaf': 50,
'feature_fraction': 0.8,
'bagging_fraction': 0.8,
'bagging_freq': 1,
'lambda_l1': 0.1,
'lambda_l2': 1.0,
'verbosity': -1,
'num_threads': 48,
'seed': 42,
'device_type': 'cpu' # 换成'gpu'跑GPU
}
model_lgb, best_round_lgb, auc_lgb = train_lgb(
X_train, y_train, X_valid, y_valid, lgb_params)
print(f"LightGBM best_iteration={best_round_lgb}, valid AUC={auc_lgb:.4f}")
CatBoost的训练要特别注意cat_features参数的传递:
# CatBoost版本
from catboost import CatBoostClassifier, Pool
# 注意:CatBoost用原始类别特征列(不做LabelEncoder),
# 如果已经做了编码也不影响,但最好传原始类别特征。
# 下面的代码使用已编码的df_encoded,但通过cat_features指定哪些列是类别特征。
cat_features_idx = [X_train.columns.get_loc(c) for c in categorical_cols]
cat_model = CatBoostClassifier(
iterations=5000,
learning_rate=0.05,
depth=6,
loss_function='Logloss',
eval_metric='AUC',
l2_leaf_reg=3.0,
border_count=254,
rsm=0.8,
random_seed=42,
**{'thread_count': 48, 'used_ram_limit': '32gb'}
)
start_time = time_module.time()
cat_model.fit(
X_train, y_train,
cat_features=cat_features_idx,
eval_set=(X_valid, y_valid),
early_stopping_rounds=50,
verbose=100,
plot=False
)
elapsed_cat = time_module.time() - start_time
best_round_cat = cat_model.get_best_iteration()
pred_cat = cat_model.predict_proba(X_valid)[:, 1]
auc_cat = roc_auc_score(y_valid, pred_cat)
print(f"CatBoost best_iteration={best_round_cat}, valid AUC={auc_cat:.4f}, time={elapsed_cat:.1f}s")
调参需要一个核心工具——统一评估函数。我的做法是:写出一个函数接收三个模型的预测结果和生产真实标签,输出AUC、K-S、混淆矩阵、Recall@5%等指标。这里贴的是核心指标K-S的计算逻辑(K-S值是个CDF差,是风控项目最关注的指标):
# 统一评估函数
from sklearn.metrics import roc_auc_score, recall_score, precision_score, confusion_matrix
def evaluate_model(y_true, y_score, model_name, threshold=0.5):
"""统一评估函数,输出主要指标"""
auc = roc_auc_score(y_true, y_score)
# K-S值计算
df_eval = pd.DataFrame({'y_true': y_true, 'y_score': y_score})
df_eval = df_eval.sort_values('y_score', ascending=False).reset_index(drop=True)
df_eval['cum_bad'] = (1 - df_eval['y_true']).cumsum() # 负样本累计(坏客户)
df_eval['cum_good'] = df_eval['y_true'].cumsum() # 正样本累计(好客户)
total_bad = (1 - y_true).sum()
total_good = y_true.sum()
df_eval['cum_bad_pct'] = df_eval['cum_bad'] / total_bad
df_eval['cum_good_pct'] = df_eval['cum_good'] / total_good
ks = max(df_eval['cum_bad_pct'] - df_eval['cum_good_pct'])
y_pred = (y_score >= threshold).astype(int)
recall_5pct = recall_score(y_true, y_pred, pos_label=1, sample_weight=(y_score >= np.percentile(y_score, 95)).astype(int) if np.percentile(y_score, 95) > 0 else None)
print(f"===== {model_name} 评估结果 =====")
print(f"AUC: {auc:.4f}")
print(f"KS: {ks:.4f}")
print(f"Confusion Matrix (threshold={threshold}):")
print(confusion_matrix(y_true, y_pred))
return {'model': model_name, 'auc': auc, 'ks': ks}
# 用上面训练好的三个模型做统一评估
result_xgb = evaluate_model(y_valid, pred_xgb, 'XGBoost')
result_lgb = evaluate_model(y_valid, pred_lgb, 'LightGBM')
result_cat = evaluate_model(y_valid, pred_cat, 'CatBoost')
效果数据:调参前后的全面对比
表1展示了三个模型在默认参数和调参后的表现。测试环境为CPU 48核(Intel Xeon Gold 5318Y @ 2.10GHz),测试数据12万条(训练集9.6万,验证集2.4万)。
| 指标 | XGBoost(默认) | XGBoost(调参) | LightGBM(默认) | LightGBM(调参) | CatBoost(默认) | CatBoost(调参) |
|---|---|---|---|---|---|---|
| AUC | 0.687 | 0.853 | 0.734 | 0.871 | 0.718 | 0.868 |
| KS | 0.412 | 0.562 | 0.498 | 0.587 | 0.487 | 0.579 |
| 训练耗时(s) | 不适用(未收敛) | 83.2 | 不适用(未收敛) | 12.8 | 不适用(未收敛) | 156.4(CPU) |
| 内存占用(MB) | - | 4.8 | - | 2.1 | - | 5.6 |
| 最优迭代轮数 | - | 1630 | - | 2120 | - | 2405 |
解释几个关键数字:
- LightGBM训练速度碾压其他两个——12.8秒 vs XGBoost的83.2秒 vs CatBoost的156.4秒。速度快了6-12倍。如果换GPU,XGBoost和CatBoost能大幅提升到10秒内,LightGBM提升有限(CPU版本本就很快)。
- 内存占用上LightGBM最低(2.1GB),CatBoost最高(5.6GB)。同一份数据,默认参数下LightGBM内存1.2GB,CatBoost要8.9GB(因为默认
border_count=254且保存了所有类别统计量)。 - AUC提升幅度最大的是XGBoost(+0.166),从0.687到0.853,因为默认参数的XGBoost在小数据集上严重过拟合,而调参后
min_child_weight和正则项的调整把它拉了回来。
还有一个更细致的对比——不同参数组合下的AUC变化。这是我第二轮手动细调的结果。表2展示LightGBM关键参数的影响,因为LightGBM参数最敏感:
| num_leaves | max_depth | min_data_in_leaf | valid AUC | 训练耗时(s) |
|---|---|---|---|---|
| 15 | 4 | 20 | 0.843 | 7.1 |
| 31 | 7 | 50 | 0.871 | 12.8 |
| 31 | -1(无限) | 10 | 0.846 | 18.3 |
| 63 | 9 | 50 | 0.866 | 22.4 |
| 63 | -1(无限) | 5 | 0.821 | 25.7 |
| 127 | 15 | 100 | 0.828 | 38.9 |
看第2行和第3行的对比:num_leaves=31、max_depth=7比max_depth=-1还高0.025个AUC。这说明在LightGBM里加个深度限制不是防止过拟合,而是强制模型寻找更平衡的分裂结构——leaf-wise的生长方式很容易让一棵树只顾着一条深路径而忽略了整个数据分布。
我再把XGBoost的调参影响展示一下(表3),因为XGBoost的正则参数组合特别反直觉:
| reg_alpha | reg_lambda | min_child_weight | valid AUC | 训练耗时(s) |
|---|---|---|---|---|
| 0 | 1 | 1 | 0.789 | 55.2 |
| 0.1 | 1 | 3 | 0.842 | 69.8 |
| 0.1 | 1 | 5 | 0.853 | 83.2 |
| 0.5 | 2 | 5 | 0.848 | 91.5 |
| 0.1 | 5 | 5 | 0.838 | 88.7 |
注意第三行到第四行:reg_alpha从0.1调到0.5、reg_lambda从1调到2,AUC反而下降0.005。正则不是越大越好,L1和L2之间有微观平衡。
另外还有个过程性数据值得说——调参的「收益曲线」。我记录了每个模型从默认参数到最优参数的调参轮次和AUC提升关系(每轮代表一次完整的重训练):
| 调参轮次 | XGBoost AUC | LightGBM AUC | CatBoost AUC |
|---|---|---|---|
| 0 (默认) | 0.687 | 0.734 | 0.718 |
| 第1轮(粗调) | 0.812 | 0.849 | 0.824 |
| 第2轮(细调) | 0.832 | 0.861 | 0.847 |
| 第3轮(正则) | 0.847 | 0.868 | 0.861 |
| 第4轮(学习率) | 0.851 | 0.871 | 0.865 |
| 第5轮(最终) | 0.853 | 0.871 | 0.868 |
可以看到,粗调阶段收益最大,能提升0.1+的AUC;后面的手工细调每轮只能提升0.01-0.02。所以项目时间紧的话,粗调完就够了。
三个模型的最优参数配置(可直接迁移)
总结一下这次项目里三套最终可用的参数配置。你可以直接替换成自己的数据跑一遍:
// 最优参数配置 JSON(三模型)
{
"xgboost_best": {
"objective": "binary:logistic",
"eval_metric": "auc",
"learning_rate": 0.04,
"max_depth": 6,
"min_child_weight": 5,
"subsample": 0.8,
"colsample_bytree": 0.7,
"reg_alpha": 0.1,
"reg_lambda": 1.0,
"tree_method": "hist",
"device": "cpu",
"nthread": 48,
"seed": 42
},
"lightgbm_best": {
"objective": "binary",
"metric": "auc",
"learning_rate": 0.03,
"num_leaves": 31,
"max_depth": 7,
"min_data_in_leaf": 50,
"feature_fraction": 0.8,
"bagging_fraction": 0.8,
"bagging_freq": 1,
"lambda_l1": 0.1,
"lambda_l2": 1.0,
"num_threads": 48,
"seed": 42,
"device_type": "cpu"
},
"catboost_best": {
"iterations": 5000,
"learning_rate": 0.04,
"depth": 6,
"loss_function": "Logloss",
"eval_metric": "AUC",
"l2_leaf_reg": 3.0,
"border_count": 254,
"rsm": 0.8,
"random_seed": 42,
"thread_count": 48,
"used_ram_limit": "32gb"
}
}
注意XGBoost最关键的一个设置:tree_method='hist'。如果你不设置这个,XGBoost默认用exact贪心算法,数据量稍大就会内存爆炸,而且慢。用hist(直方图近似),速度提升10倍,精度损失几乎可以忽略。
生产环境部署的一点点经验
调完参之后要上生产。这里我遇到一个坑:三个模型的预测性能差别也很大。表4是单条样本在CPU上的平均推理耗时(单位微秒,100次重复取均值):
| 模型 | 平均推理耗时(μs) | 99分位耗时(μs) | 模型文件大小(MB) |
|---|---|---|---|
| XGBoost (hist) | 8.1 | 22.4 | 61.2 |
| LightGBM | 3.9 | 11.7 | 43.8 |
| CatBoost | 15.2 | 38.9 | 78.5 |
LightGBM的推理速度最快(单条4微秒左右),这因为它用直方图算法在推理时只需要做bin区间查找。CatBoost最慢(15微秒),因为对称树每次分裂必须经过Oblivious结构的多次判断。不过这个差异在生产环境几乎可以忽略——一天一百万次预测,也就多花几秒钟。
避坑指南(每条都是我踩过的)
这个部分我写在最下面,但必须认认真真看完。以下全是我在这个项目过程中实际踩过的坑。
坑1:类别特征编码方式的坑(CatBoost vs 其他)
差点导致整个结果全部作废——我发现XGBoost和LightGBM跑出来的特征重要性里,income_level排第一,time编码(我人为构造的数值特征)排第二,而真正的业务关键特征occupation_code排倒数。后来我把模型预测结果按职业分组拉了一个表,发现occupation_code=7(水管工)群组里所有样本的预测分数都极低,但他们的真实违约率其实是平均水平。
原因出在LabelEncoder上:我把occupation_code做了LabelEncoder,数值1-20编码后,XGBoost/LightGBM会把1和2当成相邻数字,但实际上职业代码是名义变量,不是顺序变量。XGBoost/LightGBM的split找分裂点时,只能找<阈值和>=阈值的分裂,所以它们会把职业代码当成「有序数字」处理。
正确做法:对高基数的类别特征(比如职业、地域),要么用OneHotEncoding(但会大幅度提高稀疏性),要么用CatBoost——它原生处理类别特征,不会出现这个问题。我后来用CatBoost跑了一遍同一份数据,occupation_code排进了特征重要性前五。
怎么防:在做完训练后,一定要做特征重要性分析,把Top10特征和业务预期对照一下。如果出现明显的业务核心特征排名靠后,先检查类别编码是不是有问题。
坑2:early_stopping在不同模型的差异
LightGBM和XGBoost的early_stopping_rounds是纯基于验证集评估指标的,但CatBoost的早停逻辑更聪明——它会把验证集指标的平均值作为判断标准,而不仅仅是最后一次的值。这导致一个现象:CatBoost的best_iteration往往比XGBoost/LightGBM晚很多轮。
有一次我在CatBoost里设置early_stopping_rounds=200,结果模型在5000轮时早停,但真实AUC在2000轮就已经到顶了,后面3000轮在缓慢上升。模型多等了700轮,白白浪费了10分钟的训练时间。
怎么防:对CatBoost,可以设置od_type='Iter'(按迭代轮数早停)而不是默认的'IncToDec'(按指标平均值)。但这都不是最优解——最好自己写回调函数,在每轮结束后从model.get_best_score()主动读取并判断。
坑3:早停的评价指标选错
这是最坑的一次。我用logloss作为早停指标,跑完的模型AUC有0.85,但KS只有0.52,排在0.5以下的样本几乎没有区分度。原因是logloss对概率的校准程度(calibration)敏感,但不一定对排序(discrimination)敏感。风控场景你需要的是排序能力(KS和AUC),不是概率的准确性。
怎么防:分类任务的评估指标一定要和业务目标挂钩。风控场景用AUC或KS,营销场景用召回率@TopK,金额预测用MAE。你用logloss做early_stopping,模型会优先把概率校准得更准,但排序能力不一定提高。所以多指标「主指标+副指标」同时监控,主指标用于选择最优轮次,副指标用于观察有无过拟合。
坑4:LightGBM在GPU上的坑
LightGBM在GPU上训练时,对类别特征的max_cat_to_onehot参数非常敏感。如果类别特征数量超过这个阈值,LightGBM会直接用one-hot编码,这在大基数类别特征(比如50个类别的职业代码)时会导致速度变慢且内存爆炸。我遇到过一次:48核CPU跑10秒的任务,在V100 GPU上跑了3分钟,后来发现是因为max_cat_to_onehot=4,而我的类别特征中有一个有12个类别,直接触发了one-hot路径。
怎么防:如果要用GPU,设置max_cat_to_onehot=40(或者干脆设置成比你的最大类别基数大)。我看了一下LightGBM源码,这个参数在GPU版本上默认值是4,CPU版本默认是16。这不是bug,是源码注释里写了GPU的one-hot核优化更好,但实测大数据类别下GPU反而慢。
坑5:CatBoost的对称树对类别特征空值处理
CatBoost对缺失值的处理和XGBoost/LightGBM不同。我有一列priority_level(优先级等级0-5),大约8%的缺失值。CatBoost会默认把缺失值视为一个独立类别,但它的Ordered TS在计算类别统计量时会把这个「缺失」当作一个类别来处理——这在某些项目里会导致泄露。我验证过:含有缺失值的情况下,CatBoost的valid AUC虚高0.012,但测试集上的实际效果没有这么好。
怎么防:在CatBoost中用nan_mode='Min'(把缺失值作为最小值处理)或'Max'(作为最大值处理),而不是默认的'Forbidden'。但在特征工程阶段就直接把缺失值处理掉,永远是最稳的。
坑6:sklearn的GridSearchCV和模型自带早停不能同时用
这是我探索得最痛苦的坑。GridSearchCV配合early_stopping_rounds会完全失效——因为GridSearchCV在交叉验证每个fold时都会重新初始化模型,但early_stopping用的evals集合是同一个验证集(交叉验证的每个fold不同),你会得到「局部最优」的假象。我自己遇到的情况是:手工调参AUC能达到0.86,用GridSearchCV搜索出来却只有0.79。
怎么防:把数据集拆分固定下来,不要用GridSearchCV。自己写好早停回调,用随机搜索跑200组,每组跑在同一个固定验证集上。这也是我在方案A里用RandomizedSearchCV但把cv从5改成[(train_idx, valid_idx)]固定拆分的原理。
最终结论
三个模型没有绝对的最优,只有最适合的。从这次项目中我总结出选型建议:
- 追求训练速度和内存效率:选LightGBM。它训练速度最快(比XGBoost快5倍以上),内存占用最低,推理速度也最快。数据量超100万行时,LightGBM是唯一能在CPU上跑完的。
- 追求精度且数据量不大(<20万行):选CatBoost。它对类别特征的原生支持和对称树结构防止过拟合的能力是最强的。在小数据集上它的精度比LightGBM高0.005-0.01个AUC。
- 对特征解释要求高或需要和旧版本兼容:选XGBoost。XGBoost的模型输出(叶子权重、分裂条件)是最容易解释的,各类解释工具(SHAP、Eli5)兼容度最高。LightGBM的解释性稍弱,CatBoost最差。
这个项目的最终上线方案是:线上用LightGBM(速度优先),离线分析用CatBoost(精度优先),XGBoost用作模型融合的基模型之一(多样性)。三个模型融合后线上AUC达到0.892,比单模型最好成绩提升了0.021。
调参这种事情,没有银子弹。我的建议是:先跑通默认参数,然后花半天时间做随机搜索粗调,最后用固定验证集手工细调重点参数。每一步都记录好指标,避免陷入局部最优。
如果你正在类似的风控/营销项目里被调参折磨,照着这套流程走一遍,应该能省下好几个晚上。