Your Drift Dashboard Can Be Green While Your Model Is Failing

D

David Taiwo Balogun

Guest
A drift alert is a question, not a verdict. Here is how to build the monitor, and how to tell which alerts actually mean your model is getting worse.



Most teams meet drift monitoring the same way. The dashboard lights up red in its first week. Two features have "drifted". Someone spends a day digging, nothing gets retrained, and when the labels finally arrive, accuracy has not moved at all.



The opposite failure is quieter and costs more. Every feature looks stable, the dashboard stays green, and the model is mispricing an entire customer segment.



Both failures come from the same confusion: treating statistical drift (the inputs look different) as if it were model degradation (the predictions got worse). They overlap, but far less than most monitoring setups assume.

This article builds a drift monitor end to end with pandas, SciPy and Evidently, then runs it against five controlled scenarios so you can see exactly where drift signals and real performance agree, and where they lie to you.



The setup: a credit model with a known ground truth​


Real production data never tells you why it changed. To see drift and degradation clearly, we need a world where we control the cause. So we simulate a loan-default model: five applicant features, a hidden true risk function, and a gradient-boosted classifier trained on it.

Code:
import numpy as np
import pandas as pd
from scipy import stats
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import roc_auc_score, brier_score_loss

def make_data(n, age_shift=0.0, util_shift=0.0, channel_p=None,
              app_fraud=False, seed=None):
    rng = np.random.default_rng(seed)
    age = rng.normal(40 + age_shift, 10, n).clip(18, 80)
    income = rng.lognormal(10.5, 0.5, n)
    utilization = (rng.beta(2, 5, n) + util_shift).clip(0, 1)
    tenure = rng.exponential(5, n)
    channel = rng.choice(["branch", "web", "app"], n,
                         p=channel_p or [0.5, 0.3, 0.2])

    # The true (hidden) risk function
    z = (-2.0 + 4.0 * utilization - 0.6 * np.log(income / 36000)
         - 0.02 * (age - 40) - 0.05 * tenure)
    if app_fraud:                      # concept drift: same inputs, new outcome
        z = z + 1.8 * (channel == "app")
    y = rng.binomial(1, 1 / (1 + np.exp(-z)))
    return pd.DataFrame(dict(age=age, income=income, utilization=utilization,
                             tenure=tenure, channel=channel, default=y))

FEATS = ["age", "income", "utilization", "tenure", "channel"]
NUM = ["age", "income", "utilization", "tenure"]

def X(df):
    d = df[FEATS].copy()
    d["channel"] = d["channel"].map({"branch": 0, "web": 1, "app": 2})
    return d

train = make_data(20_000, seed=1)
model = GradientBoostingClassifier(random_state=0).fit(X(train), train["default"])



The arguments to make_data are the levers. Each one produces a different kind of change, which we will pull one at a time later.

The trained model reaches an AUC of 0.69 on held-out data, with a 26.7% default rate. Its feature importances matter for everything that follows:




Feature

Importance

utilization

0.61

income

0.19

tenure

0.11

age

0.09

channel

0.00

Utilization dominates. Channel is effectively ignored, because in the training world it carries no risk. Keep that in mind.

Step 1: Establish a reference distribution​


Every drift test is a comparison, so the reference window decides what "normal" means. Pick it badly and every later alert inherits the error.



Three rules hold up well in practice:

1. Use data the model was validated on, not the training set. Training data has been seen, resampled and often filtered. The validation or first-month production window is what the model actually performed well on.

2. Cover a full business cycle. If your traffic has a weekly or monthly rhythm, a reference shorter than that cycle will flag the rhythm itself as drift.

3. Store predictions alongside features. The prediction distribution is the cheapest high-value signal you have, and you can only compare it if you saved the baseline.



Code:
reference = make_data(5_000, seed=2)
reference["pred"] = model.predict_proba(X(reference))[:, 1]

# Persist it with the model version, not in a notebook
reference.to_parquet("reference_v1.parquet")

importances = dict(zip(FEATS, model.feature_importances_))



Version the reference with the model. When you retrain, the reference changes too; comparing a new model's traffic to an old model's baseline produces alerts about your own deployment.

Step 2: Detect drift by hand with SciPy​


Before reaching for a library, it is worth knowing what the tests actually measure. Four do most of the work.


Method

Feature type

What it answers

Rule of thumb

Kolmogorov–Smirnov (KS)

Numeric

Largest gap between the two CDFs

Statistic > 0.1 is worth a look

Population Stability Index (PSI)

Numeric, binned

How much mass moved between bins

< 0.1 stable, 0.1–0.25 moderate, > 0.25 major

Wasserstein distance

Numeric

How far values moved, in feature units

Divide by reference std; > 0.1 is drift

Chi-square

Categorical

Did category proportions change

Pair with a distance, not p alone



SciPy covers three of them directly. PSI takes a few lines:

Code:
def psi(ref, cur, bins=10):
    edges = np.unique(np.quantile(ref, np.linspace(0, 1, bins + 1)))
    edges[0], edges[-1] = -np.inf, np.inf
    r = np.histogram(ref, edges)[0] / len(ref)
    c = np.histogram(cur, edges)[0] / len(cur)
    r, c = np.clip(r, 1e-4, None), np.clip(c, 1e-4, None)
    return float(np.sum((c - r) * np.log(c / r)))

def drift_table(ref, cur):
    rows = []
    for f in NUM:
        ks = stats.ks_2samp(ref[f], cur[f])
        rows.append({
            "feature": f,
            "ks": ks.statistic,
            "p_value": ks.pvalue,
            "psi": psi(ref[f], cur[f]),
            "wasserstein_norm": stats.wasserstein_distance(ref[f], cur[f]) / ref[f].std(),
        })
    table = pd.crosstab(
        np.r_[["ref"] * len(ref), ["cur"] * len(cur)],
        np.r_[ref["channel"], cur["channel"]],
    )
    rows.append({"feature": "channel",
                 "p_value": stats.chi2_contingency(table).pvalue})
    return pd.DataFrame(rows)



Quantile bins on the reference keep PSI stable for skewed features like income. The clip stops an empty bin from producing an infinite log.

The p-value trap​


Here is the part most tutorials skip. I generated 200 fresh production windows from exactly the training distribution, with nothing changed, and ran KS on the four numeric features.



33% of those windows raised at least one alarm at p < 0.05. Four tests per window, checked every window, is a multiple-comparisons machine.



The opposite problem appears at scale. With 100,000 rows per window, shifting mean age by just six months gives a KS p-value of 8e-19, wildly "significant". Its PSI is 0.002, which is nothing.



The lesson: a p-value tells you whether a difference is detectable, not whether it matters. Decide on effect sizes (PSI, normalised Wasserstein, KS statistic). Use p-values only to rule out noise in small windows.

Step 3: Scale it up with Evidently​


Hand-rolled tests are fine for four features. For forty, you want per-column test selection, an HTML report you can send to someone, and machine-readable results for alerting. Evidently gives you all three.



The API changed substantially in 0.7, and most blog posts still show the old ColumnMapping style. This is the current one:

Code:
from evidently import Report, Dataset, DataDefinition
from evidently.presets import DataDriftPreset

definition = DataDefinition(
    numerical_columns=["age", "income", "utilization", "tenure", "pred"],
    categorical_columns=["channel"],
)
cols = FEATS + ["pred"]
ref_ds = Dataset.from_pandas(reference[cols], data_definition=definition)
cur_ds = Dataset.from_pandas(current[cols], data_definition=definition)

snapshot = Report([DataDriftPreset()]).run(current_data=cur_ds,
                                          reference_data=ref_ds)
snapshot.save_html("drift_report.html")   # for humans
results = snapshot.dict()                    # for alerting




Note that pred is included as an ordinary numeric column. That one line gets you prediction drift for free.




Evidently picks a test per column based on type and sample size. Above 1,000 rows it defaults to normalised Wasserstein for numeric features and Jensen–Shannon distance for categorical ones, both with a 0.1 threshold. Below that it falls back to KS and chi-square. That default already avoids most of the p-value trap described above.



Pulling the scores out for an alerting job is a short loop:

Code:
for m in results["metrics"]:
    print(m["metric_name"], "->", m["value"])



When you want specific tests or thresholds, compose the metrics yourself:

Code:
from evidently.metrics import ValueDrift, DriftedColumnsCount

report = Report([
    DriftedColumnsCount(columns=FEATS, drift_share=0.5),
    ValueDrift(column="utilization", method="psi", threshold=0.2),
    ValueDrift(column="pred", method="wasserstein"),
])



The preset's dataset-level verdict ("is more than half of the columns drifting?") is a poor alerting rule on its own. A single drifting column can matter more than ten. The next two sections show why.

Step 4: Statistical drift is not model degradation​


Now the experiment. Five production windows of 5,000 rows each, every one with a different cause. Because this is a simulation, we also get the labels immediately, which production rarely gives you. That lets us check each drift signal against what actually happened to the model.

Code:
scenarios = {
    "A: nothing changed":         dict(seed=10),
    "B: older applicants, more app": dict(age_shift=4, channel_p=[0.3, 0.35, 0.35], seed=11),
    "C: higher utilization":      dict(util_shift=0.08, seed=12),
    "D: fraud via the app":       dict(app_fraud=True, seed=13),
}
for name, kwargs in scenarios.items():
    current = make_data(5_000, **kwargs)
    current["pred"] = model.predict_proba(X(current))[:, 1]
    print(name, drift_table(reference, current))

# E: a pipeline starts sending income in thousands
broken = make_data(5_000, seed=14)
broken["income"] = broken["income"] / 1000






Scenario

Largest feature drift (PSI)

Prediction drift (PSI)

AUC

Predicted vs actual default rate

What it really was

Reference

—

—

0.690

27.0% vs 26.7%

Baseline

A: nothing changed

0.005 (age, but KS p = 0.049)

0.004

0.690

27.4% vs 26.6%

False alarm

B: older applicants, more app

0.16 (age); channel chi-square p = 3e-106

0.014

0.692

25.9% vs 25.9%

Benign drift

C: higher utilization

0.51 (utilization)

0.18

0.695

32.9% vs 32.3%

Real change, model still right

D: fraud via the app

0.004 (none)

0.002

0.666

27.0% vs 34.8%

Silent degradation

E: income sent in thousands

8.3 (income)

1.29

0.668

37.9% vs 27.3%

Broken pipeline



Each row teaches something different.

A: nothing changed, still an alert. Age crossed p < 0.05 on pure sampling noise. A p-value rule pages someone; PSI of 0.005 correctly says nothing.



B: loud drift, zero impact. Two features moved hard, and Evidently's preset flags both (age at 0.41 normalised Wasserstein, channel at 0.16 Jensen–Shannon). Yet AUC and calibration are untouched. Age carries 9% of the model's importance and channel carries none. The predictions barely moved, which is the giveaway.



C: real drift, the model is doing its job. The applicant pool genuinely got riskier, and the model correctly predicts a higher default rate (32.9% against an actual 32.3%). Retraining would fix nothing. This is a business alert for the credit-risk team, not an ML incident.



D: no drift, real damage. Fraudsters start applying through the app. The inputs look identical, so every feature monitor stays green. But app applicants now default at 63% while the model still predicts 27% for them. This is concept drift, where P(y | X) changes and P(X) does not. No input monitor can see it.



E: the loudest signal is usually a bug. KS of 1.0 means the two distributions do not overlap at all. Real populations do not change like that overnight; pipelines do. Check units, schemas and upstream deploys before you call it drift.



Put together: four scenarios raised a drift alert, and only one of them (E) pointed at a model problem. And the one genuine model failure (D) produced no drift alert at all.

Step 5: Investigate whether drift affects predictive quality​


In production, labels lag. A loan default shows up months later; a churn label, weeks. So the useful question is: which signals, available today, predict the performance drop you will measure later? Three get you most of the way.

1. Weight drift by what the model cares about​


A drifting feature the model ignores cannot hurt it. Multiplying each feature's PSI by its importance collapses the noise:

Code:
def weighted_drift(ref, cur):
    return sum(importances[f] * psi(ref[f], cur[f]) for f in NUM)




Scenario

Raw max PSI

Importance-weighted PSI

A: nothing changed

0.005

0.002

B: older applicants, more app

0.16

0.017

C: higher utilization

0.51

0.31

Scenario B, the loud but harmless one, drops by a factor of ten. Scenario C keeps its signal. Impurity-based importances are a rough guide; SHAP values on the reference window are better if you already compute them.

2. Watch the prediction distribution​


Prediction drift is the model telling you, directly, whether input changes reached its output. In the experiment it ranked the scenarios correctly every time an input change existed: 0.004 (A), 0.014 (B), 0.18 (C), 1.29 (E).

