Hand the bookkeeping to PyTorch.
Tensors, autograd, and nn modules: everything you built by hand, a tenth of the code -- and ready for a GPU.
We built autograd by hand in notebook 04 and a Transformer's forward pass by hand in notebook 06. Working out the backward pass through all of that by hand would be a nightmare. PyTorch removes the drudgery with three things:
- tensors, which are like NumPy's grids of numbers but can also live on the GPU and quietly remember how they were computed, so they can be differentiated,
- autograd, which is exactly notebook 04 generalized from single numbers to whole tensors: every operation records itself, and
.backward()runs the chain rule for you, nnmodules and optimizers, which are ready-made layers and update rules, so you no longer hand-write them.
Everything you have learned still applies; you simply stop writing the gradient code yourself.
# Colab setup -- fetch the files this notebook needs.
# (Does nothing when run locally in the course folder.)
import os, urllib.request
BASE = ("https://raw.githubusercontent.com/waze"
"emlabs/llm-book-code/main/")
for f in ['data/input.txt']:
if not os.path.exists(f):
d = os.path.dirname(f)
if d: os.makedirs(d, exist_ok=True)
urllib.request.urlretrieve(BASE + f, f)
print("downloaded", f)
Line by line: what each line does
import os, urllib.request: two standard-library toolboxes: checking files on disk, and downloading from the web.BASE = (...): the web address of this course's folder on GitHub, split over two lines (Python glues adjacent strings together).for f in [...]: loop over the file names this notebook needs.if not os.path.exists(f): only download what is missing -- running locally, everything already exists, so nothing happens.os.makedirs(d, exist_ok=True): create the folder for the file if it has one (exist_okmeans don't complain if it's already there).urllib.request.urlretrieve(...): download the file and save it under the same name here.
import torch
import torch.nn as nn
from torch.nn import functional as F
torch.manual_seed(1337)
device = "mps" if torch.backends.mps.is_available() else ("cuda" if torch.cuda.is_available() else "cpu")
print("torch", torch.__version__, "| device:", device)Line by line: what each line does
import torch: PyTorch, the real deep-learning framework.import torch.nn as nn: its neural-net building blocks (layers), nicknamednn.from torch.nn import functional as F: loose helper functions likeF.softmaxandF.cross_entropy, nicknamedF.torch.manual_seed(1337): PyTorch's way of pinning the randomness.device = "mps" if torch.backends.mps.is_available() else (...): ask "is there an Apple GPU?"; the chained if/else falls back to NVIDIA (cuda) then plaincpu. The chosen name is just a string we pass around to say where the math should run.
Autograd: notebook 04, now built in
Remember L = (a*b + b**2) * tanh(c) from notebook 04? We worked out its gradients by hand, and then our small engine reproduced them. Watch PyTorch produce the exact same numbers with a single call to .backward().
The one new ingredient is requires_grad=True. It marks an input as something we want gradients for, so PyTorch quietly records every operation performed on it. Calling L.backward() then walks that record backward and fills in a.grad, b.grad, and c.grad. It is the same chain rule you built, giving the same answers (-1.386, -1.848, 2.359), only handled for you.
a = torch.tensor(2.0, requires_grad=True)
b = torch.tensor(-3.0, requires_grad=True)
c = torch.tensor(0.5, requires_grad=True)
L = (a*b + b**2) * torch.tanh(c)
L.backward() # autograd walks the graph for us
print("a.grad = %.5f b.grad = %.5f c.grad = %.5f" % (a.grad, b.grad, c.grad))
print("(notebook 04 by hand: a=-1.38635 b=-1.84847 c=2.35934)")Line by line: what each line does
a = torch.tensor(2.0, requires_grad=True): a tracked number. The flag tells PyTorch "record every operation done to this, I'll want its gradient" -- exactly ourValue(2.0)from notebook 04.L = (a*b + b**2) * torch.tanh(c): ordinary math; PyTorch records the steps invisibly.L.backward(): one call fills in every gradient -- the notebook-04 engine, industrial strength.print(a.grad, b.grad, c.grad): where the answers land. Compare to notebook 04: identical numbers.
The graph PyTorch built for you
In notebook 04 you built the expression graph by hand and walked it in reverse. PyTorch did the same thing the moment you wrote the formula: every operation left a grad_fn pointing back at its inputs. Here is that recorded graph for L -- the same nodes as notebook 04's table, but you never wrote them.
# PyTorch recorded every operation in L.grad_fn. Walk it, exactly like notebook 04.
leaf = {id(a): "a", id(b): "b", id(c): "c"}
L = (a*b + b**2) * torch.tanh(c) # rebuild so the graph is intact to walk
def show(fn, depth=0):
if fn is None:
return
name = type(fn).__name__
if name == "AccumulateGrad": # a leaf input (a, b, or c)
print(" " * depth + f"leaf {leaf.get(id(fn.variable), '?')}")
else:
print(" " * depth + name) # e.g. MulBackward0, TanhBackward0
for nxt, _ in fn.next_functions:
show(nxt, depth + 1)
show(L.grad_fn)
print("\nthe same graph as notebook 04: two *, one +, one **2, one tanh, leaves a/b/c")Line by line: what each line does
leaf = {id(a): "a", ...}: a lookup so the input nodes print with friendly names.L = (a*b + b**2) * torch.tanh(c): rebuild the expression so its graph is intact to walk (backward frees it).L.grad_fn: the node for the final operation;.next_functionslinks back to what produced its inputs.show()recurses down those links, printing each recorded operation (MulBackward0,AddBackward0,PowBackward0,TanhBackward0) and stopping at the leaves.- It is the identical graph you walked by hand in notebook 04 -- PyTorch just built it as you typed the formula, then
.backward()swept it for you.
The training loop, in PyTorch form
Two new helpers keep the loop tidy. An nn.Module is a container that bundles up a model's learnable numbers, and an optimizer is the part that actually takes the downhill step. We will use AdamW, which you can think of as a smarter version of plain gradient descent: it carries a little momentum and automatically tunes the step size for each number, so it trains faster and needs less hand-holding. (Plain gradient descent -- step every number downhill by a fixed learning rate -- is the simplest optimizer, torch.optim.SGD. The name is short for stochastic gradient descent: "stochastic" because each step's gradient is measured on a fresh random mini-batch, not the whole dataset, exactly as get_batch draws one here; notebook 04 unpacks it.) The loop is always the same four lines:
logits, loss = model(x, y) # forward
optimizer.zero_grad() # clear old gradients
loss.backward() # autograd: compute new gradients
optimizer.step() # nudge every parameter downhillThe line that trips people up is zero_grad(). Recall from notebook 04 that gradients add up. PyTorch does the same, so if you do not reset them to zero each step, this step's gradients pile on top of the last step's and the update is meaningless. So the order is: clear, compute, step.
Below we rebuild the bigram from notebook 03 in this style. nn.Embedding is just the lookup table W from back then, now packaged as a layer, and notice that we write no gradient code at all. It lands at the same ceiling of about 2.45 that we computed in notebook 03, which is exactly the point: that ceiling belongs to the model's one-character memory, not to how cleverly you train it.
from pathlib import Path
# --- data + tokenizer (notebook 02) ---
text = Path("data/input.txt").read_text()
chars = sorted(set(text)); vocab_size = len(chars)
stoi = {c:i for i,c in enumerate(chars)}; itos = {i:c for c,i in stoi.items()}
encode = lambda s: [stoi[c] for c in s]; decode = lambda l: "".join(itos[i] for i in l)
data = torch.tensor(encode(text), dtype=torch.long)
n = int(0.9*len(data)); train_data, val_data = data[:n], data[n:]
block_size, batch_size = 8, 32
def get_batch(split):
d = train_data if split=="train" else val_data
ix = torch.randint(len(d)-block_size, (batch_size,))
x = torch.stack([d[i:i+block_size] for i in ix])
y = torch.stack([d[i+1:i+1+block_size] for i in ix])
return x.to(device), y.to(device)
print("vocab:", vocab_size, "| one batch:", tuple(get_batch("train")[0].shape))Line by line: what each line does
- The data lines mirror notebook 02 exactly, with one change of clothing:
torch.tensor(...)instead ofnp.array(...), anddtype=torch.longis PyTorch's 64-bit integer. n = int(0.9*len(data)); train_data, val_data = data[:n], data[n:]: the same 90/10 train/validation split.ix = torch.randint(len(d)-block_size, (batch_size,)): random start positions; PyTorch wants the size as a tuple(32,).x = torch.stack([d[i:i+block_size] for i in ix]): pile the 32 slices into a (32, 8) grid (same idea asnp.stack).x.to(device): ship the batch to the GPU (or whereverdevicepoints). Data and model must live in the same place.
class BigramLM(nn.Module):
def __init__(self, vocab_size):
super().__init__()
# each token id directly indexes a row of next-token logits
self.token_emb = nn.Embedding(vocab_size, vocab_size)
def forward(self, idx, targets=None):
logits = self.token_emb(idx) # (B,T,vocab)
loss = None
if targets is not None:
B,T,V = logits.shape
loss = F.cross_entropy(logits.view(B*T, V), targets.view(B*T))
return logits, loss
model = BigramLM(vocab_size).to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-2)
for step in range(4000):
x, y = get_batch("train")
_, loss = model(x, y)
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
# a single batch is a noisy reading, so evaluate properly over many batches
with torch.no_grad():
eval_loss = torch.stack([model(*get_batch("train"))[1] for _ in range(200)]).mean()
print(f"final loss (avg over 200 batches): {eval_loss.item():.3f}")
print("right at the ~2.45 bigram ceiling we computed in notebook 03 -- same model, zero gradient code")Line by line: what each line does
class BigramLM(nn.Module):: every PyTorch model is a class inheriting fromnn.Module; that parent is what collects parameters and makes.to(device)and.backward()work.super().__init__(): run the parent's setup first. Required boilerplate in every module.self.token_emb = nn.Embedding(vocab_size, vocab_size): a learnable lookup table, 65 rows of 65: literally ourWgrid from notebook 03, now wearing a layer costume.def forward(self, idx, targets=None):: the prediction recipe. PyTorch arranges thatmodel(x, y)runs this.targets=Nonemakes the loss optional, so generation can call it without answers.logits = self.token_emb(idx): look up a row of scores for each input id.loss = F.cross_entropy(logits.view(B*T, V), targets.view(B*T)):.viewreshapes (no data moved) into a flat list of predictions and matching answers;F.cross_entropyfuses softmax + pick-correct + negative-log + average into one call.optimizer = torch.optim.AdamW(model.parameters(), lr=1e-2): hand every knob to AdamW (a smart gradient descent);1e-2is 0.01.- The loop:
zero_grad(set_to_none=True)clears old gradients,loss.backward()computes new ones,optimizer.step()nudges every knob. with torch.no_grad():: a block where nothing is recorded: evaluation needs no gradients, so this saves time and memory.torch.stack([...]).mean(): average the loss over 200 fresh batches for an honest reading;.item()unwraps a one-number tensor to a plain number.
Same model, same result, but the gradient that took a careful derivation and np.add.at in notebook 03 is now handled entirely by loss.backward(). That is the whole point of a framework: you specify the forward computation, and the backward pass comes for free.
Attention in PyTorch
And here is notebook 05's scaled, causal self-attention, written in PyTorch. Compare it line by line with the NumPy version; it is the same arithmetic. nn.Linear is just the learned projection (our W_q, W_k, W_v) packaged as a layer. register_buffer stores the causal mask so that it travels to the GPU along with the model but is not treated as a trainable number. Because autograd is watching every step, this module is ready to train as it stands.
class Head(nn.Module):
def __init__(self, n_embd, head_size, block_size):
super().__init__()
self.key = nn.Linear(n_embd, head_size, bias=False)
self.query = nn.Linear(n_embd, head_size, bias=False)
self.value = nn.Linear(n_embd, head_size, bias=False)
self.register_buffer("tril", torch.tril(torch.ones(block_size, block_size)))
def forward(self, x):
B,T,C = x.shape
k, q = self.key(x), self.query(x)
wei = q @ k.transpose(-2,-1) * k.shape[-1]**-0.5 # scaled affinities
wei = wei.masked_fill(self.tril[:T,:T]==0, float('-inf')) # causal mask
wei = F.softmax(wei, dim=-1)
return wei @ self.value(x)
x = torch.randn(4, 8, 32)
head = Head(n_embd=32, head_size=16, block_size=8)
out = head(x)
print("attention output:", tuple(out.shape))
# verify causality on the attention weights
with torch.no_grad():
k,q = head.key(x), head.query(x)
wei = (q @ k.transpose(-2,-1) * k.shape[-1]**-0.5).masked_fill(head.tril[:8,:8]==0, float('-inf')).softmax(-1)
print("causal (upper triangle zero):", torch.allclose(torch.triu(wei[0], 1), torch.zeros(8,8)))Line by line: what each line does
self.key = nn.Linear(n_embd, head_size, bias=False)(and query, value) -- a learnable matrix multiply (input width → output width): exactly ourx @ W_q, as a layer.bias=Falsemeans no added constant.self.register_buffer("tril", torch.tril(torch.ones(...))): store the causal mask on the module so it travels with.to(device), but mark it "not a trainable knob."k, q = self.key(x), self.query(x): project the tokens into keys and queries.wei = q @ k.transpose(-2, -1) * k.shape[-1]**-0.5: query-times-key scores, scaled by 1/√(head size). (transpose(-2,-1)swaps the last two axes; negatives count from the end.)wei = wei.masked_fill(self.tril[:T, :T] == 0, float('-inf')): PyTorch's "where the mask is 0, write -inf": the causal mask, cropped to the actual sequence length.wei = F.softmax(wei, dim=-1)thenreturn wei @ self.value(x): weights, then the weighted blend of values.- The verification block re-derives the weights and checks the upper triangle is exactly zero -- causality holds.
What nn.Module is holding
An nn.Module quietly tracks two kinds of tensors: parameters, the weights the optimizer trains, and buffers, fixed helpers that ride along to the GPU but are never trained. Ask the Head what it holds -- the query, key, and value matrices are parameters; the causal tril mask is a buffer.
# nn.Module auto-registers its weights (parameters) and its fixed helpers (buffers).
print("parameters (the optimizer trains these):")
for name, p in head.named_parameters():
print(f" {name:14s} {tuple(p.shape)} {p.numel()} numbers")
print("buffers (fixed, moved to the device but never trained):")
for name, buf in head.named_buffers():
print(f" {name:14s} {tuple(buf.shape)}")
print(f"\ntotal trainable numbers in this head: {sum(p.numel() for p in head.parameters())}")
print("model.parameters() is exactly what you hand the optimizer -- no manual list to keep.")Line by line: what each line does
head.named_parameters(): the tensors PyTorch will train -- the key, query, and value weight matrices, each 16×32.head.named_buffers(): thetrilcausal mask -- registered so it follows the model onto the GPU, but never touched by the optimizer.sum(p.numel() ...): the head's trainable size, three 512-number matrices.- Because
nn.Moduletracks all of this,AdamW(model.parameters())just works -- you never assemble the weight list by hand as you did in notebook 04.
Recap
PyTorch is, in short, tensors plus autograd plus the nn building blocks. The bigram and the attention head that you worked through by hand now take only a few lines and train themselves, and you know exactly what every line is doing, because you built it the hard way first. You now have every component of a GPT.
Next: notebook 08 assembles the full GPT and inspects it before training.
07_pytorch_autograd.ipynb