
1. 从一次上采样踩坑说起第一次接触反卷积这个概念是在做一个图像分割的小项目时。当时我需要把特征图从 16×16 恢复到 256×256脑子里第一反应是“这不就是把卷积倒过来嘛”于是随手在框架里调了个ConvTranspose2d参数照着卷积抄了一遍结果训练出来的分割掩码边缘全是棋盘状的方格纹路一块一块的像是被人拿马赛克笔刷涂过。调了两天学习率、换了优化器都没用最后才发现问题根本不在训练策略上而是我对反卷积的理解从一开始就偏了。这件事让我意识到反卷积Transposed Convolution / Deconvolution这个名字本身就带有强烈的误导性。它并不是卷积的数学逆运算也不能真正“恢复”出卷积之前的信息。它做的事情本质上是一种带可学习参数的上采样——把小的特征图按一定规则铺开、填充、再卷积得到一个更大的输出。理解这一点是后面所有参数调优、棋盘效应排查、网络设计的基础。这篇文章想解决的问题很具体为什么反卷积会有棋盘效应它的输出尺寸到底怎么算output_padding、stride、padding这几个参数到底在干什么为什么有人用反卷积做超分辨率有人却坚决用双线性插值加卷积替代它我会从最直观的手算例子开始一步步把内部机制拆开再给出可以直接复现的 PyTorch 代码、参数计算表、以及我在实际项目里踩过的坑和最终的选型结论。无论你是刚学深度学习的学生还是已经在做生成模型、语义分割的工程师看完应该都能对这块有个清晰的认识至少不会再像我当初那样对着一个方块状输出怀疑人生。整篇内容围绕反卷积、Transposed conv、deconv这三个关键词展开涉及的框架以 PyTorch 为主原理部分对所有框架通用。2. 反卷积到底是什么概念澄清与设计动机2.1 名字的误导它不是卷积的逆运算先把这个最大的坑填了。卷积操作在数学上是一个多对一的映射比如一个 3×3 的卷积核在 stride2 的情况下会把 4 个输入像素的信息压缩成 1 个输出像素。这个过程是有信息损失的多个不同的输入可能产生完全相同的输出。既然映射不是一一对应的那就不存在严格意义上的“逆运算”。你没法从卷积的输出精确还原出输入就像你没法从一张缩略图还原出原始照片里每一个像素的真实值。那为什么叫“反卷积”这个名字其实来自早期的反卷积神经网络论文和某些可视化工作后来被框架沿用下来约定俗成。学术界更严谨的叫法是转置卷积Transposed Convolution因为它和普通卷积之间的关系可以通过矩阵转置来描述。Caffe 里叫 DeconvolutionPyTorch 里叫 ConvTransposeTensorFlow 里叫 conv2d_transpose名字五花八门但指的是同一个东西。提示看到 deconv 这个词先在脑子里自动替换成“转置卷积”能避免很多概念上的混乱。2.2 它解决的核心问题可学习的上采样反卷积真正解决的问题是如何把一个低分辨率的特征图放大到高分辨率并且这个放大过程是可以通过反向传播学习的。在它出现之前上采样的常规做法是双线性插值、最近邻插值这类固定规则。这些方法的问题在于它们没有任何可学习的参数放大方式完全由人为设定的插值公式决定。对于图像分割、超分辨率、生成对抗网络里的生成器这些任务来说我们更希望网络自己学会“该怎么放大”——哪些位置该补细节哪些位置该平滑让梯度告诉它。反卷积恰好满足了这一点。它把上采样和卷积融合成一个操作先按 stride 在输入元素之间插入空洞补零再用一个卷积核去卷这个膨胀后的输入。卷积核的权重是可学习的所以整个放大过程就是可训练的。上采样方式是否有可学习参数典型用途主要缺点最近邻插值否快速原型、标签图放大块状明显无法学习双线性插值否分割网络 decoder过于平滑细节丢失反卷积是生成器、分割、超分易产生棋盘效应插值 卷积部分现代分割网络主流多一步操作像素重排PixelShuffle是超分辨率需配合前置卷积2.3 和前向卷积的对称关系理解转置卷积最有效的方式是把它看作前向卷积在梯度反向传播时的操作。这一点很关键。假设你有一个普通卷积层输入是 4×4卷积核 3×3stride1padding0输出是 2×2。在反向传播时这个卷积层需要计算输入端的梯度而它计算梯度所用的操作恰好就是一个 stride1、padding2 的转置卷积具体数值关系后面手算会讲。换句话说转置卷积不是凭空造出来的新东西它是卷积反向传播的自然产物。框架里实现它本质上就是复用了卷积的反向计算逻辑。这也解释了一个现象如果你把转置卷积的输入输出尺寸反过来用同样的超参数配置一个普通卷积两者的计算量级是对称的。这种对称性在推导输出尺寸公式时非常有用。2.4 和普通卷积的参数对照很多人第一次用反卷积时会把kernel_size、stride、padding直接照搬卷积层的配置这是错误做法的重灾区。两组参数虽然名字一样但作用方向是反的。在普通卷积里stride越大输出越小padding越大输出越大。在转置卷积里stride越大输出越大padding越大输出反而越小。这个反直觉的特性如果不理解内部机制光靠试参数会非常痛苦。我在项目里总结了一个简单的记忆方法转置卷积就是把普通卷积的输入输出关系倒过来看。普通卷积从大变小转置卷积从小变大普通卷积里让输出变小的参数在转置卷积里就是让输出变大的参数。理解了这一点参数配置就不会再凭感觉瞎调了。3. 内部机制拆解一步一步手算给你看3.1 最简情形stride1 时的转置卷积先从最简单的开始。假设输入是一个 2×2 的矩阵[[1, 2], [3, 4]]卷积核是 2×2 的全 1 核stride1padding0。为了看清楚转置卷积做了什么我用手算的方式走一遍。转置卷积的第一步是把输入元素分散铺到一个更大的画布上元素之间按 stride 留空。因为 stride1元素之间不留空画布就是输入本身尺寸 2×2[1, 2] [3, 4]第二步用卷积核在这个画布上做滑窗相乘相加。但这里的滑窗方式和普通卷积不同普通卷积是卷积核在输入上滑动、每次取一块区域计算一个输出值而转置卷积是每个输入元素分别和整个卷积核相乘把结果累加到输出画布的对应位置。拿输入元素 1 举例。它在画布的位置是 (0,0)。把卷积核2×2 全 1盖在输出画布上左上角对齐 (0,0)那么卷积核的每个权重会乘上输入值 1然后加到对应位置。因为核全是 1所以贡献出去的是一个 2×2 的全 1 块放在输出画布的 (0,0) 到 (1,1) 区域。再拿输入元素 2位置 (0,1)卷积核从 (0,1) 开始盖贡献一个 2×2 的全 1 块值都是 2累加到输出。以此类推处理元素 3 和 4。四个元素贡献的块叠加起来输出就是[1, 3, 2] [4, 10, 6] [3, 7, 4]这个 3×3 的输出不是随便来的它是每个输入值乘以核对角线叠加的结果。你可以看出原来 2×2 的输入通过这个过程变成了 3×3。3.2 stride1 时的插零操作现在把 stride 改成 2其他不变。输入还是[[1, 2], [3, 4]]stride2 意味着输入元素之间要插入 1 个零stride-11 个。铺开后的画布变成[1, 0, 2] [0, 0, 0] [3, 0, 4]这就是所谓的**插零dilation**操作。画布从 2×2 变成了 (2-1)×21 3 行 3 列。接下来用 2×2 全 1 核对这个 3×3 画布做和上面一样的分块累加。每个非零元素贡献一个 2×2 的块零元素贡献的块也全是零。叠完之后输出是 4×4 的矩阵。具体的数值这里不逐个列了关键在于输出尺寸的计算输入 2、stride 2、kernel 2、padding 0输出尺寸 (2-1)×2 - 2×0 2 4。公式对上了。理解了“插零”这一层你就明白了为什么 stride 越大输出越大——因为画布被撑得更大了滑窗累加能覆盖的范围自然就更广。3.3 padding 在转置卷积里为何“反向”作用padding 这个参数在转置卷积里的行为最容易让人困惑。在普通卷积里padding 是往输入边缘补零目的是控制输出尺寸别缩太快。在转置卷积里padding 的作用发生在“累加完成之后”它会在输出画布的四周裁掉 padding 个像素。回到 stride1、输入 2×2、核 2×2 的例子不加 padding 时输出是 3×3。如果设置 padding1那就在这个 3×3 输出的上下左右各裁掉 1 行/列剩下中心 1×1 的区域。所以输出尺寸 (2-1)×1 - 2×1 2 1。输出变成了 1×1。这就是为什么说 padding 在转置卷积里是“反向作用”的padding 越大输出越小。这个设计不是拍脑袋定的它和转置卷积作为卷积反向传播的数学性质有关——正向卷积里 padding 掉的边缘在反向传播时要对应地补回来于是体现为裁剪。3.4 output_padding补上那一点点尺寸output_padding是 PyTorch 里特有的参数TensorFlow 里对应的是output_shape它的作用是在输出画布的右下侧额外补上若干行和列。为什么需要它考虑 stride2、kernel4、padding1 这组配置。按公式算(in-1)×2 - 2×1 4 2×in。如果输入是 4输出是 8。但如果我们想要输出 9 呢按公式算出来的尺寸是固定的没法微调。这时output_padding1就能在右下侧补 1 行和 1 列让输出变成 9×9。它存在的根本原因在于转置卷积是从低分辨率推高分辨率同一个低分辨率尺寸可能对应多个高分辨率尺寸比如 4 可以对应 8、9甚至 10光靠 stride、padding、kernel 这三个参数无法唯一确定输出尺寸output_padding就是用来消歧的。注意output_padding的取值必须小于 stride否则会报错。它的作用仅仅是调整尺寸补上去的那一圈数值由卷积计算自然产生不是单纯填零。3.5 输出尺寸公式的推导与验证把前面所有情况统一到一个公式里out (in - 1) × stride - 2 × padding kernel output_padding我们逐一验证输入 instridepaddingkerneloutput_padding计算结果实际输出21020(2-1)×1-020 3322020(2-1)×2-020 4421120(2-1)×1-220 1142140(4-1)×2-240 8842141(4-1)×2-241 99公式对上了。再补充一个等价关系如果和普通卷积做对照转置卷积的 stride、padding、kernel 对应普通卷积的 (stride, padding, kernel)输入输出角色互换后普通卷积的输出尺寸公式是out (in 2×padding - kernel) / stride 1向下取整。把转置卷积的输出尺寸代进去验算两个公式互相吻合。这就是 2.3 节说的对称关系在公式层面的体现。4. 实操用 PyTorch 复现并验证4.1 最小可运行例子光看公式不够写代码跑一遍印象最深。下面这段代码可以直接复制运行用全 1 卷积核和固定输入验证尺寸和数值。import torch import torch.nn as nn # 手动指定一个 2x2 输入 x torch.tensor([[[[1., 2.], [3., 4.]]]]) # 定义转置卷积层输入通道1输出通道1核2x2stride1padding0 deconv nn.ConvTranspose2d( in_channels1, out_channels1, kernel_size2, stride1, padding0, biasFalse ) # 把卷积核设成全 1方便和手算对照 with torch.no_grad(): deconv.weight.fill_(1.0) y deconv(x) print(y.shape) # torch.Size([1, 1, 3, 3]) print(y)跑出来的结果应该和 3.1 节手算的 3×3 数值完全一致。如果不一致那大概率是权重没设成 1或者输入张量维度写错了。这里再强调一次维度顺序PyTorch 的输入是(batch, channel, height, width)少一维都会报错。4.2 用 stride 和 padding 控制输出尺寸把上面的参数改成 stride2、padding0 再跑一次输出应该是 4×4。改成 stride1、padding1输出是 1×1。这两组对照能帮你直观建立参数和尺寸之间的映射关系。deconv nn.ConvTranspose2d(1, 1, kernel_size2, stride2, padding0, biasFalse) with torch.no_grad(): deconv.weight.fill_(1.0) print(deconv(x).shape) # torch.Size([1, 1, 4, 4]) deconv nn.ConvTranspose2d(1, 1, kernel_size2, stride1, padding1, biasFalse) with torch.no_grad(): deconv.weight.fill_(1.0) print(deconv(x).shape) # torch.Size([1, 1, 1, 1])4.3 验证 output_padding 的实际效果前面讲的 output_padding 用代码验证最清楚deconv nn.ConvTranspose2d(1, 1, kernel_size4, stride2, padding1, output_padding1, biasFalse) with torch.no_grad(): deconv.weight.fill_(1.0) x4 torch.randn(1, 1, 4, 4) print(deconv(x4).shape) # torch.Size([1, 1, 9, 9])输入 4×4 输出 9×9和公式算的一致。这个参数在需要精确恢复原始尺寸的分割任务里非常有用比如经历了几次下采样之后特征图尺寸从 256 变成 16上采样回 256 时round 的过程会出现尺寸对不齐output_padding 就是用来对齐的。提示如果你在写分割网络的 decoder发现特征图上采样后和 encoder 的 skip connection 尺寸差 1 个像素先检查 output_padding 是不是没配。4.4 从零实现一个反卷积不用 nn 层想彻底搞明白最好能手写一遍。下面这个实现用矩阵乘法的方式模拟转置卷积的核心逻辑虽然效率低但对理解机制极有帮助import torch def manual_transposed_conv(x, weight, stride, padding): in_h, in_w x.shape k_h, k_w weight.shape # 计算输出尺寸 out_h (in_h - 1) * stride - 2 * padding k_h out_w (in_w - 1) * stride - 2 * padding k_w out torch.zeros(out_h, out_w) # 遍历每个输入元素 for i in range(in_h): for j in range(in_w): # 该元素在输出画布上的锚点位置 r i * stride - padding c j * stride - padding # 把卷积核乘上输入值累加到输出对应区域 for ki in range(k_h): for kj in range(k_w): rr r ki cc c kj if 0 rr out_h and 0 cc out_w: out[rr, cc] x[i, j] * weight[ki, kj] return out x torch.tensor([[1., 2.], [3., 4.]]) w torch.ones(2, 2) print(manual_transposed_conv(x, w, stride1, padding0))这段代码把 3.1 节的逻辑写成了程序。跑出来的 3×3 结果和 PyTorch 的ConvTranspose2d完全一致。写一遍之后你会发现转置卷积真的不神秘它就是一个“分块加权累加”的操作。5. 棋盘效应为什么会出问题怎么解决5.1 棋盘效应的成因分析回过头说我在第一节提到的那个坑。棋盘效应checkerboard artifacts是反卷积最著名的副作用表现为输出图像上出现规律的方格状纹理。第一次遇到时我以为是训练不充分实际上它和训练完全无关是结构性的。成因有两个层面的叠加。第一层来自kernel_size 不能被 stride 整除。当 stride2、kernel3 时卷积核在铺开的画布上滑动时有些输出位置被更多的核元素覆盖有些被覆盖得少叠加之后就会形成周期性不均匀的响应。第二层来自卷积核权重的学习过程如果不同位置的覆盖次数差异很大网络会倾向于让某些权重变得很大来补偿进一步强化了周期不均匀。用一个比喻假设你在给一面墙刷漆刷子宽度是 3 厘米但你要每隔 2 厘米移动一次。那么墙面有些地方会被刷两遍有些地方只刷一遍干了之后自然能看到一道道深浅不一的条纹。反卷积的棋盘效应就是这个道理。5.2 一个能复现棋盘效应的最小实验光讲原理不够直观用代码复现一下import torch import torch.nn as nn import numpy as np torch.manual_seed(0) # 用 stride2, kernel3 的配置 deconv nn.ConvTranspose2d(1, 1, kernel_size3, stride2, padding1, biasFalse) # 输入一个随机的低分辨率图 x torch.randn(1, 1, 8, 8) y deconv(x) # 观察输出的方差分布棋盘格会让相邻位置的方差出现周期差异 arr y.detach().squeeze().numpy() # 打印左上角 6x6 区域看得更清楚 print(np.round(arr[:6, :6], 3))如果权重是随机初始化的你可能看不太出明显的棋盘格因为这时候是随机权重。用训练过的权重则会非常明显。更稳妥的复现方式是直接构造一组固定权重比如一个 3×3 的核中心权重大、边缘权重小然后观察输出。核心观察指标是输出的相邻位置出现周期性的亮度差异周期等于 stride。5.3 三种主流替代方案对比既然反卷积容易出棋盘效应实际项目里怎么替代我整理了三种方案都实测过。第一种是先双线性插值再普通卷积。这是目前分割网络里最主流的做法。用F.interpolate(x, scale_factor2, modebilinear, align_cornersFalse)把特征图放大再过一个 3×3 卷积调整通道和细节。好处是没有棋盘效应缺点是多了一次插值操作计算量略高而且插值本身是固定的。第二种是最近邻插值 卷积。比双线性更轻量但块状感更强一般在速度优先的场景用。第三种是PixelShuffle像素重排。先用一个卷积把通道数扩大 stride² 倍然后按空间重排成更大分辨率。这是超分辨率任务里很常见的做法没有插值全部可学习棋盘效应也基本没有。方案棋盘效应参数量计算量适用场景反卷积有需调参缓解中中快速原型、生成器双线性插值卷积无中较高语义分割主流最近邻卷积轻微中低移动端、实时任务PixelShuffle基本无中中超分辨率5.4 如果非要用反卷积怎么减轻棋盘效应有些场景就是需要反卷积比如某些 GAN 架构的既有实现那可以这样缓解把kernel_size设成stride的整数倍最常用的是 stride2、kernel4、padding1 这组配置它能保证卷积核均匀覆盖画布棋盘效应会明显减轻。另外可以用output_padding微调尺寸避免因为尺寸不齐而引入额外的边缘处理。我自己实测下来stride2、kernel4、padding1 这组配置在大多数情况下效果是可控的虽然不是完全消除但视觉上基本看不出来。如果任务对画质要求极高还是老老实实用插值加卷积更稳妥。注意kernel_size 设为 stride 的整数倍并不能 100% 消除棋盘效应它只是让覆盖更均匀。彻底消除需要换用插值方案。6. 常见问题排查与实战避坑经验6.1 输出尺寸和预期对不上这是最高频的问题。症状是上采样之后特征图尺寸和 encoder 对应层的尺寸差了 1 到 2 个像素导致 skip connection 相加时报错。排查顺序是这样的先手算一遍公式out (in-1)×stride - 2×padding kernel output_padding把每个参数代进去看结果和实际输出是否一致。如果不一致检查是不是把output_padding忘了或者kernel_size写成了和卷积层一样的值。还有一个隐蔽的坑如果输入尺寸本身是奇数经过 stride2 的下采样后取整方式可能导致尺寸变化上采样时要用 output_padding 补回来。我的一般做法是在 encoder 每一个下采样层记录下采样前的尺寸decoder 每一层构造的时候直接用这个尺寸反推 output_padding这样能保证尺寸严格对齐。6.2 通道数配置错误转置卷积的in_channels和out_channels语义和普通卷积一致但很多人会因为“方向反了”而搞混。比如你从 256 通道的特征图上采样想变成 128 通道那就设in_channels256, out_channels128。和普通卷积一样不需要反过来写。唯一要注意的是如果你在做通道维度上的“压缩上采样”需要在转置卷积之后加一个 1×1 卷积做通道调整或者在转置卷积里直接设好输出通道。6.3 训练不稳定或梯度爆炸转置卷积因为输出尺寸大、参数量相对集中梯度幅度可能比普通卷积大一些。我在一次 GAN 训练里遇到过生成器梯度爆炸loss 直接变成 nan。排查下来有两个原因一是学习率没有相应调小二是输出层没有做归一化。解决方式是在转置卷积层后面加BatchNorm2d或GroupNorm让输出分布更稳定学习率比普通卷积层适当降低 20% 到 50%如果是在 GAN 里用最后一层的转置卷积后面通常要接Tanh或Sigmoid把输出压到合理范围。6.4 内存占用比预想的大转置卷积的输出尺寸大中间激活值占用内存也多。如果输入是 64×64、输出 256×256、通道 256那中间张量的内存需求会非常可观。我在一个 1080Ti 上跑的时候 OOM 了好几次。缓解办法减小 batch size用混合精度训练或者把上采样拆成两步比如先 2 倍再 2 倍每一步的输出小一些梯度累积起来也相当于一步完成。6.5 常见问题速查表问题现象可能原因排查动作解决方法尺寸差 1-2 像素output_padding 缺失手算尺寸公式补 output_padding方块状纹理kernel 非 stride 整数倍检查 kernel_size vs stride改成 stride2,kernel4梯度爆炸学习率过大、无归一化看 loss 曲线降 lr 加 BN通道不匹配in/out_channels 写反打印张量 shape按数据流方向重设显存 OOM单步上采样倍率太大看显存占用拆成多步 混合精度输出全零stride 或 padding 配置极端检查尺寸公式重新选参数组合6.6 一些实战心得我在不同项目里用反卷积和它的替代方案慢慢形成了自己的选型习惯。如果是做语义分割decoder 直接上双线性插值加卷积稳定、无棋盘效应调参也省心。如果是做超分辨率用 PixelShuffle 配合前置卷积效果好、没有插值痕迹。如果是复现某些 GAN 论文那没办法论文用反卷积就得用反卷积但我会把 kernel 设成 stride 的整数倍来缓解。还有一个细节在 PyTorch 里ConvTranspose2d的权重初始化建议用kaiming_normal_和普通卷积一样不要用默认的均匀初始化否则在深层网络里容易训练不动。这个坑我也是踩过一次才记住的。还有一个容易被忽略的点align_corners参数。如果后面接的是双线性插值align_cornersTrue和False会让特征图的几何对齐方式完全不同skip connection 的时候如果两边设置不一致会出现细微的错位损失下降会变慢。我现在的习惯是在整个网络里统一设成align_cornersFalse避免这种隐性的不一致。最后再提一个经验调试上采样相关代码的时候先用小尺寸张量比如 4×4跑通确认尺寸和数值都对再上大尺寸训练。大尺寸下报错信息往往不直观小尺寸手算一遍能快速定位问题。这个方法帮我省下了大量排查时间尤其是在换框架或者改网络结构的时候特别管用。