尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

ResNet-50结构拆解:残差连接、Bottleneck与训练调参

ResNet-50结构拆解:残差连接、Bottleneck与训练调参 不管你是刚接触深度学习的初学者还是已经跑过不少模型的开发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)-xshortcut 把输入直接送到加法节点不引入额外参数也不占用额外计算。带来的直接好处是反向传播时梯度可以从深层经过 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 个卷积所以 346316 个 Bottleneck16×348 个卷积加上 stem 的 1 个卷积再加上最后全连接分类层的 1 个线性层总共 49150。这个口径和 torchvision 官方实现是吻合的。不同深度的 ResNet 对比如下模型每个stage的block数量block类型参数量约输入224x224的FLOPsResNet-18[2,2,2,2]BasicBlock11.7M1.8 GFLOPsResNet-34[3,4,6,3]BasicBlock21.8M3.6 GFLOPsResNet-50[3,4,6,3]Bottleneck25.6M4.1 GFLOPsResNet-101[3,4,23,3]Bottleneck44.5M7.6 GFLOPsResNet-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×3stem7×7 卷积、输出通道 64、步长 2得到 112×112×64然后接 BNReLU再经过 3×3 最大池化步长 2得到 56×56×64layer13 个 Bottleneck输出通道 256空间尺寸保持 56×56layer24 个 Bottleneck第一个 Bottleneck 里完成下采样输出尺寸 28×28通道 512layer36 个 Bottleneck第一个 Bottleneck 下采样输出尺寸 14×14通道 1024layer43 个 Bottleneck第一个 Bottleneck 下采样输出尺寸 7×7通道 2048全局平均池化变成 1×1×2048再接一个全连接层输出 1000 类 logits各阶段输出尺寸变化阶段输出尺寸通道数相对输入的下采样倍数输入224×22431xstem后56×56644xlayer156×562564xlayer228×285128xlayer314×14102416xlayer47×7204832x下采样不是每层都做而是每个 stage 的第一个 Bottleneck 通过 stride2 完成。这一点对理解网络结构非常关键如果自己搭网络时只改一个 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-503.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, stride1, downsampleNone): super().__init__() self.conv1 nn.Conv2d(in_channels, mid_channels, kernel_size1, biasFalse) self.bn1 nn.BatchNorm2d(mid_channels) self.conv2 nn.Conv2d(mid_channels, mid_channels, kernel_size3, stridestride, padding1, biasFalse) self.bn2 nn.BatchNorm2d(mid_channels) self.conv3 nn.Conv2d(mid_channels, mid_channels * self.expansion, kernel_size1, biasFalse) self.bn3 nn.BatchNorm2d(mid_channels * self.expansion) self.relu nn.ReLU(inplaceTrue) 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因为后面跟 BatchNormbias 会被 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_classes1000): super().__init__() self.in_channels 64 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) self.layer1 self._make_layer(block, 64, layers[0], stride1) self.layer2 self._make_layer(block, 128, layers[1], stride2) self.layer3 self._make_layer(block, 256, layers[2], stride2) self.layer4 self._make_layer(block, 512, layers[3], stride2) 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, stride1): 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_size1, stridestride, biasFalse), 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, modefan_out, nonlinearityrelu) elif isinstance(m, nn.BatchNorm2d): nn.init.constant_(m.weight, 1) nn.init.constant_(m.bias, 0) def resnet50(num_classes1000): return ResNet(Bottleneck, [3, 4, 6, 3], num_classesnum_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(pretrainedTrue)加载 ImageNet 预训练权重然后在自己的数据集上微调而不是从零开始训练。原因很直接ImageNet 预训练权重里的浅层特征比如边缘、纹理、局部形状对绝大多数图像任务都有用。微调通常几十个 epoch 就能拿到不错的效果而从零训练要自己面对数据规模、算力限制和调参周期的三重压力。如果确定了要从零训练常规参考配置如下优化器SGDmomentum0.9weight decay1e-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 对应 lr0.1batch size 改成 512lr 先按比例放大到 0.2 左右再观察。如果显存不够用优先开混合精度而不是盲目调小学习率。4. 训练ResNet-50时踩过的坑与排查手册4.1 训练不收敛、loss爆炸怎么办ResNet-50 虽然结构成熟但训练时照样会出问题。最常见的场景是第一个 epoch 还没跑完loss 直接变成 NaN或者从正常的 1.x 突然冲到几千。可能原因通常是学习率太大、数据没有正确归一化、BN 层在 batch size 很小时统计不稳定。我的排查步骤有固定的顺序先确认输入像素是否除以 255 并做了标准化最好打印一个 batch 的数值范围看一眼。再确认模型里的 BN 有没有写漏或者卷积层是否同时加了 bias 和 BN。然后看初始学习率batch size 较小时 lr 通常不能直接上 0.1。比如单卡 batch size 32 时我一般从 0.01 开始试。如果问题依旧先把混合精度关掉全 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。我的常规做法是这样加载 ImageNet 预训练权重替换最后的全连接层类别数改成自己的任务先把所有层冻结只训练新加的 FC 层观察 loss 正常下降后再解冻部分深层如果数据量特别小解冻阶段要把学习率分两级新层用 0.01预训练层用 0.001 甚至更低数据增强一定要做随机裁剪、随机水平翻转、色彩抖动对鲁棒性提升非常明显。还要注意一个容易被忽略的点如果你是在 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 或改用 GroupNorm5. 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 都会快很多。希望这篇拆解能让你少走一些弯路也欢迎你在自己的实验里把踩过的坑记下来这些经验往往比结构图本身更值钱。
返回列表