Neural language models
Why counting fails, and what embeddings fix.
Create a free account Try part of it here
Why it matters
A bigram model remembers one character. To remember more, a counting table needs far too many rows. In 2003, Bengio and colleagues swapped the table for a small neural network that works on learned embeddings. Every modern language model descends from that idea. In this lesson you build one. You'll see what it fixes and what it still can't do. That gap is where attention comes in.
What you'll be able to do
- Explain why counting tables can't scale to longer contexts
- Build a neural language model: embed the previous characters, then predict the next
- Show how more context lowers the loss and improves generated text
- See what the embeddings learn, and the limits that point toward attention
What it covers
- A table with too many rows
- Most rows stay empty
- Swap the table for a network
- Why this beats the table
- Worked example: one prediction by hand
- Training one on the catalog
- What it still can't do
A table with too many rows
Think of your phone's keyboard guessing the next letter as you type. The bigram model from the last lessons does this with a table. It looks at the last character. Then it reads that character's row to see what usually comes next.
One character of memory isn't much. After "th", the next letter is often "e". After a lone "h", it could be almost anything. So you'd like the model to look further back.
The trouble is the number of rows. A counting table needs one row for every possible context: every string of characters the model might be looking back at. Two letters help you count them. is the vocabulary size, the number of different characters. is the context length, how many characters the model looks back at.
In plain words: each of the slots can hold any of the characters. So you multiply by itself times.
Take a tiny alphabet with 3 letters: a, b and c. With 2 characters of context there are 3 × 3 = 9 contexts. Here they are, listed by Python.
from itertools import product letters = "abc" contexts = ["".join(p) for p in product(letters, repeat=2)] print(len(contexts), contexts) print(len(letters) ** 2)
Now scale up. The course's training text is a catalog of product descriptions. It uses 56 different characters. With 5 characters of context, that's 56 × 56 × 56 × 56 × 56. The table would need about 550 million rows. Real models use words or tokens instead of characters, and there are far more of those. Then the count becomes astronomically larger.
A table explodes with each extra word of context. A network only grows gently, because it shares what it learns between similar contexts.
V, n = 4, 3 print(V ** n)
Show the answer
64
Each of the 3 slots can hold any of the 4 characters, so it's 4 × 4 × 4 = 64. Adding one more slot would make it 256.
Most rows stay empty
A big table causes two problems.
- Most rows are never filled. The training text only shows a tiny share of all contexts. A row the model never saw has no counts, so it has nothing useful to say.
- Nothing is shared between rows. Say the table has learned what follows "warm". That tells it nothing about "wool", even though both describe materials in the same kind of sentence. Each row stands alone.
The code below measures the first problem. It splits the catalog into two parts. The first part is for training. The second part is held-out text, kept back to test the model. For each context length, the code counts the contexts seen in training. Then it checks what share of held-out contexts never appeared in training.
import requests text = requests.get("https://api.gradient.test/v1/corpus").json()["text"] docs = text.split("\n") train, val = "\n".join(docs[:270]), "\n".join(docs[270:]) V = len(set(text)) for n in [1, 3, 5, 8]: seen = {train[i:i + n] for i in range(len(train) - n)} val_contexts = [val[i:i + n] for i in range(len(val) - n)] unseen = sum(c not in seen for c in val_contexts) / len(val_contexts) print(f"{n} characters: {V ** n:>18,} possible contexts, {len(seen):>5,} seen in training, {unseen:.1%} of held-out contexts never seen")
Look at how fast the possible contexts grow with each line. The number seen in training can never be bigger than the length of the training text.
This catalog is very repetitive, so few held-out contexts are new. Ordinary text is different. There, most contexts of eight characters or more have never been seen before. So a model needs a way to generalize: to make a good guess about a new context from similar ones it has seen.
Swap the table for a network
Picture two ways to get by in a foreign city. A phrasebook only helps with the exact phrases printed in it. A person who speaks the language knows what words are like. So they can handle a sentence they've never heard. A counting table is the phrasebook. A neural language model is the speaker.
It's built on embeddings, which you met in Phase 4. Each character gets its own short list of numbers. Training adjusts those numbers. Characters that behave alike end up with similar lists.
To predict the next character, the model takes four steps.
- Look up. Take the previous few characters. Fetch each one's embedding.
- Line up. Place the embeddings side by side to make one long list. This is called concatenating them.
- Mix. Pass that list through a hidden layer, then through tanh. Tanh is the activation from Phase 4 that squashes each number into the range −1 to 1.
- Score. An output layer gives one score for every character in the vocabulary. These scores are the logits. Softmax turns them into probabilities, as in Phase 2.
print(3 * 10)
Show the answer
30
3 × 10 is 30 numbers. Lining them up places the lists end to end, which keeps the order of the characters.
Here's the same recipe as one formula. Read it from the inside out.
The symbols, one at a time:
- is the position of the character you're predicting.
- is the character just before it. is the character places back.
- stands for an embedding. So is the embedding of the character just before.
- The square brackets with semicolons mean "place side by side". The dots stand for the characters in between.
- is the hidden layer's table of weights.
- is the hidden layer's bias, a list of numbers added on just before tanh.
- is the output layer's table of weights.
- is the output layer's bias, added on at the very end.
In plain words: look up the embeddings of the last few characters, line them up, pass them through one hidden layer, and give every possible next character a score.
Why this beats the table
It shares what it learns. Characters (or, in real models, tokens) that behave alike get similar embeddings. So whatever the network learns about one context also helps with similar ones. That's how it generalizes to contexts it has never seen.
It grows slowly. Only the first layer gets bigger when the window grows. A model like the one you're about to train has 13,112 weights, counting the biases, with 6 characters of context. Double the window to 12 characters and it has 20,024 weights. A counting table for 6 characters would already need about 31 billion rows. The network grows in step with the context length. The table multiplies.
Go deeper Counting the weights
The model below uses 56 characters, 12 numbers per embedding and 96 hidden units. Its weights come in five groups:
- the embedding table: 56 × 12;
- the hidden layer's weights: (window × 12) × 96;
- the hidden layer's bias: 96;
- the output layer's weights: 96 × 56;
- the output layer's bias: 56.
Only the second group depends on the window. That's why doubling the window adds weights instead of multiplying them.
V, d, H = 56, 12, 96 # characters, numbers per embedding, hidden units for block in [6, 12]: params = V * d + (block * d) * H + H + H * V + V print(f"{block} characters of context: {params:,} weights") print(f"a counting table for 6 characters: {V ** 6:,} rows")
Worked example: one prediction by hand
Use a toy vocabulary of 3 characters: a, b and c. Each embedding holds 2 numbers: a = [1, 0], b = [0, 1] and c = [1, 1]. The context is 2 characters, "ab". Which character comes next? The weights below are made up so the arithmetic stays easy. Training would choose them.
- Look up and line up. a's [1, 0] and b's [0, 1] become [1, 0, 0, 1].
- Hidden layer. It has 2 units, and both biases are 0. Unit 1 has weights [1, 0, 0, 1]. Multiply number by number and add: 1×1 + 0×0 + 0×0 + 1×1 = 2. Unit 2 has weights [0, 1, 1, 0], which gives 0×1 + 1×0 + 1×0 + 0×1 = 0.
- Tanh. tanh(2) is about 0.96, and tanh(0) is 0. The hidden layer gives [0.96, 0].
- Output layer. It has one row of weights per character, and the biases are 0. Row a is [1, 0], which scores 0.96. Row b is [0, 1], which scores 0. Row c is [−1, 0], which scores −0.96.
- Softmax. Run each score through exp, the "e to the power of" function from the softmax lesson. (This e is the number 2.718, not an embedding.) That gives about 2.62, 1 and 0.38. They add up to about 4. Divide each by the total to get about 0.65, 0.25 and 0.10.
So the model gives "a" a 65% chance of coming next. Here's the same calculation in Python.
import math emb = {"a": [1, 0], "b": [0, 1], "c": [1, 1]} # each character → 2 numbers x = emb["a"] + emb["b"] # context "ab", lined up side by side W1, b1 = [[1, 0, 0, 1], [0, 1, 1, 0]], [0, 0] # hidden layer: 2 units h = [math.tanh(sum(w * v for w, v in zip(row, x)) + b) for row, b in zip(W1, b1)] W2, b2 = [[1, 0], [0, 1], [-1, 0]], [0, 0, 0] # output layer: one row per character logits = [sum(w * v for w, v in zip(row, h)) + b for row, b in zip(W2, b2)] probs = [math.exp(z) / sum(math.exp(s) for s in logits) for z in logits] print("lined up:", x) print("hidden:", [round(v, 2) for v in h]) print("logits:", [round(v, 2) for v in logits]) print("probs:", [round(p, 2) for p in probs])
The output matches the hand calculation: the lined-up input [1, 0, 0, 1], the hidden values [0.96, 0.0], the logits [0.96, 0.0, -0.96] and the probabilities [0.65, 0.25, 0.1]. Each sum(w * v ...) is one unit multiplying number by number and adding up. A real model does the same thing with bigger lists, and training picks every weight.
block, emb_dim = 6, 12 print(block * emb_dim)
Show the answer
72
Six embeddings of 12 numbers each, placed side by side, make one list of 6 × 12 = 72 numbers. That's what nn.Flatten produces in the next model.
Training one on the catalog
The code below builds the real thing and trains it. Here's what each part does.
windowsslides along the text. Each row ofXholds 6 characters that sit next to each other in the text. The matching entry ofyis the character that comes after them.- The model is the four steps.
nn.Embeddinggives each character 12 numbers.nn.Flattenlines the 6 embeddings up into 72 numbers. Then come a hidden layer of 96 units with tanh, and an output layer with one score per character. - Training runs 800 steps. Each step picks 64 random windows and measures the cross-entropy loss. Then the Adam optimizer nudges every weight.
- Afterwards the code measures the loss on held-out text. It writes 110 new characters, starting from "The Ju". Last, it lists the 5 characters whose embeddings point most nearly the same way as the one for "a".
import numpy as np import requests import torch import torch.nn as nn text = requests.get("https://api.gradient.test/v1/corpus").json()["text"] docs = text.split("\n") chars = sorted(set(text)) stoi = {c: i for i, c in enumerate(chars)} V, block = len(chars), 6 def windows(s): ids = torch.tensor([stoi[c] for c in s]) X = torch.stack([ids[i:len(ids) - block + i] for i in range(block)], dim=1) # 6 previous characters return X, ids[block:] # the character after them X_train, y_train = windows("\n".join(docs[:270])) X_val, y_val = windows("\n".join(docs[270:])) torch.manual_seed(0) model = nn.Sequential( nn.Embedding(V, 12), # each character → 12 numbers nn.Flatten(), # 6 embeddings side by side → 72 numbers nn.Linear(block * 12, 96), nn.Tanh(), nn.Linear(96, V), # logits for the next character ) opt = torch.optim.Adam(model.parameters(), lr=3e-3) for step in range(800): idx = torch.randint(0, len(X_train), (64,)) loss = nn.functional.cross_entropy(model(X_train[idx]), y_train[idx]) opt.zero_grad() loss.backward() opt.step() with torch.no_grad(): val_loss = nn.functional.cross_entropy(model(X_val), y_val).item() print(f"held-out loss: {val_loss:.3f} (bigram: about 2.17)") ctx, out = [stoi[c] for c in "\nThe Ju"[-block:]], "The Ju" torch.manual_seed(3) for _ in range(110): probs = torch.softmax(model(torch.tensor([ctx[-block:]])), dim=-1)[0] nxt = torch.multinomial(probs, 1).item() out += chars[nxt] ctx.append(nxt) print(repr(out)) E = model[0].weight.detach().numpy() E = E / np.linalg.norm(E, axis=1, keepdims=True) sims = E @ E[stoi["a"]] print("characters whose embeddings are closest to 'a':", [chars[j] for j in np.argsort(-sims)[1:6]])
Six characters of context take the held-out loss from about 2.17 (the bigram's score) to well under 1. Lower is better. The samples now contain real catalog phrases ("made from", "Built") mixed with near-misses.
The embeddings are still rough after so little training. Even so, "a" already sits near "A" and other vowels. With more data, the patterns in learned embeddings become much clearer. (As before, torch is the course's in-browser PyTorch subset.)
Watch out: this catalog repeats itself a lot, which makes it easier to predict. Don't expect a loss this low on ordinary text.
Show the answer
It shares learned embeddings and weights across contexts, so similar contexts get similar predictions
A table treats every context as unrelated. Embeddings let what's learned about one context carry over to similar ones.
What it still can't do
Imagine reading a book through a slot that shows six letters at a time. You'd do well on spelling. You'd lose track of the story. This model has that problem, plus two more.
- A fixed window. The model sees exactly six characters. Anything earlier is invisible, such as which product a description is about. So it can't keep a sentence consistent with how it began.
- Separate weights for each slot. Each position in the window has its own weights. So a pattern learned at position 2 has to be learned again at position 5.
- A bigger window is costly. Doubling the context doubles the first layer. And every slot is still treated separately.
Recurrent networks, from Phase 4, tried to carry information forward without limit. They ran into vanishing gradients, and they're slow because they work one step at a time. The fix that won is attention. It lets every position look straight at every earlier position. Then it decides, based on content, which ones matter. That's the next lesson.
Show the answer
Only the last 6 characters, so the product name is invisible to it
The window is fixed. Anything further back than six characters never reaches the model. Attention, in the next lesson, is built to get past this limit.
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 4 review cards for spaced repetition
Try a quiz question
In the neural LM, what does nn.Flatten do after the embedding lookup?