Problem
Every guarantee from held-out evaluation assumes the test set comes from the same distribution as the data the model will see. After deployment that assumption decays. Customers change, a competitor launches, a sensor is recalibrated, an upstream team renames a field, a pandemic moves everyone online. The model keeps returning numbers with the same confidence, and nothing in the serving path fails: a server answering in 20 ms with stale probabilities looks perfectly healthy.
The labels that would show the problem often come late or never: a loan defaults months after approval, a fraud chargeback arrives weeks after the purchase, a recommendation that wasn't clicked says little about the one that wasn't shown. Monitoring is the practice of catching these changes from whatever signals are available, telling apart the ones that hurt the model from the ones that don't, and deciding when to recalibrate, retrain, or look for a bug.
Intuition
A classifier is an estimate of P(y | x), learned from examples drawn from a joint P(x, y). That joint can change in three ways that matter differently. If only the inputs move, P(x), into regions the model saw rarely, its answers are still right where it learned well and become guesses where it didn't. If only how common each outcome is changes, P(y), every probability is off by the same factor in the odds, and a one-number correction fixes it. If the relationship itself changes, P(y | x), the model is answering a question that no longer has the same answer, and only new labels can say how.
So there are two kinds of signals. Inputs and predictions are available immediately for every request and show that something changed. Outcomes arrive later, for some of the requests, and show whether the model is wrong. A good monitor uses the cheap signals as an early warning and the expensive ones as the verdict.
Mechanics
Three kinds of shift
Write the joint two ways: P(x, y) = P(y | x) P(x) = P(x | y) P(y). Following Moreno-Torres et al. (2012), each kind of shift keeps one factor fixed and changes the other:
- Covariate shift: P(x) changes, P(y | x) does not. A credit model trained mostly on salaried applicants starts seeing freelancers. If the model learned P(y | x) well everywhere, it stays correct; its errors grow only where the new inputs are rare in training. Shimodaira (2000) showed training can be corrected by weighting each example by Pnew(x) / Ptrain(x).
- Label shift (prior shift): P(y) changes, P(x | y) does not. A disease becomes more common, but a sick patient's symptoms look the same. By Bayes' rule the correct posterior moves by a fixed factor in the odds, so the model's probabilities can be corrected without retraining (Saerens et al., 2002).
- Concept drift: P(y | x) changes. Fraudsters adapt to the rules, a feature's meaning changes, a policy change makes the same applicant riskier. The model's mapping is now wrong, and since the inputs may not move at all, nothing short of new labels reveals it.
In time, drift can be sudden (a code release), gradual (tastes changing), recurring (seasons, weekends), or a one-off outlier. Gama et al. (2014) survey the detectors and adaptation strategies for each; models that learn from every new example are covered under online learning.
What to watch
The first line of defence is the plain data checks: the share of nulls, values outside the training range, unseen categories, a feature that is suddenly constant. Most production "drift" is a broken pipeline, not a changing world, and these checks catch it the same day (Breck et al., 2017). Then come distribution tests per feature and on the model's output. The output is the most useful single series: it is one number per request, it summarizes every feature the model uses, weighted by how much the model uses it, and its mean is the base rate the model believes in, which can be compared with the observed rate once labels mature.
The population stability index
Split a feature into bins, usually deciles of the reference (training or launch) data, so each holds 10% of it. Let ei be the reference share in bin i and ai the current share. Then
Every term is non-negative, since ai − ei and ln(ai/ei) always have the same sign. PSI is the sum of the two KL divergences, KL(a ‖ e) + KL(e ‖ a), over the binned distributions, so it is symmetric. The usual reading, a convention from credit scoring with no single canonical source, is below 0.1 stable, 0.1 to 0.25 worth watching, above 0.25 a significant shift. A bin that is empty in either distribution makes its term infinite, so implementations clip shares to a small ε; the result then depends on ε.
The two-sample Kolmogorov–Smirnov test
For a continuous feature, compare the empirical CDFs Fref and Fcur directly, with no bins:
Under the null hypothesis that both samples come from one distribution, √(nm/(n+m)) · D follows the Kolmogorov distribution for large samples, so the test rejects at level α when D exceeds c(α) · √((n+m)/(nm)) with c(α) = √(−ln(α/2) / 2), 1.358 at α = 0.05. The p-value is the series 2 Σk≥1 (−1)k−1 e−2k²λ², with λ = (√Ne + 0.12 + 0.11/√Ne) · D and Ne = nm/(n+m) (Stephens' correction). The catch is that the critical value shrinks like 1/√n: with enough traffic every real-world feature "fails" the test, so KS answers "is there any difference?", not "is the difference big enough to matter?". PSI is a size, KS a significance; dashboards usually alert on a size.
Delayed and partial labels
When labels arrive over weeks, the most recent cohorts always look better than they are: only the quick outcomes are in. Comparing this week's observed rate with last quarter's fully matured one finds a false improvement every time. Compare cohorts at the same age, wait for a maturity window, or model the arrival curve. Labels can also be biased by the model itself: a rejected loan never shows whether it would have defaulted, and a fraud model only gets labels for the cases it flagged for review. Those gaps are filled with a small random holdout that is served without the model's decision, which also keeps the evaluation honest for causal questions.
Correcting label shift
If P(x | y) is fixed and the positive rate moves from π to π′, Bayes' rule gives the new posterior from the old one:
In log-odds this adds the constant ln(π′/(1−π′)) − ln(π/(1−π)) to every prediction. π′ itself is unknown without labels, but can be estimated from the predictions on new data: Saerens et al. (2002) iterate EM between the corrected posteriors and their mean, and Lipton et al. (2018) solve a linear system using the model's confusion matrix from validation (black-box shift estimation), which also detects label shift with a statistical test.
Retraining triggers
- Schedule. Retrain daily, weekly or monthly on a sliding window. Simple and predictable, wasteful when nothing changed, too slow after a sudden break.
- Input drift. PSI above the action threshold on an important feature or on the score. Fast, but covariate shift often doesn't hurt, so this trigger should open an investigation, not ship a model.
- Performance. A matured-label metric (Brier, log loss, AUC, calibration gap) crosses a threshold or a drift detector fires. The signal that matters, late by the label delay.
- Calibration only. When ranking holds (AUC unchanged) but the probabilities are off, refit a calibrator on recent labeled data: two parameters, minutes, and no new model to validate.
Whatever fires, the new model goes through the same evaluation, shadow or canary rollout as the first one; an automatic retrain that trains on a broken feed ships the bug.
| Component | Shape/Params | Meaning | Source |
|---|---|---|---|
| PSI | bin shares e, a of length B | Symmetric binned KL between reference and current; a size of shift | Formula above |
| KS statistic D | samples of size n, m | Largest gap between empirical CDFs; a test of any shift | Formula above (scipy.stats.ks_2samp) |
calibration_curve | n_bins=5, strategy="uniform" | Observed rate and mean prediction per score bin, empty bins dropped | calibration.py |
brier_score_loss | scalar | Mean squared error of the probabilities; a strictly proper score | _classification.py |
log_loss | scalar | Mean negative log-likelihood of the outcomes | _classification.py |
roc_auc_score | scalar | Ranking quality; unchanged by a monotone recalibration | _ranking.py |
confusion_matrix | K × K | The validation matrix black-box shift estimation inverts | _classification.py |
CalibratedClassifierCV | method="sigmoid" (default) | Refits a calibrator on recent labels | calibration.py |
FrozenEstimator wrapping | a fitted model | Calibrate a deployed model without refitting it; all given data goes to the calibrator | calibration.py |
_sigmoid_calibration | slope a, intercept b | Platt's two-parameter fit, with smoothed targets | calibration.py |
Worked example
One feature x, standard normal at launch. The true outcome probability is σ(2x − 1), and the deployed model learned it exactly: p̂ = σ(2x − 1). Each week is 2000 evenly spaced quantiles of that week's input distribution, an idealized sample with no noise, and each point's outcome is scored by its true probability, so every number below is the expected value, not one random draw. Monitoring uses the launch deciles of x as PSI bins.
PSI on a shift of 0.3
Reference x ~ N(0, 1) and current x ~ N(0.3, 1), 2000 points each, binned at the reference deciles:
| Bin | Reference e | Current a | a − e | ln(a/e) | Term |
|---|---|---|---|---|---|
| 1 | 0.100 | 0.0570 | -0.0430 | -0.562 | 0.0242 |
| 2 | 0.100 | 0.0700 | -0.0300 | -0.357 | 0.0107 |
| 3 | 0.100 | 0.0780 | -0.0220 | -0.248 | 0.0055 |
| 4 | 0.100 | 0.0850 | -0.0150 | -0.163 | 0.0024 |
| 5 | 0.100 | 0.0925 | -0.0075 | -0.078 | 0.0006 |
| 6 | 0.100 | 0.0990 | -0.0010 | -0.010 | 0.0000 |
| 7 | 0.100 | 0.1075 | 0.0075 | 0.072 | 0.0005 |
| 8 | 0.100 | 0.1175 | 0.0175 | 0.161 | 0.0028 |
| 9 | 0.100 | 0.1305 | 0.0305 | 0.266 | 0.0081 |
| 10 | 0.100 | 0.1630 | 0.0630 | 0.489 | 0.0308 |
| Sum | 1 | 1 | 0.0856 |
Mass moves out of the low bins into the high ones, and the two tails contribute most: 0.024 and 0.031 of the 0.0856. The PSI is below the 0.1 watch level. For two unit normals a distance δ apart, each KL direction is δ²/2, so the unbinned value is δ² = 0.09; binning can only lose information, and ten bins give 0.0856.
KS on the same shift, and on a tiny one with ten times the data
On the same two samples the largest CDF gap is D = 0.1195, near the midpoint of the two means, where the gap between two unit normals peaks at 2Φ(δ/2) − 1 = 0.1192. The critical value at α = 0.05 is 1.358 · √((2000 + 2000) / (2000 · 2000)) = 0.0429, and the p-value is 6.3e-13: rejected decisively, while PSI said "stable".
Now shift by only 0.05 with 20000 points per sample. D = 0.0200 against a critical value of 0.0136, p = 0.00068: still rejected. The PSI is 0.0024, about a fortieth of the watch level. At production volumes the test flags shifts far too small to change any prediction, which is why KS is used to rank features by how much they moved, and PSI or an effect size to decide whether to act.
Five weeks, one decision rule
Each week, the monitor computes the PSI of x against launch, then, once labels mature, the mean predicted and observed rates, the Brier score next to the best achievable Brier (that of the true probabilities), and the largest gap in a five-bin reliability table. The rule: recalibrate or retrain if any reliability gap exceeds 0.02; else investigate inputs if PSI ≥ 0.25; else watch if PSI ≥ 0.1.
| Week | What changed | PSI(x) | Predicted rate | Observed rate | Brier | Best Brier | Max gap | Decision |
|---|---|---|---|---|---|---|---|---|
| 1 | baseline | 0.0000 | 0.3523 | 0.3523 | 0.1405 | 0.1405 | 0.000 | ok |
| 2 | small input shift | 0.0094 | 0.3808 | 0.3808 | 0.1443 | 0.1443 | 0.000 | ok |
| 3 | covariate shift | 0.3401 | 0.5303 | 0.5303 | 0.1510 | 0.1510 | 0.000 | investigate inputs |
| 4 | concept drift | 0.0000 | 0.3523 | 0.3033 | 0.1946 | 0.1779 | 0.238 | recalibrate or retrain |
| 5 | base-rate drift | 0.0000 | 0.3523 | 0.4248 | 0.1555 | 0.1486 | 0.121 | recalibrate or retrain |
Since each point's expected Brier is (p̂ − p)² + p(1 − p), the gap between the two Brier columns is exactly the mean squared error of the probabilities: zero means the model is right.
- Week 3, covariate shift. x moves to N(0.6, 1). PSI is 0.340, a "significant shift", and the mean prediction jumps from 0.352 to 0.530. Yet the observed rate matches it and Brier equals the best possible: the model still knows P(y | x). The alarm is right that the world changed and wrong that the model broke. Prediction drift without a calibration gap is the signature of covariate shift.
- Week 4, concept drift. The truth becomes σ(x − 1): the feature now matters half as much. The inputs are identical, so PSI is 0.0 and the predictions are unchanged, and nothing fires until labels arrive. Then the observed rate is 0.303 against 0.352 predicted, and the top bin is off by 0.238.
- Week 5, base-rate drift. The truth becomes σ(2x − 0.5): every case is 0.5 more likely in log-odds, exactly the form a change of prior takes. Again PSI is 0.0; only labels show the observed rate rising to 0.425.
| Week 4 bin | Cases | Mean predicted | Observed | Gap |
|---|---|---|---|---|
| 1 | 847 | 0.080 | 0.139 | -0.060 |
| 2 | 387 | 0.293 | 0.280 | 0.013 |
| 3 | 284 | 0.497 | 0.376 | 0.120 |
| 4 | 249 | 0.699 | 0.482 | 0.217 |
| 5 | 233 | 0.895 | 0.657 | 0.238 |
Recalibrating instead of retraining
Both label-detected weeks keep the model's ranking: the true probability is still increasing in the model's logit z = 2x − 1. So fit Platt scaling, σ(a·z + b), on the week's labels (here by Newton's method, with each point's true probability as a soft target):
| Week | a | b | Brier before | Brier after | Best Brier |
|---|---|---|---|---|---|
| concept drift | 0.500 | -0.500 | 0.1946 | 0.1779 | 0.1779 |
| base-rate drift | 1.000 | 0.500 | 0.1555 | 0.1486 | 0.1486 |
The fits recover the drift exactly. For concept drift the truth's logit is x − 1 = 0.5·z − 0.5; for base-rate drift it is z + 0.5. Two parameters fitted in milliseconds bring Brier back to the best achievable. This works only because the drift here is monotone in z; if the new relationship had reordered cases (a different feature now mattering), no calibrator could fix it, AUC would drop, and the model would need retraining.
Prior correction and maturing labels
Label shift with a known prior: a model trained where 10% of cases were positive is deployed where 30% are. The odds factor is (0.3/0.1) / (0.7/0.9) = 27/7, adding ln(27/7) ≈ 1.350 to every log-odds:
| Model's p | 0.2 | 0.5 | 0.8 |
|---|---|---|---|
| Corrected p′ | 0.491 | 0.794 | 0.939 |
A case the model calls a coin flip is 0.794 = 27/34 likely in the new population.
Delayed labels: suppose the baseline week's positives are reported over five weeks, with this share known after each:
| Weeks since prediction | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| Share of positives reported | 20% | 50% | 80% | 95% | 100% |
| Apparent positive rate | 0.070 | 0.176 | 0.282 | 0.335 | 0.352 |
The model predicts 0.352 and is exactly right, but a week after deployment it appears to overpredict fivefold. Unlabeled cases are not negatives yet; compare each cohort with the reference at the same age.
And the empty-bin trap: with reference shares 0.25, 0.25, 0.25, 0.25 and current shares 0.4, 0.3, 0.3, 0.0, the last term is (0 − 0.25) · ln 0 = +∞. Clipping to ε = 0.0001 gives 1.955 for that term and PSI = 2.044, a number set mostly by the choice of ε. Report an emptied bin as its own alert.
Input PSI is 0.34 on the most important feature, and the matured calibration is perfect. Retrain?
Not because of this. It is covariate shift the model handles, as in week 3. Check the pipeline for a bug, check performance in the newly common region, and keep watching; retraining on the new mix may help a little but is not urgent.
Implementation
A batch monitoring job: PSI and KS per feature against a reference snapshot, and, for the cohort whose labels have matured, calibration metrics and a refit calibrator for the frozen deployed model.
import numpy as np
from scipy.stats import ks_2samp
from sklearn.calibration import CalibratedClassifierCV, calibration_curve
from sklearn.frozen import FrozenEstimator
from sklearn.metrics import brier_score_loss, log_loss, roc_auc_score
def psi(reference, current, bins=10, eps=1e-4):
edges = np.quantile(reference, np.linspace(0, 1, bins + 1)[1:-1])
e = np.bincount(np.searchsorted(edges, reference, side="right"), minlength=bins) / len(reference)
a = np.bincount(np.searchsorted(edges, current, side="right"), minlength=bins) / len(current)
empty = (a == 0) | (e == 0)
e, a = np.clip(e, eps, None), np.clip(a, eps, None)
return float(np.sum((a - e) * np.log(a / e))), bool(empty.any())
for name in features: # inputs: every request, now
value, emptied = psi(X_ref[name], X_week[name])
d, p = ks_2samp(X_ref[name], X_week[name])
report(name, psi=value, emptied=emptied, ks=d, ks_p=p)
scores = model.predict_proba(X_mature)[:, 1] # outcomes: the matured cohort
frac_pos, mean_pred = calibration_curve(y_mature, scores, n_bins=5)
report("model", auc=roc_auc_score(y_mature, scores),
brier=brier_score_loss(y_mature, scores), log_loss=log_loss(y_mature, scores),
max_gap=np.abs(frac_pos - mean_pred).max(),
rate_gap=scores.mean() - y_mature.mean())
recalibrated = CalibratedClassifierCV(FrozenEstimator(model), method="sigmoid")
recalibrated.fit(X_mature, y_mature) # two parameters; the model is not refit
FrozenEstimator makes CalibratedClassifierCV use all the data for the calibrator and leave the
model alone. scikit-learn's _sigmoid_calibration parameterizes Platt as σ(−(a·s + b)) and smooths the 0/1
targets, so its a has the opposite sign of the one above. The recalibrated model should itself be scored on a later
cohort before it replaces the old one. Thresholds and reference windows belong in configuration next to the model
version, since a retrained model needs a new reference snapshot: its own training data.
Tradeoffs
- Sensitivity vs alert fatigue. Hundreds of features times daily tests produce false alarms every day; a team that learns to ignore them misses the real one. Alert on the score and a few key features, use sizes rather than p-values, and require persistence (two windows in a row).
- PSI vs KS vs model-based detectors. PSI is easy to explain and handles categories but depends on the bins and ε. KS needs no bins but is univariate and over-sensitive at volume. Training a classifier to tell reference from current data (a domain classifier) catches multivariate shift that no single feature shows; Rabanser et al. (2019) found that testing a dimension-reduced representation, or the model's own outputs, works well for high-dimensional inputs.
- Reference window. Comparing with training data detects everything since launch; comparing with last week detects sudden changes but lets slow drift through unnoticed. Seasonal data needs last year's same week.
- Label delay vs label quality. Early, partial labels give a fast but biased verdict; waiting for maturity is accurate but slow. Proxy outcomes (a click for a purchase) are faster still and drift on their own.
- Recalibrate vs retrain vs adapt online. Recalibration is cheap and safe but fixes only monotone errors. Retraining fixes concept drift but needs fresh labels, validation and a rollout. Online updates adapt fastest and can also learn a corrupted feed within hours.
- Feedback loops. A deployed model changes the data it later trains on: approved loans are the only labeled loans, recommended items get the clicks. Sculley et al. (2015) list these hidden loops among ML systems' technical debts; a small randomized holdout is the usual remedy.
Why can't any input-only monitor detect week 4's concept drift?
Concept drift changes P(y | x) and can leave P(x) exactly as it was, as here: PSI is 0 and the predictions are identical. Only outcomes carry information about P(y | x), so only labeled data can show it.
Your KS test rejects on every feature, every day, at p < 0.001. What do you do?
Look at the effect sizes. With millions of rows, the test detects differences too small to matter; switch the alert to PSI or D above a size threshold, and use the p-values only to rank which features moved most.
The predicted rate is stable but AUC on matured labels fell from 0.82 to 0.71. Will recalibration help?
No. A monotone calibrator keeps the ranking, so it cannot change AUC; the model ranks cases worse than before. That is concept drift beyond a rescaling, and it needs retraining on recent labeled data.
Connections
Read first
- Classification metrics intro
- Calibration core
Read next
No page builds on this one yet.
References
- Moreno-Torres, Raeder, Alaiz-Rodríguez, Chawla and Herrera. A Unifying View on Dataset Shift in Classification. Pattern Recognition, 2012.
- Shimodaira. Improving Predictive Inference under Covariate Shift by Weighting the Log-Likelihood Function. Journal of Statistical Planning and Inference, 2000.
- Saerens, Latinne and Decaestecker. Adjusting the Outputs of a Classifier to New a Priori Probabilities: A Simple Procedure. Neural Computation, 2002.
- Lipton, Wang and Smola. Detecting and Correcting for Label Shift with Black Box Predictors. ICML 2018. arXiv:1802.03916
- Rabanser, Günnemann and Lipton. Failing Loudly: An Empirical Study of Methods for Detecting Dataset Shift. NeurIPS 2019. arXiv:1810.11953
- Gama, Žliobaitė, Bifet, Pechenizkiy and Bouchachia. A Survey on Concept Drift Adaptation. ACM Computing Surveys, 2014.
- Sculley et al. Hidden Technical Debt in Machine Learning Systems. NeurIPS 2015.
- Breck, Cai, Nielsen, Salib and Sculley. The ML Test Score: A Rubric for ML Production Readiness and Technical Debt Reduction. IEEE Big Data 2017.
- Platt. Probabilistic Outputs for Support Vector Machines and Comparisons to Regularized Likelihood Methods. In Advances in Large Margin Classifiers, MIT Press, 2000.
- scikit-learn 1.9.1:
calibration_curve,CalibratedClassifierCV,brier_score_loss,log_loss.