TAI BUI
Back to curriculum
BuildPython

Text Generation Before Transformers — N-gram Language Models

If a word is surprising, the model is bad. Perplexity makes surprise a number. Smoothing keeps it finite.

TIME: ~45 minutesPREREQUISITES: Phase 5 · 01 (Text Processing), Phase 2 · 14 (Naive Bayes)

Learning Objectives

  • Explain how an n-gram language model estimates P(next word | previous n-1 words) from raw counts, and why the zero-count problem makes smoothing mandatory on any real text
  • Implement Laplace and Kneser-Ney smoothing from scratch, including Kneser-Ney's continuation-probability insight for the lower-order model
  • Compute perplexity to evaluate a language model, and generate text by sampling from its next-word distribution
  • Identify where n-gram models still ship in production today (KenLM, on-device autocomplete, rescoring) and use one as a baseline before trusting a neural LM's results

The Problem

Before transformers, before RNNs, before word embeddings, this is how a language model predicted the next word: count how often it followed the previous n-1 words. "The cat" → "sat" 47 times, "the cat" → "jumped" 12 times, "the cat" → "refrigerator" 0 times. Normalize those counts into a probability distribution, and you have an n-gram language model.

This ran every speech recognizer, every spell checker, and every phrase-based machine translation system from 1980 through 2015. It still runs today, anywhere you need language modeling cheap enough to fit on a device.

The interesting problem is what to do about the n-grams you've never seen. A raw count-based model hands those zero probability, which is fatal: sentences run long, and almost every long one contains at least one sequence the training data never showed you. Fifty years of smoothing research exists to fix exactly that, and Kneser-Ney smoothing is where it landed: the empirical habit modern deep learning inherited.

Warm-up0/3
1. A weather forecaster looks at 50 past days that resembled today, and 10 of them were followed by rain. What chance of rain does simple counting suggest?Warm-up
2. A script multiplies thousands of small probabilities and the result underflows to 0.0. What is the standard way to avoid this?Warm-up
3. Before counting words in a text corpus, each sentence is first split into word-level pieces such as `the`, `cat`, `sat`. What is this step called?Warm-up
Check: The Problem0/1
1. Why is a zero probability for an unseen n-gram fatal on real text?Explain

The Concept

N-gram model: count, smooth, generate

N-gram probability: P(w_i | w_{i-n+1}, ..., w_{i-1}). Fix n (typically 3 for trigrams, 4 for 4-grams). Compute from counts:

text
P(w | context) = count(context, w) / count(context)

The zero-count problem. Any n-gram not seen in training gets probability zero. A 2007 study on the Brown corpus found that even a 4-gram model had 30% of held-out 4-grams unseen in training. You cannot evaluate on any real text without smoothing.

Smoothing approaches, in order of sophistication:

  1. Laplace (add-one). Add 1 to every count. Simple, terrible on rare events.
  2. Good-Turing. Reallocate probability mass from higher-frequency events to unseen ones based on frequency-of-frequencies.
  3. Interpolation. Combine n-gram, (n-1)-gram, etc., estimates with tunable weights.
  4. Backoff. If n-gram has count zero, fall back to (n-1)-gram. Katz backoff normalizes this.
  5. Absolute discounting. Subtract a fixed discount D from all counts, redistribute to unseen.
  6. Kneser-Ney. Absolute discounting plus a clever choice for the lower-order model: use continuation probability (how many contexts a word appears in) instead of raw frequency.

The Kneser-Ney insight is worth sitting with. "San Francisco" is a common bigram, but the unigram "Francisco" almost always follows "San." Naive absolute discounting still gives "Francisco" a high unigram probability, because its raw count is high. Kneser-Ney catches what that count is hiding: "Francisco" appears in only one context, so its continuation probability comes out low, and any novel bigram ending in "Francisco" gets the low probability it deserves.

Evaluation: perplexity. Perplexity is the exponent of the average negative log-likelihood per word on a held-out test set. Lower is better: a perplexity of 100 means the model is about as confused as it would be picking uniformly among 100 words.

text
perplexity = exp(- (1/N) * Σ log P(w_i | context_i))
Check: The Concept0/14
1. What does an n-gram language model estimate?Define
2. What problem does smoothing solve in n-gram models?Define
3. What does perplexity measure?Define
4. What does Katz backoff do when an n-gram has zero count?Define
5. What is Kneser-Ney smoothing?Define
6. What is the continuation probability of a word?Define
7. What does Laplace (add-one) smoothing do?Define
8. What insight makes Kneser-Ney smoothing better than naive absolute discounting?Explain
9. Why is Laplace smoothing considered poor on rare events?Explain
10. How does interpolation differ from backoff?Explain
11. A model has perplexity 100 on a held-out set. How should you read that number?Explain
12. What is the idea behind Good-Turing smoothing?Explain
13. What does absolute discounting do to n-gram counts?Explain
14. A model with absolute discounting and raw-count unigrams gives "Francisco" a high unigram probability, so a novel bigram like "the Francisco" scores too high. Which change fixes this?Analyze

