Under the Hood
Book ↗
Projects/Foundations
Project 04

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 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 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.py

Run 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

attention_heatmaps.png
attention_heatmaps.png
break_it_log.txt
# 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.
run_log.txt
# 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.1271

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 4 of Under the Hood.

Get the book →