File size: 7,042 Bytes
a4c610d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
"""
SHAP visualizations for the hybrid model.

Three figures, all rendered from measured attributions:

- **bar** β€” ranked feature importance for one prediction
- **waterfall** β€” how the baseline risk becomes this prediction, step by step
- **summary** β€” overall model behaviour across a sample of the record

Written to `reports/shap/`. Colour is used consistently throughout: red
raises risk, blue lowers it. Rendering uses the Agg backend so the module
is safe to import from a headless process.
"""

from __future__ import annotations

import logging
from pathlib import Path

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

from explainability.shap_explainer.config import ShapConfig
from explainability.shap_explainer.explainer import FusionShapExplainer, ShapExplanation

logger = logging.getLogger(__name__)

RAISES = "#c0392b"  # red β€” pushes risk up
LOWERS = "#2471a3"  # blue β€” pushes risk down


def _colour(value: float) -> str:
    return RAISES if value > 0 else LOWERS


def plot_feature_importance(
    explanation: ShapExplanation, config: ShapConfig, *, filename: str = "shap_bar.png"
) -> Path:
    """Ranked bar chart of the features driving one prediction."""
    config.ensure_output_dirs()
    items = explanation.top(config.top_k)[::-1]
    if not items:
        raise ValueError("No attributions to plot")

    labels = [f"{c.label}\n({c.value:g})" if c.value is not None else c.label for c in items]
    values = [c.contribution for c in items]

    fig, ax = plt.subplots(figsize=(9, 0.62 * len(items) + 2.2))
    ax.barh(labels, values, color=[_colour(v) for v in values])
    ax.axvline(0, color="#444", linewidth=0.8)
    ax.set_xlabel("contribution to fused risk (probability units)")
    ax.set_title("Why this risk score β€” ranked feature contributions")
    span = max(abs(v) for v in values) or 1.0
    for y, item in enumerate(items):
        offset = span * 0.02
        ax.text(
            item.contribution + (offset if item.contribution >= 0 else -offset),
            y,
            f"{item.share * 100:+.1f}%  {item.impact}",
            va="center",
            ha="left" if item.contribution >= 0 else "right",
            fontsize=8,
        )
    ax.set_xlim(-span * 1.45, span * 1.45)
    fig.tight_layout()
    path = config.reports_dir / filename
    fig.savefig(path, dpi=150)
    plt.close(fig)
    logger.info("SHAP bar chart -> %s", path)
    return path


def plot_waterfall(
    explanation: ShapExplanation, config: ShapConfig, *, filename: str = "shap_waterfall.png"
) -> Path:
    """Baseline β†’ prediction, one cumulative step per feature.

    Features outside the top-k are pooled into a single "other features"
    step so the bars still sum to the explained risk rather than silently
    dropping mass.
    """
    config.ensure_output_dirs()
    top = explanation.top(config.top_k)
    remainder = sum(
        c.contribution for c in explanation.contributions if c not in top
    )

    steps = [(c.label, c.contribution) for c in top]
    if abs(remainder) > 1e-9:
        steps.append((f"other features ({len(explanation.contributions) - len(top)})", remainder))

    fig, ax = plt.subplots(figsize=(10, 0.62 * len(steps) + 3.0))
    cumulative = explanation.base_value
    for index, (label, value) in enumerate(steps):
        ax.barh(index, value, left=cumulative, color=_colour(value), height=0.62)
        ax.text(
            cumulative + value / 2, index, f"{value:+.3f}",
            ha="center", va="center", fontsize=8,
            color="white" if abs(value) > 0.02 else "#222",
        )
        cumulative += value

    ax.set_yticks(range(len(steps)), [label for label, _ in steps])
    ax.invert_yaxis()
    ax.axvline(explanation.base_value, color="#555", linestyle="--", linewidth=1,
               label=f"baseline {explanation.base_value:.3f}")
    ax.axvline(explanation.predicted_risk, color="#111", linewidth=1.2,
               label=f"explained risk {explanation.predicted_risk:.3f}")
    ax.set_xlabel("fused risk (probability units)")
    ax.set_title("From baseline risk to this prediction")
    ax.legend(fontsize=8, loc="lower right")
    fig.tight_layout()
    path = config.reports_dir / filename
    fig.savefig(path, dpi=150)
    plt.close(fig)
    logger.info("SHAP waterfall -> %s", path)
    return path


def plot_summary(
    explainer: FusionShapExplainer,
    frame: pd.DataFrame,
    config: ShapConfig,
    *,
    filename: str = "shap_summary.png",
    sample_size: int | None = None,
) -> Path:
    """Overall model behaviour: attribution spread across many region-days.

    Each row is a feature; each dot is one region-day, positioned by its
    attribution and coloured by whether that day's feature value was high
    or low. Reading it: a feature whose high values sit on the right raises
    risk when it rises.
    """
    config.ensure_output_dirs()
    size = sample_size or config.summary_sample_size
    sample = frame.sample(min(size, len(frame)), random_state=0) if len(frame) > size else frame

    rows: list[dict[str, float]] = []
    values: list[dict[str, float]] = []
    for index in range(len(sample)):
        explanation = explainer.explain(sample.iloc[[index]])
        rows.append({c.feature: c.contribution for c in explanation.contributions})
        values.append({c.feature: (c.value if c.value is not None else np.nan)
                       for c in explanation.contributions})

    attributions = pd.DataFrame(rows).fillna(0.0)
    observed = pd.DataFrame(values)
    ranking = attributions.abs().mean().sort_values(ascending=False)
    features = list(ranking.index[: config.top_k])[::-1]

    fig, ax = plt.subplots(figsize=(10, 0.55 * len(features) + 2.6))
    rng = np.random.default_rng(0)
    for y, feature in enumerate(features):
        x = attributions[feature].to_numpy()
        raw = observed[feature].to_numpy(dtype=float)
        finite = np.isfinite(raw)
        if finite.sum() > 1 and np.nanmax(raw[finite]) > np.nanmin(raw[finite]):
            low, high = np.nanmin(raw[finite]), np.nanmax(raw[finite])
            shade = np.clip((raw - low) / (high - low), 0, 1)
        else:
            shade = np.full_like(x, 0.5, dtype=float)
        jitter = rng.uniform(-0.16, 0.16, size=len(x))
        scatter = ax.scatter(x, y + jitter, c=shade, cmap="coolwarm", s=13,
                             alpha=0.75, linewidths=0)

    ax.set_yticks(range(len(features)), [config.label_for(f) for f in features])
    ax.axvline(0, color="#444", linewidth=0.8)
    ax.set_xlabel("contribution to fused risk (probability units)")
    ax.set_title(f"Overall model behaviour β€” {len(sample)} region-days")
    bar = fig.colorbar(scatter, ax=ax, pad=0.02)
    bar.set_label("feature value (low β†’ high)", fontsize=8)
    fig.tight_layout()
    path = config.reports_dir / filename
    fig.savefig(path, dpi=150)
    plt.close(fig)
    logger.info("SHAP summary (%d rows) -> %s", len(sample), path)
    return path