llm_from_scratch / lessons/11 · decoding & weightslesson 12 / 16

Part IV — Pretraining

Decoding & loading pretrained weights

Lesson 10 built the training loop and watched a tiny model overfit one short story — and its greedy decoder loops on the same phrase because it always grabs the single highest-scoring token. This lesson adds two knobs, temperature and top-k, that turn that rigid argmax into controllable sampling; then it does the move that makes everything before it pay off — loading OpenAI's published GPT-2 weights into the architecture we built, so we get a strong base model without paying for pretraining.

What the last step left broken
The training loop from lesson 10 leaves two things unresolved. First, generation is still greedy: at every step we take argmax of the logits, which is deterministic, so the same prompt always produces the exact same continuation — and a model that overfit its corpus falls into short repeating cycles. There is no dial for "be more adventurous." Second, and larger: our loop learned from one tiny file, and pretraining a model that is actually good costs millions of dollars of compute. We built the whole machine correctly, yet we cannot afford to fill it with good weights. Both gaps have a fix, and the second one is the reward for having built faithfully.
Linear position
Forced by: greedy decoding (lesson 10) is deterministic and repeats itself, and pretraining a genuinely capable model from scratch is unaffordable.
New idea: control the randomness of decoding — divide logits by a temperature T before softmax (small T sharpens toward greedy, large T flattens toward uniform), and restrict sampling to the top-k most likely tokens (mask the rest to −∞, renormalize, sample) to cut the long tail of garbage. Then load OpenAI's published GPT-2 weights (124M / 355M / 774M / 1558M) into our matching modules — a strong base model for free.
Forces next: this base model completes text beautifully but will not make a decision or follow a task — ask it "is this spam?" and it just continues the sentence. Lesson 12 must fine-tune it into a classifier.
The plan
Four moves. (1) See exactly why greedy argmax is deterministic and prone to loops, and why we want randomness we can dial. (2) Derive temperature scaling — dividing logits by T before softmax — and read off the coherence/diversity trade-off, with greedy recovered as T→0. (3) Add top-k (and mention top-p / nucleus): keep the k best logits, mask the rest to −∞, renormalize, sample. (4) Load OpenAI's GPT-2 weights into our architecture and explain why a faithful build makes this a drop-in. Then drive both knobs on the book's nine-token example and watch coherence trade against diversity.

1 · Greedy decoding is deterministic — and it repeats

Every generation step in lesson 08 ended the same way: the model produced a logit vector over the vocabulary, and we took its argmax — the single highest-scoring token. That is greedy decoding. It is simple and it is defensible (each step picks the locally most probable token), but it has two properties that matter here.

First, it is deterministic. The same context always yields the same logits, so it always yields the same next token — run the model on one prompt a hundred times and you get one identical continuation every time. There is no way to ask for a second, different draft. Second, a greedy path is prone to degenerate loops: once the model is confident about a short high-probability cycle ("… and the and the and the …"), argmax keeps re-entering it, because at each step the loop's next token really is the most probable one. A tiny model that overfit its training text (lesson 10) is especially vulnerable — it has memorized a few high-probability continuations and returns to them.

The fix is to replace the hard argmax with sampling: convert the logits to a probability distribution and draw a token from it, so lower-ranked tokens sometimes win. Concretely, we swap the picker:

logits = model(tokens)              # length-V scores for the next token
# greedy (old): deterministic, repetitive
next = argmax(logits)
# sampling (new): draw proportional to probability
probs = softmax(logits)
next  = sample_from(probs)          # e.g. one multinomial draw

Sampling alone already breaks the determinism. But raw sampling from softmax(logits) is a fixed amount of randomness — we cannot make it bolder or more cautious. We want a knob. That knob is temperature.

The running example (from the book)
We will use one concrete case throughout. Given the start context "every effort moves you", suppose the model emits these next-token logits over a nine-word vocabulary:
vocab = {closer:0, every:1, effort:2, forward:3, inches:4, moves:5, pizza:6, toward:7, you:8}
logits = [4.51, 0.89, −1.90, 6.75, 1.63, −1.62, −1.89, 6.28, 1.79]
The largest logit is at index 3, so argmax → "forward". Greedy always emits "forward" here. The whole lesson is about what happens when we stop always taking the top bar.

