Skip to content

Scalar reverse mode: the Value graph

ModuleM08.2 · build · Python · Pass 2 · 2 to 3 h
You buildpython/tinyllm/autograd/scalar.py: Value (+ - * / **, exp, log, tanh, relu, backward)
Contractcourse/contracts/py/tinyllm/autograd/scalar.pyi
Testscourse/tests/M08.2/ (what they check: section 4)
NeedsM06.1 iterative topological sort (toposort) · M08.1 dual numbers (the tests compare) · M04.2 numeric VJP (or --ref-deps)
Used byL0.1 (the scalarized oracle for broadcasting backward in your Tensor engine)
MilestoneMS-P2 (Pass 2 gate: every math module of the pass checks green, then your autograd bigram trains)
Optional depthKarpathy, 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
  • Reverse mode records the computation as a graph, then walks it once from the output, pushing vˉ=∂L/∂v\bar v = \partial L / \partial v 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 iterative toposort provides 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).
Terminal window
ol start M08.2 # stubs scalar.py into your repo, contract alongside
ol tests M08.2 # read the test catalog first: rung R0, you write no tests here
ol check M08.2 # exit code is the verdict
ol check M08.2 --ref-deps # only if your M06.1, M08.1, or M04.2 is not passing yet
ol diff M08.2 # after passing: your code against the reference

Your bigram has 256×256=65536256 \times 256 = 65536 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.

