Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
- 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.
- Fix multiclass PDP highlight predictions in logodds mode to use the same raw-margin scale as SHAP contributions.
- Fix XGBoost multiclass decision-path summary wording to display `prediction (logodds)` when explainer `model_output='logodds'`.
- 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.
- 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.

### Tests
- 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.
Expand All @@ -18,6 +20,8 @@
- Add regression tests for issue #294 covering multiclass logodds consistency across prediction table, contributions, PDP highlight predictions, and XGBoost decision-path summaries.
- Add pipeline tests for transformed feature-name cleanup (`strip_pipeline_prefix`, `feature_name_fn`) and pipeline categorical grouping autodetection.
- Add explainer-method unit tests for binary-like onehot detection, transformed feature-name deduping, inferred pipeline cats, and pipeline extraction warning text.
- Add regression tests for issue #256 covering multiclass `LinearSVC` with kernel SHAP, PDP, and permutation-importances flows using `decision_function` fallback.
- Add guard tests to confirm multiclass `predict_proba` models (logistic regression) keep working for PDP and permutation-importances paths.

### Improvements
- Add pipeline feature-name cleanup options: `strip_pipeline_prefix=True` and `feature_name_fn=...` for sklearn/imblearn pipeline transformed output columns.
Expand Down
117 changes: 110 additions & 7 deletions explainerdashboard/explainer_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,87 @@ def _convert_elem(elem):
return pred_array


def _decision_scores_to_probas(decision_scores, n_labels=None):
"""Map decision_function outputs to probability-like class scores."""
scores = np.asarray(decision_scores)
if scores.ndim == 0:
scores = scores.reshape(1)
if scores.ndim == 1 and n_labels and n_labels > 2 and scores.shape[0] == n_labels:
scores = scores.reshape(1, -1)

if scores.ndim == 1:
clipped = np.clip(scores.astype("float64"), -709, 709)
pos_probs = 1.0 / (1.0 + np.exp(-clipped))
return np.column_stack([1.0 - pos_probs, pos_probs])

if scores.ndim == 2:
if scores.shape[1] == 1:
clipped = np.clip(scores[:, 0].astype("float64"), -709, 709)
pos_probs = 1.0 / (1.0 + np.exp(-clipped))
return np.column_stack([1.0 - pos_probs, pos_probs])

shifted = scores - np.max(scores, axis=1, keepdims=True)
exp_scores = np.exp(shifted)
denom = np.sum(exp_scores, axis=1, keepdims=True)
return exp_scores / np.clip(denom, np.finfo("float64").tiny, None)

raise ValueError(
f"Unexpected decision_function output shape {scores.shape}. "
"Expected 1D or 2D scores."
)


def _predict_proba_with_fallback(model, model_input, n_labels=None):
"""Return per-class probabilities, with decision_function fallback."""
pred_probas = None
predict_error = None

if hasattr(model, "predict_proba"):
try:
pred_raw = model.predict_proba(model_input)
pred_raw = _ensure_numeric_predictions(pred_raw)
pred_probas = np.asarray(pred_raw, dtype="float64")
except Exception as e:
predict_error = e

if pred_probas is not None:
if pred_probas.ndim == 1:
if n_labels == 2:
pred_probas = np.column_stack([1.0 - pred_probas, pred_probas])
else:
pred_probas = None
elif pred_probas.ndim != 2:
pred_probas = None

if (
pred_probas is not None
and n_labels is not None
and pred_probas.shape[1] != n_labels
):
pred_probas = None

if pred_probas is None and hasattr(model, "decision_function"):
decision_scores_raw = model.decision_function(model_input)
decision_scores_raw = _ensure_numeric_predictions(decision_scores_raw)
pred_probas = _decision_scores_to_probas(decision_scores_raw, n_labels=n_labels)

if pred_probas is None:
if predict_error is not None:
raise ValueError(
"Could not compute class probabilities from model.predict_proba(...)."
) from predict_error
raise ValueError(
"Could not compute class probabilities: model has neither a working "
"predict_proba(...) nor decision_function(...)."
)

if n_labels is not None and pred_probas.shape[1] != n_labels:
raise ValueError(
f"Expected {n_labels} class probabilities, got shape {pred_probas.shape}."
)
return pred_probas


def get_multiclass_logodds_scores(model, model_input, n_labels):
"""Return per-class raw scores used as multiclass logodds/margins.

Expand Down Expand Up @@ -288,7 +369,16 @@ def _wrapped_scorer(estimator, X, y_true):
if "__sklearn_tags__" in str(e):
# Model doesn't have __sklearn_tags__, call predict/predict_proba directly
if response_method == "predict_proba":
y_pred = estimator.predict_proba(X)
n_labels = (
len(estimator.classes_)
if hasattr(estimator, "classes_")
else None
)
y_pred = _predict_proba_with_fallback(
estimator,
X,
n_labels=n_labels,
)
else:
y_pred = estimator.predict(X)
y_pred = _ensure_numeric_predictions(y_pred)
Expand All @@ -307,7 +397,14 @@ def _wrapped_scorer(estimator, X, y_true):
# If scorer creation failed, use direct prediction
if scorer is None:
if response_method == "predict_proba":
y_pred = estimator.predict_proba(X)
n_labels = (
len(estimator.classes_) if hasattr(estimator, "classes_") else None
)
y_pred = _predict_proba_with_fallback(
estimator,
X,
n_labels=n_labels,
)
else:
y_pred = estimator.predict(X)
y_pred = _ensure_numeric_predictions(y_pred)
Expand Down Expand Up @@ -1066,7 +1163,8 @@ def one_vs_all_metric(metric, pos_label, y_true, y_pred):

def _scorer(clf, X, y):
warnings.filterwarnings("ignore", category=UserWarning)
y_pred = clf.predict_proba(X)
n_labels = len(clf.classes_) if hasattr(clf, "classes_") else None
y_pred = _predict_proba_with_fallback(clf, X, n_labels=n_labels)
warnings.filterwarnings("default", category=UserWarning)
y_pred = _ensure_numeric_predictions(y_pred)
y_pred = np.asarray(y_pred)
Expand Down Expand Up @@ -1440,7 +1538,10 @@ def _model_input(data):
if is_classifier:
first_row = _model_input(X_sample.iloc[[0]])
warnings.filterwarnings("ignore", category=UserWarning)
n_labels = model.predict_proba(first_row).shape[1]
class_count = len(model.classes_) if hasattr(model, "classes_") else None
n_labels = _predict_proba_with_fallback(
model, first_row, n_labels=class_count
).shape[1]
warnings.filterwarnings("default", category=UserWarning)
if multiclass:
pdp_dfs = [pd.DataFrame() for i in range(n_labels)]
Expand Down Expand Up @@ -1474,9 +1575,11 @@ def _coerce_value(value, dtype):
)
if is_classifier:
dtemp_model = _model_input(dtemp)
pred_probas_raw = model.predict_proba(dtemp_model)
pred_probas_raw = _ensure_numeric_predictions(pred_probas_raw)
pred_probas = np.asarray(pred_probas_raw).squeeze()
pred_probas = _predict_proba_with_fallback(
model,
dtemp_model,
n_labels=n_labels,
).squeeze()
if multiclass:
for i in range(n_labels):
pdp_dfs[i][grid_value] = pred_probas[:, i]
Expand Down
154 changes: 121 additions & 33 deletions explainerdashboard/explainers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1058,9 +1058,11 @@ def get_col_value_plus_prediction(
if self.is_classifier:
if pos_label is None:
pos_label = self.pos_label
pred_probas_raw = self.model.predict_proba(model_input)
pred_probas_raw = _ensure_numeric_predictions(pred_probas_raw)
pred_probas = np.asarray(pred_probas_raw).squeeze()
pred_probas = self._predict_proba_from_model(
self.model,
model_input,
n_labels=len(self.labels),
).squeeze()
if pred_probas.ndim > 1:
pred_probas = pred_probas[0]
prediction = pred_probas[pos_label].squeeze()
Expand Down Expand Up @@ -2843,17 +2845,10 @@ def __init__(
auto_detect_pipeline_cats,
)

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

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

def _decision_scores_to_probas(self, decision_scores, n_labels=None):
"""Map decision_function outputs to probability-like class scores."""
scores = np.asarray(decision_scores)
if scores.ndim == 0:
scores = scores.reshape(1)
if (
scores.ndim == 1
and n_labels
and n_labels > 2
and scores.shape[0] == n_labels
):
scores = scores.reshape(1, -1)

if scores.ndim == 1:
clipped = np.clip(scores.astype("float64"), -709, 709)
pos_probs = 1.0 / (1.0 + np.exp(-clipped))
return np.column_stack([1.0 - pos_probs, pos_probs])

if scores.ndim == 2:
if scores.shape[1] == 1:
clipped = np.clip(scores[:, 0].astype("float64"), -709, 709)
pos_probs = 1.0 / (1.0 + np.exp(-clipped))
return np.column_stack([1.0 - pos_probs, pos_probs])

shifted = scores - np.max(scores, axis=1, keepdims=True)
exp_scores = np.exp(shifted)
denom = np.sum(exp_scores, axis=1, keepdims=True)
return exp_scores / np.clip(denom, np.finfo("float64").tiny, None)

raise ValueError(
f"Unexpected decision_function output shape {scores.shape}. "
"Expected 1D or 2D scores."
)

def _predict_proba_from_model(self, model, model_input, n_labels=None):
"""Return per-class probabilities, with decision_function fallback."""
predict_probas = None
predict_error = None

if hasattr(model, "predict_proba"):
try:
predict_raw = model.predict_proba(model_input)
predict_raw = _ensure_numeric_predictions(predict_raw)
predict_probas = np.asarray(predict_raw, dtype="float64")
except Exception as e:
predict_error = e

if predict_probas is not None:
if predict_probas.ndim == 1:
if n_labels == 2:
predict_probas = np.column_stack(
[1.0 - predict_probas, predict_probas]
)
else:
predict_probas = None
elif predict_probas.ndim != 2:
predict_probas = None

if (
predict_probas is not None
and n_labels is not None
and predict_probas.shape[1] != n_labels
):
predict_probas = None

if predict_probas is None and hasattr(model, "decision_function"):
scores_raw = model.decision_function(model_input)
scores_raw = _ensure_numeric_predictions(scores_raw)
predict_probas = self._decision_scores_to_probas(
scores_raw, n_labels=n_labels
)

if predict_probas is None:
if predict_error is not None:
raise ValueError(
"Could not compute class probabilities from model.predict_proba(...)."
) from predict_error
raise ValueError(
"Could not compute class probabilities: model has neither a working "
"predict_proba(...) nor decision_function(...)."
)

if n_labels is not None and predict_probas.shape[1] != n_labels:
raise ValueError(
f"Expected {n_labels} class probabilities, got shape {predict_probas.shape}."
)
return predict_probas

@property
def pred_probas_raw(self):
"""returns pred_probas with probability for each class"""
if not hasattr(self, "_pred_probas"):
logger.info("Calculating prediction probabilities...")
assert hasattr(
self.model, "predict_proba"
), "model does not have a predict_proba method!"
if self.shap == "skorch":
pred_probas_raw = self.model.predict_proba(self.X.values)
pred_probas_raw = _ensure_numeric_predictions(pred_probas_raw)
self._pred_probas = np.asarray(pred_probas_raw).astype(self.precision)
self._pred_probas = self._predict_proba_from_model(
self.model,
self.X.values,
n_labels=len(self.labels),
).astype(self.precision)
else:
warnings.filterwarnings("ignore", category=UserWarning)
pred_probas_raw = self.model.predict_proba(self.X)
pred_probas_raw = _ensure_numeric_predictions(pred_probas_raw)
self._pred_probas = np.asarray(pred_probas_raw).astype(self.precision)
self._pred_probas = self._predict_proba_from_model(
self.model,
self.X,
n_labels=len(self.labels),
).astype(self.precision)
warnings.filterwarnings("default", category=UserWarning)
return self._pred_probas

Expand Down Expand Up @@ -3196,10 +3280,11 @@ def shap_explainer(self):

def model_predict(data_asarray):
data_asframe = pd.DataFrame(data_asarray, columns=self.columns)
pred_probas_raw = self.model.predict_proba(data_asframe)
# Handle XGBoost 3.0+ string predictions (though predict_proba usually returns numeric)
pred_probas_raw = _ensure_numeric_predictions(pred_probas_raw)
return np.asarray(pred_probas_raw)
return self._predict_proba_from_model(
self.model,
data_asframe,
n_labels=len(self.labels),
)

self._shap_explainer = shap.KernelExplainer(
model_predict,
Expand Down Expand Up @@ -3750,11 +3835,12 @@ def get_cv_metrics(n_splits):
):
X_train, X_test = self.X.iloc[train_index], self.X.iloc[test_index]
y_train, y_test = self.y.iloc[train_index], self.y.iloc[test_index]
preds_raw = (
clone(self.model).fit(X_train, y_train).predict_proba(X_test)
fitted_model = clone(self.model).fit(X_train, y_train)
preds = self._predict_proba_from_model(
fitted_model,
X_test,
n_labels=len(self.labels),
)
preds_raw = _ensure_numeric_predictions(preds_raw)
preds = np.asarray(preds_raw)
for label in range(len(self.labels)):
for cut in np.linspace(1, 99, 99, dtype=int):
y_true = np.where(y_test == label, 1, 0)
Expand Down Expand Up @@ -3981,9 +4067,11 @@ def prediction_result_df(
else:
model_input = sanitize_categorical_predict_input(X_row, self.model)

pred_probas_raw = self.model.predict_proba(model_input)
pred_probas_raw = _ensure_numeric_predictions(pred_probas_raw)
pred_probas = np.asarray(pred_probas_raw).squeeze()
pred_probas = self._predict_proba_from_model(
self.model,
model_input,
n_labels=len(self.labels),
).squeeze()
if pred_probas.ndim > 1:
pred_probas = pred_probas[0]

Expand Down
Loading
Loading