First principles · paper + Python

Everything a neural net does is a derivative away.

Nine short chapters covering the math that powers every architecture — LeNet, AlexNet, transformers, all of them. Each concept comes with a worked example small enough to compute on paper, a from-scratch implementation, the PyTorch one-liner it corresponds to, and exercises with hidden solutions.

Graph paper = worked example Red = exercise (do it on paper) Green = solution (peek after)
Chapter 00

How to use this workbook

The learning loop, and the one rule that makes it work.

The chapters are ordered so that each one uses only what came before it. The dependency chain is:

derivatives → partials/gradients → chain rule → linear layers → losses → gradient descent → backprop → activations → training loop

Backpropagation (Ch. 07) is the payoff: it is nothing but chapters 1–6 glued together. If backprop ever feels like magic, the fix is always to revisit the chain rule.

The rule

Do every exercise on paper before opening the solution. Reading math creates familiarity; computing it creates understanding. The exercises are deliberately small enough that no calculator is needed.

For each concept you'll see

  • What / Why / Where — definition, purpose, and where it appears in a real network
  • A worked example on graph paper — every number shown
  • Code twice — from scratch (plain Python/tensors) first, then the PyTorch API that hides it
  • Exercises — with solutions collapsed until you want them
Chapter 01

Derivatives

Goal: given a function, compute how fast its output changes when the input nudges.

