Foundations to FrontierPart XI: The AI LabBezhig

AI01: Autograd from scratch: backpropagation is the chain rule

25 exercises, each with a complete solution. 12 figures. 14 code listings.

On this page
  1. Prerequisites and observable goals
  2. Warm-up: spaced review (10 minutes, closed book)
  3. 1. Quick summary
  4. 2. Deep explanation
  5. 2.1. The question autograd answers
  6. 2.2. Partial derivatives: freeze the others
  7. 2.3. The chain rule on a graph
  8. 2.4. Used twice? Add the gradients
  9. 2.5. The order of work: topological sort
  10. 2.6. Gradient descent: turning gradients into learning
  11. 2.7. Checking gradients with finite differences
  12. 2.8. Python for a C++ engineer: exactly what this lab uses
  13. 2.9. Design trade-offs: why this design, and what breaks at scale
  14. 2.10. Beginner bugs: a field guide
  15. 3. The lab: build it yourself
  16. 3.0. Setup and ground rules
  17. 3.1. Step 1: a Value that remembers where it came from (worked example)
  18. 3.2. Step 2: local backward functions (partly filled)
  19. 3.3. Step 3: backward() for the whole graph (on your own, from the recipe)
  20. 3.4. Step 4: break it on purpose
  21. 3.5. Step 5: the remaining operations (on your own)
  22. 3.6. Step 6: a gradient checker (on your own)
  23. 3.7. Step 7: neurons, layers, networks (partly filled)
  24. 3.8. Step 8: teach it XOR (on your own)
  25. 4. Practice, code reading, and the gate
  26. 4.1. Practice set
  27. 4.2. Code-reading drill: micrograd
  28. 4.3. Mastery gate
  29. 4.4. Next steps
  30. 5. Development log entry
  31. 6. Stretch (optional)
  32. Hint ladders for the lab
  33. L1. Step 1: a Value that remembers
  34. L2. Step 2: local backward functions
  35. L3. Step 3: backward()
  36. L4. Step 4: break it on purpose
  37. L5. Step 5: the remaining operations
  38. L6. Step 6: the gradient checker
  39. L7. Step 7: neurons, layers, networks
  40. L8. Step 8: training
  41. Complete solutions: AI01
  42. Retention and readiness

Prerequisites and observable goals#

Before

Calculus I through the chain rule (Modules 08–11): the power rule with negative and fractional exponents, the chain rule, and the derivative of exe^x. Python basics (C01–C02) help; section 2.8 covers everything this lab uses, with C++ comparisons.

After

Compute partial derivatives by freezing the other inputs; backpropagate by hand through any small graph, including values used more than once; explain why the backward pass runs in reverse topological order; build and test a scalar autograd engine, a small neural-network library, and a training loop; check any gradient numerically and choose a sensible step size; read micrograd’s engine and compare its design with yours.

Level: AI Lab, Module 1 of 6. About 18 hours over two weeks (eight sessions). Python 3.14 standard library only, so there is nothing to install.

Warm-up: spaced review (10 minutes, closed book)#

If your error log has entries due for review, use two or three of those. Otherwise use these three, drawn from the friction points in LM, the slips learners most often make in Calculus I. All three come back in this module.

  1. Differentiate 16x−316x^{-3}. (Bookkeeping: the dropped negative exponent.)
  2. Differentiate (3x2+1)5(3x^2+1)^5. (Nesting dolls.)
  3. Compute 15−1\tfrac15-1, then differentiate x1/5x^{1/5}. (Fractions under pressure.)

The answers are at the very end of this chapter’s solutions. Number 1 returns in the power operation and in division (lab step 5). Number 2 is backpropagation itself (section 2.3). Number 3 returns in fractional powers (lab step 5 tests x0.5x^{0.5}).

1. Quick summary#

The running example L=(a⋅b+b)⋅cL=(a\cdot b+b)\cdot c, drawn as a computation graph. Black numbers flow forward (inputs to output). Orange numbers flow backward: each is ∂L/∂(that value)\partial L/\partial(\text{that value}), how much LL moves per unit nudge of that value. Because bb is used twice, it collects 15 along one path and 5 along the other: 20.

Five ideas carry this module.

  1. Training asks one question per knob. A neural network is a big expression with adjustable numbers called parameters. Training asks, for each one: if I turn this knob a little, does the error go up or down, and how fast? That is a derivative, one per knob.
  2. A partial derivative freezes the other inputs. With many inputs, take the ordinary Calculus I derivative in one input while treating every other input as a constant.
  3. Backpropagation is the chain rule, applied node by node. Each operation knows only its own local derivative. It multiplies the gradient arriving from the output side by that local derivative and passes the result to its inputs. A value used in two places adds what arrives from both.
  4. Order matters. A node may pass its gradient on only after every node that uses it has reported. That order is a reverse topological sort.
  5. Gradient descent turns gradients into learning. Nudge every parameter a small step against its gradient, then repeat. A finite-difference check proves the gradients are right.

What you will build. About 150 lines of Python in the companion code’s ai-lab/m01_autograd folder: engine.py (the Value class and its backward pass), gradcheck.py, nn.py (neurons, layers, networks), and train.py. Seventy tests check them step by step. At the end, a network you wrote learns XOR, a function no single straight line can separate. A companion-code tool, ai-lab/tools/viz.py, animates your own engine’s backward pass, and the Gradient Explorer on the companion website (bezhig.com/book/explorers/ai01-gradient-explorer.html) lets you drag the learning rate, the finite-difference step, and a neuron’s input and watch what happens.

SessionWorkWhere
1Warm-up; the question autograd answers; partial derivatives1, 2.1–2.2; AI01.01–02
2The chain rule on a graph; adding gradients2.3–2.4; AI01.03–05
3Topological sort, gradient descent, finite differences2.5–2.7; AI01.06–08
4Python for a C++ engineer; lab steps 1–22.8; 3.0–3.2
5Lab steps 3–4 and the backprop animation; the bug field guide3.3–3.4; 2.10
6Lab steps 5–63.5–3.6; AI01.09–11
7Lab steps 7–8: train XOR; design trade-offs2.9; 3.7–3.8; AI01.12–14
8Code-reading drill, mastery gate, development log4.2–4.3; 5

2. Deep explanation#

2.1. The question autograd answers#

Here is the smallest network worth the name: one neuron. It takes two inputs x1x_1 and x2x_2, multiplies each by a weight, adds a bias, and squashes the result:

output=tanh⁡(w1x1+w2x2+b).\text{output}=\tanh(w_1x_1+w_2x_2+b).

One neuron. The weights w1,w2w_1,w_2 and the bias bb are the knobs. The function tanh⁡\tanh (blue) squashes any number into the range −1-1 to 11. Its slope (dashed orange) is 1 at z=0z=0 and nearly 0 once zz is far from 0, which is where learning stalls (AI01.09).

The function tanh⁡\tanh (the hyperbolic tangent) is defined by tanh⁡z=ez−e−zez+e−z\tanh z=\dfrac{e^z-e^{-z}}{e^z+e^{-z}}. It is an S-shaped curve with tanh⁡0=0\tanh 0=0 that levels off at 11 for large zz and at −1-1 for very negative zz. You will derive its derivative in AI01.09.

The inputs come from data. The weights w1w_1 and w2w_2 and the bias bb are the knobs, called parameters, and training chooses them. To judge a choice we need one number that says how wrong the network is, the loss. For one example with target yy, a common loss is the squared error L=(output−y)2L=(\text{output}-y)^2. Training means changing w1w_1, w2w_2, and bb to make LL smaller.

For each knob the useful question is: if I nudge this knob up a little, how much does LL change? That is the derivative of LL with respect to that knob. A real model has millions or billions of knobs. Autograd (automatic gradient) computes every one of those derivatives with one forward pass and one backward pass. That is the machine you build in this module.

2.2. Partial derivatives: freeze the others#

In Calculus I every function had one input. A loss has many. The fix is almost disappointingly simple: to see how ff changes with xx, hold every other input fixed. Then ff is an ordinary one-input function of xx, and you take its ordinary derivative.

Worked example. Let f(x,y)=x2y+3yf(x,y)=x^2y+3y at the point (1,3)(1,3). First, f(1,3)=1⋅3+9=12f(1,3)=1\cdot3+9=12.

  • ∂f/∂x\partial f/\partial x: freeze yy. Then yy is a constant multiplier on x2x^2, and 3y3y is a constant term whose derivative is 00:

    ∂f∂x=y⋅ddx(x2)+0=2xy,at (1,3): 2(1)(3)=6.\frac{\partial f}{\partial x}=y\cdot\frac{d}{dx}\bigl(x^2\bigr)+0=2xy,\qquad\text{at }(1,3):\ 2(1)(3)=6.
  • ∂f/∂y\partial f/\partial y: freeze xx. Then x2x^2 is a constant coefficient on yy:

    ∂f∂y=x2⋅1+3=x2+3,at (1,3): 1+3=4.\frac{\partial f}{\partial y}=x^2\cdot1+3=x^2+3,\qquad\text{at }(1,3):\ 1+3=4.

Left: the surface f(x,y)=x2y+3yf(x,y)=x^2y+3y over the xyxy-plane. Freezing y=3y=3 slices it along the blue curve; freezing x=1x=1 slices it along the orange line f(1,y)=4yf(1,y)=4y. Right: the blue slice is an ordinary Calculus I graph, g(x)=3x2+9g(x)=3x^2+9, whose tangent at x=1x=1 has slope 6. A partial derivative is the slope of a slice.

The gradient. Collect all the partial derivatives into one list: ∇f=(∂f∂x,∂f∂y)\nabla f=\left(\dfrac{\partial f}{\partial x},\dfrac{\partial f}{\partial y}\right), read “grad ff.” At (1,3)(1,3) it is (6,4)(6,4). For training we use it one entry at a time: each entry tells its own knob which way to turn and how strongly. (Calculus III proves that the whole list, read as an arrow, points in the direction of steepest increase.)

Small nudges add up. For small nudges Δx\Delta x and Δy\Delta y,

Δf≈∂f∂xΔx+∂f∂yΔy.\Delta f\approx\frac{\partial f}{\partial x}\Delta x+\frac{\partial f}{\partial y}\Delta y.

Each partial predicts the change caused by its own nudge, and the two predictions add. This is the tangent-line approximation of Module 13, with one slope per input. Check it: move from (1,3)(1,3) to (1.01, 3.02)(1.01,\,3.02). The prediction is 6(0.01)+4(0.02)=0.06+0.08=0.146(0.01)+4(0.02)=0.06+0.08=0.14, so f≈12.14f\approx12.14. Exactly, f(1.01, 3.02)=(1.0201)(3.02)+9.06=3.080702+9.06=12.140702f(1.01,\,3.02)=(1.0201)(3.02)+9.06=3.080702+9.06=12.140702. The error, 0.00070.0007, is far smaller than either nudge. This “add the effects of small nudges” rule is the whole reason backpropagation works (section 2.4).

Check yourself: for the same ff, what is ∂f/∂x\partial f/\partial x at (2,5)(2,5)? (Answer: 2⋅2⋅5=202\cdot2\cdot5=20.)

A common misreading. “Freeze yy” does not mean yy stops mattering. The partial derivative ∂f/∂x=2xy\partial f/\partial x=2xy still contains yy: at y=3y=3 the slope in xx is 6, and at y=5y=5 it would be 10. Freezing is about which input you nudge, not which inputs the answer may depend on. That is why backprop needs every value from the forward pass.

