N-grams › N-gram Language Models

Michael BrenndoerferUpdated March 22, 20256 min read
Language AI HandbookAll chapters
Part 2 of 67Classical Text Representations
Chapter 7N-grams › N-gram Language Models
Reading section 6 of 16 · N-gram Language Models · 6 minSection 6 of 16 · 6 min

Book contents

Section 6 of 16 · 6 min read

N-grams › N-gram Language ModelsOne section · Sections in this chapter6 / 16 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

One of the most powerful applications of n-gram statistics is building language models, models that assign a probability to any sequence of words. A language model answers the question: how likely is this sentence? This capability underlies autocomplete, speech recognition, machine translation, and text generation.

The probability of a sequence of words w1,w2,…,wTw_1, w_2, \ldots, w_T can be written using the chain rule of probability:

P(w1,w2,…,wT)=∏t=1TP(wt∣w1,w2,…,wt−1)P(w_1, w_2, \ldots, w_T) = \prod_{t=1}^{T} P(w_t \mid w_1, w_2, \ldots, w_{t-1})

where P(wt∣w1,…,wt−1)P(w_t \mid w_1, \ldots, w_{t-1}) is the conditional probability of word wtw_t given all preceding words. The problem with this exact formulation is that the history w1,…,wt−1w_1, \ldots, w_{t-1} grows without bound as tt increases, making it impossible to estimate reliably from finite data.

The n-gram approximation solves this by applying the Markov assumption: the probability of the next word depends only on the last n−1n-1 words, not the full history. For a bigram model (n=2n = 2), the assumption is:

P(wt∣w1,w2,…,wt−1)≈P(wt∣wt−1)P(w_t \mid w_1, w_2, \ldots, w_{t-1}) \approx P(w_t \mid w_{t-1})

For a trigram model (n=3n = 3):

P(wt∣w1,w2,…,wt−1)≈P(wt∣wt−2,wt−1)P(w_t \mid w_1, w_2, \ldots, w_{t-1}) \approx P(w_t \mid w_{t-2}, w_{t-1})

In general, an n-gram model conditions on the preceding n−1n - 1 words:

P(wt∣w1,…,wt−1)≈P(wt∣wt−n+1,…,wt−1)P(w_t \mid w_1, \ldots, w_{t-1}) \approx P(w_t \mid w_{t-n+1}, \ldots, w_{t-1})

The intuition is that most of the predictive signal about the next word comes from the immediately preceding few words. Knowing that the last two words were "machine learning" tells you a lot about what comes next. Knowing that the word 20 positions back was "the" tells you very little.

Maximum Likelihood Estimation

The parameters of an n-gram language model are the conditional probabilities P(wt∣wt−n+1,…,wt−1)P(w_t \mid w_{t-n+1}, \ldots, w_{t-1}). The simplest way to estimate these from training data is maximum likelihood estimation (MLE):

PMLE(wt∣wt−n+1,…,wt−1)=C(wt−n+1,…,wt−1,wt)C(wt−n+1,…,wt−1)P_{\text{MLE}}(w_t \mid w_{t-n+1}, \ldots, w_{t-1}) = \frac{C(w_{t-n+1}, \ldots, w_{t-1}, w_t)}{C(w_{t-n+1}, \ldots, w_{t-1})}

where:

  • C(wt−n+1,…,wt−1,wt)C(w_{t-n+1}, \ldots, w_{t-1}, w_t): the count of the full n-gram (the n−1n-1 context words followed by wtw_t) in training data
  • C(wt−n+1,…,wt−1)C(w_{t-n+1}, \ldots, w_{t-1}): the count of the context (the n−1n-1 preceding words) alone

In plain language: the probability of word wtw_t following a given context is the fraction of times that context in training data was followed by wtw_t. If the bigram "machine learning" appears 100 times and the word "machine" appears 200 times total, then PMLE(learning∣machine)=100/200=0.5P_{\text{MLE}}(\text{learning} \mid \text{machine}) = 100 / 200 = 0.5.

