Electric Sheaves

Step 4 — A Causal Self-Attention Head

Released with Lecture 4 · ~3 hours · Starts from: Step 3 (or solutions/step-03.ipynb).

In what follows, there are blocks of code provided for you. It’s important that you can read the code. If you are not proficient with Python, then I propose the following workflow. For each block of code, keywords are provided for the python syntax which is being used, so you can look up python syntax and functions you don’t recognise. To help, beneath many code blocks you will find Python background foldouts: self-contained tutorials, with live Python boxes you can edit and run right on the page, for syntax that is new in this step (syntax already introduced is covered in earlier steps’ foldouts). Then I suggest that you first experiment with changing the code or printing out intermediate variables, until you have figured out what you think it does. Then point AI toward the page and step number, provide your precise explanation of what you believe each line of the code is doing, and ask for corrections. In this way you will quickly become a code reader (if not a code writer). — Kate

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 lemma from lecture: 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}.

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.

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

Checkpoints

If you’re stuck

Going further (optional)

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.