哈喽,大家好~
今儿和大家来聊聊超参数优化的核心逻辑。
我们在训练机器学习模型时,通常要做两类“调节”:
-
模型参数,例如神经网络的权重,这些参数是通过训练数据自动学习得到的。 -
超参数,例如学习率、正则化强度、网络层宽度、dropout 比例、优化器种类、batch size 等。这些不是网络直接学到的,需要人为设定或通过外部搜索得到。
为什么要调超参?
因为超参数决定训练过程和模型能学到什么。
好的超参数组合能让模型更快收敛、泛化更好;坏的超参数可能导致训练不稳定、欠拟合或过拟合。
超参数优化的核心任务,可以用数学语言描述为一个“二层优化问题”:
-
内层(训练层):给定超参数 ,训练得到模型参数 ,通常通过最小化训练损失: -
外层(验证层):选择使验证(或交叉验证)损失最小的超参数:
换句话说,我们在寻找一组超参数 ,使得在这个 下训练出来的模型在验证集上表现最好。
关键表达
参数更新(梯度下降):
其中 是学习率(一个关键超参)。
超参优化的元目标(外层):
贝叶斯优化中的预期改进一种采集函数:
其中 是当前最好的目标值(例如验证精度)。
实战案例
我们构造一个分类任务,多维输入,使用一个简单 MLP。
定义 PyTorch 模型与训练/评估函数。用 Optuna 做超参数优化(使用 TPE 算法),超参包括学习率、权重衰减、隐藏层维度、dropout、batch size、优化器类型等。
用 Grid Search 在两个关键超参(lr, weight_decay)上做网格评估,用以绘制热力图(可视化验证性能在这两个超参上的响应面)。
import random
import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import TensorDataset, DataLoader, random_split
import optuna
import matplotlib.pyplot as plt
import seaborn as sns
import os
RANDOM_SEED = 42
torch.manual_seed(RANDOM_SEED)
np.random.seed(RANDOM_SEED)
random.seed(RANDOM_SEED)
plt.rcParams["figure.figsize"] = (8, 6)
# 1) 数据集:有两个簇和一些噪声,输入维度为 12(模拟高维输入)
def make_synthetic(n_samples=2000, input_dim=12, n_classes=2):
X = []
y = []
for i in range(n_samples):
cls = np.random.randint(0, n_classes)
center = np.zeros(input_dim)
# 每类在某些维度有偏移
if cls == 0:
center[:4] += 1.5
center[4:8] -= 0.5
else:
center[:4] -= 1.0
center[4:8] += 1.0
x = center + 0.8 * np.random.randn(input_dim)
X.append(x)
y.append(cls)
X = np.array(X, dtype=np.float32)
y = np.array(y, dtype=np.int64)
return X, y
X, y = make_synthetic(n_samples=2000, input_dim=12, n_classes=2)
# 转为 PyTorch TensorDataset
dataset = TensorDataset(torch.from_numpy(X), torch.from_numpy(y))
# 分割为 train / val / test
n_train = int(0.7 * len(dataset))
n_val = int(0.15 * len(dataset))
n_test = len(dataset) - n_train - n_val
train_set, val_set, test_set = random_split(dataset, [n_train, n_val, n_test], generator=torch.Generator().manual_seed(RANDOM_SEED))
# 2) 定义一个可配置的 MLP(超参数:hidden_dim, dropout)
class MLP(nn.Module):
def __init__(self, input_dim=12, hidden_dim=64, dropout=0.2):
super().__init__()
self.net = nn.Sequential(
nn.Linear(input_dim, hidden_dim),
nn.ReLU(),
nn.Dropout(dropout),
nn.Linear(hidden_dim, hidden_dim//2),
nn.ReLU(),
nn.Dropout(dropout),
nn.Linear(hidden_dim//2, 2)
)
def forward(self, x):
return self.net(x)
# 3) 训练与评估函数(返回 validation accuracy)
def train_and_eval(params, return_curves=False):
# params 包含:lr, weight_decay, hidden_dim, dropout, batch_size, optimizer_name, epochs
device = torch.device("cuda"if torch.cuda.is_available() else"cpu")
model = MLP(input_dim=12, hidden_dim=params['hidden_dim'], dropout=params['dropout']).to(device)
criterion = nn.CrossEntropyLoss()
if params['optimizer_name'] == 'adam':
optimizer = optim.Adam(model.parameters(), lr=params['lr'], weight_decay=params['weight_decay'])
else:
optimizer = optim.SGD(model.parameters(), lr=params['lr'], momentum=0.9, weight_decay=params['weight_decay'])
train_loader = DataLoader(train_set, batch_size=params['batch_size'], shuffle=True)
val_loader = DataLoader(val_set, batch_size=256, shuffle=False)
epochs = params.get('epochs', 30)
train_losses, val_losses, val_accs = [], [], []
for epoch in range(epochs):
model.train()
running_loss = 0.0
for xb, yb in train_loader:
xb, yb = xb.to(device), yb.to(device)
optimizer.zero_grad()
logits = model(xb)
loss = criterion(logits, yb)
loss.backward()
optimizer.step()
running_loss += loss.item() * xb.size(0)
train_losses.append(running_loss / len(train_loader.dataset))
# eval on val
model.eval()
total, correct = 0, 0
val_loss = 0.0
with torch.no_grad():
for xb, yb in val_loader:
xb, yb = xb.to(device), yb.to(device)
logits = model(xb)
loss = criterion(logits, yb)
val_loss += loss.item() * xb.size(0)
preds = logits.argmax(dim=1)
correct += (preds == yb).sum().item()
total += xb.size(0)
val_losses.append(val_loss / len(val_loader.dataset))
val_accs.append(correct / total)
best_val_acc = max(val_accs)
if return_curves:
return best_val_acc, (train_losses, val_losses, val_accs)
return best_val_acc
# 4) 使用 Optuna 做超参数搜索(TPE)
def objective(trial):
params = {
'lr': trial.suggest_loguniform('lr', 1e-4, 1e-1),
'weight_decay': trial.suggest_loguniform('weight_decay', 1e-6, 1e-2),
'hidden_dim': trial.suggest_categorical('hidden_dim', [32, 64, 128]),
'dropout': trial.suggest_uniform('dropout', 0.0, 0.5),
'batch_size': trial.suggest_categorical('batch_size', [32, 64, 128]),
'optimizer_name': trial.suggest_categorical('optimizer_name', ['adam', 'sgd']),
'epochs': 30
}
val_acc = train_and_eval(params)
# Optuna 最大化目标(默认是 minimization,所以我们返回负的错误率,于是也可以直接设置 study=maximize)
return val_acc
# 运行 Optuna 搜索
import optuna
study = optuna.create_study(direction='maximize', sampler=optuna.samplers.TPESampler(seed=RANDOM_SEED))
study.optimize(objective, n_trials=50, n_jobs=1)
print("Best trial:")
print(study.best_trial.params)
print("Best value (val acc):", study.best_value)
# 5) 获取最优 trial 的训练曲线(重跑并获取曲线)
best_params = study.best_trial.params
best_params['epochs'] = 60# 多训练一些 epoch 以观察学习曲线
best_params['batch_size'] = int(best_params['batch_size'])
best_acc, curves = train_and_eval(best_params, return_curves=True)
train_losses, val_losses, val_accs = curves
# 6) Grid search(lr x weight_decay)用于绘制热力图(较粗网格)
lrs = np.logspace(-4, -1, 10)
wds = np.logspace(-6, -2, 10)
grid_results = np.zeros((len(wds), len(lrs)))
for i, wd in enumerate(wds):
for j, lr in enumerate(lrs):
params = {
'lr': float(lr),
'weight_decay': float(wd),
'hidden_dim': best_params['hidden_dim'],
'dropout': best_params['dropout'],
'batch_size': best_params['batch_size'],
'optimizer_name': best_params['optimizer_name'],
'epochs': 20
}
acc = train_and_eval(params)
grid_results[i, j] = acc
# 7) 超参重要性
try:
imp = optuna.importance.get_param_importances(study)
except Exception as e:
# 如果缺少依赖,fallback:根据 trials 做简单粗略重要性(方差解释)
imp = {}
trials = study.trials
# 统计每个参数与目标的皮尔逊相关(数值化 categorical)
import pandas as pd
df = pd.DataFrame([dict(t.params, value=t.value) for t in trials])
for col in df.columns:
if col == 'value': continue
try:
corr = df[col].astype(float).corr(df['value'])
imp[col] = abs(corr)
except:
imp[col] = 0.0
# 8) 可视化
# 图 1:最优 trial 的训练/验证损失与验证精度(学习曲线)
plt.figure(figsize=(10,6))
epochs = np.arange(1, len(train_losses)+1)
plt.plot(epochs, train_losses, label='train loss', color='#FF6F61', linewidth=2)
plt.plot(epochs, val_losses, label='val loss', color='#6B5B95', linewidth=2)
plt.plot(epochs, val_accs, label='val acc', color='#009B77', linewidth=2)
plt.xlabel('epoch')
plt.title('Best Trial Learning Curves (loss & val acc)')
plt.legend()
plt.grid(alpha=0.3)
plt.show()
# 图 2:lr x weight_decay 热力图
plt.figure(figsize=(10,8))
# 使用 seaborn heatmap,行对应 weight_decay(从小到大)
ax = sns.heatmap(grid_results, xticklabels=[f"{v:.1e}"for v in lrs], yticklabels=[f"{v:.1e}"for v in wds],
cmap="YlOrRd", annot=True, fmt=".3f")
plt.xlabel("learning rate")
plt.ylabel("weight decay")
plt.title("Validation Acc over lr and weight_decay (grid search)")
plt.show()
# 图 3:超参重要性条形图
plt.figure(figsize=(8,5))
items = sorted(imp.items(), key=lambda x: x[1], reverse=True)
names = [k for k,_ in items]
scores = [v for _,v in items]
palette = sns.color_palette("bright", len(names))
sns.barplot(x=scores, y=names, palette=palette)
plt.title("Hyperparameter Importance (approx.)")
plt.xlabel("importance score")
plt.show()
# 图 4:Trials 散点(lr vs val acc),颜色表示 hidden_dim,点大小表示 batch_size;并显示优化路径(按 trial number 连线)
trials = study.trials
lr_vals = [t.params['lr'] for t in trials]
acc_vals = [t.value for t in trials]
# color by hidden_dim
hidden_dims = [int(t.params['hidden_dim']) for t in trials]
batch_sizes = [int(t.params['batch_size']) for t in trials]
trial_nums = np.arange(len(trials))
plt.figure(figsize=(10,6))
sc = plt.scatter(lr_vals, acc_vals, c=hidden_dims, s=np.array(batch_sizes), cmap='viridis', alpha=0.85, edgecolor='k', linewidth=0.5)
for i in range(len(trials)-1):
plt.plot([lr_vals[i], lr_vals[i+1]], [acc_vals[i], acc_vals[i+1]], color='gray', alpha=0.3, linewidth=0.8)
plt.xscale('log')
plt.colorbar(sc, label='hidden_dim')
plt.xlabel('learning rate (log scale)')
plt.ylabel('validation accuracy')
plt.title('Trials: lr vs val acc (color=hidden_dim, size=batch_size)')
plt.show()
学习曲线:
横轴为 epoch,包含三条曲线:训练损失、验证损失和验证精度。
-
如果训练损失持续下降且验证损失也下降,说明模型在学到有用的模式,未过拟合。 -
若训练损失下降但验证损失上升,说明开始过拟合。 -
若训练与验证损失都很高,可能是欠拟合(模型容量不足或学习率问题)。
lr × weight_decay 热力图:
行是不同的 weight_decay(从上到下通常是从小到大),列是不同的学习率(从小到大),格子中的颜色与数字表示验证精度。
-
这张图直观展示在 lr 与 weight_decay 两个关键超参上的性能响应面。 -
你可能看到:当 lr 太大(右侧),模型表现变差甚至不稳定;当 lr 太小(左侧),训练过慢,精度也受限。 -
weight_decay(L2 正则)太小可能欠拟合/过拟合迹象不明显,太大会使模型欠拟合。 -
从热力图可以直接选出一个“高性能区间”供后续更精细搜索或微调使用。
超参重要性条形图:
显示 Optuna(或简单相关替代)评估的各个超参对目标(验证精度)的“重要性分数”。
-
分数高的超参表示该参数对结果影响大,值得优先调优或更细致地搜索。 -
分数低的超参可以固定一个合理值以减少搜索空间(节省计算资源)。
Trials 散点:
横轴是 lr(对数尺度),纵轴是验证精度;点颜色表示 hidden_dim,点大小表示 batch_size;并用线将 trial 依次连起来,展示 Optuna 搜索的“轨迹”。
-
这个图帮助理解搜索过程是否“集中”:是否早期样本分散,后期聚焦到高性能区域(理想情况)。 -
如果连线在探索过程中跳来跳去,说明优化器在不停尝试不同区域;如果逐渐集中说明贝叶斯优化找到了表现好的方向。 -
颜色/大小的编码还帮你看出某些超参(如 hidden_dim 或 batch_size)如何与性能关联。
总结
超参数优化是一个“有成本的搜索”问题:每次评估都要训练模型(代价可能很高)。
因此正确的策略应该综合“预算/时间/性能需求”来选择方法:
-
少量超参、且你可以并行评估很多候选:随机搜索 + Hyperband(或 ASHA)是不错的选择。 -
预算有限、想尽快找到较好解:贝叶斯优化(如 Optuna 的 TPE)通常效果好。 -
若训练非常昂贵,建议使用多级筛选(先用小模型或少量 epoch 快速筛,再用更大的资源精调)。
最后

