File size: 3,612 Bytes
42029e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44cd54e
42029e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
"""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