Turn the Unit 2 late-delivery model into a live planner worklist with an API, approvals, monitoring, a retraining plan and a KPI measured against a holdout.
Every day a warehouse ships hundreds of outbound deliveries. Each one has a planned goods issue date, the day the goods should leave. Some leave late. A late goods issue can mean a missed truck, a missed delivery window, a penalty or revenue that slips into next month.
Today, most late deliveries are discovered when they are already late. A planner sees the overdue list, phones the warehouse and pays for an express carrier. That is reactive expediting: expensive, and often too late to help the customer.
Who feels it: shipping planners and warehouse leads (order-to-cash), customer service, who take the calls, and finance, who pays the express freight and sees revenue slip.
Today's numbers must come from your own system. This project uses made-up, SAP-shaped data: about 86% of deliveries leave on time, and a late delivery that nobody saw coming costs about 250 EUR in penalties and express freight. Acting on a warning early, a check plus a cheaper fix such as an earlier truck, is assumed to cost 50 EUR. Those costs come from the metric contract in Classification and the metrics that matter; yours will differ.
In Predicting late deliveries with SAP data you built a model that ranks open deliveries by risk. A model on a laptop helps nobody. This project turns it into a small product that planners use every morning, that someone owns, and that proves its value in euros.
7:45. Omar, the planner for shipping point 1710, opens the worklist. It shows his open deliveries only, ranked by risk. Four carry a CHECK flag, with the two strongest reasons, such as "route R17103, shipping point 1710". One is marked OVERDUE: its planned date has passed.
7:50. He moves the riskiest one to an earlier truck in SAP, using his usual app, and clicks Expedite in the worklist with a note. The system records the decision and the 50 EUR cost. It never changes the delivery in SAP itself.
8:00. Pia, who plans shipping points 1010 and 1020, sees only her deliveries. She can't decide for Omar's.
9:30. Vic, a sales manager, opens the same page to answer a customer. He can see risks, but he can't record decisions.
Monday. The model owner runs the weekly monitoring check. It compares recent weeks with what the model learned. When a route suddenly gets worse, or a new route appears, it says RETRAIN RECOMMENDED.
End of month. A new model is trained on the newest outcomes. It goes live only when a second person approves it, and the old one stays ready for a rollback.
Share of deliveries whose goods issue was posted on or before the planned date
Expedite spend divided by late deliveries the worklist prevented
Baseline
86% (made-up sample; measure yours over the last quarter)
Today's reactive express freight per late delivery
Target
Agree it, for example +3 points in two quarters
Below the cost of a late delivery (250 EUR in the sample), or expediting doesn't pay
How it is measured
Outcomes from SAP joined to the app's own log
The app's decision log, compared with a holdout
Guardrail
Flags per week stay within what planners agreed to handle
Planners act on most flags; if they ignore them, find out why
The holdout is the important idea. A fixed 20% of deliveries is scored but never flagged. Comparing flagged deliveries that planners saw with flagged deliveries they didn't is a fair test of the worklist. A plain before-and-after comparison is not. In this project's simulation, a route got much slower after go-live, so the on-time rate barely moved even though the simulated worklist helped. The holdout must be agreed with the process owner, and can be stopped once the effect is proven.
flowchart LR
S[SAP S/4HANA<br/>Outbound Delivery API] -->|read only| D[Delivery copy]
D --> T[Training<br/>monthly]
T --> C[Candidate model<br/>with model card]
C -->|second person approves| L[Live model]
L --> A[Worklist API]
P[Planner in browser] --> A
A --> G[(Decision and<br/>prediction log)]
A --> X[Expedite list]
X -->|planner acts in SAP| S
G --> M[Weekly monitoring]
M -->|retrain| T
G --> K[KPI report]
Where SAP fits. As of October 2026, SAP offers several routes, and some customers will need no custom build at all:
Embedded in S/4HANA. SAP publishes an asset on SAP S/4HANA Predictive Delivery Delays. A partner blog from NTT DATA describes the Predicted Delivery Delay app, which predicts delay in days per sales order item. SAP's embedded machine learning scenarios are managed through Intelligent Scenario Lifecycle Management (ISLM), which, according to SAP's overview, includes analyzing model quality and an explicit activation step. Check your release's documentation for how this particular scenario is trained.
SAP AI Core for your own model. SAP's Architecture Center recommends it for custom models and MLOps-heavy work, and lists retraining pipelines, model versions and A/B testing.
SAP-RPT-1, a pretrained tabular model, which SAP's Architecture Center names as the default for classification and regression. Tabular AI and SAP's predictive models covers it.
So the first question is "does the embedded scenario predict what our planners need, at the moment they need it?" This project is worth building when the answer is no: your own definition of late, your own data such as carrier events, or a worklist the embedded app doesn't give.
The pain (30 s). Show last quarter's numbers: on-time rate and express freight spent after deliveries were already late.
The worklist (45 s). Open it as Omar. Point at the top flag and its reasons, and the overdue line. Click Expedite and type what you would do in SAP. Switch to Pia: she sees different deliveries and can't decide for Omar's.
The honesty (45 s). Show the backtest: a model frozen in June against one retrained monthly, on July to September. Show the monitoring alarm for the June model: a slower route and a route it has never seen.
The control (30 s). Show that the person who trained a model can't make it live, and that a rollback is one command.
The value (30 s). Show the KPI report and say clearly that the sample numbers are simulated. Explain the holdout and when you will have real numbers.
Pick one answer for each question. The explanation appears after you choose.
1Why does this project measure its KPI against a holdout instead of comparing before and after go-live?
Answer: A. Other changes happen at the same time as go-live. In the project's simulation, a route got slower and the on-time rate barely moved, while the holdout comparison still showed the effect.
2When does expediting flagged deliveries pay off?
Answer: D. Expediting costs money on every flag, including false alarms. It only pays if the spend per late delivery actually avoided stays below what a late delivery costs, 250 EUR in the sample.
3A planner clicks Expedite in the worklist. What happens in SAP?
Answer: C. The system records the decision and writes an expedite list, but it has no way to write to SAP. The planner carries out the change with their own authorizations, so SAP's checks and audit trail still apply.
4Your S/4HANA system offers the Predicted Delivery Delay app. What should you check before building this project?
Answer: B. The partner description says it predicts delay per sales order item. If that target and moment fit your process, a custom build may not be needed. Build when your definition of late, data or worklist differs.
5Weekly monitoring says a route got much slower and a new route has appeared. What is the right response?
Answer: A. The model only knows its training period. Retraining teaches it the new pattern, the evaluation shows whether the candidate is better, and the approval keeps an unchecked model from going live.
6Why must a second person approve every new model before it goes live?
Answer: B. A model changes what planners see every morning. A four-eyes rule, enforced in code, makes someone other than the builder check the model card and test results, and the promotion log records who approved what and when.
7The vendor's demo shows a perfect test score for their delay model. What do you ask first?
Answer: B. Perfect scores on business data usually mean leakage: a field that holds the answer after the fact. Such a model looks perfect in testing and flags nothing useful on open deliveries.
Ask a question
Testing: only staff see this
Stuck on something in this layer? Ask it here. Questions are answered in the order they arrive, and the answer appears under My questions.
Sign in (free) to ask a question. You can ask anonymously.
You will turn the late-delivery model from Predicting late deliveries with SAP data into a small, complete product: a worklist that planners open every morning, an API other systems can call, a model that only goes live with a second person's approval, weekly monitoring with a retraining plan, and a KPI measured against a holdout.
flowchart LR
S["Stage 1: SAP data<br/>sap_data.py"] --> M["Stage 2: model<br/>late_model.py"]
M --> A["Stage 3: API<br/>app.py"]
A --> U["Stage 4: worklist page<br/>served by app.py"]
A --> D["Stage 5: deploy<br/>manifest.yml"]
M --> E["Stage 6: backtest and monitoring<br/>evaluate.py, monitor.py"]
E -->|retrain| M
A -->|events.jsonl| K["Stage 7: KPI<br/>kpi.py"]
File
Stage
What it does
sap_data.py
1
Gets outbound deliveries as data/deliveries.json
late_model.py
2
Trains a candidate model, writes its model card, promotes it with a second person's approval
app.py
3, 4
The API and the worklist page; logs predictions and decisions
Backtest: a frozen model against monthly retraining
monitor.py
6
Weekly checks and a retrain recommendation
kpi.py
7
On-time rate and expedite spend, against the holdout
Everything runs on made-up, SAP-shaped data without any account. SAP's sandbox is an optional switch.
Prerequisites. You should have done these published topics, or be ready to look back at them:
Predicting late deliveries with SAP data (Unit 2): the label, leakage, the time split, TimeSeriesSplit and the cost-based threshold. This project reuses all of it and doesn't explain it again.
Before you start: complete Set up your computer for this course, Set up for Unit 2 and Set up for Unit 6. They give you the orchestrate-course folder, its .venv, pandas, scikit-learn, FastAPI, requests, python-dotenv and your SAP_API_KEY in .env. This project adds no new libraries.
In this stage you get outbound deliveries into one file, data/deliveries.json, in the exact JSON shape of SAP's Outbound Delivery API. Every later stage reads only that file and data/source.json, which records the date the data describes.
flowchart LR
A["--sample<br/>made-up deliveries"] --> F["data/deliveries.json<br/>data/source.json"]
B["--sandbox<br/>API_OUTBOUND_DELIVERY_SRV"] --> F
The data is the same as in Unit 2: the entity A_OutbDeliveryHeader of the OData V2 service API_OUTBOUND_DELIVERY_SRV, with the items under the navigation property to_DeliveryDocumentItem. A connector generated from the API definition lists all the header fields used here.
Field
Used as
CreationDate
Time order; start of the planned lead time
PlannedGoodsIssueDate
Label (planned side); lead time; weekday
ActualGoodsMovementDate
Label (actual side) and "is the outcome known yet?". Never a feature
Shipping point and route matter for a reason: SAP Learning's lesson on delivery scheduling explains that loading and pick/pack times come from the shipping point, and transit and transportation lead times from the route. A route that is suddenly late often means its planned durations no longer match reality.
The made-up data covers October 2025 to 9 October 2026, the data's "today". Two changes are built in on purpose, so Stage 6 has something to find: from 15 July 2026 route R17102 gets much slower, and from 1 September 2026 a new route R17103 appears at shipping point 1710.
The data and models are regenerated by the scripts, the logs hold planners' decisions, and the exports are work orders. None belong in a public portfolio. The reports/ folder stays: it is portfolio material.
In VS Code's file list, right-click projects/late-delivery, choose New File, name it sap_data.py, paste the code below and save (Ctrl+S, or Cmd+S on Mac).
"""Stage 1: get outbound deliveries into data/deliveries.json, in the JSON shape of API_OUTBOUND_DELIVERY_SRV.
How to run (from your orchestrate-course folder, with .venv turned on):
python projects/late-delivery/sap_data.py --sample # made-up deliveries, no key, no internet
python projects/late-delivery/sap_data.py --sandbox # SAP Business Accelerator Hub sandbox (SAP_API_KEY in .env)
Every later stage reads only data/deliveries.json and data/source.json, so it does not matter where they came from.
The script only reads (GET) from SAP. It never changes data.
"""
import argparse
import json
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
import pandas as pd
HERE = Path(__file__).parent
DATA = HERE / "data"
SERVICE = "/sap/opu/odata/sap/API_OUTBOUND_DELIVERY_SRV"
SANDBOX = "https://sandbox.api.sap.com/s4hanacloud" + SERVICE
SAMPLE_AS_OF = pd.Timestamp("2026-10-09") # the made-up data's "today"
def odata_date(ts: pd.Timestamp) -> str:
"""Write a date the way OData V2 JSON does: /Date(milliseconds since 1 Jan 1970)/."""
return f"/Date({int(ts.timestamp() * 1000)})/"
def make_sample(rows: int = 3300, seed: int = 11) -> dict:
"""Made-up outbound deliveries shaped like A_OutbDeliveryHeader with to_DeliveryDocumentItem.
Two changes are built in, so monitoring has something to find:
from 15 July 2026 route R17102 gets slower, and from 1 September 2026 a new route R17103 appears.
"""
rng = np.random.default_rng(seed)
start = pd.Timestamp("2025-10-01")
created = np.sort(start + pd.to_timedelta(rng.integers(0, 374, size=rows), unit="D"))
routes = {"1010": ["R10101", "R10102"], "1020": ["R10201", "R10202"], "1710": ["R17101", "R17102"]}
results = []
for i in range(rows):
creation = pd.Timestamp(created[i])
point = str(rng.choice(["1010", "1020", "1710"], p=[0.45, 0.35, 0.20]))
route = str(rng.choice(routes[point]))
if point == "1710" and creation >= pd.Timestamp("2026-09-01") and rng.random() < 0.5:
route = "R17103" # a new carrier route, unknown to older models
shipping_type = str(rng.choice(["01", "02", "03"], p=[0.6, 0.3, 0.1]))
condition = str(rng.choice(["01", "02"], p=[0.8, 0.2]))
priority = str(rng.choice(["02", "01"], p=[0.85, 0.15]))
lead = int(rng.integers(1, 11))
planned_gi = creation + pd.Timedelta(days=lead)
n_items = int(rng.poisson(2.5)) + 1
items, weight = [], 0.0
for j in range(n_items):
qty = int(rng.integers(1, 40))
item_weight = round(qty * float(rng.gamma(2.0, 4.0)), 3)
weight += item_weight
items.append({"DeliveryDocument": str(80000000 + i), "DeliveryDocumentItem": f"{(j + 1) * 10:06d}",
"Material": f"TG{int(rng.integers(10, 30))}", "Plant": point,
"ActualDeliveryQuantity": f"{qty:.3f}", "ItemGrossWeight": f"{item_weight:.3f}",
"ItemWeightUnit": "KG"})
risk = (-2.4 + 0.22 * n_items + 0.0012 * weight - 0.20 * lead
+ (0.9 if point == "1710" else 0.0)
+ (-0.8 if condition == "02" else 0.0)
+ (-0.5 if priority == "01" else 0.0)
+ (0.6 if planned_gi.dayofweek == 4 else 0.0)
+ (2.5 if route == "R17102" and creation >= pd.Timestamp("2026-07-15") else 0.0)
+ (1.5 if route == "R17103" else 0.0)
+ rng.logistic(0, 0.8))
delay = int(rng.geometric(0.45)) if risk > 0 else -int(rng.integers(0, 2))
actual_gi = planned_gi + pd.Timedelta(days=delay)
is_open = actual_gi > SAMPLE_AS_OF # goods issue not posted yet on "today"
results.append({
"DeliveryDocument": str(80000000 + i),
"ShippingPoint": point, "ShippingType": shipping_type, "ShippingCondition": condition,
"DeliveryPriority": priority, "ProposedDeliveryRoute": route,
"ShipToParty": f"10{int(rng.integers(100, 160)):05d}",
"CreationDate": odata_date(creation),
"PlannedGoodsIssueDate": odata_date(planned_gi),
"ActualGoodsMovementDate": None if is_open else odata_date(actual_gi),
"HeaderGrossWeight": f"{weight:.3f}", "HeaderWeightUnit": "KG",
"to_DeliveryDocumentItem": {"results": items},
})
return {"d": {"results": results}}
def fetch_sandbox(key: str, top: int, max_pages: int) -> dict:
"""Read outbound deliveries with items from the SAP Business Accelerator Hub sandbox (GET only)."""
try:
import requests
except ModuleNotFoundError:
sys.exit("The requests library is missing. Run: pip install -r requirements.txt (or use --sample).")
session = requests.Session()
session.headers.update({"APIKey": key, "Accept": "application/json"})
url = f"{SANDBOX}/A_OutbDeliveryHeader"
params = {"$top": top, "$expand": "to_DeliveryDocumentItem", "$format": "json"}
results = []
for _ in range(max_pages):
try:
response = session.get(url, params=params, timeout=30)
except requests.exceptions.RequestException as err:
sys.exit(f"Could not reach sandbox.api.sap.com ({type(err).__name__}). "
"Check your network or proxy, or run with --sample.")
if response.status_code != 200:
hint = {401: "the key is missing or wrong: check SAP_API_KEY in .env",
403: "the key is not allowed to call this API",
404: "the service path is wrong or the API is not on the sandbox"}
sys.exit(f"Stopped: HTTP {response.status_code}, {hint.get(response.status_code, 'see the message above')}.")
body = response.json()["d"]
results.extend(body.get("results", []))
if "__next" not in body:
break
url, params = body["__next"], None
return {"d": {"results": results}}
def parse_date(value):
"""'/Date(1759190400000)/' -> Timestamp. Empty values become NaT (not a time)."""
if not value:
return pd.NaT
match = re.search(r"/Date\((-?\d+)", str(value))
return pd.to_datetime(int(match.group(1)), unit="ms") if match else pd.NaT
def flatten(payload: dict) -> pd.DataFrame:
"""One row per delivery: header fields plus item count and total quantity."""
rows = []
for h in payload["d"]["results"]:
items = (h.get("to_DeliveryDocumentItem") or {}).get("results", [])
rows.append({
"DeliveryDocument": h.get("DeliveryDocument"),
"ShippingPoint": h.get("ShippingPoint") or "(blank)",
"ShippingType": h.get("ShippingType") or "(blank)",
"ShippingCondition": h.get("ShippingCondition") or "(blank)",
"DeliveryPriority": h.get("DeliveryPriority") or "(blank)",
"ProposedDeliveryRoute": h.get("ProposedDeliveryRoute") or "(blank)",
"CreationDate": parse_date(h.get("CreationDate")),
"PlannedGoodsIssueDate": parse_date(h.get("PlannedGoodsIssueDate")),
"ActualGoodsMovementDate": parse_date(h.get("ActualGoodsMovementDate")),
"GrossWeight": float(h.get("HeaderGrossWeight") or 0),
"WeightUnit": h.get("HeaderWeightUnit") or "",
"Items": len(items),
"TotalQuantity": sum(float(i.get("ActualDeliveryQuantity") or 0) for i in items),
})
df = pd.DataFrame(rows)
if df.empty:
return df
return df.dropna(subset=["CreationDate", "PlannedGoodsIssueDate"]).sort_values("CreationDate").reset_index(drop=True)
def load_deliveries(data_dir: Path = DATA):
"""What every later stage calls: the delivery table and the date the data describes ("as of")."""
payload_file, source_file = data_dir / "deliveries.json", data_dir / "source.json"
if not payload_file.exists():
sys.exit("No data/deliveries.json yet. Run: python projects/late-delivery/sap_data.py --sample")
source = json.loads(source_file.read_text(encoding="utf-8"))
table = flatten(json.loads(payload_file.read_text(encoding="utf-8")))
return table, pd.Timestamp(source["as_of"]), source
def main() -> None:
parser = argparse.ArgumentParser(description="Stage 1: get outbound deliveries.")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--sample", action="store_true", help="made-up deliveries; no key or internet")
group.add_argument("--sandbox", action="store_true", help="read the SAP sandbox (needs SAP_API_KEY)")
parser.add_argument("--top", type=int, default=200, help="deliveries per sandbox page")
parser.add_argument("--max-pages", type=int, default=5, help="safety limit on sandbox pages")
args = parser.parse_args()
if args.sample:
payload, source, as_of = make_sample(), "sample", SAMPLE_AS_OF
else:
try:
from dotenv import load_dotenv
load_dotenv()
except ModuleNotFoundError:
pass
key = os.environ.get("SAP_API_KEY")
if not key:
sys.exit("SAP_API_KEY is not set. Add it to .env, or run with --sample.")
payload, source = fetch_sandbox(key, args.top, args.max_pages), "sandbox"
as_of = pd.Timestamp(datetime.now(timezone.utc).date())
DATA.mkdir(exist_ok=True)
(DATA / "deliveries.json").write_text(json.dumps(payload, indent=1), encoding="utf-8")
(DATA / "source.json").write_text(json.dumps({
"source": source, "as_of": str(as_of.date()),
"read_at": datetime.now(timezone.utc).isoformat(timespec="seconds")}, indent=1), encoding="utf-8")
table = flatten(payload)
if table.empty:
print(f"Source: {source}. The answer held no deliveries: a valid, empty result.")
return
known = table["ActualGoodsMovementDate"].notna()
late = (table["ActualGoodsMovementDate"] > table["PlannedGoodsIssueDate"])[known]
print(f"Source: {source}. As of {as_of.date()}: {len(table)} deliveries, "
f"created {table['CreationDate'].min().date()} to {table['CreationDate'].max().date()}.")
print(f" Goods issue posted (outcome known): {known.sum()}, late: {late.mean():.0%}. Still open: {(~known).sum()}.")
print(f" Shipping points: {', '.join(sorted(table['ShippingPoint'].unique()))}. "
f"Routes: {table['ProposedDeliveryRoute'].nunique()}.")
if table["WeightUnit"].nunique() > 1:
print(f" Warning: mixed weight units {sorted(table['WeightUnit'].unique())}; convert before training.")
print("Wrote data/deliveries.json and data/source.json")
if __name__ == "__main__":
main()
Source: sample. As of 2026-10-09: 3300 deliveries, created 2025-10-01 to 2026-10-09.
Goods issue posted (outcome known): 3254, late: 15%. Still open: 46.
Shipping points: 1010, 1020, 1710. Routes: 7.
Wrote data/deliveries.json and data/source.json
3,254 deliveries have a posted goods issue, so their outcome is known; 15% of them left late. 46 are still open: those become the worklist in Stage 3. Open data/deliveries.json and find PlannedGoodsIssueDate and ActualGoodsMovementDate in the first record: the label is those two dates compared.
What success looks like:Source: sandbox., the number of deliveries, how many have a goods issue, and Wrote data/deliveries.json. The sandbox holds demo data, so expect far too few deliveries to train a model. Stage 2 will then stop with Only N deliveries with a known outcome. That message is a valid result: the extraction worked. Run --sample again to continue the project.
Unit 2's script trained a model and printed numbers. A model that runs in a product needs three more things:
It is saved with a model card. scikit-learn's guide on model persistence says what to keep next to a saved model: a reference to the training data, the code, the library versions and the cross-validation score. Without them, nobody can rebuild or audit it.
It is loaded safely. The same guide warns that pickle and joblib files can run arbitrary code when loaded, so you must never load one from an untrusted source. It also says loading a model saved with a different scikit-learn version is unsupported. The script refuses both cases: it checks the file's fingerprint against the card, and the version.
It goes live by a decision, not by a file copy. Training only creates a candidate. A second person promotes it, and the script records who and when.
flowchart LR
D[data/deliveries.json] --> T[train: test split,<br/>model choice, threshold]
T --> F[refit on all<br/>known outcomes]
F --> C["models/m20261009.joblib<br/>+ model card .json"]
C -->|promote, second person| L[models/active.json]
L --> API[app.py loads it]
The training follows Unit 2 step by step: label from the two dates, features known at creation, a time split for the test, two models compared on TimeSeriesSplit folds, and a threshold from the costs with TunedThresholdClassifierCV. One thing is new. After testing, the model is refit on every known outcome, including the test period, because the newest weeks are the most valuable for the live model. The test numbers in the card describe how this recipe performed; Stage 6 checks how the live model performs.
The card also stores a reference: the late share per route over the last 90 days of training data, and every code the model has seen. Stage 6 compares new weeks against it.
In projects/late-delivery, create late_model.py, paste the code below and save.
"""Stage 2: train, save and promote the late-delivery model. The API and the monitoring use the functions here.
How to run (from your orchestrate-course folder):
python projects/late-delivery/late_model.py train --by rt # candidate from all outcomes known today
python projects/late-delivery/late_model.py train --as-of 2026-06-30 --by rt # as if it were 30 June
python projects/late-delivery/late_model.py promote m20261009 --approved-by mia # a second person makes it live
python projects/late-delivery/late_model.py list
"""
import argparse
import hashlib
import json
import sys
import warnings
from datetime import datetime, timezone
from pathlib import Path
import joblib
import numpy as np
import pandas as pd
import sklearn
from sklearn.base import clone
from sklearn.compose import ColumnTransformer
from sklearn.exceptions import InconsistentVersionWarning
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score, confusion_matrix, make_scorer, roc_auc_score
from sklearn.model_selection import TimeSeriesSplit, TunedThresholdClassifierCV, cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sap_data import load_deliveries
HERE = Path(__file__).parent
MODELS = HERE / "models"
CATEGORICAL = ["ShippingPoint", "ShippingType", "ShippingCondition", "DeliveryPriority",
"ProposedDeliveryRoute", "PlannedGIWeekday"]
NUMERIC = ["Items", "TotalQuantity", "GrossWeight", "PlannedLeadDays"]
FEATURES = CATEGORICAL + NUMERIC
MIN_KNOWN = 300
LABELS = {"ShippingPoint": "shipping point", "ShippingType": "shipping type", "ShippingCondition": "shipping condition",
"DeliveryPriority": "priority", "ProposedDeliveryRoute": "route", "PlannedGIWeekday": "goods issue on",
"Items": "items", "TotalQuantity": "quantity", "GrossWeight": "weight", "PlannedLeadDays": "lead time"}
# ---------------------------------------------------------------- label, features, what is known when
def add_features(df: pd.DataFrame) -> pd.DataFrame:
"""Late = goods issue posted after the planned date. Features = what a delivery shows when it is created."""
df = df.copy()
delay = (df["ActualGoodsMovementDate"] - df["PlannedGoodsIssueDate"]).dt.days
df["Late"] = np.where(delay.isna(), np.nan, (delay > 0).astype(float))
df["PlannedLeadDays"] = (df["PlannedGoodsIssueDate"] - df["CreationDate"]).dt.days
df["PlannedGIWeekday"] = df["PlannedGoodsIssueDate"].dt.day_name().str[:3]
return df
def known_as_of(df: pd.DataFrame, as_of: pd.Timestamp) -> pd.DataFrame:
"""Deliveries whose outcome existed on the as-of date: goods issue posted on or before it."""
out = df[df["ActualGoodsMovementDate"] <= as_of].copy()
out["Late"] = out["Late"].astype(int)
return out
def open_as_of(df: pd.DataFrame, as_of: pd.Timestamp) -> pd.DataFrame:
"""Deliveries created by the as-of date whose goods issue had not been posted yet: the worklist."""
gi = df["ActualGoodsMovementDate"]
return df[(df["CreationDate"] <= as_of) & (gi.isna() | (gi > as_of))].copy()
# ---------------------------------------------------------------- training
def preprocessor() -> ColumnTransformer:
return ColumnTransformer([
("categories", OneHotEncoder(handle_unknown="ignore", sparse_output=False), CATEGORICAL),
("numbers", StandardScaler(), NUMERIC)])
def cost_of(y_true, flagged, cost_miss: float, cost_check: float) -> float:
_, fp, fn, tp = confusion_matrix(y_true, flagged, labels=[0, 1]).ravel()
return float((tp + fp) * cost_check + fn * cost_miss)
def neg_cost(y_true, flagged, cost_miss, cost_check):
return -cost_of(y_true, flagged, cost_miss, cost_check) / len(y_true)
def judge(model, rows: pd.DataFrame, cost_miss: float, cost_check: float) -> dict:
"""The numbers the business reads, on rows the model never trained on."""
y = rows["Late"].to_numpy()
scores = model.predict_proba(rows[FEATURES])[:, 1]
flagged = model.predict(rows[FEATURES])
_, fp, fn, tp = confusion_matrix(y, flagged, labels=[0, 1]).ravel()
weeks = max(1.0, (rows["CreationDate"].max() - rows["CreationDate"].min()).days / 7)
two_classes = len(set(y)) == 2
return {"rows": int(len(rows)), "late": int(y.sum()),
"auc": round(float(roc_auc_score(y, scores)), 3) if two_classes else None,
"avg_precision": round(float(average_precision_score(y, scores)), 3) if two_classes else None,
"flagged": int(tp + fp), "caught": int(tp),
"recall": round(tp / max(tp + fn, 1), 3), "precision": round(tp / max(tp + fp, 1), 3),
"flags_per_week": round((tp + fp) / weeks, 1),
"cost": cost_of(y, flagged, cost_miss, cost_check),
"cost_flag_nothing": float(y.sum() * cost_miss)}
def train_model(df: pd.DataFrame, as_of: pd.Timestamp, cost_miss=250.0, cost_check=50.0, quiet=False):
"""Compare two models on time-ordered folds, test once on the newest period, then refit on everything known."""
say = (lambda *a: None) if quiet else print
known = known_as_of(df, as_of).sort_values("CreationDate")
if len(known) < MIN_KNOWN or known["Late"].nunique() < 2:
sys.exit(f"Only {len(known)} deliveries with a known outcome on {as_of.date()}: too few to train "
f"(this project wants {MIN_KNOWN}). Use --sample data or a larger extract.")
cutoff = known["CreationDate"].quantile(0.75).normalize()
train = known[known["ActualGoodsMovementDate"] < cutoff]
test = known[known["CreationDate"] >= cutoff]
say(f"As of {as_of.date()}: {len(known)} deliveries with a known outcome. "
f"Test split at {cutoff.date()}: train {len(train)}, test {len(test)}.")
folds = TimeSeriesSplit(n_splits=5)
candidates = {
"logistic_regression": make_pipeline(preprocessor(), LogisticRegression(max_iter=1000)),
"gradient_boosting": make_pipeline(preprocessor(), HistGradientBoostingClassifier(max_depth=3, random_state=0)),
}
cv = {name: float(cross_val_score(m, train[FEATURES], train["Late"], cv=folds,
scoring="average_precision").mean()) for name, m in candidates.items()}
best = max(cv, key=cv.get)
say(" Average precision on 5 time-ordered folds: "
+ ", ".join(f"{n} {s:.2f}" for n, s in cv.items()) + f". Chosen: {best}")
scorer = make_scorer(neg_cost, cost_miss=cost_miss, cost_check=cost_check)
tested = TunedThresholdClassifierCV(clone(candidates[best]), scoring=scorer, cv=folds)
tested.fit(train[FEATURES], train["Late"])
test_result = judge(tested, test, cost_miss, cost_check)
say(f" Test ({test_result['rows']} deliveries, {test_result['late']} late): AUC {test_result['auc']}, "
f"recall {test_result['recall']}, precision {test_result['precision']}, "
f"{test_result['flags_per_week']} flags/week, cost {test_result['cost']:,.0f} EUR "
f"vs {test_result['cost_flag_nothing']:,.0f} EUR flagging nothing")
# The model that goes live learns from every known outcome, including the test period.
final = TunedThresholdClassifierCV(clone(candidates[best]), scoring=scorer, cv=folds)
final.fit(known[FEATURES], known["Late"])
explainer = make_pipeline(preprocessor(), LogisticRegression(max_iter=1000)).fit(known[FEATURES], known["Late"])
say(f" Final model refit on all {len(known)} known deliveries. Threshold {final.best_threshold_:.2f}")
recent = known[known["CreationDate"] > as_of - pd.Timedelta(days=90)]
card = {
"version": f"m{as_of:%Y%m%d}", "as_of": str(as_of.date()), "model_type": best,
"threshold": round(float(final.best_threshold_), 3),
"costs": {"miss": cost_miss, "check": cost_check},
"rows": {"known": int(len(known)), "train": int(len(train)), "test": int(len(test))},
"cv_avg_precision": {n: round(s, 3) for n, s in cv.items()},
"test": test_result,
"reference": { # what "normal" looked like in the last 90 days of training data; monitoring compares to it
"from": str((as_of - pd.Timedelta(days=90)).date()),
"late_share": round(float(recent["Late"].mean()), 3),
"late_share_by_route": {r: round(float(g["Late"].mean()), 3)
for r, g in recent.groupby("ProposedDeliveryRoute") if len(g) >= 20},
"categories": {c: sorted(known[c].astype(str).unique()) for c in CATEGORICAL},
},
"features": FEATURES,
"sklearn_version": sklearn.__version__,
}
return {"model": final, "explainer": explainer}, card
def save(bundle: dict, card: dict, source: dict, trained_by: str) -> Path:
MODELS.mkdir(exist_ok=True)
path = MODELS / f"{card['version']}.joblib"
joblib.dump(bundle, path)
card = {**card, "trained_by": trained_by, "trained_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"data_source": source["source"], "sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
(MODELS / f"{card['version']}.json").write_text(json.dumps(card, indent=1), encoding="utf-8")
return path
# ---------------------------------------------------------------- loading, scoring, explaining
def load_card(version: str) -> dict:
path = MODELS / f"{version}.json"
if not path.exists():
sys.exit(f"No model card {path.name}. Run: python projects/late-delivery/late_model.py list")
return json.loads(path.read_text(encoding="utf-8"))
def active_version() -> str:
path = MODELS / "active.json"
if not path.exists():
sys.exit("No model is live yet. Train one, then promote it with a second person's name (Stage 2).")
return json.loads(path.read_text(encoding="utf-8"))["version"]
def load_model(version: str | None = None):
"""Load only a file whose fingerprint matches its card, trained with this scikit-learn version."""
card = load_card(version or active_version())
path = MODELS / f"{card['version']}.joblib"
if hashlib.sha256(path.read_bytes()).hexdigest() != card["sha256"]:
sys.exit(f"{path.name} does not match the fingerprint in its card. Refusing to load it.")
if card["sklearn_version"] != sklearn.__version__:
sys.exit(f"{path.name} was trained with scikit-learn {card['sklearn_version']}, this is {sklearn.__version__}. "
"Retrain it here, or install the same version.")
with warnings.catch_warnings():
warnings.simplefilter("error", InconsistentVersionWarning) # a version mismatch stops the load
bundle = joblib.load(path)
return bundle, card
def score(bundle: dict, card: dict, rows: pd.DataFrame) -> pd.DataFrame:
"""Score, flag and give the two strongest reasons per delivery."""
out = rows[["DeliveryDocument"]].copy()
out["score"] = bundle["model"].predict_proba(rows[FEATURES])[:, 1].round(3)
out["flag"] = out["score"] >= card["threshold"]
pre, lr = bundle["explainer"][0], bundle["explainer"][-1]
contributions = pre.transform(rows[FEATURES]) * lr.coef_[0]
names = [n.split("__", 1)[1] for n in pre.get_feature_names_out()]
reasons = []
for row in contributions:
top = [i for i in np.argsort(row)[::-1][:2] if row[i] > 0.05]
reasons.append([readable(names[i]) for i in top])
out["reasons"] = reasons
return out
def readable(name: str) -> str:
if name in NUMERIC:
return {"PlannedLeadDays": "short lead time"}.get(name, f"high {LABELS[name]}")
column, value = next((c, name[len(c) + 1:]) for c in CATEGORICAL if name.startswith(c + "_"))
return f"{LABELS[column]} {value}"
def md_table(df: pd.DataFrame) -> str:
"""A DataFrame as a Markdown table, without extra libraries."""
lines = ["| " + " | ".join(map(str, df.columns)) + " |", "|" + " --- |" * len(df.columns)]
lines += ["| " + " | ".join(map(str, row)) + " |" for row in df.itertuples(index=False)]
return "\n".join(lines)
# ---------------------------------------------------------------- promotion: a second person makes a model live
def promote(version: str, approved_by: str) -> None:
card = load_card(version)
load_model(version) # fingerprint and version checks
if approved_by.strip().lower() == card["trained_by"].strip().lower():
sys.exit(f"Refused: {approved_by} trained {version}. A second person must approve it.")
test = card["test"]
if test["cost"] >= test["cost_flag_nothing"]:
sys.exit(f"Refused: on its test period {version} costs {test['cost']:,.0f} EUR, "
f"not less than flagging nothing ({test['cost_flag_nothing']:,.0f} EUR).")
active = MODELS / "active.json"
previous = json.loads(active.read_text(encoding="utf-8"))["version"] if active.exists() else None
record = {"version": version, "previous": previous, "approved_by": approved_by,
"approved_at": datetime.now(timezone.utc).isoformat(timespec="seconds")}
active.write_text(json.dumps(record, indent=1), encoding="utf-8")
with open(MODELS / "promotions.jsonl", "a", encoding="utf-8") as f:
f.write(json.dumps(record) + "\n")
print(f"{version} is live, approved by {approved_by}. Previous: {previous or 'none'}.")
def main() -> None:
parser = argparse.ArgumentParser(description="Stage 2: train, promote and list late-delivery models.")
sub = parser.add_subparsers(dest="command", required=True)
t = sub.add_parser("train", help="train a candidate model")
t.add_argument("--as-of", help="train as if today were this date (YYYY-MM-DD); default: the data's date")
t.add_argument("--by", required=True, help="your name, recorded in the model card")
t.add_argument("--cost-miss", type=float, default=250.0, help="EUR lost when a late delivery is not flagged")
t.add_argument("--cost-check", type=float, default=50.0, help="EUR to check and act on one flag")
p = sub.add_parser("promote", help="make a trained model live")
p.add_argument("version")
p.add_argument("--approved-by", required=True, help="the approver; must not be the person who trained it")
sub.add_parser("list", help="show trained models")
args = parser.parse_args()
if args.command == "train":
table, data_as_of, source = load_deliveries()
as_of = pd.Timestamp(args.as_of) if args.as_of else data_as_of
bundle, card = train_model(add_features(table), as_of, args.cost_miss, args.cost_check)
path = save(bundle, card, source, args.by)
print(f"Saved candidate {card['version']} to models/{path.name} with its card. It is not live yet.")
elif args.command == "promote":
promote(args.version, args.approved_by)
else:
live = (MODELS / "active.json").exists() and active_version()
cards = sorted(MODELS.glob("m*.json")) if MODELS.exists() else []
if not cards:
print("No models yet. Train one first.")
for path in cards:
c = json.loads(path.read_text(encoding="utf-8"))
mark = "LIVE" if c["version"] == live else " "
print(f"{mark} {c['version']} as of {c['as_of']} {c['model_type']:<20} threshold {c['threshold']:.2f} "
f"test recall {c['test']['recall']:.2f} trained by {c['trained_by']}")
if __name__ == "__main__":
import late_model # run the imported copy, so saved models point to late_model.neg_cost, not __main__
late_model.main()
python projects/late-delivery/late_model.py train --by alex
What success looks like (it takes a few seconds):
As of 2026-10-09: 3254 deliveries with a known outcome. Test split at 2026-07-04: train 2396, test 815.
Average precision on 5 time-ordered folds: logistic_regression 0.44, gradient_boosting 0.39. Chosen: logistic_regression
Test (815 deliveries, 128 late): AUC 0.804, recall 0.406, precision 0.423, 9.0 flags/week, cost 25,150 EUR vs 32,000 EUR flagging nothing
Final model refit on all 3254 known deliveries. Threshold 0.19
Saved candidate m20261009 to models/m20261009.joblib with its card. It is not live yet.
Read it like Unit 2's output. The test period starts on 4 July and is harder than training: it holds the slow route. Logistic regression wins again. At the cost-based threshold, the recipe caught 41% of late deliveries with about 9 flags a week, and cost 25,150 EUR against 32,000 EUR for flagging nothing. The final model then learned from all 3,254 known outcomes; its threshold is 0.19.
Open models/m20261009.json. Find trained_by, test, reference and sha256. That is the model card.
#Step 3: Promote it, the wrong way and the right way
Try to approve your own model:
python projects/late-delivery/late_model.py promote m20261009 --approved-by alex
Refused: alex trained m20261009. A second person must approve it.
Now with a second person's name. On a team, that person runs this command after reading the card:
python projects/late-delivery/late_model.py promote m20261009 --approved-by sam
m20261009 is live, approved by sam. Previous: none.
List the models:
python projects/late-delivery/late_model.py list
LIVE m20261009 as of 2026-10-09 logistic_regression threshold 0.19 test recall 0.41 trained by alex
Promotion also refuses a model whose test cost is not below "flag nothing". Every promotion is appended to models/promotions.jsonl. A rollback is the same command with the previous version.
The API puts the model behind a web address, so the worklist page, a Joule agent or another system can use it. It also does what a script can't: it filters by the user's shipping points, it logs every prediction and decision for monitoring and the KPI, and it refuses fields the model must never see.
sequenceDiagram
participant P as Planner's browser
participant A as app.py
participant L as events.jsonl
participant X as expedite_requests.csv
Note over A,L: start: score open deliveries, log "scored" once per model
P->>A: GET /api/worklist (X-User: omar)
A-->>P: omar's deliveries, scores, reasons
P->>A: POST /api/decisions (expedite)
A->>A: planner? own shipping point? not decided yet?
A->>L: decision event with cost
A->>X: one line for SAP
A-->>P: recorded
The endpoints:
Endpoint
Who
What it does
GET /health
anyone
Live model version, threshold, data date, open deliveries
GET /api/worklist
any user
Open deliveries in the user's shipping points, highest risk first; holdout deliveries without a score
POST /api/score
any user
Scores one delivery from its creation-time fields; any other field is refused
POST /api/decisions
planners, own shipping points
Records expedite or no action once per delivery; expedites go to exports/expedite_requests.csv
Three demo users stand in for real roles: pia (planner, shipping points 1010 and 1020), omar (planner, 1710) and vic (viewer, all three).
The holdout.in_holdout puts a fixed 20% of deliveries in the holdout, chosen from a fingerprint of the delivery number, so a delivery stays in or out across restarts. Holdout deliveries are scored and logged, but planners see no score, flag or reason. Stage 7 compares them with flagged deliveries that planners saw.
"""Contract tests for the API: the business rules, not only the code.
How to run (from your orchestrate-course folder, after Stages 1 and 2):
python projects/late-delivery/test_app.py
"""
import os
import sys
import tempfile
import warnings
from pathlib import Path
TMP = Path(tempfile.mkdtemp())
os.environ["LOG_DIR"], os.environ["EXPORT_DIR"] = str(TMP / "logs"), str(TMP / "exports")
sys.path.insert(0, str(Path(__file__).parent))
warnings.filterwarnings("ignore", message=".*httpx.*") # a library notice, not a test result
from fastapi.testclient import TestClient # noqa: E402
from app import create_app # noqa: E402
client = TestClient(create_app(holdout_share=0.2))
results = []
def check(name: str, ok: bool) -> None:
results.append(ok)
print(f" {'PASS' if ok else 'FAIL'} {name}")
def worklist(user: str, **params):
return client.get("/api/worklist", headers={"X-User": user}, params=params).json()["deliveries"]
def first(user: str, holdout: bool | None = None):
for d in worklist(user):
if holdout is None or (d["score"] is None) == holdout:
if d["decision"] is None:
return d
return None
DELIVERY = {"DeliveryDocument": "90000001", "ShippingPoint": "1710", "ShippingType": "01", "ShippingCondition": "01",
"DeliveryPriority": "02", "ProposedDeliveryRoute": "R17102", "CreationDate": "2026-10-05",
"PlannedGoodsIssueDate": "2026-10-09", "HeaderGrossWeight": 420.0, "Items": 4, "TotalQuantity": 60}
print("Contract tests")
health = client.get("/health").json()
check("health names the live model and the data date", health["status"] == "ok" and health["model"].startswith("m"))
check("an unknown user gets 401", client.get("/api/worklist", headers={"X-User": "eve"}).status_code == 401)
check("pia sees only shipping points 1010 and 1020",
{d["shipping_point"] for d in worklist("pia")} <= {"1010", "1020"})
check("omar sees only shipping point 1710", {d["shipping_point"] for d in worklist("omar")} <= {"1710"})
check("holdout deliveries show no score, flag or reasons",
all(d["score"] is None and not d["flag"] and not d["reasons"] for d in worklist("vic") if d["score"] is None))
check("'only flagged' returns flagged deliveries only", all(d["flag"] for d in worklist("vic", only_flagged=True)))
scored = client.post("/api/score", headers={"X-User": "vic"}, json=DELIVERY)
check("score returns a score between 0 and 1", scored.status_code == 200 and 0 <= scored.json()["score"] <= 1)
leaky = {**DELIVERY, "ActualGoodsMovementDate": "2026-10-12"}
check("score refuses a field filled after goods issue (422)",
client.post("/api/score", headers={"X-User": "vic"}, json=leaky).status_code == 422)
backwards = {**DELIVERY, "PlannedGoodsIssueDate": "2026-10-01"}
check("score refuses a planned date before creation (422)",
client.post("/api/score", headers={"X-User": "vic"}, json=backwards).status_code == 422)
target = first("omar")
decide = lambda user, delivery, action="expedite": client.post( # noqa: E731
"/api/decisions", headers={"X-User": user}, json={"delivery": delivery, "action": action, "note": "test"})
check("a viewer cannot record a decision (403)", decide("vic", target["delivery"]).status_code == 403)
check("a planner cannot decide for another shipping point (403)", decide("pia", target["delivery"]).status_code == 403)
check("an unknown delivery gets 404", decide("omar", "99999999").status_code == 404)
check("the responsible planner can expedite", decide("omar", target["delivery"]).status_code == 200)
check("a delivery is decided only once (409)", decide("omar", target["delivery"], "no_action").status_code == 409)
exports = TMP / "exports" / "expedite_requests.csv"
check("the expedite goes to a file for SAP, not to SAP", exports.exists() and target["delivery"] in exports.read_text())
print(f"\n{sum(results)} of {len(results)} passed")
sys.exit(0 if all(results) else 1)
Run it:
python projects/late-delivery/test_app.py
What success looks like:
Contract tests
PASS health names the live model and the data date
PASS an unknown user gets 401
PASS pia sees only shipping points 1010 and 1020
PASS omar sees only shipping point 1710
PASS holdout deliveries show no score, flag or reasons
PASS 'only flagged' returns flagged deliveries only
PASS score returns a score between 0 and 1
PASS score refuses a field filled after goods issue (422)
PASS score refuses a planned date before creation (422)
PASS a viewer cannot record a decision (403)
PASS a planner cannot decide for another shipping point (403)
PASS an unknown delivery gets 404
PASS the responsible planner can expedite
PASS a delivery is decided only once (409)
PASS the expedite goes to a file for SAP, not to SAP
15 of 15 passed
Most of these tests guard business rules: who sees what, who may decide, one decision per delivery, the leak guard, and the fact that expedites become a file, not an SAP change. Keep them passing after every change. The tests write their logs to a temporary folder, so they don't touch your real log.
Serving the worklist on http://127.0.0.1:8000
INFO: Started server process [12345]
...
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
The terminal is now busy running the server. Leave it open.
Open a second terminal (Terminal > New Terminal), turn on .venv again, and ask for Pia's flagged deliveries:
{"delivery":"90000001","model":"m20261009","score":0.299,"flag":true,"reasons":["goods issue on Fri","shipping point 1710"]}
Now add "ActualGoodsMovementDate":"2026-10-12", after the first { in the body and send it again. The answer is status 422 with extra_forbidden: the API refuses the field that leaked the answer in Unit 2. In PowerShell, the 422 shows as a red error message; that is the expected result.
Open http://127.0.0.1:8000/docs in a browser. FastAPI lists every endpoint with its fields. You can try them there with Try it out.
Look at projects/late-delivery/logs/events.jsonl. It starts with one scored line per open delivery, for example:
The simplest thing a planner can try is a web page. app.py serves one at /: a user picker, the worklist and two buttons per delivery. It is plain HTML and a little JavaScript, with no build step.
Set User to omar. The line under the buttons says Model m20261009, data as of 2026-10-09, 10 deliveries for omar (planner).
Read the table. Four deliveries show a red score with CHECK. Delivery 80003236 also shows OVERDUE next to its planned date: the date has passed without a goods issue, so it is already late whatever the score says. Some rows show - instead of a score: they are in the holdout.
Choose Expedite on the top delivery, 80003279. Type what you would do in SAP, for example move to earlier truck, and choose OK. The buttons are replaced by expedite.
Open projects/late-delivery/exports/expedite_requests.csv. It has one line with the delivery, the shipping point, the planned date, who asked, the note and the time. That file is the planner's to-do list in SAP.
Set User to pia. You see only shipping points 1010 and 1020.
Set User to vic, the viewer. You see all three shipping points. Choose No action on any delivery and type a reason. An alert says Only planners record decisions.
Tick Only flagged to see only the flagged deliveries.
Make the browser window narrow. The route column disappears on small screens and the rest stays usable. Planners often check the list on a tablet on the warehouse floor; check it before a demo.
The model travels as a file, with the same library versions. The app loads models/m20261009.joblib and refuses it if the scikit-learn version differs. So requirements.txt pins exactly the versions you trained with, and the models/ and data/ folders are uploaded with the app.
Logs on a container are temporary. Files written inside a Cloud Foundry container don't survive a restart, and each instance has its own. For a demo on the trial that is acceptable; for real use, events go to a database.
The Cloud Foundry Python buildpack has no default start command, and its documentation says the app should listen on the port in PORT. app.py reads PORT and then listens on all addresses.
Stage 2's test answers "how good was this recipe on one past period?". A live model raises three more questions, and this stage answers each with a script:
Question
Script
Answer in the sample
How would it have done if it had run in the last months? Is retraining worth it?
evaluate.py (backtest)
Monthly retraining caught 64 late deliveries instead of 47
Is the live model still behaving as tested?
monitor.py (weekly)
A model frozen in June raises three alarms by late September
What do we do when it isn't?
The retraining plan below
Train, backtest, approve, with a rollback ready
flowchart LR
W[Weekly: monitor.py] -->|no alarm| W
W -->|alarm| T[Train candidate<br/>late_model.py train]
S[Monthly schedule] --> T
T --> E[Backtest<br/>evaluate.py]
E --> R{Second person<br/>reviews card and report}
R -->|approve| P[promote]
R -->|reject| K[Keep live model]
P --> W
P -.->|problem| B[Roll back:<br/>promote previous]
The backtest replays July, August and September twice. The frozen model is trained as of 30 June and used for all three months. The retrained model is trained at the end of each month, on what was known then, and used for the next month. Both are judged on the same deliveries.
Create evaluate.py in the project folder, paste this and save:
"""Stage 6a: backtest. Replay the last months as if you had run the model live, two ways:
a model frozen at the start, and a model retrained at the end of every month on what was known then.
How to run (from your orchestrate-course folder):
python projects/late-delivery/evaluate.py # the last 3 complete months
python projects/late-delivery/evaluate.py --months 4
"""
import argparse
from pathlib import Path
import pandas as pd
from late_model import add_features, judge, md_table, train_model
from sap_data import load_deliveries
HERE = Path(__file__).parent
def main() -> None:
parser = argparse.ArgumentParser(description="Backtest: frozen model vs monthly retraining.")
parser.add_argument("--months", type=int, default=3, help="how many complete months to replay")
parser.add_argument("--cost-miss", type=float, default=250.0)
parser.add_argument("--cost-check", type=float, default=50.0)
args = parser.parse_args()
table, as_of, source = load_deliveries()
df = add_features(table)
last_complete = as_of.to_period("M") - 1
months = [last_complete - i for i in range(args.months - 1, -1, -1)]
origins = [(m - 1).to_timestamp(how="end").normalize() for m in months] # last day of the month before
print(f"Backtest on {source['source']} data as of {as_of.date()}: months {months[0]} to {months[-1]}.")
print(f"Frozen model trained as of {origins[0].date()}; retrained model trained at the end of each prior month.\n")
frozen, frozen_card = train_model(df, origins[0], args.cost_miss, args.cost_check, quiet=True)
rows, totals = [], {"frozen": [], "retrained": [], "nothing": 0.0}
for month, origin in zip(months, origins):
retrained, card = (frozen, frozen_card) if origin == origins[0] else \
train_model(df, origin, args.cost_miss, args.cost_check, quiet=True)
created = df["CreationDate"].dt.to_period("M") == month
test = df[created & (df["ActualGoodsMovementDate"] <= as_of)].copy() # outcome known by today
test["Late"] = test["Late"].astype(int)
for name, bundle in (("frozen", frozen), ("retrained", retrained)):
r = judge(bundle["model"], test, args.cost_miss, args.cost_check)
totals[name].append(r)
rows.append({"month": str(month), "model": name, "trained_as_of": str((origins[0] if name == "frozen" else origin).date()),
"deliveries": r["rows"], "late": r["late"], "auc": r["auc"], "recall": r["recall"],
"precision": r["precision"], "flags/week": r["flags_per_week"],
"cost EUR": int(r["cost"]), "flag nothing EUR": int(r["cost_flag_nothing"])})
totals["nothing"] += totals["frozen"][-1]["cost_flag_nothing"]
report = pd.DataFrame(rows)
print(report.to_string(index=False))
summary = []
for name in ("frozen", "retrained"):
cost = sum(r["cost"] for r in totals[name])
caught, late = sum(r["caught"] for r in totals[name]), sum(r["late"] for r in totals[name])
summary.append(f"{name:<10} caught {caught} of {late} late, cost {cost:,.0f} EUR "
f"({1 - cost / totals['nothing']:.0%} less than flagging nothing: {totals['nothing']:,.0f} EUR)")
print("\nOver all months:\n " + "\n ".join(summary))
out = HERE / "reports" / "backtest.md"
out.parent.mkdir(exist_ok=True)
out.write_text(f"# Backtest: frozen vs monthly retraining\n\nData: {source['source']}, as of {as_of.date()}. "
f"Costs: miss {args.cost_miss:.0f} EUR, check {args.cost_check:.0f} EUR.\n\n"
+ md_table(report) + "\n\n" + "\n".join(f"- {s}" for s in summary) + "\n",
encoding="utf-8")
print(f"\nSaved reports/{out.name}")
if __name__ == "__main__":
main()
Run it (it trains three models, so it takes about 15 seconds):
python projects/late-delivery/evaluate.py
What success looks like:
Backtest on sample data as of 2026-10-09: months 2026-07 to 2026-09.
Frozen model trained as of 2026-06-30; retrained model trained at the end of each prior month.
month model trained_as_of deliveries late auc recall precision flags/week cost EUR flag nothing EUR
2026-07 frozen 2026-06-30 281 41 0.791 0.317 0.361 8.4 8800 10250
2026-07 retrained 2026-06-30 281 41 0.791 0.317 0.361 8.4 8800 10250
2026-08 frozen 2026-06-30 276 43 0.809 0.419 0.474 8.9 8150 10750
2026-08 retrained 2026-07-31 276 43 0.814 0.488 0.382 12.8 8250 10750
2026-09 frozen 2026-06-30 258 44 0.817 0.364 0.571 6.8 8400 11000
2026-09 retrained 2026-08-31 258 44 0.827 0.682 0.455 15.9 6800 11000
Over all months:
frozen caught 47 of 128 late, cost 25,350 EUR (21% less than flagging nothing: 32,000 EUR)
retrained caught 64 of 128 late, cost 23,850 EUR (25% less than flagging nothing: 32,000 EUR)
Saved reports/backtest.md
Read it:
July is identical for both, because both models were trained on 30 June.
In September the frozen model caught 16 of 44 late deliveries (recall 0.36); the retrained one caught 30 (0.68). The slow route started in mid-July. The model retrained on 31 August had seen six weeks of it; the frozen one had never seen it.
Retraining raised the flag volume from about 7 to 16 a week in September. That is the guardrail question for planners: can they handle 16 checks a week? The cost still fell, from 8,400 to 6,800 EUR.
Over three months, monthly retraining caught 64 instead of 47 late deliveries, and saved 25% of the "flag nothing" cost instead of 21%.
The report is saved as reports/backtest.md for your portfolio.
Monitoring runs every week on deliveries created after the model's training date and at least 14 days old, so most outcomes are known. It runs five kinds of checks against the model card:
Check
Alarm when
Why it matters
Recall vs test
More than 0.15 below the test recall
The model misses more late deliveries than promised
Flags per week vs test
Below half or above 1.5 times the test rate
Planners get too few or too many flags
Late share vs reference
More than 8 points above the last 90 days of training
The world got worse, and the threshold may be wrong
Late share per route
A route moves 15 points or more (8 deliveries or more)
A carrier or route changed
Codes unseen in training
Over 5% of deliveries
A new route or shipping point: handle_unknown="ignore" scores it as if it didn't exist, silently
The last check deserves a second look. In Unit 2, OneHotEncoder(handle_unknown="ignore") stopped a new route from crashing the scoring. The price is silence: the model treats a new route as "no route" and scores it like an average delivery. Only monitoring notices.
Create monitor.py, paste this and save:
"""Stage 6b: weekly monitoring. Compare a model's recent weeks with what was normal when it was trained,
and say whether it needs retraining.
How to run (from your orchestrate-course folder):
python projects/late-delivery/monitor.py # the live model
python projects/late-delivery/monitor.py --model m20260630 # any trained model
"""
import argparse
from pathlib import Path
import pandas as pd
from late_model import CATEGORICAL, active_version, add_features, judge, load_model, md_table
from sap_data import load_deliveries
HERE = Path(__file__).parent
MATURITY_DAYS = 14 # most deliveries ship within two weeks of creation; younger ones would bias the window
LIMITS = {"recall_drop": 0.15, "flag_ratio": (0.5, 1.5), "late_share_rise": 0.08, "route_shift": 0.15,
"unseen_share": 0.05, "min_route_rows": 8}
def main() -> None:
parser = argparse.ArgumentParser(description="Weekly monitoring of a late-delivery model.")
parser.add_argument("--model", help="model version; default: the live model")
parser.add_argument("--weeks", type=int, default=4, help="size of the window to check")
args = parser.parse_args()
table, as_of, source = load_deliveries()
bundle, card = load_model(args.model or active_version())
ref, test = card["reference"], card["test"]
end = as_of - pd.Timedelta(days=MATURITY_DAYS)
start = max(pd.Timestamp(card["as_of"]) + pd.Timedelta(days=1), end - pd.Timedelta(weeks=args.weeks))
df = add_features(table)
window = df[(df["CreationDate"] >= start) & (df["CreationDate"] <= end) & df["Late"].notna()].copy()
print(f"Monitoring {card['version']} (trained as of {card['as_of']}) on {source['source']} data as of {as_of.date()}.")
if start > end or len(window) < 50:
print(f"Only {len(window)} deliveries with a known outcome were created after this model was trained "
f"and are at least {MATURITY_DAYS} days old. Nothing to judge yet; run again next week.")
return
window["Late"] = window["Late"].astype(int)
print(f"Window: deliveries created {start.date()} to {end.date()}, outcome known: {len(window)}.\n")
checks = []
now = judge(bundle["model"], window, card["costs"]["miss"], card["costs"]["check"])
checks.append(("Recall vs test", f"{now['recall']:.2f} vs {test['recall']:.2f}",
now["recall"] < test["recall"] - LIMITS["recall_drop"]))
ratio = now["flags_per_week"] / max(test["flags_per_week"], 0.1)
checks.append(("Flags per week vs test", f"{now['flags_per_week']} vs {test['flags_per_week']}",
not LIMITS["flag_ratio"][0] <= ratio <= LIMITS["flag_ratio"][1]))
share = window["Late"].mean()
checks.append(("Late share vs reference", f"{share:.0%} vs {ref['late_share']:.0%}",
share > ref["late_share"] + LIMITS["late_share_rise"]))
for route, group in window.groupby("ProposedDeliveryRoute"):
if route in ref["late_share_by_route"] and len(group) >= LIMITS["min_route_rows"]:
before, after = ref["late_share_by_route"][route], group["Late"].mean()
if abs(after - before) >= LIMITS["route_shift"]:
checks.append((f"Route {route} late share", f"{after:.0%} vs {before:.0%} ({len(group)} deliveries)", True))
unseen = pd.Series(False, index=window.index)
new_codes = []
for column in CATEGORICAL:
is_new = ~window[column].astype(str).isin(ref["categories"][column])
unseen |= is_new
new_codes += [f"{column}={v}" for v in sorted(window.loc[is_new, column].astype(str).unique())]
checks.append(("Deliveries with codes unseen in training",
f"{unseen.mean():.0%}" + (f" ({', '.join(new_codes)})" if new_codes else ""),
unseen.mean() > LIMITS["unseen_share"]))
report = pd.DataFrame([{"check": c, "value": v, "status": "ALARM" if a else "ok"} for c, v, a in checks])
print(report.to_string(index=False))
alarms = [c for c, _, a in checks if a]
verdict = ("RETRAIN RECOMMENDED: " + "; ".join(alarms)) if alarms else "No alarms. Keep the model; check again next week."
print(f"\n{verdict}")
if alarms:
print("Plan: train a candidate as of today, run evaluate.py, then a second person promotes it if it is better.")
out = HERE / "reports" / f"monitor_{card['version']}.md"
out.parent.mkdir(exist_ok=True)
out.write_text(f"# Monitoring {card['version']}\n\nData as of {as_of.date()}; window {start.date()} to {end.date()}, "
f"{len(window)} deliveries.\n\n{md_table(report)}\n\n**{verdict}**\n", encoding="utf-8")
print(f"Saved reports/{out.name}")
if __name__ == "__main__":
main()
Pretend the model that went live on 1 July was trained on 30 June. It is now October.
Train it as of 30 June. Don't promote it:
python projects/late-delivery/late_model.py train --as-of 2026-06-30 --by alex
As of 2026-06-30: 2368 deliveries with a known outcome. Test split at 2026-04-23: train 1731, test 594.
Average precision on 5 time-ordered folds: logistic_regression 0.42, gradient_boosting 0.36. Chosen: logistic_regression
Test (594 deliveries, 84 late): AUC 0.82, recall 0.512, precision 0.443, 10.3 flags/week, cost 15,100 EUR vs 21,000 EUR flagging nothing
Final model refit on all 2368 known deliveries. Threshold 0.28
Saved candidate m20260630 to models/m20260630.joblib with its card. It is not live yet.
Monitoring m20260630 (trained as of 2026-06-30) on sample data as of 2026-10-09.
Window: deliveries created 2026-08-28 to 2026-09-25, outcome known: 245.
check value status
Recall vs test 0.32 vs 0.51 ALARM
Flags per week vs test 6.8 vs 10.3 ok
Late share vs reference 16% vs 14% ok
Route R17102 late share 88% vs 14% (8 deliveries) ALARM
Deliveries with codes unseen in training 8% (ProposedDeliveryRoute=R17103) ALARM
RETRAIN RECOMMENDED: Recall vs test; Route R17102 late share; Deliveries with codes unseen in training
Plan: train a candidate as of today, run evaluate.py, then a second person promotes it if it is better.
Saved reports/monitor_m20260630.md
Read it. Recall fell from 0.51 in testing to 0.32. Route R17102 was late 88% of the time in the window against 14% in the reference: only 8 deliveries, so check by hand, but the size of the jump is clear. And 8% of deliveries used route R17103, which the model has never seen. Three alarms, one recommendation: retrain.
Monitor the live model:
python projects/late-delivery/monitor.py
Monitoring m20261009 (trained as of 2026-10-09) on sample data as of 2026-10-09.
Only 0 deliveries with a known outcome were created after this model was trained and are at least 14 days old. Nothing to judge yet; run again next week.
That is a valid, empty result. The live model was trained on every known outcome; nothing newer exists yet. In production you would run this every Monday, for example as a scheduled job.
The KPI comes from the system's own log joined with outcomes from SAP, not from a survey.
KPI
Computed from
On-time goods issue, before and after
Outcomes in the data: 13 weeks before go-live, and every scored delivery since
Late share, flagged and shown vs flagged in holdout
scored events (flag, holdout) joined with outcomes
Late deliveries avoided (estimate)
(holdout late share − shown late share) × flagged deliveries shown
Expedite spend, and spend per late delivery avoided
decision events with their cost
Flags shown per week
scored events with a flag outside the holdout
The made-up data stops on 9 October, so there are no real outcomes after your clicks in Stage 4. To see the arithmetic now, --simulate replays July to September: the June model scores each delivery at creation, planners expedite 80% of the flags they see, and an expedite turns half of the truly late deliveries on time. Those two rates are assumptions written at the top of kpi.py. The simulated report shows how the KPI is computed, never what the worklist achieves.
Create kpi.py in the project folder, paste this and save:
"""Stage 7: the business KPI, computed from the app's own log joined with outcomes from SAP data.
How to run (from your orchestrate-course folder):
python projects/late-delivery/kpi.py --simulate # replay July to September with made-up planner actions
python projects/late-delivery/kpi.py # your real log (logs/events.jsonl) and data/deliveries.json
KPIs: on-time goods issue rate, and expedite spend per late delivery avoided.
The fair comparison is flagged deliveries shown to planners vs flagged deliveries in the holdout.
"""
import argparse
import json
from pathlib import Path
import numpy as np
import pandas as pd
from app import in_holdout
from late_model import add_features, md_table, score, train_model
from sap_data import load_deliveries
HERE = Path(__file__).parent
ASSUMED_SAVE_RATE = 0.5 # SIMULATION ONLY: share of truly late deliveries an expedite turns on time. Made up.
ASSUMED_ACTION_RATE = 0.8 # SIMULATION ONLY: share of shown flags a planner expedites. Made up.
def simulate(df: pd.DataFrame, as_of: pd.Timestamp, go_live: pd.Timestamp, holdout: float, cost: float) -> Path:
"""Write a made-up log: the model scores each delivery at creation, planners act, outcomes follow."""
bundle, card = train_model(df, go_live - pd.Timedelta(days=1), quiet=True)
period = df[(df["CreationDate"] >= go_live) & (df["CreationDate"] <= as_of - pd.Timedelta(days=14))
& df["Late"].notna()]
scored = score(bundle, card, period).merge(period[["DeliveryDocument", "Late", "ShippingPoint"]])
rng = np.random.default_rng(3)
events = []
for r in scored.itertuples():
hold = in_holdout(r.DeliveryDocument, holdout)
events.append({"event": "scored", "delivery": r.DeliveryDocument, "model": card["version"],
"score": float(r.score), "flag": bool(r.flag), "holdout": hold, "point": r.ShippingPoint})
late = bool(r.Late)
if r.flag and not hold and rng.random() < ASSUMED_ACTION_RATE:
events.append({"event": "decision", "delivery": r.DeliveryDocument, "action": "expedite", "cost": cost})
if late and rng.random() < ASSUMED_SAVE_RATE:
late = False
events.append({"event": "outcome", "delivery": r.DeliveryDocument, "late": late})
path = HERE / "logs" / "events_simulated.jsonl"
path.parent.mkdir(exist_ok=True)
path.write_text("".join(json.dumps(e) + "\n" for e in events), encoding="utf-8")
print(f"Wrote {len(scored)} simulated deliveries to logs/{path.name} (made up, for practice only).")
return path
def main() -> None:
parser = argparse.ArgumentParser(description="Stage 7: business KPI from the log.")
parser.add_argument("--simulate", action="store_true", help="replay July to September with made-up actions")
parser.add_argument("--go-live", default="2026-07-01", help="go-live date for --simulate")
parser.add_argument("--baseline-weeks", type=int, default=13, help="weeks before go-live used as 'before'")
parser.add_argument("--holdout", type=float, default=0.2)
parser.add_argument("--expedite-cost", type=float, default=50.0)
args = parser.parse_args()
table, as_of, _ = load_deliveries()
df = add_features(table)
if args.simulate:
go_live = pd.Timestamp(args.go_live)
path = simulate(df, as_of, go_live, args.holdout, args.expedite_cost)
else:
path = HERE / "logs" / "events.jsonl"
if not path.exists():
print("No events yet. Open the worklist (Stage 4) first, or run with --simulate.")
return
events = pd.DataFrame([json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()])
scored = events[events["event"] == "scored"].drop_duplicates("delivery")
scored = scored[[c for c in ("delivery", "model", "flag", "holdout", "data_as_of") if c in scored]].astype(
{"flag": bool, "holdout": bool})
decisions = events[events["event"] == "decision"] if "action" in events else events.iloc[0:0]
if args.simulate:
outcomes = events[events["event"] == "outcome"][["delivery", "late"]].astype({"late": bool})
else:
go_live = pd.Timestamp(scored["data_as_of"].min())
known = df[df["Late"].notna()]
outcomes = pd.DataFrame({"delivery": known["DeliveryDocument"], "late": known["Late"].astype(bool)})
rows = scored.merge(outcomes, on="delivery") # only deliveries whose goods issue is posted
if rows.empty:
print(f"{len(scored)} deliveries scored, but none has a goods issue yet. Refresh the data with "
"sap_data.py after they ship, then run this again.")
return
shown = rows[rows["flag"] & ~rows["holdout"]]
held = rows[rows["flag"] & rows["holdout"]]
late_shown, late_held = shown["late"].mean(), held["late"].mean()
avoided = max(0.0, (late_held - late_shown) * len(shown)) if len(held) else float("nan")
expedites = decisions[decisions["action"] == "expedite"] if len(decisions) else decisions
spend = float(expedites["cost"].sum()) if len(expedites) else 0.0
before = df[(df["CreationDate"] < go_live) & (df["CreationDate"] >= go_live - pd.Timedelta(weeks=args.baseline_weeks))
& df["Late"].notna()]
created = df.set_index("DeliveryDocument")["CreationDate"].reindex(rows["delivery"])
weeks = max(1.0, (created.max() - created.min()).days / 7)
kpis = pd.DataFrame([
("Deliveries scored, outcome known", f"{len(rows)}"),
("Flagged and shown / flagged in holdout", f"{len(shown)} / {len(held)}"),
("Late share, flagged and shown", f"{late_shown:.0%}"),
("Late share, flagged in holdout", f"{late_held:.0%}" if len(held) else "n/a"),
("Late deliveries avoided (estimate)", f"{avoided:.0f}" if avoided == avoided else "n/a"),
("Expedites recorded / spend", f"{len(expedites)} / {spend:,.0f} EUR"),
("Spend per late delivery avoided", f"{spend / avoided:,.0f} EUR" if avoided and avoided == avoided else "n/a"),
("On-time goods issue, before (baseline)", f"{1 - before['Late'].mean():.1%} ({len(before)} deliveries)"),
("On-time goods issue, after go-live", f"{1 - rows['late'].mean():.1%} ({len(rows)} deliveries)"),
("Flags shown per week", f"{len(shown) / weeks:.1f}"),
], columns=["KPI", "value"])
label = "SIMULATED (made-up actions and effects)" if args.simulate else "from logs/events.jsonl"
print(f"\nKPIs {label}\n" + kpis.to_string(index=False, header=False))
print("\nRead the holdout rows, not before/after: before/after also moves with routes, carriers and season.")
name = "kpi_report_simulated.md" if args.simulate else "kpi_report.md"
out = HERE / "reports" / name
out.parent.mkdir(exist_ok=True)
out.write_text(f"# Late delivery KPI ({label})\n\n{md_table(kpis)}\n", encoding="utf-8")
print(f"Saved reports/{name}")
if __name__ == "__main__":
main()
Wrote 776 simulated deliveries to logs/events_simulated.jsonl (made up, for practice only).
KPIs SIMULATED (made-up actions and effects)
Deliveries scored, outcome known 776
Flagged and shown / flagged in holdout 76 / 22
Late share, flagged and shown 21%
Late share, flagged in holdout 41%
Late deliveries avoided (estimate) 15
Expedites recorded / spend 65 / 3,250 EUR
Spend per late delivery avoided 215 EUR
On-time goods issue, before (baseline) 86.4% (814 deliveries)
On-time goods issue, after go-live 86.7% (776 deliveries)
Flags shown per week 6.2
Read the holdout rows, not before/after: before/after also moves with routes, carriers and season.
Saved reports/kpi_report_simulated.md
Read it:
The before-and-after line barely moves: 86.4% on time before, 86.7% after. Read alone, it says the worklist did nothing.
The holdout line tells the real story. Flagged deliveries that planners saw were late 21% of the time; flagged ones in the holdout, 41%. About 15 late deliveries avoided. The slow route made the whole period worse, which hid the effect in the before-and-after comparison.
Spend per late delivery avoided: 215 EUR, against 250 EUR for a late delivery. In this simulation, expediting pays, but only just. If planners expedited every flag, or the save rate were lower, it wouldn't.
Only 22 flagged deliveries are in the holdout. One more or one fewer late delivery among them moves their late share by about 5 points. Treat the estimate as a range, and let the holdout run long enough to narrow it.
46 deliveries scored, but none has a goods issue yet. Refresh the data with sap_data.py after they ship, then run this again.
That is the honest answer for the sample: the open deliveries haven't shipped. With real SAP data, you rerun sap_data.py every week; as goods issues are posted, the same command fills the table from logs/events.jsonl. Use --baseline-weeks to choose how many weeks before go-live count as "before".
The two made-up assumptions of the simulation, in capitals so nobody misses them
simulate
Trains the June model, scores July to September at creation, applies the holdout, writes scored, decision and outcome events to logs/events_simulated.jsonl
Outcomes
Real mode: posted goods issues from the data. Simulated mode: the outcome events
Groups
Flagged and shown vs flagged in holdout; only deliveries whose outcome is known
KPI table
Printed and saved as reports/kpi_report.md or reports/kpi_report_simulated.md
Three decisions in this system matter, and the system makes none of them.
Changing a delivery in SAP. Moving a delivery to another truck or route changes SAP data. The worklist only records the planner's decision and adds a line to exports/expedite_requests.csv. The planner makes the change in SAP with their own authorizations.
Deciding for a delivery. Only a planner may record a decision, only for their own shipping points, and only once.
Putting a model live. Training creates a candidate; a second person promotes it.
How it is enforced in code, not in a policy document:
No write path to SAP exists.sap_data.py calls SAP only with requests.get, and no other file talks to SAP. There is no SAP user or key with write rights anywhere in the project.
decide checks role, shipping point and earlier decisions before it logs anything, and answers 403 or 409 otherwise. Four contract tests prove it.
promote refuses the trainer as approver, and a model that isn't cheaper than flagging nothing. Every promotion is logged with who and when.
load_model refuses a model file that doesn't match its card, so a model file changed after training is refused. The card sits next to the file, so in production keep the fingerprint where the trainer can't change it, such as in the approval record.
Replace the X-User header and the USERS dictionary with real sign-in, such as the identity service on SAP BTP sketched in Unit 6. Roles and shipping points come from the token, and should mirror the planners' authorizations in SAP.
Give the approver of promote a real identity too. A command-line name is a demo; a pull request approval or a sign-in to a small admin page is a real control.
Use a read-only communication user for the extraction, limited to outbound deliveries.
Extract changed deliveries only, with $filter on a date field, and keep each extract with its date so a training run can be repeated. Check the filter syntax your system accepts on the API reference.
Check the extraction against SAP's API policy. Version 4.2026a sets per-API limits and preconditions for bulk extraction and replication, and allows large-scale extraction or replication only through SAP-endorsed pathways. Ask your SAP account team whether a weekly delta of delivery history fits, and record the answer.
Snapshot features at prediction time. Today's record of an old delivery shows its final state. Planned dates can be rescheduled after creation, and SAP Learning's scheduling lesson describes forward scheduling that confirms new dates. Train on the features the API logged when it scored, not on today's record.
Move the event log, decisions and model registry into a database. Container files vanish on restart.
Run monitor.py every Monday and the retraining once a month as scheduled jobs, and send alarms to the model owner. Running AI on SAP BTP in production covers the platform side.
Watch the API like any service: latency, errors, and the number of open deliveries scored each day. A sudden drop usually means the extraction failed.
Read through released APIs, keep the model side by side, and change nothing in S/4HANA. Expedites go back through the planners' normal apps. Before building, check SAP's own routes, as of October 2026:
Embedded: Predicted Delivery Delay. SAP's asset on SAP S/4HANA Predictive Delivery Delays describes machine learning that anticipates delays in order deliveries. NTT DATA, an SAP partner, describes the Predicted Delivery Delay app: for each sales order item it shows the predicted delay in days for delivery creation and processing, with about 130 standard input fields that can be narrowed during training. That is a different prediction moment from this project, which predicts at delivery creation. The description comes from a partner; confirm scope in your release's documentation.
ISLM trains, checks and activates embedded scenarios. SAP's overview lists training with customer data, analyzing the quality of the trained model, and an explicit activation step that picks which trained model the application uses. That is the same discipline as train and promote here.
SAP AI Core serves your own model. SAP's tutorial deploys a small Python model with a ServingTemplate that defines a KServe inference service, a Docker image with a Python server, and the model file copied to /mnt/models. The model can be swapped without changing the deployment URL; a stopped deployment isn't charged but can't be restarted. SAP's Architecture Center recommends AI Core for custom models and MLOps-heavy work, and lists retraining pipelines, model versions and A/B testing, with monitoring in SAP AI Launchpad.
SAP-RPT-1 predicts from example rows without training, and SAP's Architecture Center names it the default for classification and regression, with limits on context size and GPU latency. Tabular AI and SAP's predictive models shows how to test it fairly. The backtest from Stage 6 works for it too: send the rows known on each origin date as context.
SAP HANA Cloud PAL and APL score inside the database, which the Architecture Center suggests when latency or data residency rules out the alternatives.
Need
Build (this project)
SAP
Delay per sales order item inside S/4HANA apps
Not needed
Predicted Delivery Delay, if your release has it
Your own definition of late, prediction moment or data
Good fit
Serve the same model on SAP AI Core for SAP-grade operations
A quick first model without a training pipeline
Possible, but you maintain it
SAP-RPT-1, judged with this project's backtest
Monitoring, retraining and approvals
monitor.py, promote and the plan above
AI Core versions and pipelines; ISLM's activation step for embedded scenarios
Put the project in your public portfolio repository from Set up for Unit 14. It should contain:
README.md with the problem (late goods issues, reactive expediting), the KPI (on-time goods issue and spend per late delivery avoided, with baseline, target and the holdout), and how to run each stage.
Architecture diagram: the stage overview mermaid diagram, or your own.
Run instructions that work on a fresh clone with --sample and no account, in stage order.
Evaluation report: reports/backtest.md and reports/monitor_m20260630.md, with three sentences on what you learned about drift and retraining.
A one-page design summary: label and prediction moment, features and excluded fields, threshold and costs, the holdout, the approval rules, the retraining plan, and what the Production gap would change.
A 3-minute demo script: the one in the foundational layer works; rehearse it with the worklist page.
Leave out data/, models/, logs/, exports/ and .env. Keep the simulated KPI report's name, kpi_report_simulated.md, so nobody mistakes it for results.
Save your work:
git add .gitignore projects/late-delivery
git status
Check that git status lists no data/, models/, logs/ or exports/ files, then:
git commit -m "Project: late delivery predictor"
#Exercise: add the planners' flag limit to the promotion gate
The backtest showed retraining can double the flags per week. A model that floods planners fails even if its cost is lower. You will make the promotion gate refuse a model whose test produced more flags per week than the planners agreed to handle, then write a retraining record. The same approval gate returns in the later projects of this unit.
Open projects/late-delivery/late_model.py and find def promote(version: str, approved_by: str) -> None:. Change it to:
In the same function, find the line ending not less than flagging nothing ({test['cost_flag_nothing']:,.0f} EUR)."). Below it, add these lines, with the if at the same indentation as the if above it:
if test["flags_per_week"] > max_flags:
sys.exit(f"Refused: {version} raised {test['flags_per_week']} flags a week in its test; "
f"the planners agreed to at most {max_flags}.")
In main, find the line that adds --approved-by. Below it, add:
Save, then try to promote the June model with a limit of 10:
python projects/late-delivery/late_model.py promote m20260630 --approved-by sam --max-flags-per-week 10
Refused: m20260630 raised 10.3 flags a week in its test; the planners agreed to at most 10.0.
Promote the current model with the same limit. It passes, because its test raised 9.0 flags a week:
python projects/late-delivery/late_model.py promote m20261009 --approved-by sam --max-flags-per-week 10
Run the contract tests again to check nothing else broke: python projects/late-delivery/test_app.py.
Create projects/late-delivery/reports/retraining_record.md with five lines: the monitoring alarms that triggered retraining (from reports/monitor_m20260630.md), the candidate's test recall, flags per week and cost (from models/m20261009.json), the backtest result, the flag limit you used, and who approved.
Done when: promoting m20260630 with --max-flags-per-week 10 is refused with the message above; m20261009 is live (late_model.py list shows LIVE next to it); the contract tests pass 15 of 15; and reports/retraining_record.md names the alarms, the candidate's numbers, the limit and the approver.
Pick one answer for each question. The explanation appears after you choose.
1Why does train_model refit the chosen model on every known outcome after the test?
Answer: A. The test judges the recipe on a period the model never saw. The live model then learns from all outcomes, including the latest, which in the sample hold the slow route. Stage 6 checks the live model's real performance.
2load_model refuses a file whose SHA-256 fingerprint doesn't match its card. Which risk does that control?
Answer: C. scikit-learn's guide warns that pickle and joblib files can execute arbitrary code when loaded. Checking the fingerprint means only the exact file that was trained and approved is ever loaded.
3A system posts a delivery to /api/score including ActualGoodsMovementDate. What happens, and why?
Answer: D. extra="forbid" turns the leakage rule into a contract. A field filled after goods issue can't reach the model, and a contract test proves it stays that way.
4Omar tries to record a decision for a delivery at shipping point 1010. What does the API answer?
Answer: A. decide checks the role, then that the delivery is open, then the user's shipping points, then earlier decisions. It mirrors the planner's area of responsibility in SAP.
5In the backtest, why are July's numbers identical for the frozen and the retrained model?
Answer: A. The retrained model for each month is trained on the last day of the month before. For July that is 30 June, the frozen model's date, so the first month is the same model by design.
6Monitoring shows 8% of recent deliveries on a route the model has never seen. Why is that an alarm and not just a note?
Answer: A. Ignoring unknown codes keeps scoring running, but the model then treats those deliveries like average ones. Only monitoring notices, and the fix is retraining on outcomes that include the new route.
7Monitoring the live model trained today says "Nothing to judge yet". What should you do?
Answer: D. Monitoring must use deliveries created after training whose outcomes are known. Using younger deliveries biases the window towards on-time ones, and using training data says nothing about drift.
8The simulated KPI shows 86.4% on time before and 86.7% after go-live, but 21% vs 41% late among flagged deliveries shown vs held out. What do you tell the process owner?
Answer: B. Before and after mixes the worklist's effect with everything else that changed, including the slow route. The holdout compares like with like. And because the simulation's effect is assumed, only real logs can show the real size.
9A newly retrained model is cheaper than the live one on its test, but it raises 18 flags a week and planners agreed to 12. What would you do?
Answer: B. Flag volume is a guardrail in the metric contract. A model that floods planners gets ignored, whatever its cost on paper. The exercise turns that limit into a refusal in promote.
Ask a question
Testing: only staff see this
Stuck on something in this layer? Ask it here. Questions are answered in the order they arrive, and the answer appears under My questions.
Sign in (free) to ask a question. You can ask anonymously.
Related topics
Picked from the terms, links and tags this topic shares with others.
Orchestrate Lab is a free practice ERP in your browser, built around the fictional Calder Pumpworks. These are the places in it where you do what this topic teaches.
Stages build on each other: a stage link opens the core path at that stage. To start there without doing the earlier stages yourself, use Do this stage for me on the stage before it. A chapter starts inside your own company once the core path is done (or the lab finishes the core for you first); drills and exercises start from their own prepared copy. Everything is saved in this browser only.
Sources
Adjusting Delivery and Transportation Scheduling (SAP Learning)— loading and pick/pack times come from the shipping point; transit time and transportation lead time from the route; backward scheduling from the requested delivery date; forward scheduling when a date falls in the past
sap.s4hana.api_outbound_delivery_srv_0002 connector (Ballerina Central, version 0.1.0)— third-party connector generated from the Outbound Delivery API definition; A_OutbDeliveryHeader with PlannedGoodsIssueDate, ActualGoodsMovementDate, ProposedDeliveryRoute, ShippingPoint, HeaderGrossWeight, DeliveryPriority, ShippingCondition, ShippingType, CreationDate, HeaderWeightUnit; navigation to_DeliveryDocumentItem; A_OutbDeliveryItem with ActualDeliveryQuantity
Classic ML Scenarios (SAP Architecture Center, AI golden path, last updated 23 April 2026)— RPT-1 as the default for classification and regression, with context limits and GPU latency; SAP HANA Cloud PAL/APL for in-database scoring; SAP AI Core for custom models and MLOps-heavy work, with retraining pipelines, model versions and A/B testing; monitoring through AI Core metrics and SAP AI Launchpad
Make Predictions for House Prices with SAP AI Core (SAP Developer Center tutorial)— ServingTemplate with a KServe inference service; Docker image with a Python server; model artifact copied to /mnt/models; starter resource plan; model can be swapped without changing the deployment URL; stopped deployments are not charged and cannot be restarted
Model persistence (scikit-learn 1.9 user guide)— pickle and joblib can execute arbitrary code when loading, never load from an untrusted source; loading across scikit-learn versions is unsupported; save training data reference, code, versions and cross-validation score with the model
SAP API Policy v.4.2026a (help.sap.com)— per-API limits and preconditions for bulk extraction or replication; large-scale extraction or replication only through SAP-endorsed pathways