Skip to content
Skip to lesson
Back to Topics
LearnPreparation & PrerequisitesCNNs from Scratch
🏛️EasyModel Architecture

CNNs from Scratch

A two-pixel shift breaks a model that memorizes coordinates. Trace shared kernels, shapes, pooling, and padding on one 4 by 4 crop.

20 min read
Learning path
Step 20 of 199 in the full curriculum

Personalize this lesson

Adapt explanations and teaching visuals to your background and preferred voice.

A bright hairline mark cuts diagonally across an equipment panel photo. Shift that mark two pixels right, and any capable inspector still recognizes the scratch. A model that memorizes absolute pixel coordinates breaks on that tiny shift because each coordinate feeds a separate learned weight.

The previous lesson fed named tabular features into dense layers. Their order had to match the weight columns. Images add a useful structure: nearby pixels form edges, corners, and textures. Flattening preserves every pixel value and can be reversed if you know the shape. A dense layer connects coordinates to weights; local neighborhoods and shared detectors must come from the architecture or be learned from examples.

Convolutional neural networks tackle this challenge through two ideas: local receptive fields and weight sharing.[1][2] We trace one 4 x 4 panel crop through hand calculations, tensor dimensions, pooling layers, and a matching PyTorch forward pass to see how raw pixels turn into class scores.

Make reuse pay for itself

Suppose the inspection camera resizes images to 224 x 224 RGB pixels. Each pixel holds red, green, and blue intensities, giving three channels. Connecting that input to a modest hidden layer of 256 dense units gives every single unit a distinct weight for every pixel intensity.

A convolutional layer replaces that global wiring with localized detectors. Instead of observing all 150,528 numbers at once, a 3 x 3 RGB detector inspects a small window of 3 x 3 x 3 = 27 weights plus one bias scalar. That exact detector slides across every valid position in the image.

dense-versus-conv-parameters.py
height, width, channels, hidden = 224, 224, 3, 256
dense_weights = height * width * channels * hidden
dense_biases = hidden
dense_parameters = dense_weights + dense_biases

filters, kernel = 16, 3
conv_weights = filters * channels * kernel * kernel
conv_biases = filters
conv_parameters = conv_weights + conv_biases

print(f"dense parameters: {dense_parameters:,} ({dense_weights:,} weights + {dense_biases} biases)")
print(f"conv parameters: {conv_parameters:,} ({conv_weights:,} weights + {conv_biases} biases)")
print(f"parameter-count ratio: {dense_parameters / conv_parameters:,.0f}x")
Output
dense parameters: 38,535,424 (38,535,168 weights + 256 biases)
conv parameters: 448 (432 weights + 16 biases)
parameter-count ratio: 86,017x

The dense layer has over 38.5 million parameters; the convolutional layer uses 448. The ratio is about 86,000, but the outputs and connections differ: 256 global dense values versus sixteen 222 x 222 local feature maps. Compare the task outputs and measured speed separately.

Two architectural choices drive this reduction: local connectivity and weight sharing. Local connectivity restricts each output neuron to a tiny spatial neighborhood. Weight sharing reuses the identical set of weights across every spatial position.

To isolate the contribution of weight sharing, consider a locally connected layer with sixteen 3 x 3 RGB filters and independent weights at every position. Without padding, the output grid has 222 x 222 positions. Giving each position its own private weights balloons the count to 222 * 222 * 448 = 22,079,232 parameters. Weight sharing compresses those 22 million parameters down to 448 without altering the output dimensions.

A tied 16-filter bank is reused across a 222 by 222 field of local output positions, for 448 total parameters. Giving every position its own bank uses 22,079,232 parameters for the same output shape. A dense layer over a flattened 224 by 224 RGB crop uses 38,535,424 parameters and produces 256 scores.
One 16-filter bank serves all 222 × 222 output sites with 448 parameters. Independent banks keep that local output shape and use 22,079,232 parameters. A dense layer over the flattened RGB crop uses 38,535,424 parameters for 256 scores.

Beyond parameter savings, weight sharing introduces a mathematical property called . Formally, an operation ff is equivariant to translation operator TΔT_{\Delta} when shifting the input produces an identically shifted feature map:

f(TΔ(x))=TΔ(f(x))f(T_{\Delta}(x)) = T_{\Delta}(f(x))

For stride-one convolution away from boundary effects, shifting a pattern three pixels right shifts its response three pixels right. The same detector is reused at each position. On a finite crop, padding and a pattern entering or leaving the image can break that exact equality. With stride greater than one, arbitrary pixel shifts also change alignment with the sampling grid.

Equivariance differs from translation invariance, where g(TΔ(x))=g(x)g(T_{\Delta}(x)) = g(x) produces an unchanged output. Local pooling can provide some shift tolerance. A classification head is invariant only when its features and pooling preserve the relevant shifts. Global spatial averaging is unchanged by rearranging feature-map values; boundaries and downsampling can make a translated finite image produce different features.

Let one detector find one pattern

Examine a concrete grayscale crop representing a small defect on a dark metallic surface. Values range between 0.0 (dark surface) and 1.0 (bright scratch). Four bright values form a central 2 x 2 mark surrounded by background:

Input cropColumn 1Column 2Column 3Column 4
Row 10.050.080.060.07
Row 20.120.920.880.09
Row 30.080.900.930.11
Row 40.060.070.090.05

Select a 3 x 3 filter, also called a kernel. This kernel rewards bright middle rows while penalizing brightness in adjacent rows, acting as a horizontal line and ridge detector:

KernelColumn 1Column 2Column 3
Row 1-0.6-0.6-0.6
Row 21.11.11.1
Row 3-0.6-0.6-0.6

Placing a 3 x 3 window over a 4 x 4 grid yields 4 - 3 + 1 = 2 valid starting rows and 2 valid starting columns, producing a 2 x 2 output feature map.

Assuming zero bias for this trace, overlay the kernel on the upper-left 3 x 3 patch covering rows 0 to 2 and columns 0 to 2. Multiply aligned values elementwise and sum the products:

y0,0=(−0.6)(0.05+0.08+0.06)+(1.1)(0.12+0.92+0.88)+(−0.6)(0.08+0.90+0.93)=−0.114+2.112−1.146=0.852\begin{aligned} y_{0,0} ={}& (-0.6)(0.05 + 0.08 + 0.06) \\ &+ (1.1)(0.12 + 0.92 + 0.88) \\ &+ (-0.6)(0.08 + 0.90 + 0.93) \\ ={}& -0.114 + 2.112 - 1.146 \\ ={}& 0.852 \end{aligned}

The middle row contributes +2.112, exceeding the combined negative contributions from the neighboring rows (-0.114 and -1.146). The response before any activation function is 0.852.

one-convolution-window.py
patch = (
    (0.05, 0.08, 0.06),
    (0.12, 0.92, 0.88),
    (0.08, 0.90, 0.93),
)
kernel = (
    (-0.6, -0.6, -0.6),
    (1.1, 1.1, 1.1),
    (-0.6, -0.6, -0.6),
)

row_contributions = [
    round(sum(pixel * weight for pixel, weight in zip(row, kernel_row)), 3)
    for row, kernel_row in zip(patch, kernel)
]
response = sum(
    pixel * weight
    for row, kernel_row in zip(patch, kernel)
    for pixel, weight in zip(row, kernel_row)
)

print(f"row contributions: {row_contributions}")
print(f"first response: {response:.3f}")
Output
row contributions: [-0.114, 2.112, -1.146]
first response: 0.852

Slide the identical kernel across the remaining three positions. Each placement computes an inner product between kernel weights and the aligned image window:

WindowCrop rows, colsResponse
Top left0:3, 0:30.852
Top right0:3, 1:40.789
Bottom left1:4, 0:30.817
Bottom right1:4, 1:40.874

The bottom-right window yields 0.874, the highest activation on the grid. Its window aligns most favorably with the bright pixels in rows 2 and 3.

slide-the-kernel.py
import numpy as np

image = np.array([
    [0.05, 0.08, 0.06, 0.07],
    [0.12, 0.92, 0.88, 0.09],
    [0.08, 0.90, 0.93, 0.11],
    [0.06, 0.07, 0.09, 0.05],
])
kernel = np.array([
    [-0.6, -0.6, -0.6],
    [1.1, 1.1, 1.1],
    [-0.6, -0.6, -0.6],
])

def convolve_valid(image: np.ndarray, kernel: np.ndarray) -> np.ndarray:
    out_height = image.shape[0] - kernel.shape[0] + 1
    out_width = image.shape[1] - kernel.shape[1] + 1
    output = np.empty((out_height, out_width))
    for row in range(out_height):
        for col in range(out_width):
            window = image[row:row + kernel.shape[0], col:col + kernel.shape[1]]
            output[row, col] = np.sum(window * kernel)
    return output

feature_map = convolve_valid(image, kernel)
strongest = tuple(int(pos) for pos in np.unravel_index(np.argmax(feature_map), feature_map.shape))
print(np.round(feature_map, 3))
print("strongest response:", strongest)
Output
[[0.852 0.789]
 [0.817 0.874]]
strongest response: (1, 1)
A 4 by 4 grayscale crop and a 3 by 3 horizontal detector. The first window uses row sums 0.19, 1.92, and 1.91, producing a response of 0.852. The same kernel scans all four valid positions to make a 2 by 2 feature map with responses 0.852, 0.789, 0.817, and 0.874.
The highlighted upper-left 3 × 3 window produces 0.852 from its three row sums. Reusing the kernel at all four valid offsets fills the 2 × 2 feature map; the strongest response, 0.874, lands at the bottom right.

Mathematical convolution flips the kernel horizontally and vertically before computing these sliding sums. PyTorch uses cross-correlation, with no flip.[2] [3] With freely learned filters, either kernel orientation can be absorbed into the learned parameters, so both conventions represent the same operations. For a fixed or imported asymmetric kernel, the orientation changes its responses. Our vertically symmetric kernel happens to give the same result either way. That trace used one grayscale channel and windows that fit inside the crop. What happens to the output shape when the photo has color, or when the kernel has to see the border?

Shapes, channels, and padding contracts

The crop so far has one channel. Real images bring color channels, and larger models add stride, padding, dilation, and more filters. Tracking shapes keeps each dimension in view.

For an input spatial dimension WW, kernel size KK, padding PP, stride SS, and dilation rate DD, the output spatial dimension is:

Wout=⌊W+2P−D(K−1)−1S⌋+1W_{\text{out}} = \left\lfloor \frac{W + 2P - D(K - 1) - 1}{S} \right\rfloor + 1

When dilation D=1D = 1 (standard dense sampling), the effective kernel size equals KK, simplifying the formula to:

Wout=⌊W+2P−KS⌋+1W_{\text{out}} = \left\lfloor \frac{W + 2P - K}{S} \right\rfloor + 1

The floor operation rounds down, discarding incomplete windows that hang off the edge of the padded input tensor.

Padding controls border behavior:

  1. Valid padding (P=0P = 0): No padding added. At stride one, the spatial dimension shrinks by D(K−1)D(K - 1), or by K−1K - 1 when dilation is one.
  2. Same padding at stride one: Choose padding so Wout=WW_{\text{out}} = W. With dilation one and an odd kernel, symmetric padding P=(K−1)/2P = (K - 1)/2 works: one pixel for a 3 x 3 kernel, two for 5 x 5. Dilation changes the required total padding to D(K−1)D(K - 1); an odd total needs unequal padding on the two sides. PyTorch's padding='same' supports only stride one.[3]

Dilation (D>1D > 1) inserts (D−1)(D - 1) spaces between kernel elements without adding learned parameters. A 3×33 \times 3 kernel with dilation D=2D = 2 spans a 5×55 \times 5 receptive footprint while retaining only 9 parameter weights.

convolution-output-size.py
def conv_size(size: int, kernel: int, stride: int = 1, padding: int = 0, dilation: int = 1) -> int:
    if size <= 0 or kernel <= 0 or stride <= 0 or padding < 0 or dilation <= 0:
        raise ValueError("size, kernel, stride, and dilation must be positive; padding cannot be negative")
    effective_kernel = dilation * (kernel - 1) + 1
    if effective_kernel > size + 2 * padding:
        raise ValueError("effective kernel cannot exceed padded input size")
    return (size + 2 * padding - effective_kernel) // stride + 1

configurations = [
    ("worked crop", 4, 3, 1, 0, 1),
    ("same-size crop", 4, 3, 1, 1, 1),
    ("strided screenshot", 224, 3, 2, 1, 1),
    ("dilated feature map", 28, 3, 1, 0, 2),
]

for name, size, kernel, stride, padding, dilation in configurations:
    output = conv_size(size, kernel, stride, padding, dilation)
    print(f"{name}: {size} -> {output}")
Output
worked crop: 4 -> 2
same-size crop: 4 -> 4
strided screenshot: 224 -> 112
dilated feature map: 28 -> 24

Channels introduce a third dimension. In the ordinary groups=1 convolution used here, one filter has shape (Cin,K,K)(C_{\text{in}}, K, K) and spans all three RGB channels.

At each spatial stop, the filter computes a three-dimensional inner product across all channels and sums them into a single scalar response, adding one bias term. To generate FF distinct output feature maps, the layer uses FF separate filters, yielding a parameter tensor of shape (F,Cin,K,K)(F, C_{\text{in}}, K, K) plus FF biases.

rgb-filter-parameters.py
import numpy as np

image = np.zeros((3, 224, 224))
filters = np.zeros((16, 3, 3, 3))
biases = np.zeros(16)

assert filters.shape[1] == image.shape[0]
print("input shape:", image.shape)
print("one filter shape:", filters[0].shape)
print("output channels:", filters.shape[0])
print("parameters including biases:", filters.size + biases.size)
Output
input shape: (3, 224, 224)
one filter shape: (3, 3, 3)
output channels: 16
parameters including biases: 448

For a PyTorch batch with dimensions ordered NCHW (batch, channels, height, width), FF filters map (N,Cin,Hin,Win)(N, C_{\text{in}}, H_{\text{in}}, W_{\text{in}}) to (N,F,Hout,Wout)(N, F, H_{\text{out}}, W_{\text{out}}). Input and output channel counts can differ. Grouped convolution changes connectivity: each filter sees Cin/GC_{\text{in}}/G channels, and both input and output counts must be divisible by the group count GG.[3] NCHW describes axis order here, not a required physical memory layout.

Receptive fields and channel expansion

An activation in the first feature map sees a small 3×33 \times 3 input window. Stacking a second 3×33 \times 3 convolution layer enables each second-layer activation to view a 3×33 \times 3 patch of the first feature map. That patch depends on a 5×55 \times 5 region of the original input.

The input region that influences a specific unit's activation is its . Tracking receptive field expansion across layers requires tracking two state variables:[4]

  1. field: the side length of the receptive field in raw input pixel units.
  2. jump: the distance between adjacent activation centers in raw input pixel units.

For a layer with kernel size KK, stride SS, and dilation DD, its effective kernel span is D(K−1)+1D(K - 1) + 1:

fieldnew=field+D(K−1)⋅jump,jumpnew=jump⋅S\text{field}_{\text{new}} = \text{field} + D(K - 1) \cdot \text{jump}, \qquad \text{jump}_{\text{new}} = \text{jump} \cdot S

All layers in this trace have dilation one. field describes the theoretical footprint. Learned weights, activations, padding, and input determine each pixel's actual contribution; some pixels can have zero local gradient:

receptive-field-trace.py
layers = [
    ("conv 3x3", 3, 1),
    ("conv 3x3", 3, 1),
    ("pool 2x2", 2, 2),
    ("conv 3x3", 3, 1),
]

field, jump = 1, 1
for name, kernel, stride in layers:
    field = field + (kernel - 1) * jump
    jump = jump * stride
    print(f"{name:9s} receptive field={field:2d}, jump={jump}")
Output
conv 3x3  receptive field= 3, jump=1
conv 3x3  receptive field= 5, jump=1
pool 2x2  receptive field= 6, jump=2
conv 3x3  receptive field=10, jump=2

Notice how pooling accelerates receptive field growth. A stride of 2 doubles the jump to 2. The subsequent 3×33 \times 3 convolution adds (3−1)×2=4(3 - 1) \times 2 = 4 input pixels to the receptive field, reaching a 10×1010 \times 10 context window.

Many classification backbones reduce spatial resolution and increase channel count across stages, though the exact progression varies by architecture. More channels can hold more distinct feature responses; they aren't a necessary consequence of downsampling. The figure below uses an illustrative all-valid sequence matching our receptive-field calculation: convolution, convolution, pooling, then convolution.

Early activations depend on small neighborhoods; later activations can combine evidence across larger regions. A trained model may learn edge-like early filters and more complex combinations later. Tensor shapes describe geometry; inspect filter activations on inputs to identify learned features. Downsampling also loses detail, which matters when the defect is only a few pixels wide.

Nested pixel regions show one activation’s receptive field growing from 1 by 1 at the input to 3 by 3, 5 by 5, 6 by 6 after pooling, and 10 by 10 after the next convolution. A separate channel track shows 3, 16, 32, 32, and 64 channels as spatial maps change from 224 by 224 to 222, 220, 110, and 108.
The nested regions trace one activation’s receptive field from 1 × 1 through 3 × 3, 5 × 5, 6 × 6, and 10 × 10. The lower track keeps channel count separate from spatial size: map sides step through 224, 222, 220, 110, and 108; stride-two pooling doubles the jump to two input pixels.

Pooling mechanisms and gradient routing

Downsampling feature maps reduces the number of spatial positions processed by later layers, while pooling combines evidence over a larger footprint. It also discards detail. Pooling achieves this downsampling without introducing learnable weights.

Two pooling strategies you'll encounter often are:

  1. Max pooling: Selects the maximum value within each window. It acts as a feature detector: if a crack feature fires strongly anywhere in a 2×22 \times 2 window, max pooling preserves that peak response, offering localized translation tolerance.
  2. Average pooling: Computes the arithmetic mean within each window. Feature-map responses may represent learned patterns, including background intensity. A single large response contributes less to the mean than to the maximum.

Global Average Pooling (GAP) averages the spatial values separately for each channel, giving one value per channel and example. Starting with (N,C,H,W)(N, C, H, W), the result can retain shape (N,C,1,1)(N, C, 1, 1) or be flattened to (N,C)(N, C). A following QQ-class linear head needs CQ+QCQ + Q parameters instead of CHWQ+QCHWQ + Q. This saves parameters when HW>1HW > 1 and removes spatial information from the head; measure the effect on held-out accuracy.

Evaluate standard 2×22 \times 2 max pooling with stride 2 over this 4×44 \times 4 response grid:

Feature mapColumn 1Column 2Column 3Column 4
Row 10.100.420.180.30
Row 20.250.910.440.12
Row 30.080.210.770.55
Row 40.160.140.320.49
max-pooling-winners.py
import numpy as np

feature_map = np.array([
    [0.10, 0.42, 0.18, 0.30],
    [0.25, 0.91, 0.44, 0.12],
    [0.08, 0.21, 0.77, 0.55],
    [0.16, 0.14, 0.32, 0.49],
])

