Search Tech Journey

Find topics, journeys and posts

back to blog
mlintermediate 110m read

DL S041 · Training Your Decoder on Tiny Shakespeare

Train the S040 GPT to overfit Tiny Shakespeare and generate plausible pseudo-Bard. Char-level tokenizer, dataloader, cross-entropy loss, sampling with temperature and top-k.

🧠SoftwareM07 · Transformers from scratch· Session 041 of 130 110 min

🎯 Train the S040 model on 1MB of Shakespeare, get val loss to ~1.48, and generate text that reads like Elizabethan-era pastiche.

Series: Deep Learning & LLMs From Scratch — 80 sessions · Session 41 / 80 · Module M07 · ~2 hours

The story

Yesterday you built a GPT. Today you turn it on and watch it learn.

Tiny Shakespeare — 1.1 MB of the Bard's plays, ~40k lines — is the "hello world" of language modelling. Small enough to overfit on a laptop, complex enough to produce something that fools a casual reader for half a paragraph. The training run takes 15 minutes on an M2 Mac or 3 minutes on an A100. By the end you'll have generated text like this:

DUKE OF YORK: Now, by my faith, thou art a knave and knavery. Bear him thence to my heart, and let him be A gentleman, that hath a great occasion.

That's not Shakespeare, but it's not gibberish either. Every noun is a noun. Sentences almost parse. The characters do dialogue with speaker attribution. Nothing in this session is task-specific — we don't tell the model about verbs or characters or Shakespeare. It just predicts the next character from the previous 256. Everything else emerges.

This session covers: the char-level tokenizer, the on-the-fly dataloader (no torch.utils.data.DataLoader, just a batch sampler), the training loop with mixed precision, the eval loop for val loss, learning-rate scheduling with warmup + cosine, and sampling.

You will be able to
  • Train the S040 GPT on Tiny Shakespeare to val loss ~1.48 in under 5000 steps.
  • Write a char-level tokenizer in 5 lines.
  • Build a random-batch sampler for LM training in ~10 lines (no PyTorch Dataset needed).
  • Explain cross-entropy loss as -log probability of the true token.
  • Interpret perplexity as 'branching factor at each position'.
  • Read a loss curve and identify overfitting, underfitting, and healthy training.

Prerequisites

  • S040 — the model.
  • S018 — training loop pattern.
  • S014 — cross-entropy loss.


1 · The tokenizer

Char-level. Simplest possible.

Learning Shakespeare by rebuilding a spice rack
🌍 Real world
💻 Code world
with open('input.txt', 'r', encoding='utf-8') as f:
    text = f.read()
chars = sorted(set(text))
vocab_size = len(chars)          # ~65 for Tiny Shakespeare
 
stoi = {ch: i for i, ch in enumerate(chars)}
itos = {i: ch for i, ch in enumerate(chars)}
encode = lambda s: [stoi[c] for c in s]
decode = lambda ids: ''.join(itos[i] for i in ids)

That's it. Five lines, no library. Each character becomes an integer, each integer maps back. Vocab size 65 (letters, digits, punctuation, newline). No subword magic. Real LLMs use BPE (S044) — for our toy, char-level trains fast and generalises poorly, which is fine for teaching.

1.1 · Encode the corpus

import torch
data = torch.tensor(encode(text), dtype=torch.long)  # (~1.1M,) tensor of ints
n = int(0.9 * len(data))
train_data = data[:n]
val_data = data[n:]

Standard 90/10 split. No shuffling — you want the val split to be text the model hasn't seen. In a bigger corpus you'd hold out entire documents.


2 · The dataloader (all 8 lines of it)

Tiny Shakespeare is small enough to sit in GPU memory. So we skip PyTorch's Dataset/DataLoader and just sample random windows:

def get_batch(split, block_size, batch_size, device):
    d = train_data if split == 'train' else val_data
    ix = torch.randint(len(d) - block_size, (batch_size,))
    x = torch.stack([d[i:i+block_size]        for i in ix])
    y = torch.stack([d[i+1:i+block_size+1]    for i in ix])
    return x.to(device), y.to(device)

x is (B, T) of token IDs. y is (B, T) shifted by one — the "next token" targets. Each of the B · T positions in x gets a next-token label in y. That's B · T supervised examples per batch, which is why LM training is so data-efficient per step.


3 · Cross-entropy loss — the LM objective

For each position, the model outputs a distribution over the vocabulary. The target is the ID of the true next token. Cross-entropy asks: "How much probability did you assign to the correct answer?" and penalises assigning too little.

L=1BTb,tlogpθ(yb,txb,t)\mathcal{L} = -\frac{1}{B \cdot T} \sum_{b, t} \log p_\theta(y_{b,t} \mid x_{b, \le t})

In code, PyTorch handles it in one line:

logits, loss = model(x, targets=y)   # from S040's forward

where inside the model:

loss = F.cross_entropy(logits.view(-1, vocab_size), targets.view(-1))

The .view(-1, vocab_size) flattens (B, T, V)(B*T, V) and (B, T)(B*T,) so cross-entropy treats each position as one example. F.cross_entropy combines log-softmax and NLL loss for numerical stability.

3.1 · Perplexity — the interpretable metric

PPL=eL\text{PPL} = e^{\mathcal{L}}

Perplexity is "the effective number of tokens the model is guessing between at each position". A model with PPL = 5 is as uncertain as if it had to pick uniformly among 5 tokens. A perfect model has PPL = 1. A random model on Shakespeare has PPL = 65 (uniform over the 65-char vocab).

Our target: loss ≈ 1.48, so PPL ≈ e^1.48 ≈ 4.4. The model is picking from ~4 plausible next chars at each position on average. Not bad for 15 minutes of training on 1 MB.


4 · The training loop

import torch
from torch.nn import functional as F
 
# hyperparameters (Karpathy's nanoGPT Tiny Shakespeare config)
batch_size = 64
block_size = 256
max_iters = 5000
eval_interval = 500
eval_iters = 200
learning_rate = 3e-4
device = 'cuda' if torch.cuda.is_available() else 'cpu'
 
config = GPTConfig(
    block_size=block_size, vocab_size=vocab_size,
    n_layer=6, n_head=6, n_embd=384, dropout=0.2)
model = GPT(config).to(device)
optimizer = model.configure_optimizers(
    weight_decay=0.1, learning_rate=learning_rate, betas=(0.9, 0.95))

Now the loop:

@torch.no_grad()
def estimate_loss():
    model.eval()
    out = {}
    for split in ['train', 'val']:
        losses = torch.zeros(eval_iters)
        for k in range(eval_iters):
            X, Y = get_batch(split, block_size, batch_size, device)
            _, loss = model(X, targets=Y)
            losses[k] = loss.item()
        out[split] = losses.mean().item()
    model.train()
    return out
 
for step in range(max_iters):
    if step % eval_interval == 0:
        losses = estimate_loss()
        print(f"step {step}: train {losses['train']:.4f}, val {losses['val']:.4f}")
 
    xb, yb = get_batch('train', block_size, batch_size, device)
    _, loss = model(xb, targets=yb)
 
    optimizer.zero_grad(set_to_none=True)
    loss.backward()
    optimizer.step()

Forty lines including the imports. That is a language model trainer.

4.1 · Learning rate schedule — warmup + cosine

The above uses a fixed LR. For better results, add warmup + cosine decay:

import math
warmup_iters = 100
lr_decay_iters = max_iters
min_lr = learning_rate / 10
 
def get_lr(it):
    if it < warmup_iters:
        return learning_rate * it / warmup_iters
    if it > lr_decay_iters:
        return min_lr
    decay_ratio = (it - warmup_iters) / (lr_decay_iters - warmup_iters)
    coeff = 0.5 * (1.0 + math.cos(math.pi * decay_ratio))
    return min_lr + coeff * (learning_rate - min_lr)
 
# inside the loop, before optimizer.step():
lr = get_lr(step)
for pg in optimizer.param_groups:
    pg['lr'] = lr

Linear warmup (100 steps of ramp) prevents early-training instability. Cosine decay (to 10% of peak by end) squeezes out the last bit of loss reduction. Standard for transformer training.


5 · Reading the loss curve

Expected output:

step 0: train 4.1815, val 4.1801 ln(vocab_size=65) = 4.17, random initstep 500: train 1.6832, val 1.7411step 1000: train 1.5233, val 1.6521step 2000: train 1.4098, val 1.5688step 3000: train 1.3391, val 1.5215step 4000: train 1.2807, val 1.5008step 5000: train 1.2358, val 1.4835
What to look for in the numbers
  • Initial loss ≈ ln(vocab_size). If it isn't, your init is broken. Random guess is uniform → ln(V).
  • First 500 steps: rapid drop (from ~4.2 to ~1.7). Model is learning trivial regularities (character frequencies, common bigrams).
  • Steps 500-2000: slower drop. Model learns word-like patterns.
  • Steps 2000+: slow grind. Train continues to drop, val plateaus or crosses over. Model is overfitting — dropout of 0.2 keeps it modest.
  • Val > train by ~0.25 by the end: healthy generalization gap for this dataset size.

If val starts rising while train drops → severe overfit; add more dropout or stop early. If both plateau at ~2.0 → your LR is too small, or block_size too small, or n_layer too small.


6 · Generate!

context = torch.zeros((1, 1), dtype=torch.long, device=device)   # start with token 0 (\n)
out = model.generate(context, max_new_tokens=500, temperature=0.8, top_k=40)
print(decode(out[0].tolist()))

Sample output (yours will differ — random sampling):

 
BUCKINGHAM: Come, cousin; I'll to your uncle Warwick.
Farewell, sweet lord, and let us pray to God
That we may fight and win the crown at last.
 
QUEEN MARGARET: O, God's own name, thou art a proud usurper!
Thy father slew my father, and thou dost
Continue the war upon our righteous king.

Not real Shakespeare. But: line breaks in the right places, speaker attribution, capitalisation, punctuation, iambic-ish rhythm, invented but plausible dialogue. From a model with 10 million parameters trained for 15 minutes on 1 MB of text. That's what next-token prediction with attention gets you.

Try it

Re-generate at temperature=0.2 and temperature=1.3. At 0.2 the model will pick the same few characters/names repeatedly (loops of "LORD LORD LORD..."). At 1.3 you'll get invented pseudo-English words and weirder syntax. Understanding this knob is the difference between debugging generation quality and randomly turning it.


7 · Pitfalls

War story Loss starts way above ln(vocab_size)

If step 0's loss is 6.8 instead of ~4.2 (for vocab 65), your embedding init is wrong (probably std set too high) or your logits are being multiplied somewhere they shouldn't be. Sanity check: at step 0 the model is basically random; expected loss is ~ln(vocab_size).

War story Loss NaN after 200 steps

Almost always: LR too high, or missing gradient clipping, or fp16 overflow. Add torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) before optimizer.step(). If it persists, drop LR by 3× or switch to bfloat16 (which has more range than fp16).

War story Val loss identical to train loss

Your data split is broken. Either the val set is a copy of part of the train set, or your get_batch('val', ...) is silently reading from train_data (typo). Print train_data.shape and val_data.shape at the top of the run.

War story Generation loops the same 20 chars

Temperature too low. At T = 0 this is deterministic and often gets stuck. Try T = 0.8 and top_k = 40 — this is the "safe" default that produces varied-but-coherent output.


8 · What we're NOT doing (and why)

  • No wandb / tensorboard logging. Simple print. Real projects use tools; teaching uses print.
  • No checkpointing. 15-minute training run, we can just re-run. See S055 for how to do this properly.
  • No distributed training. One GPU. See M09 for multi-GPU.
  • No bf16 mixed precision. Would give 2× speedup on modern hardware; adds ~10 lines of torch.autocast boilerplate. Optional for now.

Real nanoGPT train.py has all these. Read it after finishing this session to see the production versions.


9 · Modern-2025 twist — Tiny Shakespeare in the age of frontier models

Why still train char-level on Tiny Shakespeare in 2026, when you can download Llama 3.1 8B and it already "knows" Shakespeare far better than any toy model ever will? Because the shape of the training curve, the failure modes, and the intuitions transfer directly. Every scaling-law paper of the last three years — Chinchilla (Hoffmann et al., 2022), the Kaplan revisit (2024), the Muennighoff "overtrained" paper — uses the same recipe as your Tiny Shakespeare run, just scaled up 10 million×.

