Transposed Convolution

Transposed Convolution

A standard convolution + pooling stack reduces spatial resolution. For dense prediction (semantic segmentation, generative models, super-resolution) we need to go the other way — upsample features back to image resolution.

The standard tool: transposed convolution, also called “deconvolution” (a misnomer — it’s not a true inverse). Each input element broadcasts a full kernel into the output, contributions from neighbors get summed:

A 2 \times 2 transposed convolution: each input element scatters its kernel into the output.

Output shape grows: with stride 1, kernel k, no padding, n_{\text{out}} = n_{\text{in}} + k - 1. With stride s, multiplied accordingly.

From-scratch implementation

import tensorflow as tf
from d2l import tensorflow as d2l
def trans_conv(X, K):
    h, w = K.shape
    Y = tf.Variable(d2l.zeros((X.shape[0] + h - 1, X.shape[1] + w - 1)))
    for i in range(X.shape[0]):
        for j in range(X.shape[1]):
            Y[i: i + h, j: j + w].assign(
                Y[i: i + h, j: j + w] + X[i, j] * K)
    return Y.value()

Verify on a small example

The hand-written implementation should match the framework operator. If the shape or values differ, the usual culprits are padding semantics or channel layout:

X = d2l.tensor([[0.0, 1.0], [2.0, 3.0]])
K = d2l.tensor([[0.0, 1.0], [2.0, 3.0]])
trans_conv(X, K)
<tf.Tensor: shape=(3, 3), dtype=float32, numpy=
array([[ 0.,  0.,  1.],
       [ 0.,  4.,  6.],
       [ 4., 12.,  9.]], dtype=float32)>

Same result via the framework op (PyTorch ConvTranspose2d, etc.):

Padding, stride, channels

Padding here removes output rows/columns instead of adding them — it’s the inverse interpretation.

Stride > 1 inserts zeros between input elements before the scatter — that’s how transposed conv upsamples:

Stride-2 transposed conv: each input element’s kernel is placed at twice-spaced positions, then summed.

Multi-channel works as expected: input channels reduce-add through the kernel, output channels stack in parallel:

Connection to matrix transposition

A standard convolution can be written as a sparse matrix multiplication \mathbf{y} = \mathbf{K}\mathbf{x} where \mathbf{K} encodes the kernel + stride + padding.

A transposed convolution multiplies by the transpose: \mathbf{x}' = \mathbf{K}^\top \mathbf{y}. That’s where the name comes from.

X = d2l.reshape(d2l.arange(9.0), (3, 3))
K = d2l.tensor([[1.0, 2.0], [3.0, 4.0]])
Y = d2l.corr2d(X, K)
Y
<tf.Variable 'Variable:0' shape=(2, 2) dtype=float32, numpy=
array([[27., 37.],
       [57., 67.]], dtype=float32)>
def kernel2matrix(K):
    k = tf.Variable(d2l.zeros(5))
    W = tf.Variable(d2l.zeros((4, 9)))
    k[:2].assign(K[0, :])
    k[3:5].assign(K[1, :])
    W[0, :5].assign(k)
    W[1, 1:6].assign(k)
    W[2, 3:8].assign(k)
    W[3, 4:].assign(k)
    return W.value()

W = kernel2matrix(K)
W
<tf.Tensor: shape=(4, 9), dtype=float32, numpy=
array([[1., 2., 0., 3., 4., 0., 0., 0., 0.],
       [0., 1., 2., 0., 3., 4., 0., 0., 0.],
       [0., 0., 0., 1., 2., 0., 3., 4., 0.],
       [0., 0., 0., 0., 1., 2., 0., 3., 4.]], dtype=float32)>

Matrix view (cont.)

Y == d2l.reshape(d2l.matmul(W, d2l.reshape(X, (-1, 1))), (2, 2))
<tf.Tensor: shape=(2, 2), dtype=bool, numpy=
array([[ True,  True],
       [ True,  True]])>
Z = trans_conv(Y, K)
Z == d2l.reshape(d2l.matmul(d2l.transpose(W), d2l.reshape(Y, (-1, 1))), (3, 3))
<tf.Tensor: shape=(3, 3), dtype=bool, numpy=
array([[ True,  True,  True],
       [ True,  True,  True],
       [ True,  True,  True]])>

Recap

  • Transposed conv = upsampling op; each input element scatters a full kernel into the output and overlapping contributions sum.
  • Stride > 1 inserts zeros between inputs → upsamples by s.
  • Mathematically the transpose of a normal convolution’s matrix form (hence the name).
  • Workhorse for FCN, U-Net, GAN generators, VAE decoders.
  • Modern alternative: bilinear upsample + 3×3 conv — avoids checkerboard artifacts that transposed conv can produce.