Skip to content

CI Fix testing references to take into account string dtype - #7830

Merged
rapids-bot[bot] merged 3 commits into
NVIDIA:mainfrom
betatim:fix-string-dtype-tests
Feb 25, 2026
Merged

CI Fix testing references to take into account string dtype#7830
rapids-bot[bot] merged 3 commits into
NVIDIA:mainfrom
betatim:fix-string-dtype-tests

Conversation

@betatim

@betatim betatim commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

Newer versions of cudf are working towards supporting the arrow based string type that is coming to pandas land.

The reference dataframes we use for comparison still use a object dtype, this makes the tests fail. This PR attempts to make things more robust.

import cudf

# object dtype
a = cudf.Series(["a", "b", "c"])

# StringDtype as we get from a dask-cudf roundtrip
b = a.astype("string[pyarrow]")

# Values are identical
assert list(a.to_pandas()) == list(b.to_pandas())

# But isin() returns all False in both directions
print(a.isin(b))  # [False, False, False]
print(b.isin(a))  # [False, False, False]

I've not looked at why dask-cudf does this, but I guess it has to do with the migration of pandas to using arrow string dtypes. On the face of it, it seems odd that directly constructing a series produces a object dtype and that feeding it through dask-cudf changes it:

import cudf
import dask_cudf

df = cudf.DataFrame({'g': ['M', 'F', 'F'], 'i': [1, 3, 2]})
print(f'Direct cudf dtype: {df["g"].dtype}')

ddf = dask_cudf.from_cudf(df, npartitions=2)
print(f'Dask meta dtype:   {ddf["g"].dtype}')

roundtripped = ddf.compute()
print(f'After compute():   {roundtripped["g"].dtype}')

My curiosity is satisfied with having this fix. I expect a bit of upheaval in this transition for pandas.

fixes #7826

@copy-pr-bot

copy-pr-bot Bot commented Feb 24, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@github-actions github-actions Bot added the Cython / Python Cython or Python issue label Feb 24, 2026
@betatim

betatim commented Feb 24, 2026

Copy link
Copy Markdown
Contributor Author

/ok to test a1bbe70