When inputs drift but predictions do not, the drift is probably in a region the model is flat over. When predictions drift, something the model relies on changed, and you need to find out whether that change is real (C) or broken (E).

3. Slice performance the moment early labels land​


Scenario D is invisible to both checks above. The only defence is outcome data, even partial. Early-arriving proxies (first missed payment, chargebacks, manual-review overrides, support tickets) let you compute performance by segment long before full labels exist:



Code:
seg = current.groupby("channel").agg(
    predicted=("pred", "mean"),
    actual=("default", "mean"),
    n=("pred", "size"),
)
seg["gap"] = seg["actual"] - seg["predicted"]



In scenario D, this table shows branch at 27% predicted against 27% actual, web at 27% against 29%, and app at 27% against 63%. The aggregate gap was eight points; the segment gap is thirty-six. Aggregate metrics average a disaster away, so always slice by the dimensions your business acts on.

A triage order that works​


When an alert fires, go in this order:

1. Is it a data-quality problem? Check nulls, units, schema and recent upstream deploys. Extreme scores (KS near 1, PSI above 2) are almost always this.

2. Did predictions move? If not, log it and move on.

3. Does the model still fit the new region? Compare calibration on whatever labels or proxies you have, sliced by the drifting feature.

4. Is this a business change rather than a model fault? If the model is calibrated and the world changed, tell the people who own the decision, not the people who own the model.

Step 6: A production playbook​


Turning this into something that runs unattended mostly comes down to three decisions.



Window size. Too small and sampling noise dominates; too large and you detect change weeks late. Aim for at least 1,000 rows per window so distance metrics are stable, and align windows with your traffic cycle (a full week beats three days). For low-volume models, use a rolling window rather than daily batches.



Thresholds. Start with the conventional values (PSI 0.1 and 0.25, normalised Wasserstein 0.1), then calibrate. Run your monitor over several historical windows where you know the model was healthy, and set thresholds above the worst score you see there. That single exercise removes most false alarms.



Alert tiers. Not every signal should page someone:


Signal

Tier

Action

Schema change, nulls spike, KS near 1 on any feature

Page

Data-quality incident; consider falling back to a safe default

Prediction PSI > 0.25

Page

Run the triage order above today

Importance-weighted PSI > 0.1

Ticket

Investigate within the week; pull early labels by segment

Single low-importance feature drifting

Log

Review in the weekly model health check

Segment calibration gap > agreed tolerance

Page

Concept drift suspected; retrain or adjust the segment



A minimal scheduled job looks like this:

Code:
def run_drift_check(current: pd.DataFrame) -> dict:
    current = current.assign(pred=model.predict_proba(X(current))[:, 1])
    snapshot = Report([DataDriftPreset()]).run(
        current_data=Dataset.from_pandas(current[cols], data_definition=definition),
        reference_data=ref_ds,
    )
    snapshot.save_html(f"reports/drift_{pd.Timestamp.now():%Y%m%d}.html")
    return {
        "prediction_psi": psi(reference["pred"], current["pred"]),
        "weighted_psi": weighted_drift(reference, current),
        "max_ks": max(stats.ks_2samp(reference[f], current[f]).statistic for f in NUM),
    }



Ship the three numbers to whatever already does your alerting, and keep the HTML report as the thing a human opens when one of them fires.



Finally, decide in advance what a retrain needs to prove. Drift alone is not a reason to retrain: in scenario C, retraining on the new window would learn the same function the model already has. Retrain when labelled performance drops, or when a segment's calibration breaks, and gate the new model on a held-out window from after the change.

Conclusion​


Drift detection is easy to build and easy to get wrong. The tests themselves are a few lines of SciPy or a single Evidently preset. The hard part is interpretation.



The five scenarios make the gap concrete. A drift monitor fired on harmless noise, on a harmless population shift, and on a real change the model handled correctly. It stayed silent through the one failure that actually cost money.

So treat the monitor as an early-warning system, not a judge. Score drift by effect size, weight it by what the model uses, watch the predictions themselves, and get outcome data back, sliced by segment, as fast as your business allows. Drift tells you where to look. Labels tell you what you found.




Tested with pandas 2.x, SciPy 1.x, scikit-learn 1.x and Evidently 0.7.23.
 

Thread statistics

Created
David Taiwo Balogun,
Replies
0
Views
0
Back
Top