拓冰建站拓冰建站
首页 / 资讯中心 / 正文

残差块原理、实现与调试:从ResNet退化问题到PyTorch落地

深度学习里有个现象挺反直觉的你把网络往深了堆准确率不升反降。这不是过拟合也不是梯度消失这么简单而是一种被称作退化的问题。ResNet 和它里面的残差块就是冲着这个问题去的。我这些年做图像分类、检测、分割几乎每个项目的骨干网络都绕不开 ResNet而残差块的结构细节踩过的坑也不少。这篇就把残差块的原理、实现和调试经验,掰开揉碎讲一遍。不管你是刚学完卷积想搞明白 ResNet 到底在干什么还是已经用过 resnet 预训练模型但没细究过内部结构应该都能拿到点实在的东西。1. 为什么好好的深层网络会越深越差1.1 从VGG堆叠说起56层比20层更差这件事VGG 那波人证明了用小的 3x3 卷积核反复堆叠可以比大卷积核拿到更好的效果于是大家的直觉就是层数越多表达能力越强效果应该越好。这个直觉在浅层范围内成立20 层左右的网络确实比十几层的好。但当有人把普通的卷积网络堆到 56 层和 20 层的版本在同一个数据集CIFAR-10上用同样的训练策略对比时结果让人有点懵训练误差和测试误差56 层的那一个全都比 20 层高。这里的关键在于训练误差也变高了。如果是过拟合那应该是训练误差低、测试误差高。如果是梯度消失导致深层根本训不动那通常浅层也一起废掉。但实际观察到的是深层网络在训练集上都学不过浅层网络也就是说它连把已经学到的东西原样传下去这件事都没做好。换个说法假设 20 层网络已经能拟合出一个解了那我再往上加 36 层哪怕这 36 层什么都不做只做恒等映射输入等于输出整体至少不应该比 20 层差。可现实是让一堆卷积层去学恒等映射它学不会或者说很难学到。这就是退化问题的核心——不是深度本身有害而是让深层去拟合恒等映射太难了。1.2 退化不是过拟合也不是梯度消失那么简单很多人第一次接触这个会觉得是梯度消失。梯度消失在早期确实是个大麻烦尤其 Sigmoid 时代反向传播时梯度连乘指数级衰减。但到了 Batch NormalizationBN普及之后梯度消失已经被缓解了很多网络能训起来只是效果还是不对。所以退化问题的根子不完全在梯度上。我的理解是它更多是个优化难度的问题。一个未经残差改造的深层网络它的解空间里确实包含了恒等映射那个解但这个解在参数空间里的位置很尴尬随机初始化加上梯度下降的那条路径很难走到那里。优化器在几十上百层的非线性变换里翻山越岭很容易就卡在一个比浅层网络更差的地方。这也解释了一个现象加 BN 之后退化会轻一些但不会消失。BN 让每层的输入分布稳定相当于把山路修平了一点但没有改变要走到恒等解很绕这个事实。真正把这条路铺直的是残差连接。1.3 残差块给出的答案让网络学增量残差块的想法很朴素与其让一堆层直接去拟合目标映射 H(x)不如让它们去拟合残差 F(x) H(x) - x。最后输出写成 H(x) F(x) x那个加上的 x 就是所谓的捷径或者skip connection走的是恒等映射。这一步转换的妙处在于如果最优解本身就是恒等映射那网络只需要把 F(x) 的权重往 0 推就行这比硬生生学出一个恒等映射容易太多了。推 0 这件事对优化器来说几乎是本能——权重衰减、初始化偏小都天然往 0 靠。于是加多少层都至少不退化这个下限就被守住了最差的情况新加的层输出 0整体还是恒等映射效果和浅层持平。你可以把残差块想成是给网络装了个保底机制。每一组卷积层不再是必须完成一个完整变换而是只负责在当前特征上加一点修正。修正得好性能涨修正得烂最多不涨不会把原来的东西搞坏。这种设计让网络深度的收益变得可控ResNet 才能一口气堆到 152 层甚至上千层还能训得动。2. 残差块的核心原理拆解2.1 恒等映射与残差映射的数学表达形式化一点说一个残差块的输入是 x经过若干层通常带 BN 和 ReLU得到 F(x)最终输出 y F(x) x。这里的 F(x) 就是残差函数x 通过捷径直接加到输出上。这是最基础的形式后面所有的变体都是在这个骨架上做手脚。要注意 F(x) 和 x 的维度必须一致才能做逐元素相加。如果维度对不上比如中间做了下采样通道数变了、空间尺寸也变了那捷径上就得挂一个线性投影通常是 1x1 卷积加 BN把 x 变换成和 F(x) 同样的形状再相加。这一点后面会单独讲因为它是新手最容易出错的地方。从计算图上看前向传播多了条支路反向传播时梯度也多了一条几乎无损的路径。这是残差块最实在的收益下面细说。2.2 一条捷径如何改变梯度回传的路径假设损失函数是 L我们要算损失对浅层参数的梯度。在没有捷径的普通堆叠里梯度从深层传到浅层要连乘一长串雅可比矩阵链子越长越容易断或者爆。有了捷径之后梯度回传时除了走 F(x) 那条常规链路还能从 y 直接经过恒等映射流到 x这条路上没有参数、没有非线性等于开了一条高速公路。把 y F(x) x 对 x 求导得到 dy/dx dF(x)/dx 1。那个加 1 就是捷径贡献的。哪怕 dF(x)/dx 这一项因为深层连乘变得非常小甚至接近 0梯度整体也不会归零因为有那个 1 托底。这就是为什么残差网络能堆很深还训得动——梯度有一条几乎不衰减的通道直达浅层。这里有个细节值得说捷径上的恒等映射本身不带参数除非做投影所以它对梯度是透明的不引入额外的非线性。这也是它比普通层更容易传播梯度的原因。2.3 加法为什么必须是逐元素的残差块的融合方式是相加不是拼接concat。这个选择有讲究。拼接会把通道数翻倍后续层的参数量和计算量跟着涨堆几十个块下来显存直接爆。相加则保持维度不变参数量友好而且它对应的是信息叠加的语义——把原始信息和修正量叠在一起。DenseNet 走的是拼接路线特征复用更充分但代价是显存和计算开销大这就是不同设计之间的取舍。另外相加要求两个张量的形状完全一致包括 batch、channel、height、width 四个维度。所以每当你改变了 F(x) 的输出形状就必须同步调整捷径分支要么加 1x1 卷积投影要么做池化对齐。这个约束在实际写代码时会经常蹦出来得养成习惯去检查。3. 残差块的几种经典实现形态3.1 BasicBlock两段3x3卷积的浅层方案BasicBlock 是 ResNet-18 和 ResNet-34 用的结构非常直白两个 3x3 卷积每个后面跟 BN 和 ReLU最后和捷径相加再过一次 ReLU。伪代码长这样def forward(self, x): identity x out self.relu(self.bn1(self.conv1(x))) out self.bn2(self.conv2(out)) out out self.shortcut(identity) # shortcut 可能是恒等或1x1投影 return self.relu(out)注意两个细节。第一第二个 BN 后面不能再接 ReLU得等相加之后再激活。因为如果先 ReLU 再相加等于强制把 F(x) 的输出限定在非负区间残差的可表达范围被砍掉一半效果会变差。第二捷径分支在需要改变维度时才用 1x1 卷积否则就是纯恒等什么都不做。BasicBlock 的参数量以 64 通道为例两个 3x3x64x64 的卷积每个 36864 个参数合计 73728。这是标准配置简单但够用。3.2 Bottleneck1x1降维升维的深层方案到了 ResNet-50 及以上为了控制参数量和计算量用了 Bottleneck。它由三段卷积组成1x1 降维、3x3 卷积、1x1 升维。以输入 256 通道、中间宽度 64 为例第一个 1x1256 - 64参数 256x64 163843x3 卷积64 - 64参数 3x3x64x64 36864第二个 1x164 - 256参数 64x256 16384加起来一共 69632约 7 万个参数。对比一下如果直接用两个 3x3 在 256 通道上堆参数量是 3x3x256x256 x 2 1179648接近 118 万。Bottleneck 用不到它的十六分之一却能堆更深。这就是为什么深层 ResNet 都用 Bottleneck——同样的预算下能换更多层数。结构通道变化核心操作典型网络单块参数量256通道BasicBlock输入输出同通道3x3 3x3ResNet-18/34约 118 万两段3x3在256通道Bottleneck256-64-64-2561x1 3x3 1x1ResNet-50/101/152约 7 万3.3 预激活结构把BN和ReLU挪到前面原版残差块是卷积-BN-ReLU的顺序相加之后再加一次 ReLU这叫后激活。后来有工作提出预激活版本把顺序改成BN-ReLU-卷积残差分支的最后一个卷积输出直接加捷径相加之后不再激活。好处是捷径路径从头到尾都是纯恒等梯度高速路更干净深层上千层训练更稳。预激活在实际用的时候要注意整个网络的第一个块之前得手动补一个 BNReLU因为原来的激活被挪到块内部了。这个细节容易漏漏了的话第一层卷积吃的是原始输入分布没被规整过初期会抖得厉害。我的经验是如果网络在 50 到 152 层这个区间后激活就够用工程上更成熟、预训练权重也更好找。真要到几百上千层再考虑预激活。没必要为了先进去上预激活反而可能给自己找麻烦。4. 从零手写一个残差块4.1 环境与依赖准备我一般用 PyTorch版本 1.10 以上都行CUDA 按显卡对应装。核心依赖就 torch、torchvision。如果你想加载官方 resnet 预训练模型做对照torchvision 会帮你自动下载权重。写代码之前先把随机种子固定住方便复现import torch import torch.nn as nn torch.manual_seed(42)提示如果你在复现论文里的精度务必把 cudnn 的 benchmark 打开并且固定住数据增强的随机性否则实验之间的波动可能比你想调的那个改进还大。4.2 BasicBlock 代码逐行实现先写一个通用的卷积-BN 组合减少重复def conv3x3(in_planes, out_planes, stride1): return nn.Conv2d(in_planes, out_planes, kernel_size3, stridestride, padding1, biasFalse)注意biasFalse。因为卷积后面紧跟 BNBN 自己会减均值卷积的偏置项会被吸收掉留着纯属浪费参数。这个习惯写残差块时要保持。然后是 BasicBlockclass BasicBlock(nn.Module): expansion 1 def __init__(self, inplanes, planes, stride1, downsampleNone): super().__init__() self.conv1 conv3x3(inplanes, planes, stride) self.bn1 nn.BatchNorm2d(planes) self.relu nn.ReLU(inplaceTrue) self.conv2 conv3x3(planes, planes) self.bn2 nn.BatchNorm2d(planes) self.downsample downsample self.stride stride def forward(self, x): identity x out self.relu(self.bn1(self.conv1(x))) out self.bn2(self.conv2(out)) if self.downsample is not None: identity self.downsample(x) out identity out self.relu(out) return outexpansion 1表示输出通道不扩张。downsample这个分支留给需要改变维度的时候用。inplaceTrue的 ReLU 省内存但在某些需要保留输入的场合要小心比如调试梯度的时候。4.3 Bottleneck 实现与参数量计算Bottleneck 比 BasicBlock 多一层宽度控制靠中间的 3x3 通道数class Bottleneck(nn.Module): expansion 4 def __init__(self, inplanes, planes, stride1, downsampleNone): super().__init__() self.conv1 nn.Conv2d(inplanes, planes, kernel_size1, biasFalse) self.bn1 nn.BatchNorm2d(planes) self.conv2 nn.Conv2d(planes, planes, kernel_size3, stridestride, padding1, biasFalse) self.bn2 nn.BatchNorm2d(planes) self.conv3 nn.Conv2d(planes, planes * self.expansion, kernel_size1, biasFalse) self.bn3 nn.BatchNorm2d(planes * self.expansion) self.relu nn.ReLU(inplaceTrue) self.downsample downsample self.stride stride def forward(self, x): identity x out self.relu(self.bn1(self.conv1(x))) out self.relu(self.bn2(self.conv2(out))) out self.bn3(self.conv3(out)) if self.downsample is not None: identity self.downsample(x) out identity return self.relu(out)expansion 4是关键。Bottleneck 的 planes 指的是中间宽度最终输出通道是 planes x 4。比如 ResNet-50 的 stage 输出分别是 256、512、1024、2048对应的 planes 就是 64、128、256、512。这个 4 倍关系写死在了结构里记忆的时候别搞混。顺手把参数量算一遍planes64输入 256 通道1x1 降维 256x64163843x3 在 64 通道上是 3x3x64x64368641x1 升维 64x25616384合起来 69632。这跟前面表格对得上。要在纸上验证的时候别把 BN 的参数忘了每个 BN 有 2 个可学习参数scale 和 shift按通道数算。4.4 下游网络拼接与形状验证写完了块得把它拼成完整的网络骨架。核心是_make_layerdef _make_layer(self, block, planes, blocks, stride1): downsample None if stride ! 1 or self.inplanes ! planes * block.expansion: downsample nn.Sequential( nn.Conv2d(self.inplanes, planes * block.expansion, kernel_size1, stridestride, biasFalse), nn.BatchNorm2d(planes * block.expansion), ) layers [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)这里的判断条件很关键stride ! 1或者inplanes ! planes * block.expansion只要有一个成立捷径就必须挂 1x1 投影。为什么因为要么空间尺寸变了stride 变了要么通道数变了两者都破坏逐元素相加的前提。接完层别忘了形状验证。我习惯拿一个假输入跑一遍看一眼每层输出x torch.randn(2, 3, 224, 224) net ResNet(BasicBlock, [2, 2, 2, 2]) y net(x) print(y.shape) # 期望 torch.Size([2, 1000])跑通这一步说明维度拼接没有低级错误。5. 训练调试中的常见问题与排查技巧5.1 维度不匹配报错速查新手最常见的问题就是The size of tensor a must match the size of tensor b。基本可以断定是捷径分支没对齐。排查思路按这个顺序走先看 F(x) 的输出通道是不是等于planes * expansion再看 stride 有没有在卷积里生效最后检查 downsample 是否在正确的位置被调用。报错现象可能原因处理方式相加时通道不匹配downsample 缺失或 expansion 用错确认输出通道 planes * block.expansion相加时宽高不匹配卷积 stride 与捷径 stride 不一致两个分支的 stride 都设成同一个值加载预训练权重报参数名不对自定义结构与官方命名不一致用 strictFalse 加载并打印缺失的 key注意用strictFalse加载权重时一定要打印出缺失和多余的参数列表。我见过有人加载后没检查结果骨干网络有一半随机初始化训了一周才发现白跑。5.2 恒等捷径上的1x1卷积什么时候必须加这是个高频问题。判断标准就两条通道变了或者空间尺寸变了stride 不为 1。满足任意一条就必须在捷径上加 1x1 卷积stride 和残差分支保持一致加 BN。如果都不满足也就是输入输出完全同形那捷径就保持最纯粹的恒等映射一个参数都不加。还有一类特殊情况当残差分支的 stride 大于 1 时F(x) 的空间尺寸变小了而恒等捷径不变此时用 1x1 卷积投影的 stride 也要设成相同值才能在空间上对上。如果只想对齐通道不想丢失空间信息也可以在恒等路径上用平均池化不过这在标准实现里用得少。5.3 学习率、BN、初始化对残差网络的敏感度残差网络对初始化和学习率不算特别娇气但也有几个点要注意。第一BN 的位置。一定要放在卷积之后、ReLU 之前。如果顺序搞错了比如卷积-ReLU-BNBN 统计的分布会有偏差早期震荡明显收敛慢一截。第二Batch size 对 BN 的依赖。BN 在batch 很小时统计量不准网络宽而深的时候尤其明显。如果你只有单卡小显存batch 拉到 8 都费劲那可以考虑 GroupNorm 替换 BN代价是收敛稍慢。这属于工程折中不是必须。第三学习率 warmup。大 batch 训练时前几个 epoch 用小学习率线性升温能有效避免早期把残差分支的权重拍飞。我从 0 到 0.1 用了 5 个 epoch 的 warmup训练稳定性提升很明显。还有个小坑残差分支最后的 BN 参数如果初始化得太小会导致整个块的输出几乎等于恒等映射一开始学习信号很弱。标准做法是最后的 BN 的 gamma 初始化为 0这样块最初就是恒等映射相当于零初始化残差分支网络一开始等价于浅层网络然后逐渐把残差分支启用。这个技巧在训练极深网络时特别有用能让收敛更平滑。6. 残差思想的迁移与延展6.1 从CNN到Transformer残差连接无处不在残差块的价值早就超出了 ResNet 本身。你去看任意一个现代网络几乎都能找到加法形式的捷径。Transformer 里的每个子层也是这个套路LayerNorm、多头注意力、残差相加表达成 x Sublayer(x)。思想完全一致——让子模块只学增量把恒等通路留给网络保底。连卷积和序列建模的混血模型也都在用。可以说残差连接已经和归一化一样成了搭深层网络的标配组件。理解透残差块再去读别的架构会发现很多设计都是在同一个骨架上换零件。6.2 堆叠残差块时的通道数规划经验实际搭网络的时候通道数的规划会直接影响效果和显存。我的经验是每个 stage 的第一个块负责下采样和通道翻倍其余块保持同形做纯恒等捷径这样参数量可控。通道数一般按 64、128、256、512 这样翻倍走BasicBlock或者翻四倍走Bottleneck翻太快显存爆翻太慢深度上不去。空间下采样和通道翻倍尽量同步进行避免出现通道很大、尺寸也很大的中间层那种层最吃显存。还有一点块的数量分配不必绝对平均。像 ResNet 这种 2:2:2:2 的配置是个平衡点实际项目里可以给后段多分几个块因为深层特征的语义信息更丰富多堆一点收益更明显但也别堆太多容易过拟合。6.3 轻量化改造中的残差替代方案如果你在端侧或者算力受限场景标准残差块可能偏重。常见的替代思路有两个一是把 3x3 卷积换成深度可分离卷积MobileNet 那套残差连接保留这样参数量和计算量都大幅下降二是用分组卷积压缩通道间的计算代价是特征交互变弱需要多堆几层补偿。还有一种叫残差缩放的做法给 F(x) 乘一个小于 1 的系数再相加比如 0.2能让训练更稳尤其在块数特别多的时候。它的作用类似于给残差分支降权避免深层累积的数值过大。这个系数是超参太小会导致残差分支学不动太大又起不到稳定作用一般从 0.1 到 0.3 试。这些改造都保留了残差连接这个核心只是换了里面的零件。所以我的建议是先把标准残差块写熟、训稳再去动这些花样。基础没打牢直接上轻量化改造出了问题是分不清是残差用错了还是轻量化组件本身的问题。聊到这儿我个人在实际项目里的体会是残差块本身代码量很小真正花时间的地方全在维度对齐和训练细节上。我一开始写自定义骨干网络最常犯的错就是忘了捷径上的 1x1 投影或者把 expansion 搞混导致通道对不上每次报错都得回头排查一遍。后来我养成了一个习惯每写完一个 block 先拿假数据跑一次前向把每层的输出形状打出来看一眼确认无误再往下拼。这个习惯帮我省了大量返工时间。另外如果你的任务只是拿 resnet 预训练模型做微调其实没必要自己手写直接调 torchvision 的接口更省事也更稳。自己实现的价值在于你需要改结构、做通道裁剪或者研究训练行为的时候能完全掌控每个细节。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门