深度学习PyTorch终章:从零吃透 PyTorch 训练诊断
看曲线就知道病在哪:从零吃透 PyTorch 训练诊断
副标题:手把手带你把 loss、accuracy、梯度读成"模型体检报告"——从 loss 不动到过拟合,五种典型症状一次说清
你有没有过这种崩溃瞬间:代码写了几百行,按下"开始训练",盯着进度条和曲线,却发现——
- loss 纹丝不动,像被冻住了;
- 或者 loss 一路往下冲,可 验证集准确率就是不涨,训练集 99%、验证集 75%;
- 又或者 loss 突然 飙成 NaN,整个训练当场报废。
这时候你能怎么办?改学习率?换优化器?加数据?没有诊断方法,你就只能像无头苍蝇一样瞎试,一次改一个参数,运气好撞对了,运气不好三天都在原地打转。
其实,训练中的 loss 曲线、accuracy 曲线、梯度分布,就是模型的一份**“化验单”。你给病人抽血化验,是为了从指标反推身体哪里出了毛病;你盯着训练曲线,也是为了从数字反推模型哪里"病了"。这一课,我们就教你怎么当这个"模型医生"**——看一眼曲线,就能说出问题大概出在学习率、数据、形状、还是正则化上。
这篇文章是前面 13 篇(计算图、Shape、Dataset/DataLoader、nn.Module、初始化、优化器、学习率、GPU 训练、正则化)的集大成之作。我会把那些零散知识,串成一套可复用的诊断流程。
一、先搞懂:loss 和 accuracy 到底在"说"什么
在动手诊断前,你得先明白这两个核心指标的字面含义和数学直觉,否则你只是"看数字",而不是"读报告"。
1.1 loss:模型有多"自信地错"
以最常用的交叉熵损失为例,对单个样本,损失是真实类别的负对数似然:
L = − log e z y ∑ j = 1 C e z j L = -\log \frac{e^{z_y}}{\sum_{j=1}^{C} e^{z_j}} L=−log∑j=1Cezjezy
其中 z j z_j zj 是第 j j j 类的 logit, C C C 是类别总数, y y y 是真实类别编号。直觉上:模型越把概率压在错误类别上,loss 越大;压在正确类别上,loss 越小。
有一个反直觉但极好用的"体检基准值":如果一个 C C C 分类模型完全随机猜测(每个类概率都是 1 / C 1/C 1/C),交叉熵损失恰好等于 ln C \ln C lnC。
import math
print(math.log(10)) # 2.302585... ← 10 分类"瞎猜"的理论 loss
所以当你看到 loss 卡在 2.30 纹丝不动、且准确率约等于 10%(=1/10),那几乎可以断定:模型根本没在学,它在纯随机猜。 这个数字是"健康底线",比它低说明在学,和它一样平说明没在学。
1.2 accuracy:模型"答对了几道题"
accuracy 是更贴近人类直觉的指标——预测类别等于真实类别的样本比例。但它有个坑:对损失不敏感。模型可能把正确类的置信度从 0.51 提到 0.99(loss 大幅下降),但只要没越过 0.5 这条决策边界,accuracy 一丝不动。这就是为什么我们要两个指标一起看,而不能只看 accuracy。
1.3 训练集 vs 验证集:泛化误差的"体温差"
最关键的一步,是把 loss/accuracy 同时画在训练集和验证集上。为什么?因为"训练好"和"学得好"是两回事。
从泛化误差的角度,测试误差可以近似分解为:
E test ≈ E train + 泛化差距(gap) E_{\text{test}} \approx E_{\text{train}} + \text{泛化差距(gap)} Etest≈Etrain+泛化差距(gap)
- gap 很小:模型在训练集和验证集上表现接近,说明它学到的是"通用规律"。
- gap 很大(训练集 loss 极低、验证集 loss 高):模型把训练集的噪声也背下来了,这就是过拟合。
- 两者都高:连训练集都没学好,这是欠拟合。
💡 bias–variance 直觉补充:欠拟合对应高 bias(模型太简单/太懒,连训练规律都没抓住);过拟合对应高 variance(模型太"敏感",训练集的一丁点扰动都让它大幅改变)。诊断的本质,就是判断你现在的模型是偏 bias 还是偏 variance。

