Skip to content

Return None for a null row of a dynamic-shape ArrayXD column - #8524

Open
Kayvan-Zahiri wants to merge 2 commits into
huggingface:mainfrom
Kayvan-Zahiri:fix-arrayxd-null-row
Open

Return None for a null row of a dynamic-shape ArrayXD column#8524
Kayvan-Zahiri wants to merge 2 commits into
huggingface:mainfrom
Kayvan-Zahiri:fix-arrayxd-null-row

Conversation

@Kayvan-Zahiri

Copy link
Copy Markdown

ArrayExtensionArray.to_pylist builds the python values from to_numpy, where a null row is a bare np.nan. That is fine for the numpy format but not for the python one, and the guard added in #8363 only covered a fixed first dimension.

So for a column like Array2D(dtype="int32", shape=(None, 2)) with a None row:

ds[1]        # {'foo': nan}   instead of {'foo': None}
ds.to_dict() # AttributeError: 'float' object has no attribute 'tolist'

The same crash hits to_list, ds[:], Dataset.iter, map and filter. The identical column declared with a fixed shape returns None correctly. Dynamic first dimensions are a documented feature and test_array_xd_with_none already covers them.

The null guard is really about nulls, not about the shape, so it now runs before the numpy conversion and covers both.

PandasArrayExtensionArray.isna met the same scalar nan from the other side: pd.isna returns a plain bool for it and bool has no .any(), which broke Dataset.to_csv and DataFrame.isna().

Two regression tests added next to the existing ones. The numpy, pandas and arrow formats are unchanged, verified across all twelve ArrayXD value dtypes.

to_pylist ran the column through to_numpy first, where a null row becomes a
bare np.nan. With a dynamic first dimension that nan reached the python read
path: a lone null row came back as float nan, and a batch mixing a null with a
non-null row raised AttributeError on nan.tolist(), so to_dict, to_list, ds[:],
iter and filter all failed. The null guard that already handled this for a
fixed shape now runs before the numpy conversion, which covers both.

PandasArrayExtensionArray.isna hit the same scalar nan from the other side:
pd.isna returns a plain bool for it, and bool has no .any(), which took
Dataset.to_csv and DataFrame.isna() down with it.

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

I found one correctness issue in the pandas-formatting part of this fix. The Python-format change behaves correctly for the tested null-row cases, but the scalar-safe isna reduction still conflates an inner NaN with top-level ArrayXD nullness, so the newly working to_csv path can silently blank a present array value. Details inline.

Comment thread src/datasets/features/features.py Outdated
return np.array([pd.isna(arr).any() for arr in self._data])
# A null row of a dynamic-shape ArrayXD column is a scalar np.nan rather than an
# array, and the plain bool `pd.isna` returns for it has no `.any()`.
return np.array([np.any(pd.isna(arr)) for arr in self._data])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P2] Preserve present ArrayXD rows that contain NaN

For a dynamic/ragged float column, _data is an object array where a top-level null is scalar np.nan but each present row is an ndarray. Reducing pd.isna(arr) with np.any therefore marks a present row missing as soon as one element is NaN. With Array2D((None, 2), "float32") and [[[1.0, np.nan]], None, [[5.0, 6.0]]], Arrow's top-level null mask is [False, True, False], but this line returns [True, True, False]; on this head, to_csv() writes both of the first two cells as empty strings. Since ExtensionArray.isna() is a per-value mask, could the object/ragged path identify only the scalar missing sentinel (or preserve Arrow's validity bitmap) and add an inner-NaN regression test?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch, that is silent data loss and you are right that isna is a per-value mask. Fixed in 9746085.

Your repro, before and after, against Arrow's own validity:

                arrow      isna (before)   isna (after)
dynamic   [F, T, F]        [T, T, F]       [F, T, F]
csv row 0 was ""                           now [[ 1. nan]]

The dynamic path is object dtype, where a missing row is a scalar sentinel and present rows are arrays, so it now tests for the sentinel rather than reducing over the row:

if self._data.dtype == object:
    return np.array([not isinstance(arr, np.ndarray) and bool(pd.isna(arr)) for arr in self._data])
return np.array([pd.isna(arr).any() for arr in self._data])

I left the fixed-shape branch alone deliberately. There _data is a float ndarray and the null row is already materialised as NaNs, so nothing at that point can tell a null row from a row that happens to be all NaN. Preserving the validity bitmap would fix it properly, but that is a wider change than this PR and I would rather not fold it in. Say the word if you want it as a follow-up.

Added test_array_xd_dynamic_shape_isna_keeps_rows_with_inner_nan, which pins isna against column[i].is_valid and asserts the present NaN row survives to_csv. It fails on the previous head.

tests/features + tests/test_table.py: 646 passed, 106 skipped. ruff clean.

isna is a per-value mask, so reducing pd.isna over a present row marked it
missing as soon as one element was NaN, and to_csv blanked the cell. On the
dynamic-shape path a missing row is a scalar sentinel, so test for that instead.
The fixed-shape path is left alone: its null row is materialised as NaNs and the
validity bitmap is already gone.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants