ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

卷积网络Padding深度解析:Valid与Same的原理、计算与工程实践

卷积网络Padding深度解析:Valid与Same的原理、计算与工程实践

1. padding 到底在解决什么问题:从尺寸收缩到边缘信息丢失

刚接触 CNN 卷积网络的时候,很多人对 padding 的第一印象是“给图片外围补一圈 0”,好像只是为了凑一个尺寸,让代码不报错。但实际上,padding 要解决的是卷积操作带来的两个非常实际的副作用,理解了这两个问题,后面看 Valid 和 Same 两种方式才能顺理成章。

1.1 一个 3x3 卷积给特征图带来的两个“副作用”

先说第一个副作用:尺寸收缩。假设输入是一张 28x28 的单通道灰度图,用 3x3 卷积核、步长 stride=1 去做卷积,结果特征图会变成 26x26,也就是每个维度都缩小了 2 个像素。如果网络层数深,比如 20 层卷积堆下去,特征图会缩得面目全非,到最后可能连 5x5 都不剩,很多后续任务根本没法做。

第二个副作用更隐蔽:边缘信息被系统性丢弃。卷积核虽然每次滑动都会带到一个像素,但边缘像素在整个卷积过程中只被“碰到”很少的次数,而中心像素会被很多次卷积窗口覆盖。你可以把它理解成:同样一块地,中心区域被翻了很多遍,边角区域只被路过了一次。结果就是,卷积网络天然偏向中心位置的特征响应,边界信息几乎等于被放弃。对于图像分类任务,这种偏向不一定致命,但对分割、检测、边缘提取这类需要精细空间位置的任务,影响就非常直接了。

padding 这个词直译过来是“填充”。它的作用就是在每个特征图的四周补上一些像素(通常是 0),让卷积核在滑动的时候能多覆盖一些边界位置,同时把输出尺寸控制在网络设计者想要的范围内。如果不填充,对应的就是 Valid 方式;如果填充到输出尺寸和输入一样(步长为 1 时),对应的就是 Same 方式。

1.2 Valid 与 Same 的定义和第一印象

先给一个能直接记住的定义:

  • Valid Padding:不填充。卷积核只在“能完整覆盖”的位置滑动,输出尺寸永远小于或等于输入尺寸,公式是floor((H - k) / s) + 1。
  • Same Padding:填充后在步长为 1 时保持输出尺寸等于输入尺寸(所以叫 Same,意思是和输入一样大);当步长大于 1 时,输出尺寸约等于输入尺寸除以步长并向上取整,即ceil(H / s)。

只看字面,Same 更像是“省心模式”,你不需要手算尺寸,框架会自动帮你填充;Valid 则更“原教旨”,卷积核老老实实从头滑到尾,不额外加东西。

但“省心”不等于“更好”。我在实际项目里见过很多初学者把 padding 一律设成 Same,结果模型训练特别容易过拟合,或者在小目标检测任务里出现奇怪的边界响应。也有一些网络设计很偏向 Valid,比如某些生成模型的判别器,刻意缩小特征图,为的是扩大感受野同时减少计算量。所以这两种方式没有绝对的优劣,只看你的任务需要保留多少空间信息。

2. 两种 padding 的计算逻辑与数学推导

理解了 padding 解决什么问题之后,下一步就是手算尺寸。不要觉得这是简单功夫,代码里很多隐蔽 bug 都来源于尺寸对不上,尤其是 stride、dilation、padding 三个参数组合在一起的时候,公式算错一步,调试一小时。

2.1 输出尺寸公式的完整展开与手算案例

我习惯用一个总公式来理解所有情况:

output_size = floor((H + pad_top + pad_bottom - dilation * (kernel_size - 1) - 1) / stride) + 1

Valid 就是pad_top = pad_bottom = 0;Same 的目标是当stride = 1时output_size == H,所以需要:

pad_top + pad_bottom = dilation * (kernel_size - 1)

这里要特别留意,Same 并不总是对称填充。比如 kernel_size=3、dilation=2 时,等效核大小是2 * (3-1) + 1 = 5,需要填充的总行数是 4,那就应该上方 2 行、下方 2 行。如果总填充数是奇数,比如 kernel_size=4(不对称核),那上方补 1、下方补 2、左侧补 1、右侧补 2 都是有可能的。所以当你看到某个框架的输出不是预期尺寸时,第一个检查点就是它到底把多余的 padding 放在了哪一侧。

我拿一个常见情况手算一遍。输入 5x5,卷积核 3x3,步长 1:

  • Valid:输出floor((5 - 3) / 1) + 1 = 3,特征图变成 3x3。
  • Same:需要pad_total = 3 - 1 = 2,上下各补 1 行,左右各补 1 列,填充后输入变成 7x7,卷积后输出floor((7 - 3) / 1) + 1 = 5,正好回到 5x5。

再算一个有 striding 的情况:输入 6x6,卷积核 3x3,步长 2:

  • Valid:输出floor((6 - 3) / 2) + 1 = 2。
  • Same:需要保持ceil(6 / 2) = 3,填充后输入长度要满足floor((H_pad - 3) / 2) + 1 = 3,即floor((H_pad - 3) / 2) = 2,取H_pad = 7满足(因为 floor((7-3)/2)=2),所以要补 1 行。结果输出 3x3。

从这几个手算案例能看出,Same 在 stride=1 时补得比较“对称整齐”,但在 stride>1 时并不保证上下左右完全相等,这也是很多人感觉“框架自动填充挺神秘”的真正原因。

2.2 同一网络里两种 padding 的边缘响应差异

光算尺寸还不够,还得看实际卷积响应。我试着用一个极简例子说明这件事。

假设输入是 3x3,取值分别是:

1 2 3 4 5 6 7 8 9

卷积核是 3x3 全 1 的矩阵,步长 1。Valid 不填充,输出只有一个值,就是整个矩阵所有元素求和,等于 45。也就是说,输入的所有像素对输出的贡献完全相同,但输出只有 1 个值,空间信息被压缩得干干净净。

Same 模式下,先在外围补一圈 0,输入变成 5x5:

0 0 0 0 0 0 1 2 3 0 0 4 5 6 0 0 7 8 9 0 0 0 0 0 0

这时卷积核会滑动 9 个位置,输出 3x3。注意观察最左上角的输出,它对应的窗口是:

0 0 0 0 1 2 0 4 5

结果是 12。这个值完全由原图的左上角 2x2 区域贡献,边缘像素只在少数窗口中参与计算,权重占比被 0 填充“稀释”。但不管怎么说,边缘信息确实进到了输出里,这是 Valid 模式完全做不到的。

我自己的理解是:Same padding 相当于给边缘像素多创造了几次被卷积核“看到”的机会,尽管这些机会通常会混入 0 值。对于特征提取来说,有信息总比没有信息好,尤其是网络较深时,边缘信息哪怕弱一点,经过多层非线性变换也可能被增强。

3. 在主流框架中复现两种 padding:代码与输出尺寸对照

理论说清楚了,不落到代码里总隔一层。这个部分我用 PyTorch 和 Keras/TensorFlow 分别演示 Valid 和 Same,并对比输出尺寸和实际填充方式。建议你在自己的环境里跑一遍,代码很简单,但能帮你建立起“公式到代码”的映射。

3.1 PyTorch 里 padding 参数与手写零填充

PyTorch 的nn.Conv2d直接支持padding参数,默认为 0(也就是 Valid)。想用 Same 的话,最省事的方法是设置padding='same',这要求stride=1才能严格保持输出等于输入;stride>1 时会自动计算合适的填充。为了看清楚填充逻辑,我更喜欢用F.pad手动做一次,这样能直观看到边界补了什么。

import torch import torch.nn.functional as F x = torch.arange(9, dtype=torch.float32).reshape(1, 1, 3, 3) print("输入:") print(x[0, 0]) # Valid 卷积 conv_valid = torch.nn.Conv2d(1, 1, kernel_size=3, stride=1, padding=0) with torch.no_grad(): conv_valid.weight.copy_(torch.ones_like(conv_valid.weight)) conv_valid.bias.zero_() y_valid = conv_valid(x) print("Valid 输出:", y_valid.shape) print(y_valid[0, 0]) # Same 卷积:手动补零后卷积 x_padded = F.pad(x, (1, 1, 1, 1), mode='constant', value=0) print("手动填充后的输入:") print(x_padded[0, 0]) with torch.no_grad(): y_same_manual = conv_valid(x_padded) # 复用同一个卷积核 print("Same 手工填充输出:", y_same_manual.shape) print(y_same_manual[0, 0]) # Same 卷积:使用内置 padding='same' conv_same = torch.nn.Conv2d(1, 1, kernel_size=3, stride=1, padding='same') with torch.no_grad(): conv_same.weight.copy_(torch.ones_like(conv_same.weight)) conv_same.bias.zero_() y_same = conv_same(x) print("Same 内置输出:", y_same.shape) print(y_same[0, 0])

