Back to the map
MathematicsFoundation27h

Probability & statistics

Distributions, expectation, and reading an experiment.

Probability is the arithmetic of things that are uncertain: how likely an outcome is, and how that changes once you learn something new. A distribution is the full picture of which outcomes occur and how often. Adult heights cluster near the middle with few people at either extreme, and that shape is the distribution. Statistics runs the same machinery backwards: you hold a sample of outcomes and work out what produced it.

Every measurement you take is noisy. Without this you cannot tell whether the new prompt is genuinely four per cent better or whether it happened to meet an easier sample, so you ship changes that do nothing and revert changes that worked. The same arithmetic tells you how many examples an eval set needs before its number means anything at all.

It is the mathematical floor of the ML and research tracks alongside linear algebra and calculus, and it sits directly under supervised learning, model evaluation and metrics, evals, and shipping and measuring, where the question is always whether a difference is real.

Almost every number you measure is a sample, and a sample can be wrong by luck alone.

In more detailConditional probability, distributions, expectation and variance, and, most practically, hypothesis testing and confidence intervals. The applied payoff is being able to tell whether a measured improvement is real or noise, which matters as much in eval work as in model training.

Section 1 of 6, 6h

Probability, and reading an experiment honestly

By the end: You can tell whether a measured improvement is real or noise.

  • Distributions and expectation
  • Conditional probability and Bayes
  • Sampling variation
  • Why a difference can be meaningless

Most of the probability worth knowing here serves one practical question: my new version scored better, should I believe it? This section covers what you need to answer that, distributions and expectation, conditional probability and Bayes, and how much a measurement drifts purely because of which cases happened to be sampled. It is the difference between reporting a result and reporting a fluctuation.

What you actually need#

The probability that matters in practice is not the theory. It is being able to answer one question: my new version scored better, is that real?

A random variable, in plain words#

A random variable is neither a variable nor random in the everyday sense. It is a rule that turns something you cannot predict into a number. Roll a die and read the top face: that is a random variable. Serve a web request and record how many milliseconds it took: that is a random variable. Send a prompt to a model, have a grader score the answer out of two, and the score is a random variable. The rule never changes. What changes is which outcome the world hands you this time.

A distribution is the whole picture of one such variable: every value it can take, and how often. Write the values in one column and their long-run shares in another, and you have written the distribution down. The shares add up to one, because some outcome always happens. Nothing else is required of it, and the word carries no more mystery than that.

Make it concrete. A grader awards 0, 1 or 2 points per answer, and across everything your system produces, 20 per cent score 0, 50 per cent score 1 and 30 per cent score 2. Those three numbers are the distribution of the score. Every other quantity in this section — the average score, how much it wobbles, how far a 50-case eval can drift from the truth — is arithmetic on those three numbers.

import numpy as np
rng = np.random.default_rng(0)

values = np.array([0, 1, 2])          # what the grader can award
shares = np.array([0.2, 0.5, 0.3])    # how often each score happens; they sum to 1

draws = rng.choice(values, size=200_000, p=shares)
print(np.bincount(draws) / len(draws))
# [0.200215 0.50064  0.299145]
Ran as shown. Two hundred thousand graded answers, and the observed shares come back as the ones written down. A distribution is a claim about long-run shares, and you can always check it by generating the long run.

Expectation and variance, by hand then by counting#

The expectation — also called the mean, or the expected value — is the long-run average: each value multiplied by its share, added up. Here that is 0 times 0.2, plus 1 times 0.5, plus 2 times 0.3, which is 0 plus 0.5 plus 0.6, so 1.1. Notice what 1.1 is not. It is not a score any single answer can receive. An expectation does not have to be an attainable value, which is why an average of anything counted needs reading with care.

The variance measures spread: it is the expectation of the squared distance from the mean. The distance is squared so that two below the mean counts the same as two above, and so that one large miss counts for more than several small ones. By hand: a 0 is 1.1 away, squared 1.21, weighted by its share 0.2, contributing 0.242. A 1 is 0.1 away, squared 0.01, weighted 0.5, contributing 0.005. A 2 is 0.9 away, squared 0.81, weighted 0.3, contributing 0.243. Adding the three gives 0.49.

Squaring left the units wrong — 0.49 is in points squared — so the number usually quoted is its square root, 0.7, the standard deviation. That one is in points and can be compared directly with the mean of 1.1. If you remember one thing from the pair, it is that the mean tells you where a quantity sits and the standard deviation tells you how much a single observation of it is worth trusting.

mean = (values * shares).sum()
variance = (shares * (values - mean) ** 2).sum()
print(mean, variance, variance ** 0.5)
# 1.1 0.49 0.7

print(draws.mean(), draws.var())
# 1.09893 0.4895728551000001
Ran as shown, continuing the block above. The hand arithmetic 0.242 + 0.005 + 0.243 gives 0.49; 200,000 draws give 0.4896. The remaining gap is simulation noise, not disagreement, and the next section says how much of it to expect.

Distributions#

  • Normal: heights, measurement error, averages of many things. Symmetric around a mean.
  • Binomial: counts of successes in trials. Pass rates on an eval set are binomial.
  • Long-tailed: latency, request sizes, income. The mean is misleading and the median plus a high percentile is honest.

Report p95, not the average, for latency

Latency is long-tailed. An average hides the slow requests, which are exactly the ones users notice and complain about. The same applies to cost per request.

Conditional probability#

The probability of something given that something else is true. The definition is one line: the probability of A given B is the probability that A and B both happen, divided by the probability of B. In words, and this is the version to actually hold in your head: throw away every case where B did not happen, then ask what share of the cases left over had A. Conditioning is a filter, not a formula.

The place it goes wrong is that two facts which sound the same are different filters. Flip two fair coins. Four outcomes are equally likely: heads-heads, heads-tails, tails-heads, tails-tails. Told that at least one coin came up heads, you throw away tails-tails and three outcomes survive, of which one is two heads, so the answer is one in three. Told instead that the first coin came up heads, you keep heads-heads and heads-tails, so the answer is one in two. Same coins, same question, different filter, and most people give one half to both.

flips = rng.integers(0, 2, size=(200_000, 2))     # 1 is heads
both = flips.sum(axis=1) == 2
at_least_one = flips.sum(axis=1) >= 1
first_is_head = flips[:, 0] == 1

print(both[at_least_one].mean())     # 0.3345792524485309   -> one third
print(both[first_is_head].mean())    # 0.5008677438659486   -> one half
Ran as shown, continuing from the blocks above. Conditioning in code is a boolean mask: both[at_least_one] is the sequence of outcomes that survived the filter, and its mean is the conditional probability.

Independence is the case where the filter changes nothing. Two events are independent when the probability of both is the product of the two separate probabilities, which is the same as saying that learning one of them leaves your belief about the other exactly where it was. The first coin and the second coin are independent. The first coin and 'at least one head' are not, because knowing the first is heads guarantees the second fact.

second_is_head = flips[:, 1] == 1

print(first_is_head.mean() * second_is_head.mean(),
      (first_is_head & second_is_head).mean())
# 0.2507678055 0.251085     -> the product matches, so independent

print(first_is_head.mean() * at_least_one.mean(),
      (first_is_head & at_least_one).mean())
# 0.376200585 0.5013        -> nowhere near, so not independent
Ran as shown. The multiplication rule is the test for independence, and it is two lines of code on any data you have. Assuming independence without running it is how correlated failures get costed as if they were rare.

One more, because interviewers reach for it and because it breaks the most common wrong mental model. Uncorrelated does not mean independent. Correlation only detects a straight-line relationship. Take x spread evenly between minus one and one, and let y be x squared. Knowing x tells you y exactly — there is no relationship more dependent than that — and the correlation is zero.

x = rng.uniform(-1, 1, 200_000)
y = x ** 2

print(np.corrcoef(x, y)[0, 1])       # 0.0022397797187538398  -> effectively zero
print((y > 0.25).mean())             # 0.499755   unconditionally, a coin flip
print((y[x > 0.5] > 0.25).mean())    # 1.0        given x > 0.5, a certainty
Ran as shown. A correlation of zero and a conditional probability that moves from 0.50 to 1.00. Correlation is one specific question about a pair of variables, and a zero answer is not a licence to treat them as unrelated.

Bayes' theorem#

Bayes' theorem reverses the direction of a conditional probability. You usually know how the evidence behaves given the state of the world — how often the test fires when someone is ill — and you want the state of the world given the evidence, which is the opposite. The rule is: the probability of A given B equals the probability of B given A, times the probability of A, divided by the probability of B. Read as a procedure it is even shorter. Take what you believed before the evidence, multiply by how likely that evidence would be if the belief were true, do the same for every competing belief, and rescale so the results add to one.

The example every interviewer uses is the medical test, so work it properly once. A test is 99 per cent accurate for a condition that affects 1 person in 1000. Spell out what 99 per cent accurate means, because the phrase hides two numbers: the test catches 99 per cent of the people who have the condition, and it correctly clears 99 per cent of the people who do not, so it wrongly flags 1 per cent of them. You test positive. What is the chance you have it?

A test is 99% accurate. The condition affects 1 in 1000.

Out of 100,000 people:
  100 have it        ->  99 test positive        (99% of 100)
  99,900 do not      -> 999 test positive        (1% of 99,900)

  1,098 positives in total, of which 99 are real.
  99 / 1,098 = 0.090

A positive result means about a 9% chance of actually having it.
The whole theorem, done in counts of people. There are a thousand times more healthy people than ill ones, so a 1 per cent error rate applied to them produces ten times as many positives as the ill group can supply. Reason this way out loud in an interview: it is faster than the formula and much harder to get wrong.

The symbolic version gives the identical number. The probability of being ill given a positive is 0.99 times 0.001, divided by that same product plus 0.01 times 0.999. The numerator is 0.00099 and the denominator is 0.00099 plus 0.00999, which is 0.01098, so the answer is 0.0902. It is the counts argument with the 100,000 divided out.

def posterior(prior, sensitivity, specificity):
    hit = sensitivity * prior                       # ill, and flagged
    false_alarm = (1 - specificity) * (1 - prior)   # healthy, and flagged anyway
    return hit / (hit + false_alarm)

