Skip to content

GPT decoder-only, causal LM loss, GPT-2 weight loading

ModuleL6.1 · build · Python · Pass 5 · 3 to 4 h, plus your graded tests (rung R5)
You buildpython/tinyllm/obj/gpt.py: GPTConfig, Block, GPT (hidden, forward), clm_loss, load_hf_gpt2, fit_gpt, save_gpt, load_gpt; and your own oracle tests in python/tests/l6-1-gpt/
Contractcourse/contracts/py/tinyllm/obj/gpt.pyi
Testscourse/tests/L6.1/test_gpt.py (what they check: section 4); the oracle is a random tiny Hugging Face GPT2LMHeadModel; the learning test compares with the reference’s bar over 5 seeds
NeedsL5.2 causal_mask · L5.3 attention · L5.4 LearnedPE · L0.1 · L0.2 · L0.3 cross_entropy · L0.4 layers · L0.5 train_step · L0.6 safetensors, TokenStream · M10.3 AdamW · M10.4 cosine_with_warmup · M07.3 normal_init, scaled_residual_std · M06.3 PCG32 (or --ref-deps)
Used byL6.5 sequence, token, and reward heads · L6.7 the zoo’s gpt rows · later L6.6 LoRA targets, L8.2’s first KV cache
MilestoneMS-L6
Optional depthRadford et al., “Language Models are Unsupervised Multitask Learners” (GPT-2, 2019), section 2.3; Karpathy, nanoGPT model.py
  • A GPT is L5.5’s decoder without cross-attention: one causal stack, so every position predicts the next token and one forward pass trains on all of them (test_logits_are_causal).
  • The causal LM loss shifts by one: logits at position tt against the token at t+1t + 1; the last position has no target, and a padded target counts for nothing (test_hand_example_clm_loss, test_forward_targets_equals_clm_loss).
  • GPT-2’s block is pre-LN with a tanh-approximated GELU and a final ln_f; the output layer is the token table (test_block_matches_the_formula, test_head_is_tied_to_the_embedding).
  • Hugging Face’s GPT-2 stores projections as Conv1D, weight [in, out]: every weight is transposed on load, and c_attn is cut into three column blocks q, k, v (test_golden_hf_gpt2).
  • The two projections that write into the residual stream start at 0.02/2L0.02/\sqrt{2L} (test_gpt2_init).
Terminal window
ol start L6.1 # stubs gpt.py; prints your test path and rung (R5)
ol tests L6.1 # the course tests
ol check L6.1 # course tests and the mutation grade of your tests
ol diff L6.1 # after passing: your code against the reference

Your encoder-decoder (L5.5) maps one sequence to another, but the system you are building serves a language model: a model that continues text. Every token of a corpus is a training target for the prefix before it, and nothing needs to be encoded separately. GPT keeps exactly the half of L5.5 that does this: the decoder, minus its cross-attention. This chapter builds it to GPT-2’s exact layout, so that a checkpoint trained by Hugging Face loads into your code and gives the same logits. That loader is the bridge the rest of the course walks over: L7.9 loads SmolLM2 the same way, and L8.2 puts the first KV cache on this model.

SymbolMeaningType / shape
t0…tT−1t_0 \dots t_{T-1}the token ids of one windowint[T]
V,d,L,HV, d, L, Hvocabulary, width, layers, headsint
nctxn_{ctx}the longest window (rows of wpe)int
Wte∈RV×dW_{te} \in \mathbb{R}^{V \times d}token table wtefloat32[V, d]
Wpe∈Rnctx×dW_{pe} \in \mathbb{R}^{n_{ctx} \times d}learned positions wpe (L5.4)float32[n_ctx, d]
ht(ℓ)h^{(\ell)}_tthe residual stream at layer ℓ\ell, position tt[B, T, d]
zt∈RVz_t \in \mathbb{R}^Vlogits at position tt[B, T, V]
GELUtanh⁡(u)\text{GELU}_{\tanh}(u)12u(1+tanh⁡(2/π(u+0.044715u3)))\tfrac12 u (1 + \tanh(\sqrt{2/\pi}(u + 0.044715 u^3)))elementwise

P(t0…tT−1)=∏t=1T−1P(tt∣t0…tt−1).P(t_0 \dots t_{T-1}) = \prod_{t=1}^{T-1} P(t_t \mid t_0 \dots t_{t-1}) .

With the causal mask (L5.2), position tt attends to positions 0…t0 \dots t only, so its output is a function of the prefix and can be trained to predict tt+1t_{t+1}. One forward pass computes all TT predictions at once; without the mask position tt could read tt+1t_{t+1} and the loss would teach copying.

L=1∣K∣∑t∈K−log⁡softmax⁡(zt)tt+1,K={t<T−1:tt+1 is a real token}.\mathcal{L} = \frac{1}{|K|} \sum_{t \in K} -\log \operatorname{softmax}(z_t)_{t_{t+1}}, \qquad K = \{ t < T - 1 : t_{t+1} \text{ is a real token} \}.

In code: logits [:, :-1] against ids [:, 1:], with -100 where the target is padding (L0.3 ignores it). The shift happens once: either clm_loss shifts a full window, or the data already gives (inputs, targets) pairs shifted by one, as L0.6’s TokenStream does, and forward(x, y) uses them as they are.

h←h+Attn(LN1(h)),h←h+Wproj GELUtanh⁡(Wfc LN2(h)),h \leftarrow h + \text{Attn}(\text{LN}_1(h)), \qquad h \leftarrow h + W_{proj}\,\text{GELU}_{\tanh}(W_{fc}\,\text{LN}_2(h)),

pre-LN (L5.5’s norm="pre"), then LNf\text{LN}_f after the last block and z=LNf(h)Wte⊤z = \text{LN}_f(h) W_{te}^\top: the output layer is the token table (tied). GPT-2 used the tanh approximation of GELU; the exact form differs by up to about 10−310^{-3} at ∣u∣≈2|u| \approx 2, enough to fail a golden comparison.

Init. N(0,0.022)\mathcal{N}(0, 0.02^2) for the tables and every Linear weight; biases 0; LayerNorms (1,0)(1, 0). Each block adds two outputs to the residual stream (attn.out_proj, mlp_proj), so after LL blocks the stream holds 2L2L such terms; their weights start at 0.02/2L0.02/\sqrt{2L} (M07.3’s scaled_residual_std) to keep its variance from growing with depth.

Hugging Face’s GPT2LMHeadModel names and shapes:

HF key (after transformer.)ShapeYours
wte.weight, wpe.weight[V, d], [n_ctx, d]same names
h.i.ln_1.*, h.i.ln_2.*, ln_f.*[d]same names
h.i.attn.c_attn.weight, .bias[d, 3d], [3d]attn.q_proj, k_proj, v_proj: columns 0..d0..d, d..2dd..2d, 2d..3d2d..3d, each transposed
h.i.attn.c_proj.weight[d, d]attn.out_proj.weight transposed
h.i.mlp.c_fc.weight[d, 4d]mlp_fc.weight transposed
h.i.mlp.c_proj.weight[4d, d]mlp_proj.weight transposed

Conv1D computes xW+bx W + b with WW [in, out]; Linear computes xW⊤+bx W^\top + b with WW [out, in]. The square c_proj of attention loads either way without a shape error, which is why the golden test compares values, not shapes. Old checkpoints also carry the buffers attn.bias and attn.masked_bias (a stored causal mask): they are not weights, and the loader ignores them. lm_head.weight is the same tensor as wte.weight.

V=2V = 2, ids (0,1,1)(0, 1, 1), logits rows z0=(0,ln⁡3)z_0 = (0, \ln 3), z1=(0,0)z_1 = (0, 0), z2=(5,−5)z_2 = (5, -5).

  • Position 0 predicts t1=1t_1 = 1: softmax⁡(0,ln⁡3)=(1/4,3/4)\operatorname{softmax}(0, \ln 3) = (1/4, 3/4), loss −ln⁡0.75=0.287682-\ln 0.75 = 0.287682.
  • Position 1 predicts t2=1t_2 = 1: (1/2,1/2)(1/2, 1/2), loss ln⁡2=0.693147\ln 2 = 0.693147.
  • Position 2 has no next token: dropped.

L=(0.287682+0.693147)/2=0.490415\mathcal{L} = (0.287682 + 0.693147)/2 = 0.490415. With the third token marked as padding, position 1’s target is padding: L=0.287682\mathcal{L} = 0.287682, and the gradient on z0z_0 is softmax⁡(z0)−onehot(1)=(0.25,−0.25)\operatorname{softmax}(z_0) - \text{onehot}(1) = (0.25, -0.25), zero on the other rows. This is test_hand_example_clm_loss.

@dataclass
class GPTConfig: vocab: int; n_ctx: int; d_model: int; n_heads: int; n_layers: int; d_ff: int
dropout: float = 0.0; ln_eps: float = 1e-5; tie: bool = True
class GPT(Module):
def hidden(self, ids) -> Tensor # ln_f output [B, T, d]
def forward(self, ids, targets=None) -> tuple[Tensor, Optional[Tensor]]
def clm_loss(logits: Tensor, ids, mask=None) -> Tensor
def load_hf_gpt2(model: GPT, sd: Mapping[str, Any]) -> None
def fit_gpt(model, stream, steps, lr, warmup=0, weight_decay=0.1, clip=1.0) -> list[float]
def save_gpt(model, dir, tokenizer="bytes") / load_gpt(dir)
TestKINDChecksWhy it matters downstream
test_hand_example_clm_lossunitsection 3: 0.490415, the padded 0.287682, the gradientyou and the test agree on the shift
test_golden_hf_gpt2goldenHF logits, loss, and the gradient of every HF tensor through the loaderreal GPT-2 checkpoints load (L7.9 does the same for SmolLM2)
test_load_hf_gpt2_keysboundarybare GPT2Model keys and old buffers load; a missing key is named; a wrong config is a shape errorcheckpoints from many sources
test_logits_are_causalpropertychanging tokens after tt leaves logits 0..t0..t bitwise equaltraining on all positions is honest
test_block_matches_the_formuladifferentialone block in float64 numpy with large weightstanh GELU, pre-LN, both residuals
test_forward_targets_equals_clm_losspropertyforward(x[:, :-1], x[:, 1:]) equals clm_lossthe shift happens once
test_head_is_tied_to_the_embeddingunitno lm_head; logits =hWte⊤= h W_{te}^\top; tie=False adds onehalf the output parameters
test_gpt2_initstatisticalresidual projections at 0.02/2L0.02/\sqrt{2L}, others 0.02, seededtraining starts stable at depth
test_save_load_roundtripunitthe model directory loads back exactlythe zoo (L6.7)
test_validationboundarywindows past n_ctx, one-token loss, float idserrors, not silent garbage
test_learns_byte_storieslearning150 AdamW updates of a 2-layer byte GPT reach the reference’s held-out lossthe model learns language

Make up a GPT-2 state dict in Hugging Face’s layout yourself (random [d, 3d] c_attn, [in, out] Conv1D weights, perturbed LayerNorms), write the HF forward in float64 numpy from that dict (so your oracle never sees your transposes), load it with load_hf_gpt2, and compare logits. Add causality, the section 3 loss, bare keys, the tied head’s gradient, and the residual init. ol check L6.1 requires 0.80 with the pitfall faults killed.

PitfallSymptomCaught by
1. a Conv1D weight loaded without the transposec_proj is square: no error, wrong logitstest_golden_hf_gpt2 (mutant s01)
2. c_attn split into interleaved columns instead of three blocksq, k, v mixedtest_golden_hf_gpt2 (mutant s02)
3. no causal maskloss near zero in training, nonsense when generatingtest_logits_are_causal, test_forward_targets_equals_clm_loss (mutant s03)
4. no shift (each position predicts its own token), or the padding mask applied to inputsthe model learns to copy; padded targets countedtest_hand_example_clm_loss (mutants s04, s09)
5. exact GELUoff by about 10−310^{-3}: HF parity failstest_block_matches_the_formula (mutant s05)
6. ln_f forgottenlogits too large; HF disagreestest_golden_hf_gpt2 (mutant s06)
7. residual projections at the plain 0.02the stream’s variance grows with depthtest_gpt2_init (mutant s08)
position embeddings never addedthe model is a bag of tokenstest_golden_hf_gpt2 (mutant s07)
no gradient through the tied headwte learns only from the input sidetest_golden_hf_gpt2 (mutant s10)
DirectionModuleHow it uses this
BackL5.2causal_mask for every block
BackL5.3the attention of every block
BackL5.4LearnedPE is wpe
BackL0.1Tensor
BackL0.2the tanh GELU, matmul for the tied head
BackL0.3cross_entropy for both losses
BackL0.4Linear, Embedding, LayerNorm
BackL0.5train_step inside fit_gpt
BackL0.6safetensors model directory, TokenStream batches
BackM10.3AdamW
BackM10.4cosine_with_warmup
BackM07.3normal_init, scaled_residual_std
BackM06.3PCG32 init stream
ForwardL6.5sequence, token, and reward heads on GPT.hidden
ForwardL6.6LoRA adapters on the attention projections
ForwardL6.7the zoo’s gpt rows (bits per byte)
ForwardL8.2the first KV cache and generate
Your pieceProduction equivalentWhat it addsWhere to look
GPTnanoGPT, HF GPT2LMHeadModelflash attention, weight decay groups, torch.compilekarpathy/nanoGPT/model.py; transformers/models/gpt2/modeling_gpt2.py
load_hf_gpt2HF from_pretrainedsharded safetensors, dtype casting, key renaming hookstransformers/modeling_utils.py
learned wpeRoPE in Llama-style modelsno length cap from the table (L7.3)Part 7
fit_gptllm.c, Megatronmixed precision, gradient accumulation, data parallelismL11.1; karpathy/llm.c