LeetLLM
My PlanLearnGlossaryTracksPracticeBlog
LeetLLM

Your go-to resource for mastering AI & LLM systems.

Product

  • Learn
  • Glossary
  • Tracks
  • Practice
  • Blog
  • RSS

Legal

  • Terms of Service
  • Privacy Policy

© 2026 LeetLLM. All rights reserved.

All Topics
Your Progress
0%

0 of 177 articles completed

🛠️Computing Foundations0/9
Git, Shell, Linux for AIDocker for Reproducible AIPython for AI EngineeringNumPy and Tensor ShapesCUDA for ML TrainingMPS & Metal for ML on MacData Structures for AISQL and Data ModelingAlgorithms for ML Engineers
📊Math & Statistics0/8
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningStatistics and UncertaintyDistributions and SamplingHypothesis Tests, Intervals, and pass@k
📚Preparation & Prerequisites0/13
Neural Networks from ScratchCNNs from ScratchTraining & BackpropagationSoftmax, Cross-Entropy & OptimizationRNNs, LSTMs, GRUs, and Sequence ModelingAutoencoders and VAEsThe Transformer Architecture End-to-EndLanguage Modeling & Next TokensFrom GPT to Modern LLMsPrompt Engineering FundamentalsCalling LLM APIs in ProductionFirst AI App End-to-EndThe LLM Lifecycle
🧮ML Algorithms & Evaluation0/11
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageClustering and PCACore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingPyTorch Training LoopsDataset Pipelines and Data Quality
📦Production ML Systems0/6
Feature Engineering for Production MLBatch and Streaming Feature PipelinesGradient Boosted Trees in ProductionRanking and Recommendation SystemsForecasting and Anomaly DetectionMonitoring Predictive Models
🧪Core LLM Foundations0/8
The Bitter Lesson & ComputeBPE, WordPiece, and SentencePieceStatic to Contextual EmbeddingsPerplexity & Model EvaluationFile Ingestion for AIChunking StrategiesLLM Benchmarks & LimitationsInstruction Tuning & Chat Templates
🧰Applied LLM Engineering0/24
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernanceData Labeling and Human FeedbackEvaluating AI AgentsProduction RAG PipelinesHybrid Search: Dense + SparseReranking and Cross-Encoders for RAGRAG Evaluation for Reliable AnswersLLM-as-a-Judge EvaluationBias & Fairness in LLMsHallucination Detection & MitigationLLM Observability & MonitoringExperiment Tracking with MLflow and W&BPrompt Optimization with DSPyModel Versioning & DeploymentSemantic Caching & Cost OptimizationLLM Cost Engineering & Token EconomicsModel Gateways, Routing, and FallbacksDesign an Automated Support Agent
🎓Portfolio Capstones0/9
Capstone: Delivery ETA PredictionCapstone: Product RankingCapstone: Demand ForecastingCapstone: Image Damage ClassifierCapstone: Production ML PipelineCapstone: Document QACapstone: Eval DashboardCapstone: Fine-Tuned ClassifierCapstone: Reproducible ML Study
🧠Transformer Deep Dives0/8
Sentence Embeddings & Contrastive LossEmbedding Similarity & QuantizationScaled Dot-Product AttentionVision Transformers and Image EncodersPositional Encoding: RoPE & ALiBiLayer Normalization: Pre-LN vs Post-LNMechanistic InterpretabilityDecoding Strategies: Greedy to Nucleus
🧬Advanced Training & Adaptation0/16
Scaling Laws & Compute-Optimal TrainingPre-training Data at ScaleBuild GPT from Scratch LabJAX for PyTorch ResearchersContinued Pretraining for Domain ShiftSynthetic Data PipelinesSupervised Fine-Tuning PipelineMixed Precision TrainingDistributed Training: FSDP & ZeROLoRA & Parameter-Efficient TuningReward Modeling from Preference DataRLHF & DPO AlignmentConstitutional AI & Red TeamingRLVR & Verifiable RewardsKnowledge Distillation for LLMsModel Merging and Weight Interpolation
🤖Advanced Agents & Retrieval0/16
Vector DB Internals: HNSW & IVFAdvanced RAG: HyDE & Self-RAGGraphRAG & Knowledge GraphsRAG Security & Access ControlStructured Output GenerationReAct & Plan-and-ExecuteGuardrails & Safety FiltersCode Generation & SandboxingComputer-Use / GUI / Browser AgentsHuman-in-the-Loop Agent ArchitectureAI Coding Workflow with AgentsAgent Memory & PersistenceAgent Failure & RecoveryRecursive Language Models (RLM)Multi-Agent OrchestrationCapstone: Production Agent
⚡Inference & Production Scale0/19
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceModel Quantization: GPTQ, AWQ & GGUFLocal LLM DeploymentSLM Specialization & Edge DeploymentSpeculative DecodingLong Context Window ManagementMixture of Experts ArchitectureMamba & State Space ModelsReasoning & Test-Time ComputeAdvanced MLOps & DevOps for AIGPU Serving & AutoscalingA/B Testing for LLMs
🏗️System Design Capstones0/9
Content Moderation SystemCode Completion SystemMulti-Tenant LLM PlatformLLM-Powered Search EngineVision-Language Models & CLIPMultimodal LLM ArchitectureDiffusion Models: Images & TextReal-Time Voice AI AgentReasoning & Test-Time Compute
🎤AI Lab Interviewing0/4
AI Lab Coding Interview: Python SystemsAI Lab System Design InterviewAI Lab Behavioral InterviewAI Lab Technical Presentation
🔬Project Deep Dives0/17
Deep Dive - vLLMDeep Dive - SkyRLDeep Dive - FlashAttentionDeep Dive - FlashInferDeep Dive - DeepGEMMDeep Dive - NCCLDeep Dive - MegatronDeep Dive - DeepSpeedDeep Dive - RayDeep Dive - MLflowDeep Dive - PyTorchDeep Dive - TransformersDeep Dive - SGLangDeep Dive - slimeDeep Dive - DeepEPDeep Dive - TinkerDeep Dive - Light-PEFT
Back to Topics
LearnProduction ML SystemsMonitoring Predictive Models
⚙️MediumMLOps & Deployment

