在目标函数中加两行 early-reporting 代码,让 Optuna 自动剪枝效果差的试验,将调参时间从 13 秒降至 9.4 秒,logloss 仅多 0.0005。
一句话总结:在 objective 函数里多写两行——随着模型训练汇报分数,然后询问是否中止——就能让 Optuna 在 12/20 的试验未完成前就将其抛弃,将时钟时间从 13.0 秒压缩到 9.4 秒,代价是 log loss 只增加了 0.0005;节省的幅度不等于淘汰率,因为被修剪的试验仍需跑完预热阶段,这是没人提到的细节。
所有网格搜索都有一个共同的缺陷:它会把每个候选配置跑到底,包括那些在工作量只进行了 20% 时就已经明显无望的配置。如果是人类盯着训练曲线,会直接把那些试验 kill 掉。Pruning(修剪)就是让这个判断自动化。
WHAT PRUNING ACTUALLY DOES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
trial 7, learning_rate=0.011, depth=2
40 trees logloss 0.61 <- median so far: 0.28
80 trees logloss 0.52 <- still miles behind
-> KILLED
120, 160, 200 trees: never run.
A grid search would have finished this trial, then
politely reported that it was bad.
trial.report(loss, step) tell optuna
if trial.should_prune(): ... let it decide
这就是整个机制。你把一个跨试验可比较的进度信号交给 Optuna,让它去停止那些已经落后的试验。
可以直接运行。Optuna 特有的概念只有三个:suggest_* 定义搜索空间,report 上报进度,should_prune 给出判决。
import warnings, numpy as np, time; warnings.filterwarnings("ignore")
import optuna; optuna.logging.set_verbosity(optuna.logging.WARNING)
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import log_loss
X, y = make_classification(n_samples=1500, n_features=15, n_informative=6,
flip_y=0.03, random_state=42)
Xtr, Xva, ytr, yva = train_test_split(X, y, test_size=0.3, random_state=42, stratify=y)
def objective(trial):
lr = trial.suggest_float("learning_rate", 0.01, 0.3, log=True)
depth = trial.suggest_int("max_depth", 2, 5)
sub = trial.suggest_float("subsample", 0.6, 1.0)
clf = GradientBoostingClassifier(n_estimators=200, learning_rate=lr, max_depth=depth,
subsample=sub, random_state=0, warm_start=True)
best = 9.9
for n in range(40, 201, 40): # grow the model in steps
clf.set_params(n_estimators=n); clf.fit(Xtr, ytr)
loss = log_loss(yva, clf.predict_proba(Xva)[:, 1])
best = min(best, loss)
trial.report(loss, n) # tell optuna how it's going
if trial.should_prune(): # let optuna stop a hopeless trial
raise optuna.TrialPruned()
return best
for label, pruner in [("no pruner", optuna.pruners.NopPruner()),
("median pruner", optuna.pruners.MedianPruner(n_startup_trials=5,
n_warmup_steps=2))]:
t0 = time.perf_counter()
st = optuna.create_study(direction="minimize",
sampler=optuna.samplers.TPESampler(seed=0), pruner=pruner)
st.optimize(objective, n_trials=20, show_progress_bar=False)
el = time.perf_counter() - t0
pruned = sum(1 for t in st.trials if t.state == optuna.trial.TrialState.PRUNED)
print(f"{label:<15} best logloss {st.best_value:.4f} {el:>6.1f}s "
f"pruned {pruned:>2}/20")
no pruner best logloss 0.1908 13.0s pruned 0/20
median pruner best logloss 0.1913 9.4s pruned 12/20
修剪机制杀掉了 20 个试验中的 12 个——占搜索空间的 60%——节省了 28% 的时钟时间。这两个数字不应该相等,差距才是有价值的信息:一个被修剪的试验并不是免费的。它在有足够证据被 kill 之前已经跑了预热步骤,所以无论怎样你都要为每个差试验的前期阶段买单。n_warmup_steps=2 在这里意味着两次上报之后试验才有资格被修剪,这是你能节省的底线。
如果你看到修剪被宣传为"减少 60% 的试验,节省 60% 的时间",那是广告,不是实测。
它付出了 0.0005 的 log loss 代价——不加修剪是 0.1908,加了是 0.1913。修剪可能抛弃一个后期才发力的试验;有些配置在 80 棵树时看起来很差,到 200 棵树时却表现极好。这个风险是真实存在的,在这里它花了半千分之一。值得,也值得知道你为此付出了多少。
这种权衡会随试验成本而放大。在整个 study 只要 13 秒的情况下,这点优化无关紧要。但在 13 小时的情况下,这就是一夜跑完和跑三夜的区别。修剪是一个时钟时间优化器,所以它的价值与每次试验的成本成正比。
在一个真实项目里有一件事值得做得不一样:这个例子用单一验证集来判断是否修剪,这正是它能成为五分钟演示的原因。在生产环境中,你希望修剪信号来自交叉验证,否则你就是在用单次划分的噪声做修剪——而这个噪声正是前两篇文章所讨论的内容。
两行代码买下整个机制——trial.report(loss, step) 和 if trial.should_prune(): raise optuna.TrialPruned()。其他都是你本来就有的搜索空间。
两行代码买下整个机制——trial.report(loss, step) 和 if trial.should_prune(): raise optuna.TrialPruned()。其他都是你本来就有的搜索空间。
杀掉了 60% 的试验只节省了 28% 的时间,因为预热步骤在任何试验被修剪之前就已经跑完了。永远不要把淘汰率当作加速比来引用。
杀掉了 60% 的试验只节省了 28% 的时间,因为预热步骤在任何试验被修剪之前就已经跑完了。永远不要把淘汰率当作加速比来引用。
它付出了 0.0005 的 log loss 代价(0.1908 到 0.1913)——修剪偶尔会丢弃一个后发制人的配置,这就是时钟时间的代价。
它付出了 0.0005 的 log loss 代价(0.1908 到 0.1913)——修剪偶尔会丢弃一个后发制人的配置,这就是时钟时间的代价。
Optuna 的 pruner 把网格搜索最坏的习惯——把早已无望的试验跑完——变成了一次自动化的判断。本例实测中它抛弃了 20 个试验中的 12 个,将 13.0 秒压缩到 9.4 秒,换来 0.0005 的 log loss 损失,同时有一个诚实的备注:60% 的淘汰率只买来了 28% 的节省,因为每个被修剪的试验仍需为它的预热阶段买单。
嵌套交叉验证——在不让同一份数据燃烧两次的情况下调参与汇报。
基于 CV 信号进行修剪——如何避免在单次划分的噪声上做修剪。
多目标调优——当你需要同时考虑精度和延迟时。
搜索空间的谎言——为什么对学习率做均匀采样几乎总是错的。
关注我,等待超参数调优系列的下一篇文章!
如果今晚就让你替换掉了网格搜索,点个小心心!
有问题?在评论区提问——我会阅读并回复每一条。
你最久让一个网格搜索跑了多久?我曾经跑过 19 个小时,横跨 486 个候选配置,当我在之后终于画出曲线时,大约三分之二的试验在训练的前 15% 就已经明显死掉了。⏱️
修剪让人感觉像作弊的原因是,它是唯一需要在试验中途形成判断的优化。网格搜索和随机搜索把一次试验当作原子操作:你问一个问题,等着,拿一个数字。修剪把训练曲线当作证据本身来对待——只要你曾经盯着一条损失曲线,在第三个 epoch 就知道它好不了,这个道理就不言自明。
复制上面的 objective,换上你的模型,然后删掉你的网格。