for prior in (0.001, 0.01, 0.10):
    print(prior, round(posterior(prior, 0.99, 0.99), 4))
# 0.001 0.0902
# 0.01 0.5
# 0.1 0.9167
Ran as shown. The same test, unchanged, is worth 9 per cent, 50 per cent or 92 per cent depending only on how common the condition is. At a prevalence of 1 per cent the two terms are exactly equal, so a positive is a coin flip. A test result is never interpretable on its own.

Check it by simulation rather than believing the algebra, which takes one more block. Two million people, one in a thousand ill, everyone tested.

n = 2_000_000
has_it = rng.random(n) < 0.001
tests_positive = np.where(has_it, rng.random(n) < 0.99, rng.random(n) < 0.01)

print(tests_positive.sum(), has_it[tests_positive].mean())
# 21848 0.09044306114976199
Ran as shown. Of two million people, 21,848 test positive and 9.0 per cent of them are actually ill, against the 0.0902 the formula gave. Only about 22,000 positives carry the estimate, so the third decimal here is noise; the first two are solid.

There is a form of the rule worth memorising because it makes repeated evidence trivial. Work in odds rather than probabilities: prior odds times the likelihood ratio gives posterior odds, and the likelihood ratio for a positive is sensitivity divided by the false positive rate, here 0.99 over 0.01, which is 99. Prior odds of 1 to 999, multiplied by 99, gives 0.099 to 1, which is the 9 per cent again. Test a second time with an independent test and multiply by 99 once more: odds of 9.81 to 1, or 91 per cent.

odds = 0.001 / 0.999            # prior odds: 1 case per 999 healthy people
for test in (1, 2, 3):
    odds = odds * 99            # each independent positive multiplies by the ratio
    print(test, round(odds, 3), round(odds / (1 + odds), 4))
# 1 0.099 0.0902
# 2 9.811 0.9075
# 3 971.27 0.999
Ran as shown. Evidence multiplies odds, which is why the odds form is the one to carry into an interview. It also shows why re-testing works and why a single positive on a rare condition never should have been alarming.

None of this is medical trivia. It is the arithmetic of every rare-event classifier you will ship. A fraud model at a 1 per cent base rate with a 1 per cent false positive rate sends an analyst one real case for every one false alarm, and that is the good version; at a 0.1 per cent base rate the analyst sees ten false alarms per real case and stops reading the queue. The quantity that decides whether an alerting system is usable is precision at the operating point, and precision is this calculation.

Sampling variation#

Measuring on 50 examples gives a number with real uncertainty in it. Two systems scoring 82 percent and 86 percent on 50 examples may well be identical, and the difference is noise.

import math

def margin(p, n):
    """Rough 95% margin of error for a proportion."""
    return 1.96 * math.sqrt(p * (1 - p) / n)

print(margin(0.85, 50), margin(0.85, 500), margin(0.85, 5000))
# 0.09897514839594836 0.031298690068435775 0.009897514839594836
Ran as shown. On 50 examples the margin is ten points, so 85 per cent means roughly 75 to 95. Ten times the data halves the margin rather than shrinking it tenfold, because the square root is in the denominator: precision costs quadratically.

That formula is worth understanding rather than memorising, and it comes straight out of the previous two sections. A pass on one eval case is a random variable worth 1 or 0; if the true pass rate is p its variance is p times one minus p. Average n of them and the variance divides by n, so the standard deviation of the average is the square root of p times one minus p over n. The 1.96 turns a standard deviation into a 95 per cent range, for reasons the central limit theorem supplies later in this topic.

Now put a number on the mistake in the headline. Take two systems that are genuinely identical, both passing 85 per cent of the time, and score each on its own 50 cases. How often does the pair differ by four points or more — enough for someone to write that one is better?

a = rng.binomial(50, 0.85, 200_000) / 50      # 200,000 evals of system A
b = rng.binomial(50, 0.85, 200_000) / 50      # and of an identical system B

print((np.abs(a - b) >= 0.04).mean())    # 0.624755
print((np.abs(a - b) >= 0.10).mean())    # 0.13899
Ran as shown. Two identical systems produce a four-point gap 62 per cent of the time on 50 cases, and a ten-point gap 14 per cent of the time. The 86 against 82 that started this section is the single most likely thing to happen when nothing has changed at all.

This is the most useful thing on this page. Before claiming an improvement, check whether your eval set is large enough for the difference to mean anything.

Paired comparison is stronger#

The 62 per cent above is pessimistic on purpose: it assumed the two systems were scored on different samples. In practice you score both on the same examples, and that is a much better experiment, because the hard cases are hard for both and the easy cases are easy for both. All the shared difficulty cancels. So throw away the aggregate scores and look only at the cases where the two systems disagree.

Say A scores 41 out of 50 and B scores 43, the familiar 82 against 86. On 45 of the 50 cases they gave the same verdict. Of the five where they differed, B won three and A won two. If the two systems were equally good, each disagreement would go either way like a coin flip, so the question is whether 3 out of 5 is a surprising number of coin flips. It is not: the two-sided probability of a split at least that lopsided is 1.0, meaning every possible outcome is at least as extreme as this one.

from scipy import stats

print(stats.binomtest(3, 5, 0.5).pvalue)   # 1.0      the actual result: 3 wins of 5
print(stats.binomtest(5, 5, 0.5).pvalue)   # 0.0625   a clean sweep of all 5
print(stats.binomtest(6, 6, 0.5).pvalue)   # 0.03125  six disagreements, all one way
Ran as shown. The sign test on the disagreements. The useful line is the middle one: with only five disagreements, even a 5-0 sweep gives 0.0625, so no outcome on this eval can clear the conventional 0.05 bar. Six disagreements all going one way is the smallest result that can.

That reframes what to ask for. The number that decides whether a comparison can conclude anything is not the size of the eval set, it is the number of cases the two systems actually disagree on. Two prompts that differ on five of fifty cases cannot be separated no matter how the five fall. Either find more cases where they behave differently — which usually means harder cases — or accept that the change is too small for this eval to see.

Where people go wrong

Claiming an improvement from a small eval set. It is the single most common statistical error in this field, and it leads teams to keep changes that did nothing.

Build this: Take an eval of 50 cases and compute the margin of error on your pass rate. Then work out how many cases you would need for a three point difference to be meaningful.

Open this section on its own page
Section 2 of 6, 5h

Estimation, likelihood and uncertainty

By the end: You can reason about where loss functions come from and quantify uncertainty in a result.

  • Maximum likelihood
  • Why cross-entropy is the classification loss
  • Bootstrapping
  • Calibration

Loss functions can look like arbitrary choices somebody made. They are not: nearly all of them come from one principle, which is to pick the parameters that make the data you actually observed most probable. This section works from there to why cross-entropy is the classification loss, then on to two practical tools: a way to put a range around any measurement without any distribution theory, and a way to check whether a model's stated confidence means anything. The second matters the moment you show a probability to a user.

Maximum likelihood#

Pick the parameters that make the observed data most probable. It sounds abstract and it is what nearly all training does. Minimising a loss function is usually maximising a likelihood written the other way round.

The word likelihood is doing precise work and is worth pinning down, because it is the same arithmetic as a probability read in the opposite direction. A probability answers: given this coin is 70 per cent heads, how often do I see seven heads in ten flips? A likelihood answers: given that I did see seven heads in ten flips, how well does 70 per cent explain it? The formula is identical. What changes is which part is fixed. The data is fixed and known; the parameter is the thing being varied.

So the procedure is: write the probability of the data you actually got as a function of the parameter, then find the parameter that makes it largest. Seven heads in ten flips has probability p to the seventh times one minus p cubed, up to a counting constant that does not depend on p and so cannot move the answer. Try p equals 0.5 and you get 0.000977. Try 0.7 and you get 0.002224, more than twice as good. Try 0.9 and it falls to 0.000478, because nine-tenths heads struggles to explain the three tails.

import numpy as np
rng = np.random.default_rng(0)

heads, flips = 7, 10

def likelihood(p):
    return p ** heads * (1 - p) ** (flips - heads)

for p in (0.3, 0.5, 0.6, 0.7, 0.8, 0.9):
    print(p, round(likelihood(p), 6))
# 0.3 7.5e-05
# 0.5 0.000977
# 0.6 0.001792
# 0.7 0.002224
# 0.8 0.001678
# 0.9 0.000478

grid = np.linspace(0.001, 0.999, 999)
print("peak at", round(grid[likelihood(grid).argmax()], 3))   # peak at 0.7
Ran as shown. Scanning a thousand candidate values finds 0.7, which is 7 out of 10 — the answer anybody would have guessed. That is the point: maximum likelihood reproduces the obvious estimator here, which is what earns it trust in the cases where the obvious estimator does not exist.

Now the result that connects it to everything you train. Suppose your data came from a normal distribution and you want its mean. The log-likelihood of a normal, with the terms that do not involve the mean dropped, is minus the sum of squared distances from the mean, divided by twice the variance. That is worth reading twice: maximising the likelihood of a normal is exactly minimising a sum of squared errors. Squared error is not a convention somebody chose for convenience. It is what maximum likelihood says to do if you believe your noise is normal.

Differentiate to find where it peaks. The derivative of the sum of squared distances with respect to the mean is minus twice the sum of the deviations, and setting that to zero says the deviations must cancel — which happens at the arithmetic average and nowhere else. So the sample mean is the maximum likelihood estimate of a normal's mean. Check it on five numbers: 4.1, 5.3, 4.8, 6.2 and 5.1 add to 25.5, so the mean is 5.1, and the log-likelihood should be highest there and symmetric either side of it.

x = np.array([4.1, 5.3, 4.8, 6.2, 5.1])

def log_likelihood(mu, sigma):
    return (-0.5 * np.log(2 * np.pi * sigma ** 2) - (x - mu) ** 2 / (2 * sigma ** 2)).sum()

for mu in (4.9, 5.0, 5.1, 5.2, 5.3):
    print(mu, round(log_likelihood(mu, 1.0), 4))
# 4.9 -5.8647
# 5.0 -5.7897
# 5.1 -5.7647     <- the peak
# 5.2 -5.7897
# 5.3 -5.8647

