diff --git a/docs/source/cuml-accel/compatibility.rst b/docs/source/cuml-accel/compatibility.rst index e543a9bde9..65c8e40fae 100644 --- a/docs/source/cuml-accel/compatibility.rst +++ b/docs/source/cuml-accel/compatibility.rst @@ -492,6 +492,27 @@ sklearn.preprocessing ``LabelBinarizer`` has no known estimator-specific ``cuml.accel`` limitations. +.. dropdown:: ``OneHotEncoder`` + :name: onehotencoder + + ``OneHotEncoder`` will fall back to CPU in the following cases: + + - If ``dtype`` is not a float or bool dtype. + - If ``drop`` is ``"if_binary"`` + - If ``handle_unknown`` is ``"warn"`` or ``"infrequent_if_exist"``. + - If ``min_frequency`` is not ``None``. + - If ``max_categories`` is not ``None``. + - If ``feature_name_combiner`` is a callable. + + Additional notes: + + - cuML's encoder treats ``None`` and ``NaN`` values as identical, while + scikit-learn's encoder treats these as different categories. + + - cuML's encoder doesn't support numpy's bytes dtype (e.g. ``"S10"``) as + inputs and will error if encountered. + + .. dropdown:: ``TargetEncoder`` :name: targetencoder diff --git a/python/cuml/cuml/accel/_overrides/sklearn/preprocessing.py b/python/cuml/cuml/accel/_overrides/sklearn/preprocessing.py index cd659776d5..ab1cb67118 100644 --- a/python/cuml/cuml/accel/_overrides/sklearn/preprocessing.py +++ b/python/cuml/cuml/accel/_overrides/sklearn/preprocessing.py @@ -1,5 +1,5 @@ # -# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # import numpy as np @@ -17,6 +17,7 @@ "MinMaxScaler", "MaxAbsScaler", "PolynomialFeatures", + "OneHotEncoder", "TargetEncoder", "LabelEncoder", "LabelBinarizer", @@ -55,6 +56,13 @@ def _params_from_cpu(model): return model.get_params(deep=False) +class OneHotEncoder(ProxyBase): + _gpu_class = cuml.preprocessing.OneHotEncoder + + def _gpu_fit_transform(self, X, y=None, **fit_params): + return self._gpu.fit_transform(X, y=y, **fit_params) + + class LabelEncoder(ProxyBase): _gpu_class = cuml.preprocessing.LabelEncoder diff --git a/python/cuml/cuml/internals/validation.py b/python/cuml/cuml/internals/validation.py index 1c50a3a3d1..00a8d0f22f 100644 --- a/python/cuml/cuml/internals/validation.py +++ b/python/cuml/cuml/internals/validation.py @@ -948,51 +948,68 @@ def check_cudf( if ensure_min_features > 1 and ensure_ndim != 2: raise ValueError(f"{ensure_min_features=!r} requires ensure_ndim=2") + if cp_sp.issparse(array) or sp.issparse(array): + padded_input = f" for {input_name}" if input_name else "" + raise TypeError( + f"Sparse data was passed{padded_input}, but dense data is required. " + "Use '.toarray()' to convert to a dense array." + ) + array_type = type(array) # Coerce input to a cudf type. # XXX: cudf currently doesn't support float16, any float16 input is # automatically upcast here to float32. + # XXX: hardcode `nan_as_null=True` (cudf's default) so the behavior + # doesn't switch when cudf.pandas is active. if isinstance(array, pd.Series): if array.dtype == "float16": array = array.astype("float32") - array = cudf.Series(array) + array = cudf.Series(array, nan_as_null=True) elif isinstance(array, pd.DataFrame): f16_cols = array.select_dtypes("float16").columns.tolist() if f16_cols: array = array.astype({c: "float32" for c in f16_cols}) - array = cudf.DataFrame(array) - elif not isinstance(array, (cudf.DataFrame, cudf.Series)): - # Remaining array-like inputs go through check_array first (without - # device transfer) to normalize on cupy/numpy before coercion to cudf - array = check_array( - array, - mem_type=None, - ensure_2d=False, - ensure_min_samples=0, - ensure_min_features=0, - ensure_all_finite=False, - input_name=input_name, - ) + array = cudf.DataFrame(array, nan_as_null=True) + + if not isinstance(array, (cudf.DataFrame, cudf.Series)): + # Normalize to numpy or cupy array with minimal copying + if hasattr(array, "__cuda_array_interface__"): + array = cp.asarray(array) + elif hasattr(array, "__array__") or hasattr( + array, "__array_interface__" + ): + array = np.asarray(array) + elif not isinstance(array, np.ndarray): + array = np.asarray(array, dtype=object) + + if array.dtype.kind == "c": + raise ValueError("Complex data not supported") + if array.dtype == "float16": array = array.astype("float32") - elif ( - array.dtype == "object" - and array.size - and not isinstance(array.flat[0], str) - ): - # XXX: cudf doesn't support coercing numeric object arrays, while - # sklearn has a common check that object arrays of floats are - # supported. To support this uncommon case, we attempt to coerce - # numeric object types here. - array = array.astype("float64") - array = (cudf.DataFrame if array.ndim == 2 else cudf.Series)( - array, dtype=(np.dtype("O") if array.dtype.kind in "U" else None) - ) + + array_shape = array.shape + cls = cudf.DataFrame if array.ndim == 2 else cudf.Series + if array.dtype == "object": + # For object dtype inputs, coerce back to list (cheap) to rely on + # cudf's per-column dtype inference. On failure raise an error + # compatible with what sklearn's `check_dtype_object` expects. + try: + array = cls(array.tolist(), nan_as_null=True) + except Exception as exc: + raise TypeError( + f"An object dtype {input_name or 'input'} argument must be " + "composed of strings, numbers, booleans, or nulls." + ) from exc + else: + array = cls(array, nan_as_null=True) + else: + array_shape = array.shape # Validate shape and coerce dimensionality _check_shape( - array.shape, + array_shape, ensure_2d=(ensure_ndim == 2 and coerce_ndim is False), ensure_min_samples=ensure_min_samples, ensure_min_features=ensure_min_features, diff --git a/python/cuml/cuml/preprocessing/encoders.py b/python/cuml/cuml/preprocessing/encoders.py index 5ca5a63c75..e3f35a0384 100644 --- a/python/cuml/cuml/preprocessing/encoders.py +++ b/python/cuml/cuml/preprocessing/encoders.py @@ -1,5 +1,6 @@ # SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +import warnings from collections.abc import Sequence import cudf @@ -10,6 +11,7 @@ from cuml.common.doc_utils import generate_docstring from cuml.internals.base import Base +from cuml.internals.interop import InteropMixin, UnsupportedOnGPU from cuml.internals.mixins import DeprecatedGetFeatureNamesMixin from cuml.internals.outputs import mlfunc from cuml.internals.validation import ( @@ -47,7 +49,16 @@ def _cats_to_series(cats): if cats.dtype.kind == "O" and _safe_is_nan(cats[-1]): cats = cats.copy() cats[-1] = None - return cudf.Series(cats) + return cudf.Series(cats, nan_as_null=True) + + +def _as_numpy(x, dtype=None): + """Coerce an array-like `x` to a numpy array.""" + if hasattr(x, "to_numpy"): + return x.to_numpy(dtype=dtype) + if hasattr(x, "__cuda_array_interface__"): + x = cp.asnumpy(x) + return np.asarray(x, dtype=dtype) def _compute_categories( @@ -105,37 +116,35 @@ def _compute_categories( if auto: if not unique: Xi = Xi.drop_duplicates() + # For the edge case of floating inputs, we want to ensure NaN and null + # are treated equivalently (cudf's default). Coerce NaN to null + # and redrop duplicates in case the input had both NaN and null. + # This is cheaper than doing `nans_to_nulls` on the full input first. + if Xi.dtype.kind == "f": + Xi = Xi.nans_to_nulls().drop_duplicates() + # cudf's object dtype uses None for NA, we want NaN everywhere + if Xi.dtype == "object": + Xi = Xi.astype(str) cats = Xi.sort_values().to_numpy() else: - dtype = Xi.dtype if isinstance(Xi.dtype, np.dtype) else "O" - cats = categories[i] - cats = ( - cats.to_numpy(dtype=dtype) - if hasattr(cats, "to_numpy") - else cats.get().astype(dtype, copy=False) - if isinstance(cats, cp.ndarray) - else np.asarray(cats, dtype=dtype) + cats_cudf = cudf.Series( + categories[i], + dtype=(Xi.dtype if isinstance(Xi.dtype, np.dtype) else str), + nan_as_null=True, ) + if cats_cudf.dtype == "object": + cats_cudf = cats_cudf.astype(str) + cats = cats_cudf.to_numpy() - # `nan` may only exist in floating or object dtypes, and must be - # the last stated category - if (cats.dtype.kind == "f" and np.isnan(cats[:-1]).any()) or ( - cats.dtype.kind == "O" - and any(_safe_is_nan(c) for c in cats[:-1]) - ): + # Any null values must be the last stated category + if cats_cudf[:-1].isnull().any(): raise ValueError( "Nan should be the last element in user" f" provided categories, see categories {cats}" f" in column #{i}" ) - # Try using numpy.unique to check for uniqueness, falling back - # to pure python if that fails - try: - n_cats = len(np.unique(cats)) - except (TypeError, ValueError): - n_cats = len(set(cats)) - if cats.size != n_cats: + if not cats_cudf.is_unique: raise ValueError( f"In column {i}, the predefined categories" " contain duplicate elements." @@ -144,9 +153,18 @@ def _compute_categories( if handle_unknown == "error": if not unique: Xi = Xi.drop_duplicates() - present = Xi.sort_values().to_numpy() - diff = _get_diff(present, cats) - if diff: + if Xi.dtype.kind == "f": + Xi = Xi.nans_to_nulls() + diff = Xi[~Xi.isin(cats_cudf)] + if len(diff): + # XXX: need to repeat drop_duplicates just in case Xi had both + # None & NaN earlier. + diff = ( + diff.drop_duplicates() + .sort_values() + .to_numpy() + .tolist() + ) raise ValueError( f"Found unknown categories {diff} in column {i} during fit" ) @@ -155,7 +173,7 @@ def _compute_categories( return out -class OneHotEncoder(DeprecatedGetFeatureNamesMixin, Base): +class OneHotEncoder(DeprecatedGetFeatureNamesMixin, InteropMixin, Base): """ Encode categorical features as a one-hot numeric array. @@ -259,6 +277,8 @@ class OneHotEncoder(DeprecatedGetFeatureNamesMixin, Base): ['apple', 2]], dtype=object) """ + _cpu_class_path = "sklearn.preprocessing.OneHotEncoder" + def __init__( self, *, @@ -294,6 +314,68 @@ def __sklearn_tags__(self): tags.input_tags.allow_nan = True return tags + @classmethod + def _params_from_cpu(cls, model): + if np.dtype(model.dtype).kind not in "fb": + raise UnsupportedOnGPU(f"`dtype={model.dtype!r}` is not supported") + if isinstance(model.drop, str) and model.drop == "if_binary": + raise UnsupportedOnGPU("`drop='if_binary'` is not supported") + if model.handle_unknown in ("infrequent_if_exist", "warn"): + raise UnsupportedOnGPU( + f"`handle_unknown={model.handle_unknown!r}` is not supported" + ) + if model.min_frequency is not None: + raise UnsupportedOnGPU("`min_frequency` is not supported") + if model.max_categories is not None: + raise UnsupportedOnGPU("`max_categories` is not supported") + if not ( + isinstance(model.feature_name_combiner, str) + and model.feature_name_combiner == "concat" + ): + raise UnsupportedOnGPU("`feature_name_combiner` is not supported") + return { + "categories": model.categories, + "drop": model.drop, + "sparse_output": model.sparse_output, + "dtype": model.dtype, + "handle_unknown": model.handle_unknown, + } + + def _params_to_cpu(self): + categories = self.categories + if not (isinstance(categories, str) and categories == "auto"): + categories = [_as_numpy(c) for c in categories] + + drop = self.drop + if not (drop is None or (isinstance(drop, str) and drop == "first")): + drop = _as_numpy(drop, dtype=object) + + return { + "categories": categories, + "drop": drop, + "sparse_output": self.sparse_output, + "dtype": self.dtype, + "handle_unknown": self.handle_unknown, + } + + def _attrs_from_cpu(self, model): + return { + "categories_": model.categories_, + "drop_idx_": model.drop_idx_, + "_n_features_outs": model._n_features_outs, + **super()._attrs_from_cpu(model), + } + + def _attrs_to_cpu(self, model): + return { + "categories_": self.categories_, + "drop_idx_": self.drop_idx_, + "_n_features_outs": self._n_features_outs, + "_infrequent_enabled": False, + "_drop_idx_after_grouping": self.drop_idx_, + **super()._attrs_to_cpu(model), + } + @mlfunc(set_input_type=True) @generate_docstring(y=None) def fit(self, X, y=None) -> "OneHotEncoder": @@ -341,7 +423,7 @@ def _fit(self, X, unique=False): f"got {self.drop!r}" ) else: - drop = np.asarray(self.drop, dtype=object) + drop = _as_numpy(self.drop, dtype=object) if len(drop) != len(categories): raise ValueError( @@ -411,7 +493,8 @@ def transform(self, X): X = check_cudf(X, input_name="X") raw_inds = cp.zeros(X.shape, dtype="int32") - has_unknown = False + is_masked = False + columns_with_unknown = [] drop_idx = self.drop_idx_ for i in range(X.shape[1]): @@ -427,23 +510,24 @@ def transform(self, X): else: codes = Xi.astype(cudf.CategoricalDtype(cats)).cat.codes - if codes.has_nulls and self.handle_unknown == "error": - present = Xi.drop_duplicates().sort_values().to_numpy() - diff = _get_diff(present, self.categories_[i]) - raise ValueError( - f"Found unknown categories {diff} in column {i}" - " during transform" - ) + if codes.has_nulls: + if self.handle_unknown == "error": + present = Xi.drop_duplicates().sort_values().to_numpy() + diff = _get_diff(present, self.categories_[i]) + raise ValueError( + f"Found unknown categories {diff} in column {i}" + " during transform" + ) + is_masked = True + columns_with_unknown.append(i) if drop_idx is not None and drop_idx[i] is not None: - has_unknown = True + is_masked = True if drop_idx[i] == 0: codes -= 1 else: codes[codes == drop_idx[i]] = -1 codes[codes > drop_idx[i]] -= 1 - else: - has_unknown |= codes.has_nulls raw_inds[:, i] = codes.fillna(-1) n_samples, n_features = raw_inds.shape @@ -451,7 +535,18 @@ def transform(self, X): feature_indices = np.cumsum([0, *self._n_features_outs]) indices = (raw_inds + cp.asarray(feature_indices[:-1])).ravel() - if has_unknown: + if ( + self.handle_unknown == "ignore" + and self.drop is not None + and columns_with_unknown + ): + warnings.warn( + "Found unknown categories in columns " + f"{columns_with_unknown} during transform. These " + "unknown categories will be encoded as all zeros", + ) + + if is_masked: mask = raw_inds != -1 indices = indices[mask.ravel()] @@ -509,7 +604,7 @@ def inverse_transform(self, X): if len(cats) == 1 and drop_idx is not None: columns[i] = ( - cudf.Series(cats[drop_idx]) + cudf.Series(cats[drop_idx], nan_as_null=True) .repeat(X.shape[0]) .reset_index(drop=True) ) diff --git a/python/cuml/cuml_accel_tests/integration/test_preprocessing.py b/python/cuml/cuml_accel_tests/integration/test_preprocessing.py index 7ff7655f14..bdd864f119 100644 --- a/python/cuml/cuml_accel_tests/integration/test_preprocessing.py +++ b/python/cuml/cuml_accel_tests/integration/test_preprocessing.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 import numpy as np @@ -13,6 +13,7 @@ LabelEncoder, MaxAbsScaler, MinMaxScaler, + OneHotEncoder, PolynomialFeatures, StandardScaler, ) @@ -138,6 +139,33 @@ def test_polynomial_features(): assert isinstance(out_df, pd.DataFrame) +@pytest.mark.parametrize("sparse_output", [True, False]) +def test_one_hot_encoder(sparse_output): + X = np.array([["a", 1], ["b", 1], ["a", 2], ["a", 3]], dtype=object) + + model = OneHotEncoder(sparse_output=sparse_output).fit(X) + np.testing.assert_array_equal(model.categories_[0], ["a", "b"]) + np.testing.assert_array_equal(model.categories_[1], [1, 2, 3]) + + sol = np.array( + [ + [1, 0, 1, 0, 0], + [0, 1, 1, 0, 0], + [1, 0, 0, 1, 0], + [1, 0, 0, 0, 1], + ] + ) + Xt = model.transform(X) + if sparse_output: + assert sp.issparse(Xt) + np.testing.assert_array_equal(Xt.toarray(), sol) + else: + np.testing.assert_array_equal(Xt, sol) + + inv_X = model.inverse_transform(Xt) + np.testing.assert_array_equal(X, inv_X) + + def test_label_encoder(): y = np.array(["a", "b", "a", "b"]) enc = LabelEncoder() diff --git a/python/cuml/cuml_accel_tests/test_set_output.py b/python/cuml/cuml_accel_tests/test_set_output.py index 93522fb7f2..26b17893c4 100644 --- a/python/cuml/cuml_accel_tests/test_set_output.py +++ b/python/cuml/cuml_accel_tests/test_set_output.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 import importlib @@ -32,6 +32,11 @@ def test_set_output(cls): X, y = make_blobs(n_features=20, n_samples=100, random_state=42) model = cls().set_output(transform="pandas") + + # Only works with dense output + if hasattr(model, "sparse_output"): + model.sparse_output = False + if hasattr(model, "transform"): out = model.fit(X, y).transform(X) else: @@ -49,6 +54,7 @@ def test_set_output(cls): # No host transfer required (this isn't strictly necessary, but is currently # true for most proxied estimators). Can revisit this check if it proves tricky # when adding new estimators. - # TargetEncoder triggers sync due to sklearn's set_output accessing n_features_in_ - if cls.__name__ != "TargetEncoder": + # Some classes are excluded since sklearn's set_output accesses + # n_features_in_ which triggers a sync. + if cls.__name__ not in ("OneHotEncoder", "TargetEncoder"): assert not hasattr(model._cpu, "n_features_in_") 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 c30159e24f..0f8518830a 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 @@ -651,6 +651,9 @@ marker: cuml_accel_invalid_sklearn_tests tests: - "sklearn.model_selection.tests.test_search::test_grid_search_score_method" + - "sklearn.preprocessing.tests.test_encoders::test_one_hot_encoder_drop_manual[None]" + - "sklearn.preprocessing.tests.test_encoders::test_one_hot_encoder_drop_manual[nan0]" + - "sklearn.preprocessing.tests.test_encoders::test_one_hot_encoder_drop_manual[nan1]" - "sklearn.svm.tests.test_svm::test_svc_raises_error_internal_representation" - reason: This test asserts a copy hasn't happened, but that's not actually guaranteed by the interface. marker: cuml_accel_invalid_sklearn_tests @@ -835,6 +838,59 @@ - "sklearn.tests.test_common::test_estimators[TSNE()-check_fit2d_predict1d]" - "sklearn.tests.test_common::test_estimators[TSNE()-check_methods_sample_order_invariance]" - "sklearn.tests.test_common::test_estimators[TSNE()-check_methods_subset_invariance]" +- reason: cuml's encoder doesn't require sorted categories + marker: onehotencoder_design_differences + tests: + - "sklearn.preprocessing.tests.test_encoders::test_one_hot_encoder_unsorted_categories" +- reason: cuml's encoder doesn't support bytes dtypes + marker: onehotencoder_design_differences + condition: scikit-learn<1.9.0 + tests: + - "sklearn.preprocessing.tests.test_encoders::test_encoders_string_categories[dataframe-S-O]" + - "sklearn.preprocessing.tests.test_encoders::test_encoders_string_categories[dataframe-S-S]" + - "sklearn.preprocessing.tests.test_encoders::test_encoders_string_categories[dataframe-S-U]" +- reason: cuml's encoder doesn't support bytes dtypes + marker: onehotencoder_design_differences + condition: scikit-learn>=1.9.0 + tests: + - "sklearn.preprocessing.tests.test_encoders::test_encoders_string_categories[pandas-S-O]" + - "sklearn.preprocessing.tests.test_encoders::test_encoders_string_categories[pandas-S-S]" + - "sklearn.preprocessing.tests.test_encoders::test_encoders_string_categories[pandas-S-U]" +- reason: cuml's encoder doesn't support bytes dtypes + marker: onehotencoder_design_differences + tests: + - "sklearn.preprocessing.tests.test_encoders::test_encoders_string_categories[array-S-O]" + - "sklearn.preprocessing.tests.test_encoders::test_encoders_string_categories[array-S-S]" + - "sklearn.preprocessing.tests.test_encoders::test_encoders_string_categories[array-S-U]" + - "sklearn.preprocessing.tests.test_encoders::test_encoders_string_categories[list-S-O]" + - "sklearn.preprocessing.tests.test_encoders::test_encoders_string_categories[list-S-S]" + - "sklearn.preprocessing.tests.test_encoders::test_encoders_string_categories[list-S-U]" + - "sklearn.preprocessing.tests.test_encoders::test_mixed_string_bytes_categoricals" +- reason: cuml's encoder may use alternate dtypes to store categories + marker: onehotencoder_design_differences + tests: + - "sklearn.preprocessing.tests.test_encoders::test_encoder_dtypes" + - "sklearn.preprocessing.tests.test_encoders::test_one_hot_encoder_categories[mixed]" + - "sklearn.preprocessing.tests.test_encoders::test_one_hot_encoder_categories[string]" + - "sklearn.preprocessing.tests.test_encoders::test_one_hot_encoder_specified_categories_mixed_columns" +- reason: cuml's encoder raises a different but still useful error message + marker: onehotencoder_design_differences + tests: + - "sklearn.preprocessing.tests.test_encoders::test_ohe_more_informative_error_message" + - "sklearn.preprocessing.tests.test_encoders::test_one_hot_encoder_set_output" +- reason: cuml's encoder treats NaN and None the same + marker: onehotencoder_design_differences + tests: + - "sklearn.preprocessing.tests.test_encoders::test_ohe_missing_values_get_feature_names[None]" + - "sklearn.preprocessing.tests.test_encoders::test_one_hot_encoder_categories[missing-float-nan-object]" + - "sklearn.preprocessing.tests.test_encoders::test_one_hot_encoder_categories[missing-np.nan-object]" + - "sklearn.preprocessing.tests.test_encoders::test_one_hot_encoder_inverse[None-False-ignore]" + - "sklearn.preprocessing.tests.test_encoders::test_one_hot_encoder_specified_categories[object-None-and-nan-ignore]" + - "sklearn.preprocessing.tests.test_encoders::test_one_hot_encoder_specified_categories[object-None-and-nan-infrequent_if_exist]" + - "sklearn.preprocessing.tests.test_encoders::test_one_hot_encoder_specified_categories[object-None-and-nan-warn]" + - "sklearn.preprocessing.tests.test_encoders::test_one_hot_encoder_specified_categories[object-string-none-ignore]" + - "sklearn.preprocessing.tests.test_encoders::test_one_hot_encoder_specified_categories[object-string-none-infrequent_if_exist]" + - "sklearn.preprocessing.tests.test_encoders::test_one_hot_encoder_specified_categories[object-string-none-warn]" - reason: Calibration temperature scaling differs with cuml.accel in sklearn 1.8 condition: scikit-learn>=1.8 tests: diff --git a/python/cuml/tests/test_one_hot_encoder.py b/python/cuml/tests/test_one_hot_encoder.py index 7338eb2f2a..9f0eeb18f0 100644 --- a/python/cuml/tests/test_one_hot_encoder.py +++ b/python/cuml/tests/test_one_hot_encoder.py @@ -1,5 +1,6 @@ # SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +import cudf import numpy as np import pandas as pd import pytest @@ -79,15 +80,15 @@ def test_onehot_encoder_fit_transform(kind): @pytest.mark.parametrize( - "drop", [None, "first", [2, 2, float("nan"), 2, "banana", "b"]] + "drop", [None, "first", [2, 2, np.nan, 2, "banana", "b"]] ) def test_onehot_encoder_all_dtypes(drop): X = pd.DataFrame( { "int32": pd.Series([1, 2, 1, 2, 1], dtype="int32"), "int64": pd.Series([1, 2, 1, 2, 1], dtype="int64"), - "float32": pd.Series([1, 2, float("nan"), 2, 1], dtype="float32"), - "float64": pd.Series([1, 2, float("nan"), 2, 1], dtype="float64"), + "float32": pd.Series([1, 2, np.nan, 2, 1], dtype="float32"), + "float64": pd.Series([1, 2, np.nan, 2, 1], dtype="float64"), "string": pd.Series(["apple", "banana", "carrot", "apple", None]), "category": pd.Series( ["a", "b", "a", "b", None], dtype="category" @@ -122,6 +123,45 @@ def test_onehot_encoder_all_dtypes(drop): pd.testing.assert_frame_equal(res, sol) +@pytest.mark.parametrize("handle_unknown", ["error", "ignore"]) +@pytest.mark.parametrize("explicit_categories", [True, False]) +def test_onehot_encoder_categorical_inputs( + handle_unknown, explicit_categories +): + abc = cudf.DataFrame({"x": ["a", "b", "a", "c"]}).astype("category") + ab = cudf.DataFrame({"x": ["a", "b", "a", "b"]}).astype("category") + bcd = cudf.DataFrame({"x": ["b", "c", "d"]}).astype("category") + + if explicit_categories: + categories = [["a", "b", "c"]] + X = ab + else: + categories = "auto" + X = abc + + enc = OneHotEncoder(handle_unknown=handle_unknown, categories=categories) + enc.fit(X) + + res = enc.transform(ab).toarray().get() + sol = np.array([[1, 0, 0], [0, 1, 0], [1, 0, 0], [0, 1, 0]]) + np.testing.assert_array_equal(res, sol) + + res = enc.transform(abc).toarray().get() + sol = np.array([[1, 0, 0], [0, 1, 0], [1, 0, 0], [0, 0, 1]]) + np.testing.assert_array_equal(res, sol) + + if handle_unknown == "ignore": + res = enc.transform(bcd).toarray().get() + sol = np.array([[0, 1, 0], [0, 0, 1], [0, 0, 0]]) + np.testing.assert_array_equal(res, sol) + else: + with pytest.raises( + ValueError, + match="Found unknown categories \\['d'\\] in column 0 during transform", + ): + enc.transform(bcd) + + @pytest.mark.parametrize( "cardinalities", [ @@ -168,12 +208,13 @@ def test_onehot_encoder_cardinalities(cardinalities, drop): pd.testing.assert_frame_equal(res, pd.DataFrame(X)) -def test_onehot_encoder_invalid_parameters(): +@pytest.mark.parametrize("missing", [None, np.nan]) +def test_onehot_encoder_invalid_parameters(missing): X = pd.DataFrame( { "x": [1.0, 2.0, 1.0, 2.0], "y": [1.0, 2.0, 3.0, 1.0], - "z": [2.0, 2.0, float("nan"), 2.0], + "z": [2.0, 2.0, missing, 2.0], } ) @@ -205,51 +246,52 @@ def test_onehot_encoder_invalid_parameters(): OneHotEncoder(categories=[[2], [1, 2]]).fit(X) with pytest.raises(ValueError, match="Nan should be the last element"): - OneHotEncoder(categories=[[1, 2], [1, 2, 3], [float("nan"), 2]]).fit(X) + OneHotEncoder(categories=[[1, 2], [1, 2, 3], [missing, 2]]).fit(X) + + with pytest.raises(ValueError, match="Nan should be the last element"): + OneHotEncoder(categories=[[1, 2], [1, 2, 3], [2, None, np.nan]]).fit(X) X2 = pd.DataFrame( { "x": [1.0, 2.0, 1.0, 2.0], - "y": ["a", None, "b", None], + "y": ["a", missing, "b", missing], } ) with pytest.raises(ValueError, match="Nan should be the last element"): - OneHotEncoder(categories=[[1, 2], ["a", float("nan"), "b"]]).fit(X2) - with pytest.raises(ValueError, match="Nan should be the last element"): - OneHotEncoder(categories=[[1, 2], ["a", float("nan"), "b"]]).fit(X2) + OneHotEncoder(categories=[[1, 2], ["a", missing, "b"]]).fit(X2) with pytest.raises(ValueError, match="In column 1, .* duplicate elements"): - OneHotEncoder( - categories=[[1, 2], [1, 2, 3, 3], [2, float("nan")]] - ).fit(X) + OneHotEncoder(categories=[[1, 2], [1, 2, 3, 3], [2, missing]]).fit(X) -def test_onehot_encoder_unknown_categories_in_fit(): - X = np.array([[1, 2, float("nan"), 2]]).T +@pytest.mark.parametrize("missing", [np.nan, None]) +def test_onehot_encoder_unknown_categories_in_fit(missing): + X = np.array([[1, 2, missing, 2]]).T with pytest.raises(ValueError, match="Found unknown categories \\[nan\\]"): OneHotEncoder(categories=[[1, 2]]).fit(X) with pytest.raises( - ValueError, match="Found unknown categories \\[1.0, 2.0\\]" + ValueError, match="Found unknown categories \\[1.*, 2.*\\]" ): - OneHotEncoder(categories=[[float("nan")]]).fit(X) + OneHotEncoder(categories=[[missing]]).fit(X) - enc = OneHotEncoder(categories=[[1, 2, float("nan")]]).fit(X) - np.testing.assert_array_equal(enc.categories_[0], [1, 2, float("nan")]) + enc = OneHotEncoder(categories=[[1, 2, missing]]).fit(X) + np.testing.assert_array_equal(enc.categories_[0], [1, 2, np.nan]) -@pytest.mark.parametrize("unknown_val", ["c", float("nan")]) -def test_onehot_encoder_transform_unknown(unknown_val): +@pytest.mark.parametrize("unknown", ["c", np.nan, None]) +def test_onehot_encoder_transform_unknown(unknown): X1 = pd.DataFrame({"x": ["a", "b", "a"]}) - X2 = pd.DataFrame({"x": ["b", unknown_val]}) + X2 = pd.DataFrame({"x": ["b", unknown]}) enc = OneHotEncoder().fit(X1) # Unknown value errors by default + unknown2 = np.nan if unknown is None else unknown with pytest.raises( ValueError, - match=f".* categories \\[{unknown_val!r}\\] in column 0 during transform", + match=f".* categories \\[{unknown2!r}\\] in column 0 during transform", ): enc.transform(X2) @@ -262,14 +304,65 @@ def test_onehot_encoder_transform_unknown(unknown_val): np.testing.assert_array_equal(res.toarray(), sol.toarray()) # Explicitly passing categories also fixes things - kwargs = {"categories": [["a", "b", unknown_val]]} - cu_enc = OneHotEncoder(**kwargs).fit(X1) - sk_enc = sklearn.preprocessing.OneHotEncoder(**kwargs).fit(X1) + cu_enc = OneHotEncoder(categories=[["a", "b", unknown]]).fit(X1) + sk_enc = sklearn.preprocessing.OneHotEncoder( + categories=[["a", "b", unknown2]] + ).fit(X1) res = cu_enc.transform(X2) sol = sk_enc.transform(X2) np.testing.assert_array_equal(res.toarray(), sol.toarray()) +@pytest.mark.parametrize( + "kind", ["list", "numpy-float", "numpy-object", "cudf"] +) +def test_onehot_encoder_nan_and_null_equivalent(kind): + if kind == "list": + X = [[1], [2], [np.nan], [None]] + elif kind == "numpy-float": + X = np.array([[1], [2], [np.nan], [np.nan]], dtype="float32") + elif kind == "numpy-object": + X = np.array([[1], [2], [np.nan], [None]], dtype=object) + else: + assert kind == "cudf" + X = cudf.DataFrame({"x": [1, 2, np.nan, None]}, nan_as_null=False) + + # Categories always normalize to NaN + enc = OneHotEncoder(output_type="numpy").fit(X) + np.testing.assert_array_equal(enc.categories_[0], [1, 2, np.nan]) + + # Transform works as expected + res = enc.transform(X).toarray() + sol = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1], [0, 0, 1]]) + np.testing.assert_array_equal(res, sol) + + # Manually specifying categories with either NaN or None works + enc = OneHotEncoder(categories=[[1, 2, None]]).fit(X) + np.testing.assert_array_equal(enc.categories_[0], [1, 2, np.nan]) + enc = OneHotEncoder(categories=[[1, 2, np.nan]]).fit(X) + np.testing.assert_array_equal(enc.categories_[0], [1, 2, np.nan]) + + # Error normalizes doesn't double report NaN + enc = OneHotEncoder(categories=[[1, 2]]) + with pytest.raises( + ValueError, + match="Found unknown categories \\[nan\\] in column 0 during fit", + ): + enc.fit(X) + + +@pytest.mark.parametrize("unknown", ["c", np.nan, None]) +def test_onehot_encoder_drop_handle_unknown_ignore_transform_warns(unknown): + X1 = pd.DataFrame({"x": ["a", "b", "a"]}) + X2 = pd.DataFrame({"x": ["b", unknown]}) + + enc = OneHotEncoder(handle_unknown="ignore", drop="first").fit(X1) + with pytest.warns( + UserWarning, match="Found unknown categories in columns \\[0\\]" + ): + enc.transform(X2) + + @pytest.mark.parametrize("drop", [None, "first", ["b", 3, 1]]) @pytest.mark.parametrize("handle_unknown", ["error", "ignore"]) @pytest.mark.parametrize("sparse", [False, True]) diff --git a/python/cuml/tests/test_sklearn_import_export.py b/python/cuml/tests/test_sklearn_import_export.py index 4353bb9326..2bf73ad84c 100644 --- a/python/cuml/tests/test_sklearn_import_export.py +++ b/python/cuml/tests/test_sklearn_import_export.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # +import cudf import cupy as cp import numpy as np import pytest @@ -1173,3 +1174,66 @@ def test_label_binarizer(): np.testing.assert_array_equal(cu_out, sol) np.testing.assert_array_equal(sk_out, sol) + + +@pytest.mark.parametrize("drop", [None, "first"]) +@pytest.mark.parametrize("sparse_output", [True, False]) +@pytest.mark.parametrize("handle_unknown", ["error", "ignore"]) +@pytest.mark.filterwarnings("ignore:Found unknown categories.*:UserWarning") +def test_one_hot_encoder(drop, sparse_output, handle_unknown): + X = np.array( + [["x", "a"], ["x", "b"], ["y", "a"], ["x", "c"]], dtype="object" + ) + kws = { + "drop": drop, + "sparse_output": sparse_output, + "handle_unknown": handle_unknown, + } + cu_model = cuml.preprocessing.OneHotEncoder(**kws).fit(X) + sk_model = sklearn.preprocessing.OneHotEncoder(**kws).fit(X) + + cu_model2 = cuml.preprocessing.OneHotEncoder.from_sklearn(sk_model) + sk_model2 = cu_model.as_sklearn() + # XXX: np.array(drop) == drop is true, this assert ensures we don't + # accidentally coerce `drop` to an array. + assert type(sk_model2.drop) is type(drop) + + roundtrip = cuml.preprocessing.OneHotEncoder.from_sklearn(sk_model2) + assert_roundtrip_consistency(cu_model, roundtrip) + + for c1, c2 in zip(cu_model.categories_, sk_model2.categories_): + np.testing.assert_array_equal(c1, c2) + + cu_out = cu_model2.transform(X) + sk_out = sk_model2.transform(X) + + if sparse_output: + np.testing.assert_array_equal(cu_out.toarray(), sk_out.toarray()) + else: + np.testing.assert_array_equal(cu_out, sk_out) + + if handle_unknown == "ignore": + X = np.array([["x", "d"], ["x", "a"], ["z", "c"]], dtype="object") + cu_out = cu_model2.transform(X) + sk_out = sk_model2.transform(X) + if sparse_output: + np.testing.assert_array_equal(cu_out.toarray(), sk_out.toarray()) + else: + np.testing.assert_array_equal(cu_out, sk_out) + + +def test_one_hot_encoder_cuda_array_like_params(): + cats = [cp.array([1, 2]), cudf.Series([10, 20, 30])] + drop = cp.array([1, 20]) + X = np.array([[1, 10], [1, 20], [2, 30], [2, 10]]) + cu_model1 = cuml.preprocessing.OneHotEncoder( + categories=cats, drop=drop + ).fit(X) + sk_model = cu_model1.as_sklearn() + cu_model2 = cuml.preprocessing.OneHotEncoder.from_sklearn(sk_model) + + cu_out1 = cu_model1.transform(X) + sk_out = sk_model.transform(X) + cu_out2 = cu_model2.transform(X) + np.testing.assert_array_equal(cu_out1.toarray(), sk_out.toarray()) + np.testing.assert_array_equal(cu_out2.toarray(), sk_out.toarray()) diff --git a/python/cuml/tests/test_validation.py b/python/cuml/tests/test_validation.py index e77abacacd..715b16e852 100644 --- a/python/cuml/tests/test_validation.py +++ b/python/cuml/tests/test_validation.py @@ -2151,3 +2151,61 @@ def test_check_cudf_coerces_numeric_objects(): s = check_cudf(x, ensure_ndim=1) assert (s == cudf.Series([1.0, 2.0, 3.0])).all() assert s.dtype == "float64" + + +@pytest.mark.parametrize("kind", ["list", "array", "array-like"]) +def test_check_cudf_mixed_dtype_array_like_inputs(kind): + class ArrayLike: + def __init__(self, array): + self.array = array + + def __array__(self, dtype=None, copy=None): + return self.array + + data = [ + [1, 2.0, "x", "a", None, np.nan], + [2, 4.0, "y", None, None, np.nan], + ] + sol = cudf.DataFrame(data, nan_as_null=True) + if kind == "list": + X = data + elif kind == "array": + X = np.array(data, dtype=object) + else: + assert kind == "array-like" + X = ArrayLike(np.array(data, dtype=object)) + + res = check_cudf(X) + cudf.testing.assert_frame_equal(res, sol) + + +@pytest.mark.parametrize("kind", ["list", "numpy-object", "numpy-float"]) +def test_check_cudf_nan_as_null(kind): + """cudf's default is to treat NaN as NULL in inputs, but that default + changes when `cudf.pandas` is active. Here we check that the code paths in + `check_cudf` hardcode the `nan_as_null` configuration so that behavior + doesn't change if cudf.pandas is active.""" + + if kind == "list": + X = [[1, None], [np.nan, 1]] + elif kind == "numpy-object": + X = np.array([[1, None], [np.nan, 1]], dtype=object) + else: + assert kind == "numpy-float" + X = np.array([[1, np.nan], [np.nan, 1]], dtype="float32") + + res = check_cudf(X, ensure_ndim=None) + vals = cudf.Series([1, np.nan], nan_as_null=True) + assert res.iloc[:, 0].isin(vals).all() + assert res.iloc[:, 1].isin(vals).all() + + +@pytest.mark.parametrize("kind", ["list", "array"]) +def test_check_cudf_unsupported_object_inputs(kind): + data = [[{"x": 1}, 1], [1, 2]] + + with pytest.raises( + TypeError, + match="An object dtype X argument must be composed of", + ): + check_cudf(data, input_name="X")