loanguard / src /evaluation /explainability.py
adwitiyashukla's picture
update repo
44cd54e
Raw
History Blame Contribute Delete
3.61 kB
"""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__)
@dataclass
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