Skip to content

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 P(y∣X)P(y \mid X) from a training sample of P(X)P(X). Two things can change independently:

Covariate drift (also data drift): P(X)P(X) changes, P(y∣X)P(y \mid X) 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: P(y∣X)P(y \mid X) 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.

diagram Diagram mermaid

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:

ScenarioAccuracy on live dataInput PSI
No drift0.99800.0034
Covariate shift β€” P(X)P(X) moves 1.5 sd0.99952.0847
Concept shift β€” P(y∣X)P(y \mid X) rotates 90Β°0.47750.0053
figureThe alarm fires on the harmless one and stays silent on the damaging onematplotlib
Two bar charts. The left shows accuracy: 0.9980 for no drift, 0.9995 for covariate shift, 0.4775 for concept shift. The right shows input PSI: 0.0034, 2.0847 and 0.0053 respectively.Two bar charts. The left shows accuracy: 0.9980 for no drift, 0.9995 for covariate shift, 0.4775 for concept shift. The right shows input PSI: 0.0034, 2.0847 and 0.0053 respectively.
Covariate shift produced a PSI of 2.0847 β€” eight times the conventional 'large drift' threshold β€” while accuracy went UP slightly to 0.9995. Concept shift produced a PSI of 0.0053, well inside 'stable', while accuracy fell to 0.4775, worse than a coin flip.

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 P(y∣X)P(y \mid X) 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:

PSI=βˆ‘i=1B(ciβˆ’ri) ln⁑ ⁣ciri\mathrm{PSI} = \sum_{i=1}^{B} \big(c_i - r_i\big)\,\ln\!\frac{c_i}{r_i}

where rir_i and cic_i are the reference and current proportions in bin ii. The conventional read is <0.1< 0.1 stable, 0.10.1–0.250.25 moderate, >0.25> 0.25 large. Those thresholds are convention, not theory.

Kolmogorov-Smirnov statistic. The largest vertical gap between the two empirical CDFs:

D=sup⁑x∣Fref(x)βˆ’Fcur(x)∣D = \sup_x \big| F_{\text{ref}}(x) - F_{\text{cur}}(x) \big|

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 meanPSIKSKS p-value
0.00 sd0.00560.02880.22
0.05 sd0.01340.04370.012
0.10 sd0.03380.08134.4e-08
0.20 sd0.05070.09985.6e-12
0.30 sd0.09980.12985.5e-20
0.50 sd0.27230.21253.8e-53
0.75 sd0.58990.30403.1e-109
1.00 sd0.95550.38371.3e-175
figureNeither statistic notices a small shift, and both scream at a large onematplotlib
Two rising curves of PSI and the KS statistic against shift magnitude, with dashed horizontal lines at PSI 0.10 and 0.25, and a vertical marker where PSI crosses 0.25 at a 0.5 standard deviation shift.Two rising curves of PSI and the KS statistic against shift magnitude, with dashed horizontal lines at PSI 0.10 and 0.25, and a vertical marker where PSI crosses 0.25 at a 0.5 standard deviation shift.
PSI crosses the conventional 0.25 'act now' line at a 0.5 standard deviation shift. Below 0.2 sd both statistics stay near the floor. Note the KS p-value column in the table: at a 0.10 sd shift it is already 4.4e-08 β€” overwhelmingly significant, and almost certainly not worth acting on.

That p-value column is the trap. With enough rows, the KS test rejects any shift. At 0.10 sd the p-value is 4.4Γ—10βˆ’84.4 \times 10^{-8} β€” 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:

DayTrue accuracy todayWhat your dashboard showsInput PSI
00.9980β€”0.0054
200.9930β€”0.0081
300.91700.99800.0096
400.84000.99500.0107
500.75400.99300.0244
600.64600.91700.0089
700.55600.84000.0120
800.47800.75400.0079
figureConcept drift: invisible to inputs, and 30 days stale in the metricsmatplotlib
Three lines over 90 days. A red line for true accuracy falls from 1.0 to below 0.5; a blue line for dashboard accuracy traces the same shape shifted 30 days right; a grey PSI line stays flat near zero throughout.Three lines over 90 days. A red line for true accuracy falls from 1.0 to below 0.5; a blue line for dashboard accuracy traces the same shape shifted 30 days right; a grey PSI line stays flat near zero throughout.
Drift starts on day 20. On day 60 the model is really at 0.6460 while the dashboard, working from labels that are 30 days old, still reports 0.9170. Meanwhile input PSI never leaves the floor β€” it stays under 0.025 for the entire 90 days.

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:

SignalNeeds labelsLatencyCatches
Input distributionsnominutesBroken pipelines, covariate shift
Prediction distributionsnominutesBoth drift types, indirectly
Proxy outcomespartlyhours to daysSome concept drift, early
Actual performanceyesdays to monthsEverything, eventually

See it move

sketch A boundary that stops matching the data p5.js
The true decision boundary rotates while the deployed model's boundary stays where it was trained. Accuracy falls, but the point cloud itself never changes shape β€” which is why input monitoring sees nothing.

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.

monitor.py
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 report
monitor.py
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 report

Note 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.

SignalInvestigateAct
Feature PSI> 0.10> 0.25, and the feature matters to the model
Feature mean shift> 0.5 sd> 1.0 sd
Null rateAny increase> 2Γ— baseline β€” usually an upstream break
Prediction PSI> 0.10> 0.25
Positive rateΒ±20% relativeΒ±50% relative
Actual performanceAny sustained declineBelow 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:

  1. 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.
  2. 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.
  3. Do you have labels? Without them you cannot confirm a performance drop, only suspect one.
  4. Retrain on recent data. For concept drift this is the only real fix. Consider weighting recent rows more heavily.
  5. Roll back if the retrain is worse. Which requires that you versioned the artefact.

In code

Reproducing the central measurement:

drift_types.py
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 alarm
drift_types.py
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 alarm

Scheduled retraining, which handles most drift without any detection at all:

scheduled_retrain.py
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 + margin
scheduled_retrain.py
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 + margin

The 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 p=4.4Γ—10βˆ’8p = 4.4 \times 10^{-8}. 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

  • P(X)P(X) moving is covariate drift; P(y∣X)P(y \mid X) 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.
quizCheck yourself
  1. Input PSI on your top feature jumps to 2.08 and stays there. What should you do first?

    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.

  2. Your model's accuracy has halved but every input PSI is under 0.01. What kind of drift is this?

    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.

  3. A KS test on 4,000 rows returns p = 4.4e-08 for a 0.10 standard deviation shift. Should you alert?

    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.

  4. Labels for your churn model arrive 30 days late. What does your accuracy dashboard actually show?

    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.

  5. Which signal is label-free, immediate, AND sensitive to concept drift?

    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 coffee

Was this page helpful?

Let us know how we did