What
The derivative \(f'(x)\) is the slope of \(f\) at the point \(x\): how much the output changes per tiny change of input.
Why
Training asks one question millions of times: “if I nudge this weight, does the loss go up or down, and how steeply?” That question is a derivative.
Where
Inside loss.backward() — it computes the derivative of the loss with respect to every parameter.

Definition, in numbers first

Slope = rise over run, measured over a shrinking run \(h\):

$$ f'(x) = \lim_{h \to 0} \frac{f(x+h) - f(x)}{h} $$

Worked example

Take \(f(x) = x^2\) at \(x = 3\). Use a small run, \(h = 0.001\):

$$ \frac{f(3.001) - f(3)}{0.001} = \frac{9.006001 - 9}{0.001} = 6.001 $$

The rules below say \(f'(x) = 2x\), so \(f'(3) = 6\). The numeric estimate \(6.001\) agrees — the leftover \(0.001\) shrinks as \(h\) shrinks. This numeric check works for any derivative and is how you should verify every exercise in this book.

The five rules you actually need

Deep learning uses a tiny subset of calculus. These cover ~95% of everything:

RuleFunctionDerivative
Power\(x^n\)\(n\,x^{n-1}\)
Constant multiple\(c \cdot f(x)\)\(c \cdot f'(x)\)
Sum\(f(x) + g(x)\)\(f'(x) + g'(x)\)
Exponential\(e^{x}\)\(e^{x}\)
Log\(\ln x\)\(1/x\)

(The sixth, the chain rule, is so important it gets its own chapter.)

from scratch
def numeric_derivative(f, x, h=1e-5):
    """Estimate f'(x) with a tiny rise-over-run. Your universal answer-checker."""
    return (f(x + h) - f(x)) / h

f = lambda x: x**2
print(numeric_derivative(f, 3.0))   # ~6.00001
pytorch
import torch

x = torch.tensor(3.0, requires_grad=True)  # track operations on x
y = x**2
y.backward()      # compute dy/dx and store it in x.grad
print(x.grad)     # tensor(6.)
Exercise 1.1

Compute \(f'(2)\) for \(f(x) = 3x^2 + 2x\) using the rules table. Then verify with numeric_derivative.

Solution

Sum rule splits it; power rule handles each part: \(f'(x) = 3 \cdot 2x + 2 = 6x + 2\).

At \(x = 2\): \(f'(2) = 12 + 2 = \mathbf{14}\).

Check: \(\dfrac{f(2.001) - f(2)}{0.001} = \dfrac{16.014003 - 16}{0.001} \approx 14.003\) ✓

Exercise 1.2

Compute \(f'(2)\) for \(f(x) = \dfrac{1}{x}\). Hint: rewrite as a power, \(x^{-1}\).

Solution

Power rule with \(n = -1\): \(f'(x) = -1 \cdot x^{-2} = -\dfrac{1}{x^2}\).

At \(x = 2\): \(f'(2) = -\dfrac{1}{4} = \mathbf{-0.25}\). Negative slope: increasing \(x\) decreases \(1/x\), which matches intuition.

Chapter 02

Partial derivatives & gradients

Goal: handle functions of many inputs — because a loss depends on many weights.

What
A partial derivative \(\frac{\partial f}{\partial w_1}\) is an ordinary derivative taken while pretending every other variable is a constant. The gradient \(\nabla f\) is the vector collecting all the partials.
Why
A loss \(L(w_1, w_2, \dots, w_{61706})\) depends on every weight in LeNet. The gradient answers “which direction in weight space increases \(L\) fastest?” — so we step the opposite way.
Where
param.grad after loss.backward() — one partial derivative per entry of every weight tensor.
Worked example

Let \(f(w_1, w_2) = w_1^2 + 3 w_1 w_2\). Two partials:

$$ \frac{\partial f}{\partial w_1} = 2w_1 + 3w_2 \qquad\text{(treat } w_2 \text{ as a constant)} $$

$$ \frac{\partial f}{\partial w_2} = 3w_1 \qquad\text{(treat } w_1 \text{ as a constant; } w_1^2 \text{ vanishes)} $$

At the point \((w_1, w_2) = (2, 1)\):

$$ \nabla f(2,1) = \begin{bmatrix} 2(2) + 3(1) \\ 3(2) \end{bmatrix} = \begin{bmatrix} 7 \\ 6 \end{bmatrix} $$

Reading: near this point, nudging \(w_1\) up by a tiny \(\epsilon\) raises \(f\) by about \(7\epsilon\); nudging \(w_2\) raises it by about \(6\epsilon\). So \(w_1\) currently matters slightly more.

from scratch
def numeric_partial(f, point, i, h=1e-5):
    """Partial derivative of f w.r.t. variable i: nudge only that one."""
    nudged = list(point)
    nudged[i] += h
    return (f(*nudged) - f(*point)) / h

f = lambda w1, w2: w1**2 + 3*w1*w2
print(numeric_partial(f, (2.0, 1.0), 0))  # ~7.0  (d f / d w1)
print(numeric_partial(f, (2.0, 1.0), 1))  # ~6.0  (d f / d w2)
pytorch
w = torch.tensor([2.0, 1.0], requires_grad=True)
f = w[0]**2 + 3*w[0]*w[1]
f.backward()
print(w.grad)   # tensor([7., 6.])  — the gradient vector, all partials at once
Key mental model: the gradient lives in weight space. It does not describe the shape of the model's predictions — it describes the slope of the loss surface under the current weights. (Chapter 06 makes this distinction precise.)
Exercise 2.1

For \(f(w_1, w_2) = w_1^2 w_2 + w_2^2\), compute \(\nabla f\) at \((1, 2)\).

Solution

\(\dfrac{\partial f}{\partial w_1} = 2 w_1 w_2 = 2(1)(2) = \mathbf{4}\)

\(\dfrac{\partial f}{\partial w_2} = w_1^2 + 2 w_2 = 1 + 4 = \mathbf{5}\)

So \(\nabla f(1,2) = [4, 5]\).

Exercise 2.2

A loss is \(L(w, b) = (w \cdot 2 + b - 6)^2\) — a model \(y = wx + b\) with input \(x=2\) and target \(6\). Expand it, then compute \(\nabla L\) at \((w, b) = (1, 1)\). (If you know the chain rule already, that's faster — otherwise expand the square.)

Solution

Let \(e = 2w + b - 6\). Expanding: \(L = 4w^2 + b^2 + 36 + 4wb - 24w - 12b\).

\(\dfrac{\partial L}{\partial w} = 8w + 4b - 24\). At \((1,1)\): \(8 + 4 - 24 = \mathbf{-12}\)

\(\dfrac{\partial L}{\partial b} = 2b + 4w - 12\). At \((1,1)\): \(2 + 4 - 12 = \mathbf{-6}\)

Sanity check: at \((1,1)\) the prediction is \(2 + 1 = 3\), which is below the target 6. Both partials are negative — “increase \(w\) and \(b\) to reduce loss” — exactly right. Note both equal \(2e \cdot x\) and \(2e \cdot 1\) with \(e = -3\): the chain-rule shortcut of the next chapter.

Chapter 03

The chain rule

Goal: differentiate compositions — because a network is a composition of layers.

What
If \(y = f(g(x))\), then \(\dfrac{dy}{dx} = f'(g(x)) \cdot g'(x)\): the derivative of the outer function times the derivative of the inner one.
Why
A network is \( \text{loss}(\text{layer}_3(\text{layer}_2(\text{layer}_1(x)))) \). To learn how a weight buried in layer 1 affects the loss, you multiply the local slopes along the path connecting them. That multiplication is backpropagation.
Where
Every step of loss.backward(). Also explains vanishing gradients: multiply many slopes \(< 1\) and the product dies.
Worked example

\(y = (2x + 1)^2\) at \(x = 1\). Name the inner piece: \(u = 2x + 1\), so \(y = u^2\).

Two local slopes:

$$ \frac{dy}{du} = 2u \qquad \frac{du}{dx} = 2 $$

Chain them (at \(x=1\), \(u = 3\)):

$$ \frac{dy}{dx} = \frac{dy}{du} \cdot \frac{du}{dx} = 2(3) \cdot 2 = \mathbf{12} $$

Numeric check: \(\dfrac{(2 \cdot 1.001 + 1)^2 - 9}{0.001} = \dfrac{9.012004 - 9}{0.001} \approx 12.004\) ✓

Intuition: a nudge to \(x\) gets amplified ×2 passing through \(u\), then ×6 passing through the square. Total amplification: 12. Signals flowing backward through a network are amplified/shrunk the same way, layer by layer.

Longer chains: just keep multiplying

Three functions deep: \(y = f(g(h(x)))\)

$$ \frac{dy}{dx} = \frac{dy}{dg} \cdot \frac{dg}{dh} \cdot \frac{dh}{dx} $$

Each factor is a local derivative that one layer can compute knowing nothing about the rest. That locality is why backprop is mechanical enough for a library to automate.

from scratch vs pytorch
import torch

# By hand: y = (2x+1)^2, dy/dx = 2*(2x+1) * 2
x_val = 1.0
manual = 2 * (2*x_val + 1) * 2          # 12.0

# Autograd does the same multiplication internally
x = torch.tensor(x_val, requires_grad=True)
y = (2*x + 1)**2
y.backward()
print(manual, x.grad)                    # 12.0 tensor(12.)
Exercise 3.1

The single most important derivative in ML: \(L = (wx - t)^2\) — squared error of a linear model. With \(x = 2\), \(t = 6\), \(w = 1\), compute \(\dfrac{dL}{dw}\) using the chain rule (inner: \(e = wx - t\); outer: \(L = e^2\)).

Solution

Local slopes: \(\dfrac{dL}{de} = 2e\) and \(\dfrac{de}{dw} = x\).

At the point: \(e = 1(2) - 6 = -4\).

$$ \frac{dL}{dw} = 2e \cdot x = 2(-4)(2) = \mathbf{-16} $$

Negative gradient → increase \(w\) to reduce the loss. Correct: prediction 2 is far below target 6.

Exercise 3.2

The sigmoid \(\sigma(z) = \dfrac{1}{1+e^{-z}}\) has derivative \(\sigma'(z) = \sigma(z)(1 - \sigma(z))\). For \(y = \sigma(3x)\), compute \(\dfrac{dy}{dx}\) at \(x = 0\).

Solution

Inner: \(u = 3x\), \(\dfrac{du}{dx} = 3\). At \(x=0\): \(u = 0\), \(\sigma(0) = 0.5\).

$$ \frac{dy}{dx} = \sigma'(0) \cdot 3 = (0.5)(0.5) \cdot 3 = \mathbf{0.75} $$

Note \(\sigma'\) can never exceed \(0.25\) — chain several sigmoids and the product shrinks fast. You've just derived vanishing gradients.

Chapter 04

Linear layers

Goal: read \(y = Wx + b\) as arithmetic you can do by hand, and count its parameters.

What
A linear layer computes weighted sums: each output is a dot product of the input with that output's own weight row, plus a bias.
Why
It is the only learnable computation in most networks. Convolutions, attention, fully-connected layers — all are structured variants of “multiply by weights and sum.”
Where
nn.Linear, nn.Conv2d (a linear layer with weight sharing), the X @ W + b in your softmax recipe.

Dot product first

$$ a \cdot b = \sum_i a_i b_i \qquad e.g.\;\; [1, 2, 3] \cdot [4, 0, 2] = 4 + 0 + 6 = 10 $$

One number that measures “how strongly does the input match this weight pattern?” — the edge-detector calculation from convolution is exactly this.

Worked example

A layer with 3 inputs and 2 outputs. Weights \(W\) (one row per output), bias \(b\), input \(x\):

$$ W = \begin{bmatrix} 1 & 0 & 2 \\ -1 & 1 & 0 \end{bmatrix} \quad b = \begin{bmatrix} 1 \\ 0 \end{bmatrix} \quad x = \begin{bmatrix} 2 \\ 3 \\ 1 \end{bmatrix} $$

Each output = (its weight row) · (input) + (its bias):

$$ y_1 = (1)(2) + (0)(3) + (2)(1) + 1 = \mathbf{5} $$

$$ y_2 = (-1)(2) + (1)(3) + (0)(1) + 0 = \mathbf{1} $$

So \(y = Wx + b = [5, 1]\). That's all matrix multiplication is: stacked dot products.

Parameter count: \(W\) has \(2 \times 3 = 6\) weights, \(b\) has \(2\) → \(8\) parameters. In general: \(n_{out} \times n_{in} + n_{out}\).

Shape discipline (the #1 source of real-world bugs): \((n_{out} \times n_{in})\) matrix times \((n_{in})\) vector gives \((n_{out})\) vector. Inner dimensions must match; they get consumed. Batched version: \((B \times n_{in}) @ (n_{in} \times n_{out}) \to (B \times n_{out})\) — note PyTorch stores the batch first, so code uses X @ W with \(W\) shaped \((n_{in} \times n_{out})\), like your recipe does.
from scratch
def linear(W, b, x):
    """y_i = dot(W[i], x) + b[i], written as explicit loops."""
    n_out, n_in = len(W), len(x)
    y = [0.0] * n_out
    for i in range(n_out):                # one output at a time
        for j in range(n_in):             # accumulate the dot product
            y[i] += W[i][j] * x[j]
        y[i] += b[i]
    return y

W = [[1, 0, 2], [-1, 1, 0]]
b = [1, 0]
x = [2, 3, 1]
print(linear(W, b, x))   # [5.0, 1.0]
pytorch
import torch
from torch import nn

layer = nn.Linear(3, 2)                       # allocates W (2x3) and b (2), random init
with torch.no_grad():                          # setting weights manually, skip autograd
    layer.weight[:] = torch.tensor([[1., 0., 2.], [-1., 1., 0.]])
    layer.bias[:]   = torch.tensor([1., 0.])

x = torch.tensor([2., 3., 1.])
print(layer(x))          # tensor([5., 1.])
Exercise 4.1

With \(W = \begin{bmatrix} 2 & -1 \\ 0 & 3 \end{bmatrix}\), \(b = \begin{bmatrix} 1 \\ -2 \end{bmatrix}\), \(x = \begin{bmatrix} 4 \\ 2 \end{bmatrix}\), compute \(y = Wx + b\) by hand.

Solution

\(y_1 = 2(4) + (-1)(2) + 1 = 8 - 2 + 1 = \mathbf{7}\)

\(y_2 = 0(4) + 3(2) - 2 = 6 - 2 = \mathbf{4}\)

\(y = [7, 4]\)

Exercise 4.2

How many parameters in the MLP 784 → 256 → 10 (two linear layers, biases included)? Then: LeNet's conv1 has 6 kernels of shape \((1, 5, 5)\) plus one bias each — how many parameters, and why is it so much cheaper than a dense layer over the same image?

Solution

Layer 1: \(256 \times 784 + 256 = 200{,}960\). Layer 2: \(10 \times 256 + 10 = 2{,}570\). Total \(\mathbf{203{,}530}\).

Conv1: \(6 \times (1 \cdot 5 \cdot 5) + 6 = \mathbf{156}\). It's cheap because the same 25 weights slide across all \(28 \times 28\) positions (weight sharing) instead of every pixel getting its own weight.

Chapter 05

Softmax & loss functions

Goal: turn raw scores into probabilities, then into a single number measuring “how wrong.”

What
Softmax converts a vector of raw scores (logits) into probabilities that sum to 1. Cross-entropy then scores them: the negative log of the probability assigned to the true class. For regression, MSE \((\hat{y} - t)^2\) plays the same role.
Why
Gradient descent needs one scalar to minimize. The loss compresses “how good are all these predictions” into that scalar — and its shape determines what gradients flow back.
Where
Your recipe's softmax() + cross_entropy(); PyTorch's nn.CrossEntropyLoss fuses both (it takes raw logits).
Worked example — softmax

Logits for 3 classes: \(o = [2, 0, 1]\).

$$ \text{softmax}(o_i) = \frac{e^{o_i}}{\sum_j e^{o_j}} $$

Step 1 — exponentiate: \(e^2 \approx 7.39,\;\; e^0 = 1,\;\; e^1 \approx 2.72\)

Step 2 — sum: \(7.39 + 1 + 2.72 = 11.11\)

Step 3 — normalize: $$ \hat{y} = \left[ \tfrac{7.39}{11.11}, \tfrac{1}{11.11}, \tfrac{2.72}{11.11} \right] = [0.665,\; 0.090,\; 0.245] $$

Checks: all positive, sum = 1, and the biggest logit got the biggest probability (order preserved — that's why argmax on logits equals argmax on probabilities).

Worked example — cross-entropy

Suppose the true class is class 0. Cross-entropy only looks at the probability given to the truth:

$$ L = -\ln(\hat{y}_{\text{true}}) = -\ln(0.665) \approx \mathbf{0.408} $$

Why \(-\ln\)? It converts “confidence in the truth” into “pain,” with the right shape:

\(\hat{y}_{\text{true}}\)1.00.90.50.10.01
\(L = -\ln(\cdot)\)00.1050.6932.3034.605

Being confidently wrong (0.01) hurts ~44× more than being mildly unsure (0.9). That asymmetry produces large gradients exactly where the model most needs correcting.

from scratch (your recipe, condensed)
import torch

def softmax(O):
    O_max = O.max(dim=1, keepdim=True).values   # subtract max: avoids exp() overflow,
    exp_O = torch.exp(O - O_max)                # doesn't change the result (why? ex 5.2)
    return exp_O / exp_O.sum(dim=1, keepdim=True)

def cross_entropy(y_hat, y):
    picked = y_hat[range(len(y_hat)), y]        # prob assigned to each true class
    return (-torch.log(picked)).mean()

O = torch.tensor([[2.0, 0.0, 1.0]])
y = torch.tensor([0])
print(softmax(O))                    # [[0.665, 0.090, 0.245]]
print(cross_entropy(softmax(O), y))  # 0.408
pytorch
loss_fn = torch.nn.CrossEntropyLoss()   # = softmax + cross-entropy fused
print(loss_fn(O, y))                     # 0.408 — note: takes RAW logits, not probabilities
Exercise 5.1

Logits \(o = [1, 1]\) for 2 classes, true class = 1. Compute the softmax and the cross-entropy loss by hand. (Useful constant: \(\ln 2 \approx 0.693\).)

Solution

Equal logits → equal probabilities: \(\hat{y} = \left[\tfrac{e}{2e}, \tfrac{e}{2e}\right] = [0.5, 0.5]\).

\(L = -\ln(0.5) = \ln 2 \approx \mathbf{0.693}\).

This is the loss of pure guessing between 2 classes. For 10 classes it's \(\ln 10 \approx 2.30\) — which is why an untrained FashionMNIST model starts near loss 2.3. If your loss starts much higher, your init is broken.

Exercise 5.2

Show with \(o = [2, 0, 1]\) that subtracting the max logit from every logit (i.e. using \([0, -2, -1]\)) gives the same softmax. Why does this matter for \(o = [1000, 999]\)?

Solution

\(e^0 = 1,\; e^{-2} \approx 0.135,\; e^{-1} \approx 0.368\); sum \(\approx 1.503\).

\(\hat{y} = [0.665, 0.090, 0.245]\) — identical, because \(\dfrac{e^{o_i - m}}{\sum_j e^{o_j - m}} = \dfrac{e^{o_i} e^{-m}}{e^{-m} \sum_j e^{o_j}}\): the \(e^{-m}\) cancels.

For \([1000, 999]\): \(e^{1000}\) overflows to inf in float32. After subtracting the max: \(e^0, e^{-1}\) — perfectly safe, same answer. This is exactly the O_max line in your recipe.

Chapter 06

Gradient descent

Goal: use the gradient to actually improve the weights — and see convergence and divergence happen by hand.

What
Repeated updates \(w \leftarrow w - \eta \, \dfrac{\partial L}{\partial w}\): step every weight a little bit against its gradient. \(\eta\) is the learning rate.
Why
The gradient points uphill on the loss surface; stepping downhill reduces the loss. Small enough steps, repeated, find a valley.
Where
Your recipe's sgd(): param -= lr * param.grad. Same update inside torch.optim.SGD; Adam and friends are variations on the step size.
Worked example — watch it converge

Loss \(L(w) = (2w - 6)^2\). Chain rule: \(\dfrac{dL}{dw} = 2(2w-6)\cdot 2 = 8w - 24\). Minimum at \(w = 3\) where the gradient is 0. Start at \(w_0 = 0\), learning rate \(\eta = 0.1\):

step\(w\)grad \(= 8w - 24\)update \(w - 0.1 \cdot \text{grad}\)\(L\)
00−24\(0 + 2.4 = 2.4\)36
12.4−4.8\(2.4 + 0.48 = 2.88\)1.44
22.88−0.96\(2.88 + 0.096 = 2.976\)0.058
32.976−0.192\(2.9952\)0.002

Each step covers 80% of the remaining distance to \(w=3\). Steps shrink automatically because the gradient itself shrinks near the minimum — no schedule needed for this simple bowl.

Worked example — watch it diverge

Same problem, learning rate \(\eta = 0.3\), start \(w_0 = 0\):

step\(w\)gradnext \(w\)
00−24\(0 + 7.2 = 7.2\)
17.233.6\(7.2 - 10.08 = -2.88\)
2−2.88−47.04\(11.23\)

Each step overshoots the minimum and lands farther away, on the opposite wall of the bowl. The loss explodes. When training loss goes to NaN, this is usually what happened.

Two spaces, two graphs — do not mix them. The bowl above lives in weight space: axis = \(w\), height = loss. It's curved even though the model \(y = wx\) is a straight line in input space. Gradient descent explores weight space to pick a function; activations (Ch. 08) determine which functions exist to be picked. Curvy loss landscapes do not give the model curvy predictions.
from scratch
w, lr = 0.0, 0.1
for step in range(6):
    grad = 8*w - 24                 # dL/dw, derived by hand above
    w = w - lr * grad               # THE update. All of deep learning training is this line.
    print(f"step {step}: w={w:.4f}  loss={(2*w - 6)**2:.5f}")
# w -> 3.0, loss -> 0. Try lr = 0.3 to reproduce the divergence table.
Exercise 6.1

Loss \(L(w) = w^2 - 4w\) (minimum at \(w = 2\)). Starting from \(w_0 = 5\) with \(\eta = 0.25\), compute two update steps by hand.

Solution

\(\dfrac{dL}{dw} = 2w - 4\).

Step 1: grad \(= 2(5) - 4 = 6\); \(\;w_1 = 5 - 0.25(6) = \mathbf{3.5}\)

Step 2: grad \(= 2(3.5) - 4 = 3\); \(\;w_2 = 3.5 - 0.25(3) = \mathbf{2.75}\)

Halving the distance to 2 each step — converging.

Exercise 6.2

For \(L(w) = (2w-6)^2\), the update is \(w \leftarrow w - \eta(8w - 24)\), which rearranges to \(w \leftarrow (1 - 8\eta)w + 24\eta\). For what values of \(\eta\) does the distance to the optimum shrink each step? (Hint: look at \(|1 - 8\eta|\).)

Solution

The error from the optimum multiplies by \((1 - 8\eta)\) every step: substituting \(w = 3 + \epsilon\) gives next error \((1-8\eta)\epsilon\). Convergence needs \(|1 - 8\eta| < 1\), i.e. \(\mathbf{0 < \eta < 0.25}\).

Check against the examples: \(\eta = 0.1\) → factor \(0.2\) (fast convergence ✓); \(\eta = 0.3\) → factor \(-1.4\) (oscillating divergence ✓). The steeper the loss (bigger curvature), the smaller the safe learning rate — which is why deeper/steeper networks need careful lr tuning.

Chapter 07

Backpropagation

Goal: the payoff chapter — compute every gradient in a real (tiny) network entirely by hand, then watch PyTorch agree.

What
An algorithm for applying the chain rule to a whole network efficiently: run forward saving intermediate values, then sweep backward multiplying local derivatives, reusing shared sub-products.
Why
Naively computing each weight's derivative separately re-walks the whole network per weight. Backprop computes all of them in one backward pass — the reason training 61k-parameter (or 175B-parameter) models is feasible.
Where
loss.backward(). Everything in this chapter is what that one call does.

The network

The smallest network that has everything — a hidden layer, an activation, a loss:

$$ x \;\xrightarrow{\;\times w_1\;}\; z \;\xrightarrow{\;\text{ReLU}\;}\; h \;\xrightarrow{\;\times w_2\;}\; \hat{y} \;\xrightarrow{\;(\hat{y}-t)^2\;}\; L $$

Numbers: \(x = 2\), target \(t = 3\), weights \(w_1 = 0.5\), \(w_2 = 1.5\). (Biases omitted to keep the paper math short — they backprop the same way, see Ex. 7.2.)

Forward pass — save everything

$$ z = w_1 x = 0.5 \times 2 = 1 $$

$$ h = \text{ReLU}(z) = \max(0, 1) = 1 $$

$$ \hat{y} = w_2 h = 1.5 \times 1 = 1.5 $$

$$ L = (\hat{y} - t)^2 = (1.5 - 3)^2 = \mathbf{2.25} $$

We keep \(z, h, \hat{y}\) around — the backward pass needs them. (This is why training uses more memory than inference.)

Backward pass — chain rule, right to left

1. Loss w.r.t. prediction (outermost function first):

$$ \frac{\partial L}{\partial \hat{y}} = 2(\hat{y} - t) = 2(1.5 - 3) = -3 $$

2. Gradient for \(w_2\) — since \(\hat{y} = w_2 h\), locally \(\frac{\partial \hat{y}}{\partial w_2} = h\):

$$ \frac{\partial L}{\partial w_2} = \frac{\partial L}{\partial \hat{y}} \cdot h = (-3)(1) = \mathbf{-3} $$

3. Keep flowing to \(h\) — locally \(\frac{\partial \hat{y}}{\partial h} = w_2\):

$$ \frac{\partial L}{\partial h} = (-3)(1.5) = -4.5 $$

4. Through the ReLU — \(z = 1 > 0\), so its local slope is 1 (it would be 0 for negative \(z\), killing the signal):

$$ \frac{\partial L}{\partial z} = (-4.5)(1) = -4.5 $$

5. Gradient for \(w_1\) — since \(z = w_1 x\), locally \(\frac{\partial z}{\partial w_1} = x\):

$$ \frac{\partial L}{\partial w_1} = (-4.5)(2) = \mathbf{-9} $$

Both gradients negative → increase both weights → prediction rises toward the target. Notice step 3's value \(-4.5\) was reused in steps 4 and 5: that reuse of shared sub-products is the entire efficiency trick of backprop.

One SGD step, then re-check

With \(\eta = 0.05\):

$$ w_1 \leftarrow 0.5 - 0.05(-9) = 0.95 \qquad w_2 \leftarrow 1.5 - 0.05(-3) = 1.65 $$

New forward: \(z = 1.9\), \(h = 1.9\), \(\hat{y} = 1.9 \times 1.65 = 3.135\), \(L = 0.018\).

Loss fell from \(2.25\) to \(\mathbf{0.018}\) in one step. Training is nothing more than this, repeated.

from scratch — mirrors the paper math line by line
x, t = 2.0, 3.0
w1, w2 = 0.5, 1.5

# ---- forward (save intermediates) ----
z     = w1 * x
h     = max(0.0, z)              # ReLU
y_hat = w2 * h
L     = (y_hat - t)**2

# ---- backward (chain rule, right to left) ----
dL_dyhat = 2 * (y_hat - t)       # step 1:  -3
dL_dw2   = dL_dyhat * h          # step 2:  -3
dL_dh    = dL_dyhat * w2         # step 3:  -4.5
dL_dz    = dL_dh * (1.0 if z > 0 else 0.0)   # step 4: ReLU gate
dL_dw1   = dL_dz * x             # step 5:  -9

print(L, dL_dw1, dL_dw2)         # 2.25 -9.0 -3.0
pytorch — the same five steps, automated
import torch

w1 = torch.tensor(0.5, requires_grad=True)
w2 = torch.tensor(1.5, requires_grad=True)
x, t = torch.tensor(2.0), torch.tensor(3.0)

y_hat = w2 * torch.relu(w1 * x)   # forward builds the computation graph
L = (y_hat - t)**2
L.backward()                       # backward walks it right-to-left, exactly as above

print(w1.grad, w2.grad)            # tensor(-9.) tensor(-3.)  — matches the paper ✓
Scaling up changes nothing conceptual. In LeNet, \(w_1\) becomes a \((6,1,5,5)\) kernel tensor and \(x\) a batch of images, so the scalar products become tensor products — but the algorithm is identical: forward saving intermediates, backward multiplying local derivatives. Autograd exists because doing this by hand for 61,706 parameters is miserable, not because it's different math.
Exercise 7.1

Same network, but now \(w_1 = -0.5\) (everything else unchanged: \(x=2, t=3, w_2=1.5\)). Run the forward and backward passes by hand. Something interesting happens — what, and why is it a real problem?

Solution

Forward: \(z = -1\), \(h = \text{ReLU}(-1) = 0\), \(\hat{y} = 0\), \(L = 9\).

Backward: \(\frac{\partial L}{\partial \hat{y}} = -6\); \(\;\frac{\partial L}{\partial w_2} = (-6)(0) = \mathbf{0}\); \(\;\frac{\partial L}{\partial h} = -9\); ReLU's local slope at \(z = -1\) is 0, so \(\frac{\partial L}{\partial z} = 0\) and \(\frac{\partial L}{\partial w_1} = \mathbf{0}\).

Both gradients are zero while the loss is 9: a dead ReLU. The neuron is off, so no gradient flows, so it can never turn back on (for this input). This is the classic ReLU failure mode — mitigated in practice by good initialization, other inputs activating the neuron, or LeakyReLU.

Exercise 7.2

Add a bias: \(\hat{y} = w_2 h + b\), with \(b = 0.5\) and the original weights (\(w_1 = 0.5, w_2 = 1.5, x = 2, t = 3\)). Compute \(L\) and all three gradients \(\frac{\partial L}{\partial w_1}, \frac{\partial L}{\partial w_2}, \frac{\partial L}{\partial b}\).

Solution

Forward: \(z = 1, h = 1\), \(\hat{y} = 1.5 + 0.5 = 2\), \(L = (2-3)^2 = \mathbf{1}\).

\(\frac{\partial L}{\partial \hat{y}} = 2(2-3) = -2\). Since \(\frac{\partial \hat{y}}{\partial b} = 1\):

\(\frac{\partial L}{\partial b} = (-2)(1) = \mathbf{-2}\) — a bias just passes the incoming gradient through.

\(\frac{\partial L}{\partial w_2} = (-2)(h) = \mathbf{-2}\)

\(\frac{\partial L}{\partial w_1} = (-2)(w_2)(\text{ReLU}'{=}1)(x) = (-2)(1.5)(1)(2) = \mathbf{-6}\)

Chapter 08

Activations & nonlinearity

Goal: prove that layers without activations collapse, and see what the activation buys.

What
An elementwise nonlinear function \(\phi\) applied after each linear step: \(h = \phi(Wx + b)\). ReLU \(= \max(0, x)\), sigmoid \(= 1/(1+e^{-x})\), tanh, GELU…
Why
Without \(\phi\), any stack of linear layers is algebraically equal to one linear layer — depth adds nothing. The activation is what makes depth meaningful.
Where
Between every pair of layers. Last layer usually has none (logits go straight into the loss).
Worked example — the collapse

Two layers, no activation: \(h = 2x + 1\), then \(y = 3h + 2\). Substitute:

$$ y = 3(2x + 1) + 2 = 6x + 5 $$

Two layers = one layer with \(w = 6, b = 5\). In matrix form, \(W_2(W_1 x + b_1) + b_2 = (W_2 W_1)x + (W_2 b_1 + b_2)\) — always mergeable, at any depth. A 100-layer activation-free network has exactly the power of softmax regression.

Worked example — what one ReLU buys

Insert ReLU: \(h = \text{ReLU}(2x + 1)\), \(y = 3h + 2\). Evaluate at three inputs:

\(x\)\(2x+1\)\(h = \text{ReLU}\)\(y = 3h + 2\)
13311
−0.5002
−2−302

For all \(x \le -0.5\) the output is stuck at 2; above, it climbs with slope 6. That's a bend — a shape no single linear layer can make. Each ReLU neuron contributes one bend; a wide network combines thousands of bends into arbitrary curves and decision boundaries.

Concrete consequence for images: a linear-only model can only add up fixed per-pixel votes (“bright pixel (12,7) → +0.3 sneaker, always”). With activations, a neuron can report a feature only when present (output 0 otherwise), so later layers can express conditions like “edge here AND here → sole of a shoe.” Gates and conditions, not just weighted sums.
verify the collapse
import torch
from torch import nn

x = torch.linspace(-3, 3, 7).reshape(-1, 1)     # 7 test inputs

no_act = nn.Sequential(nn.Linear(1, 4), nn.Linear(4, 4), nn.Linear(4, 1))
with_act = nn.Sequential(nn.Linear(1, 4), nn.ReLU(),
                         nn.Linear(4, 4), nn.ReLU(), nn.Linear(4, 1))

# no_act's output is ALWAYS a perfect straight line in x — check the
# differences between consecutive outputs (constant slope => all equal):
y = no_act(x).detach().squeeze()                 # detach: just numbers, no autograd
print(torch.diff(y))                             # all identical values
print(torch.diff(with_act(x).detach().squeeze()))  # varies — the line bends
Exercise 8.1

Collapse this activation-free stack into a single layer \(y = wx + b\): layer 1 is \(h = -x + 4\); layer 2 is \(y = 5h - 3\). Then confirm your \((w, b)\) reproduces the stack's output at \(x = 2\).

Solution

\(y = 5(-x + 4) - 3 = -5x + 17\), so \(\mathbf{w = -5,\; b = 17}\).

Check at \(x=2\): stack gives \(h = 2\), \(y = 7\); formula gives \(-10 + 17 = 7\) ✓

Exercise 8.2

Two neurons: \(h_1 = \text{ReLU}(x_1 - x_2)\), \(h_2 = \text{ReLU}(x_2 - x_1)\), output \(y = h_1 + h_2\). Evaluate on inputs \([9,0]\), \([0,9]\), \([5,5]\). What familiar function of \(x_1, x_2\) does this network compute, and why can no linear model do it?

Solution

\([9,0]\): \(h_1 = 9, h_2 = 0, y = 9\). \([0,9]\): \(h_1 = 0, h_2 = 9, y = 9\). \([5,5]\): \(y = 0\).

It computes \(\mathbf{|x_1 - x_2|}\) — an “edge detector” that fires for a difference in either direction. A linear model \(y = w_1 x_1 + w_2 x_2 + b\) can't: making \([9,0]\) and \([0,9]\) both output 9 forces \(9w_1 + b = 9w_2 + b = 9\), so \(w_1 = w_2\), which then gives \([5,5] \to 10 w_1 + b \neq 0\) whenever the first equations hold. The ReLU's trick is deleting negative evidence instead of letting it cancel positive evidence.

Chapter 09

The full training loop

Goal: assemble chapters 1–8 into the complete algorithm — and map every from-scratch piece to its PyTorch name.

Every training run — LeNet, GPT, anything — is this loop:

  1. Forward (Ch. 04, 08): push a batch through linear layers + activations to get predictions
  2. Loss (Ch. 05): compress predictions vs. targets into one scalar
  3. Backward (Ch. 03, 07): chain rule computes every parameter's gradient
  4. Update (Ch. 06): step each parameter against its gradient
  5. Repeat
from scratch — the loop with nothing hidden
import torch

# Tiny 2-layer MLP on fake data, pure tensors — no nn.Module anywhere.
torch.manual_seed(0)
X = torch.randn(100, 4)                       # 100 samples, 4 features
true_W = torch.tensor([[1.], [-2.], [3.], [0.5]])
y = X @ true_W + 0.1*torch.randn(100, 1)      # targets from a secret linear rule

W1 = (torch.randn(4, 8) * 0.1).requires_grad_(True)   # layer 1 weights
b1 = torch.zeros(8, requires_grad=True)
W2 = (torch.randn(8, 1) * 0.1).requires_grad_(True)   # layer 2 weights
b2 = torch.zeros(1, requires_grad=True)
lr = 0.05

for step in range(200):
    # 1. forward                       (Ch. 04 + 08)
    h = torch.relu(X @ W1 + b1)
    y_hat = h @ W2 + b2
    # 2. loss                          (Ch. 05 — MSE here)
    loss = ((y_hat - y)**2).mean()
    # 3. backward                      (Ch. 03 + 07)
    loss.backward()
    # 4. update                        (Ch. 06)
    with torch.no_grad():              # raw arithmetic, don't record for autograd
        for p in (W1, b1, W2, b2):
            p -= lr * p.grad           # the gradient descent step
            p.grad.zero_()             # clear: .backward() ACCUMULATES by default
    if step % 50 == 0:
        print(f"step {step}: loss {loss.item():.4f}")

The translation table

Every wrapper you'll meet (in d2l, PyTorch, Lightning) is one of these rows:

You wrote (from scratch)PyTorch APIChapter
X @ W + bnn.Linear04
torch.relu(...) / manual maxnn.ReLU08
softmax() + cross_entropy()nn.CrossEntropyLoss05
manual chain ruleloss.backward()03, 07
p -= lr * p.gradoptimizer.step()06
p.grad.zero_()optimizer.zero_grad()06
the whole loopd2l.Trainer.fit()09
Exercise 9.1

In the from-scratch loop, predict what happens if you delete the p.grad.zero_() line — then run it and confirm.

Solution

.backward() adds new gradients onto .grad rather than replacing them. Without zeroing, step \(n\) uses the sum of all gradients from steps \(1..n\) — effectively an ever-growing step size. Loss drops at first, then oscillates or explodes, like the divergence table in Ch. 06.

Exercise 9.2 — capstone

Rewrite the loop using nn.Sequential, nn.MSELoss, and torch.optim.SGD, keeping behavior identical. Then verify both versions reach a similar loss.

Solution
from torch import nn

net = nn.Sequential(nn.Linear(4, 8), nn.ReLU(), nn.Linear(8, 1))
loss_fn = nn.MSELoss()
opt = torch.optim.SGD(net.parameters(), lr=0.05)

for step in range(200):
    loss = loss_fn(net(X), y)     # forward + loss
    opt.zero_grad()               # p.grad.zero_() for every param
    loss.backward()               # chain rule
    opt.step()                    # p -= lr * p.grad for every param

Same four phases, same math — the API just names them. If you can write both versions and explain each line's chapter number, you own the foundations.

Appendix

Resources

Curated for the paper-first style of this workbook.