CourseAI / ML Engineer: The Complete Course · Module 1: Mathematical Foundations · part 10 of 93
Part 10 · Module 1: Mathematical Foundations

Lesson 3: How sure are you? Probability for model builders

29 min read·
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.

WHERE THIS FITS The Kestrel system this lesson SHARED SERVICES Depot scans parcels, routes Ingestion nightly batch Validation Pandera checks Data lake Parquet, DuckDB Experiments MLflow runs Training LightGBM, Optuna Online store Feast, Redis Features point in time Gates vs the champion Registry staged versions Serving API FastAPI, ONNX Consumers vans, customers Retraining trigger, budget Alerting on call Monitoring drift, Evidently Late labels a day later Orchestrator Prefect flows Versions DVC, Git CI/CD GitHub Actions Containers Docker images
Statistics runs through training (the loss), experiments (tests and intervals) and monitoring (drift): this lesson's parts are blue, the rest grey.

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".

CASE STUDY A significant result is not 95% sure FALSE POSITIVE RISK AT 80% POWER, BY SHARE OF IDEAS THAT WORK Microsoft, 33% 5.9% Bing, 15% 15.0% Booking.com, Google Ads, Netflix, 10% 22.0% Airbnb Search, 8% 26.4% the 337% lift: power about 3%, so even at 33% success the false positive risk is 63%
Kohavi, Deng and Vermeer's false positive risk: the rarer real wins are, the more significant results are false (red), from 5.9% at Microsoft's 33% success rate to 26.4% at Airbnb Search's 8%; the underpowered 337% test reaches 63%.

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.
From outcomes to decisions Estimate, test, then say how sure Active flow 1 Processing step number Outcomes 110,586 parcels late or not Doncaster 2,281 parcels kestrel/maths/stats.py SciPy estimate: likelihood, prior test: z, permutation interval: Wilson, bootstrap Decisions warn KP0104204? expected pounds is Doncaster worse? and by how much 1 2 3 4
The lesson's path: labelled outcomes (yellow, data) go through estimates, tests and intervals in kestrel/maths/stats.py (blue) to the two decisions Morag asked about (green); circles give the order.

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.

BY THE NUMBERS When the Binomial is wrong Bernoulli late 1 or 0, p = 0.0846 Binomial late count of 11 parcels Binomial says 5.97% route-days show 8.6% 3 OR MORE LATE OF 11 not independent shared weather
A parcel is late or not (Bernoulli, p = 0.0846); 11 independent parcels would have 3 or more late 5.97% of the time (amber, the model), but real route-days do it 8.6% of the time (red): shared weather breaks independence.

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.

HOW IT WORKS Two shapes for two columns 0 1 address_quality: Beta(8, 1.5) 0 kg 12 kg weight_kg: log-normal(0.6, 0.9) the generator's parameters; section 4 recovers them from the data
Two continuous shapes (amber, densities drawn from their formulas): address quality bunches near 1 like a Beta(8, 1.5); weight is positive with a long right tail, like a log-normal(0.6, 0.9).

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.

TRADEOFF KP0104204: warn or stay silent? stay silent £1.334 3.80 x 0.351 warn at 06:00 £0.646 1.10 x 0.351 + 0.40 x 0.649 expected cost per parcel, Morag's illustrative pounds, p = 0.351 from lesson 2
With p = 0.351 and Morag's illustrative costs, staying silent costs £1.334 on average (red) and warning £0.646 (green): an expectation is a probability-weighted average, and here it says warn.

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.)

BY THE NUMBERS Spread, and moving together variance of late 0.0775 p(1 - p), and measured covariance -0.00412 address_quality, late correlation -0.132 scaled to -1 to 1 better addresses go with fewer late parcels
Lateness has variance p(1 - p) = 0.0775 (amber); address quality and lateness move against each other, covariance -0.00412 and correlation -0.132 (blue).

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.

HOW IT WORKS Turning a probability around all parcels late rate 0.0846 rain: 38.5% late given rain 0.1028 dry: 61.5% late given dry 0.0732 P(rain | late) 0.1028 x 0.385 / 0.0846 = 0.4685, counted 0.4685
Rain on 38.5% of promised days lifts the late rate to 0.1028 (amber); Bayes' theorem turns it around, P(rain | late) = 0.4685, the same as counting late parcels directly (green).

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.

BY THE NUMBERS 1,000 ideas, 10% of them real each square is 10 ideas 80 real wins found (80% power) 20 real wins missed 22.5 duds pass 0.025 (about 2 squares) 877.5 duds correctly rejected 22.5 / (80 + 22.5) = 22% of significant results are false
Of 1,000 ideas where 10% work, 80% power finds 80 (green) and the threshold lets 22.5 duds through (red): 22% of significant results are false, Kohavi's figure for a 10% success rate.

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.

