Python & Data Science
MLOps Under review

Monitoring Model Performance in Production (Without the Fancy Tools)

The ‘Silent Failure’ Problem

Last time, Dev caught the holiday shopping season blindsiding his recommender. The KS test on his user features fired, the drift score climbed past his 0.1 threshold, and the Slack alert landed. He built a DriftMonitor class that compared production feature distributions against training data, learned the difference between data drift (P(X)P(X) shifting) and concept drift (P(YX)P(Y|X) shifting), and added Wasserstein distance and PSI to his toolkit. But those alerts arrived the morning after the drift had already started costing conversions. He’d found the gap: detecting drift in a batch job on yesterday’s data isn’t the same as knowing the model is working right now.

Say Dev’s recommender has been in production for three months. The API endpoint runs without errors. No alerts, no crashes. Everything looks fine.

Except the recommender has been silently returning the same default item — a pair of generic cotton socks — for every one of its ten million users for the last six weeks. The predictions are confident and consistent. And they’re costing the company thousands of dollars a day in missed conversions.

Software fails loudly. It throws an error, the app crashes, someone gets paged at 2 a.m. Models fail differently. They give you wrong answers with complete confidence, and you don’t know anything’s wrong until revenue drops or a customer complains.

A broken compass doesn’t beep. It just points you the wrong way while you walk confidently off course.

Here’s what that looks like in code:

import numpy as np
from datetime import datetime, timedelta

# Simulate a model that's broken but doesn't know it
def broken_model_prediction(user_data):
    # This model always predicts 0 (no purchase)
    # It never throws an error. It just... fails.
    return 0

# Simulate a week of predictions
today = datetime.now()
predictions = []

for day in range(7):
    current_date = today - timedelta(days=day)
    # Imagine 1000 users per day
    for user_id in range(1000):
        pred = broken_model_prediction({'user_id': user_id, 'date': current_date})
        predictions.append({
            'date': current_date,
            'user_id': user_id,
            'prediction': pred,
            'confidence': 0.95  # The model is very confident it's right
        })

# Check what happened
unique_predictions = set([p['prediction'] for p in predictions])
print(f"Unique predictions made: {unique_predictions}")
print(f"Total predictions: {len(predictions)}")
print(f"Percentage predicting 'no purchase': {sum(1 for p in predictions if p['prediction'] == 0) / len(predictions) * 100:.1f}%")

This simulates the kind of silent failure that could happen to Dev’s recommender — a model that never throws an error, never crashes, but quietly collapsed to always returning the same useless prediction for every user.

  • import numpy as np — NumPy for numerical operations. In Dev’s real recommender, predictions would come from his model’s inference endpoint, not a synthetic function, but the monitoring logic is the same.
  • from datetime import datetime, timedelta — imports datetime utilities for simulating a week of production traffic.
  • def broken_model_prediction(user_data): — a stand-in for Dev’s recommender that has silently failed. In the real world, this could be a model whose weights got corrupted, a feature pipeline that started sending zeros, or a serving container that fell back to a default fallback response — returning the same item for every user.
  • return 0 — always returns 0. In the recommender context, this is the equivalent of always returning the same default item regardless of who’s browsing — every user gets the same useless recommendation, just like the “cotton socks for everyone” scenario.
  • today = datetime.now() / predictions = [] — sets up the simulation start date and an empty list to collect prediction records.
  • for day in range(7): — simulates 7 days of production traffic, iterating backward from today.
  • for user_id in range(1000): — simulates 1,000 users per day. Dev’s real recommender serves ten million, but the pattern is the same — just more identical wrong predictions.
  • pred = broken_model_prediction({'user_id': user_id, 'date': current_date}) — calls the broken model for each user. In production, this would be the model’s actual inference call.
  • predictions.append({...}) — logs each prediction with its date, user ID, prediction value, and a confidence score of 0.95. The confidence is key — the model is very confident it’s right, even though it’s returning the same value for everyone.
  • 'confidence': 0.95 — the model reports 95% confidence. This is what makes silent failures so dangerous: the model doesn’t know it’s broken. It’s not throwing an error or returning a low-confidence score. It’s just confidently wrong.
  • unique_predictions = set([p['prediction'] for p in predictions]) — collects the set of unique prediction values across all 7,000 predictions. If the model is healthy, this set should contain multiple values. If it’s collapsed, it’ll contain just one: {0}.
  • print(f"Unique predictions made: {unique_predictions}") — prints the unique set. For the broken model, this shows {0} — one unique prediction across 7,000 requests.
  • print(f"Total predictions: {len(predictions)}") — prints 7,000 (7 days × 1,000 users).
  • print(f"Percentage predicting 'no purchase': ...") — calculates what percentage of predictions are 0. For the broken model, this is 100.0% — every single prediction is the same.

When you run this, the model makes 7,000 predictions — all ‘0’, with 95% confidence. No errors. No warnings. Just wrong.

The question isn’t whether your model will break. It will. So how will you know?

The Feedback Loop: Why We Are Flying Blind

In training, you know the answers. You have a dataset with features and labels. You can measure accuracy, precision, recall—all of it. You’re working with a scorecard where every answer is already graded.

In production, you’re flying blind.

Why? Because the actual answer—what we call ‘ground truth’—often takes a long time to arrive. This is the hardest part of model monitoring, and it’s not a technical problem. It’s a business problem.

Say you build a model to predict whether a customer will default on a loan. You deploy it on Monday. The model makes predictions for thousands of people. But you won’t know if those predictions were right until months later, when the loan either gets repaid or defaults. You can’t wait six months to find out your model is broken.

Compare that to a different problem: predicting whether someone will click an ad. You deploy the model. Someone sees the ad. They click or they don’t. You know the answer in milliseconds. Immediate feedback.

These two problems are completely different from a monitoring perspective. One has a feedback loop measured in seconds. The other has a feedback loop measured in months. The speed of feedback determines how fast you can detect problems.

When you don’t have ground truth, you have to get creative. You look for ‘proxies’—clues that tell you something is wrong without waiting for the true answer. If you’re predicting loan defaults, you might monitor whether the model is predicting the same thing for similar customers (consistency). If you’re predicting churn, you might monitor whether customers the model flagged as ‘low risk’ are actually staying around (a quick proxy for correctness).

Let’s simulate what this looks like:

import pandas as pd
import numpy as np

# Simulate a production data stream
# Imagine we're predicting loan defaults
np.random.seed(42)

data = []
for i in range(1000):
    data.append({
        'customer_id': i,
        'prediction': np.random.choice([0, 1], p=[0.8, 0.2]),  # 80% predicted 'no default'
        'prediction_date': '2024-01-15',
        'actual_default': None  # We don't know yet—it's a loan that hasn't matured
    })

df = pd.DataFrame(data)

# Check the feedback loop
print(f"Total predictions made: {len(df)}")
print(f"Predictions with ground truth available: {df['actual_default'].notna().sum()}")
print(f"Percentage of predictions with known answers: {df['actual_default'].notna().sum() / len(df) * 100:.1f}%")
print(f"\nThis means we're flying blind on {df['actual_default'].isna().sum()} predictions.")

# Now let's use a proxy: consistency
# Group by prediction and count
print(f"\nProxy metric - Distribution of predictions:")
print(df['prediction'].value_counts())
print(f"\nThis tells us what the model is doing, even without ground truth.")

This simulates the feedback-loop problem Dev faces whenever his recommender makes predictions whose outcomes take time to measure — did the user actually click? Did they convert? Did they come back next week?

  • import pandas as pd / import numpy as np — pandas for DataFrame operations, NumPy for random sampling.
  • np.random.seed(42) — fixes the random seed for reproducibility.
  • data = [] / for i in range(1000): — builds a list of 1,000 prediction records, simulating a batch of production predictions.
  • 'prediction': np.random.choice([0, 1], p=[0.8, 0.2]) — randomly assigns each prediction a 0 or 1 with 80% probability of 0. In the loan-default example, 0 means “no default”; in Dev’s recommender, this could be “user will click” vs “user won’t click.”
  • 'actual_default': None — the ground truth label is None because the outcome hasn’t happened yet. This is the crux of the feedback-loop problem: the model made a prediction, but the answer won’t arrive for weeks or months.
  • df = pd.DataFrame(data) — converts the list of dicts into a pandas DataFrame for easier analysis.
  • df['actual_default'].notna().sum() — counts how many predictions have a non-null ground truth. In this simulation, that’s 0 — every label is None.
  • df['actual_default'].notna().sum() / len(df) * 100 — calculates the percentage of predictions with known answers. Here it’s 0.0%, meaning the model is flying completely blind.
  • df['actual_default'].isna().sum() — counts how many predictions have None (unknown) ground truth. Here it’s 1,000 — all of them.
  • print(df['prediction'].value_counts()) — the proxy metric: instead of checking accuracy (which requires ground truth), Dev checks the distribution of predictions. If the model suddenly starts predicting 1 (default/click/convert) for 99% of users when it used to predict 1 for only 20%, something is wrong — even without knowing the true answers.

