N-grams: Capturing Word Order in Text
Covers n-gram text representations including bigrams, trigrams, character n-grams, and skip-grams. Topics include extraction techniques.
Language AI HandbookAll chapters N-gramsFull chapter · Sections in this chapter1 / 16 ↓
Reading tools Definitions, links & notes
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-grams
Language does not work word by word. The meaning of any given word almost always depends on what surrounds it. When you read "I need to book a flight," the word "book" is a verb. When you read "hand me that book," it is a noun. You understood the difference not from the word itself but from the words nearby. This dependence on local context is one of the most fundamental properties of natural language, and n-grams are the simplest tool that captures it.
As we saw in the previous chapter on bag of words, representing a document as a multiset of individual word counts discards every piece of word-order information. The sentence "The dog bit the man" and the sentence "The man bit the dog" produce identical bag-of-words vectors, even though they describe opposite events. For many classification tasks, word-order loss is acceptable and the simplicity of bag of words is worth the trade-off. But as soon as meaning depends on sequence, we need a richer representation.
N-grams offer a direct extension of bag of words: instead of treating single words as atomic units, we treat fixed-length sequences of consecutive words as atomic units. An n-gram is a contiguous sequence of items drawn from a text, where items can be words, characters, syllables, or any other tokens. When we work with words, we call them word n-grams. When we work with characters, we call them character n-grams. The special cases have familiar names: a 1-gram is a unigram (the same as standard bag of words), a 2-gram is a bigram, and a 3-gram is a trigram.
The intuition is straightforward: phrases like "machine learning," "New York," "not good," and "highly recommend" carry meaning as units that their individual words cannot convey in isolation. "Machine" and "learning" are common words on their own, but together they signal a technical domain. "Not" and "good" are individually neutral in many contexts, but as a bigram they express sentiment that would be lost if you only counted the word "good." N-grams capture this by including multi-word sequences in the vocabulary.
This chapter explores how n-grams preserve local word order, why vocabulary size grows explosively with n, and how the statistical properties of n-grams follow predictable mathematical patterns. You will learn to extract n-grams efficiently, understand when character n-grams outperform word n-grams, see how skip-grams relax the strict adjacency requirement, and understand how n-gram models assign probabilities to text sequences. By the end you will have a complete picture of what n-grams can do, what they cannot do, and why they remain relevant even in the age of large language models.
The theoretical foundations of n-gram modeling trace back to Claude Shannon's 1948 paper "A Mathematical Theory of Communication." Shannon used character-level n-gram statistics to estimate the entropy of English text. This shows that consecutive characters are statistically dependent. He showed that letter sequences follow predictable patterns by asking human subjects to guess the next character in a sequence: the more characters they had seen, the better they could guess, up to a point. This experiment captured the essence of what n-gram language models formalize mathematically. Shannon's work directly inspired Frederick Jelinek and colleagues at IBM in the 1970s and 1980s, who built the first large-scale n-gram language models for speech recognition. Their trigram models, trained on tens of millions of words, achieved dramatically better word error rates than earlier systems. The same statistical approach dominated speech recognition for nearly three decades before neural networks overtook it in the early 2010s.
From Words to Word Sequences
The bag of words model creates a vocabulary of individual words and counts how often each appears in a document. N-grams extend this idea by creating a vocabulary of word sequences and counting how often each sequence appears. This small change has large practical consequences.
Think of it this way: a unigram vocabulary for English has perhaps 50,000 to 100,000 entries, one per word form. A bigram vocabulary for the same corpus has millions of entries, one per pair of consecutive words. A trigram vocabulary has tens of millions. The vocabulary grows exponentially, but each entry carries more information about local context.
To see the difference concretely, consider how different n-gram orders segment the same sentence:
An n-gram is a contiguous sequence of n items from a text. When the items are words, we call them word n-grams. When the items are characters, we call them character n-grams. Common special cases include unigrams (n=1), bigrams (n=2), and trigrams (n=3). The general plural "n-grams" refers to any or all of these sequence lengths.
Let's see how the same sentence produces different representations at each n-gram level:
PythonIn [3]Expand
def extract_ngrams(tokens, n):
"""Extract n-grams from a list of tokens."""
return [tuple(tokens[i : i + n]) for i in range(len(tokens) - n + 1)]
# Sample sentence
sentence = "the quick brown fox jumps over the lazy dog"
tokens = sentence.split()
# Extract different n-gram sizes
unigrams = extract_ngrams(tokens, 1)
bigrams = extract_ngrams(tokens, 2)
trigrams = extract_ngrams(tokens, 3)ConsoleOut [4]Expand
Sentence: 'the quick brown fox jumps over the lazy dog'
Tokens: 9
Unigrams (9):
['the', 'quick', 'brown', 'fox', 'jumps', 'over', 'the', 'lazy', 'dog']
Bigrams (8):
[('the', 'quick'), ('quick', 'brown'), ('brown', 'fox'), ('fox', 'jumps'), ('jumps', 'over'), ('over', 'the'), ('the', 'lazy'), ('lazy', 'dog')]
Trigrams (7):
[('the', 'quick', 'brown'), ('quick', 'brown', 'fox'), ('brown', 'fox', 'jumps'), ('fox', 'jumps', 'over'), ('jumps', 'over', 'the'), ('over', 'the', 'lazy'), ('the', 'lazy', 'dog')]Notice the pattern: each sentence with tokens produces a different number of n-grams depending on the sequence length you choose. The relationship between sentence length, n-gram order, and count is deterministic:
where:
- : the total number of tokens in the sentence
- : the n-gram size (1 for unigrams, 2 for bigrams, 3 for trigrams, etc.)
- : the resulting number of n-grams
The key insight behind this formula is that each n-gram starts at a unique position in the token sequence. The very first n-gram starts at position 1, and the last valid starting position is , because that is the last position where we can still find consecutive tokens. Starting any later would run past the end of the sentence. With 9 tokens, we get 9 unigrams (), 8 bigrams (), and 7 trigrams (). As increases, we extract fewer n-grams from each document, but each n-gram carries more contextual information. The coverage decreases; the specificity increases.
Worked Example: Tracing Bigrams Through a Sentence
Let's trace through a short, meaningful sentence step by step to build intuition for what bigrams represent. Take the sentence "cats eat fish."
After lowercasing and tokenizing, we have tokens: ["cats", "eat", "fish"]. Applying the bigram extraction algorithm:
- Position 1: window covers tokens 1 and 2, producing
("cats", "eat") - Position 2: window covers tokens 2 and 3, producing
("eat", "fish")
We stop here because the next position would require a token 4 that does not exist. The final bigram representation is [("cats", "eat"), ("eat", "fish")].
Notice that "eat" appears in both bigrams. Each word (except the first and last) participates in exactly two bigrams: one where it is the second word, and one where it is the first. This overlapping structure means that consecutive n-grams share most of their tokens, which is why they remain sensitive to local order while still providing a sliding-window view of the text.
Now compare this to a different sentence with the same words: "fish eat cats." The tokens are identical but in reverse order, giving bigrams [("fish", "eat"), ("eat", "cats")]. These two sets share no bigrams at all, even though they use exactly the same vocabulary. This is the property that unigrams cannot provide: sensitivity to word order.
Bigrams and Trigrams in Practice
Bigrams are the most commonly used n-grams in applied NLP. They capture immediate word relationships, such as noun-adjective pairs, compound nouns, and common verbal phrases, while keeping vocabulary size manageable. Trigrams provide richer context and are standard in language modeling, where they allow conditioning on a two-word history. Longer n-grams exist but are rarely used as standalone features because the sparsity problem becomes severe.
The most revealing way to understand what bigrams encode is to look at their frequency distributions in real text. Common bigrams cluster around function word combinations like "of the," "in the," and "to be," but the bigrams with the strongest discriminative power for content-based tasks are typically noun phrase patterns and domain-specific collocations:
PythonIn [5]Expand
from collections import Counter
# A small corpus to analyze
corpus = [
"I love machine learning",
"I love deep learning",
"machine learning is powerful",
"deep learning is a subset of machine learning",
"I study machine learning every day",
]
# Tokenize and extract bigrams from entire corpus
all_bigrams = []
for doc in corpus:
tokens = doc.lower().split()
bigrams = extract_ngrams(tokens, 2)
all_bigrams.extend(bigrams)
# Count bigram frequencies
bigram_counts = Counter(all_bigrams)ConsoleOut [6]Expand
Corpus bigram frequencies: ---------------------------------------- machine learning 4 i love 2 deep learning 2 learning is 2 love machine 1 love deep 1 is powerful 1 is a 1 a subset 1 subset of 1 Total bigram tokens: 21 Unique bigrams: 15
The bigram "machine learning" appears frequently because it is a meaningful phrase in this corpus. This reveals the key insight: common n-grams often correspond to collocations or other meaningful multi-word expressions, including domain-specific phrases. When you add bigrams as features alongside unigrams in a text classifier, you are giving the model explicit access to these multi-word units rather than requiring it to infer their meaning from co-occurring unigrams.
PythonIn [7]Expand
# Compare unigram vs bigram representations
all_unigrams = []
for doc in corpus:
tokens = doc.lower().split()
all_unigrams.extend(tokens)
unigram_counts = Counter(all_unigrams)ConsoleOut [8]Expand
Unigram frequencies (top 10): learning 6 machine 4 i 3 love 2 deep 2 is 2 powerful 1 a 1 subset 1 of 1 Unique unigrams: 13 Unique bigrams: 15 Vocabulary explosion factor: 1.2x
Even in this tiny corpus, the bigram vocabulary is larger than the unigram vocabulary. This vocabulary explosion is not a flaw; it is a reflection of the combinatorial richness of natural language. The same words can be combined in many different orders and contexts, and each unique sequence carries distinct information. But the growth in vocabulary size has direct practical costs in memory, computational complexity, and the amount of data needed to estimate reliable statistics.
VisualizationOut [9]Expand

