Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Tokens gave the model its vocabulary. Embeddings gave those tokens useful geometry. Now an engineer needs a measurement: when the real next token appears, how much probability did the model assign to it?
Suppose an incident log begins The request returned. A model that assigns high probability to 500 and low probability to volcano understands this local pattern better than a model that treats both as equally plausible. Perplexity converts that held-out prediction behavior into one number.
You'll build that number from probabilities, implement its failure-resistant details, and write an evaluation report that answers a more important question than "is the score low?": is this score comparable to the previous run, and is it enough for the product decision?
Prediction-fit metric: Perplexity is a next-token fit metric for causal language models. It's useful when you keep the evaluation contract fixed. It isn't a score for factuality, helpfulness, or safe product behavior.
From surprise to a metric
A causal language model assigns a probability to every possible next token. During evaluation, you don't reward the model for a token it could have emitted. You score the probability it assigned to the token that occurred in held-out text.
If the observed token has probability , its negative log-likelihood (NLL), or surprise, is:
A certain correct prediction has probability 1 and surprise 0. A probability close to 0 produces a large penalty. Confident misses hurt language-model loss the most.
1import math
2
3probabilities = {
4 "500": 0.60,
5 "200": 0.25,
6 "volcano": 0.001,
7}
8
9for token in ["500", "200", "volcano"]:
10 surprise = -math.log(probabilities[token])
11 print(f"{token:9s} probability={probabilities[token]:.3f} surprise={surprise:.3f} nats")1500 probability=0.600 surprise=0.511 nats
2200 probability=0.250 surprise=1.386 nats
3volcano probability=0.001 surprise=6.908 natsThe held-out token matters. If the log says 500, the first score counts. It doesn't matter that 200 also sounded reasonable: likelihood evaluates the text the model was asked to predict.
A model assigns probability 0.8 to the observed token on one step and 0.02 on another. Which step dominates its loss, and why?
Answer
The 0.02 step dominates because negative log-likelihood grows as assigned probability falls. A confident miss contributes far more surprise than an already likely observed token.
Average surprise becomes perplexity
For held-out tokens , the average NLL is:
Perplexity exponentiates that average:
This is the standard definition used for autoregressive, or causal, language models. It isn't the standard metric for masked models such as BERT, because they are trained to predict masked positions rather than the next token in sequence.[1]
Start with three observed token probabilities:
1import math
2
3observed_probabilities = [0.50, 0.10, 0.80]
4token_nll = [-math.log(probability) for probability in observed_probabilities]
5average_nll = sum(token_nll) / len(token_nll)
6perplexity = math.exp(average_nll)
7
8print(f"token NLL: {[round(value, 3) for value in token_nll]}")
9print(f"average NLL: {average_nll:.3f} nats")
10print(f"perplexity: {perplexity:.2f}")1token NLL: [0.693, 2.303, 0.223]
2average NLL: 1.073 nats
3perplexity: 2.92The output means the model behaved, on average, as though it faced about 2.92 equally likely choices at each prediction step. That effective choice count is an interpretation, not a claim that exactly 2.92 vocabulary tokens were available.
1import math
2
3for equally_likely_options in [1, 4, 20, 100]:
4 probability = 1 / equally_likely_options
5 loss = -math.log(probability)
6 perplexity = math.exp(loss)
7 print(f"{equally_likely_options:3d} options -> loss={loss:.3f}, PPL={perplexity:.1f}")11 options -> loss=-0.000, PPL=1.0
2 4 options -> loss=1.386, PPL=4.0
3 20 options -> loss=2.996, PPL=20.0
4100 options -> loss=4.605, PPL=100.0

Compute from logits without numerical failure
Models produce logits, not probabilities. A naive implementation calls exp(logit) directly. Large logits can overflow even though the eventual softmax probabilities are ordinary values. Stable log-softmax subtracts the largest logit before exponentiating.
1import math
2
3def stable_log_softmax(logits: list[float]) -> list[float]:
4 maximum = max(logits)
5 log_normalizer = maximum + math.log(
6 sum(math.exp(value - maximum) for value in logits)
7 )
8 return [value - log_normalizer for value in logits]
9
10logits = [1000.0, 998.0, 997.0]
11observed_token_id = 0
12
13try:
14 math.exp(logits[observed_token_id])
15except OverflowError:
16 print("naive exp(logit) overflowed")
17
18log_probabilities = stable_log_softmax(logits)
19nll = -log_probabilities[observed_token_id]
20print(f"stable NLL={nll:.3f}, PPL={math.exp(nll):.3f}")1naive exp(logit) overflowed
2stable NLL=0.170, PPL=1.185In a framework evaluator, cross-entropy normally applies this stable computation for you. You still need to know the principle when debugging inf losses, implementing metrics, or reviewing a custom evaluation loop.
Why should an evaluator accumulate negative log-likelihood instead of multiplying token probabilities together?
Answer
Products of many probabilities underflow toward zero. Sums of log-probabilities stay numerically usable, and exponentiating the average at the end yields the same perplexity.
The comparison contract
A perplexity score is never complete without its units and conditioning rules. At minimum, log:
| Contract field | Why it changes the score |
|---|---|
| Dataset and split | An incident-log corpus isn't a legal-contract corpus; train data isn't held-out data. |
| Tokenizer revision | Tokens set the denominator and the events being predicted. |
| Context length and stride | More usable left context generally makes token prediction easier. |
| Special-token and masking policy | Scoring or skipping initial and padding tokens changes the aggregate. |
| Model objective | A causal next-token model isn't directly comparable to a masked-language objective. |

Represent that contract in code before you compare checkpoints:
1from dataclasses import dataclass
2
3@dataclass(frozen=True)
4class EvaluationContract:
5 dataset: str
6 tokenizer: str
7 context_tokens: int
8 stride_tokens: int
9 objective: str = "causal-next-token"
10 special_token_policy: str = "skip-padding"
11 first_token_policy: str = "skip-unconditioned-first-token"
12 masking_policy: str = "score-each-target-once"
13
14def comparable(left: EvaluationContract, right: EvaluationContract) -> bool:
15 return left == right
16
17baseline = EvaluationContract("incident-holdout-v3", "bpe-v7", 2048, 512)
18new_checkpoint = EvaluationContract("incident-holdout-v3", "bpe-v7", 2048, 512)
19short_context_run = EvaluationContract("incident-holdout-v3", "bpe-v7", 512, 512)
20different_masking_run = EvaluationContract(
21 "incident-holdout-v3",
22 "bpe-v7",
23 2048,
24 512,
25 masking_policy="score-all-window-targets",
26)
27
28print("baseline vs new checkpoint:", comparable(baseline, new_checkpoint))
29print("baseline vs short context:", comparable(baseline, short_context_run))
30print("baseline vs different masking:", comparable(baseline, different_masking_run))1baseline vs new checkpoint: True
2baseline vs short context: False
3baseline vs different masking: FalseNow compare two model checkpoints on the same token outcomes:
1import math
2
3def perplexity(observed_probabilities: list[float]) -> float:
4 average_nll = sum(-math.log(p) for p in observed_probabilities) / len(
5 observed_probabilities
6 )
7 return math.exp(average_nll)
8
9held_out_probabilities = {
10 "checkpoint-0400": [0.31, 0.44, 0.18, 0.52, 0.24],
11 "checkpoint-0800": [0.42, 0.59, 0.29, 0.61, 0.35],
12}
13
14for name, probabilities in held_out_probabilities.items():
15 print(f"{name}: PPL={perplexity(probabilities):.2f}")1checkpoint-0400: PPL=3.18
2checkpoint-0800: PPL=2.31The result supports a narrow statement: checkpoint-0800 predicts tokens in this held-out set better under this protocol. It doesn't yet prove better incident answers.
Aggregate loss once
Evaluation windows are rarely the same size. Averaging each window's already exponentiated PPL gives a short hard window too much influence. Sum NLL weighted by scored-token count, divide once, and exponentiate once.
1import math
2
3windows = [
4 {"average_nll": 0.50, "scored_tokens": 2},
5 {"average_nll": 2.00, "scored_tokens": 8},
6]
7
8wrong = sum(math.exp(window["average_nll"]) for window in windows) / len(windows)
9total_nll = sum(
10 window["average_nll"] * window["scored_tokens"] for window in windows
11)
12total_tokens = sum(window["scored_tokens"] for window in windows)
13right = math.exp(total_nll / total_tokens)
14
15print(f"average of window PPLs: {wrong:.2f}")
16print(f"token-weighted corpus PPL: {right:.2f}")1average of window PPLs: 4.52
2token-weighted corpus PPL: 5.47Run A reports PPL 12 and run B reports PPL 10. What must be checked before declaring B better?
Answer
Check that dataset split, tokenizer, context length, stride, masking and special-token policy, and model objective match. If any of those changed, raw PPL no longer isolates model improvement.
Different tokenizers need common units
Token-level perplexity depends on tokenization. The string Compile CI log may be four subword tokens for one model and fourteen character tokens for another. A probability event per large subword isn't the same unit as a probability event per character. Hugging Face's perplexity documentation explicitly warns that tokenization affects PPL comparisons.[1]
For the same raw UTF-8 evaluation text, bits per byte (BPB) gives both models one shared denominator. PALOMA uses BPB as a practical compromise when tokenizers differ, while noting that it still scores the canonical token sequence chosen by each tokenizer rather than marginalizing over every valid segmentation.[2]
where is the byte count of the original text. A related metric, bits per character, is useful when a benchmark defines character units instead of bytes.

