Search Tech Journey

Find topics, journeys and posts

back to blog
mlbeginner 150m read

DL S010 · Backprop by Hand

Derive every one of the 9 gradients of the S009 2-2-1 MLP on paper, code the backward pass in ~15 lines of NumPy, verify against a numeric gradient check to 1e-8, then train it on XOR — the problem Minsky & Papert wrote a book about — using code you wrote from scratch. Story hook (Rumelhart-Hinton-Williams 1986 mailing floppy disks of their C code), the 4-line 'backprop cheat sheet' every ML engineer eventually memorises, and the direct line from your hand-derivation to PyTorch's autograd.backward. Part of the 'Deep Learning & LLMs From Scratch' 80-session self-study series.

🧠SoftwareM02 · Neural nets from scratch in numpy· Session 010 of 130 150 min

🎯 Do backpropagation on paper for the S009 2-2-1 MLP, compute all 9 gradients, code them in ~15 lines of NumPy, verify with a two-sided finite-difference check to ~1e-8, then train the network to 100% on XOR.

Series: Deep Learning & LLMs From Scratch — 80 sessions · Session 10 / 80 · Module M02 · ~2.5 hours


0 · The story — the day deep learning stopped being magic

October 9, 1986. Nature prints Rumelhart–Hinton–Williams's 8-page letter on backpropagation. The math in that letter is exactly what you're going to do today by hand on a 9-parameter network. But the paper is not really about the math — Werbos had the derivation in 1974, Parker had it in 1985, LeCun had it in his 1985 dissertation. What Rumelhart–Hinton–Williams did was make it routinely reproducible. They wrote clean C code, they mailed floppy disks to anyone who wrote and asked, and their small demonstration networks (XOR, a family tree, a symmetry detector) actually trained on the toy 68020-based UNIX workstations of the day.

Andrej Karpathy wrote in 2016 a blog post titled "Yes you should understand backprop" that has become the internal-training doctrine at half the ML companies I've worked with. His argument, compressed: every time a training run goes wrong in a way that's not "loss is NaN," the fix requires understanding what the gradient tensor is doing. Vanishing gradients through a sigmoid stack, exploding gradients through a long RNN, gradients zeroed by a dying ReLU, gradients that don't accumulate across a .detach() you forgot — every one of these is invisible unless the shape of the gradient computation is in your head. And the only way it gets into your head is doing it once, by hand, on numbers you can hold in your head.

That's today. Nine parameters. One example. One BCE loss. Full derivation. Then code. Then numeric check. Then a real training loop that solves XOR. After this session, loss.backward() is not a mysterious PyTorch call; it is code you have written yourself, and you know exactly what bytes it moves.

1 · What you'll build today

Compute, verify, and code every one of the 9 gradients of the 2-2-1 MLP; then train the network on XOR to 100% accuracy in <3000 SGD steps.

You will be able to
  • Recite from memory the four cheat-sheet formulas: dX for a linear layer, dW for a linear layer, db for a linear layer, and the ReLU gradient.
  • Compute all 9 parameter gradients of the 2-2-1 MLP by hand for one input example and get numbers that match NumPy to 4 dp.
  • Implement the backward pass in ~15 lines of NumPy and match the paper computation to floating-point precision.
  • Run a two-sided numeric gradient check on every one of the 9 parameters — expect agreement to ~1e-7.
  • Chain forward + backward + SGD into a training loop and drive XOR loss from log 2 to under 0.05.
  • Explain what dY_pre = (Y − y_true) / N would look like if the loss used `sum` instead of `mean`.
  • Draw the forward-and-backward flow diagram from memory.

2 · Checkpoint · you should already know



3 · Recap — the forward pass from S009

H_pre = X @ W1 + b1              shape (N, 2)
H     = ReLU(H_pre)              shape (N, 2)
Y_pre = H @ W2 + b2              shape (N, 1)
Y     = σ(Y_pre)                 shape (N, 1)
L     = BCE(Y, y_true)           scalar (mean over batch)

Cache: (X, H_pre, H, Y_pre, Y).

BCE with mean reduction:

L=1Ni=1N[yilogYi+(1yi)log(1Yi)]L = -\frac{1}{N}\sum_{i=1}^{N} \left[\, y_i \log Y_i + (1 - y_i) \log(1 - Y_i)\,\right]

Now we walk backwards.


