N-grams: Capturing Word Order in Text

Michael BrenndoerferUpdated March 22, 202547 min read

Covers n-gram text representations including bigrams, trigrams, character n-grams, and skip-grams. Topics include extraction techniques.

Language AI HandbookAll chapters
Part 2 of 67Classical Text Representations
Chapter 7N-grams
Reading the full chapter · 16 sections · 47 min16 sections · 47 min

All 16 sections · 47 min read

N-gramsFull chapter · Sections in this chapter1 / 16 ↓
Track your reading progress

Sign in to mark sections as read and track your learning journey

Sign in →
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

Notes & comments

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 nn 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.

Historical Context: N-grams and the Shannon Legacy

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:

N-gram

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]
CollapseExpand
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]
CollapseExpand
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 kk 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:

count=k−n+1\text{count} = k - n + 1

where:

  • kk: the total number of tokens in the sentence
  • nn: the n-gram size (1 for unigrams, 2 for bigrams, 3 for trigrams, etc.)
  • count\text{count}: 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 k−n+1k - n + 1, because that is the last position where we can still find nn consecutive tokens. Starting any later would run past the end of the sentence. With 9 tokens, we get 9 unigrams (9−1+1=99 - 1 + 1 = 9), 8 bigrams (9−2+1=89 - 2 + 1 = 8), and 7 trigrams (9−3+1=79 - 3 + 1 = 7). As nn 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]
CollapseExpand
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]
CollapseExpand
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]
CollapseExpand
# 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]
CollapseExpand
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]
CollapseExpand
Heatmap matrix showing word co-occurrence counts with darker cells indicating more frequent bigrams.
Bigram co-occurrence matrix showing which words follow which in our sample corpus. Each cell represents how often the column word follows the row word. The matrix is sparse, with most combinations never occurring. High-frequency pairs like 'machine followed by learning' and 'deep followed by learning' stand out as meaningful collocations that unigrams would miss entirely.

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 VV unique word types, the maximum number of possible distinct n-grams is:

VnV^n

where VV is the vocabulary size and nn is the n-gram order. For a modest vocabulary of 10,000 word types:

  • Unigrams: 10,0001=10,00010{,}000^1 = 10{,}000 possible
  • Bigrams: 10,0002=100,000,00010{,}000^2 = 100{,}000{,}000 possible
  • Trigrams: 10,0003=1,000,000,000,00010{,}000^3 = 1{,}000{,}000{,}000{,}000 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]
CollapseExpand
# 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_count
ConsoleOut [11]
CollapseExpand
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]
CollapseExpand
Line chart showing vocabulary size increasing from about 7000 for unigrams to over 100000 for 5-grams.
Vocabulary size growth as n-gram order increases in Jane Austen's Emma. The number of unique n-gram types grows roughly exponentially with each step, scaling from about 7,000 unigrams to over 100,000 five-grams on this relatively small text. In larger modern corpora, the growth is steeper and the absolute counts are orders of magnitude higher.

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

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 ff times, the second most frequent word appears approximately f/2f/2 times, the third appears approximately f/3f/3 times, and so on. The law generalizes to:

f(r)∝1rf(r) \propto \frac{1}{r}

where f(r)f(r) is the frequency of the word at rank rr. On a log-log plot, a Zipfian distribution appears as a straight line with slope approximately −1-1.

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]
CollapseExpand
# 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]
CollapseExpand
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': 56

The 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]
CollapseExpand
Bar chart showing hapax ratio increasing from about 50% for unigrams to over 80% for trigrams.
Hapax ratio (proportion of n-gram types appearing exactly once) increases sharply with n-gram order in Emma. While roughly half of all unigram types are hapax legomena, this rises to over 80 percent for trigrams. The implication is that most trigrams you would encounter in held-out text never appear in training data, making zero-count handling essential for any trigram-based model.
VisualizationOut [16]
CollapseExpand
Log-log plot showing frequency vs rank for unigrams, bigrams, and trigrams, all showing approximately linear relationships.
Zipf''s law holds across n-gram orders in Emma. Each curve shows n-gram frequencies plotted against their rank on log-log axes. All three follow approximately linear relationships, confirming Zipf''s law. The steeper slope for bigrams and trigrams indicates an even more extreme concentration of probability mass in the top-ranked sequences: the most common trigrams appear far more often than a uniform distribution would predict.

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 w1,w2,…,wTw_1, w_2, \ldots, w_T can be written using the chain rule of probability:

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

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

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

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

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

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

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

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

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

Maximum Likelihood Estimation

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

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

where:

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

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

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

Smoothing: Handling Unseen N-grams

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

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

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

where:

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

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

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

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

PythonIn [17]
CollapseExpand
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]
CollapseExpand
Bigram Language Model (Laplace smoothed):
  Vocabulary size: 15

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

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

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

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

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

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.

Character N-gram

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]
CollapseExpand
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_trigrams
ConsoleOut [20]
CollapseExpand
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 404=2,560,00040^4 = 2{,}560{,}000.

PythonIn [21]
CollapseExpand
# 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]
CollapseExpand
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]
CollapseExpand
Bar chart showing the top 10 English character trigram frequencies.
English character trigram frequencies form a distinctive language profile from the 10 most common sequences in the sample.
Bar chart showing the top 10 French character trigram frequencies.
French character trigram frequencies form a distinctive language profile from the 10 most common sequences in the sample.
VisualizationsOut [24]
CollapseExpand
Bar chart showing the top 10 German character trigram frequencies.
German character trigram frequencies form a distinctive language profile from the 10 most common sequences in the sample.
Bar chart showing the top 10 Spanish character trigram frequencies.
Spanish character trigram frequencies form a distinctive language profile from the 10 most common sequences in the sample.

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.

Skip-gram

A skip-gram is a generalization of n-grams that allows gaps between the selected tokens. A kk-skip-nn-gram selects nn tokens from a window such that no two consecutive selected tokens are more than k+1k + 1 positions apart. When k=0k = 0, skip-grams reduce to ordinary n-grams. Larger kk 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]
CollapseExpand
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]
CollapseExpand
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 kk skips produce substantially more combinations than ordinary bigrams:

PythonIn [27]
CollapseExpand
# 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]
CollapseExpand
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]
CollapseExpand
Bar chart showing vocabulary size increasing from regular bigrams through skip-1, skip-2, and skip-3 bigrams.
Vocabulary growth as skip distance increases for our sample corpus. Each additional unit of skip distance allows more word pairs to be formed, expanding the vocabulary substantially. Even at skip-3, the vocabulary is significantly larger than regular bigrams, illustrating the inherent tension between capturing flexible context and maintaining a tractable feature space.

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]
CollapseExpand
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]
CollapseExpand
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]
CollapseExpand
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]
CollapseExpand
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]
CollapseExpand
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]
CollapseExpand
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'): 4

NLTK'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]
CollapseExpand
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]
CollapseExpand
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 nn captures exactly nn consecutive words, no more and no less. Any relationship between words separated by more than n−1n - 1 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) and ngrams(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 include
  • max_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 vocabulary
  • pad_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 vector
  • TfidfVectorizer(ngram_range=(1, 3)): Extracts unigrams through trigrams with TF-IDF weighting
  • analyzer='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 nn 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 nn, 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 n−1n - 1 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

Question 1 of 100 of 10 completed
A sentence has 12 tokens. How many trigrams (3-grams) can be extracted from it?
Track your reading progress

Sign in to mark sections as read and track your learning journey

Sign in →
Join the discussionAsk a question or share what you learned.

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!

A different angle
All writing
Language AI Handbook · Vol. 1

Term Frequency: Weighting Schemes for Text Analysis

Examines raw, log-scaled, boolean, augmented, and L2-normalized term frequency variants, with sparsity analysis and efficient computation using scikit-learn.

46 min read
Language AI Handbook · Vol. 1

Inverse Document Frequency

Explains how Inverse Document Frequency (IDF) measures word importance across a corpus by weighting rare, discriminative terms higher than common words.

59 min read
Language AI Handbook · Vol. 1

TF-IDF for Text Representation

Covers TF-IDF for text representation, including the core formula, variants, normalization, document similarity, feature extraction, sklearn TfidfVectorizer.

51 min read

About the author

Reference

Citation details

Cite or share this article.

BIBTEXAcademic
@misc{brenndoerfer2025ngrams, author = {Michael Brenndoerfer}, title = {N-grams: Capturing Word Order in Text}, year = {2025}, url = {https://mbrenndoerfer.com/writing/n-grams-bigrams-trigrams-text-representation-nlp}, organization = {mbrenndoerfer.com}, note = {Accessed: 2026-10-27} }
APAAcademic
Michael Brenndoerfer (2025). N-grams: Capturing Word Order in Text. Retrieved from https://mbrenndoerfer.com/writing/n-grams-bigrams-trigrams-text-representation-nlp
MLAAcademic
Michael Brenndoerfer. "N-grams: Capturing Word Order in Text." 2026. Web. October 27, 2026. <https://mbrenndoerfer.com/writing/n-grams-bigrams-trigrams-text-representation-nlp>.
CHICAGOAcademic
Michael Brenndoerfer. "N-grams: Capturing Word Order in Text." Accessed October 27, 2026. https://mbrenndoerfer.com/writing/n-grams-bigrams-trigrams-text-representation-nlp.
HARVARDAcademic
Michael Brenndoerfer (2025) 'N-grams: Capturing Word Order in Text'. Available at: https://mbrenndoerfer.com/writing/n-grams-bigrams-trigrams-text-representation-nlp (Accessed: October 27, 2026).
SimpleBasic
Michael Brenndoerfer (2025). N-grams: Capturing Word Order in Text. https://mbrenndoerfer.com/writing/n-grams-bigrams-trigrams-text-representation-nlp
Newsletter

Stay up to date

Get new articles, book updates, and notes on AI and technology.

Unsubscribe at any time. Privacy policy