When you run this, you’ll see that we have 1,000 predictions but zero ground truth labels. We’re 100% blind. But we can still monitor the distribution of predictions—that’s our proxy. If the model suddenly starts predicting ‘1’ (default) for 99% of customers, that’s a red flag, even without knowing the true answers.

Data Drift: When the World Changes, But Your Model Doesn’t

Say you run a clothing retailer. You train a model to predict what shirt size a customer will buy. You collect data from June through August—summer. Your training data is full of people buying light, loose-fitting clothes. So the model learns: people want large, breathable shirts.

Then you deploy it in December. Now customers are buying heavy jackets, fitted sweaters, thermal layers. The model still predicts “large, breathable” because that’s all it knows. The world has changed. The input data has shifted.

This is data drift. Your model isn’t broken. The problem it was trained to solve no longer exists.

A related concept is concept drift, where the relationship between inputs and outputs changes. Say your summer model learned that tall people buy large shirts. In winter, tall people buy medium jackets because they layer up. The relationship shifted. The rules changed.

What this means: your model is solving yesterday’s problem.

Let’s visualize this:

import matplotlib.pyplot as plt
import numpy as np

# Simulate training data (summer)
np.random.seed(42)
training_ages = np.random.normal(loc=35, scale=12, size=1000)  # Average age 35

# Simulate production data (winter, 6 months later)
# The customer base has shifted—younger people are buying more
production_ages = np.random.normal(loc=28, scale=10, size=1000)  # Average age 28

# Create a simple comparison
print("Training data (summer):")
print(f"  Average customer age: {training_ages.mean():.1f}")
print(f"  Age range: {training_ages.min():.1f} to {training_ages.max():.1f}")

print("\nProduction data (winter, 6 months later):")
print(f"  Average customer age: {production_ages.mean():.1f}")
print(f"  Age range: {production_ages.min():.1f} to {production_ages.max():.1f}")

print("\nThe customer base has shifted younger by 7 years on average.")
print("Your model learned to predict for 35-year-olds.")
print("Now it's predicting for 28-year-olds.")
print("That's data drift.")

# Visualize it
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))

ax1.hist(training_ages, bins=30, alpha=0.7, color='blue', edgecolor='black')
ax1.set_title('Training Data Distribution (Summer)')
ax1.set_xlabel('Customer Age')
ax1.set_ylabel('Count')
ax1.axvline(training_ages.mean(), color='blue', linestyle='--', linewidth=2, label=f'Mean: {training_ages.mean():.1f}')
ax1.legend()

ax2.hist(production_ages, bins=30, alpha=0.7, color='red', edgecolor='black')
ax2.set_title('Production Data Distribution (Winter)')
ax2.set_xlabel('Customer Age')
ax2.set_ylabel('Count')
ax2.axvline(production_ages.mean(), color='red', linestyle='--', linewidth=2, label=f'Mean: {production_ages.mean():.1f}')
ax2.legend()

plt.tight_layout()
plt.savefig('drift_example.png', dpi=100, bbox_inches='tight')
print("\nVisualization saved as 'drift_example.png'")

This visualizes the kind of distribution shift Dev saw when his recommender’s user base changed between seasons — the same pattern that triggered his KS-test alerts in the previous article.

  • import matplotlib.pyplot as plt / import numpy as np — matplotlib for plotting, NumPy for generating synthetic data.
  • np.random.seed(42) — fixes the random seed for reproducibility.
  • training_ages = np.random.normal(loc=35, scale=12, size=1000) — generates 1,000 samples from a normal distribution with mean 35 and standard deviation 12. This simulates the age distribution of Dev’s training data (summer shoppers).
  • production_ages = np.random.normal(loc=28, scale=10, size=1000) — generates 1,000 samples with mean 28 and standard deviation 10. The mean has dropped by 7 years — the kind of shift Dev sees when a new demographic starts using the platform.
  • training_ages.mean() / production_ages.mean() — prints the mean of each distribution. The 7-year gap (35 vs 28) is the visible signal of drift.
  • fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4)) — creates a figure with two side-by-side subplots for comparing the two distributions visually.
  • ax1.hist(training_ages, bins=30, ...) / ax2.hist(production_ages, bins=30, ...) — plots histograms of each distribution with 30 bins. The visual gap between the two histograms is what drift looks like.
  • ax1.axvline(training_ages.mean(), ...) / ax2.axvline(production_ages.mean(), ...) — draws a dashed vertical line at each distribution’s mean, making the 7-year shift immediately visible.
  • plt.savefig('drift_example.png', dpi=100, bbox_inches='tight') — saves the figure to a PNG file. In Dev’s production system, this kind of chart would be auto-generated and posted to a Slack channel or saved to an S3 bucket for the daily monitoring report.

