Data Drift Detection: Keeping ML Models Honest in Production

Your model was accurate when you deployed it. Six months later, it is quietly making bad predictions. Data drift is usually why. Here is how to detect it before users notice.

Machine learning models have a fundamental assumption baked in: the data they will see in production looks like the data they were trained on. This assumption breaks. Gradually, then suddenly.

Understanding drift - what it is, how to detect it, and what to do about it - is one of the most practically important skills in ML engineering.

Three Types of Drift

Covariate drift (X drift): The input distribution changes. Your churn model was trained on data from 2023, when your average customer was 28 years old. By 2025, your growth strategy shifted, and your average customer is now 45. The features fed to the model look different from training time.

P_train(X) ≠ P_prod(X)     ← covariate drift
P_train(Y|X) = P_prod(Y|X) ← relationship is still valid

The relationship between features and outcome has not changed - a customer with these feature values still has this churn probability. But now you are seeing many more customers with feature values your model has less confidence on.

Concept drift (Y|X drift): The relationship between inputs and outputs changes. What made a customer churn in 2023 (bad support) is different from what makes them churn in 2025 (price sensitivity). Your model learned the old relationship.

P_train(Y|X) ≠ P_prod(Y|X) ← concept drift

This is harder to detect and requires ground truth labels to measure directly.

Label drift: The distribution of outcomes changes. In training, 10% of customers churned. In production, market conditions have changed and 25% are churning. Your model's threshold is calibrated for 10%.

Detecting Covariate Drift

The most tractable drift to detect - you do not need labels.

Statistical tests on individual features:

python
import numpy as np from scipy import stats from evidently import ColumnMapping from evidently.report import Report from evidently.metric_preset import DataDriftPreset def detect_feature_drift(train_df, production_df, feature_columns, threshold=0.05): """ Returns dict of {feature: (statistic, p_value, is_drifted)} Uses KS test for continuous features. """ results = {} for col in feature_columns: train_values = train_df[col].dropna() prod_values = production_df[col].dropna() # Kolmogorov-Smirnov test: are these from the same distribution? statistic, p_value = stats.ks_2samp(train_values, prod_values) is_drifted = p_value < threshold results[col] = { "statistic": statistic, "p_value": p_value, "drifted": is_drifted, "train_mean": train_values.mean(), "prod_mean": prod_values.mean(), "train_std": train_values.std(), "prod_std": prod_values.std() } return results drift_report = detect_feature_drift(X_train, X_production_last_30d, feature_names) drifted_features = [k for k, v in drift_report.items() if v['drifted']] print(f"Drifted features: {drifted_features}")

Population Stability Index (PSI) - the industry standard:

PSI is widely used in financial services. It quantifies how much a feature distribution has shifted:

python
def calculate_psi(expected, actual, buckets=10): """ PSI < 0.1: stable PSI 0.1-0.2: slight shift, monitor PSI > 0.2: significant drift, investigate """ # Create buckets based on expected distribution breakpoints = np.percentile(expected, np.linspace(0, 100, buckets + 1)) breakpoints[0] = -np.inf breakpoints[-1] = np.inf # Count observations in each bucket expected_counts = np.histogram(expected, bins=breakpoints)[0] actual_counts = np.histogram(actual, bins=breakpoints)[0] # Convert to proportions, avoid division by zero expected_props = np.where(expected_counts == 0, 0.0001, expected_counts / len(expected)) actual_props = np.where(actual_counts == 0, 0.0001, actual_counts / len(actual)) psi = np.sum((actual_props - expected_props) * np.log(actual_props / expected_props)) return psi for feature in feature_names: psi = calculate_psi(X_train[feature], X_production[feature]) status = "STABLE" if psi < 0.1 else ("MONITOR" if psi < 0.2 else "DRIFT") print(f"{feature}: PSI={psi:.3f} [{status}]")

Monitoring Prediction Distribution

Even without labels, watch your model's output distribution:

python
import pandas as pd from collections import defaultdict class PredictionMonitor: def __init__(self, baseline_predictions): self.baseline_preds = baseline_predictions self.baseline_mean = np.mean(baseline_predictions) self.baseline_std = np.std(baseline_predictions) self.recent_preds = [] def record_prediction(self, prediction: float): self.recent_preds.append(prediction) # Alert if window is large enough to evaluate if len(self.recent_preds) >= 1000: recent_mean = np.mean(self.recent_preds[-1000:]) # Alert if mean shifts by more than 2 standard deviations z_score = abs(recent_mean - self.baseline_mean) / self.baseline_std if z_score > 2: self._alert(recent_mean, z_score) def _alert(self, current_mean, z_score): print(f"ALERT: Prediction distribution shift detected!") print(f"Baseline mean: {self.baseline_mean:.3f}") print(f"Current mean: {current_mean:.3f}") print(f"Z-score: {z_score:.2f}")

Detecting Concept Drift with Labels

