-
Notifications
You must be signed in to change notification settings - Fork 651
fix(compose): support object dtype in ColumnTransformer + SimpleImputer on cuDF #8317
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
97095ed
96c4849
090007f
e841e5a
b20ad11
c9bab09
51f0af8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -326,6 +326,28 @@ def infer_output_type(array, array_like="numpy"): | |
| return None | ||
|
|
||
|
|
||
| def _is_object_dtype(res): | ||
| """Check for NumPy object dtype on an array-like or dataframe-like.""" | ||
| dtype = getattr(res, "dtype", None) | ||
| if dtype is not None: | ||
| # Array-like or Series: a single (possibly extension) dtype. Only a | ||
| # plain numpy object dtype has no device representation; extension | ||
| # dtypes are a separate, unsupported case handled elsewhere. | ||
| return isinstance(dtype, np.dtype) and dtype.kind == "O" | ||
|
|
||
| dtypes = getattr(res, "dtypes", None) | ||
| if dtypes is not None: | ||
| # DataFrame-like: dtypes is per-column. | ||
| try: | ||
| return any( | ||
| isinstance(dt, np.dtype) and dt.kind == "O" for dt in dtypes | ||
| ) | ||
| except TypeError: | ||
| return False | ||
|
|
||
| return False | ||
|
|
||
|
|
||
| class ArrayIndexPair: | ||
| """An array paired with an aligned index. | ||
|
|
||
|
|
@@ -534,6 +556,57 @@ def convert_arrays( | |
| return pd.Series(obj.flatten(), index=index) | ||
| return pd.DataFrame(obj, index=index) | ||
| return pd.Series(obj, index=index) | ||
| elif _is_object_dtype(obj): | ||
| # NumPy object arrays have no device representation. Preserve | ||
| # host arrays for "array" and internal "cuml" outputs, or wrap | ||
| # them in a dataframe-like type. | ||
| if output_type in ("array", "cuml"): | ||
| return obj | ||
| elif output_type in ("cupy", "numba"): | ||
| raise TypeError( | ||
| f"{output_type=!r} doesn't support outputs of dtype " | ||
| f"object and shape {obj.shape}" | ||
| ) | ||
|
|
||
| if hasattr(index, "to_pandas"): | ||
| index = index.to_pandas() | ||
| if output_type == "series": | ||
| if obj.ndim == 2: | ||
| if obj.shape[1] == 1: | ||
| obj = obj.flatten() | ||
| else: | ||
| raise ValueError( | ||
| "Only single dimensional arrays can be transformed to" | ||
| " Series." | ||
| ) | ||
| elif obj.ndim == 0: | ||
| obj = obj[None] | ||
| elif output_type == "dataframe": | ||
| if obj.ndim == 1: | ||
| obj = obj[:, None] | ||
| elif obj.ndim == 0: | ||
| obj = obj[None, None] | ||
|
|
||
| if obj.ndim == 2: | ||
| if ( | ||
| one_col_2d_as_series | ||
| and obj.shape[1] == 1 | ||
| and output_type != "dataframe" | ||
| ): | ||
| host_df = pd.Series(obj.flatten(), index=index) | ||
| else: | ||
| host_df = pd.DataFrame(obj, index=index) | ||
| else: | ||
| host_df = pd.Series(obj, index=index) | ||
|
|
||
| try: | ||
| return cudf.from_pandas(host_df) | ||
| except (TypeError, ValueError, NotImplementedError): | ||
| if isinstance(host_df, pd.DataFrame): | ||
| # cudf cannot represent every mixed object DataFrame. | ||
| # Preserve the host DataFrame instead of losing its data. | ||
| return host_df | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This can return a pandas DataFrame even when |
||
| raise | ||
| else: | ||
| # Other output types use device memory, coerce to cupy and take | ||
| # cupy code path. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| # | ||
| # SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION. | ||
| # SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| # | ||
| import numbers | ||
|
|
@@ -697,6 +697,15 @@ def check_array( | |
| # No original dtype, use first provided dtype | ||
| dtype = dtype[0] | ||
|
|
||
| # Object dtype (e.g. strings) has no device-memory representation - cupy | ||
| # has no way to store arbitrary Python objects on the GPU. Force host | ||
| # output in that case regardless of the requested `mem_type`, rather than | ||
| # crashing when attempting the device conversion below. This is required | ||
| # for estimators (e.g. SimpleImputer) that explicitly support categorical | ||
| # / string data with an object dtype. | ||
| if isinstance(dtype, np.dtype) and dtype.kind == "O": | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This silently changes an explicit |
||
| mem_type = "host" | ||
|
|
||
| # Coerce `array` to numpy/cupy/scipy.sparse/cupyx.scipy.sparse values as | ||
| # requested. For dataframe-like inputs also extract the index for later use. | ||
| index = None | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,15 +1,35 @@ | ||
| # | ||
| # 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 numpy as np | ||
| import pandas as pd | ||
|
|
||
|
|
||
| def _is_na_sentinel(value): | ||
| """Return whether a scalar missing-value sentinel is NA-like.""" | ||
| if isinstance(value, str): | ||
| return value == "NaN" | ||
| return pd.isna(value) | ||
|
|
||
|
|
||
| def _get_mask(X, value_to_mask): | ||
| """Compute the boolean mask X == missing_values.""" | ||
| if value_to_mask == "NaN" or cp.isnan(value_to_mask): | ||
| return cp.isnan(X) | ||
| if isinstance(value_to_mask, str) and value_to_mask == "NaN": | ||
| if isinstance(X, cp.ndarray): | ||
| return cp.isnan(X) | ||
| return pd.isna(X) | ||
| # NaN-like sentinels (np.nan, None, pd.NA, pd.NaT, ...) require an | ||
| # NA-aware comparison: a plain `==` against e.g. pd.NA propagates | ||
| # instead of returning a boolean mask, and `cp.isnan` doesn't accept | ||
| # non-numeric scalars like pd.NA in the first place. | ||
| if _is_na_sentinel(value_to_mask): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| if isinstance(X, cp.ndarray): | ||
| return cp.isnan(X) | ||
| # Host (e.g. object dtype) arrays can't use isnan - fall back to an | ||
| # NA-aware elementwise check that also recognizes None/pd.NA. | ||
| return pd.isna(X) | ||
| else: | ||
| return X == value_to_mask | ||
|
|
||
|
|
@@ -20,7 +40,7 @@ def _masked_column_median(arr, masked_value): | |
| mask = _get_mask(arr, masked_value) | ||
| if arr.size == 0: | ||
| return cp.full(arr.shape[1], cp.nan) | ||
| if not cp.isnan(masked_value): | ||
| if not _is_na_sentinel(masked_value): | ||
| arr_sorted = arr.copy() | ||
| # If nan is not the missing value, any column with nans should | ||
| # have a median of nan | ||
|
|
@@ -59,7 +79,7 @@ def _masked_column_mean(arr, masked_value): | |
| count_missing_values = mask.sum(axis=0) | ||
| n_elems = arr.shape[0] - count_missing_values | ||
| mean = cp.nansum(arr, axis=0) | ||
| if not cp.isnan(masked_value): | ||
| if not _is_na_sentinel(masked_value): | ||
| mean -= count_missing_values * masked_value | ||
| mean /= n_elems | ||
| return mean | ||
|
|
@@ -68,18 +88,22 @@ def _masked_column_mean(arr, masked_value): | |
| def _masked_column_mode(arr, masked_value): | ||
| """Determine the most frequently appearing element in each column in the 2D | ||
| array arr, ignoring any instances of masked_value""" | ||
| # Object-dtype arrays (e.g. strings) live on host, since cupy has no way | ||
| # to represent them on device. Dispatch to the array's own module so this | ||
| # works for both device (numeric) and host (object dtype) input. | ||
| xp = cp.get_array_module(arr) | ||
| mask = _get_mask(arr, masked_value) | ||
| n_features = arr.shape[1] | ||
| most_frequent = np.empty(n_features, dtype=arr.dtype) | ||
| for i in range(n_features): | ||
| feature_mask_idxs = cp.where(~mask[:, i])[0] | ||
| values, counts = cp.unique( | ||
| feature_mask_idxs = xp.where(~mask[:, i])[0] | ||
| values, counts = xp.unique( | ||
| arr[feature_mask_idxs, i], return_counts=True | ||
| ) | ||
| count_max = counts.max() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If a column is entirely missing, counts is empty and |
||
| if count_max > 0: | ||
| value = values[counts == count_max].min() | ||
| else: | ||
| value = cp.nan | ||
| value = xp.nan | ||
| most_frequent[i] = value | ||
| return cp.array(most_frequent) | ||
| return xp.array(most_frequent) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
add_indicator=Truestill goes through the dense_concatenate_indicatorpath, which unconditionally usescupy.hstackbecausenpis CuPy in this file. For object-dtype imputed data, that sends the host object array back to CuPy and reproduces the same failure this PR is trying to avoid.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed.
_concatenate_indicatornow detects object-dtype imputed or indicator data and stacks on host withnumpy.hstack(moving any cupy operands to host via.get()), instead of routing the host object array throughcupy.hstack. The sparse path and the numeric devicehstackfast path are unchanged. Added a regression test withadd_indicator=Trueon string columns comparing cuML against scikit-learn.