Skip to content
advanced Phase 17 · MLOps: Packaging, Serving, and Monitoring

Production Monitoring and Drift Detection

Watch inputs, predictions, and latency for degradation after release.

1h 5m
0 problems
Topic Progress 0%

Accuracy Holds Steady While the Model Rots: The Three Drifts Hiding in Production

The dashboard says AUC 0.87, unchanged for six weeks. Then quarter-end losses arrive and you discover the model has been wrong for a month. The dashboard watched the wrong thing: with labels arriving 30 days late, "live AUC" is computed on predictions made a month ago, while the inputs shifted last Tuesday. You need drift signals you can compute today — and you must know which drift each signal can and cannot see.

There are three distinct failures that people collapse into the word "drift":

Name What shifts Concrete example Only visible to
Covariate (input) drift P(X) new merchant categories; mobile share up 20 points KS/PSI on features, prediction-distribution checks
Label (prior) drift P(Y) default rate climbs 2% → 5% label distribution once labels arrive
Concept drift P(Y|X) identical spending now means something different (policy change, new competitor) accuracy/loss on fresh labels — nothing else

KS and PSI detect distribution shift in X — they cannot see concept drift. If inputs are unchanged but the mapping to the outcome broke, every feature test passes while the model decays. Only fresh labels reveal it, which is why input monitoring and label-based monitoring are separate systems, not one dashboard.

The two-sample Kolmogorov-Smirnov test compares a numeric feature's reference and live samples by the largest vertical gap between their empirical CDFs:

D = max_x |F_ref(x) − F_live(x)|

and at α = 0.05 the critical value is approximately:

D_crit = 1.36 · sqrt((n + m) / (n · m))

Work it: with n = 2,000 reference and m = 2,000 live rows, D_crit = 1.36 · sqrt(4,000 / 4,000,000) = 1.36 · 0.0316 ≈ 0.043. Observed D = 0.071 exceeds it → reject equality → drift. Now shrink both samples to 500: D_crit = 1.36 · sqrt(1,000/250,000) ≈ 0.086, and the same D = 0.071 passes comfortably. Nothing about the data changed — only the sample size. Small monitoring windows hide drift, so minimum sample requirements belong in your alert thresholds.

from scipy.stats import ks_2samp

res = ks_2samp(reference["txn_amount"], live["txn_amount"])
# res.statistic = D, res.pvalue < 0.05 means the distributions differ
if res.statistic > 1.36 * ((len(reference) + len(live)) / (len(reference) * len(live))) ** 0.5:
    flag("txn_amount drifted")

For binned or categorical features, population stability index buckets live data into the reference's deciles and sums a weighted log-ratio:

PSI = Σ (A_i − E_i) · ln(A_i / E_i), where E_i is the reference fraction and A_i the live fraction per bin.

A worked example with reference deciles (E = 0.10 each) and live fractions [0.04, 0.06, 0.08, 0.10, 0.12, 0.14, 0.14, 0.12, 0.10, 0.10]:

Bin E A (A−E)·ln(A/E)
1 0.10 0.04 (−0.06)·ln(0.4) = 0.0550
2 0.10 0.06 (−0.04)·ln(0.6) = 0.0204
3 0.10 0.08 (−0.02)·ln(0.8) = 0.0045
4-7 0.10 0.10-0.14 0.0306 (sum of positive terms)
8-10 0.10 0.12, 0.10, 0.10 0.0036

