把网格换到贝叶斯时踩过的坑
108 种组合 × 2 小时一轮,算力账单比 deadline 先到。
这次要记的事:一个小型图像分类任务,从 GridSearch 暴力穷举换到 Bayesian Optimization,中间踩了哪些坑、最后怎么收口的。
起因:为什么还要搞超参数搜索
先说清楚问题。最近在做一个小型图像分类任务,数据集不算大,大概 1.2 万张图片,分成 12 个类别。模型用的是 ResNet-18,因为资源有限,也只能用这种轻量级架构。
初次训练的效果还算能接受,验证准确率在 82% 左右,但离项目要求的 85% 还有差距。改个学习率,跑一夜,看结果,再改,再跑——又慢又靠运气。
最要命的是,有个 deadline 挂在那,而按这种"经验+尝试"的节奏,大概率会赶不上。这才意识到:不能再靠感觉调参了,得系统性地搞。
需求:我们到底要解决什么
说实话,这次的需求其实不复杂,但要真的搞清楚却花了一点时间。
目标就四个:两张 2080Ti 上 2 小时内收敛;学习率、权重衰减、momentum、batch size(可能还有 dropout)都要搜到;过程可复现;烂配置早点停,别白烧算力。
方案选择:从 GridSearch 到 Bayesian
一开始的想法很直接:GridSearch。这玩意儿简单,稳定,代码好写,结果好解释。
from sklearn.model_selection import ParameterGrid
param_grid = {
'learning_rate': [0.1, 0.01, 0.001, 0.0001],
'weight_decay': [1e-4, 1e-5, 1e-6],
'momentum': [0.9, 0.95, 0.99],
'batch_size': [32, 64, 128]
}
grid = ParameterGrid(param_grid)
print(f"Total combinations: {len(grid)}")
# Total combinations: 108
108 种组合,每个训练 2 小时,就是 216 小时。这个数字直接让人清醒。就算并行跑,也要好几天。而且问题是,这还是简化版的搜索空间,如果再加几个维度,组合数会爆炸式增长。
这时候才明白:GridSearch 适合"空间小、资源足"的场景,但这次明显不匹配。
决定改用 Bayesian Optimization:用已有 trial 建代理模型,往更有希望的区域探,相当于带着地图找路,而不是盲扫全网格。

上图左侧是 GridSearch 的网格采样,右侧是 Bayesian 的探索轨迹——后者会跟着已评估点的反馈,往更可能有优势的区域聚拢,而不是均匀撒点。
技术选型上,最终选了 Optuna。原因简单:API 清晰,文档全,社区活跃,而且支持 PyTorch 和 TensorFlow。
实现:用 Optuna 跑起来
基础设置
先定义搜索空间和目标函数。这次主要优化学习率、权重衰减、momentum 和 batch size。
import optuna
import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import models
def objective(trial):
# 定义搜索空间
lr = trial.suggest_float('learning_rate', 1e-5, 1e-1, log=True)
weight_decay = trial.suggest_float('weight_decay', 1e-6, 1e-3, log=True)
momentum = trial.suggest_float('momentum', 0.8, 0.99)
batch_size = trial.suggest_categorical('batch_size', [32, 64, 128])
# 初始化模型
model = models.resnet18(pretrained=True)
model.fc = nn.Linear(model.fc.in_features, 12)
optimizer = optim.SGD(model.parameters(),
lr=lr,
momentum=momentum,
weight_decay=weight_decay)
criterion = nn.CrossEntropyLoss()
# 训练一轮(简化版)
for epoch in range(10):
train_loss = train_one_epoch(model, train_loader, optimizer, criterion)
val_acc = validate(model, val_loader)
# 早停
trial.report(val_acc, epoch)
if trial.should_prune():
raise optuna.TrialPruned()
return val_acc
这里有几个细节:
- 对数空间搜索:学习率和权重衰减在对数空间搜索更合理,避免大部分搜索集中在数值小的区域
- 早停机制:
trial.should_prune()可以在中间阶段终止明显不好的配置 - 分类变量:batch size 这种离散变量用
suggest_categorical更合适
创建 Study 并运行
# 创建 Study
study = optuna.create_study(
study_name='resnet18_hyperparam_search',
direction='maximize',
sampler=optuna.samplers.TPESampler(seed=42),
pruner=optuna.pruners.MedianPruner()
)
# 运行优化
study.optimize(
objective,
n_trials=100,
timeout=7200, # 2 小时超时
show_progress_bar=True
)
# 输出最佳结果
print('Best trial:')
trial = study.best_trial
print(f' Value: {trial.value}')
print(f' Params: ')
for key, value in trial.params.items():
print(f' {key}: {value}')
搜索过程可视化
Optuna 内置了可视化功能,能清楚看到搜索过程。
from optuna.visualization import plot_optimization_history
from optuna.visualization import plot_param_importances
from optuna.visualization import plot_parallel_coordinate
# 优化历史
fig = plot_optimization_history(study)
fig.show()
# 参数重要性
fig = plot_param_importances(study)
fig.show()
# 并行坐标图
fig = plot_parallel_coordinate(study)
fig.show()
从这些图中能看出几个有意思的现象:
- 学习率最重要:这个参数对结果影响最大,不同值之间的差异能差 5 个百分点以上
- 权重衰减次之:但在某个区间内变化平缓,说明模型对这个参数不敏感
- batch size 影响最小:在这个数据规模下,32、64、128 效果差不多
这个发现后来被验证了:用最优参数跑完整训练,验证准确率达到了 86.5%,超过了预期的 85%。

