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 filesEach 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.pyThe 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
# 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.
# 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 →