The co-occurrence matrix reveals the structure hidden in bigram counts. The cell at row "machine" and column "learning" shows a high count because "machine learning" is a frequent phrase. Most cells are zero. This reflects the sparsity inherent in natural language: most word pairs never occur together in the same order in any given corpus, even if both words appear individually.
The Vocabulary Explosion Problem
The theoretical maximum number of distinct n-grams grows exponentially with n. If your vocabulary contains unique word types, the maximum number of possible distinct n-grams is:
where is the vocabulary size and is the n-gram order. For a modest vocabulary of 10,000 word types:
- Unigrams: possible
- Bigrams: possible
- Trigrams: possible
In practice, most of these theoretical combinations never occur in natural language. The phrase "the the the" is a grammatically valid trigram structure but appears almost never in coherent writing. Real language has strong statistical regularities that constrain which sequences occur. Still, the number of observed n-grams grows rapidly with n, and even the observed vocabulary can quickly exceed what fits in memory or can be reliably estimated from finite data.
PythonIn [10]Expand
# Download sample text
# nltk.download("gutenberg", quiet=True)
from nltk.corpus import gutenberg
# Use a substantial text for realistic statistics
text = gutenberg.raw("austen-emma.txt")
tokens = text.lower().split()
# Count unique n-grams for different values of n
ngram_vocab_sizes = {}
for n in range(1, 6):
ngrams = extract_ngrams(tokens, n)
unique_count = len(set(ngrams))
ngram_vocab_sizes[n] = unique_countConsoleOut [11]Expand
Text: Emma by Jane Austen Total tokens: 158,167 Vocabulary size by n-gram order: ----------------------------------- Unigrams 16,945 (1.0x unigrams) Bigrams 81,586 (4.8x unigrams) Trigrams 136,444 (8.1x unigrams) 4-grams 154,062 (9.1x unigrams) 5-grams 157,344 (9.3x unigrams)
The vocabulary roughly doubles or triples with each increment in n. A trigram vocabulary can easily be 10 times larger than the unigram vocabulary. For a large English corpus, unigrams might number around 50,000 word types, bigrams around 500,000 unique pairs, and trigrams around 2 to 5 million unique triples. This progression directly affects:
- Memory usage: Each n-gram type must be stored with its count. A trigram index over a large corpus can require gigabytes of RAM.
- Model complexity: A language model parameterized by trigram probabilities needs to store millions of conditional probability estimates.
- Data requirements: Reliable probability estimates require that each n-gram appears many times in training data. If most trigrams appear only once, your estimates will be unreliable.
- Generalization: A model trained on observed n-grams cannot assign probability to n-grams it has never seen, even if those sequences are perfectly grammatical.
VisualizationOut [12]Expand

