资讯动态

从零手写ResNet18:CIFAR-10图像分类准确率95.46%实战记录

发布时间:2026/8/31 22:19:59 来源:尧图企业网站定制
简介一份面向深度学习初学者的PyTorch实战项目从零开始训练ResNet18网络完成CIFAR-10图像分类不依赖任何预训练权重最终在测试集上达到95.46%的准确率。内容完整覆盖数据预处理、数据加载、残差块实现、批量归一化、ReLU激活、全局平均池化、全连接分类、损失函数与优化器配置、训练迭代、测试评估以及模型保存加载等环节并结合随机翻转、裁剪等数据增强手段说明如何抑制过拟合可帮助读者系统掌握ResNet结构和PyTorch训练范式。压缩包共7个文件包括5个Python脚本和2个Markdown说明文档脚本分别实现模型定义、数据读取、训练、测试与数据增强工具文档补充项目说明和使用指导目录安排清晰便于逐个模块理解与复用。包体仅10KB轻量精悍下载与阅读都非常方便。资源已有1915人学习适合希望深入理解卷积神经网络原理、动手完成完整图像分类任务的初学者也可作为课程设计与算法复现的参考基线。 最近我拿PyTorch从零手写了一个ResNet18在Cifar10数据集上做图像分类训练最后测试集准确率刷到了95.46%。这个结果放在今天不算顶配但整个过程不依赖任何预训练权重完全是从0开始跑出来的。写这篇文章是因为踩了不少坑也发现很多新手拿到CIFAR-10就直接torchvision.models.resnet18(pretrainedTrue)结果在32x32的小图上表现并不好。我想把这一条完整的路记录下来怎么搭网络、怎么加载数据、怎么调参以及最终95.46%是怎么一步一步挤出来的。无论你是刚接触PyTorch还是想找一个标准的分类任务练手这篇文章应该都能给你一点参考。1. 为什么从零手写ResNet18而不是直接调官方模型1.1 官方模型在CIFAR-10上并不直接适用torchvision里的resnet18是按照224x224的ImageNet设计。第一层是7x7卷积stride2再接一个3x3 maxpool这两个操作一下子把32x32的输入压到8x8信息损失非常大。很多人以为“官方模型总没问题”其实在CIFAR-10上直接替换最后全连接层跑一遍上限很低甚至不如自己改过的浅层网络。后来我意识到必须把第一层改成3x3/stride1并且去掉maxpool网络才能适应这个小尺寸输入。这也是CIFAR-10上跑ResNet时一个约定俗成的做法官方开源实现里也能看到类似的改动。其实还有一个更隐蔽的问题官方resnet18的第一层卷积是专门为224x224的输入设计的感受野偏大。对32x32的输入来说7x7卷积几乎覆盖了整张图提取的只是全局粗糙信息很难捕捉小目标细节。换成3x3卷积后感受野更贴合小图上的局部模式后面几个stage才学得到有用的边缘和纹理。很多网上代码直接用torchvision.models.resnet18(num_classes10)然后把输入图片resize到224x224结果训练时间变长准确率也不高。CIFAR-10原始就是32x32强行resize只会让问题复杂化没必要绕这个弯。1.2 从0开始对理解网络结构更重要使用预训练权重还有一个坑ImageNet是1000类大图CIFAR-10是10类32x32小图底层特征差异很大。强迁过来反而让模型学不到适合当前分布的特征。我试过用ImageNet预训练ResNet18去finetune CIFAR-10最终测试集只有93%左右而我从零开始训练反而到了95%以上。这说明不是所有任务都适合迁移学习。更关键的是手写一遍结构后我对残差连接、stride变化、通道数翻倍这些概念理解清楚了很多出问题时能定位。如果你实在要用官方预训练建议至少把conv1和maxpool去掉换成3x3卷积再接后面的layer然后再微调。但那个实验我做下来依然不如从零训练。原因也不难理解预训练权重里已经被ImageNet的224x224输入方式绑定住了适配小图时要动第一层权重就得重新学迁移优势被大幅削弱。所以这个任务里我最终选择完全不加载任何外部权重。1.3 实验环境准备我先用Anaconda创建了Python 3.9环境PyTorch用2.0.1cu118版本。安装命令直接去PyTorch官网生成注意选择CUDA版本要和本机驱动匹配。装好后跑一句python -c import torch; print(torch.cuda.is_available())确认GPU可用。如果输出是True说明cuda环境正常如果输出False大概率是驱动太旧或者安装的是CPU版。我这块RTX 3060做这个任务足够200轮训练大概1.5到2小时。CIFAR-10数据用torchvision.datasets.CIFAR10自动下载存到./data目录如果网络太慢可以用镜像站点下载后手动放进去具体路径按Readme结构放就可以。提示不要在没确认GPU版本的情况下盲目装CPU版否则后面训练一个epoch要几分钟差距非常大。先跑通小数据再上GPU也是可以的但这次既然目标是95.46%最好一步到位。2. ResNet18结构手写残差连接和stage维度变化2.1 先理解通道数和分辨率的变化标准的ResNet18有四个stage每个stage包含两个BasicBlock。随着stage变深特征图的通道数分别是64、128、256、512而分辨率从32x32经过三次stride2下采样变成4x4。第一层我改成了3x3/stride1卷积通道数64输出还是32x32。每个stage第一个block通过downsample结构完成空间下采样stride2和通道对齐1x1卷积。BasicBlock的核心是残差把输入通过shortcut加到第二个卷积的输出上再通过ReLU。这样即使网络很深梯度也能直接流过shortcut缓解梯度消失。为什么要通道数翻倍因为分辨率减半后每个像素包含的信息量应该增加通道数翻倍可以在不显著增计算量的前提下保留足够表达能力。如果分辨率降了但通道数不变信息瓶颈会非常明显。原始ResNet论文里这个设计叫“bottleneck式增长”在CIFAR这种小图上效果同样明显。我第一次尝试时偷懒没有按这个比例改结果最后一个stage表达能力偏弱测试准确率掉了将近1个百分点。2.2 手写BasicBlock和整体ResNet18我直接放核心代码这一段完全可以照着敲一遍import torch import torch.nn as nn class BasicBlock(nn.Module): expansion 1 def __init__(self, in_channels, out_channels, stride1, downsampleNone): super().__init__() self.conv1 nn.Conv2d(in_channels, out_channels, kernel_size3, stridestride, padding1, biasFalse) self.bn1 nn.BatchNorm2d(out_channels) self.conv2 nn.Conv2d(out_channels, out_channels, kernel_size3, stride1, padding1, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) self.downsample downsample self.relu nn.ReLU(inplaceTrue) def forward(self, x): identity x out self.conv1(x) out self.bn1(out) out self.relu(out) out self.conv2(out) out self.bn2(out) if self.downsample is not None: identity self.downsample(x) out identity out self.relu(out) return out class ResNet18(nn.Module): def __init__(self, num_classes10): super().__init__() self.in_channels 64 self.conv1 nn.Conv2d(3, 64, kernel_size3, stride1, padding1, biasFalse) self.bn1 nn.BatchNorm2d(64) self.relu nn.ReLU(inplaceTrue) self.layer1 self._make_layer(64, 2, stride1) self.layer2 self._make_layer(128, 2, stride2) self.layer3 self._make_layer(256, 2, stride2) self.layer4 self._make_layer(512, 2, stride2) self.avgpool nn.AdaptiveAvgPool2d((1, 1)) self.fc nn.Linear(512, num_classes) def _make_layer(self, out_channels, blocks, stride): downsample None if stride ! 1 or self.in_channels ! out_channels: downsample nn.Sequential( nn.Conv2d(self.in_channels, out_channels, kernel_size1, stridestride, biasFalse), nn.BatchNorm2d(out_channels), ) layers [BasicBlock(self.in_channels, out_channels, stride, downsample)] self.in_channels out_channels for _ in range(1, blocks): layers.append(BasicBlock(out_channels, out_channels)) return nn.Sequential(*layers) def forward(self, x): x self.conv1(x) x self.bn1(x) x self.relu(x) x self.layer1(x) x self.layer2(x) x self.layer3(x) x self.layer4(x) x self.avgpool(x) x torch.flatten(x, 1) x self.fc(x) return xBasicBlock里两个3x3卷积之间接BN和ReLU卷积层的biasFalse因为后面马上接BNBN自带可学习的偏置项。如果是第一个block且stride2或者通道不匹配就需要一个downsample来做1x1卷积stride和主分支保持一致否则相加时尺寸对不上。很多人会问为什么两个卷积之后才加identity而不是每个卷积后都加这种post-activation的原始残差设计在CIFAR上很稳定我也没有额外改成pre-activation变体因为没必要增加复杂度。2.3 与官方实现的微小差异相比torchvision官方我没有用7x7卷积和maxpool改成3x3卷积后前向流程变成conv1 - bn1 - relu - layer1 - layer2 - layer3 - layer4本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价