ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ResNet-50结构详解:残差连接与Bottleneck如何让50层深度网络高效训练

ResNet-50结构详解:残差连接与Bottleneck如何让50层深度网络高效训练 我第一次把ResNet-50的网络结构画到纸上是在一个周六的下午。当时我已经被各种“深度残差学习”的论文解读绕得晕头转向干脆自己把每一层的输入输出、卷积核大小、stride、通道数全部列了出来。结果发现ResNet-50并没有想象中那么复杂它本质上就是把一堆相似结构的bottleneck block串起来用残差连接把梯度“抄近道”送回浅层从而让50层网络真正训练得动。这篇文章我尽量用“干活”的口吻写。适合刚入门深度学习、想彻底看懂ResNet-50结构的人也适合那些要在自己数据集上微调ResNet-50、但总被各种细节坑到的同学。你不需要提前背熟所有层名我会从“为什么要有残差”开始讲然后一路把每个阶段的尺寸变化、参数量来源、实际训练注意事项都过一遍。1. 从50层的“深”说起ResNet-50到底牛在哪1.1 50层这个数字是工程权衡的结果现在大家动不动就上百层觉得50层不算深。但放到ResNet提出的年代VGG最深的版本也就是19层GoogleNet是22层。更深的网络不是没人试过而是训练效果反而更差网络越深训练集上的loss越高。这个现象后来被叫做“退化问题”。它不是因为过拟合而是因为深层网络在做恒等映射时优化困难。ResNet-50的出现把“更深”变成了可能但它并没有盲目堆层。50层的构成非常清楚1个7×7卷积做stem带stride2把224×224的输入变成112×1121个3×3最大池化再把尺寸减半到56×56后面是4个stage分别有3、4、6、3个bottleneck block每个bottleneck block内部有3个卷积所以这4个stage贡献了(3463)×348个卷积层最后接一个全局平均池化和1个全连接分类层。也就是说1个stem卷积 48个bottleneck卷积 1个全连接刚好对得上“50”这个数字。这个设计不是玄学它有明确的阶段划分、统一的残差模块、清晰的通道数扩张节奏读代码的时候非常舒服。1.2 残差模块到底解决了什么问题假设我们希望一个深层网络学到的映射是H(x)。如果直接堆卷积层去拟合H(x)随着层数增加梯度在反向传播时连乘次数太多容易指数级变小或者爆炸。ResNet的解决办法是不让每一层直接学H(x)而是学残差F(x)H(x)−x然后把输入x原封不动地加到输出上得到F(x)x。这个改动在数学上非常朴素但效果极好。对于已经训练好的浅层网络如果我们要在上面加更多层最理想的情况是新增层至少能做一个恒等映射。对残差模块来说想退化成恒等映射很容易只要让F(x)趋向0就可以了。而在普通卷积堆叠里想让深层网络自动学到恒等映射非常困难。我在实际调试中体会最深的一点是残差连接给优化器留了一条“保底路径”。即使新加的层学不到什么有效特征至少梯度可以通过shortcut直接传回去不会因为中间层的连乘效应而消失。这也是为什么后来很多网络都把ResNet当默认骨架而不是因为它有什么神奇的卷积核而是它的训练稳定性太好了。2. 真正吃透bottleneck block才能看懂之后所有变体2.1 三个卷积像一场“压缩-计算-恢复”的操作ResNet-50里最核心的模块不是普通卷积块而是bottleneck block。以stage2里的一个block为例输入输出通道数都是256但中间瓶颈只有64个通道。第一个1×1卷积把256通道压缩到64通道计算量大幅度降低第二个3×3卷积在64通道上做真正的空间特征提取第三个1×1卷积把64通道再扩张回256通道保持输入输出形状一致方便加shortcut。为什么要绕这么一圈直接算一笔账就明白了。如果不用bottleneck直接用两个3×3卷积把256通道映射到256通道单层参数量是256×256×3×3≈59万两层就是118万。而bottleneck block的三个卷积加起来是256×6464×64×3×364×256≈7万参数差了十几倍。所以ResNet-50能用50层保持参数可控关键不在“更深”而在“更省”。这种“通道先压缩再扩张”的设计后来也出现在MobileNet、EfficientNet等很多网络里是很值得记住的一种工程思路。2.2 残差连接里的identity和projection shortcut在一个block内部shortcut的加法必须保证x和F(x)形状一致。大多数bottleneck block的输入输出通道数是一样的直接相加就行这种叫identity shortcut。但在每个stage的第一个block里输出通道数会翻倍空间尺寸也可能减半。比如从stage2进入stage3时输入是56×56×256输出要求变成28×28×512。这时候x不能直接用了需要做一个projection shortcut用一个1×1卷积改变通道数如果空间尺寸变了就在这个1×1卷积里设置stride2顺便把分辨率降下来。projection shortcut虽然会引入少量额外参数但换来的是整个网络可以顺畅地跨stage传递信息。我在自己实现时习惯把它写成独立的downsample层方便在forward里判断是否需要调用。2.3 BN和ReLU的摆放顺序决定了训练稳定性原始ResNet论文里的block顺序是卷积 → BN → ReLU。每个卷积后面都接BN只有最后一个卷积的BN输出先做shortcut加法再过一个ReLU。这个细节容易被人忽略但它很关键。如果ReLU放在shortcut加法之前那残差流就会被截断x的负值部分可能在相加之前就被置0这会影响梯度传播。原版把shortcut加法放在最后的ReLU之前等于把“原始信息”和“学习到的残差”先融合再统一做非线性激活。后来很多实现用pre-activation结构也就是把BN和ReLU挪到卷积前面理论上更好但原版这个顺序已经足够稳定。我自己写PyTorch代码时会按这样的逻辑来组织一个bottleneck blockimport torch.nn as nn class Bottleneck(nn.Module): def __init__(self, in_channels, mid_channels, out_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, out_channels, kernel_size1, biasFalse) self.bn3 nn.BatchNorm2d(out_channels) 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这个模块如果你能闭着眼写出来ResNet-50一半就算掌握了。剩下的只是反复堆叠这个模块并处理好每个stage之间的通道和stride变化。3. 把输入输出尺寸追一遍50层就没那么吓人了3.1 从224×224到7×7的前向过程理解ResNet-50最快的方式不是背层名而是从输入数据出发把每个阶段的张量尺寸逐一写下来。阶段输入尺寸关键操作输出尺寸stem224×224×37×7卷积stride2112×112×64stem112×112×643×3最大池化stride256×56×64conv2_x56×56×643个bottleneck block输出通道25656×56×256conv3_x56×56×2564个bottleneck block首个block使用stride228×28×512conv4_x28×28×5126个bottleneck block首个block使用stride214×14×1024conv5_x14×14×10243个bottleneck block首个block使用stride27×7×2048classifier7×7×2048全局平均池化 全连接1000我刚开始一直困惑stage2里的block输出不是64吗为什么表里写256后来才反应过来bottleneck block内部虽然有64通道的中间层但每个block的最终输出通道是mid_channels的4倍。也就是说stage2的3个block每个的输入输出都是256通道shortcut可以直接相加不需要projection。真正需要downsample的是conv3_x、conv4_x和conv5_x这三个阶段的第一个block。空间尺寸的下降节奏也很规律从56到28从28到14从14到7每次都是在下采样block里由3×3卷积的stride2完成。如果只看最终的特征图ResNet-50输出的7×7×2048张量已经是一个非常抽象的高维特征后面接任意分类头或者检测头都很有代表性。3.2 参数量不是均匀分布的大头在最后的stageResNet-50总参数量大约25.6MFLOPs大约4.1G。很多人以为50层网络参数会均匀分布在每一层其实不是。来看几个阶段的粗略估计阶段参数来源大概参数量stem7×7卷积约0.01Mconv2_x3个bottleneck输入输出256约0.2Mconv3_x4个bottleneck输出512约1.2Mconv4_x6个bottleneck输出1024约7.1Mconv5_x3个bottleneck输出2048约15Mclassifier2048×1000全连接约2M最后的conv5_x只有3个block但参数占比最高因为它的通道数已经到了2048。这提醒我们一个很实际的问题如果你要在自己的任务里做轻量化与其到处改结构不如先看最后几个stage的通道数是不是真需要这么大。FLOPs也是类似绝大部分计算量集中在stage4和stage5。很多剪枝方法会优先去剪这两个stage的冗余通道效果往往比均匀压缩前面所有stage好得多。4. 实际训练或微调ResNet-50时最容易翻车的三个细节网络结构看明白了不等于跑得好。我自己在真实项目里用ResNet-50踩过不少坑下面这三个几乎每次换数据集都会遇到。4.1 输入尺寸和预处理不要乱换ResNet-50预训练模型在ImageNet上用的是224×224输入并配套一套固定的归一化参数。PyTorch的torchvision模型默认期望的mean是[0.485, 0.456, 0.406]std是[0.229, 0.224, 0.225]。如果你换成了自己的数据请务必保持这套归一化而不是自己算一遍mean/std就直接替换。因为预训练的卷积核权重是在原分布下学出来的你换成另一种分布前期几层可能就要重新适应。很多微调任务一开始loss不降不是因为模型坏了而是输入的像素分布跟预训练权重对不上。如果你因为任务需要把输入改成384×384甚至更大也要注意ResNet-50的降采样倍数。它的总stride是32所以输入尺寸不能被32整除时最后特征图尺寸会很别扭后面接检测或分割头时容易出错。常见的办法是保持224×224或者把输入改成能被32整除的尺寸比如256、320、384。4.2 学习率和Batch Size必须配套调整ImageNet上训练ResNet-50的常见配置是batch size256初始学习率0.1用SGD加momentum。但我们在自己的数据集上通常没有那么大batch size比如只能用8或者16。这时候如果还沿用0.1的学习率优化过程大概率会非常不稳定。比较稳妥的做法是线性缩放batch size减半学习率也减半。batch size从256降到32相当于缩小了8倍学习率可以尝试从0.1降到0.0125左右。另外还要加warmup前几个epoch用很小的学习率让BN统计量稳定下来再慢慢升到目标学习率。我自己的经验是ResNet-50在迁移学习/微调时初始学习率从0.001到0.01之间比较常见后面配合余弦退火或step decay。相比从零训练微调时更怕学习率太大因为预训练特征很容易被破坏。4.3 冻结backbone时BatchNorm是最大的坑很多做目标检测或特征提取的同学喜欢把ResNet-50当作backbone冻结起来只训练后面的任务头。听上去没问题但BatchNorm层会给你使绊子。ResNet-50每个卷积后面都跟着BN。BN在训练模式下会统计当前batch的均值和方差来做归一化同时更新全局running stats。如果你把整个backbone的requires_grad设成False但网络仍然处于train模式BN依旧会用当前batch的统计量而不是用预训练时累积的全局统计量。这会导致特征分布和预训练权重不匹配训练初期很容易崩。常见解决办法有两种把backbone里的BN层设成eval模式用预训练好的running stats做推理式的前向计算不冻结整个backbone只冻结前面几个stage把最后的stage和任务头一起参与微调。第二种方式现在更主流因为保留部分backbone的可学习能力在目标任务数据量适中时效果更好。只冻结前3个stage微调conv4_x之后的层通常能省显存又不会破坏太多预训练特征。5. ResNet-50在今天的位置不再是最前沿但仍是最值得先跑的基线5.1 它依然是实验对比里的“默认单位”这些年新模型层出不穷EfficientNet、RegNet、Vision Transformer都有更强的表现。但在论文和实际项目中ResNet-50仍然是最常见的baseline之一。原因很简单大家太熟悉它了任何改动都能精确归因到具体模块上。如果你刚开始做一个新的视觉任务我强烈建议第一版模型先上ResNet-50。它稳定、容易训、显存压力小、社区资料多。等ResNet-50的结果跑通了再用ResNet-101、Swin Transformer或者自己的魔改结构去替代它这样至少能保证有一个可靠的对照点。否则一上来就用很复杂的结构出了问题你会分不清是数据问题、训练策略问题还是模型结构问题。5.2 作为backbone它的扩展能力非常强ResNet-50不只是用来做分类。目标检测里的FPN、语义分割里的DeepLab、关键点检测里的hourglass设计很多都能用ResNet-50当骨架。它的分层特征正好对应不同语义层级浅层分辨率高、适合定位深层通道多、适合分类。把stage3、stage4、stage5的输出接进FPN就能组成一个非常经典的多尺度检测头。我经常跟身边人说如果你能把ResNet-50的结构细节讲清楚再去理解YOLO、Mask R-CNN这些复杂模型会轻松很多。因为它们说自己“换了backbone”时本质上就是把ResNet-50的几个stage替换成更轻量或更强大的特征提取模块但整体设计逻辑仍然相似。如果让我给刚入门深度学习的同学一个建议我会说别急着刷论文先手写一个ResNet-50把每个block的通道变化、stride设置、shortcut实现都跑通。这个过程里你踩过的每一个坑几乎就是你以后调试所有CNN模型时都会遇到的坑。ResNet-50不是终点但它一定是性价比最高的起点。
返回列表