2 · Temperature scaling

Temperature scaling is a one-line change with a large effect: before the softmax, divide every logit by a positive number T called the temperature.

softmaxT(z)i = exp(zi / T) / Σj exp(zj / T)

Why does dividing the logits reshape the distribution? Softmax cares about the gaps between logits — the difference z_i − z_j sets the odds ratio e^{(z_i − z_j)} between tokens i and j. Dividing every logit by T divides every gap by T, which rescales all those odds ratios together:

On our example, this is exactly the diversity/coherence trade-off in miniature. With T = 1, the model would pick "forward" roughly 60% of the time — mostly coherent, occasionally choosing a sensible alternative like "toward". Push to T = 0.1 and "forward" wins essentially 100% of the time — indistinguishable from greedy, safe but repetitive. Push to T = 5 and the bars nearly level out: "forward" drops to about a quarter of the mass, and low-probability tokens start getting drawn. That flattening is what buys variety — and also what lets nonsense through. At T = 5 the token "pizza" gets sampled roughly 4% of the time, producing continuations like "every effort moves you pizza". Temperature does not make the model smarter; it only decides how far down the ranked list you are willing to reach.

The trap
Temperature is a divisor, so T = 0 is undefined — dividing logits by zero blows up. In code, "temperature 0" is a convention meaning "use argmax," not a literal division; implement it as a special case (if T is at or below a small floor, return the argmax one-hot) rather than dividing. The widget below guards this exactly.

3 · Top-k sampling (and a word on top-p)

Temperature reshapes the whole distribution, which means even at a modest temperature there is always a little probability mass smeared across hundreds of terrible tokens — the long tail. Sampling can, rarely, draw from that tail and derail the sentence. Top-k sampling removes the tail outright: keep only the k most likely tokens and give every other token zero probability, so a bad token cannot be selected no matter how many times you sample.

The mechanism is the same masking trick we used for the causal attention mask (lesson 06): set the unwanted logits to −∞ before the softmax. Because e^{−∞} = 0, those positions get probability exactly 0, and the softmax automatically renormalizes the surviving k tokens so they sum to 1 — no separate renormalization step needed.

topk_vals, topk_idx = top_k(logits, k)      # the k largest logits and where they are
mask = fill(logits.shape, -inf)             # everything masked off by default
mask[topk_idx] = logits[topk_idx]           # let only the top-k logits through
probs = softmax(mask / T)                    # masked → 0 prob; survivors renormalize
next  = sample_from(probs)

Take our example with k = 3. The three largest logits are "forward" (6.75), "toward" (6.28), and "closer" (4.51); every other token — including "pizza" at −1.89 — is set to −∞. Softmax over the survivors gives roughly:

[ closer 0.06, forward 0.57, toward 0.36 ]   (all others 0)

Now the model still samples — it might pick "toward" for variety — but it is structurally impossible to emit "pizza." Top-k gives you the diversity of sampling with a floor under the quality: it bounds how bad the chosen token can be. The knob k sets the width of the candidate set: k = 1 is greedy again (only the top token survives), while large k approaches plain temperature sampling. Temperature and top-k compose — a common recipe is a moderate temperature with a moderate k, flattening the head for variety while the mask deletes the tail.

Top-p / nucleus sampling
A close cousin is top-p (nucleus) sampling: instead of a fixed count k, keep the smallest set of top tokens whose cumulative probability first exceeds a threshold p (say 0.9), and sample from those. It adapts the candidate count to the shape of the distribution — few tokens when the model is confident, many when it is unsure — where top-k always keeps exactly k. The two are often combined. We build top-k here because it is the simpler mask; top-p is the same idea with a cumulative cutoff. Serving stacks add repetition penalties and more on top — see the inference cross-reference below.

