训练脚本能运行,只能说明语法大致没问题。loss 不降、验证集变差、结果每次飘得离谱,才是深度学习项目真正花时间的地方。
很多人一遇到这些现象就换模型、加层数、上更大的 GPU。通常太早了。排错应该从数据和最小实验开始,最后才轮到复杂结构。
1. 先建立一条排查顺序
建议按下面的顺序查:
数据与标签 → 单个 batch → 小样本过拟合 → loss/输出层 → 梯度
→ 学习率 → 正则化 → 模型容量 → 数据规模与结构
这个顺序故意把“换大模型”放在后面。标签对不上、类别编号错位、训练集和验证集处理不一致时,更深的模型只会更快地学错。
图解:按依赖顺序排查:数据或更新链路失败时,复杂调参很难提供有效信息。
每次只改变一个诊断条件,保留相同的数据划分与随机种子。loss 出现 NaN 时先记录第一个非有限值的位置,不要先通过截断 loss 掩盖问题。小样本能拟合后,再看训练与验证的差距;两者都差与训练好验证差,需要不同的下一步。
动手核对: 保存正常、学习率过大、标签打乱三份实验配置。由日志绘图,并在图注写清样本数、步数、随机种子,不能将理论示意曲线当作运行结果。
2. 第一关:数据有没有说谎
训练前至少打印这些信息:
features, labels = next(iter(train_loader))
print("features:", features.shape, features.dtype)
print("labels:", labels.shape, labels.dtype)
print("range:", features.min().item(), features.max().item())
print("classes:", labels.unique(sorted=True))
图像任务还要把增强后的样本画出来。文本任务则把 token id 还原成文字检查。肉眼看十几条样本,往往比盯半小时日志有效。
尤其留意数据泄漏。常见形式包括:
- 同一用户、同一视频或同一原始图片的切片同时进入训练和验证;
- 在全量数据上先计算均值、词表或特征选择,再划分数据;
- 文件名、时间戳、背景水印等泄露标签;
- 数据增强在划分之前生成,增强副本被分到不同集合。
验证集很好看,不等于模型学会了任务。它也可能只是认出了重复样本。
3. 小样本过拟合测试
取 16 或 32 个样本,关闭随机增强和大部分正则化,让模型反复训练同一小批数据。一个有足够容量、实现正确的分类模型,应该能把训练损失压得很低。
small_x, small_y = next(iter(train_loader))
small_x = small_x[:32].to(device)
small_y = small_y[:32].to(device)
model = MyModel().to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
criterion = torch.nn.CrossEntropyLoss()
for step in range(300):
model.train()
optimizer.zero_grad(set_to_none=True)
logits = model(small_x)
loss = criterion(logits, small_y)
loss.backward()
optimizer.step()
if step % 50 == 0:
accuracy = (logits.argmax(1) == small_y).float().mean().item()
print(step, loss.item(), accuracy)
如果连这一步都做不到,先别谈泛化。检查输出维度、标签、损失函数、激活函数和参数是否真的参与优化。
4. 看曲线,不要只看最后一个数字
先确认两条曲线的统计口径一致:训练损失是否含正则项,是否启用增强,记录的是最后一个 batch 还是整轮按样本加权平均?口径不同造成的差距,不应立刻解释成过拟合。把这些条件写在图旁边,后面的诊断才有依据。
典型现象可以这样判断:
| 训练曲线 | 验证曲线 | 更可能的问题 |
|---|---|---|
| loss 都不降 | loss 都不降 | 学习率、实现、标签或容量不足 |
| 快速变好 | 先好后差 | 过拟合 |
| 剧烈震荡 | 同样震荡 | 学习率过大、batch 太小、异常样本 |
| 正常下降 | 长期不变 | 训练/验证分布不一致 |
| 突然变成 NaN | 无意义 | 数值溢出、非法输入、梯度爆炸 |
准确率也会遮住问题。类别不平衡时,模型全猜多数类仍可能得到看似不错的 accuracy。至少同时看每类召回率、Macro F1 和混淆矩阵。
5. 学习率先于大部分超参数
学习率通常是最先调的超参数。
optimizer = torch.optim.AdamW(
model.parameters(),
lr=3e-4,
weight_decay=1e-2,
)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
optimizer,
T_max=20,
)
几个信号:
- loss 一开始就爆炸:先把学习率降一个数量级;
- loss 稳定但几乎不动:确认梯度正常后,再尝试提高学习率;
- 前几轮改善快,后面反复震荡:考虑衰减策略;
- 微调预训练模型:骨干网络通常使用更小学习率,新的分类头可以更大。
不要同时改学习率、batch size、优化器和网络结构。否则即使变好了,也不知道是谁起作用。
6. 梯度该怎么观察
def grad_report(model: torch.nn.Module) -> dict[str, float]:
report = {}
for name, parameter in model.named_parameters():
if parameter.grad is not None:
report[name] = parameter.grad.detach().norm().item()
return report
如果某层一直没有梯度,可能是前向路径根本没经过它,或者中途错误地 detach() 了。梯度范数长期接近 0,考虑激活饱和或深层传播衰减;突然极大,则要检查异常 batch 和数值稳定性。
序列模型中常用梯度裁剪:
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
裁剪是保险丝,不是修复一切的胶带。如果每一步都触发强裁剪,模型结构、输入尺度或学习率仍可能有根本问题。
7. 初始化、归一化和残差连接
框架内置层通常已有合理初始化,但理解它们的目标很有用:让不同层的激活和梯度尺度不要随深度快速放大或缩小。
ReLU 网络常见 Kaiming 初始化;Sigmoid/Tanh 网络常见 Xavier 初始化。自定义参数时可以明确设置:
for module in model.modules():
if isinstance(module, torch.nn.Linear):
torch.nn.init.kaiming_uniform_(module.weight, nonlinearity="relu")
if module.bias is not None:
torch.nn.init.zeros_(module.bias)
BatchNorm 使用 batch 统计量,图像网络里很常见;LayerNorm 对单个样本的特征维度归一化,更适合 Transformer 等序列结构。残差连接让某层学习“在输入上改多少”,也给梯度提供了更直接的传播路径。
8. 过拟合时先做什么
过拟合不是“训练太好”,而是模型对训练数据的细节记得太牢,遇到新数据就失灵。处理优先级大致如下:
- 检查划分方式和分布差异;
- 增加或清洗数据;
- 使用符合业务规律的数据增强;
- 减少模型容量或训练轮数;
- 加入 weight decay、Dropout 等正则化;
- 使用早停保存最佳验证权重。
class EarlyStopping:
def __init__(self, patience: int = 5, min_delta: float = 0.0) -> None:
self.patience = patience
self.min_delta = min_delta
self.best = float("inf")
self.bad_epochs = 0
def update(self, value: float) -> bool:
if value < self.best - self.min_delta:
self.best = value
self.bad_epochs = 0
else:
self.bad_epochs += 1
return self.bad_epochs >= self.patience
早停监控什么要与任务一致。类别极不平衡时,只盯 val_loss 或 accuracy 可能错过小类表现恶化,应补充任务真正关心的指标。
9. 可复现不是只设一个 seed
import os
import random
import numpy as np
import torch
def seed_everything(seed: int = 42) -> None:
os.environ["PYTHONHASHSEED"] = str(seed)
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
torch.backends.cudnn.benchmark = False
torch.backends.cudnn.deterministic = True
确定性设置可能牺牲速度,而且不同硬件、驱动和依赖版本之间仍不保证逐位相同。更实际的做法是记录:代码版本、数据版本、划分文件、依赖版本、随机种子、配置和最佳 checkpoint。
训练结果有少量波动很正常。若一个结论只在某个随机种子下成立,它本身就不稳。
10. Windows、Colab 与 CPU 的差异
- Windows 的
DataLoader多进程问题较多,先从num_workers=0开始; - Colab 会话会中断,checkpoint 应保存到持久位置;
- CPU 路径要缩小 batch、图像尺寸或数据子集,而不是把 epoch 无限拉长;
- CUDA 可用不代表所有张量都自动上 GPU,仍要统一
.to(device); - 混合精度能降低显存,但出现 NaN 时应先退回全精度定位。
11. 一张实用排错清单
训练前:
- 随机抽样可视化;
- 确认标签映射和类别数;
- 检查训练/验证划分单位;
- 记录输入 shape、dtype、范围;
- 保存一份固定验证样本。
训练中:
- 同时记录训练和验证 loss;
- 检查每类指标,而非只有 accuracy;
- 定期记录学习率和梯度范数;
- 保存最佳权重与配置;
- 遇到异常先保留出错 batch。
12. 练习与面试题
- 为上一节的小模型加入训练曲线记录,并画出 train/val loss。
- 人为打乱一部分标签,观察小样本过拟合测试的变化。
- 将学习率分别放大和缩小,描述曲线差异,不追求精确数值。
- 写一个函数检查所有参数是否拿到了梯度。
Dropout 在训练和推理时有什么区别?
训练时随机屏蔽部分单元并按规则缩放输出;推理时使用完整网络。因此必须正确切换 train() 和 eval()。
weight decay 与 Dropout 有什么不同?
weight decay 约束参数规模,Dropout 在训练时随机改变子网络。两者都可能改善泛化,但作用位置和机制不同。
发现验证集明显高于训练集,一定有问题吗?
不一定。训练阶段有随机增强和 Dropout,验证阶段没有,验证指标可能短暂更高。但若差距异常,应检查数据泄漏和预处理不一致。
下一篇
训练骨架和排错顺序已经齐了。下一篇进入第一种真正处理图像结构的模型:CNN 从卷积到图像分类。已有 OpenCV 文章讲的是图像处理;CNN 关注的是如何从数据中学出特征,两者不要混为一谈。
💬 评论
这套方案在哪一步卡住了?也欢迎补充你的实现方式或踩坑经验。