1import math
2
3text = "Compile CI log"
4byte_count = len(text.encode("utf-8"))
5evaluations = [
6 {"name": "subword model", "tokens": 4, "total_nll": 8.4},
7 {"name": "character model", "tokens": 14, "total_nll": 9.0},
8]
9
10for run in evaluations:
11 ppl = math.exp(run["total_nll"] / run["tokens"])
12 bpb = run["total_nll"] / (byte_count * math.log(2))
13 print(f"{run['name']:15s} token PPL={ppl:.2f}, BPB={bpb:.3f}")
14
15print("Lower BPB identifies less surprise on identical bytes.")1subword model token PPL=8.17, BPB=0.866
2character model token PPL=1.90, BPB=0.927
3Lower BPB identifies less surprise on identical bytes.The character model looks dramatically better under raw token PPL because it predicts smaller units. BPB gives the fairer comparison: the subword model assigned more total probability to the same bytes. It doesn't erase every tokenization effect, so keep logging the tokenizer and use a fixed vocabulary when possible.
Long documents need a scoring policy
A real evaluation file may contain thousands of tokens, while a model accepts only a fixed number of context tokens. Cutting text into disjoint blocks is fast, but tokens at each block boundary lose usable left context. A strided sliding window reuses context and scores only newly exposed target tokens.
Hugging Face demonstrates this protocol for GPT-2 Large on WikiText-2: a no-overlap stride = 1024 run reports PPL 19.44, while stride = 512 reports 16.44 for the same model and corpus. More context improved the score; the model weights didn't change.[1]