@betatim betatim added improvement Improvement / enhancement to an existing function non-breaking Non-breaking change labels Feb 24, 2026
assert_frame_equal(
inv.compute().to_pandas().reset_index(drop=True), df.to_pandas()
inv.compute().to_pandas().reset_index(drop=True),
X.compute().to_pandas().reset_index(drop=True),

@betatim betatim Feb 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

X is what is passed to fit_transform so it seems reasonable to use it as reference for the result of inverse_transform. And round tripping it through dask-cudf means we should get what ever behaviour it has (across versions)?!

@betatim
betatim marked this pull request as ready for review February 24, 2026 11:37
@betatim
betatim requested a review from a team as a code owner February 24, 2026 11:37
@betatim
betatim requested a review from viclafargue February 24, 2026 11:37
@coderabbitai

coderabbitai Bot commented Feb 24, 2026

Copy link
Copy Markdown
📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • Enhanced dtype compatibility in OneHotEncoder operations to properly validate drop targets and handle categorical comparisons when data types differ between inputs and encoder categories.
  • Tests

    • Enabled previously skipped tests in Dask OneHotEncoder and updated test assertions to improve coverage and validation of inverse transformations.

Walkthrough

This PR adds dtype-normalization before membership checks in OneHotEncoder/encoder logic and adjusts Dask OneHotEncoder tests by removing xfail markers and updating expected comparisons.

Changes

Cohort / File(s) Summary
Encoder dtype fixes
python/cuml/cuml/preprocessing/encoders.py, python/cuml/cuml/preprocessing/onehotencoder_mg.py
Normalize/cast encoder category arrays to the input/category dtype before performing isin() membership checks. _compute_drop_idx now normalizes drop values to encoder categories' dtype and derives masks via cats.isin(drop_vals); _has_unknown casts encoder categories when dtypes differ.
Dask OneHotEncoder tests
python/cuml/tests/dask/test_dask_one_hot_encoder.py
Removed multiple xfail decorators, updated inverse_transform assertions to compare against input X/Y or computed pandas equivalents, and adjusted category comparisons to convert categories to pandas before numpy comparisons.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested reviewers

  • csadorf
  • divyegala
  • dantegd
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: fixing test references to handle string dtype differences between object and arrow-based string types.
Description check ✅ Passed The description clearly explains the root cause (newer cudf versions using Arrow-based string dtype) and demonstrates the issue with concrete code examples showing dtype mismatches.
Linked Issues check ✅ Passed The PR addresses all coding objectives from issue #7826: handles dtype mismatches in _has_unknown, normalizes drop values in _compute_drop_idx, removes xfail decorators, and adjusts test assertions to handle string dtype differences.
Out of Scope Changes check ✅ Passed All code changes directly address dtype compatibility issues described in the linked issue; copyright year update is standard maintenance; no extraneous modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
python/cuml/tests/dask/test_dask_one_hot_encoder.py (1)

109-112: Consider check_dtype=False instead of the dask_cudf round-trip for the reference.

Routing the static ref fixture through dask_cudf.from_cudf(...).compute() just to align dtypes is fragile: if dask_cudf's dtype inference diverges from the encoder's internal conversion path, this test can become a false positive without any visible signal. Using check_dtype=False in assert_frame_equal directly expresses the intent (values match, dtypes may differ) and is immune to that coupling.

♻️ Proposed alternative
-    ref = dask_cudf.from_cudf(ref, npartitions=1).compute().to_pandas()
-    assert_frame_equal(df.compute().to_pandas(), ref)
+    assert_frame_equal(df.compute().to_pandas(), ref.to_pandas(), check_dtype=False)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@python/cuml/tests/dask/test_dask_one_hot_encoder.py` around lines 109 - 112,
The test currently forces ref through dask_cudf.from_cudf(...).compute() to
match dtypes before comparing to df from enc.inverse_transform(Y_ohe); instead,
remove the dask_cudf round-trip and call
assert_frame_equal(df.compute().to_pandas(), ref, check_dtype=False) so the
comparison checks values only while allowing dtype differences; update the
assertion that references assert_frame_equal, df (result of
enc.inverse_transform), ref, enc, and Y_ohe accordingly.
python/cuml/cuml/preprocessing/onehotencoder_mg.py (1)

47-50: Dtype-normalization logic is duplicated from OneHotEncoder._has_unknown; extract a shared helper.

Lines 48–49 are verbatim copies of encoders.py lines 323–324. Since the only structural difference between the two overrides is the trailing .compute(), a small protected helper (e.g., _normalize_encoder_cat_dtype) on the base class would eliminate the duplication and keep the two implementations in sync automatically.

♻️ Suggested refactor

In encoders.py OneHotEncoder:

+    `@staticmethod`
+    def _normalize_encoder_cat_dtype(X_cat, encoder_cat):
+        if hasattr(encoder_cat, "dtype") and X_cat.dtype != encoder_cat.dtype:
+            encoder_cat = encoder_cat.astype(X_cat.dtype)
+        return encoder_cat
+
     def _has_unknown(self, X_cat, encoder_cat):
         """Check if X_cat has categories that are not present in encoder_cat."""
-        if hasattr(encoder_cat, "dtype") and X_cat.dtype != encoder_cat.dtype:
-            encoder_cat = encoder_cat.astype(X_cat.dtype)
+        encoder_cat = self._normalize_encoder_cat_dtype(X_cat, encoder_cat)
         return not X_cat.isin(encoder_cat).all()

In onehotencoder_mg.py OneHotEncoderMG:

     def _has_unknown(self, X_cat, encoder_cat):
-        if hasattr(encoder_cat, "dtype") and X_cat.dtype != encoder_cat.dtype:
-            encoder_cat = encoder_cat.astype(X_cat.dtype)
+        encoder_cat = self._normalize_encoder_cat_dtype(X_cat, encoder_cat)
         return not X_cat.isin(encoder_cat).all().compute()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@python/cuml/cuml/preprocessing/onehotencoder_mg.py` around lines 47 - 50,
Extract the duplicated dtype-normalization into a protected helper on the base
OneHotEncoder (e.g., add a method _normalize_encoder_cat_dtype(self, X_cat,
encoder_cat) in OneHotEncoder that checks hasattr(encoder_cat, "dtype") and
casts encoder_cat to X_cat.dtype when needed), then replace the duplicated lines
in OneHotEncoder._has_unknown and OneHotEncoderMG._has_unknown by calling this
new helper before performing the remaining logic (keep OneHotEncoderMG's
trailing .compute() behavior). Ensure method names are exactly
_normalize_encoder_cat_dtype, OneHotEncoder._has_unknown, and
OneHotEncoderMG._has_unknown so both classes call the shared helper.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@python/cuml/cuml/preprocessing/encoders.py`:
- Around line 307-308: The bug: drop_idx[feature] is set to
cp.asarray(cats[idx].index) which yields a 1D cupy array instead of a Python int
and later breaks get_feature_names() when names.pop(self.drop_idx_[i]) expects
an int. Fix by extracting a plain Python int scalar from the matching index
(e.g., use cats[idx].index[0] or int(cp.asarray(cats[idx].index)[0])) when
assigning drop_idx[feature] so drop_idx values match the scalar type used in the
"first" branch; update the assignment in the block that computes drop_idx
(references: cats, drop_vals, idx, drop_idx, get_feature_names).

---

Nitpick comments:
In `@python/cuml/cuml/preprocessing/onehotencoder_mg.py`:
- Around line 47-50: Extract the duplicated dtype-normalization into a protected
helper on the base OneHotEncoder (e.g., add a method
_normalize_encoder_cat_dtype(self, X_cat, encoder_cat) in OneHotEncoder that
checks hasattr(encoder_cat, "dtype") and casts encoder_cat to X_cat.dtype when
needed), then replace the duplicated lines in OneHotEncoder._has_unknown and
OneHotEncoderMG._has_unknown by calling this new helper before performing the
remaining logic (keep OneHotEncoderMG's trailing .compute() behavior). Ensure
method names are exactly _normalize_encoder_cat_dtype,
OneHotEncoder._has_unknown, and OneHotEncoderMG._has_unknown so both classes
call the shared helper.

In `@python/cuml/tests/dask/test_dask_one_hot_encoder.py`:
- Around line 109-112: The test currently forces ref through
dask_cudf.from_cudf(...).compute() to match dtypes before comparing to df from
enc.inverse_transform(Y_ohe); instead, remove the dask_cudf round-trip and call
assert_frame_equal(df.compute().to_pandas(), ref, check_dtype=False) so the
comparison checks values only while allowing dtype differences; update the
assertion that references assert_frame_equal, df (result of
enc.inverse_transform), ref, enc, and Y_ohe accordingly.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 58da454 and a1bbe70.

📒 Files selected for processing (3)
  • python/cuml/cuml/preprocessing/encoders.py
  • python/cuml/cuml/preprocessing/onehotencoder_mg.py
  • python/cuml/tests/dask/test_dask_one_hot_encoder.py

Comment thread python/cuml/cuml/preprocessing/encoders.py

@jcrist jcrist left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall seems fine to me, just one quick question.

Comment thread python/cuml/cuml/preprocessing/encoders.py Outdated
@betatim

betatim commented Feb 25, 2026

Copy link
Copy Markdown
Contributor Author

/merge

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
python/cuml/cuml/preprocessing/encoders.py (1)

310-311: ⚠️ Potential issue | 🟠 Major

Store drop_idx_ as a scalar int, not an array.

Line 311 still stores cp.asarray(cats[idx].index), which is array-shaped; later (Line 550) names.pop(...) expects an integer index and can fail for dict/list drop paths.

Proposed change
                 idx = cats.isin(drop_vals)
-                drop_idx[feature] = cp.asarray(cats[idx].index)
+                drop_idx[feature] = int(cats[idx].index[0])
#!/bin/bash
python - <<'PY'
import numpy as np
names = ["a", "b", "c"]
idx = np.asarray([1])  # analogous shape issue
try:
    names.pop(idx)
except Exception as e:
    print(type(e).__name__, str(e))
PY
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@python/cuml/cuml/preprocessing/encoders.py` around lines 310 - 311, The code
currently assigns drop_idx[feature] = cp.asarray(cats[idx].index) which produces
an array; change this to store a scalar Python int so later names.pop(...)
works. Replace the assignment in the block that computes drop_idx (referencing
drop_idx, cats, and feature) with a scalar conversion such as using .item() or
indexing plus int(): e.g. extract the single index value then assign int(value)
(or value.item()) to drop_idx[feature] so drop_idx_ becomes an integer rather
than an array.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@python/cuml/cuml/preprocessing/encoders.py`:
- Around line 300-301: The current unconditional dtype coercion of drop_vals to
cats (variables drop_vals and cats) should be restricted so you only coerce when
one side is a string-like/object dtype (e.g., pandas object or pyarrow string)
to handle benign string-representation differences; if the dtypes differ and
neither is string/object, or the mismatch is numeric vs string, raise a
TypeError (or ValueError) instead of astype-ing. Update the logic around the
astype calls used before the isin validation (the blocks that currently do
drop_vals = drop_vals.astype(cats.dtype)) to: (1) detect string-like vs object
using pandas/pyarrow checks, (2) perform astype only for those
string/object-compatible cases, and (3) otherwise abort with a clear error
referencing the offending feature/categories so the subsequent isin(cats).all()
cannot produce false positives.

---

Duplicate comments:
In `@python/cuml/cuml/preprocessing/encoders.py`:
- Around line 310-311: The code currently assigns drop_idx[feature] =
cp.asarray(cats[idx].index) which produces an array; change this to store a
scalar Python int so later names.pop(...) works. Replace the assignment in the
block that computes drop_idx (referencing drop_idx, cats, and feature) with a
scalar conversion such as using .item() or indexing plus int(): e.g. extract the
single index value then assign int(value) (or value.item()) to drop_idx[feature]
so drop_idx_ becomes an integer rather than an array.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a1bbe70 and 3b56a00.

📒 Files selected for processing (2)
  • python/cuml/cuml/preprocessing/encoders.py
  • python/cuml/cuml/preprocessing/onehotencoder_mg.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • python/cuml/cuml/preprocessing/onehotencoder_mg.py

Comment thread python/cuml/cuml/preprocessing/encoders.py
@rapids-bot
rapids-bot Bot merged commit 2797825 into NVIDIA:main Feb 25, 2026
91 checks passed
@betatim
betatim deleted the fix-string-dtype-tests branch February 26, 2026 07:44
@coderabbitai coderabbitai Bot mentioned this pull request May 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Cython / Python Cython or Python issue improvement Improvement / enhancement to an existing function non-breaking Non-breaking change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[CI] Dask OneHotEncoder tests failing

4 participants