Know arithmetic? Then you can train a neural network
Using all 100 products from 0×0 to 9×9, build a minimal neural network from scratch and train it by hand to a perfect score: first with the dumb method that uses nothing but arithmetic, then backpropagation and why it is hundreds of times faster.
Part one of the “times table” series; part two, What Attention Actually Does, uses the model trained here as its reference object.
The whole article follows one causal chain:
problem (a,b) → 20-cell input x → 24 cells z → GELU output h → 82 scores u → probabilities p → penalty L a parameter moves slightly → L changes → gradient → parameter moves back → the next prediction changes
Every new name is introduced by stating which existing numbers produce it, its shape, and what the next step consumes. Getting 100/100 proves only that this model fits these 100 training examples; it does not by itself prove multiplication on unseen numbers.
The classic approach: an MLP that learns all 100 products from 0 to 9
An MLP (multilayer perceptron) is the most basic kind of neural network: the input is laid out all at once and computed forward layer by layer, with no branching anywhere. MLPs remain one of the basic building blocks of deep learning — including the FFN inside every Transformer block.
00The task
Input two single digits a and b, output a×b.
The model contains no concept of “multiplication” — only a pile of weights and weighted sums. The times table has to be learned from 100 examples.
The 0–9 table was chosen because it is small enough to draw every single wire: 100 data points, a couple thousand parameters. Every figure below is computed live off the real model — no illustrations.
01Structure and inference
Three layers: 20 input dims on the left — each digit takes 10 slots, only its own slot
is 1 and the rest are 0 (this is called one-hot);
24 hidden units in the middle;
82 output dims on the right — 0×0=0 through 9×9=81,
every possible answer in the table; whichever scores highest is the answer.
Every wire carries a number, called a weight. A hidden unit's value =
each of the 20 inputs times its own wire's weight, summed, plus a bias
(one constant per unit). Writing all 24 units together gives
z = W1·x + b1; one more such step to the output layer gives u = W2·h + b2.
So this model has exactly four blocks to train: two matrices and two biases. How many numbers each holds, and the total:
What W1 looks like
Matrix multiplication collapses here into “take two columns and add them”. x is one-hot: 18 of the 20 terms are multiplied by 0.
Therefore: column a of W1 — those 24 numbers — is the model's entire representation of the digit a. Doing a problem = adding the two digits' representations.
Note the pathway is static: a travels down column a because of which dimension it sits in, regardless of what digit it is.
The nonlinearity in the middle
z cannot be passed on as is; each dimension must first go through an activation function (a nonlinear function applied to a single number). Here it is GELU (Gaussian Error Linear Unit). The formula is one line:
GELU(t) = 0.5 · t · ( 1 + tanh( 0.7978845608 · ( t + 0.044715 · t³ ) ) )
It contains only three things:
t³is justt · t · t;tanhis an off-the-shelf function whose job is to squash any number into −1 to +1: large numbers approach +1, very negative ones approach −1, and near 0 it passes through roughly unchanged. So the1 + tanh(…)outside the parentheses lands between 0 and 2, and multiplying by0.5makes it a switch between 0 and 1;0.7978845608and0.044715are two hard-coded constants (the first is√(2÷π)), not trained — they are not among the 2,554 parameters counted later.
Read together: GELU(t) = t × a switch between 0 and 1, and the size of that switch is
decided by t itself. The larger t is, the closer the switch is to 1
(passes through unchanged); the more negative, the closer to 0 (squashed). Drag the slider:
What W2 looks like
After the nonlinearity we have h; multiply by the second matrix to get scores for the 82 candidate answers:
“Collapsing to two columns” is a special privilege of one-hot input, not a general law of matrix multiplication — h is not one-hot, and not one of its 24 terms can be skipped.
The complete inference code
Chain those steps together and one inference is these six lines. The code on the left can be edited directly, and the right shows the numbers this very line computed — single-step through it, or break something and watch how it fails.
The verdict at the end is wrong: the parameters are still random, so it can only guess. After section 02 trains it, come back here and the same spot will read 56.
Six “why is it built this way” — the designers' view
Section 01 walked past six unexplained decisions. Here is the reasoning for each:
Why one-hot instead of feeding 7 directly? — the model can only multiply and add.
Feed the raw number and relations like “7 is bigger than 3, adjacent to 8” get forced into every expression,
yet in the times table the answers to 7×8 and 6×8 are in no sense “adjacent”
⇒ one-hot gives each digit its own private column of parameters, mutually unentangled — the digit becomes a lookup key, not a magnitude
Why the middle layer? — first see what happens without the nonlinearity (associativity, two lines):
u = W2·( W1·x ) = ( W2·W1 )·x # multiply the two tables into one: two layers collapse into one
⇒ without GELU the middle layer adds nothing; the gate has to exist first for the middle layer to exist — what the gate buys is disproved on the spot in part two's FFN section
Why 24 hidden units? — a knob found by trial, same category as the learning rate; no mystical meaning
Why does each unit also get a b? — z = sum of two columns + b: without b, the gate's bend is welded to the origin;
b is each gate's learnable threshold, moving the “squash zone” to wherever is useful for this task
Why random initialization instead of all zeros? — at all-zero, the 24 units have identical expressions and identical inputs
⇒ identical forward outputs ⇒ identical slopes ⇒ still identical after the step ⇒ 24 copies forever, equivalent to one unit
The sole purpose of the random numbers: to break the tie, giving specialization a chance to grow
Why 82 outputs to “pick one from”, instead of just emitting the number 56? — emit one number and the penalty can only be “how far off”:
guessing 55 for 56 costs 1, guessing 12 costs 44 — but in a times table being wrong by 1 and by 44 are equally wrong;
worse, the whole “composed for training” property of softmax + −ln disappears (the core of section 02)
⇒ with 82 candidates each holding a slot, “correct” has one definite slot, and the −ln(py) move becomes available
02Training
At the end of the last section the model was wrong: for 7 × 8 it answered 62. This section
trains it right — changing only those 2,554 numbers, not one wire.
The hard part isn't this one problem; it's getting all 100 right at the same time.
The 100 problems pull against each other: 7×8 wants to drag column 7 of W1 this way,
7×3 wants to drag it that way.
Pin down one parameter: how 100 problems jointly decide its direction
Choose W1[k,7]: the one weight used by hidden unit k when the first digit is 7.
Because x is one-hot, only 7×0…7×9 use it; the other 90 problems give it an exactly zero per-example gradient.
responsibility from problem b: ∂L7×b/∂W1k,7 = dzk(7,b) × x7 = dzk(7,b) gradient of the 100-problem mean: ∂L/∂W1k,7 = [ dzk(7,0) + dzk(7,1) + … + dzk(7,9) ] ÷ 100 actual update: W1k,7 ← W1k,7 − learning_rate × ∂L/∂W1k,7
There is no metaphor in “training moves toward a reasonable direction” here: if most of those ten gradients have the same sign, they reinforce one another; positive and negative ones cancel in the sum. The optimizer hears only the total — it does not know which problem is more reasonable. A reusable pattern is precisely a parameter direction that receives compatible updates across many examples.
The denominator remains 100 because L was defined as the mean of all 100 problems; the 90 zeros still belong to that mean. With a sampled batch, it becomes the batch size.
①Run it once: get p
Section 01 covered this step; only the last line p = softmax(u) remains.
u is the model's score for each of the 82 candidates — it can be positive or negative with no
bounds, so it cannot be read as “confidence”. The 82 candidates are numbered (candidate 0 means answer 0,
… candidate 81 means 81), so:
c = any one candidate's index (0…81) # a placeholder index; “holds for any c” means it holds for all 82 uc = the score candidate c received pc = candidate c's probability pc = e^uc ÷ (e^u₀ + e^u₁ + … + e^u₈₁) y = the correct answer's index # here y = 56, since 7×8=56 py = the probability the model gives the correct answer # here p₅₆ = 0.0041 — it has barely considered it
Each score first becomes e to that power (always positive), then is divided by the total of all 82
(so they sum to exactly 1). 82 non-negative numbers summing to 1 — that is p.
②Measure how far off: loss
For the model to improve itself, we first need one number stating “how bad it is right now”.
Using py directly is awkward in two ways: convention wants a penalty where
smaller is better; and py dropping 0.50 → 0.49 versus 0.02 → 0.01
both look like “down 0.01”, though the latter is halving the probability.
Taking the logarithm and then a negative sign fixes both at once:
ln x answers one question: e to what power equals x.
(e ≈ 2.718; ln = log base e, the natural logarithm — not the base-10 default of school.
All math here writes ln; but code writes log — numpy's and PyTorch's log is ln.)
ln(1) = 0 because e to the 0 = 1
ln(0.1) = −2.30 ln(0.01) = −4.61
every order of magnitude the probability drops adds a fixed 2.30
Probabilities never exceed 1, so ln is always negative or 0 — put a minus in front and the penalty becomes positive. That is where the minus in −ln comes from.
penalty for one problem L = −ln( py ) # 100% → 0; 10% → 2.30; 1% → 4.61; approaching 0 → to infinity
100 problems fused into one number L = ( L0×0 + L0×1 + … + L9×9 ) ÷ 100
That “÷ 100” is bigger than it looks. A hundred requirements cannot be reconciled one by one; a single number, though, only has high and low — you always know which way to go. The “pulling against each other” from the opening is solved right here.
③Work out which way each parameter should move: the gradient
This is the only difficult step. The whole section does one thing: break “which way should L move” into 2,554 questions of “should this one number go up or down”.
First get clear: L is a function of what?
To differentiate, you must know with respect to what. Trace L's origins backwards:
L ← the average penalty over 100 problems ← each problem's p # computed from W1 b1 W2 b2 and this problem's two digits ← the problem (a, b) and answer a×b # 7×8=56 is always 56; immovable ← W1 b1 W2 b2 # ← only these can move W1 24 × 20 = 480 b1 24 = 24 W2 82 × 24 = 1,968 b2 82 = 82 ⇒ 2,554 turnable numbers in total
Problems and answers are given constants; x, z, h, u, p are computed
intermediates and cannot be set directly. The only knobs on the whole chain are the numbers in
those 4 tables. So L is a function that eats 2,554 numbers and emits 1.
Write it out in full and it is section 01's six lines
x = onehot2(a, b) # the problem spread over 20 numbers: slot a and slot 10+b are 1 z = matvec(W1, x, b1) # 24 numbers uses 48 of W1's + all 24 of b1 h = gelu(z) # 24 numbers 0 parameters u = matvec(W2, h, b2) # 82 numbers uses all 1,968 of W2 + all 82 of b2 p = softmax(u) # 82 numbers 0 parameters L = −ln( p[a×b] ) # ← only this line is new
The first five lines are unchanged word for word; line 6 was swapped: there it was
answer = argmax(p), picking the highest probability as the answer — that is inference;
here we take the probability at the correct answer's slot and compute a penalty — that is training.
One and the same forward pass; afterwards one use answers, the other scores.
Substituting py's definition (division becomes subtraction inside ln, and
ln(e^uy) is just uy), then averaging the 100 problems,
the whole of L written out is these three lines:
L = 1/100 · Σa=0…9 Σb=0…9 [ −ua·b + ln( e^u₀ + e^u₁ + … + e^u₈₁ ) ]
where uc = Σk=0…23 W2c,k · GELU( W1k,a + W1k,10+b + b1k ) + b2c
where GELU(t) = 0.5·t·( 1 + tanh( 0.7978845608·( t + 0.044715·t³ ) ) )
Σ just means “add up what follows”: two nested Σ = a and b each run 0–9, one pass per each of the 100 problems
That's all of it; there is nothing else. The letters in the formula come in only two kinds:
① W1 b1 W2 b2 — the 2,554 turnable numbers;
② a, b — the problem, constants.
Everything else is add, multiply, tanh, powers of e, ln.
Underneath the words “deep learning model” sits exactly this one expression. What training does, in one sentence: find a set of w₁…w₂₅₅₄ that makes it smallest.
Nudge a little, measure the ratio — that's how a slope is measured
2,554 unknowns is unthinkable, so retreat to a single unknown: that parabola everyone has
seen, y = x². At x = 3, nudge x up by 0.01 and y goes from 9.0000 to 9.0601 —
the rise ÷ the nudge = 6.01; nudge 0.001 and you get 6.001; nudge 0.0001 and you get 6.0001.
That increasingly steady number 6 is the slope of y = x² at
x = 3, also called the derivative. It says exactly one thing:
nudge x a little here and y rises 6 times that little.
Back to the 2,554 unknowns. Same method, only hold the other 2,553 still first — with them fixed, L is left as a single-variable curve in this one parameter, and you can nudge and measure the slope just the same.
That is the entire meaning of the word “partial”: nudge one, hold the rest.
A slope measured this way is a partial derivative, written ∂L/∂w —
read as “nudge w a little, L rises how many times that little”.
Slope positive → turn it down; negative → turn it up. Go against the slope. That one sentence is the entire action of training.
Ask all 2,554, take one step — and really train it right
Asking once per parameter gives one slope; asking all 2,554 gives 2,554 slopes, lined up in a column that is called the gradient. It points the direction of steepest increase in L, so we go the opposite way:
wi ← wi − learning rate × gi # gi is this parameter's partial derivative; the learning rate is a knob you set — more in ④
The direction hides in g's own sign: g positive, subtract a positive, it goes down automatically; g negative, subtract a negative, it goes up. One formula, no conditionals. The rest is just repetition: measure all 2,554 partials → everyone takes a small step → measure again. No chain rule, no backpropagation, no matrices needed.
Let's weld the notation first — above uses the math forms wi,
gi; in code they have other names.
Every code pane on this page uses only this one set, with no further renaming:
math form name in the code (exactly as written in lab.en.js) ──────────────────────────────────────────────────────────── w₁ … w₂₅₅₄ the 2,554 numbers → ps = [W1, b1, W2, b2] # four tables; flattened, that's the 2,554 (line 122) wi's current value → p.d[i] # d = data, the number itself wi's slope gi → p.g[i] # g = grad, a block the same size as p.d, just for slopes “run through all 2,554” → each((p, i) => …) # two nested for loops, written once, reused below L (average penalty over 100) → averagePenalty() # called fullLoss() in lab.en.js learning rate → the 5 hard-coded in the code
So the dumb method written out in full is 9 lines:
Why does everyone moving on their own bring the whole thing down to 100/100? — Because the causality is inverted: it isn't that the training method happens to suit L; L was composed for this training method from the start. That is the core of deep learning.
Training has exactly one move: follow the slope, everyone takes a small step. Look back at every design decision in L — all of them serve that move: softmax turns scores into penalizable probabilities; −ln punishes wilder errors harder while staying smooth everywhere, with a floor; averaging 100 compresses a hundred requirements into one number. “It bottoms out at exactly 100/100” you just measured by hand above; “why it must go down” is two lines below.
That sentence, “the causality is inverted”, can be computed in two lines. Every gi
is defined as “hold the others still, move only this one”, yet the code moves all 2,554
at once — what stops them from working against each other?
Add up the total change. Each parameter moved by Δwi = −learning rate × gi,
and each changes L by gi × Δwi. As long as every step is small enough,
the total change is just their sum:
ΔL ≈ g₁·Δw₁ + g₂·Δw₂ + … + g₂₅₅₄·Δw₂₅₅₄ = −learning rate × ( g₁² + g₂² + … + g₂₅₅₄² ) = −learning rate × Σg²
Look inside the parentheses: all squares, every term ≥ 0, so the whole sum is non-negative, and with the minus in front → ΔL is always ≤ 0; the loss must go down.
That is why nothing pops up elsewhere: every parameter contributes gi², all of them helping, none able to cancel another. Move in any other direction and those squares turn into a mix of signs that really can cancel — which is exactly why it must be the negative gradient direction.
This proof asks only one thing of L: smoothness — a slope everywhere. Our L is built from add, multiply, tanh, powers of e, and ln, smooth everywhere, so it qualifies. Not a coincidence — as said above: L was composed to fit this proof.
The dumb method closes the loop here: it really did train it right. And the price is visible too — every single step reruns the full dataset 2,555 times.
Do the accounting first: where the dumb method is slow
The root of the slowness is waste: the intermediates z, h, u computed while
measuring parameter 1 are thrown away and recomputed identically while measuring parameter 2 —
the same thing torn down and rebuilt 2,554 times.
Saving that waste is the entire source of the speedup.
One rule all the way down: the chain rule
Backpropagation has to achieve this: one pass over the data, all 2,554 slopes at once. It relies on a single rule. First see the dependencies clearly — it's one chain:
Only 4 gradients are actually wanted: ∂L/∂W1, ∂L/∂b1, ∂L/∂W2, ∂L/∂b2 — only they are parameters.
∂L/∂u, ∂L/∂h, ∂L/∂z are just passing through: computing them isn't the goal; it's how the error gets carried from the right to the left, then discarded. ∂L/∂x need not be computed at all — x is the input, unchangeable.
Chain rule: if A affects C through B, then “A's effect on C” = “A's effect on B” × “B's effect on C”. Nudge A a little, B moves with it, C moves after that — two effects multiplied.
Follow that chain from right to left:
∂L/∂u = computed directly # this is line 1 of the five ∂L/∂W2 = ∂L/∂u × ∂u/∂W2 # upstream × this layer's local derivative ∂L/∂h = ∂L/∂u × ∂u/∂h ∂L/∂z = ∂L/∂h × ∂h/∂z # swap in the new upstream and continue ∂L/∂W1 = ∂L/∂z × ∂z/∂W1 ∂L/∂b1 = ∂L/∂z × ∂z/∂b1
Look at the bold parts: each line's first factor is what the previous line just computed. That is the entire meaning of the word “backpropagation”: walk backwards from L, reusing the previous step's result at every step, computing only the one new local derivative of this layer. Without the reuse, every parameter would have to be pushed all the way from L — which is the dumb method.
Derive the five lines from the definition of the partial derivative
The whole derivation uses one formula — the definition of the partial derivative:
nudge x up by ε and look at the ratio: ( f(x+ε) − f(x) ) ÷ ε
the derivative ∂f/∂x = the number that ratio settles on as ε shrinks to 0
Apply it to the parabola above: f(x) = x², with letters throughout, no numbers substituted:
( (x+ε)² − x² ) ÷ ε = ( x² + 2xε + ε² − x² ) ÷ ε = ( 2xε + ε² ) ÷ ε = 2x + ε
ε shrinks to 0 ⇒ the derivative of x² = 2x # a new function holding for every x; put x=3 and get 6 — exactly the 6.01, 6.001 measured earlier
Following it is three steps: replace w with w+ε → subtract the two expressions (terms without w are identical and cancel completely) → divide by ε and shrink ε to 0. All five lines are those three steps.
When several quantities are nudged at once, their contributions add — the reason: moving them one at a time, the second move lands at a position “already moved by the first”, and the deviation that introduces is of order ε×ε; divide by ε and one ε remains, which vanishes as it shrinks to 0.
The five lines below are derived for one problem.
The L used is that one problem's penalty −uy + ln S —
no 1/100 and none of those Σ. Why is that enough? Back to the definition, two lines:
( (f+g)(w+ε) − (f+g)(w) ) ÷ ε = ( f(w+ε)−f(w) )÷ε + ( g(w+ε)−g(w) )÷ε # just expand it
ε shrinks to 0 ⇒ the derivative of a sum = the sum of derivatives; likewise (c·f)' = c × f'
⇒ ∂/∂w [ ( L0×0 + L0×1 + … + L9×9 ) ÷ 100 ] = ( ∂L0×0/∂w + … + ∂L9×9/∂w ) ÷ 100
So: derive each problem separately, add the 100 slopes, divide by 100 — that is the real L's slope.
In code those are the two lines W2.g += … (accumulate) and p.g[i] /= 100 (average) —
both methods train on all 100 problems, not one skipped.
Two notations to establish; all five lines depend on them:
W2i,j = in W2, that table of 82 rows × 24 columns, the single number at row i, column j
ui = the i-th of u's 82 numbers (same for hj, zi, xj)
onehot(y) = a row of 82 slots where only slot y is 1 and the rest are 0 # y = the correct answer's index, set up in ①
Line 1 du = p − onehot(y) 82 numbers
Three tools first — all of them just counting “how many copies were multiplied”, with one restriction: the base must be positive (roots of negatives get strange; everything here is positive):
Rule 1 x³·x² = (x·x·x)·(x·x) = x⁵ # same base multiplied = exponents added ⇒ e^a·e^b = e^(a+b)
Rule 2 (x³)² = (x·x·x)·(x·x·x) = x⁶ # a power of a power = exponents multiplied ⇒ (e^p)ⁿ = e^(np)
e ≝ ( 1 + 1/n )^n n=1 → 2 n=10 → 2.59374… n=1000 → 2.71692… ⇒ e = 2.71828…
ln is the reverse lookup of e^: in the table e^y = w, going from y to w is e^, going from w to y is ln.
ln e = 1 # e¹ = e, so looking back gives 1
The two rules translated to the ln side (write a = e^p, b = e^q, i.e. p = ln a, q = ln b):
ln(a/b) = ln a − ln b # a/b = e^p ÷ e^q = e^(p−q) (Rule 1)
ln(xⁿ) = n·ln x # xⁿ = (e^p)ⁿ = e^(np) (Rule 2)
Lemma 1 the derivative of ln w = 1/w by the definition, three steps: ( ln(w+ε) − ln w ) ÷ ε = ln( (w+ε)/w ) ÷ ε = ln( 1 + ε/w ) ÷ ε # division becomes subtraction write n = w/ε, i.e. ε = w/n (ε shrinking to 0 ⇔ n growing): = (n/w)·ln( 1 + 1/n ) = (1/w) · ln( (1+1/n)ⁿ ) # the coefficient moves into the exponent as n grows, (1+1/n)ⁿ closes in on e ⇒ that ln closes in on ln e = 1 # this step borrows “the table is continuous”: nudge the looked-up number a little and the result moves only a little ⇒ the derivative of ln w = 1/w # check w=3, ε=0.001: n=3000, (1+1/n)ⁿ=2.71783, its ln=0.99983, whole ratio=0.333278 → 1/3 ✓
The other half of the table comes free — same step, swap the axes, and the ratio flips upside down:
Lemma 2 the derivative of e^t = e^t # t is any point; later substituted with uc The same step: t → t+δ w = e^t → w+ε # i.e. t = ln w, t+δ = ln(w+ε); one end shrinking to 0 drags the other with it ln side (nudge w, watch t): ( ln(w+ε) − ln w ) ÷ ε = δ ÷ ε → 1/w # Lemma 1 e^ side (nudge t, watch w): ( e^(t+δ) − e^t ) ÷ δ = ε ÷ δ = 1 ÷ (δ÷ε) # same pair of numbers, flipped ⇒ the derivative of e^t = w = e^t # check t=ln3, δ=0.001: ε÷δ=3.001500 → 3 ✓
To prove: ∂L/∂uc = pc − onehot(y)c L(u) = −uy + ln S, where S = e^u₀ + … + e^u₈₁ # the form derived in “L is a function of what” By the definition: replace uc with uc+ε (the other 81 unchanged), subtract, divide by ε. First term −uy, subtracted: c = y: ( −(uy+ε) ) − ( −uy ) = −ε ÷ ε = −1 c ≠ y: uc does not appear, so the expression is identical before and after ⇒ difference = 0 ÷ ε = 0 Second term ln S — first the change in S; subtract the 82 terms one by one, only e^uc changed: ΔS = e^(uc+ε) − e^uc ΔS ÷ ε → e^uc # exactly Lemma 2's ratio with t = uc; as ε shrinks to 0, ΔS shrinks with it Now the change in ln — split into two ratios multiplied (the chain-rule move): ( ln(S+ΔS) − ln S ) ÷ ε = ( ln(S+ΔS) − ln S ) ÷ ΔS × ΔS ÷ ε ε shrinks to 0: the first settles at 1/S (Lemma 1 with w replaced by S), the second at e^uc ⇒ ∂(ln S)/∂uc = (1/S) × e^uc = e^uc/S = pc # which is exactly softmax's definition Add the two terms: c = y: ∂L/∂uy = py − 1 c ≠ y: ∂L/∂uc = pc 82 of them as a vector: du = p − onehot(y) 82 numbers ✓ QED
Line 2 dW2 = du ⊗ h , db2 = du 82×24 + 82
To prove: ∂L/∂W2i,j = dui × hj
First lay out row i of u in full (one row of section 01's matrix multiply: 24 multiplications plus one bias):
ui = W2i,0·h0 + W2i,1·h1 + … + W2i,j·hj + … + W2i,23·h23 + b2i
Replace W2i,j with W2i,j+ε; h is computed from W1 and x and does not budge.
How much does u change — subtract the 82 rows one by one (the … below are those terms without W2i,j):
row i: ( … + (W2i,j+ε)·hj + … ) − ( … + W2i,j·hj + … ) = ε·hj # every other term is identical and cancels
the other 81 rows: W2i,j does not appear ⇒ difference = 0
How much does L change: in all of u only ui moved, by ε·hj
Line 1 already proved dui = “per unit ui moves, L moves this much”:
ΔL = dui × ε·hj
÷ ε, ε shrinks to 0 ⇒ ∂L/∂W2i,j = dui·hj QED
Put the 1,968 cells back in the table: row i, column j holds dui·hj — written dW2 = du ⊗ h (outer product) 82×24 ✓
b2i the same way: Δui = ε ⇒ ΔL = dui·ε ⇒ db2 = du
Line 3 dh = W2ᵀ · du 24 numbers
To prove: ∂L/∂hj = Σi dui·W2i,j — in matrix form, W2ᵀ·du Replace hj with hj+ε. How much does u change — subtract the 82 rows one by one: u₀ : ( … + W20,j·(hj+ε) + … ) − ( … + W20,j·hj + … ) = W20,j·ε u₁ : same way = W21,j·ε ⋮ # this time every row contains hj — all 82 moved u₈₁: same way = W281,j·ε How much does L change: 82 u's each moved a little at once, contributions add (that rule from the definition): ΔL = du₀·W20,j·ε + du₁·W21,j·ε + … + du₈₁·W281,j·ε ÷ ε, ε shrinks to 0 ⇒ ∂L/∂hj = du₀·W20,j + … + du₈₁·W281,j = take W2's column j and du, multiply position by position and add QED The 24 j's each use a column; but matrix multiplication always takes rows ⇒ flip W2 over, rows for columns: dh = W2ᵀ·du 24 ✓ # the transpose isn't a convention; it is forced by “hj feeds all 82 u's at once”
Line 4 dz = dh ⊙ GELU′(z) 24 numbers
To prove: ∂L/∂zj = dhj × GELU′(zj) Replace zj with zj+ε. How much does h change: GELU works dimension by dimension, so only hj moves: Δhj = GELU(zj+ε) − GELU(zj) = GELU′(zj)·ε # GELU′ is defined as exactly this ratio — that curve's slope How much does L change: only hj moved: ΔL = dhj × GELU′(zj)·ε ÷ ε, ε shrinks to 0 ⇒ ∂L/∂zj = dhj·GELU′(zj) QED All 24 dims mind their own ⇒ dz = dh ⊙ GELU′(z) (⊙ = position-by-position product) 24 ✓ # when zj is very negative the gate's slope ≈ 0: the forward pass let nothing through, so no upstream slope gets through either
Line 5 dW1 = dz ⊗ x , db1 = dz 24×20 + 24
To prove: ∂L/∂W1i,j = dzi × xj — exactly the same derivation as line 2 Replace W1i,j with W1i,j+ε. Subtract z row by row: row i: ( … + (W1i,j+ε)·xj + … ) − ( … + W1i,j·xj + … ) = ε·xj the other 23 rows: no W1i,j ⇒ difference = 0 How much does L change: only zi moved, by ε·xj ⇒ ΔL = dzi × ε·xj ÷ ε, ε shrinks to 0 ⇒ ∂L/∂W1i,j = dzi·xj ⇒ dW1 = dz ⊗ x 24×20 ✓ QED b1 the same way: Δzi = ε ⇒ db1 = dz Bonus: x is one-hot — 18 of its 20 slots are 0 xj = 0 ⇒ every cell of that column = dzi·0 = 0 — the whole column's gradient is zero ⇒ one problem a×b only changes columns a and 10+b — section 01's “welded wiring”, proved right here
Adding db1 = dz, at this point all
2,554 gradients are complete — one forward pass, one backward pass.
Look back at the bill from “crank it by hand”: the dumb method needs 2,554 reruns of all 100 problems to fill in 2,554 partials; these five lines do the same job in a single pass. What they compute is the same batch of numbers.
Into the loop: the saved waste is one variable
Same starting point and same stepping rule as cranking by hand; only the slopes now come from these five lines. The code gains two names, and they are exactly that discarded intermediate from the accounting section:
Notation · part two (part one is in the “crank it by hand” section):
as written in the five lines name in the code
────────────────────────────────────────────────────────
the 5 forward lines fwd(a, b) computes, then hands back the intermediates in a bundle
that bundle of intermediates act = { z, h, u, p }
the 5 backward lines bwd(act)
dW2 db2 dW1 db1 (the wanted) W2.g b2.g W1.g b1.g # no new variables; they land straight in .g
du dh dz (just passing) temporaries, discarded after use # as the chain-rule section said: carriers, not the goal
correct answer index y a×b
Why W2.g += … and not dW2 = …?
Because L is the average over 100 problems: each problem computes its own dW2 and accumulates
into the same W2.g, and after all 100 it is divided by 100.
That plus sign is the first half of “taking the average”.
Each intermediate the five lines need is in act: line 1 takes act.p, line 2 act.h,
line 4 act.z (u was consumed by softmax and isn't needed backwards).
“Saving that waste”, in code, is this one variable act.
Both beats end here: the two routes compute the same batch of slopes; the difference is only speed. One of the four things remains — how far to move.
④Take a small step: learning rate and Adam
The gradient says which way, not how far. That “how far” is the learning rate — it appears in both beats' code: hard-coded as 5 in the dumb method's 9 lines, and 5 again in backprop's 13. Too small and it can't move; too large and it overshoots (the “as long as every step is small enough” in beat one's proof is exactly its price). Same model, three learning rates, one training run each:
One learning rate has to serve 2,554 parameters whose gradients differ by orders of magnitude. Adam patches exactly that: accumulate momentum to cancel jitter, then normalize each direction by its own historical magnitude — effectively giving every parameter its own learning rate. At heart it is still a small step down the negative gradient; only the step size is no longer one-size-fits-all.
What gradient descent guarantees, and what it doesn't
ΔL ≤ 0 only says “the next step is lower”; it never says you reach the lowest point.
Gradient descent uses only information at the current point, start to finish. So where does it stop?
Retrain from a different random start and find out — if there were only one lowest place,
every starting point should slide into the same bottom:
Same score, completely different parameters. Combinations that get all 100 right are plentiful, and gradient descent merely found a nearby one. It was never looking for “the optimum”; it looks for “one that will do”.
Not every step needs all 100: the batch
Up to here, every step has computed all 100 problems — L is defined as that average, and nothing was skipped: the dumb method reran 100 problems per parameter measured, and backprop ran all 100 forward and backward on every step.
But that's expensive. And that average can be estimated from a few samples — a bit rougher, with the direction broadly intact. The handful drawn is called a batch, and its size the batch size.
Final version: that loop above, with two changes
Nothing new. Take the loop you stepped through by hand in “into the loop”, bolt on Adam and the batch, and you have the training code everyone actually uses. Compare line by line: only two lines changed (the four things from the opening figure, ①②③④, marked on the right):
for (let step = 1; ; step++) {
each((p, i) => p.g[i] = 0) # zero the slopes
for (const [a, b] of this drawn batch) { ← change 1: was all 100; now a drawn batch
const act = fwd(a, b) # ① run it once
loss = −ln act.p[a×b] # ② measure how far off — for your eyes only; training doesn't use it
bwd(act) # ③ the five lines folded into one call; slopes accumulate into p.g
}
each((p, i) => p.g[i] /= this batch's size) # take the average
adam(ps, 0.02, step) ← change 2: was p.d[i] −= 5 × p.g[i]
}
That loss on line ②: the whole training does not need it.
L never appears in the five lines — line 1, du = act.p − onehot(a×b), is already the slopes' origin.
The source proves it: in lab.en.js, the function that computes every gradient
(gradOnly(), lines 182–191) contains just this.bwd(this.fwd(a, b)); —
it doesn't even take the return value; the loss that bwd computes in passing is thrown away,
and all 2,554 slopes are complete anyway.
What actually drives training has only ever been those 2,554 slopes.
Watch it train
This model runs in your own browser — no server, no pretrained weights. Click “train 1 step” to watch the loss move, then “train until all correct”; it converges in about 100 steps.
Once it's trained, scroll back to the figure in section 01. Not one cell of the structure changed;
only the 2,554 numbers were replaced — and the verdict goes from 62 ✗ to 56 ✓
at 100% confidence. The numbers in W1's column 7 are no longer noise; they are the representation of the
digit 7 that the model grew for itself.
And 7 travels down W1's column 7 because it was placed in dimension 7 — nothing to do with it being a 7. Put any digit in the first position and it walks the same wires. That pathway is welded at the factory.
Part two: where welded wiring gets stuck, and what that one extra step in a transformer actually does — What Attention Actually Does. The MLP trained to a perfect score here is the reference object over there.
Playable from the console: inside.mlp is this model,
inside.redraw() refreshes all figures.