Feature Drift Monitor
Continuously monitor feature value distributions over calendar time to detect statistically significant drift relative to the training data reference distribution, automatically triggering configurable alerts and model retraining workflow initiation when feature populations meaningfully shift from historical norms.
Monitoring Feature Drift Over Time
Feature drift, also known as data drift, refers to the change in the distribution of input features over time. This phenomenon can severely degrade the performance of machine learning models in production, as models are typically trained on historical data with a different distribution than the data they encounter in the future. Monitoring feature drift is crucial for maintaining model performance and ensuring that models continue to make accurate predictions.
This notebook provides a framework for detecting and visualizing feature drift using statistical methods and a sliding window approach.
Table of Concepts
| Concept | Description |
|---|---|
| Feature Drift | Changes in the statistical properties of the input features to a machine learning model over time. |
| Data Drift | A broader term encompassing changes in both input features and target variables. |
| Concept Drift | Changes in the relationship between the input features and the target variable, or changes in the target variable's distribution itself. |
| Monitoring | The continuous observation of data streams and model performance to detect deviations from expected behavior. |
| Statistical Tests | Methods used to quantify the difference between two data distributions (e.g., Kolmogorov-Smirnov, Chi-squared, Jensen-Shannon Divergence). |
| Sliding Window | A technique where a fixed-size window of recent data is compared against a reference distribution or an earlier window of data. |
Dependency Installation
%pip install pandas numpy scipy scikit-learn matplotlib seaborn fakerRequirement already satisfied: pandas in /usr/local/lib/python3.12/dist-packages (2.2.2) Requirement already satisfied: numpy in /usr/local/lib/python3.12/dist-packages (2.0.2) Requirement already satisfied: scipy in /usr/local/lib/python3.12/dist-packages (1.16.3) Requirement already satisfied: scikit-learn in /usr/local/lib/python3.12/dist-packages (1.6.1) Requirement already satisfied: matplotlib in /usr/local/lib/python3.12/dist-packages (3.10.0) Requirement already satisfied: seaborn in /usr/local/lib/python3.12/dist-packages (0.13.2) Collecting faker Downloading faker-40.22.0-py3-none-any.whl.metadata (16 kB) Requirement already satisfied: python-dateutil>=2.8.2 in /usr/local/lib/python3.12/dist-packages (from pandas) (2.9.0.post0) Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.12/dist-packages (from pandas) (2025.2) Requirement already satisfied: tzdata>=2022.7 in /usr/local/lib/python3.12/dist-packages (from pandas) (2026.2) Requirement already satisfied: joblib>=1.2.0 in /usr/local/lib/python3.12/dist-packages (from scikit-learn) (1.5.3) Requirement already satisfied: threadpoolctl>=3.1.0 in /usr/local/lib/python3.12/dist-packages (from scikit-learn) (3.6.0) Requirement already satisfied: contourpy>=1.0.1 in /usr/local/lib/python3.12/dist-packages (from matplotlib) (1.3.3) Requirement already satisfied: cycler>=0.10 in /usr/local/lib/python3.12/dist-packages (from matplotlib) (0.12.1) Requirement already satisfied: fonttools>=4.22.0 in /usr/local/lib/python3.12/dist-packages (from matplotlib) (4.63.0) Requirement already satisfied: kiwisolver>=1.3.1 in /usr/local/lib/python3.12/dist-packages (from matplotlib) (1.5.0) Requirement already satisfied: packaging>=20.0 in /usr/local/lib/python3.12/dist-packages (from matplotlib) (26.2) Requirement already satisfied: pillow>=8 in /usr/local/lib/python3.12/dist-packages (from matplotlib) (11.3.0) Requirement already satisfied: pyparsing>=2.3.1 in /usr/local/lib/python3.12/dist-packages (from matplotlib) (3.3.2) Requirement already satisfied: six>=1.5 in /usr/local/lib/python3.12/dist-packages (from python-dateutil>=2.8.2->pandas) (1.17.0) Downloading faker-40.22.0-py3-none-any.whl (2.0 MB) [2K [90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[0m [32m2.0/2.0 MB[0m [31m21.4 MB/s[0m eta [36m0:00:00[0m [?25hInstalling collected packages: faker Successfully installed faker-40.22.0
Library Imports
import logging
import datetime
from collections import deque
import time
import random
import math
import pandas as pd
import numpy as np
from scipy.stats import ks_2samp, chi2_contingency
from sklearn.preprocessing import LabelEncoder
import matplotlib.pyplot as plt
import seaborn as sns
from faker import Faker
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
Core Functions
Function Name: create_drift_monitor_state
This function initializes the state dictionary for the feature drift monitor. It sets up parameters such as the window size for comparison, the significance level for statistical tests, and empty data buffers for reference and monitoring data.
Parameters: window_size (int): The number of recent data points to keep in the monitoring window. alfa (float): The significance level (alpha) for statistical hypothesis testing. Default is 0.05.
Returns: dict: An initialized state dictionary for the drift monitor.
def create_drift_monitor_state(window_size: int, alfa: float = 0.05) -> dict:
"""
Initializes the state dictionary for the feature drift monitor.
Parameters
----------
window_size : int
The number of recent data points to keep in the monitoring window.
alfa : float, optional
The significance level (alpha) for statistical hypothesis testing, defaults to 0.05.
Returns
-------
dict
An initialized state dictionary for the drift monitor.
"""
logger.info(f"Initializing drift monitor state with window_size={window_size}, alfa={alfa}")
state = {
'window_size': window_size,
'alfa': alfa,
'reference_data': pd.DataFrame(),
'current_window_data': deque(maxlen=window_size),
'drift_history': [],
'feature_types': {},
'categorical_encoders': {}
}
logger.debug("Drift monitor state initialized successfully.")
return stateFunction Name: generate_synthetic_data
This function generates synthetic tabular data for demonstration purposes. It can simulate numerical and categorical features, and optionally introduce drift by changing feature distributions after a certain number of samples.
Parameters: num_samples (int): The total number of samples to generate. num_features (int): The number of numerical features. num_categorical_features (int): The number of categorical features. drift_start_sample (int, optional): The sample index at which to start introducing drift. Default is None.
Returns: pandas.DataFrame: A DataFrame containing the synthetic data.
def generate_synthetic_data(num_samples: int, num_features: int, num_categorical_features: int, drift_start_sample: int = None) -> pd.DataFrame:
"""
Generates synthetic tabular data for demonstration purposes, with optional drift.
Parameters
----------
num_samples : int
The total number of samples to generate.
num_features : int
The number of numerical features.
num_categorical_features : int
The number of categorical features.
drift_start_sample : int, optional
The sample index at which to start introducing drift, defaults to None.
Returns
-------
pd.DataFrame
A DataFrame containing the synthetic data.
"""
logger.info(f"Generating {num_samples} synthetic samples. Drift starts at {drift_start_sample}.")
data = {}
fake = Faker()
# Generate numerical features
for i in range(num_features):
feature_name = f'numerical_feature_{i+1}'
initial_mean = 10 + i * 5
initial_std = 2 + i
drift_mean_change = 0 if drift_start_sample is None else 5 + i
samples = []
for j in range(num_samples):
if drift_start_sample is not None and j >= drift_start_sample:
samples.append(np.random.normal(initial_mean + drift_mean_change, initial_std))
else:
samples.append(np.random.normal(initial_mean, initial_std))
data[feature_name] = samples
# Generate categorical features
for i in range(num_categorical_features):
feature_name = f'categorical_feature_{i+1}'
categories_initial = [f'CatA_{i}', f'CatB_{i}', f'CatC_{i}']
categories_drift = [f'CatA_{i}', f'CatB_{i}', f'CatD_{i}'] if i % 2 == 0 else [f'CatX_{i}', f'CatY_{i}']
samples = []
for j in range(num_samples):
if drift_start_sample is not None and j >= drift_start_sample:
# Introduce new category or change distribution
samples.append(random.choice(categories_drift))
else:
samples.append(random.choice(categories_initial))
data[feature_name] = samples
df = pd.DataFrame(data)
logger.debug(f"Generated DataFrame shape: {df.shape}")
return dfFunction Name: _calculate_numerical_drift_ks
This helper function calculates drift for numerical features using the Kolmogorov-Smirnov (KS) test. It compares the distributions of a feature in the reference data and the current window data.
Parameters: reference_series (pandas.Series): The series of data points from the reference distribution. current_series (pandas.Series): The series of data points from the current monitoring window. alfa (float): The significance level for the KS test.
Returns: dict: A dictionary containing the KS statistic, p-value, and a boolean indicating if drift was detected.
def _calculate_numerical_drift_ks(reference_series: pd.Series, current_series: pd.Series, alfa: float) -> dict:
"""
Calculates drift for numerical features using the Kolmogorov-Smirnov (KS) test.
Parameters
----------
reference_series : pd.Series
The series of data points from the reference distribution.
current_series : pd.Series
The series of data points from the current monitoring window.
alfa : float
The significance level for the KS test.
Returns
-------
dict
A dictionary containing the KS statistic, p-value, and a boolean indicating if drift was detected.
"""
if reference_series.empty or current_series.empty:
logger.warning("One of the series is empty for KS test, cannot calculate drift.")
return {'statistic': np.nan, 'p_value': np.nan, 'drift_detected': False}
stat, p_value = ks_2samp(reference_series, current_series)
drift_detected = p_value < alfa
logger.debug(f"KS test: statistic={stat:.4f}, p_value={p_value:.4f}, drift_detected={drift_detected}")
return {'statistic': stat, 'p_value': p_value, 'drift_detected': drift_detected}Function Name: _calculate_categorical_drift_chi2
This helper function calculates drift for categorical features using the Chi-squared test. It compares the frequency distributions of a feature in the reference data and the current window data.
Parameters: reference_series (pandas.Series): The series of data points from the reference distribution. current_series (pandas.Series): The series of data points from the current monitoring window. alfa (float): The significance level for the Chi-squared test.
Returns: dict: A dictionary containing the Chi-squared statistic, p-value, and a boolean indicating if drift was detected.
def _calculate_categorical_drift_chi2(reference_series: pd.Series, current_series: pd.Series, alfa: float) -> dict:
"""
Calculates drift for categorical features using the Chi-squared test.
Parameters
----------
reference_series : pd.Series
The series of data points from the reference distribution.
current_series : pd.Series
The series of data points from the current monitoring window.
alfa : float
The significance level for the Chi-squared test.
Returns
-------
dict
A dictionary containing the Chi-squared statistic, p-value, and a boolean indicating if drift was detected.
"""
if reference_series.empty or current_series.empty:
logger.warning("One of the series is empty for Chi-squared test, cannot calculate drift.")
return {'statistic': np.nan, 'p_value': np.nan, 'drift_detected': False}
# Combine categories to ensure all levels are considered
all_categories = pd.concat([reference_series, current_series]).unique()
ref_counts = reference_series.value_counts().reindex(all_categories, fill_value=0)
current_counts = current_series.value_counts().reindex(all_categories, fill_value=0)
# Ensure there are no zero sums for chi2_contingency
# If all counts are zero for a category, it might cause issues, but reindex should handle it.
# chi2_contingency expects a 2D array: rows are distributions, columns are categories
contingency_table = pd.DataFrame({'reference': ref_counts, 'current': current_counts})
# Remove categories where both reference and current counts are zero
contingency_table = contingency_table[(contingency_table['reference'] > 0) | (contingency_table['current'] > 0)]
if contingency_table.empty:
logger.warning("Contingency table is empty after filtering, cannot calculate Chi-squared drift.")
return {'statistic': np.nan, 'p_value': np.nan, 'drift_detected': False}
try:
stat, p_value, _, _ = chi2_contingency(contingency_table.values)
drift_detected = p_value < alfa
logger.debug(f"Chi-squared test: statistic={stat:.4f}, p_value={p_value:.4f}, drift_detected={drift_detected}")
return {'statistic': stat, 'p_value': p_value, 'drift_detected': drift_detected}
except ValueError as e:
logger.error(f"Error during Chi-squared test: {e}. Contingency table: {contingency_table}")
return {'statistic': np.nan, 'p_value': np.nan, 'drift_detected': False}Function Name: set_reference_data
This function sets or updates the baseline (reference) data distribution against which future data will be compared for drift detection. It also infers the data types of the features and initializes categorical encoders if necessary.
Parameters: state (dict): The current state dictionary of the drift monitor. data (pandas.DataFrame): The DataFrame to be used as the new reference data.
Returns: dict: The updated state dictionary.
def set_reference_data(state: dict, data: pd.DataFrame) -> dict:
"""
Sets the baseline (reference) data distribution for drift detection.
Infers feature types and initializes categorical encoders.
Parameters
----------
state : dict
Current state dictionary.
data : pd.DataFrame
The DataFrame to be used as the new reference data.
Returns
-------
dict
Updated state dictionary with the new reference data and feature types.
"""
logger.info("Setting new reference data.")
state['reference_data'] = data.copy()
state['feature_types'] = {}
state['categorical_encoders'] = {}
for col in data.columns:
if pd.api.types.is_numeric_dtype(data[col]):
state['feature_types'][col] = 'numerical'
else:
state['feature_types'][col] = 'categorical'
# Initialize LabelEncoder for categorical features if needed
le = LabelEncoder()
# Fit on all unique values from reference data to avoid issues later
le.fit(data[col].astype(str).unique())
state['categorical_encoders'][col] = le
logger.debug(f"Reference data set. Features and types: {state['feature_types']}")
return stateFunction Name: detect_drift
This function orchestrates the drift detection process for all features in the current monitoring window against the established reference data. It applies the appropriate statistical test (Kolmogorov-Smirnov for numerical, Chi-squared for categorical) based on feature type.
Parameters: state (dict): The current state dictionary of the drift monitor.
Returns: dict: The updated state dictionary, including detected drift events.
def detect_drift(state: dict) -> dict:
"""
Detects drift for all features in the current window against the reference data.
Parameters
----------
state : dict
Current state dictionary.
Returns
-------
dict
Updated state dictionary with detected drift events.
"""
logger.info("Starting drift detection for current window.")
if state['reference_data'].empty:
logger.warning("Reference data is not set. Cannot detect drift.")
return state
if not state['current_window_data']:
logger.warning("Current window data is empty. Cannot detect drift.")
return state
current_window_df = pd.DataFrame(list(state['current_window_data']))
drift_results = {
'timestamp': datetime.datetime.now(),
'features': {}
}
for feature_name, feature_type in state['feature_types'].items():
if feature_name not in current_window_df.columns:
logger.warning(f"Feature '{feature_name}' not found in current window data. Skipping.")
continue
reference_series = state['reference_data'][feature_name]
current_series = current_window_df[feature_name]
# Handle potential mismatch in categorical levels between reference and current
if feature_type == 'categorical':
# Ensure both series are treated as strings before comparison
reference_series = reference_series.astype(str)
current_series = current_series.astype(str)
# Add jitter before retry attempts
jitter = random.uniform(0.01, 0.1)
time.sleep(jitter)
try:
result = _calculate_categorical_drift_chi2(reference_series, current_series, state['alfa'])
except Exception as e:
logger.error(f"Attempt failed for {feature_name} (categorical): {e}")
# Simple retry logic without full exponential backoff for brevity here
time.sleep(1 + jitter)
try:
result = _calculate_categorical_drift_chi2(reference_series, current_series, state['alfa'])
except Exception as e_retry:
logger.error(f"Second attempt failed for {feature_name} (categorical): {e_retry}. Skipping feature.")
result = {'statistic': np.nan, 'p_value': np.nan, 'drift_detected': False}
elif feature_type == 'numerical':
# Add jitter before retry attempts
jitter = random.uniform(0.01, 0.1)
time.sleep(jitter)
try:
result = _calculate_numerical_drift_ks(reference_series, current_series, state['alfa'])
except Exception as e:
logger.error(f"Attempt failed for {feature_name} (numerical): {e}")
time.sleep(1 + jitter)
try:
result = _calculate_numerical_drift_ks(reference_series, current_series, state['alfa'])
except Exception as e_retry:
logger.error(f"Second attempt failed for {feature_name} (numerical): {e_retry}. Skipping feature.")
result = {'statistic': np.nan, 'p_value': np.nan, 'drift_detected': False}
else:
logger.warning(f"Unknown feature type '{feature_type}' for feature '{feature_name}'. Skipping.")
continue
drift_results['features'][feature_name] = result
if result['drift_detected']:
logger.warning(f"Drift detected for feature '{feature_name}' (p-value: {result['p_value']:.4f})")
state['drift_history'].append(drift_results)
logger.debug("Drift detection completed for current window.")
return stateFunction Name: update_monitor_with_new_data
This function adds a new batch of data to the monitoring window. It ensures that the window maintains its window_size by removing the oldest data points as new ones arrive. After updating the window, it triggers the drift detection process.
Parameters: state (dict): The current state dictionary of the drift monitor. new_data_batch (pandas.DataFrame): A DataFrame containing new data points to add to the monitor.
Returns: dict: The updated state dictionary after processing the new data and detecting drift.
def update_monitor_with_new_data(state: dict, new_data_batch: pd.DataFrame) -> dict:
"""
Adds new data to the monitoring window and triggers drift detection.
Parameters
----------
state : dict
Current state dictionary.
new_data_batch : pd.DataFrame
A DataFrame containing new data points to add to the monitor.
Returns
-------
dict
Updated state dictionary after processing the new data and detecting drift.
"""
logger.info(f"Updating monitor with {len(new_data_batch)} new data points.")
for _, row in new_data_batch.iterrows():
state['current_window_data'].append(row.to_dict())
if len(state['current_window_data']) == state['window_size']:
logger.debug("Current window is full, performing drift detection.")
state = detect_drift(state)
else:
logger.info(f"Current window not yet full ({len(state['current_window_data'])}/{state['window_size']}). Skipping drift detection.")
return stateFunction Name: summarize_drift_history
This function processes the drift_history stored in the monitor's state and converts it into a structured pandas DataFrame. This DataFrame makes it easier to analyze and visualize drift detection results over time.
Parameters: state (dict): The current state dictionary of the drift monitor.
Returns: pandas.DataFrame: A DataFrame summarizing the drift detection history.
def summarize_drift_history(state: dict) -> pd.DataFrame:
"""
Summarizes the drift detection history into a pandas DataFrame.
Parameters
----------
state : dict
Current state dictionary.
Returns
-------
pd.DataFrame
A DataFrame summarizing the drift detection history.
"""
logger.info("Summarizing drift detection history.")
summary_records = []
for entry in state['drift_history']:
timestamp = entry['timestamp']
for feature_name, results in entry['features'].items():
summary_records.append({
'timestamp': timestamp,
'feature': feature_name,
'statistic': results['statistic'],
'p_value': results['p_value'],
'drift_detected': results['drift_detected']
})
if not summary_records:
logger.warning("No drift history records to summarize.")
return pd.DataFrame()
df_summary = pd.DataFrame(summary_records)
logger.debug(f"Drift history summarized into DataFrame of shape: {df_summary.shape}")
return df_summaryDemonstration/Visualization
This section demonstrates how to use the implemented functions to monitor feature drift. We will:
- Generate synthetic data, including a period where drift is introduced.
- Initialize the drift monitor with a reference dataset.
- Continuously feed new data batches to the monitor and detect drift.
- Visualize the detected drift over time for each feature.
- Compare feature distributions before and after drift.
# 1. Generate Synthetic Data with Drift
TOTAL_SAMPLES = 1000
NUM_NUMERICAL_FEATURES = 3
NUM_CATEGORICAL_FEATURES = 2
DRIFT_START_SAMPLE = 600 # Drift starts after 600 samples
logger.info("Generating synthetic dataset for demonstration.")
synthetic_data = generate_synthetic_data(
num_samples=TOTAL_SAMPLES,
num_features=NUM_NUMERICAL_FEATURES,
num_categorical_features=NUM_CATEGORICAL_FEATURES,
drift_start_sample=DRIFT_START_SAMPLE
)
display(synthetic_data.head())
display(synthetic_data.tail())
logger.info(f"Total samples generated: {len(synthetic_data)}")| numerical_feature_1 | numerical_feature_2 | numerical_feature_3 | categorical_feature_1 | categorical_feature_2 | |
|---|---|---|---|---|---|
| 0 | 10.125874 | 17.995509 | 17.051536 | CatA_0 | CatA_1 |
| 1 | 7.130339 | 11.859207 | 23.584236 | CatB_0 | CatC_1 |
| 2 | 10.454157 | 17.812961 | 22.880805 | CatC_0 | CatA_1 |
| 3 | 9.438758 | 12.229983 | 19.486056 | CatB_0 | CatA_1 |
| 4 | 10.340809 | 18.898225 | 22.470405 | CatA_0 | CatB_1 |
| numerical_feature_1 | numerical_feature_2 | numerical_feature_3 | categorical_feature_1 | categorical_feature_2 | |
|---|---|---|---|---|---|
| 995 | 12.626661 | 13.841663 | 23.165995 | CatA_0 | CatY_1 |
| 996 | 18.094311 | 21.707680 | 24.160554 | CatB_0 | CatX_1 |
| 997 | 16.335071 | 20.460886 | 30.511063 | CatA_0 | CatY_1 |
| 998 | 12.359324 | 25.535520 | 26.492764 | CatA_0 | CatX_1 |
| 999 | 15.114195 | 22.948111 | 26.116884 | CatD_0 | CatX_1 |
# 2. Initialize the Drift Monitor
WINDOW_SIZE = 100 # Compare last 100 samples against reference
ALFA_LEVEL = 0.01 # Significance level
logger.info("Initializing drift monitor.")
drift_monitor_state = create_drift_monitor_state(window_size=WINDOW_SIZE, alfa=ALFA_LEVEL)
# Set the first 500 samples as the reference data (before drift)
REFERENCE_DATA_SIZE = 500
initial_reference_data = synthetic_data.iloc[:REFERENCE_DATA_SIZE]
drift_monitor_state = set_reference_data(drift_monitor_state, initial_reference_data)
logger.info(f"Reference data set with {len(initial_reference_data)} samples.")
print("Reference Data Features and Types:")
display(pd.DataFrame([drift_monitor_state['feature_types']]).T.rename(columns={0: 'Type'}))Reference Data Features and Types:
| Type | |
|---|---|
| numerical_feature_1 | numerical |
| numerical_feature_2 | numerical |
| numerical_feature_3 | numerical |
| categorical_feature_1 | categorical |
| categorical_feature_2 | categorical |
# 3. Continuously Feed New Data Batches and Detect Drift
BATCH_SIZE = 10
logger.info(f"Simulating data stream with batch size {BATCH_SIZE}.")
# Start monitoring from where reference data ends
for i in range(REFERENCE_DATA_SIZE, TOTAL_SAMPLES, BATCH_SIZE):
end_idx = min(i + BATCH_SIZE, TOTAL_SAMPLES)
current_batch = synthetic_data.iloc[i:end_idx]
# Simulate some processing time with random jitter
jitter_delay = random.uniform(0.01, 0.1)
time.sleep(jitter_delay)
logger.debug(f"Processing batch from index {i} to {end_idx-1}.")
drift_monitor_state = update_monitor_with_new_data(drift_monitor_state, current_batch)
logger.info("Data stream simulation complete.")WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0034) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0001) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0007) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_2' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'numerical_feature_3' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_1' (p-value: 0.0000) WARNING:__main__:Drift detected for feature 'categorical_feature_2' (p-value: 0.0000)
# Summarize the drift history
drift_summary_df = summarize_drift_history(drift_monitor_state)
if not drift_summary_df.empty:
print("Drift Detection Summary:")
display(drift_summary_df.head())
display(drift_summary_df.tail())
else:
print("No drift events recorded or insufficient data to summarize.")Drift Detection Summary:
| timestamp | feature | statistic | p_value | drift_detected | |
|---|---|---|---|---|---|
| 0 | 2026-06-10 07:55:18.963780 | numerical_feature_1 | 0.082000 | 0.610592 | False |
| 1 | 2026-06-10 07:55:18.963780 | numerical_feature_2 | 0.094000 | 0.436030 | False |
| 2 | 2026-06-10 07:55:18.963780 | numerical_feature_3 | 0.172000 | 0.013188 | False |
| 3 | 2026-06-10 07:55:18.963780 | categorical_feature_1 | 0.207649 | 0.901383 | False |
| 4 | 2026-06-10 07:55:18.963780 | categorical_feature_2 | 1.315042 | 0.518134 | False |
| timestamp | feature | statistic | p_value | drift_detected | |
|---|---|---|---|---|---|
| 200 | 2026-06-10 07:55:33.376726 | numerical_feature_1 | 0.766000 | 9.810956e-50 | True |
| 201 | 2026-06-10 07:55:33.376726 | numerical_feature_2 | 0.754000 | 6.707466e-48 | True |
| 202 | 2026-06-10 07:55:33.376726 | numerical_feature_3 | 0.636000 | 1.456301e-32 | True |
| 203 | 2026-06-10 07:55:33.376726 | categorical_feature_1 | 209.228623 | 4.274579e-45 | True |
| 204 | 2026-06-10 07:55:33.376726 | categorical_feature_2 | 600.000000 | 1.549608e-128 | True |
# 4. Visualize Drift Over Time
if not drift_summary_df.empty:
plt.figure(figsize=(15, 7))
sns.lineplot(data=drift_summary_df, x='timestamp', y='p_value', hue='feature', marker='o')
plt.axhline(y=ALFA_LEVEL, color='r', linestyle='--', label=f'Significance Level (alpha={ALFA_LEVEL})')
plt.title('Feature Drift p-values Over Time')
plt.xlabel('Time')
plt.ylabel('P-value')
plt.xticks(rotation=45)
plt.legend(title='Feature')
plt.grid(True, linestyle='--', alpha=0.7)
plt.tight_layout()
plt.show()
# Plot just the drift detected points
drift_detected_df = drift_summary_df[drift_summary_df['drift_detected'] == True]
if not drift_detected_df.empty:
plt.figure(figsize=(15, 5))
sns.scatterplot(data=drift_detected_df, x='timestamp', y='p_value', hue='feature', style='feature', s=100, palette='deep')
plt.axhline(y=ALFA_LEVEL, color='r', linestyle='--', label=f'Significance Level (alpha={ALFA_LEVEL})')
plt.title('Detected Feature Drift Events (p-value < alpha)')
plt.xlabel('Time')
plt.ylabel('P-value')
plt.xticks(rotation=45)
plt.legend(title='Drifting Feature')
plt.grid(True, linestyle='--', alpha=0.7)
plt.tight_layout()
plt.show()
else:
logger.info("No drift detected to visualize.")
else:
logger.info("No drift summary data available for visualization.")# 5. Compare Feature Distributions Before and After Drift
# Define the 'before drift' data (reference data)
before_drift_df = initial_reference_data
# Define the 'after drift' data (e.g., a window well after drift_start_sample)
after_drift_start_idx = max(DRIFT_START_SAMPLE + WINDOW_SIZE, REFERENCE_DATA_SIZE + WINDOW_SIZE)
after_drift_end_idx = min(after_drift_start_idx + WINDOW_SIZE, TOTAL_SAMPLES)
after_drift_df = synthetic_data.iloc[after_drift_start_idx:after_drift_end_idx]
if after_drift_df.empty:
logger.warning("Insufficient 'after drift' data to perform comparison visualizations.")
else:
print(f"Comparing distributions using {len(before_drift_df)} 'before drift' samples and {len(after_drift_df)} 'after drift' samples.")
for feature_name, f_type in drift_monitor_state['feature_types'].items():
plt.figure(figsize=(12, 5))
if f_type == 'numerical':
sns.histplot(before_drift_df[feature_name], color='blue', label='Before Drift', kde=True, stat='density', alpha=0.6)
sns.histplot(after_drift_df[feature_name], color='red', label='After Drift', kde=True, stat='density', alpha=0.6)
plt.title(f'Distribution of {feature_name} (Numerical)')
plt.xlabel(feature_name)
plt.ylabel('Density')
else: # Categorical
# Combine and normalize counts for comparison
before_counts = before_drift_df[feature_name].value_counts(normalize=True).sort_index()
after_counts = after_drift_df[feature_name].value_counts(normalize=True).sort_index()
combined_index = sorted(list(set(before_counts.index).union(set(after_counts.index))))
plot_df = pd.DataFrame({
'Before Drift': before_counts.reindex(combined_index, fill_value=0),
'After Drift': after_counts.reindex(combined_index, fill_value=0)
})
plot_df.plot(kind='bar', ax=plt.gca(), width=0.8)
plt.title(f'Distribution of {feature_name} (Categorical)')
plt.xlabel(feature_name)
plt.ylabel('Proportion')
plt.xticks(rotation=45, ha='right')
plt.legend()
plt.grid(axis='y', linestyle='--', alpha=0.7)
plt.tight_layout()
plt.show()Comparing distributions using 500 'before drift' samples and 100 'after drift' samples.
Production Considerations
Monitoring feature drift in a production environment requires more than just statistical tests. It involves integrating monitoring tools into the MLOps pipeline, defining clear thresholds, and establishing response mechanisms.
| Aspect | Best Practices |
|---|---|
| Alerting & Thresholds | Define clear, actionable thresholds for drift detection (e.g., p-value < 0.01 for severe drift, p-value < 0.05 for moderate). Integrate with alerting systems (email, Slack, PagerDuty) to notify stakeholders. Consider different thresholds for different features based on their impact. |
| Automated Retraining | When significant drift is detected, trigger an automated model retraining pipeline using fresh, representative data. This ensures the model adapts to new data distributions. Implement a robust validation process before deploying the retrained model. |
| Data Versioning | Version all data (training, validation, reference, and production data streams) to allow for reproducibility and rollback. This is crucial for debugging drift issues and understanding the impact of data changes. |
| Monitoring Infrastructure | Deploy monitoring solutions that can handle high-volume data streams efficiently. Use tools like Prometheus, Grafana, or cloud-specific monitoring services (e.g., Google Cloud Monitoring, AWS CloudWatch) to collect and visualize drift metrics. |
| Explainability | When drift is detected, try to identify the root cause. This might involve deep dives into data sources, upstream data transformations, or external events. Explainable AI (XAI) techniques can help in understanding which features are most contributing to model performance degradation post-drift. |
| Reference Data Management | Periodically update the reference data to reflect natural, expected evolution of features, without incorporating unwanted drift. This could be done by using a rolling reference window or by retraining the model and re-establishing a new reference baseline. |
| A/B Testing | Before fully deploying a retrained model or a model with updated features, use A/B testing or canary deployments to evaluate its performance in a controlled environment. This helps mitigate risks associated with new data or model versions. |
| Cost Considerations | Monitoring can incur computational and storage costs. Optimize the frequency of drift checks, the size of monitoring windows, and the number of features being monitored to balance detection accuracy with resource utilization. |
Conclusion
This notebook provided a foundational framework for monitoring feature drift over time. We implemented core functions to:
- Initialize and manage a drift monitoring state, including setting reference data and storing drift history.
- Detect drift in both numerical and categorical features using appropriate statistical tests (Kolmogorov-Smirnov and Chi-squared).
- Simulate data streams and continuously update the monitoring window.
- Visualize drift detection results, including p-values over time and comparisons of feature distributions before and after drift.
Effective feature drift monitoring is an essential component of robust MLOps practices, enabling timely intervention to maintain the reliability and accuracy of machine learning models in dynamic production environments.