The Mathematics of Large Language Models

Step 4 — A Causal Self-Attention Head

Released with Lecture 4 · Starts from: Step 3 (or solutions/step-03.ipynb) · Solution: notebook · read online

Goal

Implement one head of causal self-attention — the mathematical object at the center of the course — as a standalone, thoroughly tested module. No training this week; the deliverables are correctness proofs executed as assertions and pictures of attention matrices on real text. (In Step 5 this head gets multiplied, wrapped, and stacked into the full GPT.)

Notation follows Lecture 4 and Phuong–Hutter.

On paper first

  1. Write out, with all dimensions labeled, the map

    Xsoftmax ⁣(mask(XWQ(XWK)dk))XWVX \mapsto \operatorname{softmax}\!\Bigl(\operatorname{mask}\bigl(\tfrac{XW_Q (XW_K)^\top}{\sqrt{d_k}}\bigr)\Bigr)\, X W_V

    for XRT×dX \in \mathbb{R}^{T\times d}, WQ,WKRd×dkW_Q, W_K \in \mathbb{R}^{d\times d_k}, WVRd×dvW_V \in \mathbb{R}^{d\times d_v}. Which pairs of tokens does entry AijA_{ij} of the attention matrix couple, and what does row ii of the output equal, as an expectation?

  2. Prove the two properties you will turn into unit tests:

    • Rows of AA are in the simplex (nonnegative, sum to 1), so output row ii is a convex combination of value vectors — attention cannot leave the convex hull of {vj}\{v_j\}.
    • Causality: with the mask sending j>ij > i scores to -\infty, output row ii is a function of x1,,xix_1,\dots,x_i only. State this as: (outi)/xj=0\partial(\text{out}_i)/\partial x_j = 0 for j>ij > i.
  3. Recompute the variance calculation from lecture (Remark 2.6): for q,kq, k with iid mean-0, variance-1 entries, Var(qk)=dk\operatorname{Var}(q\cdot k) = d_k — hence the dk\sqrt{d_k}.

From proofs to executable assertions

This step isolates one causal head so that its structural properties can be tested before training a full transformer. This is a useful scientific order: first establish what the implementation must satisfy for every parameter value, then examine the patterns produced by particular parameters.

A minibatch of size BB adds a leading coordinate to every shape:

objectbatched shape
input XXB×T×dB\times T\times d
Q,KQ,KB×T×dkB\times T\times d_k
VVB×T×dvB\times T\times d_v
scores and weightsB×T×TB\times T\times T
outputB×T×dvB\times T\times d_v

Matrix multiplication acts on the final two coordinates and preserves the batch coordinate. Before running the implementation, writing these shapes beside every line catches most transposition mistakes.

Each main test below is a theorem of Lecture 4 specialized to finite arrays.

assertionmathematical source
all weights are nonnegative and rows sum to oneProposition 3.1
the strict upper triangle is zeroDefinition 4.1
earlier outputs survive a future perturbationProposition 4.3
future input-gradient blocks are zeroProposition 4.3
raw score scale grows like dk\sqrt{d_k}Remark 2.6
row-permuted unmasked inputs give row-permuted outputsTheorem 7.1

Some equalities are structural and some are numerical. A stored causal mask can make the upper triangle exactly zero. Floating-point row sums are usually only close to one. A perturbation test should compare outputs that execute the same deterministic arithmetic on the same earlier data; gradient tests should allow for the numerical tolerance appropriate to the chosen data type.

A good unit test is a small proof with numbers substituted. Proposition 4.3 says that a future variable is absent from an earlier output formula. The perturbation test checks the resulting function equality; the gradient test checks its differential consequence. Agreement between the two makes an indexing accident much less likely.

Tasks

4.1 The head

import torch, torch.nn as nn        # import with alias (as)
import torch.nn.functional as F

class Head(nn.Module):              # class definition with inheritance (subclass of nn.Module)
    def __init__(self, d, d_head, T_max):
        super().__init__()          # super(): call the parent class's constructor
        self.key   = nn.Linear(d, d_head, bias=False)
        self.query = nn.Linear(d, d_head, bias=False)
        self.value = nn.Linear(d, d_head, bias=False)
        # causal mask, precomputed; `register_buffer` = tensor state that isn't a parameter
        self.register_buffer('tril', torch.tril(torch.ones(T_max, T_max)))

    def forward(self, x):                       # x: (B, T, d)
        B, T, d = x.shape                       # tuple unpacking
        q, k, v = self.query(x), self.key(x), self.value(x)     # (B, T, d_head)
        scores = q @ k.transpose(-2, -1) / k.shape[-1]**0.5     # @ = matrix multiply; ** = power; -1 = last index
        scores = scores.masked_fill(self.tril[:T, :T] == 0, float('-inf'))  # boolean mask (==); float('-inf')
        A = F.softmax(scores, dim=-1)                           # (B, T, T)
        self.A = A.detach()                                     # stash for visualization
        return A @ v                                            # (B, T, d_head)
