Index Ai Academy
Phase 5 · Transformers · Language modeling

A bigram model

The simplest language model, built from counts.

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 is the simplest language model that looks at context. It guesses each character from the one right before it. The whole model is one table, small enough to see in full. It's also the bridge to neural language models. Train a single neural layer on the same task, and it learns the very same table. Every later model in this phase is a better way to fill in that "what comes next" table.

What you'll be able to do

  • Build a bigram language model from a table of next-character counts
  • Generate text by sampling one character at a time
  • Handle unseen pairs with smoothing, and measure the improvement over unigrams
  • Train the same model as a one-layer neural network and see it reach the same answer

What it covers

  1. Count what follows what
  2. The full table for the catalog
  3. Generating text
  4. Smoothing: never say never
  5. Scoring a tiny model
  6. A whole bigram model on one word
  7. The same model, learned by gradient descent

Count what follows what

Your phone's keyboard suggests the next word as you type. It learned those guesses from what usually comes next. A bigram model does the same thing with single characters. It learns by counting.

"Bigram" means "two letters". The model only ever looks at pairs of neighboring characters. To build one, you read through the training text. For every character, you note which character came right after it, and you keep a tally.

Take the word "abracadabra". Its neighboring pairs are ab, br, ra, ac, ca, ad, da, ab, br and ra. Now look only at the pairs that start with "a". There are four: ab, ac, ad and ab again. So after "a", the next letter was "b" twice, "c" once and "d" once.

Turn those counts into chances by dividing by the total, 4. After "a", the chance of "b" is 2/4 = 0.5. The chance of "c" is 1/4 = 0.25, and so is the chance of "d". The three chances add up to 1, so together they form a probability distribution over the next letter.

The full model is a table. It has one row for each character you might be on. It has one column for each character that might come next. Each row holds that character's tallies. Divide each row by its own total, and every row becomes a distribution over the next character.

P(b∣a)=count(a,b)count(a)P(b \mid a) = \frac{\text{count}(a, b)}{\text{count}(a)}

The left side, P(b∣a)P(b \mid a), is the chance that the next character is bb when the current one is aa. The upright bar reads "given". On top, count(a,b)\text{count}(a, b) is how often bb came right after aa. Underneath, count(a)\text{count}(a) is the row total: how many pairs start with aa.

In plain words: to guess what follows "a", find every time "a" had a character after it. Tally which character came next. Then divide each tally by the total.

Here's the "abracadabra" example in Python. zip(text, text[1:]) lines the text up with itself, shifted by one, so it walks through every neighboring pair. Counter tallies them.

from collections import Counter

text = "abracadabra"
pairs = Counter(zip(text, text[1:]))          # (current, next) -> how often
row_a = {b: n for (a, b), n in pairs.items() if a == "a"}
total = sum(row_a.values())
for b, n in sorted(row_a.items()):
    print(f"P({b} | a) = {n}/{total} = {n / total:.2f}")
It runs right here in your browser, with nothing to install.

It prints 0.50 for "b" and 0.25 for both "c" and "d", the numbers you worked out by hand.

Quick check
Predict what this code prints.
from collections import Counter
text = "hello"
pairs = Counter(zip(text, text[1:]))
print(pairs[("l", "o")] / (pairs[("l", "l")] + pairs[("l", "o")]))
  1. 0.5
  2. 2
  3. 1.0
  4. 0.25
Show the answer

0.5

The pairs are he, el, ll and lo. Two of them start with "l": ll and lo. One of those two is lo, so the chance of "o" after "l" is 1/2 = 0.5.

The full table for the catalog

Now build the same table for the course catalog: the 300 short product descriptions from the last lesson. The first 270 descriptions are for training. The last 30 are held back, so you can test the model on text it has never seen.

The code below builds the table and prints the four likeliest characters after "q", "w" and the space. Then it measures the loss on the held-out text and draws part of the table as a picture.

import math
import numpy as np
import matplotlib.pyplot as plt
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:])
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
V = len(chars)
tr = np.array([stoi[c] for c in train])
va = np.array([stoi[c] for c in val])

counts = np.zeros((V, V))
np.add.at(counts, (tr[:-1], tr[1:]), 1)                    # counts[a, b] = how often b follows a
probs = (counts + 1) / (counts + 1).sum(axis=1, keepdims=True)   # add-one smoothing per row