HOW IT WORKS The most likely late rate peak at 0.0846 = the late rate 531 lower 392 lower 0.05 0.07 0.09 0.11 candidate late rate p LOG-LIKELIHOOD (HIGHER IS BETTER)
The log-likelihood (amber) of every candidate late rate peaks at 0.0846, exactly the sample mean (green): for a 0-or-1 outcome, maximum likelihood is the average.

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.

BY THE NUMBERS Maximum likelihood finds the truth PARAMETER GENERATOR MLE GAP address_quality, Beta a 8 8.03 0.03 address_quality, Beta b 1.5 1.51 0.01 weight_kg, log-normal mu 0.6 0.598 0.002 weight_kg, log-normal sigma 0.9 0.897 0.003 only simulated data lets you check against the truth
Fitted by maximum likelihood, the Beta and log-normal parameters land within 0.03 of the values the generator used (green): simulated data is the one place you can check an estimate against the truth.

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.

BY THE NUMBERS More parcels, less wobble 10 parcels 0.0871 theory sqrt(p(1-p)/n) = 0.0880 100 parcels 0.0279 theory sqrt(p(1-p)/n) = 0.0278 1,000 parcels 0.0088 theory sqrt(p(1-p)/n) = 0.0088 SPREAD OF 10,000 SAMPLE LATE RATES n = 10: 41.4% of samples have no late parcel
The late rate of a random sample wobbles less as it grows: 0.0871 with 10 parcels, 0.0279 with 100, 0.0088 with 1,000 (amber), each matching the standard error formula; with 10 parcels 41.4% of samples see no late parcel (red).

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.

HOW IT WORKS Averages turn into a bell 10 parcels 0 0.3 skew 0.92 100 parcels 0 0.3 skew 0.24 1,000 parcels 0 0.3 skew 0.07
The sample late rate's distribution (amber, Binomial probabilities) is lumpy and skewed for 10 parcels and nearly a bell for 1,000: the central limit theorem; the measured skew falls from 0.92 to 0.07.

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?

BY THE NUMBERS Is Doncaster worse? Doncaster 0.1605 366 of 2,281 late the rest 0.0830 8,992 of 108,305 z = 13.15 p = 1.7e-39 permutation p = 0.0005 (the floor) if the rates were the same, a gap this big would almost never happen
Doncaster's late rate, 0.1605 (red), against 0.0830 for the rest (blue): the z test gives p = 1.7e-39 and none of 2,000 label shuffles comes close, so the gap is real (green).

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.

BREAK IT Not significant is not the same first week only p = 0.92 9 of 105 late, 0.0857 95% interval 0.046 to 0.155 includes twice the fleet rate three weeks later: 0.160 the gap was there all along
Doncaster's first week alone gives p = 0.92 (amber), but its interval, 0.046 to 0.155, could not rule out double the fleet rate, and the rate soon reached 0.160 (red): no evidence of a difference is not evidence of none.

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.

COMPARE How sure: three intervals 0 0.05 0.1 0.15 0.2 first week, Wilson 0.046 to 0.155 four weeks, Wilson 0.146 to 0.176 four weeks, bootstrap 0.146 to 0.175 fleet 0.083
Doncaster's 95% intervals: 105 parcels give 0.046 to 0.155 (amber), too wide to act on; 2,281 give 0.146 to 0.176, and the bootstrap agrees (blue); both sit well above the fleet's 0.083 (grey).

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.

HOW IT WORKS What 95% means true rate 0.0846 94.45% of 2,000 intervals sketch: 40 intervals
A 95% interval is a promise about the method: of 2,000 Wilson intervals from samples of 500 parcels, 94.45% contained the true fleet rate (green) and the rest missed (red); the 40 lines are a sketch.

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.

TRADEOFF Bias plus variance, measured route's own rate 0.00387 fleet rate 0.00320 shrunk 0.00177 MEAN SQUARED ERROR OF A ROUTE'S LATE RATE FROM 20 PARCELS bias squared variance
Each estimate's error is bias squared (red) plus variance (amber): a route's own rate wobbles, the fleet rate aims off, and shrinking between them halves the error to 0.00177 (green).

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.

HOW IT WORKS Rare outcomes carry little news late: 0.418 bits coin toss: 1 bit 0 0.5 1 probability of the outcome
Entropy (blue) is the average surprise of a yes-or-no outcome: 1 bit for a coin toss, 0.418 bits for Kestrel's lateness at 0.0846 (amber), because on time is rarely news.

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.

BY THE NUMBERS What the model saves, in bits constant 0.0846 0.4183 bits logistic model 0.3734 bits CROSS-ENTROPY PER PARCEL saves 0.0448 bits per parcel; 0.3734 bits = 0.2588 nats, the log loss parcels before 7 September
Predicting the constant late rate costs 0.4183 bits per parcel, the entropy (grey); the logistic model costs 0.3734 (amber), so its features save 0.0448 bits; in nats, 0.2588 is the log loss (green).

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.

COMPARE KL divergence is not a distance COMPARED ONE WAY OTHER WAY Doncaster against the rest 0.0457 bits 0.0378 bits depot mix: 6 Jul week against 31 Aug week 0.141 bits infinite infinite because Doncaster had no parcels in July a monitor must handle categories that did not exist before
KL divergence differs by direction: 0.0457 against 0.0378 bits for Doncaster and the rest, and for the depot mix 0.141 bits one way but infinite the other, because Doncaster had no July parcels (red).

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.

HOW IT WORKS A belief that learns prior, 50 parcels mean 0.083 to 16 Aug: 9/105 mean 0.085 to 23 Aug: 76/538 mean 0.136 to 30 Aug: 199/1,299 mean 0.151 to 6 Sep: 366/2,281 mean 0.159 0 0.05 0.1 0.15 0.2
Starting from the fleet rate worth 50 parcels (grey), Doncaster's posterior (amber, 95% credible intervals) moves from 0.085 after its first 105 parcels to 0.159 after 2,281 as the data outweighs the prior.

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.

COMPARE SciPy runs here; MATLAB is named RUNS HERE NAMED ONLY Tool SciPy scipy.stats MATLAB fishertest Product SciPy 1.18 Statistics and ML Toolbox Fisher's test fisher_exact fishertest Docs read SciPy v1.18.0 manual R2026b documentation In the course run, every number not run
The course runs SciPy's statistics functions, including fisher_exact; MATLAB's Statistics and Machine Learning Toolbox has fishertest (grey, named only); both pages read 24 October 2026.

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.

python
%pip install -q scipy==1.18.1
!mkdir -p kestrel/maths

Then guide 06 blocks 1 to 6, 8 and 9, unchanged.

python
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.

python
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.

python
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.

python
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.

python
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 p

Code walkthrough: guide 06 block 5, unchanged.

python
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.

python
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
        pass

Code walkthrough: guide 06 block 8, unchanged.

python
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.

python
%%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.

python
%%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

python
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).

text
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.132
python
from 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.

text
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.0126
python
from 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.

text
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

python
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.

text
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.000
python
from 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.

text
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]
python
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.

text
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.9445

Bias, information and belief

python
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.

text
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.00177
python
from 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.

text
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)
python
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.

text
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.174

Break it: an interval that cannot see zero

BREAK IT An interval that cannot see zero Wald, 0 of 30 0 to 0 certainty from nothing Wilson, 0 of 30 0 to 0.114 honest about 30 parcels Wald 91.9% Wilson 96.2% of "95%" intervals that contain p 7.9% of 30-parcel samples have no late parcel at all
On 30 parcels the Wald interval reports 0 to 0 whenever no parcel is late, which happens 7.9% of the time, and covers the truth only 91.9% of the time (red); Wilson gives 0 to 0.114 and covers 96.2% (green).

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.

python
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:

text
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

  1. A colleague says "p = 0.03, so there is a 97% chance Doncaster is worse". What is wrong?
  2. Why did 3 or more late parcels on a route-day happen more often than the Binomial said?
  3. Why can shrinkage beat a route's own late rate, though it is biased?
  1. 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.
  2. Parcels on one route on one day share weather and a driver, so they are not independent.
  3. 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.

python
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.

python
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.

text
all checks passed: 282 parcels per group | simulated power 0.803

Key terms

TermMeaning
BernoulliA 0 or 1 outcome with probability p
ExpectationA probability-weighted average
Bayes' theoremP(A given B) from P(B given A), P(A) and P(B)
Maximum likelihoodThe parameters that make the data most probable
Standard errorHow much an estimate wobbles between samples
p-valueHow surprising the data would be if nothing were going on
Confidence intervalA range built to contain the truth 95% of the time
Bias and varianceAiming off, and wobbling; MSE is bias² plus variance
Cross-entropyAverage surprise of outcomes under your predictions; log loss
KL divergenceExtra surprise from the wrong distribution; not symmetric
PosteriorA 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.stats and scipy.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