
1. 先把边界划清楚这个项目到底在练什么做CIFAR10 彩色图片识别这件事我见过太多人把它当成“跑通一个 PyTorch 教程”就结束了下载数据、套个 LeNet、跑十个 epoch、打印 60% 的准确率、截图发群里收工。但如果你真把PyTorch 实战当成一门手艺来练这个数据集的定位其实非常特殊——它是从“玩具级”跨到“真实级”的第一道门槛。MNIST 是灰度 28×28、十类数字、类间差异极大一个两层全连接网络都能刷到 97%你从里面几乎学不到任何工程判断力而 CIFAR10 是 32×32 的三通道彩色图十类全是自然物体猫和狗、鹿和马、飞机和船彼此之间的像素统计差异非常小模型必须真正学到“耳朵形状”“机翼轮廓”这类局部结构才能分开。这意味着你在这里做的每一个决策——归一化参数怎么定、增强加多重、学习率怎么退火、BN 放在哪——都会直接体现在曲线上而不是被数据的简单性掩盖掉。我写这篇东西的目的很明确假设你已经会写nn.Module、知道什么是交叉熵但每次自己搭一个图像分类流程结果总在 70% 上下晃、调参全靠玄学。那这篇就按我真实的做事顺序走一遍先把技术路线和取舍讲清楚再把数据管线和训练循环拆到每一行代码为什么这么写然后是完整跑一遍的实测记录和结果解读最后是我踩过的坑和排查顺序。文中所有代码都可以直接抄所有参数都给了推导不是拍脑袋填的。2. 项目整体设计与技术路线拆解2.1 为什么第 P2 周一定要从灰度跳到彩色三通道很多人会问既然只是练手为什么不用更简单的数据我的答案反过来了正因为它不简单才值得花一周。从 MNIST 换到 CIFAR10本质上有四个维度同时升级每一个都会给你带来新的工程量。第一是通道维度。输入从[N, 1, 28, 28]变成[N, 3, 32, 32]你要开始考虑 RGB 三个通道各自的分布差异。CIFAR10 的图像整体偏亮、天空和草地占比高三个通道的均值并不是 0.5而是大约 0.49/0.48/0.45标准差也不是 0.5而是 0.20 左右。如果你偷懒直接Normalize((0.5,0.5,0.5),(0.5,0.5,0.5))网络前几层就得额外花容量去把这个偏移学回来收敛会明显慢一拍。第二是类间距离变小。MNIST 里“3”和“8”的差距是结构性的而 CIFAR10 里“猫”和“狗”在 32×32 分辨率下可能只有耳朵尖和口鼻的比例不同。这就要求网络必须有足够的感受野和层次化的特征提取能力简单的“卷积—池化—全连接”堆两层已经不够用了。第三是数据量带来的过拟合压力。50000 张训练图对一个百万参数级的网络来说其实很少。我实测过不加任何增强、不加正则一个带 BN 的六层卷积网络在第 15 个 epoch 左右训练损失就掉到 0.1 以下而验证准确率卡在 78% 不动——这就是典型的记住训练集。所以数据增强在这里不是“可选优化”是必需品。第四是训练成本开始变得真实。MNIST 一个 epoch 在 CPU 上十几秒CIFAR10 在 GPU 上一个 epoch 也要几秒到几十秒跑 100 个 epoch 就是半小时到一小时。这意味着你不可能像以前那样“改一行跑一次”必须开始做实验管理固定种子、规范命名、保存日志。这个习惯一旦养成后面做任何项目都受益。2.2 三条可选路线我为什么选中间那条在动手写第一行代码之前路线选择决定了你这一周能学到什么。我把常见的三条路列出来对比路线典型结构参数量级预期 Top-1单卡训练时长100 epoch学习收益A. 教学型小网络2×(ConvPool)FC约 60K65%~72%20 分钟以内熟悉流程但学不到正则和调度B. 自建 VGG 风格3 个 stage通道 64/128/256配 BN约 1.1M~1.5M88%~92%40~70 分钟完整覆盖 BN、增强、调度、正则C. 直接搬 ResNet18 预训练迁移学习11M96%20 分钟只训头部快但跳过了底层原理路线 C 看起来很香二十分钟就能出高分但它有个副作用你会误以为自己懂了。预训练权重把最难的那部分——如何从随机初始化开始让网络自己找到特征——替你做完了。第 P2 周这个阶段我强烈建议走 B。它刚好卡在“结构够复杂能学到东西”和“复杂到自己能看懂每一层”之间而且 90% 左右这个准确率区间是你调每一个超参都能看到反馈的甜区。具体到 B 的内部我选的形态是三个 stage每个 stage 是“两个 3×3 卷积 BN ReLU然后一次 2×2 最大池化”通道数依次 64、128、256最后用全局平均池化接一个线性层。这个设计不是随手写的往下看你就明白每一处对应解决什么问题。注意路线 B 的参数量别看只有 1.1M如果你把最后的全局平均池化换成Flatten Linear(256*4*4, 10)全连接层一层就是 40960 个参数而且极易过拟合。我第一版就是这么写的验证集比用 GAP 低了将近 2 个点。2.3 工程目录与实验约定让实验可复现一个人做项目最容易被忽略的就是“可复现”。我的习惯是每个实验一个目录命名带时间戳和关键超参这样一周后回头看不会认不出哪个是哪个cifar10_p2/ ├── data/ # 数据集根目录不放进版本管理 ├── src/ │ ├── dataset.py # 数据加载与 transforms │ ├── model.py # 网络定义 │ ├── engine.py # train_one_epoch / evaluate │ ├── utils.py # 种子、日志、checkpoint │ └── train.py # 主入口参数解析 ├── runs/ │ └── 20250114_bs128_lr0.1_sgd_cosine/ │ ├── config.json # 当次所有超参快照 │ ├── train.log # 每 epoch 一行 │ └── best.pth └── notebooks/ └── error_analysis.ipynb # 混淆矩阵与错例可视化三条硬性约定我建议你也照做一是每次启动都把完整超参 dump 成 json 存进 run 目录包括你以为不会变的东西比如 transforms二是每个 epoch 只往日志写一行格式固定为epoch, lr, train_loss, train_acc, val_loss, val_acc, elapsed后面画图直接pandas.read_csv就行三是 checkpoint 只留最好的和最后一个别每个 epoch 存一次一周下来硬盘会被你塞满。3. 数据管线从原始文件到喂进网络的 Tensor3.1 数据集的物理结构与加载方式CIFAR10 的原始包解压后是一个cifar-10-batches-py目录里面有data_batch_1到data_batch_5每个 10000 张训练图、test_batch10000 张测试图和batches.meta类别名与编码。这些文件是 Python pickle 序列化的字典键分别是bdata形状[10000, 3072]的 uint8每张图按 R 通道 1024 个像素、G 通道 1024 个、B 通道 1024 个的顺序排开和blabels。你会看到很多人手工解这个 pickle其实没必要torchvision.datasets.CIFAR10已经封装好了关键是理解它返回的是PIL Image而不是 Tensor这一点决定了你 transforms 的写法。from torchvision import datasets, transforms train_set datasets.CIFAR10(root./data, trainTrue, downloadTrue, transformtrain_tf) val_set datasets.CIFAR10(root./data, trainTrue, downloadTrue, transformval_tf)这里有个非常容易被忽略的坑为了切出验证集上面写了两遍trainTrue然后靠random_split分开但两次实例化必须用同一个torch.Generator且顺序固定否则训练集和验证集的划分会不一致。更稳妥的做法是先实例化一次不带 transform 的数据集切好索引再用Subset包一层import torch from torch.utils.data import Subset, random_split base datasets.CIFAR10(root./data, trainTrue, downloadTrue) g torch.Generator().manual_seed(42) train_idx, val_idx random_split(range(len(base)), [45000, 5000], generatorg)拿到索引后分别构造两个 transform 不同的 Dataset这样既保证划分一致又能让训练集和验证集用不同的预处理。5000 张验证集占训练集的 10%这个比例我觉得是合适的再少验证准确率的波动会大到 ±1.5%你根本判断不出改进是否有效再多训练数据损失又太可惜。3.2 transforms 的顺序为什么不能乱参数怎么定训练集的 transform 我是这么写的mean (0.4914, 0.4822, 0.4465) std (0.2023, 0.1994, 0.2010) train_tf transforms.Compose([ transforms.RandomCrop(32, padding4, padding_modereflect), transforms.RandomHorizontalFlip(p0.5), transforms.ToTensor(), transforms.Normalize(mean, std), transforms.RandomErasing(p0.25, scale(0.02, 0.15)), ])先解释mean和std这两个数组。它们是 CIFAR10 训练集 50000 张图在 [0,1] 尺度上逐通道统计出来的均值和标准差。为什么不直接用 0.5因为如果数据分布本身偏离 0.5用 0.5 归一化后得到的并不是零均值单位方差而是有系统性偏移的量。零均值的好处很直接梯度的方向更集中在参数更新的有效方向上配合 ReLU 时不会出现某一侧被大面积截断导致“死神经元”。这两个数我是按全训练集统计得到的你也可以自己算一遍验证import numpy as np imgs np.stack([np.array(base[i][0]) for i in range(len(base))]) / 255.0 print(imgs.mean(axis(0,1,2)), imgs.std(axis(0,1,2)))跑出来应该在 0.49/0.48/0.45 和 0.20 附近。再说顺序。ToTensor()必须在Normalize()之前因为Normalize的输入要求是 float Tensor而RandomCrop、HorizontalFlip这类几何变换必须在ToTensor()之前因为它们期望 PIL Image 输入。至于最后的RandomErasing它要求 Tensor 输入所以必须排在Normalize之后——但这里又有个细节如果你在归一化之后做擦除填进去的值 0 对应的其实是“均值像素”而不是黑色这个视觉上是有差别的我实测下来放在 Normalize 之后略好一点因为它擦掉的是“归一化空间里的中性区域”。RandomCrop(32, padding4)的含义是先四周各补 4 像素用 reflect 模式比默认的常数 0 填充更自然边缘不会出现突兀的黑边然后随机裁回 32×32。这相当于给网络看“轻微平移轻微缩放”的版本模拟拍摄时的位置抖动。padding 取 4 是经验值等于原图边长的 12.5%如果你取 8图里会有相当比例的纯填充区域反而引入噪声。水平翻转只对“左右对称成立”的类别安全CIFAR10 十类都是自然物体翻转后语义不变可以放心用但如果你以后做的是字符识别或者交通标志这一步就是灾难。3.3 DataLoader 的四个参数每个都有代价from torch.utils.data import DataLoader train_loader DataLoader(train_ds, batch_size128, shuffleTrue, num_workers4, pin_memoryTrue, drop_lastTrue, persistent_workersTrue) val_loader DataLoader(val_ds, batch_size256, shuffleFalse, num_workers4, pin_memoryTrue)batch_size128是我在这个规模下的默认选择。算一下迭代次数45000 / 128 351.5drop_lastTrue之后每 epoch 351 个 step。为什么建议drop_lastTrue因为网络里有 BN而 BN 在最后一个不满 128 的 batch这里只有 351×12844928剩下 72 张上统计出的均值和方差波动更大会让这一步的梯度方向偏一点。虽然影响不大但既然是免费的稳定性为什么要省。num_workers的选择取决于你的 CPU 核数和磁盘速度。经验公式是min(4, CPU核数//2)设太大反而会因为进程间通信开销变慢。我在这台 8 核机器上测过num_workers0时单 epoch 数据加载占了将近 40% 的时间4时降到 8% 左右8反而回升到 12%。persistent_workersTrue是在 epoch 之间保持 worker 进程存活省掉反复启动的开销配合num_workers0使用。pin_memoryTrue的作用是把 batch 数据放在锁页内存里这样 CPU 到 GPU 的传输可以用异步拷贝实测能省 5%~10% 的单步时间。但它只在有 GPU 时有意义纯 CPU 训练开着反而占内存。验证集的batch_size我开到 256 且shuffleFalse因为推理不吃 batch 内部相关性大一点更快。3.4 增强的边界什么时候它会反过来害你这里我要泼一盆冷水。很多人一看准确率不够就猛加增强结果越加越差。原因是增强本质上是在告诉网络“这些变换不改变标签”一旦你加的变换破坏了语义网络就被迫去学一个矛盾的映射。我做过一组对照同样跑 60 epoch其他全一样增强配置验证准确率训练/验证损失差无增强78.4%0.62Crop(4)Flip88.1%0.21Crop(8)Flip86.7%0.18Crop(4)FlipRotation(15°)87.2%0.19Crop(4)FlipColorJitter(0.4)85.9%0.15可以看到Crop 从 4 涨到 8 反而掉了 1.4 个点加旋转在 CIFAR10 上收益很小因为水平翻转已经覆盖了大部分对称性而旋转会引入大量无效的插值伪影32×32 分辨率经不起转颜色抖动在这个数据集上是负收益因为颜色本身是重要线索——青蛙是绿的、船和水相关、天空是蓝的你把颜色搅乱了等于主动删信息。RandomErasing(p0.25, scale(0.02, 0.15))我留着了因为它和上面几个不一样它不改变全局几何只是在局部挖一个块迫使网络不要依赖单一判别区域。但 p 不能太高0.5 就过火了我在 p0.5 时验证准确率掉了将近 2 个点。4. 网络结构、损失与训练循环的落地实现4.1 一个够用又不平庸的 CNN 是怎么长出来的先上代码再逐块解释import torch.nn as nn def conv_bn_relu(cin, cout): return nn.Sequential( nn.Conv2d(cin, cout, 3, padding1, biasFalse), nn.BatchNorm2d(cout), nn.ReLU(inplaceTrue), ) class CifarNet(nn.Module): def __init__(self, num_classes10): super().__init__() self.stem nn.Sequential( conv_bn_relu(3, 64), conv_bn_relu(64, 64), nn.MaxPool2d(2), # 32 - 16 ) self.stage2 nn.Sequential( conv_bn_relu(64, 128), conv_bn_relu(128, 128), nn.MaxPool2d(2), # 16 - 8 ) self.stage3 nn.Sequential( conv_bn_relu(128, 256), conv_bn_relu(256, 256), nn.MaxPool2d(2), # 8 - 4 ) self.head nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Dropout(0.2), nn.Linear(256, num_classes), ) def forward(self, x): return self.head(self.stage3(self.stage2(self.stem(x))))三个设计点值得单独说。卷积全部biasFalse。因为紧跟着就是 BNBN 本身有一个可学习的beta参数它承担的就是偏置的角色。卷积再加 bias 是纯冗余多几十个参数是小事关键是它会让 BN 的更新和 bias 的更新互相干扰。这个细节在很多教程里被忽略但它是“看起来一样的结构收敛速度差一截”的原因之一。通道数按 64-128-256 翻倍特征图边长按 32-16-8-4 减半。这是 CNN 设计的经典守恒每次下采样空间信息减半就用通道数翻倍来补偿表达能力。如果你从 32 直接跳到 256中间的特征维度会断崖后面的层学不到平滑过渡的特征层级。用AdaptiveAvgPool2d(1)而不是 Flatten。前者把每个通道的 4×4 平均成一个数得到 256 维向量后者是 256×4×44096 维。除了参数量的差别GAP 还有一个隐含的正则效果它对特征图做平均等于强制“每个通道的响应强度”而不是“每个位置的响应强度”进入分类器天然对空间位置不敏感泛化更好。这条路是当年一批研究者反复验证过的我在 CIFAR10 上实测 GAP 比 Flatten 稳定高 1.5~2 个点。参数量算一下三个 stage 的卷积层分别是 3×3×3×64 3×3×64×64 3×3×64×128 3×3×128×128 3×3×128×256 3×3×256×256 17283686473728147456294912589824 ≈ 1.14M加上 BN 和最后的分类头总共约 1.2M。这个规模在一张消费级显卡上显存占用不到 1GB非常友好。提示nn.Dropout(0.2)放在 GAP 之后。有人习惯在每个 stage 之间都加 Dropout那是全连接时代的做法。卷积层之间用 Dropout 会打乱空间相关性效果通常不如 BN数据增强我试过在 stage 之间加 p0.1 的 Dropout验证准确率反而降了 0.6 个点。4.2 损失、优化器与正则每个参数都在解决一个具体问题import torch criterion nn.CrossEntropyLoss(label_smoothing0.1) optimizer torch.optim.SGD(model.parameters(), lr0.1, momentum0.9, weight_decay5e-4, nesterovTrue)label_smoothing0.1是把 one-hot 标签[0,0,1,0,...]软化成一[0.011,0.011,0.911,0.011,...]。为什么这么做因为 CIFAR10 里确实存在标注模糊的图——比如一张很模糊的猫模型给出 0.6 的猫概率、0.3 的狗概率本来是个合理的判断但硬标签会逼着它把猫的概率往 1 推等于在鼓励过度自信。软化之后模型不需要把 logit 拉到极端泛化通常能好 0.5~1.5 个点。0.1 这个值是常用的起点我试过 0.05 和 0.20.1 在这个模型上表现最稳。优化器我坚持用 SGD momentum而不是 Adam。原因有两个层面一是这个任务上SGD 找到的解在测试集上的泛化普遍更好学界有个被反复讨论的现象是自适应方法容易收敛到“尖锐”的极小值对分布偏移更敏感二是 SGD 能配合较大的初始学习率0.1做粗粒度探索而 Adam 的默认 lr1e-3 在这个任务上需要额外调且和 weight decay 的耦合关系复杂Adam 的 L2 和真正的 weight decay 不是一回事。nesterovTrue是在 momentum 基础上再往前看一步收敛曲线更平滑基本是免费的提升。weight_decay5e-4是 L2 正则项系数它对 1.2M 参数的模型来说大致相当于给每个参数施加一个往零拉的力。这个值我调过1e-4 时过拟合明显训练验证损失差 0.31e-3 时欠拟合验证准确率掉 2 个点且训练损失降不下去5e-4 是甜点。4.3 学习率调度warmup 不是玄学大学习率配 BN 有个隐患训练最开始BN 的 running_mean 和 running_var 还是初始值0 和 1而实际数据的统计量完全不同这时候如果 lr 直接是 0.1第一批梯度会非常大可能把参数推到一个很难恢复的区域。表现出来就是 loss 前几十步剧烈震荡甚至变 NaN。解决办法就是 warmup前若干个 step 让学习率从很小线性升到目标值给 BN 一点时间把统计量校准。import math def build_scheduler(optimizer, warmup_epochs, total_epochs, steps_per_epoch): warmup_steps warmup_epochs * steps_per_epoch total_steps total_epochs * steps_per_epoch def lr_lambda(step): if step warmup_steps: return (step 1) / warmup_steps progress (step - warmup_steps) / max(1, total_steps - warmup_steps) return 0.5 * (1 math.cos(math.pi * progress)) return torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)warmup 我设 3 个 epoch351×3 ≈ 1053 步足够。后面接余弦退火到 0好处是后期学习率自然变小模型能在极小值附近精细收敛不需要你手工判断“什么时候该降 lr”。这里有个必须注意的调用顺序陷阱scheduler.step()每个step调一次因为上面是按 step 计的 LambdaLR而不是每个 epoch 调一次。如果你写成 epoch 级余弦曲线会被压缩成 60 个点形状完全不对效果还不如不调度。我第一版就踩了这个坑loss 曲线在后期明显有个台阶。4.4 训练循环骨架两个函数装下全部逻辑def train_one_epoch(model, loader, criterion, optimizer, scheduler, device): model.train() loss_sum, correct, total 0.0, 0, 0 for imgs, labels in loader: imgs, labels imgs.to(device, non_blockingTrue), labels.to(device, non_blockingTrue) optimizer.zero_grad(set_to_noneTrue) out model(imgs) loss criterion(out, labels) loss.backward() optimizer.step() scheduler.step() loss_sum loss.item() * imgs.size(0) correct (out.argmax(1) labels).sum().item() total imgs.size(0) return loss_sum / total, correct / total torch.no_grad() def evaluate(model, loader, criterion, device): model.eval() loss_sum, correct, total 0.0, 0, 0 for imgs, labels in loader: imgs, labels imgs.to(device, non_blockingTrue), labels.to(device, non_blockingTrue) out model(imgs) loss_sum criterion(out, labels).item() * imgs.size(0) correct (out.argmax(1) labels).sum().item() total imgs.size(0) return loss_sum / total, correct / total几个细节optimizer.zero_grad(set_to_noneTrue)比默认的zero_grad()略快因为它把梯度置为 None 而不是填 0PyTorch 在 backward 时会直接分配新张量省了一次写内存。non_blockingTrue要配合前面 DataLoader 的pin_memoryTrue才有效。损失累加用loss.item() * imgs.size(0)再除以总数而不是简单对 batch 损失求平均——因为最后一个 batch 大小可能不同简单平均会有偏差。model.eval()和torch.no_grad()两个都要。前者切换 BN 用 running 统计量、关闭 Dropout后者关掉计算图。只写其中一个都会出问题忘了 eval验证准确率会随 batch 大小剧烈波动忘了 no_grad显存会随验证集大小线性增长10000 张图跑下来很容易 OOM。5. 完整实操从零跑通并读懂结果5.1 环境准备与种子复现性的地基import os, random, numpy as np, torch def seed_everything(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) os.environ[PYTHONHASHSEED] str(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark Falsecudnn.deterministicTrue让 cuDNN 只选确定性的算法代价是可能慢 10%~20%benchmarkFalse关掉自动算法搜索这个搜索本身在输入尺寸固定时是有益的但它会引入非确定性。如果你只是在做实验、不在乎那点速度两个都按上面设。如果你已经确定结构要跑很久可以benchmarkTrue换速度但要知道结果不能逐位复现。我先用 CPU 跑一遍batch_size8的前向反向确认 shape 链路没问题再上 GPU。这一步花不了两分钟但能省掉后面“跑了十分钟才发现维度错”的浪费。5.2 单 epoch 实测耗时与总时长估算在单张消费级显卡上batch_size128、num_workers4我实测的结果是训练一个 epoch 约 11~13 秒验证约 1.5 秒。跑 60 个 epoch 总计约 13 分钟。如果你在纯 CPU 上跑大概要慢 15~25 倍那就是四五个小时建议至少把 epoch 降到 20 做个 sanity check别一上来就挂满。这个量级意味着你一天能跑二三十组对比实验完全够做一轮系统的消融。我的建议是把实验分成“快验证”和“全量跑”两档调结构时用 20 epoch 快速看趋势确定方向后再用 100 epoch 跑终版。20 epoch 和 100 epoch 的排序在 90% 的配置下是一致的这个经验能帮你省下大量时间。5.3 评估不能只看 accuracy混淆矩阵告诉你该改什么验证准确率是个标量它不告诉你模型错在哪。跑完训练后我必做的一件事是画混淆矩阵顺便看每类的 recallfrom sklearn.metrics import confusion_matrix, classification_report model.eval() preds, gts [], [] with torch.no_grad(): for imgs, labels in val_loader: out model(imgs.to(device)) preds.append(out.argmax(1).cpu()) gts.append(labels) preds torch.cat(preds).numpy() gts torch.cat(gts).numpy() print(classification_report(gts, preds, target_namesclasses, digits3)) cm confusion_matrix(gts, preds)我那次 89.3% 的模型分类报告里最刺眼的两行是cat 的 recall 只有 0.79dog 的 recall 0.81而这两个类之间互相误判占了全部错误的将近三成。再看 deer 和 horse 也有一对明显的混淆。这说明什么说明模型在“四足动物”这个大类的细粒度区分上能力不足换句话说它学到了“有毛、有四条腿”但没学到“耳朵形状、口鼻比例”这种判别性细节。对策就很有针对性了不是盲目加深网络而是提高有效分辨率。两个做法一是把输入上采样到 64×64 再进网络成本翻四倍但细粒度提升明显二是用更大的卷积核或者引入多尺度特征融合。我在这个基础上试了第二种在 stage3 后面接一个 3×3 空洞卷积扩大感受野cat/dog 的 recall 各涨了 2 个点左右整体从 89.3% 到 90.6%。这类“先看错误分布再决定改什么”的习惯比漫无目的地试结构有效率得多。混淆矩阵就是你的体检报告。5.4 准确率分段对照你在哪个区间问题可能是什么我把这几周跑过的十几组结果整理成一张对照表你可以拿自己的数字对号入座验证准确率区间典型症状最可能的原因优先动作 60%训练损失都降不下来管线有 bug归一化漏了、标签错位、lr 太大用 100 张图做 overfit test先让 loss 能到 060%~75%训练损失降但验证不动模型容量不足或没有增强加深通道、加 CropFlip75%~85%训练 0.1 验证 0.5差距大过拟合加 weight_decay、加 Dropout、加增强85%~90%曲线正常收敛这是不加特殊技巧的合理区间调 lr 调度、试 GAP、加 label smoothing90%~93%需要更细致调优结构或训练策略有优化空间换更强结构、加 EMA、训练更久93%~95%需要较长训练 精细正则常规手段基本用尽100 epoch、随机深度、mixup/cutmix 95%通常需要集成单模型接近上限TTA、多模型投票、知识蒸馏那个“overfit test”我想多说一句它是我排查问题的第一把刀拿 100 张图、关掉所有增强和正则训练 200 步如果损失降不到接近 0、准确率到不了 100%那就一定是管线有问题别急着调模型。我遇到过两次一次是忘了Normalize一次是random_split两次用了不同种子导致训练集里混进了验证图。这种问题你用正常训练跑一天都发现不了。6. 常见问题与排查技巧实录6.1 损失不降或者剧烈震荡按这个顺序查我把这套顺序贴在显示器边上每次出问题从头过一遍基本五分钟内能定位先看数据。随机取一个 batch 打印imgs.min(), imgs.max(), imgs.mean(), imgs.std()。归一化之后应该在 ±2.5 左右、均值接近 0。如果 max 是 255说明ToTensor漏了如果均值是 0.5说明Normalize漏了或者参数写错。再看标签。打印labels[:20]和对应图片确认没有错位。CIFAR10 的data和labels是分开存的如果你自己解 pickle按通道拼接时很容易把维度搞错导致图和标签对不上——这种模型照样能训但准确率永远上不去。然后做 overfit test。见上一节。这一步能排除 80% 的“模型写错了”。检查学习率。把 lr 除以 10 再跑 200 步如果 loss 开始稳定下降那就是 lr 太大。反过来如果 loss 几乎不动乘 3 试试。检查 BN。如果 loss 出现 NaN八成是 BN 在某个 batch 上方差接近 0。确认batch_size不要太离谱小于 8 时要小心确认输入没有常量通道。最后才怀疑结构。结构出问题的概率远低于前五条但很多人第一反应就是改网络。注意我见过最隐蔽的一个 bug 是scheduler.step()写在了optimizer.step()之前导致每次更新用的都是被衰减过的 lr而且衰减速度是预期的 351 倍。表现出来就是 loss 前 100 步降得挺好然后突然停滞。这种问题只能靠打印 lr 发现所以我建议每 50 个 step 打一次当前 lr。6.2 显存不够、训练太慢分别怎么处理显存不够时优先顺序是先降 batch_size128→64显存差不多减半但要注意 BN 在小 batch 上统计量会变差通常 64 还是安全的32 以下就要考虑换 GroupNorm 了再开混合精度torch.cuda.amp显存省 30%~40%速度还能快 20% 左右最后才考虑梯度累积——梯度累积能用小显存模拟大 batch但它对 BN 没用因为 BN 看的是单个 microbatch 的大小这一点很多人踩过。训练太慢的话先用torch.profiler或者简单地在 dataloader 迭代和 forward 之间打时间戳判断瓶颈在数据还是算力。我遇到过一次num_workers4但每张图都做了一次RandomRotationCPU 直接成为瓶颈GPU 利用率只有 30%。把旋转去掉之后 GPU 利用率到 90% 以上速度翻了两倍多。这也印证了前面说的增强不是越多越好它是要付 CPU 成本的。6.3 过拟合还是欠拟合一张表判断现象判断处理训练损失高、验证损失也高且接近欠拟合加容量、减正则、训更久、调大 lr训练损失低、验证损失高差距 0.3过拟合加增强、加 weight_decay、加 Dropout、早停训练损失持续降、验证损失先降后升典型过拟合拐点在拐点处保存模型用早停训练和验证都在降但很慢学习率偏小或调度不合适调大初始 lr检查 scheduler 调用频率验证准确率波动 ±2% 以上验证集太小或 batch 内顺序有偏增大验证集或验证时用固定顺序多跑几次平均最后一行值得展开。如果你的验证集只有 1000 张那每个样本就是 0.1% 的准确率波动两个点就是 20 张图的差异很容易被个别难样本带偏你会误以为模型退步了。这也是我坚持用 5000 张做验证的原因。7. 把准确率再往上推的几个实用手段7.1 测试时增强与权重平均几乎零成本的提升训练完之后如果还想再挤一两个点我一般先做这两件事因为它们不需要重新训练。TTA测试时增强的思路是推理时把同一张图做几种不改变语义的变换原图、水平翻转分别送进网络把 softmax 概率平均。对 CIFAR10 这种左右对称的类别原图翻转两路的 TTA 通常能稳定涨 0.3~0.8 个点。代码很短torch.no_grad() def predict_tta(model, imgs): p1 model(imgs).softmax(1) p2 model(torch.flip(imgs, dims[3])).softmax(1) return (p1 p2) / 2权重平均有两个层次。简单的是 checkpoint averaging把最后 5 个 epoch 的权重取算术平均因为余弦退火末期模型在极小值附近游走平均能起到平滑作用。更正规的是 EMA指数移动平均训练过程中维护一份影子权重ema decay * ema (1-decay) * paramdecay取 0.999 左右。用 EMA 权重做推理通常比最后一轮的权重高 0.5~1 个点而且曲线更稳。代价是显存多存一份参数1.2M 的模型完全无所谓。7.2 混合精度与梯度累积的正确组合torch.cuda.amp用起来很简单但有两个坑。第一loss.backward()必须在scaler.scale(loss)上调用optimizer.step()之前要scaler.unscale_(optimizer)再clip_grad_norm_顺序错了梯度裁剪就不生效。第二BN 层在 autocast 下仍然用 fp32 计算这是 PyTorch 帮你处理好的但如果你自己写了自定义的归一化要手动with torch.cuda.amp.autocast(enabledFalse)包起来。梯度累积则是把batch_size128拆成 4 次 32 的 forward/backward 再统一 step数学上等价于 128 的 batch但 BN 不等价——这一点必须清楚。如果你的目的是“省显存同时保持大 batch 的优化效果”梯度累积可以如果你的目的是“保持 BN 的大 batch 统计”那只能老实加显存或者用 SyncBN 多卡。7.3 一个我常用来收尾的小技巧训练到最后我习惯把学习率退到 0 之后再多跑 3~5 个 epoch把 lr 固定在 1e-4 这个很小的值上。这一步叫“fine-tune tail”作用是让模型在已经找到的极小值附近再走几步把那些还在震荡的参数稳定下来。实测能涨 0.2~0.4 个点成本几乎为零。我在这个项目上反复体会到的一件事是CIFAR10 的准确率从来不是靠某一个“大杀器”提上去的而是靠十来个各值半个点的细节叠出来的——biasFalse 半个点、GAP 两个点、label smoothing 一个点、warmup 半个点、余弦退火一个点、EMA 一个点。这些细节单看都不起眼但它们是可解释、可迁移的换成别的数据集、别的任务这套判断逻辑照样能用。反倒是那种“换个更强的 backbone 直接涨三个点”的做法换个场景就抓瞎了。另外一个真实感受是别把时间花在反复跑全量实验上。我前两周最大的浪费就是改一行跑一次 100 epoch一天只能试三组。后来改成“20 epoch 快速筛选 只对前 3 名跑全量”同样的时间能试二十组。判断趋势不需要跑完曲线前 20 个 epoch 的形状已经能说明 90% 的问题了。