做图像分割和检测的朋友大概率都被同一个问题折磨过网络想看得远就得把特征图一路缩水高层语义是有了边缘细节却糊成一团。空洞卷积Dilated Convolution是我在解决这类“又想看得远、又想看得细”的矛盾时最常用的工具。它能在不损失分辨率的情况下撑大感受野而且参数和计算量基本不涨这个特性让它在语义分割、目标检测、语音序列建模里都成了常客。这篇内容适合刚接触空洞卷积的初学者也适合已经在用但总被感受野和棋盘格伪影绕晕的同学。我会从原理、结构设计、PyTorch实现到踩坑经验一次讲完。1. 分辨率与感受野的拉扯空洞卷积为什么被频繁使用1.1 分割任务里的经典困境先还原一个非常真实的场景。你输入一张 512x512 的图片要做一个像素级的语义分割。传统做法是让网络不断做卷积和下采样一路缩到 32x32这样最后一层的每个点能看到整张图的大致结构分类很准但它回不到原来的分辨率。要恢复细节就得靠上采样、跳跃连接边缘还是免不了出现一块一块的钝感。更麻烦的是小目标经常在下采样过程中直接消失一个 8 像素宽的行人几轮池化之后就只剩一个点。这个问题的本质是感受野和分辨率互相打架。想扩大感受野通常要下采样或加大卷积核可一旦下采样空间细节就没了。而空洞卷积正是为这个矛盾设计的折中方案。它的思路非常直接普通卷积核里的采样点本来是挨着的空洞卷积把这些点之间的间隔拉开卷积核不变大覆盖范围却变大了。用大白话说就是给卷积核“打了几个洞”。1.2 空洞卷积怎么做到两头兼顾用生活化的比喻来理解。普通 3x3 卷积像一个近视眼只能看清正前方九宫格那么大的区域看得清楚但视野小。空洞卷积把这只近视眼的两个瞳孔之间的距离拉开看得远、看得广但每个局部看得没那么密集。它没有增加卷积核里采样点的数量只是把这些点放得更开所以计算量和原来差不多。这个特性带来的直接结果是你可以在不下采样、不增大卷积核的前提下让每一层覆盖更大的范围。比如输入一张街道场景普通 3x3 卷积只能看到路面上的一个小坑空洞卷积的一层就能同时看到人行道、车辆和标志牌的一部分。对分割任务来说这意味着高分辨率特征图可以直接保留同时高层感受野也够大后面不需要做特别暴力的上采样也能获得不错的结果。空洞卷积不是新奇概念但它真正被大规模使用是语义分割把分类网络改成全卷积结构之后的事。分类网络里习惯用池化和带步长的卷积来压分辨率可分割网络不想压分辨率。于是大家就想能不能把池化去掉把最后几层的步长改成 1同时用空洞卷积填补被稀释的感受野实践证明这条路完全可行而且效果比反复上下采样稳定得多。这也是为什么你会看到很多分割骨架网络在最后几个 stage 会切换成空洞卷积模式。2. 核心原理膨胀系数、等效卷积核与感受野计算2.1 膨胀系数到底改了什么空洞卷积里最核心的参数是膨胀系数 dilation也叫空洞率。定义一个 3x3 卷积核dilation1 时就是普通卷积相邻采样点距离是 1 个像素dilation2 时相邻采样点之间隔一个像素dilation4 时隔三个像素。这个参数并不改变卷积核里参与计算的权重数量只改变权重在输入特征图上的分布位置。比如一个 3x3 卷积核dilation2 时它的九个采样点会落到输入图上间隔为 2 的网格点上实际覆盖的区域是一个 5x5 的范围。虽然不是 5x5 的每个点都有权重但网络能“感知”到整个 5x5 范围里的信息。这就是为什么我们常听人说“3x3 空洞卷积等效于 5x5 卷积”严格说应该是“等效覆盖范围是 5x5”。下面这张表可以帮你快速建立直觉卷积核大小膨胀系数等效覆盖范围3x313x33x325x53x337x73x349x93x3613x135x529x95x5313x13计算公式是等效覆盖尺寸 卷积核大小 (卷积核大小 - 1) x (膨胀系数 - 1)。这个公式虽然短但我建议你亲手算几遍因为在多层串联叠加时很容易把膨胀系数直接相加那是不对的需要一层一层地换算等效覆盖范围再求感受野。2.2 感受野怎么算一个可以直接套用的公式感受野指的是输出特征图上一个点在原始输入上对应多大区域。单层空洞卷积的感受野其实就是上面的“等效覆盖范围”。多层串联时不能直接把每层的等效覆盖范围相加因为新的一层是在上一层输出上继续卷积覆盖范围会叠加。当所有卷积层的步长都是 1 时感受野计算可以简化为感受野 1 所有层的等效覆盖尺寸 - 1之和我拿一个三层卷积举例。如果三层都是普通 3x3 卷积等效覆盖尺寸都是 3感受野等于 1 2 2 2 7。如果三层都是 dilation2 的 3x3 空洞卷积每层等效覆盖尺寸是 5感受野就等于 1 4 4 4 13。同样是三层卷积感受野从 7 扩大到 13参数量却没变。这个差距在小目标检测里非常明显6 个像素的差距往往就决定了一个目标能不能被识别出来。如果中间某些层有 stride情况会复杂一点后面的层产生的感受野会被放大。公式是当前层感受野 上一层感受野 (当前层等效覆盖尺寸 - 1) x 前面所有层的 stride 连乘。经验之谈调试空洞卷积时先把所有 stride1 的情况算清楚再看有 stride 的情况否则非常容易算错。2.3 计算量几乎不涨这才是关键普通 3x3 卷积假设输入通道是 C_in输出通道是 C_out输出特征图是 HxW计算次数大约是 HxWxC_inxC_outx9。空洞卷积只改变采样位置采样点数量仍然是 9 个所以乘法次数和普通 3x3 卷积完全一样。参数量更不用说权重只有 9xC_inxC_out 个和膨胀系数一点关系都没有。这是空洞卷积最吸引人的地方视场变大但是账单一分钱没多花。不过我要提醒一句这是理论 FLOPs 的算法。实际部署到推理引擎时空洞卷积的访存模式更分散某些硬件优化不到位的后端可能不比普通卷积快甚至会变慢。这个我放到工程坑的部分再展开。先记住一个结论在精度和感受野的权衡上空洞卷积是一个非常划算的方案但对它的效率期待要留有余地。3. 结构设计串行、并行与混合膨胀方案3.1 串行堆叠让感受野层层展开把几层空洞卷积串起来是一种非常自然的设计。比如连续用三层 dilation2等效覆盖尺寸都是 5感受野按 1 - 5 - 9 - 13 这样拾级而上。这种方式实现简单代码改动小适合用在已经有普通卷积的网络上直接把最后几个卷积层的 dilation 参数改掉就行。但串行路线有一个容易翻车的点如果每层都用同一个膨胀系数比如全都用 dilation2层数一多输出特征图上会出现“棋盘格”伪影。原因是卷积核采样位置在空间上形成了固定间隔有些区域始终没被采样到。后面我会单独讲混合膨胀方案来解这个问题。串行设计更适合先粗后细的思路建议前几层用普通卷积后面再逐步放大膨胀系数一般不会出问题。3.2 并行多尺度像 ASP 模块那样分兵作战现实里的目标尺寸千差万别一个 3 像素的圆点和一块 300 像素的招牌不可能用同一个感受野去兼顾。并行多尺度方案就是同时开几个分支每个分支用不同的膨胀系数各自提取不同尺度的上下文然后把结果拼到一起。语义分割里经典的 ASP 模块就是这个思路通常用 1x1 卷积配合几个不同 dilation 的空洞卷积分支再接上全局池化分支最终把所有特征拼起来。AS 模块的好处是灵活网络一次能同时看到局部细节、中等上下文和全局结构。我在实际项目里通常先按膨胀系数 6、12、18 起步再根据目标尺寸调整。如果检测对象偏小就把 18 换成 12如果场景大、目标很稀疏可以加一个 24 的分支。每个分支的卷积核基本都是 3x3算下来整体计算量还在可控范围内比用多个不同尺寸的卷积核划算得多。3.3 HDC 混合膨胀别让棋盘格毁掉你的特征图连续使用相同膨胀系数带来的棋盘格伪影是空洞卷积最著名的坑。为了说清楚它我们可以算一下三层 dilation2 的 3x3 卷积堆在一起等效感受野是 13x13但采样点位置之间存在一个固定的 2 像素间隔等效于在特征图上“挑着看”中间有一大圈信息虽然理论上在感受野范围内却没有实际进入计算。输出的特征图就会出现规则的空洞或条纹尤其在物体边缘位置非常显眼。解决办法是使用一组“锯齿状”的膨胀系数比如 [1, 2, 5, 1, 2, 5]或者 [1, 2, 4, 8] 这种递增序列。关键原则是让这组系数的最大公约数为 1这样采样点能够比较均匀地覆盖全图。我自己常用的默认组合是 [1, 2, 5]因为这个序列既不会让第一层太稀疏又能快速把感受野拉开。如果你是在一个已经下采样过的低分辨率特征图上做膨胀卷积膨胀系数别贪大尽量从 1 和 2 开始否则起不到精细特征提取的作用。4. 实操在 PyTorch 里实现空洞卷积4.1 一个最基础的空洞卷积层PyTorch 里用 Conv2d 就能直接实现空洞卷积参数名是 dilation。下面是一个最小例子import torch import torch.nn as nn # 输入1 张 3 通道的 64x64 图片 x torch.randn(1, 3, 64, 64) # 普通 3x3 卷积 conv1 nn.Conv2d(in_channels3, out_channels16, kernel_size3, padding1) y1 conv1(x) print(y1.shape) # torch.Size([1, 16, 64, 64]) # 空洞卷积3x3dilation2 conv2 nn.Conv2d(in_channels3, out_channels16, kernel_size3, dilation2, padding2) y2 conv2(x) print(y2.shape) # torch.Size([1, 16, 64, 64])留意 padding 的变化。普通 3x3 卷积保持尺寸需要 padding1dilation2 的 3x3 卷积等效覆盖范围是 5x5要保持输出尺寸就得 padding2。这里的规律是对于奇数卷积核padding dilation x (kernel_size - 1) / 2。不少新手只改了 dilation 忘了改 padding结果输出尺寸突然小了几像素后面接的层全对不上。4.2 写一个感受野验证脚本理论学习再多都不如直接跑一个脚本来得踏实。我调试时习惯写一个函数把每一层的 kernel_size、dilation、stride 列出来直接算出整条链路的感受野def compute_receptive_field(layers): layers: list of dict, 每个元素包含 {kernel: int, dilation: int, stride: int} 返回整条链路的感受野 receptive_field 1 stride_product 1 for layer in layers: k layer[kernel] d layer[dilation] s layer[stride] effective_k k (k - 1) * (d - 1) receptive_field (effective_k - 1) * stride_product stride_product * s return receptive_field # 三层普通 3x3 卷积 normal_layers [ {kernel: 3, dilation: 1, stride: 1}, {kernel: 3, dilation: 1, stride: 1}, {kernel: 3, dilation: 1, stride: 1}, ] print(compute_receptive_field(normal_layers)) # 7 # 三层 dilation2 的 3x3 空洞卷积 dilated_layers [ {kernel: 3, dilation: 2, stride: 1}, {kernel: 3, dilation: 2, stride: 1}, {kernel: 3, dilation: 2, stride: 1}, ] print(compute_receptive_field(dilated_layers)) # 13这个脚本建议所有做分割和检测的人都留一份。很多时候模型结构看着没问题输出却很奇怪我用这个脚本一算发现某层感受野比预期小太多原因就是中间夹了一个 stride2 的下采样层导致后面的膨胀卷积“看起来很大”实际覆盖范围被压缩了。4.3 一块能直接用的多尺度融合模块实操中我更常用的是并行多尺度结构。下面这个模块可以当成一个基础版 ASP 块来用三个分支分别用 dilation6、12、18再接一个 1x1 卷分支做全局上下文补充import torch import torch.nn as nn import torch.nn.functional as F class MultiScaleDilatedBlock(nn.Module): def __init__(self, in_channels, out_channels, rates(6, 12, 18)): super().__init__() self.branches nn.ModuleList() for rate in rates: self.branches.append( nn.Sequential( nn.Conv2d( in_channels, out_channels, kernel_size3, paddingrate, dilationrate, ), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue), ) ) # 1x1 卷积分支聚合全局上下文 self.global_branch nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size1), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue), ) def forward(self, x): feats [branch(x) for branch in self.branches] global_feat self.global_branch(x) feats.append(global_feat) return torch.cat(feats, dim1) # 测试输入 64x6432 通道 x torch.randn(2, 32, 64, 64) block MultiScaleDilatedBlock(32, 16) out block(x) print(out.shape) # torch.Size([2, 64, 64, 64])这个模块的默认膨胀系数是按分辨率 64 到 128 设计的如果你用更高分辨率输入可以把膨胀系数整体调大比如 12、24、36。低分辨率输入反而要调小否则 3x3 卷积在 padding 区域花掉太多计算。我见过有人把这张模块直接塞进一个小目标检测网络然后用默认的 6/12/18在 256x256 输入上效果反而比普通卷积差把膨胀系数改成 2/4/6 之后立刻正常了。5. 常见问题与排查实录5.1 padding、dilation、stride 三者怎么搭配这是被问得最多的一个问题。先说结论padding 只影响输出尺寸不影响感受野stride 影响感受野的放大倍数dilation 影响单层等效覆盖范围。三者的关系可以总结为输出尺寸由 padding、dilation、stride 共同决定感受野则由 dilation、stride、层数共同决定。所以不要只盯着一个参数调。最常见组合是 stride1这样不会丢分辨率。此时要保持特征图尺寸不变padding 直接用 dilation 乘以 (kernel_size - 1) / 2 即可。如果 kernel_size3那就是 padding dilation。如果 kernel_size5padding 2 x dilation。另一个常见组合是用 stride2 配合 dilation 增大后层感受野但这时输出尺寸会减半通常用于下采样。我建议把 stride 和 dilation 分开看待stride 负责压缩空间dilation 负责弥补视野不要指望 dilation 能替代 stride。5.2 感受野算错到底错在哪感受野算错一般就三种原因。第一忘了把中间层的 stride 考虑进去导致后面层的感受野被低估。第二把 padding 也算进感受野其实 padding 只会让边界信息参与计算并不会扩大真正的有效感受野。第三只算最后一层忽略整条链路的累加。第四把等效覆盖尺寸直接相加忽略中间隔的层数。前面那个函数已经可以解决大多数问题你只要把每层参数填对结果就八九不离十。还有一个容易被忽略的点理论感受野不等于有效感受野。一根 3x3 卷积核在大扩张率下中心位置的权重贡献远大于边缘位置所以名义上看到 13x13真正对输出产生强影响的可能只有中间 7x7。这一点在小目标上尤其明显。想验证有效感受野可以给输入图的不同位置打上标记点逐个看输出响应或者用随机噪声作为输入观察梯度回传到输入的分布范围。这种方法比纯看公式可靠得多。5.3 棋盘格伪影到底怎么排查棋盘格伪影的典型表现是特征图热力图上出现规律的网格纹理像国际象棋。出现这种问题的原因前面已经提过就是连续几层用了相同的膨胀系数导致采样点形成固定间隔的栅格很多区域永远进不了卷积计算。排查方法也很直接把各层 dilation 打出来看有没有重复看它们之间是否互质。我的避坑经验是三条。第一连续使用空洞卷积时优先选择 [1, 2, 5, 1, 2, 5] 这样的循环组合让采样点在多次卷积后尽可能覆盖连续区域。第二第一层尽量用 dilation1而不是一上来就用大膨胀系数因为第一层输出的位置偏差会影响后面所有层。第三如果数据集目标普遍偏小可以完全不使用大于 12 的膨胀系数小目标在高膨胀率下越来越容易被“漏采”。棋盘格不是只能事后发现调参前算一遍采样覆盖范围能省很多事。5.4 工程里的几个隐形坑空洞卷积在框架里写起来很容易但部署和训练时有几个坑值得记下来。第一推理引擎的支持度。某些加速库对 dilation 大的卷积处理并不好可能退回通用算子速度反而比普通 3x3 卷积还慢。如果你的模型要上移动端或边缘设备建议先在目标推理环境里跑一遍基准测试别只看论文里的 FLOPs 数字。第二显存消耗。空洞卷积的中间激活张量和普通卷积一样大因为它不缩小分辨率。连续堆叠空洞卷积时整份特征图一直在内存里占着显存可能比你想的要高。第三BatchNorm 和空洞卷积的交互。在低批次训练时即使改了膨胀率BN 的滑动均值也可能不稳定我试过在个别任务上把 BN 换成 InstanceNorm 效果更稳但换回大模型训练又不一定适用需要针对任务试。5.5 一张速查表问题、原因与解法为了让你排查起来更快我把实际操作中积累的高频问题整理成一张表现象可能原因建议解法输出尺寸变小改了 dilation 忘了改 paddingpadding dilation x (kernel - 1) / 2感受野不够大中间插入 stride2单层覆盖被压缩调整层序或在 stride 后增加膨胀卷积补偿特征图有棋盘格连续多层使用相同膨胀系数改用 [1, 2, 5] 或 [1, 2, 4, 8] 组合小目标检测变差膨胀系数过大有效感受野稀疏调小膨胀系数配合小 padding部署推理变慢算子不支持、访存分散用普通卷积替代部分空洞卷积分支训练损失波动大大膨胀率改变特征分布BN 统计不稳降低学习率或换用 InstanceNorm 试一下这张表不能覆盖所有情况但能帮你快速定位最常见的异常。我一般调试时是先看输出尺寸再看感受野最后看特征图可视化三步排查下来基本能找到问题。6. 写在最后的一点实践心得空洞卷积不是银弹它只是把“看得远”和“看得细”这对矛盾向后推了一把。在我自己的项目里最常用的套路是普通卷积负责底层细节小膨胀系数的空洞卷积负责中程上下文大膨胀系数配合全局池化分支负责大场景语义最后用跳跃连接把不同尺度拼在一起。这样设计出来的网络既不会因为纯用高斯核而丢失边缘也不会因为重复膨胀而出现棋盘格。我建议你在自己的模型里动手改一个层试试就用 dilation2 和 padding2 替换掉某个 3x3 普通卷积然后把输出和特征图可视化出来对比前后差异。空洞卷积这东西看十篇讲解不如亲手跑一次。如果你在实验中也遇到过“加了空洞卷积反而变花、变慢、变差”的情况回头按这张速查表逐条核对大概率能很快找到原因。