Skip to content

Commit 46b5ccc

Browse files
committed
Fix multiclass LinearSVC fallback paths and close #256
1 parent 9898a3b commit 46b5ccc

4 files changed

Lines changed: 390 additions & 40 deletions

File tree

RELEASE_NOTES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
- Fix issue #294: align multiclass `model_output='logodds'` semantics across Prediction Box and Contributions Plot by using per-class raw margins for multiclass logodds displays.
1111
- Fix multiclass PDP highlight predictions in logodds mode to use the same raw-margin scale as SHAP contributions.
1212
- Fix XGBoost multiclass decision-path summary wording to display `prediction (logodds)` when explainer `model_output='logodds'`.
13+
- Fix issue #256: add robust multiclass probability fallback for classifiers that expose `decision_function` but not `predict_proba` (e.g. `LinearSVC`), and use it consistently across kernel SHAP, prediction helpers, PDP, and permutation scorer paths.
14+
- Prevent multiclass class-count mismatches when user-provided/broken `predict_proba` outputs do not match model class count by falling back to `decision_function`-based probabilities.
1315

1416
### Tests
1517
- Add regression tests for LightGBM with string categorical features covering dashboard initialization, `get_shap_row(...)`, unseen categorical values in `X_row`, and regression dashboard initialization.
@@ -18,6 +20,8 @@
1820
- Add regression tests for issue #294 covering multiclass logodds consistency across prediction table, contributions, PDP highlight predictions, and XGBoost decision-path summaries.
1921
- Add pipeline tests for transformed feature-name cleanup (`strip_pipeline_prefix`, `feature_name_fn`) and pipeline categorical grouping autodetection.
2022
- Add explainer-method unit tests for binary-like onehot detection, transformed feature-name deduping, inferred pipeline cats, and pipeline extraction warning text.
23+
- Add regression tests for issue #256 covering multiclass `LinearSVC` with kernel SHAP, PDP, and permutation-importances flows using `decision_function` fallback.
24+
- Add guard tests to confirm multiclass `predict_proba` models (logistic regression) keep working for PDP and permutation-importances paths.
2125

2226
### Improvements
2327
- Add pipeline feature-name cleanup options: `strip_pipeline_prefix=True` and `feature_name_fn=...` for sklearn/imblearn pipeline transformed output columns.

explainerdashboard/explainer_methods.py

Lines changed: 110 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,87 @@ def _convert_elem(elem):
200200
return pred_array
201201

202202

203+
def _decision_scores_to_probas(decision_scores, n_labels=None):
204+
"""Map decision_function outputs to probability-like class scores."""
205+
scores = np.asarray(decision_scores)
206+
if scores.ndim == 0:
207+
scores = scores.reshape(1)
208+
if scores.ndim == 1 and n_labels and n_labels > 2 and scores.shape[0] == n_labels:
209+
scores = scores.reshape(1, -1)
210+
211+
if scores.ndim == 1:
212+
clipped = np.clip(scores.astype("float64"), -709, 709)
213+
pos_probs = 1.0 / (1.0 + np.exp(-clipped))
214+
return np.column_stack([1.0 - pos_probs, pos_probs])
215+
216+
if scores.ndim == 2:
217+
if scores.shape[1] == 1:
218+
clipped = np.clip(scores[:, 0].astype("float64"), -709, 709)
219+
pos_probs = 1.0 / (1.0 + np.exp(-clipped))
220+
return np.column_stack([1.0 - pos_probs, pos_probs])
221+
222+
shifted = scores - np.max(scores, axis=1, keepdims=True)
223+
exp_scores = np.exp(shifted)
224+
denom = np.sum(exp_scores, axis=1, keepdims=True)
225+
return exp_scores / np.clip(denom, np.finfo("float64").tiny, None)
226+
227+
raise ValueError(
228+
f"Unexpected decision_function output shape {scores.shape}. "
229+
"Expected 1D or 2D scores."
230+
)
231+
232+
233+
def _predict_proba_with_fallback(model, model_input, n_labels=None):
234+
"""Return per-class probabilities, with decision_function fallback."""
235+
pred_probas = None
236+
predict_error = None
237+
238+
if hasattr(model, "predict_proba"):
239+
try:
240+
pred_raw = model.predict_proba(model_input)
241+
pred_raw = _ensure_numeric_predictions(pred_raw)
242+
pred_probas = np.asarray(pred_raw, dtype="float64")
243+
except Exception as e:
244+
predict_error = e
245+
246+
if pred_probas is not None:
247+
if pred_probas.ndim == 1:
248+
if n_labels == 2:
249+
pred_probas = np.column_stack([1.0 - pred_probas, pred_probas])
250+
else:
251+
pred_probas = None
252+
elif pred_probas.ndim != 2:
253+
pred_probas = None
254+
255+
if (
256+
pred_probas is not None
257+
and n_labels is not None
258+
and pred_probas.shape[1] != n_labels
259+
):
260+
pred_probas = None
261+
262+
if pred_probas is None and hasattr(model, "decision_function"):
263+
decision_scores_raw = model.decision_function(model_input)
264+
decision_scores_raw = _ensure_numeric_predictions(decision_scores_raw)
265+
pred_probas = _decision_scores_to_probas(decision_scores_raw, n_labels=n_labels)
266+
267+
if pred_probas is None:
268+
if predict_error is not None:
269+
raise ValueError(
270+
"Could not compute class probabilities from model.predict_proba(...)."
271+
) from predict_error
272+
raise ValueError(
273+
"Could not compute class probabilities: model has neither a working "
274+
"predict_proba(...) nor decision_function(...)."
275+
)
276+
277+
if n_labels is not None and pred_probas.shape[1] != n_labels:
278+
raise ValueError(
279+
f"Expected {n_labels} class probabilities, got shape {pred_probas.shape}."
280+
)
281+
return pred_probas
282+
283+
203284
def get_multiclass_logodds_scores(model, model_input, n_labels):
204285
"""Return per-class raw scores used as multiclass logodds/margins.
205286
@@ -288,7 +369,16 @@ def _wrapped_scorer(estimator, X, y_true):
288369
if "__sklearn_tags__" in str(e):
289370
# Model doesn't have __sklearn_tags__, call predict/predict_proba directly
290371
if response_method == "predict_proba":
291-
y_pred = estimator.predict_proba(X)
372+
n_labels = (
373+
len(estimator.classes_)
374+
if hasattr(estimator, "classes_")
375+
else None
376+
)
377+
y_pred = _predict_proba_with_fallback(
378+
estimator,
379+
X,
380+
n_labels=n_labels,
381+
)
292382
else:
293383
y_pred = estimator.predict(X)
294384
y_pred = _ensure_numeric_predictions(y_pred)
@@ -307,7 +397,14 @@ def _wrapped_scorer(estimator, X, y_true):
307397
# If scorer creation failed, use direct prediction
308398
if scorer is None:
309399
if response_method == "predict_proba":
310-
y_pred = estimator.predict_proba(X)
400+
n_labels = (
401+
len(estimator.classes_) if hasattr(estimator, "classes_") else None
402+
)
403+
y_pred = _predict_proba_with_fallback(
404+
estimator,
405+
X,
406+
n_labels=n_labels,
407+
)
311408
else:
312409
y_pred = estimator.predict(X)
313410
y_pred = _ensure_numeric_predictions(y_pred)
@@ -1066,7 +1163,8 @@ def one_vs_all_metric(metric, pos_label, y_true, y_pred):
10661163

10671164
def _scorer(clf, X, y):
10681165
warnings.filterwarnings("ignore", category=UserWarning)
1069-
y_pred = clf.predict_proba(X)
1166+
n_labels = len(clf.classes_) if hasattr(clf, "classes_") else None
1167+
y_pred = _predict_proba_with_fallback(clf, X, n_labels=n_labels)
10701168
warnings.filterwarnings("default", category=UserWarning)
10711169
y_pred = _ensure_numeric_predictions(y_pred)
10721170
y_pred = np.asarray(y_pred)
@@ -1440,7 +1538,10 @@ def _model_input(data):
14401538
if is_classifier:
14411539
first_row = _model_input(X_sample.iloc[[0]])
14421540
warnings.filterwarnings("ignore", category=UserWarning)
1443-
n_labels = model.predict_proba(first_row).shape[1]
1541+
class_count = len(model.classes_) if hasattr(model, "classes_") else None
1542+
n_labels = _predict_proba_with_fallback(
1543+
model, first_row, n_labels=class_count
1544+
).shape[1]
14441545
warnings.filterwarnings("default", category=UserWarning)
14451546
if multiclass:
14461547
pdp_dfs = [pd.DataFrame() for i in range(n_labels)]
@@ -1474,9 +1575,11 @@ def _coerce_value(value, dtype):
14741575
)
14751576
if is_classifier:
14761577
dtemp_model = _model_input(dtemp)
1477-
pred_probas_raw = model.predict_proba(dtemp_model)
1478-
pred_probas_raw = _ensure_numeric_predictions(pred_probas_raw)
1479-
pred_probas = np.asarray(pred_probas_raw).squeeze()
1578+
pred_probas = _predict_proba_with_fallback(
1579+
model,
1580+
dtemp_model,
1581+
n_labels=n_labels,
1582+
).squeeze()
14801583
if multiclass:
14811584
for i in range(n_labels):
14821585
pdp_dfs[i][grid_value] = pred_probas[:, i]

explainerdashboard/explainers.py

Lines changed: 121 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,9 +1058,11 @@ def get_col_value_plus_prediction(
10581058
if self.is_classifier:
10591059
if pos_label is None:
10601060
pos_label = self.pos_label
1061-
pred_probas_raw = self.model.predict_proba(model_input)
1062-
pred_probas_raw = _ensure_numeric_predictions(pred_probas_raw)
1063-
pred_probas = np.asarray(pred_probas_raw).squeeze()
1061+
pred_probas = self._predict_proba_from_model(
1062+
self.model,
1063+
model_input,
1064+
n_labels=len(self.labels),
1065+
).squeeze()
10641066
if pred_probas.ndim > 1:
10651067
pred_probas = pred_probas[0]
10661068
prediction = pred_probas[pos_label].squeeze()
@@ -2843,17 +2845,10 @@ def __init__(
28432845
auto_detect_pipeline_cats,
28442846
)
28452847

2846-
assert hasattr(model, "predict_proba"), (
2848+
assert hasattr(model, "predict_proba") or hasattr(model, "decision_function"), (
28472849
"for ClassifierExplainer, model should be a scikit-learn "
2848-
"compatible *classifier* model that has a predict_proba(...) "
2849-
f"method, so not a {type(model)}! If you are using e.g an SVM "
2850-
"with hinge loss (which does not support predict_proba), you "
2851-
"can try the following monkey patch:\n\n"
2852-
"import types\n"
2853-
"def predict_proba(self, X):\n"
2854-
" pred = self.predict(X)\n"
2855-
" return np.array([1-pred, pred]).T\n"
2856-
"model.predict_proba = types.MethodType(predict_proba, model)\n"
2850+
"compatible *classifier* model that has either predict_proba(...) "
2851+
f"or decision_function(...), so not a {type(model)}!"
28572852
)
28582853

28592854
self._params_dict = {
@@ -2965,23 +2960,112 @@ def y_binary(self, pos_label):
29652960
self._y_binaries = [self.y.values for i in range(len(self.labels))]
29662961
return self._y_binaries[pos_label]
29672962

2963+
def _decision_scores_to_probas(self, decision_scores, n_labels=None):
2964+
"""Map decision_function outputs to probability-like class scores."""
2965+
scores = np.asarray(decision_scores)
2966+
if scores.ndim == 0:
2967+
scores = scores.reshape(1)
2968+
if (
2969+
scores.ndim == 1
2970+
and n_labels
2971+
and n_labels > 2
2972+
and scores.shape[0] == n_labels
2973+
):
2974+
scores = scores.reshape(1, -1)
2975+
2976+
if scores.ndim == 1:
2977+
clipped = np.clip(scores.astype("float64"), -709, 709)
2978+
pos_probs = 1.0 / (1.0 + np.exp(-clipped))
2979+
return np.column_stack([1.0 - pos_probs, pos_probs])
2980+
2981+
if scores.ndim == 2:
2982+
if scores.shape[1] == 1:
2983+
clipped = np.clip(scores[:, 0].astype("float64"), -709, 709)
2984+
pos_probs = 1.0 / (1.0 + np.exp(-clipped))
2985+
return np.column_stack([1.0 - pos_probs, pos_probs])
2986+
2987+
shifted = scores - np.max(scores, axis=1, keepdims=True)
2988+
exp_scores = np.exp(shifted)
2989+
denom = np.sum(exp_scores, axis=1, keepdims=True)
2990+
return exp_scores / np.clip(denom, np.finfo("float64").tiny, None)
2991+
2992+
raise ValueError(
2993+
f"Unexpected decision_function output shape {scores.shape}. "
2994+
"Expected 1D or 2D scores."
2995+
)
2996+
2997+
def _predict_proba_from_model(self, model, model_input, n_labels=None):
2998+
"""Return per-class probabilities, with decision_function fallback."""
2999+
predict_probas = None
3000+
predict_error = None
3001+
3002+
if hasattr(model, "predict_proba"):
3003+
try:
3004+
predict_raw = model.predict_proba(model_input)
3005+
predict_raw = _ensure_numeric_predictions(predict_raw)
3006+
predict_probas = np.asarray(predict_raw, dtype="float64")
3007+
except Exception as e:
3008+
predict_error = e
3009+
3010+
if predict_probas is not None:
3011+
if predict_probas.ndim == 1:
3012+
if n_labels == 2:
3013+
predict_probas = np.column_stack(
3014+
[1.0 - predict_probas, predict_probas]
3015+
)
3016+
else:
3017+
predict_probas = None
3018+
elif predict_probas.ndim != 2:
3019+
predict_probas = None
3020+
3021+
if (
3022+
predict_probas is not None
3023+
and n_labels is not None
3024+
and predict_probas.shape[1] != n_labels
3025+
):
3026+
predict_probas = None
3027+
3028+
if predict_probas is None and hasattr(model, "decision_function"):
3029+
scores_raw = model.decision_function(model_input)
3030+
scores_raw = _ensure_numeric_predictions(scores_raw)
3031+
predict_probas = self._decision_scores_to_probas(
3032+
scores_raw, n_labels=n_labels
3033+
)
3034+
3035+
if predict_probas is None:
3036+
if predict_error is not None:
3037+
raise ValueError(
3038+
"Could not compute class probabilities from model.predict_proba(...)."
3039+
) from predict_error
3040+
raise ValueError(
3041+
"Could not compute class probabilities: model has neither a working "
3042+
"predict_proba(...) nor decision_function(...)."
3043+
)
3044+
3045+
if n_labels is not None and predict_probas.shape[1] != n_labels:
3046+
raise ValueError(
3047+
f"Expected {n_labels} class probabilities, got shape {predict_probas.shape}."
3048+
)
3049+
return predict_probas
3050+
29683051
@property
29693052
def pred_probas_raw(self):
29703053
"""returns pred_probas with probability for each class"""
29713054
if not hasattr(self, "_pred_probas"):
29723055
logger.info("Calculating prediction probabilities...")
2973-
assert hasattr(
2974-
self.model, "predict_proba"
2975-
), "model does not have a predict_proba method!"
29763056
if self.shap == "skorch":
2977-
pred_probas_raw = self.model.predict_proba(self.X.values)
2978-
pred_probas_raw = _ensure_numeric_predictions(pred_probas_raw)
2979-
self._pred_probas = np.asarray(pred_probas_raw).astype(self.precision)
3057+
self._pred_probas = self._predict_proba_from_model(
3058+
self.model,
3059+
self.X.values,
3060+
n_labels=len(self.labels),
3061+
).astype(self.precision)
29803062
else:
29813063
warnings.filterwarnings("ignore", category=UserWarning)
2982-
pred_probas_raw = self.model.predict_proba(self.X)
2983-
pred_probas_raw = _ensure_numeric_predictions(pred_probas_raw)
2984-
self._pred_probas = np.asarray(pred_probas_raw).astype(self.precision)
3064+
self._pred_probas = self._predict_proba_from_model(
3065+
self.model,
3066+
self.X,
3067+
n_labels=len(self.labels),
3068+
).astype(self.precision)
29853069
warnings.filterwarnings("default", category=UserWarning)
29863070
return self._pred_probas
29873071

@@ -3196,10 +3280,11 @@ def shap_explainer(self):
31963280

31973281
def model_predict(data_asarray):
31983282
data_asframe = pd.DataFrame(data_asarray, columns=self.columns)
3199-
pred_probas_raw = self.model.predict_proba(data_asframe)
3200-
# Handle XGBoost 3.0+ string predictions (though predict_proba usually returns numeric)
3201-
pred_probas_raw = _ensure_numeric_predictions(pred_probas_raw)
3202-
return np.asarray(pred_probas_raw)
3283+
return self._predict_proba_from_model(
3284+
self.model,
3285+
data_asframe,
3286+
n_labels=len(self.labels),
3287+
)
32033288

32043289
self._shap_explainer = shap.KernelExplainer(
32053290
model_predict,
@@ -3750,11 +3835,12 @@ def get_cv_metrics(n_splits):
37503835
):
37513836
X_train, X_test = self.X.iloc[train_index], self.X.iloc[test_index]
37523837
y_train, y_test = self.y.iloc[train_index], self.y.iloc[test_index]
3753-
preds_raw = (
3754-
clone(self.model).fit(X_train, y_train).predict_proba(X_test)
3838+
fitted_model = clone(self.model).fit(X_train, y_train)
3839+
preds = self._predict_proba_from_model(
3840+
fitted_model,
3841+
X_test,
3842+
n_labels=len(self.labels),
37553843
)
3756-
preds_raw = _ensure_numeric_predictions(preds_raw)
3757-
preds = np.asarray(preds_raw)
37583844
for label in range(len(self.labels)):
37593845
for cut in np.linspace(1, 99, 99, dtype=int):
37603846
y_true = np.where(y_test == label, 1, 0)
@@ -3981,9 +4067,11 @@ def prediction_result_df(
39814067
else:
39824068
model_input = sanitize_categorical_predict_input(X_row, self.model)
39834069

3984-
pred_probas_raw = self.model.predict_proba(model_input)
3985-
pred_probas_raw = _ensure_numeric_predictions(pred_probas_raw)
3986-
pred_probas = np.asarray(pred_probas_raw).squeeze()
4070+
pred_probas = self._predict_proba_from_model(
4071+
self.model,
4072+
model_input,
4073+
n_labels=len(self.labels),
4074+
).squeeze()
39874075
if pred_probas.ndim > 1:
39884076
pred_probas = pred_probas[0]
39894077

0 commit comments

Comments
 (0)