Skip to content

Convolution as a linear map

ModuleM12.1 · build · Python · Pass 12 · 3 to 4 h
You buildpython/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)
Contractcourse/contracts/py/tinyllm/sig/conv.pyi
Testscourse/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
Needsno 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 byM12.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)
MilestoneMS-P12 (the multimodal gate)
Optional depthDumoulin 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)
  • 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: ⌊(n+2p−d(k−1)−1)/s⌋+1\lfloor (n + 2p - d(k-1) - 1)/s \rfloor + 1 (test_conv_out_size_table).
  • A conv is linear, so it is a matrix: the doubly block Toeplitz TT with vec⁡(y)=Tvec⁡(x)\operatorname{vec}(y) = T \operatorname{vec}(x). 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).
Terminal window
ol start M12.1 # stubs python/tinyllm/sig/conv.py into your repo
ol tests M12.1 # read the test catalog first: rung R0, you write no tests here
ol check M12.1 # exit code is the verdict
ol diff M12.1 # after passing: your code against the reference

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.

SymbolMeaningType / shape
xxinput batch, NCHW (2-D) or NCL (1-D)float64[N, C, H, W]
wwweights, KCRS (2-D) or KCR (1-D)float64[K, C/g, R, S]
bbbias, one per output channelfloat64[K]
ss, pp, ddstride, zero padding, dilation (per axis)ints
gggroups: channels split into gg independent convsint
PP, QQoutput height and widthints
vec⁡(⋅)\operatorname{vec}(\cdot)row-major flattening
TTdoubly block Toeplitz matrix of a single-channel convfloat64[P*Q, H*W]

The cross-correlation of an image xx with a kernel ww slides ww over xx and takes a dot product at each position: y[i,j]=∑r,sw[r,s] x[i+r,j+s].y[i, j] = \sum_{r, s} w[r, s]\, x[i + r, j + s]. Signal processing’s convolution flips the kernel first, y[i,j]=∑r,sw[r,s] x[i−r,j−s]y[i,j] = \sum_{r,s} w[r,s]\,x[i - r, j - s], which is the same as correlating with ww 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 kk with dilation dd places its taps dd samples apart, so it spans d(k−1)+1d(k-1) + 1 input samples. Padding adds pp zeros at each end, and a stride ss moves the window ss samples per output. The first window starts at 0; the last must end inside the padded input. The number of outputs is out=⌊n+2p−d(k−1)−1s⌋+1.\text{out} = \left\lfloor \frac{n + 2p - d(k - 1) - 1}{s} \right\rfloor + 1 . Output position oo, tap rr reads padded index os+rdo s + r d, which is input index os+rd−po s + r d - p. Groups split the CC input channels into gg blocks of C/gC/g and the KK output channels into gg blocks of K/gK/g; output channel kk only sees input block ⌊k/(K/g)⌋\lfloor k / (K/g) \rfloor. That is why the weight’s second axis is C/gC/g, not CC. Depthwise convolution is g=Cg = C. Whisper’s stem uses k=3k = 3, p=1p = 1, which keeps 3000 frames at s=1s = 1 and halves them to 1500 at s=2s = 2.

Each output is a fixed linear combination of inputs, so for a single-channel image there is a matrix TT with vec⁡(y)=Tvec⁡(x)\operatorname{vec}(y) = T \operatorname{vec}(x). Row (p,q)(p, q) of TT 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 TT explicitly; it is PQ×HWPQ \times HW, mostly zeros, and only for understanding. The forward pass is TxT x; the backward pass with respect to xx is T⊤gT^\top g (M08.2: a VJP is a transposed Jacobian).