This estimate is intuitive and easy to compute. However, it assigns zero probability to any n-gram that did not appear in training data. Since any held-out text will contain many unseen n-grams, a pure MLE n-gram model assigns zero probability to real sentences, which makes it unusable for tasks like perplexity evaluation or speech recognition scoring.

Smoothing: Handling Unseen N-grams

Smoothing is the family of techniques used to assign non-zero probabilities to unseen n-grams by redistributing probability mass from seen n-grams to unseen ones. Every practical n-gram language model uses some form of smoothing.

Laplace (Add-one) Smoothing is the simplest approach. We add 1 to every n-gram count, including unseen ones:

PLaplace(wt∣wt−1)=C(wt−1,wt)+1C(wt−1)+VP_{\text{Laplace}}(w_t \mid w_{t-1}) = \frac{C(w_{t-1}, w_t) + 1}{C(w_{t-1}) + V}

where:

  • C(wt−1,wt)C(w_{t-1}, w_t): the observed bigram count
  • C(wt−1)C(w_{t-1}): the observed unigram count for the context word
  • VV: the vocabulary size (the number of word types)

The denominator increases by VV rather than by 11 because we are adding 1 to the count of every possible next word, and there are VV of them. Think of it as: every word in the vocabulary is given one free observation following every context. This ensures no zero-probability outcomes.

The key insight is that adding VV to the denominator normalizes the distribution so that probabilities still sum to 1 over all possible next words. Without this correction, the probabilities would be inflated.

In practice, Laplace smoothing assigns far too much probability mass to unseen n-grams, which dilutes the probability of observed ones. More sophisticated methods like Kneser-Ney smoothing achieve much better results in practice by using a more careful accounting of how probability mass should be redistributed based on the diversity of contexts each word appears in. But Laplace smoothing is useful to understand because it captures the essential idea: every technique in the smoothing family is ultimately trying to solve the same problem of zero counts.

In[17]:
Code
def build_bigram_lm(corpus_docs):
    """Build a bigram language model with Laplace smoothing."""
    unigram_counts = Counter()
    bigram_counts_lm = Counter()

    for doc in corpus_docs:
        toks = ["<s>"] + doc.lower().split() + ["</s>"]
        for w in toks:
            unigram_counts[w] += 1
        for bigram in extract_ngrams(toks, 2):
            bigram_counts_lm[bigram] += 1

    vocab = set(unigram_counts.keys())
    V = len(vocab)

    def bigram_prob(word, context):
        """Laplace-smoothed bigram probability."""
        bigram = (context, word)
        numerator = bigram_counts_lm[bigram] + 1
        denominator = unigram_counts[context] + V
        return numerator / denominator

    def sentence_log_prob(sentence):
        """Log probability of a sentence under the model."""
        import math

        toks = ["<s>"] + sentence.lower().split() + ["</s>"]
        log_p = 0.0
        for i in range(1, len(toks)):
            p = bigram_prob(toks[i], toks[i - 1])
            log_p += math.log(p)
        return log_p

    return bigram_prob, sentence_log_prob, vocab, V


bigram_prob, sentence_log_prob, lm_vocab, lm_V = build_bigram_lm(corpus)
Out[18]:
Console
Bigram Language Model (Laplace smoothed):
  Vocabulary size: 15

  P(? | 'machine'):
    P('learning' | 'machine') = 0.263158
    P('translation' | 'machine') = 0.052632
    P('is' | 'machine') = 0.052632

  P(? | 'deep'):
    P('learning' | 'deep') = 0.176471
    P('sea' | 'deep') = 0.058824
    P('blue' | 'deep') = 0.058824

  P(? | 'i'):
    P('love' | 'i') = 0.166667
    P('hate' | 'i') = 0.055556
    P('study' | 'i') = 0.111111

Sentence log-probabilities:
  'I love machine learning': -8.534
  'machine learning is powerful': -9.803
  'deep learning is powerful': -10.203
  'fish eat the sky purple': -16.536

Notice how corpus-consistent sentences score much higher log-probability than nonsensical ones. The sentence "fish eat the sky purple" is grammatically unusual and uses words in combinations never seen in training, so even with Laplace smoothing its probability is much lower. This is exactly the behavior we want from a language model.