看曲线就知道病在哪:从零吃透 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=logj=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)} EtestEtrain+泛化差距(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 倍步长太小,参数几乎不动
梯度是否为 Noneprint(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 都得先清洗

⚠️ 梯度裁剪不是"治本"的药,而是"安全带"。它防止你撞车,但根因往往还是学习率太大或数据没归一化。裁剪可以先用上稳住训练,再回头把学习率调小。

交互组件 1

🎮 👉 点击在线体验此交互组件


五、情况 3:loss 降,但 accuracy 不升(最阴险)

症状

loss 在稳步下降,看起来一切正常,可验证集(甚至训练集)准确率纹丝不动。这说明模型确实在"学",但学错了方向——它可能在最小化一个和"答对题"无关的目标。

排查清单

检查项怎么查
标签 Shape 对不对多分类要 [B] 而非 [B, num_classes]
标签类型对不对CrossEntropyLosslong,不是 float
损失函数选对没多分类用 CrossEntropyLoss,二分类用 BCEWithLogitsLoss
是否多手加了 SoftmaxCrossEntropyLoss 内部自带,别手动加
输出维度对不对最后一层 Linearout_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 正则化)讲的方法全套上,按"性价比"从高到低试:

  1. Weight Decay:用 AdamW(weight_decay=0.01),限制权重别长太大;
  2. Dropout:在隐藏层加 nn.Dropout(0.3~0.5),训练时随机"掐掉"部分神经元,强迫模型不依赖单点特征;
  3. Early Stopping:监控验证集 loss,连续 patience=5 个 epoch 不降就停;
  4. 数据增强:图像任务做翻转/裁剪,本质是"造更多训练样本";
  5. 减小模型容量:网络太宽太深也更容易过拟合。

一张"过拟合演化"曲线图


七、情况 5:训练、测试都差(欠拟合)

症状

训练集: Loss 1.80, Acc 45%
验证集: Loss 1.85, Acc 43%

连训练集都没学好,train 和 val 双双"摆烂"。这是高 bias:模型容量或训练力度不够,连基本规律都没抓住。

解决方案

方法作用
增大模型加宽/加深网络,提升表达能力
训练更久增加 epoch,可能只是还没训够
换更好的优化器SGDAdam,加速收敛
降正则化强度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() 求整个梯度向量的模长,相当于给这层做一次"血压测量",一个数值就能代表健康程度。

交互组件 2

🎮 👉 点击在线体验此交互组件


九、训练日志模板:诊断的"基础体检表"

好的训练日志是诊断的地基。下面这个模板同时打印 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 双线"的价值:单看训练集,你会以为模型还在变好。


核心要点小结

  1. loss 是"自信地错"的程度,accuracy 是"答对几题"的比例:两者要一起看, ln ⁡ C \ln C lnC 是随机猜测的 loss 底线。
  2. train/val 双线对比诊断泛化:gap 小=正常,train 低 val 高=过拟合(高 variance),两者都高=欠拟合(高 bias)。
  3. 诊断决策树:loss 不动→学习率/代码;震荡/NaN→降 lr/梯度裁剪;loss 降 acc 不升→Shape/损失函数/标签;训练好测试差→过拟合;都差→欠拟合。
  4. 梯度范数是"血常规":0=bug,太小=消失,正常 0.01~1,太大(>100)=爆炸。
  5. 好的训练日志是地基:每行同时打印 train/val 的 loss、acc、LR,过拟合的拐点一眼可见。
  6. 到这里,阶段四结束——你已经能独立搭建 PyTorch 训练脚本并诊断问题了。

动手思考题

  1. 场景题:你训练一个 10 分类模型,loss 卡在 2.30 不动,accuracy 停在 10%。请列出至少 3 个可能原因,以及对应的排查代码(提示:回想 1.1 节的 ln ⁡ 10 \ln 10 ln10 基准和情况 1 的清单)。
  2. 对比题:训练 loss 从 2.0 降到 0.3,但验证 loss 从 0.5 升到 1.2。这是哪种"病"?用 bias–variance 的视角解释,并说出至少 3 种解法。
  3. 排查题:写出你遇到 loss 变 NaN 时的完整排查步骤(至少 4 步),并说明梯度裁剪为什么只是"安全带"而不是"治本药"。
  4. 动手实验:把上面第九节的日志模板抄下来,故意把 optimizer.zero_grad() 删掉,观察 loss 曲线的变化——你能解释为什么会那样吗?(提示:梯度在累加)
  5. 在评论区贴出你真实踩过最离谱的训练 bug(loss 不动?突然 NaN?acc 永远 50%?),我们一起当"模型医生"给你会诊 💬

下一篇进入阶段五:用真实项目把前面学的全串起来,从最朴素的线性回归开始,亲手跑通"数据→模型→训练→诊断"的完整闭环。


📚 关于本系列

本文是 「AI 学习路线 · 阶段四:PyTorch 深度学习基础」 系列中的一篇。所有文章在我的个人博客上都有 可交互动画 + 完整学习路线 版本,建议配合食用 👇

🔗 在博客上阅读本文原版(含可交互组件、公式动画)
👉 看曲线就知道病在哪:从零吃透 PyTorch 训练诊断

🗺️ 查看完整 AI 学习路线(从 0 到进阶,持续更新)
👉 bestsdz.xyz

觉得有帮助的话,欢迎去博客点个收藏 ⭐,你的支持是我更新的最大动力!

更多推荐