资讯详情 PyTorch CIFAR10彩色图片识别:从数据管线到精度调优实战
📅 2026/10/26 2:50:31
上周有位朋友把训练脚本发过来说跑了一整天验证准确率卡在58%上下反复横跳问我是不是数据集坏了。我把日志翻了一遍问题不在数据在他把训练集的RandomHorizontalFlip和Normalize顺序写反了又把测试集的均值方差自己重新算了一遍。这个现象在刚接触CIFAR10 彩色图片识别这个任务时特别典型——看起来只是一个跑通就行的入门数据集实际上从数据管线到网络结构、从训练循环到精度调优每一步都有坑而且这些坑在 MNIST 上根本不会暴露。这篇内容围绕PyTorch下CIFAR10 彩色图片识别的完整实战链路展开把从数据加载、网络搭建、训练循环、精度排查到进阶优化的全流程拆开讲清楚。适合已经能用 PyTorch 跑通简单全连接网络、准备进入卷积神经网络实战阶段的读者也适合那些代码能跑但准确率上不去、想搞清楚背后原因的人。我会尽量把每个设计的动机和参数选择的依据讲明白而不是甩一段代码让你自己悟。1. CIFAR10 作为彩色图像分类入门任务的真实难度很多人以为这只是一个五万张图、十个类别的小任务随便搭个网络就能到80%。实际动手之后会发现60%到70%这一段是最难受的区间因为该犯的错都犯完了剩下的提升全靠对细节的理解。1.1 三通道输入带来的第一层变化从 MNIST 迁移过来的人最先撞上的就是输入张量的形状差异。MNIST 是[N, 1, 28, 28]CIFAR10 是[N, 3, 32, 32]。这不只是多了一个数字而是意味着第一层卷积的输入通道数从1变成了3卷积核参数量直接翻三倍。一个3×3的卷积核在 MNIST 上是3×3×19个参数在 CIFAR10 上是3×3×327个参数。如果你的第一层输出通道设成64那这一层的参数量就是从576跳到1728。更关键的是RGB 三通道意味着颜色本身成为了判别特征。在 MNIST 上所有的信息都压在灰度强度里而在 CIFAR10 上一张船的图往往是蓝色背景占大头鹿和马的区别有时真的只在棕色深浅上。这带来一个反直觉的结论对 CIFAR10 做一些颜色相关的增广比如大幅度的ColorJitter反而可能伤害准确率因为你在破坏模型赖以判别的关键信号。我做过一个对照实验用同一个网络结构一组保留三通道一组在预处理阶段转成灰度再复制成三通道结果灰度版本的准确率掉了将近4个百分点。这个差距说明颜色信息在这个数据集上是有实打实贡献的不要想当然地觉得灰度化能降维提速就动手改。1.2 32×32 的分辨率决定了网络下采样的上限第二个容易被忽略的约束是分辨率。32×32 意味着整张图只有1024个像素点。如果你的网络像在 ImageNet 上那样堆五六次MaxPool2d(2)特征图尺寸会依次变成 16、8、4、2、1到第五次池化之后空间维度只剩1×1后面再谈感受野和局部特征就没有意义了。我给自己的经验法则是CIFAR10 上的总下采样倍数控制在8到16倍之间。也就是最终特征图保持在 4×4 到 2×2 这个区间比较合适。超过这个范围空间信息被压得太狠小目标的类别比如鸟和猫这种体态相近的几乎无法区分。下面这张表是我常用来做结构规划的一个对照列出不同池化次数下的特征图尺寸和对应的典型做法池化次数特征图尺寸适用网络风格备注3次4×4VGG风格小CNN最常用平衡点4次2×2更深的网络需要配合BN5次1×1不推荐空间信息损失过大1.3 十个类别之间的天然混淆关系CIFAR10 的十个类别是飞机、汽车、鸟、猫、鹿、狗、蛙、马、船、卡车每类训练集5000张、测试集1000张分布完全均衡。这个均衡性其实降低了难度你不需要考虑类别不平衡的采样策略。真正的难点在于类间相似度。猫和狗在32×32的分辨率下很多时候连人眼都要犹豫汽车和卡车因为视角和形状接近混淆率也高鹿和马在体型上高度相似。而飞机和船几乎没有混淆可能因为背景差异太大了。理解这一点很重要因为它直接影响你怎么看准确率数字。如果模型总准确率是72%但飞机和船各自都到了90%猫和狗只有50%那问题出在特征表达不足而不是训练不充分。后面第5节我会讲怎么用混淆矩阵把这层信息挖出来。2. 数据管线搭建那些看起来没问题的细节数据管线是整个流程里最容易埋雷的地方因为它的错误不会报异常只会让准确率莫名其妙地低几个百分点。2.1 归一化用的均值方差到底从哪来Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010))这组数字几乎成了 CIFAR10 的标配但我发现不少人对它的来源是不清楚的只知道抄。这组值是从训练集五万张图上对每个通道的所有像素做统计得到的均值和标准差。为什么用它ToTensor()会把像素值从[0, 255]的整数映射到[0, 1]的浮点数这个分布是严重右偏的。减去均值除以标准差之后输入分布被拉成近似标准正态各层输入的数值范围稳定反向传播时梯度不会因为某层输入整体偏大而爆掉。这是训练稳定性的基础。如果你想自己验证这组数可以这样算import torch from torchvision import datasets, transforms train_set datasets.CIFAR10(root./data, trainTrue, downloadTrue) # 把数据堆成一个 [50000, 3, 32, 32] 的张量 data torch.stack([transforms.ToTensor()(img) for img, _ in train_set]) mean data.mean(dim[0, 2, 3]) std data.std(dim[0, 2, 3]) print(mean) # tensor([0.4914, 0.4822, 0.4465]) print(std) # tensor([0.2470, 0.2435, 0.2616])跑一下你会发现标准差和标配那组对不上。标配用的0.2023, 0.1994, 0.2010是按通道独立计算后再做某种加权的版本社区里沿用下来。这两种都能用差异在0.5个百分点以内。重点是统计量必须来自训练集绝对不能碰测试集。用测试集的统计量做归一化等于提前把测试集的信息泄露给模型你的验证准确率会虚高上线之后必然掉。2.2 transform 顺序写反会直接毁掉训练这是我朋友那个案例的具体成因。他的写法是# 错误写法 transform_train transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean, std), transforms.RandomHorizontalFlip(p0.5), # 顺序错了 ])RandomHorizontalFlip作用在 Tensor 上其实也能跑不会报错但这样做会让翻转发生在归一化之后翻转对数值分布没有影响看起来无害。真正的问题出在更隐蔽的地方——如果换成RandomCrop它带padding填充填充值默认是0而0在归一化之后的语义是均值像素这个填充区域的颜色就完全不对了。正确的顺序应该是把所有基于 PIL Image 的几何变换放在最前面然后才是ToTensor和Normalizemean (0.4914, 0.4822, 0.4465) std (0.2023, 0.1994, 0.2010) transform_train transforms.Compose([ transforms.RandomCrop(32, padding4), # 几何增广在前 transforms.RandomHorizontalFlip(p0.5), transforms.ToTensor(), # 再转张量 transforms.Normalize(mean, std), # 最后归一化 ]) transform_test transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean, std), ])注意RandomCrop(32, padding4)里 padding 用4而不是2是因为4像素的填充在32×32的尺度上大约相当于12%的边界扩展这个比例既能提供足够的平移不变性又不会让主体内容被裁掉太多。超过8就会出现明显的主体丢失。2.3 DataLoader 的几个参数在什么场景下要改DataLoader的默认配置能跑但效率未必好。下面这几项值得关注num_workers数据加载的子进程数。CPU核数少的时候设2到4就够设太大反而因为进程调度开销变慢。在 Windows 上如果遇到卡死或者BrokenPipeError直接设成0。pin_memoryTrue把数据固定在页锁内存里加速 CPU 到 GPU 的拷贝。用 GPU 训练时开着基本没有副作用。drop_lastTrue丢弃最后一个不完整的 batch。当你的网络里有 BN 层、最后一个 batch 只有几个样本时BN 的统计量会非常不准容易造成训练波动。训练集开着验证集不用。persistent_workersTrue配合num_workers0避免每个 epoch 重新创建进程。还有一个实操细节验证集千万不要用shuffleTrue。虽然理论上不影响准确率计算但会让你的错误分析变得麻烦——你无法把第 i 个预测结果和固定的图片对上号做可视化的时候要找半天。3. 手写一个小型 CNN每一层的尺寸推导网络结构这块我的建议是第二周先别碰 ResNet老老实实手搓一个 VGG 风格的 5 到 7 层网络。原因在于你需要亲手推导一遍每一层的输入输出尺寸这个推导过程建立起来的手感比直接抄一个现成模型有用得多。3.1 为什么不建议一上来就用预训练大模型用resnet18(pretrainedTrue)微调在 CIFAR10 上很容易做到94%以上看起来很美。但它掩盖了一个问题你并不知道准确率是怎么来的。一旦换个数据集、换个输入尺寸或者需要从头训练比如数据分布和 ImageNet 差很远你就完全没有调参的锚点了。而且 CIFAR10 的 32×32 和 ImageNet 的 224×224 差距太大。ResNet 的第一层是 7×7 卷积 stride2 加 3×3 池化直接用在32×32的图上两次下采样之后特征图只剩8×8等于把原始图像的四分之三信息在入口处就扔掉了。这也是为什么在 CIFAR10 上专门设计的 3×3 小卷积核网络往往比改装的 ImageNet 模型效果更好。我自己的练习路径是先手写一个约50万参数的小 CNN 跑到 75% 左右理解清楚每个模块的作用再去看 ResNet 的设计这时候你就明白那些 shortcut 和 bottleneck 是在解决什么问题了。3.2 卷积核尺寸与尺寸变化的推导过程尺寸推导的公式很简单但要随手算对需要练。卷积输出尺寸是out floor((in 2*padding - kernel_size) / stride) 1记住两个最常用的特例就够了kernel3, padding1, stride1保持尺寸不变kernel3, padding0, stride1尺寸减2。池化kernel2, stride2尺寸减半。下面是我常用的一个结构把每层的输出尺寸都标出来方便对照层配置输出尺寸输出通道输入-3×32×323Block12×(Conv3×3BNReLU) MaxPool232×16×1664Block22×(Conv3×3BNReLU) MaxPool264×8×8128Block32×(Conv3×3BNReLU) MaxPool2128×4×4256GAP 或 Flatten-256 或 4096-FCLinear(→256) ReLU Dropout256-FCLinear(256→10)10-这个结构最终下采样8倍特征图落到4×4正好在我前面说的合理区间里。整体参数量大概在150万左右训练起来很快单卡跑50个 epoch 也就十几分钟。对应的代码import torch.nn as nn class SmallCNN(nn.Module): def __init__(self, num_classes10): super().__init__() def conv_block(in_ch, out_ch, num_conv): layers [] for i in range(num_conv): layers [ nn.Conv2d(in_ch if i 0 else out_ch, out_ch, kernel_size3, padding1, biasFalse), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), ] layers.append(nn.MaxPool2d(2)) return nn.Sequential(*layers) self.features nn.Sequential( conv_block(3, 64, 2), # → 64×16×16 conv_block(64, 128, 2), # → 128×8×8 conv_block(128, 256, 2), # → 256×4×4 ) self.classifier nn.Sequential( nn.AdaptiveAvgPool2d(1), # → 256×1×1 nn.Flatten(), nn.Linear(256, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(256, num_classes), ) def forward(self, x): return self.classifier(self.features(x))这里我用AdaptiveAvgPool2d(1)代替了Flatten。这个替换带来的好处是输入尺寸变化时不用改全连接层的维度而且参数量从256×4×4×256104万直接降到256×2566.5万模型轻了非常多。代价是丢掉了一部分空间位置信息在这个任务上实测影响很小。3.3 BatchNorm 和 Dropout 放在哪一层才有用BN 的位置有个反复被讨论的问题放在 ReLU 前面还是后面。标准做法是Conv → BN → ReLU这也是原论文的写法。理由是 BN 在激活之前把分布拉正ReLU 的输入落在零附近的合理区间。我试过Conv → ReLU → BN的顺序在 CIFAR10 上差距不到0.3个点但收敛速度会略慢一点。所以不用纠结按标准写法来。另一个要注意的是Conv2d里的bias。既然后面接了 BNBN 本身有可学习的beta参数负责平移卷积的 bias 就是冗余的而且会在统计时被减掉。设成biasFalse能省一点参数也避免了不必要的数值计算。Dropout 我只放在全连接层之前p0.5。卷积层后面不加 Dropout因为在 32×32 这种小图上卷积层的特征本身就比较紧凑Dropout 会把相邻位置的信息割裂开效果不好。如果一定要加正则化用Dropout2d按通道丢弃比普通 Dropout 更合适。还有一个坑BN 和 Dropout 同时大量使用时模型的训练和推理行为差异会变复杂。训练时 Dropout 在随机丢弃神经元BN 用的是当前 batch 的统计量推理时都不做这些操作。如果发现验证准确率比训练准确率低很多比如差8个点以上先检查是不是 Dropout 设太大了而不是急于上更多的正则化。4. 训练循环里那些必须显式写对的地方训练循环的骨架几乎人人都能默写出来但真正决定成败的是几个默认你该知道的细节。4.1 train 与 eval 模式切换不是可选项model.train() for images, labels in train_loader: ... model.eval() with torch.no_grad(): for images, labels in val_loader: ...model.train()和model.eval()会让 BN 和 Dropout 表现完全不同。BN 在训练模式下用当前 batch 的均值和方差同时更新全局的滑动统计量在 eval 模式下用全局统计量。如果你在验证阶段忘了写model.eval()会发生两件坏事一是验证结果会随 batch 组成大幅波动因为每个 batch 用自己算的统计量二是你的全局 BN 统计量会被验证数据污染导致后面重新训练时行为异常。torch.no_grad()也不是可省的。不加的话验证阶段会构建计算图显存占用变成训练时的1.5倍以上而且速度慢一大截。有些人在验证时忘了这一句跑几百个 epoch 之后显存爆掉还以为是模型太大了。4.2 损失函数与优化器的组合选择nn.CrossEntropyLoss()内部已经包含了 softmax 和 NLL所以网络最后一层不要再加 softmax。这个错误在初学者代码里出现频率极高表现为 loss 一直在0.5以上降不下去而准确率莫名其妙地看起来还行。优化器方面有两套常见组合优化器学习率其他参数特点SGD0.1momentum0.9, weight_decay5e-4收敛慢但泛化好经典基线Adam1e-3weight_decay1e-4收敛快前期好调后期可能不如SGDAdamW3e-4weight_decay0.05权重衰减和自适应学习率解耦目前更推荐如果你是第一次跑用 Adamlr1e-3最容易看到效果大概20个 epoch 就能到70%左右。想冲更高的分数切到 SGD cosine 衰减通常能再涨3到5个点但需要跑满100个 epoch 以上才看得出来。weight_decay的取值有个容易被忽略的点它作用在所有可学习参数上包括 BN 的gamma和beta。但 BN 的这两个参数本来就不该被强正则化把它们排除掉通常能带来小幅提升decay, no_decay [], [] for name, p in model.named_parameters(): if not p.requires_grad: continue if name.endswith(.bias) or bn in name or downsample.1 in name: no_decay.append(p) else: decay.append(p) optimizer torch.optim.SGD([ {params: decay, weight_decay: 5e-4}, {params: no_decay, weight_decay: 0.0}, ], lr0.1, momentum0.9)这个写法在 CIFAR10 上实测能带来0.5到1个点成本很低值得加上。4.3 batch size 与实际显存占用的估算batch size 设 128 是个稳妥的起点。它和显存的关系不是线性的因为中间激活值、梯度、优化器状态都要算进去。粗略估算激活值占用可以用这个思路每层的输出特征图大小 × batch size × 4字节float32把所有层加起来再乘个2到3的系数因为反向传播需要保存中间变量。以我上面那个网络为例Block1 的输出是128×64×16×16大约200万个元素8MB。Block2 是128×128×8×8同样约800万元素32MB。Block3 是128×256×4×4约200万元素。总共加起来100MB上下乘个系数大约300MB。一块8GB的卡上batch size 设到512都不成问题。但如果你的显存紧张优先考虑torch.cuda.amp混合精度而不是一味减小 batch size。batch size 太小比如16以下会让 BN 的统计量非常不稳定训练曲线抖得厉害。混合精度能把显存占用砍掉将近一半速度还能提升20%到40%from torch.cuda.amp import autocast, GradScaler scaler GradScaler() for images, labels in train_loader: images, labels images.cuda(), labels.cuda() optimizer.zero_grad() with autocast(): outputs model(images) loss criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()注意optimizer.zero_grad()要放在autocast外面scaler.update()放在最后。顺序错了会静默失效loss 看起来正常但梯度没更新。4.4 从训练曲线读出的三类异常信号我习惯每个 epoch 记录 train loss、train acc、val loss、val acc 四个数画在同一张图上。这三种形态对应三种问题第一种train loss 持续下降val loss 先降后升。这是过拟合的典型形态通常在30到50个 epoch 之间出现。CIFAR10 五万张训练图小模型跑到严重过拟合需要时间如果第10个 epoch 就出现了说明模型容量太大或者正则化太弱。对策是加大weight_decay、提高 Dropout 比例或者上更强的数据增广。第二种两条 loss 曲线都降得很慢最后停在1.5以上。这是欠拟合通常是学习率太小或者网络太浅。先调学习率从1e-3往上试到1e-2看 loss 下降速度有没有明显变化。第三种val acc 在每个 epoch 之间大幅震荡比如 0.68、0.61、0.70、0.63。这是学习率偏大的信号模型在最优点附近来回跳。解决办法是加学习率衰减或者把 batch size 调大一点让梯度更平滑。还有一个小技巧在每个 epoch 结束时打印当前学习率。如果你的调度器写错了学习率一直不变光看 loss 曲线是很难发现的。5. 准确率卡在60%时的完整排查链路这是我处理过最多次的问题下面按照我实际的排查顺序写每一步都能缩小范围。5.1 先查数据再查模型准确率异常时我的第一反应永远是数据。具体查三件事第一图像是否被归一化了两次。如果你在transform_test和transform_train里都写了Normalize又在Dataset的__getitem__里写了一遍输入数值范围会变成[-1, 1]再压缩到[-0.5, 0.5]附近模型学到的特征完全错位。检查方法是打印一个 batch 的最大值和最小值正常应该在[-2.5, 2.5]这个量级。第二标签和图像是否错位。自己写Dataset时如果__getitem__返回的索引处理有问题很容易出现图和标签对不上。快速验证方法取一个 batch打印前四张图的标签然后把图反归一化保存出来肉眼看。十有八九能发现问题。第三训练集和验证集是否用了同一个 transform。我见过几次验证集也用了RandomCrop和RandomHorizontalFlip这种情况下验证准确率会明显偏低且不稳定因为每次评估看到的图都不一样。5.2 学习率与 loss 曲线的错配诊断数据没问题之后看前50个 iteration 的 loss。正常的训练loss 应该从2.3附近十个类别的随机猜测交叉熵ln(10)≈2.3开始前20步快速降到1.5以下。如果 loss 一直卡在2.3附近不动说明梯度几乎为零或者参数没更新。检查optimizer.zero_grad()和loss.backward()是否都在以及优化器传入的是不是model.parameters()而不是某个子模块的参数。如果 loss 前几步就变成nan基本是学习率太大。先降到原来的十分之一试。还有一种可能是输入数据里有nan或者异常大的值用torch.isnan(images).any()查一下。如果 loss 降得很慢比如50步之后还在2.0以上先确认学习率是不是真的生效了。有个隐蔽的写法问题# 错误这样写后面设置的 lr 会被覆盖 optimizer torch.optim.SGD(model.parameters(), lr0.1) optimizer torch.optim.SGD(model.parameters(), lr0.001) # 重新创建了或者更常见的先创建了调度器后面又改了optimizer.param_groups[0][lr]导致调度器的初始值不对。我现在的习惯是创建完优化器和调度器之后立刻打印一次当前学习率确认。5.3 用梯度范数快速定位训练停滞当 loss 能降但降得很慢我会加一行梯度范数的打印total_norm 0.0 for p in model.parameters(): if p.grad is not None: total_norm p.grad.data.norm(2).item() ** 2 total_norm total_norm ** 0.5 print(fgrad_norm: {total_norm:.4f})经验值参考健康的训练梯度范数通常在0.1到10这个区间。如果一直小于1e-3说明梯度消失了多半是网络太深没有残差连接或者激活函数用了 sigmoid。如果大于100说明梯度爆炸需要梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0)这个裁剪加在loss.backward()之后、optimizer.step()之前。max_norm设 5 到 10 都可以设太小会拖慢收敛。5.4 混淆矩阵告诉你模型究竟错在哪准确率是个标量看不出结构性问题。跑完训练之后我会在验证集上算一个 10×10 的混淆矩阵from sklearn.metrics import confusion_matrix import numpy as np model.eval() all_preds, all_labels [], [] with torch.no_grad(): for images, labels in val_loader: images images.cuda() outputs model(images) preds outputs.argmax(dim1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) cm confusion_matrix(all_labels, all_preds) classes [plane,car,bird,cat,deer,dog,frog,horse,ship,truck] for i in range(10): row cm[i] acc row[i] / row.sum() worst np.argsort(row)[::-1][1] # 除自己外最容易混淆的类 print(f{classes[i]:6s} acc{acc:.3f} 最常被误判为 {classes[worst]} ({row[worst]}次))跑出来之后如果看到cat 最常被误判为 dog180次说明模型对这两个类别的特征区分不开这时候加数据增广或者换更强的骨干网络比盲目增加 epoch 有效得多。反过来如果plane 被误判为 ship的次数也很多那就要怀疑是不是标签有问题或者数据加载顺序乱了。6. 从 75% 往上推的几套可落地手段到75%之后每一次提升都需要明确知道自己在做什么。6.1 数据增广的适用边界在 32×32 的小图上增广不是越多越好。我做过一组消融以75.2%为基线增广组合准确率结论仅 RandomCrop(32,4) Flip75.2%基线加 ColorJitter(0.2,0.2)74.1%有害颜色是判别特征加 RandomErasing(p0.25)77.6%有效模拟遮挡加 Cutout(16×16)78.3%更有效但需要更长训练Cutout Flip去掉Crop77.1%略低于三者组合结论很清楚几何和遮挡类增广有效颜色类增广有害。RandomErasing和Cutout本质是同一类东西都是在图上挖一块填成均值或者随机值强迫模型不能只依赖局部特征。这两个在同一个小图上同时用会过强选一个就行。Cutout的实现很简单不需要额外库class Cutout: def __init__(self, size16, p0.5): self.size size self.p p def __call__(self, img): if torch.rand(1).item() self.p: return img h, w img.shape[1], img.shape[2] y torch.randint(0, h, (1,)).item() x torch.randint(0, w, (1,)).item() y1, y2 max(0, y - self.size // 2), min(h, y self.size // 2) x1, x2 max(0, x - self.size // 2), min(w, x self.size // 2) img[:, y1:y2, x1:x2] 0.0 return img注意它作用在归一化之后的张量上填充值用0对应的是均值像素视觉上是灰色块比纯黑更合理。6.2 学习率调度与 warmup 的实际参数单靠固定学习率很难上到80%。我常用的配置是 SGD CosineAnnealing配5个 epoch 的 warmupfrom torch.optim.lr_scheduler import CosineAnnealingLR, LambdaLR import math base_lr 0.1 warmup_epochs 5 total_epochs 120 def lr_lambda(epoch): if epoch warmup_epochs: return (epoch 1) / warmup_epochs progress (epoch - warmup_epochs) / max(1, total_epochs - warmup_epochs) return 0.5 * (1 math.cos(math.pi * progress)) optimizer torch.optim.SGD(model.parameters(), lrbase_lr, momentum0.9, weight_decay5e-4) scheduler LambdaLR(optimizer, lr_lambda)warmup 的作用是让模型在初始阶段不要被大学习率带偏。CIFAR10 上如果直接lr0.1前几个 epoch 的 loss 会先冲高再回落白白浪费训练时间。加了 warmup 之后loss 曲线会平滑很多。cosine 衰减比 step 衰减好的地方在于末尾阶段学习率降得足够小模型能收敛到一个更平坦的极小值泛化通常好1到2个点。跑120个 epoch 是这个配置的合理长度短于80个 epoch 的话 cosine 的形状还没走完收益不明显。6.3 TTA 与模型集成的性价比训练到后期Test Time Augmentation是最省力的提分手段。做法是把测试图做几次不同的变换原图、水平翻转各自推理之后把 softmax 概率平均def predict_tta(model, loader): model.eval() correct, total 0, 0 with torch.no_grad(): for images, labels in loader: images images.cuda() p1 torch.softmax(model(images), dim1) p2 torch.softmax(model(torch.flip(images, dims[3])), dim1) prob (p1 p2) / 2 preds prob.argmax(dim1).cpu() correct (preds labels).sum().item() total labels.size(0) return correct / total这个改动只需要几行代码通常能涨0.8到1.5个点几乎没有额外成本。但要注意如果模型在训练时没用过Flip增广TTA 反而可能掉点因为翻转后的分布是模型没见过的。模型集成方面训练3到5个不同随机种子的模型把输出概率平均能稳定涨1.5到2个点。代价是训练时间和推理时间成倍增加适合最后冲榜不适合日常迭代。6.4 一份完整的训练日志对照最后贴一份我自己的实测记录同一个网络结构不同配置下的表现方便你对照自己跑的结果判断是否正常配置Epochs最优 val acc备注Adam lr1e-3无增广3070.4%第18个epoch后过拟合SGD lr0.1 cosine基础增广12082.1%曲线平滑稳定收敛上一行 Cutout(16)12084.7%训练loss明显更高val更好上一行 TTA12085.6%推理成本翻倍3模型集成3×12086.9%性价比一般如果你的基础增广 SGD方案跑完120个 epoch 只有 78% 左右先别急着换模型按第5节的链路查一遍数据管线大概率能找到问题。我在不同机器上复现这个配置结果波动基本在0.5个点以内如果偏差超过2个点几乎一定是管线有 bug 而不是随机性。最后再分享一个小技巧每次改动配置之前先用torch.manual_seed()固定种子跑3个不同的种子取平均值做对比。CIFAR10 上单次实验的随机波动有时能到1个点只跑一次的结论很可能是噪声。我自己吃过这个亏为了一个0.3个点的提升折腾了两天最后发现是种子带来的差异。