Electric Sheaves

Step 3 — Autodiff, and a Neural nn-gram Model

Released with Lecture 3 · ~4 hours (the long one — start early) · Starts from: your Step 1 tokenized corpus and the matrix-layer ideas of Step 2.

In what follows, there are blocks of code provided for you. It’s important that you can read the code. If you are not proficient with Python, then I propose the following workflow. For each block of code, keywords are provided for the python syntax which is being used, so you can look up python syntax and functions you don’t recognise. To help, beneath many code blocks you will find Python background foldouts: self-contained tutorials, with live Python boxes you can edit and run right on the page, for syntax that is new in this step (syntax already introduced is covered in earlier steps’ foldouts). Then I suggest that you first experiment with changing the code or printing out intermediate variables, until you have figured out what you think it does. Then point AI toward the page and step number, provide your precise explanation of what you believe each line of the code is doing, and ask for corrections. In this way you will quickly become a code reader (if not a code writer). — Kate

Goal

Two things, in order:

  1. Build reverse-mode automatic differentiation yourself—a roughly 60-line scalar “autograd engine” in the style of micrograd. Validate it on a graph with fan-out, with finite differences, and on the hand-wired diamond network from Step 2. After this you may use loss.backward()\,\texttt{loss.backward()}\, for the rest of your life with a clear conscience.
  2. Use PyTorch’s autograd to train the first learned neural language model of the course: Bengio et al. (2003)—an embedding matrix plus an MLP reading a context of kk characters.

On paper first

  1. A computation is a DAG whose nodes hold values viv_i computed by primitives from their parents. Reverse-mode AD computes adjoints vˉi=L/vi\bar v_i = \partial \mathcal{L}/\partial v_i by the chain rule, in reverse topological order: vˉi+=vˉjvj/vi\bar v_i \mathrel{+}= \bar v_j \,\partial v_j/\partial v_i summed over children jj. Write out this recursion for L=(ab+a)tanh(b)\mathcal L = (a b + a)\tanh(b), by hand, both forward-mode (derivatives along with values) and reverse-mode, and note what each pass costs.
  2. Cheap gradient principle (Lecture 3): reverse mode computes the gradient with respect to all nn inputs in O(1)O(1) function evaluations, vs O(n)O(n) for forward mode or finite differences. For a billion-parameter model, that constant is the entire field.
  3. Why must the traversal be in reverse topological order, and why does the update use += rather than =? (Both bugs will otherwise find you in Task 3.1; fan-out — a node used twice — is the test case.)

Tasks

3.1 A scalar autograd engine

Implement a class Value wrapping a float, supporting +, *, tanh, exp, log, relu, and **, that records the computation graph and backpropagates:

class Value:                              # class definition
    def __init__(self, data, _parents=()):  # constructor; self; default argument (an empty tuple)
        self.data = data                  # attribute assignment
        self.grad = 0.0
        self._parents = _parents
        self._backward = lambda: None     # lambda function; how to push my grad to my parents

    def __add__(self, other):             # dunder method: overloads the + operator
        other = other if isinstance(other, Value) else Value(other)  # conditional (ternary) expression; isinstance
        out = Value(self.data + other.data, (self, other))  # tuple
        def _backward():                  # nested function (closure)
            self.grad  += out.grad        # d(a+b)/da = 1
            other.grad += out.grad
        out._backward = _backward         # functions are values: stored in an attribute
        return out

    def __mul__(self, other):
        # you write this one, and tanh/exp/log/relu/pow in the same pattern
        ...                               # Ellipsis: placeholder for missing code

    def backward(self):
        order = []                        # list; topological sort of the graph
        visited = set()                   # set
        def visit(v):                     # recursive function
            if v not in visited:          # membership test (not in)
                visited.add(v)
                for p in v._parents:
                    visit(p)
                order.append(v)
        visit(self)
        self.grad = 1.0                   # dL/dL = 1
        for v in reversed(order):         # reversed
            v._backward()
Python background: classes, __init__, and self

A class bundles data together with the functions (methods) that operate on it. Calling Counter() builds a new object and runs the constructor __init__ to set it up; inside every method, self is the particular object being operated on, and self.count = ... stores an attribute on it. Each object carries its own attributes — that is the point: a Value will carry its data, its grad, and its parents.

