Fix HF Space graph rendering
Browse files- app.py +8 -7
- interpret.py +87 -62
app.py
CHANGED
|
@@ -277,7 +277,8 @@ FEATURES_HEADING = "#### Extracted Features"
|
|
| 277 |
PROBS_HEADING = "#### Class Probabilities"
|
| 278 |
SHAPLEY_HEADING = (
|
| 279 |
"#### Shapley Attribution\n\n"
|
| 280 |
-
"*
|
|
|
|
| 281 |
)
|
| 282 |
MEANS_HEADING = "#### What This Means"
|
| 283 |
NOT_READY_MSG = (
|
|
@@ -432,7 +433,7 @@ def handle_own_wine(
|
|
| 432 |
)
|
| 433 |
|
| 434 |
progress(0.55, desc="Computing Shapley attributions (shapiq)…")
|
| 435 |
-
|
| 436 |
|
| 437 |
progress(0.75, desc="Running what-if probes…")
|
| 438 |
counterfactuals = run_counterfactuals(clf, features)
|
|
@@ -465,7 +466,7 @@ def handle_own_wine(
|
|
| 465 |
SHOW,
|
| 466 |
gr.update(value=probs_to_df(probabilities), visible=True),
|
| 467 |
SHOW,
|
| 468 |
-
gr.update(value=
|
| 469 |
gr.update(value=attributions, visible=True),
|
| 470 |
SHOW,
|
| 471 |
gr.update(value=explanation, visible=True),
|
|
@@ -544,7 +545,7 @@ def handle_sample_predict(
|
|
| 544 |
)
|
| 545 |
|
| 546 |
progress(0.5, desc="Computing Shapley attributions (shapiq)…")
|
| 547 |
-
|
| 548 |
|
| 549 |
progress(0.85, desc="Writing plain-English explanation…")
|
| 550 |
from agent import generate_layman_explanation
|
|
@@ -565,7 +566,7 @@ def handle_sample_predict(
|
|
| 565 |
gr.update(value=probs_to_df(probabilities), visible=True),
|
| 566 |
gr.update(value=result_md, visible=True),
|
| 567 |
SHOW,
|
| 568 |
-
gr.update(value=
|
| 569 |
gr.update(value=attributions, visible=True),
|
| 570 |
SHOW,
|
| 571 |
gr.update(value=explanation, visible=True),
|
|
@@ -670,7 +671,7 @@ Free-tier usage limits apply on Prior Labs; see their docs for current quotas.
|
|
| 670 |
with gr.Column():
|
| 671 |
sample_verdict_md = gr.Markdown(visible=False)
|
| 672 |
sample_shapley_heading = gr.Markdown(SHAPLEY_HEADING, visible=False)
|
| 673 |
-
sample_attributions_plot = gr.
|
| 674 |
sample_attributions_table = gr.DataFrame(visible=False)
|
| 675 |
sample_means_heading = gr.Markdown(MEANS_HEADING, visible=False)
|
| 676 |
sample_explanation_md = gr.Markdown(visible=False)
|
|
@@ -719,7 +720,7 @@ Free-tier usage limits apply on Prior Labs; see their docs for current quotas.
|
|
| 719 |
own_probs_heading = gr.Markdown(PROBS_HEADING, visible=False)
|
| 720 |
own_probs_table = gr.DataFrame(visible=False)
|
| 721 |
own_shapley_heading = gr.Markdown(SHAPLEY_HEADING, visible=False)
|
| 722 |
-
own_attributions_plot = gr.
|
| 723 |
own_attributions_table = gr.DataFrame(visible=False)
|
| 724 |
own_means_heading = gr.Markdown(MEANS_HEADING, visible=False)
|
| 725 |
own_explanation_md = gr.Markdown(visible=False)
|
|
|
|
| 277 |
PROBS_HEADING = "#### Class Probabilities"
|
| 278 |
SHAPLEY_HEADING = (
|
| 279 |
"#### Shapley Attribution\n\n"
|
| 280 |
+
"*Each bar is a feature's Shapley contribution toward the predicted class "
|
| 281 |
+
"(positive = supports, negative = works against).*"
|
| 282 |
)
|
| 283 |
MEANS_HEADING = "#### What This Means"
|
| 284 |
NOT_READY_MSG = (
|
|
|
|
| 433 |
)
|
| 434 |
|
| 435 |
progress(0.55, desc="Computing Shapley attributions (shapiq)…")
|
| 436 |
+
attribution_img, attributions, shapley_meta = safe_explain(features, pred_idx)
|
| 437 |
|
| 438 |
progress(0.75, desc="Running what-if probes…")
|
| 439 |
counterfactuals = run_counterfactuals(clf, features)
|
|
|
|
| 466 |
SHOW,
|
| 467 |
gr.update(value=probs_to_df(probabilities), visible=True),
|
| 468 |
SHOW,
|
| 469 |
+
gr.update(value=attribution_img, visible=attribution_img is not None),
|
| 470 |
gr.update(value=attributions, visible=True),
|
| 471 |
SHOW,
|
| 472 |
gr.update(value=explanation, visible=True),
|
|
|
|
| 545 |
)
|
| 546 |
|
| 547 |
progress(0.5, desc="Computing Shapley attributions (shapiq)…")
|
| 548 |
+
attribution_img, attributions, shapley_meta = safe_explain(feat, pred_idx)
|
| 549 |
|
| 550 |
progress(0.85, desc="Writing plain-English explanation…")
|
| 551 |
from agent import generate_layman_explanation
|
|
|
|
| 566 |
gr.update(value=probs_to_df(probabilities), visible=True),
|
| 567 |
gr.update(value=result_md, visible=True),
|
| 568 |
SHOW,
|
| 569 |
+
gr.update(value=attribution_img, visible=attribution_img is not None),
|
| 570 |
gr.update(value=attributions, visible=True),
|
| 571 |
SHOW,
|
| 572 |
gr.update(value=explanation, visible=True),
|
|
|
|
| 671 |
with gr.Column():
|
| 672 |
sample_verdict_md = gr.Markdown(visible=False)
|
| 673 |
sample_shapley_heading = gr.Markdown(SHAPLEY_HEADING, visible=False)
|
| 674 |
+
sample_attributions_plot = gr.Image(visible=False, type="pil")
|
| 675 |
sample_attributions_table = gr.DataFrame(visible=False)
|
| 676 |
sample_means_heading = gr.Markdown(MEANS_HEADING, visible=False)
|
| 677 |
sample_explanation_md = gr.Markdown(visible=False)
|
|
|
|
| 720 |
own_probs_heading = gr.Markdown(PROBS_HEADING, visible=False)
|
| 721 |
own_probs_table = gr.DataFrame(visible=False)
|
| 722 |
own_shapley_heading = gr.Markdown(SHAPLEY_HEADING, visible=False)
|
| 723 |
+
own_attributions_plot = gr.Image(visible=False, type="pil")
|
| 724 |
own_attributions_table = gr.DataFrame(visible=False)
|
| 725 |
own_means_heading = gr.Markdown(MEANS_HEADING, visible=False)
|
| 726 |
own_explanation_md = gr.Markdown(visible=False)
|
interpret.py
CHANGED
|
@@ -2,13 +2,17 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 5 |
import warnings
|
| 6 |
-
from typing import Any, Dict, Optional, Tuple
|
| 7 |
|
|
|
|
|
|
|
|
|
|
| 8 |
import matplotlib.pyplot as plt
|
| 9 |
import numpy as np
|
| 10 |
import pandas as pd
|
| 11 |
-
from
|
| 12 |
from tabpfn_client import TabPFNClassifier
|
| 13 |
from tabpfn_extensions.interpretability.shapiq import get_tabpfn_imputation_explainer
|
| 14 |
|
|
@@ -19,6 +23,12 @@ _explainer_cache: Dict[int, object] = {}
|
|
| 19 |
_BACKGROUND_ROWS = 256
|
| 20 |
_SHAPIQ_BUDGET = 64
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
def clear_explainer_cache() -> None:
|
| 24 |
"""Drop cached explainers after reconnecting with a new TabPFN token."""
|
|
@@ -33,8 +43,6 @@ def _get_explainer(
|
|
| 33 |
if class_index not in _explainer_cache:
|
| 34 |
n = min(_BACKGROUND_ROWS, len(train_df))
|
| 35 |
background = train_df[FEATURES].sample(n=n, random_state=42).values
|
| 36 |
-
# Permutation sampling avoids KernelSHAP's regression solve, which can emit
|
| 37 |
-
# divide-by-zero / overflow warnings with near-flat TabPFN coalitions.
|
| 38 |
_explainer_cache[class_index] = get_tabpfn_imputation_explainer(
|
| 39 |
clf,
|
| 40 |
background,
|
|
@@ -48,49 +56,76 @@ def _get_explainer(
|
|
| 48 |
return _explainer_cache[class_index]
|
| 49 |
|
| 50 |
|
| 51 |
-
def
|
| 52 |
-
"""Dark-theme styling and extra margins so labels are not clipped in Gradio."""
|
| 53 |
-
fig.patch.set_facecolor("#1e0f14")
|
| 54 |
-
primary_ax = fig.axes[0] if fig.axes else None
|
| 55 |
-
|
| 56 |
-
for ax in fig.axes:
|
| 57 |
-
ax.set_facecolor("#1e0f14")
|
| 58 |
-
ax.tick_params(colors="#f5e6d3", labelcolor="#f5e6d3")
|
| 59 |
-
for spine in ax.spines.values():
|
| 60 |
-
spine.set_color("#722f37")
|
| 61 |
-
if ax.title:
|
| 62 |
-
ax.title.set_color("#f5e6d3")
|
| 63 |
-
if ax.xaxis.label:
|
| 64 |
-
ax.xaxis.label.set_color("#c9a87c")
|
| 65 |
-
for label in ax.get_yticklabels() + ax.get_xticklabels():
|
| 66 |
-
label.set_color("#f5e6d3")
|
| 67 |
-
xmin, xmax = ax.get_xlim()
|
| 68 |
-
pad = (xmax - xmin) * 0.14
|
| 69 |
-
ax.set_xlim(xmin - pad, xmax + pad)
|
| 70 |
-
|
| 71 |
-
if primary_ax is not None:
|
| 72 |
-
w, h = fig.get_size_inches()
|
| 73 |
-
fig.set_size_inches(max(w, 12.0), max(h, 6.5))
|
| 74 |
-
primary_ax.tick_params(axis="y", pad=10)
|
| 75 |
-
# Wide left margin for full feature names; padding on x for arrow heads.
|
| 76 |
-
fig.subplots_adjust(left=0.42, right=0.90, bottom=0.16, top=0.86)
|
| 77 |
-
|
| 78 |
-
return fig
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
def _shapley_table(sv) -> pd.DataFrame:
|
| 82 |
-
"""First-order Shapley values as a readable table."""
|
| 83 |
rows = []
|
| 84 |
for interaction, value in sv.dict_values.items():
|
| 85 |
if len(interaction) != 1:
|
| 86 |
continue
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
bar_len = max(1, round(magnitude * 40)) if magnitude > 0 else 0
|
| 91 |
rows.append({
|
| 92 |
-
"Feature":
|
| 93 |
-
"Shapley": round(
|
| 94 |
"Effect": (
|
| 95 |
"supports predicted class" if value > 0
|
| 96 |
else "works against predicted class" if value < 0
|
|
@@ -98,10 +133,7 @@ def _shapley_table(sv) -> pd.DataFrame:
|
|
| 98 |
),
|
| 99 |
"Bar": "█" * bar_len,
|
| 100 |
})
|
| 101 |
-
|
| 102 |
-
if df.empty:
|
| 103 |
-
return df
|
| 104 |
-
return df.reindex(df["Shapley"].abs().sort_values(ascending=False).index)
|
| 105 |
|
| 106 |
|
| 107 |
def explain_prediction(
|
|
@@ -109,8 +141,8 @@ def explain_prediction(
|
|
| 109 |
train_df: pd.DataFrame,
|
| 110 |
features: dict,
|
| 111 |
class_index: int,
|
| 112 |
-
) -> Tuple[Optional[
|
| 113 |
-
"""Run shapiq SV attribution and return
|
| 114 |
explainer = _get_explainer(clf, train_df, class_index)
|
| 115 |
x = np.array([[features[f] for f in FEATURES]], dtype=float)
|
| 116 |
with warnings.catch_warnings():
|
|
@@ -121,23 +153,16 @@ def explain_prediction(
|
|
| 121 |
)
|
| 122 |
sv = explainer.explain(x, budget=_SHAPIQ_BUDGET)
|
| 123 |
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
max_display=len(FEATURES),
|
| 129 |
-
abbreviate=False,
|
| 130 |
-
)
|
| 131 |
-
fig = ax.figure if ax is not None else plt.gcf()
|
| 132 |
-
_style_waterfall(fig)
|
| 133 |
|
| 134 |
-
|
| 135 |
-
float(v) for k, v in sv.dict_values.items() if len(k) == 1
|
| 136 |
-
)
|
| 137 |
meta = {
|
| 138 |
-
"baseline_probability": round(
|
| 139 |
-
"final_probability": round(
|
| 140 |
"predicted_class_index": class_index,
|
| 141 |
"attribution_method": "shapiq SV via permutation sampling (first-order)",
|
| 142 |
}
|
| 143 |
-
return
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
import io
|
| 6 |
import warnings
|
| 7 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 8 |
|
| 9 |
+
import matplotlib
|
| 10 |
+
|
| 11 |
+
matplotlib.use("Agg")
|
| 12 |
import matplotlib.pyplot as plt
|
| 13 |
import numpy as np
|
| 14 |
import pandas as pd
|
| 15 |
+
from PIL import Image
|
| 16 |
from tabpfn_client import TabPFNClassifier
|
| 17 |
from tabpfn_extensions.interpretability.shapiq import get_tabpfn_imputation_explainer
|
| 18 |
|
|
|
|
| 23 |
_BACKGROUND_ROWS = 256
|
| 24 |
_SHAPIQ_BUDGET = 64
|
| 25 |
|
| 26 |
+
_POS_COLOR = "#e85d75"
|
| 27 |
+
_NEG_COLOR = "#4a9eff"
|
| 28 |
+
_BG_COLOR = "#1e0f14"
|
| 29 |
+
_TEXT_COLOR = "#f5e6d3"
|
| 30 |
+
_GRID_COLOR = "#722f37"
|
| 31 |
+
|
| 32 |
|
| 33 |
def clear_explainer_cache() -> None:
|
| 34 |
"""Drop cached explainers after reconnecting with a new TabPFN token."""
|
|
|
|
| 43 |
if class_index not in _explainer_cache:
|
| 44 |
n = min(_BACKGROUND_ROWS, len(train_df))
|
| 45 |
background = train_df[FEATURES].sample(n=n, random_state=42).values
|
|
|
|
|
|
|
| 46 |
_explainer_cache[class_index] = get_tabpfn_imputation_explainer(
|
| 47 |
clf,
|
| 48 |
background,
|
|
|
|
| 56 |
return _explainer_cache[class_index]
|
| 57 |
|
| 58 |
|
| 59 |
+
def _shapley_pairs(sv) -> List[Tuple[str, float]]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
rows = []
|
| 61 |
for interaction, value in sv.dict_values.items():
|
| 62 |
if len(interaction) != 1:
|
| 63 |
continue
|
| 64 |
+
key = FEATURES[interaction[0]]
|
| 65 |
+
rows.append((FEATURE_DISPLAY_NAMES[key], float(value)))
|
| 66 |
+
rows.sort(key=lambda item: abs(item[1]), reverse=True)
|
| 67 |
+
return rows
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _shapley_bar_image(
|
| 71 |
+
pairs: List[Tuple[str, float]],
|
| 72 |
+
baseline: float,
|
| 73 |
+
final: float,
|
| 74 |
+
) -> Image.Image:
|
| 75 |
+
"""Diverging bar chart — rasterized for reliable display in Gradio on HF."""
|
| 76 |
+
if not pairs:
|
| 77 |
+
fig, ax = plt.subplots(figsize=(10, 2), facecolor=_BG_COLOR)
|
| 78 |
+
ax.set_facecolor(_BG_COLOR)
|
| 79 |
+
ax.text(0.5, 0.5, "No attributions available", ha="center", va="center", color=_TEXT_COLOR)
|
| 80 |
+
ax.axis("off")
|
| 81 |
+
else:
|
| 82 |
+
names = [name for name, _ in pairs]
|
| 83 |
+
values = [value for _, value in pairs]
|
| 84 |
+
height = max(5.0, len(names) * 0.42)
|
| 85 |
+
fig, ax = plt.subplots(figsize=(10, height), facecolor=_BG_COLOR)
|
| 86 |
+
ax.set_facecolor(_BG_COLOR)
|
| 87 |
+
colors = [_POS_COLOR if v > 0 else _NEG_COLOR for v in values]
|
| 88 |
+
y_pos = np.arange(len(names))
|
| 89 |
+
ax.barh(y_pos, values, color=colors, height=0.72)
|
| 90 |
+
ax.set_yticks(y_pos)
|
| 91 |
+
ax.set_yticklabels(names, color=_TEXT_COLOR, fontsize=10)
|
| 92 |
+
ax.axvline(0, color=_GRID_COLOR, linewidth=1)
|
| 93 |
+
ax.tick_params(axis="x", colors=_TEXT_COLOR, labelcolor=_TEXT_COLOR)
|
| 94 |
+
ax.set_xlabel("Shapley contribution (predicted-class probability)", color="#c9a87c")
|
| 95 |
+
ax.set_title(
|
| 96 |
+
f"Baseline {baseline:.3f} → prediction {final:.3f}",
|
| 97 |
+
color=_TEXT_COLOR,
|
| 98 |
+
fontsize=11,
|
| 99 |
+
pad=10,
|
| 100 |
+
)
|
| 101 |
+
for spine in ax.spines.values():
|
| 102 |
+
spine.set_color(_GRID_COLOR)
|
| 103 |
+
ax.grid(axis="x", color=_GRID_COLOR, alpha=0.35, linestyle="--", linewidth=0.6)
|
| 104 |
+
ax.invert_yaxis()
|
| 105 |
+
fig.tight_layout()
|
| 106 |
+
|
| 107 |
+
buf = io.BytesIO()
|
| 108 |
+
fig.savefig(
|
| 109 |
+
buf,
|
| 110 |
+
format="png",
|
| 111 |
+
dpi=150,
|
| 112 |
+
bbox_inches="tight",
|
| 113 |
+
facecolor=_BG_COLOR,
|
| 114 |
+
edgecolor="none",
|
| 115 |
+
)
|
| 116 |
+
plt.close(fig)
|
| 117 |
+
buf.seek(0)
|
| 118 |
+
return Image.open(buf)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _shapley_table(pairs: List[Tuple[str, float]]) -> pd.DataFrame:
|
| 122 |
+
rows = []
|
| 123 |
+
for name, value in pairs:
|
| 124 |
+
magnitude = abs(value)
|
| 125 |
bar_len = max(1, round(magnitude * 40)) if magnitude > 0 else 0
|
| 126 |
rows.append({
|
| 127 |
+
"Feature": name,
|
| 128 |
+
"Shapley": round(value, 4),
|
| 129 |
"Effect": (
|
| 130 |
"supports predicted class" if value > 0
|
| 131 |
else "works against predicted class" if value < 0
|
|
|
|
| 133 |
),
|
| 134 |
"Bar": "█" * bar_len,
|
| 135 |
})
|
| 136 |
+
return pd.DataFrame(rows)
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
|
| 139 |
def explain_prediction(
|
|
|
|
| 141 |
train_df: pd.DataFrame,
|
| 142 |
features: dict,
|
| 143 |
class_index: int,
|
| 144 |
+
) -> Tuple[Optional[Image.Image], pd.DataFrame, Dict[str, Any]]:
|
| 145 |
+
"""Run shapiq SV attribution and return a bar-chart image, table, and metadata."""
|
| 146 |
explainer = _get_explainer(clf, train_df, class_index)
|
| 147 |
x = np.array([[features[f] for f in FEATURES]], dtype=float)
|
| 148 |
with warnings.catch_warnings():
|
|
|
|
| 153 |
)
|
| 154 |
sv = explainer.explain(x, budget=_SHAPIQ_BUDGET)
|
| 155 |
|
| 156 |
+
pairs = _shapley_pairs(sv)
|
| 157 |
+
shapley_sum = sum(value for _, value in pairs)
|
| 158 |
+
baseline = float(sv.baseline_value)
|
| 159 |
+
final = baseline + shapley_sum
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
+
image = _shapley_bar_image(pairs, baseline, final)
|
|
|
|
|
|
|
| 162 |
meta = {
|
| 163 |
+
"baseline_probability": round(baseline, 4),
|
| 164 |
+
"final_probability": round(final, 4),
|
| 165 |
"predicted_class_index": class_index,
|
| 166 |
"attribution_method": "shapiq SV via permutation sampling (first-order)",
|
| 167 |
}
|
| 168 |
+
return image, _shapley_table(pairs), meta
|