Perplexity › Held-Out Evaluation Methodology
Language AI HandbookAll chapters 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:
- Training set: Used to estimate model parameters, specifically the n-gram counts and probability estimates. This is where the model learns.
- Development set (dev set): Used to tune hyperparameters like the smoothing constant . You evaluate different settings on this set and pick the best one.
- 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.
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)Data Split:
----------------------------------------
train: 28 sentences
dev: 6 sentences
test: 6 sentencesWith 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.
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])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.11The good smoothing parameter balances two competing effects. Too little smoothing (small ) assigns very low probabilities to unseen n-grams, causing high perplexity when the test set contains novel combinations. Too much smoothing (large ) flattens the probability distribution toward uniform. This makes all words nearly equally likely regardless of context. The best sits between these extremes. This provides just enough smoothing to handle unseen combinations without destroying the signal from observed patterns.

Finally, we evaluate on the test set using the tuned parameter. This is the only time the test set is touched.
# 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"])Final Evaluation: ---------------------------------------- Best smoothing parameter: k = 0.1 Development perplexity: 78.26 Test perplexity: 52.57
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.