这张图展示了贝叶斯优化的典型轨迹:早期探索阶段(蓝色)广泛采样不同区域,中期开发阶段(橙色)向有希望的区域集中,后期收敛阶段(红色)在最优解附近精细搜索。星号标记的是最终找到的全局最优解。
踩坑:这些坑是我真实踩过的
过程中踩的坑不少,这里挑几个典型的说说。
坑一:搜索空间定义不当
一开始把学习率设成了 suggest_float('learning_rate', 0.001, 0.1),结果前 20 个 trial 全集中在 0.1 附近,后来意识到应该用对数空间。
# 错误:线性空间
lr = trial.suggest_float('learning_rate', 0.001, 0.1)
# 正确:对数空间
lr = trial.suggest_float('learning_rate', 1e-3, 1e-1, log=True)
这个问题浪费了不少时间,后来通过 plot_parallel_coordinate 才看出来。
坑二:早停设置太激进
一开始用了很激进的早停策略,结果很多本来有潜力的配置被提前终止了。
# 错误:太激进的早停
pruner = optuna.pruners.MedianPruner(
n_startup_trials=5,
n_warmup_steps=2,
interval_steps=1
)
# 正确:更温和的早停
pruner = optuna.pruners.MedianPruner(
n_startup_trials=10,
n_warmup_steps=5,
interval_steps=3
)
这个参数要根据具体任务调,不是越大越好或越小越好。
坑三:数据泄露
在优化过程中,不小心用了测试集的指标作为优化目标,导致后来模型泛化性很差。这是个经典错误,但很容易犯。
# 错误:用测试集作为优化目标
val_acc = validate(model, test_loader) # 不该用 test
# 正确:用验证集作为优化目标
val_acc = validate(model, val_loader) # 应该用 val
改成验证集后,情况才正常了。后来还是用测试集评估了一次最终模型,确认没过拟合。
结果:到底收获了什么
折腾一圈下来,收获比预期多。
直接收益
- 模型精度提升:从 82% 提升到 86.5%,满足了项目要求
- 搜索效率提高:原来需要 100+ 小时的搜索,现在 2 小时内完成
- 找到最优配置:
- learning_rate: 0.0123
- weight_decay: 4.56e-5
- momentum: 0.934
- batch_size: 64
间接收益
更实在的是一套能复用的搜索流程。后面换任务,改搜索空间、目标函数和评估指标就行,小资源环境下尤其省事。
事后想想
GridSearch 适合空间小、算力足、还想把某个参数影响摸透的时候。Bayesian 适合空间大、时间紧、先找个够用的解——我这次明显是后者。
贝叶斯也不是包治百病:搜索空间设歪了、评估噪声太大、或者要同时抠精度和速度,都会翻车。工具能省体力,搜索范围怎么划、早停多激进、最后拿啥指标交差,还是得自己拍板。
参考:
- Optuna 官方文档:https://optuna.readthedocs.io/
- “A Tutorial on Bayesian Optimization of Expensive Cost Functions” by Bergstra et al.
- PyTorch 官方文档
可用性说明:本文发布于 2021 年 7 月,距今已超过五年。文中涉及的软件版本、接口、下载地址、命令参数和操作界面可能已经发生变化,部分方案在当前环境下可能失效。请结合官方最新文档核对后再操作,生产环境使用前务必先行验证。
版权声明: 本文首发于 指尖魔法屋-把网格换到贝叶斯时踩过的坑(https://blog.thinkmoon.cn/post/281-ai-hyperparameter-search-grid-bayesian-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。