What has changed at frontier scale as of 2026 that you should be aware of:

  • Chinchilla-optimal token budget: for a model with N params, train on ~20N tokens (Hoffmann 2022). Your Tiny Shakespeare setup: N=10M, tokens≈200M (5000 steps × 64 × 256) — you're actually training at ~20× Chinchilla, which is why the model overfits so happily.
  • Overtraining is now standard. Llama 3 8B trained on 15T tokens, ~1875× Chinchilla-optimal. The Muennighoff 2023 paper showed inference-cost dominance flips the compute-optimal frontier: over-train smaller models to get better inference-cost-per-quality.
  • Muon optimizer (Jordan et al., 2024) is the first optimizer to seriously challenge AdamW for LM pretraining. modded-nanoGPT's speedrun uses it. Try swapping it in and you'll see the loss curve drop ~15% faster.
  • Data mixing (S045–S046) matters more than architecture. DeepSeek-V3 attributes most of its edge over Llama 3 to data curation.
  • 1.58-bit BitNet (Ma et al., Microsoft, Feb 2024) trains a Tiny-Shakespeare-scale model with ternary weights and matches FP16 loss. If you have a spare hour, port your S040 model to BitNet and watch it work.

Further reading:


Common misconception
✗ What most people think

"Training loss going down means the model is learning. As long as the curve keeps falling, training is working — the only thing to watch is that it does not plateau."

✓ What is actually true

A falling training loss is compatible with several quite different situations, and the curve alone cannot distinguish them. On a small corpus like Tiny Shakespeare, the loss will keep falling long after the model has stopped learning anything general and started memorising the training text — and memorisation looks identical to learning on the training curve, because it is reducing training loss, just by storing rather than generalising. The only instrument that separates them is a held-out split evaluated on the same axis. When validation loss turns upward while training loss continues down, the gap between the two curves is the amount of memorisation, and no amount of staring at the training curve alone will reveal it.

Why the myth is so sticky

Because on genuinely large corpora the belief is true, and that is where most modern intuition is formed. When a model sees each token roughly once, it cannot memorise, so training loss and validation loss track each other closely and watching one really is enough. Tiny Shakespeare is the opposite regime — a small corpus cycled many times — and it is precisely the regime where the habit fails. The belief is also protected by the fact that samples keep looking better as the model memorises: it starts producing fluent, correctly-formatted, Shakespeare-shaped text, because reproducing training text is the fastest route to a good-looking sample. Both instruments you are watching agree, and both are misleading you.

Prove it to yourself

Plot both curves on the same axes and then check the samples against the source:

# Split before you train, and evaluate both every N steps.
n = int(0.9 * len(data))
train_data, val_data = data[:n], data[n:]

for step in range(max_steps):
    ...
    if step % eval_interval == 0:
        print(step, estimate_loss(train_data), estimate_loss(val_data))

# The diagnostic that settles it:
sample = generate(model, n_tokens=300)
for k in (20, 40, 80):
    spans = [sample[i:i+k] for i in range(len(sample) - k)]
    hits = sum(s in raw_text for s in spans)
    print(k, hits / len(spans))
# Long verbatim spans copied from the source = memorisation,
# regardless of how good the training curve looks.
From first principles
Start with the question

Why is the initial loss of a character-level language model approximately ln(vocab_size), and why is that number the single most useful sanity check in the entire training script?

  1. 1
    At initialisation the output projection is small random weights applied to essentially unstructured activations, so the logits across the vocabulary are near-identical and carry no information about the input.
    forced by · random initialisation is deliberately symmetric — nothing has yet broken the tie between output classes
  2. 2
    A softmax over near-identical logits gives a near-uniform distribution: every one of the V tokens receives probability close to 1/V.
    forced by · softmax of a constant vector is exactly uniform, and small perturbations give something very close to it
  3. 3
    Cross-entropy is the negative log of the probability the model assigned to the correct token. With uniform probabilities that is -ln(1/V), which is ln(V) — independent of what the correct token actually was.
    forced by · the loss depends only on the probability assigned to the target, and under uniformity every target gets the same one
  4. 4
    So the expected initial loss is a number you can compute in your head from the vocabulary alone: about 4.2 for a 65-character vocabulary, about 10.8 for a 50k-token vocabulary. It requires no training run to predict.
    forced by · ln(V) has no dependence on the data, the architecture, or the optimiser — only on the number of output classes
  5. 5
    Therefore any deviation at step zero localises a bug to a specific place. Much higher means the logits are not near-uniform — bad initialisation scale, or an output layer producing large values. Much lower means the model already knows something it should not — the classic cause being a target-alignment bug where the label is visible in the input.
    forced by · at step zero the model has learned nothing, so any information in the loss came from the plumbing, not from training
⇒ Therefore

Therefore ln(V) is a free, exact, architecture-independent assertion about the first training step, and checking it costs one line.

Note what this predicts beyond the initial value, and use it: since the loss is a negative log-probability, exp(loss) is the effective number of tokens the model is choosing between — the perplexity. A loss of 1.5 on a character model means it has narrowed 65 characters down to roughly four-and-a-half plausible ones at each position. That converts an abstract number into something you can reason about, and it lets you predict what the samples should look like before you generate any: a model at perplexity 4 on characters cannot possibly be producing coherent long-range structure, and if your samples look better than that, check for a data leak.

Mental modelTwo curves and a gap

Every training run is two curves on one plot. Both start at ln(V) and fall together while the model learns structure that genuinely transfers. At some point they separate: the training curve continues down, the validation curve flattens and then rises. The moment of separation is the moment the model runs out of general structure to learn and starts storing specifics.

The gap between the curves is the memorisation; the level of the validation curve is the actual capability. Everything you can do — more data, less capacity, dropout, early stopping — moves one or the other, and knowing which one you are trying to move is the whole skill.

  • Step-zero loss must be about ln(V). Higher means an initialisation or scale bug; lower means a leak between input and target.
  • exp(loss) is the effective branching factor. Use it to translate a loss number into an expectation about sample quality before you generate.
  • Separation of the curves is not failure — it is the signal to stop, or to add data. A run that never separates was either too short or too small.
  • The batch construction is where the subtle bugs live: inputs are data[i : i+T] and targets are data[i+1 : i+T+1]. An off-by-one in the wrong direction leaks the answer and shows up as a suspiciously low initial loss.
🔔 Fires when you see

Fire this model the moment you see: a loss curve presented without a validation split · samples that look far better than the loss implies · a training run described as "still improving" with only one curve shown · an initial loss that is not what you predicted · a decision about whether to train longer.

The tradeoff

Your validation loss has stopped improving while training loss keeps falling. What do you actually change?

More data
+ you gain the only intervention that raises the ceiling rather than just delaying the overfit; more distinct examples means memorisation is a worse strategy relative to generalisation, so the separation point moves later and the validation floor moves down
− you pay often unavailable, and new data of lower quality or different distribution can hurt more than the extra volume helps; and it changes your evaluation baseline, so previous runs stop being comparable
pick when you can obtain more data from the same distribution — always try this first, because the others only manage the symptom
Less capacity or more regularisation
+ you gain directly reduces the model's ability to store specifics, so the curves stay together longer; dropout and weight decay are cheap to tune and need no new data; a smaller model is also faster to train and serve
− you pay lowers the ceiling too — you are trading away the ability to represent genuine structure alongside the ability to memorise, and there is no way to remove only one; heavy regularisation can leave the model underfitting the patterns you actually wanted
pick when the dataset is fixed and small, and the gap between curves is large — meaning capacity is clearly in excess of what the data can support
Stop early
+ you gain free, immediate, and guaranteed to give you the best validation performance the run ever reached; no retraining and no hyperparameter search
− you pay it does not improve anything, it only avoids making things worse; and it leaves capacity unused, which means you paid for a model larger than you could exploit
pick when you need a usable checkpoint now and the run is already past its validation minimum — a deployment decision, not a research one
What a senior engineer actually does

The ordering is data, then capacity, then early stopping — and the reason is that only the first raises the ceiling. The other two are ways of managing a mismatch between model size and dataset size, and if you find yourself reaching for them repeatedly, the real answer is that your model is too large for your corpus.

The deeper habit to build: before touching anything, ask which curve you are trying to move. If the validation floor is too high, regularisation will not help you — it will make it worse. If the gap is too wide, more training will not help you. Nearly every wasted training run comes from applying a gap-closing intervention to a ceiling problem, or the reverse.


Retention scaffold

Quick recall · click to reveal
★ = stretch question

One-line summary (write it in your own words): _______________________________________________

Spaced review: re-read §5 (loss curve interpretation) and §9 (Chinchilla ratios) in 24 hours. On day 7, re-run the training with a different hyperparameter and predict the loss curve before running.

Next session (S042): we just trained a model. Now let's make INFERENCE 100× faster with a KV cache. Naive generation re-computes attention over all past tokens at every step; the KV cache is the fix. Every LLM serving stack in the world runs on this trick.

Sticky note (keep on your desk): loss(step 0) ≈ ln(vocab). PPL = exp(loss). Warmup + cosine. Sample at T≈0.8, top-k=40. Overfit if tokens/params ≫ 20 (Chinchilla).


Previous: ← DL S040 · Next: DL S042 →