Say it back (one sentence, out loud or on paper): what does ∂f/∂y=4\partial f/\partial y=4 at (1,3)(1,3) promise about ff when yy moves from 3 to 3.01?

2.3. The chain rule on a graph#

Computation graphs. Any expression can be drawn as a graph. Each node is one value. An arrow from uu to vv means “uu was used to compute vv.” The inputs are the leaves, and the last node is the output. The running example L=(a⋅b+b)⋅cL=(a\cdot b+b)\cdot c with a=3a=3, b=−2b=-2, c=5c=5 builds three nodes (the figure in section 1): e=a⋅b=−6e=a\cdot b=-6, then d=e+b=−8d=e+b=-8, then L=d⋅c=−40L=d\cdot c=-40. These are the Russian nesting dolls of composition laid out flat, one node per doll.

Local derivatives: the Modular Workbench. Each node knows only how its own output moves when one of its inputs moves, with its other inputs frozen. These are partial derivatives of a tiny function, and there are only a handful of kinds:

NodeLocal derivative with respect to each inputIn words
out=u+v\text{out}=u+v∂out/∂u=1\partial\text{out}/\partial u=1, ∂out/∂v=1\partial\text{out}/\partial v=1passes the gradient through
out=u⋅v\text{out}=u\cdot v∂out/∂u=v\partial\text{out}/\partial u=v, ∂out/∂v=u\partial\text{out}/\partial v=ueach input gets the other one
out=un\text{out}=u^n (nn a number)n u n−1n\,u^{\,n-1}the power rule; watch the sign of n−1n-1
out=eu\text{out}=e^ueue^u, which is out itself
out=tanh⁡u\text{out}=\tanh u1−tanh⁡2u=1−out21-\tanh^2u=1-\text{out}^2derived in AI01.09
out=max⁡(0,u)\text{out}=\max(0,u) (ReLU)11 if u>0u>0, and 00 if u≤0u\le000 at exactly u=0u=0 is a convention

Treat these like uu, vv, u′u', v′v' in the product rule: find each local derivative on its own line, check it, and only then assemble.

The chain rule, graph form. In Calculus I, if LL depends on uu and uu depends on xx, then dLdx=dLdu⋅dudx\dfrac{dL}{dx}=\dfrac{dL}{du}\cdot\dfrac{du}{dx}. On a graph this becomes one rule applied at every node:

∂L∂(input)⏟sent to the input=∂L∂(node)⏟upstream gradient×∂(node)∂(input)⏟local derivative.\underbrace{\frac{\partial L}{\partial(\text{input})}}_{\text{sent to the input}}=\underbrace{\frac{\partial L}{\partial(\text{node})}}_{\text{upstream gradient}}\times\underbrace{\frac{\partial(\text{node})}{\partial(\text{input})}}_{\text{local derivative}}.

The upstream gradient is how much the final output cares about this node; your code stores it in the node’s grad. The backward pass starts at the output with ∂L/∂L=1\partial L/\partial L=1: nudging LL changes LL by exactly the nudge. That 1 is called the seed.

Worked backward pass for the running example. Work from the output back toward the leaves.

StepNodeUpstreamLocal derivativesSent to inputs
1L=d⋅cL=d\cdot c11∂L/∂d=c=5\partial L/\partial d=c=5; ∂L/∂c=d=−8\partial L/\partial c=d=-8dd gets 55; cc gets −8-8
2d=e+bd=e+b5511 and 11ee gets 55; bb gets 55
3e=a⋅be=a\cdot b55∂e/∂a=b=−2\partial e/\partial a=b=-2; ∂e/∂b=a=3\partial e/\partial b=a=3aa gets −10-10; bb gets 1515

So ∂L/∂a=−10\partial L/\partial a=-10 and ∂L/∂c=−8\partial L/\partial c=-8. The leaf bb received two messages, 5 and 15; the next section shows why they add to ∂L/∂b=20\partial L/\partial b=20. Check algebraically: L=(ab+b)c=bc(a+1)L=(ab+b)c=bc(a+1), so ∂L/∂a=bc=−10\partial L/\partial a=bc=-10, ∂L/∂b=c(a+1)=20\partial L/\partial b=c(a+1)=20, and ∂L/∂c=b(a+1)=−8\partial L/\partial c=b(a+1)=-8.

Your turn: a faded example. Take L=(x+y)⋅xL=(x+y)\cdot x at x=2x=2, y=3y=3, with one inner node s=x+y=5s=x+y=5, so L=s⋅x=10L=s\cdot x=10. Fill in the blanks on paper before reading on.

StepNodeUpstreamLocal derivativesSent to inputs
1L=s⋅xL=s\cdot x11∂L/∂s=x=2\partial L/\partial s=x=2; ∂L/∂x=s=\partial L/\partial x=s=\rule{0.8cm}{0.4pt}ss gets ; xx gets
2s=x+ys=x+y and xx gets ; yy gets

Then total each leaf. Check after you finish: ss gets 2 and xx gets 5; the add node passes 2 to both. So ∂L/∂x=5+2=7\partial L/\partial x=5+2=7 and ∂L/∂y=2\partial L/\partial y=2. Algebra agrees: L=x2+xyL=x^2+xy gives 2x+y=72x+y=7 and x=2x=2. Notice that xx is used twice, which is the subject of the next section.

Three patterns cover most of backpropagation. Here gg is the gradient arriving at the node from the output side.

2.4. Used twice? Add the gradients#

Why should bb’s two messages add? The nudge rule from section 2.2 answers it. The node d=e+bd=e+b depends on bb twice: directly, and through e=a⋅be=a\cdot b. Nudge bb by a small amount Δb\Delta b and follow both effects:

  • ee changes by Δe≈∂e∂bΔb=a Δb=3 Δb\Delta e\approx\dfrac{\partial e}{\partial b}\Delta b=a\,\Delta b=3\,\Delta b;
  • d=e+bd=e+b changes by both: Δd=Δe+Δb≈3 Δb+Δb=4 Δb\Delta d=\Delta e+\Delta b\approx3\,\Delta b+\Delta b=4\,\Delta b;
  • LL changes by ΔL≈∂L∂dΔd=5⋅4 Δb=20 Δb\Delta L\approx\dfrac{\partial L}{\partial d}\Delta d=5\cdot4\,\Delta b=20\,\Delta b.

So ∂L/∂b=20=15+5\partial L/\partial b=20=15+5: one term for each path from bb to LL. Check with numbers: b=−2→−1.999b=-2\to-1.999 gives L=(3(−1.999)+(−1.999))(5)=(−7.996)(5)=−39.98L=\bigl(3(-1.999)+(-1.999)\bigr)(5)=(-7.996)(5)=-39.98, a change of 0.02=20×0.0010.02=20\times0.001.

The chain rule as a gearbox. Each local derivative is a gear ratio: turn bb by one notch and ee turns 3 notches, because ∂e/∂b=a=3\partial e/\partial b=a=3. Along a path the ratios multiply. Where two paths meet at an add node, their turns add, the way the differentials in old mechanical computers added two shafts’ rotations.

In general, ∂L/∂x\partial L/\partial x is the sum, over every path from xx to LL, of the product of the local derivatives along that path. Backpropagation computes exactly that sum without listing the paths. That matters: a graph can have exponentially many paths (a chain of kk diamonds like the one in lab step 3 has 2k2^k), yet backprop touches each edge once, because each node stores its total so far and hands it on. Reusing stored partial totals instead of recomputing them is called dynamic programming, a technique Module 3 makes central.

Say it back: using the gearbox picture, explain in two sentences why ∂L/∂b\partial L/\partial b is 20 and not 15.

The smallest example is y=a⋅ay=a\cdot a at a=3a=3. The multiply node sees the same value on both inputs. It sends “the other input times gg” to its first input and to its second input, and both are aa: 3+3=6=ddaa23+3=6=\frac{d}{da}a^2 at a=3a=3. With = you would get 3. Lab step 4 has you break your own code this way and watch it happen.

2.5. The order of work: topological sort#

A node’s gradient is finished only when every node that uses it has sent its message. In the running example, bb’s gradient is not complete until both dd and ee have reported. So a node may run its backward step only after all of its users have run theirs.

An ordering in which every node comes after all of its inputs is called a topological order. The forward pass already happens in one. The backward pass runs in the reverse of one: output first, and each node before its inputs.

The algorithm: depth-first search, post-order.

  1. To visit a node vv: if vv has not been visited, mark it visited, visit each of its inputs, and then append vv to the list.
  2. Visit the output.

Because vv is appended only after all of its inputs, every node lands after its inputs. Reverse the list and every node comes before its inputs.

Trace on the running example (children visited in the order d,cd,c for LL; e,be,b for dd; a,ba,b for ee): visit LL →\to visit dd →\to visit ee →\to visit aa (append aa) →\to visit bb (append bb) →\to append ee →\to visit bb again (already visited, skip) →\to append dd →\to visit cc (append cc) →\to append LL.

The post-order list for the running example, and the direction the backward pass walks it.

Say it back: why may ee not run its backward step before dd has run its own?

The visited set is what stops bb from being appended twice. That would be harmless for a leaf, but a shared interior node listed twice would run its backward step twice and double-count its gradient (bug B5).

Cost. Every node and every edge is touched once, so the sort is O(V+E)O(V+E) for VV nodes and EE edges: proportional to the size of the graph. The whole backward pass costs a small constant multiple of the forward pass, however many inputs there are. (In an engine like yours it is even cheaper than the forward pass: for a 100,000-operation chain on one desktop PC, a complete engine of this design took about 0.06 s for the backward pass, while building the graph took 0.08 to 0.2 s.) That is why training is affordable at all.

A Python limit. Python stops a chain of nested function calls at about 1000 by default (python -c "import sys; print(sys.getrecursionlimit())" prints 1000 on a standard Python installation). A recursive visit on a 5000-node chain therefore raises RecursionError. Stretch S1 replaces the recursion with an explicit stack.

2.6. Gradient descent: turning gradients into learning#

Now use the gradients. Take a knob ww with gradient g=∂L/∂wg=\partial L/\partial w. If g>0g>0, increasing ww increases LL, so decrease ww. If g<0g<0, increasing ww decreases LL, so increase ww. Both cases say the same thing: step against the gradient.

w ← w−η g.w\ \leftarrow\ w-\eta\,g.

The Greek letter η\eta (eta) is the learning rate, a small positive number you choose. Updating every knob once is one step.

Why not set ∂L/∂w=0\partial L/\partial w=0 and solve, the way calculus finds a minimum (Modules 13 and 14)? For one knob you could. For millions of knobs tangled together through tanh⁡\tanh, no formula exists. Walking downhill works anyway.

Worked example. L(w)=(w−3)2L(w)=(w-3)^2 has its minimum at w=3w=3, and dL/dw=2(w−3)dL/dw=2(w-3). Start at w0=0w_0=0 with η=0.1\eta=0.1:

w1=0−0.1⋅2(0−3)=0+0.6=0.6,w2=0.6−0.1⋅2(0.6−3)=0.6+0.48=1.08,w3=1.08−0.1⋅2(1.08−3)=1.08+0.384=1.464.\begin{align*} w_1&=0-0.1\cdot2(0-3)=0+0.6=0.6,\\ w_2&=0.6-0.1\cdot2(0.6-3)=0.6+0.48=1.08,\\ w_3&=1.08-0.1\cdot2(1.08-3)=1.08+0.384=1.464. \end{align*}

To see the pattern, subtract 3 from both sides of the update:

wk+1−3=(wk−3)−2η(wk−3)=(1−2η)(wk−3).w_{k+1}-3=(w_k-3)-2\eta(w_k-3)=(1-2\eta)(w_k-3).

The distance to the minimum is multiplied by 1−2η1-2\eta at every step. With η=0.1\eta=0.1 that factor is 0.80.8: a steady approach. With η=0.5\eta=0.5 it is 00: the first step lands exactly on 3. With η=0.9\eta=0.9 it is −0.8-0.8: each step overshoots to the other side, by less each time. With η=1\eta=1 it is −1-1: the steps bounce between 0 and 6 forever. With η>1\eta>1 the factor’s size exceeds 1, and every step overshoots further than the last.

Gradient descent on (w−3)2(w-3)^2 from w=0w=0. Too small is slow, too large explodes, and the safe range depends on how sharply the loss curves. With many knobs you find a good η\eta by experiment (Module 2 runs a learning-rate sweep).

Two knobs at once. With two knobs the loss is a surface, and a contour map shows it from above: each closed curve joins points of equal loss, like the height lines on a hiking map. The gradient ∇L=(∂L/∂w1, ∂L/∂w2)\nabla L=(\partial L/\partial w_1,\ \partial L/\partial w_2) points straight uphill, at right angles to the contour through your position, so the update w←w−η∇Lw\leftarrow w-\eta\nabla L steps straight downhill. (Calculus III proves the right angle; the figure below lets you see it.)

Take L(w1,w2)=12(w12+8w22)L(w_1,w_2)=\tfrac12\left(w_1^2+8w_2^2\right). Its gradient is (w1, 8w2)(w_1,\ 8w_2): the bowl is 8 times steeper in the w2w_2 direction. Each knob gets its own factor, exactly as in the one-knob example: w1←(1−η) w1w_1\leftarrow(1-\eta)\,w_1 and w2←(1−8η) w2w_2\leftarrow(1-8\eta)\,w_2. The steep direction limits the learning rate: w2w_2 converges only when ∣1−8η∣<1|1-8\eta|<1, that is, η<0.25\eta<0.25. At η=0.23\eta=0.23 the w2w_2 factor is 1−1.84=−0.841-1.84=-0.84, so the path zigzags across the valley. At η=0.05\eta=0.05 nothing zigzags, but w1w_1 moves only 5% of its remaining distance per step, so progress along the valley floor is slow.

Gradient descent on L=12(w12+8w22)L=\tfrac12(w_1^2+8w_2^2) from (−4.5, 1.2)(-4.5,\,1.2). Both axes use the same scale, so the right angle between the downhill arrow and the contour is real. A bowl much steeper in one direction than another is called ill-conditioned. Its steepest direction caps the learning rate, and its gentlest direction then crawls. Optimizers such as Adam (Module 2) give each knob its own effective step size, partly to fix exactly this.

Real losses have many valleys, and gradient descent finds a nearby low point, not necessarily the lowest. For neural networks this works far better than early theory predicted, which is one of the field’s genuine surprises.

Where this goes: every model at Anthropic, OpenAI, and Tesla trains with this loop. The differences are scale: tensors instead of single numbers, GPUs, and smarter step rules such as AdamW (Module 2).

2.7. Checking gradients with finite differences#

How do you know a backward pass is right? Go back to the definition of the derivative, Module 08’s difference quotient, with a small hh instead of a limit:

forward difference:  f′(x)≈f(x+h)−f(x)h,central difference:  f′(x)≈f(x+h)−f(x−h)2h.\text{forward difference: }\ f'(x)\approx\frac{f(x+h)-f(x)}{h},\qquad \text{central difference: }\ f'(x)\approx\frac{f(x+h)-f(x-h)}{2h}.

For a partial derivative, nudge one input and freeze the others.

Why central is better, Algebra First. Take f(x)=x3f(x)=x^3 and expand (x+h)3=x3+3x2h+3xh2+h3(x+h)^3=x^3+3x^2h+3xh^2+h^3.

  • Forward: (x+h)3−x3h=3x2+3xh+h2\dfrac{(x+h)^3-x^3}{h}=3x^2+3xh+h^2. The error, 3xh+h23xh+h^2, shrinks in proportion to hh.
  • Central: (x−h)3=x3−3x2h+3xh2−h3(x-h)^3=x^3-3x^2h+3xh^2-h^3, so (x+h)3−(x−h)3=6x2h+2h3(x+h)^3-(x-h)^3=6x^2h+2h^3. Dividing by 2h2h gives 3x2+h23x^2+h^2. The error, h2h^2, shrinks in proportion to h2h^2: the 3xh3xh terms cancel because the two nudges are symmetric.

At x=2x=2 with h=0.1h=0.1 the true slope is 12. The forward difference gives 12.61 and the central difference 12.01 (AI01.08).

Why not use a tiny hh? A computer stores about 16 significant digits (a Python float is a 64-bit double, with relative precision about 2.2×10−162.2\times10^{-16}). When hh is tiny, f(x+h)f(x+h) and f(x−h)f(x-h) agree in almost every digit. Subtracting cancels the digits that agree and leaves mostly rounding noise, and dividing by a tiny 2h2h magnifies that noise. So rounding error grows like 1/h1/h while the approximation error shrinks like hh or h2h^2.

Error in the estimated slope of tanh⁡\tanh at x=0.5x=0.5, both axes logarithmic. On the right, the approximation error dominates (slope 1 for forward, 2 for central). On the left, rounding error dominates and gets worse as hh shrinks. The central difference is most accurate near h=10−6h=10^{-6}, with an error around 10−1210^{-12}; the forward difference is best near h=10−8h=10^{-8}, around 10−910^{-9}.

The Gradient Explorer’s third panel draws this curve live in your browser; slide hh and watch the error fall, bottom out, and climb again. Practical rule: use the central difference with hh between 10−510^{-5} and 10−610^{-6}, and compare with a tolerance of about 10−510^{-5} times the size of the gradient. AI01.16 derives the best hh with Module 14’s optimization. Gradient checks are to autograd what unit tests are to any other program: too slow for training (two evaluations per input), perfect for tests.

2.8. Python for a C++ engineer: exactly what this lab uses#

C++Python in this lab
A class with declared membersMembers are created by assigning self.data = ... inside __init__. self is an explicit this.
operator+ overloaddef __add__(self, other). Methods named with double underscores (“dunder” methods) are hooks Python calls for operators: __mul__, __pow__, __neg__, __sub__, __truediv__.
Overload chosen by type at compile timeFor 2 + a, Python first calls the int’s __add__, which returns NotImplemented because it has never heard of Value; then it tries the reflected a.__radd__(2). Without __radd__ you get TypeError. Careful: __rsub__ must compute 5−a5-a, not a−5a-5.
A lambda capturing by reference, [&]A def nested inside a method is a closure: it remembers self, other, and out from the enclosing call and reads them when it runs later. That is exactly what _backward needs. One trap: closures created in a loop all see the loop variable’s final value (late binding). This lab doesn’t hit it, but remember it.
A std::function memberAssign a function to an attribute: out._backward = _backward.
Pointer comparison p == qa is b. Plain == calls __eq__, which compares identity unless you define it. Define __eq__ and Python removes the default hash, so the class can no longer go in a set (bug B10). Keep Values compared by identity only.
std::unordered_set<Value*>set(), which hashes objects by identity by default.
dynamic_cast checkisinstance(other, Value), used to wrap plain numbers in a Value.
std::accumulate(first, last, init)sum(items, start). Without start, sum begins at the int 0, so its first addition is 0 + Value, which needs __radd__.
doublefloat is always a 64-bit double.
Stack size you can raiseA recursion limit of about 1000 nested calls.
std::formatf-strings: f"Value(data={self.data}, grad={self.grad})".

2.9. Design trade-offs: why this design, and what breaks at scale#

  • Scalars versus tensors. Your engine stores one number per Value, and every operation allocates a Python object and a closure. Measured with a complete engine of this design on one desktop PC, one operation costs about 1.5 to 3 microseconds (forward plus backward; building nodes is the slow, variable part) and about 860 bytes of memory. One 4096×40964096\times4096 matrix multiplication, a routine step in a small language model, is 40963≈6.9×10104096^3\approx6.9\times10^{10} multiply-adds, or about 1.4×10111.4\times10^{11} of your nodes. At that rate it is two to five days of computing and 118 terabytes of memory. A GPU does it in a few milliseconds. A tensor library stores millions of numbers per node: one matrix-multiply node replaces millions of your nodes and runs on the GPU. The chain rule and the topological sort are identical. Module 2 makes that move. This scale is for understanding, not speed.
  • Memory. The graph keeps every intermediate value alive until the backward pass, because local derivatives need them (a multiply node needs both inputs’ data). That is why training uses far more memory than running a model. Large models trade compute for memory by recomputing some intermediates during the backward pass, which is called activation checkpointing.
  • Built as it runs, or compiled first. Your graph is built by ordinary Python as it runs, so any if or for works and an error points at your own line. PyTorch’s default mode works the same way. Compiled approaches such as torch.compile and JAX trace the graph first and then optimize it: faster, harder to debug. Module 2 uses both.
  • Reverse mode versus forward mode. One backward pass gives the gradient of one output with respect to every input. Forward mode (W04’s dual numbers) carries the derivative with respect to one input forward per sweep, so it needs one sweep per input. One loss and a billion knobs means reverse mode (AI01.15).
  • Accumulating gradients is a feature. Because backward adds into grad, gradients from several backward calls sum. Large-model training uses this to add up gradients over several small batches (“gradient accumulation”). The price is that you must zero the gradients before each step (bug B8). PyTorch does the same for parameters.
  • The order of addition changes the last digits. Floating-point addition is not exactly associative: (a+b)+c(a+b)+c and a+(b+c)a+(b+c) can differ in the last bit, because each sum is rounded. If _prev were a set, Python would order the inputs by memory address, which changes from run to run, so gradients would be summed in a different order each time. Usually that is invisible. In training, though, tiny differences can grow. With a set, XOR runs of a complete engine gave a different final loss on every rerun for all 50 random starts tried; in one batch of five reruns, a single start ranged from 1.3×10−301.3\times10^{-30} to 3.7×10−113.7\times10^{-11}. A tuple fixes the order and makes every run identical, which Module 5’s experiment loop depends on. (GPU libraries face the same issue at a much larger scale, and offer “deterministic” modes that trade speed for identical results.)
  • A closure per node, or a table of operations. Storing a closure in each node is short and flexible. PyTorch instead records which operation made each node and calls a compiled backward function for that operation, which is faster and uses less memory per node.

2.10. Beginner bugs: a field guide#

These are the mistakes people really make when they first write an autograd engine. Each has a symptom you can recognize and at least one test in your lab that catches it. Each was planted, one at a time, in a working engine, and every one of these “mutants” fails at least one of the lab’s tests (AI00, section G).

BugSymptomHow to spot it; which test catches it
B1Overwrite: grad = instead of +=Right answers until a value is used twice; a+aa+a gives 1. The bug can even hide: if the add node overwrites first and a later node adds, the total comes out right.Test graphs with reuse. In multiply: step 2’s manual backprop. In add: step 4’s a+aa+a test.
B2Mirror: multiply sends an input its own value∂(ab)/∂a\partial(ab)/\partial a comes out as aaUse a≠ba\ne b and check both. Step 2’s multiply test.
B3Missing seedEvery gradient is 0Print the output’s grad. Step 3.
B4Wrong order: forward order, or appending a node before its inputsGradients 0 or partial in deep graphsA long chain. Step 3’s chain test.
B5Double visit: no visited setA shared interior node is counted twiceAn interior node used several times. Step 3.
B6Wrong local derivative: n xnn\,x^n, a dropped minus in n−1n-1, 1−t1-t for tanh⁡\tanhPowers wrong, especially negative ones: x−2x^{-2} gets slope −2x3-2x^{3} instead of −2x−3-2x^{-3}Test negative and fractional exponents, and points away from 0 (at x=0x=0, 1−t1-t and 1−t21-t^2 agree). Step 5; step 6’s random graphs.
B7Forgotten chain: local derivative without * out.gradCorrect when the operation is the output, wrong inside a longer expressionTest operations inside a chain. Every step 5 test multiplies by 3 for this reason.
B8Stale gradients: no zero_gradTraining is erratic or divergesLoss curve; step 8’s stale-gradient test.
B9Uphill step: p.data += lr * p.gradLoss rises every stepLoss curve. Step 8.
B10Unhashable Value: defined __eq__TypeError: unhashable typeThe traceback. Step 1.
B11Reflected operations backwards: 5−a5-a computed as a−5a-5Sign errors whenever a plain number is on the leftStep 5’s rsub and rdiv tests.
B12Overflowing tanh⁡\tanh: computed as (e2x−1)/(e2x+1)(e^{2x}-1)/(e^{2x}+1)OverflowError for large inputs (math.exp overflows just past 709)Extreme inputs. Step 5’s saturation test.

3. The lab: build it yourself#

3.0. Setup and ground rules#

Everything lives in the companion code’s ai-lab/m01_autograd folder; copy the ai-lab folder into a repository of your own first (AI00, section G). You write engine.py, gradcheck.py, nn.py, and train.py; each starts as a set of docstrings that are its specification. The tests folder and check.py come with the starters; don’t edit them. Open a terminal (PowerShell on Windows) in the folder that holds your copy and run the first check:

cd ai-lab\m01_autograd
python check.py 1

Every test fails with “not written yet”. That is the starting line.

  • Type the code yourself. Don’t paste from this book or anywhere else: typing is how your hands learn the patterns.
  • Predict before every run, in writing. The orange boxes tell you what to predict.
  • When a test fails, read the test before your code. Its name says what it checks, and its body in tests/ shows the exact inputs. Reading someone else’s test code is code-reading practice too.
  • Stuck? Climb that step’s hint ladder (after the practice set, on its own page) one rung at a time.
  • Checking your work. The tests are the judge: a step is finished when its tests pass on code you typed yourself. When one fails, find the first incorrect step before you change anything, and log it with its field-guide number (section 2.10). After all eight steps pass, the code-reading drill (section 4.2) compares your engine with micrograd, a complete published engine of the same design.

3.1. Step 1: a Value that remembers where it came from (worked example)#

Goal. A Value holds a number and a gradient and remembers its inputs; + and * build new Values. Type this into engine.py, replacing the matching stubs:

Python
    def __init__(self, data, _children=(), _op="", label=""):
        self.data = float(data)
        self.grad = 0.0
        self._prev = tuple(_children)
        self._op = _op
        self.label = label
        self._backward = lambda: None

    def __repr__(self):
        return f"Value(data={self.data}, grad={self.grad})"

    def __add__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data + other.data, (self, other), "+")
        return out

Line by line: float(data) stores a float even when you pass an int. grad starts at 0 because nothing is known yet. _prev holds the inputs, in order, as a tuple. (For a * a it holds a twice; the visited set in step 3 makes sure a is still processed once.) A tuple keeps every run identical to the last digit; section 2.9 explains why a set would not. _op and label are for pictures and debugging. _backward starts as a function that does nothing, which is right for a leaf; every operation will replace it. In __add__, the first line wraps a plain number so that a + 1 works.

Now write __mul__ yourself. It has the same shape as __add__. Only the arithmetic and the operation symbol change.

Run python check.py 1. Passing proves the forward arithmetic, the graph bookkeeping, the wrapping of plain numbers, and that a Value can live in a set.

3.2. Step 2: local backward functions (partly filled)#

Goal. Each operation attaches a _backward that multiplies its local derivatives by out.grad and adds the results into its inputs’ gradients. Extend __add__ and fill in the two blanks from the table in section 2.3:

Python
    def __add__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data + other.data, (self, other), "+")

        def _backward():
            self.grad += ____ * out.grad    # d(out)/d(self) for out = self + other
            other.grad += ____ * out.grad   # d(out)/d(other)
        out._backward = _backward
        return out

_backward is defined inside __add__ so that it is a closure: it remembers self, other, and out, and it runs later, once out.grad is known. Now give __mul__ its _backward on your own. Then run the backward pass by hand in the Python prompt:

Python
from engine import Value
a, b, c = Value(3.0, label="a"), Value(-2.0, label="b"), Value(5.0, label="c")
e = a * b; d = e + b; L = d * c
L.grad = 1.0
L._backward(); d._backward(); e._backward()
print(a.grad, b.grad, c.grad)

Run python check.py 2.

3.3. Step 3: backward() for the whole graph (on your own, from the recipe)#

Goal. L.backward() fills in every gradient in one call. No code this time: use the recipe in section 2.5 and the three numbered steps in the docstring (sort, seed, walk in reverse). Use a set of visited nodes, and let the output’s gradient with respect to itself be 1.

Run python check.py 3. Then watch it work with the companion code’s visualizer, from the same folder:

python ..\tools\viz.py example

This builds the running example with your engine and opens out/graph.html, an animation of the forward pass and then of backprop, one node at a time. It replays your own _backward functions. Step through and watch bb receive +5+5 when dd reports and +15+15 when ee reports. If a gradient looks wrong in the animation, the frame where it goes wrong names the operation to fix.

3.4. Step 4: break it on purpose#

This step is an experiment, not new code.

  1. In __add__’s _backward, change both += to =. Predict a.grad after (a + a).backward() with a=3a=3. Run python check.py 4 and note which tests fail. Restore the +=.
  2. Do the same in __mul__ and predict a.grad for (a * a).backward() at a=3a=3. Run, note, restore.
  3. Write one sentence in your log: why does = give exactly 1 for a+aa+a?

Run python check.py 4 once more with everything restored; all four steps should pass.

3.5. Step 5: the remaining operations (on your own)#

Goal. __pow__, __neg__, __radd__, __sub__, __rsub__, __rmul__, __truediv__, __rtruediv__, exp, tanh, and relu. Their docstrings give the contracts. Before typing each one, write its local derivative on paper (the Workbench).

Build from what you have. An operation made out of existing operations needs no _backward of its own, because the pieces already have one. For example −x=x⋅(−1)-x=x\cdot(-1), a−b=a+(−b)a-b=a+(-b), and a/b=a⋅b−1a/b=a\cdot b^{-1}. The trade-off: more nodes (slower) in exchange for less code and fewer places for bugs. __pow__, exp, tanh, and relu need their own _backward. If your power rule drops the sign of the new exponent, check.py reports -48.0 != -0.75 in test_pow_negative_exponent. That −48-48 is not a coincidence you should shrug at: it is the test’s 3⋅(−2)⋅233\cdot(-2)\cdot2^{3} where 3⋅(−2)⋅2−33\cdot(-2)\cdot2^{-3} belongs, the same slip as answering warm-up question 1 with −48x4-48x^4.

Run python check.py 5.

3.6. Step 6: a gradient checker (on your own)#

Goal. numerical_grad and max_grad_error in gradcheck.py, following section 2.7 and the docstrings. Step 6 also includes a test that builds 40 random expressions from all of your operations and compares your backward pass with central differences computed by the test itself. It is the strongest single check in the lab.

Run python check.py 6.

3.7. Step 7: neurons, layers, networks (partly filled)#

MLP(2, [4, 4, 1]). Every line is a weight. Each neuron also has a bias. Hidden layers apply tanh⁡\tanh; the last layer does not, so the output can be any number.

Goal. nn.py: a Neuron computes tanh⁡(w⋅x+b)\tanh(w\cdot x+b) (or the plain sum when nonlin=False); a Layer is a list of neurons reading the same inputs; an MLP (multilayer perceptron) applies layers one after another with a linear last layer. Everything is ordinary Python on top of Value, with no new calculus. Here is the neuron with its two key lines left blank:

Python
class Neuron(Module):
    def __init__(self, nin, nonlin=True):
        self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)]
        self.b = Value(0.0)
        self.nonlin = nonlin

    def __call__(self, x):
        act = ____        # w0*x0 + w1*x1 + ... + b, as one Value
        return ____       # tanh of it, unless nonlin is False

Then write Neuron.parameters, Module, Layer, and MLP yourself.

Why random starting weights? If every weight started equal, every neuron in a layer would compute the same thing and receive the same gradient, forever. Random starts break that symmetry. The bias can start at 0 because the weights already make the neurons different. Why a linear last layer? So the output is not trapped between −1-1 and 11; the loss decides what scale it needs.

Run python check.py 7.

3.8. Step 8: teach it XOR (on your own)#

XOR’s output is +1+1 (blue) when exactly one input is 1, and −1-1 (red) otherwise.

Why XOR needs a hidden layer. Left: one neuron is blue on one side of a straight line and red on the other, and no straight line can put both blue points on one side and both red points on the other. Right: a network wired by hand with two hidden neurons, h1=tanh⁡(4(x1+x2−0.5))h_1=\tanh\bigl(4(x_1+x_2-0.5)\bigr) and h2=tanh⁡(4(x1+x2−1.5))h_2=\tanh\bigl(4(x_1+x_2-1.5)\bigr), and output h1−h2−1h_1-h_2-1. It gives −0.96-0.96, +0.93+0.93, +0.93+0.93, and −0.96-0.96 at the four corners.

Why a hidden layer works. A tanh⁡\tanh neuron outputs nearly +1+1 on one side of the line w⋅x+b=0w\cdot x+b=0 and nearly −1-1 on the other, so each neuron draws one soft straight line. On its own it can only split the square into two half-planes. Two hidden neurons draw two lines, and the output neuron adds their votes, so it can keep just the band between them. Training finds weights like the hand-wired ones by itself. Stacking layers this way, building complicated regions out of simple pieces, is the core idea behind deep networks.

Goal. In train.py: mse_loss (the mean of the squared errors), train_step (zero the gradients, forward, backward, update, in that order, returning the loss as a float), and train (repeat, collecting the losses).

Run python check.py 8. Then train and look:

python train.py
python train.py --viz

The second command opens out/xor.html: your loss curve and a map of what your network outputs over the whole input square.

A complete solution’s run with seed 1: MLP(2, [4, 4, 1]), learning rate 0.1, 400 steps of full-batch gradient descent (every step uses all four examples). Left: the loss on a logarithmic scale. It keeps falling at a steady rate (a straight line on this scale) because a network with a linear output can fit four points exactly; on real data the loss levels off instead. Right: the trained network’s output over the input square (blue near +1+1, red near −1-1, pale near 0), with the four training points. Your curve will differ in detail, because random starting weights depend on how your code draws them.

