From 36e42a756bdce4192ba2e44dea67dc29f092f309 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Fri, 14 Aug 2026 10:04:00 -0500 Subject: [PATCH 1/3] Add `check_input_features` Adds a new validation check `check_input_features` for validating the `input_features` arg to the common `get_feature_names_out` methods. --- python/cuml/cuml/internals/validation.py | 49 ++++++++++++++++++++++++ python/cuml/tests/test_validation.py | 39 +++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py index 6fcd06670f..086747b665 100644 --- a/python/cuml/cuml/internals/validation.py +++ b/python/cuml/cuml/internals/validation.py @@ -22,6 +22,7 @@ "check_is_fitted", "check_random_seed", "check_features", + "check_input_features", "check_consistent_length", "check_all_finite", "check_non_negative", @@ -333,6 +334,54 @@ def check_features(estimator, X, reset=False) -> None: ) +def check_input_features(estimator, input_features=None): + """Check `input_features` and generate names if needed. + + Mainly useful when implementing `get_feature_names_out`. + + Parameters + ---------- + input_features : array-like of str or None, default=None + Input features. + + - If `input_features` is `None`, then `feature_names_in_` is used as + input feature names. If `feature_names_in_` is not defined, then the + following input feature names are generated: `["x0", "x1", ..., + "x(n_features_in_ - 1)"]`. + - If `input_features` is an array-like, then `input_features` must + match `feature_names_in_` if `feature_names_in_` is defined. + + Returns + ------- + feature_names_in : numpy.ndarray[str] or None + Validated input feature names. + """ + feature_names_in = getattr(estimator, "feature_names_in_", None) + + if input_features is not None: + input_features = np.asarray(input_features, dtype=object) + if feature_names_in is not None and not np.array_equal( + feature_names_in, input_features + ): + raise ValueError( + "input_features is not equal to feature_names_in_" + ) + + elif len(input_features) != estimator.n_features_in_: + raise ValueError( + "input_features should have length equal to number of " + f"features ({estimator.n_features_in_}), got {len(input_features)}" + ) + return input_features + + if feature_names_in is not None: + return feature_names_in + + return np.asarray( + [f"x{i}" for i in range(estimator.n_features_in_)], dtype=object + ) + + def check_consistent_length(*arrays) -> None: """Check whether all inputs have the same number of samples. diff --git a/python/cuml/tests/test_validation.py b/python/cuml/tests/test_validation.py index d089e60edf..d76907b965 100644 --- a/python/cuml/tests/test_validation.py +++ b/python/cuml/tests/test_validation.py @@ -25,6 +25,7 @@ check_consistent_length, check_cudf, check_features, + check_input_features, check_inputs, check_non_negative, check_random_seed, @@ -477,6 +478,44 @@ def test_feature_names_mismatch_errors(): assert "Feature names must be in the same order" in str(rec.value) +def test_check_input_features(): + X = pd.DataFrame({"a": [1], "b": [2], "c": [3]}) + + # Correct names are fine + model_named = MyModel().fit(X) + model_unnamed = MyModel().fit(X.to_numpy()) + + # no-args call returns feature_names_in_ + np.testing.assert_array_equal( + check_input_features(model_named), + model_named.feature_names_in_, + ) + + # if no feature_names_in_, names are generated like x0, x1, ... + np.testing.assert_array_equal( + check_input_features(model_unnamed), + np.array(["x0", "x1", "x2"], dtype="object"), + ) + + # If `input_features` provided, that's returned if valid + np.testing.assert_array_equal( + check_input_features(model_named, ["a", "b", "c"]), + model_named.feature_names_in_, + ) + np.testing.assert_array_equal( + check_input_features(model_unnamed, ["x", "y", "z"]), + np.array(["x", "y", "z"]), + ) + + # Errors if feature_names_in_ defined and doesn't match + with pytest.raises(ValueError, match="input_features is not equal to"): + check_input_features(model_named, ["x", "y", "z"]) + + # If no `feature_names_in_`, errors if input_features isn't the right size + with pytest.raises(ValueError, match=r".*number of features \(3\), got 2"): + check_input_features(model_unnamed, ["x", "y"]) + + def test_check_consistent_length(): y3 = np.empty(3) y4 = np.empty(4) From f0305d218590f6b8eed164ba5289f68d11bbadb3 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Fri, 14 Aug 2026 20:49:37 -0500 Subject: [PATCH 2/3] Deprecate `get_feature_names` in favor of `get_feature_names_out` Deprecates all existing implementations of `get_feature_names` in favor of `get_feature_names_out` implementations. Also aligns the implementation in all cases with the expected behavior of modern sklearn. --- .../preprocessing/_column_transformer.py | 81 +++++++++++++------ .../sklearn/preprocessing/_data.py | 56 ++++++++----- .../feature_extraction/_tfidf_vectorizer.py | 13 +-- .../cuml/feature_extraction/_vectorizers.py | 23 +++--- python/cuml/cuml/internals/mixins.py | 26 +++++- python/cuml/cuml/preprocessing/encoders.py | 46 +++++------ .../cuml/cuml/testing/test_preproc_utils.py | 4 +- python/cuml/tests/test_compose.py | 55 +++++++++---- python/cuml/tests/test_one_hot_encoder.py | 49 ++++++----- python/cuml/tests/test_preprocessing.py | 17 ++-- .../tests/test_text_feature_extraction.py | 67 ++++++++------- 11 files changed, 268 insertions(+), 169 deletions(-) diff --git a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_column_transformer.py b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_column_transformer.py index 85b2460ebe..1e3e3725d5 100644 --- a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_column_transformer.py +++ b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_column_transformer.py @@ -24,6 +24,7 @@ import cudf import cupy as np +import numpy as cpu_np import numba import pandas as pd import scipy.sparse as sp_sparse @@ -35,7 +36,13 @@ import cuml from cuml.internals.global_settings import _global_settings_data -from cuml.internals.validation import check_is_fitted, check_features, check_array +from cuml.internals.mixins import DeprecatedGetFeatureNamesMixin +from cuml.internals.validation import ( + check_is_fitted, + check_features, + check_array, + check_input_features, +) from ..preprocessing._function_transformer import FunctionTransformer from ..utils.skl_dependencies import ( @@ -421,7 +428,12 @@ def _message_with_time(source, message, time): return "%s%s%s" % (start_message, dots_len * '.', end_message) -class ColumnTransformer(TransformerMixin, BaseComposition, BaseEstimator): +class ColumnTransformer( + DeprecatedGetFeatureNamesMixin, + TransformerMixin, + BaseComposition, + BaseEstimator, +): """Applies transformers to columns of an array or dataframe. This estimator allows different columns or column subsets of the input @@ -747,38 +759,55 @@ def named_transformers_(self): return Bunch(**{name: trans for name, trans, _ in self.transformers_}) - def get_feature_names(self): - """Get feature names from all transformers. + 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 : list of strings - Names of the features produced by transform. + feature_names_out : numpy.ndarray of str objects. + Transformed feature names. """ check_is_fitted(self) - feature_names = [] - for name, trans, column, _ in self._iter(fitted=True): - if trans == 'drop' or ( - hasattr(column, '__len__') and not len(column)): + input_features = check_input_features(self, input_features) + out = [] + for trans_name, trans, col, _ in self._iter(fitted=True): + if trans == "drop": continue - if trans == 'passthrough': - if hasattr(self, '_df_columns'): - if ((not isinstance(column, slice)) - and all(isinstance(col, str) for col in column)): - feature_names.extend(column) - else: - feature_names.extend(self._df_columns[column]) + + # Determine column subset to pass to trans + if isinstance(col, slice): + inputs = input_features[col].tolist() + elif isinstance(col, int): + inputs = [input_features[col]] + elif isinstance(col, str): + inputs = [col] + elif isinstance(col, list): + if all(isinstance(c, str) for c in col): + inputs = col else: - indices = np.arange(self._n_features) - feature_names.extend(['x%d' % i for i in indices[column]]) + inputs = [input_features[c] for c in col] + + if not len(inputs): continue - if not hasattr(trans, 'get_feature_names'): - raise AttributeError("Transformer %s (type %s) does not " - "provide get_feature_names." - % (str(name), type(trans).__name__)) - feature_names.extend([name + "__" + f for f in - trans.get_feature_names()]) - return feature_names + + if trans == 'passthrough': + names = inputs + elif not hasattr(trans, 'get_feature_names_out'): + raise AttributeError( + f"Transformer {trans_name!s} (type {type(trans).__name__}) " + "does not provide get_feature_names_out." + ) + else: + names = trans.get_feature_names_out(inputs) + + out.extend([f"{trans_name}__{name}" for name in names]) + + return cpu_np.array(out, dtype=object) def _update_fitted_transformers(self, transformers): # transformers are fitted; excludes 'drop' cases diff --git a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_data.py b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_data.py index e4c39751bc..3502c236fe 100644 --- a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_data.py +++ b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_data.py @@ -36,9 +36,18 @@ from cuml.common.sparse import csr_row_normalize_l1, csr_row_normalize_l2 from cuml.internals.interop import InteropMixin -from cuml.internals.mixins import AllowNaNTagMixin, SparseInputTagMixin +from cuml.internals.mixins import ( + AllowNaNTagMixin, + SparseInputTagMixin, + DeprecatedGetFeatureNamesMixin, +) from cuml.internals.outputs import using_output_type, mlfunc, ReflectedAttr -from cuml.internals.validation import check_is_fitted, check_array, check_inputs +from cuml.internals.validation import ( + check_is_fitted, + check_array, + check_inputs, + check_input_features, +) from cuml.thirdparty_adapters.sparsefuncs_fast import csr_polynomial_expansion from ..utils.extmath import _incremental_mean_and_var, row_norms @@ -1480,10 +1489,13 @@ def robust_scale(X, *, axis=0, with_centering=True, with_scaling=True, return X -class PolynomialFeatures(TransformerMixin, - BaseEstimator, - AllowNaNTagMixin, - SparseInputTagMixin): +class PolynomialFeatures( + DeprecatedGetFeatureNamesMixin, + TransformerMixin, + BaseEstimator, + AllowNaNTagMixin, + SparseInputTagMixin, +): """Generate polynomial and interaction features. Generate a new feature matrix consisting of all polynomial combinations @@ -1584,35 +1596,35 @@ def powers_(self): minlength=self.n_input_features_) for c in combinations]) - def get_feature_names(self, input_features=None): - """ - Return feature names for output features + def get_feature_names_out(self, input_features=None): + """Get output feature names for transformation. Parameters ---------- - input_features : list of string, length n_features, optional - String names for input features if available. By default, - "x0", "x1", ... "xn_features" is used. + input_features : array-like of str or None, default=None + Input feature names. Returns ------- - output_feature_names : list of string, length n_output_features - + feature_names_out : numpy.ndarray of str objects. + Transformed feature names. """ - powers = self.powers_ - if input_features is None: - input_features = ['x%d' % i for i in range(powers.shape[1])] + check_is_fitted(self) + input_features = check_input_features(self, input_features) feature_names = [] - for row in powers: + for row in self.powers_: inds = cpu_np.where(row)[0] if len(inds): - name = " ".join("%s^%d" % (input_features[ind], exp) - if exp != 1 else input_features[ind] - for ind, exp in zip(inds, row[inds])) + name = " ".join( + f"{input_features[ind]}^{exp}" + if exp != 1 + else input_features[ind] + for ind, exp in zip(inds, row[inds]) + ) else: name = "1" feature_names.append(name) - return feature_names + return cpu_np.asarray(feature_names, dtype=object) @mlfunc(set_input_type=True) def fit(self, X, y=None) -> "PolynomialFeatures": diff --git a/python/cuml/cuml/feature_extraction/_tfidf_vectorizer.py b/python/cuml/cuml/feature_extraction/_tfidf_vectorizer.py index 55d5d35342..9452ad07dc 100644 --- a/python/cuml/cuml/feature_extraction/_tfidf_vectorizer.py +++ b/python/cuml/cuml/feature_extraction/_tfidf_vectorizer.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2020-2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Portions of this code are derived from the scikit-learn feature_extraction @@ -282,14 +282,3 @@ def transform(self, raw_documents): """ X = super().transform(raw_documents) return self._tfidf.transform(X, copy=False) - - def get_feature_names(self): - """ - Array mapping from feature integer indices to feature name. - - Returns - ------- - feature_names : Series - A list of feature names. - """ - return super().get_feature_names() diff --git a/python/cuml/cuml/feature_extraction/_vectorizers.py b/python/cuml/cuml/feature_extraction/_vectorizers.py index 716b9fe631..83899da32f 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-2026, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # import numbers @@ -15,6 +15,7 @@ import cuml.internals.logger as logger from cuml.common.sparse import csr_row_normalize_l1, csr_row_normalize_l2 from cuml.feature_extraction._stop_words import ENGLISH_STOP_WORDS +from cuml.internals.mixins import DeprecatedGetFeatureNamesMixin CUPY_SPARSE_DTYPES = [cp.float32, cp.float64, cp.complex64, cp.complex128] @@ -384,7 +385,7 @@ def _term_frequency(X): return term_freq["count"].values -class CountVectorizer(_VectorizerMixin): +class CountVectorizer(DeprecatedGetFeatureNamesMixin, _VectorizerMixin): """ Convert a collection of text documents to a matrix of token counts @@ -752,18 +753,20 @@ def inverse_transform(self, X): vocab = Series(self.vocabulary_) return [vocab[X[i, :].indices] for i in range(X.shape[0])] - def get_feature_names(self): - """ - Array mapping from feature integer indices to feature name. + 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 + Not used, present here for API consistency by convention. Returns ------- - - feature_names : Series - A list of feature names. - + feature_names_out : numpy.ndarray of str objects. + Transformed feature names. """ - return self.vocabulary_ + return self.vocabulary_.to_numpy(dtype=object) class HashingVectorizer(_VectorizerMixin): diff --git a/python/cuml/cuml/internals/mixins.py b/python/cuml/cuml/internals/mixins.py index 13d3716b69..14a205266d 100644 --- a/python/cuml/cuml/internals/mixins.py +++ b/python/cuml/cuml/internals/mixins.py @@ -1,8 +1,8 @@ # -# SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # - +import warnings from dataclasses import dataclass, field from sklearn.utils import ( @@ -17,6 +17,28 @@ from cuml.common.doc_utils import generate_docstring from cuml.internals.outputs import ClassLabels, mlfunc + +class DeprecatedGetFeatureNamesMixin: + """Defines the deprecated `get_feature_names` method.""" + + def get_feature_names(self, input_features=None): + """Return feature names for output features. + + .. deprecated:: 26.10 + + This method was deprecated in version 26.10 and will be removed in + version 26.12. Please use `get_feature_names_out` instead. + """ + warnings.warn( + "`get_feature_names` was deprecated in version 26.10 and will be " + "removed in version 26.12. Please use `get_feature_names_out` instead", + FutureWarning, + ) + return self.get_feature_names_out( + input_features=input_features + ).tolist() + + ############################################################################### # Tag Functionality Mixin # ############################################################################### diff --git a/python/cuml/cuml/preprocessing/encoders.py b/python/cuml/cuml/preprocessing/encoders.py index ceffc4a401..4cff2d08ad 100644 --- a/python/cuml/cuml/preprocessing/encoders.py +++ b/python/cuml/cuml/preprocessing/encoders.py @@ -13,8 +13,13 @@ import cuml from cuml.common.doc_utils import generate_docstring from cuml.internals.base import Base +from cuml.internals.mixins import DeprecatedGetFeatureNamesMixin from cuml.internals.outputs import mlfunc -from cuml.internals.validation import check_features, check_is_fitted +from cuml.internals.validation import ( + check_features, + check_input_features, + check_is_fitted, +) from cuml.preprocessing._label import LabelEncoder @@ -112,7 +117,7 @@ def categories_(self): return [self._encoders[f].classes_ for f in self._features] -class OneHotEncoder(BaseEncoder): +class OneHotEncoder(DeprecatedGetFeatureNamesMixin, BaseEncoder): """ Encode categorical features as a one-hot numeric array. The input to this estimator should be a :py:class:`cuDF.DataFrame` or a @@ -479,41 +484,32 @@ def inverse_transform(self, X): ) return result - def get_feature_names(self, input_features=None): - """Return feature names for output features. + def get_feature_names_out(self, input_features=None): + """Get output feature names for transformation. Parameters ---------- - input_features : list of str of shape (n_features,) - String names for input features if available. By default, - "x0", "x1", ... "xn_features" is used. + input_features : array-like of str or None, default=None + Input feature names. Returns ------- - output_feature_names : ndarray of shape (n_output_features,) - Array of feature names. + feature_names_out : numpy.ndarray of str objects. + Transformed feature names. """ check_is_fitted(self) cats = self.categories_ - if input_features is None: - input_features = ["x%d" % i for i in range(len(cats))] - elif len(input_features) != len(self.categories_): - raise ValueError( - "input_features should have length equal to number of " - "features ({}), got {}".format( - len(self.categories_), len(input_features) - ) - ) + input_features = check_input_features(self, input_features) - feature_names = [] - for i in range(len(cats)): - names = [input_features[i] + "_" + str(t) for t in cats[i]] - if self.drop_idx_ is not None and self.drop_idx_[i] is not None: - names.pop(self.drop_idx_[i]) - feature_names.extend(names) + out = [] + for i, (col, cats) in enumerate(zip(input_features, self.categories_)): + drop_idx = None if self.drop_idx_ is None else self.drop_idx_[i] + if drop_idx is not None: + cats = np.delete(cats, drop_idx) + out.extend(f"{col}_{val!s}" for val in cats) - return np.array(feature_names, dtype=object) + return np.array(out, dtype=object) @classmethod def _get_param_names(cls): diff --git a/python/cuml/cuml/testing/test_preproc_utils.py b/python/cuml/cuml/testing/test_preproc_utils.py index 64f2d17dfc..49a19fac4c 100644 --- a/python/cuml/cuml/testing/test_preproc_utils.py +++ b/python/cuml/cuml/testing/test_preproc_utils.py @@ -58,7 +58,9 @@ def convert(dataset, output_type): if output_type == "cudf": renaming = {i: f"c{i}" for i in range(dataset.shape[1])} converted_dataset = converted_dataset.rename(columns=renaming) - dataset = cp.asnumpy(dataset) + dataset = converted_dataset.to_pandas() + else: + dataset = cp.asnumpy(dataset) return dataset, converted_dataset diff --git a/python/cuml/tests/test_compose.py b/python/cuml/tests/test_compose.py index a008e7a3c7..1414b10bd1 100644 --- a/python/cuml/tests/test_compose.py +++ b/python/cuml/tests/test_compose.py @@ -206,25 +206,50 @@ def test_make_column_transformer_sparse( assert_allclose(t_X, sk_t_X) -@pytest.mark.skip( - reason="scikit-learn replaced get_feature_names with " - "get_feature_names_out" - "https://github.com/rapidsai/cuml/issues/5159" -) -def test_column_transformer_get_feature_names(clf_dataset): # noqa: F811 +@pytest.mark.parametrize("remainder", ["drop", "passthrough"]) +def test_column_transformer_get_feature_names_out(clf_dataset, remainder): X_np, X = clf_dataset - cu_transformers = [("PolynomialFeatures", cuPolynomialFeatures(), [0, 2])] - transformer = cuColumnTransformer(cu_transformers) - transformer.fit_transform(X) - cu_feature_names = transformer.get_feature_names() + cu_transformer = cuColumnTransformer( + [ + ("t1", cuPolynomialFeatures(), slice(0, 2)), + ("t2", cuPolynomialFeatures(), [0, 2]), + ("t3", cuPolynomialFeatures(), lambda X: [1]), + ], + remainder=remainder, + ).fit(X) + cu_transformer.fit_transform(X) - sk_transformers = [("PolynomialFeatures", skPolynomialFeatures(), [0, 2])] - transformer = skColumnTransformer(sk_transformers) - transformer.fit_transform(X_np) - sk_feature_names = transformer.get_feature_names() + sk_transformer = skColumnTransformer( + [ + ("t1", skPolynomialFeatures(), slice(0, 2)), + ("t2", skPolynomialFeatures(), [0, 2]), + ("t3", skPolynomialFeatures(), lambda X: [1]), + ], + remainder=remainder, + ).fit(X_np) + + res = cu_transformer.get_feature_names_out() + sol = sk_transformer.get_feature_names_out() + np.testing.assert_array_equal(res, sol) + + # If the input data lacks feature names, also check the generated output + # names if feature names are provided explicitly. + if not hasattr(cu_transformer, "feature_names_in_"): + input_features = [f"c{i}" for i in range(X.shape[1])] + res = cu_transformer.get_feature_names_out(input_features) + sol = sk_transformer.get_feature_names_out(input_features) + np.testing.assert_array_equal(res, sol) + + +def test_column_transformer_get_feature_names_deprecated(): + X = np.array([[1.5, 2.5, 3.5], [1.6, 2.4, 3.7]]) + model = cuColumnTransformer([("t1", cuPolynomialFeatures(), [0, 2])]) + model.fit(X) + with pytest.warns(FutureWarning, match="get_feature_names"): + res = model.get_feature_names() - assert cu_feature_names == sk_feature_names + np.testing.assert_array_equal(res, model.get_feature_names_out()) def test_column_transformer_named_transformers_(clf_dataset): # noqa: F811 diff --git a/python/cuml/tests/test_one_hot_encoder.py b/python/cuml/tests/test_one_hot_encoder.py index 89db7cf701..92c04d8517 100644 --- a/python/cuml/tests/test_one_hot_encoder.py +++ b/python/cuml/tests/test_one_hot_encoder.py @@ -1,4 +1,4 @@ -# 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 math @@ -369,26 +369,31 @@ def test_onehot_category_class_count(total_classes: int): ) -@pytest.mark.parametrize("as_array", [True, False], ids=["cupy", "cudf"]) -def test_onehot_get_feature_names(as_array): - fruits = ["apple", "banana", "strawberry"] - if as_array: - fruits = [ord(fruit[0]) for fruit in fruits] - sizes = [0, 1, 2] - X = DataFrame({"fruits": fruits, "sizes": sizes}) - if as_array: - X = _from_df_to_cupy(X) +@pytest.mark.parametrize("named", [True, False]) +def test_onehot_get_feature_names_out(named): + columns = [["apple", "banana", "strawberry"], [0, 1, 2]] + names = ["fruits", "sizes"] if named else [0, 1] + X = pd.DataFrame(dict(zip(names, columns))) - enc = OneHotEncoder().fit(X) + cu_model = OneHotEncoder().fit(X) + sk_model = SkOneHotEncoder().fit(X) + + res = cu_model.get_feature_names_out() + sol = sk_model.get_feature_names_out() + assert np.array_equal(res, sol) + + if not named: + res = cu_model.get_feature_names_out(["fruit", "size"]) + sol = sk_model.get_feature_names_out(["fruit", "size"]) + assert np.array_equal(res, sol) + + +def test_onehot_get_feature_names_deprecated(): + X = pd.DataFrame( + {"fruits": ["apple", "banana", "strawberry"], "sizes": [0, 1, 2]} + ) + model = OneHotEncoder().fit(X) + with pytest.warns(FutureWarning, match="get_feature_names"): + res = model.get_feature_names() - feature_names_ref = ["x0_" + str(fruit) for fruit in fruits] + [ - "x1_" + str(size) for size in sizes - ] - feature_names = enc.get_feature_names() - assert np.array_equal(feature_names, feature_names_ref) - - feature_names_ref = ["fruit_" + str(fruit) for fruit in fruits] + [ - "size_" + str(size) for size in sizes - ] - feature_names = enc.get_feature_names(["fruit", "size"]) - assert np.array_equal(feature_names, feature_names_ref) + np.testing.assert_array_equal(res, model.get_feature_names_out()) diff --git a/python/cuml/tests/test_preprocessing.py b/python/cuml/tests/test_preprocessing.py index 9fb4d39a9f..12e7fd6099 100644 --- a/python/cuml/tests/test_preprocessing.py +++ b/python/cuml/tests/test_preprocessing.py @@ -468,7 +468,7 @@ def test_poly_features( ) t_X = polyfeatures.fit_transform(X) assert type(X) is type(t_X) - cu_feature_names = polyfeatures.get_feature_names() + cu_feature_names = polyfeatures.get_feature_names_out() if isinstance(t_X, np.ndarray): if order == "C": @@ -483,12 +483,19 @@ def test_poly_features( include_bias=include_bias, ) sk_t_X = polyfeatures.fit_transform(X_np) - if sklearn.__version__ <= "1.0": - sk_feature_names = polyfeatures.get_feature_names() + sk_feature_names = polyfeatures.get_feature_names_out() assert_allclose(t_X, sk_t_X, rtol=0.1, atol=0.1) - if sklearn.__version__ <= "1.0": - assert sk_feature_names == cu_feature_names + np.testing.assert_array_equal(cu_feature_names, sk_feature_names) + + +def test_poly_features_get_feature_names_deprecated(): + X = np.array([[1.5, 2.5, 3.5], [1.6, 2.4, 3.7]]) + model = cuPolynomialFeatures().fit(X) + with pytest.warns(FutureWarning, match="get_feature_names"): + res = model.get_feature_names() + + np.testing.assert_array_equal(res, model.get_feature_names_out()) @pytest.mark.parametrize("degree", [2, 3]) diff --git a/python/cuml/tests/test_text_feature_extraction.py b/python/cuml/tests/test_text_feature_extraction.py index 8f6400bbd8..54a864fd43 100644 --- a/python/cuml/tests/test_text_feature_extraction.py +++ b/python/cuml/tests/test_text_feature_extraction.py @@ -1,5 +1,5 @@ # -# 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 # @@ -59,17 +59,13 @@ def test_count_vectorizer(): NGRAM_IDS = [f"ngram_range={str(r)}" for r in NGRAM_RANGES] -@pytest.mark.skip( - reason="scikit-learn replaced get_feature_names with " - "get_feature_names_out" - "https://github.com/rapidsai/cuml/issues/5159" -) @pytest.mark.parametrize("ngram_range", NGRAM_RANGES, ids=NGRAM_IDS) def test_word_analyzer(ngram_range): v = CountVectorizer(ngram_range=ngram_range).fit(DOCS_GPU) ref = SkCountVect(ngram_range=ngram_range).fit(DOCS) - assert ( - ref.get_feature_names() == v.get_feature_names().to_arrow().to_pylist() + assert_array_equal( + ref.get_feature_names_out(), + v.get_feature_names_out(), ) @@ -102,7 +98,7 @@ def test_countvectorizer_stop_words_ngrams(): v = CountVectorizer(ngram_range=(2, 2), stop_words="english") v.fit(stop_words_doc) - assert expected_vocabulary == v.get_feature_names().to_arrow().to_pylist() + assert_array_equal(v.get_feature_names_out(), expected_vocabulary) def test_countvectorizer_max_features(): @@ -120,10 +116,7 @@ def test_countvectorizer_max_features(): # test bounded number of extracted features vec = CountVectorizer(max_df=0.6, max_features=4) vec.fit(DOCS_GPU) - assert ( - set(vec.get_feature_names().to_arrow().to_pylist()) - == expected_vocabulary - ) + assert set(vec.get_feature_names_out()) == expected_vocabulary assert set(vec.stop_words_.to_arrow().to_pylist()) == expected_stop_words @@ -138,9 +131,9 @@ def test_countvectorizer_max_features_counts(): counts_3 = cv_3.fit_transform(JUNK_FOOD_DOCS_GPU).sum(axis=0) counts_None = cv_None.fit_transform(JUNK_FOOD_DOCS_GPU).sum(axis=0) - features_1 = cv_1.get_feature_names() - features_3 = cv_3.get_feature_names() - features_None = cv_None.get_feature_names() + features_1 = cv_1.get_feature_names_out() + features_3 = cv_3.get_feature_names_out() + features_None = cv_None.get_feature_names_out() # The most common feature is "the", with frequency 7. assert 7 == counts_1.max() @@ -208,10 +201,7 @@ def test_count_binary_occurrences(): test_data = Series(["aaabc", "abbde"]) vect = CountVectorizer(analyzer="char", max_df=1.0) X = cp.asnumpy(vect.fit_transform(test_data).todense()) - assert_array_equal( - ["a", "b", "c", "d", "e"], - vect.get_feature_names().to_arrow().to_pylist(), - ) + assert_array_equal(["a", "b", "c", "d", "e"], vect.get_feature_names_out()) assert_array_equal([[3, 1, 1, 0, 0], [1, 2, 0, 1, 1]], X) # using boolean features, we can fetch the binary occurrence info @@ -256,9 +246,10 @@ def test_space_ngrams(ngram_range): data_gpu = Series(data) vec = CountVectorizer(ngram_range=ngram_range).fit(data_gpu) ref = SkCountVect(ngram_range=ngram_range).fit(data) - assert ( - ref.get_feature_names() - ) == vec.get_feature_names().to_arrow().to_pylist() + assert_array_equal( + ref.get_feature_names_out(), + vec.get_feature_names_out(), + ) def test_empty_doc_after_limit_features(): @@ -283,7 +274,7 @@ def test_non_ascii(): res = cv.fit_transform(non_ascii_gpu) ref = SkCountVect().fit_transform(non_ascii) - assert "αγγλικά" in set(cv.get_feature_names().to_arrow().to_pylist()) + assert "αγγλικά" in set(cv.get_feature_names_out()) cp.testing.assert_array_equal(res.todense(), ref.toarray()) @@ -321,9 +312,10 @@ def test_character_ngrams(analyzer, ngram_range): ref = SkCountVect(analyzer=analyzer, ngram_range=ngram_range).fit(data) - assert ( - ref.get_feature_names() - ) == res.get_feature_names().to_arrow().to_pylist() + assert_array_equal( + res.get_feature_names_out(), + ref.get_feature_names_out(), + ) @pytest.mark.parametrize( @@ -400,7 +392,7 @@ def test_tfidf_vectorizer(norm, use_idf, smooth_idf, sublinear_tf): cp.testing.assert_array_almost_equal(tfidf_mat.todense(), ref.toarray()) -def test_tfidf_vectorizer_get_feature_names(): +def test_tfidf_vectorizer_get_feature_names_out(): corpus = [ "This is the first document.", "This document is the second document.", @@ -420,7 +412,24 @@ def test_tfidf_vectorizer_get_feature_names(): "third", "this", ] - assert vectorizer.get_feature_names().to_arrow().to_pylist() == output + assert_array_equal(vectorizer.get_feature_names_out(), output) + + +@pytest.mark.parametrize("cls", [TfidfVectorizer, CountVectorizer]) +def test_vectorizer_get_feature_names_deprecated(cls): + X = Series( + [ + "This is the first document.", + "This document is the second document.", + "And this is the third one.", + "Is this the first document?", + ] + ) + model = cls().fit(X) + with pytest.warns(FutureWarning, match="get_feature_names"): + res = model.get_feature_names() + + np.testing.assert_array_equal(res, model.get_feature_names_out()) # ---------------------------------------------------------------- From 1df684809078d8cd92fb75014ff9e01b149aca80 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Fri, 14 Aug 2026 21:32:14 -0500 Subject: [PATCH 3/3] Respond to feedback --- .../sklearn/preprocessing/_column_transformer.py | 7 +++++-- python/cuml/cuml/feature_extraction/_vectorizers.py | 1 + python/cuml/cuml/preprocessing/encoders.py | 9 ++++++++- python/cuml/tests/test_compose.py | 6 ++++++ python/cuml/tests/test_one_hot_encoder.py | 7 ++++--- 5 files changed, 24 insertions(+), 6 deletions(-) diff --git a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_column_transformer.py b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_column_transformer.py index 1e3e3725d5..f4cb94bdf1 100644 --- a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_column_transformer.py +++ b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_column_transformer.py @@ -786,9 +786,12 @@ def get_feature_names_out(self, input_features=None): inputs = [input_features[col]] elif isinstance(col, str): inputs = [col] - elif isinstance(col, list): + else: + col = cpu_np.asarray(col, dtype=object) if all(isinstance(c, str) for c in col): - inputs = col + inputs = col.tolist() + elif all(isinstance(c, bool) for c in col): + inputs = input_features[col.astype(bool)].tolist() else: inputs = [input_features[c] for c in col] diff --git a/python/cuml/cuml/feature_extraction/_vectorizers.py b/python/cuml/cuml/feature_extraction/_vectorizers.py index 83899da32f..8f1376a02b 100644 --- a/python/cuml/cuml/feature_extraction/_vectorizers.py +++ b/python/cuml/cuml/feature_extraction/_vectorizers.py @@ -766,6 +766,7 @@ def get_feature_names_out(self, input_features=None): feature_names_out : numpy.ndarray of str objects. Transformed feature names. """ + # TODO: use `check_is_fitted` once this class subclasses from `Base` return self.vocabulary_.to_numpy(dtype=object) diff --git a/python/cuml/cuml/preprocessing/encoders.py b/python/cuml/cuml/preprocessing/encoders.py index 4cff2d08ad..ba874365f4 100644 --- a/python/cuml/cuml/preprocessing/encoders.py +++ b/python/cuml/cuml/preprocessing/encoders.py @@ -504,7 +504,14 @@ def get_feature_names_out(self, input_features=None): out = [] for i, (col, cats) in enumerate(zip(input_features, self.categories_)): - drop_idx = None if self.drop_idx_ is None else self.drop_idx_[i] + # TODO: when `drop_idx_` is actually implemented properly, this can + # be simplified to + # drop_idx = None if self.drop_idx_ is None else self.drop_idx_[i] + drop_idx = cp.asnumpy( + None + if self.drop_idx_ is None + else self.drop_idx_[self._features[i]] + ).item() if drop_idx is not None: cats = np.delete(cats, drop_idx) out.extend(f"{col}_{val!s}" for val in cats) diff --git a/python/cuml/tests/test_compose.py b/python/cuml/tests/test_compose.py index 1414b10bd1..665ad12b8a 100644 --- a/python/cuml/tests/test_compose.py +++ b/python/cuml/tests/test_compose.py @@ -210,11 +210,16 @@ def test_make_column_transformer_sparse( def test_column_transformer_get_feature_names_out(clf_dataset, remainder): X_np, X = clf_dataset + bool_mask = [False] * X_np.shape[1] + bool_mask[0] = True + bool_mask[-1] = True + cu_transformer = cuColumnTransformer( [ ("t1", cuPolynomialFeatures(), slice(0, 2)), ("t2", cuPolynomialFeatures(), [0, 2]), ("t3", cuPolynomialFeatures(), lambda X: [1]), + ("t4", cuPolynomialFeatures(), bool_mask), ], remainder=remainder, ).fit(X) @@ -225,6 +230,7 @@ def test_column_transformer_get_feature_names_out(clf_dataset, remainder): ("t1", skPolynomialFeatures(), slice(0, 2)), ("t2", skPolynomialFeatures(), [0, 2]), ("t3", skPolynomialFeatures(), lambda X: [1]), + ("t4", skPolynomialFeatures(), bool_mask), ], remainder=remainder, ).fit(X_np) diff --git a/python/cuml/tests/test_one_hot_encoder.py b/python/cuml/tests/test_one_hot_encoder.py index 92c04d8517..d826a93e4a 100644 --- a/python/cuml/tests/test_one_hot_encoder.py +++ b/python/cuml/tests/test_one_hot_encoder.py @@ -370,13 +370,14 @@ def test_onehot_category_class_count(total_classes: int): @pytest.mark.parametrize("named", [True, False]) -def test_onehot_get_feature_names_out(named): +@pytest.mark.parametrize("drop", [None, "first"]) +def test_onehot_get_feature_names_out(named, drop): columns = [["apple", "banana", "strawberry"], [0, 1, 2]] names = ["fruits", "sizes"] if named else [0, 1] X = pd.DataFrame(dict(zip(names, columns))) - cu_model = OneHotEncoder().fit(X) - sk_model = SkOneHotEncoder().fit(X) + cu_model = OneHotEncoder(drop=drop).fit(X) + sk_model = SkOneHotEncoder(drop=drop).fit(X) res = cu_model.get_feature_names_out() sol = sk_model.get_feature_names_out()