4 · Loading pretrained weights — the payoff of building it faithfully

Here is the move this whole track was set up to make. Everything we built — the BPE tokenizer (01), the token + positional embeddings (02), causal multi-head attention (05–06), the pre-norm transformer block with its GELU feed-forward (07), the stack with a final LayerNorm and an LM head (08) — is the same architecture OpenAI used for GPT-2. We did not build a lookalike; we built the thing. That means we do not have to train it from scratch. OpenAI published the trained GPT-2 weights, and because our modules match theirs shape-for-shape, we can pour those weights straight into our model and inherit a capable base model — skipping the compute bill entirely.

GPT-2 was released in four sizes; all share the design, differing only in width d, number of heads h, and depth L:

nameparamsd (emb_dim)L (layers)h (heads)
GPT-2 small124M7681212
GPT-2 medium355M10242416
GPT-2 large774M12803620
GPT-2 XL1558M16004825

All four use the same vocabulary V = 50{,}257 and context length 1024. The 124M row is exactly the GPT_CONFIG_124M we have been building against, which is why it drops in cleanly: pick the config, instantiate the model, and copy the published tensors into the matching parameters.

What "loading weights" actually means

Loading is a bookkeeping exercise, not a learning one. For every parameter tensor in our model there is a corresponding tensor in the published checkpoint; you walk the model layer by layer and assign. The only real work is matching the layout: our parameter names and tensor shapes must line up with the checkpoint's, and where a convention differs — for example, a weight matrix stored transposed, or GPT-2's habit of packing the query, key, and value projections into one combined tensor that we split into three — you reshape or transpose as you copy. A single assertion protects you: check that each source shape equals the destination shape before assigning, so a silent mismatch becomes a loud error.

def load_gpt2_weights(model, params):
    # embeddings
    assign(model.pos_emb,  params["wpe"])          # (context_length, d)
    assign(model.tok_emb,  params["wte"])          # (V, d)
    for b in range(L):                              # each transformer block
        # GPT-2 packs Q,K,V into one matrix — split it into our three
        q_w, k_w, v_w = split(params[b]["attn.c_attn.weight"], 3, axis=-1)
        assign(model.blocks[b].att.W_q, transpose(q_w))
        assign(model.blocks[b].att.W_k, transpose(k_w))
        assign(model.blocks[b].att.W_v, transpose(v_w))
        assign(model.blocks[b].att.W_o, transpose(params[b]["attn.c_proj.weight"]))
        # feed-forward, layer norms, biases … one assign per tensor
        ...
    assign(model.final_norm, params["ln_f"])
    # LM head is weight-tied to the token embedding in GPT-2
    assign(model.out_head,  params["wte"])

The specific keys and transposes depend on the checkpoint format you download, but the shape of the task never changes: every tensor in, every tensor matched, one assertion per copy. Note the last line — GPT-2 ties the output head to the token-embedding matrix (the same (V, d) weights are reused to embed inputs and to produce logits), so there is nothing extra to load there. Once the copy finishes, running our forward pass produces genuine GPT-2 quality text, and with the temperature/top-k decoder from this lesson you can steer it.

Why this is the whole point
If our architecture had been an approximation — a residual missed here, a norm in the wrong place, heads shaped differently — the published weights would not fit, and we would be stuck training a toy from scratch. Because we derived every piece as a forced move and matched GPT-2 exactly, decades of someone else's compute drops in with a loop of assignments. Faithfulness is not pedantry; it is what converts "we understand a GPT" into "we have a working GPT."

5 · Steer the decoder yourself

The widget holds the book's fixed nine-token logits — no randomness in the scores. The temperature slider divides those logits by T before softmax (watch the bars sharpen below 1 and flatten above 1); the k slider masks all but the k largest logits to −∞ and lets softmax renormalize the survivors. The bars are the resulting sampling distribution. The blue bar is the argmax ("forward"); the green marker shows which token a fixed-seed sampler would draw from this exact distribution — deterministic, so the same settings always highlight the same token. The KPIs quantify the trade-off: entropy and effective #tokens (e^{entropy}, roughly "how many tokens are genuinely in play") both rise as you flatten, and the nonsense risk is the probability mass sitting on tokens outside the sensible set — the chance of a "pizza"-style slip.

