资讯动态

转置卷积与反卷积原理详解:上采样、尺寸公式及棋盘效应

发布时间:2026/10/2 19:21:52 来源:尧图企业网站定制
第一次在代码里敲下nn.ConvTranspose2d的时候我盯着这个类名看了很久卷积明明是拿来做下采样的怎么还有个转置的版本后来在语义分割、超分辨率、自编码器解码器这些任务里反复用它做上采样才算把这东西彻底摸透。反卷积Transposed conv / deconv这个名字起得相当有误导性它既不是把卷积反过来也不是卷积的逆运算本质上是把卷积的运算矩阵做了一次转置。这篇就把我这些年理解的路径完整讲一遍矩阵视角怎么推、输出尺寸怎么算、步长大于 1 时底层到底发生了什么、PyTorch 里怎么亲手验证、以及那个让无数人翻车的棋盘效应从哪来。不管你是刚接触 CNN 的新手还是已经用过几百次却说不清原理的老手应该都能从里面捡到点东西。1. 先给反卷积这个名字去魅1.1 它和卷积的逆没有半点关系信号处理领域里确实有一个正经的反卷积deconvolution概念已知系统的输出和系统本身的冲激响应反过来求输入信号。但那是个典型的病态逆问题因为噪声和带宽限制会让解不唯一工程上得靠正则化、迭代求解才能勉强拿到一个近似结果。深度学习里的deconv跟这套东西一点关系都没有反字纯属历史遗留的叫法。我用一个最直白的例子来说明为什么它不可能是逆运算。假设输入是一张 4x4 的特征图经过一个 3x3、stride2、padding0 的普通卷积输出是 1x1。16 个数被压缩成 1 个数信息在这个过程中已经丢了。你手里只有这 1 个数想反回 16 个数数学上是不可能的——存在无穷多组 4x4 输入都能产生同一个 1x1 输出。转置卷积做的事情只是把这 1 个数按核的形状重新散回 9 个位置形状上回到 3x3散出来的值跟原来那 16 个数没有任何数值对应关系。所以别被名字骗了转置卷积解决的是形状匹配问题不是信息恢复问题。想清楚这一点后面很多困惑都会自动消失。1.2 工程里为什么非要它上采样的刚需有一类任务天然需要把小图放大语义分割要输出和原图同分辨率的逐像素分类图超分辨率要把低清图变成高清图GAN 的生成器要从一个噪声向量长出一张完整图像自编码器的解码器要把压缩后的瓶颈特征还原回原尺寸。这些场景的共同点就是中间层特征图越来越小最后必须放大回来。放大特征图的方案大致分两类。一类是固定权重的插值比如最近邻nearest、双线性bilinear、双三次bicubic它们没有任何可学习参数放大规则完全由坐标距离决定。另一类是可学习的上采样转置卷积和 PixelShuffle 都属于这一阵营。插值的优点是稳、快、不会引入奇怪的伪影缺点是网络学不到这个地方应该怎么放大只能被动接受固定的插值核。转置卷积的价值就在这儿它的卷积核是参数可以在训练中更新。网络完全有能力学出在通道维度上把信息重新铺开的模式而不是死板地按距离加权。FCN 这类早期的分割网络之所以大量使用它就是看中了这种可学习性。1.3 deconv / transposed conv / fractionally strided conv 三个名字的由来同一个操作在文献里有三个常见叫法搞清楚它们各自站在什么角度能省下大量读论文时的困惑。deconv是最早的叫法FCN 那篇论文里就直接写成 deconvolution后来的综述文章反复吐槽这个命名不严谨但习惯已经养成了社区里照样大量使用。transposed conv描述的是运算本质——它做的确实是某个卷积矩阵的转置乘法这个叫法在数学上最站得住脚。fractionally strided conv则是从步长视角切入的stride2 的转置卷积等价于 stride1/2 的普通卷积也就是分数步长卷积这个视角在解释输出尺寸公式时特别顺手。我个人的习惯是写代码时用ConvTranspose跟人讨论原理时说转置卷积解释步长行为时提一嘴分数步长。三个名字指的是同一个东西不用纠结哪个更正宗。2. 矩阵乘法视角一次卷积就是一乘一个稀疏矩阵2.1 把 4x4 图像和 3x3 卷积写成 y Cx想彻底搞懂转置卷积绕不开把卷积写成矩阵乘法这一步。我们拿一个具体的例子来推输入 x 是 4x4卷积核 w 是 3x3padding0stride1输出 y 是 2x2。把输入拉成一个 16 维列向量把输出拉成一个 4 维列向量那么这次卷积可以写成y C x其中 C 是一个 4x16 的矩阵。C 的每一行对应一个输出位置每一列对应一个输入位置。拿输出的第一个位置 y[0,0] 来说它等于核覆盖的那 9 个输入元素的加权和y[0,0] w[0,0]*x[0,0] w[0,1]*x[0,1] w[0,2]*x[0,2] w[1,0]*x[1,0] w[1,1]*x[1,1] w[1,2]*x[1,2] w[2,0]*x[2,0] w[2,1]*x[2,1] w[2,2]*x[2,2]对应到矩阵 C 的第一行就是在这 9 个位置填上对应的 w 值其余 7 个位置对应 x[0,3]、x[1,3]、x[3,*] 这些没被覆盖的全部填 0。四行加起来C 是一个高度稀疏的矩阵每一行只有 9 个非零元素非零元素的位置由滑动窗口的位置决定。这就是所谓的im2col 视角。它的妙处在于把卷积这个滑动窗口的直观操作翻译成了标准的线性代数形式。一旦写成矩阵很多性质就变得一目了然了。2.2 转置卷积就是乘 C 的转置z C^T y有了y C x转置卷积的定义就顺理成章了给它一个 4 维向量 g乘上 C 的转置得到 16 维向量 z即z C^T g。C 是 4x16C^T 就是 16x4。乘完之后维度从 4 涨回 16形状上和原来的 x 一致。从谁贡献给谁的角度看正向卷积是每个输出从 9 个输入收集信息gather转置卷积是每个输入从若干输出收集信息。C^T 的第 j 列告诉我们输出向量 g 的哪些元素会贡献给 z 的第 j 个位置贡献多少。这里有个容易忽略的细节正向卷积里 C 是稀疏的转置之后 C^T 也是稀疏的但非零元素的分布不一样。在 2.1 的例子中C^T 的每一行对应一个输入位置可能只有 1 个非零也可能有 4 个非零取决于这个输入位置被多少个滑动窗口覆盖过。覆盖次数的差异正是后面棋盘效应的根子这里先埋个伏笔。2.3 为什么反向传播里会自然长出一个 C^T如果只是数学上定义了一个转置操作其实没必要把它做成一个神经网络层。它之所以能成为常用组件是因为反向传播本来就在做这件事。考虑一个前向是y C x的网络层。当损失 L 对 y 的梯度 ∂L/∂y 传回来时根据链式法则它对 x 的梯度是∂L/∂x C^T ∂L/∂y也就是说一个前向做普通卷积的层它在反向传播时对输入的梯度计算天然就是一次乘 C^T。PyTorch 这些框架在实现卷积的 backward 时底层调用的就是转置卷积的 forward 内核。反过来如果你定义一个前向是转置卷积的层它的 backward 就变成了一次普通卷积。两者在内核实现上是同一段代码的两个方向。理解了这一层ConvTranspose2d就不再是一个凭空冒出来的类而是卷积层的梯度接口被单独封装成了一个可调用层。这也是为什么它和Conv2d在参数命名上有那么多对称的地方。2.4 手算一遍从 1x1 的输入散回 2x2 的输出纸上推演再多次不如手动算一遍。取最简单的配置输入 g 是一个 1x1 的张量值为 1卷积核 w 是 2x2四个值分别记为 a、b、c、d对应左上、右上、左下、右下stride1padding0。正向卷积 1x1 输入配 2x2 核输出是 1x1值为1*a 1*b 1*c 1*d。转置卷积反过来输入 1x1输出 2x2按 PyTorch 的定义展开z[0,0] g[0,0] * w[0,0] a z[0,1] g[0,0] * w[0,1] b z[1,0] g[0,0] * w[1,0] c z[1,1] g[0,0] * w[1,1] d换句话说输入里的每一个数都按卷积核的排布被摊到了输出的对应位置上重叠的地方就相加。这就是转置卷积最朴素的画面不是滑动窗口在收集而是每个输入元素拿着核往输出上盖章。步长大于 1 时盖章的位置会拉开间距padding 会决定章的边缘落在哪。整个过程用一句话概括输入元素按核的形状散射到输出空间重叠处求和。3. 尺寸公式与步长大于 1 时到底发生了什么3.1 输出尺寸公式逐项拆解PyTorch 文档里给出的转置卷积输出尺寸公式是H_out (H_in - 1) * stride - 2 * padding dilation * (kernel_size - 1) output_padding 1每一项都对应一个物理动作拆开看就不难记公式项含义(H_in - 1) * stride输入元素之间插入 stride-1 个零后首尾元素之间的间距1把首元素本身占的那一格补回来得到插零后的总长度(H_in-1)*stride 1- 2 * padding输出两端各裁掉 padding 圈 dilation * (kernel_size - 1)核本身在输出上撑开的总宽度 output_padding只补形状不补数值用于凑出需要的尺寸把 dilation1 的情况代进去简化一下H_out (H_in - 1) * stride - 2 * padding kernel_size output_padding。这个简化版覆盖了绝大多数实际用法可以当成日常心算的口诀。举几个常见配置验证一下。kernel4, stride2, padding1, output_padding0输入 4x4(4-1)*2 - 2 4 6 - 2 4 8正好翻倍这是 DCGAN 里经典的配置。kernel3, stride2, padding1, output_padding1输入 4x4(4-1)*2 - 2 3 1 6 - 2 3 1 8同样翻倍。两种配置都能实现 2 倍上采样但底层的覆盖模式完全不同这个差异在讲棋盘效应时会体现出来。3.2 步长等于 1补零和翻转的两个小细节stride1 时转置卷积有一个非常干净的等价形式先对输入补kernel_size - padding - 1圈零再拿 180 度翻转后的核做一次普通卷积stride1。翻转这一步很多人会漏掉。因为 PyTorch 的Conv2d实现的是互相关cross-correlation没有翻转核而转置关系要求的是真正的卷积所以核必须翻转 180 度才能对上。补零的圈数也值得记一下kernel_size - padding - 1。当 kernel3、padding1 时补零圈数是 1当 kernel3、padding0 时补零圈数是 2。我建议你拿张纸算一个 3x3 输入的简单例子把两种做法直接调用ConvTranspose2d和使用补零 翻转卷积的结果逐位对比一遍。亲手对过一次之后这个等价关系就再也不会忘了。3.3 步长大于 1把输入撑开再插零stride1 的直觉不能直接套到 stride2 上因为元素间距变了。stride1 的正确画面是这样的先在输入的相邻元素之间插入 stride-1 个零把输入撑开然后再按上一节的方式补零 翻转卷积处理。举个例子输入 5x5stride2那么在相邻元素之间各插 1 个零撑开后的尺寸是(5-1)*2 1 9变成 9x9。此时 9x9 里只有第 0、2、4、6、8 行和第 0、2、4、6、8 列是原来的输入值其余全是零。接下来用 kernel3、padding 换算后的补零圈数做一次普通卷积就能得到转置卷积的输出。这个视角解释了分数步长卷积这个名字从撑开后的 9x9 来看核是每次移动 1 格的普通卷积但如果站在原始 5x5 输入的角度看核每次跨过的距离是 2只不过中间那些位置的数据是零。等效的步长变成了 1/2。3.4 output_padding 只对形状负责不对数值负责output_padding是个特别容易踩坑的参数。它的作用是在输出的右侧和下方额外补上若干行/列让输出尺寸变大但这些多出来的位置上的数值是未定义的实际实现里是参与计算的只是用了一种不对称的规则并不是多算了一部分内容。两条硬性规则必须记住一是output_padding必须严格小于stride填大了 PyTorch 直接报错二是它只能用来微调尺寸不能指望它改变特征质量。典型使用场景是这样的输入 7x7kernel3, stride2, padding1算出来(7-1)*2 - 2 3 13如果你的解码器希望恢复到 14x14就加output_padding1凑到 14。如果目标本来就是 13那就别加。配合Conv2d做对称的编码器-解码器时我一般会遵循一条经验规则解码器的kernel_size、stride、padding完全照抄编码器output_padding由希望恢复的目标尺寸倒推。这样形状一定对得上不用反复试。4. PyTorch 里亲手把转置卷积拆开验证4.1 ConvTranspose2d 参数与形状对照表先把参数表过一遍重点是权重的形状这块最容易和Conv2d记混参数说明in_channels输入通道数对应上层输出的通道数out_channels输出通道数对应上层的输入通道数kernel_size核大小stride步长决定上采样倍数padding两侧裁掉的圈数output_padding输出侧的额外补边必须小于 stridegroups分组数与Conv2d用法一致bias是否带偏置dilation空洞率权重形状是(in_channels, out_channels / groups, kH, kW)。注意这里和Conv2d的(out_channels, in_channels / groups, kH, kW)相比前两维是互换的。这个设计不是随便定的它让ConvTranspose2d(a, b)的权重可以直接从Conv2d(b, a)继承过来做梯度一致性验证时特别方便。配对使用时的通道关系也顺手记一下如果编码器是Conv2d(4, 8, ...)解码器就写ConvTranspose2d(8, 4, ...)通道数是反着来的核参数保持一致。4.2 用零插值加翻转卷积复现逐位比对光看文档不够跑一遍代码才有底。下面这段直接把 3.2 节说的等价形式实现出来和ConvTranspose2d逐位对比import torch import torch.nn as nn import torch.nn.functional as F torch.manual_seed(1) C, k, s, p 1, 3, 2, 1 deconv nn.ConvTranspose2d(C, C, k, strides, paddingp, biasFalse) x torch.randn(1, C, 5, 5) y deconv(x) print(ConvTranspose2d 输出形状:, y.shape) # (1, 1, 9, 9) # 手动等价实现 up torch.zeros(1, C, (5 - 1) * s 1, (5 - 1) * s 1) # 9x9 up[:, :, ::s, ::s] x # 偶数位置填原值其余为零 w deconv.weight.data # (C, C, k, k) w_flip torch.flip(w, dims[2, 3]) # 核翻转 180 度 y_manual F.conv2d(up, w_flip, paddingk - p - 1) # 补零后做普通卷积 print(手动实现输出形状:, y_manual.shape) print(逐位最大误差:, (y - y_manual).abs().max().item())跑下来误差应该在 1e-6 量级浮点精度范围内。这里的三个关键数字串起来就是插零后的边长(5-1)*21 9补零圈数k - p - 1 1最终输出9 2*1 - 3 1 9。全部对上。注意torch.flip千万别省。我第一次写验证代码时忘了翻转结果误差大得离谱查了半小时才发现问题出在这儿。因为F.conv2d做的是互相关要等价于转置卷积必须先把核转 180 度。4.3 用 unfold / fold 验证伴随关系看清覆盖次数还有一条更优雅的验证路径F.unfold和F.fold。unfold是把滑动窗口展开成列im2colfold是把列叠回图像。这两个操作互为伴随adjoint正好对应卷积与转置卷积的关系。import torch import torch.nn.functional as F torch.manual_seed(0) x torch.randn(1, 1, 4, 4) cols F.unfold(x, kernel_size3, padding1) # (1, 9, 16) back F.fold(cols, output_size(4, 4), kernel_size3, padding1) counts (back / x)[0, 0] print(counts.round())跑完你会看到counts长这样tensor([[4., 6., 6., 4.], [6., 9., 9., 6.], [6., 9., 9., 6.], [4., 6., 6., 4.]])这张表极其重要它告诉我们4x4 输入里正中间的位置被 9 个滑动窗口覆盖过边缘被 6 个覆盖四个角只被 4 个覆盖。fold(unfold(x))的结果不是 x而是 x 乘以这张覆盖次数表。这解释了为什么转置卷积的输出在边缘和中心的强度天然不均匀——不是实现有 bug是数学结构决定的。再回头看 2.2 节提到的 C^T 稀疏性你会发现那张覆盖次数表就是 C^T 每一行非零元素个数的二维展开。同一个东西两个视角。5. 棋盘效应最常见也最容易被忽略的坑5.1 用全 1 权重把棋盘纹路算出来棋盘效应checkerboard artifact是转置卷积最出名的副作用表现为生成图像上出现规则网格状的明暗不均。要理解它的来源最简单的办法是把权重全设成 1输入全设成 1然后看输出长什么样import torch import torch.nn as nn deconv nn.ConvTranspose2d(1, 1, kernel_size3, stride2, padding1, biasFalse) deconv.weight.data torch.ones_like(deconv.weight.data) x torch.ones(1, 1, 6, 6) y deconv(x) print(y[0, 0])输出会呈现出 1 和 4 交替的规律网格。为什么会这样回忆 3.3 节的插零视角输入被撑开成 11x11偶数位置是 1奇数位置是 0。全 1 的 3x3 核在每个输出位置累加覆盖范围内的值。落在三个偶数行 × 三个偶数列上的位置能累加 9 个 1落在两个偶数行的位置只能累加 6 个落在边缘的位置更少。这种周期性的覆盖差异就是棋盘纹。实际训练时权重是随机的不会这么规整但覆盖次数的周期性差异依然存在网络只能通过调整权重大小去补偿补偿不彻底就会在输出上留下网格状的痕迹。5.2 现象、成因、解法速查表我整理了一张速查表处理这类问题时可以直接对照现象直接成因处理办法输出有规则网格明暗纹kernel_size 不能被 stride 整除覆盖次数周期性波动改用kernel_size stride的整数倍比如 stride2 配 kernel4边缘发暗、中心偏亮边缘位置的覆盖次数低于中心见 4.3 的覆盖次数表网络层数够深时可以淡化对边缘敏感的任务改用插值上采样高层特征上采样后出现高频噪点转置卷积的核学到了高频模式换成interpolate Conv2d把平滑和学习解耦输出尺寸总差 1output_padding没配对按 3.1 的公式反推或者让解码器完全复刻编码器的核参数关于第一条有个经验结论可以直接抄stride2 时用 kernel4、padding1覆盖次数完全均匀这是 DCGAN 那套配置长盛不衰的原因。相比之下kernel3, stride2, padding1虽然在尺寸上更好凑配上 output_padding 也能翻倍但覆盖模式是波动的。5.3 我排查这类问题时固定的三个动作第一个动作是把权重和输入都置成全 1单独看一层。这一步能把结构性问题和训练问题区分开。如果全 1 输入就已经出现网格那是核和步长的搭配问题如果全 1 输入是平的但实际输出有网格那多半是权重学歪了得从损失函数或正则化上想办法。第二个动作是打印覆盖次数矩阵就像 4.3 节那样。看到边界 4、内部 9 这种差异心里就有数了。如果这个矩阵本身就不均匀那么无论怎么训输出的边缘都会比中心弱一档。第三个动作是做一次 A/B 替换把转置卷积换成nn.Upsample(scale_factor2, modenearest) nn.Conv2d(...)其他都不动重新训一遍对比。如果网格消失了、指标也没掉那就说明原来的转置卷积确实是噪声源应该换掉。这个对照实验我做过好几次结论相当一致在中低分辨率任务上插值加卷积的方案反而更稳。6. 上采样方案怎么选反卷积不是唯一答案6.1 四种主流方案横向对比把常见的上采样方案摆在一起看各自的性格就清楚了方案可学习参数计算开销输出平滑度典型问题最近邻插值无极低差有块状感边缘锯齿明显双线性插值无低好表达能力受限转置卷积有中等取决于核配置覆盖不均导致棋盘效应PixelShuffle配合前置卷积中等好需要通道数是倍数的平方PixelShuffle 的原理值得一提它把C * r^2个通道的数据重排成C个通道、空间尺寸放大r倍整个过程没有任何插值运算纯粹是内存重排。前置一个普通卷积让它自己学出该往哪些通道里放信息效果通常比转置卷积干净。超分辨率领域里的很多网络就是这套思路亚像素卷积sub-pixel convolution说的也是它。6.2 我在不同任务里的取舍记录分割任务我一般用双线性插值加卷积。分割对边缘的平滑度要求高棋盘效应在逐像素分类图上表现为边界处的分类抖动非常影响观感。插值先做一次平滑上采样再用一个 3x3 卷积融合通道信息成本低、结果稳训起来也省心。生成任务里我倾向按 stride 和 kernel 的搭配来定。如果非要用转置卷积就把kernel_size设为stride的整数倍让覆盖次数均匀。如果模型对纹理质量敏感我会先试 PixelShuffle实在不行再退回转置卷积。超分辨率任务基本就是 PixelShuffle 的主场。放大倍数固定2x、4x通道数的重排关系清清楚楚也不会有插值带来的模糊。还有一个容易被忽略的场景如果上采样倍数不大比如 2 倍以内双线性插值 卷积的表现往往不比转置卷积差还少一堆调参负担。我早期做项目时总觉得可学习的一定更好后来发现很多时候只是徒增了训练的不稳定性。选型这事还是得看具体任务和数据别迷信某个层。最后分享一个我踩过好几次的坑ConvTranspose2d的padding和output_padding一定要和编码器那边的参数成对回忆。有时候改了一处编码器的 stride忘了同步解码器模型照样能训、损失照样下降只是重构出来的形状悄悄偏了一位直到可视化的时候才发现对齐歪了。养成编码器参数改了就在解码器旁边写注释的习惯能省掉不少返工。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