Shared Kestrel Data
Every module uses the same Kestrel data, so you never learn a new example. This page is the single source for all of it: the code that makes the data, and what that code prints.
All of this data is simulated. Kestrel, its depots, routes, parcels, weather and customer messages are fictional and made up for the course. Phone numbers in the messages use the 07700 900xxx range that Ofcom reserves for drama, and email addresses use the reserved .example domain. Two things are real and say so: the 2026 bank holiday dates, read from gov.uk/bank-holidays.json on 24 October 2026, and the depots' town names, which are real places used only as labels.
How lessons get the data
The data is generated by code, not downloaded. The generator uses plain Python and one fixed seed, so it makes exactly the same data on every computer. The blocks below were run in order with Python 3.12.15 on 24 October 2026, and every output on this page comes from that run. Blocks 1 to 8 also gave the same fingerprints on Python 3.9.6, which shows the generator does not depend on the Python version; the course pilot confirms the routes, days and parcels fingerprints in Colab. Blocks 1 to 8 use only the standard library; blocks 9 and 10 use pandas, and the authors' check after block 7 uses scikit-learn. The whole page runs in about five seconds on a laptop CPU.
Each block is under 40 lines, so a lesson pastes only what it needs:
| A lesson that needs | Pastes blocks | Gets |
|---|---|---|
| Depots, dates, events, fleet facts | 1 | DEPOTS, FLEET, EVENTS, BANK_HOLIDAYS, DATA_AS_OF, SEED |
| Routes and weather | 1, 2 | ROUTES, DAYS |
| The parcels (almost every lesson) | 1 to 6 | PARCELS, a list of 147,444 dictionaries |
| The customer messages (Modules 4, 5, 12, 13, 14) | 1 to 7 | MESSAGES |
| Fingerprints | the blocks it pasted, then 8 | a check that you have the same data |
| A DataFrame | 1 to 6, then 9 | df, with dates as dates |
| The numbers lessons quote | 1 to 6, 9, then 10 | a printed summary of every planted trap |
Lessons paste blocks unchanged and never edit them. The Module 1 lab saves blocks 1 to 8 as kestrel/data.py, and later labs import from there.
For authors: copy these blocks exactly. Do not invent other depots, staff or dates. If a lesson needs more data, add it in the lesson, say "added in this lesson", and keep what is here unchanged. Quote only numbers printed here or measured in the lesson. The fixed facts behind this data are in 03-kestrel.md.
The planted traps
Real data has problems nobody announces. This data has five on purpose, plus the late labels, and the lessons find them:
| Trap | Where it is planted | Found in |
|---|---|---|
A leaking column: attempts is only known after delivery, and it gives the answer away | block 5 | Module 3 lesson 3 |
A unit change on a known date: from 7 September, weight_kg carries grams | block 4 | Module 5 lesson 3 (the contract), Module 11 (drift) |
| A new depot that drifts: Doncaster opens on 10 August and gets later as its volume grows | blocks 3 and 5 | Module 6 lesson 2, Module 11 |
| An imbalanced late rate: under one parcel in ten is late | block 5 | Module 3 lesson 3 |
| A segment where the model is worse: island routes depend on ferries, and the ferry timetable is not a column | blocks 2 and 5 | Module 6 lesson 2, Module 11, Module 14 |
| Late labels: an outcome is known the day after delivery, later for late parcels | block 5 | Module 11 lesson 1 |
1. Dates, events, the fleet and the depots
The fixed facts everything else uses: the twelve-week window, the snapshot date, the dated events, the two bank holidays in the window, Kestrel's fleet numbers and its nine depots.
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
COURSE_DATEis the day versions and prices were checked.WINDOWis the twelve weeks of parcels shipped.DATA_AS_OFis the day the snapshot was taken: an outcome not known by then is missing. Code counts days toDATA_AS_OF, never todate.today(), so a lesson gives the same answer next year.SEEDfixes every random choice on this page.EVENTSholds the dated events the lessons rely on: Doncaster opening, the scanner upgrade that turns kilograms into grams, and three days of gales in Scotland.BANK_HOLIDAYSholds the summer bank holiday for each nation that has a depot, the only two inside the window.FLEETis the whole of Kestrel; the data is a 1% sample (sample_rate).scores_per_parcelis why Kestrel makes about 420,000 predictions a day.- Each
DEPOTSrow gives a depot's id, name, nation (which decides its bank holiday), number of routes, share of the sample's parcels, and a small effect on lateness in log-odds (block 5 explains log-odds). Doncaster's share is 0 because it has not opened when the window starts. NATIONis a lookup from depot id to nation, used by later blocks.
Output:
9 depots; 200 routes; window 2026-07-06 to 2026-09-272. Routes and daily conditions
Each depot's routes, and the weather on every day at every depot. One thing in this block is never a column in the parcel data: whether the island ferries ran. It still changes which parcels are late, which is exactly why the model will do worse on island routes.
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
make_routesbuilds every route fromrandom.Random(seed + 1), its own random stream, so changing one block never shifts another block's numbers.- Three Inverness routes (
INV-R08toINV-R10) areislandroutes; island routes, every fifth route, and half of Inverness's and Doncaster's routes arerural. Distances are drawn by type: island routes are the longest. make_daysdraws the weather for every depot on every day of the window and ten days beyond it, because parcels shipped late in the window are delivered after it. Scottish depots get more rain and wind.ferry_outis true on about 12% of days and every day of the gales. It is only kept for Inverness, and it never becomes a parcel column: it is a cause the model cannot see.- The
printcounts the routes, the island routes, the depot-days and the ferry disruptions.
Output:
200 routes; 3 island routes; 846 depot-days; 14 ferry disruption days at INV3. Volumes, promises and the calendar
How many parcels each depot gets each day, how Doncaster ramps up, how a promise date is counted, and when a bank holiday gets in the way.
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
DOWscales the day's collections from Monday to Sunday; Kestrel collects nothing on Sundays.SERVICESpairs each service with the delivery days it promises;SERVICE_MIXis how often each is chosen.shares_ongives each depot's share of a day's parcels. From 10 August Doncaster's share rises from 0 to 8% over 21 days, taken from Leeds (5 points) and Leicester (3 points).depot_effectreturns a depot's effect on lateness on a given day. Doncaster's grows by up to 0.9 over its first 42 days: the depot gets later as it gets busier, which is the drift Module 11 detects.add_dayscounts delivery days forward, skipping Sundays, so a next-day parcel collected on a Saturday is promised for Monday.crosses_holidayis true when the depot's bank holiday falls between collection and the promised day.- The
printchecks three of these: Doncaster's share once fully open, its lateness effect on 21 September, and a Saturday next-day promise.
Output:
Doncaster share on 1 September: 0.08 | its lateness effect on 21 September: 1.1 | next-day promise for a Saturday collection: 2026-09-284. One parcel's features
Everything Kestrel knows about a parcel at the moment it is collected. This is where the unit trap is planted.
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
parcel_rowmakes one parcel from the random streamrng, its numbern, the ship date and a route. It picks a service, works out the promised date, and looks up the weather at the depot on that date.true_kgis the parcel's real weight, drawn from a skewed (log-normal) distribution and capped at 30 kg.weight_kgis what the scanners report. From 7 September (EVENTS["scanner_v4"]) they report grams, so the same parcel shows a number a thousand times larger under the same column name. This is the unit trap.- The other columns are the parcel's features: customer type, the hour it was collected, distance (the route's typical distance, varied), stops on the route that day, the quality of its address (a score from 0 to 1), the weather, whether a bank holiday is in the way, whether the driver is new (55% at Doncaster, 8% elsewhere), and the route's
ruralandislandflags. _true_kgand_ferry_outstart with an underscore: they are needed to decide the outcome in block 5, then removed.- This block defines a function and prints nothing.
5. The outcome: late or not, and when Kestrel finds out
Whether each parcel is late, what is recorded afterwards, and when the label arrives. The leaking column and the late labels are planted here.
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
late_logitadds up the reasons a parcel might be late as log-odds: a number that becomes a probability through the logistic function,1 / (1 + e^(-z)). The base, -4.24, makes lateness rare. The depot (with Doncaster's drift from block 3), a new driver and next-day service each add a fixed amount.- Real lateness is rarely a straight line, so most causes here have a shape. Some are thresholds, where nothing happens until a cut-off and then a lot does: a next-day parcel collected at 18:00 or later misses the evening trunk run, an address scoring under 0.72 is one a driver struggles to find, a parcel over 15 kg needs two people, a rural leg over 45 km is long, and stops count only above 125.
- Some are interactions, where one cause depends on another: rain hurts rural routes about three times as much as town routes, wind above 35 km/h hurts island routes 2.5 times as much, a Monday backlog hits only Dartford, Manchester, Leicester and Leeds, and a bank holiday hurts long routes more than short ones (counted up to 60 km).
- Rain is also saturating: the first few millimetres do most of the harm, then the effect levels off, which is what
1 - e^(-rain/3)does. - A model that gives each column one straight-line slope, such as logistic regression, cannot see these shapes unless someone builds them as features. Tree models find cut-offs and combinations by themselves, which is why they win on this data (Module 3 lesson 3 measures it).
- The weight term uses
_true_kg, the real kilograms, whatever the column says. Lateness does not change on 7 September; only the column does. - The last term adds 2.2 for island parcels on days the ferries did not run. No column records the ferries, so no model can learn this exactly: the island segment is where the model will be worse.
add_outcomedraws whether the parcel is late from that probability. A late parcel is delivered one or two delivery days after its promise; the label is ready the next day, when scans are reconciled overnight.attemptsis the number of delivery attempts. A late parcel usually took more than one, so this column almost gives the answer away, and it is only known afterwards. It is the leaking column.- If the label is not ready by
DATA_AS_OF,attempts,delivered_dateandlateareNone: those are the late labels. The two underscore helpers are deleted, so they never reach a lesson. - This block defines functions and prints nothing.
6. The parcels
Every sampled parcel shipped in the window, in ship-date order.
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
make_parcelswalks through every day of the window. For each day it works out how many parcels the 1% sample holds (210,000 times 1% times the weekday factor), splits them across depots byshares_on, picks a route at random within the depot, and makes each parcel with blocks 4 and 5.- Parcel ids run
KP0000001upwards in ship-date order, so a later parcel always has a larger id. labelledkeeps the parcels whose outcome is known. Theprintgives the total, how many are labelled, how many are still waiting, and the late rate among the labelled ones.
Output:
147444 parcels; 144450 labelled; 2994 waiting for a label; late rate 0.08667. Customer messages
Simulated messages customers sent about their parcels, for the text model (Modules 4 and 12), labelling quality (Module 5), the NLP track (Module 13) and personal data (Modules 5 and 14). They are built to be hard, like real ones: many phrasings made from parts, typos and abbreviations, some messages that mix two intents, some that say too little to tell, and two annotators who do not always agree and are sometimes simply wrong. Block 7 is two cells: the parts, then the generator.
SAY = { # intent: ways customers put it ({p} parcel id, {d} a day, {t} a town)
"where_is_my_parcel": ["where is {p}", "any update on {p}", "tracking for {p} hasn't moved since {d}", "is {p} still coming {d}",
"no tracking info for {p}", "has {p} left the {t} depot yet", "when will {p} arrive",
"the app just says in transit for {p}", "can you tell me where {p} is", "what time is {p} coming {d}"],
"delivery_late": ["{p} was due {d} and still nothing", "{p} is late again", "third day waiting for {p}",
"{p} missed its delivery day", "promised {d}, still not here", "why is {p} always late",
"{p} should have come {d}", "still waiting for {p}, it's overdue", "you said {d} for {p}. it is now late"],
"change_delivery": ["can {p} be left with a neighbour", "please redeliver {p} to my work in {t}", "can you hold {p} at {t}",
"change the address for {p}", "can I collect {p} from the depot", "leave {p} in the porch please",
"can {p} come {d} instead", "I'm away, please don't deliver {p} {d}", "send {p} to my mum's in {t}"],
"damaged": ["{p} arrived crushed", "the item in {p} is broken", "{p} came open and something is missing",
"{p} looks like it was dropped", "{p} was soaked through", "the contents of {p} are smashed",
"box for {p} was ripped and taped up", "got {p} {d} but the screen is cracked"],
"compliment": ["the driver for {p} was lovely", "{p} came early, great service", "thanks for getting {p} to {t} so fast",
"really helpful driver {d}", "brilliant service as always", "the driver carried {p} upstairs, thank you"],
}
VAGUE = ["what is going on with {p}", "{p}??", "still nothing {d}", "can someone call me about {p}", "not impressed with {p}",
"about {p}", "this is the second time with {p}", "re {p}"] # no clear intent: annotators have to guess
OPEN = ["", "", "", "Hi,", "Hello", "hiya", "Morning.", "Dear Kestrel,", "hey", "Good afternoon,"]
EXTRA = ["I took the day off for this.", "It's a birthday present.", "The app says out for delivery.", "nobody knocked",
"This is the second time.", "The driver left a card.", "I've emailed twice already.", "I'm out {d}.", "it's medicine"]
SIGN = ["", "", "", "Thanks", "thx", "Cheers", "Not happy.", "pls sort this", "Regards", "ta"]
SHORT = {"please": "pls", "you": "u", "tomorrow": "tmrw", "thanks": "thx", "parcel": "parcle", "delivery": "delivry",
"today": "2day", "because": "cos", "for": "4", "are": "r"} # what phone keyboards and thumbs do
NEAR = {"where_is_my_parcel": "delivery_late", "delivery_late": "where_is_my_parcel", "change_delivery": "where_is_my_parcel",
"damaged": "delivery_late", "compliment": "change_delivery"} # the intent a tired annotator slips to
TOWNS = ["Leicester", "Walsall", "Salford", "Wakefield", "Bath", "Gravesend", "Paisley", "Nairn", "Rotherham"]
DAYS_SAID = ["today", "tomorrow", "on Saturday", "on Monday", "this morning", "yesterday", "on the 14th", "last week"]Code walkthrough
SAYholds five intents, each with six to ten ways customers put it.{p}becomes a parcel id (or "my parcel"),{d}a day and{t}a town.VAGUEholds messages with no clear intent, such as "re {p}". Nobody can label these with confidence, so the annotators guess.OPEN,EXTRAandSIGNare greetings, extra details and sign-offs. They appear with every intent, so a model cannot lean on them; some ("nobody knocked", "Not happy.") sound like one intent but are not.SHORTis the abbreviations people type on a phone, such as "pls" and "4".NEARnames, for each intent, the one an annotator most easily slips to, such as "where is my parcel" for "delivery late".- This cell defines lists and prints nothing.
def scruff(rng, text):
"""Abbreviations, swapped letters and missing capitals, as people type on a phone."""
out = []
for w in text.split():
if w.lower() in SHORT and rng.random() < 0.25:
w = SHORT[w.lower()]
elif len(w) > 5 and rng.random() < 0.04:
k = rng.randrange(1, len(w) - 2)
w = w[:k] + w[k + 1] + w[k] + w[k + 2:]
out.append(w)
return " ".join(out).lower() if rng.random() < 0.3 else " ".join(out)
def make_messages(seed=SEED, n=1200):
"""Simulated customer messages. Two annotators label each; they disagree on some, as people do."""
rng, names, out = random.Random(seed + 4), list(SAY), []
late_ids, all_ids = [p["parcel_id"] for p in PARCELS if p["late"] == 1], [p["parcel_id"] for p in PARCELS]
for i in range(n):
intent = rng.choices(names, weights=[30, 25, 20, 15, 10])[0]
kind = rng.choices(["clear", "mixed", "vague"], weights=[74, 15, 11])[0]
other = NEAR[intent] if rng.random() < 0.6 else rng.choice([x for x in names if x != intent])
pid = rng.choice(late_ids if intent == "delivery_late" else all_ids)
core = [rng.choice(VAGUE)] if kind == "vague" else [rng.choice(SAY[intent])] + [rng.choice(SAY[other])] * (kind == "mixed")
rng.shuffle(core)
parts = [rng.choice(OPEN), *(c + rng.choice([".", "", "!", "?"]) for c in core)]
parts += rng.sample(EXTRA, rng.choice([0, 0, 1, 1, 2, 3])) + [rng.choice(SIGN)]
said = pid if rng.random() < 0.75 else rng.choice(["my parcel", "my order", "it"])
text = scruff(rng, " ".join(x for x in parts if x).format(p=said, d=rng.choice(DAYS_SAID), t=rng.choice(TOWNS)))
if rng.random() < 0.06: # personal data a later lesson must redact (reserved ranges only)
text += f" Call me on 07700 900{rng.randint(100, 999)} or email c{i}@customer.example"
a, b = (rng.choice([intent, other]) if kind != "clear" else NEAR[intent] if rng.random() < slip else intent
for slip in (0.05, 0.12)) # annotator a slips on 5% of clear messages, b on 12%
out.append({"message_id": f"M{i + 1:04d}", "parcel_id": pid, "text": text, "label_a": a, "label_b": b})
return out
MESSAGES = make_messages()
print(len(MESSAGES), "messages;", sum(m["label_a"] != m["label_b"] for m in MESSAGES), "annotator disagreements;",
sum("07700" in m["text"] for m in MESSAGES), "contain a phone number")Code walkthrough
scruffroughens a message: a word inSHORTis abbreviated a quarter of the time, a long word has two letters swapped 4% of the time, and three messages in ten are typed all in lower case.make_messagesuses its own stream,random.Random(seed + 4). For each message it chooses an intent (where-is-my-parcel is the most common) and akind: 74% are clear, 15% mix two intents, and 11% are vague. The second intent is theNEARone six times in ten.parcel_idis a real parcel (a late one fordelivery_late); the text names it three times in four and otherwise says "my parcel", "my order" or "it".- A message is a greeting, one or two intent phrases in random order, up to three extra details and a sign-off, so lengths run from one word to several sentences.
- About 6% of messages get a phone number in the Ofcom drama range and a
.exampleemail: personal data a later lesson must redact. - The labels: on a clear message, annotator a slips to the
NEARintent 5% of the time and annotator b 12% of the time. On a mixed or vague message each annotator picks one of the two intents at random. No label is guaranteed true, as with real annotators; Module 5 lesson 3 measures their agreement. - The
printcounts the messages, the disagreements and the messages with a phone number.
Output:
1200 messages; 301 annotator disagreements; 72 contain a phone numberHow hard the messages are (for authors)
Lessons do not paste this cell. It checks the claim that the messages are hard: the annotators' agreement beyond chance (Cohen's kappa, Module 5 lesson 3), and a simple text model on the same split Module 4 lesson 3 uses. It needs scikit-learn.
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import cohen_kappa_score, f1_score
a, b = [m["label_a"] for m in MESSAGES], [m["label_b"] for m in MESSAGES]
print("kappa between the annotators", round(cohen_kappa_score(a, b), 3))
order = list(range(len(MESSAGES)))
random.Random(0).shuffle(order) # the same 80/20 split Module 4 lesson 3 uses
train, test = order[:960], order[960:]
tfidf = TfidfVectorizer(ngram_range=(1, 2), sublinear_tf=True)
X_train = tfidf.fit_transform([MESSAGES[i]["text"] for i in train])
clf = LogisticRegression(max_iter=2000).fit(X_train, [a[i] for i in train])
guess = clf.predict(tfidf.transform([MESSAGES[i]["text"] for i in test]))
print("TF-IDF and logistic regression, macro F1 on 240 held-out messages against label_a",
round(f1_score([a[i] for i in test], guess, average="macro"), 3))Code walkthrough
cohen_kappa_scoregives the annotators' agreement after removing what chance alone would give: 1 is perfect, 0 is chance.- A seeded shuffle splits the messages 960 for training and 240 held out.
TfidfVectorizerturns each message into word and word-pair weights, andLogisticRegressionlearns the intent from them. f1_score(..., average="macro")averages the F1 of the five intents, so the rarecomplimentcounts as much as the commonwhere_is_my_parcel.
Output:
kappa between the annotators 0.672
TF-IDF and logistic regression, macro F1 on 240 held-out messages against label_a 0.8558. Fingerprints: check you have the same data
A fingerprint is a short code computed from the data. If one character anywhere were different, the fingerprint would be completely different. Print it after pasting the blocks and compare it with the one below.
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
fingerprintturns the data into one long text withjson.dumps(keys sorted, so the order is fixed; dates written as text), hashes it with SHA-256 and keeps the first 12 hexadecimal digits.CHECKSnames the data behind each fingerprint.DAYShas tuple keys, which JSON cannot store, so its check turns each key into a date string and a depot id first.- The loop tries each check; a
NameErrormeans a block it needs was not pasted, so that line is skipped. A lesson that pasted blocks 1 and 2 seesroutesanddaysonly. Theallline needs blocks 1 to 7. - If yours differ, a block was changed when it was pasted: paste it again from this page.
Output:
routes 3167417f391b
days 80a1703dc861
parcels 40ba9d4eaca4
messages ba9d7ac7fe4f
all f0423440d6159. As a pandas DataFrame
Most lessons work with a DataFrame. This block makes one, with the four date columns as real dates.
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
pd.DataFrame(PARCELS)turns the list of dictionaries into a table with one row per parcel and one column per key.- The loop converts the four date columns from ISO text to pandas dates. Missing dates (labels not yet known) become
NaT, pandas' "not a time". - The
printgives the shape and the column names in order: the 18 features known at collection come first, then the four that are only known afterwards (attempts,delivered_date,late,label_ready_date).
Output:
147444 rows, 22 columns: parcel_id, ship_date, promised_date, depot_id, route_id, service, customer_type, ship_hour, weight_kg, distance_km, stops_on_route, address_quality, rain_mm, wind_kph, bank_holiday, driver_new, rural, island, attempts, delivered_date, late, label_ready_date10. What the generator made (the numbers lessons quote)
Not needed by any later code. It prints the numbers the lessons quote, so they are measured, not typed.
lab = df[df["late"].notna()]
print("rows", len(df), "| labelled", len(lab), "| waiting", df["late"].isna().sum(),
"| first unlabelled ship date", df.loc[df["late"].isna(), "ship_date"].min().date())
by_depot = lab.groupby("depot_id")["late"].mean().round(3).to_dict()
print("late rate", round(lab["late"].mean(), 4), "| by depot", by_depot)
print("island late rate", round(lab.loc[lab["island"], "late"].mean(), 3), "on", int(lab["island"].sum()),
"parcels | elsewhere", round(lab.loc[~lab["island"], "late"].mean(), 3))
print("late rate by attempts (the leak):", lab.groupby("attempts")["late"].mean().round(3).to_dict())
before = df["ship_date"] < pd.Timestamp(EVENTS["scanner_v4"])
print("median weight_kg before and after", EVENTS["scanner_v4"], ":", df.loc[before, "weight_kg"].median(),
df.loc[~before, "weight_kg"].median())
don = lab[lab["depot_id"] == "DON"].groupby(lab["ship_date"].dt.to_period("W"))["late"].agg(["size", "mean"])
print("Doncaster by week (parcels, late rate):", [(int(s), round(float(m), 3)) for s, m in don.values])
print("bank-holiday parcels", int(df["bank_holiday"].sum()), "| late rate",
round(lab.loc[lab["bank_holiday"], "late"].mean(), 3))Code walkthrough
labkeeps the labelled rows. The first line counts rows, labelled rows and rows waiting for a label, and finds the first ship date with a missing label.- The second line gives the late rate overall and by depot.
- The third compares island routes with every other route.
- The fourth shows the leak: the late rate for parcels that took one, two and three attempts.
- The fifth shows the unit trap: the median
weight_kgbefore and after 7 September. dongroups Doncaster's labelled parcels by week: its volume rises and its late rate climbs (the first week is a partial week, the last has labels missing).- The last line counts parcels with a bank holiday in the way, and their late rate.
Output:
rows 147444 | labelled 144450 | waiting 2994 | first unlabelled ship date 2026-09-22
late rate 0.0866 | by depot {'BHM': 0.06, 'BRS': 0.058, 'DAR': 0.107, 'DON': 0.162, 'GLA': 0.078, 'INV': 0.173, 'LDS': 0.065, 'LEI': 0.091, 'MAN': 0.07}
island late rate 0.265 on 2645 parcels | elsewhere 0.083
late rate by attempts (the leak): {1.0: 0.034, 2.0: 0.561, 3.0: 1.0}
median weight_kg before and after 2026-09-07 : 1.82 1830.0
Doncaster by week (parcels, late rate): [(105, 0.086), (433, 0.155), (761, 0.162), (982, 0.17), (982, 0.172), (982, 0.155), (719, 0.161)]
bank-holiday parcels 5134 | late rate 0.167The numbers everyone quotes
All simulated, all measured by the blocks above on 24 October 2026.
| Fact | Value |
|---|---|
| Window | parcels shipped 6 July to 27 September 2026; snapshot (DATA_AS_OF) 28 September 2026 |
| Depots and routes | 9 depots, 200 routes, 3 of them island routes |
| Parcels | 147,444 rows, 22 columns; 144,450 labelled, 2,994 waiting for a label (from ship date 22 September) |
| Late rate | 0.0866 overall; by depot from 0.058 (Bristol) to 0.173 (Inverness); Doncaster 0.162 |
| Island segment | late rate 0.265 on 2,645 labelled island parcels, 0.083 elsewhere |
The leak (attempts) | late rate 0.034 at one attempt, 0.561 at two, 1.0 at three |
The unit trap (weight_kg) | median 1.82 before 7 September, 1830.0 from 7 September |
| Doncaster by week | 105 parcels at 0.086 in its first (partial) week, then 433 at 0.155, 761 at 0.162, 982 at 0.170, 982 at 0.172, 982 at 0.155, 719 at 0.161 |
| Bank holidays | 5,134 parcels with a bank holiday between collection and promise; late rate 0.167 |
| Weather | 846 depot-days; 14 ferry disruption days at Inverness |
| Messages | 1,200; 301 annotator disagreements; 72 contain a phone number; kappa between the annotators 0.672; TF-IDF and logistic regression macro F1 0.855 on 240 held-out messages |
| Fingerprints | routes 3167417f391b, days 80a1703dc861, parcels 40ba9d4eaca4, messages ba9d7ac7fe4f, all f0423440d615 |
The people
See 03-kestrel.md for the full cast. In short: Adaeze Okafor (head of data and ML), Morag Sinclair (operations), Tomasz Wierzbicki (data engineering), Rhys Llewellyn (platform and on call), Hannah Lindqvist (customer experience and compliance) and Imran Qureshi (finance).