fix(compose): support object dtype in ColumnTransformer + SimpleImputer on cuDF - #8317
fix(compose): support object dtype in ColumnTransformer + SimpleImputer on cuDF#8317nethum529 wants to merge 7 commits into
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughSummary by CodeRabbit
WalkthroughChangesObject-dtype and host data handling
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
python/cuml/cuml/common/array_descriptor.py (1)
76-87: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winObject-dtype check misses DataFrame-like values (
.dtypes, plural).This only inspects
.dtype, unlike the equivalent_is_object_dtypehelper added inoutputs.py, which also handles.dtypesfor DataFrame-like values. Not a live bug today since attributes routed through this descriptor (e.g.statistics_) are always plain ndarrays, but if a future descriptor-backed attribute stores an object-dtype DataFrame, this would silently miss the bypass and fall through toCumlArray.from_input, which can't represent object dtype.Consider reusing the
_is_object_dtypehelper fromoutputs.pyhere for consistency.♻️ Suggested consolidation
- input_value = existing.get_input_value() - input_dtype = getattr(input_value, "dtype", None) - if isinstance(input_dtype, np.dtype) and input_dtype.kind == "O": - return input_value + input_value = existing.get_input_value() + if _is_object_dtype(input_value): + return input_value🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@python/cuml/cuml/common/array_descriptor.py` around lines 76 - 87, The object-dtype bypass in array_descriptor.py only checks input_value.dtype, so DataFrame-like values exposing .dtypes can be missed and incorrectly fall through to CumlArray.from_input. Update the descriptor logic in the get_input_value path to reuse the same _is_object_dtype helper used in outputs.py, or otherwise extend the check to cover both .dtype and .dtypes consistently for existing.get_input_value(). Keep the existing early return behavior for object dtype values.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@python/cuml/cuml/_thirdparty/sklearn/preprocessing/_column_transformer.py`:
- Around line 970-979: The object-dtype guard in the column stacking logic does
not catch DataFrame outputs because it only checks X.dtype, so
string/categorical columns from passthrough or custom transformers can still
reach the device-side hstack path and fail. Update the stacking branch in the
_column_transformer helper to detect object dtypes from DataFrame-like outputs
via their per-column dtypes as well as array dtypes, and keep the existing
host-side cpu_np.hstack fallback in that case.
---
Nitpick comments:
In `@python/cuml/cuml/common/array_descriptor.py`:
- Around line 76-87: The object-dtype bypass in array_descriptor.py only checks
input_value.dtype, so DataFrame-like values exposing .dtypes can be missed and
incorrectly fall through to CumlArray.from_input. Update the descriptor logic in
the get_input_value path to reuse the same _is_object_dtype helper used in
outputs.py, or otherwise extend the check to cover both .dtype and .dtypes
consistently for existing.get_input_value(). Keep the existing early return
behavior for object dtype values.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: 6b96728f-a66c-439b-aa0b-850e020cd68e
📒 Files selected for processing (7)
python/cuml/cuml/_thirdparty/sklearn/preprocessing/_column_transformer.pypython/cuml/cuml/_thirdparty/sklearn/preprocessing/_imputation.pypython/cuml/cuml/common/array_descriptor.pypython/cuml/cuml/internals/outputs.pypython/cuml/cuml/internals/validation.pypython/cuml/cuml/thirdparty_adapters/adapters.pypython/cuml/tests/test_compose.py
viclafargue
left a comment
There was a problem hiding this comment.
Thanks for tackling this. The overall approach makes sense to me. Keeping object-dtype/string data on host is probably the right direction, and the new ColumnTransformer + SimpleImputer regression test covers the reported workflow nicely.
I found two remaining issues that I think should be fixed before merge:
| if dtypes is not None: | ||
| # DataFrame-like: dtypes is per-column. | ||
| return any( | ||
| isinstance(dt, np.dtype) and dt.kind == "O" for dt in dtypes |
There was a problem hiding this comment.
_is_object_dtype treats any object with .dtypes as dataframe-like, but pd.Series.dtypes/cudf.Series.dtypes is a scalar dtype, not an iterable. This makes coerce_arrays(pd.Series(...), "pandas") fail before normal output coercion:
There was a problem hiding this comment.
Fixed. _is_object_dtype now checks the scalar .dtype first, so a Series (whose .dtypes is a scalar dtype, not per-column) and extension-dtype Series (category/string/tz) return without hitting the iteration branch. The per-column .dtypes path now only runs for DataFrames (which have no .dtype) and is wrapped defensively. Added a unit test covering Series / extension-dtype / DataFrame / ndarray inputs.
| "verbose", | ||
| "copy" | ||
| "copy", | ||
| "add_indicator", |
There was a problem hiding this comment.
add_indicator=True still goes through the dense _concatenate_indicator path, which unconditionally uses cupy.hstack because np is 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.
Fixed. _concatenate_indicator now detects object-dtype imputed or indicator data and stacks on host with numpy.hstack (moving any cupy operands to host via .get()), instead of routing the host object array through cupy.hstack. The sparse path and the numeric device hstack fast path are unchanged. Added a regression test with add_indicator=True on string columns comparing cuML against scikit-learn.
…er on cuDF impute-then-transform is one of the most common sklearn pipeline shapes, but ColumnTransformer + SimpleImputer crashed on a native cuDF DataFrame whenever categorical/string columns were involved (e.g. missing_values=pd.NA, strategy="constant"/"most_frequent" on object dtype data). Root causes, all stemming from cuML forcing data through cupy/CumlArray regardless of dtype, even though cupy has no way to represent Python object dtype (strings) at all: - check_array unconditionally tried to move object-dtype input to device. - CumlArrayDescriptor._to_output and coerce_arrays (the output-coercion paths behind every `@reflect`-decorated method and fitted attribute) had the same device-forcing assumption. - ColumnTransformer._hstack used cupy's hstack unconditionally, which can't combine a numeric cupy block with an object-dtype host block. - _get_mask's sentinel-detection logic crashed with a pd.NA truthiness error, since cuDF's null string values round-trip through `to_numpy(dtype="object")` as pd.NA (not None/nan). - SimpleImputer._get_param_names omitted "missing_values" and "add_indicator", so sklearn.base.clone() (used by ColumnTransformer to clone each transformer before fitting) silently dropped a user-supplied missing_values sentinel back to the default np.nan. Each fix targets only the previously-always-broken object-dtype path; numeric/device flows are untouched. Non-numpy extension dtypes (e.g. pandas/cudf `category`) are conservatively excluded from the new dtype checks rather than passed to `np.dtype()`, which would raise TypeError. Adds a regression test reproducing the exact issue shape (cuDF DataFrame, numeric + categorical constant-fill + categorical most-frequent-fill columns) and asserting cuML's output matches scikit-learn's bit-for-bit. Closes rapidsai#6183 Signed-off-by: nethum529 <nethumweerasinghe.nw@gmail.com>
- outputs._is_object_dtype: check scalar `.dtype` before per-column `.dtypes`, so extension-dtype Series (category/string/tz-datetime) no longer raise on iteration; DataFrame per-column detection is preserved. - ColumnTransformer._hstack and CumlArrayDescriptor: reuse the shared `_is_object_dtype` helper so object-dtype DataFrame outputs are caught, not just array `.dtype`. - SimpleImputer._concatenate_indicator: stack object-dtype (host) imputed data with the indicator mask on host instead of routing the host array through cupy.hstack, so add_indicator=True works for string columns. - _get_mask: drop the try/except that silently defaulted to False and could disable NA masking; pd.isna is total over the scalar sentinels. - De-duplicate: _imputation.py now imports the shared _is_object_dtype instead of a local copy. - Tests: add regression coverage for add_indicator=True on object data and for _is_object_dtype on Series / extension-dtype / DataFrame inputs. Signed-off-by: nethum529 <nethumweerasinghe.nw@gmail.com>
Signed-off-by: nethum529 <nethumweerasinghe.nw@gmail.com>
Signed-off-by: nethum529 <nethumweerasinghe.nw@gmail.com>
6095331 to
e841e5a
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (2)
python/cuml/cuml/thirdparty_adapters/adapters.py (1)
17-34: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRedundant first branch in
_get_mask.The
isinstance(value_to_mask, str) and value_to_mask == "NaN"branch (lines 19-22) is a strict subset of what_is_na_sentinel(value_to_mask)(lines 27-32) already covers —_is_na_sentinelreturnsvalue == "NaN"for strings, so the string"NaN"case always falls into the first branch before the second is ever reached, and both branches execute identical logic (cp.isnan/pd.isna). This is now dead/duplicate code left over from the refactor.♻️ Suggested simplification
def _get_mask(X, value_to_mask): """Compute the boolean mask X == missing_values.""" - 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): 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🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@python/cuml/cuml/thirdparty_adapters/adapters.py` around lines 17 - 34, Remove the redundant `"NaN"` string branch from `_get_mask` and route all NA-like values through the existing `_is_na_sentinel(value_to_mask)` branch, preserving its `cp.isnan` handling for CuPy arrays and `pd.isna` fallback for host data.python/cuml/cuml/internals/outputs.py (1)
530-628: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicated reshape logic between object-dtype and cp.ndarray paths.
The
series/dataframendim-reshaping logic (lines 543-558) and theone_col_2d_as_serieshost-frame construction (560-570) exactly mirror the equivalent block forcp.ndarrayat lines 595-622. Any future fix/behavior change to one path risks silently diverging from the other.♻️ Suggested extraction
+def _reshape_for_output(obj, output_type, one_col_2d_as_series): + 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] + return objThen call this helper from both the object-dtype and
cp.ndarraybranches instead of duplicating the block.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@python/cuml/cuml/internals/outputs.py` around lines 530 - 628, Extract the shared series/dataframe reshaping and one_col_2d_as_series frame-construction logic from the object-dtype and cp.ndarray branches into a helper, then call it from both paths. Preserve the existing ndim validation, reshaping, index handling, and host/device frame types while leaving each branch’s output conversion behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@python/cuml/cuml/internals/outputs.py`:
- Around line 530-628: Extract the shared series/dataframe reshaping and
one_col_2d_as_series frame-construction logic from the object-dtype and
cp.ndarray branches into a helper, then call it from both paths. Preserve the
existing ndim validation, reshaping, index handling, and host/device frame types
while leaving each branch’s output conversion behavior unchanged.
In `@python/cuml/cuml/thirdparty_adapters/adapters.py`:
- Around line 17-34: Remove the redundant `"NaN"` string branch from `_get_mask`
and route all NA-like values through the existing
`_is_na_sentinel(value_to_mask)` branch, preserving its `cp.isnan` handling for
CuPy arrays and `pd.isna` fallback for host data.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: 423d5527-6bd5-4396-a200-e6c4bfe278c5
📒 Files selected for processing (9)
python/cuml/cuml/_thirdparty/sklearn/preprocessing/_column_transformer.pypython/cuml/cuml/_thirdparty/sklearn/preprocessing/_imputation.pypython/cuml/cuml/internals/outputs.pypython/cuml/cuml/internals/validation.pypython/cuml/cuml/thirdparty_adapters/adapters.pypython/cuml/tests/test_adapters.pypython/cuml/tests/test_compose.pypython/cuml/tests/test_reflection.pypython/cuml/tests/test_validation.py
🚧 Files skipped from review as they are similar to previous changes (4)
- python/cuml/cuml/internals/validation.py
- python/cuml/cuml/_thirdparty/sklearn/preprocessing/_column_transformer.py
- python/cuml/tests/test_compose.py
- python/cuml/cuml/_thirdparty/sklearn/preprocessing/_imputation.py
|
/ok to test e841e5a |
Signed-off-by: nethum529 <nethumweerasinghe.nw@gmail.com>
Signed-off-by: nethum529 <nethumweerasinghe.nw@gmail.com>
|
The 42 failing jobs on the last run are not from this PR. The conda test jobs die during env solve (nvforest/librmm 26.10 nightlies pin cuda-version >=13, conflicting with the cuda 12.2 matrix, plus a rapids-xgboost pin with no viable option), and the wheel jobs die on import with a pylibraft/rmm nightly ABI mismatch (undefined symbol pool_memory_resource_impl). The same failure set shows on main's own build workflow and on unrelated PRs like #8402, so a rerun once the nightly channels heal should clear it. Also pushed two small commits making convert_arrays raise TypeError for object dtype with output_type cupy/numba, matching the ClassLabels.to_output convention, with tests. |
|
/ok to test 51f0af8 |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
python/cuml/cuml/internals/outputs.py (1)
59-83: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winAdd
stacklevel=2to the deprecation warning.Without an explicit stack level, users see
outputs.pyas the warning location instead of the call site, making the migration warning less actionable. As per coding guidelines, flake8 is required for Python changes; this triggers Ruff B028.Proposed fix
warnings.warn( f"`output_type={output_type!r}` was deprecated in version 26.08 " "and will be removed in version 26.10. Please use " f"`output_type={alt!r}` instead.{suffix}", FutureWarning, + stacklevel=2, )🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@python/cuml/cuml/internals/outputs.py` around lines 59 - 83, Update the warnings.warn call in warn_if_output_type_deprecated to pass stacklevel=2, ensuring the deprecation warning points to the caller’s call site while preserving the existing warning message and category.Sources: Coding guidelines, Linters/SAST tools
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@python/cuml/cuml/internals/outputs.py`:
- Around line 59-83: Update the warnings.warn call in
warn_if_output_type_deprecated to pass stacklevel=2, ensuring the deprecation
warning points to the caller’s call site while preserving the existing warning
message and category.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: b7cdade8-0c73-4142-8d9f-33049a82caee
📒 Files selected for processing (3)
python/cuml/cuml/internals/outputs.pypython/cuml/tests/test_compose.pypython/cuml/tests/test_reflection.py
🚧 Files skipped from review as they are similar to previous changes (2)
- python/cuml/tests/test_compose.py
- python/cuml/tests/test_reflection.py
| # 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): |
There was a problem hiding this comment.
pd.isna(value_to_mask) conflates None, np.nan, and pd.NA, so each sentinel masks all NA-like values. This can impute values the user did not select. Please special-case pd.NA; preserve distinct handling for None and np.nan.
| values, counts = xp.unique( | ||
| arr[feature_mask_idxs, i], return_counts=True | ||
| ) | ||
| count_max = counts.max() |
There was a problem hiding this comment.
If a column is entirely missing, counts is empty and counts.max() raises. Please handle counts.size == 0 by storing NaN, so SimpleImputer can drop that column like sklearn.
| # 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": |
There was a problem hiding this comment.
This silently changes an explicit mem_type="device" request to host output, violating check_array’s documented return-type contract. Could this host fallback be scoped to the imputer path or made an explicit opt-in?
| 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 |
There was a problem hiding this comment.
This can return a pandas DataFrame even when output_type="cudf" was explicitly requested. Please raise a clear error for unsupported object layouts instead of silently returning a different output type.
Summary
Fixes #6183.
ColumnTransformer+SimpleImputercrashed on a native cuDFDataFramewhenever categorical/string columns were involved (e.g.missing_values=pd.NA,strategy="constant"/"most_frequent"on object dtype data). impute-then-transform is one of the most common sklearn pipeline shapes, so this blocked a very common workflow.Root cause: several layers of cuML unconditionally forced data through cupy/
CumlArrayregardless of dtype, but cupy has no way to represent Python object dtype (strings) at all. Fixed each layer to keep object-dtype data on host instead of crashing trying to move it to device:check_array(cuml/internals/validation.py) unconditionally tried to move object-dtype input to device.CumlArrayDescriptor._to_output(cuml/common/array_descriptor.py) andcoerce_arrays(cuml/internals/outputs.py) — the output-coercion paths behind every@reflect-decorated method and fitted attribute — had the same device-forcing assumption.ColumnTransformer._hstack(cuml/_thirdparty/sklearn/preprocessing/_column_transformer.py) used cupy'shstackunconditionally, which can't combine a numeric cupy block with an object-dtype host block._get_mask's sentinel-detection logic (cuml/thirdparty_adapters/adapters.py) crashed with apd.NAtruthiness error, since cuDF's null string values round-trip throughto_numpy(dtype="object")aspd.NA(notNone/nan). Also generalized_masked_column_modeto dispatch viacp.get_array_moduleinstead of hardcoding cupy.SimpleImputer._get_param_names(cuml/_thirdparty/sklearn/preprocessing/_imputation.py) omitted"missing_values"and"add_indicator", sosklearn.base.clone()(used byColumnTransformerto clone each transformer before fitting) silently dropped a user-suppliedmissing_valuessentinel back to the defaultnp.nan. This looks like the closest match to the exact symptom in the original report.Each fix targets only the previously-always-broken object-dtype path; numeric/device flows are untouched. Non-numpy extension dtypes (e.g. pandas/cudf
category) are conservatively excluded from the new dtype checks rather than passed tonp.dtype(), which would raiseTypeError— they fall through to their own pre-existing (unrelated) failure mode.Before / after
ValueError: Unsupported dtype object(or, on older cuML/cuDF versions, theKeyError: 'dtype'/AttributeError: 'DataFrame' object has no attribute 'dtype'in the original report).array([[1.0, 'a'], [0.0, 'missing'], [3.0, 'c']], dtype=object), matching scikit-learn's ownColumnTransformeroutput for the same input.Test plan
test_column_transformer_simple_imputer_categorical_cudfinpython/cuml/tests/test_compose.py, reproducing the exact issue shape (cuDF DataFrame, numeric constant-fill + categorical constant-fill + categorical most-frequent-fill columns) and asserting cuML's output matches scikit-learn's bit-for-bit.cuml-cu12wheel with this branch's Python sources layered on top (this environment has no compiledlibcuml++, so CI is relied on for the full native build).test_compose.pyandtest_preprocessing.pyimputer/ColumnTransformersuites against the patched wheel — no regressions (same pre-existing, version-skew-related failure count as on unpatched code).ruff check,ruff format --check, andisort --checkpass on all non-_thirdpartychanged files.🤖 Generated with Claude Code