Skip to content
ExponaBLOG
PRACTICAL AI & MACHINE LEARNING · DAY 059 / 100

Model monitoring in production: best practices explained

Models degrade quietly.

Expona Admin10 min read

The Silent Problem

You deployed your model three months ago and it was performing beautifully in validation. Today it's silently degrading, serving predictions that miss 40% more often than they did on day one. Your team doesn't know because nobody built monitoring, and by the time a customer notices, you've lost revenue and trust. Model monitoring in production: best practices explained is not optional infrastructure—it's the difference between a system you control and one that controls you.

Unlike traditional software, machine learning systems fail quietly. Code failures throw exceptions. Models just drift, slowly returning worse predictions while looking healthy on the surface. This article walks through the core practices we've learned across trading platforms, SaaS recommendation engines, and ride-hailing ML systems: tracking input distributions for drift, monitoring prediction distributions, comparing against outcomes when labels arrive, and alerting and retraining on schedule or trigger.

Track Input Distributions for Drift

Your model was trained on data from Q2. Now it's Q4 and your user base has shifted. New customer segments behave differently. Seasonal patterns emerge. Traffic sources change. These shifts alter the distribution of input features, and a model trained on Q2 data will struggle with Q4 patterns it's never seen.

Input distribution drift is your first warning sign. Before the model makes a bad prediction, the data arriving at it has already changed. We track this by computing statistical summaries of key features and comparing them over time windows—typically daily or hourly depending on traffic volume.

A practical approach starts with the Kolmogorov-Smirnov (KS) test or Population Stability Index (PSI) for numerical features:

python
import numpy as np
from scipy.stats import ks_2samp

def calculate_psi(expected, actual, buckets=10):
    """
    Population Stability Index: measures distribution shift.
    PSI > 0.25 typically indicates significant drift.
    """
    def psi_bucket(expected_prop, actual_prop):
        if expected_prop == 0:
            expected_prop = 0.0001
        if actual_prop == 0:
            actual_prop = 0.0001
        return (actual_prop - expected_prop) * np.log(actual_prop / expected_prop)
    
    breakpoints = np.percentile(expected, np.linspace(0, 100, buckets + 1))
    breakpoints[0] = -np.inf
    breakpoints[-1] = np.inf
    
    expected_counts = np.histogram(expected, bins=breakpoints)[0]
    actual_counts = np.histogram(actual, bins=breakpoints)[0]
    
    expected_props = expected_counts / len(expected)
    actual_props = actual_counts / len(actual)
    
    return sum(psi_bucket(e, a) for e, a in zip(expected_props, actual_props))

# Baseline from training data
baseline_feature = model_training_data['user_age']

# Compare with production data from yesterday
production_feature = production_data['user_age']

psi_value = calculate_psi(baseline_feature, production_feature)
if psi_value > 0.25:
    alert("Feature 'user_age' has drifted. PSI: {:.3f}".format(psi_value))

For categorical features, use chi-square tests or simply track the proportion of each category over time. The key insight: if your input distribution has shifted significantly, your model's assumptions have been violated. This doesn't guarantee predictions are wrong yet, but it's a red flag that warrants investigation.

Store baseline distributions from training and roll them forward. In production, compute distributions daily (or whatever granularity makes sense for your traffic). Set thresholds for PSI, KS statistic, or Jensen-Shannon divergence. When a feature crosses the threshold, log it and alert your team.

Monitor Prediction Distributions

Even if input distributions haven't changed, model behavior can shift. A classification model might suddenly return positive predictions 60% of the time instead of 20%. This can happen due to upstream changes in how data flows to your model, bugs in feature engineering, or subtle model decay.

Monitoring prediction distributions is straightforward: track the histogram of raw model outputs and the distribution of class predictions. Compare these across time windows.

python
import pandas as pd
from collections import Counter

