ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

贝叶斯优化调参实战:高斯过程、采集函数与避坑指南

贝叶斯优化调参实战:高斯过程、采集函数与避坑指南

简介:面向机器学习与深度学习实践者的贝叶斯优化超参数调优资源包,适合希望摆脱手工试错、系统理解自动化调参流程的初中级读者。压缩包共4个文件,以2个Python脚本为主,分别对应传统机器学习与深度网络场景,并配有CSV与NPZ格式的数据文件,整体约10.96MB,便于直接运行和对照实验。已有4417人学习,说明该主题在模型性能提升与训练效率优化方面关注度较高。内容围绕高斯过程代理模型与获取函数展开,通过鸢尾花分类和手写数字识别两个任务,演示如何在搜索空间内平衡探索与开发,逐步定位更优的学习率、网络结构及正则化等参数组合。读者可据此理解贝叶斯优化相较网格搜索和随机搜索的优势,掌握将调参策略迁移到自身项目的方法,并积累可复用的实验脚本与数据组织思路。

1. 贝叶斯优化:为什么调参的瓶颈从来不是算力,而是“没有记忆”

调参调多了会发现一个反直觉的事实:贝叶斯优化带来的收益,大部分不是来自搜得更快,而是来自不再浪费。深度学习项目里,学习率从 1e-3 挪到 3e-4,验证集指标跳两个点,再挪回去又回不来——这种玄学背后是目标函数本身噪声极大、单次评估极贵。网格搜索和随机搜索的毛病不在慢,在于没有记忆:第 40 次试验对前 39 次一无所知,预算被均匀摊在整片空间,包括早就被证明很差的角落。贝叶斯优化把试过的点压成一个关于目标函数的概率模型,让每一次试验都去改变下一次采样的分布。用 Python 写机器学习流程的人迟早会撞上这个场景:一次训练半小时,手上只有二十次预算,该往哪儿下注。

2. 代理模型:高斯过程凭什么用二十个点猜出整片损失曲面

2.1 代理模型替代的不是函数,而是“函数的不确定性”

先把问题形式化。超参数优化的目标函数是 f(λ) = 验证集指标,λ 是超参数向量。这个函数没有解析形式、没有可用梯度、每次求值要跑一次完整训练,输出还带噪声。所有基于梯度的优化手段在这里全部失效,剩下的只有采样。

网格搜索和随机搜索做的都是无记忆采样:采样分布 p(λ) 从头到尾不变。贝叶斯优化多做的唯一一件事,是在每轮采样前先用已有观测拟合一个代理模型,再用它决定下一个点。

“代理模型”这个叫法容易误解成“近似函数”。它真正提供的是一个条件分布 p(y | λ, D),D 是已有观测集合。这个区别决定了一切——有了分布才有方差,有了方差才能算出“这个位置还剩多少不确定性可挖”,采集函数才有东西可算。如果代理模型只输出一个点预测,整个框架就退化成“用模型猜最优点”,那和直接训一个回归器没区别,也就没有探索可言了。

2.2 后验均值和后验方差:一次 Cholesky 分解算出的两件事

