Files
ray-project--ray/python/ray/data/tests/test_pandas_block.py
T
2026-07-13 13:17:40 +08:00

541 lines
19 KiB
Python

import pickle
import random
import sys
import numpy as np
import pandas as pd
import pyarrow as pa
import pytest
from packaging.version import parse as parse_version
import ray
import ray.data
from ray.data._internal.pandas_block import (
PandasBlockAccessor,
PandasBlockBuilder,
PandasBlockColumnAccessor,
)
from ray.data._internal.util import is_null
from ray.data._internal.utils.arrow_utils import get_pyarrow_version
from ray.data.context import DataContext
# Set seed for the test for size as it related to sampling
np.random.seed(42)
def simple_series(null):
return pd.Series([1, 2, null, 6])
@pytest.mark.parametrize("arr", [simple_series(None), simple_series(np.nan)])
class TestPandasBlockColumnAccessor:
@pytest.mark.parametrize(
"ignore_nulls, expected",
[
(True, 3),
(False, 4),
],
)
def test_count(self, arr, ignore_nulls, expected):
accessor = PandasBlockColumnAccessor(arr)
result = accessor.count(ignore_nulls=ignore_nulls, as_py=True)
assert result == expected
@pytest.mark.parametrize(
"ignore_nulls, expected",
[
(True, 9),
(False, np.nan),
],
)
def test_sum(self, arr, ignore_nulls, expected):
accessor = PandasBlockColumnAccessor(arr)
result = accessor.sum(ignore_nulls=ignore_nulls, as_py=True)
assert result == expected or is_null(result) and is_null(expected)
@pytest.mark.parametrize(
"ignore_nulls, expected",
[
(True, 1),
(False, np.nan),
],
)
def test_min(self, arr, ignore_nulls, expected):
accessor = PandasBlockColumnAccessor(arr)
result = accessor.min(ignore_nulls=ignore_nulls, as_py=True)
assert result == expected or is_null(result) and is_null(expected)
@pytest.mark.parametrize(
"ignore_nulls, expected",
[
(True, 6),
(False, np.nan),
],
)
def test_max(self, arr, ignore_nulls, expected):
accessor = PandasBlockColumnAccessor(arr)
result = accessor.max(ignore_nulls=ignore_nulls, as_py=True)
assert result == expected or is_null(result) and is_null(expected)
@pytest.mark.parametrize(
"ignore_nulls, expected",
[
(True, 3.0),
(False, np.nan),
],
)
def test_mean(self, arr, ignore_nulls, expected):
accessor = PandasBlockColumnAccessor(arr)
result = accessor.mean(ignore_nulls=ignore_nulls, as_py=True)
assert result == expected or is_null(result) and is_null(expected)
@pytest.mark.parametrize(
"provided_mean, expected",
[
(3.0, 14.0),
(None, 14.0),
],
)
def test_sum_of_squared_diffs_from_mean(self, arr, provided_mean, expected):
accessor = PandasBlockColumnAccessor(arr)
result = accessor.sum_of_squared_diffs_from_mean(
ignore_nulls=True, mean=provided_mean, as_py=True
)
assert result == expected or is_null(result) and is_null(expected)
def test_to_pylist(self, arr):
accessor = PandasBlockColumnAccessor(arr)
result = accessor.to_pylist()
expected = arr.to_list()
assert all(
[a == b or is_null(a) and is_null(b) for a, b in zip(expected, result)]
)
class TestPandasBlockColumnAccessorAllNullSeries:
@pytest.fixture
def all_null_series(self):
return pd.Series([None] * 3, dtype=np.float64)
def test_count_all_null(self, all_null_series):
accessor = PandasBlockColumnAccessor(all_null_series)
# When ignoring nulls, count should be 0; otherwise, count returns length.
assert accessor.count(ignore_nulls=True, as_py=True) == 0
assert accessor.count(ignore_nulls=False, as_py=True) == len(all_null_series)
@pytest.mark.parametrize("ignore_nulls", [True, False])
def test_sum_all_null(self, all_null_series, ignore_nulls):
accessor = PandasBlockColumnAccessor(all_null_series)
result = accessor.sum(ignore_nulls=ignore_nulls)
assert is_null(result)
@pytest.mark.parametrize("ignore_nulls", [True, False])
def test_min_all_null(self, all_null_series, ignore_nulls):
accessor = PandasBlockColumnAccessor(all_null_series)
result = accessor.min(ignore_nulls=ignore_nulls, as_py=True)
assert is_null(result)
@pytest.mark.parametrize("ignore_nulls", [True, False])
def test_max_all_null(self, all_null_series, ignore_nulls):
accessor = PandasBlockColumnAccessor(all_null_series)
result = accessor.max(ignore_nulls=ignore_nulls)
assert is_null(result)
@pytest.mark.parametrize("ignore_nulls", [True, False])
def test_mean_all_null(self, all_null_series, ignore_nulls):
accessor = PandasBlockColumnAccessor(all_null_series)
result = accessor.mean(ignore_nulls=ignore_nulls)
assert is_null(result)
@pytest.mark.parametrize("ignore_nulls", [True, False])
def test_sum_of_squared_diffs_all_null(self, all_null_series, ignore_nulls):
accessor = PandasBlockColumnAccessor(all_null_series)
result = accessor.sum_of_squared_diffs_from_mean(
ignore_nulls=ignore_nulls, mean=None
)
assert is_null(result)
@pytest.mark.parametrize(
"input_block, fill_column_name, fill_value, expected_output_block",
[
(
pd.DataFrame({"a": [0, 1]}),
"b",
2,
pd.DataFrame({"a": [0, 1], "b": [2, 2]}),
),
],
)
def test_fill_column(input_block, fill_column_name, fill_value, expected_output_block):
block_accessor = PandasBlockAccessor.for_block(input_block)
actual_output_block = block_accessor.fill_column(fill_column_name, fill_value)
assert actual_output_block.equals(expected_output_block)
def test_pandas_block_timestamp_ns(ray_start_regular_shared):
# Input data with nanosecond precision timestamps
data_rows = [
{"col1": 1, "col2": pd.Timestamp("2023-01-01T00:00:00.123456789")},
{"col1": 2, "col2": pd.Timestamp("2023-01-01T01:15:30.987654321")},
{"col1": 3, "col2": pd.Timestamp("2023-01-01T02:30:15.111111111")},
{"col1": 4, "col2": pd.Timestamp("2023-01-01T03:45:45.222222222")},
{"col1": 5, "col2": pd.Timestamp("2023-01-01T05:00:00.333333333")},
]
# Initialize PandasBlockBuilder
pandas_builder = PandasBlockBuilder()
for row in data_rows:
pandas_builder.add(row)
pandas_block = pandas_builder.build()
assert pd.api.types.is_datetime64_ns_dtype(pandas_block["col2"])
for original_row, result_row in zip(
data_rows, pandas_block.to_dict(orient="records")
):
assert (
original_row["col2"] == result_row["col2"]
), "Timestamp mismatch in PandasBlockBuilder output"
def test_dict_and_none_use_arrow_block(ray_start_regular_shared, restore_data_context):
# Dicts are represented as Arrow struct types, so the block should remain Arrow
# even if object-extension fallback is disabled.
DataContext.get_current().enable_fallback_to_arrow_object_ext_type = False
def fn(batch):
batch["data_dict"] = [{"data": 0} for _ in range(len(batch["id"]))]
return batch
ds = ray.data.range(10).map_batches(fn)
ds = ds.materialize()
block = ray.get(next(ds.iter_internal_ref_bundles()).block_refs[0])
assert isinstance(block, pa.Table)
assert block.schema.field("data_dict").type == pa.struct([("data", pa.int64())])
def fn2(batch):
batch["data_none"] = [None for _ in range(len(batch["id"]))]
return batch
ds2 = ray.data.range(10).map_batches(fn2)
ds2 = ds2.materialize()
block = ray.get(next(ds2.iter_internal_ref_bundles()).block_refs[0])
assert isinstance(block, pa.Table)
class TestSizeBytes:
def test_small(ray_start_regular_shared):
animals = ["Flamingo", "Centipede"]
block = pd.DataFrame({"animals": animals})
block_accessor = PandasBlockAccessor.for_block(block)
bytes_size = block_accessor.size_bytes()
# check that memory usage is within 10% of the size_bytes
# For strings, Pandas seems to be fairly accurate, so let's use that.
memory_usage = block.memory_usage(index=True, deep=True).sum()
assert bytes_size == pytest.approx(memory_usage, rel=0.1), (
bytes_size,
memory_usage,
)
def test_large_str(ray_start_regular_shared):
animals = [
random.choice(["alligator", "crocodile", "centipede", "flamingo"])
for i in range(100_000)
]
block = pd.DataFrame({"animals": animals})
block["animals"] = block["animals"].astype("string")
block_accessor = PandasBlockAccessor.for_block(block)
bytes_size = block_accessor.size_bytes()
memory_usage = block.memory_usage(index=True, deep=True).sum()
assert bytes_size == pytest.approx(memory_usage, rel=0.1), (
bytes_size,
memory_usage,
)
def test_large_str_object(ray_start_regular_shared):
"""Note - this test breaks if you refactor/move the list of animals."""
num = 100_000
animals = [
random.choice(["alligator", "crocodile", "centipede", "flamingo"])
for i in range(num)
]
block = pd.DataFrame({"animals": animals})
block_accessor = PandasBlockAccessor.for_block(block)
bytes_size = block_accessor.size_bytes()
# The actual usage should be the index usage + the string data usage.
memory_usage = block.memory_usage(index=True, deep=False).sum() + sum(
[sys.getsizeof(animal) for animal in animals]
)
assert bytes_size == pytest.approx(memory_usage, rel=0.1), (
bytes_size,
memory_usage,
)
def test_large_floats(ray_start_regular_shared):
animals = [random.random() for i in range(100_000)]
block = pd.DataFrame({"animals": animals})
block_accessor = PandasBlockAccessor.for_block(block)
bytes_size = block_accessor.size_bytes()
memory_usage = pickle.dumps(block).__sizeof__()
# check that memory usage is within 10% of the size_bytes
assert bytes_size == pytest.approx(memory_usage, rel=0.1), (
bytes_size,
memory_usage,
)
def test_bytes_object(ray_start_regular_shared):
def generate_data(batch):
for _ in range(8):
yield {"data": [[b"\x00" * 128 * 1024 * 128]]}
ds = (
ray.data.range(1, override_num_blocks=1)
.map_batches(generate_data, batch_size=1)
.map_batches(lambda batch: batch, batch_format="pandas")
)
true_value = 128 * 1024 * 128 * 8
for bundle in ds.iter_internal_ref_bundles():
size = bundle.size_bytes()
# assert that true_value is within 10% of bundle.size_bytes()
assert size == pytest.approx(true_value, rel=0.1), (
size,
true_value,
)
def test_nested_numpy(ray_start_regular_shared):
size = 1024
rows = 1_000
data = [
np.random.randint(size=size, low=0, high=100, dtype=np.int8)
for _ in range(rows)
]
df = pd.DataFrame({"data": data})
block_accessor = PandasBlockAccessor.for_block(df)
block_size = block_accessor.size_bytes()
true_value = rows * size
assert block_size == pytest.approx(true_value, rel=0.1), (
block_size,
true_value,
)
def test_nested_objects(ray_start_regular_shared):
size = 10
rows = 10_000
lists = [[random.randint(0, 100) for _ in range(size)] for _ in range(rows)]
data = {"lists": lists}
block = pd.DataFrame(data)
block_accessor = PandasBlockAccessor.for_block(block)
bytes_size = block_accessor.size_bytes()
# List overhead + 10 integers per list
true_size = rows * (
sys.getsizeof([random.randint(0, 100) for _ in range(size)]) + size * 28
)
assert bytes_size == pytest.approx(true_size, rel=0.1), (
bytes_size,
true_size,
)
def test_mixed_types(ray_start_regular_shared):
rows = 10_000
data = {
"integers": [random.randint(0, 100) for _ in range(rows)],
"floats": [random.random() for _ in range(rows)],
"strings": [
random.choice(["apple", "banana", "cherry"]) for _ in range(rows)
],
"object": [b"\x00" * 128 for _ in range(rows)],
}
block = pd.DataFrame(data)
block_accessor = PandasBlockAccessor.for_block(block)
bytes_size = block_accessor.size_bytes()
# Manually calculate the size
int_size = rows * 8
float_size = rows * 8
str_size = sum(sys.getsizeof(string) for string in data["strings"])
object_size = rows * sys.getsizeof(b"\x00" * 128)
true_size = int_size + float_size + str_size + object_size
assert bytes_size == pytest.approx(true_size, rel=0.1), (bytes_size, true_size)
def test_nested_lists_strings(ray_start_regular_shared):
rows = 5_000
nested_lists = ["a"] * 3 + ["bb"] * 4 + ["ccc"] * 3
data = {
"nested_lists": [nested_lists for _ in range(rows)],
}
block = pd.DataFrame(data)
block_accessor = PandasBlockAccessor.for_block(block)
bytes_size = block_accessor.size_bytes()
# Manually calculate the size
list_overhead = sys.getsizeof(block["nested_lists"].iloc[0]) + sum(
[sys.getsizeof(x) for x in nested_lists]
)
true_size = rows * list_overhead
assert bytes_size == pytest.approx(true_size, rel=0.1), (bytes_size, true_size)
@pytest.mark.parametrize("size", [10, 1024])
def test_multi_level_nesting(ray_start_regular_shared, size):
rows = 1_000
data = {
"complex": [
{"list": [np.random.rand(size)], "value": {"key": "val"}}
for _ in range(rows)
],
}
block = pd.DataFrame(data)
block_accessor = PandasBlockAccessor.for_block(block)
bytes_size = block_accessor.size_bytes()
numpy_size = np.random.rand(size).nbytes
values = ["list", "value", "key", "val"]
str_size = sum([sys.getsizeof(v) for v in values])
list_ref_overhead = sys.getsizeof([np.random.rand(size)])
dict_overhead1 = sys.getsizeof({"key": "val"})
dict_overhead3 = sys.getsizeof(
{"list": [np.random.rand(size)], "value": {"key": "val"}}
)
true_size = (
numpy_size + str_size + list_ref_overhead + dict_overhead1 + dict_overhead3
) * rows
assert bytes_size == pytest.approx(true_size, rel=0.15), (
bytes_size,
true_size,
)
def test_boolean(ray_start_regular_shared):
data = [random.choice([True, False, None]) for _ in range(100_000)]
block = pd.DataFrame({"flags": pd.Series(data, dtype="boolean")})
block_accessor = PandasBlockAccessor.for_block(block)
bytes_size = block_accessor.size_bytes()
# No object case
true_size = block.memory_usage(index=True, deep=True).sum()
assert bytes_size == pytest.approx(true_size, rel=0.1), (bytes_size, true_size)
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("10.0.1"),
reason="ArrowDtype requires pyarrow>=10.0.1",
)
def test_arrow(ray_start_regular_shared):
data = [
random.choice(["alligator", "crocodile", "flamingo"]) for _ in range(50_000)
]
arrow_dtype = pd.ArrowDtype(pa.string())
block = pd.DataFrame({"animals": pd.Series(data, dtype=arrow_dtype)})
block_accessor = PandasBlockAccessor.for_block(block)
bytes_size = block_accessor.size_bytes()
true_size = block.memory_usage(index=True, deep=False).sum() + sum(
sys.getsizeof(x) for x in data
)
assert bytes_size == pytest.approx(true_size, rel=0.1), (bytes_size, true_size)
def test_deterministic_across_blocks(self):
"""size_bytes() must return the same value for two blocks holding
identical data. Non-determinism here can cause a streaming generator
task to produce different block counts across replay attempts (e.g.
lineage reconstruction), since each attempt rebuilds the block and
re-estimates its size. That surfaces as a silent hang or silent data
loss downstream.
"""
# Use enough rows to trigger sampling (sample_size < total_size), and
# vary the string lengths so a different sample yields a different
# estimate (this is what makes the non-deterministic case observable).
data = [f"str_{i}" for i in range(10_000)]
block1 = pd.DataFrame({"col": pd.Series(data, dtype="string")})
block2 = pd.DataFrame({"col": pd.Series(data, dtype="string")})
first = PandasBlockAccessor.for_block(block1).size_bytes()
second = PandasBlockAccessor.for_block(block2).size_bytes()
assert (
first == second
), f"size_bytes() is non-deterministic: first={first}, second={second}"
def test_iter_rows_with_na(ray_start_regular_shared):
block = pd.DataFrame({"col": [pd.NA]})
block_accessor = PandasBlockAccessor.for_block(block)
rows = block_accessor.iter_rows(public_row_format=True)
# We should return None for NaN values.
assert list(rows) == [{"col": None}]
def test_empty_dataframe_with_object_columns(ray_start_regular_shared):
"""Test that size_bytes handles empty DataFrames with object/string columns.
The warning log:
"Error calculating size for column 'parent': cannot call `vectorize`
on size 0 inputs unless `otypes` is set"
should not be logged in the presence of empty columns.
"""
from unittest.mock import patch
# Create an empty DataFrame but with defined columns and dtypes
block = pd.DataFrame(
{
"parent": pd.Series([], dtype=object),
"child": pd.Series([], dtype="string"),
"data": pd.Series([], dtype=object),
}
)
block_accessor = PandasBlockAccessor.for_block(block)
# Check that NO warning is logged after calling size_bytes
with patch("ray.data._internal.pandas_block.logger.warning") as mock_warning:
bytes_size = block_accessor.size_bytes()
mock_warning.assert_not_called()
assert bytes_size >= 0
def test_tensor_column_with_all_nan_preserves_type(ray_start_regular_shared):
from ray.data.extensions import ArrowTensorType, ArrowTensorTypeV2, TensorArray
# Create a DataFrame with all-NaN tensor column
df = pd.DataFrame(
{"foo": TensorArray([np.array([np.nan, np.nan]), np.array([np.nan, np.nan])])}
)
block_accessor = PandasBlockAccessor.for_block(df)
arrow_table = block_accessor.to_arrow()
# The column should preserve tensor type
assert isinstance(
arrow_table.schema.field("foo").type, (ArrowTensorType, ArrowTensorTypeV2)
), "TensorDtype column with all-NaN values should preserve tensor type"
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))