SymbolMeaningType / shape
LLthe final scalar output (the loss)Value
vvany node of the graph: an input, a constant, or an intermediate resultValue
vˉ=∂L/∂v\bar v = \partial L / \partial vthe adjoint of vv: how much LL changes per unit change of vvv.grad
y=g(x1,…,xk)y = g(x_1, \dots, x_k)one operation with inputs xjx_jValue with _prev = (x_1, ..., x_k)
∂g/∂xj\partial g / \partial x_jthe operation’s local derivativefloat
uua weight vector for a vector-valued output, in u⊤Ju^\top Jfloat64[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 LL depends on xx only through the nodes yy that consume xx, the multivariable chain rule says

xˉ=∑y consumes xyˉ ∂y∂x.\bar x = \sum_{y \text{ consumes } x} \bar y\, \frac{\partial y}{\partial x}.

So each node yy, once its own yˉ\bar y is complete, adds yˉ ∂y/∂xj\bar y\, \partial y / \partial x_j into each input’s adjoint. The sum over consumers is why the update is x.grad += ...: a node used twice (the variable aa 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 Lˉ=1\bar L = 1 (LL 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 yˉ\bar y times its partial derivatives:

yyadds to its inputs
x1+x2x_1 + x_2xˉ1+=yˉ\bar x_1 \mathrel{+}= \bar y, xˉ2+=yˉ\bar x_2 \mathrel{+}= \bar y
x1x2x_1 x_2xˉ1+=x2 yˉ\bar x_1 \mathrel{+}= x_2\,\bar y, xˉ2+=x1 yˉ\bar x_2 \mathrel{+}= x_1\,\bar y
xkx^k, constant kkxˉ+=kxk−1 yˉ\bar x \mathrel{+}= k x^{k-1}\,\bar y
exe^xxˉ+=ex yˉ\bar x \mathrel{+}= e^x\,\bar y (reuse the output)
log⁡x\log xxˉ+=yˉ/x\bar x \mathrel{+}= \bar y / x
tanh⁡x\tanh xxˉ+=(1−y2) yˉ\bar x \mathrel{+}= (1 - y^2)\,\bar y (reuse the output)
relu(x)\mathrm{relu}(x)xˉ+=[x>0] yˉ\bar x \mathrel{+}= [x > 0]\,\bar y, so 0 at exactly x=0x = 0

Subtraction and division need no rules of their own: a−b=a+(−1)ba - b = a + (-1)b and a/b=a⋅b−1a / b = a \cdot b^{-1}. 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 y=f(x)∈Rmy = f(x) \in \mathbb{R}^m, run backward from L=∑iuiyiL = \sum_i u_i y_i: the leaves end up holding u⊤Jf(x)u^\top J_f(x), 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).

L=(ab+c) aL = (ab + c)\,a at a=2a = 2, b=−3b = -3, c=10c = 10.

Forward.

nodeoperationvalue
dda⋅ba \cdot b−6-6
eed+cd + c44
LLe⋅ae \cdot a88

Backward, in reverse topological order L,e,dL, e, d, then the leaves:

visitits adjointpushes
LLLˉ=1\bar L = 1 (seed)eˉ+=a⋅1=2\bar e \mathrel{+}= a \cdot 1 = 2, aˉ+=e⋅1=4\bar a \mathrel{+}= e \cdot 1 = 4
eeeˉ=2\bar e = 2dˉ+=2\bar d \mathrel{+}= 2, cˉ+=2\bar c \mathrel{+}= 2
dddˉ=2\bar d = 2aˉ+=b⋅2=−6\bar a \mathrel{+}= b \cdot 2 = -6, bˉ+=a⋅2=4\bar b \mathrel{+}= a \cdot 2 = 4

Result: aˉ=4−6=−2\bar a = 4 - 6 = -2, bˉ=4\bar b = 4, cˉ=2\bar c = 2. By hand, L=a2b+acL = a^2 b + ac, so ∂L/∂a=2ab+c=−12+10=−2\partial L/\partial a = 2ab + c = -12 + 10 = -2, ∂L/∂b=a2=4\partial L/\partial b = a^2 = 4, ∂L/∂c=a=2\partial L/\partial c = a = 2. The two paths into aa (through LL directly and through dd) add up; with = instead of +=, aˉ\bar a would end as −6-6 or 44 depending on the order.

These numbers are the first case in section 4, test_hand_example.

python/tinyllm/autograd/scalar.py
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 order

backward 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.

TestKINDChecksWhy it matters downstream
test_hand_exampleunitthe section 3 forward and backward tablesyou and the test agree on the walk
test_gradcheck_each_opgradcheckevery operation against the frozen central differences at three pointseach local rule is right
test_matches_dualdifferentialone expression using every operation, both partials, against M08.1 at 25 pointsforward and reverse mode agree
test_vjp_matches_numeric_vjpdifferentialu⊤Ju^\top J for a map R3→R3\mathbb{R}^3 \to \mathbb{R}^3 against M04.2’s vjp_numericreverse mode is a VJP
test_reused_node_accumulatesunitx+xx + x, x⋅xx \cdot x, x⋅x+xx \cdot x + xweights shared across positions
test_diamond_visits_each_node_onceunit3ex+4ex3e^x + 4e^x gives 7ex7e^xshared subexpressions everywhere
test_deep_chain_no_recursion_errorboundarya 50000-node chainunrolled recurrences (L3.1)
test_relu_gradient_at_zeroboundarygradient 0 at exactly 0agreement with PyTorch at kinks
test_constants_on_either_sideunit2x2x, x2x2, 2+x2 + x, 1−x1 - x, x−1x - 1, 6/x6/x, x/2x/2, −x-xnumbers mixed into expressions
test_pow_needs_a_constant_exponentboundaryValue ** Value is a TypeErrorno silently wrong rule
test_backward_twice_accumulatesunita second call doubles the leaf gradientwhy training zeroes gradients
PitfallSymptomCaught by
1. x.grad = ... instead of +=a node used twice keeps only its last contributiontest_reused_node_accumulates (mutant s01)
2. walking inputs first, or walking every pathpartial or repeated pushes; shared nodes get wrong multiplestest_diamond_visits_each_node_once (mutants s03, s13)
3. a recursive topological sort inside backwardRecursionError on a deep chaintest_deep_chain_no_recursion_error (mutant s05)
4. giving each factor its own value in the product ruleaˉ\bar a is wrong in the worked exampletest_hand_example (mutant s02)
5. tanh⁡′\tanh' from the input (1−x21 - x^2) instead of the output (1−y21 - y^2)wrong slopes away from 0test_gradcheck_each_op (mutant s06)
6. >= in relu’s backwardgradient 1 at the kink, unlike PyTorchtest_relu_gradient_at_zero (mutant s07)
7. a reflected method that reverses the operands1−x1 - x has slope +1+1test_constants_on_either_side (mutants s11, s12)
8. adding to the seed instead of setting itthe second backward starts from 2test_backward_twice_accumulates (mutant s04)
DirectionModuleHow it uses this
BackM06.1toposort(root, parents): the order of the walk, without recursion
BackM08.1Dual computes the same derivatives forward; the tests compare
BackM04.2vjp_numeric approximates the same u⊤Ju^\top J by central differences
ForwardL0.1your 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.

Your pieceProduction equivalentWhat it addsWhere to look
Valuemicrogradthe same engine plus a tiny neural-network library on topmicrograd/engine.py
backwardPyTorch’s autograd enginedependency counting and a ready queue instead of a precomputed order, multithreaded per device, retained or freed graphstorch/csrc/autograd/engine.cpp
VJP from backwardJAX jax.vjpreverse mode derived by linearizing and transposing the forward tracejax/_src/interpreters/ad.py