首页 / 资讯中心 / 文章详情

Bottleneck架构实战:压缩比、步长与残差通路避坑

Bottleneck架构实战:压缩比、步长与残差通路避坑 ★ FEATURED ARTICLE
第一次在一个跑通的骨干网络上把普通残差块换成 bottleneck architecture我把压缩比从 4 改成 2参数量掉了将近六成单卡吞吐肉眼可见地涨了一截结果验证集精度卡在 68% 不动比原来还低两个多点。翻了半天日志才发现压缩比根本不是主因真正的问题是我把下采样的步长扔在了第一个 1×1 卷积上同时在最后一个 1×1 后面顺手接了 ReLU——这两处改动单独看都不起眼叠在一起就把残差分支的信息通路截断了。这件事之后我对 bottleneck architecture 的理解才从“先降维再升维”这句口诀落到具体的通道数、步长位置、BN 位置、激活函数位置上。这篇内容就把我这些年踩过的坑、做过的消融、手算过的参数量摊开讲一遍它到底是什么为什么能省算力什么情况下省了算力反而掉精度以及在 PyTorch 里怎么写出一个可以放心复现的版本。适合已经能跑通基础卷积网络、准备往深里做或者往端上部署的读者也适合被“瓶颈”这个词绕晕过、想搞清楚它和倒残差到底差在哪的人。1. 瓶颈架构到底在解决什么问题1.1 计算的隐形成本藏在通道维度里很多人第一次接触 bottleneck是从“减少参数量”这个说法开始的但参数量只是表象真正压垮显存和延迟的是特征图的通道维度。一个标准的 3×3 卷积输入 C 通道、输出 C 通道参数量是 9C²计算量按乘加算也是 9C²HW通道翻倍代价直接四倍。当骨干网络走到 256 通道、512 通道这个量级单个 3×3 卷积就能吃掉整网相当比例的算力。瓶颈架构的核心操作是把一次大卷积拆成“压缩—卷积—恢复”三段先用 1×1 把通道从 C 压到 C/r中间在低维空间做 3×3 空间建模最后再用 1×1 把通道扩回目标维度。因为 3×3 卷积的代价和通道数的平方成正比把它放在窄通道上执行省下来的量级非常可观。这里有个容易被忽略的点1×1 卷积本身并不便宜它的参数量是 C²/r两个加起来是 2C²/r。压缩比 r 太小比如 r1的时候两个 1×1 反而比原来的 3×3 更贵。所以瓶颈不是无脑省它有一个明确的盈亏平衡区间后面我会用具体数字把这个区间算给大家看。1.2 一句话背后的三条设计主线我习惯把 bottleneck 拆成三条独立的设计主线来看这样遇到不同变体的时候不容易混。第一条是空间与通道的解耦。3×3 卷积同时承担空间建模和通道混合两个职责1×1 只做通道混合。把它们分开之后空间建模可以在窄通道上做通道混合可以在低成本下做各司其职。MobileNet 系列后来把这条线推到极致直接用 depthwise 把空间和通道彻底拆开。第二条是信息压缩的度。瓶颈层本质上是在强迫网络用一个更低维的表示去概括输入这跟自编码器里的瓶颈层是同一个思路。压缩得太狠关键信息在窄通道里被挤掉后面的层再怎么扩也补不回来压缩得太松省不下算力。这个“度”就是 expansion ratio是整个结构里最需要调的参数。第三条是梯度通路的保真。残差连接要求主干分支和捷径分支在相加时语义对齐所以最后一个 1×1 后面通常不接激活函数否则主干的输出被截断在非负区间和捷径相加之后分布会偏移。这条线看起来是工程细节实际上是深网能不能训起来的关键。1.3 什么场景下瓶颈反而不划算我得先把反面情况说清楚不然容易形成“凡卷积必瓶颈”的误解。在小分辨率输入上比如 32×32 的图特征图的空间尺寸本来就小通道压缩省下来的绝对计算量有限而多出来的两个 1×1 卷积会增加算子数量和访存次数GPU 上的实际延迟可能不降反升。我做过一次对比同样层数的网络在 32×32 输入上带瓶颈的版本理论 FLOPs 低了 40%但实测单步耗时只低了 8%因为瓶颈块里碎片化的小 kernel 拉低了硬件利用率。另一个不划算的场景是浅网络。通道数本来就只有 64、128 的时候压到 C/4 就只剩 16 或 32 通道窄到 BN 的统计量都估不准训练很容易发散。经典的做法是在 18 层、34 层这类浅骨干上直接用基础残差块只在 50 层以上才引入瓶颈。还有一类是强依赖细粒度空间信息的下游任务比如小目标检测和高分辨率语义分割。瓶颈层在压缩通道的同时也会损失一部分通道间的细微差异如果后面的解码器没有足够的旁路信息补充边缘和小目标的响应会明显变弱。这类任务里我一般会把压缩比调小或者在瓶颈旁边保留一条轻量的直连。2. 结构拆解每个零件在干什么2.1 第一个 1×1 卷积降维与步长的位置之争第一个 1×1 卷积承担降维职责把输入从 C_in 压到中间宽度。这层的参数量是 C_in × width是整个块里最便宜的一层也是决定信息损失程度的一层。真正容易出错的是步长的位置。早期实现把 stride2 放在第一个 1×1 上理由是此时特征图尺寸减半后面的 3×3 就在一半的分辨率上算能进一步省算力。听起来很合理但 1×1 卷积加 stride2 的等效操作是对特征图做隔点采样四个像素里只保留一个剩下三个的信息直接丢掉没有任何卷积核去补偿。这个损失在浅层还能靠后续层补回来到了深层就是不可逆的信息坍缩。后来的主流实现改成了把 stride2 放到中间的 3×3 上。3×3 卷积在滑动时相邻窗口有重叠即使输出尺寸减半每个输出位置仍然聚合了输入上一个 3×3 邻域的信息相当于用卷积核做了抗混叠。代价是 3×3 在原始分辨率上跑算力略高一些但精度收益远大于这点开销。这个改动在多个骨干网络的复现里都被验证过我自己的实验里把 stride 从 1×1 挪到 3×3ImageNet 风格的分类任务上 top-1 一般能回来 0.3 到 0.8 个点。提示如果你的实现是从某些老版本代码里抄来的务必确认 stride 挂在哪个卷积上。这是最容易在复现时被忽略、又实实在在影响精度的一处差异。2.2 中间 3×3 卷积窄通道上的空间建模中间的 3×3 卷积是整个块的算力大头但因为它工作在 width 通道上代价被压得很低。这里的 width 一般取输出通道的 1/4也就是 expansion ratio 为 4。选 4 不是拍脑袋是在“省算力”和“保表达”之间多次消融出来的经验值。从信息论的角度看中间层的宽度决定了这个块能承载的独立特征维度上限。宽度太小多个语义方向会被压到同一组通道里后面的 1×1 再怎么扩也区分不开宽度太大省算力的意义就消失了。我的经验是中间宽度不要低于 32 通道低于这个数之后 BN 的批统计量方差偏大小 batch 训练时尤其明显。如果输出通道本身就只有 64、128那压缩比取 2 比取 4 更稳。还有一点值得说中间 3×3 卷积的分组数groups也是可以调的。设成大于 1 的分组卷积能进一步降参数量但跨组的信息交换就断了需要在后面的 1×1 里补回来。这条路上走得比较远的是分组卷积加通道重排那一类结构思路是把分组带来的通道隔离用一次置换操作打散。2.3 第二个 1×1 卷积升维与激活函数的位置第二个 1×1 把通道从 width 扩回 C_out参数量是 width × C_out和第一个 1×1 对称。这层的输出直接参与残差相加所以它后面不能接 ReLU。原因很直接残差相加要求主干分支的输出能够取到负值才能和捷径分支做有意义的叠加。如果这层后面跟了 ReLU输出被截断在非负区间那么相加的结果永远偏向正侧深层堆叠之后激活值分布会持续右偏训练不稳定。ResNet 原始论文里就是 conv-BN 之后直接相加再统一过一次 ReLU这个顺序不能改。配套的一个技巧是把这层 BN 的缩放参数初始化为 0。这样训练开始时主干分支的输出恒等于 0整个块退化成恒等映射网络相当于从一个浅层状态开始训练梯度能顺畅地回传到每一层。等训练推进这些参数慢慢离开零点深层结构才逐渐发挥作用。这个初始化我在 100 层以上的网络上试过收敛初期的 loss 曲线明显更平滑前期不稳定的情况少了很多。2.4 三种主流瓶颈形态的对照把瓶颈这个词放在不同语境下指的其实不是同一个东西。我整理了一张对照表遇到别人说“瓶颈结构”的时候先对照一下是哪种。形态通道变化路径算力集中在哪里典型用途使用时要注意正瓶颈压缩型C → C/r → C中间 3×3窄通道深层骨干网络的堆叠块r 一般取 4浅网慎用倒残差扩张型C → tC → C首尾 1×1中间是深度卷积移动端、边缘设备t 取 4 到 6激活函数用低精度友好的沙漏型编解码逐层降 → 最窄层 → 逐层升最窄的中间层图像重建、去噪、分割最窄层别低于 16 通道跳跃连接要接对位置通道重标定型C → C/r → C全局池化后两个全连接层注意力模块内嵌r 取 16sigmoid 前后不要加 BN正瓶颈和倒残差经常被搞混区别在于压缩发生在哪。正瓶颈是“先窄后宽”中间那个 3×3 是省算力的关键倒残差是“先宽后窄”中间用深度卷积在宽通道上做空间建模因为深度卷积的代价本来就低所以敢在宽通道上跑。两者都是为了在有限算力下最大化表达力只是把省下来的预算花在了不同地方。注意倒残差结构里如果通道扩张之后没有做残差连接性能会明显下降。扩张层的特征维度高、信息密度低缺少捷径会让梯度在深层衰减得很快。3. 从零实现一个可复现的瓶颈块3.1 代码实现与逐行说明下面这个版本是我自己项目里一直在用的接口对齐了常见骨干网络的写法支持分组和自定义归一化层。import torch import torch.nn as nn class Bottleneck(nn.Module): expansion 4 def __init__(self, in_ch, planes, stride1, downsampleNone, groups1, base_width64, norm_layerNone): super().__init__() if norm_layer is None: norm_layer nn.BatchNorm2d width int(planes * (base_width / 64.0)) * groups # 第一段降维步长不放在这里 self.conv1 nn.Conv2d(in_ch, width, 1, biasFalse) self.bn1 norm_layer(width) # 第二段空间建模步长放在这里 self.conv2 nn.Conv2d(width, width, 3, stridestride, padding1, groupsgroups, biasFalse) self.bn2 norm_layer(width) # 第三段升维后面不接激活 self.conv3 nn.Conv2d(width * groups, planes * self.expansion, 1, biasFalse) self.bn3 norm_layer(planes * self.expansion) self.relu nn.ReLU(inplaceTrue) self.downsample downsample self.stride stride # 关键末层 BN 的缩放参数零初始化 nn.init.zeros_(self.bn3.weight) 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 out identity return self.relu(out)几个点需要单独说明。biasFalse是必须的因为后面紧跟 BN卷积自带的偏置会被 BN 的减均值操作抵消掉纯属浪费参数。width的计算里乘了groups是为了保证分组卷积之后拼接起来的总通道数符合预期。downsample分支在通道或尺寸不匹配时启用一般是一个 1×1 卷积加 BN步长和主干对齐。nn.init.zeros_(self.bn3.weight)这一行前面提过作用是让块在初始状态下等价于恒等映射。有人会顺手把bn3.bias也置零其实没必要BN 的偏置在缩放为 0 的时候不起作用置不置零结果一样。3.2 参数量与算力的手算核对我不太信任“大概能省很多”这种说法参数量必须手算一遍才能判断某个压缩比到底值不值。通用公式如下假设输入输出通道都是 C中间宽度为 C/r参数量 P 2C²/r 9C²/r²相对普通 3×3 卷积9C²的比例 2/(9r) 1/r²取 C 256把不同压缩比的结果列出来压缩比 r中间宽度参数量相对普通 3×3实际感受1等效不压缩256720,8961.22 倍比普通卷积还贵纯亏2128212,9920.36 倍省得有限精度损失小46469,6320.12 倍经典取值性价比拐点83225,6000.043 倍省得狠窄通道有风险161610,4960.018 倍基本不可用BN 统计失真从表里能看出两个规律。r1 的时候两个 1×1 的额外开销把省下的算力全吃掉了甚至倒亏这说明瓶颈的前提是“真的压缩”。从 r4 到 r8参数量还能再降 63%但精度收益开始急剧衰减因为中间宽度掉到 32 之后独立特征维度的上限被压得太低。r16 那一档我实测过小 batch 下 BN 的 running stats 抖动非常明显验证集指标直接崩。再补一个整网层面的对照更能说明瓶颈的价值。一个 34 层的基础残差网络和一个 50 层的瓶颈残差网络前者的理论算力约 3.6 GFLOPs后者约 3.8 GFLOPs层数多了 16 层算力只涨了不到 6%而分类精度普遍能高 2 到 3 个点。这就是瓶颈最核心的贡献它把“加深网络”的成本从平方级压到了接近线性。3.3 训练配置与验证方法结构改完之后训练配置也要跟着调不然容易把结构问题和优化问题混在一起。学习率方面引入瓶颈之后每层的参数变少同样的学习率下参数更新的相对幅度会变大初期更容易震荡。我的做法是加 5 个 epoch 的线性预热从 1/1000 的目标学习率线性爬升到设定值然后再走余弦衰减。这个改动在多个复现里都有效尤其对 100 层以上的配置。权重衰减保持常规值就行不需要因为参数变少而调整因为衰减作用在参数绝对值上和参数数量无关。验证环节建议做一个消融固定总 FLOPs 预算扫描不同的压缩比看精度曲线在哪里出现拐点。伪代码如下for r in [2, 4, 8]: model build_backbone(bottleneck_ratior, target_flops3.8e9) acc train_and_eval(model, epochs100) print(fr{r}, params{count_params(model)/1e6:.1f}M, top1{acc:.2f})这里有个细节固定 FLOPs 意味着不同压缩比下网络的深度或宽度要相应调整否则对比不公平。压缩比大、省下来的预算可以拿去堆更深压缩比小、层数就得少一些。这个消融做下来一般能在某一段看到一个明显的性价比平台区平台区的左端就是可以放心用的取值。提示跑消融时务必固定随机种子、固定数据增强流程、固定 batch size。瓶颈结构对 batch size 比较敏感因为 BN 的统计量依赖批内样本数中间通道窄的时候这个敏感性会被放大。4. 常见问题与排查实录4.1 精度掉点的四个高频原因瓶颈块上线之后精度不如预期八成跑不出下面这几种情况我按出现频率从高到低排。第一个是末层激活没去掉。这个错我见过不止一次很多基础块的模板里最后一个卷积后面跟着 ReLU直接复制粘贴过来就带进了瓶颈块。判断方法很简单把块内的主干输出在相加之前打印出来看最小值是不是恒为 0如果是那基本可以确认。第二个是 stride 位置放错。前面讲过放在第一个 1×1 上会丢信息。排查方法是看下采样那一层的输入输出尺寸变化如果第一个 1×1 出来尺寸就减半了那就是放错了。第三个是 downsample 分支没对齐。捷径分支在主干的尺寸或通道发生变化时必须同步变换常见错误是用平均池化加通道填充来处理通道数变化这种做法在通道数从 64 跳到 256 的时候会让 192 个通道全填零等于捷径分支只传了 1/4 的信息。正确做法是用 1×1 卷积加 BN 做投影。第四个是中间宽度太窄。这个最难发现因为网络能训、loss 也降就是精度上不去。判断方法是统计各阶段中间层的通道数如果某一段低于 32那就是瓶颈过窄了。处理办法要么调小压缩比要么加宽那一段的基准宽度。4.2 一张速查表我把实际遇到过的问题整理成了表格出现症状时按表排查比翻代码快。症状最可能的原因快速验证处理动作训练 loss 正常验证精度明显低于基线中间宽度过窄打印各阶段通道数压缩比降到 2或加宽基准宽度前几个 epoch 就出现 loss 尖刺或 NaN末层 BN 未做零初始化学习率偏高查初始化代码、看梯度范数补零初始化加预热深层堆叠后梯度范数逐层衰减主干输出被激活函数截断打印相加前的输出分布移除末层 ReLU显存没降速度也没涨小算子碎片化硬件利用率低用 profiler 看算子耗时占比融合 BN改用通道后置内存格式通道切换处精度出现台阶式下滑捷径分支用填充代替卷积投影检查 downsample 实现换成 1×1 卷积加 BNbatch size 一变精度就抖窄通道 BN 统计不稳对比不同 batch 的指标中间宽度保底 32改用组归一化4.3 几个文档里不会写的实操心得心得一压缩比不要一次调到位。我习惯先把结构固定成 r4 跑通确认训练曲线正常再往下调。一次性把结构改到位出问题的时候根本分不清是结构问题还是优化问题。心得二用模型的中间特征做可视化比看指标快。瓶颈块出问题往往体现在中间层的特征响应上把几个 block 的输出做一次通道维度的均值统计如果某一层的响应方差突然塌缩到接近 0那说明信息在这个块里被压没了。这个检查几分钟就能做完比跑完整训练快得多。心得三小分辨率任务上别迷信 FLOPs。前面提过理论算力和实测延迟在小特征图上偏差很大。判断一个瓶颈结构在端上是否值得一定要在目标设备上实测看的是单帧延迟和内存带宽占用不是报表上的 FLOPs 数字。我吃过这个亏纸面省了 40% 算力实机只快了 8%还多占了显存。心得四残差分支上可以加一个可学习的缩放系数。有些实现会给主干输出乘一个初始为 0 的标量再相加效果和 BN 零初始化类似但更灵活训练过程中这个系数会自己长到合适的值。这个技巧在特别深的配置上收益明显浅网上基本看不出来。5. 瓶颈思想的迁移不只是卷积网络5.1 自编码器里的瓶颈层把 input 压到一个极低维的隐表示再重建这是自编码器的基本形态中间那个最窄的层就是标准的瓶颈。它和卷积瓶颈的区别在于目的卷积瓶颈是为了省算力自编码器的瓶颈是为了强制抽取本质特征、去掉冗余。这个场景下最需要关注的是重建误差和表示维度的关系曲线。维度从大到小扫一遍误差曲线通常会在某个拐点处突然抬头拐点之前的维度就是有效表示维度。我做过一个图像去噪的小实验输入 256×256 的图隐层维度从 256 降到 8降到 32 之前重建质量几乎不掉降到 16 开始出现明显的细节模糊降到 8 就直接糊成色块了。这个拐点位置和数据的固有维度强相关换数据集必须重新扫。另外瓶颈层前面的编码器和后面的解码器要尽量对称。不对称的结构容易让某一侧承担过多的表达能力另一侧退化成简单的查表泛化会变差。5.2 低秩分解把大矩阵拆成两个瘦矩阵瓶颈的数学本质是低秩近似。一个 C×C 的权重矩阵如果用它自己的两个因子 C×r 和 r×C 相乘来代替参数量从 C² 降到 2Cr当 r 远小于 C/2 的时候就是净省。这条思路在微调大模型的时候用得特别多冻结原权重旁挂一对小矩阵做增量更新秩 r 取 4 到 16 就够用。选 r 的方法和卷积瓶颈一样扫描不同秩看下游任务的指标曲线在哪里饱和。我在一个小规模文本分类任务上试过r 从 2 扫到 32r8 之后指标就基本平了再往上加纯属浪费。需要注意的是低秩分解假设权重矩阵本身是低秩的这个假设在有些层上并不成立。实践中一般只对注意力层的投影矩阵做分解对前馈层的第一个大矩阵保持原样因为那一层的秩通常比较高强行分解会明显掉点。5.3 什么信号说明瓶颈压过头了不管在哪个场景判断瓶颈是否过窄都有一些共性信号我把它们列在一起方便跨场景套用。训练 loss 下降正常但验证指标提前饱和是第一个信号说明模型容量被结构限制住了不是数据量不够。中间层特征的秩明显低于理论宽度是第二个信号可以通过对激活值做奇异值分解来看如果前几个奇异值就占了绝大部分能量说明实际有效维度远低于名义维度。对不同输入样本中间层输出的区分度下降是第三个信号取一批样本算中间层特征的类间距离如果距离随着训练推进反而缩小那就是压缩得太狠了。这三个信号我一般在结构调整之后的第一轮训练里就检查一遍比等完整训练跑完再看结果省时间。最后一个我个人的体会是瓶颈结构的调参顺序应该是先定中间宽度的下限再定压缩比最后才去动学习率和正则。反过来做的话你永远不知道指标变化是结构带来的还是优化带来的。
阅读完成 · 觉得有帮助?
咨询建站