[Data] Preserve schema for empty datasets in schema unification and to_pandas() - #65539
[Data] Preserve schema for empty datasets in schema unification and to_pandas()#65539lonexreb wants to merge 5 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request addresses issue #59946 by ensuring that empty datasets preserve their schema's columns and dtypes when converted to pandas DataFrames, and that schemas are correctly unified when all blocks or bundles are empty. The review feedback highlights critical issues in the implementation: pyarrow.Schema lacks an empty_table() method, which will cause an AttributeError, and retrieving column types from base_schema for pandas-backed datasets will fail and fall back to losing dtypes. Additionally, the reviewer suggests caching bundle.num_rows() to avoid duplicate calls and adding a test case for a 0-block empty dataset to ensure full coverage of the fallback path.
| base_schema = getattr(schema, "base_schema", None) | ||
| if isinstance(base_schema, pa.Schema): | ||
| # Route the empty Arrow table through the same | ||
| # `BlockAccessor.to_pandas()` path used for non-empty blocks | ||
| # so the two agree on column types (types_mapper, tensor | ||
| # casting, etc.). | ||
| df = BlockAccessor.for_block(base_schema.empty_table()).to_pandas() |
There was a problem hiding this comment.
The pyarrow.Schema class does not have an empty_table() method. Calling base_schema.empty_table() will raise an AttributeError when executing this path (e.g., for a 0-block empty dataset like ray.data.range(0)). Instead, use pyarrow.Table.from_batches([], schema=base_schema) to create an empty table with the given schema.
| base_schema = getattr(schema, "base_schema", None) | |
| if isinstance(base_schema, pa.Schema): | |
| # Route the empty Arrow table through the same | |
| # `BlockAccessor.to_pandas()` path used for non-empty blocks | |
| # so the two agree on column types (types_mapper, tensor | |
| # casting, etc.). | |
| df = BlockAccessor.for_block(base_schema.empty_table()).to_pandas() | |
| base_schema = getattr(schema, "base_schema", None) | |
| if isinstance(base_schema, pa.Schema): | |
| # Route the empty Arrow table through the same | |
| # `BlockAccessor.to_pandas()` path used for non-empty blocks | |
| # so the two agree on column types (types_mapper, tensor | |
| # casting, etc.). | |
| df = BlockAccessor.for_block(pa.Table.from_batches([], schema=base_schema)).to_pandas() |
| # Pandas-backed schema: preserve per-column dtypes when known. | ||
| types = getattr(base_schema, "types", None) | ||
| if types is not None and len(types) == len(schema.names): | ||
| df = pandas.DataFrame( | ||
| { | ||
| name: pandas.Series([], dtype=dtype) | ||
| for name, dtype in zip(schema.names, types) | ||
| } | ||
| ) | ||
| else: | ||
| df = pandas.DataFrame(columns=list(schema.names)) |
There was a problem hiding this comment.
For pandas-backed datasets, base_schema is typically a pandas.Index or pandas.Series of dtypes, neither of which has a types attribute. Thus, getattr(base_schema, "types", None) will return None, causing it to always fall back to the else block and lose the dtypes. Instead, use schema.types directly, which is a standard property of the Ray Schema class and is guaranteed to contain the column types.
| # Pandas-backed schema: preserve per-column dtypes when known. | |
| types = getattr(base_schema, "types", None) | |
| if types is not None and len(types) == len(schema.names): | |
| df = pandas.DataFrame( | |
| { | |
| name: pandas.Series([], dtype=dtype) | |
| for name, dtype in zip(schema.names, types) | |
| } | |
| ) | |
| else: | |
| df = pandas.DataFrame(columns=list(schema.names)) | |
| # Pandas-backed schema: preserve per-column dtypes when known. | |
| types = schema.types | |
| if types and len(types) == len(schema.names): | |
| df = pandas.DataFrame( | |
| { | |
| name: pandas.Series([], dtype=dtype) | |
| for name, dtype in zip(schema.names, types) | |
| } | |
| ) | |
| else: | |
| df = pandas.DataFrame(columns=list(schema.names)) |
| for bundle in ref_bundles: | ||
| if bundle.schema is not None and ( | ||
| bundle.num_rows() is None or bundle.num_rows() > 0 | ||
| ): | ||
| if bundle.schema is None: | ||
| continue | ||
| if bundle.num_rows() is None or bundle.num_rows() > 0: | ||
| schemas_to_unify.append(bundle.schema) | ||
| else: | ||
| empty_bundle_schemas.append(bundle.schema) |
There was a problem hiding this comment.
Calling bundle.num_rows() twice in the condition can be optimized by caching the result in a local variable num_rows.
| for bundle in ref_bundles: | |
| if bundle.schema is not None and ( | |
| bundle.num_rows() is None or bundle.num_rows() > 0 | |
| ): | |
| if bundle.schema is None: | |
| continue | |
| if bundle.num_rows() is None or bundle.num_rows() > 0: | |
| schemas_to_unify.append(bundle.schema) | |
| else: | |
| empty_bundle_schemas.append(bundle.schema) | |
| for bundle in ref_bundles: | |
| if bundle.schema is None: | |
| continue | |
| num_rows = bundle.num_rows() | |
| if num_rows is None or num_rows > 0: | |
| schemas_to_unify.append(bundle.schema) | |
| else: | |
| empty_bundle_schemas.append(bundle.schema) |
| # Pandas-backed empty datasets preserve columns and dtypes too. | ||
| pandas_df = pd.DataFrame( | ||
| {"a": pd.Series([], dtype="int64"), "b": pd.Series([], dtype="float64")} | ||
| ) | ||
| ds3 = ray.data.from_pandas(pandas_df) | ||
| df3 = ds3.to_pandas() | ||
| assert list(df3.columns) == ["a", "b"] | ||
| assert df3["a"].dtype == np.int64 | ||
| assert df3["b"].dtype == np.float64 |
There was a problem hiding this comment.
The current tests only cover empty datasets with 1 empty block (since from_arrow_refs and from_pandas with empty tables/DataFrames still create 1 block). In these cases, df.shape[1] is not 0, so the fallback code path in to_pandas() is never executed. To ensure the fallback code path is actually tested and works (especially for 0-block datasets), we should add test cases for 0-block datasets (e.g., using ray.data.range(0)).
| # Pandas-backed empty datasets preserve columns and dtypes too. | |
| pandas_df = pd.DataFrame( | |
| {"a": pd.Series([], dtype="int64"), "b": pd.Series([], dtype="float64")} | |
| ) | |
| ds3 = ray.data.from_pandas(pandas_df) | |
| df3 = ds3.to_pandas() | |
| assert list(df3.columns) == ["a", "b"] | |
| assert df3["a"].dtype == np.int64 | |
| assert df3["b"].dtype == np.float64 | |
| # Pandas-backed empty datasets preserve columns and dtypes too. | |
| pandas_df = pd.DataFrame( | |
| {"a": pd.Series([], dtype="int64"), "b": pd.Series([], dtype="float64")} | |
| ) | |
| ds3 = ray.data.from_pandas(pandas_df) | |
| df3 = ds3.to_pandas() | |
| assert list(df3.columns) == ["a", "b"] | |
| assert df3["a"].dtype == np.int64 | |
| assert df3["b"].dtype == np.float64 | |
| # Test 0-block empty dataset (Arrow-backed) to ensure the fallback path is covered. | |
| ds_zero = ray.data.range(0) | |
| df_zero = ds_zero.to_pandas() | |
| assert list(df_zero.columns) == ["id"] | |
| assert df_zero["id"].dtype == np.int64 |
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 37f7e66. Configure here.
|
Thanks for the reviews — addressed in 682e386. Point-by-point:
All 42 tests in |
`Dataset.to_pandas()` builds the result by iterating `iter_batches`, which yields no batches for an empty dataset. The `PandasBlockBuilder` then produces a DataFrame with zero columns, so e.g. `from_arrow_refs([empty_table_with_columns]).to_pandas()` returned a frame with no columns even though the dataset's schema is known (issue ray-project#59946). When the built DataFrame has no columns, reconstruct the columns (and dtypes, when the underlying schema is an Arrow schema) from the dataset schema. Closes ray-project#59946 Signed-off-by: lonexreb <reach2shubhankar@gmail.com>
Address review feedback: when the empty dataset's schema is a PandasBlockSchema, build the empty DataFrame with per-column dtypes from schema.types instead of falling back to object dtype. Expand the test to assert dtypes for both Arrow-backed and Pandas-backed empty datasets. Signed-off-by: lonexreb <reach2shubhankar@gmail.com>
… parity Address review feedback (cursor): reconstruct the empty Arrow-backed result via BlockAccessor.for_block(...).to_pandas() instead of pyarrow's empty_table().to_pandas() so empty and non-empty results agree on column dtypes (e.g. both use the Arrow-backed int32[pyarrow] dtype). Update the test to assert the empty dtype matches a non-empty dataset of the same schema. Signed-off-by: lonexreb <reach2shubhankar@gmail.com>
unify_block_metadata_schema and unify_ref_bundles_schema skip blocks with num_rows == 0, so a dataset made only of empty blocks (e.g. from_arrow_refs on an empty table) reported no schema even though an empty Arrow table carries a valid one. Use the empty blocks' schemas as a fallback when no non-empty block provides a schema. Issue ray-project#59946 Signed-off-by: lonexreb <reach2shubhankar@gmail.com>
- Route the pandas-backed empty DataFrame through the same BlockAccessor.to_pandas() conversion used for non-empty blocks so internal extension dtypes (e.g. TensorDtype) convert identically, with a guard for dtypes that cannot back an empty Series. - Cache bundle.num_rows() in unify_ref_bundles_schema. - Add 0-block dataset coverage to the to_pandas regression test. Signed-off-by: lonexreb <reach2shubhankar@gmail.com>
682e386 to
b0dfb18
Compare