This requires ground truth, which often arrives with delay. A customer might churn 30 days after their prediction. Build a delayed evaluation pipeline:

python
def evaluate_delayed_ground_truth(predictions_log, ground_truth_log, window_days=30): """ Join predictions from 30 days ago with labels that are now available. """ cutoff_date = pd.Timestamp.now() - pd.Timedelta(days=window_days) # Get predictions made before the cutoff historical_preds = predictions_log[ predictions_log['prediction_time'] <= cutoff_date ] # Join with ground truth evaluation_df = historical_preds.merge( ground_truth_log[['customer_id', 'churned']], on='customer_id', how='inner' ) current_auc = roc_auc_score(evaluation_df['churned'], evaluation_df['predicted_probability']) print(f"Rolling 30-day AUC: {current_auc:.3f}") return current_auc

Plot this AUC over time. A declining trend is concept drift. A sudden drop is usually a data pipeline bug.

A Practical Monitoring Architecture

Production API
     │
     ├── Log request features + predictions to structured log
     │
     ▼
Event stream (Kafka / SQS) or batch log collection
     │
     ▼
Daily drift job:
  - Load training baseline statistics
  - Compute PSI / KS for each feature on last 24h of production data
  - Compute prediction distribution shift
  - Write results to metrics DB
     │
     ▼
Alerting (Grafana + PagerDuty or Datadog monitors)
  - PSI > 0.2 on any critical feature → Slack alert
  - Prediction mean shift > 2σ → Slack alert
  - Rolling AUC drop > 0.05 → incident ticket

When to Retrain

Not all drift requires immediate retraining. Guidelines:

SignalAction
PSI > 0.2 on a featureInvestigate source system; retrain if root cause is real distribution shift
Prediction mean shift > 2σShadow-test a retrained model; promote if better
Rolling AUC drop > 0.05 over 2 weeksTrigger retraining pipeline immediately
Sudden AUC drop > 0.1Likely a data pipeline bug, not drift - investigate before retraining

Automated retraining (trigger retrain when drift threshold is crossed) is tempting but fragile. A data pipeline bug can trigger unnecessary retrains. Start with alert-to-human, graduate to automated retraining once your data pipelines are stable.

Tooling Options

ToolBest forCost
Evidently AIFeature drift reports, quick setupOpen source
WhyLogsProduction logging + profilingOpen source
Arize AIFull ML observability platformPaid (free tier)
Grafana + custom PSI jobExisting infra, maximum controlOpen source
AWS SageMaker Model MonitorAWS-native deploymentsPaid

Start with Evidently or a custom PSI calculation before investing in a paid platform.

Common Mistakes

Monitoring only infrastructure metrics and missing feature drift. CPU utilization, memory usage, and request latency tell you whether your serving infrastructure is healthy, but they are blind to the data quality problems that most often degrade model performance in production. A model can serve thousands of requests per second at normal latency while receiving completely out-of-distribution inputs. Feature-level distribution monitoring is the only way to detect data drift before it becomes a prediction quality problem.

Using absolute thresholds instead of relative drift magnitude. A feature whose distribution shifts by 5% in absolute PSI terms is very different depending on whether it was previously stable or was already drifting. Relative drift magnitude - comparing the rate of change to the historical baseline variance - gives more actionable signals than fixed absolute thresholds applied uniformly across all features.

Alerting on every minor drift event without severity tiers. If every small distribution shift triggers an alert, on-call engineers quickly learn to ignore drift alerts, which means critical drift events go unnoticed. Tier your alerts: informational for minor drift, warning for moderate drift approaching the model's training distribution boundary, and critical for severe drift that likely warrants immediate investigation or rollback.

What to Practice Next

  • Implement Population Stability Index (PSI) for one numerical feature in a pipeline you own; plot PSI over time on a week of production data and identify the threshold at which you would escalate to an alert.
  • Define a three-tier severity schema (low/medium/high) for feature drift alerts with explicit PSI thresholds for each tier; write the alerting rule in pseudocode or as a monitoring config.
  • Instrument a model serving pipeline to log raw feature distributions to a time-series store; verify that you can reconstruct the feature histogram for any hour in the past 30 days.

Related Posts

More posts

Open-Weight and Small Models in 2026: When to Self-Host

Open-weight models are competitive, small models run on a phone, and the API-for-everything default is no longer obviously right. Here is a decision framework for self-hosting versus API, where small models win, what mixture-of-experts changes about the parameter count, and the hybrid most teams end up with.

#open-weight#slm#on-device#model-routing#serving#mlops

ML Model to Production: A Complete Walkthrough

Most ML models die in notebooks. Walk through the full path from trained model to live API endpoint serving real traffic - packaging, containerizing, deploying, and monitoring.

#deployment#mlops#serving

Model Versioning with MLflow: Practical Guide

Without model versioning, you cannot reproduce results, roll back broken deployments, or compare experiments. MLflow gives you a practical registry - here is how to use it well.

#mlops#experiment-tracking#deployment