AutoML调参实战:从600次搜索到28次
发布日期: 2026/08/13 阅读总量: 0

先说一个真实的事

去年我们做一个某券类App的核销预测模型。业务方给了个死命令:模型要上线,但资源不给加,线上预测机还那台,训练机也只有一台8C16G的旧服务器。

第一版我用的是手动调参加网格搜索。GridSearchCV带5折交叉验证,LightGBM里面就6个核心超参,每折5.6万样本、120个特征。网格搜索把所有参数组合乘一遍,大概是2×3×4×3×2×3,432组参数。每组参数在5折上平均跑4~6分钟,算下来整整跑了7.2小时。

而且出来的结果还不好——调了一晚上,F1只有0.781,线上预估延迟倒是没超,就是效果不达标。

后来换成Optuna的TPE(Tree-structured Parzen Estimator)加剪枝,只跑了28轮,21分钟跑完,F1拉到0.843。同一个模型,同一个验证集,收益差6.2个点。这篇文章就把完整的对比过程、代码、参数、坑全部放出来。

问题定义:为什么网格搜索撑不住

核心业务:用户在App内领取某优惠券后,预测他7天内会不会核销。这是一个二分类任务。样本来自Hive数仓,大概120个特征(用户历史行为、券面信息、城市、时间特征等),正负样本比例大约1:4。因为正样本少,评价指标用F1(precision和recall的调和平均),AUC作为参考。

原始超参数空间:

参数名类型搜索范围默认值
n_estimatorsint100~1000100
learning_ratefloat0.01~0.30.1
num_leavesint16~25531
max_depthint3~12-1
min_child_samplesint5~10020
subsamplefloat0.5~1.01.0
colsample_bytreefloat0.5~1.01.0
reg_alphafloat1e-4~100
reg_lambdafloat1e-4~101

如果用网格搜索,每个参数取5个档位,就是5的9次方,将近200万组,每组训练一轮5折交叉验证,这辈子不用干别的了。所以纯网格搜索从来就不适合参数超过4个的模型。项目第一版我用网格搜索是偷懒,也是因为团队之前没有做自动化搜索的基础设施。

网格搜索唯一的优点是能并行。缺点是:参数维度一旦≥5,组合爆炸;且它对参数之间的交互关系毫无感知,花了大量时间在无效区域搜索。

方案对比:网格搜索 vs 随机搜索 vs TPE贝叶斯优化

三种搜索策略的区别

网格搜索是一个不落的暴力枚举,随机搜索是隔点采样。TPE贝叶斯优化则是根据历史评估结果建模概率分布,把下一次采样点引导到最有希望的区域,同时还要平衡探索和利用。

直观地解释TPE:它维护了两组历史评估结果的分布——一组是表现较好的参数(比如F1排名前20%),另一组是表现较差的参数。每次迭代,它会计算:当前候选参数在“好分布”里出现的概率除以在“坏分布”里出现的概率,这个比值越大,说明这个候选参数越有可能是好的。Optuna会在这个比值指导下采样。

这个策略的好处是:它对连续参数很友好,而且每次采样都能用上之前所有试验的信息。网格搜索虽然可以并行,但没有任何记忆,跑过的区域无效还会再跑。

随机搜索同理,只是从离散网格变成连续随机,没有利用历史结果。

性能预估

我们的实测环境:

环境项配置
CPUIntel Xeon Silver 4210 × 2,共20物理核40线程
内存16GB DDR4
系统Ubuntu 22.04.3 LTS
Python版本3.11.7
LightGBM版本4.3.0(预编译二进制)
Optuna版本3.6.1
scikit-learn1.4.2
数据量训练集5.6万行,验证集1.4万行,特征120维

在8C16G实际可用条件下(训练时我们还跑着其他服务,所以限制了CPU),三类搜索策略的理论开销:

策略需要评估的组数单次训练耗时总耗时(估算)
网格搜索(6参数×约3档)729约35秒约7.1小时
随机搜索(300次)300约30秒约2.5小时
TPE(30次+剪枝)30平均约40秒(部分被剪掉要快得多)约21分钟