for c in ["q", "w", " "]:
    row = probs[stoi[c]]
    top = np.argsort(-row)[:4]
    print(f"after {c!r}: " + ", ".join(f"{chars[j]!r} {row[j]:.2f}" for j in top))

loss = -np.mean(np.log(probs[va[:-1], va[1:]]))
print(f"\nbigram loss on held-out text: {loss:.3f} (perplexity {math.exp(loss):.1f})")

shown = [stoi[c] for c in "aeilnorst ."]
plt.imshow(probs[np.ix_(shown, shown)], cmap="viridis")
plt.xticks(range(len(shown)), [repr(chars[i]) for i in shown], fontsize=7)
plt.yticks(range(len(shown)), [repr(chars[i]) for i in shown], fontsize=7)
plt.xlabel("next character")
plt.ylabel("current character")

Here's what the code did, step by step.

  1. stoi gives every character a number, its ID. Character IDs double as row and column numbers.
  2. np.add.at does the tallying. For every neighboring pair (a, b), it adds 1 to counts[a, b].
  3. The probs line adds 1 to every cell, then divides each row by its total. That extra 1 is called smoothing, and you'll see why it's there in a moment.
  4. The loss line looks up the chance the model gave each real next character in the held-out text. It averages -log of those chances. That's the same loss as in the last lesson.
  5. The picture is a heat map of a few rows and columns. Each row is a current character. Brighter squares are likelier next characters.

One character of context drops the loss from about 3.1 to about 2.2. (The 3.1 is the unigram model from the last lesson, which ignores context.) Perplexity falls from about 22 to about 9. Perplexity is roughly how many characters the model is choosing between at each step. So the model went from a 22-way guess to something like a 9-way guess.

Some rows are focused. After "q" comes "u", and smoothing spreads a little chance over every other character. After "w" the likeliest letters are "a" and "n", as in warm, water and down. Other rows are spread out. After a space, almost any letter can come next, because almost any word can start there.

Quick check
The unigram model had a perplexity of about 22. The bigram model has about 9. What does that mean?
  1. The bigram model only knows 9 characters
  2. The bigram model makes 9 mistakes in each description
  3. The bigram model is right 9% of the time
  4. At each step the bigram model is about as unsure as picking among 9 equally likely characters, not 22
Show the answer

At each step the bigram model is about as unsure as picking among 9 equally likely characters, not 22

Perplexity reads as "effectively choosing among this many options". Knowing the previous character narrows the choice from about 22 to about 9.

Generating text

Think of each row as a loaded die with one face per character. The faces aren't equally likely. Characters that often came next get bigger faces.

To write text, start on the newline, the break between two descriptions. Roll the newline's die to pick the first character. Then roll that character's die to pick the next one. Keep going until you roll a newline or reach 80 characters. Choosing at random by the chances like this is called sampling.

import numpy as np
import requests

text = requests.get("https://api.gradient.test/v1/corpus").json()["text"]
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
ids = np.array([stoi[c] for c in text])
counts = np.ones((len(chars), len(chars)))
np.add.at(counts, (ids[:-1], ids[1:]), 1)
probs = counts / counts.sum(axis=1, keepdims=True)

rng = np.random.default_rng(0)
for _ in range(3):
    current, out = stoi["\n"], ""
    for _ in range(80):
        current = rng.choice(len(chars), p=probs[current])
        if chars[current] == "\n":
            break
        out += chars[current]
    print(repr(out))

This cell counts over the whole catalog, because it only writes text and doesn't measure a loss. rng.choice is the die roll: it picks a character ID using the chances in the current row. The random generator starts from a fixed seed (0), so you get the same three lines every run.

The output has the texture of the catalog. Common letter pairs appear, and spaces land in sensible places. But there are no real words and no meaning. The model remembers one character only. After an "a", it has no idea which word it's in. So it can't spell anything longer than two letters on purpose.

Try it now

A bigram model has one row like this for each letter. Generating text is just sampling from the current row, again and again.

Smoothing: never say never

Some pairs never show up in the training text. Their count is zero, so their chance is zero. The model is saying "this can never happen."

Then a new description uses that pair. The loss for that step is −log⁡0-\log 0, which is infinite. The loss is an average, so one infinite step makes the whole average infinite.

Think of a weather forecaster who says "0% chance of rain", and then it rains. By this score, the forecaster was infinitely wrong. A careful forecaster never says exactly 0%.

The fix is called smoothing. Before you divide, add a small extra count to every cell of the table, even the empty ones. Adding 1 is called add-one smoothing. Adding some other amount is called add-α smoothing. The Greek letter α (alpha) stands for that extra amount.

P(b∣a)=count(a,b)+αcount(a)+αVP(b \mid a) = \frac{\text{count}(a, b) + \alpha}{\text{count}(a) + \alpha V}

The top is the old count plus α. On the bottom, VV is the number of different characters. Every row has VV cells, and each one got an extra α. So the row total grows by α times VV, and each row still adds up to 1.

In plain words: pretend you saw every possible pair a few extra times. Then count and divide as before. Pairs you never saw get a small chance instead of zero.

Back to "abracadabra". It uses V=5V = 5 letters: a, b, c, d and r. Row "a" has the counts 0, 2, 1, 1 and 0. With α = 1, add 1 to each: 1, 3, 2, 2 and 1. The new total is 9, which is the old total of 4 plus 1 × 5. So the chance of "b" after "a" is 3/9, about 0.333. The pair "ar" never appeared, yet it now gets 1/9, about 0.111.

from collections import Counter

text = "abracadabra"
letters = sorted(set(text))                   # a, b, c, d, r: V = 5
pairs = Counter(zip(text, text[1:]))
alpha = 1
row = [pairs[("a", b)] + alpha for b in letters]
total = sum(row)
for b, n in zip(letters, row):
    print(f"P({b} | a) = {n}/{total} = {n / total:.3f}")

The five chances it prints add up to 1.

The amount you add matters. Too little, and unseen pairs still get tiny chances. Too much, and the extra counts drown out the real ones. Every row drifts toward equal chances, and the model forgets what it learned. The exercise tries several amounts on the catalog.

Neural models get this protection for free. Their last step is a softmax, and a softmax never outputs exactly zero.

Go deeper Why one zero ruins the score

The loss adds up −log⁡p-\log p for every pair, then divides by the number of pairs. As pp shrinks toward 0, −log⁡p-\log p grows without limit: 0.1 gives about 2.3, 0.001 about 6.9, and 0.000001 about 13.8. At exactly 0 it is infinite. Adding anything to infinity still gives infinity, and so does dividing it by the number of pairs. That's why a single unseen pair is enough to make an unsmoothed model's loss infinite.

Quick check
Why does a bigram model with no smoothing give an infinite loss on some new texts?
  1. Bigram models can't compute a loss
  2. A character pair never seen in training gets probability 0, and −log 0 is infinite
  3. The text is too long
  4. It runs out of memory
Show the answer

A character pair never seen in training gets probability 0, and −log 0 is infinite

Smoothing (or a softmax) keeps every probability above zero.

Scoring a tiny model

Here's the loss again, written out for a bigram model. Number the test text's characters x0,x1,x2x_0, x_1, x_2 and so on. Call the last one xnx_n, so the text has nn neighboring pairs.

loss=−1n∑i=1nlog⁡P(xi∣xi−1)\text{loss} = -\frac{1}{n} \sum_{i=1}^{n} \log P(x_i \mid x_{i-1})

The symbol Σ (sigma) means "add up". Here it adds over every pair, from the first (i = 1) to the last (i = n). The log is the natural log, the same as NumPy's np.log.

In plain words: walk through the test text one pair at a time. For each pair, look up the chance the model gave the real next character. Take minus the log of it. Then average.

Train on "abracadabra" with α = 1, and test on "radar". It has four pairs: ra, ad, da and ar. The model gives them chances of 3/7, 2/9, 2/6 and 1/9. Minus the logs are about 0.847, 1.504, 1.099 and 2.197. Their average is about 1.412. Without smoothing, "ar" has a chance of 0, and the loss is infinite.

Worked example

A whole bigram model on one word

Train a bigram model on "abracadabra", then score it on "radar". Do it twice: once with no smoothing (α = 0) and once with add-one smoothing (α = 1).

import math
from collections import Counter

train, test = "abracadabra", "radar"
letters = sorted(set(train))
V = len(letters)
pairs = Counter(zip(train, train[1:]))