The practical form keeps the matrix structure but moves it to the data. im2col copies every receptive field of xx into a column: row index (c,r,s)(c, r, s), column index (p,q)(p, q), shape [N,CRS,PQ][N, C R S, P Q]. Flattening the weights in the same (c,r,s)(c, r, s) order gives Wflat∈RK×CRSW_{\text{flat}} \in \mathbb{R}^{K \times CRS}, and Y=Wflat⋅im2col⁡(x),Y∈RN×K×PQ,Y = W_{\text{flat}} \cdot \operatorname{im2col}(x), \qquad Y \in \mathbb{R}^{N \times K \times PQ}, 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 ⟨im2col⁡(x),y⟩=⟨x,col2im⁡(y)⟩\langle \operatorname{im2col}(x), y \rangle = \langle x, \operatorname{col2im}(y) \rangle for all x,yx, y. With stride smaller than the kernel, a pixel sits in several windows; assigning instead of adding loses all but the last contribution.

With Y=WflatXcY = W_{\text{flat}} X_c where Xc=im2col⁡(x)X_c = \operatorname{im2col}(x) and upstream gradient GG (shaped like YY), the matrix calculus of M08 gives ∂L∂Wflat=∑nGnXc,n⊤,∂L∂x=col2im⁡(Wflat⊤G),∂L∂bk=∑n,p,qG[n,k,p,q].\frac{\partial L}{\partial W_{\text{flat}}} = \sum_n G_n X_{c,n}^\top, \qquad \frac{\partial L}{\partial x} = \operatorname{col2im}\Big(W_{\text{flat}}^\top G\Big), \qquad \frac{\partial L}{\partial b_k} = \sum_{n, p, q} G[n, k, p, q]. 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.

Image and kernel: x=(1201013121001012),w=(100−1).x = \begin{pmatrix} 1 & 2 & 0 & 1 \\ 0 & 1 & 3 & 1 \\ 2 & 1 & 0 & 0 \\ 1 & 0 & 1 & 2 \end{pmatrix}, \qquad w = \begin{pmatrix} 1 & 0 \\ 0 & -1 \end{pmatrix}. The output is 4−2+1=34 - 2 + 1 = 3 on each axis (conv_out_size(4, 2) = 3). The correlation is y[i,j]=x[i,j]−x[i+1,j+1]y[i, j] = x[i, j] - x[i+1, j+1]:

j=0j = 0j=1j = 1j=2j = 2
i=0i = 01−1=01 - 1 = 02−3=−12 - 3 = -10−1=−10 - 1 = -1
i=1i = 10−1=−10 - 1 = -11−0=11 - 0 = 13−0=33 - 0 = 3
i=2i = 22−0=22 - 0 = 21−1=01 - 1 = 00−2=−20 - 2 = -2

