我最早注意到 CSPNet,不是因为它在 ImageNet 上又刷了多少个点,而是在翻一份 YOLO 的配置文件时发现,CSPNet、Backbone 这两个词几乎出现在每一代的骨干网定义里。后来顺藤摸瓜读回原论文《CSPNet: A New Backbone that can Enhance Learning Capability of CNN》,才意识到它解决的问题比"又出了一个新结构"要实在得多。这篇论文的核心其实就一句话:在不牺牲精度的前提下,把 CNN 骨干网里的计算和显存开销打下来,同时让网络的学习能力不掉队。它提出的 Cross Stage Partial 结构,思路朴素到有点反直觉——把特征图在通道维度上切一刀,一半走深层堆叠,一半直接旁路过去。就这么一个动作,后来被 YOLOv4、YOLOv5、YOLOv8 一路沿用并演化,成了检测领域最主流的骨干网设计范式之一。
这篇笔记适合三类人看:正在啃 CNN 经典结构、想弄明白"为什么改一个 concat 位置就能提点"的学生;准备把骨干网换到自己检测或分割项目里、需要一套能直接跑起来的代码的工程师;以及被 FLOPs 和实际推理延迟之间的差距坑过、想搞清楚算力账到底怎么算的人。下面我按"问题是什么—结构怎么设计—代码怎么写—实验怎么读—坑在哪"的顺序展开,中间会把论文里没写透、但复现时会撞上的细节一并补上。
1. 这个Backbone到底改了什么:CSPNet的问题出发点
读一篇结构类论文,最容易犯的错是直接跳到网络结构图。我自己的习惯是先看作者在抱怨什么,因为抱怨决定了他们愿意牺牲什么。CSPNet 的出发点非常明确:当输入分辨率变大、任务变复杂(比如检测里的高分辨率输入),CNN 的计算量会以让人难受的速度膨胀,而这件事在移动端和边缘设备上尤其致命。作者把这个问题拆成了两块,一块是推理时的算力瓶颈,另一块是优化过程中的梯度信息重复,后者才是这篇论文真正的着眼点。
1.1 两块被长期忽略的成本:梯度信息重复与推理瓶颈
推理瓶颈好理解,就是 FLOPs 和内存访问量。真正有意思的是"梯度信息重复"这个提法。作者观察到,在 DenseNet 这类密集连接结构里,反向传播时同一份梯度信息会经由多条路径反复作用在权重上。你可以把它想象成一个团队里所有人都在转抄同一份会议纪要:看起来信息量很大,实际上有效新增信息很少,而且每个人都在为这份重复的纪要消耗算力。梯度更新也是这样,重复的梯度信息会让权重更新的方向变得不那么"差异化",优化效率反而下降。
这个观察在当年是有点反常识的。因为大家习惯性地认为,DenseNet 那种把前面所有层输出都 concat 上来的做法,是为梯度提供了更多通路,属于好事。CSPNet 的贡献在于指出:通路多和梯度信息多样化不是一回事。如果每一条通路传回来的都是同一份东西,那增加通路的收益会迅速衰减,成本却一直涨。我在自己做小数据集训练时确实有类似体感——把 DenseNet 堆得很深,训练集上很快过拟合,但验证集提升很慢,loss 曲线抖得厉害。当时归因于数据量不够,现在回头看,梯度信息的高度冗余可能也是原因之一。
需要说明的是,"梯度信息重复"在论文里给出的是一套基于前向/反向公式的直观推导,作者用来解释 CSP 结构为什么有效,但它本身还称不上一个被严格证明的定理。所以读的时候,把它当成一个非常有启发性的设计直觉,而不是一条物理定律,心态会摆得比较正。
1.2 Cross Stage Partial 字面翻译背后的真实含义
"Cross Stage Partial"这三个词,我第一次看到也觉得绕。拆开看就好懂了。Stage 是指骨干网里分辨率相同的那个阶段,比如 ResNet 里从 56×56 到 28×28 之间的那一串残差块,一个 stage 就是一个"战斗单元"。Partial 是部分,指特征图在通道维度上被分成两份。Cross 是交叉,指这两份特征不是各自独立走下去,而是在 stage 的输出端重新汇合。
所以 CSP 的完整动作是:stage 的输入特征图,在通道维度上一分为二,一份经过这个 stage 的主体计算(堆叠的残差块或者 dense 块),另一份什么也不做、直接旁路到 stage 末端,两边 concat 之后再做一次融合卷积(论文里叫 partial transition layer),输出给下一个 stage。写成伪代码就是四行:
- 输入 x,沿通道切分成 x1 和 x2;
- y1 = 主体计算(x1),主体可以是一串残差块;
- y2 = x2,原样保留;
- 输出 = 融合卷积(concat(y1, y2))。
这个设计最妙的地方在于它同时干了两件事。前向方向上,主体只处理一半的通道,计算量直接下降;反向方向上,旁路那半边的梯度不需要穿过主体计算,于是主体收到的梯度更新不再包含这部分重复信息。一个动作,同时缓解了算力和梯度冗余两个问题,这就是为什么它后来被大量工程化项目采纳——改造成本极低,收益却是双份的。
1.3 论文给自己定的三个目标,以及它实际做到了什么
作者在论文里明说了三个目标:第一,增强 CNN 的学习能力,让同等参数下精度更高;第二,去掉计算瓶颈,尤其是让推理时各层之间的计算量更均衡;第三,降低显存占用。第三个目标经常被读者忽略,但对部署来说它可能比前两个都值钱,因为显存往往是移动端先撞上的那堵墙。
我把这三个目标和实际效果对应一下。学习能力方面,论文报告的 ImageNet 分类结果里,CSP 版本相对对应的基线骨干网,在参数量和计算量都下降的前提下,Top-1 精度还有小幅提升——注意是"下降前提下还提升",不是"用更多算力换更高精度",这个方向性的差别很重要。计算瓶颈方面,CSP 结构把一个大 stage 的计算量摊到了两条分支上,避免了单个超长残差堆叠带来的算力尖峰。显存方面,由于旁路分支不参与主体计算,中间激活的显存占用也有明显下降。
我把话说得保守一点:论文里给出的具体小数位我不在这里逐条背诵,因为不同变体(CSPResNet、CSPResNeXt、CSPDarknet)和不同配置下的数字差别不小,而且检测任务的 AP 受检测头和训练策略影响很大。真正值得记住的是量级和方向——参数量、计算量、显存三项同时下降,精度基本持平或小幅上升。这个"三降一平"的组合,才是 CSPNet 能活到今天的原因。
2. CSPNet的结构原理拆解:一次切分带来了什么
理解 CSP 的结构,不能只看那张"切成两半再拼起来"的示意图,否则很容易得出"这不就是把网络变窄了吗"的错误结论。要讲清楚它和单纯减通道的区别,得从三个层面看:前向的计算路径、反向的梯度路径,以及它和 ResNet、DenseNet、ResNeXt 这三种主流设计的血缘关系。我尽量把这几层拆开讲透,因为后面写代码和调参时,所有的坑几乎都出在这三个层面。
2.1 基座-切分-合并:CSP模块的最小结构
抛开具体实现,CSP 模块的最小骨架可以归纳成"基座—切分—合并"三步。基座是 stage 的输入投影,通常是一个 1×1 卷积,把输入通道映射到 stage 需要的工作宽度;切分是沿通道一分为二;合并是把两路 concat 之后做一次 1×1 卷积把通道数收敛到目标输出。
这里有个细节值得单独说:切分和基座投影的先后顺序,在论文给出的不同变体里并不完全一致。有些变体是先做基座 1×1 再切分,有些是先切分再分别投影。这两种写法在参数上略有差别,在实际精度上差别通常很小,但会影响到卷积融合的算子调度效率。我自己写代码时更倾向于先 1×1 再切分,因为这样切分点之前的计算是共享的,可以少一次小的卷积启动开销。
另一个容易被忽略的点是:两路合并之后的那个 1×1 融合卷积,是 CSP 模块里最贵的一层。当输入输出通道都是 C 时,这一层的计算量是 C² 量级,而主体分支省下来的算力有可能还不到这个数。所以论文里管它叫 partial transition layer,是把它当成一个"有节制的过渡层"来设计的——它的输出通道往往比 concat 后的总通道数小,用来控制下一个 stage 的输入规模。如果你照抄结构但把这一层的输出通道设成和输入一样大,会发现算力没省下来多少,这就是原因。
2.2 为什么Partition能减少计算而不是简单砍参数
很多人第一反应是:切一半通道,主体计算量当然减半,这不就是变相砍宽度吗,凭什么精度不掉?这个质疑是有道理的,单纯砍宽度确实会掉点。CSP 的关键差别在于,被切出去的那一半并没有消失,它以"零计算成本"的方式参与了最终输出的 concat。
拿一个具体的例子算账。假设某个残差块,输入输出都是 C 通道,内部隐藏宽度是 C/4。标准做法的逐位置乘加量是:1×1 的 C→C/4 是 0.25C²,3×3 的 C/4→C/4 是 9×C²/16 即 0.5625C²,1×1 的 C/4→C 是 0.25C²,加起来 1.0625C²。如果改成 CSP 形态,旁路那一半直接跳过这个块,主体只在 C/2 通道上工作,隐藏宽度取 C/4,那么主体的逐位置乘加量是 0.125C² + 0.5625C² + 0.125C² = 0.8125C²,大约是原来的 76%。
看起来只省了四分之一,好像不多。但把视角放大到一整个 stage 就完全不一样了。假设这个 stage 里有 6 个这样的块,标准形态是 6×1.0625C² = 6.375C²;CSP 形态下主体的 6 个块只有 6×0.8125C² = 4.875C²,旁路分支几乎不花钱,只在合并处多一次 1×1。也就是说,在整个 stage 的尺度上,省下来的绝对量是可观的,而且省下来的这部分并没有把信息丢掉——被旁路的那一半特征原封不动地进入了输出。
这就是为什么 CSP 不像单纯砍宽度。砍宽度是信息量的净损失,CSP 是把一部分通道的计算从"必须处理"变成"直接传递",信息量没少,只是处理方式变了。
2.3 从反向传播看梯度路径的变化
前向的账算完,再来看反向。这是 CSPNet 论文里最有价值、也最容易被跳过的一段。
在标准 DenseNet 里,每个 dense 块的输出都 concat 到后面所有层的输入上,反向传播时,某一层的权重梯度会从后续所有路径汇集上来,而这些路径在数值上高度相关。用一个不严谨但好记的说法:同一个梯度信号被数了好几遍,导致权重更新的有效学习率在一些方向上被放大,在另一些方向上被稀释,优化的"信噪比"下降。
CSP 的做法是,把旁路分支从主体计算里摘出去。这样一来,合并层的梯度有一部分直接回传到 stage 输入,完全绕过了主体里的那些卷积层。而主体分支的权重,收到的梯度只来自它自己那条路径,不再叠加旁路那一份重复信号。用一句话概括:局部上梯度变"稀疏"了,但稀疏带来的是差异化,差异化带来的是更好的学习效率。
我自己的观察是,这个效应在小 batch、小数据集上会更明显。大 batch 训练时梯度本身噪声就大,这点差异容易被淹没;而在数据量只有几万张、batch 又开不大的场景里,CSP 结构的训练曲线往往比基线更稳。这不是论文的结论,是我复现时的一个体感,供你参考。
2.4 与ResNet、DenseNet、ResNeXt的结构对照表
把 CSP 和三种主流结构放在一起对照,能更清楚地看出它站在哪个位置。
| 结构 | 跨层连接方式 | 反向梯度路径 | 主要开销来源 | 通道复用方式 |
|---|---|---|---|---|
| ResNet | 逐元素相加(shortcut) | 有恒等通路,梯度可直达 | 3×3 卷积本身 | 无显式复用 |
| DenseNet | 通道维 concat 全部前层 | 通路极多,梯度信息重复 | concat 后的通道膨胀 | 全量复用,代价是通道数爆炸 |
| ResNeXt | 分组卷积 + 相加 | 与 ResNet 类似 | 分组卷积的等效宽度 | 组内复用 |
| CSPNet | stage 内切分 + 末端 concat | 旁路直达,主体梯度不叠加 | 合并层的 1×1 | 部分复用,通道数可控 |
这张表里我最想让你注意的是最后一行"部分复用"。DenseNet 是全量复用,好处是特征极度丰富,代价是通道数随深度线性甚至更快膨胀,concat 那一步的内存拷贝非常伤;ResNet 是完全不复用,好处是干净,代价是每一层都得自己从头学特征。CSP 在这个光谱上找到了一个中间点:只复用一部分,而且复用是跨 stage 级别的、不是逐层的,所以通道数可以被精确控制。
也正因为这个定位,CSP 天然是"可加装"的——它不改变残差块内部的结构,只是在 stage 的组织方式上动手。这一点在工程上价值巨大,意味着你可以拿一个现成的 ResNet 实现,花二三十行代码就改成 CSP 版本,不需要重写整个骨干网。下一节讲变体的时候,你会看到作者也是这样做的。
3. 三个变体与两个配套模块:工程落地时的选型
论文里 CSPNet 不是一个固定的网络,而是三个变体加两个可插拔模块的组合。搞不清楚这五个东西分别解决什么问题,选型时就会瞎猜。我把它们的关系梳理成这样:三个变体是"把 CSP 套到谁身上"的问题,两个模块是"检测任务里额外补什么"的问题。前者决定骨干网,后者决定脖子和头这一段怎么接。
3.1 CSPResNet、CSPResNeXt、CSPDarknet的差别在哪
CSPResNet 是最基础的版本,把 ResNet 的每个 stage 换成 CSP 形态。改动集中在两处:stage 的入口做通道切分,stage 的出口做 concat 加融合卷积。瓶颈块内部结构完全不变。这种改法最保守,也最容易验证效果,我第一次复现就是从这个版本入手的。
CSPResNeXt 是在 CSPResNet 基础上把瓶颈块里的 3×3 卷积换成分组卷积。分组卷积本来就是为了在同等参数下扩大等效宽度,套上 CSP 之后,两条分支各自是分组卷积,算力分布更均匀。论文里 CSPResNeXt-50 对比 ResNeXt-50 的数据是三个变体里最漂亮的,参数量和计算量都下降,精度还略高。
CSPDarknet 是给检测任务准备的。Darknet 系列本身的结构比 ResNet 更"重",每个 stage 的残差堆叠更长,所以套 CSP 之后省下来的算力比例也更大。这也是为什么后来 YOLOv4 选的是 CSPDarknet53 而不是 CSPResNet50——不是因为精度绝对更高,而是因为在检测这个具体场景下,CSPDarknet 的算力-精度曲线更划算。
这里补充一个重要的事实:论文里的 CSPDarknet 和后来 YOLOv5 里那个被大家叫做 C3 的模块,细节上并不完全一致。C3 是工程化之后的变体,切分方式更简洁(用两个 1×1 卷积隐式实现切分),通道比例也做过调整。你如果拿 YOLOv5 的 C3 去和论文里的 CSPDarknet 逐层对,会发现对不上,这是正常的。写代码时以你实际用的框架为准,别死抠论文。
3.2 Partial Transition Layer:那个容易被忽略的关键层
在 CSP 模块的末端,concat 之后那一次 1×1 卷积,论文叫 partial transition layer。这个名字里的 partial 是有讲究的——它不只是"过渡",它同时承担了三个职责。
第一个职责是通道收敛。两路 concat 之后通道数是输入的两倍,如果不收敛,下一个 stage 的输入会翻倍,几层下去通道数就失控了。第二个职责是特征融合。旁路那半边的特征没经过任何变换,和主体输出直接拼在一起,两者在数值分布上有差异,需要一个可学的层把它们对齐。第三个职责,也是最少被提到的,是梯度分流。这一层是旁路分支梯度回传的必经之路,它的存在让旁路的梯度有了一个"可控的闸门"。
我踩过一次坑:早期复现时为了省参数,把这个融合层去掉,直接 concat 完就送进下一个 stage。结果训练能跑,但精度比基线还低了一截。回头分析,问题就出在第二个职责上——两路特征的分布差异没有被对齐,之后的 BN 层要花很多精力去纠正这个分布,等于把工作从一个 1×1 卷积转移到了 BN,代价更大。所以这一层千万别省。
它的输出通道怎么定也有讲究。常见做法是设成和输入相同的 C,这样每个 stage 前后通道数不变,堆叠时不用额外考虑;也有做法是设成 C 的一半,让通道数阶梯式增长。前者稳定,后者更省。我的建议是先用 C,跑通了再尝试压缩。
3.3 EFM:特征金字塔融合的成本控制
EFM 全称 Exact Fusion Model,是论文里针对检测任务的配套模块,解决的是特征金字塔融合阶段的算力问题。检测网络一般要把不同尺度的特征图上采样或下采样到同一分辨率再相加,这个阶段的算力开销在总体里占比不小,尤其是输入分辨率大的时候。
EFM 的做法是给融合引入注意力式的加权:不同来源的特征图不是简单地相加,而是通过一个可学习的方式决定各自的贡献,同时用较大的感受野来做融合,避免小卷积核反复堆叠带来的开销。论文报告这一块能把融合阶段的计算量压到原来的四分之一左右。
这里我要提醒一句:EFM 和后来大家更熟悉的 FPN、PAN、BiFPN 属于同一类东西,都是脖子部分的融合设计。如果你现在用的是 YOLOv5 或 YOLOv8 这类成熟框架,它们的脖子已经做过大量优化,没必要再手动把 EFM 塞进去。EFM 的价值更多是提供一种思路:融合阶段是可以做减法的,不是只能一味加层。
3.4 SAM:空间注意力在骨干网里加在哪儿
SAM 全称 Spatial Attention Module,在论文里的用法比较克制——它不是加到每个 stage 上,而是加在低层特征图上,目的是在不增加太多计算的前提下扩大感受野。低层特征图分辨率高、通道数少,在这里做注意力比在高层做便宜得多,而且低层特征对定位更重要,放大感受野对检测框的回归有直接帮助。
我实测下来,SAM 这类模块的收益比较看任务。如果你的任务是检测小目标、低层特征的重要性高,加 SAM 通常能看到收益;如果是纯分类任务,加它的收益就非常有限,有时候甚至因为额外参数导致过拟合。所以别把它当成必选项,要做消融再决定。
4. 计算量手算与代码实现:把CSPNet落到自己的项目里
前面都是纸面分析,这一节开始动手。我按"先算账、再写码、最后实测验证"的顺序来,因为算账这一步能帮你判断一个改动值不值得做。很多人写代码是一边改一边试,改到后面自己都说不清是哪一步起了作用,根子就在于没提前算账。
4.1 单个Bottleneck的MAC手算
先把三种常见瓶颈块的逐位置乘加量(MAC)算清楚。假设输入输出通道都是 C,只统计卷积,不算 BatchNorm 和激活函数,也不考虑偏置项。所谓"逐位置",是指每个空间位置上的乘加次数,这样算出来的量再乘以特征图面积就是总 FLOPs。
| 结构 | 内部宽度 | 1×1 降维 | 3×3 卷积 | 1×1 升维 | 合计(单位 C²) |
|---|---|---|---|---|---|
| ResNet式瓶颈 | C/4 | 0.25 | 0.5625 | 0.25 | 1.0625 |
| Darknet式瓶颈 | C/2 | 0.5 | 2.25 | 0.5 | 3.25 |
| CSP主体(ResNet式) | C/4 | 0.125 | 0.5625 | 0.125 | 0.8125 |
| CSP主体(Darknet式) | C/2 | 0.25 | 1.125 | 0.25 | 1.625 |
算的原理很简单,一个输入通道数 a、输出通道数 b 的卷积,在每个空间位置上的乘加次数就是 a×b;卷积核是 3×3 的话再乘以 9。所以 1×1 的 C→C/4 就是 0.25C²,3×3 的 C/4→C/4 是 9×C²/16 = 0.5625C²。
从表里能看出两件事。第一,3×3 卷积是绝对的耗粮大户,在 ResNet 式瓶颈里它占了全部算力的 53%,在 Darknet 式瓶颈里占到了 69%。第二,CSP 主体之所以便宜,一半功劳来自通道切分(输入通道从 C 变成 C/2),另一半来自内部宽度的变化。这两者的贡献是可以分别控制的,这给了你调参的空间:想省算力就多切一点,想保精度就少切一点。
再算一下合并层的账,把它和主体省的算力放一起对比,你会看得更清楚。合并层是 1×1 卷积,输入是两路 concat 的 C,输出也是 C,算下来是 C²。而主体分支通过切分省下的算力,在 ResNet 式瓶颈里是 1.0625C² − 0.8125C² = 0.25C²。也就是说,光看一个块,合并层的 C² 比省下的 0.25C² 大了四倍。
这个对比第一次算出来的时候我是有点懵的,感觉 CSP 是不是白折腾了。但放到 stage 尺度就理解了:一个 stage 有 6 到 8 个块,每个块省 0.25C²,累积起来是 1.5C² 到 2C²,而合并层只在整个 stage 的出口出现一次,成本是 C²。所以 stage 越长,CSP 越划算;stage 只有一两个块的时候,改造成 CSP 反而可能亏本。这也是为什么后来所有工程化实现都只在"长 stage"上做 CSP,短 stage 保持原样。
4.2 C3模块的PyTorch实现与逐行说明
说完账,上代码。先给一个最贴近工程实践的版本,也就是 YOLOv5 系列里那个被广泛使用的 C3 模块。它用一个很巧的写法隐式实现了切分。
import torch import torch.nn as nn def autopad(k, p=None): """让卷积输出尺寸与输入保持一致,k 为奇数时 p = k // 2""" if p is None: p = k // 2 if isinstance(k, int) else [x // 2 for x in k] return p class Conv(nn.Module): """Conv + BN + 激活 的标准组合,激活默认用 SiLU""" def __init__(self, c1, c2, k=1, s=1, p=None, g=1, act=True): super().__init__() self.conv = nn.Conv2d(c1, c2, k, s, autopad(k, p), groups=g, bias=False) self.bn = nn.BatchNorm2d(c2) self.act = nn.SiLU() if act else nn.Identity() def forward(self, x): return self.act(self.bn(self.conv(x))) class Bottleneck(nn.Module): """残差瓶颈块:1x1 降维 -> 3x3 卷积 -> 残差相加""" def __init__(self, c1, c2, shortcut=True, g=1, e=0.5): super().__init__() c_ = int(c2 * e) # 内部隐藏宽度,默认是输出的 1/2 self.cv1 = Conv(c1, c_, 1, 1) self.cv2 = Conv(c_, c2, 3, 1, g=g) self.add = shortcut and c1 == c2 # 通道不一致时自动关掉残差 def forward(self, x): return x + self.cv2(self.cv1(x)) if self.add else self.cv2(self.cv1(x)) class C3(nn.Module): """CSP 结构的工程化实现:两条支路各走一半通道,末端 concat + 1x1 融合""" def __init__(self, c1, c2, n=1, shortcut=True, g=1, e=0.5): super().__init__() c_ = int(c2 * e) # 每条支路的工作通道数 self.cv1 = Conv(c1, c_, 1, 1) # 支路一:进入堆叠瓶颈块 self.cv2 = Conv(c1, c_, 1, 1) # 支路二:直接旁路(只做一次投影) self.cv3 = Conv(2 * c_, c2, 1, 1) # 融合:把两路拼起来压回目标通道 self.m = nn.Sequential( *[Bottleneck(c_, c_, shortcut, g, e=1.0) for _ in range(n)] ) def forward(self, x): y1 = self.m(self.cv1(x)) y2 = self.cv2(x) return self.cv3(torch.cat((y1, y2), dim=1))逐行说几个关键点。c_ = int(c2 * e)里的 e 默认 0.5,意思是每条支路只处理输出通道数的一半,两条加起来正好等于 c2。cv1和cv2都是从同一个输入 c1 出发的两个 1×1 卷积,各自输出 c_ 个通道——这在数学上等价于"先把输入沿通道一分为二,再各自做 1×1 变换",但写起来只有两行,而且两条支路的通道数可以独立调整,灵活度更高。这是工程实现比论文描述更讨巧的地方。
self.m里那一串瓶颈块,注意传给 Bottleneck 的e=1.0。因为外层已经做过一次 0.5 的降维了,如果块内再来一次 0.5,内部宽度会变成 c2 的四分之一,网络会变得很窄、表达力不足。这个双重 e 的坑我第一次写的时候踩过,网络能训但精度明显偏低,盯了很久才发现是这里。记住一条经验:e 这个缩放因子在一个 C3 里只应该生效一次。
最后cv3是融合层,把 2×c_ 压回 c2。如果你把 c2 设得比 2×c_ 大,那这层就在做升维;反过来就是降维。绝大多数配置里两者相等,融合层只做通道对齐。
4.3 替换ResNet的Bottleneck:改造步骤
如果你手上有个现成的 ResNet 想改成 CSP 版本,改法比想象中简单,核心就是把Bottleneck换成C3这类 stage 级模块。步骤我列一下:
- 找到 ResNet 的
_make_layer函数,它负责生成一个 stage 里堆叠的残差块; - 把每个 stage 的第一个块保留为下采样块(stride=2),其余块保持不变;
- 在 stage 的入口和出口外面包一层 C3 结构,或者直接替换整个 stage 的实现;
- 检查通道数变化:CSP 版本里 stage 的输入会被切分,所以入口的 1×1 投影要按输入通道算,别用错。
第 4 步是高频出错点。C3 的cv1和cv2的输入通道是c1(也就是 stage 的输入通道),不是切分之后的通道数。因为切分是在这两个卷积内部隐式发生的。如果你按"切分后的通道数"去设 c1,通道就对不上了,跑起来会直接报维度错误。
另外一个建议是分批改:先只改 stage 3 和 stage 4 这两个最长的 stage,跑一遍看精度和算力变化;确认没问题再考虑改 stage 2。理由是前面算过账,短 stage 改 CSP 收益很低,有可能得不偿失。
4.4 用thop实测参数量与FLOPs
手算能帮你判断方向,但手算有两个系统误差:一是没算 BN 和激活,二是没算上采样、concat 这些算子。所以最终还是要实测。用 thop 这个库最省事:
import torch from thop import profile # 这里的 CSPResNet50 换成你自己的模型实例 model = CSPResNet50(num_classes=1000).eval() dummy = torch.randn(1, 3, 224, 224) with torch.no_grad(): macs, params = profile(model, inputs=(dummy,), verbose=False) print(f"参数量: {params / 1e6:.2f} M") print(f"MACs: {macs / 1e9:.2f} G") print(f"FLOPs: {macs * 2 / 1e9:.2f} G") # 一次乘加算两次浮点运算这里有三个实测时容易搞混的地方,我一个个说。
注意:MACs 和 FLOPs 是两个不同的量,很多论文和开源实现混着用。一次乘加是一乘一加,算两次浮点运算,所以 FLOPs 一般是 MACs 的 2 倍。看到别人报"多少 GFLOPs"时,先确认他说的是哪一个,不然对比出来的差距可能全是口径问题。
第一个坑是输入分辨率。FLOPs 和输入尺寸是线性关系,224×224 和 256×256 差着 30%,跨分辨率比 FLOPs 没有意义。第二个坑是thop对某些自定义算子会漏算,尤其是 concat、split 这类纯内存操作,所以它给出的数字是"卷积部分"的算力,和推理框架实际测出来的延迟不是一回事。第三个坑是分组卷积,thop在旧版本里对 grouped conv 的统计有偏差,如果你用的是 CSPResNeXt,最好用新版或者交叉验证一下。
5. 训练与复现:消融实验怎么做才有说服力
结构和代码都有了,接下来是训练和验证。这一节我想重点说消融实验的设计,因为结构类论文的结论可信度,几乎完全取决于消融做得干不干净。我见过太多复现文章,改了三个地方一起上,涨点了就归功于自己最想证明的那一处,这种结论没法指导别人。
5.1 训练配置的常见选择
ImageNet 分类的标准配置大致是这样:优化器用带动量的 SGD,动量 0.9,权重衰减 5e-4;学习率用余弦退火,初始值随 batch size 线性缩放,batch 256 时初始学习率在 0.1 到 0.2 之间;训练 100 到 300 轮;数据增强标配随机裁剪加翻转,想要更好效果再叠 mixup 或 cutmix,标签平滑 0.1;权重指数移动平均(EMA)通常能白拿 0.2 个点左右的提升。
这套配置不是必须照抄,但它经过大量验证,是个不容易出错的起点。我要特别提一句权重衰减:CSP 结构的参数量比同深度基线少,同样的权重衰减系数在 CSP 上的相对约束会更强一点。如果你发现 CSP 版本比基线更容易欠拟合,可以试着把权重衰减调小一些,比如从 5e-4 降到 3e-4,再看曲线。
5.2 消融实验的设计要点
做结构消融,最容易犯的错误是"用同深度对比"。CSP 版本和基线在同样的层数下,参数量和算力是不同的,这样对比出来的精度差异,说不清是结构带来的还是容量带来的。正确的做法有两个:要么控制算力相同(比较同等 FLOPs 下的精度),要么控制参数量相同(比较同等参数下的精度)。论文里用的是前者,因为它更贴近部署时的真实约束。
第二个要点是随机性控制。ImageNet Top-1 的训练噪声通常在 0.1 到 0.3 个百分点之间,如果你的改动只带来 0.2 个点的提升,那很可能落在噪声范围里。稳妥的做法是至少跑两个随机种子取平均,如果一个种子涨 0.3、另一个掉 0.1,说明这个改动不可靠。
第三个要点是训练轮数要足够。我在复现时遇到过这种情况:前 60 轮 CSP 版本落后基线,80 轮之后反超。如果只训 60 轮就下结论,会得出完全相反的答案。结构类改动往往影响的是收敛速度而不是最终上限,训练不充分时看到的是速度差异,不是容量差异。
5.3 论文里几组关键数据的读法
读实验结果表的时候,我建议关注三列而不是一列:参数量、FLOPs(或者 MACs)、Top-1 精度。只看精度的提升,会漏掉"用更少算力拿到同样精度"这个更有价值的结论。CSPNet 的三个变体在论文里的共同特点就是这三点同时往好的方向走。
具体数字我不在这里逐个复述,因为不同变体、不同输入尺寸、是否用多裁剪测试,数字差别都能有零点几到一点几,逐个抄反而容易误导。更实际的做法是:把论文里的表格结构和自己的实验表格对齐,用同样的口径(同样的输入尺寸、同样的评测方式)去算一遍自己的数字,这样得到的结论才对你有用。
检测任务上的迁移结果也一样。CSP 结构的检测器在 COCO 上的表现,受检测头、训练策略、是否用预训练权重影响很大。我自己的经验是,在检测任务上把骨干网换成 CSP 版本,收益往往比在分类任务上更明显,因为检测用的输入分辨率更高,算力瓶颈本来就更突出,CSP 缓解的正是这个瓶颈。
6. 踩坑记录与常见问题速查
最后这一节写复现时真正会撞上的东西。前面几节讲的是"应该怎么做",这里讲的是"为什么我按应该做的做了还是不行"。
6.1 不收敛与掉点的几个典型原因
第一个原因是之前提到的双重 e。c_ = c2 * e在外层算了 0.5,块内又算了一次 0.5,内部宽度变成 c2 的四分之一。表现是 loss 下降很慢、最终精度比基线低一到两个点,但因为网络能跑,很容易被忽略。排查方法很简单:打印每个模块的实际通道数,对着看。
第二个原因是融合层缺 BN。两路 concat 之后特征分布不一致,如果融合卷积后面没有 BN,下一个 stage 的 BN 要承担纠偏的工作,训练会不稳定。标准实现里融合卷积永远是 Conv+BN+激活,不要为了省参数去掉。
第三个原因是学习率没跟着改。CSP 版本参数量更少,同样的学习率下每步更新的相对幅度更大,容易训飞。表现是前几轮 loss 振荡甚至变成 NaN。解决办法是先把学习率降一半试,稳定了再慢慢往上调。
第四个原因是旁路分支的通道数太少。如果为了省算力把切分比例设成 0.25/0.75,旁路只留四分之一通道,信息量不足,精度会掉。经验值是切分比例不要超过 0.5,也就是主体至少拿到一半通道。
6.2 通道切分比例、下采样位置、BN位置的细节
切分比例是最值得调的参数,但它的影响没有想象中大。我做过一组对比,比例从 0.5 调到 0.6,ImageNet 精度的变化在噪声范围内。所以别在这上面花太多时间,0.5 是个稳妥的默认值。真正需要按 stage 调的是"哪些 stage 用 CSP",长 stage 用、短 stage 不用,这个收益比调比例明显。
下采样位置有一个隐性的坑。如果 stage 的首个块带 stride=2,而这个下采样发生在主体分支里,那么旁路分支也需要对应的下采样,否则 concat 时空间尺寸对不上。常见做法是把下采样放在 stage 入口的 1×1 投影上,两条支路共享这次下采样,这样最省事也不容易出错。如果你把下采样放在主体里,一定要记得给旁路也加一个 stride=2 的操作。
BN 的位置也有讲究。两路分别做 BN 再 concat,和 concat 之后统一做 BN,效果是不一样的。分别做 BN 的好处是各自归一化,分布更干净;统一做 BN 的好处是计算量少,而且两路之间有了耦合。工业实现里两种都有,我倾向于统一做 BN,因为它在推理时更容易被融合进卷积,部署友好。
还有一个部署侧的细节值得提前知道:CSP 结构里的 concat 会增加内存访问。在桌面 GPU 上,算力是瓶颈,concat 的影响不大;但在移动端或者一些 NPU 上,内存带宽才是瓶颈,concat 造成的额外拷贝会让实际延迟比 FLOPs 预测的要高。所以如果你是在端侧部署,不要只看 FLOPs 的下降,一定要在目标设备上实跑一遍延迟。
6.3 常见问题速查表
把这一节的内容整理成表,方便你遇到问题时直接对照。
| 现象 | 可能原因 | 排查方法 | 处理建议 |
|---|---|---|---|
| loss 下降慢,最终精度低于基线 | 双重 e 导致内部通道过窄 | 打印各模块实际通道数 | 检查缩放因子是否只生效一次 |
| 训练前几轮 loss 振荡或 NaN | 学习率相对参数量偏大 | 降低学习率重跑 | 初始学习率降为原来的 0.5 倍 |
| 训练不稳定,曲线抖动大 | 融合层缺 BN 或分布未对齐 | 检查融合层是否 Conv+BN+Act | 补齐 BN,不要省这一层 |
| concat 时维度报错 | 下采样只做在了主体分支 | 打印两路输出的 shape | 下采样移到 stage 入口共享 |
| 同深度对比精度反而下降 | 对比口径不公平 | 核对参数量与 FLOPs | 改为同 FLOPs 或同参数对比 |
| 精度提升在 0.3 个点以内 | 训练噪声覆盖了真实差异 | 换随机种子重跑两次 | 多次取平均后再下结论 |
| FLOPs 降了但端侧延迟没降 | concat 带来额外内存访问 | 在目标设备实测延迟 | 端侧部署时以实测延迟为准 |
我个人的体会是,这张表里前三行占了复现失败原因的一大半。结构本身其实很简单,出问题几乎都出在通道数、学习率和归一化这三个地方。把这三个守住,CSP 的复现基本不会翻车。
再补一个我后来才想明白的点:CSP 这类"组织层面"的改动,和改变卷积核、改变激活函数这类"算子层面"的改动,收益是叠加的,但叠加效果不是简单的相加。我试过在已经用了 CSP 的骨干网上再加注意力模块,收益明显比在基线骨干网上加要小。合理的解释是,两者都在改善梯度流动和信息复用,边际效用递减。所以如果你已经在用 CSP 结构,下一步的优化方向可能不是继续堆结构改动,而是回到数据和训练策略上找空间。