☰
ResNet残差网络详解:从梯度消失到PyTorch实现
2026/10/15 22:21:52 网站建设 项目流程

平时在社区里回答网络结构相关问题时,被问最多的就是 ResNet。2015年它刚出来的时候,大家还都在为“网络越深越难训练”发愁,结果它靠一个残差块的改动,把网络深度直接刷到上百层,横扫各类榜单。直到今天,ResNet依然是最常用的主干网络之一,很多检测、分割模型都在Backbone里用它的变体。这篇就把它从原理到代码,从搭建到调参,完整拆一遍。适合刚学完基础卷积神经网络、准备上手写分类模型,以及想深入理解残差连接到底在做什么的人。

1. 从梯度消失说起:为什么需要残差结构

1.1 深度网络训练的核心痛点

很多初学者第一反应是“网络越深,效果越好”,但真正去堆层数时就会发现完全不是那么回事。单纯叠加卷积层,到了四五十层以后,训练集上的loss往往就降不下来了,更别说验证集。这不是过拟合,而是优化本身出了问题。

关键原因就是梯度消失和梯度爆炸。反向传播时,梯度要从最后一层一路乘回去,每一层卷积的权重矩阵引入一个梯度缩放。随着层数增加,这些缩放因子连乘之后会变得非常小,或者非常大。梯度爆炸还好处理,用梯度裁剪就能压住;梯度消失则很难办,前面几十层的权重收不到有效更新,等于白加了这些层。

Batch Normalization能够缓解一部分问题,它把每层的输入强行拉回均值为0、方差为1的分布,让梯度不会因为激活函数饱和而直接消失。但玄学的是,即使加了BN,纯堆叠式的深层网络在几十层以上仍然不好训练,训练loss会先下降,然后在某个深度后反而回升。这也说明,梯度问题只是表象,还有一个更隐蔽的“退化问题”。

1.2 残差学习的直觉

退化问题听起来反直觉:增加网络深度,哪怕只增加恒等映射层,理论上训练误差也不应该变大。可实际上,让一堆非线性卷积层去逼近恒等映射,难度比想象中大得多。权重初始化稍有不慎,网络输出就会偏离输入,导致优化器很难把“什么都不做”这个状态学出来。

残差网络的思路很直接:不在每个层去学习目标映射 H(x),而是让网络去学残差 F(x) = H(x) - x,最后用 F(x) + x 作为输出。如果目标就是恒等映射,那网络只需要把 F(x) 学成0,这个比学成 x 容易太多。

注意:残差连接还有一个非常朴素的好处——它给梯度提供了一条“高速公路”。反向传播时梯度可以直接通过捷径分支传到前面层,即使深层卷积权重梯度消失,也能确保浅层有稳定的梯度信号。这是ResNet能训练上百层的关键保障之一。

我经常用一个类比说明这件事:不加残差的深层网络像一列堵车的车队,后车的路况完全取决于前面每一辆车;加了残差,相当于每隔一段就开一条辅路,让信息可以绕开拥堵路段直行。

2. ResNet核心构件拆解

2.1 残差块的常规结构

ResNet的基本单元由两条路径组成:主路径执行卷积、BN、激活,捷径路径把输入直接加到主路径输出上。以最经典的BasicBlock为例:

  • 主路径:3x3卷积 -> BatchNorm -> ReLU -> 3x3卷积 -> BatchNorm
  • 合并操作:主路径输出与捷径输出做逐元素相加
  • 相加之后再过一个ReLU作为最终输出

这里的顺序值得掰扯。第一个卷积前要不要先过ReLU?原始ResNet中的BasicBlock第一个卷积是直接接收输入的,所以流程是 Conv-BN-ReLU-Conv-BN,然后 add + ReLU。也有人喜欢改成 Conv-BN-ReLU-Conv-BN-ReLU,但这些细节对最终精度影响不大,关键是必须保证相加的两个张量形状一致且语义上能对应。

