Skip to content

Perplexity, bits per byte, NLL accumulator

ModuleM11.2 · build · Python · Pass 3 · 2 to 3 h
You buildpython/tinyllm/info/ppl.py: perplexity, NLLAccumulator (add, merge, result)
Contractcourse/contracts/py/tinyllm/info/ppl.pyi
Testscourse/tests/M11.2/ (what they check: section 4)
NeedsM00.1 bits_per_byte (tinyllm/num/units.py) · reading: M11.1 cross-entropy, M09.2 compensated sums (or --ref-deps)
Used byL1.6 tokenizer metrics · later: L2.1, L2.2, L3.6 model perplexities, L6.7 the model-zoo bpb table, L8.5 quantization budgets, C1
MilestoneMS-P3 (tokens and data)
Optional depthCover and Thomas, Elements of Information Theory (2nd ed.), ch. 2 and 5; Gao et al., “The Pile” (2020), section 4 (bits per byte); Higham, Accuracy and Stability of Numerical Algorithms, ch. 4
  • Perplexity is ee to the mean NLL per token: the geometric mean of 1/q1/q, so a model that is uniform over kk choices scores exactly kk (test_hand_example_sentence, test_perplexity_of_a_uniform_model_is_k).
  • Bits per byte divides the same total by the text’s UTF-8 length instead of its token count, which makes it the one number that compares models with different tokenizers (test_bits_per_byte_compares_tokenizers).
  • A corpus mean is the total over all tokens divided by the token count, never the average of batch means, and padding is selected out, not multiplied by zero (test_hand_example_two_batches_with_padding, test_masked_positions_are_ignored_whatever_they_hold).
  • Summing each batch exactly and carrying the rounding error across batches makes any split of the corpus give the same answer to float64 rounding (test_streaming_equals_one_batch, test_compensated_sum_across_many_adds).
Terminal window
ol start M11.2 # stubs ppl.py into your repo
ol tests M11.2 # read the test catalog first
ol check M11.2 # exit code is the verdict
ol check M11.2 --ref-deps # only if your M00.1 is not passing yet
ol diff M11.2 # after passing: your code against the reference

Your tracer bigram reports one number, nll: the mean negative log-likelihood of one text, in nats per token. Pass 3 breaks that number in two ways. First, tokenizers arrive: the byte tokenizer, BPE with 4096 merges (L1.2), Unigram (L1.4). A BPE model and a byte model scoring the same text report nats per their token, and a BPE token is three or four bytes, so their perplexities are not comparable; L1.6’s tokenizer metrics and the C1 ablation need a per-byte number. Second, evaluation stops fitting in one call: the zoo (L6.7) scores 10710^7 tokens in padded batches, possibly on several workers. Averaging batch means, counting padding, or letting float rounding drift all change the reported number while every individual line of code looks right. This module turns per-token losses into perplexity and bits per byte, correctly, one batch at a time.

SymbolMeaningType / shape
x1,…,xnx_1, \dots, x_nthe tokens of an evaluation textint64[n]
qtq_tthe probability the model gave the true token xtx_tfloat in (0,1](0, 1]
ℓt=−ln⁡qt\ell_t = -\ln q_tthat token’s negative log-likelihood, in natsfloat ≥0\ge 0
S=∑tℓtS = \sum_t \ell_tthe total NLL of the text, in natsfloat
nnthe number of scored tokensint
BBthe number of UTF-8 bytes the scored tokens coverint
ℓˉ=S/n\bar\ell = S / nthe mean NLL per token (the cross-entropy, M11.1)float
PPL=eℓˉ\mathrm{PPL} = e^{\bar\ell}perplexityfloat ≥1\ge 1
bpb=S/(Bln⁡2)\mathrm{bpb} = S / (B \ln 2)bits per bytefloat
ss, cca running sum and its compensation (the low bits it lost)float64

From NLL to perplexity. The model gives the text probability ∏tqt\prod_t q_t (each prediction conditioned on what came before). Its log is −S-S. The mean ℓˉ=S/n\bar\ell = S/n is the cross-entropy between the text and the model, in nats per token. Perplexity undoes the log:

