Skip to content

Commit 3365c40

Browse files
Add files via upload
1 parent 97da0bf commit 3365c40

5 files changed

Lines changed: 138 additions & 12 deletions

File tree

app.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@
2424
format_percent,
2525
summarize_scored_transactions,
2626
)
27-
from src.reason_codes import shap_reason_codes, split_reason_codes # noqa: E402
27+
from src.reason_codes import ( # noqa: E402
28+
positive_class_shap_values,
29+
shap_reason_codes,
30+
split_reason_codes,
31+
)
2832
from src.score_new_transactions import score_dataframe # noqa: E402
2933
from src.validation import ( # noqa: E402
3034
REQUIRED_FEATURE_COLUMNS,
@@ -154,7 +158,7 @@ def explain_single_transaction(model, df_scored: pd.DataFrame, row_idx: int):
154158

155159
shap_vals = explainer.shap_values(x_for_shap)
156160

157-
shap_for_fraud_class = shap_vals[1][0] if isinstance(shap_vals, list) else shap_vals[0]
161+
shap_for_fraud_class = positive_class_shap_values(shap_vals)[0]
158162

159163
fig = plot_single_shap_bar(shap_for_fraud_class, feature_names)
160164
reasons = shap_reason_codes(shap_for_fraud_class, feature_names, max_reasons=5)

requirements.txt

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
pandas
2-
numpy
3-
scikit-learn
4-
scipy
5-
matplotlib
6-
joblib
7-
shap
8-
streamlit
1+
pandas>=2.1,<4
2+
numpy>=1.24,<3
3+
scikit-learn>=1.3,<2
4+
scipy>=1.10,<2
5+
matplotlib>=3.7,<4
6+
joblib>=1.3,<2
7+
shap>=0.44,<1
8+
streamlit>=1.30,<2

src/explain.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import shap
66

77
from .config import FIGURES_DIR, MODELS_DIR, PROCESSED_DATA_DIR, TARGET_COL
8+
from .reason_codes import positive_class_shap_values
89

910

1011
def load_model_and_test():
@@ -46,13 +47,13 @@ def compute_and_plot_global_shap(
4647
X_for_shap = X_transformed.toarray() if sp.issparse(X_transformed) else X_transformed
4748

4849
explainer = shap.TreeExplainer(clf)
49-
shap_values = explainer.shap_values(X_for_shap)
50+
shap_values = positive_class_shap_values(explainer.shap_values(X_for_shap))
5051

5152
# SHAP expects names for features; use transformed feature names
5253
feature_names = preprocessor.get_feature_names_out()
5354

5455
shap.summary_plot(
55-
shap_values[1], # class 1 (fraud)
56+
shap_values, # class 1 (fraud)
5657
X_for_shap,
5758
feature_names=feature_names,
5859
show=False,

src/reason_codes.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,28 @@ def humanize_feature_name(feature_name: str) -> str:
156156
return FRIENDLY_FEATURE_NAMES.get(raw, raw.replace("_", " "))
157157

158158

159+
def positive_class_shap_values(shap_values: object) -> np.ndarray:
160+
"""Return SHAP values for the positive (fraud) class, across shap versions.
161+
162+
``TreeExplainer.shap_values`` has returned different shapes over time for
163+
binary classifiers:
164+
165+
- legacy shap: a list ``[class_0_array, class_1_array]``;
166+
- modern shap (>= 0.43): a single array shaped
167+
``(n_samples, n_features, n_classes)``.
168+
169+
This normalizes both (and an already-2-D array) to the class-1 values so the
170+
rest of the code can stay version-agnostic.
171+
"""
172+
if isinstance(shap_values, list):
173+
return np.asarray(shap_values[1])
174+
175+
arr = np.asarray(shap_values)
176+
if arr.ndim == 3:
177+
return arr[..., 1]
178+
return arr
179+
180+
159181
def shap_reason_codes(
160182
shap_values: Iterable[float],
161183
feature_names: Iterable[str],

tests/test_explainability.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from __future__ import annotations
2+
3+
import unittest
4+
5+
import numpy as np
6+
7+
from src.config import TARGET_COL
8+
from src.generate_synthetic_data import generate_synthetic_fraud_dataset
9+
from src.reason_codes import positive_class_shap_values
10+
11+
try:
12+
import shap # noqa: F401
13+
14+
HAS_SHAP = True
15+
except Exception: # pragma: no cover - shap is an optional heavy dependency
16+
HAS_SHAP = False
17+
18+
19+
class PositiveClassShapValuesTests(unittest.TestCase):
20+
"""Unit tests for the version-robust SHAP normalizer (no shap required)."""
21+
22+
def test_legacy_list_returns_class_one(self) -> None:
23+
class0 = np.zeros((4, 3))
24+
class1 = np.ones((4, 3))
25+
result = positive_class_shap_values([class0, class1])
26+
self.assertEqual(result.shape, (4, 3))
27+
self.assertTrue(np.allclose(result, 1.0))
28+
29+
def test_modern_3d_array_selects_positive_class(self) -> None:
30+
# Shape (n_samples, n_features, n_classes); class 1 is all ones.
31+
arr = np.zeros((4, 3, 2))
32+
arr[..., 1] = 1.0
33+
result = positive_class_shap_values(arr)
34+
self.assertEqual(result.shape, (4, 3))
35+
self.assertTrue(np.allclose(result, 1.0))
36+
37+
def test_two_dimensional_array_passes_through(self) -> None:
38+
arr = np.arange(12, dtype=float).reshape(4, 3)
39+
result = positive_class_shap_values(arr)
40+
self.assertEqual(result.shape, (4, 3))
41+
self.assertTrue(np.allclose(result, arr))
42+
43+
44+
@unittest.skipUnless(HAS_SHAP, "shap is not installed")
45+
class ShapIntegrationTests(unittest.TestCase):
46+
"""End-to-end SHAP smoke test; skipped cleanly when shap is unavailable."""
47+
48+
def test_global_shap_runs_and_writes_figure(self) -> None:
49+
import matplotlib
50+
51+
matplotlib.use("Agg")
52+
53+
from src.config import FIGURES_DIR
54+
from src.explain import compute_and_plot_global_shap
55+
from src.features import build_pipeline
56+
57+
df = generate_synthetic_fraud_dataset(n_samples=300, fraud_rate=0.2, seed=7)
58+
X = df.drop(columns=[TARGET_COL])
59+
y = df[TARGET_COL]
60+
61+
pipeline = build_pipeline()
62+
pipeline.fit(X, y)
63+
64+
out_path = FIGURES_DIR / "shap_summary.png"
65+
if out_path.exists():
66+
out_path.unlink()
67+
68+
# Must not raise; modern shap returns a 3-D array that the old
69+
# shap_values[1] indexing mishandled.
70+
compute_and_plot_global_shap(pipeline, X, max_samples=100)
71+
72+
self.assertTrue(out_path.exists())
73+
74+
def test_single_row_positive_class_shape(self) -> None:
75+
import scipy.sparse as sp
76+
77+
from src.features import build_pipeline
78+
79+
df = generate_synthetic_fraud_dataset(n_samples=300, fraud_rate=0.2, seed=11)
80+
X = df.drop(columns=[TARGET_COL])
81+
y = df[TARGET_COL]
82+
83+
pipeline = build_pipeline()
84+
pipeline.fit(X, y)
85+
86+
preprocessor = pipeline.named_steps["preprocess"]
87+
clf = pipeline.named_steps["clf"]
88+
x_row = X.iloc[[0]]
89+
x_t = preprocessor.transform(x_row)
90+
x_t = x_t.toarray() if sp.issparse(x_t) else x_t
91+
92+
explainer = shap.TreeExplainer(clf)
93+
single = positive_class_shap_values(explainer.shap_values(x_t))[0]
94+
95+
self.assertEqual(single.shape, (x_t.shape[1],))
96+
97+
98+
if __name__ == "__main__":
99+
unittest.main()

0 commit comments

Comments
 (0)