简介: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-100:epoch 数不变,增强不加,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, padding=4), # 先做随机裁剪,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", train=True, download=True, transform=transform_train) test_set = torchvision.datasets.CIFAR100( root="./data", train=False, download=True, transform=transform_test) train_loader = torch.utils.data.DataLoader( train_set, batch_size=128, shuffle=True, num_workers=4, pin_memory=True, drop_last=True) test_loader = torch.utils.data.DataLoader( test_set, batch_size=256, shuffle=False, num_workers=4)这段代码有三个容易踩的细节。第一,RandomCrop必须在ToTensor之前,因为裁剪操作期望输入是 PIL 图像,转成 Tensor 后再切会让数据分布发生偏移。第二,测试集的 transform 不要加任何随机操作,很多人复制训练集 transform 到测试集,导致测试精度上下波动好几个点。第三,drop_last=True只用在训练集,测试集必须保留全部数据,否则 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×224;CIFAR 版第一步用 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, stride=1): super().__init__() # 两个 3x3 卷积,卷积后接 BN,BN 后再接 ReLU self.conv1 = nn.Conv2d(in_ch, out_ch, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_ch) self.conv2 = nn.Conv2d(out_ch, out_ch, kernel_size=3, stride=1, padding=1, bias=False) 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_size=1, stride=stride, bias=False), 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_classes=100): super().__init__() # 第一层 3x3 卷积,输出 32x32,不降分辨率 self.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(64) # 3 个 stage,每个 stage 4 个 block,步长分别为 1/2/2 self.stage1 = self._make_stage(64, 64, blocks=4, stride=1) self.stage2 = self._make_stage(64, 128, blocks=4, stride=2) self.stage3 = self._make_stage(128, 256, blocks=4, stride=2) 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, stride=1)) 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)这段代码里有几个参数值得细说。bias=False在卷积层上必须设置,因为后面紧跟 BN 层,卷积的 bias 会被 BN 的平移参数抵消,留着只会浪费参数。shortcut只有在通道数或分辨率变化时才激活,其余 block 直接走恒等映射,这也是 ResNet 能训练深层的根基。每个 stage 的stride=2会把特征图从 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 + Nesterov | Adam 在 CIFAR 系上容易过拟合 |
| 初始学习率 | 0.1 | batch size 128 下的常用值 |
| 动量 | 0.9 | SGD 标准配置 |
| weight decay | 5e-4 | 只对卷积和全连接生效,BN 不衰减 |
| batch size | 128 | 单卡 10GB 内可以跑 WRN-24-8 |
| epoch | 300 | 低于 200 时 cosine 尾部没走完 |
| 学习率调度 | warmup 5 epoch + cosine | 避免初期 BN 统计量震荡 |
| label smoothing | 0.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 层,k=8 的参数量大约是 ResNet-24 的 6 倍,但精度能涨 5 个点以上,训练时间只多 2 到 3 倍,性价比远高于继续加深。
加宽之所以比加深有效,在于 CIFAR-100 每类样本太少,深层网络容易过拟合,而宽网络可以在较浅的深度下获得足够容量。配合 dropout 抑制过拟合后,Wide-ResNet 在 CIFAR-100 上一直是性价比最高的选择之一。常见配置是 WRN-28-10,28 层、k=10,但那是 36M 参数的大家伙,单卡训练比较吃力;我更推荐 WRN-24-8,精度只低 0.3 到 0.5 个点,显存和训练时间友好很多。
4.2 从 ResNet-24 改到 WRN-24-8:diff 思路与关键代码
从 ResNet-24 到 Wide-ResNet 不需要重写整个网络,改动集中在BasicBlock里加一个widen_factor参数,在残差相加之后、ReLU 之前插入 dropout。
class WideBasicBlock(nn.Module): def __init__(self, in_ch, out_ch, stride=1, dropout_rate=0.3): super().__init__() self.conv1 = nn.Conv2d(in_ch, out_ch, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_ch) self.conv2 = nn.Conv2d(out_ch, out_ch, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_ch) self.dropout = nn.Dropout(p=dropout_rate) self.shortcut = nn.Sequential() if stride != 1 or in_ch != out_ch: self.shortcut = nn.Sequential( nn.Conv2d(in_ch, out_ch, kernel_size=1, stride=stride, bias=False), 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_classes=100, k=8, dropout_rate=0.3): super().__init__() base_ch = 16 * k # Wide-ResNet 的起始通道数从 16 起步,不是 64 self.conv1 = nn.Conv2d(3, base_ch, kernel_size=3, stride=1, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(base_ch) self.stage1 = self._make_stage(base_ch, base_ch, blocks=4, stride=1, dropout_rate=dropout_rate) self.stage2 = self._make_stage(base_ch, base_ch * 2, blocks=4, stride=2, dropout_rate=dropout_rate) self.stage3 = self._make_stage(base_ch * 2, base_ch * 2, blocks=4, stride=2, dropout_rate=dropout_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 里做 stride=2 下采样。如果你跑起来发现显存不够,第一步先调低k到 6,精度影响约 1 个点;第二步才是减深度。
4.3 加宽因子、深度和显存的三角约束
加宽因子 k 直接决定了参数量和显存占用,选型时有个三角约束:宽度越大精度越高,但显存和单 epoch 时间同步上涨,而且收益递减。
| 模型配置 | 参数量级 | 单卡训练显存 | 测试精度区间 |
|---|---|---|---|
| ResNet-24 | 约 14M | 约 2GB | 77% ~ 79% |
| WRN-24-6 | 约 11M | 约 3.5GB | 80% ~ 82% |
| WRN-24-8 | 约 20M | 约 6GB | 82% ~ 84% |
| WRN-28-10 | 约 36M | 约 11GB | 83% ~ 85% |
这组数据来自我多次实验的区间范围,具体精度受增强强度、epoch 数和随机种子影响,但趋势是稳定的:k 从 6 涨到 8 收益约 1.5 个点,从 8 涨到 10 收益不到 1 个点,而同比例增长的训练时间在 k=10 时已经很难接受。
提示:如果你的显卡在 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 学习率曲线中途加 epoch,acc 反而掉回去
现象:用 cosine 调度跑完 200 epoch,acc 停在 82%。想继续提升,直接从 checkpoint 加载模型再跑 100 epoch,结果前 20 个 epoch 精度明显下降,最后只能恢复到 81% 左右,白白浪费计算资源。
原因:torch 的 cosine scheduler 是按总 epoch 数计算学习率的。续跑时如果重新创建 scheduler 并设置T_max=100,学习率会从峰值 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%,换成两张卡DistributedDataParallel,batch 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,建议先把这条路径完整走一遍,希望帮到你。
本文还有配套的精品资源,点击获取