Incident: A squared probability went before a jury
Module 1 · Incident file · About 30 minutes · Needs: Lesson 3 (Bayes' theorem, independence, the Binomial count)
This file is about a real criminal case in which two babies died. It is used here only for the statistical reasoning that the Royal Statistical Society and the Court of Appeal criticised in public. Everything below about the case comes from those two documents, quoted where it matters.
What happened
On 9 November 1999 Sally Clark was convicted at Chester Crown Court of murdering her two baby sons, and her first appeal was dismissed on 2 October 2000. Those dates come from the Court of Appeal's judgment of 11 April 2003 ([R v Clark [2003] EWCA Crim 1020](https://caselaw.nationalarchives.gov.uk/ewca/crim/2003/1020)). Part of the evidence at trial was a number.
A medical expert witness for the prosecution was asked about a government-funded study of sudden infant death syndrome (SIDS, or cot death). He agreed that 1 in 8,543 was the chance of one such death in a family like the Clarks'. Then, in the judgment's words, he said: "you have to multiply 1 in 8,543 times 1 in 8,543 ... it's approximately a chance of 1 in 73 million". With about 700,000 live births a year, he added, "that happening will occur about once every hundred years".
On 23 January 2002 Professor Peter Green, President of the Royal Statistical Society, wrote to the Lord Chancellor (RSS letter, 23 January 2002). "The calculation leading to 1 in 73 million is invalid." The figure, the letter says, "had an immediate and dramatic impact on all media reports of the trial".
On 28 and 29 January 2003 the Court of Appeal heard a second appeal, referred by the Criminal Cases Review Commission, and concluded that "the convictions were unsafe and must be set aside". The main ground was medical evidence that had never been disclosed. On the statistics, the court wrote that if the point had been fully argued it "would, in all probability" have been "a quite distinct basis upon which the appeal had to be allowed".
Why it happened
Multiplying two probabilities is only correct when the events are independent: when one happening tells you nothing about the other. The letter: the figure "would only be valid if SIDS cases arose independently within families". There were "very strong reasons for supposing that the assumption is false": "unknown genetic or environmental factors" that a family shares would make a second case "much more likely". The court agreed: "There is evidence to suggest that it may happen much more frequently than suggested by that figure."
The error also has a direction. When a shared cause links events, multiplying always makes them look rarer than they are. The letter says the error was "almost certainly in one particular direction", and that direction was against the defendant.
The second mistake was reading the number backwards. Some press reports treated 1 in 73 million as the chance that the deaths were accidental. The letter calls this "a serious error of logic known as the Prosecutor's Fallacy". The chance of the evidence given one explanation is not the chance of that explanation given the evidence. "Two deaths by murder may well be even more unlikely. What matters is the relative likelihood of the deaths under each explanation." That is Bayes' theorem from Lesson 3: compare the explanations, then weigh them by how common each is.
The letter raises a third point: the family's characteristics "were chosen on the basis of the same data as was used to evaluate the frequency for that classification", a "double use of data" statisticians know is "perilous". You will meet it again in Module 6 as a test set used to choose a model. The court added a fourth: one number for both deaths "encouraged consideration of the two counts together as a package".
The same failure at Kestrel
Morag's spare-van rule (illustrative): when three parcels on one route miss their day, the route gets a spare van the next time. To budget vans she asks how often three late parcels share a route-day. The quick answer multiplies, as the expert did: the late rate, cubed. Paste guide 06 blocks 1 to 6 to make the parcels.
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: dates, events, the fleet and the nine depots.
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: routes and the weather on every depot-day, which every parcel on a route that day shares.
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: volumes, promises and the calendar.
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: one parcel's features.
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: the outcome, attempts and the late labels.
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: the 147,444 parcels.
from collections import defaultdict
from math import comb
lab = [p for p in PARCELS if p["late"] is not None and p["ship_date"] < "2026-09-07"]
rate = sum(p["late"] for p in lab) / len(lab)
route_days = defaultdict(list) # the outcomes of every parcel due on one route on one day
for p in lab:
route_days[(p["route_id"], p["promised_date"])].append(p["late"])
def multiplied(r):
"""The expert's method: one parcel's chance, multiplied by itself r times."""
return rate ** r
print("late rate", round(rate, 4), "over", len(route_days), "route-days")
for r in (2, 3):
print(f"{r} late parcels on one route-day, multiplied: 1 in {1 / multiplied(r):,.0f}")Code walkthrough
labkeeps the labelled parcels shipped before 7 September, the period Lesson 3 used;rateis their late rate.route_daysgroups the outcomes (1 late, 0 on time) by route and promised day: parcels that share a van, a driver and the weather.multiplied(r)is the shortcut: the late rate to the powerr, which assumes parcels are independent.
late rate 0.0846 over 10578 route-days
2 late parcels on one route-day, multiplied: 1 in 140
3 late parcels on one route-day, multiplied: 1 in 1,650The fix
Do not multiply what you can count. Kestrel has the data: take every group of two (or three) parcels that shared a route-day, and count how often all of them were late.
def counted(r):
"""Count it: of all groups of r parcels that shared a route-day, the share in which all r were late."""
groups = sum(comb(len(v), r) for v in route_days.values())
all_late = sum(comb(sum(v), r) for v in route_days.values())
return all_late / groups
for r in (2, 3):
ratio = counted(r) / multiplied(r)
print(f"{r} late: counted 1 in {1 / counted(r):,.0f}; it happens {ratio:.1f} times as often as multiplying says")Code walkthrough
comb(n, r)is the number of ways to chooserparcels fromn. Summed over route-days,groupsis every set ofrparcels that shared a route-day.comb(sum(v), r)counts the sets made only of late parcels; the share is the measured chance that allrwere late.ratiosays how far the shortcut was off. Above 1 means multiplying made the event look rarer than it is.
2 late: counted 1 in 90; it happens 1.5 times as often as multiplying says
3 late: counted 1 in 447; it happens 3.7 times as often as multiplying saysMultiplying made three late parcels on one route-day look 3.7 times rarer than it is, and the error grows with every extra parcel, as the letter warned. Rain, wind, the driver and the ferries are shared causes, like a family's unknown factors. Morag's van budget built on the shortcut would be about a quarter of what three-late route-days need.
Now the fallacy. A depot manager sees a parcel with two delivery attempts (attempts is known only after delivery: Module 3 removes it from training, but after the fact it is evidence). Only 4% of on-time parcels need two attempts, so "it is 96% sure to be late". Bayes says otherwise.
late = [p for p in lab if p["late"] == 1]
on_time = [p for p in lab if p["late"] == 0]
if_on_time = sum(p["attempts"] >= 2 for p in on_time) / len(on_time) # P(2+ attempts | on time)
if_late = sum(p["attempts"] >= 2 for p in late) / len(late) # P(2+ attempts | late)
print(f"P(2+ attempts | on time) = {if_on_time:.3f}, so the fallacy says {1 - if_on_time:.0%} sure it is late")
likelihood_ratio = if_late / if_on_time # how much more likely the evidence is if the parcel was late
prior_odds = rate / (1 - rate) # how common late parcels are to begin with
posterior = prior_odds * likelihood_ratio / (1 + prior_odds * likelihood_ratio)
two_plus = [p["late"] for p in lab if p["attempts"] >= 2]
print(f"likelihood ratio {likelihood_ratio:.1f} | Bayes: {posterior:.3f} | counted: {sum(two_plus) / len(two_plus):.3f}")Code walkthrough
if_on_timeis the chance of the evidence under one explanation (on time). One minus it is the fallacy: reading a probability of the evidence as a probability of the explanation.if_lateis the chance of the same evidence under the other explanation. Their ratio, the likelihood ratio, is the letter's "relative likelihood of the deaths under each explanation".prior_oddsweighs in how rare late parcels are; prior odds times the likelihood ratio are the posterior odds, turned back into a probability.- The last line checks Bayes against a direct count over the same parcels.
P(2+ attempts | on time) = 0.040, so the fallacy says 96% sure it is late
likelihood ratio 16.3 | Bayes: 0.601 | counted: 0.601The fallacy said 96%; the truth is 60%. Late parcels are rare, so even strong evidence leaves a lot of doubt.
How you would have caught it
Every shortcut that multiplies probabilities carries an assumption, and an assumption you can measure belongs in a test. This one compares the shortcut with the count and fails loudly when they disagree. Kestrel's Module 2 test suite is where it would live.
def check_independent(r, tolerance=1.25):
"""Fail when multiplying is more than `tolerance` times off the counted rate."""
ratio = counted(r) / multiplied(r)
assert ratio < tolerance, f"{r} late on a route-day is {ratio:.1f}x as common as multiplying says: not independent"
return ratio
for r in (2, 3):
try:
check_independent(r)
print(r, "parcels: independence holds")
except AssertionError as err:
print("check failed:", err)Code walkthrough
check_independentcomputes the same ratio as the fix and raises anAssertionErrorwith the reason when it passes the tolerance (1.25, a judgement call: a quarter out is too far to plan vans on).- The loop catches the error only so the page can print both results; in a test file the failure would stop the build.
check failed: 2 late on a route-day is 1.5x as common as multiplying says: not independent
check failed: 3 late on a route-day is 3.7x as common as multiplying says: not independentThe review question that goes with it: "Which events are we assuming independent, and have we counted them together?" Ask it of any number made by multiplying.
Key points
- Multiplying probabilities assumes independence. Shared causes (a family's factors, a route's weather) break it, and the error always makes the joint event look rarer.
- The chance of the evidence under one explanation is not the chance of the explanation. Compare explanations with a likelihood ratio, then weigh by how common each is.
- When you can count the joint event, count it. Kestrel's three-late route-days were 3.7 times as common as the cube of the late rate.
- Do not choose a group and measure its rate on the same data; Module 6 makes this a rule for test sets.
- Put an assumption you can measure into a test, so the number fails before anyone acts on it.
Sources
- Professor Peter Green, President, Royal Statistical Society, letter to the Lord Chancellor regarding the use of statistical evidence in court cases, 23 January 2002 (archived copy printed 15 February 2017). https://www.rss.org.uk/RSS/media/File-library/Membership/Sections/2020/Sally-Clark-RSS-letter-2002.pdf
- Court of Appeal (Criminal Division), R v Clark [2003] EWCA Crim 1020, judgment handed down 11 April 2003, paragraphs 1, 5, 96 to 99 and 172 to 181. The National Archives, Find Case Law. https://caselaw.nationalarchives.gov.uk/ewca/crim/2003/1020
- Kestrel data: course-guide/06, blocks 1 to 6 (simulated).