07 · Content revision 7
From assigned probability to perplexity
Turn the probabilities assigned to observed target tokens into mean negative log-likelihood and perplexity while keeping the target count, document boundaries, and evaluated data splits explicit.
Start with the probabilities of what actually happened
Chapter 6 built a smoothed bigram model. For each current token, it can compute a probability distribution over possible continuations. To score an observed transition, use the probability of the next token that actually occurred, not the maximum probability in that distribution. Suppose two successive targets received these probabilities:
Before introducing the metric, make three predictions:
- Which of the two targets should count as more surprising?
- The two probabilities multiply to . Why is that product not yet a fair per-target score for comparing sequences with different numbers of targets?
- If both positions had received one constant probability, what value would represent the same overall difficulty?
For an event assigned probability , define its surprise here as . The logarithm is natural. The two positions therefore contribute
The second target confirms the first prediction: a smaller assigned probability means greater surprise. Under these fixed contexts, is the likelihood that the model assigns to the two observed targets together. Taking the negative logarithm turns that product into a sum: . This is why the sum is called negative log-likelihood. Its value, , is total surprise for two targets.
Every additional observed target multiplies the sequence likelihood by another probability and adds another surprise term. A longer sequence therefore usually has a smaller raw likelihood even when the model performs equally well per target. To remove that length effect, divide total surprise by the number of observed targets. Here the result is mean negative log-likelihood (mean NLL), nats per target. Exponentiating it gives perplexity .
The constant probability requested in the third prediction is , approximately . If the model assigned that probability at both positions, the geometric mean of the assigned probabilities—and therefore perplexity—would remain unchanged. Equivalently, this perplexity is the effective number of equally likely continuations that would produce the same average surprise. It does not mean that the model literally had choices.
Two simple cases make the scale easier to understand. If every observed target gets probability one, every term is , so mean NLL is and perplexity is . If a model is uniform over a vocabulary , every observed target gets ; each surprise is , so mean NLL is and perplexity is . For , these formulas give nats per target and perplexity .
Before writing the general formula, consider a common averaging mistake. Document A has one target assigned probability . Document B has three targets, each assigned probability . Written target by target, the surprises are
The correct mean is , with perplexity . If we instead compute a mean for each document and then average those two values, both documents receive equal weight. That gives the incorrect mean and perplexity . The result is wrong because Document B contributes three scored targets while Document A contributes only one. The fact that the correct result matches the earlier two-target perplexity is a coincidence of these particular numbers, not a general rule.
Sum surprise, divide by targets, then exponentiate
The complete rule is:
At target position , is the token that occurred, and is the probability assigned to it under the fixed context-construction rule. The metric uses that specific probability—not the largest probability in the distribution and not the entropy of the whole distribution.
In this chapter means the natural logarithm, so . For a valid positive probability no greater than one, is nonpositive. The leading minus sign turns it into nonnegative surprise: decreasing increases . We sum that quantity for every scored target, then divide once by , the total number of target tokens across all scored documents. This is the length normalization missing from a raw sequence product.
The mean is measured in nats per target. Perplexity is dimensionless; it is obtained by applying to that mean. When every assigned probability is positive, the same relation can be written
This equation does not define a second measurement. Perplexity is the geometric mean of the inverse assigned probabilities already used in mean NLL. Equivalently, is the geometric mean of the original assigned probabilities.
Mean NLL can also be described as empirical cross-entropy, but only under explicit conditions. For each fixed context, define an empirical distribution that assigns probability to the token that actually occurred and to every other token. The cross-entropy between this distribution and the model distribution is exactly . Averaging those values over the same contexts and the same target tokens gives the mean NLL above. If the empirical distribution and the rule for building context have not been stated, the clearer name is mean NLL on these targets, not simply “cross-entropy.”
A zero probability from the model is different from invalid input. If the model assigns probability to a token that occurred, it has declared that event impossible. Zero is valid input to the metric, but it makes that token’s surprise, the resulting mean NLL, and perplexity positive infinity. The implementation does not replace zero with an .
An empty slice, NaN, positive or negative infinity supplied as a probability,
or a value outside is an input error. Rust validates the whole slice
before summing, so [0, NaN] reports the invalid NaN instead of returning
infinity because of the earlier zero. Add-one smoothing makes the bigram
probabilities used later positive; it does not change this general rule.
Locate every symbol in the worked example
| Symbol | Operational meaning |
|---|---|
| The one-based index of an observed target position. In the tiny example, . | |
| The target token that actually occurred at position , whether or not the model ranked it highest. | |
| The conditional probability assigned to that observed token using the same context rule at every scored position; it is not an empirical frequency. | |
| The number of scored target tokens across all documents. In the tiny example, ; it is not a document count. | |
| The natural logarithm in this chapter, equal to . | |
| Mean negative log-likelihood, measured in natural-log nats per target. | |
| The natural exponential function, which reverses the natural logarithm. | |
| Perplexity, the exponential re-expression of the same mean NLL. |
The formula follows mathematical one-based indexing, while Rust slices and the
diagram trace are zero-based. Trace index=0 is the first observed target,
; trace index=1 is the second, . Nothing is shifted, and index zero
does not denote a boundary token.
Across corpus documents, BOS supplies the first context and is not counted in
. Each document’s EOS is its final target and is counted. Because documents
remain separate, no fabricated EOS→BOS transition contributes to either the
sum or the denominator.
Add logarithms instead of multiplying probabilities directly
Shannon’s 1948 paper develops logarithmic measures in connection with information, choice, and uncertainty. The complete Harvard copy of the paper provides the facts needed here. Its Introduction (viewer pages 1–2) explains that the logarithm base determines the unit. Section 6 (viewer and printed pages 10–11) shows that certainty has zero discrete entropy and that a uniform distribution over outcomes reaches the maximum . This is information-theory background; Shannon does not define language-model mean NLL or perplexity.
The JMLR page for Bengio, Ducharme, Vincent, and Jauvin (2003) links their account of a neural probabilistic language model. In the paper’s PDF, §1.1 (printed page 1139) writes a word-sequence probability as a product of conditional next-word probabilities. Section 2 (printed page 1141) describes perplexity both as the geometric mean of inverse assigned probabilities and as the exponential of average negative log-likelihood. Section 4 (printed pages 1148–1149) states which tokens enter the reported averages before comparing named models on the Brown and AP News corpora. Those results apply only to the named experiments; they establish neither a universal model ranking nor a rule for comparing perplexities across tokenizers.
For this course, we use natural logarithms, give every target token equal weight, define the behavior for and invalid input, keep documents separate, and allow the Chapter 7 bigram scorer to evaluate only training or validation data. We compare perplexities only when the tokenizer, the mapping from token IDs to token values, the document-boundary rules, the rule for building context, and the exact evaluated targets all match. These are course design decisions, not claims drawn from either paper.
The Chapter 7 Rust program shows why logarithms are useful. Multiplying
factors of in f64 eventually rounds the product to 0.000e0, even though
every factor is valid. The sum of the negative logarithms remains finite. This
conversion does not change which sequence is more likely: a larger likelihood
corresponds to a smaller total surprise. Only the floating-point representation
changes—from a fragile product to a stable sum.
A separate example shows why checking only loses information. Let the
observed target be B:
q = [A: 0.60, B: 0.30, C: 0.10]
r = [A: 0.60, B: 0.20, C: 0.20]
Both distributions rank A highest. However, assigns probability to
the observed token B, while assigns it . Their NLL values for B are
therefore and . The NLL under is lower because
assigned more probability to the token that occurred. This example illustrates
a limitation of ; it does not claim that perplexity historically replaced
accuracy everywhere.
Implement the metric once and reuse it for the frozen bigram
The generic metric function first checks that a nonempty slice contains only
finite probabilities in [0,1]. Only after validating the entire slice does it
add each selected target probability to the shared accumulator. finish divides
total surprise by the target count and exponentiates the resulting mean. An
assigned zero contributes positive infinity; an assigned one contributes exactly
+0.0.
rust/crates/llm-from-scratch/src/metrics.rs#assigned-probability-metrics #[derive(Clone, Copy, Debug, Default)]
struct MetricAccumulator {
total_surprise: f64,
target_count: usize,
}
impl MetricAccumulator {
fn observe(&mut self, probability: f64) {
self.target_count += 1;
if probability == 0.0 {
self.total_surprise = f64::INFINITY;
} else if probability == 1.0 {
// Preserve positive zero for exact learner-facing output.
self.total_surprise += 0.0;
} else {
self.total_surprise += -probability.ln();
}
}
fn finish(self) -> Result<MetricSummary, MetricError> {
if self.target_count == 0 {
return Err(MetricError::EmptyTargets);
}
let mean_nll = self.total_surprise / self.target_count as f64;
Ok(MetricSummary {
total_surprise: self.total_surprise,
target_count: self.target_count,
mean_nll,
perplexity: mean_nll.exp(),
})
}
}
/// Scores probabilities assigned to observed targets using natural logarithms.
///
/// The complete slice is validated before accumulation. Both `0.0` and `-0.0`
/// are valid impossible-evidence values and produce positive infinity without a
/// clamp.
pub fn score_assigned_probabilities(probabilities: &[f64]) -> Result<MetricSummary, MetricError> {
if probabilities.is_empty() {
return Err(MetricError::EmptyTargets);
}
for (index, &probability) in probabilities.iter().enumerate() {
if !probability.is_finite() || !(0.0..=1.0).contains(&probability) {
return Err(MetricError::InvalidProbability { index, probability });
}
}
let mut accumulator = MetricAccumulator::default();
for &probability in probabilities {
accumulator.observe(probability);
}
accumulator.finish()
} The bigram adapter does not implement another formula. It takes a reference to
the already fitted BigramModel, walks windows(2) inside each encoded document,
queries the probability assigned to each observed next token, and feeds it to
the same private accumulator. score_bigram_partition accepts a
ScoredPartition; that enum contains only Train and Validation, so the
function cannot select the test partition.
rust/crates/llm-from-scratch/src/metrics.rs#train-validation-scoring /// A partition Chapter 7 is permitted to score.
///
/// The test partition is intentionally unavailable until Chapter 34.
///
/// ```compile_fail,E0599
/// use llm_from_scratch::metrics::ScoredPartition;
///
/// let _missing_variant: ScoredPartition = ScoredPartition::Test;
/// ```
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ScoredPartition {
Train,
Validation,
}
impl ScoredPartition {
const fn corpus_partition(self) -> Partition {
match self {
Self::Train => Partition::Train,
Self::Validation => Partition::Validation,
}
}
}
/// Scores adjacent target transitions without refitting or joining documents.
pub fn score_bigram_partition(
model: &BigramModel,
partitions: &EncodedCorpusPartitions,
partition: ScoredPartition,
) -> Result<PartitionScore, MetricError> {
let documents = partitions.documents(partition.corpus_partition());
let mut accumulator = MetricAccumulator::default();
for document in documents {
for transition in document.token_ids().windows(2) {
let probability = model.smoothed_probability(transition[0], transition[1])?;
accumulator.observe(probability);
}
}
Ok(PartitionScore {
partition,
document_count: documents.len(),
metrics: accumulator.finish()?,
})
} To make the results reproducible, the setup code loads the corpus and split
definition stored in the repository, learns eight BPE merge ranks from the
training documents, creates tokenizer layout 1 with a vocabulary of 266
tokens, and fits one bigram model with from eight training documents and
1,844 transitions. The excerpt below shows that sequence. Checks later in the
same file stop with an error if the corpus checksum, document IDs, tokenizer
settings, or model-training setup differ from the expected values.
rust/demos/ch07-language-model-metrics/src/lib.rs#frozen-metric-fixture /// Executes the data-to-model path before the frozen identities are checked.
fn reconstruct_frozen_metric_fixture() -> Result<ReconstructedFixture, FrozenFixtureError> {
let corpus = Corpus::from_json(CORPUS_JSON)?;
let manifest = SplitManifest::from_json(SPLIT_MANIFEST_SOURCE)?;
let source_partitions = manifest.partition(&corpus)?;
let training = BpeTrainer::new(EXPECTED_REQUESTED_MERGES).train(&source_partitions)?;
let tokenizer = BpeTokenizer::from_training(&training)?;
let encoded_partitions =
EncodedCorpusPartitions::from_partitions(&source_partitions, &tokenizer);
let model = BigramModel::fit_encoded_training_partition(
tokenizer.layout().vocabulary_size(),
FIT_ALPHA,
&encoded_partitions,
)?;
Ok(ReconstructedFixture {
corpus,
manifest,
training,
tokenizer,
encoded_partitions,
model,
})
} The following main.rs excerpt first computes metrics for the
example and for the perfect and uniform scale anchors. It also covers zero
probability and empty input. Next, it contrasts correct and incorrect document
weighting, compares two distributions with the same argmax, and demonstrates
why a direct probability product can underflow. Finally, it scores the same
unchanged model separately on training and validation. Code outside the
displayed region prints the returned results. While printing the result for
factors of , it also checks whether the already-computed total
surprise is finite. That check is not a new metric calculation: no probability
sequence or corpus partition is scored again.
rust/demos/ch07-language-model-metrics/src/main.rs#learner-output let tiny_probabilities = [0.5, 0.25];
let tiny = score_assigned_probabilities(&tiny_probabilities)?;
let perfect = score_assigned_probabilities(&[1.0, 1.0])?;
let uniform = score_assigned_probabilities(&[1.0 / 5.0; 5])?;
let impossible = score_assigned_probabilities(&[0.8, 0.0])?;
let empty_error = match score_assigned_probabilities(&[]) {
Err(error) => error,
Ok(_) => return Err("empty metric input unexpectedly produced a score".into()),
};
let weighted_documents = score_assigned_probabilities(&[1.0, 0.25, 0.25, 0.25])?;
// Deliberate misuse: because each document has one constant assigned
// probability, [1.0, 0.25] represents their two geometric means. Scoring
// that two-item pseudo-sample gives documents equal weight instead of
// weighting all four observed targets.
let wrong_equal_document_means = score_assigned_probabilities(&[1.0, 0.25])?;
let q_distribution = [0.60, 0.30, 0.10];
let r_distribution = [0.60, 0.20, 0.20];
let q_target_b = score_assigned_probabilities(&[q_distribution[1]])?;
let r_target_b = score_assigned_probabilities(&[r_distribution[1]])?;
let lower = if q_target_b.mean_nll() < r_target_b.mean_nll() {
"q"
} else {
"r"
};
let halves = vec![0.5; 2_000];
let raw_product = halves.iter().copied().product::<f64>();
let halves_score = score_assigned_probabilities(&halves)?;
let fixture = frozen_metric_fixture()?;
let train = score_bigram_partition(
fixture.model(),
fixture.encoded_partitions(),
ScoredPartition::Train,
)?;
let validation = score_bigram_partition(
fixture.model(),
fixture.encoded_partitions(),
ScoredPartition::Validation,
)?; The separate executable at examples/diagram_trace.rs calls
render_language_model_metrics_trace in src/diagram_trace.rs and prints the
returned string. The displayed region below calls the same metric and fixture
functions and computes the two partition scores. It also checks three properties
of the training and validation token sequences: BOS never appears as a target,
EOS occupies the final target position of every document, and no EOS→BOS pair
is introduced. Code after the displayed region serializes those results as ten
trace records. It adds test_selectable=no as a literal during serialization;
the restriction itself is enforced by ScoredPartition, which has no Test
variant. All ten trace records therefore preserve evidence produced by those
metric, fixture, and boundary checks; no second metric is calculated for the
figure.
rust/demos/ch07-language-model-metrics/src/diagram_trace.rs#language-model-metrics-trace let tiny_probabilities = [0.5, 0.25];
let first_target = score_assigned_probabilities(&tiny_probabilities[0..1])?;
let second_target = score_assigned_probabilities(&tiny_probabilities[1..2])?;
let tiny = score_assigned_probabilities(&tiny_probabilities)?;
let fixture = frozen_metric_fixture()?;
let train = score_bigram_partition(
fixture.model(),
fixture.encoded_partitions(),
ScoredPartition::Train,
)?;
let validation = score_bigram_partition(
fixture.model(),
fixture.encoded_partitions(),
ScoredPartition::Validation,
)?;
let (bos_is_target, eos_is_target, has_cross_document_pair) =
boundary_evidence(fixture.encoded_partitions()); Run the Chapter 7 program:
./course run cargo run --quiet --locked -p ch07-language-model-metrics
For the same unchanged model, the output reports training mean NLL
3.832941183107 and perplexity 46.198216022322 across 1,844 targets, plus
validation mean NLL 3.981939680567 and perplexity 53.620940919077 across
469 targets. These values are deterministic for the recorded corpus, split,
tokenizer, model, and evaluation rules. Because training and validation contain
different target sets, the gap applies only to these data and does not by itself
support a general conclusion about model quality elsewhere. Scoring validation
neither updates nor refits the model.
Trace each displayed value back to its Rust calculation
As you read the figure, answer five questions about how its values are related:
- Which trace row corresponds to mathematical target , and where is its assigned probability converted to surprise?
- Where does
target_count=2enter the calculation, and why is it displayed separately from total surprise? - Which stage converts mean NLL to perplexity without requesting additional probabilities from the model?
- Were the training and validation rows produced by one fitted model or two?
- Which boundary record shows that
score_bigram_partitioncannot select the test partition?
From target probabilities to mean NLL and perplexity
The Rust-generated trace shows each metric stage for two observed targets, then reports training and validation metrics for the same unchanged fitted model.
A five-stage calculation turns two assigned target probabilities into surprise, divides their total surprise by two targets, and obtains mean negative log-likelihood and perplexity. A second panel shows how one model was fitted on training documents, reports its separate training and validation scores, explains the document-boundary rules, and shows that the bigram scorer cannot select test data.
Two observed targets, one calculation chain
Use horizontal scrolling to follow every stage; keyboard users can focus this region and scroll it.
| Zero-based target index in Rust | Probability assigned to the observed target | Negative-log surprise |
|---|---|---|
index=0 | 0.500000000000 | 0.693147180560 |
index=1 | 0.250000000000 | 1.386294361120 |
then
Total surprise and target count
- Sum across both targets
- 2.079441541680
- Denominator: target tokens
- 2
then
Mean negative log-likelihood
Mean NLL 1.039720770840
then
Perplexity
Perplexity 2.828427124746
One fitted model, separate training and validation scores
Fit the model once on training documents, then use the same unchanged model to score training and validation separately.
How the data and model were prepared
- Corpus checksum
fnv1a64:723b071980ae8a22- Document split strategy
fixed-paired-document-holdout-v1- Tokenizer layout version
- 1
- Requested BPE merges
- 8
- Learned BPE merges
- 8
- Vocabulary size
- 266
- Bigram smoothing
- 1.000000000000
- Data split used to fit the model
train- Documents used to fit
- 8
- Transitions used to fit
- 1844
| Data split being scored | Document count | Target-token count | Total surprise | Mean NLL | Perplexity |
|---|---|---|---|---|---|
Training score partition=train | 8 | 1844 | 7067.943541648752 | 3.832941183107 | 46.198216022322 |
Validation score partition=validation | 2 | 469 | 1867.529710185699 | 3.981939680567 | 53.620940919077 |
Which targets and data splits are scored
- BOS supplies context and is never a scored target.
bos_target=no - EOS is the final scored target in each document.
eos_target=yes - Documents remain separate; no EOS→BOS transition is introduced.
cross_document=no - The Chapter 7 bigram scorer cannot score test data.
test_selectable=no
The first panel reproduces the opening example from the Rust trace.
Rows index=0 and index=1 contain the two probabilities and surprises, while
the aggregate shows both total surprise and its two-target denominator. The final
stage applies to the computed mean NLL. It requests no new model
probabilities; perplexity and mean NLL determine each other uniquely.
The second panel applies the same metric to training and validation. A shared
setup block shows that the model was fitted on the training documents and then
kept unchanged for both scores. The boundary list shows exactly which tokens are
counted: BOS supplies context only, EOS is a target, documents remain separate,
and the Chapter 7 bigram scorer cannot select test data.
Predict each result before checking the reasoning
Work on paper first. Show the denominator in averaging problems and the document boundaries in sequence problems.
- For assigned probabilities , compute both surprises, total surprise, target count, mean NLL, and perplexity.
- Derive the mean NLL and perplexity for perfect assignments and for a uniform distribution over a vocabulary of size .
- Predict the result for . Why would replacing zero with an change the metric rather than merely stabilize it?
- One document has one target at ; another has three targets at . Repair an implementation that averages their two document means equally.
- In
[BOS,A,B,EOS], identify every scored target and explain the role ofBOS. - Join
[BOS,A,EOS]and[BOS,B,EOS]. Which artificial transition is created at the join? - For observed target
B, compare and . Why can their NLLs differ even though both have ? - Can you directly compare perplexities produced with different tokenizers or different evaluated target sets? State every evaluation condition that must match.
- Why are the training and validation scores of the bigram model with finite even though the general metric maps an assigned zero to positive infinity?
Check the nine calculations and explanations
- The surprises are and . Their total is . There are targets, so mean NLL is nats per target. Exponentiating gives .
- A perfect assignment has at every target, hence every term is ; the mean is and PPL is . Under a uniform vocabulary, , so every term is . The mean is therefore , and PPL is .
- The
0.8term is finite, but the observed target assigned0contributes positive infinity. Therefore total surprise, mean NLL, and PPL are positive infinity, not an input error. Replacing zero with epsilon invents a nonzero probability and produces a finite, -dependent score, so it changes the metric. - Write all four terms: . Their mean is , giving PPL . If we instead compute a mean for each document and average the two results, both documents receive equal weight. This incorrectly gives , with PPL .
- The scored targets are
A,B, andEOS: they are the second member ofBOS→A,A→B, andB→EOS.BOSsupplies context for the first target and is never itself a target, so this document contributes three terms to . - Flattening gives
BOS,A,EOS,BOS,B,EOS. Its middle adjacent pair is the fabricatedEOS→BOStransition, which wrongly treats the second document’sBOSas a target of the first document’sEOS. - Both distributions place their largest probability,
0.60, onA, but NLL uses the probability assigned to observedB. Thus contributes , while contributes . The NLL under is lower because assigned more probability to what occurred. - No. A direct comparison requires the same tokenizer, the same mapping from token IDs to token values, the same document-boundary rules, the same rule for building context, and exactly the same evaluated targets. If any of these differ, the terms or denominator may represent different events, so the numerical values are not directly comparable.
- Add-one smoothing changes this bigram model’s distribution and makes every probability it returns positive, so every corpus surprise is finite. The general metric can also receive probabilities from other models. If any model assigns exactly zero to an observed target, the metric returns positive infinity without changing that probability. It neither imposes a lower bound nor replaces zero with an epsilon.
Misconception check: perplexity and mean NLL are not independent measurements. Perplexity is exactly , so either value determines the other. The value is the constant probability that would produce the same mean NLL if assigned to every target. Computing perplexity does not request any additional probabilities from the model.
Keep one measurement while the model becomes more capable
Chapter 7 can now report mean NLL and perplexity for the unchanged Chapter 6 model on the exact training and validation targets. Both reports use the same metric and model: scoring neither updates the transition counts nor refits the table, and the Chapter 7 bigram-scoring API offers no test-partition choice.
Chapter 8 begins the numerical engine with tensor values stored in a flat
Vec<f64>, plus shapes, strides, and checked coordinate-to-offset indexing.
Chapters 8–22 build the tensor, differentiation, and optimization machinery
that later models need in order to improve mean NLL.
Chapter 33 will train the complete decoder and select one state using validation loss only. Chapter 34 will freeze that state and demonstrate one local post-selection evaluation on a fixed teaching fixture, comparing it with the retained bigram model. That describes the order inside the demonstrated execution; it makes no claim about how often the fixed result has been used during repository development. A fair comparison requires both models to use the same tokenizer, the same mapping from token IDs to token values, the same exact target set, the same document-boundary and context-construction rules, and the same source data. Chapter 7 supplies the measurement; the later chapters determine how to improve a model and conduct a fair final comparison.