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:
- 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.
- 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:
- 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.
- 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.
- 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?
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?
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?
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?
Flashcards
Question
What are the three kinds of drift, and which signal reveals each?
Click to reveal answer
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.
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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.