资讯详情 从ResNet到Wide-ResNet:CIFAR-100图像分类实战与调参指南
📅 2026/10/12 19:32:03
简介面向图像分类学习者的一份CIFAR-100实战开源代码包基于ResNet与Wide-ResNet两种经典卷积架构完整覆盖数据加载、数据增强、模型搭建、训练测试与调优流程适合希望深入理解深度学习模型设计和实验操作的开发人员与研究者。压缩包共15个文件大小仅1.05MB包含5个ipynb实验笔记本、3个py训练脚本、3个png准确率曲线图、3个md说明文档及1个项目介绍文件其中py脚本可直接运行ipynb适合分步学习目录结构清晰便于按步骤复现实验。目前已有194人学习下载。代码展示了从76%到78%正确率的多个版本迭代并附有不同随机数下的结果对比图通过阅读md笔记和ipynb可完整了解ResNet与Wide-ResNet在CIFAR-100上的实现细节、调参思路与性能差异对想要快速上手图像分类实战的初学者和寻求优化策略的研究者都很有价值也能为后续改进模型提供可参考的基线。1. CIFAR-100 分类实战从 ResNet 换到 Wide-ResNet我收获了什么用 ResNet 跑 CIFAR-100很多人第一步就踩进「深度越深越好」的惯性里。我在模拟项目X里从 ResNet-18 一路试到 ResNet-50精度始终卡在 72% 上下改深度、调学习率都像在摸黑。直到把代码重构为 Wide-ResNet 结构同样的数据、同样的训练轮数Top-1 直接跳到 80% 以上。这份资源就是我当时整理出来的完整可运行代码包覆盖数据加载、模型搭建、训练脚本、评估可视化适合正在做课程设计、论文复现或入门图像分类的从业者。读完你不仅能跑通这条从窄到宽的路线还能知道每个参数为什么这么设。2. 数据管道先行CIFAR-100 的加载、增强与归一化参数2.1 数据集结构100 个细类与 20 个粗类的组织方式CIFAR-100 和 CIFAR-10 最大的差异不在于图片数量而在于标签层级。每张 32×32 的彩色图片同时带两个标签fine_label 对应 100 个细类coarse_label 对应 20 个超类。比如「苹果」的 fine_label 是 0coarse_label 是 4水果和蔬菜。训练集 50000 张、测试集 10000 张每类恰好 500 张训练图这个均衡分布意味着你不用做额外的类别重采样。用 PyTorch 加载时我一般直接走 torchvision 的 dataset 接口它返回的 target 是 fine_label 的索引。如果你后面要做多任务学习或者想观察模型在粗类上的混淆情况需要自己把 coarse_label 一起读出来。常见做法是下载到本地后用 pickle 解包再把两个 label 都存成 tensor。我这份代码里默认用 fine_label 做分类但也留了一个加载 coarse_label 的函数方便你扩展。import torch import torchvision import torchvision.transforms as transforms # 下载并加载 CIFAR-100root 换成你自己的数据目录 train_set torchvision.datasets.CIFAR100( root./data, trainTrue, downloadTrue, transformtransforms.ToTensor() ) test_set torchvision.datasets.CIFAR100( root./data, trainFalse, downloadTrue, transformtransforms.ToTensor() ) # 查看样本结构data 是 NCHW 的 uint8targets 是 fine_label 列表 img, fine_label train_set[0] print(img.shape, fine_label) print(粗类示例:, train_set.classes)这段代码验证的是数据集的基本存取。注意 downloadTrue 第一次会从远端把压缩包拉到本地之后离线也能用。classses 属性返回的是 100 个类名的字母序列表和你预期的类别顺序可能不一致写混淆矩阵或输出报告时不要直接用这个顺序去对应语义标签。2.2 数据增强padding4 的 RandomCrop 与水平翻转组合CIFAR-100 每张图只有 32×32 像素直接喂给网络很快就会过拟合。我在代码里用的增强策略是业界处理 CIFAR 系列最通用的组合RandomCrop 加 4 像素 padding配合 RandomHorizontalFlip。Padding 4 意味着先把图片四周补 0 扩展到 40×40再随机裁剪回 32×32相当于给模型制造了微小平移扰动。水平翻转对自然图像是合理的先验但注意 CIFAR-100 里有些类别比如「钟表」左右翻转后依旧合理而「螃蟹」这类具有方向性的物体翻转后语义不变整体影响不大。测试集和验证集只做归一化不做增强这一点必须和训练集分开。很多人翻车是因为把训练 transform 直接套到测试集上导致评估结果带随机性跑几次精度都不一样。下面这段是我在代码里实际使用的完整 transform 配置。import torchvision.transforms as transforms # 训练集先 padding 再裁剪配合随机翻转与标准化 train_transform transforms.Compose([ transforms.RandomCrop(32, padding4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize( mean(0.5071, 0.4867, 0.4408), std(0.2675, 0.2565, 0.2761) ), ]) # 测试集只做张量化和标准化不引入随机性 test_transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize( mean(0.5071, 0.4867, 0.4408), std(0.2675, 0.2565, 0.2761) ), ])这里的均值和标准差不是 ImageNet 的数值而是 CIFAR-100 训练集三通道的真实统计量。很多从 ImageNet 迁移过来的代码直接沿用 0.485、0.456、0.229在 CIFAR-100 上会略微拉低精度。如果你换了数据集最好自己跑一行统计代码重算不要抄别人的。数据增强的强度也有边界CIFAR-100 本身分辨率低CutOut 或 MixUp 这类强增强要等模型能正常收敛后再加否则训练初期的损失曲线会很难看。2.3 DataLoader 参数num_workers、batch size 与显存的关系数据管道最后一步是 DataLoader。batch size 我习惯设 128在单张消费级显卡上 ResNet-18 和 Wide-ResNet-28-10 都能跑动。num_workers 根据 CPU 核心数配置Windows 上设超过 0 偶尔会报 BrokenPipeErrorLinux 上通常 4 到 8 都没问题。pin_memoryTrue 在 GPU 训练时能把数据加载和训练重叠起来速度提升明显。from torch.utils.data import DataLoader train_loader DataLoader( train_set, batch_size128, shuffleTrue, num_workers4, pin_memoryTrue, drop_lastTrue ) test_loader DataLoader( test_set, batch_size128, shuffleFalse, num_workers4, pin_memoryTrue )drop_lastTrue 会丢掉最后一个不满 128 的 batch保证 BN 层的统计量稳定测试集不需要 drop。shuffle 只在训练集开启测试集顺序不影响结果。如果训练时发现 GPU 利用率波动很大优先检查 num_workers而不是加 batch size。batch size 翻倍到 256 时学习率也要跟着翻倍或按线性缩放原则调整否则收敛速度会变慢。3. ResNet 基线BasicBlock 搭建与 200 轮训练配置3.1 为什么先搭 ResNet-18 而不是直接上 Wide-ResNet先跑通一个窄网络作为基线意义在于给后续改进找参照系。我在重构代码时第一步就是复现 ResNet-18确认数据管道、训练超参没有问然后再引入宽度因子。ResNet-18 在 CIFAR-100 上的合理精度大约在 70% 到 75% 之间这个数字受训练轮数和学习率调度影响很大。如果基线都跑不到合理区间后面加宽、加深只会放大问题。ResNet-18 的核心是 BasicBlock每层两个 3×3 卷积中间夹 BatchNorm 和 ReLU残差连接把输入加到第二个卷积的输出上。这个结构用在 CIFAR-100 上有一个关键改动首层卷积从 ImageNet 版本的 7×7 stride2 改成 3×3 stride1因为 32×32 的输入经不起大幅下采样。代码里我按这个思路手写了 BasicBlock没有直接用 torchvision 的 ResNet 实现主要是为了后面切换 Wide-ResNet 时保持一致的接口。import torch import torch.nn as nn class BasicBlock(nn.Module): def __init__(self, in_planes, planes, stride1): super().__init__() self.conv1 nn.Conv2d(in_planes, planes, kernel_size3, stridestride, padding1, biasFalse) self.bn1 nn.BatchNorm2d(planes) self.conv2 nn.Conv2d(planes, planes, kernel_size3, stride1, padding1, biasFalse) self.bn2 nn.BatchNorm2d(planes) self.shortcut nn.Sequential() if stride ! 1 or in_planes ! planes: # 下采样或通道数变化时用 1x1 卷积对齐维度 self.shortcut nn.Sequential( nn.Conv2d(in_planes, planes, kernel_size1, stridestride, biasFalse), nn.BatchNorm2d(planes) ) def forward(self, x): out torch.relu(self.bn1(self.conv1(x))) out self.bn2(self.conv2(out)) out self.shortcut(x) out torch.relu(out) return out这段代码有几个细节所有卷积 biasFalse因为后面接 BatchNormbias 会被 BN 的均值和方差计算吸收掉留着只会增加冗余参数。shortcut 不是恒等映射的情况有两种空间尺寸变化或通道数变化统一用 1×1 卷积做投影。最后 out shortcut(x) 之前两条路径的 shape 必须严格一致否则会直接报错。3.2 优化器与学习率调度SGD、动量 0.9、余弦退火CIFAR-100 分类训练里Adam 不是首选。SGD 加动量 0.9 配合适当的学习率调度最终精度普遍高于 Adam。权重衰减设 5e-4这是 CIFAR 系列任务的经验值。学习率从 0.1 起步200 轮训练内按余弦退火降到接近 0比固定步长衰减更平滑。如果你资源紧张只训练 100 轮建议用 step 衰减而不是余弦否则后半段学习率降太慢浪费算力。import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR optimizer optim.SGD( model.parameters(), lr0.1, momentum0.9, weight_decay5e-4 ) scheduler CosineAnnealingLR(optimizer, T_max200) criterion nn.CrossEntropyLoss()T_max 必须和总训练轮数保持一致否则余弦退火在最后一轮时学习率没有降到最低点。weight_decay 对 BN 层参数也会生效但实际影响很小。这里的学习率 0.1 对应 batch size 128如果你把 batch size 改成 256学习率应该先试试 0.2而不是死守 0.1。3.3 训练循环的骨架train 与 eval 模式切换训练循环看起来简单但模式切换是新手最容易漏的一环。PyTorch 的 BatchNorm 层在训练和推理时行为不同训练时用当前 batch 的均值和方差更新 running 统计量推理时用累计的 running 统计量。忘记切 model.eval() 会导致测试阶段 BN 仍在用 batch 内统计量小 batch size 下精度波动非常明显。Dropout 层也是同理eval 模式下会自动关闭。def train_one_epoch(model, loader, optimizer, criterion, device): model.train() running_loss, correct, total 0.0, 0, 0 for inputs, targets in loader: inputs, targets inputs.to(device), targets.to(device) optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, targets) loss.backward() optimizer.step() running_loss loss.item() * inputs.size(0) _, pred outputs.max(1) correct pred.eq(targets).sum().item() total targets.size(0) return running_loss / total, correct / total def evaluate(model, loader, criterion, device): model.eval() running_loss, correct, total 0.0, 0, 0 with torch.no_grad(): for inputs, targets in loader: inputs, targets inputs.to(device), targets.to(device) outputs model(inputs) loss criterion(outputs, targets) running_loss loss.item() * inputs.size(0) _, pred outputs.max(1) correct pred.eq(targets).sum().item() total targets.size(0) return running_loss / total, correct / totalevaluate 里用 torch.no_grad() 关闭梯度追踪可以显著减少显存占用和推理时间。outputs.max(1) 返回每行最大值和索引索引就是预测类别。注意 loss 的累积用 loss.item() 乘以 batch 大小再除以总数算出来的是整个 epoch 的平均损失而不是最后一个 batch 的损失。4. Wide-ResNet 改型宽度因子、Dropout 与学习率协同调参4.1 从深度到宽度为什么加宽比加深更划算ResNet 在 CIFAR-100 上加深到 50 层以上收益会快速递减。深层网络要面对梯度传播、优化难度和训练时间的三重压力。Wide-ResNet 的思路很简单保持网络不太深把每个残差块的通道数乘上一个宽度因子 k让每一层能容纳更多特征。模拟项目X里我用 WRN-28-10即 28 层、宽度因子 10参数量大约 36.5M在单卡上训练时间和 ResNet-50 相当精度却高出不少。宽度因子 k 的含义是每个 BasicBlock 中间的卷积输出通道数从基础值乘以 k。基础通道数在 WRN 论文里通常取 16k10 时中间层是 160 个通道最后全连接层输入是 640。需要强调的是k10 不是越大越好显存和训练时间会线性增长k4 到 8 是性价比更高的区间尤其在小数据集上过宽的模型反而容易在训练后期过拟合。4.2 Wide-BasicBlock 的代码实现在 BasicBlock 里加 DropoutWide-ResNet 的残差块和普通 BasicBlock 的区别在于第一个 3×3 卷积之后、第二个 3×3 卷积之前插入 Dropout。位置有讲究放在激活之后、卷积之前的效果最好。Dropout 概率我固定为 0.3这个值来自 WRN 论文在 CIFAR 系列上的调参结论。如果你的训练集更小比如每个类只有 250 张图可以考虑提高到 0.5但要注意验证集精度的拐点。class WideBasicBlock(nn.Module): def __init__(self, in_planes, planes, stride1, dropout_rate0.3): super().__init__() self.bn1 nn.BatchNorm2d(in_planes) self.conv1 nn.Conv2d(in_planes, planes, kernel_size3, stridestride, padding1, biasFalse) self.dropout nn.Dropout(pdropout_rate) self.bn2 nn.BatchNorm2d(planes) self.conv2 nn.Conv2d(planes, planes, kernel_size3, stride1, padding1, biasFalse) self.shortcut nn.Sequential() if stride ! 1 or in_planes ! planes: self.shortcut nn.Sequential( nn.Conv2d(in_planes, planes, kernel_size1, stridestride, biasFalse), nn.BatchNorm2d(planes) ) def forward(self, x): out torch.relu(self.bn1(x)) out self.conv1(out) out self.dropout(out) out torch.relu(self.bn2(out)) out self.conv2(out) out self.shortcut(x) return out这个块的顺序和原始 ResNet 有区别BN 和 ReLU 放在卷积之前这叫 pre-activation 结构。它有两个实际好处一是梯度流动更顺畅二是配合 Dropout 时不会把激活值的信息在残差连接之前丢掉太多。如果你直接照搬 torchvision 的 BasicBlock 加 Dropout效果会因为 BN 和 ReLU 的位置不同而变化别混用。4.3 组装 WRN-28-10三个阶段的通道配置与全连接层WRN-28-10 的 28 层不是所有卷积层都是 3×3它由三个阶段组成每阶段包含 N 个 Wide-BasicBlock。28 1首层卷积 4×3每阶段 4 个块 1全连接前的池化所以 N4。三个阶段的输出通道是 160、320、640对应宽度因子 10 乘以基础通道 16、32、64。第一个 block 的 stride1第二个阶段起 stride2 做下采样。class WideResNet(nn.Module): def __init__(self, depth28, k10, num_classes100, dropout_rate0.3): super().__init__() n (depth - 4) // 6 # 28 - 4每阶段 4 个 block base_channels [16, 32, 64] self.in_planes 16 self.conv1 nn.Conv2d(3, 16, kernel_size3, stride1, padding1, biasFalse) self.layer1 self._make_layer(base_channels[0] * k, n, stride1) self.layer2 self._make_layer(base_channels[1] * k, n, stride2) self.layer3 self._make_layer(base_channels[2] * k, n, stride2) self.bn nn.BatchNorm2d(base_channels[2] * k) self.fc nn.Linear(base_channels[2] * k, num_classes) def _make_layer(self, planes, num_blocks, stride): strides [stride] [1] * (num_blocks - 1) layers [] for s in strides: layers.append(WideBasicBlock(self.in_planes, planes, strides)) self.in_planes planes return nn.Sequential(*layers) def forward(self, x): out self.conv1(x) out self.layer1(out) out self.layer2(out) out self.layer3(out) out torch.relu(self.bn(out)) out torch.nn.functional.adaptive_avg_pool2d(out, (1, 1)) out out.view(out.size(0), -1) out self.fc(out) return out组装时算好 n 是关键depth28 时 n4如果你改成 depth16n 就变成 2。每阶段的 first block 用 stride2 下采样其余 block 保持 stride1。in_planes 在 _make_layer 内部不断更新保证下一个 block 的输入通道和上一阶段的输出一致。全连接层不再需要中间隐层直接把 640 维映射到 100 类。4.4 训练策略调整学习率、轮数与正则化协同WRN-28-10 在 CIFAR-100 上的训练配置和 ResNet-18 不太一样。宽度大了之后模型容量变大过拟合风险明显上升。我实践的配置是SGD 动量 0.9权重衰减 5e-4学习率从 0.1 开始200 轮余弦退火Dropout 0.3batch size 128。这个组合在模拟项目X上 Top-1 稳定达到 80% 以上训练时间在单 GPU 上约 3 小时。如果你想压缩训练时间可以只训 120 轮但学习率调度要改成 milestone 衰减比如在 60 轮和 90 轮各乘 0.2。宽网络的容错性比窄网络好但对学习率的敏感度更高。如果 loss 在前期震荡不降先检查是不是学习率过高把 0.1 降到 0.05 重跑 10 轮观察趋势。另外 label smoothing 可以尝试设 0.1对 WRN 这类大容量模型有一定正则作用。import torch import torch.nn as nn import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR model WideResNet(depth28, k10, num_classes100, dropout_rate0.3) device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) optimizer optim.SGD(model.parameters(), lr0.1, momentum0.9, weight_decay5e-4) scheduler CosineAnnealingLR(optimizer, T_max200) criterion nn.CrossEntropyLoss(label_smoothing0.1)label_smoothing 把 one-hot 标签变成软标签对 WRN 这种超大容量模型有明显抑制过拟合的效果。但注意加了 label smoothing 之后模型的预测置信度会整体下降最终准确率不一定直接上升只是通常泛化性更好。如果你要追求指标好看而不是泛化可以不加。5. 训练避坑与排查CIFAR-100 上常见的 5 个翻车点5.1 损失不降或震荡先查学习率再查 BN 位置现象训练开始时 loss 一直在 4.6 附近log(100) 对应随机猜测10 轮后仍然几乎不动或者 loss 上下剧烈波动。原因学习率过高导致优化过程在损失曲面边缘震荡也可能是首层卷积 stride 设成 232×32 输入被下采样到 16×16信息损失严重。解决先把学习率降到 0.01 跑 5 轮看 loss 是否平稳下降如果恢复正常再用 0.1 重跑并在代码里检查 conv1 的 stride 必须为 1。预训练模型加载时如果 strictTrue 报 key 不匹配多半是分类头维度不同忽略最后一层权重即可。5.2 验证集精度远低于训练集典型过拟合信号现象训练集 Top-1 到 95%验证集卡在 72% 不再上升而且随训练轮数增加差距持续拉大。原因模型容量超过数据集复杂度CIFAR-100 每类只有 500 张训练图WRN-28-10 的 36.5M 参数如果不加正则过拟合是必然结果。解决确认 Dropout 已启用WRN 的 dropout_rate 不要设 0增加 weight_decay 到 1e-3 试试如果还不行把 k 降到 4 重新训练。训练集和验证集的精度差在 15 个百分点以内属于正常超过这个值就要动手减容量。5.3 多卡训练时 BN 统计量不准同步 BN 的显存代价现象单卡正常换成 DataParallel 双卡后验证精度掉 2 到 3 个百分点。原因DataParallel 默认每张卡独立计算各自的 BN 统计量batch size 被拆成 64 后每个 BN 的统计量方差变大推理时 running mean 偏差增大。解决小规模实验直接单卡训多卡场景用 torch.nn.SyncBatchNorm 把各卡的均值方差同步但这会额外增加通信开销显存占用也会上升。模拟项目X里我为了稳定复现全程单卡训练省去排查多卡 BN 的麻烦。5.4 复现结果不一致随机种子与数据增强的确定性现象同一份代码跑两次最终精度差 0.5 到 1 个百分点。原因PyTorch 的 CUDA 卷积实现本身有非确定性加上数据增强的随机裁剪两次训练不可能完全一致。如果连加载顺序都不一样diff 更大。解决在训练脚本开头固定随机种子包括 Python 内置 random、numpy、torch 和 CUDA。这不能保证完全一致但能显著缩小差异。如果你想精确复现还需要把 DataLoader 的 shuffle 固定下来不过通常没必要0.5 个点的波动在可接受范围内。import random import numpy as np import torch def set_seed(seed42): 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 set_seed(42)cudnn.benchmarkFalse 会让卷积选择固定算法牺牲一点速度换确定性。如果你的显存紧张可以考虑 benchmarkTrue 以获取更快的卷积实现但实验的可复现性会被破坏。做消融实验时我建议强制确定模式。5.5 显存不足 OOM梯度累积与混合精度的取舍现象训练 WRN-28-10 时 batch size 128 直接 OOM或跑了几百步后显存爆掉。原因中间激活值占了大部分显存batch size 太大或者输入图片意外变成非 32×32 尺寸激活值成倍增长。解决先确认 input tensor 的 shape 是 N×3×32×32。如果没问题用梯度累积模拟更大的 batch每 4 个 batch 更新一次参数效果等同于 batch size 翻 4 倍。也可以打开 torch.cuda.amp 混合精度训练显存占用能减半但 BN 层在 FP16 下可能有数值稳定性问题需要保持 FP32 计算。scaler torch.cuda.amp.GradScaler() accumulation_steps 4 for i, (inputs, targets) in enumerate(train_loader): inputs, targets inputs.to(device), targets.to(device) with torch.cuda.amp.autocast(): outputs model(inputs) loss criterion(outputs, targets) / accumulation_steps scaler.scale(loss).backward() if (i 1) % accumulation_steps 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()梯度累积的 loss 要除以 accumulation_steps否则总损失会被放大相当于学习率被抬高。混合精度下用 GradScaler 防止梯度下溢每步 forward 的 autocast 上下文不能少。如果你观察到 loss 曲线出现突然的大跳变很可能是 FP16 的精度问题把 autocast 只包在卷积层附近。6. 进阶验证混淆矩阵、置信度分析与标签平滑复检模型训完不是只看一个 Accuracy 数字就收工。我在模拟项目X的最后一轮用测试集做了三件事第一件事是画 100 类的混淆矩阵按 20 个粗类重新聚合成 20×20 的粗粒度混淆矩阵能直观看到模型把「大型猫科动物」里的哪几个细类搞混。第二件事是统计预测置信度分布找到那些虽然分类正确但置信度低于 0.5 的样本它们通常是模糊物体或遮挡严重的图这是后续数据清洗的候选对象。第三件事是检查 label smoothing 对输出分布的影响——加了平滑之后模型预测的 softmax 分布峰值更平缓这会让集成学习或知识蒸馏时的软标签更平滑但要确认 Top-1 没有明显倒退。如果你手头有两份训练好的权重可以做一个最简单的模型集成对测试集分别得到两组 softmax 概率取平均再取 argmax。在 CIFAR-100 上两个 WRN-28-10 的集成通常能再涨 1 到 1.5 个点。代价是测试时多跑一次 forward训练时也要分别训。更经济的做法是取训练过程中保存的最优两个 checkpoint而不是重新训练。我代码里默认每 10 轮存一次 checkpoint最后用验证集精度最高的两个做平均。验证脚本里有一个值得养成的习惯用 torch.onnx.export 导出模型再用 onnxruntime 的 CPU 推理跑一遍测试集对比 PyTorch 的精度。如果两者差异超过 0.5%说明某个算子常见是 BN 折叠在导出时没有处理好。我在项目后期每次换模型结构都会强制走一遍这个流程从那以后部署到推理环境前都先做一次 ONNX 精度对齐省掉了不少上线后才发现精度不一致的麻烦。希望这份代码和排错笔记能帮你把 CIFAR-100 从「能跑」推进到「跑得明白」的阶段。本文还有配套的精品资源点击获取