When you run this, you’ll see two histograms side by side. The training data centers around age 35. The production data centers around age 28. The distributions have shifted. That’s data drift in the real world.

The ‘Smoke Test’ for Models: Monitoring Distributions

You don’t need a dashboard or a PhD in statistics to spot drift. Compare two distributions and ask a simple question: are these the same?

The Kolmogorov-Smirnov test, or KS test, is built for exactly this. Think of it as a difference score between 0 and 1. Close to 0, the distributions are basically the same. Close to 1, they’re very different.

The test also gives you a p-value. A tiny one, say 0.001, means almost no chance the two distributions match by accident — the data has shifted. A large p-value like 0.5 suggests they’re probably the same.

Let’s apply this to monitor our model:

from scipy import stats
import numpy as np

# Training data (what we trained on)
training_ages = np.random.normal(loc=35, scale=12, size=1000)

# Production data (what we're seeing now)
production_ages = np.random.normal(loc=28, scale=10, size=1000)

# Run the KS test
ks_statistic, p_value = stats.ks_2samp(training_ages, production_ages)

print(f"KS Statistic: {ks_statistic:.3f}")
print(f"P-value: {p_value:.6f}")

# Interpret the results
if p_value < 0.05:
    print("\n⚠️  WARNING: Data has drifted!")
    print(f"The distributions are significantly different (p-value: {p_value:.6f}).")
    print(f"The KS statistic is {ks_statistic:.3f}, meaning the maximum difference between")
    print(f"the two distributions is about {ks_statistic*100:.1f}%.")
else:
    print("\n✓ OK: Distributions look similar.")
    print(f"No significant drift detected (p-value: {p_value:.6f}).")

This is the KS test Dev uses to check whether his recommender’s production data has drifted from the training data — the same test he built into his DriftMonitor class in the previous article, now applied as a one-off check.

  • from scipy import stats — imports SciPy’s statistics module, which contains the ks_2samp function.
  • training_ages = np.random.normal(loc=35, scale=12, size=1000) — synthetic training data: 1,000 samples from a normal distribution with mean 35.
  • production_ages = np.random.normal(loc=28, scale=10, size=1000) — synthetic production data: 1,000 samples with mean 28. The 7-unit mean shift is the drift signal.
  • ks_statistic, p_value = stats.ks_2samp(training_ages, production_ages) — runs the two-sample Kolmogorov-Smirnov test. Returns the KS statistic (the maximum gap between the two empirical CDFs, between 0 and 1) and a p-value (the probability that two samples this different came from the same distribution).
  • if p_value < 0.05: — the standard significance threshold. If the p-value is below 0.05, the test declares drift. In Dev’s production DriftMonitor, he thresholds on the statistic (> 0.1) rather than the p-value, because the p-value becomes hyper-sensitive at large sample sizes. For this quick one-off check, the p-value threshold is sufficient.
  • print(f"The KS statistic is {ks_statistic:.3f}, meaning the maximum difference ...") — interprets the statistic as a percentage: a KS statistic of 0.15 means the two CDFs differ by up to 15% at their widest point.

Run this and you’ll get a KS statistic around 0.23 to 0.31, depending on randomness, plus a p-value near 0. The distributions are different. Your data has drifted.

In practice you’d set a threshold. Many teams use p < 0.05 as the alarm; some go stricter with p < 0.01. You can automate this check in five lines of code.

Building a ‘Poor Man’s Dashboard’ in Python

Here’s the catch: you don’t need Datadog, Grafana, or Azure to start monitoring. A loop and a CSV file will do.

Let’s build something real. The idea: every hour (or day, depending on your volume), run a batch of data through your model, calculate some health metrics, and log them to a file. Open it in Excel and check whether anything looks off.

import pandas as pd
import numpy as np
from scipy import stats
from datetime import datetime

np.random.seed(42)

# Simulate training data (this is what we trained on)
training_data = pd.DataFrame({
    'age': np.random.normal(loc=35, scale=12, size=1000),
    'income': np.random.normal(loc=50000, scale=20000, size=1000)
})

# Function to generate a batch of production data.
# Batches 1-2 match the training distribution (a healthy week). Batches 3-5
# simulate a real shift — a younger, lower-income segment starts showing up —
# so the monitor actually has both a clean case and a drifted case to show.
def get_production_batch(batch_number):
    """Simulate getting a batch of new data from production."""
    if batch_number <= 2:
        return pd.DataFrame({
            'age': np.random.normal(loc=35, scale=12, size=500),
            'income': np.random.normal(loc=50000, scale=20000, size=500)
        })
    return pd.DataFrame({
        'age': np.random.normal(loc=24, scale=9, size=500),
        'income': np.random.normal(loc=32000, scale=14000, size=500)
    })

