简介:面向深度学习初学者与图像分类项目开发者,以PyTorch框架调用CIFAR-100数据集,基于ResNet50完成完整训练与推理流程,解决从模型搭建、训练调参到可视化验证的一站式需求。资源共5个文件,包含3个Python脚本、1个日志文本和1个训练权重文件,压缩包整体84.24MB。训练脚本采用交叉熵损失与SGD优化器,并提供训练50个epochs后的模型权重,测试集准确率达62%;两套推理代码分别基于matplotlib和tkinter实现,便于在命令行或图形界面中直观查看图片、真值标签与预测结果。附带训练日志可供对比损失与准确率变化。目前已有5151人学习下载,适合希望快速上手ResNet图像分类、开展课程设计或进行模型对比实验的PyTorch用户。
1. 用 ResNet50 把 CIFAR-100 训到 90% 以上:Pytorch 实现里最该较真的不是模型
CIFAR-100 这个数据集和 ResNet50 的组合,是图像分类入门到进阶之间最常见的坎。很多人跑 ImageNet 级别的代码范式搬到 CIFAR-100 上直接翻车:验证集精度卡在 60% 上下、显存被 batch size 撑爆、训练到第 30 个 epoch 损失开始震荡。问题不在 ResNet50 本身,而在 CIFAR-100 的 32×32 小图会触发 ResNet50 第一批 stage 的下采样缺陷、Pytorch 训练循环里数据增强与学习率策略的匹配、以及标签层级关系没被利用这三个点上。
这篇笔记按我自己跑通的经验,从数据准备写到训练调度,再写到踩坑记录,全程用 Pytorch 官方风格的代码组织方式。你要是有 4GB 以上显存的 GPU,按文中的参数设置大概率能在 100 个 epoch 内把 CIFAR-100 验证精度推到 90% 附近。
2. 先看懂 ResNet50 在 CIFAR-100 上的处境:两个硬性改动与一个隐含先验
2.1 ResNet50 原始结构为什么在 32×32 输入上会掉点
ResNet50 的标准输入尺寸是 224×224,首层卷积核 stride 为 2,紧跟一个 stride 2 的 3×3 max pooling。CIFAR-100 的图像分辨率只有 32×32,直接套用原结构意味着第一步就把空间分辨率从 32 压到 8,后续 stage 的特征图几乎不具备空间判别力。
我在第一次实验里踩过这个坑:把 224 尺寸的 ResNet50 原封不动加载,输入改成 32×32 硬跑,验证精度只有 62%。这不是优化器的问题,是网络前几层把有效信息直接扔了。
最常用的修正方案是替换 stem 层:
import torch.nn as nn def cifar_stem(in_channels=3): """CIFAR 专用 stem:保留更多空间信息""" return nn.Sequential( nn.Conv2d(in_channels, 64, kernel_size=3, stride=1, padding=1, bias=False), nn.BatchNorm2d(64), nn.ReLU(inplace=True), # 去掉 224 输入的 stride=2 和 maxpool,避免 32x32 被过度压缩 )这里把原 ResNet50 的 7×7 stride 2 卷积和 3×3 maxpool 替换为单个 3×3 stride 1 卷积。替换后第一个 stage 输出的特征图从 32×32 直接进入残差块,空间信息保留完整。
注意替换后是否补一个 stride 2 的下采样:如果你想保持 ResNet50 后续四个 stage 的分辨率比例和预训练权重兼容,就在 stem 之后加一个 stride 2 的池化层。但 CIFAR-100 上常见做法是不加池化,让四个 stage 分别处理 32、16、8、4 分辨率的特征图,这样模型容量对 100 类小规模数据完全够用。
2.2 预训练权重要不要用:从 224×224 迁移到 32×32 的适配技巧
ImageNet 上预训练的 ResNet50 权重能不能迁移到 CIFAR-100?能,但必须处理 stem 层的形状不匹配。因为你的 stem 结构改了,官方权重里conv1的 7×7 卷积核形状和新 stem 不兼容。
我一般这样处理:
import torchvision.models as models def build_resnet50_cifar(num_classes=100, pretrained=True): model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1 if pretrained else None) # 替换 stem model.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False) model.maxpool = nn.Identity() # 去掉 maxpool # 替换分类头 model.fc = nn.Linear(model.fc.in_features, num_classes) return model如果你的代码跑在旧版 torchvision 上,weights=ResNet50_Weights.IMAGENET1K_V1可能不可用,改用pretrained=True并忽略 deprecation 警告即可。
关键点在conv1替换后,预训练权重里 7×7 卷积核的参数无法直接加载到 3×3 卷积里。Pytorch 的load_state_dict(strict=False)会跳过形状不匹配的层,但这样你就丢了 conv1 学到的边缘检测能力。更实用的做法:不加载这个层,让它在训练中自己学;因为 CIFAR-100 的 32×32 小图和 ImageNet 的 224×224 在底层特征上有较大分布差异,让 conv1 重新学反而更快收敛。
微调策略上,我把 stem 层的学习率调低到主干层的 0.1 倍,分类头学习率调高到主干的 10 倍。这样既能保留预训练特征提取能力,又能让新分类头快速适应 100 类任务。
2.3 CIFAR-100 的标签层级:fine label 之外还有个 coarse label
CIFAR-100 有 100 个 fine 类别,每 5 个 fine 类归入一个 coarse 超类,共 20 个超类。多数教程只用 fine label 做分类,但如果你借用了超类信息做辅助监督或课程学习,收敛速度能明显提升。
我这里建议至少把数据集结构拿到手:
from torchvision import datasets, transforms transform_train = transforms.Compose([ transforms.RandomCrop(32, padding=4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize((0.5071, 0.4865, 0.4409), (0.2673, 0.2564, 0.2762)), ]) trainset = datasets.CIFAR100(root='./data', train=True, download=True, transform=transform_train) print(trainset.classes) # 100 个 fine 类 print(trainset.class_to_idx)RandomCrop(32, padding=4)是 CIFAR 系列数据增强的基本操作:先把 32×32 图像 padding 到 40×40,再随机裁剪回 32。这一步等于在原始图像上做了随机平移,配合RandomHorizontalFlip可以把训练样本的有效数翻几倍。CIFAR-100 每类只有 500 张训练图,不增强基本不可能训好。
Normalize 的均值方差用的不是 ImageNet 的默认值,而是 CIFAR-100 数据集的统计值。用错归一化参数会让预训练权重发挥不出效果,验证集精度通常掉 2~3 个点。
3. 从数据处理到训练循环:一套能跑通 CIFAR-100 的 Pytorch 最小实现
3.1 数据加载与增强策略:cutout 和 mixup 该不该加
基础增强之外,CIFAR-100 上我发现 cutout(随机挖块)比 mixup 更容易配合 ResNet50 这类深层网络。cutout 不会改变标签分布,直接用原始 one-hot 标签即可;mixup 则需要额外管理混合比例和打乱索引,训练循环复杂不少。
cutout 我一般这样实现:
import random import torch class Cutout: """随机擦除一个正方形区域""" def __init__(self, size=8): self.size = size def __call__(self, img): if self.size <= 0: return img h, w = img.shape[1:] y = random.randint(0, h - 1) x = random.randint(0, w - 1) y1 = max(0, y - self.size // 2) y2 = min(h, y + self.size // 2) x1 = max(0, x - self.size // 2) x2 = min(w, x + self.size // 2) img[:, y1:y2, x1:x2] = 0 return img # 使用方式 transform_train = transforms.Compose([ transforms.RandomCrop(32, padding=4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize((0.5071, 0.4865, 0.4409), (0.2673, 0.2564, 0.2762)), Cutout(size=8), ])size=8意味着挖掉的区域边长约为 8 个像素,占 32×32 图像面积的 6% 左右。这个比例是经验值:太小没有正则化效果,太大则把关键目标区域挖掉导致训练不稳定。
cutout 的位置在 Normalize 之后,因为擦除操作要作用在归一化后的张量上,置 0 表示像素值等于均值,这样不会引入颜色偏移。如果你在 ToTensor 之前做 cutout,需要先把 PIL 图像转成 numpy 数组再处理,麻烦且容易出错。
3.2 训练循环的 BatchSize、学习率与 epoch 关系
CIFAR-100 全量训练集 50000 张图,ResNet50 每 epoch 的前向推理时间在单张 RTX 3060 上大约是 30~40 秒。batch size 我推荐 128:太小则梯度噪声大,太大则显存吃紧或学习率需要同步调大。
学习率调度上我采用 cosine annealing,这是 CIFAR 这种中小数据集上最容易出效果的调度器:
import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR model = build_resnet50_cifar(num_classes=100, pretrained=True) optimizer = optim.SGD(model.parameters(), lr=0.05, momentum=0.9, weight_decay=5e-4) scheduler = CosineAnnealingLR(optimizer, T_max=100, eta_min=0.0)lr=0.05对 batch size 128 和预训练模型并不算高,但对 CIFAR-100 来说,因为数据量少,过高的初始学习率会导致前几个 epoch 损失不降反升。T_max=100和训练总 epoch 数一致,这样学习率从 0.05 沿着余弦曲线衰减到接近 0。
SGD 带 momentum 和 weight decay 的组合比 Adam 在 ResNet 系列上更容易刷高精度。Adam 收敛快但最终精度通常低 2~3 个点。如果你想用 AdamW,weight decay 要开到 0.01 以上,而不是 SGD 的 5e-4。
完整训练循环框架如下:
from torch.utils.data import DataLoader trainloader = DataLoader(trainset, batch_size=128, shuffle=True, num_workers=4, pin_memory=True) testloader = DataLoader(testset, batch_size=128, shuffle=False, num_workers=4, pin_memory=True) criterion = nn.CrossEntropyLoss() model = model.cuda() for epoch in range(100): model.train() running_loss = 0.0 for inputs, labels in trainloader: inputs, labels = inputs.cuda(), labels.cuda() optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * inputs.size(0) scheduler.step() # 每个 epoch 结束后跑一次验证,记录精度pin_memory=True配合num_workers=4能让数据加载速度跟上 GPU 计算速度。如果你的机器是 Windows,num_workers超过 0 容易触发多进程 bug,建议设成 0,但训练速度会明显变慢。Linux 服务器上无脑用 4 或 8。
3.3 用 TensorBoard 监控训练:损失下降和精度跳跃的对应关系
我看训练过程从来不看单 epoch 的损失曲线,而是同时看训练损失、验证损失和验证精度三条曲线。CIFAR-100 上典型的现象是:前 10 个 epoch 验证精度从随机水平快速上升到 50% 左右,20 到 50 epoch 进入缓慢爬升期,最后的 20 epoch 精度每 5 个 epoch 才会跳动不到 0.5 个百分点。
这个阶段最容易让人提前终止训练。我建议当验证精度在 70 多分徘徊时,至少再跑 20 个 epoch 观察损失趋势。cosine 调度的优势就在此:后期学习率逐 epoch 变小,模型在小数据上做精调。
Pytorch 的torch.utils.tensorboard可以直接记录:
from torch.utils.tensorboard import SummaryWriter writer = SummaryWriter('runs/cifar100_resnet50') # 在每个 epoch 结束时 writer.add_scalar('val/acc', val_acc, epoch) writer.add_scalar('val/loss', val_loss, epoch) writer.add_scalar('train/loss', train_loss, epoch)启动可视化命令:
tensorboard --logdir=runs --samples_per_plugin=images=20--samples_per_plugin=images=20这个参数值得注意:默认 TensorBoard 可能会加载过多图片样本拖慢界面,手动限制到 20 张即可。CIFAR-100 的图像本身分辨率低,放大后的图形对调试数据增强是否合理很有帮助——比如你可以可视化 cutout 后的图像确认挖块位置没有覆盖目标主体。
4. 把验证精度推到 90% 的调参路径:标签平滑与模型微调的取舍
4.1 标签平滑对 CIFAR-100 的作用:从 87% 到 89% 的关键一步
ResNet50 在 CIFAR-100 上容易过拟合训练集,当训练精度接近 100% 而验证精度停在 88% 左右时,过拟合信号已经很明显。标签平滑是我最习惯用的正则化手段,它在损失函数层面把 one-hot 标签变成平滑分布,抑制模型对训练样本的过度自信。
Pytorch 没有内置的 label smoothing 损失函数,我自己实现了一个:
class LabelSmoothCrossEntropy(nn.Module): def __init__(self, smoothing=0.1): super().__init__() self.smoothing = smoothing def forward(self, logits, targets): log_probs = torch.log_softmax(logits, dim=-1) n_classes = logits.size(-1) with torch.no_grad(): true_dist = torch.zeros_like(log_probs) true_dist.fill_(self.smoothing / (n_classes - 1)) true_dist.scatter_(1, targets.unsqueeze(1), 1.0 - self.smoothing) return torch.mean(-true_dist * log_probs)smoothing=0.1是 ImageNet 规范里的常用值,在 CIFAR-100 上我试过 0.05、0.1、0.2 三档,0.1 综合效果最好。0.05 对过拟合的抑制不够,0.2 则会让训练损失下降变慢、精度损失明显。
注意这个实现里的true_dist.fill_用了torch.no_grad(),因为真实标签分布不应该参与梯度反传。如果你把true_dist直接当作需要梯度的张量参与 loss 计算,Pytorch 会报错或产生错误梯度路径。
4.2 冻结前几个 stage 做迁移学习:省时间还是省精度
预训练 ResNet50 从 ImageNet 迁移到 CIFAR-100,前两个 stage 学到的是低级边缘和纹理特征,对自然图像高度通用。常见做法是冻结layer1和layer2的参数,只微调后面两个 stage 和分类头。
for name, param in model.named_parameters(): if name.startswith('layer1') or name.startswith('layer2'): param.requires_grad = False这个方法最大的收益是显存占用下降、训练速度提升约 30%,但代价是最终验证精度可能比全量微调低 1~1.5 个点。CIFAR-100 和 ImageNet 的域差距不像医学图像那么大,前两层特征几乎可以原样复用。
我的建议:如果你的训练时间不紧张,就做全量微调;如果 GPU 资源有限或需要快速跑通验证,冻结前两层是最省事的方案。在 CIFAR-100 上全量微调和部分微调的最终精度差距没有你想象的大。
4.3 一个容易忽略的 trick:验证时用平移投票
验证集上对每张图做多次不同裁剪的预测取平均,可以稳定提升 0.5~1.5 个点。常见做法是对测试图像做 4 个角的裁剪加中心裁剪,然后翻转再预测一次,共 10 次前向取平均。
def predict_tta(model, image): # image: 3x32x32 tensor trans = transforms.Compose([ transforms.RandomCrop(32, padding=4), transforms.RandomHorizontalFlip(), ]) model.eval() with torch.no_grad(): preds = [] for _ in range(10): aug_img = trans(image).unsqueeze(0).cuda() preds.append(torch.softmax(model(aug_img), dim=1)) avg = torch.mean(torch.stack(preds), dim=0) return avg.argmax(dim=1)注意这里RandomCrop虽然带有“随机”字眼,但用在验证阶段相当于对图像做多次不同的数据增强,平均后的预测更鲁棒。CIFAR-100 每类验证图只有 100 张,10 次预测的总计算量等于 1000 张图,对 ResNet50 来说是秒级开销。
我实际用 TTA 时发现有个边界:当验证精度已经超过 92% 时,TTA 的提升可能只剩 0.1~0.2 个点,性价比变低。这时候不如优化模型结构或数据质量。
5. ResNet50 训练 CIFAR-100 的避坑指南:五个高频翻车现场与排查路径
5.1 验证集精度卡在 60%~70%:先看数据增强和归一化
现象:训练损失持续下降,但验证集精度始终在 60%~70% 区间上不去,像是模型在 20 个 epoch 后就停止学习了。
原因:最常见的两种情况叠加。一是使用了错误的归一化参数,CIFAR-100 的像素均值和方差与 ImageNet 差异较大,直接套用会打乱预训练特征的分布;二是数据增强里漏了RandomCrop或裁剪范围过小,模型见过训练图像太少。
解决:确认自己用的是 CIFAR-100 的统计值,同时在训练集上可视化增强后的图像,肉眼检查目标主体是否完整保留。再检查代码里是否在验证集上也加了随机增强,这会让验证精度被低估。
5.2 显存溢出(CUDA out of memory):batch size 与输入分辨率的组合问题
现象:RuntimeError: CUDA out of memory,提示无法分配 128MB 显存。
原因:CIFAR-100 图像虽然只有 32×32,但 ResNet50 的中间特征图数量众多,特别是layer4输出通道为 2048,batch size 128 时的特征图会占据大量显存。另一个常见坑:DataLoader加载的数据是 8-bit uint8,但输入网络前转成 float32,内存峰值翻倍。
解决:优先把 batch size 降到 64 或 32;如果非要 128,考虑用torch.utils.checkpoint做梯度检查点:
from torch.utils.checkpoint import checkpoint # 对 layer3 和 layer4 启用 model.layer3 = checkpoint_sequential(model.layer3, segments=2)checkpoint_sequential以时间换空间,把中间激活值不存显存、反向传播时重算。代价是训练速度下降约 30%,但 batch size 能保住了。
5.3 验证精度始终在 10% 左右:标签和类别数对不上
现象:模型很快收敛,但验证精度约等于 1/100,像是随机猜测。
原因:分类头输出维度不是 100。model.fc = nn.Linear(model.fc.in_features, num_classes)里num_classes变量被覆盖,或者你在加载旧 checkpoint 时strict=True没有把新分类头单独处理。
解决:打印模型输出的形状outputs.size(),确认是(batch, 100)。同时打印trainset.class_to_idx的条目数,确认数据集类别数为 100。如果是strict=False加载的 checkpoint,务必验证缺失和多余的 key 列表。
5.4 训练损失出现 NaN:学习率过大或 weight decay 设置不对
现象:训练正常到某个 epoch 后,损失突然变成 NaN,之后永不恢复。
原因:residual 网络分支上的 BatchNorm 在过大学习率下产生梯度爆炸,或者 weight decay 被应用在部分不恰当的地方导致数值不稳定。也可能是数据里含 NaN 像素值——直接下载的 CIFAR-100 理论上没问题,但如果你用了自定义的切分或人工处理过数据就要排查。
解决:先把学习率除以 10 重跑;如果仍然出现,检查梯度范数:
total_norm = 0.0 for p in model.parameters(): if p.grad is not None: param_norm = p.grad.data.norm(2) total_norm += param_norm.item() ** 2 total_norm = total_norm ** 0.5 print(total_norm)梯度范数超过 100 基本危险,超过 1000 大概率下一步就是 NaN。对 ResNet50 来说,clip_grad_norm_(model.parameters(), max_norm=5)可以兜底,但更好的做法是降低初始学习率到 0.01 量级。
5.5 训练和验证精度都在下降:检查数据加载器的 shuffle 状态
现象:输入代码没有任何改动,但某一轮训练精度和验证精度同步滑落,且并非正常的波动区间。
原因:trainloader在每次 epoch 开始前没有shuffle=True,这会让模型反复按相同顺序看到数据。更隐蔽的情况:trainset和testset共享了同一个transform对象,训练集增强后的数据被缓存污染了测试集。
解决:确认DataLoader(trainloader, shuffle=True)无误,并让transform_train和transform_test是两个不同的对象。Pytorch 中同一个Compose实例在多进程环境下共享状态,容易引发随机种子错乱。
6. 提升最终结果的三个进阶验证技巧:特征可视化与模型鲁棒性检查
6.1 用混淆矩阵定位易混淆类别:颜料盒与甲虫这对组合
CIFAR-100 的 20 个超类内都有容易错分的子类,比如超类“小交通工具”下的自行车、摩托车、公交车。我用混淆矩阵做过分析,发现 ResNet50 在超类内部混淆的概率远高于跨超类混淆。
from sklearn.metrics import confusion_matrix import numpy as np model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for images, labels in testloader: images, labels = images.cuda(), labels.cuda() outputs = model(images) preds = outputs.argmax(dim=1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.cpu().numpy()) cm = confusion_matrix(all_labels, all_preds) np.save('cifar100_confusion.npy', cm)我把混淆矩阵存成 numpy 文件后再用 matplotlib 可视化,热力图能一眼看出哪些类别互相干扰。这个信息对后续数据增强有用:如果“汽车”和“皮卡”高度混淆,可以针对这两类增加翻转和裁剪的强度或者采集更多相关样本。
6.2 权重可视化:分类头里每个类别的激活模式能验证模型学会了什么
对训练完成的模型,分类头的权重矩阵形状是 $[100, 2048]$,每一行对应一个类别的特征组合模式。对每个类别的权重向量做 L2 归一化后,用 cosine 相似度聚类,可以看到相似类别在特征空间的距离。
这个验证对 CIFAR-100 尤其有价值,因为 100 个细类在视觉上有很强的相关性,你希望同类超类的类别特征距离更近。如果模型输出的类别特征距离杂乱无章,说明训练可能陷入了局部最优。
6.3 梯度检查:用自己的样本验证反向传播路径
跑通模型后不要直接上市,先过一遍梯度检查:
model.train() for inputs, labels in trainloader: optimizer.zero_grad() outputs = model(inputs.cuda()) loss = criterion(outputs, labels.cuda()) loss.backward() # 检查每个参数的梯度是否非空 grads_ok = all([p.grad is not None and not torch.isnan(p.grad).any() for p in model.parameters() if p.requires_grad]) assert grads_ok, "存在梯度未计算或 NaN 的层" break这一步能抓出你自定义 stem 层或checkpoint_sequential导致的梯度断裂问题。比如你把maxpool替换成nn.Identity()后,某些版本 Pytorch 对Identity的梯度反推没问题,但如果你直接用nn.AvgPool2d且stride=2,参数更新路径可能会有偏差。
我自己的习惯:每改一次网络结构就跑一次这个梯度检查,再开始漫长的 100 epoch 训练,省得浪费时间发现训练完精度异常。
最后说句实在话,ResNet50 在 CIFAR-100 上已经不算绝对前沿的模型,但作为理解卷积神经网络训练流程的范本,它仍然值得完整跑通一遍。这套流程里踩过的坑、调过的参数,在迁移到 EfficientNet、ConvNeXt 甚至 transformer 图像分类模型时依然有效。希望帮到你。
本文还有配套的精品资源,点击获取