print(x.mean(), ((x - x.mean()) ** 2).sum())
# 5.1 2.3400000000000007
Ran as shown. The peak sits at 5.1, the sample mean, and the curve is symmetric around it: 5.0 and 5.2 score identically, as do 4.9 and 5.3. The squared deviations are 1.0, 0.04, 0.09, 1.21 and 0, summing to 2.34, which is the number the code prints.

The same derivation gives the variance: divide that 2.34 by the five observations to get 0.468. And here is the wrinkle interviewers like, because it is the one place maximum likelihood is visibly imperfect. That estimate is biased low. It measures spread around the sample mean rather than the true mean, and the sample mean sits, by construction, in the middle of the very data being measured, so the distances come out too small. The correction is to divide by n minus one instead of n, which is where that famous and otherwise unmotivated minus one comes from.

mle, unbiased = [], []
for _ in range(20_000):
    sample = rng.normal(10.0, 2.0, 5)          # true variance is 4.0
    mle.append(((sample - sample.mean()) ** 2).mean())     # divide by n
    unbiased.append(sample.var(ddof=1))                    # divide by n - 1

print(round(np.mean(mle), 3), round(np.mean(unbiased), 3))
# 3.205 4.006
Ran as shown. Twenty thousand samples of five points from a distribution whose true variance is 4. The maximum likelihood estimate averages 3.205, low by a factor of four fifths, which is exactly n minus one over n. The n-minus-one version averages 4.006. On samples of five it matters; by a few hundred observations it is invisible.

Where cross-entropy comes from#

For classification, the model outputs a probability for each class. The likelihood of the data is the product of the probabilities assigned to the correct classes. Taking the log turns the product into a sum, and negating it gives cross-entropy. It is not an arbitrary choice; it is the likelihood.

Correct class given probability 0.9   ->  loss -log(0.9)  = 0.11
Correct class given probability 0.5   ->  loss -log(0.5)  = 0.69
Correct class given probability 0.01  ->  loss -log(0.01) = 4.6

A confident wrong answer is punished very hard, which is
exactly the behaviour you want.

Trace it once through real numbers, because the chain from raw model output to loss is short and worth being able to draw from memory. The network emits one unbounded score per class, called a logit. The softmax exponentiates them and divides by the total, which forces them positive and makes them sum to one, so they can be read as probabilities. Cross-entropy then picks out the single probability assigned to the correct class and takes its negative logarithm. Three logits of 2.0, 1.0 and 0.1 become probabilities of 0.659, 0.242 and 0.099, so the loss is 0.417 if the answer was the first class and 2.317 if it was the third.

logits = np.array([2.0, 1.0, 0.1])          # raw model outputs, any sign, any size
e = np.exp(logits - logits.max())           # subtract the max: identical result, no overflow
probs = e / e.sum()
print(np.round(probs, 4))                   # [0.659  0.2424 0.0986]

print(round(-np.log(probs[0]), 4), round(-np.log(probs[2]), 4))
# 0.417 2.317
Ran as shown. This is the whole of torch.nn.functional.cross_entropy on one example, and calling it on these logits returns 0.41703 for class 0. Frameworks want raw logits rather than probabilities precisely so they can do the max subtraction and the logarithm in one numerically stable step.

Once you see losses as likelihoods, the choice of loss stops being a menu and becomes a statement about the noise. Assume the target is the truth plus normal noise and you get squared error. Assume each label is a coin flip whose bias the model predicts and you get cross-entropy. Assume counts arriving at a rate the model predicts and you get the Poisson loss used for demand and traffic forecasting. Assume noise with heavier tails than normal and you get absolute error, which is why mean absolute error is the robust default on data with outliers. Picking a loss is picking a noise model, and if you cannot say what noise model your loss implies, you have not chosen it, you have inherited it.

Bootstrapping#

When you cannot derive a confidence interval, resample your data with replacement many times, recompute the statistic each time, and look at the spread. It is general, it needs no distributional assumptions, and it answers the question most people actually have about an eval score.

The idea behind it is a sleight of hand that works. You wanted many samples from the population and you only have one sample of fifty. So treat the fifty you have as if they were the population, draw fifty from them with replacement, and score that. Repeat ten thousand times. The spread of those ten thousand scores mimics the spread you would have seen across ten thousand genuine eval sets. With replacement is the part people get wrong and the part that makes it work: without it, every resample is the original set reordered and the spread is zero.

pass_fail = rng.binomial(1, 0.85, 50)      # 50 graded eval cases, 1 = passed
print(pass_fail.sum(), pass_fail.mean())   # 44 0.88

def bootstrap_ci(results, n=10_000):
    results = np.asarray(results)
    samples = rng.choice(results, (n, len(results)), replace=True)
    means = samples.mean(axis=1)
    return np.percentile(means, [2.5, 97.5])

print(bootstrap_ci(pass_fail))             # [0.78 0.96]
Ran as shown, continuing the blocks above. Forty-four passes out of fifty reads as 88 per cent, and the honest statement is 88 per cent with a range of 78 to 96. The normal approximation on the same data gives 0.79 to 0.97, so on a plain proportion the bootstrap earns nothing; it earns its keep on medians, percentiles, ratios and weighted scores, where no formula is to hand.

Calibration#

A calibrated model's 80 percent predictions are right about 80 percent of the time. Most modern networks are overconfident. Calibration matters whenever a probability drives a decision, such as a threshold or a confidence display, and it can be measured by grouping predictions by confidence and checking accuracy in each group.

Measuring it takes about ten lines and no theory. Sort every prediction into buckets by the probability it stated, then within each bucket compare the average stated probability against the share that actually came true. A calibrated model produces two columns of matching numbers. The average gap, weighted by how many predictions fall in each bucket, is the expected calibration error, and it is the number usually reported.

n = 20_000
truth_rate = rng.uniform(0.05, 0.95, n)              # the real chance for each case
happened = (rng.random(n) < truth_rate).astype(int)  # what actually occurred

logit = np.log(truth_rate / (1 - truth_rate))
stated = 1 / (1 + np.exp(-2.0 * logit))              # a model that doubles its logits

edges = np.linspace(0, 1, 6)
bucket = np.clip(np.digitize(stated, edges) - 1, 0, 4)
ece = 0.0
for b in range(5):
    m = bucket == b
    says, actually = stated[m].mean(), happened[m].mean()
    ece += m.mean() * abs(says - actually)
    print(f"{edges[b]:.1f}-{edges[b+1]:.1f}  n={m.sum():5d}  says {says:.2f}  happens {actually:.2f}")
print("ECE", round(ece, 3))

# 0.0-0.2  n= 6303  says 0.07  happens 0.19
# 0.2-0.4  n= 2641  says 0.29  happens 0.39
# 0.4-0.6  n= 2224  says 0.50  happens 0.50
# 0.6-0.8  n= 2575  says 0.70  happens 0.60
# 0.8-1.0  n= 6257  says 0.93  happens 0.81
# ECE 0.102
Ran as shown. The middle bucket is perfect and the outer ones are wrong by twelve points in opposite directions, which is the signature of overconfidence: the model is pushed away from the middle in both directions. A single accuracy number cannot see any of this, and the fix — fitting one temperature on held-out data and dividing the logits by it — is smaller than the diagnosis.

An uncalibrated probability is not a probability

Showing users a 92 percent confidence figure from an uncalibrated model is worse than showing nothing, because it looks like information. Check calibration before exposing any score.

Where people go wrong

Displaying model confidence to users without checking calibration. The number looks precise and means little.

Build this: Bootstrap confidence intervals for two versions of a system on the same eval. Then plot a calibration curve for a classifier and see how overconfident it is.

Open this section on its own page
Section 3 of 6, 4h

Entropy, cross-entropy, KL and perplexity

By the end: You can say what each of the four numbers measures, compute them, and tell when a reported one is not comparable with another.

  • Surprise and entropy
  • Cross-entropy as the cost of a wrong distribution
  • KL divergence as the excess, and why it is not a distance
  • Perplexity as the exponential of cross-entropy
  • Where each of them turns up in practice

Four numbers turn up constantly in model reporting and are routinely confused with one another. This section separates them, starting from the single quantity all four are averages of, and gives each a plain reading: what the floor is, what your model costs above that floor, what the gap between the two is used for, and what a perplexity figure actually tells you. It also covers when two of these numbers cannot be compared at all, which is the mistake most often made with them in public.

Surprise#

The surprise of an outcome is the negative logarithm of the probability you gave it. Something you said was certain surprises you not at all; something you gave one chance in a thousand surprises you a great deal. Everything on this page is an average of that one quantity, taken over a different set of assumptions.

surprise of an outcome     -log p
entropy        H(p)        average surprise, using the true distribution for both
cross-entropy  H(p, q)     average surprise of your q, when reality is p
KL             KL(p || q)  = H(p, q) - H(p)    the excess you pay for using q

log base 2 gives bits, natural log gives nats. Keep one base throughout.

Entropy is how uncertain the truth itself is: a fair coin has one bit, a loaded one has less, a certain outcome has none. It is the floor. No model, however good, can average less surprise than the entropy of what it is predicting, which is why a training loss that stops falling is not necessarily a model that stopped learning.

Compute it once by hand so the units mean something. A fair coin: each side has probability a half, whose negative log base two is one, and the average of one and one is one bit. A coin that lands heads nine times in ten: heads carries a surprise of 0.152 bits and tails carries 3.322, and the average weighted by how often each happens is 0.9 times 0.152 plus 0.1 times 3.322, which is 0.469 bits. Less than half the fair coin, because you can usually guess. Push it to 99 per cent heads and it falls to 0.081 bits, and at certainty it is zero.

import math

def entropy_bits(p):
    if p in (0.0, 1.0):
        return 0.0
    return -(p * math.log2(p) + (1 - p) * math.log2(1 - p))

for p in (0.5, 0.75, 0.9, 0.99, 1.0):
    print(p, round(entropy_bits(p), 4))
# 0.5 1.0
# 0.75 0.8113
# 0.9 0.469
# 0.99 0.0808
# 1.0 0.0
Ran as shown. Entropy is maximal at the fair coin and falls away in both directions. It is also why a bit is a sensible unit: one bit is exactly the information in one fair yes-or-no answer, so 0.469 bits says the loaded coin is worth less than half a question.