Temperature & top-k sampler
Proves the trade-off: temperature and k trade coherence against diversity, and greedy is just T→0. Low T or small k → one confident bar (safe, repetitive); high T and large k → flat bars (varied, risks nonsense). Logits are fixed; only the reshaping changes.
argmax token
sampled token (seed)
entropy (bits)
effective #tokens
nonsense risk

Failure modes & checklist

Failure modes

  • Dividing by T = 0. Temperature is a divisor; zero is undefined. Signal: NaNs/Infs in the logits. Treat "T = 0" as a special-cased argmax, not a literal division.
  • Temperature too high. The distribution flattens toward uniform; sampling reaches deep into the tail and the text goes incoherent ("… moves you pizza"). Diversity you cannot use.
  • k too small (or k = 1). Collapses back to greedy — deterministic and repetitive, defeating the point of sampling.
  • Renormalizing wrong after masking. If you zero the tail after softmax without re-dividing, the probabilities no longer sum to 1. Masking to −∞ before softmax fixes this for free.
  • Assuming greedy is "correct." Argmax is just T→0; it is one point on the dial, not ground truth, and it is the one most prone to loops.

Checklist

  • Temperature divides logits before softmax; T<1 sharpens, T>1 flattens, T→0 = greedy.
  • Guard the T→0 case as argmax, never an actual divide-by-zero.
  • Top-k masks all but the k best logits to −∞, then softmax renormalizes.
  • To load GPT-2: match config (124M = our build), then copy tensor-by-tensor.
  • Assert source shape == destination shape on every copy; handle transposes and the packed QKV split.
  • Remember the LM head is weight-tied to the token embedding.

Checkpoint

Try it
Set k = 9 (no masking) and drag temperature from 1.0 down toward 0.1: watch "forward" climb toward 100% and the effective-#tokens KPI fall toward 1 — you are turning the dial back into greedy. Now push temperature to 5.0 and read the nonsense risk KPI: mass has leaked onto "pizza" and the other tail tokens. Finally, hold temperature at 5.0 but drag k down to 3 — the nonsense risk snaps to 0 because the tail is masked away, yet the distribution over {closer, forward, toward} stays varied. That is the whole recipe: temperature for how bold, top-k for how safe. Which of lesson 00's three words — accurate, trainable, steerable — do these knobs touch?

Where this points next

With sampling knobs and real GPT-2 weights, we now have something genuinely useful: a base model that completes text fluently and that we can steer for variety or safety. But notice what it still will not do. Prompt it with "Classify this message as spam or not: 'You won a prize, click here'" and it does not answer — it just continues the passage, because completing text is the only behavior it has. A base model has knowledge and fluency but no notion of "produce a decision" or "follow this instruction." The next wall is turning a completer into something that acts. Lesson 12, Classification fine-tuning — a spam detector, takes the smallest possible step: swap the LM head for a tiny class head, read the last token, and fine-tune the base model into a spam classifier.

Takeaway
Greedy decoding takes argmax of the logits every step — deterministic, and prone to repetitive loops. Temperature divides logits by T before softmax: T<1 sharpens toward greedy (recovered exactly as T→0), T>1 flattens toward uniform, trading coherence for diversity. Top-k keeps only the k most likely tokens by masking the rest to −∞ before softmax (renormalizing for free), cutting the long tail of nonsense; top-p is the adaptive-count cousin. Then the payoff: because our architecture is GPT-2's, we load OpenAI's published weights (124M–1558M) tensor-by-tensor into our modules and get a strong base model without paying for pretraining — the reward for having built every piece faithfully.

Interview prompts

Companion reads: cs336 · 18 Inference covers decoding and serving at production scale (KV cache, batching, speculative decoding); gpt_mini · 02 Pretrain is the compact tour of pretraining the base model whose weights we load here.