Project: character-level language model
From bigram counts to an attention-equipped seq2seq, all from scratch
You now know every ingredient of a neural language model. Building one from counts upward — following Karpathy's makemore — makes the next-token objective, embeddings, perplexity and sampling concrete, and prepares you to build GPT itself in 7.8 Build GPT from scratch (nanoGPT walkthrough). Adding a tiny attention-based seq2seq model closes Phase 6 by implementing the equations of 6.7 Attention is born with your own hands.
You'll be able to
- Build a bigram model from counts and reproduce it as a trained one-layer network with identical loss
- Implement an MLP character language model with learned embeddings and evaluate it by perplexity
- Sample from a language model with temperature and interpret the results
- Implement a small attention-based seq2seq model with greedy and beam-search decoding
Key ideas
- A bigram model from counts and a one-layer network trained with NLL are the same model: the one-hot input selects a row of W, so W learns log-counts (≈2.45 nats on names).
- L2 regularization on the weights behaves like add-k smoothing on the counts.
- An MLP over embedded previous characters shares statistics across contexts and beats bigrams (dev NLL ≈ 2.1–2.2).
Show 4 more key ideas
- Initial loss should be ≈ log(V); a much larger value means the initialization makes confident wrong guesses.
- Temperature divides logits before softmax: < 1 sharpens (safe, repetitive), > 1 flattens (creative, noisy).
- A GRU seq2seq with dot-product attention and teacher forcing solves date normalization nearly perfectly; attention plots show where the decoder looks.
- Keep results comparable: same splits, same tokenization, perplexity per character.
Suggested path through this lesson
What you will build
Four stages, each a few dozen lines of PyTorch, each runnable on a laptop CPU:
- Bigram model from counts on a list of ~32k names (the makemore dataset) — an n-gram model (6.2 Classical NLP and n-gram language models).
- The same bigram model as a neural network — a one-hot input, one linear layer, softmax, trained with negative log-likelihood. Its loss should match the count model's.
- An MLP language model in the style of Bengio et al. (2003): embed the previous characters, concatenate, hidden layer, softmax over the alphabet (6.3 Word embeddings, 4.1 From perceptron to MLP; universal approximation).
- A tiny attention seq2seq model that normalizes dates ("March 5, 2024" → "2024-03-05") or reverses strings, with greedy and beam decoding (6.6 Seq2seq, encoder–decoder and beam search, 6.7 Attention is born).
Follow Karpathy's first three makemore videos for stages 1–3; the lessons below tell you what to look for and what numbers to expect.
Stage 1 — counts
Add a boundary token "." to each name, count every character bigram into a matrix , and normalize rows to get . Evaluate with the average negative log-likelihood over all bigrams in the data,
Expected: NLL ≈ 2.45 nats (perplexity ≈ 11.6 — the model is as uncertain as a uniform choice among ~12 of the 27 characters). Add 1 to every count before normalizing (add-one smoothing) so unseen bigrams do not give infinite loss (6.2 Classical NLP and n-gram language models). Sample names with torch.multinomial; they will look vaguely name-like and mostly silly.
Stage 2 — the same model, learned
One-hot encode the previous character (), multiply by a weight matrix , take a softmax over the result, and minimize the NLL with gradient descent. Because a one-hot vector selects a row of , the logits for "previous character " are just row : the network is a table of log-counts. After training, the loss converges to the same ≈2.45 — and now you have a procedure that scales to models for which counting is impossible. Add an penalty on and notice it behaves exactly like add- smoothing (3.6 Regularization and generalization).
import torch, torch.nn.functional as F
xs, ys = ... # tensors of previous-char and next-char indices
W = torch.randn(27, 27, requires_grad=True)
for step in range(200):
logits = F.one_hot(xs, 27).float() @ W # (M, 27)
loss = F.cross_entropy(logits, ys) + 0.01 * (W**2).mean()
W.grad = None; loss.backward()
W.data -= 50 * W.grad # plain gradient descent
print(loss.item()) # -> about 2.45
Stage 3 — an MLP with embeddings
Use a context of the previous characters, each mapped through an embedding table (start with ), concatenated into a -vector, passed through a hidden layer (, 200 units) and a softmax over 27 characters. Split the names 80/10/10 into train/dev/test and use mini-batches (4.5 Optimizers and learning-rate schedules). Watch for: the initial loss should be about if the output layer is initialized small (a much larger initial loss means confidently wrong initial predictions — fix the initialization, 4.6 Initialization and normalization); dead or saturated units; and the learning-rate sweep trick (increase the LR exponentially over one epoch and plot the loss). Expected dev NLL ≈ 2.1–2.2 — a real improvement over bigrams, because the model shares statistics across contexts through the embedding. Plot the 2-D embedding (set for the plot): vowels cluster together. Then sample at temperatures 0.7, 1.0 and 1.3 by dividing the logits by before the softmax (8.5 Decoding and sampling) and describe the difference.
Stage 4 — attention seq2seq
Generate synthetic pairs: random dates rendered in several human formats as source, ISO format as target (or simply strings and their reverses). Build a one-layer GRU encoder (6.5 LSTM and GRU) and a GRU decoder with Luong dot-product attention over encoder states (6.7 Attention is born), trained with teacher forcing (6.6 Seq2seq, encoder–decoder and beam search). Decode greedily, then implement beam search with and length normalization. Expected: near-perfect accuracy on dates after a few minutes of training; a plotted attention matrix shows the decoder jumping to the month name when it emits the month digits. Measure how often beam search changes the output compared with greedy (on this easy task, rarely — explain why).
Deliverables
- A repository with four scripts or one notebook, fixed seeds, and a results table: bigram-counts NLL, bigram-NN NLL, MLP dev/test NLL and perplexity, seq2seq accuracy (greedy vs beam).
- Twenty sampled names per model and five attention heatmaps.
- A short write-up: why the learned bigram matches the counts, what the embedding learned, and what the attention plots show.
Stretch goals
Implement makemore parts 4–5 (manual backprop through the whole MLP, then a WaveNet-style hierarchical model); replace the GRU seq2seq with a two-layer bidirectional encoder; or swap the character alphabet for a 512-merge BPE vocabulary you train yourself (6.1 Text to numbers: tokenization) and compare perplexities per character so they remain comparable.
Where this shows up later
- The same data pipeline, loss and sampling loop scale directly to GPT in 7.8 Build GPT from scratch (nanoGPT walkthrough); initialization and activation-statistics debugging is 4.6 Initialization and normalization, 4.8 Training dynamics and debugging.
- Perplexity and held-out evaluation — 6.2 Classical NLP and n-gram language models, 8.7 Evaluating LLMs; temperature sampling — 8.5 Decoding and sampling; attention heatmaps vs real interpretability — 8.11 Interpretability: what is happening inside.
Common pitfalls (4)
- Evaluating on the training set; always report dev and a single final test number.
- Forgetting the boundary token, so names never end (or never start).
- Comparing perplexity of a character model with a BPE model per token — normalize per character.
- Decoding the seq2seq model with teacher forcing by accident and reporting an inflated accuracy.
Recap: all 7 key points
- A bigram model from counts and a one-layer network trained with NLL are the same model: the one-hot input selects a row of W, so W learns log-counts (≈2.45 nats on names).
- L2 regularization on the weights behaves like add-k smoothing on the counts.
- An MLP over embedded previous characters shares statistics across contexts and beats bigrams (dev NLL ≈ 2.1–2.2).
- Initial loss should be ≈ log(V); a much larger value means the initialization makes confident wrong guesses.
- Temperature divides logits before softmax: < 1 sharpens (safe, repetitive), > 1 flattens (creative, noisy).
- A GRU seq2seq with dot-product attention and teacher forcing solves date normalization nearly perfectly; attention plots show where the decoder looks.
- Keep results comparable: same splits, same tokenization, perplexity per character.
Watch
Stages 1–2: bigram counts, then the identical neural network; the broadcasting and loss discussions are gold.
Read
Exercises
- Stages 1–2
Build the count-based and learned bigram models and show their losses agree to two decimals. Expected: ≈ 2.45 nats both; samples are name-like gibberish.
- Stage 3
Train the MLP, report dev/test NLL and perplexity, plot the 2-D embeddings and 20 samples at three temperatures. Expected: dev NLL ≈ 2.1–2.2; vowels cluster in the embedding; temperature 0.7 gives common-looking names, 1.3 gives unpronounceable ones.
- Stage 4
Train the attention seq2seq on synthetic date normalization; report accuracy for greedy and beam (B = 3) decoding and include five attention heatmaps. Expected: > 98% accuracy; heatmaps show month-name attention when emitting month digits.
Quiz
Flashcards
These cards are now in your spaced-repetition queue. Review all due cards →