Stack the blocks, add embeddings and a head -- that’s a GPT.
A guided tour of gpt.py: every class, what it does, and why. Every line traces back to something you built by hand in Part 1.
Now we assemble everything (attention, multi-head attention, the feed-forward network, LayerNorm, the residuals, and the embeddings) into a complete GPT. To keep it reusable, the model lives in a file called gpt.py, so that notebooks 09 and 10 can simply import it. This notebook is a guided tour of that file: we read each class in turn and explain what it does and why.
Here is the whole journey a token takes, from top to bottom:
token ids -> token embedding + position embedding
-> N x Transformer block (attention + feed-forward, with residuals)
-> final LayerNorm
-> linear head -> logits over the vocabulary (next-token scores)In words: turn each token id into a vector and add in where it sits; let the stack of blocks mix and refine those vectors; give them a final clean-up; then read out a score for every possible next token. That last list of scores is exactly what softmax and sampling turn into the next character.
# 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 ['gpt.py', '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, inspect
from gpt import GPTConfig, Head, MultiHeadAttention, FeedForward, Block, GPT
torch.manual_seed(1337) # reproducible init + untrained sample
device = "mps" if torch.backends.mps.is_available() else ("cuda" if torch.cuda.is_available() else "cpu")
print("model lives in gpt.py | device:", device)Line by line: what each line does
import torch, inspect: PyTorch, plusinspect, a standard tool that can fetch a class's source code as text.from gpt import GPTConfig, Head, ...: import the model's classes from the local filegpt.py, so notebooks 08-10 all share the exact same code.torch.manual_seed(1337): fix the seed so the untrained sample lower down is reproducible (the same seed the other notebooks use).- The device line picks Apple GPU → NVIDIA → CPU, as before.
Config
Every size of the model, gathered in one place. Think of these as the dials you turn up for a larger and more capable (but slower and more demanding) model: more layers (n_layer) makes it deeper; a wider embedding (n_embd) gives each token more room to represent meaning; more heads (n_head) lets it track more relationships at once; and a longer block_size lets it look further back. Our values are deliberately tiny, so the whole thing trains in about a minute.
print(inspect.getsource(GPTConfig))Line by line: what each line does
print(inspect.getsource(GPTConfig)): fetch and print the class's real source from gpt.py. What you see below is the actual code.@dataclass: a decorator (the @ line above the class) that auto-writes the boring constructor: you just declare fields with types and defaults, andGPTConfig(vocab_size=65)works.vocab_size: int = 65: a field: its name, its type, its default. Pure settings, no behavior. The other fields (block_size,n_layer,n_head,n_embd) are the size dials you turn up for a bigger model.
One attention head
This is identical to notebook 05, now written as a trainable nn.Module. key, query, and value are the learned projections; the tril buffer enforces causality (no looking at the future); and the 1/sqrt(d) scaling keeps softmax soft (notebook 05, step 4). Nothing here is new; it is the same head, wired up so that autograd can train it.
print(inspect.getsource(Head))Line by line: what each line does
- This prints notebook 07's
Head, with two production touches: self.key/query/value = nn.Linear(...): the three learned projections, as layers.self.register_buffer("tril", ...): the causal mask, carried with the model but not trained.self.dropout = nn.Dropout(cfg.dropout): during training, randomly zero a fraction of the attention weights (regularization, explained in notebook 09). Withdropout=0.0it does nothing.wei = q @ k.transpose(-2,-1) * k.shape[-1]**-0.5... -- scale, mask, softmax, blend: line-for-line what you built in notebooks 05 and 07.- The
"""..."""right under the class line is a docstring -- documentation tools can read it; Python ignores it at runtime.
Multi-head attention
Run several heads in parallel and join their outputs together, so the model can attend to different kinds of relationship at once, then use a projection (proj) to mix them back to the embedding width (notebook 06). The split head_size = n_embd // n_head is what keeps the heads affordable: four heads each do a quarter-width job, so the total cost matches that of a single full-width head.
print(inspect.getsource(MultiHeadAttention))Line by line: what each line does
head_size = cfg.n_embd // cfg.n_head: split the width across heads (128 / 4 = 32 each).self.heads = nn.ModuleList([Head(cfg, head_size) for _ in range(cfg.n_head)]): a list of heads that PyTorch knows about (a plain Python list would hide their parameters from the optimizer).torch.cat([h(x) for h in self.heads], dim=-1): run every head, then glue their outputs side by side along the last axis: 4 heads × 32 = 128. This is the "concatenate" from notebook 06.self.projthenself.dropout: a final projection that mixes the heads, then dropout.
Feed-forward
After the tokens have traded information through attention, each one is processed on its own by a small MLP: expand to four times the width, apply GELU, then project back (notebook 06). This is the "now think about what you just heard" step, and most of the model's numbers live here.
print(inspect.getsource(FeedForward))Line by line: what each line does
self.net = nn.Sequential(...): a pipeline: each layer feeds the next, in order. Callingself.net(x)runs the whole chain.nn.Linear(n_embd, 4 * n_embd)→nn.GELU()→nn.Linear(4 * n_embd, n_embd): widen 4×, bend, narrow back: notebook 06's feed-forward, with PyTorch's exact GELU.nn.Dropout(cfg.dropout): the same regularization knob at the end.def forward(self, x): return self.net(x): just run the pipeline.
The block
One block is attention (communicate) followed by the feed-forward network (think), each wrapped in pre-norm and a residual connection. Stacking these blocks is the entire depth of the network. The residual, written x = x + sublayer(norm(x)) (keep the input, add an edit on top), is what lets the gradients flow cleanly through a deep stack (notebook 06).
print(inspect.getsource(Block))Line by line: what each line does
self.ln1 = nn.LayerNorm(cfg.n_embd)(and ln2) -- the LayerNorm you wrote by hand in notebook 06, now built in, with learned dials.self.sa = MultiHeadAttention(cfg)/self.ffwd = FeedForward(cfg): the two sub-layers.x = x + self.sa(self.ln1(x)): normalize → attend → add back (residual).x = x + self.ffwd(self.ln2(x)): normalize → think → add back. Two lines, the whole block.
The full GPT
This is the whole model, and every line should look familiar by now:
token_embedding: a lookup table that turns each token id into a learnable vector (its "meaning"). Nothing assigns these meanings by hand; the vectors start out random and are nudged by training until tokens used in similar ways end up with similar vectors. Meaning emerges from usage.position_embedding: a second table that adds in where the token sits (notebook 06's position information, learned here rather than fixed as sinusoids).blocks: the stack of Transformer blocks that do the real work.ln_fand thenlm_head: a final LayerNorm clean-up, followed by a linear layer that turns each position's vector into logits over the vocabulary, that is, a score for every possible next token.
If you pass in targets, the model also returns the cross-entropy loss (notebook 01). And generate is the autoregressive sampling loop from notebook 03, with two new dials we will explore in notebook 10 (temperature and top_k): predict, take the last position's logits, optionally filter them, apply softmax, draw one token, append it, and repeat, always cropping the context to the last block_size tokens so the model never looks back further than it was trained to.
print(inspect.getsource(GPT))Line by line: what each line does
self.token_embedding = nn.Embedding(cfg.vocab_size, cfg.n_embd): the meaning table: 65 rows (one per character), 128 numbers each.self.position_embedding = nn.Embedding(cfg.block_size, cfg.n_embd): the same idea for positions 0..127.self.blocks = nn.Sequential(*[Block(cfg) for _ in range(cfg.n_layer)]): build n_layer Blocks; the*unpacks the list into separate arguments.self.ln_f/self.lm_head: a final LayerNorm, then a Linear that turns each token's 128 numbers into 65 next-token scores.self.apply(self._init_weights): run a small setup on every sub-module: start weights small-random (std 0.02), biases at zero. Good starting points matter.x = tok + pos: meaning plus position, added together (broadcasting fills the batch axis).@torch.no_grad()abovegenerate: a decorator meaning "don't record gradients": generation is pure inference.- Inside
generate: crop to the lastblock_sizetokens, keep only the last position's logits, divide by temperature, optionally keep just the top-k (torch.topkfinds them; everything below gets -inf), softmax, thentorch.multinomial(probs, 1)rolls the weighted die andtorch.catappends it. num_params(): add upp.numel()(element count) over every weight grid: the parameter total.
Instantiate it
Build a real, if small, GPT sized for our character data, and count its numbers. It comes out at around 825,000, which sounds like a lot until you learn that GPT-3 has 175 billion, roughly 200,000 times more. The striking part is that the design you are looking at is, line for line, what the giant models use. They are the same thing, only far wider, far deeper, and trained on far more text.
from pathlib import Path
text = Path("data/input.txt").read_text()
vocab_size = len(sorted(set(text)))
cfg = GPTConfig(vocab_size=vocab_size, block_size=128, n_layer=4, n_head=4, n_embd=128)
model = GPT(cfg).to(device)
print(f"vocab={cfg.vocab_size} block={cfg.block_size} layers={cfg.n_layer} heads={cfg.n_head} width={cfg.n_embd}")
print(f"total parameters: {model.num_params():,}")Line by line: what each line does
cfg = GPTConfig(vocab_size=vocab_size, block_size=128, n_layer=4, n_head=4, n_embd=128): fill in the size dials.model = GPT(cfg).to(device): build the model and ship every weight to the GPU.f"{model.num_params():,}": the:,format inserts thousands separators: 824,897.
Where the 824,897 parameters live
That single number is easier to trust once you see it split up. Group every parameter tensor by the part of the model it belongs to, and one thing stands out: the stacked Transformer blocks hold about 96% of the weights, while the embeddings and the output head are a thin shell around them.
from collections import OrderedDict
groups = OrderedDict()
for name, p in model.named_parameters():
top = name.split(".")[0]
label = {"token_embedding": "token embedding", "position_embedding": "position embedding",
"blocks": "4 transformer blocks", "ln_f": "final LayerNorm",
"lm_head": "output head"}.get(top, top)
groups[label] = groups.get(label, 0) + p.numel()
total = sum(groups.values())
for label, n in groups.items():
print(f" {label:22s} {n:8,d} ({100*n/total:4.1f}%)")
print(f" {'TOTAL':22s} {total:8,d}")
import matplotlib.pyplot as plt
plt.figure(figsize=(6.5, 3))
bars = plt.bar([l.replace(" ", "\n", 1) for l in groups], list(groups.values()),
color=["#5b5bd6" if "blocks" in l else "#8b8bd6" for l in groups])
for b, n in zip(bars, groups.values()):
plt.text(b.get_x() + b.get_width() / 2, n, f"{n:,}", ha="center", va="bottom", fontsize=7)
plt.ylabel("parameters"); plt.title("where the parameters live (blocks dominate)")
plt.xticks(fontsize=7); plt.tight_layout(); plt.show()Line by line: what each line does
- The loop reads each parameter tensor's name (like
blocks.0.sa...) and adds its size to the matching component. - The token and position embeddings and the output head are small -- a few thousand numbers each; the four blocks together hold 791,552, about 96%.
- Inside each block most of that is the feed-forward network's two big matrices (the 4× expand and contract from notebook 06).
- The bar chart makes the imbalance plain: real capacity lives in the stacked blocks, which is why "make it bigger" almost always means more or wider blocks.
Forward pass and an untrained sample
A forward pass returns logits with shape (batch, time, vocab) and, when given targets, a loss near ln(vocab) = 4.17. This is because a freshly built model has random weights and is essentially guessing all 65 characters as equally likely (notebook 01's random baseline). Generating from it produces pure gibberish. That is our starting line; notebook 09 trains it into something that writes Shakespeare.
import math
B, T = 4, cfg.block_size
x = torch.randint(0, vocab_size, (B, T), device=device)
y = torch.randint(0, vocab_size, (B, T), device=device)
logits, loss = model(x, y)
print("logits:", tuple(logits.shape), "| loss: %.3f | ln(vocab) = %.3f" % (loss.item(), math.log(vocab_size)))
stoi = {c:i for i,c in enumerate(sorted(set(text)))}; itos = {i:c for c,i in stoi.items()}
decode = lambda l: "".join(itos[i] for i in l)
ctx = torch.zeros((1,1), dtype=torch.long, device=device)
print("\n--- untrained sample (gibberish) ---")
print(decode(model.generate(ctx, 200)[0].tolist()))Line by line: what each line does
x = torch.randint(0, vocab_size, (B, T), device=device): random token ids shaped like a real batch, just to exercise the forward pass.logits, loss = model(x, y): run the model; with targets it also returns the loss.math.log(vocab_size): ln(65) = 4.174, the know-nothing baseline; the untrained loss lands right next to it.ctx = torch.zeros((1,1), dtype=torch.long, device=device): the generation prompt: one sequence holding one token (id 0, the newline) -- a neutral start.decode(model.generate(ctx, 200)[0].tolist()): generate 200 tokens, take the first (only) sequence, turn the tensor into a plain list, decode to text. Untrained = gibberish.
Follow the shapes through one forward pass
The diagram at the top of this page traced the path from token ids to logits. Here it is with real tensors, so you can watch each stage keep the batch and time axes and change only the last one: ids become 128-wide vectors, the blocks refine them without reshaping, and the head finally opens each position out to one score per vocabulary character.
One tensor, all the way through
The one thing that trips people up is shapes. Here is a real tensor flowing through the model. Notice the batch and time axes stay put the whole way — the model just reshapes each position's vector, ending with one score per vocab character.
# Run the model's stages by hand and print the shape after each (mirrors GPT.forward).
idx = torch.randint(0, cfg.vocab_size, (1, 20), device=device)
tok = model.token_embedding(idx)
pos = model.position_embedding(torch.arange(20, device=device))
h = tok + pos
hb = model.blocks(h)
hn = model.ln_f(hb)
logits = model.lm_head(hn)
print(f"idx (token ids) : {tuple(idx.shape)} (batch, time)")
print(f"token + position embed : {tuple(h.shape)} (batch, time, width 128)")
print(f"after the 4 blocks : {tuple(hb.shape)} (same shape -- blocks preserve it)")
print(f"after final LayerNorm : {tuple(hn.shape)} (same shape)")
print(f"logits (lm_head) : {tuple(logits.shape)} (a score per vocab char, per position)")Line by line: what each line does
idx: a batch of token ids, shape (batch, time).token_embedding + position_embedding: each id becomes a 128-wide vector and the position vector is added -- now (batch, time, 128).model.blocks(h): the four Transformer blocks run and keep the shape exactly, just refining the numbers.ln_fthenlm_head: a final normalize, then a linear layer opens each position out to one score per vocabulary character -- (batch, time, 65).- Every stage keeps the batch and time axes; only the last axis changes. That regularity is exactly what lets the blocks stack.
Recap
The GPT is assembled and lives in gpt.py: embeddings, then blocks, then a final norm, then logits, with about a million numbers in total, and both the forward pass and generate already working (just untrained so far). Every line traces back to something you built by hand in Part 1; nothing here is new, it has only been wired together.
Next, notebook 09 trains it on Shakespeare and we watch the loss fall and the text sharpen.
08_build_gpt.ipynb