AI Knowledge Tree

Smoothing and back-off

Fix zero-probability n-grams with Laplace add-one and Kneser-Ney interpolation, and measure the improvement with perplexity.

⏱ 28 minPrerequisites:N-gram language models

The MLE bigram assigns probability zero to any pair never seen in training. One unseen bigram in a test sentence collapses the entire sentence probability to zero and the perplexity to +∞+\infty. Smoothing redistributes a portion of the probability mass from seen n-grams to unseen ones, so that every grammatical sentence receives a positive score[2, 3].

Laplace add-one smoothing

The simplest fix: pretend each n-gram was seen one extra time.

p^add-1(b∣a)=c(a,b)+1c(a)+V,\hat{p}_{\text{add-1}}(b \mid a) = \frac{c(a, b) + 1}{c(a) + V},

where VV is the vocabulary size (the +V+V in the denominator ensures each row still sums to 1).

Every unseen bigram now gets probability 1/(c(a)+V)1 / (c(a) + V). The cost is heavy over-assignment: for a rare context aa with c(a)=2c(a) = 2 and V=50,000V = 50{,}000, the pseudocount of 1 inflates the mass on unseen events to 50,000/50,002≈99.9%50{,}000 / 50{,}002 \approx 99.9\% of the row's probability.

Add-one is useful as a pedagogical baseline but too aggressive for any practical use.

Discounting and redistribution

A better approach: reduce the counts of seen events by a small amount dd, and pool the recovered mass into a "leftover" budget distributed to unseen events. This is the principle behind all practical smoothing methods.

Define the discounted count c∗(a,b)=c(a,b)−dc^*(a, b) = c(a, b) - d for some 0<d<10 < d < 1. The discounted probability is:

p^disc(b∣a)=c∗(a,b)c(a)for c(a,b)>0.\hat{p}_{\text{disc}}(b \mid a) = \frac{c^*(a, b)}{c(a)} \quad \text{for } c(a,b) > 0.

The total mass removed from row aa is:

λ(a)=1−∑b:c(a,b)>0p^disc(b∣a)=d⋅N1(a)c(a),\lambda(a) = 1 - \sum_{b: c(a,b)>0} \hat{p}_{\text{disc}}(b \mid a) = \frac{d \cdot N_1(a)}{c(a)},

where N1(a)N_1(a) is the number of distinct words bb with c(a,b)≥1c(a,b) \ge 1. This leftover mass λ(a)\lambda(a) is redistributed to unseen bigrams by a back-off distribution.

Kneser-Ney smoothing

Kneser-Ney (KN) is the best-performing classical smoother; it was the standard for over a decade[1]. The key innovation is the back-off distribution pKN(b)p_{\text{KN}}(b): instead of using the unigram frequency of bb, use the continuation probability, the number of distinct contexts in which bb appears.

pKN(b)=∣{a:c(a,b)>0}∣∣{(a,b):c(a,b)>0}∣.p_{\text{KN}}(b) = \frac{|\{a : c(a,b) > 0\}|}{|\{(a,b) : c(a,b) > 0\}|}.

Words like "York" have high unigram frequency but only appear after "New", so their continuation count is low. Words like "the" appear in many different contexts, so their continuation count is high. The continuation probability is a much better estimate of "how useful is bb as a completion when we have never seen the context."

The full interpolated KN bigram:

p^KN(b∣a)=max⁡(c(a,b)−d,0)c(a)+λ(a) pKN(b).\hat{p}_{\text{KN}}(b \mid a) = \frac{\max(c(a,b) - d, 0)}{c(a)} + \lambda(a)\, p_{\text{KN}}(b).

For higher-order n-grams, the interpolation recurses: the trigram backs off to the KN bigram, which backs off to the KN unigram.

Back-off vs interpolation

Two redistribution strategies:

Back-off (Katz 1987): use the discounted n-gram probability when the count is nonzero; fall back to the (n−1)(n-1)-gram otherwise, weighted by a normalizing factor.

