Data Drift, Model Monitoring & Retraining Triggers

Monitoring a model in production requires more than Prometheus metrics. The model can degrade silently — same CPU/memory, but wrong answers — because the world changed.

0/0 checks

Types of Drift

graph TD
    subgraph Input["Input changes"]
        DD["Data Drift<br/>Feature distribution changed<br/>from training data<br/><br/>Example: avg order value was $50,<br/>now users spend $200"]
        CD["Covariate Shift<br/>Input distribution changed<br/>but relationship still holds<br/><br/>(subset of data drift)"]
    end

    subgraph Output["Output changes"]
        CONC["Concept Drift<br/>Relationship between input<br/>and correct output changed<br/><br/>Example: 'fast' meant quick delivery,<br/>now users mean fast website"]
        PD["Prediction Drift<br/>Model outputs shifted<br/>(proxy for concept drift)"]
    end

    DD & CD --> RETRAIN_DATA["Retrain with new data distribution"]
    CONC --> RETRAIN_LABELS["Retrain with new labels<br/>(harder — need human annotation)"]
    PD --> INVESTIGATE["Investigate: is input or output drifting?"]

Four named drift types, flip through them:

Input changes. Feature distribution changed from training data — e.g. average order value was $50, now users spend $200. Fix: retrain with the new data distribution.
Input changes (subset of data drift). The input distribution changed, but the relationship between input and output still holds. Fix: same as data drift — retrain with the new data distribution.
Output changes. The relationship between input and correct output changed — e.g. "fast" used to mean quick delivery, now users mean fast website. Fix: retrain with new labels, which is harder because it needs human annotation.
Output changes (proxy for concept drift). Model outputs shifted. Fix: investigate whether it's the input or the output that's actually drifting before deciding how to retrain.

Data drift and concept drift both mean the model's predictions might now be wrong. What's the key difference in how you fix each?


Evidently — Data and Model Monitoring

Evidently generates HTML reports and JSON metrics comparing a reference dataset (training) against current production data.

Install

pip install evidently

Generate a drift report

import pandas as pd
from evidently.report import Report
from evidently.metric_preset import DataDriftPreset, DataQualityPreset
from evidently.metrics import ColumnDriftMetric

# Reference = training data (what the model was trained on)
reference_data = pd.read_parquet("s3://my-data/training/reference.parquet")

# Current = last 24h of production requests (with features logged)
current_data = pd.read_parquet("s3://my-data/production/last_24h.parquet")

# Generate report
report = Report(metrics=[
    DataDriftPreset(),           # checks all features for drift
    DataQualityPreset(),         # null rates, min/max, distribution
    ColumnDriftMetric(column_name="user_age"),     # specific feature
    ColumnDriftMetric(column_name="purchase_amount"),
])

report.run(reference_data=reference_data, current_data=current_data)
report.save_html("drift_report.html")

# Extract metrics programmatically
result = report.as_dict()
drift_score = result["metrics"][0]["result"]["dataset_drift"]
drifted_features = result["metrics"][0]["result"]["number_of_drifted_columns"]
print(f"Drift detected: {drift_score}, drifted features: {drifted_features}")

Run as a K8s CronJob

apiVersion: batch/v1
kind: CronJob
metadata:
  name: drift-monitor
  namespace: ml-monitoring
spec:
  schedule: "0 6 * * *"    # daily at 6am
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: drift-check
            image: myrepo/drift-monitor:v1.2
            env:
            - name: REFERENCE_PATH
              value: "s3://my-data/training/reference.parquet"
            - name: CURRENT_PATH
              value: "s3://my-data/production/yesterday.parquet"
            - name: DRIFT_THRESHOLD
              value: "0.3"
            - name: WEBHOOK_URL
              value: "http://pipeline-trigger/webhooks/drift-detected"
          restartPolicy: OnFailure
# monitor.py — inside the CronJob container
import sys, requests, pandas as pd
from evidently.report import Report
from evidently.metric_preset import DataDriftPreset

