Electric Sheaves

Step 5 — Assembling the GPT

Released with Lecture 5 · ~3 hours · Starts from: Step 4 (or solutions/step-04.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

Assemble Step 4’s head into the complete GPT architecture: multi-head attention, MLP blocks, residual connections, LayerNorm, and the embedding/unembedding maps. At the end you have the full model class — untrained, but verified for shapes, causality, and an exact parameter count you derive on paper first. Step 6 trains it.

On paper first

  1. The architecture as one formula. With LL blocks, write the model as the composition (pre-norm convention, as in GPT-2):

    X(0)=TokEmb(x)+PosEmb,X()+=MHA(LN1()(X()))X()+=MLP(LN2()(X()))Z=LNf(X(L))WU.X^{(0)} = \text{TokEmb}(x) + \text{PosEmb},\qquad \begin{aligned} X^{(\ell)} &{}\mathrel{+}= \text{MHA}(\text{LN}_1^{(\ell)}(X^{(\ell)}))\\ X^{(\ell)} &{}\mathrel{+}= \text{MLP}(\text{LN}_2^{(\ell)}(X^{(\ell)})) \end{aligned} \qquad Z = \text{LN}_f(X^{(L)})\,W_U.

    The += structure is the residual stream: every sublayer reads from and writes small updates into a running state in RT×d\mathbb{R}^{T\times d}. (Lecture 5 develops why this matters for both optimization and interpretation.)

  2. Parameter count. Derive, term by term, the parameter count of your model as a function of (V,Tmax,d,L)(V, T_{\max}, d, L) with dff=4dd_{ff} = 4d and biases where your code has them. Show the blocks contribute 12Ld2\approx 12 L d^2 and identify exactly which matrices the 1212 counts. You will check this number against numel() to the last parameter.

  3. LayerNorm. For uRdu \in \mathbb{R}^d: LN(u)=γuuˉ1σ(u)+β\text{LN}(u) = \gamma \odot \frac{u - \bar u\,\mathbf 1}{\sigma(u)} + \beta. Show the normalization part is: orthogonal projection onto 1\mathbf 1^\perp, then radial projection onto the sphere of radius d\sqrt d. So the stream is repeatedly pinned to a (d2)(d{-}2)-sphere — a strong geometric constraint to keep in mind whenever you picture “vectors in the residual stream.”

Tasks

5.1 Multi-head attention

HH heads with dhead=d/Hd_{\text{head}} = d/H, run in parallel, concatenated, then mixed by an output projection:

class MultiHeadAttention(nn.Module):
    def __init__(self, d, H, T_max):
        super().__init__()
        self.heads = nn.ModuleList(Head(d, d // H, T_max) for _ in range(H))  # generator expression; // = integer division
        self.proj = nn.Linear(d, d)

    def forward(self, x):
        return self.proj(torch.cat([h(x) for h in self.heads], dim=-1))  # list comprehension
Python background: integer division (//)

/ always produces a float, even when the division is exact; // divides and rounds down to an integer. Sizes and indices must be integers, which is why the per-head dimension is d // H and not d / Hnn.Linear(d, 32.0) would be an error. (The _ in for _ in range(H) is the same throwaway name from Step 2’s tuple foldout: the loop runs H times and never uses the counter.)

print(7 / 2)          # ordinary division: always a float
print(7 // 2)         # integer division: rounds down
print(128 / 4)        # exact — but still a float!
print(128 // 4)       # an int, usable as a size or index
for _ in range(3):    # run 3 times, counter unused
    print("another head")

(This loop-over-heads version is deliberately transparent; the “going further” refactor batches it into one tensor op.)

5.2 The MLP block

Position-wise: hidden width 4d4d, GELU nonlinearity, back to dd. Two nn.Linears and an F.gelu; note it acts on each of the TT positions independently — all cross-position communication in the entire model happens inside attention, nowhere else.

5.3 The block and the model

class Block(nn.Module):
    def __init__(self, d, H, T_max):
        super().__init__()
        self.ln1, self.attn = nn.LayerNorm(d), MultiHeadAttention(d, H, T_max)  # parallel assignment (tuple unpacking)
        self.ln2, self.mlp  = nn.LayerNorm(d), MLP(d)

    def forward(self, x):
        x = x + self.attn(self.ln1(x))     # read, transform, write back
        x = x + self.mlp(self.ln2(x))
        return x

Then GPT(nn.Module) holding: token embedding (V, d), positional embedding (T_max, d), L blocks, final LayerNorm, and the unembedding nn.Linear(d, V, bias=False). forward(idx, targets=None) returns logits (B, T, V) and, if targets are given, F.cross_entropy on the flattened logits. Also port your Step 3 generate (crop the context to the last T_max tokens each iteration — why is cropping required for this model when it wasn’t for the MLP?).

Course configuration (matches Step 6’s training budget):

V=65, T_max=64, d=128, H=4, L=4        # just over 0.8M parameters — your baby GPT

5.4 Verification suite

  1. Parameter count: sum(p.numel() for p in model.parameters()) must equal your paper formula exactly. Chase any discrepancy to the guilty term (biases and LayerNorms are the usual suspects). Report the percentage of parameters in: embeddings, attention, MLPs.
  2. Causality, end-to-end: Step 4’s perturbation test on the full model. It must still pass — residuals, LayerNorm, and MLPs are all position-wise, so the mask remains the only cross-position gate.
  3. Initial loss: on a random batch, loss should land just above ln65=4.174\ln 65 = 4.174 (see checkpoints for the exact figure and why it is not equal). If it’s 8, your init is too hot (logits too spread — softmax collapsed); GPT-2’s fix, which you should apply: scale the output projections in each block by 1/2L1/\sqrt{2L} (Lecture 6 derives why: 2L2L writes into the stream, variance adds).
  4. A shape table: print x.shape after every stage for one forward pass — embeddings, each block, final LN, logits. This table is the architecture; keep it next to the formula from “on paper” 1.

Checkpoints

If you’re stuck

Going further (optional)

Catch-up

Run solutions/step-04.ipynb, whose Head class is this step’s only dependency; everything else is fresh construction from Lecture 5.