Under the Hood
Book ↗
Projects/Building a GPT
Project 05

Your GPT from a Blank File

the smallest complete system.

The smallest complete GPT: tokenizer → batches → transformer blocks → training loop → generation, in one file. Train it on Shakespeare-shaped text in 3 seconds on CPU.

The Concept

A GPT-style language model is a stack of identical pre-norm transformer blocks operating on token + position embeddings, with a tied LM head projecting back to vocabulary logits. Training is a tight loop: sample a batch of (input, target+1-shifted), compute cross-entropy, backprop, update with AdamW, clip gradients, repeat.

The blocks themselves are the canonical pattern:

x = x + attn(LayerNorm(x))
x = x + mlp(LayerNorm(x))

Pre-norm + residual + LayerNorm. Same recipe, repeated N times.

Why It Matters

Once you have written this, every later GPT implementation you encounter — nanoGPT, llama, gpt-2, mistral — stops looking like wizardry and starts looking like a variation on these ~250 lines.


The Build

build.py

$python projects/05_your-gpt-from-a-blank-file/build.py --tiny

"""
Project 5: Your GPT from a Blank File — complete working build.

A tiny GPT-style language model assembled from scratch with PyTorch nn.Module.
Token embedding + position embedding + N transformer blocks (causal attention +
MLP, pre-norm, residual connections) + final layer norm + tied LM head.

Train on a small built-in corpus, then sample.

Run:
    python build.py --tiny      # 200 steps, ~30s on CPU
    python build.py --full      # 5000 steps, ~10 min on CPU
"""

from __future__ import annotations

import argparse
import math
from dataclasses import dataclass
from pathlib import Path

import torch
import torch.nn as nn
import torch.nn.functional as F

# A small built-in corpus to train on — fragments meant to be Shakespeare-shaped.
DEFAULT_CORPUS = """First Citizen:
Before we proceed any further, hear me speak.

All:
Speak, speak.

First Citizen:
You are all resolved rather to die than to famish?

All:
Resolved, resolved.

First Citizen:
First, you know Caius Marcius is chief enemy to the people.

All:
We know't, we know't.

First Citizen:
Let us kill him, and we'll have corn at our own price.
Is't a verdict?

All:
No more talking on't; let it be done: away, away!

Second Citizen:
One word, good citizens.

First Citizen:
We are accounted poor citizens, the patricians good.
What authority surfeits on would relieve us: if they
would yield us but the superfluity, while it were
wholesome, we might guess they relieved us humanely;
but they think we are too dear: the leanness that
afflicts us, the object of our misery, is as an
inventory to particularise their abundance; our
sufferance is a gain to them.

Let us revenge this with our pikes, ere we become rakes:
for the gods know I speak this in hunger for bread,
not in thirst for revenge.

Second Citizen:
Would you proceed especially against Caius Marcius?

All:
Against him first: he's a very dog to the commonalty.

Second Citizen:
Consider you what services he has done for his country?

First Citizen:
Very well; and could be content to give him good
report fort, but that he pays himself with being proud.

Second Citizen:
Nay, but speak not maliciously.

First Citizen:
I say unto you, what he hath done famously, he did
it to that end: though soft-conscienced men can be
content to say it was for his country he did it to
please his mother and to be partly proud; which he
is, even till the altitude of his virtue.
"""


@dataclass
class GPTConfig:
    block_size: int = 32
    n_layers: int = 2
    n_heads: int = 4
    d_model: int = 64
    dropout: float = 0.0