reference = pd.read_parquet(os.environ["REFERENCE_PATH"])
current = pd.read_parquet(os.environ["CURRENT_PATH"])
threshold = float(os.environ["DRIFT_THRESHOLD"])

report = Report(metrics=[DataDriftPreset()])
report.run(reference_data=reference, current_data=current)
result = report.as_dict()

drift_share = result["metrics"][0]["result"]["share_of_drifted_columns"]

if drift_share > threshold:
    print(f"DRIFT DETECTED: {drift_share:.2%} of features drifted")
    requests.post(os.environ["WEBHOOK_URL"], json={
        "drift_score": drift_share,
        "feature": "dataset",
        "timestamp": datetime.utcnow().isoformat(),
    })
    sys.exit(0)   # drift detected but handled — exit 0 (not a job failure)

print(f"No significant drift: {drift_share:.2%}")

The daily cycle that CronJob runs, step by step:

1. Schedule fires. The CronJob's schedule (0 6 * * *) triggers a new Job daily at 6am, spinning up the drift-monitor container.
2. Load reference and current data. The container reads REFERENCE_PATH (training data) and CURRENT_PATH (yesterday's production data) from S3.
3. Run the drift report. Evidently's DataDriftPreset compares the two datasets and computes share_of_drifted_columns.
4. Compare against threshold. If drift_share is below DRIFT_THRESHOLD (0.3 here), it logs "No significant drift" and the job ends quietly.
5. Fire the webhook. If the threshold is exceeded, it POSTs to WEBHOOK_URL with the drift score, then exits 0 — drift detected and handled is not a job failure.

The drift-monitor CronJob detects drift above threshold, posts to the webhook, then calls sys.exit(0) instead of raising an error. Why exit 0 and not a non-zero failure code?


Statistical Tests Used by Evidently

Test Metric type What it detects
Kolmogorov-Smirnov Continuous (float) Distribution shift
Chi-squared Categorical Category proportion change
Jensen-Shannon divergence Both Probability distribution distance
Population Stability Index (PSI) Both Industry standard for credit models
Wasserstein distance Continuous Earth mover's distance

Evidently auto-selects the right test based on column type. You can override:

from evidently.calculations.stattests import ks_stat_test, chi_stat_test

ColumnDriftMetric(
    column_name="user_age",
    stattest=ks_stat_test,
    stattest_threshold=0.05,   # p-value threshold
)

You don't specify a stattest for a ColumnDriftMetric. Does Evidently skip the check, or pick one for you?


Shadow Scoring — Detecting Concept Drift

Data drift = inputs changed. Concept drift = correct answer for same input changed. You can only detect concept drift with ground truth labels — which arrive delayed (e.g., did the customer actually churn?).

sequenceDiagram
    participant REQ as Request
    participant PROD as Production Model
    participant SHADOW as Shadow Model (new version)
    participant LOG as Label Store

    REQ->>PROD: input features
    REQ->>SHADOW: same input (parallel, async)
    PROD-->>REQ: prediction (served to user)
    SHADOW-->>LOG: prediction logged (not served)
    Note over LOG: 7 days later...
    LOG->>LOG: ground truth arrives (did user churn?)
    LOG->>LOG: compare: production accuracy vs shadow accuracy
    Note over LOG: if shadow >> production: promote shadow
# Shadow scoring pattern in FastAPI
@app.post("/predict")
async def predict(features: dict):
    # Serve production model
    prod_prediction = production_model.predict(features)

    # Shadow: run new model async, log results but don't serve
    asyncio.create_task(
        log_shadow_prediction(shadow_model, features, prod_prediction)
    )

    return {"prediction": prod_prediction}

async def log_shadow_prediction(shadow_model, features, prod_pred):
    shadow_pred = shadow_model.predict(features)
    await metrics_store.log({
        "timestamp": datetime.utcnow(),
        "features_hash": hash(str(features)),
        "prod_prediction": prod_pred,
        "shadow_prediction": shadow_pred,
    })

In shadow scoring, why does the shadow model's prediction never get returned to the user?


A/B Testing Model Versions

Route a percentage of production traffic to the new model version:

# KServe canary — 10% to new model
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: churn-model
spec:
  predictor:
    canaryTrafficPercent: 10     # 10% to v2
    model:
      storageUri: "s3://models/churn-v1/"
# Track A/B metrics in Prometheus
from prometheus_client import Counter, Histogram

predictions = Counter("model_predictions_total", "Predictions by version",
                       labelnames=["model_version"])
accuracy = Histogram("model_accuracy", "Prediction accuracy by version",
                     labelnames=["model_version"])

# After ground truth arrives:
def record_outcome(version: str, correct: bool):
    predictions.labels(model_version=version).inc()
    accuracy.labels(model_version=version).observe(1.0 if correct else 0.0)
# A/B accuracy comparison
sum(rate(model_accuracy_sum[1h])) by (model_version)
/
sum(rate(model_accuracy_count[1h])) by (model_version)

The canary rollout, step by step:

1. Deploy the canary. The new model version goes live behind the same InferenceService, taking a small slice of production traffic — canaryTrafficPercent: 10 here means v2 gets 10% of requests, v1 keeps the rest.
2. Track metrics per version. Every prediction increments a Prometheus counter labeled by model_version; once ground truth arrives, a histogram records 1.0/0.0 for correct/incorrect, also labeled by version.
3. Compare with PromQL. The accuracy query groups by model_version, giving a side-by-side accuracy rate for v1 vs v2 over the same time window.
4. Decide. Whichever version's accuracy wins that comparison is the one worth keeping — the losing version's traffic share goes back down.

Both the canary rollout and the accuracy comparison label metrics by model_version. Why not just look at the new model's raw accuracy number on its own?


Monitoring Stack Summary

graph TD
    A["Production request logging"] -->|daily batch| B["Feature store / data lake (S3)"]
    B --> C["Evidently CronJob"]
    C --> D["Drift report"]
    D -->|webhook if threshold exceeded| E["Training pipeline triggered"]
    E --> F["New model trained + evaluated"]
    F -->|if accuracy gate passes| G["Model registered in Staging"]
    G --> H["Shadow test against production"]
    H -->|if shadow accuracy > production| I["Promote to Production<br/>(via MLflow API or<br/>ArgoCD + model URI update)"]

The same pipeline, one stage at a time:

1. Logging. Every production request's features get logged to a feature store / data lake in S3 — this becomes the "current" dataset in tomorrow's drift report.
2. Daily drift check. The Evidently CronJob runs once a day, comparing that data against the training reference, and only fires a webhook if the drift share exceeds the threshold.
3. Retrain. The webhook triggers the training pipeline, which trains and evaluates a new model on the new data distribution.
4. Gate and stage. Only if the new model clears its accuracy gate does it get registered in Staging — a failing model never makes it past this point.
5. Shadow test. The staged model runs as a shadow alongside production, scoring the same live traffic without serving it, until delayed ground truth lets you compare its accuracy against the incumbent.
6. Promote. Only if the shadow's accuracy beats production's does it get promoted — via the MLflow API or an ArgoCD-driven model URI update.

A new model clears the accuracy gate in the training pipeline and gets registered in Staging. Is it safe to promote it to Production at that point?


Key Metrics to Alert On

Metric How to measure Alert
Feature drift score Evidently PSI / KS > 0.25
Prediction distribution shift Distribution of model outputs KS p-value < 0.05
Model accuracy (if labels available) Correct / total Drop > 5% from baseline
Null rate in features % of null values per feature Spike > 3× baseline
Request volume Prometheus rate(predictions_total[5m]) Drop > 30% (upstream issue)
TTFT / latency vLLM metrics p99 > SLO