注意,随机搜索300次是“看起来还行”的方案,但它有一个问题:300次采样在9维超参空间里覆盖率极低,很容易错过最优区域。而TPE只用30次就能收敛,因为它不是盲目采样。

完整代码实现(可直接跑)

数据准备

先从Hive里取数,这个环节我不展开SQL优化,直接给一段能复现的取数SQL(我用的版本:Hive 3.1.3):


-- 训练集:近90天领券用户行为特征
SELECT
    user_id,
    coupon_id,
    city_level,
    user_total_order_cnt,
    user_coupon_used_rate_30d,
    coupon_amount,
    coupon_discount_rate,
    -- 注意:这里没有把label和用户id以外的id特征进模型
    datediff(expire_date, get_date) AS valid_days,
    CASE WHEN redeem_date IS NOT NULL THEN 1 ELSE 0 END AS label
FROM dwd_coupon_receive_di
WHERE dt BETWEEN '2024-09-01' AND '2024-11-30'
  AND get_date >= '2024-09-01'
  AND get_date <= '2024-11-30'
  AND (redeem_date IS NULL OR redeem_date <= date_add(get_date, 7))
DISTRIBUTE BY rand();

取完数后,需要把用户ID、券ID这些高基数离散特征排除,只保留统计特征和类别特征。我们用了target encoding(目标编码)处理城市等级等类别特征,这部分不展开。

安装依赖


# 建议用conda建独立环境
# conda create -n automl python=3.11
# conda activate automl

pip install lightgbm==4.3.0 optuna==3.6.1 scikit-learn==1.4.2 pandas==2.1.4 numpy==1.26.3

GridSearchCV基线代码

下面是第一版网格搜索的代码,我用这个代码跑了7个多小时,大家可以直接跑,但建议把参数字典改小一点再试。


# grid_search_baseline.py
import time
import pandas as pd
from sklearn.model_selection import GridSearchCV, StratifiedKFold
from sklearn.metrics import f1_score, roc_auc_score
import lightgbm as lgb

# 加载数据
df = pd.read_parquet("train_data.parquet")
X = df.drop(columns=["user_id", "coupon_id", "label"])
y = df["label"]

# 网格搜索参数空间(已经缩减过,原始项目里更大)
param_grid = {
    "n_estimators": [200, 400],
    "learning_rate": [0.05, 0.1],
    "num_leaves": [31, 63, 127],
    "max_depth": [5, 7, 9],
    "min_child_samples": [20, 50],
    "subsample": [0.7, 0.9],
    "colsample_bytree": [0.7, 0.9],
}

base_model = lgb.LGBMClassifier(
    objective="binary",
    metric="binary_logloss",
    random_state=42,
    n_jobs=8,  # 限制在8核,避免和线上服务抢资源
    verbosity=-1,
)

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

start = time.time()
grid_search = GridSearchCV(
    estimator=base_model,
    param_grid=param_grid,
    scoring="f1",
    cv=cv,
    n_jobs=1,  # 单进程跑,因为8C16G内存不够跑并行多组训练
    verbose=1,
)
grid_search.fit(X, y)

end = time.time()

print(f"网格搜索最佳参数: {grid_search.best_params_}")
print(f"网格搜索最佳F1: {grid_search.best_score_:.4f}")
print(f"总耗时: {(end - start) / 3600:.2f} 小时")

我把参数从200万组缩减到2×2×3×3×2×2×2=288组,跑了7.2小时。如果全量9参数,要跑几个月。关键是:7.2小时换来的最优参数,后来被TPE用20分钟就给超越了。

Optuna TPE贝叶斯优化完整代码

这是最终线上版本的调参代码,可以直接复制,把路径改成自己的数据就能跑。Optuna版本3.6.1。


# optuna_tpe_search.py
import time
import json
import optuna
import pandas as pd
import numpy as np
from sklearn.model_selection import StratifiedKFold
from sklearn.metrics import f1_score, roc_auc_score
import lightgbm as lgb

