A practical guide to understanding and implementing drift detection in ML systems.
A model trained on historical data makes one critical assumption: the future will look like the past. When that assumption breaks, the model's predictions become unreliable. This is "drift."
There are three distinct types, and they require different detection strategies:
What changes: The distribution of input features.
Example: Your churn model was trained on customers aged 25-45, but a marketing campaign brings in users aged 55+. The model has never seen this population and may produce unreliable predictions.
Detection: Compare the feature distributions between training data and production data using statistical tests like PSI or KS-test.
Key insight: Data drift can happen WITHOUT the model being wrong — the model might still make correct predictions for the new population. But it's a warning sign that the model is operating outside its training domain.
What changes: The relationship between features and the target variable.
Example: During a recession, the factors that predict customer churn change. Previously, low engagement predicted churn. Now, even engaged customers are churning due to budget cuts. The model's learned rules are no longer valid.
Detection: Monitor prediction errors over time. If the error rate increases while the input distribution stays stable, it's likely concept drift. ADWIN and Page-Hinkley are online algorithms that detect this without storing all historical data.
Key insight: Concept drift is more dangerous than data drift because even familiar-looking inputs produce wrong outputs. The model is confidently wrong.
What changes: The proportion of positive/negative cases.
Example: A fraud detection model trained when 1% of transactions were fraudulent starts seeing 5% fraud (new attack vector). The model's threshold, calibrated for 1% base rate, now produces too many false negatives.
Detection: Track the prediction distribution and the ground-truth positive rate over time.
PSI measures how much a distribution has shifted by comparing binned probability distributions between a reference (training) set and the current (production) set.
How it works:
1. Bin the reference data into N equal-width buckets (default: 10)
2. Count what proportion of reference data falls in each bin
3. Count what proportion of current data falls in each bin
4. For each bin: psi_i = (P_current - P_reference) × ln(P_current / P_reference)
5. Sum across all bins: PSI = Σ psi_i
Interpretation:
| PSI Value | Interpretation | Action |
|---|---|---|
| < 0.10 | No significant drift | Continue monitoring |
| 0.10 – 0.25 | Moderate drift | Investigate; consider retraining |
| > 0.25 | Significant drift | Retrain the model |
Strengths:
Weaknesses:
The KS test compares two empirical CDFs and measures the maximum vertical distance between them. It's non-parametric — no assumptions about the underlying distribution.
How it works:
1. Sort both samples
2. Build the empirical CDF for each: F(x) = (count of values ≤ x) / n
3. Find the point where the CDFs are furthest apart: D = max |F_ref(x) - F_cur(x)|
4. Compare D against a critical value that depends on sample sizes and significance level
Interpretation:
1.36 × √((n1+n2)/(n1×n2))Strengths:
Weaknesses:
For categorical features (country, device type, subscription tier), we use Pearson's chi-squared test to compare category frequencies.
How it works:
1. Count occurrences of each category in reference and current data
2. Scale reference counts to match current sample size (expected counts)
3. Compute: χ² = Σ (observed_i - expected_i)² / expected_i
4. Compare against chi-squared distribution with (k-1) degrees of freedom
When to use: Any feature with a small number of discrete values. For features with 100+ categories, consider grouping rare categories or using a different test.
ADWIN maintains a variable-length window of recent observations (typically prediction errors). At each step, it checks whether any split of the window shows a statistically significant mean difference.
Key properties:
delta parameter (smaller = fewer false alarms)When to use: Gradual drift where you want automatic sensitivity adjustment.
A cumulative sum (CUSUM) test that detects changes in the mean of a stream. It maintains a running sum of deviations from the mean and triggers when the cumulative deviation exceeds a threshold.
Key properties:
delta (dead zone) and lambda (threshold)When to use: Abrupt concept drift (sudden model degradation).
Is the feature categorical?
├── YES → Chi-Squared Test
└── NO (continuous) →
├── Do you need a single interpretability score? → PSI
├── Do you need rigorous statistical significance? → KS Test
└── For online streaming detection → ADWIN or Page-Hinkley
| Prediction Volume | Recommended Interval |
|---|---|
| < 100/day | Weekly batch analysis |
| 100-10,000/day | Daily batch analysis |
| 10,000-1M/day | Hourly drift checks |
| > 1M/day | Continuous (every N predictions) |
Both reference and current samples should have at least 200-500 observations per feature for reliable test results. With fewer than 100, statistical tests lack power and may miss real drift.
1. Investigate before retraining. Drift might be a data quality issue, not a real distribution change.
2. Check upstream data sources. Schema changes, ETL bugs, and missing imputation are common root causes.
3. Compare drift magnitude across features. If one feature drifts while others are stable, it's likely a data issue rather than a population shift.
4. Monitor performance alongside drift. Data drift without performance degradation may not require action.
5. When retraining, use a blend of old and new data unless the old distribution is completely irrelevant.
Step-by-step instructions for integrating the monitoring toolkit into your ML serving infrastructure.
pip install -r requirements.txtAdd monitoring hooks to your model serving endpoint:
from src.drift_detectors import MultiFeatureDriftScanner
from src.performance_monitor import ClassificationMonitor, ClassificationMetrics
from src.prediction_logger import PredictionLogger
from src.metric_exporters import MLMetricsExporter
from src.alert_engine import AlertEngine, AlertRule, LogNotificationChannel
# --- Set up components ---
# Prediction logging
pred_logger = PredictionLogger(
log_dir="prediction_logs",
buffer_size=100,
model_name="churn_model",
model_version="v2.3",
)
# Performance monitoring with baseline from validation
baseline = ClassificationMetrics(accuracy=0.92, precision=0.89, recall=0.87, f1_score=0.88)
perf_monitor = ClassificationMonitor(window_size=1000, baseline_metrics=baseline)
# Prometheus exporter
exporter = MLMetricsExporter()
exporter.start_http_server(port=9090)
# --- In your prediction handler ---
def predict(features: dict) -> dict:
# Your model inference
prediction = model.predict(features)
confidence = model.predict_proba(features)
# Log prediction
pred_logger.log(
features=features,
prediction=prediction,
confidence=confidence,
)
# Update performance (when ground truth arrives later)
# perf_monitor.add_prediction(y_true=actual, y_pred=prediction)
# Export to Prometheus
exporter.record_prediction("churn_model", "v2.3", confidence=confidence)
return {"prediction": prediction, "confidence": confidence}Create a cron job or scheduled task that runs drift analysis against reference data:
import json
from src.drift_detectors import MultiFeatureDriftScanner
# Load reference data (from training set)
with open("reference_data.json") as f:
reference = json.load(f)
# Load recent production data (from prediction logs)
# ... your data loading logic here ...
current = load_recent_predictions(hours=24)
scanner = MultiFeatureDriftScanner(
psi_threshold=0.25,
ks_alpha=0.05,
)
results = scanner.scan_numeric(reference["numeric"], current["numeric"])
results += scanner.scan_categorical(reference["categorical"], current["categorical"])
report = scanner.generate_report(results)
if report["status"] == "drift_detected":
print(f"DRIFT: {report['features_drifted']}/{report['features_tested']} features")1. Open Grafana → Dashboards → Import
2. Upload dashboards/model_overview.json
3. Upload dashboards/drift_analysis.json
4. Select your Prometheus data source
5. The dashboards auto-discover models via label queries
Edit configs/alert_rules.yaml to match your thresholds, then load them:
import yaml
from src.alert_engine import AlertEngine, LogNotificationChannel, WebhookNotificationChannel
with open("configs/alert_rules.yaml") as f:
config = yaml.safe_load(f)
engine = AlertEngine(consecutive_required=2)
engine.load_rules_from_dicts(config["rules"])
engine.add_channel(LogNotificationChannel())
# Optional: add Slack webhook
# engine.add_channel(WebhookNotificationChannel("https://hooks.example.com/YOUR_WEBHOOK"))┌──────────────────┐ ┌──────────────┐ ┌──────────────┐
│ Model Server │────▶│ Prediction │────▶│ JSONL Logs │
│ (your code) │ │ Logger │ │ (on disk) │
└────────┬─────────┘ └──────────────┘ └──────────────┘
│
│ metrics
▼
┌──────────────────┐ ┌──────────────┐ ┌──────────────┐
│ Metric Exporter │────▶│ Prometheus │────▶│ Grafana │
│ (:9090/metrics) │ │ (scrapes) │ │ dashboards │
└──────────────────┘ └──────────────┘ └──────────────┘
│
┌──────────────────┐ ┌──────────────┐ │
│ Drift Scanner │────▶│ Alert Engine │────────────┘
│ (scheduled) │ │ (webhook/log)│
└──────────────────┘ └──────────────┘
| File | Purpose |
|---|---|
src/drift_detectors.py | PSI, KS-test, chi-squared statistical drift tests |
src/concept_drift.py | ADWIN and Page-Hinkley online concept drift detection |
src/performance_monitor.py | Sliding-window classification and regression metrics |
src/prediction_logger.py | Structured JSONL prediction logging with ground-truth join |
src/metric_exporters.py | Prometheus metric export via HTTP endpoint |
src/alert_engine.py | Rule-based alerting with cooldown and escalation |
src/stats_utils.py | Pure-Python statistical helpers (CDF, histogram, KS math) |
dashboards/model_overview.json | Grafana dashboard — model health overview |
dashboards/drift_analysis.json | Grafana dashboard — feature drift deep-dive |
configs/alert_rules.yaml | Alert rule definitions with thresholds |
configs/monitoring_config.yaml | Central monitoring configuration |
configs/prometheus.yaml | Prometheus scrape configuration |
Metrics endpoint returns empty:
prometheus_client is installed: pip install prometheus-clientcurl http://localhost:9090/metricsDrift tests show no drift on obviously shifted data:
Alert not firing:
consecutive_required settingQuestions or issues? Contact support@datanest.dev