# Function to calculate health metrics
def calculate_health_metrics(training_df, production_df, batch_number):
    """Calculate drift metrics for a batch."""
    metrics = {'batch': batch_number, 'timestamp': datetime.now()}
    
    # Check each feature for drift
    for column in training_df.columns:
        ks_stat, p_value = stats.ks_2samp(training_df[column], production_df[column])
        metrics[f'{column}_ks_stat'] = ks_stat
        metrics[f'{column}_p_value'] = p_value
        metrics[f'{column}_drifted'] = 'YES' if p_value < 0.05 else 'NO'
    
    return metrics

# Main monitoring loop (simulate 5 batches)
health_log = []

for batch_num in range(1, 6):
    print(f"\n--- Processing Batch {batch_num} ---")
    
    # Get new production data
    prod_batch = get_production_batch(batch_num)
    
    # Calculate metrics
    metrics = calculate_health_metrics(training_data, prod_batch, batch_num)
    health_log.append(metrics)
    
    # Print a human-readable health report
    print(f"Timestamp: {metrics['timestamp']}")
    print(f"Age drift: {metrics['age_drifted']} (KS: {metrics['age_ks_stat']:.3f})")
    print(f"Income drift: {metrics['income_drifted']} (KS: {metrics['income_ks_stat']:.3f})")
    
    if metrics['age_drifted'] == 'YES' or metrics['income_drifted'] == 'YES':
        print("⚠️  ALERT: Drift detected!")
    else:
        print("✓ All clear.")

# Save the log to a CSV
health_df = pd.DataFrame(health_log)
health_df.to_csv('model_health_log.csv', index=False)
print(f"\nHealth log saved to 'model_health_log.csv'")
print("\nHealth Summary:")
print(health_df[['batch', 'age_drifted', 'income_drifted']])

This is the monitoring loop Dev builds to check his recommender’s health on a schedule — a lightweight, no-infrastructure-required alternative to a full observability platform.

  • import pandas as pd / import numpy as np / from scipy import stats / from datetime import datetime — the import stack: pandas for DataFrames, NumPy for synthetic data, SciPy for the KS test, and datetime for timestamps.
  • np.random.seed(42) — fixes the random seed so this walkthrough’s numbers match what you’ll see when you run it.
  • training_data = pd.DataFrame({...}) — the reference (training) data with two features: age (mean 35) and income (mean 50,000). In Dev’s real pipeline, this would be the training data snapshot saved alongside the model version in MLflow.
  • def get_production_batch(batch_number): — simulates fetching a new batch of production data. Batches 1 and 2 are drawn from the same distribution as training — a healthy week. Batches 3 through 5 are drawn from a shifted distribution — a younger, lower-income segment showing up — simulating a real change partway through the monitoring run. In Dev’s real system, this would query the feature store or the production logs for the last hour’s worth of recommender inputs.
  • def calculate_health_metrics(training_df, production_df, batch_number): — the core monitoring function. Takes the training reference, a new production batch, and a batch number, and returns a dictionary of health metrics.
  • metrics = {'batch': batch_number, 'timestamp': datetime.now()} — starts building the metrics dict with the batch number and current timestamp.
  • for column in training_df.columns: — iterates over each feature in the training data. For Dev’s recommender, these might be session_duration, items_viewed, time_of_day, price_range, etc.
  • ks_stat, p_value = stats.ks_2samp(training_df[column], production_df[column]) — runs the KS test on each feature, comparing the training distribution to the production distribution.
  • metrics[f'{column}_drifted'] = 'YES' if p_value < 0.05 else 'NO' — flags the feature as drifted if the p-value is below 0.05. Note: this uses the p-value threshold, whereas Dev’s production DriftMonitor (from the previous article) thresholds on the KS statistic (> 0.1) to avoid the large-sample sensitivity problem.
  • health_log = [] / for batch_num in range(1, 6): — simulates 5 batches of monitoring. In production, this loop would run once per hour or once per day.
  • health_df.to_csv('model_health_log.csv', index=False) — saves the full health log to a CSV file. This is the “poor man’s dashboard” — a CSV that Dev can open in Excel or a notebook to spot trends. Not glamorous, but it catches the silent failures.