# 固定随机种子保证可复现
SEED = 42
optuna.logging.set_verbosity(optuna.logging.WARNING)

df = pd.read_parquet("train_data.parquet")
X = df.drop(columns=["user_id", "coupon_id", "label"])
y = df["label"]

# 5折交叉验证,和网格搜索用同样的划分方式
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=SEED)


def objective(trial):
    """Optuna的objective函数:定义超参搜索空间并返回F1分数"""
    params = {
        "objective": "binary",
        "metric": "binary_logloss",
        "verbosity": -1,
        "boosting_type": "gbdt",
        "n_estimators": trial.suggest_int("n_estimators", 100, 800, step=50),
        "learning_rate": trial.suggest_float("learning_rate", 0.01, 0.3, log=True),
        "num_leaves": trial.suggest_int("num_leaves", 16, 255, step=16),
        "max_depth": trial.suggest_int("max_depth", 3, 12),
        "min_child_samples": trial.suggest_int("min_child_samples", 5, 100, step=5),
        "subsample": trial.suggest_float("subsample", 0.5, 1.0),
        "colsample_bytree": trial.suggest_float("colsample_bytree", 0.5, 1.0),
        "reg_alpha": trial.suggest_float("reg_alpha", 1e-4, 10.0, log=True),
        "reg_lambda": trial.suggest_float("reg_lambda", 1e-4, 10.0, log=True),
        "random_state": SEED,
        "n_jobs": 8,
    }

    scores_f1 = []
    scores_auc = []

    for train_idx, valid_idx in cv.split(X, y):
        X_train, X_valid = X.iloc[train_idx], X.iloc[valid_idx]
        y_train, y_valid = y.iloc[train_idx], y.iloc[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(stopping_rounds=50, verbose=False),
                lgb.log_evaluation(period=0),
            ],
        )

        y_pred = model.predict(X_valid)
        y_prob = model.predict_proba(X_valid)[:, 1]

        scores_f1.append(f1_score(y_valid, y_pred))
        scores_auc.append(roc_auc_score(y_valid, y_prob))

    # 取5折F1平均值作为优化目标
    return np.mean(scores_f1)


# 用TPE采样器,n_startup_trials=10表示前10次随机采样(用于初始化概率分布)
sampler = optuna.samplers.TPESampler(
    n_startup_trials=10,
    seed=SEED,
)
# MedianPruner剪枝:如果中间结果低于历史中位数,提前终止
pruner = optuna.pruners.MedianPruner(
    n_startup_trials=10,
    n_warmup_steps=30,
    interval_steps=1,
)

study = optuna.create_study(
    direction="maximize",
    sampler=sampler,
    pruner=pruner,
    study_name="lightgbm_coupon_f1",
)

start = time.time()
study.optimize(objective, n_trials=50, n_jobs=1, show_progress_bar=True)
end = time.time()

print(f"最佳F1: {study.best_value:.4f}")
print(f"最佳参数: {json.dumps(study.best_params, indent=2, ensure_ascii=False)}")
print(f"总耗时: {(end - start) / 60:.2f} 分钟")

# 保存最优参数到JSON,供训练最终版模型使用
with open("best_params.json", "w", encoding="utf-8") as f:
    json.dump(study.best_params, f, indent=2, ensure_ascii=False)

# 保存所有试验记录,方便后续分析
trials_df = study.trials_dataframe()
trials_df.to_csv("trials_log.csv", index=False)

这个代码里有几个关键点:

  • TPESampler的n_startup_trials=10,意思是前10组参数完全随机,只有攒够10个历史试验之后才开始建模。随机种子固定为42,可复现。
  • MedianPruner在LightGBM的early_stopping基础上再做一层剪枝——如果某次试验在第30轮迭代时验证集指标低于历史中位数,就杀掉,不继续训练。这能大幅减少无效训练。
  • 5折交叉验证里面嵌套了LightGBM自带的early_stopping,eval_set用的是当折验证集。

最后用最优参数训练上线模型


