在深度学习项目里,代码能跑通只是第一步。真正拉开差距的,是跑通之后如何系统性地完成模型改进、损失函数调优、实验管理和结果复现。很多团队把大量时间花在改网络结构和调超参数上,却忽略了基线管理、错误分析和评估一致性,最后得到的模型既无法解释,也无法上线。这篇文章要解决的问题就是:代码跑起来之后,下一步应该做什么,以及每一步怎么做才不至于白做。
文章会沿着一条完整的技术主线展开:先讲清楚跑通代码和完成有效训练之间的差距,再建立一个最小可复现的实验基线,然后通过错误分析确定改进方向,接着讨论损失函数和模型结构改进的正确做法,最后把部署阶段的数值精度问题一起讲透。读者可以是刚跑通代码还没理清思路的初学者,也可以是已经在模型改进过程中反复遇到“改了没效果、调了没过拟合、部署后指标掉”的开发人员。学完后,你至少能得到一套可复用的实验记录模板和排查清单,后续所有实验都能往这个框架里填。
1. 先想清楚:代码跑通不等于实验完成
1.1 跑通和有效训练是两种状态
很多人判断一个深度学习项目是否成功,标准是“按下训练按钮之后没有报错,loss一直在下降”。这个标准在演示阶段够用,但在实际项目里远远不够。跑通代码只是说明程序路径是通顺的,而有效训练要求的是结果可信、可复现、可对比。
把这两个状态放进同一张表里看会更清楚:
| 维度 | 代码跑通 | 有效训练 |
|---|---|---|
| 训练过程 | 进程不退出,loss 有变化 | 收敛稳定,指标在合理范围 |
| 评估行为 | 能输出验证集结果 | 使用固定评估脚本,指标可复现 |
| 模型保存 | 可能只保存了最后一次权重 | 保存最佳权重和完整训练信息 |
| 随机性 | 结果每次都不同 | 固定种子后结果基本一致 |
| 改进能力 | 改参数后不知道是否真正变好 | 每次实验有对应基线,能判断提升 |
| 部署准备 | 未验证数值精度和推理一致性 | 训练和部署精度差异已量化 |
实际项目里最常见的误区,是看到 loss 下降就以为模型在“学东西”。但如果数据加载顺序错乱、标签映射错误、验证集混入训练集,loss 一样会下降,模型一样会收敛,最后评估出来的指标却没有任何参考价值。所以第一步不是急着换模型,而是先确认现在的实验结果是否可信。
1.2 最小可复现基线是后续所有改进的地基
所谓“最小可复现基线”,是用当前代码得到一组稳定的、固定的指标,并且这组指标可以被任何人重新跑出来。它不需要效果好,甚至不需要超过随机太多,但它必须满足三个条件:数据固定、配置固定、评估方式固定。
操作顺序建议如下:
- 先用极小规模数据跑 100 个 batch,确认前向、反向、梯度更新不报错。
- 用完整训练集跑一个完整 epoch,观察单 epoch 耗时和显存占用。
- 在验证集上跑一次评估脚本,记录指标。
- 把模型结构、损失函数、优化器、学习率、batch size、数据路径全部写进配置文件。
- 保存这次实验的所有日志和最佳权重,作为第 0 号实验。
很多项目没有做第 5 步。等到后来改了损失函数,发现验证指标提升了 2%,却说不清提升是因为损失函数改对了,还是因为这次训练时随机种子碰巧好、数据增强随机方式变了。没有基线,所有改进都是猜测。
1.3 实验记录模板应该包含哪些内容
记录实验不是写日记,而是要让每条信息都能回答“这次实验和上一次到底差在哪里”。推荐用实验编号加配置目录的方式管理,目录结构可以是:
experiments/ exp001_baseline/ config.yaml train.log best_model.pt metrics.json exp002_focal_loss/ config.yaml train.log best_model.pt metrics.json每个实验目录里至少记录以下信息:
| 记录项 | 示例值 | 作用 |
|---|---|---|
| 数据集版本 | data_v2_20240401 | 数据是否变化 |
| 模型结构 | resnet50 + se_block | 结构是否变化 |
| 损失函数 | cross_entropy_weighted | 损失是否变化 |
| 优化器 | sgd_momentum_0.9 | 优化策略是否变化 |
| 初始学习率 | 0.01 | 超参数是否变化 |
| batch size | 32 | 训练吞吐是否变化 |
| 训练轮数 | 50 | 训练量是否变化 |
| 最佳 epoch | 37 | 最佳模型出现位置 |
| 验证指标 | acc=0.862 | 最终结果 |
| 备注 | 只改了 Focal Loss gamma=2.0 | 人类可读的变更说明 |
如果你想在团队里使用,建议把这份记录做成 Markdown 模板或 YAML 文件,放在每个实验目录里。没有记录的训练,等于没有跑过。
2. 从第一次有效训练开始,把“能训练”升级成“可评估”
2.1 固定随机种子和确定性设置
深度学习训练过程中存在多个随机来源:数据加载顺序、模型权重初始化、Dropout 随机失活、GPU 并行计算顺序。如果不固定随机种子,哪怕训练代码完全一致,两次训练出来的模型指标也会有波动。波动幅度可能不大,但足够掩盖真实改进效果。
PyTorch 里一个常用的固定种子函数如下:
import random import numpy as np import torch def set_seed(seed: int): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False代码里有几个关键点需要理解。torch.manual_seed负责 CPU 上的随机数生成,torch.cuda.manual_seed_all负责所有 GPU 上的随机数生成,这两个通常要同时设置。cudnn.deterministic = True让 cuDNN 选择确定性算法,保证卷积计算顺序稳定。cudnn.benchmark = False关闭自动寻找最快卷积算法的机制,因为 benchmark 模式会引入微小波动。
但要注意,固定 seed 不能解决所有可复现问题。DataLoader 开启num_workers > 0之后,数据加载子进程的随机行为仍然可能造成波动。如果对复现性要求很高,可以给 DataLoader 也设置generator=torch.Generator().manual_seed(seed),并且在测试阶段关闭 Dropout、BatchNorm 切换为 eval 模式。
注意:确定性模式会让训练变慢一些。它在“复现实验结果”和“追求最快训练速度”之间是一种取舍。日常消融实验建议开启确定性,纯生产训练如果对速度敏感,可以关闭确定性但保留日志记录。
2.2 记录训练指标和评估指标
训练指标是给训练过程看的,评估指标是给模型效果看的。训练时最常看的指标是 loss,它能反映收敛趋势,但 loss 下降不代表评估指标会同步上升,尤其在模型过拟合之后。所以每次实验至少要同时记录两类指标:
- 训练过程指标:loss、梯度范数、当前学习率、每秒处理样本数。
- 验证评估指标:准确率、精确率、召回率、F1、mAP、IoU 等,具体取决于任务类型。
记录指标最省事的方式是使用 TensorBoard 或 WandB。如果不想引入额外平台,也可以自己写一个轻量保存逻辑:
best_score = 0.0 for epoch in range(epochs): train_loss = train_one_epoch(model, train_loader, optimizer, criterion, device) val_score = validate(model, val_loader, device) print(f"epoch={epoch}, train_loss={train_loss:.4f}, val_score={val_score:.4f}") if val_score > best_score: best_score = val_score torch.save( { "epoch": epoch, "model_state_dict": model.state_dict(), "optimizer_state_dict": optimizer.state_dict(), "best_score": best_score, "config": config, }, "best_model.pt", )这段代码里有两个容易被新手忽视的地方。一个是保存 optimizer_state_dict,断点续训时需要恢复优化器的动量等信息,只保存模型权重会导致续训后训练行为发生变化。另一个是保存 config,checkpoint 文件自带配置后,后续排查权重来自哪次实验时就不用再去翻目录。
这里的验证指标可以是准确率,也可以是 mAP、IoU 等。关键是判断“哪个值越大模型越好”时要保持一致,后续所有实验都用同一个评估函数计算,否则会前功尽弃。
2.3 验证集必须与训练集严格隔离
有一种“假改进”现象是:训练时发现 loss 不断下降,验证集指标也一路暴涨,最后部署到真实场景却表现很差。最常见的原因是验证集没有和训练集严格隔离,或者验证流程里混入了不合理的预处理。
需要检查的点包括:
- 数据划分时是否按文件列表随机切分,同一个样本是否可能同时出现在训练集和验证集。
- 评估时是否使用了训练阶段的数据增强,比如随机裁剪、随机翻转。验证集应该只做确定性预处理。
- 训练时的归一化参数,是否也用在验证和推理阶段。均值、方差必须和训练阶段一致。
- 验证脚本和数据加载代码是否和训练脚本完全分离,避免开发时不小心修改了评估函数。
如果发现验证集指标比训练集还好一大截,不要高兴,先怀疑数据泄漏。正确做法是先画出训练集和验证集的标签分布,确认分布接近,再检查文件路径是否有重叠。
3. 用错误分析决定改进方向,而不是凭感觉调参
3.1 对验证集做逐样本错误分析
loss 是一个聚合指标,它告诉你模型整体表现不好,但不会告诉你具体错在哪里。模型改进最忌讳的做法是:看到指标不行,就盲目改学习率、加网络层数、换损失函数。正确做法是先做错误分析,把模型在验证集上的错误样本翻出来,看它们是哪一种错误。
简单实现如下:
model.eval() wrong_samples = [] with torch.no_grad(): for batch_idx, (x, y_true) in enumerate(val_loader): logits = model(x) y_pred = logits.argmax(dim=1) probs = torch.softmax(logits, dim=1) mask = y_pred != y_true for i in torch.nonzero(mask).flatten().tolist(): wrong_samples.append( { "batch": batch_idx, "index_in_batch": i, "true": y_true[i].item(), "pred": y_pred[i].item(), "confidence": probs[i, y_pred[i]].item(), } ) print(f"total wrong samples: {len(wrong_samples)}")拿到错误样本之后,先不要急着看 loss。按置信度排序,观察两个极端:
- 高置信度但预测错误:模型很自信地错了。这类样本往往意味着训练数据里有标签噪声,或者模型在某些特征上过拟合。
- 低置信度错误:模型本身就犹豫。这类样本说明模型学到该类别特征的能力不足,通常需要增加数据量、增加特征表达能力,或者调整损失函数。
3.2 使用混淆矩阵定位类别问题
对分类任务来说,混淆矩阵是最直接的定位工具。使用 sklearn 可以快速计算:
from sklearn.metrics import confusion_matrix, classification_report cm = confusion_matrix(y_true_list, y_pred_list) print(cm) print(classification_report(y_true_list, y_pred_list))读取混淆矩阵时要关注几个位置:
- 对角线数值是否明显高于其它位置。
- 哪些类别之间互相混淆最严重。
- 某类的样本量是否很少,导致 recall 很低。
如果模型总是把类别 A 预测成类别 B,说明这两个类别在特征空间里过于接近,优先考虑数据增强、类别加权或者难例挖掘,而不是盲目加深网络。如果某个类别样本极少,换更复杂的模型大概率没有用,重点应该放在数据采集、数据增强或者使用类别平衡的损失函数。
3.3 可视化 bad case,发现标注和预处理问题
在图像任务中,把预测错误的输入图片连同标签、预测结果、置信度一起保存下来,是找数据问题的最快方式。很多模型“改进”到最后发现,真正的问题是标注边界不统一,或者预处理时做了错误归一化。
可视化 bad case 时建议按类别分组保存,比如:
bad_cases/ class_0/ sample_with_true_0_pred_1.jpg ... class_1/ ...一个很小的细节:保存图片时要同时保存原始输入和模型实际看到的输入。模型实际看到的输入是经过归一化、resize 之后的版本。如果你只保存原始图片,可能会漏掉预处理环节导致的问题。
注意:可视化 bad case 的目标不是找到几个错误样本就结束,而是统计错误模式。等你看到第三十个错误样本时,基本能判断是数据问题、模型容量问题还是类别不平衡问题。
4. 损失函数改进:先理解当前损失函数的局限,再动手修改
4.1 交叉熵损失函数为什么是默认选择
交叉熵损失函数是分类任务最常用的损失函数,PyTorch 中可以直接使用:
import torch.nn as nn criterion = nn.CrossEntropyLoss(weight=None)它的含义是:对于正确类别,希望模型输出的概率尽量接近 1。从公式上看:
L = -sum(y * log(p))其中 y 是 one-hot 标签,p 是模型预测的概率分布。这个公式简单、可导、梯度稳定,在类别均衡的多分类任务里已经是很好的选择。
但交叉熵有几个明显局限:
- 对类别不平衡不敏感。如果 99% 的样本是背景类,1% 是目标类,交叉熵会让模型学习到一个“把所有人预测成背景”的局部最优,loss 依然很低。
- 对难易样本一视同仁。大量已经分类正确的样本仍然贡献梯度,可能淹没少量困难样本的梯度。
- 对像素级分割任务,它只考虑逐像素误差,不考虑区域整体结构。
因此在做损失函数改进之前,先要问自己:当前任务的问题到底是不平衡、难样本、还是区域连续性?不要因为“别人用了 Focal Loss 效果不错”就直接照搬。
4.2 常见损失函数改进与实现示例
| 损失函数 | 核心思想 | 适合任务 | 注意事项 |
|---|---|---|---|
| 加权交叉熵 | 按类别频率给 loss 加权 | 类别不平衡分类 | 权重设计会影响收敛,过大会导致震荡 |
| Focal Loss | 降低易分样本权重,聚焦难样本 | 稠密目标检测、极端不平衡 | gamma 和 alpha 需要调参 |
| Dice Loss | 优化区域重叠度 | 医学图像分割 | 小目标区域训练不稳定 |
| CE + Dice | 结合逐像素和区域损失 | 分割任务 | 两个损失需要控制量级 |
加权交叉熵只需要改nn.CrossEntropyLoss(weight=class_weight),权重为每个类别的样本占比倒数或平方根倒数。
Focal Loss 是另一个常用改进,核心实现如下:
import torch import torch.nn as nn import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, alpha=None, gamma=2.0, reduction="mean"): super().__init__() self.alpha = alpha self.gamma = gamma self.reduction = reduction def forward(self, logits, targets): ce = F.cross_entropy(logits, targets, reduction="none") pt = torch.exp(-ce) focal_loss = (1 - pt) ** self.gamma * ce if self.alpha is not None: if isinstance(self.alpha, (float, int)): alpha_t = self.alpha else: alpha_t = self.alpha[targets] focal_loss = alpha_t * focal_loss if self.reduction == "mean": return focal_loss.mean() if self.reduction == "sum": return focal_loss.sum() return focal_loss这段代码的关键是pt。它表示模型对真实类别的预测概率,数值越接近 1,说明样本越容易分类。(1 - pt) ** gamma会给容易分类的样本一个很小的系数,给难分类的样本保留较大权重。gamma 通常取 2,越大越关注难样本,但过大容易让训练不稳定。
如果是二分类任务或者类别极不平衡的目标检测,还有一个思路是使用基于归一化高斯距离的 NWD-Loss 处理小目标,它把目标框建模成高斯分布,缓解小目标对 IoU 类损失过于敏感的问题。这类损失在特定任务中很有效,但落地前要确认实现和任务场景是否匹配。
做损失函数改进时最容易踩三个坑:
- 只在训练集上对比 loss 是否下降,却不看验证集评估指标。损失函数的改进一定要落到评估指标上。
- 直接组合多个损失后数值量级差异巨大。比如 CE 是 1.0,Dice 是 0.1,组合后 Dice 贡献被忽略,实验结果等于没改。
- alpha 或 class weight 没有放到正确的设备上。代码里用了
self.alpha[targets]时,alpha 必须和 targets 在同一个 device,否则会报设备不一致错误。
4.3 自定义组合损失时如何控制量级
组合多个损失时,先分别计算每个损失在第一批数据上的数值范围,再设置权重。下面是一个 CE + Dice 的组合:
class CombinedLoss(nn.Module): def __init__(self, ce_weight=1.0, dice_weight=1.0): super().__init__() self.ce = nn.CrossEntropyLoss() self.ce_weight = ce_weight self.dice_weight = dice_weight def forward(self, logits, targets): ce_loss = self.ce(logits, targets) dice_loss = dice_loss_fn(logits, targets) return self.ce_weight * ce_loss + self.dice_weight * dice_loss对应的 Dice Loss 简版实现:
def dice_loss_fn(logits, targets, eps=1e-6): probs = torch.softmax(logits, dim=1) num_classes = probs.shape[1] one_hot = F.one_hot(targets, num_classes=num_classes).permute(0, 3, 1, 2).float() intersection = (probs * one_hot).sum(dim=(2, 3)) union = probs.sum(dim=(2, 3)) + one_hot.sum(dim=(2, 3)) dice = (2.0 * intersection + eps) / (union + eps) return 1.0 - dice.mean()logits 的形状是(B, C, H, W),targets 的形状是(B, H, W),两个损失相加前最好都打印出来看一下数值量级。推荐先让每个损失单独在 100 个 batch 上输出平均值,再决定权重。不要凭感觉写ce_weight=1.0, dice_weight=10.0,可能一上来就把梯度方向带偏。
5. 模型结构改进:在核心模块上做小而稳的改动
5.1 先确定改进位置:主干、颈部还是头部
模型结构改进不能没有方向。以常见检测和分割网络为例,可以把改动位置分成三段:
| 改进位置 | 常见手段 | 影响范围 | 风险 |
|---|---|---|---|
| 主干网络 | 换 ResNet 系列、增加注意力模块 | 特征提取能力 | 计算量增加,收敛变慢 |
| 颈部网络 | FPN 多尺度融合、PANet | 多尺度特征融合 | 结构复杂度提升 |
| 头部网络 | 检测头、分割头、分类头 | 任务预测能力 | 容易过拟合 |
先跑完基线实验,再结合第 3 章的错误分析结果判断瓶颈在哪。如果小目标检测效果差,问题可能出在颈部特征融合不够,而不是简单加深主干;如果所有类别的边缘都比较粗糙,问题可能出在分割头或损失函数;如果训练集上效果很好但验证集效果差,结构已经够强,应该先处理过拟合。
以 UNet 这类分割网络为例,如果觉得特征通道的利用效率不高,可以在编码器每个 block 的卷积之后插入 SE Block。这类改动比直接更换整个主干更容易控制变量。
5.2 给模型加入注意力模块的示例
SE Block 是一种经典的通道注意力模块,它通过全局池化计算每个通道的重要性,然后对特征图做通道加权。实现如下:
import torch from torch import nn class SEBlock(nn.Module): def __init__(self, channels, reduction=16): super().__init__() self.squeeze = nn.AdaptiveAvgPool2d(1) self.excitation = nn.Sequential( nn.Linear(channels, channels // reduction), nn.ReLU(inplace=True), nn.Linear(channels // reduction, channels), nn.Sigmoid(), ) def forward(self, x): b, c, _, _ = x.shape y = self.squeeze(x).view(b, c) y = self.excitation(y).view(b, c, 1, 1) return x * ySE Block 的 forward 过程分为两步:先用全局平均池化把一个通道压缩成一个数值,得到全局描述;再通过两层全连接和 Sigmoid 得到 0 到 1 之间的权重,把权重乘回原始特征图。reduction控制中间层的压缩比例,常见取 16。
插入已有网络时,要保证位置一致。通常放在残差块之后、激活函数之前。以 UNet 为例,可以在每个编码器 block 的卷积输出后面加一个SEBlock(channels)。如果插入位置不一致,对比实验就无法解释是注意力模块起作用,还是只是网络加深了。
注意:不要试图在同一个实验里同时换主干、加注意力、改损失函数。一次只改一个变量,否则改进的来源无法判断。
5.3 改进后的对比实验必须遵守一个变量原则
设计对比实验时,用表格记录会比记忆更可靠:
| 实验编号 | 网络结构 | 损失函数 | 其他训练配置 | 验证指标 | 结论 |
|---|---|---|---|---|---|
| exp001 | baseline | CE | lr=0.01, bs=32 | 0.862 | 基线 |
| exp002 | baseline | Focal Loss | 同上 | 0.871 | 损失改进有效 |
| exp003 | baseline + SE | CE | 同上 | 0.868 | 结构改进有效 |
| exp004 | baseline + SE | Focal Loss | 同上 | 0.875 | 两者叠加有效 |
这里面最容易出错的地方是“其他训练配置”并没有真的保持一致。比如改了损失函数之后,某个全局temperature参数没有复用;或者在加注意力模块时,没有保持初始化的方式一致。建议把训练代码抽成同一个入口,让 loss 和 model 通过配置文件传入,而不是复制多个脚本。
6. 模型部署前要搞清楚数值精度:FP32、FP16、BF16、TF32
6.1 四种浮点格式的核心差异
模型改进完、评估也满意之后,进入部署阶段,很多问题会集中体现在数值精度上。训练时默认使用 FP32,但部署推理时为了提速和节省显存,通常会把模型转换成 FP16、BF16,或者利用 Tensor Core 的 TF32 计算路径。理解这些格式的差异,既是部署基础,也是在模型改进过程中避免“训练好、部署差”的关键。
| 格式 | 总位数 | 指数位 | 尾数位 | 常见用途 | 主要注意点 |
|---|---|---|---|---|---|
| FP32 | 32 位 | 8 位 | 23 位 | 训练默认、CPU/GPU 通用 | 精度高但显存占用大 |
| FP16 | 16 位 | 5 位 | 10 位 | 混合精度训练、GPU 推理加速 | 范围小,容易溢出,需要 loss scaling |
| BF16 | 16 位 | 8 位 | 7 位 | 混合精度训练、大模型训练 | 范围接近 FP32,但精度相对较低 |
| TF32 | 32 位输入 | 19 位内部精度 | 内部 | Tensor Core 加速路径 | Ampere 及更新架构常见,精度介于 FP32 和 FP16 之间 |
FP16 的问题是数值范围太小,指数位只有 5 位,能表示的最大值约 65504。训练过程中激活值一旦超过这个范围,就会出现 inf 或 NaN。所以 PyTorch 做混合精度训练时,需要搭配GradScaler,它反向传播前对梯度做缩放,更新参数前再缩小回来。
BF16 用 16 位换来了和 FP32 一样的指数范围,尾数位却少了,所以它的相对精度比 FP16 低,但由于动态范围大,大模型训练更稳定。TF32 不是一个新的存储格式,而是在 Tensor Core 计算中使用的一种截断精度模式,读取 FP32 数据后用 19 位内部精度计算。
6.2 用 PyTorch AMP 做混合精度训练时的正确姿势
PyTorch 官方推荐使用torch.cuda.amp做自动混合精度训练,核心代码是:
scaler = torch.cuda.amp.GradScaler() for batch in train_loader: x, y = batch optimizer.zero_grad() with torch.cuda.amp.autocast(): logits = model(x) loss = criterion(logits, y) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()autocast负责在 forward 过程中自动选择哪些算子用 FP16、哪些继续用 FP32。GradScaler负责把 loss 放大一个系数,完成反向传播后再把梯度缩小回来,避免小梯度在 FP16 下变成 0。
这段代码常见的问题是:
- 忘了加
GradScaler,导致 loss 正常但梯度已经溢出。 - 在
autocast外部手动把模型输出转成 FP16,反而破坏了自动精度选择。 - 验证和部署阶段不小心启用了
autocast,导致输出精度低于预期。
6.3 如何量化验证精度修改是否破坏模型
部署前如果用 FP16 或 TF32 跑推理,必须做一次精度对比。方法很简单:用同一批测试数据分别跑 FP32 和 FP16,计算预测输出的最大绝对误差,以及评估指标差异。
model_fp32.eval() model_fp16.eval() max_diff = 0.0 with torch.no_grad(): for x in test_loader: y1 = model_fp32(x) y2 = model_fp16(x).float() diff = (y1 - y2).abs().max().item() max_diff = max(max_diff, diff) print(f"max diff: {max_diff:.6f}")如果最大误差很小,再分别计算 FP32 和 FP16 的评估指标。评估指标通常允许零点几百分比的波动。如果 mAP 掉得比较多,优先检查:
- 归一化参数是否一致。
- 模型是否包含对数值范围敏感的算子,比如 Softmax 和 LayerNorm。
- 是否有溢出或下溢,中间激活值是否出现 inf。
生产环境里不要只看“模型能推理”就认为部署成功,要同时记录部署精度和参考精度,两者差异必须在可接受范围内。
7. 常见问题排查与完整实验管理清单
7.1 从 loss 现象倒推问题来源
模型改进过程中遇到的情况通常有规律。下面是一张可以直接对照排查的表:
| 问题现象 | 可能原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| loss 完全不变 | 学习率过小、梯度为 0、数据和标签不对应、模型输出被错误截断 | 打印 loss 和梯度范数;检查输入输出形状 | 用极小数据过拟合一个 batch,再用标准学习率训练 |
| loss 下降很快但验证指标不涨 | 数据泄漏、过拟合、验证代码不一致 | 检查数据划分;对比训练和验证预处理 | 修复数据划分,统一评估脚本 |
| loss 出现 NaN | 学习率过大、FP16 溢出、log(0)、梯度爆炸 | 打印梯度范数;开启 AMP 的 GradScaler | 降低学习率,检查损失函数内部有无除零风险 |
| 训练集指标好但验证集差 | 过拟合 | 观察训练和验证曲线是否持续拉大 | 增加数据增强、降低模型容量、加入正则化 |
| 训练不稳定震荡 | batch size 过小、损失函数数值量级失衡 | 画出每个 batch 的 loss | 增加 batch size,调整损失权重 |
| 换了损失函数后指标反而下降 | 没有对齐数值量级、参数不合适 | 分别打印各分量 loss | 单独验证各损失,再组合 |
排查时建议按顺序来:先检查输入数据的形状和标签分布,再检查模型 forward 和 loss 是否对齐,接着看梯度范数,最后才调学习率。直接改学习率很容易掩盖真实问题,尤其是标签错位这类基础问题。
7.2 每次实验发布前都要确认的清单
整理一份可复用的实验管理清单,可以贴在项目 README 里,每次实验开始和结束各检查一遍:
- [ ] 代码是否提交到 Git,commit 号是否记录。
- [ ] 数据集版本、划分方式是否固定。
- [ ] 是否设置随机种子。
- [ ] 模型结构、损失函数、优化器、学习率、batch size 是否写入配置。
- [ ] 验证集和训练集是否严格分离。
- [ ] 评估脚本是否与训练脚本独立并保持固定。
- [ ] 是否保存了 best checkpoint 和 last checkpoint。
- [ ] checkpoint 是否包含 optimizer 状态和 config。
- [ ] 是否记录了训练日志和评估指标。
- [ ] 部署阶段是否用同一套预处理。
- [ ] 若切换 FP16、BF16 或 TF32,是否做了精度对比。
这套清单的核心价值不是走形式,而是保证任何一次“改进”都能追溯到明确的实验变化。没有这套流程,模型改进基本靠运气。
7.3 下一步可以扩展的方向
如果基线已经稳定,错误分析也做完了,损失函数和模型结构都有一轮有效改进,可以继续向三个方向扩展:
- 超参数搜索。使用网格搜索、随机搜索或贝叶斯优化,但每次搜索都要走同一个实验记录流程,否则结果无法归因。
- 数据增强策略。从简单翻转、裁剪,到更强的 MixUp、CutMix 等,重点是观察验证集是否同步提升。
- 模型压缩。训练完成后做剪枝、量化和知识蒸馏,目标是在保住评估指标的前提下减小模型体积和推理耗时。
这三个方向都不是独立的,最终都要回到同一个问题:评估指标是否提升、是否可解释、是否可复现。没有评估闭环的扩展,只是在制造更多的实验噪声。
回到开头那个问题:深度学习代码跑通后要做什么?核心答案不是马上换大模型,也不是盲目调学习率,而是先把基线训练、评估、记录这套闭环建立起来,然后在损失函数和模型结构上做受控改进,最后在部署精度层面验证结果是否可接受。对于刚入门的读者,建议从今天开始给每个实验建一个目录,把配置、日志、权重、指标全部放进去,坚持几个项目之后,你会明显感觉到模型的每次改进都有据可查,不会再出现“改了不知道有没有用”的处境。