两个3x3卷积堆叠的感受野是5x5,但参数数量是 2×9×C×C = 18C²,而直接用一个5x5卷积需要 25C²,所以残差块在参数上更省,非线性表达能力却几乎不输。这就是ResNet中大量使用“两个3x3代替一个5x5”的原因。

2.2 两种残差块设计:BasicBlock 与 Bottleneck

不同深度的ResNet,残差块形状不一样:

  • ResNet18/34使用BasicBlock:每层两个3x3卷积,通道数通常不变
  • ResNet50/101/152使用Bottleneck:每层三个卷积,形状为 1x1->3x3->1x1

Bottleneck的设计核心是先用1x1卷积压缩通道数,比如从256压缩到64,再用3x3卷积做空间特征提取,最后用1x1卷积把通道数恢复到256。三明治结构让中间3x3卷积的计算量大幅下降,整体参数和FLOPs都更可控。

以输入256通道为例算一下:

  • BasicBlock 两个3x3:2 × 3 × 3 × 256 × 256 = 1,179,648 个乘法操作
  • Bottleneck 1x1-3x3-1x1:1×1×256×64 + 3×3×64×64 + 1×1×64×256 = 16,384 + 36,864 + 16,384 = 69,632

差了近17倍。所以深层网络用Bottleneck不只是为了提点,更是为了让计算量不要爆炸。如果你在搭建50层以上网络时还沿用BasicBlock,显存和训练时间都会很不舒服。

2.3 下采样与捷径连接的三种处理方式

残差块的输入输出shape必须一致,相加才能成立。一旦需要下采样或者升维,有几种做法:

  • 直接在捷径分支使用 stride=2 的1x1卷积,同时改变通道数
  • 直接在捷径分支做全局平均池化,再接1x1卷积调整通道
  • 使用恒等映射加零填充补齐通道,也就是说输入本身不经过任何操作,只在相加时把多出来的通道补0

第一种是最常用的,ResNet官方实现也采用这种方式。第二种在部分变体里出现过,效果往往略好但实现稍复杂。第三种省参数,但语义上补0通道会让网络难以利用这部分信息,实际用的不多。

需要注意的是,下采样卷积的stride放在哪个卷积层。BasicBlock做下采样时,官方代码把stride=2放在两个3x3卷积的第一个上。Bottleneck做下采样时,stride既可以放在中间的3x3卷积,也可以放在第一个1x1卷积。经验上放在第一个1x1上通常信息损失更少,因为1x1卷积会先对空间位置逐点重映射,如果直接stride=2,就相当于跳过了一半像素点。很多经典实现都放在第一个1x1上,你可以对比实验看看。

3. 常见ResNet变体与整体设计规律

3.1 ResNet家族速览

常被挑做Backbone的ResNet版本主要是这几个:

网络残差块类型总层数典型场景
ResNet18BasicBlock18轻量任务、嵌入式、快速验证
ResNet34BasicBlock34中轻度任务
ResNet50Bottleneck50多数视觉任务的默认选择
ResNet101Bottleneck101精度要求高的任务
ResNet152Bottleneck152大规模数据集极限精度

层数计算有个公式,以ResNet50为例:

  • 起始一个7x7卷积加一个最大池化,不算进基本残差块
  • 接下来四个Layer分别包含 3、4、6、3 个Bottleneck
  • 每个Bottleneck包含3个卷积,所以残差块总卷积数 = (3+4+6+3) × 3 = 48
  • 最后一个分类层前的全局平均池化不算卷积,但在统计总层数时常被加入,最终把初始卷积加上得到50层

如果你自己动手数,可能有人把BN层也算进去,就会得到更复杂的数字。在论文语境里,层数通常只统计带权重的卷积层和全连接层,BN不算。

3.2 通道数翻倍与连续下采样

