Skip to content

Commit e350c8c

Browse files
authored
Add cuml.accel support for StandardScaler (#7766)
## Summary Adds GPU acceleration support for `sklearn.preprocessing.StandardScaler` via `cuml.accel`. Closes #7765 ## Changes - Implemented `InteropMixin` in StandardScaler for CPU/GPU model conversion - Added `StandardScaler` proxy wrapper with automatic GPU/CPU fallback - Updated documentation (FAQ, limitations) - Added basic test coverage ## GPU Fallback (CPU used for) - `partial_fit()` - incremental learning not supported - `sample_weight` parameter - weighted statistics not supported - Complex/object dtypes - not supported on GPU Authors: - Simon Adorf (https://github.com/csadorf) Approvers: - Tim Head (https://github.com/betatim) URL: #7766
1 parent 6e1365e commit e350c8c

9 files changed

Lines changed: 299 additions & 9 deletions

File tree

docs/source/cuml-accel/faq.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ the following estimators are mostly or entirely accelerated when run with
6868
* ``sklearn.neighbors.KNeighborsClassifier``
6969
* ``sklearn.neighbors.KNeighborsRegressor``
7070
* ``sklearn.neighbors.KernelDensity``
71+
* ``sklearn.preprocessing.StandardScaler``
7172
* ``sklearn.preprocessing.TargetEncoder``
7273
* ``sklearn.svm.SVC``
7374
* ``sklearn.svm.SVR``

docs/source/cuml-accel/limitations.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,16 @@ Additional notes:
402402
sklearn.preprocessing
403403
---------------------
404404

405+
StandardScaler
406+
^^^^^^^^^^^^^^
407+
408+
``StandardScaler`` will fall back to CPU in the following cases:
409+
410+
- If ``partial_fit`` is called (incremental learning not supported on GPU).
411+
- If ``sample_weight`` is provided (weighted statistics not supported on GPU).
412+
- If ``X`` has object dtype, half precision (``float16``) dtype, or complex dtype (``complex64``, ``complex128``).
413+
- If ``X`` is a sparse matrix with integer dtype or in a format other than CSR or CSC.
414+
405415
TargetEncoder
406416
^^^^^^^^^^^^^
407417

python/cuml/cuml/_thirdparty/sklearn/preprocessing/_data.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
# SPDX-FileCopyrightText: Eric Martin <eric@ericmart.in>
66
# SPDX-FileCopyrightText: Giorgio Patrini <giorgio.patrini@anu.edu.au>
77
# SPDX-FileCopyrightText: Eric Chang <ericchang2017@u.northwestern.edu>
8-
# SPDX-FileCopyrightText: Copyright (c) 2020-2025, NVIDIA CORPORATION.
8+
# SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION.
99
# SPDX-License-Identifier: Apache-2.0 AND BSD-3-Clause
1010

1111
# Original authors from Sckit-Learn:
@@ -40,6 +40,7 @@
4040
SparseInputTagMixin,
4141
StatelessTagMixin,
4242
)
43+
from cuml.internals.interop import InteropMixin, to_cpu, to_gpu
4344

4445
from ....common.array_descriptor import CumlArrayDescriptor
4546
from ....internals.array import CumlArray
@@ -519,6 +520,7 @@ def minmax_scale(X, feature_range=(0, 1), *, axis=0, copy=True):
519520

