☰
CSPNet 详解:跨阶段部分连接如何优化目标检测 Backbone
2026/10/2 22:55:43 网站建设 项目流程

第一次看到 CSPNet 这个标题,我下意识以为又是一个靠堆模块刷点数的 backbone 工作——2019 年前后改网络结构的论文多到看不过来,真正有价值的那几篇,往往不是发明了新算子,而是把某个大家都习以为常的细节讲透了。把这篇论文完整翻完、又在自己项目里把 ResNet 的 stage 改成 CSP 结构跑过一轮之后,我的判断变了:它做的事情非常朴素,就是在一个 stage 的入口把特征图按通道切成两份,一份走主干,一份直连到出口再拼回来,但这一刀切下去,算力、显存、梯度质量三件事同时被撬动了。

更重要的是,这个思路后来几乎成了目标检测骨干网络的默认选项。你在 CSPDarknet53、YOLOv5 的 C3、YOLOv8 的 C2f 里都能看到它的影子。所以对我来说,这篇论文不只是一篇"读过就存档"的笔记,更像是理解现代 CNN backbone 为什么长成今天这个样子的一把钥匙。下面这些内容,适合三类人看:正在改网络结构、想找性价比高的改造点的算法工程师;准备复现检测模型、被 C3/C2f 这些名字绕晕的学生;以及需要向别人解释"为什么这么设计"的技术负责人。

1. 先搞清楚 CSPNet 想动的是哪块蛋糕

1.1 backbone 在模型里到底扮演什么角色

很多人做检测或者分割任务时,注意力都在 head 和 loss 上,backbone 直接拿来主义,用别人训好的权重。但如果你算过一次 FLOPs 的分布就会发现,在高分辨率输入下,backbone 往往吃掉整个模型一半以上的计算量,而且它决定了特征质量的上限——后面的 neck 再怎么融合,也只能在 backbone 给出的特征上做文章。这就是为什么"改 backbone"这件事的投入产出比一直很高:改对了,整条链路都受益;改错了,后面所有模块都在给烂特征擦屁股。

论文里反复强调的一个词是 backbone,也就是从输入图像到多尺度特征图这一段。ResNet、ResNeXt、DenseNet 都属于这一类。CSPNet 要做的不是替换掉它们,而是给它们换一种"连接方式"。这个定位很关键,因为它意味着改造成本低——你不需要重新设计一个网络,只要把已有的 stage 结构重新组织一下。

1.2 两条路线夹缝里冒出来的问题

那几年 CNN 的演进其实分成两条线在跑。一条是精度线:ResNeXt 用分组卷积加宽通道,DenseNet 用稠密连接疯狂复用特征,SENet 加通道注意力,精度一路往上走,代价是计算量和显存也跟着涨。另一条是轻量线:MobileNet 用深度可分离卷积,ShuffleNet 用通道混洗,把 FLOPs 压到极致,但精度肉眼可见地掉。

问题就出在这两条线的夹缝里。想上精度的模型,部署端跑不动;想轻量的模型,业务指标达不到。而且更麻烦的是,很多论文只报 FLOPs,实际跑起来加速比根本对不上——因为真正的瓶颈不只是乘加次数,还有内存访问、特征图复制、算子启动开销。CSPNet 的切入点就在这里:它不去动卷积本身,而是去动"信息在网络里怎么流动",试图在同样的算力预算下拿到更好的精度。

1.3 论文的两块拼图:CSP 化的 backbone 和 EFM

这篇论文实际上给了两个东西。第一个是 CSP 结构本身,可以套在 DenseNet 上变成 CSPDenseNet,也可以套在 ResNet/ResNeXt 上变成 CSPResNet、CSPResNeXt,这是论文的主菜。第二个是 EFM,全称 Exact Fusion Model,用在检测任务的颈部融合上,用 Maxout 的思路替代传统的逐元素相加,属于配菜,但对做检测的人来说同样有用。

论文给出的整体结论方向是:CSP 化的 backbone 在计算量下降大约两成的条件下,精度不降反升,检测任务上 AP 能有明显提升。我记得论文里 CSPResNeXt-50 配上 EFM 在 COCO 上报告的是 43% 上下的 AP,推理速度在单张 1080Ti 上还能保持 100 FPS 以上。具体表格里的数字我这里不逐项抄,因为不同实现和训练配置下差别不小,重要的是这个方向:同样精度更省算力,同样算力精度更高。

2. 把 DenseNet 和 ResNet 摊开:CSP 到底切了什么、接了哪根线

2.1 从 DenseNet 的 concat 说起

要理解 CSP,得先看它针对的是什么。DenseNet 的一个 stage 里,第 k 层的输入是把前面所有层的输出在通道维拼起来,公式写出来大概就是 x_k = w_k * [x_0, x_1, ..., x_{k-1}],其中方括号代表 concat。这样做的好处很直观:特征被反复复用,梯度有很多条路可以回流,浅层也能拿到来自深层的信号。

但代价也很直观。通道数是线性增长的,一个 stage 走完,特征图的通道会膨胀好几倍,紧接着的 transition 层为了把通道压回去,必须做一次完整的一乘一卷积,同时还要把膨胀后的特征图整个复制一遍。这个复制操作在显存带宽上非常贵,实机跑的时候经常出现"FLOPs 明明不高,但就是慢"的情况。论文的观察更进一步:DenseNet 的梯度里存在大量重复信息,每一层的权重更新都叠加了一大堆高度相关的梯度项,这些项并没有带来等量的新信息。

2.2 一次拆分、两条路、一个部分过渡层

CSP 的做法是把 stage 的输入在通道维切成两份,记作 x0' 和 x0''。x0'' 走主干,也就是原来那串密集连接或者残差块;x0' 不参与主干计算,直接"抄近路"到出口。主干算完之后,输出先经过一个 1x1 卷积做部分过渡,然后和 x0' 在通道维拼起来,再由出口的过渡层融合并压到目标通道数,交给下一个 stage。

这里有三个细节值得单独拎出来。第一,切分发生在stage 入口,不是每个 block 内部,所以整个 stage 只有一次拆一次合,额外开销极小。第二,那条直连支路没有经过任何卷积,它是"部分"这个词的来源——只有一部分特征真正参与了深层的非线性变换。第三,两条支路最后是用 concat 而不是相加来合并的,这意味着主干支路和直连支路各自保留了独立的通道段,下游卷积可以自己去学怎么权衡两边。

2.3 套到 ResNet/ResNeXt 上要改哪几行

套到 ResNet 上的改动比想象中小。ResNet 的一个 stage 本来就是一串 bottleneck 残差块,CSP 版本只是把入口特征切一半,让其中一半进入这串残差块,另一半直连,最后在出口拼回来。ResNeXt 同理,只不过主干里的卷积是分组卷积。也就是说,CSP 是一种与具体 block 类型解耦的结构改造,它不关心你中间用的是普通卷积、分组卷积还是深度可分离卷积。

这一点在工程上很有吸引力。你的项目里已经有一个调好的 ResNet 系列,想试试 CSP,不需要重训一整套超参搜索,只要把 stage 的入口和出口重新组织一下,参数量和计算量的账立刻就能算清楚。下表是我自己整理的一个粗略对照,方便判断改动到底动了什么。

对比项原始 stageCSP 化后的 stage
入口特征整份通道全部进主干按比例切分,一部分进主干
主干承担的通道数全部通道约 50%(取决于 ratio)
出口合并方式主干输出直接过渡主干输出与直连支路 concat 后过渡
反向传播路径主干内部的多条路径主干路径 + 一条绕过主干的直连通路
FLOPs 相对量基准明显下降,幅度与切分比例相关
显存占用基准,transition 处有整图复制通常更低

2.4 partial ratio 怎么定:0.5 不是拍脑袋

论文默认的切分比例是 1/2,也就是主干拿走一半通道。这个值不是随手写的。切得太少,比如只切 1/8 出来直连,主干仍然承担了绝大部分计算,省下来的算力杯水车薪,等于白改。切得太多,比如留 3/4 给直连,主干只剩四分之一通道,那串残差块能承载的表达能力就不够了,直连支路反而变成了一条信息高速路,网络整体深度带来的优势被稀释。

我自己的经验是:中量级以上的网络,宽度比较足的时候,1/2 是稳妥的起点;如果是通道本来就窄的小模型,可以往 1/4 到 1/3 调一点点,避免主干太瘦。另外还有一个容易被忽略的约束:切出来的两段通道数最好都是 8 或 16 的倍数,否则在某些推理后端上会因为通道不对齐而掉速,甚至触发额外的内存重排。

3. 梯度账本:CSP 为什么敢说自己"增强了学习能力"

3.1 论文把反向传播拆成求和项想说明什么

论文里有一段推导,把 DenseNet 里各个权重的梯度更新写成若干梯度项相加的形式。粗略地说,越靠近 stage 后段的层,它的权重更新里叠加的梯度项就越多,而这些项来自同一批前序层,彼此之间存在高度相关性。从优化角度看,这相当于每次更新时都在用一份"被重复计票"的信息去调整参数——有效信息量远小于项数。

CSP 之后,主干支路只看到 x0'',x0' 的梯度被绕开了,不再出现在主干权重的更新式里。论文的说法是通过截断一部分梯度路径,减少了重复项的干扰,同时保留下来的那部分梯度组合更加多样。这听起来有点抽象,换个说法就是:网络不再听那么多口径一致的意见,而是让不同分支贡献差异化的信号。

3.2 重复梯度到底浪费在哪

我拿一件日常的事打比方。你要改一份稿子,十个人同时给你批注,结果其中八个的意见有八成重合,你真正拿到的有效反馈其实就两三条,但你要花十份的时间去读。如果让其中四个人先别看、只看另外几个人改完的版本,再给出意见,你拿到的信息量反而更丰富。

CSP 就是这个逻辑的结构化版本。它把一部分特征从主干里"撤出来",让主干专心做非线性变换,出口再让两条路的信息汇合。汇合点用的是 concat,所以下游卷积要自己去学怎么把两路信息揉到一起,而不是被迫接受一个已经被高度同质化的特征。

3.3 和 stochastic depth、dropout 不是一回事

有人会问:这不就是随机丢层的思路吗?不是。stochastic depth 是在训练时随机跳过某些层,测试时全用,属于一种随机的正则化手段。CSP 是确定性的结构改造,训练和推理时走的路径完全一致,部署时没有任何额外分支判断。也正因为它是结构性的,它的收益不会随着训练结束而消失,而是被固化在网络的连接方式里。

另外一个常被提到的关联是"顺带降了过拟合"。我个人的观察是,CSP 版模型在训练集上的拟合速度会比 baseline 稍慢一点点,验证集曲线更平,但差距没有大到能当作主要卖点。把它看成"更好的梯度组合 + 更低的计算开销"这两件事的组合,比把它当正则化工具更贴合实际。

4. 自己写一个 CSP Block:从代码到对比实验

4.1 最忠实论文的写法:先 chunk 再 concat

如果你只想理解机制,建议先用最原始的写法实现一遍,别用任何封装。核心就是三段:按通道切、主干计算、拼回来加过渡。下面这段是我自己调试时用的最小实现,可以直接跑。

import torch import torch.nn as nn class PartialTransition(nn.Module): """把主干分支和直连分支拼起来,再用 1x1 卷积融合并压到目标通道。""" def __init__(self, c_main, c_short, c_out): super().__init__() self.bn_short = nn.BatchNorm2d(c_short) self.fuse = nn.Conv2d(c_main + c_short, c_out, 1, 1, bias=False) self.bn_out = nn.BatchNorm2d(c_out) self.act = nn.SiLU(inplace=True) def forward(self, y_main, x_short): x_short = self.bn_short(x_short) out = torch.cat([y_main, x_short], dim=1) return self.act(self.bn_out(self.fuse(out))) class CSPStage(nn.Module): def __init__(self, c_in, c_out, n_block=2, ratio=0.5): super().__init__() c_main = int(c_in * ratio) c_short = c_in - c_main assert c_main > 0 and c_short > 0, "切分比例太极端,导致有一路为空" self.c_main, self.c_short = c_main, c_short self.blocks = nn.Sequential(*[ nn.Sequential( nn.Conv2d(c_main, c_main, 3, 1, 1, bias=False), nn.BatchNorm2d(c_main), nn.SiLU(inplace=True), ) for _ in range(n_block) ]) self.trans = PartialTransition(c_main, c_short, c_out) def forward(self, x): x_main, x_short = torch.split(x, [self.c_main, self.c_short], dim=1) return self.trans(self.blocks(x_main), x_short)

有几个地方我特意没省。直连分支加了一个 BatchNorm,这是为了让两路特征在 concat 之前尺度接近;卷积一律 bias=False 后面接 BN,是常规操作但在这个结构里尤其重要,因为两路特征的分布本身就不一样。过渡层用 1x1 卷积,是因为它同时承担了融合和降维两件事,如果换成 3x3,省下来的算力会被吃掉一大块。

4.2 工程里更常见的写法:双 1x1 分支

真正在检测模型里铺开的写法,和论文原版有一点微妙差别。以 YOLOv5 的 C3 为例,大致的形状是这样:

class C3(nn.Module): def __init__(self, c1, c2, n=1, shortcut=True, g=1, e=0.5): super().__init__() c_ = int(c2 * e) self.cv1 = nn.Conv2d(c1, c_, 1, 1, bias=False) self.cv2 = nn.Conv2d(c1, c_, 1, 1, bias=False) self.cv3 = nn.Conv2d(2 * c_, c2, 1, 1, bias=False) self.m = nn.Sequential(*[Bottleneck(c_, c_, shortcut, g) for _ in range(n)]) def forward(self, x): return self.cv3(torch.cat((self.m(self.cv1(x)), self.cv2(x)), 1))

它并不是严格意义上的"按通道切一刀",而是用两个 1x1 卷积把输入各自投影到 c_ 通道,一条路继续进 bottleneck 序列,另一条直接过来 concat。这样做的好处是输入通道和目标通道解耦,设计网络时不用被"必须能整除"绑住手脚;代价是多了一次 1x1 卷积的计算,严格来说和论文原版并不完全等价。

我提这一点的原因是:很多人在复现论文时会直接搜到这类代码,然后对着论文里的公式一头雾水,觉得对不上。其实不是谁抄错了,是工程实现为了通用性做了取舍。理解了这一点,你就不会在"论文说切通道、代码里却是两个卷积"这种问题上纠结太久。

4.3 对比实验怎么设计才有说服力

自己动手验证的时候,最容易犯的错是把所有变量一起改。我的建议是只改一处:保持 stage 数量、宽度、训练轮数、数据增强完全一致,唯一的变化是 stage 是否 CSP 化。量化的指标至少要覆盖下面几项,不然很容易得出错误结论。

指标怎么量我这边观察到的方向
参数量直接统计可训练参数CSP 化后通常略降或持平
FLOPsthop 或 fvcore 统计明显下降,幅度与 ratio 相关
峰值显存训练时记录显存峰值多数情况下更低
推理吞吐固定 batch 下测 img/s提升幅度小于 FLOPs 降幅
精度验证集 top-1 或任务指标不掉或小幅上升

注意最后两行的落差。FLOPs 降了两成,实机吞吐往往只能提升一成左右,这个差值在后面讲坑的时候还会再提一次。做消融时至少要跑三个随机种子,CSP 带来的精度变化很多时候正好落在噪声区间里,单次实验的结论不可靠。

4.4 训练侧要注意的几个旋钮

改完结构之后,训练超参不能完全照搬。第一,学习率和 warmup 建议保持原样先跑一次,确认结构本身没问题,再考虑调。第二,因为主干承担的通道数变少了,等效容量下降,如果原来就处于过拟合边缘,正则化强度可以适当放松一点。第三,激活函数的选择会影响收敛速度,SiLU 在这个结构里比 ReLU 更稳,尤其是过渡层的输出。第四,如果你用小 batch 训练,注意观察 BN 的 running stats 是否稳定,必要的话把 momentum 调小一点,让统计量更新更平滑。

5. 检测头上的那一半工作:EFM 与 Maxout 融合

5.1 为什么论文要管 neck 和 head

检测任务的算力分布和分类任务完全不一样。分类任务里 backbone 几乎吃掉全部计算,但检测任务在高分辨率输入下,颈部融合网络和多尺度预测头的计算占比可以高得惊人,尤其当你要在多个尺度上做预测时,通道数和特征图尺寸都不小。论文注意到了这一点,所以除了 backbone 之外,还专门给颈部融合提了一个设计,这就是 EFM。

EFM 全称 Exact Fusion Model,核心有两块:一是用 Maxout 来做多尺度特征的融合,二是引入全局过渡来压缩 concat 之后的通道。论文报告说这个设计能在几乎不增加算力的前提下改善融合质量。如果你只关心 backbone,这一段可以快速略过;但如果你正在做多尺度检测,这部分的价值不比 CSP 本身低。

5.2 max 融合和 sum 融合的差别

传统 FPN 用逐元素相加来融合不同尺度的特征,PANet 在 FPN 基础上又加了一条自底向上的路径。相加这个操作的问题在于:它假设所有尺度的特征重要性相同,而且反向传播时梯度会同时流向所有输入分支。如果两路特征本身尺度差异大,相加之后的值会被大的一方主导,小的一方相当于被稀释了。

Maxout 换成逐元素取最大值。前向时它只把每个位置上最强的那一路信号传下去,反向时梯度也主要回流到取到最大值的那一路。这种"选择性保留"的效果有点像注意力机制,但不需要任何额外的参数和计算。我自己的理解是:相加是求平均,取最大是选代表,而多尺度融合这件事上,选代表通常比求平均更合理——因为不同尺度的特征本来就各有所长,没必要非得一视同仁。

5.3 EFM 的代价与实测感受

取最大不是没有代价的。它会让输出特征的分布发生变化,如果直接在已经训好的权重上替换融合方式,前几个 iteration 的 loss 波动会很明显。我的做法是替换之后把学习率降一档,跑几百步再恢复,让 BN 的统计量重新适配。

另一个要注意的是,Maxout 这一招在数据量小的任务上收益不稳定。我在一个小规模数据集上试过,效果和相加基本打平,甚至偶尔更差;换到数据量充足的场景,优势才显现出来。所以如果要做这个替换,建议先做一次小范围的消融,别直接全网络铺开。

6. 复现路上真会踩到的坑

6.1 通道数对不上:三类典型报错

第一类是把切分放错了位置。有人理解成"在每个 block 内部都切一刀",结果主干每一层的通道都在变,计算量压根没省下来,反而因为反复切分拼合更慢了。记住:一个 stage 只切一次,在入口切。

第二类是过渡层输出通道设错。过渡层的输出通道应该等于下一个 stage 期望的输入通道,而不是主干支路的通道数。很多人写完发现下一层报维度不匹配,就是这里写成了 c_main。

第三类是下采样时的尺寸不匹配。如果这个 stage 要做二倍下采样,那么直连那一支也必须同步下采样,否则 concat 时空间尺寸对不上。常见做法是给直连支路配一个 stride=2 的 1x1 卷积,或者用平均池化。

6.2 归一化尺度不一致导致的训练抖动

这是我踩得最深的一个坑。最开始写的时候,我觉得直连支路反正不经过任何计算,加 BN 是浪费,就直接把原始特征 concat 上去。结果训练前期 loss 抖得厉害,前十几个 epoch 的验证集曲线像心电图。原因很简单:主干支路经过了好几层卷积和 BN,输出是标准化过的;直连支路是原始输入,尺度和分布完全不同。两路拼在一起之后,下游 BN 要同时应付两种分布,统计量来回摇摆。

加上一个 BN 之后问题基本消失。代价几乎可以忽略,收益是训练稳定性肉眼可见地改善。后来我在别的结构改造里也沿用了这个习惯:凡是两条分布不同的支路要 concat,就先把它们各自的尺度拉齐。

6.3 只算 FLOPs 不看访存,加速比打对折

前面表格里提到,FLOPs 降两成,实际吞吐可能只提升一成。原因有几个:concat 操作本身要搬运内存,通道拆分之后每组卷积的通道数变少,算子的计算密度下降,GPU 的利用率没那么高;如果切出来的通道数不是硬件友好的对齐值,还会触发额外的 kernel 切换。所以判断一个结构改造值不值,最终还是要看端到端的实测吞吐,尤其是部署目标平台上的实测数据,FLOPs 只能作为早期筛选指标。

6.4 部署时的通道对齐问题

这一条是做工程落地时才体会到的。切分比例一定要让两路通道数都落在 8 或 16 的倍数上。如果切出来是 37、59 这种数字,某些推理框架会退化成慢速路径,甚至需要插入额外的 padding 和对齐操作,最终速度可能还不如不改。我的习惯是在设计网络时就把主干通道数定成能被 2 的幂整除的数值,切分之后自然对齐。

现象可能的根因处理方式
FLOPs 没降切分放在 block 内部改到 stage 入口,只切一次
维度报错过渡层输出通道填成主干通道改为下一 stage 的输入通道
训练前期 loss 抖动直连支路缺少归一化给直连支路补一个 BN
实机提速不明显访存开销与算子密度问题端到端实测,调整通道对齐
小模型上精度反降切分后主干过窄调小切分比例或加宽主干

7. 从 CSPNet 往后看:这个思想被用在了哪些地方

7.1 检测模型里的 C3、C2f 与跨阶段设计

CSP 结构后来在检测领域被反复使用。有的实现把它直接搬进骨干网络,形成一整套 CSP 化的主干;有的把它包装成 C3 这样的模块,用在特征融合的各个阶段;再往后,多分支加部分连接加 concat 融合这套组合,演化出了更复杂的跨阶段聚合模块。名字换来换去,内核还是那三件事:切分、分流、重汇。理解了 CSPNet,再看这些模块,基本能在几分钟内看懂它的数据流图。

7.2 什么规模的模型值得上 CSP

不是所有模型都适合。通道宽、层级深的中量级和重量级网络,收益最明显,因为主干承担的计算量大,切一半省下来的东西足够多。而本来就非常窄的小模型,切完之后两路都瘦得可怜,表达力受损,精度很容易掉。我在一个宽度很小的网络上试过一次,参数量是降了,验证集指标也跟着降了一截,果断回退。

另一个经验判断是:如果你的任务本身对特征复用要求很高,比如细粒度分类或者小目标检测,CSP 带来的梯度多样性收益会更明显一些。如果只是简单的二分类,改动带来的差别可能跑三次都看不出来。

7.3 如果只想做最小改动,我会怎么下手

假设你现在手上有一个训好的 backbone,想低成本试一下 CSP 思想,我的做法是这样的:先找出网络里计算量占比最高的那个 stage,只改它一个单元,把入口特征按 1/2 切开,直连支路加一层 BN,出口用 1x1 卷积融合到原来的输出通道数,其他什么都不动。跑一次消融,看端到端的吞吐和指标。如果这两项里至少有一项有明确改善,再考虑往其他 stage 铺开;如果连着跑三次都在噪声范围内,那就先别铺,回去检查通道对齐和归一化这两件事有没有做对。

这个习惯我坚持了挺久,好处是每次改动都能被归因。结构改造最怕的就是一次改五个地方,最后指标动了却不知道是谁的功劳,下一次换任务又得从头摸一遍。CSP 这种改动量小、收益可量化、和具体 block 解耦的结构,恰好特别适合用这种方式一点点吃透。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询