Monocurl Essays
Series Download

Backpropagation: the chain rule, organized

Run a tiny network forward with real numbers, get every weight's slope in one backward pass, prove the delta recursion, and check it by nudging each weight.

23 min·Early college·

AssumesWhat a neural network computesGradient descent: learning by walking downhillThe product rule and the chain rule, seen

One forward pass and one backward pass through a network with six weights. Values (blue) are computed from the inputs to the loss, then the slope of the loss with respect to each value and weight (orange) from the loss back.
Monocurl source
mesh net = Net
mesh wts = Weights
mesh legend = Legend
mesh v_in = VIn
mesh v_hid = []
mesh v_out = []
mesh v_loss = []
mesh g_out = []
mesh g_c = []
mesh g_h = []
mesh g_w = []
mesh flow = []

slide "passes"
    play Wait(0.5)
    flow = Flow(t: 0, [0, 1, 2, 3], AZURE)
    play Fade(0.15, [&flow])
    flow.t = 1
    play Lerp(0.7, [&flow])
    flow = []
    v_hid = VHid
    play Fade(0.3)
    flow = Flow(t: 0, [4, 5], AZURE)
    play Fade(0.15, [&flow])
    flow.t = 1
    play Lerp(0.7, [&flow])
    flow = []
    v_out = VOut
    play Fade(0.3)
    flow = Flow(t: 0, [6], AZURE)
    play Fade(0.15, [&flow])
    flow.t = 1
    play Lerp(0.6, [&flow])
    flow = []
    v_loss = Above(LOSS, F0[3], ROSE)
    play Fade(0.3)
    play Wait(0.7)
    flow = Flow(t: 0, [6], EMBER, 1)
    play Fade(0.15, [&flow])
    flow.t = 1
    play Lerp(0.6, [&flow])
    flow = []
    g_out = Below(OUT, fmt(B0[0]))
    play Fade(0.3)
    flow = Flow(t: 0, [4, 5], EMBER, 1)
    play Fade(0.15, [&flow])
    flow.t = 1
    play Lerp(0.7, [&flow])
    flow = []
    g_c = [WGrad(4), WGrad(5)]
    g_h = [Below(HD[0], fmt(B0[1][0])), Below(HD[1], fmt(B0[1][1]))]
    play Fade(0.3)
    flow = Flow(t: 0, [0, 1, 2, 3], EMBER, 1)
    play Fade(0.15, [&flow])
    flow.t = 1
    play Lerp(0.7, [&flow])
    flow = []
    g_w = map(range(0, 4), WGrad)
    play Fade(0.3)
    play Wait(1.8)

Uses the shared definitions for this lesson's figures.

The network above has six weights. To train it by gradient descent we need six numbers: for each weight, how fast the loss changes when that weight alone is changed. There is an obvious way to get them. Raise one weight by a hair, run the network again, and see how far the loss moved.

For six weights that is fine. A large modern network has about a billion weights, and one run through it costs about a billion multiplications, so the obvious way costs $10^{18}$ multiplications for a single step of training. Yet such networks are trained every day, because there is a method that delivers all billion slopes for about the price of three runs. It is called backpropagation. We will find it by doing the obvious thing first, on the six-weight network, and noticing how much of the work is repeated.

A small network, run forward

The previous lesson built networks out of neurons: a neuron takes a weighted sum of its inputs, called its score, and applies the bend $\operatorname{ReLU}(z) = \max(0, z)$. Our network has two inputs $x_1, x_2$, two hidden neurons and one output:

$$ z_1 = w_{11}x_1 + w_{12}x_2, \quad h_1 = \operatorname{ReLU}(z_1), \qquad z_2 = w_{21}x_1 + w_{22}x_2, \quad h_2 = \operatorname{ReLU}(z_2), \qquad \hat y = c_1h_1 + c_2h_2. $$

Here $w_{jk}$ is the weight from input $k$ into hidden neuron $j$, and $c_j$ the weight from hidden neuron $j$ to the output. (Biases are left out to keep the count at six; they return later.) The weights at this moment of training are

$$ w_{11} = 1, \quad w_{12} = 1, \quad w_{21} = 1, \quad w_{22} = -1, \quad c_1 = 2, \quad c_2 = -1. $$

We have one training example: the input $(x_1, x_2) = (2, 1)$ with target $y = 3.5$. The loss is the squared error of the earlier lessons, $L = (\hat y - y)^2$.

Pause and try

Compute $z_1$, $z_2$, $h_1$, $h_2$, $\hat y$ and $L$ for this example.

Show answerHide answer

$z_1 = 1 \cdot 2 + 1 \cdot 1 = 3$ and $z_2 = 1 \cdot 2 - 1 \cdot 1 = 1$. Both scores are positive, so $h_1 = 3$ and $h_2 = 1$. Then $\hat y = 2 \cdot 3 - 1 \cdot 1 = 5$, and $L = (5 - 3.5)^2 = 2.25$.

Computing every value in order, from the inputs to the loss, is called the forward pass.

Nudge one weight and follow the nudge

Start with $w_{11}$ and do the obvious thing: raise it from $1$ to $1.01$, leave everything else alone, and run the network again. Now $z_1 = 1.01 \cdot 2 + 1 = 3.02$, so $h_1 = 3.02$. The output is $\hat y = 2 \cdot 3.02 - 1 = 5.04$ and the loss is $(5.04 - 3.5)^2 = 1.54^2 = 2.3716$. The loss rose by $0.1216$ when the weight rose by $0.01$, a rate of $12.16$.

  1. Raise the weight from the first input to the first hidden neuron by 0.01.
  2. That weight multiplies the input 2, so the hidden value above it rises by 0.02.
  3. The hidden value reaches the output through a weight of 2, so the output rises by 0.04.
  4. The loss goes from 1.5 squared to 1.54 squared. It rises by 0.1216, which is 3.04 times the rise in the output.
  5. Rise in the loss over rise in the weight: 12.16, close to the product of the three multipliers, 12.
A nudge of $0.01$ to the weight $w_{11}$, followed through the network. Each purple number is the change in the value above it.
Monocurl source
let FN = forward(X0, [[1.01, 1], [1, -1]], C0, Y0)
let Delta = |pos, v, p| Below(pos, str_join(["+", to_string(v, p)]), PLUM)
mesh net = Net
mesh wts = Weights
mesh vals = [VIn, VHid, VOut, Above(LOSS, F0[3], ROSE)]
mesh path = []
mesh nudge = []
mesh d_h = []
mesh d_out = []
mesh d_loss = []
mesh flow = []
mesh total = []

slide "Raise the weight from the first input to the first hidden neuron by 0.01."
    nudge = center{GPOS[0]} Math("+0.01", 0.8, PLUM)
    play Fade(0.6)

slide "That weight multiplies the input 2, so the hidden value above it rises by 0.02."
    path = [Edge(EDGES[0], PLUM, 4)]
    flow = Flow(t: 0, [0], PLUM)
    play Fade(0.3)
    flow.t = 1
    play Lerp(0.8, [&flow])
    flow = []
    d_h = Delta(HD[0], FN[1][0] - F0[1][0], 2)
    play Fade(0.4)