ResNet的特征提取部分分成Stage2到Stage5,通道数从64开始,依次翻倍为128、256、512,对应特征图分辨率逐级减半。这种“分辨率下降、通道数上升”的结构是后来所有主干网络的基本范式,因为高分辨率层关注细节但通道可以少,低分辨率层感受野大但需要更多通道来表达语义。

每个Stage的开头第一个残差块负责下采样。以ResNet50为例,Stage3的第一个Bottleneck会把输入的 56×56×128 变成 28×28×256,这是通过把第一个1x1卷积stride设为2实现的。之后同Stage内的其他残差块stride都保持1,分辨率不再下降。

有次某同学问为什么特征图在Stage内还要反复做3x3卷积,这不浪费计算吗?实际上同一分辨率下的多个残差块是在逐步增加非线性映射能力,让网络在保持分辨率的同时提取更抽象的特征。如果每个Stage只放一个残差块,网络就很浅,效果会明显下滑。

4. 从零搭建ResNet:以PyTorch为例

4.1 基础模块代码实现

直接上代码,这里用PyTorch的常用写法,拆成BasicBlock和Bottleneck两个类。

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.relu = nn.ReLU(inplace=True) self.downsample = downsample def forward(self, x): identity = x if self.downsample is not None: identity = self.downsample(x) out = self.conv1(x) out = self.bn1(out) out = self.relu(out) out = self.conv2(out) out = self.bn2(out) out += identity out = self.relu(out) return out

Bottleneck略复杂一点,需要注意通道数变化逻辑:

class Bottleneck(nn.Module): expansion = 4 def __init__(self, in_channels, out_channels, stride=1, downsample=None): super().__init__() # 第一个1x1:降通道 self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) # 第二个3x3:空间卷积,stride决定是否下采样 self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) # 第三个1x1:恢复4倍通道 self.conv3 = nn.Conv2d(out_channels, out_channels * self.expansion, kernel_size=1, bias=False) self.bn3 = nn.BatchNorm2d(out_channels * self.expansion) self.relu = nn.ReLU(inplace=True) self.downsample = downsample def forward(self, x): identity = x if self.downsample is not None: identity = self.downsample(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) out += identity out = self.relu(out) return out

这里全部用了 bias=False,因为后续有BatchNorm,卷积的偏置会被BN的均值减法抵消,留着只会浪费参数和显存。这是很多人容易踩的小坑。

4.2 构建完整网络骨架

定义好残差块后,再搭整体框架。主要包含四个Stage和最后的分类头。