class PredictionMonitor:
    def __init__(self, baseline_predictions):
        """
        baseline_predictions: array of predictions from validation set
        """
        self.baseline_positive_rate = (baseline_predictions > 0.5).mean()
        self.baseline_mean_score = baseline_predictions.mean()
        self.baseline_quantiles = np.quantile(baseline_predictions, [0.25, 0.5, 0.75])
    
    def check(self, recent_predictions, window_name='last_24h'):
        """
        Compare recent predictions against baseline distribution
        """
        recent_positive_rate = (recent_predictions > 0.5).mean()
        recent_mean_score = recent_predictions.mean()
        recent_quantiles = np.quantile(recent_predictions, [0.25, 0.5, 0.75])
        
        issues = []
        
        # Check if positive rate has shifted by more than 10 percentage points
        if abs(recent_positive_rate - self.baseline_positive_rate) > 0.1:
            issues.append(
                f"Positive rate changed: {self.baseline_positive_rate:.2%} -> "
                f"{recent_positive_rate:.2%}"
            )
        
        # Check if mean score has shifted significantly
        if abs(recent_mean_score - self.baseline_mean_score) > 0.05:
            issues.append(
                f"Mean prediction score changed: {self.baseline_mean_score:.3f} -> "
                f"{recent_mean_score:.3f}"
            )
        
        if issues:
            for issue in issues:
                alert(f"[{window_name}] Prediction distribution shift: {issue}")
        
        return len(issues) == 0

# Usage
monitor = PredictionMonitor(validation_predictions)
hourly_predictions = get_predictions_from_last_hour()
monitor.check(hourly_predictions, window_name='last_hour')

This catches cases where the model is systematically over-predicting or under-predicting, or where the spread of predictions has narrowed (e.g., the model has become more confident and less calibrated). These patterns often emerge before you see accuracy degradation in labels.

Compare Against Outcomes When Labels Arrive

The gold standard for monitoring is comparing predictions to ground truth. The challenge: labels often arrive late. In a ride-hailing platform, you might predict ride demand hours before it's fulfilled. In a credit model, loan outcomes take months. In a recommendation system, you might never know whether the user would have clicked if you'd recommended something else (counterfactual problem).

Nevertheless, compare predictions to labels as soon as labels become available. For problems where labels are eventually observable, set up a daily or weekly batch that pulls predictions made X days ago and the corresponding labels that have now arrived.

python
class OutcomeMonitor:
    def __init__(self, lookback_days=7):
        self.lookback_days = lookback_days
    
    def evaluate_delayed_labels(self):
        """
        For predictions made N days ago, labels should now be available.
        Compare predictions to actual outcomes.
        """
        # For a recommendation system: predictions made 7 days ago
        # Can now check if user clicked, purchased, etc.
        prediction_timestamp = datetime.now() - timedelta(days=self.lookback_days)
        
        predictions_df = query_predictions(
            made_after=prediction_timestamp - timedelta(hours=1),
            made_before=prediction_timestamp
        )
        
        # Join with outcomes
        outcomes_df = query_outcomes(
            prediction_ids=predictions_df['id']
        )
        
        merged = predictions_df.merge(outcomes_df, on='prediction_id')
        
        # Calculate metrics
        accuracy = (merged['predicted_label'] == merged['actual_label']).mean()
        precision = (
            merged[merged['predicted_label'] == 1]['actual_label'].mean()
        )
        recall = (
            merged[merged['actual_label'] == 1]['predicted_label'].mean()
        )
        
        # Compare against baseline (e.g., 90 days ago)
        baseline_accuracy = get_baseline_metric('accuracy', days_ago=90)
        
        if accuracy < baseline_accuracy * 0.95:  # 5% degradation
            alert(
                f"Accuracy degraded: {baseline_accuracy:.2%} -> {accuracy:.2%}"
            )
        
        return {
            'accuracy': accuracy,
            'precision': precision,
            'recall': recall,
            'sample_size': len(merged)
        }

# Run daily
monitor = OutcomeMonitor(lookback_days=7)
monitor.evaluate_delayed_labels()

This is your real ground truth check. If labels show degradation, you know your model is actually failing, not just drifting in input distribution. Use this data to inform retraining decisions and to validate that your input drift detection is actually predictive of real performance loss.

Alert and Retrain on Schedule or Trigger

Once you're monitoring, you need to act. Most teams use a hybrid approach: scheduled retraining plus trigger-based retraining.

Scheduled retraining is straightforward. Every Sunday night, or every first of the month, you retrain on the latest data and deploy if metrics look reasonable. This keeps your model fresh and accounts for gradual drift that doesn't spike but compounds over time.

Trigger-based retraining fires when a specific alert condition is met. Input drift exceeds threshold. Prediction distribution shifts. Accuracy drops below baseline. You retrain immediately and evaluate whether the new model is better before deploying.

python
class RetrainingOrchestrator:
    def __init__(self, model_name, schedule='weekly', trigger_thresholds=None):
        self.model_name = model_name
        self.schedule = schedule
        self.trigger_thresholds = trigger_thresholds or {}
    
    def should_retrain(self, reason='scheduled'):
        """
        Decide whether to trigger retraining.
        reason: 'scheduled', 'drift_detected', 'accuracy_drop', etc.
        """
        if reason == 'scheduled':
            return is_scheduled_window(self.schedule)
        
        if reason == 'drift_detected':
            # Check input distribution drift
            psi = calculate_psi(baseline_dist, current_dist)
            if psi > self.trigger_thresholds.get('psi', 0.25):
                return True
        
        if reason == 'accuracy_drop':
            # Check against recent labeled data
            recent_accuracy = evaluate_delayed_labels()
            baseline_accuracy = get_baseline_metric('accuracy')
            if recent_accuracy < baseline_accuracy * 0.95:
                return True
        
        return False
    
    def retrain_and_evaluate(self):
        """
        Retrain on latest data. Evaluate against holdout set.
        Only deploy if significantly better.
        """
        # Fetch training data up to yesterday
        training_data = fetch_training_data(up_to_date=datetime.now() - timedelta(days=1))
        
        # Retrain
        new_model = train_model(training_data, model_type=self.model_name)
        
        # Evaluate against holdout
        holdout_metrics = evaluate(new_model, holdout_data)
        current_metrics = get_production_metrics()
        
        # Deploy only if improvement is significant (e.g., 1% better accuracy)
        if holdout_metrics['accuracy'] > current_metrics['accuracy'] * 1.01:
            deploy(new_model, version=datetime.now().isoformat())
            log_event('model_redeployed', metrics=holdout_metrics, reason='triggered')
            return True
        else:
            log_event('retraining_aborted', reason='no_improvement')
            return False

# Orchestration loop (runs hourly or daily)
orchestrator = RetrainingOrchestrator(
    model_name='conversion_model',
    schedule='weekly',
    trigger_thresholds={'psi': 0.25, 'accuracy_drop': 0.05}
)

if orchestrator.should_retrain(reason='drift_detected'):
    orchestrator.retrain_and_evaluate()

Key principle: don't deploy just because you retrained. The new model must demonstrate improvement over the current production model on held-out data before you push it live. This prevents well-intentioned retraining from making things worse.

Track retraining frequency and reasons. If you're retraining daily due to drift alerts, that's a signal your model architecture or feature pipeline needs investigation. If you're retraining monthly without triggers, you might be under-monitoring.

Common Mistakes

Only monitoring accuracy on old labels. Accuracy metrics from data labeled weeks ago don't capture current model performance. Always compare to the most recent labels available. Set up automatic evaluation pipelines that pull delayed labels daily.

Ignoring input drift because predictions still look good. Input distribution shift is a leading indicator. By the time accuracy drops measurably, you've already lost ground. Treat input drift alerts seriously even if recent predictions seem fine.

Retraining without evaluation gates. If every drift alert triggers an automatic deployment, you'll eventually deploy a worse model. Always compare new models to production baselines before deploying.

Monitoring aggregates only. Monitor cohorts too. Your overall accuracy might be stable while performance for a specific user segment (e.g., new users, mobile app users, European region) has degraded significantly.

Setting thresholds without baselines. "Alert if accuracy drops 5%" doesn't work if you don't know what your baseline was. Establish clear baselines from training and validation data, and refresh them quarterly.

Key Takeaways

  • Track input feature distributions daily using PSI or KS tests; establish baselines from training data and alert when significant drift occurs.
  • Monitor prediction distributions (positive rate, mean score, quantiles) separately from accuracy; shifts here often precede labeled accuracy drops.
  • Compare predictions to ground truth labels as soon as labels arrive, even if delayed by days or weeks; this is your ground truth check.
  • Implement both scheduled retraining (weekly or monthly) and trigger-based retraining (on drift or accuracy alerts); always evaluate new models before deploying.
  • Set specific, quantified thresholds for alerts (e.g., PSI > 0.25, accuracy drop > 5%); make these thresholds team conventions, not guesses.
  • Monitor by cohort and segment, not just globally; model decay often appears in specific user groups first.
  • Track retraining frequency and reasons; frequent unplanned retraining indicates systemic issues worth investigating.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles