Skip to main content
Monitoring ML Models in Production: Data, Concept and Prediction Drift, With Code

Monitoring ML Models in Production: Data, Concept and Prediction Drift, With Code

KU
• 8 min read•0 views

Three kinds of drift, two tests that catch most of it, one Prometheus exporter, and the alert rules that do not page you every Monday morning.

Key takeaways

  • Monitor data drift in the inputs, prediction drift in the outputs, and concept drift, which only labels reveal.
  • Compute PSI and the KS statistic per feature against a training baseline, export them to Prometheus, and alert on sustained shifts in important features.
  • Treat drift as a prompt to check accuracy, not proof of failure.

Monitor inputs, outputs and, once labels arrive, accuracy. Data drift and prediction drift are cheap to measure every hour with PSI and a two-sample KS test; concept drift needs ground truth and shows up days later. Alert on the KS statistic or PSI, never on the KS p-value: on 100,000 rows almost any difference is "significant".

Verified against

SciPy 1.18 (scipy.stats.ks_2samp), prometheus-client 0.26.0, against the SciPy reference docs, the Prometheus Python client docs and the Prometheus alerting-rules docs. Checked October 2026. The sample output below is from one run of the exporter script with seed 0.

A model that worked at launch decays quietly. Nothing throws an exception; the predictions just get worse. The job of monitoring is to tell you that before a business metric does, and to tell you which input moved.

Three kinds of drift, and which one you can see today

Data drift (covariate shift) is a change in the input distribution: a new customer segment, a partner sending prices in cents instead of euros, a feature pipeline that started emitting nulls. You can measure it immediately, with no labels.

Prediction drift is a change in the distribution of the model's outputs. The share of transactions scored as fraud doubles, or the average predicted churn probability slides. Also label-free, and often the single most useful signal, because it summarizes every input at once.

Concept drift is a change in the relationship between inputs and the target. The inputs look the same, but what they mean has changed: a competitor's promotion changes who churns. Only labels reveal it, so you see it when ground truth arrives, which for fraud or churn can be weeks.

The practical consequence: monitor data and prediction drift as leading indicators, and treat accuracy on labeled data as the lagging truth. We wrote about the retrieval version of this problem in embeddings drift detection; tabular models have the same failure with simpler math.

The two tests that cover most cases

Population Stability Index (PSI) bins a feature using the training distribution and compares bin proportions. It is symmetric, works for categorical and numeric features, and gives one number per feature. A widely used rule of thumb reads below 0.1 as stable, 0.1 to 0.25 as moderate shift, and above 0.25 as significant. Treat those as starting points to tune, not as statistics.

Two-sample Kolmogorov-Smirnov (KS) compares two empirical distributions of a numeric feature and returns the maximum distance between their CDFs. SciPy's ks_2samp returns a statistic and a pvalue; with method='auto' it computes an exact p-value when both samples are under 10,000 rows and an asymptotic one otherwise. The statistic is bounded between 0 and 1 and comparable across features. The p-value shrinks toward zero as sample size grows, which is why alerting on it pages you forever.

A runnable drift exporter

This script computes both tests per feature against a baseline and exposes them on /metrics for Prometheus to scrape:

Python
# drift_exporter.py
import time
import numpy as np
from scipy.stats import ks_2samp
from prometheus_client import Gauge, start_http_server

PSI = Gauge("model_feature_psi", "PSI vs training baseline", ["model", "feature"])
KS = Gauge("model_feature_ks_statistic", "KS statistic vs baseline", ["model", "feature"])
PRED_MEAN = Gauge("model_prediction_mean", "Mean predicted score", ["model"])

def psi(expected, actual, bins=10, eps=1e-6):
    edges = np.quantile(expected, np.linspace(0, 1, bins + 1))
    edges[0], edges[-1] = -np.inf, np.inf
    e = np.histogram(expected, edges)[0] / len(expected)
    a = np.histogram(actual, edges)[0] / len(actual)
    e, a = np.clip(e, eps, None), np.clip(a, eps, None)
    return float(np.sum((a - e) * np.log(a / e)))

def load_window(rng, shift):
    # Replace with a query against your feature log or inference table.
    return {"amount": rng.lognormal(3 + shift, 1, 5000),
            "age_days": rng.normal(400, 120, 5000)}

if __name__ == "__main__":
    rng = np.random.default_rng(0)
    baseline = load_window(rng, 0.0)
    start_http_server(9105)
    while True:
        live = load_window(rng, shift=0.3)
        for name, ref in baseline.items():
            PSI.labels("churn-v3", name).set(psi(ref, live[name]))
            KS.labels("churn-v3", name).set(ks_2samp(ref, live[name]).statistic)
        PRED_MEAN.labels("churn-v3").set(float(rng.beta(2, 8, 5000).mean()))
        time.sleep(300)
