Skip to content

Commit 57aca25

Browse files
authored
fix: route the SHAP predictor through predict_prepared
as_shap_predictor handed SHAP a closure over model.predict. The callers (KernelShap, RegressionKernelShap, ContrastiveShap) first move the background into the model's feature space with prepare_model_input, so going through predict ran the model's input preparation a second time over an already prepared matrix. SHAP then perturbs that matrix into plain arrays, which the preparation cannot consume at all, and the failure surfaced far from its cause as AttributeError: 'numpy.ndarray' object has no attribute 'types' The module docstring and a comment above each caller already said the model had to be queried through predict_prepared; only the call was left behind. test_the_wrapped_predictor_never_routes_through_predict pins it: its stub raises if predict is reached, so a future regression fails at the wrapper instead of five frames away inside SHAP.
1 parent 74b6bb6 commit 57aca25

2 files changed

Lines changed: 36 additions & 10 deletions

File tree

DashAI/back/explainability/model_input.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525

2626
def as_shap_predictor(model: Any) -> Callable:
27-
"""Wrap ``model.predict`` so SHAP receives a plain function, not a method.
27+
"""Wrap the model's prepared-matrix prediction so SHAP gets a plain function.
2828
2929
SHAP suppresses scikit-learn's "X does not have valid feature names"
3030
warning by blanking ``feature_names_in_`` on whatever object the callable
@@ -43,6 +43,13 @@ def as_shap_predictor(model: Any) -> Callable:
4343
interface for ``model``. The only thing lost is the suppression of a
4444
cosmetic scikit-learn warning.
4545
46+
It routes to ``predict_prepared``, not to ``predict``. Callers hand SHAP a
47+
background already moved into the model's feature space with
48+
``prepare_model_input``, and SHAP then queries the model with perturbed
49+
copies of *that* matrix. Going through ``predict`` would run the model's
50+
input preparation a second time over an already prepared matrix — and SHAP
51+
passes plain arrays, which the preparation cannot consume at all.
52+
4653
Parameters
4754
----------
4855
model : Any
@@ -51,12 +58,12 @@ def as_shap_predictor(model: Any) -> Callable:
5158
Returns
5259
-------
5360
Callable
54-
A one-argument function calling ``model.predict`` positionally, the
55-
same way SHAP calls it today.
61+
A one-argument function calling ``model.predict_prepared``
62+
positionally, the same way SHAP calls it.
5663
"""
5764

5865
def predict(x):
59-
return model.predict(x)
66+
return model.predict_prepared(x)
6067

6168
return predict
6269

tests/back/explainers/test_shap_predictor_handover.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,12 @@
1616
from DashAI.back.explainability.model_input import as_shap_predictor
1717

1818

19-
def test_the_wrapped_predictor_forwards_to_predict_positionally():
19+
def test_the_wrapped_predictor_forwards_to_predict_prepared_positionally():
2020
"""SHAP calls the model with one positional argument; that must not change."""
2121
seen = {}
2222

2323
class Model:
24-
def predict(self, x):
24+
def predict_prepared(self, x):
2525
seen["arg"] = x
2626
return [0]
2727

@@ -30,15 +30,34 @@ def predict(self, x):
3030
assert seen["arg"] == "the frame"
3131

3232

33+
def test_the_wrapped_predictor_never_routes_through_predict():
34+
"""The callers hand SHAP a background already in the model's feature space.
35+
36+
Routing to ``predict`` would prepare an already prepared matrix a second
37+
time, and SHAP perturbs it into plain arrays that the preparation cannot
38+
consume at all — which surfaces far away, as
39+
``'numpy.ndarray' object has no attribute 'types'``.
40+
"""
41+
42+
class Model:
43+
def predict(self, x):
44+
raise AssertionError("predict would prepare an already prepared matrix")
45+
46+
def predict_prepared(self, x):
47+
return [1]
48+
49+
assert as_shap_predictor(Model())("the frame") == [1]
50+
51+
3352
def test_the_wrapped_predictor_hides_the_model_from_shap():
3453
"""The whole mechanism: no ``__self__`` means SHAP never reaches the model."""
3554

3655
class Model:
37-
def predict(self, x):
56+
def predict_prepared(self, x):
3857
return [0]
3958

4059
model = Model()
41-
assert getattr(model.predict, "__self__", None) is model
60+
assert getattr(model.predict_prepared, "__self__", None) is model
4261
assert getattr(as_shap_predictor(model), "__self__", None) is None
4362

4463

@@ -53,15 +72,15 @@ class ReadOnlyFeatureNames:
5372
def feature_names_in_(self):
5473
return ["a", "b"]
5574

56-
def predict(self, x):
75+
def predict_prepared(self, x):
5776
return [0]
5877

5978
model = ReadOnlyFeatureNames()
6079

6180
# The failure this exists to prevent: SHAP reaches the model through
6281
# ``__self__`` and tries to blank the attribute.
6382
with pytest.raises(AttributeError, match="feature_names_in_"):
64-
convert_to_model(model.predict)
83+
convert_to_model(model.predict_prepared)
6584

6685
converted = convert_to_model(as_shap_predictor(model))
6786

0 commit comments

Comments
 (0)