First simulate which positions a sliding-window loop scores:
1tokens = list("ABCDEFGHIJ")
2windows = [
3 {"context": (0, 5), "score": (1, 5)},
4 {"context": (3, 8), "score": (5, 8)},
5 {"context": (5, 10), "score": (8, 10)},
6]
7
8scored_tokens: list[str] = []
9for index, window in enumerate(windows, start=1):
10 begin, end = window["context"]
11 score_begin, score_end = window["score"]
12 context = "".join(tokens[begin:end])
13 scored = "".join(tokens[score_begin:score_end])
14 scored_tokens.extend(tokens[score_begin:score_end])
15 print(f"window {index}: context={context}, newly scored={scored}")
16
17print("scored exactly once:", scored_tokens == tokens[1:])1window 1: context=ABCDE, newly scored=BCDE
2window 2: context=DEFGH, newly scored=FGH
3window 3: context=FGHIJ, newly scored=IJ
4scored exactly once: TrueThe first token is input context because a causal model needs a previous position before it can score a next-token target. In a framework implementation, context-only labels are commonly masked with -100 so cross-entropy ignores them.[1]
This dependency-free evaluation loop uses precomputed token NLL values. A real model supplies the losses; aggregation logic stays the same.
1import math
2
3new_target_losses = [
4 [0.30, 0.72, 0.51, 0.43],
5 [0.27, 0.61, 0.38],
6 [0.56, 0.48],
7]
8
9total_nll = sum(sum(window) for window in new_target_losses)
10scored_tokens = sum(len(window) for window in new_target_losses)
11perplexity = math.exp(total_nll / scored_tokens)
12
13print(f"scored tokens={scored_tokens}")
14print(f"average NLL={total_nll / scored_tokens:.3f}")
15print(f"PPL={perplexity:.2f}")1scored tokens=9
2average NLL=0.473
3PPL=1.61For every reported PPL, store max_context_tokens, stride_tokens, the first-token policy, and the masking policy beside the score. Those details are measurement settings, not implementation trivia.
PPL answers one question, not every question
Suppose your incident assistant predicts common status-log language fluently but recommends the wrong rollback step. Perplexity can reward fluent next-token prediction without detecting that operational failure. Likewise, changing decoding strategy can change generated text quality even when the underlying model is unchanged, as Holtzman et al. demonstrated when studying repetitive neural generation.[3]
Use PPL for the question it answers:
| Decision | Useful measurement |
|---|---|
| Did a base-model checkpoint get better at held-out next-token prediction? | PPL under fixed protocol, or BPB across tokenizers |
| Did the assistant provide the correct incident status and cite supplied evidence? | Task-specific deterministic checks |
| Did an open-ended reply follow a rubric for clarity and groundedness? | Calibrated judge or human review |
| Is a release safe for a high-impact workflow? | Task regressions plus human-reviewed edge cases |
1candidates = [
2 {"name": "fluent-wrong", "ppl": 8.9, "policy_checks_passed": 1},
3 {"name": "grounded-answer", "ppl": 9.8, "policy_checks_passed": 3},
4]
5
6best_language_fit = min(candidates, key=lambda row: row["ppl"])
7best_product_answer = max(candidates, key=lambda row: row["policy_checks_passed"])
8
9print("best held-out language fit:", best_language_fit["name"])
10print("best incident answer result:", best_product_answer["name"])1best held-out language fit: fluent-wrong
2best incident answer result: grounded-answer
When deterministic checks end
Many incident cases can be checked without a model judge: expected status, required citation identifier, and forbidden runbook claim are deterministic. Use those checks first.
1EXPECTED_STATUS = "blocked"
2REQUIRED_SOURCE = "incident_policy_483"
3
4def score_answer(answer: dict[str, str]) -> tuple[int, list[str]]:
5 failures: list[str] = []
6 if answer["status"] != EXPECTED_STATUS:
7 failures.append("wrong status")
8 if answer["source"] != REQUIRED_SOURCE:
9 failures.append("missing evidence")
10 return 2 - len(failures), failures
11
12answers = [
13 {"name": "A", "status": "blocked", "source": "incident_policy_483"},
14 {"name": "B", "status": "approved", "source": "incident_policy_483"},
15]
16
17for answer in answers:
18 score, failures = score_answer(answer)
19 print(answer["name"], score, failures or ["pass"])1A 2 ['pass']
2B 1 ['wrong status']Open-ended tone, clarity, and partial correctness may require rubric review. LLM judges can scale that review, but the MT-Bench and Chatbot Arena study documents position bias, verbosity bias, and preference for model-like answers. Treat a judge as a calibrated measurement instrument, not truth.[4]

That boundary matters. Later evaluation lessons build judge calibration, benchmark selection, and online experimentation in depth.
Keep evaluation data clean
PPL needs held-out text. If training data includes your evaluation records, lower loss may reflect memorization rather than generalization. Product task suites have the same failure: if prompt examples or fine-tuning rows include hidden test tickets, release metrics lose meaning.
1training_record_ids = {"ticket-101", "ticket-102", "ticket-103"}
2validation_record_ids = {"ticket-201", "ticket-202", "ticket-103"}
3
4overlap = training_record_ids & validation_record_ids
5if overlap:
6 print("FAIL leaked record ids:", sorted(overlap))
7else:
8 print("PASS validation set is disjoint")1FAIL leaked record ids: ['ticket-103']For public LLM benchmarks, test content can also enter later training corpora. LiveBench addresses that risk with frequently updated questions from recent sources and objective ground-truth scoring; it limits contamination risk rather than making every future score immune to leakage.[5]