Build It

Step 1: trigram counts

python
from collections import Counter, defaultdict
 
 
def train_ngram(corpus_tokens, n=3):
    ngrams = Counter()
    contexts = Counter()
    for sentence in corpus_tokens:
        padded = ["<s>"] * (n - 1) + sentence + ["</s>"]
        for i in range(len(padded) - n + 1):
            ctx = tuple(padded[i:i + n - 1])
            word = padded[i + n - 1]
            ngrams[ctx + (word,)] += 1
            contexts[ctx] += 1
    return ngrams, contexts
 
 
def raw_probability(ngrams, contexts, context, word):
    ctx = tuple(context)
    if contexts.get(ctx, 0) == 0:
        return 0.0
    return ngrams.get(ctx + (word,), 0) / contexts[ctx]

Input is a list of tokenized sentences; output is n-gram counts and context counts. <s> and </s> mark sentence boundaries.

Step 2: Laplace smoothing

python
def laplace_probability(ngrams, contexts, vocab_size, context, word):
    ctx = tuple(context)
    numerator = ngrams.get(ctx + (word,), 0) + 1
    denominator = contexts.get(ctx, 0) + vocab_size
    return numerator / denominator

Add 1 to every count. That smooths out the zeros, but it over-allocates mass to events that never occurred, at the cost of the rare-but-real ones.

Step 3: Kneser-Ney (bigram, interpolated)

python
def kneser_ney_bigram_model(corpus_tokens, discount=0.75):
    unigrams = Counter()
    bigrams = Counter()
    unigram_contexts = defaultdict(set)
 
    for sentence in corpus_tokens:
        padded = ["<s>"] + sentence + ["</s>"]
        for i, w in enumerate(padded):
            unigrams[w] += 1
            if i > 0:
                prev = padded[i - 1]
                bigrams[(prev, w)] += 1
                unigram_contexts[w].add(prev)
 
    total_unique_bigrams = sum(len(ctx_set) for ctx_set in unigram_contexts.values())
    continuation_prob = {
        w: len(ctx_set) / total_unique_bigrams for w, ctx_set in unigram_contexts.items()
    }
 
    context_totals = Counter()
    for (prev, w), count in bigrams.items():
        context_totals[prev] += count
 
    unique_follow = defaultdict(set)
    for (prev, w) in bigrams:
        unique_follow[prev].add(w)
 
    def prob(prev, w):
        count = bigrams.get((prev, w), 0)
        denom = context_totals.get(prev, 0)
        if denom == 0:
            return continuation_prob.get(w, 1e-9)
        first_term = max(count - discount, 0) / denom
        lambda_prev = discount * len(unique_follow[prev]) / denom
        return first_term + lambda_prev * continuation_prob.get(w, 1e-9)
 
    return prob

Three moving parts here. continuation_prob is the Kneser-Ney innovation itself: how many different contexts a word shows up in. lambda_prev is the mass the discount frees up, used to weight the backoff term. The final probability is just the discounted main term plus that weighted continuation term.

Step 4: generating text with sampling

python
import random
 
 
def generate(prob_fn, vocab, prefix, max_len=30, seed=0):
    rng = random.Random(seed)
    tokens = list(prefix)
    for _ in range(max_len):
        candidates = [(w, prob_fn(tokens[-1], w)) for w in vocab]
        total = sum(p for _, p in candidates)
        r = rng.random() * total
        acc = 0.0
        for w, p in candidates:
            acc += p
            if r <= acc:
                tokens.append(w)
                break
        if tokens[-1] == "</s>":
            break
    return tokens

This samples proportional to probability, so the output changes with the seed. For beam-search-like output instead, pick the argmax at each step (greedy), and add a small randomness knob (temperature) to loosen that up.

Step 5: perplexity

python
import math
 
 
def perplexity(prob_fn, sentences):
    total_log_prob = 0.0
    total_tokens = 0
    for sentence in sentences:
        padded = ["<s>"] + sentence + ["</s>"]
        for i in range(1, len(padded)):
            p = prob_fn(padded[i - 1], padded[i])
            total_log_prob += math.log(max(p, 1e-12))
            total_tokens += 1
    return math.exp(-total_log_prob / total_tokens)

Lower is better. On the Brown corpus, a well-tuned 4-gram KN model lands around perplexity 140. A transformer LM on the same test set hits 15-30. That's roughly a 10x gap, and it's why the field moved on.

