N-gram Language Models: Probability and Text Generation
Explains how n-gram language models assign probabilities to word sequences using the chain rule, Markov assumption, and MLE.
Language AI HandbookAll chapters N-gram Language ModelsFull chapter · Sections in this chapter1 / 15 ↓
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
N-gram Language Models
What makes some word sequences sound natural while others feel wrong? "The cat sat on the mat" flows effortlessly, but "mat the on sat cat the" is gibberish even though it contains exactly the same words. Native speakers intuitively know which sequences are probable, even for sentences they have never heard before. N-gram language models capture this intuition mathematically, assigning probabilities to sequences of words based on patterns learned from large text collections.
Language models are foundational to natural language processing. They power spell checkers that suggest "their" over "thier," autocomplete systems that predict your next word, and speech recognizers that disambiguate "recognize speech" from "wreck a nice beach." Before neural networks dominated the field, n-gram models were the workhorses of language modeling. Systems built on trigrams and simple smoothing techniques ran at the heart of Google's search products, Microsoft's Bing, and virtually every production voice interface from the 1990s through the early 2010s. Understanding them provides essential intuition for the modern approaches covered throughout this handbook.
As we saw in the previous chapter on N-grams, sequences of adjacent words reveal statistical patterns in text. This chapter takes that idea further: instead of simply counting n-gram frequencies, we use those counts to build a probability model over entire word sequences. You will learn the probability theory that makes n-gram language models work, implement them from scratch, evaluate them rigorously with perplexity, and discover both their power and their fundamental limitations. The next chapter on Smoothing Techniques addresses the central failure mode you will encounter here.
The Language Modeling Problem
A language model assigns probabilities to sequences of words. Given a sequence of words, denoted , the model estimates , the probability of that exact sequence occurring in natural language. Here represents the -th word in the sequence. For example, might be "the" and might be "cat."
A language model is a probability distribution over sequences of words. It assigns higher probabilities to sequences that are more likely to occur in natural language and lower probabilities to unlikely or ungrammatical sequences.
Why is assigning probabilities to word sequences useful? The applications are remarkably broad.
Disambiguation. A speech recognition system hears an ambiguous audio signal. It might produce two candidate transcriptions: "I saw a van" and "eyes awe of an." A language model knows the first sequence is far more probable and helps the system choose correctly. The acoustic model tells you what sounds were produced; the language model tells you which word sequence those sounds most plausibly correspond to.
Ranking. A machine translation system generates dozens of candidate translations for a source sentence. A language model scores each candidate by how natural it sounds in the target language, allowing the system to prefer fluent output over grammatically awkward but technically accurate alternatives.
Generation. If we can compute for any sequence, we can generate new text by sampling words according to their probabilities given the preceding context. Start from the beginning of a sentence, pick the next word proportionally to its probability, advance the window, and repeat. This same generation mechanism, refined and scaled enormously, underlies modern large language models like GPT-4.
Evaluation. A language model provides a principled way to measure how "natural" a corpus is. Lower perplexity under the model means the text fits the learned distribution more closely, which makes language models useful for detecting out-of-domain text, machine-generated content, or low-quality data.
The Chain Rule of Probability
Computing the probability of a full sequence seems daunting at first. How could we possibly estimate directly? We would need to count how often this exact six-word sequence appears in training data, then divide by the total number of six-word sequences. But most sentences are unique. Even in a corpus of billions of words, we are unlikely to see any specific sentence more than a handful of times. For longer sentences, the chance of observing a particular sequence approaches zero.
The chain rule of probability offers an escape from this dilemma. Instead of estimating the joint probability directly, we decompose it into a product of conditional probabilities. Each conditional probability asks a simpler question: given everything that came before, how likely is the next word?
The chain rule derives from the definition of conditional probability. Conditional probability is defined as:
where:
- : the probability of event occurring given that event has already occurred
- : the joint probability of both and occurring together
- : the probability of event occurring, serving as the normalizing denominator
Rearranging gives the multiplication rule: . The probability of both events equals the probability of the first event times the probability of the second event given the first. For a sequence of words, we apply this rule repeatedly to break down the joint probability into simpler conditional probabilities.
Step 1: Start with the joint probability of all words:
Step 2: Apply the multiplication rule to separate the last word, treating "all words up to " as event and " appears next" as event :
Step 3: Apply the same rule recursively to , peeling off one word at a time, until only remains:
Using product notation, this is written compactly as:
where:
- : the -th word in the sequence
- : the conditional probability of word given all preceding words, the "history"
- : the product operator, which multiplies all terms together
The probability of a sequence equals the probability of the first word, times the probability of the second word given the first, times the probability of the third word given the first two, and so on. Each step conditions on all previously observed words.
This decomposition is mathematically exact, not an approximation. The chain rule holds for any joint distribution, regardless of how complex the dependencies between words are.
Let us see this decomposition in action with a concrete example:
PythonIn [3]Expand
# Illustrate the chain rule decomposition
sentence = "the cat sat on the mat"
words = sentence.split()
# The chain rule breaks the joint probability into conditional probabilities
decomposition = []
for i, word in enumerate(words):
if i == 0:
term = f"P({word})"
else:
context = " ".join(words[:i])
term = f"P({word}|{context})"
decomposition.append(term)ConsoleOut [4]Expand
Sentence: "the cat sat on the mat"
Chain rule decomposition:
P(the cat sat on the mat) =
P(the)
x P(cat|the)
x P(sat|the cat)
x P(on|the cat sat)
x P(the|the cat sat on)
x P(mat|the cat sat on the)Each term in this product answers a progressively more specific question. The first asks: how likely is "the" to start a sentence? The second asks: given we started with "the," how likely is "cat" to follow? By the time we reach the final word, we are asking: given the entire context "the cat sat on the," how likely is "mat"?
The chain rule is mathematically exact, but it creates a severe practical problem. To compute , we need the probability of word given the entire preceding context. For long sentences, this context can be arbitrarily long. A 20-word sentence requires estimating the probability of the 20th word given all 19 preceding words. We will never have enough training data to estimate these long-context probabilities reliably, since most 19-word contexts appear only once, if ever. The number of distinct 19-word contexts in any finite corpus is essentially as large as the corpus itself.
This is where the Markov assumption comes in.
The Markov Assumption
The chain rule gives us an exact decomposition, but it demands something impractical: reliable estimates of probabilities conditioned on arbitrarily long histories. The key insight of n-gram models is to make a simplifying assumption that trades some accuracy for tractability.
The Markov assumption states that the probability of a word depends only on a fixed-length history of preceding words, not the entire sequence. An n-gram model assumes the probability of word depends only on the previous words.
The assumption is named after the Russian mathematician Andrey Markov, who studied stochastic processes with this "memoryless" property in the early 20th century. In a Markov chain, the probability of the next state depends only on the current state, not on the full history of how you arrived there. In the context of language, it means we assume the future depends only on the recent past, not the distant past.
For a unigram model (), each word is treated as completely independent of all others:
The unigram model ignores all context. It assigns the same probability to "cat" whether the preceding words were "the fluffy" or "the entire concept of." This is obviously a poor approximation, but unigrams are useful as a baseline and as a component in more sophisticated models.
For a bigram model (), each word depends only on the single word immediately before it:
where:
- : the true conditional probability given the full history (left side)
- : the bigram approximation using only the immediately preceding word (right side)
- : indicates this is an approximation, not an equality
For a trigram model (), the memory extends to two previous words:
where:
- : the word two positions before the current word
- : the word immediately before the current word
- The context forms a two-word history window
In general, for an n-gram model, the Markov approximation is:
where the context window spans exactly preceding words. We discard information from the distant past to make estimation tractable from finite data.
Why does this help? Consider the difference in what we need to estimate for the sentence "the cat sat on the mat":
- Full history: We need , requiring counts of the specific five-word context.
- Trigram: We need , requiring only counts of the two-word context.
- Bigram: We need , requiring only counts of the single word "the."
Shorter contexts appear far more frequently in training data, giving us more reliable probability estimates. The Markov assumption is an approximation, but it works surprisingly well in practice because language has enough local structure that nearby words carry most of the predictive information. The word that most immediately constrains "mat" is likely "the" (or "on the"), not the first word five positions back.
PythonIn [5]Expand
# Compare full history vs Markov approximations
sentence = "the cat sat on the mat"
words = sentence.split()
approximations = [
(
"Full history (n=6)",
"P(mat | the cat sat on the)",
"exact but impractical",
),
("Trigram (n=3)", "P(mat | on the)", "considers 2 previous words"),
("Bigram (n=2)", "P(mat | the)", "considers 1 previous word"),
("Unigram (n=1)", "P(mat)", "ignores all context"),
]ConsoleOut [6]Expand
Markov Approximations for the final word 'mat': ----------------------------------------------------------------- Full history (n=6): P(mat | the cat sat on the) -> exact but impractical Trigram (n=3): P(mat | on the) -> considers 2 previous words Bigram (n=2): P(mat | the) -> considers 1 previous word Unigram (n=1): P(mat) -> ignores all context
Maximum Likelihood Estimation
With the Markov assumption in hand, we have reduced our problem to estimating conditional probabilities like . But how do we compute these from data?
The most straightforward approach is maximum likelihood estimation (MLE): count how often each n-gram appears in our training corpus, then normalize. For a given context, the MLE probability of the next word is simply the fraction of times that word followed that context in training.
Deriving the MLE Formula
The MLE formula follows directly from the definition of conditional probability. Recall that conditional probability is defined as the joint probability of both events divided by the marginal probability of the conditioning event:
To estimate from training data, we approximate each probability with its empirical relative frequency. The joint probability is approximated by how often the pair occurs, divided by the total number of bigrams. The marginal probability is approximated by how often occurs, divided by the total number of words.
When we compute the ratio, the denominators cancel, and we are left with:
where:
- : the count of times the bigram appears consecutively in the training corpus
- : the count of times the context word appears anywhere in the corpus
- The ratio gives the proportion of occurrences that are followed by
Worked example: If "the" appears 100 times in our corpus and "the cat" appears 8 times, then:
This means 8% of the time "the" is followed by "cat."
For trigrams, we extend the same logic to two-word contexts:
where:
- : the count of the trigram (three consecutive words)
- : the count of the two-word context alone (the bigram prefix)
The general MLE formula for any n-gram order is:
where:
- : the word whose probability we want to estimate
- : the context of words immediately preceding
- : the count of the complete n-gram in the training corpus
- : the count of the -gram context alone
The formula always divides the count of the full n-gram by the count of its -gram prefix. This relative frequency estimate is the maximum likelihood solution because it maximizes the probability of observing the training data under the model. No other choice of conditional probability parameters would assign a higher likelihood to the observed training corpus. If you were to increase any single above the relative frequency estimate, you would have to decrease the probability of other words following that context, and the net effect would be a decrease in training data likelihood.
Implementing MLE Estimation
Let us implement this and see it work on a small corpus:
PythonIn [7]Expand
from collections import Counter
def count_ngrams(tokens, n):
"""Count n-grams in a token sequence."""
ngram_counts = Counter()
for i in range(len(tokens) - n + 1):
ngram = tuple(tokens[i : i + n])
ngram_counts[ngram] += 1
return ngram_counts
# Sample corpus
corpus = """
the cat sat on the mat
the cat ate the fish
the dog sat on the mat
the dog chased the cat
"""
# Tokenize
tokens = corpus.lower().split()
# Count unigrams and bigrams
unigram_counts = count_ngrams(tokens, 1)
bigram_counts = count_ngrams(tokens, 2)ConsoleOut [8]Expand
Corpus tokens: 22
Unique unigrams: 9
Unique bigrams: 14
Top 10 bigrams:
('the', 'cat'): 3
('sat', 'on'): 2
('on', 'the'): 2
('the', 'mat'): 2
('mat', 'the'): 2
('the', 'dog'): 2
('cat', 'sat'): 1
('cat', 'ate'): 1
('ate', 'the'): 1
('the', 'fish'): 1Our small corpus contains 24 tokens but only 9 unique words. The most frequent bigrams involve "the," reflecting its role as the most common English word. Notice the Zipf-like distribution: a few bigrams appear multiple times while most appear only once. This pattern explains why n-gram models struggle with data sparsity. As we move to higher-order n-grams, the long tail grows longer and most n-grams remain unseen even in large corpora.
Now let us apply the MLE formula to compute actual probabilities:
PythonIn [9]Expand
def bigram_probability(w1, w2, bigram_counts, unigram_counts):
"""Calculate P(w2|w1) using MLE."""
bigram = (w1, w2)
unigram = (w1,)
bigram_count = bigram_counts.get(bigram, 0)
unigram_count = unigram_counts.get(unigram, 0)
if unigram_count == 0:
return 0.0
return bigram_count / unigram_count
# Calculate example probabilities
examples = [
("the", "cat"),
("the", "dog"),
("the", "mat"),
("cat", "sat"),
("sat", "on"),
("on", "the"),
]
probabilities = [
(w1, w2, bigram_probability(w1, w2, bigram_counts, unigram_counts))
for w1, w2 in examples
]ConsoleOut [10]Expand
Bigram Probabilities P(w2|w1): --------------------------------------------- P(cat|the) = 3/8 = 0.375 P(dog|the) = 2/8 = 0.250 P(mat|the) = 2/8 = 0.250 P(sat|cat) = 1/3 = 0.333 P(on|sat) = 2/2 = 1.000 P(the|on) = 2/2 = 1.000
The probabilities reveal patterns in our corpus. After "the," both "cat" and "dog" appear with equal probability (25% each), while "mat" also appears 25% of the time. After "sat," the word "on" appears with 100% probability. This reflects the fixed phrase "sat on" that appears in every relevant sentence. After "on," the word "the" follows every time.
These probabilities capture the local structure of our tiny corpus. With only four sentences, these patterns are artifacts of limited data. A larger corpus would reveal more detailed distributions, but the estimation procedure is exactly the same.
We can visualize the complete bigram probability matrix as a heatmap, where each cell shows , the probability of word given context word :
VisualizationOut [11]Expand

The heatmap reveals the sparse structure of bigram probabilities. Most cells are empty (zero probability), indicating word pairs that never occurred in our corpus. The non-zero cells cluster around specific patterns: "the" can be followed by several words, but "sat" is always followed by "on." This sparsity becomes even more pronounced with larger vocabularies and higher-order n-grams. With a real vocabulary of 50,000 words, the matrix would be 50,000 by 50,000 with the overwhelming majority of cells set to zero.
Start and End Tokens
Sentences have beginnings and endings, and a language model needs to capture both. We add special boundary tokens: <s> marks the start of a sentence and </s> marks the end. These tokens allow the model to learn which words typically begin sentences and which words tend to end them.
Without boundary tokens, a bigram model cannot distinguish words that begin sentences from words that appear mid-sentence. If "the" starts every sentence in our corpus, the model should assign high probability to "the" appearing after the sentence boundary. But without a boundary token, there is no special context to condition on for sentence-initial words. The start token <s> provides a synthetic context that captures sentence-initial patterns, enabling the model to compute , the probability of a word starting a sentence.
The end token </s> is equally important. Without it, the model has no mechanism to learn when sentences typically end. A generation system using the model would need an external stopping criterion. With </s> in the vocabulary, the model learns to assign probability to sentence endings, and generation naturally terminates when </s> is sampled.
PythonIn [12]Expand
def add_sentence_markers(text, n):
"""Add start and end markers to sentences."""
sentences = text.strip().split("\n")
marked_sentences = []
for sentence in sentences:
if sentence.strip():
toks = sentence.lower().split()
# Add n-1 start tokens and 1 end token
marked = ["<s>"] * (n - 1) + toks + ["</s>"]
marked_sentences.append(marked)
return marked_sentences
# Process corpus with markers for bigram model
marked_corpus = add_sentence_markers(corpus, n=2)
# Flatten for counting
all_tokens = [token for sentence in marked_corpus for token in sentence]
# Recount with markers
unigram_counts_marked = count_ngrams(all_tokens, 1)
bigram_counts_marked = count_ngrams(all_tokens, 2)ConsoleOut [13]Expand
Sentences with markers (bigram model):
1. <s> the cat sat on the mat </s>
2. <s> the cat ate the fish </s>
3. <s> the dog sat on the mat </s>
4. <s> the dog chased the cat </s>
Bigrams involving markers:
('<s>', 'the'): 4
('</s>', '<s>'): 3
('mat', '</s>'): 2
('fish', '</s>'): 1
('cat', '</s>'): 1The start marker <s> lets us compute , the probability of a word starting a sentence. In our corpus, "the" starts every sentence, so . The end marker </s> lets us model sentence endings: "mat," "fish," and "cat" all end sentences.
For higher-order n-grams, we add multiple start tokens. A trigram model needs two <s> tokens at the beginning to provide context for the first two content words. A 4-gram model needs three start tokens. The general rule: prepend start tokens for an order- model. This ensures that even the very first content words in a sentence have a full context of length available.
Computing Sequence Probabilities
With n-gram probabilities estimated, we can compute the probability of any sequence. We combine the chain rule decomposition with the Markov approximation to get:
where:
- : the total number of words in the sequence
- : the n-gram order (2 for bigrams, 3 for trigrams)
- : the -th word in the sequence
- : the context of words immediately preceding
- : multiply all conditional probabilities together
For a bigram model (), this simplifies to:
where:
- : the probability of the first word given the start-of-sentence marker
- : the probability of each subsequent word given only the single preceding word
Log Probabilities and Numerical Stability
Multiplying many small probabilities leads to numerical underflow: numbers become too small to represent in floating-point arithmetic. A sentence of 300 words, each with probability 0.1, gives , close to the minimum representable double-precision float near . Longer sentences or lower-probability words make this worse. Taking logarithms converts products to sums, keeping values in a manageable range.
Instead of computing a product of probabilities:
we compute a sum of log probabilities:
where:
- : the natural logarithm function (base )
- : sum over all words in the sequence
- : the log probability of each bigram conditional, always a negative number since
This transformation works because of the logarithm's fundamental property: . While individual probabilities might be 0.01 or smaller, their log values are manageable negative numbers (for example, ). To recover the original probability if needed, compute .
PythonIn [14]Expand
import math
def sequence_probability(sentence, bigram_counts, unigram_counts, log=True):
"""Calculate the probability of a sentence using bigram model."""
toks = ["<s>"] + sentence.lower().split() + ["</s>"]
if log:
log_prob = 0.0
for i in range(1, len(toks)):
w1, w2 = toks[i - 1], toks[i]
prob = bigram_probability(w1, w2, bigram_counts, unigram_counts)
if prob == 0:
return float("-inf")
log_prob += math.log(prob)
return log_prob
else:
prob = 1.0
for i in range(1, len(toks)):
w1, w2 = toks[i - 1], toks[i]
prob *= bigram_probability(w1, w2, bigram_counts, unigram_counts)
return prob
# Test sentences
test_sentences = [
"the cat sat on the mat",
"the dog sat on the mat",
"the cat chased the dog",
"the fish ate the cat",
]
results = [
(
s,
sequence_probability(
s, bigram_counts_marked, unigram_counts_marked, log=True
),
)
for s in test_sentences
]ConsoleOut [15]Expand
Sentence Log-Probabilities (bigram model): ------------------------------------------------------------ log P = -3.47 "the dog sat on the mat" log P = -3.47 "the cat sat on the mat" log P = -inf "the cat chased the dog" log P = -inf "the fish ate the cat"
VisualizationsOut [16]Expand


The visualization confirms why log probabilities are essential. With an average per-word probability of 0.1, raw probabilities approach the limits of floating-point representation at around 300 words. Log probabilities decrease linearly and remain tractable for sequences of any length.
The Zero Probability Problem
What happens when we encounter an n-gram that never appeared in training?
PythonIn [17]Expand
# Test with a sentence containing unseen bigrams
unseen_sentence = "the bird flew over the house"
unseen_tokens = ["<s>"] + unseen_sentence.lower().split() + ["</s>"]
# Check each bigram
bigram_analysis = []
for i in range(1, len(unseen_tokens)):
w1, w2 = unseen_tokens[i - 1], unseen_tokens[i]
bigram = (w1, w2)
count = bigram_counts_marked.get(bigram, 0)
prob = bigram_probability(
w1, w2, bigram_counts_marked, unigram_counts_marked
)
bigram_analysis.append((w1, w2, count, prob))ConsoleOut [18]Expand
Analyzing: "the bird flew over the house" ------------------------------------------------------- (<s>, the): count=4, P=1.000 [OK] (the, bird): count=0, P=0.000 [UNSEEN] (bird, flew): count=0, P=0.000 [UNSEEN] (flew, over): count=0, P=0.000 [UNSEEN] (over, the): count=0, P=0.000 [UNSEEN] (the, house): count=0, P=0.000 [UNSEEN] (house, </s>): count=0, P=0.000 [UNSEEN] Total probability: 0.0
A single unseen bigram makes the entire sequence probability zero. This is the zero probability problem: perfectly valid sentences receive zero probability just because our training data did not contain every possible word combination. The sentence "the bird flew over the house" is entirely plausible English, but our tiny corpus knows nothing about birds or flying. The MLE estimate assigns probability zero to any n-gram not seen in training, and since sequence probability is a product of these terms, any zero drives the whole product to zero.
The deeper issue is that zero probability is semantically wrong, in addition to being numerically inconvenient. A language model that assigns zero probability to valid English sentences fundamentally misrepresents the distribution of language. A well-calibrated language model should assign some small but non-zero probability to any grammatical sequence. This brittleness motivates smoothing techniques, covered in the next chapter. Raw MLE estimates are too brittle for real applications since any test sentence containing an unseen n-gram collapses to zero probability regardless of its plausibility.
Evaluating Language Models: Perplexity
So far we have talked about probability as the goal of language modeling, but how do we measure whether one language model is better than another? We need an evaluation metric that captures how well a model assigns probability to new, held-out text.
The most widely used evaluation metric for language models is perplexity. Perplexity measures how "surprised" a model is by new text. A model that assigns high probability to the test data is not very surprised, giving low perplexity. A model that assigns low probability to the test data is very surprised, giving high perplexity.
Defining Perplexity
Perplexity is defined as the inverse probability of the test set, normalized by the number of words. For a test sequence of words, perplexity is:
where:
- : the probability assigned to the test sequence by the model
- : the exponent normalizes for sequence length, so longer sequences are comparable to shorter ones
- The outer operation takes the -th root (or equivalently, raises to the power )
Expanding the sequence probability using the chain rule, perplexity becomes:
Using the n-gram Markov approximation for a bigram model:
This is equivalent to the geometric mean of the inverse bigram probabilities. Minimizing perplexity is equivalent to maximizing the log probability of the test set per word.
In practice, we compute perplexity in log space to avoid numerical underflow:
and then exponentiate:
where:
- : number of words in the test set
- : log probability of each word given its context (a negative number)
- The average negative log probability gives the per-word entropy in nats
- Exponentiating converts entropy back to a perplexity value
Interpreting Perplexity
Perplexity has an intuitive interpretation as the effective branching factor of the language model. If every word has exactly the same number of equally likely continuations, perplexity equals that branching factor. A perplexity of 100 means the model is as confused as if it were choosing uniformly among 100 equally likely options at each step.
Consider the extremes:
- Perplexity = 1: The model is perfectly certain about every word. Given the context, only one continuation is possible. This happens when text is entirely predictable (like a model evaluated on its own training data with no unseen n-grams).
- Perplexity = V (vocabulary size): The model is completely uncertain, equivalent to a uniform distribution over all vocabulary words. This happens when context provides no information.
- Perplexity in between: Real language models fall somewhere between these extremes. A good bigram model on English text might achieve a perplexity of 50 to 200, depending on the corpus and vocabulary.
Lower perplexity is always better, but the absolute value matters less than relative comparisons between models evaluated on the same test set.
PythonIn [19]Expand
def perplexity(model, test_sentences):
"""Calculate perplexity of a language model on test sentences."""
total_log_prob = 0.0
total_words = 0
for sentence in test_sentences:
toks = sentence.lower().split()
n = model.n
padded = ["<s>"] * (n - 1) + toks + ["</s>"]
for i in range(n - 1, len(padded)):
context = tuple(padded[i - n + 1 : i])
word = padded[i]
prob = model.probability(word, context)
if prob <= 0:
# If any word has zero probability, perplexity is undefined
return float("inf")
total_log_prob += math.log(prob)
total_words += 1
# Perplexity = exp(-average log probability)
avg_log_prob = total_log_prob / total_words
return math.exp(-avg_log_prob)Building a Complete N-gram Language Model Class
Let us build a reusable n-gram language model class that encapsulates everything we have covered so far:
PythonIn [20]Expand
class NgramLanguageModel:
"""An n-gram language model with MLE estimation."""
def __init__(self, n=2):
self.n = n
self.ngram_counts = Counter()
self.context_counts = Counter()
self.vocab = set()
def train(self, text):
"""Train the model on text."""
sentences = text.strip().split("\n")
for sentence in sentences:
if not sentence.strip():
continue
toks = sentence.lower().split()
toks = ["<s>"] * (self.n - 1) + toks + ["</s>"]
self.vocab.update(toks)
for i in range(len(toks) - self.n + 1):
ngram = tuple(toks[i : i + self.n])
context = tuple(toks[i : i + self.n - 1])
self.ngram_counts[ngram] += 1
self.context_counts[context] += 1
def probability(self, word, context):
"""Calculate P(word|context) using MLE."""
if isinstance(context, str):
context = tuple(context.split())
ngram = context + (word,)
ngram_count = self.ngram_counts.get(ngram, 0)
context_count = self.context_counts.get(context, 0)
if context_count == 0:
return 0.0
return ngram_count / context_count
def sentence_log_probability(self, sentence):
"""Calculate log probability of a sentence."""
toks = sentence.lower().split()
toks = ["<s>"] * (self.n - 1) + toks + ["</s>"]
log_prob = 0.0
for i in range(self.n - 1, len(toks)):
context = tuple(toks[i - self.n + 1 : i])
word = toks[i]
prob = self.probability(word, context)
if prob == 0:
return float("-inf")
log_prob += math.log(prob)
return log_probThe NgramLanguageModel class encapsulates n-gram counting, context tracking, and probability computation. The train() method processes text and builds count tables. The probability() method returns using MLE. The sentence_log_probability() method computes the log probability of an entire sentence.
Let us train models of different orders on a larger corpus and compare their perplexity:
PythonIn [21]Expand
# Larger training corpus
training_corpus = """
the cat sat on the mat
the cat ate the fish
the dog sat on the mat
the dog chased the cat
the cat ran from the dog
the dog ate the food
the bird flew over the tree
the bird sat on the branch
the cat watched the bird
the dog watched the cat
"""
# Train unigram, bigram, and trigram models
unigram_model = NgramLanguageModel(n=1)
bigram_model = NgramLanguageModel(n=2)
trigram_model = NgramLanguageModel(n=3)
unigram_model.train(training_corpus)
bigram_model.train(training_corpus)
trigram_model.train(training_corpus)
# Compare on test sentences
test_sentences = [
"the cat sat on the mat",
"the dog chased the cat",
"the bird flew over the tree",
"the cat ate the bird",
]
model_comparison = []
for sentence in test_sentences:
scores = {
"unigram": unigram_model.sentence_log_probability(sentence),
"bigram": bigram_model.sentence_log_probability(sentence),
"trigram": trigram_model.sentence_log_probability(sentence),
}
model_comparison.append((sentence, scores))ConsoleOut [22]Expand
Model Comparison (log probabilities): ======================================================================== Sentence Unigram Bigram Trigram ------------------------------------------------------------------------ the cat sat on the mat -16.24 -5.30 -3.11 the dog chased the cat -13.35 -5.30 -3.62 the bird flew over the tree -19.83 -5.99 -2.71 the cat ate the bird -13.17 -5.99 -inf
PythonIn [23]Expand
# Compute perplexity on training data (where no zero probabilities exist)
# We use training data here since our tiny corpus has many unseen n-grams even on test
train_sentences_list = [
s for s in training_corpus.strip().split("\n") if s.strip()
]
pp_uni = perplexity(unigram_model, train_sentences_list)
pp_bi = perplexity(bigram_model, train_sentences_list)
pp_tri = perplexity(trigram_model, train_sentences_list)ConsoleOut [24]Expand
Perplexity on Training Data: ---------------------------------------- Unigram model: 10.95 Bigram model: 2.41 Trigram model: 1.74 Lower perplexity = better model fit. Trigram achieves best fit by using more context.
The results reveal key differences between model orders. The unigram model assigns similar probabilities to all sentences because it ignores word order entirely. The bigram model shows more variation, penalizing sentences with rare or unseen word transitions. The trigram model provides the finest discrimination. On training data, perplexity decreases with increasing n-gram order because more context allows more precise predictions. On held-out data with a small training corpus, this pattern can reverse if higher-order n-grams are too sparse.
Perplexity and Entropy
Perplexity has a deep connection to information theory. The entropy of a language model is:
where:
- : the per-word entropy in bits
- : total number of words in the test set
- : logarithm base 2 converts to bits
- The entropy measures the average number of bits needed to encode each word given perfect context
Perplexity is simply when using log base 2, or when using natural logarithms. A perplexity of 100 corresponds to an entropy of about 6.6 bits per word, meaning the model is about as uncertain as if it had to choose uniformly among 100 equally likely options at each step.
This connection to information theory has a practical interpretation: a model with perplexity requires approximately bits per word to encode text under that model. Lower perplexity means more compressible text, which means the model has captured more of the statistical regularities in the language.
Higher-Order N-gram Tradeoffs
Choosing the right n-gram order involves a fundamental tradeoff: more context yields better predictions, but more context also means sparser counts and more zero probabilities.
A unigram model treats every word independently and assigns the same probability to "the cat" and "the mat." A bigram model knows that "cat" frequently follows "the" but "xkcd" rarely does. A trigram model additionally knows that "sat on the" is a common pattern while "flew under the" is unusual in most corpora. A 4-gram model can capture even longer dependencies, like the fact that "I would like to" commonly continues with a verb.
As n-gram order increases, the number of possible n-grams grows exponentially:
where:
- : the vocabulary size (number of unique word types)
- : the n-gram order
- : exponential growth, so each additional word of context multiplies the parameter count by
For English with words and (trigrams), we have potential trigrams. Even storing just one byte per trigram would require a petabyte of memory. Fortunately, the vast majority of combinations never occur in real text, so sparse storage structures (dictionaries or tries) are practical.
The deeper problem is not storage but coverage. Even with enormous corpora, higher-order n-grams remain sparse:
| N-gram Order | Model Type | Approx. Test Coverage | Notes |
|---|---|---|---|
| 1 | Unigram | ~98% | Almost all words seen in training |
| 2 | Bigram | ~85% | Most word pairs covered |
| 3 | Trigram | ~62% | Significant gaps emerge |
| 4 | 4-gram | ~35% | Majority unseen |
| 5 | 5-gram | ~15% | Severe sparsity |
The coverage statistics explain why simply using more context is not an obvious win. Yes, a 5-gram model can condition on four words of context, but 85% of the time it will encounter a 5-gram it has never seen and must fall back to some default. In practice, trigram models () were the standard choice for production NLP systems before neural language models, balancing context length against manageable sparsity.
VisualizationsOut [25]Expand


The plots use our small training corpus but illustrate the key pattern: as order increases, both the count of observed n-grams grows (because there are more distinct longer sequences) and coverage of the theoretical maximum collapses (because the space grows much faster than the data).
Model Storage and Efficiency
PythonIn [26]Expand
def estimate_model_size(model):
"""Estimate memory usage of an n-gram model."""
num_ngrams = len(model.ngram_counts)
num_contexts = len(model.context_counts)
vocab_size = len(model.vocab)
# Each n-gram tuple holds string references (~50 bytes each) plus count
ngram_memory = num_ngrams * (model.n * 50 + 28)
context_memory = num_contexts * ((model.n - 1) * 50 + 28)
vocab_memory = vocab_size * 50
total_bytes = ngram_memory + context_memory + vocab_memory
return {
"num_ngrams": num_ngrams,
"num_contexts": num_contexts,
"vocab_size": vocab_size,
"estimated_mb": total_bytes / (1024 * 1024),
}
sizes = {
"Unigram": estimate_model_size(unigram_model),
"Bigram": estimate_model_size(bigram_model),
"Trigram": estimate_model_size(trigram_model),
}ConsoleOut [27]Expand
Model Storage Statistics: ------------------------------------------------------------ Model N-grams Contexts Vocab Est. MB ------------------------------------------------------------ Unigram 19 1 19 0.0023 Bigram 36 19 20 0.0068 Trigram 44 29 20 0.0120
Our toy corpus produces tiny models measured in kilobytes. Real-world models trained on billions of words require a completely different approach to storage. Production systems typically use trie-based storage, where each path from the root to a node represents an n-gram prefix and nodes store counts and probabilities. Tries share prefixes between n-grams, reducing memory and enabling efficient prefix queries. The ARPA language model format, used by tools like KenLM and SRILM, stores n-gram counts and back-off weights in a text format that most speech recognition and MT systems can read. KenLM further compresses this using sorted arrays, reducing a multi-gigabyte text file to a few hundred megabytes in binary format.
Generating Text from N-gram Models
N-gram models can generate new text by sampling words according to their conditional probabilities. Starting from the start token, we repeatedly sample the next word given the current context, then advance the context window by one position.
The generation algorithm is simple: maintain a sliding window of the last words (the context), look up all words that have appeared after that context in training, sample from the distribution, add the sampled word to the output and to the context window, and repeat until </s> is generated or a maximum length is reached.
PythonIn [28]Expand
import random
def generate_sentence(model, max_length=20, seed=None):
"""Generate a sentence from an n-gram model by sampling."""
if seed is not None:
random.seed(seed)
toks = ["<s>"] * (model.n - 1) if model.n > 1 else []
for _ in range(max_length):
context = tuple(toks[-(model.n - 1) :]) if model.n > 1 else ()
candidates = []
for ngram, count in model.ngram_counts.items():
if ngram[:-1] == context:
word = ngram[-1]
prob = model.probability(word, context)
candidates.append((word, prob))
if not candidates:
break
words, probs = zip(*candidates)
next_word = random.choices(words, weights=probs, k=1)[0]
if next_word == "</s>":
break
toks.append(next_word)
start = model.n - 1 if model.n > 1 else 0
return " ".join(toks[start:])
generated_bigram = [generate_sentence(bigram_model, seed=i) for i in range(5)]
generated_trigram = [generate_sentence(trigram_model, seed=i) for i in range(5)]ConsoleOut [29]Expand
Generated sentences (bigram model): -------------------------------------------------- 1. the bird sat on the fish 2. the bird 3. the tree 4. the dog chased the dog sat on the cat ate the dog watched the dog sat on the dog ate 5. the cat Generated sentences (trigram model): -------------------------------------------------- 1. the dog 2. the bird 3. the bird flew over the tree 4. the dog chased the cat sat on the mat 5. the cat
The bigram model generates sentences that reflect local word patterns from the training data. The trigram model maintains coherence over slightly longer distances since it sees two words of context at each step. Both models tend to produce sentences similar to those in the training corpus: this is a feature, not a bug, since the models have learned the statistical patterns of the training text. With a larger and more diverse training corpus, the generated text would reflect the full variety of patterns the model has seen.
Temperature Sampling
We can control the randomness of generation with a temperature parameter . Temperature scaling adjusts the probability distribution over next words before sampling, sharpening it (more deterministic) or flattening it (more exploratory).
The idea is to scale log probabilities by dividing by before re-normalizing:
where:
- : the original probability of word from the language model
- : the temperature parameter, a positive real number typically between 0.1 and 2.0
- : dividing the log probability by amplifies or compresses differences between words
- : converts the scaled log probability back to a positive number for normalization
- The denominator normalizes the result so all probabilities sum to 1
The temperature controls the shape of the distribution:
- (low temperature): Differences in log probability are amplified, sharpening the distribution and concentrating probability on the most likely words.
- : Uses the original probabilities unchanged.
- (high temperature): Differences are compressed, flattening the distribution and giving rare words more chance.
PythonIn [30]Expand
def generate_with_temperature(model, temperature=1.0, max_length=20, seed=None):
"""Generate with temperature-scaled probabilities."""
if seed is not None:
random.seed(seed)
toks = ["<s>"] * (model.n - 1) if model.n > 1 else []
for _ in range(max_length):
context = tuple(toks[-(model.n - 1) :]) if model.n > 1 else ()
candidates = []
for ngram, count in model.ngram_counts.items():
if ngram[:-1] == context:
word = ngram[-1]
prob = model.probability(word, context)
candidates.append((word, prob))
if not candidates:
break
words, probs = zip(*candidates)
if temperature != 1.0:
log_probs = [math.log(p + 1e-10) / temperature for p in probs]
max_log = max(log_probs)
scaled_probs = [math.exp(lp - max_log) for lp in log_probs]
total = sum(scaled_probs)
probs = tuple(p / total for p in scaled_probs)
next_word = random.choices(words, weights=probs, k=1)[0]
if next_word == "</s>":
break
toks.append(next_word)
start = model.n - 1 if model.n > 1 else 0
return " ".join(toks[start:])
temperatures = [0.5, 1.0, 1.5]
temp_samples = {
t: [
generate_with_temperature(bigram_model, temperature=t, seed=i)
for i in range(3)
]
for t in temperatures
}ConsoleOut [31]Expand
Effect of Temperature on Generation: ============================================================ Temperature = 0.5: 1. the dog 2. the food 3. the bird flew over the dog ate the dog ate the cat Temperature = 1.0: 1. the bird sat on the fish 2. the bird 3. the tree Temperature = 1.5: 1. the bird sat on the fish 2. the bird 3. the branch
At low temperatures, the model gravitates toward the most common patterns, often producing the same output regardless of the random seed because the probability mass is concentrated on a single continuation. At high temperatures, it explores less likely paths and sometimes produces more creative but less coherent output. This temperature parameter appears throughout modern language modeling, including GPT-style systems. The same mathematical operation, scaling log probabilities before softmax, is used identically in transformer decoders.
VisualizationsOut [32]Expand