# train_final_model.py
import json
import pandas as pd
import lightgbm as lgb
from sklearn.model_selection import StratifiedKFold
from sklearn.metrics import f1_score, roc_auc_score

# 读取TPE搜到的最优参数
with open("best_params.json", "r") as f:
    best_params = json.load(f)

df = pd.read_parquet("train_data.parquet")
X = df.drop(columns=["user_id", "coupon_id", "label"])
y = df["label"]

# 用全量训练集 + early_stopping重新训练
X_train = X
y_train = y

# 注意:最优参数里没有n_estimators,因为early_stopping会自动确定最优迭代次数
final_params = {
    **best_params,
    "objective": "binary",
    "metric": "auc",
    "verbosity": -1,
    "random_state": 42,
    "n_jobs": 8,
}

model = lgb.LGBMClassifier(**final_params)
model.fit(
    X_train, y_train,
    eval_set=[(X_train, y_train)],
    eval_metric="auc",
    callbacks=[
        lgb.early_stopping(stopping_rounds=100, verbose=True),
        lgb.log_evaluation(period=50),
    ],
)

model.booster_.save_model("lgb_coupon_model.txt")

# 输出特征重要性
importance_df = pd.DataFrame({
    "feature": X.columns,
    "importance": model.feature_importances_,
}).sort_values("importance", ascending=False)
print(importance_df.head(20))

效果数据:网格搜索 vs 随机搜索 vs TPE

下面这张表是实测数据,不是估算。测试集是独立的14天数据,不在训练和验证范围内。

策略搜索次数总耗时验证集F1测试集F1测试集AUC
网格搜索(GridSearchCV)288组7.2小时0.7850.7810.8213
随机搜索(300次)300次2.6小时0.8030.7980.8357
Optuna TPE(50次,实际提前收敛)50次21分钟0.8460.8430.8571

几个值得注意的点:

  • TPE跑到第28次试验时,F1就达到了0.844,后面22次基本在0.838~0.846之间小范围波动。所以实际你设置n_trials=30就够了。
  • TPE最终的最佳参数组合里,learning_rate是0.043,num_leaves是143。这个组合在网格搜索的离散空间里根本不存在——网格搜索只有0.05和0.1两个档位,而最优解是0.043,这就是网格搜索效果差的根本原因。
  • 随机搜索300次的效果比网格搜索好,因为在连续空间里随机采样覆盖面更广,但依然远不如TPE。因为随机搜索没有利用历史信息。
  • 耗时上,TPE比网格搜索快了20倍,比随机搜索快了7倍,同时指标还更高。

Early Stopping带来的额外因素

这里有个实验细节要说明。网格搜索跑了7.2小时,原因之一是参数组合里面有learning_rate=0.01的档位,导致每棵树的迭代要跑到1000多轮才收敛。TPE之所以快,一部分原因是它把learning_rate当成log-uniform分布来采样,更容易避开极小的学习率。

另外,我设置了early_stopping(50轮),让每组参数的训练在验证集指标不再上升时提前结束。这比固定n_estimators要省时间,同时也是更严谨的做法。

Trial可视化与趋势分析

Optuna自带可视化工具,虽然不能直接在生产环境用(需要notebook),但在本地分析时非常有用。


# analyze_trials.py
import optuna
import pandas as pd
import matplotlib.pyplot as plt

# 从CSV恢复study
trials_df = pd.read_csv("trials_log.csv")
study = optuna.create_study(direction="maximize")
# 将历史的试验记录加载进study(从DataFrame恢复)
# 这里简单起见,直接画trials_df的趋势图
plt.figure(figsize=(10, 5))
plt.plot(trials_df["value"], marker="o", linestyle="-", alpha=0.7)
plt.xlabel("Trial Number")
plt.ylabel("F1 Score")
plt.title("TPE Search Progress")
plt.grid(True)
plt.savefig("tpe_progress.png", dpi=150)

从趋势图可以看到一个典型现象:前10次随机探索阶段F1在0.77~0.81之间大幅波动,第10次之后TPE开始建模,第15次左右F1直接跳到0.84以上,后面就在窄幅波动。这个现象也验证了TPE“先探索后利用”的特点。

避坑指南

以下坑是我在项目里真实踩过的,每一个都花了一到两天排查。

坑一:learning_rate范围设置不当,搜索效率急剧下降

我第一次用Optuna时,把learning_rate设成均匀分布(uniform),范围0.01到0.3。结果TPE采样大量集中在0.1~0.3,因为历史试验里这个区间指标还不错,但实际上最优解在0.04附近。日志显示试验10到20之间全部落在0.15以上,最佳F1始终没超过0.82。

原因:learning_rate对模型效果的影响是log尺度的,0.01和0.1之间的差距比0.2和0.3之间的差距大得多。需要把参数空间设为对数均匀分布。

正确写法:


learning_rate=trial.suggest_float("learning_rate", 0.01, 0.3, log=True)

reg_alpha、reg_lambda这类正则化参数同样是log尺度。

坑二:Optuna的剪枝策略和LightGBM early_stopping冲突

我用MedianPruner,刚开始没有设n_warmup_steps,结果LightGBM的early_stopping还没触发,Optuna就先砍掉了一大批试验。剪枝本身也有“前N步不剪枝”的预热期,不然前期试验还在训练就被误杀了。

正确配置:


pruner = optuna.pruners.MedianPruner(
    n_startup_trials=10,
    n_warmup_steps=30,
    interval_steps=1,
)

这个配置表示:前10次试验不剪枝,每次试验的前30轮迭代不剪枝。之后每迭代1轮检查一次。

坑三:n_jobs设置导致CPU过载,训练反而变慢

在8C16G的机器上,一开始我把LightGBM的n_jobs设为-1(用满所有核心),同时Optuna的n_jobs也设为4,想并行跑4组试验。结果每组试验占8核,4个并行就是32个线程抢20个物理核,频繁上下文切换,每个试验反而慢了2~3倍,而且还把线上服务打到了CPU 100%,被运维警告。

最终决定:机器留给线上服务余量,训练只用8个线程。Optuna的并行设为n_jobs=1,一次只跑一组试验。虽然排除了并行加速,但TPE本身需要的试验次数很少,单线程就够。

坑四:验证集和测试集分布不一致,搜索出来的参数在测试集上大跌

第一次搜索时,因为Hive分区取数没有过滤用户活跃度,导致训练数据里高活跃用户占比偏高(因为他们更容易领券),而真实上线流量里各种活跃度的用户都有。TPE在验证集上F1有0.85,测试集上只有0.79。后来在取数时增加了按user_id哈希分桶抽样,让训练/验证/测试的用户分布一致,这个问题才解决。

这个坑和数据泄漏无关,纯粹是采样偏差。切记:如果上线流量分布和训练集分布不一致,再好的超参搜索结果也没用。

坑五:Optuna的best_params里包含n_estimators,导致最终模型重复训练

在objective里我定义了n_estimators的范围,并在model.fit时传了params。但LightGBM配合early_stopping之后,n_estimators会被覆盖为early_stopping找到的最佳迭代数。如果再用best_params里的n_estimators去训练最终模型,会出现两个问题:

  • n_estimators是搜索时给的上限值(比如800),最终模型会老老实实训练800棵,忽略early_stopping,导致训练时间暴增且过拟合。
  • 或者,如果搜索时n_estimators太小比如100,而early_stopping认为150轮更好,最终模型就欠拟合。

解决办法:把n_estimators从搜索参数里去掉,或者训练最终模型时显式从best_params里删除这个键。我的代码里在train_final_model.py里做了这个处理。

坑六:不要迷信“搜索出来的参数”直接上线,要做稳定性验证

TPE搜出来的最优参数,和网格搜索搜出来的最优参数,区别在于:网格搜索的每个参数组合都做了5折交叉验证,分数方差可以评估;TPE因为试验次数少,某次试验的5折F1方差可能很大。我的做法是:对TPE给出的top 5参数组合做3次重复5折交叉验证,选平均F1最高且方差小于0.005的那组。

