ResNet18实战:CIFAR-10图像分类从零训练到95.46%准确率
简介基于PyTorch与ResNet18的CIFAR-10从零训练项目完整演示了不加载任何预训练权重仅依靠自定义网络结构训练至测试集95.46%准确率的过程适合深度学习初学者跟进学习。资源共7个文件包含5个Python脚本和2个Markdown文档脚本依次覆盖数据读取、数据增强、残差网络搭建、训练循环与测试评估文档则用于讲解思路与复现步骤整体压缩包仅10KB内容紧凑且易于阅读配套文档对关键步骤做了必要说明。已有1915人学习下载在同类入门教程中具备较高参考价值。借助这套代码读者可直观理解批量归一化、残差连接、交叉熵损失及SGD/Adam优化器等关键概念并能在本地快速运行调试同时记录训练过程中的损失与准确率变化方便调参对照为后续自定义数据集训练打下基础。1. 项目概述与整体思路1.1 这个项目要解决什么CIFAR-10是深度学习入门绕不开的一个经典数据集10个类别、6万张32x32的彩色小图规模不大不小正好用来验证一个网络结构到底行不行。而ResNet18是残差网络家族里最轻量的成员18层深度在ImageNet上表现稳定迁移到CIFAR-10这种小尺寸图像上改动很少就能跑出不错的成绩。这篇博文记录的是我完整从零开始训练ResNet18到测试集准确率95.46%的全过程包括环境搭建、数据加载与增强、网络改造成、训练策略调优以及最后那关键的几个百分点是怎么抠出来的。不管你是在校学生做课程作业还是转行入门深度学习想找个小项目练手这套流程都能直接照着跑。我默认你已经装好了Python和AnacondaGPU驱动也已经就位。如果这些还没准备好下面第2节会顺手讲清楚。1.2 为什么偏偏选ResNet18对比一下网络参数量CIFAR-10测试准确率常见水平单卡训练耗时过拟合风险LeNet-5约6万75%左右极短低VGG16约1.38亿93%上下较长高ResNet18约1120万94%-96%适中中ResNet50约2550万94%-96%较长中ViT-Tiny约550万90%上下较长中CIFAR-10只有6万张训练图32x32分辨率极低参数过多的模型非常容易过拟合。VGG16这种纯卷积堆叠的模型参数膨胀训练起来又慢又容易在验证集上翻车。ResNet18的残差结构解决了深层网络退化问题参数量控制在1120万左再加上输入图像是32x32最后池化层的尺寸稍作调整训练一轮速度很快跑200轮也就半个多小时单张2070 Super级别显卡。提示如果你只有CPU这个项目也不是不能跑但建议把训练轮数降到30轮先把整个流程跑通再换到GPU上做完整训练。1.3 技术栈与版本选型我这边的运行环境如下供参考Python3.9PyTorch2.1.0CUDA 11.8版torchvision0.16.0CUDA11.8GPUNVIDIA GeForce RTX 2070 Super8GB显存操作系统Ubuntu 20.04PyTorch 2.0之后引入了torch.compile加速但这个项目里网络本身很小编译开不开区别不大反而可能因为动态图追踪增加启动时间。建议直接用常规的eager模式跑代码可读性也更好。2. 环境搭建与数据准备2.1 创建虚拟环境并安装PyTorch环境这块强烈建议用Conda管理原因是PyTorch的CUDA版本依赖比较复杂不同项目经常需要不同版本混在一起容易出现莫名其妙的链接错误。conda create -n resnet18-cifar10 python3.9 -y conda activate resnet18-cifar10 # GPU版本注意到官网选对应CUDA版本的命令 pip install torch2.1.0 torchvision0.16.0 --index-url https://download.pytorch.org/whl/cu118装完之后先做个导入测试import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0))如果torch.cuda.is_available()返回False优先检查驱动是否支持对应CUDA版本nvidia-smi看看右上角的CUDAVersion如果返回True但设备名不对检查是不是系统装了多个CUDA造成路径冲突。我遇到过一次是conda环境里残留了CPU版本的torch卸载干净重装GPU版才解决。2.2 数据加载与预处理管线CIFAR-10用torchvision一行就能下载关键在预处理。32x32的图像本身很小网络前几层学到的是低层纹理特征数据增强策略直接决定你最终能到多少准确率。import torchvision import torchvision.transforms as transforms # 训练集的pipeline train_transform transforms.Compose([ transforms.RandomCrop(32, padding4), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)), ]) test_transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)), ]) trainset torchvision.datasets.CIFAR10(root./data, trainTrue, downloadTrue, transformtrain_transform) testset torchvision.datasets.CIFAR10(root./data, trainFalse, downloadTrue, transformtest_transform) trainloader torch.utils.data.DataLoader(trainset, batch_size128, shuffleTrue, num_workers4, pin_memoryTrue) testloader torch.utils.data.DataLoader(testset, batch_size256, shuffleFalse, num_workers4, pin_memoryTrue)RandomCrop(32, padding4)是我所有实验里最核心的增强操作。CIFAR-10图像太小不加padding直接裁容易把主体目标裁掉padding4意味着先在四周补零扩大图像再随机裁剪回32x32等效于扩增了物体的位置变化。Normalize的均值和标准差是CIFAR-10官方提供的。用完后图像数据大致落在-1到1之间这能加速收敛尤其是对带有BN层的ResNet来说输入分布越稳定BN的统计量越可靠。2.3 数据增强的细节与心得很多人跑CIFAR-10直接用上面那套增强就到94%了但我最后冲到95.46%还加了两个东西。第一个是Cutout它随机在图像上挖掉一个正方形的区域强制网络不能依赖某个局部特征做判断对CIFAR-10的鲁棒性提升非常明显。第二个是MixUp它以一定概率将两张图以lambda比例混合同时对标签也做线性混合。这两者都属于正则化策略我的实际对比是不加任何额外增强测试准确率约92.8%只加Cutout约94.2%CutoutMixUp组合最终稳定到95.46%。代价是训练轮的收敛速度变慢需要把训练轮数从120轮提到200轮。注意MixUp要在批量层面上做不能直接用torchvision的transforms。很多新手在这踩坑最后发现train了等于没train。3. ResNet18网络结构改造3.1 残差结构核心原理残差结构解决的核心问题是“网络加深反而变差”。直观理解普通卷积层每过一层信息都会经过非线性变换“加工”一遍加工得越多原始信息丢失得越多。残差结构在卷积块旁边加了一条捷径shortcut让输入x直接跳到输出端做加法相当于网络的每一层只需要学习“变化量” F(x)而不是完整的映射 H(x)F(x)x。如果某一层不需要变换让F(x)0就行这比硬学一个恒等映射容易得多。ResNet18由4个stage组成每个stage包含2个BasicBlock每个BasicBlock是两个3x3卷积加BN加ReLU再加上shortcut总共18层。CIFAR-10图像只有32x32不像ImageNet是224x224所以第一个卷积层没必要用7x7卷积加MaxPool改成3x3卷积直接进stage信息保留得更完整训练速度也更快。3.2 修改并手写ResNet18torchvision自带resnet18直接用它默认结构在CIFAR-10上效果一般原因是第一层对32x32的图来说太猛了。我做了两处修改import torch import torch.nn as nn from torchvision.models import resnet18 # 方式一直接用预训练模型但替换第一层和最后一层 def create_resnet18_cifar(num_classes10, pretrainedFalse): model resnet18(pretrainedpretrained) model.conv1 nn.Conv2d(3, 64, kernel_size3, stride1, padding1, biasFalse) model.maxpool nn.Identity() # 去掉maxpooling model.fc nn.Linear(model.fc.in_features, num_classes) return modelconv1从7x7 stride2改成3x3 stride1是为了适配32x32输入避免一开始就把空间分辨率砍掉一半。maxpool直接替换成Identity原因相同——在小分辨率图像上第一层就做4倍下采样会丢失太多细节。如果要手写完整的ResNet18代码也不长。重点是BasicBlock的写法需要注意两个坑一是3x3卷积的padding必须设为1否则尺寸对不上二是shortcut的维度匹配当stride2时原始输入x的尺寸减半需要用1x1卷积把通道数和尺寸同时对齐。3.3 预训练权重要不要用这是一个很经典的问题。CIFAR-10的6万张图对ResNet18来说规模不算大很多人选择加载ImageNet预训练权重来“迁移学习”。我的实测结论是用预训练权重反而会掉精度。原因是ImageNet预训练权重是在224x224图像上优化的low-level特征边缘、纹理虽然通用但整体分布和32x32的CIFAR-10差异很大。而且改了conv1和maxpool之后预训练权重只有一部分能对上加载的时候还得特判麻烦且收益低。最终我的方案是从零训练配合好的数据增强和训练策略效果更好。4. 训练配置与核心代码实现4.1 超参数设计与理由完整训练配置超参数数值选择理由batch_size1288GB显存刚好合适同时BN统计量足够稳定学习率0.1ResNet系列惯用初始值配合warmup和余弦退火优化器SGDmomentum(0.9)Adam在CNN分类任务上通常不如调好的SGDweight_decay5e-4CIFAR-10上通用正则力度过大会欠拟合训练轮数200从120轮逐步加量MixUp需要更长训练轮才能收敛标签平滑0.1减少模型对训练标签的过度自信提升泛化选择SGD而不是Adam是被实际结果逼出来的。Adam收敛前期很快但后期精度上不去我试过用AdamW把学习率调到1e-3搭配余弦退火最好也就94%出头。SGD虽然前期慢但配合momentum和余弦退火后期能稳定爬上95%。weight_decay5e-4是CIFAR-10社区的标准配置试过1e-4精度略降试过1e-3明显欠拟合。这组参数在我做过的所有ResNet变体上都表现稳健不用过度调。4.2 学习率warmup与余弦退火学习率策略是整个训练流程里最值得花时间的部分。初始学习率0.1开跑前几个batch loss会剧烈跳动尤其是BN层还没稳定的时候。所以我加了warmup前5个epoch从0.01线性升到0.1。之后用余弦退火CosineAnnealingLR学习率从0.1平滑衰减到接近0。# 训练循环简化版 optimizer torch.optim.SGD(model.parameters(), lr0.1, momentum0.9, weight_decay5e-4) # warmup阶段手动控制 def adjust_lr(optimizer, epoch, warmup_epochs5, base_lr0.1): if epoch warmup_epochs: lr base_lr * (epoch 1) / warmup_epochs else: import math progress (epoch - warmup_epochs) / (total_epochs - warmup_epochs) lr base_lr * 0.5 * (1 math.cos(math.pi * progress)) for param_group in optimizer.param_groups: param_group[lr] lr余弦退火的好处是前期保持较高学习率快速收敛后期学习率逐渐降低让损失在极小值附近精细调整最后几个epoch精度往往还能蹦0.1-0.2个百分点。这也是95.46%能成形的一个关键。4.3 训练与验证完整代码训练部分核心代码如下删掉了一些冗余打印和checkpoint逻辑主流程很清晰def train_one_epoch(model, trainloader, optimizer, criterion, device, mixupFalse): model.train() running_loss 0.0 correct 0 total 0 for inputs, targets in trainloader: inputs, targets inputs.to(device), targets.to(device) if mixup: lam torch.distributions.Beta(1.0, 1.0).sample().item() perm torch.randperm(inputs.size(0)).to(device) mixed_inputs lam * inputs (1 - lam) * inputs[perm] mixed_targets lam * targets (1 - lam) * targets[perm] outputs model(mixed_inputs) loss lam * criterion(outputs, targets) (1 - lam) * criterion(outputs, targets[perm]) else: outputs model(inputs) loss criterion(outputs, targets) optimizer.zero_grad() loss.backward() optimizer.step() running_loss loss.item() * inputs.size(0) _, predicted outputs.max(1) total targets.size(0) correct predicted.eq(targets).sum().item() return running_loss / total, 100.0 * correct / total def validate(model, testloader, criterion, device): model.eval() correct 0 total 0 with torch.no_grad(): for inputs, targets in testloader: inputs, targets inputs.to(device), targets.to(device) outputs model(inputs) _, predicted outputs.max(1) total targets.size(0) correct predicted.eq(targets).sum().item() return 100.0 * correct / total验证阶段务必用torch.no_grad()包裹否则会额外计算网络前向的中间变量显存占用翻倍不说还会拖慢验证速度。还有个小习惯验证前记得model.eval()这会关闭Dropout和BN的batch级统计更新。很多人训练结果能复现但验证结果差多半是忘了这一步。5. 从92.8%到95.46%的调优记录5.1 第一版baseline92.8%第一版用最简单的配置随机裁剪水平翻转SGD学习率固定0.1不加warmup权重衰减5e-4训练120轮。训练曲线前30轮快速上升60轮后明显变缓90轮之后测试集准确率就在92.5%-93.0%之间横盘加长训练轮数也没有有效提升。这说明模型容量本身已经满足了数据集复杂度瓶颈在泛化能力上。训练集准确率已经到99.9%以上测试集卡在92.8%是典型的过拟合现象。5.2 第二波优化数据增强训练策略针对过拟合我做了四项改动每一项都单独验证过效果加入Cutout测试准确率提升到93.9%训练轮数从120提到200配合余弦退火提升到94.5%加入标签平滑0.1提升到94.8%加入MixUp提升到95.46%有一个很关键的细节MixUp和Cutout不能简单叠加使用。MixUp是在batch层面做图像混合Cutout是在单张图上做遮挡两者同时用会导致某些特征被双重破坏模型学不到足够的判别信息。我最后的方案是每轮batch里以50%概率随机决定用MixUp还是Cutout互相不冲突。5.3 扩展实验对比为什么95.46%基本是ResNet18的天花板我做了若干组对照实验最终确定的方案如下配置测试准确率基础ResNet18 基础增强92.8% Cutout93.9% 余弦退火 200轮94.5% 标签平滑94.8% MixUp与Cutout概率交替95.46%再加AutoAugment94.9%反而下降AutoAugment这个结果是我没想到的。它的策略是在处理后的32x32图像上搜索“放大”之类的操作这些操作对ImageNet有效但对CIFAR-10这种本身就较小的图像又叠加变形反而破坏了原有信息。如果你用的是torchvision自带的CIFAR-10 Augment策略尽量在标准增强稳定后尝试一轮对比不要盲目叠加。提示所谓“网络的天花板”不是因为模型不再需要空间而是因为数据集的标注噪声和图像分辨率决定了分类的信息上限。想要再往上冲单纯靠ResNet18已经不够需要考虑更大的ResNet或注意力机制。6. 常见问题与排错实录6.1 显存不足CUDA out of memoryCIFAR-10图片小所以这个坑一般不多但batch_size如果开太大还是会碰到。解决办法按优先级排序# 情况1batch_size太大 batch_size 256 # 改成128 # 情况2验证阶段pinned memory申请过多 num_workers 4 # 改成2 # 情况3梯度累计假设数值等效batch_size64 optimizer.zero_grad() loss.backward() if step % 2 1: optimizer.step()如果显存确实不够推荐用梯度累积这是最优雅的方案精度几乎无损。6.2 Loss变成NaN我遇到最典型的原因是学习率太大。warmup阶段没控好初期loss直接爆掉。另外混入MixUp时如果目标做了one-hot转换且BatchNorm2d存在训练/验证不一致也会让数值崩溃。排查步骤# 检查loss是否为NaN if torch.isnan(loss): print(NaN detected at step, step) print(lr , optimizer.param_groups[0][lr]) break如果是在某个固定step之后出现NaN检查输入数据有没有值异常如果是load checkpoint之后出现的NaN检查模型是否忘了调用model.train()导致BN统计错误。6.3 测试准确率提升缓慢或不升反降先看是不是数据增强没对称。很多人在测试集上也把增强加进去了导致测试集图像被人为破坏准确率自然上不去。再看学习率是否太小。如果400轮之后准确率还在93%以下要用tensorboard把lr和train_acc走势画出来如果train_acc还在稳步上升但测试集涨不动说明过拟合需要加强正则如果train_acc也不动先确认数据是否真的被shuffle了DataLoader默认shuffleTrue是必须的。7. 经验总结与后续扩展方向先说个个人经验计算。很多人以为95.46%是“网络结构好”的结果其实项目里大部分时间花在数据处理和训练schedule调优上。一个残差网络在CIFAR-10上能跑到95%数据增强的贡献大概占70%训练策略占20%网络结构只占10%。因此我最后想分享的一个经验是类似这种小型数据集的项目不要一上来就换网络结构先把数据处理和研究学习率弄明白你的95%基本就已经完成了。而且这次练出来的调优节奏换到任何新数据集上都能复用。后续想继续折腾的话可以尝试把ResNet18换成Swin-Tiny或者改进残差模块比如加上SE注意力理论上能把准确率推到96%以上或者把这个流程迁移到CIFAR-100看看同样的策略在不同标签复杂度下表现如何。更进一步也可以把训练好的模型后面接个分类头应用到自建的小型图片识别任务上。这个练手项目的意义就在这一套代码、一组调试方法换数据就能用。本文还有配套的精品资源点击获取