Python background: import and aliases (import ... as)

import module brings a library into your program; you then reach its contents with a dot, module.thing. import module as m does the same under a shorter name of your choosing, and from module import name pulls one name in directly. torch.nn as nn and torch.nn.functional as F are the universal PyTorch conventions — worth recognizing on sight.

import math                    # use it with a dot
print(math.sqrt(16))
import math as m               # the same module, shorter name
print(m.pi)
from math import sqrt, pi      # or import specific names directly
print(sqrt(2) * pi)
Python background: inheritance and super()

class Dog(Animal): makes Dog a subclass of Animal: it inherits all of Animal’s methods, may add its own, and may override ones it wants to change. Inside __init__, super().__init__(...) runs the parent class’s constructor so the parent’s setup still happens. Head(nn.Module) works the same way: inheriting from nn.Module is what gives your class parameter tracking, and the super().__init__() call is what switches that machinery on — forget it and PyTorch will complain.

class Animal:
    def __init__(self, name):
        self.name = name
    def speak(self):
        return self.name + " makes a sound"

class Dog(Animal):                 # Dog inherits from Animal
    def __init__(self, name, trick):
        super().__init__(name)     # run the parent's constructor first
        self.trick = trick
    def speak(self):               # override the inherited method
        return self.name + " says woof"

d = Dog("Rex", "sit")
print(d.speak())
print(d.name, d.trick)             # name came from Animal's constructor
print(Animal("Cat").speak())
PyTorch background: nn.Module, parameters, and buffers

nn.Module is PyTorch’s base class for model pieces, and inheriting from it (previous foldout) buys three things. Registration: any submodule or parameter assigned to self is tracked automatically, so model.parameters() can hand an optimizer every trainable tensor — nn.Linear (a learnable matrix, with optional bias), nn.Embedding (a table of learned vectors, indexed by token id), and containers like nn.ModuleList (Step 5) all participate. Buffers: register_buffer stores tensor state that is not trained — the causal mask here — so it still travels with the model between devices and into checkpoints, but receives no gradient. Calling: head(x) runs your forward(x); you never call forward directly. Modules also carry a train/eval flag, toggled by model.train() / model.eval(), which changes the behaviour of layers like dropout — Step 6 uses it. (No live box — PyTorch doesn’t run in the browser; comments show outputs.)

lin = nn.Linear(32, 16, bias=False)  # a learnable 16x32 matrix
print(sum(p.numel() for p in lin.parameters()))   # 512 trainable numbers
emb = nn.Embedding(65, 32)           # 65 learned vectors of length 32
head = Head(32, 16, T_max=8)
print(len(list(head.parameters())))  # 3: the query, key, value matrices
print(head.tril.requires_grad)       # False — a buffer, not a parameter
Python background: the @ (matrix multiply) and ** (power) operators

** is “to the power of” — with a fractional exponent it takes roots, so d ** 0.5 is d\sqrt d. @ is matrix multiplication (rows times columns). It is a different operation from *, which multiplies elementwise — confusing the two is a classic silent bug, so run both below and compare.

import numpy as np
print(2 ** 10)
print(16 ** 0.5)             # fractional power: a square root
A = np.array([[1., 2.],
              [3., 4.]])
B = np.array([[1., 0.],
              [0., 2.]])
print(A @ B)                 # matrix product: rows of A times columns of B
print(A * B)                 # elementwise — a different animal entirely

In the head, q @ k.transpose(-2, -1) is a whole batch of matrix products at once: @ multiplies over the last two axes and carries any leading batch axes along.

Python background: boolean masks and float('-inf')

Comparisons on arrays act elementwise and produce arrays of booleans — a mask. Indexing or assigning through a mask touches exactly the positions where it is True. float('-inf') is minus infinity, and the reason it appears before a softmax is arithmetic: e=0e^{-\infty} = 0, so masked positions get exactly zero probability.

import numpy as np
x = np.array([3., -1., 2., -7.])
print(x == 2.)                 # elementwise comparison: a boolean array
print(x > 0)
y = x.copy()
y[x > 0] = 0.                  # assign wherever the mask is True
print(y)
z = x.copy()
z[x == 2.] = float('-inf')     # the masking idiom from the head
print(z)
print(np.exp(float('-inf')))   # e^(-inf) = 0: why -inf, not some big number

masked_fill(self.tril[:T, :T] == 0, float('-inf')) is this exact move: build a boolean mask from a comparison, then overwrite the True positions.

Batched matrix multiplication (@ over the last two dims of a 3-tensor) is doing all the work; write out what q @ k.transpose(-2,-1) computes entrywise before running anything.

