Convolutional Foundations — Pixels, Kernels, Strides, Receptive Fields & im2col¶
Prerequisites
Multivariate calculus, multi-dimensional array slicing, and backpropagation. Review Linear Algebra, Calculus & Optimization, and Neural Network Foundations.
1. The Big Picture¶
In a standard Multi-Layer Perceptron (MLP), an input image of modest resolution \(256 \times 256 \times 3\) has \(196,608\) input features. Connecting this input to a single hidden layer with \(1,024\) neurons requires:
This naive dense approach suffers from three fundamental flaws:
- Parameter Explosion & Overfitting: Billions of weights for modest images, causing extreme sample inefficiency.
- Loss of Spatial Topology: Flattening an image \(\mathbf{x} \in \mathbb{R}^{H \times W \times C} \to \mathbb{R}^{H \cdot W \cdot C}\) destroys spatial adjacency. A pixel at \((i, j)\) is treated as equally distant from its immediate neighbor \((i, j+1)\) as from a corner pixel \((0, 0)\).
- Lack of Translation Equivariance: If an object shifts by 5 pixels, an MLP must re-learn its detector weights for the new coordinate location from scratch.
Convolutional Neural Networks (CNNs) solve all three problems by introducing two inductive biases rooted in the physics of the visual world:
- Spatial Locality: Neurons only receive connections from a small local spatial patch (receptive field), reflecting the statistical property that nearby pixels are strongly correlated.
- Weight Sharing (Stationarity): The exact same kernel (feature detector) is swept across every spatial position, enforcing translation equivariance and slashing parameter counts by orders of magnitude.
flowchart TD
subgraph Fully Connected Failure
IMG1["Image: (H, W, C)"] --> FLAT["Flatten: Vector (H·W·C)"]
FLAT --> DENSE["Dense Matrix W: (Hidden, H·W·C) -> Millions of Weights"]
DENSE -. "Destroys 2D Adjacency & Overfits" .-> OUT1["Fragile Representation"]
end
subgraph Convolutional Solution
IMG2["Image: (C, H, W)"] --> CONV["Local Filter K: (C, k, k)"]
CONV --> SLIDE["Slide K across all (x, y) with Weight Sharing"]
SLIDE --> FEAT["Feature Map: Preserves 2D Topology & Translation Equivariance"]
end
2. Image Representation: Pixel Tensors and Color Spaces¶
Digital images are discrete 3-order tensors \(\mathbf{I} \in \mathbb{R}^{C \times H \times W}\) (in PyTorch standard CHW format) or \(\mathbb{R}^{H \times W \times C}\) (in TensorFlow standard HWC format):
- \(H\): Height (vertical spatial resolution, rows \(i \in \{0, \dots, H-1\}\)).
- \(W\): Width (horizontal spatial resolution, columns \(j \in \{0, \dots, W-1\}\)).
- \(C\): Number of channels (\(C=1\) for Grayscale, \(C=3\) for RGB, \(C \ge 4\) for multispectral satellite/medical imagery).
2.1 Dynamic Range and Normalization¶
Raw image bytes are stored as unsigned 8-bit integers \(\text{uint8} \in [0, 255]\). In deep learning, pixels are converted to floating-point tensors and normalized:
For ImageNet pretrained models:
- \(\boldsymbol{\mu} = [0.485, 0.456, 0.406]\)
- \(\boldsymbol{\sigma} = [0.229, 0.224, 0.225]\)
2.2 Color Spaces¶
- RGB (Additive Color Space): Orthogonal primary color intensities. Channels are highly correlated (luminance and chrominance are conflated).
-
Grayscale (Luminance Conversion via ITU-R BT.601): $\(Y = 0.299 R + 0.587 G + 0.114 B\)$
-
HSV (Hue, Saturation, Value): Decouples chromatic content (Hue: \(0^\circ - 360^\circ\)), color purity (Saturation: \([0, 1]\)), and brightness (Value: \([0, 1]\)), ideal for color-based object segmentation robust to lighting shifts.
3. The 2D Convolution Operation: Discrete Mathematics¶
3.1 Mathematical Convolution vs. Cross-Correlation¶
In pure functional analysis, the continuous convolution of two functions \(f\) and \(g\) is defined by flipping the kernel:
For discrete 2D spatial images \(I\) and kernel \(K \in \mathbb{R}^{k_h \times k_w}\):
-
True 2D Convolution (with Kernel Flip): $\((I * K)(i, j) = \sum_{m=-M}^M \sum_{n=-N}^N I(i - m, j - n) K(m, n)\)$
-
2D Cross-Correlation (Without Flip): $\((I \star K)(i, j) = \sum_{m=-M}^M \sum_{n=-N}^N I(i + m, j + n) K(m, n)\)$
The Deep Learning Convention:
Virtually all deep learning libraries (PyTorch nn.Conv2d, TensorFlow tf.keras.layers.Conv2D) compute cross-correlation but call it convolution. Because kernel weights \(K\) are learned via backpropagation, flipping the filter prior to training is mathematically superfluous: the optimizer simply learns the reflected filter directly.
3.2 Multi-Channel 2D Convolution¶
Let the input tensor be \(X \in \mathbb{R}^{C_{\text{in}} \times H \times W}\).
A convolutional layer with \(C_{\text{out}}\) output channels comprises \(C_{\text{out}}\) distinct 3D filter tensors, where each filter \(K^{(c_{\text{out}})} \in \mathbb{R}^{C_{\text{in}} \times k \times k}\) and bias \(b_{c_{\text{out}}} \in \mathbb{R}\).
The output feature map at channel \(c_{\text{out}}\) and spatial coordinate \((i, j)\) is:
flowchart TD
subgraph Multi-Channel Convolution
IN["Input X: (C_in, H, W)"] --> F1["Filter 1: (C_in, k, k)"]
IN --> F2["Filter 2: (C_in, k, k)"]
IN --> FC["Filter C_out: (C_in, k, k)"]
F1 --> O1["Channel 1 Output: (H_out, W_out)"]
F2 --> O2["Channel 2 Output: (H_out, W_out)"]
FC --> OC["Channel C_out Output: (H_out, W_out)"]
O1 & O2 & OC --> STACK["Stacked Tensor Y: (C_out, H_out, W_out)"]
end
3.3 Translation Equivariance¶
Let \(T_{\mathbf{v}}\) be the translation operator shifting an image by vector \(\mathbf{v}\): \((T_{\mathbf{v}} I)(\mathbf{x}) = I(\mathbf{x} - \mathbf{v})\).
An operator \(f\) is translation equivariant if translating the input results in an identically translated output:
Because convolution applies the exact same filter weights at all positions, if an edge in an image moves right by 10 pixels, the corresponding activation peak in the output feature map shifts right by 10 pixels.
4. Kernels & Filters: Classical Feature Detectors¶
Before deep learning, computer vision engineers designed analytical kernels by hand. Modern CNNs initialize weights randomly and automatically learn optimal task-specific kernels.
flowchart LR
GAUSS["Gaussian Kernel: Low-pass Blur (Smooth Noise)"] --> SOBEL["Sobel Kernels (Gx, Gy): 1st Derivative (Edges)"]
SOBEL --> LAPLACE["Laplacian Kernel: 2nd Derivative (Sharpening / Zero Crossings)"]
LAPLACE --> LEARNED["Deep Conv Layers: Automatically learn rich hierarchical kernels"]
4.1 Gaussian Kernel (Noise Suppression)¶
A 2D isotropic Gaussian blur kernel of size \((2k+1) \times (2k+1)\):
A standard \(3 \times 3\) normalized approximation:
4.2 Sobel Operator (First Derivative / Edge Detection)¶
Edges correspond to local maxima in the spatial gradient magnitude \(\|\nabla I\| = \sqrt{(\frac{\partial I}{\partial x})^2 + (\frac{\partial I}{\partial y})^2}\).
The Sobel operator estimates partial derivatives using separable finite differences with smoothing:
4.3 Laplacian Filter (Second Spatial Derivative)¶
The continuous Laplacian \(\nabla^2 I = \frac{\partial^2 I}{\partial x^2} + \frac{\partial^2 I}{\partial y^2}\) detects zero-crossings at edges:
5. Padding, Stride, and Output Dimension Formula¶
5.1 Output Dimension Formula¶
Given:
- Input spatial dimension: \(W_{\text{in}}\)
- Kernel spatial size: \(K\)
- Zero-padding: \(P\) (number of zero pixels added to each border)
- Stride: \(S\) (step size of kernel sweep)
- Dilation: \(d\) (spacing between kernel elements, default \(d=1\))
The output spatial dimension \(W_{\text{out}}\) is:
For standard non-dilated convolution (\(d=1\)):
5.2 Padding Types¶
- Valid Padding (\(P = 0\)): No padding. The filter stays strictly within image boundaries. Spatial dimensions shrink by \(K - 1\) at each layer.
-
Same Padding: Chooses padding \(P\) such that \(W_{\text{out}} = \lceil W_{\text{in}} / S \rceil\). For stride \(S=1\) and odd kernel size \(K\): $\(P = \frac{K - 1}{2}\)$ For a \(3 \times 3\) kernel, \(P = 1\). For a \(5 \times 5\) kernel, \(P = 2\).
-
Full Padding (\(P = K - 1\)): The kernel visits every position where it overlaps with at least 1 pixel of the input, producing output dimension \(W_{\text{in}} + K - 1\).
6. Pooling Operations: Downsampling & Invariance¶
Pooling downsamples feature maps, providing two key benefits:
- Computational & Memory Reduction: Halving spatial dimensions reduces memory and FLOPs of subsequent layers by \(4\times\).
- Translation Invariance: While convolutions are translation equivariant, pooling introduces local translation invariance (\(f(T_{\mathbf{v}} X) \approx f(X)\)).
flowchart TD
subgraph "2x2 Max Pooling (Stride 2)"
P1["[ 1 3 ]\n[ 2 9 ]"] -->|max| M1["9"]
P2["[ 4 6 ]\n[ 5 1 ]"] -->|max| M2["6"]
P3["[ 8 2 ]\n[ 3 0 ]"] -->|max| M3["8"]
P4["[ 7 5 ]\n[ 2 4 ]"] -->|max| M4["7"]
end
6.1 Max Pooling vs. Average Pooling¶
-
Max Pooling: Selects the maximum value in local window \(\Omega(i, j)\): $\(Y(i, j) = \max_{(m, n) \in \Omega(i, j)} X(m, n)\)$ Preserves sharp, high-frequency structural signals (edges, corners, textures).
-
Average Pooling: Computes the arithmetic mean: $\(Y(i, j) = \frac{1}{|\Omega|} \sum_{(m, n) \in \Omega(i, j)} X(m, n)\)$ Acts as a low-pass spatial smoothing filter.
-
Global Average Pooling (GAP, Lin et al., 2013): Collapses each entire \((H \times W)\) channel into a single scalar: $\(Y(c) = \frac{1}{H \cdot W} \sum_{i=1}^H \sum_{j=1}^W X(c, i, j)\)$ Replaces fragile, parameter-heavy dense classification heads with a parameter-free vector representation.
7. Receptive Field Calculus¶
The Receptive Field (RF) of a unit in layer \(l\) is the spatial region of the input image \(X^{[0]}\) that directly contributes to that unit's activation.
flowchart BT
IN["Input Image (RF = 1)"] --> L1["Conv 3x3, stride 1 (RF = 3)"]
L1 --> L2["Conv 3x3, stride 1 (RF = 5)"]
L2 --> L3["Conv 3x3, stride 2 (RF = 7, jump = 2)"]
L3 --> L4["Conv 3x3, stride 1 (RF = 11, jump = 2)"]
7.1 Analytical Recurrence Relations¶
Let:
- \(k_l\): Kernel size at layer \(l\).
- \(s_l\): Stride at layer \(l\).
- \(p_l\): Padding at layer \(l\).
- \(j_l\): The jump (feature stride) at layer \(l\), defined as the distance in input pixels between two adjacent features in layer \(l\).
- \(RF_l\): The receptive field diameter at layer \(l\).
Base Case (Input layer \(l=0\)): $\(RF_0 = 1, \qquad j_0 = 1\)$
Inductive Step for Layer \(l \ge 1\):
-
Jump Update: $\(j_l = j_{l-1} \cdot s_l\)$
-
Receptive Field Update: $\(RF_l = RF_{l-1} + (k_l - 1) \cdot j_{l-1}\)$
7.2 Factorization of Large Convolutions (VGG Insight)¶
Consider replacing a single \(7 \times 7\) convolution (\(s=1\)) with a cascade of three \(3 \times 3\) convolutions (\(s=1\)):
- Layer 1 (\(3 \times 3\)): \(RF_1 = 1 + (3 - 1) \times 1 = 3\).
- Layer 2 (\(3 \times 3\)): \(RF_2 = 3 + (3 - 1) \times 1 = 5\).
- Layer 3 (\(3 \times 3\)): \(RF_3 = 5 + (3 - 1) \times 1 = 7\).
Both achieve the exact same \(7 \times 7\) receptive field!
Parameter Comparison (\(C\) channels):
- Single \(7 \times 7\): \(C \times (7 \times 7 \times C) = 49 C^2\) weights.
- Three \(3 \times 3\): \(3 \times (C \times 3 \times 3 \times C) = 27 C^2\) weights — a 45% parameter reduction!
Furthermore, the stacked \(3 \times 3\) layers incorporate 3 non-linear activation functions instead of 1, drastically increasing representation capacity.
8. Analytical Backward Pass of 2D Convolution¶
Let \(\mathcal{L}\) be the scalar loss. Assume we receive the upstream gradient tensor \(\frac{\partial \mathcal{L}}{\partial Y} \in \mathbb{R}^{C_{\text{out}} \times H_{\text{out}} \times W_{\text{out}}}\).
We need to derive \(\frac{\partial \mathcal{L}}{\partial K}\) and \(\frac{\partial \mathcal{L}}{\partial X}\).
8.1 Gradient with Respect to Filter Weights (\(\frac{\partial \mathcal{L}}{\partial K}\))¶
By the chain rule:
From the forward formula \(Y(c_{\text{out}}, i, j) = \dots + X(c_{\text{in}}, i \cdot s + m, j \cdot s + n) K(\dots)\), the partial derivative is:
Therefore:
This is itself a cross-correlation between the input \(X\) and the upstream gradient \(\frac{\partial \mathcal{L}}{\partial Y}\)!
8.2 Gradient with Respect to Input (\(\frac{\partial \mathcal{L}}{\partial X}\))¶
Similarly, differentiating with respect to an input element \(X(c_{\text{in}}, u, v)\):
For stride \(S=1\), computing \(\frac{\partial \mathcal{L}}{\partial X}\) is mathematically equivalent to a full convolution of the upstream gradient with the spatially rotated (flipped) kernel:
where \(K^{\text{rot180}}(m, n) = K(k - 1 - m, k - 1 - n)\).
9. The im2col & col2im GEMM Formulation¶
Naive convolution using nested loops requires 6 nested loops:
This is horribly inefficient and cannot leverage GPU Tensor Cores.
In 2006, Kumar Chellapilla et al. introduced im2col, which reformulates 2D convolution as a single high-performance General Matrix Multiply (GEMM).
flowchart LR
IMG["Input X: (C_in, H, W)"] -->|im2col extraction| XCOL["Matrix X_col: (C_in · k · k, H_out · W_out)"]
KERN["Filter K: (C_out, C_in, k, k)"] -->|Reshape| WROW["Matrix W_row: (C_out, C_in · k · k)"]
WROW & XCOL -->|BLAS GEMM MatMul| YMAT["Y_mat = W_row @ X_col: (C_out, H_out · W_out)"]
YMAT -->|Reshape| YOUT["Output Y: (C_out, H_out, W_out)"]
-
im2colTransformation: Every \(C_{\text{in}} \times k \times k\) receptive field patch in \(X\) is unrolled into a column vector of length \(C_{\text{in}} \cdot k \cdot k\).
Gathering all \(H_{\text{out}} \cdot W_{\text{out}}\) patches produces a matrix \(X_{\text{col}} \in \mathbb{R}^{(C_{\text{in}} \cdot k \cdot k) \times (H_{\text{out}} \cdot W_{\text{out}})}\). -
Weight Matrix Reshape: The filter tensor is reshaped into \(W_{\text{row}} \in \mathbb{R}^{C_{\text{out}} \times (C_{\text{in}} \cdot k \cdot k)}\).
-
GEMM Evaluation: $\(Y_{\text{mat}} = W_{\text{row}} \cdot X_{\text{col}} \in \mathbb{R}^{C_{\text{out}} \times (H_{\text{out}} \cdot W_{\text{out}})}\)$
-
Reshape: \(Y_{\text{mat}}\) is reshaped to \((C_{\text{out}}, H_{\text{out}}, W_{\text{out}})\).
In the backward pass:
- \(dW_{\text{row}} = dY_{\text{mat}} \cdot X_{\text{col}}^T\)
- \(dX_{\text{col}} = W_{\text{row}}^T \cdot dY_{\text{mat}}\)
col2im: Accumulates the columns of \(dX_{\text{col}}\) back into overlapping spatial pixel buffers in \(dX\).
10. Implementation 1 — Vectorized 2D Conv & MaxPool from Scratch (NumPy)¶
The following complete, production-grade script implements im2col, col2im, ScratchConv2d, and ScratchMaxPool2d from first principles in pure NumPy, verified with analytical gradient checks.
"""
scratch_conv2d.py
High-Performance 2D Convolution and Max Pooling via im2col and col2im in pure NumPy.
"""
import numpy as np
from typing import Tuple
def im2col_indices(
x: np.ndarray,
kh: int,
kw: int,
padding: int = 1,
stride: int = 1,
) -> np.ndarray:
"""
Unrolls image receptive field patches into columns.
x shape: (N, C, H, W)
Returns: cols of shape (C * kh * kw, N * H_out * W_out)
"""
N, C, H, W = x.shape
H_out = int((H + 2 * padding - kh) / stride + 1)
W_out = int((W + 2 * padding - kw) / stride + 1)
x_padded = np.pad(x, ((0, 0), (0, 0), (padding, padding), (padding, padding)), mode="constant")
# Generate index matrices for vectorized extraction
i0 = np.repeat(np.arange(kh), kw)
i0 = np.tile(i0, C)
i1 = stride * np.repeat(np.arange(H_out), W_out)
j0 = np.tile(np.arange(kw), kh * C)
j1 = stride * np.tile(np.arange(W_out), H_out)
i = i0.reshape(-1, 1) + i1.reshape(1, -1)
j = j0.reshape(-1, 1) + j1.reshape(1, -1)
k = np.repeat(np.arange(C), kh * kw).reshape(-1, 1)
cols = x_padded[:, k, i, j]
cols = cols.transpose(1, 2, 0).reshape(kh * kw * C, -1)
return cols
def col2im_indices(
cols: np.ndarray,
x_shape: Tuple[int, int, int, int],
kh: int,
kw: int,
padding: int = 1,
stride: int = 1,
) -> np.ndarray:
"""
Accumulates columns back into image tensor gradients.
"""
N, C, H, W = x_shape
H_padded, W_padded = H + 2 * padding, W + 2 * padding
x_padded = np.zeros((N, C, H_padded, W_padded), dtype=cols.dtype)
H_out = int((H + 2 * padding - kh) / stride + 1)
W_out = int((W + 2 * padding - kw) / stride + 1)
i0 = np.repeat(np.arange(kh), kw)
i0 = np.tile(i0, C)
i1 = stride * np.repeat(np.arange(H_out), W_out)
j0 = np.tile(np.arange(kw), kh * C)
j1 = stride * np.tile(np.arange(W_out), H_out)
i = i0.reshape(-1, 1) + i1.reshape(1, -1)
j = j0.reshape(-1, 1) + j1.reshape(1, -1)
k = np.repeat(np.arange(C), kh * kw).reshape(-1, 1)
cols_reshaped = cols.reshape(C * kh * kw, -1, N).transpose(2, 0, 1)
np.add.at(x_padded, (slice(None), k, i, j), cols_reshaped)
if padding == 0:
return x_padded
return x_padded[:, :, padding:-padding, padding:-padding]
class ScratchConv2d:
"""
2D Convolutional Layer powered by im2col GEMM.
"""
def __init__(self, in_channels: int, out_channels: int, kernel_size: int = 3, stride: int = 1, padding: int = 1):
self.in_channels = in_channels
self.out_channels = out_channels
self.k = kernel_size
self.stride = stride
self.padding = padding
# He (Kaiming) initialization
scale = np.sqrt(2.0 / (in_channels * kernel_size * kernel_size))
self.W = np.random.randn(out_channels, in_channels, kernel_size, kernel_size) * scale
self.b = np.zeros((out_channels, 1))
self.dW = np.zeros_like(self.W)
self.db = np.zeros_like(self.b)
self.cache = None
def forward(self, x: np.ndarray) -> np.ndarray:
N, C, H, W = x.shape
H_out = int((H + 2 * self.padding - self.k) / self.stride + 1)
W_out = int((W + 2 * self.padding - self.k) / self.stride + 1)
x_col = im2col_indices(x, self.k, self.k, padding=self.padding, stride=self.stride)
W_row = self.W.reshape(self.out_channels, -1)
out = W_row @ x_col + self.b
out = out.reshape(self.out_channels, H_out, W_out, N).transpose(3, 0, 1, 2)
self.cache = (x, x_col)
return out
def backward(self, dout: np.ndarray) -> np.ndarray:
x, x_col = self.cache
N, C, H, W = x.shape
# Transpose dout to (C_out, H_out * W_out * N)
dout_reshaped = dout.transpose(1, 2, 3, 0).reshape(self.out_channels, -1)
# Gradients for weights and bias
dW_row = dout_reshaped @ x_col.T
self.dW = dW_row.reshape(self.W.shape)
self.db = np.sum(dout_reshaped, axis=1, keepdims=True)
# Gradient for input x
W_row = self.W.reshape(self.out_channels, -1)
dx_col = W_row.T @ dout_reshaped
dx = col2im_indices(dx_col, x.shape, self.k, self.k, padding=self.padding, stride=self.stride)
return dx
class ScratchMaxPool2d:
"""
2D Max Pooling Layer (2x2, stride 2).
"""
def __init__(self, size: int = 2, stride: int = 2):
self.size = size
self.stride = stride
self.cache = None
def forward(self, x: np.ndarray) -> np.ndarray:
N, C, H, W = x.shape
H_out = int((H - self.size) / self.stride + 1)
W_out = int((W - self.size) / self.stride + 1)
# Reshape to easily take max over 2x2 windows
x_reshaped = x.reshape(N, C, H_out, self.stride, W_out, self.stride)
out = x_reshaped.max(axis=(3, 5))
self.cache = (x, x_reshaped, out)
return out
def backward(self, dout: np.ndarray) -> np.ndarray:
x, x_reshaped, out = self.cache
# Backprop: gradient routes exclusively to the position of the maximum
dout_expanded = dout[:, :, :, np.newaxis, :, np.newaxis]
mask = (x_reshaped == out[:, :, :, np.newaxis, :, np.newaxis])
dx = (mask * dout_expanded).reshape(x.shape)
return dx
# --------------------------------------------------------------------------
# Demonstration & Verification
# --------------------------------------------------------------------------
if __name__ == "__main__":
np.random.seed(42)
x = np.random.randn(2, 3, 8, 8) # 2 images, 3 channels, 8x8 pixels
conv = ScratchConv2d(in_channels=3, out_channels=4, kernel_size=3, padding=1, stride=1)
pool = ScratchMaxPool2d(size=2, stride=2)
# Forward
conv_out = conv.forward(x)
pool_out = pool.forward(conv_out)
print(f"Input Shape: {x.shape}")
print(f"Conv Out Shape: {conv_out.shape} (Expected: (2, 4, 8, 8))")
print(f"Pool Out Shape: {pool_out.shape} (Expected: (2, 4, 4, 4))")
# Backward
dout = np.random.randn(*pool_out.shape)
dconv = pool.backward(dout)
dx = conv.backward(dconv)
print(f"Backward dx Shape: {dx.shape} | dW Shape: {conv.dW.shape}")
print("NumPy im2col Conv2d & MaxPool successfully executed!")
11. Implementation 2 — Modern PyTorch Equivalent¶
"""
pytorch_conv_demo.py
PyTorch implementation benchmarking Conv2d against classical edge detectors.
"""
import torch
import torch.nn as nn
import torch.nn.functional as F
class CustomEdgeConv(nn.Module):
"""
Demonstrates initializing Conv2d with classical Sobel filters.
"""
def __init__(self):
super().__init__()
self.conv = nn.Conv2d(1, 2, kernel_size=3, stride=1, padding=1, bias=False)
# Manually load Sobel Gx and Gy filters
sobel_x = torch.tensor([[-1., 0., 1.], [-2., 0., 2.], [-1., 0., 1.]])
sobel_y = torch.tensor([[-1., -2., -1.], [0., 0., 0.], [1., 2., 1.]])
weights = torch.stack([sobel_x, sobel_y]).unsqueeze(1) # (2, 1, 3, 3)
self.conv.weight = nn.Parameter(weights, requires_grad=False)
def forward(self, x: torch.Tensor) -> torch.Tensor:
# Computes Gx and Gy responses, then calculates gradient magnitude
grad_maps = self.conv(x)
gx = grad_maps[:, 0:1, :, :]
gy = grad_maps[:, 1:2, :, :]
edge_magnitude = torch.sqrt(gx**2 + gy**2 + 1e-8)
return edge_magnitude
if __name__ == "__main__":
detector = CustomEdgeConv()
dummy_image = torch.zeros(1, 1, 16, 16)
dummy_image[:, :, 4:12, 4:12] = 1.0 # white square in the center
edges = detector(dummy_image)
print(f"Input Tensor Shape: {dummy_image.shape}")
print(f"Edge Response Shape: {edges.shape}")
print("Peak Edge Magnitude:", edges.max().item())
12. Common Errors, Gotchas & Debugging¶
1. Shape Mismatch Between Conv and Linear Layers¶
Symptom: RuntimeError: mat1 and mat2 shapes cannot be multiplied (32x2048 and 512x10).
Root Cause: Hardcoding the input size of the first dense layer after convolutional layers. When input image dimensions change (\(224 \times 224 \to 256 \times 256\)), the flattened feature map dimension changes.
Fix: Use Global Average Pooling (nn.AdaptiveAvgPool2d((1, 1))) before the classification head, making the linear layer input dimension invariant to input image spatial resolution.
# BROKEN
self.features = nn.Sequential(nn.Conv2d(3, 64, 3, padding=1), nn.MaxPool2d(2, 2))
self.fc = nn.Linear(64 * 112 * 112, 10) # Fails if image is not 224x224!
# FIXED
self.features = nn.Sequential(nn.Conv2d(3, 64, 3, padding=1), nn.MaxPool2d(2, 2))
self.gap = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(64, 10) # Always works!
2. Forgetting Padding on Repeated \(3 \times 3\) Convolutions¶
Symptom: Spatial dimensions rapidly shrink to \(1 \times 1\) after 4 layers, crashing downstream convolutions.
Root Cause: Without padding (\(P=0\)), each \(3 \times 3\) convolution discards 2 boundary pixels along each spatial dimension (\(H_{\text{out}} = H_{\text{in}} - 2\)).
Fix: Set padding=1 on \(3 \times 3\) convolutions to maintain constant spatial resolution until explicit downsampling (pooling or stride 2).
3. Confusing Translation Equivariance with Translation Invariance¶
Symptom: Expecting a standard convolutional layer output to remain unchanged when an image is shifted.
Root Cause: Convolution is equivariant (\(f(T x) = T f(x)\)), not invariant (\(f(T x) = f(x)\)). Invariance is achieved only through pooling operations or global average pooling at the network head.
13. Staff-Level Technical Interview Questions¶
Q1: Derive the receptive field recurrence relation \(RF_l = RF_{l-1} + (k_l - 1) \cdot j_{l-1}\) and explain the physical meaning of the jump \(j_{l-1}\).¶
Model Answer:
Let \(RF_l\) denote the spatial receptive field diameter of layer \(l\) in input pixel units.
Consider a single unit in layer \(l\). In the preceding feature map (layer \(l-1\)), this unit connects to a spatial window of \(k_l\) adjacent features.
The distance in original input pixels between two adjacent features in layer \(l-1\) is defined as the feature stride or jump \(j_{l-1}\). Because layer \(l-1\) features were created by sub-sampling earlier layers by strides \(s_1, s_2, \dots, s_{l-1}\):
A filter of width \(k_l\) spans \((k_l - 1)\) intervals between adjacent features in layer \(l-1\).
The physical distance in input pixels between the leftmost and rightmost feature centers is \((k_l - 1) \cdot j_{l-1}\).
Since each individual feature in layer \(l-1\) already possesses a receptive field of diameter \(RF_{l-1}\) centered at its location, the total input coverage spanned by the layer \(l\) filter is the coverage of one feature plus the physical span across the remaining \((k_l - 1)\) features:
Q2: Why does im2col increase temporary memory consumption during forward propagation, and what trade-off does it represent?¶
Model Answer:
Let input \(X \in \mathbb{R}^{C \times H \times W}\). In im2col, each \(k \times k\) spatial receptive field patch is copied into a column of length \(C \cdot k^2\).
Because overlapping patches share pixels (with stride \(S=1\), every internal pixel is shared across \(k^2\) distinct patches), the unrolled matrix \(X_{\text{col}}\) has shape \((C \cdot k^2) \times (H_{\text{out}} \cdot W_{\text{out}})\).
The ratio of memory consumed by \(X_{\text{col}}\) relative to the raw input \(X\) is:
For a standard \(3 \times 3\) kernel, im2col causes an \(\approx 9\times\) memory bloat.
The Trade-off: This spatial redundancy is deliberately accepted because matrix multiplication (GEMM) maps directly to hardware-accelerated BLAS / cuBLAS routines. GEMM achieves near \(100\%\) arithmetic intensity on GPU Tensor Cores via cache tiling and coalesced memory reads, running \(10\times - 50\times\) faster than direct loop-based convolutions despite the memory overhead.
Q3: Why is Global Average Pooling (GAP) preferred over Flattening followed by dense Linear layers in modern CNN classification heads?¶
Model Answer:
Introduced by Lin et al. in Network in Network (2013), Global Average Pooling collapses each channel feature map \(H \times W\) into its spatial average:
Advantages over Dense Flattening:
- Dramatic Parameter Reduction: In VGG-16, flattening a \(512 \times 7 \times 7\) feature map into a 4096-neuron dense layer required \(512 \times 7 \times 7 \times 4096 \approx 102.7\) million parameters (\(>70\%\) of the entire network's parameters). With GAP, the \(512\) channels map directly to a \(512 \times K\) classification matrix, slashing parameters to a few thousand.
- Elimination of Overfitting: The vast majority of overfitting in classical CNNs occurred in dense heads. GAP has zero learnable parameters, acting as a structural regularizer.
- Input Resolution Flexibility: A flattened layer expects an exact fixed dimension (\(512 \cdot 7 \cdot 7\)). If test image resolution changes, the dense layer crashes. GAP produces a vector of length \(C\) regardless of spatial size \((H, W)\), allowing native multi-scale evaluation.
- Interpretability: GAP establishes direct correspondence between feature map channels and category confidence scores, enabling Class Activation Mapping (CAM).
Q4: Prove that 2D convolution with stride 1 and circular padding is a circulant matrix multiplication, and explain why convolution in spatial domain corresponds to point-wise multiplication in Fourier domain.¶
Model Answer:
In 1D discrete signal processing, circular convolution of signal \(\mathbf{x} \in \mathbb{R}^N\) with filter \(\mathbf{k} \in \mathbb{R}^N\) is represented as \(\mathbf{y} = C \mathbf{x}\), where \(C\) is a circulant matrix whose rows are cyclic shifts of \(\mathbf{k}\):
A fundamental theorem of linear algebra states that every circulant matrix is diagonalized by the Discrete Fourier Transform (DFT) matrix \(F\):
Therefore:
This is the Discrete Convolution Theorem: circular convolution in the spatial domain equals element-wise multiplication in the frequency domain.
For large kernel sizes (\(k \ge 7\)), computing convolution via Fast Fourier Transform (FFT) costs \(\mathcal{O}(N \log N)\) rather than spatial \(\mathcal{O}(N \cdot k^2)\), which libraries like cuDNN exploit via FFT convolution algorithms.
Q5: What is dilated (atrous) convolution, and what problem does it solve in semantic segmentation?¶
Model Answer:
A dilated convolution inserts \((d - 1)\) spaces between kernel weights. For a 1D filter of size \(k\) with dilation rate \(d\), the effective filter footprint is:
In standard CNNs, expanding the receptive field to capture global context requires downsampling via pooling or stride \(>1\). However, in dense pixel prediction tasks like semantic segmentation, spatial downsampling discards high-frequency spatial details (object boundaries, thin structures) that cannot be fully recovered.
Dilated convolution exponentially expands the receptive field without downsampling spatial resolution and without increasing parameter count or FLOPs. A \(3 \times 3\) kernel with dilation \(d=2\) covers a \(5 \times 5\) footprint using only 9 weights, preserving full-resolution feature maps while aggregating wide contextual information.
14. Mastery Ladder¶
- L1: Represent images as 3D tensors (\(C, H, W\)) and convert between RGB, Grayscale, and normalized float tensors.
- L2: Formulate discrete 2D cross-correlation and distinguish it from functional mathematical convolution.
- L3: Explain translation equivariance and prove why weight sharing guarantees it across feature maps.
- L4: Write Sobel and Laplacian kernels and explain their relationship to 1st and 2nd spatial derivatives.
- L5: Apply the output spatial dimension formula \(W_{\text{out}} = \lfloor \frac{W_{\text{in}} - K + 2P}{S} \rfloor + 1\) under valid and same padding.
- L6: Compare Max Pooling, Average Pooling, and Global Average Pooling (GAP) in terms of parameter overhead and invariance.
- L7: Derive the receptive field recurrence \(RF_l = RF_{l-1} + (k_l - 1) \cdot j_{l-1}\) and jump update \(j_l = j_{l-1} \cdot s_l\).
- L8: Prove why factorizing one \(7 \times 7\) convolution into three \(3 \times 3\) convolutions reduces parameters by 45%.
- L9: Formulate the forward and backward passes of 2D convolution as GEMM matrix multiplication via
im2colandcol2im. - L10: Implement a fully functional Conv2d and MaxPool2d layer from scratch in pure NumPy with gradient verification.