class Counter:
    def __init__(self, start=0):   # the constructor, run by Counter(...)
        self.count = start         # an attribute on this particular object
    def bump(self):                # a method; self = the object it's called on
        self.count += 1

c = Counter()
c.bump()
c.bump()
print(c.count)
d = Counter(start=100)             # a second, independent object
d.bump()
print(d.count)
print(c.count)                     # c was not affected
Python background: dunder methods (operator overloading) and the ... placeholder

Names with double underscores — “dunder” methods — are hooks Python calls for you: a + b runs a.__add__(b), and print(a) uses a.__repr__(). Defining them makes your own objects work with the ordinary operators, which is exactly how Value teaches + and * to record the computation graph. The expression ... (Ellipsis) is a legal do-nothing placeholder — the course uses it to mark code you will write.

class Vec:
    def __init__(self, x, y):
        self.x, self.y = x, y
    def __add__(self, other):          # defines what + means for Vec
        return Vec(self.x + other.x, self.y + other.y)
    def __repr__(self):                # defines how a Vec prints
        return "Vec(" + str(self.x) + ", " + str(self.y) + ")"

print(Vec(1, 2) + Vec(10, 20))
def todo():
    ...                                # placeholder body: runs, does nothing
print(todo())                          # a function with no return gives None
Python background: conditional (ternary) expressions and isinstance

A if condition else B is an expression — it produces a value, so it can sit on the right of an assignment. isinstance(x, T) asks whether x is a value of type T. Together they make the idiom in __add__: “if other is already a Value, keep it; otherwise wrap it in one” — which is what lets you write v + 3 with a plain number.

x = 7
parity = 'odd' if x % 2 == 1 else 'even'   # a value picked inline
print(parity)
print(isinstance(x, int))
print(isinstance('7', int))
v = '5'
v = v if isinstance(v, int) else int(v)    # the wrap-if-needed pattern
print(v + 1)
Python background: nested functions and closures

Functions are ordinary values: they can be defined inside another function, stored in a variable or attribute, passed around, and called later. A function defined inside another remembers the variables that surrounded it (a closure) — even after the outer function has returned. That is the whole trick of _backward: each one remembers its own self, other, and out.

def make_adder(n):
    def add(x):            # defined inside: it remembers this n
        return x + n
    return add             # functions are values — return one

add5 = make_adder(5)
add100 = make_adder(100)   # a separate closure with its own n
print(add5(3))
print(add100(3))
print(add5(3))             # add5 still remembers n = 5
Python background: recursion, in / not in, and reversed

A function may call itself — recursion — as long as some condition eventually stops it; visit uses this to walk the graph, and the visited set plus the membership test not in keeps it from processing a node twice. reversed(...) iterates a list back to front without copying it.

def countdown(n):
    if n == 0:
        return             # the base case: stop recursing
    print(n)
    countdown(n - 1)       # the function calls itself
countdown(3)
vals = [3, 1, 4]
print(3 in vals)           # membership test
print(9 not in vals)
seen = set()
seen.add('a')
print('a' in seen, 'b' in seen)   # fast membership: why visited is a set
for v in reversed(vals):
    print(v)

Validate it three ways (this is the substance of the task):

  1. Fan-out test: L=(ab+a)tanh(b)\mathcal L=(ab+a)\tanh(b) at a=2,b=1a=2,b=-1; check a.grada.\mathrm{grad} and b.gradb.\mathrm{grad} against your hand calculation.

  2. Compare your engine with central finite differences on a few random expressions. Use float64 and several step sizes.

  3. The full circle: rebuild one forward pass of Step 2’s diamond network from scalar Value\texttt{Value} objects at x=(0.5,0.25)\mathbf{x}=(0.5,0.25)^\top, γ=4\gamma=4, with target “inside.” Because both coordinates are positive there,

    L=log(1+exp(2γ(x1+x21))),\mathcal L = \log\left(1+\exp\left(2\gamma(x_1+x_2-1)\right)\right),

    and both input derivatives equal

    Lxi=2γp(outsidex)0.9536.\frac{\partial\mathcal L}{\partial x_i} = 2\gamma\,p(\mathrm{outside}\mid\mathbf{x}) \approx0.9536.

    Check this analytic value, your engine, and finite differences against one another. The ReLU primitive also gives you a piecewise-linear test; do not place a finite-difference check exactly at a ReLU corner.

3.2 Switch to PyTorch autograd

Repeat the diamond check with PyTorch’s tensor-valued engine:

point = torch.tensor([[0.5, 0.25]], dtype=torch.float64,
                     requires_grad=True)
_, _, _, probs = diamond_net(point, gamma=4.0)
loss = -probs[0, 1].log()             # target = inside
loss.backward()

expected = 2 * 4.0 * probs.detach()[0, 0]
print(point.grad)                      # both entries ≈ 0.9536
print(expected)
PyTorch background: autograd — requires_grad, backward(), detach()

You have just built this machinery yourself, so the API maps one-to-one onto your Value class. requires_grad=True marks a tensor as a leaf whose gradient you want (your engine tracked every node; PyTorch lets you opt in). Operations on such tensors record the computation graph, and loss.backward() is your topological-sort-and-sweep, filling .grad on every marked leaf. Gradients accumulate into .grad — the += of your engine — which is why training loops must zero them between steps. detach() returns the same values cut loose from the graph (no history, so nothing flows back through them), and the torch.no_grad() context switches recording off wholesale — you will meet it in Step 6. (PyTorch doesn’t run in the browser, so no live box; the comments show what each line produces.)

W = torch.randn(3, 3, requires_grad=True)
loss = (W * W).sum()      # every operation on W is recorded
loss.backward()           # the reverse sweep: your Value.backward()
print(W.grad)             # d(loss)/dW = 2W, accumulated into .grad
W.grad.zero_()            # zero it before the next step — or gradients add up
frozen = W.detach()       # same numbers, no graph attached

PyTorch is your Value class with three upgrades: tensors instead of scalars, a C++/GPU backend, and a library of primitives. Nothing else.

3.3 The Bengio model

Now a model that genuinely needs autodiff. Context length kk (start k=3k=3), embedding dimension d=16d=16, hidden width h=128h=128:

x=(C[a1],,C[ak])Rkd,z=W2tanh(W1x+b1)+b2,p=softmax(z)x = \bigl(\,C[a_1],\dots,C[a_k]\,\bigr) \in \mathbb{R}^{kd},\qquad z = W_2 \tanh(W_1 x + b_1) + b_2,\qquad p = \operatorname{softmax}(z)

with parameters CRV×dC \in \mathbb{R}^{V \times d} (the embedding matrix — each character becomes a learned vector), W1Rh×kdW_1 \in \mathbb{R}^{h \times kd}, W2RV×hW_2 \in \mathbb{R}^{V \times h}.

  1. Build the dataset of (context, next-char) pairs with a rolling window over ids. Split train = first 90%, val = last 10% — from now on, all reported numbers are validation numbers.
  2. Implement the forward pass with tensor ops (C[X] gathers embeddings; .view(-1, k*d) flattens the context; use F.cross_entropy(logits, ys) — it is exactly Lecture 2’s stabilized log-sum-exp loss, and you have now earned it).
  3. Write minibatch SGD yourself (no torch.optim until Step 6): sample a random batch of 64 contexts per step, for p in params: p -= lr * p.grad, inside torch.no_grad(); don’t forget to zero the grads (why does PyTorch accumulate instead of overwrite? — you know this from your own engine).
  4. Train ~20k steps. Plot train and val loss curves. Compare full-batch loss-per-step against minibatch loss-per-wallclock-second to see why stochasticity wins.
  5. Sample from the model (roll the context window as you generate).
  6. Sweep k=1,3,5,8k = 1, 3, 5, 8 and tabulate val loss and parameter count against the 65k+165^{k+1} entries the corresponding count matrix would need. Two things to explain, and the second is the interesting one:
    • k=1k{=}1 should land near the bigram’s ≈2.45 (why must it? — what family does the k=1k{=}1 model contain?);
    • the sweep is not monotone in kk. See the checkpoints below before you decide you have a bug.

Checkpoints

If you’re stuck

Going further (optional)

Catch-up

Run the data-loading and tokenization cells from solutions/step-01.ipynb\texttt{solutions/step-01.ipynb}, then copy your \texttt{diamond_net} function from Step 2 for the validation check. If you did not complete Step 2, its supplied forward-pass code is enough; the new work here is differentiating it.