Skip to content

Preserve MultiIndex column fidelity through ColumnAccessor round trips - #23365

Merged
rapids-bot[bot] merged 6 commits into
NVIDIA:release/26.08from
galipremsagar:multiindex-column-fidelity
Jul 22, 2026
Merged

Preserve MultiIndex column fidelity through ColumnAccessor round trips#23365
rapids-bot[bot] merged 6 commits into
NVIDIA:release/26.08from
galipremsagar:multiindex-column-fidelity

Conversation

@galipremsagar

Copy link
Copy Markdown
Contributor

Description

Split out of #23255 (2/6).

Rebuilding a frame's pandas columns MultiIndex from tuples re-sorts the levels and re-infers their dtypes, losing the exact source layout: unsorted explicit level orders (which change the behavior of pandas operations that work on level codes, e.g. legacy stack(sort=True) after a fast-to-slow conversion under cudf.pandas), categorical/object/int64 level dtypes (int64 levels with missing entries upcast to float64), and NaN column labels (fresh float('nan') objects hash unequal, so lookups miss).

  • Prime the cached to_pandas_index with the exact source pd.MultiIndex at DataFrame construction and propagate it through accessor copies.
  • Restore recorded per-level dtypes in to_pandas_index when the cast round-trips losslessly.
  • Match NaN-containing column labels under pandas' all-NaNs-equal semantics.
  • Read level dtypes off MultiIndex.levels (get_level_values materializes missing entries as NaN and upcasts), also for cudf.MultiIndex columns.
  • Keep hierarchical columns through DataFrame binops when only level dtypes differ (restored Int8 vs int64 fails Index.equals).

Fixes 13 pandas-tests (constructor dict-NaN-key, concat keys with specific levels, groupby ordered multi-func aggregate, MultiIndex loc, and several test_stack_unstack.py cases); their xfail entries are removed. Attribution verified by running each node id against an isolated build containing only this change (pass) and a clean build (fail).

Independent of the other #23255 split PRs, but the stack (3/6) and unstack (4/6) PRs depend on this one.

Checklist

  • I am familiar with the Contributing Guidelines.
  • New or existing tests cover these changes.
  • The documentation is up to date with these changes.

Rebuilding a frame's pandas columns MultiIndex from tuples re-sorts the
levels and re-infers their dtypes, losing the exact source layout:
unsorted explicit level orders (which change the behavior of pandas
operations that work on codes, e.g. legacy stack(sort=True) after a
fast-to-slow conversion), categorical/object/int64 level dtypes (int64
levels with missing entries upcast to float64), and NaN column labels
(fresh float('nan') objects hash unequal, so lookups miss).

- prime the cached to_pandas_index with the exact source pd.MultiIndex
  at DataFrame construction and propagate it through accessor copies
- restore recorded per-level dtypes in to_pandas_index when the cast
  round-trips losslessly
- match NaN-containing labels under pandas' all-NaNs-equal semantics
- read level dtypes off MultiIndex.levels (get_level_values materializes
  missing entries as NaN and upcasts), also for cudf.MultiIndex columns
- keep hierarchical columns through DataFrame binops when only level
  dtypes differ (restored Int8 vs int64 fails Index.equals)

Fixes 13 pandas-tests; their xfail entries are removed. Attribution
verified against an isolated build containing only this change.
@galipremsagar
galipremsagar requested a review from a team as a code owner July 21, 2026 13:59
@copy-pr-bot

copy-pr-bot Bot commented Jul 21, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@github-actions github-actions Bot added Python Affects Python cuDF API. cudf.pandas Issues specific to cudf.pandas labels Jul 21, 2026
@GPUtester GPUtester moved this to In Progress in cuDF Python Jul 21, 2026
@galipremsagar galipremsagar added bug Something isn't working non-breaking Non-breaking change labels Jul 21, 2026
@galipremsagar

Copy link
Copy Markdown
Contributor Author

/okay to test 485f7fd

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

ColumnAccessor now supports NaN-aware label lookup and restores preserved MultiIndex level dtypes. DataFrame construction, column assignment, and binary operations retain pandas MultiIndex metadata. Regression tests and pandas-testing expected-failure mappings were updated.

Changes

NaN-aware MultiIndex handling

