☰
ResNet残差网络原理详解与PyTorch从零实现指南
2026/10/15 15:09:36 网站建设 项目流程

做图像分类、目标检测这些视觉任务,ResNet是绕不开的一块基石,也是很多工程师接触残差结构的起点。我第一次意识到残差连接的威力,是在一个小数据集上反复堆网络层数、效果却越来越差的阶段——当时对照论文把代码重写了一遍之后,才真正明白为什么单纯把网络加深是走不通的,以及ResNet是怎么用一条“捷径”解决这个难题的。这篇文章我会把ResNet的网络结构掰开揉碎,从网络退化问题的来龙去脉、残差学习的数学原理,到BasicBlock和Bottleneck两种残差块的差异,最后直接用PyTorch从零把ResNet搭出来并跑通一次前向传播,整个过程都给出可复现的代码和注释。不管你是在校学生准备复现经典网络,还是做项目时要设计自己的骨干网络,这篇文章都能给你一份可以直接参考的完整方案。

1. ResNet为什么是绕不开的基线:从网络退化到残差学习

1.1 网络越深效果越差的退化现象是怎么回事

在ResNet出现之前,卷积网络的主流方向就是拼命加深。从2012年的8层网络到2014年的16/19层VGG,大家默认“层数越多,模型表达能力越强,效果越好”。这个直觉本身没错,但很快有人发现一个诡异的现象:把网络从34层加到56层甚至上百层之后,不管是训练集还是验证集上的表现,反而都不如34层。很多人第一反应是过拟合,但奇怪的地方在于训练集上的loss都在升高——过拟合应该是训练集表现很好、验证集变差才对,这里却是训练本身都学不动。

这个现象后来被大家叫作网络退化问题。它并不是简单的梯度消失,更深层的原因是:当网络层数增多时,通过多层非线性变换后信息不断被压缩,梯度在反向传播过程中也会因为链式法则不断衰减,导致深层网络很难被优化到理想的解。换句话说,56层网络的参数空间确实比34层更大,表达能力理论上更强,但它很难在训练中找到那个更优的解。增加的网络层不仅没有带来收益,反而成了优化的负担。

当时针对这个问题的常规实验也做得很直接:有人在34层网络后面再“恒等堆叠”若干层,新加的层如果不做任何操作、原样输出,那么整个网络的效果应该至少不低于34层。可实际上普通卷积层学不出这种恒等映射,权重参数再怎么迭代也达不到“什么都不做”的效果。于是研究者开始认真思考:与其让网络隐式地去学一个恒等映射,不如直接在结构上把恒等映射显式给出来。这就引出了残差学习。

1.2 残差学习解决了什么:恒等映射与梯度近路

残差学习的核心思路是改变每个网络块的学习目标。假设某个残差块希望拟合的目标映射是“输入x经过一系列变换后的期望输出H(x)”,普通网络会让堆叠的卷积层直接去拟合这个H(x)。ResNet则把网络块的任务改成拟合残差F(x),使得最终输出为:

H(x) = F(x) + x

也就是说,不再要求卷积层直接输出最终结果,而是让卷积层学到“期望输出与输入之间的差值”。这种情况下,如果某个残差块已经达到了很好的精度、后续块再学也学不到什么新东西,卷积网络只需要把F(x)逼近到0,残差块就会退化成恒等映射,前层信息会原封不动地传到后面。把卷积层的权重逼近0,显然比直接拟合一个复杂的恒等映射要容易得多。

反向传播的角度也很好理解。残差块的输出对输入求梯度时,表达式类似:

∂H / ∂x = 1 + ∂F / ∂x

这个等号右侧的常数1非常关键。它相当于在梯度从后往前传的时候,每经过一个残差块都至少有一条“直达通道”,梯度不用全部穿过卷积层和激活函数的连乘,而可以“抄近路”流到前面。用比较生活化的比喻,普通网络像一条每一步都可能堵车的单向小路,层数越多堵车越严重;残差连接则是在旁边修了一条高架,即使小路完全堵死,信息和服务梯度也能顺利到达目的地。

这个设计的实际效果非常明显:论文里34层ResNet在ImageNet上的错误率比18层更低,而且比参数量大得多的VGG-19更好;到152层时效果还能继续提升。很多人当时把ResNet的成功单纯理解成“加深网络”,其实更深层的意义是它改变了结构的学习目标,让深度不再是一种负担。

