CourseAI / ML Engineer: The Complete Course · Course guide · part 5 of 93
Part 5 · Course guide

Shared Kestrel Data

27 min read·

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 needsPastes blocksGets
Depots, dates, events, fleet facts1DEPOTS, FLEET, EVENTS, BANK_HOLIDAYS, DATA_AS_OF, SEED
Routes and weather1, 2ROUTES, DAYS
The parcels (almost every lesson)1 to 6PARCELS, a list of 147,444 dictionaries
The customer messages (Modules 4, 5, 12, 13, 14)1 to 7MESSAGES
Fingerprintsthe blocks it pasted, then 8a check that you have the same data
A DataFrame1 to 6, then 9df, with dates as dates
The numbers lessons quote1 to 6, 9, then 10a 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:

TrapWhere it is plantedFound in
A leaking column: attempts is only known after delivery, and it gives the answer awayblock 5Module 3 lesson 3
A unit change on a known date: from 7 September, weight_kg carries gramsblock 4Module 5 lesson 3 (the contract), Module 11 (drift)
A new depot that drifts: Doncaster opens on 10 August and gets later as its volume growsblocks 3 and 5Module 6 lesson 2, Module 11
An imbalanced late rate: under one parcel in ten is lateblock 5Module 3 lesson 3
A segment where the model is worse: island routes depend on ferries, and the ferry timetable is not a columnblocks 2 and 5Module 6 lesson 2, Module 11, Module 14
Late labels: an outcome is known the day after delivery, later for late parcelsblock 5Module 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.

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

  • COURSE_DATE is the day versions and prices were checked. WINDOW is the twelve weeks of parcels shipped. DATA_AS_OF is the day the snapshot was taken: an outcome not known by then is missing. Code counts days to DATA_AS_OF, never to date.today(), so a lesson gives the same answer next year. SEED fixes every random choice on this page.
  • EVENTS holds 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_HOLIDAYS holds the summer bank holiday for each nation that has a depot, the only two inside the window.
  • FLEET is the whole of Kestrel; the data is a 1% sample (sample_rate). scores_per_parcel is why Kestrel makes about 420,000 predictions a day.
  • Each DEPOTS row 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.
  • NATION is a lookup from depot id to nation, used by later blocks.

Output:

text
9 depots; 200 routes; window 2026-07-06 to 2026-09-27

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

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

  • make_routes builds every route from random.Random(seed + 1), its own random stream, so changing one block never shifts another block's numbers.
  • Three Inverness routes (INV-R08 to INV-R10) are island routes; island routes, every fifth route, and half of Inverness's and Doncaster's routes are rural. Distances are drawn by type: island routes are the longest.
  • make_days draws 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_out is 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 print counts the routes, the island routes, the depot-days and the ferry disruptions.

Output:

text
200 routes; 3 island routes; 846 depot-days; 14 ferry disruption days at INV

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

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

  • DOW scales the day's collections from Monday to Sunday; Kestrel collects nothing on Sundays. SERVICES pairs each service with the delivery days it promises; SERVICE_MIX is how often each is chosen.
  • shares_on gives 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_effect returns 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_days counts delivery days forward, skipping Sundays, so a next-day parcel collected on a Saturday is promised for Monday.
  • crosses_holiday is true when the depot's bank holiday falls between collection and the promised day.
  • The print checks three of these: Doncaster's share once fully open, its lateness effect on 21 September, and a Saturday next-day promise.

Output:

text
Doncaster share on 1 September: 0.08 | its lateness effect on 21 September: 1.1 | next-day promise for a Saturday collection: 2026-09-28

4. One parcel's features

Everything Kestrel knows about a parcel at the moment it is collected. This is where the unit trap is planted.

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

  • parcel_row makes one parcel from the random stream rng, its number n, 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_kg is the parcel's real weight, drawn from a skewed (log-normal) distribution and capped at 30 kg.
  • weight_kg is 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 rural and island flags.
  • _true_kg and _ferry_out start 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.

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

  • late_logit adds 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_outcome draws 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.
  • attempts is 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_date and late are None: 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.

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

  • make_parcels walks 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 by shares_on, picks a route at random within the depot, and makes each parcel with blocks 4 and 5.
  • Parcel ids run KP0000001 upwards in ship-date order, so a later parcel always has a larger id.
  • labelled keeps the parcels whose outcome is known. The print gives the total, how many are labelled, how many are still waiting, and the late rate among the labelled ones.

Output:

text
147444 parcels; 144450 labelled; 2994 waiting for a label; late rate 0.0866

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

python
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

  • SAY holds 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.
  • VAGUE holds messages with no clear intent, such as "re {p}". Nobody can label these with confidence, so the annotators guess.
  • OPEN, EXTRA and SIGN are 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.
  • SHORT is the abbreviations people type on a phone, such as "pls" and "4".
  • NEAR names, 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.
python
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

  • scruff roughens a message: a word in SHORT is 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_messages uses its own stream, random.Random(seed + 4). For each message it chooses an intent (where-is-my-parcel is the most common) and a kind: 74% are clear, 15% mix two intents, and 11% are vague. The second intent is the NEAR one six times in ten.
  • parcel_id is a real parcel (a late one for delivery_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 .example email: personal data a later lesson must redact.
  • The labels: on a clear message, annotator a slips to the NEAR intent 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 print counts the messages, the disagreements and the messages with a phone number.

Output:

text
1200 messages; 301 annotator disagreements; 72 contain a phone number

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

python
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_score gives 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. TfidfVectorizer turns each message into word and word-pair weights, and LogisticRegression learns the intent from them.
  • f1_score(..., average="macro") averages the F1 of the five intents, so the rare compliment counts as much as the common where_is_my_parcel.

Output:

text
kappa between the annotators 0.672
TF-IDF and logistic regression, macro F1 on 240 held-out messages against label_a 0.855

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

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

  • fingerprint turns the data into one long text with json.dumps (keys sorted, so the order is fixed; dates written as text), hashes it with SHA-256 and keeps the first 12 hexadecimal digits.
  • CHECKS names the data behind each fingerprint. DAYS has 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 NameError means a block it needs was not pasted, so that line is skipped. A lesson that pasted blocks 1 and 2 sees routes and days only. The all line needs blocks 1 to 7.
  • If yours differ, a block was changed when it was pasted: paste it again from this page.

Output:

text
routes    3167417f391b
days      80a1703dc861
parcels   40ba9d4eaca4
messages  ba9d7ac7fe4f
all       f0423440d615

9. As a pandas DataFrame

Most lessons work with a DataFrame. This block makes one, with the four date columns as real dates.

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

  • 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 print gives 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:

text
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_date

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

python
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

  • lab keeps 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_kg before and after 7 September.
  • don groups 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:

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

The numbers everyone quotes

All simulated, all measured by the blocks above on 24 October 2026.

FactValue
Windowparcels shipped 6 July to 27 September 2026; snapshot (DATA_AS_OF) 28 September 2026
Depots and routes9 depots, 200 routes, 3 of them island routes
Parcels147,444 rows, 22 columns; 144,450 labelled, 2,994 waiting for a label (from ship date 22 September)
Late rate0.0866 overall; by depot from 0.058 (Bristol) to 0.173 (Inverness); Doncaster 0.162
Island segmentlate 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 week105 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 holidays5,134 parcels with a bank holiday between collection and promise; late rate 0.167
Weather846 depot-days; 14 ferry disruption days at Inverness
Messages1,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
Fingerprintsroutes 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).