《GhostNet》这篇论文,值得每个做端侧模型的人细读
这个系列写到第四篇了,这次想聊聊GhostNet。第一次看到这个标题"More Features from Cheap Operations"时,我其实有点不以为然:又是类似MobileNet那种把标准卷积拆成两步的轻量化结构吧。但等我真正把论文和开源代码捋完,才发现它切入的角度跟MobileNet系列完全不同——MobileNet们从算子改造入手,而GhostNet抓住的其实是特征图冗余这个再明显不过却很少有人直接动刀的现象。五年过去,这个思路在端侧部署、模型压缩、NAS搜索里反而越来越常用。这篇笔记我从原理讲到复现,尽量把当时读的时候认为值得注意的点都交代清楚。
1. 为什么翻到这篇老论文:轻量化模型的演进到了另一个十字路口
1.1 在MobileNet和NAS夹缝中的GhostNet
先把时间线摆出来。GhostNet发表在CVPR 2020,那会儿大家的注意力都集中在这几个方向上:MobileNetV3把NAS和手工设计结合,在移动端成了事实标准;EfficientNet用compound scaling把FLOPs和精度的关系捋得很明白;各路NAS搜出来的网络也一个比一个卷精度。新模型都在谈"我比MobileNetV3掉点更少"或者"同精度下我FLOPs最低"。
GhostNet的出身和它们都不太一样,它来自华为诺亚方舟实验室,更像纯粹研究驱动的一个产物。整篇论文的核心动机不是搜一个更好的结构,而是先把问题说清楚:神经网络的特征图里,其实有大量冗余。你随便拿个训练好的CNN,把某层特征图可视化出来,会发现好多通道长得特别像——轮廓一样、纹理一样,只是对比度或相位稍微有点差别。以前大家处理这种冗余的方式是剪枝、低秩分解、知识蒸馏,这些方法都是在模型训完之后或者训练中做补救,而GhostNet直接把这个冗余建模进了网络结构。
这个思路在当时确实不算主流。你翻MobileNet系列的论文,作者讨论的是depthwise卷积有多省算力、SE注意力放哪个位置最合适、swish激活比relu好多少。GhostNet则问了一个更底层的问题:既然输出特征图有一部分是"重复劳动"算出来的,那些冗余特征有没有可能用更cheap的方式直接生成?
1.2 这个点子在今天反而更值得读
2024年再看GhostNet,轻量化模型这条赛道已经很挤了。MobileNetV4、EfficientFormer、RepViT、FasterNet这些名字轮番登场,很多结构看起来跟2019年的GhostNet毫无关系。但如果你把"降低冗余计算"这条主线拎出来,GhostNet的建模方式几乎成了很多后续工作的默认底座。比如后来的FasterNet讨论partial convolution为什么有效,本质就是在说一部分特征通道不需要复杂的空间卷积;再比如很多reparameterization方法在重参数完之后会得到类似Ghost分支的结构。这些都在反复验证一个观察:CNN在卷积过程中生成了大量可预测、可复用的特征。
我自己是把GhostNet当作"轻量化结构教科书"来读的。它的篇幅很短,核心方法讲得极其干净,没有为了SOTA堆太多trick,非常适合作为结构设计入门到进阶的一个跳板。而且它的实现复杂度很低,不管是用PyTorch手写还是用TensorFlow搭,基本一两天就能跑通并验证想法。
2. Ghost Module的数学拆解:所谓"廉价操作"到底廉价在哪
2.1 从普通卷积的参数量算起
先把概念定义清楚。给定输入特征图 $X \in R^{c \times h \times w}$,一个普通卷积层生成 $n$ 个输出特征图,公式是:
$$Y = X \cdot f + b$$
其中 $f \in R^{c \times k \times k \times n}$ 是这层的卷积核,$k$ 是卷积核大小,$Y \in R^{n \times h' \times w'}$ 是输出。这个过程的浮点运算量大概在 $n \cdot h' \cdot w' \cdot c \cdot k \cdot k$ 这个量级。
GhostNet的观察是:$Y$ 里这 $n$ 个通道并不是都"独立"的。如果用聚类或者直接可视化去看,大约有相当一部分通道和另一部分通道之间存在线性相关性。那么在理想情况下,你其实需要生成的"独立信息"没有 $n$ 个那么多,假设只有 $m$ 个,剩下的 $n-m$ 个就能通过这 $m$ 个做某种变换得到。于是作者把输出分为两层:
$$Y' = X \cdot f'$$
其中 $f' \in R^{c \times k \times k \times m}$,$m \le n$,生成的是"内在特征"(intrinsic feature maps),也就是那些信息量较高的核心特征。然后,对于 $Y'$ 中的每个特征图 $y'_i$,再用一个廉价变换生成若干个ghost特征:
$$y_{ij} = \Phi_{ij}(y'_i), \quad i=1,...,m,; j=1,...,s$$
这里的 $\Phi_{ij}$ 就是所谓cheap operation,论文里最常用的是depthwise卷积。当然,每个内在特征还会留一个直接输出的分支,相当于恒等变换,对应 $j=s$ 那个位置。
整个模块的最终输出拼接起来仍然是 $n$ 个通道,这样就保证了Ghost Module可以像普通卷积一样插入任何网络位置,不改变后续层的输入适配。
2.2 参数量的压缩比到底怎么算
这是Ghost Module最核心的一个数字对比。假设普通卷积核尺寸是 $k \times k$,Ghost Module里内在特征用的也是 $k \times k$ 卷积,但输出只有 $m = n/s$ 个通道(论文里经常取 $s=2$),然后再用 $d \times d$ 的depthwise卷积做ghost特征生成(论文常用 $d=3$ 或 $d=5$)。
普通卷积的参数量:
$$n \cdot c \cdot k \cdot k$$
Ghost Module的参数量:
$$\frac{n}{s} \cdot c \cdot k \cdot k + (s-1) \cdot \frac{n}{s} \cdot d \cdot d$$
把ratio写出来,约等于:
$$\frac{\frac{n}{s} \cdot c \cdot k \cdot k + (s-1) \cdot \frac{n}{s} \cdot d \cdot d}{n \cdot c \cdot k \cdot k} = \frac{1}{s} + \frac{s-1}{s} \cdot \frac{d \cdot d}{c \cdot k \cdot k}$$
拿一个典型例子来算。假设输入通道 $c=64$,输出通道 $n=128$,$k=3$,$d=3$,$s=2$,那么参数量压缩比是:
$$\frac{1}{2} + \frac{1}{2} \cdot \frac{9}{64 \times 9} \approx 0.5078$$
也就是大约省了一半参数量。计算量也因为输出通道数少了一半、以及后续ghost变换是depthwise卷积,会压得更狠——直接按FLOPs算大约能压到接近 $1/s$ 的水平。
这里有个很容易忽略的细节:$d$ 的取值和 $c$(上一层的通道数)会影响压缩率。如果上一层通道数很小,比如stem后第一层 $c=16$,那 $d=3$ 的depthwise卷积相对于 $3\times3$ 普通卷积的节省就没那么明显。这也是为什么论文里网络前几层一般不直接用Ghost Module,或者会把 $s$ 调小。
2.3 为什么偏偏选depthwise卷积作为"廉价变换"
文章里作者做过一个对比实验,试了三种生成ghost特征的方式:一种是直接用普通的 $3 \times 3$ 卷积,一种是类似跨通道的仿射变换,还有一种就是depthwise卷积。结果depthwise卷积在精度和计算量的平衡上最好。
我自己理解这套选择的逻辑有三个层面。第一,depthwise卷积的计算量天然就是普通卷积的 $1/c$ 量级,它的FLOPs是 $d \cdot d \cdot h' \cdot w' \cdot n$,没有输入通道数这一项,确实足够"cheap"。第二,depthwise卷积保持空间结构,生成的特征在视觉语义上更接近"同一物体的不同底层响应",也就是ghost特征和内在特征仍然共享空间位置;如果换个通道级的线性变换,那生成的特征就变成通道间的线性组合,可表达性不够丰富。第三,实现层面太友好了,几乎每个推理框架都有成熟的depthwise卷积算子,不会像一些刁钻的结构那样需要自定义算子才能跑。
其实我更愿意把这理解成一种"显式的特征复用"。做神经网络的都知道,$3 \times 3$ 卷积单个输出通道对应一个二维滤波器,它只感受局部区域。当它要负责提取一个空间位置上的边缘、纹理、朝向等多种语义时,需要好多滤波器冗余地去覆盖不同组合。Ghost Module把这种组合拆了一步:先用少量滤波器把"基"信息提出来,再用depthwise卷积做局部变换把这些"基"的衍生响应补齐。这有点像图像压缩里的亮度分量和色度分量——前者保留主要结构,后者只需要花很少的码字来编码差值。
3. 从Ghost Module到Ghost Bottleneck再到完整网络
3.1 Ghost Bottleneck内部到底长什么样
只有一个Module还不够,要真正堆出一个网络,作者仿照MobileNetV2/V3的倒残差结构设计了一个叫Ghost Bottleneck的基本单元。
这个Bottleneck通常包含两个Ghost Module。第一个Ghost Module做通道扩展,把输入通道先扩张成更大的通道数,相当于倒残差结构里的expand部分;第二个Ghost Module把通道压回去,匹配输入通道,方便做残差连接。中间在stride=2的时候,会额外插入一个stride=2的depthwise卷积来做空间下采样,同时在shortcut路径上也加一个下采样模块(一般也是stride=2的depthwise卷积),保证两个分支的特征图尺寸和通道数都对齐。此外,在第二个Ghost Module之后还会接一个SE模块做通道注意力。
如果你把它和MobileNetV3 的bottleneck放一起对比,会发现骨架几乎一模一样,唯一的差别就是把MobileNetV3中的第一个 $1 \times 1$ 扩展卷积和中间的 $3 \times 3$ depthwise卷积,用Ghost Module整体替代了一下。这个替代的意义在于:MobileNetV3的Expand层会把通道放大4倍再压缩,这中间存在大量冗余特征;Ghost Module直接在生成阶段就少算了一部分冗余通道。
GhostNet完整网络的结构也很直接。它按照MobileNetV3的stage配置重新设计了特征图的尺寸和通道数,并把标准block替换成Ghost Bottleneck。stem仍然用一个普通的 $3 \times 3$ 卷积,网络最后接一个 $1 \times 1$ 卷积展开特征,再接GAP和全连接分类层。stem和最后几个关键层没用Ghost Module,这个细节我放在3.3节说。
3.2 一个可运行的PyTorch核心实现
理论上讲再多,不如一个能直接改改跑跑的实现直观。这里给出Ghost Module最核心的PyTorch代码(参照官方开源的思路重新整理),没有加太多面向训练的复杂分支,方便理解。
import torch import torch.nn as nn class GhostModule(nn.Module): def __init__(self, inp, oup, kernel_size=1, ratio=2, dw_size=3, stride=1, relu=True): super(GhostModule, self).__init__() self.oup = oup init_channels = math.ceil(oup / ratio) new_channels = init_channels * (ratio - 1) # 第一步:普通卷积生成内在特征,输出通道数只有 oup/s self.primary_conv = nn.Sequential( nn.Conv2d(inp, init_channels, kernel_size, stride, kernel_size // 2, bias=False), nn.BatchNorm2d(init_channels), nn.ReLU(inplace=True) if relu else nn.Sequential(), ) # 第二步:对内在特征做depthwise卷积,生成ghost特征 self.cheap_operation = nn.Sequential( nn.Conv2d(init_channels, new_channels, dw_size, 1, dw_size // 2, groups=init_channels, bias=False), nn.BatchNorm2d(new_channels), nn.ReLU(inplace=True) if relu else nn.Sequential(), ) def forward(self, x): x1 = self.primary_conv(x) x2 = self.cheap_operation(x1) out = torch.cat([x1, x2], dim=1) return out[:, :self.oup, :, :]代码里有两个细节非常关键。第一个是ratio参数,也就是论文里的 $s$,它决定内在特征占总输出的比例。第二个是最后一个cat之后的裁剪操作——因为ceil的原因,拼接出来的通道数可能比oup略大,必须裁到oup,否则后面维度就对不上了。
Ghost Bottleneck组装起来也很容易,基本就是两个GhostModule加一个残差:
class GhostBottleneck(nn.Module): def __init__(self, inp, hidden_dim, oup, kernel_size=3, stride=1, use_se=True): super(GhostBottleneck, self).__init__() assert stride in [1, 2] self.conv = nn.Sequential( GhostModule(inp, hidden_dim, kernel_size=1, relu=True), # 当stride=2时,中间额外插一个depthwise卷积做下采样 nn.Conv2d(hidden_dim, hidden_dim, kernel_size, stride, kernel_size // 2, groups=hidden_dim, bias=False) if stride == 2 else nn.Sequential(), nn.BatchNorm2d(hidden_dim), GhostModule(hidden_dim, oup, kernel_size=1, relu=False), ) if use_se: self.se = SqueezeExcite(oup) else: self.se = nn.Sequential() if stride == 1 and inp == oup: self.shortcut = nn.Sequential() else: self.shortcut = nn.Sequential( nn.Conv2d(inp, oup, 1, 1, 0, bias=False), nn.BatchNorm2d(oup), nn.Conv2d(oup, oup, kernel_size, stride, kernel_size // 2, groups=oup, bias=False), nn.BatchNorm2d(oup), ) def forward(self, x): residual = self.shortcut(x) x = self.conv(x) x = self.se(x) return x + residual注意GhostModule的第二个模块设了relu=False,这是因为主分支最后输出接加法之前一般不加激活,直接和shortcut融合,这个设计和MobileNetV3保持一致。SqueezeExcite模块可以用一个全局平均池化加两个全连接层实现,不需要额外赘述。
3.3 容易被忽略的结构细节:s怎么设、哪些层不能用Ghost Module
GhostNet论文里有一个容易被快速翻过去的表,就是完整网络配置表。我最初照着配置复现时踩过一个小坑——最后一个stage的通道数不是简单按照宽度系数缩放就行,有些层的s被刻意调小了。
从论文配置来看,stem和末尾展开层用的都是普通卷积,理由是前几层输入通道少、分辨率高,普通卷积和Ghost Module的收益差距还不够大,勉强用反而会损失精度。而中间大量Ghost Bottleneck里的ratio默认是2,但最后几个stage使用了ratio=2、dw_size较大的组合。其实你如果去看MobileNetV3的配置会发现一个类似现象:网络最后几个stage的expand ratio往往会降一点,避免在低分辨率特征图上用过大的扩展比。
作者在消融实验里对比了不同的s和d。结论比较直观:$s=2$、$d=3$ 是综合最优,s再增大(比如 $s=4$)压缩收益已经很小,但精度掉得更多。原因是内在特征图的数量减少以后,单个内在特征图要承担的信息变多,线性变换生成ghost特征的误差也会被放大。这个规律在后面很多工作里也反复出现:冗余压缩有一个临界点,过了这个点,压缩的边际收益会被误差的边际成本吞掉。
4. 从ImageNet到COCO:GhostNet的实验结果值不值得信
4.1 ImageNet上的指标与同精度对比
论文报告的核心指标是ImageNet top-1 accuracy。对于GhostNet-1.0x,输入224x224,参数量大概4.1M到6M这个量级,FLOPs不到150M的情况下,top-1能达到75.7%左右。这个数字放在今天不算惊艳,但关键在于同时期的对比对象。MobileNetV3-Large在同参数量附近的top-1在75.2%左右,FLOPs却高出不少。如果只按分类精度算,GhostNet在当时确实做到了轻量级网络的一个新平衡点。
比起精度数字,更值得看的是论文里做的FLOPs-accuracy曲线对比。在相近FLOPs约束下,GhostNet几乎全程压着MobileNetV3、FBNet、EfficientNet-B0这些结构打。这个结果说明Ghost Module带来的不是某一点上的碰巧收益,而是整个结构设计上有一个稳定的效率优势。
当然,ImageNet结果只能说明"分类能力"和"计算效率"的关系。实际业务中大家更关心的是迁移到检测、分割等下游任务上还能不能保持这个优势。
4.2 COCO检测和时延验证:FLOPs之外的江湖
GhostNet论文在COCO目标检测和Cityscapes语义分割上都做了实验,backbone换成GhostNet之后,在相似FLOPs限制下,检测mAP和分割mIoU都优于MobileNetV3作为backbone的对应结果。这说明Ghost Module学到的特征并不是只在分类任务上自洽,对于需要密集预测的任务同样够用。
另外一个容易被人忽视的部分是作者在真实硬件上测的时延。模型大小和FLOPs毕竟只是理论指标,工程落地看的是真实推理时间。论文里分别测了GPU(V100)和手机ARM CPU(Qualcomm 855)上的GPU时延,GhostNet在ARM CPU上的加速比要明显好于GPU。这个现象其实很合理:GPU对大矩阵乘法的并行利用率更高,而Ghost Module拆出来的depthwise卷积在GPU上并不能达到理论算力利用率;ARM CPU上的算子简单、内存访问密度低,反而更接近理论收益。
这个教训后来我一直记着:看轻量化模型不能只看FLOPs降了多少,要看目标平台的算子实际效率。GhostNet在GPU的加速比大概是 $2\sim 3$ 倍,但在ARM上可能接近 $4\sim 5$ 倍,这就是平台差异。
4.3 消融实验给出的设计启示
论文里最有价值的消融不是精度调参,而是关于"变换方式"的对比。作者比较了直接用普通卷积做ghost变换、用depthwise卷积做变换、以及干脆不做变换(也就是把内在特征直接复制拼接)之间的差别。结果只用复制(identity)也能保持相当不错的精度,加了depthwise变换之后精度进一步提升。这说明两件事:特征图冗余确实存在到"复制一份"都不太影响结果的程度;但同时,空间维度上的局部变换仍然能提供额外的有效信息。
我见过有复现者在Ghost Module的cheap_operation里把depthwise卷积换成普通卷积,FLOPs涨了一大截,精度只高了一点点。这说明在ghost特征生成这个环节,信息瓶颈并不在通道间的交互,而在空间位置上的局部变换。用depthwise卷积是恰到好处的复杂度选择,也算是对"cheap operation"命名的一个印证。
5. 复现与部署中的亲身踩坑记录
5.1 复现精度对不上,先检查这三个地方
我最早复现GhostNet跑ImageNet,训练配置完全按论文来,结果top-1比论文低了0.6%。排查一圈,最后定位到三个问题。第一个是EMA(指数移动平均)权重没有加。那个年代很多轻量模型训练都会做EMA,论文的Table里用的基本是EMA后的权重,你直接拿最后的checkpoint测自然差一点。第二个是最后一个stage的s和通道数配比被我按照理想缩放把所有层都设成s=2,实际上有部分层设的是s=3或别的方式,配置表没看仔细导致网络表达能力跟论文不一致。第三个是数据增强的细节,轻量模型对强增强普遍敏感,GhostNet也一样,它的最佳配置并不是把mixup、cutmix都开满。
如果在自己的数据集上做finetune,我建议把EMA去掉直接训练,或者至少保留一份未EMA的checkpoint,因为很多下游任务微调的时候,EMA模型和正常模型的更新频率不一致,反而不容易微调。
5.2 FLOPs砍了,延迟却砍不动:平台算子的现实问题
GhostNet真正到了部署阶段,最大的坑不是精度,是推理框架的算子支持。Ghost Module里那个cat操作在训练里很自然,但在TensorRT或OpenVINO里,如果框架不能把primary_conv的输出和cheap_operation的输出无损融合,可能会引入额外的内存拷贝。而且depthwise卷积在GPU上的实际利用率常年不高,导致你跑出来的延迟降低幅度远小于FLOPs降低幅度。
我自己在部署一个基于GhostNet的检测模型时,遇到过TensorRT对group=1的depthwise卷积支持不友好、导致被替换成普通卷积的情况,那一瞬间模型大小直接涨了将近一倍。解决办法是把depthwise卷积的group设到输入通道数(正确的做法),并在转ONNX之后仔细检查算子的mapping;如果框架不支持小通道数上的depthwise,可以考虑在channle数较大的层才使用Ghost Module,浅层仍然用普通卷积。
此外如果是量化部署,Ghost Module里两个分支的量化参数差异容易导致精度掉点。因为primary_conv输出和cheap_operation输出在数值分布上差异较大,量化时建议对两个分支分别统计scale和zero_point,而不要统一校准。这个细节在PTQ(训练后量化)里尤其关键,我踩过一次之后基本都会在代码里写死per-channel量化。
5.3 用GhostNet替换ResNet做业务模型的一点体会
我实际在一个OCR检测模型里尝试过把backbone从ResNet18换到GhostNet。同样的训练数据、同样的检测头,FLOPs从将近3G降到不到1G,模型大小也从40多MB降到12MB左右,推理速度在GPU上提升了约一倍,但在CPU上能提升近三倍。不过,精度并不是直接持平,而是低了0.5到1个点左右。这说明ResNet作为通用特征提取器,在密集任务上的冗余度比想象中高,但也确实有一部分表达能力不是白给的。
后期我用知识蒸馏把ResNet18的logits蒸馏到GhostNet上,精度的缺口基本补回来了。所以如果你打算在业务里替换backbone,强烈建议配套一个蒸馏方案,而不是期待GhostNet直接顶上还能不掉点。毕竟模型压缩的本质就是信息浓缩,如果没有额外的监督信号,凭空浓缩总是会损失一点。
6. 抛开指标,GhostNet给后续工作留下了什么
6.1 从"更cheap的操作"到"冗余结构设计"
GhostNet最有价值的一点,是它把"特征图冗余"从一个经验观察变成了一个可以直接设计网络的显式机制。后来的GhostNetV2花了很大力气去解长距离依赖问题,但底座仍然是Ghost Module。再往后看,很多针对低功耗场景设计的分支结构,都带着GhostNet的影子:先用一个轻量分支处理核心特征,再用另一个更cheap的分支做补充。
这也引申出一个我在读论文时反复思考的问题:特征图通道之间的低秩性、相似性到底从哪来?一个直观的解释是自然图像本身就存在大量的自相似结构,纹理基元频繁重复出现;CNN在浅层学习到的是这些基元的不同组合,到了深层就变成语义概念的组合。只要输入的分布还贴着自然图像,这种冗余就会一直在。所以GhostNet不是针对某一类任务特调的,它是对整个分布做了一个通用假设。
6.2 读完GhostNet之后,我实际沿用至今的设计习惯
第一,每次设计一个新网络,我都会先拿训练好的模型把每层的特征图通道做一次可视化或聚类,看看冗余度大概在什么水平,然后再决定要不要上类似Ghost的压缩模块。这个习惯来自一篇论文不太直观、但实践价值很高。第二,在写训练配置时,尽量把s和d做成可配置的,方便对不同stage设置不同压缩比,而不是在代码里写死。第三,所有轻量化结构替换都必须在真实目标平台上做profile,而不是只拿FLOPs说事。
GhostNet给我最大的启发是,好结构的出发点往往不是堆叠更复杂的模块,而是认真审视现有网络在哪里浪费了算力。这种"先找冗余,再设计压缩"的思路,比直接套用现成的轻量模块要可靠得多。这些年端侧模型的需求一直在变,但这种从问题出发的方法论,基本上没有过时。