Build an evaluation report
An engineering metric becomes useful when it ships with enough context to reproduce a decision. A compact report should include metric value, protocol fields, leakage checks, and product task gates.
1from dataclasses import dataclass
2
3@dataclass(frozen=True)
4class Report:
5 checkpoint: str
6 perplexity: float
7 dataset: str
8 tokenizer: str
9 context_tokens: int
10 stride_tokens: int
11 objective: str
12 special_token_policy: str
13 first_token_policy: str
14 masking_policy: str
15 leaked_records: int
16 policy_pass_rate: float
17
18def release_gate(report: Report) -> str:
19 if report.leaked_records:
20 return "BLOCK: contaminated evaluation set"
21 if report.policy_pass_rate < 1.0:
22 return "BLOCK: product regressions"
23 return "PASS: protocol recorded and product checks passed"
24
25report = Report(
26 checkpoint="incident-lm-0800",
27 perplexity=9.81,
28 dataset="incident-holdout-v3",
29 tokenizer="bpe-v7",
30 context_tokens=2048,
31 stride_tokens=512,
32 objective="causal-next-token",
33 special_token_policy="skip-padding",
34 first_token_policy="skip-unconditioned-first-token",
35 masking_policy="score-each-target-once",
36 leaked_records=0,
37 policy_pass_rate=1.0,
38)
39
40print(f"{report.checkpoint}: PPL={report.perplexity} @ {report.context_tokens}/{report.stride_tokens}")
41print(
42 "protocol:",
43 report.objective,
44 report.special_token_policy,
45 report.first_token_policy,
46 report.masking_policy,
47)
48print(release_gate(report))1incident-lm-0800: PPL=9.81 @ 2048/512
2protocol: causal-next-token skip-padding skip-unconditioned-first-token score-each-target-once
3PASS: protocol recorded and product checks passedThe report refuses two common shortcuts: treating an untrusted held-out set as evidence, and treating language fit as a substitute for application correctness.
Perplexity rules worth keeping
- Perplexity is
exp(average NLL): an interpretable view of held-out next-token surprise. - Raw PPL comparison requires the same dataset, tokenizer, objective, context, stride, and masking policy.
- Bits per byte puts models with different tokenizers onto one raw-text denominator.
- Long-document evaluation must score new target tokens once while reusing context and aggregating loss before exponentiating.
- Low PPL doesn't establish factual, useful, or safe outputs; application checks and calibrated review answer those questions.
- Leakage invalidates confident evaluation claims, whether the set measures PPL or product behavior.
Mastery check
Key concepts
- Held-out next-token likelihood
- Cross-entropy to perplexity
- Stable log-probability scoring
- Evaluation protocol contracts
- Bits-per-byte normalization
- Strided context windows
- Intrinsic versus product quality
- Leakage-resistant evaluation sets
Evaluation rubric
- Foundational: Computes token surprise, average NLL, and PPL from observed probabilities.
- Intermediate: Explains effective choice count without treating it as a vocabulary-size claim.
- Intermediate: Rejects invalid raw PPL comparisons by checking protocol fields.
- Advanced: Uses BPB when tokenizers differ and aggregates strided loss correctly.
- Advanced: Designs a report that separates language-fit metrics from product and leakage gates.
Follow-up questions
Why can two models not be compared by raw PPL when their tokenizers differ?
Answer
PPL averages surprise per token, and each tokenizer defines different prediction events and token counts. Use a shared denominator such as raw UTF-8 bytes for a fairer comparison, and still report tokenizers because BPB doesn't remove every segmentation effect.
Why does decreasing stride often lower PPL for the same fixed-context model?
Answer
A smaller stride reuses more left context for newly scored tokens. Predictions become better conditioned, so the protocol usually produces lower loss even though the model weights did not change.
A runbook model has lower PPL but fails rollback-safety checks. Which model should ship?
Answer
Don't use PPL to override a product correctness failure. PPL says the model fits held-out text better; an incident-assistant release must pass task-specific safety and evidence checks.
Common pitfalls
Comparing scores without a protocol
-
Symptom: A team declares victory from PPL 10 versus PPL 12 but can't name the tokenizer, data split, or stride.
-
Cause: The score was treated as a universal model rating instead of a metric with units and conditioning rules.
-
Fix: Store the evaluation contract with every result and compare raw PPL only when contracts match.
Averaging window perplexities
-
Symptom: Long-document PPL changes when window boundaries move, even though scored token losses are unchanged.
-
Cause: Per-window perplexities were averaged directly.
-
Fix: Sum token NLL across all windows, divide by scored-token count once, then exponentiate.
Selecting chat behavior using language fit alone
-
Symptom: A fluent model ships a wrong policy answer because it had the lowest PPL.
-
Cause: Intrinsic next-token evaluation was confused with application correctness.
-
Fix: Gate releases on deterministic task checks and calibrated review in addition to base-model fit metrics.
Testing on leaked records
-
Symptom: Evaluation looks unusually strong, then fails on genuinely new tickets.
-
Cause: Training or prompt examples overlap with hidden evaluation data.
-
Fix: Enforce disjoint identifiers, keep private held-out records, and rotate realistic challenge cases.