slide "The hidden value reaches the output through a weight of 2, so the output rises by 0.04."
    path = [Edge(EDGES[0], PLUM, 4), Edge(EDGES[4], PLUM, 4)]
    flow = Flow(t: 0, [4], PLUM)
    play Fade(0.3)
    flow.t = 1
    play Lerp(0.8, [&flow])
    flow = []
    d_out = Delta(OUT, FN[2] - F0[2], 2)
    play Fade(0.4)

slide "The loss goes from 1.5 squared to 1.54 squared. It rises by 0.1216, which is 3.04 times the rise in the output."
    path = [Edge(EDGES[0], PLUM, 4), Edge(EDGES[4], PLUM, 4), Edge(EDGES[6], PLUM, 4)]
    flow = Flow(t: 0, [6], PLUM)
    play Fade(0.3)
    flow.t = 1
    play Lerp(0.8, [&flow])
    flow = []
    d_loss = Delta(LOSS, FN[3] - F0[3], 4)
    play Fade(0.4)

slide "Rise in the loss over rise in the weight: 12.16, close to the product of the three multipliers, 12."
    total = center{[0, -1.95, 0]} Math(str_join(["\frac{", to_string(FN[3] - F0[3], 4), "}{0.01} = ", to_string((FN[3] - F0[3]) / 0.01, 2), "\qquad 2 \times 2 \times 3 = 12"]), 0.85, PLUM)
    play Fade(0.6)

Uses the shared definitions for this lesson's figures.

Look at how the nudge traveled. It was multiplied by $x_1 = 2$ on entering $z_1$, by 1 through the bend, because the neuron is on, by $c_1 = 2$ on the way to $\hat y$, and by $3.04$ at the loss. With a nudge of $0.0001$ the same computation gives a rate of $12.0016$. The rates are heading for $12 = 2 \times 1 \times 2 \times 3$, where the last factor, 3, is the derivative of $(\hat y - 3.5)^2$ with respect to $\hat y$ at $\hat y = 5$, namely $2(\hat y - 3.5) = 3$.

That rates multiply along a chain of dependencies is the chain rule: if $L = f(u)$ and $u = g(w)$ with $f$ and $g$ differentiable, then $\frac{dL}{dw} = f'(u)\,g'(w)$. Our chain has four links, $w_{11} \to z_1 \to h_1 \to \hat y \to L$. Writing $\partial$ for a partial derivative, the rate of change with respect to one variable while the others are held fixed,

$$ \frac{\partial L}{\partial w_{11}} = \frac{\partial L}{\partial \hat y} \cdot \frac{\partial \hat y}{\partial h_1} \cdot \frac{\partial h_1}{\partial z_1} \cdot \frac{\partial z_1}{\partial w_{11}} = 3 \cdot 2 \cdot 1 \cdot 2 = 12. $$

Each factor is local: it involves one link of the chain and is read off from numbers the forward pass already computed.

Pause and try

Find $\partial L / \partial w_{12}$ by multiplying along its chain. Then check it by raising $w_{12}$ to $1.01$ and recomputing the loss.

Show answerHide answer

The chain is $w_{12} \to z_1 \to h_1 \to \hat y \to L$. Only the first link differs: $w_{12}$ multiplies $x_2 = 1$. So $\partial L/\partial w_{12} = 3 \cdot 2 \cdot 1 \cdot 1 = 6$. Check: $z_1 = 2 + 1.01 = 3.01$, $\hat y = 6.02 - 1 = 5.02$, $L = 1.52^2 = 2.3104$, a rise of $0.0604$ for a nudge of $0.01$: a rate of $6.04$.

The same factors keep coming back

Write out the product for every weight, with the factors in order from the loss end:

weightchain of factorsslope
$c_1$$3 \cdot h_1 = 3 \cdot 3$$9$
$c_2$$3 \cdot h_2 = 3 \cdot 1$$3$
$w_{11}$$3 \cdot 2 \cdot 1 \cdot 2$$12$
$w_{12}$$3 \cdot 2 \cdot 1 \cdot 1$$6$
$w_{21}$$3 \cdot (-1) \cdot 1 \cdot 2$$-6$
$w_{22}$$3 \cdot (-1) \cdot 1 \cdot 1$$-3$

Every product starts with the same 3. The products for $w_{11}$ and $w_{12}$ share their first three factors, $3 \cdot 2 \cdot 1 = 6$, and that shared piece has a meaning of its own: it is $\partial L/\partial z_1$, the rate at which the loss responds to the score of neuron 1. Computing the products separately repeats that work, twice here and a thousand times in a network with a thousand inputs.

So compute the shared pieces once, starting from the end they share, and store each at its node. This is the backward pass:

  1. At the output: $\dfrac{\partial L}{\partial \hat y} = 2(\hat y - y) = 3$.
  2. Output weights: $\hat y = c_1h_1 + c_2h_2$, so $\dfrac{\partial L}{\partial c_j} = \dfrac{\partial L}{\partial \hat y} \cdot h_j$, giving $9$ and $3$.
  3. Hidden values: by the same formula, $\dfrac{\partial L}{\partial h_j} = \dfrac{\partial L}{\partial \hat y} \cdot c_j$, giving $6$ and $-3$.
  4. Through the bend: $\dfrac{\partial L}{\partial z_j} = \dfrac{\partial L}{\partial h_j} \cdot \operatorname{ReLU}'(z_j)$. The derivative of $\operatorname{ReLU}$ is 1 at a positive score and 0 at a negative one. Both scores are positive, so these are again $6$ and $-3$.
  5. First-layer weights: $z_j = w_{j1}x_1 + w_{j2}x_2$, so $\dfrac{\partial L}{\partial w_{jk}} = \dfrac{\partial L}{\partial z_j} \cdot x_k$, giving $12, 6, -6, -3$.
  1. Start at the loss. Its slope with respect to the output is twice the error: 2 times 1.5, which is 3.
  2. Each output weight gets 3 times the hidden value it multiplied in the forward pass: 9 and 3.
  3. Each hidden value gets 3 times the weight on its edge to the output: 6 and -3.
  4. Both neurons are on, so 6 and -3 pass back through the bend. Each first-layer weight gets its neuron’s number times its input.
The backward pass. Each orange number is the slope of the loss with respect to the value or the weight it sits beside, computed from the orange number one step closer to the loss.
Monocurl source
mesh net = Net
mesh wts = Weights
mesh legend = Legend
mesh vals = [VIn, VHid, VOut, Above(LOSS, F0[3], ROSE)]
mesh g_out = []
mesh g_c = []
mesh g_h = []
mesh g_w = []
mesh flow = []

slide "Start at the loss. Its slope with respect to the output is twice the error: 2 times 1.5, which is 3."
    flow = Flow(t: 0, [6], EMBER, 1)
    play Fade(0.3)
    flow.t = 1
    play Lerp(0.8, [&flow])
    flow = []
    g_out = Below(OUT, fmt(B0[0]))
    play Fade(0.4)

slide "Each output weight gets 3 times the hidden value it multiplied in the forward pass: 9 and 3."
    g_c = [WGrad(4), WGrad(5)]
    play Fade(0.6)

slide "Each hidden value gets 3 times the weight on its edge to the output: 6 and -3."
    flow = Flow(t: 0, [4, 5], EMBER, 1)
    play Fade(0.3)
    flow.t = 1
    play Lerp(0.8, [&flow])
    flow = []
    g_h = [Below(HD[0], fmt(B0[1][0])), Below(HD[1], fmt(B0[1][1]))]
    play Fade(0.4)