class CausalSelfAttention(nn.Module):
    def __init__(self, cfg: GPTConfig) -> None:
        super().__init__()
        assert cfg.d_model % cfg.n_heads == 0
        self.n_heads = cfg.n_heads
        self.head_dim = cfg.d_model // cfg.n_heads
        self.qkv = nn.Linear(cfg.d_model, 3 * cfg.d_model)
        self.proj = nn.Linear(cfg.d_model, cfg.d_model)
        self.dropout = nn.Dropout(cfg.dropout)
        self.register_buffer(
            "mask",
            torch.tril(torch.ones(cfg.block_size, cfg.block_size)).view(
                1, 1, cfg.block_size, cfg.block_size
            ),
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        B, T, C = x.shape
        qkv = self.qkv(x)
        q, k, v = qkv.split(C, dim=2)
        q = q.view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
        k = k.view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
        v = v.view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
        att = (q @ k.transpose(-2, -1)) / math.sqrt(self.head_dim)
        att = att.masked_fill(self.mask[:, :, :T, :T] == 0, float("-inf"))
        att = F.softmax(att, dim=-1)
        att = self.dropout(att)
        y = att @ v
        y = y.transpose(1, 2).contiguous().view(B, T, C)
        return self.dropout(self.proj(y))


class MLP(nn.Module):
    def __init__(self, cfg: GPTConfig) -> None:
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(cfg.d_model, 4 * cfg.d_model),
            nn.GELU(),
            nn.Linear(4 * cfg.d_model, cfg.d_model),
            nn.Dropout(cfg.dropout),
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.net(x)


class Block(nn.Module):
    """Pre-norm transformer block: attention then MLP, each with residual + LayerNorm."""

    def __init__(self, cfg: GPTConfig, use_residual: bool = True) -> None:
        super().__init__()
        self.use_residual = use_residual
        self.ln1 = nn.LayerNorm(cfg.d_model)
        self.attn = CausalSelfAttention(cfg)
        self.ln2 = nn.LayerNorm(cfg.d_model)
        self.mlp = MLP(cfg)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        if self.use_residual:
            x = x + self.attn(self.ln1(x))
            x = x + self.mlp(self.ln2(x))
        else:
            # BREAK IT mode: replace residuals with the raw sublayer output.
            x = self.attn(self.ln1(x))
            x = self.mlp(self.ln2(x))
        return x


class GPT(nn.Module):
    def __init__(self, cfg: GPTConfig, vocab_size: int, use_residual: bool = True) -> None:
        super().__init__()
        self.cfg = cfg
        self.vocab_size = vocab_size
        self.token_embedding = nn.Embedding(vocab_size, cfg.d_model)
        self.position_embedding = nn.Embedding(cfg.block_size, cfg.d_model)
        self.blocks = nn.ModuleList(
            [Block(cfg, use_residual=use_residual) for _ in range(cfg.n_layers)]
        )
        self.ln_f = nn.LayerNorm(cfg.d_model)
        self.lm_head = nn.Linear(cfg.d_model, vocab_size, bias=False)
        # Weight tying: share embedding with output head.
        self.lm_head.weight = self.token_embedding.weight

    def forward(
        self, idx: torch.Tensor, targets: torch.Tensor | None = None
    ) -> tuple[torch.Tensor, torch.Tensor | None]:
        B, T = idx.shape
        pos = torch.arange(T, device=idx.device)
        x = self.token_embedding(idx) + self.position_embedding(pos)
        for block in self.blocks:
            x = block(x)
        x = self.ln_f(x)
        logits = self.lm_head(x)
        if targets is None:
            return logits, None
        loss = F.cross_entropy(logits.view(-1, self.vocab_size), targets.view(-1))
        return logits, loss

    @torch.no_grad()
    def generate(
        self, idx: torch.Tensor, max_new_tokens: int, temperature: float = 1.0
    ) -> torch.Tensor:
        for _ in range(max_new_tokens):
            idx_cond = idx[:, -self.cfg.block_size :]
            logits, _ = self(idx_cond)
            logits = logits[:, -1, :] / max(temperature, 1e-6)
            probs = F.softmax(logits, dim=-1)
            next_token = torch.multinomial(probs, num_samples=1)
            idx = torch.cat((idx, next_token), dim=1)
        return idx


def char_tokenizer(text: str) -> tuple[dict[str, int], dict[int, str], int]:
    chars = sorted(set(text))
    stoi = {ch: i for i, ch in enumerate(chars)}
    itos = {i: ch for ch, i in stoi.items()}
    return stoi, itos, len(chars)


def encode(text: str, stoi: dict[str, int]) -> torch.Tensor:
    return torch.tensor([stoi[c] for c in text], dtype=torch.long)


def decode(ids: list[int] | torch.Tensor, itos: dict[int, str]) -> str:
    if isinstance(ids, torch.Tensor):
        ids = ids.tolist()
    return "".join(itos[i] for i in ids)


def get_batch(
    data: torch.Tensor, block_size: int, batch_size: int, generator: torch.Generator
) -> tuple[torch.Tensor, torch.Tensor]:
    ix = torch.randint(len(data) - block_size - 1, (batch_size,), generator=generator)
    x = torch.stack([data[i : i + block_size] for i in ix])
    y = torch.stack([data[i + 1 : i + block_size + 1] for i in ix])
    return x, y


def train_gpt(
    model: GPT,
    train_data: torch.Tensor,
    val_data: torch.Tensor,
    steps: int,
    batch_size: int,
    lr: float,
    eval_every: int,
    seed: int = 0,
) -> tuple[list[float], list[float]]:
    g = torch.Generator().manual_seed(seed)
    optimizer = torch.optim.AdamW(model.parameters(), lr=lr)
    train_hist: list[float] = []
    val_hist: list[float] = []
    for step in range(steps):
        x, y = get_batch(train_data, model.cfg.block_size, batch_size, g)
        _, loss = model(x, y)
        optimizer.zero_grad()
        assert loss is not None
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        optimizer.step()
        if step % eval_every == 0 or step == steps - 1:
            train_hist.append(float(loss.item()))
            with torch.no_grad():
                vx, vy = get_batch(val_data, model.cfg.block_size, batch_size, g)
                _, vloss = model(vx, vy)
                val_hist.append(float(vloss.item()))
    return train_hist, val_hist


def write_loss_curve(
    train_hist: list[float], val_hist: list[float], eval_every: int, path: Path
) -> None:
    try:
        import matplotlib

        matplotlib.use("Agg")
        import matplotlib.pyplot as plt
    except ImportError:
        return
    xs = [i * eval_every for i in range(len(train_hist))]
    fig, ax = plt.subplots(figsize=(6, 4))
    ax.plot(xs, train_hist, label="train")
    ax.plot(xs, val_hist, label="val")
    ax.set_xlabel("training step")
    ax.set_ylabel("cross-entropy loss")
    ax.set_title("Project 5: tiny GPT training")
    ax.legend()
    ax.grid(True, alpha=0.3)
    fig.tight_layout()
    fig.savefig(path, dpi=120)
    plt.close(fig)


def main() -> int:
    parser = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
    )
    parser.add_argument("--tiny", action="store_true")
    parser.add_argument("--full", action="store_true")
    parser.add_argument("--steps", type=int, default=None)
    parser.add_argument("--lr", type=float, default=3e-3)
    parser.add_argument("--batch-size", type=int, default=32)
    parser.add_argument("--block-size", type=int, default=32)
    parser.add_argument("--d-model", type=int, default=64)
    parser.add_argument("--n-heads", type=int, default=4)
    parser.add_argument("--n-layers", type=int, default=2)
    parser.add_argument("--seed", type=int, default=0)
    parser.add_argument(
        "--output-dir",
        type=Path,
        default=Path(__file__).parent / "outputs",
    )
    args = parser.parse_args()

    if args.steps is None:
        args.steps = 5000 if args.full else 200

    args.output_dir.mkdir(parents=True, exist_ok=True)
    torch.manual_seed(args.seed)
    text = DEFAULT_CORPUS
    stoi, itos, vocab_size = char_tokenizer(text)
    data = encode(text, stoi)
    n_train = int(0.9 * len(data))
    train_data = data[:n_train]
    val_data = data[n_train:]
    print(f"Corpus: {len(text)} chars, vocab_size={vocab_size}")
    print(f"Train tokens: {len(train_data)}  Val tokens: {len(val_data)}")

    cfg = GPTConfig(
        block_size=args.block_size,
        n_layers=args.n_layers,
        n_heads=args.n_heads,
        d_model=args.d_model,
    )
    model = GPT(cfg, vocab_size=vocab_size)
    n_params = sum(p.numel() for p in model.parameters())
    # Account for weight tying — embedding and lm_head share weights.
    n_params -= model.token_embedding.weight.numel()
    print(f"Model parameters (after weight tying): ~{n_params}")

    eval_every = max(1, args.steps // 10)
    print(f"\nTraining for {args.steps} steps ...")
    train_hist, val_hist = train_gpt(
        model,
        train_data,
        val_data,
        steps=args.steps,
        batch_size=args.batch_size,
        lr=args.lr,
        eval_every=eval_every,
        seed=args.seed,
    )
    print(
        f"Final: train_loss={train_hist[-1]:.4f}  val_loss={val_hist[-1]:.4f}  "
        f"(uniform would be log(vocab)={math.log(vocab_size):.4f})"
    )

    # === Generate samples ===
    model.eval()
    print("\n=== Sample generations (temperature=1.0) ===")
    prompt = encode("First Citizen:\n", stoi).unsqueeze(0)
    samples = []
    for _ in range(3):
        out_ids = model.generate(prompt, max_new_tokens=120, temperature=1.0)
        out_text = decode(out_ids[0], itos)
        samples.append(out_text)
        print(out_text)
        print("-" * 40)

    write_loss_curve(train_hist, val_hist, eval_every, args.output_dir / "loss_curve.png")
    log = args.output_dir / "run_log.txt"
    lines = [
        "# Project 5 run log",
        f"corpus_chars     : {len(text)}",
        f"vocab_size       : {vocab_size}",
        f"block_size       : {args.block_size}",
        f"d_model          : {args.d_model}",
        f"n_heads          : {args.n_heads}",
        f"n_layers         : {args.n_layers}",
        f"steps            : {args.steps}",
        f"n_parameters     : {n_params}",
        f"train_loss_final : {train_hist[-1]:.4f}",
        f"val_loss_final   : {val_hist[-1]:.4f}",
        f"uniform_baseline : {math.log(vocab_size):.4f}",
        "",
        "# sample generations (3 samples, temperature=1.0)",
    ]
    for i, sample in enumerate(samples):
        lines.append(f"--- sample {i + 1} ---")
        lines.append(sample)
    log.write_text("\n".join(lines) + "\n", encoding="utf-8")
    print(f"\nOutputs written to {args.output_dir.resolve()}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

The Steps

13 reference files

Each step is the code for one piece, in isolation — read them as the book introduces them. They assemble into build.py above.

Step 01Decide The Smallest Complete Systemstep_01_decide-the-smallest-complete-system.py
"""
Project 5: Step 1 — Decide the smallest complete system

Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""

from dataclasses import dataclass

@dataclass
class Config:
    batch_size = 64
    block_size = 128
    d_model = 256
    n_heads = 4
    n_layers = 6
    dropout = 0.2
    learning_rate = 3e-4
    min_lr = 3e-5
    warmup_steps = 200
    max_steps = 10000
    eval_interval = 200
    eval_steps = 50
    grad_clip = 1.0
    device = "cuda"
Step 02Load Text And Tokenize Itstep_02_load-text-and-tokenize-it.py
"""
Project 5: Step 2 — Load text and tokenize it

Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""

chars = sorted(list(set(text)))
stoi = {ch: i for i, ch in enumerate(chars)}
itos = {i: ch for ch, i in stoi.items()}

def encode(s):
    return [stoi[c] for c in s]

def decode(ids):
    return "".join(itos[i] for i in ids)

n = int(0.9 * len(data))
train_data = data[:n]
val_data = data[n:]
Step 03Write The Batch Sampler Honestlystep_03_write-the-batch-sampler-honestly.py
"""
Project 5: Step 3 — Write the batch sampler honestly

Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""

def get_batch(split: str, cfg: 'Config') -> tuple:
    data = train_data if split == "train" else val_data
    ix = torch.randint(len(data) - cfg.block_size - 1, (cfg.batch_size,))
    x = torch.stack([data[i:i+cfg.block_size] for i in ix])
    y = torch.stack([data[i+1:i+cfg.block_size+1] for i in ix])
    return x.to(cfg.device), y.to(cfg.device)
Step 04Build The Transformer Piecesstep_04_build-the-transformer-pieces.py
"""
Project 5: Step 4 — Build the transformer pieces

Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""

tok = self.token_embedding(idx)          # (B, T, d_model)
pos = self.position_embedding(pos_ids)   # (T, d_model)
x = tok + pos

class CausalSelfAttention(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        assert cfg.d_model % cfg.n_heads == 0
        self.n_heads = cfg.n_heads
        self.head_dim = cfg.d_model // cfg.n_heads
        self.qkv = nn.Linear(cfg.d_model, 3 * cfg.d_model)
        self.proj = nn.Linear(cfg.d_model, cfg.d_model)
        self.dropout = nn.Dropout(cfg.dropout)
        self.register_buffer(
            "mask",
            torch.tril(torch.ones(cfg.block_size, cfg.block_size))
                .view(1, 1, cfg.block_size, cfg.block_size)
        )

    def forward(self, x):
        B, T, C = x.shape
        qkv = self.qkv(x)
        q, k, v = qkv.split(C, dim=2)

        q = q.view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
        k = k.view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
        v = v.view(B, T, self.n_heads, self.head_dim).transpose(1, 2)

        att = (q @ k.transpose(-2, -1)) / math.sqrt(self.head_dim)
        att = att.masked_fill(self.mask[:, :, :T, :T] == 0, float("-inf"))
        att = F.softmax(att, dim=-1)
        att = self.dropout(att)

        y = att @ v
        y = y.transpose(1, 2).contiguous().view(B, T, C)
        return self.dropout(self.proj(y))

class MLP(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(cfg.d_model, 4 * cfg.d_model),
            nn.GELU(),
            nn.Linear(4 * cfg.d_model, cfg.d_model),
            nn.Dropout(cfg.dropout),
        )

    def forward(self, x):
        return self.net(x)

class Block(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        self.ln1 = nn.LayerNorm(cfg.d_model)
        self.attn = CausalSelfAttention(cfg)
        self.ln2 = nn.LayerNorm(cfg.d_model)
        self.mlp = MLP(cfg)

    def forward(self, x):
        x = x + self.attn(self.ln1(x))
        x = x + self.mlp(self.ln2(x))
        return x
Step 05Write The Gpt Class As One Whole Systemstep_05_write-the-gpt-class-as-one-whole-system.py
"""
Project 5: Step 5 — Write the GPT class as one whole system

Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""

class GPT(nn.Module):
    def __init__(self, cfg, vocab_size):
        super().__init__()
        self.cfg = cfg
        self.token_embedding = nn.Embedding(vocab_size, cfg.d_model)
        self.position_embedding = nn.Embedding(cfg.block_size, cfg.d_model)
        self.blocks = nn.ModuleList([Block(cfg) for _ in range(cfg.n_layers)])
        self.ln_f = nn.LayerNorm(cfg.d_model)
        self.lm_head = nn.Linear(cfg.d_model, vocab_size, bias=False)

        # weight tying
        self.lm_head.weight = self.token_embedding.weight

        self.apply(self._init_weights)

self.lm_head.weight = self.token_embedding.weight
Step 06Treat Initialization As Part Of The Modelstep_06_treat-initialization-as-part-of-the-model.py
"""
Project 5: Step 6 — Treat initialization as part of the model

Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""

def _init_weights(self, module):
    if isinstance(module, nn.Linear):
        nn.init.xavier_uniform_(module.weight)
        if module.bias is not None:
            nn.init.zeros_(module.bias)
    elif isinstance(module, nn.Embedding):
        nn.init.normal_(module.weight, mean=0.0, std=0.02)
Step 07Write Forward So Shapes Stay Honeststep_07_write-forward-so-shapes-stay-honest.py
"""
Project 5: Step 7 — Write `forward()` so shapes stay honest

Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""

def forward(self, idx: torch.Tensor, targets: torch.Tensor = None) -> tuple:
    B, T = idx.shape
    assert T <= self.cfg.block_size

    pos = torch.arange(0, T, device=idx.device)
    x = self.token_embedding(idx) + self.position_embedding(pos)

    for block in self.blocks:
        x = block(x)

    x = self.ln_f(x)
    logits = self.lm_head(x)

    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
Step 08Write Generate For Autoregressive Samplingstep_08_write-generate-for-autoregressive-sampling.py
"""
Project 5: Step 8 — Write `generate()` for autoregressive sampling

Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""

@torch.no_grad()
def generate(self, idx: torch.Tensor, max_new_tokens: int, temperature: float = 1.0) -> torch.Tensor:
    for _ in range(max_new_tokens):
        idx_cond = idx[:, -self.cfg.block_size:]
        logits, _ = self(idx_cond)
        logits = logits[:, -1, :] / temperature
        probs = F.softmax(logits, dim=-1)
        next_idx = torch.multinomial(probs, num_samples=1)
        idx = torch.cat([idx, next_idx], dim=1)
    return idx
Step 09Count Parameters Before And After Weight Tyingstep_09_count-parameters-before-and-after-weight-tying.py
"""
Project 5: Step 9 — Count parameters before and after weight tying

Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""

def count_params(model):
    return sum(p.numel() for p in model.parameters())
Step 10Write The Optimizer And Your Own Learning Rate Schedulestep_10_write-the-optimizer-and-your-own-learning-rate-schedule.py
"""
Project 5: Step 10 — Write the optimizer and your own learning rate schedule

Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""

def get_lr(step: int, cfg: 'Config') -> float:
    if step < cfg.warmup_steps:
        return cfg.learning_rate * (step + 1) / cfg.warmup_steps
    if step > cfg.max_steps:
        return cfg.min_lr
    decay_ratio = (step - cfg.warmup_steps) / (cfg.max_steps - cfg.warmup_steps)
    coeff = 0.5 * (1.0 + math.cos(math.pi * decay_ratio))
    return cfg.min_lr + coeff * (cfg.learning_rate - cfg.min_lr)
Step 11Write The Training Loop Yourselfstep_11_write-the-training-loop-yourself.py
"""
Project 5: Step 11 — Write the training loop yourself

Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""

import logging

model = GPT(cfg, vocab_size).to(cfg.device)
optimizer = torch.optim.AdamW(model.parameters(), lr=cfg.learning_rate)

for step in range(cfg.max_steps):
    lr = get_lr(step, cfg)
    for param_group in optimizer.param_groups:
        param_group["lr"] = lr

    xb, yb = get_batch("train", cfg)
    logits, loss = model(xb, yb)

    optimizer.zero_grad(set_to_none=True)
    loss.backward()
    torch.nn.utils.clip_grad_norm_(model.parameters(), cfg.grad_clip)
    optimizer.step()

    if step % cfg.eval_interval == 0:
        train_loss = estimate_loss("train")
        val_loss = estimate_loss("val")
        logging.info("step %d  train_loss %.4f  val_loss %.4f", step, train_loss, val_loss)
        sample_text(model)
        save_checkpoint(...)
Step 12Measure Validation Loss Separatelystep_12_measure-validation-loss-separately.py
"""
Project 5: Step 12 — Measure validation loss separately

Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""

@torch.no_grad()
def estimate_loss(split: str) -> float:
    model.eval()
    losses = torch.zeros(cfg.eval_steps)
    for k in range(cfg.eval_steps):
        xb, yb = get_batch(split, cfg)
        _, loss = model(xb, yb)
        losses[k] = loss.item()
    model.train()
    return losses.mean().item()
Step 14Add Checkpoint Saveload Before You Need Itstep_14_add-checkpoint-saveload-before-you-need-it.py
"""
Project 5: Step 14 — Add checkpoint save/load before you need it

Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""

def save_checkpoint(path: str, model: 'GPT', optimizer, step: int, cfg: 'Config') -> None:
    torch.save({
        "model": model.state_dict(),
        "optimizer": optimizer.state_dict(),
        "step": step,
        "config": cfg.__dict__,
    }, path)

def load_checkpoint(path: str, model: 'GPT', optimizer) -> int:
    ckpt = torch.load(path, map_location=cfg.device)
    model.load_state_dict(ckpt["model"])
    optimizer.load_state_dict(ckpt["optimizer"])
    return ckpt["step"]

Break It

break_it.py

The standard transformer block is:

x = x + attn(LayerNorm(x))    # ← that "+ x" is the residual
x = x + mlp(LayerNorm(x))     # ← and that one

What happens if we replace those with the raw sublayer output?

x = attn(LayerNorm(x))   # no residual
x = mlp(LayerNorm(x))    # no residual
Uniform baseline (random guessing): 3.8712

mode                          final train     final val
-------------------------------------------------------
baseline (residual)                1.5735        2.7224
broken (no residual)               2.8488        2.8053

The broken model's train and val are essentially equal — about 1 nat above the baseline. It's barely better than uniform random. Without the residual, the random init of the first sublayer wipes out the signal before training can do anything useful — and stacking more layers compounds the destruction.

Lesson: the residual connection is not a small optimization. It is the architectural reason transformers can be deep. Each sublayer learns a correction to the running representation; without the "+x" the sublayer becomes a replacement, and a randomly-initialized replacement destroys whatever the previous layer expressed.


$python projects/05_your-gpt-from-a-blank-file/break_it.py --tiny

"""
Project 5: BREAK IT — remove the residual connections.

In a pre-norm transformer block, the standard pattern is:
    x = x + attn(LayerNorm(x))
    x = x + mlp(LayerNorm(x))

We replace those with:
    x = attn(LayerNorm(x))
    x = mlp(LayerNorm(x))

The "+ x" is the residual connection. It lets the original signal pass through
unmodified — each sublayer learns a correction, not a replacement. Without it,
early-training noise from random sublayers wipes out the input signal entirely,
and the network struggles to learn anything coherent.

Run:
    python break_it.py --tiny
"""

from __future__ import annotations

import argparse
import math
from pathlib import Path

import torch

from build import (
    DEFAULT_CORPUS,
    GPT,
    GPTConfig,
    char_tokenizer,
    encode,
    train_gpt,
)


def main() -> int:
    parser = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
    )
    parser.add_argument("--tiny", action="store_true")
    parser.add_argument("--full", action="store_true")
    parser.add_argument("--steps", type=int, default=None)
    parser.add_argument("--seed", type=int, default=0)
    parser.add_argument(
        "--output-dir",
        type=Path,
        default=Path(__file__).parent / "outputs",
    )
    args = parser.parse_args()

    if args.steps is None:
        args.steps = 1000 if args.full else 200

    args.output_dir.mkdir(parents=True, exist_ok=True)
    text = DEFAULT_CORPUS
    stoi, _itos, vocab_size = char_tokenizer(text)
    data = encode(text, stoi)
    n_train = int(0.9 * len(data))
    train_data = data[:n_train]
    val_data = data[n_train:]

    cfg = GPTConfig(block_size=32, n_layers=2, n_heads=4, d_model=64)

    # === Baseline (with residuals) ===
    torch.manual_seed(args.seed)
    baseline = GPT(cfg, vocab_size=vocab_size, use_residual=True)
    base_train, base_val = train_gpt(
        baseline,
        train_data,
        val_data,
        steps=args.steps,
        batch_size=32,
        lr=3e-3,
        eval_every=max(1, args.steps // 10),
        seed=args.seed,
    )

    # === Broken (no residuals) ===
    torch.manual_seed(args.seed)
    broken = GPT(cfg, vocab_size=vocab_size, use_residual=False)
    broken_train, broken_val = train_gpt(
        broken,
        train_data,
        val_data,
        steps=args.steps,
        batch_size=32,
        lr=3e-3,
        eval_every=max(1, args.steps // 10),
        seed=args.seed,
    )

    uniform = math.log(vocab_size)
    print(f"\nUniform baseline (random guessing): {uniform:.4f}")
    print(f"\n{'mode':25s}  {'final train':>14s}  {'final val':>12s}")
    print("-" * 55)
    print(f"{'baseline (residual)':25s}  {base_train[-1]:>14.4f}  {base_val[-1]:>12.4f}")
    print(f"{'broken (no residual)':25s}  {broken_train[-1]:>14.4f}  {broken_val[-1]:>12.4f}")

    log = args.output_dir / "break_it_log.txt"
    log.write_text(
        f"# Project 5 BREAK IT — remove residual connections\n\n"
        f"uniform baseline      : {uniform:.4f}\n"
        f"baseline (residual)   : train={base_train[-1]:.4f}  val={base_val[-1]:.4f}\n"
        f"broken (no residual)  : train={broken_train[-1]:.4f}  val={broken_val[-1]:.4f}\n"
        "\n"
        "Lesson: each residual connection lets the original signal flow through\n"
        "unmodified. The sublayer learns a CORRECTION, not a REPLACEMENT. When you\n"
        "remove residuals:\n"
        "  - Random init of attention/MLP wipes out the input signal completely\n"
        "    before training has done anything useful.\n"
        "  - Deep stacks become untrainable — each layer destroys what the\n"
        "    previous layer learned to express.\n"
        "  - LayerNorm pre-attention can't compensate; it can normalize but it\n"
        "    can't restore the lost signal.\n"
        "\n"
        "This is the single biggest architectural reason transformers go deep\n"
        "in the first place. Without residual connections, deeper = worse.\n",
        encoding="utf-8",
    )
    print(f"\nLog written to {log.resolve()}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Outputs

break_it_log.txt
# Project 5 BREAK IT — remove residual connections

uniform baseline      : 3.8712
baseline (residual)   : train=1.5735  val=2.7224
broken (no residual)  : train=2.8488  val=2.8053

Lesson: each residual connection lets the original signal flow through
unmodified. The sublayer learns a CORRECTION, not a REPLACEMENT. When you
remove residuals:
  - Random init of attention/MLP wipes out the input signal completely
    before training has done anything useful.
  - Deep stacks become untrainable — each layer destroys what the
    previous layer learned to express.
  - LayerNorm pre-attention can't compensate; it can normalize but it
    can't restore the lost signal.

This is the single biggest architectural reason transformers go deep
in the first place. Without residual connections, deeper = worse.
loss_curve.png
loss_curve.png
run_log.txt
# Project 5 run log
corpus_chars     : 1635
vocab_size       : 48
block_size       : 32
d_model          : 64
n_heads          : 4
n_layers         : 2
steps            : 200
n_parameters     : 102144
train_loss_final : 1.5735
val_loss_final   : 2.7224
uniform_baseline : 3.8712

# sample generations (3 samples, temperature=1.0)
--- sample 1 ---
First Citizen:
First, wene song wethatins obes ves pearopiceeresoo s tingslct ct; hinsth?
Rmest aelakSe to tilla s'sped ke kne hary ver
--- sample 2 ---
First Citizen:
No pecoure wecitay, anow e, ak.
Second Citizen Car tolititizen:

etid wicitizensons wice, Couldied, cius wol he'ty; cthe
--- sample 3 ---
First Citizen:
Ve areict; couled inelasounarespespespe Mless Nresk.
Wespeak.

Apepecond Cititizens't Coullay.
First, Citizens Can:
Nay.

Read it in the book

This page ships the code. The full derivation — why the chain rule is mechanical not magical, what the computational graph buys you, the long-form reasoning — lives in Chapter 5 of Under the Hood.

Get the book →