520521
class StandardScaler(TransformerMixin,
521522
BaseEstimator,
523+
InteropMixin,
522524
AllowNaNTagMixin,
523525
SparseInputTagMixin):
524526
"""Standardize features by removing the mean and scaling to unit variance
@@ -658,6 +660,47 @@ def _get_param_names(cls):
658660
"copy"
659661
]
660662

663+
# InteropMixin requirements
664+
_cpu_class_path = "sklearn.preprocessing.StandardScaler"
665+
666+
@classmethod
667+
def _params_from_cpu(cls, model):
668+
"""Convert sklearn StandardScaler hyperparameters to cuML format."""
669+
return {
670+
"copy": model.copy,
671+
"with_mean": model.with_mean,
672+
"with_std": model.with_std,
673+
}
674+
675+
def _params_to_cpu(self):
676+
"""Convert cuML StandardScaler hyperparameters to sklearn format."""
677+
return {
678+
"copy": self.copy,
679+
"with_mean": self.with_mean,
680+
"with_std": self.with_std,
681+
}
682+
683+
def _attrs_from_cpu(self, model):
684+
"""Convert sklearn StandardScaler fitted attributes to cuML format."""
685+
attrs = {
686+
"mean_": to_gpu(mean) if (mean := getattr(model, "mean_", None)) is not None else None,
687+
"var_": to_gpu(var) if (var := getattr(model, "var_", None)) is not None else None,
688+
"scale_": to_gpu(scale) if (scale := getattr(model, "scale_", None)) is not None else None,
689+
"n_samples_seen_": to_gpu(nss) if (nss := getattr(model, "n_samples_seen_", None)) is not None else None,
690+
}
691+
return {**attrs, **super()._attrs_from_cpu(model)}
692+
693+
def _attrs_to_cpu(self, model):
694+
"""Convert cuML StandardScaler fitted attributes to sklearn format."""
695+
696+
attrs = {
697+
"mean_": to_cpu(mean) if (mean := getattr(self, "mean_", None)) is not None else None,
698+
"var_": to_cpu(var) if (var := getattr(self, "var_", None)) is not None else None,
699+
"scale_": to_cpu(scale) if (scale := getattr(self, "scale_", None)) is not None else None,
700+
"n_samples_seen_": None if (nss := getattr(self, "n_samples_seen_", None)) is None else cpu_np.int64(nss) if cpu_np.isscalar(nss) else to_cpu(nss),
701+
}
702+
return {**attrs, **super()._attrs_to_cpu(model)}
703+
661704
@reflect(reset=True)
662705
def fit(self, X, y=None) -> "StandardScaler":
663706
"""Compute the mean and std to be used for later scaling.

python/cuml/cuml/_thirdparty/sklearn/utils/skl_dependencies.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def _check_n_features(self, X, reset):
6565
)
6666
if n_features != self.n_features_in_:
6767
raise ValueError(
68-
'X has {} features, but this {} is expecting {} features '
68+
'X has {} features, but {} is expecting {} features '
6969
'as input.'.format(n_features, self.__class__.__name__,
7070
self.n_features_in_)
7171
)

python/cuml/cuml/accel/_wrappers/sklearn/preprocessing.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,72 @@
33
# SPDX-License-Identifier: Apache-2.0
44
#
55

6+
import cupyx.scipy.sparse as cupy_sparse
67
import numpy as np
8+
from scipy import sparse as sp_sparse
79

810
import cuml.preprocessing
911
from cuml.accel.estimator_proxy import ProxyBase
1012
from cuml.internals.interop import UnsupportedOnGPU
1113

