Convolution as a linear map
Overview
Section titled “Overview”| Module | M12.1 · build · Python · Pass 12 · 3 to 4 h |
| You build | python/tinyllm/sig/conv.py: conv_out_size, conv1d_direct and conv2d_direct (stride, padding, dilation, groups), correlate_vs_convolve, im2col and its adjoint col2im, and conv_matrix (the doubly block Toeplitz form) |
| Contract | course/contracts/py/tinyllm/sig/conv.pyi |
| Tests | course/tests/M12.1/test_conv.py (what they check: section 4), golden values from course/oracle/M12.1/conv_torch.py (PyTorch F.conv1d, F.conv2d, F.unfold, F.fold, autograd) in course/fixtures/M12.1/conv_torch.npz |
| Needs | no module code. Reading: M03.2 and M03.3 (a linear map is a matrix; its transpose), M08.2 (a backward pass is a transposed Jacobian), and the naive loop in ml/06-neural-architectures/code/conv2d.c |
| Used by | M12.3 (resample_audio is one strided conv1d_direct) · later L13.1 Conv2d and Conv1d on autograd (im2col, col2im) · L14.2 the Whisper stem (conv_out_size) |
| Milestone | MS-P12 (the multimodal gate) |
| Optional depth | Dumoulin and Visin, “A guide to convolution arithmetic for deep learning” (2016, free); Chellapilla, Puri, Simard, “High performance convolutional neural networks for document processing” (2006), the im2col paper; Goodfellow, Bengio, Courville, Deep Learning, ch. 9 (free) |
Key Takeaways
Section titled “Key Takeaways”- A deep-learning “convolution” is a cross-correlation: no kernel flip. Flipping the kernel gives the true convolution of signal processing (
test_hand_example_correlation,test_flip_turns_correlation_into_convolution). - One formula sizes every conv output: (
test_conv_out_size_table). - A conv is linear, so it is a matrix: the doubly block Toeplitz with . im2col turns it into one matmul with the flattened weights (
test_conv_is_one_matmul). - col2im is the adjoint of im2col (it sums overlapping windows), so the backward pass of a conv is built from the same two functions (
test_col2im_is_the_adjoint_of_im2col,test_backward_is_a_conv).
How to work this chapter
Section titled “How to work this chapter”ol start M12.1 # stubs python/tinyllm/sig/conv.py into your repool tests M12.1 # read the test catalog first: rung R0, you write no tests hereol check M12.1 # exit code is the verdictol diff M12.1 # after passing: your code against the reference1. Why now
Section titled “1. Why now”Pass 12 teaches the system to see and hear. Every vision tower in L13 starts with a convolution (a ViT’s patch embedding is a conv whose kernel equals its stride), and Whisper’s encoder in L14 starts with two 1-D convolutions over the log-mel frames. Without this module, L13.1 has no way to build Conv2d on the autograd engine: a Python loop over output pixels is far too slow, and a vectorized version needs im2col for the forward pass and col2im for the backward pass. Get the output size wrong and the Whisper stem hands 1501 positions to an encoder whose position table has 1500; get the row order of im2col wrong and the weights multiply the wrong pixels while every shape still lines up.
2. Principles
Section titled “2. Principles”| Symbol | Meaning | Type / shape |
|---|---|---|
| input batch, NCHW (2-D) or NCL (1-D) | float64[N, C, H, W] | |
| weights, KCRS (2-D) or KCR (1-D) | float64[K, C/g, R, S] | |
| bias, one per output channel | float64[K] | |
| , , | stride, zero padding, dilation (per axis) | ints |
| groups: channels split into independent convs | int | |
| , | output height and width | ints |
| row-major flattening | ||
| doubly block Toeplitz matrix of a single-channel conv | float64[P*Q, H*W] |
2.1 Correlation and convolution
Section titled “2.1 Correlation and convolution”The cross-correlation of an image with a kernel slides over and takes a dot product at each position:
Signal processing’s convolution flips the kernel first, , which is the same as correlating with rotated by 180 degrees. Flipping is what makes convolution commutative and makes the convolution theorem (M12.4) hold. A learned kernel does not care: whatever it would learn flipped, it learns unflipped. So every framework computes the correlation and calls it a convolution, and conv2d_direct does the same. correlate_vs_convolve returns both so you can see the difference: on one row the true convolution is exactly numpy.convolve(..., 'valid').
2.2 Output size, stride, padding, dilation, groups
Section titled “2.2 Output size, stride, padding, dilation, groups”A kernel of size with dilation places its taps samples apart, so it spans input samples. Padding adds zeros at each end, and a stride moves the window samples per output. The first window starts at 0; the last must end inside the padded input. The number of outputs is Output position , tap reads padded index , which is input index . Groups split the input channels into blocks of and the output channels into blocks of ; output channel only sees input block . That is why the weight’s second axis is , not . Depthwise convolution is . Whisper’s stem uses , , which keeps 3000 frames at and halves them to 1500 at .
2.3 A conv is a matrix
Section titled “2.3 A conv is a matrix”Each output is a fixed linear combination of inputs, so for a single-channel image there is a matrix with . Row of holds the kernel’s taps in the columns of the pixels that window touches, and zeros elsewhere: each block of rows repeats the same pattern shifted by one column (Toeplitz), and the blocks repeat shifted by one image row (block Toeplitz), hence “doubly block Toeplitz”. Taps that land in the zero padding have no column. conv_matrix builds explicitly; it is , mostly zeros, and only for understanding. The forward pass is ; the backward pass with respect to is (M08.2: a VJP is a transposed Jacobian).
2.4 im2col and col2im
Section titled “2.4 im2col and col2im”The practical form keeps the matrix structure but moves it to the data. im2col copies every receptive field of into a column: row index , column index , shape . Flattening the weights in the same order gives , and one large matmul that BLAS runs at full speed. The row order matters: any other order still produces the right shapes, but pairs each weight with the wrong pixel. im2col is a linear map that copies; its adjoint, col2im, sends every column entry back to the pixel it came from and adds where windows overlap. Adjoint means for all . With stride smaller than the kernel, a pixel sits in several windows; assigning instead of adding loses all but the last contribution.
2.5 The gradient of a conv is a conv
Section titled “2.5 The gradient of a conv is a conv”With where and upstream gradient (shaped like ), the matrix calculus of M08 gives
The weight gradient is a correlation of the input with the upstream gradient, and the input gradient is a “transposed convolution” of the upstream gradient with the weights: both are convolutions again. L13.1 implements Conv2d’s backward exactly this way.
3. Worked example by hand
Section titled “3. Worked example by hand”Image and kernel:
The output is on each axis (conv_out_size(4, 2) = 3). The correlation is :
The flipped kernel is , so the true convolution is : exactly the negative of the table. This is test_hand_example_correlation. In im2col form, column of im2col(x, 2, 2) is the window in order, and gives , the same entry.
4. The interface
Section titled “4. The interface”def conv_out_size(n, k, stride=1, pad=0, dilation=1) -> int: ...def conv2d_direct(x, w, b, stride=(1, 1), pad=(0, 0), dilation=(1, 1), groups=1) -> NDArray: ...def im2col(x, kh, kw, stride=(1, 1), pad=(0, 0), dilation=(1, 1)) -> NDArray: # [N, C*kh*kw, P*Q]def col2im(cols, x_shape, kh, kw, stride=(1, 1), pad=(0, 0), dilation=(1, 1)) -> NDArray: # adjoint, sums overlapsdef conv_matrix(in_shape, w, stride=(1, 1), pad=(0, 0)) -> NDArray: # T with vec(y) = T vec(x)What the tests check
Section titled “What the tests check”| Test | KIND | Checks | Why it matters downstream |
|---|---|---|---|
test_hand_example_correlation | unit, smoke | section 3’s correlation and its negated convolution | you and the tests agree on “no flip” |
test_conv_out_size_table | unit, smoke | Whisper stem, ViT patch conv, dilation, exact fit, and bad arguments | every L13 and L14 layer sizes itself with it |
test_conv2d_matches_torch | golden | four conv2d cases against F.conv2d: plain, strided and dilated with groups, depthwise, patch embedding | L13.1 must equal PyTorch |
test_conv1d_matches_torch | golden | three conv1d cases including Whisper’s stride-2 stem | L14.2’s stem |
test_im2col_col2im_match_unfold_fold | golden | im2col equals F.unfold, col2im equals F.fold | row order and summed overlaps |
test_conv_is_one_matmul | differential | direct loop, grouped im2col matmul, and the Toeplitz matrix agree | the three views of one map |
test_backward_is_a_conv | golden | dW, dx, db from im2col and col2im equal PyTorch’s autograd | L13.1’s backward |
test_col2im_is_the_adjoint_of_im2col | property | with overlapping windows | why col2im is the right backward |
test_flip_turns_correlation_into_convolution | property | flipped-kernel correlation is convolution; one row equals numpy.convolve | the definition, independently |
test_rejects_shapes_that_do_not_fit | boundary | groups that do not divide, wrong channel count, oversized kernel, wrong cols; inputs untouched | model-definition bugs fail loudly |
5. Pitfalls
Section titled “5. Pitfalls”| Pitfall | Symptom | Caught by |
|---|---|---|
| 1. flipping the kernel (true convolution) | every output differs from PyTorch; pretrained weights give garbage | test_hand_example_correlation, test_conv2d_matches_torch (mutant s01) |
| 2. leaving dilation out of the kernel span | dilated layers get too many outputs | test_conv_out_size_table, test_conv1d_matches_torch (mutant s02) |
| 3. col2im assigning instead of adding | wrong input gradients wherever windows overlap | test_col2im_is_the_adjoint_of_im2col, test_backward_is_a_conv (mutant s03) |
| 4. im2col rows in order | shapes fine, values wrong for | test_im2col_col2im_match_unfold_fold, test_conv_is_one_matmul (mutant s04) |
| 5. every group reading the first group’s channels | grouped and depthwise convs wrong | test_conv2d_matches_torch (mutant s05) |
| 6. all the padding on one side | outputs shifted by | test_conv2d_matches_torch (mutant s06) |
| 7. Toeplitz columns without the padding offset | shifted against the direct conv | test_conv_is_one_matmul (mutant s07) |
| dropping the in the size formula | one output too few everywhere | test_conv_out_size_table (mutant m01) |
| rejecting an exact fit | a kernel as large as the input raises | test_conv_out_size_table (mutant m02) |
| not checking that groups divide the output channels | silent zero channels | test_rejects_shapes_that_do_not_fit (mutant m03) |
6. Where it’s used next
Section titled “6. Where it’s used next”| Direction | Module | How it uses this |
|---|---|---|
| Back | M03.2, M03.3 | a linear map is a matrix and its adjoint is the transpose (reading) |
| Back | M08.2 | a backward pass multiplies by the transposed Jacobian (reading) |
| Forward | M12.3 | resample_audio applies its sinc filter bank as one strided conv1d_direct |
| Forward | L13.1 | Conv2d, Conv1d, and pooling on autograd: im2col forward, col2im backward (with B14’s L13 group) |
| Forward | L13.3 | PatchEmbed is a conv with kernel = stride |
| Forward | L14.2 | the Whisper stem sizes its output with conv_out_size |
| Forward | S-M12 | output sizes, receptive fields, the Toeplitz form, and the adjoint by hand |
Going further
Section titled “Going further”| Your piece | Production equivalent | What it adds | Where to look |
|---|---|---|---|
im2col + matmul | PyTorch aten/src/ATen/native/ConvolutionMM2d.cpp (slow_conv2d) | the same unfold-then-GEMM path, threaded, for CPU fallbacks | slow_conv2d_forward_out_cpu |
conv2d_direct | oneDNN / cuDNN | Winograd and FFT algorithms, implicit GEMM without materializing columns, autotuning per shape | cuDNN cudnnFindConvolutionForwardAlgorithm |
col2im | F.fold / conv_transpose2d | the adjoint as a first-class layer (upsampling in decoders) | aten/src/ATen/native/Col2Im.cpp |
the C loop in conv2d.c | optional L13.8 | im2col in C with a blocked matmul, benchmarked against the naive loop | c/src/kernels/conv.c |