Attention from Scratch
Q/K/V, scaled dot-product, masking, multi-head.
Q/K/V projections, scaled dot products, causal masking, multi-head split — all written by hand with no `nn.MultiheadAttention` to paper over what's happening. Then watch the 1/√d_head scaling factor stop being optional.
The Concept
Every token embedding gets re-expressed three ways through learned projections:
- Q (queries) — what each token is looking for
- K (keys) — what each token offers as a match target
- V (values) — the information to carry forward when selected
Pairwise dot products Q @ K.T produce a (T, T) score matrix. Divide by √d_head (so dot-product magnitudes don't grow with dimension). Mask out the upper triangle (so position i can only see positions ≤ i). Softmax. Multiply by V. You now have a weighted mixture, per token, of every prior token's value vector.
Why It Matters
Attention is where critical design choices live: the 1/√d_head scaling factor, causal masking, multi-head splitting, and softmax. Each piece exists to prevent a concrete failure. The chapter argues — and this project demonstrates — that you understand each piece much better by watching what fails when you remove it than by reading about it.
The Build
build.py$python projects/04_attention-from-scratch/build.py --tiny
"""
Project 4: Attention from Scratch — complete working build.
Builds single-head causal self-attention, then multi-head attention, both with
explicit Q/K/V projections. Demonstrates the 1/sqrt(d_head) scaling and prints
per-head attention entropy as a diagnostic.
Run:
python build.py --tiny
python build.py --full
"""
from __future__ import annotations
import argparse
import math
from pathlib import Path
import torch
import torch.nn.functional as F
# A short pretend "sentence" — we don't need a real tokenizer for this project.
TOY_TOKENS = ["the", "cat", "sat", "on", "the", "mat", ".", "and"]
def make_input_embeddings(T: int, d_model: int, seed: int = 0) -> torch.Tensor:
"""Random per-position embeddings — stand-in for token embeddings."""
g = torch.Generator().manual_seed(seed)
return torch.randn(T, d_model, generator=g)
def causal_mask(T: int) -> torch.Tensor:
"""Lower-triangular mask: position i can attend to positions <= i."""
return torch.tril(torch.ones(T, T))
def single_head_attention(
x: torch.Tensor,
W_Q: torch.Tensor,
W_K: torch.Tensor,
W_V: torch.Tensor,
mask: torch.Tensor,
scale: bool = True,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Returns (output, weights) where weights are the (T, T) attention matrix."""
Q = x @ W_Q
K = x @ W_K
V = x @ W_V
scores = Q @ K.T
if scale:
d_head = Q.shape[-1]
scores = scores / math.sqrt(d_head)
scores = scores.masked_fill(mask == 0, float("-inf"))
weights = torch.softmax(scores, dim=-1)
out = weights @ V
return out, weights
def multi_head_attention(
x: torch.Tensor,
W_Q: torch.Tensor,
W_K: torch.Tensor,
W_V: torch.Tensor,
W_O: torch.Tensor,
n_heads: int,
mask: torch.Tensor,
scale: bool = True,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Returns (output, per_head_weights of shape (H, T, T))."""
T, d_model = x.shape
d_head = d_model // n_heads
Q = x @ W_Q # (T, d_model)
K = x @ W_K
V = x @ W_V
# Reshape: (T, d_model) -> (T, H, d_head) -> (H, T, d_head)
Q = Q.view(T, n_heads, d_head).transpose(0, 1)
K = K.view(T, n_heads, d_head).transpose(0, 1)
V = V.view(T, n_heads, d_head).transpose(0, 1)
scores = Q @ K.transpose(-2, -1) # (H, T, T)
if scale:
scores = scores / math.sqrt(d_head)
scores = scores.masked_fill(mask == 0, float("-inf"))
weights = torch.softmax(scores, dim=-1)
head_out = weights @ V # (H, T, d_head)
out = head_out.transpose(0, 1).contiguous().view(T, d_model)
out = out @ W_O
return out, weights
def attention_entropy(weights: torch.Tensor) -> torch.Tensor:
"""For each row of `weights` (a probability vector), compute -sum(p*log(p))."""
# Add tiny eps to avoid log(0); zeroed positions in causal mask contribute 0 anyway.
log_w = torch.log(weights.clamp_min(1e-12))
return -(weights * log_w).sum(dim=-1)
def init_projections(
d_model: int, n_heads: int, seed: int = 0
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
g = torch.Generator().manual_seed(seed)
scale = 1.0 / math.sqrt(d_model)
W_Q = torch.randn(d_model, d_model, generator=g) * scale
W_K = torch.randn(d_model, d_model, generator=g) * scale
W_V = torch.randn(d_model, d_model, generator=g) * scale
W_O = torch.randn(d_model, d_model, generator=g) * scale
return W_Q, W_K, W_V, W_O
def write_heatmap(weights: torch.Tensor, tokens: list[str], path: Path, title: str) -> None:
"""Plot per-head attention heatmaps (H, T, T) into a grid."""
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
except ImportError:
return
if weights.ndim == 2:
weights = weights.unsqueeze(0)
H = weights.shape[0]
cols = min(H, 4)
rows = (H + cols - 1) // cols
fig, axes = plt.subplots(rows, cols, figsize=(3 * cols, 3 * rows), squeeze=False)
for h in range(H):
ax = axes[h // cols][h % cols]
im = ax.imshow(weights[h].numpy(), cmap="viridis", aspect="auto", vmin=0, vmax=1)
ax.set_title(f"head {h}")
ax.set_xticks(range(len(tokens)))
ax.set_xticklabels(tokens, rotation=45, fontsize=8)
ax.set_yticks(range(len(tokens)))
ax.set_yticklabels(tokens, fontsize=8)
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
for h in range(H, rows * cols):
axes[h // cols][h % cols].axis("off")
fig.suptitle(title)
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("--d-model", type=int, default=32)
parser.add_argument("--n-heads", type=int, default=4)
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.full:
args.d_model = 64
args.n_heads = 8
args.output_dir.mkdir(parents=True, exist_ok=True)
tokens = TOY_TOKENS
T = len(tokens)
d_model = args.d_model
d_head = d_model // args.n_heads
print(f"Tokens: {tokens}")
print(f"T={T} d_model={d_model} n_heads={args.n_heads} d_head={d_head}")
x = make_input_embeddings(T, d_model, seed=args.seed)
mask = causal_mask(T)
W_Q, W_K, W_V, W_O = init_projections(d_model, args.n_heads, seed=args.seed)
# === Single head — show the shapes ===
print("\n=== Single-head attention ===")
out1, weights1 = single_head_attention(x, W_Q, W_K, W_V, mask, scale=True)
print(f" Q,K,V shape: ({T}, {d_model})")
print(f" scores (post-mask, post-softmax) shape: {tuple(weights1.shape)}")
print(f" output shape: {tuple(out1.shape)}")
print(f" attention matrix (one head):\n{weights1.numpy().round(3)}")
# === Multi-head ===
print("\n=== Multi-head attention ===")
out_mh, weights_mh = multi_head_attention(x, W_Q, W_K, W_V, W_O, args.n_heads, mask, scale=True)
print(f" multi-head output shape: {tuple(out_mh.shape)}")
print(f" weights shape (H, T, T): {tuple(weights_mh.shape)}")
# Per-head entropy
entropy = attention_entropy(weights_mh) # (H, T)
mean_entropy_per_head = entropy.mean(dim=-1)
max_entropy_possible = math.log(T) # uniform over T positions
print("\nPer-head mean entropy (lower = more concentrated):")
for h, e in enumerate(mean_entropy_per_head.tolist()):
bar = "#" * int(20 * e / max_entropy_possible)
print(f" head {h}: {e:.3f} {bar:<20s} (max possible: {max_entropy_possible:.3f})")
# === Outputs ===
write_heatmap(
weights_mh,
tokens,
args.output_dir / "attention_heatmaps.png",
title=f"Multi-head attention (T={T}, H={args.n_heads}, scaled)",
)
log = args.output_dir / "run_log.txt"
lines = [
"# Project 4 run log",
f"tokens : {tokens}",
f"T : {T}",
f"d_model : {d_model}",
f"n_heads : {args.n_heads}",
f"d_head : {d_head}",
"",
"# single-head attention weights (causal mask + scaled dot product)",
f"{weights1.numpy().round(3)}",
"",
f"# per-head mean entropy (max possible = log(T) = {max_entropy_possible:.3f})",
]
for h, e in enumerate(mean_entropy_per_head.tolist()):
lines.append(f" head {h}: {e:.4f}")
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
9 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 02Produce Queries Keys And Valuesstep_02_produce-queries-keys-and-values.py
"""
Project 4: Step 2 — Produce queries, keys, and values
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.
"""
Q = x @ W_Q
K = x @ W_K
V = x @ W_V
Step 03Compute Raw Attention Scoresstep_03_compute-raw-attention-scores.py
"""
Project 4: Step 3 — Compute raw attention scores
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.
"""
scores = Q @ K.T
Step 04Scale The Scoresstep_04_scale-the-scores.py
"""
Project 4: Step 4 — Scale the scores
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.
"""
scores = (Q @ K.T) / math.sqrt(d_head)
Step 05Turn Scores Into Attention Weights With Softmaxstep_05_turn-scores-into-attention-weights-with-softmax.py
"""
Project 4: Step 5 — Turn scores into attention weights with softmax
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.
"""
weights = torch.softmax(scores, dim=-1)
Step 06Mix The Valuesstep_06_mix-the-values.py
"""
Project 4: Step 6 — Mix the values
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.
"""
out = weights @ V
Step 08Add Causal Maskingstep_08_add-causal-masking.py
"""
Project 4: Step 8 — Add causal masking
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.
"""
mask = torch.tril(torch.ones(T, T))
scores = scores.masked_fill(mask == 0, float('-inf'))
Step 11Minimal Multi Head Implementationstep_11_minimal-multi-head-implementation.py
"""
Project 4: Step 11 — Minimal multi-head implementation
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.
"""
# x: (T, d_model)
Q = x @ W_Q
K = x @ W_K
V = x @ W_V
# reshape to (H, T, d_head)
Q = Q.view(T, H, d_head).transpose(0, 1)
K = K.view(T, H, d_head).transpose(0, 1)
V = V.view(T, H, d_head).transpose(0, 1)
scores = (Q @ K.transpose(-2, -1)) / math.sqrt(d_head) # (H, T, T)
scores = scores.masked_fill(mask == 0, float('-inf'))
weights = torch.softmax(scores, dim=-1)
head_out = weights @ V # (H, T, d_head)
# back to (T, d_model)
out = head_out.transpose(0, 1).contiguous().view(T, d_model)
Step 12Add The Output Projectionstep_12_add-the-output-projection.py
"""
Project 4: Step 12 — Add the output projection
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.
"""
final = out @ W_O
Step 14Measure Per Head Attention Entropystep_14_measure-per-head-attention-entropy.py
"""
Project 4: Step 14 — Measure per-head attention entropy
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.
"""
entropy = -(weights * (weights.clamp_min(1e-9).log())).sum(dim=-1) # (H, T)
mean_entropy_per_head = entropy.mean(dim=-1) # (H,)
Break It
break_it.pyRun the same attention without the scaling factor:
Max possible entropy per row (uniform over 8 positions): 2.079
d_head = 16 for first two rows, d_head = 64 for third
mode min mean max
----------------------------------------------------------------------
scaled (d_head=16) 0.974 1.078 1.179
unscaled (d_head=16) 0.244 0.417 0.626
unscaled, d_head=64 (catastrophic) 0.184 0.208 0.258
Without scaling, entropy collapses by >5× at d_head=64. A representative row from the broken model:
[0.000 0.000 0.9925 0.000 0.000 0.0002 0.0073 0.000]
99.25% of the probability is on one position. That's softmax saturation. The gradient through a saturated softmax is vanishingly small for the suppressed positions, so the model cannot learn to attend somewhere else. Training would look fine for a few steps then plateau — exactly the symptom that the book's author describes spending hours mistaking for a learning-rate problem.
Lesson: the dot product Q·K of two d-dimensional vectors has variance that scales with d. Without 1/√d_head, score magnitudes grow with embedding dimension, softmax saturates, gradients vanish for the unselected positions, and the model gets stuck. The 1/√d_head factor is not cosmetic. It is structural.
$python projects/04_attention-from-scratch/break_it.py --tiny
"""
Project 4: BREAK IT — remove the 1/sqrt(d_head) scaling.
When raw scores get large in magnitude, softmax saturates: one entry per row
gets almost all the probability, the rest get almost none. Attention entropy
collapses, and gradients through the softmax become tiny. Training would
look fine for the first few steps then stop learning.
Run:
python break_it.py --tiny
"""
from __future__ import annotations
import argparse
import math
from pathlib import Path
import torch
from build import (
TOY_TOKENS,
attention_entropy,
causal_mask,
init_projections,
make_input_embeddings,
multi_head_attention,
)
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("--d-model", type=int, default=64)
parser.add_argument("--n-heads", type=int, default=4)
parser.add_argument("--seed", type=int, default=0)
parser.add_argument(
"--output-dir",
type=Path,
default=Path(__file__).parent / "outputs",
)
args = parser.parse_args()
args.output_dir.mkdir(parents=True, exist_ok=True)
tokens = TOY_TOKENS
T = len(tokens)
d_model = args.d_model
d_head = d_model // args.n_heads
x = make_input_embeddings(T, d_model, seed=args.seed)
mask = causal_mask(T)
W_Q, W_K, W_V, W_O = init_projections(d_model, args.n_heads, seed=args.seed)
# === Scaled (baseline) ===
_, w_scaled = multi_head_attention(x, W_Q, W_K, W_V, W_O, args.n_heads, mask, scale=True)
ent_scaled = attention_entropy(w_scaled).mean(dim=-1)
# === Unscaled (broken) ===
_, w_unscaled = multi_head_attention(x, W_Q, W_K, W_V, W_O, args.n_heads, mask, scale=False)
ent_unscaled = attention_entropy(w_unscaled).mean(dim=-1)
# === Even worse: bigger d_head + unscaled ===
big_d = 256
big_x = make_input_embeddings(T, big_d, seed=args.seed)
big_W_Q, big_W_K, big_W_V, big_W_O = init_projections(big_d, args.n_heads, seed=args.seed)
_, w_big_unscaled = multi_head_attention(
big_x, big_W_Q, big_W_K, big_W_V, big_W_O, args.n_heads, mask, scale=False
)
ent_big_unscaled = attention_entropy(w_big_unscaled).mean(dim=-1)
max_ent = math.log(T)
print(f"Max possible entropy per row (uniform over {T} positions): {max_ent:.3f}")
print(f"d_head = {d_head} for first two rows, d_head = {big_d // args.n_heads} for third\n")
print(f"{'mode':40s} {'min':>6s} {'mean':>6s} {'max':>6s}")
print("-" * 70)
def row(label: str, e: torch.Tensor) -> None:
print(
f"{label:40s} {e.min().item():>6.3f} {e.mean().item():>6.3f} {e.max().item():>6.3f}"
)
row(f"scaled (d_head={d_head})", ent_scaled)
row(f"unscaled (d_head={d_head})", ent_unscaled)
row(f"unscaled, d_head={big_d // args.n_heads} (catastrophic)", ent_big_unscaled)
print("\nHead 0 attention row for position 7 of the unscaled, large-d_head model:")
print(f" {w_big_unscaled[0, 7].numpy().round(4)}")
print("(Note: probability is nearly all on one position — softmax saturated.)")
log = args.output_dir / "break_it_log.txt"
log.write_text(
f"# Project 4 BREAK IT — remove 1/sqrt(d_head) scaling\n\n"
f"max possible entropy: {max_ent:.3f} (uniform over T={T})\n\n"
f"scaled, d_head={d_head} mean_entropy={ent_scaled.mean().item():.4f}\n"
f"unscaled, d_head={d_head} mean_entropy={ent_unscaled.mean().item():.4f}\n"
f"unscaled, d_head={big_d // args.n_heads} "
f"mean_entropy={ent_big_unscaled.mean().item():.4f}\n"
"\n"
"Lesson: dot products of d-dimensional vectors grow with d. Unscaled scores\n"
"saturate softmax, collapsing each row to one-hot. Gradients through that\n"
"saturated softmax are vanishingly small for the suppressed positions, so\n"
"the model can't learn to attend to the right place. The 1/sqrt(d_head)\n"
"factor keeps scores in a useful magnitude range. It is NOT cosmetic.\n",
encoding="utf-8",
)
print(f"\nLog written to {log.resolve()}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Outputs

# Project 4 BREAK IT — remove 1/sqrt(d_head) scaling
max possible entropy: 2.079 (uniform over T=8)
scaled, d_head=16 mean_entropy=1.0783
unscaled, d_head=16 mean_entropy=0.4171
unscaled, d_head=64 mean_entropy=0.2079
Lesson: dot products of d-dimensional vectors grow with d. Unscaled scores
saturate softmax, collapsing each row to one-hot. Gradients through that
saturated softmax are vanishingly small for the suppressed positions, so
the model can't learn to attend to the right place. The 1/sqrt(d_head)
factor keeps scores in a useful magnitude range. It is NOT cosmetic.# Project 4 run log
tokens : ['the', 'cat', 'sat', 'on', 'the', 'mat', '.', 'and']
T : 8
d_model : 32
n_heads : 4
d_head : 8
# single-head attention weights (causal mask + scaled dot product)
[[1. 0. 0. 0. 0. 0. 0. 0. ]
[0.547 0.453 0. 0. 0. 0. 0. 0. ]
[0.091 0.057 0.853 0. 0. 0. 0. 0. ]
[0.171 0.155 0.645 0.028 0. 0. 0. 0. ]
[0.07 0.11 0.035 0.715 0.069 0. 0. 0. ]
[0.449 0.037 0.061 0.198 0.183 0.072 0. 0. ]
[0.052 0.113 0.217 0.029 0.223 0.233 0.132 0. ]
[0.023 0.097 0.244 0.049 0.061 0.175 0.257 0.094]]
# per-head mean entropy (max possible = log(T) = 2.079)
head 0: 1.0474
head 1: 1.0450
head 2: 1.0431
head 3: 1.1271Read 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 4 of Under the Hood.
Get the book →