The flipped kernel is (−1001)\begin{pmatrix} -1 & 0 \\ 0 & 1 \end{pmatrix}, so the true convolution is −x[i,j]+x[i+1,j+1]-x[i, j] + x[i+1, j+1]: exactly the negative of the table. This is test_hand_example_correlation. In im2col form, column (0,1)(0, 1) of im2col(x, 2, 2) is the window (2,0,1,3)(2, 0, 1, 3) in (r,s)(r, s) order, and Wflat=(1,0,0,−1)W_{\text{flat}} = (1, 0, 0, -1) gives 2−3=−12 - 3 = -1, the same entry.

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 overlaps
def conv_matrix(in_shape, w, stride=(1, 1), pad=(0, 0)) -> NDArray: # T with vec(y) = T vec(x)
TestKINDChecksWhy it matters downstream
test_hand_example_correlationunit, smokesection 3’s correlation and its negated convolutionyou and the tests agree on “no flip”
test_conv_out_size_tableunit, smokeWhisper stem, ViT patch conv, dilation, exact fit, and bad argumentsevery L13 and L14 layer sizes itself with it
test_conv2d_matches_torchgoldenfour conv2d cases against F.conv2d: plain, strided and dilated with groups, depthwise, patch embeddingL13.1 must equal PyTorch
test_conv1d_matches_torchgoldenthree conv1d cases including Whisper’s stride-2 stemL14.2’s stem
test_im2col_col2im_match_unfold_foldgoldenim2col equals F.unfold, col2im equals F.foldrow order and summed overlaps
test_conv_is_one_matmuldifferentialdirect loop, grouped im2col matmul, and the Toeplitz matrix agreethe three views of one map
test_backward_is_a_convgoldendW, dx, db from im2col and col2im equal PyTorch’s autogradL13.1’s backward
test_col2im_is_the_adjoint_of_im2colproperty⟨im2col⁡x,y⟩=⟨x,col2im⁡y⟩\langle \operatorname{im2col} x, y\rangle = \langle x, \operatorname{col2im} y\rangle with overlapping windowswhy col2im is the right backward
test_flip_turns_correlation_into_convolutionpropertyflipped-kernel correlation is convolution; one row equals numpy.convolvethe definition, independently
test_rejects_shapes_that_do_not_fitboundarygroups that do not divide, wrong channel count, oversized kernel, wrong cols; inputs untouchedmodel-definition bugs fail loudly
PitfallSymptomCaught by
1. flipping the kernel (true convolution)every output differs from PyTorch; pretrained weights give garbagetest_hand_example_correlation, test_conv2d_matches_torch (mutant s01)
2. leaving dilation out of the kernel spandilated layers get too many outputstest_conv_out_size_table, test_conv1d_matches_torch (mutant s02)
3. col2im assigning instead of addingwrong input gradients wherever windows overlaptest_col2im_is_the_adjoint_of_im2col, test_backward_is_a_conv (mutant s03)
4. im2col rows in (r,s,c)(r, s, c) ordershapes fine, values wrong for C>1C > 1test_im2col_col2im_match_unfold_fold, test_conv_is_one_matmul (mutant s04)
5. every group reading the first group’s channelsgrouped and depthwise convs wrongtest_conv2d_matches_torch (mutant s05)
6. all the padding on one sideoutputs shifted by pptest_conv2d_matches_torch (mutant s06)
7. Toeplitz columns without the padding offsetTxT x shifted against the direct convtest_conv_is_one_matmul (mutant s07)
dropping the +1+1 in the size formulaone output too few everywheretest_conv_out_size_table (mutant m01)
rejecting an exact fita kernel as large as the input raisestest_conv_out_size_table (mutant m02)
not checking that groups divide the output channelssilent zero channelstest_rejects_shapes_that_do_not_fit (mutant m03)
DirectionModuleHow it uses this
BackM03.2, M03.3a linear map is a matrix and its adjoint is the transpose (reading)
BackM08.2a backward pass multiplies by the transposed Jacobian (reading)
ForwardM12.3resample_audio applies its sinc filter bank as one strided conv1d_direct
ForwardL13.1Conv2d, Conv1d, and pooling on autograd: im2col forward, col2im backward (with B14’s L13 group)
ForwardL13.3PatchEmbed is a conv with kernel = stride
ForwardL14.2the Whisper stem sizes its output with conv_out_size
ForwardS-M12output sizes, receptive fields, the Toeplitz form, and the adjoint by hand
Your pieceProduction equivalentWhat it addsWhere to look
im2col + matmulPyTorch aten/src/ATen/native/ConvolutionMM2d.cpp (slow_conv2d)the same unfold-then-GEMM path, threaded, for CPU fallbacksslow_conv2d_forward_out_cpu
conv2d_directoneDNN / cuDNNWinograd and FFT algorithms, implicit GEMM without materializing columns, autotuning per shapecuDNN cudnnFindConvolutionForwardAlgorithm
col2imF.fold / conv_transpose2dthe adjoint as a first-class layer (upsampling in decoders)aten/src/ATen/native/Col2Im.cpp
the C loop in conv2d.coptional L13.8im2col in C with a blocked matmul, benchmarked against the naive loopc/src/kernels/conv.c