The spike is not a bug. In that run the loss falls to about 7.7×10−57.7\times10^{-5} by step 74, then shoots up to 2.87 at step 84, higher than where it started, and then falls again. Near the bottom of this valley the loss curves much more sharply than it did at the start. A learning rate that was safe on the gentle early slope is now too big, which is section 2.6’s η>1\eta>1 bounce, happening in 37 dimensions at once. The overshoot throws the weights somewhere flatter, and descent resumes. In runs of a complete solution at learning rate 0.1, 16 of 50 random starts climb back above a loss of 1 after first falling below 0.01 (the worst reaches 6.9), and all 50 still learn XOR. If your curve spikes, check the loss on the steps after the spike before suspecting your code; a smaller learning rate such as 0.05 removes the spikes but learns more slowly.

This is not a toy curiosity; researchers study it directly. As training goes on, the loss surface around the weights often gets sharper (progressive sharpening), and once its sharpest curvature passes 2/η2/\eta (=20=20 here), a gradient-descent step overshoots, exactly as in the one-knob example. Cohen et al. (2021) documented this “edge of stability” in full-batch gradient descent. A blow-up that ends in a flatter region also resembles what Lewkowycz et al. (2020) call a catapult, which they observed at the very start of training with a large learning rate. Your spike is consistent with this picture, and stretch S6 shows how you could test it.

When python check.py reports all eight steps passing, you have written a working autograd engine and trained a neural network with it. Copy the summary line into the development log (section 5).

4. Practice, code reading, and the gate#

4.1. Practice set#

F = foundation; C = standard; T = transfer; S = optional stretch; L = lab; R = code reading; G = mastery gate. Follow the session plan in section 1. Attempt each problem completely before opening its solution, and log every miss in error_log.csv with its friction family.

AI01.01FSolution

Let f(x,y)=3x2y−5y2+xf(x,y)=3x^2y-5y^2+x. Find ∂f/∂x\partial f/\partial x and ∂f/∂y\partial f/\partial y, then evaluate both at (2,−1)(2,-1). Say which letter is frozen before each derivative.

AI01.02CSolution

Which letter is the variable? Let g(w,b)=(wx+b−y)2g(w,b)=(wx+b-y)^2 with data x=2x=2 and y=1y=1. Find ∂g/∂w\partial g/\partial w and ∂g/∂b\partial g/\partial b at w=1w=1, b=0b=0. Name the constants first.

AI01.03CSolution

(a) From memory, write the local derivatives of u+vu+v, u⋅vu\cdot v, unu^n, eue^u, and tanh⁡u\tanh u with respect to each input. (b) Rewrite q=x/yq=x/y as x⋅y−1x\cdot y^{-1} and find ∂q/∂x\partial q/\partial x and ∂q/∂y\partial q/\partial y at (6,3)(6,3). Final sweep: check the sign of every exponent.

AI01.04CSolution

Draw the graph of L=(x+y)⋅(y⋅z)L=(x+y)\cdot(y\cdot z) with nodes s=x+ys=x+y and p=y⋅zp=y\cdot z. At (x,y,z)=(2,−1,3)(x,y,z)=(2,-1,3), do the forward pass, then backpropagate by hand with a table like the one in section 2.3. Explain the value you get for ∂L/∂y\partial L/\partial y, then check all three gradients by expanding LL.

AI01.05CSolution

Build y=x3y=x^3 as (x⋅x)⋅x(x\cdot x)\cdot x, using two multiply nodes. At x=2x=2, backpropagate by hand and show that 3x2=123x^2=12 arises as a sum over paths. How many paths lead from xx to yy, and what does each contribute?

AI01.06CSolution

Algorithm lane. Run the depth-first search of section 2.5 on the running example, but visit each node’s inputs in the opposite order (LL: cc then dd; dd: bb then ee; ee: bb then aa). Give the post-order list and its reverse. Then decide whether each is a valid topological order: (i) a,b,e,d,c,La,b,e,d,c,L; (ii) c,b,a,e,d,Lc,b,a,e,d,L; (iii) e,a,b,d,c,Le,a,b,d,c,L.

AI01.07CSolution

Gradient descent on L(w)=(w−3)2L(w)=(w-3)^2 from w0=0w_0=0. (a) Take three steps with η=0.1\eta=0.1. (b) Show that wk+1−3=(1−2η)(wk−3)w_{k+1}-3=(1-2\eta)(w_k-3). (c) For which η>0\eta>0 does it converge, bounce forever, or diverge? (d) Which η\eta reaches 3 in one step?

AI01.08CSolution

For f(x)=x3f(x)=x^3 at x=2x=2 with h=0.1h=0.1, compute the forward and central differences and their errors. Then use the expansions in section 2.7 to explain both errors exactly.

AI01.09TSolution

Starting from tanh⁡x=ex−e−xex+e−x\tanh x=\dfrac{e^x-e^{-x}}{e^x+e^{-x}}, use the quotient rule to show that ddxtanh⁡x=1−tanh⁡2x\dfrac{d}{dx}\tanh x=1-\tanh^2x. Evaluate it at x=0x=0 and at x=3x=3. What does the value at x=3x=3 mean for a neuron whose input sum is large?

AI01.10TSolution

The sigmoid is σ(x)=11+e−x\sigma(x)=\dfrac{1}{1+e^{-x}}. Rewrite it as a power, differentiate, and show that σ′(x)=σ(x)(1−σ(x))\sigma'(x)=\sigma(x)\bigl(1-\sigma(x)\bigr). Evaluate σ′(0)\sigma'(0).

AI01.11TSolution

One neuron, one step. Let z=wx+bz=wx+b, p=tanh⁡zp=\tanh z, and L=(p−y)2L=(p-y)^2 with x=1x=1, y=1y=1, w=0.5w=0.5, b=0b=0. Compute the forward pass; then ∂L/∂p\partial L/\partial p, ∂p/∂z\partial p/\partial z, ∂L/∂w\partial L/\partial w, and ∂L/∂b\partial L/\partial b; then one gradient-descent step with η=0.5\eta=0.5 and the new loss. Give six decimals.

AI01.12TSolution

A game engine’s skeleton. In the author’s FateMMO engine, the function that poses a cutout animation rig, evaluatePose, computes a bone’s world scale as its parent’s world scale times its local scale, and its local scale as its bind scale times the animation delta’s scale. Take a chain root →\to arm →\to hand with every bind scale 1, rig-root scale 1, and delta scales d0=2d_0=2, d1=0.5d_1=0.5, d2=3d_2=3. (a) Draw the graph for the hand’s world scale SS and compute SS. (b) Backpropagate to find ∂S/∂d0\partial S/\partial d_0, ∂S/∂d1\partial S/\partial d_1, and ∂S/∂d2\partial S/\partial d_2. (c) In the same function, world rotation composes by addition. What is the derivative of the hand’s world rotation with respect to the arm’s delta rotation? (d) To make S=4S=4, minimize (S−4)2(S-4)^2 over d1d_1 alone: take one gradient step with η=0.01\eta=0.01 and give the new SS and loss. (e) In what order would backpropagation visit the bones, compared with evaluatePose’s loop?

AI01.13LSolution

Complete lab steps 1–8 until python check.py reports every step passing on your own code. Record the first test that failed and its first incorrect step in error_log.csv.

AI01.14LSolution

Mutation hunt. Make each change below in engine.py, one at a time. For each, predict which step’s tests fail first, run python check.py, compare, and undo the change. (a) = instead of += in __mul__; (b) no seed; (c) walk the topological order forwards instead of reversed; (d) tanh⁡\tanh’s local derivative written as 1−t1-t; (e) __rsub__ returning self - other. Write one sentence per change on what the failing test’s name told you.

AI01.15SSolution

Reverse mode versus forward mode. A function has nn inputs and one output, and one evaluation costs CC. Estimate the cost of all nn partial derivatives by (a) central differences, (b) forward mode, which needs one sweep per input at a small multiple of CC each, and (c) reverse mode. Compare for n=109n=10^9. When would forward mode win?

AI01.16SSolution

The best step size. For the central difference, the approximation error is about h26M\tfrac{h^2}{6}M with M=∣f′′′(x)∣M=|f'''(x)| (Calculus II’s Taylor series proves this; for x3x^3 you saw exactly h2h^2), and the rounding error is about ε/h\varepsilon/h with ε≈10−16\varepsilon\approx10^{-16} when ∣f∣|f| is near 1. Minimize E(h)=M6h2+εhE(h)=\tfrac{M}{6}h^2+\tfrac{\varepsilon}{h} with Module 14’s method, compute h∗h^* for M=1M=1, and compare with the figure in section 2.7. (If you haven’t reached Module 14: set E′(h)=0E'(h)=0 and solve for hh. The solution shows why that gives a minimum.)

4.2. Code-reading drill: micrograd#

micrograd, by Andrej Karpathy (MIT license), is the classic hundred-line autograd engine. The engine you built follows the same design, which makes it the gentlest possible first read of someone else’s code. Read the file at this pinned commit; the line numbers below refer to it:

Protocol. Read the whole file once, top to bottom, without stopping (five minutes). Then, for each question, write your explanation in your own words before opening its solution. Describe what the code does, not what you think it should do.

AI01.17RSolution

Lines 5–11 and 24–33. (a) What type is _prev, and what does it contain after e = a * a? (b) Lines 29 and 30 each add to a gradient. For a * a, which object does each line update? Explain why the gradient still comes out as 2a2a although _prev holds a only once.

AI01.18RSolution

Lines 54–70. Explain backward() in three sentences: what build_topo produces, why line 68 exists, and why line 69 uses reversed. Which part would fail on a 5000-node chain, and what error would you see?

AI01.19RSolution

Line 68 assigns the seed, while every closure adds. On the running example, predict a.grad, b.grad, and c.grad after calling L.backward() twice without zeroing. Show the second pass node by node. Why is the answer not simply double the first?

AI01.20RSolution

(a) Line 49: what gradient does ReLU pass back when its input is exactly 0? (b) Lines 84–91: list the methods that run, in order, when Python evaluates 2 / a, and draw the nodes they create. Use the nodes to compute the derivative of 2/a2/a at a=4a=4.

AI01.21RSolution

Compare micrograd with your engine. Find two design differences (for example how the topological sort is written, which operations exist, how data is stored, what happens on a 5000-node chain) and give one advantage of each side for each difference.

4.3. Mastery gate#

AI01.22GSolution

Find ∂h/∂x\partial h/\partial x and ∂h/∂y\partial h/\partial y for h(x,y)=x ey+y2h(x,y)=x\,e^y+y^2 at (2,0)(2,0).

AI01.23GSolution

Let u=x⋅yu=x\cdot y, v=u+xv=u+x, and L=v2L=v^2, at x=1x=1, y=2y=2. Draw the graph, run the forward pass, backpropagate by hand with a table, and check both gradients algebraically.

AI01.24GSolution

A linear neuron p=wx+bp=wx+b with squared error L=(p−y)2L=(p-y)^2 on one example x=2x=2, y=5y=5, starting from w=1w=1, b=0.5b=0.5. Compute the loss, both gradients, one gradient-descent step with η=0.05\eta=0.05, and the new loss.

AI01.25GSolution

Each _backward below has one bug. For each: name it (field-guide number), give the smallest input you can find that exposes it and the wrong result it produces, and fix it.

Python
# inside __mul__
def _backward():
    self.grad = other.data * out.grad
    other.grad = self.data * out.grad

# inside tanh, where t = math.tanh(self.data)
def _backward():
    self.grad += (1 - t) * out.grad

4.4. Next steps#

  • Record the module. Keep a progress log, a spreadsheet or CSV with one row per module: first attempt, gate score, teach-back, tests passing, first error, and next review in seven days. Fill in the AI01 row, and log every miss in error_log.csv.
  • Calculus. Module 13’s linear approximation is the nudge rule of section 2.2 with one input; Module 14’s optimization reappears in AI01.16. Both will feel familiar now.
  • Module 2 starts from the engine you built: a Value holding a whole array instead of one number, and the same chain rule with matrix shapes.

5. Development log entry#

Keep a running file DEVLOG.md at the top of your lab repository. It becomes public evidence of what you built, so write every claim so that a stranger could check it. For this module, one entry:

6. Stretch (optional)#

  • S1. No recursion. Rewrite backward()’s topological sort with an explicit stack of (node, children_done) pairs, and pass python check.py --stretch, which backpropagates through a 5000-node chain.
  • S2. Why reverse mode wins. AI01.15, then the opening of W04 for forward mode with dual numbers.
  • S3. The best step size. AI01.16: Module 14’s optimization, applied to the finite-difference error.
  • S4. Second derivatives. Your engine cannot compute d2Ldx2\frac{d^2L}{dx^2}: each _backward does arithmetic on plain floats, so the backward pass leaves no graph behind to differentiate again. Sketch what would change if _backward built new Values instead. This is what PyTorch’s create_graph=True does, and optimizers that use curvature depend on it.
  • S5. Port it to C++23 (portfolio). Store nodes in one std::vector and refer to them by index, as an arena; record each node’s operation as an enum and switch on it in the backward pass instead of storing closures; sort iteratively. Benchmark nodes per second against your Python engine and write up the measured difference. If you already write C++, it plays to your strengths; either way, it gives Module 2’s argument for tensors your own numbers.
  • S6. A research question you can already ask (best after Module 2’s vectors). Is the XOR spike a catapult? The prediction: the loss’s sharpest curvature is above 2/η=202/\eta=20 just before the spike and below it just after. You can measure it with tools you have. Your engine gives gradients, and a finite difference of two gradients, (∇L(w+εv)−∇L(w−εv))/(2ε)\bigl(\nabla L(w+\varepsilon v)-\nabla L(w-\varepsilon v)\bigr)/(2\varepsilon) for a direction vv of length 1, gives a new vector: how the gradient itself changes as you move along vv. Re-aim vv along that vector, rescale it to length 1, and repeat (power iteration). vv turns toward the sharpest direction, and the vector’s length settles at the sharpest curvature. (Strictly, it finds the largest curvature in size, which near a minimum is the sharpest one.) Plot it next to the loss curve. This is how researchers measure the effect; a clean plot of it on your own engine is a genuine small result worth a write-up.

Frontier connections: where this module’s ideas show up at scale#

Read these after the gate, for orientation. Each links a Module 1 idea to the paper where it matters for large models; later modules return to them.

  • Built as it runs (section 2.9). PyTorch’s design paper argues that an eager, define-by-run style like your engine’s can still be fast: Paszke et al. (2019), arxiv.org/abs/1912.01703.
  • The graph costs memory (2.9). Recomputing some activations during the backward pass trades one extra forward pass for far less memory: Chen et al. (2016), arxiv.org/abs/1604.06174.
  • Rounding (2.7). Large models train in 16-bit or 8-bit numbers, where your rounding-error curve moves far to the right. Loss scaling and a 32-bit master copy of the weights keep training stable: Micikevicius et al. (2017), arxiv.org/abs/1710.03740.
  • The ill-conditioned valley (2.6). Adam gives each parameter its own step size (Kingma and Ba, 2014, arxiv.org/abs/1412.6980); AdamW fixes how it handles weight decay (Loshchilov and Hutter, 2017, arxiv.org/abs/1711.05101). Muon, an optimizer developed through the public NanoGPT speedrun, goes further and reshapes each weight matrix’s whole update: kellerjordan.github.io/posts/muon/.
  • Initialization and learning rates (3.7, 2.6). Under the right scaling rules, the best learning rate found on a small model transfers to a much wider one, so large runs can be tuned cheaply: Yang et al. (2022), arxiv.org/abs/2203.03466.
  • Spikes (3.8). Cohen et al. (2021), arxiv.org/abs/2103.00065; Lewkowycz et al. (2020), arxiv.org/abs/2003.02218.

Hint ladders for the lab#

Climb one rung at a time, and try again after each one. Rung 1 is a question, rung 2 points to the idea, rung 3 is a skeleton with the key parts left for you.

L1. Step 1: a Value that remembers#

Rung 1. Which six attributes does the class docstring promise? List them on paper before typing.

Rung 2. Section 2.8: dunder methods, and isinstance for wrapping plain numbers.

Rung 3. __mul__ is __add__ with two changes: the arithmetic (* for +) and the operation symbol ("*" for "+"). Nothing else changes yet.

L2. Step 2: local backward functions#

Rung 1. For out=a⋅b\text{out}=a\cdot b: if you nudge aa by one unit with bb frozen, how much does out move?

Rung 2. Section 2.3’s table gives each local derivative; section 2.4 explains why every line uses +=.

Rung 3.

Python
        def _backward():
            self.grad += <d(out)/d(self), using numbers you have> * out.grad
            other.grad += <d(out)/d(other)> * out.grad
        out._backward = _backward

L3. Step 3: backward()#

Rung 1. Which node’s gradient do you know without computing anything? Which node can safely run its _backward first, and which must wait?

Rung 2. Section 2.5: depth-first search, post-order, then reverse.

Rung 3.

Python
        order, visited = [], set()
        def visit(v):
            if ____:
                ____
                for child in ____:
                    visit(child)
                order.append(v)      # why here, and not before the loop?
        visit(self)
        ____ = 1.0
        for v in ____:
            v._backward()

L4. Step 4: break it on purpose#

Rung 1. In a + a, how many times does the add node’s _backward write to a.grad?

Rung 2. Section 2.4, the smallest example.

Rung 3. With =, the line for other runs second, and other is a. What value does it leave behind? Restore += before moving on.

L5. Step 5: the remaining operations#

Rung 1. Can the new operation be written using ones you already have? Which four cannot?

Rung 2. Section 2.3’s table; section 2.8 on reflected operators; the field guide’s B6, B11, and B12.

Rung 3.

Python
    def __pow__(self, n):
        out = Value(self.data ** n, (self,), f"**{n}")
        def _backward():
            self.grad += (____) * out.grad
        out._backward = _backward
        return out

    def __rsub__(self, other):    # other - self
        return ____               # build it from operations you have

For tanh⁡\tanh, compute the forward value with a function that cannot overflow (the math module has one), and write its local derivative in terms of out.data.

L6. Step 6: the gradient checker#

Rung 1. To estimate ∂f/∂xi\partial f/\partial x_i, which coordinates change and which stay fixed? What must xs look like when you return?

Rung 2. Section 2.7: the central difference.

Rung 3.

Python
    grads = []
    for i in range(len(xs)):
        up, down = list(xs), list(xs)      # copies, so xs is never changed
        up[i] += h
        down[i] -= h
        grads.append(____)
    return grads

For max_grad_error, the float version of f is lambda x: f([Value(v) for v in x]).data.

L7. Step 7: neurons, layers, networks#

Rung 1. Which Value operations does w0x0+w1x1+⋯+bw_0x_0+w_1x_1+\dots+b need? What does sum start from if you don’t tell it?

Rung 2. Section 2.1’s neuron; section 2.8 on sum(items, start).

Rung 3.

Python
        act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b)
        return ____ if self.nonlin else ____

class MLP(Module):
    def __init__(self, nin, nouts):
        sizes = [nin] + nouts
        self.layers = [Layer(sizes[i], sizes[i + 1], nonlin=____)
                       for i in range(len(nouts))]

In MLP.__call__, remember that a hidden layer of width 1 hands back a single Value, while the next layer’s neurons expect a list. If you see 'Value' object is not iterable, that is the cause.

L8. Step 8: training#

Rung 1. Which four things happen in one step, and in what order? What is in grad before you begin?

Rung 2. Section 2.6 (the update rule), and the field guide’s B8 and B9.

Rung 3.

Python
def train_step(model, xs, ys, lr):
    model.zero_grad()
    loss = mse_loss([model(x) for x in xs], ys)
    loss.backward()
    for p in model.parameters():
        p.data ____ lr * p.grad
    return ____

Complete solutions: AI01#

Open a solution only after you have written your own attempt. Compare at the first step where your work differs. A corrected answer is not yet an independent success.

AI01.01Show solution

Freeze, then differentiate

Freeze yy: 3x2y3x^2y is the constant 3y3y times x2x^2, the term −5y2-5y^2 is constant, and xx has derivative 1. So ∂f/∂x=6xy+1\partial f/\partial x=6xy+1, and at (2,−1)(2,-1) it is 6(2)(−1)+1=−116(2)(-1)+1=\boxed{-11}. Freeze xx: 3x2y3x^2y is the constant 3x23x^2 times yy, −5y2-5y^2 gives −10y-10y, and xx is constant. So ∂f/∂y=3x2−10y\partial f/\partial y=3x^2-10y, and at (2,−1)(2,-1) it is 12+10=2212+10=\boxed{22}. Each term that does not contain the active letter disappears.

Back to AI01.01
AI01.02Show solution

Data is frozen; knobs vary

The constants are the data, x=2x=2 and y=1y=1; the variables are ww and bb. Let r=wx+b−yr=wx+b-y (the residual), so g=r2g=r^2. By the chain rule, ∂g/∂w=2r⋅∂r/∂w=2r x\partial g/\partial w=2r\cdot\partial r/\partial w=2r\,x and ∂g/∂b=2r⋅1\partial g/\partial b=2r\cdot1. At w=1w=1, b=0b=0: r=2+0−1=1r=2+0-1=1, so ∂g/∂w=2(1)(2)=4\partial g/\partial w=2(1)(2)=\boxed{4} and ∂g/∂b=2\partial g/\partial b=\boxed{2}. The factor xx in ∂g/∂w\partial g/\partial w is the inner derivative. Forgetting it is the classic slip, and it is exactly “gradient equals twice the residual times the input,” which Module 2 will write for a whole matrix at once.

Back to AI01.02
AI01.03Show solution

The Workbench table, then a rewrite

(a) u+vu+v: 1 and 1. u⋅vu\cdot v: vv and uu. unu^n: n un−1n\,u^{n-1}. eue^u: eue^u. tanh⁡u\tanh u: 1−tanh⁡2u1-\tanh^2u. (b) q=x y−1q=x\,y^{-1}. Freeze yy: ∂q/∂x=y−1=1/3\partial q/\partial x=y^{-1}=\boxed{1/3}. Freeze xx: ∂q/∂y=x⋅(−1) y−2=−x/y2=−6/9=−2/3\partial q/\partial y=x\cdot(-1)\,y^{-2}=-x/y^2=-6/9=\boxed{-2/3}. Final sweep: the new exponent is −1−1=−2-1-1=-2, still negative. Writing y0y^{0} or y2y^{2} here is the same slip as −48x4-48x^4 in warm-up question 1.

Back to AI01.03
AI01.04Show solution

Two paths that cancel

Forward: s=2+(−1)=1s=2+(-1)=1, p=(−1)(3)=−3p=(-1)(3)=-3, L=s⋅p=−3L=s\cdot p=-3.

NodeUpstreamLocal derivativesSent
L=s⋅pL=s\cdot p1∂L/∂s=p=−3\partial L/\partial s=p=-3, ∂L/∂p=s=1\partial L/\partial p=s=1ss gets −3-3, pp gets 11
s=x+ys=x+y−3-31, 1xx gets −3-3, yy gets −3-3
p=y⋅zp=y\cdot z11∂p/∂y=z=3\partial p/\partial y=z=3, ∂p/∂z=y=−1\partial p/\partial z=y=-1yy gets 33, zz gets −1-1

Totals: ∂L/∂x=−3\partial L/\partial x=\boxed{-3}, ∂L/∂y=−3+3=0\partial L/\partial y=-3+3=\boxed{0}, ∂L/∂z=−1\partial L/\partial z=\boxed{-1}. At this point a small nudge to yy pushes LL down through ss exactly as much as it pushes LL up through pp. A zero gradient doesn’t mean yy doesn’t matter; it means its effects balance here. Expanding, L=(x+y)yz=xyz+y2zL=(x+y)yz=xyz+y^2z, so ∂L/∂x=yz=−3\partial L/\partial x=yz=-3, ∂L/∂y=xz+2yz=6−6=0\partial L/\partial y=xz+2yz=6-6=0, and ∂L/∂z=xy+y2=−2+1=−1\partial L/\partial z=xy+y^2=-2+1=-1.

Back to AI01.04
AI01.05Show solution

Where the 3 in 3x23x^2 comes from

Forward: u=x⋅x=4u=x\cdot x=4, y=u⋅x=8y=u\cdot x=8. Backward: yy has gradient 1. The node y=u⋅xy=u\cdot x sends uu the value x⋅1=2x\cdot1=2 and sends xx the value u⋅1=4u\cdot1=4 (the direct path). The node u=x⋅xu=x\cdot x has xx as both inputs, so it sends x⋅2=4x\cdot2=4 to its first input and x⋅2=4x\cdot2=4 to its second, and both land on xx. Total: 4+4+4=12=3x24+4+4=\boxed{12}=3x^2. There are three paths, one for each place xx appears, and each contributes x2=4x^2=4. That is why the power rule’s coefficient equals the number of copies.

Back to AI01.05
AI01.06Show solution

Many valid orders

With the opposite child order: visit LL →\to visit cc (append cc) →\to visit dd →\to visit bb (append bb) →\to visit ee →\to bb already visited →\to visit aa (append aa) →\to append ee →\to append dd →\to append LL. The post-order is c,b,a,e,d,L\boxed{c,b,a,e,d,L} and its reverse is L,d,e,a,b,cL,d,e,a,b,c. (i) is valid: ee follows aa and bb, dd follows ee and bb, and LL follows dd and cc. (ii) is valid; it is the list just found. (iii) is invalid: ee comes before its inputs aa and bb. A graph usually has many topological orders, and backpropagation works with any of them.

Back to AI01.06
AI01.07Show solution

The factor 1−2η1-2\eta

(a) 0.60.6, 1.081.08, 1.4641.464, as in section 2.6. (b) wk+1=wk−η⋅2(wk−3)w_{k+1}=w_k-\eta\cdot2(w_k-3); subtract 3 from both sides and factor: wk+1−3=(wk−3)(1−2η)w_{k+1}-3=(w_k-3)(1-2\eta). (c) Let r=1−2ηr=1-2\eta. The iteration converges when ∣r∣<1|r|<1, that is for 0<η<10<\eta<1 (without overshooting when η<0.5\eta<0.5, zigzagging when 0.5<η<10.5<\eta<1). At η=1\eta=1, r=−1r=-1 and it bounces 0,6,0,6,…0,6,0,6,\dots forever. For η>1\eta>1, ∣r∣>1|r|>1 and it diverges. (d) η=0.5\boxed{\eta=0.5} makes r=0r=0. For L=k(w−3)2L=k(w-3)^2 the factor becomes 1−2kη1-2k\eta: the steeper the valley, the smaller the safe learning rate.

Back to AI01.07
AI01.08Show solution

Expand, then compare

2.13=9.2612.1^3=9.261 and 1.93=6.8591.9^3=6.859. Forward: (9.261−8)/0.1=12.61(9.261-8)/0.1=\boxed{12.61}, error 0.610.61. Central: (9.261−6.859)/0.2=2.402/0.2=12.01(9.261-6.859)/0.2=2.402/0.2=\boxed{12.01}, error 0.010.01. Section 2.7 predicts both exactly. The forward error is 3xh+h2=3(2)(0.1)+0.01=0.613xh+h^2=3(2)(0.1)+0.01=0.61, and the central error is h2=0.01h^2=0.01.

Back to AI01.08
AI01.09Show solution

tanh⁡\tanh and saturation

Let N=ex−e−xN=e^x-e^{-x} and D=ex+e−xD=e^x+e^{-x}. Since ddxe−x=−e−x\frac{d}{dx}e^{-x}=-e^{-x}, we get N′=ex+e−x=DN'=e^x+e^{-x}=D and D′=ex−e−x=ND'=e^x-e^{-x}=N. The quotient rule gives

ddxND=N′D−ND′D2=D2−N2D2=1−(ND)2=1−tanh⁡2x.\frac{d}{dx}\frac{N}{D}=\frac{N'D-ND'}{D^2}=\frac{D^2-N^2}{D^2}=1-\left(\frac ND\right)^2=\boxed{1-\tanh^2x}.

At x=0x=0: tanh⁡0=0\tanh0=0, so the derivative is 11. At x=3x=3: tanh⁡3≈0.995055\tanh3\approx0.995055, so it is 1−0.990134≈0.0098661-0.990134\approx\boxed{0.009866}. A neuron whose input sum is 3 passes back less than 1% of the gradient it receives, so its weights barely learn: it is saturated. Keeping sums in the responsive middle of tanh⁡\tanh is the job of good initialization and normalization in Module 2.

Back to AI01.09
AI01.10Show solution

Rewrite, differentiate, then clean up the fraction

σ(x)=(1+e−x)−1\sigma(x)=(1+e^{-x})^{-1}. Outer layer: −1⋅(1+e−x)−2-1\cdot(1+e^{-x})^{-2}. Inner layer: ddx(1+e−x)=−e−x\frac{d}{dx}(1+e^{-x})=-e^{-x}. The product is σ′(x)=e−x(1+e−x)2\sigma'(x)=\dfrac{e^{-x}}{(1+e^{-x})^2}; the two minus signs cancel. Now 1−σ=(1+e−x)−11+e−x=e−x1+e−x1-\sigma=\dfrac{(1+e^{-x})-1}{1+e^{-x}}=\dfrac{e^{-x}}{1+e^{-x}}, so σ(1−σ)=11+e−x⋅e−x1+e−x=σ′(x)\sigma(1-\sigma)=\dfrac{1}{1+e^{-x}}\cdot\dfrac{e^{-x}}{1+e^{-x}}=\sigma'(x). At 0: σ(0)=12\sigma(0)=\tfrac12, so σ′(0)=12⋅12=0.25\sigma'(0)=\tfrac12\cdot\tfrac12=\boxed{0.25}.

Back to AI01.10
AI01.11Show solution

One neuron, one step

Forward: z=0.5z=0.5, p=tanh⁡0.5=0.462117p=\tanh0.5=0.462117, L=(0.462117−1)2=0.289318L=(0.462117-1)^2=\boxed{0.289318}. Backward: ∂L/∂p=2(p−y)=−1.075766\partial L/\partial p=2(p-y)=-1.075766; ∂p/∂z=1−p2=0.786448\partial p/\partial z=1-p^2=0.786448; ∂L/∂z=−1.075766×0.786448=−0.846033\partial L/\partial z=-1.075766\times0.786448=-0.846033. Since ∂z/∂w=x=1\partial z/\partial w=x=1 and ∂z/∂b=1\partial z/\partial b=1, both ∂L/∂w\partial L/\partial w and ∂L/∂b\partial L/\partial b equal −0.846033\boxed{-0.846033}. Step: w=0.5−0.5(−0.846033)=0.923017w=0.5-0.5(-0.846033)=\boxed{0.923017} and b=0+0.423017=0.423017b=0+0.423017=\boxed{0.423017}. New forward: z=1.346033z=1.346033, p=0.873114p=0.873114, new loss 0.016100\boxed{0.016100}. One step cut the loss by a factor of 18.

Back to AI01.11
AI01.12Show solution

A skeleton is a computation graph

(a) S=((1⋅d0)⋅d1)⋅d2S=\bigl((1\cdot d_0)\cdot d_1\bigr)\cdot d_2: the root’s world scale is the rig scale times d0d_0, the arm’s is that times d1d_1, and the hand’s is that times d2d_2. So S=2⋅0.5⋅3=3S=2\cdot0.5\cdot3=\boxed{3}. (b) Start with gradient 1 at SS. The hand’s multiply sends the arm’s world scale (1) to d2d_2 and d2=3d_2=3 to the arm’s world scale. The arm’s multiply sends the root’s world scale times 3, which is 2⋅3=62\cdot3=6, to d1d_1, and d1⋅3=1.5d_1\cdot3=1.5 to the root’s world scale. The root’s multiply sends 1⋅1.5=1.51\cdot1.5=1.5 to d0d_0. So ∂S/∂d0=1.5\partial S/\partial d_0=\boxed{1.5}, ∂S/∂d1=6\partial S/\partial d_1=\boxed{6}, ∂S/∂d2=1\partial S/\partial d_2=\boxed{1}: each is the product of the other two scales. (c) World rotations add, and add passes gradients through unchanged, so the derivative is 1\boxed{1}. (d) With ℓ=(S−4)2\ell=(S-4)^2, dℓ/dd1=2(S−4)⋅6=2(−1)(6)=−12d\ell/dd_1=2(S-4)\cdot6=2(-1)(6)=-12. Then d1←0.5−0.01(−12)=0.62d_1\leftarrow0.5-0.01(-12)=0.62, giving S=2(0.62)(3)=3.72S=2(0.62)(3)=\boxed{3.72} and loss (3.72−4)2=0.0784(3.72-4)^2=\boxed{0.0784}, down from 1. (e) evaluatePose runs root, arm, hand (parent first). Backpropagation runs hand, arm, root: the same list, reversed. Fitting joint parameters to move an end effector toward a target by gradient descent is one standard way to solve inverse kinematics.

Back to AI01.12
AI01.13Show solution

The tests, and the usual first failures

Your own code passing every test is the result; there is no single correct file to match, and section 4.2 compares your design with a published one. The usual first failures, in order: B10 (an __eq__ was defined) in step 1; B1 or B2 in step 2; B3 or B4 in step 3; B6 with negative exponents, and B12, in step 5; B8 in step 8. Log the category and the first incorrect step, not just “fixed.”

Back to AI01.13
AI01.14Show solution

What each mutation breaks

(a) = in __mul__: step 2’s manual backprop fails first, with bb’s gradient 15 instead of 20, because ee’s report overwrites dd’s. (b) No seed: step 3 fails, with every gradient 0. (c) Forward order: step 3 fails; leaves run their (empty) backward first, and the output’s gradient reaches its inputs only after they have already run. Only graphs one operation deep survive (the single-leaf test, the seed test, a+aa+a, and a⋅aa\cdot a), because their inputs are leaves with nothing to pass on. (d) 1−t1-t: step 5’s tanh⁡\tanh test fails, and so do step 6’s random graphs. The saturation test fails too, but only because of x=−1000x=-1000: there t=−1t=-1, so 1−t=21-t=2 while 1−t2=01-t^2=0. At x=+1000x=+1000, t=1t=1 and both expressions give 0, so a test at +1000+1000 alone would have missed it. Test away from special points, and on both sides of them. (e) self - other: step 5’s rsub test fails with -6.0 != 6.0. You computed 3−5=−23-5=-2 instead of 5−3=25-3=2, and the test multiplies by 3. In each case, the failing test’s name points straight at the idea that broke.

Back to AI01.14
AI01.15Show solution

Reverse mode is why training is affordable

(a) Central differences need two evaluations per input: about 2nC2nC. (b) Forward mode needs one sweep per input: about nn times a small multiple of CC. (c) Reverse mode needs one forward and one backward pass: a small constant multiple of CC that does not grow with nn (it does need memory to keep the graph). For n=109n=10^9 and C=1C=1 second, finite differences would take 2×1092\times10^9 seconds, about 63 years; reverse mode takes a few seconds. Forward mode wins with few inputs and many outputs. With one input and mm outputs, one forward sweep gives all mm derivatives while reverse mode needs mm backward passes, and forward mode stores no graph.

Back to AI01.15
AI01.16Show solution

Module 14 applied to a numerical method

E′(h)=M3h−εh2E'(h)=\tfrac{M}{3}h-\tfrac{\varepsilon}{h^2}. Setting it to zero: M3h3=ε\tfrac M3h^3=\varepsilon, so h∗=(3ε/M)1/3h^*=\bigl(3\varepsilon/M\bigr)^{1/3}. With M=1M=1 and ε=10−16\varepsilon=10^{-16}: h∗=(3×10−16)1/3≈6.7×10−6h^*=(3\times10^{-16})^{1/3}\approx\boxed{6.7\times10^{-6}}. It is a minimum because E′′(h)=M3+2εh3>0E''(h)=\tfrac M3+\tfrac{2\varepsilon}{h^3}>0. The error there is E(h∗)≈7.5×10−12+1.5×10−11≈2×10−11E(h^*)\approx7.5\times10^{-12}+1.5\times10^{-11}\approx2\times10^{-11}. The figure’s best central step, near 10−610^{-6} with error near 10−1210^{-12}, agrees in order of magnitude. Real rounding errors are often smaller than the worst case, and for tanh⁡\tanh at 0.5, ∣f′′′∣≈0.57|f'''|\approx0.57.

