资讯动态

残差网络优化实战:从ResNet到mHC混合卷积

发布时间:2026/10/17 3:37:01 来源:尧图企业网站定制
残差层的优化这件事我在一个60类左右的小型图像分类项目上反复折腾了小半年。从最普通的 ResNet 残差块出发一路改到 HCHybrid Convolution混合残差块最后在 HC 的基础上又落地了 mHCmodified 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 卷积对水平条纹、垂直边缘这类图像结构的建模效率并不高。第三个是当下采样发生的时候捷径不一定真的“恒等”stride2 或者通道数变化时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 之后参数量是 336减少了三分之一。感受野层面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类数据集。训练超参数如下超参数配置优化器SGDmomentum0.9nesterovTrue初始学习率0.1学习率策略Cosine 衰减到 1e-4总轮数300Batch Size128权重衰减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, stride1): super().__init__() self.conv1 nn.Conv2d(in_ch, out_ch, 3, stride, 1, biasFalse) self.bn1 nn.BatchNorm2d(out_ch) self.conv2 nn.Conv2d(out_ch, out_ch, 3, 1, 1, biasFalse) 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, biasFalse), 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, stride1): super().__init__() self.conv_h nn.Conv2d(in_ch, mid_ch, (3, 1), padding(1, 0), biasFalse) self.bn_h nn.BatchNorm2d(mid_ch) self.conv_v nn.Conv2d(mid_ch, mid_ch, (1, 3), padding(0, 1), biasFalse) self.bn_v nn.BatchNorm2d(mid_ch) self.conv1 nn.Conv2d(mid_ch, out_ch, 3, stride, 1, biasFalse) 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, biasFalse), 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, stride1, se_reduction4): super().__init__() self.conv_h nn.Conv2d(in_ch, mid_ch, (3, 1), padding(1, 0), biasFalse) self.bn_h nn.BatchNorm2d(mid_ch) self.conv_v nn.Conv2d(mid_ch, mid_ch, (1, 3), padding(0, 1), biasFalse) self.bn_v nn.BatchNorm2d(mid_ch) self.conv1 nn.Conv2d(mid_ch, out_ch, 3, stride, 1, biasFalse) 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, biasFalse), 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_stride2): blocks [mHCBlock(in_ch, out_ch, out_ch, stridefirst_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 完整版基准训练曲线最稳去掉 alphashortcut 固定为 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 之后问题消失。零初始化加上 alpha1.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 入手把非对称卷积跑通再往上加注意力与加权短接一次只动一个变量。这样即使出了问题你也知道该去翻哪一段代码。残差网络之所以能练得很深核心从来不是某个炫酷的卷积结构而是那条恒等路径是否始终干净、畅通。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