Skip to content
Closed
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
34 changes: 34 additions & 0 deletions DashAI/back/models/scikit_learn/linear_svc_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
34 changes: 34 additions & 0 deletions DashAI/back/models/scikit_learn/sgd_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
52 changes: 52 additions & 0 deletions tests/back/explainers/test_explainers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading