1. 先搞清楚它解决什么问题
深度学习圈子这几年每隔一阵子就会冒出一个刷屏的概念,深度可分离卷积(Depthwise Separable Convolution)绝对算得上是常青树之一。从 MobileNet 到 Xception,从边缘设备上的实时推理到 Transformer 里的 Patch Embedding 变体,到处都有它的影子。很多人第一次看到这个名字会觉得很高深,其实拆开来看就两个词:depthwise 和 separable,核心就一句话——把标准卷积拆成两步来做,从而大幅降低参数量和计算量。
我最早接触这个东西是在做移动端图像分类模型压缩的时候。当时要把一个 ResNet 50 大小的模型塞进手机里跑实时推理,直接量化蒸馏折腾了半天,效果始终不理想。后来换了 MobileNet 的 backbone,用深度可分离卷积替换掉标准卷积的堆叠,模型小了将近十倍,速度上去了,精度损失也完全在可接受范围内。那一刻我才真正意识到,这种结构上的改进比事后压缩要优雅得多。
这篇东西适合谁看?如果你是刚入门深度学习的同学,可以用它建立起对卷积操作本质的理解;如果你已经在用 MobileNet、EfficientNet 这类模型做项目,那这篇文章能帮你搞清楚这些网络到底为什么快、为什么小,以及真正使用时有哪些坑。我会从原理、计算量推导、手写实现、实际应用和踩坑记录五个维度完整聊一遍,尽量做到不废话、可落地。
2. 深度可分离卷积的核心思路:把标准卷积“拆开”
2.1 标准卷积到底在做什么
要理解深度可分离卷积,第一步是先重新审视一下标准卷积的空间维度。假设输入特征图的尺寸是 H×W×C,也就是高 H、宽 W、通道数 C,我们想输出一个 H′×W′×N 的特征图,标准卷积层会用 N 个尺寸为 k×k×C 的卷积核去滑动计算。
这里有个很容易被忽略的细节:标准卷积的每个卷积核是同时处理“空间维度”和“通道维度”的。卷积核在空间上扫过 k×k 的邻域,在通道上覆盖全部 C 个输入通道,然后把 k×k×C 个数加权求和得到一个输出值。也就是说,一次卷积操作同时完成了两件事:提取局部空间特征和融合跨通道信息。
打个比方,标准卷积好比一个全能型员工,既要做区域调研,又要做跨部门的信息汇总,所有事情一次性干完。效率听起来很高,但代价是参数和计算量都很大。对于一个 k×k 卷积核,单个输出通道需要 k×k×C 个参数,N 个输出通道就是 k×k×C×N。当 C 和 N 都是 256、512 这种量级时,这个数字是非常夸张的。
2.2 第一步:深度卷积(Depthwise Convolution)
深度可分离卷积的第一步叫做深度卷积,它的思路很极端:每个输入通道单独配一个 k×k 的卷积核,只在空间上做卷积,完全不做通道间的信息交互。也就是说,输入是 H×W×C,我们准备 C 个卷积核,每个卷积核负责一个通道,输出的尺寸还是 H×W×C,只是空间信息被重新编码了一遍。
继续用刚才的比喻,深度卷积相当于派 C 个专员分头去调研 C 个区域,各管各的,互不沟通。这样做的直接好处是参数少得可怜,只有 k×k×C 个参数,相比标准卷积少了 N 这个倍数。
但问题也很明显:各通道之间完全没有信息流动。如果就这样堆叠多层,每个通道始终只看到自己的信息,特征表达能力会大打折扣。这也是深度卷积极少单独使用的原因,它必须搭配第二步来补上通道间的交互。
2.3 第二步:逐点卷积(Pointwise Convolution)
第二步叫做逐点卷积,本质上就是一个 1×1 的标准卷积。它把深度卷积的输出作为输入,用 N 个 1×1×C 的卷积核在通道维度上做线性组合,输出尺寸变为 H×W×N。
逐点卷积的角色就是“跨通道信息融合专员”。深度卷积负责在空间维度上提取特征,逐点卷积负责把各个通道的特征按权重组合起来,两者各司其职,合在一起正好完成了标准卷积的全部功能。
这里有个非常关键的认知:标准卷积把“空间特征提取”和“通道特征融合”捆绑在一次操作里完成,深度可分离卷积则把这两件事彻底解耦,分两步来做。这种解耦带来的直接收益,就是参数量和计算量的数量级下降。
3. 用数字说话:参数量和计算量到底省了多少
3.1 复杂度的标准公式
先明确一下符号。设输入特征图尺寸为 H×W×C,输出通道数为 N,卷积核大小为 k×k,输出特征图空间尺寸为 H′×W′。忽略偏置的情况下:
标准卷积的参数量:
P_std = k × k × C × N
标准卷积的计算量(乘法次数):
F_std = H′ × W′ × k × k × C × N
深度可分离卷积的参数量由两部分组成。深度卷积部分每个输入通道一个 k×k 卷积核,参数量是 k×k×C;逐点卷积部分输出 N 个 1×1×C 卷积核,参数量是 1×1×C×N = C×N。合计:
P_dsc = k×k×C + C×N
计算量同样分两部分。深度卷积部分每个通道在 H′×W′ 的空间上做 k×k 卷积,共 C 个通道,即 H′×W′×k×k×C;逐点卷积部分在 H′×W′×C 的特征图上做 1×1 卷积,输出 N 个通道,即 H′×W′×C×N。合计:
F_dsc = H′×W′×k×k×C + H′×W′×C×N
3.2 压缩比的推导
拿深度可分离卷积和标准卷积做比值,计算量压缩比:
F_dsc / F_std = (H′×W′×k×k×C + H′×W′×C×N) / (H′×W′×k×k×C×N) = 1/N + 1/k²
这个公式是理解深度可分离卷积价值的核心。当输出通道数 N 比较大时,1/N 这一项可以忽略,压缩比约等于 1/k²。用 3×3 卷积核时,计算量大约降到标准卷积的九分之一;用 5×5 就是二十五分之一。参数量压缩比也类似:
P_dsc / P_std = (k×k×C + C×N) / (k×k×C×N) = 1/N + 1/k²
3.3 一个具体的实例计算
光看公式不够直观,我拿一个实际问题来算一遍。假设输入特征图是 112×112×64,我们希望输出 128 个通道,使用 3×3 卷积核,输出空间尺寸保持不变。
标准卷积:
- 参数量:3×3×64×128 = 73728
- 计算量:112×112×3×3×64×128 ≈ 9.25 亿
深度可分离卷积:
- 深度卷积参数:3×3×64 = 576
- 逐点卷积参数:1×1×64×128 = 8192
- 总参数量:8768
- 深度卷积计算量:112×112×3×3×64 ≈ 723 万
- 逐点卷积计算量:112×112×1×1×64×128 ≈ 1.03 亿
- 总计算量:约 1.10 亿
算下来参数量大约是标准卷积的 11.9%,计算量大约是 11.9%。跟公式预测的 1/64 + 1/9 ≈ 0.126 基本吻合。
| 对比项 | 标准卷积 3×3 | 深度可分离卷积 3×3 | 比例 |
|---|---|---|---|
| 输入尺寸 | 112×112×64 | 112×112×64 | — |
| 输出通道 | 128 | 128 | — |
| 参数量 | 73728 | 8768 | 约 11.9% |
| 计算量 | 约 9.25 亿 | 约 1.10 亿 | 约 11.9% |
注意:这里的压缩比是在理想情况下算出来的。实际网络中还有 BN、激活函数、shortcut 等其他结构的开销,所以端到端的模型压缩幅度不会正好是九分之一,但数量级的优势是实打实的。
4. 手写实现:从公式到代码一次跑通
4.1 PyTorch 实现
PyTorch 里实现深度可分离卷积非常直接,用 torch.nn.Conv2d 配合 groups 参数就可以完成。先建一个标准模块:
import torch import torch.nn as nn class DepthwiseSeparableConv(nn.Module): def __init__(self, in_channels, out_channels, kernel_size=3, stride=1, padding=1): super().__init__() # 第一步:深度卷积,每个输入通道独立卷积 self.depthwise = nn.Conv2d( in_channels, in_channels, kernel_size=kernel_size, stride=stride, padding=padding, groups=in_channels ) # 第二步:逐点卷积,融合通道信息 self.pointwise = nn.Conv2d( in_channels, out_channels, kernel_size=1, stride=1, padding=0 ) def forward(self, x): x = self.depthwise(x) x = self.pointwise(x) return x这里最关键的是groups=in_channels这个参数,它让每个输入通道单独对应一个卷积核,实现了 depthwise 的效果。如果不设置 groups,默认是 1,那这个模块就退化成普通卷积了。
实际使用的时候,我建议把 BN 和激活函数加进去,而不是单独在外面套,这样模块的可复用性更高:
class DepthwiseSeparableConvBlock(nn.Module): def __init__(self, in_channels, out_channels, kernel_size=3, stride=1, padding=1): super().__init__() self.depthwise = nn.Conv2d( in_channels, in_channels, kernel_size=kernel_size, stride=stride, padding=padding, groups=in_channels ) self.pointwise = nn.Conv2d( in_channels, out_channels, kernel_size=1, stride=1, padding=0 ) self.bn1 = nn.BatchNorm2d(in_channels) self.bn2 = nn.BatchNorm2d(out_channels) self.act = nn.ReLU(inplace=True) def forward(self, x): x = self.act(self.bn1(self.depthwise(x))) x = self.act(self.bn2(self.pointwise(x))) return x4.2 TensorFlow / Keras 实现
Keras 里的实现思路相同,同样利用 DepthwiseConv2D 显式声明深度卷积,再用 Conv2D 1×1 做逐点卷积:
import tensorflow as tf from tensorflow.keras import layers, models def depthwise_separable_conv_block(inputs, out_channels, kernel_size=3, stride=1, padding='same'): x = layers.DepthwiseConv2D( kernel_size=kernel_size, strides=stride, padding=padding, depth_multiplier=1 )(inputs) x = layers.BatchNormalization()(x) x = layers.ReLU()(x) x = layers.Conv2D(out_channels, kernel_size=1, strides=1, padding='valid')(x) x = layers.BatchNormalization()(x) x = layers.ReLU()(x) return x注意depth_multiplier=1这个参数。它表示每个输入通道生成几个输出通道,如果设为 2,参数量会相应翻倍,但一般轻量网络里都保持 1。
4.3 实现过程中的几个关键细节
第一点,深度卷积的 padding 和 stride 要和标准卷积保持一致,否则输出特征图尺寸会变化,导致后续逐点卷积的输入维度对不上。
第二点,MobileNet 原论文里有一个容易被忽略的细节:第一个标准卷积层和最后的全连接层不参与深度可分离卷积替换,因为输入通道数太少(RGB 三通道),替换的意义不大,直接用一个普通 3×3 卷积反而更高效。
第三点,如果你的网络要部署到移动端,务必确认推理框架对 groups 卷积做了优化。很多老版本框架对深度卷积的底层实现还停留在通用分组卷积的逻辑上,速度提升没有理论上那么明显,选型时一定要实测。
5. 实际应用:MobileNet 和 Xception 的不同玩法
5.1 MobileNet:为移动端而生的轻量网络
Google 在 2017 年提出 MobileNet V1 时,核心思路就是用深度可分离卷积堆叠整个网络。V1 的网络结构非常规整,除了第一层是标准 3×3 卷积,中间全部由深度可分离卷积块组成。整网 28 层里,绝大多数计算量都集中在 1×1 卷积上,因为逐点卷积的 FLOPs 占了大约 74%,而深度卷积只占很小的比例。
MobileNet V1 还有一个额外的超参数叫 width multiplier(宽度乘子),通过对每层的通道数统一乘以一个系数来进一步控制模型大小。配合深度可分离卷积,你可以把基准模型从 100% 一路缩到 25% 甚至更小,这就给不同算力级别的设备提供了灵活的选择。实测下来,在 ImageNet 上 MobileNet V1 的 top-1 精度只比 VGG16 低了不到一个百分点,但参数量和计算量少了两到三个数量级,这在当年的移动端场景完全是颠覆性的。
5.2 Xception:把解耦做到极致
Xception 的出发点不同。它不是奔着轻量去的,而是想验证一个假设:跨通道相关性和空间相关性是否可以完全解耦。Xception 的做法是把深度可分离卷积里的逐点卷积放在深度卷积之前,并且在每个深度卷积后面都接 BN 和 ReLU,结构上更接近 Inception 模块的极端版本。
Xception 的实验结果很有意思:在 ImageNet 上和 Inception V3 对比,参数量差不多的情况下精度更高,训练收敛也更快。这说明通道间和空间间的信息解耦不仅省资源,在一定程度上还能提升特征表达效率。不过要注意,Xception 的参数量和 MobileNet 完全不是一个量级,它属于高质量高开销的路线,不适合直接搬到移动端。
5.3 后续演进和其他应用
深度可分离卷积的影响远不止 MobileNet 和 Xception。EfficientNet 的 MBConv 模块在深度可分离卷积的基础上加入了 SE(Squeeze-and-Excitation)注意力机制,在倒残差结构里先升维再做深度卷积再降维,效果进一步提升。MobileNet V2 则引入了 Linear Bottleneck 和 Inverted Residual,专门解决深度卷积在低维空间表达能力弱的问题。
还有一个容易被忽视的应用场景是图像分割和目标检测的轻量化主干。像 DeepLabV3+ 的 encoder 部分大量使用了深度可分离卷积,因为高分辨率输入本身计算量就大,只有用这种结构才能在保证精度的前提下跑得动。
6. 踩坑记录:真实使用中的常见问题与排查技巧
6.1 深度卷积之后到底要不要跟激活函数
这个问题我前后纠结过很久,也反复查证过。MobileNet V1 的深度卷积后面是跟 ReLU 的,但 MobileNet V2 的作者发现,在低维空间里 ReLU 会把很多信息映射到零,造成信息丢失,所以在逐点卷积降维之后不接激活函数,直接进残差连接。这就是 Linear Bottleneck 的由来。
实操中的建议是:如果你在网络的高维特征阶段使用深度可分离卷积,接 ReLU 没问题;但如果通道数已经压得很低,比如 32 甚至 16,激活函数可能会成为瓶颈,需要靠实验对比来定。
6.2 深度卷积没有 BN 会怎样
深度卷积的每个卷积核只处理一个通道,输出通道的分布高度依赖输入通道。如果不加 BN,训练很容易出现梯度不稳定,尤其是网络比较深的时候,收敛速度会变得很慢。我最初在自定义数据集上训练轻量分类模型时,为了图省事跳过了 depthwise 后面的 BN,结果 loss 振荡得很厉害,加上之后就稳定了。所以深度卷积和逐点卷积之间、以及逐点卷积之后,都要保留 BN,这个位置很关键。
6.3 部署到移动端时的真实速度问题
纸面上的 FLOPs 降低不等于实际的推理提速。我踩过一个很典型的坑:模型在 GPU 上测试,比同等精度的标准卷积模型快了很多,但交叉编译到 ARM CPU 上之后,速度优势没有想象中明显。原因是很多推理库对深度卷积的底层实现没有单独优化,而是走了通用的 im2col 或分组卷积路径,导致内存访问不连续,缓存命中率下降。
解决思路有几个方向。第一,优先选择对深度卷积有专门优化算子的推理框架,比如较新版本的 TFLite、NCNN、MNN;第二,把卷积核限定为 3×3,因为多数框架对 3×3 深度卷积做了汇编级优化;第三,如果条件允许,考虑通道剪枝或者量化,进一步减少逐点卷积的开销,因为逐点卷积才是真正的耗时大头。
| 问题现象 | 可能原因 | 排查思路 |
|---|---|---|
| 模型训练 loss 不收敛 | 深度卷积后缺少 BN | 检查每个 DW 和 PW 层后是否都加了 BN |
| 精度比预期低 | 低维通道处使用 ReLU 导致信息丢失 | 改用 Linear Bottleneck 或调整通道数 |
| GPU 快但手机端慢 | 推理库对深度卷积优化不足 | 换用支持 DW 专用算子的框架,或将卷积核统一为 3×3 |
| 输出尺寸对不上 | padding 或 stride 设置不一致 | 在模型里打印每层 tensor shape 逐步核对 |
6.4 参数初始化的小技巧
深度卷积的每个通道只有 k×k 个参数,数据量少,初始化不当很容易导致某些通道激活值一直很小,变成“死通道”。我的习惯是给深度卷积的权重设置稍大一点的标准差,或者用 He 初始化后手动乘以一个大于 1 的缩放系数,让每个通道在初始阶段有足够的激活幅度。这个操作在论文里很少提,但实测对训练初期的稳定性帮助很大。
7. 最后说点个人经验
深度可分离卷积这个东西,表面上看是一个结构技巧,本质上反映的是一种建模思路:把复杂操作拆解成语义单一、可独立优化的子操作。空间特征提取和通道特征融合,本来就是两个维度的事,硬绑在一起反而增加了不必要的冗余。后续的很多工作,比如 Ghost Module、FasterNet 里的 Partial Convolution,都是在沿着这条思路做更极致的探索。
我在实际项目里最常说的一句话是:不要只看 FLOPs,一定要跑真机。结构优化带来的理论收益和工程收益之间,隔着一个算子实现的鸿沟。选型的时候,先确认目标推理框架对深度卷积的支持程度,再决定主线方案。同时,训练策略也要跟着结构走,学习率、weight decay、BN 的 momentum 这些超参,在轻量网络上的敏感度往往比大网络更高,需要多一些耐心去调。
如果你正准备在项目里引入深度可分离卷积,我的建议是先把 MobileNet V2 的完整代码读一遍,尤其是倒残差结构里的维度变化和 stride 处理,再动手改自己的网络。前期多花点时间把基础打牢,后面工程化的时候会省掉很多不必要的折腾。