pooled = np.empty((2, 2))
winners = []
for row in range(2):
    for col in range(2):
        block = feature_map[row * 2:row * 2 + 2, col * 2:col * 2 + 2]
        local_row, local_col = np.unravel_index(np.argmax(block), block.shape)
        pooled[row, col] = block[local_row, local_col]
        winners.append((int(row * 2 + local_row), int(col * 2 + local_col)))

print(np.round(pooled, 2))
print("winner coordinates:", winners)
Output
[[0.91 0.44]
 [0.21 0.77]]
winner coordinates: [(1, 1), (1, 2), (2, 1), (2, 2)]

The output picks 0.91, 0.44, 0.21, and 0.77, one winner per quadrant.

During backpropagation, error gradients δ=∂L∂y\delta = \frac{\partial \mathcal{L}}{\partial y} propagate backwards through pooling operations differently:

  • In max pooling, the selected input activation receives the window's gradient: ∂L∂xwinner=δ\frac{\partial \mathcal{L}}{\partial x_{\text{winner}}} = \delta. Other cells receive zero from that window. These aren't necessarily raw pixels. Ties require a backward convention; our NumPy example selects the first maximum in flattened order.
  • In average pooling over an unpadded K×KK \times K window, each cell receives δ/K2\delta/K^2 from that window. Backpropagation computes gradients, not parameter updates. If pooling windows overlap, contributions to a shared cell add.

Now reduce the stride to one on a 3 x 3 map whose center is the largest value. Its center wins all four overlapping windows. Predict whether it receives the largest incoming gradient or their sum, then check PyTorch's forward indices and backward result:

overlapping-pooling-gradients.py
import torch

values = torch.tensor([[1., 2., 3.], [4., 9., 5.], [6., 7., 8.]],
                      dtype=torch.float64, requires_grad=True)
pool = torch.nn.MaxPool2d(kernel_size=2, stride=1, return_indices=True)
pooled, indices = pool(values[None, None])
incoming = torch.tensor([[[[2., -1.], [0.5, 3.]]]], dtype=torch.float64)
pooled.backward(incoming)

print("pooled:", pooled.detach()[0, 0].tolist())
print("flat winner indices:", indices[0, 0].tolist())
print("input gradients:", values.grad.tolist())
assert values.grad[1, 1].item() == 4.5
assert torch.count_nonzero(values.grad).item() == 1
Output
pooled: [[9.0, 9.0], [9.0, 9.0]]
flat winner indices: [[4, 4], [4, 4]]
input gradients: [[0.0, 0.0, 0.0], [0.0, 4.5, 0.0], [0.0, 0.0, 0.0]]

The center's flattened index is 4 in the 3 x 3 input plane. All four outputs return that index, and backward adds their signed contributions: 2−1+0.5+3=4.52-1+0.5+3=4.5.[5] A manual implementation must accumulate with +=; assigning with = would overwrite earlier contributions. Pooling selects the winner from forward activations, then routes the signed incoming gradient to that location.

A 4 by 4 feature map is split into four non-overlapping 2 by 2 windows. The maxima 0.91, 0.44, 0.21, and 0.77 form the pooled map. Incoming gradients 2.0, minus 1.0, 0.5, and 3.0 return only to the four winning cells, producing a sparse 4 by 4 input-gradient map.
Each colored 2 × 2 window contributes its maximum to the pooled map. In the backward example, each incoming gradient returns to that winner; every other cell receives zero.

Failure modes: border evidence and padding artifacts

Our worked windows so far fit neatly inside the crop. At an image edge, padding supplies values beyond the picture, and those synthetic values can contribute to the filter response.

For a stride-one valid 3 x 3 operation on an image at least 5 x 5, a corner pixel participates in one window and an interior pixel can participate in nine. On our smaller 4 x 4 crop, even the most central pixels participate in only four. Fewer placements can reduce aggregate evidence in some models; the response also depends on the kernel weights and classification head.

padding-border-evidence.py
import numpy as np

def total_window_response(image: np.ndarray, padding: int) -> float:
    padded = np.pad(image, padding)
    kernel = np.ones((3, 3))
    total = 0.0
    for row in range(padded.shape[0] - 2):
        for col in range(padded.shape[1] - 2):
            total += np.sum(padded[row:row + 3, col:col + 3] * kernel)
    return total

edge_signal = np.zeros((5, 5))
edge_signal[0, 0] = 1.0
center_signal = np.zeros((5, 5))
center_signal[2, 2] = 1.0

print(f"valid edge total: {total_window_response(edge_signal, padding=0):.0f}")
print(f"valid center total: {total_window_response(center_signal, padding=0):.0f}")
print(f"padded edge total: {total_window_response(edge_signal, padding=1):.0f}")
Output
valid edge total: 1
valid center total: 9
padded edge total: 4

Padding helps by creating extra placements where the corner pixel lands inside valid windows, raising the count from 1 to 4.

Padding can also introduce responses to synthetic structure. Zero padding places zero-valued pixels around the image. On a uniform gray surface of brightness 0.6, the transition to 0.0 creates an artificial intensity step. An edge filter can respond strongly to that step; a classifier might mistake the response for a defect.

Reflection padding (mode='reflect') mirrors interior values across the boundary.[6] It avoids this gray-to-black transition on a uniform image, while still imposing an artificial continuation that can create other gradients or border artifacts.

zero-padding-artifact.py
import numpy as np

card = np.full((5, 5), 0.6)
horizontal_edge = np.array([
    [1.0, 1.0, 1.0],
    [0.0, 0.0, 0.0],
    [-1.0, -1.0, -1.0],
])

def response_map(padded: np.ndarray, kernel: np.ndarray) -> np.ndarray:
    output = np.empty((padded.shape[0] - 2, padded.shape[1] - 2))
    for row in range(output.shape[0]):
        for col in range(output.shape[1]):
            output[row, col] = np.sum(padded[row:row + 3, col:col + 3] * kernel)
    return output

for mode in ("constant", "reflect"):
    padded = np.pad(card, 1, mode=mode)
    responses = response_map(padded, horizontal_edge)
    print(f"{mode} max absolute response: {np.abs(responses).max():.1f}")
Output
constant max absolute response: 1.8
reflect max absolute response: 0.0

Constant zero padding yields an artificial response of 1.8 on an untextured, uniform surface. Reflection padding outputs 0.0. When edge artifacts appear near image borders, evaluate padding modes and keep training and inference configurations synchronized.

Build it: an inspectable forward pass

Now follow the unpadded 4 x 4 crop through one complete forward pass: convolution, ReLU, max pooling, and a two-class linear head. The weights are hand-picked, so the logits illustrate class-score arithmetic. This network has no validation evidence as a crack detector.

A stack of convolutions without non-linear operations composes into an affine map, or a linear map if biases are absent. Finite boundaries and strides can make the merged operation differ from an ordinary shared-kernel convolution.

Diagram showing Input: 4x4 crop, Conv2d: 3x3 kernel -> 2x2 map, ReLU: max(0, x) -> 2x2 map, MaxPool2d: 2x2 -> 1 value (0.874), and Linear: 1 in -> 2 logits.
Input: 4x4 crop, Conv2d: 3x3 kernel -> 2x2 map, ReLU: max(0, x) -> 2x2 map, MaxPool2d: 2x2 -> 1 value (0.874), and Linear: 1 in -> 2 logits.
cnn-forward-numpy.py
import numpy as np

image = np.array([
    [0.05, 0.08, 0.06, 0.07],
    [0.12, 0.92, 0.88, 0.09],
    [0.08, 0.90, 0.93, 0.11],
    [0.06, 0.07, 0.09, 0.05],
])
kernel = np.array([
    [-0.6, -0.6, -0.6],
    [1.1, 1.1, 1.1],
    [-0.6, -0.6, -0.6],
])

def conv_valid(image: np.ndarray, kernel: np.ndarray) -> np.ndarray:
    output = np.empty((2, 2))
    for row in range(2):
        for col in range(2):
            output[row, col] = np.sum(image[row:row + 3, col:col + 3] * kernel)
    return output

feature_map = conv_valid(image, kernel)
activated = np.maximum(feature_map, 0.0)
pooled = np.array([activated.max()])
classifier_weights = np.array([[1.4], [-0.9]])
classifier_bias = np.array([-0.2, 0.1])
numpy_logits = classifier_weights @ pooled + classifier_bias

assert feature_map.shape == (2, 2)
assert pooled.shape == (1,)
print("feature map:")
print(np.round(feature_map, 3))
print("pooled activation:", np.round(pooled, 3))
print("logits:", np.round(numpy_logits, 3))
Output
feature map:
[[0.852 0.789]
 [0.817 0.874]]
pooled activation: [0.874]
logits: [ 1.024 -0.687]

The NumPy pipeline produces logits [1.024, -0.687].

Now construct the same architecture in PyTorch. Conv2d accepts a batch with shape (N, C, H, W) or an unbatched (C, H, W) tensor. Here unsqueeze(0).unsqueeze(0) adds the batch and channel axes, turning (4, 4) into (1, 1, 4, 4). Copy the manual weights to check numerical agreement.

same-forward-pass-pytorch.py
import torch
from torch import nn

image_bchw = torch.tensor(image, dtype=torch.float32).unsqueeze(0).unsqueeze(0)

model = nn.Sequential(
    nn.Conv2d(1, 1, kernel_size=3, bias=False),
    nn.ReLU(),
    nn.MaxPool2d(kernel_size=2),
    nn.Flatten(),
    nn.Linear(1, 2),
)

with torch.no_grad():
    model[0].weight.copy_(torch.tensor(kernel, dtype=torch.float32).view(1, 1, 3, 3))
    model[4].weight.copy_(torch.tensor(classifier_weights, dtype=torch.float32))
    model[4].bias.copy_(torch.tensor(classifier_bias, dtype=torch.float32))

pytorch_logits = model(image_bchw).detach().numpy()[0]
assert np.allclose(pytorch_logits, numpy_logits, rtol=0.0, atol=1e-6)
print("pytorch logits:", np.round(pytorch_logits, 3))
print("matches NumPy:", True)
Output
pytorch logits: [ 1.024 -0.687]
matches NumPy: True

The float32 PyTorch results agree with NumPy's float64 calculation within the asserted tolerance. Optimized implementations can use different arithmetic orders and produce different bitwise results through floating-point rounding.

Modern vision architectures: ResNet and vision transformers

The forward pass is now a score you can recompute with fixed kernels. The tempting next move is more layers. LeNet-5 and AlexNet showed that learned convolutional features work for document recognition and for large-scale image classification.[1] [7] Adding depth then produced a surprise. Some deeper plain networks had higher training error than shallower ones. That is the degradation problem.

Higher training error points to an optimization difficulty. The ResNet paper distinguishes this degradation from overfitting and vanishing gradients: its plain networks used batch normalization, and the authors reported healthy backward gradient norms.[8] More depth can make optimization harder even when gradient magnitudes remain healthy.

He et al.'s ResNet makes the learned branch represent a residual, F(x)\mathcal{F}(\mathbf{x}), which is added to the original input when their shapes match.[8] An identity shortcut computes:

y=F(x)+x\mathbf{y} = \mathcal{F}(\mathbf{x}) + \mathbf{x}

The following block applies ReLU after this sum, as the original basic ResNet block does. Changing spatial size or channel count would also require a compatible shortcut, such as a projection; this example keeps both unchanged.

BatchNorm2d normalizes each channel using statistics across batch and spatial positions, then applies learned scale and offset parameters. With its default settings, training updates running statistics that evaluation reuses.[9] The newly created block below is in training mode. This example checks tensor shapes; evaluate classifier accuracy on held-out data.

residual-block-pytorch.py
import torch
from torch import nn

class ResidualBlock(nn.Module):
    def __init__(self, channels: int):
        super().__init__()
        self.conv1 = nn.Conv2d(channels, channels, kernel_size=3, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(channels)
        self.relu = nn.ReLU(inplace=True)
        self.conv2 = nn.Conv2d(channels, channels, kernel_size=3, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(channels)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        identity = x
        out = self.relu(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out))
        out += identity
        return self.relu(out)

block = ResidualBlock(channels=16)
dummy_input = torch.randn(1, 16, 28, 28)
output = block(dummy_input)
print("input shape:", list(dummy_input.shape))
print("output shape:", list(output.shape))
print("shape preserved:", dummy_input.shape == output.shape)
Output
input shape: [1, 16, 28, 28]
output shape: [1, 16, 28, 28]
shape preserved: True

The derivative of the residual connection during backpropagation reveals why this works:

∂L∂x=∂L∂y(∂F∂x+I)\frac{\partial \mathcal{L}}{\partial \mathbf{x}} = \frac{\partial \mathcal{L}}{\partial \mathbf{y}} \left( \frac{\partial \mathcal{F}}{\partial \mathbf{x}} + \mathbf{I} \right)

For the residual sum, the identity term provides a direct additive gradient path. If the branch Jacobian is small, this path can help avoid repeatedly shrinking gradients through that branch. A branch Jacobian of −I-\mathbf{I} cancels the identity term exactly, so total gradient size depends on both paths. The block's ReLU after addition introduces another gate, so the displayed derivative is for the sum before that ReLU. The original paper demonstrates successful training of residual networks with 50, 101, and 152 layers.[8]

A different connection pattern powers Vision Transformers (ViT).[10] Instead of sliding small local filters, ViT slices an image into non-overlapping patches (typically 16×1616 \times 16 pixels). Linearly projecting each flattened patch into an embedding vector of dimension DD is mathematically equivalent to a standard 2D convolution with kernel_size=16, stride=16, and out_channels=D.

The architectural contrast lies in how subsequent layers mix information:

  • Convolutional networks share local filters across space, encouraging a detector learned in one location to be useful elsewhere. This inductive bias, an architectural assumption about useful functions, can help with limited labeled data. Strides, boundaries, and later heads still affect shift behavior.
  • The original Vision Transformer retains spatial structure through patch extraction and positional embeddings, while its self-attention can combine information from any pair of patches in a layer. It has less built-in image locality than a CNN; the original paper found large-scale pretraining useful for its comparisons.[10]

Data efficiency and model rankings depend on task and training recipe. DeiT showed competitive image transformers trained on ImageNet without external training images, using a stronger training recipe and teacher distillation.[11] ConvNeXt modernized convolutional design and competed with the Transformers studied in its experiments.[12] For an inspection task, compare suitable pretrained backbones on held-out panel photos, including tiny defects and image boundaries. Account for inference cost as well as accuracy. A score traces back to a kernel window, a pooling winner, and a border the padding invented. This chapter uses hand-chosen weights to trace predictions; training would update them using prediction error.

Change the crop, predict the response

Test your understanding of spatial shapes, kernel weights, and receptive fields before revealing the answers.

Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.A fixed cross-correlation window currently sums to 1.2. Its aligned pixel is 0.4 and its weight changes from −0.5 to 0.25. All other pixels, weights and bias stay fixed. What new response follows?
2.An NCHW input has shape (2,6,17,17). Conv2d has 8 output channels, a 3×3 kernel, stride 2, padding 1, dilation 2, groups 2 and one bias per output. Which output shape and stored parameter count follow?
3.Start with receptive field 1 and jump 1. Apply a kernel-3 stride-2 dilation-1 convolution, then a kernel-3 stride-1 dilation-2 convolution. What final field and jump follow?
4.A supplied one-dimensional cross-correlation uses kernel [−1,1], stride 2 and valid start indices 0,2,4 on six values. Compare x=[0,0,1,1,1,1] with a one-position-right shifted boundary x′=[0,0,0,1,1,1]. Which response maps and global maxima result?
5.Four unpadded 2×2 stride-1 pooling windows overlap at one center cell. That cell is the unique maximum in every window. The four output gradients are [[-2,1],[4,0.5]]. What total center gradient follows for max pooling, and for average pooling over the same four windows?
6.A uniform 5×5 image has brightness 0.4. Use the zero-bias kernel [[1,1,1],[0,0,0],[−1,−1,−1]], stride 1 and a one-cell border. What maximum absolute responses follow under constant-zero padding and reflection padding?
7.A batch of 3 has 12 feature channels on a 5×7 spatial grid. A 4-class linear head includes one bias per class. Compare flattening all spatial values with global average pooling before the head. Which pooled shape and parameter counts follow?
8.A supplied convolution map is [[-0.2,0.3],[0.9,0.4]]. Apply ReLU, then one 2×2 max pool. A two-logit head has weights [2,−1] and biases [−0.1,0.2]. Which logits and zero-based saved pooling winner result?
9.A scalar residual block computes ReLU(F(x)+x) at x=2. Its incoming output gradient is 3. Case A has F(x)=−1 and F′(x)=−0.5; case B has F(x)=−3 with the same derivative. What input gradients follow?
10.An 8×8 RGB image is split into nonoverlapping 2×2 patches. The same linear projection maps every flattened patch to 5 features and includes 5 biases. Which patch-token shape and shared parameter count match an equivalent Conv2d(kernel_size=2,stride=2,out_channels=5)?

10 questions remaining.

References

Gradient-Based Learning Applied to Document Recognition

Yann LeCun, Leon Bottou, Yoshua Bengio, Patrick Haffner · 1998

https://ieeexplore.ieee.org/document/726791

Deep Learning

Goodfellow, I., Bengio, Y., Courville, A. · 2016

https://www.deeplearningbook.org/

Conv2d

PyTorch Contributors · 2026

https://docs.pytorch.org/docs/stable/generated/torch.nn.Conv2d.html

Computing Receptive Fields of Convolutional Neural Networks

Araujo, A., Norris, W., and Sim, J. · 2019

https://distill.pub/2019/computing-receptive-fields/

MaxPool2d (PyTorch 2.14)

PyTorch Contributors · 2026

https://docs.pytorch.org/docs/2.14/generated/torch.nn.MaxPool2d.html

numpy.pad

NumPy Developers · 2026

https://numpy.org/doc/stable/reference/generated/numpy.pad.html

ImageNet Classification with Deep Convolutional Neural Networks

Krizhevsky, A., et al. · 2012 · NeurIPS 2012

https://www.cs.toronto.edu/~kriz/imagenet_classification_with_deep_convolutional.pdf

Deep Residual Learning for Image Recognition.

He, K., Zhang, X., Ren, S., Sun, J. · 2015 · CVPR 2016

https://arxiv.org/abs/1512.03385

BatchNorm2d

PyTorch Contributors · 2026

https://docs.pytorch.org/docs/2.14/generated/torch.nn.BatchNorm2d.html

An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale.

Dosovitskiy, A., et al. · 2020 · ICLR 2021

https://arxiv.org/abs/2010.11929

Training Data-Efficient Image Transformers & Distillation through Attention

Touvron, H., Cord, M., Douze, M., Massa, F., Sablayrolles, A., & Jégou, H. · 2021 · ICML 2021

https://arxiv.org/abs/2012.12877

A ConvNet for the 2020s

Liu, Z., Mao, H., Wu, C.-Y., Feichtenhofer, C., Darrell, T., & Xie, S. · 2022

https://arxiv.org/abs/2201.03545

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.