import numpy as np import pandas as pd from scipy import stats from typing import Dict, Any def calculate_psi(expected: np.ndarray, actual: np.ndarray, num_buckets: int = 10, eps: float = 1e-4) -> float: """ Calculates Population Stability Index (PSI): PSI = sum((Actual% - Expected%) * ln(Actual% / Expected%)) PSI < 0.10: Stable (Green) 0.10 <= PSI < 0.25: Moderate Drift (Yellow) PSI >= 0.25: Significant Drift (Red) """ expected = expected[~np.isnan(expected)] actual = actual[~np.isnan(actual)] if len(expected) < 20 or len(actual) < 20: return 0.0 percentiles = np.linspace(0, 100, num_buckets + 1) buckets = np.percentile(expected, percentiles) buckets[0] = -np.inf buckets[-1] = np.inf exp_counts, _ = np.histogram(expected, bins=buckets) act_counts, _ = np.histogram(actual, bins=buckets) exp_pct = exp_counts / len(expected) act_pct = act_counts / len(actual) exp_pct = np.clip(exp_pct, eps, 1.0) act_pct = np.clip(act_pct, eps, 1.0) psi_val = np.sum((act_pct - exp_pct) * np.log(act_pct / exp_pct)) return float(psi_val) class FeatureDriftMonitor: """ Monitors feature distribution drift between training reference window and current production window. """ def __init__(self, psi_threshold_warning: float = 0.10, psi_threshold_alert: float = 0.25): self.psi_threshold_warning = psi_threshold_warning self.psi_threshold_alert = psi_threshold_alert def audit_drift(self, ref_df: pd.DataFrame, cur_df: pd.DataFrame, feature_cols: list) -> Dict[str, Any]: results = {} for col in feature_cols: if col in ref_df.columns and col in cur_df.columns: ref_vals = ref_df[col].dropna().values cur_vals = cur_df[col].dropna().values psi_score = calculate_psi(ref_vals, cur_vals) ks_stat, p_val = stats.ks_2samp(ref_vals, cur_vals) status = "GREEN" if psi_score >= self.psi_threshold_alert: status = "RED" elif psi_score >= self.psi_threshold_warning: status = "YELLOW" results[col] = { "psi": psi_score, "ks_stat": float(ks_stat), "p_value": float(p_val), "status": status, } return results