PPL=eℓˉ=(∏t1qt)1/n,\mathrm{PPL} = e^{\bar\ell} = \Big(\prod_t \frac{1}{q_t}\Big)^{1/n},

the geometric mean of 1/qt1/q_t. If the model spreads its mass evenly over kk tokens every time, qt=1/kq_t = 1/k, ℓt=ln⁡k\ell_t = \ln k, and PPL=k\mathrm{PPL} = k exactly: perplexity is the effective number of tokens the model is choosing between. A perfect model scores 1. A model that gives a real token probability 0 has ℓt=∞\ell_t = \infty and PPL=∞\mathrm{PPL} = \infty (the zero problem of M07.2). In float64, eℓˉe^{\bar\ell} overflows once ℓˉ>ln⁡(DBL_MAX)≈709.78\bar\ell > \ln(\text{DBL\_MAX}) \approx 709.78; an untrained model can get there, so the function returns ∞\infty instead of raising.

Units. One bit is ln⁡2\ln 2 nats (M00.1), so bits per token is ℓˉ/ln⁡2\bar\ell / \ln 2, and PPL=2bits per token\mathrm{PPL} = 2^{\text{bits per token}}.

Bits per byte. SS is the length, in nats, of the shortest code for the whole text under the model: an arithmetic coder driven by the model spends −log⁡qt-\log q_t for each token (M11.3). That total does not care how the text was cut into tokens; only the model’s skill at predicting it matters. Dividing by the number of tokens brings the tokenizer back in. Dividing by the number of bytes does not, because every tokenizer sees the same BB bytes:

bpb=SBln⁡2=bits per token×nB.\mathrm{bpb} = \frac{S}{B \ln 2} = \text{bits per token} \times \frac{n}{B}.

A BPE model whose tokens average 4 bytes and a byte model can have per-token perplexities that differ by a factor of hundreds while their bits per byte say which one actually predicts the text better. L6.7 reports bpb for every model in the zoo for this reason.

One mean, many batches. The corpus mean is S/nS/n with SS and nn summed over every batch. The average of batch means, 1m∑jSj/nj\frac1m \sum_j S_j / n_j, weights each batch equally, so a short last batch or heavily padded batches pull it away from S/nS/n; the two agree only when every batch has the same token count. So the accumulator keeps the two totals, never a running mean.

Masks select. Padded batches have slots with no real token, whose loss is garbage: a loss against a pad id, ∞\infty, or NaN. A mask marks the slots that count. Multiplying by the mask looks equivalent and is not, because NaN×0=NaN\mathrm{NaN} \times 0 = \mathrm{NaN} and ∞×0=NaN\infty \times 0 = \mathrm{NaN}; selecting the masked values (x[mask]) never touches them. The token count is the number of selected slots, not the batch size.

Summing 10710^7 numbers. Adding a small number to a large float64 total rounds away its low bits: near 1.0 the spacing is 2.2×10−162.2 \times 10^{-16}, so 1+10−16=11 + 10^{-16} = 1 exactly. Over 10710^7 tokens the lost bits add up to changes in the last digits of a reported perplexity, and worse, the answer depends on batch size and order. Two tools fix it. Within a batch, math.fsum returns the exactly rounded sum of the batch (after widening float32 losses to float64: summing 10510^5 float32 values in float32 loses about seven digits). Across batches, Neumaier’s compensated summation keeps a second float cc: for each batch total xx,