When you run this, you’ll get a CSV file with one row per batch. Batches 1 and 2 come back “NO” on both features (KS statistics around 0.03–0.06) — the model is fine. Batches 3 through 5 come back “YES” on both (KS statistics jump to roughly 0.43–0.48) — the shifted segment has arrived. Open it in Excel, sort by the ‘drifted’ column, and the split between the first two rows and the last three is visible at a glance.

This isn’t fancy. It’s not a Grafana dashboard. But it works. Schedule it nightly, and by morning you’ll know if something broke.

What should Dev actually monitor? — four signals, each catching a different failure mode:

SignalWhat it catchesWhat it missesWhen to use it
Prediction distributionModel collapse (always returning the same item), sudden class imbalance, output range anomaliesSilent accuracy degradation where the distribution looks fine but the predictions are wrongFirst-line check — cheap, requires no ground truth, catches the “cotton socks for everyone” failure
Input feature distributions (KS test)Data drift — the world changed and the model’s inputs no longer match trainingConcept drift where the inputs look the same but the input→output relationship changedSecond-line check — catches the seasonal shift Dev saw when summer browsing gave way to holiday shopping
Confidence scoresModel uncertainty spiking (the model is seeing inputs it doesn’t recognize)Overconfident wrong predictions (the model is wrong but doesn’t know it)Complement to distribution checks — a drop in average confidence is a leading indicator that the model is encountering unfamiliar data
Business KPIs (conversion rate, CTR, revenue)The bottom-line impact of any model problem — if the KPI drops, something is wrongRoot cause identification — a KPI drop tells you that the model is hurting the business, not whyThe ultimate safety net — but it’s a lagging indicator. By the time conversion drops, the damage is already happening

Build your own vs. adopt a dedicated ML monitoring tool:

The “poor man’s dashboard” approach in this article — a Python loop, a KS test, and a CSV file — is free, transparent, and forces Dev to understand what every metric means. It also has limits:

ApproachProsConsWhen to graduate
Build your own (Python script + CSV + cron)Free, fully customizable, no vendor lock-in, forces understanding of the metricsNo alerting beyond print statements, no visualization beyond Excel, manual to extend, hard to share with non-technical stakeholdersDev’s current stage — one model, one team, getting the basics right
Lightweight dashboarding (Grafana + Prometheus, Metabase, Streamlit)Visual dashboards, alerting, shareable with stakeholders, still self-hostedStill requires building the data pipeline; no ML-specific features (no automatic drift detection, no model comparison)When Dev needs to show monitoring to product managers or executives
Dedicated ML monitoring (Evidently, Arize, Fiddler, WhyLabs)Pre-built drift detection, model performance dashboards, data quality checks, automatic alerting, integrates with model registriesCost (often per-model or per-data-volume pricing), vendor lock-in, learning curve, may abstract away the reasoning Dev needs to understandWhen Dev has 3+ models in production, a team of 5+ engineers, and the cost of silent failures exceeds the tooling cost

What to Do When the Alarm Goes Off

So your monitoring script just flagged drift. The alarm is going off. What now?

Don’t panic. Here’s a three-step playbook:

Step 1: Is it a data bug or a world change?

First, check the pipeline. Did someone change the data collection code, or did a sensor break? Maybe the database schema changed. Check the logs. Talk to the data engineering team. Half the time, what looks like drift is actually a pipeline bug — not a real change in the world. That has to be an actual test, not a step you print and skip: check whether the values are still inside the range the feature is physically allowed to take.

Step 2: Retraining isn’t always the answer, but it’s the first thing to try.

If the world actually changed (not a bug), retrain your model on recent data. This is often the right move. But be careful: retrain too aggressively and you might overfit to noise. I’d lean toward retraining when you see consistent drift over multiple batches, not after a single alarm. Judge “large” relative to the feature’s own spread — a 10-unit shift is enormous for a feature with a standard deviation of 1, and invisible for one with a standard deviation of 10,000.

Step 3: The ‘Human in the Loop’ — when to ask a person to check the labels.

If you’re not sure what’s happening, ask someone to manually check a sample of predictions. Have them label 100 random examples from the production batch and compare their accuracy against the model’s own validation-set baseline — not a fixed number. A model with 99% validation accuracy dropping to 92% is a real problem; a model with a 70% baseline sitting at 68% is normal noise.

Here’s what that looks like in code, run against two scenarios: a genuine world change, and a unit-conversion bug (the kind Step 1 exists to catch).

import pandas as pd
import numpy as np
from scipy import stats

np.random.seed(7)

# Simulate a scenario where drift is detected: a temperature sensor feature,
# in Celsius, with a plausible operating range of 0-40C
training_data = pd.DataFrame({
    'temperature_c': np.random.normal(loc=20, scale=5, size=1000)
})
training_std = training_data['temperature_c'].std()
baseline_accuracy = 0.92  # the model's known validation-set accuracy

def check_alert(production_data, label):
    print(f"=== DRIFT DETECTION ALERT ({label}) ===")
    ks_stat, p_value = stats.ks_2samp(training_data['temperature_c'], production_data['temperature_c'])
    print(f"P-value: {p_value:.6f}")
    print(f"KS Statistic: {ks_stat:.3f}")

    if p_value < 0.05:
        print("\n⚠️  DRIFT DETECTED")

        # Step 1: an actual test, not just a printed conclusion. Values far
        # outside the sensor's plausible physical range are a strong signal
        # of a pipeline bug (unit mismatch, broken sensor) rather than a
        # genuine world change.
        print("\nStep 1: Checking for data pipeline issues...")
        expected_min, expected_max = 0, 40
        out_of_range_share = ((production_data['temperature_c'] < expected_min) |
                               (production_data['temperature_c'] > expected_max)).mean()
        print(f"  - Training data mean: {training_data['temperature_c'].mean():.2f}")
        print(f"  - Production data mean: {production_data['temperature_c'].mean():.2f}")
        print(f"  - Share of readings outside the {expected_min}-{expected_max}C plausible range: {out_of_range_share:.1%}")

        if out_of_range_share > 0.10:
            print("  ⚠️  PIPELINE BUG SUSPECTED — too many readings outside the plausible range.")
            print("  Stop here. Fix the pipeline before considering retraining.")
        else:
            print("  ✓ No obvious pipeline bug — readings stay within the plausible range.")

            # Step 2: the threshold is relative to this feature's own spread
            # (training_std), not a bare unit count, so it carries over to
            # features on a different scale.
            print("\nStep 2: Considering retraining...")
            mean_shift = abs(training_data['temperature_c'].mean() - production_data['temperature_c'].mean())
            print(f"  Shift: {mean_shift:.2f}C ({mean_shift / training_std:.1f} training standard deviations)")
            if mean_shift > 2 * training_std:
                print("  ⚠️  Large shift detected (> 2 standard deviations).")
                print("  Recommendation: Retrain the model on recent data.")
            else:
                print("  Small shift detected (<= 2 standard deviations).")
                print("  Recommendation: Monitor for 1-2 more batches before retraining.")

            # Step 3: judged against the model's own baseline accuracy,
            # not a fixed pair of numbers.
            print("\nStep 3: Manual validation...")
            print("  Recommendation: Have a human label 100 random predictions.")
            print(f"  Compare their accuracy to the model's validation accuracy ({baseline_accuracy:.0%}).")
            print("  Within a few points of baseline: the drift is probably harmless.")
            print("  More than ~10 points below baseline: retrain immediately.")
    print()

# Scenario A: a genuine, gradual real-world shift (a warmer week)
production_data_real = pd.DataFrame({'temperature_c': np.random.normal(loc=25, scale=5, size=1000)})
check_alert(production_data_real, "genuine drift")

# Scenario B: a unit-conversion bug — someone accidentally sent Fahrenheit
production_data_bug = pd.DataFrame({'temperature_c': np.random.normal(loc=20, scale=5, size=1000) * 9 / 5 + 32})
check_alert(production_data_bug, "unit-conversion bug")

This is the decision tree Dev follows when his monitoring system flags drift in the recommender — check for bugs first, then decide whether to retrain, then decide whether to ask a human to spot-check. It runs against two scenarios so both branches of Step 1 actually execute.

  • training_data = pd.DataFrame({'temperature_c': np.random.normal(loc=20, scale=5, size=1000)}) — synthetic training data for a temperature sensor in Celsius, mean 20, standard deviation 5. expected_min, expected_max = 0, 40 is the sensor’s plausible physical range.
  • check_alert(production_data, label) — runs the full playbook against whatever production batch it’s given, so the same logic can be exercised against a clean scenario and a broken one.
  • ks_stat, p_value = stats.ks_2samp(...) — runs the KS test to detect the shift. Both scenarios below produce a p-value near 0 — the distributions are clearly different in both cases; the question the playbook answers is why.
  • Step 1 — out_of_range_share = ((production_data[...] < expected_min) | (... > expected_max)).mean() — this is the actual test: the share of production readings outside the plausible physical range. If more than 10% of readings are outside it, that’s the pipeline-bug branch, and the playbook stops there rather than moving on to retraining. Genuine drift (Scenario A, a 5-degree warm shift) keeps essentially all readings in-range; a unit-conversion bug (Scenario B, Celsius values sent as Fahrenheit) pushes almost all of them out.
  • Step 2 — if mean_shift > 2 * training_std: — only reached once Step 1 has ruled out a pipeline bug. The threshold is expressed in standard deviations of the training feature, not a bare unit count, so the same code works whether the feature is a 0-40 range or a 0-100,000 range.
  • Step 3 — baseline_accuracy = 0.92 — the human-in-the-loop check is judged against the model’s own validation accuracy, not a fixed 90%/85% pair that would be meaningless for a model with a very different baseline.

