Kiru Lab / Deep Learning / Architectures as Priors
Convolution: Locality Made Structural
A convolutional layer is a fully connected layer with most weights forced to zero and the rest tied together.
Concept · about 30 minutes
A convolution slides a small filter across an input and computes a dot product at each position. Two structural constraints define it: locality, because each output sees only a small neighborhood; and weight sharing, because the same filter is applied everywhere.
Both constraints are claims about the world. Locality claims that nearby inputs are more related than distant ones. Weight sharing claims that a pattern worth detecting in one location is worth detecting in all of them — translation invariance. For natural images both hold. For tabular data neither does, which is why convolution is the wrong tool there.
The learned hierarchy
Stack convolutions and the receptive field grows with depth. Early layers reliably learn edge and color-gradient detectors; middle layers learn textures and simple shapes; late layers learn object parts. Nobody specified this. It emerges from the structure plus the data, and it is one of the strongest empirical results in the field.
The mammalian visual system shows the same progression: oriented edge detectors in V1, texture and contour selectivity in V2 and V4, object-level selectivity in inferotemporal cortex. Two systems built by completely different processes converged on the same layered decomposition, which suggests the hierarchy is a property of the problem rather than of either implementation.
Hold on to
- Convolution encodes locality and translation invariance structurally
- Weight sharing is a prior about the world, not a memory optimization
- Edge-to-texture-to-object hierarchies emerge in both CNNs and visual cortex
Work through
Try each one before opening the solution. Getting it wrong first is most of where the learning happens.
-
Implement 2D convolution with explicit loops, then verify against a library version.
Hint
Slide the kernel over every valid position and take an elementwise product-sum.
Solution
The explicit version makes the two structural constraints visible: the inner loop only ever touches a small neighborhood (locality), and the same `kernel` array is used at every position (weight sharing). Note the output is smaller than the input unless you pad — that shrinkage is why deep convolutional stacks need padding to preserve spatial dimensions.
import numpy as np from scipy.signal import convolve2d def conv2d(image, kernel): kh, kw = kernel.shape h, w = image.shape[0] - kh + 1, image.shape[1] - kw + 1 out = np.zeros((h, w)) for i in range(h): for j in range(w): out[i, j] = np.sum(image[i:i + kh, j:j + kw] * kernel) return out rng = np.random.default_rng(0) image, kernel = rng.normal(size=(20, 20)), rng.normal(size=(3, 3)) reference = convolve2d(image, kernel[::-1, ::-1], mode="valid") # note the flip assert np.allclose(conv2d(image, kernel), reference)Check your work
Paste this after your own code. If it runs without raising, you have it.
import numpy as np image = np.arange(16, dtype=float).reshape(4, 4) kernel = np.ones((2, 2)) out = conv2d(image, kernel) assert out.shape == (3, 3) assert out[0, 0] == 0 + 1 + 4 + 5 print("ok") -
Visualize the first-layer filters of a pretrained CNN. Count how many are recognizable edge detectors.
Hint
Reshape the first conv layer weights to (out_channels, 3, kh, kw) and show them as RGB images.
Solution
In a trained network typically half to two-thirds of first-layer filters are recognizable oriented edge detectors — bright on one side, dark on the other, at various angles — and most of the rest are color-opponent blobs. This closely matches what Hubel and Wiesel measured in cat V1 in 1959, from a system that learned it from ImageNet rather than from evolution. Two very different processes, the same solution.
import matplotlib.pyplot as plt import torchvision model = torchvision.models.resnet18(weights="DEFAULT") filters = model.conv1.weight.detach().numpy() # (64, 3, 7, 7) fig, axes = plt.subplots(8, 8, figsize=(8, 8)) for f, ax in zip(filters, axes.ravel()): f = (f - f.min()) / (f.max() - f.min()) ax.imshow(f.transpose(1, 2, 0)); ax.axis("off") plt.tight_layout(); plt.show()
In the Bio Mirror
The Visual HierarchySign in to track your progress through the lab.