12-
__all__ = ("TargetEncoder",)
14+
__all__ = ("StandardScaler", "TargetEncoder")
15+
16+
17+
def _check_standardscaler_unsupported_inputs(X, **kwargs):
18+
"""Check if inputs are supported by cuML's StandardScaler on GPU.
19+
20+
Raises UnsupportedOnGPU for unsupported cases to trigger CPU fallback.
21+
"""
22+
if kwargs.get("sample_weight") is not None:
23+
raise UnsupportedOnGPU("sample_weight is not supported")
24+
25+
# Reject complex, object, and float16 dtypes
26+
if hasattr(X, "dtype"):
27+
if np.issubdtype(X.dtype, np.complexfloating):
28+
raise UnsupportedOnGPU("complex dtype is not supported")
29+
if X.dtype == np.object_:
30+
raise UnsupportedOnGPU("object dtype is not supported")
31+
if X.dtype == np.float16:
32+
raise UnsupportedOnGPU("float16 dtype is not supported")
33+
34+
# Check for sparse matrices with unsupported properties
35+
if sp_sparse.issparse(X):
36+
if np.issubdtype(X.dtype, np.integer):
37+
raise UnsupportedOnGPU(
38+
"sparse matrix with integer dtype is not supported"
39+
)
40+
# cuML's StandardScaler algorithm only supports CSR/CSC formats.
41+
if X.format not in ("csr", "csc"):
42+
raise UnsupportedOnGPU(
43+
f"sparse matrix format '{X.format}' is not supported"
44+
)
45+
elif cupy_sparse.issparse(X):
46+
if np.issubdtype(X.dtype, np.integer):
47+
raise UnsupportedOnGPU(
48+
"sparse matrix with integer dtype is not supported"
49+
)
50+
# cuML's StandardScaler algorithm only supports CSR/CSC formats.
51+
if X.format not in ("csr", "csc"):
52+
raise UnsupportedOnGPU(
53+
f"sparse matrix format '{X.format}' is not supported"
54+
)
55+
56+
57+
class StandardScaler(ProxyBase):
58+
_gpu_class = cuml.preprocessing.StandardScaler
59+
60+
def _gpu_fit(self, X, y=None, sample_weight=None):
61+
kwargs = {"sample_weight": sample_weight}
62+
_check_standardscaler_unsupported_inputs(X, **kwargs)
63+
return self._gpu.fit(X, y)
64+
65+
def _gpu_fit_transform(self, X, y=None, **fit_params):
66+
_check_standardscaler_unsupported_inputs(X, **fit_params)
67+
return self._gpu.fit_transform(X, y, **fit_params)
68+
69+
def _gpu_partial_fit(self, X, y=None, sample_weight=None):
70+
"""partial_fit not supported on GPU - always fall back to CPU."""
71+
raise UnsupportedOnGPU("partial_fit not supported on GPU")
1372

1473

1574
def _check_unsupported_inputs(X, y, cpu_model):

python/cuml/cuml/accel/estimator_proxy.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#
2-
# SPDX-FileCopyrightText: Copyright (c) 2024-2025, NVIDIA CORPORATION.
2+
# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION.
33
# SPDX-License-Identifier: Apache-2.0
44
from __future__ import annotations
55

@@ -8,7 +8,11 @@
88

99
import sklearn
1010
from packaging.version import Version
11-
from sklearn.base import BaseEstimator, ClassNamePrefixFeaturesOutMixin
11+
from sklearn.base import (
12+
BaseEstimator,
13+
ClassNamePrefixFeaturesOutMixin,
14+
OneToOneFeatureMixin,
15+
)
1216
from sklearn.utils._set_output import _wrap_data_with_container
1317

1418
from cuml.accel import profilers
@@ -335,12 +339,16 @@ def _gpu_set_output(self, *, transform=None):
335339

336340
def _gpu_get_feature_names_out(self, input_features=None):
337341
# In the common case `get_feature_names_out` doesn't require fitted attributes
338-
# on the CPU. Here we detect and special case a common mixin, falling back to
342+
# on the CPU. Here we detect and special case common mixins, falling back to
339343
# CPU when necessary. This helps avoid unnecessary device -> host transfers.
340344
cpu_method = self._cpu_class.get_feature_names_out
341345
if cpu_method is ClassNamePrefixFeaturesOutMixin.get_feature_names_out:
342346
# Can run cpu method directly on GPU instance, it only references `_n_features_out`
343347
return cpu_method(self._gpu, input_features=input_features)
348+
if cpu_method is OneToOneFeatureMixin.get_feature_names_out:
349+
# Uses n_features_in_ (and optionally feature_names_in_) on the estimator.
350+
# cuML models set n_features_in_ on fit; feature_names_in_ is optional.
351+
return cpu_method(self._gpu, input_features=input_features)
344352

345353
# Fallback to CPU
346354
raise UnsupportedOnGPU

python/cuml/cuml/thirdparty_adapters/adapters.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#
2-
# SPDX-FileCopyrightText: Copyright (c) 2020-2025, NVIDIA CORPORATION.
2+
# SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION.
33
# SPDX-License-Identifier: Apache-2.0
44
#
55
import cudf
@@ -9,6 +9,7 @@
99
import pandas as pd
1010
from scipy import sparse as cpu_sparse
1111