2. ResNet结构拆解:两种残差块与各版本配置

2.1 BasicBlock:给浅层网络用的标准残差块

ResNet的基本组成单元是残差块。在ResNet18和ResNet34这类层数较浅的网络里,用的是BasicBlock,结构非常简单:

输入x → 3x3卷积 → BatchNorm → ReLU → 3x3卷积 → BatchNorm → 与shortcut相加 → ReLU → 输出

中间那个“与shortcut相加”就是残差连接。shortcut的路径上最理想的情况是什么都不做,直接让x与卷积输出相加。但这个理想情况有一个前提:输入和卷积输出的通道数必须一样、特征图尺寸也必须一样。实际搭建网络的过程中,一旦某个残差块需要下采样,或者通道数需要翻倍,shortcut就必须做相应处理。

BasicBlock里的stride设置也很关键。每个layer的第一个残差块决定是否下采样,而这个stride是加在第一个3x3卷积上的。比如从56x56的layer1过渡到28x28的layer2时,第一个BasicBlock的conv1会设置stride=2,让特征图尺寸减半;与此同时通道数从64变成128,shortcut就需要用一个1x1卷积来完成两个任务:把通道数从64变成128,同时用stride=2把空间尺寸从56x56降到28x28。

明白这个逻辑之后再动手写代码就不会糊里糊涂。BasicBlock中第二个3x3卷积始终是stride=1,不改变任何尺寸,只负责在指定通道数下做特征提取。

2.2 Bottleneck:深网络靠它降计算量

ResNet50以上的版本没有再使用BasicBlock,而是改用Bottleneck结构。如果直接堆叠BasicBlock到50层以上,计算量会变得难以接受,因为每个BasicBlock里有两个3x3卷积,高通道数下的计算开销非常大。

Bottleneck的卷积结构是1x1、3x3、1x1的三段式:

输入x → 1x1卷积降维 → 3x3卷积 → 1x1卷积升维 → 与shortcut相加 → ReLU → 输出

第一个1x1卷积先把通道数从256压缩到64,中间3x3卷积就在64维的低维空间里工作,最后一个1x1卷积再把64维恢复成256维。这样一来,理论上单块的计算量主要取决于3x3卷积所在的低维空间,而不是直接在原始高维空间做两次大卷积。对比一下,假设输入输出都是256通道,BasicBlock两个3x3卷积都在256通道上计算,Bottleneck只有中间那个3x3在64通道上计算,计算量差距非常明显。

Bottleneck里还出现了一个重要的参数:expansion。BasicBlock的expansion等于1,意味着残差块输出通道数等于中间通道数;而Bottleneck的expansion等于4,输出通道数等于中间planes的4倍。所以Bottleneck第一个1x1卷积把通道降到planes,最后一个1x1卷积再把通道升到planes * expansion。

2.3 全网络主干:从conv1到全连接层的组织方式

把残差块串起来之后,整个ResNet主干可以划分为几个阶段。以输入224x224的RGB图像为例:

首先是conv1层,一个7x7卷积,stride=2,输出通道64。相比VGG里那些重复的小卷积,这里直接用大核卷积快速把空间分辨率降到112x112。7x7卷积的感受野大,可以在网络早期提取到更多全局信息,一步完成大范围降维,减少后续计算量。

接下来是BatchNorm、ReLU,以及一个3x3的max pooling,stride=2,尺寸从112x112降到56x56。之后依次是layer1到layer4,每层由若干残差块组成。每个layer的第一个残差块负责空间降采样和通道调整,所以在代码里表现为layer2、layer3、layer4的起始块stride都为2,而layer1不降采样,只做通道调整。

网络最后的池化用的是全局平均池化而不是最大池化。AdaptiveAvgPool2d((1,1))能把任意尺寸的特征图压成1x1输出,之后把特征图展平,接到全连接层上。全局平均池化相比早期的多层全连接,最大的优势在于没有额外参数,并且能让每个通道的响应对应到特定的语义特征上,对防止过拟合也有帮助。

2.4 各版本ResNet参数对照与适用场景

不同版本的ResNet只是改变了组成layer的残差块类型和数量。一个通用规则是:浅层网络(18、34)用BasicBlock,深层网络(50及以上)用Bottleneck。各版本的主要配置可以参考下面的对照:

