Smoothing Techniques › Code Implementation

Michael BrenndoerferMarch 22, 202515 min read
Language AI HandbookAll chapters
Part 2 of 67Classical Text Representations
Chapter 9Smoothing Techniques › Code Implementation
Reading section 9 of 13 · Code Implementation · 15 minSection 9 of 13 · 15 min

Book contents

Section 9 of 13 · 15 min read

Smoothing Techniques › Code ImplementationOne section · Sections in this chapter9 / 13 ↓
Reading tools Definitions & inline links

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

Article links

Make inline references clickable

Let's implement each smoothing method step by step, building up to Kneser-Ney. We'll use a simple corpus to make the computations visible and verifiable.

Setup and Corpus

First, let's prepare a small corpus and build the basic counting infrastructure. We'll use a slightly extended corpus so that the frequency-of-frequency statistics have enough variety to be meaningful.

In[3]:
Code
from collections import Counter

# Small illustrative corpus
corpus = (
    "the cat sat on the mat the cat sat the dog sat on the mat "
    "a cat and a dog sat on a mat the dog and the cat ran away "
    "the quick brown fox jumped over the lazy dog the fox ran fast"
)

# Tokenize
tokens = corpus.lower().split()

# Build vocabulary
vocab = sorted(set(tokens))
V = len(vocab)


# Count unigrams, bigrams, trigrams
def build_ngrams(tokens, n):
    ngrams = []
    for i in range(len(tokens) - n + 1):
        ngrams.append(tuple(tokens[i : i + n]))
    return ngrams


unigrams = build_ngrams(tokens, 1)
bigrams = build_ngrams(tokens, 2)
trigrams = build_ngrams(tokens, 3)

unigram_counts = Counter(unigrams)
bigram_counts = Counter(bigrams)
trigram_counts = Counter(trigrams)

N = len(tokens)  # Total tokens
Out[4]:
Console
Corpus size: 44 tokens
Vocabulary size: 17 unique words
Unique bigrams: 33
Unique trigrams: 38

Top 5 bigrams: [(('the', 'cat'), 3), (('sat', 'on'), 3), (('cat', 'sat'), 2), (('on', 'the'), 2), (('the', 'mat'), 2)]

Laplace Smoothing

Now let's implement Laplace and add-k smoothing functions and compare them to MLE for both seen and unseen bigrams.

In[5]:
Code
def laplace_bigram_prob(w2, w1, bigram_counts, unigram_counts, V):
    """Add-one smoothed bigram probability P(w2 | w1)."""
    numerator = bigram_counts.get((w1, w2), 0) + 1
    denominator = unigram_counts.get((w1,), 0) + V
    return numerator / denominator


def add_k_bigram_prob(w2, w1, bigram_counts, unigram_counts, V, k=0.1):
    """Add-k smoothed bigram probability P(w2 | w1)."""
    numerator = bigram_counts.get((w1, w2), 0) + k
    denominator = unigram_counts.get((w1,), 0) + k * V
    return numerator / denominator


# Compare MLE vs Laplace vs add-k for a seen bigram
w1, w2 = "the", "cat"
mle_count = bigram_counts.get((w1, w2), 0)
ctx_count = unigram_counts.get((w1,), 0)

mle_prob = mle_count / ctx_count if ctx_count > 0 else 0.0
laplace_prob = laplace_bigram_prob(w2, w1, bigram_counts, unigram_counts, V)
addk_prob = add_k_bigram_prob(w2, w1, bigram_counts, unigram_counts, V, k=0.1)

# For an unseen bigram
w1_u, w2_u = "the", "fox"
mle_unseen = bigram_counts.get((w1_u, w2_u), 0) / unigram_counts.get((w1_u,), 1)
laplace_unseen = laplace_bigram_prob(
    w2_u, w1_u, bigram_counts, unigram_counts, V
)
addk_unseen = add_k_bigram_prob(
    w2_u, w1_u, bigram_counts, unigram_counts, V, k=0.1
)
Out[6]:
Console
Bigram ('the', 'cat'): count=3, context count=10
  MLE:        0.3000
  Laplace:    0.1481
  Add-k(0.1): 0.2650

Unseen bigram ('the', 'fox'):
  MLE:        0.1000  (zero probability!)
  Laplace:    0.0741
  Add-k(0.1): 0.0940

Notice that both Laplace and add-k give the same probability to the unseen bigram "the fox" regardless of the word "fox." This uniform treatment of all unseen words is the fundamental weakness that Kneser-Ney addresses.

Let's also visualize how the discount varies with count for these simple methods. We want to see how much probability mass is being moved relative to the raw count.

