Fall 2026
  • Discord
  • Gradescope
  • Syllabus
  • Spring 2026

Demo: Does a Convolution Really Move with Its Input?

A shared convolutional kernel is shift equivariant, but the boundary rule is part of that statement. The setup creates a small image, a detector for horizontal changes in intensity, and a circular shift.

import numpy as np
import matplotlib.pyplot as plt
import torch
import torch.nn.functional as F

plt.style.use("../figure_style.mplstyle")

image = torch.zeros(1, 1, 32, 32)
image[:, :, 5:14, 2:10] = 1.0
for i in range(10, 25):
    image[:, :, i, i] = 0.7

kernel = torch.tensor([[-1.0, 0.0, 1.0],
                       [-2.0, 0.0, 2.0],
                       [-1.0, 0.0, 1.0]]).reshape(1, 1, 3, 3)
shift = (0, -6)  # vertical and horizontal displacement; change this pair
shifted_image = torch.roll(image, shifts=shift, dims=(-2, -1))

print("Image shape:", tuple(image.shape[-2:]), "shift:", shift)
Image shape: (32, 32) shift: (0, -6)

Question: When Do Shifting and Convolving Commute?

We compare two orders: convolve a shifted image, or shift the convolution of the original image. Circular padding wraps both the image and its neighborhoods. Zero padding instead introduces a special outside value at the boundary. Predict where the two comparisons can differ.

# Circular padding wraps neighborhoods at the image boundary.
padded_image = F.pad(image, (1, 1, 1, 1), mode="circular")
padded_shifted_image = F.pad(shifted_image, (1, 1, 1, 1), mode="circular")
convolved_image = F.conv2d(padded_image, kernel)
convolve_after_shift = F.conv2d(padded_shifted_image, kernel)
shift_after_convolve = torch.roll(convolved_image, shifts=shift, dims=(-2, -1))
circular_error = (convolve_after_shift - shift_after_convolve)[0, 0]

# Repeat the same comparison with zeros beyond the boundary.
convolved_image = F.conv2d(image, kernel, padding=1)
convolve_after_shift = F.conv2d(shifted_image, kernel, padding=1)
shift_after_convolve = torch.roll(convolved_image, shifts=shift, dims=(-2, -1))
zero_error = (convolve_after_shift - shift_after_convolve)[0, 0]

Both error panels use the same color scale. Zero means the two orders agree at that pixel.

fig, axes = plt.subplots(1, 4, figsize=(9, 2.8), layout="constrained")
axes[0].imshow(image[0, 0], cmap="gray_r", vmin=0, vmax=1)
axes[0].set_xlabel("Original Input")
axes[1].imshow(shifted_image[0, 0], cmap="gray_r", vmin=0, vmax=1)
axes[1].set_xlabel("Shifted Input")
vmax = max(circular_error.abs().max().item(), zero_error.abs().max().item(), 1e-9)
axes[2].imshow(circular_error, cmap="RdBu", vmin=-vmax, vmax=vmax)
axes[2].set_xlabel("Circular-Padding Error")
im = axes[3].imshow(zero_error, cmap="RdBu", vmin=-vmax, vmax=vmax)
axes[3].set_xlabel("Zero-Padding Error")
for ax in axes:
    ax.set_xticks([])
    ax.set_yticks([])
fig.colorbar(im, ax=axes[2:], fraction=0.045, pad=0.03, label="Difference")
plt.show()

print(f"Largest circular-padding error: {circular_error.abs().max().item():.2e}")
print(f"Largest zero-padding error:     {zero_error.abs().max().item():.2e}")

Largest circular-padding error: 0.00e+00
Largest zero-padding error:     4.00e+00

Answer. With circular padding, shifting and convolving commute up to floating-point precision. With zero padding, the equality fails where the wrapped shift changes which neighborhoods meet the image boundary.

Convolution provides equivariance relative to a specified boundary convention. Pooling, stride, and later nonlinear decisions can weaken the end-to-end symmetry further.