diff --git a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_data.py b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_data.py index 3502c236fe..87e0af1229 100644 --- a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_data.py +++ b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_data.py @@ -33,6 +33,7 @@ from cupyx.scipy import sparse from scipy import optimize, stats from scipy.special import boxcox +from sklearn.base import OneToOneFeatureMixin, ClassNamePrefixFeaturesOutMixin from cuml.common.sparse import csr_row_normalize_l1, csr_row_normalize_l2 from cuml.internals.interop import InteropMixin @@ -214,9 +215,12 @@ def scale(X, *, axis=0, with_mean=True, with_std=True, copy=True): return X -class MinMaxScaler(TransformerMixin, - BaseEstimator, - AllowNaNTagMixin): +class MinMaxScaler( + TransformerMixin, + AllowNaNTagMixin, + OneToOneFeatureMixin, + BaseEstimator, +): """Transform features by scaling each feature to a given range. This estimator scales and translates each feature individually such @@ -524,11 +528,14 @@ def minmax_scale(X, feature_range=(0, 1), *, axis=0, copy=True): return X -class StandardScaler(TransformerMixin, - AllowNaNTagMixin, - SparseInputTagMixin, - BaseEstimator, - InteropMixin): +class StandardScaler( + TransformerMixin, + AllowNaNTagMixin, + SparseInputTagMixin, + InteropMixin, + OneToOneFeatureMixin, + BaseEstimator, +): """Standardize features by removing the mean and scaling to unit variance The standard score of a sample `x` is calculated as: @@ -946,10 +953,13 @@ def inverse_transform(self, X, copy=None): return X -class MaxAbsScaler(TransformerMixin, - BaseEstimator, - AllowNaNTagMixin, - SparseInputTagMixin): +class MaxAbsScaler( + TransformerMixin, + AllowNaNTagMixin, + SparseInputTagMixin, + OneToOneFeatureMixin, + BaseEstimator, +): """Scale each feature by its maximum absolute value. This estimator scales and translates each feature individually such @@ -1196,10 +1206,13 @@ def maxabs_scale(X, *, axis=0, copy=True): return X -class RobustScaler(TransformerMixin, - BaseEstimator, - AllowNaNTagMixin, - SparseInputTagMixin): +class RobustScaler( + TransformerMixin, + AllowNaNTagMixin, + SparseInputTagMixin, + OneToOneFeatureMixin, + BaseEstimator, +): """Scale features using statistics that are robust to outliers. This Scaler removes the median and scales the data according to the @@ -1867,9 +1880,12 @@ def normalize(X, norm='l2', *, axis=1, copy=True, return_norm=False): return X -class Normalizer(TransformerMixin, - SparseInputTagMixin, - BaseEstimator): +class Normalizer( + TransformerMixin, + SparseInputTagMixin, + OneToOneFeatureMixin, + BaseEstimator, +): """Normalize samples individually to unit norm. Each sample (i.e. each row of the data matrix) with at least one @@ -2002,9 +2018,12 @@ def binarize(X, *, threshold=0.0, copy=True): return X -class Binarizer(TransformerMixin, - SparseInputTagMixin, - BaseEstimator): +class Binarizer( + TransformerMixin, + SparseInputTagMixin, + OneToOneFeatureMixin, + BaseEstimator, +): """Binarize data (set feature values to 0 or 1) according to a threshold Values greater than the threshold map to 1, while values less than @@ -2165,7 +2184,11 @@ def add_dummy_feature(X, value=1.0): return X -class KernelCenterer(TransformerMixin, BaseEstimator): +class KernelCenterer( + TransformerMixin, + ClassNamePrefixFeaturesOutMixin, + BaseEstimator, +): """Center a kernel matrix Let K(x, z) be a kernel defined by phi(x)^T phi(z), where phi is a @@ -2208,6 +2231,15 @@ def __init__(self): # Needed for backported inspect.signature compatibility with PyPy pass + @property + def _n_features_out(self): + return self.n_features_in_ + + def __sklearn_tags__(self): + tags = super().__sklearn_tags__() + tags.input_tags.pairwise = True + return tags + @mlfunc(set_input_type=True) def fit(self, K, y=None) -> 'KernelCenterer': """Fit KernelCenterer @@ -2263,14 +2295,13 @@ def transform(self, K, copy=True): return K - @property - def _pairwise(self): - return True - -class QuantileTransformer(TransformerMixin, - BaseEstimator, - AllowNaNTagMixin): +class QuantileTransformer( + TransformerMixin, + AllowNaNTagMixin, + OneToOneFeatureMixin, + BaseEstimator, +): """Transform features using quantiles information. This method transforms the features to follow a uniform or a normal @@ -2804,9 +2835,12 @@ def quantile_transform(X, *, axis=0, n_quantiles=1000, " axis={}".format(axis)) -class PowerTransformer(TransformerMixin, - BaseEstimator, - AllowNaNTagMixin): +class PowerTransformer( + TransformerMixin, + AllowNaNTagMixin, + OneToOneFeatureMixin, + BaseEstimator, +): """Apply a power transform featurewise to make data more Gaussian-like. Power transforms are a family of parametric, monotonic transformations diff --git a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_discretization.py b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_discretization.py index 7d60bc1096..741b69d755 100644 --- a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_discretization.py +++ b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_discretization.py @@ -25,15 +25,18 @@ from cuml.internals.mixins import SparseInputTagMixin from cuml.preprocessing.encoders import OneHotEncoder from cuml.internals.outputs import using_output_type, mlfunc, ReflectedAttr -from cuml.internals.validation import check_is_fitted, check_inputs, check_array +from cuml.internals.validation import ( + check_is_fitted, + check_input_features, + check_inputs, + check_array, +) from ..utils.skl_dependencies import BaseEstimator, TransformerMixin from ..utils.validation import FLOAT_DTYPES -class KBinsDiscretizer(TransformerMixin, - BaseEstimator, - SparseInputTagMixin): +class KBinsDiscretizer(TransformerMixin, BaseEstimator, SparseInputTagMixin): """ Bin continuous data into intervals. @@ -131,20 +134,17 @@ class KBinsDiscretizer(TransformerMixin, [ 0.5, 3.5, -1.5, 1.5]]) """ + n_bins_ = ReflectedAttr() - def __init__(self, n_bins=5, *, encode='onehot', strategy='quantile'): + def __init__(self, n_bins=5, *, encode="onehot", strategy="quantile"): self.n_bins = n_bins self.encode = encode self.strategy = strategy @classmethod def _get_param_names(cls): - return super()._get_param_names() + [ - "n_bins", - "encode", - "strategy" - ] + return super()._get_param_names() + ["n_bins", "encode", "strategy"] @mlfunc(set_input_type=True) def fit(self, X, y=None) -> "KBinsDiscretizer": @@ -166,16 +166,20 @@ def fit(self, X, y=None) -> "KBinsDiscretizer": """ X = check_inputs(self, X, dtype=FLOAT_DTYPES, reset=True) - valid_encode = ('onehot', 'onehot-dense', 'ordinal') + valid_encode = ("onehot", "onehot-dense", "ordinal") if self.encode not in valid_encode: - raise ValueError("Valid options for 'encode' are {}. " - "Got encode={!r} instead." - .format(valid_encode, self.encode)) - valid_strategy = ('uniform', 'quantile', 'kmeans') + raise ValueError( + "Valid options for 'encode' are {}. " + "Got encode={!r} instead.".format(valid_encode, self.encode) + ) + valid_strategy = ("uniform", "quantile", "kmeans") if self.strategy not in valid_strategy: - raise ValueError("Valid options for 'strategy' are {}. " - "Got strategy={!r} instead." - .format(valid_strategy, self.strategy)) + raise ValueError( + "Valid options for 'strategy' are {}. " + "Got strategy={!r} instead.".format( + valid_strategy, self.strategy + ) + ) n_features = X.shape[1] n_bins = self._validate_n_bins(n_features) @@ -187,29 +191,36 @@ def fit(self, X, y=None) -> "KBinsDiscretizer": col_min, col_max = column.min(), column.max() if col_min == col_max: - warnings.warn("Feature %d is constant and will be " - "replaced with 0." % jj) + warnings.warn( + "Feature %d is constant and will be replaced with 0." % jj + ) n_bins[jj] = 1 bin_edges[jj] = np.array([-np.inf, np.inf]) continue - if self.strategy == 'uniform': - bin_edges[jj] = np.linspace(col_min, col_max, int(n_bins[jj]) + 1) + if self.strategy == "uniform": + bin_edges[jj] = np.linspace( + col_min, col_max, int(n_bins[jj]) + 1 + ) - elif self.strategy == 'quantile': + elif self.strategy == "quantile": quantiles = np.linspace(0, 100, n_bins[jj] + 1) bin_edges[jj] = np.asarray(np.percentile(column, quantiles)) - elif self.strategy == 'kmeans': + elif self.strategy == "kmeans": # Deterministic initialization with uniform spacing uniform_edges = np.linspace(col_min, col_max, n_bins[jj] + 1) init = (uniform_edges[1:] + uniform_edges[:-1])[:, None] * 0.5 # 1D k-means procedure - km = KMeans(n_clusters=n_bins[jj], init=init, n_init=1, - output_type='cupy') + km = KMeans( + n_clusters=n_bins[jj], + init=init, + n_init=1, + output_type="cupy", + ) km = km.fit(column[:, None]) - with using_output_type('cupy'): + with using_output_type("cupy"): centers = km.cluster_centers_[:, 0] # Must sort, centers may be unsorted even with sorted init centers.sort() @@ -217,60 +228,73 @@ def fit(self, X, y=None) -> "KBinsDiscretizer": bin_edges[jj] = np.r_[col_min, bin_edges[jj], col_max] # Remove bins whose width are too small (i.e., <= 1e-8) - if self.strategy in ('quantile', 'kmeans'): + if self.strategy in ("quantile", "kmeans"): mask = np.diff(bin_edges[jj], prepend=-np.inf) > 1e-8 bin_edges[jj] = bin_edges[jj][mask] if len(bin_edges[jj]) - 1 != n_bins[jj]: - warnings.warn('Bins whose width are too small (i.e., <= ' - '1e-8) in feature %d are removed. Consider ' - 'decreasing the number of bins.' % jj) + warnings.warn( + "Bins whose width are too small (i.e., <= " + "1e-8) in feature %d are removed. Consider " + "decreasing the number of bins." % jj + ) n_bins[jj] = len(bin_edges[jj]) - 1 self.bin_edges_ = bin_edges self.n_bins_ = n_bins - if 'onehot' in self.encode: + if "onehot" in self.encode: self._encoder = OneHotEncoder( categories=[np.arange(i) for i in self.n_bins_], - sparse_output=self.encode == 'onehot', output_type='cupy') + sparse_output=self.encode == 'onehot', + output_type='cupy', + ) # Fit the OneHotEncoder with toy datasets # so that it's ready for use after the KBinsDiscretizer is fitted - self._encoder.fit(np.zeros((1, len(self.n_bins_)), dtype=int)) + self._encoder.fit(np.zeros((1, len(self.n_bins_)))) return self def _validate_n_bins(self, n_features): - """Returns n_bins_, the number of bins per feature. - """ + """Returns n_bins_, the number of bins per feature.""" orig_bins = self.n_bins if isinstance(orig_bins, numbers.Number): if not isinstance(orig_bins, numbers.Integral): - raise ValueError("{} received an invalid n_bins type. " - "Received {}, expected int." - .format(KBinsDiscretizer.__name__, - type(orig_bins).__name__)) + raise ValueError( + "{} received an invalid n_bins type. " + "Received {}, expected int.".format( + KBinsDiscretizer.__name__, type(orig_bins).__name__ + ) + ) if orig_bins < 2: - raise ValueError("{} received an invalid number " - "of bins. Received {}, expected at least 2." - .format(KBinsDiscretizer.__name__, orig_bins)) + raise ValueError( + "{} received an invalid number " + "of bins. Received {}, expected at least 2.".format( + KBinsDiscretizer.__name__, orig_bins + ) + ) return np.full(n_features, orig_bins, dtype=int) - n_bins = check_array(orig_bins, dtype=np.int, copy=True, - ensure_2d=False) + n_bins = check_array( + orig_bins, dtype=np.int, copy=True, ensure_2d=False + ) if n_bins.ndim > 1 or n_bins.shape[0] != n_features: - raise ValueError("n_bins must be a scalar or array " - "of shape (n_features,).") + raise ValueError( + "n_bins must be a scalar or array of shape (n_features,)." + ) bad_nbins_value = (n_bins < 2) | (n_bins != orig_bins) violating_indices = np.where(bad_nbins_value)[0] if violating_indices.shape[0] > 0: indices = ", ".join(str(i) for i in violating_indices) - raise ValueError("{} received an invalid number " - "of bins at indices {}. Number of bins " - "must be at least 2, and must be an int." - .format(KBinsDiscretizer.__name__, indices)) + raise ValueError( + "{} received an invalid number " + "of bins at indices {}. Number of bins " + "must be at least 2, and must be an int.".format( + KBinsDiscretizer.__name__, indices + ) + ) return n_bins @mlfunc @@ -294,11 +318,11 @@ def transform(self, X): bin_edges = self.bin_edges_ for jj in range(Xt.shape[1]): Xt[:, jj] = np.searchsorted( - bin_edges[jj][1:-1], Xt[:, jj], side='right' + bin_edges[jj][1:-1], Xt[:, jj], side="right" ) Xt = Xt.astype(np.int32) - if self.encode == 'ordinal': + if self.encode == "ordinal": return Xt Xt = self._encoder.transform(Xt) @@ -324,15 +348,17 @@ def inverse_transform(self, Xt): """ check_is_fitted(self) - if 'onehot' in self.encode: - Xt = check_array(Xt, accept_sparse=['csr', 'coo'], copy=True) + if "onehot" in self.encode: + Xt = check_array(Xt, accept_sparse=["csr", "coo"], copy=True) Xt = self._encoder.inverse_transform(Xt) Xinv = check_array(Xt, copy=True, dtype=FLOAT_DTYPES) n_features = self.n_bins_.shape[0] if Xinv.shape[1] != n_features: - raise ValueError("Incorrect number of features. Expecting {}, " - "received {}.".format(n_features, Xinv.shape[1])) + raise ValueError( + "Incorrect number of features. Expecting {}, " + "received {}.".format(n_features, Xinv.shape[1]) + ) for jj in range(n_features): bin_edges = self.bin_edges_[jj] @@ -341,3 +367,23 @@ def inverse_transform(self, Xt): Xinv[:, jj] = bin_centers[idxs.astype(np.int32)] return Xinv + + def get_feature_names_out(self, input_features=None): + """Get output feature names for transformation. + + Parameters + ---------- + input_features : array-like of str or None, default=None + Input feature names. + + Returns + ------- + feature_names_out : numpy.ndarray of str objects. + Transformed feature names. + """ + check_is_fitted(self) + input_features = check_input_features(self, input_features) + if "onehot" in self.encode: + return self._encoder.get_feature_names_out(input_features) + + return input_features diff --git a/python/cuml/cuml/cluster/kmeans.pyx b/python/cuml/cuml/cluster/kmeans.pyx index c204596aa9..11ffa44b6e 100644 --- a/python/cuml/cuml/cluster/kmeans.pyx +++ b/python/cuml/cuml/cluster/kmeans.pyx @@ -5,6 +5,7 @@ from numbers import Integral import cupy as cp import numpy as np +from sklearn.base import ClassNamePrefixFeaturesOutMixin from cuml.common.doc_utils import generate_docstring from cuml.internals.base import Base, get_handle @@ -434,10 +435,13 @@ cdef _kmeans_predict_host_chunked( return labels_host, total_inertia -class KMeans(InteropMixin, - ClusterMixin, - CMajorInputTagMixin, - Base): +class KMeans( + InteropMixin, + ClusterMixin, + CMajorInputTagMixin, + ClassNamePrefixFeaturesOutMixin, + Base, +): """ KMeans is a basic but powerful clustering method which is optimized via Expectation Maximization. It randomly selects K data points in X, and @@ -717,10 +721,11 @@ class KMeans(InteropMixin, self.init_size = init_size @property + @mlfunc(convert_output=False) def _n_features_out(self): """Number of transformed output features.""" # Exposed to support sklearn's `get_feature_names_out` - return self.n_clusters + return self.cluster_centers_.shape[0] @generate_docstring() @mlfunc(set_input_type=True) diff --git a/python/cuml/cuml/decomposition/pca.pyx b/python/cuml/cuml/decomposition/pca.pyx index b8e19edf12..0f0f5214cd 100644 --- a/python/cuml/cuml/decomposition/pca.pyx +++ b/python/cuml/cuml/decomposition/pca.pyx @@ -5,6 +5,7 @@ import cupy as cp import cupyx.scipy.sparse import numpy as np +from sklearn.base import ClassNamePrefixFeaturesOutMixin from cuml.common.doc_utils import generate_docstring from cuml.common.sparse import is_sparse, sparse_cov_and_mean @@ -83,6 +84,7 @@ cdef extern from "cuml/decomposition/pca.hpp" namespace "ML" nogil: class PCA(InteropMixin, FMajorInputTagMixin, SparseInputTagMixin, + ClassNamePrefixFeaturesOutMixin, Base): """ @@ -333,6 +335,7 @@ class PCA(InteropMixin, self.whiten = whiten @property + @mlfunc(convert_output=False) def _n_features_out(self): """Number of transformed output features.""" # Exposed to support sklearn's `get_feature_names_out` diff --git a/python/cuml/cuml/decomposition/tsvd.pyx b/python/cuml/cuml/decomposition/tsvd.pyx index a0302b8fe0..4c74dd2c32 100644 --- a/python/cuml/cuml/decomposition/tsvd.pyx +++ b/python/cuml/cuml/decomposition/tsvd.pyx @@ -4,6 +4,7 @@ # import cupy as cp import numpy as np +from sklearn.base import ClassNamePrefixFeaturesOutMixin from cuml.common.doc_utils import generate_docstring from cuml.internals.base import Base, get_handle @@ -70,6 +71,7 @@ cdef extern from "cuml/decomposition/tsvd.hpp" namespace "ML" nogil: class TruncatedSVD(InteropMixin, FMajorInputTagMixin, + ClassNamePrefixFeaturesOutMixin, Base): """ TruncatedSVD is used to compute the top K singular values and vectors of a @@ -277,6 +279,7 @@ class TruncatedSVD(InteropMixin, self.tol = tol @property + @mlfunc(convert_output=False) def _n_features_out(self): """Number of transformed output features.""" # Exposed to support sklearn's `get_feature_names_out` diff --git a/python/cuml/cuml/manifold/umap/umap.pyx b/python/cuml/cuml/manifold/umap/umap.pyx index 8541bce35e..a02588443e 100644 --- a/python/cuml/cuml/manifold/umap/umap.pyx +++ b/python/cuml/cuml/manifold/umap/umap.pyx @@ -12,6 +12,7 @@ import joblib import numpy as np import scipy.sparse import scipy.spatial +from sklearn.base import ClassNamePrefixFeaturesOutMixin from cuml.common.doc_utils import generate_docstring from cuml.common.sparse import is_sparse @@ -655,7 +656,13 @@ cdef init_params(self, lib.UMAPParams ¶ms, n_rows, is_sparse=False, is_fit=T params.build_params.nnd.intermediate_graph_degree = intermediate_graph_degree -class UMAP(InteropMixin, CMajorInputTagMixin, SparseInputTagMixin, Base): +class UMAP( + InteropMixin, + CMajorInputTagMixin, + SparseInputTagMixin, + ClassNamePrefixFeaturesOutMixin, + Base, +): """Uniform Manifold Approximation and Projection Finds a low dimensional embedding of the data that approximates @@ -1104,6 +1111,7 @@ class UMAP(InteropMixin, CMajorInputTagMixin, SparseInputTagMixin, Base): "_disconnection_distance": disconnection_distance, "_initial_alpha": self.learning_rate, "_n_neighbors": self._n_neighbors, + "_n_features_out": self._n_features_out, "_supervised": self._supervised, "_small_data": False, "_knn_dists": knn_dists, @@ -1196,6 +1204,11 @@ class UMAP(InteropMixin, CMajorInputTagMixin, SparseInputTagMixin, Base): self.build_kwds = build_kwds self.device_ids = device_ids + @property + @mlfunc(convert_output=False) + def _n_features_out(self): + return self.embedding_.array.shape[1] + @generate_docstring( X="dense_sparse", skip_parameters_heading=True, diff --git a/python/cuml/cuml/preprocessing/encoders.py b/python/cuml/cuml/preprocessing/encoders.py index ec939762f9..5ca5a63c75 100644 --- a/python/cuml/cuml/preprocessing/encoders.py +++ b/python/cuml/cuml/preprocessing/encoders.py @@ -6,6 +6,7 @@ import cupy as cp import cupyx.scipy.sparse as cp_sp import numpy as np +from sklearn.base import OneToOneFeatureMixin from cuml.common.doc_utils import generate_docstring from cuml.internals.base import Base @@ -577,7 +578,7 @@ def get_feature_names_out(self, input_features=None): return np.array(out, dtype=object) -class OrdinalEncoder(Base): +class OrdinalEncoder(OneToOneFeatureMixin, Base): """Encode categorical features as an integer array. The input to this transformer should be an array-like of integers or diff --git a/python/cuml/cuml/random_projection/random_projection.py b/python/cuml/cuml/random_projection/random_projection.py index c1c4fff949..3063ae50d1 100644 --- a/python/cuml/cuml/random_projection/random_projection.py +++ b/python/cuml/cuml/random_projection/random_projection.py @@ -3,6 +3,7 @@ import cupy as cp import cupyx.scipy.sparse as sp import numpy as np +from sklearn.base import ClassNamePrefixFeaturesOutMixin from cuml.common.doc_utils import generate_docstring from cuml.internals.base import Base @@ -45,7 +46,11 @@ def johnson_lindenstrauss_min_dim(n_samples, eps=0.1): ) -class _BaseRandomProjection(SparseInputTagMixin, Base): +class _BaseRandomProjection( + SparseInputTagMixin, + ClassNamePrefixFeaturesOutMixin, + Base, +): """Base class for RandomProjection estimators.""" components_ = ReflectedAttr() @@ -76,6 +81,11 @@ def _get_param_names(cls): def _gen_random_matrix(self, n_components, n_features, dtype): raise NotImplementedError + @property + @mlfunc(convert_output=False) + def _n_features_out(self): + return self.components_.shape[0] + @generate_docstring() @mlfunc(set_input_type=True) def fit(self, X, y=None): diff --git a/python/cuml/tests/test_incremental_pca.py b/python/cuml/tests/test_incremental_pca.py index 99abd6a9dc..e6bdcaa765 100644 --- a/python/cuml/tests/test_incremental_pca.py +++ b/python/cuml/tests/test_incremental_pca.py @@ -1,10 +1,10 @@ # -# SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # - import cupy as cp import cupyx +import numpy as np import pytest from sklearn.decomposition import IncrementalPCA as skIPCA from sklearn.exceptions import NotFittedError @@ -207,3 +207,12 @@ def test_svd_flip(): assert array_equal(reco_true, x) assert array_equal(reco_false, x) + + +def test_get_feature_names_out(): + X, _ = make_blobs(n_features=5) + cu_model = cuIPCA(n_components=2).fit(X) + sk_model = skIPCA(n_components=2).fit(X.get()) + res = cu_model.get_feature_names_out() + sol = sk_model.get_feature_names_out() + np.testing.assert_array_equal(res, sol) diff --git a/python/cuml/tests/test_kmeans.py b/python/cuml/tests/test_kmeans.py index cf1e658d2b..661acd901d 100644 --- a/python/cuml/tests/test_kmeans.py +++ b/python/cuml/tests/test_kmeans.py @@ -543,3 +543,12 @@ def test_kmeans_device_buffer_samples_host_path( rtol=1e-3, ) assert adjusted_rand_score(dev_labels, host_labels) >= 0.97 + + +def test_get_feature_names_out(): + X, _ = make_blobs(n_features=5) + cu_model = cuml.KMeans().fit(X) + sk_model = sklearn.cluster.KMeans().fit(X.get()) + res = cu_model.get_feature_names_out() + sol = sk_model.get_feature_names_out() + np.testing.assert_array_equal(res, sol) diff --git a/python/cuml/tests/test_ordinal_encoder.py b/python/cuml/tests/test_ordinal_encoder.py index f7541a3e8b..e6b71d4dfd 100644 --- a/python/cuml/tests/test_ordinal_encoder.py +++ b/python/cuml/tests/test_ordinal_encoder.py @@ -313,3 +313,17 @@ def test_ordinal_encoder_inverse_transform(): ValueError, match="Samples \\[0\\] can not be inverted" ): enc.inverse_transform(Xt) + + +def test_ordinal_encoder_get_feature_names_out(): + X = pd.DataFrame( + { + "fruits": ["apple", "banana", "apple"], + "counts": [0, 1, 2], + } + ) + cu_model = OrdinalEncoder().fit(X) + sk_model = sklearn.preprocessing.OrdinalEncoder().fit(X) + res = cu_model.get_feature_names_out() + sol = sk_model.get_feature_names_out() + assert np.array_equal(res, sol) diff --git a/python/cuml/tests/test_pca.py b/python/cuml/tests/test_pca.py index b48dc1e1d7..820aefda3c 100644 --- a/python/cuml/tests/test_pca.py +++ b/python/cuml/tests/test_pca.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # @@ -305,3 +305,12 @@ def test_exceptions(): with pytest.raises(NotFittedError): X = cp.random.random((10, 10)) cuPCA().inverse_transform(X) + + +def test_get_feature_names_out(): + X, _ = make_blobs(n_features=5) + cu_model = cuPCA(n_components=2).fit(X) + sk_model = skPCA(n_components=2).fit(X) + res = cu_model.get_feature_names_out() + sol = sk_model.get_feature_names_out() + np.testing.assert_array_equal(res, sol) diff --git a/python/cuml/tests/test_preprocessing.py b/python/cuml/tests/test_preprocessing.py index 12e7fd6099..c99e9c4c0e 100644 --- a/python/cuml/tests/test_preprocessing.py +++ b/python/cuml/tests/test_preprocessing.py @@ -8,6 +8,7 @@ import pytest import scipy import sklearn +import sklearn.datasets from packaging.version import Version from sklearn.impute import MissingIndicator as skMissingIndicator from sklearn.impute import SimpleImputer as skSimpleImputer @@ -1274,3 +1275,62 @@ def test__repr__(): assert cuRobustScaler().__repr__() == "RobustScaler()" assert cuSimpleImputer().__repr__() == "SimpleImputer()" assert cuStandardScaler().__repr__() == "StandardScaler()" + + +@pytest.mark.parametrize( + "cu_cls, sk_cls, params", + [ + (cuMinMaxScaler, skMinMaxScaler, {}), + (cuMaxAbsScaler, skMaxAbsScaler, {}), + (cuRobustScaler, skRobustScaler, {}), + (cuStandardScaler, skStandardScaler, {}), + (cuQuantileTransformer, skQuantileTransformer, {"n_quantiles": 10}), + (cuPowerTransformer, skPowerTransformer, {}), + (cuNormalizer, skNormalizer, {}), + (cuBinarizer, skBinarizer, {}), + ], +) +def test_get_feature_names_out(cu_cls, sk_cls, params): + iris = sklearn.datasets.load_iris() + cu_model = cu_cls(**params).fit(iris.data) + sk_model = sk_cls(**params).fit(iris.data) + + res = cu_model.get_feature_names_out() + sol = sk_model.get_feature_names_out() + np.testing.assert_array_equal(res, sol) + + res = cu_model.get_feature_names_out(iris.feature_names) + sol = sk_model.get_feature_names_out(iris.feature_names) + np.testing.assert_array_equal(res, sol) + + +def test_kernel_centerer_get_feature_names_out(): + from sklearn.metrics.pairwise import linear_kernel + + rng = np.random.RandomState(0) + X = rng.random_sample((6, 4)) + X_pairwise = linear_kernel(X) + + cu_model = cuKernelCenterer().fit(X_pairwise) + sk_model = skKernelCenterer().fit(X_pairwise) + res = cu_model.get_feature_names_out() + sol = sk_model.get_feature_names_out() + np.testing.assert_array_equal(res, sol) + + +@pytest.mark.parametrize( + "encode", + [ + "onehot", + "onehot-dense", + "ordinal", + ], +) +def test_kbins_discretizer_get_feature_names_out(encode): + X = np.array([[-2, 1, -4], [-1, 2, -3], [0, 3, -2], [1, 4, -1]]) + + cu_model = cuKBinsDiscretizer(n_bins=4, encode=encode).fit(X) + sk_model = skKBinsDiscretizer(n_bins=4, encode=encode).fit(X) + res = cu_model.get_feature_names_out() + sol = sk_model.get_feature_names_out() + np.testing.assert_array_equal(res, sol) diff --git a/python/cuml/tests/test_random_projection.py b/python/cuml/tests/test_random_projection.py index 7fe87b4c6f..d29820222f 100644 --- a/python/cuml/tests/test_random_projection.py +++ b/python/cuml/tests/test_random_projection.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2018-2026, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2018-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 import cupy as cp import cupyx.scipy.sparse as cp_sp @@ -6,6 +6,7 @@ import pandas as pd import pytest import scipy.sparse as sp +import sklearn.random_projection from scipy.spatial.distance import pdist from cuml.random_projection import ( @@ -220,3 +221,25 @@ def test_output_type_sparse_inputs(cls): else: assert isinstance(out, cp.ndarray) assert isinstance(model.components_, cp.ndarray) + + +@pytest.mark.parametrize( + "cu_cls, sk_cls", + [ + ( + GaussianRandomProjection, + sklearn.random_projection.GaussianRandomProjection, + ), + ( + SparseRandomProjection, + sklearn.random_projection.SparseRandomProjection, + ), + ], +) +def test_get_feature_names_out(cu_cls, sk_cls): + X = random_array(10, 100) + cu_model = cu_cls(n_components=5, random_state=42).fit(X) + sk_model = sk_cls(n_components=5, random_state=42).fit(X) + res = cu_model.get_feature_names_out() + sol = sk_model.get_feature_names_out() + np.testing.assert_array_equal(res, sol) diff --git a/python/cuml/tests/test_sklearn_compatibility.py b/python/cuml/tests/test_sklearn_compatibility.py index 2ea6829315..2ee9ab1857 100644 --- a/python/cuml/tests/test_sklearn_compatibility.py +++ b/python/cuml/tests/test_sklearn_compatibility.py @@ -298,7 +298,7 @@ def test_sklearn_compatible_estimator(estimator, check): check(estimator) -def test_all_estimators_covered(): +def test_sklearn_compatible_estimator_coverage(): all_estimators = _all_cuml_estimators() tested = {type(est) for est in ESTIMATORS} excluded = set(EXCLUDED) @@ -324,3 +324,81 @@ def test_all_estimators_covered(): c.__name__ for c in sorted(stale, key=lambda c: c.__name__) ) ) + + +GET_FEATURE_NAMES_OUT_ESTIMATORS = [ + PCA(), + IncrementalPCA(), + TruncatedSVD(n_components=2), + KMeans(), + GaussianRandomProjection(n_components=2), + SparseRandomProjection(n_components=2), + UMAP(n_components=2), + Binarizer(), + KernelCenterer(), + MaxAbsScaler(), + MinMaxScaler(), + Normalizer(), + PowerTransformer(), + QuantileTransformer(n_quantiles=10), + RobustScaler(), + StandardScaler(), + OneHotEncoder(), + OrdinalEncoder(), + PolynomialFeatures(), + KBinsDiscretizer(), + ColumnTransformer(transformers=[("trans1", PolynomialFeatures(), [0, 1])]), +] + +GET_FEATURE_NAMES_OUT_XFAILS = {} + + +def gen_get_feature_names_out_tests(): + checks = [ + estimator_checks.check_get_feature_names_out_error, + estimator_checks.check_transformer_get_feature_names_out, + estimator_checks.check_transformer_get_feature_names_out_pandas, + ] + for estimator in GET_FEATURE_NAMES_OUT_ESTIMATORS: + est_name = type(estimator).__name__ + xfails = GET_FEATURE_NAMES_OUT_XFAILS.get(type(estimator), {}) + for check in checks: + if (reason := xfails.get(check.__name__)) is not None: + mark = pytest.mark.xfail(reason=reason, strict=True) + else: + mark = () + + yield pytest.param( + estimator, check, marks=mark, id=f"{est_name}-{check.__name__}" + ) + + +@pytest.mark.parametrize( + "estimator, check", list(gen_get_feature_names_out_tests()) +) +def test_sklearn_get_feature_names_out(estimator, check): + """Apply upstream sklearn `get_feature_names_out` checks to estimators + in cuml that implement that method. + + Only instances in `GET_FEATURE_NAMES_OUT_ESTIMATORS` are checked. If a + class implements `get_feature_names_out` and isn't added to this list it + will be caught by `test_sklearn_get_feature_names_out_coverage`. + + If an estimator fails a specific test, it may be xfailed by adding it + to `GET_FEATURE_NAMES_OUT_XFAILS`. + """ + check(estimator.__class__.__name__, estimator) + + +def test_sklearn_get_feature_names_out_all_estimators_covered(): + supported = { + c + for c in _all_cuml_estimators() + if hasattr(c, "get_feature_names_out") + } + tested = {type(est) for est in GET_FEATURE_NAMES_OUT_ESTIMATORS} + uncovered = supported - tested + assert not uncovered, ( + f"Estimators implementing `get_feature_names_out` that aren't tested or " + f"excluded: {', '.join(sorted(c.__name__ for c in uncovered))}" + ) diff --git a/python/cuml/tests/test_tsvd.py b/python/cuml/tests/test_tsvd.py index d1f456fdef..73edc64b34 100644 --- a/python/cuml/tests/test_tsvd.py +++ b/python/cuml/tests/test_tsvd.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2019-2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 import numpy as np @@ -119,3 +119,12 @@ def test_tsvd_inverse_transform(datatype, name): input_gdf = cutsvd.inverse_transform(Xcutsvd) assert array_equal(input_gdf, X, 0.4, with_sign=True) + + +def test_get_feature_names_out(): + X, _ = make_blobs(n_features=5) + cu_model = cuTSVD(n_components=2).fit(X) + sk_model = skTSVD(n_components=2).fit(X) + res = cu_model.get_feature_names_out() + sol = sk_model.get_feature_names_out() + np.testing.assert_array_equal(res, sol) diff --git a/python/cuml/tests/test_umap.py b/python/cuml/tests/test_umap.py index 7c09b901e1..6c3d115799 100644 --- a/python/cuml/tests/test_umap.py +++ b/python/cuml/tests/test_umap.py @@ -1658,3 +1658,11 @@ def test_inverse_transform_dimension_mismatch(): with pytest.raises(ValueError, match="components"): umap_model.inverse_transform(wrong_embedding) + + +def test_get_feature_names_out(): + X, _ = make_blobs(n_features=5, random_state=42) + cu_model = cuUMAP(n_components=2).fit(X) + res = cu_model.get_feature_names_out() + sol = np.array(["umap0", "umap1"], dtype=object) + np.testing.assert_array_equal(res, sol)