Check: Build It0/10
1. Why do generated trigram-LM sentences feel locally fluent but globally incoherent?Explain
2. On the Brown corpus, what perplexity gap separates a well-tuned 4-gram Kneser-Ney model from a transformer LM?Explain
3. `contexts[("the", "cat")]` is 60 and `ngrams[("the", "cat", "sat")]` is 15. What does `raw_probability(ngrams, contexts, ["the", "cat"], "sat")` return?Apply
4. With `vocab_size` 1000, a context seen 20 times and a word never seen after it, what does `laplace_probability` return?Apply
5. `kneser_ney_bigram_model` is trained on the corpus `[["a", "b"], ["c", "b"]]`. What is `continuation_prob["b"]`?Apply
6. Using the same corpus `[["a", "b"], ["c", "b"]]` with discount 0.75, what does `prob("a", "b")` return?Apply
7. With the same model, what does `prob("zzz", "b")` return when "zzz" never appeared as a previous word?Apply
8. `perplexity` scores 3 tokens and the model assigns probability 1/4 to every one. What does it return?Apply
9. `generate` is called twice with the same `prob_fn`, `vocab`, `prefix` and `seed=0`. What happens?Apply
10. A Laplace-smoothed trigram model on a large vocabulary gives rare but real phrases too little probability and unseen nonsense phrases too much. Which explanation fits?Analyze

Use It

  • Classical NLP teaching. The clearest exposure to smoothing, MLE, and perplexity you can get.
  • KenLM. Production n-gram library. Used as a rescorer in speech and MT systems where low latency matters.
  • On-device autocomplete. Trigram models in keyboards. Still.
  • Baselines. Always compute an n-gram LM perplexity before declaring your neural LM good. If your transformer does not beat KN by a wide margin, something is wrong.
Check: Use It0/4
1. Where do n-gram models still ship in production?Define
2. Why is computing an n-gram baseline before declaring a neural LM good still recommended?Explain
3. Your transformer LM reports a held-out perplexity of 135, while a Kneser-Ney n-gram baseline on the same data scores 140. What is the most likely explanation?Analyze
4. A keyboard app must suggest the next word on a phone with no network and a tight memory and latency budget. Which approach fits the lesson's ideas?Design

Ship It

Save as outputs/prompt-lm-baseline.md:

markdown
---
name: lm-baseline
description: Build a reproducible n-gram language model baseline before training a neural LM.
phase: 5
lesson: 16
---
 
Given a corpus and target use (next-word prediction, rescoring, perplexity baseline), output:
 
1. N-gram order. Trigram for general English, 4-gram if corpus is large, 5-gram for speech rescoring.
2. Smoothing. Modified Kneser-Ney is the default; Laplace only for teaching.
3. Library. `kenlm` for production, `nltk.lm` for teaching, roll your own only to learn.
4. Evaluation. Held-out perplexity with consistent tokenization between train and test sets.
 
Refuse to report perplexity computed with different tokenization between systems being compared — perplexity numbers are comparable only under identical tokenization. Flag the OOV (out-of-vocabulary) rate in the test set; KN handles OOV words poorly unless you reserve a special <UNK> token during training.
Check: Ship It0/6
1. Why must train and test sets use identical tokenization when comparing perplexity numbers?Explain
2. Why should a language-model pipeline reserve a special <UNK> token during training?Explain
3. A paper reports perplexity 20 for its neural LM and 140 for a KN baseline, but the neural model used subword tokens and the baseline used whole words. What is wrong with the comparison?Analyze
4. You need a baseline LM to rescore the output of a speech recognizer. Which default is the sensible choice?Design
5. You have trained a new neural LM and want to claim it is good. Which procedure best follows the lesson's guidance?Design
6. You are building a baseline LM for next-word prediction on a very large corpus of general English. Which n-gram order does the lesson's guidance favor?Design
Author's Note

What I actually want you to carry out of this lesson is the discipline buried in the Ship It skill, not the Kneser-Ney math: compute the boring baseline before you trust the fancy model. I've watched more than one "the transformer isn't working" debugging session turn out to be a broken tokenizer or a leaked label, and the tell was always the same. Nobody had checked what a trigram model scored on the same data first. A stupid model that's 10x worse than your neural one tells you the neural one is plausible. A stupid model that scores about the same tells you something upstream is broken. Better to catch that before you've spent a week training than after.

Exercises

  1. Easy. Train a trigram LM on a 1,000-sentence Shakespeare corpus. Generate 20 sentences. They will be locally plausible but globally incoherent. This is the canonical demo.
  2. Medium. Implement perplexity for your KN model on a held-out Shakespeare split. Compare against Laplace. You should see KN lower perplexity by 30-50%.
  3. Hard. Build a trigram spell corrector: given a misspelled word and its context, generate corrections and rank by context probability under the LM. Evaluate on the Birkbeck spelling corpus (public).

Key Terms

TermWhat people sayWhat it actually means
N-gramWord sequenceSequence of n consecutive tokens.
SmoothingAvoiding zerosReallocating probability mass so unseen events get non-zero probability.
PerplexityLM quality metricexp(-average log-prob) on held-out data. Lower is better.
BackoffFallback to shorter contextIf trigram count is zero, use bigram. Katz backoff formalizes this.
Kneser-NeyBest smoothing for n-gramsAbsolute discounting + continuation probability for the lower-order model.
Continuation probabilityKN-specificP(w) weighted by number of contexts w appears in, not by raw count.

Further Reading

Code

Ships With