Type at least 2 letters. Enter opens the first result, Esc closes.

    Deep Learning · Lesson 3 · Networks and how they learn

    Backpropagation on computational graphs

    Intermediate 65 min 5 interactive figures 4 videos Colab notebook

    Before this lesson: lesson 01 (the MLP in matrix form) and lesson 02 (losses, softmax cross-entropy, the training loop). From other tracks: the chain rule as a product of Jacobians (calculus track); JVPs, VJPs and forward vs reverse mode in linear-algebra lesson 15; round-off and the unit roundoff uu in linear-algebra lesson 13. Time: about 65 minutes, plus the notebook.


    Why this matters #

    Every call to loss.backward() in PyTorch or jax.grad in JAX runs the algorithm of this lesson. With the explicit matrix formulas you can:

    • write a custom backward pass (a PyTorch autograd.Function or a fused GPU kernel) and find bugs where a gradient is silently wrong: a wrong sum over the batch, a missing mask, a missing transpose;
    • predict the cost of training. One training step of a model with NN parameters costs about 6N6N floating-point operations (FLOPs) per token, and this number sets the compute budgets of large language models;
    • understand where training memory goes and what activation checkpointing does;
    • trust a new layer, because a gradient check confirms its backward pass.

    In ML for science, the same reverse mode is called the adjoint method. It gives the sensitivity of a simulator or a PDE solver to all its parameters at once.

    1. Predict first #

    2. The picture #

    The chain rule, read from the end #

    Start with a chain of scalars: x→u=g(x)→L=f(u)x \to u = g(x) \to \mathcal{L} = f(u). The chain rule gives dL/dx=f′(u) g′(x)d\mathcal{L}/dx = f'(u)\, g'(x). Backpropagation evaluates this product from the end:

    1. Start with ∂L/∂L=1\partial\mathcal{L}/\partial\mathcal{L} = 1.
    2. Multiply by the local derivative f′(u)f'(u). This gives ∂L/∂u\partial \mathcal{L}/\partial u.
    3. Multiply by the local derivative g′(x)g'(x). This gives ∂L/∂x\partial \mathcal{L}/\partial x.

    We write uˉ=∂L/∂u\bar u = \partial \mathcal{L}/\partial u and call it the adjoint of uu: the derivative of the final scalar loss with respect to that variable. Each step needs only two numbers: the adjoint that arrives from above (the upstream gradient) and the derivative of one operation (the local gradient). The rule to remember is

    adjoint of an input  =  upstream gradient×local gradient.\text{adjoint of an input} \;=\; \text{upstream gradient} \times \text{local gradient}.

    In this lesson L\mathcal{L} is always the scalar loss and LL is the number of layers.

    Computational graphs #

    A computational graph has one node per operation and one edge per value. Here is a tiny network with one hidden ReLU unit, a residual connection and a squared loss:

    u=w1x,z=u+b1,h=relu⁡(z),v=w2h,y^=h+v,r=y^−y,L=r2.u = w_1 x,\quad z = u + b_1,\quad h = \operatorname{relu}(z),\quad v = w_2 h,\quad \hat y = h + v,\quad r = \hat y - y,\quad \mathcal{L} = r^2 .

    The value hh is used twice: directly in y^=h+v\hat y = h + v (the residual path) and inside v=w2hv = w_2 h. We say that hh fans out. Two rules make backprop work on any such graph:

    1. Each node maps the adjoint of its output to adjoints of its inputs (upstream times local). For vectors this is a vector-Jacobian product (VJP): xˉ⊤=uˉ⊤ ∂u/∂x\bar x^\top = \bar u^\top\, \partial u/\partial x. The full Jacobian ∂u/∂x\partial u/\partial x is never formed.
    2. Fan-out sums. If a variable feeds several nodes, its adjoint is the sum of the contributions from all of them. This is the multivariable chain rule. Residual connections x+F(x)x + F(x) and tied (shared) weights are fan-out.
    Interactive figure: loading…

    Press Forward step until the loss appears, then Backward step. Forward values are blue, adjoints are orange, parameters are violet, and the node being computed is yellow. The panel shows "upstream × local" for each step. Watch node hh: two orange contributions arrive and add up. Move the sliders and compare the backprop gradients with the finite differences in the table.

    The default numbers are x=1.5x = 1.5, w1=0.8w_1 = 0.8, b1=−0.2b_1 = -0.2, w2=0.5w_2 = 0.5, y=1y = 1.

    Forward pass (top to bottom): u=0.8⋅1.5=1.2u = 0.8 \cdot 1.5 = 1.2; z=1.2−0.2=1.0z = 1.2 - 0.2 = 1.0; h=relu⁡(1.0)=1.0h = \operatorname{relu}(1.0) = 1.0; v=0.5⋅1.0=0.5v = 0.5 \cdot 1.0 = 0.5; y^=1.0+0.5=1.5\hat y = 1.0 + 0.5 = 1.5; r=1.5−1=0.5r = 1.5 - 1 = 0.5; L=0.52=0.25\mathcal{L} = 0.5^2 = 0.25.

    Backward pass (from the loss down). Each line is "upstream × local":

    adjoint upstream × local value
    Lˉ\bar{\mathcal{L}} (the seed) 11
    rˉ\bar r Lˉ⋅2r\bar{\mathcal{L}} \cdot 2r 1⋅1.0=1.01 \cdot 1.0 = 1.0
    y^ˉ\bar{\hat y} rˉ⋅1\bar r \cdot 1 1.01.0
    vˉ\bar v y^ˉ⋅1\bar{\hat y} \cdot 1 1.01.0
    wˉ2\bar w_2 vˉ⋅h\bar v \cdot h 1.0⋅1.0=1.01.0 \cdot 1.0 = 1.0
    hˉ\bar h y^ˉ⋅1+vˉ⋅w2\bar{\hat y} \cdot 1 + \bar v \cdot w_2 (two paths) 1.0+0.5=1.51.0 + 0.5 = 1.5
    zˉ\bar z hˉ⋅relu⁡′(z)\bar h \cdot \operatorname{relu}'(z) 1.5⋅1=1.51.5 \cdot 1 = 1.5
    bˉ1\bar b_1, uˉ\bar u zˉ⋅1\bar z \cdot 1 1.51.5
    wˉ1\bar w_1 uˉ⋅x\bar u \cdot x 1.5⋅1.5=2.251.5 \cdot 1.5 = 2.25

    Check one of them directly: L=((1+w2)relu⁡(w1x+b1)−y)2\mathcal{L} = \big((1 + w_2)\operatorname{relu}(w_1 x + b_1) - y\big)^2, so ∂L/∂w1=2r (1+w2) x=2⋅0.5⋅1.5⋅1.5=2.25\partial\mathcal{L}/\partial w_1 = 2r\,(1 + w_2)\,x = 2 \cdot 0.5 \cdot 1.5 \cdot 1.5 = 2.25 (the ReLU is active, so its derivative is 1). The same number, without ever writing the whole formula.

    This answers question 3 of section 1: the contributions add, so hˉ=1.0+0.5=1.5\bar h = 1.0 + 0.5 = 1.5.

    Quick check

    In the graph above, set w2=−1w_2 = -1. Then y^=h+w2h=0\hat y = h + w_2 h = 0 for every input. What is hˉ\bar h?

    Why reverse order? #

    The loss is a map L:Rn→R\mathcal{L}: \mathbb{R}^n \to \mathbb{R} from nn parameters to one number. Its gradient is one row of length nn. The chain rule writes it as a product of Jacobians: ∇L⊤=JkJk−1⋯J1\nabla\mathcal{L}^\top = J_k J_{k-1} \cdots J_1, where JkJ_k (the last one) has only one row.

    • Reverse mode multiplies from the left. It starts with the 1×11 \times 1 seed and every intermediate result is a single row. Each step is a VJP and costs about as much as the forward operation. One backward pass gives all nn derivatives.
    • Forward mode multiplies from the right. It pushes one input direction through the graph per pass (a Jacobian-vector product, JVP). It needs nn passes for nn parameters.

    For a scalar loss and millions of parameters, reverse mode wins by a factor of millions. Linear-algebra lesson 15 treats JVPs and VJPs in detail.

    Interactive figure: loading…

    Build the Jacobian one pass at a time. Press Run all passes in both modes. A forward pass (JVP) fills one column of JJ; a reverse pass (VJP) fills one row. For a loss (m=1m = 1 output) one reverse pass gives the whole gradient. Switch to the simulator shape (n=3n = 3 inputs, m=12m = 12 outputs): now forward mode needs fewer passes.

    Quick check

    A model has 10810^8 parameters and a scalar loss. About how many passes through the graph does each mode need for the full gradient?

    3. Definitions and main results #

    The setting #

    A batch has BB examples, one per row (the row convention of lesson 01). A linear layer computes

    Z=AW+1b,A∈RB×n,  W∈Rn×m,  b∈R1×m,  Z∈RB×m,Z = AW + \mathbf{1}b, \qquad A \in \mathbb{R}^{B \times n},\; W \in \mathbb{R}^{n \times m},\; b \in \mathbb{R}^{1 \times m},\; Z \in \mathbb{R}^{B \times m},

    where 1∈RB×1\mathbf{1} \in \mathbb{R}^{B \times 1} is a column of ones, so 1b\mathbf{1}b adds the row bb to every row. Write G=∂L/∂ZG = \partial \mathcal{L}/\partial Z. It has the same shape as ZZ: entry GbjG_{bj} is the derivative of the loss with respect to entry ZbjZ_{bj}. In the same way, Wˉ\bar W has the shape of WW and Aˉ\bar A the shape of AA.

    Rule 1: the linear layer #

    First, entry by entry. Entry WijW_{ij} appears in column jj of ZZ, in every row: Zbj=∑kAbkWkj+bjZ_{bj} = \sum_k A_{bk} W_{kj} + b_j, so ∂Zbj/∂Wij=Abi\partial Z_{bj}/\partial W_{ij} = A_{bi}. By the chain rule, summed over every entry of ZZ that WijW_{ij} touches (one per example):

    Wˉij=∑b=1B∂L∂Zbj ∂Zbj∂Wij=∑b=1BAbi Gbj=(A⊤G)ij.\bar W_{ij} = \sum_{b=1}^{B} \frac{\partial \mathcal{L}}{\partial Z_{bj}}\,\frac{\partial Z_{bj}}{\partial W_{ij}} = \sum_{b=1}^{B} A_{bi}\, G_{bj} = (A^\top G)_{ij}.

    In the same way, AbiA_{bi} appears in row bb of ZZ, in every column, with ∂Zbj/∂Abi=Wij\partial Z_{bj}/\partial A_{bi} = W_{ij}. So Aˉbi=∑jGbjWij=(GW⊤)bi\bar A_{bi} = \sum_j G_{bj} W_{ij} = (G W^\top)_{bi}. And bjb_j appears in every row of column jj, so bˉj=∑bGbj\bar b_j = \sum_b G_{bj}.

    Theorem (backward pass of a linear layer). For Z=AW+1bZ = AW + \mathbf{1}b and G=∂L/∂ZG = \partial\mathcal{L}/\partial Z:

    Wˉ=A⊤G    (n×m),Aˉ=GW⊤    (B×n),bˉ=1⊤G    (1×m, the column sums of G).\bar W = A^\top G \;\; (n \times m), \qquad \bar A = G W^\top \;\; (B \times n), \qquad \bar b = \mathbf{1}^\top G \;\; (1 \times m,\ \text{the column sums of } G).

    Shapes as a memory aid. Wˉ\bar W must have the shape of WW, n×mn \times m. From AA (B×nB \times n) and GG (B×mB \times m), the only product with that shape is A⊤GA^\top G. From GG (B×mB \times m) and WW (n×mn \times m), the only product with the shape of AA is GW⊤GW^\top. When you forget a formula, check the shapes.

    A compact proof with differentials

    For a scalar L\mathcal{L}, dL=∑b,jGbj dZbj=⟨G,dZ⟩d\mathcal{L} = \sum_{b,j} G_{bj}\, dZ_{bj} = \langle G, dZ \rangle, the Frobenius inner product ⟨X,Y⟩=tr⁡(X⊤Y)\langle X, Y \rangle = \operatorname{tr}(X^\top Y). If we can write dL=⟨M,dW⟩d\mathcal{L} = \langle M, dW \rangle, then Wˉ=M\bar W = M. Now dZ=dA W+A dW+1 dbdZ = dA\, W + A\, dW + \mathbf{1}\, db. Move each factor to the other side of the inner product (cyclic property of the trace):

    1. ⟨G,A dW⟩=⟨A⊤G,dW⟩\langle G, A\,dW \rangle = \langle A^\top G, dW \rangle, so Wˉ=A⊤G\bar W = A^\top G.
    2. ⟨G,dA W⟩=⟨GW⊤,dA⟩\langle G, dA\,W \rangle = \langle G W^\top, dA \rangle, so Aˉ=GW⊤\bar A = G W^\top.
    3. ⟨G,1 db⟩=⟨1⊤G,db⟩\langle G, \mathbf{1}\,db \rangle = \langle \mathbf{1}^\top G, db \rangle, so bˉ=1⊤G\bar b = \mathbf{1}^\top G. ■\blacksquare

    Rule 2: an elementwise activation #

    For A=σ(Z)A = \sigma(Z), applied entry by entry, each AbjA_{bj} depends only on ZbjZ_{bj}. So the Jacobian is diagonal, and the VJP is an elementwise product (⊙\odot):

    ∂L∂Z=∂L∂A⊙σ′(Z).\frac{\partial \mathcal{L}}{\partial Z} = \frac{\partial \mathcal{L}}{\partial A} \odot \sigma'(Z).

    For ReLU, σ′(Z)\sigma'(Z) is the mask 1[Z>0]\mathbf{1}[Z > 0]: the gradient passes where the unit was active and is blocked where it was off.

    Rule 3: softmax cross-entropy #

    Take one example with logits z∈RKz \in \mathbb{R}^K, probabilities p=softmax⁡(z)p = \operatorname{softmax}(z) and a target yy with ∑cyc=1\sum_c y_c = 1 (one-hot, or smoothed). The loss is ℓ=−∑cyclog⁡pc\ell = -\sum_c y_c \log p_c, and log⁡pc=zc−log⁡∑jezj\log p_c = z_c - \log \sum_j e^{z_j}. So

    ∂ℓ∂zk=−yk+(∑cyc)ezk∑jezj=pk−yk.\frac{\partial \ell}{\partial z_k} = -y_k + \Big(\sum_c y_c\Big) \frac{e^{z_k}}{\sum_j e^{z_j}} = p_k - y_k .

    Lesson 02 derived this too. For the batch mean L=1B∑bℓb\mathcal{L} = \frac1B \sum_b \ell_b, each example's loss is divided by BB, so

    GL=P−YB,P,Y∈RB×K.G_L = \frac{P - Y}{B}, \qquad P, Y \in \mathbb{R}^{B \times K}.
    The softmax Jacobian and its VJP

    The softmax Jacobian is J=diag⁡(p)−pp⊤J = \operatorname{diag}(p) - pp^\top (K×KK \times K). Its VJP with a row v⊤v^\top is v⊤J=(p⊙(v−(v⊤p)1))⊤v^\top J = \big(p \odot (v - (v^\top p)\mathbf{1})\big)^\top, computed in O(K)O(K) without forming JJ. With v=∂ℓ/∂p=−y/pv = \partial\ell/\partial p = -y/p (entrywise), v⊤p=−∑cyc=−1v^\top p = -\sum_c y_c = -1, and the formula gives p⊙(−y/p+1)=p−yp \odot (-y/p + \mathbf{1}) = p - y again. Attention (lesson 09) applies a softmax to every row of a score matrix and uses this VJP.

    Quick check

    AA is 32×10032 \times 100 and Z=AWZ = AW is 32×1032 \times 10. Which expression is ∂L/∂W\partial\mathcal{L}/\partial W?

    Theorem: the MLP backward pass #

    Forward pass. A0=XA_0 = X; Zl=Al−1Wl+1blZ_l = A_{l-1}W_l + \mathbf{1}b_l for l=1,…,Ll = 1, \dots, L; Al=σ(Zl)A_l = \sigma(Z_l) for l<Ll < L; P=softmax⁡(ZL)P = \operatorname{softmax}(Z_L) row by row; and L=−1B∑b,cYbclog⁡Pbc\mathcal{L} = -\frac1B \sum_{b,c} Y_{bc} \log P_{bc} with one-hot rows in YY.

    Theorem. With Gl:=∂L/∂ZlG_l := \partial \mathcal{L}/\partial Z_l, for l=L,L−1,…,1l = L, L-1, \dots, 1:

    1. GL=(P−Y)/BG_L = (P - Y)/B.
    2. Wˉl=Al−1⊤Gl\bar W_l = A_{l-1}^\top G_l and bˉl=1⊤Gl\bar b_l = \mathbf{1}^\top G_l.
    3. Aˉl−1=GlWl⊤\bar A_{l-1} = G_l W_l^\top.
    4. Gl−1=(GlWl⊤)⊙σ′(Zl−1)G_{l-1} = (G_l W_l^\top) \odot \sigma'(Z_{l-1}) (for l≥2l \ge 2).

    Proof (induction from the top layer down).

    1. Step 1 is rule 3.
    2. Suppose GlG_l is correct. The graph is a chain: L\mathcal{L} depends on WlW_l, blb_l and Al−1A_{l-1} only through ZlZ_l. Rule 1 with A=Al−1A = A_{l-1} and G=GlG = G_l gives steps 2 and 3.
    3. Al−1=σ(Zl−1)A_{l-1} = \sigma(Z_{l-1}) elementwise, and L\mathcal{L} depends on Zl−1Z_{l-1} only through Al−1A_{l-1}. Rule 2 gives step 4, which is Gl−1G_{l-1}. So the claim holds for l−1l - 1. ■\blacksquare

    Note where 1/B1/B enters: once, in GLG_L. The products Al−1⊤GlA_{l-1}^\top G_l and 1⊤Gl\mathbf{1}^\top G_l then sum over the batch, so every gradient is a mean over the batch.

    Interactive figure: loading…

    Step through a 2-layer MLP on a batch. Each matrix is drawn to scale: forward values blue, weights violet, gradients orange. In each product the inner sizes must match (they are marked). Change BB, the widths and the classes, and check that every gradient has the shape of its variable. Press Show a wrong version to see the shape error of a common bug in magenta.

    Cost: time and memory #

    Time. A forward matmul AWAW with A∈RB×nA \in \mathbb{R}^{B \times n}, W∈Rn×mW \in \mathbb{R}^{n \times m} costs about 2Bnm2Bnm FLOPs (one multiply and one add per term). The backward pass needs two matmuls of the same size, A⊤GA^\top G and GW⊤G W^\top. So the backward pass costs about 2× the forward pass, and one training step costs about 3× the forward pass. Elementwise steps cost O(Bm)O(Bm), which is small next to the matmuls.

    • For a transformer, the forward pass costs about 2N2N FLOPs per token for NN parameters (one multiply-add per weight). So a training step costs about 6N6N FLOPs per token (attention scores are not counted in this estimate).
    • The cheap gradient principle: reverse mode computes the gradient of any scalar program for a small constant times the cost of the program, whatever the number of inputs. Baur and Strassen (1983) prove a constant between 3 and 5 for arithmetic circuits, depending on how operations are counted.

    Memory. Steps 2–4 for layer ll need Al−1A_{l-1} and σ′(Zl−1)\sigma'(Z_{l-1}). The forward pass produces them in the order 1,…,L1, \dots, L; the backward pass uses them in the order L,…,1L, \dots, 1. So all of them are in memory when the backward pass starts: O(L⋅B⋅width)O(L \cdot B \cdot \text{width}) numbers, on top of the weights, gradients and optimizer state. For large models with long sequences, these stored activations are the largest part of training memory.

    What each operation saves for the backward pass: a matmul saves its input; ReLU needs only the sign (PyTorch saves its output); GELU saves its input; dropout saves its mask; softmax saves its output.

    Activation checkpointing #

    Activation checkpointing (also called rematerialization) trades compute for memory. Split the LL layers into ss segments.

    1. Forward pass: store only the input of each segment. Do not keep the activations inside a segment.
    2. Backward pass, segment by segment from the last one: run the segment's forward pass again from its stored input (now keeping its activations), backpropagate through it, then free them.
    • Memory: about s+L/ss + L/s layer activations (ss segment inputs, plus the L/sL/s activations of one segment). This is smallest at s=Ls = \sqrt{L}, where it equals 2L2\sqrt{L}.
    • Compute: at most one extra forward pass. A step costs about 4F4F instead of 3F3F, about 33% more.
    Interactive figure: loading…

    Choose the number of layers LL and of segments ss. Each box is a layer. Blue marks an activation stored in the forward pass and orange a layer that is run a second time in the backward pass. The chart shows the peak number of stored activations against ss; find its minimum and compare it with L\sqrt{L}. The counts follow PyTorch's checkpoint_sequential, which does not checkpoint its last segment.

    For L=16L = 16 layers (PyTorch's checkpoint_sequential, counted in section 5):

    segments ss layer forward calls stored activations
    1 (no checkpointing) 16 17
    2 24 10
    4 28 8
    16 31 17

    With s=4=16s = 4 = \sqrt{16} only 8 activations are stored, for 12 extra layer calls. With 16 segments nothing is gained: every layer input is a segment input.

    More memory-compute trades
    • Recursive schedules reach O(log⁡L)O(\log L) memory for O(Llog⁡L)O(L \log L) forward compute (Chen et al. 2016, §4).
    • In practice, large models checkpoint each transformer block and keep only the block inputs. Megatron's selective recomputation (Korthikanti et al. 2022) recomputes only the attention-score part, which is large in memory but cheap in FLOPs.
    • FlashAttention's backward pass recomputes the attention matrix from QQ, KK, VV and the softmax statistics instead of storing it: the same trade inside one GPU kernel.

    Gradient checking #

    A gradient check compares the gradient from your backward pass with a numerical estimate. The central difference with step hh in coordinate ii is

    gnum=f(x+hei)−f(x−hei)2h,g_{\text{num}} = \frac{f(x + h e_i) - f(x - h e_i)}{2h},

    where eie_i is the ii-th unit vector. (PyTorch's gradcheck calls the step eps. We write hh because ε\varepsilon means machine epsilon in linear-algebra lesson 13.) Two errors compete.

    Truncation error. By Taylor's theorem, f(x±h)=f±hf′+h22f′′±h36f′′′+O(h4)f(x \pm h) = f \pm h f' + \tfrac{h^2}{2} f'' \pm \tfrac{h^3}{6} f''' + O(h^4). In the difference, the even terms cancel:

    gnum=f′(x)+h26f′′′(x)+O(h4).g_{\text{num}} = f'(x) + \frac{h^2}{6} f'''(x) + O(h^4).

    So the error is O(h2)O(h^2). The forward difference (f(x+h)−f(x))/h(f(x+h) - f(x))/h keeps the f′′f'' term and has error O(h)O(h), which is much worse.

    Round-off error. Each computed value of ff has a relative error of about the unit roundoff uu (u=2−53≈1.1⋅10−16u = 2^{-53} \approx 1.1 \cdot 10^{-16} in float64, u=2−24≈6⋅10−8u = 2^{-24} \approx 6 \cdot 10^{-8} in float32). The difference of two nearly equal numbers keeps this absolute error, about u∣f∣u|f|, and the division by 2h2h makes it about u∣f∣/hu|f|/h. It grows when hh gets small.

    The best step. Add the two errors and minimize over hh:

    E(h)≈h2∣f′′′∣6+u∣f∣h,E′(h)=0  ⇒  h∗=(3u∣f∣∣f′′′∣)1/3.E(h) \approx \frac{h^2 |f'''|}{6} + \frac{u |f|}{h}, \qquad E'(h) = 0 \;\Rightarrow\; h^* = \Big(\frac{3u|f|}{|f'''|}\Big)^{1/3}.

    When ∣f∣≈∣f′′′∣|f| \approx |f'''|, this gives h∗≈(3u)1/3≈7⋅10−6h^* \approx (3u)^{1/3} \approx 7 \cdot 10^{-6} in float64, with an error near 2⋅10−112 \cdot 10^{-11}. In float32, h∗≈6⋅10−3h^* \approx 6 \cdot 10^{-3} and the best error is only about 10−510^{-5}: too coarse to separate a small bug from round-off. Check gradients in float64.

    Interactive figure: loading…

    Move the point xx and switch the function and the precision. The chart shows the error of the central difference and of the forward difference against the step hh, on log-log axes. The dashed lines are the two error models: truncation (falls as hh gets smaller) and round-off (grows). Notice the V shape, its minimum near 10−510^{-5} in float64, and how float32 moves the minimum up and to the right.

    How to run a gradient check in practice.

    • Use the relative error ∣gnum−g∣/max⁡(∣gnum∣,∣g∣,tiny)|g_{\text{num}} - g| / \max(|g_{\text{num}}|, |g|, \text{tiny}). Gradients of different parameters differ by orders of magnitude, and the relative error does not depend on scale. For a smooth function in float64, expect 10−710^{-7} or less. A relative error of 10−210^{-2} or more almost always means a bug (the rule of thumb of Stanford CS231n).
    • Avoid kinks. If x±hx \pm h crosses a ReLU joint or a tie in a max, the numerical derivative is wrong. Use smooth activations for the check, or check that no pre-activation is within hh of 0.
    • Save time on big models. The check needs two evaluations of ff per coordinate. Check a few random coordinates, or one random direction vv: (f(x+hv)−f(x−hv))/(2h)≈∇f(x)⋅v(f(x + hv) - f(x - hv))/(2h) \approx \nabla f(x) \cdot v.

    Answers to section 1. (1) About 2F2F: the backward pass does two matmuls of the same size for every forward matmul. (2) 10−510^{-5}, close to (3u)1/3(3u)^{1/3}. At 10−210^{-2} the truncation error dominates; at 10−1010^{-10} and 10−1510^{-15} the round-off error dominates.

    Quick check

    A gradient check in float64 with h=10−6h = 10^{-6} and tanh activations gives a relative error of 3⋅10−23 \cdot 10^{-2} for one weight matrix and 10−910^{-9} for all the others. What is the most likely cause?

    4. Worked example #

    A batch of B=2B = 2 examples, two inputs, one hidden ReLU layer of width 2, K=2K = 2 classes, all biases zero:

    X=(1−1−10),W1=(12−11),W2=(1−1−22),labels (0,1), so Y=(1001).X = \begin{pmatrix} 1 & -1 \\ -1 & 0 \end{pmatrix},\quad W_1 = \begin{pmatrix} 1 & 2 \\ -1 & 1 \end{pmatrix},\quad W_2 = \begin{pmatrix} 1 & -1 \\ -2 & 2 \end{pmatrix},\quad \text{labels } (0, 1), \text{ so } Y = \begin{pmatrix} 1 & 0 \\ 0 & 1 \end{pmatrix}.

    Forward pass.

    1. Z1=XW1Z_1 = XW_1. Row 1: (1⋅1+(−1)(−1),  1⋅2+(−1)⋅1)=(2,1)(1 \cdot 1 + (-1)(-1),\; 1 \cdot 2 + (-1) \cdot 1) = (2, 1). Row 2: ((−1)⋅1+0,  (−1)⋅2+0)=(−1,−2)((-1) \cdot 1 + 0,\; (-1) \cdot 2 + 0) = (-1, -2). So Z1=(21−1−2)Z_1 = \begin{pmatrix} 2 & 1 \\ -1 & -2 \end{pmatrix} and A1=relu⁡(Z1)=(2100)A_1 = \operatorname{relu}(Z_1) = \begin{pmatrix} 2 & 1 \\ 0 & 0 \end{pmatrix}. Both hidden units are off for example 2.
    2. Z2=A1W2Z_2 = A_1 W_2. Row 1: (2⋅1+1⋅(−2),  2⋅(−1)+1⋅2)=(0,0)(2 \cdot 1 + 1 \cdot (-2),\; 2 \cdot (-1) + 1 \cdot 2) = (0, 0). Row 2 is (0,0)(0, 0) because A1A_1 has a zero row.
    3. All logits are 0, so every row of PP is (12,12)(\tfrac12, \tfrac12), and L=−12(log⁡12+log⁡12)=log⁡2≈0.6931\mathcal{L} = -\tfrac12(\log\tfrac12 + \log\tfrac12) = \log 2 \approx 0.6931.

    Backward pass.

    1. G2=(P−Y)/2=12(−0.50.50.5−0.5)=(−0.250.250.25−0.25)G_2 = (P - Y)/2 = \tfrac12\begin{pmatrix} -0.5 & 0.5 \\ 0.5 & -0.5 \end{pmatrix} = \begin{pmatrix} -0.25 & 0.25 \\ 0.25 & -0.25 \end{pmatrix}.
    2. Wˉ2=A1⊤G2=(2010)(−0.250.250.25−0.25)=(−0.50.5−0.250.25)\bar W_2 = A_1^\top G_2 = \begin{pmatrix} 2 & 0 \\ 1 & 0 \end{pmatrix}\begin{pmatrix} -0.25 & 0.25 \\ 0.25 & -0.25 \end{pmatrix} = \begin{pmatrix} -0.5 & 0.5 \\ -0.25 & 0.25 \end{pmatrix}, and bˉ2=1⊤G2=(0,0)\bar b_2 = \mathbf{1}^\top G_2 = (0, 0).
    3. Aˉ1=G2W2⊤=(−0.250.250.25−0.25)(1−2−12)=(−0.510.5−1)\bar A_1 = G_2 W_2^\top = \begin{pmatrix} -0.25 & 0.25 \\ 0.25 & -0.25 \end{pmatrix}\begin{pmatrix} 1 & -2 \\ -1 & 2 \end{pmatrix} = \begin{pmatrix} -0.5 & 1 \\ 0.5 & -1 \end{pmatrix}.
    4. G1=Aˉ1⊙1[Z1>0]=(−0.510.5−1)⊙(1100)=(−0.5100)G_1 = \bar A_1 \odot \mathbf{1}[Z_1 > 0] = \begin{pmatrix} -0.5 & 1 \\ 0.5 & -1 \end{pmatrix} \odot \begin{pmatrix} 1 & 1 \\ 0 & 0 \end{pmatrix} = \begin{pmatrix} -0.5 & 1 \\ 0 & 0 \end{pmatrix}. The mask removes example 2.
    5. Wˉ1=X⊤G1=(1−1−10)(−0.5100)=(−0.510.5−1)\bar W_1 = X^\top G_1 = \begin{pmatrix} 1 & -1 \\ -1 & 0 \end{pmatrix}\begin{pmatrix} -0.5 & 1 \\ 0 & 0 \end{pmatrix} = \begin{pmatrix} -0.5 & 1 \\ 0.5 & -1 \end{pmatrix}, and bˉ1=(−0.5,1)\bar b_1 = (-0.5, 1).

    Shape check. Wˉ2\bar W_2 is 2×22 \times 2 like W2W_2; Aˉ1\bar A_1 is B×2B \times 2 like A1A_1. Every gradient has the shape of its variable.

    Read the result. Both hidden units are off for example 2. Its row of A1A_1 is zero, so it adds nothing to Wˉ2\bar W_2, and the ReLU mask removes it from G1G_1. So example 2 reaches only the output bias b2b_2, and there its gradient cancels the gradient of example 1. The first-layer weights W1W_1 learn from example 1 alone.

    Quick check

    In the worked example, why is bˉ2=(0,0)\bar b_2 = (0, 0)?

    Finite differences on a known function. For f=sin⁡f = \sin at x=1x = 1 (so f′=cos⁡1f' = \cos 1), the central difference has these errors in float64:

    hh 10−110^{-1} 10−310^{-3} 10−510^{-5} 10−710^{-7} 10−910^{-9} 10−1110^{-11}
    error 9.0⋅10−49.0 \cdot 10^{-4} 9.0⋅10−89.0 \cdot 10^{-8} 1.1⋅10−111.1 \cdot 10^{-11} 1.9⋅10−101.9 \cdot 10^{-10} 3.0⋅10−93.0 \cdot 10^{-9} 1.2⋅10−61.2 \cdot 10^{-6}

    For h≥10−3h \ge 10^{-3} the error equals the truncation term h2cos⁡(1)/6h^2 \cos(1)/6: it falls by 10410^4 when hh falls by 100. Near 10−510^{-5} the two sources have the same size. Below that the error grows like u/hu/h (round-off).

    5. In code #

    NumPy checks the worked example, compares it with central differences, and reproduces the table of errors. The notebook has these cells and the exercises.

    import numpy as np
    
    X = np.array([[1., -1.], [-1., 0.]])
    W1 = np.array([[1., 2.], [-1., 1.]])
    W2 = np.array([[1., -1.], [-2., 2.]])
    Y = np.eye(2)                                  # labels (0, 1) as one-hot rows
    B = X.shape[0]
    
    def loss(W1, W2):
        Z1 = X @ W1; A1 = np.maximum(Z1, 0); Z2 = A1 @ W2
        Z2 = Z2 - Z2.max(axis=1, keepdims=True)    # stable softmax
        P = np.exp(Z2) / np.exp(Z2).sum(axis=1, keepdims=True)
        return -np.mean(np.log((P * Y).sum(axis=1))), (Z1, A1, P)
    
    L, (Z1, A1, P) = loss(W1, W2)
    G2 = (P - Y) / B
    dW2 = A1.T @ G2
    G1 = (G2 @ W2.T) * (Z1 > 0)
    dW1 = X.T @ G1
    print(round(L, 4))                             # 0.6931
    print(dW2.tolist(), G2.sum(axis=0).tolist())   # [[-0.5, 0.5], [-0.25, 0.25]] [0.0, 0.0]
    print(dW1.tolist(), G1.sum(axis=0).tolist())   # [[-0.5, 1.0], [0.5, -1.0]] [-0.5, 1.0]
    
    # central differences for every entry of W1
    # (no pre-activation is near 0, so no ReLU kink is crossed)
    h = 1e-6
    num = np.zeros_like(W1)
    for i in range(2):
        for j in range(2):
            E = np.zeros_like(W1); E[i, j] = h
            num[i, j] = (loss(W1 + E, W2)[0] - loss(W1 - E, W2)[0]) / (2 * h)
    rel = np.abs(num - dW1).max() / np.abs(dW1).max()
    print(rel < 1e-8)                              # True
    
    # error of the central difference for sin'(1) against h
    hs = 10.0 ** -np.arange(1, 12, 2)
    err = np.abs((np.sin(1 + hs) - np.sin(1 - hs)) / (2 * hs) - np.cos(1))
    print(" ".join(f"{a:.0e}:{e:.1e}" for a, e in zip(hs, err)))
    # 1e-01:9.0e-04 1e-03:9.0e-08 1e-05:1.1e-11 1e-07:1.9e-10 1e-09:3.0e-09 1e-11:1.2e-06
    

    PyTorch's autograd gives the same numbers, and it can count what checkpointing stores (16 Linear+ReLU layers, width 256):

    import torch, torch.nn.functional as F
    
    Xt, W1t, W2t = (torch.tensor(M, dtype=torch.float64) for M in (X, W1, W2))
    W1t.requires_grad_(); W2t.requires_grad_()
    F.cross_entropy(torch.relu(Xt @ W1t) @ W2t, torch.tensor([0, 1])).backward()
    print(W1t.grad.tolist())                       # [[-0.5, 1.0], [0.5, -1.0]]
    Xg = Xt.clone().requires_grad_()
    print(torch.autograd.gradcheck(lambda t: torch.tanh(t @ W2t).sum(), (Xg,)))  # True
    # checkpoint_sequential on 16 layers (the counting code is in the notebook)
    # segments, forward calls, saved activations: 1 16 17 | 2 24 10 | 4 28 8 | 16 31 17
    

    6. Where it shows up #

    • Deep-learning frameworks. Autograd in PyTorch and JAX is rules 1–3 written for hundreds of operations. Each operation has a forward function and a VJP. Fused GPU kernels come with a hand-written backward pass, and a gradient check is how their authors test it.
    • Large-scale training. The estimate of 6N6N FLOPs per token sets compute budgets and the model FLOPs utilization (MFU) of a training run. Activation memory decides the micro-batch size, where to checkpoint, and when to split sequences across GPUs.
    • Normalization and residual connections (lesson 05) are fan-out: the residual stream x+F(x)x + F(x) receives the adjoint from both paths, so gradients reach early layers directly.
    • ML for science. The adjoint method for ODE and PDE solvers is reverse mode applied to the solver's steps; neural ODEs use its continuous-time form. Variational data assimilation in weather prediction (4D-Var) computes its gradient with an adjoint model, and long adjoint runs use checkpointing schedules such as Griewank's revolve.
    • Physics-informed losses. A PINN (physics-informed neural network) loss contains derivatives such as ∂u/∂x\partial u/\partial x computed by autograd. Training then backpropagates through that derivative (double backward, create_graph=True in PyTorch). This is one reason smooth activations matter (lesson 04).

    7. Common mistakes #

    • Dividing by BB twice, or not at all. Divide once, in GLG_L. Otherwise the gradient has the wrong scale and depends on BB, and the learning rate seems to change when BB changes.
    • A wrong order or a missing transpose. Wˉ=A⊤G\bar W = A^\top G, not G⊤AG^\top A (that is m×nm \times n, the transposed shape). Check shapes at every step.
    • The mask from the wrong layer. Step 4 uses σ′(Zl−1)\sigma'(Z_{l-1}), the pre-activation below WlW_l, not σ′(Zl)\sigma'(Z_l).
    • Not summing at fan-out. Residual connections and shared weights get the sum of all contributions. In PyTorch, the opposite bug is forgetting optimizer.zero_grad(), so the gradients of different steps add up.
    • A bad gradient check: float32, h=10−10h = 10^{-10}, a step across a ReLU joint, or absolute instead of relative errors.
    • In-place operations that overwrite a tensor saved for the backward pass. PyTorch stops with an error about a modified variable version.

    8. Check yourself #

    1. AA has shape B×nB \times n and G=∂L/∂ZG = \partial \mathcal{L}/\partial Z has shape B×mB \times m, for Z=AW+1bZ = AW + \mathbf{1}b. Give Wˉ\bar W, Aˉ\bar A, bˉ\bar b and their shapes.
      AnswerWˉ=A⊤G\bar W = A^\top G (n×mn \times m), Aˉ=GW⊤\bar A = GW^\top (B×nB \times n), bˉ=1⊤G\bar b = \mathbf{1}^\top G (1×m1 \times m, the column sums of GG over the batch).
    2. Why does ∂ℓ/∂z=p−q\partial \ell/\partial z = p - q also hold for a label-smoothed target q=(1−ε) y+ε/Kq = (1-\varepsilon)\,y + \varepsilon/K (one-hot yy, smoothing ε\varepsilon, KK classes)?
      AnswerThe derivation used only ∑cqc=1\sum_c q_c = 1, and the smoothed target also sums to 1: (1−ε)+K⋅ε/K=1(1 - \varepsilon) + K \cdot \varepsilon/K = 1.
    3. You switch from the mean loss over the batch to the sum. What changes in the backward pass?
      AnswerOnly GLG_L loses the factor 1/B1/B, so every gradient is BB times larger. With SGD that is the same as a BB times larger learning rate. Adam is almost invariant to it (except through its small constant in the denominator).
    4. A 64-layer network uses L\sqrt{L} checkpointing. About how many layer activations are stored, and what is the extra compute?
      Answers=8s = 8 segments: about s+L/s=16s + L/s = 16 activations instead of 64, for at most one extra forward pass (a step of about 4F4F instead of 3F3F).
    5. What is the truncation error of the central difference for f=exf = e^x at x=0x = 0 with h=10−4h = 10^{-4}?
      Answerh2f′′′(0)/6=10−8/6≈1.7⋅10−9h^2 f'''(0)/6 = 10^{-8}/6 \approx 1.7 \cdot 10^{-9}. (The quotient is exactly sinh⁡(h)/h=1+h2/6+…\sinh(h)/h = 1 + h^2/6 + \dots)
    6. Reverse mode is right for training. When is forward mode better?
      AnswerWhen there are few inputs and many outputs, for example the Jacobian of R3→R106\mathbb{R}^3 \to \mathbb{R}^{10^6} (the sensitivity of a field to 3 physical parameters): 3 forward passes (JVPs) against 10610^6 reverse passes (VJPs).
    Recall cardsAnswer in your head first, then reveal. Add the cards to your review deck to see them again just before you would forget.
    1. What is the adjoint uˉ\bar u of a variable uu?

    2. How does backprop compute the adjoint of a node's input from the adjoint of its output?

    3. A variable feeds two operations that send it adjoints aa and bb. What is its adjoint?

    4. For Z=AW+1bZ = AW + \mathbf{1}b with G=∂L/∂ZG = \partial\mathcal{L}/\partial Z, what are Wˉ\bar W and Aˉ\bar A?

    5. What is GLG_L for softmax cross-entropy averaged over a batch of BB examples?

    6. Write the backward recursion from GlG_l to Gl−1G_{l-1} in an MLP.

    7. Why does training use reverse mode and not forward mode?

    8. About how much does one training step cost compared with a forward pass, and how many FLOPs per token for NN parameters?

    9. Which step hh is best for a central-difference check in float64, and why?

    10. What does activation checkpointing trade, and what is the best number of segments?

    Open the review →

    9. Interview angle #

    • "Derive the gradient of softmax cross-entropy." p−yp - y, from log⁡pc=zc−log⁡∑jezj\log p_c = z_c - \log\sum_j e^{z_j} and ∑cyc=1\sum_c y_c = 1.
    • "Write the backward pass of a two-layer MLP in NumPy." The four steps of the theorem in section 3, with shapes: G2 = (P - Y) / B, dW2 = A1.T @ G2, db2 = G2.sum(0), G1 = (G2 @ W2.T) * (Z1 > 0), dW1 = X.T @ G1, db1 = G1.sum(0).
    • "Where does training memory go, and how do you reduce activation memory?" Weights, gradients, optimizer state and stored activations. Reduce activations with checkpointing (L\sqrt{L} segments, or selective recomputation), smaller micro-batches, or by splitting sequences across devices.
    • "How do you know your custom backward pass is correct?" gradcheck in float64, relative error, h≈10−6h \approx 10^{-6}, smooth functions or no kinks near the test point, or a random-direction check.
    • "Why does a training step cost about 6N6N FLOPs per token?" 2N2N for the forward pass (one multiply-add per weight), and two matmuls of the same size in the backward pass for every forward matmul.

    10. Practice #

    The notebook for this lesson runs in Colab. Its core exercises use NumPy only; PyTorch is used as a reference when it is installed.

    1. Write a tiny scalar autograd engine: a Value class with +, *, relu and backward(), and check it on the graph of section 2.
    2. Write mlp_backward for a 2-layer MLP with softmax cross-entropy in matrix form (no loop over examples), and check it on the worked example.
    3. Write numerical_gradient (central differences) and rel_error, and use them to check exercise 2 on random data.
    4. Plot the error of central and forward differences against hh, and find the best hh.
    5. Write checkpoint_counts(L, s) that returns the forward calls and stored activations of checkpoint_sequential, and find the best ss for L=64L = 64.

    11. Watch and read #

    The idea before the formulas: how each weight's change is pushed back from the loss.
    Open on YouTube · the player loads from youtube-nocookie.com when you press play
    The chain rule on a chain of single neurons, and how sums appear when a neuron feeds many others.
    Open on YouTube · the player loads from youtube-nocookie.com when you press play
    Backprop on computational graphs with numbers at each node, and the add, multiply and max gates.
    Open on YouTube · the player loads from youtube-nocookie.com when you press play
    A full manual backward pass of an MLP in PyTorch, checked tensor by tensor against autograd.
    Open on YouTube · the player loads from youtube-nocookie.com when you press play