PyTorch 中的转置卷积 ConvTranspose2d
现有的关于转置卷积的介绍大多流于表面,并未详细的说明这一操作内部具体的操作流程。由于转置卷积的设计主要是为了对标标准卷积,所以其实现流程与标准卷积基本相反,所以内部的操作逻辑并不直观。其按照卷积的相反逻辑的参数设置方式,这种反逻辑的形式使得我们很难直接从参数的角度去理解。
torch.nn.ConvTranspose2d(in_channels, out_channels, kernel_size, stride=1, padding=0, output_padding=0, groups=1, bias=True, dilation=1, padding_mode='zeros', device=None, dtype=None)
This module can be seen as the gradient of Conv2d with respect to its input. It is also known as a fractionally-strided convolution or a deconvolution (although it is not an actual deconvolution operation as it does not compute a true inverse of convolution). For more information, see the visualizations here and the Deconvolutional Networks paper.
这里面涉及到了多个参数,包括 in_channels, out_channels, kernel_size, groups=1, bias=True, dilation=1, padding_mode='zeros', device=None, dtype=None
这样的一看就可以理解对的参数,也有一些实际情况和我们想的并不一致的参数 stride=1, padding=0, output_padding=0
。
首先要明确的一点,由于转置卷积可以看做是标准卷积的相反流程(虽然细节处理不同,并非真正的转置),考虑到标准卷积的直观性,所以我们可以从对应的标准卷积的角度去理解转置卷积的参数含义。即反着来看参数的作用。对于相同的参数设置,转置卷积输出和输出的形状,一般与标准卷积的输入和输出一致。
对于 2d 形式,我们分别设置 kernel_size, stride, padding, output_padding
为
k
,
s
,
p
,
p
o
k, s, p, p_o
k,s,p,po:
- 输入形状为 ( N , C i , H i , W i ) (N, C_i, H_i, W_i) (N,Ci,Hi,Wi) 或者为 ( C i , H i , W i ) (C_i, H_i, W_i) (Ci,Hi,Wi)
- 输出形状为 ( N , C o , H o , W o ) (N, C_o, H_o, W_o) (N,Co,Ho,Wo) 或者为 ( C o , H o , W o ) (C_o, H_o, W_o) (Co,Ho,Wo)
- 卷积权重形状为 ( C i , C o / / G , k [ 0 ] , k [ 1 ] ) (C_i, C_o//G, k[0], k[1]) (Ci,Co//G,k[0],k[1])
- 卷积偏置形状为 ( C o ) (C_o) (Co)
- H o = ( H i − 1 ) × s [ 0 ] − 2 p [ 0 ] + d [ 0 ] × ( k [ 0 ] − 1 ) + p o [ 0 ] + 1 H_o = (H_i - 1) \times s[0] - 2p[0] + d[0] \times (k[0] - 1) + p_o[0] + 1 Ho=(Hi−1)×s[0]−2p[0]+d[0]×(k[0]−1)+po[0]+1
- W o = ( W i − 1 ) × s [ 1 ] − 2 p [ 1 ] + d [ 1 ] × ( k [ 1 ] − 1 ) + p o [ 1 ] + 1 W_o = (W_i - 1) \times s[1] - 2p[1] + d[1] \times (k[1] - 1) + p_o[1] + 1 Wo=(Wi−1)×s[1]−2p[1]+d[1]×(k[1]−1)+po[1]+1
转置卷积的过程
总体而言,结合对应的标准卷积,转置卷积的计算可以拆分为两部分:调整形状和局部聚合。这里的介绍可以结合 A guide to convolution arithmetic for deep learning 中提供的图示进行理解。
k = 3 , s = 1 , p = 0 k=3,s=1,p=0 k=3,s=1,p=0 | k = 3 , s = 2 , p = 0 k=3,s=2,p=0 k=3,s=2,p=0 |
k = 4 , s = 1 , p = 2 k=4,s=1,p=2 k=4,s=1,p=2 | k = 3 , s = 1 , p = 1 k=3,s=1,p=1 k=3,s=1,p=1 |
k = 3 , s = 2 , p = 1 k=3,s=2,p=1 k=3,s=2,p=1 | k = 3 , s = 2 , p = 1 , p o = 1 k=3,s=2,p=1,p_o=1 k=3,s=2,p=1,po=1 |
调整形状
实际上,转置卷积最难理解的还是这一步。
对输入使用 stride 处理,注意,转置卷积的 stride 并不同于标准卷积,而是在各个输入元素之间插入 s − 1 s-1 s−1 个 0。这样的设定,可以使得转置卷积的输入数据,就像是转置卷积的输出数据通过标准卷积经过这样的 stride 滑动后得到的一样。那些转置卷积的输入数据中间插进去的 0,实际上就是这种标准卷积因为步长的设定而被跳过的输出。
而且另外一点需要注意,为了确保对卷积输入输出运算过程形状的对应性,所以 在卷积核滑动之前,须要在输入的四边上进行 padding,这里默认 padding 的值与卷积核的形状有关,即四边的 padding 为 ( k [ 0 ] − 1 , k [ 1 ] − 1 , k [ 0 ] − 1 , k [ 1 ] − 1 ) (k[0]-1, k[1]-1, k[0]-1, k[1]-1) (k[0]−1,k[1]−1,k[0]−1,k[1]−1)。
这里需要强调的是,不论如何,转置卷积的本质还是卷积,仍然是对输入的局部聚合。所以如果不考虑 padding 和插 0 的情况,输出必然要比输入的尺寸要小。
所以当 s = ( 1 , 1 ) s=(1,1) s=(1,1) 的时候,输入需要 padding 才能够实现输出尺寸的增大。
- p [ ⋆ ] = 0 p[\star]=0 p[⋆]=0:这是转置卷积的默认情况,此时转置卷积会对输入进行隐式的 padding,如前所述为 ( k [ 0 ] − 1 , k [ 1 ] − 1 , k [ 0 ] − 1 , k [ 1 ] − 1 ) (k[0]-1, k[1]-1, k[0]-1, k[1]-1) (k[0]−1,k[1]−1,k[0]−1,k[1]−1)。
- p [ ⋆ ] > 1 p[\star] > 1 p[⋆]>1:相当于是标准卷积 p [ ⋆ ] > 1 p[\star] > 1 p[⋆]>1 的时候。在其他参数不变的时候,标准卷积的输出会相对扩大,对应回来,也就是转置卷积的输出应该相对缩小。于是我们可以看到, p p p 实际上起到一个反向调整的作用,即在默认的隐式 padding 上减去转置卷积设置中的 p p p。因而实际的隐式 padding 会变为 ( k [ 0 ] − 1 − p [ 0 ] , k [ 1 ] − 1 − p [ 1 ] , k [ 0 ] − 1 − p [ 0 ] , k [ 1 ] − 1 − p [ 1 ] ) (k[0]-1-p[0], k[1]-1-p[1], k[0]-1-p[0], k[1]-1-p[1]) (k[0]−1−p[0],k[1]−1−p[1],k[0]−1−p[0],k[1]−1−p[1])。
[!important] p p p 的取值范围
结合上面的推理,进一步可以推测出转置卷积中对 p p p 的限制。
- 首先必须大于等于 0,所以左边界为 0。
- 对于右侧边界,必须要保证一点,即经过默认 padding 调整后的输入,在使用 p p p 剪裁后剩下的必须大于等于 k [ 0 ] × k [ 1 ] k[0] \times k[1] k[0]×k[1],也就是卷积操作必须有效。所以可以推算出,最大值为 k [ 0 ] − 1 + ( H i − k [ 0 ] ) / / 2 k[0]-1+(H_i-k[0])//2 k[0]−1+(Hi−k[0])//2 和 k [ 1 ] − 1 + ( W i − k [ 1 ] ) / / 2 k[1]-1+(W_i-k[1])//2 k[1]−1+(Wi−k[1])//2。
[!questions] 操作顺序
此处可能有一个问题,究竟是“先将隐式 padding 使用 p p p 处理后再卷积?”还是“先基于默认隐式 padding 卷积后再使用 p p p 来剪裁数据?”从实际效果上来看二者是一样的。所以问题不大。
这里有一个额外的参数 p o p_o po 也非常重要。在原始文档中提到,这一参数主要的用处是为了保证在 s [ ⋆ ] > 0 s[\star]>0 s[⋆]>0 的时候可以在形状上对齐标准卷积。
对于标准卷积而言,如果 s [ ⋆ ] > 0 s[\star]>0 s[⋆]>0,则同一种输出形状可以存在多种输入相形状。所以转置卷积通过用户指定的参数来消除这种不确定性,从而明确输出形状的具体尺寸。如果输出形状不合适,可以使用这一参数来进行单边的补齐。注意,这一参数仅是作用于单边,对于 2D 情况,在 H 和 W 轴的末端上补 0。
另外,虽然文档提到“Note that output_padding is only used to find output shape, but does not actually add zero-padding to output.”,但是从实际效果来看,就是 0 的补齐,文档这一句话应该是在强调内部实现并非直接补 0。
局部聚合
使用卷积核在插 0 后的输入上滑动从而获得初步的输出。要注意,这里的 stride 参数并不会影响转置卷积本身卷积核的滑动,可以认为转置卷积核步长始终为 1。
使用标准卷积实现转置卷积
如果单纯使用框架自带的卷积函数,标准卷积只能实现
s
=
1
s=1
s=1 的转置卷积。而且在使用相同的卷积参数的时候,需要注意的是卷积权重的索引顺序。从 PyTorch 中的转置卷积详解——全网最细 中我们可以知道,如果使用相同的卷积权重,标准卷积与转置卷积的权重索引方式不同,需要进行 .flip(dim=*)
来调整。
典型案例为:
# 1-D
In [59]: a = torch.arange(0, 3, 1).float().reshape(1, 1, 3)
In [60]: b = torch.arange(3, 6, 1).float().reshape(1, 1, 3)
In [65]: F.conv_transpose1d(a, b, stride=1, padding=0, output_padding=0)
Out[65]: tensor([[[ 0., 3., 10., 13., 10.]]])
In [66]: F.conv1d(a, b.transpose(0, 1).flip(-1), stride=1, padding=2)
Out[66]: tensor([[[ 0., 3., 10., 13., 10.]]])
# 2-D
In [67]: a = torch.arange(0, 9, 1).float().reshape(1, 1, 3, 3)
In [68]: b = torch.arange(3, 12, 1).float().reshape(1, 1, 3, 3)
In [69]: F.conv_transpose2d(a, b, stride=1, padding=0, output_padding=0)
Out[69]:
tensor([[[[ 0., 3., 10., 13., 10.],
[ 9., 30., 65., 62., 41.],
[ 36., 99., 192., 165., 102.],
[ 63., 150., 263., 206., 119.],
[ 54., 123., 208., 157., 88.]]]])
In [71]: F.conv2d(a, b.transpose(0, 1).flip(-1).flip(-2), stride=1, padding=2)
Out[71]:
tensor([[[[ 0., 3., 10., 13., 10.],
[ 9., 30., 65., 62., 41.],
[ 36., 99., 192., 165., 102.],
[ 63., 150., 263., 206., 119.],
[ 54., 123., 208., 157., 88.]]]])
[!important] 标准卷积与转置卷积共用权重需要注意的地方
PyTorch 中标准卷积与转置卷积的权重形状中,输入输出维度恰好相反,所以相同的权重需要进行额外交换轴的操作,即上面代码中.transpose(0, 1)
处理。