12+
from cuml.accel import enabled as cuml_accel_enabled
1213
from cuml.internals.input_utils import input_to_cupy_array
1314

1415
numeric_types = [
@@ -227,6 +228,31 @@ def check_array(
227228
array_converted : object
228229
The converted and validated array.
229230
"""
231+
# Convert list-like inputs to numpy arrays early for compatibility with cuml.accel
232+
# This ensures downstream functions can safely access .dtype and other array attributes
233+
if (
234+
cuml_accel_enabled()
235+
and not isinstance(
236+
array,
237+
(
238+
np.ndarray,
239+
pd.DataFrame,
240+
cudf.DataFrame,
241+
pd.Series,
242+
cudf.Series,
243+
cp.ndarray,
244+
),
245+
)
246+
and not (cpu_sparse.issparse(array) or gpu_sparse.issparse(array))
247+
and not hasattr(array, "__cuda_array_interface__")
248+
):
249+
# Check if it's array-like (just like scikit-learn's check_array)
250+
if (
251+
hasattr(array, "__len__")
252+
or hasattr(array, "shape")
253+
or hasattr(array, "__array__")
254+
):
255+
array = np.asarray(array)
230256

231257
if dtype == "numeric":
232258
dtype = numeric_types
@@ -250,7 +276,18 @@ def check_array(
250276
hasshape = hasattr(array, "shape")
251277
if ensure_2d and hasshape:
252278
if len(array.shape) != 2:
253-
raise ValueError("Not 2D")
279+
if len(array.shape) == 1:
280+
raise ValueError(
281+
f"Expected 2D array, got 1D array instead:\narray={array!r}.\n"
282+
"Reshape your data either using array.reshape(-1, 1) if "
283+
"your data has a single feature or array.reshape(1, -1) "
284+
"if it contains a single sample."
285+
)
286+
else:
287+
raise ValueError(
288+
f"Expected 2D array, got {len(array.shape)}D array instead:\n"
289+
f"array shape: {array.shape}.\n"
290+
)
254291

255292
if not allow_nd and hasshape:
256293
if len(array.shape) > 2:

python/cuml/cuml_accel_tests/test_basic_estimators.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
KNeighborsRegressor,
1818
NearestNeighbors,
1919
)
20-
from sklearn.preprocessing import TargetEncoder
20+
from sklearn.preprocessing import StandardScaler, TargetEncoder
2121

2222

2323
def test_kmeans():
@@ -44,6 +44,34 @@ def test_truncated_svd():
4444
svd.transform(X)
4545

4646

47+
def test_standard_scaler():
48+
import numpy as np
49+
50+
X, _ = make_blobs(n_samples=100, centers=3, random_state=42)
51+
scaler = StandardScaler().fit(X)
52+
53+
# Check fitted attributes exist
54+
assert hasattr(scaler, "mean_")
55+
assert hasattr(scaler, "var_")
56+
assert hasattr(scaler, "scale_")
57+
assert scaler.mean_.shape == (X.shape[1],)
58+
assert scaler.var_.shape == (X.shape[1],)
59+
assert scaler.scale_.shape == (X.shape[1],)
60+
61+
# Transform and check shape
62+
X_transformed = scaler.transform(X)
63+
assert X_transformed.shape == X.shape
64+
65+
# Check that transformed data has mean ≈ 0 and std ≈ 1
66+
assert np.allclose(X_transformed.mean(axis=0), 0, atol=1e-7)
67+
assert np.allclose(X_transformed.std(axis=0), 1, atol=1e-7)
68+
69+
# Check inverse transform
70+
X_inverse = scaler.inverse_transform(X_transformed)
71+
assert X_inverse.shape == X.shape
72+
assert np.allclose(X_inverse, X, atol=1e-6)
73+
74+
4775
def test_linear_regression():
4876
X, y = make_regression(
4977
n_samples=100, n_features=20, noise=0.1, random_state=42

0 commit comments

Comments
 (0)