When you run this, Scenario A (genuine drift) passes Step 1 clean — 0.3% of readings out of range — and moves on to Steps 2 and 3. Scenario B (the unit-conversion bug) fails Step 1 — 99.8% of readings out of range — and stops there. That’s the actual workflow that works in production: don’t retrain on a pipeline bug.

Wrapping Up

What you’ve learned:

  1. Models fail silently. Confident, wrong answers. You won’t know unless you monitor.

  2. Ground truth delay is the hardest part. The true answer might take weeks or months to arrive. Proxies and distribution monitoring fill the gap.

  3. Data drift is real. The world changes; your model doesn’t. Spot when the inputs shift.

  4. You don’t need fancy tools to start. A KS test, a loop, and a CSV file will catch most problems.

  5. When the alarm goes off, follow the playbook. Check for bugs first, then consider retraining, then ask a human.

Monitoring doesn’t require a PhD in DevOps. It takes discipline, some Python, and the habit of checking on your model regularly. Start simple, start today. Catch a problem before it costs the company money, and your future self will thank you.


Dev now has a monitoring layer that catches silent failures in near-real-time. The KS test fires when input distributions shift, the prediction-distribution check catches model collapse, and the health log surfaces problems before they compound. But here’s what’s bugging him: every alert he’s built is reactive. The alarm goes off after the model has already started degrading, after conversion rates have dipped, after users have already been getting bad recommendations. What if he could retrain the recommender before the drift alarm fires — scheduling retraining based on a signal that predicts degradation rather than waiting for it? That’s the next question: when should you retrain, and how do you build a trigger that’s proactive instead of reactive?

Check Your Understanding

Questions below move from simple recall up to open-ended design, roughly following Bloom’s Taxonomy.

Remember Why does the article say models “fail silently” in a way that traditional software doesn’t?

Understand In your own words, explain the difference between the loan-default example and the ad-click example in terms of “feedback loop” speed, and why that difference changes how you’d monitor each one.

Apply Using the article’s three-step playbook (bug check → retrain decision → human-in-the-loop), if a KS test flags drift but you find the data pipeline was accidentally sending temperature in Fahrenheit instead of Celsius, which step resolves this, and does it require retraining?

Analyze The article distinguishes Data Drift (P(X)P(X) changing — the inputs shift) from Concept Drift (the relationship between inputs and outputs changes — “tall people buy medium jackets because they layer”). Walk through why the KS test on feature distributions (like age) would catch Data Drift but would completely miss a pure Concept Drift scenario where the age distribution stays identical but the buying behavior for each age group flips.

Evaluate The article’s “proxy metric” for the loan-default model monitors the distribution of predictions (e.g., “if the model suddenly starts predicting default for 99% of customers, that’s a red flag”). Critique this proxy: describe a failure mode where the model is now silently and badly wrong, but the prediction distribution looks completely normal and wouldn’t trigger this specific check.

Create Design a monitoring plan for a new scenario with a long feedback loop: a model predicting whether a job candidate will still be employed at the company after 1 year. Using the article’s toolkit (KS test on features, proxy metrics, human-in-the-loop spot checks), propose what you’d monitor weekly versus what you’d only be able to validate after a year, and one proxy metric that might surface a problem early.


References & Further reading

  • Breck, E., Bai, Y., Gulcehre, C., & Sculley, D. (2017). The ML Test Score: A Rubric for ML Production Readiness and Technical Debt Reduction. SE4ML: Software Engineering for Machine Learning Workshop at ICSE. — the seminal paper on ML production readiness, including monitoring and testing practices for deployed models. The authors (at Google) introduced the concept of “silent failures” in ML systems and the rubric for evaluating whether a model is truly production-ready.
  • SciPy ks_2samp documentation — https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.ks_2samp.html — official reference for the two-sample Kolmogorov-Smirnov test used throughout this article’s monitoring code.

Apply What You Learned is for Supporter and Insider subscribers.

Subscribe to unlock the exercises on this post.

See plans

Looking for something else?

Search every article by title, summary or topic.