# 超参数调优:网格搜索、随机搜索、贝叶斯优化,以及实际项目里怎么选 **TL;DR:** 网格搜索穷举所有组合,在超参数少时简单有效,但维度灾难让它很快变得不可行。随机搜索随机采样,Bergstra 和 Bengio 在 2012 年证明了它在大多数场景下比网格搜索更高效——因为通常只有少数超参数真正重要。贝叶斯优化用历史试验结果指导下一步搜索,是最"聪明"的方法,用更少的试验找到更好的配置。实际项目里的最佳策略:先用粗粒度随机搜索定位区域,再用贝叶斯优化精细搜索。 ## 为什么这很重要 假设你用随机森林做一个分类任务。随机森林有十几个超参数:树的数量、最大深度、最小分裂样本数、最大特征数、bootstrap 采样方式…… 你不可能手动试遍所有组合。但随便选一组超参数,可能比最优配置差很多。 一个真实的例子:同一个 XGBoost 模型,在同一个数据集上: - 默认参数:AUC = 0.82 - 调优后:AUC = 0.91 这不是个例。在很多任务中,超参数调优能带来 5-15% 的性能提升。这可能就是"模型勉强可用"和"模型可以上线"之间的差距。 但超参数调优也有代价:每次试验都要训练一个模型。如果一个模型训练需要 1 小时,网格搜索 100 个组合需要 100 小时(超过 4 天)。如果你还有 5 折交叉验证,就是 500 小时(超过 20 天)。 所以超参数调优的核心问题是:**如何在有限的计算预算内,找到最好的(或足够好的)超参数配置。** ## 核心概念 ### 超参数 vs 参数 首先搞清楚什么是超参数(hyperparameter),什么是参数(parameter)。 **参数**:模型通过训练数据**学到的**权重。比如线性回归的系数、神经网络的权重、决策树的分裂阈值。你不需要手动设置,模型会自动学习。 **超参数**:训练过程**开始前**手动设置的配置。比如学习率、正则化强度、树的深度。你需要自己选择,模型不会自动确定。 ``` 训练过程: 超参数(你设的) 参数(模型学的) ↓ ↓ [学习率=0.01] → 训练 → [权重w1=0.5] [树深度=10] → 训练 → [分裂阈值=3.2] [batch_size=32] → 训练 → [卷积核参数] ``` 常见的超参数: | 模型 | 超参数 | 典型范围 | |------|--------|---------| | 随机森林 | n_estimators, max_depth, min_samples_split | 50-500, 3-30, 2-20 | | XGBoost | learning_rate, max_depth, n_estimators, subsample | 0.01-0.3, 3-10, 100-5000, 0.5-1.0 | | 神经网络 | learning_rate, batch_size, hidden_units, dropout | 1e-5~1e-1, 16-256, 32-512, 0.1-0.5 | | SVM | C, gamma, kernel | 1e-3~1e3, 1e-4~1e1, {rbf, linear, poly} | | Lasso/Ridge | alpha | 1e-4~1e4 | ### 超参数空间 超参数搜索的空间可以是: **连续的**:learning_rate 在 [0.0001, 0.1] 之间连续取值。对于连续超参数,网格搜索需要离散化(比如取 0.0001, 0.001, 0.01, 0.1),可能错过最优值。 **离散的**:batch_size 在 {16, 32, 64, 128} 中取值。 **条件的**:某些超参数只有在其他超参数取特定值时才有意义。比如只有 kernel='poly' 时,degree 才需要设置。 ### 一个类比 超参数调优就像在一片未知地形中寻找最高峰。 **网格搜索**:你把地图划分成等距的网格,每个交叉点都爬上去看看高度。问题是你不知道应该划分多密的网格——太粗会错过山峰,太细会累死。 **随机搜索**:你在地图上随机选点爬上去看。令人惊讶的是,即使只爬很少的点,你也可能找到和网格搜索差不多高的位置。 **贝叶斯优化**:你有一个经验丰富的向导。每爬完一座山,他会根据之前所有观测点的地形模式,猜测最高峰大概在哪里,并建议你去下一个最可能更高的点。 ## 工作原理(简化的心智模型) ### 网格搜索为什么低效 假设你有两个超参数:learning_rate 和 regularization_strength。你只关心 learning_rate(它对性能影响很大),regularization_strength 影响很小。 ``` 网格搜索(4×4=16 次试验): reg=0.1 lr=0.001 → 0.72 reg=0.01 lr=0.001 → 0.71 reg=0.001 lr=0.001 → 0.73 reg=0.0001 lr=0.001 → 0.72 reg=0.1 lr=0.01 → 0.81 reg=0.01 lr=0.01 → 0.80 reg=0.001 lr=0.01 → 0.82 reg=0.0001 lr=0.01 → 0.81 reg=0.1 lr=0.1 → 0.85 reg=0.01 lr=0.1 → 0.84 reg=0.001 lr=0.1 → 0.86 reg=0.0001 lr=0.1 → 0.85 reg=0.1 lr=1.0 → 0.60 reg=0.01 lr=1.0 → 0.58 reg=0.001 lr=1.0 → 0.62 reg=0.0001 lr=1.0 → 0.59 → 测试了 4 个 learning_rate 值(0.001, 0.01, 0.1, 1.0) → 但每个值都测了 4 次(不同 reg)→ 总共 16 次 → 最优 learning_rate=0.1,但花了 12 次在重复验证 随机搜索(16 次试验): reg=0.08 lr=0.003 → 0.73 reg=0.02 lr=0.08 → 0.85 reg=0.003 lr=0.15 → 0.84 reg=0.0005 lr=0.05 → 0.83 reg=0.05 lr=0.2 → 0.83 reg=0.001 lr=0.12 → 0.86 ← 更接近最优! ...(10 次更多随机采样) → 测试了 16 个不同的 learning_rate 值(而不是只有 4 个) → 对 learning_rate 的覆盖更密 → 更可能找到好的值 ``` Bergstra 和 Bengio 在 2012 年的论文中,用理论分析和实验证明了这个直觉:**当只有少数超参数重要时,随机搜索比网格搜索更高效。** 因为随机搜索把试验预算花在了探索重要超参数的更多取值上,而不是在无关超参数的组合上浪费。 ### 贝叶斯优化的直觉 贝叶斯优化和前两种方法的关键区别:**它从历史结果中学习。** 网格搜索和随机搜索的每次试验都是独立的——第 10 次试验的结果不会影响第 11 次尝试什么超参数。贝叶斯优化则维护一个"超参数→性能"的概率模型,每次试验后更新模型,然后用模型来指导下一个试验点。 ``` 贝叶斯优化过程: 试验 1: lr=0.1 → 0.80 → 更新模型: "lr=0.1 附近性能不错" 试验 2: lr=0.5 → 0.65 → 更新模型: "lr=0.5 附近性能变差" 试验 3: lr=0.01 → 0.75 → 更新模型: "lr 在 0.01~0.1 之间有峰值" 试验 4: lr=0.05 → 0.87 → 更新模型: "lr=0.05 附近可能就是最优区域" 试验 5: lr=0.07 → 0.89 → 越来越接近最优! ``` 贝叶斯优化的核心是一个权衡:**exploitation vs exploration**。 - Exploitation(利用):在当前模型认为最可能好的区域多采样 - Exploration(探索):在不确定性高的区域采样,因为那里可能有更好的点 这个权衡通过一个**采集函数(Acquisition Function)**来实现。常用的采集函数包括: - **Expected Improvement (EI)**:选择"期望改善量"最大的点 - **Probability of Improvement (PI)**:选择"超过当前最优"概率最大的点 - **Upper Confidence Bound (UCB)**:选择"均值 + k×标准差"最大的点 ## 工作原理(详细机制) ### 网格搜索(Grid Search) **算法**:穷举搜索空间中所有超参数组合。 ```python from sklearn.model_selection import GridSearchCV param_grid = { 'n_estimators': [100, 200, 300], 'max_depth': [5, 10, 15], 'min_samples_split': [2, 5, 10] } # 总组合: 3 × 3 × 3 = 27 # 加上 5-fold CV: 27 × 5 = 135 次训练 grid_search = GridSearchCV( RandomForestClassifier(random_state=42), param_grid, cv=5, scoring='roc_auc', n_jobs=-1 ) grid_search.fit(X_train, y_train) ``` **优点**: - 简单直观,不需要任何假设 - 保证找到搜索空间内的最优组合 - 容易并行化(每个组合独立) **缺点**: - 组合爆炸:K 个超参数各有 n 个取值 → $n^K$ 次试验 - 对连续超参数不友好(需要离散化) - 在不重要的超参数上浪费大量计算 **维度灾难的具体例子**: - 3 个超参数各取 5 个值:$5^3 = 125$ 次试验 → 可行 - 6 个超参数各取 5 个值:$5^6 = 15625$ 次试验 → 很贵 - 10 个超参数各取 5 个值:$5^{10} = 9765625$ 次试验 → 不现实 ### 随机搜索(Random Search) **算法**:在搜索空间中随机采样 N 个超参数组合。 ```python from sklearn.model_selection import RandomizedSearchCV from scipy.stats import loguniform, randint param_distributions = { 'n_estimators': randint(100, 500), 'max_depth': randint(3, 20), 'min_samples_split': randint(2, 20), 'learning_rate': loguniform(1e-3, 1e-1) # 对数均匀分布 } # 只做 50 次随机采样(而不是 15625 次穷举) random_search = RandomizedSearchCV( GradientBoostingClassifier(random_state=42), param_distributions, n_iter=50, cv=5, scoring='roc_auc', n_jobs=-1, random_state=42 ) random_search.fit(X_train, y_train) ``` **关键技巧:用对数尺度采样**。 learning_rate 的最优值通常在 0.001 到 0.1 之间。如果你在均匀分布 [0.001, 0.1] 上采样,90% 的值会落在 [0.01, 0.1] 区间(因为区间长度占 90%),只有 10% 落在 [0.001, 0.01]。但实际上 learning_rate=0.003 和 learning_rate=0.005 的差异,和 learning_rate=0.05 和 learning_rate=0.1 的差异一样重要。 对数均匀分布解决了这个问题:在 log 空间上均匀采样,确保每个数量级(0.001-0.01, 0.01-0.1)获得同等的探索。 ```python from scipy.stats import loguniform # 对数均匀分布:在 log 空间上均匀采样 # loguniform(1e-4, 1e-1) 意味着: # P(lr ∈ [0.0001, 0.001]) ≈ P(lr ∈ [0.001, 0.01]) ≈ P(lr ∈ [0.01, 0.1]) # 每个数量级获得同等概率 ``` **为什么随机搜索在实践中表现好**: Bergstra 和 Bengio 的理论分析表明,对于大多数机器学习超参数空间: 1. 只有一个或少数几个超参数真正重要(有效维度低) 2. 这些重要超参数的性能曲线通常是"碗形"的(有明确的最优点) 3. 随机搜索在这些条件下能以更高的概率找到好的超参数 他们的实验显示:在同样的试验预算下,随机搜索找到的超参数配置比网格搜索好 20-50%(以测试误差衡量)。 ### 贝叶斯优化(Bayesian Optimization) 贝叶斯优化由两个核心组件构成: **1. 代理模型(Surrogate Model)** 代理模型是对"超参数 → 性能"这个未知函数的近似。常用的有: **高斯过程(Gaussian Process, GP)**: - 对每个超参数配置,不仅给出性能的预测值,还给出不确定性 - 适合低维连续空间(通常 < 10 维) - 计算量随观测数据增加而增大(需要求逆一个 N×N 矩阵) **TPE(Tree-structured Parzen Estimator)**: - 不直接建模 p(y|x),而是建模 p(x|y) 和 p(y) - 将观测结果分为"好"和"坏"两组,分别建模 - 适合高维和混合类型(连续 + 离散)的空间 - Optuna 默认使用 TPE TPE 的工作原理: ``` 假设你已经有了一些试验结果: 好的试验(性能 top 30%): lr=0.01, depth=5 lr=0.05, depth=8 lr=0.03, depth=6 坏的试验(性能 bottom 70%): lr=0.5, depth=3 lr=1.0, depth=15 lr=0.0001, depth=10 ... TPE 为"好的试验"和"坏的试验"分别建模: l(x) = "好试验"中 x 出现的概率密度 g(x) = "坏试验"中 x 出现的概率密度 选择下一个试验点的标准: 最大化 l(x) / g(x) → 在"好试验"区域密度高、"坏试验"区域密度低的点 ``` **2. 采集函数(Acquisition Function)** 采集函数决定下一个试验点在哪里。它平衡 exploitation(在已知好区域深挖)和 exploration(在未知区域探索)。 **Expected Improvement (EI)**: $$ \text{EI}(x) = E[\max(f(x) - f(x^+), 0)] $$ 其中 $f(x^+)$ 是目前观察到的最佳值。EI 选择的是"期望上能比当前最好结果改善多少"最大的点。 直觉:如果一个点的预测值高(exploitation)或者不确定性大(exploration),EI 值就大。 ### 剪枝(Pruning)和早停 在超参数搜索中,有些配置在训练的前几个 epoch 就明显表现很差。如果继续训练完,就是浪费时间。 **Optuna 的剪枝机制**:在训练过程中定期检查中间结果,如果当前配置的表现明显不如之前试过的配置,就提前终止。 ```python import optuna def objective(trial): lr = trial.suggest_float('lr', 1e-4, 1e-1, log=True) for epoch in range(100): # 训练一个 epoch loss = train_one_epoch(model, lr) # 报告中间结果 trial.report(loss, epoch) # 检查是否应该剪枝 if trial.should_prune(): raise optuna.TrialPruned() return final_score ``` 这可以节省大量计算。比如你计划训练 100 个 epoch,但某个配置在前 10 个 epoch 就表现很差,剪枝可以让你省下 90% 的训练时间。 ### 超参数的重要性评估 调完超参数后,一个有价值的问题是:**哪些超参数真正重要?** 这能帮助你理解模型行为,指导未来的搜索。 **方法 1:fANOVA(Functional ANOVA)** 把性能的方差分解到各个超参数上。比如: - learning_rate 解释了 45% 的性能方差 - max_depth 解释了 30% - min_samples_split 解释了 5% - 其他超参数解释了 20% 这告诉你 learning_rate 和 max_depth 是最重要的,下次调参可以主要关注这两个。 **方法 2:Optuna 的参数重要性图** Optuna 内置了参数重要性评估,可以快速可视化哪些超参数对性能影响最大。 ### 学习率调度作为超参数 学习率是深度学习中最重要的超参数。不仅是学习率本身,学习率的调度方式也是一个关键选择。 常见的学习率调度: **Constant**:固定学习率。最简单,但通常不是最优的。 **Step Decay**:每过 N 个 epoch,学习率乘以一个衰减因子。 ```python # 每 30 个 epoch,学习率 × 0.1 scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.1) # lr: 0.1 → 30 epoch 后 0.01 → 60 epoch 后 0.001 ``` **Cosine Annealing**:学习率按余弦曲线从初始值衰减到接近 0。 $$ \eta_t = \eta_{\min} + \frac{1}{2}(\eta_{\max} - \eta_{\min})(1 + \cos(\frac{t}{T}\pi)) $$ ```python scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100) # lr: 平滑地从 0.1 衰减到接近 0 ``` **Warmup + Cosine Decay**:先线性增加学习率(warmup),然后按余弦衰减。这是 Transformer 训练的标准配置。 ```python # Warmup: 5 个 epoch 线性增加 lr 从 0 到 0.001 # 然后: Cosine decay 从 0.001 到 0 ``` **One Cycle Policy**:学习率先增加后减少,在一个周期内完成。通常比固定学习率效果更好,而且训练更快。 **学习率 finder**:一种快速找到合理学习率范围的方法。从一个很小的学习率开始,逐步增大,记录每个学习率下的 loss。选择 loss 下降最快的学习率。 ```python # 学习率 finder 的伪代码 lr = 1e-7 for batch in dataloader: loss = train_step(model, batch, lr) losses.append(loss) lrs.append(lr) lr *= 1.1 # 每步增加 10% # 画 loss vs lr 曲线,选择 loss 下降最快点的 lr ``` ## 代码示例 ```python import numpy as np import pandas as pd import matplotlib.pyplot as plt import time import warnings warnings.filterwarnings('ignore') from sklearn.datasets import make_classification from sklearn.model_selection import ( train_test_split, GridSearchCV, RandomizedSearchCV, cross_val_score ) from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier from sklearn.metrics import roc_auc_score from scipy.stats import loguniform, randint, uniform np.random.seed(42) # ============================================================ # 0. 准备数据 # ============================================================ print("=" * 70) print("超参数调优对比实验") print("=" * 70) X, y = make_classification( n_samples=5000, n_features=30, n_informative=15, n_redundant=5, random_state=42, weights=[0.7, 0.3] ) X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) print(f"训练集: {X_train.shape}, 正样本比例: {y_train.mean():.2%}") print(f"测试集: {X_test.shape}") # 基线:默认参数 baseline = GradientBoostingClassifier(random_state=42) baseline.fit(X_train, y_train) baseline_auc = roc_auc_score(y_test, baseline.predict_proba(X_test)[:, 1]) print(f"\n基线(默认参数)AUC: {baseline_auc:.4f}") # ============================================================ # 1. 网格搜索 # ============================================================ print("\n" + "=" * 70) print("1. 网格搜索(Grid Search)") print("=" * 70) param_grid = { 'n_estimators': [100, 200, 300], 'max_depth': [3, 5, 7], 'learning_rate': [0.01, 0.05, 0.1], 'min_samples_split': [2, 5], 'subsample': [0.8, 1.0] } total_combos = np.prod([len(v) for v in param_grid.values()]) print(f"搜索空间: {total_combos} 种组合 × 5-fold CV = {total_combos * 5} 次训练") start = time.time() grid_search = GridSearchCV( GradientBoostingClassifier(random_state=42), param_grid, cv=5, scoring='roc_auc', n_jobs=-1, verbose=0 ) grid_search.fit(X_train, y_train) grid_time = time.time() - start grid_auc = roc_auc_score(y_test, grid_search.predict_proba(X_test)[:, 1]) print(f"\n耗时: {grid_time:.1f}s") print(f"最优参数: {grid_search.best_params_}") print(f"CV 最佳分数: {grid_search.best_score_:.4f}") print(f"测试集 AUC: {grid_auc:.4f}") # ============================================================ # 2. 随机搜索 # ============================================================ print("\n" + "=" * 70) print("2. 随机搜索(Random Search)") print("=" * 70) param_dist = { 'n_estimators': randint(100, 500), 'max_depth': randint(3, 15), 'learning_rate': loguniform(1e-3, 2e-1), 'min_samples_split': randint(2, 20), 'subsample': uniform(0.6, 0.4), # 0.6 ~ 1.0 'min_samples_leaf': randint(1, 10), 'max_features': uniform(0.3, 0.7) # 0.3 ~ 1.0 } n_iter = 50 print(f"搜索空间: {n_iter} 次随机采样 × 5-fold CV = {n_iter * 5} 次训练") print(f"对比: 网格搜索用 {total_combos * 5} 次训练") start = time.time() random_search = RandomizedSearchCV( GradientBoostingClassifier(random_state=42), param_dist, n_iter=n_iter, cv=5, scoring='roc_auc', n_jobs=-1, verbose=0, random_state=42 ) random_search.fit(X_train, y_train) random_time = time.time() - start random_auc = roc_auc_score(y_test, random_search.predict_proba(X_test)[:, 1]) print(f"\n耗时: {random_time:.1f}s") print(f"最优参数: {random_search.best_params_}") print(f"CV 最佳分数: {random_search.best_score_:.4f}") print(f"测试集 AUC: {random_auc:.4f}") # ============================================================ # 3. 贝叶斯优化(Optuna) # ============================================================ print("\n" + "=" * 70) print("3. 贝叶斯优化(Optuna TPE)") print("=" * 70) try: import optuna optuna.logging.set_verbosity(optuna.logging.WARNING) def objective(trial): params = { 'n_estimators': trial.suggest_int('n_estimators', 100, 500), 'max_depth': trial.suggest_int('max_depth', 3, 15), 'learning_rate': trial.suggest_float('learning_rate', 1e-3, 2e-1, log=True), 'min_samples_split': trial.suggest_int('min_samples_split', 2, 20), 'subsample': trial.suggest_float('subsample', 0.6, 1.0), 'min_samples_leaf': trial.suggest_int('min_samples_leaf', 1, 10), 'max_features': trial.suggest_float('max_features', 0.3, 1.0), 'random_state': 42 } model = GradientBoostingClassifier(**params) scores = cross_val_score(model, X_train, y_train, cv=5, scoring='roc_auc', n_jobs=-1) return scores.mean() n_trials = 50 print(f"搜索空间: {n_trials} 次 TPE 采样 × 5-fold CV = {n_trials * 5} 次训练") start = time.time() study = optuna.create_study(direction='maximize', sampler=optuna.samplers.TPESampler(seed=42)) study.optimize(objective, n_trials=n_trials, show_progress_bar=False) optuna_time = time.time() - start best_params = study.best_params best_model = GradientBoostingClassifier(**best_params, random_state=42) best_model.fit(X_train, y_train) optuna_auc = roc_auc_score(y_test, best_model.predict_proba(X_test)[:, 1]) print(f"\n耗时: {optuna_time:.1f}s") print(f"最优参数: {best_params}") print(f"CV 最佳分数: {study.best_value:.4f}") print(f"测试集 AUC: {optuna_auc:.4f}") # 参数重要性 print(f"\n参数重要性:") try: importance = optuna.importance.get_param_importances(study) for param, imp in importance.items(): bar = '█' * int(imp * 50) print(f" {param:25s}: {imp:.3f} {bar}") except: print(" (无法计算参数重要性)") has_optuna = True except ImportError: print("Optuna 未安装,跳过贝叶斯优化示例") print("安装方法: pip install optuna") optuna_auc = None optuna_time = None has_optuna = False # ============================================================ # 4. 结果对比 # ============================================================ print("\n" + "=" * 70) print("4. 方法对比总结") print("=" * 70) print(f"\n{'方法':<20} {'训练次数':<15} {'耗时':<12} {'测试AUC':<12} {'vs基线'}") print("-" * 75) print(f"{'基线(默认参数)':<20} {'1':<15} {'-':<12} {baseline_auc:<12.4f} {'-':<10}") print(f"{'网格搜索':<20} {f'{total_combos*5}':<15} {f'{grid_time:.1f}s':<12} {grid_auc:<12.4f} {'+' + f'{(grid_auc-baseline_auc)*100:.2f}%':<10}") print(f"{'随机搜索':<20} {f'{n_iter*5}':<15} {f'{random_time:.1f}s':<12} {random_auc:<12.4f} {'+' + f'{(random_auc-baseline_auc)*100:.2f}%':<10}") if has_optuna: print(f"{'贝叶斯优化':<20} {f'{n_trials*5}':<15} {f'{optuna_time:.1f}s':<12} {optuna_auc:<12.4f} {'+' + f'{(optuna_auc-baseline_auc)*100:.2f}%':<10}") print(f"\n关键观察:") print(f" 1. 所有调优方法都比默认参数好") print(f" 2. 随机搜索和贝叶斯优化用更少的试验量达到了接近或超过网格搜索的效果") print(f" 3. 贝叶斯优化在相同试验次数下通常找到更好的配置") # ============================================================ # 5. 学习率尺度的重要性 # ============================================================ print("\n" + "=" * 70) print("5. 对数尺度 vs 线性尺度采样") print("=" * 70) # 对比线性采样和对数采样 n_samples = 1000 # 线性均匀采样 lr_linear = np.random.uniform(1e-4, 1e-1, n_samples) # 对数均匀采样 lr_log = np.exp(np.random.uniform(np.log(1e-4), np.log(1e-1), n_samples)) print(f"线性采样 learning_rate 分布:") for lo, hi in [(1e-4, 1e-3), (1e-3, 1e-2), (1e-2, 1e-1)]: pct = ((lr_linear >= lo) & (lr_linear < hi)).mean() print(f" [{lo:.0e}, {hi:.0e}): {pct:.1%}") print(f"\n对数采样 learning_rate 分布:") for lo, hi in [(1e-4, 1e-3), (1e-3, 1e-2), (1e-2, 1e-1)]: pct = ((lr_log >= lo) & (lr_log < hi)).mean() print(f" [{lo:.0e}, {hi:.0e}): {pct:.1%}") print(f"\n→ 线性采样: 90% 的值集中在 [0.01, 0.1],低学习率探索不足") print(f"→ 对数采样: 每个数量级获得约 1/3 的概率,探索更均匀") # ============================================================ # 6. 搜索过程可视化 # ============================================================ print("\n" + "=" * 70) print("6. 搜索过程可视化") print("=" * 70) # 收集随机搜索的历史 random_results = pd.DataFrame(random_search.cv_results__) random_scores = random_results['mean_test_score'].values random_best_so_far = np.maximum.accumulate(random_scores) fig, axes = plt.subplots(1, 2, figsize=(14, 5)) # 左图:每次试验的分数和累计最优 axes[0].plot(range(1, n_iter+1), random_scores, 'o-', alpha=0.3, label='每次试验') axes[0].plot(range(1, n_iter+1), random_best_so_far, 'r-', linewidth=2, label='累计最优') axes[0].axhline(grid_search.best_score_, color='g', linestyle='--', label=f'网格搜索最优 ({grid_search.best_score_:.4f})') axes[0].set_xlabel('试验次数') axes[0].set_ylabel('AUC') axes[0].set_title('随机搜索的收敛过程') axes[0].legend() axes[0].grid(True, alpha=0.3) # 右图:learning_rate 的分布 if has_optuna: trials_df = study.trials_dataframe() axes[1].scatter(trials_df['params_learning_rate'], trials_df['value'], c=trials_df['number'], cmap='viridis', alpha=0.6) axes[1].set_xscale('log') axes[1].set_xlabel('learning_rate (log scale)') axes[1].set_ylabel('AUC') axes[1].set_title('Optuna: learning_rate vs 性能\n(颜色=试验顺序,深色=后期)') axes[1].grid(True, alpha=0.3) # 标记最优 best_trial = trials_df.loc[trials_df['value'].idxmax()] axes[1].scatter(best_trial['params_learning_rate'], best_trial['value'], marker='*', s=200, c='red', label=f'最优 (lr={best_trial["params_learning_rate"]:.4f})') axes[1].legend() plt.tight_layout() plt.savefig('hyperparameter_search.png', dpi=150) plt.show() print("超参数搜索过程可视化已保存") # ============================================================ # 7. 实用的调参策略 # ============================================================ print("\n" + "=" * 70) print("7. 实用调参策略总结") print("=" * 70) strategy = """ ╔════════════════════════════════════════════════════════════════════════╗ ║ 实用超参数调优策略 ║ ╠════════════════════════════════════════════════════════════════════════╣ ║ ║ ║ 第一阶段:快速定位(粗粒度随机搜索) ║ ║ ├── 目标:找到大致的最优区域 ║ ║ ├── 方法:随机搜索,n_iter = 20-50 ║ ║ ├── 搜索空间:宽范围(如 lr: 1e-4 ~ 1e-1) ║ ║ └── 时间:几分钟到几小时 ║ ║ ║ ║ 第二阶段:精细搜索(贝叶斯优化) ║ ║ ├── 目标:在最优区域附近找到精确的最优点 ║ ║ ├── 方法:Optuna TPE,n_trials = 50-200 ║ ║ ├── 搜索空间:缩窄到第一阶段的 top 区域附近 ║ ║ └── 时间:取决于模型训练速度 ║ ║ ║ ║ 第三阶段:验证 ║ ║ ├── 用最优参数在完整训练集上重新训练 ║ ║ ├── 在独立测试集上评估最终性能 ║ ║ └── 如果性能不满意,回到第一阶段扩大搜索空间 ║ ║ ║ ║ 关键原则: ║ ║ 1. 对学习率等连续参数用对数尺度 ║ ║ 2. 先调最重要的参数(learning_rate, max_depth) ║ ║ 3. 再调次要参数(min_samples_split, subsample) ║ ║ 4. 正则化参数最后调(因为它们依赖于其他参数) ║ ║ 5. 每次调参都记录结果,建立经验库 ║ ║ ║ ╚════════════════════════════════════════════════════════════════════════╝ """ print(strategy) ``` ## 真实案例 ### 案例 1:XGBoost 在 Kaggle 竞赛中的调参策略 Kaggle Grandmaster 的典型 XGBoost 调参流程: **第一步:固定基础参数,只调 n_estimators** ```python # 固定其他参数为合理默认值 xgb_params = { 'learning_rate': 0.1, 'max_depth': 5, 'min_child_weight': 1, 'subsample': 0.8, 'colsample_bytree': 0.8, 'n_estimators': ? # 通过 early stopping 确定 } # 用 early stopping 自动确定 n_estimators model = XGBClassifier(**xgb_params, early_stopping_rounds=50) model.fit(X_train, y_train, eval_set=[(X_val, y_val)]) print(f"最优 n_estimators: {model.best_iteration}") ``` **第二步:调 max_depth 和 min_child_weight** 这两个参数控制树的复杂度。max_depth 越大树越复杂,min_child_weight 越大树越保守。 ```python param_grid = { 'max_depth': [3, 5, 7, 9], 'min_child_weight': [1, 3, 5, 7] } # 4×4=16 种组合,网格搜索即可 ``` **第三步:调 gamma(正则化)** ```python param_grid = {'gamma': [0, 0.1, 0.2, 0.5, 1, 2]} ``` **第四步:调 subsample 和 colsample_bytree** ```python param_grid = { 'subsample': [0.6, 0.7, 0.8, 0.9, 1.0], 'colsample_bytree': [0.6, 0.7, 0.8, 0.9, 1.0] } ``` **第五步:降低学习率,增加 n_estimators** ```python # 把 learning_rate 从 0.1 降到 0.01 # n_estimators 相应增加 10 倍 # 通常能带来 1-2% 的额外提升 ``` 这种分步调参的策略比一次性搜索所有参数更高效,因为每一步都在上一步找到的最优值基础上微调。 ### 案例 2:深度学习中的学习率选择 某团队训练一个图像分类 CNN。他们使用学习率 finder 技术来确定初始学习率范围: ``` lr loss 1e-7 2.30 1e-6 2.28 1e-5 2.10 1e-4 1.50 3e-4 0.80 ← loss 下降最快的区域 1e-3 0.60 3e-3 0.55 1e-2 0.70 ← loss 开始震荡 3e-2 1.50 ← loss 开始发散 1e-1 NaN ← 训练崩溃 ``` 他们选择了 lr=3e-4(loss 下降最快区域的中间值),使用 One Cycle Policy 训练,最大 lr=1e-3。 结果: - 固定 lr=0.01:最终准确率 89% - lr finder + One Cycle:最终准确率 93%,训练速度快了 40% ### 案例 3:AutoML 中的超参数优化 Google 的 Cloud AutoML 和其他 AutoML 系统使用的策略: 1. **初始阶段**:用一组已知效果好的默认配置作为起点(来自之前在大量任务上积累的经验) 2. **搜索策略**:使用 TPE 或 Hyperband(结合了贝叶斯优化和早停) 3. **迁移学习**:在相似任务上的调参经验用来初始化搜索 4. **资源分配**:先给每个配置少量训练预算(比如只训练 1 个 epoch),淘汰表现差的,然后给幸存的配置更多预算 Hyperband 算法的核心思想: ``` Round 1: 启动 100 个配置,每个训练 1 epoch → 淘汰 80 个 Round 2: 剩余 20 个,每个训练 5 epoch → 淘汰 16 个 Round 3: 剩余 4 个,每个训练 25 epoch → 淘汰 3 个 Round 4: 剩余 1 个,训练 125 epoch → 最终模型 总训练量: 100×1 + 20×5 + 4×25 + 1×125 = 425 epoch 而不是: 100 × 125 = 12500 epoch(完整训练所有配置) → 节省了 97% 的计算量 ``` ## 权衡取舍以及何时不该使用 ### 超参数调优的代价 **计算代价**:这是最直接的代价。每次试验都要训练一个模型。对于大模型,这可能意味着天甚至周的计算时间。 **过拟合风险**:如果你在测试集上反复评估不同超参数,测试集实际上变成了验证集。你的"最优超参数"可能只是在这个特定测试集上表现好,不代表真正的泛化性能。 **解决方法**: - 保留一个完全独立的测试集(holdout test set),只在最终评估时使用 - 用交叉验证做超参数选择 - 如果计算允许,用嵌套交叉验证 ### 何时不该过度调参 1. **数据质量是瓶颈时**。如果你的数据有大量标注错误、特征信息量低、或者数据量很少,调参能带来的提升非常有限。先把精力花在数据上。 2. **性能已经满足需求时**。如果你的模型已经达到了业务要求的指标,继续调参的边际收益很小。时间花在特征工程或数据收集上可能更值得。 3. **模型很快会被替换时**。如果这个模型只是原型,后续会用不同的方法重做,不值得花大量时间调参。 4. **搜索空间太小时**。如果你的搜索空间只有几个组合,网格搜索就够了,不需要贝叶斯优化。 ### 常见的错误做法 **错误 1:在测试集上选超参数** ```python # 错误 for params in param_list: model = train(X_train, y_train, **params) score = evaluate(model, X_test, y_test) # 在测试集上评估 if score > best_score: best_params = params # 正确 # 用交叉验证在训练数据上选超参数 grid_search = GridSearchCV(model, param_grid, cv=5) grid_search.fit(X_train, y_train) # 只在最终确认时用一次测试集 final_score = evaluate(grid_search.best_estimator_, X_test, y_test) ``` **错误 2:搜索空间设计不合理** ```python # 错误:线性空间的 learning_rate 'learning_rate': [0.001, 0.002, 0.003, ..., 0.099, 0.1] # 大部分值在 0.01-0.1 之间,低值探索不足 # 正确:对数空间 'learning_rate': loguniform(1e-4, 1e-1) # 每个数量级获得同等探索 ``` **错误 3:一次调太多参数** 一次性搜索 10 个超参数的组合,即使每个只有 3 个取值,也有 $3^{10} = 59049$ 种组合。正确的做法是分阶段:先调最重要的 2-3 个参数,锁定后调次要参数。 **错误 4:忽视超参数的交互效应** 有时候两个超参数的最优值是相互依赖的。比如 learning_rate=0.1 时 max_depth=3 最好,但 learning_rate=0.01 时 max_depth=7 最好。分步调参可能错过这种交互效应。如果计算预算允许,联合搜索更好。 ## 关键要点 1. **网格搜索在超参数多时不可行**。3 个参数各 5 个值需要 125 次试验,6 个参数就需要 15625 次。维度爆炸是网格搜索的致命问题。 2. **随机搜索在大多数场景下优于网格搜索**。Bergstra 和 Bengio 的经典论文证明了:当只有少数超参数重要时(实际情况几乎总是如此),随机搜索用更少的试验找到更好或相当的配置。 3. **贝叶斯优化是最高效的方法**。它通过建模超参数和性能的关系来指导搜索,避免了浪费计算在明显差的区域。Optuna 是目前最流行的框架,推荐使用。 4. **对学习率等参数用对数尺度**。学习率的最优值可能跨越多个数量级(0.0001 到 0.1),线性采样会让大部分试验集中在一个数量级上。对数采样确保每个数量级获得同等的探索。 5. **分阶段调参比一次性搜索更实用**。先调最重要的参数(learning_rate、max_depth),锁定后再调次要参数。对于 XGBoost 等模型,先确定 n_estimators(用 early stopping),再调树相关的参数。 6. **超参数调优不是解决所有问题的银弹**。如果数据质量差、特征工程不到位,调参带来的提升很有限。先确保数据和特征没问题,再花时间调参。 7. **始终保留独立的测试集**。交叉验证选出的超参数可能对验证集过拟合。最终的性能评估必须在从未参与调参过程的测试集上进行。 ## 延伸阅读 1. Bergstra, J., & Bengio, Y. (2012). "Random Search for Hyper-Parameter Optimization." *JMLR*, 13, 281-305. — 证明随机搜索优于网格搜索的经典论文 2. Bergstra, J., Bardenet, R., Bengio, Y., & Kégl, B. (2011). "Algorithms for Hyper-Parameter Optimization." *NeurIPS*. — TPE 算法的原始论文 3. Snoek, J., Larochelle, H., & Adams, R. P. (2012). "Practical Bayesian Optimization of Machine Learning Algorithms." *NeurIPS*. — 贝叶斯优化在机器学习中的应用 4. Li, L., Jamieson, K., DeSalvo, G., Rostamizadeh, A., & Talwalkar, A. (2018). "Hyperband: A Novel Bandit-Based Approach to Hyperparameter Optimization." *JMLR*. — Hyperband 算法 5. Akiba, T., Sano, S., Yanase, T., Ohta, T., & Koyama, M. (2019). "Optuna: A Next-generation Hyperparameter Optimization Framework." *KDD*. — Optuna 框架论文 6. Smith, L. N. (2018). "A disciplined approach to neural network hyper-parameters: Part 1 -- learning rate, batch size, momentum, and weight decay." *arXiv:1803.09820*. — 学习率选择和调参的系统方法