class ResNet(nn.Module): def __init__(self, block, layers, num_classes=1000): super().__init__() self.in_channels = 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], stride=1) 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) def _make_layer(self, block, out_channels, blocks, stride=1): downsample = None if stride != 1 or self.in_channels != out_channels * block.expansion: downsample = nn.Sequential( nn.Conv2d(self.in_channels, out_channels * block.expansion, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(out_channels * block.expansion), ) layers = [] layers.append(block(self.in_channels, out_channels, stride, downsample)) self.in_channels = out_channels * block.expansion for _ in range(1, blocks): layers.append(block(self.in_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.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的第一个残差块负责下采样和通道调整,所以单独构造downsample,后面的残差块都保持空间尺寸和通道数不变。这种写法与官方实现基本一致,后续加注意力模块或FPN时也方便插入。

实例化不同的深度:

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)

4.3 输入尺寸与参数计算

官方默认输入是 224×224 的RGB图。以ResNet50为例,前向传播各阶段特征图变化如下:

  • conv1:224×224×3 -> 112×112×64
  • maxpool:112×112×64 -> 56×56×64
  • layer1:56×56×64 -> 56×56×256
  • layer2:28×28×128 -> 28×28×512
  • layer3:14×14×256 -> 14×14×1024
  • layer4:7×7×512 -> 7×7×2048
  • avgpool:7×7×2048 -> 1×1×2048
  • fc:2048 -> 1000

如果你输入尺寸不是224,比如是320或512,也不用改结构。AdaptiveAvgPool会保证最后一层池化输出永远是1×1,所以全连接层能接上。但要注意,小尺寸输入时maxpool之后特征图可能小于7×7,或者直接变成奇数尺寸,部分Stage的卷积加padding=1后能够适配大多数尺寸,不过resize到官方标称尺寸通常最稳。

关于参数数量,ResNet50大约25.6M,ResNet101约44.5M,这个量级在很多任务里算“中等偏大”。使用Bottleneck后参数集中在1×1卷积,其中很多是在调整通道。

4.4 初始化策略:为什么要有专门的init

PyTorch默认初始化在深层网络里效果其实一般,ResNet论文作者专门提出了适合ReLU的初始化方法——何恺明初始化。PyTorch里可以用如下方式手动给网络做初始化:

def init_weights(m): 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) elif isinstance(m, nn.Linear): nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') nn.init.constant_(m.bias, 0) model.apply(init_weights)

这里使用 fan_out 模式,是因为前向传播时希望保持响应方差稳定,同时配合ReLU的非线性。BN的weight初始化为1,bias为0则是最常规做法。如果你用迁移学习加载预训练权重,初始化这步就可以跳过,因为预训练模型里的值已经经过了大量数据调整,重新初始化反而会破坏预训练特征。

5. 训练实战与调参经验

5.1 数据增强与超参基线

搭建好模型只是起点,真正让ResNet起效果的是训练配置。以ImageNet规模的数据集为例,一套在实战中广泛验证过的配置大致是:

  • 输入尺寸:随机resize到256后随机裁剪224×224
  • 水平随机翻转:概率0.5
  • 颜色抖动:亮度、对比度、饱和度变化0.2~0.4
  • 标称化:均值 [0.485, 0.456, 0.406],标准差 [0.229, 0.224, 0.225]
  • 优化器:SGD + Momentum 0.9 + Weight Decay 1e-4
  • 批次大小:256(多卡时按卡数等倍扩大)
  • 学习率初始:0.1(批次256对应的标称值)
  • 学习率调整:每30个epoch降到原来的1/10,共90epoch

如果是小数据集,batch size没那么大,学习率也要等比缩放。一个经验法则是:batch size翻倍,学习率也翻倍;batch size减半,学习率减半。直接拿着0.1跑到batch size 32上,很容易发散。

关于weight decay,ResNet通常用1e-4,太大容易欠拟合,太小容易过拟合。BN层里的scale参数和卷积权重一样会被衰减,所以不要把BN参数从weight decay中排除,除非你做的是超大规模蒸馏等特殊场景。

5.2 学习率策略与迁移学习

ResNet训练中,warmup对超大规模batch很有帮助。线性warmup 5个epoch,把学习率从0.05倍目标值慢慢升到目标值,能有效避免前期loss突然冲高。这是因为大batch全局梯度方向比较稳定,但如果学习率一开始就太大,BN统计量还没来得及稳定,会带来训练震荡。

如果不想从头训练,加载预训练权重做迁移学习是更常见的做法。在分类任务中,通常把最后一层全连接替换成自己的类别数,然后选择微调策略:

  • 只训练新全连接层,冻结前面所有层的参数,适合数据量极少的情况
  • 以较低学习率(如0.001~0.01)训练全部层,适合中等数据量
  • 在骨干网络后面加可训练的线性探测层,先固定骨干跑几个epoch,再解锁全网络微调

我实际实验过,很多时候冻结前几层、微调后面几个Stage,效果比全量微调更稳,尤其是数据分布和预训练数据集差异较大时。如果数据和预训练数据同域,全量微调通常最好。

5.3 Batch Size 与显存优化

ResNet50跑224×224,batch size 32在常见10G显存卡上勉勉强强,batch 16更稳。想省显存有几招:

  • 把激活函数换为inplace=True,理论上能省一些内存,虽然实际不容易量化
  • 用梯度累积模拟大batch:batch size 32,梯度累积4步,等效batch 128
  • 在BatchNorm开启时,梯度累积前后损失需要除以累积步数,并且每步backward后要手动清零梯度

另一个要注意的是混合精度训练。AMP自动混合精度对ResNet加速明显,显存占用也能砍掉不少。开启前建议先确认BN计算是否保持FP32,大多数框架默认会保持,这点不用担心。

6. 常见问题与排查技巧

6.1 训练不收敛:先排除结构错误

ResNet最常见的“不收敛”其实是代码错误,而不是超参问题。症状往往是loss震荡、降不下去,或者提前降到某个值后卡死。

排查顺序:

  1. 输入输出维度是否匹配,用随机输入单步前向测试
  2. shortcut相加的位置是否正确,是否在最后ReLU之前
  3. BN是否在train和eval模式下都能正常运行
  4. 标签是否从0开始,loss是否计算正确
  5. 是否做了正确的数据归一化

最快的方法是先用很小的数据集(比如几百张图)拟合看看,如果loss能降到接近0,说明结构能work,那问题出在数据或超参;如果小数据上依然不收敛,那就是结构写错了。

6.2 显存不足,多数是特征图缓存爆炸

ResNet显存占用大头是前向缓存的特征图,尤其前两个Stage虽然大通道不多,但分辨率高,每个tensor都不小。遇到OOM可以这样尝试:

  • 减小batch size,从32降到16
  • 关闭计算图缓存只做测试时,用torch.no_grad()
  • 使用混合精度
  • 降低输入分辨率,但注意最终精度会受影响
  • 检查是否有不必要的中间变量被保留

还有个容易忽略的点:训练时每个epoch前要调用model.train(),验证时调用model.eval(),忘记切换会让BN统计量一直在更新,导致验证阶段表现时好时坏,而且会让梯度计算量加倍,显存压力也变大。

6.3 精度上不去:从数据与训练细节找原因

如果网络结构没问题,但分类精度总差着几个点,优先检查:

  • 是否做了随机裁剪和翻转,若只做中心裁剪,模型很容易过拟合
  • 学习率调度是否正确,很多情况是最后一阶段学习率没降够
  • 训练epoch是否足够,ResNet在ImageNet上往往要到90~100个epoch才完全收敛
  • 数据是否有标签噪声或者预处理不一致

我见过有人把图片归一化做错,减均值前没有将图像从0~255缩放到0~1,导致输入特征集中在很大的绝对值上,BN虽然能兜底一部分,但效果始终差一些。这类低级问题排查起来最费时间,建议先打印一批输入tensor的数值范围确认。

6.4 残差块实现易错点速查表

错误点具体表现解决方案
捷径分支忘记下采样相加时shape不匹配报错在downsample中设置stride
下采样位置错误特征图分辨率降得太早确保下采样只发生在每个Stage第一个块
BN放在ReLU之后训练稳定但收敛慢按Conv-BN-ReLU顺序
最后一个ReLU在相加前残差被截断,效果下降确保add后再ReLU
conv层bias=True参数冗余且精度无益设置bias=False
多个Stage通道数没翻倍模型容量不足检查_make_layer输出通道

7. 最后分享一点个人心得

ResNet这个结构看起来简单,但它的设计思路影响了好几年的Backbone演进。理解了残差连接为什么有效,后续再看DenseNet、ResNeXt、SENet都会轻松很多。如果你是自己动手搭网络,我建议先搭出一个能跑通的ResNet18,在CIFAR或者自己的小数据集上从零训练到80%以上准确率,再逐步加深。别一上来就上152层,那样出了问题你根本不知道是结构问题还是优化问题。把基础知识夯实,后面才能真正玩转各种复杂结构。

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

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

立即咨询