|
| 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