1. 从一次通道数对不上的报错说起
如果你写过一段时间的卷积神经网络,大概率遇到过这种场景:编码器一路下采样,特征图从 256×256 缩到 8×8,通道数从 3 涨到 512;到了解码器想把特征图还原回原图尺寸,结果一算尺寸对不上,拼接的时候直接报维度错误。更让人困惑的是,明明把卷积核、步长、填充都调成了和前面卷积"对称"的参数,输出尺寸却总是差那么一两个像素。
这个问题的根源,就藏在转置卷积(Transposed Convolution)里。很多人第一次接触它是在语义分割、图像生成或者超分辨率任务中,看到别人代码里写了一个nn.ConvTranspose2d,输出尺寸就神奇地变大了,于是照抄过来用,但一旦要自己设计网络结构,就完全不知道stride、padding、output_padding这几个参数该怎么配。
这篇内容就是围绕转置卷积这个部件展开的。我会把它的数学本质、尺寸计算公式、stride和padding到底在做什么、以及"转置卷积"和"反卷积"这两个名字的争议,一层一层拆开讲清楚。适合已经能跑通基础 CNN、想深入理解上采样机制的人,也适合正在做分割或生成任务、被尺寸问题反复折磨的开发者。读完你应该能做到:给定任意输入尺寸和卷积参数,手算出转置卷积的输出尺寸,并且知道为什么这么算。
2. 转置卷积到底"转置"了什么
2.1 先把普通卷积写成矩阵乘法
要理解转置卷积,最干净的方式是先把普通卷积用矩阵形式表达出来。假设输入是一个 4×4 的单通道特征图,我们把它拉平成一个 16×1 的列向量。卷积核是 3×3,步长 1,无填充,那么输出是 2×2,也就是 4 个值。
普通卷积的每一个输出值,都是输入中某个 3×3 区域和卷积核逐元素相乘再求和。这个过程可以写成一个矩阵乘法:
输出(4×1) = 卷积矩阵C(4×16) × 输入(16×1)这个C矩阵的每一行对应一个输出位置,行里的非零元素就是那个位置用到的输入像素对应的卷积核权重。因为每个输出只和 9 个输入有关,所以C是一个高度稀疏的矩阵,每行只有 9 个非零值。
关键点来了:这个C矩阵的形状是 4×16,它把 16 维的输入映射成了 4 维的输出。现在如果我们想要反过来,把 4 维的东西映射回 16 维,最自然的想法就是用C的转置矩阵C^T,形状是 16×4:
输出(16×1) = C^T(16×4) × 输入(4×1)这就是"转置卷积"这个名字的由来——它做的是乘以卷积矩阵的转置。注意,这里说的"转置"是矩阵意义上的转置,不是把卷积核旋转 180 度那种转置。这一点非常容易混淆,后面讲名字争议的时候还会展开。
2.2 转置卷积不是卷积的逆运算
这里必须澄清一个高频误解:转置卷积不是卷积的逆运算。逆运算要求C^{-1}存在,但C是 4×16 的长方矩阵,根本不可逆。转置卷积只是"形状上反过来",把小的东西变回大的形状,但数值上它无法还原原始输入。
打个比方:普通卷积像是把一张大照片压缩成缩略图,很多细节被丢掉了;转置卷积像是把缩略图放大回原来的尺寸,但放大出来的图是"猜"出来的,不可能和原图一模一样。它做的是形状还原,不是信息还原。
这个认知很重要,因为它决定了你在设计网络时的预期。如果你指望转置卷积能无损恢复下采样丢掉的信息,那一定会失望。实际网络中,转置卷积恢复的尺寸会和编码器对应层的特征图做拼接或相加,用编码器保留的细节来弥补丢失的信息,这也是 U-Net 这类结构存在的意义。
2.3 从"插零"角度看转置卷积的计算过程
矩阵转置的视角很严谨,但不够直观。另一种更工程化的理解方式是"插零 + 普通卷积"。
以步长 2 的转置卷积为例,输入是 2×2,我们想把它变成 4×4 或更大。操作分三步:
- 在输入元素之间插入
stride - 1个零。步长为 2 时,每两个相邻元素之间插 1 个零,2×2 就变成了 3×3(边缘还要补零,具体补多少和 padding 有关)。 - 在插零后的特征图周围补零,补的数量由
padding决定。 - 用一个普通卷积核在插零后的图上做步长为 1 的卷积。
这个视角的好处是,它把转置卷积完全转化成了你熟悉的普通卷积,尺寸计算也变得可推导。但它有个副作用:插零会让计算量虚高,因为大量乘加是在和零做运算。所以实际框架实现时不会真的插零,而是用更高效的等价算法(比如把卷积核翻转后做普通卷积的梯度计算),但理解层面用插零模型完全够用。
提示:插零视角只适合理解,不要据此估算实际计算量。真实实现里框架会做优化,插零模型算出来的 FLOPs 会明显偏高。
3. 尺寸公式的推导与手算验证
3.1 普通卷积的输出尺寸回顾
在讲转置卷积之前,先把普通卷积的尺寸公式摆出来,因为转置卷积的公式是从它反推的。
对于普通卷积,输入尺寸W_in,卷积核大小K,步长S,填充P,输出尺寸:
W_out = floor((W_in + 2P - K) / S) + 1这个公式里的floor是向下取整,意味着当(W_in + 2P - K)不能被S整除时,边缘的一些像素会被丢弃。这个"丢弃"是后面转置卷积尺寸对不上的罪魁祸首。
举个例子:W_in = 7, K = 3, S = 2, P = 0,那么(7 - 3) / 2 = 2,W_out = 2 + 1 = 3。输入 7 输出 3,中间丢掉了信息。
3.2 转置卷积尺寸公式的两种写法
转置卷积的尺寸公式,本质是把上面的公式"解出来"。给定转置卷积的输入W_in(也就是普通卷积的输出),求它的输出W_out(也就是普通卷积的输入):
W_out = (W_in - 1) * S - 2P + K + output_padding这里的output_padding是一个转置卷积特有的参数,默认是 0。它的作用是解决"同一个输出尺寸可能对应多个输入尺寸"的歧义问题。
为什么会有歧义?回到刚才的例子,W_in = 7时W_out = 3。但如果W_in = 6,(6 - 3) / 2 = 1.5,向下取整还是 1,W_out还是 3。也就是说,普通卷积把 6 和 7 都映射成了 3。那么转置卷积从 3 反推时,到底该还原成 6 还是 7?公式默认给出的是最小值,output_padding就是用来补上这个差值的。
用公式验证:W_in = 3, S = 2, P = 0, K = 3, output_padding = 0,得到(3-1)*2 - 0 + 3 + 0 = 7。如果想让输出是 6,就设output_padding = -1?不行,output_padding只能是非负数,且必须小于S。所以从 3 出发,你只能得到 7,得不到 6。这就是为什么设计网络时,编码器和解码器的尺寸要仔细对齐——有些尺寸一旦下采样就再也回不去了。
3.3 一张表看清 stride 和 padding 的影响
光看公式容易晕,直接上表格。固定K = 3,输入W_in = 5,看不同S和P组合下的输出:
| stride S | padding P | output_padding | 输出尺寸 W_out |
|---|---|---|---|
| 1 | 0 | 0 | 7 |
| 1 | 1 | 0 | 5 |
| 2 | 0 | 0 | 11 |
| 2 | 1 | 0 | 9 |
| 2 | 0 | 1 | 12 |
| 3 | 1 | 0 | 13 |
从表里能看出几个规律:
stride是放大倍数的主要来源。S = 1时输出只比输入大K - 1 - 2P,S = 2时输出接近输入的两倍。padding在转置卷积里是减小输出的。这和普通卷积里 padding 增大输出正好相反,因为转置卷积的 padding 对应的是普通卷积"丢掉"的边缘。output_padding只能微调,且必须小于stride,它不改变卷积的实际覆盖范围,只是把输出的边缘多补几行/列。
注意:
output_padding不是"填充零",它只是让输出尺寸变大,多出来的那些位置是通过正常卷积计算得到的,不是补零。这一点和padding有本质区别。
3.4 手算一个真实案例
假设你在做一个 2 倍上采样的解码器,编码器最后输出的特征图是 8×8,你希望解码器第一层输出 16×16。选K = 4, S = 2, P = 1,代入公式:
W_out = (8 - 1) * 2 - 2*1 + 4 + 0 = 14 - 2 + 4 = 16正好 16。这套参数K=4, S=2, P=1是 2 倍上采样里非常经典的配置,很多生成网络和分割网络都用它。为什么是K=4而不是K=3?因为K=3, S=2, P=1算出来是(8-1)*2 - 2 + 3 = 15,差一个像素。要让K=3也能得到 16,得设output_padding = 1,但这样输出的边缘会稍微不对称。所以追求尺寸整齐时,K=4, S=2, P=1更省心。
4. stride 与 padding 在转置卷积里的真实语义
4.1 stride 不是"跳跃",而是"放大倍率"
在普通卷积里,stride的语义是"卷积核每次移动几格",步长越大输出越小。但在转置卷积里,stride的语义变了,它决定的是放大倍率。
从插零视角看最清楚:stride = 2意味着在输入元素之间插入 1 个零,输入的有效"跨度"就变成了原来的约 2 倍。所以转置卷积的输出尺寸大致是输入的S倍(再减去 padding 和核大小的影响)。
这也是为什么转置卷积常被用作"学习式上采样"。相比直接的双线性插值,转置卷积的卷积核权重是可学习的,网络可以自己决定怎么放大最有利于任务。代价是它容易产生棋盘效应(checkerboard artifact)——当stride和K不能整除时,输出像素的覆盖不均匀,某些位置被多次计算,某些位置只被计算一次,视觉上就出现了网格状的明暗条纹。
4.2 棋盘效应的成因与规避
棋盘效应的根源在于卷积核在输出上的覆盖重叠不均匀。举个极端例子:K = 3, S = 2,输出位置 0 只被核的第一个元素覆盖,位置 1 被核的前两个元素覆盖,位置 2 又被第一个元素覆盖……覆盖次数在 1 和 2 之间交替,导致输出有周期性波动。
规避方法有几种,实测下来最有效的是:
- 让
K能被S整除,比如K = 4, S = 2或K = 6, S = 3。这样每个输出位置的覆盖次数一致,从根本上消除周期性。 - 先做转置卷积再跟一个普通卷积(比如 3×3),用后面的卷积平滑掉不均匀。
- 干脆放弃转置卷积,改用"最近邻上采样 + 普通卷积"的组合,虽然参数量略多,但完全没有棋盘效应。
我个人在分割任务里更倾向第三种方案,因为它的输出更干净,调试起来也简单。转置卷积的优势主要在生成任务里,因为那里需要网络自己学习上采样的方式。
4.3 padding 在转置卷积里"反向"作用
前面表格里已经看到,转置卷积的padding增大时输出变小。这个反直觉的行为让很多人困惑,其实只要记住一点:转置卷积的 padding 对应的是普通卷积的 padding。
在普通卷积里,padding = 1意味着在输入边缘补一圈零,让输出尺寸不至于缩得太快。到了转置卷积,这个 padding 变成了"从输出里裁掉多少"。因为转置卷积是在"还原"普通卷积的过程,普通卷积当初补的零,现在要裁掉,才能得到和原始输入一致的尺寸。
所以当你看到一对对称的卷积/转置卷积,比如编码器用Conv2d(K=4, S=2, P=1),解码器用ConvTranspose2d(K=4, S=2, P=1),它们的 padding 是相同的数值,但作用方向相反。这种对称配置能让尺寸完美对齐,是设计编解码结构的常用手法。
4.4 output_padding 的实战用法
output_padding平时用得不多,但在某些尺寸下它是唯一的救命稻草。考虑编码器K=3, S=2, P=1,输入 32×32:
W_out = floor((32 + 2 - 3) / 2) + 1 = floor(31/2) + 1 = 15 + 1 = 16输出 16×16。现在解码器要从 16 还原回 32,用K=3, S=2, P=1:
W_out = (16 - 1) * 2 - 2 + 3 = 30 - 2 + 3 = 31只有 31,差一个像素。这时候设output_padding = 1,就得到 32。这就是output_padding的典型场景:当(W_in - 1) * S - 2P + K比目标尺寸小 1 到S-1时,用它补齐。
提示:
output_padding只能补,不能减。如果你的公式算出来比目标大,那只能调K、S或P,output_padding帮不上忙。
5. "转置卷积"与"反卷积"的名称之争
5.1 为什么有人叫它反卷积
"反卷积"(Deconvolution)这个叫法在早期文献和很多框架的 API 里都能见到,比如 Caffe 里的Deconvolution层。叫这个名字的逻辑是:它和卷积在尺寸上是相反的,卷积缩小,它放大,所以看起来像"反"过来。
但这个叫法在数学上是不严谨的。真正的反卷积在信号处理领域有明确定义,指的是已知系统输出和系统响应,反推系统输入的过程,通常需要解卷积方程。而转置卷积只是矩阵转置,不涉及任何求逆,两者不是一回事。
5.2 为什么"转置卷积"更准确
"转置卷积"这个名字直接点出了它的数学本质:乘以卷积矩阵的转置。这个描述是精确的,不会引起歧义。所以在较新的框架和教材里,ConvTranspose逐渐成为主流叫法。
不过要注意,即使是"转置卷积"这个名字,也容易让人误以为是把卷积核转置了。实际上卷积核本身没有转置,转置的是整个运算的矩阵表示。理解这一点,就不会在实现时纠结"要不要把核翻转"这种问题。
5.3 实际交流中怎么称呼
在工程实践中,两种叫法都有人用,沟通时看上下文。如果对方说的是"反卷积",你心里清楚它指的是转置卷积就行,不必纠正。但如果是在写文档或做技术分享,建议用"转置卷积",因为它在数学上站得住脚,也符合主流框架的命名。
还有一个容易混淆的点:有些资料把转置卷积叫"分数步长卷积"(Fractional Stride Convolution)。这个叫法是从"步长小于 1"的角度理解的,因为转置卷积相当于把普通卷积的步长取倒数。这个视角在理论分析里有用,但日常开发中很少这么说。
6. 在 PyTorch 里把转置卷积用对
6.1 ConvTranspose2d 的参数逐个说清
PyTorch 的nn.ConvTranspose2d签名如下:
nn.ConvTranspose2d( in_channels, out_channels, kernel_size, stride=1, padding=0, output_padding=0, groups=1, bias=True, dilation=1, padding_mode='zeros' )几个关键参数的含义:
in_channels/out_channels:注意方向。转置卷积的in_channels是输入特征图的通道数,out_channels是输出通道数。在解码器里,通常是通道数逐渐减少,所以in_channels > out_channels。kernel_size:卷积核大小,可以是整数或元组。stride:放大倍率,决定输出尺寸的主要因素。padding:对应普通卷积的 padding,增大它会让输出变小。output_padding:微调输出尺寸,必须小于stride。dilation:空洞卷积的膨胀率,转置卷积也支持,但和stride同时用时要小心尺寸计算。
6.2 一个可复现的尺寸验证脚本
光看公式不放心,直接写段代码验证。下面这段脚本会遍历多组参数,打印实际输出尺寸,你可以拿它对照公式:
import torch import torch.nn as nn def check_size(W_in, K, S, P, OP): conv_t = nn.ConvTranspose2d(1, 1, kernel_size=K, stride=S, padding=P, output_padding=OP) x = torch.randn(1, 1, W_in, W_in) y = conv_t(x) formula = (W_in - 1) * S - 2 * P + K + OP print(f"K={K}, S={S}, P={P}, OP={OP} -> " f"实际={y.shape[-1]}, 公式={formula}") check_size(5, 3, 1, 0, 0) check_size(5, 3, 1, 1, 0) check_size(5, 3, 2, 0, 0) check_size(5, 3, 2, 1, 0) check_size(5, 3, 2, 0, 1) check_size(8, 4, 2, 1, 0)跑一遍你会发现,实际输出和公式完全一致。这个脚本建议收藏,以后设计网络时先跑一遍确认尺寸,比在完整模型里调试快得多。
6.3 权重初始化为什么更敏感
转置卷积的权重初始化比普通卷积更敏感。原因是它的输出尺寸更大,如果初始化方差偏大,输出很容易出现大面积的高值区域,训练初期 loss 会剧烈震荡。
PyTorch 默认用 Kaiming 初始化,对大多数情况够用。但如果你发现训练不稳定,可以手动调小初始化方差:
nn.init.kaiming_normal_(conv_t.weight, mode='fan_out', nonlinearity='relu')或者干脆用更保守的正态初始化,标准差设小一点。我在生成任务里试过把标准差从默认值降到一半,训练稳定性明显改善,代价是收敛稍慢。
6.4 和 BatchNorm 搭配的顺序
转置卷积后面接 BatchNorm 时,顺序建议是ConvTranspose -> BN -> 激活。这个顺序和普通卷积一致,但有个细节:转置卷积的输出尺寸大,BN 的统计量在训练初期波动更大,所以momentum可以适当调小,比如从 0.1 调到 0.05,让统计量更新更平滑。
如果用了output_padding,要注意它补出来的边缘像素统计特性和中间像素不同,BN 可能会学到有偏的均值。这种情况可以考虑在转置卷积后先做一次裁剪,把output_padding补出来的部分去掉,再做 BN。不过实测下来影响不大,除非你对边缘精度要求很高。
7. 几个容易踩的坑和排查思路
7.1 尺寸对不上时的排查顺序
尺寸报错是转置卷积最常见的坑。排查时按这个顺序走:
- 先确认编码器每一层的输出尺寸,打印出来,别靠脑算。
- 对解码器每一层,用公式算出期望输出,和实际打印对比。
- 如果差 1 到
S-1,优先考虑加output_padding。 - 如果差得多,检查
K、S、P是否和编码器对称。 - 如果拼接时报错,检查通道数,转置卷积只改尺寸不改通道对齐,通道要靠
out_channels控制。
我见过最常见的错误是编码器用了K=3, S=2, P=1,解码器却用了K=2, S=2, P=0,尺寸算出来差好几个像素,然后花半天找原因。其实只要把两边的参数列个表对照,问题一目了然。
7.2 通道数不匹配的隐蔽问题
尺寸对了但通道数不对,是另一个高频坑。转置卷积的out_channels决定输出通道,但很多人会忘记在拼接前把通道数调成和编码器对应层一致。
比如编码器某层输出 256 通道,解码器转置卷积输出 128 通道,直接拼接就会报错。正确做法是让转置卷积的out_channels等于256 - 编码器跳连的通道数,或者拼接后再用 1×1 卷积调整。这个计算在设计阶段就要想清楚,不要等到运行时报错才改。
7.3 训练不收敛时的检查清单
转置卷积导致训练不收敛,通常有这几个原因:
- 学习率偏大。转置卷积的梯度传播路径和普通卷积不同,对学习率更敏感,可以先降一个数量级试试。
- 权重初始化方差过大,输出饱和。
- 棋盘效应导致 loss 有周期性波动,看起来像不收敛,其实是正常现象,加个平滑卷积就能缓解。
output_padding用错位置,导致输出和标签错位,loss 自然降不下去。
排查时建议先把转置卷积换成"最近邻上采样 + 普通卷积",如果这样能收敛,说明问题出在转置卷积本身,再逐个排查上面的原因。
7.4 显存占用比预期高
转置卷积的显存占用往往比同参数量的普通卷积高,因为它的输出特征图更大。如果显存吃紧,可以考虑:
- 减小
out_channels,通道数对显存的影响是平方级的。 - 用
groups参数做分组转置卷积,能显著降低参数量和显存。 - 把上采样拆成两步,比如先 2 倍再 2 倍,而不是一步 4 倍,中间可以插入通道压缩。
这些手段在移动端或边缘设备部署时特别有用,我做过一个分割模型,把一步 4 倍上采样拆成两步 2 倍,显存降了将近 40%,精度只掉了不到 1 个点。
8. 写在最后的一点个人体会
转置卷积这个部件,刚接触时觉得参数多、公式绕,但把矩阵转置的本质想通之后,剩下的就是尺寸计算和参数搭配的体力活。我自己的习惯是,每次设计新的编解码结构,先在纸上把每层的输入输出尺寸列成表格,确认对齐了再写代码,这样能省掉大量调试时间。
另外,不要迷信转置卷积。它在生成任务里确实好用,但在分割任务里,"上采样 + 普通卷积"往往更稳、更好调。工具没有优劣,关键看场景。把两种方案的尺寸计算都练熟,遇到问题时你就有得选,而不是只能硬着头皮调参。