slide "Both neurons are on, so 6 and -3 pass back through the bend. Each first-layer weight gets its neuron's number times its input."
    flow = Flow(t: 0, [0, 1, 2, 3], EMBER, 1)
    play Fade(0.3)
    flow.t = 1
    play Lerp(0.8, [&flow])
    flow = []
    g_w = map(range(0, 4), WGrad)
    play Fade(0.4)

Uses the shared definitions for this lesson's figures.

One sweep from right to left produced all six slopes, the gradient of the loss. The negative slope of $w_{21}$ says that raising it lowers the loss: it raises $h_2$, which enters the too-high output with weight $-1$.

Checking against nudges

A slope from the backward pass can always be tested with the obvious method. For a weight $w$ and a small $\varepsilon > 0$, the finite-difference quotient is

$$ \frac{L(w + \varepsilon) - L(w)}{\varepsilon}, $$

with every other weight held fixed. Its limit as $\varepsilon \to 0$ is the partial derivative, so for small $\varepsilon$ it must be close to the backward pass’s number.

The six slopes from the backward pass, drawn as bars, beside the finite-difference quotients computed by raising each weight by 0.01 and by 0.0001 and rerunning the forward pass.
The six slopes from the backward pass, drawn as bars, beside the finite-difference quotients computed by raising each weight by $0.01$ and by $0.0001$ and rerunning the forward pass.
Monocurl source
let ROW = |i| 1.05 - 0.52 * i
let ZX = -0.45
let UNIT = 0.085
let BASE = F0[3]
mesh guide = [
    Seg([ZX, 1.4, 0], [ZX, -1.9, 0], SLATE, 2),
    map([-6, 6, 12], |v| [
        Seg([ZX + UNIT * v, 1.4, 0], [ZX + UNIT * v, -1.9, 0], MIST, 1.5),
        center{[ZX + UNIT * v, -2.08, 0]} Math(to_string(v), 0.7, SLATE)
    ]),
    center{[ZX, -2.08, 0]} Math("0", 0.7, SLATE)
]
mesh bars = map(range(0, 6), |i| Region([
    [ZX, ROW(i) - 0.15, 0], [ZX + UNIT * WGRADS[i], ROW(i) - 0.15, 0],
    [ZX + UNIT * WGRADS[i], ROW(i) + 0.15, 0], [ZX, ROW(i) + 0.15, 0]
], EMBER))
mesh heads = [
    center{[-3.2, 1.8, 0]} Note("weight", 0.74),
    center{[-2.0, 1.8, 0]} Cap("backward", 0.74, EMBER),
    center{[1.55, 1.8, 0]} Cap("nudge 0.01", 0.74, PLUM),
    center{[3.0, 1.8, 0]} Cap("0.0001", 0.74, PLUM)
]
mesh rows = map(range(0, 6), |i| [
    center{[-3.2, ROW(i), 0]} Math(WNAMES[i], 0.85, INK),
    center{[-2.0, ROW(i), 0]} Math(fmt(WGRADS[i]), 0.85, EMBER),
    center{[1.55, ROW(i), 0]} Math(to_string((loss_nudged(i, 0.01) - BASE) / 0.01, 2), 0.82, PLUM),
    center{[3.0, ROW(i), 0]} Math(to_string((loss_nudged(i, 0.0001) - BASE) / 0.0001, 4), 0.82, PLUM)
])

Uses the shared definitions for this lesson's figures.

All six agree, and the disagreement that remains is 100 times smaller for a nudge 100 times smaller. For $w_{11}$ we can see why. With $w_{11} = 1 + \varepsilon$ the output is $5 + 4\varepsilon$, so the loss is $(1.5 + 4\varepsilon)^2 = 2.25 + 12\varepsilon + 16\varepsilon^2$ and the quotient is $12 + 16\varepsilon$: the slope, plus an error proportional to $\varepsilon$. Running this comparison to test backpropagation code is called a gradient check.

When a value feeds two neurons

In this network each weight has a single route to the loss, which is why one chain of factors was enough. The input $x_1$ is different: it feeds both hidden neurons, so a nudge to it reaches the loss along two routes. We do not train inputs, but in a deeper network $x_1$ would be a hidden value of an earlier layer, and the backward pass would need $\partial L/\partial x_1$ to continue past it.

Nudge first. With $x_1 = 2.01$: $z_1 = 3.01$ and $z_2 = 1.01$, so $\hat y = 6.02 - 1.01 = 5.01$ and $L = 1.51^2 = 2.2801$, a rise of $0.0301$. The rate is $3.01$.

Pause and try

Multiply the factors along each of the two routes from $x_1$ to $L$, using $\partial L/\partial z_1 = 6$ and $\partial L/\partial z_2 = -3$. How do the two products combine to give a number near $3.01$?

Show answerHide answer

On its way into $z_1$, $x_1$ is multiplied by $w_{11} = 1$, so the first product is $6 \cdot 1 = 6$. On its way into $z_2$ it is multiplied by $w_{21} = 1$, so the second is $-3 \cdot 1 = -3$. Neither is near $3.01$. Their sum, $3$, is.

The input x_1 reaches the loss along two routes, through h_1 (purple) and through h_2 (gold). Each route contributes the product of its factors, and the slope with respect to x_1 is the sum.
The input $x_1$ reaches the loss along two routes, through $h_1$ (purple) and through $h_2$ (gold). Each route contributes the product of its factors, and the slope with respect to $x_1$ is the sum.
Monocurl source
mesh net = Net
mesh routes = [
    Edge(EDGES[0], PLUM, 4.5), Edge(EDGES[4], PLUM, 4.5),
    Edge(EDGES[2], GOLD, 4.5), Edge(EDGES[5], GOLD, 4.5)
]
mesh wts = list_subset(Weights, [0, 2, 4, 5])
mesh vals = [VIn, VHid, VOut, Above(LOSS, F0[3], ROSE)]
mesh grads = [
    Below(HD[0], fmt(B0[2][0])), Below(HD[1], fmt(B0[2][1])), Below(OUT, fmt(B0[0])),
    center{[-1.35, 1.22, 0]} Math(str_join(["\times\,", fmt(B0[2][0]), " = ", fmt(W0[0][0] * B0[2][0])]), 0.8, PLUM),
    center{[-1.8, -0.68, 0]} Math(str_join(["\times\,(", fmt(B0[2][1]), ") = ", fmt(W0[1][0] * B0[2][1])]), 0.8, GOLD)
]
mesh total = [
    center{[1.75, 1.6, 0]} Math(str_join(["\frac{\partial L}{\partial x_1} = ", fmt(W0[0][0] * B0[2][0]), " + (", fmt(W0[1][0] * B0[2][1]), ") = ", fmt(B0[5][0])]), 0.85, EMBER),
    center{[2.3, -1.6, 0]} Math(str_join(["\text{nudge: } ", to_string((forward([2.01, 1], W0, C0, Y0)[3] - F0[3]) / 0.01, 2)]), 0.8, PLUM)
]

Uses the shared definitions for this lesson's figures.

Adding the contributions of the routes is the chain rule for functions of several variables.