Back to AI01.16
AI01.17Show solution

A set of inputs, two separate updates

(a) Line 10 builds _prev as a set of the children. For a * a the children are (a, a), so the set holds a once. (b) In the multiply closure, self and other are both a. Line 29 adds other.data×g=a g\texttt{other.data}\times g=a\,g into a.grad, and line 30 adds self.data×g=a g\texttt{self.data}\times g=a\,g into the same object again, so a.grad becomes 2a g2a\,g. The set only controls which nodes the traversal visits. The arithmetic lives in the closure, which captured self and other as two separate names. If either line used =, the second would overwrite the first and give a ga\,g (bug B1). Your engine stores the tuple (a, a) instead; the visited set in backward() makes sure a is still processed once, so both designs give the same gradient.

Back to AI01.17
AI01.18Show solution

Sort, seed, reverse

build_topo (lines 59–64) is a recursive depth-first search that appends a node only after all of its children, using the visited set so that each node appears once; the result is a topological order. Line 68 seeds the output’s gradient with 1, because ∂L/∂L=1\partial L/\partial L=1. Line 69 walks the list in reverse, so each node runs its _backward only after every node that uses it has added its contribution. On a 5000-node chain, build_topo calls itself once per node, which exceeds Python’s default limit of about 1000 nested calls and raises RecursionError: maximum recursion depth exceeded.

