Scalar reverse mode: the Value graph
Overview
Section titled “Overview”| Module | M08.2 · build · Python · Pass 2 · 2 to 3 h |
| You build | python/tinyllm/autograd/scalar.py: Value (+ - * / **, exp, log, tanh, relu, backward) |
| Contract | course/contracts/py/tinyllm/autograd/scalar.pyi |
| Tests | course/tests/M08.2/ (what they check: section 4) |
| Needs | M06.1 iterative topological sort (toposort) · M08.1 dual numbers (the tests compare) · M04.2 numeric VJP (or --ref-deps) |
| Used by | L0.1 (the scalarized oracle for broadcasting backward in your Tensor engine) |
| Milestone | MS-P2 (Pass 2 gate: every math module of the pass checks green, then your autograd bigram trains) |
| Optional depth | Karpathy, micrograd and the lecture “The spelled-out intro to neural networks and backpropagation”; Baydin et al., Automatic Differentiation in Machine Learning: a Survey, section 3.2 |
Key Takeaways
Section titled “Key Takeaways”- Reverse mode records the computation as a graph, then walks it once from the output, pushing into each node’s inputs by the chain rule (
test_hand_example). - A node used several times receives several contributions, so gradients are accumulated with
+=, never assigned (test_reused_node_accumulates). - The walk must visit each node once, after every consumer has added to it: reverse topological order, which
M06.1’s iterativetoposortprovides without recursion (test_diamond_visits_each_node_once,test_deep_chain_no_recursion_error). - One backward pass gives the gradient with respect to every input, and it agrees with forward mode on every input (
test_matches_dual,test_gradcheck_each_op).
How to work this chapter
Section titled “How to work this chapter”ol start M08.2 # stubs scalar.py into your repo, contract alongsideol tests M08.2 # read the test catalog first: rung R0, you write no tests hereol check M08.2 # exit code is the verdictol check M08.2 --ref-deps # only if your M06.1, M08.1, or M04.2 is not passing yetol diff M08.2 # after passing: your code against the reference1. Why now
Section titled “1. Why now”Your bigram has weights and one loss. Forward mode (M08.1) would need 65536 passes to get the gradient, one per weight; L0.5 trains it for thousands of steps. Reverse mode gets all 65536 partial derivatives from one forward pass and one backward pass, and that is the engine L0.1 builds over numpy tensors. Tensors add broadcasting, shapes, and in-place buffers, which hide the mechanics. This module builds the same engine one scalar at a time, where every node is a number you can print, and L0.1 then uses it as an oracle: a tensor op’s gradient must equal the gradient of the same computation spelled out in scalar Values.
2. Principles
Section titled “2. Principles”| Symbol | Meaning | Type / shape |
|---|---|---|
| the final scalar output (the loss) | Value | |
| any node of the graph: an input, a constant, or an intermediate result | Value | |
| the adjoint of : how much changes per unit change of | v.grad | |
| one operation with inputs | Value with _prev = (x_1, ..., x_k) | |
| the operation’s local derivative | float | |
| a weight vector for a vector-valued output, in | float64[m] |
The graph. Every operation creates a new Value holding its result, the inputs it came from (_prev), and a closure (_backward) that knows the local derivatives. Inputs are leaves. Because a node is created only after its inputs exist, the graph has no cycles: it is a directed acyclic graph, the object M06.1 sorts.
The chain rule, pushed backward. If depends on only through the nodes that consume , the multivariable chain rule says
So each node , once its own is complete, adds into each input’s adjoint. The sum over consumers is why the update is x.grad += ...: a node used twice (the variable in the worked example, every weight shared across time steps in L3.1) collects one term per use.
The order of the walk. A node may push its adjoint only after every consumer has pushed into it, otherwise it pushes a partial sum. Seed ( changes one-for-one with itself) and visit nodes so that every node comes before its inputs: a reverse topological order. M06.1’s toposort(root, parents) returns exactly that list for parents = lambda v: v._prev, each reachable node once, and it is iterative, so a graph 50000 nodes deep does not hit Python’s recursion limit of about 1000 frames. Walking paths instead of nodes (a stack that pushes every input every time) visits a shared node once per path: its _backward runs several times and its inputs get multiples of the right gradient.
Local rules. Each operation’s _backward adds times its partial derivatives:
| adds to its inputs | |
|---|---|
| , | |
| , | |
| , constant | |
| (reuse the output) | |
| (reuse the output) | |
| , so 0 at exactly |
Subtraction and division need no rules of their own: and . A Python number in an operation becomes a constant leaf; Python calls the reflected method (__rsub__, __rtruediv__) when the number is on the left, and the operand order must survive.
What it costs, and what it computes. The forward pass does one operation per node; the backward pass does one local-derivative product per edge. So the full gradient of a scalar costs a small constant times one forward pass, whatever the number of inputs. That is the asymmetry with forward mode, which costs one pass per input. For a vector output , run backward from : the leaves end up holding , a vector-Jacobian product (VJP), the row-vector counterpart of M08.1’s JVP.
Backward accumulates, it never resets. Calling backward twice adds every gradient twice (PyTorch behaves the same). Training loops clear gradients before each step for this reason (zero_grad in M10.2).
3. Worked example by hand
Section titled “3. Worked example by hand”at , , .
Forward.
| node | operation | value |
|---|---|---|
Backward, in reverse topological order , then the leaves:
| visit | its adjoint | pushes |
|---|---|---|
| (seed) | , | |
| , | ||
| , |
Result: , , . By hand, , so , , . The two paths into (through directly and through ) add up; with = instead of +=, would end as or depending on the order.
These numbers are the first case in section 4, test_hand_example.
4. The interface
Section titled “4. The interface”class Value: data: float; grad: float; _prev: tuple[Value, ...]; _op: str; _backward: Callable[[], None] def __init__(self, data: float, _children: tuple = (), _op: str = '') -> None # + - * / with Values or numbers on either side, unary -, ** with a constant exponent def exp(self) -> Value; def log(self) -> Value; def tanh(self) -> Value; def relu(self) -> Value def backward(self) -> None # self.grad = 1, then _backward in M06.1's toposort orderbackward calls toposort(self, lambda v: v._prev) from tinyllm.autograd.graph (M06.1): each reachable node once, every node before the nodes it was computed from. Value ** Value is a TypeError.
What the tests check
Section titled “What the tests check”| Test | KIND | Checks | Why it matters downstream |
|---|---|---|---|
test_hand_example | unit | the section 3 forward and backward tables | you and the test agree on the walk |
test_gradcheck_each_op | gradcheck | every operation against the frozen central differences at three points | each local rule is right |
test_matches_dual | differential | one expression using every operation, both partials, against M08.1 at 25 points | forward and reverse mode agree |
test_vjp_matches_numeric_vjp | differential | for a map against M04.2’s vjp_numeric | reverse mode is a VJP |
test_reused_node_accumulates | unit | , , | weights shared across positions |
test_diamond_visits_each_node_once | unit | gives | shared subexpressions everywhere |
test_deep_chain_no_recursion_error | boundary | a 50000-node chain | unrolled recurrences (L3.1) |
test_relu_gradient_at_zero | boundary | gradient 0 at exactly 0 | agreement with PyTorch at kinks |
test_constants_on_either_side | unit | , , , , , , , | numbers mixed into expressions |
test_pow_needs_a_constant_exponent | boundary | Value ** Value is a TypeError | no silently wrong rule |
test_backward_twice_accumulates | unit | a second call doubles the leaf gradient | why training zeroes gradients |
5. Pitfalls
Section titled “5. Pitfalls”| Pitfall | Symptom | Caught by |
|---|---|---|
1. x.grad = ... instead of += | a node used twice keeps only its last contribution | test_reused_node_accumulates (mutant s01) |
| 2. walking inputs first, or walking every path | partial or repeated pushes; shared nodes get wrong multiples | test_diamond_visits_each_node_once (mutants s03, s13) |
3. a recursive topological sort inside backward | RecursionError on a deep chain | test_deep_chain_no_recursion_error (mutant s05) |
| 4. giving each factor its own value in the product rule | is wrong in the worked example | test_hand_example (mutant s02) |
| 5. from the input () instead of the output () | wrong slopes away from 0 | test_gradcheck_each_op (mutant s06) |
6. >= in relu’s backward | gradient 1 at the kink, unlike PyTorch | test_relu_gradient_at_zero (mutant s07) |
| 7. a reflected method that reverses the operands | has slope | test_constants_on_either_side (mutants s11, s12) |
| 8. adding to the seed instead of setting it | the second backward starts from 2 | test_backward_twice_accumulates (mutant s04) |
6. Where it’s used next
Section titled “6. Where it’s used next”| Direction | Module | How it uses this |
|---|---|---|
| Back | M06.1 | toposort(root, parents): the order of the walk, without recursion |
| Back | M08.1 | Dual computes the same derivatives forward; the tests compare |
| Back | M04.2 | vjp_numeric approximates the same by central differences |
| Forward | L0.1 | your Tensor engine’s broadcasting backward is checked against the same computation spelled out in Values |
If you skip this module, ol check L0.1 stops with L0.1 needs M08.2: build it, or rerun with --ref-deps.
Going further
Section titled “Going further”| Your piece | Production equivalent | What it adds | Where to look |
|---|---|---|---|
Value | micrograd | the same engine plus a tiny neural-network library on top | micrograd/engine.py |
backward | PyTorch’s autograd engine | dependency counting and a ready queue instead of a precomputed order, multithreaded per device, retained or freed graphs | torch/csrc/autograd/engine.cpp |
VJP from backward | JAX jax.vjp | reverse mode derived by linearizing and transposing the forward trace | jax/_src/interpreters/ad.py |