FinRiskGuard / src /explainability /shap_explainer.py
Sarvarbek13's picture
Add src pipeline code
2d2a96b
Raw
History Blame Contribute Delete
17.3 kB
import shap
import joblib
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from pathlib import Path
import sys
sys.path.append(str(Path(__file__).resolve().parents[2]))
from src.logger import get_logger
logger = get_logger("shap_explainer")
ROOT_DIR = Path(__file__).resolve().parents[2]
FIGURES_DIR = ROOT_DIR / "outputs" / "figures"
# Task-specific label mapping
TASK_LABELS = {
"fraud" : {"positive": "Fraud", "negative": "Legit"},
"credit" : {"positive": "Default", "negative": "No-Default"},
}
# ── Helper ────────────────────────────────────────────────────────────────────
def _save(fig, path: Path, dpi: int = 150) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(path, dpi=dpi, bbox_inches="tight")
plt.close(fig)
logger.info(f" Saved: {path}")
def _get_tree_model(model) -> object:
"""Extract underlying tree model from Pipeline or return as-is."""
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
if isinstance(model, Pipeline):
for _, step in model.steps:
if isinstance(step, LogisticRegression):
raise ValueError(
"Stacking meta-learner (LogisticRegression) is not supported "
"by TreeExplainer. Pass a base tree model (tuned_xgb/lgb/cat) instead."
)
return model
def get_explainer(model) -> shap.TreeExplainer:
"""Create TreeExplainer. Raises if model is not tree-based."""
model = _get_tree_model(model)
logger.info(f"Creating TreeExplainer for {type(model).__name__}...")
return shap.TreeExplainer(model)
def get_shap_values(
explainer: shap.TreeExplainer,
X: pd.DataFrame,
n_samples: int = 2000,
) -> tuple:
"""Sample X and compute SHAP values."""
X_sample = X.sample(n=min(n_samples, len(X)), random_state=42)
logger.info(f"Computing SHAP values on {len(X_sample):,} samples...")
shap_values = explainer(X_sample, check_additivity=False)
logger.info(f" SHAP values shape: {shap_values.values.shape}")
logger.info(f" Base value : {float(explainer.expected_value):.4f}")
return shap_values, X_sample
# ── Global SHAP ───────────────────────────────────────────────────────────────
def plot_global_bar(
shap_values: shap.Explanation,
task: str = "fraud",
max_display: int = 20,
) -> None:
"""Mean absolute SHAP β€” overall feature importance ranking."""
logger.info("Global SHAP β€” Bar plot...")
fig, ax = plt.subplots(figsize=(10, 8))
shap.plots.bar(shap_values, max_display=max_display, show=False, ax=ax)
ax.set_title(
f"Global SHAP Feature Importance β€” {task.upper()} Model",
fontsize=13, fontweight="bold", pad=12,
)
_save(fig, FIGURES_DIR / task / "shap" / "01_global_bar.png")
def plot_global_beeswarm(
shap_values: shap.Explanation,
task: str = "fraud",
max_display: int = 20,
) -> None:
"""Beeswarm β€” feature importance + direction + distribution."""
logger.info("Global SHAP β€” Beeswarm plot...")
shap.plots.beeswarm(shap_values, max_display=max_display, show=False)
plt.title(
f"Global SHAP Summary (Beeswarm) β€” {task.upper()} Model",
fontsize=13, fontweight="bold", pad=12,
)
fig = plt.gcf()
_save(fig, FIGURES_DIR / task / "shap" / "02_global_beeswarm.png")
def plot_global_heatmap(
shap_values: shap.Explanation,
task: str = "fraud",
max_display: int = 20,
) -> None:
"""Heatmap β€” sample Γ— feature SHAP matrix."""
logger.info("Global SHAP β€” Heatmap...")
shap.plots.heatmap(shap_values, max_display=max_display, show=False)
plt.title(
f"Global SHAP Heatmap β€” {task.upper()} Model",
fontsize=13, fontweight="bold", pad=12,
)
fig = plt.gcf()
_save(fig, FIGURES_DIR / task / "shap" / "03_global_heatmap.png")
def plot_mean_abs_shap(
shap_values: shap.Explanation,
X_sample: pd.DataFrame,
task: str = "fraud",
top_n: int = 20,
) -> None:
"""Custom mean |SHAP| bar β€” cleaner than shap.plots.bar for reports."""
logger.info("Global SHAP β€” Mean |SHAP| custom bar...")
mean_abs = pd.Series(
np.abs(shap_values.values).mean(axis=0),
index=X_sample.columns,
).sort_values(ascending=True).tail(top_n)
fig, ax = plt.subplots(figsize=(10, 8))
colors = ["#E24B4A" if "FE_" in c else "#378ADD" for c in mean_abs.index]
ax.barh(mean_abs.index, mean_abs.values, color=colors, edgecolor="none")
ax.set_xlabel("Mean |SHAP value|")
ax.set_title(
f"Top {top_n} Features β€” Mean |SHAP| | {task.upper()}",
fontsize=13, fontweight="bold",
)
# Legend
from matplotlib.patches import Patch
legend = [
Patch(color="#E24B4A", label="Engineered (FE_)"),
Patch(color="#378ADD", label="Original"),
]
ax.legend(handles=legend, loc="lower right")
plt.tight_layout()
_save(fig, FIGURES_DIR / task / "shap" / "07_mean_abs_shap_custom.png")
# ── Local SHAP ────────────────────────────────────────────────────────────────
def plot_local_waterfall(
shap_values: shap.Explanation,
X_sample: pd.DataFrame,
idx: int,
task: str = "fraud",
label: str = "",
max_display: int = 15,
) -> None:
"""Waterfall plot β€” single sample explanation."""
logger.info(f"Local SHAP β€” Waterfall (idx={idx}, {label})...")
shap.plots.waterfall(shap_values[idx], max_display=max_display, show=False)
plt.title(
f"Local SHAP Waterfall β€” Sample #{idx} ({label}) | {task.upper()}",
fontsize=13, fontweight="bold", pad=12,
)
fig = plt.gcf()
fname = f"04_local_waterfall_{label.lower().replace(' ', '_')}_idx{idx}.png"
_save(fig, FIGURES_DIR / task / "shap" / fname)
def plot_local_force(
explainer: shap.TreeExplainer,
shap_values: shap.Explanation,
X_sample: pd.DataFrame,
idx: int,
task: str = "fraud",
label: str = "",
) -> None:
"""Force plot β€” single sample push/pull visualization."""
logger.info(f"Local SHAP β€” Force plot (idx={idx}, {label})...")
shap.initjs()
shap.force_plot(
explainer.expected_value,
shap_values.values[idx],
X_sample.iloc[idx],
show=False,
matplotlib=True,
figsize=(18, 4),
)
fig = plt.gcf()
plt.title(
f"Local SHAP Force β€” Sample #{idx} ({label}) | {task.upper()}",
fontsize=12, fontweight="bold",
)
fname = f"05_local_force_{label.lower().replace(' ', '_')}_idx{idx}.png"
_save(fig, FIGURES_DIR / task / "shap" / fname)
# ── Interaction SHAP ──────────────────────────────────────────────────────────
def plot_dependence(
shap_values: shap.Explanation,
X_sample: pd.DataFrame,
feature: str,
interaction_feature: str = None,
task: str = "fraud",
) -> None:
"""Dependence plot β€” feature value vs SHAP value, colored by interaction."""
logger.info(f"SHAP Dependence β€” {feature} Γ— {interaction_feature}...")
if feature not in X_sample.columns:
logger.info(f" Feature {feature} not found, skipping.")
return
fig, ax = plt.subplots(figsize=(10, 6))
feat_idx = list(X_sample.columns).index(feature)
shap_feat = shap_values.values[:, feat_idx]
if interaction_feature and interaction_feature in X_sample.columns:
inter_idx = list(X_sample.columns).index(interaction_feature)
color_vals = X_sample[interaction_feature].values
sc = ax.scatter(
X_sample[feature], shap_feat,
c=color_vals, cmap="coolwarm",
alpha=0.6, edgecolors="none", s=20,
)
plt.colorbar(sc, ax=ax, label=interaction_feature)
else:
ax.scatter(
X_sample[feature], shap_feat,
alpha=0.5, edgecolors="none", s=20, color="steelblue",
)
ax.axhline(0, color="gray", linestyle="--", linewidth=0.8)
ax.set_xlabel(feature)
ax.set_ylabel(f"SHAP value for {feature}")
ax.set_title(
f"SHAP Dependence: {feature}"
+ (f" Γ— {interaction_feature}" if interaction_feature else ""),
fontsize=13, fontweight="bold",
)
plt.tight_layout()
fname = f"06_dependence_{feature}.png"
_save(fig, FIGURES_DIR / task / "shap" / fname)
def plot_positive_negative_shap(
shap_values: shap.Explanation,
X_sample: pd.DataFrame,
task: str = "fraud",
top_n: int = 15,
) -> None:
"""Split mean SHAP into risk-increasing vs risk-decreasing features."""
logger.info("Global SHAP β€” Positive vs Negative split...")
labels = TASK_LABELS.get(task, {"positive": "Positive", "negative": "Negative"})
mean_shap = pd.Series(
shap_values.values.mean(axis=0),
index=X_sample.columns,
).sort_values()
pos = mean_shap[mean_shap > 0].tail(top_n)
neg = mean_shap[mean_shap < 0].head(top_n)
fig, axes = plt.subplots(1, 2, figsize=(16, 7))
axes[0].barh(neg.index, neg.values, color="#378ADD", edgecolor="none")
axes[0].set_title(
f"Risk-Decreasing Features\n(↓ {labels['positive']} probability)",
fontweight="bold"
)
axes[0].set_xlabel("Mean SHAP value")
axes[0].axvline(0, color="black", lw=0.8)
axes[1].barh(pos.index, pos.values, color="#E24B4A", edgecolor="none")
axes[1].set_title(
f"Risk-Increasing Features\n(↑ {labels['positive']} probability)",
fontweight="bold"
)
axes[1].set_xlabel("Mean SHAP value")
axes[1].axvline(0, color="black", lw=0.8)
plt.suptitle(
f"SHAP Direction Analysis β€” {task.upper()} Model",
fontsize=13, fontweight="bold",
)
plt.tight_layout()
_save(fig, FIGURES_DIR / task / "shap" / "08_positive_negative_shap.png")
def plot_fe_vs_raw_contribution(
shap_values: shap.Explanation,
X_sample: pd.DataFrame,
task: str = "fraud",
) -> None:
"""Compare total SHAP contribution: engineered features vs raw features."""
logger.info("SHAP β€” FE vs Raw contribution comparison...")
mean_abs = pd.Series(
np.abs(shap_values.values).mean(axis=0),
index=X_sample.columns,
)
fe_total = mean_abs[[c for c in mean_abs.index if c.startswith("FE_")]].sum()
raw_total = mean_abs[[c for c in mean_abs.index if not c.startswith("FE_")]].sum()
total = fe_total + raw_total
logger.info(f" FE features contribution : {fe_total:.4f} ({fe_total/total*100:.1f}%)")
logger.info(f" Raw features contribution : {raw_total:.4f} ({raw_total/total*100:.1f}%)")
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
axes[0].pie(
[fe_total, raw_total],
labels=[
f"Engineered (FE_)\n{fe_total/total*100:.1f}%",
f"Original\n{raw_total/total*100:.1f}%",
],
colors=["#E24B4A", "#378ADD"],
autopct="%1.1f%%", startangle=90,
textprops={"fontsize": 11},
)
axes[0].set_title(
"Total SHAP Contribution\nEngineered vs Original Features",
fontweight="bold",
)
# Top FE features
fe_features = mean_abs[
[c for c in mean_abs.index if c.startswith("FE_")]
].sort_values(ascending=True).tail(15)
axes[1].barh(fe_features.index, fe_features.values,
color="#E24B4A", edgecolor="none")
axes[1].set_xlabel("Mean |SHAP value|")
axes[1].set_title("Top Engineered Features β€” SHAP Importance", fontweight="bold")
plt.suptitle(
f"Feature Engineering Impact β€” {task.upper()} Model",
fontsize=13, fontweight="bold",
)
plt.tight_layout()
_save(fig, FIGURES_DIR / task / "shap" / "09_fe_vs_raw_contribution.png")
# ── Full Analysis ─────────────────────────────────────────────────────────────
def run_full_shap_analysis(
model,
X: pd.DataFrame,
y: pd.Series = None,
task: str = "fraud",
n_samples: int = 2000,
top_k_dependence: int = 3,
) -> None:
"""Run complete SHAP analysis and save all figures for the given task."""
logger.info("=" * 60)
logger.info(f"SHAP FULL ANALYSIS β€” {task.upper()}")
logger.info("=" * 60)
labels = TASK_LABELS.get(task, {"positive": "Positive", "negative": "Negative"})
explainer = get_explainer(model)
shap_values, X_sample = get_shap_values(explainer, X, n_samples)
# ── Global ────────────────────────────────────────────────
plot_global_bar(shap_values, task)
plot_global_beeswarm(shap_values, task)
plot_global_heatmap(shap_values, task)
plot_mean_abs_shap(shap_values, X_sample, task)
plot_positive_negative_shap(shap_values, X_sample, task)
plot_fe_vs_raw_contribution(shap_values, X_sample, task)
# ── Local β€” task-aware labels ─────────────────────────────
if y is not None:
y_sample = y.loc[X_sample.index]
# MODIFIED: Find sample with highest probability for positive class
pos_mask = y_sample == 1
if pos_mask.any():
pos_probs = model.predict_proba(X_sample[pos_mask])[:, 1]
# Get index of maximum probability within the positive samples
pos_idx = int(np.where(pos_mask.values)[0][pos_probs.argmax()])
plot_local_waterfall(
shap_values, X_sample, pos_idx, task,
label=f"High-Risk {labels['positive']}"
)
try:
plot_local_force(
explainer, shap_values, X_sample, pos_idx, task,
label=f"High-Risk {labels['positive']}"
)
except Exception as e:
logger.info(f" Force plot skipped: {e}")
# Find sample with lowest probability for positive class (most negative)
neg_mask = y_sample == 0
if neg_mask.any():
neg_probs = model.predict_proba(X_sample[neg_mask])[:, 1]
neg_idx = int(np.where(neg_mask.values)[0][neg_probs.argmin()])
plot_local_waterfall(
shap_values, X_sample, neg_idx, task,
label=f"Clear {labels['negative']}"
)
else:
plot_local_waterfall(shap_values, X_sample, 0, task, label="Sample")
# ── Dependence β€” top K features ───────────────────────────
mean_abs_shap = np.abs(shap_values.values).mean(axis=0)
top_features = X_sample.columns[
np.argsort(mean_abs_shap)[-top_k_dependence:][::-1]
].tolist()
logger.info(f"Top {top_k_dependence} features for dependence: {top_features}")
for i, feat in enumerate(top_features):
inter = top_features[i + 1] if i + 1 < len(top_features) else None
plot_dependence(shap_values, X_sample, feat, inter, task)
logger.info("=" * 60)
logger.info(f"SHAP COMPLETE β€” figures: outputs/figures/{task}/shap/")
logger.info("=" * 60)
# ── Quick single-sample explanation ──────────────────────────────────────────
def explain_single(
model,
X_single: pd.DataFrame,
task: str = "fraud",
label: str = "single",
) -> dict:
"""Explain one prediction with SHAP β€” returns contribution dict."""
labels = TASK_LABELS.get(task, {"positive": "Positive", "negative": "Negative"})
explainer = get_explainer(model)
shap_values = explainer(X_single, check_additivity=False)
contributions = pd.Series(
shap_values.values[0],
index=X_single.columns,
).sort_values(key=abs, ascending=False)
base_value = float(explainer.expected_value)
pred_shap = base_value + shap_values.values[0].sum()
logger.info(f"\nSingle explanation ({label}):")
logger.info(f" Base value (avg prediction) : {base_value:.4f}")
logger.info(f" SHAP prediction : {pred_shap:.4f}")
logger.info(f"\n Top 10 contributors:")
for feat, val in contributions.head(10).items():
direction = f"↑ {labels['positive']}" if val > 0 else f"↓ {labels['positive']}"
logger.info(f" {feat:35s} {val:+.4f} {direction}")
plot_local_waterfall(shap_values, X_single, 0, task, label=label)
return {
"base_value" : base_value,
"shap_pred" : pred_shap,
"contributions": contributions.to_dict(),
}