Cross-entropy and KL#

Cross-entropy is what you actually pay: the average surprise of your predicted distribution against outcomes drawn from the real one. It is the training loss for classification and for next-token prediction. KL divergence is the difference between what you paid and the floor, so it is the part of the loss that is your model's fault rather than the world's.

That split has a direct consequence for how you read a loss number, and it is the reason two training losses are so often compared when they should not be. A loss of 1.6 nats is excellent on text whose own entropy is 1.5 and dreadful on text whose entropy is 0.5, and nothing in the number itself distinguishes the two cases. So a loss curve is only comparable against another run on the same data. A loss that flattens at 1.6 may be a model that has run out of capacity, or a model that has learned everything there is and is now sitting on the floor, and the way to tell them apart is to change the model rather than to stare at the curve.

import math

def kl(p, q):
    return sum(pi * math.log(pi / qi) for pi, qi in zip(p, q))

p = [0.7, 0.2, 0.1]
q = [0.5, 0.3, 0.2]

kl(p, q)     # 0.085 nats
kl(q, p)     # 0.092 nats  -- a different number from the same pair
The order matters, so KL is not a distance and cannot be reported as one.

The asymmetry is not a technicality. KL(p || q) punishes q for putting almost no probability where p has some, so minimising it spreads q out to cover everything. KL(q || p) punishes q for putting probability where p has none, so minimising it makes q concentrate on one mode and ignore the rest. Which direction a method uses decides whether its approximation hedges or commits.

  • Preference training uses KL from the starting model as a leash, penalising the tuned model for drifting too far from what it began as.
  • Distillation trains the student by minimising KL against the teacher's full output distribution, which carries more information than the label alone.
  • Monitoring uses it to detect drift, comparing the distribution of today's inputs against the distribution the model was validated on.

Perplexity#

Perplexity is the exponential of cross-entropy, taken in the same base. It exists because a loss of 1.9 nats per token means nothing to anyone, while its exponential does: a perplexity of 8 says the model is as uncertain, on average, as if it were choosing uniformly between 8 equally likely tokens at each step. That is the average branching factor, and it is the only intuition the number carries.

# Natural-log probabilities the model gave to each correct next token.
logprobs = [-0.10, -2.30, -0.69, -1.61, -0.36]

cross_entropy = -sum(logprobs) / len(logprobs)
perplexity = math.exp(cross_entropy)
print(round(cross_entropy, 3), round(perplexity, 3))     # 1.012 2.751

# Comparable across tokenizers, because bytes are not a modelling choice:
n_bytes = 23
print(round(-sum(logprobs) / math.log(2) / n_bytes, 3))  # 0.317
Ran as shown, continuing from the entropy block. Just over one nat per token, a perplexity of 2.75, and 0.32 bits per byte. Five tokens is far too few to report, and the arithmetic is identical at five million.

The branching-factor reading gives entropy a job at inference time as well as in training. Apply the same average-surprise calculation to the model's next-token distribution and its exponential tells you, in effect, how many tokens the model is really choosing between at that step. That number is what a sampling temperature moves. Dividing the logits by a temperature below one sharpens the distribution and lowers its entropy; dividing by a temperature above one flattens it and raises the entropy.

import numpy as np

logits = np.array([4.0, 3.0, 1.0, 0.5, 0.0])
for T in (0.5, 1.0, 2.0):
    z = logits / T
    e = np.exp(z - z.max())
    probs = e / e.sum()
    H = -(probs * np.log(probs)).sum()                 # entropy in nats
    print(T, np.round(probs, 3), round(float(H), 3), round(float(np.exp(H)), 2))

# 0.5 [0.878 0.119 0.002 0.001 0.   ] 0.389 1.48
# 1.0 [0.682 0.251 0.034 0.021 0.012] 0.857 2.36
# 2.0 [0.468 0.284 0.104 0.081 0.063] 1.327 3.77
Ran as shown. The same five logits, three temperatures. At 0.5 the model is effectively choosing between 1.5 tokens, which is why low temperature reads as repetitive; at 2.0 it is choosing between 3.8 of these five, which is why high temperature wanders. Per-token entropy is also a usable uncertainty signal: a spike in it is the model not knowing what comes next.

Compare perplexity across tokenizers and you have measured the tokenizer

Perplexity is per token, and a different vocabulary splits the same text into a different number of tokens. A model whose tokenizer produces fewer, longer tokens has to carry more information in each one, so its perplexity looks worse on identical text. Two perplexities are comparable only with the same tokenizer and the same test set. Across tokenizers, convert to bits per byte or bits per character.

It is also not a task metric. Perplexity measures how well the model predicts held-out text of the same kind as its training data, which is a different question from whether it does your job. It is a good early warning, a good check on whether quantization damaged something, and a poor argument for shipping.

Where people go wrong

Quoting a perplexity as a quality score. It is comparable only within one tokenizer and one test set, it says nothing about whether the model does your task, and a model can improve on it while getting worse at everything you care about.

Build this: Score the same held-out file with two models that share a tokenizer and compute cross-entropy and perplexity for each. Then repeat with a model using a different tokenizer, see how misleading the raw comparison is, and convert all three to bits per byte.

Open this section on its own page
Section 4 of 6, 4h

Probability by simulation

By the end: You can answer probability questions by simulating them in a few lines of Python, and check your maths against the simulation.

  • Estimating a probability by counting
  • The law of large numbers
  • The birthday problem
  • Base rates: the medical test
  • The central limit theorem, seen

Probability questions are easy to get wrong by reasoning about them and hard to get wrong by counting. This section takes that seriously: for each idea you simulate the situation tens of thousands of times in a few lines of Python and count what happened, then do the maths and check the two agree. The habit is worth more than any single result here, because it means you can settle an argument about a probability on your own instead of looking for someone to confirm it.

Count what happens#

Every simulation in this section has the same three steps, and they are worth naming because the third is the one people leave out. Generate the situation many times. Reduce each repetition to a true or false. Take the mean of those booleans, which in NumPy is the share that were true. That is the probability. Two dice summing to seven is the smallest example: there are 36 equally likely pairs and six of them sum to seven, so the answer is one sixth, and the simulation should land on 0.167.

import numpy as np
rng = np.random.default_rng(0)

rolls = rng.integers(1, 7, size=(100_000, 2))       # two dice, 100,000 times
print((rolls.sum(axis=1) == 7).mean())              # 0.16775, against 6/36 = 0.1667
Ran as shown. Note integers(1, 7) is inclusive of 1 and exclusive of 7, which is the off-by-one that silently turns a die into a five-sided one. Print the range of anything you generate before you count with it.

Any probability is the long-run share of times something happens. If you can simulate the situation, you can estimate it by counting, and check an exact answer you worked out on paper.

How wrong can a simulation be?#

A simulated answer is itself a measurement, so it has its own error bar, and knowing it is what separates using this technique from waving at it. The estimate is a proportion from n independent trials, so the margin is the same one as for any proportion: 1.96 times the square root of p times one minus p over n. At 100,000 trials and a true answer near one sixth, that is about two parts in a thousand, which is why 0.16775 and 0.1667 agreeing to two decimals is a pass and agreeing to four would be luck.

import math

def mc_margin(p_hat, trials):
    return 1.96 * math.sqrt(p_hat * (1 - p_hat) / trials)

for trials in (1_000, 100_000, 10_000_000):
    print(trials, round(mc_margin(0.1667, trials), 5))
# 1000 0.0231
# 100000 0.00231
# 10000000 0.00023
Ran as shown. A hundredfold more work buys one more decimal place, because the error falls with the square root of the trials. Decide how many digits you need before choosing n, and never report more digits than this allows.

More samples, closer answer#

That square-root law is the law of large numbers with a rate attached. The average of repeated draws converges on the expectation, but slowly and not monotonically: it can be further away after more draws than it was before. Watch it wander in on a fair coin.

for n in [10, 100, 1_000, 100_000]:
    print(n, rng.integers(0, 2, n).mean())   # coin flips: drifts towards 0.5
# 10 0.8
# 100 0.49
# 1000 0.509
# 100000 0.49946
Ran as shown. Ten flips came out 80 per cent heads, which is not a bug and not a rare event: on ten flips, 8 or more heads happens about 5 per cent of the time. This is the same fact as an eval of ten cases being worthless, wearing different clothes.

The birthday problem#

Twenty-three people in a room, and the chance that two share a birthday is better than even. Nearly everyone guesses far lower, because they answer a different question — the chance that someone shares their birthday — when the real one counts all 253 pairs. It is the standard demonstration that intuition about compound events is not to be trusted, and it has an exact answer to check against: multiply 365/365 by 364/365 by 363/365 and so on, which is the probability that everyone is different, then subtract from one.

def shared_birthday(people, trials=50_000):
    days = rng.integers(0, 365, size=(trials, people))
    s = np.sort(days, axis=1)
    return (np.diff(s, axis=1) == 0).any(axis=1).mean()

def exact(people):
    none_shared = 1.0
    for i in range(people):
        none_shared *= (365 - i) / 365
    return 1 - none_shared

for k in (10, 23, 40, 60):
    print(k, round(shared_birthday(k), 3), round(exact(k), 3))
# 10 0.116 0.117
# 23 0.509 0.507
# 40 0.893 0.891
# 60 0.994 0.994
Ran as shown: simulated, then exact, agreeing everywhere to within the margin the previous block predicted for 50,000 trials. The sorting trick is worth keeping — sort each row, then look for a zero gap between neighbours — because it detects duplicates across a whole matrix without a Python loop.

The engineering version of this is hash collisions, and the numbers are the same shape. With a 64-bit hash you expect a collision after about four billion keys rather than after eighteen quintillion, because it is pairs that matter, not items. Any time you reason about the chance that some pair of things collides, the birthday calculation is the one to reach for.

Monty Hall#

Three doors, a car behind one, you pick a door, the host — who knows where the car is — opens a different door showing a goat and offers you the switch. Almost everyone says it makes no difference. It doubles your chances, and the simulation is shorter than the argument. The key is that your first pick is right one time in three, and the host's behaviour cannot change that, so the other unopened door holds the car the remaining two times in three. Switching wins exactly when your first pick was wrong.

