超参数调优:在指数级空间里聪明地搜索 本节摘要:超参数(Hyperparameters)是训练开始前你拧的那些旋钮。拧得好与拧得差,是一个平庸模型与一个优秀模型的差别。你的梯度提升模型有学习率、树数、最大深度、叶最小样本数、子采样率、列采样率——六个超参数,每个 5 个合理取值,网格就有 5^6=15,625 种组合,每个训练 10 秒就是 43 小时算力。本节讲透如何聪明地搜索:网格搜索穷举但指数爆炸;随机搜索在多数超参数「有效维度低」时以相同预算覆盖更重要维度;贝叶斯优化用代理模型记住过去的评估、不再浪费算力在明显差的区域,以 25 倍更少评估找到更好配置。我们还会讲学习率调度、早停(Hyperband)、嵌套交叉验证,以及哪些超参数真正重要(学习率永远第一)。
本节摘要:超参数(Hyperparameters)是训练开始前你拧的那些旋钮。拧得好与拧得差,是一个平庸模型与一个优秀模型的差别。你的梯度提升模型有学习率、树数、最大深度、叶最小样本数、子采样率、列采样率——六个超参数,每个 5 个合理取值,网格就有 5^6=15,625 种组合,每个训练 10 秒就是 43 小时算力。本节讲透如何聪明地搜索:网格搜索穷举但指数爆炸;随机搜索在多数超参数「有效维度低」时以相同预算覆盖更重要维度;贝叶斯优化用代理模型记住过去的评估、不再浪费算力在明显差的区域,以 2~5 倍更少评估找到更好配置。我们还会讲学习率调度、早停(Hyperband)、嵌套交叉验证,以及哪些超参数真正重要(学习率永远第一)。
阅读完本节,你应当能够:
你的梯度提升模型有学习率、树数、最大深度、叶最小样本数、子采样率、列采样率——六个超参数。每个 5 个合理取值,网格就是 5^6 = 15,625 种组合。每个训练 10 秒,全部试一遍是 43 小时算力。
网格搜索是最直觉的思路,也是规模上来后最糟的思路。随机搜索用更少算力做得更好。贝叶斯优化通过从过去评估中学习,做得更好。知道用哪种策略、哪些超参数真正重要,能省下几天的浪费 GPU 时间。
参数(Parameter)在训练中学习(权重、偏置、分裂阈值)。超参数在训练开始前设定,控制学习如何发生。
| 超参数 | 控制什么 | 典型范围 |
|---|---|---|
| 学习率 | 每次更新的步长 | 0.001 到 1.0 |
| 树数 / epoch 数 | 训多久 | 10 到 10,000 |
| 最大深度 | 模型复杂度 | 1 到 30 |
| 正则化(lambda) | 防过拟合强度 | 0.0001 到 100 |
| 批大小 | 梯度估计噪声 | 16 到 512 |
| Dropout 率 | 失活神经元比例 | 0.0 到 0.5 |
网格搜索评估指定取值的每种组合。穷举、好懂,但随超参数数量指数爆炸。
2 个超参数的网格: learning_rate: [0.01, 0.1, 1.0] max_depth: [3, 5, 7] 评估数:3 x 3 = 9 种组合 (0.01, 3) (0.01, 5) (0.01, 7) (0.1, 3) (0.1, 5) (0.1, 7) (1.0, 3) (1.0, 5) (1.0, 7)
网格搜索有根本缺陷:如果一个超参数重要而另一个不重要,多数评估是浪费的。9 次评估里,那个重要参数你只测到 3 个不同值。
随机搜索从分布里采样超参数,而不是用网格。同样的 9 次评估预算,每个超参数你都能测到 9 个不同值。
随机胜网格的原因(Bergstra & Bengio, 2012):
随机搜索忽略结果。它不会学到「高学习率导致发散」或「深度 3 持续优于深度 10」。贝叶斯优化用过去的评估来决定下一步搜哪里。
两个关键组件:
代理模型(Surrogate Model):一个评估成本低的模型(通常高斯过程,Gaussian Process),近似那个昂贵的真实目标函数。它在搜索空间任意点既给出预测又给出不确定性估计。
采集函数(Acquisition Function):通过权衡开发(exploitation,在已知好点附近搜)和探索(exploration,在不确定性高的地方搜)来决定下一步评估哪里。常见选择:
贝叶斯优化通常比随机搜索用 2~5 倍更少的评估找到更好超参数。拟合代理模型的开销相比训练真实模型可忽略。
不是每次训练都需要跑完。如果一个配置在 10 个 epoch 后明显很差,就停掉它转向下一个。这是超参数搜索语境下的早停。
策略:
Hyperband 特别有效。它启动 81 个配置各跑 1 个 epoch,保留前 1/3 给它们 3 个 epoch,再保留前 1/3……这样比把所有配置跑满预算快 10~50 倍找到好配置。
学习率几乎永远是最重要的超参数。与其保持固定,调度器会在训练中调整它。
| 调度器 | 公式 | 何时用 |
|---|---|---|
| 阶梯衰减 | 每 N 个 epoch 乘 0.1 | 经典 CNN 训练 |
| 余弦退火 | lr * 0.5 * (1 + cos(pi * t / T)) | 现代默认 |
| 预热+衰减 | 线性升再余弦降 | Transformer |
| 单周期 | 一个周期内先升后降 | 快速收敛 |
| 平台衰减 | 指标停滞时降一档 | 安全默认 |
不是所有超参数同等重要。随机森林(Probst 等,2019)和梯度提升的研究显示一致规律:
高重要性:
中等重要性:
低重要性:
先调重要的,其余留默认。
具体流程:
在单一验证切分上调超参有风险——最佳超参可能过拟合到那个特定验证折。嵌套交叉验证(Nested Cross-Validation)用两个循环解决:
每个外层折独立找自己的最佳超参。外层分数是泛化性能的无偏估计。
⚠️ 嵌套 CV 很贵(5 外层 x 5 内层 x 27 网格点 = 675 次模型拟合),但给你可信的性能估计。在论文报告最终结果、或决策利害攸关时用它。
先调学习率。 它对基于梯度的方法永远是最重要的超参数。一个坏学习率让其他一切都不重要。把其他超参固定在默认值,先扫学习率。
学习率和正则化用对数均匀分布。 0.001 和 0.01 的差别与 0.1 和 1.0 的差别一样大。线性搜索在大值端浪费预算。
用早停代替调 n_estimators。 对提升和神经网络,把 n_estimators 或 epoch 设高,让早停决定何时停。这样从搜索里去掉一个超参数。
预算分配。 把调参预算的 60% 花在最重要的前 2 个超参数上,剩下 40% 花在其他一切。前 2 个贡献了大部分性能变化。
尺度很重要。 批大小绝不在对数尺度上搜(16、32、64 就好)。学习率永远在对数尺度上搜。让搜索分布匹配超参数影响模型的方式。
| 模型类型 | 顶级超参数 | 推荐搜索 | 预算 |
|---|---|---|---|
| 随机森林 | n_estimators, max_depth, min_samples_leaf | 随机搜索,50 次 | 低(训练快) |
| 梯度提升 | learning_rate, n_estimators, max_depth | 贝叶斯,100 次 + 早停 | 中 |
| 神经网络 | learning_rate, weight_decay, batch_size | 贝叶斯或随机,100+ 次 | 高(训练慢) |
| SVM | C, gamma(RBF 核) | 对数尺度网格,25~50 次 | 低(2 参数) |
| Lasso/Ridge | alpha | 一维对数搜索,20 次 | 极低 |
| XGBoost | learning_rate, max_depth, subsample, colsample | 贝叶斯,100~200 次 + 早停 | 中 |
拿不准时:随机搜索,试验数至少是超参数数的 2 倍(如 6 个超参数至少 12+ 次试验)。你会惊讶于 50 次随机搜索多频繁地击败精心设计的网格搜索。
code/tuning.py 从零实现网格搜索、随机搜索和一个简化的贝叶斯优化器。
def grid_search(model_fn, param_grid, X_train, y_train, X_val, y_val): keys = list(param_grid.keys()) values = list(param_grid.values()) best_score = -float("inf") best_params = None n_evals = 0 for combo in itertools.product(*values): params = dict(zip(keys, combo)) model = model_fn(**params) model.fit(X_train, y_train) score = evaluate(model, X_val, y_val) n_evals += 1 if score > best_score: best_score = score best_params = params return best_params, best_score, n_evals
def random_search(model_fn, param_distributions, X_train, y_train, X_val, y_val, n_iter=50, seed=42): rng = np.random.RandomState(seed) best_score = -float("inf") best_params = None for _ in range(n_iter): params = {k: sample(v, rng) for k, v in param_distributions.items()} model = model_fn(**params) model.fit(X_train, y_train) score = evaluate(model, X_val, y_val) if score > best_score: best_score = score best_params = params return best_params, best_score, n_iter
核心思想:用高斯过程拟合已观测的 (超参数, 分数) 对,再用采集函数决定下一步看哪里。
class SimpleBayesianOptimizer: def __init__(self, search_space, n_initial=5): self.search_space = search_space self.n_initial = n_initial self.X_observed = [] self.y_observed = [] def _kernel(self, x1, x2, length_scale=1.0): dists = np.sum((x1[:, None, :] - x2[None, :, :]) ** 2, axis=2) return np.exp(-0.5 * dists / length_scale ** 2) def _fit_gp(self, X_new): X_obs = np.array(self.X_observed) y_obs = np.array(self.y_observed) y_mean = y_obs.mean() y_centered = y_obs - y_mean K = self._kernel(X_obs, X_obs) + 1e-4 * np.eye(len(X_obs)) K_star = self._kernel(X_new, X_obs) L = np.linalg.cholesky(K) alpha = np.linalg.solve(L.T, np.linalg.solve(L, y_centered)) mu = K_star @ alpha + y_mean v = np.linalg.solve(L, K_star.T) var = 1.0 - np.sum(v ** 2, axis=0) var = np.maximum(var, 1e-6) return mu, var def _expected_improvement(self, mu, var, best_y): sigma = np.sqrt(var) z = (mu - best_y) / (sigma + 1e-10) ei = sigma * (z * norm_cdf(z) + norm_pdf(z)) return ei def suggest(self): if len(self.X_observed) < self.n_initial: return sample_random(self.search_space) candidates = [sample_random(self.search_space) for _ in range(500)] X_cand = np.array([to_vector(c) for c in candidates]) mu, var = self._fit_gp(X_cand) ei = self._expected_improvement(mu, var, max(self.y_observed)) return candidates[np.argmax(ei)] def observe(self, params, score): self.X_observed.append(to_vector(params)) self.y_observed.append(score)
GP 代理在每点给出两样东西:预测分数(mu)和不确定性(var)。期望改进( Expected Improvement)权衡两者:它偏爱模型预测高分或不确定性高的点。早期多数点不确定性高,优化器探索;后期聚焦最有前途的区域。
在同一合成目标上跑三种方法对比。这里用一个简化的包装器,直接对一个目标函数调用各优化器(无模型训练):
def synthetic_objective(params): lr = params["learning_rate"] depth = params["max_depth"] return -(np.log10(lr) + 2) ** 2 - (depth - 4) ** 2 + 10 param_grid = { "learning_rate": [0.001, 0.01, 0.1, 1.0], "max_depth": [2, 3, 4, 5, 6, 7, 8], } grid_best = None grid_score = -float("inf") grid_history = [] for combo in itertools.product(*param_grid.values()): params = dict(zip(param_grid.keys(), combo)) score = synthetic_objective(params) grid_history.append((params, score)) if score > grid_score: grid_score = score grid_best = params param_dist = { "learning_rate": ("log_float", 0.001, 1.0), "max_depth": ("int", 2, 8), } rand_best = None rand_score = -float("inf") rand_history = [] rng = np.random.RandomState(42) for _ in range(28): params = {k: sample(v, rng) for k, v in param_dist.items()} score = synthetic_objective(params) rand_history.append((params, score)) if score > rand_score: rand_score = score rand_best = params optimizer = SimpleBayesianOptimizer(param_dist, n_initial=5) bayes_history = [] for _ in range(28): params = optimizer.suggest() score = synthetic_objective(params) optimizer.observe(params, score) bayes_history.append((params, score)) bayes_score = max(s for _, s in bayes_history) print(f"{'方法':<20} {'最佳分数':>12} {'评估数':>12}") print("-" * 50) print(f"{'网格搜索':<20} {grid_score:>12.4f} {len(grid_history):>12}") print(f"{'随机搜索':<20} {rand_score:>12.4f} {len(rand_history):>12}") print(f"{'贝叶斯优化':<20} {bayes_score:>12.4f} {len(bayes_history):>12}")
相同预算下,贝叶斯优化通常最快找到最佳分数,因为它不在明显差的区域浪费评估。随机搜索比网格搜索覆盖更广。网格搜索只在超参数很少、能负担穷举时才赢。
Optuna 是严肃调参的推荐库,开箱支持剪枝、分布式搜索、可视化。
import optuna def objective(trial): lr = trial.suggest_float("learning_rate", 1e-4, 1e-1, log=True) n_est = trial.suggest_int("n_estimators", 50, 500) max_depth = trial.suggest_int("max_depth", 2, 10) model = GradientBoostingRegressor( learning_rate=lr, n_estimators=n_est, max_depth=max_depth, ) model.fit(X_train, y_train) return mean_squared_error(y_val, model.predict(X_val)) study = optuna.create_study(direction="minimize") study.optimize(objective, n_trials=100) print(f"最佳参数: {study.best_params}") print(f"最佳 MSE: {study.best_value:.4f}")
Optuna 关键特性:
suggest_float(..., log=True) 用于最好在对数尺度搜的参数(学习率、正则化)。suggest_int 用于整数参数。suggest_categorical 用于离散选择。MedianPruner 给坏试验早停。study.trials_dataframe() 用于分析。剪枝早停没前途的试验,省下大量算力:
import optuna from sklearn.model_selection import cross_val_score def objective(trial): params = { "learning_rate": trial.suggest_float("lr", 1e-4, 0.5, log=True), "max_depth": trial.suggest_int("max_depth", 2, 10), "n_estimators": trial.suggest_int("n_estimators", 50, 500), "subsample": trial.suggest_float("subsample", 0.5, 1.0), } model = GradientBoostingRegressor(**params) scores = cross_val_score(model, X_train, y_train, cv=3, scoring="neg_mean_squared_error") mean_score = -scores.mean() trial.report(mean_score, step=0) if trial.should_prune(): raise optuna.TrialPruned() return mean_score pruner = optuna.pruners.MedianPruner(n_startup_trials=10, n_warmup_steps=5) study = optuna.create_study(direction="minimize", pruner=pruner) study.optimize(objective, n_trials=200)
MedianPruner 在某试验的中间值比同步骤所有已完成试验的中位数还差时停掉它。剪枝要求调 trial.report() 报告中间指标、调 trial.should_prune() 检查是否该停。n_startup_trials=10 确保至少 10 个试验完整跑完再启用剪枝。这通常省 40~60% 总算力。
快速实验时,sklearn 提供 GridSearchCV、RandomizedSearchCV、HalvingRandomSearchCV:
from sklearn.model_selection import RandomizedSearchCV from scipy.stats import loguniform, randint param_dist = { "learning_rate": loguniform(1e-4, 0.5), "max_depth": randint(2, 10), "n_estimators": randint(50, 500), } search = RandomizedSearchCV( GradientBoostingRegressor(), param_dist, n_iter=100, cv=5, scoring="neg_mean_squared_error", random_state=42, n_jobs=-1, ) search.fit(X_train, y_train) print(f"最佳参数: {search.best_params_}") print(f"最佳 CV MSE: {-search.best_score_:.4f}")
用 scipy 的 loguniform 给学习率和正则化,用 randint 给整数超参。n_jobs=-1 跨所有 CPU 核并行。
from sklearn.model_selection import cross_val_score, GridSearchCV from sklearn.ensemble import GradientBoostingRegressor inner_cv = GridSearchCV( GradientBoostingRegressor(), param_grid={ "learning_rate": [0.01, 0.05, 0.1], "max_depth": [2, 3, 5], "n_estimators": [50, 100, 200], }, cv=5, scoring="neg_mean_squared_error", ) outer_scores = cross_val_score( inner_cv, X, y, cv=5, scoring="neg_mean_squared_error" ) print(f"嵌套 CV MSE: {-outer_scores.mean():.4f} +/- {outer_scores.std():.4f}")
| 维度 | 手写实现 | Optuna / sklearn |
|---|---|---|
| 灵活 | 可看清 GP、采集函数内部 | 封装好,默认合理 |
| 算力利用 | 无剪枝 | 内置剪枝、并行 |
| 适用 | 理解原理 | 生产调参 |
预处理导致数据泄漏。 若在交叉验证前对全数据拟合 scaler,验证折的信息就泄漏进训练。永远把预处理放进 Pipeline,让它只在训练折上拟合。
对验证集过拟合。 跑几千次试验实际上等于在验证集上训练。最终性能估计用嵌套交叉验证,或留一个调参时从不碰的独立测试集。
搜索范围太窄。 若你的最佳值落在搜索空间边界,你搜得还不够广。最优值可能在范围外。永远检查最佳参数是否在边缘。
忽略交互效应。 学习率和估计器数在提升里强交互。低学习率需要更多估计器。独立调它们不如一起调。
对迭代模型不用早停。 对梯度提升和神经网络,把 n_estimators 或 epoch 设高用早停。这严格优于把迭代数当超参数调。
本节产出 outputs/prompt-hyperparameter-tuner.md——一个帮你设计调参策略的提示词。喂给它模型类型、数据规模、可用算力,它会给出该先调哪些超参数、用什么搜索策略、起始范围、预算分配建议。
Python 代码(code/tuning.py)是独立的从零实现,可与 Optuna/sklearn 直接对照。
网格 vs 随机:用相同总预算(如 50 次评估)跑网格搜索和随机搜索,对比找到的最佳分数。换不同种子跑 10 次,随机搜索多频繁地赢?
从零实现 Hyperband:启动 81 个配置各训练 1 个 epoch,每轮保留前 1/3 并把它们的预算翻三倍。对比总算力(所有配置所有 epoch 之和)与把 81 个配置跑满预算的算力。
加学习率调度:给第 11 节的梯度提升实现加余弦退火调度,对比固定学习率是否有帮助。
Optuna 调随机森林:用 Optuna 在真实数据集(如 sklearn 乳腺癌数据集)上调 RandomForestClassifier,用 optuna.visualization.plot_param_importances(study) 看哪些超参数最重要。是否与本节的重要性排序吻合?
实现采集函数:实现期望改进(EI),演示探索 vs 开发。画出代理模型的均值和不确定性,展示 EI 选择在哪里评估。
下一节,我们讲机器学习流水线——如何把填补、缩放、编码、建模打包成一个可复现、防数据泄漏的单一对象。