Spaces:
Running
Running
| """SHAP-based global and local explanations. | |
| For tree models we use TreeExplainer (fast, exact). For autoencoders | |
| and isolation forests we fall back to KernelExplainer on a small | |
| background sample. | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass | |
| from typing import Any | |
| import numpy as np | |
| import pandas as pd | |
| try: | |
| import shap # type: ignore | |
| _SHAP_OK = True | |
| except ImportError: # pragma: no cover | |
| shap = None # type: ignore | |
| _SHAP_OK = False | |
| from ..utils.logging import get_logger | |
| log = get_logger(__name__) | |
| class Explanation: | |
| feature: str | |
| value: float | |
| contribution: float | |
| direction: str # "↑ fraud" or "↓ fraud" | |
| class ShapExplainer: | |
| """Wraps SHAP for both global and per-application explanations.""" | |
| def __init__(self, model: Any, X_background: pd.DataFrame | None = None): | |
| if not _SHAP_OK: | |
| raise ImportError("shap is not installed") | |
| self.model = model | |
| self.X_background = X_background | |
| self.explainer_: Any = None | |
| # ------------------------------------------------------------------ # | |
| # Fit | |
| # ------------------------------------------------------------------ # | |
| def fit(self) -> "ShapExplainer": | |
| # Detect which kind of explainer to use | |
| cls_name = type(self.model).__name__ | |
| try: | |
| # TreeExplainer works on XGB / LGBM / CatBoost via duck-typing | |
| inner = getattr(self.model, "model", self.model) | |
| self.explainer_ = shap.TreeExplainer(inner) | |
| log.info(f"[SHAP] using TreeExplainer for {cls_name}") | |
| except Exception: # pragma: no cover | |
| bg = self.X_background.sample(min(100, len(self.X_background))) | |
| self.explainer_ = shap.KernelExplainer( | |
| lambda X: self.model.predict_proba(pd.DataFrame(X, columns=bg.columns)), | |
| bg, | |
| ) | |
| log.info(f"[SHAP] using KernelExplainer for {cls_name}") | |
| return self | |
| # ------------------------------------------------------------------ # | |
| # Global importance | |
| # ------------------------------------------------------------------ # | |
| def global_importance(self, X: pd.DataFrame, max_display: int = 20) -> pd.DataFrame: | |
| sv = self.explainer_.shap_values(X) | |
| if isinstance(sv, list): | |
| sv = sv[1] if len(sv) > 1 else sv[0] | |
| mean_abs = np.abs(sv).mean(axis=0) | |
| return ( | |
| pd.DataFrame({"feature": X.columns, "mean_abs_shap": mean_abs}) | |
| .sort_values("mean_abs_shap", ascending=False) | |
| .head(max_display) | |
| .reset_index(drop=True) | |
| ) | |
| # ------------------------------------------------------------------ # | |
| # Local explanation - used by the API for adverse-action notice | |
| # ------------------------------------------------------------------ # | |
| def explain_one(self, x: pd.DataFrame, top_k: int = 5) -> list[Explanation]: | |
| if len(x) != 1: | |
| raise ValueError("explain_one expects a single-row DataFrame") | |
| sv = self.explainer_.shap_values(x) | |
| if isinstance(sv, list): | |
| sv = sv[1] if len(sv) > 1 else sv[0] | |
| sv = np.asarray(sv).reshape(-1) | |
| order = np.argsort(-np.abs(sv))[:top_k] | |
| results: list[Explanation] = [] | |
| for i in order: | |
| feat = x.columns[i] | |
| val = float(x.iloc[0, i]) | |
| contrib = float(sv[i]) | |
| direction = "↑ fraud" if contrib > 0 else "↓ fraud" | |
| results.append(Explanation(feat, val, contrib, direction)) | |
| return results | |