训练:损失、优化器与调参
这一篇是「能不能训出来」的分水岭。
架构是公开的,数据是你的,能拉开差距的是:知道训练为什么失败,以及怎么一项项排除。
一、损失函数:你在优化什么
| 任务 | 损失 | PyTorch |
|---|---|---|
| 多分类 | 交叉熵 | nn.CrossEntropyLoss() |
| 二分类 | BCE(带 logits 版更稳) | nn.BCEWithLogitsLoss() |
| 回归 | MSE / L1 / SmoothL1 | nn.MSELoss() / nn.L1Loss() |
| 类别不平衡 | 加权交叉熵 | CrossEntropyLoss(weight=...) |
| 排序/对比 | InfoNCE、Triplet | 手写 |
| 生成(LLM) | 下一个 token 的交叉熵 | 同 CrossEntropy,位移一位 |
python
# 类别不平衡:按频次倒数加权
counts = torch.bincount(y_train)
w = (1.0 / counts).float()
w = w / w.sum() * len(w)
criterion = nn.CrossEntropyLoss(weight=w, label_smoothing=0.1)label_smoothing=0.05~0.1 通常能小幅提分并缓解过自信,几乎无成本。
二、优化器:选哪个
| 优化器 | 特点 | 建议 |
|---|---|---|
| SGD + momentum | 泛化常更好,对 lr 敏感 | CV 任务、追求极致精度 |
| AdamW | 自适应、好调、默认首选 | 90% 场景起点 |
| RMSProp | 老牌自适应 | 少见 |
| Lion / 8-bit Adam | 省显存 / 新式 | 大模型微调 |
python
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.01)weight_decay 要排除 bias 与 norm 层参数(进阶但重要):
python
decay, no_decay = [], []
for n, p in model.named_parameters():
if not p.requires_grad: continue
(no_decay if p.ndim < 2 else decay).append(p)
optimizer = torch.optim.AdamW([
{"params": decay, "weight_decay": 0.01},
{"params": no_decay, "weight_decay": 0.0},
], lr=3e-4)三、学习率:最重要的超参
经验起点
| 场景 | lr |
|---|---|
| 从头训小 CNN | 1e-3(SGD 可到 1e-2) |
| Adam/AdamW 微调 | 1e-4 ~ 3e-4 |
| 全参数微调大模型 | 1e-5 ~ 5e-5 |
| LoRA 微调 | 1e-4 ~ 2e-4 |
| 线性探测(只训分类头) | 1e-3 |
调度策略
python
# 预热 + 余弦退火:现代训练标配
scheduler = torch.optim.lr_scheduler.OneCycleLR(
optimizer, max_lr=1e-3,
total_steps=len(train_dl) * epochs, pct_start=0.1,
)
for epoch in range(epochs):
for xb, yb in train_dl:
...
optimizer.step()
scheduler.step() # OneCycle 按 step 更新!| 策略 | 何时用 |
|---|---|
| 常数 | 快速实验 |
| 余弦退火 | 通用,收尾更平滑 |
| OneCycle | 单次训练冲最好结果 |
| ReduceLROnPlateau | 指标停滞时降 lr(省心) |
| 线性预热 | Transformer / 大模型必备(防早期发散) |
找学习率(1 分钟法)
从一个极小值开始每步指数放大,记录 loss,选 loss 下降最陡的那段中点。
python
lrs, losses = [], []
lr = 1e-7
for step, (xb, yb) in enumerate(train_dl):
if step > 200: break
for g in optimizer.param_groups: g["lr"] = lr
loss = loss_fn(model(xb), yb)
optimizer.zero_grad(); loss.backward(); optimizer.step()
lrs.append(lr); losses.append(loss.item())
lr *= 1.15
# 画图:横轴 log(lr),纵轴 loss,取最低点前 1~2 个数量级四、正则化:对抗过拟合
| 手段 | 做法 | 说明 |
|---|---|---|
| 数据增强 | 随机裁剪/翻转/色彩抖动/ Mixup | 最有效,几乎必做 |
| Dropout | nn.Dropout(0.1~0.3) | 小数据有用,大模型常关闭 |
| Weight decay | 1e-2 ~ 5e-2 | 与 AdamW 配合 |
| 早停 | 验证指标 N 轮不升就停 | 必做,省时间还防过拟合 |
| 标签平滑 | 0.05~0.1 | 便宜有效 |
| 减小模型 | 降层数/宽度 | 最后手段 |
python
from torchvision import transforms
train_tf = transforms.Compose([
transforms.RandomResizedCrop(224, scale=(0.7, 1.0)),
transforms.RandomHorizontalFlip(),
transforms.ColorJitter(0.2, 0.2, 0.2),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225]),
])验证集不能做增强
验证/测试只用 Resize + CenterCrop + Normalize。加了随机增强,指标会抖动且不可比。
五、故障诊断表(最有价值的部分)
| 现象 | 最可能原因 | 排查顺序 |
|---|---|---|
| loss 完全不降 | lr 太大/太小;标签与输出错位;忘了 zero_grad | 用 1 个 batch 过拟合测试(见下) |
| loss 变 nan | lr 太大;除零;log(0);fp16 溢出 | 降 lr、加 eps、用 bf16 |
| 训练集 loss 高、验证也高 | 欠拟合 / 模型太小 / 特征不足 | 加大模型、训更久 |
| 训练集极低、验证高 | 过拟合 | 增强、正则、早停、更多数据 |
| 验证指标剧烈震荡 | batch 太小 / lr 太大 / 验证集太小 | 增大 batch、降 lr |
| 训练正常,推理结果全错 | 忘了 eval();预处理不一致;没 to(device) | 对比训练与推理的预处理 |
| 显存 OOM | batch 太大 / 未 no_grad / 残留计算图 | 降 batch、梯度累积、del + empty_cache |
万能的「单批过拟合测试」
任何训练异常,先做这个 —— 3 分钟定位 90% 的 bug:
python
xb, yb = next(iter(train_dl))
model.train()
for i in range(200):
loss = loss_fn(model(xb), yb)
optimizer.zero_grad(); loss.backward(); optimizer.step()
if i % 40 == 0: print(i, loss.item())
# 期望:loss 迅速趋近 0,训练准确率 100%- 能过拟合一个 batch → 模型与训练流程没问题,问题在数据/容量/正则
- 不能 → 代码有 bug(形状错、标签错、lr 错、梯度被切断)
六、混合精度与梯度累积
python
# 混合精度:省显存、提速,几乎不掉精度
scaler = torch.amp.GradScaler("cuda")
for xb, yb in train_dl:
with torch.autocast("cuda", dtype=torch.bfloat16): # A100+ 用 bf16
loss = loss_fn(model(xb), yb)
optimizer.zero_grad()
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
# 梯度累积:显存不够时模拟大 batch
ACCUM = 4
for i, (xb, yb) in enumerate(train_dl):
loss = loss_fn(model(xb), yb) / ACCUM
loss.backward()
if (i + 1) % ACCUM == 0:
optimizer.step(); optimizer.zero_grad()七、一个可复用的训练骨架
python
def train(model, train_dl, val_dl, epochs=20, lr=3e-4, patience=3):
device = next(model.parameters()).device
opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs)
crit = nn.CrossEntropyLoss(label_smoothing=0.1)
best, best_state, bad = 0.0, None, 0
history = []
for ep in range(epochs):
model.train()
tr_loss = 0.0
for xb, yb in train_dl:
xb, yb = xb.to(device), yb.to(device)
loss = crit(model(xb), yb)
opt.zero_grad(); loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) # 防梯度爆炸
opt.step()
tr_loss += loss.item() * xb.size(0)
sched.step()
val_acc = evaluate(model, val_dl, device)
history.append((ep, tr_loss / len(train_dl.dataset), val_acc))
print(f"ep{ep+1} loss={history[-1][1]:.4f} val_acc={val_acc:.4f}")
if val_acc > best:
best, bad = val_acc, 0
best_state = {k: v.clone() for k, v in model.state_dict().items()}
else:
bad += 1
if bad >= patience:
print(f"early stop at ep{ep+1}, best={best:.4f}")
break
if best_state: model.load_state_dict(best_state)
return history
@torch.no_grad()
def evaluate(model, dl, device):
model.eval()
c = t = 0
for xb, yb in dl:
c += (model(xb.to(device)).argmax(1).cpu() == yb).sum().item()
t += yb.size(0)
return c / t这段代码请背下来。 它覆盖了你未来 80% 的实验需求。