二、诊断决策树:看曲线 + 分情况
遇到训练问题,不要一个个参数乱试。按下面这条"决策树"从上往下排查,能覆盖 95% 的情况:
1. loss 完全不动 → 学习率太小 / 代码 bug(backward、requires_grad、zero_grad)
2. loss 震荡或变 NaN → 学习率太大 / 梯度爆炸 / 数据有脏值
3. loss 降但 acc 不升 → Shape / 损失函数 / 标签类型 错
4. 训练好、测试差 → 过拟合(加正则化)
5. 训练、测试都差 → 欠拟合(加大模型 / 降正则 / 查数据)

记住一句总纲:
训练诊断 = 看曲线 + 分情况。 loss 不降看学习率和代码,loss 降了准确率不升看 Shape 和损失函数,训练好测试差看过拟合,都差看欠拟合。
下面我们逐条拆开讲,每个症状都给你"症状 → 排查清单 → 诊断代码"三件套。
三、情况 1:loss 完全不动(水平线)
症状
Epoch 1, Loss: 2.3026
Epoch 2, Loss: 2.3026
Epoch 3, Loss: 2.3026
loss 像被钉死了一样,几乎是一条水平线。回想 1.1 节的基准值——如果是 10 分类,这 2.3026 就是
ln
10
\ln 10
ln10,意味着模型在纯随机猜,参数压根没更新。
排查清单
| 检查项 | 怎么查 | 典型病因 |
|---|---|---|
| 学习率是否太小 | print(optimizer.param_groups[0]['lr']),试试调大 10 倍 | 步长太小,参数几乎不动 |
| 梯度是否为 None | print(model.fc1.weight.grad) | backward() 没被调用 / 计算图断了 |
| 梯度是否全 0 | 看 .grad 是否全是 0 | 初始化全 0、或 requires_grad=False |
zero_grad 位置错 | 确认在 backward() 之前调用 | 梯度被提前清零或累加错乱 |
| 数据与模型同设备 | print(x.device, next(model.parameters()).device) | 一个在 CPU 一个在 GPU,算了个寂寞 |
requires_grad 开启 | print(param.requires_grad) 应为 True | 冻结了不该冻结的层 |
诊断代码:一键把每一层的梯度"验个遍"
# 把模型所有参数的 requires_grad 和 grad 打印出来
for name, param in model.named_parameters():
print(f"{name}: requires_grad={param.requires_grad}, grad={param.grad}")
如果某个本该训练的层 requires_grad=False,或者 grad 全是 None/0,那参数自然不会动,loss 也就冻住了。先让梯度"活"起来,再谈别的。
四、情况 2:loss 震荡或变 NaN
症状
Epoch 1, Loss: 2.3000
Epoch 2, Loss: 1.8000
Epoch 3, Loss: 5.2000 # 突然飙升
Epoch 4, Loss: nan # 直接报废
曲线上下乱跳,甚至最后变成 nan。这说明参数更新步子太大,在损失曲面上"弹射"甚至飞出去了。
排查清单
| 原因 | 解决 |
|---|---|
| 学习率太大 | 降 10 倍重试 |
| 梯度爆炸 | 加梯度裁剪 clip_grad_norm_ |
| 数据有异常值 | 检查是否有 inf/nan,并做标准化 |
batch_size 太小 | 适当增大,让梯度估计更稳 |
诊断代码:梯度裁剪 + 数据体检
# ① 梯度裁剪:把梯度总范数限制在 max_norm 以内,防止爆炸
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
# ② 数据体检:检查输入里有没有脏值
print(torch.isnan(x).any(), torch.isinf(x).any()) # 任一为 True 都得先清洗
⚠️ 梯度裁剪不是"治本"的药,而是"安全带"。它防止你撞车,但根因往往还是学习率太大或数据没归一化。裁剪可以先用上稳住训练,再回头把学习率调小。