The vocabulary explosion is the central engineering challenge of n-gram methods. Every design decision in n-gram systems, from vocabulary pruning to smoothing to hashing, is ultimately a strategy for managing this exponential growth.
N-gram Frequency Distributions and Zipf's Law
Individual words follow Zipf's law: the frequency of a word is inversely proportional to its rank in the frequency table. A small number of words account for most of the occurrences in any large corpus. The word "the" might make up 6 to 7 percent of all words in English text, while thousands of low-frequency words each appear only once or twice.
N-grams follow the same pattern, but in an even more extreme form. The top bigrams (phrases like "of the," "in the," "it is") each appear thousands of times, while the vast majority of observed bigrams appear just once. This extreme concentration at the high-frequency end and long tail at the low-frequency end is a universal property of natural language at every level of granularity.
Zipf's law states that in a corpus of natural language, the frequency of any word is inversely proportional to its rank in the frequency table. If the most frequent word appears times, the second most frequent word appears approximately times, the third appears approximately times, and so on. The law generalizes to:
where is the frequency of the word at rank . On a log-log plot, a Zipfian distribution appears as a straight line with slope approximately .
The practical consequence of Zipf's law for n-grams is severe: most of the n-gram types you will ever observe occur exactly once in any given corpus. These single-occurrence n-grams are called hapax legomena (from Greek: "said only once"). As the n-gram order increases, the hapax ratio climbs sharply, because longer sequences are less likely to repeat.
PythonIn [13]Expand
# Analyze frequency distribution for different n-gram orders
def analyze_frequency_distribution(tokens, n, top_k=10):
"""Compute frequency statistics for n-grams."""
ngrams = extract_ngrams(tokens, n)
counts = Counter(ngrams)
# Get frequency of frequencies
freq_of_freq = Counter(counts.values())
# Calculate statistics
total = len(ngrams)
unique = len(counts)
hapax = sum(1 for c in counts.values() if c == 1) # Words appearing once
return {
"total": total,
"unique": unique,
"hapax": hapax,
"hapax_ratio": hapax / unique,
"top_k": counts.most_common(top_k),
"freq_of_freq": freq_of_freq,
}
# Analyze unigrams, bigrams, and trigrams
stats = {n: analyze_frequency_distribution(tokens, n) for n in [1, 2, 3]}ConsoleOut [14]Expand
Frequency Distribution Statistics:
============================================================
Unigrams:
Total tokens: 158,167
Unique types: 16,945
Hapax legomena (appear once): 10,232 (60.4%)
Top 5 most frequent:
'the': 5,120
'to': 5,079
'and': 4,445
'of': 4,196
'a': 3,055
Bigrams:
Total tokens: 158,166
Unique types: 81,586
Hapax legomena (appear once): 65,609 (80.4%)
Top 5 most frequent:
'to be': 566
'of the': 558
'in the': 441
'it was': 387
'she had': 313
Trigrams:
Total tokens: 158,165
Unique types: 136,444
Hapax legomena (appear once): 126,523 (92.7%)
Top 5 most frequent:
'i do not': 94
'i am sure': 75
'she could not': 65
'a great deal': 56
'it would be': 56The hapax ratio climbs steeply with n. For trigrams, the vast majority appear exactly once in even a reasonably large corpus. This sparsity is the core challenge of n-gram models. When you encounter a trigram in new text that you never saw during training, you have no observed count to draw on. You need a strategy for handling zero-count n-grams, which is the motivation for smoothing (discussed in the next section).
VisualizationOut [15]Expand

VisualizationOut [16]Expand

The Zipfian distribution means that most of your n-gram model's probability mass is concentrated in a tiny fraction of all possible sequences. The top 100 trigrams in a large corpus might account for a significant fraction of all trigram occurrences, while the bottom 90 percent of trigram types each contribute almost nothing. In practice this creates an asymmetry: the common sequences are very well estimated from data, while the rare ones are estimated poorly or not at all.
N-gram Language Models
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 can be written using the chain rule of probability:
where is the conditional probability of word given all preceding words. The problem with this exact formulation is that the history grows without bound as 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 words, not the full history. For a bigram model (), the assumption is:
For a trigram model ():
In general, an n-gram model conditions on the preceding words:
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 . The simplest way to estimate these from training data is maximum likelihood estimation (MLE):
where:
- : the count of the full n-gram (the context words followed by ) in training data
- : the count of the context (the preceding words) alone
In plain language: the probability of word following a given context is the fraction of times that context in training data was followed by . If the bigram "machine learning" appears 100 times and the word "machine" appears 200 times total, then .
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:
where:
- : the observed bigram count
- : the observed unigram count for the context word
- : the vocabulary size (the number of word types)
The denominator increases by rather than by because we are adding 1 to the count of every possible next word, and there are 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 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.
PythonIn [17]Expand
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)ConsoleOut [18]Expand
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.536Notice 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.
Character N-grams for Noisy Text
Word n-grams assume clean, correctly spelled text. But real-world text contains typos, spelling variations, invented words, and domain-specific jargon that may not appear in any vocabulary. Character n-grams handle these issues by operating on character sequences instead of word boundaries.
The key property of character n-grams is that similar words share many character n-grams. "Learning" and "learner" share the character trigrams "lea," "ear," "arn," and "rni." A typo like "lerning" still shares most trigrams with "learning." This makes character n-grams naturally tolerant of surface variation while remaining sensitive to lexical identity.
A character n-gram is a contiguous sequence of n characters from a text, including spaces and punctuation. Character n-grams operate below the word level and can capture subword patterns such as prefixes, suffixes, and stems. They are particularly useful for morphologically rich languages, informal text with spelling variations, and tasks involving noisy input.
PythonIn [19]Expand
def extract_char_ngrams(text, n):
"""Extract character n-grams from text."""
return [text[i : i + n] for i in range(len(text) - n + 1)]
# Compare word and character n-grams
word = "learning"
char_bigrams = extract_char_ngrams(word, 2)
char_trigrams = extract_char_ngrams(word, 3)
# Show how typos affect matching
correct = "learning"
typo = "lerning" # Missing 'a'
correct_trigrams = set(extract_char_ngrams(correct, 3))
typo_trigrams = set(extract_char_ngrams(typo, 3))
overlap = correct_trigrams & typo_trigramsConsoleOut [20]Expand
Word: 'learning' Character bigrams: ['le', 'ea', 'ar', 'rn', 'ni', 'in', 'ng'] Character trigrams: ['lea', 'ear', 'arn', 'rni', 'nin', 'ing'] Typo robustness example: Correct: 'learning' -> trigrams: ['arn', 'ear', 'ing', 'lea', 'nin', 'rni'] Typo: 'lerning' -> trigrams: ['ern', 'ing', 'ler', 'nin', 'rni'] Overlap: ['ing', 'nin', 'rni'] (3/6 = 50%)
Despite the missing letter, most character trigrams still match between the correctly spelled word and the typo. This partial matching property is why character n-grams excel at several tasks:
- Spelling correction: Finding the closest dictionary word to a misspelled query by measuring character n-gram overlap
- Language identification: Different languages have characteristic character-level frequency patterns that persist even in short text snippets
- Author attribution: Writing style appears in subtle character-level patterns, from punctuation and spacing to how words are constructed
- Morphologically rich languages: Languages with complex inflectional systems (Turkish, Finnish, Arabic) express grammatical information through affixes; character n-grams capture these subword units naturally
- Social media and informal text: Character n-grams handle usernames and hashtags as well as abbreviations or unconventional spellings better than word-level models that depend on a fixed vocabulary
The trade-off is a much larger character n-gram vocabulary relative to the number of character types. English has only about 40 distinct characters (letters, common punctuation, and space), so character unigrams are tiny. But character trigrams can number in the thousands for natural text, and the full range of possible character 4-grams is .
PythonIn [21]Expand
# Language identification using character trigrams
samples = {
"English": (
"The sun rises early in the morning and sets late in the evening. "
"Children play in the streets while their parents work in the garden. "
"The market opens every morning with fresh fruits and vegetables. "
"People gather together to share stories and enjoy the afternoon sun."
),
"French": (
"Le soleil se lève tôt le matin et se couche tard le soir. "
"Les enfants jouent dans les rues pendant que leurs parents travaillent. "
"Le marché ouvre chaque matin avec des fruits et des légumes frais. "
"Les gens se rassemblent pour partager des histoires et profiter du soleil."
),
"German": (
"Die Sonne geht früh am Morgen auf und spät am Abend unter. "
"Kinder spielen auf den Straßen während ihre Eltern im Garten arbeiten. "
"Der Markt öffnet jeden Morgen mit frischem Obst und frischem Gemüse. "
"Die Menschen versammeln sich um Geschichten zu teilen und die Sonne zu genießen."
),
"Spanish": (
"El sol sale temprano por la mañana y se pone tarde por la noche. "
"Los niños juegan en las calles mientras sus padres trabajan en el jardín. "
"El mercado abre cada mañana con frutas y verduras frescas del campo. "
"La gente se reúne para compartir historias y disfrutar del sol de la tarde."
),
}
# Extract character trigram profiles
profiles = {}
for lang, text in samples.items():
trigrams = extract_char_ngrams(text.lower(), 3)
profiles[lang] = Counter(trigrams).most_common(10)ConsoleOut [22]Expand
Top 5 character trigrams by language: -------------------------------------------------- English : 'the', 'he ', ' th', ' in', 'in ' French : 'es ', ' le', 'le ', 'ent', ' so' German : 'en ', 'nd ', ' ge', ' un', 'sch' Spanish : 'as ', 'el ', ' la', 'la ', 'l s'
Each language has distinctive character trigram signatures. German shows patterns like "sch" (a common consonant cluster), French has character sequences from its characteristic liaison and nasal vowel patterns, and Spanish shows the influence of its double-letter combinations and accent marks. These patterns form the basis of language detection algorithms, which are fast and accurate even on very short text because character trigram profiles diverge quickly between languages.
VisualizationsOut [23]Expand