The plots illustrate how temperature reshapes probability distributions. At T=0.5, the highest-probability word dominates the distribution. At T=1.5, the distribution flattens and rare words have a much better chance of being selected. This trade-off between coherence and diversity reappears in transformer-based language models, where it is controlled by exactly the same mechanism.
Practical Implementation with NLTK
NLTK provides a well-tested n-gram language modeling module that handles the bookkeeping of counting, vocabulary management, and probability computation:
PythonIn [33]Expand
import nltk
from nltk.lm import MLE
from nltk.lm.preprocessing import padded_everygram_pipeline
# Download required tokenizer data
try:
nltk.data.find("tokenizers/punkt_tab")
except LookupError:
nltk.download("punkt_tab", quiet=True)
# Prepare training sentences as lists of tokens
train_sentences = [
s.lower().split() for s in training_corpus.strip().split("\n") if s.strip()
]
# padded_everygram_pipeline generates all n-grams up to order n
# and handles <s> / </s> padding automatically
n = 3
train_data, padded_vocab = padded_everygram_pipeline(n, train_sentences)
nltk_model = MLE(n)
nltk_model.fit(train_data, padded_vocab)ConsoleOut [34]Expand
NLTK 3-gram MLE model trained Vocabulary size: 21
PythonIn [35]Expand
# Query probabilities from the NLTK model
test_contexts = [
["the"],
["the", "cat"],
["sat", "on"],
]
nltk_results = []
for context in test_contexts:
probs = {}
for word in nltk_model.vocab:
try:
p = nltk_model.score(word, context)
if p > 0:
probs[word] = p
except Exception:
pass
top_words = sorted(probs.items(), key=lambda x: -x[1])[:5]
nltk_results.append((context, top_words))ConsoleOut [36]Expand
NLTK Model Top Predictions: -------------------------------------------------- Context: ['the'] P(cat) = 0.300 P(dog) = 0.250 P(bird) = 0.150 P(mat) = 0.100 P(fish) = 0.050 Context: ['the', 'cat'] P(</s>) = 0.333 P(sat) = 0.167 P(ate) = 0.167 P(ran) = 0.167 P(watched) = 0.167 Context: ['sat', 'on'] P(the) = 1.000
The NLTK predictions match what we expect from our training data. Given ["the"], the model predicts words like "cat," "dog," and "bird" that frequently follow "the." Given ["sat", "on"], the model strongly predicts "the." NLTK's padded_everygram_pipeline handles the boundary token logic automatically. The MLE class implements the same count-based formula we derived earlier.
Key Parameters
The key parameters for n-gram language models are:
-
n (n-gram order): Controls how many words of context the model considers. Unigrams () ignore context entirely. Bigrams () use one word of context. Trigrams () were the production standard before neural models. Higher values require exponentially more training data.
-
temperature: Controls randomness during text generation. Values below 1.0 make generation more deterministic. Values above 1.0 make generation more diverse. Values between 0.7 and 1.2 work well for most applications.
-
start and end tokens: The
<s>token provides context for sentence-initial words; prepend start tokens for an order- model. The</s>token allows the model to learn which words end sentences. -
vocabulary handling: Case normalization reduces vocabulary size but loses capitalization information. An unknown token
<UNK>handles out-of-vocabulary words at inference time. A minimum frequency threshold excludes rare words.
A Worked Example: From Corpus to Probability
Let us walk through the complete process step by step with a concrete example, starting from a raw sentence and ending with a sequence probability. This ties together everything from this chapter.
Setup. Suppose we train a bigram model on the sentence "the cat sat on the mat" (treated as both training and test for illustration). We want to compute the probability of the new sentence "the cat sat on the mat" under the learned model.
Step 1: Tokenize and add markers.
The sentence becomes: <s> the cat sat on the mat </s>
Step 2: Count unigrams.
From "the cat sat on the mat":
- the: 2
- cat: 1
- sat: 1
- on: 1
- mat: 1
Step 3: Count bigrams (with markers).
<s>the: 1- the cat: 1
- cat sat: 1
- sat on: 1
- on the: 1
- the mat: 1
- mat
</s>: 1
Step 4: Apply MLE formula.
Step 5: Multiply the conditional probabilities.
Step 6: Compute log probability.
Step 7: Compute perplexity.
The sequence has 6 content words plus the end token, so :
This is remarkably low perplexity, essentially because the model was evaluated on its own training sentence. In real evaluation, we always separate training and test data.
Let us verify this programmatically:
PythonIn [37]Expand
# Verify the worked example
example_corpus = "the cat sat on the mat"
example_model = NgramLanguageModel(n=2)
example_model.train(example_corpus)
# Walk through each bigram probability
example_toks = ["<s>"] + example_corpus.split() + ["</s>"]
print_probs = []
for i in range(1, len(example_toks)):
w1, w2 = example_toks[i - 1], example_toks[i]
context = (w1,)
ngram = (w1, w2)
ngram_c = example_model.ngram_counts.get(ngram, 0)
context_c = example_model.context_counts.get(context, 0)
prob = example_model.probability(w2, context)
print_probs.append((w1, w2, ngram_c, context_c, prob))ConsoleOut [38]Expand
Step-by-step bigram probability computation: ----------------------------------------------------------------- Context Word C(bigram) C(context) P ----------------------------------------------------------------- <s> the 1 1 1.0000 the cat 1 2 0.5000 cat sat 1 1 1.0000 sat on 1 1 1.0000 on the 1 1 1.0000 the mat 1 2 0.5000 mat </s> 1 1 1.0000 Product (P(sentence)) = 0.2500 Log P = -1.3863 Perplexity (N=7) = 1.2190
The computed values match our manual calculation exactly. Notice that "the" has context count 2 because it appears twice in the training sentence, which creates the non-trivial probability of 0.5 for and .
Smoothing: A Preview
The zero probability problem is so central to n-gram language modeling that it demands a direct solution. Before the next chapter covers smoothing techniques in depth, let us preview the core idea.
The fundamental problem is that MLE assigns probability zero to any n-gram not seen in training. But in a finite corpus, many perfectly valid n-grams simply never occurred by chance. The solution is smoothing: redistribute some probability mass from seen n-grams to unseen ones.
Add-One (Laplace) Smoothing
The simplest smoothing technique is add-one smoothing (also called Laplace smoothing): pretend we saw every n-gram one extra time. This is equivalent to adding a pseudocount of 1 to every possible n-gram count:
where:
- : the actual count of the bigram in training
- : the actual count of the context word in training
- : the vocabulary size (number of unique words)
- Adding 1 to the numerator gives every bigram at least one pseudo-observation
- Adding to the denominator ensures probabilities still sum to 1 (since there are possible next words, each getting +1)
For our earlier example of "the bird flew over the house," add-one smoothing would assign:
This is small but non-zero, which saves the entire sentence from a zero probability. The approach works in principle but has a known flaw: it allocates far too much probability mass to unseen events. For a vocabulary of 50,000 words, adding to every context count dramatically dilutes the probability of observed events. More sophisticated smoothing methods like Kneser-Ney interpolation do a much better job of distributing probability to unseen events, and that is the subject of the next chapter.
PythonIn [39]Expand
def laplace_bigram_probability(
w1, w2, bigram_counts, unigram_counts, vocab_size
):
"""P(w2|w1) with add-one (Laplace) smoothing."""
bigram = (w1, w2)
unigram = (w1,)
bigram_count = bigram_counts.get(bigram, 0)
unigram_count = unigram_counts.get(unigram, 0)
return (bigram_count + 1) / (unigram_count + vocab_size)
# Compare MLE vs Laplace on seen and unseen bigrams
vocab_size = len(unigram_counts_marked)
comparison_pairs = [
("the", "cat"), # seen bigram
("sat", "on"), # seen bigram (probability 1.0 under MLE)
("the", "bird"), # unseen in original corpus
("cat", "flew"), # unseen
]ConsoleOut [40]Expand
MLE vs. Laplace Smoothing: ------------------------------------------------------------ Bigram MLE P Laplace P ------------------------------------------------------------ (the, cat) [seen] 0.3750 0.2105 (sat, on) [seen] 1.0000 0.2308 (the, bird) [unseen] 0.0000 0.0526 (cat, flew) [unseen] 0.0000 0.0714
Laplace smoothing solves the zero-probability problem: unseen bigrams now get a small but non-zero probability. However, observe that the Laplace probability for "sat, on" (which appeared with probability 1.0 under MLE) drops dramatically. This is the core tension: any probability mass given to unseen events must come from somewhere, and naive Laplace smoothing takes too much from the seen events. The next chapter shows how to do this redistribution more carefully.
Limitations and Impact
N-gram models face fundamental limitations that explain why they were eventually supplanted by neural approaches, but also historical significance that earns them careful study.
The Sparsity Problem
The most severe limitation is data sparsity. Most possible n-grams never appear in training data, even in corpora of billions of words. This is not a problem that more data fully solves: as n-gram order increases, the number of unseen combinations grows exponentially. Even with 1 trillion words of training text, trigrams for technical or domain-specific language will have coverage gaps. The LDC English Gigaword corpus, a standard benchmark with about 4.3 billion words, covers only a fraction of possible English trigrams. A model trained on the full web, with hundreds of billions of words, still encounters unseen trigrams regularly.
The sparsity problem has a deeper mathematical explanation. The true distribution of language assigns non-zero probability to an enormous number of grammatically valid sentences, most of which will never appear in any finite training corpus. Any model trained by counting observations in finite data fundamentally underestimates the variety of natural language.
Long-Range Dependencies
The Markov assumption is a second major limitation. Real language has long-range dependencies that exceed any practical context window. Consider the sentence "The cat that the dog had been chasing for three blocks finally escaped." The verb "escaped" agrees with "cat," but the subject and verb are separated by 10 words. A trigram model sees only "blocks finally escaped" and cannot make that connection. More subtly, pronoun resolution ("She arrived late. The professor was angry with her."), discourse coherence, and topic tracking all require understanding relationships across sentences, not just consecutive words.
The history of NLP is partly a history of attempts to overcome this limitation. Long short-term memory networks (LSTMs) extended the context window by learning to remember relevant information from many steps back. Transformer models addressed it more directly by computing attention over the entire sequence simultaneously, allowing any word to directly access any other word regardless of distance.
Vocabulary Generalization
N-gram models also fail to generalize across similar words. The model treats "cat," "feline," and "kitty" as entirely unrelated symbols. If "the feline sat" was never seen in training, it gets zero probability, even though "the cat sat" appeared many times. Knowledge learned about one word does not transfer to synonyms or semantically related words.
This is the word representation problem: n-gram models use discrete symbols with no notion of similarity or meaning. The solution, word embeddings, was not directly invented to fix n-gram models, but the intuition that similar words should have similar representations emerged partly from recognizing this gap. We discuss word embeddings extensively in later chapters.
What N-gram Models Enabled
Despite these limitations, n-gram models enabled important applications before neural networks took over, and they did so at scales that were practical with 1990s and 2000s hardware.
Speech recognition. Google Voice, Apple Siri (in its early forms), and every voice interface from that era used trigram language models to disambiguate acoustically similar phrases. The acoustic model provides probability distributions over phonemes; the language model rescores those hypotheses to favor plausible word sequences. The combination of a strong acoustic model and a good language model was the dominant paradigm for about two decades.
Statistical machine translation. Systems like Google Translate's early versions used phrase-based statistical MT, which generated candidate translations using phrase tables and scored their fluency using a trigram language model. The language model ensured that the output sounded like natural target-language text even when the phrase-by-phrase translation produced awkward word orders.
Spelling correction. Typing "teh" gets corrected to "the" partly because a character-level language model knows "teh" is not a word, and partly because a word-level language model knows which correction makes the surrounding sequence more probable.
Autocomplete. Early autocomplete systems in smartphones and text editors predicted likely next words using bigram or trigram language models. The experience of a phone typing "on my way" after "I am" emerged from exactly the kind of conditional probability estimation we implemented in this chapter.
The concepts introduced by n-gram models remain central to modern language modeling: the chain rule decomposition, the tension between context length and data sparsity, maximum likelihood estimation from counts, perplexity as an evaluation metric, and temperature-based sampling. Understanding n-grams is not just historical background. It provides the conceptual foundation for the neural approaches covered throughout the rest of this handbook. The chain rule is how neural language models are also trained and evaluated. Perplexity is the standard metric by which GPT-4 and its contemporaries are compared on held-out benchmarks. Temperature sampling, which we implemented here in a few lines, is the same parameter you adjust when calling the OpenAI API.
Comparison with Neural Language Models
The contrast with neural language models is sharp and instructive. Consider what a neural language model does differently:
Parameter sharing across contexts. An n-gram model stores separate probabilities for every context it has seen. A neural model learns a shared function from context to probability, which means patterns learned in one context generalize to similar contexts. If the model learns that "the fluffy cat" likely continues with verbs about cat behavior, it can generalize this to "the fluffy kitten" without having seen that trigram.
Dense representations. Neural models map words to dense vectors in a continuous space, where similar words have similar vectors. This allows semantic generalization: information about "cat" contributes to predictions about "kitten" because their vector representations are nearby.
Unlimited context. Transformer-based language models attend to the entire context window simultaneously, up to thousands or tens of thousands of tokens. There is no fixed context length and no exponential blowup in parameters as context grows. The attention mechanism handles variable-length dependencies directly.
Scalability. Neural language models can use the same compute hardware (GPUs and TPUs) as other deep learning work. Training efficiency, along with data requirements, has driven the rise of neural approaches.
The transition from n-gram models to neural models was not sudden. For years, researchers used neural language models as components within larger hybrid systems that also included n-gram components. The neural models handled long-range dependencies while the n-gram models provided reliable low-order estimates. It was only after the Transformer architecture and large-scale pretraining made pure neural approaches dominate on every benchmark that n-gram models were fully retired from production use.
Summary
N-gram language models assign probabilities to word sequences by decomposing joint probabilities into conditional probabilities using the chain rule. The Markov assumption limits the conditioning context to the previous words, making estimation tractable from finite data.
Key takeaways:
- Chain rule decomposition breaks sequence probability into a product of conditional probabilities: .
- Markov assumption limits history to words, trading long-range accuracy for tractability.
- Maximum likelihood estimation computes probabilities as normalized n-gram counts: the count of the full n-gram divided by the count of its -gram prefix.
- Start and end tokens model sentence boundaries explicitly, with start tokens for an order- model.
- Log probabilities prevent numerical underflow when multiplying many small values.
- Zero probability problem occurs when test data contains unseen n-grams, motivating smoothing.
- Perplexity measures model quality as the inverse per-word probability: lower perplexity means the model assigns higher probability to test text. Perplexity equals where is per-word entropy in bits.
- Text generation samples words from conditional distributions, with temperature controlling the sharpness of the distribution.
- Higher-order n-grams capture more context but suffer exponentially increasing sparsity, with possible n-grams for vocabulary size .
- Sparse storage using dictionaries or tries makes large models practical.
- Smoothing (Laplace and more sophisticated methods) addresses zero probabilities by redistributing probability mass from seen to unseen n-grams.
N-gram models reveal the fundamental tension in language modeling: longer context improves predictions but requires exponentially more data. The next chapter on Smoothing Techniques addresses the zero probability problem directly, showing how to assign non-zero probabilities to unseen n-grams while still respecting patterns learned from training data. Understanding these classical solutions provides essential background for appreciating why neural language models, despite their greater complexity, represent such a decisive advance.
N-gram Language Models Quiz
Join the discussionAsk a question or share what you learned.
Keep exploring
Reference
Citation details
Cite or share this article.


Comments
Add to the conversation
A question, a correction, or an example from your own work.
No comments yet. Be the first to share your thoughts!