The Project: Kestrel
Every lesson in this course is set at one company, on one project. Kestrel is made up. The company, its people, its depots, every parcel, message and number in the project are fictional and simulated. The real cases that open each lesson are separate, cited and clearly marked as real.
The company
Kestrel is a UK parcel-delivery company. Its hub is in Leicester, and it runs nine depots from Dartford to Inverness, 1,150 vans, and about 210,000 parcels a day (September 2026). Businesses hand parcels to Kestrel with a promise: next day, two days (standard) or three days (economy). Kestrel delivers Monday to Saturday.
The problem
About one parcel in twelve misses its promised day. When that happens with no warning, the customer calls, writes or complains publicly, and Kestrel pays for it in contact-centre time and lost business. If Kestrel knew in the morning which parcels were at risk, it could warn those customers at 06:00 and send its few spare vans to the routes that need them most.
A data scientist built a prototype in a notebook last spring. It scored well. Nobody could rerun it, it was trained on a column that is only known after delivery, and it has never seen a parcel from Doncaster, the depot that opened in August. Adaeze, the head of data and ML, wants the real thing: a model that is right, that runs every day without anyone touching it, and that tells someone when it stops being right.
You are the ML engineer Kestrel hired in October 2026 to build it.
The model
| Item | Value |
|---|---|
| Name | kestrel-late-risk |
| Predicts | the probability that a parcel misses its promised day (late = 1) |
| Unit | one parcel |
| When it scores | at collection (online, one parcel at a time) and at 06:00 on the promised day (batch, every parcel due that day) |
| Who uses it | the 06:00 customer warnings, and the depot planners who place spare vans |
| Inputs | the 18 columns known at collection (FEATURES, guide 06 block 4: parcel_id plus 17 columns that describe the parcel); never attempts, delivered_date, late or label_ready_date |
| When the truth arrives | the day after delivery, when scans are reconciled overnight; later for parcels that were late |
What this course uses the project for
This course teaches the AI / ML engineer's job: training models and keeping them correct in production. Kestrel is the instrument. Each module adds one piece and its proof to one repository, so by the capstone the model lives in production, keeps working without anyone watching it, and you can answer the roadmap's five questions from your own measurements.
It is not Northdesk (AI System Design), BrightCart (AI Engineering), Harbourline Freight (Forward Deployed Engineer), the returns desk (Agentic AI Engineer), the Quillpath support desk (Large Language Models), Hollin Answers (RAG), the notes assistant (MCP), the TinyFormer lab (Transformers), Booknook (Python) or the Tidewell Cycles inbox (NLP). Kestrel is a different company, with a different cast, data and problem.
The people
| Name | Role | In the project | The question they ask |
|---|---|---|---|
| Adaeze Okafor | Head of data and ML (your manager) | Signs off decision records, the promotion gates and the model card. The voice of "the question Adaeze will ask" in every lesson | Can you reproduce last month's model exactly? |
| Morag Sinclair | Operations director | Owns the business problem: warnings and spare vans. Set the illustrative business costs | What will this save us, in pounds, and how sure are you? |
| Tomasz Wierzbicki | Data engineering lead | Owns the depot scan feeds, the lake and the warehouse system. Rolled out scanner software v4 on 7 September | What happens if the feature pipeline silently changes a column? |
| Rhys Llewellyn | Platform and on-call lead | Owns serving, the latency SLO, deployments, rollbacks and the GPU question | What is p99, and where does the time go? |
| Hannah Lindqvist | Customer experience and compliance lead | Owns the warning wording, customer messages, fairness to island customers, UK GDPR and EU AI Act questions. Her questions are answered from named, verified sources and marked "not legal advice" | Is it fair to the customers it serves worst? |
| Imran Qureshi | Finance business partner | Cost of one prediction, one training run and the GPU bill; self-hosting against an API | What does one prediction cost, and one training run? |
| You | ML engineer | Build the model, the pipeline around it and the evidence that it works |
Do not add named Kestrel staff. Customers appear only as message senders in the shared data, never by name.
The depots
| Id | Depot | Nation (bank holidays) | Routes | Share of parcels | Notes |
|---|---|---|---|---|---|
LEI | Leicester hub | England and Wales | 30 | 16%, falling to 13% as Doncaster ramps up | |
BHM | Birmingham | England and Wales | 26 | 14% | |
MAN | Manchester | England and Wales | 26 | 14% | |
LDS | Leeds | England and Wales | 22 | 12%, falling to 7% | gives routes to Doncaster |
BRS | Bristol | England and Wales | 20 | 10% | the lowest late rate |
DAR | Dartford | England and Wales | 32 | 18% | the busiest, London east |
GLA | Glasgow | Scotland | 18 | 10% | gales 15 to 17 September |
INV | Inverness | Scotland | 10 | 6% | three routes cross to the islands by ferry |
DON | Doncaster | England and Wales | 16 | 0%, rising to 8% from 10 August | opened 10 August 2026; many new drivers |
Shares are of the parcels in the sample (guide 06, block 3). The late rates by depot are measured in guide 06, block 10.
Fixed numbers (simulated)
These are the facts every lesson uses. They do not change; lessons that need another number add it, say "added in this lesson", and label it.
| Fact | Value |
|---|---|
| Parcels a day (whole company) | 210,000; the shared data is a 1% sample |
| Predictions a day | about 420,000 (two scores per parcel) |
| Late rate in the sample | 0.0866 of labelled parcels (measured in guide 06) |
| Service level objective (Rhys) | online scoring p99 at most 50 ms at the service; the 06:00 batch finished within 20 minutes |
| Illustrative business costs (Morag, GBP) | late with no warning £3.80; late and warned at 06:00 £1.10; warned but on time £0.40; one spare van for a day £165. These are round numbers for reasoning, not industry figures, and lessons label them illustrative every time |
| Promotion gates (Adaeze) | on the latest two labelled weeks (from Module 10 on, 7 to 20 September: the locked test set was looked at once in Module 6 and is then retired; from that point those weeks are ordinary labelled production weeks that gate later models). The gate weeks stop one day before the Module 6 test set, which runs to 21 September: 21 September is the Monday that starts the last week, whose late labels are still arriving, so the gates use the two whole weeks whose labels have settled. A challenger is promoted only if: PR-AUC is at least 0.005 above the champion's; the Brier score is no worse; no depot's PR-AUC is more than 0.02 below the champion's; p99 is at most 50 ms in the benchmark; the data contract passes |
| Retraining budget (Adaeze and Imran) | at most 4 automatic retrains a month, each at most 2 CPU-hours; anything more needs Adaeze |
| Rollout guard (Module 10 lesson 2, added there, illustrative) | during a canary: error rate at most 0.1%, p99 at most 50 ms, and the challenger's warn rate within 0.10 of the champion's in the same window |
| Retraining triggers (Module 10 lesson 3, added there, illustrative) | drift trigger at PSI 0.25 on an input; performance trigger when PR-AUC falls under 0.36 on the last fortnight's labelled parcels; a 3-day cooldown between automatic retrains |
| Registry | the MLflow registered model kestrel-late-risk, aliases champion and challenger |
Kestrel's money is in pounds sterling. Cloud and model providers publish prices in US dollars; lessons quote those in dollars and convert only with a dated exchange rate and its source.
Dates
- The shared data covers parcels shipped Monday 6 July to Sunday 27 September 2026 (12 weeks). The snapshot was taken on Monday 28 September 2026 (
DATA_AS_OF). Code counts days toDATA_AS_OF, never todate.today(), so lessons give the same answers whenever you run them. - A parcel's label is ready the day after it is delivered. Labels not ready by
DATA_AS_OFare missing (lateisNone); the first ship date with missing labels is 22 September. - Dates in prose are written day first ("7 September 2026"); dates in data are ISO (
2026-09-07).
Key events in the data
| When | What happened | Where the course uses it |
|---|---|---|
| Monday 3 August 2026 | Scotland's summer bank holiday (Glasgow, Inverness) | Module 3 (features), Module 15 (a rule beats the model) |
| Monday 10 August 2026 | Doncaster opens and takes routes from Leeds and Leicester over three weeks; its late rate rises as its volume grows | Module 6 (error analysis), Module 11 (covariate and label shift), Module 14 (new drivers as a bias source) |
| Monday 31 August 2026 | England and Wales summer bank holiday | Module 3, Module 15 |
| Monday 7 September 2026 | Scanner software v4 at every depot: weight_kg now carries grams. Nobody told the ML team | Module 5 (the data contract), Module 11 (drift, the postmortem), Module 16 (the fourth question) |
| 15 to 17 September 2026 | High winds at Glasgow and Inverness; island ferries stop (ferries are never a column) | Module 11 (concept drift), Module 14 (subgroups) |
| Monday 28 September 2026 | The snapshot; outcomes after it are not known yet | Module 11 (performance with late labels) |
Weather and the gales are simulated. The two bank holidays are real dates, read from gov.uk/bank-holidays.json on 24 October 2026.
The project weeks
| Week | Monday | Module | What happens |
|---|---|---|---|
| 1 | 2 November 2026 | 1 | The maths behind the prototype, rebuilt by hand; logistic regression from scratch |
| 2 | 9 November | 2 | The notebook becomes a tested package |
| 3 | 16 November | 3 | The leak found; model v1, a calibrated LightGBM, against honest baselines |
| 4 | 23 November | 4 | A network tried against the trees; the message classifier |
| 5 | 30 November | 5 | The data path: pipeline, lake, data contract, features without leakage |
| 6 | 7 December | 6 | Tracked experiments; model v2 and an honest comparison |
| 7 | 14 December | 7 | What training would cost at scale, and why Kestrel does not need a GPU for this model |
| 8 | 21 December | 8 | ONNX, benchmarks, cost per thousand predictions |
| 9 | 28 December | 9 | The scoring service: batch at 06:00, online at collection |
| 10 | 4 January 2027 | 10 | Registry, gates, shadow, canary, rollback, retraining |
| 11 | 11 January | 11 | September replayed: the grams found, alerts tuned, a postmortem |
| 12 | 18 January | 12 | A small foundation model adapted for customer messages |
| 13 | 25 January | 13 | What depth in tabular and risk modelling means |
| 14 | 1 February | 14 | Fairness for island customers, explanations, the model card |
| 15 | 8 February | 15 | Framing, judgement and the portfolio |
| 16 | 15 February | 16 | Capstone review with Adaeze, Morag and Rhys: the five questions |
Gemini 3.8 Flash's price doubles on 1 January 2027, in week 9: course-guide/02 says so, and Module 12 lesson 2 uses it as the example of a dated price.
Fixed names
So that modules fit together, these names do not change:
- The repository is
kestrel/; the package inside it iskestrel/(imported asimport kestrel); tests live intests/; pipelines inpipelines/; the service inservice/; evaluation of the message models inevals/; documents indocs/; reports generated by code inreports/; model files inartifacts/. Paths below are relative to the repository root. kestrel/data.pyholds blocks 1 to 8 of the shared data, copied exactly.FEATURES(inkestrel/features.py, Module 3) is the list of the 18 columns known at collection;FORBIDDEN = ["attempts", "delivered_date", "late", "label_ready_date"]never reach training.- Every model, whatever its library, is wrapped to
predict_proba(df) -> numpy arrayof late probabilities, so baselines, trees, networks and the ONNX runtime are interchangeable in the gates and the service. - The time-based split: train on ship dates before Monday 24 August 2026, validate on 24 August to 6 September, and keep 7 to 21 September as the locked test set (Module 6 lesson 2). Labels after 21 September are incomplete. The test set starts on the day
weight_kgturns into grams: Modules 3 and 4 train and test on dates before 7 September only, and from Module 5 on the data contract converts September's grams back to kilograms before anything trains or scores (kestrel/contracts.py). Monitoring lessons replay the raw feed, grams included, on purpose. - The service's request carries the 18 columns of
FEATURES(parcel_idincluded); the response carrieslate_probability,model_versionandfallback(true when the baseline answered). - Every run record holds the Git commit, the data fingerprint from guide 06 block 8, the parameters, the metrics and the model file's hash.
- Reports in
reports/are generated by code and never edited by hand.
One repository: which module adds which file
In Colab each lesson writes the files it needs with %%writefile (one small file per component, never more than about 40 lines) and imports them; on a laptop the same files form the repository. Tests run with !python -m pytest -q in Colab and pytest -q locally. Every lab from Module 2 on starts by checking that the earlier modules' files are present and writing back any that are missing, so you can start at any module. This table is the plan; it will be regenerated from the published lessons' %%writefile cells when the course is built.
| Module | Lessons add | The lab adds |
|---|---|---|
| 1 | kestrel/maths/linalg.py, kestrel/maths/optimise.py, kestrel/maths/stats.py | kestrel/__init__.py, docs/adr/TEMPLATE.md (the ADR template below), kestrel/data.py, kestrel/baseline_logit.py, tests/test_data.py, requirements.txt, .gitignore, README.md |
| 2 | pyproject.toml, kestrel/schema.py, kestrel/featurise.py, kestrel/profiling.py, kestrel/io.py, kestrel/parallel.py, tests/conftest.py, tests/test_featurise.py, Dockerfile (written, not built), .github/workflows/ci.yml (written, not run) | kestrel/cli.py, tests/test_cli.py, Makefile |
| 3 | kestrel/models/linear.py, kestrel/models/trees.py, kestrel/models/others.py, kestrel/explore/cluster.py, kestrel/explore/anomaly.py, kestrel/forecast.py, kestrel/features.py, kestrel/split.py, kestrel/leakage.py, kestrel/calibrate.py, kestrel/metrics.py, kestrel/baselines.py | kestrel/train_v1.py, tests/test_leakage.py, reports/model-v1.md (generated), docs/adr/ADR-001-model-family.md |
| 4 | kestrel/nn/tiny_autograd.py, kestrel/nn/mlp.py, kestrel/nn/train_loop.py, kestrel/nn/diagnose.py, kestrel/nn/text_model.py, kestrel/nn/vision.py, kestrel/nn/dataset.py, kestrel/nn/lit_module.py, kestrel/seed.py | kestrel/nn/tabular_net.py, tests/test_nn.py, artifacts/intent_classifier.pt (generated), docs/adr/ADR-002-trees-or-network.md |
| 5 | pipelines/ingest_flow.py, kestrel/etl.py, kestrel/backfill.py, kestrel/lake.py, kestrel/contracts.py, kestrel/lineage.py, kestrel/annotation.py, kestrel/pii.py, dvc.yaml, kestrel/feature_build.py, kestrel/skew.py, feature_repo/features.py | pipelines/daily.py, tests/test_contracts.py, docs/data-contract.md, docs/adr/ADR-003-data-path.md |
| 6 | kestrel/track.py, kestrel/tune.py, kestrel/compare.py, kestrel/holdout.py, kestrel/error_analysis.py, docs/experiments/log.md | kestrel/train_v2.py, reports/experiments.md (generated) |
| 7 | kestrel/scale/vram.py, kestrel/scale/gpu_cost.py, scripts/ddp_train.py, kestrel/scale/checkpoint.py, kestrel/scale/profile_loop.py, kestrel/scale/loader_bench.py | kestrel/scale/report.py, reports/training-throughput.md (generated), docs/adr/ADR-004-training-hardware.md |
| 8 | kestrel/optimise/quantise.py, kestrel/optimise/distil.py, kestrel/optimise/prune.py, kestrel/optimise/export_onnx.py, kestrel/optimise/batcher.py, kestrel/bench.py, kestrel/cost_per_k.py | kestrel/optimise/onnx_model.py, artifacts/model.onnx (generated), reports/benchmark.md (generated), docs/adr/ADR-005-inference-runtime.md |
| 9 | service/app.py, service/schemas.py, kestrel/batch_score.py, service/admission.py, service/fallback.py, service/versions.py, kestrel/preprocess.py | service/main.py, service/Dockerfile (written, not built), tests/test_service.py, docker-compose.yml (written, not run), docs/slo.md, docs/adr/ADR-006-serving-shape.md |
| 10 | pipelines/train_flow.py, kestrel/registry.py, kestrel/gates.py, kestrel/reproduce.py, kestrel/rollout.py, kestrel/flags.py, .github/workflows/model-ci.yml (written, not run), docs/runbook-rollback.md, kestrel/triggers.py, kestrel/retrain_budget.py, tests/test_pipeline.py, data/slice.parquet (generated) | pipelines/continuous_training.py, .github/workflows/tests.yml (written, not run; supersedes ci.yml), docs/adr/ADR-007-rollout-policy.md |
| 11 | kestrel/monitor/drift.py, kestrel/monitor/performance.py, kestrel/monitor/segments.py, kestrel/monitor/ood.py, kestrel/monitor/alerts.py, kestrel/monitor/report.py, docs/postmortem-template.md | pipelines/monitor_flow.py, docs/dashboard.md, docs/postmortem-2026-09-07.md, docs/adr/ADR-008-alert-thresholds.md |
| 12 | kestrel/fm/finetune_set.py, kestrel/fm/lora_intent.py, kestrel/fm/forgetting.py, kestrel/fm/memory_maths.py, kestrel/fm/economics.py, kestrel/fm/load_test.py, evals/messages_set.py, evals/messages_v1.jsonl (generated), evals/judge.py, artifacts/intent_lora/ (generated) | evals/messages_eval.py, evals/regression.py, kestrel/llm.py (the setup cell and ask()), docs/adr/ADR-009-self-host-or-api.md |
| 13 | kestrel/tracks/rank_vans.py, kestrel/tracks/search_messages.py, kestrel/tracks/cost_curve.py, docs/depth-tabular.md | docs/specialisation.md |
| 14 | kestrel/responsible/fairness.py, kestrel/responsible/explain.py, kestrel/responsible/counterfactual.py, kestrel/responsible/audit.py, kestrel/responsible/privacy.py, kestrel/responsible/robustness.py | docs/model-card.md, reports/fairness.md (generated), docs/adr/ADR-010-deploy-decision.md |
| 15 | docs/framing.md, kestrel/value.py, docs/writeup.md | README.md (portfolio version), docs/interview/kestrel-design.md |
| 16 | pipelines/make_all.py, service/live.py (serves v1 and v2 on the columns they read), docs/five-answers.md, reports/capstone.md (generated), make all target that rebuilds everything from a commit |
Decision records
Decisions are written as architecture decision records (ADRs) in docs/adr/, numbered in the order above (ADR-001 in Module 3 to ADR-010 in Module 14). Every ADR uses this template:
# ADR-NNN: <decision in a few words>
Status: proposed | accepted | superseded by ADR-MMM
Date: <ISO date>
Deciders: <names from the cast>
## Context
<the problem, with the measured numbers that force a decision>
## Options considered
<two or three, each with quality (PR-AUC, Brier), latency, cost and what it locks in>
## Decision
<the choice, in one or two sentences>
## Consequences
<what gets easier, what gets harder, what we now have to operate or monitor>
## Revisit when
<the measurable condition that would change the decision, for example "Doncaster's PR-AUC falls 0.02 below the fleet's">The artifacts you end with
| Artifact | File | Started | Finished |
|---|---|---|---|
| The model, versioned in the registry | kestrel-late-risk (MLflow), artifacts/model.onnx | Module 3 lab (v1) | Module 10 lab (gated, promoted) |
| Decision records | docs/adr/ | Module 3 lab | Module 14 lab |
| Data contract | docs/data-contract.md, kestrel/contracts.py | Module 5 | |
| Experiment log, with negative results | docs/experiments/log.md, reports/experiments.md | Module 6 | every later module that trains |
| Benchmark and cost per thousand predictions | reports/benchmark.md | Module 8 | regenerated by the capstone |
| SLO and rollback runbook | docs/slo.md, docs/runbook-rollback.md | Modules 9, 10 | rehearsed in the capstone |
| Monitoring dashboard, alert rules and a postmortem | docs/dashboard.md, docs/postmortem-2026-09-07.md | Module 11 | |
| Model card and fairness report | docs/model-card.md, reports/fairness.md | Module 14 | |
| The five answers | docs/five-answers.md | Module 16 |
The finished system
- A pipeline from raw depot scans to a registered model, run by one command and versioned (data with DVC, code with Git, models in the registry)
- A data contract that fails loudly on the 7 September grams
- Model v1 and v2 with tracked experiments, honest baselines and a significance test
- An ONNX model in a FastAPI service with a 06:00 batch path and an online path, a fallback to the baseline, and a benchmark against the 50 ms p99 SLO
- Promotion gates, shadow and canary rollout simulated, and a rehearsed rollback
- Drift, null-rate, prediction and segment monitoring that works before labels arrive, with alerts tuned on September and a postmortem
- An automated retraining trigger inside a cost ceiling
- A small foundation model adapted for customer messages and evaluated against a hosted one
- A model card, a fairness report and an explanation a customer could read
The five questions
The capstone answers the roadmap's five questions from measured numbers:
- What does one prediction cost, and one training run? (Imran; Modules 7 and 8)
- What is p99, and where does it go? (Rhys; Modules 8 and 9)
- How would you know the model degraded, and how fast? (Adaeze; Module 11)
- What happens if the feature pipeline silently changes a column? (Tomasz; Modules 5 and 11, the 7 September grams)
- Can you reproduce last month's model exactly? (Adaeze; Modules 6 and 10)
The one idea
Training is the bounded part; keeping a model correct in production is the job. Every component in this course has to earn its place with a number: a leak it removes, a drift it catches, a millisecond it saves, a pound it saves, or a rollback it makes possible.