Monitoring Predictive Models

Monitor predictive models from feature freshness through delayed labels, then gate retraining, promotion, and rollback.

16 min read
Learning path
Step 47 of 177 in the full curriculum
Forecasting and Anomaly DetectionThe Bitter Lesson & Compute

Personalize this lesson

Adapt explanations and teaching visuals to your background and preferred voice.

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?

Predictive-model monitoring dashboard separating request-time safety, delayed outcome quality, and reversible release rollback.
Request-time checks score 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.

Start with a served release and feature traces

Use a job-SLA model as the running example. Each job request carries two model inputs:

FeatureMeaningWhy it can fail
queue_backlogjobs waiting in the runner poolfeed can become missing or use wrong units
hours_since_last_heartbeatage of latest scheduler heartbeatheartbeat 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.

build-live-feature-traces.py
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")
Output
1deployed: job-risk-v3 2feature contract: features-v3 3feature traces: 6 4label status: pending

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

Block invalid inputs before waiting for labels

Define two request-time invariants:

  1. Required features can't be missing.
  2. Feature snapshots can't be older than 60 minutes.

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.

check-request-time-input-health.py
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))
Output
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: 2

Job 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]Reference 1Hidden Technical Debt in Machine Learning Systems.https://research.google/pubs/hidden-technical-debt-in-machine-learning-systems/ Their point is practical: models depend on surrounding data systems, not model code alone.

Why block s-104 before its SLA label arrives?

Answer

The three-hour-old feature snapshot violates a request-time freshness contract. A future label may measure outcome quality, but it can't make an unsafe current input trustworthy.

Measure drift without calling it failure

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]Reference 2Monitoring Machine Learning Models in Production.https://arxiv.org/abs/2007.06299

Compare the historical and current distributions for hours_since_last_heartbeat:

BucketTraining windowCurrent windowChange
0-4h50%30%-20 points
4-12h30%25%-5 points
12-24h15%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):

PSI=∑i(ai−ei)log⁡(aiei)\text{PSI} = \sum_i (a_i - e_i)\log\left(\frac{a_i}{e_i}\right)PSI=i∑​(ai​−ei​)log(ei​ai​​)

Here eie_iei​ is the historical share for bucket iii, and aia_iai​ 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.

calculate-input-drift-diagnostic.py
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")
Output
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 shift

PSI is a compact diagnostic, not an accuracy verdict. The 0.20 cutoff is a local policy calibrated on stable windows. This result calls for inspection; it doesn't prove release v3 is inaccurate or that retraining is the first fix.

Why shouldn't the 0.370 PSI result automatically start retraining?

Answer

PSI only reports a distribution change. Inspect source-data health, business context, and delayed outcomes first. Retraining on corrupted inputs can produce a newer broken model.

Data drift isn't concept drift

Data drift (covariate shift) is a change in the input distribution P(x)P(x)P(x). Feature PSI and JS measure that class of change. Concept drift is a change in the relationship P(y∣x)P(y \mid x)P(y∣x): the same feature values start mapping to different outcomes. Label / prior shift is a change in P(y)P(y)P(y) that may or may not accompany either of the others.

ChangeWhat movedEarly evidenceFirst response
data driftP(x)P(x)P(x) (feature mix)feature PSI/JS, freshness, schemainspect sources and traffic mix
concept driftP(y∣x)P(y \mid x)P(y∣x) (label relationship)delayed quality, slice calibration, residual patterns after inputs look validfreeze clean snapshot and retrain candidate when inputs and parity are healthy
label / prior shiftP(y)P(y)P(y) base rateoutcome rate in mature cohortsrecheck decision threshold and cost policy before blaming the model

Delayed quality metrics and slice calibration are concept-drift evidence: they compare scores to outcomes after the label window closes. Feature PSI alone can't prove concept drift. Don't retrain on corrupt or skewed inputs either; fix data path and parity first, then use mature delayed quality to decide whether the learned relationship still holds.

Score distributions are a useful early signal between feature PSI and delayed labels. If risk scores shift sharply while feature mix and parity look stable, inspect serving config, threshold policy, and recent transform deploys before outcomes mature. A score histogram isn't an accuracy verdict; it only shortens the time to notice that something changed.

Reproduce training-serving skew

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.

reproduce-training-serving-skew.py
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)
Output
1training backlog ratio: 0.4 2serving backlog ratio: 0.2 3parity: False

The 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]Reference 3MLOps: Continuous Delivery and Automation Pipelines in Machine Learning.https://docs.cloud.google.com/architecture/mlops-continuous-delivery-and-automation-pipelines-in-machine-learning

Join delayed labels back to stored predictions

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.

join-delayed-job-SLA-labels.py
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])
Output
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.

Why exclude p-006 from this quality cohort instead of counting it as an on-schedule job?

Answer

Its outcome window hasn't closed, so the prediction isn't mature. Replacing that unknown outcome with a negative label biases the report. Separately, missing labels for predictions that are already mature must reduce reported coverage and can block quality decisions.

Evaluate decisions and calibration

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 and recall, priority-job misses, and a small review-cost receipt.

measure-delayed-quality-window.py
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)
Output
1precision: 0.667 2recall: 1.0 3missed priority jobs: 0 4review cost: 2

The 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]Reference 4On Calibration of Modern Neural Networkshttps://arxiv.org/abs/1706.04599 Compare average score with observed job-SLA rate inside two tiny buckets.

measure-risk-score-calibration.py
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 )
Output
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.667

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

Triage repair, inspection, and retraining separately

Drift, skew, and delayed quality degradation can appear together. They don't imply the same action:

EvidenceFirst action
missing or stale online featuresrepair data path
parity probe failsrepair training-serving contract
distribution shiftsinspect source health and business context
delayed decision cost regresses after inputs are validtrain frozen-snapshot candidate

Encode that order. The first scenario has broken data and parity. The second has clean inputs plus delayed quality regression.

triage-monitoring-signals.py
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))
Output
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]Reference 3MLOps: Continuous Delivery and Automation Pipelines in Machine Learning.https://docs.cloud.google.com/architecture/mlops-continuous-delivery-and-automation-pipelines-in-machine-learning

Diagram showing Production requests feature traces, Fast checks schema + freshness, Predictions release v3, and Delayed labels + quality window cost + slices.
Production requests feature traces, Fast checks schema + freshness, Predictions release v3, and Delayed labels + quality window cost + slices.

Freeze an immutable candidate bundle

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.

publish-immutable-candidate-bundle.py
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"])
Output
1candidate bundle: job-risk-v4-edde9c2176 2rollback pointer: job-risk-v3

