[Data] Fix Arrow-backed pandas regressions in preprocessors and pandas batches - #65272
[Data] Fix Arrow-backed pandas regressions in preprocessors and pandas batches#65272AarryaSaraf wants to merge 3 commits into
Conversation
44161d5 to
3b845a4
Compare
f676a8e to
a4a7116
Compare
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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.
| 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) |
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>
57a1773 to
3835e3c
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Reviewed by Cursor Bugbot for commit 3835e3c. Configure here.
`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>

Description
Ray 2.56 (#63017) made
BlockAccessor.to_pandasreturn Arrow-backed pandas columns(
pd.ArrowDtype) instead of copying into NumPy arrays. That removed a copy, but it alsochanged two things downstream code relied on:
pd.NAinstead ofnp.nan.np.nananswers everycomparison with
False;pd.NAanswers "unknown", producing a three-valued mask, andit cannot be stored in a numeric NumPy array.
%,divmod,rmodandrdivmodare unimplemented(pandas-dev/pandas#58723), and
to_numpy()/.valuesdegrade todtype=object.Six user-visible regressions followed:
PowerTransformerraisedIndexError: only integers, slices ... are valid indices—series >= 0yields a three-valuedbool[pyarrow]mask that cannot index a NumPy array.CategorizerraisedTypeError: boolean value of NA is ambiguous—pd.NAsurvived the null filter into the category sort.Concatenatorsilently emitted a column of pickled Python objects instead of a tensor, and raisedTypeError: ... not 'NAType'with an integerdtypeorflatten=True.FeatureHashersilently emitted pickled objects too:0 + pd.NAispd.NA, so one null poisons every hash bucket.df["x"] % 2raisedNotImplementedError: mod not implemented.df[["a", "b"]].to_numpy()silently gotdtype=objectinstead offloat64.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_nullnow recognizespd.NA/pd.NaT, which isthe whole
Categorizerfix.PowerTransformer,ConcatenatorandFeatureHasherconvert the columns they operate on through a new
to_numpy_backedhelper, whichreproduces 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_batchesanditer_batchesnow convert pandas batches back toNumPy-backed pandas before calling it. Ray's own preprocessors are audited (commit 1) and
opt back in through an internal
arrow_backed_pandasflag, so they keep the zero-copyconversion #63017 added.
Dataset.to_pandaskeeps Arrow-backed dtypes as well: it returnsa 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.
pd.NA, covered by testsmap_batches(batch_format="pandas")iter_batches(batch_format="pandas")Dataset.to_pandas()No public API signature changes: the flag is threaded through the existing
Dataset.map_batches_internaland the privateDataIterator._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 above2**53;pd.NAbecomesnp.nan, so a float column no longer distinguishes "missing" from "not a number"; andbool-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()orbatch_format="pyarrow", neither of which converts.Related issues
Related to #64765 (the
SimpleImputerfailure 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")anditer_batches(batch_format="pandas")return NumPy-backed pandas again, as they did before2.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_conversionremains the escape hatch in theother direction; its docstring is updated here, because it previously advised setting it to
Falsespecifically to work around the%failure above.New tests are all in
python/ray/data/tests/test_arrow_backed_pandas.py(56 tests): unitcoverage for
is_nullandto_numpy_backed, per-preprocessor regression tests, and thecontract 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/rangerather thanfrom_pandas,because a pandas block never goes through
to_pandasand so never reaches the code thesebugs live in.