简介:一份面向深度学习初学者的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(pretrained=True),结果在32x32的小图上表现并不好。我想把这一条完整的路记录下来:怎么搭网络、怎么加载数据、怎么调参,以及最终95.46%是怎么一步一步挤出来的。无论你是刚接触PyTorch,还是想找一个标准的分类任务练手,这篇文章应该都能给你一点参考。
1. 为什么从零手写ResNet18,而不是直接调官方模型
1.1 官方模型在CIFAR-10上并不直接适用
torchvision里的resnet18是按照224x224的ImageNet设计。第一层是7x7卷积,stride=2,再接一个3x3 maxpool,这两个操作一下子把32x32的输入压到8x8,信息损失非常大。很多人以为“官方模型总没问题”,其实在CIFAR-10上直接替换最后全连接层跑一遍,上限很低,甚至不如自己改过的浅层网络。后来我意识到,必须把第一层改成3x3/stride=1,并且去掉maxpool,网络才能适应这个小尺寸输入。这也是CIFAR-10上跑ResNet时一个约定俗成的做法,官方开源实现里也能看到类似的改动。
其实还有一个更隐蔽的问题:官方resnet18的第一层卷积是专门为224x224的输入设计的,感受野偏大。对32x32的输入来说,7x7卷积几乎覆盖了整张图,提取的只是全局粗糙信息,很难捕捉小目标细节。换成3x3卷积后,感受野更贴合小图上的局部模式,后面几个stage才学得到有用的边缘和纹理。很多网上代码直接用torchvision.models.resnet18(num_classes=10),然后把输入图片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.1+cu118版本。安装命令直接去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经过三次stride=2下采样变成4x4。第一层我改成了3x3/stride=1卷积,通道数64,输出还是32x32。每个stage第一个block通过downsample结构完成空间下采样(stride=2)和通道对齐(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, stride=1, downsample=None): super().__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.downsample = downsample self.relu = nn.ReLU(inplace=True) 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_classes=10): super().__init__() self.in_channels = 64 self.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(64) self.relu = nn.ReLU(inplace=True) self.layer1 = self._make_layer(64, 2, stride=1) self.layer2 = self._make_layer(128, 2, stride=2) self.layer3 = self._make_layer(256, 2, stride=2) self.layer4 = self._make_layer(512, 2, stride=2) 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_size=1, stride=stride, bias=False), 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,卷积层的bias=False,因为后面马上接BN,BN自带可学习的偏置项。如果是第一个block且stride=2或者通道不匹配,就需要一个downsample来做1x1卷积,stride和主分支保持一致,否则相加时尺寸对不上。很多人会问为什么两个卷积之后才加identity,而不是每个卷积后都加?这种post-activation的原始残差设计在CIFAR上很稳定,我也没有额外改成pre-activation变体,因为没必要增加复杂度。
2.3 与官方实现的微小差异
相比torchvision官方,我没有用7x7卷积和maxpool,改成3x3卷积后,前向流程变成:conv1 -> bn1 -> relu -> layer1 -> layer2 -> layer3 -> layer4
本文还有配套的精品资源,点击获取