Interpolation: always take a weighted sum of all orders 1,…,n1, \dots, n, regardless of whether the n-gram was seen. Interpolation consistently outperforms pure back-off; KN uses interpolation.

Implementing Laplace and measuring perplexity

python · numpy
import numpy as np
from collections import Counter, defaultdict

corpus = [
    ["<bos>", "I", "am", "Sam", "<eos>"],
    ["<bos>", "Sam", "I", "am", "<eos>"],
    ["<bos>", "I", "do", "not", "like", "green", "eggs", "<eos>"],
]
unigrams = Counter(w for sent in corpus for w in sent)
bigrams  = Counter((a, b) for sent in corpus for a, b in zip(sent, sent[1:]))
V = len(unigrams)

def p_mle(a, b):
    return bigrams[(a, b)] / unigrams[a] if unigrams[a] else 0.0

def p_add1(a, b):
    return (bigrams[(a, b)] + 1) / (unigrams[a] + V)

def perplexity(model_p, test_sentences):
    log_prob, n = 0.0, 0
    for sent in test_sentences:
        for a, b in zip(sent, sent[1:]):
            log_prob += np.log(model_p(a, b) + 1e-300)
            n += 1
    return np.exp(-log_prob / n)

test = [
    ["<bos>", "I", "like", "ham", "<eos>"],   # contains unseen "I like"
    ["<bos>", "Sam", "I", "am", "<eos>"],
]

print(f"MLE perplexity:   {perplexity(p_mle,  test):.1f}")   # inf due to "I like"
print(f"Add-1 perplexity: {perplexity(p_add1, test):.1f}")   # finite

The MLE model returns +∞+\infty because "I like" was unseen. Add-one fixes this at the cost of significant probability distortion: each seen bigram has its probability shrunk to allocate mass to the vast space of unseen events.

Maximum-likelihood n-gram estimates give unseen events probability 0, fatal for a language model. Smoothing shaves mass off the seen events and redistributes it to the unseen ones.
Maximum-likelihood n-gram estimates give unseen events probability 0, fatal for a language model. Smoothing shaves mass off the seen events and redistributes it to the unseen ones.

What smoothing cannot fix

No smoothing method overcomes the fixed-range structural limit. Redistributing mass among unseen bigrams says nothing about which unseen bigrams are more likely; that requires generalizing through the geometry of word representations, which is exactly what embedding-based neural models provide.

Exercises

Exercise 1. A context aa has c(a)=4c(a) = 4 and a vocabulary of V=6V = 6. The only bigram seen with this context is (a,b)(a, b) with c(a,b)=4c(a, b) = 4. Compute p^add-1(b∣a)\hat{p}_{\text{add-1}}(b \mid a) and p^add-1(b′∣a)\hat{p}_{\text{add-1}}(b' \mid a) for any unseen b′b', and check that the row sums to 1.

Solution

Step 1. Apply the add-one formula p^add-1(b∣a)=c(a,b)+1c(a)+V\hat{p}_{\text{add-1}}(b \mid a) = \frac{c(a,b) + 1}{c(a) + V} to the seen pair:

p^add-1(b∣a)=4+14+6=510=0.5.\hat{p}_{\text{add-1}}(b \mid a) = \frac{4 + 1}{4 + 6} = \frac{5}{10} = 0.5.

Step 2. For any of the V−1=5V - 1 = 5 unseen next-tokens b′b', c(a,b′)=0c(a, b') = 0:

p^add-1(b′∣a)=0+14+6=110=0.1.\hat{p}_{\text{add-1}}(b' \mid a) = \frac{0 + 1}{4 + 6} = \frac{1}{10} = 0.1.

Step 3. Sum over the whole vocabulary: one seen token at 0.50.5 plus five unseen at 0.10.1:

0.5+5⋅0.1=0.5+0.5=1.0.5 + 5 \cdot 0.1 = 0.5 + 0.5 = 1.

The row normalizes. Note the distortion: the MLE gave bb probability 4/4=14/4 = 1, but add-one shrinks it to 0.50.5 to fund the unseen mass, even though bb was the only thing ever observed here.

Exercise 2. Kneser-Ney replaces the unigram back-off with the continuation probability pKN(b)∝∣{a:c(a,b)>0}∣p_{\text{KN}}(b) \propto |\{a : c(a, b) > 0\}|. Take a corpus where "York" appears 100 times but always after "New", and "city" appears 40 times after 12 distinct words. Which word has the larger continuation probability, and why is that the behavior KN wants?

Solution

Step 1. Continuation probability counts distinct preceding contexts, not raw frequency. "York" appears in exactly one context ("New"), so its continuation count is ∣{a:c(a,York)>0}∣=1|\{a : c(a, \text{York}) > 0\}| = 1.

Step 2. "city" appears after 12 distinct words, so its continuation count is 1212.

Step 3. The unnormalized continuation scores are 11 for "York" and 1212 for "city", so "city" has the larger continuation probability despite "York" having higher raw frequency (100100 versus 4040).

Step 4. This is the behavior KN wants. When the model backs off because a context was never seen, the question is "how plausible is this word as a generic continuation?" "York" is frequent only because of one fixed collocation; outside "New", seeing "York" is unlikely. "city" genuinely follows many words, so it is a safer back-off bet. Penalizing words trapped in a single collocation is the central KN idea.

Exercise 3 (code). On the toy corpus, build add-one and MLE bigram models. Verify that the MLE assigns probability 0 to the unseen bigram ("I", "like") while add-one assigns it a positive probability, and that the add-one row for context "I" still sums to 1.

Solution
python · numpy
import numpy as np
from collections import Counter

corpus = [
    ["<bos>", "I", "am", "Sam", "<eos>"],
    ["<bos>", "Sam", "I", "am", "<eos>"],
    ["<bos>", "I", "do", "not", "like", "green", "eggs", "<eos>"],
]
unigrams = Counter(w for sent in corpus for w in sent)
bigrams = Counter((a, b) for sent in corpus for a, b in zip(sent, sent[1:]))
V = len(unigrams)
vocab = list(unigrams)

def p_mle(a, b):
    return bigrams[(a, b)] / unigrams[a] if unigrams[a] else 0.0

def p_add1(a, b):
    return (bigrams[(a, b)] + 1) / (unigrams[a] + V)

# "I like" never occurs in training
assert p_mle("I", "like") == 0.0
assert p_add1("I", "like") > 0.0

# Add-one row for context "I" sums to 1 over the full vocabulary
row_sum = sum(p_add1("I", w) for w in vocab)
assert np.isclose(row_sum, 1.0)
print("p_mle(I->like) =", p_mle("I", "like"),
      " p_add1(I->like) =", round(p_add1("I", "like"), 4),
      " row sum =", round(row_sum, 4))

The MLE gives the unseen bigram exactly 0; add-one lifts it to 1/(c(I)+V)1 / (c(\text{I}) + V) and the smoothed row still normalizes over the whole vocabulary.

The next lesson introduces BPE subword tokenization, which is a prerequisite for any neural LM.


References

  1. [1]Chen, S. F., & Goodman, J. (1999). "An empirical study of smoothing techniques for language modeling". Computer Speech & Language, 13(4), 359-394. The definitive comparison; establishes interpolated Kneser-Ney as the strongest classical method. doi:10.1006/csla.1999.0128↩
  2. [2]Manning, C. D., & Schütze, H. (1999). Foundations of Statistical Natural Language Processing. MIT Press. Discounting, back-off, and interpolation.↩
  3. [3]Jurafsky, D., & Martin, J. H. (2024). Speech and Language Processing (3rd ed. draft). Smoothing of n-gram models.↩