From 2ac4fb7dfb19ae57b1e0098a514249145f373076 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Mon, 13 Apr 2026 15:15:09 -0500 Subject: [PATCH 01/29] Add `check_consistent_length` --- python/cuml/cuml/internals/validation.py | 17 +++++++++++++++++ python/cuml/tests/test_validation.py | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py index 9bed8f48fa..c4021a1a22 100644 --- a/python/cuml/cuml/internals/validation.py +++ b/python/cuml/cuml/internals/validation.py @@ -15,6 +15,7 @@ "check_is_fitted", "check_random_seed", "check_features", + "check_consistent_length", ) @@ -222,3 +223,19 @@ def check_features(estimator, X, reset=False) -> None: f"X has {n_features} features, but {estimator.__class__.__name__} " f"is expecting {estimator.n_features_in_} features as input." ) + + +def check_consistent_length(*arrays) -> None: + """Check whether all inputs have the same number of samples. + + Parameters + ---------- + *arrays : array or None + The input variables to validate. None-values are ignored. + """ + lengths = [X.shape[0] for X in arrays if X is not None] + if len(set(lengths)) > 1: + raise ValueError( + f"Found input variables with inconsistent number of samples: " + f"{sorted(int(n) for n in lengths)}" + ) diff --git a/python/cuml/tests/test_validation.py b/python/cuml/tests/test_validation.py index ac09ca1084..c0b34dfd02 100644 --- a/python/cuml/tests/test_validation.py +++ b/python/cuml/tests/test_validation.py @@ -12,6 +12,7 @@ from cuml.internals.validation import ( _get_feature_names, _get_n_features, + check_consistent_length, check_features, check_random_seed, ) @@ -239,3 +240,21 @@ def test_feature_names_mismatch_errors(): with pytest.raises(ValueError, match="The feature names") as rec: model.predict(bad) assert "Feature names must be in the same order" in str(rec.value) + + +def test_check_consistent_length(): + y3 = np.empty(3) + y4 = np.empty(4) + x34 = np.empty((3, 4)) + + check_consistent_length() + check_consistent_length(None) + check_consistent_length(x34) + check_consistent_length(x34, y3) + check_consistent_length(x34, None, y3) + + with pytest.raises( + ValueError, + match=r"Found input variables with inconsistent number of samples: \[3, 4\]", + ): + check_consistent_length(x34, y4) From 131f6db61186ad97b11f2834e5e423ded2f778e1 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Mon, 13 Apr 2026 15:48:26 -0500 Subject: [PATCH 02/29] Add `check_all_finite` --- python/cuml/cuml/internals/validation.py | 73 ++++++++++++++++++++++++ python/cuml/tests/test_validation.py | 62 ++++++++++++++++++++ 2 files changed, 135 insertions(+) diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py index c4021a1a22..f1552cee73 100644 --- a/python/cuml/cuml/internals/validation.py +++ b/python/cuml/cuml/internals/validation.py @@ -7,8 +7,10 @@ import cudf import cupy as cp +import cupyx.scipy.sparse as cp_sp import numpy as np import pandas as pd +import scipy.sparse as sp from sklearn.utils.validation import check_is_fitted __all__ = ( @@ -239,3 +241,74 @@ def check_consistent_length(*arrays) -> None: f"Found input variables with inconsistent number of samples: " f"{sorted(int(n) for n in lengths)}" ) + + +_cupy_all_finite = cp.ReductionKernel( + "T x", + "bool out", + "isfinite(x)", + "a && b", + "out = a", + "true", + "all_finite", +) + + +_cupy_all_finite_or_nan = cp.ReductionKernel( + "T x", + "bool out", + "isinf(x)", + "a || b", + "out = !a", + "false", + "all_finite_or_nan", +) + + +def check_all_finite(array, *, allow_nan=False, input_name=None) -> None: + """Check if all input values are finite. + + Parameters + ---------- + array : dense or sparse array + The array to check. + allow_nan : bool, default=False + Whether to allow NaN values. + input_name : str or None, default=None + The input parameter name to use in error messages. + """ + if not np.isdtype(array.dtype, "real floating"): + # No-op for non floating inputs + return + + if cp_sp.issparse(array) or sp.issparse(array): + array = array.data + + if not array.size: + # No-op for empty inputs + return + + if isinstance(array, cp.ndarray): + if allow_nan: + ok = _cupy_all_finite_or_nan(array) + else: + ok = _cupy_all_finite(array) + else: + # First try an O(1) space solution for the common case + with np.errstate(over="ignore"): + x_sum = array.sum() + if np.isfinite(x_sum): + ok = True + elif not allow_nan and np.isnan(x_sum): + ok = False + else: + # Maybe overflow or nan in data, fallback to O(n) path + if allow_nan: + ok = not np.isinf(array).any() + else: + ok = np.isfinite(array).all() + if not ok: + kind = "infinite" if allow_nan else "NaN or infinite" + raise ValueError( + f"Input {input_name or 'array'} contains {kind} values" + ) diff --git a/python/cuml/tests/test_validation.py b/python/cuml/tests/test_validation.py index c0b34dfd02..9594dc3cab 100644 --- a/python/cuml/tests/test_validation.py +++ b/python/cuml/tests/test_validation.py @@ -5,6 +5,7 @@ import cudf import cupy as cp +import cupyx.scipy.sparse as cp_sp import numpy as np import pandas as pd import pytest @@ -12,6 +13,7 @@ from cuml.internals.validation import ( _get_feature_names, _get_n_features, + check_all_finite, check_consistent_length, check_features, check_random_seed, @@ -258,3 +260,63 @@ def test_check_consistent_length(): match=r"Found input variables with inconsistent number of samples: \[3, 4\]", ): check_consistent_length(x34, y4) + + +@pytest.mark.parametrize("device", [True, False]) +@pytest.mark.parametrize("sparse_format", [None, "csr", "coo"]) +def test_check_all_finite(device, sparse_format): + def array(values, dtype=None): + x = cp.array(values, dtype=dtype) + if sparse_format is not None: + x = getattr(cp_sp, f"{sparse_format}_matrix")(x) + if not device: + x = x.get() + return x + + non_floating = array([True, False, True], dtype="bool") + f32_empty = array([], dtype="float32") + f32_good = array([1.5, -1.5, 2.5], dtype="float32") + f32_nan = array([1.5, float("nan"), 2.5], dtype="float32") + f32_inf = array([1.5, float("inf"), 2.5], dtype="float32") + f64_both = array([[1.5, float("inf"), float("nan")]], dtype="float64") + + check_all_finite(non_floating) + check_all_finite(f32_empty) + check_all_finite(f32_good, allow_nan=False) + check_all_finite(f32_good, allow_nan=True) + check_all_finite(f32_nan, allow_nan=True) + + with pytest.raises( + ValueError, match="Input X contains NaN or infinite values" + ): + check_all_finite(f32_nan, allow_nan=False, input_name="X") + + with pytest.raises( + ValueError, match="Input array contains infinite values" + ): + check_all_finite(f32_inf, allow_nan=True) + + with pytest.raises( + ValueError, match="Input array contains NaN or infinite values" + ): + check_all_finite(f64_both) + + +def test_check_all_finite_host_fallback(): + x_good = np.array([1e307] * 100, dtype="float64") + x_nan = np.array([1e307] * 99 + [float("nan")], dtype="float64") + x_inf = np.array([1e307] * 99 + [float("inf")], dtype="float64") + + check_all_finite(x_good) + check_all_finite(x_good, allow_nan=True) + check_all_finite(x_nan, allow_nan=True) + + with pytest.raises( + ValueError, match="Input array contains NaN or infinite values" + ): + check_all_finite(x_nan) + + with pytest.raises( + ValueError, match="Input array contains infinite values" + ): + check_all_finite(x_inf, allow_nan=True) From 532dc27412063eca76bb2cc013155e0b0ca478f9 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Mon, 13 Apr 2026 16:00:15 -0500 Subject: [PATCH 03/29] Add `check_non_negative` --- python/cuml/cuml/internals/validation.py | 20 +++++++++++++++ python/cuml/tests/test_validation.py | 31 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py index f1552cee73..49d76bff30 100644 --- a/python/cuml/cuml/internals/validation.py +++ b/python/cuml/cuml/internals/validation.py @@ -18,6 +18,8 @@ "check_random_seed", "check_features", "check_consistent_length", + "check_all_finite", + "check_non_negative", ) @@ -312,3 +314,21 @@ def check_all_finite(array, *, allow_nan=False, input_name=None) -> None: raise ValueError( f"Input {input_name or 'array'} contains {kind} values" ) + + +def check_non_negative(array, *, input_name=None): + """Check if all input values are non-negative. + + Parameters + ---------- + array : dense or sparse array + The array to check. + input_name : str or None, default=None + The input parameter name to use in error messages. + """ + if cp_sp.issparse(array) or sp.issparse(array): + array = array.data + xp = cp if isinstance(array, cp.ndarray) else np + if array.size != 0 and xp.nanmin(array) < 0: + suffix = f" passed to {input_name}" if input_name is not None else "" + raise ValueError(f"Negative values in data{suffix}") diff --git a/python/cuml/tests/test_validation.py b/python/cuml/tests/test_validation.py index 9594dc3cab..7fe9d9e483 100644 --- a/python/cuml/tests/test_validation.py +++ b/python/cuml/tests/test_validation.py @@ -16,6 +16,7 @@ check_all_finite, check_consistent_length, check_features, + check_non_negative, check_random_seed, ) @@ -320,3 +321,33 @@ def test_check_all_finite_host_fallback(): ValueError, match="Input array contains infinite values" ): check_all_finite(x_inf, allow_nan=True) + + +@pytest.mark.parametrize("device", [True, False]) +@pytest.mark.parametrize("sparse_format", [None, "csr", "coo"]) +def test_check_non_negative(device, sparse_format): + def array(values, dtype=None): + x = cp.array(values, dtype=dtype) + if sparse_format is not None: + x = getattr(cp_sp, f"{sparse_format}_matrix")(x) + if not device: + x = x.get() + return x + + f32_empty = array([], dtype="float32") + f32_good = array([0, 1, 2], dtype="float32") + f64_good_nan = array([0, float("nan"), 1], dtype="float64") + f32_bad = array([-1, 1, 2], dtype="float32") + f64_bad_nan = array([-1, float("nan"), 1], dtype="float64") + + check_non_negative(f32_empty) + check_non_negative(f32_good) + check_non_negative(f64_good_nan) + + with pytest.raises(ValueError, match="Negative values in data"): + check_non_negative(f32_bad) + + with pytest.raises( + ValueError, match="Negative values in data passed to X" + ): + check_non_negative(f64_bad_nan, input_name="X") From 6f02b7ed89e54456ccfd7daa9adb195ac7f214e4 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Mon, 13 Apr 2026 16:06:46 -0500 Subject: [PATCH 04/29] Add `_check_shape` --- python/cuml/cuml/internals/validation.py | 85 ++++++++++++++++++------ 1 file changed, 64 insertions(+), 21 deletions(-) diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py index 49d76bff30..4545ad8f54 100644 --- a/python/cuml/cuml/internals/validation.py +++ b/python/cuml/cuml/internals/validation.py @@ -61,6 +61,68 @@ def check_random_seed(random_state) -> int: return int(randint(low=0, high=2**32, dtype=np.uint32)) +def _check_shape( + shape, + *, + ensure_2d=True, + ensure_min_samples=0, + ensure_min_features=0, + array_type=None, +) -> None: + """Check that an input shape is as expected. + + Parameters + ---------- + shape : tuple + The array shape. + ensure_2d : bool, default=True + If True, only 2D arrays are accepted. Otherwise accepts 1D or 2D + arrays. + ensure_min_samples : int, default=0 + A minimum number of samples to require. Defaults to 0 for no minimum. + ensure_min_features : int, default=0 + A minimum number of features to require. Defaults to 0 for no minimum. + array_type : type or None, default=None + The type of the array-like object. Used in error messages. + """ + ndim = len(shape) + + if ndim == 0 or ndim == 1 and ensure_2d: + if issubclass(array_type, (cudf.Series, pd.Series)): + msg = ( + f"Expected a 2-dimensional container but got {array_type.__name__} " + "instead. Pass a DataFrame containing a single row (i.e. " + "single sample) or a single column (i.e. single feature) " + "instead." + ) + else: + kind = "scalar" if ndim == 0 else f"{ndim}D" + msg = ( + f"Expected 2D array, got {kind} array instead. Reshape your data " + "using array.reshape(-1, 1) if your data has a single feature, " + "or array.reshape(1, -1) if it contains a single sample." + ) + raise ValueError(msg) + elif ndim > 2: + raise ValueError(f"Expected 2D array, got {ndim}D array instead.") + + if ensure_min_samples > 0: + n_samples = shape[0] + if n_samples < ensure_min_samples: + raise ValueError( + f"Found array with {n_samples} sample(s) (shape={shape}) " + f"while a minimum of {ensure_min_samples} is required." + ) + + if ensure_min_features > 0 and ndim == 2: + n_features = shape[1] + if n_features < ensure_min_features: + raise ValueError( + f"Found array with {n_features} feature(s) (shape={shape}) " + f"while a minimum of {ensure_min_features} is required." + ) + + def _get_n_features(X): if isinstance(X, (list, tuple)): if len(X) == 0: @@ -86,26 +148,7 @@ def _get_n_features(X): else: shape = np.asarray(X).shape - ndim = len(shape) - - if ndim < 2: - if isinstance(X, (cudf.Series, pd.Series)): - msg = ( - f"Expected a 2-dimensional container but got {type(X).__name__} " - "instead. Pass a DataFrame containing a single row (i.e. " - "single sample) or a single column (i.e. single feature) " - "instead." - ) - else: - kind = "scalar" if ndim == 0 else f"{ndim}D" - msg = ( - f"Expected 2D array, got {kind} array instead. Reshape your data " - "using array.reshape(-1, 1) if your data has a single feature, " - "or array.reshape(1, -1) if it contains a single sample." - ) - raise ValueError(msg) - elif ndim > 2: - raise ValueError(f"Expected 2D array, got {ndim}D array instead.") + _check_shape(shape, ensure_2d=True, array_type=type(X)) return shape[1] @@ -316,7 +359,7 @@ def check_all_finite(array, *, allow_nan=False, input_name=None) -> None: ) -def check_non_negative(array, *, input_name=None): +def check_non_negative(array, *, input_name=None) -> None: """Check if all input values are non-negative. Parameters From 174dcd8ad926f16236fc3bf022dd2a84ce3428f6 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Tue, 14 Apr 2026 11:01:37 -0500 Subject: [PATCH 05/29] Skip `check_all_finite` if `assume_finite=True` --- python/cuml/cuml/internals/validation.py | 8 ++++++++ python/cuml/tests/test_validation.py | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py index 4545ad8f54..123d12ba06 100644 --- a/python/cuml/cuml/internals/validation.py +++ b/python/cuml/cuml/internals/validation.py @@ -11,6 +11,7 @@ import numpy as np import pandas as pd import scipy.sparse as sp +import sklearn from sklearn.utils.validation import check_is_fitted __all__ = ( @@ -313,6 +314,9 @@ def check_consistent_length(*arrays) -> None: def check_all_finite(array, *, allow_nan=False, input_name=None) -> None: """Check if all input values are finite. + This check is skipped if scikit-learn's ``assume_finite`` option is + configured via ``sklearn.set_config(assume_finite=True)``. + Parameters ---------- array : dense or sparse array @@ -326,6 +330,10 @@ def check_all_finite(array, *, allow_nan=False, input_name=None) -> None: # No-op for non floating inputs return + if sklearn.get_config()["assume_finite"]: + # no-op if assume_finite configured + return + if cp_sp.issparse(array) or sp.issparse(array): array = array.data diff --git a/python/cuml/tests/test_validation.py b/python/cuml/tests/test_validation.py index 7fe9d9e483..9e4c2af52f 100644 --- a/python/cuml/tests/test_validation.py +++ b/python/cuml/tests/test_validation.py @@ -9,6 +9,7 @@ import numpy as np import pandas as pd import pytest +import sklearn from cuml.internals.validation import ( _get_feature_names, @@ -323,6 +324,13 @@ def test_check_all_finite_host_fallback(): check_all_finite(x_inf, allow_nan=True) +def test_check_all_finite_assume_finite(): + bad = cp.array([1.5, float("nan"), 2.5], dtype="float32") + # No errors for bad inputs if `assume_finite=True` configured + with sklearn.config_context(assume_finite=True): + check_all_finite(bad) + + @pytest.mark.parametrize("device", [True, False]) @pytest.mark.parametrize("sparse_format", [None, "csr", "coo"]) def test_check_non_negative(device, sparse_format): From d1c612d8ca350161d47c9bb154fb519ec5ea93fb Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Thu, 16 Apr 2026 14:01:59 -0500 Subject: [PATCH 06/29] Disable multiple errors in hypothesis This leads to more straightforward tracebacks, and also lets `--pdb` work with pytest. Without this some errors are not debuggable. This will _not_ degrade the quality of the testing, cases that would error before will still error and the tests will still have the same coverage. It just changes how errors are raised within the test itself. --- python/cuml/tests/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/cuml/tests/conftest.py b/python/cuml/tests/conftest.py index 6b946e58e9..9f5e190325 100644 --- a/python/cuml/tests/conftest.py +++ b/python/cuml/tests/conftest.py @@ -95,6 +95,7 @@ def _set_cuda_cache_path_per_xdist_worker(config): parent=hypothesis.settings.get_profile("default"), phases=HYPOTHESIS_DEFAULT_PHASES, max_examples=20, + report_multiple_bugs=False, suppress_health_check=HEALTH_CHECKS_SUPPRESSED_BY_DEFAULT, ) From 6e6fe8f9549ef3b5edaa4f98f3114c4b100a24f4 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Thu, 16 Apr 2026 14:18:38 -0500 Subject: [PATCH 07/29] Fixup `check_consistent_length` --- python/cuml/cuml/internals/validation.py | 24 ++++++++++++++++++++++-- python/cuml/tests/test_validation.py | 15 +++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py index 123d12ba06..efb56fa245 100644 --- a/python/cuml/cuml/internals/validation.py +++ b/python/cuml/cuml/internals/validation.py @@ -125,6 +125,7 @@ def _check_shape( def _get_n_features(X): + """Get the number of features in X.""" if isinstance(X, (list, tuple)): if len(X) == 0: return 0 @@ -154,6 +155,22 @@ def _get_n_features(X): return shape[1] +def _get_n_samples(X): + """Get the number of samples in X.""" + + if (shape := getattr(X, "shape", None)) is not None: + if len(shape) == 0: + raise TypeError("Expected array-like, got scalar instead.") + return shape[0] + + try: + return len(X) + except TypeError as exc: + raise TypeError( + f"Expected array-like, got {type(X)} instead." + ) from exc + + def _get_feature_names(X): """Get feature names from X. @@ -276,16 +293,19 @@ def check_features(estimator, X, reset=False) -> None: def check_consistent_length(*arrays) -> None: """Check whether all inputs have the same number of samples. + Typically should be called after arrays have been validated and normalized + by other checks, but also works on typical unvetted user inputs. + Parameters ---------- *arrays : array or None The input variables to validate. None-values are ignored. """ - lengths = [X.shape[0] for X in arrays if X is not None] + lengths = [_get_n_samples(X) for X in arrays if X is not None] if len(set(lengths)) > 1: raise ValueError( f"Found input variables with inconsistent number of samples: " - f"{sorted(int(n) for n in lengths)}" + f"{sorted(n for n in lengths)}" ) diff --git a/python/cuml/tests/test_validation.py b/python/cuml/tests/test_validation.py index 9e4c2af52f..9f336994b9 100644 --- a/python/cuml/tests/test_validation.py +++ b/python/cuml/tests/test_validation.py @@ -1,5 +1,6 @@ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 +import re import warnings from contextlib import contextmanager @@ -257,12 +258,26 @@ def test_check_consistent_length(): check_consistent_length(x34, y3) check_consistent_length(x34, None, y3) + # Supports non-normalized inputs too + check_consistent_length(x34.tolist(), y3, None) + check_consistent_length(pd.DataFrame(x34), cudf.Series(y3), None) + with pytest.raises( ValueError, match=r"Found input variables with inconsistent number of samples: \[3, 4\]", ): check_consistent_length(x34, y4) + with pytest.raises( + TypeError, match="Expected array-like, got scalar instead" + ): + check_consistent_length(x34, None, np.array(1.5)) + + with pytest.raises( + TypeError, match=re.escape(f"Expected array-like, got {int} instead") + ): + check_consistent_length(x34, None, 1) + @pytest.mark.parametrize("device", [True, False]) @pytest.mark.parametrize("sparse_format", [None, "csr", "coo"]) From 61eb1338bc1fbcd78dba596796389ff6ae19da0d Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Mon, 13 Apr 2026 16:48:56 -0500 Subject: [PATCH 08/29] Add `check_array` and tests --- python/cuml/cuml/internals/validation.py | 291 ++++++++++- python/cuml/tests/test_validation.py | 604 +++++++++++++++++++++++ 2 files changed, 893 insertions(+), 2 deletions(-) diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py index efb56fa245..471211fbf1 100644 --- a/python/cuml/cuml/internals/validation.py +++ b/python/cuml/cuml/internals/validation.py @@ -21,6 +21,7 @@ "check_consistent_length", "check_all_finite", "check_non_negative", + "check_array", ) @@ -91,13 +92,13 @@ def _check_shape( if ndim == 0 or ndim == 1 and ensure_2d: if issubclass(array_type, (cudf.Series, pd.Series)): msg = ( - f"Expected a 2-dimensional container but got {array_type.__name__} " + f"Expected a 2-dimensional container but got {array_type} " "instead. Pass a DataFrame containing a single row (i.e. " "single sample) or a single column (i.e. single feature) " "instead." ) else: - kind = "scalar" if ndim == 0 else f"{ndim}D" + kind = "scalar" if ndim == 0 else "1D" msg = ( f"Expected 2D array, got {kind} array instead. Reshape your data " "using array.reshape(-1, 1) if your data has a single feature, " @@ -403,3 +404,289 @@ def check_non_negative(array, *, input_name=None) -> None: if array.size != 0 and xp.nanmin(array) < 0: suffix = f" passed to {input_name}" if input_name is not None else "" raise ValueError(f"Negative values in data{suffix}") + + +def check_array( + array, + *, + accept_sparse=False, + accept_large_sparse=False, + dtype=None, + convert_dtype=True, + mem_type="device", + order=None, + ensure_all_finite=True, + ensure_non_negative=False, + ensure_2d=True, + ensure_min_samples=1, + ensure_min_features=1, + input_name=None, + return_index=False, +): + """Validate and coerce an array-like to a supported type. + + Parameters + ---------- + array : array-like + The array-like input to validate. + accept_sparse : bool, str, list[str], default=False + The sparse matrix format(s) to support. If the input is sparse + but not in a supported format, it will be converted to the first + listed format. Pass True to support any input format. The default + of False will raise an error on sparse inputs. + accept_large_sparse : bool, default=False + Whether large (int64) indices are supported for sparse containers with + CSR/CSC/COO/BSR formats. If not supported, an appropriate error will be + raised if the sparse indices aren't int32. + dtype : None, dtype, list[dtype], default=None + The dtype(s) to support. By default no dtype validation is performed. + Pass a dtype or a list of supported dtypes to enforce a dtype for the + output. If the input doesn't have a supported dtype, it will be + converted to the first listed dtype. + convert_dtype : bool, default=True + Whether to support dtype conversion. If False, an error will be raised + if the input isn't a supported dtype. + mem_type : {'device', 'host'} or None, default='device' + The memory type use for the output. If 'device', the output will be a + ``cupy.ndarray`` if dense, or a ``cupyx.scipy.sparse.spmatrix`` if + sparse. If 'host', the output will be a ``numpy.ndarray`` if dense, or + a ``scipy.sparse.spmatrix`` if sparse. If ``None``, the output will + have the same memory type as the input (i.e. device if already on + device, host otherwise). + order : {'F', 'C', 'A', None}, default=None + The order and contiguity to enforce for dense outputs. Use 'F' for + F-contiguous outputs, 'C' for C-contiguous outputs, 'A' for either F or + C contiguous, or `None` for no contiguity requirements. + ensure_all_finite : bool or 'allow-nan', default=True + If True, an error will be raised if non-finite values are found in the + input. If 'allow-nan', an error will be raised if infinite values are + found (but not for NaN). If False then ``check_all_finite`` is skipped. + ensure_non_negative : bool, default=False + If True, an error will be raised if negative values are found in the + input. By default ``check_non_negative`` is skipped. + ensure_2d : bool, default=True + If True, the input must be 2D. If False, 1D or 2D inputs are accepted. + ensure_min_samples : int, default=1 + A minimum number of samples to require. Set to 0 for no minimum. + ensure_min_features : int, default=1 + A minimum number of features to require for 2D inputs. Set to 0 for no + minimum. + input_name : str or None, default=None + The input parameter name to use in error messages. + return_index : bool, default=False + Whether to return the index of ``array`` (if a dataframe-like value). + This is useful for functions that need to return an output with a + dataframe index aligned with the input. + + Returns + ------- + array : dense or sparse array + The converted and validated array. Depending on input and parameters, + will be one of ``cupy.ndarray``, ``numpy.ndarray``, + ``cupyx.scipy.sparse.spmatrix``, or ``scipy.sparse.spmatrix``. + index : pandas.Index, cudf.Index, or None + The index of the input if a dataframe-like, or None if no index. The + index will be converted to match ``mem_type``. Only returned if + ``return_index=True``. + """ + # Normalize and validate arguments + if mem_type not in ("device", "host", None): + raise ValueError(f"Unsupported {mem_type=!r}") + if order not in ("F", "C", "A", None): + raise ValueError(f"Unsupported {order=!r}") + + if isinstance(dtype, (list, tuple)): + dtype = [np.dtype(dt) for dt in dtype] + elif dtype is not None: + dtype = np.dtype(dtype) + + # Extract original array type and dtype (when possible) + array_type = type(array) + if isinstance(array, (cudf.DataFrame, pd.DataFrame)): + if all(isinstance(dt, np.dtype) for dt in array.dtypes): + array_dtype = np.result_type(*array.dtypes) + elif any(dt == "object" for dt in array.dtypes): + array_dtype = np.dtype("object") + else: + array_dtype = None + else: + array_dtype = getattr(array, "dtype", None) + if not isinstance(array_dtype, np.dtype): + array_dtype = None + + # Infer proper output dtype + if array_dtype is not None: + # Check for complex inputs before conversion when possible + if np.isdtype(array_dtype, "complex floating"): + raise ValueError("Complex data not supported") + if dtype is None: + dtype = array_dtype + else: + accept_dtypes = dtype if isinstance(dtype, list) else [dtype] + if array_dtype not in accept_dtypes: + if convert_dtype: + # Convert to first provided dtype + dtype = accept_dtypes[0] + else: + raise ValueError( + f"Expected array with dtype in {[str(d) for d in accept_dtypes]} " + f"but got {str(array_dtype)!r}" + ) + else: + dtype = array_dtype + elif isinstance(dtype, (list, tuple)): + # No original dtype, use first dtype in list inputs + dtype = dtype[0] + + # Coerce `array` to numpy/cupy/scipy.sparse/cupyx.scipy.sparse values as + # requested. For dataframe-like inputs also extract the index for later use. + index = None + if cp_sp.issparse(array) or sp.issparse(array): + # Handle sparse inputs + if isinstance(accept_sparse, str): + accept_sparse = [accept_sparse] + elif accept_sparse is True: + # Only support formats cupyx.scipy.sparse supports + accept_sparse = ["csr", "coo", "csc", "dia"] + + if not accept_sparse: + padded_input = f" for {input_name}" if input_name else "" + raise TypeError( + f"Sparse data was passed{padded_input}, but dense data is required. " + "Use '.toarray()' to convert to a dense array." + ) + if not accept_large_sparse: + if array.format == "coo": + index_keys = ["col", "row"] + elif array.format in ["csr", "csc", "bsr"]: + index_keys = ["indices", "indptr"] + else: + index_keys = [] + + for key in index_keys: + indices_dtype = getattr(array, key).dtype + if indices_dtype != "int32": + raise ValueError( + "Only sparse matrices with int32 indices are currently " + f"supported. Found {indices_dtype} indices instead." + ) + + # Coerce to accepted format if needed + if array.format not in accept_sparse: + array = array.asformat(accept_sparse[0]) + + # Validate dimensions and shape are as expected. We do this here + # _before_ host/device conversion, since cupyx doesn't have a sparse + # array type and thus only supports 2D inputs. + _check_shape( + array.shape, + array_type=array_type, + ensure_2d=ensure_2d, + ensure_min_samples=ensure_min_samples, + ensure_min_features=ensure_min_features, + ) + + # Coerce data to accepted dtype if needed + if dtype is not None and array.dtype != dtype: + array = array.astype(dtype) + + # Coerce to device or host if needed + if mem_type == "device" and not cp_sp.issparse(array): + if array.ndim != 2: + raise ValueError("cupyx.scipy.sparse only supports 2D arrays") + array = getattr(cp_sp, f"{array.format}_matrix")(array) + elif mem_type == "host" and not sp.issparse(array): + array = array.get() + else: + # Handle dense inputs + if isinstance(array, (cudf.DataFrame, cudf.Series)): + # Handle cudf inputs + index = array.index + if mem_type == "host": + array = np.asarray( + array.to_numpy(dtype=dtype), dtype=dtype, order=order + ) + else: + # XXX: the dtype keyword to `to_cupy` is buggy, and also + # doesn't support all dtype coercions. For now we do a + # manual cast to handle any coercions. + # See https://github.com/rapidsai/cudf/issues/22136. + if dtype is not None: + array = array.astype(dtype, copy=False) + array = cp.asarray(array.to_cupy(), dtype=dtype, order=order) + elif isinstance(array, (pd.DataFrame, pd.Series)): + # Handle pandas inputs + index = array.index + array = array.to_numpy(dtype=dtype) + if mem_type == "device": + array = cp.asarray(array, dtype=dtype, order=order) + elif mem_type is None and cudf.pandas.LOADED: + # With cudf.pandas, the array is already on device + array = cp.asarray(array, dtype=dtype, order=order) + else: + array = np.asarray(array, dtype=dtype, order=order) + elif hasattr(array, "__cuda_array_interface__"): + # Handle device-backed array-like inputs + if mem_type == "host": + array = cp.asnumpy(array, order=order or "A") + # Possible 2nd copy done on host for dtype enforcement + array = np.asarray(array, dtype=dtype, order=order) + else: + array = cp.asarray(array, dtype=dtype, order=order) + else: + # Handle all other inputs + if mem_type == "device": + array = cp.asarray(array, dtype=dtype, order=order) + else: + array = np.asarray(array, dtype=dtype, order=order) + + # XXX: order="A" isn't consistently handled by cupy or numpy. If a copy + # was made, the output will definitely already be contiguous. If no + # copy was already made though, we may need to make one to enforce + # contiguity (here we default to F-contiguous, mirroring what _most_ + # code paths do with `order="A"`). + if order == "A" and not ( + array.flags["F_CONTIGUOUS"] or array.flags["C_CONTIGUOUS"] + ): + array = ( + cp.asarray(array, order="F") + if isinstance(array, cp.ndarray) + else np.asarray(array, order="F") + ) + + # Validate dimensions and shape are as expected + _check_shape( + array.shape, + array_type=array_type, + ensure_2d=ensure_2d, + ensure_min_samples=ensure_min_samples, + ensure_min_features=ensure_min_features, + ) + + # Check for complex inputs after conversion for cases when `dtype=None` + if np.isdtype(array.dtype, "complex floating"): + raise ValueError("Complex data not supported") + + # Validate data meets expected value requirements + if ensure_all_finite: + check_all_finite( + array, + allow_nan=ensure_all_finite == "allow-nan", + input_name=input_name, + ) + if ensure_non_negative: + check_non_negative(array, input_name=input_name) + + # Process index if requested, then return + if return_index: + if isinstance(index, cudf.Index) and mem_type == "host": + index = ( + cudf.pandas.as_proxy_object(index) + if cudf.pandas.LOADED + else index.to_pandas() + ) + elif isinstance(index, pd.Index) and mem_type == "device": + index = cudf.Index(index) + return array, index + else: + return array diff --git a/python/cuml/tests/test_validation.py b/python/cuml/tests/test_validation.py index 9f336994b9..b81dba6fda 100644 --- a/python/cuml/tests/test_validation.py +++ b/python/cuml/tests/test_validation.py @@ -7,15 +7,19 @@ import cudf import cupy as cp import cupyx.scipy.sparse as cp_sp +import hypothesis.strategies as st import numpy as np import pandas as pd import pytest +import scipy.sparse as sp import sklearn +from hypothesis import assume, example, given from cuml.internals.validation import ( _get_feature_names, _get_n_features, check_all_finite, + check_array, check_consistent_length, check_features, check_non_negative, @@ -23,6 +27,168 @@ ) +@st.composite +def dense_arrays( + draw, + kind=None, + dtype=None, + order=None, + ndim=2, + n_samples=5, + n_features=4, +): + """A strategy for generating dense array inputs. + + Parameters + ---------- + kind : {'cupy', 'numpy', 'list', 'pandas', 'cudf'} or list + The input kind(s) to select from. + dtype : dtype-like or list[dtype] + The dtype(s) to select from. + order : {'C', 'F', None} or list + The contiguity order requirement(s) to select from. + ndim : {1, 2} or list + The number of dimensions to select from. + n_samples : int + The number of samples to generate + n_features : int + The number of features to generate (if ndim=2). + """ + + def select(value, choices, cast=None): + if value is None: + value = choices + if isinstance(value, (list, tuple)): + value = draw(st.sampled_from(value)) + if cast is not None: + value = cast(value) + assert value in choices + return value + + kind = select(kind, ("cupy", "numpy", "list", "pandas", "cudf")) + dtype = select( + dtype, + ("i1", "i2", "i4", "i8", "u1", "u2", "u4", "u8", "f2", "f4", "f8"), + cast=np.dtype, + ) + assume(not (kind == "cudf" and dtype == "float16")) + + ndim = select(ndim, (1, 2)) + shape = (n_samples, n_features) if ndim == 2 else (n_samples,) + + if kind in ("cupy", "numpy", "pandas"): + order = select(order, ("C", "F", None) if ndim == 2 else ("C", None)) + if order is None: + # generate a non-contiguous strided input if order=None + shape = ( + (n_samples * 2, n_features) if ndim == 2 else (n_samples * 2,) + ) + else: + # Other containers don't have flexible contiguity + order = None + + rng = np.random.default_rng(42) + if dtype.kind == "f": + data = rng.uniform(0, 100, size=shape) + else: + data = rng.integers(0, 100, size=shape) + + if kind == "cupy": + out = cp.asarray(data, dtype=dtype, order=order) + return out[::2] if order is None else out + elif kind == "numpy": + out = np.asarray(data, dtype=dtype, order=order) + return out[::2] if order is None else out + elif kind == "list": + return data.tolist() + elif kind == "pandas": + out = pd.Series(data) if ndim == 1 else pd.DataFrame(data) + return out[::2] if order is None else out + elif kind == "cudf": + return cudf.Series(data) if ndim == 1 else cudf.DataFrame(data) + + +@st.composite +def sparse_arrays( + draw, + kind=None, + dtype=None, + format=None, + n_samples=5, + n_features=4, + density=0.5, +): + """A strategy for generating sparse array inputs. + + Parameters + ---------- + kind : {'cupy', 'scipy', 'scipy-array'} or list + The input kind(s) to select from. + dtype : dtype-like or list[dtype] + The dtype(s) to select from. + format : sparse format or list + The format(s) to select from. + n_samples : int + The number of samples to generate + n_features : int + The number of features to generate + density : float + The density of the sparse matrix to generate + """ + + def select(value, choices, cast=None): + if value is None: + value = choices + if isinstance(value, (list, tuple)): + value = draw(st.sampled_from(value)) + if cast is not None: + value = cast(value) + assume(value in choices) + return value + + kind = select(kind, ("cupy", "scipy", "scipy-array")) + mem_type = "device" if kind == "cupy" else "host" + + DTYPES = ("f4", "f8") + FORMATS = ("csr", "csc", "coo", "dia") + if mem_type == "host": + DTYPES += ("i4", "i8") + FORMATS += ("bsr", "dok", "lil") + + dtype = select(dtype, DTYPES, cast=np.dtype) + format = select(format, FORMATS) + + rng = np.random.default_rng(42) + array = sp.random( + n_samples, + n_features, + density=density, + format=format, + dtype=dtype, + data_rvs=( + (lambda n: rng.uniform(0, 100, size=n)) + if dtype.kind == "f" + else (lambda n: rng.integers(0, 100, size=n)) + ), + random_state=42, + ) + if kind == "cupy": + array = getattr(cp_sp, f"{format}_matrix")(array) + elif kind == "scipy-array": + array = getattr(sp, f"{format}_array")(array) + + return array + + +def as_cupy(array, dtype=None, order=None): + """Coerce an array to cupy""" + if isinstance(array, (cudf.Series, cudf.DataFrame)): + if dtype is not None: + array = array.astype(dtype, copy=False) + array = array.to_cupy() + return cp.asarray(array, dtype=dtype, order=order) + + @contextmanager def assert_no_warnings(): """Small helper for asserting no warnings raised""" @@ -374,3 +540,441 @@ def array(values, dtype=None): ValueError, match="Negative values in data passed to X" ): check_non_negative(f64_bad_nan, input_name="X") + + +def test_check_array_bad_args(): + with pytest.raises(ValueError, match="Unsupported mem_type='bad'"): + check_array([1, 2, 3], mem_type="bad") + + with pytest.raises(ValueError, match="Unsupported order='bad'"): + check_array([1, 2, 3], order="bad") + + # Exception raised by `np.dtype`, we don't really care what it is + with pytest.raises(Exception, match="'bad'"): + check_array([1, 2, 3], dtype="bad") + with pytest.raises(Exception, match="'bad'"): + check_array([1, 2, 3], dtype=("int32", "bad")) + + +@pytest.mark.parametrize( + "func", + [ + pytest.param(lambda x: x, id="list"), + pytest.param(np.array, id="numpy"), + pytest.param(cp.array, id="cupy"), + pytest.param( + lambda x: cp_sp.csr_matrix(cp.asarray(x)), id="cupyx.sparse" + ), + pytest.param( + lambda x: sp.csr_matrix(np.asarray(x)), id="scipy.sparse" + ), + ], +) +def test_check_array_complex_errors(func): + array = func([[complex(1), complex(2, 3)]]) + with pytest.raises(ValueError, match="Complex data not supported"): + check_array(array) + + +@example(array=np.asarray([1, 2, 3]), mem_type=None) +@example(array=np.asarray([[1, 2], [3, 4]]), mem_type="device") +@example(array=cp.asarray([[1, 2], [3, 4]]), mem_type="host") +@given( + array=dense_arrays(dtype="float32", order="C"), + mem_type=st.sampled_from(["device", "host", None]), +) +def test_check_array_mem_type(array, mem_type): + out = check_array(array, mem_type=mem_type, ensure_2d=False) + if mem_type is None: + cls = ( + cp.ndarray + if isinstance(array, (cp.ndarray, cudf.DataFrame, cudf.Series)) + else np.ndarray + ) + else: + cls = cp.ndarray if mem_type == "device" else np.ndarray + + assert isinstance(out, cls) + cp.testing.assert_allclose(as_cupy(array), as_cupy(out)) + + +@example( + array=cp.asarray([[1, 2], [3, 4]], order="C"), + order="F", + mem_type="device", +) +@example( + array=cp.asarray([[1, 2], [3, 4]], order="F"), + order="C", + mem_type="device", +) +@example( + array=np.array([[1, 2], [3, 4], [5, 6]])[::2], + order="A", + mem_type="host", +) +@given( + array=dense_arrays(dtype="float32"), + order=st.sampled_from(["C", "F", "A"]), + mem_type=st.sampled_from(["device", "host", None]), +) +def test_check_array_order(array, order, mem_type): + out = check_array(array, ensure_2d=False, order=order, mem_type=mem_type) + cp.testing.assert_allclose(cp.asarray(out), as_cupy(array)) + if order == "A": + assert out.flags["C_CONTIGUOUS"] or out.flags["F_CONTIGUOUS"] + elif order == "C": + assert out.flags["C_CONTIGUOUS"] + elif order == "F": + assert out.flags["F_CONTIGUOUS"] + + +@example(array=[[1, 2]], mem_type="device") +@example(array=cp.array([[1, 2]]), mem_type="host") +@example(array=np.array([[1, 2]]), mem_type="device") +@example(array=cudf.DataFrame([[1, 2]]), mem_type="device") +@example(array=pd.DataFrame([[1, 2]]), mem_type="device") +@given( + array=dense_arrays(ndim=2, dtype="int32"), + mem_type=st.sampled_from(["device", "host", None]), +) +def test_check_array_dtype(array, mem_type): + """Generate arrays with definitive different dtype, and ensure + they're all cast appropriately. Checks that `dtype` is passed properly + in all code paths.""" + + # By default the input dtype is kept the same + out = check_array(array, dtype=None, mem_type=mem_type) + if hasattr(array, "dtype"): + assert out.dtype == array.dtype + + out = check_array(array, dtype=array.dtype, mem_type=mem_type) + assert out.dtype == array.dtype + + # If a sequence, no coercion done if dtype already valid + out = check_array( + array, dtype=("float32", array.dtype), mem_type=mem_type + ) + assert out.dtype == array.dtype + + # Coercion to a specific dtype + out = check_array(array, dtype="float32", mem_type=mem_type) + assert out.dtype == "float32" + cp.testing.assert_allclose(cp.asarray(out), as_cupy(array, "float32")) + + out = check_array(array, dtype="float64", mem_type=mem_type) + assert out.dtype == "float64" + cp.testing.assert_allclose(cp.asarray(out), as_cupy(array, "float64")) + + # If a sequence, the first dtype is used when coercion needed + out = check_array(array, dtype=("float32", "float64"), mem_type=mem_type) + assert out.dtype == "float32" + + +def test_check_array_convert_dtype(): + array = cp.array([[1, 2, 3]], dtype="float32") + + with pytest.raises( + ValueError, + match=r"Expected array with dtype in \['int32'\] but got 'float32'", + ): + check_array(array, dtype="int32", convert_dtype=False) + + array = pd.DataFrame({"x": [1, 2, 3], "y": [1.5, 2.5, 3.5]}) + with pytest.raises(ValueError, match=r"\['int32'\] but got 'float64'"): + check_array(array, dtype="int32", convert_dtype=False) + + array = pd.DataFrame({"x": [1, 2, 3], "y": ["a", "b", "a"]}) + with pytest.raises(ValueError, match=r"\['int32'\] but got 'object'"): + check_array(array, dtype="int32", convert_dtype=False) + + +@pytest.mark.parametrize("kind", ["cudf", "pandas"]) +@pytest.mark.parametrize("mem_type", ["device", "host", None]) +def test_check_array_dataframe_mixed_dtypes(kind, mem_type): + xdf = cudf if kind == "cudf" else pd + + df = xdf.DataFrame( + { + "x": [1, 2, 3, 4, 5], + "y": [2.5, 3.5, 4.5, 5.5, 6.5], + "z": ["1", "2", "3.5", "4", "5"], + } + ) + # Non-numeric columns -> object dtype by default + if mem_type == "device" or mem_type is None and kind == "cudf": + # cupy doesn't support object dtypes + with pytest.raises((ValueError, TypeError), match="object"): + check_array(df, mem_type=mem_type) + else: + # dtype=None does no conversion by default + out = check_array(df, mem_type=mem_type) + assert out.dtype == "object" + + # Can coerce all columns to specified dtype + out = check_array(df, mem_type=mem_type, dtype=("float32", "float64")) + assert out.dtype == "float32" + np.testing.assert_allclose(cp.asnumpy(out), df.to_numpy(dtype="float32")) + + # Subset of numeric columns -> numeric by default + df2 = df[["x", "y"]] + out = check_array(df2, mem_type=mem_type) + assert out.dtype == df2.y.dtype + np.testing.assert_allclose(cp.asnumpy(out), df2.to_numpy()) + + +@pytest.mark.parametrize("kind", ["cudf", "pandas"]) +@pytest.mark.parametrize("mem_type", ["device", "host", None]) +def test_check_array_dataframe_non_numpy_dtype(kind, mem_type): + xdf = cudf if kind == "cudf" else pd + + df = xdf.DataFrame({"x": ["1", "2", "1", "3"]}).astype("category") + # Can coerce all columns to specified dtype + out = check_array(df, mem_type=mem_type, dtype=("float32", "float64")) + assert out.dtype == "float32" + np.testing.assert_allclose(cp.asnumpy(out), df.to_numpy(dtype="float32")) + + +@pytest.mark.parametrize("kind", ["cudf", "pandas"]) +@pytest.mark.parametrize("ndim", [1, 2]) +@pytest.mark.parametrize("mem_type", ["device", "host", None]) +def test_check_array_return_index(kind, ndim, mem_type): + xdf = cudf if kind == "cudf" else pd + if ndim == 2: + array = xdf.DataFrame({"x": [1, 2, 3]}, index=[1, 3, 5]) + else: + array = xdf.Series([1, 2, 3], index=[1, 3, 5]) + + out, index = check_array( + array, ensure_2d=False, return_index=True, mem_type=mem_type + ) + if mem_type is None: + out_xdf = xdf + else: + out_xdf = cudf if mem_type == "device" else pd + assert isinstance(index, out_xdf.Index) + assert (cp.asnumpy(index) == np.array([1, 3, 5])).all() + + +@pytest.mark.parametrize("kind", ["numpy", "cudf", "pandas"]) +@pytest.mark.parametrize("mem_type", ["device", "host", None]) +def test_check_array_object_dtype(kind, mem_type): + array = np.array(["1.5", "2.5", "3.5"], dtype="object") + if kind == "cudf": + array = cudf.Series(array) + elif kind == "pandas": + array = pd.Series(array) + + if mem_type == "device" or mem_type is None and kind == "cudf": + # cupy doesn't support object dtypes + with pytest.raises((ValueError, TypeError), match="object"): + check_array(array, mem_type=mem_type, ensure_2d=False) + else: + out = check_array(array, mem_type=mem_type, ensure_2d=False) + assert out.dtype == "object" + + # Can coerce to numeric if specified + out = check_array( + array, mem_type=mem_type, dtype=("float32", "float64"), ensure_2d=False + ) + assert out.dtype == "float32" + cp.testing.assert_allclose(cp.asarray(out), as_cupy(array, "float32")) + + +@pytest.mark.parametrize( + "array", + [ + pytest.param(cp_sp.csr_matrix(cp.array([[1.0, 0]])), id="cupy matrix"), + pytest.param(sp.csr_matrix(np.array([[1.0, 0]])), id="scipy matrix"), + pytest.param(sp.csr_array(np.array([[1.0, 0]])), id="scipy array"), + ], +) +def test_check_array_sparse_not_supported(array): + with pytest.raises( + TypeError, match="Sparse data was passed, but dense data is required" + ): + check_array(array) + + with pytest.raises( + TypeError, + match="Sparse data was passed for X, but dense data is required", + ): + check_array(array, input_name="X") + + +@example(array=cp_sp.csr_matrix(cp.array([[1.0, 0], [0, 0]])), mem_type="host") +@example(array=sp.csr_matrix(np.array([[1.0, 0], [0, 0]])), mem_type="device") +@given( + array=sparse_arrays(), + mem_type=st.sampled_from(["device", "host", None]), +) +def test_check_array_sparse_input(array, mem_type): + if mem_type is None: + ns = cp_sp if cp_sp.issparse(array) else sp + else: + ns = cp_sp if mem_type == "device" else sp + + # dtype=None case + if ( + mem_type == "device" + and array.dtype.kind != "f" + and array.format != "dia" + ): + # cupy only supports floating dtypes for these inputs. We let cupy + # itself raise an exception, we don't really care what it is. + with pytest.raises(ValueError, match="float32"): + check_array(array, accept_sparse=True, mem_type=mem_type) + else: + out = check_array(array, accept_sparse=True, mem_type=mem_type) + assert ns.issparse(out) + assert out.dtype == array.dtype + + # Coerce to specified dtypes + out = check_array( + array, accept_sparse=True, dtype="float32", mem_type=mem_type + ) + assert ns.issparse(out) + assert out.dtype == "float32" + + +@example( + array=cp_sp.csr_matrix(cp.array([[1.0, 0], [0, 0]])), + mem_type="host", + format="coo", +) +@example( + array=sp.csr_matrix(np.array([[1.0, 0], [0, 0]])), + mem_type="device", + format="coo", +) +@given( + array=sparse_arrays(dtype="float32"), + mem_type=st.sampled_from(["device", "host", None]), + format=st.sampled_from(["csr", "csc", "coo"]), +) +def test_check_array_sparse_input_format(array, mem_type, format): + if mem_type is None: + ns = cp_sp if cp_sp.issparse(array) else sp + else: + ns = cp_sp if mem_type == "device" else sp + + out = check_array(array, accept_sparse=True, mem_type=mem_type) + assert ns.issparse(out) + assert out.dtype == array.dtype + # Format unchanged unless not a format cupy supports + if array.format in ["csr", "coo", "csc", "dia"]: + assert out.format == array.format + else: + assert out.format == "csr" + + # Coerce to specified formats + out = check_array(array, accept_sparse=format, mem_type=mem_type) + assert ns.issparse(out) + assert out.dtype == array.dtype + assert out.format == format + + out = check_array(array, accept_sparse=[format, "csc"], mem_type=mem_type) + assert ns.issparse(out) + assert out.dtype == array.dtype + assert out.format == ( + array.format if array.format in [format, "csc"] else format + ) + + +@pytest.mark.parametrize("format", ["csr", "csc", "coo", "bsr"]) +def test_check_array_accept_large_sparse(format): + array = sp.random(20, 10, density=0.5, format=format, random_state=42) + if array.format == "coo": + array.coords = tuple(v.astype("int64") for v in array.coords) + else: + for name in ["indices", "indptr"]: + setattr(array, name, getattr(array, name).astype("int64")) + + with pytest.raises(ValueError, match="sparse matrices with int32 indices"): + check_array(array, accept_sparse=True) + + check_array( + array, accept_sparse=True, accept_large_sparse=True, mem_type="host" + ) + + +@example(array=np.ones((3, 2))) +@example(array=cp_sp.csr_matrix(cp.ones((3, 2)))) +@given( + array=st.one_of( + dense_arrays(dtype="float32", ndim=2, n_samples=3, n_features=2), + sparse_arrays(dtype="float32", n_samples=3, n_features=2), + ) +) +def test_check_array_ensure_min_samples_and_ensure_min_features(array): + with pytest.raises( + ValueError, + match=( + r"Found array with 3 sample\(s\) \(shape=\(3, 2\)\) while a " + "minimum of 6 is required" + ), + ): + check_array(array, ensure_min_samples=6, accept_sparse=True) + + with pytest.raises( + ValueError, + match=( + r"Found array with 2 feature\(s\) \(shape=\(3, 2\)\) while a " + "minimum of 5 is required" + ), + ): + check_array(array, ensure_min_features=5, accept_sparse=True) + + +@pytest.mark.parametrize( + "func", + [ + pytest.param(lambda x: x, id="list"), + pytest.param(np.asarray, id="numpy"), + pytest.param(cp.asarray, id="cupy"), + pytest.param(cudf.Series, id="cudf"), + pytest.param(pd.Series, id="pandas"), + pytest.param(sp.csr_array, id="scipy.sparse.csr_array"), + ], +) +def test_check_array_ensure_2d(func): + array = func([1.5, 2.5, 3.5]) + if isinstance(array, (pd.Series, cudf.Series)): + err_msg = "Expected a 2-dimensional container" + else: + err_msg = "Expected 2D array, got 1D array instead" + with pytest.raises(ValueError, match=err_msg): + check_array(array, accept_sparse=True) + + +def test_check_array_ensure_all_finite(): + """Tests plumbing of check_array -> check_all_finite""" + f32_nan = np.array([[1.5, float("nan"), 2.5]], dtype="float32") + f64_both = np.array([[1.5, float("inf"), float("nan")]], dtype="float64") + + # No errors + check_array(f32_nan, ensure_all_finite="allow-nan") + check_array(f64_both, ensure_all_finite=False) + + with pytest.raises( + ValueError, match="Input X contains NaN or infinite values" + ): + check_array(f32_nan, input_name="X") + + with pytest.raises( + ValueError, match="Input array contains NaN or infinite values" + ): + check_array(f64_both) + + +def test_check_array_ensure_non_negative(): + """Tests plumbing of check_array -> check_non_negative""" + array = np.array([[-1, 1, 2]], dtype="float32") + + # No error, check disabled by default + check_array(array) + + with pytest.raises( + ValueError, match="Negative values in data passed to X" + ): + check_array(array, input_name="X", ensure_non_negative=True) From f40733bd41876f6283294faedd34cce99d7e12e1 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Wed, 15 Apr 2026 15:54:41 -0500 Subject: [PATCH 09/29] Add `check_y` and tests --- python/cuml/cuml/internals/validation.py | 225 ++++++++++++++-- python/cuml/tests/test_validation.py | 321 ++++++++++++++++++++--- 2 files changed, 497 insertions(+), 49 deletions(-) diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py index 471211fbf1..effef24f61 100644 --- a/python/cuml/cuml/internals/validation.py +++ b/python/cuml/cuml/internals/validation.py @@ -6,12 +6,14 @@ import warnings import cudf +import cudf.pandas import cupy as cp import cupyx.scipy.sparse as cp_sp import numpy as np import pandas as pd import scipy.sparse as sp import sklearn +from sklearn.exceptions import DataConversionWarning from sklearn.utils.validation import check_is_fitted __all__ = ( @@ -22,6 +24,7 @@ "check_all_finite", "check_non_negative", "check_array", + "check_y", ) @@ -494,11 +497,10 @@ def check_array( raise ValueError(f"Unsupported {mem_type=!r}") if order not in ("F", "C", "A", None): raise ValueError(f"Unsupported {order=!r}") - - if isinstance(dtype, (list, tuple)): - dtype = [np.dtype(dt) for dt in dtype] - elif dtype is not None: - dtype = np.dtype(dtype) + if dtype is not None: + if not isinstance(dtype, (list, tuple)): + dtype = [dtype] + dtype = [np.dtype(i) for i in dtype] # Extract original array type and dtype (when possible) array_type = type(array) @@ -521,20 +523,18 @@ def check_array( raise ValueError("Complex data not supported") if dtype is None: dtype = array_dtype - else: - accept_dtypes = dtype if isinstance(dtype, list) else [dtype] - if array_dtype not in accept_dtypes: - if convert_dtype: - # Convert to first provided dtype - dtype = accept_dtypes[0] - else: - raise ValueError( - f"Expected array with dtype in {[str(d) for d in accept_dtypes]} " - f"but got {str(array_dtype)!r}" - ) + elif array_dtype not in dtype: + if convert_dtype: + # Convert to first provided dtype + dtype = dtype[0] else: - dtype = array_dtype - elif isinstance(dtype, (list, tuple)): + raise ValueError( + f"Expected array with dtype in {[str(d) for d in dtype]} " + f"but got {str(array_dtype)!r}" + ) + else: + dtype = array_dtype + elif dtype is not None: # No original dtype, use first dtype in list inputs dtype = dtype[0] @@ -690,3 +690,192 @@ def check_array( return array, index else: return array + + +_is_integral = cp.ReductionKernel( + "T x", + "bool out", + "isfinite(x) && (ceilf(x) == x)", + "a && b", + "out = a", + "true", + "is_integral", +) + + +def check_y( + y, + *, + dtype=None, + convert_dtype=True, + mem_type="device", + order=None, + accept_multi_output=False, + return_classes=False, +): + """Validate and coerce ``y`` to a supported type. + + Parameters + ---------- + y : array-like + The array-like input to validate. + dtype : None, dtype, list[dtype], default=None + The dtype(s) to support. By default no dtype enforcement is performed; + for classifiers the output will be a suitable integral type, otherwise + the input dtype will be used. Pass a dtype or a list of supported + dtypes to enforce a dtype for the output. If the input doesn't have a + supported dtype, it will be converted to the first listed dtype. + convert_dtype : bool, default=True + Whether to support dtype conversion. If False, an error will be raised + if the input isn't a supported dtype. + mem_type : {'device', 'host'} or None, default='device' + The memory type use for the output. If 'device', the output will be a + ``cupy.ndarray``. If 'host', the output will be a ``numpy.ndarray``. If + ``None``, the output will have the same memory type as the input (i.e. + device if already on device, host otherwise). + order : {'F', 'C', 'A', None}, default=None + The order and contiguity to enforce for dense outputs. Use 'F' for + F-contiguous outputs, 'C' for C-contiguous outputs, 'A' for either F or + C contiguous, or `None` for no contiguity requirements. + accept_multi_output : bool, default=False + Whether multi-output y is accepted. By default only 1D inputs (or 2D + inputs with a single column) are accepted. Set to True to accept + multi-column inputs as well. + return_classes : bool, default=False + Set to True to also label encode ``y`` and return the ``classes``. + + Returns + ------- + y : cupy.ndarray or numpy.ndarray + The converted and validated array. + classes : numpy.ndarray or list[numpy.ndarray] + The collected classes for a classifier input. Only returned if + ``return_classes=True``. + """ + if y is None: + raise ValueError( + "This estimator requires y to be passed, but the target y is None" + ) + + # Normalize `dtype` arg + if dtype is not None: + if not isinstance(dtype, (list, tuple)): + dtype = [dtype] + dtype = [np.dtype(i) for i in dtype] + + # Coerce `y` to a supported array type + if return_classes: + # cudf may coerce the dtype, store the original so we can cast back later + input_dtype = y.dtype if isinstance(y, np.ndarray) else None + + # No cuda container supports all dtypes. Here we coerce to cupy when + # possible, falling back to cudf Series/DataFrame otherwise. + if not isinstance(y, (cudf.DataFrame, cudf.Series)): + y = check_array( + y, + mem_type=None, + ensure_2d=False, + ensure_min_samples=0, + ensure_all_finite=False, + input_name="y", + ) + # If no original dtype found on input, use the coerced one instead + if input_dtype is None: + input_dtype = y.dtype + if mem_type is None: + mem_type = "host" if isinstance(y, np.ndarray) else "device" + if y.dtype.kind in "iufb": + y = cp.asarray(y) + else: + y = (cudf.DataFrame if y.ndim == 2 else cudf.Series)( + y, dtype=(np.dtype("O") if y.dtype.kind in "U" else None) + ) + elif mem_type is None: + mem_type = "device" + else: + y = check_array( + y, + dtype=dtype, + convert_dtype=convert_dtype, + mem_type=mem_type, + order=order, + ensure_2d=False, + ensure_min_samples=0, + input_name="y", + ) + + # Warn/error appropriately for 2D inputs + if y.ndim == 2: + if y.shape[1] == 1 and (return_classes or not accept_multi_output): + warnings.warn( + "A column-vector y was passed when a 1d array was expected. " + "Please change the shape of y to (n_samples,), for example " + "using ravel().", + DataConversionWarning, + ) + if return_classes: + y = ( + y.iloc[:, 0] + if isinstance(y, cudf.DataFrame) + else y.ravel() + ) + elif not accept_multi_output: + raise ValueError( + f"y should be a 1d array, got an array of shape {y.shape} instead." + ) + + if not return_classes: + return y + + # For classifiers, we label encode y and return the integral labels as well + # as the classes. + def _encode(y): + """Encode `y` to codes and classes""" + if y.dtype.kind == "f" and not _is_integral(y): + raise ValueError( + "Unknown label type: continuous. Maybe you are trying to fit a " + "classifier, which expects discrete classes on a regression target " + "with continuous values." + ) + if isinstance(y, cudf.Series): + y = y.astype("category") + codes = cp.asarray(y.cat.codes) + classes = y.cat.categories.to_numpy() + # cudf will sometimes translate non-numeric dtypes. Coerce back to + # the input dtype if the input was originally a numpy array. + if input_dtype is not None: + classes = classes.astype(input_dtype, copy=False) + else: + classes, codes = cp.unique(y, return_inverse=True) + classes = classes.get() + return codes, classes + + # Return C order if C requested, otherwise F. + if order != "C": + order = "F" + + if y.ndim == 1: + y, classes = _encode(y) + if dtype is not None and y.dtype not in dtype: + y = y.astype(dtype[0]) + else: + getter = y.iloc if isinstance(y, cudf.DataFrame) else y + encoded_cols, classes = zip( + *(_encode(getter[:, i]) for i in range(y.shape[1])) + ) + classes = list(classes) + # Infer output dtype + out_dtype = cp.result_type(*(c.dtype for c in encoded_cols)) + if dtype is not None and out_dtype not in dtype: + dtype = dtype[0] + else: + dtype = out_dtype + y = cp.empty(shape=y.shape, dtype=dtype, order=order) + for i, col in enumerate(encoded_cols): + y[:, i] = col + + if mem_type == "host": + # convert back to host if needed + y = y.get(order=order) + + return y, classes diff --git a/python/cuml/tests/test_validation.py b/python/cuml/tests/test_validation.py index b81dba6fda..e4b01afa8f 100644 --- a/python/cuml/tests/test_validation.py +++ b/python/cuml/tests/test_validation.py @@ -14,6 +14,7 @@ import scipy.sparse as sp import sklearn from hypothesis import assume, example, given +from sklearn.exceptions import DataConversionWarning from cuml.internals.validation import ( _get_feature_names, @@ -24,9 +25,48 @@ check_features, check_non_negative, check_random_seed, + check_y, ) +def gen_dense_array( + *, + kind="cupy", + dtype="float32", + order="C", + ndim=2, + n_samples=5, + n_features=4, +): + """Generate a suitable dense array input.""" + shape = (n_samples, n_features) if ndim == 2 else (n_samples,) + dtype = np.dtype(dtype) + + if kind in ("cupy", "numpy", "pandas") and order is None: + # generate a non-contiguous strided input if order=None + shape = (n_samples * 2, n_features) if ndim == 2 else (n_samples * 2,) + + rng = np.random.default_rng(42) + if dtype.kind == "f": + data = rng.uniform(0, 100, size=shape).astype(dtype) + else: + data = rng.integers(0, 100, size=shape).astype(dtype) + + if kind == "cupy": + out = cp.asarray(data, dtype=dtype, order=order) + return out[::2] if order is None else out + elif kind == "numpy": + out = np.asarray(data, dtype=dtype, order=order) + return out[::2] if order is None else out + elif kind == "list": + return data.tolist() + elif kind == "pandas": + out = pd.Series(data) if ndim == 1 else pd.DataFrame(data) + return out[::2] if order is None else out + elif kind == "cudf": + return cudf.Series(data) if ndim == 1 else cudf.DataFrame(data) + + @st.composite def dense_arrays( draw, @@ -74,38 +114,21 @@ def select(value, choices, cast=None): assume(not (kind == "cudf" and dtype == "float16")) ndim = select(ndim, (1, 2)) - shape = (n_samples, n_features) if ndim == 2 else (n_samples,) if kind in ("cupy", "numpy", "pandas"): order = select(order, ("C", "F", None) if ndim == 2 else ("C", None)) - if order is None: - # generate a non-contiguous strided input if order=None - shape = ( - (n_samples * 2, n_features) if ndim == 2 else (n_samples * 2,) - ) else: # Other containers don't have flexible contiguity order = None - rng = np.random.default_rng(42) - if dtype.kind == "f": - data = rng.uniform(0, 100, size=shape) - else: - data = rng.integers(0, 100, size=shape) - - if kind == "cupy": - out = cp.asarray(data, dtype=dtype, order=order) - return out[::2] if order is None else out - elif kind == "numpy": - out = np.asarray(data, dtype=dtype, order=order) - return out[::2] if order is None else out - elif kind == "list": - return data.tolist() - elif kind == "pandas": - out = pd.Series(data) if ndim == 1 else pd.DataFrame(data) - return out[::2] if order is None else out - elif kind == "cudf": - return cudf.Series(data) if ndim == 1 else cudf.DataFrame(data) + return gen_dense_array( + kind=kind, + dtype=dtype, + order=order, + ndim=ndim, + n_samples=n_samples, + n_features=n_features, + ) @st.composite @@ -189,6 +212,16 @@ def as_cupy(array, dtype=None, order=None): return cp.asarray(array, dtype=dtype, order=order) +def assert_contiguity(array, order): + """Assert an array has the proper contiguity""" + if order == "A": + assert array.flags["C_CONTIGUOUS"] or array.flags["F_CONTIGUOUS"] + elif order == "C": + assert array.flags["C_CONTIGUOUS"] + elif order == "F": + assert array.flags["F_CONTIGUOUS"] + + @contextmanager def assert_no_warnings(): """Small helper for asserting no warnings raised""" @@ -621,12 +654,7 @@ def test_check_array_mem_type(array, mem_type): def test_check_array_order(array, order, mem_type): out = check_array(array, ensure_2d=False, order=order, mem_type=mem_type) cp.testing.assert_allclose(cp.asarray(out), as_cupy(array)) - if order == "A": - assert out.flags["C_CONTIGUOUS"] or out.flags["F_CONTIGUOUS"] - elif order == "C": - assert out.flags["C_CONTIGUOUS"] - elif order == "F": - assert out.flags["F_CONTIGUOUS"] + assert_contiguity(out, order) @example(array=[[1, 2]], mem_type="device") @@ -978,3 +1006,234 @@ def test_check_array_ensure_non_negative(): ValueError, match="Negative values in data passed to X" ): check_array(array, input_name="X", ensure_non_negative=True) + + +@example(y=cp.asarray([[1, 2], [3, 4]], order="F"), order="C", mem_type="host") +@example( + y=np.asarray([[1, 2], [3, 4]], order="C"), order="F", mem_type="device" +) +@given( + y=dense_arrays(ndim=(1, 2), dtype="int64"), + order=st.sampled_from(["C", "F", "A"]), + mem_type=st.sampled_from(["device", "host", None]), +) +def test_check_y(y, mem_type, order): + if mem_type == "device": + exp_type = cp.ndarray + elif mem_type == "host": + exp_type = np.ndarray + else: + exp_type = ( + cp.ndarray + if isinstance(y, (cp.ndarray, cudf.DataFrame, cudf.Series)) + else np.ndarray + ) + + out = check_y(y, mem_type=mem_type, order=order, accept_multi_output=True) + if hasattr(y, "dtype"): + assert out.dtype == y.dtype + assert isinstance(out, exp_type) + assert_contiguity(out, order) + cp.testing.assert_allclose(as_cupy(out), as_cupy(y)) + + # Check that if dtype specified it's properly converted + out = check_y( + y, + mem_type=mem_type, + order=order, + accept_multi_output=True, + dtype="float32", + ) + assert out.dtype == "float32" + assert isinstance(out, exp_type) + assert_contiguity(out, order) + cp.testing.assert_allclose(as_cupy(out), as_cupy(y, "float32")) + + # Sequence of dtypes also works + out = check_y( + y, + accept_multi_output=True, + dtype=("int32", "int64"), + ) + assert out.dtype in ("int32", "int64") + + +@pytest.mark.parametrize("kind", ["array", "dataframe"]) +def test_check_y_accept_multi_output(kind): + if kind == "array": + y_2d_1col = np.array([1, 2, 3])[:, None] + y_2d = np.array([[1, 2, 3], [4, 5, 6]]) + else: + y_2d_1col = pd.DataFrame({"x": [1, 2, 3]}) + y_2d = pd.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]}) + + # 2d with 1 column warns, but still returns 2D + with pytest.warns(DataConversionWarning, match="A column-vector y"): + out = check_y(y_2d_1col) + assert out.ndim == 2 + + # 2d with multiple columns just errors + with pytest.raises(ValueError, match="y should be a 1d"): + check_y(y_2d) + + # With accept_multi_output=True, no cases error or warn + out = check_y(y_2d_1col, accept_multi_output=True) + assert out.ndim == 2 + out = check_y(y_2d, accept_multi_output=True) + assert out.ndim == 2 + + +@example( + kind="cupy", + label_dtype="int32", + n_classes=2, + mem_type="host", + dtype=None, + order=None, +) +@example( + kind="pandas", + label_dtype="O", + n_classes=(2, 4), + mem_type="device", + dtype="int32", + order="F", +) +@example( + kind="list", + label_dtype="O", + n_classes=2, + mem_type=None, + dtype=None, + order="A", +) +@given( + kind=st.sampled_from(["cupy", "numpy", "pandas", "cudf", "list"]), + label_dtype=st.sampled_from(["int32", "int64", "bool", "O", "U"]), + n_classes=st.sampled_from([2, 4, (3,), (2, 4)]), + mem_type=st.sampled_from(["device", "host", None]), + dtype=st.sampled_from([None, "int32", "int64", "float32"]), + order=st.sampled_from(["C", "F", "A", None]), +) +def test_check_y_return_classes( + kind, label_dtype, n_classes, mem_type, dtype, order +): + # Construct input data + if label_dtype in ("O", "U"): + if mem_type is None: + assume(kind not in ("cudf", "cupy")) + else: + assume(not (kind == "cupy" or mem_type == "device")) + labels = np.array(["a", "b", "c", "d"], dtype=label_dtype) + elif label_dtype == "bool": + labels = np.array([True, False]) + else: + labels = np.array([5, 10, 15, 20], dtype=label_dtype) + + rng = np.random.default_rng(42) + if isinstance(n_classes, int): + assume(n_classes < len(labels)) + inds = rng.integers(n_classes, size=100) + else: + assume(all(n < len(labels) for n in n_classes)) + inds = np.stack([rng.integers(n, size=100) for n in n_classes]).T + + y = labels.take(inds) + if kind == "cupy": + y = cp.asarray(y) + elif kind == "pandas": + y = pd.DataFrame(y) if inds.ndim == 2 else pd.Series(y) + elif kind == "cudf": + y = cudf.DataFrame(y) if inds.ndim == 2 else cudf.Series(y) + elif kind == "list": + y = y.tolist() + + # Construct the expected outputs + sol = inds.flatten() if inds.ndim == 2 and inds.shape[1] == 1 else inds + if dtype is not None: + sol = sol.astype(dtype) + if mem_type == "device" or mem_type is None and kind in ("cupy", "cudf"): + sol = cp.asarray(sol) + + y2 = check_array(y, mem_type="host", ensure_2d=False) + if inds.ndim == 2 and inds.shape[1] != 1: + sol_classes = [np.unique(y2[:, i]) for i in range(y2.shape[1])] + else: + sol_classes = np.unique(y2) + + # Helper function so we don't have to repeat args below + def check(accept_multi_output=False): + return check_y( + y, + order=order, + dtype=dtype, + mem_type=mem_type, + accept_multi_output=accept_multi_output, + return_classes=True, + ) + + # Check the calls warn/raise appropriately and return expected types + if inds.ndim == 2: + if inds.shape[1] == 1: + # For classifiers this always warns, even if multi-output accepted + with pytest.warns( + DataConversionWarning, match="A column-vector y" + ): + out, classes = check() + assert out.ndim == 1 + assert isinstance(classes, np.ndarray) + with pytest.warns( + DataConversionWarning, match="A column-vector y" + ): + out, classes = check(True) + else: + with pytest.raises(ValueError, match="y should be a 1d"): + check() + out, classes = check(True) + else: + out, classes = check() + + # Assert encoded y is correct + assert_contiguity(out, order) + if dtype is None: + assert out.dtype.kind in "iu" # default to some integral type + else: + assert out.dtype == dtype + assert out.shape == sol.shape + assert isinstance(out, type(sol)) + np.testing.assert_allclose(cp.asnumpy(out), cp.asnumpy(sol)) + + # Assert classes are correct + if isinstance(sol_classes, list): + assert isinstance(classes, list) + assert len(classes) == len(sol_classes) + for c, s in zip(classes, sol_classes): + assert c.dtype == s.dtype + assert (c == s).all() + else: + assert classes.dtype == sol_classes.dtype + assert (classes == sol_classes).all() + + +def test_check_y_classifier_on_floating_input(): + # integral floating values are accepted + good = np.array([1.0, 2.0, 1.0]) + out, classes = check_y(good, return_classes=True) + assert classes.dtype == good.dtype + np.testing.assert_array_equal(classes, np.unique(good)) + np.testing.assert_array_equal(cp.asnumpy(out), np.array([0, 1, 0])) + + # Non integral values error + bad = [ + np.array([1.5, 2.5, 3.5]), + cp.array([1.0, float("nan"), 3.0]), + np.array([1.0, float("inf"), 3.0]), + ] + for array in bad: + with pytest.raises(ValueError, match="Unknown label type: continuous"): + check_y(array, return_classes=True) + + +def test_check_y_none(): + with pytest.raises(ValueError, match="This estimator requires y"): + check_y(None) From ba3769a393ca3b6d89491c453c05023acb5c5cbb Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Thu, 16 Apr 2026 14:35:57 -0500 Subject: [PATCH 10/29] Use `check_y` in classifiers --- python/cuml/cuml/common/classification.py | 133 +----------------- .../cuml/ensemble/randomforestclassifier.py | 9 +- .../cuml/linear_model/logistic_regression.py | 9 +- .../cuml/linear_model/mbsgd_classifier.py | 5 +- .../cuml/neighbors/kneighbors_classifier.pyx | 10 +- python/cuml/cuml/svm/linear_svc.py | 12 +- python/cuml/cuml/svm/svc.py | 9 +- .../upstream/scikit-learn/xfail-list.yaml | 6 - .../cuml/tests/test_sklearn_compatibility.py | 12 -- 9 files changed, 25 insertions(+), 180 deletions(-) diff --git a/python/cuml/cuml/common/classification.py b/python/cuml/cuml/common/classification.py index ee21a7d2c2..38a4820bec 100644 --- a/python/cuml/cuml/common/classification.py +++ b/python/cuml/cuml/common/classification.py @@ -1,142 +1,13 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 -import warnings - import cudf import cupy as cp import numpy as np -import pandas as pd from cuml.internals.array import CumlArray, cuda_ptr -from cuml.internals.input_utils import input_to_cuml_array, input_to_cupy_array +from cuml.internals.input_utils import input_to_cupy_array from cuml.internals.output_utils import cudf_to_pandas -is_integral = cp.ReductionKernel( - "T x", - "bool out", - "ceilf(x) == x", - "a && b", - "out = a", - "true", - "is_integral", -) - - -def check_classification_targets(y): - """Check if `y` is composed of valid class labels""" - if y.dtype.kind == "f" and not is_integral(y): - raise ValueError( - "Unknown label type: continuous. Maybe you are trying to fit a " - "classifier, which expects discrete classes on a regression target " - "with continuous values." - ) - - -def preprocess_labels( - y, dtype=None, order="C", n_samples=None, allow_multitarget=False -): - """Preprocess the `y` input to a classifier. - - Parameters - ---------- - y : array-like - The labels for fitting, may be any type cuml supports as input. - dtype : dtype, optional - The output dtype to use for the encoded labels. If not provided, - a data-dependent integral type will be used. - order : {"C", "F"}, optional - The array order to use for the encoded labels. - n_samples : int, optional - If provided, will raise an error if the number of samples in `y` - doesn't match. - allow_multitarget : bool, optional - Whether to allow multi-target labels. - - Returns - ------- - y_encoded : cp.ndarray - The labels, encoded as integers in [0, n_classes - 1]. - classes : np.ndarray or list[np.ndarray] - The classes as a numpy array, or a list of numpy arrays if - y is multi-target. - """ - # cudf may coerce the dtype, store the original so we can cast back later - y_dtype = y.dtype if isinstance(y, np.ndarray) else None - - # No cuda container supports all dtypes. Here we coerce to cupy when - # possible, falling back to cudf Series/DataFrame otherwise. - if isinstance(y, np.ndarray) and y.dtype.kind in "iufb": - y = cp.asarray(y) - elif isinstance(y, pd.DataFrame): - y = cudf.DataFrame(y) - elif isinstance(y, pd.Series): - y = cudf.Series(y) - elif not isinstance(y, (cp.ndarray, cudf.DataFrame, cudf.Series)): - # Non-numeric dtype, always go through cudf - y = input_to_cuml_array(y, convert_to_mem_type=False).array - if y.dtype.kind in "iufb": - y = y.to_output("cupy") - else: - y = (cudf.DataFrame if y.ndim == 2 else cudf.Series)( - y, dtype=(np.dtype("O") if y.dtype.kind in "U" else None) - ) - - # Validate dimensionality, ensuring 1D/2D y is as expected - if y.ndim == 2 and y.shape[1] == 1: - warnings.warn( - "A column-vector y was passed when a 1d array was expected. Please " - "change the shape of y to (n_samples,), for example using ravel()." - ) - y = y.iloc[:, 0] if isinstance(y, cudf.DataFrame) else y.ravel() - elif allow_multitarget and y.ndim not in (1, 2): - raise ValueError( - f"y should be a 1d or 2d array, got an array of shape {y.shape} instead." - ) - elif not allow_multitarget and y.ndim != 1: - raise ValueError( - f"y should be a 1d array, got an array of shape {y.shape} instead." - ) - - # Validate correct number of samples - if n_samples is not None and y.shape[0] != n_samples: - raise ValueError( - f"Expected `y` with {n_samples} samples, got {y.shape[0]}" - ) - - def _encode(y): - """Encode `y` to codes and classes""" - check_classification_targets(y) - if isinstance(y, cudf.Series): - y = y.astype("category") - codes = cp.asarray(y.cat.codes) - classes = y.cat.categories.to_numpy() - # cudf will sometimes translate non-numeric dtypes. Coerce back to - # the input dtype if the input was originally a numpy array. - if y_dtype is not None: - classes = classes.astype(y_dtype, copy=False) - else: - classes, codes = cp.unique(y, return_inverse=True) - classes = classes.get() - return codes, classes - - if y.ndim == 1: - y_encoded, classes = _encode(y) - if dtype is not None: - y_encoded = y_encoded.astype(dtype, copy=False) - else: - getter = y.iloc if isinstance(y, cudf.DataFrame) else y - encoded_cols, classes = zip( - *(_encode(getter[:, i]) for i in range(y.shape[1])) - ) - classes = list(classes) - if dtype is None: - dtype = cp.result_type(*(c.dtype for c in encoded_cols)) - y_encoded = cp.empty(shape=y.shape, dtype=dtype, order=order) - for i, col in enumerate(encoded_cols): - y_encoded[:, i] = col - - return y_encoded, classes - def decode_labels(y_encoded, classes, output_type="cupy"): """Convert encoded labels back into their original classes. diff --git a/python/cuml/cuml/ensemble/randomforestclassifier.py b/python/cuml/cuml/ensemble/randomforestclassifier.py index 1df2edde04..fb93d98fbd 100644 --- a/python/cuml/cuml/ensemble/randomforestclassifier.py +++ b/python/cuml/cuml/ensemble/randomforestclassifier.py @@ -7,14 +7,14 @@ import cuml.internals import cuml.internals.nvtx as nvtx from cuml.common.array_descriptor import CumlArrayDescriptor -from cuml.common.classification import decode_labels, preprocess_labels +from cuml.common.classification import decode_labels from cuml.common.doc_utils import generate_docstring, insert_into_docstring from cuml.ensemble.randomforest_common import BaseRandomForestModel from cuml.internals.array import CumlArray from cuml.internals.input_utils import input_to_cuml_array from cuml.internals.interop import UnsupportedOnGPU from cuml.internals.mixins import ClassifierMixin -from cuml.internals.validation import check_features +from cuml.internals.validation import check_features, check_y from cuml.metrics import accuracy_score @@ -222,15 +222,14 @@ def fit(self, X, y, *, convert_dtype=True) -> "RandomForestClassifier": y to be of dtype int32. This will increase memory used for the method. """ + y, classes = check_y(y, dtype=cp.int32, return_classes=True) X_m = input_to_cuml_array( X, convert_to_dtype=(np.float32 if convert_dtype else None), check_dtype=[np.float32, np.float64], order="F", + check_rows=y.shape[0], ).array - y, classes = preprocess_labels( - y, n_samples=X_m.shape[0], dtype=cp.int32 - ) self.classes_ = classes self.n_classes_ = len(classes) y_m = CumlArray(data=y) diff --git a/python/cuml/cuml/linear_model/logistic_regression.py b/python/cuml/cuml/linear_model/logistic_regression.py index c44efee975..4cb2457f0f 100644 --- a/python/cuml/cuml/linear_model/logistic_regression.py +++ b/python/cuml/cuml/linear_model/logistic_regression.py @@ -9,11 +9,7 @@ import cuml.internals from cuml.common.array_descriptor import CumlArrayDescriptor -from cuml.common.classification import ( - decode_labels, - preprocess_labels, - process_class_weight, -) +from cuml.common.classification import decode_labels, process_class_weight from cuml.common.doc_utils import generate_docstring from cuml.internals.array import CumlArray from cuml.internals.base import Base @@ -24,6 +20,7 @@ to_gpu, ) from cuml.internals.mixins import ClassifierMixin, SparseInputTagMixin +from cuml.internals.validation import check_y from cuml.linear_model.base import LinearClassifierMixin from cuml.solvers.qn import fit_qn @@ -306,7 +303,7 @@ def fit( """ Fit the model with X and y. """ - y, classes = preprocess_labels(y) + y, classes = check_y(y, return_classes=True) _, sample_weight = process_class_weight( classes, y, diff --git a/python/cuml/cuml/linear_model/mbsgd_classifier.py b/python/cuml/cuml/linear_model/mbsgd_classifier.py index 2a3906cf9c..6ba7ea94b0 100644 --- a/python/cuml/cuml/linear_model/mbsgd_classifier.py +++ b/python/cuml/cuml/linear_model/mbsgd_classifier.py @@ -6,10 +6,11 @@ import cuml.internals from cuml.common.array_descriptor import CumlArrayDescriptor -from cuml.common.classification import decode_labels, preprocess_labels +from cuml.common.classification import decode_labels from cuml.common.doc_utils import generate_docstring from cuml.internals.base import Base from cuml.internals.mixins import ClassifierMixin, FMajorInputTagMixin +from cuml.internals.validation import check_y from cuml.linear_model.base import LinearClassifierMixin from cuml.solvers.sgd import fit_sgd @@ -179,7 +180,7 @@ def fit(self, X, y, *, convert_dtype=True) -> "MBSGDClassifier": Fit the model with X and y. """ - y, classes = preprocess_labels(y) + y, classes = check_y(y, return_classes=True) if len(classes) > 2: raise ValueError( f"MBSGDClassifier only supports binary classification, got " diff --git a/python/cuml/cuml/neighbors/kneighbors_classifier.pyx b/python/cuml/cuml/neighbors/kneighbors_classifier.pyx index be032c2910..67d2e8a1ae 100644 --- a/python/cuml/cuml/neighbors/kneighbors_classifier.pyx +++ b/python/cuml/cuml/neighbors/kneighbors_classifier.pyx @@ -9,13 +9,14 @@ import numpy as np import cuml from cuml.common import input_to_cuml_array -from cuml.common.classification import decode_labels, preprocess_labels +from cuml.common.classification import decode_labels from cuml.common.doc_utils import generate_docstring from cuml.internals import get_handle from cuml.internals.array import CumlArray from cuml.internals.interop import UnsupportedOnGPU from cuml.internals.mixins import ClassifierMixin, FMajorInputTagMixin from cuml.internals.outputs import reflect, run_in_internal_context +from cuml.internals.validation import check_consistent_length, check_y from cuml.neighbors.nearest_neighbors import NeighborsBase from cuml.neighbors.weights import compute_weights @@ -175,13 +176,14 @@ class KNeighborsClassifier(ClassifierMixin, FMajorInputTagMixin, NeighborsBase): ) super().fit(X, convert_dtype=convert_dtype) - y, classes = preprocess_labels( + y, classes = check_y( y, - n_samples=self.n_samples_fit_, order="F", dtype=np.int32, - allow_multitarget=True + accept_multi_output=True, + return_classes=True, ) + check_consistent_length(X, y) self.classes_ = classes self._y = y return self diff --git a/python/cuml/cuml/svm/linear_svc.py b/python/cuml/cuml/svm/linear_svc.py index 1f45d2d7f8..135b66e960 100644 --- a/python/cuml/cuml/svm/linear_svc.py +++ b/python/cuml/cuml/svm/linear_svc.py @@ -8,11 +8,7 @@ import cuml.svm.linear from cuml.common.array_descriptor import CumlArrayDescriptor -from cuml.common.classification import ( - decode_labels, - preprocess_labels, - process_class_weight, -) +from cuml.common.classification import decode_labels, process_class_weight from cuml.common.doc_utils import generate_docstring from cuml.internals.array import CumlArray from cuml.internals.base import Base @@ -25,7 +21,7 @@ ) from cuml.internals.mixins import ClassifierMixin from cuml.internals.outputs import reflect, run_in_internal_context -from cuml.internals.validation import check_features, check_is_fitted +from cuml.internals.validation import check_features, check_is_fitted, check_y from cuml.linear_model.base import LinearClassifierMixin __all__ = ("LinearSVC",) @@ -246,15 +242,15 @@ def fit( self, X, y, sample_weight=None, *, convert_dtype=True ) -> "LinearSVC": """Fit the model according to the given training data.""" + y, classes = check_y(y, return_classes=True) X = input_to_cuml_array( X, convert_to_dtype=(np.float32 if convert_dtype else None), check_dtype=[np.float32, np.float64], + check_rows=y.shape[0], order="F", ).array - y, classes = preprocess_labels(y, n_samples=X.shape[0]) - _, sample_weight = process_class_weight( classes, y, diff --git a/python/cuml/cuml/svm/svc.py b/python/cuml/cuml/svm/svc.py index 39cf9edc80..12ac0860df 100644 --- a/python/cuml/cuml/svm/svc.py +++ b/python/cuml/cuml/svm/svc.py @@ -6,11 +6,7 @@ from sklearn.exceptions import NotFittedError from sklearn.utils.metaestimators import available_if -from cuml.common.classification import ( - decode_labels, - preprocess_labels, - process_class_weight, -) +from cuml.common.classification import decode_labels, process_class_weight from cuml.common.doc_utils import generate_docstring from cuml.common.sparse_utils import is_sparse from cuml.internals.array import CumlArray @@ -32,6 +28,7 @@ check_features, check_is_fitted, check_random_seed, + check_y, ) from cuml.multiclass import OneVsOneClassifier, OneVsRestClassifier from cuml.svm.svm_base import SVMBase @@ -443,7 +440,7 @@ def fit(self, X, y, sample_weight=None, *, convert_dtype=True) -> "SVC": if hasattr(self, "_multiclass"): del self._multiclass - y, classes = preprocess_labels(y) + y, classes = check_y(y, return_classes=True) if len(classes) == 1: raise ValueError( "This solver needs samples of at least 2 classes in the data, but " diff --git a/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml b/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml index a45a757298..4ce49f7c4d 100644 --- a/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml +++ b/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml @@ -737,10 +737,8 @@ - "sklearn.tests.test_common::test_estimators[LogisticRegression()-check_estimators_empty_data_messages]" - "sklearn.tests.test_common::test_estimators[LogisticRegression()-check_estimators_nan_inf]" - "sklearn.tests.test_common::test_estimators[LogisticRegression()-check_fit2d_1sample]" - - "sklearn.tests.test_common::test_estimators[LogisticRegression()-check_requires_y_none]" - "sklearn.tests.test_common::test_estimators[LogisticRegression()-check_sample_weights_not_an_array]" - "sklearn.tests.test_common::test_estimators[LogisticRegression()-check_sparsify_coefficients]" - - "sklearn.tests.test_common::test_estimators[LogisticRegression()-check_supervised_y_2d]" - "sklearn.tests.test_common::test_estimators[LogisticRegression()-check_supervised_y_no_nan]" - "sklearn.tests.test_multioutput::test_multiclass_multioutput_estimator_predict_proba" - reason: Test should fail with cuml.accel (scikit-learn 1.6+) @@ -1056,7 +1054,6 @@ tests: - "sklearn.tests.test_common::test_estimators[SVC()-check_classifier_data_not_an_array]" - "sklearn.tests.test_common::test_estimators[SVC()-check_estimators_nan_inf]" - - "sklearn.tests.test_common::test_estimators[SVC()-check_requires_y_none]" - "sklearn.tests.test_common::test_estimators[SVC()-check_sample_weights_not_an_array]" - "sklearn.tests.test_common::test_estimators[SVC()-check_supervised_y_no_nan]" - reason: SVM doesn't handle sample_weight identically to sklearn @@ -1115,7 +1112,6 @@ - "sklearn.tests.test_common::test_estimators[KNeighborsClassifier()-check_dtype_object]" - "sklearn.tests.test_common::test_estimators[KNeighborsClassifier()-check_estimators_empty_data_messages]" - "sklearn.tests.test_common::test_estimators[KNeighborsClassifier()-check_estimators_nan_inf]" - - "sklearn.tests.test_common::test_estimators[KNeighborsClassifier()-check_requires_y_none]" - "sklearn.tests.test_common::test_estimators[KNeighborsClassifier()-check_supervised_y_no_nan]" - "sklearn.tests.test_common::test_estimators[KNeighborsRegressor()-check_dtype_object]" - "sklearn.tests.test_common::test_estimators[KNeighborsRegressor()-check_estimators_empty_data_messages]" @@ -1368,10 +1364,8 @@ - "sklearn.tests.test_common::test_estimators[LinearSVC()-check_classifier_data_not_an_array]" - "sklearn.tests.test_common::test_estimators[LinearSVC()-check_dtype_object]" - "sklearn.tests.test_common::test_estimators[LinearSVC()-check_estimators_nan_inf]" - - "sklearn.tests.test_common::test_estimators[LinearSVC()-check_requires_y_none]" - "sklearn.tests.test_common::test_estimators[LinearSVC()-check_sample_weights_not_an_array]" - "sklearn.tests.test_common::test_estimators[LinearSVC()-check_sparsify_coefficients]" - - "sklearn.tests.test_common::test_estimators[LinearSVC()-check_supervised_y_2d]" - "sklearn.tests.test_common::test_estimators[LinearSVC()-check_supervised_y_no_nan]" - "sklearn.tests.test_common::test_estimators[LinearSVR()-check_dtype_object]" - "sklearn.tests.test_common::test_estimators[LinearSVR()-check_estimators_nan_inf]" diff --git a/python/cuml/tests/test_sklearn_compatibility.py b/python/cuml/tests/test_sklearn_compatibility.py index 287abd2360..a593fdfdfa 100644 --- a/python/cuml/tests/test_sklearn_compatibility.py +++ b/python/cuml/tests/test_sklearn_compatibility.py @@ -129,10 +129,8 @@ "check_classifiers_train": "LogisticRegression does not handle list inputs", "check_classifiers_train(readonly_memmap=True)": "LogisticRegression does not handle readonly memmap", "check_classifiers_train(readonly_memmap=True,X_dtype=float32)": "LogisticRegression does not handle readonly memmap with float32", - "check_supervised_y_2d": "LogisticRegression does not handle 2D y", "check_class_weight_classifiers": "LogisticRegression does not handle class weights properly", "check_fit2d_1sample": "LogisticRegression does not handle single sample", - "check_requires_y_none": "LogisticRegression does not handle y=None", }, LinearRegression: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", @@ -187,9 +185,6 @@ "check_estimators_nan_inf": "KNeighborsClassifier does not check for NaN and inf", "check_classifier_data_not_an_array": "KNeighborsClassifier does not handle non-array data", "check_classifiers_train": "KNeighborsClassifier does not validate input data properly", - "check_supervised_y_no_nan": "KNeighborsClassifier does not check for NaN in y", - "check_supervised_y_2d": "KNeighborsClassifier does not handle 2D y", - "check_requires_y_none": "KNeighborsClassifier does not handle y=None", }, RandomForestClassifier: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", @@ -201,10 +196,7 @@ "check_classifiers_train": "RandomForestClassifier does not handle list inputs", "check_classifiers_train(readonly_memmap=True)": "RandomForestClassifier does not handle readonly memmap", "check_classifiers_train(readonly_memmap=True,X_dtype=float32)": "RandomForestClassifier does not handle readonly memmap with float32", - "check_supervised_y_no_nan": "RandomForestClassifier does not check for NaN in y", - "check_supervised_y_2d": "RandomForestClassifier does not handle 2D y", "check_dict_unchanged": "RandomForestClassifier modifies input dictionaries", - "check_requires_y_none": "RandomForestClassifier does not handle y=None", }, KNeighborsRegressor: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", @@ -238,8 +230,6 @@ "check_classifiers_train": "LinearSVC does not handle list inputs", "check_classifiers_train(readonly_memmap=True)": "LinearSVC does not handle readonly memmap", "check_classifiers_train(readonly_memmap=True,X_dtype=float32)": "LinearSVC does not handle readonly memmap with float32", - "check_supervised_y_2d": "LinearSVC does not handle 2D y", - "check_requires_y_none": "LinearSVC does not handle y=None", }, LinearSVR: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", @@ -269,9 +259,7 @@ "check_classifiers_train": "SVC does not handle list inputs", "check_classifiers_train(readonly_memmap=True)": "SVC does not handle readonly memmap", "check_classifiers_train(readonly_memmap=True,X_dtype=float32)": "SVC does not handle readonly memmap with float32", - "check_requires_y_none": "SVC does not handle y=None", "check_sample_weights_list": "SVC does not handle list sample weights", - "check_supervised_y_2d": "SVC does not warn on 1 column 2D y", }, SVR: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", From c475ce50b0a4663b0011c6b484248cfa37b93b6d Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Thu, 16 Apr 2026 15:39:38 -0500 Subject: [PATCH 11/29] Fix tests with `cudf.pandas` enabled --- python/cuml/cuml/internals/validation.py | 10 ++-- python/cuml/tests/test_validation.py | 68 +++++++++++------------- 2 files changed, 38 insertions(+), 40 deletions(-) diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py index effef24f61..8f24fa61c9 100644 --- a/python/cuml/cuml/internals/validation.py +++ b/python/cuml/cuml/internals/validation.py @@ -620,8 +620,12 @@ def check_array( array = array.to_numpy(dtype=dtype) if mem_type == "device": array = cp.asarray(array, dtype=dtype, order=order) - elif mem_type is None and cudf.pandas.LOADED: - # With cudf.pandas, the array is already on device + elif ( + mem_type is None + and cudf.pandas.LOADED + and np.isdtype(array.dtype, ("numeric", "bool")) + ): + # With cudf.pandas, supported arrays are already on device array = cp.asarray(array, dtype=dtype, order=order) else: array = np.asarray(array, dtype=dtype, order=order) @@ -784,7 +788,7 @@ def check_y( input_dtype = y.dtype if mem_type is None: mem_type = "host" if isinstance(y, np.ndarray) else "device" - if y.dtype.kind in "iufb": + if np.isdtype(y.dtype, ("numeric", "bool")): y = cp.asarray(y) else: y = (cudf.DataFrame if y.ndim == 2 else cudf.Series)( diff --git a/python/cuml/tests/test_validation.py b/python/cuml/tests/test_validation.py index e4b01afa8f..0db9ca24da 100644 --- a/python/cuml/tests/test_validation.py +++ b/python/cuml/tests/test_validation.py @@ -29,6 +29,28 @@ ) +def is_cuda_output(mem_type, value=..., kind=...): + """Infer if cuda output given `mem_type` and a value or kind""" + if mem_type is None: + if value is not ...: + if cudf.pandas.LOADED: + if isinstance(value, pd.DataFrame): + return not any(d == "object" for d in value.dtypes) + elif isinstance(value, pd.Series): + return value.dtype != "object" + return isinstance( + value, + (cp.ndarray, cp_sp.spmatrix, cudf.Series, cudf.DataFrame), + ) + elif kind is not ...: + if cudf.pandas.LOADED: + return kind in ("cupy", "cudf", "pandas") + return kind in ("cupy", "cudf") + else: + raise ValueError("Expected `value` or `kind`") + return mem_type == "device" + + def gen_dense_array( *, kind="cupy", @@ -618,15 +640,7 @@ def test_check_array_complex_errors(func): ) def test_check_array_mem_type(array, mem_type): out = check_array(array, mem_type=mem_type, ensure_2d=False) - if mem_type is None: - cls = ( - cp.ndarray - if isinstance(array, (cp.ndarray, cudf.DataFrame, cudf.Series)) - else np.ndarray - ) - else: - cls = cp.ndarray if mem_type == "device" else np.ndarray - + cls = cp.ndarray if is_cuda_output(mem_type, array) else np.ndarray assert isinstance(out, cls) cp.testing.assert_allclose(as_cupy(array), as_cupy(out)) @@ -730,7 +744,7 @@ def test_check_array_dataframe_mixed_dtypes(kind, mem_type): } ) # Non-numeric columns -> object dtype by default - if mem_type == "device" or mem_type is None and kind == "cudf": + if is_cuda_output(mem_type, df): # cupy doesn't support object dtypes with pytest.raises((ValueError, TypeError), match="object"): check_array(df, mem_type=mem_type) @@ -793,7 +807,7 @@ def test_check_array_object_dtype(kind, mem_type): elif kind == "pandas": array = pd.Series(array) - if mem_type == "device" or mem_type is None and kind == "cudf": + if is_cuda_output(mem_type, array): # cupy doesn't support object dtypes with pytest.raises((ValueError, TypeError), match="object"): check_array(array, mem_type=mem_type, ensure_2d=False) @@ -837,11 +851,7 @@ def test_check_array_sparse_not_supported(array): mem_type=st.sampled_from(["device", "host", None]), ) def test_check_array_sparse_input(array, mem_type): - if mem_type is None: - ns = cp_sp if cp_sp.issparse(array) else sp - else: - ns = cp_sp if mem_type == "device" else sp - + ns = cp_sp if is_cuda_output(mem_type, array) else sp # dtype=None case if ( mem_type == "device" @@ -881,11 +891,7 @@ def test_check_array_sparse_input(array, mem_type): format=st.sampled_from(["csr", "csc", "coo"]), ) def test_check_array_sparse_input_format(array, mem_type, format): - if mem_type is None: - ns = cp_sp if cp_sp.issparse(array) else sp - else: - ns = cp_sp if mem_type == "device" else sp - + ns = cp_sp if is_cuda_output(mem_type, array) else sp out = check_array(array, accept_sparse=True, mem_type=mem_type) assert ns.issparse(out) assert out.dtype == array.dtype @@ -1018,17 +1024,7 @@ def test_check_array_ensure_non_negative(): mem_type=st.sampled_from(["device", "host", None]), ) def test_check_y(y, mem_type, order): - if mem_type == "device": - exp_type = cp.ndarray - elif mem_type == "host": - exp_type = np.ndarray - else: - exp_type = ( - cp.ndarray - if isinstance(y, (cp.ndarray, cudf.DataFrame, cudf.Series)) - else np.ndarray - ) - + exp_type = cp.ndarray if is_cuda_output(mem_type, y) else np.ndarray out = check_y(y, mem_type=mem_type, order=order, accept_multi_output=True) if hasattr(y, "dtype"): assert out.dtype == y.dtype @@ -1120,10 +1116,8 @@ def test_check_y_return_classes( ): # Construct input data if label_dtype in ("O", "U"): - if mem_type is None: - assume(kind not in ("cudf", "cupy")) - else: - assume(not (kind == "cupy" or mem_type == "device")) + # cupy doesn't support these types + assume(not (kind == "cupy" or is_cuda_output(mem_type, kind=kind))) labels = np.array(["a", "b", "c", "d"], dtype=label_dtype) elif label_dtype == "bool": labels = np.array([True, False]) @@ -1152,7 +1146,7 @@ def test_check_y_return_classes( sol = inds.flatten() if inds.ndim == 2 and inds.shape[1] == 1 else inds if dtype is not None: sol = sol.astype(dtype) - if mem_type == "device" or mem_type is None and kind in ("cupy", "cudf"): + if is_cuda_output(mem_type, kind=kind): sol = cp.asarray(sol) y2 = check_array(y, mem_type="host", ensure_2d=False) From 0d4462d2ad9b52fc6656000d4743a40fd6b730aa Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Thu, 16 Apr 2026 15:54:03 -0500 Subject: [PATCH 12/29] Change default order to `'A'` In most cases we do want contiguous inputs, supporting strided inputs is the exception. Seems safer to have the user opt-in to supporting non-contiguous inputs than accidentally forgetting to pass `order='A'` and having weird errors turn up on strided inputs. --- python/cuml/cuml/internals/validation.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py index 8f24fa61c9..73f32652ec 100644 --- a/python/cuml/cuml/internals/validation.py +++ b/python/cuml/cuml/internals/validation.py @@ -417,7 +417,7 @@ def check_array( dtype=None, convert_dtype=True, mem_type="device", - order=None, + order="A", ensure_all_finite=True, ensure_non_negative=False, ensure_2d=True, @@ -456,10 +456,11 @@ def check_array( a ``scipy.sparse.spmatrix`` if sparse. If ``None``, the output will have the same memory type as the input (i.e. device if already on device, host otherwise). - order : {'F', 'C', 'A', None}, default=None + order : {'F', 'C', 'A', None}, default='A' The order and contiguity to enforce for dense outputs. Use 'F' for F-contiguous outputs, 'C' for C-contiguous outputs, 'A' for either F or - C contiguous, or `None` for no contiguity requirements. + C contiguous, or `None` for no contiguity requirements (may be + non-contiguous!). ensure_all_finite : bool or 'allow-nan', default=True If True, an error will be raised if non-finite values are found in the input. If 'allow-nan', an error will be raised if infinite values are @@ -713,7 +714,7 @@ def check_y( dtype=None, convert_dtype=True, mem_type="device", - order=None, + order="A", accept_multi_output=False, return_classes=False, ): @@ -737,10 +738,11 @@ def check_y( ``cupy.ndarray``. If 'host', the output will be a ``numpy.ndarray``. If ``None``, the output will have the same memory type as the input (i.e. device if already on device, host otherwise). - order : {'F', 'C', 'A', None}, default=None + order : {'F', 'C', 'A', None}, default='A' The order and contiguity to enforce for dense outputs. Use 'F' for F-contiguous outputs, 'C' for C-contiguous outputs, 'A' for either F or - C contiguous, or `None` for no contiguity requirements. + C contiguous, or `None` for no contiguity requirements (may be + non-contiguous!). accept_multi_output : bool, default=False Whether multi-output y is accepted. By default only 1D inputs (or 2D inputs with a single column) are accepted. Set to True to accept From 3d7bbf9d5dc31b09f7b2eb5d7f1a8fecb6a1b594 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Thu, 16 Apr 2026 16:27:25 -0500 Subject: [PATCH 13/29] Add `check_sample_weight` --- python/cuml/cuml/internals/validation.py | 69 ++++++++++++++++++++++++ python/cuml/tests/test_validation.py | 63 ++++++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py index 73f32652ec..6b73ebea8f 100644 --- a/python/cuml/cuml/internals/validation.py +++ b/python/cuml/cuml/internals/validation.py @@ -25,6 +25,7 @@ "check_non_negative", "check_array", "check_y", + "check_sample_weight", ) @@ -885,3 +886,71 @@ def _encode(y): y = y.get(order=order) return y, classes + + +def check_sample_weight( + sample_weight, + *, + dtype=None, + convert_dtype=True, + mem_type="device", + order="A", + ensure_non_negative=False, +): + """Validate and coerce ``sample_weight`` to a supported type. + + Parameters + ---------- + sample_weight : array-like, scalar, or None + The ``sample_weight`` input to validate. + dtype : None, dtype, list[dtype], default=None + The dtype(s) to support. By default no dtype enforcement is performed; + for classifiers the output will be a suitable integral type, otherwise + the input dtype will be used. Pass a dtype or a list of supported + dtypes to enforce a dtype for the output. If the input doesn't have a + supported dtype, it will be converted to the first listed dtype. + convert_dtype : bool, default=True + Whether to support dtype conversion. If False, an error will be raised + if the input isn't a supported dtype. + mem_type : {'device', 'host'} or None, default='device' + The memory type use for the output. If 'device', the output will be a + ``cupy.ndarray``. If 'host', the output will be a ``numpy.ndarray``. If + ``None``, the output will have the same memory type as the input (i.e. + device if already on device, host otherwise). + order : {'F', 'C', 'A', None}, default='A' + The order and contiguity to enforce for dense outputs. Use 'F' for + F-contiguous outputs, 'C' for C-contiguous outputs, 'A' for either F or + C contiguous, or `None` for no contiguity requirements (may be + non-contiguous!). + ensure_non_negative : bool, default=False + If True, an error will be raised if negative values are found in the + input. By default ``check_non_negative`` is skipped. + + Returns + ------- + sample_weight : cupy.ndarray, numpy.ndarray, or None + The converted and validated weights. + """ + if sample_weight is None: + return None + + # A uniform sample_weight is the same as unweighted + if cp.isscalar(sample_weight): + return None + + sample_weight = check_array( + sample_weight, + dtype=dtype, + convert_dtype=convert_dtype, + mem_type=mem_type, + order=order, + ensure_2d=False, + ensure_non_negative=ensure_non_negative, + input_name="sample_weight", + ) + if sample_weight.ndim != 1: + raise ValueError( + f"Sample weights must be 1D array or scalar, got " + f"{sample_weight.ndim}D array." + ) + return sample_weight diff --git a/python/cuml/tests/test_validation.py b/python/cuml/tests/test_validation.py index 0db9ca24da..d0f9cc6f6d 100644 --- a/python/cuml/tests/test_validation.py +++ b/python/cuml/tests/test_validation.py @@ -25,6 +25,7 @@ check_features, check_non_negative, check_random_seed, + check_sample_weight, check_y, ) @@ -1231,3 +1232,65 @@ def test_check_y_classifier_on_floating_input(): def test_check_y_none(): with pytest.raises(ValueError, match="This estimator requires y"): check_y(None) + + +@example( + sample_weight=cp.array([1.5, 2.5, 3.5], dtype="float32"), + dtype=None, + order="C", + mem_type="host", +) +@example( + sample_weight=np.array([1.5, 2.5, 3.5], dtype="float64")[::2], + dtype="float32", + order="A", + mem_type="device", +) +@given( + sample_weight=dense_arrays(ndim=1, dtype=("int32", "float32", "float64")), + dtype=st.sampled_from(["float32", "float64", None]), + order=st.sampled_from(["C", "F", "A", None]), + mem_type=st.sampled_from(["device", "host", None]), +) +def test_check_sample_weight(sample_weight, dtype, order, mem_type): + exp_type = ( + cp.ndarray if is_cuda_output(mem_type, sample_weight) else np.ndarray + ) + out = check_sample_weight( + sample_weight, dtype=dtype, order=order, mem_type=mem_type + ) + if dtype is None and hasattr(sample_weight, "dtype"): + assert out.dtype == sample_weight.dtype + elif dtype is not None: + assert out.dtype == dtype + assert isinstance(out, exp_type) + assert_contiguity(out, order) + cp.testing.assert_allclose(as_cupy(out), as_cupy(sample_weight)) + + +@pytest.mark.parametrize("shape", [(4, 5), (4, 1)]) +def test_check_sample_weight_errors_2d(shape): + bad = np.ones(shape) + with pytest.raises( + ValueError, match="Sample weights must be 1D array or scalar" + ): + check_sample_weight(bad) + + +def test_check_sample_weight_scalar_or_none(): + assert check_sample_weight(None) is None + assert check_sample_weight(1.5) is None + assert check_sample_weight(np.float32(1.0)) is None + + +def test_check_sample_weight_ensure_non_negative(): + """Tests plumbing of check_sample_weight -> check_non_negative""" + array = np.array([-1, 1, 2], dtype="float32") + + # No error, check disabled by default + check_sample_weight(array) + + with pytest.raises( + ValueError, match="Negative values in data passed to sample_weight" + ): + check_sample_weight(array, ensure_non_negative=True) From 2891a8bc622a0967289642905f84bff03eb2e6d7 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Thu, 16 Apr 2026 17:21:36 -0500 Subject: [PATCH 14/29] Add `check_inputs` --- python/cuml/cuml/internals/validation.py | 199 +++++++++++++++++++++++ python/cuml/tests/test_validation.py | 102 ++++++++++++ 2 files changed, 301 insertions(+) diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py index 6b73ebea8f..c8d03ebbb3 100644 --- a/python/cuml/cuml/internals/validation.py +++ b/python/cuml/cuml/internals/validation.py @@ -26,6 +26,7 @@ "check_array", "check_y", "check_sample_weight", + "check_inputs", ) @@ -954,3 +955,201 @@ def check_sample_weight( f"{sample_weight.ndim}D array." ) return sample_weight + + +def check_inputs( + estimator, + X, + y=..., + sample_weight=..., + *, + accept_sparse=False, + accept_large_sparse=False, + dtype=None, + y_dtype=..., + sample_weight_dtype=..., + convert_dtype=True, + mem_type="device", + order="A", + ensure_all_finite=True, + ensure_non_negative=False, + ensure_min_samples=1, + ensure_min_features=1, + accept_multi_output=False, + return_classes=False, + return_index=False, + reset=False, +): + """Validate and coerce common inputs to an estimator method. + + This plumbs together several common checks. For a method with ``X``, ``y``, + and ``sample_weight``, it's roughly equivalent to: + + ``` + check_features(estimator, X, reset=reset) + X = check_array(X, input_name="X", ...) + y = check_y(y, ...) + sample_weight = check_sample_weight(sample_weight, ...) + check_consistent_length(X, y, sample_weight) + ``` + + If this pattern doesn't work for an estimator, you can call always call + some of the individual checks directly. + + Parameters + ---------- + estimator : Base + The estimator to check. + X : array-like + The ``X`` input. + y : array-like, default=... + The ``y`` input. May be omitted. + sample_weight : array-like, scalar, or None + The ``sample_weight`` input. May be omitted. + accept_sparse : bool, str, list[str], default=False + The sparse matrix format(s) to support. If the input is sparse + but not in a supported format, it will be converted to the first + listed format. Pass True to support any input format. The default + of False will raise an error on sparse inputs. + accept_large_sparse : bool, default=False + Whether large (int64) indices are supported for sparse containers with + CSR/CSC/COO/BSR formats. If not supported, an appropriate error will be + raised if the sparse indices aren't int32. + dtype : None, dtype, list[dtype], default=None + The dtype(s) to support for X. By default no dtype validation is performed. + Pass a dtype or a list of supported dtypes to enforce a dtype for the + output. If the input doesn't have a supported dtype, it will be + converted to the first listed dtype. + y_dtype : None, dtype, list[dtype], default=... + The dtype(s) to support for y. If not specified, defaults to ``None`` + if ``return_classes=True``, and the output dtype of ``X`` otherwise. + sample_weight_dtype : None, dtype, list[dtype], default=... + The dtype(s) to support for sample_weight. If not specified, defaults + to the output dtype of ``X``. + convert_dtype : bool, default=True + Whether to support dtype conversion. If False, an error will be raised + if the input isn't a supported dtype. + mem_type : {'device', 'host'} or None, default='device' + The memory type use for the output. If 'device', the output will be a + ``cupy.ndarray`` if dense, or a ``cupyx.scipy.sparse.spmatrix`` if + sparse. If 'host', the output will be a ``numpy.ndarray`` if dense, or + a ``scipy.sparse.spmatrix`` if sparse. If ``None``, the output will + have the same memory type as the input (i.e. device if already on + device, host otherwise). + order : {'F', 'C', 'A', None}, default='A' + The order and contiguity to enforce for dense outputs. Use 'F' for + F-contiguous outputs, 'C' for C-contiguous outputs, 'A' for either F or + C contiguous, or `None` for no contiguity requirements (may be + non-contiguous!). + ensure_all_finite : bool or 'allow-nan', default=True + If True, an error will be raised if non-finite values are found in X. + If 'allow-nan', an error will be raised if infinite values are + found (but not for NaN). If False then ``check_all_finite`` is skipped. + ensure_non_negative : bool, default=False + If True, an error will be raised if negative values are found in X. By + default ``check_non_negative`` is skipped. + ensure_min_samples : int, default=1 + A minimum number of samples to require. Set to 0 for no minimum. + ensure_min_features : int, default=1 + A minimum number of features to require for 2D inputs. Set to 0 for no + minimum. + accept_multi_output : bool, default=False + Whether multi-output y is accepted. By default only 1D inputs (or 2D + inputs with a single column) are accepted. Set to True to accept + multi-column inputs as well. + return_classes : bool, default=False + Set to True to also label encode ``y`` and return the ``classes``. + return_index : bool, default=False + Whether to return the index of ``X`` (if a dataframe-like value). + This is useful for functions that need to return an output with a + dataframe index aligned with the input. + reset : bool, default=False + If True, ``n_features_in_`` and ``feature_names_in_`` are set on + ``estimator`` to match ``X``. Otherwise ``X`` is checked to match the + existing ``n_features_in_`` and ``feature_names_in_``. ``reset=True`` + should be used for fit-like methods, and False otherwise. + + Returns + ------- + X : dense or sparse array + The converted and validated array. Depending on input and parameters, + will be one of ``cupy.ndarray``, ``numpy.ndarray``, + ``cupyx.scipy.sparse.spmatrix``, or ``scipy.sparse.spmatrix``. + y : cupy.ndarray or numpy.ndarry + The converted and validated array. Omitted if no ``y`` provided. + sample_weight : cupy.ndarray, numpy.ndarray, or None + The converted and validated weights. Omitted if no ``sample_weight`` + provided. + classes : numpy.ndarray or list[numpy.ndarray] + The collected classes from ``y`` for a classifier input. Only returned + if ``return_classes=True``. + index : pandas.Index, cudf.Index, or None + The index of the input if a dataframe-like, or None if no index. The + index will be converted to match ``mem_type``. Only returned if + ``return_index=True``. + """ + check_features(estimator, X, reset=reset) + + # Validate X + X = check_array( + X, + accept_sparse=accept_sparse, + accept_large_sparse=accept_large_sparse, + dtype=dtype, + convert_dtype=convert_dtype, + mem_type=mem_type, + order=order, + ensure_all_finite=ensure_all_finite, + ensure_non_negative=ensure_non_negative, + ensure_min_samples=ensure_min_samples, + ensure_min_features=ensure_min_features, + return_index=return_index, + input_name="X", + ) + if return_index: + X, index = X + else: + index = None + out = [X] + + # Validate y + classes = None + if y is not ...: + if y_dtype is ...: + # Follow X dtype by default unless a classifier + y_dtype = None if return_classes else X.dtype + y = check_y( + y, + dtype=y_dtype, + convert_dtype=convert_dtype, + mem_type=mem_type, + order=order, + accept_multi_output=accept_multi_output, + return_classes=return_classes, + ) + if return_classes: + y, classes = y + out.append(y) + + # Validate sample_weight + if sample_weight is not ...: + if sample_weight_dtype is ...: + # Follow X dtype by default + sample_weight_dtype = X.dtype + sample_weight = check_sample_weight( + sample_weight, + dtype=sample_weight_dtype, + convert_dtype=convert_dtype, + mem_type=mem_type, + order=order, + ) + out.append(sample_weight) + + check_consistent_length(*out) + + if return_classes: + out.append(classes) + if return_index: + out.append(index) + + return out[0] if len(out) == 1 else tuple(out) diff --git a/python/cuml/tests/test_validation.py b/python/cuml/tests/test_validation.py index d0f9cc6f6d..2877ef491a 100644 --- a/python/cuml/tests/test_validation.py +++ b/python/cuml/tests/test_validation.py @@ -23,6 +23,7 @@ check_array, check_consistent_length, check_features, + check_inputs, check_non_negative, check_random_seed, check_sample_weight, @@ -1294,3 +1295,104 @@ def test_check_sample_weight_ensure_non_negative(): ValueError, match="Negative values in data passed to sample_weight" ): check_sample_weight(array, ensure_non_negative=True) + + +def test_check_inputs_X(): + model = MyModel() + X = np.arange(6).reshape((3, 2)) + + X2 = check_inputs(model, X, reset=True) + assert model.n_features_in_ == 2 + cp.testing.assert_array_equal(X2, cp.asarray(X)) + + with pytest.raises( + ValueError, + match="X has 3 features, but MyModel is expecting 2 features as input", + ): + check_inputs(model, np.ones((3, 3))) + + +def test_check_inputs_X_y(): + model = MyModel() + + X = np.arange(6, dtype="float32").reshape((3, 2)) + y = cp.arange(3, dtype="int32") + + X2, y2 = check_inputs(model, X, y, reset=True) + assert model.n_features_in_ == 2 + assert X2.dtype == "float32" + # y defaults to X output dtype + assert y2.dtype == "float32" + cp.testing.assert_array_equal(X2, cp.asarray(X)) + cp.testing.assert_array_equal(y2, y.astype("float32")) + + # check y_dtype overrides + _, y2 = check_inputs(model, X, y, y_dtype="int32", reset=True) + assert y2.dtype == "int32" + + +def test_check_inputs_X_y_sample_weight(): + model = MyModel() + X = np.arange(6, dtype="float32").reshape((3, 2)) + y = cp.ones(3, dtype="int32") + sample_weight = [10, 20, 30] + + X2, y2, sample_weight2 = check_inputs( + model, X, y, sample_weight, reset=True + ) + assert model.n_features_in_ == 2 + assert X2.dtype == "float32" + assert y2.dtype == "float32" + # sample_weight defaults to X output dtype + assert sample_weight2.dtype == "float32" + cp.testing.assert_array_equal(X2, cp.asarray(X)) + cp.testing.assert_array_equal(y2, y.astype("float32")) + cp.testing.assert_array_equal( + sample_weight2, cp.array(sample_weight, dtype="float32") + ) + + # check sample_weight_dtype overrides + _, _, sample_weight2 = check_inputs( + model, X, y, sample_weight, sample_weight_dtype="float64" + ) + assert sample_weight2.dtype == "float64" + + # sample_weight=None is supported + _, _, sample_weight2 = check_inputs(model, X, y, None) + assert sample_weight2 is None + + +def test_check_inputs_return_classes(): + model = MyModel() + X = cp.ones((3, 2), "float32") + y = np.array(["a", "b", "a"], dtype="O") + + _, y2, classes = check_inputs(model, X, y, return_classes=True, reset=True) + # y defaults to an integral type + assert y2.dtype.kind in "iu" + cp.testing.assert_array_equal(y2, cp.array([0, 1, 0])) + np.testing.assert_array_equal(classes, np.array(["a", "b"], dtype="O")) + + # check y_dtype overrides + _, y2, classes = check_inputs( + model, X, y, y_dtype="float32", return_classes=True, reset=True + ) + assert y2.dtype == "float32" + cp.testing.assert_array_equal(y2, cp.array([0, 1, 0], dtype="float32")) + + +def test_check_inputs_return_index(): + model = MyModel() + X = pd.DataFrame({"x": [1.0, 2.0, 3.0]}, index=[10, 20, 30]) + sample_weight = [0, 1, 0] + + X2, sample_weight2, index = check_inputs( + model, X, sample_weight=sample_weight, return_index=True, reset=True + ) + assert X2.dtype == sample_weight2.dtype + cp.testing.assert_array_equal(X2, cp.asarray(X.to_numpy())) + cp.testing.assert_array_equal( + sample_weight2, cp.asarray(sample_weight, dtype=X2.dtype) + ) + assert isinstance(index, cudf.Index) + assert (index == cudf.Index([10, 20, 30])).all() From 312d1a28c11528f6cc3996d927d047ee89361ee3 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Fri, 17 Apr 2026 16:34:33 -0500 Subject: [PATCH 15/29] Error messages more consistent with sklearn Improves error messages for non-finite or non-integral inputs when those are requirements so the errors are more in-line with with sklearn produces. --- python/cuml/cuml/internals/validation.py | 116 ++++++++++-------- .../upstream/scikit-learn/xfail-list.yaml | 6 - python/cuml/tests/test_validation.py | 60 ++++----- 3 files changed, 99 insertions(+), 83 deletions(-) diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py index c8d03ebbb3..325ce725c7 100644 --- a/python/cuml/cuml/internals/validation.py +++ b/python/cuml/cuml/internals/validation.py @@ -315,25 +315,17 @@ def check_consistent_length(*arrays) -> None: ) -_cupy_all_finite = cp.ReductionKernel( +# Returns status in a bitfield: +# 0b01: contains NaN +# 0b10: contains +/-inf +_cupy_any_inf_or_nan = cp.ReductionKernel( "T x", - "bool out", - "isfinite(x)", - "a && b", + "uint8 out", + "((unsigned char)isinf(x)) << 1 | ((unsigned char)isnan(x))", + "a | b", "out = a", - "true", - "all_finite", -) - - -_cupy_all_finite_or_nan = cp.ReductionKernel( - "T x", - "bool out", - "isinf(x)", - "a || b", - "out = !a", - "false", - "all_finite_or_nan", + "0", + "any_inf_or_nan", ) @@ -367,30 +359,29 @@ def check_all_finite(array, *, allow_nan=False, input_name=None) -> None: # No-op for empty inputs return + has_nan = has_inf = False if isinstance(array, cp.ndarray): - if allow_nan: - ok = _cupy_all_finite_or_nan(array) - else: - ok = _cupy_all_finite(array) + status = _cupy_any_inf_or_nan(array).item() + has_nan = status & 0b01 + has_inf = status & 0b10 else: # First try an O(1) space solution for the common case with np.errstate(over="ignore"): x_sum = array.sum() - if np.isfinite(x_sum): - ok = True - elif not allow_nan and np.isnan(x_sum): - ok = False - else: - # Maybe overflow or nan in data, fallback to O(n) path - if allow_nan: - ok = not np.isinf(array).any() - else: - ok = np.isfinite(array).all() - if not ok: - kind = "infinite" if allow_nan else "NaN or infinite" - raise ValueError( - f"Input {input_name or 'array'} contains {kind} values" - ) + if not np.isfinite(x_sum): + has_nan = np.isnan(x_sum) + if not has_nan or allow_nan: + has_inf = np.isinf(array).any() + + if has_nan and not allow_nan: + msg = "NaN" + elif has_inf: + msg = f"infinity or a value too large for {array.dtype!r}" + else: + msg = None + + if msg is not None: + raise ValueError(f"Input {input_name or 'array'} contains {msg}.") def check_non_negative(array, *, input_name=None) -> None: @@ -699,17 +690,51 @@ def check_array( return array -_is_integral = cp.ReductionKernel( +# Returns status in a bitfield: +# 0b001: contains NaN +# 0b010: contains +/-inf +# 0b100: contains real (non-integral) values +_cupy_any_inf_or_nan_or_real = cp.ReductionKernel( "T x", - "bool out", - "isfinite(x) && (ceilf(x) == x)", - "a && b", + "uint8 out", + ( + "((unsigned char)(ceilf(x) != x)) << 2 " + "| ((unsigned char)isinf(x)) << 1 " + "| ((unsigned char)isnan(x))" + ), + "a | b", "out = a", - "true", - "is_integral", + "0", + "any_inf_or_nan_or_real", ) +def _check_classification_targets(y): + """Check if `y` is composed of valid class labels. + + Catches NaN, infinity, and non-integral inputs. + + Parameters + ---------- + y : cupy.ndarray + The ``y`` input to check. + """ + if y.dtype.kind == "f": + status = _cupy_any_inf_or_nan_or_real(y) + if status & 0b001: + raise ValueError("Input y contains NaN.") + elif status & 0b010: + raise ValueError( + f"Input y contains infinity or a value too large for {y.dtype!r}." + ) + elif status & 0b100: + raise ValueError( + "Unknown label type: continuous. Maybe you are trying to fit a " + "classifier, which expects discrete classes on a regression target " + "with continuous values." + ) + + def check_y( y, *, @@ -839,12 +864,7 @@ def check_y( # as the classes. def _encode(y): """Encode `y` to codes and classes""" - if y.dtype.kind == "f" and not _is_integral(y): - raise ValueError( - "Unknown label type: continuous. Maybe you are trying to fit a " - "classifier, which expects discrete classes on a regression target " - "with continuous values." - ) + _check_classification_targets(y) if isinstance(y, cudf.Series): y = y.astype("category") codes = cp.asarray(y.cat.codes) diff --git a/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml b/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml index 4ce49f7c4d..624a272efd 100644 --- a/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml +++ b/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml @@ -468,7 +468,6 @@ - "sklearn.tests.test_common::test_estimators[Pipeline(steps=[('scaler',StandardScaler()),('final_estimator',LogisticRegression())])-check_estimators_nan_inf]" - "sklearn.tests.test_common::test_estimators[Pipeline(steps=[('scaler',StandardScaler()),('final_estimator',LogisticRegression())])-check_fit2d_1sample]" - "sklearn.tests.test_common::test_estimators[Pipeline(steps=[('scaler',StandardScaler()),('final_estimator',LogisticRegression())])-check_supervised_y_2d]" - - "sklearn.tests.test_common::test_estimators[Pipeline(steps=[('scaler',StandardScaler()),('final_estimator',LogisticRegression())])-check_supervised_y_no_nan]" - "sklearn.tests.test_common::test_estimators[Pipeline(steps=[('scaler',StandardScaler()),('final_estimator',Ridge())])-check_estimators_nan_inf]" - "sklearn.tests.test_common::test_estimators[Pipeline(steps=[('scaler',StandardScaler()),('final_estimator',Ridge())])-check_regressor_data_not_an_array]" - "sklearn.tests.test_common::test_estimators[Pipeline(steps=[('scaler',StandardScaler()),('final_estimator',Ridge())])-check_supervised_y_no_nan]" @@ -739,7 +738,6 @@ - "sklearn.tests.test_common::test_estimators[LogisticRegression()-check_fit2d_1sample]" - "sklearn.tests.test_common::test_estimators[LogisticRegression()-check_sample_weights_not_an_array]" - "sklearn.tests.test_common::test_estimators[LogisticRegression()-check_sparsify_coefficients]" - - "sklearn.tests.test_common::test_estimators[LogisticRegression()-check_supervised_y_no_nan]" - "sklearn.tests.test_multioutput::test_multiclass_multioutput_estimator_predict_proba" - reason: Test should fail with cuml.accel (scikit-learn 1.6+) marker: cuml_accel_bugs @@ -1055,7 +1053,6 @@ - "sklearn.tests.test_common::test_estimators[SVC()-check_classifier_data_not_an_array]" - "sklearn.tests.test_common::test_estimators[SVC()-check_estimators_nan_inf]" - "sklearn.tests.test_common::test_estimators[SVC()-check_sample_weights_not_an_array]" - - "sklearn.tests.test_common::test_estimators[SVC()-check_supervised_y_no_nan]" - reason: SVM doesn't handle sample_weight identically to sklearn marker: cuml_accel_svm_sample_weight tests: @@ -1112,7 +1109,6 @@ - "sklearn.tests.test_common::test_estimators[KNeighborsClassifier()-check_dtype_object]" - "sklearn.tests.test_common::test_estimators[KNeighborsClassifier()-check_estimators_empty_data_messages]" - "sklearn.tests.test_common::test_estimators[KNeighborsClassifier()-check_estimators_nan_inf]" - - "sklearn.tests.test_common::test_estimators[KNeighborsClassifier()-check_supervised_y_no_nan]" - "sklearn.tests.test_common::test_estimators[KNeighborsRegressor()-check_dtype_object]" - "sklearn.tests.test_common::test_estimators[KNeighborsRegressor()-check_estimators_empty_data_messages]" - "sklearn.tests.test_common::test_estimators[KNeighborsRegressor()-check_estimators_nan_inf]" @@ -1152,7 +1148,6 @@ - "sklearn.tests.test_common::test_estimators[RandomForestClassifier()-check_classifiers_train]" - "sklearn.tests.test_common::test_estimators[RandomForestClassifier()-check_dtype_object]" - "sklearn.tests.test_common::test_estimators[RandomForestClassifier()-check_estimators_empty_data_messages]" - - "sklearn.tests.test_common::test_estimators[RandomForestClassifier()-check_supervised_y_no_nan]" - "sklearn.tests.test_common::test_estimators[RandomForestRegressor()-check_dtype_object]" - "sklearn.tests.test_common::test_estimators[RandomForestRegressor()-check_estimators_empty_data_messages]" - "sklearn.tests.test_common::test_estimators[RandomForestRegressor()-check_regressor_data_not_an_array]" @@ -1366,7 +1361,6 @@ - "sklearn.tests.test_common::test_estimators[LinearSVC()-check_estimators_nan_inf]" - "sklearn.tests.test_common::test_estimators[LinearSVC()-check_sample_weights_not_an_array]" - "sklearn.tests.test_common::test_estimators[LinearSVC()-check_sparsify_coefficients]" - - "sklearn.tests.test_common::test_estimators[LinearSVC()-check_supervised_y_no_nan]" - "sklearn.tests.test_common::test_estimators[LinearSVR()-check_dtype_object]" - "sklearn.tests.test_common::test_estimators[LinearSVR()-check_estimators_nan_inf]" - "sklearn.tests.test_common::test_estimators[LinearSVR()-check_regressor_data_not_an_array]" diff --git a/python/cuml/tests/test_validation.py b/python/cuml/tests/test_validation.py index 2877ef491a..ae08437c44 100644 --- a/python/cuml/tests/test_validation.py +++ b/python/cuml/tests/test_validation.py @@ -526,19 +526,19 @@ def array(values, dtype=None): check_all_finite(f32_good, allow_nan=True) check_all_finite(f32_nan, allow_nan=True) - with pytest.raises( - ValueError, match="Input X contains NaN or infinite values" - ): + with pytest.raises(ValueError, match="Input X contains NaN."): check_all_finite(f32_nan, allow_nan=False, input_name="X") with pytest.raises( - ValueError, match="Input array contains infinite values" + ValueError, + match=( + r"Input array contains infinity or a value too large for " + r"dtype\('float32'\)." + ), ): check_all_finite(f32_inf, allow_nan=True) - with pytest.raises( - ValueError, match="Input array contains NaN or infinite values" - ): + with pytest.raises(ValueError, match="Input array contains NaN."): check_all_finite(f64_both) @@ -546,20 +546,25 @@ def test_check_all_finite_host_fallback(): x_good = np.array([1e307] * 100, dtype="float64") x_nan = np.array([1e307] * 99 + [float("nan")], dtype="float64") x_inf = np.array([1e307] * 99 + [float("inf")], dtype="float64") + x_both = np.array( + [1e307] * 98 + [float("inf"), float("nan")], dtype="float64" + ) check_all_finite(x_good) check_all_finite(x_good, allow_nan=True) check_all_finite(x_nan, allow_nan=True) - with pytest.raises( - ValueError, match="Input array contains NaN or infinite values" - ): + with pytest.raises(ValueError, match="Input array contains NaN."): check_all_finite(x_nan) - with pytest.raises( - ValueError, match="Input array contains infinite values" - ): - check_all_finite(x_inf, allow_nan=True) + with pytest.raises(ValueError, match="Input array contains infinity"): + check_all_finite(x_inf) + + with pytest.raises(ValueError, match="Input array contains NaN."): + check_all_finite(x_both) + + with pytest.raises(ValueError, match="Input array contains infinity"): + check_all_finite(x_both, allow_nan=True) def test_check_all_finite_assume_finite(): @@ -992,15 +997,11 @@ def test_check_array_ensure_all_finite(): check_array(f32_nan, ensure_all_finite="allow-nan") check_array(f64_both, ensure_all_finite=False) - with pytest.raises( - ValueError, match="Input X contains NaN or infinite values" - ): + with pytest.raises(ValueError, match="Input X contains NaN."): check_array(f32_nan, input_name="X") - with pytest.raises( - ValueError, match="Input array contains NaN or infinite values" - ): - check_array(f64_both) + with pytest.raises(ValueError, match="Input array contains infinity."): + check_array(f64_both, ensure_all_finite="allow-nan") def test_check_array_ensure_non_negative(): @@ -1220,14 +1221,15 @@ def test_check_y_classifier_on_floating_input(): np.testing.assert_array_equal(cp.asnumpy(out), np.array([0, 1, 0])) # Non integral values error - bad = [ - np.array([1.5, 2.5, 3.5]), - cp.array([1.0, float("nan"), 3.0]), - np.array([1.0, float("inf"), 3.0]), - ] - for array in bad: - with pytest.raises(ValueError, match="Unknown label type: continuous"): - check_y(array, return_classes=True) + has_nan = cp.array([1.0, float("nan"), 3.0]) + has_inf = np.array([1.0, float("inf"), 3.0]) + non_integral = np.array([1.5, 2.5, 3.5]) + with pytest.raises(ValueError, match="Input y contains NaN."): + check_y(has_nan, return_classes=True) + with pytest.raises(ValueError, match="Input y contains infinity"): + check_y(has_inf, return_classes=True) + with pytest.raises(ValueError, match="Unknown label type: continuous"): + check_y(non_integral, return_classes=True) def test_check_y_none(): From 3496df1e2bc198e582c311a8ce1d48e1282900ea Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Mon, 20 Apr 2026 11:47:58 -0500 Subject: [PATCH 16/29] A few more tests --- python/cuml/tests/test_validation.py | 62 +++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/python/cuml/tests/test_validation.py b/python/cuml/tests/test_validation.py index ae08437c44..be00e7123b 100644 --- a/python/cuml/tests/test_validation.py +++ b/python/cuml/tests/test_validation.py @@ -30,6 +30,8 @@ check_y, ) +DTYPES = ("i1", "i2", "i4", "i8", "u1", "u2", "u4", "u8", "f2", "f4", "f8") + def is_cuda_output(mem_type, value=..., kind=...): """Infer if cuda output given `mem_type` and a value or kind""" @@ -130,11 +132,7 @@ def select(value, choices, cast=None): return value kind = select(kind, ("cupy", "numpy", "list", "pandas", "cudf")) - dtype = select( - dtype, - ("i1", "i2", "i4", "i8", "u1", "u2", "u4", "u8", "f2", "f4", "f8"), - cast=np.dtype, - ) + dtype = select(dtype, DTYPES, cast=np.dtype) assume(not (kind == "cudf" and dtype == "float16")) ndim = select(ndim, (1, 2)) @@ -720,6 +718,34 @@ def test_check_array_dtype(array, mem_type): assert out.dtype == "float32" +@example(mem_type="device", dtype="int32", order="C", shape=(3, 4)) +@example(mem_type="host", dtype="float32", order="F", shape=(3,)) +@given( + mem_type=st.sampled_from(["device", "host"]), + dtype=st.sampled_from(DTYPES), + order=st.sampled_from(["C", "F"]), + shape=st.sampled_from([(3, 4), (3,)]), +) +def test_check_array_no_copy_needed(mem_type, dtype, order, shape): + """Ensure no copy made for fast paths.""" + xp = cp if mem_type == "device" else np + array = xp.ones(shape, dtype=dtype, order=order) + + if len(shape) == 1: + # all orders are equivalent for 1D inputs + orders = ("C", "F", "A", None) + else: + orders = (order, "A", None) + + for dtype, mem_type, order in zip((dtype, None), (mem_type, None), orders): + out = check_array( + array, dtype=dtype, mem_type=mem_type, order=order, ensure_2d=False + ) + assert xp.may_share_memory(out, array), ( + f"{dtype=}, {mem_type=}, {order=}" + ) + + def test_check_array_convert_dtype(): array = cp.array([[1, 2, 3]], dtype="float32") @@ -1364,6 +1390,32 @@ def test_check_inputs_X_y_sample_weight(): assert sample_weight2 is None +def test_check_inputs_check_consistent_length(): + model = MyModel() + + x34 = np.ones((3, 4)) + y3 = np.ones(3) + y4 = np.ones(4) + + with pytest.raises( + ValueError, + match=r"Found input variables with inconsistent number of samples: \[3, 4\]", + ): + check_inputs(model, x34, y4, None, reset=True) + + with pytest.raises( + ValueError, + match=r"Found input variables with inconsistent number of samples: \[3, 4\]", + ): + check_inputs(model, x34, sample_weight=y4, reset=True) + + with pytest.raises( + ValueError, + match=r"Found input variables with inconsistent number of samples: \[3, 3, 4\]", + ): + check_inputs(model, x34, y3, sample_weight=y4, reset=True) + + def test_check_inputs_return_classes(): model = MyModel() X = cp.ones((3, 2), "float32") From d559cb6af9d6f958a13acceee23acd0cab199b44 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Mon, 20 Apr 2026 11:52:46 -0500 Subject: [PATCH 17/29] Update xfail list --- .../cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml b/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml index 624a272efd..9437d80319 100644 --- a/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml +++ b/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml @@ -463,11 +463,9 @@ - "sklearn.tests.test_common::test_estimators[NuSVC()-check_sample_weights_invariance(kind=zeros)]" - "sklearn.tests.test_common::test_estimators[NuSVR()-check_sample_weights_invariance(kind=zeros)]" - "sklearn.tests.test_common::test_estimators[OneClassSVM()-check_sample_weights_invariance(kind=zeros)]" - - "sklearn.tests.test_common::test_estimators[Pipeline(steps=[('scaler',StandardScaler()),('final_estimator',LogisticRegression())])-check_classifier_data_not_an_array]" - "sklearn.tests.test_common::test_estimators[Pipeline(steps=[('scaler',StandardScaler()),('final_estimator',LogisticRegression())])-check_dtype_object]" - "sklearn.tests.test_common::test_estimators[Pipeline(steps=[('scaler',StandardScaler()),('final_estimator',LogisticRegression())])-check_estimators_nan_inf]" - "sklearn.tests.test_common::test_estimators[Pipeline(steps=[('scaler',StandardScaler()),('final_estimator',LogisticRegression())])-check_fit2d_1sample]" - - "sklearn.tests.test_common::test_estimators[Pipeline(steps=[('scaler',StandardScaler()),('final_estimator',LogisticRegression())])-check_supervised_y_2d]" - "sklearn.tests.test_common::test_estimators[Pipeline(steps=[('scaler',StandardScaler()),('final_estimator',Ridge())])-check_estimators_nan_inf]" - "sklearn.tests.test_common::test_estimators[Pipeline(steps=[('scaler',StandardScaler()),('final_estimator',Ridge())])-check_regressor_data_not_an_array]" - "sklearn.tests.test_common::test_estimators[Pipeline(steps=[('scaler',StandardScaler()),('final_estimator',Ridge())])-check_supervised_y_no_nan]" From 4160d067bc565b4038dfef157de62f7d84d46e4a Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Mon, 20 Apr 2026 17:30:25 -0500 Subject: [PATCH 18/29] `check_inputs` have y_dtype default to follow X --- python/cuml/cuml/internals/validation.py | 8 +++----- python/cuml/tests/test_validation.py | 16 ++++++++++++---- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py index 325ce725c7..bc647e9cb5 100644 --- a/python/cuml/cuml/internals/validation.py +++ b/python/cuml/cuml/internals/validation.py @@ -1041,8 +1041,8 @@ def check_inputs( output. If the input doesn't have a supported dtype, it will be converted to the first listed dtype. y_dtype : None, dtype, list[dtype], default=... - The dtype(s) to support for y. If not specified, defaults to ``None`` - if ``return_classes=True``, and the output dtype of ``X`` otherwise. + The dtype(s) to support for y. If not specified, defaults to + the output dtype of ``X``. sample_weight_dtype : None, dtype, list[dtype], default=... The dtype(s) to support for sample_weight. If not specified, defaults to the output dtype of ``X``. @@ -1136,8 +1136,7 @@ def check_inputs( classes = None if y is not ...: if y_dtype is ...: - # Follow X dtype by default unless a classifier - y_dtype = None if return_classes else X.dtype + y_dtype = X.dtype y = check_y( y, dtype=y_dtype, @@ -1154,7 +1153,6 @@ def check_inputs( # Validate sample_weight if sample_weight is not ...: if sample_weight_dtype is ...: - # Follow X dtype by default sample_weight_dtype = X.dtype sample_weight = check_sample_weight( sample_weight, diff --git a/python/cuml/tests/test_validation.py b/python/cuml/tests/test_validation.py index be00e7123b..170aaf53e5 100644 --- a/python/cuml/tests/test_validation.py +++ b/python/cuml/tests/test_validation.py @@ -1422,17 +1422,25 @@ def test_check_inputs_return_classes(): y = np.array(["a", "b", "a"], dtype="O") _, y2, classes = check_inputs(model, X, y, return_classes=True, reset=True) - # y defaults to an integral type + # y defaults to X dtype + assert y2.dtype == "float32" + cp.testing.assert_array_equal(y2, cp.array([0, 1, 0])) + np.testing.assert_array_equal(classes, np.array(["a", "b"], dtype="O")) + + # check y_dtype=None results in an integral type + _, y2, classes = check_inputs( + model, X, y, y_dtype=None, return_classes=True, reset=True + ) assert y2.dtype.kind in "iu" cp.testing.assert_array_equal(y2, cp.array([0, 1, 0])) np.testing.assert_array_equal(classes, np.array(["a", "b"], dtype="O")) # check y_dtype overrides _, y2, classes = check_inputs( - model, X, y, y_dtype="float32", return_classes=True, reset=True + model, X, y, y_dtype="float64", return_classes=True, reset=True ) - assert y2.dtype == "float32" - cp.testing.assert_array_equal(y2, cp.array([0, 1, 0], dtype="float32")) + assert y2.dtype == "float64" + cp.testing.assert_array_equal(y2, cp.array([0, 1, 0], dtype="float64")) def test_check_inputs_return_index(): From 4ad68a3eb35cce91df0b9bc7bd4e687893ce2cac Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Mon, 20 Apr 2026 20:18:50 -0500 Subject: [PATCH 19/29] Error on all zero sample_weight --- python/cuml/cuml/internals/validation.py | 7 +++++++ python/cuml/tests/test_validation.py | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py index bc647e9cb5..d79f561883 100644 --- a/python/cuml/cuml/internals/validation.py +++ b/python/cuml/cuml/internals/validation.py @@ -955,8 +955,12 @@ def check_sample_weight( if sample_weight is None: return None + all_zero_msg = "Sample weights must contain at least one non-zero number." + # A uniform sample_weight is the same as unweighted if cp.isscalar(sample_weight): + if sample_weight == 0: + raise ValueError(all_zero_msg) return None sample_weight = check_array( @@ -974,6 +978,9 @@ def check_sample_weight( f"Sample weights must be 1D array or scalar, got " f"{sample_weight.ndim}D array." ) + + if (sample_weight == 0).all(): + raise ValueError(all_zero_msg) return sample_weight diff --git a/python/cuml/tests/test_validation.py b/python/cuml/tests/test_validation.py index 170aaf53e5..c87ee0d98f 100644 --- a/python/cuml/tests/test_validation.py +++ b/python/cuml/tests/test_validation.py @@ -1325,6 +1325,22 @@ def test_check_sample_weight_ensure_non_negative(): check_sample_weight(array, ensure_non_negative=True) +@pytest.mark.parametrize( + "sample_weight", + [ + pytest.param(0, id="scalar"), + pytest.param(np.zeros(3), id="numpy"), + pytest.param(cp.zeros(3), id="cupy"), + ], +) +def test_check_sample_weight_all_zero(sample_weight): + with pytest.raises( + ValueError, + match="Sample weights must contain at least one non-zero number", + ): + check_sample_weight(sample_weight) + + def test_check_inputs_X(): model = MyModel() X = np.arange(6).reshape((3, 2)) From 50bbbbd4501b5e6cbef63610880ae0f6ec8c03d3 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Mon, 20 Apr 2026 20:55:08 -0500 Subject: [PATCH 20/29] check_y error on non-str object arrays --- python/cuml/cuml/internals/validation.py | 6 ++++++ python/cuml/tests/test_validation.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py index d79f561883..a025cd5a80 100644 --- a/python/cuml/cuml/internals/validation.py +++ b/python/cuml/cuml/internals/validation.py @@ -819,6 +819,12 @@ def check_y( mem_type = "host" if isinstance(y, np.ndarray) else "device" if np.isdtype(y.dtype, ("numeric", "bool")): y = cp.asarray(y) + elif y.dtype == "object" and not isinstance(y.flat[0], str): + raise ValueError( + "Unknown label type: unknown. Maybe you are trying to fit a " + "classifier, which expects discrete classes on a regression target " + "with continuous values." + ) else: y = (cudf.DataFrame if y.ndim == 2 else cudf.Series)( y, dtype=(np.dtype("O") if y.dtype.kind in "U" else None) diff --git a/python/cuml/tests/test_validation.py b/python/cuml/tests/test_validation.py index c87ee0d98f..f20fef93ce 100644 --- a/python/cuml/tests/test_validation.py +++ b/python/cuml/tests/test_validation.py @@ -1258,6 +1258,12 @@ def test_check_y_classifier_on_floating_input(): check_y(non_integral, return_classes=True) +def test_check_y_classifier_on_non_str_object(): + bad = np.array([1, 2, 0], dtype=object) + with pytest.raises(ValueError, match="Unknown label type: unknown"): + check_y(bad, return_classes=True) + + def test_check_y_none(): with pytest.raises(ValueError, match="This estimator requires y"): check_y(None) From 09e55dfac2b75f5ca3a5545ec1fa92e410f743f9 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Tue, 21 Apr 2026 11:10:07 -0500 Subject: [PATCH 21/29] Accept large sparse if can be coerced to small sparse --- python/cuml/cuml/internals/validation.py | 75 +++++++++++++++++++----- python/cuml/tests/test_validation.py | 37 ++++++++++-- 2 files changed, 90 insertions(+), 22 deletions(-) diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py index a025cd5a80..9deaaac95a 100644 --- a/python/cuml/cuml/internals/validation.py +++ b/python/cuml/cuml/internals/validation.py @@ -402,6 +402,63 @@ def check_non_negative(array, *, input_name=None) -> None: raise ValueError(f"Negative values in data{suffix}") +def _ensure_int32_sparse(array): + """Convert sparse array to int32 indices if possible, and error otherwise""" + INT32_MAX = (1 << 31) - 1 + + # All sparse arrays must have shapes and nnz that fit in an int32. In addition, + # CSR, CSC, and BSR must have indices/indptr that fit in an int32. + if ( + any(s > INT32_MAX for s in array.shape) + or array.nnz > INT32_MAX + or ( + array.format in ["csr", "csc", "bsr"] + and ( + len(array.indices) > INT32_MAX or len(array.indptr) > INT32_MAX + ) + ) + ): + raise ValueError( + "Only sparse matrices with int32 indices are currently supported." + ) + + # Definitely safe to downscast to int32, but only cast if needed since + # the sparse constructors do a little bit of work. + if array.format == "coo": + if array.row.dtype == "int32" and array.col.dtype == "int32": + return array + return type(array)( + ( + array.data, + ( + array.row.astype("int32", copy=False), + array.col.astype("int32", copy=False), + ), + ), + shape=array.shape, + ) + elif array.format == "dia": + if array.offsets.dtype == "int32": + return array + return type(array)( + (array.data, array.offsets.astype("int32")), shape=array.shape + ) + elif array.format in ["csr", "csc", "bsr"]: + if array.indices.dtype == "int32" and array.indptr.dtype == "int32": + return array + return type(array)( + ( + array.data, + array.indices.astype("int32", copy=False), + array.indptr.astype("int32", copy=False), + ), + shape=array.shape, + ) + else: + # Other type without numeric indices, can just return + return array + + def check_array( array, *, @@ -549,25 +606,11 @@ def check_array( f"Sparse data was passed{padded_input}, but dense data is required. " "Use '.toarray()' to convert to a dense array." ) - if not accept_large_sparse: - if array.format == "coo": - index_keys = ["col", "row"] - elif array.format in ["csr", "csc", "bsr"]: - index_keys = ["indices", "indptr"] - else: - index_keys = [] - - for key in index_keys: - indices_dtype = getattr(array, key).dtype - if indices_dtype != "int32": - raise ValueError( - "Only sparse matrices with int32 indices are currently " - f"supported. Found {indices_dtype} indices instead." - ) - # Coerce to accepted format if needed if array.format not in accept_sparse: array = array.asformat(accept_sparse[0]) + if not accept_large_sparse: + array = _ensure_int32_sparse(array) # Validate dimensions and shape are as expected. We do this here # _before_ host/device conversion, since cupyx doesn't have a sparse diff --git a/python/cuml/tests/test_validation.py b/python/cuml/tests/test_validation.py index f20fef93ce..7438f4f80a 100644 --- a/python/cuml/tests/test_validation.py +++ b/python/cuml/tests/test_validation.py @@ -948,21 +948,46 @@ def test_check_array_sparse_input_format(array, mem_type, format): ) -@pytest.mark.parametrize("format", ["csr", "csc", "coo", "bsr"]) -def test_check_array_accept_large_sparse(format): +@pytest.mark.parametrize("format", ["coo", "csr", "csc", "bsr", "dia"]) +@pytest.mark.parametrize("mem_type", ["host", "device"]) +def test_check_array_coerce_large_sparse(format, mem_type): array = sp.random(20, 10, density=0.5, format=format, random_state=42) if array.format == "coo": array.coords = tuple(v.astype("int64") for v in array.coords) - else: - for name in ["indices", "indptr"]: - setattr(array, name, getattr(array, name).astype("int64")) + elif array.format in ["csr", "csc", "bsr"]: + array.indices = array.indices.astype("int64") + array.indptr = array.indptr.astype("int64") + else: # dia + array.offsets = array.offsets.astype("int64") + + # Sparse matrices with indices > int32 but _could_ fit in int32 are supported + out = check_array(array, accept_sparse=True, mem_type=mem_type) + if mem_type == "device": + assert cp_sp.issparse(out) + out = out.get() + assert (out != array).nnz == 0 + + +def test_check_array_large_sparse_errors(): + # Large sparse matrices that truly cannot fit in int32 error by default + # This is only possible to efficiently test in CI for COO, other large + # sparse matrices also allocate large arrays. + array = sp.coo_matrix( + ( + np.array([1.5]), + (np.array([0], dtype="int64"), np.array([0], dtype="int64")), + ), + shape=(2**32, 10), + ) with pytest.raises(ValueError, match="sparse matrices with int32 indices"): check_array(array, accept_sparse=True) - check_array( + # No error when large sparse matrices are accepted + out = check_array( array, accept_sparse=True, accept_large_sparse=True, mem_type="host" ) + assert out is array @example(array=np.ones((3, 2))) From b46d8df112c51beed5870079df10d263c893f20d Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Tue, 21 Apr 2026 17:36:15 -0500 Subject: [PATCH 22/29] Fix bug in floating integral check on large doubles --- python/cuml/cuml/internals/validation.py | 2 +- python/cuml/tests/test_validation.py | 26 ++++++++++++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py index 9deaaac95a..fa29a2533e 100644 --- a/python/cuml/cuml/internals/validation.py +++ b/python/cuml/cuml/internals/validation.py @@ -741,7 +741,7 @@ def check_array( "T x", "uint8 out", ( - "((unsigned char)(ceilf(x) != x)) << 2 " + "((unsigned char)(cuda::std::ceil(x) != x)) << 2 " "| ((unsigned char)isinf(x)) << 1 " "| ((unsigned char)isnan(x))" ), diff --git a/python/cuml/tests/test_validation.py b/python/cuml/tests/test_validation.py index 7438f4f80a..cd73c5b91d 100644 --- a/python/cuml/tests/test_validation.py +++ b/python/cuml/tests/test_validation.py @@ -1263,14 +1263,28 @@ def check(accept_multi_output=False): assert (classes == sol_classes).all() -def test_check_y_classifier_on_floating_input(): +@pytest.mark.parametrize( + "array", + [ + pytest.param( + np.array([1.0, 2.0, 1.0], dtype="float32"), id="small-float32" + ), + pytest.param( + np.array([2**24, 2**24 + 1, 2**24 + 2], dtype="float64"), + id="big-float64", + ), + ], +) +def test_check_y_classifier_floating_input_accepted(array): # integral floating values are accepted - good = np.array([1.0, 2.0, 1.0]) - out, classes = check_y(good, return_classes=True) - assert classes.dtype == good.dtype - np.testing.assert_array_equal(classes, np.unique(good)) - np.testing.assert_array_equal(cp.asnumpy(out), np.array([0, 1, 0])) + out, classes = check_y(array, return_classes=True) + assert classes.dtype == array.dtype + sol_classes, sol_ind = np.unique(array, return_inverse=True) + np.testing.assert_array_equal(classes, sol_classes) + np.testing.assert_array_equal(cp.asnumpy(out), sol_ind) + +def test_check_y_classifier_floating_input_errors(): # Non integral values error has_nan = cp.array([1.0, float("nan"), 3.0]) has_inf = np.array([1.0, float("inf"), 3.0]) From f913c50dae908266d91fffc7d700d6143ebffcc0 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Tue, 21 Apr 2026 17:47:22 -0500 Subject: [PATCH 23/29] Fixup check_all_finite on np.array([-inf, inf]) --- python/cuml/cuml/internals/validation.py | 11 ++++++++--- python/cuml/tests/test_validation.py | 12 ++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py index fa29a2533e..e54b6d41b3 100644 --- a/python/cuml/cuml/internals/validation.py +++ b/python/cuml/cuml/internals/validation.py @@ -366,11 +366,16 @@ def check_all_finite(array, *, allow_nan=False, input_name=None) -> None: has_inf = status & 0b10 else: # First try an O(1) space solution for the common case - with np.errstate(over="ignore"): + with np.errstate(over="ignore", invalid="ignore"): x_sum = array.sum() if not np.isfinite(x_sum): - has_nan = np.isnan(x_sum) - if not has_nan or allow_nan: + # We can't infer anything from the value of x_sum being non-finite + # - NaN could mean NaN present, or both -inf and inf + # - inf could mean inf present, or just overflow + # Here we selectively apply O(n) space fallbacks as needed. + if allow_nan: + has_inf = np.isinf(array).any() + elif not (has_nan := np.isnan(array).any()): has_inf = np.isinf(array).any() if has_nan and not allow_nan: diff --git a/python/cuml/tests/test_validation.py b/python/cuml/tests/test_validation.py index cd73c5b91d..f64c2145fb 100644 --- a/python/cuml/tests/test_validation.py +++ b/python/cuml/tests/test_validation.py @@ -516,6 +516,9 @@ def array(values, dtype=None): f32_good = array([1.5, -1.5, 2.5], dtype="float32") f32_nan = array([1.5, float("nan"), 2.5], dtype="float32") f32_inf = array([1.5, float("inf"), 2.5], dtype="float32") + f32_pos_neg_inf = array( + [1.5, -float("inf"), float("inf")], dtype="float32" + ) f64_both = array([[1.5, float("inf"), float("nan")]], dtype="float64") check_all_finite(non_floating) @@ -536,6 +539,15 @@ def array(values, dtype=None): ): check_all_finite(f32_inf, allow_nan=True) + with pytest.raises( + ValueError, + match=( + r"Input array contains infinity or a value too large for " + r"dtype\('float32'\)." + ), + ): + check_all_finite(f32_pos_neg_inf) + with pytest.raises(ValueError, match="Input array contains NaN."): check_all_finite(f64_both) From e3f12fada86969bc135b8b1a7a2a7c3611fc4341 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Tue, 21 Apr 2026 23:06:22 -0500 Subject: [PATCH 24/29] Fixup check_sample_weight scalar checks --- python/cuml/cuml/internals/validation.py | 13 +++++++--- python/cuml/tests/test_validation.py | 33 +++++++++++++++++------- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py index e54b6d41b3..be1f7e18c2 100644 --- a/python/cuml/cuml/internals/validation.py +++ b/python/cuml/cuml/internals/validation.py @@ -1011,11 +1011,18 @@ def check_sample_weight( all_zero_msg = "Sample weights must contain at least one non-zero number." - # A uniform sample_weight is the same as unweighted - if cp.isscalar(sample_weight): + if np.isscalar(sample_weight): if sample_weight == 0: raise ValueError(all_zero_msg) - return None + elif ensure_non_negative and sample_weight < 0: + raise ValueError("Negative values in data passed to sample_weight") + elif np.isnan(sample_weight): + raise ValueError("Input sample_weight contains NaN") + elif np.isinf(sample_weight): + raise ValueError("Input sample_weight contains infinity") + else: + # A uniform sample_weight is the same as unweighted + return None sample_weight = check_array( sample_weight, diff --git a/python/cuml/tests/test_validation.py b/python/cuml/tests/test_validation.py index f64c2145fb..42010ccc3a 100644 --- a/python/cuml/tests/test_validation.py +++ b/python/cuml/tests/test_validation.py @@ -1369,17 +1369,20 @@ def test_check_sample_weight_scalar_or_none(): assert check_sample_weight(np.float32(1.0)) is None -def test_check_sample_weight_ensure_non_negative(): - """Tests plumbing of check_sample_weight -> check_non_negative""" - array = np.array([-1, 1, 2], dtype="float32") - - # No error, check disabled by default - check_sample_weight(array) - +@pytest.mark.parametrize( + "sample_weight", + [ + pytest.param(-1, id="scalar"), + pytest.param(np.full(3, -1), id="numpy"), + pytest.param(cp.full(3, -1), id="cupy"), + ], +) +def test_check_sample_weight_ensure_non_negative(sample_weight): with pytest.raises( - ValueError, match="Negative values in data passed to sample_weight" + ValueError, + match="Negative values in data passed to sample_weight", ): - check_sample_weight(array, ensure_non_negative=True) + check_sample_weight(sample_weight, ensure_non_negative=True) @pytest.mark.parametrize( @@ -1398,6 +1401,18 @@ def test_check_sample_weight_all_zero(sample_weight): check_sample_weight(sample_weight) +@pytest.mark.parametrize("value", ["NaN", "infinity"]) +def test_check_sample_weight_non_finite(value): + scalar = float(value) + array = np.array([1.5, scalar, 2.5]) + msg = f"Input sample_weight contains {value}" + with pytest.raises(ValueError, match=msg): + check_sample_weight(scalar) + + with pytest.raises(ValueError, match=msg): + check_sample_weight(array) + + def test_check_inputs_X(): model = MyModel() X = np.arange(6).reshape((3, 2)) From ef56c81ce1d25a59ba4cd541f8d0d6ef19bd10d2 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Tue, 21 Apr 2026 23:10:23 -0500 Subject: [PATCH 25/29] Update comment on cudf.pandas --- python/cuml/cuml/internals/validation.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py index be1f7e18c2..317bb1edc7 100644 --- a/python/cuml/cuml/internals/validation.py +++ b/python/cuml/cuml/internals/validation.py @@ -667,7 +667,11 @@ def check_array( and cudf.pandas.LOADED and np.isdtype(array.dtype, ("numeric", "bool")) ): - # With cudf.pandas, supported arrays are already on device + # We treat pandas objects with supported dtypes as device + # memory when running under cudf.pandas. Note that the output + # of `to_numpy` in cudf.pandas returns a proxy array that's + # remains on device, so we're not paying a device<>host + # roundtrip cost here. array = cp.asarray(array, dtype=dtype, order=order) else: array = np.asarray(array, dtype=dtype, order=order) From b7cb21dc531e497bc792f4b5582e8e9447e4a527 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Tue, 21 Apr 2026 23:12:00 -0500 Subject: [PATCH 26/29] Fixup check_sample_weight docstring --- python/cuml/cuml/internals/validation.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py index 317bb1edc7..a7c3dbb1ce 100644 --- a/python/cuml/cuml/internals/validation.py +++ b/python/cuml/cuml/internals/validation.py @@ -983,11 +983,10 @@ def check_sample_weight( sample_weight : array-like, scalar, or None The ``sample_weight`` input to validate. dtype : None, dtype, list[dtype], default=None - The dtype(s) to support. By default no dtype enforcement is performed; - for classifiers the output will be a suitable integral type, otherwise - the input dtype will be used. Pass a dtype or a list of supported - dtypes to enforce a dtype for the output. If the input doesn't have a - supported dtype, it will be converted to the first listed dtype. + The dtype(s) to support. By default no dtype validation is performed. + Pass a dtype or a list of supported dtypes to enforce a dtype for the + output. If the input doesn't have a supported dtype, it will be + converted to the first listed dtype. convert_dtype : bool, default=True Whether to support dtype conversion. If False, an error will be raised if the input isn't a supported dtype. From 4db315b238dfbd65ab448a7dde7ea0e8edefe881 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Tue, 21 Apr 2026 23:33:10 -0500 Subject: [PATCH 27/29] Support empty y and sample_weight --- python/cuml/cuml/internals/validation.py | 9 +++++-- python/cuml/tests/test_validation.py | 34 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py index a7c3dbb1ce..6e9fcae707 100644 --- a/python/cuml/cuml/internals/validation.py +++ b/python/cuml/cuml/internals/validation.py @@ -871,7 +871,11 @@ def check_y( mem_type = "host" if isinstance(y, np.ndarray) else "device" if np.isdtype(y.dtype, ("numeric", "bool")): y = cp.asarray(y) - elif y.dtype == "object" and not isinstance(y.flat[0], str): + elif ( + y.dtype == "object" + and y.size + and not isinstance(y.flat[0], str) + ): raise ValueError( "Unknown label type: unknown. Maybe you are trying to fit a " "classifier, which expects discrete classes on a regression target " @@ -1034,6 +1038,7 @@ def check_sample_weight( mem_type=mem_type, order=order, ensure_2d=False, + ensure_min_samples=0, ensure_non_negative=ensure_non_negative, input_name="sample_weight", ) @@ -1043,7 +1048,7 @@ def check_sample_weight( f"{sample_weight.ndim}D array." ) - if (sample_weight == 0).all(): + if sample_weight.size and (sample_weight == 0).all(): raise ValueError(all_zero_msg) return sample_weight diff --git a/python/cuml/tests/test_validation.py b/python/cuml/tests/test_validation.py index 42010ccc3a..cdb698220b 100644 --- a/python/cuml/tests/test_validation.py +++ b/python/cuml/tests/test_validation.py @@ -1315,6 +1315,30 @@ def test_check_y_classifier_on_non_str_object(): check_y(bad, return_classes=True) +@pytest.mark.parametrize( + "mem_type, dtype", + [ + ("device", "int32"), + ("host", "int32"), + ("host", "object"), + ], +) +@pytest.mark.parametrize("return_classes", [False, True]) +def test_check_y_empty(mem_type, dtype, return_classes): + xp = cp if mem_type == "device" else np + array = xp.array([], dtype=dtype) + if return_classes: + y, classes = check_y(array, mem_type=None, return_classes=True) + assert classes.dtype == dtype + assert classes.size == 0 + assert y.dtype.kind in "iu" + assert y.size == 0 + else: + y = check_y(array, mem_type=None) + assert y.dtype == array.dtype + assert y.size == 0 + + def test_check_y_none(): with pytest.raises(ValueError, match="This estimator requires y"): check_y(None) @@ -1363,6 +1387,16 @@ def test_check_sample_weight_errors_2d(shape): check_sample_weight(bad) +@pytest.mark.parametrize("mem_type", ["device", "host"]) +@pytest.mark.parametrize("ensure_non_negative", [False, True]) +def test_check_sample_weight_empty(mem_type, ensure_non_negative): + xp = cp if mem_type == "device" else np + array = xp.array([], dtype="float32") + out = check_sample_weight(array, ensure_non_negative=ensure_non_negative) + assert out.dtype == array.dtype + assert out.size == 0 + + def test_check_sample_weight_scalar_or_none(): assert check_sample_weight(None) is None assert check_sample_weight(1.5) is None From 7466b8db29b8bf3d32e30eaf46cea9b2f1eb946e Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Tue, 21 Apr 2026 23:45:29 -0500 Subject: [PATCH 28/29] Unxfail one more test --- python/cuml/tests/test_sklearn_compatibility.py | 1 - 1 file changed, 1 deletion(-) diff --git a/python/cuml/tests/test_sklearn_compatibility.py b/python/cuml/tests/test_sklearn_compatibility.py index a593fdfdfa..e88be0bf9f 100644 --- a/python/cuml/tests/test_sklearn_compatibility.py +++ b/python/cuml/tests/test_sklearn_compatibility.py @@ -253,7 +253,6 @@ "check_sample_weight_equivalence_on_dense_data": "SVC sample weight equivalence not implemented", "check_sample_weight_equivalence_on_sparse_data": "SVC does not handle sparse data", "check_all_zero_sample_weights_error": "SVC does not validate all-zero sample weights", - "check_dtype_object": "SVC does not handle object dtype", "check_estimators_nan_inf": "SVC does not check for NaN and inf", "check_classifier_data_not_an_array": "SVC does not handle non-array data", "check_classifiers_train": "SVC does not handle list inputs", From ece977ab87287d8ae79a45ea018e1a23befa1119 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Wed, 22 Apr 2026 00:56:22 -0500 Subject: [PATCH 29/29] More fixups? --- python/cuml/cuml/internals/validation.py | 2 +- .../cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py index 6e9fcae707..fd1e484c90 100644 --- a/python/cuml/cuml/internals/validation.py +++ b/python/cuml/cuml/internals/validation.py @@ -750,7 +750,7 @@ def check_array( "T x", "uint8 out", ( - "((unsigned char)(cuda::std::ceil(x) != x)) << 2 " + "((unsigned char)(ceil(x) != x)) << 2 " "| ((unsigned char)isinf(x)) << 1 " "| ((unsigned char)isnan(x))" ), diff --git a/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml b/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml index 9437d80319..16c5383a08 100644 --- a/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml +++ b/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml @@ -463,7 +463,6 @@ - "sklearn.tests.test_common::test_estimators[NuSVC()-check_sample_weights_invariance(kind=zeros)]" - "sklearn.tests.test_common::test_estimators[NuSVR()-check_sample_weights_invariance(kind=zeros)]" - "sklearn.tests.test_common::test_estimators[OneClassSVM()-check_sample_weights_invariance(kind=zeros)]" - - "sklearn.tests.test_common::test_estimators[Pipeline(steps=[('scaler',StandardScaler()),('final_estimator',LogisticRegression())])-check_dtype_object]" - "sklearn.tests.test_common::test_estimators[Pipeline(steps=[('scaler',StandardScaler()),('final_estimator',LogisticRegression())])-check_estimators_nan_inf]" - "sklearn.tests.test_common::test_estimators[Pipeline(steps=[('scaler',StandardScaler()),('final_estimator',LogisticRegression())])-check_fit2d_1sample]" - "sklearn.tests.test_common::test_estimators[Pipeline(steps=[('scaler',StandardScaler()),('final_estimator',Ridge())])-check_estimators_nan_inf]"