深度学习里有个现象挺反直觉的:你把网络往深了堆,准确率不升反降。这不是过拟合,也不是梯度消失这么简单,而是一种被称作"退化"的问题。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 Normalization(BN)普及之后,梯度消失已经被缓解了很多,网络能训起来,只是效果还是不对。所以退化问题的根子,不完全在梯度上。
我的理解是,它更多是个"优化难度"的问题。一个未经残差改造的深层网络,它的解空间里确实包含了恒等映射那个解,但这个解在参数空间里的位置很"尴尬",随机初始化加上梯度下降的那条路径,很难走到那里。优化器在几十上百层的非线性变换里翻山越岭,很容易就卡在一个比浅层网络更差的地方。
这也解释了一个现象:加 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 Bottleneck:1x1降维升维的深层方案
到了 ResNet-50 及以上,为了控制参数量和计算量,用了 Bottleneck。它由三段卷积组成:1x1 降维、3x3 卷积、1x1 升维。以输入 256 通道、中间宽度 64 为例:
- 第一个 1x1:256 -> 64,参数 256x64 = 16384
- 3x3 卷积:64 -> 64,参数 3x3x64x64 = 36864
- 第二个 1x1:64 -> 256,参数 64x256 = 16384
加起来一共 69632,约 7 万个参数。
对比一下,如果直接用两个 3x3 在 256 通道上堆,参数量是 3x3x256x256 x 2 = 1179648,接近 118 万。Bottleneck 用不到它的十六分之一,却能堆更深。这就是为什么深层 ResNet 都用 Bottleneck——同样的预算下能换更多层数。
| 结构 | 通道变化 | 核心操作 | 典型网络 | 单块参数量(256通道) |
|---|---|---|---|---|
| BasicBlock | 输入输出同通道 | 3x3 + 3x3 | ResNet-18/34 | 约 118 万(两段3x3在256通道) |
| Bottleneck | 256->64->64->256 | 1x1 + 3x3 + 1x1 | ResNet-50/101/152 | 约 7 万 |
3.3 预激活结构:把BN和ReLU挪到前面
原版残差块是"卷积-BN-ReLU"的顺序,相加之后再加一次 ReLU,这叫后激活。后来有工作提出预激活版本,把顺序改成"BN-ReLU-卷积",残差分支的最后一个卷积输出直接加捷径,相加之后不再激活。好处是捷径路径从头到尾都是纯恒等,梯度高速路更干净,深层(上千层)训练更稳。
预激活在实际用的时候要注意:整个网络的第一个块之前得手动补一个 BN+ReLU,因为原来的激活被挪到块内部了。这个细节容易漏,漏了的话第一层卷积吃的是原始输入,分布没被规整过,初期会抖得厉害。
我的经验是,如果网络在 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, stride=1): return nn.Conv2d(in_planes, out_planes, kernel_size=3, stride=stride, padding=1, bias=False)注意bias=False。因为卷积后面紧跟 BN,BN 自己会减均值,卷积的偏置项会被吸收掉,留着纯属浪费参数。这个习惯写残差块时要保持。
然后是 BasicBlock:
class BasicBlock(nn.Module): expansion = 1 def __init__(self, inplanes, planes, stride=1, downsample=None): super().__init__() self.conv1 = conv3x3(inplanes, planes, stride) self.bn1 = nn.BatchNorm2d(planes) self.relu = nn.ReLU(inplace=True) 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这个分支留给需要改变维度的时候用。inplace=True的 ReLU 省内存,但在某些需要保留输入的场合要小心,比如调试梯度的时候。
4.3 Bottleneck 实现与参数量计算
Bottleneck 比 BasicBlock 多一层,宽度控制靠中间的 3x3 通道数:
class Bottleneck(nn.Module): expansion = 4 def __init__(self, inplanes, planes, stride=1, downsample=None): super().__init__() self.conv1 = nn.Conv2d(inplanes, planes, kernel_size=1, bias=False) self.bn1 = nn.BatchNorm2d(planes) self.conv2 = nn.Conv2d(planes, planes, kernel_size=3, stride=stride, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(planes) self.conv3 = nn.Conv2d(planes, planes * self.expansion, kernel_size=1, bias=False) self.bn3 = nn.BatchNorm2d(planes * self.expansion) self.relu = nn.ReLU(inplace=True) 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 倍关系写死在了结构里,记忆的时候别搞混。
顺手把参数量算一遍(planes=64,输入 256 通道):1x1 降维 256x64=16384,3x3 在 64 通道上是 3x3x64x64=36864,1x1 升维 64x256=16384,合起来 69632。这跟前面表格对得上。要在纸上验证的时候,别把 BN 的参数忘了:每个 BN 有 2 个可学习参数(scale 和 shift),按通道数算。
4.4 下游网络拼接与形状验证
写完了块,得把它拼成完整的网络骨架。核心是_make_layer:
def _make_layer(self, block, planes, blocks, stride=1): downsample = None if stride != 1 or self.inplanes != planes * block.expansion: downsample = nn.Sequential( nn.Conv2d(self.inplanes, planes * block.expansion, kernel_size=1, stride=stride, bias=False), 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 都设成同一个值 |
| 加载预训练权重报参数名不对 | 自定义结构与官方命名不一致 | 用 strict=False 加载并打印缺失的 key |
注意:用
strict=False加载权重时一定要打印出缺失和多余的参数列表。我见过有人加载后没检查,结果骨干网络有一半随机初始化,训了一周才发现,白跑。
5.2 恒等捷径上的1x1卷积什么时候必须加
这是个高频问题。判断标准就两条:通道变了,或者空间尺寸变了(stride 不为 1)。满足任意一条,就必须在捷径上加 1x1 卷积(stride 和残差分支保持一致)加 BN。如果都不满足,也就是输入输出完全同形,那捷径就保持最纯粹的恒等映射,一个参数都不加。
还有一类特殊情况:当残差分支的 stride 大于 1 时,F(x) 的空间尺寸变小了,而恒等捷径不变,此时用 1x1 卷积投影的 stride 也要设成相同值,才能在空间上对上。如果只想对齐通道不想丢失空间信息,也可以在恒等路径上用平均池化,不过这在标准实现里用得少。
5.3 学习率、BN、初始化对残差网络的敏感度
残差网络对初始化和学习率不算特别娇气,但也有几个点要注意。
第一,BN 的位置。一定要放在卷积之后、ReLU 之前。如果顺序搞错了(比如卷积-ReLU-BN),BN 统计的分布会有偏差,早期震荡明显,收敛慢一截。
第二,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 的接口更省事也更稳。自己实现的价值在于,你需要改结构、做通道裁剪或者研究训练行为的时候,能完全掌控每个细节。