Out[7]:
Visualization
Line chart showing discount ratio vs raw count for Laplace and add-k smoothing.
Effective discount ratio for Laplace and add-k smoothing as a function of raw bigram count. The discount ratio shows what fraction of the original count is preserved after smoothing. Laplace (k=1) preserves almost nothing for low-count bigrams, effectively treating them as uniform noise. Add-k with k=0.1 is more conservative but still discounts low-count bigrams heavily.

The discount ratio shows how much of the original count is preserved. A ratio below 1 means we're reducing the effective probability of this bigram. Laplace with k=1k=1 discounts aggressively for low counts, bringing a count-1 bigram down to roughly 10% of its original probability in this example. Add-k with k=0.1k=0.1 is more conservative, and k=0.01k=0.01 barely discounts at all. The key insight is that none of these methods differentiate between how much they discount based on the structure of the language, they just use the same mechanical formula for all contexts.

Good-Turing Estimation

Now let's implement Good-Turing and examine the frequency-of-frequency distribution for our corpus.

In[8]:
Code
def compute_frequency_of_frequencies(count_dict):
    """Compute N_c: number of n-grams with count c."""
    freq_of_freq = Counter(count_dict.values())
    return freq_of_freq


def good_turing_adjusted_count(c, freq_of_freq):
    """
    Compute the Good-Turing adjusted count c* for a given raw count c.
    Formula: c* = (c+1) * N_{c+1} / N_c
    Falls back to raw count c when estimate is unreliable.
    """
    N_c = freq_of_freq.get(c, 0)
    N_c1 = freq_of_freq.get(c + 1, 0)
    if N_c == 0 or N_c1 == 0:
        return c  # Fall back to MLE for unreliable estimates
    return (c + 1) * N_c1 / N_c


# Build frequency of frequencies for bigrams
bigram_fof = compute_frequency_of_frequencies(bigram_counts)

N1 = bigram_fof.get(1, 0)
N2 = bigram_fof.get(2, 0)
p_unseen = N1 / N  # Total probability of all unseen n-grams

# GT adjusted counts for common values
gt_adjusted = {
    c: good_turing_adjusted_count(c, bigram_fof) for c in range(1, 6)
}
Out[9]:
Console
Frequency of Frequencies (bigrams):
  N_1 = 25  (25 distinct bigrams appear 1 time(s))
  N_2 = 6  (6 distinct bigrams appear 2 time(s))
  N_3 = 2  (2 distinct bigrams appear 3 time(s))

N1 (hapax count) = 25
N2 (twice-seen)  = 6
Estimated prob. mass for all unseen bigrams: 0.5682

Good-Turing adjusted counts:
  c=1  ->  c* = 0.480  (discount = 0.520)
  c=2  ->  c* = 1.000  (discount = 1.000)
  c=3  ->  c* = 3.000  (discount = 0.000)
  c=4  ->  c* = 4.000  (discount = 0.000)
  c=5  ->  c* = 5.000  (discount = 0.000)
Out[10]:
Visualization
Line chart comparing raw counts vs Good-Turing adjusted counts for bigram counts 1 through 5.
Good-Turing adjusted counts compared to raw counts for bigrams with counts 1 through 5. The adjusted count is always at or below the raw count, which reflects the discounting that redistributes probability mass to unseen events. Singletons receive the largest relative discount, while bigrams with higher counts are discounted proportionally less.

The shaded region shows the probability mass that Good-Turing redistributes from seen to unseen n-grams. For our small corpus, the adjustments may be large because NcN_c values fluctuate heavily with so few samples. In a real corpus of millions of sentences, the NcN_c sequence is much smoother and the Good-Turing estimates are more reliable.

Absolute Discounting

Now let's implement absolute discounting, which approximates Good-Turing with a single constant discount.

In[11]:
Code
def estimate_discount(bigram_fof):
    """
    Estimate absolute discount d from frequency-of-frequency counts.
    Formula: d = N1 / (N1 + 2*N2)
    """
    N1 = bigram_fof.get(1, 0)
    N2 = bigram_fof.get(2, 0)
    if N1 + 2 * N2 == 0:
        return 0.75  # Fallback default
    return N1 / (N1 + 2 * N2)


d = estimate_discount(bigram_fof)


def abs_discount_prob(w2, w1, bigram_counts, unigram_counts, unigram_probs, d):
    """Absolute discounting bigram probability P(w2 | w1)."""
    c_w1w2 = bigram_counts.get((w1, w2), 0)
    c_w1 = unigram_counts.get((w1,), 0)

    if c_w1 == 0:
        return unigram_probs.get(w2, 1e-10)

    # Distinct word types following w1
    n_types = sum(1 for (a, _) in bigram_counts if a == w1)
    lambda_w1 = (d * n_types) / c_w1

    discounted = max(c_w1w2 - d, 0) / c_w1
    return discounted + lambda_w1 * unigram_probs.get(w2, 1e-10)


# Compute unigram probabilities (MLE)
total_tokens = sum(unigram_counts.values())
unigram_probs = {w[0]: c / total_tokens for w, c in unigram_counts.items()}

# Test
prob_seen = abs_discount_prob(
    "cat", "the", bigram_counts, unigram_counts, unigram_probs, d
)
prob_unseen = abs_discount_prob(
    "fox", "the", bigram_counts, unigram_counts, unigram_probs, d
)
Out[12]:
Console
Estimated discount d = 0.6757

Absolute discounting P('cat' | 'the')  = 0.2693
Absolute discounting P('fox' | 'the')  = 0.0509

Kneser-Ney Smoothing

Now let's implement Kneser-Ney. The key addition is computing continuation counts for each word.

In[13]:
Code
from collections import defaultdict


def build_kneser_ney_counts(bigram_counts):
    """
    Build continuation counts for Kneser-Ney.
    For each word w, count the number of distinct words that precede it.
    This gives the continuation count |{v : C(v, w) > 0}|.
    """
    predecessors = defaultdict(set)
    for u, w in bigram_counts:
        predecessors[w].add(u)

    continuation_count = {w: len(preds) for w, preds in predecessors.items()}
    total_distinct_bigrams = len(bigram_counts)

    return continuation_count, total_distinct_bigrams


continuation_count, total_distinct_bigrams = build_kneser_ney_counts(
    bigram_counts
)


def kneser_ney_unigram(w, continuation_count, total_distinct_bigrams):
    """KN continuation probability for word w (lower-order unigram)."""
    return continuation_count.get(w, 0) / total_distinct_bigrams


def kneser_ney_bigram(
    w2,
    w1,
    bigram_counts,
    unigram_counts,
    continuation_count,
    total_distinct_bigrams,
    d,
):
    """Kneser-Ney smoothed bigram probability P(w2 | w1)."""
    c_w1w2 = bigram_counts.get((w1, w2), 0)
    c_w1 = unigram_counts.get((w1,), 0)

    if c_w1 == 0:
        return kneser_ney_unigram(
            w2, continuation_count, total_distinct_bigrams
        )

    n_types = sum(1 for (a, _) in bigram_counts if a == w1)
    lambda_w1 = (d * n_types) / c_w1

    discounted = max(c_w1w2 - d, 0) / c_w1
    lower = kneser_ney_unigram(w2, continuation_count, total_distinct_bigrams)

    return discounted + lambda_w1 * lower


# Test for several words
test_words = ["cat", "dog", "mat", "fox", "aardvark"]
Out[14]:
Console
Continuation counts (number of distinct predecessors):
  'cat': continuation_count=2, KN_unigram=0.0606, MLE_unigram=0.0909
  'dog': continuation_count=3, KN_unigram=0.0909, MLE_unigram=0.0909
  'mat': continuation_count=2, KN_unigram=0.0606, MLE_unigram=0.0682
  'fox': continuation_count=2, KN_unigram=0.0606, MLE_unigram=0.0455
  'aardvark': continuation_count=0, KN_unigram=0.0000, MLE_unigram=0.0000

Kneser-Ney bigram probabilities P(w | 'the'):
  P('cat' | 'the'):  KN=0.2570, AbsDiscount=0.2693
  P('dog' | 'the'):  KN=0.1693, AbsDiscount=0.1693
  P('mat' | 'the'):  KN=0.1570, AbsDiscount=0.1601
  P('fox' | 'the'):  KN=0.0570, AbsDiscount=0.0509
  P('aardvark' | 'the'):  KN=0.0000, AbsDiscount=0.0000

Notice how the KN unigram for "fox" differs from its MLE unigram. In the MLE model, "fox" might appear several times in the corpus, but Kneser-Ney asks: how many distinct words precede "fox"? Only "the" does in our corpus, so the continuation count is 1 and the KN unigram is small. This correctly reflects that "fox" is not a versatile continuation word.

Out[15]:
Visualization
Bar chart of MLE unigram probabilities ranked by frequency.
MLE unigram probabilities for words in the corpus. Raw frequency drives MLE probability, so words like 'the' and 'sat' dominate simply because they appear often overall.
Bar chart of Kneser-Ney continuation probabilities showing different ranking from MLE.
Kneser-Ney continuation probabilities for the same words. Words that appear after many different predecessors ('the', 'a', 'on') score higher, while words tightly bound to a single preceding context ('fox', 'quick', 'lazy') score lower, correctly capturing their limited distributional versatility.

The contrast between the two plots reveals the power of the continuation count. In the MLE plot, words rank by their raw frequency in the corpus. In the KN continuation plot, words rank by how many distinct predecessors they have, capturing their contextual versatility. This is exactly the signal that makes Kneser-Ney a superior lower-order distribution.

Comparing Perplexity on a Test Set

Let's now evaluate all four methods on a held-out set of test sentences using perplexity as the evaluation metric.

In[16]:
Code
import math


def sentence_log_prob(sentence, prob_fn):
    """Compute log probability of a sentence using a bigram model."""
    words = sentence.lower().split()
    log_p = 0.0
    for i in range(1, len(words)):
        p = prob_fn(words[i], words[i - 1])
        if p <= 0:
            p = 1e-10  # Clamp to avoid log(0)
        log_p += math.log2(p)
    return log_p


def perplexity(sentences, prob_fn):
    """Compute perplexity of a list of sentences under a bigram model."""
    total_log_p = 0.0
    total_words = 0
    for sent in sentences:
        words = sent.lower().split()
        total_log_p += sentence_log_prob(sent, prob_fn)
        total_words += len(words) - 1  # Number of prediction steps
    avg_log_p = total_log_p / total_words
    return 2 ** (-avg_log_p)


# Test sentences (some seen patterns, some new combinations)
test_sentences = [
    "the cat ran fast",
    "a dog sat on a mat",
    "the fox jumped over a mat",
    "the brown cat sat on the mat",
    "a quick dog ran away",
]

# Build probability functions for each method
laplace_fn = lambda w2, w1: laplace_bigram_prob(
    w2, w1, bigram_counts, unigram_counts, V
)
addk_fn = lambda w2, w1: add_k_bigram_prob(
    w2, w1, bigram_counts, unigram_counts, V, k=0.1
)
abs_fn = lambda w2, w1: abs_discount_prob(
    w2, w1, bigram_counts, unigram_counts, unigram_probs, d
)
kn_fn = lambda w2, w1: kneser_ney_bigram(
    w2,
    w1,
    bigram_counts,
    unigram_counts,
    continuation_count,
    total_distinct_bigrams,
    d,
)

pp_laplace = perplexity(test_sentences, laplace_fn)
pp_addk = perplexity(test_sentences, addk_fn)
pp_abs = perplexity(test_sentences, abs_fn)
pp_kn = perplexity(test_sentences, kn_fn)
Out[17]:
Console
Perplexity comparison on test sentences:
  Laplace (add-1):      10.62
  Add-k (k=0.1):        6.76
  Absolute Discounting: 7.72
  Kneser-Ney:           7.44

Lower perplexity = better model (less surprised by the test data)

Lower perplexity indicates a better model. Kneser-Ney and absolute discounting typically outperform Laplace and add-k, especially as the test set contains more unseen n-grams. On larger, more realistic corpora, the gap between Kneser-Ney and simpler methods like Laplace is dramatic.

Out[18]:
Visualization
Line chart showing perplexity vs k parameter for add-k smoothing with Kneser-Ney baseline.
Perplexity of the add-k model as a function of the k parameter on the test sentences. Very small k values leave unseen bigrams with near-zero probability, causing high perplexity when those bigrams appear in test data. Large k values over-smooth by erasing the discriminative signal from seen bigrams. Even the best k in this range produces higher perplexity than Kneser-Ney, shown as a dashed reference line.

The U-shaped curve in the add-k perplexity chart is informative. Too-small kk fails because unseen bigrams get near-zero probability and the test sentences contain many novel combinations. Too-large kk fails because it smooths away the evidence from well-attested bigrams, making the model approximately uniform. The optimal kk sits in the middle, but even that optimum falls short of Kneser-Ney, which achieves lower perplexity without any tuning of kk at all.

Key Parameters

The key parameters for smoothing methods are:

  • k (add-k smoothing): The additive constant. Tune on a held-out development set. Typical range: 0.001 to 0.5. Smaller values preserve more of the MLE signal; larger values move toward uniform redistribution.
  • d (absolute discounting / Kneser-Ney): The discount subtracted from every non-zero count. Estimated automatically as N1/(N1+2N2)N_1 / (N_1 + 2N_2) from the training data. Typically around 0.75 for most natural language corpora.
  • n (n-gram order): Higher orders capture more context but suffer more from data sparsity. Smoothing is more important for higher-order models. Trigrams with Kneser-Ney often give the best balance of expressiveness and reliability.