from __future__ import annotations import json from dataclasses import dataclass from pathlib import Path from typing import Any import matplotlib.pyplot as plt import numpy as np import pandas as pd try: import shap except Exception as e: raise RuntimeError("SHAP is required for explainability. Install with: uv add shap") from e @dataclass(frozen=True) class ExplainConfig: n_background: int = 5000 n_sample: int = 2000 random_state: int = 42 def _safe_sample_df(df: pd.DataFrame, n: int, random_state: int) -> pd.DataFrame: if n <= 0 or n >= len(df): return df.copy() return df.sample(n=n, random_state=random_state).copy() def split_X_y_from_features(df: pd.DataFrame) -> tuple[pd.DataFrame, pd.Series]: if "TARGET" not in df.columns: raise ValueError("features dataframe must contain TARGET column.") df_train = df[df["TARGET"].notna()].copy() y = df_train["TARGET"].astype(int) X = df_train.drop(columns=["TARGET"]) return X, y def unpack_pipeline(pipeline: Any) -> tuple[Any, Any]: """ Expects a sklearn Pipeline: preprocess/steps + final estimator. Returns (preprocess, model). """ if not hasattr(pipeline, "named_steps"): raise TypeError("Expected a sklearn Pipeline with named_steps.") # Convention: last step is model model = list(pipeline.named_steps.values())[-1] preprocess = pipeline[:-1] # everything before the model return preprocess, model def get_feature_names(preprocess: Any) -> np.ndarray | None: # Many sklearn transformers support get_feature_names_out if hasattr(preprocess, "get_feature_names_out"): try: names = preprocess.get_feature_names_out() return np.asarray(names) except Exception: return None return None def transform_X(preprocess: Any, X: pd.DataFrame) -> tuple[np.ndarray, np.ndarray | None]: Xt = preprocess.transform(X) # sparse -> dense if needed (SHAP likes dense for some plots) if hasattr(Xt, "toarray"): Xt = Xt.toarray() feature_names = get_feature_names(preprocess) return np.asarray(Xt), feature_names def lgbm_gain_importance(model: Any, feature_names: np.ndarray | None) -> pd.DataFrame: """ Works with lightgbm.LGBMClassifier. """ if not hasattr(model, "booster_"): raise TypeError( "Model does not look like a fitted LightGBM sklearn estimator (missing booster_)." ) booster = model.booster_ gain = booster.feature_importance(importance_type="gain") split = booster.feature_importance(importance_type="split") if feature_names is None: feature_names = np.array([f"f{i}" for i in range(len(gain))]) df = pd.DataFrame( { "feature": feature_names, "gain": gain, "split": split, } ).sort_values("gain", ascending=False) return df def plot_feature_importance(df_imp: pd.DataFrame, topn: int, outpath: Path) -> None: outpath.parent.mkdir(parents=True, exist_ok=True) df_top = df_imp.head(topn).iloc[::-1] # reverse for horizontal bar plot plt.figure(figsize=(10, 8)) plt.barh(df_top["feature"], df_top["gain"]) plt.title(f"LightGBM Feature Importance (gain) — Top {topn}") plt.xlabel("gain") plt.tight_layout() plt.savefig(outpath, dpi=160) plt.close() def compute_shap_tree(model: Any, X_background: np.ndarray, X_explain: np.ndarray): """ Robust SHAP for LightGBM. - Use feature_perturbation="interventional" to avoid leaf coverage errors. - Disable additivity check (can fail due to numerical issues / approximations). - Prefer explainer(X) which returns an Explanation object in recent SHAP. """ explainer = shap.TreeExplainer( model, data=X_background, feature_perturbation="interventional", ) explanation = explainer(X_explain, check_additivity=False) shap_values = explanation.values base_values = explanation.base_values # base_values may be (n_samples,) -> convert to scalar for waterfall plots if isinstance(base_values, np.ndarray): expected_value = float(np.mean(base_values)) else: expected_value = float(base_values) # For binary classification, shap_values should be (n_samples, n_features). # If SHAP returns (n_samples, n_features, 2), keep class 1. if isinstance(shap_values, np.ndarray) and shap_values.ndim == 3 and shap_values.shape[-1] == 2: shap_values = shap_values[..., 1] return shap_values, expected_value def plot_shap_beeswarm( shap_values: np.ndarray, X_explain: np.ndarray, feature_names: np.ndarray | None, outpath: Path, max_display: int = 30, ) -> None: outpath.parent.mkdir(parents=True, exist_ok=True) plt.figure(figsize=(10, 7)) shap.summary_plot( shap_values, X_explain, feature_names=feature_names, show=False, max_display=max_display, ) plt.tight_layout() plt.savefig(outpath, dpi=160, bbox_inches="tight") plt.close() def plot_shap_waterfall( shap_values_row: np.ndarray, X_row: np.ndarray, expected_value: float, feature_names: np.ndarray | None, outpath: Path, max_display: int = 25, ) -> None: outpath.parent.mkdir(parents=True, exist_ok=True) # SHAP Explanation object for waterfall exp = shap.Explanation( values=shap_values_row, base_values=expected_value, data=X_row, feature_names=feature_names, ) plt.figure(figsize=(10, 6)) shap.plots.waterfall(exp, max_display=max_display, show=False) plt.tight_layout() plt.savefig(outpath, dpi=160, bbox_inches="tight") plt.close() def pick_examples_by_pred_proba( model_pipeline: Any, X: pd.DataFrame, k: int = 1 ) -> tuple[int, int]: """ Returns (idx_high_risk, idx_low_risk) indices in X. """ proba = model_pipeline.predict_proba(X)[:, 1] idx_high = int(np.argmax(proba)) idx_low = int(np.argmin(proba)) return idx_high, idx_low def run_explainability( pipeline: Any, X_raw: pd.DataFrame, config: ExplainConfig, out_dir: Path, topn_importance: int = 30, max_display_shap: int = 30, ) -> dict[str, Any]: out_dir.mkdir(parents=True, exist_ok=True) fig_dir = out_dir / "figures" fig_dir.mkdir(parents=True, exist_ok=True) preprocess, model = unpack_pipeline(pipeline) # sample background + explain set on RAW X, then transform consistently X_bg_raw = _safe_sample_df(X_raw, config.n_background, config.random_state) X_ex_raw = _safe_sample_df(X_raw, config.n_sample, config.random_state) X_bg, feature_names = transform_X(preprocess, X_bg_raw) X_ex, _ = transform_X(preprocess, X_ex_raw) # 1) Feature importance imp = lgbm_gain_importance(model, feature_names) plot_feature_importance(imp, topn_importance, fig_dir / "feature_importance_gain.png") # 2) SHAP beeswarm shap_values, expected_value = compute_shap_tree(model, X_bg, X_ex) plot_shap_beeswarm( shap_values, X_ex, feature_names, fig_dir / "shap_beeswarm.png", max_display=max_display_shap, ) # 3) SHAP waterfall for two examples (high risk / low risk) idx_high, idx_low = pick_examples_by_pred_proba(pipeline, X_ex_raw, k=1) plot_shap_waterfall( shap_values_row=shap_values[idx_high], X_row=X_ex[idx_high], expected_value=float(expected_value), feature_names=feature_names, outpath=fig_dir / "shap_waterfall_high_risk.png", max_display=25, ) plot_shap_waterfall( shap_values_row=shap_values[idx_low], X_row=X_ex[idx_low], expected_value=float(expected_value), feature_names=feature_names, outpath=fig_dir / "shap_waterfall_low_risk.png", max_display=25, ) # Save metadata for traceability meta = { "n_background": config.n_background, "n_sample": config.n_sample, "random_state": config.random_state, "expected_value": float(expected_value), "idx_high_risk_in_sample": idx_high, "idx_low_risk_in_sample": idx_low, "topn_importance": topn_importance, } (out_dir / "explainability_meta.json").write_text(json.dumps(meta, indent=2), encoding="utf-8") return meta