Layer / File(s) Summary
ColumnAccessor lookup and MultiIndex conversion
python/cudf/cudf/core/column_accessor.py
NaN-containing labels are canonicalized for lookup, cached pandas indexes are copied, and preserved MultiIndex level dtypes are restored when casts are lossless.
DataFrame MultiIndex metadata wiring
python/cudf/cudf/core/dataframe.py
MultiIndex level dtypes and exact pandas indexes are retained across construction, column assignment, and matching DataFrame binary operations.
Regression coverage and expected-failure updates
python/cudf/cudf/tests/private_objects/test_column_accessor.py, python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py
Tests cover unsorted levels, integer dtype restoration, fresh NaN labels, and ambiguous pd.NA labels; related expected-failure entries are removed.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • rapidsai/cudf#22966: Both changes preserve MultiIndex level_dtypes when constructing ColumnAccessor instances.
  • rapidsai/cudf#23255: Both changes modify ColumnAccessor NaN lookup and MultiIndex dtype restoration.

Suggested labels: improvement

Suggested reviewers: brandon-b-miller, vyasr, wence-

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: preserving MultiIndex column fidelity through ColumnAccessor round trips.
Description check ✅ Passed The description is directly related to the changeset and explains the MultiIndex, NaN-label, and test updates.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

@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: 2

🤖 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/cudf/cudf/core/column_accessor.py`:
- Around line 38-45: Update _is_nan_scalar in
python/cudf/cudf/core/column_accessor.py:38-45 to recognize NaN values across
NumPy floating scalar types, while preserving existing Python-float behavior;
adjust the comparison flow at python/cudf/cudf/core/column_accessor.py:204-207
to safely handle nullable results such as pd.NA without raising before checking
later NaN labels; add or update coverage at
python/cudf/cudf/tests/private_objects/test_column_accessor.py:416-425 for NumPy
NaN labels and nullable key comparisons.

In `@python/cudf/cudf/tests/private_objects/test_column_accessor.py`:
- Around line 410-413: Update the test around gdf._data.to_pandas_index to
invoke the accessor on an uncached copy of the column/index data, ensuring the
from_tuples/set_levels dtype-restoration path executes. Keep the existing dtype
and index equality assertions unchanged.
🪄 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: c12d4a64-b856-43d5-941d-adee2551ffdb

📥 Commits

Reviewing files that changed from the base of the PR and between 738fb74 and 485f7fd.

📒 Files selected for processing (4)
  • python/cudf/cudf/core/column_accessor.py
  • python/cudf/cudf/core/dataframe.py
  • python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py
  • python/cudf/cudf/tests/private_objects/test_column_accessor.py
💤 Files with no reviewable changes (1)
  • python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py

Comment thread python/cudf/cudf/core/column_accessor.py
Comment thread python/cudf/cudf/tests/private_objects/test_column_accessor.py
Comment thread python/cudf/cudf/core/column_accessor.py Outdated
Comment thread python/cudf/cudf/core/column_accessor.py
Comment thread python/cudf/cudf/core/column_accessor.py
Comment thread python/cudf/cudf/core/dataframe.py Outdated
- Move the to_pandas_index cache priming into ColumnAccessor: a
  pandas_index constructor argument primes via _prime_to_pandas_index,
  which owns the multiindex/length conditions; the four DataFrame call
  sites pass the source index instead of reaching into the cache.
- Recognize NumPy floating NaNs in _is_nan_scalar (only np.float64
  subclasses python float; np.float16/np.float32 NaN labels previously
  bypassed the canonicalized lookup).
- Treat ambiguous label comparisons as non-matches in the NaN lookup
  retry: a pd.NA label makes == return pd.NA, whose truthiness raises
  TypeError, which previously escaped before later NaN labels matched.
- Reword the canonicalization docstring around the identity shortcut:
  no NaN compares equal to any NaN; dict/tuple comparisons short-circuit
  on identity, and mapping every NaN to the np.nan singleton is what
  makes the lookup work.
- Exercise the dtype-restoration path on an unprimed accessor copy in
  the test, and cover NumPy floating NaN labels and pd.NA labels.
@galipremsagar

Copy link
Copy Markdown
Contributor Author

All review points addressed in 358a983:

  • Priming centralized (vyasr): ColumnAccessor now owns the logic — a pandas_index= constructor argument routes through _prime_to_pandas_index(), which holds the multiindex/type/length conditions; the DataFrame call sites just pass the source index (one post-construction site calls the method).
  • NumPy NaN labels (CodeRabbit, confirmed by vyasr): _is_nan_scalar accepts np.floatingnp.float16/32 NaNs are not python floats and previously bypassed the canonicalized lookup.
  • pd.NA labels: ambiguous comparisons (== returning pd.NA) are treated as non-matches instead of raising out of the retry loop.
  • Comment reworded to lead with the identity-shortcut mechanism (all NaNs compare unequal; dict/tuple comparison short-circuits on identity and np.nan is a singleton).
  • Tests: the dtype-restoration test now asserts on an unprimed .copy() so the rebuild path actually runs; new tests cover np.float16/32/64 NaN labels and the pd.NA case. Also verified the multiple-NaN-representations question: pandas treats them as duplicates of one label, and cudf rejects duplicate labels at construction, so the lookup can only ever see one NaN label.

Re-verified: classic dataframe/reshape/groupby-agg suites (16k tests, no regressions) and the PR's 13 pandas-tests pass strict under an isolated build of this branch.

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
python/cudf/cudf/core/column_accessor.py (1)

163-167: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve exact MultiIndex metadata in method copies. ColumnAccessor.copy() has unchanged labels, so dropping its cached source index causes an avoidable tuple rebuild and loses unsorted level order.

  • python/cudf/cudf/core/column_accessor.py#L163-L167: forward an already-cached to_pandas_index through ColumnAccessor.copy().
  • python/cudf/cudf/tests/private_objects/test_column_accessor.py#L398-L400: assert that gdf._data.copy() preserves the original unsorted MultiIndex.
  • python/cudf/cudf/tests/private_objects/test_column_accessor.py#L411-L413: explicitly clear the copied accessor’s cache before asserting dtype restoration.
Proposed fix
 def copy(self, deep: bool = False) -> Self:
     data = {k: v.copy(deep=deep) for k, v in self._data.items()}
     return self.__class__(
         data=data,
         multiindex=self.multiindex,
         level_names=self.level_names,
         rangeindex=self.rangeindex,
         label_dtype=self.label_dtype,
         verify=False,
         level_dtypes=self._level_dtypes,
+        pandas_index=self.__dict__.get("to_pandas_index"),
     )
-    copied = ColumnAccessor(gdf._data)
+    copied = gdf._data.copy()
     pd.testing.assert_index_equal(copied.to_pandas_index, pmi, exact=True)

-    result = gdf._data.copy().to_pandas_index
+    unprimed = gdf._data.copy()
+    unprimed.__dict__.pop("to_pandas_index")
+    result = unprimed.to_pandas_index

Based on PR objectives, exact source MultiIndex fidelity must survive reconstruction.

🤖 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/cudf/cudf/core/column_accessor.py` around lines 163 - 167, Update
ColumnAccessor.copy() to propagate an already-cached to_pandas_index so copied
accessors preserve exact unsorted MultiIndex metadata; add the requested
assertion in python/cudf/cudf/tests/private_objects/test_column_accessor.py
lines 398-400, and explicitly clear the copied accessor cache before the
dtype-restoration assertion at lines 411-413.
🤖 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/cudf/cudf/core/column_accessor.py`:
- Around line 163-167: Update ColumnAccessor.copy() to propagate an
already-cached to_pandas_index so copied accessors preserve exact unsorted
MultiIndex metadata; add the requested assertion in
python/cudf/cudf/tests/private_objects/test_column_accessor.py lines 398-400,
and explicitly clear the copied accessor cache before the dtype-restoration
assertion at lines 411-413.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 757ff81d-9aca-4bf8-843e-771ed234a534

📥 Commits

Reviewing files that changed from the base of the PR and between 485f7fd and 358a983.

📒 Files selected for processing (3)
  • python/cudf/cudf/core/column_accessor.py
  • python/cudf/cudf/core/dataframe.py
  • python/cudf/cudf/tests/private_objects/test_column_accessor.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • python/cudf/cudf/core/dataframe.py

…-column-fidelity

# Conflicts:
#	python/cudf/cudf/pandas/scripts/pandas-testing-plugin.py
@galipremsagar
galipremsagar requested a review from vyasr July 21, 2026 20:08
@galipremsagar

Copy link
Copy Markdown
Contributor Author

/okay to test b422758

@galipremsagar

Copy link
Copy Markdown
Contributor Author

/okay to test 29bf8ea

@galipremsagar

Copy link
Copy Markdown
Contributor Author

/merge

@rapids-bot
rapids-bot Bot merged commit 8b4c4a5 into NVIDIA:release/26.08 Jul 22, 2026
126 checks passed
@galipremsagar galipremsagar added 5 - Ready to Merge Testing and reviews complete, ready to merge and removed 3 - Ready for Review Ready for review by team labels Jul 22, 2026
@github-project-automation github-project-automation Bot moved this from In Progress to Done in cuDF Python Jul 22, 2026
rapids-bot Bot pushed a commit that referenced this pull request Jul 23, 2026
#23370)

Split out of #23255 (3/6), superseding it. **Depends on #23365 (MultiIndex column fidelity)** — 14 of the 28 un-xfailed pandas-tests need both fixes, so this PR's pandas-tests job goes green once #23365 merges.

- Resolve `level` positionally: integer column-level *names* no longer collide with level *positions* (pandas' `Index.get_level_values` resolves integers by name first, so frames with integer level names returned data from the wrong level).
- Validate out-of-bounds integer levels (`IndexError`) and duplicated level names (`ValueError`) with pandas' messages; negative out-of-bounds levels previously wrapped around silently.
- Build the stacked level keys from the column MultiIndex's own levels/codes so per-level dtypes survive: int64 levels with missing entries no longer upcast to float64, and categorical levels stay categorical through the pylibcudf `tile` step (which only sees codes).
- Emit stacked keys in appearance order, matching pandas. This replaces the argsort-based reordering, which misaligned column data for non-involution column permutations (e.g. a 3-cycle) and NaN keys; pandas legacy stack sorts multi-level keys by level *codes*, not values.
- Attach pandas-faithful levels/codes to the result index eagerly (the original index contributes its own levels/codes; flat indexes and the tiled level get appearance-order factorization) so a later `unstack` restores the original row/column order; the legacy `dropna` path preserves them by masking codes instead of gathering the index.

Fixes 28 pandas-tests; their xfail entries are removed. Three classic categorical unstack params are un-xfailed (fixed by this change together with #23365). Attribution verified per node id against isolated builds: 14 pass with only this change, 14 need this plus #23365.

Authors:
  - GALI PREM SAGAR (https://github.com/galipremsagar)

Approvers:
  - Matthew Murray (https://github.com/Matt711)

URL: #23370
rapids-bot Bot pushed a commit that referenced this pull request Jul 23, 2026
Split out of #23255 (4/6). **Depends on #23365 (fidelity), the stack PR (#23370), and #23366 (groupby agg)** — 8 of the 29 un-xfailed pandas-tests need those fixes too, so this PR's pandas-tests job goes green once they merge.

- Order result rows/columns by the removed level's codes (level order preserved, missing keys first) instead of sorted values with nulls last, by encoding the integer code columns instead of the level values.
- Propagate the source frame's column-axis level names into the result instead of hardcoding `None`; also fixes the `ValueError: Length of names must match number of levels` crash when unstacking MultiIndex-column frames.
- Promote integer source columns to float64 when the reshape introduces missing cells (pandas' block semantics), gated on `mode.pandas_compatible`; `pivot_table`/`crosstab` opt out via a module-private `_unstack` parameter when `fill_value` fills the cells afterwards.
- Preserve unused categories of the removed level in the result's column levels (pandas GH 17845); also fixes a libcudf `Column sizes don't match` crash for indexes with unused categorical categories.
- Validate the level on flat-index frames (`KeyError`) and duplicated index names (`ValueError`) like pandas; `pivot` with `values=` drops the original columns-axis names.

Fixes 29 pandas-tests; their xfail entries are removed, three remaining `test_stack_unstack.py` entries get real failure reasons, and two classic categorical unstack params are un-xfailed. Attribution verified per node id against isolated builds: 21 pass with only this change, 4 need the stack PR, 2 need stack+fidelity, 2 need the groupby-agg PR.

Authors:
  - GALI PREM SAGAR (https://github.com/galipremsagar)

Approvers:
  - Matthew Roeschke (https://github.com/mroeschke)

URL: #23368
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

5 - Ready to Merge Testing and reviews complete, ready to merge bug Something isn't working cudf.pandas Issues specific to cudf.pandas non-breaking Non-breaking change Python Affects Python cuDF API.

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants