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
1 change: 1 addition & 0 deletions docs/source/cuml-accel/faq.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``
Expand Down
5 changes: 5 additions & 0 deletions docs/source/cuml-accel/limitations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
^^^^^^^^^^^^^

Expand Down
5 changes: 5 additions & 0 deletions python/cuml/cuml/accel/_overrides/sklearn/preprocessing.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"MaxAbsScaler",
"PolynomialFeatures",
"TargetEncoder",
"LabelEncoder",
)


Expand Down Expand Up @@ -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.

Expand Down
42 changes: 1 addition & 41 deletions python/cuml/cuml/accel/_patches/sklearn/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
47 changes: 47 additions & 0 deletions python/cuml/cuml/accel/_patches/sklearn/utils/discovery.py
Original file line number Diff line number Diff line change
@@ -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]))
Comment thread
jcrist marked this conversation as resolved.

# Return the sorted list of estimators like the original
return sorted(set(estimators), key=itemgetter(0))
1 change: 1 addition & 0 deletions python/cuml/cuml/accel/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
43 changes: 32 additions & 11 deletions python/cuml/cuml/accel/estimator_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__

Expand All @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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()
Expand Down
22 changes: 21 additions & 1 deletion python/cuml/cuml/preprocessing/_label.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -57,6 +58,8 @@ class LabelEncoder(Base):
array(['apple', 'banana', 'grape'], dtype='<U6')
"""

_cpu_class_path = "sklearn.preprocessing.LabelEncoder"

def __init__(
self,
*,
Expand All @@ -78,6 +81,23 @@ def __sklearn_is_fitted__(self) -> 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 {}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 = (
Expand Down
11 changes: 11 additions & 0 deletions python/cuml/cuml_accel_tests/integration/test_preprocessing.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import pytest
from sklearn.datasets import make_blobs
from sklearn.preprocessing import (
LabelEncoder,
MaxAbsScaler,
MinMaxScaler,
PolynomialFeatures,
Expand Down Expand Up @@ -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)
Comment thread
jcrist marked this conversation as resolved.
9 changes: 8 additions & 1 deletion python/cuml/cuml_accel_tests/test_estimator_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
23 changes: 23 additions & 0 deletions python/cuml/tests/test_sklearn_import_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Loading