跑完这段代码你会发现,F.pad手动补零得到的输出和padding='same'完全一致。更重要的是,你能直观看到补零后的输入长什么样——所有外围都是 0,边界像素只被卷积核“半覆盖”一次。这里我习惯把卷积核权重复制为全 1,排除随机初始化对观察的影响。

有一个细节要注意:PyTorch 的padding='same'在stride=1时没问题,但如果stride=2,它的行为是尽量让输出为ceil(H / stride),但并没有严格保证左右上下完全对称。真需要精确控制填充位置时,我会直接用F.pad手工定义填充量,再配合普通的padding=0卷积,这样每一侧补多少都完全可控。

3.2 Keras / TensorFlow 里 valid 和 same 的行为差异

Keras 的Conv2D提供了字符串形式的padding='valid'或padding='same',行为比 PyTorch 更“固定”。直接看示例:

import tensorflow as tf # 构建一个固定权重的卷积层 layer_valid = tf.keras.layers.Conv2D( filters=1, kernel_size=3, strides=1, padding='valid', kernel_initializer=tf.keras.initializers.ones(), use_bias=False ) layer_same = tf.keras.layers.Conv2D( filters=1, kernel_size=3, strides=1, padding='same', kernel_initializer=tf.keras.initializers.ones(), use_bias=False ) x = tf.reshape(tf.range(49, dtype=tf.float32), (1, 7, 7, 1)) y_v = layer_valid(x) y_s = layer_same(x) print("Valid 输出尺寸:", y_v.shape) # (1, 5, 5, 1) print("Same 输出尺寸:", y_s.shape) # (1, 7, 7, 1)

这里需要注意,TensorFlow 的 padding 是作用在整个高度和宽度方向上的。对于 kernel_size=3、stride=1,Same 模式下它会在上/下各补一行、左/右各补一列。如果 kernel_size=4 呢?Keras 会怎么分配奇数填充?实测发现它倾向于把多出来的 1 放在右下侧。所以如果你在处理要求“上下严格对称”的任务,用 Keras 的 Same 也要小心,尽量用奇数尺寸卷积核,避免非对称填充带来的干扰。

另外,TensorFlow 在conv2d底层还有一个EXPLICIT模式,可以精确指定padding的数量,格式是[[0, 0], [top, bottom], [left, right], [0, 0]]。我一般在部署到移动端或写自定义算子时才会用到,日常实验用字符串模式就够了。

3.3 带 dilation 和 stride 的复杂场景验证

很多人以为 padding 只在普通的 3x3 stride=1 卷积里才有效,一遇到空洞卷积就蒙了。空洞卷积的等效核大小是kernel_size + (kernel_size - 1) * (dilation - 1),比如 kernel_size=3、dilation=2 时,等效核是 5,Same 需要的总填充就是5 - 1 = 4。

我自己验证过一个案例。输入 16x16,卷积核 3x3,dilation=2,stride=1:

  • Valid 输出:floor((16 - 5) / 1) + 1 = 12
  • Same 输出:16x16,需要在四周补 4 行/列。

用 PyTorch 代码验证:

x = torch.randn(1, 1, 16, 16) conv_d = torch.nn.Conv2d(1, 1, 3, stride=1, padding=4, dilation=2) print(conv_d(x).shape) # torch.Size([1, 1, 16, 16])

如果这里把 padding 错设成 2,输出就会是 14x14,完全达不到保持尺寸的目的。这个案例给我的教训是:不要死记“Same 就是 padding=kernel_size//2”这种速记口诀,遇到空洞卷积必须用等效核大小参与计算。

4. 真实项目里最容易踩的 padding 坑

代码写法都是小事,真正让人头疼的是框架语义不一致和网络设计时的隐性问题。这个部分我整理了几个高频坑,全部来自实际项目里遇到过的报错或异常结果。

4.1 padding 在转置卷积、池化、空洞卷积中的语义混淆

第一个易错点是转置卷积(反卷积)。在Conv2dTranspose里,padding 的语义是反过来的——padding 越大,输出反而越小。这是因为转置卷积本质上是卷积的逆向操作,padding 相当于“抵消一部分扩边”。如果拿普通卷积的直觉去套,很容易把特征图尺寸算错。

