卷积运算和运算后特征图大小计算,听起来是深度学习里最"人畜无害"的一节。大多数人背下 (W - K + 2P)/S + 1 这个公式就觉得自己会了,可真到自己搭网络、调分类或检测模型的时候,因为算错一个特征图尺寸而让训练白跑两小时的情况,我隔三差五就能碰到一次。这个系列的第一篇把基础卷积公式推导了一遍,这一篇我想把那些"背完公式之后才会遇到的东西"一次讲清楚:空洞卷积、分组卷积、深度可分离卷积、转置卷积分别怎么算尺寸;多层串联时怎么快速心算而不出错;最后再给一份可以无脑抄的验证脚本。内容更适合已经写过几个网络、开始碰分割/检测/轻量模型这类场景的朋友,当然,刚学完基础公式的同学也能直接当进阶笔记看。
1. 为什么一个"小学算术"级的公式,现实中却频频算错
1.1 三种最常见的尺寸翻车场景
公式本身确实不难,真正让人翻车的不是公式,而是框架里的默认参数、特殊卷积形态、整除规则这些暗处的细节。我见过太多比如"模型跑了一半突然显存爆掉""两个分支特征图拼不上""分类头前的全连接层死活初始化不了"之类的问题,最后定位下来,全是特征图尺寸算错导致的。
| 报错现象 | 根因 | 高频位置 |
|---|---|---|
| 训练跑到一半显存不足 | 某个卷积忘了配padding,尺寸比预期大一圈,中间特征图越积越多 | 随意堆叠的小网络 |
cat或+时维度对不上 | 上下采样时取整方向不一致,两边差了1个像素 | 多尺度特征融合结构 |
| 全连接层输入尺寸接不上 | 只算了卷积层,忘了算全局池化或flatten前的实际张量大小 | 分类网络末尾 |
这三个场景指向同一个词:维度漂移。维度漂移的意思是,某个block输出的尺寸和你以为的只差1个像素,单看一层无伤大雅,但在几十层里累积起来,轻则shape对不上,重则某个concat点直接抛异常。最气人的是,报错位置往往离罪魁祸首很远,你得从头逐层print才能找到那"差出去的一像素"到底在哪一层产生。
1.2 尺寸算错后到底会引发什么后果
尺寸算错的代价远不止"程序报错"这么简单。首先是显存估算失效,特征图比预期多出一圈,batch size就得往小了调,训练效率肉眼可见地下降。其次是模型结构失效,如果后面接了全连接层,输入维度不匹配时模型根本初始化不了;而在分割或检测任务里,Decoder部分经常依赖逐层拼接特征图,尺寸对不上就直接断掉整条训练。最难受的还是时间成本,一个模型改完参数重新训练动辄一两天,等跑完才发现某个中间层尺寸漂移,心态很容易崩掉。
这也是为什么很多开源框架的示例代码会在每层后面打印输出shape,目的就是让尺寸问题第一时间暴露,而不是憋到训练中段才炸。我自己现在搭任何网络,第一件事就是把尺寸表拉出来对一遍,比出错了再慢慢调要省事一个量级。
2. 把基础公式从"背住"变成"理解":尺寸公式到底在算什么
2.1 滑窗视角下的每一步拆解
正向卷积的尺寸公式一般写作:
out = floor((in + 2P - K) / S) + 1如果卷积核带了空洞率(dilation),把K换成有效核大小 K_eff = D * (K - 1) + 1 就可以,稍后我会专门说这个。
公式的直观理解其实是"卷积核能完整放得下的起点有多少个"。把输入边长加上padding后的总长度看成一条路,卷积核是尺子,步长决定每次往前挪多远。路上的每把尺子对应一个输出像素,所以输出数量就是"可用起点数"。如果剩余距离不够再走一步,最后那半格就被丢弃了——这就是floor下取整的由来。
举个生活化的例子:假设路长10米,尺子长3米,每走2米放一次尺子。起点0、2、4、6、8都可以完整放下尺子,但是到8之后剩2米,不够3米,于是不能再放。起点数就是5,对应输出尺寸5。卷积公式本质上就是数这种"合法起点"。
2.2 padding、stride、kernel 三者的配合
很多初学者会困惑:为什么很多层用了3x3卷积之后尺寸不变?为什么有些层用同样的3x3卷积却减半了?差别全在stride和padding的配合上。
| kernel | stride | padding | 效果 |
|---|---|---|---|
| 3 | 1 | 1 | 尺寸不变 |
| 3 | 2 | 1 | 尺寸约减半 |
| 5 | 1 | 2 | 尺寸不变 |
| 7 | 2 | 3 | 尺寸约减半 |
规律很直接:当 padding = (kernel - 1) / 2 时,stride=1 的卷积保持尺寸不变;stride=2 时,输入是偶数的情况下尺寸正好减半。原因也很好理解:padding在两边各补一圈,补完后kernel占掉的不再是输入本身的像素,而是被padding分担了一部分,于是输出和输入一样大。
这个规律在实际搭网络时非常有用。我习惯先把网络里所有"保持分辨率"的层记成 stride=1 + 补边,把"降采样"的层记成 stride=2 + 补边,心算速度会快很多,具体怎么心算第五节再展开。
2.3 整除下取整的那点事
公式里的 floor 是另一个高频翻车点。为什么是下取整而不是四舍五入或者上取整?因为卷积核不能超出边界滑动,最后放不下的那一格直接丢弃,不存在"强行凑一个输出"的可能。
举个例子:输入64,kernel=3,stride=2,padding=1,算出来是 (64 + 2 - 3) // 2 + 1 = 63 // 2 + 1 = 31 + 1 = 32。如果输入是63,同样参数下是 (63 + 2 - 3) // 2 + 1 = 62 // 2 + 1 = 31 + 1 = 32,还是32。注意,这里两个输入相差1,输出却完全一样,这就是下取整"吃掉"的那个像素。
这种差异在单层可能无所谓,但在多层串联时会逐渐累积,最终出现在拼接或全连接层。尤其当你想把手动的尺寸算结果和框架中的实际shape做核对时,默认的整数除法就是地板除,别手滑写个round或ceil上去。
3. 进阶卷积形态的尺寸规则:空洞、分组、深度可分离
3.1 空洞卷积:等效核大小和感受野要一起算
空洞卷积(dilated convolution)的设计初衷是在不增加参数量的前提下扩大感受野。它通过在核元素之间插入空洞来"撑大"卷积核,而真正参与计算的权重数量不变。插入空洞后,核的实际覆盖范围不再是K,而是:
K_eff = D * (K - 1) + 1比如3x3卷积,dilation=2时,K_eff = 2 * (3 - 1) + 1 = 5。这就相当于把一个3x3的核间距拉大,覆盖范围变成5x5,但计算量还是3x3的量。尺寸公式完全复用基础公式,只是把K替换成K_eff:
out = floor((in + 2P - K_eff) / S) + 1| 输入 | kernel | dilation | stride | padding | 输出 |
|---|---|---|---|---|---|
| 128 | 3 | 2 | 1 | 0 | 124 |
| 128 | 3 | 2 | 1 | 2 | 128 |
| 128 | 3 | 2 | 2 | 2 | 64 |
可以看到,K=3、D=2、P=2、S=1 时,输出尺寸保持不变,但感受野从3x3扩大到了5x5甚至更大。这就是空洞卷积在分割任务里常被用来"不降分辨率扩大感受野"的原理。
我自己踩过的坑是漏算dilation:拿着K=3直接带进公式,结果算出来比实际框架输出大了几个像素。另外要注意,stride不会跟着dilation一起被放大,它是输出采样的间隔,只负责决定相邻输出中心点之间的距离,别把两者混在一起。
3.2 分组卷积:空间尺寸不受影响,通道分组有硬约束
分组卷积(grouped convolution)的groups参数把输入通道分成g组,每组各自独立做卷积,最后再拼回输出通道。它的空间尺寸计算公式和普通卷积完全一样,不改变H、W方向上的任何行为。
真正要小心的是通道维度的硬约束:输入通道数和输出通道数都必须能被groups整除。比如输入是32通道,输出是64通道,groups=4,那就要求32能被4整除、64也能被4整除,否则框架在初始化阶段就会报错。
举例来说,输入shape是(1, 32, 128, 128),配置groups=4,kernel=3,stride=1,padding=1,输出是(1, 64, 128, 128)。空间上还是128x128,但每个group只在8个输入通道上算16个输出通道。换句话说,分组卷积改变的是通道与通道之间的连接关系,而不是空间分辨率。
当groups=C_in且C_out=C_in时,分组卷积就退化成了深度卷积(depthwise convolution),这也是轻量模型里最常见的基础构件。理解这一点后,深度可分离卷积就很好讲了。
3.3 深度可分离卷积:先后再宽,尺寸跟着depthwise走
深度可分离卷积把标准卷积拆成两层:先做逐通道卷积(depthwise,groups=C_in),再做逐点卷积(pointwise,1x1卷积)。这两层的尺寸变化规则都很简单,但很多人第一次看时容易绕晕。
逐通道卷积只负责在每个通道内部做空间卷积,输入输出通道数不变,空间尺寸由它自己的stride和padding决定;逐点卷积是1x1卷积,只改变通道数,空间尺寸永远不变。所以整条深度可分离卷积的尺寸走向,完全由中间那个depthwise层决定。
举个例子。输入(1, 32, 128, 128):
- depthwise: kernel=3, stride=1, padding=1 → 输出(1, 32, 128, 128)
- pointwise: kernel=1, stride=1, padding=0 → 输出(1, 64, 128, 128)
如果想下采样,就把stride放到depthwise上,比如kernel=3、stride=2、padding=1,输出变成(1, 32, 64, 64),后面接pointwise后还是64x64。工程上的一个重要经验是:轻量网络如果要降分辨率,尽量在depthwise层完成,让1x1卷积始终在分辨率不变的前提下调整通道,这样特征信息损失最小。
4. 转置卷积:尺寸是"反向铺开"而不是"逆运算"
4.1 转置卷积的尺寸公式和output_padding
转置卷积(transposed convolution,也叫反卷积)是分割、生成类模型里最常用的上采样手段,但它的名字容易让人产生误解。它不是卷积的逆运算,而是把输入上的每个像素按卷积核权重"铺开"到输出区域,重叠的部分再累加。所以它的尺寸计算和正向卷积是两套逻辑。
尺寸公式为:
out = (in - 1) * stride - 2 * padding + K_eff + output_padding其中 K_eff 同样是考虑到dilation的有效核大小。这里的output_padding是个很容易被误解的参数,它不是真正往输入里补像素,而是只影响最终输出尺寸,让转置卷积能拼出期望的分辨率。
看两个常用配置。输入64,kernel=3,stride=2,padding=1,output_padding=1:out = (64 - 1) * 2 - 2 * 1 + 3 + 1 = 128,正好2倍上采样。另一个组合是kernel=2,stride=2,padding=0,output_padding=0:out = (64 - 1) * 2 + 2 = 128,同样2倍上采样。
| 上采样倍数 | 常用配置 |
|---|---|
| 2x | K=2, S=2, P=0 |
| 2x | K=3, S=2, P=1, OP=1 |
| 2x | K=4, S=2, P=1 |
| 4x | 连续两次2x上采样 |
4.2 上采样倍数的心算技巧与典型误区
做上采样时有个很省事的心算技巧:如果 (K - 2P + OP) 恰好等于S,那么输出尺寸就是输入尺寸乘以S。反过来设计时,想要2倍上采样,只需要让 K - 2P + OP = 2 即可。这就是为什么上面几种配置看起来不同但都能实现2倍效果。
典型误区有这么几个。一是把output_padding当成真正的填充操作,以为它会往张量里补数值,实际它只改变输出尺寸,不参与计算。二是把padding设得太大或太小,导致输出尺寸不是输入尺寸的整数倍,后面每拼一次都要手动裁剪,整条链路变得极其脆弱。三是拿转置卷积当"逆运算"去反推上一层的输入尺寸,其实应该用正向卷积公式验证,两者方向完全不同。
我自己在写分割模型时,会专门把Encoder下采样的倍数和Decoder上采样的倍数分别列出来,一层层对上,任何一步对不齐都先改配置再往下走,绝不指望最后靠一个自适应池化糊弄过去。
5. 从单层到整条链路:多层串联时怎么快速追踪尺寸漂移
5.1 池化、stride=2 与 padding 补偿的叠加效应
特征图尺寸的变化不只在卷积层发生,池化层同样会改变分辨率。池化的尺寸公式和卷积基本一样,最常见的是2x2、stride=2的最大池化,padding=0,输出尺寸直接约等于输入的一半;也有3x3、stride=2、padding=1的池化,效果同样是减半但带了一点重叠。
这里有个很容易被忽视的细节:池化层的ceil_mode。PyTorch里这个参数默认是False,即向下取整;如果打开变成True,输出会向上取整,尺寸可能比默认大1。在普通分类任务里多1少1无所谓,但在分割、检测这种需要对特征图做逐像素对齐的任务里,这1个像素的偏差足以让后面的concat或加法全部错位。
我习惯把"下采样"统一记成两类:stride=2的卷积或池化,约减半;stride=1且padding配平的卷积或池化,尺寸不变。遇到奇数尺寸的输入再回头用精确公式校验一遍,大多数情况下心算足够用了。
5.2 一套"保持/减半"心智模型
当你面对一个几十层的网络时,逐层套完整公式太慢,所以我总结了一套"保持/减半"的快速判断流程:
- 从输入尺寸开始,逐模块往下走。
- 看到 stride=1 且 padding=(kernel-1)/2 的层,直接记"保持"。
- 看到 stride=2 且 padding=(kernel-1)/2 的层,直接记"减半"。
- 看到 stride=2 且 padding=0 的层,约减半,但建议用公式精确算一次。
- 遇到池化层,按池化窗口和stride同样处理。
- 遇到1x1卷积或逐点卷积,忽略不计。
- 最后拿一段代码打出的实际shape校验一遍。
为什么padding配平时stride=2会精确减半?假设输入是偶数in,kernel=3,stride=2,padding=1:in + 2 - 3 = in - 1,(in - 1) // 2 等于 in/2 - 1,再加1正好是 in/2。如果输入是奇数,这里就会差一个像素,所以心算时一定要关注输入的奇偶性。
举个常见的链条:输入224 → 卷积(k7, s2, p3) → 112 → 最大池化(k3, s2, p1) → 56 → 卷积(k3, s1, p1) → 56 → 卷积(k3, s2, p1) → 28 → 卷积(k3, s1, p1) → 28 → 最大池化(k2, s2, p0) → 14 → 卷积(k3, s1, p1) → 14 → 全局平均池化 → 1。整个过程就是"减半、减半、保持、减半、保持、减半、保持"的节奏,非常清晰。
5.3 尺寸减到0前的边界条件
还有一个容易忽略的点:当输入小于有效核大小时,尺寸公式会算出0甚至负数。比如输入2x2,配一个3x3卷积且padding=0,公式是 (2 - 3) // 1 + 1 = 0。框架对这种边界的处理不一致,有的直接报错,有的会给出一个奇怪的维度,反正都不是你想要的。
所以在设计网络时,我不建议把输入尺寸压到边界附近。常见分类网络最终的卷积特征图一般不会小于7x7或8x8,再往下就走全局池化或直接flatten了。如果你要用一个很小的输入跑实验,务必先用公式验证每层输出都大于等于1,再开始训练,否则会在推理阶段遇到莫名其妙的异常。
6. 用几行代码把尺寸计算"焊死":验证脚本和自动化习惯
6.1 一个可以直接抄的纯Python计算函数
前面讲了这么多手算技巧,但真到了几十层的网络,手算始终不是最终方案。我把常用的尺寸计算封装成两个纯Python函数,直接抄走就能用:
def conv_out_size(in_size, kernel=3, stride=1, padding=0, dilation=1): # 有效卷积核大小,考虑空洞 effective_kernel = dilation * (kernel - 1) + 1 result = (in_size + 2 * padding - effective_kernel) // stride + 1 # 尺寸不能小于0,这里兜底,方便提前发现问题 return max(0, result) def conv_transpose_out_size(in_size, kernel=3, stride=2, padding=1, output_padding=1, dilation=1): # 转置卷积的输出尺寸公式 effective_kernel = dilation * (kernel - 1) + 1 return (in_size - 1) * stride - 2 * padding + effective_kernel + output_padding这两个函数覆盖了卷积和转置卷积的绝大多数场景。注意转置卷积公式里output_padding已经是可选参数,默认传1其实是2倍上采样的常见配置,如果你不用上采样,记得改成0。
6.2 和框架实现逐层对拍:用PyTorch验证公式
函数写得对不对,最稳妥的办法是和框架的真实输出对拍。下面这段脚本用PyTorch随机初始化一个卷积层,比较预测尺寸和实际shape:
import torch import torch.nn as nn def verify_conv(in_size, kernel, stride, padding, dilation): x = torch.zeros(2, 4, in_size, in_size) layer = nn.Conv2d(4, 8, kernel_size=kernel, stride=stride, padding=padding, dilation=dilation) y = layer(x) predicted = conv_out_size(in_size, kernel, stride, padding, dilation) assert predicted == y.shape[-1], \ f"参数: in={in_size}, k={kernel}, s={stride}, p={padding}, d={dilation}," \ f"预期{predicted},实际{y.shape[-1]}" return y.shape[-1] for args in [(224, 7, 2, 3, 1), (128, 3, 1, 1, 2), (64, 3, 2, 1, 1), (32, 5, 1, 2, 1)]: print(args, "->", verify_conv(*args))转置卷积同理:
def verify_convt(in_size, kernel, stride, padding, output_padding): x = torch.zeros(2, 4, in_size, in_size) layer = nn.ConvTranspose2d(4, 8, kernel_size=kernel, stride=stride, padding=padding, output_padding=output_padding) y = layer(x) predicted = conv_transpose_out_size(in_size, kernel, stride, padding, output_padding) assert predicted == y.shape[-1], \ f"参数: in={in_size}, k={kernel}, s={stride}, p={padding}, op={output_padding}," \ f"预期{predicted},实际{y.shape[-1]}" return y.shape[-1] for args in [(64, 3, 2, 1, 1), (64, 2, 2, 0, 0), (32, 4, 2, 1, 0), (16, 3, 4, 1, 3)]: print(args, "->", verify_convt(*args))跑一遍如果全部通过,基本可以确认公式、取整方向、dilation和output_padding的处理都没问题。后续只要在模型每个关键模块里调用这两个函数,把计算值和tensor.shape比对,整个维度漂移问题就能在源头被拦截住。
6.3 工程里的尺寸断言习惯
最后聊聊工程习惯。我自己的做法是,在每个block末尾加一条形状断言,例如:
assert x.shape[-1] == expected_h and x.shape[-2] == expected_w, \ f"block 输出尺寸错误: 期望({expected_h}, {expected_w}),实际{x.shape[-2:]}"同时用一个shape logger把每层输出都打出来,新搭网络时先跑一遍尺寸表,再开始写训练循环。这样可以完全避免"训练跑到一半发现维度对不上"的情况。数据并行、混合精度这些配置都不会改变张量尺寸,所以尺寸验证这件事完全可以在训练前一次性解决,没必要等到真的炸了再去逐个排查。
最后分享一个我自己养成的臭毛病:每次搭完一个网络,我一定先把输入从1x3x224x224这种标准尺寸跑一遍,把每层输出shape打出来,逐行对一遍尺寸表,再动手写训练循环。这个习惯帮我省下的调试时间,比读十篇资料都管用。如果你也被特征图尺寸问题折磨过,不妨从把上面那个验证函数放进你的工具包开始。