举例:TPE跑出来的最优组合,5折F1分别是0.831、0.859、0.836、0.851、0.847,均值0.8448,方差约0.00012;第二优组合均值0.8431,方差0.00021,虽然均值略低但更稳定。最后线上用了第二优组合,因为上线后更稳。

再聊一点:为什么不直接用AutoML框架(AutoGluon/FLAML)

技术调研时我们试过AutoGluon 1.0和FLAML 2.1。结论:效果都很好,但它们会为了追求精度而引入额外开销(模型集成、多层stacking),导致模型体积和预测耗时都涨了。我们的预测服务是PHP 8.3的扩展方式调用LightGBM的C库,单次预测必须在20ms内。AutoGluon的集成模型动辄几百MB,直接给预测服务带来延迟压力。

最终我们只取Optuna的搜索能力,模型还是单棵LightGBM,预测延迟稳定在4.2ms。

扩展方式示例(PHP调LightGBM预测):


<?php
// predict.php
// 基于 FFI 调用 LightGBM C API,生产环境实测单次预测耗时 4.2ms
$ffi = FFI::cdef("
    typedef void* BoosterHandle;
    int LGBM_BoosterCreateFromModelfile(const char* filename, int* out_num_iterations, BoosterHandle* out_booster);
    int LGBM_BoosterPredictForMat(BoosterHandle booster, const void* data, int nrow, int ncol, int is_row_major, int predict_type, int niter, int begin_iteration, int end_iteration, int* out_len, double* out_result);
    void LGBM_BoosterFree(BoosterHandle booster);
", "liblightgbm.so");

$modelPath = __DIR__ . "/lgb_coupon_model.txt";
$numIters = 0;
$booster = null;
$ret = $ffi->LGBM_BoosterCreateFromModelfile($modelPath, FFI::addr($numIters), FFI::addr($booster));
if ($ret !== 0) {
    throw new RuntimeException("模型加载失败");
}

// 特征数组,和训练时的特征顺序保持一致
$features = [1.0, 0.0, 2.0, 0.35, 12.0, 500.0, 0.8, 30.0];
$nrow = 1;
$ncol = count($features);
// 转换为C double数组
$data = FFI::new("double[$ncol]", false);
foreach ($features as $i => $val) {
    $data[$i] = $val;
}
$predictType = 1; // C_API_PREDICT_NORMAL
$outLen = 0;
$outResult = FFI::new("double[1]", false);
$ret = $ffi->LGBM_BoosterPredictForMat(
    $booster, $data, $nrow, $ncol, 1, $predictType, 0, 0, $numIters,
    FFI::addr($outLen), $outResult
);
if ($ret !== 0) {
    throw new RuntimeException("预测失败");
}
$probability = $outResult[0];
$ffi->LGBM_BoosterFree($booster);
echo json_encode(["probability" => $probability]);

总结

所有搜索策略都在重复做同一件事:在超参数空间里找一组能让评估指标最高的参数。网格搜索是穷举,随机搜索是瞎猜,贝叶斯优化是带着历史经验猜。在超参数超过5个、每个参数有连续范围的情况下,网格搜索和随机搜索都不具备可用性。Optuna TPE是开源社区里最成熟的贝叶斯优化实现,和LightGBM配合,用很少的试验次数就能逼近最优解。

最后给一组建议参数范围(基于本项目实测),如果你也在做类似的二分类模型:

  • learning_rate:0.01~0.1,log均匀分布,别用uniform
  • num_leaves:31~255,和max_depth联动设置,num_leaves不要超过2的max_depth次方
  • min_child_samples:20~100,数值越大越防过拟合
  • subsample和colsample_bytree:0.5~1.0之间各自独立采样
  • reg_alpha、reg_lambda:1e-4~10,log均匀分布
  • n_estimators不参与搜索,交给early_stopping

这套方法跑完以后,我们组内的其他分类项目也照搬了这套流程,包括用户流失预警和支付风控模型。整体算下来,从原先每周调参3天,变成每天上午跑一次搜索,下午就能拿到结果。工具选型选对了,比堆算力省钱得多。