def prob(a, b, alpha):
    row_total = sum(pairs[(a, c)] for c in letters)
    return (pairs[(a, b)] + alpha) / (row_total + alpha * V)

for alpha in [0, 1]:
    losses = []
    line = f"alpha {alpha}:"
    for a, b in zip(test, test[1:]):
        p = prob(a, b, alpha)
        losses.append(-math.log(p) if p > 0 else math.inf)
        line += f"  {a}->{b} {p:.3f}"
    print(line, f"  loss {sum(losses) / len(losses):.3f}")
In the lesson you can change this and run it.

prob is the add-α formula. The row total counts every pair that starts with a. With α = 0, the pairs r→a and d→a get a chance of 1.000, because in training "r" and "d" were always followed by "a". But a→r gets 0.000, since "ar" never appeared. Its minus log is infinite, so the loss prints as inf. With α = 1, every chance is above zero and the loss is about 1.412. Notice that smoothing lowered the sure chances too: r→a fell from 1.000 to 0.429. That's the price of never saying never.

The same model, learned by gradient descent

So far you built the table by counting. Now build it a different way: by training, as in Phase 4. This is the key step toward neural language models.

Make a table WW with one row per character and one column per possible next character. It has VV rows and VV columns. Row a holds a raw score for every character that might follow a. These raw scores are called logits. A softmax turns a row of logits into chances that add up to 1.

Fetching row a is exactly what an embedding layer does. As you saw in Phase 4, that's the same as multiplying a one-hot vector by the table. So the whole model is one layer.

Training works as usual. The scores start out random. The model sees each pair from the training text. Cross-entropy measures how surprised it was by the real next character. Then gradient descent nudges the scores to cut that surprise. The optimizer here is Adam.

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")
train, val = "\n".join(docs[:270]), "\n".join(docs[270:])
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
tr = torch.tensor([stoi[c] for c in train])
va = torch.tensor([stoi[c] for c in val])

torch.manual_seed(0)
W = nn.Embedding(len(chars), len(chars))         # row a = logits for the character after a
opt = torch.optim.Adam(W.parameters(), lr=0.1)
for step in range(150):
    loss = nn.functional.cross_entropy(W(tr[:-1]), tr[1:])
    opt.zero_grad()
    loss.backward()
    opt.step()
    if step % 50 == 0 or step == 149:
        with torch.no_grad():
            val_loss = nn.functional.cross_entropy(W(va[:-1]), va[1:]).item()
        print(f"step {step:>3}  training loss {loss.item():.3f}  held-out loss {val_loss:.3f}")

Both losses fall as training goes on. The trained layer ends at a held-out loss of about 2.17. That's the same as the counting model with light smoothing. (The exercise compares smoothing amounts.) It's the same model, learned two ways. (torch here is the course's in-browser PyTorch subset.)

The neural version is worth the extra effort because it can grow, and a counting table can't. For two characters of context, a table would need a row for every pair of characters. It also can't share what it learns between similar contexts. The neural version can take more context and more layers, as the next lessons show.

Go deeper Why training lands on the counts

Look at one row. Cross-entropy is lowest when the softmax's chances match how often each next character really followed. Those rates are the counts divided by the row total. So gradient descent heads toward the counting table.

A pair that never appeared would need a chance of exactly 0. A softmax can't reach 0, so training keeps pushing that pair's score lower. After 150 steps those scores are low but not endlessly low. The result acts like a counting table with a little smoothing.

Quick check
A single neural layer trained on the catalog's character pairs reaches a held-out loss of about 2.17. Counting with light smoothing gets about the same. What does that tell you?
  1. Both methods arrive at essentially the same next-character table
  2. The neural layer has learned to spell whole words
  3. Counting is always better than training
  4. The held-out text was used for training
Show the answer

Both methods arrive at essentially the same next-character table

A V × V layer trained with cross-entropy rediscovers the pair counts. The neural version matters because it can be extended with more context and layers.

Practice what you just read

  • 6 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

What does row a of a bigram probability table represent?

  1. The distribution of the next character, given that the current character is a
  2. How often the character a appears in the text, compared with the others
  3. The distribution of the previous character, given that the current one is a
  4. The embedding of a: a list of numbers learned to capture how it's used

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

Before this lesson