Back to AI01.18
AI01.19Show solution

Assign the seed, accumulate everything else

After the first call: L=1L=1, d=5d=5, c=−8c=-8, e=5e=5, b=20b=20, a=−10a=-10. The second call first resets LL’s gradient to 1. Then, in reverse topological order:

  • L=d⋅cL=d\cdot c: dd gains c⋅1=5c\cdot1=5 and becomes 10; cc gains d⋅1=−8d\cdot1=-8 and becomes −16-16.
  • d=e+bd=e+b, now with gradient 10: ee gains 10 and becomes 15; bb gains 10 and becomes 30.
  • e=a⋅be=a\cdot b, now with gradient 15: aa gains b⋅15=−30b\cdot15=-30 and becomes −40-40; bb gains a⋅15=45a\cdot15=45 and becomes 75.

Final: a=−40, b=75, c=−16\boxed{a=-40,\ b=75,\ c=-16}. Doubling would give −20-20, 4040, −16-16. The interior gradients of dd and ee were never reset, so each started the second pass already holding its first-pass value, added a second copy, and passed the inflated total downstream. Only cc, a direct input of LL (whose gradient was reset), is exactly doubled. PyTorch avoids this. It refuses a second backward pass through the same graph unless you pass retain_graph=True, and even then it recomputes the gradients through the graph from scratch and only adds the results into the leaves’ .grad, so the leaves would be exactly doubled. The lesson: zero every gradient (or build a fresh graph) before each backward pass.

Back to AI01.19
AI01.20Show solution

A convention, and a chain of fallbacks

(a) Line 49 multiplies the incoming gradient by the truth value of “output greater than 0.” At input 0 the output is 0, so that is false (0) and ReLU passes back 0. The true derivative does not exist at the corner; any value from 0 to 1 is defensible, and 0 is the common convention (PyTorch uses it too). (b) Python first asks the int 2 to divide by a; the int returns NotImplemented, so Python calls a.__rtruediv__(2) (lines 90–91). That computes 2 times a to the power −1-1. The power runs first (__pow__, lines 35–43), making node p=a−1=0.25p=a^{-1}=0.25. Next comes the product with the plain number 2. The int again returns NotImplemented, so Python calls p.__rmul__(2) (lines 84–85), which calls __mul__ (lines 24–33). That wraps 2 in a Value (line 25) and makes node q=0.5q=0.5. The nodes are aa, pp, the wrapped 2, and qq. Backward: qq starts at 1; the multiply sends 2⋅1=22\cdot1=2 to pp; the power sends (−1)a−2⋅2=−216(-1)a^{-2}\cdot2=-\tfrac{2}{16} to aa. So the derivative is −0.125\boxed{-0.125}, matching dda2a−1=−2a−2\frac{d}{da}2a^{-1}=-2a^{-2}. The exponent −1−1=−2-1-1=-2 stays negative.

Back to AI01.20
AI01.21Show solution

What a good comparison contains

A strong answer names two real differences and gives each side a fair advantage. Typical differences:

  • The sort. micrograd’s sort is recursive: short and easy to read, but limited to about 1000 levels. An explicit stack handles any depth but is longer.
  • Set or tuple. micrograd’s set stores each input once and is simple, but it orders inputs by memory address, so gradient sums can differ in the last digits from run to run. A tuple costs a duplicate entry for a * a and gives identical runs (section 2.9).
  • The operations. micrograd has no tanh or exp in its engine (its network uses ReLU). A smaller surface means fewer tests; more operations means more places for bugs.
  • Stored types. micrograd keeps whatever number type it is given (an int stays an int, and gradients start as the int 0). Converting to float keeps printing and arithmetic uniform.
  • Power exponents. micrograd checks with an assert that the exponent is a plain number. A TypeError is more conventional in library code, and an assert disappears entirely under python -O.

If both your differences are cosmetic (names, comments, formatting), find one that changes behavior.

Back to AI01.21
AI01.22Show solution

Gate: freeze, then differentiate

Freeze yy: x eyx\,e^y is xx times the constant eye^y, and y2y^2 is constant. So ∂h/∂x=ey\partial h/\partial x=e^y, which is e0=1e^0=\boxed{1} at (2,0)(2,0). Freeze xx: ∂h/∂y=x ey+2y\partial h/\partial y=x\,e^y+2y, which is 2⋅1+0=22\cdot1+0=\boxed{2}.

Back to AI01.22
AI01.23Show solution

Gate: backpropagation by hand

Forward: u=2u=2, v=3v=3, L=9L=9. Backward: L=v2L=v^2 gives vv the gradient 2v=62v=6. Then v=u+xv=u+x sends 6 to uu and 6 to xx (first path). Then u=x⋅yu=x\cdot y sends y⋅6=12y\cdot6=12 to xx (second path) and x⋅6=6x\cdot6=6 to yy. Totals: ∂L/∂x=6+12=18\partial L/\partial x=6+12=\boxed{18} and ∂L/∂y=6\partial L/\partial y=\boxed{6}. Check: L=(xy+x)2=x2(y+1)2L=(xy+x)^2=x^2(y+1)^2, so ∂L/∂x=2x(y+1)2=18\partial L/\partial x=2x(y+1)^2=18 and ∂L/∂y=2x2(y+1)=6\partial L/\partial y=2x^2(y+1)=6.

Teach-back rubric (gate part 3). A complete explanation (i) names both paths from bb to LL and what each carries: 15 through ee (local derivative a=3a=3 times 5) and 5 directly into dd (local derivative 1 times 5); (ii) says the two add because the effects of small nudges add; (iii) says a node’s gradient is complete only after all of its users have reported, which reverse topological order guarantees; and (iv) uses “upstream gradient” and “local derivative” correctly.

Back to AI01.23
AI01.24Show solution

Gate: one step downhill

p=1⋅2+0.5=2.5p=1\cdot2+0.5=2.5 and L=(2.5−5)2=6.25L=(2.5-5)^2=\boxed{6.25}. ∂L/∂p=2(p−y)=−5\partial L/\partial p=2(p-y)=-5, so ∂L/∂w=−5⋅x=−10\partial L/\partial w=-5\cdot x=\boxed{-10} and ∂L/∂b=−5\partial L/\partial b=\boxed{-5}. Step: w=1−0.05(−10)=1.5w=1-0.05(-10)=\boxed{1.5} and b=0.5−0.05(−5)=0.75b=0.5-0.05(-5)=\boxed{0.75}. New prediction 3+0.75=3.753+0.75=3.75; new loss (−1.25)2=1.5625(-1.25)^2=\boxed{1.5625}.

Back to AI01.24
AI01.25Show solution

Gate: find the bugs

First snippet: B1, the overwrite. Smallest exposing input: a * a with a=3a=3. Both lines write a.grad, the second overwrites the first, and you get 33 instead of 66. Any graph where a value has more than one user also exposes it. Fix: += on both lines. Second snippet: B6, a wrong local derivative (1−t1-t instead of 1−t21-t^2). At x=0x=0 both give 1, so a test at 0 passes. At x=0.5x=0.5 the code gives 0.5378830.537883 instead of 0.7864480.786448. Fix: self.grad += (1 - t**2) * out.grad. Lesson: test away from special points such as 0 and 1.

Back to AI01.25

Warm-up answers#

1. 16⋅(−3)x−3−1=−48x−416\cdot(-3)x^{-3-1}=\boxed{-48x^{-4}}: the new exponent is −4-4, still negative. 2. Outer layer 5(3x2+1)45(3x^2+1)^4, inner layer 6x6x: 30x(3x2+1)4\boxed{30x(3x^2+1)^4}. 3. 15−1=1−55=−45\tfrac15-1=\tfrac{1-5}{5}=\boxed{-\tfrac45}, so ddxx1/5=15x−4/5\frac{d}{dx}x^{1/5}=\boxed{\tfrac15x^{-4/5}}.

Retention and readiness#

In two days, redo AI01.04 and AI01.09 without looking. In seven days, make up a fresh backpropagation-by-hand problem in the style of AI01.23 (a new expression and new numbers), solve it by hand, check every gradient with your own engine, and redo AI01.19. Log every miss under its friction family. Move on to Module 2 only after the mastery gate in section 4.3 passes.