
1. padding 到底在解决什么问题从尺寸收缩到边缘信息丢失刚接触 CNN 卷积网络的时候很多人对 padding 的第一印象是“给图片外围补一圈 0”好像只是为了凑一个尺寸让代码不报错。但实际上padding 要解决的是卷积操作带来的两个非常实际的副作用理解了这两个问题后面看 Valid 和 Same 两种方式才能顺理成章。1.1 一个 3x3 卷积给特征图带来的两个“副作用”先说第一个副作用尺寸收缩。假设输入是一张 28x28 的单通道灰度图用 3x3 卷积核、步长 stride1 去做卷积结果特征图会变成 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) 1Valid 就是pad_top pad_bottom 0Same 的目标是当stride 1时output_size H所以需要pad_top pad_bottom dilation * (kernel_size - 1)这里要特别留意Same 并不总是对称填充。比如 kernel_size3、dilation2 时等效核大小是2 * (3-1) 1 5需要填充的总行数是 4那就应该上方 2 行、下方 2 行。如果总填充数是奇数比如 kernel_size4不对称核那上方补 1、下方补 2、左侧补 1、右侧补 2 都是有可能的。所以当你看到某个框架的输出不是预期尺寸时第一个检查点就是它到底把多余的 padding 放在了哪一侧。我拿一个常见情况手算一遍。输入 5x5卷积核 3x3步长 1Valid输出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步长 2Valid输出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 在 stride1 时补得比较“对称整齐”但在 stride1 时并不保证上下左右完全相等这也是很多人感觉“框架自动填充挺神秘”的真正原因。2.2 同一网络里两种 padding 的边缘响应差异光算尺寸还不够还得看实际卷积响应。我试着用一个极简例子说明这件事。假设输入是 3x3取值分别是1 2 3 4 5 6 7 8 9卷积核是 3x3 全 1 的矩阵步长 1。Valid 不填充输出只有一个值就是整个矩阵所有元素求和等于 45。也就是说输入的所有像素对输出的贡献完全相同但输出只有 1 个值空间信息被压缩得干干净净。Same 模式下先在外围补一圈 0输入变成 5x50 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 的话最省事的方法是设置paddingsame这要求stride1才能严格保持输出等于输入stride1 时会自动计算合适的填充。为了看清楚填充逻辑我更喜欢用F.pad手动做一次这样能直观看到边界补了什么。import torch import torch.nn.functional as F x torch.arange(9, dtypetorch.float32).reshape(1, 1, 3, 3) print(输入) print(x[0, 0]) # Valid 卷积 conv_valid torch.nn.Conv2d(1, 1, kernel_size3, stride1, padding0) 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), modeconstant, value0) 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 卷积使用内置 paddingsame conv_same torch.nn.Conv2d(1, 1, kernel_size3, stride1, paddingsame) 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手动补零得到的输出和paddingsame完全一致。更重要的是你能直观看到补零后的输入长什么样——所有外围都是 0边界像素只被卷积核“半覆盖”一次。这里我习惯把卷积核权重复制为全 1排除随机初始化对观察的影响。有一个细节要注意PyTorch 的paddingsame在stride1时没问题但如果stride2它的行为是尽量让输出为ceil(H / stride)但并没有严格保证左右上下完全对称。真需要精确控制填充位置时我会直接用F.pad手工定义填充量再配合普通的padding0卷积这样每一侧补多少都完全可控。3.2 Keras / TensorFlow 里 valid 和 same 的行为差异Keras 的Conv2D提供了字符串形式的paddingvalid或paddingsame行为比 PyTorch 更“固定”。直接看示例import tensorflow as tf # 构建一个固定权重的卷积层 layer_valid tf.keras.layers.Conv2D( filters1, kernel_size3, strides1, paddingvalid, kernel_initializertf.keras.initializers.ones(), use_biasFalse ) layer_same tf.keras.layers.Conv2D( filters1, kernel_size3, strides1, paddingsame, kernel_initializertf.keras.initializers.ones(), use_biasFalse ) x tf.reshape(tf.range(49, dtypetf.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_size3、stride1Same 模式下它会在上/下各补一行、左/右各补一列。如果 kernel_size4 呢Keras 会怎么分配奇数填充实测发现它倾向于把多出来的 1 放在右下侧。所以如果你在处理要求“上下严格对称”的任务用 Keras 的 Same 也要小心尽量用奇数尺寸卷积核避免非对称填充带来的干扰。另外TensorFlow 在conv2d底层还有一个EXPLICIT模式可以精确指定padding的数量格式是[[0, 0], [top, bottom], [left, right], [0, 0]]。我一般在部署到移动端或写自定义算子时才会用到日常实验用字符串模式就够了。3.3 带 dilation 和 stride 的复杂场景验证很多人以为 padding 只在普通的 3x3 stride1 卷积里才有效一遇到空洞卷积就蒙了。空洞卷积的等效核大小是kernel_size (kernel_size - 1) * (dilation - 1)比如 kernel_size3、dilation2 时等效核是 5Same 需要的总填充就是5 - 1 4。我自己验证过一个案例。输入 16x16卷积核 3x3dilation2stride1Valid 输出floor((16 - 5) / 1) 1 12Same 输出16x16需要在四周补 4 行/列。用 PyTorch 代码验证x torch.randn(1, 1, 16, 16) conv_d torch.nn.Conv2d(1, 1, 3, stride1, padding4, dilation2) print(conv_d(x).shape) # torch.Size([1, 1, 16, 16])如果这里把 padding 错设成 2输出就会是 14x14完全达不到保持尺寸的目的。这个案例给我的教训是不要死记“Same 就是 paddingkernel_size//2”这种速记口诀遇到空洞卷积必须用等效核大小参与计算。4. 真实项目里最容易踩的 padding 坑代码写法都是小事真正让人头疼的是框架语义不一致和网络设计时的隐性问题。这个部分我整理了几个高频坑全部来自实际项目里遇到过的报错或异常结果。4.1 padding 在转置卷积、池化、空洞卷积中的语义混淆第一个易错点是转置卷积反卷积。在Conv2dTranspose里padding 的语义是反过来的——padding 越大输出反而越小。这是因为转置卷积本质上是卷积的逆向操作padding 相当于“抵消一部分扩边”。如果拿普通卷积的直觉去套很容易把特征图尺寸算错。第二个易错点是池化层。PyTorch 里MaxPool2d的默认padding0Keras 的MaxPooling2D默认也是valid。但池化的 padding 影响和卷积不太一样池化只取局部最大值补 0 之后那些原本没有有效像素的位置会被 0 占据如果某个窗口覆盖的几乎全是 0输出就会偏向 0这会对后续激活函数产生非预期影响。我遇到过一个真实场景在某图像分割模型里下采样路径用 stride2 的卷积做 Same padding一路把 512x512 降到 16x16上采样路径再用转置卷积恢复。因为下采样和上采样两边的 padding 理解不一致跳连接拼接时特征图尺寸对不上报错报得我找了好久才发现是转置卷积里 padding 参数设反了。所以我的建议是每个包含 padding 的层都单独写下预期输出尺寸不要只靠框架自动推断。4.2 选 valid 还是 same我的一些项目和调参经验从项目实践角度我给一个比较“实操”的选择建议需要严格保持空间尺寸比如分割、检测或者深层网络想保留高分辨率特征图的首选 Same。想逐步缩小特征图但又不希望用太多池化层可以用 stride1 的卷积配合 Valid每层降采样也能顺便增加感受野。训练初期出现了奇怪的边界过拟合可以试试 Valid因为 Same 模式下边界位置信息有重复参与神经元更容易记忆边缘噪声。如果你的数据本身做了很多随机裁剪、翻转、旋转之类的增强Same padding 的边界统计偏差会被增强操作进一步放大这一点在训练人脸关键点或小目标检测时尤其明显。我的习惯是分类网络用 Same stride2 做下采样生成模型特别是图像修复、超分这类任务优先 Valid 或者少量填充因为补 0 容易在边界产生暗带或伪影。之前做一个图像去噪项目时训练数据全是中心裁剪的网络用了 Same padding结果测试时原图直接喂进去边缘出现了一条明显的噪声残留带。后来改成 Valid padding边缘反而干净了不少。4.3 边缘伪影与镜像填充图像任务里的进阶选择最后聊一个很多人不知道的细节补 0 并不是唯一的 padding 方式还有镜像填充reflect、重复边界的复制填充replicate。对于图像领域零填充虽然简单但会在边界引入较大的梯度跳变因为 0 和真实像素之间差异很大。尤其在图像生成、修复、风格迁移这类任务里零填充经常导致边缘伪影。PyTorch 里可以用nn.ReflectionPad2d或F.pad(..., modereflect)实现镜像填充效果比零填充平滑很多。Keras 里没有直接把 padding 改成 reflect 的内置参数通常需要先手动tf.pad或者用自定义 Lambda 层。如果你正在做图像类任务并且发现输出图边缘有奇怪的条纹或噪点优先检查是不是零填充导致的边界跳变换成镜像填充大概率能改善。我自己用过一次镜像填充是在做壁画纹理的裂缝修复模型时输入是带边界纹理的局部块零填充会让修复区域边缘的纹理方向被拉偏改成反射填充后生成结果的自然度立刻上了一个档次。当然镜像填充也有代价计算量稍微大一点而且在高度、宽度维度都要单独处理调试成本变高。另一个容易忽略的是 padding 与 BatchNorm 的交互。卷积后面接 BatchNorm 时Same padding 会让边界位置统计量均值和方差跟中心位置有明显差异。网络很浅时差异不大一旦网络变深边界通道的分布会逐渐偏离影响最终训练稳定性。这种情况我见过不止一次最后解决方案是在输入阶段做边缘增强或边缘检测特征拼接而不是单纯依赖 padding 补信息。5. 写在最后的实操心得我在实际调试网络时有一个屡试不爽的排查套路先打印每一层的输出尺寸跟理论计算值对照尺寸全对但效果不对时再可视化第一层卷积的特征图看边缘和中心响应是否出现异常。90% 以上的 padding 相关 bug都能被这两招快速锁定。如果一定要给一个最简单的起步建议默认情况下用 3x3 卷积核 Same padding stride 1 先把网络跑通当模型出现边界过拟合、边缘伪影、输出尺寸异常时再回头审视 padding 的具体位置和数值而不是直接改网络结构。padding 这个看起来不起眼的参数往往决定了整个网络的空间信息流值得多花一点时间把它彻底算明白。