VisualizationsOut [24]Expand


Skip-grams: Flexible Context Windows
Standard n-grams require strict adjacency: every token must immediately precede the next one in the sequence. This constraint is linguistically motivated for many phrase-level patterns, but it misses relationships between words that are related despite not being immediately adjacent.
Consider the sentence "the big brown bear ate salmon." The relationship between "bear" and "ate" is meaningful, but they are separated by two words. A standard bigram representation misses this. A trigram captures "big brown bear" and "brown bear ate" but still does not directly pair "bear" with its verb "ate." Skip-grams relax the adjacency constraint to capture these longer-range dependencies.
A skip-gram is a generalization of n-grams that allows gaps between the selected tokens. A -skip--gram selects tokens from a window such that no two consecutive selected tokens are more than positions apart. When , skip-grams reduce to ordinary n-grams. Larger values capture relationships between words that are not immediately adjacent.
The term "skip-gram" in the context of this chapter refers to this linguistic extension. Note that the word "skip-gram" also names a Word2Vec training objective (covered in a later chapter), but the two uses are related: the Word2Vec skip-gram model was inspired by this linguistic notion of flexible context windows.
PythonIn [25]Expand
def extract_skip_bigrams(tokens, max_skip):
"""Extract skip-bigrams with up to max_skip words between."""
skip_bigrams = []
for i in range(len(tokens)):
for j in range(i + 1, min(i + max_skip + 2, len(tokens))):
skip_bigrams.append((tokens[i], tokens[j]))
return skip_bigrams
# Compare regular bigrams with skip-bigrams
sentence = "the cat sat on the mat"
tokens = sentence.split()
regular_bigrams = extract_ngrams(tokens, 2)
skip_1_bigrams = extract_skip_bigrams(tokens, 1)
skip_2_bigrams = extract_skip_bigrams(tokens, 2)ConsoleOut [26]Expand
Sentence: 'the cat sat on the mat'
Regular bigrams (5):
[('the', 'cat'), ('cat', 'sat'), ('sat', 'on'), ('on', 'the'), ('the', 'mat')]
Skip-1 bigrams (9):
[('the', 'cat'), ('the', 'sat'), ('cat', 'sat'), ('cat', 'on'), ('sat', 'on'), ('sat', 'the'), ('on', 'the'), ('on', 'mat'), ('the', 'mat')]
Skip-2 bigrams (12):
[('the', 'cat'), ('the', 'sat'), ('the', 'on'), ('cat', 'sat'), ('cat', 'on'), ('cat', 'the'), ('sat', 'on'), ('sat', 'the'), ('sat', 'mat'), ('on', 'the'), ('on', 'mat'), ('the', 'mat')]With skip-1 bigrams, we capture pairs like ("cat", "on") and ("sat", "the") that regular bigrams miss entirely. With skip-2 bigrams, we reach even further, capturing ("the", "on") and ("cat", "the"). The total number of pairs grows quadratically with the skip distance, since each word can now be paired with multiple downstream words rather than just the immediately following one.
Skip-grams are useful in several scenarios:
- Word order variation: Different phrasings of the same idea may use the same words in different positions. Skip-grams make the representation more invariant to minor reorderings.
- Intervening modifiers: "The big fluffy cat" and "the cat" both express a relationship between "the" and "cat" that skip-bigrams capture regardless of how many modifiers intervene.
- Long-distance verb-object dependencies: In "she carefully and deliberately chose the option," "chose" and "option" are separated by several words. Skip-bigrams can capture this relationship.
The trade-off, as always, is vocabulary size. Skip-grams with skips produce substantially more combinations than ordinary bigrams:
PythonIn [27]Expand
# Count vocabulary sizes for different skip values
skip_vocab_sizes = {}
for max_skip in range(4):
if max_skip == 0:
all_skipgrams = []
for doc in corpus:
doc_tokens = doc.lower().split()
all_skipgrams.extend(extract_ngrams(doc_tokens, 2))
else:
all_skipgrams = []
for doc in corpus:
doc_tokens = doc.lower().split()
all_skipgrams.extend(extract_skip_bigrams(doc_tokens, max_skip))
skip_vocab_sizes[max_skip] = len(set(all_skipgrams))ConsoleOut [28]Expand
Vocabulary size by skip distance: ---------------------------------------- Regular bigrams 15 unique Skip-1 bigrams 29 unique Skip-2 bigrams 38 unique Skip-3 bigrams 44 unique
VisualizationOut [29]Expand

