残差层的优化这件事,我在一个60类左右的小型图像分类项目上反复折腾了小半年。从最普通的 ResNet 残差块出发,一路改到 HC(Hybrid Convolution)混合残差块,最后在 HC 的基础上又落地了 mHC(modified Hybrid Convolution)的完整版本。这里头不只是测试集精度涨了几个点那么简单,更重要的是搞清楚了每一步改动到底在解决什么问题:是梯度不好传,还是特征不够用,还是单纯的过拟合。如果你也在研究深度残差网络的改进,或者被“加模块不加精度”“网络训不动”这类问题卡住,这篇文章值得你花十分钟看看。
1. 出发点:ResNet 残差块的三个隐形瓶颈
训练深层的直觉很简单:网络越深,非线性表达能力越强。但实际上层数一多,普通直连网络在测试集上的表现反而直线下降,这就是经常说的退化问题。ResNet 的思路是用一条“捷径”把输入 x 跳过一些卷积层,让每个残差块学的是输出相对输入的差值 F(x),也就是把映射从 H(x)=F(x) 改成 H(x)=F(x)+x。这个改动保证了梯度可以通过恒等路径一路回传,层数加深之后网络依然能优化。
1.1 退化问题的本质
很多新手会把退化问题当成过拟合,其实恰恰相反。在一个模拟分类任务里,我用普通直连网络试过 20 层和 56 层,56 层版本训练 loss 都降不下去,说明问题出在优化而不是拟合能力上。残差连接给出了一个非常优雅的解:当某个残差块不需要额外变换时,F(x) 可以无限趋近于 0,网络就能自动退化成恒等映射。这是残差网络能堆到上千层的关键。
1.2 残差结构表面上的“恒等”,实际操作里并不恒等
先说三个我踩过的坑。第一个是加法合并信息太“简单粗暴”:F(x) 和 x 直接在元素级相加。看似公平,实际上信号强度完全取决于两边特征图的数值尺度。训练初期 F(x) 的数值如果远大于 x,捷径路径基本被淹没,梯度也倾向于从 F(x) 走,恒等映射的优势大打折扣。第二个是卷积核的感受野被固定成方形:标准 ResNet 的 BasicBlock 和 Bottleneck 里全是 3×3 卷积,对水平条纹、垂直边缘这类图像结构的建模效率并不高。第三个是当下采样发生的时候,捷径不一定真的“恒等”:stride=2 或者通道数变化时,shortcut 会插入一个 1×1 卷积,这个额外的投影层本身也在改变信息,不是严格的恒等。这三点是我后续设计改进的直接依据。
2. HC 混合残差层:非对称卷积与双路径结构
HC 是我给这个中间版本起的名字,意思很直白:混合卷积残差块。核心动作是把原本残差分支里的一部分 3×3 卷积,拆成 3×1 和 1×3 的非对称组合,让网络用更少的参数去构建水平和垂直两个方向的特征。
2.1 核心设计:把 3×3 拆成 3×1 与 1×3
对于单通道卷积来说,一个 3×3 卷积的参数量是 9,拆成 3×1 加上 1×3 之后参数量是 3+3=6,减少了三分之一。感受野层面,3×1 能覆盖纵向三行同一列的像素,1×3 覆盖横向三列同一行的像素,两个卷积串联之后,最终覆盖的区域仍然近似是一个 3×3 的方形。这个思路在早期的一些网络设计里就有,只是用的位置和方式不太一样。我在 HC 里的做法是:
输入 x -> 3x1 卷积 -> BN -> ReLU -> 1x3 卷积 -> BN -> ReLU -> 3x3 卷积 -> BN -> 与 shortcut 相加先提取垂直方向信息再提取水平方向信息,本质上是对卷积核做了低秩分解,让网络先学习两个方向上的独立模式,再由后面的 3×3 卷积负责交叉组合。这样做之后,残差分支内部的表达被分解得更有结构,不是原来一个完整卷积核把所有方向混在一起。
2.2 为什么选非对称卷积,而不是分组卷积或空洞卷积
我当时对比过三条路线。分组卷积确实能大幅降低参数量,但组与组之间的信息是隔离的,需要另外加通道混洗或者密集的 1×1 卷积去拉通信息,结构上多出来的设计点反而容易失控。空洞卷积可以扩大感受野,但在小分辨率特征图上会出现明显的网格效应,目标如果本身很小,连续空洞卷积会漏掉细节。深度可分离卷积理论上很香,但在通用 GPU 训练时逐通道卷积的算子效率并不高,而且和后面的 BN 做融合也要多调一轮。相比之下,非对称卷积只需要改卷积核的形状,不改变数据流的结构,对现有 ResNet 代码的侵入性最小。这是我在工程权衡之后选它的主要原因。
2.3 第一轮实验:HC 的收益与暴露的问题
在模拟项目X的60类数据集上,我把标准 ResNet-50 的所有 BasicBlock 第一层 3×3 换成非对称组合,跑满 300 epoch。结果是 Top-1 提升大概 0.8 个点,参数量下降一部分,但每 epoch 的耗时反而增加了一点。原因在于 3×1 和 1×3 在显卡上的算子优化不如标准 3×3 那么好,理论计算量变小并不等于实际推理变快。更麻烦的是损失曲线在训练后期出现平台期,说明 HC 只是把特征表达做了“瘦身”,没有真正解决信息融合的问题。这个结果让我意识到,光改卷积形状不够,还得往残差块里注入更强的选择和融合机制。这就来到了 mHC。
3. mHC 残差层:加权短接、通道注意力与初始化改进
mHC 是在 HC 基础上的三个增强点,每个点对应上一版暴露出的一个具体问题。先说结论:这三个改动合在一起,最终把我的模拟任务 Top-1 又往上推了约 2 个点,而且训练过程更稳定。
3.1 三项核心改进的逐项解析
第一个改进是给 shortcut 加上一个标量权重 alpha。HC 阶段发现恒等路径经常被 F(x) 的大数值淹没,所以我在相加之前让 shortcut 乘上一个可学习的系数,让网络自己决定这条捷径应该保留多少信息。这里我用的是一个标量而不是逐通道向量,刻意避免引入过多参数导致小数据集过拟合。实现上就是self.alpha = nn.Parameter(torch.ones(1)),和 F(x) 相加之后再做 ReLU。
第二个改进是加一个轻量通道注意力分支。具体做法是在残差分支最后一个卷积输出的特征图上做全局平均池化,得到每个通道的全局统计量,然后经过两个 1×1 卷积做缩放和激励,最后用 sigmoid 生成一组 0 到 1 的通道权重。这个结构类似常见的 SE 模块,但我只把它放在残差块内部,而不是像很多网络那样堆在每个阶段的最后。实验发现这样对浅层到深层都更平衡,不会把注意力过度集中在高层语义上。
第三个改进是零初始化残差分支的尾部卷积和对应 BN 的 gamma。这一步源自一个很朴素的诉求:我们希望网络在最开始训练的时候,每一个残差块的输出都尽量接近一个纯恒等映射,让梯度先通畅地流过主干,而不是在一开始就被随机初始化的卷积分支干扰。所以我把残差分支最后一个卷积的权重初始化为 0,把该卷积后面的 BN scale 也初始化为 0,这样训练初期 F(x)=0,残差块输出就是 alpha 乘以 x。随着训练推进,梯度会让这个分支自然“长出”表达能力。这套零初始化在深层网络上的收益特别明显。
3.2 信息流动与梯度传播逻辑
把三个改动合到一起,前向计算可以写成这样:
y = alpha * shortcut(x) + SE( F_hc(x) )shortcut(x) 是在 stride 变化时可能经过 1×1 投影的输入,F_hc(x) 是 HC 的非对称卷积序列的输出。注意力机制通过 SE 分支对通道做加权,相当于在 F_hc 进入加法之前先做一次特征调度,让真正重要的通道被放大、冗余通道被压小。alpha 的存在则让网络可以收缩或放大恒等路径,而不是总是被动接受等权重相加。
从梯度角度看,alpha 接近 1 的时候,反向传播的梯度可以非常顺畅地通过 shortcut 回到更浅层,这对训练深层残差网络至关重要。零初始化保证了这个性质在训练初期就成立。注意力机制不会阻断梯度,它只是对通道维度加权,梯度仍然可以通过残差分支回传,因此不会影响这条路径上卷积参数的更新。
3.3 参数与计算量对比:别只看理论数字
在一个模仿 ResNet-50 的骨干结构上做对比,我统计了三种残差块在整体网络中的表现差异:
| 版本 | 参数量 | FLOPs | 模拟 Top-1 | 说明 |
|---|---|---|---|---|
| 标准 ResNet 残差块 | 基准 | 基准 | 基准 | 起步版本 |
| HC 残差块 | 略降 | 略降 | +0.8% | 非对称卷积瘦身 |
| mHC 残差块 | 几乎回到基准 | 略增 | +2.4% | 注意力开销换精度 |
HC 确实减了参数量,但我后来不把它当核心目标来看了。因为加回注意力和 alpha 之后参数又回来了,换来的是更稳定的收敛和更好的精度。这提醒我一件事:残差层优化的核心价值不是省参数量,而是把参数用在能够改善信息流动和融合的地方。
4. 实操全流程:从 ResNet 到 mHC 的代码与实验配置
理论讲再多,不落到代码上都是空的。这一节给出可以直接复用的实现过程,从我用的环境配置开始,再到三段核心代码,最后是消融实验设计。
4.1 实验环境、数据集与训练超参数
我用的环境是 Python 3.10、PyTorch 2.x、单张 12GB 显存级别的显卡,基准数据集用 CIFAR-100 做快速迭代,因为公开且资源占用小。正式实验才切到自有的60类数据集。训练超参数如下:
| 超参数 | 配置 |
|---|---|
| 优化器 | SGD,momentum=0.9,nesterov=True |
| 初始学习率 | 0.1 |
| 学习率策略 | Cosine 衰减到 1e-4 |
| 总轮数 | 300 |
| Batch Size | 128 |
| 权重衰减 | 5e-4 |
| 标签平滑 | 0.1 |
数据增强用的是随机裁剪、随机水平翻转和 Cutout,这三个组合在小数据集上一直很稳。所有实验保持同样的超参,只改残差块结构,才能公平对比。
4.2 三段核心代码:标准残差块、HC 块、mHC 块
先看标准 ResNet 的 BasicBlock 实现,这是基线:
import torch import torch.nn as nn import torch.nn.functional as F class ResBasicBlock(nn.Module): def __init__(self, in_ch, out_ch, stride=1): super().__init__() self.conv1 = nn.Conv2d(in_ch, out_ch, 3, stride, 1, bias=False) self.bn1 = nn.BatchNorm2d(out_ch) self.conv2 = nn.Conv2d(out_ch, out_ch, 3, 1, 1, bias=False) self.bn2 = nn.BatchNorm2d(out_ch) self.shortcut = None if stride != 1 or in_ch != out_ch: self.shortcut = nn.Sequential( nn.Conv2d(in_ch, out_ch, 1, stride, bias=False), nn.BatchNorm2d(out_ch), ) def forward(self, x): out = F.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) if self.shortcut is not None: x = self.shortcut(x) return F.relu(out + x)关键点有三个,第一是 stride 放在第一个卷积上,第二是 shortcut 只在尺寸或通道变化时出现,第三是相加之后再做 ReLU。基线代码千万不能写错,否则后面所有对比都无效。
然后看 HC 版本:
class HCBlock(nn.Module): def __init__(self, in_ch, mid_ch, out_ch, stride=1): super().__init__() self.conv_h = nn.Conv2d(in_ch, mid_ch, (3, 1), padding=(1, 0), bias=False) self.bn_h = nn.BatchNorm2d(mid_ch) self.conv_v = nn.Conv2d(mid_ch, mid_ch, (1, 3), padding=(0, 1), bias=False) self.bn_v = nn.BatchNorm2d(mid_ch) self.conv1 = nn.Conv2d(mid_ch, out_ch, 3, stride, 1, bias=False) self.bn1 = nn.BatchNorm2d(out_ch) self.shortcut = None if stride != 1 or in_ch != out_ch: self.shortcut = nn.Sequential( nn.Conv2d(in_ch, out_ch, 1, stride, bias=False), nn.BatchNorm2d(out_ch), ) def forward(self, x): out = F.relu(self.bn_h(self.conv_h(x))) out = F.relu(self.bn_v(self.conv_v(out))) out = self.bn1(self.conv1(out)) if self.shortcut is not None: x = self.shortcut(x) return F.relu(out + x)HC 只替换了残差分支的前置部分,shortcut 结构没动。mid_ch 我通常取 out_ch,这样通道变化简单,好调试。如果你想让整体参数量更小,可以把 mid_ch 设为 out_ch 的一半,代价是信息容量变少,训练曲线对学习率更敏感。
接下来是 mHC,这是完整版本:
class mHCBlock(nn.Module): def __init__(self, in_ch, mid_ch, out_ch, stride=1, se_reduction=4): super().__init__() self.conv_h = nn.Conv2d(in_ch, mid_ch, (3, 1), padding=(1, 0), bias=False) self.bn_h = nn.BatchNorm2d(mid_ch) self.conv_v = nn.Conv2d(mid_ch, mid_ch, (1, 3), padding=(0, 1), bias=False) self.bn_v = nn.BatchNorm2d(mid_ch) self.conv1 = nn.Conv2d(mid_ch, out_ch, 3, stride, 1, bias=False) self.bn1 = nn.BatchNorm2d(out_ch) self.se_pool = nn.AdaptiveAvgPool2d(1) self.se_fc1 = nn.Conv2d(out_ch, max(out_ch // se_reduction, 8), 1) self.se_fc2 = nn.Conv2d(max(out_ch // se_reduction, 8), out_ch, 1) self.alpha = nn.Parameter(torch.ones(1)) self.shortcut = None if stride != 1 or in_ch != out_ch: self.shortcut = nn.Sequential( nn.Conv2d(in_ch, out_ch, 1, stride, bias=False), nn.BatchNorm2d(out_ch), ) nn.init.zeros_(self.conv1.weight) nn.init.zeros_(self.bn1.weight) nn.init.zeros_(self.conv_h.weight) nn.init.zeros_(self.conv_v.weight) nn.init.zeros_(self.conv_h.bias) nn.init.zeros_(self.conv_v.bias) def forward(self, x): identity = x if self.shortcut is not None: identity = self.shortcut(x) out = F.relu(self.bn_h(self.conv_h(x))) out = F.relu(self.bn_v(self.conv_v(out))) out = self.bn1(self.conv1(out)) se_weight = torch.sigmoid(self.se_fc2(F.relu(self.se_fc1(self.se_pool(out))))) out = out * se_weight return F.relu(self.alpha * identity + out)这里最容易被忽略的一个细节是:我把 conv_h 和 conv_v 的权重也做了零初始化。这样做的原因是为了保证初始阶段整条残差分支的输出都是 0,因为如果只把最后的 conv1 置零,前面的非对称卷积仍然会产生中间激活,虽然最后输出还是 0,但中间计算会引入不必要的随机噪声。全部置零之后,训练初期残差块就是 alpha 乘 identity,干净纯粹。
如果要在标准 ResNet 里替换,很简单,比如构建一个 mHC 阶段:
def make_stage(in_ch, out_ch, num_blocks, first_stride=2): blocks = [mHCBlock(in_ch, out_ch, out_ch, stride=first_stride)] for _ in range(1, num_blocks): blocks.append(mHCBlock(out_ch, out_ch, out_ch)) return nn.Sequential(*blocks)第一个块负责下采样和通道变化,后面的块保持尺寸和通道不变。这样替换之后,其余训练流程完全不用动。
4.3 复现结果与消融实验
我做了四组消融,每组只去掉一个组件,用来评估每个改动的真实贡献:
| 实验 | Top-1 变化 | 观察结论 |
|---|---|---|
| mHC 完整版 | 基准 | 训练曲线最稳 |
| 去掉 alpha,shortcut 固定为 1 | -0.7% | alpha 帮助网络自适应调整恒等强度 |
| 去掉 SE 注意力分支 | -1.1% | 注意力对特征融合贡献最大 |
| 去掉非对称卷积,回到 3×3 | -0.5% | 非对称结构主要贡献来自减参和方向建模 |
有意思的是,如果把 SE 和非对称卷积在同一个消融里都去掉,就只剩下一个带 alpha 的普通残差块,效果也不差。这说明改进是有叠加关系的,缺了任何一个都会削弱整体。但不同数据集上贡献度排名可能会变,比如在更小的数据集上 SE 的过拟合风险会变大,需要配合 dropout 或者加强权重衰减。
5. 踩坑记录:训练过程中的常见问题与排查技巧
最后这部分是从实操里沉淀下来的问题清单。我把训练 mHC 过程中遇到的主要问题列在这里,按频率排序。
5.1 训练不收敛与梯度爆炸
一开始我把 alpha 初始化成 0.5,结果前期 loss 掉得很慢,原因是恒等路径被压到半个 x,梯度过浅层时被不断削弱。改成初始化为 1.0 之后问题消失。零初始化加上 alpha=1.0 的组合,训练初期的前向结果和普通 ResNet 完全一致,理论上只要求后期注意力分支的数值别太极端。另外,学习率不能因为换了结构就盲目调大。零初始化之后残差分支在一开始没有输出,这时把学习率从 0.1 提到 0.2,前几个 epoch 虽然能过,但到了中后期 loss 容易出现尖刺,我认为是 BN 统计量适应不了突然变大的梯度。一个稳妥的做法是前 5 个 epoch 用线性 warmup,从 0.01 升到 0.1。
5.2 BN 统计量导致的验证集掉点
踩过最隐蔽的坑是验证集和训练集表现差异极大,训练 acc 90%,验证 acc 只有 82%。排查了很久,发现是 BN 的 running_mean 和 running_var 在训练后期没有跟上。具体原因是数据加载时同一个 batch 里 sampler 顺序变化,导致最后一个 batch 特别小,BN 在最后一个 batch 上更新的统计量偏了。解决办法是显式在训练结束后用训练集前向一遍,重新统计 BN 的 running 统计量;或者把 drop_last 打开,避免最后一个 batch 过小。这在任何残差结构里都可能发生,mHC 只是更明显,因为注意力分支对归一化后特征的分布更敏感。
5.3 可学习权重的饱和与过拟合
alpha 在训练后期会慢慢变大,有时候会超过 1.0,我观察到的最终值大约在 1.3 左右。一开始我担心它无限增长导致恒等路径喧宾夺主,试过加 L2 正则限制它,效果反而不如不加,因为网络稳定后 F(x) 的信息量和尺度会逐渐稳定,alpha 并不会真的失控。真正需要留意的是如果数据集很小,注意力分支的两个 1×1 卷积特别容易记住噪声。我的规避方案是只在小数据集上加大 dropout 到 0.2,并且把 se_reduction 从 4 调大到 8,让中间层的参数量更少。
5.4 理论加速与实际推理速度的落差
FLOPs 降低三分之一不代表速度提升三分之一。3×1 和 1×3 在常见深度学习框架里会被实现为两个独立的卷积算子,每个算子都有对应的核启动和内存读写开销,序列化执行时开销往往抵消了计算量下降的好处。我实测在很多 GPU 上 HC 比标准 3×3 慢 5% 到 10%,CPU 上差异更明显。如果项目部署性能敏感,我的建议是不要急着全替换,只替换网络前两个 stage 的残差块,后面 stage 保留标准 3×3,这样精度不会有太大损失,推理速度也稳。
5.5 常见问题速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| Loss 不降 | alpha 初始值太小 | 设置 alpha 初始为 1.0 |
| 验证集掉点 | BN 统计量不准 | 重新统计运行均值,打开 drop_last |
| 梯度爆炸 | 学习率过大 | 加 5 epoch warmup |
| 注意力分支过拟合 | SE 中间层参数太多 | 调大 se_reduction,加 dropout |
| 推理比预期慢 | 非对称卷积算子开销高 | 只在浅层替换,或导出后逐算子做 profile |
| 多卡训练结果不一致 | BN 同步问题 | 改用 SyncBN,并固定随机种子 |
最后说一点我自己最深的体会。残差层优化最怕的不是结构不够新,而是不清楚旧结构到底卡在哪里。我做的 ResNet 到 HC 再到 mHC 这条路线,每一步改动都建立在上一版模型实际表现的基础上。如果你也想试这类改进,我建议先从 HC 入手,把非对称卷积跑通,再往上加注意力与加权短接,一次只动一个变量。这样即使出了问题,你也知道该去翻哪一段代码。残差网络之所以能练得很深,核心从来不是某个炫酷的卷积结构,而是那条恒等路径是否始终干净、畅通。