首页 / 资讯中心 / 文章详情

从零手写ResNet18:PyTorch实战CIFAR-10与残差网络原理详解

从零手写ResNet18:PyTorch实战CIFAR-10与残差网络原理详解 ★ FEATURED ARTICLE
1. 为什么残差网络值得你花时间搞懂如果你接触过深度学习大概率听过ResNet的大名。2015年它刚出来的时候直接把ImageNet的top-5错误率压到了3.57%这个数字在当时几乎让人怀疑是不是统计出了bug。更关键的是它解决了一个非常实际的问题网络越深效果反而越差。这不是过拟合而是训练本身出了问题——梯度传着传着就没了或者炸了。ResNet18是残差家族里最轻量的成员之一18层参数量约1100万权重文件不到45MB。这个体量意味着什么你在自己的笔记本上用一块普通的消费级显卡甚至纯CPU都能跑起来训练和推理。对于想入门计算机视觉、想理解残差结构到底怎么回事、想拿一个能实际跑通的代码来改改做自己项目的人来说ResNet18是最合适的起点。这篇文章我会从零开始用PyTorch把ResNet18完整实现一遍。不是调torchvision.models.resnet18()那种一行代码的玩法而是手写残差块、手写网络结构、手写训练循环。我会解释每一个设计决策背后的原因比如为什么basic block要设计成两层卷积、shortcut什么时候需要下采样、BN层放在卷积前面还是后面。代码全部可以直接复制运行训练部分我用CIFAR-10数据集做演示你在自己机器上就能复现。适合谁看如果你已经了解卷积、池化、反向传播的基本概念能用PyTorch搭一个简单的CNN那这篇文章正好帮你从“能搭网络”进阶到“理解网络为什么这么搭”。如果你是完全的新手也没关系我会把关键概念用生活化的方式解释清楚代码注释也尽量写详细。2. 残差网络的核心思路拆解2.1 深层网络到底出了什么问题在ResNet出现之前研究者的直觉是网络越深表达能力越强效果应该越好。但实际实验发现一个56层的网络在训练集上的表现反而不如20层的网络。注意这里说的不是测试集是训练集。也就是说这不是过拟合问题而是网络本身就没学好。这个现象的根本原因在于梯度消失和梯度爆炸。反向传播的时候梯度从最后一层往前传每经过一层就要乘以该层的权重和激活函数的导数。如果这些值都小于1梯度会指数级衰减传到前面几层的时候已经接近于零了那些层的参数几乎不更新。如果都大于1又会指数级增长导致训练发散。Batch Normalization的提出缓解了这个问题它通过归一化每层的输入分布让梯度更稳定。但BN并没有完全解决深层网络的退化问题。何恺明团队做了一个实验在20层网络的基础上后面加一些恒等映射层就是输出等于输入什么都不做的层按理说效果不应该变差因为新增的层没有引入任何额外的变换能力。但实验发现加了这些层之后效果反而变差了。这说明网络很难学到恒等映射。2.2 残差连接是怎么破局的残差网络的核心思想非常朴素与其让网络直接学习目标映射H(x)不如让它学习残差F(x) H(x) - x然后最终的输出是F(x) x。这个x就是通过shortcut连接直接跳过来的输入。为什么这样设计有效如果最优的映射就是恒等映射那网络只需要把F(x)的权重都学成零就行了这比让一堆非线性层去逼近恒等映射容易得多。换句话说残差结构给网络提供了一条“捷径”让信息可以无障碍地向前传播梯度也可以沿着这条捷径直接回传不用经过那些可能衰减的层。你可以把残差块想象成一条高速公路上的匝道。主干道是那条shortcut连接车辆信息可以直接快速通过匝道是那两层卷积车辆可以选择上去绕一圈再回来。网络需要做什么变换就在匝道上完成不需要变换就直接走主干道。这种设计让深层网络的训练变得可行。2.3 ResNet18的整体架构选择ResNet18的“18”指的是带权重的层数17个卷积层加1个全连接层。它的结构可以分成五个阶段Stage 0一个7x7卷积步长2输出通道64后面接3x3最大池化步长2。这一层负责初步的特征提取和降采样。Stage 1两个BasicBlock每个块内两层3x3卷积通道数64。Stage 2两个BasicBlock第一个块的第一层卷积步长2通道数从64变128同时做降采样。Stage 3两个BasicBlock通道数从128变256同样在第一个块做降采样。Stage 4两个BasicBlock通道数从256变512。最后全局平均池化 全连接层输出1000类ImageNet或你自定义的类别数。每个Stage的第一个BasicBlock需要处理通道数变化和特征图尺寸减半的问题。通道数变化意味着shortcut连接不能直接相加需要用一个1x1卷积把输入通道数调整到和输出一致。特征图尺寸减半是因为主路径的卷积步长为2shortcut也需要用步长为2的1x1卷积来匹配尺寸。这里有个细节值得注意ResNet18用的是BasicBlock而ResNet50用的是Bottleneck。BasicBlock是两层3x3卷积Bottleneck是1x1降维、3x3卷积、1x1升维的三层结构。为什么ResNet18不用Bottleneck因为18层的参数量本来就不大用Bottleneck反而会让参数更少表达能力不足。Bottleneck是为更深的网络设计的目的是在保持表达能力的同时减少计算量。3. 手写ResNet18的完整实操3.1 环境准备与依赖确认在开始写代码之前先确认你的环境。我用的配置是Python 3.9、PyTorch 2.0、torchvision 0.15。如果你用的是更新或稍旧的版本问题也不大核心API都是兼容的。pip install torch torchvision如果你有NVIDIA显卡并且装了CUDAPyTorch会自动使用GPU。没有也没关系CIFAR-10这个数据集用CPU训练也能在合理时间内跑完。我实测下来用一块入门级显卡比如GTX 1650ResNet18在CIFAR-10上训练50个epoch大概需要20分钟。纯CPU的话大概要两三个小时建议至少跑10个epoch看看效果。提示如果你用的是Apple Silicon的MacPyTorch支持MPS后端可以在代码里把设备设为mps速度比CPU快不少。3.2 BasicBlock的代码实现与逐行解读先写最核心的残差块。ResNet18用的是BasicBlock结构是卷积 → BN → ReLU → 卷积 → BN然后加上shortcut最后再过一次ReLU。import torch import torch.nn as nn class BasicBlock(nn.Module): expansion 1 def __init__(self, in_channels, out_channels, stride1): super(BasicBlock, self).__init__() # 第一个卷积层可能带步长 self.conv1 nn.Conv2d( in_channels, out_channels, kernel_size3, stridestride, padding1, biasFalse ) self.bn1 nn.BatchNorm2d(out_channels) # 第二个卷积层步长固定为1 self.conv2 nn.Conv2d( out_channels, out_channels, kernel_size3, stride1, padding1, biasFalse ) self.bn2 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) # shortcut连接如果需要改变通道数或尺寸用1x1卷积 self.shortcut nn.Sequential() if stride ! 1 or in_channels ! out_channels: self.shortcut nn.Sequential( nn.Conv2d( in_channels, out_channels, kernel_size1, stridestride, biasFalse ), nn.BatchNorm2d(out_channels) ) def forward(self, x): identity self.shortcut(x) out self.relu(self.bn1(self.conv1(x))) out self.bn2(self.conv2(out)) out identity out self.relu(out) return out几个关键点解释一下。biasFalse是因为后面接了BN层BN本身有偏置参数卷积的偏置就是冗余的去掉可以省一点参数。expansion1这个类属性是为了和Bottleneck保持接口一致Bottleneck的expansion是4因为它的输出通道是中间通道的4倍。shortcut的判断条件stride ! 1 or in_channels ! out_channels很关键。当stride为2时主路径的特征图尺寸减半shortcut也必须用步长为2的1x1卷积来匹配。当通道数变化时1x1卷积负责调整通道数。如果两者都不变shortcut就是空的Sequential直接返回输入本身。注意out identity这行用的是原地加法。在PyTorch里原地操作有时候会影响自动求导但这里因为identity和out都是计算图上的节点加法本身是安全的。如果你遇到奇怪的梯度问题可以改成out out identity试试。3.3 组装完整的ResNet18网络有了BasicBlock组装整个网络就简单了。我按照标准的ResNet18结构来搭class ResNet18(nn.Module): def __init__(self, num_classes10): super(ResNet18, self).__init__() self.in_channels 64 # Stage 0: 初始卷积和池化 self.conv1 nn.Conv2d(3, 64, kernel_size7, stride2, padding3, biasFalse) self.bn1 nn.BatchNorm2d(64) self.relu nn.ReLU(inplaceTrue) self.maxpool nn.MaxPool2d(kernel_size3, stride2, padding1) # 四个Stage 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, num_blocks, stride): layers [] # 第一个块可能需要降采样 layers.append(BasicBlock(self.in_channels, out_channels, stride)) self.in_channels out_channels # 后续的块不需要降采样 for _ in range(1, num_blocks): layers.append(BasicBlock(out_channels, out_channels, stride1)) return nn.Sequential(*layers) def forward(self, x): x self.relu(self.bn1(self.conv1(x))) x self.maxpool(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 x_make_layer这个辅助函数负责构建每个Stage。第一个块的stride由参数决定后续块都是1。self.in_channels在第一个块之后更新为out_channels这样下一个Stage的第一个块就知道输入通道数是多少了。关于初始卷积层标准ResNet用的是7x7卷积、步长2、padding 3。这个设计是为了在ImageNet的224x224输入上快速降采样。但CIFAR-10的图片只有32x32用7x7卷积加最大池化会把特征图缩得太小。所以针对CIFAR-10通常的做法是把初始卷积改成3x3、步长1、padding 1并且去掉最大池化层。这样特征图尺寸在进入Stage 1之前保持32x32。我两种都试过。用标准结构在CIFAR-10上也能跑但最终准确率会低一两个百分点因为信息损失太多了。如果你要复现论文级别的CIFAR-10结果建议用CIFAR适配版# CIFAR-10适配版初始层 self.conv1 nn.Conv2d(3, 64, kernel_size3, stride1, padding1, biasFalse) # 去掉maxpool3.4 数据加载与预处理CIFAR-10用torchvision加载非常方便。预处理方面我用了随机裁剪和随机水平翻转做数据增强归一化的均值和方差用CIFAR-10的统计值import torchvision import torchvision.transforms as transforms transform_train transforms.Compose([ transforms.RandomCrop(32, padding4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)) ]) transform_test transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)) ]) trainset torchvision.datasets.CIFAR10( root./data, trainTrue, downloadTrue, transformtransform_train ) trainloader torch.utils.data.DataLoader( trainset, batch_size128, shuffleTrue, num_workers2 ) testset torchvision.datasets.CIFAR10( root./data, trainFalse, downloadTrue, transformtransform_test ) testloader torch.utils.data.DataLoader( testset, batch_size100, shuffleFalse, num_workers2 )RandomCrop(32, padding4)是先把图片padding到40x40然后随机裁回32x32。这个操作模拟了平移不变性让网络对物体位置不那么敏感。RandomHorizontalFlip是随机水平翻转进一步增加数据多样性。这两个增强手段在CIFAR-10上几乎是标配能提升两三个百分点的准确率。提示num_workers在Windows上有时会有问题如果报错就设成0。在Linux或Mac上设成2或4可以加速数据加载。3.5 训练循环与超参数设置训练部分我用的是SGD加动量学习率用余弦退火调度。这套组合在CIFAR-10上很稳import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR device torch.device(cuda if torch.cuda.is_available() else cpu) model ResNet18(num_classes10).to(device) criterion nn.CrossEntropyLoss() optimizer optim.SGD(model.parameters(), lr0.1, momentum0.9, weight_decay5e-4) scheduler CosineAnnealingLR(optimizer, T_max50) for epoch in range(50): model.train() running_loss 0.0 correct 0 total 0 for inputs, labels in trainloader: inputs, labels inputs.to(device), labels.to(device) optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() _, predicted outputs.max(1) total labels.size(0) correct predicted.eq(labels).sum().item() scheduler.step() train_acc 100. * correct / total print(fEpoch {epoch1}, Loss: {running_loss/len(trainloader):.4f}, fTrain Acc: {train_acc:.2f}%)学习率0.1是SGD的经典起点配合动量0.9可以加速收敛。weight_decay设5e-4是ResNet论文里的推荐值用来抑制过拟合。余弦退火让学习率从0.1平滑降到接近0训练后期模型能更精细地调整参数。我实测下来50个epoch之后训练准确率能到99%以上测试准确率在93%到94%之间。如果你想要更高的测试准确率可以加更多数据增强比如Cutout、Mixup或者训练更长时间。3.6 模型评估与结果分析训练完之后在测试集上跑一遍model.eval() correct 0 total 0 with torch.no_grad(): for inputs, labels in testloader: inputs, labels inputs.to(device), labels.to(device) outputs model(inputs) _, predicted outputs.max(1) total labels.size(0) correct predicted.eq(labels).sum().item() print(fTest Accuracy: {100. * correct / total:.2f}%)model.eval()会把BN层切换到推理模式用训练时累积的均值和方差而不是当前batch的统计量。同时Dropout也会被关闭ResNet18本身没有Dropout但这是个好习惯。torch.no_grad()关闭梯度计算省显存也加速推理。如果测试准确率明显低于训练准确率比如训练99%、测试85%说明过拟合了。解决办法包括增加数据增强、增大weight_decay、加Dropout层、或者用早停策略。如果两者都低说明欠拟合需要增大模型容量或训练更久。4. 实操中踩过的坑与排查技巧4.1 常见报错与解决方案速查问题现象可能原因解决方法训练loss不下降学习率太小或太大尝试0.01到0.1之间的值观察loss曲线loss变成NaN学习率太大或梯度爆炸降低学习率加梯度裁剪测试准确率远低于训练过拟合增加数据增强增大weight_decay显存不够batch_size太大减小batch_size或用梯度累积shortcut维度不匹配通道数或尺寸不一致检查BasicBlock的stride和通道设置BN层报错batch_size1增大batch_size或改用GroupNorm4.2 关于shortcut连接的几个细节shortcut连接看起来简单但有几个容易踩坑的地方。第一当主路径和shortcut的特征图尺寸不一致时不能直接相加。我见过有人忘了在shortcut里加stride2的卷积结果运行时报维度错误。第二shortcut里的BN层不能省。虽然理论上1x1卷积加BN和直接相加在数学上不等价但BN能稳定训练去掉之后准确率会掉。还有一个细节在原始的ResNet论文里shortcut连接在相加之后才过ReLU。也就是说ReLU是作用在F(x) x上的而不是分别作用。这个顺序很重要如果先对F(x)做ReLU再相加就改变了残差学习的语义。4.3 学习率调度的实际体验我试过固定学习率、StepLR、CosineAnnealingLR三种策略。固定学习率0.1的话训练前期下降很快但后期会在最优值附近震荡测试准确率大概92%左右。StepLR每30个epoch降10倍效果比固定好一点能到93%。CosineAnnealingLR是最稳的平滑下降没有突变最终能到93.5%以上。如果你不想调学习率直接用CosineAnnealingLRT_max设成总epoch数就行。这个策略对超参数不敏感基本不会出大问题。4.4 关于BatchNorm的注意事项BN层在训练和推理时的行为不一样。训练时用当前batch的均值和方差同时用移动平均更新全局统计量。推理时用全局统计量。如果你在训练过程中做验证一定要先调model.eval()否则验证结果会不稳定。另外BN对batch_size比较敏感。batch_size太小比如小于8统计量估计不准训练会不稳定。CIFAR-10用128的batch_size很合适。如果显存不够至少也要保证32。还有一个坑如果你用了预训练权重做迁移学习冻结了前面的层那些层里的BN仍然会更新统计量。如果你希望完全冻结需要把BN层也设为eval模式。4.5 模型保存与加载的实用技巧保存模型的时候推荐保存state_dict而不是整个模型对象。state_dict只包含参数文件小加载灵活# 保存 torch.save(model.state_dict(), resnet18_cifar10.pth) # 加载 model ResNet18(num_classes10) model.load_state_dict(torch.load(resnet18_cifar10.pth)) model.to(device) model.eval()如果你要保存优化器状态以便继续训练可以保存一个字典checkpoint { epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), loss: loss, } torch.save(checkpoint, checkpoint.pth)加载的时候对应地恢复就行。这个方式在训练大模型时特别有用万一中断了可以从断点继续。4.6 从CIFAR-10迁移到自己的数据集如果你想用自己的数据集训练ResNet18需要改几个地方。第一num_classes改成你的类别数。第二数据加载部分换成你自己的Dataset类。第三如果图片尺寸不是32x32初始卷积层可能需要调整。对于224x224的输入用标准的7x7卷积加最大池化。对于64x64或128x128的输入可以用3x3卷积加最大池化或者保留7x7但调整步长。核心原则是进入Stage 1之前特征图不要小于8x8否则后面四次降采样之后就变成0了。如果你的数据集很小比如几千张图片建议用预训练权重做微调。把学习率设小一点比如0.001只训练最后几层前面的层冻结。这样能利用ImageNet上学到的通用特征在小数据上也能有不错的效果。5. 残差结构的扩展思考与变体5.1 为什么ResNet18适合作为入门和基线ResNet18的参数量约1100万在GPU上训练CIFAR-10大概20分钟就能收敛。这个速度意味着你可以快速做实验改改残差块的结构、试试不同的学习率、加加数据增强一两个小时就能跑好几组对比。相比之下ResNet50参数量是2500万训练时间翻倍对于入门来说负担太重。作为基线模型ResNet18在CIFAR-10上93%到94%的准确率已经不错了。如果你的改进方法能在这个基线上稳定提升一两个百分点说明方法有效。如果连基线都跑不过那就要检查是不是实现有问题。5.2 残差思想的通用性残差连接不局限于卷积网络。Transformer里的残差连接、U-Net里的跳跃连接本质上都是同一个思想给信息一条捷径让梯度更容易传播。理解了ResNet的残差块你再去看Transformer的x x Attention(x)会发现思路完全一致。甚至在一些非深度学习的场景里残差思想也有应用。比如梯度提升树GBDT每一棵树学习的是前面所有树的残差。虽然实现方式不同但核心逻辑都是“让模型学习修正量而不是直接学习目标”。5.3 后续可以尝试的改进方向如果你已经把ResNet18跑通了想进一步探索有几个方向可以试试。第一把BasicBlock换成Bottleneck实现ResNet50对比两者的训练速度和准确率。第二加入SE注意力模块Squeeze-and-Excitation让网络自适应地调整通道权重。第三试试Pre-activation ResNet把BN和ReLU放到卷积前面训练更稳定。第四用混合精度训练AMP加速在支持Tensor Core的显卡上能快一倍。我个人在实际操作中的体会是ResNet18最大的价值不是它的准确率有多高而是它提供了一个清晰、可修改、可扩展的框架。你在这个框架上做的每一次改动都能快速看到效果这种反馈循环对学习深度学习非常重要。踩过几次坑之后你对卷积网络的理解会比只看论文深刻得多。
阅读完成 · 觉得有帮助?
咨询建站