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:


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 →