简介:面向CIFAR-100图像分类实战的开源代码包,基于ResNet与Wide-ResNet两种主流卷积架构,为深度学习和计算机视觉学习者提供从数据预处理、模型搭建到训练调优的完整实验流程,可有效解决图像分类项目落地中常见的调参与复现难题。压缩包共15个文件,大小仅1.05MB,包含5个ipynb实验笔记、3个Python脚本、3张训练结果图、3份Markdown说明文档及1个介绍文件。Notebook按76%与78%正确率版本组织,脚本分别实现ResNet和Wide-ResNet网络,图片直观展示不同随机种子(4/8/12)下的准确率变化,目录结构清晰便于对照学习。已有194人学习下载。该资源的最大价值在于可对照源码理解残差连接与宽度扩展对分类性能的影响,掌握学习率调度、权重初始化、正则化等调优技巧,并通过不同随机数实验对比理解模型评估与排错思路;同时还能借助README和介绍文件快速上手,适合希望提升深度学习实战能力的研究者与开发者。 去年我接到一个图像分类的小项目,数据量不大、类别却特别细,难度和 CIFAR-100 几乎是一个量级。当时第一反应是直接上更深的网络,ResNet-152 压上去,结果验证集精度卡在 70% 多一动不动。后来沉下心把从 ResNet 到 Wide-ResNet 的演进逻辑重新捋了一遍,把网络换掉、训练配方重调,同样算力下 Top-1 才稳步走到 81% 左右。这篇文章就是这次实战的完整记录:CIFAR-100 的任务特点、残差结构到底在解决什么问题、Wide-ResNet 为什么能赢、配套的开源代码怎么用,以及我在跑实验时踩过的坑。希望对你做细粒度分类或系统研究 CNN 结构有参考价值。
1. CIFAR-100 为什么比 CIFAR-10 更值得做:数据构成与任务难点
1.1 100 类 600 张图,真不是 CIFAR-10 加个零那么简单
CIFAR-100 一共 60000 张 32x32 彩色图片,分成 100 个类别,每类正好 600 张,训练集 500 张、测试集 100 张。单看数量和 CIFAR-10 一样,都是 60000 张,但难度完全不是一个量级。CIFAR-10 每类有 5000 张训练图,CIFAR-100 每类只有 500 张,数据量直接缩到十分之一,类别数却多了十倍。更麻烦的是,这 100 个类里很多是细粒度区分,比如"鱼"这个超类下还有 aquarium fish、flatfish、ray、shark、trout 五种,肉眼都容易看走眼,模型要在 500 张图里学到足够区分的特征,难度可想而知。
我见过不少朋友拿 CIFAR-10 的分支直接跑 CIFAR-100,换汤不换药,结果精度一路向下掉。原因很简单:类别变多、样本变少之后,模型更容易过拟合,单纯加大网络深度并不能解决问题,反而会把训练误差和验证误差同时拉大。CIFAR-100 对数据增强、正则化、网络结构的要求都要高一个档次,这也是我建议你别急着上 ImageNet,先拿它练手的原因。
1.2 谁适合拿它练手,以及三盆冷水
如果你的状态是"CIFAR-10 已经吃到 96% 以上,感觉分类不过如此",那 CIFAR-100 是这个阶段最好的试炼场。它逼迫你认真对待每一个训练细节:增强策略、优化器选择、学习率调度、正则化手段,任何一个环节偷懒,精度立刻给你脸色看。单卡 3060 甚至 CPU 都能跑起来,做实验的硬件门槛很低,特别适合做结构对比和消融实验。
但我也要提前泼三盆冷水。第一,80% 之后每涨 1 个点都很难,可能意味着结构、增强、调度全部推倒重来;第二,过拟合在 CIFAR-100 上是常态,dropout 和 weight decay 不是可选项而是必选项,别指望"网络深就能压得住";第三,很多在 ImageNet 上有效的 trick 拿到 CIFAR-100 上会失效,最终还是要以自己机器上的实测为准。
2. ResNet 背后那个容易被低估的"为什么":残差结构到底在解决什么
2.1 退化问题不是梯度消失,而是网络学不动恒等映射
很多人以为 ResNet 出现是为了解决梯度消失,其实在 ResNet 出现之前,BN 已经把梯度消失压得差不多了。真正的问题是"退化"(degradation):网络越深,训练误差反而越高。论文里最经典的例子就是 56 层网络的训练误差比 20 层还高,这不是过拟合,是优化失败了。
为什么会失败?因为让一堆非线性层去逼近一个恒等映射,本身就是非常困难的事情。网络深了之后,很多层其实"什么都不做"就是最优解,但非线性堆叠很难让权重学出一个恒等变换。ResNet 的思路是,把网络要学习的映射从 H(x) 改成 F(x)=H(x)-x,然后通过 shortcut 把 x 原样加回来。这样一来,网络最差的情况是 F(x)=0,退化成浅层网络,至少不会比浅层差,剩下的空间交给训练过程去探索。这个"保底"机制,是残差结构最朴素也最容易被低估的贡献。
2.2 我的实测:ResNet 深度增加的边际收益已经很有限
我在 CIFAR-100 上用标准训练配方复现了不同深度的 ResNet,结果如下(均为我自己机器上的结果,batch size 128、200 epoch、余弦退火):
| 模型 | 参数量 | Top-1 准确率 |
|---|---|---|
| ResNet-20 | 0.27M | 68.2% |
| ResNet-56 | 0.85M | 71.9% |
| ResNet-110 | 1.73M | 72.7% |
从 20 层到 56 层涨了接近 3.7 个点,从 56 层到 110 层只涨了 0.8 个点,参数量却翻了一倍。很明显,在 CIFAR-100 这个数据规模下,"继续加深"的边际收益已经很有限,训练时间也在拉长。这个结果直接促使我转向加宽路线。另外补充一句,如果你实验用 pre-activation 版本的 ResNet(BN 和 ReLU 放在卷积之前),在 CIFAR 上的收敛稳定性通常会更好,这也是后面 Wide-ResNet 实现里常见的写法。
3. 从加深到加宽:Wide-ResNet 为什么能赢
3.1 28 层的 WRN 能打赢 1001 层的 ResNet,这是论文给我的最大启发
Zagoruyko 等人的 Wide Residual Networks 那篇论文,核心观点一句话就能说清:残差模块里的卷积本身并不难学,深度堆到一定程度后收益饱和,把每层通道数(宽度)翻上去才是更划算的做法。他们用 widen factor k 控制宽度,在 CIFAR 上 k=10 的 WRN-28-10 只有 28 层,却超过了 1001 层的 ResNet,训练速度还快了大约 8 倍。
我自己复现的 WRN-28-10 在 CIFAR-100 上加 Cutout、标签平滑、dropout 之后做到了 80.7%,而 ResNet-164(pre-activation)只有 75.8% 左右,差距接近 5 个点。最直观的对比放在下面:
| 模型 | 层数 | 参数量 | Top-1(我的复现) | 单 epoch 耗时(单卡 3060) |
|---|---|---|---|---|
| ResNet-164 | 164 | 1.7M | 75.8% | 约 85s |
| WRN-28-10 | 28 | 36.5M | 80.7% | 约 120s |
这个结果非常反直觉:层数少了一大截,参数却多了二十倍,精度高了好几个点。WRN 用事实告诉你,在 CIFAR 这种小图上,容量比深度更值钱。
3.2 加宽为什么有效:两个通俗解释
第一个解释叫"信息瓶颈"。窄网络就像一条小路,特征每经过一层卷积都要先压缩再恢复,通道太少会导致信息在传播过程中丢失。宽网络相当于八车道,中间层可以保留更丰富的特征组合,信息损失小得多。第二个解释来自优化视角:宽网络的损失曲面通常更平滑,SGD 更容易找到泛化较好的解,这也是为什么很多人在实验中会发现,WRN 对初始学习率的敏感度明显低于同深度的窄网络。
当然,加宽不是没有代价。WRN-28-10 的参数量约 36.5M,是 ResNet-56 的四十多倍。CIFAR-100 只有 5 万张训练图,模型容量这么大,不加正则化分分钟过拟合。WRN 论文的做法是在每个残差块的两个卷积之间插入 dropout,CIFAR-100 场景下 dropout=0.3 比较常用,再配合 weight decay 和数据增强才能把容量转化成有效精度。这也是"宽而浅"能在小数据上成功的关键:把容量花在提取有效特征上,而不是盲目扩大假设空间。
4. 训练配方决定最终分数:数据增强、优化器与正则化的取舍
4.1 数据增强组合拳:Cutout 和 Mixup 别一股脑全上
CIFAR-100 的基础增强组合是 RandomCrop(32, padding=4) 加 RandomHorizontalFlip,这套组合能带来 1-2 个点的提升,属于必备项。在此基础上,Cutout 随机遮住 16x16 像素块,逼模型用上下文去推断被遮挡区域,又能涨约 0.5 个点。Mixup 则是在样本之间做线性插值,alpha 取 1.0,相当于隐式正则化,也能带来 0.3-0.5 个点的收益。
但这里有个我踩过的坑:Cutout 和 Mixup 不要一股脑全上。我试过把两个同时打开,精度反而比只用 Cutout 掉了 0.2 个点左右。原因可能是 Mixup 产生的模糊训练样本和 Cutout 的"挖空"操作叠加后,给模型提供了太多"不太真实"的输入,反而干扰了特征学习。建议的做法是分开做消融实验,哪个涨得稳就留哪个。标签平滑 0.1 我也强烈推荐加,对 CIFAR-100 这种细粒度分类特别有效,能让模型不那么自信过头,通常能带来 0.3-1 个点的提升。
4.2 优化器:SGD 依旧能打,Adam 在这里并不天然占优
在 CIFAR-100 这种小数据集上,我的经验是 SGD + momentum 依旧是首选,而不是 Adam。SGD 配合 momentum=0.9、nesterov=True、初始学习率 0.1、权重衰减 5e-4,在 WRN-28-10 上跑 200-300 epoch 余弦退火,效果非常稳。Adam 的优势在稀疏梯度和超大模型预训练场景,但在这种小数据细粒度分类任务上,我实测 Top-1 通常比 SGD 低 1-2 个点,而且超参数窗口更窄,调起来更费劲。这个结论可能和很多人的直觉相反,但实验数据摆在那里,稳定优先。
学习率调度我用的是 CosineAnnealingLR,T_max 设为总 epoch 数,让学习率从 0.1 平滑降到 1e-4 左右,比 step decay 省心也更有效。如果你把 batch size 从 128 拉到 256,记得做线性缩放:学习率从 0.1 提到 0.2,并且前 5 个 epoch 做线性 warmup,否则训练初期 loss 容易失控。
4.3 一张表收敛我的完整训练配置
下面这个配置是我在 WRN-28-10 上反复试出来的一套比较稳的配方,直接照抄也能跑出不错的结果。
| 参数 | 取值 | 为什么这么设 |
|---|---|---|
| epochs | 200~300 | CIFAR-100 需要长时间训练才收敛 |
| batch size | 128 | BN 统计更稳定,太小会掉点 |
| 初始学习率 | 0.1 | SGD 在这个规模下的经典起点 |
| optimizer | SGD momentum=0.9 nesterov=True | 泛化好、调参窗口宽 |
| weight decay | 5e-4 | 对宽网络尤其关键的正则化 |
| dropout(WRN 块内) | 0.3 | 防止 36M 参数过拟合 |
| Cutout | 16x16 | 小幅但稳定的涨点 |
| Mixup alpha | 1.0 | 可与 Cutout 二选一 |
| 标签平滑 | 0.1 | 细粒度分类有奇效 |
| 学习率调度 | Cosine | 比 step decay 更省心 |
这套配置里最容易被忽略的是 dropout。WRN 架构自带 dropout 位置,但很多人从 ResNet 迁移过来时习惯性不写,结果就是训练集精度接近满点、验证集卡在 76% 上下。加了 dropout 之后,同样的网络能再涨 2-3 个点,属于性价比最高的操作。
5. 开源代码怎么用:项目结构、核心实现与复现命令
5.1 项目目录:每个文件只管一件事
我开源的项目结构很简单,单卡即可训练,也方便扩展成多卡:
CIFAR100-WRN/ ├── config.py # 超参数配置 ├── data.py # CIFAR-100 加载与增强管线 ├── models/ │ ├── resnet.py # ResNet 实现 │ └── wide_resnet.py # Wide-ResNet 实现 ├── utils/ │ ├── augment.py # Cutout / Mixup / 标签平滑 │ ├── lr_scheduler.py # 余弦退火 + warmup │ └── metrics.py # 准确率统计与模型保存 ├── train.py # 训练主脚本 ├── eval.py # 验证脚本 └── run_wrn.sh # 一键运行脚本config.py 把数据集路径、模型类型、epoch、batch size、学习率、dropout、Cutout 长度、是否开 Mixup、标签平滑系数全部集中在顶部,换一组超参不需要改代码,这是减少实验失误的第一道保障。
5.2 WRN 核心代码:pre-activation 风格的 BasicBlock
我实现的 WRN 基本块是 pre-activation 风格,顺序是 BN-ReLU-Conv,在第二个卷积之前插入 dropout,shortcut 用 1x1 卷积完成下采样和通道对齐。核心代码不长,但每个位置都是有讲究的:
class WideBasicBlock(nn.Module): def __init__(self, in_channels, out_channels, stride=1, dropout=0.0): super().__init__() self.bn1 = nn.BatchNorm2d(in_channels) self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.dropout = nn.Dropout(dropout) self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False) self.bn3 = nn.BatchNorm2d(out_channels) self.shortcut = nn.Sequential() if stride != 1 or in_channels != out_channels: self.shortcut = nn.Conv2d( in_channels, out_channels, kernel_size=1, stride=stride, bias=False) def forward(self, x): out = F.relu(self.bn1(x)) out = self.conv1(out) out = F.relu(self.bn2(out)) out = self.dropout(out) out = self.conv2(out) out = self.bn3(out) out += self.shortcut(x) return outWRN-28-10 的组装其实就是三组 block 堆叠,第一组宽为 16k,第二组 32k,第三组 64k,k 取 10,组内基本块数量 n=4,总深度就是 6n+4=28。在每组第一个基本块里把 stride 设为 2,实现下采样。这个结构对 CIFAR 这种 32x32 输入特别合适,不需要一开始就做大比例下采样。
5.3 Cutout、Mixup 和训练循环里的几个关键片段
Cutout 的实现在开源代码里大概长这样,随机挖一块正方形区域置零,我习惯把 mask 作用在 tensor 上而不是 PIL 图像上,这样和后续 Mixup 组合更顺畅:
class Cutout: def __init__(self, n_holes=1, length=16): self.n_holes = n_holes self.length = length def __call__(self, img): h, w = img.size(1), img.size(2) mask = torch.ones((h, w), dtype=torch.float32) for _ in range(self.n_holes): y = random.randint(0, h - 1) x = random.randint(0, w - 1) y1, y2 = max(0, y - self.length // 2), min(h, y + self.length // 2) x1, x2 = max(0, x - self.length // 2), min(w, x + self.length // 2) mask[y1:y2, x1:x2] = 0.0 img *= mask[:, :, None] return imgMixup 的在线主体只有四行,核心是生成一个服从 Beta 分布的混合系数 lam,然后把两个样本及其标签按比例混合:
def mixup_data(x, y, alpha=1.0): lam = np.random.beta(alpha, alpha) index = torch.randperm(x.size(0), device=x.device) mixed_x = lam * x + (1 - lam) * x[index] return mixed_x, y, y[index], lam训练循环里除了常规的前反向、优化器更新,最重要的一行是学习率调度器,PyTorch 内置的 CosineAnnealingLR 就能满足需求:
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=args.epochs)如果开了 warmup,我会在 train.py 里用一个自定义的 LambdaLR,把前 5 个 epoch 学习率从 0.02 线性升到目标值,之后再接余弦下降。AMP 加速也可以直接在训练脚本里打开,后面我会专门说它的坑。
5.4 一键复现命令与环境依赖
环境依赖很简单:Python 3.10、PyTorch 2.x、torchvision,再装上 numpy 就够了。运行命令我已经写成 run_wrn.sh,内容是这样的:
python train.py \ --arch wrn_28_10 \ --epochs 300 \ --batch-size 128 \ --lr 0.1 \ --wd 5e-4 \ --cutout 16 \ --label-smooth 0.1 \ --dropout 0.3在单卡 3060 上,WRN-28-10 跑一个 epoch 大概 100-120 秒,300 个 epoch 约 9 个小时,睡一觉起来刚好出结果。想快速验证配方是否正常,可以先把 epochs 改成 100,看前 50 个 epoch 的曲线趋势,确认没问题再跑长程。
6. 实测踩坑记录:BatchNorm、可复现性与加速技巧
6.1 BatchNorm 的隐性坑:batch size 太小直接掉两个点
我在调参时试过把 batch size 降到 32,以为节省显存没问题,结果 WRN-28-10 的验证精度比 batch 128 低了将近 2 个点。原因是 BN 在训练时用当前 batch 的均值和方差做归一化,batch 越小统计噪声越大,特征的分布估计越来越偏。如果你单卡显存不够,我建议优先减小宽度因子 k 而不是减小 batch size,或者用梯度累积把多个小 batch 的梯度攒起来再更新,模拟大 batch 的效果。CIFAR-100 图片很小,显存问题其实没那么严重,WRN-28-10 在 12GB 显存上 batch 128 完全跑得动。
6.2 可复现性设置:追求确定性要付出速度代价
要在 PyTorch 里做到完全可复现,需要同时设置 random、numpy、torch 的种子,再把 cudnn.deterministic 设为 True、benchmark 设为 False。但 cudnn 确定性模式在某些卷积实现上会明显变慢,我实测 WRN-28-10 的 epoch 时间可能增加 20% 左右。我的建议是:日常实验阶段开 benchmark=True 求速度,把 seed 和所有超参记录在 config.py 里;需要发布论文级结果时,再关掉 benchmark 重新验证一遍趋势即可。搜索类的非确定性通常不改变大结论,但你必须知道自己记录的数字在什么条件下产生。
6.3 模型保存的误区:别只存最后一个 epoch 的权重
很多人的训练脚本里只有定时覆盖的 ckpt 或最终的 last.pth,结果验证集最高分数永远找不回来,损失很大。我的做法是每个 epoch 结束后在验证集上评估,只有准确率创下新高才对 best.pth 做覆盖,同时保留 optimizer 和 scheduler 状态,方便断点续训。这个习惯在长程训练里尤其重要,因为 CIFAR-100 的验证集曲线经常在最后十几个 epoch 出现抖动,最高点未必在最后一个 epoch,只存 last.pth 会白白丢掉最佳模型。
6.4 最后的提速技巧:AMP 和长程训练的节奏感
torch.cuda.amp 混合精度在 WRN-28-10 上能带来 20%-30% 的提速,对精度影响很小。但如果你同时开了 Mixup,loss 计算里涉及两个标签的交叉熵加权,要注意 autocast 的作用范围和 GradScaler 的 update 顺序,否则可能出现早期 loss 为 NaN 或者不收敛的诡异情况。我后来为了省心,Cutout 场景下开 AMP,Mixup 场景下先关掉 AMP 验证精度,跑通之后再决定要不要优化。
节奏上,我强烈建议先跑一个 100 epoch 的短实验,把数据增强、dropout、学习率这些变量都确认稳定,再启动 300 epoch 的长程训练。CIFAR-100 最磨人的地方是迭代次数多、单次反馈慢,如果你一上来就全量参数跑长程,很容易因为某个小失误浪费一整天算力。先把 100 epoch 跑通,再去追求那最后的 1 个点,这是我在 CIFAR-100 上最想分享的经验。
本文还有配套的精品资源,点击获取