A note on the line self.A = A.detach(): storing an attention matrix for plotting should not keep an entire backpropagation graph alive, so the head stashes a gradient-detached copy for visualization. Detaching the diagnostic copy does not alter the returned attention output; detaching the matrix used in the returned output would incorrectly cut the training gradient.

4.2 Tests — your proofs, executed

With random x = torch.randn(4, 8, 32) and a Head(32, 16, T_max=8):

  1. Simplex: A nonnegative; A.sum(dim=-1) all ones; upper triangle of every A[b] exactly zero.
  2. Causality by perturbation: run the head, change x[:, 5, :] arbitrarily, rerun; assert outputs at positions 0–4 are bitwise unchanged, positions 5–7 changed.
  3. Causality by gradient: out[0, 3].sum().backward(); x.grad[0, j] is zero for all j>3j > 3 and (generically) nonzero for j3j \le 3. This is test 2’s derivative form — and a free lesson in what .backward() on a non-scalar slice means.
  4. Scale check: print scores.std() with and without the dk\sqrt{d_k}, at d_head ∈ {16, 64, 256}. Watch softmax saturate to one-hot rows without it, exactly per the variance lemma.

4.3 Attention on real text — the pictures

Attention is only interesting when XX carries content and position, so build the minimal context: token embedding plus learned positional embedding (Lecture 4’s equivariance theorem is why the positional term must be there — without it, this week’s head literally cannot know token order):

tok_emb = nn.Embedding(V, 32)
pos_emb = nn.Embedding(64, 32)
idx = torch.tensor(encode(text[:64]))[None, :]          # indexing with None adds an axis; (1, 64) real Shakespeare
x = tok_emb(idx) + pos_emb(torch.arange(64))[None, :]
head = Head(32, 16, T_max=64)
out = head(x)
plt.imshow(head.A[0], cmap='Blues'); plt.colorbar()     # semicolon: two statements on one line
Python background: adding an axis with None (and semicolons)

Indexing with None inserts a new axis of length 1 at that position: a vector of shape (64,) becomes a one-row matrix of shape (1, 64) via v[None, :], or a column of shape (64, 1) via v[:, None]. Here it manufactures the batch axis a (B, T, d) model expects when you have just one sequence. (A semicolon merely puts two statements on one line — common in notebooks for plotting, best avoided elsewhere.)

import numpy as np
v = np.array([1., 2., 3.])
print(v.shape)
print(v[None, :].shape)        # new leading axis: one "batch" of size 1
print(v[:, None].shape)        # new trailing axis: a column
a = 1; b = 2                   # two statements, one line
print(a + b)

The new size-1 axis is also the standard way to set up broadcasting (Step 1’s foldout): a (64, 1) column against a (64,) row broadcasts into a full (64, 64) matrix.

Untrained weights already produce structure (why is the first column heavy? think about what row 1’s simplex constraint forces, and how the mask compounds it). Now the real experiment: train nothing, but plot A for 5 different random seeds, and describe the family of patterns — diagonal-hugging, column-collapse, near-uniform. In Step 6 you’ll plot these same pictures after training and watch actual algorithms (previous token heads, induction-like patterns) appear in this space.

4.4 (Bridge) Attention as smoothing

One experiment tying to the kernel view from lecture: replace q, k with positions onlyscores[i,j] = -(i-j)**2 / (2*s**2), masked — and plot what the head computes for several bandwidths s. This is Nadaraya–Watson smoothing along the sequence, the WQ=WK=0W_Q{=}W_K{=}0 degenerate case of attention; content-dependence is the only thing attention adds. (5 lines; do plot it.)

This experiment supplies a controlled comparison for the pictures of 4.3. Its bandwidth is known, so a narrow diagonal or a broad running average has a mathematical explanation (Lecture 4, Example 8.4, and the heatmaps in its §8.2). Trained heatmaps can later be compared with these baselines.

Checkpoints

If you’re stuck

Going further (optional)

What this head changes from Step 3

The Step 3 neural nn-gram compresses a fixed concatenated context with one MLP. Its first-layer parameter count grows with the chosen context length. The Step 4 head uses shared projections at every position, and its parameter count is independent of the current TT. Its attention matrix then selects a different convex combination for every input and query position.

This does not make one head a language model. It has no multi-head output projection, residual connection, normalization, position-wise MLP, or unembedding to vocabulary logits. Lecture 5 assembles those pieces. Step 4’s goal is narrower and foundational: make the data-dependent communication operator correct before multiplying and stacking it.

Catch-up

Run solutions/step-03.ipynb for the data pipeline and encode; this step needs only Lecture 4 and reuses no model code from Step 3 — a good re-entry point if you’ve fallen behind.