Skip to content

[Data] Fix Arrow-backed pandas regressions in preprocessors and pandas batches - #65272

Open
AarryaSaraf wants to merge 3 commits into
ray-project:masterfrom
AarryaSaraf:data-fix-null-type-arrow-backed-to-pandas
Open

[Data] Fix Arrow-backed pandas regressions in preprocessors and pandas batches#65272
AarryaSaraf wants to merge 3 commits into
ray-project:masterfrom
AarryaSaraf:data-fix-null-type-arrow-backed-to-pandas

Conversation

@AarryaSaraf

@AarryaSaraf AarryaSaraf commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Description

Ray 2.56 (#63017) made BlockAccessor.to_pandas return Arrow-backed pandas columns
(pd.ArrowDtype) instead of copying into NumPy arrays. That removed a copy, but it also
changed two things downstream code relied on:

  1. A missing value became pd.NA instead of np.nan. np.nan answers every
    comparison with False; pd.NA answers "unknown", producing a three-valued mask, and
    it cannot be stored in a numeric NumPy array.
  2. Pandas' Arrow backend is not a drop-in for its NumPy backend. %, divmod,
    rmod and rdivmod are unimplemented
    (pandas-dev/pandas#58723), and
    to_numpy()/.values degrade to dtype=object.

Six user-visible regressions followed:

Symptom
PowerTransformer raised IndexError: only integers, slices ... are valid indicesseries >= 0 yields a three-valued bool[pyarrow] mask that cannot index a NumPy array.
Categorizer raised TypeError: boolean value of NA is ambiguouspd.NA survived the null filter into the category sort.
Concatenator silently emitted a column of pickled Python objects instead of a tensor, and raised TypeError: ... not 'NAType' with an integer dtype or flatten=True.
FeatureHasher silently emitted pickled objects too: 0 + pd.NA is pd.NA, so one null poisons every hash bucket.
A pandas UDF doing df["x"] % 2 raised NotImplementedError: mod not implemented.
A pandas UDF doing df[["a", "b"]].to_numpy() silently got dtype=object instead of float64.

Three of the six raised nothing at all, so the new tests assert on Arrow types and dtypes
rather than only on values.

The fix is two independently reviewable commits.

Commit 1 — the four preprocessors. is_null now recognizes pd.NA/pd.NaT, which is
the whole Categorizer fix. PowerTransformer, Concatenator and FeatureHasher
convert the columns they operate on through a new to_numpy_backed helper, which
reproduces the conversion pandas performs when Arrow-backing is disabled and is a no-op on
already-NumPy-backed input.

Commit 2 — the batch-format contract. Ray cannot audit what a user's function does
with a pandas frame, so map_batches and iter_batches now convert pandas batches back to
NumPy-backed pandas before calling it. Ray's own preprocessors are audited (commit 1) and
opt back in through an internal arrow_backed_pandas flag, so they keep the zero-copy
conversion #63017 added. Dataset.to_pandas keeps Arrow-backed dtypes as well: it returns
a result to the driver rather than feeding a user function, and Arrow-backing is what lets
an integer column containing nulls stay integral instead of widening to float.

Caller pandas backing Why
Ray's preprocessors Arrow (zero-copy) audited for pd.NA, covered by tests
map_batches(batch_format="pandas") NumPy arbitrary user code
iter_batches(batch_format="pandas") NumPy arbitrary user code
Dataset.to_pandas() Arrow driver-side result; preserves types

No public API signature changes: the flag is threaded through the existing
Dataset.map_batches_internal and the private DataIterator._iter_batches.
The pre-2.56 dtype mapping is lossy, and this PR brings that back. An integer column
containing nulls widens to float64, losing exactness above 2**53; pd.NA becomes
np.nan, so a float column no longer distinguishes "missing" from "not a number"; and
bool-with-nulls, string, decimal and nested columns become object. This is deliberate —
it is the representation NumPy-assuming code requires and the one Ray returned in every
release before 2.56. Callers who need exact typesshould use Dataset.to_pandas() or batch_format="pyarrow", neither of which converts.

Related issues

Related to #64765 (the SimpleImputer failure from the same 2.56 change, fixed in #65187
this PR adds the regression test that fix shipped without).

Additional information

This is a default-behaviour change. map_batches(batch_format="pandas") and
iter_batches(batch_format="pandas") return NumPy-backed pandas again, as they did before
2.56, so anyone who started depending on Arrow-backed dtypes there in 2.56 will see the
pre-2.56 dtypes instead. That is the intent, but it warrants a look from the owners of
#63017. DataContext.enable_arrow_backed_pandas_conversion remains the escape hatch in the
other direction; its docstring is updated here, because it previously advised setting it to
False specifically to work around the % failure above.

New tests are all in python/ray/data/tests/test_arrow_backed_pandas.py (56 tests): unit
coverage for is_null and to_numpy_backed, per-preprocessor regression tests, and the
contract in the table above. 22 fail without commit 1's source changes and 4 fail without
commit 2's. They build their input with from_items/range rather than from_pandas,
because a pandas block never goes through to_pandas and so never reaches the code these
bugs live in.

@AarryaSaraf
AarryaSaraf force-pushed the data-fix-null-type-arrow-backed-to-pandas branch from 44161d5 to 3b845a4 Compare August 6, 2026 21:11
@AarryaSaraf AarryaSaraf added data Ray Data-related issues go add ONLY when ready to merge, run all tests labels Aug 6, 2026
@AarryaSaraf
AarryaSaraf force-pushed the data-fix-null-type-arrow-backed-to-pandas branch from f676a8e to a4a7116 Compare August 6, 2026 23:55
@AarryaSaraf
AarryaSaraf marked this pull request as ready for review August 7, 2026 00:54
@AarryaSaraf
AarryaSaraf requested a review from a team as a code owner August 7, 2026 00:54

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request ensures that pandas batches handed to user-defined functions are NumPy-backed by default, preventing compatibility issues with Arrow-backed pandas (such as missing operators or pd.NA propagation). It introduces a to_numpy_backed utility and an internal arrow_backed_pandas flag to allow Ray's own preprocessors to opt into Arrow-backed pandas for performance. The review feedback highlights a critical performance optimization in to_numpy_backed to avoid reconstructing DataFrames column-by-column when no Arrow-backed columns are present.

Comment thread python/ray/data/_internal/util.py Outdated
Comment on lines +1733 to +1735
if isinstance(data, pd.DataFrame):
converted = {column: to_numpy_backed(data[column]) for column in data.columns}
return pd.DataFrame(converted, index=data.index, columns=data.columns)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Calling to_numpy_backed on a pd.DataFrame currently reconstructs the DataFrame column-by-column even if none of the columns are Arrow-backed (pd.ArrowDtype). Since to_numpy_backed is called on every single pandas batch in _format_batch by default, this introduces a significant and unnecessary CPU/memory overhead for standard NumPy-backed pandas datasets.

We can optimize this by checking if any column is actually Arrow-backed first, and returning the DataFrame directly if not.

Suggested change
if isinstance(data, pd.DataFrame):
converted = {column: to_numpy_backed(data[column]) for column in data.columns}
return pd.DataFrame(converted, index=data.index, columns=data.columns)
if isinstance(data, pd.DataFrame):
if not any(isinstance(dtype, pd.ArrowDtype) for dtype in data.dtypes):
return data
converted = {column: to_numpy_backed(data[column]) for column in data.columns}
return pd.DataFrame(converted, index=data.index, columns=data.columns)

AarryaSaraf and others added 2 commits August 6, 2026 18:24
Since ray-project#63017 (Ray 2.56) `BlockAccessor.to_pandas` returns Arrow-backed
columns, so a missing value is `pd.NA` rather than `np.nan`. `pd.NA`
propagates through comparisons as "unknown" instead of `False`, and it
cannot be stored in a numeric NumPy array. Four preprocessors broke:

- `PowerTransformer` raised `IndexError`, because `series >= 0` yields a
  three-valued `bool[pyarrow]` mask that cannot index a NumPy array.
- `Categorizer` raised `TypeError: boolean value of NA is ambiguous`,
  because `is_null` did not recognize `pd.NA` and so it survived the
  filter into the category sort.
- `Concatenator` silently emitted a column of pickled Python objects
  instead of a tensor, and raised `TypeError: ... not 'NAType'` when
  given an integer `dtype` or `flatten=True`.
- `FeatureHasher` silently emitted pickled objects as well: `0 + pd.NA`
  is `pd.NA`, so a single null poisons every hash bucket.

`is_null` now recognizes `pd.NA` and `pd.NaT`. The other three convert
the columns they operate on through a new `to_numpy_backed` helper, which
reproduces the conversion pandas performs when Arrow-backing is disabled
(`int64` with nulls widens to `float64` with `np.nan`, strings become
`object` with `None`), and is a no-op on already-NumPy-backed input.

All of the tests are new. The existing preprocessor tests build their
input with `from_pandas`, which never goes through `to_pandas`, so none
of them could observe any of this. The new file also covers the
null-typed-column `SimpleImputer` fix from ray-project#65187, which landed without
a regression test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
ray-project#63017 made every pandas batch Arrow-backed, including the batches Ray
hands to user-supplied functions through `map_batches` and `iter_batches`.
Two problems follow, neither of which involves a missing value:

- pandas' Arrow backend leaves `mod`, `rmod`, `divmod` and `rdivmod`
  unimplemented, so `df["x"] % 2` inside a pandas UDF raises
  `NotImplementedError: mod not implemented.`
  (pandas-dev/pandas#58723).
- `to_numpy()` and `.values` on an Arrow-backed frame return
  `dtype=object`, so code that passes the array to NumPy or to a model
  silently receives boxed Python objects instead of a numeric buffer.

Ray cannot audit what a user's function does with the frame, so
`map_batches` and `iter_batches` now convert pandas batches back to
NumPy-backed pandas before calling it. Ray's own preprocessors are
audited for `pd.NA` handling (see the preceding commit) and opt back in
through an internal `arrow_backed_pandas` flag, so they keep the
zero-copy conversion ray-project#63017 added.

`Dataset.to_pandas` also keeps Arrow-backed dtypes. It returns a result
to the driver rather than feeding a user function, and Arrow-backing is
what lets an integer column containing nulls stay integral instead of
widening to float.

No public API signature changes: the flag is threaded through the
existing `Dataset.map_batches_internal` and the private
`DataIterator._iter_batches`.

`test_basic[pandas]` in `block_batching/test_block_batching.py` asserted
the old behavior by comparing against `ArrowBlockAccessor.to_pandas()`,
so it now expects a NumPy-backed frame, and a new sibling test pins the
opt-in side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
@AarryaSaraf
AarryaSaraf force-pushed the data-fix-null-type-arrow-backed-to-pandas branch from 57a1773 to 3835e3c Compare August 7, 2026 01:24

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 3835e3c. Configure here.

Comment thread python/ray/data/_internal/util.py Outdated
`to_numpy_backed` rebuilt a `DataFrame` by indexing each column by name.
With a duplicate column name, `data[name]` returns a `DataFrame` rather
than a `Series`, so the recursive call re-entered the frame branch with
the same shape and hit `RecursionError`.

No Ray path can deliver such a frame today -- `from_arrow` raises
`KeyError: 'Field "a" exists 2 times in schema'`, `read_parquet` raises
`ArrowInvalid`, and a UDF returning one dies earlier in
`pandas_block.py` -- but `to_numpy_backed` is a general utility and
should not recurse forever on a valid `DataFrame`.

Index positionally instead: `iloc` always yields a `Series`, integer
keys keep the reassembled frame unambiguous, and the original column
labels are restored afterwards.

Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

data Ray Data-related issues go add ONLY when ready to merge, run all tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant