diff --git a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_column_transformer.py b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_column_transformer.py index 573af00c02..fa419f2b9c 100644 --- a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_column_transformer.py +++ b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_column_transformer.py @@ -1,6 +1,6 @@ # SPDX-FileCopyrightText: Andreas Mueller # SPDX-FileCopyrightText: Joris Van den Bossche -# SPDX-FileCopyrightText: Copyright (c) 2020-2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: BSD-3-Clause # Original authors from Sckit-Learn: @@ -36,6 +36,7 @@ import cuml from cuml.internals.array_sparse import SparseCumlArray from cuml.internals.global_settings import _global_settings_data +from cuml.internals.validation import check_is_fitted from ....thirdparty_adapters import check_array from ..preprocessing._function_transformer import FunctionTransformer @@ -44,7 +45,6 @@ BaseEstimator, TransformerMixin, ) -from ..utils.validation import check_is_fitted _ERR_MSG_1DCOLUMN = ("1D data passed to a transformer that expects 2D data. " "Try to specify the column selection as a list of one " diff --git a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_data.py b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_data.py index f4eade3c2e..ee40fcdf74 100644 --- a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_data.py +++ b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_data.py @@ -41,6 +41,7 @@ StatelessTagMixin, ) from cuml.internals.interop import InteropMixin, to_cpu, to_gpu +from cuml.internals.validation import check_is_fitted from ....common.array_descriptor import CumlArrayDescriptor from ....internals.array import CumlArray @@ -59,11 +60,7 @@ mean_variance_axis, min_max_axis, ) -from ..utils.validation import ( - FLOAT_DTYPES, - check_is_fitted, - check_random_state, -) +from ..utils.validation import FLOAT_DTYPES, check_random_state BOUNDS_THRESHOLD = 1e-7 diff --git a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_discretization.py b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_discretization.py index c12be7de81..b91e388f70 100644 --- a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_discretization.py +++ b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_discretization.py @@ -24,13 +24,14 @@ from cuml.cluster import KMeans from cuml.internals.mixins import SparseInputTagMixin from cuml.preprocessing.encoders import OneHotEncoder +from cuml.internals.validation import check_is_fitted from ....common.array_descriptor import CumlArrayDescriptor from ....internals.array_sparse import SparseCumlArray from ....internals.outputs import using_output_type, reflect from ....thirdparty_adapters import check_array from ..utils.skl_dependencies import BaseEstimator, TransformerMixin -from ..utils.validation import FLOAT_DTYPES, check_is_fitted +from ..utils.validation import FLOAT_DTYPES def digitize(x, bins): diff --git a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_imputation.py b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_imputation.py index 82e771a2a8..dfda287d3a 100644 --- a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_imputation.py +++ b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_imputation.py @@ -1,6 +1,6 @@ # SPDX-FileCopyrightText: Nicolas Tresegnie # SPDX-FileCopyrightText: Sergey Feldman -# SPDX-FileCopyrightText: Copyright (c) 2020-2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: BSD-3-Clause # Original authors from Sckit-Learn: @@ -28,6 +28,7 @@ SparseInputTagMixin, StringInputTagMixin, ) +from cuml.internals.validation import check_is_fitted from ....common.array_descriptor import CumlArrayDescriptor from ....internals.array_sparse import SparseCumlArray @@ -39,7 +40,7 @@ _masked_column_mode, ) from ..utils.skl_dependencies import BaseEstimator, TransformerMixin -from ..utils.validation import FLOAT_DTYPES, check_is_fitted +from ..utils.validation import FLOAT_DTYPES def is_scalar_nan(x): diff --git a/python/cuml/cuml/_thirdparty/sklearn/utils/validation.py b/python/cuml/cuml/_thirdparty/sklearn/utils/validation.py index 7d354b2f5e..0b3fae75b2 100644 --- a/python/cuml/cuml/_thirdparty/sklearn/utils/validation.py +++ b/python/cuml/cuml/_thirdparty/sklearn/utils/validation.py @@ -5,7 +5,7 @@ # SPDX-FileCopyrightText: Alexandre Gramfort # SPDX-FileCopyrightText: Nicolas Tresegnie # SPDX-FileCopyrightText: Sylvain Marie -# SPDX-FileCopyrightText: Copyright (c) 2020-2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: BSD-3-Clause # Original authors from Sckit-Learn: @@ -32,7 +32,6 @@ import cupyx.scipy.sparse as sp import numpy as np -from ....common.exceptions import NotFittedError from ....thirdparty_adapters import check_array FLOAT_DTYPES = (np.float64, np.float32, np.float16) @@ -176,74 +175,6 @@ def check_random_state(seed): ' instance' % seed) -def check_is_fitted(estimator, attributes=None, *, msg=None, all_or_any=all): - """Perform is_fitted validation for estimator. - - Checks if the estimator is fitted by verifying the presence of - fitted attributes (ending with a trailing underscore) and otherwise - raises a NotFittedError with the given message. - - This utility is meant to be used internally by estimators themselves, - typically in their own predict / transform methods. - - Parameters - ---------- - estimator : estimator instance. - estimator instance for which the check is performed. - - attributes : str, list or tuple of str, default=None - Attribute name(s) given as string or a list/tuple of strings - Eg.: ``["coef_", "estimator_", ...], "coef_"`` - - If `None`, `estimator` is considered fitted if there exist an - attribute that ends with a underscore and does not start with double - underscore. - - msg : string - The default error message is, "This %(name)s instance is not fitted - yet. Call 'fit' with appropriate arguments before using this - estimator." - - For custom messages if "%(name)s" is present in the message string, - it is substituted for the estimator name. - - Eg. : "Estimator, %(name)s, must be fitted before sparsifying". - - all_or_any : callable, {all, any}, default all - Specify whether all or any of the given attributes must exist. - - Returns - ------- - None - - Raises - ------ - NotFittedError - If the attributes are not found. - """ - if isclass(estimator): - raise TypeError("{} is a class, not an instance.".format(estimator)) - if msg is None: - msg = ("This %(name)s instance is not fitted yet. Call 'fit' with " - "appropriate arguments before using this estimator.") - - if not hasattr(estimator, 'fit'): - raise TypeError("%s is not an estimator instance." % (estimator)) - - if attributes is not None: - if not isinstance(attributes, (list, tuple)): - attributes = [attributes] - attrs = all_or_any([hasattr(estimator, attr) for attr in attributes]) - elif hasattr(estimator, "__sklearn_is_fitted__"): - attrs = estimator.__sklearn_is_fitted__() - else: - attrs = [v for v in vars(estimator) - if v.endswith("_") and not v.startswith("__")] - - if not attrs: - raise NotFittedError(msg % {'name': type(estimator).__name__}) - - def _allclose_dense_sparse(x, y, rtol=1e-7, atol=1e-9): """Check allclose for sparse and dense data. diff --git a/python/cuml/cuml/cluster/hdbscan/hdbscan.pyx b/python/cuml/cuml/cluster/hdbscan/hdbscan.pyx index f06ae64f27..767f970240 100644 --- a/python/cuml/cuml/cluster/hdbscan/hdbscan.pyx +++ b/python/cuml/cuml/cluster/hdbscan/hdbscan.pyx @@ -18,6 +18,7 @@ from cuml.internals.interop import ( ) from cuml.internals.mem_type import MemoryType from cuml.internals.mixins import ClusterMixin, CMajorInputTagMixin +from cuml.internals.validation import check_is_fitted from cython.operator cimport dereference as deref from libc.stdint cimport int64_t, uint64_t, uintptr_t @@ -906,8 +907,7 @@ class HDBSCAN(Base, InteropMixin, ClusterMixin, CMajorInputTagMixin): the label of new/unseen points. This data is only useful if you are intending to use functions from hdbscan.prediction. """ - if getattr(self, "labels_", None) is None: - raise ValueError("The model is not trained yet (call fit() first).") + check_is_fitted(self) with cuml.using_output_type("cuml"): labels = self.labels_ @@ -1107,10 +1107,8 @@ def _check_clusterer(clusterer): f"Expected an instance of `HDBSCAN`, got {type(clusterer).__name__}" ) - if getattr(clusterer, "labels_", None) is None: - raise ValueError( - "The clusterer is not fit, please call `clusterer.fit` first" - ) + check_is_fitted(clusterer) + cdef _HDBSCANState state = <_HDBSCANState?>clusterer._state if state.prediction_data == NULL: diff --git a/python/cuml/cuml/cluster/kmeans.pyx b/python/cuml/cuml/cluster/kmeans.pyx index 33e34c0258..d047931a19 100644 --- a/python/cuml/cuml/cluster/kmeans.pyx +++ b/python/cuml/cuml/cluster/kmeans.pyx @@ -19,7 +19,7 @@ from cuml.internals.interop import ( ) from cuml.internals.mixins import ClusterMixin, CMajorInputTagMixin from cuml.internals.outputs import reflect, run_in_internal_context -from cuml.internals.utils import check_random_seed +from cuml.internals.validation import check_is_fitted, check_random_seed from libc.stdint cimport int64_t, uintptr_t from libcpp cimport bool @@ -633,6 +633,8 @@ class KMeans(Base, inertia : float Sum of squared distances of samples to their closest cluster center. """ + check_is_fitted(self) + dtype = self.cluster_centers_.dtype X_m, n_rows, _, _ = input_to_cuml_array( @@ -694,6 +696,8 @@ class KMeans(Base, Transform X to a cluster-distance space. """ + check_is_fitted(self) + dtype = self.cluster_centers_.dtype X_m = input_to_cuml_array( diff --git a/python/cuml/cuml/cluster/spectral_clustering.pyx b/python/cuml/cuml/cluster/spectral_clustering.pyx index 6959c84120..8a55a8933e 100644 --- a/python/cuml/cuml/cluster/spectral_clustering.pyx +++ b/python/cuml/cuml/cluster/spectral_clustering.pyx @@ -14,7 +14,7 @@ from cuml.common.array_descriptor import CumlArrayDescriptor from cuml.internals.array import CumlArray from cuml.internals.base import Base, get_handle from cuml.internals.input_utils import input_to_cupy_array -from cuml.internals.utils import check_random_seed +from cuml.internals.validation import check_random_seed from libc.stdint cimport uint64_t, uintptr_t from libcpp cimport bool diff --git a/python/cuml/cuml/common/exceptions.py b/python/cuml/cuml/common/exceptions.py index e02c6d3f37..a45b53bcc6 100644 --- a/python/cuml/cuml/common/exceptions.py +++ b/python/cuml/cuml/common/exceptions.py @@ -1,12 +1,23 @@ # -# SPDX-FileCopyrightText: Copyright (c) 2020, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 # +__all__ = ("NotFittedError",) # noqa -class NotFittedError(ValueError, AttributeError): - """Exception class to raise if estimator is used before fitting. +def __getattr__(name): + if name == "NotFittedError": + import warnings - This class inherits from both ValueError and AttributeError to help with - exception handling and backward compatibility. - """ + from sklearn.exceptions import NotFittedError + + warnings.warn( + "`cuml.common.exceptions.NotFittedError` was deprecated in 26.04 " + "and will be removed in 26.06. Please use " + "`sklearn.exceptions.NotFittedError` instead.", + FutureWarning, + stacklevel=2, + ) + return NotFittedError + else: + raise AttributeError(f"module {__name__} has no attribute {name}") diff --git a/python/cuml/cuml/covariance/ledoit_wolf.py b/python/cuml/cuml/covariance/ledoit_wolf.py index 9834e77069..beb84d396d 100644 --- a/python/cuml/cuml/covariance/ledoit_wolf.py +++ b/python/cuml/cuml/covariance/ledoit_wolf.py @@ -14,6 +14,7 @@ from cuml.internals.base import Base from cuml.internals.input_utils import input_to_cupy_array from cuml.internals.interop import InteropMixin, to_cpu, to_gpu +from cuml.internals.validation import check_is_fitted def _ledoit_wolf_shrinkage(X, assume_centered=False, block_size=1000): @@ -299,6 +300,8 @@ def get_precision(self): precision_ : ndarray of shape (n_features, n_features) The precision matrix associated to the current covariance object. """ + check_is_fitted(self) + if self.store_precision: return self.precision_ else: @@ -324,6 +327,8 @@ def score(self, X_test, y=None) -> float: log_likelihood : float Log-likelihood of the data under the fitted Gaussian model. """ + check_is_fitted(self) + X_arr, _, n_features, _ = input_to_cupy_array( X_test, check_dtype=[np.float32, np.float64], @@ -367,6 +372,8 @@ def error_norm( The Mean Squared Error (in the sense of the Frobenius norm) between `self` and `comp_cov`. """ + check_is_fitted(self) + comp_cov_arr, _, _, _ = input_to_cupy_array( comp_cov, check_dtype=[np.float32, np.float64], @@ -408,6 +415,8 @@ def mahalanobis(self, X): mahalanobis_distances : ndarray of shape (n_samples,) Squared Mahalanobis distances of the observations. """ + check_is_fitted(self) + X_arr, _, _, _ = input_to_cupy_array( X, check_dtype=[np.float32, np.float64], diff --git a/python/cuml/cuml/dask/cluster/kmeans.py b/python/cuml/cuml/dask/cluster/kmeans.py index a9eebd0bc1..31148cf952 100644 --- a/python/cuml/cuml/dask/cluster/kmeans.py +++ b/python/cuml/cuml/dask/cluster/kmeans.py @@ -14,7 +14,7 @@ ) from cuml.dask.common.input_utils import DistributedDataHandler, concatenate from cuml.dask.common.utils import wait_and_raise_from_futures -from cuml.internals.utils import check_random_seed +from cuml.internals.validation import check_random_seed class KMeans(BaseEstimator, DelayedPredictionMixin, DelayedTransformMixin): diff --git a/python/cuml/cuml/dask/preprocessing/LabelEncoder.py b/python/cuml/cuml/dask/preprocessing/LabelEncoder.py index 5fa1887638..d67fb46d58 100644 --- a/python/cuml/cuml/dask/preprocessing/LabelEncoder.py +++ b/python/cuml/cuml/dask/preprocessing/LabelEncoder.py @@ -1,13 +1,13 @@ -# SPDX-FileCopyrightText: Copyright (c) 2021-2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 # from collections.abc import Sequence from dask_cudf import DataFrame as dcDataFrame from dask_cudf import Series as dcSeries +from sklearn.exceptions import NotFittedError from toolz import first -from cuml.common.exceptions import NotFittedError from cuml.dask.common.base import ( BaseEstimator, DelayedInverseTransformMixin, diff --git a/python/cuml/cuml/datasets/classification.py b/python/cuml/cuml/datasets/classification.py index 85c52605b0..b4fcb2b95a 100644 --- a/python/cuml/cuml/datasets/classification.py +++ b/python/cuml/cuml/datasets/classification.py @@ -8,7 +8,7 @@ import cuml.internals import cuml.internals.nvtx as nvtx from cuml.datasets.utils import _create_rs_generator -from cuml.internals.utils import check_random_seed +from cuml.internals.validation import check_random_seed def _generate_hypercube(samples, dimensions, random_state): diff --git a/python/cuml/cuml/decomposition/incremental_pca.py b/python/cuml/cuml/decomposition/incremental_pca.py index b262872cd7..efe4584aa2 100644 --- a/python/cuml/cuml/decomposition/incremental_pca.py +++ b/python/cuml/cuml/decomposition/incremental_pca.py @@ -15,6 +15,7 @@ from cuml.internals.array import CumlArray from cuml.internals.base import Base from cuml.internals.input_utils import input_to_cupy_array +from cuml.internals.validation import check_is_fitted class IncrementalPCA(PCA): @@ -418,6 +419,7 @@ def transform(self, X, *, convert_dtype=False) -> CumlArray: X_new : array-like, shape (n_samples, n_components) """ + check_is_fitted(self) if scipy.sparse.issparse(X) or cupyx.scipy.sparse.issparse(X): X = _validate_sparse_input(X) diff --git a/python/cuml/cuml/decomposition/pca.pyx b/python/cuml/cuml/decomposition/pca.pyx index ca46030c12..b74116c6af 100644 --- a/python/cuml/cuml/decomposition/pca.pyx +++ b/python/cuml/cuml/decomposition/pca.pyx @@ -10,7 +10,6 @@ import cuml.internals from cuml.common import using_output_type from cuml.common.array_descriptor import CumlArrayDescriptor from cuml.common.doc_utils import generate_docstring -from cuml.common.exceptions import NotFittedError from cuml.common.sparse_utils import is_sparse from cuml.internals.array import CumlArray from cuml.internals.base import Base, get_handle @@ -22,6 +21,7 @@ from cuml.internals.interop import ( to_gpu, ) from cuml.internals.mixins import FMajorInputTagMixin, SparseInputTagMixin +from cuml.internals.validation import check_is_fitted from cuml.prims.stats import cov from libc.stdint cimport uintptr_t @@ -610,7 +610,7 @@ class PCA(Base, In other words, return an input X_original whose transform would be X. """ - self._check_is_fitted() + check_is_fitted(self) if is_sparse(X): return self._inverse_transform_sparse( X, return_sparse=return_sparse, sparse_tol=sparse_tol @@ -701,14 +701,8 @@ class PCA(Base, from a training set. """ - self._check_is_fitted() + check_is_fitted(self) if is_sparse(X): return self._transform_sparse(X) return self._transform_dense(X, convert_dtype=convert_dtype) - - def _check_is_fitted(self): - if not hasattr(self, "components_"): - msg = ("This instance is not fitted yet. Call 'fit' " - "with appropriate arguments before using this estimator.") - raise NotFittedError(msg) diff --git a/python/cuml/cuml/decomposition/tsvd.pyx b/python/cuml/cuml/decomposition/tsvd.pyx index 6c3d5b6c4a..04578f576a 100644 --- a/python/cuml/cuml/decomposition/tsvd.pyx +++ b/python/cuml/cuml/decomposition/tsvd.pyx @@ -13,6 +13,7 @@ from cuml.internals.array import CumlArray from cuml.internals.base import Base, get_handle from cuml.internals.interop import InteropMixin, to_cpu, to_gpu from cuml.internals.mixins import FMajorInputTagMixin +from cuml.internals.validation import check_is_fitted from libc.stdint cimport uintptr_t from libcpp cimport bool @@ -400,6 +401,8 @@ class TruncatedSVD(Base, Returns X_original whose transform would be X. """ + check_is_fitted(self) + dtype = self.components_.dtype X_m, n_rows, _, _ = input_to_cuml_array( X, @@ -455,6 +458,8 @@ class TruncatedSVD(Base, Perform dimensionality reduction on X. """ + check_is_fitted(self) + dtype = self.components_.dtype X_m, n_rows, _, _ = input_to_cuml_array( X, diff --git a/python/cuml/cuml/ensemble/randomforest_common.pyx b/python/cuml/cuml/ensemble/randomforest_common.pyx index d97d182b43..657d6d7625 100644 --- a/python/cuml/cuml/ensemble/randomforest_common.pyx +++ b/python/cuml/cuml/ensemble/randomforest_common.pyx @@ -20,7 +20,7 @@ from cuml.internals.interop import ( UnsupportedOnGPU, ) from cuml.internals.treelite import safe_treelite_call -from cuml.internals.utils import check_random_seed +from cuml.internals.validation import check_is_fitted, check_random_seed from cuml.metrics import accuracy_score, r2_score from libc.stdint cimport uint64_t, uintptr_t @@ -363,6 +363,8 @@ class BaseRandomForestModel(Base, InteropMixin): ------- treelite.Model """ + check_is_fitted(self) + return treelite.Model.deserialize_bytes(self._treelite_model_bytes) def as_fil( @@ -393,6 +395,8 @@ class BaseRandomForestModel(Base, InteropMixin): A Forest Inference model which can be used to perform inferencing on the random forest model. """ + check_is_fitted(self) + return ForestInference( verbose=self.verbose, output_type=self.output_type, diff --git a/python/cuml/cuml/feature_extraction/_tfidf.py b/python/cuml/cuml/feature_extraction/_tfidf.py index 925650abbf..ccd73675cf 100644 --- a/python/cuml/cuml/feature_extraction/_tfidf.py +++ b/python/cuml/cuml/feature_extraction/_tfidf.py @@ -4,9 +4,9 @@ # import cupy as cp import cupyx +from sklearn.exceptions import NotFittedError import cuml.internals -from cuml.common.exceptions import NotFittedError from cuml.common.sparsefuncs import ( csr_diag_mul, csr_row_normalize_l1, diff --git a/python/cuml/cuml/feature_extraction/_vectorizers.py b/python/cuml/cuml/feature_extraction/_vectorizers.py index 0f9a1b64bb..f3c0c96aa3 100644 --- a/python/cuml/cuml/feature_extraction/_vectorizers.py +++ b/python/cuml/cuml/feature_extraction/_vectorizers.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2020-2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 # import numbers @@ -9,9 +9,9 @@ import numpy as np import pandas as pd from cudf import Series +from sklearn.exceptions import NotFittedError import cuml.internals.logger as logger -from cuml.common.exceptions import NotFittedError from cuml.common.sparsefuncs import ( create_csr_matrix_from_count_df, csr_row_normalize_l1, diff --git a/python/cuml/cuml/internals/utils.py b/python/cuml/cuml/internals/utils.py deleted file mode 100644 index 6591498a76..0000000000 --- a/python/cuml/cuml/internals/utils.py +++ /dev/null @@ -1,36 +0,0 @@ -# -# SPDX-FileCopyrightText: Copyright (c) 2024-2025, NVIDIA CORPORATION. -# SPDX-License-Identifier: Apache-2.0 -# - -import numbers - -import cupy as cp -import numpy as np - - -def check_random_seed(seed): - """Turn a np.random.RandomState instance into a seed. - - Parameters - ---------- - seed : None | int | instance of RandomState - If seed is None, return a random int as seed. - If seed is an int, return it. - If seed is a RandomState instance, derive a seed from it. - Otherwise raise ValueError. - """ - if seed is None: - seed = np.random.RandomState(None) - - if isinstance(seed, numbers.Integral): - return seed - if isinstance(seed, np.random.RandomState): - return seed.randint( - low=0, high=np.iinfo(np.uint32).max, dtype=np.uint32 - ) - if isinstance(seed, cp.random.RandomState): - return seed.randint( - low=0, high=np.iinfo(cp.uint32).max, dtype=cp.uint32 - ).get() - raise ValueError("%r cannot be used to create a seed." % seed) diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py new file mode 100644 index 0000000000..07120cd791 --- /dev/null +++ b/python/cuml/cuml/internals/validation.py @@ -0,0 +1,52 @@ +# +# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION. +# SPDX-License-Identifier: Apache-2.0 +# +import numbers + +import cupy as cp +import numpy as np +from sklearn.utils.validation import check_is_fitted + +__all__ = ( + "check_is_fitted", + "check_random_seed", +) + + +def check_random_seed(random_state) -> int: + """Turn a `random_state` argument into a seed. + + Parameters + ---------- + random_state : None | int | instance of RandomState + If random_state is None, return a random int as seed. + If random_state is an int, return it. + If random_state is a RandomState instance, derive a seed from it. + + Returns + ------- + seed : int + A seed in the range [0, 2**32 - 1]. + """ + if isinstance(random_state, numbers.Integral): + if random_state < 0 or random_state >= 2**32: + raise ValueError( + f"Expected `0 <= random_state <= 2**32 - 1`, got {random_state}" + ) + return int(random_state) + + if random_state is None: + randint = np.random.randint + elif isinstance( + random_state, (np.random.RandomState, cp.random.RandomState) + ): + randint = random_state.randint + else: + raise TypeError( + f"`random_state` must be an `int`, an instance of `RandomState`, or `None`. " + f"Got {random_state!r} instead." + ) + + # randint returns in [low, high), so high=2**32 to sample all uint32s + return int(randint(low=0, high=2**32, dtype=np.uint32)) diff --git a/python/cuml/cuml/kernel_ridge/kernel_ridge.py b/python/cuml/cuml/kernel_ridge/kernel_ridge.py index 336a7d5cf8..ab41f461dd 100644 --- a/python/cuml/cuml/kernel_ridge/kernel_ridge.py +++ b/python/cuml/cuml/kernel_ridge/kernel_ridge.py @@ -22,6 +22,7 @@ to_gpu, ) from cuml.internals.mixins import RegressorMixin +from cuml.internals.validation import check_is_fitted from cuml.metrics import pairwise_kernels @@ -333,6 +334,8 @@ def predict(self, X, *, convert_dtype=True): C : array of shape (n_samples,) or (n_samples, n_targets) Returns predicted values. """ + check_is_fitted(self) + dtype = self.X_fit_.dtype X_m = input_to_cuml_array( diff --git a/python/cuml/cuml/linear_model/base.py b/python/cuml/cuml/linear_model/base.py index fd500cc530..df3da5c77a 100644 --- a/python/cuml/cuml/linear_model/base.py +++ b/python/cuml/cuml/linear_model/base.py @@ -1,5 +1,5 @@ # -# SPDX-FileCopyrightText: Copyright (c) 2020-2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 # import cuml.internals @@ -8,6 +8,7 @@ from cuml.internals.array import CumlArray from cuml.internals.array_sparse import SparseCumlArray from cuml.internals.input_utils import input_to_cuml_array +from cuml.internals.validation import check_is_fitted class LinearPredictMixin: @@ -24,11 +25,7 @@ def predict(self, X, *, convert_dtype=True) -> CumlArray: """ Predicts `y` values for `X`. """ - if getattr(self, "coef_", None) is None: - raise ValueError( - "LinearModel.predict() cannot be called before fit(). " - "Please fit the model first." - ) + check_is_fitted(self) X = input_to_cuml_array( X, @@ -64,6 +61,8 @@ class LinearClassifierMixin: @cuml.internals.reflect def decision_function(self, X, *, convert_dtype=True) -> CumlArray: """Predict confidence scores for samples.""" + check_is_fitted(self) + if is_sparse(X): X = SparseCumlArray( X, convert_to_dtype=self.coef_.dtype diff --git a/python/cuml/cuml/linear_model/logistic_regression.py b/python/cuml/cuml/linear_model/logistic_regression.py index 7500418c81..c44efee975 100644 --- a/python/cuml/cuml/linear_model/logistic_regression.py +++ b/python/cuml/cuml/linear_model/logistic_regression.py @@ -384,10 +384,10 @@ def predict_proba(self, X, *, convert_dtype=True) -> CumlArray: """ Predicts the class probabilities for each class in X """ - n_classes = self.classes_.shape[0] - scores = self.decision_function(X, convert_dtype=convert_dtype) scores = scores.to_output("cupy") + + n_classes = self.classes_.shape[0] if n_classes == 2: proba = cp.zeros((scores.shape[0], 2)) proba[:, 1] = 1 / (1 + cp.exp(-scores.ravel())) diff --git a/python/cuml/cuml/manifold/spectral_embedding.pyx b/python/cuml/cuml/manifold/spectral_embedding.pyx index 6ae9104bf0..69589e87a5 100644 --- a/python/cuml/cuml/manifold/spectral_embedding.pyx +++ b/python/cuml/cuml/manifold/spectral_embedding.pyx @@ -19,7 +19,7 @@ from cuml.internals.interop import ( ) from cuml.internals.mixins import CMajorInputTagMixin from cuml.internals.outputs import reflect -from cuml.internals.utils import check_random_seed +from cuml.internals.validation import check_random_seed from libc.stdint cimport int64_t, uint64_t, uintptr_t from libcpp cimport bool diff --git a/python/cuml/cuml/manifold/t_sne.pyx b/python/cuml/cuml/manifold/t_sne.pyx index bf6a41f78f..660a5e874b 100644 --- a/python/cuml/cuml/manifold/t_sne.pyx +++ b/python/cuml/cuml/manifold/t_sne.pyx @@ -23,7 +23,7 @@ from cuml.internals.interop import ( ) from cuml.internals.mixins import CMajorInputTagMixin, SparseInputTagMixin from cuml.internals.outputs import reflect -from cuml.internals.utils import check_random_seed +from cuml.internals.validation import check_random_seed from libc.stdint cimport int64_t, uintptr_t from libcpp cimport bool diff --git a/python/cuml/cuml/manifold/umap/umap.pyx b/python/cuml/cuml/manifold/umap/umap.pyx index 41e295d259..a461e60c18 100644 --- a/python/cuml/cuml/manifold/umap/umap.pyx +++ b/python/cuml/cuml/manifold/umap/umap.pyx @@ -15,7 +15,6 @@ import scipy.spatial from cuml.common.array_descriptor import CumlArrayDescriptor from cuml.common.doc_utils import generate_docstring -from cuml.common.exceptions import NotFittedError from cuml.common.sparse_utils import is_sparse from cuml.common.sparsefuncs import extract_knn_graph from cuml.internals import logger, reflect @@ -31,7 +30,7 @@ from cuml.internals.interop import ( ) from cuml.internals.mem_type import MemoryType from cuml.internals.mixins import CMajorInputTagMixin, SparseInputTagMixin -from cuml.internals.utils import check_random_seed +from cuml.internals.validation import check_is_fitted, check_random_seed from libc.stdint cimport int64_t, uintptr_t from libcpp cimport bool @@ -1409,6 +1408,8 @@ class UMAP(Base, InteropMixin, CMajorInputTagMixin, SparseInputTagMixin): Specifically, the transform() function is stochastic: https://github.com/lmcinnes/umap/issues/158 """ + check_is_fitted(self) + if len(X.shape) != 2: raise ValueError("Reshape your data: X should be two dimensional") @@ -1543,11 +1544,8 @@ class UMAP(Base, InteropMixin, CMajorInputTagMixin, SparseInputTagMixin): """Transform X in the existing embedded space back into the input data space and return that transformed output. """ - if not hasattr(self, "embedding_") or self.embedding_ is None: - raise NotFittedError( - "This UMAP instance is not fitted yet. Call 'fit' with " - "appropriate arguments before using 'inverse_transform'." - ) + check_is_fitted(self) + if self._sparse_data: raise ValueError("Inverse transform not available for sparse input.") if self.n_components >= 8: diff --git a/python/cuml/cuml/model_selection/_split.py b/python/cuml/cuml/model_selection/_split.py index 73762f41b1..cd3627e980 100644 --- a/python/cuml/cuml/model_selection/_split.py +++ b/python/cuml/cuml/model_selection/_split.py @@ -14,7 +14,7 @@ from cuml.common import input_to_cuml_array from cuml.internals.input_utils import input_to_host_array -from cuml.internals.utils import check_random_seed +from cuml.internals.validation import check_random_seed def train_test_split( diff --git a/python/cuml/cuml/naive_bayes/naive_bayes.py b/python/cuml/cuml/naive_bayes/naive_bayes.py index a2cb3bd7a0..dc482f8655 100644 --- a/python/cuml/cuml/naive_bayes/naive_bayes.py +++ b/python/cuml/cuml/naive_bayes/naive_bayes.py @@ -17,6 +17,7 @@ from cuml.internals.input_utils import input_to_cuml_array, input_to_cupy_array from cuml.internals.mixins import ClassifierMixin from cuml.internals.outputs import reflect +from cuml.internals.validation import check_is_fitted from cuml.prims.label.classlabels import make_monotonic _binarize = cp.ElementwiseKernel( @@ -196,6 +197,8 @@ def predict(self, X, *, convert_dtype=True) -> CumlArray: Perform classification on an array of test vectors X. """ + check_is_fitted(self) + if scipy.sparse.isspmatrix(X) or cupyx.scipy.sparse.isspmatrix(X): X = _convert_x_sparse(X) index = None @@ -236,6 +239,8 @@ def predict_log_proba(self, X, *, convert_dtype=True) -> CumlArray: Return log-probability estimates for the test vector X. """ + check_is_fitted(self) + if scipy.sparse.isspmatrix(X) or cupyx.scipy.sparse.isspmatrix(X): X = _convert_x_sparse(X) index = None diff --git a/python/cuml/cuml/neighbors/kernel_density.py b/python/cuml/cuml/neighbors/kernel_density.py index 563c6515cb..566a645b69 100644 --- a/python/cuml/cuml/neighbors/kernel_density.py +++ b/python/cuml/cuml/neighbors/kernel_density.py @@ -9,13 +9,12 @@ import numpy as np from cupyx.scipy.special import gammainc -from cuml.common.exceptions import NotFittedError from cuml.internals.array import CumlArray from cuml.internals.base import Base from cuml.internals.input_utils import input_to_cuml_array, input_to_cupy_array from cuml.internals.interop import InteropMixin, UnsupportedOnGPU from cuml.internals.outputs import reflect, run_in_internal_context -from cuml.internals.utils import check_random_seed +from cuml.internals.validation import check_is_fitted, check_random_seed from cuml.metrics import pairwise_distances from cuml.metrics.pairwise_distances import ( PAIRWISE_DISTANCE_METRICS as SUPPORTED_METRICS, @@ -352,8 +351,7 @@ def score_samples(self, X, *, convert_dtype=True) -> CumlArray: probability densities, so values will be low for high-dimensional data. """ - if not hasattr(self, "_X"): - raise NotFittedError() + check_is_fitted(self) X = input_to_cuml_array( X, @@ -463,8 +461,7 @@ def sample(self, n_samples=1, random_state=None) -> CumlArray: X : cupy array of shape (n_samples, n_features) List of samples. """ - if not hasattr(self, "_X"): - raise NotFittedError() + check_is_fitted(self) supported_kernels = ["gaussian", "tophat"] if self.kernel not in supported_kernels: diff --git a/python/cuml/cuml/neighbors/nearest_neighbors.pyx b/python/cuml/cuml/neighbors/nearest_neighbors.pyx index 84632954cc..c5d30ca5c6 100644 --- a/python/cuml/cuml/neighbors/nearest_neighbors.pyx +++ b/python/cuml/cuml/neighbors/nearest_neighbors.pyx @@ -21,6 +21,7 @@ from cuml.internals.input_utils import input_to_cuml_array from cuml.internals.interop import InteropMixin, UnsupportedOnGPU, to_gpu from cuml.internals.mixins import CMajorInputTagMixin, SparseInputTagMixin from cuml.internals.outputs import reflect, using_output_type +from cuml.internals.validation import check_is_fitted from libc.stdint cimport int64_t, uint32_t, uintptr_t from libcpp cimport bool @@ -556,6 +557,7 @@ class NeighborsBase(Base, InteropMixin, CMajorInputTagMixin, SparseInputTagMixin return { "n_samples_fit_": model.n_samples_fit_, "effective_metric_": model.effective_metric_, + "effective_metric_params_": model.effective_metric_params_, "_fit_X": fit_X, "_fit_method": "brute", **super()._attrs_from_cpu(model), @@ -592,10 +594,14 @@ class NeighborsBase(Base, InteropMixin, CMajorInputTagMixin, SparseInputTagMixin self.algo_params = algo_params self.p = p self.algorithm = algorithm - self.selected_algorithm_ = algorithm self.algo_params = algo_params self.n_jobs = n_jobs # Ignored, here for sklearn API compatibility + @property + def _effective_p(self): + """The `p` value to use, based on `effective_metric_params_` or `p`""" + return self.effective_metric_params_.get("p", self.p) + def __getstate__(self): state = self.__dict__.copy() # TODO: Indices currently aren't pickleable. For now we drop them and @@ -618,7 +624,7 @@ class NeighborsBase(Base, InteropMixin, CMajorInputTagMixin, SparseInputTagMixin self.effective_metric_, fit_method, params=self.algo_params, - p=self.p, + p=self._effective_p, ) @generate_docstring(X='dense_sparse') @@ -642,6 +648,23 @@ class NeighborsBase(Base, InteropMixin, CMajorInputTagMixin, SparseInputTagMixin convert_to_dtype=(np.float32 if convert_dtype else None), ) + # Normalize metric, and simplify for common cases + self.effective_metric_ = self.metric + self.effective_metric_params_ = ( + {} if self.metric_params is None else self.metric_params.copy() + ) + # Drop "p" and simplify metric, unless minkowski is necessary + p = self.effective_metric_params_.pop("p", self.p) + if self.effective_metric_ in ("minkowski", "lp"): + if p == 1: + self.effective_metric_ = "manhattan" + elif p == 2: + self.effective_metric_ = "euclidean" + elif p == np.inf: + self.effective_metric_ = "chebyshev" + else: + self.effective_metric_params_["p"] = p + self.n_samples_fit_, self.n_features_in_ = self._fit_X.shape if self.algorithm == "auto": @@ -649,7 +672,7 @@ class NeighborsBase(Base, InteropMixin, CMajorInputTagMixin, SparseInputTagMixin self.n_features_in_ in (2, 3) and not sparse and self.effective_metric_ in cuml.neighbors.VALID_METRICS["rbc"] - and X.shape[0]**0.5 >= self.n_neighbors + and self._fit_X.shape[0]**0.5 >= self.n_neighbors ): self._fit_method = "rbc" else: @@ -677,7 +700,7 @@ class NeighborsBase(Base, InteropMixin, CMajorInputTagMixin, SparseInputTagMixin self.effective_metric_, self._fit_method, params=self.algo_params, - p=self.p, + p=self._effective_p, ) elif self._fit_method == "rbc": self._index = RBCIndex.build(self._fit_X, self.effective_metric_) @@ -744,13 +767,11 @@ class NeighborsBase(Base, InteropMixin, CMajorInputTagMixin, SparseInputTagMixin indices : {} The indices of the k-nearest neighbors for each column vector in X """ + check_is_fitted(self) + n_neighbors = self.n_neighbors if n_neighbors is None else n_neighbors if use_training_data := (X is None): - if not hasattr(self, "_fit_X"): - raise ValueError( - "Model needs to be trained before calling kneighbors()" - ) X = self._fit_X n_neighbors += 1 @@ -823,7 +844,7 @@ class NeighborsBase(Base, InteropMixin, CMajorInputTagMixin, SparseInputTagMixin cdef float* X_ptr = X_m.ptr cdef int64_t* indices_ptr = indices.ptr cdef float* distances_ptr = distances.ptr - cdef float metric_arg = self.p + cdef float metric_arg = self._effective_p with nogil: brute_force_knn( @@ -858,7 +879,7 @@ class NeighborsBase(Base, InteropMixin, CMajorInputTagMixin, SparseInputTagMixin if not ( metric == DistanceType.L2SqrtExpanded or metric == DistanceType.L2Expanded or - (metric == DistanceType.LpUnexpanded and self.p == 2) + (metric == DistanceType.LpUnexpanded and self._effective_p == 2) ): # Nothing to do return distances, indices @@ -892,7 +913,7 @@ class NeighborsBase(Base, InteropMixin, CMajorInputTagMixin, SparseInputTagMixin cdef size_t batch_size_query = algo_params.get("batch_size_query", 10000) cdef DistanceType metric = _metric_to_distance_type(self.effective_metric_) - cdef float metric_arg = self.p + cdef float metric_arg = self._effective_p # Extract query input components X_m = SparseCumlArray(X, convert_to_dtype=cp.float32) @@ -982,10 +1003,7 @@ class NeighborsBase(Base, InteropMixin, CMajorInputTagMixin, SparseInputTagMixin numpy's CSR sparse graph (host) """ - if not hasattr(self, "_fit_X"): - raise ValueError('This NearestNeighbors instance has not been ' - 'fitted yet, call "fit" before using this ' - 'estimator') + check_is_fitted(self) if n_neighbors is None: n_neighbors = self.n_neighbors @@ -1017,18 +1035,6 @@ class NeighborsBase(Base, InteropMixin, CMajorInputTagMixin, SparseInputTagMixin shape=(n_samples, self.n_samples_fit_) ) - @property - def effective_metric_(self): - return self.metric - - @effective_metric_.setter - def effective_metric_(self, val): - self.metric = val - - @property - def effective_metric_params_(self): - return self.metric_params or {} - class NearestNeighbors(NeighborsBase): """ @@ -1104,7 +1110,7 @@ class NearestNeighbors(NeighborsBase): - n_bits: (int) bits allocated per subquantizer - usePrecomputedTables : (bool) whether to use precomputed tables metric_params : dict, optional (default = None) - This is currently ignored. + Additional keyword arguments for the metric function. n_jobs : int (default = None) Ignored, here for scikit-learn API compatibility. output_type : {'input', 'array', 'dataframe', 'series', 'df_obj', \ @@ -1254,10 +1260,7 @@ class NearestNeighbors(NeighborsBase): [0., 1., 0.], [1., 0., 1.]]) """ - if not hasattr(self, "_fit_X"): - raise ValueError("This NearestNeighbors instance has not been " - "fitted yet, call 'fit' before using this " - "estimator") + check_is_fitted(self) if isinstance(self._fit_X, SparseCumlArray) or is_sparse(X): raise TypeError("`radius_neighbors_graph` doesn't support sparse inputs") @@ -1368,7 +1371,8 @@ def kneighbors_graph( itself. If 'auto', then True is used for mode='connectivity' and False for mode='distance'. - metric_params : dict, optional (default = None) This is currently ignored. + metric_params : dict, optional (default = None) + Additional keyword arguments for the metric function. Returns ------- diff --git a/python/cuml/cuml/preprocessing/LabelEncoder.py b/python/cuml/cuml/preprocessing/LabelEncoder.py index dacf57c73f..954c55eb18 100644 --- a/python/cuml/cuml/preprocessing/LabelEncoder.py +++ b/python/cuml/cuml/preprocessing/LabelEncoder.py @@ -5,8 +5,8 @@ from typing import TYPE_CHECKING -from cuml._thirdparty.sklearn.utils.validation import check_is_fitted from cuml.internals.base import Base +from cuml.internals.validation import check_is_fitted if TYPE_CHECKING: import cudf diff --git a/python/cuml/cuml/preprocessing/TargetEncoder.py b/python/cuml/cuml/preprocessing/TargetEncoder.py index ebb3ae6076..0d62a68268 100644 --- a/python/cuml/cuml/preprocessing/TargetEncoder.py +++ b/python/cuml/cuml/preprocessing/TargetEncoder.py @@ -2,7 +2,6 @@ # SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 # - import warnings import cudf @@ -10,11 +9,11 @@ import numpy as np import pandas as pd -from cuml.common.exceptions import NotFittedError from cuml.internals.array import CumlArray from cuml.internals.base import Base from cuml.internals.interop import InteropMixin, to_cpu, to_gpu from cuml.internals.outputs import reflect +from cuml.internals.validation import check_is_fitted # Module-level flag to ensure deprecation warning only fires once per process _COMBINATION_MODE_1D_WARNING_SHOWN = False @@ -234,7 +233,6 @@ def __init__( self.train = None self.stat = stat self.multi_feature_mode = multi_feature_mode - self._fitted = False @reflect(reset=True) def fit(self, X, y, *, fold_ids=None): @@ -293,7 +291,6 @@ def fit(self, X, y, *, fold_ids=None): res, train = self._fit_transform(X, y, fold_ids=fold_ids) self.train_encode = res self.train = train - self._fitted = True # Set _n_features_out for sklearn compatibility (get_feature_names_out) if getattr(self, "_independent_mode_fitted", False): @@ -353,7 +350,7 @@ def transform(self, X) -> CumlArray: The ordinally encoded input series """ - self._check_is_fitted() + check_is_fitted(self) test = self._data_with_strings_to_cudf_dataframe(X) # Check feature dimensions match @@ -782,16 +779,6 @@ def _groupby_agg(self, train, x_cols, op, y_cols): ) return df_each_fold, df_all - def _check_is_fitted(self): - # Check if fitted - either via fit() or from_sklearn() - # When loaded from sklearn, train may be None but encode_all exists - if not self._fitted and not hasattr(self, "encode_all"): - msg = ( - "This TargetEncoder instance is not fitted yet. Call 'fit' " - "with appropriate arguments before using this estimator." - ) - raise NotFittedError(msg) - def _is_train_df(self, df): """ Return True if the dataframe `df` is the training dataframe, which @@ -993,7 +980,6 @@ def _attrs_from_cpu(self, model): "_n_features_out": n_features, # sklearn always uses independent mode "mean": float(model.target_mean_), "y_stat_val": float(model.target_mean_), - "_fitted": True, "train": None, "train_encode": None, "target_type_": getattr(model, "target_type_", "continuous"), diff --git a/python/cuml/cuml/preprocessing/encoders.py b/python/cuml/cuml/preprocessing/encoders.py index b1846cac05..ed59fc5696 100644 --- a/python/cuml/cuml/preprocessing/encoders.py +++ b/python/cuml/cuml/preprocessing/encoders.py @@ -12,9 +12,9 @@ import cuml.internals.logger as logger from cuml.common.doc_utils import generate_docstring -from cuml.common.exceptions import NotFittedError from cuml.internals.base import Base from cuml.internals.output_utils import cudf_to_pandas +from cuml.internals.validation import check_is_fitted from cuml.preprocessing.LabelEncoder import LabelEncoder @@ -228,7 +228,6 @@ def __init__( self.dtype = dtype self.handle_unknown = handle_unknown self.drop = drop - self._fitted = False self.drop_idx_ = None self._features = None self._encoders = None @@ -258,13 +257,10 @@ def _validate_keywords(self): "zero." ) - def _check_is_fitted(self): - if not self._fitted: - msg = ( - "This OneHotEncoder instance is not fitted yet. Call 'fit' " - "with appropriate arguments before using this estimator." - ) - raise NotFittedError(msg) + def __sklearn_is_fitted__(self): + # TODO: fix state management of this class so `check_is_fitted` works + # without special casing + return getattr(self, "_fitted", False) def _compute_drop_idx(self): """Helper to compute indices to drop from category to drop.""" @@ -359,7 +355,8 @@ def fit_transform(self, X, y=None): ) def transform(self, X): """Transform X using one-hot encoding.""" - self._check_is_fitted() + check_is_fitted(self) + X = self._check_input(X) cols, rows = list(), list() @@ -457,7 +454,8 @@ def inverse_transform(self, X): X_tr : cudf.DataFrame or cupy.ndarray Inverse transformed array. """ - self._check_is_fitted() + check_is_fitted(self) + if cupyx.scipy.sparse.issparse(X): # cupyx.scipy.sparse 7.x does not support argmax, # when we upgrade cupy to 8.x, we should add a condition in the @@ -529,7 +527,8 @@ def get_feature_names(self, input_features=None): output_feature_names : ndarray of shape (n_output_features,) Array of feature names. """ - self._check_is_fitted() + check_is_fitted(self) + cats = self.categories_ if input_features is None: input_features = ["x%d" % i for i in range(len(cats))] diff --git a/python/cuml/cuml/random_projection/random_projection.py b/python/cuml/cuml/random_projection/random_projection.py index fb1839a97c..1b7f551941 100644 --- a/python/cuml/cuml/random_projection/random_projection.py +++ b/python/cuml/cuml/random_projection/random_projection.py @@ -13,7 +13,7 @@ from cuml.internals.input_utils import input_to_cuml_array from cuml.internals.mixins import SparseInputTagMixin from cuml.internals.outputs import reflect -from cuml.internals.utils import check_random_seed +from cuml.internals.validation import check_is_fitted, check_random_seed def johnson_lindenstrauss_min_dim(n_samples, eps=0.1): @@ -119,6 +119,8 @@ def fit(self, X, y=None, *, convert_dtype=True): @reflect def transform(self, X, *, convert_dtype=True) -> CumlArray: """Project the data by taking the matrix product with the random matrix.""" + check_is_fitted(self) + # Coerce X to a cupy array or cupyx sparse matrix index = None if sp.issparse(X): diff --git a/python/cuml/cuml/svm/linear_svc.py b/python/cuml/cuml/svm/linear_svc.py index 933258b2e4..7556c8f2d2 100644 --- a/python/cuml/cuml/svm/linear_svc.py +++ b/python/cuml/cuml/svm/linear_svc.py @@ -3,6 +3,7 @@ # import cupy as cp import numpy as np +from sklearn.exceptions import NotFittedError import cuml.svm.linear from cuml.common.array_descriptor import CumlArrayDescriptor @@ -12,7 +13,6 @@ process_class_weight, ) from cuml.common.doc_utils import generate_docstring -from cuml.common.exceptions import NotFittedError from cuml.internals.array import CumlArray from cuml.internals.base import Base from cuml.internals.input_utils import input_to_cuml_array @@ -24,6 +24,7 @@ ) from cuml.internals.mixins import ClassifierMixin from cuml.internals.outputs import reflect, run_in_internal_context +from cuml.internals.validation import check_is_fitted from cuml.linear_model.base import LinearClassifierMixin __all__ = ("LinearSVC",) @@ -330,6 +331,8 @@ def predict_proba(self, X, *, convert_dtype=True) -> CumlArray: The model must have been fit with ``probability=True`` for this method to be available. """ + check_is_fitted(self) + if self.prob_scale_ is None: raise NotFittedError( "This classifier is not fitted to predict " diff --git a/python/cuml/cuml/svm/svc.py b/python/cuml/cuml/svm/svc.py index 26147dcebb..d2f7fd2285 100644 --- a/python/cuml/cuml/svm/svc.py +++ b/python/cuml/cuml/svm/svc.py @@ -3,6 +3,7 @@ # import cupy as cp import numpy as np +from sklearn.exceptions import NotFittedError from cuml.common.classification import ( decode_labels, @@ -10,7 +11,6 @@ process_class_weight, ) from cuml.common.doc_utils import generate_docstring -from cuml.common.exceptions import NotFittedError from cuml.common.sparse_utils import is_sparse from cuml.internals.array import CumlArray from cuml.internals.array_sparse import SparseCumlArray @@ -27,7 +27,7 @@ reflect, run_in_internal_context, ) -from cuml.internals.utils import check_random_seed +from cuml.internals.validation import check_is_fitted, check_random_seed from cuml.multiclass import OneVsOneClassifier, OneVsRestClassifier from cuml.svm.svm_base import SVMBase @@ -518,6 +518,8 @@ def predict(self, X, *, convert_dtype=True): Predicts the class labels for X. The returned y values are the class labels associated to sign(decision_function(X)). """ + check_is_fitted(self) + if hasattr(self, "_multiclass"): inds = self._multiclass.predict(X).to_output("cupy") elif self.probability: @@ -553,6 +555,8 @@ def predict_proba(self, X, *, log=False) -> CumlArray: Whether to return log probabilities. """ + check_is_fitted(self) + from cupyx.scipy.special import expit if not self.probability: @@ -625,6 +629,8 @@ def decision_function(self, X, *, convert_dtype=True) -> CumlArray: number of samples used during fit. """ + check_is_fitted(self) + if hasattr(self, "_multiclass"): return self._multiclass.decision_function(X) diff --git a/python/cuml/cuml/svm/svr.py b/python/cuml/cuml/svm/svr.py index fdca0efafc..80f99fbf28 100644 --- a/python/cuml/cuml/svm/svr.py +++ b/python/cuml/cuml/svm/svr.py @@ -10,6 +10,7 @@ from cuml.internals.input_utils import input_to_cuml_array from cuml.internals.mixins import RegressorMixin from cuml.internals.outputs import reflect +from cuml.internals.validation import check_is_fitted from cuml.svm.svm_base import SVMBase @@ -213,6 +214,8 @@ def predict(self, X, *, convert_dtype=True) -> CumlArray: number of samples used during fit. """ + check_is_fitted(self) + dtype = self.support_vectors_.dtype # For precomputed kernels, check that columns match training set size 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 0a5199d2e3..7a55b9643a 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 @@ -380,6 +380,9 @@ - "sklearn.neighbors.tests.test_neighbors::test_neighbors_validate_parameters[csr_matrix-KNeighborsRegressor]" - "sklearn.neighbors.tests.test_neighbors::test_pipeline_with_nearest_neighbors_transformer" - "sklearn.neighbors.tests.test_neighbors::test_precomputed_cross_validation" + - "sklearn.neighbors.tests.test_neighbors::test_query_equidistant_kth_nn[ball_tree]" + - "sklearn.neighbors.tests.test_neighbors::test_query_equidistant_kth_nn[brute]" + - "sklearn.neighbors.tests.test_neighbors::test_query_equidistant_kth_nn[kd_tree]" - "sklearn.neighbors.tests.test_neighbors::test_unsupervised_inputs[float64-KNeighborsClassifier]" - "sklearn.neighbors.tests.test_neighbors::test_unsupervised_inputs[float64-KNeighborsRegressor]" - "sklearn.neighbors.tests.test_neighbors::test_unsupervised_inputs[float64-NearestNeighbors]" diff --git a/python/cuml/tests/dask/test_dask_label_encoder.py b/python/cuml/tests/dask/test_dask_label_encoder.py index 3134e058d4..0877430c2c 100644 --- a/python/cuml/tests/dask/test_dask_label_encoder.py +++ b/python/cuml/tests/dask/test_dask_label_encoder.py @@ -1,14 +1,14 @@ -# SPDX-FileCopyrightText: Copyright (c) 2020-2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 import cudf import cupy as cp import dask_cudf import numpy as np import pytest +from sklearn.exceptions import NotFittedError +from sklearn.utils.validation import check_is_fitted import cuml -from cuml._thirdparty.sklearn.utils.validation import check_is_fitted -from cuml.common.exceptions import NotFittedError from cuml.dask.preprocessing.LabelEncoder import LabelEncoder diff --git a/python/cuml/tests/explainer/test_gpu_treeshap.py b/python/cuml/tests/explainer/test_gpu_treeshap.py index 75fb80bcda..297bc0ce7a 100644 --- a/python/cuml/tests/explainer/test_gpu_treeshap.py +++ b/python/cuml/tests/explainer/test_gpu_treeshap.py @@ -1,5 +1,5 @@ # -# SPDX-FileCopyrightText: Copyright (c) 2021-2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 # @@ -16,9 +16,9 @@ from sklearn.datasets import make_classification, make_regression from sklearn.ensemble import RandomForestClassifier as sklrfc from sklearn.ensemble import RandomForestRegressor as sklrfr +from sklearn.exceptions import NotFittedError import cuml -from cuml.common.exceptions import NotFittedError from cuml.ensemble import RandomForestClassifier as curfc from cuml.ensemble import RandomForestRegressor as curfr from cuml.explainer.tree_shap import TreeExplainer diff --git a/python/cuml/tests/test_incremental_pca.py b/python/cuml/tests/test_incremental_pca.py index 5aa77e38be..4e65c18600 100644 --- a/python/cuml/tests/test_incremental_pca.py +++ b/python/cuml/tests/test_incremental_pca.py @@ -1,5 +1,5 @@ # -# SPDX-FileCopyrightText: Copyright (c) 2020-2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 # @@ -7,8 +7,8 @@ import cupyx import pytest from sklearn.decomposition import IncrementalPCA as skIPCA +from sklearn.exceptions import NotFittedError -from cuml.common.exceptions import NotFittedError from cuml.datasets import make_blobs from cuml.decomposition import IncrementalPCA as cuIPCA from cuml.decomposition.incremental_pca import _svd_flip diff --git a/python/cuml/tests/test_kernel_density.py b/python/cuml/tests/test_kernel_density.py index cbe2a4c206..59a37cfe81 100644 --- a/python/cuml/tests/test_kernel_density.py +++ b/python/cuml/tests/test_kernel_density.py @@ -1,5 +1,5 @@ # -# SPDX-FileCopyrightText: Copyright (c) 2022-2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 # @@ -11,12 +11,12 @@ from hypothesis import strategies as st from hypothesis.extra.numpy import arrays from sklearn.datasets import make_blobs +from sklearn.exceptions import NotFittedError from sklearn.metrics import pairwise_distances as skl_pairwise_distances from sklearn.model_selection import GridSearchCV from sklearn.neighbors._ball_tree import kernel_norm import cuml -from cuml.common.exceptions import NotFittedError from cuml.neighbors import VALID_KERNELS, KernelDensity from cuml.neighbors.kernel_density import logsumexp from cuml.testing.utils import as_type diff --git a/python/cuml/tests/test_label_encoder.py b/python/cuml/tests/test_label_encoder.py index 6b37b94428..222d0c0afc 100644 --- a/python/cuml/tests/test_label_encoder.py +++ b/python/cuml/tests/test_label_encoder.py @@ -7,9 +7,9 @@ import numpy as np import pandas as pd import pytest +from sklearn.exceptions import NotFittedError +from sklearn.utils.validation import check_is_fitted -from cuml._thirdparty.sklearn.utils.validation import check_is_fitted -from cuml.common.exceptions import NotFittedError from cuml.preprocessing.LabelEncoder import LabelEncoder cudf_pandas_active = cudf.pandas.LOADED diff --git a/python/cuml/tests/test_linear_svm.py b/python/cuml/tests/test_linear_svm.py index ccc743fb22..94aa87b4be 100644 --- a/python/cuml/tests/test_linear_svm.py +++ b/python/cuml/tests/test_linear_svm.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2021-2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 # import math @@ -7,11 +7,11 @@ import pytest import sklearn.svm from sklearn.datasets import make_classification, make_regression +from sklearn.exceptions import NotFittedError from sklearn.model_selection import train_test_split import cuml import cuml.svm as cu -from cuml.common.exceptions import NotFittedError from cuml.testing.utils import as_type diff --git a/python/cuml/tests/test_nearest_neighbors.py b/python/cuml/tests/test_nearest_neighbors.py index 93e6704a1a..6aeabde309 100644 --- a/python/cuml/tests/test_nearest_neighbors.py +++ b/python/cuml/tests/test_nearest_neighbors.py @@ -13,6 +13,7 @@ import pandas as pd import pytest import sklearn +import sklearn.datasets from numpy.testing import assert_allclose, assert_array_equal from scipy.sparse import isspmatrix_csr from sklearn.metrics import pairwise_distances @@ -427,9 +428,9 @@ def test_knn_fit_twice(): @pytest.mark.parametrize("nrows", [unit_param(500), stress_param(70000)]) @pytest.mark.parametrize("n_feats", [unit_param(20), stress_param(1000)]) def test_nn_downcast_fails(input_type, nrows, n_feats): - from sklearn.datasets import make_blobs as skmb - - X, y = skmb(n_samples=nrows, n_features=n_feats, random_state=0) + X, y = sklearn.datasets.make_blobs( + n_samples=nrows, n_features=n_feats, random_state=0 + ) knn_cu = cuKNN() if input_type == "dataframe": @@ -790,3 +791,30 @@ def test_n_jobs_parameter_passthrough(): assert cunn.n_jobs == 1 cunn.set_params(n_jobs=12) assert cunn.n_jobs == 12 + + +@pytest.mark.parametrize( + "metric, p, effective_metric", + [ + ("minkowski", 1, "manhattan"), + ("minkowski", 2, "euclidean"), + ("minkowski", 3, "minkowski"), + ("minkowski", np.inf, "chebyshev"), + ("sqeuclidean", 2, "sqeuclidean"), + ], +) +@pytest.mark.parametrize("use_params", [False, True]) +def test_effective_metric_and_params(metric, p, effective_metric, use_params): + kws = {"p": 1000, "metric_params": {"p": p}} if use_params else {"p": p} + X, _ = sklearn.datasets.make_blobs(random_state=42) + cunn = cuKNN(metric=metric, **kws).fit(X) + sknn = skKNN(metric=metric, p=p).fit(X) + assert cunn.effective_metric_ == effective_metric + assert cunn.effective_metric_ == sknn.effective_metric_ + assert cunn.effective_metric_params_.get( + "p" + ) == sknn.effective_metric_params_.get("p") + + sol, _ = sknn.kneighbors(n_neighbors=5) + res, _ = cunn.kneighbors(n_neighbors=5) + np.testing.assert_allclose(sol, res, atol=1e-4) diff --git a/python/cuml/tests/test_pca.py b/python/cuml/tests/test_pca.py index 2b84b55757..953104ccb1 100644 --- a/python/cuml/tests/test_pca.py +++ b/python/cuml/tests/test_pca.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2019-2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 # @@ -12,9 +12,9 @@ from sklearn import datasets from sklearn.datasets import make_blobs, make_multilabel_classification from sklearn.decomposition import PCA as skPCA +from sklearn.exceptions import NotFittedError from cuml import PCA as cuPCA -from cuml.common.exceptions import NotFittedError from cuml.testing.utils import ( array_equal, quality_param, diff --git a/python/cuml/tests/test_sklearn_compatibility.py b/python/cuml/tests/test_sklearn_compatibility.py index 8cecb3eacd..e8ef44643d 100644 --- a/python/cuml/tests/test_sklearn_compatibility.py +++ b/python/cuml/tests/test_sklearn_compatibility.py @@ -44,10 +44,46 @@ pytest.importorskip("sklearn", minversion="1.8") -PER_ESTIMATOR_XFAIL_CHECKS = { +ESTIMATORS = [ + GaussianRandomProjection(n_components=2), + SparseRandomProjection(n_components=2), + DBSCAN(), + HDBSCAN(), + AgglomerativeClustering(), + KernelRidge(), + GaussianNB(), + ComplementNB(), + CategoricalNB(), + BernoulliNB(), + MultinomialNB(), + UMAP(), + TSNE(), + TruncatedSVD(), + IncrementalPCA(), + PCA(), + SVR(), + SVC(), + LinearSVR(), + LinearSVC(), + NearestNeighbors(), + KNeighborsRegressor(), + KNeighborsClassifier(), + KernelDensity(), + LedoitWolf(), + Ridge(), + ElasticNet(), + Lasso(), + LinearRegression(), + RandomForestClassifier(), + RandomForestRegressor(), + KMeans(), + LogisticRegression(), +] + + +XFAILS = { KMeans: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_estimators_unfitted": "KMeans does not raise NotFittedError before fit", "check_n_features_in_after_fitting": "KMeans does not check n_features_in consistency", "check_sample_weights_not_an_array": "KMeans does not handle non-array sample weights", "check_sample_weights_list": "KMeans does not handle list sample weights", @@ -61,7 +97,6 @@ }, KernelRidge: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_estimators_unfitted": "KernelRidge does not raise NotFittedError before fit", "check_n_features_in_after_fitting": "KernelRidge does not check n_features_in consistency", "check_sample_weights_pandas_series": "KernelRidge does not handle pandas Series sample weights", "check_sample_weights_not_an_array": "KernelRidge does not handle non-array sample weights", @@ -81,7 +116,6 @@ }, LogisticRegression: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_estimators_unfitted": "LogisticRegression does not raise NotFittedError before fit", "check_n_features_in_after_fitting": "LogisticRegression does not check n_features_in consistency", "check_sample_weights_not_an_array": "LogisticRegression does not handle non-array sample weights", "check_sample_weights_list": "LogisticRegression does not handle list sample weights", @@ -104,7 +138,6 @@ }, LinearRegression: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_estimators_unfitted": "LinearRegression does not raise NotFittedError before fit", "check_n_features_in_after_fitting": "LinearRegression does not check n_features_in consistency", "check_sample_weights_not_an_array": "LinearRegression does not handle non-array sample weights", "check_sample_weights_list": "LinearRegression does not handle list sample weights", @@ -124,7 +157,6 @@ }, Ridge: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_estimators_unfitted": "Ridge does not raise NotFittedError before fit", "check_n_features_in_after_fitting": "Ridge does not check n_features_in consistency", "check_sample_weights_not_an_array": "Ridge does not handle non-array sample weights", "check_sample_weights_list": "Ridge does not handle list sample weights", @@ -143,7 +175,6 @@ }, RandomForestRegressor: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_estimators_unfitted": "RandomForestRegressor does not raise NotFittedError before fit", "check_do_not_raise_errors_in_init_or_set_params": "RandomForestRegressor raises errors in init or set_params", "check_n_features_in_after_fitting": "RandomForestRegressor does not check n_features_in consistency", "check_dtype_object": "RandomForestRegressor does not handle object dtype", @@ -162,8 +193,6 @@ }, KNeighborsClassifier: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_no_attributes_set_in_init": "KNeighborsClassifier sets attributes during init", - "check_estimators_unfitted": "KNeighborsClassifier does not raise NotFittedError before fit", "check_do_not_raise_errors_in_init_or_set_params": "KNeighborsClassifier raises errors in init or set_params", "check_n_features_in_after_fitting": "KNeighborsClassifier does not check n_features_in consistency", "check_dtype_object": "KNeighborsClassifier does not handle object dtype", @@ -173,13 +202,11 @@ "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_fit_check_is_fitted": "KNeighborsClassifier passes check_is_fitted before being fit", "check_fit2d_predict1d": "KNeighborsClassifier does not handle 1D prediction input gracefully", "check_requires_y_none": "KNeighborsClassifier does not handle y=None", }, RandomForestClassifier: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_estimators_unfitted": "RandomForestClassifier does not raise NotFittedError before fit", "check_do_not_raise_errors_in_init_or_set_params": "RandomForestClassifier raises errors in init or set_params", "check_n_features_in_after_fitting": "RandomForestClassifier does not check n_features_in consistency", "check_dtype_object": "RandomForestClassifier does not handle object dtype", @@ -198,8 +225,6 @@ }, KNeighborsRegressor: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_no_attributes_set_in_init": "KNeighborsRegressor sets attributes during init", - "check_estimators_unfitted": "KNeighborsRegressor does not raise NotFittedError before fit", "check_do_not_raise_errors_in_init_or_set_params": "KNeighborsRegressor raises errors in init or set_params", "check_n_features_in_after_fitting": "KNeighborsRegressor does not check n_features_in consistency", "check_dtype_object": "KNeighborsRegressor does not handle object dtype", @@ -211,21 +236,17 @@ "check_regressor_data_not_an_array": "KNeighborsRegressor does not handle non-array data", "check_supervised_y_2d": "KNeighborsRegressor does not handle 2D y", "check_supervised_y_no_nan": "KNeighborsRegressor does not check for NaN in y", - "check_fit_check_is_fitted": "KNeighborsRegressor passes check_is_fitted before being fit", "check_fit2d_predict1d": "KNeighborsRegressor does not handle 1D prediction input gracefully", "check_requires_y_none": "KNeighborsRegressor does not handle y=None", }, NearestNeighbors: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_no_attributes_set_in_init": "NearestNeighbors sets attributes during init", "check_dtype_object": "NearestNeighbors does not handle object dtype", "check_estimators_empty_data_messages": "NearestNeighbors does not handle empty data", "check_estimators_nan_inf": "NearestNeighbors does not check for NaN and inf", - "check_fit_check_is_fitted": "NearestNeighbors passes check_is_fitted before being fit", }, LinearSVC: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_estimators_unfitted": "LinearSVC does not raise NotFittedError before fit", "check_n_features_in_after_fitting": "LinearSVC does not check n_features_in consistency", "check_estimators_dtypes": "LinearSVC does not handle dtypes properly", "check_sample_weights_not_an_array": "LinearSVC does not handle non-array sample weights", @@ -252,7 +273,6 @@ }, LinearSVR: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_estimators_unfitted": "LinearSVR does not raise NotFittedError before fit", "check_n_features_in_after_fitting": "LinearSVR does not check n_features_in consistency", "check_sample_weights_not_an_array": "LinearSVR does not handle non-array sample weights", "check_sample_weights_list": "LinearSVR does not handle list sample weights", @@ -272,7 +292,6 @@ }, SVC: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_estimators_unfitted": "SVC does not raise NotFittedError before fit", "check_n_features_in_after_fitting": "SVC does not check n_features_in consistency", "check_estimators_dtypes": "SVC does not handle dtypes properly", "check_sample_weights_not_an_array": "SVC does not handle non-array sample weights", @@ -301,7 +320,6 @@ }, SVR: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_estimators_unfitted": "SVR does not raise NotFittedError before fit", "check_n_features_in_after_fitting": "SVR does not check n_features_in consistency", "check_sample_weights_not_an_array": "SVR does not handle non-array sample weights", "check_sample_weights_list": "SVR does not handle list sample weights", @@ -375,7 +393,6 @@ }, Lasso: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_estimators_unfitted": "Lasso does not raise NotFittedError before fit", "check_n_features_in_after_fitting": "Lasso does not check n_features_in consistency", "check_sample_weights_not_an_array": "Lasso does not handle non-array sample weights", "check_sample_weights_list": "Lasso does not handle list sample weights", @@ -394,7 +411,6 @@ }, ElasticNet: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_estimators_unfitted": "ElasticNet does not raise NotFittedError before fit", "check_n_features_in_after_fitting": "ElasticNet does not check n_features_in consistency", "check_sample_weights_not_an_array": "ElasticNet does not handle non-array sample weights", "check_sample_weights_list": "ElasticNet does not handle list sample weights", @@ -440,42 +456,20 @@ }, HDBSCAN: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_no_attributes_set_in_init": "HDBSCAN sets attributes during init", - "check_do_not_raise_errors_in_init_or_set_params": "HDBSCAN raises errors in init or set_params", "check_dtype_object": "HDBSCAN does not handle object dtype", "check_estimators_empty_data_messages": "HDBSCAN does not handle empty data", "check_estimators_nan_inf": "HDBSCAN does not check for NaN and inf", - "check_estimator_sparse_array": "HDBSCAN does not handle sparse arrays gracefully", - "check_estimator_sparse_matrix": "HDBSCAN does not handle sparse matrices gracefully", - "check_parameters_default_constructible": "HDBSCAN parameters are mutated on init", - "check_estimators_pickle": "HDBSCAN does not support pickling", - "check_estimators_pickle(readonly_memmap=True)": "HDBSCAN does not support pickling with readonly memmap", - "check_f_contiguous_array_estimator": "HDBSCAN does not handle F-contiguous arrays", - "check_methods_sample_order_invariance": "HDBSCAN results depend on sample order", - "check_methods_subset_invariance": "HDBSCAN results depend on data subset", "check_fit2d_1sample": "HDBSCAN does not handle single sample properly", - "check_fit2d_1feature": "HDBSCAN does not handle single feature properly", - "check_dict_unchanged": "HDBSCAN modifies input dictionaries", - "check_fit_idempotent": "HDBSCAN fit is not idempotent", - "check_fit_check_is_fitted": "HDBSCAN does not check is_fitted properly", - "check_n_features_in": "HDBSCAN does not set n_features_in properly", "check_fit1d": "HDBSCAN does not raise ValueError for 1D input", - "check_fit2d_predict1d": "HDBSCAN does not handle 1D prediction input gracefully", }, AgglomerativeClustering: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_no_attributes_set_in_init": "AgglomerativeClustering sets attributes during init", - "check_do_not_raise_errors_in_init_or_set_params": "AgglomerativeClustering raises errors in init or set_params", "check_dtype_object": "AgglomerativeClustering does not handle object dtype", "check_estimators_nan_inf": "AgglomerativeClustering does not check for NaN and inf", - "check_estimator_sparse_array": "AgglomerativeClustering does not handle sparse arrays gracefully", - "check_estimator_sparse_matrix": "AgglomerativeClustering does not handle sparse matrices gracefully", - "check_parameters_default_constructible": "AgglomerativeClustering parameters are mutated on init", "check_fit1d": "AgglomerativeClustering does not raise ValueError for 1D input", }, GaussianNB: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_estimators_unfitted": "GaussianNB does not raise NotFittedError before fit", "check_n_features_in_after_fitting": "GaussianNB does not check n_features_in consistency", "check_estimators_dtypes": "GaussianNB does not handle dtypes properly", "check_sample_weights_pandas_series": "GaussianNB does not handle pandas Series sample weights", @@ -501,65 +495,26 @@ }, GaussianRandomProjection: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_fit_score_takes_y": "GaussianRandomProjection raises ValueError with small datasets", - "check_estimators_overwrite_params": "GaussianRandomProjection raises ValueError with small datasets", - "check_estimators_fit_returns_self": "GaussianRandomProjection raises ValueError with small datasets", - "check_readonly_memmap_input": "GaussianRandomProjection raises ValueError with small datasets", - "check_n_features_in_after_fitting": "GaussianRandomProjection raises ValueError with small datasets", - "check_positive_only_tag_during_fit": "GaussianRandomProjection raises ValueError with small datasets", - "check_estimators_dtypes": "GaussianRandomProjection raises ValueError with small datasets", - "check_complex_data": "GaussianRandomProjection raises ValueError with small datasets", - "check_dtype_object": "GaussianRandomProjection raises ValueError with small datasets", - "check_estimators_empty_data_messages": "GaussianRandomProjection does not handle empty data", - "check_pipeline_consistency": "GaussianRandomProjection raises ValueError with small datasets", + "check_n_features_in_after_fitting": "GaussianRandomProjection doesn't check n_features_in_", + "check_complex_data": "GaussianRandomProjection doesn't support complex data", + "check_dtype_object": "GaussianRandomProjection doesn't support dtype object", + "check_estimators_empty_data_messages": "GaussianRandomProjection doesn't check for empty data", "check_estimators_nan_inf": "GaussianRandomProjection does not check for NaN and inf", - "check_estimator_sparse_tag": "GaussianRandomProjection raises ValueError with small datasets even though it supports sparse inputs", - "check_estimator_sparse_array": "GaussianRandomProjection does not handle sparse arrays gracefully", - "check_estimator_sparse_matrix": "GaussianRandomProjection does not handle sparse matrices gracefully", - "check_estimators_pickle": "GaussianRandomProjection raises ValueError with small datasets", - "check_estimators_pickle(readonly_memmap=True)": "GaussianRandomProjection raises ValueError with small datasets", - "check_f_contiguous_array_estimator": "GaussianRandomProjection raises ValueError with small datasets", "check_transformer_data_not_an_array": "GaussianRandomProjection does not handle non-array data", - "check_transformer_general": "GaussianRandomProjection raises ValueError with small datasets", - "check_transformer_general(readonly_memmap=True)": "GaussianRandomProjection raises ValueError with small datasets", - "check_dict_unchanged": "GaussianRandomProjection raises ValueError with small datasets", - "check_fit_idempotent": "GaussianRandomProjection raises ValueError with small datasets", - "check_fit_check_is_fitted": "GaussianRandomProjection raises ValueError with small datasets", - "check_n_features_in": "GaussianRandomProjection raises ValueError with small datasets", "check_fit2d_predict1d": "GaussianRandomProjection does not handle 1D prediction input gracefully", }, SparseRandomProjection: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_fit_score_takes_y": "SparseRandomProjection raises ValueError with small datasets", - "check_estimators_overwrite_params": "SparseRandomProjection raises ValueError with small datasets", - "check_estimators_fit_returns_self": "SparseRandomProjection raises ValueError with small datasets", - "check_readonly_memmap_input": "SparseRandomProjection raises ValueError with small datasets", - "check_n_features_in_after_fitting": "SparseRandomProjection raises ValueError with small datasets", - "check_positive_only_tag_during_fit": "SparseRandomProjection raises ValueError with small datasets", - "check_estimators_dtypes": "SparseRandomProjection raises ValueError with small datasets", - "check_complex_data": "SparseRandomProjection raises ValueError with small datasets", - "check_dtype_object": "SparseRandomProjection raises ValueError with small datasets", - "check_estimators_empty_data_messages": "SparseRandomProjection does not handle empty data", - "check_pipeline_consistency": "SparseRandomProjection raises ValueError with small datasets", + "check_n_features_in_after_fitting": "SparseRandomProjection doesn't check n_features_in_", + "check_complex_data": "SparseRandomProjection doesn't support complex data", + "check_dtype_object": "SparseRandomProjection doesn't support dtype object", + "check_estimators_empty_data_messages": "SparseRandomProjection doesn't check for empty data", "check_estimators_nan_inf": "SparseRandomProjection does not check for NaN and inf", - "check_estimator_sparse_tag": "SparseRandomProjection raises ValueError with small datasets", - "check_estimator_sparse_array": "SparseRandomProjection does not handle sparse arrays gracefully", - "check_estimator_sparse_matrix": "SparseRandomProjection does not handle sparse matrices gracefully", - "check_estimators_pickle": "SparseRandomProjection raises ValueError with small datasets", - "check_estimators_pickle(readonly_memmap=True)": "SparseRandomProjection raises ValueError with small datasets", - "check_f_contiguous_array_estimator": "SparseRandomProjection raises ValueError with small datasets", "check_transformer_data_not_an_array": "SparseRandomProjection does not handle non-array data", - "check_transformer_general": "SparseRandomProjection raises ValueError with small datasets", - "check_transformer_general(readonly_memmap=True)": "SparseRandomProjection raises ValueError with small datasets", - "check_dict_unchanged": "SparseRandomProjection raises ValueError with small datasets", - "check_fit_idempotent": "SparseRandomProjection raises ValueError with small datasets", - "check_fit_check_is_fitted": "SparseRandomProjection raises ValueError with small datasets", - "check_n_features_in": "SparseRandomProjection raises ValueError with small datasets", "check_fit2d_predict1d": "SparseRandomProjection does not handle 1D prediction input gracefully", }, BernoulliNB: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_estimators_unfitted": "BernoulliNB does not raise NotFittedError before fit", "check_n_features_in_after_fitting": "BernoulliNB does not check n_features_in consistency", "check_estimators_dtypes": "BernoulliNB expects specific dtypes, not bool", "check_sample_weights_pandas_series": "BernoulliNB does not handle pandas Series sample weights", @@ -587,7 +542,6 @@ "check_estimators_overwrite_params": "ComplementNB overwrites params on clone", "check_estimators_fit_returns_self": "ComplementNB fit does not return self for certain data types", "check_readonly_memmap_input": "ComplementNB does not handle readonly memmap input", - "check_estimators_unfitted": "ComplementNB does not raise NotFittedError before fit", "check_n_features_in_after_fitting": "ComplementNB does not check n_features_in consistency", "check_positive_only_tag_during_fit": "ComplementNB does not validate positive-only requirement", "check_estimators_dtypes": "ComplementNB expects specific dtypes, not bool", @@ -619,7 +573,6 @@ "check_estimators_overwrite_params": "CategoricalNB overwrites params on clone", "check_estimators_fit_returns_self": "CategoricalNB fit does not return self for certain data types", "check_readonly_memmap_input": "CategoricalNB does not handle readonly memmap input", - "check_estimators_unfitted": "CategoricalNB does not raise NotFittedError before fit", "check_n_features_in_after_fitting": "CategoricalNB does not check n_features_in consistency", "check_positive_only_tag_during_fit": "CategoricalNB does not validate positive-only requirement", "check_estimators_dtypes": "CategoricalNB expects specific dtypes, not bool", @@ -645,7 +598,6 @@ }, MultinomialNB: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", - "check_estimators_unfitted": "MultinomialNB does not raise NotFittedError before fit", "check_n_features_in_after_fitting": "MultinomialNB does not check n_features_in consistency", "check_estimators_dtypes": "MultinomialNB does not handle all dtypes properly", "check_sample_weights_pandas_series": "MultinomialNB does not handle pandas Series sample weights", @@ -671,8 +623,11 @@ } -def get_xfails(estimator): - return PER_ESTIMATOR_XFAIL_CHECKS.get(type(estimator), {}) +# Sanity check that xfails listed have at least one estimator instance +if missing := set(XFAILS).difference((type(est) for est in ESTIMATORS)): + raise ValueError( + f"xfails defined for {missing}, but that estimator isn't tested!" + ) def _check_name(check): @@ -684,41 +639,8 @@ def _check_name(check): @estimator_checks.parametrize_with_checks( - [ - GaussianRandomProjection(), - SparseRandomProjection(), - DBSCAN(), - AgglomerativeClustering(), - KernelRidge(), - GaussianNB(), - ComplementNB(), - CategoricalNB(), - BernoulliNB(), - MultinomialNB(), - UMAP(), - TSNE(), - TruncatedSVD(), - IncrementalPCA(), - PCA(), - SVR(), - SVC(), - LinearSVR(), - LinearSVC(), - NearestNeighbors(), - KNeighborsRegressor(), - KNeighborsClassifier(), - KernelDensity(), - LedoitWolf(), - Ridge(), - ElasticNet(), - Lasso(), - LinearRegression(), - RandomForestClassifier(), - RandomForestRegressor(), - KMeans(), - LogisticRegression(), - ], - expected_failed_checks=get_xfails, + ESTIMATORS, + expected_failed_checks=lambda est: XFAILS.get(type(est), {}), xfail_strict=True, ) @pytest.mark.filterwarnings( diff --git a/python/cuml/tests/test_sklearn_import_export.py b/python/cuml/tests/test_sklearn_import_export.py index fb045b5d3f..0204c171d2 100644 --- a/python/cuml/tests/test_sklearn_import_export.py +++ b/python/cuml/tests/test_sklearn_import_export.py @@ -552,7 +552,7 @@ def test_nearest_neighbors(random_state, sparse): (50, 20), dtype="float32" ) - cu_model = cuml.NearestNeighbors(n_neighbors=10).fit(X) + cu_model = cuml.NearestNeighbors(metric="minkowski", n_neighbors=10).fit(X) sk_model = sklearn.neighbors.NearestNeighbors(n_neighbors=10).fit(X) sk_model2 = cu_model.as_sklearn() @@ -592,9 +592,9 @@ def test_kneighbors_regressor(random_state, sparse, n_targets, weights): X[X < -0.5] = 0 X = scipy.sparse.csr_matrix(X) - cu_model = cuml.KNeighborsRegressor(n_neighbors=10, weights=weights).fit( - X, y - ) + cu_model = cuml.KNeighborsRegressor( + metric="minkowski", n_neighbors=10, weights=weights + ).fit(X, y) sk_model = sklearn.neighbors.KNeighborsRegressor( n_neighbors=10, weights=weights ).fit(X, y) @@ -642,9 +642,9 @@ def test_kneighbors_classifier(random_state, sparse, n_labels, weights): X = X.astype("float32") - cu_model = cuml.KNeighborsClassifier(n_neighbors=10, weights=weights).fit( - X, y - ) + cu_model = cuml.KNeighborsClassifier( + metric="minkowski", n_neighbors=10, weights=weights + ).fit(X, y) sk_model = sklearn.neighbors.KNeighborsClassifier( n_neighbors=10, weights=weights ).fit(X, y) diff --git a/python/cuml/tests/test_svm.py b/python/cuml/tests/test_svm.py index 6d1bf3ab83..a2b4fa87c5 100644 --- a/python/cuml/tests/test_svm.py +++ b/python/cuml/tests/test_svm.py @@ -19,6 +19,7 @@ make_gaussian_quantiles, make_regression, ) +from sklearn.exceptions import NotFittedError from sklearn.metrics import mean_squared_error from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler @@ -26,7 +27,6 @@ import cuml import cuml.svm as cu_svm from cuml.common import input_to_cuml_array -from cuml.common.exceptions import NotFittedError from cuml.testing.utils import ( compare_probabilistic_svm, compare_svm, diff --git a/python/cuml/tests/test_validation.py b/python/cuml/tests/test_validation.py new file mode 100644 index 0000000000..64d2a454e3 --- /dev/null +++ b/python/cuml/tests/test_validation.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION. +# SPDX-License-Identifier: Apache-2.0 +import cupy as cp +import numpy as np +import pytest + +from cuml.internals.validation import check_random_seed + + +@pytest.mark.parametrize( + "seed", + [ + pytest.param(None, id="none"), + pytest.param(42, id="int"), + pytest.param(np.random.RandomState(42), id="numpy"), + pytest.param(cp.random.RandomState(42), id="cupy"), + ], +) +def test_check_random_seed(seed): + res = check_random_seed(seed) + assert isinstance(res, int) + assert 0 <= res <= (2**32 - 1) # in range for uint32 + if isinstance(seed, int): + assert check_random_seed(seed) == res + + +def test_check_random_seed_errors(): + for bad in [-1, 2**32]: + with pytest.raises( + ValueError, match=r"Expected `0 <= random_state <= 2\*\*32 - 1`" + ): + check_random_seed(bad) + for ok in [0, 2**32 - 1]: + check_random_seed(ok) + with pytest.raises(TypeError, match="`random_state` must be"): + check_random_seed("incorrect type")