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.
🎯 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.
- 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
1 · The tokenizer
Char-level. Simplest possible.
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.
In code, PyTorch handles it in one line:
logits, loss = model(x, targets=y) # from S040's forwardwhere 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
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'] = lrLinear 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:
- 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.
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
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).
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).
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.
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.autocastboilerplate. 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
Nparams, train on~20Ntokens (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:
- Chinchilla paper (Hoffmann et al., 2022) — the token-budget formula.
- Muennighoff et al., "Scaling Data-Constrained Language Models" (2023) — why over-training is now standard.
- Karpathy's llm.c training log — same loss-curve intuitions at GPT-2 scale.
"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."
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.
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.
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.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?
- 1At 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
- 2A softmax over near-identical logits gives a near-uniform distribution: every one of the
Vtokens receives probability close to1/V.forced by · softmax of a constant vector is exactly uniform, and small perturbations give something very close to it - 3Cross-entropy is the negative log of the probability the model assigned to the correct token. With uniform probabilities that is
-ln(1/V), which isln(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 - 4So 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
- 5Therefore 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 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.
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 aredata[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.
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.
Your validation loss has stopped improving while training loss keeps falling. What do you actually change?
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
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).