diff --git a/DashAI/back/models/scikit_learn/linear_svc_classifier.py b/DashAI/back/models/scikit_learn/linear_svc_classifier.py index dcc7cbff6..52efc66e6 100644 --- a/DashAI/back/models/scikit_learn/linear_svc_classifier.py +++ b/DashAI/back/models/scikit_learn/linear_svc_classifier.py @@ -295,6 +295,40 @@ def __init__(self, **kwargs) -> None: def __sklearn_is_fitted__(self) -> bool: return self._calibrated is not None + @property + def classes_(self): + """Expose the calibrated model's classes for sklearn compatibility. + + sklearn utilities (e.g. permutation_importance, partial_dependence) + read ``estimator.classes_`` directly on any object tagged as a + classifier, regardless of which prediction method they end up + calling. Since this wrapper fits ``self._calibrated`` instead of + ``self``, that attribute must be proxied explicitly. + """ + from sklearn.exceptions import NotFittedError + + if self._calibrated is None: + raise NotFittedError( + f"This {self.__class__.__name__} instance is not fitted yet. " + "Call 'train' with appropriate arguments before using this estimator." + ) + return self._calibrated.classes_ + + def predict_proba(self, x_pred) -> "ndarray": # noqa: F821 + """Return class-probability matrix using the calibrated model. + + Parameters + ---------- + x_pred : DashAIDataset or pd.DataFrame + Input data. + + Returns + ------- + np.ndarray + Class probability matrix. + """ + return self.predict(x_pred) + def train(self, x_train, y_train, x_validation=None, y_validation=None): """Train using CalibratedClassifierCV to expose predict_proba. diff --git a/DashAI/back/models/scikit_learn/sgd_classifier.py b/DashAI/back/models/scikit_learn/sgd_classifier.py index f1a445d46..d0ae85b9f 100644 --- a/DashAI/back/models/scikit_learn/sgd_classifier.py +++ b/DashAI/back/models/scikit_learn/sgd_classifier.py @@ -325,6 +325,40 @@ def __init__(self, **kwargs) -> None: def __sklearn_is_fitted__(self) -> bool: return self._calibrated is not None + @property + def classes_(self): + """Expose the calibrated model's classes for sklearn compatibility. + + sklearn utilities (e.g. permutation_importance, partial_dependence) + read ``estimator.classes_`` directly on any object tagged as a + classifier, regardless of which prediction method they end up + calling. Since this wrapper fits ``self._calibrated`` instead of + ``self``, that attribute must be proxied explicitly. + """ + from sklearn.exceptions import NotFittedError + + if self._calibrated is None: + raise NotFittedError( + f"This {self.__class__.__name__} instance is not fitted yet. " + "Call 'train' with appropriate arguments before using this estimator." + ) + return self._calibrated.classes_ + + def predict_proba(self, x_pred) -> "ndarray": # noqa: F821 + """Return class-probability matrix using the calibrated model. + + Parameters + ---------- + x_pred : DashAIDataset or pd.DataFrame + Input data. + + Returns + ------- + np.ndarray + Class probability matrix. + """ + return self.predict(x_pred) + def train(self, x_train, y_train, x_validation=None, y_validation=None): """Train using CalibratedClassifierCV to guarantee predict_proba availability. diff --git a/tests/back/explainers/test_explainers.py b/tests/back/explainers/test_explainers.py index 2c854683a..1825b45e6 100644 --- a/tests/back/explainers/test_explainers.py +++ b/tests/back/explainers/test_explainers.py @@ -21,6 +21,8 @@ from DashAI.back.models.scikit_learn.decision_tree_classifier import ( DecisionTreeClassifier, ) +from DashAI.back.models.scikit_learn.linear_svc_classifier import LinearSVCClassifier +from DashAI.back.models.scikit_learn.sgd_classifier import SGDClassifier from DashAI.back.types.categorical import Categorical from DashAI.back.types.utils import save_types_in_arrow_metadata from DashAI.back.types.value_types import Float @@ -114,6 +116,56 @@ def trained_model(dataset): return model +@pytest.fixture(scope="module", params=[LinearSVCClassifier, SGDClassifier]) +def trained_calibrated_model(request, dataset): + """Models that internally calibrate via CalibratedClassifierCV. + + Regression coverage for the bug where these wrappers didn't proxy + ``classes_``/``predict_proba`` onto themselves, breaking any explainer + that relies on sklearn's classifier introspection (e.g. PFI, PDP). + """ + x, y = dataset + model = request.param() + model.train(x["train"], y["train"]) + return model + + +def test_permutation_feature_importance_calibrated_model( + trained_calibrated_model: BaseModel, dataset: DatasetDict +): + explainer = PermutationFeatureImportance( + trained_calibrated_model, + scoring="accuracy", + n_repeats=5, + random_state=None, + max_samples_fraction=1.0, + ) + explanation = explainer.explain(copy.deepcopy(dataset)) + + assert all( + key in explanation + for key in ["features", "importances_mean", "importances_std"] + ) + for values in explanation.values(): + assert len(values) == len(INPUT_COLUMNS) + + +def test_partial_dependence_calibrated_model( + trained_calibrated_model: BaseModel, dataset +): + explainer = PartialDependence( + trained_calibrated_model, + grid_resolution=50, + lower_percentile=0.01, + upper_percentile=0.99, + ) + explanation = explainer.explain(copy.deepcopy(dataset)) + + metadata = explanation.pop("metadata") + assert set(metadata["target_names"]) == set(TARGETS) + assert len(explanation) == len(INPUT_COLUMNS) + + def test_partial_dependence(trained_model: BaseModel, dataset): parameters = { "grid_resolution": 50,