Google 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]Reference 3MLOps: Continuous Delivery and Automation Pipelines in Machine Learning.https://docs.cloud.google.com/architecture/mlops-continuous-delivery-and-automation-pipelines-in-machine-learning

Gate offline metrics and canary traffic

Compare candidate v4 with current v3 before changing live traffic. These thresholds are explicit local policies:

check-offline-promotion-gates.py
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()))
Output
1mature_label_coverage: True 2recall: True 3priority_misses: True 4review_cost: True 5latency_budget: True 6offline gate: True

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

check-canary-promotion-gates.py
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()))
Output
1enough_requests: True 2error_rate: True 3p95_latency_ms: True 4review_queue_load_ratio: True 5canary gate: True

Argo Rollouts documents canary steps, metric analysis, unsuccessful-analysis aborts, and post-promotion analysis that can switch traffic back to a previous stable release.[5]Reference 5Argo Rollouts - Kubernetes Progressive Delivery Controllerhttps://argoproj.github.io/argo-rollouts/ The exact deployment tool may differ, but promotion needs measured conditions and a rollback path.

Promote an alias, then rehearse rollback

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.

promote-and-rollback-model-alias.py
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])
Output
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: 54de7af4a622

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

When monitoring breaks

SymptomCauseFix
Accuracy report arrives after days of bad inputsonly label metrics are monitoredadd schema and freshness alarms
Every drift alert starts retrainingdrift is confused with failureinspect data path, context, and delayed outcomes
Feature PSI is quiet but delayed cost risesconcept drift (P(y∣x)P(y \mid x)P(y∣x)) without covariate shiftuse mature quality and calibration; retrain only on clean inputs
New training run repeats wrong scoresserving transform differs from trainingadd parity probes before retraining
Report looks good while labels are still arrivingmetrics use whichever labels arrived firstdefine mature cohorts, report label coverage, and block or qualify incomplete windows
New model can't be rolled back cleanlybundle and alias aren't versioned separatelypublish immutable candidate plus rollback pointer
Canary passes but delayed cost later risesonly fast rollout metrics were checkedkeep post-promotion guardrails and rehearse rollback
Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.At noon, a job-risk request has all required feature values, but its feature snapshot is from 09:00. The request-time contract requires snapshots no older than 60 minutes. What should the monitor do before SLA labels arrive?

Correct answer: Block scoring because the 180-minute feature age violates freshness; later labels can measure outcomes but cannot make stale input safe.

Freshness is a fast request-time invariant. It is checked before outcome labels exist because stale inputs can make the current score unsafe even if a future label later shows what happened.

2.A heartbeat-age drift report compares reference shares [50, 30, 15, 5] with current shares [30, 25, 25, 20] and computes PSI 0.37 against a local investigation threshold of 0.20. What action is justified first?

Correct answer: Inspect source-data health, business context, and delayed outcomes because PSI reports distribution shift, not an accuracy verdict.

PSI summarizes how an input distribution changed. A high local PSI can justify inspection, but it does not identify whether the cause is data corruption, business mix, a scheduler event, or true model-quality degradation.

3.Training computes backlog_ratio = queue_backlog / staffed_capacity, but serving was changed to compute queue_backlog / 100. For a raw row with queue_backlog = 20 and staffed_capacity = 50, what should the parity probe conclude?

Correct answer: Training gives 0.400 and serving gives 0.200; parity fails, so repair the shared transform contract before retraining.

Training-serving skew is a feature-computation mismatch. Retraining on the old training function will not fix a serving path that still computes the feature differently.

4.A delayed quality window stored six predictions. Outcome windows have closed for p-001 through p-005, and all five labels arrived. The outcome window for p-006 closes next hour. What should the report do?

Correct answer: Compute quality on the five mature predictions, report 5/5 mature-label coverage, and list p-006 as immature.

Cohort maturity is determined by whether the outcome window has closed, not by whether a label happened to arrive. The five mature rows have complete coverage; p-006 is not in that denominator yet. Any missing label among mature rows would be a reported coverage gap rather than an assumed outcome.

5.A fully labeled mature cohort uses threshold 0.50 and has (score, missed_sla) values: (0.15, False), (0.32, False), (0.68, True), (0.78, False), and (0.91, True). False positives cost 2 and false negatives cost 10. Which receipt is correct?

