☰
ResNet-50结构拆解:残差连接、Bottleneck与训练调参
2026/9/30 1:21:43 网站建设 项目流程

不管你是刚接触深度学习的初学者,还是已经跑过不少模型的开发,ResNet-50都是一个绕不开的名字。它几乎成了视觉领域最常用的骨干网络:图像分类、目标检测、语义分割、特征提取,到处都能看到它的背影,甚至很多开源项目里默认给的预训练权重就是 ResNet-50。这篇文章就围绕ResNet-50的网络结构,从设计原理、逐层细节、代码实现到训练踩坑,完整拆一遍,争取让你看完后能直接动手复现和使用。

我不会只贴一张结构图就完事。结构图看十遍不如自己推导一遍维度、自己写一遍代码、自己把训练时炸掉的 loss 修好一次。理解 ResNet-50 的核心,不只是记住它有 16 个 Bottleneck、4 个阶段,而是要搞清楚三个问题:它为什么能解决网络加深后性能退化的问题?每一层的通道和分辨率是怎么变化的?换到自己的项目里该怎么搭、怎么训、怎么排查问题?下面一个个展开。

1. ResNet要解决什么问题,残差连接为什么有效

1.1 网络加深带来的退化问题

在 ResNet 出现之前,大家普遍认为网络越深、表达能力越强。可真的把网络从 20 层加到 56 层,结果并不是更好,反而在训练集上的误差更高。这个现象不是过拟合,因为训练误差本身都没降下来,说明深层网络的优化变难了。当时已经有一些办法能缓解梯度消失和梯度爆炸,比如 BatchNorm 和合理的初始化,但“退化”问题仍然存在。

“退化”的焦点在于:一个更深模型的最优解,应该至少不差于浅层模型,因为我们总可以让深层网络的后半段退化成恒等映射。可问题是,直接用一组非线性卷积层去拟合恒等映射是相当困难的。这里的关键是:让网络“学习一个零函数”比“学习一个恒等映射”容易得多。残差结构恰好把“学习恒等映射”的问题转化成了“学习残差趋向零”的问题,这样当深层网络不需要那么多容量时,残差块可以让主分支直接输出接近 0,输入原样送到输出端。这解释了我一直觉得很重要的一个点:残差不是让网络变聪明,而是把优化目标改简单了。

1.2 残差块:F(x)+x到底改了什么

残差块的核心就是一条 shortcut(跳连)和主分支相加:输出 H(x)=F(x)+x。主分支负责学习残差 F(x)=H(x)-x,shortcut 把输入直接送到加法节点,不引入额外参数,也不占用额外计算。带来的直接好处是:反向传播时,梯度可以从深层经过 shortcut 几乎无损地传到浅层,极大缓解了梯度消失问题。

我常用的类比是:残差机制不是让网络从零开始规划一条完整路线,而是让它在已经走过的路径上只修正偏差。比如你从一个地点出发,走错了一个角度,这个时候只需要纠正偏差方向,比从头推导整条路线要容易得多。从计算上看,shortcut 也几乎零成本,这对后续大规模堆积层数的方案非常重要。

提示:理解残差块时,要把它和 DenseNet 那种“特征重用”思路区分开。ResNet 用的是“学习增量”,DenseNet 是把所有层输出都拼起来作为下一层的输入,两者目的不同,网络连接方式也完全不同。

2. ResNet-50 结构逐层拆解:从 stem 到分类头

2.1 ResNet家族中的“50”是怎么数出来的

ResNet-50 里的“50”,是指网络中的“权重层”大约 50 层,BN、池化这些不算权重层。更具体一点:stem 里有 1 个 7×7 卷积;后面四个 stage 分别堆叠了 3、4、6、3 个 Bottleneck,每个 Bottleneck 内部有 3 个卷积,所以 3+4+6+3=16 个 Bottleneck,16×3=48 个卷积,加上 stem 的 1 个卷积,再加上最后全连接分类层的 1 个线性层,总共 49+1=50。这个口径和 torchvision 官方实现是吻合的。

