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.
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
| Concept | Description | Importance |
|---|---|---|
| Data Drift | Changes 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 Drift | Changes 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 Data | The dataset used to train the model, representing the expected data distribution. | Baseline for comparison to detect deviations in production data. |
| Production Data | Live data streams that the model processes for inference. | The data being monitored for drift against the reference. |
| Statistical Tests | Methods like KS-test, Chi-squared, Wasserstein distance to quantify distributional differences. | Provide quantitative measures to determine if observed differences are statistically significant. |
| Monitoring Windows | Defining 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 Systems | Mechanisms 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.
# Install necessary libraries
!pip install -qq numpy pandas scipy scikit-learn matplotlib seaborn evidently[2K [90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[0m [32m11.7/11.7 MB[0m [31m33.0 MB/s[0m eta [36m0:00:00[0m [2K [90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[0m [32m238.0/238.0 kB[0m [31m9.5 MB/s[0m eta [36m0:00:00[0m [2K [90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[0m [32m581.1/581.1 kB[0m [31m14.6 MB/s[0m eta [36m0:00:00[0m [2K [90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[0m [32m59.1/59.1 kB[0m [31m2.9 MB/s[0m eta [36m0:00:00[0m [2K [90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[0m [32m225.0/225.0 kB[0m [31m11.1 MB/s[0m eta [36m0:00:00[0m [2K [90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[0m [32m62.7/62.7 kB[0m [31m2.9 MB/s[0m eta [36m0:00:00[0m [2K [90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[0m [32m72.2/72.2 kB[0m [31m3.5 MB/s[0m eta [36m0:00:00[0m [2K [90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[0m [32m2.0/2.0 MB[0m [31m33.0 MB/s[0m eta [36m0:00:00[0m [?25h
Library Imports
This section imports all required libraries. Standard Python libraries are imported first, followed by third-party libraries.
# 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.
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 stateFunction 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.
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 dfFunction 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.
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 stateFunction 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.
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_resultsFunction 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.
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_resultsFunction 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'.
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_metricsFunction 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.
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 stateDemonstration/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.
# 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 |
# 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.
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()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.
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()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.
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()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.
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()Production Considerations
Deploying and maintaining ML models in production requires robust strategies for monitoring and managing drift. Here are some best practices:
| Aspect | Best Practices | Tools/Techniques |
|---|---|---|
| Granularity of Monitoring | Monitor 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 & Alerting | Set 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 Strategy | Develop 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. |
| Explainability | When 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 Versioning | Maintain 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 Deployments | For 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 Pipelines | Ensure 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 & Simulation | Regularly 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 & Runbooks | Maintain 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.