diff --git a/docs/source/cuml-accel/faq.rst b/docs/source/cuml-accel/faq.rst index b1c9ee2378..ff0d074d11 100644 --- a/docs/source/cuml-accel/faq.rst +++ b/docs/source/cuml-accel/faq.rst @@ -73,6 +73,7 @@ the following estimators are mostly or entirely accelerated when run with * ``sklearn.preprocessing.MinMaxScaler`` * ``sklearn.preprocessing.MaxAbsScaler`` * ``sklearn.preprocessing.PolynomialFeatures`` + * ``sklearn.preprocessing.LabelEncoder`` * ``sklearn.preprocessing.TargetEncoder`` * ``sklearn.svm.SVC`` * ``sklearn.svm.SVR`` diff --git a/docs/source/cuml-accel/limitations.rst b/docs/source/cuml-accel/limitations.rst index 57a8061095..fa0b991c9f 100644 --- a/docs/source/cuml-accel/limitations.rst +++ b/docs/source/cuml-accel/limitations.rst @@ -458,6 +458,11 @@ PolynomialFeatures - If ``order`` is ``"F"``. - When run on scikit-learn < 1.8. +LabelEncoder +^^^^^^^^^^^^ + +``LabelEncoder`` supports all cases and will never fall back to CPU. + TargetEncoder ^^^^^^^^^^^^^ diff --git a/python/cuml/cuml/accel/_overrides/sklearn/preprocessing.py b/python/cuml/cuml/accel/_overrides/sklearn/preprocessing.py index 145c3036f2..24940a9e6f 100644 --- a/python/cuml/cuml/accel/_overrides/sklearn/preprocessing.py +++ b/python/cuml/cuml/accel/_overrides/sklearn/preprocessing.py @@ -18,6 +18,7 @@ "MaxAbsScaler", "PolynomialFeatures", "TargetEncoder", + "LabelEncoder", ) @@ -53,6 +54,10 @@ def _params_from_cpu(model): return model.get_params(deep=False) +class LabelEncoder(ProxyBase): + _gpu_class = cuml.preprocessing.LabelEncoder + + def _check_targetencoder_y(y): """Check if inputs are supported on GPU. diff --git a/python/cuml/cuml/accel/_patches/sklearn/utils/__init__.py b/python/cuml/cuml/accel/_patches/sklearn/utils/__init__.py index 16439b5735..85747b153a 100644 --- a/python/cuml/cuml/accel/_patches/sklearn/utils/__init__.py +++ b/python/cuml/cuml/accel/_patches/sklearn/utils/__init__.py @@ -2,46 +2,6 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 # -import functools -from collections import defaultdict -from operator import itemgetter - -from sklearn.utils.discovery import all_estimators as _all_estimators - -from cuml.accel.estimator_proxy import is_proxy +from cuml.accel._patches.sklearn.utils.discovery import all_estimators __all__ = ("all_estimators",) - - -@functools.wraps(_all_estimators) -def all_estimators(*args, **kwargs): - # This function replaces sklearn's all_estimators function with a version - # that filters out duplicate estimator names, keeping only proxy estimators - # when both proxy and non-proxy versions exist. - # - # When the accelerator is installed, sklearn's all_estimators() returns - # duplicate entries for the same estimator name (e.g., LinearSVC appears - # both with sklearn.svm.LinearSVC and sklearn._classes.LinearSVC). This causes - # a TypeError during test collection when sklearn tries to sort the - # estimators as it attempts to sort based on the type. - - # Obtain the list of all estimators from sklearn - ret = _all_estimators(*args, **kwargs) - - # Group estimators by name - estimator_groups = defaultdict(list) - for name, cls in ret: - estimator_groups[name].append(cls) - - # Drop non-proxies wherever there are multiple classes with the same name - estimators = [] - for name, cls_list in estimator_groups.items(): - if len(cls_list) == 1: - estimators.append((name, cls_list[0])) - else: - proxied_cls = [cls for cls in cls_list if is_proxy(cls)] - assert len(proxied_cls) == 1 - estimators.append((name, proxied_cls[0])) - - # Return the sorted list of estimators like the original - return sorted(set(estimators), key=itemgetter(0)) diff --git a/python/cuml/cuml/accel/_patches/sklearn/utils/discovery.py b/python/cuml/cuml/accel/_patches/sklearn/utils/discovery.py new file mode 100644 index 0000000000..16439b5735 --- /dev/null +++ b/python/cuml/cuml/accel/_patches/sklearn/utils/discovery.py @@ -0,0 +1,47 @@ +# +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION. +# SPDX-License-Identifier: Apache-2.0 +# +import functools +from collections import defaultdict +from operator import itemgetter + +from sklearn.utils.discovery import all_estimators as _all_estimators + +from cuml.accel.estimator_proxy import is_proxy + +__all__ = ("all_estimators",) + + +@functools.wraps(_all_estimators) +def all_estimators(*args, **kwargs): + # This function replaces sklearn's all_estimators function with a version + # that filters out duplicate estimator names, keeping only proxy estimators + # when both proxy and non-proxy versions exist. + # + # When the accelerator is installed, sklearn's all_estimators() returns + # duplicate entries for the same estimator name (e.g., LinearSVC appears + # both with sklearn.svm.LinearSVC and sklearn._classes.LinearSVC). This causes + # a TypeError during test collection when sklearn tries to sort the + # estimators as it attempts to sort based on the type. + + # Obtain the list of all estimators from sklearn + ret = _all_estimators(*args, **kwargs) + + # Group estimators by name + estimator_groups = defaultdict(list) + for name, cls in ret: + estimator_groups[name].append(cls) + + # Drop non-proxies wherever there are multiple classes with the same name + estimators = [] + for name, cls_list in estimator_groups.items(): + if len(cls_list) == 1: + estimators.append((name, cls_list[0])) + else: + proxied_cls = [cls for cls in cls_list if is_proxy(cls)] + assert len(proxied_cls) == 1 + estimators.append((name, proxied_cls[0])) + + # Return the sorted list of estimators like the original + return sorted(set(estimators), key=itemgetter(0)) diff --git a/python/cuml/cuml/accel/core.py b/python/cuml/cuml/accel/core.py index 3dbf5a2338..22d15868e4 100644 --- a/python/cuml/cuml/accel/core.py +++ b/python/cuml/cuml/accel/core.py @@ -94,6 +94,7 @@ def debug(self, msg: str) -> None: "sklearn.pipeline", "sklearn.utils", "sklearn.utils._array_api", + "sklearn.utils.discovery", } ACCELERATED_MODULES = sorted(_OVERRIDES.union(_PATCHES)) diff --git a/python/cuml/cuml/accel/estimator_proxy.py b/python/cuml/cuml/accel/estimator_proxy.py index 6d77f82edd..54d23f669e 100644 --- a/python/cuml/cuml/accel/estimator_proxy.py +++ b/python/cuml/cuml/accel/estimator_proxy.py @@ -228,10 +228,22 @@ def __init_subclass__(cls, **kwargs: Any) -> None: # Wrap __init__ to ensure signature compatibility. orig_init = cls.__init__ + if cls._cpu_class.__init__ is object.__init__: + # XXX: Python < 3.13 `inspect.signature` has a bug where a wrapped + # version of `object.__init__` will display `*args, **kwargs`, + # while the original `object.__init__` won't. Here we special case + # estimators with not parameters to work around this. This can be + # removed once we drop support for Python < 3.13. + @functools.wraps(cls._cpu_class.__init__) + def __init__(self): + orig_init(self) + + del __init__.__wrapped__ + else: - @functools.wraps(cls._cpu_class.__init__) - def __init__(self, *args, **kwargs): - orig_init(self, *args, **kwargs) + @functools.wraps(cls._cpu_class.__init__) + def __init__(self, *args, **kwargs): + orig_init(self, *args, **kwargs) cls.__init__ = __init__ @@ -256,10 +268,22 @@ def __init__(self, *args, **kwargs): except AttributeError: pass - # Forward _estimator_type as a class attribute if available - _estimator_type = getattr(cls._cpu_class, "_estimator_type", None) - if isinstance(_estimator_type, str): - cls._estimator_type = _estimator_type + # Forward a few optional class attributes if defined. We do a type + # check on them for sanity and to avoid forwarding properties. + for name, typ in [ + ("_estimator_type", str), + ("_parameter_constraints", dict), + ]: + if isinstance(val := getattr(cls._cpu_class, name, None), typ): + setattr(cls, name, val) + + # All transformer _classes_ have `set_output` defined and gated with + # `@available_if`. If `get_feature_names_out` isn't defined, then + # `set_output` won't be available on an _instance_. We exclude + # `set_output` in that case. + exclude = set() + if not hasattr(cls._cpu_class, "get_feature_names_out"): + exclude.add("set_output") # Add proxy method definitions for all public methods on CPU class # that aren't already defined on the proxy class @@ -268,6 +292,7 @@ def __init__(self, *args, **kwargs): for name in dir(cls._cpu_class) if not name.startswith("_") and callable(getattr(cls._cpu_class, name)) + and name not in exclude ] def _make_method(name): @@ -645,10 +670,6 @@ def _metadata_request(self): def _estimator_type(self): return self._cpu._estimator_type - @classproperty - def _parameter_constraints(cls): - return cls._cpu_class._parameter_constraints - @classmethod def _get_param_names(cls): return cls._cpu_class._get_param_names() diff --git a/python/cuml/cuml/preprocessing/_label.py b/python/cuml/cuml/preprocessing/_label.py index f86ca5febe..fab8d6ac14 100644 --- a/python/cuml/cuml/preprocessing/_label.py +++ b/python/cuml/cuml/preprocessing/_label.py @@ -9,6 +9,7 @@ from cuml.common.doc_utils import generate_docstring from cuml.internals.array import CumlArray from cuml.internals.base import Base +from cuml.internals.interop import InteropMixin, UnsupportedOnCPU from cuml.internals.outputs import ( exit_internal_context, reflect, @@ -17,7 +18,7 @@ from cuml.internals.validation import check_cudf, check_is_fitted, check_y -class LabelEncoder(Base): +class LabelEncoder(Base, InteropMixin): """Encode target labels with values between 0 and n_classes - 1. This transformer should be used to encode target values (`y`) and not the @@ -57,6 +58,8 @@ class LabelEncoder(Base): array(['apple', 'banana', 'grape'], dtype=' bool: def _more_static_tags(): return {"X_types": ["1dlabels"]} + @classmethod + def _params_from_cpu(cls, model): + return {} + + def _params_to_cpu(self): + if self.handle_unknown != "error": + raise UnsupportedOnCPU( + f"`handle_unknown={self.handle_unknown}` is not supported" + ) + return {} + + def _attrs_from_cpu(self, model): + return {"classes_": model.classes_} + + def _attrs_to_cpu(self, model): + return {"classes_": self.classes_} + def _validate_keywords(self): if self.handle_unknown not in ("error", "ignore"): msg = ( diff --git a/python/cuml/cuml_accel_tests/integration/test_preprocessing.py b/python/cuml/cuml_accel_tests/integration/test_preprocessing.py index 1cc731bce1..e2a7a8ff1c 100644 --- a/python/cuml/cuml_accel_tests/integration/test_preprocessing.py +++ b/python/cuml/cuml_accel_tests/integration/test_preprocessing.py @@ -6,6 +6,7 @@ import pytest from sklearn.datasets import make_blobs from sklearn.preprocessing import ( + LabelEncoder, MaxAbsScaler, MinMaxScaler, PolynomialFeatures, @@ -107,3 +108,13 @@ def test_polynomial_features(): model.set_output(transform="pandas") out_df = model.transform(X) assert isinstance(out_df, pd.DataFrame) + + +def test_label_encoder(): + y = np.array(["a", "b", "a", "b"]) + enc = LabelEncoder() + y2 = enc.fit_transform(y) + np.testing.assert_array_equal(y2, np.array([0, 1, 0, 1])) + np.testing.assert_array_equal(enc.classes_, np.array(["a", "b"])) + y3 = enc.inverse_transform(y2) + np.testing.assert_array_equal(y3, y) diff --git a/python/cuml/cuml_accel_tests/test_estimator_proxy.py b/python/cuml/cuml_accel_tests/test_estimator_proxy.py index ae0c421bbf..f35d3d9ff2 100644 --- a/python/cuml/cuml_accel_tests/test_estimator_proxy.py +++ b/python/cuml/cuml_accel_tests/test_estimator_proxy.py @@ -28,7 +28,7 @@ from sklearn.model_selection import GridSearchCV from sklearn.neighbors import NearestNeighbors from sklearn.pipeline import Pipeline -from sklearn.preprocessing import StandardScaler +from sklearn.preprocessing import LabelEncoder, StandardScaler from cuml.accel import is_proxy from cuml.accel.estimator_proxy import ProxyBase @@ -78,6 +78,13 @@ def test_method_metadata(): assert inspect.signature(LogisticRegression.fit) == cpu_sig +def test_init_no_parameters_signature(): + """On Python < 3.13 `inspect.signature` has a bug that led to `__init__`s + of proxies with no parameters mistakenly having `*args, **kwargs` in + the signature. This test checks that we successfully work around that.""" + assert not inspect.signature(LabelEncoder).parameters + + def test_sklearn_introspect_estimator_type(): if not SKLEARN_18: assert LogisticRegression._estimator_type == "classifier" diff --git a/python/cuml/tests/test_sklearn_import_export.py b/python/cuml/tests/test_sklearn_import_export.py index 0d39035186..e3ca75b356 100644 --- a/python/cuml/tests/test_sklearn_import_export.py +++ b/python/cuml/tests/test_sklearn_import_export.py @@ -8,6 +8,7 @@ import scipy.sparse import sklearn import sklearn.kernel_ridge +import sklearn.preprocessing import sklearn.svm import umap from numpy.testing import assert_allclose @@ -1018,3 +1019,25 @@ def test_target_encoder(random_state): assert array_equal(original_output, sklearn_output) assert array_equal(original_output, roundtrip_output) + + +def test_label_encoder(): + y = np.array(["a", "b", "b", "a"]) + cu_model = cuml.preprocessing.LabelEncoder().fit(y) + sk_model = sklearn.preprocessing.LabelEncoder().fit(y) + + cu_model2 = cuml.preprocessing.LabelEncoder.from_sklearn(sk_model) + sk_model2 = cu_model.as_sklearn() + + roundtrip = cuml.preprocessing.LabelEncoder.from_sklearn(sk_model2) + assert_roundtrip_consistency(cu_model, roundtrip) + + np.testing.assert_array_equal(cu_model.classes_, sk_model2.classes_) + np.testing.assert_array_equal(cu_model.classes_, roundtrip.classes_) + + sol = np.array([0, 1, 1, 0]) + cu_out = cu_model2.transform(y) + sk_out = sk_model2.transform(y) + + np.testing.assert_array_equal(cu_out, sol) + np.testing.assert_array_equal(sk_out, sol)