Index Ai Academy
Phase 5 · Transformers · Language modeling

Neural language models

Why counting fails, and what embeddings fix.

About 35 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. A table with too many rows
  2. Most rows stay empty
  3. Swap the table for a network
  4. Why this beats the table
  5. Worked example: one prediction by hand
  6. Training one on the catalog
  7. 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. VV is the vocabulary size, the number of different characters. nn is the context length, how many characters the model looks back at.

possible contexts=Vn\text{possible contexts} = V^n

In plain words: each of the nn slots can hold any of the VV characters. So you multiply VV by itself nn 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)
It runs right here in your browser, with nothing to install.

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.

Try it now

A table explodes with each extra word of context. A network only grows gently, because it shares what it learns between similar contexts.

Quick check
Predict what this prints. It counts the rows a table needs for 4 different characters and 3 characters of context.
V, n = 4, 3
print(V ** n)
  1. 64
  2. 12
  3. 81
  4. 7
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.

  1. 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.
  2. 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.

  1. Look up. Take the previous few characters. Fetch each one's embedding.
  2. Line up. Place the embeddings side by side to make one long list. This is called concatenating them.
  3. 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.
  4. 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.
Quick check
Three characters, each with a 10-number embedding, get lined up. How many numbers go into the hidden layer?
print(3 * 10)
  1. 30
  2. 13
  3. 1000
  4. 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.

logits=W2tanh⁡(W1[ ext−n;… ;ext−1 ]+b1)+b2\text{logits} = W_2 \tanh\big(W_1 [\, e_{x_{t-n}} ; \dots ; e_{x_{t-1}} \,] + b_1\big) + b_2

The symbols, one at a time:

  • tt is the position of the character you're predicting.
  • xt−1x_{t-1} is the character just before it. xt−nx_{t-n} is the character nn places back.
  • ee stands for an embedding. So ext−1e_{x_{t-1}} 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.
  • W1W_1 is the hidden layer's table of weights.
  • b1b_1 is the hidden layer's bias, a list of numbers added on just before tanh.
  • W2W_2 is the output layer's table of weights.
  • b2b_2 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

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.

  1. Look up and line up. a's [1, 0] and b's [0, 1] become [1, 0, 0, 1].
  2. 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.
  3. Tanh. tanh(2) is about 0.96, and tanh(0) is 0. The hidden layer gives [0.96, 0].
  4. 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.
  5. 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])
In the lesson you can change this and run it.

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.

Quick check
A model looks back at 6 characters, and each embedding holds 12 numbers. Predict what this prints: the length of the lined-up list that enters the hidden layer.
block, emb_dim = 6, 12
print(block * emb_dim)
  1. 6
  2. 72
  3. 18
  4. 12
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.

  • windows slides along the text. Each row of X holds 6 characters that sit next to each other in the text. The matching entry of y is the character that comes after them.
  • The model is the four steps. nn.Embedding gives each character 12 numbers. nn.Flatten lines 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.

Quick check
Why does the neural model generalize better than a counting table with the same context length?
  1. It doesn't need training data
  2. It uses a bigger vocabulary
  3. It memorizes every context exactly
  4. It shares learned embeddings and weights across contexts, so similar contexts get similar predictions
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.

Quick check
The model's window is 6 characters. A description named its product 40 characters ago. What can the model use when it predicts the next character?
  1. The whole description, because the embeddings remember it
  2. The last 40 characters, because the window stretches when needed
  3. Only the last 6 characters, so the product name is invisible to it
  4. Every character in the training text
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

Create a free account

Try a quiz question

In the neural LM, what does nn.Flatten do after the embedding lookup?

  1. Places the embeddings of the context characters side by side as one input vector
  2. Averages the context characters' embeddings into one vector the same size
  3. Removes the embeddings, leaving the character IDs for the hidden layer to read
  4. Sorts the context characters, so that the order they came in stops mattering

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson