Lesson 3: How sure are you? Probability for model builders
Module 1 · Lesson 3 · About 55 minutes · Needs: Lessons 1 and 2 (vectors, log loss, KP0104204's probability of 0.351), and the Kestrel data (course-guide/06). No model calls, no keys.
The situation
End of week 1. Morag has two questions. "Your model gives KP0104204 a 35% chance of being late. Should we warn the customer?" And: "Doncaster looks bad. Is it really worse, or is it a few unlucky days?" Adaeze adds a third: "Whatever you answer, tell me how sure you are."
The parcel is still KP0104204 (simulated); the depot is Doncaster, open since 10 August 2026. This lesson uses the labelled parcels shipped before 7 September, the period Modules 3 and 4 train on.
A real case, first
In "A/B Testing Intuition Busters" (KDD 2022), Ron Kohavi, Alex Deng and Lukas Vermeer take apart a real test shared by the GuessTheTest newsletter in December 2021: a landing page with 3 conversions from 82 visitors against 12 from 75, a lift of 337% at p = 0.009 (0.013 by Fisher's exact test).
They ask what a significant result is worth. The False Positive Risk, "the probability that the statistically significant result is a false positive", follows from Bayes' rule and the share of ideas that truly work. With 80% power and α/2 = 0.025 (only significant improvements count as wins), a 33% success rate (Microsoft) gives 5.9%, 10% (Booking.com, Google Ads, Netflix) 22.0%, and 8% (Airbnb Search) 26.4%. The 337% test had about 3% power, so even at 33% success its risk is 63%. Their advice: invoke Twyman's law, "any figure that looks interesting or different is usually wrong".
What you will be able to do
- Name a column's distribution, fit it by maximum likelihood, and compute expectations.
- Use Bayes' theorem on rain, and on a significant test result.
- Test whether Doncaster is worse, with an interval, and say what a p-value does not mean.
- Split error into bias and variance, measure surprise in bits, and update a Beta prior.
Theory first, worked by hand on KP0104204 and Doncaster; from "Run it", code proves every number.
1. Distributions and their parameters (Essential)
A distribution says how often each value turns up; its parameters are the few numbers that pin it down. Choosing the right one tells you what "normal" looks like.
Lateness is a Bernoulli variable: 1 with probability p, else 0. Before 7 September p = 0.0846. A route-day of 11 parcels gives a Binomial count: if parcels were independent, 3 or more late would happen 5.97% of the time. It happens 8.6% of the time: parcels on one route on one day share the weather, so they are not independent.
Continuous columns have continuous distributions. address_quality lives between 0 and 1, the home of the Beta distribution; weight_kg is positive and skewed, like a log-normal. Section 4 recovers their parameters.
2. Expectation, variance, covariance (Essential)
An expectation is a probability-weighted average: what something is worth on average. Every pound in Morag's question is one.
KP0104204 has p = 0.351. With Morag's illustrative costs, staying silent costs 3.80 × 0.351 = £1.334 on average; warning costs 1.10 × 0.351 + 0.40 × 0.649 = £0.646. Warn. (Module 3 turns this into a threshold.)
Variance measures spread: for a Bernoulli it is p(1 - p) = 0.0775, and the data agrees. Covariance measures how two columns move together: address_quality and lateness have -0.00412, a correlation of -0.132.
3. Bayes' theorem and conditional probability (Essential)
A conditional probability answers "given this, how likely is that?" Bayes' theorem turns it around.
Rain falls on 38.5% of parcels' promised days. Given rain, the late rate is 0.1028; dry, 0.0732. Turned around: P(rain | late) = P(late | rain) × P(rain) / P(late) = 0.1028 × 0.385 / 0.0846 = 0.4685. Counting late parcels directly gives the same 0.4685.
Kohavi's false positive risk is the same rule. Of 1,000 ideas where 10% work, 80% power finds 80 real wins and the 0.025 threshold lets through 22.5 of the 900 duds: 22.5 / 102.5 = 22%. The formula gives 21.95%; their Microsoft row, 5.9%, comes out at 5.97%. A p-value below 0.05 does not mean 95% sure.
4. Maximum likelihood estimation (Essential)
Data comes from a distribution with unknown parameters. Maximum likelihood estimation (MLE) picks the parameters that make what you saw most probable.
The likelihood of p for Kestrel's outcomes is p^k (1 - p)^(n - k). On a log scale it peaks at exactly the late rate, 0.0846: for a Bernoulli the MLE is the sample mean. At 0.06 or 0.11 the log-likelihood is 531 and 392 lower.
Because the data is simulated, we know the truth. The Beta MLE for address_quality gives 8.03 and 1.51; the generator used 8 and 1.5. The log-normal fit to weight_kg gives 0.598 and 0.897, against 0.6 and 0.9.
5. Sampling and the central limit theorem (Essential)
A sample estimates the late rate, and the estimate wobbles. How much is the most useful number in statistics.
Draw 10,000 random samples and look at their late rates. With 10 parcels the spread is 0.0871 and 41.4% of samples have no late parcel at all. With 100 it is 0.0279; with 1,000, 0.0088. Theory says √(p(1 - p)/n), the standard error: 0.0880, 0.0278, 0.0088.
The central limit theorem says the average of many independent draws is close to normal, whatever the single draw looks like. Lumpy 0-or-1 outcomes average into a bell: the skew falls from 0.92 at n = 10 to 0.07 at n = 1,000.
6. Hypothesis testing and p-values (Essential)
A test asks: if nothing were going on, how surprising would this data be?
Doncaster: 366 of 2,281 late, 0.1605. The rest: 0.0830. The null hypothesis is "same rate". A two-proportion z test gives z = 13.15 and a p-value, the chance of a gap this big if the null were true, of 1.7e-39. A permutation test shuffles the depot labels 2,000 times; no shuffle comes close, so p is at its floor, 0.0005.
Now Doncaster's first week alone: 9 of 105, 0.0857, p = 0.92. Not significant, but 105 parcels could not have shown a real gap. "No evidence of a difference" is not "evidence of no difference".
7. Confidence intervals (Essential)
A p-value says whether; an interval says how much. Morag's "how sure are you" is an interval.
A 95% confidence interval is built so that 95% of intervals made this way contain the true rate. Doncaster's first week: 0.046 to 0.155, too wide to act on. All four weeks: 0.146 to 0.176. A bootstrap interval (resample the parcels, recompute, take the middle 95%) gives 0.146 to 0.175.
Does 95% hold? Of 2,000 samples of 500 parcels, 94.45% of Wilson intervals contained the fleet rate.
8. Bias-variance decomposition (Essential)
Every estimate errs in two ways: aiming at the wrong place (bias) and wobbling from sample to sample (variance). Mean squared error is exactly bias² plus variance.
Estimate each route's late rate from 20 parcels, 300 times. The route's own rate has almost no bias (0.00002) but variance 0.00385. The fleet rate barely wobbles (0.00002) but is biased (0.00318). Shrinkage, (k + 20 × fleet) / (20 + 20), trades a little of each: 0.00079 + 0.00098 = 0.00177, about half the error of either.
Model complexity plays the same game: simple models are biased, flexible ones wobble. Belkin and colleagues (2019) add that very large models can improve again past the point where they fit training data exactly ("double descent").
9. Information theory: entropy, KL divergence, cross-entropy (Essential)
Lesson 2's log loss is a measure of surprise. Information theory explains why it is the right loss.
Entropy is the average surprise of an outcome. A coin toss is 1 bit; Kestrel's lateness, at p = 0.0846, is 0.418 bits, because "on time" is rarely news.
Cross-entropy is the surprise when outcomes arrive and you predicted q. Predicting the constant 0.0846 costs exactly the entropy, 0.4183 bits. The logistic model costs 0.3734: it saves 0.0448 bits per parcel. In nats that is 0.2588, the log loss, on this lesson's wider date range.
KL divergence is the extra surprise from using the wrong distribution, and it is not symmetric: Doncaster against the rest is 0.0457 bits, the other way 0.0378. Compare the depot mix of 6 July's week with 31 August's: 0.141 bits one way, infinite the other, because Doncaster had no parcels in July.
10. Bayesian inference (Recommended)
On 16 August Doncaster had 105 parcels, too few to judge alone. Bayesian inference starts from a belief and updates it with data.
The prior is the fleet rate, 0.083, worth 50 parcels: a Beta(4.15, 45.85). After week one (9 of 105) the posterior mean is 0.085, with a 95% credible interval of 0.046 to 0.133. By 6 September (366 of 2,281) it is 0.159, 0.144 to 0.174: the data has outweighed the prior. Section 8's shrinkage is the same idea.
The course runs NumPy and SciPy; MATLAB's Statistics and Machine Learning Toolbox has the same tests, such as fishertest (R2026b documentation, read 24 October 2026). Named only, not run.
Run it
The run took under ten seconds on the course laptop (24 October 2026). The first cell pins SciPy and makes the folder.
%pip install -q scipy==1.18.1
!mkdir -p kestrel/mathsThen guide 06 blocks 1 to 6, 8 and 9, unchanged.
import math
import random
from datetime import date, timedelta
COURSE_DATE = date(2026, 10, 24) # versions, model names and prices checked on this day
WINDOW = (date(2026, 7, 6), date(2026, 9, 27)) # 12 weeks of parcels shipped, Monday to Sunday
DATA_AS_OF = date(2026, 9, 28) # the snapshot: an outcome not known by this day is missing
SEED = 20260928
EVENTS = { # fixed dates the data and the lessons share
"doncaster_opens": date(2026, 8, 10), # new depot DON takes routes from Leeds and Leicester
"scanner_v4": date(2026, 9, 7), # from this day weight_kg arrives in grams (the unit trap)
"highland_gales": (date(2026, 9, 15), date(2026, 9, 17)), # high winds at GLA and INV
}
BANK_HOLIDAYS = { # the two inside the window, from gov.uk/bank-holidays.json (read 24 October 2026)
"england-and-wales": date(2026, 8, 31), "scotland": date(2026, 8, 3)}
FLEET = { # the whole of Kestrel in September 2026 (fixed facts, simulated)
"depots": 9, "vans": 1_150, "parcels_per_day": 210_000, "sample_rate": 0.01,
"scores_per_parcel": 2, # scored at collection, and again at 06:00 on the promised day
}
DEPOTS = [ # id, name, nation, routes, share of parcels, effect on lateness (log-odds)
("LEI", "Leicester hub", "england-and-wales", 30, 0.16, 0.00),
("BHM", "Birmingham", "england-and-wales", 26, 0.14, 0.05),
("MAN", "Manchester", "england-and-wales", 26, 0.14, 0.10),
("LDS", "Leeds", "england-and-wales", 22, 0.12, 0.00),
("BRS", "Bristol", "england-and-wales", 20, 0.10, -0.05),
("DAR", "Dartford", "england-and-wales", 32, 0.18, 0.20),
("GLA", "Glasgow", "scotland", 18, 0.10, 0.05),
("INV", "Inverness", "scotland", 10, 0.06, 0.25),
("DON", "Doncaster", "england-and-wales", 16, 0.00, 0.20), # opens 10 August; share ramps up
]
NATION = {d[0]: d[2] for d in DEPOTS}
print(len(DEPOTS), "depots;", sum(d[3] for d in DEPOTS), "routes; window", WINDOW[0], "to", WINDOW[1])Code walkthrough: guide 06 block 1, unchanged.
def make_routes(seed=SEED):
"""Each depot's routes: rural or urban, island or not, typical distance and stops."""
rng = random.Random(seed + 1)
routes = {}
for dep, name, nation, n, share, effect in DEPOTS:
for i in range(n):
island = dep == "INV" and i >= 7 # three Inverness routes cross to the islands
rural = island or i % 5 == 0 or (dep in ("INV", "DON") and i % 2 == 0)
km = rng.uniform(80, 160) if island else rng.uniform(25, 60) if rural else rng.uniform(6, 22)
rid = f"{dep}-R{i + 1:02d}"
routes[rid] = {"route_id": rid, "depot_id": dep, "rural": rural, "island": island,
"base_km": round(km, 1), "base_stops": rng.randint(95, 135)}
return routes
def make_days(seed=SEED):
"""Weather per depot per day, and the ferry disruptions that are never a column."""
rng = random.Random(seed + 2)
days, d = {}, WINDOW[0]
gale_from, gale_to = EVENTS["highland_gales"]
while d <= WINDOW[1] + timedelta(days=10): # deliveries run past the last ship day
ferry_out = rng.random() < 0.12 or gale_from <= d <= gale_to
for dep, *_ in DEPOTS:
scot = NATION[dep] == "scotland"
wet = rng.random() < (0.55 if scot else 0.40)
rain = rng.expovariate(1 / (3.5 if scot else 2.0)) if wet else 0.0
wind = rng.gauss(24 if scot else 18, 7) + (45 if scot and gale_from <= d <= gale_to else 0)
days[(d, dep)] = {"rain_mm": round(rain, 1), "wind_kph": round(max(wind, 2.0), 1),
"ferry_out": ferry_out and dep == "INV"}
d += timedelta(days=1)
return days
ROUTES = make_routes()
DAYS = make_days()
print(len(ROUTES), "routes;", sum(r["island"] for r in ROUTES.values()), "island routes;",
len(DAYS), "depot-days;", sum(v["ferry_out"] for v in DAYS.values()), "ferry disruption days at INV")Code walkthrough: guide 06 block 2, unchanged.
DOW = [1.15, 1.05, 1.00, 1.00, 1.10, 0.55, 0.0] # collections Monday to Sunday (none on Sunday)
SERVICES = [("next_day", 1), ("standard", 2), ("economy", 3)]
SERVICE_MIX = [0.45, 0.40, 0.15]
def shares_on(d):
"""Share of the day's parcels per depot. Doncaster ramps up over 21 days from 10 August."""
s = {dep: share for dep, _, _, _, share, _ in DEPOTS}
if d >= EVENTS["doncaster_opens"]:
ramp = min(1.0, (d - EVENTS["doncaster_opens"]).days / 21)
s["DON"], s["LDS"], s["LEI"] = 0.08 * ramp, s["LDS"] - 0.05 * ramp, s["LEI"] - 0.03 * ramp
return s
def depot_effect(dep, d):
"""Doncaster's lateness drifts upwards as its volume grows (the drifting new depot)."""
base = {x[0]: x[5] for x in DEPOTS}[dep]
if dep == "DON":
base += 0.9 * min(1.0, (d - EVENTS["doncaster_opens"]).days / 42)
return base
def add_days(d, n):
"""n delivery days after d; Kestrel delivers Monday to Saturday."""
while n:
d += timedelta(days=1)
n -= d.weekday() != 6
return d
def crosses_holiday(dep, ship, promised):
"""True when the depot's bank holiday falls between collection and the promised day."""
return ship <= BANK_HOLIDAYS[NATION[dep]] <= promised
print("Doncaster share on 1 September:", shares_on(date(2026, 9, 1))["DON"],
"| its lateness effect on 21 September:", round(depot_effect("DON", date(2026, 9, 21)), 2),
"| next-day promise for a Saturday collection:", add_days(date(2026, 9, 26), 1))Code walkthrough: guide 06 block 3, unchanged.
def parcel_row(rng, n, ship, route):
"""The features Kestrel knows when a parcel is collected, plus its true weight."""
dep = route["depot_id"]
service, sdays = rng.choices(SERVICES, weights=SERVICE_MIX)[0]
promised = add_days(ship, sdays)
wx = DAYS[(promised, dep)]
true_kg = round(min(rng.lognormvariate(0.6, 0.9), 30.0), 2)
return {
"parcel_id": f"KP{n:07d}", "ship_date": ship.isoformat(), "promised_date": promised.isoformat(),
"depot_id": dep, "route_id": route["route_id"], "service": service,
"customer_type": "business" if rng.random() < 0.3 else "residential",
"ship_hour": rng.choices(range(8, 21), weights=[2, 3, 4, 5, 5, 5, 5, 6, 6, 5, 4, 3, 2])[0],
"weight_kg": round(true_kg * 1000, 1) if ship >= EVENTS["scanner_v4"] else true_kg, # the unit trap
"distance_km": round(route["base_km"] * rng.uniform(0.6, 1.4), 1),
"stops_on_route": route["base_stops"] + rng.randint(-15, 25),
"address_quality": round(rng.betavariate(8, 1.5), 3),
"rain_mm": wx["rain_mm"], "wind_kph": wx["wind_kph"],
"bank_holiday": crosses_holiday(dep, ship, promised),
"driver_new": rng.random() < (0.55 if dep == "DON" else 0.08),
"rural": route["rural"], "island": route["island"],
"_true_kg": true_kg, "_ferry_out": wx["ferry_out"],
}Code walkthrough: guide 06 block 4, unchanged.
def late_logit(p, ship):
"""Log-odds that a parcel misses its promised day. Weight counts in kilograms, whatever the column says."""
dep, kg, km, nd = p["depot_id"], p["_true_kg"], p["distance_km"], p["service"] == "next_day"
z = -4.24 + depot_effect(dep, ship) + 0.45 * p["driver_new"] + 0.3 * nd
z += 0.2 * (p["ship_hour"] >= 17) + (1.3 if nd and p["ship_hour"] >= 18 else 0.0) # the 18:00 trunk cut-off
z += 1.5 * (p["address_quality"] < 0.72) # an address a driver cannot find
z += 0.9 * (kg > 15) + (0.7 if p["rural"] and km > 45 else 0.0) # two-person parcels, long rural legs
z += (1.6 if p["rural"] else 0.5) * (1 - math.exp(-p["rain_mm"] / 3)) # rain: quick to hurt, then levels off
z += 0.08 * max(0, p["stops_on_route"] - 125) # only an overloaded route slows
z += 0.06 * max(0.0, p["wind_kph"] - 35) * (2.5 if p["island"] else 1.0) # wind matters most at sea
z += 0.8 * (ship.weekday() == 0 and dep in ("DAR", "MAN", "LEI", "LDS")) # Monday backlog at four hubs
z += p["bank_holiday"] * (0.3 + 0.025 * min(km, 60)) # a holiday hurts long routes most
return z + (2.2 if p["_ferry_out"] and p["island"] else 0.0) # ferries: a cause with no column
def add_outcome(rng, p, ship):
"""What is only known afterwards: late or not, attempts (the leak), and when the label arrives."""
late = rng.random() < 1 / (1 + math.exp(-late_logit(p, ship)))
promised = date.fromisoformat(p["promised_date"])
delivered = add_days(promised, (1 + (rng.random() < 0.3)) if late else 0)
label_ready = delivered + timedelta(days=1) # delivery scans are reconciled overnight
known = label_ready <= DATA_AS_OF
p["attempts"] = (1 + (rng.random() < (0.55 if late else 0.04)) + (late and rng.random() < 0.2)) if known else None
p["delivered_date"] = delivered.isoformat() if known else None
p["late"] = int(late) if known else None
p["label_ready_date"] = label_ready.isoformat()
for k in ("_true_kg", "_ferry_out"):
del p[k]
return pCode walkthrough: guide 06 block 5, unchanged.
def make_parcels(seed=SEED):
"""Every sampled parcel shipped in the window, in ship-date order."""
rng = random.Random(seed + 3)
by_depot = {}
for r in ROUTES.values():
by_depot.setdefault(r["depot_id"], []).append(r)
rows, n, d = [], 1, WINDOW[0]
while d <= WINDOW[1]:
per_day = round(FLEET["parcels_per_day"] * FLEET["sample_rate"] * DOW[d.weekday()])
shares = shares_on(d)
for dep, share in shares.items():
for _ in range(round(per_day * share)):
p = parcel_row(rng, n, d, rng.choice(by_depot[dep]))
rows.append(add_outcome(rng, p, d))
n += 1
d += timedelta(days=1)
return rows
PARCELS = make_parcels()
labelled = [p for p in PARCELS if p["late"] is not None]
print(len(PARCELS), "parcels;", len(labelled), "labelled;", len(PARCELS) - len(labelled),
"waiting for a label; late rate", round(sum(p["late"] for p in labelled) / len(labelled), 4))Code walkthrough: guide 06 block 6, unchanged.
import hashlib
import json
def fingerprint(*datasets):
"""The first 12 hex digits of a SHA-256 hash of the data: equal fingerprints mean identical data."""
blob = json.dumps(datasets, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8")
return hashlib.sha256(blob).hexdigest()[:12]
CHECKS = { # label: the data behind it (only the blocks you pasted are checked)
"routes": lambda: [ROUTES],
"days": lambda: [[(d.isoformat(), dep, v) for (d, dep), v in DAYS.items()]],
"parcels": lambda: [PARCELS],
"messages": lambda: [MESSAGES],
"all": lambda: [DEPOTS, FLEET, ROUTES, PARCELS, MESSAGES],
}
for label, data in CHECKS.items():
try:
print(f"{label:9}", fingerprint(*data()))
except NameError: # a block this check needs was not pasted
passCode walkthrough: guide 06 block 8, unchanged.
import pandas as pd
df = pd.DataFrame(PARCELS)
for c in ("ship_date", "promised_date", "delivered_date", "label_ready_date"):
df[c] = pd.to_datetime(df[c])
print(df.shape[0], "rows,", df.shape[1], "columns:", ", ".join(df.columns))Code walkthrough: guide 06 block 9, unchanged.
The file
kestrel/maths/stats.py is written in two cells before the first import.
%%writefile kestrel/maths/stats.py
"""Probability and statistics for Kestrel: rates, tests, intervals and information, in NumPy and SciPy."""
import numpy as np
from scipy import stats
def bernoulli_loglik(p, y):
"""Log-likelihood of a late rate p for 0/1 outcomes y: how well p explains what happened."""
k, n = float(np.sum(y)), len(y)
return k * np.log(p) + (n - k) * np.log(1 - p)
def wald_interval(k, n, z=1.96):
"""The textbook interval: estimate plus or minus z standard errors. Breaks for small n or rare events."""
p = k / n
half = z * np.sqrt(p * (1 - p) / n)
return max(p - half, 0.0), min(p + half, 1.0)
def wilson_interval(k, n, z=1.96):
"""Wilson's interval for a proportion: honest even when k is 0 or n is small."""
p, z2 = k / n, z * z
centre = (p + z2 / (2 * n)) / (1 + z2 / n)
half = z / (1 + z2 / n) * np.sqrt(p * (1 - p) / n + z2 / (4 * n * n))
return max(centre - half, 0.0), min(centre + half, 1.0)
def two_proportion_z(k1, n1, k2, n2):
"""z statistic and two-sided p-value for 'these two late rates are the same'."""
pooled = (k1 + k2) / (n1 + n2)
z = (k1 / n1 - k2 / n2) / np.sqrt(pooled * (1 - pooled) * (1 / n1 + 1 / n2))
return float(z), float(2 * stats.norm.sf(abs(z)))Code walkthrough: bernoulli_loglik adds k log p and (n - k) log(1 - p). wald_interval is estimate ± z standard errors; wilson_interval recentres and widens for small n. two_proportion_z pools both groups for the standard error under the null and reads the two-sided p-value from the normal curve.
%%writefile -a kestrel/maths/stats.py
def permutation_pvalue(y, in_group, n_perm=2000, seed=0):
"""Shuffle the group labels n_perm times: how often is the gap in late rates at least as big as observed?"""
rng = np.random.default_rng(seed)
gap = abs(y[in_group].mean() - y[~in_group].mean())
hits = sum(abs(y[g].mean() - y[~g].mean()) >= gap for g in (rng.permutation(in_group) for _ in range(n_perm)))
return (hits + 1) / (n_perm + 1)
def false_positive_risk(alpha_half, power, prior_null):
"""Kohavi, Deng and Vermeer's Bayes rule: P(no real effect | a significant result)."""
return alpha_half * prior_null / (alpha_half * prior_null + power * (1 - prior_null))
def entropy_bits(p):
"""Uncertainty of a yes/no outcome with probability p, in bits."""
return float(-(p * np.log2(p) + (1 - p) * np.log2(1 - p)))
def cross_entropy_bits(y, q):
"""Average surprise, in bits, when outcomes y arrive and you predicted probabilities q (log loss in base 2)."""
q = np.clip(q, 1e-15, 1 - 1e-15)
return float(-np.mean(y * np.log2(q) + (1 - y) * np.log2(1 - q)))
def beta_posterior(a, b, k, n):
"""Beta(a, b) prior plus k late out of n: posterior mean and 95% credible interval."""
post = stats.beta(a + k, b + n - k)
return float(post.mean()), (float(post.ppf(0.025)), float(post.ppf(0.975)))Code walkthrough: permutation_pvalue counts shuffles with a gap at least as large as the real one; the +1s keep it above 0. false_positive_risk is the paper's Bayes rule. beta_posterior adds late parcels to a and on-time ones to b.
Distributions, expectation, Bayes and likelihood
import numpy as np
from scipy import stats
d = df[df["late"].notna() & (df["ship_date"] < "2026-09-07")] # labelled, before the grams arrive
y = d["late"].to_numpy(dtype=float)
p = y.mean()
print(len(d), "parcels | late rate (Bernoulli p):", round(p, 4), "| variance p(1 - p):", round(p * (1 - p), 4), "| measured:", round(y.var(), 4))
rd = d.groupby(["route_id", "ship_date"])["late"].agg(["size", "sum"])
print("parcels per route-day: mean", round(rd["size"].mean(), 1), "| P(3 or more late of 11), Binomial:", round(stats.binom.sf(2, 11, p), 4),
"| measured on 11-parcel route-days:", round((rd.loc[rd["size"] == 11, "sum"] >= 3).mean(), 4))
q = 0.351 # KP0104204's probability from lesson 2
print("KP0104204, expected cost (illustrative GBP): stay silent", round(3.80 * q, 3), "| warn", round(1.10 * q + 0.40 * (1 - q), 3))
cov = np.cov(d["address_quality"], y)[0, 1]
print("cov(address_quality, late):", round(cov, 5), "| correlation:", round(np.corrcoef(d["address_quality"], y)[0, 1], 3))Code walkthrough: d is the labelled parcels before 7 September. rd counts parcels and late parcels per route-day; stats.binom.sf(2, 11, p) is P(3 or more of 11).
110586 parcels | late rate (Bernoulli p): 0.0846 | variance p(1 - p): 0.0775 | measured: 0.0775
parcels per route-day: mean 10.8 | P(3 or more late of 11), Binomial: 0.0597 | measured on 11-parcel route-days: 0.086
KP0104204, expected cost (illustrative GBP): stay silent 1.334 | warn 0.646
cov(address_quality, late): -0.00412 | correlation: -0.132from kestrel.maths.stats import false_positive_risk
rain = (d["rain_mm"] > 0).to_numpy()
p_rain, p_late_rain = rain.mean(), y[rain].mean()
print("P(rain)", round(p_rain, 3), "| P(late | rain)", round(p_late_rain, 4), "| P(late | dry)", round(y[~rain].mean(), 4))
print("Bayes: P(rain | late) = P(late | rain) P(rain) / P(late) =", round(p_late_rain * p_rain / p, 4), "| counted directly:", round(rain[y == 1].mean(), 4))
for org, success in (("Microsoft", 0.33), ("Bing", 0.15), ("Booking.com, Google Ads, Netflix", 0.10), ("Airbnb Search", 0.08)):
print(f"{org:33} success {success:.0%}: false positive risk {false_positive_risk(0.025, 0.80, 1 - success):.2%}")
print("the 337% lift, power 3%, success 33%:", f"{false_positive_risk(0.025, 0.03, 0.67):.0%}",
"| Fisher's exact test on 3/82 against 12/75: p =", round(stats.fisher_exact([[3, 79], [12, 63]]).pvalue, 4))Code walkthrough: rain marks wet promised days; Bayes' rule is checked against a direct count. The loop repeats the paper's table; stats.fisher_exact its 337% example.
P(rain) 0.385 | P(late | rain) 0.1028 | P(late | dry) 0.0732
Bayes: P(rain | late) = P(late | rain) P(rain) / P(late) = 0.4685 | counted directly: 0.4685
Microsoft success 33%: false positive risk 5.97%
Bing success 15%: false positive risk 15.04%
Booking.com, Google Ads, Netflix success 10%: false positive risk 21.95%
Airbnb Search success 8%: false positive risk 26.44%
the 337% lift, power 3%, success 33%: 63% | Fisher's exact test on 3/82 against 12/75: p = 0.0126from kestrel.maths.stats import bernoulli_loglik
grid = np.linspace(0.05, 0.12, 701)
ll = [bernoulli_loglik(g, y) for g in grid]
print("log-likelihood is highest at p =", round(grid[int(np.argmax(ll))], 4), "| the sample mean:", round(p, 4))
print("log-likelihood at 0.06, 0.0846, 0.11:", [round(float(bernoulli_loglik(g, y)), 1) for g in (0.06, 0.0846, 0.11)])
aq = d["address_quality"].clip(0.0005, 0.9995) # 21 values were rounded to exactly 1.0
a_hat, b_hat, _, _ = stats.beta.fit(aq, floc=0, fscale=1)
lw = np.log(d["weight_kg"])
print("address_quality, Beta MLE: a", round(a_hat, 2), "b", round(b_hat, 2), "(the generator uses 8 and 1.5)")
print("weight_kg, log-normal MLE: mu", round(lw.mean(), 3), "sigma", round(lw.std(), 3), "(the generator uses 0.6 and 0.9, capped at 30 kg)")Code walkthrough: grid tries 701 values of p and keeps the most likely. stats.beta.fit with floc=0, fscale=1 fits only the two shape parameters; the log-normal MLE is the mean and standard deviation of log weight.
log-likelihood is highest at p = 0.0846 | the sample mean: 0.0846
log-likelihood at 0.06, 0.0846, 0.11: [-32591.4, -32060.5, -32452.2]
address_quality, Beta MLE: a 8.03 b 1.51 (the generator uses 8 and 1.5)
weight_kg, log-normal MLE: mu 0.598 sigma 0.897 (the generator uses 0.6 and 0.9, capped at 30 kg)Samples, tests and intervals
rng = np.random.default_rng(0)
for n in (10, 100, 1000):
rates = rng.choice(y, size=(10_000, n)).mean(axis=1) # 10,000 random samples of n parcels each
print(f"n {n:>4}: mean of sample rates {rates.mean():.4f} | spread {rates.std():.4f} | theory sqrt(p(1-p)/n) {np.sqrt(p * (1 - p) / n):.4f}"
f" | skew {stats.skew(rates):.2f} | share with 0 late {np.mean(rates == 0):.3f}")Code walkthrough: rng.choice(y, size=(10_000, n)) draws 10,000 samples of n parcels with replacement; their means are the sample late rates.
n 10: mean of sample rates 0.0839 | spread 0.0871 | theory sqrt(p(1-p)/n) 0.0880 | skew 0.92 | share with 0 late 0.414
n 100: mean of sample rates 0.0847 | spread 0.0279 | theory sqrt(p(1-p)/n) 0.0278 | skew 0.24 | share with 0 late 0.000
n 1000: mean of sample rates 0.0846 | spread 0.0088 | theory sqrt(p(1-p)/n) 0.0088 | skew 0.07 | share with 0 late 0.000from kestrel.maths.stats import permutation_pvalue, two_proportion_z, wilson_interval
don = (d["depot_id"] == "DON").to_numpy()
k1, n1, k2, n2 = int(y[don].sum()), int(don.sum()), int(y[~don].sum()), int((~don).sum())
print(f"Doncaster {k1}/{n1} = {k1 / n1:.4f} | the rest {k2}/{n2} = {k2 / n2:.4f}")
z, p_value = two_proportion_z(k1, n1, k2, n2)
print("two-proportion z test: z", round(z, 2), "| p-value", f"{p_value:.1e}")
print("permutation test, 2,000 shuffles: p-value", round(permutation_pvalue(y, don), 5), "(none of the shuffles reached the real gap)")
first = d[don & (d["ship_date"] < "2026-08-17").to_numpy()]
k, n = int(first["late"].sum()), len(first)
z1, p1 = two_proportion_z(k, n, k2, n2)
print(f"Doncaster's first week only: {k}/{n} = {k / n:.4f} | z {z1:.2f} | p-value {p1:.2f}")
print("95% intervals (Wilson): first week", np.round(wilson_interval(k, n), 3), "| all four weeks", np.round(wilson_interval(k1, n1), 3))Code walkthrough: k1, n1 and k2, n2 are late and total parcels at Doncaster and elsewhere; first is Doncaster's first week.
Doncaster 366/2281 = 0.1605 | the rest 8992/108305 = 0.0830
two-proportion z test: z 13.15 | p-value 1.7e-39
permutation test, 2,000 shuffles: p-value 0.0005 (none of the shuffles reached the real gap)
Doncaster's first week only: 9/105 = 0.0857 | z 0.10 | p-value 0.92
95% intervals (Wilson): first week [0.046 0.155] | all four weeks [0.146 0.176]boot = stats.bootstrap((y[don],), np.mean, n_resamples=2000, method="percentile", random_state=0)
print("Doncaster, bootstrap 95% interval:", np.round([boot.confidence_interval.low, boot.confidence_interval.high], 4),
"| Wilson:", np.round(wilson_interval(k1, n1), 4))
truth, hits = p, 0
rng = np.random.default_rng(1)
for _ in range(2000): # does the interval catch the true rate 95% of the time?
sample = rng.choice(y, size=500)
lo, hi = wilson_interval(sample.sum(), 500)
hits += lo <= truth <= hi
print("Wilson intervals from 2,000 samples of 500 that contain the fleet rate:", hits / 2000)Code walkthrough: stats.bootstrap resamples Doncaster's outcomes 2,000 times. The loop draws 2,000 samples of 500 parcels and counts the Wilson intervals that contain the fleet rate.
Doncaster, bootstrap 95% interval: [0.1456 0.1754] | Wilson: [0.146 0.1761]
Wilson intervals from 2,000 samples of 500 that contain the fleet rate: 0.9445Bias, information and belief
routes = d.groupby("route_id")["late"]
true_rate = routes.mean() # each route's rate over about 550 parcels
rng, m, n = np.random.default_rng(2), 20, 20 # 20 parcels per route; prior weight 20
est = {"route's own rate": [], "fleet rate": [], "shrunk": []}
for _ in range(300):
ks = np.array([rng.choice(v.to_numpy(), size=n).sum() for _, v in routes])
fleet = ks.sum() / (n * len(ks))
est["route's own rate"].append(ks / n)
est["fleet rate"].append(np.full(len(ks), fleet))
est["shrunk"].append((ks + m * fleet) / (n + m)) # pull small samples towards the fleet
for name, e in est.items():
e = np.array(e) # (300 repeats, 200 routes)
bias2, var = ((e.mean(axis=0) - true_rate.to_numpy()) ** 2).mean(), e.var(axis=0).mean()
mse = ((e - true_rate.to_numpy()) ** 2).mean()
print(f"{name:17} bias^2 {bias2:.5f} + variance {var:.5f} = {bias2 + var:.5f} | measured error {mse:.5f}")Code walkthrough: routes groups outcomes by route; true_rate uses all their parcels. Each repeat draws 20 parcels per route and makes three estimates; bias2, var and mse are averaged over routes.
route's own rate bias^2 0.00002 + variance 0.00385 = 0.00387 | measured error 0.00387
fleet rate bias^2 0.00318 + variance 0.00002 = 0.00320 | measured error 0.00320
shrunk bias^2 0.00079 + variance 0.00098 = 0.00177 | measured error 0.00177from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from kestrel.maths.stats import cross_entropy_bits, entropy_bits
NUMERIC = ["ship_hour", "weight_kg", "distance_km", "stops_on_route", "address_quality", "rain_mm", "wind_kph", "driver_new"]
model = make_pipeline(StandardScaler(), LogisticRegression()).fit(d[NUMERIC], y)
q_model = model.predict_proba(d[NUMERIC])[:, 1]
h = entropy_bits(p)
ce_const, ce_model = cross_entropy_bits(y, np.full(len(y), p)), cross_entropy_bits(y, q_model)
print("entropy of late:", round(h, 4), "bits | cross-entropy, constant p:", round(ce_const, 4), "| logistic model:", round(ce_model, 4))
print("the model saves", round(h - ce_model, 4), "bits per parcel | the same log loss in nats:", round(ce_model * np.log(2), 4))
kl = lambda a, b: a * np.log2(a / b) + (1 - a) * np.log2((1 - a) / (1 - b))
print("KL(Doncaster || the rest):", round(kl(k1 / n1, k2 / n2), 4), "bits | KL(rest || Doncaster):", round(kl(k2 / n2, k1 / n1), 4))
week_of = lambda day: d.loc[d["ship_date"].dt.to_period("W").dt.start_time == day, "depot_id"]
july, sept = (week_of(day).value_counts(normalize=True).reindex(sorted(d["depot_id"].unique()), fill_value=0) for day in ("2026-07-06", "2026-08-31"))
print("depot mix in bits: KL(July week || week of 31 Aug)", round(float(stats.entropy(july, sept, base=2)), 4),
"| KL(week of 31 Aug || July week)", stats.entropy(sept, july, base=2), "(Doncaster had no parcels in July)")Code walkthrough: model scales the eight columns and fits logistic regression. kl is the Bernoulli KL divergence in bits. week_of picks one week's depot ids; stats.entropy(a, b) is KL(a || b), infinite when b has a zero where a does not.
entropy of late: 0.4183 bits | cross-entropy, constant p: 0.4183 | logistic model: 0.3734
the model saves 0.0448 bits per parcel | the same log loss in nats: 0.2588
KL(Doncaster || the rest): 0.0457 bits | KL(rest || Doncaster): 0.0378
depot mix in bits: KL(July week || week of 31 Aug) 0.1412 | KL(week of 31 Aug || July week) inf (Doncaster had no parcels in July)from kestrel.maths.stats import beta_posterior
a0, b0 = 50 * k2 / n2, 50 * (1 - k2 / n2) # prior: the fleet rate, worth 50 parcels
print("prior Beta(", round(a0, 2), ",", round(b0, 2), ") mean", round(a0 / (a0 + b0), 4))
weeks = d[don].groupby(d.loc[don, "ship_date"].dt.to_period("W"))["late"].agg(["sum", "size"])
k_seen = n_seen = 0
for week, (kw, nw) in weeks.iterrows():
k_seen, n_seen = k_seen + int(kw), n_seen + int(nw)
mean, (lo, hi) = beta_posterior(a0, b0, k_seen, n_seen)
print(f"to {week.end_time.date()}: {k_seen:>3}/{n_seen:<4} raw {k_seen / n_seen:.3f} | posterior {mean:.3f}, 95% credible {lo:.3f} to {hi:.3f}")Code walkthrough: a0, b0 put the fleet rate in a prior worth 50 parcels. weeks counts Doncaster's late and total parcels by week, and the loop updates the posterior cumulatively.
prior Beta( 4.15 , 45.85 ) mean 0.083
to 2026-08-16: 9/105 raw 0.086 | posterior 0.085, 95% credible 0.046 to 0.133
to 2026-08-23: 76/538 raw 0.141 | posterior 0.136, 95% credible 0.110 to 0.165
to 2026-08-30: 199/1299 raw 0.153 | posterior 0.151, 95% credible 0.132 to 0.170
to 2026-09-06: 366/2281 raw 0.160 | posterior 0.159, 95% credible 0.144 to 0.174Break it: an interval that cannot see zero
The textbook interval, estimate ± 1.96 standard errors (the Wald interval), is fine for Doncaster's 2,281 parcels. Use it on a route with 30.
from kestrel.maths.stats import wald_interval
rng, n, trials = np.random.default_rng(3), 30, 10_000
cover = {"wald": 0, "wilson": 0}
zero_width = 0
for _ in range(trials):
k = int(rng.binomial(n, p)) # 30 parcels from a route with the fleet rate
for name, f in (("wald", wald_interval), ("wilson", wilson_interval)):
lo, hi = f(k, n)
cover[name] += lo <= p <= hi
zero_width += k == 0
print("samples with 0 late of 30:", zero_width / trials, "| Wald interval for 0/30:", tuple(map(float, wald_interval(0, 30))),
"| Wilson:", tuple(round(float(v), 3) for v in wilson_interval(0, 30)))
print("share of 95% intervals that contain the true rate:", {k: round(float(v) / trials, 4) for k, v in cover.items()})Code walkthrough: rng.binomial(30, p) simulates a 30-parcel route; each interval is checked for the true rate, and samples with 0 late are counted.
Run this and you will see:
samples with 0 late of 30: 0.079 | Wald interval for 0/30: (0.0, 0.0) | Wilson: (0.0, 0.114)
share of 95% intervals that contain the true rate: {'wald': 0.9187, 'wilson': 0.9619}In 7.9% of samples a 30-parcel route has no late parcel, and Wald reports 0 to 0: certainty from nothing. Over 10,000 samples only 91.9% of its "95%" intervals contain the true rate. Wilson's interval gives 0 to 0.114 for 0 of 30 and covers 96.2%.
In production
Kohavi and colleagues recommend platforms show the false positive risk next to p-values and replicate surprising results. Kestrel's version: Module 6 compares models with paired tests and intervals, not one score; Module 10 sizes online tests before running them, as the exercise does; Module 11 watches KL-style drift scores and must handle categories that did not exist last month. The Beta prior returns whenever a new depot, route or customer has too little history.
The question Adaeze will ask
"Doncaster's first week was 0.086, no worse than the fleet. Why did you not tell planners it was fine?"
Because p = 0.92 on 105 parcels means the test could not tell, not that the rates are equal: the 95% interval ran from 0.046 to 0.155, which includes double the fleet rate. The posterior, 0.046 to 0.133, said the same. Three weeks later the rate was 0.160.
What you added to Kestrel
kestrel/maths/stats.py: log-likelihood, Wald and Wilson intervals, a two-proportion z test and a permutation test.- The false positive risk, entropy, cross-entropy in bits, and a Beta posterior for a new depot.
Check yourself
- A colleague says "p = 0.03, so there is a 97% chance Doncaster is worse". What is wrong?
- Why did 3 or more late parcels on a route-day happen more often than the Binomial said?
- Why can shrinkage beat a route's own late rate, though it is biased?
- A p-value is the chance of data this extreme if there were no difference, not the chance that there is one; that needs Bayes and a prior, as the false positive risk shows.
- Parcels on one route on one day share weather and a driver, so they are not independent.
- Its small bias costs less than the variance it removes: 0.00177 against 0.00387.
Exercise
How many parcels does a test need? Write sample_size(p0, p1, alpha, power) for a two-sided two-proportion test, using z_(1-α/2) √(2 p̄(1 - p̄)) + z_power √(p0(1 - p0) + p1(1 - p1)), squared, over (p1 - p0)². The check simulates the test at your answer.
def check_sample_size(fn):
"""Checks your sample size by simulation: at that n, the test should catch the gap about 80% of the time."""
n = fn(0.083, 0.160, alpha=0.05, power=0.80)
assert isinstance(n, int) and 50 < n < 1000, f"want a whole number of parcels per group, got {n}"
rng = np.random.default_rng(4)
found = sum(two_proportion_z(rng.binomial(n, 0.160), n, rng.binomial(n, 0.083), n)[1] < 0.05 for _ in range(4000))
assert 0.75 < found / 4000 < 0.86, f"at n = {n} the test finds the gap {found / 4000:.0%} of the time, not about 80%"
print("all checks passed:", n, "parcels per group | simulated power", round(found / 4000, 3))Code walkthrough: The check calls your function for 0.083 against 0.160, then runs the test 4,000 times at that size and asserts it finds the gap 75% to 86% of the time.
def sample_size(p0, p1, alpha=0.05, power=0.80):
"""Parcels per group for a two-sided two-proportion z test to detect p0 against p1."""
za, zb = stats.norm.ppf(1 - alpha / 2), stats.norm.ppf(power)
pbar = (p0 + p1) / 2
top = za * np.sqrt(2 * pbar * (1 - pbar)) + zb * np.sqrt(p0 * (1 - p0) + p1 * (1 - p1))
return int(np.ceil(top**2 / (p1 - p0) ** 2))
check_sample_size(sample_size)Code walkthrough: za and zb are the normal quantiles for the threshold and the power; pbar is the average rate; np.ceil rounds up to whole parcels.
all checks passed: 282 parcels per group | simulated power 0.803Key terms
| Term | Meaning |
|---|---|
| Bernoulli | A 0 or 1 outcome with probability p |
| Expectation | A probability-weighted average |
| Bayes' theorem | P(A given B) from P(B given A), P(A) and P(B) |
| Maximum likelihood | The parameters that make the data most probable |
| Standard error | How much an estimate wobbles between samples |
| p-value | How surprising the data would be if nothing were going on |
| Confidence interval | A range built to contain the truth 95% of the time |
| Bias and variance | Aiming off, and wobbling; MSE is bias² plus variance |
| Cross-entropy | Average surprise of outcomes under your predictions; log loss |
| KL divergence | Extra surprise from the wrong distribution; not symmetric |
| Posterior | A prior belief updated with data |
Sources
- Ron Kohavi, Alex Deng, Lukas Vermeer, "A/B Testing Intuition Busters: Common Misunderstandings in Online Controlled Experiments", KDD 2022 (author's copy, read 24 October 2026). https://airbnb.tech/wp-content/uploads/sites/19/2025/10/ABTestingIntuitionBusters.pdf and https://exp-platform.com/abtestingintuitionbusters/
- Mikhail Belkin, Daniel Hsu, Siyuan Ma, Soumik Mandal, "Reconciling modern machine learning practice and the bias-variance trade-off", PNAS, 2019 (arXiv 1812.11118). https://arxiv.org/abs/1812.11118
- SciPy v1.18.0 manual,
scipy.statsandscipy.stats.fisher_exact, read 24 October 2026. https://docs.scipy.org/doc/scipy/reference/stats.html - MathWorks,
fishertest, Statistics and Machine Learning Toolbox, R2026b documentation, read 24 October 2026. https://www.mathworks.com/help/stats/fishertest.html