| """Shapley attributions via shapiq + TabPFN imputation explainer (cloud-safe).""" |
|
|
| from __future__ import annotations |
|
|
| import io |
| import warnings |
| from typing import Any, Dict, List, Optional, Tuple |
|
|
| import matplotlib |
|
|
| matplotlib.use("Agg") |
| import matplotlib.pyplot as plt |
| import numpy as np |
| import pandas as pd |
| from PIL import Image |
| from tabpfn_client import TabPFNClassifier |
| from tabpfn_extensions.interpretability.shapiq import get_tabpfn_imputation_explainer |
|
|
| from model import FEATURES, FEATURE_DISPLAY_NAMES |
|
|
| |
| _explainer_cache: Dict[int, object] = {} |
| _BACKGROUND_ROWS = 256 |
| _SHAPIQ_BUDGET = 64 |
|
|
| _POS_COLOR = "#e85d75" |
| _NEG_COLOR = "#4a9eff" |
| _BG_COLOR = "#1e0f14" |
| _TEXT_COLOR = "#f5e6d3" |
| _GRID_COLOR = "#722f37" |
|
|
|
|
| def clear_explainer_cache() -> None: |
| """Drop cached explainers after reconnecting with a new TabPFN token.""" |
| _explainer_cache.clear() |
|
|
|
|
| def _get_explainer( |
| clf: TabPFNClassifier, |
| train_df: pd.DataFrame, |
| class_index: int, |
| ): |
| if class_index not in _explainer_cache: |
| n = min(_BACKGROUND_ROWS, len(train_df)) |
| background = train_df[FEATURES].sample(n=n, random_state=42).values |
| _explainer_cache[class_index] = get_tabpfn_imputation_explainer( |
| clf, |
| background, |
| index="SV", |
| max_order=1, |
| imputer="baseline", |
| class_index=class_index, |
| approximator="permutation", |
| random_state=42, |
| ) |
| return _explainer_cache[class_index] |
|
|
|
|
| def _shapley_pairs(sv) -> List[Tuple[str, float]]: |
| rows = [] |
| for interaction, value in sv.dict_values.items(): |
| if len(interaction) != 1: |
| continue |
| key = FEATURES[interaction[0]] |
| rows.append((FEATURE_DISPLAY_NAMES[key], float(value))) |
| rows.sort(key=lambda item: abs(item[1]), reverse=True) |
| return rows |
|
|
|
|
| def _shapley_bar_image( |
| pairs: List[Tuple[str, float]], |
| baseline: float, |
| final: float, |
| ) -> Image.Image: |
| """Diverging bar chart — rasterized for reliable display in Gradio on HF.""" |
| if not pairs: |
| fig, ax = plt.subplots(figsize=(10, 2), facecolor=_BG_COLOR) |
| ax.set_facecolor(_BG_COLOR) |
| ax.text(0.5, 0.5, "No attributions available", ha="center", va="center", color=_TEXT_COLOR) |
| ax.axis("off") |
| else: |
| names = [name for name, _ in pairs] |
| values = [value for _, value in pairs] |
| height = max(5.0, len(names) * 0.42) |
| fig, ax = plt.subplots(figsize=(10, height), facecolor=_BG_COLOR) |
| ax.set_facecolor(_BG_COLOR) |
| colors = [_POS_COLOR if v > 0 else _NEG_COLOR for v in values] |
| y_pos = np.arange(len(names)) |
| ax.barh(y_pos, values, color=colors, height=0.72) |
| ax.set_yticks(y_pos) |
| ax.set_yticklabels(names, color=_TEXT_COLOR, fontsize=10) |
| ax.axvline(0, color=_GRID_COLOR, linewidth=1) |
| ax.tick_params(axis="x", colors=_TEXT_COLOR, labelcolor=_TEXT_COLOR) |
| ax.set_xlabel("Shapley contribution (predicted-class probability)", color="#c9a87c") |
| ax.set_title( |
| f"Baseline {baseline:.3f} → prediction {final:.3f}", |
| color=_TEXT_COLOR, |
| fontsize=11, |
| pad=10, |
| ) |
| for spine in ax.spines.values(): |
| spine.set_color(_GRID_COLOR) |
| ax.grid(axis="x", color=_GRID_COLOR, alpha=0.35, linestyle="--", linewidth=0.6) |
| ax.invert_yaxis() |
| fig.tight_layout() |
|
|
| buf = io.BytesIO() |
| fig.savefig( |
| buf, |
| format="png", |
| dpi=150, |
| bbox_inches="tight", |
| facecolor=_BG_COLOR, |
| edgecolor="none", |
| ) |
| plt.close(fig) |
| buf.seek(0) |
| return Image.open(buf) |
|
|
|
|
| def _shapley_table(pairs: List[Tuple[str, float]]) -> pd.DataFrame: |
| rows = [] |
| for name, value in pairs: |
| magnitude = abs(value) |
| bar_len = max(1, round(magnitude * 40)) if magnitude > 0 else 0 |
| rows.append({ |
| "Feature": name, |
| "Shapley": round(value, 4), |
| "Effect": ( |
| "supports predicted class" if value > 0 |
| else "works against predicted class" if value < 0 |
| else "neutral" |
| ), |
| "Bar": "█" * bar_len, |
| }) |
| return pd.DataFrame(rows) |
|
|
|
|
| def explain_prediction( |
| clf: TabPFNClassifier, |
| train_df: pd.DataFrame, |
| features: dict, |
| class_index: int, |
| ) -> Tuple[Optional[Image.Image], pd.DataFrame, Dict[str, Any]]: |
| """Run shapiq SV attribution and return a bar-chart image, table, and metadata.""" |
| explainer = _get_explainer(clf, train_df, class_index) |
| x = np.array([[features[f] for f in FEATURES]], dtype=float) |
| with warnings.catch_warnings(): |
| warnings.filterwarnings( |
| "ignore", |
| category=RuntimeWarning, |
| module=r"shapiq\.approximator\.regression", |
| ) |
| sv = explainer.explain(x, budget=_SHAPIQ_BUDGET) |
|
|
| pairs = _shapley_pairs(sv) |
| shapley_sum = sum(value for _, value in pairs) |
| baseline = float(sv.baseline_value) |
| final = baseline + shapley_sum |
|
|
| image = _shapley_bar_image(pairs, baseline, final) |
| meta = { |
| "baseline_probability": round(baseline, 4), |
| "final_probability": round(final, 4), |
| "predicted_class_index": class_index, |
| "attribution_method": "shapiq SV via permutation sampling (first-order)", |
| } |
| return image, _shapley_table(pairs), meta |
|
|