-
Log every prediction as an immutable event — no logging = no monitoring. Per request, write one row: prediction_id, ts, model_version, the raw feature vector actually scored (post-transform, exactly what the model saw), the output (pred_proba + pred_label), and a label_join_key. Land it in a columnar store (Parquet on S3, BigQuery, Delta). Labels arrive later out-of-band → write them to a separate table keyed by label_join_key and left-join on arrival; never block scoring on a label. Snapshot the training reference (a held-out slice of the training data + its predictions) once and pin it — every drift test compares live vs this fixed reference.
-
Pick the drift test per feature type — do not PSI everything.
| Signal | Test | Fires when | Default threshold |
|---|
| Numeric / continuous feature | PSI (population stability index) | Binned distribution shifted vs reference | PSI > 0.2 = significant; 0.1–0.2 = watch |
| Numeric, distribution shape | KS (Kolmogorov–Smirnov) 2-sample | Max CDF gap large | p < 0.05 |
| Categorical feature | Chi-square / PSI on category freqs | Category mix shifted, new/unseen level | p < 0.05 / PSI > 0.2 |
| Prediction output (proba) | PSI / KS on pred_proba | Output distribution drifts | PSI > 0.2 |
| Multivariate / overall | Domain classifier (ref vs live, AUC) | Classifier separates ref from live | AUC > 0.7 |
Compute over a rolling window (default: last 7 days or 10k preds, whichever larger) vs the pinned reference. Use a fixed reference for stable populations; switch to a trailing-window reference only if the population legitimately evolves (and document that you've given up detecting slow drift). Apply Bonferroni/BH correction across features — with 200 features at p<0.05 you get ~10 false alarms per run by chance.
-
Separate the three drift types — they mean different things and trigger different actions.
- Data (input) drift — features moved. Model may still be fine; this is an early warning, not proof of decay. Page only if widespread.
- Prediction drift — output distribution moved without a known input cause → upstream feature pipeline broke, or real population shift. Higher signal than single-feature input drift.
- Concept drift / performance decay — the input→output relationship changed. Only measurable once labels land. This is the one that actually justifies a retrain. Track the real metric (AUC/F1/MAE — whatever you optimized) per cohort window vs a baseline window (e.g. first 2 weeks post-deploy, or last known-good).
-
Run it with a library — don't hand-roll the stats. Evidently for reports + tests, whylogs for lightweight profile logging at scale, NannyML for estimating performance before labels arrive (CBPE/DLE). Pin evidently==0.4.* and use its Report / metric_preset API:
from evidently.report import Report
from evidently.metric_preset import DataDriftPreset, TargetDriftPreset
from evidently import ColumnMapping
cm = ColumnMapping(prediction="pred_proba")
report = Report(metrics=[
DataDriftPreset(stattest="psi", stattest_threshold=0.2),
TargetDriftPreset(),
])
report.run(reference_data=ref_df, current_data=live_df, column_mapping=cm)
res = report.as_dict()
drift = res["metrics"][0]["result"]
if drift["share_of_drifted_columns"] > 0.3:
fire_alert("data_drift", detail=drift)
For pre-label performance estimation when labels lag:
import nannyml as nml
est = nml.CBPE(problem_type="classification_binary", y_pred="pred_label",
y_pred_proba="pred_proba", y_true="label",
metrics=["roc_auc"], chunk_size=5000)
est.fit(reference_df)
estimated = est.estimate(live_df)
-
Detect train/serve skew explicitly — it's a silent killer. Re-score a sample of logged production feature vectors through the offline model and assert abs(online_proba − offline_proba) < 1e-4. Mismatch = a transform diverged between training and serving (different encoder fit, a default-fill applied online only, version skew in a preprocessing lib). Also compare training-time feature distributions vs serving-time for the same feature: skew shows up as a step change at deploy, not a gradual drift. Run this nightly on a sample.
-
Set thresholds and a retraining trigger — opinionated defaults, then tune to your false-alarm budget.
- Trigger retrain when any holds: estimated/actual primary metric drops > 5% relative below baseline for ≥2 consecutive windows; OR prediction-drift PSI > 0.2 sustained; OR > 30% of top-importance features drifted. One noisy window ≠ retrain — require persistence (2+ windows) to kill flapping.
- Schedule a baseline retrain regardless (weekly/monthly) so you never rely solely on drift detection catching it.
- On trigger, retrain a challenger and gate promotion through a champion/challenger comparison (step 7) — never auto-promote on a drift signal alone; drift can be benign.
-
Champion/challenger before promotion. Train challenger on fresh data, evaluate both on the same recent labeled window (and ideally a shadow/online split). Promote only if challenger beats champion on the primary metric by a margin beyond noise (bootstrap CI on the metric, or a paired test) — not a single point estimate. Log the decision + metrics to a model registry. Hand the actual rollout (canary, traffic shift, rollback) to serve-deploy-ml-model; this skill decides whether, that skill does how.
-
Alert routing, not just detection. Page on performance decay and prediction drift (high signal). Send input drift to a dashboard/digest, not a pager — single-feature input drift is frequent and usually benign; paging on it trains everyone to ignore the channel. Every alert carries: which signal, which features/metric, the value vs threshold, the window, and a link to the drift report.
Done = an injected input shift fires only the right feature's drift alert (negative control stays silent), prediction drift is detected independently, the performance tracker reflects the known-degraded period and trips the retrain trigger after sustained (not single-window) decay, train/serve skew is caught, and champion/challenger blocks a worse model from promoting.