PSI ≈ 0.114. The conventional reading: below 0.10 stable, 0.10-0.25 investigate, above 0.25 strong shift that warrants the retraining path. So this feature lands in "investigate" — meaning someone looks this week, not a page at 3 a.m. Two implementation cautions: PSI needs enough samples per bin (under ~1,000 rows, empty bins make ln blow up — add smoothing or don't compute it), and KS at α = 0.05 false-alarms on 5% of tests by chance, which becomes a real problem when you monitor 60 features. That threshold design is the next chapter's subject.

Nobody Reads the Dashboard: Alert Thresholds That Survive Week Two

Monitoring that requires someone to open a dashboard is not monitoring — it is a report nobody requested. Alerting is the working part, and it is a design problem: every alert needs a signal, a window, a threshold, a severity, and the piece teams skip — the action it triggers. An alert without a runbook entry becomes noise inside two weeks, and noise trains the on-call to ignore everything, including the page that mattered.

A starting baseline for a synchronous fraud endpoint with a p99 SLO of 200 ms:

Signal Metric Window Threshold Action
Latency p99 inference latency 5 min > 250 ms page; check replicas, queue depth, model size
Errors 5xx + inference exceptions 15 min > 1% page; roll back if it started post-deploy
Freshness age of newest feature value 30 min > 4 h (SLA 2 h) ticket; check upstream job/consumer
Null rate per-feature null fraction 1 h > 2× the 7-day baseline ticket; likely source-side bug
Input shift PSI per feature 24 h > 0.25 for 3 consecutive days ticket; open a retraining evaluation
Prediction dist. mean score, decile mix 24 h > 3σ vs same window last week investigate input pipeline
Traffic request volume vs expected 15 min ±40% vs same hour last week check upstream or app outage
Quality AUC on newly arrived labels daily drop > 2 pts vs gate evaluation trigger challenger training + review

Note the severity split: latency and errors are page (users are hurting now); drift and freshness are ticket (degradation, nothing is broken this minute). Paging on drift trains people to ignore pages; ticketing on a latency breach trains people to ignore drift.

Two disciplines separate working alerts from pager spam:

  1. Sample size and multiple comparisons. The KS math from the previous chapter shows the critical value depends on sample size — comparing 200 random rows hourly will fire on noise. Require a minimum n (1,000+ rows per comparison) or aggregate to daily windows for slow features. Then correct for multiplicity: testing 60 features at α = 0.05 expects ~3 false alarms per day by chance alone. Fixes: screen at α = 0.01, require persistence (PSI > 0.25 three days running), and apply a Holm-Bonferroni correction if you alert per-feature rather than on a single worst-of-set statistic.
  2. Compare like with like. Traffic, transaction amounts, and feature distributions are seasonal — Sunday is not Tuesday, and month-end is not the 15th. A threshold of "yesterday's value ± 40%" pages every Monday morning. Compare against the same window last week, or model a seasonal expected value and alert on the residual. The prediction-distribution row in the table exists for the same reason: a mean score 3σ off last Monday is signal; 3σ off yesterday is the weekend.

Watch the serving layer even when the model is statistically fine. GPU utilization (an idle GPU bills identically to a busy one), queue depth (it rises minutes before p99 breaches — your earliest latency warning), cache hit rate, and feature-store miss rate all predict user-visible failure earlier than the user-visible metric does. Queue depth leading p99 is the detail interviewers remember, because it shows you monitor causes, not just symptoms.

Finish the wiring an alert needs: every row maps to a runbook section that says confirm (is this real? check for a deploy, an incident, a schema change), classify (upstream bug → fix upstream; genuine shift → train a challenger; broken labels → fix labels first), and record the outcome so thresholds get tuned from evidence instead of opinion. Without that loop, the table above is decoration.

The Label Arrives Thirty Days Late: Measuring Real Quality and Deciding to Retrain

Concept drift lives where labels live: falling accuracy, rising log-loss, or worsening calibration on feedback data. Two properties of real labels make this much harder than "join predictions to outcomes" — labels arrive late, and labels arrive selectively. Get either wrong and your quality metric lies to you in the confident direction.

Late labels. A default is known 90 days after the loan; a churn outcome shows up weeks after the behavior. Build a daily job that joins newly arrived outcomes to the predictions they belong to, keyed by (entity, model_version), and compute metrics over a rolling 14-day window of those predictions. The version key is the part teams omit: once v7 deploys, old labels must keep scoring v6's predictions so each version's true quality curve stays visible. Join on prediction time, not on today — otherwise every version inherits the others' results.

Selective labels. A collections agency gets default labels only for loans it approved; rejected applicants' outcomes are never observed. Evaluate naively on observed labels and the model looks excellent — you are measuring "how did we do on the cases we chose," and retraining on that measure reinforces the selection. Two standard corrections: reserve a small randomized holdout (1-2% of traffic) whose decisions ignore the model, giving an unbiased window into reality, or apply inverse-propensity weighting to scale each observed label by the inverse probability it got labeled. Interviewers who know production ask this; naming it separates dashboard readers from ML engineers.

Encode retraining triggers instead of debating them weekly:

retrain_triggers:
  data_drift:  { psi_max: 0.25, sustained_days: 3 }
  quality:     { auc_drop_pts: 2.0, window_days: 14 }
  freshness:   { feature_age_hours: 4, features: [txn_agg_90d] }
  calendar:    { every_days: 30 }        # periodic backstop, drift or not
any_trigger: open_challenger             # trains a candidate — never auto-promotes

The last line is the safety property: a trigger opens a challenger, it never overwrites production. The challenger trains, then runs the exact evaluation gate from CI/CD for ML Pipelines — effect size, paired-bootstrap confidence, slice guardrails, canary — before it can touch a single live request. Monitoring detects; the gate decides. Collapsing the two is how a data glitch becomes a self-inflicted outage.

Every quality alert needs a three-step runbook, because "accuracy dropped" has three different causes with opposite fixes:

  1. Confirm it is real. Check for a recent deploy, an upstream schema change, an incident, or a broken label feed. Roughly half of these alerts resolve to a pipeline problem, and retraining on bad labels makes the model worse, not better.
  2. Classify. Input pipeline bug → fix upstream, no retrain. Genuine distribution shift → train the challenger (the calendar/drift triggers above). Broken or delayed labels → fix the label path first; you cannot measure quality with a broken meter.
  3. Record the outcome. Note in the incident whether the threshold was right (too sensitive? too slow?) and adjust the numbers from evidence. Thresholds that never get tuned stay wrong forever.

A randomized 1% control group — kept on the previous model, decisions unchanged — is the cheapest insurance in this chapter. When control and candidate both degrade, the world moved (data or labels). When only the candidate degrades, your model is the problem. Without the control you are guessing between those two, and guessing is not monitoring.

This is the last operational layer of the lifecycle: endpoints serving under an SLO, gated promotions, and monitoring that turns degradation into a challenger run on evidence. The capstone asks you to wire all three together into one pipeline you can demo and defend: End-to-End Capstone Project.

Quiz

1. You compare a numeric feature with the two-sample KS test: reference n = 500, live m = 500, observed D = 0.071. Is there significant drift at α = 0.05?

Question 1 options

2. A decile bin used for PSI has reference fraction E = 0.10 and live fraction A = 0.20. What is that bin's contribution to PSI, and how is the total interpreted?

Question 2 options

3. Input distributions look identical to the reference for six weeks — KS and PSI stay green — yet accuracy on newly arrived labels falls 5 points. What is the correct diagnosis?

Question 3 options

4. You run one KS test per feature every hour: 60 features × 24 hours = 1,440 tests per day at α = 0.05, with no correction. Approximately how many fire from chance alone, and what is the right fix?

Question 4 options

Flashcards

Question

What are the three kinds of drift, and which signal reveals each?

Answer

Covariate drift — P(X) shifts (new categories, mix change): visible to KS/PSI on features and prediction-distribution checks. Label/prior drift — P(Y) shifts (default rate 2%→5%): visible in the label distribution once labels arrive. Concept drift — P(Y|X) shifts (same inputs, new meaning): visible ONLY in accuracy/loss on fresh labels; every input test stays green.

Question

State the two-sample KS statistic and its 5% critical value, and explain the sample-size trap.

Answer

D = max_x |F_ref(x) − F_live(x)|, the largest gap between the two empirical CDFs. Critical value ≈ 1.36·sqrt((n+m)/(n·m)). n=m=2000 → 0.043; n=m=500 → 0.086. The trap: the same observed D=0.071 is significant at the larger sample and not at the smaller — small monitoring windows hide drift, so minimum-n requirements belong in alert thresholds.

Question

How is PSI computed and interpreted, and what are its implementation cautions?

Answer

Bucket live data into the reference's deciles, then PSI = Σ (A_i − E_i)·ln(A_i/E_i) over bins, with E_i/A_i the reference/live fractions. Thresholds: < 0.10 stable, 0.10–0.25 investigate, > 0.25 strong shift → retraining path. Cautions: needs enough rows per bin (~1,000+ or smooth empty bins, since ln(0) explodes) and it still only measures P(X), not concept drift.

Question

What five properties must every ML alert define, and how do you split severity?

Answer

Signal, window, threshold, severity, and the action it triggers (runbook entry) — an alert with no action becomes noise within two weeks. Severity: page for user-facing failures right now (p99 latency breach, error rate); ticket for degradation (drift PSI > 0.25 for days, feature freshness past SLA). Paging on drift and ticketing on latency both train responders to ignore alerts.

Question

Why do delayed and selective labels break quality monitoring, and what fixes each?

Answer

Delayed labels: live AUC scores predictions from weeks ago — join outcomes to predictions keyed by (entity, model_version) on a rolling window so each version's curve stays true. Selective labels: outcomes exist only for chosen cases (approved loans), inflating measured quality — fix with a small randomized holdout whose decisions ignore the model, or inverse-propensity weighting. Triggers open a challenger that still passes the promotion gate; nothing auto-promotes.

Revision Notes

Key Takeaways

  • 1. KS and PSI measure P(X) only — concept drift with unchanged inputs stays invisible until fresh labels arrive, so input monitoring and label monitoring are separate systems
  • 2. The KS critical value 1.36·sqrt((n+m)/(n·m)) depends on sample size, so window length silently decides whether drift can be detected at all
  • 3. PSI sums (A−E)·ln(A/E) over reference deciles: <0.10 stable, 0.10-0.25 investigate, >0.25 strong shift — and it needs enough rows per bin to be defined
  • 4. Every alert needs signal, window, threshold, severity, and a runbook action; page on latency/errors, ticket on drift/freshness, and compare against the same window last week
  • 5. Delayed and selective labels both inflate apparent quality — version-keyed rolling joins, a randomized holdout or IPS, and a 1% control group keep the measurement honest

Interview Tips

  • • "Data drift vs concept drift?" — define both precisely (P(X) shift vs P(Y|X) shift), name the test for the first (KS/PSI with the critical value), and state plainly that no input test catches the second — only accuracy on fresh labels does
  • • "How would you set an alert threshold?" — cite sample size (KS critical value moves with n), multiplicity (60 features × hourly × 0.05 ≈ 72 false alarms/day), persistence rules, and seasonality (compare vs same window last week), then name the action each alert triggers
  • • "Your model's live AUC dropped 4 points — walk me through it." — confirm it's real (deploy? label feed?), classify (upstream bug vs genuine shift vs broken labels), then open a challenger through the promotion gate; mention selective-label bias if outcomes exist only for chosen cases
  • • "What do you monitor besides model quality?" — p99 latency, error rate, feature freshness, null rates, prediction distribution, queue depth (leading indicator), GPU utilization, feature-store miss rate — and severity-split them into pages vs tickets

Cheat Sheet

Production Monitoring and Drift Detection

Three drifts, three signals:

Drift Shifts Seen by
Covariate P(X) KS / PSI on features, prediction dist
Label (prior) P(Y) label distribution when labels arrive
Concept P(Y|X) accuracy/loss on fresh labels ONLY

KS: D = max|F_ref − F_live|; crit ≈ 1.36·sqrt((n+m)/(n·m)). n=m=2000 → 0.043; n=m=500 → 0.086. Same D can be significant or not — sample size is part of the threshold.

from scipy.stats import ks_2samp
res = ks_2samp(ref, live)   # res.statistic, res.pvalue

PSI: decile bins of the reference; PSI = Σ (A−E)·ln(A/E). <0.10 stable | 0.10-0.25 investigate | >0.25 strong shift. Needs ~1,000+ rows/bin; smooth empty bins.

Alert baseline: p99 > 250 ms/5 min → PAGE · error rate > 1%/15 min → PAGE · feature age > 4 h (SLA 2) → TICKET · null rate > 2× baseline → TICKET · PSI > 0.25 for 3 days → TICKET · AUC drop > 2 pts on fresh labels → challenger.

Noise control: 60 features × 24 hourly tests × α 0.05 ≈ 72 false alarms/day → screen at α 0.01, min n 1,000, require persistence, Holm-Bonferroni, compare vs same window last week.

Quality loop: join labels to predictions by (entity, model_version), rolling 14 days; randomized 1% control or inverse-propensity weighting for selective labels; triggers open a CHALLENGER that passes the promotion gate — never auto-promote.

Continue Learning