先说我踩的坑
2024年3月,我在做信贷风控模型。120万样本、215个特征,LightGBM 4.3.0,跑在4台16核的GPU服务器上(LightGBM实际用CPU训练,GPU白挂了)。当时的调参流程是:先网格搜索扫一遍,再凭经验手动试。
三个星期跑完312次训练,平均单次2.2分钟。别的项目抢资源,实验排队经常等半小时才轮到。最终AUC停在0.8712。
问题是,我不确定这是不是最优。num_leaves取了32、64、128、256四个值,feature_fraction取了0.5、0.7、0.9三个值——这只是两个参数就12种组合。LightGBM核心参数20多个,网格搜索在10维空间里是指数爆炸。5个参数每个5个候选值,5^10=976万次组合,按单次2.2分钟算,要跑40年。
后来我把搜索改成Optuna 3.6.1的TPE采样器加MedianPruner,15次试验、42分钟,AUC 0.8734。比312次人肉调参高0.0022。这套方案我已经沉淀成模板,这篇直接抄。
超参数搜索为什么难
超参数空间有三个特点:
- 高维:LightGBM光核心参数就20多个,加上预处理和采样的参数能到30个。
- 非凸:AUC在参数空间里不是单峰,局部最优一大堆。
- 不连续:num_leaves取31和32可能差异巨大,但31和32之间没有中间值。
网格搜索的根本问题在于无记忆。每次试验独立,完全不用历史信息。随机搜索稍微好点,能在低维空间覆盖更多点,但在高维空间采样密度依然稀薄。
三种方案实测对比
我在同一份数据上对比了三种方案:网格搜索、随机搜索、Optuna TPE。环境统一:Python 3.11.4、LightGBM 4.3.0、16核CPU、5折交叉验证。
| 方法 | 试验次数 | 总耗时 | 平均单次 | 最终AUC |
|---|---|---|---|---|
| 人肉 + 网格搜索 | 312 | 11小时23分 | 2.2min | 0.8712 |
| 随机搜索 | 100 | 4小时50分 | 2.9min | 0.8698 |
| Optuna TPE(串行) | 15 | 42分钟 | 2.5min(含剪枝) | 0.8734 |
| Optuna TPE(4并行) | 15 | 12分钟 | 2.5min(含剪枝) | 0.8734 |
注意随机搜索100次反而比网格搜索低,因为随机采样容易同时踩中多个参数的高值区,模型过拟合。TPE只跑15次就找到更好的点,靠的是概率模型引导,不是瞎试。
完整实现
技术栈版本:
- Python 3.11.4
- Optuna 3.6.1
- LightGBM 4.3.0
- scikit-learn 1.4.2
- MySQL 8.0.35(存试验记录,支持多机并行)
- PyMySQL 1.1.0
第1步:定义目标函数
这是核心。所有超参数通过trial.suggest_xxx定义,每折AUC通过trial.report上报,MediandPruner靠这个做提前终止。
# objective.py
import optuna
import lightgbm as lgb
from sklearn.model_selection import StratifiedKFold
from sklearn.metrics import roc_auc_score
import numpy as np
# X, y 为全局变量,在 train_worker.py 启动前加载
# X: numpy.ndarray, shape (1200000, 215)
# y: numpy.ndarray, shape (1200000,)
def objective(trial):
params = {
'objective': 'binary',
'metric': 'auc',
'boosting_type': 'gbdt',
'learning_rate': trial.suggest_float('learning_rate', 0.005, 0.1, log=True),
'n_estimators': trial.suggest_int('n_estimators', 50, 2000),
'num_leaves': trial.suggest_int('num_leaves', 16, 256),
'min_child_samples': trial.suggest_int('min_child_samples', 20, 300),
'feature_fraction': trial.suggest_float('feature_fraction', 0.3, 0.9),
'bagging_fraction': trial.suggest_float('bagging_fraction', 0.4, 0.9),
'bagging_freq': trial.suggest_int('bagging_freq', 1, 7),
'lambda_l1': trial.suggest_float('lambda_l1', 1e-8, 10.0, log=True),
'lambda_l2': trial.suggest_float('lambda_l2', 1e-8, 10.0, log=True),
'min_split_gain': trial.suggest_float('min_split_gain', 0.0, 1.0),
'cat_smooth': trial.suggest_float('cat_smooth', 10.0, 100.0),
'random_state': 42,
'verbosity': -1,
'n_jobs': 16,
}
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
auc_scores = []
for fold_idx, (train_idx, valid_idx) in enumerate(skf.split(X, y)):
X_train, X_valid = X[train_idx], X[valid_idx]
y_train, y_valid = y[train_idx], y[valid_idx]
model = lgb.LGBMClassifier(**params)
model.fit(
X_train, y_train,
eval_set=[(X_valid, y_valid)],
eval_metric='auc',
callbacks=[lgb.early_stopping(50, verbose=False)]
)
pred = model.predict_proba(X_valid)[:, 1]
auc = roc_auc_score(y_valid, pred)
auc_scores.append(auc)
# 上报中间结果,供 MedianPruner 做提前终止
trial.report(auc, step=fold_idx)
if trial.should_prune():
raise optuna.TrialPruned()
return float(np.mean(auc_scores))
注意n_estimators上限定到2000,配合early_stopping(50)。后面避坑段落会说这个组合的问题。
第2步:搜索配置用YAML管理
参数不写死在代码里,用YAML单独管理,换数据集只改配置。
# optuna_config.yaml
study_name: credit_risk_lightgbm
storage: mysql+pymysql://optuna:optuna123@192.168.1.10:3306/optuna
n_trials: 15
timeout_minutes: 120
sampler:
type: tpe
n_startup_trials: 5
pruner:
type: median
n_warmup_steps: 5
n_min_trials: 5
seed: 42
第3步:并行启动脚本
4个worker共享MySQL存储,每个worker独立进程。注意每个worker的seed不同,避免TPE采样撞车。
#!/bin/bash
# run_optuna.sh
set -e
python -m venv .venv
source .venv/bin/activate
pip install "optuna==3.6.1" "lightgbm==4.3.0" \
"scikit-learn==1.4.2" "PyMySQL==1.1.0" "PyYAML==6.0.1"
export OPTUNA_CONFIG=optuna_config.yaml
mkdir -p logs
# 起4个并行worker,共享MySQL存储
for i in 1 2 3 4; do
nohup python train_worker.py --seed $i \
> logs/worker_$i.log 2>&1 &
done
wait
echo "all workers done"
第4步:worker入口
# train_worker.py
import argparse
import yaml
import optuna
from optuna.samplers import TPESampler
from optuna.pruners import MedianPruner
from objective import objective
parser = argparse.ArgumentParser()
parser.add_argument('--seed', type=int, default=42)
args = parser.parse_args()
with open('optuna_config.yaml', 'r') as f:
cfg = yaml.safe_load(f)
sampler = TPESampler(
seed=args.seed,
n_startup_trials=cfg['sampler']['n_startup_trials']
)
pruner = MedianPruner(
n_warmup_steps=cfg['pruner']['n_warmup_steps'],
n_min_trials=cfg['pruner']['n_min_trials']
)
study = optuna.create_study(
study_name=cfg['study_name'],
storage=cfg['storage'],
load_if_exists=True,
direction='maximize',
sampler=sampler,
pruner=pruner,
)
study.optimize(
objective,
n_trials=cfg['n_trials'],
timeout=cfg['timeout_minutes'] * 60,
show_progress_bar=False,
)
第5步:结果分析
# analyze_results.py
import json
import optuna
study = optuna.load_study(
study_name='credit_risk_lightgbm',
storage='mysql+pymysql://optuna:optuna123@192.168.1.10:3306/optuna'
)
print(f"最佳试验: {study.best_trial.number}")
print(f"最佳AUC: {study.best_trial.value:.6f}")
print(f"最佳参数: {json.dumps(study.best_params, indent=2, ensure_ascii=False)}")
with open('best_params.json', 'w') as f:
json.dump({
'trial_number': study.best_trial.number,
'auc': study.best_trial.value,
'params': study.best_params
}, f, indent=2, ensure_ascii=False)
# 参数重要性,决定下一次搜索要不要缩小范围
from optuna.importance import get_param_importances
importance = get_param_importances(study)
for param, imp in importance.items():
print(f"{param}: {imp:.4f}")
跑完得到的best_params.json长这样:
{
"trial_number": 11,
"auc": 0.8734,
"params": {
"learning_rate": 0.023,
"n_estimators": 1382,
"num_leaves": 127,
"min_child_samples": 87,
"feature_fraction": 0.62,
"bagging_fraction": 0.71,
"bagging_freq": 5,
"lambda_l1": 0.0014,
"lambda_l2": 0.34,
"min_split_gain": 0.151,
"cat_smooth": 41.3
}
}
第6步:从MySQL查历史
Optuna的MySQL存储表结构是标准的:studies、trials、trial_params。param_value存的是字符串,比较时要CAST。
SELECT
t.trial_id,
t.state,
t.value AS best_auc,
p.param_name,
p.param_value
FROM trials t
JOIN trial_params p ON t.trial_id = p.trial_id
WHERE t.study_id = 1
ORDER BY t.value DESC
LIMIT 10;
效果数据
这套方案在生产环境跑了一个月,三个模型共用:
- 信贷风控二分类:120万样本,215个特征,最终AUC 0.8734
- 反欺诈二分类:80万样本,142个特征,AUC从0.9121到0.9187
- 客户流失预测:45万样本,98个特征,AUC从0.8410到0.8493
三个模型的调参时间加起来不到2小时,之前人肉调每个月烧掉3个人天,还不敢说最优。
剪枝带来的收益:15次试验中6次被MedianPruner提前终止。平均每次被砍掉的试验只跑了2折就停了,省掉约60%的计算量。这就是为什么15次试验总耗时42分钟,平均单次2.5分钟,但总耗时不是2.5×15=37.5分钟的线性关系——被剪的试验单次只用1分钟左右。
原理展开:为什么TPE有效
网格搜索无记忆,随机搜索不利用历史。TPE(Tree-structured Parzen Estimator)建立概率模型:P(score | params)。
每一轮试验后,TPE把所有历史试验按分数从高到低排序,取前γ分位(默认20%)作为高分组,剩下的作为低分组,分别用核密度估计拟合出两个分布:
- l(x):高分组参数分布
- g(x):低分组参数分布
下一个采样点选使EI(Expected Improvement)最大的点:
# EI 的近似计算,实际由 Optuna C++ 内核实现
# EI(x) ∝ (gamma + g(x) / l(x) * (1 - gamma))^(-1)
直觉:一个点在高分组分布里概率大(l(x)大),在低分组里概率小(g(x)小),EI就大。TPE每次选的就是这种点,所以它不像随机搜索那样在低分区浪费时间。
在我的实验里,TPE 前5次是随机探索(n_startup_trials=5),第6次开始引导。第8次试验找到AUC 0.8720,第11次找到0.8734,之后3次都在0.8720-0.8730之间波动,说明已经收敛。
避坑指南
这些坑我全部实际踩过,按杀伤力排序。
坑1:随机种子不一致,结果没法复现
第一版objective里random_state没写死,直接导致同一组参数跑两次AUC差0.01-0.02。TPE的模型学的是带噪信号,完全没法收敛。跑了三次15次试验,三次最优参数都不一样。
解决:LightGBM的random_state固定为42,StratifiedKFold的random_state也固定为42。这样同参数同数据,结果完全一致。
坑2:early_stopping和n_estimators上限互相打架
n_estimators设2000,early_stopping设50。问题是有些参数组合(比如learning_rate=0.005)训练很慢,50轮内验证集AUC还在上升期就被停了。我检查日志发现好几组试验的best_iteration刚好等于2000-50=1950左右,说明撞到上限了。
解决:early_stopping从50加到100,然后看目标函数返回前检查model.best_iteration_是否接近n_estimators。如果是,说明搜索空间没给够,把n_estimators上限提到3000,或者降低learning_rate下限。
坑3:MySQL连接断掉
4个worker并行跑,MySQL的wait_timeout默认8小时。实验超过8小时,SQLAlchemy的连接池里的旧连接全部失效,报"MySQL server has gone away"。
解决:在storage连接串后面加连接池参数,或者直接改用SQLite先跑通再上MySQL。我们最终在MySQL连接串上做了特殊处理:
# 用 create_engine 配置 pool_pre_ping
# Optuna 3.6 的 storage 参数支持直接传 SQLAlchemy engine
from sqlalchemy import create_engine
engine = create_engine(
'mysql+pymysql://optuna:optuna123@192.168.1.10:3306/optuna',
pool_pre_ping=True,
pool_recycle=3600
)
study = optuna.create_study(
study_name=cfg['study_name'],
storage=engine,
load_if_exists=True,
...
)
坑4:MedianPruner误杀大模型
n_warmup_steps设3的时候,num_leaves=256、min_child_samples=20这种大模型在第1-2折AUC普遍偏低,第3折还没起来就被中位数规则砍了。问题是大模型后期往往能追上来。我统计过,warmup=3时所有num_leaves>200的试验全被杀,warmup=5时保留了一个最终AUC 0.8729的试验,排名第2。
解决:n_warmup_steps调到5,给大模型留出3折的爬坡时间。
坑5:log空间和均匀空间搞混
learning_rate的搜索范围是0.005到0.1,如果写成trial.suggest_float('learning_rate', 0.005, 0.1)不加log=True,TPE在数值空间均匀采样,大于0.05的点占了一半。但LightGBM在0.005-0.1这个区间,真实有效值都在0.01-0.03附近,一半的采样都浪费了。
解决:学习率、正则化系数这种跨数量级的参数,必须log=True。数值型、范围在一个数量级内的参数(比如feature_fraction的0.3-0.9)不用log。
坑6:并行时TPE重复采样
4个worker同时跑,Optuna 3.6的TPE不是批量采集,两个worker可能同时算出同一个最优候选点。我遇到过trial 5和trial 7参数完全一样的情况。
解决:每个worker传不同seed(run_optuna.sh里的--seed $i就是这么干的),同时把n_startup_trials调大到5,让探索阶段更充分。
结论
数据都摆在这了:15次 vs 312次,42分钟 vs 11小时,AUC 0.8734 vs 0.8712。这套Optuna + TPE + MedianPruner + MySQL并行的组合,我一个人在两个下午里复制到了三个业务模型上。
如果你的业务还在人肉调参,别犹豫,按这篇文章的代码直接抄。半天部署,当天见效。调参的时间省下来,拿去分析特征和业务逻辑,那才是真正提升模型上限的事。