Why are these changes needed?
Dataset.to_pandas()(and schema propagation generally) drops columns for an empty dataset:Two layers cause this:
unify_block_metadata_schema()/unify_ref_bundles_schema()skip blocks withnum_rows == 0, so a dataset made only of empty blocks reports no schema — even though an empty Arrow table carries a perfectly valid one (as noted by @mango766 on the issue). This PR makes empty-block schemas a fallback when no non-empty block provides a schema; non-empty blocks still take precedence.to_pandas()builds its result fromiter_batches(), which yields no batches for an empty dataset, soPandasBlockBuilder.build()produced a DataFrame with zero columns. This PR reconstructs columns and dtypes from the dataset schema, routing Arrow-backed schemas through the sameBlockAccessor.to_pandas()path used for non-empty blocks so empty and non-empty results agree on dtypes.This supersedes #64721 (same fix for layer 2, closed by the stale bot after both bot-review comments had been addressed; GitHub would not allow reopening it). This PR carries those commits, rebased on current master, plus the new metadata-layer fix.
Related issue number
Fixes #59946
Checks
black==22.10.0per repo pin) on changed files.test_to_pandas_empty_dataset_preserves_columns(Arrow-backed and pandas-backed empty datasets, dtype parity with non-empty datasets).test_unify_block_metadata_schema_all_empty_blocks(fallback + precedence).setup-dev.pysymlinks:pytest python/ray/data/tests/test_util.py→ 39 passedpytest python/ray/data/tests/test_arrow_block.py -k to_pandas→ 13 passedNotes for reviewers / AI-assistance disclosure