4 · The four cheat-sheet formulas — the whole backprop world in a table

For every graph in this series (and every graph you'll ever meet), gradients flow through linear layers, elementwise activations, and loss endpoints. Three shapes of node, three kinds of formula:

Node typeForwardBackward (given upstream dZ or dA)
Linear Z = X @ W + b(N, i) @ (i, o) + (o,) → (N, o)dX = dZ @ W.T, dW = X.T @ dZ, db = dZ.sum(0)
ReLU A = max(0, Z)elementwisedZ = dA * (Z > 0)
Sigmoid A = σ(Z)elementwisedZ = dA * A * (1 - A)
Tanh A = tanh(Z)elementwisedZ = dA * (1 - A**2)
Sigmoid + BCE fusedcombineddZ_out = (Y - y_true) / N (§3.1 below)
Softmax + CE fusedcombineddZ_out = (Y - y_one_hot) / N (S005 §7)

Memorise this table. It is roughly 80% of the practical backprop knowledge you will use for the next ten years of ML work. The remaining 20% is: BatchNorm/LayerNorm (S021), Conv (S025), Attention (S037), all of which are specialised versions of the same three moves.

Blame, flowing backwards through a factory
🌍 Real world
💻 Code world
Shape rule that saves you

    5 · Backward pass — the full derivation, walked

    Start at the very end and walk left.

    5.1 dY_pre — the clean sigmoid + BCE gradient

    From S007 §6 (the cancellation): the pre-activation gradient for sigmoid + BCE is (Y - y_true), and the mean reduction adds the 1/N:

    dY_pre = (Y - y_true) / N       shape (N, 1)

    If your loss used sum instead of mean, drop the 1/N. If it used sum and then divided by a fixed constant K outside, divide by K. The rule is: whatever scalar you multiplied the loss by, multiply the gradient by too — because gradient of a scalar-times-loss is that scalar times the gradient.

    5.2 Layer 2 — the output linear layer Y_pre = H @ W2 + b2

    Apply the cheat-sheet formulas with upstream dZ = dY_pre:

    dW2 = H.T @ dY_pre shape (2, 1)db2 = dY_pre.sum(axis=0) shape (1,)dH = dY_pre @ W2.T shape (N, 2) gradient flowing into hidden layer

    Notice dH — the gradient with respect to layer 1's output — becomes the upstream gradient for the ReLU node next.

    5.3 ReLU node — H = ReLU(H_pre)

    The derivative of max(0, z) is 1 where z > 0, 0 elsewhere:

    dH_pre = dH * (H_pre > 0)       shape (N, 2)

    Elementwise multiplication with a boolean mask (which broadcasts as 0/1). Where H_pre was negative, the gradient is killed. That is exactly the "dying ReLU" pathology from S008 §7, seen from the backward-pass side.

    5.4 Layer 1 — H_pre = X @ W1 + b1

    Same cheat-sheet formulas with upstream dZ = dH_pre:

    dW1 = X.T @ dH_pre shape (2, 2)db1 = dH_pre.sum(axis=0) shape (2,)dX = dH_pre @ W1.T shape (N, 2) usually discarded (input, not param)

    We usually don't care about dX unless we're doing adversarial examples, saliency maps, or backprop-into-inputs (which shows up in style transfer, DeepDream, and PGD attacks).

    Done. We have dW1, db1, dW2, db2 — all 9 gradients. Every one is a matmul, a sum, or an elementwise mask. All of backprop for this network is: 4 matmuls + 2 sums + 1 mask. That's it. That's the algorithm that trains ChatGPT (at a very much larger scale).


    6 · By-hand computation on one example

    Reuse the numbers from S009 §4:

    x   = [1.0, 2.0]                  # single example, N = 1
    W1  = [[0.1, 0.2], [0.3, 0.4]]
    b1  = [0.1, 0.1]
    W2  = [[0.5], [0.6]]
    b2  = [0.2]

    Forward gave us:

    H_pre = [0.8, 1.1]     H = [0.8, 1.1]     Y_pre = [1.26]     Y ≈ [0.7789]

    Take y_true = 1.0 — the network was "somewhat right" (predicted 0.78 for a positive example), so the gradients should be moderate.

    6.1 dY_pre

    dY_pre = (Y - y_true) / N = (0.7789 - 1.0) / 1 = -0.2211        shape (1,)

    Negative because Y < y_true: we underpredicted, so increasing Y_pre (and thus Y) would reduce loss.

    6.2 dW2, db2, dH

    dW2 = H.T @ dY_pre
        = [[0.8], [1.1]] · (-0.2211)
        = [[-0.1769], [-0.2432]]                                     shape (2, 1)
     
    db2 = dY_pre.sum() = -0.2211                                     shape (1,)
     
    dH  = dY_pre @ W2.T = -0.2211 · [0.5, 0.6] = [-0.1106, -0.1327]  shape (1, 2)

    6.3 dH_pre (ReLU mask)

    Both entries of H_pre are positive, so the mask is [1, 1]:

    dH_pre = dH * (H_pre > 0) = [-0.1106, -0.1327] * [1, 1] = [-0.1106, -0.1327]

    6.4 dW1, db1

    dW1 = x.T @ dH_pre
        = [[1.0], [2.0]] @ [-0.1106, -0.1327]
        = [[1.0 · -0.1106,  1.0 · -0.1327],
           [2.0 · -0.1106,  2.0 · -0.1327]]
        = [[-0.1106, -0.1327],
           [-0.2211, -0.2653]]                                       shape (2, 2)
     
    db1 = dH_pre.sum(axis=0) = [-0.1106, -0.1327]                    shape (2,)

    All 9 gradients computed by hand. Type them into NumPy in the next section and confirm agreement to at least 4 dp.

    6.5 What do the gradients mean?

    dW2[0,0] = -0.177 means: increasing W2[0,0] by a tiny dw would decrease loss by ~0.177 · dw. So to reduce loss, SGD will increase that weight by lr · 0.177. That is what gradient descent will do:

    W2[0,0]_new = W2[0,0] - lr · dW2[0,0] = 0.5 - 0.1 · (-0.177) = 0.5177

    Interpret every gradient this way. Sign matters (increase or decrease?). Magnitude matters (does this parameter matter right now?). If a whole row of dW1 is zero, that hidden unit is effectively dead and won't update — see the "dying ReLU" war story below.


    7 · The backward pass in code

    def mlp_backward(y_true, cache, params):
        X, H_pre, H, Y_pre, Y = cache
        W1, b1, W2, b2 = params
        N = X.shape[0]
     
        # sigmoid + BCE fused: pre-activation gradient
        dY_pre = (Y - y_true.reshape(Y.shape)) / N       # (N, 1)
     
        # layer 2 (linear)
        dW2 = H.T @ dY_pre                                # (2, 1)
        db2 = dY_pre.sum(axis=0)                          # (1,)
        dH  = dY_pre @ W2.T                               # (N, 2)
     
        # ReLU
        dH_pre = dH * (H_pre > 0)                         # (N, 2)
     
        # layer 1 (linear)
        dW1 = X.T @ dH_pre                                # (2, 2)
        db1 = dH_pre.sum(axis=0)                          # (2,)
     
        # shape asserts — catch bugs at their source
        assert dW1.shape == W1.shape
        assert db1.shape == b1.shape
        assert dW2.shape == W2.shape
        assert db2.shape == b2.shape
     
        return dW1, db1, dW2, db2

    Fifteen lines of algorithm + four asserts. That's your first hand-written backprop. Run it on our example and verify it matches §6:

    import numpy as np
    X  = np.array([[1.0, 2.0]])
    y  = np.array([1.0])
    params = (
        np.array([[0.1, 0.2], [0.3, 0.4]]),
        np.array([0.1, 0.1]),
        np.array([[0.5], [0.6]]),
        np.array([0.2]),
    )
    Y, cache = mlp_forward(X, params)
    dW1, db1, dW2, db2 = mlp_backward(y, cache, params)
    print("dW1:\n", dW1)
    # [[-0.1106 -0.1327]
    #  [-0.2211 -0.2653]]     ✅ matches paper
    print("dW2:\n", dW2)
    # [[-0.1769]
    #  [-0.2432]]              ✅

    Matches. ✅


    8 · Numeric gradient check — the single most useful debug tool in ML

    Symbolic gradients are wrong more often than you'd think. Always sanity-check with two-sided finite differences (S007 §6.4):

    def num_grad(params, X, y, key, index, h=1e-6):
        """Perturb params[key][index] by ±h, recompute loss, return numeric derivative."""
        p_plus  = tuple(p.copy() for p in params)
        p_minus = tuple(p.copy() for p in params)
        p_plus[key][index]  += h
        p_minus[key][index] -= h
        L_plus  = bce_loss(mlp_forward(X, p_plus)[0], y)
        L_minus = bce_loss(mlp_forward(X, p_minus)[0], y)
        return (L_plus - L_minus) / (2*h)
     
    # Check every entry of dW1
    Y, cache = mlp_forward(X, params)
    dW1_anal, db1_anal, dW2_anal, db2_anal = mlp_backward(y, cache, params)
    print("dW1 check:")
    for i in range(2):
        for j in range(2):
            num  = num_grad(params, X, y, key=0, index=(i, j))
            anal = dW1_anal[i, j]
            print(f"  dW1[{i},{j}]: analytic={anal: .6f}  numeric={num: .6f}  diff={abs(num-anal):.2e}")

    Expected — differences all around ~1e-8:

    dW1 check:
      dW1[0,0]: analytic=-0.110616  numeric=-0.110616  diff=3.11e-09
      dW1[0,1]: analytic=-0.132717  numeric=-0.132717  diff=1.24e-09
      dW1[1,0]: analytic=-0.221232  numeric=-0.221232  diff=2.79e-09
      dW1[1,1]: analytic=-0.265434  numeric=-0.265434  diff=8.90e-10

    If any difference is bigger than ~1e-5, you have a bug. Do not proceed. In production, use relative error|a-n| / max(|a|, |n|, ε) — which handles gradients of very different magnitudes uniformly. See Karpathy's CS231n gradient-check notes for the definitive treatment.

    8.1 Common numeric-check pitfalls

    • h too small → floating-point noise swamps the signal; use 1e-5 to 1e-7.
    • h too large → the finite-difference truncation error swamps the answer; don't use 1e-2.
    • Using single precision → checks fail even when analytic is right; run gradcheck in float64.
    • Random state changing between forward passes (dropout!) → different subgraphs, different gradients. Fix seed or disable stochastic layers during gradcheck.
    • Checking on a saturated point → the local slope is legitimately zero; try a different input.

    9 · Full training loop — solve XOR

    Combine forward + backward + SGD:

    def bce_loss(Y, y, eps=1e-9):
        Y = Y.reshape(-1); y = y.reshape(-1)
        return -np.mean(y*np.log(Y+eps) + (1-y)*np.log(1-Y+eps))
     
    def train_mlp(X, y, hidden=8, lr=0.5, steps=5000, seed=0):
        rng = np.random.default_rng(seed)
        N, d_in = X.shape
        # He init (S022) — sqrt(2/fan_in) for ReLU nets
        W1 = rng.standard_normal((d_in, hidden)) * np.sqrt(2/d_in)
        b1 = np.zeros(hidden)
        W2 = rng.standard_normal((hidden, 1)) * np.sqrt(2/hidden)
        b2 = np.zeros(1)
        for t in range(steps):
            Y, cache = mlp_forward(X, (W1, b1, W2, b2))
            L = bce_loss(Y, y)
            dW1, db1, dW2, db2 = mlp_backward(y, cache, (W1, b1, W2, b2))
            W1 -= lr * dW1;  b1 -= lr * db1
            W2 -= lr * dW2;  b2 -= lr * db2
            if t % 500 == 0:
                acc = ((Y.flatten() > 0.5) == (y > 0.5)).mean()
                print(f"step {t:4d}  loss={L:.4f}  acc={acc:.2%}")
        return W1, b1, W2, b2

    Now try it on the XOR problem a single neuron could not solve (S007 §8):

    X = np.array([[0,0], [0,1], [1,0], [1,1]], dtype=float)
    y = np.array([0, 1, 1, 0], dtype=float)
    train_mlp(X, y, hidden=8, lr=0.5, steps=5000)

    Expected:

    step    0  loss=0.6931  acc=50.00%
    step  500  loss=0.4321  acc=75.00%
    step 1000  loss=0.0891  acc=100.00%
    step 1500  loss=0.0341  acc=100.00%
    ...
    step 4500  loss=0.0104  acc=100.00%

    Hits 100% accuracy on XOR. You just solved the problem Minsky and Papert wrote a book about, using code you wrote from scratch. Take a moment. Every line was yours; every gradient you can defend on a whiteboard. This is the whole magic — nothing more, nothing less.


    10 · Diagram — forward and backward together (screenshot this)

    FORWARD (left right) BACKWARD (right left) X X @ W1 + b1 H_pre ReLU H H @ W2 + b2 Y_pre σ Y L y_true dX dH_pre @ W1.T dH_pre * (H_pre>0) dH dY_pre @ W2.T dY_pre (Y - y)/N (compute (compute dW1 = X.T @ dH_pre) dW2 = H.T @ dY_pre) db1 = dH_pre.sum(0) db2 = dY_pre.sum(0)

    Print this, tape it above your monitor. You now understand every training loop ever written. Every deep network's backward pass is this graph, generalised to more layers and more edge types. The exact same diagram, drawn for a 96-layer GPT, is what makes loss.backward() cost 2× a forward pass — one node per layer, one edge per matmul, backwards.


    11 · The line from your NumPy code to PyTorch's autograd

    PyTorch's loss.backward() does what you just did — with three refinements:

    1. The graph is built dynamically during forward. Every operation on a requires_grad=True tensor records its inputs and its local backward function into a graph node. Your cache tuple is PyTorch's ctx.save_for_backward(...).
    2. The traversal is a reverse-topological sort. PyTorch walks the graph from the loss node backwards, calling each node's backward function in the right order. Your left-to-right derivation was the manual version of this sort.
    3. Local backward functions are looked up in C++. Every op (matmul, relu, sigmoid, ...) has a hand-written backward in the ATen library. Your §4 cheat-sheet table is basically the (Python) header of ATen's derivatives.yaml.

    When you write loss.backward(), PyTorch:

    1. Sets loss.grad = 1.0.
    2. Reverse-topo-sorts the graph.
    3. For each node, calls its local backward with the upstream gradient, computes gradients for its inputs, and either accumulates into parameter .grad attributes or propagates further.
    4. Frees the graph (unless retain_graph=True).

    You just did all four steps manually. You have written a two-node autograd engine. S012 (micrograd) will generalise this to an arbitrary scalar-valued DAG.


    12 · The 2025 twist — why hand backprop still matters in the autograd era

    Three places in the current stack where knowing backprop by hand is not optional:

    • Custom kernels — every FlashAttention v1/v2/v3 (Dao 2022, 2023, 2024) release includes both a forward and a hand-derived backward. If you write a Triton or CUDA kernel for a new attention variant, the forward is fun; the backward is where the paper gets written. See the FlashAttention-2 paper (Dao, 2023, arxiv 2307.08691), §3.
    • Gradient checkpointing (Chen et al. 2016) trades compute for memory by not caching intermediates during forward and recomputing them during backward. To decide what to recompute, you have to know the shape and cost of each backward step. Every 100B+ LLM uses this.
    • Debugging exploding / vanishing / NaN gradients in production LLM training. When your 70B model's loss spikes at step 47,000, pytorch_lightning will not save you. You need to know that dW = X.T @ dZ scales with the input norm, that the dying-ReLU mask can kill entire columns, and that RoPE positional encoding's backward has non-obvious phase behaviour. Every one of these debug sessions cashes out into a cheat-sheet formula you learned today.

    Further reading:


    13 · War stories

    War story I dropped the /N and my loss decreased 10× too fast

    Computed dY_pre = Y - y instead of (Y - y) / N. Effectively used a learning rate N× too high (batch size 32 → 32× too high). Loss decreased fast at first, then blew up. Fix: match your loss reduction (mean vs sum) with your gradient formula. If loss is mean, gradients divide by N. If sum, they don't. This is such a common bug that PyTorch's F.cross_entropy defaults to reduction='mean' for exactly this reason.

    War story ReLU mask on the wrong tensor

    Used dH * (H > 0) instead of dH * (H_pre > 0). Since H = ReLU(H_pre) ≥ 0, (H > 0) differs from (H_pre > 0) only at exactly the boundary (H_pre = 0). In practice, this worked 99.99% of the time and I never noticed. But there's a subtle edge case: a hidden unit that's zero because its pre-activation is exactly zero has (H > 0) = False but (H_pre > 0) = False too — actually the same. The real difference emerges only if you use H from a different forward pass (e.g. re-running with slight input noise). Convention: use H_pre > 0. Match what most tutorials and PyTorch's internal C++ do.

    War story I forgot the transpose (again, and again)

    Wrote dW2 = H @ dY_pre (shapes (N,2) @ (N,1)) — errored out. Fix: dW2 = H.T @ dY_pre (shapes (2,N) @ (N,1) → (2,1), matches W2 shape). Shape asserts save you 100 of these errors: assert dW2.shape == W2.shape after every gradient. I put four of these into the mlp_backward function above precisely because I have made this exact mistake more times than I have written this sentence.

    War story Numeric gradcheck passed in float64, failed in float32, model didn't train

    Passed my check_grad in double precision (diff ~1e-9). Ran the training loop in single precision. Model didn't train — loss oscillated forever. Turned out my analytic gradient had a very small bug (a factor of 1 + ε for some tiny ε due to a numerical stability trick I'd added) that was invisible in float64 gradcheck but catastrophic under the 6-decimal-digit resolution of float32 SGD. Gradcheck in the same precision you'll train in. In 2025 that usually means bf16, which has worse resolution than fp32 — so gradcheck's tolerance has to be commensurate.

    War story One dead ReLU killed 5% of my training capacity

    On a 64-hidden-unit MLP for a customer-churn model, after ~200 steps of a slightly too-high learning rate, 5 of the 64 hidden units had H_pre < 0 for every input in the entire training set. Their columns in dW1 were exactly zero forever. Fix: reduce lr, switch to LeakyReLU (S008 §7), and monitor (H > 0).mean(axis=0) per epoch — if it drops below 0.5 for any unit, that unit is probably dead. This is one of the diagnostics I add to every MLP training loop I write.


    14 · Try it yourself

    Try it

    Extend num_grad to check W1, b1, W2, b2 — all 9 parameters — and print the max absolute error across all of them. Should be under 1e-7. If it isn't, find the bug before Session 011.

    Try it

    Use sklearn.datasets.make_moons(n_samples=500, noise=0.2) — two interleaved half-moons that are famously non-linearly-separable. Train the MLP from §9 with hidden=16, lr=0.3, steps=5000. Visualise the decision boundary — it should curve to fit the moons. Compare to a single sigmoid neuron from S007: single-neuron accuracy ~85%, MLP ~99%.

    Try it
    1. Set lr = 10 on XOR. Watch what happens to the loss.
    2. Delete the ReLU (make hidden activation identity). Watch XOR fail at 50%.
    3. Initialise W1 = 0. Watch every hidden unit compute the same thing and the network collapse to a single-neuron equivalent.
    4. For each, explain the pathology in one sentence and cite which cheat-sheet formula tells you why.
    Try it
    import torch, torch.nn as nn, torch.nn.functional as F
    class TinyMLP(nn.Module):
        def __init__(self, d_in=2, hidden=8):
            super().__init__()
            self.fc1 = nn.Linear(d_in, hidden)
            self.fc2 = nn.Linear(hidden, 1)
        def forward(self, x):
            return self.fc2(F.relu(self.fc1(x)))
    model = TinyMLP()
    opt = torch.optim.SGD(model.parameters(), lr=0.5)
    X_t = torch.tensor(X, dtype=torch.float32); y_t = torch.tensor(y, dtype=torch.float32).view(-1,1)
    for t in range(5000):
        logits = model(X_t)
        loss = F.binary_cross_entropy_with_logits(logits, y_t)
        opt.zero_grad(); loss.backward(); opt.step()

    Achieves the same 100% accuracy in similar step counts. Compare model.fc1.weight.grad to your dW1.T (PyTorch's Linear stores weights transposed — (out, in) instead of our (in, out)). Numbers should match at machine precision when seeded identically.


    15 · Recap

    You did backpropagation on paper for a 2-2-1 MLP: 9 gradients, one input example, four cheat-sheet formulas. You coded the backward pass in 15 lines of NumPy and verified every one of the 9 gradients against a two-sided finite-difference check to ~1e-8. You trained the same network on XOR to 100% accuracy in <3000 SGD steps — solving the problem that ended the first AI winter, in code you wrote from scratch. Along the way you saw the direct line from your NumPy backward to PyTorch's autograd, and heard about the modern echoes: FlashAttention custom backwards, gradient checkpointing, dead-ReLU debugging on 70B-parameter LLMs. Everything else in this series is scale.

    Common misconception
    ✗ What most people think

    "Backprop computes the gradient of the loss with respect to each weight. So W.grad is dL/dW — the thing I derived on paper — and if my finite-difference check disagrees slightly, that's just floating-point noise."

    ✓ What is actually true

    W.grad is an accumulator, not a value. Backward adds into it rather than assigning. If you call backward twice without zeroing, you get the sum of two gradients, and every step afterwards moves in a direction that is a running total of every batch you ever saw. This is not noise — it is a silently wrong direction that still decreases the loss slowly enough to look like a bad learning rate.

    Why the myth is so sticky

    Because for the ordinary loop — zero, forward, backward, step — accumulate and assign are indistinguishable, and that is the only loop you ever write while learning. The distinction only surfaces in exactly the cases where you need it: gradient accumulation over micro-batches, multiple losses summed into one parameter set, and a parameter used in more than one place in the graph. It is also the mathematically correct default — a value used twice must receive the sum of both paths' gradients, so accumulation is not a convenience, it is the multivariate chain rule. The framework is doing the right thing; the myth is that it is doing a different thing.

    Prove it to yourself

    Same computation, three backward calls, three different "gradients":

    import numpy as np
    # hand-rolled accumulation, exactly what autograd does
    class P:
        def __init__(s, v): s.v, s.grad = v, 0.0
    w = P(3.0)
    for i in range(3):
        # L = w**2  ->  dL/dw = 2w = 6.0 every time
        w.grad += 2 * w.v            # backward ACCUMULATES
        print(i, w.grad)             # 6.0, 12.0, 18.0  <- not 6,6,6
    
    w.grad = 0.0                     # the zero_grad() you forgot
    w.grad += 2 * w.v
    print("correct", w.grad)         # 6.0
    From first principles
    Start with the question

    Why does the gradient for a weight matrix come out as x.T @ delta — an outer-product-shaped thing — while the gradient flowing back to the input is delta @ W.T? The transposes look like shape-juggling until you derive them.

    1. 1
      For one sample, z = x @ W with x of shape (1, n_in) and W of shape (n_in, n_out), so z[j] = Σ_i x[i] W[i,j].
      forced by · that is the definition of the matmul, written out coordinate-wise
    2. 2
      Differentiate one entry: ∂z[j]/∂W[i,j] = x[i], and ∂z[j]/∂W[i,k] = 0 for k ≠ j. Each weight touches exactly one output.
      forced by · W[i,j] appears in the sum for z[j] and in no other output's sum
    3. 3
      Let delta[j] = dL/dz[j]. Then by the chain rule dL/dW[i,j] = delta[j] · x[i] — every (i,j) pair, which is precisely the outer product x ⊗ delta, i.e. x.T @ delta with shapes (n_in,1) @ (1,n_out).
      forced by · only one term survives the chain-rule sum, since each weight feeds one output
    4. 4
      Now the input: x[i] appears in every output's sum, so its gradient must sum over all of them: dL/dx[i] = Σ_j delta[j] W[i,j], which is delta @ W.T.
      forced by · fan-out means summing contributions along every path the value took — the multivariate chain rule
    5. 5
      Batching just stacks samples on axis 0. x becomes (B, n_in), delta becomes (B, n_out), and x.T @ delta is (n_in, B) @ (B, n_out) — the batch axis is contracted away, meaning it is summed over samples.
      forced by · the loss is a sum (or mean) over the batch, so parameter gradients must sum over the batch while activation gradients must not
    ⇒ Therefore

    Therefore the two transposes encode two genuinely different operations: weight gradients contract over the batch axis, activation gradients contract over the feature axis. The shapes are not a convention you memorise, they are the only shapes the chain rule permits.

    And note the prediction: because dW sums over the batch, the raw gradient magnitude should scale with batch size — which is exactly why losses are meaned rather than summed, and why switching from sum to mean in a loss silently changes your effective learning rate by a factor of B. It also predicts the bias gradient must be delta.sum(axis=0): the bias is added to every output identically, so its gradient contracts the batch axis and nothing else. Verify both by checking shapes and by comparing dW at batch size 1 versus 32 with a summed loss.

    Mental modelTwo sweeps, one cache

    Backprop is a forward walk that writes a cache and a backward walk that reads it. Going forward, every layer stores whatever its own derivative will need — a Linear layer stores its input, a ReLU stores the sign mask, softmax+CE stores the probabilities. Going backward, each layer receives one thing, delta, the gradient of the loss with respect to its own output.

    Each layer then does exactly two jobs with that delta: compute its own parameter gradients from delta plus the cache, and produce the delta for the layer below. Nothing else. Every layer is that same two-line contract, which is why the whole thing composes.

    • Backward per layer: dW = cached_input.T @ delta · db = delta.sum(axis=0) · delta_below = delta @ W.T. Three lines, always.
    • Elementwise ops multiply: ReLU backward is delta * (z > 0). They never change shape.
    • Fan-out adds. If a value is used twice, its gradient is the sum of the two incoming deltas. Missing this is the classic hand-backprop bug, and residual connections are the most common place it hides.
    • Every gradient has the shape of the thing it differentiates: dW.shape == W.shape, delta.shape == z.shape. Assert it — it catches most errors instantly.
    🔔 Fires when you see

    Fire this model the moment you see: a custom autograd.Function · a gradient shape mismatch · a model that trains but converges suspiciously slowly · a residual or skip connection · loss reduction changed from mean to sum · retain_graph=True · memory that spikes on backward rather than forward.

    The tradeoff

    Backward needs the forward activations. Keep every one of them in memory, or recompute some during the backward pass?

    Store all activations
    + you gain backward is a single sweep with no redundant compute — the fastest possible wall-clock per step, and the simplest code path
    − you pay peak memory grows with depth times batch times activation size, and for deep models on long sequences this is the dominant memory consumer, not the parameters; it is the reason your OOM happens on .backward()
    pick when the model fits comfortably with room for your target batch size — measure with torch.cuda.max_memory_allocated() rather than guessing
    Gradient checkpointing
    + you gain store activations only at segment boundaries and recompute the interior during backward; memory drops toward the square root of depth rather than linear in it, which typically unlocks a substantially larger batch or longer context
    − you pay roughly one extra forward pass of compute, so a meaningful slowdown per step; and it interacts badly with anything nondeterministic inside the checkpointed region (dropout, some fused kernels) unless RNG state is handled
    pick when you are memory-bound rather than compute-bound, and the larger batch it unlocks is worth more than the recompute costs — verify by checking whether GPU utilisation is low while memory is near the ceiling
    Reduce the batch, accumulate gradients
    + you gain cuts activation memory proportionally with no recompute overhead at all, and because gradients accumulate by default it is a handful of lines; preserves the effective batch size exactly
    − you pay more optimiser steps' worth of launch overhead per effective batch, and small micro-batches under-utilise the accelerator; batch-norm statistics are computed per micro-batch and so genuinely change behaviour
    pick when the model uses no batch norm and the micro-batch is still large enough to keep the GPU busy — the first thing to try, because it is nearly free to implement
    What a senior engineer actually does

    Reach for gradient accumulation first: it is the smallest change and it does not cost compute. Move to checkpointing when accumulation cannot shrink the micro-batch any further without wrecking utilisation — typically once you are at a micro-batch of one and still out of memory, which is the long-context regime.

    Whichever you choose, remember the accumulation semantics that this session's misconception is about. Gradient accumulation only works because backward adds rather than assigns, and the loss must be scaled by 1/num_micro_batches or your effective learning rate silently multiplies by the accumulation count. That scaling is the single most commonly omitted line in the pattern.



    🧠 Retention scaffold

    Quick recall · click to reveal
    ★ = stretch question

    One-line summary (write it in your own words): _______________________________________________________________

    Spaced review: re-derive the §6 hand-trace in 24 hours; revisit §10 (forward-and-backward diagram) on day 7.

    Next session (S011): put forward + backward + SGD together and train a 784-256-10 MLP on real MNIST — 60,000 hand-written digits — in NumPy. Target: 96%+ test accuracy in under 60 lines of code. Everything from M01 and M02 pays off.

    Sticky note (keep on your desk):

    • Cheat-sheet table (linear: dX = dZ@W.T, dW = X.T@dZ, db = dZ.sum(0); ReLU: dZ = dA*(Z>0); sigmoid+BCE: dY_pre = (Y-y)/N). Draw it on an index card.
    • Shape rule: dTensor.shape == Tensor.shape. Always. Assert it.
    • Debug tool: two-sided numeric gradcheck. h = 1e-6. Every from-scratch backprop starts with this.

    Previous: ← DL S009 · The Forward Pass for a 2-Layer MLP · Next: DL S011 · MLP on MNIST — All Numpy, No PyTorch →