高斯过程的核心假设只有一句:任意有限个输入点上的函数值,服从一个联合高斯分布。给定均值函数 m(x) 和核函数 k(x, x'),先验写成 f ~ GP(m, k)。观测到 D = {(x_i, y_i)} 之后,后验仍然是高斯分布,且存在闭式解:

μ(x*) = k*ᵀ (K + σ²I)⁻¹ y σ²(x*) = k(x*, x*) − k*ᵀ (K + σ²I)⁻¹ k*

其中 K 是 n×n 的 Gram 矩阵,K_ij = k(x_i, x_j),k* 是新点与所有已观测点的核向量。两条式子里出现的是同一个逆矩阵,这就是全部计算量所在。实现上不会真的求逆:对 K + σ²I 做 Cholesky 分解得到下三角矩阵 L,然后前代、回代解两个三角方程。拟合复杂度 O(n³) 来自分解,单次预测 O(n²)。

这里 n 是试验次数,通常几十到几百,相对一次模型训练可以忽略不计。但一旦 n 上千,GP 自己就成了瓶颈——这是后面 TPE 那类方法存在的现实理由,不只是为了多样而多样。

方差那条式子里藏着一个容易被忽略的性质:σ²(x*) 只依赖 x 的位置,不依赖观测到的 y 值。换句话说,不确定性的形状完全由“哪些位置试过”决定,和“试出来多好”无关。这就是为什么选点时必须同时看 μ 和 σ,只看 μ 等于放弃了整个探索维度。

2.3 核函数与它的“超超参数”:Matern 为什么比 RBF 更常用

核函数 k 编码的是先验信念:相近的 x 应该给出相近的 y,而且这个“相近”有多光滑。RBF(平方指数核)假设函数无限可微,Matern ν=5/2 只假设二阶可微。超参数响应面通常有平台、有拐点、有分段行为——学习率跨过某个量级直接崩,batch size 大到一定程度收益饱和——RBF 会把这种不光滑强行抹平,在拐点附近给出过度自信的预测,然后算法就再也不敢往那儿采了。

实战里默认用 Matern 5/2,几乎不需要犹豫。

import numpy as np from sklearn.gaussian_process import GaussianProcessRegressor from sklearn.gaussian_process.kernels import ConstantKernel, Matern, WhiteKernel def fit_surrogate(X, y, d): """X: (n, d) 已归一化到 [0,1];y: (n,) 越大越好的指标""" kernel = ( ConstantKernel(1.0, (1e-3, 1e3)) # 输出尺度,等价于先验方差 * Matern( length_scale=np.full(d, 0.3), length_scale_bounds=(5e-2, 1e2), # 下界别给太小,否则退化成白噪声 nu=2.5, ) + WhiteKernel(noise_level=1e-3, # 小验证集的指标抖动很大 noise_level_bounds=(1e-6, 1e-1)) ) gp = GaussianProcessRegressor( kernel=kernel, normalize_y=True, # 把 y 拉成零均值单位方差,否则先验均值完全错位 n_restarts_optimizer=5, # 对数边际似然非凸,多起点重启 alpha=1e-8, # 数值 jitter,防止 Cholesky 分解失败 random_state=0, ) gp.fit(X, y) return gp def posterior(gp, Xcand): mu, std = gp.predict(Xcand, return_std=True) return mu, np.maximum(std, 1e-9)

几个参数值得单独说。normalize_y=True内部会把 y 标准化后再拟合、预测时再反变换,这是最容易被漏掉的一行:不做的话,ConstantKernel 得靠自己把量级拟合出来,而边际似然的优化经常卡在局部,拟合出的尺度会偏得离谱。n_restarts_optimizer是在核超参数空间(length_scale、noise、amplitude)里反复跑 L-BFGS,因为对数边际似然不是凸函数,单起点很容易停在坏解上。alpha加在对角线上,纯粹为了数值正定,取值比信号方差小两三个数量级即可。return_std=True返回的后验标准差,就是采集函数的第二个输入。

gp.kernel_.theta能取出拟合完成的核参数,这个调试入口非常有用。如果某一维的 length_scale 顶到上界,说明这一维几乎不影响结果,可以考虑固定或直接删掉;如果顶到下界,说明这一维的响应是白噪声级的剧烈震荡,多半是搜索区间给错了——学习率、weight decay 这类跨数量级的参数必须在对数空间采样,用线性区间喂给 GP,等于人为制造了一个高频函数。

2.4 维度上去之后,GP 为什么会失效

GP 的样本复杂度随维度指数上升。10 维以内很舒服,20 维以上基本退化成随机搜索——这不是实现问题,而是“用有限个点填满高维空间”这件事本身不成立。所以工程上真正有效的优化通常不是换更强的代理模型,而是砍维度:先通过敏感性分析固定掉一批不敏感的超参数,把跨数量级的参数放进 log 空间,类别型参数单独编码。

还有一类更麻烦的情况:条件搜索空间。比如某个正则项只在选中特定优化器时才存在,此时 GP 的输入空间形状不规则,核函数无法直接定义距离。这种场景下改用基于密度估计的方法会省事得多,后面会展开。

3. 采集函数:EI、UCB、PI 在权衡的其实是同一件事

3.1 EI 的解析式是怎么推出来的

把当前最好的观测值记为 f*,定义改进量 I(x) = max(f(x) − f* − ξ, 0)。因为后验是高斯的,f(x) ~ N(μ, σ²),这个截断期望存在闭式解:

EI(x) = (μ − f* − ξ) Φ(z) + σ φ(z), z = (μ − f* − ξ) / σ

Φ 是标准正态的累积分布函数,φ 是概率密度函数。两项的含义很清晰:第一项是“均值比 incumbent 好多少”乘以“好过 incumbent 的概率”,属于纯利用;第二项 σ·φ(z) 是纯探索,只要 σ 大就有贡献,哪怕 μ 远远落后。

这就是 EI 不需要手工调探索系数却能自动平衡的机制:模型自己在不同区域切换主导项。σ → 0 时 EI → 0,已经确定的点不会被反复采;μ 远低于 f* 时第一项为负,但被第二项抵消,不会变成负数去惩罚探索。

ξ 控制“至少要好多少才算改进”。ξ = 0 时算法倾向于在 incumbent 附近做极小抖动,因为任何微小正改进都会被计入;ξ 调大会把搜索推向高不确定区域。经验值取目标函数量级的百分之一,配合normalize_y之后就是 0.01 这个数。这个参数踩过的坑不少——ξ 设成 0,后期会死锁在原地;设成 0.1 又几乎变成纯探索。

3.2 UCB 的 κ 与 PI 的短视:两种失败的探索

UCB 的形式是 μ + κσ,κ 是显式的探索系数。问题在于 κ 不会随迭代自动收敛:固定的 κ 在优化后期仍然会持续扑向高方差区域,收敛速度明显不如 EI;想让它收敛就得按 schedule 衰减(比如 κ_t = κ₀ / √t),于是又多了一个要调的超参数。用贝叶斯优化去调贝叶斯优化的探索系数,听着荒诞,但确实有人这么干过——这本身就是设计味道不对的信号。

PI 是 Φ((μ − f* − ξ)/σ),只看“好过 incumbent 的概率”,不看“好多少”。后果是它对“可能提升 0.001 但概率 60%”和“可能提升 0.2 但概率 40%”这两个点一视同仁,非常短视,早期探索能力弱。它在收敛末期反而表现不错,因为那时确实只需要在最优附近微调。

三者的差别可以压缩成一句话:PI 只看概率,UCB 用手调的 κ 加权,EI 用后验分布自己算出权重。所以默认选 EI,只有在目标函数噪声极大、需要更激进的探索时才考虑 UCB。

3.3 内层优化:最大化 EI 本身也是个非凸问题

采集函数的最大化是嵌套在 BO 里的第二个优化问题,而且它非凸、梯度不一定好用。工程上有两种主流做法。

第一种是候选点采样:用 Sobol 序列或拉丁超立方在搜索空间撒 2000 到 10000 个点,批量算出 EI,取 argmax。简单、可向量化、天然支持类别变量和整数参数,缺点是分辨率受候选集规模限制。

第二种是多起点梯度优化:以候选集里 top-k 的点为起点跑 L-BFGS-B,边界就是搜索空间。精度更高,但对离散参数和条件空间支持很差。

绝大多数开源实现走第一种,因为鲁棒性比精度重要得多。

from scipy.stats import norm def expected_improvement(mu, std, best, xi=0.01): """mu/std: 候选点上的后验均值与标准差;best: 当前最优观测值""" std = np.maximum(std, 1e-9) z = (mu - best - xi) / std ei = (mu - best - xi) * norm.cdf(z) + std * norm.pdf(z) ei[std < 1e-9] = 0.0 # 后验完全确定的点不再采 return ei def propose(gp, Xcand, best, xi=0.01): mu, std = posterior(gp, Xcand) ei = expected_improvement(mu, std, best, xi) if ei.max() <= 0: return None # 触发停止条件 return Xcand[int(np.argmax(ei))]

ei[std < 1e-9] = 0.0这行不是多余的:当候选点与某个已观测点重合时,σ 会算成 0 或极小负数,z 变成 inf,norm.cdf(inf)返回 1 而norm.pdf(inf)返回 0,乘出来是inf * 0得到 NaN。这个 NaN 一旦进入 argmax,会静默地把整个候选集污染掉,表现为“优化器突然开始乱采点”,排查起来极其难受。

propose返回 None 是一个实用的停止信号:所有候选点的 EI 都不大于 0,说明在当前 ξ 下已经没有值得尝试的区域,继续跑只是在烧算力。另一个常见停止条件是连续 k 轮(比如 5 轮)观测值没有刷新最优,或者预算用完。

4. 一次 ask-tell 循环:从冷启动采样到 incumbent 更新的完整时序

4.1 为什么 TPE 敢不用高斯过程

TPE(Tree-structured Parzen Estimator)走的是完全不同的路:它不做回归,做密度估计。把已有观测按目标值排序,取前 γ(典型 25%)作为“好组”,剩下作为“坏组”,各自用核密度估计拟合出 p(x | y_good) 和 p(x | y_bad),采集函数取比值:

l(x) = p(x | y_good) / p(x | y_bad)

直观解释是:这个点在好组里出现的密度,比在坏组里高多少倍,就在多大程度上值得试。

它敢绕开 GP 有几个硬理由。KDE 是逐维独立的,拟合复杂度 O(n·d),n 上千也不怕求逆;对离散、类别、条件搜索空间天然支持,某个参数只在特定分支下存在时,只要把样本按分支分开统计就行;GP 那套核函数和长度尺度在树结构空间里根本没法定义,TPE 完全绕过了这个问题。

代价同样明确:它没有后验方差,探索完全靠 γ 和 KDE 带宽撑着,样本很少的早期非常不稳,容易在两个局部最优之间来回横跳。另一个细节是 TPE 里的 incumbent 不是某个观测最大值,而是“好组”的分布本身,这让它对噪声的容忍度反而比 GP 高一些。经验上,试验次数超过几百之后,TPE 通常比 GP 更实用;试验次数只有二三十次时,GP 的信息利用率明显更高。

4.2 一次迭代的六步时序与最小实现

把整个循环拆开,一次迭代是六步:

  1. 判断是否处于冷启动阶段,是则纯随机采样;
  2. 用已有观测拟合代理模型;
  3. 在候选集上计算采集函数,选出下一个点;
  4. 把归一化空间的点解码回真实参数(对数反变换、整数取整、边界裁剪);
  5. 调用目标函数训练评估——整个循环里唯一昂贵的一步;
  6. 回填观测、更新 incumbent、检查停止条件。
def bo_loop(objective, space, n_trials=60, n_startup=10, xi=0.01, seed=0): rng = np.random.default_rng(seed) X, y = [], [] keys = list(space) lo = np.log([space[k][0] for k in keys]) # 全部按 log 空间处理 hi = np.log([space[k][1] for k in keys]) for t in range(n_trials): if t < n_startup or len(X) < 5: u = rng.uniform(0.0, 1.0, size=len(keys)) # 冷启动:单位超立方体均匀撒点 else: Xn = (np.array([[np.log(x[k]) for k in keys] for x in X]) - lo) / (hi - lo) gp = fit_surrogate(Xn, np.array(y), Xn.shape[1]) cand = rng.uniform(0.0, 1.0, size=(4000, len(keys))) mu, std = posterior(gp, cand) ei = expected_improvement(mu, std, best=max(y), xi=xi) u = cand[int(np.argmax(ei))] x = {k: float(np.exp(lo[i] + u[i] * (hi[i] - lo[i]))) for i, k in enumerate(keys)} score = objective(x) # 唯一的昂贵调用,务必最后执行 X.append(x) y.append(score) return X[int(np.argmax(y))], max(y)

n_startup我一般取 max(10, 2d+2)。原因很实在:没有观测时 GP 的后验等于先验,EI 在任何点上都一样大,最大化它等价于随机;而且 length_scale 无从拟合。冷启动阶段用 log-uniform 而不是均匀分布,是为了让 1e-5 到 1e-2 这个区间不至于只被抽到一两次。

Xn和cand必须在同一个归一化空间里,否则 length_scale 的含义会随参数尺度漂移——这是个非常隐蔽的 bug,表现为模型对某个参数异常敏感或异常迟钝,但代码看起来毫无问题。候选集这里用均匀随机只是为了代码短,正式实现换 Sobol 序列分布更均匀。

4.3 并行与多保真:在途点和低精度评估怎么处理

并行是绕不开的需求。同时开 8 个 worker,就会出现“在途点”(pending trials):这些点已经提交但结果还没回来,模型不知道它们的存在,采集函数会在同一个位置重复推荐。常见的补救是 constant liar:先给在途点填一个虚拟目标值(比如当前最差值),拟合进模型再采下一个,采完更新;另一种是 kriging believer,用后验均值填充。两种做法都只是缓解,本质上是并行度越高、代理模型的信息越滞后。

多保真是另一条正交的优化路径。一次完整训练太贵时,先用低精度代理(前几个 epoch 的 loss、子采样数据集、小模型)做粗筛。Successive Halving 和 Hyperband 是这个思路的两个变体:撒一批配置,各给很少的资源,按表现砍掉一半,剩下的加资源继续。它和 BO 解决的不是同一个问题——BO 决定“试哪个配置”,多保真决定“这个配置值得给多少资源”。成熟实现里两者通常组合:用 BO 采样,用 pruner 提前终止明显没希望的 trial。

这里有个容易忽略的耦合:如果 pruner 砍掉的 trial 的目标值是按“半程表现”估算的,那它和完整训练的目标值不在同一个尺度上,直接混在一起喂给 GP 会污染整个模型。要么给这类观测单独设一个高噪声项,要么只把完整跑完的结果作为正式观测。

5. 避坑与排查:贝叶斯优化跑不出效果的五个典型现场

5.1 采样阶段:点堆在角落,或者干脆像随机搜索

现象一:前十几次试验的结果和随机搜索没差别,之后曲线也没抬起来,跑完二十次试验只比基线好一点点。

原因:初始点太少或者维度太高,GP 后验基本等于先验,采集函数在哪儿都一样大,选点退化成随机。二十维空间里给十个初始点,对 GP 来说信息量接近于零。

解决:n_startup至少取 2d+2;维度超过 20 先做敏感性筛选或直接换 TPE;如果单次评估噪声大(小验证集、单折验证),同一个点重复评估 3 次取均值,并把 WhiteKernel 的噪声下界抬高一点,让模型知道“这点差异不算差异”。

现象二:大约第 15 次开始,优化器反复在同一个角落采点,后验标准差几乎不变,EI 却一直最高。

原因:length_scale 被拟合到上界,模型认为整片空间极度平滑、任何位置的 μ 都差不多,于是后验方差被人为压在一个区域内;另一种可能是 WhiteKernel 把真实信号当噪声吃掉了,σ 被系统性低估,EI 的第二项失去作用。

解决:打印gp.kernel_.theta检查 length_scale 是否顶界;给length_scale_bounds设一个合理的下界(比如 0.05),不让它无限变大;确认 y 没有被归一化两次(normalize_y已经做了一次,外面再手动标准化一遍会让噪声估计完全错位)。

5.2 目标函数与复现性:NaN、种子和验证集过拟合

现象三:训练 OOM 或者 loss 变 NaN 的那几个 trial,最后反而被选成了最优配置。

原因:objective 里用 try/except 把异常吞了,return 0;如果指标是 loss(越小越好),0 就是全场最优。这个 bug 极其常见,而且日志上看一切正常。

解决:失败必须返回一个明确的惩罚值——loss 场景返回 inf 或者一个比历史最差还大的常数,绝对不能返回 0、None 或 NaN。GP 对 NaN 会直接抛异常,看起来像代码崩了,其实是数据问题;而静默返回 0 更危险,它不报错,只是悄悄把优化方向带偏。

现象四:同样的代码跑两遍,找到的最优点差一个数量级。

原因:随机种子没固定;或者用了并行 sampler,采样顺序不确定,同一个 trial 无法复现。

解决:固定 sampler 的 seed 和 objective 内部的种子;并行时给每个 worker 独立且确定性的 sampler,不要在共享 study 上做非确定性 ask;把每次 trial 的参数和结果原样落盘,事后能重放整个序列。

现象五:BO 在验证集上找到的最优点,换到独立测试集指标掉了一大截。

原因:验证集本身噪声大,而 BO 的寻优能力会主动去拟合这部分噪声——它比随机搜索更容易过拟合验证集,这是概率模型带来的副作用,不是 bug。

解决:目标函数用 k 折均值而不是单折;验证集样本量要够,宁可多花训练时间;最终结果在完全独立的测试集上报告,且坚决不把测试集结果反哺给 BO,否则下一次的“泛化”就是自欺欺人。

6. 设计取舍:代理模型、搜索维度与评估预算之间的三角

选哪种代理模型,本质上是被三个约束共同决定的:单次评估多贵、搜索空间多少维、预算多少次试验。这三者构成一个不太可能同时满足的三角,任何实现都必须妥协。

代理模型拟合复杂度维度适应性离散/条件空间是否给方差典型适用
高斯过程O(n³)20 维以内差是评估极贵、维度低、需要严谨的探索度量
TPE(密度比)O(n·d)中高维较稳好否树结构/条件空间、几百次试验
随机森林代理O(n log n)·树数中高维一般近似离散参数多、噪声大

顺着这张表往下推,什么情况下不该用贝叶斯优化也就清楚了。单次评估便宜到能跑几千次时,随机搜索配合早停的实际收益往往更高——BO 的建模开销和串行性在这个量级上反而成了负担。维度超过 50 时,GP 和 TPE 都在盲猜,先做降维比换算法有用得多。目标函数完全不可重复(比如每次评估的随机性远大于参数带来的差异),任何代理模型都只是在拟合噪声。

真正让我改变习惯的是一次不太好看的事故:一个小数据集任务上,BO 在二十次试验里就把验证集刷到了历史最高,我直接拿那个配置上了线,测试集掉了三个点。事后回看,那二十次试验里有十四次的差异都在验证集噪声范围内,模型忠实地把噪声当成了信号。从那以后我每次开 BO 之前都强制走一遍同一套检查:目标函数是不是 k 折均值、失败路径返回的是不是明确的惩罚值、候选空间是不是 log 采样、n_startup有没有到 2d+2。这四行检查花不了五分钟,但能省掉一整个下午的自我怀疑。

压缩包里那套从零实现的 GP + EI 脚本和参数笔记,价值也在这里:它把normalize_y、length_scale_bounds、xi、冷启动轮数这些平时藏在库默认值后面的东西全摊开了,改一行就能看到后验形状怎么变。把上面的机制对着代码走一遍,比读十篇调参经验贴管用。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进