☰
从零手写ResNet18:PyTorch实战CIFAR-10与残差网络原理详解
2026/10/21 22:47:18 网站建设 项目流程

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卷积把输入通道数调整到和输出一致。特征图尺寸减半是因为主路径的卷积步长为2,shortcut也需要用步长为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显卡并且装了CUDA,PyTorch会自动使用GPU。没有也没关系,CIFAR-10这个数据集用CPU训练也能在合理时间内跑完。我实测下来,用一块入门级显卡(比如GTX 1650),ResNet18在CIFAR-10上训练50个epoch大概需要20分钟。纯CPU的话大概要两三个小时,建议至少跑10个epoch看看效果。

提示:如果你用的是Apple Silicon的Mac,PyTorch支持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, stride=1): super(BasicBlock, self).__init__() # 第一个卷积层,可能带步长 self.conv1 = nn.Conv2d( in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False ) self.bn1 = nn.BatchNorm2d(out_channels) # 第二个卷积层,步长固定为1 self.conv2 = nn.Conv2d( out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False ) self.bn2 = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) # 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_size=1, stride=stride, bias=False ), 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

几个关键点解释一下。bias=False是因为后面接了BN层,BN本身有偏置参数,卷积的偏置就是冗余的,去掉可以省一点参数。expansion=1这个类属性是为了和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_classes=10): super(ResNet18, self).__init__() self.in_channels = 64 # Stage 0: 初始卷积和池化 self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False) self.bn1 = nn.BatchNorm2d(64) self.relu = nn.ReLU(inplace=True) self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) # 四个Stage 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, 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, stride=1)) 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_size=3, stride=1, padding=1, bias=False) # 去掉maxpool

3.4 数据加载与预处理

CIFAR-10用torchvision加载非常方便。预处理方面,我用了随机裁剪和随机水平翻转做数据增强,归一化的均值和方差用CIFAR-10的统计值:

import torchvision import torchvision.transforms as transforms transform_train = transforms.Compose([ transforms.RandomCrop(32, padding=4), 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', train=True, download=True, transform=transform_train ) trainloader = torch.utils.data.DataLoader( trainset, batch_size=128, shuffle=True, num_workers=2 ) testset = torchvision.datasets.CIFAR10( root='./data', train=False, download=True, transform=transform_test ) testloader = torch.utils.data.DataLoader( testset, batch_size=100, shuffle=False, num_workers=2 )

RandomCrop(32, padding=4)是先把图片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_classes=10).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4) scheduler = CosineAnnealingLR(optimizer, T_max=50) 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(f'Epoch {epoch+1}, Loss: {running_loss/len(trainloader):.4f}, ' f'Train 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(f'Test 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_size=1增大batch_size,或改用GroupNorm

4.2 关于shortcut连接的几个细节

shortcut连接看起来简单,但有几个容易踩坑的地方。第一,当主路径和shortcut的特征图尺寸不一致时,不能直接相加。我见过有人忘了在shortcut里加stride=2的卷积,结果运行时报维度错误。第二,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%以上。

如果你不想调学习率,直接用CosineAnnealingLR,T_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_classes=10) 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最大的价值不是它的准确率有多高,而是它提供了一个清晰、可修改、可扩展的框架。你在这个框架上做的每一次改动,都能快速看到效果,这种反馈循环对学习深度学习非常重要。踩过几次坑之后,你对卷积网络的理解会比只看论文深刻得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询