做深度学习的,迟早都要和“卷积运算”硬碰硬。模型跑不通的时候,报错信息十有八九都会落在特征图维度上,而“特征图大小计算”这个看似简单的算术问题,恰恰是很多人卡壳的地方。这篇是“卷积运算和运算后特征图大小计算”的第二篇,重点放在公式怎么用、边界情况怎么处理,以及在真实模型调参时怎么用手算来少走弯路。
如果你正准备手写卷积模块、搭一个自定义网络,或者只是在追网络结构代码时被一长串input_shape、output_shape绕晕,这篇都值得看完。我不打算堆太多数学推导,而是用实际调模型时最容易碰到的情形来拆解,每一步都给出能直接上手用的结论。
1. 先搞懂卷积运算的本质,尺寸变化才有依据
1.1 卷积在算什么:一个局部加权求和过程
很多入门教程喜欢把卷积描述成“滤波器扫描图像”,这个说法对,但不够到底。卷积本质上是局部加权求和:一个尺寸为 K×K 的小窗口,按顺序滑过输入特征图,每停在一个位置,就把窗口内覆盖到的输入值,和窗口对应的权重一一相乘,再把 K×K 个乘积加起来,得到输出特征图上的一个像素值。
我用一个生活化例子来理解:可以把卷积想象成手电筒照过一面墙,手电筒的照射范围就是卷积核,墙就是输入特征图。每往右挪一步,就是步长;手电筒光圈大小就是核大小;为了让墙的边缘也能被照到,我们在墙外边临时补一圈假墙,这就是填充。你每照一个位置,只根据光圈内的东西得到一个读数,这个读数就是输出像素。
理解了这一点,尺寸变化就很好理解了:输出特征图上能放多少个“读数”,取决于手电筒能在墙上一共停多少个有效位置。这个有效位置的数量,直接由核大小、步长、填充三者决定,和输入的具体像素值没有关系。所以特征图尺寸计算是纯几何问题,跟图像内容无关,这是它能够被公式统一描述的原因。
1.2 滑窗机制:步长、感受野与边界填充的配合
滑窗机制是卷积的核心执行方式。拿一个具体的例子来走一遍:输入是 5×5,卷积核是 3×3,步长为 1,不做填充。窗口从左上角开始,覆盖第 1 到 3 行、第 1 到 3 列,得到一个输出值;接着向右移动一格,覆盖第 1 到 3 行、第 2 到 4 列,再得到一个输出值;继续右移,覆盖第 1 到 3 行、第 3 到 5 列。到这一步窗口的右侧已经贴到输入边界,再往右就没法完整覆盖 3 列了,所以这一行只能产出 3 个值。同理纵向也只能产生 3 个值,最终输出是 3×3。
这里有个特别容易被忽略的点:输出尺寸不是“输入尺寸减核尺寸”这么简单,而是要考虑窗口一共能启动多少次。当步长为 2 时,窗口每移动一次跨过两个像素,位置更稀疏,输出会进一步变小。而填充的作用是允许窗口从输入边界“外面一点”开始滑,相当于在数学上扩展了输入的可滑动范围,从而让输出尺寸不至于缩得那么快,甚至能做到输入输出尺寸不变。
我把这三者的关系用一句话总结:输出尺寸等于“窗口能完整放置的起始位置个数”,起始位置必须在扩展后的有效区域内,并且相邻起始位置之间间隔为步长。
1.3 从公式到直觉:特征图尺寸变化的三要素
尽管公式后面还会展开讲,但你需要先在脑子里建立一个直觉框架:特征图尺寸变不变、变大还是变小,归根到底只看三个量——核大小、步长、填充量,以及后来会遇到的空洞率。核越大,卷积一次看的信息越多,但输出往往收缩得越明显;步长越大,输出越稀疏,尺寸成比例缩小;填充越多,相当于给输入“镶边”,可以在一定程度上抵消核和步长带来的收缩。
为什么三个量里没有一个直接叫“输入尺寸”?因为输入尺寸只决定了可滑动总范围,而真正决定每一步走多远、窗口多大、能不能从边缘外开始,是由核、步长、填充决定的。这就像一条跑道的总长度决定了你最多能跑几个桩点,但桩点之间间隔多远、起点能不能画在跑道外,才真正决定桩点数量。
有了这个直觉,你在设计网络结构时就不会再犯那种低级错误:比如只有输入数据和核大小,却完全没交代步长和填充策略。这三要素缺一个,特征图尺寸就算不出来。
2. 特征图尺寸计算:完整公式与边界情况
2.1 标准卷积的尺寸公式与向下取整问题
标准卷积层的特征图尺寸公式,几乎所有教材都会给:
H_out = floor((H_in + 2P - K) / S) + 1
其中 H_in 是输入高度,K 是核大小,P 是单侧填充大小,S 是步长。宽度 W 同理。这里最关键的是那个向下取整的 floor,很多人在这上面栽跟头。
我用一个具体例子说明:输入高度 H_in = 6,核大小 K = 3,步长 S = 2,不做填充 P = 0。套公式:(6 - 3) / 2 = 1.5,向下取整得到 1,再加 1 得到 2,所以输出高度是 2。为什么是 2 而不是向上取整到 3?因为窗口起点位置分别是第 0 位和第 2 位,如果还想取第 3 个位置,起点就得是第 4 位,但 3×3 窗口起点在第 4 位时,窗口覆盖到第 6 位正好越界,无法完整放置,所以只能停在第 2 位,一共两个起点位置。
向下取整不是数学上的严谨问题,而是物理约束:窗口不能跑到输入边界外面去。但在带填充的情况下,扩展后的边界决定了窗口可以越界多少,公式里的 +2P 就是在处理这个边界扩展。如果你对“算出来带小数”感到困惑,最简单的心法是:先算窗口可移动的总距离,再用步长看能切出多少个完整的间隔。
2.2 填充模式带来的尺寸差异:VALID 与 SAME
不同深度学习框架对卷积填充有两种常见模式,很多误算特征图尺寸都是因为混用了这两者的经验公式。第一种是“无填充”模式,对应 P = 0,输出尺寸严格按上面公式算,特征图会明显变小。第二种是“保持尺寸”模式,它的设计目标不是无脑补零,而是让输出尺寸能维持在一个可预期的数值上。
在这类模式下,框架会自动计算填充量。输出尺寸通常可以近似写成:
H_out = ceil(H_in / S)
也就是说,输入高度除以步长后向上取整。当 S = 1 时,输出尺寸等于输入尺寸,这就是标准卷积里常用的“尺寸不变”配置。当 S = 2 时,输出高度约等于输入高度的一半,但具体到奇数输入,就会向上取整。比如输入 5,步长 2,在保持尺寸模式下输出是 3。
很多人会在这里搞混:明明手动填了 P = 1,为什么输出还是和自己预期不一致?因为保持尺寸模式下的 P 不是随便填的 1,而是根据核大小和步长自动算出来的。如果你用的接口暴露了自动填充参数,就要优先相信打印出来的实际输出形状,而不是自己套标准卷积公式。如果必须在手动模式下计算,通常框架给出的计算公式和 2.1 节是一致的,只是 P 可能来自外部配置。
2.3 空洞卷积的尺寸计算:等效核尺寸是关键
空洞卷积(也叫膨胀卷积)常被用于图像分割和目标检测,它可以在不增加参数量和计算量的前提下扩大感受野。它和标准卷积的尺寸计算核心区别在于:核的实际可见范围变大了,但参与计算的权重数量不变。
空洞率 d 会让一个 K×K 的卷积核在空间上“撑开”,等效核尺寸为:
K_eff = (K - 1) × d + 1
比如一个 3×3 的核,空洞率 d = 2,等效核尺寸就是 (3 - 1)×2 + 1 = 5。也就是说,它实际覆盖的范围和 5×5 卷积核一样大,但只有 9 个位置有权重,其余位置都是空洞。
计算输出尺寸的时候,把公式里的 K 换成等效核尺寸即可:
H_out = floor((H_in + 2P - K_eff) / S) + 1
我用一个例子验证:输入 7×7,3×3 核,空洞率 2,步长 1,无填充。等效核是 5,公式为 (7 - 5)/1 + 1 = 3,输出 3×3。如果误把原来的 K=3 代入,你会得到 5,直接多算了整整一层。这个点在高分辨率特征图上做预测的任务里尤其重要,因为一个格子往往对应输入图像里一大片区域,尺寸算错会导致后续上采样对齐失败。
2.4 转置卷积的尺寸计算:反直觉的放大机制
转置卷积经常被叫成“反卷积”,但它不是卷积的逆运算,只是在形状上恢复放大的效果。它的尺寸变化规则和普通卷积相反,公式是:
H_out = (H_in - 1) × S - 2P + K
如果还有 output_padding,就再加它。举例:输入 4×4,核 3,步长 2,填充 1,output_padding 为 0。输出高度为 (4 - 1)×2 - 2×1 + 3 = 7。同样宽度也是 7。
这个公式看起来简单,但使用的时候有两个常见坑。第一个是 output_padding 到底加在哪:它不是额外贴一圈边,而是保证输出尺寸能够被整除到目标大小。比如从 7×7 上采样到 8×8,步长为 2 的计算结果可能是 7,此时加一个 output_padding 就把尺寸补到 8。第二个坑是,转置卷积的输出尺寸变大,并不代表每个像素都来自真实的新增信息,更多是学习到的上采样映射,所以在分割任务里它经常需要配合跳跃连接来恢复细节。
转置卷积的尺寸计算虽然容易混淆,但只要记住一个原则就行:把普通卷积的公式反过来用,并且始终以最终输出 shape 为准。
3. 实操环节:手算、验证与模型设计中的尺寸推演
3.1 一个完整的手算案例:从 224 一路下采样
在真实模型设计里,特征图尺寸通常是逐层变化的。我以一个 224×224 的输入为例,设计三条卷积路径:
第一层:核 3×3,步长 2,填充 1。代入公式:(224 + 2×1 - 3)/2 + 1 = 112,输出 112×112。这个倍数关系很常见,卷积加步长 2 基本就是为了做“下采样”。第二层继续用相同的参数:输入 112,输出 (112 + 2 - 3)/2 + 1 = 56。第三层同样降一半,得到 28。
如果是不同参数组合,比如核 5、步长 1、填充 2:(224 + 4 - 5)/1 + 1 = 224,尺寸保持不变。所以核 5 配填充 2,和核 3 配填充 1,同样可以做到“不改变空间尺寸”。设计网络结构时,利用这个对应关系,可以灵活切换感受野而不破坏后续层级的尺寸对齐。
我把这类手算做成一张表,方便对照:
| 输入尺寸 | 核大小 | 步长 | 填充 | 输出尺寸 |
|---|---|---|---|---|
| 224 | 3 | 2 | 1 | 112 |
| 112 | 3 | 2 | 1 | 56 |
| 56 | 3 | 2 | 1 | 28 |
| 224 | 5 | 1 | 2 | 224 |
| 28 | 3 | 1 | 0 | 26 |
这张表看起来枯燥,但它能帮你建立对网络层级变化的直观感觉。每次动手搭网络前,先把这类表列出来,比写完代码再调试要省力太多。
3.2 用一行代码快速验证计算结果
手算容易出错,但好在我们可以用一个非常简单的函数来验证。不需要任何复杂库,普通 Python 就能写:
def conv_out_size(h_in, k, s, p, d=1): k_eff = (k - 1) * d + 1 h_out = (h_in + 2 * p - k_eff) // s + 1 return h_out # 例子:输入 224,核 3,步长 2,填充 1 print(conv_out_size(224, 3, 2, 1)) # 112这段代码只负责做整数运算,和公式一一对应。如果换成转置卷积,就写另一行:
def conv_transpose_out_size(h_in, k, s, p, output_padding=0): return (h_in - 1) * s - 2 * p + k + output_padding # 例子:输入 4,核 3,步长 2,填充 1 print(conv_transpose_out_size(4, 3, 2, 1)) # 7我实际调模型的经验是,光靠代码验证还不够,还要在定义网络时打印出每一层输出的 shape。尤其是在一个结构很深的网络里,某一层的维度稍微差一点,后面全连接层就会报维度不匹配。先手算一遍,再加打印验证,双保险。
3.3 反推输入尺寸:倒着设计网络结构的技巧
有些时候你是反过来设计网络的,比如分割网络里的 U 形结构,后半部分需要把特征图尺寸恢复到和输入一致。这时已知输出尺寸和卷积参数,需要反推输入尺寸。普通卷积的反推公式是:
H_in = (H_out - 1) × S + K - 2P
转置卷积的话,反推公式则对应 2.4 节公式的逆运算。在实际项目中,我惯用做法是先把目标尺寸写出来,再根据对称结构从两端同时往中间推。前端下采样时记录每一步的尺寸变化,后端上采样时照着同一组参数反向恢复,中间一旦对不上,大概率是某个填充或者步长没对齐。
另一个非常实用的技巧是“尺寸不变卷积配置”:当核大小是奇数时,令 S = 1、P = (K - 1) // 2,输入输出尺寸完全一致。K=3 就对应用 P=1,K=5 对应 P=2。很多主干网络大量使用这种配置来堆叠深度,你又想增大感受野又不想让特征图缩小,这就是标准答案。
3.4 串联池化与全连接层时的整体推演
池化层虽然不是卷积,但它的尺寸变化逻辑和卷积很像。常见最大池化参数是核 2、步长 2,效果就是空间尺寸直接减半。举例输入 28×28,经过一次池化变成 14×14,两次池化变成 7×7。池化层没有可学习的填充策略,所以计算通常简单得多。
但要特别注意全连接层出现的位置。全连接层要求输入是一个固定维度的向量,它会把特征图展平。假设某一层输出是 4×4×64,展平后得到 4×4×64 = 1024 个数值,全连接层第一层必须是 1024 维输入。如果前面任何一层的卷积步长、填充算错,导致特征图变成 3×3×64 = 576,那全连接层的维度就全错。
我自己就踩过这个坑:某次设计一个用于彩色图像分类的小型网络,从输入 64×64 出发,连续四层卷积加池化,手算时忘了某一层的边界处理,结果实际特征图比预期小了一块,全连接层运行时报维度不匹配。最后排查时一层层打印,才发现是第三层的填充配置没有让尺寸保持住。
这类问题的教训很直接:手写网络结构时,不能只算卷积层,要把池化、展平、全连接串成一条完整链路。任何一个中间环节的尺寸变化都会传导到最终维度。
4. 常见问题与避坑经验
4.1 特征图尺寸总和差 1:别被整除“骗”了
很多人算出来的特征图尺寸比别人代码里的尺寸少 1 或多 1,就是因为忽略了向下取整。比如输入 7、核 3、步长 2、填充 0,(7 - 3)/2 = 2.0,输出为 3,算起来没毛病。可输入变成 8 时,(8 - 3)/2 = 2.5,向下取整得到 2,输出就是 3。如果心里默认四舍五入,就会得到 4,差别立刻出现。
向下取整的本质在于窗口不能“半个身子”探出边界。遇到尺寸不整除时,输出取小的一侧。这个规则对于池化层同样适用。如果你写代码时遇到总和差 1,最有效的方法是把表达式里的除法换成整型除法,也就是先做减法和乘法最后再整除,看结果是否一致。
4.2 填充策略不同,经验公式不能乱套
“填充为 1”在不同框架或不同配置下,含义可能略有区别。有的接口用显式的 P 值,每边补几圈就是几;有的接口用策略字符串,比如“无填充”和“保持尺寸”,这时 P 不是固定的。经验公式的适用前提是 P 明确,如果用的是保持尺寸策略,直接套公式反而容易出错。
我建议遇到这种情况,直接用打印验证来确认。因为在自动填充策略下,规则的细节很容易被记错,而打印真实输出 shape 永远不会骗人。你只需在构建网络时加一行中间结果输出,几秒钟就能定位。
4.3 步长与核大小设计不当会让输出“消失”
一个更隐蔽的问题是输出尺寸小于等于 0。比如输入 3×3,核 7×7,无填充,步长 1,(3 - 7)/1 + 1 = -3,输出维度竟然是负的。有些框架在这种情况下会直接报错,有些则会给出非常奇怪的形状或后续操作失败。
我看过一些新手代码,网络写成后总是出现负数维度或零维度报错,排查后发现是输入图片太小而卷积核太大。解决办法很简单:在模型最开始加一个尺寸保护判断,如果计算出的输出尺寸小于 1,就自动调整卷积参数或增加填充。尤其是做小尺寸图像任务时,输入往往只有几十像素,核大小一定不能盲目参照大图像任务来定。
4.4 快速心算与一排代码走查网络结构的技巧
调模型时,我不建议每次都套公式硬算。更高效的方式是用几个默认值做心算:
- 核 3、步长 1、填充 1:尺寸不变。
- 核 3、步长 2、填充 1:尺寸近似减半,奇数输入向上取整。
- 核 3、步长 1、填充 0:尺寸减 2。
- 核 5、步长 2、填充 2:尺寸近似减半。
这套心算规则能让你在读别人代码时,快速判断某一层大致把特征图缩小了多少倍。如果心算结果和预期不一致,再回到公式细算。如果心算没发现问题,但运行时报错,那就用批量打印 shape 的方式扫一遍,把每个中间结果都打出来,一眼就能看到是哪一层出了岔子。
我把常见现象整理成速查表,方便你对照排查:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 输出比预期小 1 | 步长无法整除时用四舍五入替代向下取整 | 换成整除运算重新计算 |
| 输出不变但和手算不一致 | 自动填充策略下 P 不是固定值 | 打印实际输出 shape |
| 维度为 0 或负数 | 核大小接近或超过输入,且无填充 | 增加填充或缩小核 |
| 全连接层维度不匹配 | 卷积池化某一步尺寸被算错 | 从第一层开始逐层打印 shape |
| 分割任务上采样错位 | 转置卷积 output_padding 缺失 | 检查恢复尺寸是否为目标值 |
最后再分享一个我自己的习惯:每次写好一个带卷积结构的模型,第一件事不是直接训练,而是先用假数据跑一次前向,把每一层的输出尺寸打印出来,对照设计表逐层核对。很多问题在正式训练前就能被提前拦下,比起训练到一半再报错,能省下大把时间。你现在只要把 2.1 节那个公式真正想明白,再加一套打印 shape 的调试流程,以后不管遇到什么卷积变体,都不会在特征图尺寸上迷失方向。