Perplexity › Held-Out Evaluation Methodology

Michael BrenndoerferMarch 26, 20257 min read
Language AI HandbookAll chapters
Part 2 of 67Classical Text Representations
Chapter 10Perplexity › Held-Out Evaluation Methodology
Reading section 10 of 17 · Held-Out Evaluation Methodology · 7 minSection 10 of 17 · 7 min

Book contents

Section 10 of 17 · 7 min read

Perplexity › Held-Out Evaluation MethodologyOne section · Sections in this chapter10 / 17 ↓
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

Proper evaluation requires careful data splitting to ensure that model choices are made on separate data from final performance assessment. The standard approach uses three sets:

  1. Training set: Used to estimate model parameters, specifically the n-gram counts and probability estimates. This is where the model learns.
  2. Development set (dev set): Used to tune hyperparameters like the smoothing constant kk. You evaluate different settings on this set and pick the best one.
  3. Test set: Used only for final evaluation, never touched during development. This gives an unbiased estimate of how the model will perform on new data.

The critical rule is that the test set must never be used to make any modeling decisions. If you ever tune your model or choose between alternatives based on test set performance, you've inadvertently "trained" on the test set, and your performance estimate will be optimistic. This is called data leakage, and it's one of the most common mistakes in building evaluation systems.

Why not just use a train/test split without a development set? Because tuning on the test set poisons the well: you might try 20 different smoothing values, pick the one that works best on the test set, and report that number as your performance. But that number reflects how well you searched over the test set, not how well your model generalizes. Adding a separate development set for tuning leaves the test set completely uncontaminated.

In[25]:
Code
def split_data(sentences, train_ratio=0.7, dev_ratio=0.15, seed=42):
    """Split data into train/dev/test sets."""
    import random

    random.seed(seed)

    shuffled = sentences.copy()
    random.shuffle(shuffled)

    n = len(shuffled)
    train_end = int(n * train_ratio)
    dev_end = int(n * (train_ratio + dev_ratio))

    return {
        "train": shuffled[:train_end],
        "dev": shuffled[train_end:dev_end],
        "test": shuffled[dev_end:],
    }


# Create a larger corpus for demonstration
larger_corpus = """
the quick brown fox jumps over the lazy dog
a quick brown dog runs through the green field
the lazy cat sleeps on the warm mat
a small bird sings in the tall tree
the big dog chases the small cat
a brown fox hides behind the old tree
the green field stretches to the blue horizon
a lazy dog lies in the warm sun
the tall tree provides cool shade
a quick cat catches the small mouse
the warm sun shines on the green grass
a big bird flies over the blue lake
the old tree stands in the open field
a small mouse hides in the tall grass
the blue lake reflects the clear sky
the red fox runs across the dry plain
a gentle breeze blows through the open window
the young girl reads a long book
a fast river flows down the steep hill
the clever rabbit hops over the wooden fence
a hungry wolf howls at the bright moon
the small house sits near the wide river
a white cloud drifts across the pale sky
the bold eagle soars above the rocky cliff
a tired horse rests beside the wooden barn
the cold wind sweeps across the frozen lake
a playful puppy chases the orange butterfly
the old farmer walks along the dusty road
a dark storm gathers above the distant hills
the curious fox peers through the tall bushes
a sleepy owl blinks in the fading light
the noisy crow perches on the broken fence
a swift deer leaps over the mossy log
the happy child runs through the wet grass
a quiet stream winds between the smooth stones
the bright sun warms the empty courtyard
a grey rabbit nibbles the fresh green clover
the spotted fish swims beneath the clear water
a strong wind bends the slender birch trees
the golden harvest fills the wide open barn
""".strip().split("\n")

corpus_sentences = [line.lower().split() for line in larger_corpus]
splits = split_data(corpus_sentences)
Out[26]:
Console
Data Split:
----------------------------------------
   train: 28 sentences
     dev: 6 sentences
    test: 6 sentences

With this split, most data goes to training, a smaller portion to development for tuning, and the rest is held out for final evaluation. The test set remains untouched until we've finalized all model choices.

Now let's use the development set to tune the smoothing parameter. This is a principled way to select hyperparameters: try multiple values, evaluate each on the development set, and pick the best.

In[27]:
Code
def tune_smoothing(train_sentences, dev_sentences, n=2):
    """Find optimal smoothing parameter k using dev set."""
    k_values = [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0]
    results = []

    for k in k_values:
        model = NgramModel(n=n, k=k)
        model.train(train_sentences)
        pp = model.perplexity(dev_sentences)
        results.append((k, pp))

    return results


tuning_results = tune_smoothing(splits["train"], splits["dev"], n=2)
best_k, best_pp = min(tuning_results, key=lambda x: x[1])
Out[28]:
Console
Smoothing Parameter Tuning (Bigram Model):
----------------------------------------
         k  Dev Perplexity
----------------------------------------
     0.001          176.29
     0.005          111.79
     0.010           96.48
     0.050           79.69
     0.100           78.26 <-- best
     0.500           85.94
     1.000           93.11

The good smoothing parameter balances two competing effects. Too little smoothing (small kk) assigns very low probabilities to unseen n-grams, causing high perplexity when the test set contains novel combinations. Too much smoothing (large kk) flattens the probability distribution toward uniform. This makes all words nearly equally likely regardless of context. The best kk sits between these extremes. This provides just enough smoothing to handle unseen combinations without destroying the signal from observed patterns.

Out[29]:
Visualization
Line plot showing perplexity on development set as a function of smoothing parameter k on a log scale, with a U-shaped curve and a red star at the minimum.
Development set perplexity across smoothing parameter values for a bigram model. The U-shaped curve shows the tradeoff between under-smoothing (very small k causes high perplexity from zero-probability n-grams) and over-smoothing (very large k flattens the distribution, losing learned patterns). The optimal k, marked in red, minimizes development perplexity.

Finally, we evaluate on the test set using the tuned parameter. This is the only time the test set is touched.

In[30]:
Code
# Train final model with best k
final_model = NgramModel(n=2, k=best_k)
final_model.train(splits["train"])

# Evaluate on test set
test_pp = final_model.perplexity(splits["test"])
Out[31]:
Console
Final Evaluation:
----------------------------------------
Best smoothing parameter: k = 0.1
Development perplexity: 78.26
Test perplexity: 52.57
Out[32]:
Console

Test perplexity is 32.8% lower than development, suggesting the test set may be easier or more similar to training data.

The test perplexity may differ from development perplexity because the test set contains different sentences. If test perplexity is much higher, it could indicate overfitting to the development set during hyperparameter tuning (though with only 7 values to try, this is unlikely). If it's much lower, the test set might happen to contain more familiar patterns.