10

Lesson 10 · Part II · Autograd on single numbers

The order of blame

In what order must the graph be visited?

~40 min · 1 lab · builds on 09 Many paths, one sum

This lesson builds on lessons 01–09. New here? Start at lesson 01, or carry on and load the finished pieces with one click.

c=a⋅a+ac = a \cdot a + a at a = 3. After backward, what is a.grad?

The product sends 3 to each of its two uses of a, and the sum sends 1: 3 + 3 + 1 = 7.

x is a Value holding 2.0, and you write y = x * 3. What does y record in _prev and _op?

Nothing extra: y holds 6.0, and that is enough to go backward
6.0 alone can't say where it came from: 2 · 3 and 1.5 · 4 both make it.
x and a new Value holding 3.0, and '*'
The plain 3 is wrapped in a Value first, so both parents are Values that a gradient can reach.
The numbers 2.0 and 3.0, and '*'
Numbers can't receive a gradient or lead any further back, so _prev keeps Values.

_prev holds x and the wrapped 3.0, and _op is '*'. Today's question is the order in which backward visits such records.

Who goes first?

Each _backward now adds its share into its inputs' gradients, so a Value used several times collects every share.

So far you called each _backward by hand, in an order you picked. A model has thousands of Values, so a program has to pick the order. Which order is safe?

A small graph:

w = Value(2.0)
x = w * w
e = x * 3
d = e * 2
c = x + d

Walk back from c breadth-first (c, then its parents, then theirs) and run each node's _backward the first time you reach it. What is w.grad at the end? Nudging says the true slope is 28.

28: every node is visited, so every share arrives
Every node is visited, but x too early: its backward runs while x.grad holds only the 1 that came straight from c. The 6 that comes through d arrives after x has passed its gradient on.
4: x passes its gradient on before the share through d and e arrives
x's backward runs with x.grad = 1, sending 2 · 2 · 1 = 4 to w, and never runs again.
32: x is reached by two paths, so its backward runs twice
Running twice would send 4 when x holds 1, then 28 when it holds 7, and 4 + 28 counts the first share twice. The walk marks each node as seen and never queues it again, so x's backward runs once, too early.

4. x is reached first straight from c, and its backward runs before e has sent x its share of 6. x ends up holding 7, but it passed its gradient on to w while it held only 1, so w holds 4. A node may pass gradient on only after every node that uses it has finished. The cell below runs the walk: breadth-first gives w.grad = 4.0 where nudging says 28.0.

Breadth-first backward on the graph above, next to the nudged slope.

from collections import deque
import hx
from ember.scalar import Value


def breadth_first_backward(root):
    """Visit nodes nearest the output first; run each node's _backward the first time it is reached."""
    root.grad = 1.0
    seen, queue = {id(root)}, deque([root])
    while queue:
        node = queue.popleft()
        node._backward()
        for parent in node._prev:
            if id(parent) not in seen:
                seen.add(id(parent))
                queue.append(parent)


w = Value(2.0)
x = w * w
e = x * 3
d = e * 2
c = x + d
for v, name in [(w, "w"), (x, "x"), (e, "e"), (d, "d"), (c, "c")]:
    v.label = name
breadth_first_backward(c)
print(f"breadth-first: w.grad = {w.grad}")
hx.show_graph(c)


def c_of(w):
    """The same graph on plain floats: c as a function of w alone."""
    x = w * w
    return x + (x * 3) * 2


nudged = (c_of(2.001) - c_of(2.0)) / 0.001
print(f"nudging w by 0.001: slope {nudged:.1f}")
breadth-first: w.grad = 4.0
nudging w by 0.001: slope 28.0

The graph after the breadth-first walk, each node with its value and the gradient it collected. The two unnamed nodes are the plain 3 and 2, each wrapped in a Value.

nodeopforward valuegradient
#0input212
#1input38
winput24
x*47
e*122
d*241
c+281

Users before the used

The rule: run a node's _backward only after every node that uses it has run, so that its gradient is complete before it passes anything on. An order of a graph's nodes in which every node comes after the nodes it was made from is called a topological order. Backward runs one in reverse: users before the used.

Put the nodes of the graph in an order that runs every _backward correctly: each node only after every node that uses it.

  1. c
  2. d
  3. e
  4. x
  5. w

c, d, e, x, w, and here it is the only order: x is used by c and by e, so x waits for both, and e waits for d.

A program finds a topological order by depth-first search. Start at the output. Before you add a node to the list, visit each of its parents, so a node goes in only once all of its parents are in. The list then runs from the leaves to the output, and reversed it is a safe backward order. Written as a function that calls itself, it is only a few lines long. Run it on a long chain.

The recursive depth-first order on a chain of 100 additions, then 5000.

from ember.scalar import Value


def order_recursive(node, seen, order):
    if id(node) in seen:
        return
    seen.add(id(node))
    for parent in node._prev:
        order_recursive(parent, seen, order)
    order.append(node)


for length in [100, 5000]:
    out = Value(1.0)
    for _ in range(length):
        out = out + 1.0
    try:
        order = []
        order_recursive(out, set(), order)
        print(f"chain of {length} additions: ordered {len(order)} nodes")
    except RecursionError:
        print(f"chain of {length} additions: RecursionError (Python ran out of stack)")
chain of 100 additions: ordered 201 nodes
chain of 5000 additions: RecursionError (Python ran out of stack)

A chain of 100 additions is fine, and its order holds every added 1.0 too, each wrapped in a Value of its own. A chain of 5000 raises RecursionError: every nested call is a frame on Python's stack, and Python stops at about 1000, its recursion limit. A recurrent network unrolled over a long text is deeper than that, so the walk keeps its own stack in a list instead.

What: write Value.backward(): build a topological order of every Value this one depends on with an explicit stack (no recursion), set this Value's grad to 1, and run each node's _backward from the output back to the leaves.

Why: without it you call every _backward by hand, in an order you work out yourself. From here on nobody does. Lesson 12's training loop calls loss.backward() once per step, and lesson 14 rebuilds this walk for tensors.

Your ember backpropagates any graph with one call.

  1. Recursive depth-first search appends a node after all its parents are done. How can a list used as a stack, holding (node, parents_done) pairs, do the same without recursion?
  2. Push (self, False). Pop a pair: if its flag is True, append the node to the order; otherwise, if the node is new, mark it seen, push (node, True), then push (parent, False) for each parent. Then set self.grad = 1.0 and call _backward on each node of reversed(order).
  3. Nearly the code: order, seen, stack = [], set(), [(self, False)]; while stack: node, done = stack.pop(); ... then self.grad = 1.0 and for node in reversed(order): node._backward().
  1. L.backward() gives every gradient of L = (z - 3)^2 with z = w*x + b.
  2. After y.backward(), y.grad is 1.0.
  3. x reaches c directly and through d, so x's backward must wait for both shares.
  4. backward() handles a chain of 5,000 additions without a RecursionError.
  5. In a * a * a, a's gradient is 3a^2, counted once per use.
  6. For d = (a + 1) * (3a) the gradient is 6a + 3.

Your backward() on the breadth-first graph and on the 5000-step chain.

import hx
from ember.scalar import Value

w = Value(2.0)
x = w * w
e = x * 3
d = e * 2
c = x + d
for v, name in [(w, "w"), (x, "x"), (e, "e"), (d, "d"), (c, "c")]:
    v.label = name
c.backward()
print(f"backward(): w.grad = {w.grad}")
hx.show_graph(c)

v = Value(1.0)
out = v
for _ in range(5000):
    out = out + 1.0
out.backward()
print(f"chain of 5000 additions: v.grad = {v.grad}")
backward(): w.grad = 28.0
chain of 5000 additions: v.grad = 1.0

Your backward() gives w.grad = 28.0, the nudged slope, and v.grad = 1.0 at the start of the 5000-step chain, with no recursion to run out of.

The same graph after your backward(). x collects 1 from c and 6 through e before it passes anything on, and w, used twice by x, receives 2 · 7 = 14 from each use: 28.

nodeopforward valuegradient
#0input212
#1input38
winput228
x*47
e*122
d*241
c+281

Suppose you had kept a list of every Value of this graph in the order it was made. Would running each one's _backward from the end of that list, newest first, also work? Why?

No: the order Values were created in has nothing to do with the graph
It has everything to do with it: a Value can only be made from Values that already exist.
Only if no Value is used twice
Shared Values are exactly why order matters, and newest-first still visits every user of x before x.
Yes: every Value is created after its inputs, so newest-first visits every user of a Value before the Value itself
A Value can only be made from Values that already exist, so each parent is older than every Value made from it.

Yes: creation order is already a topological order. backward() sorts because all it is handed is the output, not the history of when each Value was made.

Where this goes

Your engine backpropagates any graph built from + and *. Lesson 11 adds powers, exp, log, tanh and relu, and a way to prove each backward right.

When you finish this lesson

10

Your ember can now: backpropagate through any graph with one call to backward()

It already could:

  • get the right gradient for a value used in many places
  • send a gradient backward through + and *
  • record how every number was made, and draw it as a graph

Next Trust, but verify · How do you know your derivative is right? · ~45 min

Continue

Report a problem with this lesson