1. 名字叫反卷积,其实它从来不"反"卷积
如果你第一次听到Transposed conv(转置卷积,很多地方也写成 deconv、反卷积),大概率会以为它是卷积的逆运算——输入一个特征图,输出一个被"还原"回原样的图。这个直觉是错的,而且错得很彻底。反卷积这个叫法属于历史遗留,它既不能把卷积的结果还原回原始输入,也不是任何意义下的"逆"。它在数学上的真实身份,是卷积这个线性算子的转置(transpose),或者更准确地说,是卷积矩阵的伴随算子。
我见过太多人在调网络的时候卡在这里:一边看着nn.ConvTranspose2d的文档,一边心里想着"我要把下采样丢掉的信息恢复回来",结果发现输出尺寸怎么调都对不上,或者上采样出来的图带着一层一层的网格纹。这些问题的根子,几乎都在于没搞明白它到底在算什么。搞清楚 Transposed conv 的实现原理,其实不需要多高深的数学功底,但需要你愿意把卷积从"滑窗动画"这个心智模型里拽出来,换一个更朴素的视角重新看一遍。这篇内容就是干这件事的,从矩阵关系讲到像素级的计算过程,再从输出尺寸公式讲到棋盘效应的成因和替代方案,最后给出可以直接跑的代码和我在实际项目里踩过的坑。适合正在做语义分割、生成模型、超分辨率、特征图放大的朋友,也适合刚学完卷积想继续往深走一步的人。
1.1 一个被叫错名字的算子
先说清楚命名这件事,因为它直接影响你查资料时的判断力。学术界比较严谨的写法是transposed convolution,字面意思就是"转置卷积";也有叫fractionally-strided convolution(分数步长卷积)的,这个名字其实更贴近它的行为本质;而deconvolution这个名字,在经典信号处理领域里是指"已知系统响应和输出,求原始输入"的逆滤波操作,和我们深度学习里的这个层完全不是一回事。所以你会在不同框架、不同论文、不同教材里看到这三个名字混着用,但它们指向的往往是同一个东西。
我个人的建议是:在代码里用框架给的名字(比如 PyTorch 的ConvTranspose2d),在跟人交流的时候说"转置卷积",在心里永远把它记成上采样算子。这三套话术各自适合各自的场合,混着用也不会出大问题,但你要是真把它当逆运算去设计网络结构,那就一定会翻车。
1.2 卷积下采样到底丢了什么
要理解为什么"逆"不成立,得先看清楚卷积是怎么丢信息的。假设一个 4x4 的输入,用一个 3x3 的核做步长为 1、无填充的卷积,输出是 2x2。这个过程中,输入里有 16 个数字,输出只有 4 个数字。从 16 维空间到 4 维空间的线性映射,必然是不可逆的——同一个 2x2 输出,可能对应无穷多个不同的 4x4 输入。
举个极端的例子:输入全零图,和输入一张精心构造的、每个 3x3 感受野内加权和都为零的图,卷积后都是全零输出。你从全零输出出发,永远无法判断原来到底是哪一种。这就是"信息丢失"的具体含义,也是为什么任何号称能把卷积结果精确还原的操作都是耍流氓。转置卷积不承诺还原,它只承诺把张量的空间维度放大,放大过程中填进去的内容由可学习的权重决定,至于填得对不对,那是损失函数管的事。
1.3 从"求逆"到"求转置"的思路切换
既然逆运算不存在,那转置卷积到底在算什么?答案很直白:如果我们把卷积写成矩阵乘法y = Cx,其中C是一个形状为(输出维度, 输入维度)的稀疏矩阵,那么转置卷积就是z = Cᵀy,把C转置一下,乘在输出上。这一转置,形状就从(小, 大)变成了(大, 小),于是输入是小的,输出是大的——维度被放大了。
这里有个很关键的点:转置运算只改变映射的方向和形状,不改变信息量。Cᵀ的秩和C一样,它作用在y上得到的z,永远落在C的列空间里,永远不可能是"任意一个 4x4 图"。它只是一个形状刚好对得上的线性变换,把Cᵀ的参数交给梯度下降去学而已。想明白这一层,后面所有关于尺寸、padding、棋盘效应的困惑都会变得好解释。
2. 从矩阵乘法看转置关系的由来
我知道很多人一看矩阵就头疼,但这一节真的值得花时间读下去,因为转置卷积所有"反直觉"的地方,答案都藏在矩阵形式里。一旦你把卷积写成矩阵,转置卷积就不再神秘,它只是同一块砖头换个方向垒。
2.1 把一个 1D 卷积摊成稀疏矩阵
为了看清楚,我们先用一维的例子。输入x = [x0, x1, x2, x3],卷积核w = [w0, w1, w2],步长 1,无填充。输出是两个数:
y0 = w0*x0 + w1*x1 + w2*x2 y1 = w0*x1 + w1*x2 + w2*x3把这两行写成矩阵形式:
[ y0 ] [ w0 w1 w2 0 0 ] [ x0 ] [ y1 ] = [ 0 w0 w1 w2 0 ] * [ x1 ] [ x2 ] [ x3 ]这个 2x5 的矩阵C就是卷积算子本身。它有明显的结构特征:每一行都是同一个核向量在水平方向平移得到的,Toeplitz 矩阵说的就是这种"沿对角线常数"的矩阵。行数是输出长度,列数是输入长度,每一行有几个非零元素取决于核的大小,剩下的全是零,所以它是稀疏的。
2.2 转置卷积就是乘上这个矩阵的转置
现在把这个矩阵转置,得到一个 5x2 的矩阵:
[ w0 0 ] [ w1 w0 ] [ w2 w1 ] [ 0 w2 ]用它去乘一个长度为 2 的输入y = [y0, y1],得到的输出长度是 4:
z0 = w0*y0 z1 = w1*y0 + w0*y1 z2 = w2*y0 + w1*y1 z3 = w2*y1这就是一维、步长 1、无填充情况下的转置卷积。你会发现它做的事情非常朴素:输入的两个数分别坐在位置 0 和位置 1 上,各自把核[w0, w1, w2]加权撒向周围的位置,重叠的地方数值相加。位置 0 的y0贡献了[w0, w1, w2]到下标 0、1、2;位置 1 的y1贡献了[w0, w1, w2]到下标 1、2、3;两者在下标 1 和 2 处叠加。这个"撒点加权重叠"的画面,是理解转置卷积最省脑力的方式。
2.3 为什么"转置"不等于"求逆":一对多的映射
对比一下两个方向就能看出本质差异。正向卷积Cx把 4 维降到 2 维,是多对一:不同的输入可以映射到同一个输出。反向的Cᵀy把 2 维升到 4 维,是一对多:给定一个y,Cᵀy只给出其中一个特定的 4 维向量,也就是把y投影回输入空间的那一个点。
举个具体数字感受一下。设w = [1, 1, 1],x = [1, 2, 3, 4],那么y = [1+2+3, 2+3+4] = [6, 9]。反过来,拿y = [6, 9]做转置卷积,得到z = [6, 7, 8, 9]。这个z和原来的x = [1,2,3,4]不一样,但它的正向卷积结果确实是[6, 9]。再看另一个输入x' = [5, 1, 0, 9],它的正向卷积是[6, 10],和[6, 9]不同,说明不同的y会对应不同的z;但如果我构造x'' = [1, 2, 3, 4] + [1, -2, 1, 0] = [2, 0, 4, 4],它的正向卷积是[2+0+4, 0+4+4] = [6, 8],也不一样。
更直接的说法是:所有满足Cx = y的x,构成了一个仿射子空间,Cᵀy只是这个子空间里离原点最近的那一个(在特定度量下),它是最小范数解的一个近似,而不是唯一的真解。这个性质在理论上有用,在实际训练里意义不大——因为我们根本不在乎"还原原始输入",我们在乎的是"放大特征图并且让网络自己学怎么放"。
提示:如果你在推导里看到"转置卷积等于对梯度做正向卷积",别慌,它说的是同一件事。反向传播时,损失对输入的梯度要用权重的转置去乘,这个操作和转置卷积的形式完全一致,所以实现转置卷积最省事的办法就是复用卷积的反向传播代码。
3. 三种等价视角:同一个算子的三张面孔
矩阵视角严谨但不够直观,实际写代码或者调结构的时候,我更常用另外三种理解方式。它们彼此等价,只是切入角度不同,遇到不同问题时挑一个顺手的用就行。
3.1 视角一:输入元素按步长"撒点",核按权重铺开
这是最贴近实现的视角,也是我推荐新手先掌握的。过程分三步走:
- 准备一张比目标输出更大的零画布,尺寸由公式算出来。
- 把输入里每个元素,按照步长
stride决定的位置间隔,一一放到画布上。 - 每放一个元素,就在它周围铺开一个
kernel_size x kernel_size的核,核里每个权重乘以这个输入元素的值,累加到画布对应位置上。
步长在这里扮演的角色是"输入元素之间的间距"。步长 1 时,输入元素紧挨着坐;步长 2 时,它们之间空一格;步长越大,撒得越稀,画布最后也就越大,这就是为什么有人叫它"分数步长卷积"——从输出的角度看,它的采样密度是输入的1/stride。
这个视角解释了一个常见的困惑:为什么转置卷积的输出比输入大?因为每个输入元素不再只影响一个输出点,而是影响一整片区域。一个输入元素撒出一个核,十个输入元素就撒出十片叠加的核,总面积自然比输入大得多。
3.2 视角二:在输入里插零,然后做一次普通卷积
这个视角特别适合用来写代码,因为它把问题归约成了"我熟悉的操作"。具体做法是:
- 在输入的相邻元素之间,插入
stride - 1个零。 - 在插零之后的张量四周,补上
kernel_size - 1 - padding圈零。 - 用同样的核,做一次步长为 1、padding 为 0 的普通卷积。
我们来验算一下尺寸。原输入长度为n,插零之后的长度是(n-1)*stride + 1。左右各补k-1-p圈零后,总长度是(n-1)*stride + 1 + 2(k-1-p)。经过长度为k的卷积,输出长度为(n-1)*stride + 1 + 2(k-1-p) - k + 1,化简之后正好等于(n-1)*stride - 2p + k,和标准公式完全吻合。
这里有一个容易搞混的地方:核要不要翻转。如果你学的是信号处理里的严格卷积定义,答案是"要翻转 180 度";但在深度学习框架里,所有"卷积"其实是互相关(cross-correlation),转置卷积作为互相关矩阵的转置,用的时候不翻转核。所以你在 PyTorch 里手写这个等价过程时,直接用同一份权重就行,别自作聪明去flip。
3.3 视角三:反向传播里的梯度计算
第三个视角来自自动微分。前向卷积y = Cx,反向传播求损失对x的梯度时,会用到Cᵀ。也就是说,如果我把一个卷积层的输入设成可求导的、并把它的输出梯度当成输入喂进去,得到的就是转置卷积的结果。
这个视角的实际价值在于解释"为什么框架能免费送你一个转置卷积"。早期一些实现真的就是这么干的:用卷积层的 backward 函数来充当转置卷积的 forward。现在框架都有专门的核函数,性能更好,但理解这层关系有助于你读懂一些老论文和源码注释。
3.4 用一个小例子验证三种视角等价
说了这么多,不如动手验一次。输入x = [[1, 2], [3, 4]],核w = [[1, 0], [0, 1]](就是个单位核),步长 2,padding 0。按公式算,输出尺寸是(2-1)*2 - 0 + 2 = 4,所以是 4x4。
按"撒点"视角:输入 (0,0) 位置的值 1,撒在输出左上角 2x2 区域,核是[[1,0],[0,1]],所以给 (0,0) 加 1、给 (1,1) 加 1。输入 (0,1) 的值 2,基准位置是 (0, 2),给 (0,2) 加 2、(1,3) 加 2。输入 (1,0) 的值 3,基准位置 (2, 0),给 (2,0) 加 3、(3,1) 加 3。输入 (1,1) 的值 4,基准位置 (2,2),给 (2,2) 加 4、(3,3) 加 4。
最终结果是一个对角线上有[1, 1, 2, 2, 3, 3, 4, 4]这样分布的稀疏矩阵。你把这组数字代进"插零 + 卷积"的路径,得到的完全一样。三种视角在这里汇合,说明它们描述的是同一个线性映射,只是叙事不同。
| 视角 | 最适合的用途 | 容易犯的错 |
|---|---|---|
| 矩阵转置 | 推导性质、证明维度关系 | 把转置当成求逆 |
| 撒点铺核 | 理解步长与输出尺寸的关系 | 忘记重叠位置要累加 |
| 插零卷积 | 手写实现、替换框架层 | 多此一举地翻转卷积核 |
| 梯度视角 | 读源码、理解自动微分 | 误以为只能在训练时使用 |
4. 输出尺寸公式:参数之间该怎么配合
尺寸对不上是转置卷积最高频的报错来源。它的公式不复杂,但涉及到四个参数,少考虑一个就会差一格,而差一格往往会在后面跟别的张量拼接时炸掉。
4.1 正向卷积的尺寸怎么算
先把正向的写出来,因为转置卷积的公式是从它反推出来的。给定输入尺寸n、核大小k、步长s、填充p,正向卷积的输出是:
n_out = floor((n_in + 2p - k) / s) + 1这里的floor是向下取整,它带来一个副作用:同一个输出尺寸可能对应多个输入尺寸。比如k=3, s=2, p=1时,输入 4 和输入 5 的输出都是 3。这个信息在转置的时候会被抹掉,所以转置卷积的输出尺寸存在歧义,需要额外的参数来指定——这就是output_padding存在的理由。
4.2 把公式反过来推
转置卷积的输出公式是:
n_out = (n_in - 1) * s - 2p + k + output_padding推导过程其实不难。正向公式里,n_out - 1 = floor((n_in + 2p - k)/s),把这个等式两边乘s,得到(n_out - 1) * s <= n_in + 2p - k,整理一下就得到n_in >= (n_out - 1) * s - 2p + k。在小数部分可以被整除的情况下,等号成立。这里的n_in是转置卷积的输出,n_out是转置卷积的输入,把名字交换一下就得到了上面那个式子。
我建议你把这个公式记住,但不要死记,而是记住它的"物理含义":(n-1)*s是撒点之后的跨度,-2p是padding 砍掉的两头,+k是补上最后一个核覆盖的范围。这样即使哪天记混了,也能靠想象撒点的画面重新推出来。
4.3 output_padding 到底补了什么
这个参数常被误解成"在输出上再加一圈像素,用来做某些特殊处理"。它的实际作用非常有限:只影响输出的尺寸,不引入任何新的计算,只在输出的右下方向补出额外的行列。
具体来说,当你需要一个特定的输出尺寸,但公式算出来的值差了一点,就可以用output_padding补上,取值范围是0 <= output_padding < stride。它补出来的那些像素位置,不会参与任何卷积计算,值由累计到那里的加权和决定——如果那个位置没被任何核覆盖到,它就是零。
注意:
output_padding必须小于stride,这是框架层面的硬约束。如果你填了一个大于等于stride的值,会直接报错。原因是补得再多,也没法还原出正向卷积里被 floor 丢掉的那些情况。
4.4 参数组合速查
下面这张表可以直接对着调参,所有数值都按n_out = (n_in - 1)*s - 2p + k + op算出来,可以直接验算:
| 输入尺寸 | kernel | stride | padding | output_padding | 输出尺寸 |
|---|---|---|---|---|---|
| 2 | 3 | 1 | 0 | 0 | 4 |
| 2 | 3 | 1 | 1 | 0 | 2 |
| 2 | 3 | 2 | 0 | 0 | 5 |
| 2 | 3 | 2 | 0 | 1 | 6 |
| 2 | 3 | 2 | 1 | 0 | 3 |
| 2 | 3 | 2 | 1 | 1 | 4 |
| 6 | 4 | 2 | 1 | 0 | 12 |
| 16 | 4 | 2 | 1 | 0 | 32 |
最后一行是最经典的配置:kernel=4, stride=2, padding=1,输入 16 输出 32,正好是两倍。这个组合在分割网络和生成网络里出现频率极高,原因是它能做到整数倍放大且尺寸精确对齐,同时kernel能被stride整除,棋盘效应也相对轻微。
再看一个配对例子。正向卷积n=4, k=3, s=2, p=1,输出是floor((4+2-3)/2)+1 = floor(1.5)+1 = 2。如果我想用转置卷积从 2 变回 4,按公式需要op=1:(2-1)*2 - 2 + 3 + 1 = 4。这说明在做编码器-解码器结构时,如果编码器用了k=3, s=2, p=1,解码器想精确回到原尺寸,就得配output_padding=1。忘了这一步,尺寸就会差一格,然后在 skip connection 拼接时直接报维度不匹配。
5. 棋盘效应:转置卷积最容易被投诉的地方
用转置卷积做上采样,十个人里大概有六个人会看到输出图上出现规律的格子状纹路,尤其在浅色区域或者做图像重建任务时特别明显。这个现象有个专门的名字,叫checkerboard artifact(棋盘效应),它不是 bug,而是转置卷积本身的数学性质导致的。
5.1 棋盘纹的成因:核覆盖次数不均匀
回到"撒点铺核"的画面。输入元素按步长撒开,每个元素铺一个核,重叠处累加。关键在于:不同输出位置被多少个核覆盖,数量是不一样的。
拿kernel=3, stride=2举例。输入两个相邻元素,分别坐在位置 0 和位置 2,各自的核覆盖[0,1,2]和[2,3,4]。位置 1 只被第一个核覆盖,位置 2 被两个核同时覆盖,位置 3 只被第二个核覆盖。于是输出上出现了"一次覆盖、两次覆盖、一次覆盖"的周期模式。如果核里的权重数值差异比较大,这种不均匀就会在图像上表现为明暗交替的格子。
反过来说,如果kernel能被stride整除(比如 4/2、6/3),每个输出位置被覆盖的次数就趋于均匀,棋盘效应基本消失。这就是为什么k=4, s=2, p=1这个配置被那么多人推荐,不是因为它数学上更优雅,而是因为它不容易出视觉问题。
5.2 替代方案一:先插值,再卷积
最稳的替代做法是把上采样拆成两步:先用不带参数的插值(最近邻、双线性)把特征图放大,再用一个普通的3x3卷积做特征融合。这样做的好处有三点。
- 放大过程是确定的、平滑的,不会有覆盖不均的问题。
- 卷积部分的参数量更少,
3x3只有 9 个权重每个通道对,而4x4是 16 个。 - 尺寸能精确控制,插值的输出尺寸可以直接指定,不需要绕
output_padding那一圈。
代价是表达能力稍弱一点——上采样的方式是固定的,网络没法学出"每个位置该怎么放大"。但在大部分分割和重建任务里,这点损失几乎看不出来,换来的是稳定的视觉质量和更少的调参烦恼。
5.3 替代方案二:PixelShuffle 亚像素卷积
另一个思路是PixelShuffle(在有些框架里叫 depth_to_space 或 sub-pixel convolution)。做法是:先用一个普通卷积把通道数从C扩到C * r * r,然后把通道维度按r x r的排布重排成空间维度,空间尺寸就变成了原来的r倍。整个过程只有一次普通卷积,不存在步长,自然也没有覆盖不均的问题。
这个方案的计算效率通常更高,因为卷积是在低分辨率上做的,通道数虽然涨了r²倍,但空间维度小,总的浮点运算量往往比直接在低分辨率上做转置卷积更低。它在超分辨率领域用得非常多。
5.4 三种方案的横向对比
| 方案 | 参数量 | 棋盘效应 | 表达能力 | 适用场景 |
|---|---|---|---|---|
| 转置卷积 | 中 | 取决于 k/s 组合 | 强 | 需要学习上采样方式、对效果要求高 |
| 插值 + 卷积 | 少 | 无 | 中 | 分割解码器、快速原型、图像重建 |
| PixelShuffle | 中 | 无 | 强 | 超分辨率、生成模型、对速度敏感 |
我自己的默认选择顺序是:先试"插值 + 卷积",效果不够再换 PixelShuffle,只有在明确知道kernel能被stride整除、而且需要最强的表达能力时,才直接用转置卷积。这个顺序没有理论依据,纯粹是踩坑经验——前两种方案出问题的概率低得多。
6. 代码实现:从手写 numpy 到框架一行调用
理论讲完了,该动手了。我会先给一个纯 numpy 的版本,把每一步都摊开,然后再对照框架的接口,把参数逐个解释清楚。手写一遍的价值在于,以后再遇到尺寸或者形状的问题,你能立刻在脑子里模拟出计算过程。
6.1 numpy 手写 2D 转置卷积
import numpy as np def conv_transpose2d(x, w, stride=1, padding=0, output_padding=0): """ x: 输入,形状 (H, W) w: 卷积核,形状 (kh, kw) 返回: 输出,形状 (H_out, W_out) """ kh, kw = w.shape H, W = x.shape H_out = (H - 1) * stride - 2 * padding + kh + output_padding W_out = (W - 1) * stride - 2 * padding + kw + output_padding out = np.zeros((H_out, W_out), dtype=np.float64) for i in range(H): for j in range(W): # 该输入元素在输出画布上的基准位置 r0 = i * stride - padding c0 = j * stride - padding for a in range(kh): for b in range(kw): rr, cc = r0 + a, c0 + b # 越界的位置直接丢弃,这就是 padding 的作用 if 0 <= rr < H_out and 0 <= cc < W_out: out[rr, cc] += x[i, j] * w[a, b] return out跑一个例子验证一下:
x = np.array([[1., 2.], [3., 4.]]) w = np.array([[1., 0.], [0., 1.]]) print(conv_transpose2d(x, w, stride=2, padding=0))输出应该是一个 4x4 矩阵,对角线区域上有我们前面手算过的那些值。你可以把stride改成 1、把padding改成 1,观察输出尺寸和数值怎么变。这个小实验做一遍,比读十页公式管用。
顺便说一句,这段代码是四层嵌套循环,慢得离谱,只适合理解原理。真实项目里绝对不要用,框架的 GPU 实现比它快几个数量级。
6.2 PyTorch 里参数逐个拆
import torch import torch.nn as nn m = nn.ConvTranspose2d( in_channels=3, out_channels=8, kernel_size=4, stride=2, padding=1, output_padding=0, bias=True, ) x = torch.randn(1, 3, 16, 16) y = m(x) print(y.shape) # torch.Size([1, 8, 32, 32]) print(m.weight.shape) # torch.Size([3, 8, 4, 4])这里有两个新手最容易栽的点,我单独拎出来说。
第一个是weight 的形状。Conv2d的权重是(out_channels, in_channels/groups, kh, kw),而ConvTranspose2d是(in_channels, out_channels/groups, kh, kw),前两维正好反过来。原因是转置卷积在数学上对应的是卷积的转置操作,权重矩阵的行列角色调换了。你要是从别处 copy 一段初始化代码,这个顺序搞错的话,要么直接报形状错误,要么更糟——不报错但学出乱七八糟的结果。
第二个是padding 的含义。在正向卷积里,padding是"在输入边缘补多少圈零";在转置卷积里,padding是"从输出边缘砍掉多少圈"。这个方向感特别容易搞反,因为它和你的直觉相反。记住一句话就不会错:正向补零,反向砍边。
6.3 亲手测一遍尺寸关系
我建议你新建一个脚本,把这两个层配成一对,亲手验证尺寸能不能对得上:
import torch import torch.nn as nn down = nn.Conv2d(8, 16, kernel_size=3, stride=2, padding=1) up = nn.ConvTranspose2d(16, 8, kernel_size=3, stride=2, padding=1, output_padding=1) x = torch.randn(2, 8, 32, 32) z = down(x) print("下采样后:", z.shape) # [2, 16, 16, 16] y = up(z) print("上采样后:", y.shape) # [2, 8, 32, 32]如果你把output_padding改成 0,上采样后就会变成 31x31,和原图差一格,后面做torch.cat或者相加直接报错。这个实验我做第一次的时候试了大概五六组参数,才真正把公式和参数对应起来,推荐你也花十分钟走一遍。
6.4 常见报错与排查顺序
| 报错或现象 | 可能原因 | 排查动作 |
|---|---|---|
| 尺寸拼接时 shape 不匹配 | output_padding没设对 | 用公式手算一遍再对着调 |
| 权重加载时报形状错误 | 权重维度顺序和 Conv2d 搞混 | 打印weight.shape确认前两维 |
| 输出全是零 | padding 过大,有效区域被砍光 | 检查2*padding是否接近 kernel |
| 结果有明显的规律条纹 | 棋盘效应 | 换成插值 + 卷积验证 |
| 数值爆炸或全 NaN | 初始化方差过大 | 换用更小的初始化标准差 |
7. 它到底被用在哪些地方
绕了一圈原理,回到实用层面。转置卷积之所以值得花时间学,是因为它在几个大方向上几乎是绕不开的组件。
7.1 语义分割的解码器
分割网络的典型结构是"编码器不断下采样、解码器不断上采样"。编码器把分辨率降下来提取语义,解码器要把它升回原图尺寸,好让每个像素都有分类结果。这个上升的过程,早期主流做法就是用转置卷积。后面大家发现棋盘效应会让分割边界出现锯齿,逐渐改成"插值 + 卷积"的组合,但转置卷积依然是很多经典网络结构的组成部分,读懂它对理解这些网络至关重要。
7.2 特征图放大与超分辨率
超分辨率任务本身就是"把小图变成大图",转置卷积天然适合。不过这里更常见的做法是 PixelShuffle,原因是它速度快、没有棋盘纹。如果你只是想在网络中间把特征图放大两倍,而且不希望引入额外的视觉 artifact,优先考虑 PixelShuffle。
7.3 生成模型里的上采样路径
从随机噪声生成图像的网络,几乎都要经历"从低分辨率一路放大到高分辨率"的过程。这条路径上的每一步放大,候选方案就是转置卷积、插值加卷积、PixelShuffle 这三样。早期生成网络偏爱转置卷积,后来因为棋盘效应换到其他方案的也不少。做这类项目的时候,建议把三种方案都跑一遍,看哪种在你自己的数据上视觉效果更干净。
7.4 什么时候不该用它
有几种情况我建议你直接放弃转置卷积。第一种是数据量很小、模型容量本来就吃紧的时候,转置卷积的参数量比"插值 + 卷积"多不少,容易过拟合。第二种是对输出像素的数值范围有严格要求的时候,比如要做逐像素的回归,转置卷积的覆盖不均会让某些位置的值系统性偏移。第三种是你需要精确的可逆变换时——直说,转置卷积做不到,任何方案都做不到,卷积本身丢的信息是找不回来的。
8. 我在实际项目里踩过的几个坑
最后这部分是我自己攒下来的,文档里基本不会写,但踩上去会浪费你半天时间。
第一个坑是初始化。转置卷积的默认初始化方差往往偏大,尤其在上采样倍数高的时候,输出数值范围很容易炸。我的做法是手动设一个更小的标准差,或者在转置卷积前面加一层归一化。具体数值跟任务相关,但经验上把标准差压到 0.02 左右通常比较安全,你可以在自己的数据上微调。
第二个坑是和 BatchNorm 的顺序。有人习惯"转置卷积 → BN → 激活",有人习惯"BN → 转置卷积 → 激活"。这两种在尺寸和数值上都没问题,但如果你在很小的 batch size 下训练,BN 放在转置卷积之前往往更稳,因为归一化处理的是低分辨率的特征图,统计量更可靠。这个差别不大,但在我遇到过的两次训练不收敛里,调换顺序确实起作用了。
第三个坑是深度可分离的转置卷积。当你用groups参数做分组卷积时,转置卷积的行为和普通卷积不完全对称。具体表现在通道的分组方式上,如果你从普通卷积迁过来,记得检查groups和in_channels、out_channels的整除关系,以及权重形状里第二个维度要除以groups。这类错误有时候不报异常,只是静默地算错,调试起来很痛苦。
第四个坑是和裁剪操作配合时的尺寸。当你的输入尺寸不是stride的整数倍时,下采样会丢掉边缘的几个像素,上采样之后就对不齐了。解决办法有两个:一是把输入裁剪或缩放到stride的整数倍,二是在解码器一侧统一用output_padding补回来。我一般选第一种,因为在数据预处理阶段解决,比在模型里到处补参数要清爽得多。
第五个坑比较隐蔽:同样的配置在不同框架里行为可能不同。kernel、stride、padding这套参数在主流框架里语义是一致的,但output_padding的默认值和取值范围偶尔有差异。跨框架移植模型的时候,别只看权重能不能加载成功,一定要跑一遍前向,把每一层的输出形状打印出来对一遍。这一步花五分钟,能省下后面几小时的困惑。
还有一个小技巧分享给你:如果你实在搞不清楚某个转置卷积层的输出尺寸,不用算,直接把输入设成一个很小的张量(比如 2x2)跑一次前向,打印形状就行。这个方法听起来笨,但在参数组合复杂的时候效率极高,我现在调新结构基本都这么干。