MLOps·Model Lifecycle Management·Advanced

Model Drift Detection

Detect both feature distribution drift and model prediction drift in production ML systems using formal statistical hypothesis tests including Kullback-Leibler divergence, Kolmogorov-Smirnov two-sample test, and Population Stability Index to trigger automated model retraining workflows before performance degrades.

machine-learningml-engineeringmlops

Detect Model/Data Drift in Production

This notebook explores methods and best practices for detecting model and data drift in machine learning systems deployed in production. Data drift refers to changes in the distribution of input data over time, which can lead to a degradation of model performance. Model drift, also known as concept drift, refers to changes in the relationship between input features and target variables, or changes in the target variable itself. Both types of drift are critical to monitor for maintaining the reliability and accuracy of ML models.

Key Concepts

ConceptDescriptionImportance
Data DriftChanges in the statistical properties of the input features over time.Directly impacts model performance as the model is trained on a different data distribution.
Model DriftChanges in the relationship between input features and target, or in the target distribution itself.Can lead to outdated or ineffective models even if input data remains stable.
Reference DataThe dataset used to train the model, representing the expected data distribution.Baseline for comparison to detect deviations in production data.
Production DataLive data streams that the model processes for inference.The data being monitored for drift against the reference.
Statistical TestsMethods like KS-test, Chi-squared, Wasserstein distance to quantify distributional differences.Provide quantitative measures to determine if observed differences are statistically significant.
Monitoring WindowsDefining periods (e.g., daily, weekly) over which production data is aggregated for drift analysis.Enables systematic and periodic checking for drift without overwhelming resources.
Alerting SystemsMechanisms to notify stakeholders when drift is detected beyond predefined thresholds.Crucial for timely intervention and mitigation of performance degradation.

Dependency Installation

All necessary libraries are installed in a single block using pip.

[8]
# Install necessary libraries
!pip install -qq numpy pandas scipy scikit-learn matplotlib seaborn evidently
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 11.7/11.7 MB 33.0 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 238.0/238.0 kB 9.5 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 581.1/581.1 kB 14.6 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 59.1/59.1 kB 2.9 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 225.0/225.0 kB 11.1 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 62.7/62.7 kB 2.9 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 72.2/72.2 kB 3.5 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 2.0/2.0 MB 33.0 MB/s eta 0:00:00
[?25h

Library Imports

This section imports all required libraries. Standard Python libraries are imported first, followed by third-party libraries.

[9]
# Standard Library Imports
import logging
import random
import time
from collections import deque

# Third-party Imports
import numpy as np
import pandas as pd
import scipy.stats as stats
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, f1_score
import matplotlib.pyplot as plt
import seaborn as sns

# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

Core Functions

This section defines the core functions for simulating data, detecting drift, and managing the application state. Each function is presented in its own dedicated code block with a markdown header, docstrings, type hints, and logging statements.

Function Name: create_initial_state

This function initializes the application state as a dictionary. It sets up parameters for data simulation, drift detection thresholds, and placeholders for reference data and model.

Parameters: None

Returns: state (dict): An initialized state dictionary.

[10]
def create_initial_state() -> dict:
    """
    Initializes the application state dictionary with default parameters.

    Returns
    -------
    dict
        An initialized state dictionary containing simulation parameters,
        drift thresholds, and placeholders for data and model.
    """
    logger.info("Initializing application state.")
    state = {
        'n_samples': 1000, # Number of samples for initial data
        'n_features': 5, # Number of features
        'n_categorical': 2, # Number of categorical features
        'random_state': 42,
        'drift_magnitude': 0.1, # How much to shift for data drift simulation
        'model_performance_threshold': 0.85, # F1-score threshold for model drift
        'ks_alpha': 0.05, # Significance level for KS-test
        'chi2_alpha': 0.05, # Significance level for Chi-squared test
        'reference_data': None,
        'production_data_window': deque(maxlen=500), # Rolling window for production data
        'trained_model': None,
        'feature_names': [f'feature_{i}' for i in range(5)],
        'categorical_feature_names': [f'feature_{i}' for i in range(3, 5)],
        'numerical_feature_names': [f'feature_{i}' for i in range(3)],
        'target_name': 'target'
    }
    logger.debug(f"Initial state created: {state}")
    return state

Function Name: simulate_data

This function generates synthetic tabular data suitable for demonstrating data and model drift. It can simulate numerical and categorical features, and a binary target variable. Drift can be introduced by shifting feature distributions or changing the underlying relationship with the target.

Parameters: state (dict): The current application state dictionary. n_samples (int): Number of samples to generate. drift_type (str): Type of drift to simulate. Options: 'none', 'data_drift_numerical', 'data_drift_categorical', 'model_drift'. drift_magnitude (float): The extent of drift to apply (e.g., shift standard deviation).

Returns: df (pd.DataFrame): A pandas DataFrame containing simulated features and target.

[11]
def simulate_data(state: dict, n_samples: int, drift_type: str = 'none', drift_magnitude: float = 0.0) -> pd.DataFrame:
    """
    Simulates synthetic tabular data with optional drift.

    Parameters
    ----------
    state : dict
        The current application state dictionary.
    n_samples : int
        The number of samples to generate.
    drift_type : str, optional
        Type of drift to simulate. Options: 'none', 'data_drift_numerical',
        'data_drift_categorical', 'model_drift'. Defaults to 'none'.
    drift_magnitude : float, optional
        The magnitude of drift to apply, e.g., standard deviation shift.
        Defaults to 0.0.

    Returns
    -------
    pd.DataFrame
        A pandas DataFrame containing simulated features and a target variable.
    """
    np.random.seed(state['random_state'] + int(time.time() * 1000) % 1000) # Jitter for reproducibility
    logger.info(f"Simulating data with {n_samples} samples and drift type: {drift_type}")

    data = {}
    # Simulate numerical features
    for i in range(state['n_features'] - state['n_categorical']):
        mean_val = 0.0
        std_val = 1.0
        if drift_type == 'data_drift_numerical':
            mean_val += drift_magnitude # Shift mean for drift
        data[f'feature_{i}'] = np.random.normal(loc=mean_val, scale=std_val, size=n_samples)

    # Simulate categorical features
    for i in range(state['n_features'] - state['n_categorical'], state['n_features']):
        categories = [0, 1, 2]
        probabilities = [0.4, 0.3, 0.3]
        if drift_type == 'data_drift_categorical':
            # Change probabilities for drift
            probabilities = [0.2, 0.5, 0.3] # Example shift

        data[f'feature_{i}'] = np.random.choice(categories, size=n_samples, p=probabilities)

    df = pd.DataFrame(data)

    # Simulate target variable (e.g., binary classification)
    # Target depends on features, creating a linear relationship with some noise
    target_weights = np.random.rand(state['n_features'])
    linear_combination = df.dot(target_weights)

    if drift_type == 'model_drift':
        # Introduce a change in how target depends on features
        # For example, invert the importance of one feature or add a new interaction
        target_weights_drift = np.copy(target_weights)
        target_weights_drift[0] *= -1 # Invert weight of first feature
        linear_combination = df.dot(target_weights_drift) # Use new weights

    probability = 1 / (1 + np.exp(-(linear_combination + np.random.normal(0, 1, n_samples)))) # Sigmoid function
    df[state['target_name']] = (probability > 0.5).astype(int)

    logger.debug(f"Simulated data head:\n{df.head()}")
    return df

Function Name: train_model

This function trains a simple machine learning model (RandomForestClassifier) on the provided dataset. It stores the trained model in the application state.

Parameters: state (dict): The current application state dictionary. df (pd.DataFrame): The DataFrame containing features and target for training.

Returns: state (dict): The updated state dictionary with the trained model.

[12]
def train_model(state: dict, df: pd.DataFrame) -> dict:
    """
    Trains a RandomForestClassifier model and stores it in the state.

    Parameters
    ----------
    state : dict
        The current application state dictionary.
    df : pd.DataFrame
        The DataFrame containing features and target for training.

    Returns
    -------
    dict
        The updated state dictionary with the 'trained_model' key populated.
    """
    logger.info("Training a RandomForestClassifier model.")
    X = df[state['feature_names']]
    y = df[state['target_name']]

    # Convert categorical features to 'category' dtype for scikit-learn compatibility if needed
    # For RandomForest, one-hot encoding is often preferred for categorical features if they are not ordinal.
    # For simplicity, assuming numerical representation is sufficient for this simulation.
    # If proper handling is needed, use pd.get_dummies or sklearn.preprocessing.OneHotEncoder

    model = RandomForestClassifier(random_state=state['random_state'])
    model.fit(X, y)
    state['trained_model'] = model
    logger.info("Model training complete.")
    return state

Function Name: detect_drift_ks_test

This function applies the Kolmogorov-Smirnov (KS) test to detect statistical differences between the distributions of numerical features in a reference dataset and a production dataset. It's suitable for continuous data.

Parameters: state (dict): The current application state dictionary. reference_data (pd.DataFrame): The DataFrame representing the reference data distribution. production_data (pd.DataFrame): The DataFrame representing the current production data distribution.

Returns: drift_results (dict): A dictionary indicating for each numerical feature if drift was detected (True/False) and the p-value.

[13]
def detect_drift_ks_test(state: dict, reference_data: pd.DataFrame, production_data: pd.DataFrame) -> dict:
    """
    Detects data drift in numerical features using the Kolmogorov-Smirnov (KS) test.

    Parameters
    ----------
    state : dict
        The current application state dictionary containing 'numerical_feature_names' and 'ks_alpha'.
    reference_data : pd.DataFrame
        The DataFrame of the reference dataset.
    production_data : pd.DataFrame
        The DataFrame of the current production dataset.

    Returns
    -------
    dict
        A dictionary where keys are numerical feature names and values are dictionaries
        containing 'drift_detected' (bool) and 'p_value' (float).
    """
    logger.info("Performing KS-test for numerical feature drift detection.")
    drift_results = {}
    for feature in state['numerical_feature_names']:
        if feature not in reference_data.columns or feature not in production_data.columns:
            logger.warning(f"Feature {feature} not found in one of the datasets. Skipping KS-test.")
            continue

        # Add a small random jitter to avoid identical values leading to p-value = 0 issues
        ref_values = reference_data[feature].values + np.random.normal(0, 1e-6, len(reference_data))
        prod_values = production_data[feature].values + np.random.normal(0, 1e-6, len(production_data))

        try:
            stat, p_value = stats.ks_2samp(ref_values, prod_values)
            drift_detected = p_value < state['ks_alpha']
            drift_results[feature] = {'drift_detected': drift_detected, 'p_value': p_value}
            logger.debug(f"KS-test for {feature}: p-value={p_value:.4f}, Drift detected: {drift_detected}")
        except ValueError as e:
            logger.error(f"Error during KS-test for feature {feature}: {e}")
            drift_results[feature] = {'drift_detected': False, 'p_value': np.nan}

    logger.info("KS-test drift detection complete.")
    return drift_results

Function Name: detect_drift_chi2_test

This function utilizes the Chi-squared test to assess if there are significant differences in the distribution of categorical features between a reference dataset and a production dataset. It's appropriate for discrete data.

Parameters: state (dict): The current application state dictionary. reference_data (pd.DataFrame): The DataFrame representing the reference data distribution. production_data (pd.DataFrame): The DataFrame representing the current production data distribution.

Returns: drift_results (dict): A dictionary indicating for each categorical feature if drift was detected (True/False) and the p-value.

[14]
def detect_drift_chi2_test(state: dict, reference_data: pd.DataFrame, production_data: pd.DataFrame) -> dict:
    """
    Detects data drift in categorical features using the Chi-squared test.

    Parameters
    ----------
    state : dict
        The current application state dictionary containing 'categorical_feature_names' and 'chi2_alpha'.
    reference_data : pd.DataFrame
        The DataFrame of the reference dataset.
    production_data : pd.DataFrame
        The DataFrame of the current production dataset.

    Returns
    -------
    dict
        A dictionary where keys are categorical feature names and values are dictionaries
        containing 'drift_detected' (bool) and 'p_value' (float).
    """
    logger.info("Performing Chi-squared test for categorical feature drift detection.")
    drift_results = {}
    for feature in state['categorical_feature_names']:
        if feature not in reference_data.columns or feature not in production_data.columns:
            logger.warning(f"Feature {feature} not found in one of the datasets. Skipping Chi-squared test.")
            continue

        # Create contingency table
        ref_counts = reference_data[feature].value_counts().sort_index()
        prod_counts = production_data[feature].value_counts().sort_index()

        # Ensure all categories are present in both, fill with 0 if not
        all_categories = sorted(list(set(ref_counts.index).union(set(prod_counts.index))))

        contingency_table = pd.DataFrame({
            'reference': ref_counts.reindex(all_categories, fill_value=0),
            'production': prod_counts.reindex(all_categories, fill_value=0)
        })

        # Chi-squared test requires at least one non-zero value per row/column
        if contingency_table.sum().sum() == 0 or (contingency_table == 0).all().any():
            logger.warning(f"Contingency table for {feature} has all zeros or empty categories after alignment. Skipping Chi-squared test.")
            drift_results[feature] = {'drift_detected': False, 'p_value': np.nan}
            continue

        try:
            stat, p_value, _, _ = stats.chi2_contingency(contingency_table)
            drift_detected = p_value < state['chi2_alpha']
            drift_results[feature] = {'drift_detected': drift_detected, 'p_value': p_value}
            logger.debug(f"Chi-squared test for {feature}: p-value={p_value:.4f}, Drift detected: {drift_detected}")
        except ValueError as e:
            logger.error(f"Error during Chi-squared test for feature {feature}: {e}")
            drift_results[feature] = {'drift_detected': False, 'p_value': np.nan}

    logger.info("Chi-squared test drift detection complete.")
    return drift_results

Function Name: calculate_model_performance

This function evaluates the performance of a trained model on a given dataset using F1-score and accuracy. It's used to monitor for model drift, which manifests as a drop in performance over time on new data.

Parameters: state (dict): The current application state dictionary. data (pd.DataFrame): The DataFrame on which to evaluate the model.

Returns: performance_metrics (dict): A dictionary containing 'accuracy' and 'f1_score'.

[15]
def calculate_model_performance(state: dict, data: pd.DataFrame) -> dict:
    """
    Calculates the F1-score and accuracy of the trained model on provided data.

    Parameters
    ----------
    state : dict
        The current application state dictionary containing 'trained_model', 'feature_names', and 'target_name'.
    data : pd.DataFrame
        The DataFrame on which to evaluate the model.

    Returns
    -------
    dict
        A dictionary containing 'accuracy' and 'f1_score'. Returns default values
        if the model is not trained.
    """
    logger.info("Calculating model performance.")
    if state['trained_model'] is None:
        logger.warning("No trained model found in state. Skipping performance calculation.")
        return {'accuracy': np.nan, 'f1_score': np.nan}

    X = data[state['feature_names']]
    y_true = data[state['target_name']]

    y_pred = state['trained_model'].predict(X)

    accuracy = accuracy_score(y_true, y_pred)
    f1 = f1_score(y_true, y_pred)

    performance_metrics = {'accuracy': accuracy, 'f1_score': f1}
    logger.debug(f"Model performance: {performance_metrics}")
    return performance_metrics

Function Name: monitor_production_stream

This function simulates monitoring a live production data stream. It continually appends new data to a rolling window and periodically triggers drift detection tests and model performance checks. It incorporates random jitter for more realistic simulation of stream processing.

Parameters: state (dict): The current application state dictionary. num_batches (int): The number of simulated data batches to process. batch_size (int): The number of samples in each batch. drift_frequency (int): How many batches before drift is introduced (e.g., 5 means drift after 5 batches).

Returns: state (dict): The updated state dictionary after monitoring, including collected metrics.

[16]
def monitor_production_stream(state: dict, num_batches: int = 20, batch_size: int = 100, drift_frequency: int = 5) -> dict:
    """
    Simulates monitoring a production data stream and performing drift detection.

    Parameters
    ----------
    state : dict
        The current application state dictionary.
    num_batches : int, optional
        The number of simulated data batches to process. Defaults to 20.
    batch_size : int, optional
        The number of samples in each batch. Defaults to 100.
    drift_frequency : int, optional
        How many batches before introducing drift. Defaults to 5.

    Returns
    -------
    dict
        The updated state dictionary, potentially with collected drift metrics and performance data.
    """
    logger.info(f"Starting production stream monitoring for {num_batches} batches.")
    state['drift_detection_history'] = []
    state['model_performance_history'] = []

    for i in range(num_batches):
        # Add random jitter to simulate varying processing times
        time.sleep(0.1 + random.uniform(0, 0.2))

        current_drift_type = 'none'
        current_drift_magnitude = 0.0

        if i == drift_frequency: # Introduce data drift after some batches
            current_drift_type = 'data_drift_numerical'
            current_drift_magnitude = state['drift_magnitude']
            logger.warning(f"Introducing {current_drift_type} at batch {i}.")
        elif i == drift_frequency * 2: # Introduce model drift later
            current_drift_type = 'model_drift'
            current_drift_magnitude = state['drift_magnitude'] * 0.5 # Model drift might be more subtle
            logger.warning(f"Introducing {current_drift_type} at batch {i}.")

        new_production_data = simulate_data(state, batch_size, current_drift_type, current_drift_magnitude)
        state['production_data_window'].extend([new_production_data])

        # Concatenate data in the deque for analysis
        current_production_df = pd.concat(list(state['production_data_window']))

        logger.info(f"Processing batch {i+1}/{num_batches}. Current window size: {len(current_production_df)} samples.")

        # Perform drift detection if enough data in window
        if len(current_production_df) >= state['n_samples']:
            ks_results = detect_drift_ks_test(state, state['reference_data'], current_production_df)
            chi2_results = detect_drift_chi2_test(state, state['reference_data'], current_production_df)
            state['drift_detection_history'].append({
                'batch': i,
                'ks_test': ks_results,
                'chi2_test': chi2_results,
                'drift_type_simulated': current_drift_type
            })

            # Calculate model performance
            model_perf = calculate_model_performance(state, current_production_df)
            state['model_performance_history'].append({'batch': i, **model_perf})

            if model_perf['f1_score'] < state['model_performance_threshold']:
                logger.critical(f"Model performance dropped below threshold at batch {i}: F1-score = {model_perf['f1_score']:.2f}")

    logger.info("Production stream monitoring finished.")
    return state

Demonstration/Visualization

This section demonstrates the usage of the core functions by simulating a complete drift detection pipeline. It includes data simulation, model training, monitoring, and various visualizations to illustrate detected drift and model performance changes.

[17]
# Initialize the state
app_state = create_initial_state()

# Simulate reference data and train the model
reference_df = simulate_data(app_state, app_state['n_samples'], drift_type='none')
app_state['reference_data'] = reference_df
app_state = train_model(app_state, reference_df)

logger.info("Initial setup complete. Reference data simulated and model trained.")

# Display reference data head
print("\n--- Reference Data Head ---")
display(app_state['reference_data'].head())

--- Reference Data Head ---
feature_0 feature_1 feature_2 feature_3 feature_4 target
0 -1.353554 0.482399 -0.992212 0 2 1
1 -0.018058 -0.334149 0.212526 2 1 1
2 1.453020 -1.698208 0.200732 0 0 0
3 1.186372 0.011980 0.766949 0 1 1
4 -0.518893 1.524137 -0.701899 0 1 1
[18]
# Simulate monitoring a production stream with potential drift
app_state = monitor_production_stream(app_state, num_batches=20, batch_size=100, drift_frequency=7)

logger.info("Monitoring simulation complete. Analyzing results.")
WARNING:__main__:Introducing data_drift_numerical at batch 7.
CRITICAL:__main__:Model performance dropped below threshold at batch 9: F1-score = 0.85
CRITICAL:__main__:Model performance dropped below threshold at batch 11: F1-score = 0.84
CRITICAL:__main__:Model performance dropped below threshold at batch 12: F1-score = 0.84
CRITICAL:__main__:Model performance dropped below threshold at batch 13: F1-score = 0.85
WARNING:__main__:Introducing model_drift at batch 14.
CRITICAL:__main__:Model performance dropped below threshold at batch 14: F1-score = 0.84
CRITICAL:__main__:Model performance dropped below threshold at batch 15: F1-score = 0.84
CRITICAL:__main__:Model performance dropped below threshold at batch 16: F1-score = 0.84
CRITICAL:__main__:Model performance dropped below threshold at batch 17: F1-score = 0.84
CRITICAL:__main__:Model performance dropped below threshold at batch 18: F1-score = 0.84
CRITICAL:__main__:Model performance dropped below threshold at batch 19: F1-score = 0.83

Visualization: Numerical Feature Distribution (Pre-Drift vs. Post-Drift)

This plot compares the distribution of a numerical feature from the reference data against a production data batch where numerical data drift was introduced. A clear shift in the distribution indicates drift.

[19]
plt.figure(figsize=(12, 6))

# Identify a numerical feature to plot
num_feature = app_state['numerical_feature_names'][0]

sns.histplot(app_state['reference_data'][num_feature], color='blue', label='Reference Data', kde=True, stat='density', alpha=0.5)

# Find a production batch where numerical drift was introduced
drift_batch_index = next((i for i, d in enumerate(app_state['drift_detection_history']) if d['drift_type_simulated'] == 'data_drift_numerical'), -1)

if drift_batch_index != -1:
    # Reconstruct the production data at that batch
    prod_data_at_drift = pd.concat(list(app_state['production_data_window'])[max(0, drift_batch_index - 5):drift_batch_index + 1]) # Take a few batches around it
    sns.histplot(prod_data_at_drift[num_feature], color='red', label='Production Data (with drift)', kde=True, stat='density', alpha=0.5)
    plt.title(f'Distribution of {num_feature} (Reference vs. Production with Numerical Drift)')
else:
    # If no drift was simulated, just show the latest production data
    sns.histplot(pd.concat(list(app_state['production_data_window']))[num_feature], color='green', label='Latest Production Data (No Drift Simulated)', kde=True, stat='density', alpha=0.5)
    plt.title(f'Distribution of {num_feature} (Reference vs. Latest Production)')

plt.xlabel(num_feature)
plt.ylabel('Density')
plt.legend()
plt.grid(True, linestyle='--', alpha=0.7)
plt.show()
cell output

Visualization: Categorical Feature Distribution (Pre-Drift vs. Post-Drift)

This bar plot illustrates changes in the distribution of a categorical feature between the reference dataset and a production dataset where categorical drift was simulated. Different bar heights for the same category indicate drift.

[20]
plt.figure(figsize=(12, 6))

# Identify a categorical feature to plot
cat_feature = app_state['categorical_feature_names'][0]

# Calculate counts for reference data
ref_counts = app_state['reference_data'][cat_feature].value_counts(normalize=True).sort_index()
ref_df = ref_counts.reset_index()
ref_df.columns = [cat_feature, 'proportion']
ref_df['dataset'] = 'Reference Data'

# Find a production batch where categorical drift was introduced
drift_batch_index_cat = next((i for i, d in enumerate(app_state['drift_detection_history']) if d['drift_type_simulated'] == 'data_drift_categorical'), -1)

if drift_batch_index_cat != -1:
    # Reconstruct the production data at that batch
    prod_data_at_drift_cat = pd.concat(list(app_state['production_data_window'])[max(0, drift_batch_index_cat - 5):drift_batch_index_cat + 1])
    prod_counts = prod_data_at_drift_cat[cat_feature].value_counts(normalize=True).sort_index()
    prod_df = prod_counts.reset_index()
    prod_df.columns = [cat_feature, 'proportion']
    prod_df['dataset'] = 'Production Data (with drift)'
    plot_df = pd.concat([ref_df, prod_df])
    title_str = f'Distribution of {cat_feature} (Reference vs. Production with Categorical Drift)'
else:
    # If no drift was simulated, just show the latest production data
    latest_prod_data = pd.concat(list(app_state['production_data_window']))
    prod_counts = latest_prod_data[cat_feature].value_counts(normalize=True).sort_index()
    prod_df = prod_counts.reset_index()
    prod_df.columns = [cat_feature, 'proportion']
    prod_df['dataset'] = 'Latest Production Data (No Drift Simulated)'
    plot_df = pd.concat([ref_df, prod_df])
    title_str = f'Distribution of {cat_feature} (Reference vs. Latest Production)'

sns.barplot(x=cat_feature, y='proportion', hue='dataset', data=plot_df, palette='viridis')
plt.title(title_str)
plt.xlabel(cat_feature)
plt.ylabel('Proportion')
plt.legend(title='Dataset')
plt.grid(axis='y', linestyle='--', alpha=0.7)
plt.show()
cell output

Visualization: Drift Detection P-values Over Time

This plot displays the p-values from the KS-test and Chi-squared test for selected features across different batches of production data. A horizontal line indicates the significance threshold (alpha). P-values falling below this line signal detected drift.

[21]
plt.figure(figsize=(15, 7))

# Prepare data for plotting p-values
ks_p_values = []
chi2_p_values = []
batch_numbers = []

for entry in app_state['drift_detection_history']:
    batch_numbers.append(entry['batch'])

    # Collect KS test p-values for the first numerical feature
    ks_p = entry['ks_test'].get(app_state['numerical_feature_names'][0], {}).get('p_value', np.nan)
    ks_p_values.append(ks_p)

    # Collect Chi2 test p-values for the first categorical feature
    chi2_p = entry['chi2_test'].get(app_state['categorical_feature_names'][0], {}).get('p_value', np.nan)
    chi2_p_values.append(chi2_p)

plt.subplot(2, 1, 1)
sns.lineplot(x=batch_numbers, y=ks_p_values, marker='o', label=f'KS Test P-value ({app_state['numerical_feature_names'][0]})', color='blue')
plt.axhline(y=app_state['ks_alpha'], color='red', linestyle='--', label=f'Significance Level (alpha={app_state['ks_alpha']})')
plt.title('Numerical Feature Drift: KS-Test P-values Over Batches')
plt.xlabel('Batch Number')
plt.ylabel('P-value')
plt.ylim(0, 1.05)
plt.legend()
plt.grid(True, linestyle='--', alpha=0.7)

plt.subplot(2, 1, 2)
sns.lineplot(x=batch_numbers, y=chi2_p_values, marker='o', label=f'Chi-squared Test P-value ({app_state['categorical_feature_names'][0]})', color='green')
plt.axhline(y=app_state['chi2_alpha'], color='red', linestyle='--', label=f'Significance Level (alpha={app_state['chi2_alpha']})')
plt.title('Categorical Feature Drift: Chi-squared Test P-values Over Batches')
plt.xlabel('Batch Number')
plt.ylabel('P-value')
plt.ylim(0, 1.05)
plt.legend()
plt.grid(True, linestyle='--', alpha=0.7)

plt.tight_layout()
plt.show()
cell output

Visualization: Model Performance Over Time

This line plot tracks the F1-score and Accuracy of the deployed model on successive batches of production data. A significant drop in these metrics indicates potential model drift, prompting further investigation.

[22]
plt.figure(figsize=(15, 7))

# Prepare data for plotting model performance
model_accuracy = []
model_f1_score = []
perf_batch_numbers = []

for entry in app_state['model_performance_history']:
    perf_batch_numbers.append(entry['batch'])
    model_accuracy.append(entry['accuracy'])
    model_f1_score.append(entry['f1_score'])

plt.subplot(2, 1, 1)
sns.lineplot(x=perf_batch_numbers, y=model_accuracy, marker='o', label='Accuracy', color='purple')
plt.axhline(y=app_state['model_performance_threshold'], color='orange', linestyle='--', label=f'F1-score Threshold ({app_state['model_performance_threshold']})')
plt.title('Model Accuracy Over Batches')
plt.xlabel('Batch Number')
plt.ylabel('Accuracy')
plt.ylim(0, 1.05)
plt.legend()
plt.grid(True, linestyle='--', alpha=0.7)

plt.subplot(2, 1, 2)
sns.lineplot(x=perf_batch_numbers, y=model_f1_score, marker='o', label='F1-score', color='brown')
plt.axhline(y=app_state['model_performance_threshold'], color='orange', linestyle='--', label=f'F1-score Threshold ({app_state['model_performance_threshold']})')
plt.title('Model F1-score Over Batches')
plt.xlabel('Batch Number')
plt.ylabel('F1-score')
plt.ylim(0, 1.05)
plt.legend()
plt.grid(True, linestyle='--', alpha=0.7)

plt.tight_layout()
plt.show()
cell output

Production Considerations

Deploying and maintaining ML models in production requires robust strategies for monitoring and managing drift. Here are some best practices:

AspectBest PracticesTools/Techniques
Granularity of MonitoringMonitor drift at different levels: individual features, feature groups, and overall model performance. Define appropriate monitoring windows (e.g., daily, weekly, hourly) based on data volatility and business needs.Rolling windows, time-series analysis, statistical process control charts.
Thresholds & AlertingSet clear, interpretable thresholds for drift detection based on domain knowledge and historical data. Implement automated alerting (e.g., email, Slack, PagerDuty) when thresholds are breached.Statistical significance levels (p-values), fixed performance drops, anomaly detection algorithms.
Model Retraining StrategyDevelop a clear strategy for model retraining. This could be scheduled retraining (e.g., weekly), triggered retraining (on drift detection), or continuous learning. Ensure retraining is robust and tested.CI/CD for ML (MLOps), automated pipelines, version control for models.
ExplainabilityWhen drift is detected, use explainability techniques (e.g., SHAP, LIME) to understand which features are driving the drift or performance degradation. This helps in diagnosing the root cause.SHAP, LIME, Feature Importance (from tree-based models), Partial Dependence Plots.
Data VersioningMaintain strict version control for both training data and model artifacts. This ensures reproducibility and allows for easy rollback if new models perform poorly.DVC, MLflow, Great Expectations, Apache Parquet.
A/B Testing & Canary DeploymentsFor significant model updates or retraining, use A/B testing or canary deployments to safely test new models in a production environment with a small subset of users before full rollout.Experimentation platforms, phased rollouts.
Robust Data PipelinesEnsure data ingestion and preprocessing pipelines are robust, fault-tolerant, and perform data validation. Inconsistent data quality can mimic drift.Data validation checks, schema enforcement, data quality monitoring tools.
Backtesting & SimulationRegularly backtest drift detection mechanisms and retraining strategies on historical data to ensure they would have caught past drift events and successfully mitigated them. Simulate various drift scenarios.Historical data analysis, synthetic drift injection, simulation environments.
Documentation & RunbooksMaintain comprehensive documentation for drift detection methodologies, thresholds, alerting procedures, and runbooks for incident response when drift is detected.Wiki, Confluence, operational runbooks.

Conclusion

This notebook provided a practical demonstration of detecting model and data drift in a simulated production environment. We explored various statistical tests for numerical (KS-test) and categorical (Chi-squared test) feature drift, and monitored model performance (F1-score, accuracy) as an indicator of model drift. The visualizations clearly showed how these metrics change when drift is introduced.

Effective drift detection is a continuous process that is crucial for maintaining the reliability, fairness, and performance of machine learning models in dynamic real-world applications. By integrating these monitoring strategies into MLOps pipelines, organizations can proactively address issues, ensure models remain relevant, and ultimately deliver sustained business value.