不同深度的 ResNet 对比如下:

模型每个stage的block数量block类型参数量(约)输入224x224的FLOPs
ResNet-18[2,2,2,2]BasicBlock11.7M1.8 GFLOPs
ResNet-34[3,4,6,3]BasicBlock21.8M3.6 GFLOPs
ResNet-50[3,4,6,3]Bottleneck25.6M4.1 GFLOPs
ResNet-101[3,4,23,3]Bottleneck44.5M7.6 GFLOPs
ResNet-152[3,8,36,3]Bottleneck60.2M11.3 GFLOPs

从 50 层开始全部采用 Bottleneck,原因很简单:直接用两个 3×3 卷积去堆出更深网络,参数量和计算量会爆发。Bottleneck 通过先降维、再卷积、最后升维的方式,让模型在可控复杂度下继续加深。ResNet-50 也是在“精度”和“代价”之间最均衡的版本,这也是它为什么在工程里用得最多。

2.2 224x224图像在ResNet-50里的完整旅程

我用一张 224×224 的 3 通道输入图,走一遍标准 ResNet-50 的完整流程。

  • 输入:224×224×3
  • stem:7×7 卷积、输出通道 64、步长 2,得到 112×112×64,然后接 BN+ReLU,再经过 3×3 最大池化,步长 2,得到 56×56×64
  • layer1:3 个 Bottleneck,输出通道 256,空间尺寸保持 56×56
  • layer2:4 个 Bottleneck,第一个 Bottleneck 里完成下采样,输出尺寸 28×28,通道 512
  • layer3:6 个 Bottleneck,第一个 Bottleneck 下采样,输出尺寸 14×14,通道 1024
  • layer4:3 个 Bottleneck,第一个 Bottleneck 下采样,输出尺寸 7×7,通道 2048
  • 全局平均池化变成 1×1×2048,再接一个全连接层,输出 1000 类 logits

各阶段输出尺寸变化:

阶段输出尺寸通道数相对输入的下采样倍数
输入224×22431x
stem后56×56644x
layer156×562564x
layer228×285128x
layer314×14102416x
layer47×7204832x

下采样不是每层都做,而是每个 stage 的第一个 Bottleneck 通过 stride=2 完成。这一点对理解网络结构非常关键:如果自己搭网络时只改一个 Bottleneck 的 stride,后续所有层的空间尺寸就会全部错位。另外要特别记住,layer4 是 3 个 Bottleneck,不是 6 个,网上很多结构图画错了。

2.3 Bottleneck的精妙之处:降维-卷积-升维

一个 Bottleneck 内部是典型的三段式结构:

  • 第一个 1×1 卷积,通道降到原来的 1/4(比如 256 → 64)
  • 第二个 3×3 卷积,在低维空间上做空域特征提取,通道数仍为 64
  • 第三个 1×1 卷积,把通道升到原来的 4 倍(64 → 256)

这样做最直接的优势是省参数。以输入 256 通道、输出 256 通道的残差块为例,如果不降维直接拿两个 3×3 卷积来做,第一个卷积 3×3×256×256 约 59 万参数,第二个同样约 59 万,合计近 118 万参数。而 Bottleneck 的三个卷积参数量大约是:1×1×256×64 约 1.6 万,3×3×64×64 约 3.7 万,1×1×64×256 约 1.6 万,合计约 6.9 万,两者差了一个数量级。省下来的参数量意味着同样显存可以堆更多层,这也是 ResNet-50 能比 VGG 训练得更快、更容易扩展的关键原因。

注意:1×1 卷积在 GoogLeNet 里就已经大规模使用,ResNet 把它放在残差块的瓶颈结构里,这个组合方式非常值得记住。后续很多模型,比如 MobileNet、EfficientNet,都吸收了这种“先压缩、后处理、再恢复”的设计思路。

3. 用PyTorch从零搭建ResNet-50

3.1 定义Bottleneck模块

想真正吃透网络结构,最好的方法就是自己动手写一遍代码。PyTorch 里实现 ResNet-50 非常方便,核心是 Bottleneck 类。这里需要注意,Bottleneck 的构造参数里有一个downsample,这个参数只有在 shortcut 需要改变通道数或下采样时才会传入。

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

这里面有三个细节值得强调。第一,所有卷积都不带 bias,因为后面跟 BatchNorm,bias 会被 BN 吸收,加了也是白加,还会多一份参数。第二,ReLU 只放在主分支的卷积之间和最后相加之后,shortcut 分支不经过任何激活函数,这样才能让信息无损传递。第三,如果输入输出通道数不一致,或者 stride 改变了空间尺寸,shortcut 必须加 downsample,否则out += identity这一步会直接报维度错误。

3.2 用_make_layer组织四个阶段

有了 Bottleneck,再写 ResNet 主体就很清晰了。每个 stage 由一个_make_layer函数负责,它的作用就是重复堆叠相同类型的 Bottleneck。

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) self._init_weights() def _make_layer(self, block, mid_channels, blocks, stride=1): downsample = None if stride != 1 or self.in_channels != mid_channels * block.expansion: downsample = nn.Sequential( nn.Conv2d(self.in_channels, mid_channels * block.expansion, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(mid_channels * block.expansion), ) layers = [] layers.append(block(self.in_channels, mid_channels, stride, downsample)) self.in_channels = mid_channels * block.expansion for _ in range(1, blocks): layers.append(block(self.in_channels, mid_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 def _init_weights(self): 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 resnet50(num_classes=1000): return ResNet(Bottleneck, [3, 4, 6, 3], num_classes=num_classes)

这个代码里有一个容易忽略但很关键的判断:_make_layer在 stride 不等于 1,或者输入通道数和输出通道数不相等时,会给 shortcut 加上一个 1×1 卷积和 BN。这个 downsample 就是为了让 shortcut 分支能正确对齐主分支的输出维度。如果漏掉这个判断,模型在 stage 切换时一定报错。

3.3 初始化、预训练与训练超参

初始化不是玄学,对 ResNet 来说,卷积层用 Kaiming 初始化,BN 的 weight 设为 1、bias 设为 0,是官方默认做法。BN 初始化尤其重要,它直接影响训练初期的数值稳定性。如果 BN 里 weight 被随机初始化成比较大的数,前几个 epoch 的 loss 可能会乱跳。

实际项目里,我更推荐直接用torchvision.models.resnet50(pretrained=True)加载 ImageNet 预训练权重,然后在自己的数据集上微调,而不是从零开始训练。原因很直接:ImageNet 预训练权重里的浅层特征,比如边缘、纹理、局部形状,对绝大多数图像任务都有用。微调通常几十个 epoch 就能拿到不错的效果,而从零训练要自己面对数据规模、算力限制和调参周期的三重压力。

如果确定了要从零训练,常规参考配置如下:

  • 优化器:SGD,momentum=0.9
  • weight decay:1e-4
  • 初始学习率:0.1,对应 batch size 为 256 的情况
  • 学习率策略:每 30 个 epoch 衰减 0.1,训练 90 个 epoch
  • 输入分辨率:224×224
  • 图像归一化:ImageNet 均值 [0.485, 0.456, 0.406],标准差 [0.229, 0.224, 0.225]

特别提醒一下,batch size 变了,学习率也要跟着变。最简单的经验是:基准 batch size 256 对应 lr=0.1,batch size 改成 512,lr 先按比例放大到 0.2 左右再观察。如果显存不够用,优先开混合精度,而不是盲目调小学习率。

4. 训练ResNet-50时踩过的坑与排查手册

4.1 训练不收敛、loss爆炸怎么办

ResNet-50 虽然结构成熟,但训练时照样会出问题。最常见的场景是:第一个 epoch 还没跑完,loss 直接变成 NaN,或者从正常的 1.x 突然冲到几千。可能原因通常是学习率太大、数据没有正确归一化、BN 层在 batch size 很小时统计不稳定。

我的排查步骤有固定的顺序:

  1. 先确认输入像素是否除以 255 并做了标准化,最好打印一个 batch 的数值范围看一眼。
  2. 再确认模型里的 BN 有没有写漏,或者卷积层是否同时加了 bias 和 BN。
  3. 然后看初始学习率,batch size 较小时 lr 通常不能直接上 0.1。比如单卡 batch size 32 时,我一般从 0.01 开始试。
  4. 如果问题依旧,先把混合精度关掉,全 float32 跑一遍,排除精度溢出导致的 NaN。

上面这个顺序基本能解决 90% 的问题。为什么要按这个顺序来?因为最便宜的问题,比如数据预处理,永远是优先检查项。先检查数据,再检查模型结构,最后才动优化器参数,这个思路能省大量时间。

4.2 显存不足、训练速度太慢

ResNet-50 显存占用和训练速度取决于输入分辨率、batch size、是否使用混合精度。训练慢除了硬件原因,多半是模型没有跑到最优的计算模式。至少可以做三件事:

  • 开启混合精度训练,大多数 GPU 上都能获得明显的内存下降和速度提升;
  • 适当增大 batch size,配合学习率策略调整;
  • 在部分硬件上尝试channels_last内存格式,可能加速卷积运算。

如果显存依然不够,用梯度累积。具体做法是把 batch 切小,多次前向计算得到的梯度累加后,每达到目标步数再做一次参数更新。但要注意,BN 层在 batch 太小时统计不准。一般来说,BN 的 batch size 至少要有 8 到 16。如果数据集很小,又必须用大 batch 的 BN,就需要考虑同步 BN 或者把 BN 换成 GroupNorm。

4.3 迁移学习和分类任务的实用建议

实际项目里,很少有人在一个自定义小数据集上直接从零训练 ResNet-50。我的常规做法是这样:

  1. 加载 ImageNet 预训练权重;
  2. 替换最后的全连接层,类别数改成自己的任务;
  3. 先把所有层冻结,只训练新加的 FC 层,观察 loss 正常下降后再解冻部分深层;
  4. 如果数据量特别小,解冻阶段要把学习率分两级:新层用 0.01,预训练层用 0.001 甚至更低;
  5. 数据增强一定要做,随机裁剪、随机水平翻转、色彩抖动,对鲁棒性提升非常明显。

还要注意一个容易被忽略的点:如果你是在 CIFAR 这类 32×32 小输入上自己跑 ResNet-50,标准 ImageNet 版的 stem 是 7×7 卷积加 maxpool,对 32×32 输入来说太大,特征图会直接塌缩。通常要改成 3×3 卷积且不带 maxpool。这也是很多人拿 CIFAR 复现 ResNet-50 时结果异常的原因之一。

常见问题速查表:

现象可能原因解决办法
loss 为 NaN学习率过大、输入未归一化、AMP溢出降低 lr、检查预处理、关闭 AMP 测试
loss 降不下去学习率太小、数据增强过强调大 lr、减弱增强强度
验证集准确率低过拟合、数据分布不一致增加正则、检查数据划分
显存 OOMbatch size 太大、分辨率太高降 batch、开 AMP、缩分辨率
维度对不上shortcut 没加 downsample检查 stride 与通道数变化
BN 在测试集表现差训练 batch size 太小增大 batch 或改用 GroupNorm

5. ResNet-50的后续演化与真实项目选型

5.1 从V1到V2:残差块的小改动带来大收益

ResNet-50 原版结构常被称为 ResNet V1,特点是先卷积、后 BN、再 ReLU,最后把残差相加后接一个 ReLU。后面出现的 ResNet V2 把 BN 和 ReLU 提前到卷积之前,叫预激活。因为 shortcut 分支上不再经过任何激活函数,整个网络的信息流更干净,训练更稳定,在极深网络下提升更明显。

我在工程里会把官方 V1 作为默认基线,因为它和 torchvision 预训练权重完全兼容,部署生态最好。但如果你是从零训练,而且层数很深,优先考虑 V2 的预激活形式。V1 胜在兼容性,V2 胜在训练稳定性和极深网络的表现,这个选择没有绝对的好坏,取决于你是否需要用预训练权重。

5.2 什么时候继续用ResNet-50,什么时候换其他结构

ResNet-50 虽然经典,但后来出现了 ResNeXt、RegNet、EfficientNet 甚至 ViT 等结构。选型不能只看榜单数字,要看任务、数据量和算力条件。

  • 数据量不大,比如就几千张图,算力有限:ResNet-50 是默认选择,预训练权重好找、社区资料多、调参经验丰富;
  • 数据量中等,需要更高精度:可以试 ResNet-101 或 ResNeXt-50,通过加宽网络来提升表达能力;
  • 追求高性价比和显存效率:EfficientNet 系列在同等 FLOPs 下精度往往更高,但需要根据硬件调整输入分辨率和缩放系数;
  • 数据量很大,有充足 GPU 资源:可以考虑 ViT/Swin Transformer,但它们的训练技巧和数据处理方式跟 CNN 差别不小,不一定适合新手直接上手。

在这个行业里,ResNet-50 更像一把“标准尺子”。很多论文的 baseline、开源框架的默认 backbone,都会用它来衡量新方法的收益。你完全可以先用 ResNet-50 建立自己的训练和评估流程,再决定要不要换更复杂的模型。

6. 实际应用中的几个补充细节

到这里,模型结构其实已经讲完了。但作为长期用 ResNet-50 落地项目的人,我还想再补几个平时文档里很少写清楚、但工程里很关键的细节。

第一个是输出特征图的使用。ResNet-50 不是只能做分类,去掉最后的分类头后,layer4 输出的 2048 维特征向量是通用特征提取结果,可以当作图像 embedding 用在检索、聚类、对比学习等任务里。layer3 和 layer4 的中间特征也常被用于检测和分割网络的多尺度特征金字塔,比如 Faster R-CNN 里就大量沿用这个设计。理解每个 stage 输出特征的位置和尺寸,比背结构图更有用。

第二个是部署时的优化。训练好的 ResNet-50 在推理阶段可以做一些结构化剪枝、蒸馏,也可以把 BN 层折叠进前面的卷积层,减少推理时间。实际用 TensorRT 这类推理引擎时,通常会把 BN 参数融合进卷积的 weight 和 bias,这样每个残差块就只剩纯卷积和 ReLU,速度提升比较明显。这个折叠操作思想不复杂,但实现细节很琐碎,用框架导出 ONNX 时多数工具已经自动处理了。

第三个关于微调关键点:如果新任务和 ImageNet 差别很大,比如医学图像、遥感图像,千万不要无脑冻结全部浅层。底层特征未必能直接迁移,反而要让浅层也能更新。可以只解冻最后两个 stage 试跑几轮,再根据验证集表现决定要不要继续解冻。这个思路比一上来就全量微调要稳定,尤其是数据里目标物体的外观、颜色、纹理和 ImageNet 差异很大的时候。

最后还是想多说一句:ResNet-50 这个模型看起来很经典,但踏踏实实把它跑通、跑熟,你学到的其实是深度学习在视觉领域最通用的一套打法。我自己带项目时,第一件事从来不追求最新结构,都是先把 ResNet-50 搭好、跑通、把训练流程和问题排查习惯建立起来,再往上去换更强的 backbone。这套基本功一旦扎实,后面接触 EfficientNet、Swin Transformer 都会快很多。希望这篇拆解能让你少走一些弯路,也欢迎你在自己的实验里把踩过的坑记下来,这些经验往往比结构图本身更值钱。

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

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

立即咨询