t=s+x,c+={(s−t)+x∣s∣≥∣x∣(x−t)+sotherwise,s=t,t = s + x, \qquad c \mathrel{+}= \begin{cases} (s - t) + x & \lvert s \rvert \ge \lvert x \rvert \\ (x - t) + s & \text{otherwise} \end{cases}, \qquad s = t,

and the total is s+cs + c. When ∣s∣≥∣x∣\lvert s \rvert \ge \lvert x \rvert, s−ts - t is computed exactly, and (s−t)+x(s - t) + x is exactly the part of xx that tt dropped. This is the same idea as Kahan’s sum in M09.2, run as a stream. merge adds another accumulator’s ss and then its cc, so shards scored on different workers combine without losing their low bits.

Score the cat sat. as four BPE tokens with the model probabilities below. The text is 12 UTF-8 bytes.

tokenthe cat sat.total
bytes3441B=12B = 12
qtq_t1/41/21/81/2
ℓt=−ln⁡qt\ell_t = -\ln q_t2ln⁡22 \ln 2ln⁡2\ln 23ln⁡23 \ln 2ln⁡2\ln 2S=7ln⁡2=4.8520S = 7 \ln 2 = 4.8520 nats
  • Mean: ℓˉ=7ln⁡2/4=1.75ln⁡2=1.2130\bar\ell = 7 \ln 2 / 4 = 1.75 \ln 2 = 1.2130 nats per token, which is 1.75 bits per token.
  • Perplexity: e1.75ln⁡2=21.75=3.3636e^{1.75 \ln 2} = 2^{1.75} = 3.3636. The model is as unsure as a fair choice among about 3.4 tokens.
  • Bits per byte: 7 bits/12 bytes=0.58337 \text{ bits} / 12 \text{ bytes} = 0.5833.

A byte model on the same text. Suppose a byte-level model spends 0.7 bits on each of the 12 bytes: 8.4 bits in all. Its perplexity per (byte) token is 20.7=1.622^{0.7} = 1.62, which looks better than 3.36. Its bits per byte, 0.7, is worse than 0.5833. The BPE model predicts this text better; only bpb says so.

Batches. Score the same four tokens as two batches, [the] and [ cat, sat, ., PAD] with mask [1, 1, 1, 0] and NaN in the pad slot. Batch means: 2ln⁡22 \ln 2 and (1+3+1)/3⋅ln⁡2=1.667ln⁡2(1 + 3 + 1)/3 \cdot \ln 2 = 1.667 \ln 2. Their average is 1.833ln⁡21.833 \ln 2: wrong. The totals give S=2ln⁡2+5ln⁡2=7ln⁡2S = 2\ln 2 + 5 \ln 2 = 7 \ln 2 over n=1+3=4n = 1 + 3 = 4 tokens: 1.75ln⁡21.75 \ln 2, the same as one batch.

Compensation. Start at s=1s = 1, c=0c = 0 and add 10−1610^{-16} ten times. Plain addition: 1+10−161 + 10^{-16} rounds to 1 every time, so the total stays 1. Neumaier: each step t=1t = 1, c+=(1−1)+10−16c \mathrel{+}= (1 - 1) + 10^{-16}, so after ten steps c=10−15c = 10^{-15} and s+c=1.000000000000001s + c = 1.000000000000001, the right answer.

These numbers are the first cases in section 4: test_hand_example_sentence and test_hand_example_two_batches_with_padding.

python/tinyllm/info/ppl.py
def perplexity(nll_sum: float, n_tokens: int) -> float # exp(nll_sum / n_tokens); inf on overflow
class NLLAccumulator:
def __init__(self) -> None
def add(self, nll: ArrayLike, mask: ArrayLike | None = None, n_bytes: int = 0) -> None
def merge(self, other: "NLLAccumulator") -> None
def result(self) -> dict[str, float]
# {"nll_sum", "n_tokens", "n_bytes", "nll_mean", "ppl", "bits_per_token", "bpb"}

Call bits_per_byte from your M00.1 for bpb; with no bytes added, bpb is NaN. A counted NaN or negative loss is a ValueError; a counted +∞+\infty is allowed and makes ppl infinite.

TestKINDChecksWhy it matters downstream
test_hand_example_sentenceunit7ln⁡27 \ln 2, 1.75 bits per token, PPL=3.3636\mathrm{PPL} = 3.3636, bpb 0.5833you and the test agree on every definition
test_hand_example_two_batches_with_paddingunittwo batches with a NaN pad give the one-batch numbersthe mean of means is wrong
test_perplexity_of_a_uniform_model_is_kpropertyPPL=k\mathrm{PPL} = k for uniform-kk losses, up to k=50 257k = 50\,257perplexity as a number of choices
test_perplexity_overflow_is_infboundary800 nats per token and an infinite total give ∞\inftyuntrained models in the zoo
test_perplexity_rejects_bad_argumentsboundaryzero tokens, a negative or NaN totalsign bugs upstream
test_masked_positions_are_ignored_whatever_they_holdboundaryNaN, ∞\infty, and negative values in masked slots; 0/1 maskspadded batches in L6.7
test_rejects_bad_batchesboundarymask shape, counted NaN or negative, negative bytes; a counted ∞\infty is allowedcaller bugs fail loudly
test_result_needs_tokens_and_bpb_needs_bytesboundaryno tokens raises; no bytes gives NaN bpba table shows “missing”, not “perfect”
test_streaming_equals_one_batchpropertyrandom splits in reverse order match one batch to 10−1310^{-13}the number does not depend on batch size
test_compensated_sum_across_many_addsboundary1+105×10−151 + 10^5 \times 10^{-15} to within 2 ulpsdrift over 10710^7 tokens
test_float32_batches_are_widenedboundary10510^5 float32 losses summed to 10−1510^{-15}losses arrive as float32
test_merge_equals_a_single_accumulatorpropertymerged shards equal one accumulator, low bits included; other unchangedsharded evaluation in dur.11
test_bits_per_byte_compares_tokenizersunitthe byte model has lower perplexity and higher bpbL1.6, the C1 tokenizer ablation
test_golden_casesgoldenthree long cases against 50-digit totals from course/oracle/M11.2independent of float64 rounding
PitfallSymptomCaught by
1. averaging batch meansthe reported loss changes with the batch size and the last batch’s lengthtest_hand_example_two_batches_with_padding (mutant s01)
2. multiplying by the maskNaN in a pad slot turns the total into NaN; pad slots are counted as tokenstest_masked_positions_are_ignored_whatever_they_hold (mutants s02, s03)
3. plain float64 addition across batches10−1510^{-15}-sized contributions vanish into a large total; the result depends on ordertest_compensated_sum_across_many_adds (mutant s04)
4. summing float32 losses in float32seven digits of a 10510^5-token batch losttest_float32_batches_are_widened (mutant s05)
5. math.exp of a huge meanOverflowError in the middle of a zoo reporttest_perplexity_overflow_is_inf (mutant s06)
6. bits per byte divided by tokens, or left in natsa bpb that is really bits per token, or off by ln⁡2\ln 2test_hand_example_sentence (mutants s07, s08)
7. a merge that drops the other side’s compensation or bytessharded and single runs disagree in the last digits, or bpb is wrong after mergingtest_merge_equals_a_single_accumulator (mutants s09, s10)
8. accepting a counted NaN, or reporting on zero tokensNaN or a ZeroDivisionError reaches the reporttest_rejects_bad_batches, test_result_needs_tokens_and_bpb_needs_bytes (mutants s11, s12)

| Forward | L2.1 | Registered call site uses this module. | | Forward | L2.2 | Registered call site uses this module. |

DirectionModuleHow it uses this
BackM00.1bits_per_byte converts the nats total to bits per byte
BackM11.1cross-entropy: the mean NLL is the cross-entropy of the text under the model
BackM09.2Kahan’s compensated sum, here run as a stream across batches
ForwardL1.6tokenizer metrics report bytes per token and the bpb of a reference model per tokenizer
ForwardL2.1, L2.2, L3.6each statistical and recurrent model reports its validation perplexity and bpb through an accumulator
ForwardL6.7the model zoo scores every family with one accumulator per model and prints bpb
ForwardL8.5, C1quantization is allowed a fixed bpb increase; the capstone’s training curve and ablations are in bpb

If you skip this module, ol check L1.6 stops with L1.6 needs M11.2: build it, or rerun with --ref-deps.

Your pieceProduction equivalentWhat it addsWhere to look
result()["bpb"]EleutherAI lm-evaluation-harnessbits_per_byte, byte_perplexity, and word_perplexity aggregations over documents, weighted by lengthlm_eval/api/metrics.py
perplexity over a corpusllama.cpp perplexitystrided windows that score only the second half of each context, so every token has enough historytools/perplexity/perplexity.cpp
batch sums with fsumCPython math.fsumShewchuk’s exact partial sums, correctly rounded whatever the orderModules/mathmodule.c (math_fsum)