N-gram Indexing for Search
Beyond language modeling and document classification, n-grams have an important application in approximate string matching and search. By building an inverted index from n-grams to documents, you can efficiently retrieve documents that share specific word sequences with a query, even if the match is not exact.
The key insight is that an inverted index maps each observed n-gram to the set of document identifiers that contain it. A query can then be decomposed into its n-grams, and documents that contain the most query n-grams are ranked highest. This is fast because set intersection is much cheaper than scanning every document for every query.
PythonIn [30]Expand
from collections import defaultdict
class NGramIndex:
"""Simple n-gram based search index."""
def __init__(self, n=3):
self.n = n
self.index = defaultdict(set)
self.documents = {}
def add_document(self, doc_id, text):
"""Index a document by its n-grams."""
self.documents[doc_id] = text
tokens = text.lower().split()
ngrams = extract_ngrams(tokens, self.n)
for ngram in ngrams:
self.index[ngram].add(doc_id)
def search(self, query, min_matches=1):
"""Find documents containing query n-grams."""
query_tokens = query.lower().split()
query_ngrams = extract_ngrams(query_tokens, self.n)
# Count matches per document
doc_scores = Counter()
for ngram in query_ngrams:
for doc_id in self.index.get(ngram, []):
doc_scores[doc_id] += 1
# Filter by minimum matches
results = [
(doc_id, score)
for doc_id, score in doc_scores.items()
if score >= min_matches
]
return sorted(results, key=lambda x: -x[1])
# Build index
index = NGramIndex(n=2)
documents = [
"machine learning algorithms",
"deep learning neural networks",
"machine learning for natural language processing",
"statistical machine translation",
"reinforcement learning agents",
]
for i, doc in enumerate(documents):
index.add_document(i, doc)ConsoleOut [31]Expand
Indexed documents: [0] machine learning algorithms [1] deep learning neural networks [2] machine learning for natural language processing [3] statistical machine translation [4] reinforcement learning agents Query: 'machine learning' Score 1: [0] machine learning algorithms Score 1: [2] machine learning for natural language processing Query: 'deep learning' Score 1: [1] deep learning neural networks Query: 'natural language' Score 1: [2] machine learning for natural language processing
This inverted index approach is the foundation of many search engines and fuzzy matching systems. Notice how the query "machine learning" retrieves documents 0, 2, and 3, because all three contain the bigram ("machine", "learning") or the word "machine" in a bigram with a neighbor. The n-gram index trades storage space for query speed: storing all n-grams requires more memory, but queries become fast set intersection operations rather than full document scans.
In practice, n-gram indexing is used in:
- Search engines: Phrase queries rely on n-gram indices to find documents containing exact multi-word phrases
- Plagiarism detection: Fingerprinting documents with their n-gram sets allows fast detection of copied passages
- Database fuzzy matching: Finding records similar to a query string despite typos or abbreviations
- Genomics: Searching for short DNA sequences (k-mers, which are character n-grams) in large genomic databases
Using NLTK for N-gram Extraction
While we have implemented n-gram extraction from scratch to understand the mechanics, NLTK provides optimized utilities that are more convenient for production use:
PythonIn [32]Expand
from nltk import bigrams, ngrams, trigrams
from nltk.util import everygrams
# NLTK's n-gram functions
text = "the quick brown fox jumps over the lazy dog"
tokens = text.split()
# Extract using NLTK
nltk_bigrams = list(bigrams(tokens))
nltk_trigrams = list(trigrams(tokens))
nltk_ngrams_4 = list(ngrams(tokens, 4))
# everygrams extracts all n-grams from 1 to max_len
all_grams = list(everygrams(tokens, min_len=1, max_len=3))ConsoleOut [33]Expand
NLTK n-gram extraction:
Bigrams: [('the', 'quick'), ('quick', 'brown'), ('brown', 'fox'), ('fox', 'jumps')]...
Trigrams: [('the', 'quick', 'brown'), ('quick', 'brown', 'fox'), ('brown', 'fox', 'jumps')]...
4-grams: [('the', 'quick', 'brown', 'fox'), ('quick', 'brown', 'fox', 'jumps')]...
Everygrams (1-3): 24 total
Sample: [('the',), ('the', 'quick'), ('the', 'quick', 'brown'), ('quick',), ('quick', 'brown')]...NLTK also provides FreqDist for counting and analyzing n-gram frequencies:
PythonIn [34]Expand
from nltk import FreqDist
# Count bigram frequencies across a corpus
all_corpus_bigrams = []
for doc in corpus:
doc_tokens = doc.lower().split()
all_corpus_bigrams.extend(bigrams(doc_tokens))
bigram_freq = FreqDist(all_corpus_bigrams)ConsoleOut [35]Expand
NLTK FreqDist analysis:
Total bigrams: 21
Unique bigrams: 15
Most common: [(('machine', 'learning'), 4), (('i', 'love'), 2), (('deep', 'learning'), 2), (('learning', 'is'), 2), (('love', 'machine'), 1)]
Frequency of ('machine', 'learning'): 4NLTK's FreqDist wraps a Counter with additional NLP-specific methods. The .N() method returns the total number of tokens (sum of all counts), while .B() returns the number of unique types (vocabulary size). These are the type/token distinction applied to n-gram collections, which is important for computing statistics like the type-to-token ratio.
Practical Considerations
When working with n-grams in real applications, several engineering and modeling decisions have significant impact on your results.
Choosing n. Larger n captures more context but creates sparser data. The right choice depends on your task, your corpus size, and your tolerance for sparsity:
- Unigrams (n=1): Baseline that loses all word order, but works surprisingly well for many classification tasks
- Bigrams (n=2): Good balance for most tasks; captures immediate phrase structure while remaining tractable
- Trigrams (n=3): Richer context; standard for language modeling tasks; requires substantially more data
- n greater than 3: Rarely used as standalone features due to extreme sparsity; occasionally useful in specific applications like DNA sequence analysis or stylometry
Vocabulary pruning. You almost always want to remove n-grams that appear too rarely (below some minimum document frequency) or too frequently (above some maximum). Rare n-grams add noise rather than signal: if a trigram appears only once in your training data, your estimate of its probability is unreliable. Very frequent n-grams like "of the" and "in the" appear everywhere and carry almost no discriminative information for tasks like topic classification or sentiment analysis. Pruning reduces vocabulary size, which saves memory and often improves model quality.
Padding. For language modeling applications, you may want to add special start and end tokens to capture sentence boundary statistics:
PythonIn [36]Expand
def extract_padded_ngrams(tokens, n, pad_symbol="<PAD>"):
"""Extract n-grams with padding for boundary awareness."""
padded = [pad_symbol] * (n - 1) + tokens + [pad_symbol] * (n - 1)
return extract_ngrams(padded, n)
sentence_tokens = ["the", "cat", "sat"]
padded_bigrams = extract_padded_ngrams(sentence_tokens, 2)
padded_trigrams = extract_padded_ngrams(sentence_tokens, 3)ConsoleOut [37]Expand
Padded n-grams capture sentence boundaries:
Tokens: ['the', 'cat', 'sat']
Padded bigrams: [('<PAD>', 'the'), ('the', 'cat'), ('cat', 'sat'), ('sat', '<PAD>')]
Padded trigrams: [('<PAD>', '<PAD>', 'the'), ('<PAD>', 'the', 'cat'), ('the', 'cat', 'sat'), ('cat', 'sat', '<PAD>'), ('sat', '<PAD>', '<PAD>')]The padding approach ensures that the beginning and end of a sentence are treated as a special context rather than being silently ignored. Without padding, the first and last words of a sentence only appear as the second element of starting bigrams or the first element of ending bigrams, which underestimates how often they occur in those boundary positions. With <s> and </s> boundary markers, the model can learn that certain words frequently begin or end sentences.
Memory efficiency. For large corpora, string-based n-gram storage becomes prohibitively expensive. In practice:
- Store n-grams as hashed integer tuples rather than string tuples. The hash reduces memory by roughly 4-8x.
- Use sparse matrix representations (scipy.sparse) when building document-term matrices from n-gram features.
- Apply feature hashing (the "hashing trick") to map n-grams to a fixed-dimensional vector without storing the vocabulary explicitly. This bounds memory at the cost of hash collisions.
- Use count sketches or min-count thresholding to discard rare n-grams during construction rather than after.
Combining n-gram orders. In practice, the best results often come from combining multiple n-gram orders. Scikit-learn's CountVectorizer and TfidfVectorizer accept ngram_range=(1, 2) to include both unigrams and bigrams, or ngram_range=(1, 3) to include unigrams through trigrams. Combining orders gives the model access to both word-level and phrase-level patterns, usually improving performance on classification tasks at the cost of a larger feature space.
Limitations and Trade-offs
N-grams improve on bag of words by preserving local word order and capturing multi-word phrases as single units. But they carry significant limitations that motivate the development of richer representations.
The most fundamental limitation is the fixed context window. An n-gram of size captures exactly consecutive words, no more and no less. Any relationship between words separated by more than positions is invisible to the model. The phrase "if she studies consistently throughout the semester, then her exam results improve" carries a long-range correlation between "if" and "then" that spans many words. No practical n-gram can capture this. Language models that use trigrams model context three words at a time; the rest of the sentence history is discarded by the Markov approximation.
The vocabulary explosion is a fundamental challenge. Moving from bigrams to trigrams multiplies the number of possible n-gram types by the vocabulary size. For a 50,000-word vocabulary, that means going from 2.5 billion possible bigrams to 125 trillion possible trigrams. The observed fraction in any corpus is tiny, making most of the probability space inaccessible and forcing heavy reliance on smoothing. Smoothing helps but cannot fully solve the zero-count problem; it only redistributes probability mass according to heuristics that may not reflect the true distribution.
No semantic understanding is perhaps the most limiting aspect of n-gram representations. "Excellent movie" and "great film" share no n-grams despite expressing the same sentiment. A synonym substitution produces a completely different n-gram fingerprint. This means that n-gram models cannot generalize across synonymous expressions, different phrasings of the same meaning, or across languages. Every surface form is treated as a completely distinct symbol, with no connection to any other symbol that happens to mean the same thing.
Data requirements grow exponentially with n. A reliable bigram model might require millions of words of training data; a trigram model might need tens of millions; 5-gram models used in machine translation systems were trained on billions of words. This makes high-order n-gram models inaccessible to researchers and applications that do not have access to massive corpora.
The sensitivity to tokenization is also important in practice. Whether "New York" is one token or two, whether contractions are split, whether punctuation is attached to words or separated: all of these decisions change the n-gram representation significantly. N-gram models can be brittle when the tokenization of training data differs from the tokenization of test data, because even a slight mismatch propagates through every n-gram that spans a tokenization boundary.
Despite these limitations, n-grams occupied the center of NLP for decades because they are simple and fast, with interpretable features that often work surprisingly well. They require no deep learning infrastructure, no GPU, and no careful architecture design. A trigram language model trained on a large corpus achieves reasonable performance on speech recognition and machine translation with nothing but counting and smoothing.
Impact and Applications
The impact of n-gram methods on the history of language technology is hard to overstate. Before neural networks became practical in the late 2000s and early 2010s, n-gram language models were the dominant approach for any application requiring statistical knowledge about word sequences.
Language modeling and speech recognition was the original application and remained dominated by n-gram models for three decades. IBM's trigram models from the 1980s reduced word error rates in dictation systems by more than half compared to earlier methods. Google's voice search and Apple's Siri relied on n-gram language models as core components well into the 2010s.
Statistical machine translation used n-gram language models as one of two key components (alongside a translation model). The language model ensured that the target-side output was fluent English (or whatever target language), scored by a trigram or 5-gram model trained on billions of words of text. This approach produced the first practically useful machine translation systems.
Text classification benefits substantially from adding bigrams to a bag-of-words feature set. In spam detection, sentiment analysis, and topic classification, bigrams capture phrases that single words cannot. The bigram "not good" carries entirely different sentiment from either "not" or "good" individually.
Spell checking and autocomplete both rely on character n-grams and word n-grams respectively. Character n-gram similarity identifies likely corrections for misspelled words. Word n-grams predict likely next words given the current context, which is the core of every autocomplete system.
Plagiarism detection uses n-gram fingerprinting, also called "shingling." A document is represented as the set of its k-grams (typically with k around 5 to 10 words). Two documents that share many k-grams are likely to contain copied passages. This approach tolerates minor paraphrasing and word substitutions because it requires matching sequences of five or more consecutive words, which is hard to achieve by coincidence.
Information retrieval and phrase-based search use bigram and trigram indices to answer phrase queries efficiently. Searching for "New York Times" as a phrase requires matching the trigram instead of checking only for all three words somewhere in the document.
The transition from n-gram models to neural language models beginning around 2010 to 2013 did not eliminate n-grams from the field. Modern subword tokenizers like BPE (Byte Pair Encoding) and WordPiece operate by iteratively merging the most frequent character n-grams until a target vocabulary size is reached. In a sense, BPE learns which character sequences deserve to be treated as atomic units, automating the process of vocabulary construction that n-gram methods handled by counting. Understanding n-grams provides direct intuition for why these newer tokenization methods work: they are learning the most informative character-level sequence patterns from data, rather than treating every possible sequence uniformly.
Key Functions and Parameters
When working with n-grams in Python, these are the essential functions and their most important parameters:
nltk.ngrams(sequence, n, pad_left=False, pad_right=False)
sequence: The input tokens (list of words or characters)n: The size of the n-gram (2 for bigrams, 3 for trigrams, etc.)pad_left,pad_right: Whether to add padding symbols at boundaries. Useful for language modeling where start and end context matters
nltk.bigrams(sequence) and nltk.trigrams(sequence)
- Convenience functions equivalent to
ngrams(sequence, 2)andngrams(sequence, 3) - Return generators, so wrap in
list()if you need to iterate multiple times
nltk.everygrams(sequence, min_len=1, max_len=-1)
min_len: Minimum n-gram size to includemax_len: Maximum n-gram size to include (-1 means use the full sequence length)- Useful when you want to combine multiple n-gram orders in a single feature set
collections.Counter(iterable)
- Essential for counting n-gram frequencies from any iterable of n-gram tuples
.most_common(n): Returns the n most frequent items as (item, count) pairs- Supports arithmetic operations for combining counts across documents
Custom extraction parameters:
max_skip: For skip-grams, controls how many words can be skipped between selected words. Higher values capture more distant relationships at the cost of larger vocabularypad_symbol: The token used for boundary padding (commonly<s>,</s>, or<PAD>)
Scikit-learn integration:
CountVectorizer(ngram_range=(1, 2)): Extracts both unigrams and bigrams as a combined feature vectorTfidfVectorizer(ngram_range=(1, 3)): Extracts unigrams through trigrams with TF-IDF weightinganalyzer='char': Switches from word n-grams to character n-grams, useful for noisy text or morphologically rich languages
Summary
N-grams extend bag of words by preserving local word order, turning text from a multiset of isolated symbols into a collection of overlapping sequences. Key takeaways:
- N-grams are contiguous sequences of tokens (words or characters), with unigrams, bigrams, and trigrams as the most common orders
- Bigrams capture immediate word relationships and collocations; trigrams add one more word of context and are standard in language modeling
- Vocabulary explosion means the number of unique n-gram types grows exponentially with , increasing memory use and sparsity while demanding more data
- Zipf's law applies to n-grams at every order, with most types appearing only once and a few appearing extremely often
- N-gram language models use the Markov assumption to approximate the full chain rule of probability, conditioning each word's probability on the preceding words
- Smoothing is essential in any n-gram language model because maximum likelihood estimation assigns zero probability to unseen n-grams
- Character n-grams tolerate typos and spelling variation while capturing morphological complexity below the word boundary
- Skip-grams relax strict adjacency to capture relationships between non-adjacent words at the cost of larger vocabulary
- N-gram indexing enables fast approximate search and phrase matching through inverted index construction
- Trade-offs are inherent: more context requires more data, creates sparser representations, and makes the zero-count problem worse
N-grams bridge the gap between treating words as isolated units and understanding them in context. In the next chapter, we will explore TF-IDF, which adds statistical weighting to distinguish informative terms from common ones. This provides a complementary approach to improving text representations beyond simple count-based methods.
Quiz
Ready to test your understanding? Take this quick quiz to reinforce what you've learned about n-grams and text representation.
N-grams 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!