
PyTorch Conv2d applies learned filters across an image’s height and width. For a batch, its input shape is (N, C, H, W): images, channels, height, then width. The output has the same batch size, the chosen number of output channels, and spatial dimensions determined by the kernel, stride, padding, and dilation.
This guide uses five small CPU examples with fixed weights. We ran 28 checks with Python 3.12.14 and PyTorch 2.15.0+cpu, including independent calculations of every output value in the shape examples. Each Python block runs independently with PyTorch installed. No image files, pretrained model, or dataset are required.
The Conv2d API reference describes the layer’s parameters. Here, one single-channel image is three pixels high and four pixels wide. Its values run from 1 to 12. The filter is two rows high and three columns wide:
import torch
x = torch.arange(1, 13, dtype=torch.float32).reshape(1, 1, 3, 4)
layer = torch.nn.Conv2d(1, 1, kernel_size=(2, 3), bias=False)
with torch.no_grad():
layer.weight.copy_(torch.tensor([[[[1., 0., -1.], [1., 0., -1.]]]]))
y = layer(x)
print('Input:', tuple(x.shape))
print('Output:', tuple(y.shape))
print('Values:', y[0, 0].detach().tolist())
Observed output:
Input: (1, 1, 3, 4)
Output: (1, 1, 2, 2)
Values: [[-4.0, -4.0], [-4.0, -4.0]]
The top-left input patch contains rows [1, 2, 3] and [5, 6, 7]. Multiplying by the displayed filter gives (1 - 3) + (5 - 7) = -4. Moving right, then down, gives the other three outputs. Two vertical positions and two horizontal positions fit without padding.
We deliberately set the coefficients and disabled the bias. A normal training process would learn the coefficients. The operation uses the filter in its displayed order, without flipping it; that is cross-correlation in mathematical terminology. These values expose the arithmetic rather than demonstrate a trained image detector.
A color-image pipeline may supply arrays shaped (N, H, W, C). Three channel values sit together at each pixel. Conv2d expects the channel axis before height and width, so use permute to rearrange those axes.
Our synthetic batch contains two images, each four pixels high and five pixels wide, with three values per pixel:
import torch
# Two images: height 4, width 5, three values at each pixel.
raw = torch.arange(120, dtype=torch.float32).reshape(2, 4, 5, 3)
x = raw.permute(0, 3, 1, 2)
layer = torch.nn.Conv2d(3, 2, kernel_size=(2, 3), bias=False)
with torch.no_grad():
layer.weight.fill_(1.)
y = layer(x)
print('Input:', tuple(x.shape))
print('First channel, first row:', x[0, 0, 0].tolist())
print('Weight:', tuple(layer.weight.shape))
print('Output:', tuple(y.shape))
print('First output row:', y[0, 0, 0].detach().tolist())
Observed output:
Input: (2, 3, 4, 5)
First channel, first row: [0.0, 3.0, 6.0, 9.0, 12.0]
Weight: (2, 3, 2, 3)
Output: (2, 2, 3, 3)
First output row: [207.0, 261.0, 315.0]
The first channel’s first row remains [0, 3, 6, 9, 12]. A reshape to the same target dimensions produced different values in our check, so it would not be a valid substitute for this permutation.
The filter weights have shape (2, 3, 2, 3): two output channels, three input channels per filter, then kernel height and width. The first output sums 18 values across the three input planes and the two-by-three spatial patch, giving 207. Both output channels match because we assigned all their weights the same value.
Output channels are learned feature maps; choosing two does not turn an RGB image into a two-color image. Also inspect what your loader already returns. Applying a permutation to a batch that is already NCHW would scramble its axis meanings. Our PyTorch DataLoader guide covers the batching step.
For a grayscale batch shaped (N, H, W), adding a channel axis with unsqueeze(1) expresses one channel per image. Do not assume every three-dimensional tensor is a batch: our separate check confirmed that Conv2d also accepts an unbatched (C, H, W) input.
For integer padding applied symmetrically on each spatial axis, calculate:
H_out = floor((H_in + 2p_h - d_h(k_h - 1) - 1) / s_h) + 1
W_out = floor((W_in + 2p_w - d_w(k_w - 1) - 1) / s_w) + 1
The first element of a parameter pair controls height; the second controls width. A scalar applies to both. With an input of 5 × 7, a 3 × 3 kernel, stride (2, 3), no padding, and dilation 1, the output is 2 × 2.
The next example varies one spatial setting at a time, then combines a rectangular kernel with different settings on each axis:
import torch
x = torch.arange(1, 36, dtype=torch.float32).reshape(1, 1, 5, 7)
cases = [
('valid', dict(kernel_size=3)),
('pad 1', dict(kernel_size=3, padding=1)),
('stride (2, 3)', dict(kernel_size=3, stride=(2, 3))),
('dilation (2, 1)', dict(kernel_size=3, dilation=(2, 1))),
('rectangle', dict(kernel_size=(2, 3), stride=(2, 1), padding=(0, 1))),
]
for label, options in cases:
layer = torch.nn.Conv2d(1, 1, bias=False, **options)
with torch.no_grad():
layer.weight.fill_(1.)
y = layer(x)
print(label, tuple(y.shape), 'top-left:', y[0, 0, 0, 0].item())
Observed output:
valid (1, 1, 3, 5) top-left: 81.0
pad 1 (1, 1, 5, 7) top-left: 20.0
stride (2, 3) (1, 1, 2, 2) top-left: 81.0
dilation (2, 1) (1, 1, 1, 5) top-left: 144.0
rectangle (1, 1, 2, 7) top-left: 20.0
With padding 1, the top-left window sees zeros outside the image. Only values 1, 2, 8, and 9 contribute, producing 20. With dilation (2, 1), the first window reads image rows 0, 2, and 4 while keeping adjacent columns. Its sum is 144. The kernel still has nine coefficients; those coefficients span a taller patch.
For the rectangle case, padding=(0, 1) adds no rows above or below, and one column on each horizontal edge. It is a height/width pair, not a left/right pair. The output is two rows by seven columns. We checked the complete output arrays for all five cases against a separate nested-loop calculation, not just their printed corner values.
A separate check showed that padding='same' retains height and width at stride 1, while constructing this mode with stride 2 raises an error in the tested version. Another check rejected a kernel wider than the unpadded image. Increasing output channels does not make an oversized spatial window fit.
This batch contains one image with two channels. The second plane is ten times the first. Compare full connectivity with two separate channel groups:
import torch
plane = torch.arange(1, 10, dtype=torch.float32).reshape(1, 1, 3, 3)
x = torch.cat([plane, 10 * plane], dim=1)
for groups in (1, 2):
layer = torch.nn.Conv2d(2, 2, kernel_size=2, groups=groups, bias=False)
with torch.no_grad():
layer.weight.fill_(1.)
print('Groups:', groups, 'Weight:', tuple(layer.weight.shape))
print('Values:', layer(x)[0].detach().tolist())
Observed output:
Groups: 1 Weight: (2, 2, 2, 2)
Values: [[[132.0, 176.0], [264.0, 308.0]], [[132.0, 176.0], [264.0, 308.0]]]
Groups: 2 Weight: (2, 1, 2, 2)
Values: [[[12.0, 16.0], [24.0, 28.0]], [[120.0, 160.0], [240.0, 280.0]]]
The first two-by-two patch sums to 12 in channel one and 120 in channel two. With groups=1, both output filters add the two contributions, giving 132. With groups=2, one output reads the first channel and the other reads the second.
The grouped layer has eight coefficients instead of sixteen in this example. We also tested a depthwise multiplier of two using Conv2d(2, 4, 2, groups=2): each input plane produced two separate outputs. Invalid channel counts that were not divisible by the group count were rejected. Fewer coefficients do not establish a speed advantage; we did not benchmark these layers.
PyTorch’s channels_last memory format changes how tensor elements are arranged in memory. It does not change the meaning or order of the dimensions reported by shape. The official memory-format tutorial explains that distinction.
Here, a one-by-one filter also makes the per-pixel channel sum easy to check:
import torch
x = torch.arange(120, dtype=torch.float32).reshape(2, 3, 4, 5)
x_cl = x.contiguous(memory_format=torch.channels_last)
layer = torch.nn.Conv2d(3, 2, kernel_size=1, bias=False)
with torch.no_grad():
layer.weight.fill_(1.)
print('Shapes:', tuple(x.shape), tuple(x_cl.shape))
print('Strides:', x.stride(), x_cl.stride())
print('Same values:', torch.equal(x, x_cl))
print('Same output:', torch.equal(layer(x), layer(x_cl)))
Observed output:
Shapes: (2, 3, 4, 5) (2, 3, 4, 5)
Strides: (60, 20, 5, 1) (60, 1, 15, 3)
Same values: True
Same output: True
Both tensors still report (2, 3, 4, 5). Their strides differ, but the indexed values and convolution outputs match. The first output row in our additional check was [60, 63, 66, 69, 72], the sum of three channel values at each pixel. A one-by-one kernel can mix channels without reading neighboring pixels.
Do not permute an NCHW tensor to NHWC merely because you want channels-last storage. Choose the logical axis order first. Consider memory format afterward, and measure any performance effect in your own environment. This CPU demonstration establishes value preservation, not a GPU speedup.
We also tested a backward pass through a two-by-two filter of ones on a three-by-three input. Summing the four outputs produced input gradients [[1, 2, 1], [2, 4, 2], [1, 2, 1]]. The center pixel participates in four windows. This verifies gradient flow for the example, not convergence on an image task.
Once these mechanics are correct, use the task to guide the wider model design. Our neural network architecture guide covers that decision. These examples use float32 CPU tensors and do not test image preprocessing quality, model accuracy, mixed precision, CUDA, or throughput. The environment emitted a missing-NumPy initialization warning; the tensor-only checks completed without NumPy.
Pick one AI, compute, or storage workload and see the difference for yourself. Spin it up in minutes, or let our team map your fastest path to production.