code
$ pip install numpy scipy "prometheus-client==0.26.0"
$ python drift_exporter.py &
$ curl -s localhost:9105/metrics | grep model_feature
model_feature_psi{feature="amount",model="churn-v3"} 0.09761131771286286
model_feature_psi{feature="age_days",model="churn-v3"} 0.003315223265888138
model_feature_ks_statistic{feature="amount",model="churn-v3"} 0.133
model_feature_ks_statistic{feature="age_days",model="churn-v3"} 0.022199999999999998

The synthetic shift=0.3 on amount pushes its KS statistic to 0.133 against 0.022 for the unchanged age_days, while PSI sits just under the 0.1 "stable" line: the two tests disagree on borderline shifts, which is why we export both. Bin edges come from baseline quantiles so each bin holds about a tenth of the training data, and the eps clip avoids log(0) when a live bin is empty.

What to alert on

Alert on sustained, important shifts, not on every wiggle. A rule that requires drift to persist for two hours and limits itself to features you know matter:

YAML
# prometheus/rules/model-drift.yml
groups:
  - name: model-drift
    rules:
      - alert: FeatureDriftHigh
        expr: max_over_time(model_feature_psi{feature=~"amount|tenure|plan"}[1h]) > 0.25
        for: 2h
        labels:
          severity: ticket
        annotations:
          summary: "PSI {{ $value | printf \"%.2f\" }} on {{ $labels.feature }} ({{ $labels.model }})"
      - alert: PredictionMeanShift
        expr: abs(model_prediction_mean - avg_over_time(model_prediction_mean[7d])) > 0.05
        for: 1h
        labels:
          severity: page

Route feature drift to a ticket and reserve the page for prediction shifts and for accuracy drops once labels land. If you are deciding where these metrics live, Prometheus vs Datadog covers the cost and cardinality trade-offs; one series per model per feature stays cheap.

Failure modes, and the false alarms they cause

  • Seasonality. Monday traffic is not Sunday traffic, and December is not October. Compare against the same weekday or a rolling same-period baseline, not a single training snapshot, or the alert fires every week.
  • Label delay. Concept drift metrics computed on the last seven days of labels only cover the cases that resolved quickly, which skews toward one class. Record label latency and compute accuracy on cohorts that have fully matured.
  • Small windows. PSI on 200 rows is noise. Set a minimum window size and skip the metric when the window is short.
  • The KS p-value. Already covered, and still the most common mistake: pvalue of 0.0 on every feature after launch means large samples, not a broken model.
  • Upstream schema changes. A feature silently becoming all nulls produces a huge PSI. Good, that is a real incident, but catch it earlier with data validation on the pipeline.
  • High-cardinality labels. Putting customer_id in a label creates a series per customer. Keep labels to model, version and feature.

Where this breaks

Drift metrics tell you the world changed, not that the model got worse. A shift in an unimportant feature can be harmless, and a model can degrade with no visible input drift when the concept moves. Univariate tests also miss correlated changes across features. The only full answer is labeled evaluation, which is why a registry that records the exact training baseline, as in model registry vs MLflow tracking, pays for itself.

Questions people ask

What is the difference between data drift and concept drift?

Data drift is a change in the inputs, such as a new customer segment or a units bug, and you can detect it without labels. Concept drift is a change in the relationship between inputs and the target, so identical inputs now deserve a different prediction. It only shows up in labeled accuracy, which often arrives days or weeks after the prediction.

What PSI value indicates drift?

A common rule of thumb treats PSI below 0.1 as stable, 0.1 to 0.25 as a moderate shift worth watching, and above 0.25 as significant. These thresholds are conventions, not statistical tests, so tune them per feature against historical windows. Compute PSI with bin edges taken from the training distribution, and clip empty bins to avoid dividing by zero.

Should I use the KS test p-value to detect drift?

No. With large production samples, the KS p-value approaches zero for tiny, harmless differences, so a p-value threshold fires constantly. Use the KS statistic instead: it is the maximum distance between the two CDFs, bounded between 0 and 1, and comparable across features. Set a threshold on the statistic and require it to persist.

The call we'd make

Export PSI and the KS statistic for your top ten features plus the prediction mean, alert to a ticket on sustained drift, and page only on prediction shifts and on accuracy drops in matured label cohorts. Start with the 0.25 PSI threshold and tune it after a month of history. The caveat: if labels arrive within hours, skip most of this and monitor accuracy directly.

Sources

Explore topics:MLOpsMonitoringAI
React
Share this post
Cite this article
Plain text

Kiril Urbonas (2026). Monitoring ML Models in Production: Data, Concept and Prediction Drift, With Code. DevOpsNess. https://www.devopsness.com/blog/ml-model-monitoring-drift-production

Markdown

[Monitoring ML Models in Production: Data, Concept and Prediction Drift, With Code](https://www.devopsness.com/blog/ml-model-monitoring-drift-production) (Kiril Urbonas, DevOpsNess, 2026-10-31)

KU

Kiril Urbonas

AI Engineer

585 articles
View all articles by Kiril Urbonas

You might have missed

Evergreen posts worth revisiting.