第一次用空洞卷积(Dilated Convolution)是在一个像素级分割的小项目上,当时要做一个表面划痕检测的Demo,模型已经堆到五十多层,特征图经过多次池化缩小到原来的十六分之一,分割出来的划痕边缘糊成一片,细小的裂纹直接断成一段一段。一开始我以为是数据不够,试过数据增强、换损失函数,效果都不理想。后来把部分下采样层去掉、改用带空洞的卷基层,只是改了一个参数,边界质量和细小结构保留就明显好了。
这篇内容适合做图像分割、目标检测、语音序列建模的朋友,也适合准备面试时系统补一下卷积网络基础的同学。空洞卷积不是新鲜东西,但它背后的数学原理、适用边界,以及那些看起来隐蔽的坑,确实值得认真捋一遍。我会把自己踩过的坑和调参心得一起放进来,尽量做到看完就能用。
1. 从一次分割模型翻车开始:为什么要给卷积核"开洞"
1.1 一个典型困境:感受野与分辨率不可兼得
做语义分割或者密集预测这类任务时,经常会遇到一个让人头疼的矛盾:想要大感受野,就得不断卷积加池化;可池化一多,特征图就变小,细节信息大量丢失。
就拿我那个划痕检测项目来说,最朴素的做法是照搬分类网络的结构:几层卷积加一次池化,重复四五次,最后得到很小的特征图,再接一个上采样层把尺寸恢复回来。分类任务里这种结构没问题,因为分类只需要一个全局语义;但分割任务要求每个像素都要有类别,上采样恢复出来的边缘在像素层面根本回不到原来那个精度。缩小再放大,细节已经永久丢了,这和把一张图片缩小再看大是同一个道理,缩小时丢掉的高频信息不会因为放大而重新出现。
早期做分割有一种思路是"减少池化、增大卷积核"。减少池化确实能保住分辨率,但后面的卷积层感受野就上不去;增大卷积核倒是能拉感受野,5x5、7x7直接上,可参数量和计算量涨得飞快。以3x3卷积为例,参数量是9C²,换成7x7直接变成49C²,差不多5.4倍,换到后面几百个通道的层,训练和推理都吃不消。而且大卷积核在小尺寸特征图上计算时,大量计算其实花在边缘补齐上,性价比并不高。
1.2 "开洞"的本质:用同样的参数看更大的范围
空洞卷积的做法很直接:在标准卷积核的元素之间插入一定数量的空洞,让卷积核以同样的参数量覆盖更大的区域。
以3x3卷积核为例,dilation rate(空洞率,通常记作d)设为1时就是标准卷积,感受野是3x3。d设为2时,卷积核的9个有效采样点被拉开,中间夹着空白,实际覆盖范围变成5x5。d设为4时,覆盖范围达到11x11。但不管d怎么变,真正参与乘法的参数始终只有9个。
这个"用同样的钱买更大的地"的思路,在需要同时满足两个条件——大感受野、高分辨率——的任务里几乎是量身定做的。很多分割网络后面的几层,就是靠空洞卷积把特征图保持在原分辨率的四分之一甚至二分之一,同时还能让每个输出像素感知到足够大的上下文。我在项目里把两次池化去掉、换成d=2的空洞卷积之后,划痕的连续性肉眼可见地改善了,这就是为什么这个机制值得花时间彻底弄明白。
2. 空洞卷积的数学直觉:一个参数改变了什么
2.1 有效卷积核尺寸与输出尺寸怎么算
很多人对空洞卷积只有一个模糊的印象,真正要改代码或者设计网络时却算不清尺寸,这里我把公式直接列出来。
对于卷积核大小k、空洞率d的卷积,有效卷积核尺寸为:
k_eff = k + (k - 1) * (d - 1)等价于:
k_eff = (k - 1) * d + 1举例:
- k=3, d=1:k_eff = 3
- k=3, d=2:k_eff = 5
- k=3, d=4:k_eff = 11
- k=5, d=2:k_eff = 9
输出尺寸跟标准卷积一样,用有效卷积核尺寸代入即可:
out = floor((in + 2 * padding - k_eff) / stride) + 1注意,很多框架(包括我常用的PyTorch)实现时公式写成:
out = floor((in + 2 * padding - dilation * (k - 1) - 1) / stride + 1)两者是一样的,只是表达方式不同。
如果想让 stride=1 的卷积保持输入输出尺寸不变,padding 需要满足:
padding = dilation * (k - 1) / 2对3x3卷积,这个式子可以进一步化简成padding = dilation。也就是说:
- d=1, k=3 时 padding=1,标准配置
- d=2, k=3 时 padding=2
- d=4, k=3 时 padding=4
这个规律特别好记,我后来设计网络时基本不用现场推导,直接写。
2.2 感受野递推公式与一张速查表
堆叠多层卷积时,感受野的计算有个递推公式,对空洞卷积同样适用:
R_l = R_{l-1} + (k_eff - 1) * s_prod其中 s_prod 是之前所有层 stride 的累积乘积(当前层之前)。如果之前所有层 stride 都是1,那 s_prod 恒等于1,公式简化成:
R_l = R_{l-1} + (k_eff - 1)初始感受野 R_0 = 1(假设输入是一个点)。
我用3x3卷积、stride=1、dilation 按 1 -> 2 -> 4 -> 8 指数增长,做了一个递推:
| 层序号 | dilation | k_eff | 累积感受野 |
|---|---|---|---|
| 第1层 | 1 | 3 | 3 |
| 第2层 | 2 | 5 | 7 |
| 第3层 | 4 | 9 | 15 |
| 第4层 | 8 | 17 | 31 |
每层只有9个参数,四层堆叠后参数总量是36C²,感受野却达到了31。作为对比,用标准3x3卷积要达到感受野31,需要15层,参数总量是135C²。接近四倍的参数节省,这就是指数扩张空洞卷积的厉害之处。
如果只想快速比较"两层d=2的空洞卷积"和"四层标准卷积":
- 两层 d=2:感受野 = 1 + (5-1) + (5-1) = 9,参数 = 2 * 9 * C² = 18C²
- 四层 d=1:感受野 = 1 + 4*(3-1) = 9,参数 = 4 * 9 * C² = 36C²
感受野相同,参数少一半。当然,这只是理论比较,实际还要考虑非线性层数量、特征表达能力等因素,但在轻量化设计的场景下,这个账是实打实划算的。
2.3 在代码里落地:其实就是一行参数
主流深度学习框架里,空洞卷积基本就是卷积层的一个参数,我用PyTorch写个示例:
import torch.nn as nn # 标准3x3卷积 conv1 = nn.Conv2d(in_channels=64, out_channels=128, kernel_size=3, stride=1, padding=1, dilation=1) # 空洞率2的3x3卷积,padding=2保持尺寸不变 conv2 = nn.Conv2d(in_channels=64, out_channels=128, kernel_size=3, stride=1, padding=2, dilation=2) # 空洞率4的3x3卷积,padding=4 conv3 = nn.Conv2d(in_channels=64, out_channels=128, kernel_size=3, stride=1, padding=4, dilation=4)如果输入是序列数据,比如语音或者时间序列,可以用一维空洞卷积:
conv1d = nn.Conv1d(in_channels=64, out_channels=128, kernel_size=3, stride=1, padding=2, dilation=2)核心就两个:dilation 参数本身,以及随之改变的 padding 值。大多数框架里,空洞卷积的标准用法就是这么简单,难点不在写代码,而在知道该把dilation设成多少、放在哪一层。
3. 真正适合的场景和不该硬上的地方
3.1 密集预测任务:分割和边缘检测的主场
空洞卷积最典型的应用场景就是语义分割、边缘检测、深度估计这类逐像素输出的任务。这类任务共同的特点是:输入和输出尺寸接近,希望网络的中间特征图尽量保持分辨率,同时又不希望感受野太小。
我之前做过一个边缘检测实验,对比了两种结构:一种用5个stride=2的下采样把分辨率一路压到1/32,最后输出边缘时再上采样回原尺寸;另一种只下采样到1/4,然后用三层dilation=2的空洞卷积把感受野拉回来。结果很明确,第二种方案生成的边缘连续性明显更好,细线条被人为切碎的概率低很多。原因不复杂——高分辨率特征图保留了更多细节,空洞卷积补足了感受野,两者各司其职。
目标检测里的特征金字塔也经常用到空洞卷积,在主干网络的后几个stage保持一定的分辨率,同时扩大每个输出格子的视野,让模型能感知到更大范围的上下文。这类任务不需要逐像素输出,但需要高层特征有足够大的感受野来覆盖不同尺寸的目标,空洞卷积是个很好用的工具。
3.2 序列建模里的指数扩张套路
空洞卷积在序列建模里有个非常经典的设计:dilation 按 1 -> 2 -> 4 -> 8 指数增长。这样做的好处很直观,每一层感受野翻倍,少量层数就能覆盖极长的上下文。
比如一个语音前端模型,输入采样率16kHz,假设每层处理的帧长是10ms,四层指数扩张后,输出点能看到的上下文可以从几十毫秒拉到几百毫秒,这对建模连续发音、停顿、语速变化都有帮助。类似的结构在音频生成、时间序列预测里反复出现。
序列建模和图像分割用空洞卷积时有个方向上的区别:图像任务往往保持分辨率不变,每个位置都输出;序列任务则常配合因果卷积(只看到当前时刻之前的输入),避免未来信息泄漏。如果你做的是因果空洞卷积,还需要保证“膨胀后的卷积核”不会看到未来位置,通常的做法是把输入做适当的padding,然后严格控制卷积核的偏移方向。
3.3 不适合硬上的地方
空洞卷积不是万能的,我至少遇到过两个明显不适合的场景,这里提前排雷。
第一个是极小的目标检测或分割任务。比如目标只有12x12像素,你在高层用d=4的3x3空洞卷积,等效感受野11x11,采样的9个点几乎覆盖了整个目标区域,但目标边界外的背景信息也混进来了。更麻烦的是,dilation过大时,卷积核的采样点间隔大于目标尺寸,可能出现“采样点全部落在目标边缘或目标外部”的情况,导致目标信息被稀释。我自己测试过,对10x10以下的细小目标,dilation超过2之后,检测召回率不升反降。
第二个是点云或稀疏数据任务。点云本身就有大量空白位置,典型的卷积核在稀疏区域已经只能采到很少的有效点,再叠加空洞,采样点可能大部分落在空区域,信息利用率极低。这类任务不如老老实实用稀疏卷积或者普通卷积,别为了“大感受野”而强行上空洞。
另外还要注意,空洞卷积并不是无代价的。虽然它的参数和有效计算量跟标准卷积一样,但一旦你用它替换了下采样,特征图分辨率就保住了,后续所有卷积层的计算量都会增大。一个stride=2的层被改成stride=1后,下一层输入的空间大小翻倍,计算量至少翻倍。所以“用空洞卷积保持分辨率”的代价不是花在空洞卷积本身,而是花在它后面的所有层。设计网络时要整体评估,不能只看单层的账。
4. 网格伪影:空洞卷积最容易踩的坑
4.1 感受野覆盖不均匀是怎么产生的
连续堆叠相同空洞率的空洞卷积,会产生一个很隐蔽的问题:感受野覆盖不均匀,特征图出现网格状的周期响应,俗称网格伪影(grating artifact)。
我用一维情况来解释,大家理解起来最直观。假设输入是长度为N的一维信号,用3x3卷积、d=2(等效核大小5)。某个输出位置t,它采样的输入位置是:
t, t+2, t+4再叠一层同样d=2的3x3卷积。第二层某个输出位置t,先采样第一层输出的位置:
t, t+2, t+4而第一层输出的每个位置又各自采样输入的位置,合并等效后,第二层输出看到的所有输入位置是:
t, t+2, t+4, t+6, t+8看出问题了吗?t+1、t+3、t+5、t+7 这四个位置从头到尾都没有被第二层“看到”过。也就是说,虽然名义上两层d=2的3x3卷积累积感受野达到了9,但内部有一半的位置是盲区。这种盲区在实际训练后就会表现为特征图里周期性的网格纹理,再经过上采样传到最终输出,分割图就出现棋盘状的伪影,细小结构尤其明显。
4.2 我的实战翻车记录
我踩这个坑踩得还挺狠。某个分割模型里,我把连续三个膨胀残差块的dilation都设成了2,训练曲线很正常,loss降得也不错,但模型输出的掩膜里总是有一层淡淡的网格纹理。一开始我以为是上采样代码写错了,排查了半天,最后把每层特征图可视化出来才发现,问题出在卷积本身的覆盖模式上。
把dilation从 (2, 2, 2) 改成 (1, 2, 3) 之后,同样的训练配置,模型输出的网格纹理几乎消失了,边缘也更干净。这个改动只动了几行参数,效果天差地别,所以我现在对“连续相同空洞率”的组合特别敏感。
4.3 HDC原则:怎么设计空洞率组合
针对网格伪影,目前业界比较认可的方案是HDC(混合空洞卷积),核心原则三条,我做网络设计时基本照着检查:
一个组合里的各个dilation不能有大于1的公因子。比如 (2, 2, 2) 就有问题,(2, 4, 8) 也有问题,因为它们共享因子2。换成 (1, 2, 3) 或者 (2, 3, 4),相邻层之间互质或“锯齿状”变化,覆盖就均匀得多。
dilation图案要设计成锯齿状,也就是递增后回落,比如 1 -> 2 -> 3 -> 1 -> 2 -> 3。完整的全卷积网络通常很深,不能永远递增下去,周期性回落可以持续保证覆盖均匀。
dilation的最大值不应超过特征图分辨率的可接受范围。经验上,在常见的256x256到512x512分辨率骨干网络里,dilation用到8已经算很大了,用到16以上就要警惕采样点大面积越界和信息稀释。
如果实在拿不准,有个笨办法但很有效:把每一层空洞卷积等效采样的位置画出来,看有没有大片的盲区。我在调试阶段经常这么做,比直接看最终效果图更容易定位问题。
5. 实操心得:空洞率怎么选、调参时我踩过的坑
5.1 空洞率组合的常见配置与选择逻辑
空洞率不是拍脑袋定的,我的选择逻辑是从目标覆盖尺寸反推。
假设网络要感知一个区域半径大约是R(像素),卷积核大小是k,那么单个空洞卷积层的等效感受野大约是(k-1)*d+1,让这个值接近目标尺寸即可得到初始的d。比如目标是能感知到输入上大约21x21的区域,用3x3卷积,那么 d ≈ (21-1)/(3-1) ≈ 10。但实际很少直接用这么大的d,因为前面已经有很多层积累感受野了,后面单层的d不需要那么大。
更常见的做法是把空洞卷积放在网络的最后几个阶段。骨干网络已经提供了基础感受野,最后几层的d从2到8之间选择即可。我在分割任务里比较顺手的组合有:
- (1, 2, 3):最稳,适合大多数场景
- (2, 3, 4):感受野更大一点,适合需要更多上下文的任务
- (1, 2, 5):锯齿落差大,某些实验中表现更好,但需要测试
一定要避免的是 (2, 2, 2)、 (2, 4, 8) 这类等比递增或等值重复的组合。每次我遇到模型输出带网格纹理,第一反应就是去查dilation组合。
5.2 训练和部署中的几个注意点
训练层面,空洞卷积本身不引入额外参数,所以初始化方法、优化器配置跟普通卷积基本一致。但有一点我在项目中遇到过:当空洞卷积替代了下采样层后,网络的感受野分布会发生明显变化,BatchNorm的统计量可能不如预训练模型稳定,尤其是从分类预训练迁移到分割任务时。稳妥的做法是迁移后先用小的学习率微调几十轮,再恢复正常学习率,让BN统计量重新适应。
部署层面,空洞卷积在主流框架里的推理速度不一定和理论计算量成正比。有的推理引擎对空洞卷积的优化并不充分,实际跑起来会比标准卷积慢一些,尤其是在嵌入式设备或者移动端。我做过一次实验,某个轻量模型在CPU推理时,把标准卷积层换成dilation=2的空洞卷积之后,单层耗时增加了百分之二十左右。原因是空洞卷积会造成访存不连续,缓存命中率下降。如果要做上线部署,性能评估一定要基于真实设备的实测,别只看FLOPs。
5.3 验证感受野是否达到预期的小技巧
最后分享一个我常用的调试方法:感受野可视化。做法很简单,构造一个全是0的输入,在某个位置放一个1(或者一个高斯点),前向一次,观察输出特征图上响应最强的位置和扩散范围。
如果一个dilation=2的空洞卷积真的把感受野扩大到了预期范围,输出的响应图应该出现覆盖面大致符合理论值的扩散区域。如果发现响应图只有几个孤立点、没有连成片,那就要警惕覆盖不均匀的问题,去检查dilation组合。这个方法不需要任何可视化工具,几张热力图就能看出来。
还有一个小技巧是用梯度回传检查输入各位置的贡献。对输出某个点做梯度反传,看输入图上每个像素的梯度绝对值,如果贡献位置出现周期性空洞,说明网络确实存在盲区。这个方法的优点是可以检查整个网络,不局限于单层。
空洞卷积是个性价比很高的组件,但它的效果高度依赖设计细节。连续同空洞率、空洞率过大、位置放错,都会带来反效果。记住一条简单原则:先用小dilation跑通,再逐步增大并观察特征图,最后做感受野可视化确认覆盖均匀。这样基本能绕开我踩过的那些坑,也能在设计新网络时把空洞卷积用在刀刃上。