简介CIFAR-100分类实战项目是一份面向机器学习和计算机视觉学习者的开源代码资源使用ResNet与Wide-ResNet两种网络实现图像分类覆盖数据加载、增强、模型搭建、训练测试与调优全过程。资源共15个文件压缩包大小约1.05MB其中包括5个ipynb实验笔记本、3个Python源码文件、3张结果图片、3份Markdown说明文档和1个介绍文件结构清晰ipynb记录不同随机数和训练配置下的实验过程py文件提供ResNet、Wide-ResNet实现及主程序图片展示准确率曲线md文档辅助快速上手整体按实验流程组织便于逐步复现。目前已有194人次浏览学习。项目展示了76%与78%正确率两个版本方便对照复现通过学习该代码能深入理解残差连接与网络宽度对性能的影响并掌握学习率调整、权重初始化等实用调参技巧是入门中级图像分类实战的良好参考。1. CIFAR-100 分类实战先跑通小图再谈 ResNet 与 Wide-ResNet 的差距很多人把 CIFAR-100 当成入门玩具实际跑起来才发现它比想象中难缠。同样是 32×32 的小图100 个类别每类只有 500 张训练图模型稍微宽一点就过拟合浅一点又欠拟合ResNet 在这里跟 Wide-ResNet 的差距能拉到十几个点。这篇文章把我从 ResNet 基线到 Wide-ResNet 的完整路径、开源代码整理思路和训练参数一起讲清楚适合正在做 CIFAR-100 分类、想复现高精度结果、或者准备把 ResNet 换 Wide-ResNet 但不知道从何下手的从业者。你能拿走的是一套可复现的训练配置加上几条用钱买不来的踩坑记录。2. CIFAR-100 数据与评估协议50k/10k 划分、预处理和该不该碰测试集2.1 CIFAR-100 与 CIFAR-10 的差距不在尺寸在每类样本量CIFAR-100 总共 6 万张 32×32 彩色图像100 个类别每类 600 张其中 500 张训练、100 张测试固定划分成 5 万训练和 1 万测试。单看数字跟 CIFAR-10 差不多但 CIFAR-10 每类有 5000 张训练图CIFAR-100 直接砍到十分之一。样本量少导致两个直接后果一是模型容量稍微大一点就记住训练集验证集精度上不去二是数据增强的作用被放大同一个 ResNet 在 CIFAR-100 上增强做没做好能差 5 个点以上。很多人犯的第一个错误是拿 CIFAR-10 的习惯直接套 CIFAR-100epoch 数不变增强不加dropout 不配。跑出来发现 ResNet 只有 70% 上下于是怀疑网络实现有问题。实际上问题基本出在训练协议上。CIFAR-100 的常见做法是 200 到 400 epoch、带 warmup 的 cosine 学习率、配合 label smoothing 和 weight decay这套组合比单纯堆网络宽度更值得先落实。数据层面还有个容易被忽略的点CIFAR-100 的类别里有超类结构20 个超类各包含 5 个细类比如“鱼”这个超类下面有比目鱼、鲨鱼等。做分类任务时一般按 100 个细类输出但如果你的业务场景本身是层级分类这个超类标签可以直接拿来当辅助监督对低样本类别有一定帮助。2.2 数据加载与预处理一次写对的代码模板CIFAR-100 的预处理代码本身不难但顺序和参数写错会影响整个训练。我一般直接用 PyTorch 的torchvision.datasets.CIFAR100训练集和测试集分开加载关键在 transform 的组装顺序。import torch import torchvision import torchvision.transforms as T # CIFAR-100 公开统计量如果怕数据集版本差异也可以自己从训练集统计 mean (0.5071, 0.4867, 0.4408) std (0.2675, 0.2565, 0.2761) transform_train T.Compose([ T.RandomCrop(32, padding4), # 先做随机裁剪padding 4 是 CIFAR 系标配 T.RandomHorizontalFlip(), # 随机水平翻转概率默认 0.5 T.ToTensor(), # 转 Tensor像素值归一化到 [0, 1] T.Normalize(mean, std), # 用数据集的 mean/std 做标准化 ]) transform_test T.Compose([ T.ToTensor(), T.Normalize(mean, std), ]) train_set torchvision.datasets.CIFAR100( root./data, trainTrue, downloadTrue, transformtransform_train) test_set torchvision.datasets.CIFAR100( root./data, trainFalse, downloadTrue, transformtransform_test) train_loader torch.utils.data.DataLoader( train_set, batch_size128, shuffleTrue, num_workers4, pin_memoryTrue, drop_lastTrue) test_loader torch.utils.data.DataLoader( test_set, batch_size256, shuffleFalse, num_workers4)这段代码有三个容易踩的细节。第一RandomCrop必须在ToTensor之前因为裁剪操作期望输入是 PIL 图像转成 Tensor 后再切会让数据分布发生偏移。第二测试集的 transform 不要加任何随机操作很多人复制训练集 transform 到测试集导致测试精度上下波动好几个点。第三drop_lastTrue只用在训练集测试集必须保留全部数据否则 10000 张测试图被 256 的 batch 切剩 32 张Top-1 精度计算不完整。我通常还会在RandomCrop后再加一个Cutout或RandomErasing在 32×32 的图上挖一个 8×8 的方形洞。这个操作对 CIFAR-100 有明显正收益尤其是 Wide-ResNet 这类大容量网络。需要注意的是洞的大小不要超过 16小图上挖太大反而会把完整物体破坏掉精度不升反降。2.3 用训练集切验证子集还是直接测测试集CIFAR-100 本身没有官方验证集新手最容易犯的毛病是把测试集当验证集反复用调一次参数测一次测试集最后报告的数字其实是「在测试集上调过参」的数字。这个数字看起来高但换一个数据分布就露馅。正经做法是从训练集里固定切出 1000 张做验证集模型选择和 early stop 只看这个子集测试集只允许在最终评估时碰一次。import numpy as np from torch.utils.data import Subset rng np.random.RandomState(42) # 固定随机种子保证每次切分一致 indices np.arange(len(train_set)) rng.shuffle(indices) val_indices indices[:1000] # 前 1000 张做验证 train_indices indices[1000:] # 剩下 49000 张做训练 val_set Subset(train_set, val_indices) train_sub Subset(train_set, train_indices)验证集切出来之后要确认一件事原train_set的 transform 仍然是带增强的版本验证子集如果也用这个 transform验证精度会偏低且不稳定。正确的做法是给验证子集单独换掉 transform用测试集那套纯ToTensor Normalize。我通常直接为验证子集创建一个不带增强的 dataset 对象再按同样索引切分避免把增强带到验证过程里。这个 1k 验证子集还有个额外用途它能帮你判断训练是否过拟合。训练精度 95%、验证精度 82%差距在 10 个点以上说明模型容量过大或正则不够此时再去调 weight decay 或 dropout 就有明确的方向。如果你一直只盯测试集根本分不清是训练没收敛还是过拟合。3. ResNet 基线搭建把残差块调成适合 32×32 输入的宽度与深度3.1 basic block 与 bottleneck小图上该选谁ResNet 在 ImageNet 上用的是 bottleneck 结构1×1 降维 → 3×3 卷积 → 1×1 升维目的是在深层网络里控制参数量和计算量。但 CIFAR-100 输入只有 32×32网络深度一般不超过 30 层bottleneck 的降维优势完全发挥不出来反而因为第一个 1×1 卷积丢失了空间信息。我在 CIFAR-100 上一律用 basic block两个 3×3 卷积加上残差连接参数量适中梯度回传更直接。另一个关键决策是通道数设计。ImageNet 版 ResNet 第一个卷积层是 7×7 步长 2输入 224×224CIFAR 版第一步用 3×3 卷积不立刻降分辨率随后接 3 个 stage通道数按 64 → 128 → 256 翻倍。如果你想更深把每个 stage 的 block 数从 2 加到 4 或 5这就是 ResNet-24 或 ResNet-32 的来源。深度超过 40 后在 CIFAR-100 上收益很小但训练时间和显存占用线性上涨性价比不高。3.2 ResNet-24 的最小实现网络结构代码与参数解释我给出一版可以直接跑的实验结构重点在于把 stride 下采样放在每个 stage 的第一个 block 里而不是单独加池化层。代码略去了完整训练脚本只保留网络骨架。import torch import torch.nn as nn import torch.nn.functional as F class BasicBlock(nn.Module): def __init__(self, in_ch, out_ch, stride1): super().__init__() # 两个 3x3 卷积卷积后接 BNBN 后再接 ReLU self.conv1 nn.Conv2d(in_ch, out_ch, kernel_size3, stridestride, padding1, biasFalse) self.bn1 nn.BatchNorm2d(out_ch) self.conv2 nn.Conv2d(out_ch, out_ch, kernel_size3, stride1, padding1, biasFalse) self.bn2 nn.BatchNorm2d(out_ch) # 输入输出通道不一致或分辨率变化时shortcut 用 1x1 卷积对齐 self.shortcut nn.Sequential() if stride ! 1 or in_ch ! out_ch: self.shortcut nn.Sequential( nn.Conv2d(in_ch, out_ch, kernel_size1, stridestride, biasFalse), nn.BatchNorm2d(out_ch)) def forward(self, x): identity self.shortcut(x) out F.relu(self.bn1(self.conv1(x))) out self.bn2(self.conv2(out)) out identity # 残差相加 return F.relu(out) class ResNet24(nn.Module): def __init__(self, num_classes100): super().__init__() # 第一层 3x3 卷积输出 32x32不降分辨率 self.conv1 nn.Conv2d(3, 64, kernel_size3, stride1, padding1, biasFalse) self.bn1 nn.BatchNorm2d(64) # 3 个 stage每个 stage 4 个 block步长分别为 1/2/2 self.stage1 self._make_stage(64, 64, blocks4, stride1) self.stage2 self._make_stage(64, 128, blocks4, stride2) self.stage3 self._make_stage(128, 256, blocks4, stride2) self.avg_pool nn.AdaptiveAvgPool2d(1) self.fc nn.Linear(256, num_classes) def _make_stage(self, in_ch, out_ch, blocks, stride): layers [BasicBlock(in_ch, out_ch, stride)] # 第一个 block 负责下采样 for _ in range(1, blocks): layers.append(BasicBlock(out_ch, out_ch, stride1)) return nn.Sequential(*layers) def forward(self, x): x F.relu(self.bn1(self.conv1(x))) x self.stage1(x) x self.stage2(x) x self.stage3(x) x self.avg_pool(x) x torch.flatten(x, 1) return self.fc(x)这段代码里有几个参数值得细说。biasFalse在卷积层上必须设置因为后面紧跟 BN 层卷积的 bias 会被 BN 的平移参数抵消留着只会浪费参数。shortcut只有在通道数或分辨率变化时才激活其余 block 直接走恒等映射这也是 ResNet 能训练深层的根基。每个 stage 的stride2会把特征图从 32×32 降到 16×16再降到 8×8最后经全局平均池化输出 256 维向量接分类头。ResNet-24 的参数量大约是 14M在 CIFAR-100 上已经够用。你可以把每个 stage 的 block 数改成 3 得到 ResNet-20改成 5 得到 ResNet-32改动非常小。我的经验是 ResNet-24 到 ResNet-32 精度提升约 1 个百分点但训练时间增加约 30%作为基线 24 层是一个性价比不错的选择。3.3 基线的超参起点和预期分数区间ResNet 基线跑得好不好超参比网络结构重要得多。下面这一组是 CIFAR-100 上多次验证过的起点配置直接照着用一般不会翻车。超参数推荐值说明优化器SGD NesterovAdam 在 CIFAR 系上容易过拟合初始学习率0.1batch size 128 下的常用值动量0.9SGD 标准配置weight decay5e-4只对卷积和全连接生效BN 不衰减batch size128单卡 10GB 内可以跑 WRN-24-8epoch300低于 200 时 cosine 尾部没走完学习率调度warmup 5 epoch cosine避免初期 BN 统计量震荡label smoothing0.1对 100 类输出有明显稳定作用在这个配置下ResNet-24 配合第 2 章的增强策略CIFAR-100 测试精度大约落在 77% 到 79% 之间。如果你的结果低于 75%优先检查两件事一是训练集 transform 是不是每条样本都做增强而不是只做了归一化二是学习率曲线是不是在 epoch 200 附近降到了接近 0。前者会让模型快速过拟合后者会让模型在最后 100 个 epoch 里几乎学不到东西。4. 从 ResNet 到 Wide-ResNet加宽因子、dropout 位置与两个必调参数4.1 Wide-ResNet 改了什么加宽、加深和 dropout 三件事Wide-ResNet 的核心思路很直接与其把网络堆得很深不如把每个残差块里的通道数放大。它的做法是在 basic block 基础上引入一个加宽因子 k通道数从 64 变成 64×k。同样 24 层k8 的参数量大约是 ResNet-24 的 6 倍但精度能涨 5 个点以上训练时间只多 2 到 3 倍性价比远高于继续加深。加宽之所以比加深有效在于 CIFAR-100 每类样本太少深层网络容易过拟合而宽网络可以在较浅的深度下获得足够容量。配合 dropout 抑制过拟合后Wide-ResNet 在 CIFAR-100 上一直是性价比最高的选择之一。常见配置是 WRN-28-1028 层、k10但那是 36M 参数的大家伙单卡训练比较吃力我更推荐 WRN-24-8精度只低 0.3 到 0.5 个点显存和训练时间友好很多。4.2 从 ResNet-24 改到 WRN-24-8diff 思路与关键代码从 ResNet-24 到 Wide-ResNet 不需要重写整个网络改动集中在BasicBlock里加一个widen_factor参数在残差相加之后、ReLU 之前插入 dropout。class WideBasicBlock(nn.Module): def __init__(self, in_ch, out_ch, stride1, dropout_rate0.3): super().__init__() self.conv1 nn.Conv2d(in_ch, out_ch, kernel_size3, stridestride, padding1, biasFalse) self.bn1 nn.BatchNorm2d(out_ch) self.conv2 nn.Conv2d(out_ch, out_ch, kernel_size3, stride1, padding1, biasFalse) self.bn2 nn.BatchNorm2d(out_ch) self.dropout nn.Dropout(pdropout_rate) self.shortcut nn.Sequential() if stride ! 1 or in_ch ! out_ch: self.shortcut nn.Sequential( nn.Conv2d(in_ch, out_ch, kernel_size1, stridestride, biasFalse), nn.BatchNorm2d(out_ch)) def forward(self, x): identity self.shortcut(x) out F.relu(self.bn1(self.conv1(x))) out self.bn2(self.conv2(out)) out self.dropout(out) # dropout 放在残差相加之前 out identity return F.relu(out) class WideResNet24_8(nn.Module): def __init__(self, num_classes100, k8, dropout_rate0.3): super().__init__() base_ch 16 * k # Wide-ResNet 的起始通道数从 16 起步不是 64 self.conv1 nn.Conv2d(3, base_ch, kernel_size3, stride1, padding1, biasFalse) self.bn1 nn.BatchNorm2d(base_ch) self.stage1 self._make_stage(base_ch, base_ch, blocks4, stride1, dropout_ratedropout_rate) self.stage2 self._make_stage(base_ch, base_ch * 2, blocks4, stride2, dropout_ratedropout_rate) self.stage3 self._make_stage(base_ch * 2, base_ch * 2, blocks4, stride2, dropout_ratedropout_rate) self.avg_pool nn.AdaptiveAvgPool2d(1) self.fc nn.Linear(base_ch * 2, num_classes)这里有两个关键参数容易搞错。第一起始通道数用 16×k 而不是 64×k这是 Wide-ResNet 论文里的标准设计如果沿用 ResNet-24 的 64 起步WRN-24-8 参数量会膨胀到 200M 以上训练速度和显存都不现实。第二dropout 的位置放在第二个 BN 之后、残差相加之前而不是相加之后。放在相加后会对恒等映射也做随机置零损害已经学好的特征传递放在相加前只对新增卷积路径做正则残差路径保持完整实验效果更稳定。Stem 层仍然用 3×3 卷积保持 32×32 分辨率不变三个 stage 的通道数按 16k → 32k → 32k 排布Stage2 和 Stage3 都在第一个 block 里做 stride2 下采样。如果你跑起来发现显存不够第一步先调低k到 6精度影响约 1 个点第二步才是减深度。4.3 加宽因子、深度和显存的三角约束加宽因子 k 直接决定了参数量和显存占用选型时有个三角约束宽度越大精度越高但显存和单 epoch 时间同步上涨而且收益递减。模型配置参数量级单卡训练显存测试精度区间ResNet-24约 14M约 2GB77% ~ 79%WRN-24-6约 11M约 3.5GB80% ~ 82%WRN-24-8约 20M约 6GB82% ~ 84%WRN-28-10约 36M约 11GB83% ~ 85%这组数据来自我多次实验的区间范围具体精度受增强强度、epoch 数和随机种子影响但趋势是稳定的k 从 6 涨到 8 收益约 1.5 个点从 8 涨到 10 收益不到 1 个点而同比例增长的训练时间在 k10 时已经很难接受。提示如果你的显卡在 10GB 以下优先用 WRN-24-6不要硬上 28-10。宽度带来的收益必须在足够多的 epoch 下才能兑现显存紧张被迫调小 batch size 反而会破坏 BN 统计量的稳定性。WRN 还有一个容易忽略的点加宽之后模型容量变大必须同步增强正则。具体做法是 weight decay 从 5e-4 提到 1e-3 或 2e-3同时把 dropout_rate 从 0.3 提到 0.4。这两个参数不调WRN 和 ResNet 之间的精度差距可能会缩小到 2 个点以内看起来像是白换了网络。5. CIFAR-100 训练避坑增强强度、lr 同步和 checkpoint 的 4 个翻车点5.1 数据增强加到什么程度会反噬现象按网上推荐的「全套餐」配置RandomCrop、HorizontalFlip、Cutout、RandomErasing、AutoAugment 全上训练集 loss 降到 0.8 就不动了验证精度只有 76%比只做基础增强还低。原因32×32 的小图上同时叠加多种随机破坏很多样本被增强到看不清主体。Cutout 挖掉 16×16 的洞已经占整图面积的 25%再叠加 RandomErasing相当一部分训练样本的有效信息所剩无几模型学不到稳定的类别特征。解决增强不是越多越好CIFAR-100 上只保留 RandomCrop HorizontalFlip Cutout 三件套Cutout 的洞控制在 8×8 到 12×12 之间。AutoAugment 在这类小数据集上容易过拟合到增强策略本身换数据集后不通用我一般不用。5.2 学习率曲线中途加 epochacc 反而掉回去现象用 cosine 调度跑完 200 epochacc 停在 82%。想继续提升直接从 checkpoint 加载模型再跑 100 epoch结果前 20 个 epoch 精度明显下降最后只能恢复到 81% 左右白白浪费计算资源。原因torch 的 cosine scheduler 是按总 epoch 数计算学习率的。续跑时如果重新创建 scheduler 并设置T_max100学习率会从峰值 0.1 重新开始而模型权重已经收敛到了低学习率阶段的最优点突然遇到 0.1 的大学习率权重被撞出原来的稳定区域。解决续跑时不要重置 cosine 周期。正确做法是加载 checkpoint 后把初始学习率设为当前 epoch 对应的学习率值的十分之一做短平快的 fine-tune不再套新的 schedule。如果一定要从头用完整的 cosine那不如直接重新训练效果更好。5.3 换 WRN 时忘调 weight decay 与 dropout 的踩坑现象从 ResNet-24 切到 WRN-24-8网络结构照着改完超参原封不动搬过去20 epoch 后训练 acc 比 ResNet 同期还低 3 个点一度怀疑代码写错了。原因WRN 容量是 ResNet 的将近 2 倍同样的 weight decay 5e-4 压不住大模型的过拟合训练集很快被记住验证集精度爬不上去。另外WRN 加了 dropout 之后如果放在model.eval()后 Dropout 层没有自动关闭验证精度会把 dropout 的随机性也算进去。解决切 WRN 时必须同时调两处。第一weight decay 提到 1e-3 或 1.5e-3让大容量模型受到更强的约束第二训练代码里确保model.train()和model.eval()正确切换PyTorch 的 BN 和 Dropout 都依赖这个状态这在踩坑记录里是出现频率最高的低级错误。5.4 多卡训练 batch size 变了lr 却没跟着变现象单卡 batch size 128 跑 WRN-24-8 精度 83%换成两张卡DistributedDataParallelbatch size 总和翻到 256其他不变最终精度只有 81.5%训练过程看着还一切正常。原因batch size 翻倍后梯度估计更稳定同样的学习率相当于被放大了模型在训练早期就走出了过大的步长落入一个更差的极小值。学习率需要按 batch size 的比例做同步调整。解决batch size 从 128 提到 256 时学习率从 0.1 提到 0.2或者按常见做法乘上sqrt(256/128)得到约 0.14。用 DDP 时还要注意每个进程的 batch size 是 128总 batch size 是 256学习率调整按总 batch size 计算。不做同步的话多卡训练结果还不如单卡这是 DDP 里最容易忽略的暗坑。注意对 CIFAR-100 这种小数据集单卡训练也就几小时不一定要追多卡。如果只是想把实验跑完先把单卡配置调到最优再考虑多卡扩展。6. 不偷看测试集也能验证固定验证子集与训练自检的小技巧6.1 固定验证子集给实验一个可信的对比基准我习惯把 1000 张验证子集的索引保存在一个文件里跟训练配置放一起。每次实验无论怎么改增强、改网络对比都用同一批验证样本比较才公平。如果没有固定子集每次随机切 1000 张验证精度会因为样本差异波动 0.5 到 1 个点很可能掩盖真实的改进效果。固定子集的方法在第 2 章已给出实际操作时把它封装成一个函数生成后立即保存索引文件避免每次跑脚本重新生成。6.2 训练自检先看能不能记住训练集再谈测试集分数代码改完先别急着跑完整训练抽 100 张训练样本单独训一个小循环设置固定 seed。如果模型在这个小样本子上连 90% 都到不了说明数据管线或网络实现有 bug而此时你连测试集都不用碰就能定位问题。等到小样本可以过拟合到接近 100%再跑完整训练遇到精度瓶颈就能确认是超参问题而不是代码问题。这个习惯帮我省下的排查时间远超过多跑几次小实验的时间。最后一个花了很多轮才养成的习惯每次实验记录 checkpoint 的同时把训练配置、随机种子和验证精度写入文件模型权重和配置永远成对保存。哪次结果好回头能精确知道用的是哪一组超参不会出现「上次跑的 84% 到底用的什么增强」这种黑匣子问题。如果你也准备从 ResNet 切到 Wide-ResNet建议先把这条路径完整走一遍希望帮到你。本文还有配套的精品资源点击获取