第二个易错点是池化层。PyTorch 里MaxPool2d的默认padding=0,Keras 的MaxPooling2D默认也是valid。但池化的 padding 影响和卷积不太一样,池化只取局部最大值,补 0 之后,那些原本没有有效像素的位置会被 0 占据,如果某个窗口覆盖的几乎全是 0,输出就会偏向 0,这会对后续激活函数产生非预期影响。

我遇到过一个真实场景:在某图像分割模型里,下采样路径用 stride=2 的卷积做 Same padding,一路把 512x512 降到 16x16,上采样路径再用转置卷积恢复。因为下采样和上采样两边的 padding 理解不一致,跳连接拼接时特征图尺寸对不上,报错报得我找了好久才发现是转置卷积里 padding 参数设反了。所以我的建议是:每个包含 padding 的层,都单独写下预期输出尺寸,不要只靠框架自动推断。

4.2 选 valid 还是 same:我的一些项目和调参经验

从项目实践角度,我给一个比较“实操”的选择建议:

  • 需要严格保持空间尺寸,比如分割、检测,或者深层网络想保留高分辨率特征图的,首选 Same。
  • 想逐步缩小特征图但又不希望用太多池化层,可以用 stride>1 的卷积配合 Valid,每层降采样也能顺便增加感受野。
  • 训练初期出现了奇怪的边界过拟合,可以试试 Valid,因为 Same 模式下边界位置信息有重复参与,神经元更容易记忆边缘噪声。
  • 如果你的数据本身做了很多随机裁剪、翻转、旋转之类的增强,Same padding 的边界统计偏差会被增强操作进一步放大,这一点在训练人脸关键点或小目标检测时尤其明显。

我的习惯是:分类网络用 Same + stride=2 做下采样;生成模型特别是图像修复、超分这类任务,优先 Valid 或者少量填充,因为补 0 容易在边界产生暗带或伪影。之前做一个图像去噪项目时,训练数据全是中心裁剪的,网络用了 Same padding,结果测试时原图直接喂进去,边缘出现了一条明显的噪声残留带。后来改成 Valid padding,边缘反而干净了不少。

4.3 边缘伪影与镜像填充:图像任务里的进阶选择

最后聊一个很多人不知道的细节:补 0 并不是唯一的 padding 方式,还有镜像填充(reflect)、重复边界的复制填充(replicate)。对于图像领域,零填充虽然简单,但会在边界引入较大的梯度跳变,因为 0 和真实像素之间差异很大。尤其在图像生成、修复、风格迁移这类任务里,零填充经常导致边缘伪影。

PyTorch 里可以用nn.ReflectionPad2d或F.pad(..., mode='reflect')实现镜像填充,效果比零填充平滑很多。Keras 里没有直接把 padding 改成 reflect 的内置参数,通常需要先手动tf.pad或者用自定义 Lambda 层。如果你正在做图像类任务,并且发现输出图边缘有奇怪的条纹或噪点,优先检查是不是零填充导致的边界跳变,换成镜像填充大概率能改善。

我自己用过一次镜像填充是在做壁画纹理的裂缝修复模型时,输入是带边界纹理的局部块,零填充会让修复区域边缘的纹理方向被拉偏,改成反射填充后,生成结果的自然度立刻上了一个档次。当然,镜像填充也有代价:计算量稍微大一点,而且在高度、宽度维度都要单独处理,调试成本变高。

另一个容易忽略的是 padding 与 BatchNorm 的交互。卷积后面接 BatchNorm 时,Same padding 会让边界位置统计量(均值和方差)跟中心位置有明显差异。网络很浅时差异不大,一旦网络变深,边界通道的分布会逐渐偏离,影响最终训练稳定性。这种情况我见过不止一次,最后解决方案是在输入阶段做边缘增强或边缘检测特征拼接,而不是单纯依赖 padding 补信息。

5. 写在最后的实操心得

我在实际调试网络时,有一个屡试不爽的排查套路:先打印每一层的输出尺寸,跟理论计算值对照;尺寸全对但效果不对时,再可视化第一层卷积的特征图,看边缘和中心响应是否出现异常。90% 以上的 padding 相关 bug,都能被这两招快速锁定。

如果一定要给一个最简单的起步建议:默认情况下用 3x3 卷积核 + Same padding + stride 1 先把网络跑通;当模型出现边界过拟合、边缘伪影、输出尺寸异常时,再回头审视 padding 的具体位置和数值,而不是直接改网络结构。padding 这个看起来不起眼的参数,往往决定了整个网络的空间信息流,值得多花一点时间把它彻底算明白。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进