trials = 200_000
car = rng.integers(0, 3, trials)
first_pick = rng.integers(0, 3, trials)

print("stay  ", (first_pick == car).mean())     # 0.332165
print("switch", (first_pick != car).mean())     # 0.667835
Ran as shown. The host never appears in the code, which is the whole insight: he only ever opens a goat door, so he adds no information about your own pick. Change the story so he opens a door at random and sometimes reveals the car, and switching drops to one half — a two-line edit that flips the famous answer.

Base rates#

A test catches 99% of people with a condition and wrongly flags 5% of people without it. The condition affects 1% of people. You test positive. How likely is it you have it?

n = 1_000_000
sick = rng.random(n) < 0.01
positive = np.where(sick, rng.random(n) < 0.99, rng.random(n) < 0.05)
print(sick[positive].mean())    # 0.16511298003850441: most positives are false alarms
Ran as shown. The exact answer is 0.99 times 0.01, over that plus 0.05 times 0.99, which is 0.0099 over 0.0594, exactly one sixth. The simulation is within a thousandth of it, and no algebra was required to get there.

Because healthy people vastly outnumber sick ones, 5% of them outnumber 99% of the sick. That is Bayes' theorem done in counts rather than symbols, and the same arithmetic explains why a fraud model with a low false positive rate still produces mostly false alerts.

Getting there by simulation rather than by formula is not a lesser route. The three lines above encode the story directly — who is sick, what the test does to each group — so a misreading of the question shows up as code you cannot write, whereas a misreading of the formula shows up as a confident wrong number. When a conditional probability argument matters, write both and make them agree.

Averages become bell curves#

Waiting times are about as un-bell-shaped as a quantity gets: most waits are short, a few are very long, and the histogram is a cliff falling to the right. Take thirty of them and average, though, and something strange happens to the average. Its distribution is close to symmetric, close to normal, and much narrower than the waits themselves.

waits = rng.exponential(scale=2.0, size=(100_000, 30))   # skewed, not bell-shaped
means = waits.mean(axis=1)                                 # the average of 30 waits
print(means.mean(), means.std())
# 2.00171649553826 0.3669219319476103
# the mean of the averages is the mean of the waits; 2.0 / sqrt(30) = 0.365
Ran as shown. Averaging thirty waits leaves the centre where it was and divides the spread by the square root of thirty. That second part is the whole reason an error bar shrinks when you collect more data.

The central limit theorem is the claim that this happens for almost anything you average, whatever shape it started as, and that the limit is specifically the normal distribution. Claims like that deserve measurement rather than assent, so measure it. Standardise the average — subtract its mean, multiply by the square root of n — and a normal result must have its middle 95 per cent between minus 1.96 and plus 1.96, and zero skewness. Then watch those three numbers as n grows.

for n in (1, 2, 5, 30, 200):
    m = rng.exponential(1.0, size=(200_000, n)).mean(axis=1)
    z = (m - 1.0) * math.sqrt(n)          # standardised: normal means 2.5% at -1.96
    print(f"n={n:4d}  2.5% {np.percentile(z, 2.5):6.2f}   97.5% {np.percentile(z, 97.5):5.2f}"
          f"   skew {float(((z - z.mean()) ** 3).mean()):5.2f}")

# n=   1  2.5%  -0.97   97.5%  2.68   skew  2.00
# n=   2  2.5%  -1.24   97.5%  2.51   skew  1.36
# n=   5  2.5%  -1.51   97.5%  2.35   skew  0.90
# n=  30  2.5%  -1.78   97.5%  2.12   skew  0.37
# n= 200  2.5%  -1.89   97.5%  2.03   skew  0.14
# normal reference:  -1.96, 1.96, skew 0
Ran as shown. At n = 1 the distribution is the raw exponential, skewed by 2.0 and lopsided. Every column marches towards the normal reference and none of them arrives: even at 200 the lower tail is 4 per cent short and the skew is still 0.14. The theorem is a limit, and how fast you approach it depends on how skewed you started.

That last point is the practical one, and it is why this is worth measuring rather than quoting. The average of many independent values is approximately normally distributed whatever the shape of the original values, with spread shrinking as the square root of the sample size, which is why error bars on averages work. But approximately is doing real work in that sentence. The rule of thumb that thirty is enough comes from mildly skewed data; on revenue per user, or latency, or anything where one observation in a thousand is a hundred times the others, thirty is nowhere near enough and the error bar you computed is too narrow. Simulate your own data before trusting a normal interval on it.

Where people go wrong

Trusting intuition on conditional probabilities. Simulate or count; intuition is reliably wrong on base rates.

Build this: Simulate the Monty Hall problem, the birthday problem for group sizes from 5 to 60, and the medical test at three prevalence levels. Plot each and check against the exact answers.

Open this section on its own page
Section 5 of 6, 4h

The distributions you will actually meet

By the end: You can recognise which distribution fits a quantity, sample and fit it with SciPy, and spot heavy tails that break averages.

  • Yes or no: Bernoulli and binomial
  • Counts per period: Poisson
  • Waiting times: exponential
  • Measurements: normal
  • Latencies and incomes: long tails

A distribution describes how a quantity tends to come out when it varies: request latencies, support tickets per hour, measurement error. Knowing which one fits a quantity tells you what counts as normal, what is genuinely unusual, and which summary numbers will mislead you. This section is a field guide to the few you actually meet, with the code to sample and fit each, and a warning about the heavy-tailed ones, where the average is not a useful number at all.

Two numbers summarise any of them#

Whatever the shape, two numbers describe it. The mean is the centre. The variance is the spread: take each value's distance from the mean, square it so that being below counts the same as being above, and average those. Squaring leaves the units wrong — a variance of latencies is in milliseconds squared — so the number usually quoted is its square root, the standard deviation, which is back in the original units and can be compared with the mean.

Both are expectations, which is the word for a probability-weighted average: the mean is the expectation of the value, and the variance is the expectation of the squared distance from the mean. When you have a distribution written down, that weighting is by each outcome's probability. When you have a pile of data instead, every observation is equally likely, so the weighting collapses into the ordinary average and the two definitions become the same arithmetic.

import numpy as np

latencies = np.array([90, 95, 100, 105, 110])
mean = latencies.mean()
variance = ((latencies - mean) ** 2).mean()
print(mean, variance, np.sqrt(variance))
# 100.0 50.0 7.0710678118654755

# Same mean, far more spread. The mean alone tells you nothing about this.
spiky = np.array([10, 20, 100, 180, 190])
print(spiky.mean(), spiky.std())
# 100.0 76.15773105863909
Ran as shown. Both sets average 100. The deviations in the first are -10, -5, 0, 5, 10, whose squares average 50, so the standard deviation is 7.07. In the second they are -90, -80, 0, 80, 90, whose squares average 5,800, so the standard deviation is 76.2 — a spread eleven times larger behind an identical headline number.

Where this turns up again

Variance is the word behind several things you will meet later: the bias-variance tradeoff is about how much a model's predictions move when the training data changes, principal components are ranked by how much variance each explains, and a regression tree splits wherever it reduces variance most. They are all this same quantity.

A field guide#

Quantity                                   Distribution to try first
Did the user convert? (yes/no)             Bernoulli; conversions out of n: binomial
Support tickets per hour                   Poisson
Time until the next request                exponential
Measurement error, heights, averages       normal
Request latency, file sizes, incomes       log-normal or other heavy-tailed
Which of k classes                         categorical

The rest of this section takes that table one row at a time. For each distribution: the signal in the problem statement that should make you reach for it, the two numbers that describe it, and a check in code. Recognition is the skill being trained here — the maths of each is a lookup, but noticing that a quantity is a count per period rather than a proportion is not.

Yes or no: Bernoulli and binomial#

The recognition signal is a single trial with two outcomes. Did the user convert, did the request succeed, did the eval case pass. That is a Bernoulli variable: it takes the value 1 with probability p and 0 otherwise. Its mean is p, because the average of a pile of ones and zeros is the share of ones. Its variance is p times one minus p, which is largest at p equals a half and shrinks to nothing at either end — a system that succeeds 99 per cent of the time varies far less from sample to sample than one that succeeds half the time.

The binomial is the same trial repeated n times, counting successes. Its mean is n times p and its variance is n times p times one minus p, which is just n independent Bernoulli variances added. Any pass rate on an eval set of fixed size is binomial, which is why the margin-of-error formula for a proportion works and where it comes from.

rng = np.random.default_rng(0)

one = rng.binomial(1, 0.3, 200_000)              # Bernoulli(0.3)
print(one.mean(), one.var())                     # p, then p(1 - p) = 0.21
# 0.299145 0.209657268975

k = rng.binomial(20, 0.3, 200_000)               # Binomial(20, 0.3)
print(k.mean(), k.var())                         # np = 6, then np(1 - p) = 4.2
# 6.00427 4.2180517671
Ran as shown. Both formulas recovered to three digits. NumPy has no separate bernoulli: a Bernoulli is a binomial with n equal to 1, which is worth knowing because it is also true of the maths.

Counts per period: Poisson#

The recognition signal is a count of events in a window, where there is no natural maximum. Support tickets per hour, errors per minute, arrivals per second. The binomial needs an n; the Poisson does not, which is exactly what makes it the right shape for counts. It has one parameter, the average rate, and its defining oddity is that its variance equals its mean. If you measure a count whose variance is much larger than its mean, the events are not arriving independently — they are bursty, and a Poisson model will badly understate your worst hours.

The Poisson and the exponential are the same story told twice. If events arrive independently at a steady rate, the count per window is Poisson and the gaps between consecutive events are exponential. You can derive one from the other in code rather than on paper: generate exponential gaps, add them up, and count how many land in the first hour.

lam = 12.0                                             # 12 tickets an hour on average
gaps = rng.exponential(1 / lam, size=(50_000, 40))     # exponential time between arrivals
arrival = np.cumsum(gaps, axis=1)
per_hour = (arrival <= 1.0).sum(axis=1)

print(per_hour.mean(), per_hour.var())     # a Poisson has variance equal to its mean
# 11.9993 11.954779509999998

from scipy import stats
print((per_hour == 20).mean(), stats.poisson(mu=12).pmf(20))
# 0.00976 0.00968203216821916
Ran as shown. Exponential gaps summed into counts reproduce the Poisson exactly, including the tail: a 20-ticket hour turns up in 0.98 per cent of simulated hours against a theoretical 0.97 per cent. The mean and variance both land on 12.

Waiting times: exponential#

The recognition signal is time until the next thing happens, when the thing has no memory of how long you have already waited. Its mean is one over the rate, so at twelve arrivals an hour the average gap is five minutes, and its standard deviation equals its mean, which is why waiting times feel so unpredictable.

Memorylessness is the property worth checking, because it is the one that violates intuition. Having already waited three minutes tells you nothing about how much longer you will wait: the distribution of remaining time is identical to the distribution you faced at the start. This is why a retry after a timeout on an exponential service does not improve your position, and it is exactly the assumption that fails for real queues, where a request that has already been slow is usually stuck on something and will keep being slow.

w = rng.exponential(2.0, 500_000)        # mean wait of 2

print((w > 2).mean())                    # 0.367862   chance of waiting past 2
print((w[w > 3] > 5).mean())             # 0.36882405745062835
# given you have already waited 3, the chance of another 2 is the same number
Ran as shown. 0.3679 against 0.3688: the same probability to three digits, which is what memorylessness means. Both are one over e, because the chance of surviving one mean's worth of waiting is always exp(-1).

Measurements: normal#

The recognition signal is a quantity built from many small independent contributions: heights, measurement error, and above all averages of anything at all, by the central limit theorem. It takes two parameters, the mean and the standard deviation, and those two describe it completely — there is no third number to learn. The figures worth memorising are that 68 per cent of the mass lies within one standard deviation of the mean, 95 per cent within two, and 99.7 per cent within three, which is where the 1.96 in every confidence interval comes from.

z = rng.normal(0, 1, 500_000)
for k_ in (1, 2, 3):
    print(k_, (np.abs(z) < k_).mean())
# 1 0.682944
# 2 0.954696
# 3 0.997334
Ran as shown: 68, 95 and 99.7 per cent. The third line is the one that misleads. Three standard deviations sounds like never, and it is three days in every thousand, which on a metric sampled every minute is several times an hour.

Which of k: categorical, and the softmax#

The recognition signal is a choice among k options with no ordering, and it is the distribution you will meet most often in this field, because it is what a language model emits at every step. A categorical distribution is just k probabilities that sum to one. Models do not produce those directly; they produce k unbounded scores, and the softmax turns scores into a categorical distribution by exponentiating and dividing by the total.

Sampling from it is then one call. The temperature knob divides the scores before the exponential, so a temperature below one exaggerates the gaps and concentrates the mass on the leader, while a temperature above one flattens everything towards uniform. Watching the sampled frequencies match the computed probabilities is the check that the sampler is doing what you think.

logits = np.array([4.0, 3.0, 1.0, 0.5, 0.0])       # raw model scores over 5 tokens

def softmax(z, T=1.0):
    e = np.exp(z / T - (z / T).max())
    return e / e.sum()

for T in (0.5, 1.0, 2.0):
    p = softmax(logits, T)
    draws = rng.choice(len(logits), size=100_000, p=p)
    print(T, np.round(p, 3), np.round(np.bincount(draws, minlength=5) / 100_000, 3))

# 0.5 [0.878 0.119 0.002 0.001 0.   ] [0.878 0.118 0.002 0.001 0.   ]
# 1.0 [0.682 0.251 0.034 0.021 0.012] [0.682 0.251 0.034 0.021 0.013]
# 2.0 [0.468 0.284 0.104 0.081 0.063] [0.467 0.285 0.104 0.082 0.063]
Ran as shown. The left column is the distribution, the right is 100,000 samples drawn from it. At temperature 0.5 the top token takes 88 per cent of the mass; at 2.0 it takes 47 per cent and the two least likely tokens become genuinely reachable. That is the whole of what a temperature setting does.

With SciPy#

SciPy gives every one of these the same four methods, which is worth knowing because it means learning one distribution object teaches you all of them. The pmf or pdf is the height at a value, the cdf is the mass at or below a value, ppf is the inverse of the cdf and is how you get a percentile, and rvs draws samples. Nearly every question you have about a distribution is one of those four.

print(stats.binom(n=1000, p=0.03).interval(0.95))   # (20.0, 41.0) conversions to expect
print(stats.poisson(mu=12).pmf(20))                 # 0.00968203216821916
print(stats.norm(loc=0, scale=1).cdf(1.96))         # 0.9750021048517795
Ran as shown. The first line is the useful one in practice: at a 3 per cent conversion rate over a thousand visitors, anything from 20 to 41 conversions is an ordinary day. A drop from 35 to 24 is not a regression, and someone will call it one.

Heavy tails break averages#

Latency is not normally distributed. Most requests are fast and a few are extremely slow. The mean is dragged up by the slow ones and describes almost nobody's experience. That is why services report the median and high percentiles such as p95 and p99.

lat = rng.lognormal(mean=5, sigma=1, size=100_000)       # a realistic latency shape
print(f"mean {lat.mean():.0f}  median {np.median(lat):.0f}  p99 {np.percentile(lat, 99):.0f}")
# mean 245  median 147  p99 1527

# What a normal model would have predicted for the p99:
print(round(float(lat.mean() + 2.326 * lat.std())))
# 996
Ran as shown. The mean is 245 and the median is 147, so the average request is nowhere near the average latency. Worse, treating the data as normal puts the p99 at 996 when it is really 1,527 — the capacity plan is short by a third, and it is short precisely on the requests that decide whether the service feels broken.

Fitting the right family instead takes one line and gives back the parameters you generated with. stats.lognorm.fit(lat, floc=0) returns a shape of 1.003 and a log scale of 4.999, against the 1.0 and 5.0 used to make the data. The reason to fit rather than to eyeball is that the fitted object then answers questions the sample cannot: the chance of exceeding a timeout you have never yet hit, or the percentile a new SLA would land on.

Where people go wrong

Assuming every quantity follows a normal probability distribution and computing mean plus or minus two standard deviations on latency or revenue. Heavy tails make those ranges wrong.

Build this: Collect or simulate latencies from a real API, fit normal and log-normal distributions, compare their predicted p99 with the actual p99, and compute the fan-out effect for 5, 10 and 50 parallel calls.

Open this section on its own page
Section 6 of 6, 4h

Hypothesis tests and intervals, practically

By the end: You can run and interpret the common tests, say what a p-value does and does not mean, and prefer intervals where they help.

  • What a p-value is
  • Common misreadings
  • t-test and chi-square in SciPy
  • Confidence intervals
  • Power, briefly

You have measured a difference between two things — two models, two prompts, two versions of a page. This section is about the question that follows: could a gap that size have turned up by chance alone? It covers what a p-value does and does not say, the several ways it is routinely misread, how to run the standard tests in SciPy, and why a confidence interval is usually the more honest thing to put in the report.

The definition, carefully#

A p-value is the probability of seeing data at least as extreme as yours if there were really no effect. A small p-value means your data would be surprising in a world with no effect. That is all it means.

  • It is not the probability that there is no effect.
  • It is not the probability your result is a fluke.
  • It says nothing about how big or important the effect is. A tiny effect in a huge sample can have a tiny p-value.

The definition has a consequence that makes the whole thing click, and it is easier to see by simulation than to argue. If there is genuinely no effect, the p-value is a uniformly distributed random number between 0 and 1. Not usually large — uniform. So 5 per cent of the time it lands below 0.05, half the time below 0.5, and there is nothing special about any particular value it takes. Run two identical arms against each other twenty thousand times and look.

import math
import numpy as np
from scipy import stats

rng = np.random.default_rng(0)

def two_proportion_test(x1, n1, x2, n2):
    """Successes and trials for each arm. Returns the two-sided p-value."""
    pooled = (x1 + x2) / (n1 + n2)
    se = math.sqrt(pooled * (1 - pooled) * (1 / n1 + 1 / n2))
    z = (x2 / n2 - x1 / n1) / se
    return 2 * (1 - stats.norm.cdf(abs(z)))

null_p = np.array([
    two_proportion_test(rng.binomial(2_000, 0.03), 2_000,
                        rng.binomial(2_000, 0.03), 2_000)
    for _ in range(20_000)
])
print((null_p < 0.05).mean(), (null_p < 0.10).mean(), (null_p < 0.50).mean())
# 0.049 0.09855 0.49675
Ran as shown. Two arms with identical 3 per cent conversion, so every difference is noise, and the p-values come out uniform: 4.9 per cent below 0.05, 9.9 per cent below 0.10, 49.7 per cent below 0.50. The 5 per cent threshold is not a discovery rate, it is the false alarm rate you chose to accept.

A worked A/B test#

Take a real-shaped question. The control page converts at 3.0 per cent and the variant at 3.4 per cent, a relative lift of 13 per cent, on 10,000 visitors each. Someone wants to ship it on Friday. The two-proportion test gives p equal to 0.108, so by the usual convention it does not clear the bar — and the more useful output is the interval on the difference, which runs from minus 0.09 to plus 0.89 percentage points. The data is consistent with the variant being slightly worse and with it being a 30 per cent relative improvement. Run it to 40,000 per arm and the same rates give p equal to 0.0013 and an interval of 0.16 to 0.64 points.

def ab(x1, n1, x2, n2):
    p1, p2 = x1 / n1, x2 / n2
    se = math.sqrt(p1 * (1 - p1) / n1 + p2 * (1 - p2) / n2)
    lift = p2 - p1
    return p1, p2, two_proportion_test(x1, n1, x2, n2), lift - 1.96 * se, lift + 1.96 * se

for n in (10_000, 40_000):
    p1, p2, pv, lo, hi = ab(int(0.030 * n), n, int(0.034 * n), n)
    print(n, p1, p2, round(pv, 4), round(lo * 100, 3), round(hi * 100, 3))

# 10000 0.03 0.034 0.108  -0.088  0.888
# 40000 0.03 0.034 0.0013  0.156  0.644
Ran as shown. Identical measured rates, four times the data, and the conclusion changes completely. Nothing about the variant differed between those two rows; only how much evidence was collected about it.

Two common tests#

Proportions are the common case in this field, but two others turn up constantly. Use a t-test when you are comparing averages of a continuous quantity, and pass equal_var=False, because the equal-variance version assumes something you rarely have any reason to believe. Use a chi-square test on a contingency table when you are asking whether two categorical variables are related.

group_a = rng.lognormal(mean=3.0, sigma=0.6, size=400)     # session length, minutes
group_b = rng.lognormal(mean=3.1, sigma=0.6, size=400)
t, p = stats.ttest_ind(group_a, group_b, equal_var=False)
print(round(group_a.mean(), 1), round(group_b.mean(), 1), round(float(t), 2), round(float(p), 4))
# 23.9 26.8 -2.39 0.0169

table = [[120, 880],    # plan A: churned, stayed
         [ 90, 910]]    # plan B
chi2, p2, dof, expected = stats.chi2_contingency(table)
print(round(float(chi2), 2), round(float(p2), 4), dof)
print(np.round(expected, 1).tolist())
# 4.47 0.0344 1
# [[105.0, 895.0], [105.0, 895.0]]
Ran as shown. The expected table is the one worth printing: it is what you would have seen if churn were identical across plans, 105 in each cell against the observed 120 and 90. The chi-square statistic is a summary of how far the observed table is from that, and reading the expected table tells you which cell drove it.

Prefer an interval#

A confidence interval gives a range of effect sizes consistent with the data. 'Plan B churn is 3 points lower, 95% interval 0.2 to 5.8' tells you the direction, the size and the uncertainty at once, which a p-value alone does not.

It also makes the two honest failure modes distinguishable, and a p-value does not. An interval from minus 0.1 to plus 0.9 points says you have not learned much and should keep the experiment running. An interval from minus 0.05 to plus 0.06 points says you have learned something definite: whatever the effect is, it is too small to care about. Both of those come back as not significant, and they call for opposite decisions.

What the 95 per cent is attached to

The interval either contains the true effect or it does not; there is no 95 per cent about this particular one. The 95 per cent describes the procedure: intervals built this way cover the truth 95 times in 100 over the long run. Say 'a 95 per cent confidence interval' and describe the range, and do not say 'there is a 95 per cent chance the effect is in here', which is the Bayesian statement and needs a prior to be true.

Power#

Power is the chance your study detects an effect of a given size if it exists. Small studies have low power, so they miss real effects, and the effects they do report tend to be exaggerated. Estimate the sample you need before collecting data.

Power is simulable, which means you never have to trust a formula you half remember. Decide the effect you would want to catch, generate data with that effect in it, run your actual test, and count how often it fires. Here the effect is the 3.0 against 3.4 per cent from above, and the question is how many visitors per arm it takes to see it reliably.

for n in (5_000, 20_000, 80_000):
    wins = sum(
        two_proportion_test(rng.binomial(n, 0.030), n, rng.binomial(n, 0.034), n) < 0.05
        for _ in range(4_000)
    )
    print(n, wins / 4_000)
# 5000 0.2125
# 20000 0.61925
# 80000 0.99425
Ran as shown. At 5,000 per arm the test finds a real effect one time in five, so four experiments in five end in 'no significant difference' about a change that genuinely works. That is what an underpowered test buys: not caution, but a coin flip dressed as a decision.

The closed form agrees and is quicker for planning. For two proportions, the sample per arm is 2.8 squared — that constant being 1.96 for the false positive rate plus 0.84 for 80 per cent power — times the sum of the two variances, divided by the squared difference. For these numbers it says 30,353 per arm, and simulating at exactly that n gives 80.2 per cent power.

p1, p2 = 0.030, 0.034
n_needed = (1.96 + 0.84) ** 2 * (p1 * (1 - p1) + p2 * (1 - p2)) / (p2 - p1) ** 2
print(round(n_needed))       # 30353

wins = sum(
    two_proportion_test(rng.binomial(30_353, p1), 30_353, rng.binomial(30_353, p2), 30_353) < 0.05
    for _ in range(4_000)
)
print(wins / 4_000)          # 0.80225
Ran as shown. The formula and the simulation agree to within a fifth of a point. The term that dominates is the squared difference on the bottom: halving the effect you want to detect multiplies the sample you need by four, which is why detecting small wins is so expensive and why teams quietly stop trying.

Twenty metrics, one winner#

The most common way a team fools itself is not a bad test, it is many tests. Every dashboard has twenty metrics, and each one gets a 5 per cent chance of a false alarm, so the chance that at least one fires when nothing at all has changed is one minus 0.95 to the twentieth, which is 64 per cent. On average one metric will be significant every time you look.

print(round(1 - 0.95 ** 20, 4))          # 0.6415

flags = rng.random((50_000, 20)) < 0.05   # 20 metrics, nothing happening
print(flags.any(axis=1).mean(), flags.sum(axis=1).mean())
# 0.64172 0.99986
Ran as shown. Simulated and algebraic answers agree: 64 per cent of experiments produce at least one significant metric, and the average count of false winners is exactly one. The fixes are to name the one primary metric before the experiment starts, and to treat anything else that lights up as a hypothesis for the next experiment rather than a finding from this one.

Where people go wrong

Treating p below 0.05 as 'real' and above as 'no effect'. Both conclusions depend on sample size, and the effect size is what matters.

Build this: Simulate 1,000 A/B tests with no true difference and count how many give p below 0.05. Then simulate a small true difference at three sample sizes and measure the power of each.

Open this section on its own page

Where you use this

A topic is easier to care about when you can see what it is for. These projects lean on it at a named stage.

An eval harness that fails the build
  • Stage 5, Check the judge before trusting it: What an agreement rate over 50 cases does and does not establish.
Serve a model, then make it cheaper
  • Stage 2, Measure it honestly: Reading a measurement honestly, including how many samples a p99 needs before it means anything.

Now actually do it

Reading a topic is not learning it. These are the hours that count, and each one tells you when you are finished.

1Work out how big your eval set needs to be

6h

For pass rates of 0.7, 0.85 and 0.95, compute the sample size needed to detect a 3 point difference. Then check whether any eval you currently run is large enough.

Done when: You have a number for your own eval set and know whether its results mean anything.

2Simulate the base rate problem

6h

Write a simulation of a 99 percent accurate test for a condition with 1 in 1000 prevalence. Vary prevalence and accuracy and plot the positive predictive value.

Done when: You can explain to someone non-technical why most positives are false, using your own numbers.

3Paired comparison on a real eval

13h

Take two versions of a system and compare them on identical inputs. Look only at the cases where they differ, and decide whether the aggregate difference rests on more than a handful of examples.

Done when: You have changed your mind about at least one improvement you previously believed in.

25 hours of practice

Words used here

10 terms this topic teaches, each with a one-line definition if one of them stopped you.

Perplexity
The exponential of average cross-entropy loss, read as the number of equally likely options a model is effectively choosing between at each token. Lower means less surprised by the text.
Tail latency
The latency at a high percentile: p99 is the figure 99% of requests come in under. It describes the slow requests that shape how a service is perceived.
Probability distribution
A description of how likely each possible outcome is, with the total across all of them equal to one. A model's next-token output is one over the vocabulary.
Expectation
The long-run average of a random quantity, each outcome weighted by its probability.
Variance
The average squared distance of a random quantity from its mean, measuring how spread out it is. Its square root, the standard deviation, is in the original units and easier to read.
KL divergence
How much information is lost when one distribution is used in place of another, in bits or nats. Preference training uses it as a leash, penalising the tuned model for straying from the model it started as.
Entropy
The average surprise in a distribution — how many bits it takes to encode an outcome drawn from it. Zero when one outcome is certain, highest when everything is equally likely.
Bayes' theorem
The rule for updating a belief with evidence: multiply what you believed beforehand by how likely the evidence would be if it were true, then normalise. It is why a very accurate test for a very rare condition still gives mostly false positives.
Maximum likelihood estimation
Choosing the parameters under which the observed data would have been most probable. Minimising cross-entropy loss is exactly this, done by gradient descent.
Normal distribution
The symmetric, bell-shaped distribution that sums of many small independent effects tend towards, described entirely by its mean and variance.

Interview questions

20 questions, grouped by the section that teaches the answer. An answer you have read is not an answer you can give, so use Test yourself and say it out loud first.

Probability, and reading an experiment honestly

Your new prompt scores 86 percent against 82 percent on a 50-case eval. Ship it?

Not on that evidence. The margin of error on 50 samples is around ten points, so the difference is inside the noise. Two identical systems scored on 50 cases each differ by four points or more about 62 per cent of the time. Look at which specific cases changed, and if the direction seems real, enlarge the eval set before claiming an improvement.

Then they push further

Both were scored on the same 50 cases. Does that change your answer?

It makes the comparison much stronger, so analyse it that way: ignore the totals and count only the cases where the two disagree. If they disagree on five and the new prompt wins three, that is a coin flip. With five disagreements even a clean sweep gives a two-sided p of 0.0625, so nothing this eval could have returned would clear 0.05.

How many cases would you need?

Ask for disagreements rather than cases. Six disagreements all falling the same way is the smallest result that reaches significance in a sign test. For an unpaired comparison, the margin of error shrinks with the square root of n, so separating a genuine four-point difference needs on the order of a thousand cases per side, which is why paired comparison is worth the trouble.

What gets marked up. A passable answer says the sample is too small. A strong one gives a number for how often noise alone produces that gap, and moves to the paired analysis without being prompted.

A test for a condition affecting 1 in 1000 people is 99 percent accurate. You test positive. What is the chance you have it?

About 9 per cent. Take 100,000 people: 100 have the condition and 99 of them test positive, while 99,900 do not and 1 per cent of those, 999 people, test positive anyway. That is 1,098 positives of which 99 are real, so 99 divided by 1,098, about 0.09.

Then they push further

Why is the answer so far from 99 percent?

Because the healthy group is a thousand times larger, so even a 1 per cent error rate on it produces ten times more positives than the ill group can supply. The test did not change; the base rate dominates. At a prevalence of 1 per cent the same test gives exactly 50 per cent, and at 10 per cent it gives 92 per cent.

You test positive a second time, with an independent test. Now what?

Work in odds. Prior odds are 1 to 999, and each positive multiplies by the likelihood ratio, which is sensitivity over false positive rate, 0.99 over 0.01, so 99. After one positive the odds are 0.099, about 9 per cent; after two they are 9.81, about 91 per cent.

Where does this show up in your work?

Any classifier for a rare event. A fraud or abuse model with a 1 per cent false positive rate on a 0.1 per cent base rate sends an analyst ten false alarms per real case, so the queue gets ignored. Precision at the operating point is this same calculation, and it is what users actually experience.

What gets marked up. Reasoning in counts of people out loud is the clearest route under pressure and is marked up over reciting the formula. Saying 99 per cent, or failing to notice that 99 per cent accurate hides two separate rates, is the failure.

Why is accuracy misleading on imbalanced data?

Because the base rate dominates. A model that always predicts the majority class scores very well while being useless, and even an accurate classifier for a rare condition produces mostly false positives. Precision, recall and the cost of each error type are the honest measures.

Why report p95 latency rather than the mean?

Latency is long-tailed, so the mean is pulled around by a few slow requests and hides them at the same time. The high percentile is what users actually experience when things go wrong.

Estimation, likelihood and uncertainty

What is maximum likelihood estimation?

Writing the probability of the data you actually observed as a function of the parameters, then choosing the parameters that make it largest. The data is fixed and the parameter is varied, which is the opposite of how the same formula is read as a probability.

Then they push further

Show it on something concrete.

The mean of a normal. The log-likelihood, dropping terms that do not involve the mean, is minus the sum of squared distances from it. Differentiating and setting to zero says the deviations must cancel, which happens only at the arithmetic average. So the sample mean is the MLE, and minimising squared error is maximum likelihood under normal noise.

Is the maximum likelihood estimate of the variance correct?

It is biased low. It measures spread around the sample mean rather than the true mean, and the sample mean sits in the middle of that same data, so the distances are too small by a factor of n minus one over n. On samples of five, a true variance of 4 comes out at about 3.2. Dividing by n minus one corrects it.

Where does the choice of loss come in?

The loss is the noise model. Normal noise gives squared error, a Bernoulli label gives cross-entropy, counts give Poisson loss, and heavier tails give absolute error. Choosing a loss is asserting how the data is noisy.

What gets marked up. Strong answers derive at least one estimator rather than defining the term, and name the squared-error connection without prompting.

Why is cross-entropy the loss for classification?

It is the negative log-likelihood of the correct labels under the model's predicted probabilities. Minimising it is maximum likelihood estimation, and it punishes confident wrong predictions heavily, which is the desired behaviour.

How would you put a confidence interval on an eval score?

Bootstrap: resample the per-example results with replacement many times, recompute the score each time, and take the 2.5th and 97.5th percentiles. It needs no distributional assumptions.

What does it mean for a model to be calibrated?

Its predicted probabilities match observed frequencies: predictions made at 80 percent are right about 80 percent of the time. Most deep networks are overconfident, which matters whenever a probability drives a decision.

Entropy, cross-entropy, KL and perplexity

What is perplexity?

The exponential of the average cross-entropy per token. It reads as an average branching factor: a perplexity of 8 means the model is as uncertain as if it were choosing uniformly among 8 tokens at each step. Lower means less surprised by the text.

Then they push further

One model reports 12 and another reports 9. Is the second better?

Only if they share a tokenizer and were measured on the same text. Perplexity is per token, so a vocabulary that splits text into fewer, longer tokens carries more information per token and scores worse for no reason connected to quality.

How would you compare two models with different tokenizers?

Convert to bits per byte or bits per character: total negative log probability over the text, divided by the number of bytes rather than the number of tokens. Bytes are a property of the text, not a modelling choice.

Perplexity improved and your eval got worse. What happened?

They measure different things. Perplexity is fit to held-out text of a similar kind, and a model can predict such text better while doing your task worse, particularly after training on data that resembles the perplexity set more than it resembles production traffic. The task eval is the one that decides.

What gets marked up. Strong answers give the exponential-of-cross-entropy definition, the branching factor reading, and the tokenizer caveat without being prompted.

What is KL divergence?

The average extra surprise from using distribution q when the truth is p: cross-entropy minus entropy. It is zero when the two match and grows as they diverge, and it is measured in bits or nats rather than in any unit of distance.

Then they push further

Why is it not a distance?

It is asymmetric, so KL(p || q) and KL(q || p) are different numbers, and it does not obey the triangle inequality. The direction is a modelling choice: one order makes an approximation spread out to cover everything, the other makes it concentrate on a single mode.

Where would you meet KL divergence in an LLM pipeline?

As the drift penalty in preference training, keeping the tuned model near the one it started from. As the distillation loss against a teacher's full output distribution. And in monitoring, comparing the distribution of live inputs against the one the model was validated on.

Why is cross-entropy the thing being minimised rather than entropy?

Entropy is a property of the data and cannot be changed by the model. Cross-entropy is what the model's own distribution costs, so it is the only part that training can move, and the gap between the two is the KL divergence the model is actually reducing.

Probability by simulation

A test is 99% sensitive with a 5% false positive rate for a condition with 1% prevalence. What is the chance a positive result is correct?

About 17%. Out of 10,000 people, 100 are sick and 99 test positive; of 9,900 healthy people, 495 test positive. So 99 of 594 positives are real.

Then they push further

How does this apply to machine learning?

Any classifier for a rare event has the same problem: a small false positive rate applied to a huge number of negatives produces more false alarms than true detections. Precision at the operating point is what users experience.

What gets marked up. Working with counts of people out loud, rather than recalling Bayes' formula, is the clearest way to get this right under pressure.

What does the central limit theorem say, and where does it break?

The average of many independent draws is approximately normal whatever the shape of the individual draws, centred on their mean, with a standard deviation equal to theirs divided by the square root of the sample size. It breaks when the draws are not independent, when the underlying distribution has no finite variance, and in practice long before that whenever the data is badly skewed: on exponential data, averages of 200 still have measurable skew and a lower tail 4 per cent short of the normal one.

Then they push further

Is thirty enough?

It depends entirely on the skew, which is why the rule is a rule of thumb and not a theorem. Simulate your own data: standardise the averages, check the 2.5th and 97.5th percentiles against minus and plus 1.96, and raise n until they agree. On revenue per user, where one customer in a thousand is worth a hundred times the rest, thirty is nowhere near enough.

You estimated a probability by simulation and got 0.167. How many digits should you report?

As many as the Monte Carlo margin allows, and no more. The estimate is a proportion over n trials, so the margin is 1.96 times the square root of p times one minus p over n. At 100,000 trials that is about 0.002, so two decimal places. Going to four decimals needs ten million trials, because the error falls with the square root of the work.

The distributions you will actually meet

Why report p95 or p99 latency rather than the mean?

Latency is heavy-tailed. The mean is pulled up by a few very slow requests while describing few real ones, and it hides the tail entirely. High percentiles describe the experience of your unluckiest users, which is where problems show.

Then they push further

A request fans out to 10 backend calls in parallel and waits for all. How does the tail affect it?

It waits for the slowest of 10. If each call is slow 1% of the time, the request is slow about 10% of the time, so a backend's p99 becomes the request's p90. Fan-out magnifies tails.

What gets marked up. Marked up for the fan-out argument, which shows real systems experience.

I hand you a quantity. How do you decide which distribution to model it with?

By what generated it rather than by looking at a histogram. One trial with two outcomes is Bernoulli, and a count of successes out of a fixed n is binomial. A count of events in a window with no natural maximum is Poisson. The time until the next event is exponential. A measurement built from many small independent contributions, or any average, is normal. A quantity that is a product of effects rather than a sum — latency, file size, income — is log-normal or heavier.

Then they push further

You model support tickets per hour as Poisson and the variance is four times the mean. What does that tell you?

That the arrivals are not independent. A Poisson has variance equal to its mean by construction, so anything above that is overdispersion: tickets arrive in bursts, usually because one incident generates many. A Poisson model will badly understate the worst hours, which are the ones staffing has to cover, and a negative binomial is the usual replacement.

Why is the exponential the odd one out?

It is memoryless: having waited three minutes leaves the distribution of the remaining wait exactly as it was at the start. That is a strong claim and it fails for most real queues, where a request that is already slow is stuck on something and will stay slow, so retry and timeout policies built on it tend to be wrong.

What gets marked up. Strong answers reason from the generating process. Naming distributions from the shape of a histogram is the weaker answer and gets probed.

Hypothesis tests and intervals, practically

What does a p-value of 0.03 mean?

If there were no real effect, data at least this extreme would occur about 3% of the time. It does not mean there is a 97% chance the effect is real, and it says nothing about the effect's size.

Then they push further

You test 20 metrics and one has p = 0.03. Should you trust it?

Not much. With 20 tests at the 5% level you expect about one false positive by chance. Correct for multiple comparisons or treat it as a hypothesis to confirm with new data.

What gets marked up. Precise wording matters here. Candidates who say 'the probability the null is true' are marked down.

How many users do you need for an A/B test?

It depends on four things and nothing else: the baseline rate, the smallest lift worth shipping, the false positive rate you accept and the power you want. For a 3.0 per cent baseline and a 0.4 point lift at 5 per cent and 80 per cent, it is about 30,000 per arm. The term that dominates is the squared difference on the denominator, so halving the lift you want to detect multiplies the sample by four.

Then they push further

You only have 5,000 per arm. What do you tell the team?

That the test has about 20 per cent power for that effect, so four times in five it will come back not significant even if the change genuinely works, and the times it does fire the measured effect will be exaggerated. Either pick a bigger effect to chase, move to a more sensitive metric, or accept that this experiment cannot answer the question.

How would you check any of this without trusting a formula?

Simulate. Generate data with the effect you care about, run the actual test you plan to run, and count how often it fires. That also catches the cases the formula does not cover, such as a skewed revenue metric or a test applied to correlated observations.

Your dashboard has twenty metrics and one moved with p = 0.03. What do you do?

Treat it as nothing. With twenty independent metrics at a 5 per cent threshold, the chance at least one fires when nothing has changed is one minus 0.95 to the twentieth, about 64 per cent, and the average number of false winners is exactly one. Name the primary metric before the experiment and treat everything else as a hypothesis for the next one.

These questions on their own page, one link per question

Resources

Free to watch and read, picked for this topic.