We use this theorem without proof; it is in any multivariable calculus text. It is believable from the first-order approximation of the lesson on gradients: a small change in $t$ changes each $u_k$ by about $\frac{du_k}{dt}\Delta t$, and small changes $\Delta u_k$ change $F$ by about $\sum_k \frac{\partial F}{\partial u_k}\Delta u_k$. For us $t$ is $x_1$, the $u_k$ are $z_1$ and $z_2$, and

$$ \frac{\partial L}{\partial x_1} = \frac{\partial L}{\partial z_1}\,w_{11} + \frac{\partial L}{\partial z_2}\,w_{21} = 6 \cdot 1 + (-3) \cdot 1 = 3. $$

Backpropagation in general

The same computation works in a network of any size, once the bookkeeping has names.

Our network has $N = 2$, with $\varphi_1 = \operatorname{ReLU}$, $\varphi_2(z) = z$, activations $a^{(1)} = (h_1, h_2)$, second-layer weights $w^{(2)}_{1j} = c_j$, all biases zero, and $\ell(a) = (a - y)^2$.

With the input and all weights fixed, the loss is determined by the scores of any one layer, because the layers above can be run from them: $L = F_l\bigl(z^{(l)}\bigr)$. The number stored at each node in step 4 was a partial derivative of this function.

Match the parts to the hand computation. Part 1 was step 1: $\delta^{(2)}_1 = 2(\hat y - y) = 3$. Part 2 was steps 3 and 4: $\delta^{(1)}_j = \operatorname{ReLU}'(z_j)\,c_j \cdot 3$, a sum with one term because there is one output; the slope with respect to $x_1$ was the same sum with two terms. Part 3 was steps 2 and 5.

Why one backward pass is cheap

Count multiplications. The forward pass uses each weight once, in the product $w^{(l)}_{ji}\,a^{(l-1)}_i$. The backward pass uses each weight once in the sum of part 2, and each weight’s slope takes one more product in part 3. (There is also one $\varphi'$ per neuron, and there are far fewer neurons than weights.) So the backward pass costs about two forward passes, and the whole gradient about three, whatever the number of weights.

Nudging costs one forward pass for the baseline and one more per weight. For our six weights that is 7 passes against 3; for $10^9$ weights it is a billion and one against 3. Nudging also returns approximations, such as $12.16$, where the backward pass returns the derivative itself. The saving comes from the direction of travel: there is one loss and there are many weights, and working backward from the loss, every stored delta is shared by all the weights upstream of it.

Using the gradient: one step, then XOR

Gradient descent moves every weight a small step against its slope, $w \leftarrow w - \eta\,\partial L/\partial w$, with a learning rate $\eta > 0$. Take $\eta = 0.01$ and the six slopes $12, 6, -6, -3, 9, 3$:

$$ w_{11} = 0.88, \quad w_{12} = 0.94, \quad w_{21} = 1.06, \quad w_{22} = -0.97, \quad c_1 = 1.91, \quad c_2 = -1.03. $$

A new forward pass gives $z_1 = 2.70$, $z_2 = 1.15$, $\hat y = 1.91 \cdot 2.70 - 1.03 \cdot 1.15 = 3.9725$ and $L = 0.4725^2 \approx 0.223$. One step took the loss from $2.25$ to $0.22$.

Now a task with more than one example. In the previous lesson we chose by hand the weights of a network that computes XOR: output 1 at $(1, 0)$ and $(0, 1)$, output 0 at $(0, 0)$ and $(1, 1)$. Backpropagation can find such weights. Give each neuron a bias, $h_j = \operatorname{ReLU}(w_{j1}x_1 + w_{j2}x_2 + b_j)$ and $\hat y = c_1h_1 + c_2h_2 + c_0$, and take as the loss the mean of the four squared errors. The gradient of a mean is the mean of the gradients, so each step runs a forward and a backward pass per point and averages. Start from $w_1 = (1, 0.5)$, $w_2 = (0.5, 1)$, $b = (0, -0.5)$, $c = (1, -1)$, $c_0 = 0$ and take 200 steps with $\eta = 0.1$.

Training a two-neuron ReLU network on XOR by backpropagation and gradient descent: 200 steps at learning rate 0.1. Left: the mean squared error. Right: the output at the four inputs, with the targets in green.
Monocurl source
let HIST = train_xor([1, 0.5, 0.5, 1, 0, -0.5, 1, -1, 0], 0.1, 200)
let fl = Frame(0, 200, 0, 0.34, [-1.75, 0.0, 0], 3.0, 2.7)
let fb = Frame(0, 4, -0.25, 1.25, [2.1, 0.0, 0], 2.7, 2.7)
let NAMES = ["(0,0)", "(1,0)", "(0,1)", "(1,1)"]
let Bar = |i, v| {
    let x0 = fx(fb, i + 0.22)
    let x1 = fx(fb, i + 0.78)
    let top = fy(fb, v)
    let base = fy(fb, 0)
    return Region([[x0, base, 0], [x1, base, 0], [x1, top, 0], [x0, top, 0]], [AZURE, EMBER][XOR[i][2]])
}
let Run = |u| {
    let k = min(to_int(floor(u + 0.0001)), 200)
    return [
        Path(map(range(0, max(k, 1) + 1), |s| at(fl, s, HIST[s][0])), ROSE, 3.5),
        Pt(at(fl, k, HIST[k][0]), ROSE, 0.09),
        map(range(0, 4), |i| Bar(i, HIST[k][1][i])),
        map(range(0, 4), |i| Seg(at(fb, i + 0.1, XOR[i][2]), at(fb, i + 0.9, XOR[i][2]), LEAF, 4)),
        center{[-1.2, 1.9, 0]} scale{1.05} color{INK} Number(k, 0),
        center{[0.25, 1.9, 0]} scale{1.05} color{ROSE} Number(HIST[k][0], 4)
    ]
}
mesh plots = [
    PlotFrame(fl, [0, 50, 100, 150, 200], [0, 0.1, 0.2, 0.3], "step", "loss", 1),
    GridLines(fb, [], [0, 0.5, 1]),
    Seg(at(fb, 0, -0.25), at(fb, 0, 1.25), SLATE, 2.5),
    map([0, 0.5, 1], |v| center{[fx(fb, 0) - 0.36, fy(fb, v), 0]} Math(to_string(v, 1), 0.72, SLATE)),
    map(range(0, 4), |i| center{[fx(fb, i + 0.5), fy(fb, -0.25) - 0.3, 0]} Math(NAMES[i], 0.72, SLATE)),
    center{[2.1, fy(fb, -0.25) - 0.66, 0]} Note("output at each input", 0.74)
]
mesh names = [
    center{[-1.85, 1.9, 0]} Cap("step", 0.76, INK),
    center{[-0.5, 1.9, 0]} Cap("loss", 0.76, ROSE)
]
mesh run = Run(u: 0)

slide "train"
    play Wait(0.8)
    run.u = 200
    play Lerp(7, [&run], |t| t ^ 2)
    play Wait(1.6)

Uses the shared definitions for this lesson's figures.

The loss falls from $0.3125$ to $0.0002$, and the outputs at the four points go from $0, 1, 0, 0.5$ to $0.02, 0.99, 0.99, 0.01$.

Backpropagation computes the gradient exactly, and that is all it does. Whether following the gradient leads somewhere good is a separate question. Start the same network with every first-layer weight and bias negative. Then both neurons are off at all four inputs, so $\operatorname{ReLU}'(z_j) = 0$ makes both hidden deltas zero by part 2, and the slopes of every weight except $c_0$ are zero by part 3. Only $c_0$ moves. It settles at $0.5$, the mean of the targets, and the loss stays at $0.25$ forever.

Two limits: kinks and vanishing gradients

The theorem assumed each activation differentiable at each score that occurs. $\operatorname{ReLU}$ has no derivative at $z = 0$, where its slope jumps from 0 to 1. A score of exactly 0 is rare, and software uses the value 0 there.

A slower failure is built into part 2. Going back one layer multiplies a delta by $\varphi'(z)$ and by a weight. For the sigmoid $\sigma(z) = 1/(1 + e^{-z})$ of the lesson on classification, $\sigma'(z) = \sigma(z)\bigl(1 - \sigma(z)\bigr)$, and a product $p(1 - p)$ with $0 < p < 1$ is at most $\frac14$. So unless the weights are large, each layer of sigmoids shrinks the deltas by a factor of four or more, and the early layers of a deep network receive slopes too small to learn from. This is the vanishing gradient problem. It is one of the main reasons deep networks use ReLU, whose derivative is exactly 1 wherever the neuron is on.

Deltas in a chain of eight neurons, one per layer, with every weight 1, input 1 and \partial \ell/\partial a = 1 at the output, computed by the recursion of the theorem. With sigmoids each step back multiplies the delta by about 0.22; with ReLU every delta is 1. The vertical scale is logarithmic.
Deltas in a chain of eight neurons, one per layer, with every weight 1, input 1 and $\partial \ell/\partial a = 1$ at the output, computed by the recursion of the theorem. With sigmoids each step back multiplies the delta by about $0.22$; with ReLU every delta is 1. The vertical scale is logarithmic.
Monocurl source
let DEPTH = 8
let dsig = |z| sigmoid(z) * (1 - sigmoid(z))
# deltas of a chain with weights 1 and input 1, layer 1 first
let chain_deltas = |act, dact| {
    var a = 1
    var zs = []
    for (l in range(0, DEPTH)) {
        zs .= a
        a = act(a)
    }
    var d = 1
    var out = []
    for (l in range(0, DEPTH)) {
        d = d * dact(zs[DEPTH - 1 - l])
        out .= d
    }
    return reverse(out)
}
let SIG = chain_deltas(sigmoid, dsig)
let fv = Frame(0.4, 8.6, -6, 0.6, [0.1, 0.1, 0], 6.2, 3.0)
mesh grid = [
    GridLines(fv, [], [-6, -5, -4, -3, -2, -1, 0]),
    Seg(at(fv, 0.4, -6), at(fv, 8.6, -6), SLATE, 2.5),
    Seg(at(fv, 0.4, -6), at(fv, 0.4, 0.6), SLATE, 2.5),
    map(range(1, 9), |l| center{[fx(fv, l), fy(fv, -6) - 0.3, 0]} Math(to_string(l), 0.72, SLATE)),
    map([0, -2, -4, -6], |e| center{[fx(fv, 0.4) - 0.48, fy(fv, e), 0]} Math(str_join(["10^{", to_string(e), "}"]), 0.72, SLATE)),
    center{[0.1, fy(fv, -6) - 0.66, 0]} Note("layer (1 is nearest the input)", 0.74),
    center{[fx(fv, 0.4) + 0.55, fy(fv, 0.6) + 0.28, 0]} Note("size of delta", 0.74)
]
mesh bars = map(range(0, DEPTH), |l| Region([
    at(fv, l + 0.68, -6), at(fv, l + 1.32, -6),
    at(fv, l + 1.32, log10(SIG[l])), at(fv, l + 0.68, log10(SIG[l]))
], tint(AZURE, 0.75)))
mesh relu_marks = [
    Path(map(range(1, 9), |l| at(fv, l, 0)), LEAF, 3),
    map(range(1, 9), |l| Pt(at(fv, l, 0), LEAF, 0.09))
]
mesh labels = [
    center{at(fv, 2.6, -0.75)} Cap("ReLU: 1 in every layer", 0.74, LEAF),
    center{at(fv, 1.5, log10(SIG[1]) + 0.5)} Math(str_join([to_string(SIG[0] * 1000000, 1), " \times 10^{-6}"]), 0.72, AZURE),
    center{at(fv, 3.0, -2.75)} Cap("sigmoid", 0.74, AZURE),
    center{at(fv, 8.0, log10(SIG[7]) - 0.5)} Math(to_string(SIG[7], 2), 0.72, PAPER)
]

Uses the shared definitions for this lesson's figures.

Worked examples

Stepping back

The hard part was seeing that the six products overlap, and that the network itself organizes the overlap: the factors shared by all weights that feed a neuron are that neuron’s delta. A delta depends on the deltas of the next layer, so the order of work is forced: begin at the loss and move toward the inputs.

The same idea works for any computation built from differentiable steps; under the name reverse-mode automatic differentiation it is what machine learning software does. A network trained this way can drive its training loss very low. The next lesson asks whether a low training loss means the model has learned anything.

Check yourself

In some network, a weight $w$ runs from a neuron with activation $4$ to a neuron whose delta is $-0.5$. What is $\partial L/\partial w$, and which way does gradient descent move $w$?

Problems

“The lesson’s network” has $w_{11} = w_{12} = w_{21} = 1$, $w_{22} = -1$, $c_1 = 2$, $c_2 = -1$.

Problem 1

For the lesson’s network and the input $(2, 1)$, change the target to $y = 6$. Find all six slopes. Which weights will gradient descent increase?

Show solutionHide solution

The forward pass does not involve the target until the loss: $h = (3, 1)$ and $\hat y = 5$. Now $L = (5 - 6)^2 = 1$ and $\partial L/\partial \hat y = 2(5 - 6) = -2$. Output weights: $-2 \cdot 3 = -6$ and $-2 \cdot 1 = -2$. Hidden values: $-2 \cdot 2 = -4$ and $-2 \cdot (-1) = 2$; both neurons are on, so these are the deltas. First layer: $\partial L/\partial w_{11} = -4 \cdot 2 = -8$, $\partial L/\partial w_{12} = -4 \cdot 1 = -4$, $\partial L/\partial w_{21} = 2 \cdot 2 = 4$, $\partial L/\partial w_{22} = 2 \cdot 1 = 2$. Gradient descent increases the weights with negative slope: $c_1$, $c_2$, $w_{11}$ and $w_{12}$.

Problem 2

In the lesson’s example, write the loss as a function of $\varepsilon$ when $w_{22}$ is replaced by $-1 + \varepsilon$, for $|\varepsilon| < 1$. Use it to give the finite-difference quotient exactly, and evaluate it for $\varepsilon = 0.1$ and $\varepsilon = 0.001$.

Show solutionHide solution

$z_2 = 2 + (-1 + \varepsilon) = 1 + \varepsilon$, which is positive for $|\varepsilon| < 1$, so $h_2 = 1 + \varepsilon$ and $\hat y = 6 - (1 + \varepsilon) = 5 - \varepsilon$. The loss is $(1.5 - \varepsilon)^2 = 2.25 - 3\varepsilon + \varepsilon^2$. The quotient is $\frac{-3\varepsilon + \varepsilon^2}{\varepsilon} = -3 + \varepsilon$. For $\varepsilon = 0.1$ it is $-2.9$ and for $\varepsilon = 0.001$ it is $-2.999$. The error of the quotient is exactly $\varepsilon$, and its limit is the backward pass’s $-3$.

Problem 3

A fully connected network has layers of sizes 100 (input), 50, 10 and 1, with a bias on every neuron. (a) How many parameters does it have? (b) How many multiplications of a weight by an activation does one forward pass use? (c) Compare the number of such multiplications needed to get the whole gradient by nudging with the lesson’s estimate for backpropagation.

Show solutionHide solution

(a) Weights: $100 \cdot 50 + 50 \cdot 10 + 10 \cdot 1 = 5510$. Biases: $50 + 10 + 1 = 61$. Total $5571$. (b) One per weight: $5510$. (c) Nudging needs a baseline pass and one pass per parameter, $5572$ passes, or $5572 \times 5510 \approx 30.7$ million multiplications. Backpropagation costs about three forward passes, $3 \times 5510 = 16{,}530$. The ratio is about $1860$, a third of the number of parameters.

Problem 4

Give the lesson’s network biases: $z_j = w_{j1}x_1 + w_{j2}x_2 + b_j$ and $\hat y = c_1h_1 + c_2h_2 + c_0$, with $b_1 = b_2 = c_0 = 0$ at present. (a) Explain why a bias is a weight on an extra input fixed at 1, and deduce the slope formula for a bias from the one for a weight. (b) For the input $(2, 1)$ and target $3.5$, find $\partial L/\partial b_1$, $\partial L/\partial b_2$ and $\partial L/\partial c_0$.

Show solutionHide solution

(a) The term $b_j$ in the score equals $b_j \cdot 1$, which is what a third input fixed at 1 with weight $b_j$ would contribute. A weight’s slope is the delta at its head times the activation at its tail, here 1, so the slope of a bias is the delta of its neuron. (b) With zero biases the forward pass is unchanged, so the deltas are those of the lesson: $\delta_1 = 6$, $\delta_2 = -3$ for the hidden neurons and $3$ for the output. Hence $\partial L/\partial b_1 = 6$, $\partial L/\partial b_2 = -3$ and $\partial L/\partial c_0 = 3$.

Problem 5

A network is a chain with one neuron per layer and the activation $\varphi(z) = z^2$ in both layers: $z_1 = w_1x$, $a_1 = z_1^2$, $z_2 = w_2a_1$, $a_2 = z_2^2$, and $L = (a_2 - 3)^2$. Take $x = 1$, $w_1 = 2$, $w_2 = 0.5$. (a) Find $\partial L/\partial w_1$ and $\partial L/\partial w_2$ by backpropagation. (b) Write $L$ as an explicit formula in $w_1$ and $w_2$, differentiate it directly, and compare.

Show solutionHide solution

(a) Forward: $z_1 = 2$, $a_1 = 4$, $z_2 = 2$, $a_2 = 4$, $L = 1$. Since $\varphi'(z) = 2z$: $\delta_2 = \frac{\partial \ell}{\partial a_2}\varphi'(z_2) = 2(4 - 3) \cdot 4 = 8$, and $\delta_1 = \varphi'(z_1)\,w_2\,\delta_2 = 4 \cdot 0.5 \cdot 8 = 16$. Then $\partial L/\partial w_2 = \delta_2 a_1 = 8 \cdot 4 = 32$ and $\partial L/\partial w_1 = \delta_1 x = 16$. (b) $a_1 = w_1^2$ and $a_2 = (w_2w_1^2)^2 = w_2^2w_1^4$, so $L = (w_2^2w_1^4 - 3)^2$. Then $\partial L/\partial w_1 = 2(w_2^2w_1^4 - 3) \cdot 4w_2^2w_1^3 = 2 \cdot 1 \cdot 4 \cdot 0.25 \cdot 8 = 16$, and $\partial L/\partial w_2 = 2(w_2^2w_1^4 - 3) \cdot 2w_2w_1^4 = 2 \cdot 1 \cdot 2 \cdot 0.5 \cdot 16 = 32$. They agree, and backpropagation never formed the composite formula.

Problem 6

(Vanishing gradients.) A chain has $n$ layers with one sigmoid neuron each and every weight equal to $w$. (a) Prove that $\sigma'(z) \le \frac14$ for all $z$. (b) Show that $|\delta^{(1)}| \le \bigl(\frac{|w|}{4}\bigr)^{n-1}|\delta^{(n)}|$. (c) Evaluate the bound for $w = 1$ and $n = 10$.

Show solutionHide solution

(a) $\sigma'(z) = p(1 - p)$ with $p = \sigma(z)$ in $(0, 1)$. Since $p(1 - p) = \frac14 - (p - \frac12)^2 \le \frac14$, the claim follows, with equality only at $p = \frac12$, that is $z = 0$. (b) With one neuron per layer, part 2 of the theorem reads $\delta^{(l)} = \sigma'(z^{(l)})\,w\,\delta^{(l+1)}$, so $|\delta^{(l)}| \le \frac{|w|}{4}|\delta^{(l+1)}|$. Applying this $n - 1$ times, from layer $n - 1$ down to layer 1, gives the bound. (c) $(1/4)^9 = 1/262144 \approx 3.8 \times 10^{-6}$.

Problem 7

(Logistic regression is a one-layer case.) A single sigmoid neuron computes $z = \sum_i w_ix_i + b$ and $a = \sigma(z)$, and is trained on an example with label $y \in \{0, 1\}$ using the cross-entropy loss $\ell(a) = -y\ln a - (1 - y)\ln(1 - a)$. Use parts 1 and 3 of the theorem to show that $\delta = a - y$ and $\partial L/\partial w_i = (a - y)\,x_i$.

Show solutionHide solution

Differentiate the loss: $\dfrac{\partial \ell}{\partial a} = -\dfrac{y}{a} + \dfrac{1 - y}{1 - a} = \dfrac{-y(1 - a) + (1 - y)a}{a(1 - a)} = \dfrac{a - y}{a(1 - a)}$. The activation’s derivative is $\sigma'(z) = a(1 - a)$. By part 1, $\delta = \dfrac{a - y}{a(1 - a)} \cdot a(1 - a) = a - y$; the awkward factors cancel. By part 3, with the inputs $x_i$ as the activations of layer 0, $\partial L/\partial w_i = (a - y)\,x_i$ and $\partial L/\partial b = a - y$. This is the gradient of the lesson on classification: the error in the predicted probability times the input.

Shared definitions used by this lesson's figures
# house kit: prepended to every essay figure after the std imports

# palette. AZURE and EMBER are the two actors, LEAF marks an answer, ROSE a problem
let PAPER = [1, 1, 1, 1]
let INK = [0.08, 0.09, 0.11, 1]
let SLATE = [0.40, 0.44, 0.50, 1]
let MIST = [0.85, 0.86, 0.88, 1]
let AZURE = [0.13, 0.42, 0.68, 1]
let EMBER = [0.89, 0.42, 0.12, 1]
let LEAF = [0.13, 0.55, 0.36, 1]
let ROSE = [0.78, 0.22, 0.24, 1]
let PLUM = [0.47, 0.30, 0.66, 1]
let GOLD = [0.85, 0.62, 0.08, 1]

background = PAPER

# an opaque pastel of col; use for fills so overlaps do not darken
let tint = |col, amount = 0.18| [
    1 - (1 - col[0]) * amount,
    1 - (1 - col[1]) * amount,
    1 - (1 - col[2]) * amount,
    1
]

# text: Cap for labels, Note for quiet labels, Math for typeset maths
let Cap = |txt, size = 0.75, col = INK| color{col} Text(txt, size, "Charter")
let Note = |txt, size = 0.65| color{SLATE} Text(txt, size, "Charter")
let Math = |tex, size = 0.9, col = INK| color{col} Tex(tex, size)

# a paper rectangle to sit behind text that crosses lines
let Plate = |pos, size| center{pos} fill{PAPER} stroke{alpha{0} PAPER, 0} Rect(size)

# shapes with fill and stroke both set, so no stray black appears
let Pt = |pos, col = INK, r = 0.075| center{pos} fill{col} stroke{col, 1} Circle(r, 48)
let Ring = |pos, col = INK, r = 0.1, w = 3| center{pos} fill{PAPER} stroke{col, w} Circle(r, 48)
let Seg = |a, b, col = INK, w = 3| stroke{col, w} Line(a, b)
let Path = |pts, col = INK, w = 3| stroke{col, w} Polyline(pts)
let Region = |pts, col| fill{col} stroke{alpha{0} col, 0} Polygon(pts)
let Outline = |pts, col = INK, w = 3| fill{alpha{0} PAPER} stroke{col, w} Polygon(pts)
let Shape = |pts, col, amount = 0.18, w = 3| fill{tint(col, amount)} stroke{col, w} Polygon(pts)
let Disc = |pos, r, col, amount = 0.18, w = 3| center{pos} fill{tint(col, amount)} stroke{col, w} Circle(r, 96)
let Box = |pos, size, col, amount = 0.18, w = 3| center{pos} fill{tint(col, amount)} stroke{col, w} Rect(size)
# a line from a to b ending in a small solid arrowhead
let Tip = |a, b, col = INK, w = 3, head = 0.17| {
    let d = normalize([b[0] - a[0], b[1] - a[1], 0])
    let n = [-d[1], d[0], 0]
    let base = [b[0] - d[0] * head, b[1] - d[1] * head, 0]
    return [
        stroke{col, w} Line(a, base),
        fill{col} stroke{col, 1} Polygon([
            b,
            [base[0] + n[0] * head * 0.42, base[1] + n[1] * head * 0.42, 0],
            [base[0] - n[0] * head * 0.42, base[1] - n[1] * head * 0.42, 0]
        ])
    ]
}

# plots. a frame is [x0, x1, y0, y1, centre, width, height]: a data window and the scene box it fills
let Frame = |x0, x1, y0, y1, c = [0, 0, 0], w = 6.4, h = 3.6| [x0, x1, y0, y1, c, w, h]
let fx = |fr, x| fr[4][0] + (x - (fr[0] + fr[1]) / 2) * fr[5] / (fr[1] - fr[0])
let fy = |fr, y| fr[4][1] + (y - (fr[2] + fr[3]) / 2) * fr[6] / (fr[3] - fr[2])
let at = |fr, x, y| [fx(fr, x), fy(fr, y), 0]

# axes through the data origin (or the nearest edge of the window), with optional names
let Axes = |fr, x_name = "", y_name = "", col = SLATE| {
    let ox = clamp(fr[0], 0, fr[1])
    let oy = clamp(fr[2], 0, fr[3])
    var parts = [
        Tip(at(fr, fr[0], oy), [fx(fr, fr[1]) + 0.25, fy(fr, oy), 0], col, 2.5, 0.15),
        Tip(at(fr, ox, fr[2]), [fx(fr, ox), fy(fr, fr[3]) + 0.25, 0], col, 2.5, 0.15)
    ]
    if (x_name != "") {
        parts .= center{[fx(fr, fr[1]) + 0.5, fy(fr, oy), 0]} Math(x_name, 0.8, col)
    }
    if (y_name != "") {
        parts .= center{[fx(fr, ox), fy(fr, fr[3]) + 0.5, 0]} Math(y_name, 0.8, col)
    }
    return parts
}

# tick marks with numbers at the listed data values
let XTicks = |fr, xs, col = SLATE, places = 0| block {
    let oy = fy(fr, clamp(fr[2], 0, fr[3]))
    for (x in xs) {
        . Seg([fx(fr, x), oy - 0.07, 0], [fx(fr, x), oy + 0.07, 0], col, 2)
        . center{[fx(fr, x), oy - 0.33, 0]} Math(to_string(x, places), 0.72, col)
    }
}
let YTicks = |fr, ys, col = SLATE, places = 0| block {
    let ox = fx(fr, clamp(fr[0], 0, fr[1]))
    for (y in ys) {
        . Seg([ox - 0.07, fy(fr, y), 0], [ox + 0.07, fy(fr, y), 0], col, 2)
        . center{[ox - 0.36, fy(fr, y), 0]} Math(to_string(y, places), 0.72, col)
    }
}

# faint grid lines at the listed data values
let GridLines = |fr, xs, ys, col = MIST| block {
    for (x in xs) {
        . Seg(at(fr, x, fr[2]), at(fr, x, fr[3]), col, 1.5)
    }
    for (y in ys) {
        . Seg(at(fr, fr[0], y), at(fr, fr[1], y), col, 1.5)
    }
}

# the graph of f over [a, b], drawn in the frame. values are clamped to the window
let Graph = |fr, f, a, b, col = AZURE, w = 4, n = 160|
    stroke{col, w} Polyline(map(sample(a, b, n), |x| at(fr, x, clamp(fr[2], f(x), fr[3]))))

# the region between the graph of f and the x axis over [a, b]
let AreaUnder = |fr, f, a, b, col = AZURE, amount = 0.2, n = 80| {
    var pts = [at(fr, a, 0)]
    for (x in sample(a, b, n)) {
        pts .= at(fr, x, clamp(fr[2], f(x), fr[3]))
    }
    pts .= at(fr, b, 0)
    return fill{tint(col, amount)} stroke{alpha{0} col, 0} Polygon(pts)
}

# the running example: 2 inputs, 2 hidden ReLU neurons, 1 output, squared error
let X0 = [2, 1]
let W0 = [[1, 1], [1, -1]]
let C0 = [2, -1]
let Y0 = 3.5
# forward pass: [scores, hidden values, output, loss]
let forward = |x, w, c, y| {
    let z = map(w, |r| r[0] * x[0] + r[1] * x[1])
    let h = map(z, relu)
    let out = c[0] * h[0] + c[1] * h[1]
    return [z, h, out, (out - y) ^ 2]
}
# backward pass: [dL/dout, dL/dh, dL/dz, dL/dw, dL/dc, dL/dx]
let backward = |x, w, c, y| {
    let f = forward(x, w, c, y)
    let dy = 2 * (f[2] - y)
    let dh = map(c, |v| v * dy)
    let dz = map([0, 1], |j| dh[j] * [0, 1][f[0][j] > 0])
    return [
        dy, dh, dz,
        map(dz, |d| [d * x[0], d * x[1]]),
        map(f[1], |v| v * dy),
        map([0, 1], |k| w[0][k] * dz[0] + w[1][k] * dz[1])
    ]
}
let F0 = forward(X0, W0, C0, Y0)
let B0 = backward(X0, W0, C0, Y0)
# the six weights in a fixed order: w11, w12, w21, w22, c1, c2
let WVALS = [W0[0][0], W0[0][1], W0[1][0], W0[1][1], C0[0], C0[1]]
let WGRADS = [B0[3][0][0], B0[3][0][1], B0[3][1][0], B0[3][1][1], B0[4][0], B0[4][1]]
let WNAMES = ["w_{11}", "w_{12}", "w_{21}", "w_{22}", "c_1", "c_2"]
# the loss with weight i raised by eps
let loss_nudged = |i, eps| {
    let v = map(range(0, 6), |k| WVALS[k] + eps * [0, 1][k == i])
    return forward(X0, [[v[0], v[1]], [v[2], v[3]]], [v[4], v[5]], Y0)[3]
}
let fmt = |v, p = 2| to_string(v, [p, 0][abs(v - round(v)) < 0.00001])

# the network drawn as a graph
let IN = [[-3.2, 0.95, 0], [-3.2, -0.95, 0]]
let HD = [[-0.6, 0.95, 0], [-0.6, -0.95, 0]]
let OUT = [1.3, 0, 0]
let LOSS = [3.05, 0, 0]
let RAD = 0.36
let Node = |pos, tex, col| [Disc(pos, RAD, col, 0.14, 3.5), center{pos} Math(tex, 0.85, INK)]
let tail = |a, b| a + (RAD + 0.05) * normalize(b - a)
let head = |a, b| b - (RAD + 0.09) * normalize(b - a)
let Edge = |e, col = SLATE, w = 2.5| Tip(tail(e[0], e[1]), head(e[0], e[1]), col, w, 0.14)
# edges in the order of the weights, then the edge from the output to the loss
let EDGES = [[IN[0], HD[0]], [IN[1], HD[0]], [IN[0], HD[1]], [IN[1], HD[1]], [HD[0], OUT], [HD[1], OUT], [OUT, LOSS]]
# where each weight is written, and where its slope goes
let WPOS = [[-2.15, 1.22, 0], [-2.4, -0.55, 0], [-2.4, 0.55, 0], [-2.15, -1.22, 0], [0.3, 0.87, 0], [0.3, -0.87, 0]]
let GPOS = [[-1.5, 1.22, 0], [-1.9, -0.55, 0], [-1.9, 0.55, 0], [-1.5, -1.22, 0], [0.85, 0.87, 0], [0.85, -0.87, 0]]
let Net = [
    map(EDGES, |e| Edge(e)),
    Node(IN[0], "x_1", SLATE), Node(IN[1], "x_2", SLATE),
    Node(HD[0], "h_1", SLATE), Node(HD[1], "h_2", SLATE),
    Node(OUT, "\hat{y}", SLATE), Node(LOSS, "L", ROSE),
    center{[2.2, -0.38, 0]} Math(str_join(["\text{target } ", to_string(Y0, 1)]), 0.74, SLATE)
]
let Weights = map(range(0, 6), |i| center{WPOS[i]} Math(fmt(WVALS[i]), 0.8, INK))
let Above = |pos, v, col = AZURE, p = 2| center{[pos[0], pos[1] + 0.64, 0]} Math(fmt(v, p), 0.82, col)
let Below = |pos, tex, col = EMBER| center{[pos[0], pos[1] - 0.64, 0]} Math(tex, 0.82, col)
let WGrad = |i| center{GPOS[i]} Math(fmt(WGRADS[i]), 0.8, EMBER)
let VIn = [Above(IN[0], X0[0]), Above(IN[1], X0[1])]
let VHid = [Above(HD[0], F0[1][0]), Above(HD[1], F0[1][1])]
let VOut = Above(OUT, F0[2])
let VLoss = Above(LOSS, F0[3])
let Legend = [
    center{[-2.3, -2.0, 0]} Cap("value", 0.74, AZURE),
    center{[-0.4, -2.0, 0]} Cap("weight", 0.74, INK),
    center{[2.0, -2.0, 0]} Cap("slope of the loss", 0.74, EMBER)
]
# dots travelling along the listed edges; rev = 1 sends them backward
let Flow = |t, ids, col, rev = 0| map(ids, |i| {
    let a = tail(EDGES[i][0], EDGES[i][1])
    let b = head(EDGES[i][0], EDGES[i][1])
    return Pt(lerp([a, b][rev], [b, a][rev], t), col, 0.08)
})

# training on XOR: q = [w11, w12, w21, w22, b1, b2, c1, c2, c0]
let XOR = [[0, 0, 0], [1, 0, 1], [0, 1, 1], [1, 1, 0]]
# one forward and one backward pass on the point p: [output, gradient of that point's squared error]
let xor_pass = |q, p| {
    let z = [q[0] * p[0] + q[1] * p[1] + q[4], q[2] * p[0] + q[3] * p[1] + q[5]]
    let h = map(z, relu)
    let out = q[6] * h[0] + q[7] * h[1] + q[8]
    let dy = 2 * (out - p[2])
    let dz = map([0, 1], |j| dy * q[6 + j] * [0, 1][z[j] > 0])
    return [out, [dz[0] * p[0], dz[0] * p[1], dz[1] * p[0], dz[1] * p[1], dz[0], dz[1], dy * h[0], dy * h[1], dy]]
}
# n steps of gradient descent on the mean loss; returns [loss, outputs] before each step
let train_xor = |q0, eta, n| {
    var q = q0
    var hist = []
    for (s in range(0, n + 1)) {
        let cur = q
        let passes = map(XOR, |p| xor_pass(cur, p))
        let outs = map(passes, |r| r[0])
        let rec = [mean(map(range(0, 4), |i| (outs[i] - XOR[i][2]) ^ 2)), outs]
        hist .= rec
        q = map(range(0, 9), |i| cur[i] - eta * mean(map(passes, |r| r[1][i])))
    }
    return hist
}
let PlotFrame = |fr, xs, ys, xname, yname, places = 0, ydx = 0.3| [
    GridLines(fr, xs, ys),
    Seg(at(fr, fr[0], fr[2]), at(fr, fr[1], fr[2]), SLATE, 2.5),
    Seg(at(fr, fr[0], fr[2]), at(fr, fr[0], fr[3]), SLATE, 2.5),
    map(xs, |v| center{[fx(fr, v), fy(fr, fr[2]) - 0.3, 0]} Math(to_string(v, 0), 0.72, SLATE)),
    map(ys, |v| center{[fx(fr, fr[0]) - 0.4, fy(fr, v), 0]} Math(to_string(v, places), 0.72, SLATE)),
    center{[fx(fr, (fr[0] + fr[1]) / 2), fy(fr, fr[2]) - 0.66, 0]} Note(xname, 0.74),
    center{[fx(fr, fr[0]) + ydx, fy(fr, fr[3]) + 0.28, 0]} Note(yname, 0.74)
]