Electric Sheaves

Step 6 — Training the Baby GPT

Released with Lecture 6 · ~3 hours active + background training time · Starts from: Step 5 (or solutions/step-05.ipynb).

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

Train the model. This step is where the course’s optimization mathematics meets the empirical craft of making it work: you will write a real training loop (AdamW, warmup + cosine decay, gradient clipping), diagnose its behaviour with train/val curves, and run one controlled architecture experiment. At the end, your GPT writes its first Shakespeare.

Compute note: the course config (~0.8M params, T_max=64) trains to respectability in ~10–20 min on a laptop CPU and ~2 min on a free Colab T4 GPU. Use Colab this week if you can (model.to('cuda'), and move each batch too); but CPU works.

On paper first

  1. AdamW, stated precisely. Write the update: first/second moment EMAs, bias correction, θ=ηm^/(v^+ε)+ηλθ\theta \mathrel{-}= \eta\, \hat m/(\sqrt{\hat v} + \varepsilon) + \eta\lambda\theta. Show that (ignoring ε\varepsilon, at stationarity of the EMAs) each coordinate’s step size is ηsign-like\approx \eta \cdot \operatorname{sign-like}, invariant to rescaling that coordinate’s gradient — Adam is a diagonal preconditioner. Contrast with SGD’s dependence on curvature scale.
  2. Why warmup + decay? From Lecture 6: early on, moment estimates are noise and the landscape is at its roughest — warmup keeps steps small; late, decaying η\eta trades exploration for convergence. Sketch the schedule you’ll implement: η(t)=ηmaxmin(t/tw,  12(1+cos(π(ttw)/(tftw))))\eta(t) = \eta_{\max}\cdot\min(t/t_w,\; \tfrac12(1+\cos(\pi (t-t_w)/(t_f-t_w)))).
  3. What can go wrong with minibatch estimates: the loss you plot is a noisy estimator of the true loss. How does batch size enter its variance, and what does that imply about judging progress from the jagged raw curve vs an EMA of it?

Tasks

6.1 Data pipeline

Train/val split of the token stream (90/10), and a batch sampler:

def get_batch(split, B=64, T=64):                        # default parameter values
    data = train_ids if split == 'train' else val_ids   # conditional (ternary) expression
    ix = torch.randint(len(data) - T - 1, (B,))          # (B,) = a one-element tuple
    x = torch.stack([data[i:i+T] for i in ix])           # list comprehension of tensor slices
    y = torch.stack([data[i+1:i+T+1] for i in ix])       # shifted by one
    return x.to(device), y.to(device)                    # returns a tuple
Python background: one-element tuples

It is the comma, not the parentheses, that makes a tuple — so (5) is just the number 5, and a one-element tuple must be written (5,). Shape arguments are tuples, one entry per axis, which is why torch.randint(..., (B,)) asks for a one-axis result of length B: (B,) is “shape with a single dimension”, where plain B or (B) would be just a number.

t = (5,)              # one-element tuple: the comma does the work
not_t = (5)           # just a parenthesized number
print(type(t), type(not_t))
print(len(t))
import numpy as np
print(np.zeros((3,)).shape)    # shape (3,): a vector of length 3
print(np.zeros((3, 2)).shape)  # shape (3, 2): a matrix
PyTorch background: devices and .to(device)

Every tensor lives on a device — the CPU, or a GPU ('cuda'). Computation happens where the data is, and tensors can only combine with tensors on the same device, so the working rule is: move the model once, move every batch as you fetch it. That is why get_batch ends with .to(device). The standard opening lines pick the device once and thread it through everything:

device = 'cuda' if torch.cuda.is_available() else 'cpu'
model = GPT(...).to(device)   # moves all parameters AND buffers
x, y = get_batch('train')     # already on device — get_batch handled it

A RuntimeError: expected ... cuda mid-run means some tensor got left behind — see the If-you’re-stuck note about mask buffers and fresh torch.arange calls. On Colab this one habit is the entire GPU-vs-CPU speedup of the compute note above.

Note y is x shifted: every position is a training example — one batch contains B×T=4096B \times T = 4096 next-token problems, and the causal mask is what makes computing them all in one forward pass legitimate. (Connect this to Lecture 4: this is why masking, not bidirectional attention, is what a language model needs.)

6.2 Evaluation done right

@torch.no_grad()                    # decorator
def estimate_loss(model, iters=100):
    model.eval()
    # dict comprehension over ('train','val'); *get_batch(s) unpacks a tuple into arguments
    out = {s: torch.mean(torch.tensor([model(*get_batch(s))[1].item()
                                       for _ in range(iters)])) for s in ('train','val')}
    model.train()
    return out
Python background: decorators

The @something line above a def is a decorator: it passes the function you are defining through something and stores the result under the same name — @shout below is exactly greet = shout(greet). A decorator therefore wraps extra behaviour around a function without touching its body. @torch.no_grad() wraps estimate_loss so that everything inside runs with gradient tracking switched off — evaluation does not need gradients, and skipping them saves time and memory.

def shout(fn):                  # takes a function...
    def wrapper(name):
        return fn(name).upper() + "!"
    return wrapper              # ...returns a wrapped version (a closure)

@shout                          # greet = shout(greet)
def greet(name):
    return "hello " + name

print(greet("kate"))

(Nested functions and closures, which make this work, are Step 3’s foldout.)

Python background: argument unpacking with *

A * in a function call spreads a sequence out into separate arguments: if box is the tuple (2, 3, 4), then volume(*box) is exactly volume(2, 3, 4). Since get_batch returns the tuple (x, y), writing model(*get_batch(s)) feeds x and y to the model as its two arguments in one stroke.

def volume(w, h, d):
    return w * h * d
box = (2, 3, 4)
print(volume(*box))       # the tuple spread into three arguments
print(volume(2, 3, 4))    # exactly equivalent
def measurements():
    return 5, 6, 7        # a function returning a tuple...
print(volume(*measurements()))   # ...unpacked straight into another call

Averaging many batches: your answer to “on paper” 3, in code.

6.3 The training loop

Write it in full — this loop is an artifact you’ll reuse for years:

Train 5000 steps. While it runs, predict: where should the loss start (you know: ln65\ln 65), and what’s the best it could conceivably reach (your Step 1 table: Shannon says ~1 bit/char ≈ 0.69 nats)?

6.4 Read the curves

Produce and annotate (in prose, in the notebook) four plots:

  1. Train & val loss vs step — mark where they separate. Do they, at 0.8M params on 1M characters? Given ~0.8 params per training character, is the amount of overfitting you see surprising in the classical bias–variance picture? (Lecture 6’s double-descent discussion is exactly about this.)
  2. Loss vs step with your lr schedule overlaid — find the warmup kink and the late-decay flattening.
  3. Gradient norm vs step — spikes, and whether clipping engaged.
  4. Sample evolution: generate 200 characters from checkpoints at step 0, 250, 1000, 5000. Watch the model discover, in order: characters’ frequencies, words, line structure, CHARACTER NAME: stage format. This ordering is a scaling-law fact you’ll meet again in Step 7.

6.5 One controlled experiment

Pick one knob and vary it with everything else fixed, 3–4 values, tabulated (val loss @ 5000 steps, wallclock, params):

Write three sentences on the trend and whether it’s monotone. These tables get pooled in Lecture 7 as our homemade scaling-law data.

Checkpoints

If you’re stuck

Going further (optional)

Catch-up

Run solutions/step-05.ipynb to get a verified model class, then do this step in full — training is the step least worth skipping, and needs only Lecture 6.