五、情况 3:loss 降,但 accuracy 不升(最阴险)
症状
loss 在稳步下降,看起来一切正常,可验证集(甚至训练集)准确率纹丝不动。这说明模型确实在"学",但学错了方向——它可能在最小化一个和"答对题"无关的目标。
排查清单
| 检查项 | 怎么查 |
|---|---|
| 标签 Shape 对不对 | 多分类要 [B] 而非 [B, num_classes] |
| 标签类型对不对 | CrossEntropyLoss 要 long,不是 float |
| 损失函数选对没 | 多分类用 CrossEntropyLoss,二分类用 BCEWithLogitsLoss |
| 是否多手加了 Softmax | CrossEntropyLoss 内部自带,别手动加 |
| 输出维度对不对 | 最后一层 Linear 的 out_features 要等于 num_classes |
诊断代码:把张量"体检"出来
logits = model(x)
print(f"logits shape: {logits.shape}") # 应为 [B, num_classes]
print(f"y shape: {y.shape}") # 应为 [B],不是 [B, num_classes]
print(f"y dtype: {y.dtype}") # 应为 torch.long
print(f"y 内容: {y[:10]}") # 应为 0 ~ num_classes-1 的整数
最常见的一个坑:最后一层的输出维度写错,比如 10 分类却写成 Linear(512, 100),或者标签被不小心做成了 one-hot 的 [B, C] 形状。这样 loss 照算不误、还能下降,但 argmax 出来的预测和标签根本对不上号,accuracy 自然不动。
六、情况 4:训练好、测试差(过拟合)
症状
训练集: Loss 0.10, Acc 99%
验证集: Loss 0.80, Acc 75%
训练集表现完美,验证集却差一大截——这是典型的高 variance:模型把训练集的噪声和偶然规律都"背"下来了,换个新数据就不会了。
解决方案
把上一篇(#57 正则化)讲的方法全套上,按"性价比"从高到低试:
- Weight Decay:用
AdamW(weight_decay=0.01),限制权重别长太大; - Dropout:在隐藏层加
nn.Dropout(0.3~0.5),训练时随机"掐掉"部分神经元,强迫模型不依赖单点特征; - Early Stopping:监控验证集 loss,连续
patience=5个 epoch 不降就停; - 数据增强:图像任务做翻转/裁剪,本质是"造更多训练样本";
- 减小模型容量:网络太宽太深也更容易过拟合。

七、情况 5:训练、测试都差(欠拟合)
症状
训练集: Loss 1.80, Acc 45%
验证集: Loss 1.85, Acc 43%
连训练集都没学好,train 和 val 双双"摆烂"。这是高 bias:模型容量或训练力度不够,连基本规律都没抓住。
解决方案
| 方法 | 作用 |
|---|---|
| 增大模型 | 加宽/加深网络,提升表达能力 |
| 训练更久 | 增加 epoch,可能只是还没训够 |
| 换更好的优化器 | SGD → Adam,加速收敛 |
| 降正则化强度 | Dropout/Weight Decay 可能太强,把模型"压傻"了 |
| 检查数据质量 | 标签是否正确、特征是否有用 |
| 调学习率 | 太小学不动,太大又震荡,找个合适的 |
🕳️ 一个容易混淆的点:过拟合和欠拟合都会让 val 准确率低,但病因相反——过拟合是"训太好、泛化差"(train 高 val 低),欠拟合是"根本没训好"(train 也低)。看 train 集的表现,一眼就能区分,别搞反了方向去加正则(那只会让欠拟合更严重)。
八、梯度健康检查:训练中的"血常规"
光看 loss 还不够。训练中定期打印梯度范数,能提前发现梯度消失/爆炸这种"沉默的杀手"——loss 可能还没崩,但深层已经学不动了。
# 检查每一层梯度的范数(模长)
for name, param in model.named_parameters():
if param.grad is not None:
grad_norm = param.grad.norm().item()
print(f"{name}: grad_norm={grad_norm:.6f}")
| 梯度范数 | 可能问题 |
|---|---|
| 0 | 梯度消失或代码 bug(backward 没生效) |
| 很小(< 1e-7) | 梯度消失,深层几乎学不动 |
| 正常(0.01 ~ 1) | 健康,参数在合理更新 |
| 很大(> 100) | 梯度爆炸,离 NaN 不远了 |
💡 为什么看"范数"而不是单个值? 一层有成千上万个参数,单个梯度看不出整体状态。用
.norm()求整个梯度向量的模长,相当于给这层做一次"血压测量",一个数值就能代表健康程度。

九、训练日志模板:诊断的"基础体检表"
好的训练日志是诊断的地基。下面这个模板同时打印 train/val 的 loss、accuracy 和学习率,把前面所有诊断信号都汇总到一行里:
for epoch in range(epochs):
# ---- 训练阶段 ----
model.train()
train_loss, train_correct, total = 0, 0, 0
for x, y in train_loader:
x, y = x.to(device), y.to(device)
optimizer.zero_grad() # ① 清零旧梯度(必须在 backward 前)
logits = model(x)
loss = criterion(logits, y)
loss.backward() # ② 反向传播算梯度
optimizer.step() # ③ 更新参数
train_loss += loss.item()
train_correct += (logits.argmax(1) == y).sum().item()
total += y.size(0)
# ---- 验证阶段 ----
model.eval()
val_loss, val_correct, val_total = 0, 0, 0
with torch.no_grad(): # 验证不追踪梯度,省显存
for x, y in val_loader:
x, y = x.to(device), y.to(device)
logits = model(x)
val_loss += criterion(logits, y).item()
val_correct += (logits.argmax(1) == y).sum().item()
val_total += y.size(0)
# ---- 一行体检报告 ----
print(f"Epoch {epoch+1}: "
f"Train Loss={train_loss/total:.4f} Acc={train_correct/total:.4f} | "
f"Val Loss={val_loss/val_total:.4f} Acc={val_correct/val_total:.4f} | "
f"LR={optimizer.param_groups[0]['lr']:.6f}")
典型输出:
Epoch 1: Train Loss=1.8523 Acc=0.3520 | Val Loss=1.7501 Acc=0.4000 | LR=0.001000
Epoch 5: Train Loss=0.5234 Acc=0.8100 | Val Loss=0.6012 Acc=0.7800 | LR=0.001000
Epoch 10: Train Loss=0.1200 Acc=0.9700 | Val Loss=0.3500 Acc=0.8800 | LR=0.001000
Epoch 15: Train Loss=0.0500 Acc=0.9900 | Val Loss=0.5200 Acc=0.8500 | LR=0.000500
看第 15 行:训练 loss 还在降(0.05),可验证 loss 开始回升(0.35 → 0.52),验证 acc 也从 88% 掉到 85%——过拟合信号亮红灯,该 Early Stopping 了。这就是"同时看 train/val 双线"的价值:单看训练集,你会以为模型还在变好。
核心要点小结
- loss 是"自信地错"的程度,accuracy 是"答对几题"的比例:两者要一起看, ln C \ln C lnC 是随机猜测的 loss 底线。
- train/val 双线对比诊断泛化:gap 小=正常,train 低 val 高=过拟合(高 variance),两者都高=欠拟合(高 bias)。
- 诊断决策树:loss 不动→学习率/代码;震荡/NaN→降 lr/梯度裁剪;loss 降 acc 不升→Shape/损失函数/标签;训练好测试差→过拟合;都差→欠拟合。
- 梯度范数是"血常规":0=bug,太小=消失,正常 0.01~1,太大(>100)=爆炸。
- 好的训练日志是地基:每行同时打印 train/val 的 loss、acc、LR,过拟合的拐点一眼可见。
- 到这里,阶段四结束——你已经能独立搭建 PyTorch 训练脚本并诊断问题了。
动手思考题
- 场景题:你训练一个 10 分类模型,loss 卡在
2.30不动,accuracy 停在10%。请列出至少 3 个可能原因,以及对应的排查代码(提示:回想 1.1 节的 ln 10 \ln 10 ln10 基准和情况 1 的清单)。 - 对比题:训练 loss 从 2.0 降到 0.3,但验证 loss 从 0.5 升到 1.2。这是哪种"病"?用 bias–variance 的视角解释,并说出至少 3 种解法。
- 排查题:写出你遇到 loss 变
NaN时的完整排查步骤(至少 4 步),并说明梯度裁剪为什么只是"安全带"而不是"治本药"。 - 动手实验:把上面第九节的日志模板抄下来,故意把
optimizer.zero_grad()删掉,观察 loss 曲线的变化——你能解释为什么会那样吗?(提示:梯度在累加) - 在评论区贴出你真实踩过最离谱的训练 bug(loss 不动?突然 NaN?acc 永远 50%?),我们一起当"模型医生"给你会诊 💬
下一篇进入阶段五:用真实项目把前面学的全串起来,从最朴素的线性回归开始,亲手跑通"数据→模型→训练→诊断"的完整闭环。
📚 关于本系列
本文是 「AI 学习路线 · 阶段四:PyTorch 深度学习基础」 系列中的一篇。所有文章在我的个人博客上都有 可交互动画 + 完整学习路线 版本,建议配合食用 👇
🔗 在博客上阅读本文原版(含可交互组件、公式动画)
👉 看曲线就知道病在哪:从零吃透 PyTorch 训练诊断🗺️ 查看完整 AI 学习路线(从 0 到进阶,持续更新)
👉 bestsdz.xyz觉得有帮助的话,欢迎去博客点个收藏 ⭐,你的支持是我更新的最大动力!
更多推荐
所有评论(0)