Monitoring Model Drift
What youβll learn
- the three drift types, stated precisely enough to tell apart
- the measurement that matters: input monitoring is neither necessary nor sufficient
- PSI and the KS statistic, with the shift each one needs before it notices
- why a label lag makes your accuracy dashboard a picture of the past
- what to actually monitor, in what order, and what to do when it fires
Three kinds of drift
The vocabulary gets used loosely, which is why teams end up applying the wrong fix. Stated as distributions, the distinction is exact.
Your model learned to approximate from a training sample of . Two things can change independently:
Covariate drift (also data drift): changes, does not. Your users get younger, a sensor recalibrates, a new marketing channel brings different traffic. The relationship you learned is still true β you are just seeing different inputs.
Concept drift: changes. The relationship itself moved. Fraud tactics evolve, a competitor changes prices, a pandemic changes what βnormal shoppingβ means. The same input should now produce a different answer.
Performance drift: your metric got worse. This is a symptom, not a cause, and it is the only one of the three that directly matters.
flowchart TD
A["Your metric dropped"] --> B{"Did P(X) change?"}
B -->|"yes"| C{"Did P(y|X) change too?"}
B -->|"no"| D["Concept drift only.
Input monitoring saw nothing."]
C -->|"no"| E["Covariate drift only.
Often costs you NOTHING."]
C -->|"yes"| F["Both. Retrain on recent data."]
D --> G["Retrain. You need labels."]
E --> H["Check before you act β
it may be a false alarm."]
F --> G
The measurement that matters
Here is the standard advice: monitor your input distributions, alert when they shift, retrain when they do. It is cheap and requires no labels, which is why everyone recommends it.
Now here is what it actually catches. One model, trained on a fixed boundary. Three scenarios, each 2,000 live rows:
| Scenario | Accuracy on live data | Input PSI |
|---|---|---|
| No drift | 0.9980 | 0.0034 |
| Covariate shift β moves 1.5 sd | 0.9995 | 2.0847 |
| Concept shift β rotates 90Β° | 0.4775 | 0.0053 |
Read those two rows again.
Covariate shift set off a screaming alarm and cost nothing. PSI 2.0847 against a conventional βlarge driftβ threshold of 0.25 β and accuracy was 0.9995, marginally better than the no-drift case. Because never changed, the model was still right about the new inputs. Retraining here would have been work for no benefit, and if you had auto-retrained on a small recent window you might well have made things worse.
Concept shift was invisible and halved accuracy. PSI 0.0053 β as stable as the no-drift baseline β while the model fell to 0.4775. Every input looked exactly as expected. The answers changed.
So the honest conclusion: input drift monitoring is neither necessary nor sufficient for detecting performance loss. It is still worth having β it is cheap, label-free and catches broken upstream pipelines fast β but it is a smoke detector in one room of the house, and you should not describe it as fire safety.
What the statistics can see
Two standard tests for βhas this distribution moved?β
Population Stability Index. Bin the reference sample (usually deciles), compute the proportion of each sample in each bin, and sum a symmetrised log-ratio:
where and are the reference and current proportions in bin . The conventional read is stable, β moderate, large. Those thresholds are convention, not theory.
Kolmogorov-Smirnov statistic. The largest vertical gap between the two empirical CDFs:
It comes with a p-value, which is both its advantage and its trap.
Measured against a known shift, 4,000 reference rows against 2,000 current:
| Shift in the mean | PSI | KS | KS p-value |
|---|---|---|---|
| 0.00 sd | 0.0056 | 0.0288 | 0.22 |
| 0.05 sd | 0.0134 | 0.0437 | 0.012 |
| 0.10 sd | 0.0338 | 0.0813 | 4.4e-08 |
| 0.20 sd | 0.0507 | 0.0998 | 5.6e-12 |
| 0.30 sd | 0.0998 | 0.1298 | 5.5e-20 |
| 0.50 sd | 0.2723 | 0.2125 | 3.8e-53 |
| 0.75 sd | 0.5899 | 0.3040 | 3.1e-109 |
| 1.00 sd | 0.9555 | 0.3837 | 1.3e-175 |
That p-value column is the trap. With enough rows, the KS test rejects any shift. At 0.10 sd the p-value is β a shift so small that it almost certainly does not affect your model, reported as one of the most statistically significant results you will ever see. Use the statistic, not the p-value, and set the threshold from what your model actually tolerates.
Your dashboard is a picture of the past
The one thing worth monitoring above all others is actual performance. The problem is that performance requires labels, and labels arrive late β sometimes very late.
- Fraud: days to weeks, until a chargeback lands.
- Churn: a month or a quarter, by definition.
- Loan default: months to years.
- Ad clicks: minutes. (This is the lucky case, and it is why ad-tech monitoring looks so different.)
Here is what a 30-day label lag does. Concept drift begins on day 20 and ramps up:
| Day | True accuracy today | What your dashboard shows | Input PSI |
|---|---|---|---|
| 0 | 0.9980 | β | 0.0054 |
| 20 | 0.9930 | β | 0.0081 |
| 30 | 0.9170 | 0.9980 | 0.0096 |
| 40 | 0.8400 | 0.9950 | 0.0107 |
| 50 | 0.7540 | 0.9930 | 0.0244 |
| 60 | 0.6460 | 0.9170 | 0.0089 |
| 70 | 0.5560 | 0.8400 | 0.0120 |
| 80 | 0.4780 | 0.7540 | 0.0079 |
On day 60 the model is at 0.6460 and your dashboard says 0.9170. Input monitoring says everything is fine, because for concept drift it genuinely cannot say otherwise. You are running a substantially broken model with two green lights.
This is not an argument against monitoring. It is an argument for knowing the latency of each signal you rely on:
| Signal | Needs labels | Latency | Catches |
|---|---|---|---|
| Input distributions | no | minutes | Broken pipelines, covariate shift |
| Prediction distributions | no | minutes | Both drift types, indirectly |
| Proxy outcomes | partly | hours to days | Some concept drift, early |
| Actual performance | yes | days to months | Everything, eventually |
See it move
Watch the two indicators on the right. Accuracy slides from 1.0000 toward 0.5 as the grey line rotates away from the amber one, and the PSI bar never moves β because the cloud of points is identical throughout. Nothing about the inputs changed. Only the correct answer did.
Prediction drift: the cheap middle ground
The most under-used signal is the distribution of your modelβs own output. It needs no labels, arrives immediately, and unlike input monitoring it responds to concept drift β because when the relationship changes, the mix of predictions usually changes with it.
If your fraud model flagged 2.1% of transactions every day for six months and today it flags 7.4%, something happened. You do not know what, and you do not need labels to know it is worth looking at.
import numpy as np
def psi(reference, current, bins=10):
"""Population Stability Index, binned on the reference deciles."""
edges = np.quantile(reference, np.linspace(0, 1, bins + 1))
edges[0], edges[-1] = -np.inf, np.inf
ref = np.clip(np.histogram(reference, edges)[0] / len(reference), 1e-6, None)
cur = np.clip(np.histogram(current, edges)[0] / len(current), 1e-6, None)
return float(((cur - ref) * np.log(cur / ref)).sum())
def daily_report(reference_X, reference_pred, live_X, live_pred, feature_names):
"""Everything you can compute today, with no labels at all."""
report = {"n_live": len(live_X), "features": {}}
for j, name in enumerate(feature_names):
report["features"][name] = {
"psi": round(psi(reference_X[:, j], live_X[:, j]), 4),
"mean_shift_sd": round(
float((live_X[:, j].mean() - reference_X[:, j].mean())
/ reference_X[:, j].std()), 3),
"null_rate": round(float(np.isnan(live_X[:, j]).mean()), 4),
}
# The signal that responds to concept drift without needing labels.
report["prediction_psi"] = round(psi(reference_pred, live_pred), 4)
report["positive_rate"] = round(float((live_pred >= 0.5).mean()), 4)
report["reference_positive_rate"] = round(float((reference_pred >= 0.5).mean()), 4)
return reportimport numpy as np
def psi(reference, current, bins=10):
"""Population Stability Index, binned on the reference deciles."""
edges = np.quantile(reference, np.linspace(0, 1, bins + 1))
edges[0], edges[-1] = -np.inf, np.inf
ref = np.clip(np.histogram(reference, edges)[0] / len(reference), 1e-6, None)
cur = np.clip(np.histogram(current, edges)[0] / len(current), 1e-6, None)
return float(((cur - ref) * np.log(cur / ref)).sum())
def daily_report(reference_X, reference_pred, live_X, live_pred, feature_names):
"""Everything you can compute today, with no labels at all."""
report = {"n_live": len(live_X), "features": {}}
for j, name in enumerate(feature_names):
report["features"][name] = {
"psi": round(psi(reference_X[:, j], live_X[:, j]), 4),
"mean_shift_sd": round(
float((live_X[:, j].mean() - reference_X[:, j].mean())
/ reference_X[:, j].std()), 3),
"null_rate": round(float(np.isnan(live_X[:, j]).mean()), 4),
}
# The signal that responds to concept drift without needing labels.
report["prediction_psi"] = round(psi(reference_pred, live_pred), 4)
report["positive_rate"] = round(float((live_pred >= 0.5).mean()), 4)
report["reference_positive_rate"] = round(float((reference_pred >= 0.5).mean()), 4)
return reportNote mean_shift_sdmean_shift_sd. Reporting a raw mean change is useless across features with different units;
dividing by the training standard deviation makes βthis feature moved 0.4 sdβ comparable everywhere.
Thresholds and what to do
Alerting on every statistically significant change means alerting constantly. Alert on changes big enough to matter.
| Signal | Investigate | Act |
|---|---|---|
| Feature PSI | > 0.10 | > 0.25, and the feature matters to the model |
| Feature mean shift | > 0.5 sd | > 1.0 sd |
| Null rate | Any increase | > 2Γ baseline β usually an upstream break |
| Prediction PSI | > 0.10 | > 0.25 |
| Positive rate | Β±20% relative | Β±50% relative |
| Actual performance | Any sustained decline | Below the agreed floor |
Two qualifiers on that table. βAnd the feature mattersβ does real work β a PSI of 0.4 on a feature with near-zero importance is not worth a retrain. And βsustainedβ matters because a single bad day is usually a bad day, not drift.
When an alarm fires, in order:
- Is the pipeline broken? A null rate jumping to 40% is an upstream bug, not drift. Check this first; it is the most common cause and the fastest to fix.
- Which drift is it? If performance is unchanged, you may have covariate drift that costs nothing β measured at PSI 2.08 with accuracy 0.9995. Verify before acting.
- Do you have labels? Without them you cannot confirm a performance drop, only suspect one.
- Retrain on recent data. For concept drift this is the only real fix. Consider weighting recent rows more heavily.
- Roll back if the retrain is worse. Which requires that you versioned the artefact.
In code
Reproducing the central measurement:
import numpy as np
from sklearn.linear_model import LogisticRegression
TRAIN_ANGLE = 45.0
def stream(n, angle_deg=TRAIN_ANGLE, shift=0.0, seed=1):
"""shift moves P(X); angle_deg moves P(y|X). Independently."""
rng = np.random.default_rng(seed)
X = rng.normal(shift, 1, (n, 2))
a = np.radians(angle_deg)
w = np.array([np.cos(a), np.sin(a)])
return X, (X @ w > 0).astype(int)
X_tr, y_tr = stream(4000, seed=1)
model = LogisticRegression().fit(X_tr, y_tr)
for name, (X_live, y_live) in [
("no drift ", stream(2000, seed=2)),
("covariate shift ", stream(2000, shift=1.5, seed=3)),
("concept shift ", stream(2000, angle_deg=TRAIN_ANGLE + 90, seed=4)),
]:
print(f"{name} accuracy {model.score(X_live, y_live):.4f} "
f"PSI {psi(X_tr[:, 0], X_live[:, 0]):.4f}")
# no drift accuracy 0.9980 PSI 0.0034
# covariate shift accuracy 0.9995 PSI 2.0847 <- alarm, no damage
# concept shift accuracy 0.4775 PSI 0.0053 <- damage, no alarmimport numpy as np
from sklearn.linear_model import LogisticRegression
TRAIN_ANGLE = 45.0
def stream(n, angle_deg=TRAIN_ANGLE, shift=0.0, seed=1):
"""shift moves P(X); angle_deg moves P(y|X). Independently."""
rng = np.random.default_rng(seed)
X = rng.normal(shift, 1, (n, 2))
a = np.radians(angle_deg)
w = np.array([np.cos(a), np.sin(a)])
return X, (X @ w > 0).astype(int)
X_tr, y_tr = stream(4000, seed=1)
model = LogisticRegression().fit(X_tr, y_tr)
for name, (X_live, y_live) in [
("no drift ", stream(2000, seed=2)),
("covariate shift ", stream(2000, shift=1.5, seed=3)),
("concept shift ", stream(2000, angle_deg=TRAIN_ANGLE + 90, seed=4)),
]:
print(f"{name} accuracy {model.score(X_live, y_live):.4f} "
f"PSI {psi(X_tr[:, 0], X_live[:, 0]):.4f}")
# no drift accuracy 0.9980 PSI 0.0034
# covariate shift accuracy 0.9995 PSI 2.0847 <- alarm, no damage
# concept shift accuracy 0.4775 PSI 0.0053 <- damage, no alarmScheduled retraining, which handles most drift without any detection at all:
def should_promote(candidate, incumbent, X_recent, y_recent, margin=0.005):
"""Only replace the live model if the challenger is genuinely better."""
cand = candidate.score(X_recent, y_recent)
inc = incumbent.score(X_recent, y_recent)
print(f"candidate {cand:.4f} vs incumbent {inc:.4f}")
return cand > inc + margindef should_promote(candidate, incumbent, X_recent, y_recent, margin=0.005):
"""Only replace the live model if the challenger is genuinely better."""
cand = candidate.score(X_recent, y_recent)
inc = incumbent.score(X_recent, y_recent)
print(f"candidate {cand:.4f} vs incumbent {inc:.4f}")
return cand > inc + marginThe marginmargin is what stops you shipping a new model every week on noise. And note that the
comparison is on recent data β scoring both on the original test set would tell you which model
is better at the world as it used to be.
Pitfalls
Treating input drift as a performance proxy. Measured: PSI 2.0847 with accuracy 0.9995, and PSI 0.0053 with accuracy 0.4775. It is neither necessary nor sufficient.
Alerting on KS p-values. At 4,000 rows a 0.10 sd shift gives . Use the statistic and a threshold you chose.
Forgetting the label lag. On day 60 the true accuracy was 0.6460 and the dashboard said 0.9170. Know the latency of every signal you trust.
Auto-retraining on every alarm. The covariate-shift case would have triggered a retrain that bought nothing, on a possibly-small recent window.
Not monitoring predictions. It is label-free, immediate, and unlike input monitoring it responds to concept drift.
Alerting on a single bad day. Require the decline to be sustained, or you will train yourself to ignore the alerts.
Monitoring drift without knowing feature importance. A large PSI on an unimportant feature is noise; a moderate PSI on your top feature is not.
Recap
- moving is covariate drift; moving is concept drift; the metric moving is a symptom.
- Input monitoring is neither necessary nor sufficient: PSI 2.0847 with accuracy 0.9995, and PSI 0.0053 with accuracy 0.4775.
- PSI crosses the conventional 0.25 threshold at a 0.5 sd shift. Use the statistic, never the KS p-value.
- A 30-day label lag meant a true accuracy of 0.6460 displayed as 0.9170.
- Prediction distributions are the cheap middle ground β label-free, immediate, and sensitive to concept drift.
- Check the pipeline first, confirm which drift it is, then retrain on recent data with a promotion margin.
- Scheduled retraining solves most drift without needing to detect anything.
Input PSI on your top feature jumps to 2.08 and stays there. What should you do first?
Pure covariate shift can produce an enormous PSI and cost nothing, because P(y|X) is unchanged and the model is still right about the new inputs. Confirm the damage before spending a retrain β and before risking a worse model fitted to a small recent window.
Show answer
B β Check whether performance actually changed β the measured case had PSI 2.08 with accuracy 0.9995 β Pure covariate shift can produce an enormous PSI and cost nothing, because P(y|X) is unchanged and the model is still right about the new inputs. Confirm the damage before spending a retrain β and before risking a worse model fitted to a small recent window.
Your model's accuracy has halved but every input PSI is under 0.01. What kind of drift is this?
That is exactly the measured concept-shift case: PSI 0.0053 with accuracy 0.4775. The inputs look completely normal because they ARE completely normal β it is the correct answer for those inputs that changed. Only labels or a proxy outcome reveal it.
Show answer
B β Concept drift β P(y|X) changed while P(X) did not, which input monitoring cannot see β That is exactly the measured concept-shift case: PSI 0.0053 with accuracy 0.4775. The inputs look completely normal because they ARE completely normal β it is the correct answer for those inputs that changed. Only labels or a proxy outcome reveal it.
A KS test on 4,000 rows returns p = 4.4e-08 for a 0.10 standard deviation shift. Should you alert?
Statistical significance answers 'did anything change?', which at scale is always yes. The question you care about is 'did it change enough to hurt my model?' β and that needs an effect size compared against a tolerance you set deliberately.
Show answer
B β No β with enough rows any shift is significant. Use the statistic (0.0813 here) against a threshold you chose β Statistical significance answers 'did anything change?', which at scale is always yes. The question you care about is 'did it change enough to hurt my model?' β and that needs an effect size compared against a tolerance you set deliberately.
Labels for your churn model arrive 30 days late. What does your accuracy dashboard actually show?
You can only score predictions whose outcomes are known, so the dashboard necessarily lags by the label latency. It is still the most trustworthy signal you have β you just have to know how old it is, and pair it with something faster.
Show answer
B β Accuracy as it was 30 days ago β the measured example showed 0.9170 while the true figure was 0.6460 β You can only score predictions whose outcomes are known, so the dashboard necessarily lags by the label latency. It is still the most trustworthy signal you have β you just have to know how old it is, and pair it with something faster.
Which signal is label-free, immediate, AND sensitive to concept drift?
Input monitoring is label-free and immediate but blind to concept drift. Prediction monitoring is also label-free and immediate, and when the relationship changes the mix of outputs usually changes too. It is the most under-used signal in production ML.
Show answer
B β The distribution of the model's own predictions β Input monitoring is label-free and immediate but blind to concept drift. Prediction monitoring is also label-free and immediate, and when the relationship changes the mix of outputs usually changes too. It is the most under-used signal in production ML.
π§ͺ Try It Yourself
Exercise 1 β Implement PSI
Exercise 2 β Separate the two drift types
Exercise 3 β Standardise the shift so features are comparable
Exercise 4 β Watch the dashboard lag behind reality
Exercise 5 β Promote only on a real improvement
Exercise 6 β Implement PSI and calibrate your intuition
Next
That completes Phase 8 and the module. Go back to the phase overview for the practice project, or return to the Machine Learning landing page to see how the twelve phases fit together.
If this helped you, consider buying me a coffee β
Buy me a coffeeWas this page helpful?
Let us know how we did
