Monitor predictive models from feature freshness through delayed labels, then gate retraining, promotion, and rollback.
A deployed model isn't done when its endpoint responds. A system that scores job-SLA risk, ranks alerts, or forecasts runner-pool load can still fail after it reaches live traffic: inputs break now, labels arrive days later, and a new model can regress under real usage.
Monitoring answers separate questions on separate clocks: can this request be scored safely, has traffic changed, did eventual outcomes remain good enough, and should a candidate replace the current release?
4/6 safe rows and expose parity and drift problems before labels arrive. Later quality uses 5/5 label coverage in the mature cohort, and delayed review cost 8 > 5 proves the rollback pointer restores v3.Use a job-SLA model as the running example. Each job request carries two model inputs:
| Feature | Meaning | Why it can fail |
|---|---|---|
queue_backlog | jobs waiting in the runner pool | feed can become missing or use wrong units |
hours_since_last_heartbeat | age of latest scheduler heartbeat | heartbeat pipeline can become stale |
Store a feature trace with each prediction. A feature trace records the values and versions that produced one score. It lets an engineer reproduce the decision after traffic, code, or source data changes.
Build six incoming requests against deployed release job-risk-v3. Two rows contain failures that should be caught before delayed SLA labels exist.
1from datetime import datetime, timedelta, timezone
2from hashlib import sha256
3from json import dumps
4from math import log
5
6now = datetime(2026, 2, 4, 12, 0, tzinfo=timezone.utc)
7deployed = {
8 "model": "job-risk-v3",
9 "feature_contract": "features-v3",
10 "threshold": 0.50,
11}
12requests = [
13 {"job_id": "s-100", "runner_pool": "fc-a", "queue_backlog": 18, "hours_since_last_heartbeat": 2, "feature_as_of": now - timedelta(minutes=10), "risk_score": 0.18},
14 {"job_id": "s-101", "runner_pool": "fc-a", "queue_backlog": 27, "hours_since_last_heartbeat": 8, "feature_as_of": now - timedelta(minutes=20), "risk_score": 0.44},
15 {"job_id": "s-102", "runner_pool": "fc-b", "queue_backlog": 35, "hours_since_last_heartbeat": 20, "feature_as_of": now - timedelta(minutes=25), "risk_score": 0.76},
16 {"job_id": "s-103", "runner_pool": "fc-b", "queue_backlog": None, "hours_since_last_heartbeat": 26, "feature_as_of": now - timedelta(minutes=15), "risk_score": None},
17 {"job_id": "s-104", "runner_pool": "fc-a", "queue_backlog": 22, "hours_since_last_heartbeat": 6, "feature_as_of": now - timedelta(minutes=180), "risk_score": 0.52},
18 {"job_id": "s-105", "runner_pool": "fc-b", "queue_backlog": 40, "hours_since_last_heartbeat": 30, "feature_as_of": now - timedelta(minutes=5), "risk_score": 0.91},
19]
20feature_traces = [
21 {
22 **request,
23 "model": deployed["model"],
24 "feature_contract": deployed["feature_contract"],
25 }
26 for request in requests
27]
28
29print("deployed:", deployed["model"])
30print("feature contract:", deployed["feature_contract"])
31print("feature traces:", len(feature_traces))
32print("label status: pending")1deployed: job-risk-v3
2feature contract: features-v3
3feature traces: 6
4label status: pendingAt request time, you don't know which jobs will miss their SLA. You do know whether required inputs exist and whether their timestamps are fresh enough to trust. Each trace binds those values to the model and feature-contract versions that consumed them.
Define two request-time invariants:
An invariant is a condition that must stay true for the system to operate safely. Check each trace and block unsafe rows instead of sending a plausible-looking score downstream.
1required_features = ["queue_backlog", "hours_since_last_heartbeat"]
2max_feature_age_minutes = 60
3
4def request_health(row):
5 missing = [
6 name
7 for name in required_features
8 if row[name] is None
9 ]
10 feature_age = int((now - row["feature_as_of"]).total_seconds() / 60)
11 reasons = []
12 if missing:
13 reasons.append("missing=" + ",".join(missing))
14 if feature_age > max_feature_age_minutes:
15 reasons.append(f"stale={feature_age}m")
16 return reasons
17
18healthy_requests = []
19for row in feature_traces:
20 reasons = request_health(row)
21 if reasons:
22 print(row["job_id"], "-> block", ";".join(reasons))
23 else:
24 healthy_requests.append(row)
25 print(row["job_id"], "-> score")
26
27print("scored:", len(healthy_requests))
28print("blocked:", len(feature_traces) - len(healthy_requests))1s-100 -> score
2s-101 -> score
3s-102 -> score
4s-103 -> block missing=queue_backlog
5s-104 -> block stale=180m
6s-105 -> score
7scored: 4
8blocked: 2Job s-103 lacks backlog data. Job s-104 carries a three-hour-old snapshot. A label-based accuracy report would discover the unsafe scoring path too late.
Sculley et al. describe unstable data dependencies, input-data testing, and live monitoring as central production ML concerns.[1] Their point is practical: models depend on surrounding data systems, not model code alone.
Healthy rows can still look different from training traffic. Data drift means an input distribution changed over time. It's a reason to inspect, not proof that accuracy fell.[2]
Compare the historical and current distributions for hours_since_last_heartbeat:
| Bucket | Training window | Current window | Change |
|---|---|---|---|
0-4h | 50% | 30% | -20 points |
4-12h | 30% | 25% | -5 points |
12-24h | 15% | 25% | +10 points |
24h+ | 5% | 20% | +15 points |
Older heartbeats are more common now. A scheduler outage could cause that shift, but so could a holiday or runner-pool mix change.
This lab summarizes the bucket changes with Population Stability Index (PSI):
Here is the historical share for bucket , and is its current share. This exercise uses non-zero shares in every bucket so the logarithm is defined. Real monitoring code must choose an explicit policy for empty buckets.
Calculate PSI and apply a local investigation threshold of 0.20.
1bucket_names = ["0-4h", "4-12h", "12-24h", "24h+"]
2reference_counts = [50, 30, 15, 5]
3current_counts = [30, 25, 25, 20]
4
5def normalize(counts):
6 total = sum(counts)
7 return [count / total for count in counts]
8
9def population_stability_index(reference, current):
10 return sum(
11 (actual - expected) * log(actual / expected)
12 for expected, actual in zip(reference, current)
13 )
14
15reference = normalize(reference_counts)
16current = normalize(current_counts)
17psi = population_stability_index(reference, current)
18investigate_at = 0.20
19
20for name, before, after in zip(bucket_names, reference, current):
21 print(f"{name}: reference={before:.2f} current={after:.2f} delta={after - before:+.2f}")
22print("PSI:", round(psi, 3))
23print("local action:", "inspect shift" if psi >= investigate_at else "continue")10-4h: reference=0.50 current=0.30 delta=-0.20
24-12h: reference=0.30 current=0.25 delta=-0.05
312-24h: reference=0.15 current=0.25 delta=+0.10
424h+: reference=0.05 current=0.20 delta=+0.15
5PSI: 0.37
6local action: inspect shiftPSI is a compact diagnostic here, not a universal truth threshold. The output says heartbeat age changed enough to inspect. It doesn't say release v3 is inaccurate, and it doesn't say retraining is the first fix.
Training-serving skew means production computes an input differently from training. Suppose training divided backlog count by staffed runner-pool capacity, while a serving change divides by a fixed 100.
Probe the same raw row through both transformations.
1probe = {"queue_backlog": 20, "staffed_capacity": 50}
2
3def training_transform(row):
4 return round(row["queue_backlog"] / row["staffed_capacity"], 3)
5
6def buggy_serving_transform(row):
7 return round(row["queue_backlog"] / 100, 3)
8
9training_value = training_transform(probe)
10serving_value = buggy_serving_transform(probe)
11print("training backlog ratio:", training_value)
12print("serving backlog ratio:", serving_value)
13print("parity:", training_value == serving_value)1training backlog ratio: 0.4
2serving backlog ratio: 0.2
3parity: FalseThe same runner pool becomes 0.4 in training and 0.2 online. A model retrained on the existing training function won't repair that serving bug. Fix the shared transformation contract and keep a parity test in the release gate.
Google Cloud's MLOps guidance includes unit tests for feature engineering, prediction-service tests with expected inputs, data validation, and predictive-performance validation before deployment.[3]
Request-time checks move fast because they don't need outcomes. Accuracy moves slower. An SLA-miss label may arrive only after the promised job window closes.
Store model ID, score, job ID, decision context, and the time when each outcome becomes mature. Later, join outcomes by immutable prediction ID. Compute quality on cohorts whose outcome windows have closed, and report label coverage inside those cohorts. Labels that happen to arrive early can be a biased subset; a missing label in a mature cohort is a coverage gap, not an on-schedule outcome.
1predictions = [
2 {"prediction_id": "p-001", "job_id": "s-200", "risk_score": 0.15, "priority_job": False, "label_due_at": now - timedelta(hours=5)},
3 {"prediction_id": "p-002", "job_id": "s-201", "risk_score": 0.32, "priority_job": False, "label_due_at": now - timedelta(hours=4)},
4 {"prediction_id": "p-003", "job_id": "s-202", "risk_score": 0.68, "priority_job": True, "label_due_at": now - timedelta(hours=3)},
5 {"prediction_id": "p-004", "job_id": "s-203", "risk_score": 0.78, "priority_job": False, "label_due_at": now - timedelta(hours=2)},
6 {"prediction_id": "p-005", "job_id": "s-204", "risk_score": 0.91, "priority_job": True, "label_due_at": now - timedelta(hours=1)},
7 {"prediction_id": "p-006", "job_id": "s-205", "risk_score": 0.84, "priority_job": True, "label_due_at": now + timedelta(hours=1)},
8]
9decision_policy = "missed-sla-threshold-050-v1"
10predictions = [
11 {
12 **prediction,
13 "model": deployed["model"],
14 "feature_contract": deployed["feature_contract"],
15 "decision_policy": decision_policy,
16 "decision_threshold": deployed["threshold"],
17 }
18 for prediction in predictions
19]
20labels = {
21 "p-001": False,
22 "p-002": False,
23 "p-003": True,
24 "p-004": False,
25 "p-005": True,
26}
27
28matured = [prediction for prediction in predictions if prediction["label_due_at"] <= now]
29immature = [prediction for prediction in predictions if prediction["label_due_at"] > now]
30matured_labeled = []
31missing_matured_labels = []
32for prediction in matured:
33 prediction_id = prediction["prediction_id"]
34 if prediction_id in labels:
35 matured_labeled.append({**prediction, "missed_sla": labels[prediction_id]})
36 else:
37 missing_matured_labels.append(prediction_id)
38
39label_coverage = len(matured_labeled) / len(matured) if matured else 0.0
40
41print("stored predictions:", len(predictions))
42print("matured predictions:", len(matured))
43print("joined matured labels:", len(matured_labeled))
44print(f"matured label coverage: {label_coverage:.3f}")
45print("missing matured labels:", missing_matured_labels)
46print("immature predictions:", [row["prediction_id"] for row in immature])1stored predictions: 6
2matured predictions: 5
3joined matured labels: 5
4matured label coverage: 1.000
5missing matured labels: []
6immature predictions: ['p-006']Prediction p-006 is excluded because its SLA outcome window hasn't closed. Label arrival doesn't define cohort maturity. The five-row mature cohort has 100% label coverage. If a mature row were still unlabeled, the report would expose the coverage gap and withhold the quality decision rather than assume the observed subset is representative.
A binary decision uses deployed threshold 0.50: scores at or above the threshold enter job-SLA review. Require complete label coverage for this mature lab cohort, then compute precision, recall, priority-job misses, and a small review-cost receipt.
1threshold = deployed["threshold"]
2
3if missing_matured_labels:
4 raise RuntimeError("quality metrics unavailable: mature cohort has missing labels")
5
6def predicted_missed_sla(row):
7 return row["risk_score"] >= threshold
8
9true_positives = sum(predicted_missed_sla(row) and row["missed_sla"] for row in matured_labeled)
10false_positives = sum(predicted_missed_sla(row) and not row["missed_sla"] for row in matured_labeled)
11false_negatives = sum(not predicted_missed_sla(row) and row["missed_sla"] for row in matured_labeled)
12
13def rate_or_none(numerator, denominator):
14 return round(numerator / denominator, 3) if denominator else None
15
16precision = rate_or_none(true_positives, true_positives + false_positives)
17recall = rate_or_none(true_positives, true_positives + false_negatives)
18missed_priority_job = sum(
19 row["priority_job"] and row["missed_sla"] and not predicted_missed_sla(row)
20 for row in matured_labeled
21)
22review_cost = false_positives * 2 + false_negatives * 10
23
24print("precision:", precision)
25print("recall:", recall)
26print("missed priority jobs:", missed_priority_job)
27print("review cost:", review_cost)1precision: 0.667
2recall: 1.0
3missed priority jobs: 0
4review cost: 2The local cost policy assigns 2 units to an unnecessary review and 10 to a missed SLA. It's an example decision policy, not a universal business value. If a denominator is zero, report None: missing evidence isn't the same as measured failure.
Scores also need a calibration check. A score near 0.80 shouldn't be interpreted as an 80% risk unless outcomes from adequately covered mature cohorts support that use.[4] Compare average score with observed job-SLA rate inside two tiny buckets.
1score_buckets = [
2 ("low", [row for row in matured_labeled if row["risk_score"] < 0.50]),
3 ("high", [row for row in matured_labeled if row["risk_score"] >= 0.50]),
4]
5
6for name, rows_in_bucket in score_buckets:
7 if not rows_in_bucket:
8 print(f"{name}: n=0 average_score=None observed_missed_sla_rate=None")
9 continue
10 average_score = sum(row["risk_score"] for row in rows_in_bucket) / len(rows_in_bucket)
11 observed_rate = sum(row["missed_sla"] for row in rows_in_bucket) / len(rows_in_bucket)
12 print(
13 f"{name}: n={len(rows_in_bucket)} "
14 f"average_score={average_score:.3f} "
15 f"observed_missed_sla_rate={observed_rate:.3f}"
16 )1low: n=2 average_score=0.235 observed_missed_sla_rate=0.000
2high: n=3 average_score=0.790 observed_missed_sla_rate=0.667Five labels with full mature-cohort coverage are enough to explain the receipt, not enough to approve probability quality. An empty bucket should report None instead of pretending evidence exists. A production report needs larger mature windows, explicit overall and slice coverage, and slices such as runner pool, service tier, geography, and forecast age.
Drift, skew, and delayed quality degradation can appear together. They don't imply the same action:
| Evidence | First action |
|---|---|
| missing or stale online features | repair data path |
| parity probe fails | repair training-serving contract |
| distribution shifts | inspect source health and business context |
| delayed decision cost regresses after inputs are valid | train frozen-snapshot candidate |
Encode that order. The first scenario has broken data and parity. The second has clean inputs plus delayed quality regression.
1def triage(schema_failures, stale_features, parity_ok, psi, cost_regressed):
2 actions = []
3 if schema_failures or stale_features:
4 actions.append("repair online data path")
5 if not parity_ok:
6 actions.append("repair training-serving parity")
7 if psi >= investigate_at:
8 actions.append("inspect distribution shift")
9 if not schema_failures and not stale_features and parity_ok and cost_regressed:
10 actions.append("train frozen-snapshot candidate")
11 return actions
12
13print("dirty window:", triage(1, 1, False, psi, True))
14print("clean changed window:", triage(0, 0, True, psi, True))1dirty window: ['repair online data path', 'repair training-serving parity', 'inspect distribution shift']
2clean changed window: ['inspect distribution shift', 'train frozen-snapshot candidate']Continuous training doesn't mean every alert immediately overwrites production. Google Cloud documents scheduled, new-data, performance-degradation, and distribution-change triggers for retraining pipelines, with metadata and model-validation stages around those pipelines.[3]
When clean evidence justifies retraining, freeze the snapshot, training pipeline, feature contract, decision policy, evaluation policy, and previous model pointer. The resulting candidate bundle is an immutable release proposal. Promotion moves an alias; it doesn't rewrite the bundle.
Create a deterministic bundle ID from the candidate configuration.
1candidate_config = {
2 "model": "job-risk-v4",
3 "snapshot": "labels-through-2026-02-03",
4 "training_pipeline": "job-risk-train-v4",
5 "feature_contract": "features-v3",
6 "decision_policy": decision_policy,
7 "threshold": 0.50,
8 "evaluation_policy": "job-risk-eval-v1",
9 "previous_model": deployed["model"],
10}
11payload = dumps(candidate_config, sort_keys=True, separators=(",", ":"))
12bundle_id = "job-risk-v4-" + sha256(payload.encode()).hexdigest()[:10]
13candidate_bundle = {**candidate_config, "bundle_id": bundle_id}
14
15print("candidate bundle:", candidate_bundle["bundle_id"])
16print("rollback pointer:", candidate_bundle["previous_model"])1candidate bundle: job-risk-v4-edde9c2176
2rollback pointer: job-risk-v3Google Cloud's MLOps guidance recommends recording pipeline versions, arguments, produced artifacts, evaluation metrics, and a pointer to the previous trained model for rollback or comparison.[3]
Compare candidate v4 with current v3 before changing live traffic. These thresholds are explicit local policies:
1current_metrics = {
2 "recall": 0.80,
3 "priority_misses": 1,
4 "review_cost": 6,
5 "p95_latency_ms": 40,
6}
7candidate_metrics = {
8 "recall": 0.90,
9 "priority_misses": 0,
10 "review_cost": 4,
11 "p95_latency_ms": 42,
12}
13evaluation_cohort = {
14 "maturity_cutoff": "2026-02-03T12:00:00Z",
15 "matured_predictions": 1000,
16 "joined_labels": 1000,
17}
18evaluation_cohort["label_coverage"] = (
19 evaluation_cohort["joined_labels"] / evaluation_cohort["matured_predictions"]
20)
21offline_evaluation = {
22 "policy": candidate_bundle["evaluation_policy"],
23 "label_snapshot": candidate_bundle["snapshot"],
24 "cohort": evaluation_cohort,
25 "current": current_metrics,
26 "candidate": candidate_metrics,
27}
28offline_gates = {
29 "mature_label_coverage": evaluation_cohort["label_coverage"] == 1.0,
30 "recall": candidate_metrics["recall"] >= current_metrics["recall"],
31 "priority_misses": candidate_metrics["priority_misses"] == 0,
32 "review_cost": candidate_metrics["review_cost"] <= current_metrics["review_cost"],
33 "latency_budget": candidate_metrics["p95_latency_ms"] <= 50,
34}
35
36for name, passed in offline_gates.items():
37 print(f"{name}: {passed}")
38print("offline gate:", all(offline_gates.values()))1mature_label_coverage: True
2recall: True
3priority_misses: True
4review_cost: True
5latency_budget: True
6offline gate: TrueOffline evidence is necessary but incomplete. A canary sends limited live traffic to a candidate before full promotion. Gate fast signals such as service errors, latency, request count, and immediate review-queue pressure. Those signals can stop a bad rollout quickly, but they don't replace delayed-label quality.
1canary_metrics = {
2 "requests": 500,
3 "error_rate": 0.002,
4 "p95_latency_ms": 44,
5 "review_queue_load_ratio": 0.72,
6}
7canary_gates = {
8 "enough_requests": canary_metrics["requests"] >= 500,
9 "error_rate": canary_metrics["error_rate"] <= 0.005,
10 "p95_latency_ms": canary_metrics["p95_latency_ms"] <= 50,
11 "review_queue_load_ratio": canary_metrics["review_queue_load_ratio"] <= 0.80,
12}
13canary_evaluation = {
14 "policy": "job-risk-canary-v1",
15 "metrics": canary_metrics,
16 "checks": canary_gates,
17}
18
19for name, passed in canary_gates.items():
20 print(f"{name}: {passed}")
21print("canary gate:", all(canary_gates.values()))1enough_requests: True
2error_rate: True
3p95_latency_ms: True
4review_queue_load_ratio: True
5canary gate: TrueArgo Rollouts documents canary steps, metric analysis, unsuccessful-analysis aborts, and post-promotion analysis that can switch traffic back to a previous stable release.[5] The exact deployment tool may differ, but promotion needs measured conditions and a rollback path.
Keep immutable bundle IDs separate from a movable production alias. If both gate sets pass, move the alias from v3 to the candidate. Then simulate a slower delayed-label guardrail arriving after promotion. The cost threshold is evaluated only after the declared mature cohort reaches full label coverage; an incomplete cohort would report insufficient evidence rather than treat early labels as representative.
1alias = {"production": deployed["model"]}
2rollback_pointer = alias["production"]
3
4if all(offline_gates.values()) and all(canary_gates.values()):
5 alias["production"] = candidate_bundle["bundle_id"]
6
7print("production alias:", alias["production"])
8print("rollback pointer:", rollback_pointer)
9
10post_promotion = {
11 "matured_predictions": 500,
12 "joined_labels": 500,
13 "delayed_review_cost": 8,
14}
15post_promotion["label_coverage"] = (
16 post_promotion["joined_labels"] / post_promotion["matured_predictions"]
17)
18rollback_limit = 5
19rollback_reason = None
20if (
21 post_promotion["label_coverage"] == 1.0
22 and post_promotion["delayed_review_cost"] > rollback_limit
23):
24 rollback_reason = (
25 f"delayed review cost {post_promotion['delayed_review_cost']} "
26 f"> {rollback_limit}"
27 )
28 alias["production"] = rollback_pointer
29
30print(f"post-promotion label coverage: {post_promotion['label_coverage']:.3f}")
31print("delayed review cost:", post_promotion["delayed_review_cost"])
32print("final alias:", alias["production"])
33
34receipt = {
35 "candidate_bundle": candidate_bundle,
36 "previous": rollback_pointer,
37 "offline_evaluation": {**offline_evaluation, "checks": offline_gates},
38 "canary_evaluation": canary_evaluation,
39 "post_promotion": {**post_promotion, "rollback_limit": rollback_limit},
40 "rollback_reason": rollback_reason,
41 "release_action": "rolled_back" if rollback_reason else "keep_candidate",
42 "final_alias": alias["production"],
43}
44receipt_json = dumps(receipt, sort_keys=True, separators=(",", ":"))
45print("release action:", receipt["release_action"])
46print("receipt sha256:", sha256(receipt_json.encode()).hexdigest()[:12])1production alias: job-risk-v4-edde9c2176
2rollback pointer: job-risk-v3
3post-promotion label coverage: 1.000
4delayed review cost: 8
5final alias: job-risk-v3
6release action: rolled_back
7receipt sha256: 54de7af4a622The rehearsal promotes v4, waits for a fully labeled 500-row mature cohort, observes delayed review cost 8 above local limit 5, and restores alias v3. Its receipt binds candidate lineage, offline evidence, canary evidence, delayed-label coverage, and the derived rollback reason. A rollback drill is stronger than a diagram: it proves the pointer and policy work together.
Before continuing, explain the full path in your own words:
s-104 feature age from 180 to 30 minutes. Predict how many requests score before rerunning the lab.24h+ heartbeat share from 20 to 5 and move the removed 15 counts into 0-4h. Predict whether PSI rises or falls.buggy_serving_transform() to divide by staffed_capacity. Explain which triage action disappears.now past p-006's label_due_at, add label p-006: True, and compute the new precision, recall, and mature-cohort coverage.p95_latency_ms from 44 to 65. Explain whether production alias ever moves to v4.rollback_pointer assignment. Explain why a model registry entry alone doesn't prove rollback works.| Prompt | Reasoning check |
|---|---|
Freshen s-104 | Five requests score. Only s-103 remains blocked for missing backlog. |
| Restore recent-heartbeat mix | PSI falls because current traffic moves closer to reference traffic. |
| Fix serving transform | Training-serving parity becomes True, so repair training-serving parity disappears. |
Resolve p-006 as missed_sla | True positives rise from 2 to 3; precision rises to 3 / 4 = 0.75; recall remains 1.0. |
| Slow canary | p95_latency_ms gate fails. Alias remains job-risk-v3. |
| Delete rollback pointer | Candidate may still be reproducible, but rollback target is no longer proven by execution. |
| Evidence | A strong explanation demonstrates |
|---|---|
| fast controls | blocks missing or stale features before delayed labels arrive |
| drift reasoning | treats PSI as a local investigation diagnostic, not an accuracy verdict |
| parity | reproduces skew with one raw row and fixes contract before retraining |
| delayed quality | joins immutable prediction IDs, gates on mature cohorts and label coverage, and checks slices |
| candidate control | freezes snapshot, training pipeline, feature contract, decision policy, evaluation policy, and previous model pointer |
| rollout | separates offline gate, limited live canary, promotion alias, and rollback receipt |
| Symptom | Cause | Fix |
|---|---|---|
| Accuracy report arrives after days of bad inputs | only label metrics are monitored | add schema and freshness alarms |
| Every drift alert starts retraining | drift is confused with failure | inspect data path, context, and delayed outcomes |
| New training run repeats wrong scores | serving transform differs from training | add parity probes before retraining |
| Report looks good while labels are still arriving | metrics use whichever labels arrived first | define mature cohorts, report label coverage, and block or qualify incomplete windows |
| New model can't be rolled back cleanly | bundle and alias aren't versioned separately | publish immutable candidate plus rollback pointer |
| Canary passes but delayed cost later rises | only fast rollout metrics were checked | keep post-promotion guardrails and rehearse rollback |
Answer every question, then check your score. Score above 75% to mark this lesson complete.
10 questions remaining.
Hidden Technical Debt in Machine Learning Systems.
Sculley et al. · 2015
Monitoring Machine Learning Models in Production.
Klaise, J., et al. · 2020
MLOps: Continuous Delivery and Automation Pipelines in Machine Learning.
Google Cloud. · 2026 · Official documentation
On Calibration of Modern Neural Networks
Guo, C., Pleiss, G., Sun, Y., & Weinberger, K. Q. · 2017
Argo Rollouts - Kubernetes Progressive Delivery Controller
Argo Project · 2026
Questions and insights from fellow learners.