模型残差块类型各阶段块数(layer1-4)输出通道(layer4)参数量大致范围
ResNet18BasicBlock[2, 2, 2, 2]512约11M
ResNet34BasicBlock[3, 4, 6, 3]512约21M
ResNet50Bottleneck[3, 4, 6, 3]2048约25M
ResNet101Bottleneck[3, 4, 23, 3]2048约44M
ResNet152Bottleneck[3, 8, 36, 3]2048约60M

实际选型的时候,如果是移动端或者快速实验,ResNet18完全够用;做目标检测、语义分割的骨干网络,工程里最常用的还是ResNet50和ResNet101,因为特征表达能力更强、性能也更稳定。可以看到网络中layer越靠后,分配的块数越多,这是因为靠后的阶段特征图分辨率低、计算成本可控,更适合堆叠更多的非线性变换来提取高阶语义。

3. 使用PyTorch从零搭建ResNet网络的完整过程

3.1 需要准备的基础环境

这里默认你的环境已经装好了Python和PyTorch。版本上PyTorch 1.x和2.x都可以,核心代码不依赖什么新特性。我只用到了基础组件:torch.nn里的Module、Conv2d、BatchNorm2d、ReLU、Sequential、Linear,以及torch.nn.init里的初始化函数。

如果你用的是GPU版本,建议把下面的代码放到支持CUDA的机器上跑;如果只有CPU环境也没关系,搭建网络和验证前向传播完全没问题,只是训练会慢很多。

3.2 搭建BasicBlock残差块

先写BasicBlock类:

import torch import torch.nn as nn class BasicBlock(nn.Module): expansion = 1 def __init__(self, inplanes, planes, stride=1, downsample=None): super(BasicBlock, self).__init__() self.conv1 = nn.Conv2d(inplanes, planes, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(planes) self.relu = nn.ReLU(inplace=True) self.conv2 = nn.Conv2d(planes, planes, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(planes) self.downsample = downsample self.stride = stride 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

几个容易忽略的点。第一,两个卷积都设了bias=False,原因很简单,卷积后面紧跟BatchNorm,BN层已经有偏置参数,卷积的bias会被BN抵消掉,保留它只会增加参数和计算量,没有实际收益。第二,forward里先用identity = x把输入保存下来,最后再与卷积路径的输出相加。这一点比直接在shortcut分支里重复引用x更清晰,也避免在后续修改时不小心改动原始输入。第三,相加之后再过一个ReLU,这个ReLU是整个残差块的最后一步。相加前的卷积路径已经经过了一次ReLU,但shortcut路径没有激活,所以必须在merge之后再统一激活,保证两条路径的信息都能进入非线性空间。

3.3 搭建Bottleneck残差块

接着写Bottleneck:

class Bottleneck(nn.Module): expansion = 4 def __init__(self, inplanes, planes, stride=1, downsample=None): super(Bottleneck, self).__init__() self.conv1 = nn.Conv2d(inplanes, planes, kernel_size=1, bias=False) self.bn1 = nn.BatchNorm2d(planes) self.conv2 = nn.Conv2d(planes, planes, kernel_size=3, stride=stride, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(planes) self.conv3 = nn.Conv2d(planes, planes * self.expansion, kernel_size=1, bias=False) self.bn3 = nn.BatchNorm2d(planes * self.expansion) self.relu = nn.ReLU(inplace=True) self.downsample = downsample self.stride = stride 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) out = self.relu(out) out = self.conv3(out) out = self.bn3(out) if self.downsample is not None: identity = self.downsample(x) out += identity out = self.relu(out) return out

对比BasicBlock,最明显的变化就是加了一个降维、一个升维的过程。中间的3x3卷积负责空间特征提取,stride参数放在第二个卷积上。注意Bottleneck中最后一个1x1卷积会把通道数变成planes * expansion,而shortcut如果被启用,输出的也必须是planes * expansion,这样两条路径相加时维度才能对齐。

3.4 组装完整的ResNet主类

有了残差块以后,写ResNet主类就是把这些块按顺序串起来:

class ResNet(nn.Module): def __init__(self, block, layers, num_classes=1000): super(ResNet, self).__init__() self.inplanes = 64 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) self.layer1 = self._make_layer(block, 64, layers[0]) self.layer2 = self._make_layer(block, 128, layers[1], stride=2) self.layer3 = self._make_layer(block, 256, layers[2], stride=2) self.layer4 = self._make_layer(block, 512, layers[3], stride=2) self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(512 * block.expansion, num_classes) for m in self.modules(): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, mode="fan_out", nonlinearity="relu") elif isinstance(m, nn.BatchNorm2d): nn.init.constant_(m.weight, 1) nn.init.constant_(m.bias, 0) def _make_layer(self, block, planes, blocks, stride=1): downsample = None if stride != 1 or self.inplanes != planes * block.expansion: downsample = nn.Sequential( nn.Conv2d(self.inplanes, planes * block.expansion, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(planes * block.expansion), ) layers = [] layers.append(block(self.inplanes, planes, stride, downsample)) self.inplanes = planes * block.expansion for _ in range(1, blocks): layers.append(block(self.inplanes, planes)) return nn.Sequential(*layers) def forward(self, x): x = self.conv1(x) x = self.bn1(x) x = self.relu(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是最重要的方法。它的作用是创建每一阶段的一串残差块,关键逻辑在downsample的条件判断上:只有两种情况需要为shortcut单独构建下采样模块,一种是传入的stride不等于1,代表空间尺寸要减半;另一种是当前输入通道数self.inplanes不等于该层残差块的输出通道数planes * block.expansion。这个条件很隐蔽,很多人在写代码时会漏掉通道数不一致的情况。比如Bottleneck的layer1,虽然stride为1,但输入是64通道,输出却是256通道,如果不加downsample,残差相加的时候维度就对不上,直接报错。

初始化方法也值得说一句。卷积用kaiming_normal初始化,是专门针对ReLU族的激活函数设计的,能让正向传播时每一层输出的方差保持稳定。BatchNorm的weight初始化为1、bias初始化为0,表示一开始BN层尽量保持输入分布,让网络在训练初期更稳定。

3.5 实例化ResNet18与ResNet50并验证前向输出

主类写完后,定义几个常用的构建函数:

def resnet18(num_classes=1000): return ResNet(BasicBlock, [2, 2, 2, 2], num_classes=num_classes) def resnet34(num_classes=1000): return ResNet(BasicBlock, [3, 4, 6, 3], num_classes=num_classes) def resnet50(num_classes=1000): return ResNet(Bottleneck, [3, 4, 6, 3], num_classes=num_classes) def resnet101(num_classes=1000): return ResNet(Bottleneck, [3, 4, 23, 3], num_classes=num_classes)

实例化验证一下:

model = resnet18(num_classes=1000) x = torch.randn(1, 3, 224, 224) out = model(x) print(out.shape) # torch.Size([1, 1000])

如果想观察特征图尺寸变化,可以在forward过程中打印每个阶段的输出形状:

x = torch.randn(1, 3, 224, 224) x = model.conv1(x) print("after conv1:", x.shape) # [1, 64, 112, 112] x = model.bn1(x) x = model.relu(x) x = model.maxpool(x) print("after maxpool:", x.shape) # [1, 64, 56, 56] x = model.layer1(x) print("after layer1:", x.shape) # [1, 64, 56, 56] x = model.layer2(x) print("after layer2:", x.shape) # [1, 128, 28, 28] x = model.layer3(x) print("after layer3:", x.shape) # [1, 256, 14, 14] x = model.layer4(x) print("after layer4:", x.shape) # [1, 512, 7, 7]

BasicBlock和Bottleneck的差异在layer1之后就很明显:如果你实例化的是resnet50,layer1输出的通道不再是64,而是256。这一点在自定义网络、拼接其他模块时特别容易踩坑。

4. 实测高频踩坑与训练调参经验记录

4.1 shortcut尺寸匹配问题速查

搭建ResNet时最常见的报错就是形状不匹配,错误信息会提示两个张量size不一致,无法相加。按照我调试的经验,排查顺序先看downsample条件是否写对。如果使用了Bottleneck,记得检查self.inplanes != planes * block.expansion这个条件,因为通道数变化不仅发生在下采样阶段,也发生在每个Bottleneck的输出升维阶段。

还有一种隐患是手动改shortcut的1x1卷积时,stride用错了位置。1x1卷积的stride和3x3卷积的stride必须保持一致,如果下采样块中残差卷积的stride=2,shortcut里的1x1卷积也必须是stride=2,否则空间尺寸对不上。尽量不要在残差块内部额外添加adaptive pooling去强行匹配尺寸,那会打乱网络既定的下采样节奏,影响后续特征图的语义层级。

4.2 BatchNorm在训练和评估模式下的差异

这个问题在测试阶段最容易出问题。BatchNorm在训练时使用当前batch的均值和方差来归一化,同时通过滑动平均维护一个全局running_mean和running_var;在评估时,它不使用当前batch的统计量,而是直接使用这些维护好的全局统计量。如果训练完忘记调用model.eval(),模型会用训练batch的小批量统计量去推理,导致输出抖动明显,精度表现不稳定。

类似地,推理阶段如果没有配合torch.no_grad(),模型会为所有中间变量保留计算图,显存占用明显上涨。虽然结果不受影响,但速度会慢很多,也容易OOM。还有一个容易忽略的场景:如果你在训练过程中单独保存了模型权重,加载后做验证,一定要先model.eval()再喂数据,否则BN层的表现会和训练时完全不同。

4.3 从头训练还是迁移学习:数据量决定策略

很多同学拿到一个新数据集,第一反应就是从零开始训练一个ResNet。实际上如果数据量只有几千张图,从零训练的效果往往不如用大规模图像数据集预训练好的权重再微调。预训练模型已经学到了大量通用的边缘、纹理和语义特征,迁移到自己的任务时只需要在目标数据上做有限轮数的微调,收敛速度快,精度也更高。

用TorchVision加载预训练模型的时候,需要注意最后一层输出通道必须改成自己的类别数:

import torchvision model = torchvision.models.resnet50(weights=torchvision.models.ResNet50_Weights.IMAGENET1K_V1) num_classes = 10 model.fc = nn.Linear(model.fc.in_features, num_classes)

如果你自己的输入图像分辨率比较低,也不建议直接修改conv1的stride或者去掉maxpool,因为预训练权重的感受野和降采样节奏是固定的。更稳妥的做法是先把输入统一resize到224x224再做训练和推理。

4.4 显存溢出与训练不收敛的排查思路

显存溢出是训练ResNet时另一个高频问题。ResNet50输入224x224、batch size=64,显存占用大概在10GB左右,如果显存有限,首选降低batch size而不是降低输入分辨率。batch size降低后,为保证训练稳定性,可以考虑梯度累积,也就是攒够几个batch再更新一次梯度,相当于模拟了更大的batch size。更进一步的方案是混合精度训练,PyTorch的自动混合精度能把显存占用再降一截,同时利用GPU的加速单元提升训练速度。

训练不收敛的问题,除了学习率设置过大之外,最常见的原因是输入没有做归一化。一般需要用ImageNet数据集的均值(0.485, 0.456, 0.406)和标准差(0.229, 0.224, 0.225)做标准化,而不是直接喂0到255的原始像素。学习率方面,ResNet系列配合cosine退火或StepLR效果都很好,初始学习率在批量较小时建议从0.01左右开始,配合BatchNorm的预训练权重微调时,新加的全连接层可以用更大的学习率,而主干层保持较小的学习率。

常见问题可能原因解决方法
张量相加时报size不匹配shortcut的downsample条件写错检查stride和planes * expansion通道匹配
验证集精度波动大忘记model.eval()推理前切换为评估模式,并配合torch.no_grad()
训练loss不下降输入未归一化、学习率过大做标准化,调整初始学习率
显卡OOMbatch size过大降低batch、梯度累积、混合精度训练
加载预训练权重报错fc层类别数不一致替换最后一层全连接并匹配输出维度

最后再分享一个小技巧。把ResNet写熟之后,很多结构上的魔改都变得非常方便。比如在残差块里加一个注意力模块,或者把3x3卷积换成深度可分离卷积,只需要修改一个残差块的内部结构,整个网络的其余部分都不需要动。这也是ResNet作为基线的最大价值——它把网络做深做稳的工程问题简化成了模块化改造。我个人每次接触一个新任务都会先跑一遍ResNet18或ResNet50作为对照组,因为它足够稳,结果也有代表性。建议你拿到这篇文章之后,亲手把代码敲一遍,跑通一次训练和推理,过程中踩过的每一个报错都会让你对特征图尺寸和通道流动的理解更深一层。

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

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

立即咨询