Correct answer: Precision 0.667, recall 1.000, review cost 2.

Scores at or above 0.50 predict missed_sla. The window has two true positives, one false positive, and zero false negatives, so precision is 2 / 3, recall is 2 / 2, and cost is 1 * 2 + 0 * 10.

6.A risk report groups a fully labeled mature cohort by score. In the high bucket, three rows have average score 0.790 and observed missed_sla rate 0.667; an empty bucket would report average_score=None and observed_missed_sla_rate=None. Which interpretation is correct?

Correct answer: The bucket suggests the 0.80-ish scores should not be treated as calibrated probabilities without more evidence, and empty buckets provide no measured rate.

Calibration asks whether predicted scores match observed outcome frequencies. A tiny bucket can illustrate the check but cannot approve probability quality, and an empty bucket has no evidence to summarize.

7.A triage rule adds actions as follows: repair the online data path for schema or staleness failures, repair parity when the parity probe fails, inspect when PSI is at least 0.20, and train a candidate only when inputs are clean, parity passes, and cost regressed. A window has schema_failures=1, stale_features=1, parity_ok=False, PSI=0.37, and cost_regressed=True. Which actions should it return?

Correct answer: Repair online data path, repair training-serving parity, and inspect distribution shift; do not train a candidate yet.

Broken inputs and a failed parity probe are repair problems. Training a candidate is gated on clean inputs and passing parity, even when cost has regressed and drift is high.

8.A clean quality regression justifies retraining. Which candidate package supports reproducibility and rollback before promotion?

Correct answer: An immutable bundle containing the label snapshot, training pipeline, feature contract, decision and evaluation policies, threshold, model ID, and previous model pointer.

A candidate bundle is a frozen release proposal. It must bind the data snapshot, pipeline, contracts, policies, threshold, model identity, and previous model pointer so promotion can move an alias without rewriting the release evidence.

9.Offline gates pass for a candidate. Canary metrics are requests=500, error_rate=0.002, p95_latency_ms=65, and review_queue_load_ratio=0.72. Canary gates require requests at least 500, error rate at most 0.005, p95 latency at most 50, and queue load at most 0.80. What happens to the production alias?

Correct answer: The alias stays on job-risk-v3 because the canary latency gate fails even though offline metrics passed.

Promotion requires both offline and canary gates to pass. The p95 latency of 65 ms violates the canary limit of 50 ms, so the alias should not move to the candidate.

10.Offline and canary gates pass, so the production alias moves from job-risk-v3 to immutable bundle job-risk-v4-edde9c2176. Later, a 500-row mature cohort reaches 500/500 label coverage, delayed review cost is 8, the rollback limit is 5, and the rollback pointer is job-risk-v3. What should the release controller do?

Correct answer: Restore the alias to job-risk-v3 and record a rolled-back release action with the delayed-cost reason.

Offline and canary gates catch different risks from delayed-label guardrails. If post-promotion delayed cost breaches the policy, the movable alias should return to the stored previous model while the immutable bundle and receipt preserve what happened.

10 questions remaining.

Next Step
Continue to The Bitter Lesson & Compute

You can build, serve, monitor, promote, and roll back conventional predictive models with explicit evidence. Next, apply the same measurement discipline to language models whose capabilities grow with data and computation.

PreviousForecasting and Anomaly Detection
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

Hidden Technical Debt in Machine Learning Systems.

Sculley et al. · 2015

https://research.google/pubs/hidden-technical-debt-in-machine-learning-systems/

Monitoring Machine Learning Models in Production.

Klaise, J., et al. · 2020

https://arxiv.org/abs/2007.06299

MLOps: Continuous Delivery and Automation Pipelines in Machine Learning.

Google Cloud. · 2026 · Official documentation

https://docs.cloud.google.com/architecture/mlops-continuous-delivery-and-automation-pipelines-in-machine-learning

On Calibration of Modern Neural Networks

Guo, C., Pleiss, G., Sun, Y., & Weinberger, K. Q. · 2017

https://arxiv.org/abs/1706.04599

Argo Rollouts - Kubernetes Progressive Delivery Controller

Argo Project · 2026

https://argoproj.github.io/argo-rollouts/

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.