From 938f93fb43f34e722e98553a7af12d24df282a53 Mon Sep 17 00:00:00 2001 From: Samuel Hoffman Date: Thu, 9 Oct 2025 13:54:48 -0400 Subject: [PATCH] fix for sklearn tags feature Signed-off-by: Samuel Hoffman --- .github/workflows/ci.yml | 2 +- README.md | 6 ++--- aif360/sklearn/inprocessing/infairness.py | 9 ++++--- aif360/sklearn/postprocessing/__init__.py | 27 ++++++++++++------- .../calibrated_equalized_odds.py | 10 +++++-- .../reject_option_classification.py | 16 ++++++++--- aif360/sklearn/preprocessing/reweighing.py | 8 +++--- setup.py | 6 ++--- 8 files changed, 55 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 976479af..d98127de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.8, 3.9, '3.10', 3.11] + python-version: ['3.10', 3.11, 3.12, 3.13] env: UCI_DB: "https://archive.ics.uci.edu/ml/machine-learning-databases" diff --git a/README.md b/README.md index 0f0fab15..e853e3b7 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,9 @@ Supported Python Configurations: | OS | Python version | | ------- | -------------- | -| macOS | 3.8 – 3.11 | -| Ubuntu | 3.8 – 3.11 | -| Windows | 3.8 – 3.11 | +| macOS | 3.10 – 3.13 | +| Ubuntu | 3.10 – 3.13 | +| Windows | 3.10 – 3.13 | ### (Optional) Create a virtual environment diff --git a/aif360/sklearn/inprocessing/infairness.py b/aif360/sklearn/inprocessing/infairness.py index 99d32eec..ad78e08c 100644 --- a/aif360/sklearn/inprocessing/infairness.py +++ b/aif360/sklearn/inprocessing/infairness.py @@ -46,15 +46,16 @@ def __init__(self, *args, criterion, train_split=None, regression='auto', dataset=dataset, **kwargs) self.regression = regression - @property - def _estimator_type(self): + def __sklearn_tags__(self): + tags = super().__sklearn_tags__() if hasattr(self, "regression_"): - return 'regressor' if self.regression_ else 'classifier' + tags.estimator_type = 'regressor' if self.regression_ else 'classifier' elif self.regression != 'auto': - return 'regressor' if self.regression else 'classifier' + tags.estimator_type = 'regressor' if self.regression else 'classifier' else: raise NotFittedError("regression is set to 'auto'. Call 'fit' with " "appropriate arguments or set regression manually.") + return tags def get_loss(self, y_pred, y_true, X=None, training=False): """Return the loss for this batch. diff --git a/aif360/sklearn/postprocessing/__init__.py b/aif360/sklearn/postprocessing/__init__.py index b80719ea..a4bf7821 100644 --- a/aif360/sklearn/postprocessing/__init__.py +++ b/aif360/sklearn/postprocessing/__init__.py @@ -2,12 +2,20 @@ Post-processing algorithms modify predictions to be more fair (predictions in, predictions out). """ +from dataclasses import dataclass from logging import warning import pandas as pd from sklearn.base import BaseEstimator, MetaEstimatorMixin, clone from sklearn.model_selection import train_test_split from sklearn.utils.metaestimators import available_if +from sklearn.utils import get_tags, Tags + + +@dataclass +class PostProcessingTags(Tags): + requires_proba: bool = True + from aif360.sklearn.postprocessing.calibrated_equalized_odds import CalibratedEqualizedOdds from aif360.sklearn.postprocessing.reject_option_classification import RejectOptionClassifier, RejectOptionClassifierCV @@ -54,9 +62,10 @@ def __init__(self, estimator, postprocessor, *, prefit=False, val_size=0.25, self.val_size = val_size self.options = options - @property - def _estimator_type(self): - return self.postprocessor._estimator_type + def __sklearn_tags__(self): + tags = super().__sklearn_tags__() + tags.estimator_type = get_tags(self.postprocessor).estimator_type + return tags @property def classes_(self): @@ -85,8 +94,8 @@ def fit(self, X, y, sample_weight=None, **fit_params): self.estimator_ = self.estimator if self.prefit else clone(self.estimator) try: - use_proba = self.postprocessor._get_tags()['requires_proba'] - except KeyError: + use_proba = get_tags(self.postprocessor).requires_proba + except AttributeError: raise TypeError("`postprocessor` (type: {}) does not have a " "'requires_proba' tag.".format(type(self.estimator))) if use_proba and not hasattr(self.estimator, 'predict_proba'): @@ -145,7 +154,7 @@ def predict(self, X): Returns: numpy.ndarray: Predicted class label per sample. """ - use_proba = self.postprocessor_._get_tags()['requires_proba'] + use_proba = get_tags(self.postprocessor_).requires_proba y_score = (self.estimator_.predict_proba(X) if use_proba else self.estimator_.predict(X)) y_score = pd.DataFrame(y_score, index=X.index).squeeze('columns') @@ -169,7 +178,7 @@ def predict_proba(self, X): in the model, where classes are ordered as they are in ``self.classes_``. """ - use_proba = self.postprocessor_._get_tags()['requires_proba'] + use_proba = get_tags(self.postprocessor_).requires_proba y_score = (self.estimator_.predict_proba(X) if use_proba else self.estimator_.predict(X)) y_score = pd.DataFrame(y_score, index=X.index).squeeze('columns') @@ -193,7 +202,7 @@ def predict_log_proba(self, X): the model, where classes are ordered as they are in ``self.classes_``. """ - use_proba = self.postprocessor_._get_tags()['requires_proba'] + use_proba = get_tags(self.postprocessor_).requires_proba y_score = (self.estimator_.predict_proba(X) if use_proba else self.estimator_.predict(X)) y_score = pd.DataFrame(y_score, index=X.index).squeeze('columns') @@ -216,7 +225,7 @@ def score(self, X, y, sample_weight=None): Returns: float: Score value. """ - use_proba = self.postprocessor_._get_tags()['requires_proba'] + use_proba = get_tags(self.postprocessor_).requires_proba y_score = (self.estimator_.predict_proba(X) if use_proba else self.estimator_.predict(X)) y_score = pd.DataFrame(y_score, index=X.index).squeeze('columns') diff --git a/aif360/sklearn/postprocessing/calibrated_equalized_odds.py b/aif360/sklearn/postprocessing/calibrated_equalized_odds.py index c0ee1f53..ae27c676 100644 --- a/aif360/sklearn/postprocessing/calibrated_equalized_odds.py +++ b/aif360/sklearn/postprocessing/calibrated_equalized_odds.py @@ -1,3 +1,5 @@ +from dataclasses import asdict + import numpy as np from sklearn.base import BaseEstimator, ClassifierMixin from sklearn.utils import check_random_state @@ -5,6 +7,7 @@ from aif360.sklearn.metrics import difference, base_rate from aif360.sklearn.metrics import generalized_fnr, generalized_fpr +from aif360.sklearn.postprocessing import PostProcessingTags from aif360.sklearn.utils import check_inputs, check_groups @@ -67,8 +70,11 @@ def __init__(self, prot_attr=None, cost_constraint='weighted', self.cost_constraint = cost_constraint self.random_state = random_state - def _more_tags(self): - return {'requires_proba': True} + def __sklearn_tags__(self): + tags = super().__sklearn_tags__() + tags = PostProcessingTags(**asdict(tags)) + tags.requires_proba = True + return tags def _weighted_cost(self, y_true, probas_pred, pos_label=1, sample_weight=None): diff --git a/aif360/sklearn/postprocessing/reject_option_classification.py b/aif360/sklearn/postprocessing/reject_option_classification.py index 84d4a082..e8be09dd 100644 --- a/aif360/sklearn/postprocessing/reject_option_classification.py +++ b/aif360/sklearn/postprocessing/reject_option_classification.py @@ -1,3 +1,4 @@ +from dataclasses import asdict import warnings import numpy as np @@ -10,6 +11,7 @@ from aif360.sklearn.metrics import equal_opportunity_difference from aif360.sklearn.metrics import disparate_impact_ratio from aif360.sklearn.metrics import make_scorer +from aif360.sklearn.postprocessing import PostProcessingTags from aif360.sklearn.utils import check_groups @@ -90,8 +92,11 @@ def __init__(self, prot_attr=None, threshold=0.5, margin=0.1): self.threshold = threshold self.margin = margin - def _more_tags(self): - return {'requires_proba': True} + def __sklearn_tags__(self): + tags = super().__sklearn_tags__() + tags = PostProcessingTags(**asdict(tags)) + tags.requires_proba = True + return tags def fit(self, X, y, labels=None, pos_label=1, priv_group=1, sample_weight=None): @@ -284,8 +289,11 @@ def __init__(self, prot_attr=None, *, scoring, step=0.05, refit=True, **kwargs): super().__init__(RejectOptionClassifier(), {}, scoring=scoring, refit=refit, **kwargs) - def _more_tags(self): - return {'requires_proba': True} + def __sklearn_tags__(self): + tags = super().__sklearn_tags__() + tags = PostProcessingTags(**asdict(tags)) + tags.requires_proba = True + return tags def fit(self, X, y, **fit_params): """Run fit with all sets of parameters. diff --git a/aif360/sklearn/preprocessing/reweighing.py b/aif360/sklearn/preprocessing/reweighing.py index 069ff904..ae1e9d87 100644 --- a/aif360/sklearn/preprocessing/reweighing.py +++ b/aif360/sklearn/preprocessing/reweighing.py @@ -1,5 +1,6 @@ import numpy as np from sklearn.base import BaseEstimator, MetaEstimatorMixin, clone +from sklearn.utils import get_tags from sklearn.utils.metaestimators import available_if from sklearn.utils.validation import has_fit_parameter @@ -116,9 +117,10 @@ def __init__(self, estimator, reweigher=None): self.reweigher = reweigher self.estimator = estimator - @property - def _estimator_type(self): - return self.estimator._estimator_type + def __sklearn_tags__(self): + tags = super().__sklearn_tags__() + tags.estimator_type = get_tags(self.estimator).estimator_type + return tags @property def classes_(self): diff --git a/setup.py b/setup.py index 25fd4454..b68124e6 100644 --- a/setup.py +++ b/setup.py @@ -46,10 +46,10 @@ "Intended Audience :: Science/Research", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ], packages=find_packages(include=["aif360*"]), python_requires='>=3.8', @@ -57,7 +57,7 @@ 'numpy>=1.16', 'scipy>=1.2.0', 'pandas>=0.24.0', - 'scikit-learn>=1.0', + 'scikit-learn>=1.6', 'matplotlib', ], extras_require=extras,