438 lines
15 KiB
Python
438 lines
15 KiB
Python
import uuid as uuid_module
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
import ray
|
|
from ray.data.expressions import RandomExpr, UUIDExpr, col, random, uuid
|
|
from ray.data.tests.conftest import * # noqa
|
|
from ray.tests.conftest import * # noqa
|
|
|
|
|
|
def test_random_expression_creation():
|
|
"""Test that random() creates a RandomExpr with correct fields."""
|
|
# Test without seed
|
|
expr = random()
|
|
assert isinstance(expr, RandomExpr)
|
|
assert expr.seed is None
|
|
assert expr.reseed_after_execution is True
|
|
|
|
# Test with seed
|
|
expr = random(seed=42)
|
|
assert isinstance(expr, RandomExpr)
|
|
assert expr.seed == 42
|
|
assert expr.reseed_after_execution is True
|
|
|
|
# Test with seed and reseed_after_execution=False
|
|
expr = random(seed=42, reseed_after_execution=False)
|
|
assert isinstance(expr, RandomExpr)
|
|
assert expr.seed == 42
|
|
assert expr.reseed_after_execution is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"seed1,seed2,reseed1,reseed2,expected_equal",
|
|
[
|
|
(42, 42, True, True, True),
|
|
(42, 123, True, True, False),
|
|
(None, None, True, True, True),
|
|
(None, None, True, False, False),
|
|
(42, None, True, True, False),
|
|
(42, 42, True, False, False),
|
|
(42, 42, False, False, True),
|
|
],
|
|
)
|
|
def test_random_expression_structural_equality(
|
|
seed1, seed2, reseed1, reseed2, expected_equal
|
|
):
|
|
"""Test structural equality comparison for random expressions."""
|
|
expr1 = random(seed=seed1, reseed_after_execution=reseed1)
|
|
expr2 = random(seed=seed2, reseed_after_execution=reseed2)
|
|
|
|
assert expr1.structurally_equals(expr2) == expected_equal
|
|
assert expr2.structurally_equals(expr1) == expected_equal
|
|
|
|
|
|
def test_random_expression_structural_equality_with_non_random_expr():
|
|
"""Test structural equality comparison with non-synthetic expression."""
|
|
random_expr = random(seed=42)
|
|
non_random_expr = col("id")
|
|
|
|
assert not random_expr.structurally_equals(non_random_expr)
|
|
assert not non_random_expr.structurally_equals(random_expr)
|
|
|
|
|
|
def test_random_values_range(ray_start_regular_shared):
|
|
"""Test that random values are in range [0, 1)."""
|
|
ds = ray.data.range(1000).with_column("rand", random())
|
|
results = ds.take_all()
|
|
|
|
assert len(results) == 1000
|
|
for result in results:
|
|
assert isinstance(result["rand"], (float, np.floating))
|
|
assert 0.0 <= result["rand"] < 1.0
|
|
assert "id" in result # Verify other columns are preserved
|
|
|
|
|
|
def test_random_without_seed_non_deterministic(ray_start_regular_shared):
|
|
"""Test that without seed produces non-deterministic values."""
|
|
ds1 = ray.data.range(100).with_column("rand", random())
|
|
ds2 = ray.data.range(100).with_column("rand", random())
|
|
|
|
values1 = [r["rand"] for r in ds1.take_all()]
|
|
values2 = [r["rand"] for r in ds2.take_all()]
|
|
|
|
# Should be different (very unlikely to be identical)
|
|
assert values1 != values2
|
|
|
|
|
|
@pytest.mark.parametrize("seed", [0, 42, 123])
|
|
@pytest.mark.parametrize("num_blocks", [None, 1, 4, 8])
|
|
def test_random_with_seed_deterministic(ray_start_regular_shared, seed, num_blocks):
|
|
"""Test that with seed produces deterministic values."""
|
|
kwargs = {"override_num_blocks": num_blocks} if num_blocks is not None else {}
|
|
ds1 = ray.data.range(100, **kwargs).with_column("rand", random(seed=seed))
|
|
ds2 = ray.data.range(100, **kwargs).with_column("rand", random(seed=seed))
|
|
|
|
values1 = [r["rand"] for r in ds1.take_all()]
|
|
values2 = [r["rand"] for r in ds2.take_all()]
|
|
|
|
assert values1 == values2
|
|
|
|
|
|
@pytest.mark.parametrize("batch_format", ["default", "pandas", "pyarrow"])
|
|
def test_random_with_different_batch_formats(ray_start_regular_shared, batch_format):
|
|
"""Test random expression works with different batch formats."""
|
|
import pandas as pd
|
|
import pyarrow as pa
|
|
|
|
ds = ray.data.range(100).with_column("rand", random(seed=42))
|
|
|
|
all_values = []
|
|
for batch in ds.iter_batches(batch_format=batch_format):
|
|
rand_col = batch["rand"]
|
|
if batch_format == "pandas" and isinstance(rand_col, pd.Series):
|
|
all_values.extend(rand_col.tolist())
|
|
elif batch_format == "pyarrow" and isinstance(rand_col, pa.ChunkedArray):
|
|
all_values.extend(rand_col.to_pylist()) # type: ignore
|
|
else:
|
|
all_values.extend(
|
|
list(rand_col) if hasattr(rand_col, "__iter__") else [rand_col]
|
|
)
|
|
|
|
assert len(all_values) == 100
|
|
for val in all_values:
|
|
assert 0.0 <= val < 1.0
|
|
|
|
|
|
@pytest.mark.parametrize("op", [random, uuid])
|
|
def test_synthetic_empty_dataset(ray_start_regular_shared, op):
|
|
"""Test synthetic expression with empty dataset."""
|
|
ds = ray.data.range(0).with_column("col", op())
|
|
assert len(ds.take_all()) == 0
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"reseed_after_execution,expected_all_equal",
|
|
[
|
|
(True, False),
|
|
(False, True),
|
|
],
|
|
)
|
|
def test_reproducibility_across_epochs(
|
|
ray_start_regular_shared, reseed_after_execution, expected_all_equal
|
|
):
|
|
"""Test reproducibility across multiple iter_batches() epochs."""
|
|
ds = ray.data.range(100).with_column(
|
|
"rand", random(seed=42, reseed_after_execution=reseed_after_execution)
|
|
)
|
|
|
|
# Collect values from multiple epochs
|
|
epoch_values = []
|
|
for _ in range(3):
|
|
values = []
|
|
for batch in ds.iter_batches():
|
|
rand_col = batch["rand"]
|
|
if isinstance(rand_col, np.ndarray):
|
|
values.extend(rand_col.tolist())
|
|
else:
|
|
values.extend(list(rand_col))
|
|
epoch_values.append(values)
|
|
|
|
if expected_all_equal:
|
|
# Same across epochs when reseed_after_execution=False
|
|
assert epoch_values[0] == epoch_values[1]
|
|
assert epoch_values[1] == epoch_values[2]
|
|
else:
|
|
# Different across epochs when reseed_after_execution=True
|
|
assert epoch_values[0] != epoch_values[1]
|
|
assert epoch_values[1] != epoch_values[2]
|
|
|
|
|
|
@pytest.mark.parametrize("num_blocks1,num_blocks2", [(1, 4), (4, 8)])
|
|
def test_different_num_blocks_produces_different_values(
|
|
ray_start_regular_shared, num_blocks1, num_blocks2
|
|
):
|
|
"""Test that different num_blocks with same seed produces different values."""
|
|
ds1 = ray.data.range(100, override_num_blocks=num_blocks1).with_column(
|
|
"rand", random(seed=42)
|
|
)
|
|
ds2 = ray.data.range(100, override_num_blocks=num_blocks2).with_column(
|
|
"rand", random(seed=42)
|
|
)
|
|
|
|
values1 = [r["rand"] for r in ds1.take_all()]
|
|
values2 = [r["rand"] for r in ds2.take_all()]
|
|
|
|
# Should be different (expected behavior due to different task_idx)
|
|
assert values1 != values2
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"args,kwargs,expected_error,error_message",
|
|
[
|
|
# Too many positional arguments
|
|
(
|
|
(1, 2, 3),
|
|
{},
|
|
TypeError,
|
|
"random\\(\\) takes 0 positional arguments but 3 were given",
|
|
),
|
|
# Keyword "seed" with 1 positional arg (duplicate)
|
|
(
|
|
(42,),
|
|
{"seed": 123},
|
|
TypeError,
|
|
"random\\(\\) takes 0 positional arguments but 1 positional argument",
|
|
),
|
|
# Unexpected keyword argument
|
|
(
|
|
(),
|
|
{"invalid_arg": 42},
|
|
TypeError,
|
|
"random\\(\\) got an unexpected keyword argument 'invalid_arg'",
|
|
),
|
|
(
|
|
(),
|
|
{"seed": 42, "invalid_arg": True},
|
|
TypeError,
|
|
"random\\(\\) got an unexpected keyword argument 'invalid_arg'",
|
|
),
|
|
],
|
|
)
|
|
def test_random_validation_errors(
|
|
ray_start_regular_shared, args, kwargs, expected_error, error_message
|
|
):
|
|
"""Test that random() validates arguments correctly."""
|
|
with pytest.raises(expected_error, match=error_message):
|
|
random(*args, **kwargs)
|
|
|
|
|
|
def test_random_multi_block_per_task(ray_start_regular_shared):
|
|
"""Test that random values are unique when a single task processes multiple blocks.
|
|
|
|
This test verifies the fix for the issue where random() with seed would produce
|
|
duplicate values when a single task processed multiple blocks. Unlike
|
|
monotonically_increasing_id(), which uses a per-task counter in ctx.kwargs to
|
|
differentiate blocks, random() previously had no such mechanism, leading to
|
|
duplicated random data.
|
|
"""
|
|
ctx = ray.data.DataContext.get_current()
|
|
original_max_block_size = ctx.target_max_block_size
|
|
try:
|
|
# Set max block size to 32 bytes ~ 4 int64 rows per block.
|
|
# With 5 read tasks of 20 rows each, every task should see 5 blocks.
|
|
ctx.target_max_block_size = 32
|
|
|
|
ds = ray.data.range(100, override_num_blocks=5)
|
|
ds = ds.with_column("rand", random(seed=42))
|
|
result = ds.take_all()
|
|
|
|
rand_values = [row["rand"] for row in result]
|
|
assert len(rand_values) == 100, f"expected 100 rows, got {len(rand_values)}"
|
|
assert len(rand_values) == len(
|
|
set(rand_values)
|
|
), "Random values are not unique across blocks within the same task"
|
|
finally:
|
|
ctx.target_max_block_size = original_max_block_size
|
|
|
|
|
|
def test_random_with_column_then_random_shuffle_deterministic(ray_start_regular_shared):
|
|
"""Test that random() with seed produces deterministic results even after random_shuffle."""
|
|
from ray.data.expressions import random
|
|
|
|
# Create two identical pipelines
|
|
ds1 = ray.data.range(100).with_column("rand", random(seed=42))
|
|
ds1 = ds1.random_shuffle(seed=1)
|
|
|
|
ds2 = ray.data.range(100).with_column("rand", random(seed=42))
|
|
ds2 = ds2.random_shuffle(seed=1)
|
|
|
|
# The random column values should be deterministic (same seed)
|
|
# but the row order may differ due to shuffle
|
|
results1 = sorted(ds1.take_all(), key=lambda x: x["id"])
|
|
results2 = sorted(ds2.take_all(), key=lambda x: x["id"])
|
|
|
|
# Same random values for same id
|
|
for r1, r2 in zip(results1, results2):
|
|
assert r1["rand"] == r2["rand"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"all_to_all_op,op_kwargs",
|
|
[
|
|
("random_shuffle", {"seed": 100}),
|
|
("repartition", {"num_blocks": 5, "shuffle": True}),
|
|
("sort", {"key": "id"}),
|
|
("randomize_block_order", {"seed": 100}),
|
|
],
|
|
)
|
|
def test_random_reseed_after_execution_with_all_to_all_ops(
|
|
ray_start_regular_shared, all_to_all_op, op_kwargs
|
|
):
|
|
"""Test that reseed_after_execution works correctly when fused with all-to-all ops.
|
|
|
|
This test verifies the fix for the issue where random() expressions with
|
|
reseed_after_execution=True would not properly reseed across epochs when
|
|
fused with all-to-all operations (shuffle, repartition, sort, etc.). The issue
|
|
was that DataContext was not propagated to all-to-all tasks, causing
|
|
execution_idx to always be 0.
|
|
"""
|
|
from ray.data.expressions import random
|
|
|
|
# Create a dataset with random column
|
|
ds = ray.data.range(10, override_num_blocks=1).with_column(
|
|
"rand", random(seed=42, reseed_after_execution=True)
|
|
)
|
|
|
|
# Apply the all-to-all operation
|
|
ds_transformed = getattr(ds, all_to_all_op)(**op_kwargs)
|
|
|
|
# First execution
|
|
first_results = sorted(ds_transformed.take_all(), key=lambda x: x["id"])
|
|
|
|
# Second execution - should have different random values
|
|
second_results = sorted(ds_transformed.take_all(), key=lambda x: x["id"])
|
|
|
|
# Verify random values are different across executions
|
|
first_rand_values = [r["rand"] for r in first_results]
|
|
second_rand_values = [r["rand"] for r in second_results]
|
|
|
|
assert first_rand_values != second_rand_values, (
|
|
f"Random values should differ across executions when "
|
|
f"reseed_after_execution=True, even with {all_to_all_op} fusion"
|
|
)
|
|
|
|
# Verify the row ids are the same (just checking we have the same data)
|
|
first_ids = [r["id"] for r in first_results]
|
|
second_ids = [r["id"] for r in second_results]
|
|
assert first_ids == second_ids == list(range(10))
|
|
|
|
|
|
def test_uuid_expression_creation():
|
|
"""Test that uuid() creates a UUIDExpr."""
|
|
expr = uuid()
|
|
assert isinstance(expr, UUIDExpr)
|
|
|
|
|
|
def test_uuid_expression_structural_equality():
|
|
"""Test structural equality comparison for uuid expressions."""
|
|
expr1 = uuid()
|
|
expr2 = uuid()
|
|
|
|
# All uuid() expressions should be structurally equal (no parameters)
|
|
assert expr1.structurally_equals(expr2)
|
|
assert expr2.structurally_equals(expr1)
|
|
|
|
|
|
def test_uuid_expression_structural_equality_with_non_uuid_expr():
|
|
"""Test structural equality comparison with non-synthetic expression."""
|
|
uuid_expr = uuid()
|
|
non_uuid_expr = col("id")
|
|
|
|
assert not uuid_expr.structurally_equals(non_uuid_expr)
|
|
assert not non_uuid_expr.structurally_equals(uuid_expr)
|
|
|
|
|
|
def test_uuid_values_format(ray_start_regular_shared):
|
|
"""Test that uuid values are valid UUID strings."""
|
|
ds = ray.data.range(100).with_column("uuid_col", uuid())
|
|
results = ds.take_all()
|
|
|
|
assert len(results) == 100
|
|
for result in results:
|
|
uuid_value = result["uuid_col"]
|
|
assert isinstance(uuid_value, str)
|
|
# Validate UUID format
|
|
uuid_module.UUID(uuid_value) # Will raise ValueError if invalid
|
|
assert "id" in result # Verify other columns are preserved
|
|
|
|
|
|
def test_uuid_values_unique(ray_start_regular_shared):
|
|
"""Test that uuid values are unique."""
|
|
ds = ray.data.range(1000).with_column("uuid_col", uuid())
|
|
results = ds.take_all()
|
|
|
|
uuid_values = [r["uuid_col"] for r in results]
|
|
# All UUIDs should be unique
|
|
assert len(uuid_values) == len(set(uuid_values))
|
|
|
|
|
|
@pytest.mark.parametrize("batch_format", ["default", "pandas", "pyarrow"])
|
|
def test_uuid_with_different_batch_formats(ray_start_regular_shared, batch_format):
|
|
"""Test uuid expression works with different batch formats."""
|
|
import pandas as pd
|
|
import pyarrow as pa
|
|
|
|
ds = ray.data.range(100).with_column("uuid_col", uuid())
|
|
|
|
all_values = []
|
|
for batch in ds.iter_batches(batch_format=batch_format):
|
|
uuid_col = batch["uuid_col"]
|
|
if batch_format == "pandas" and isinstance(uuid_col, pd.Series):
|
|
all_values.extend(uuid_col.tolist())
|
|
elif batch_format == "pyarrow" and isinstance(uuid_col, pa.ChunkedArray):
|
|
all_values.extend(uuid_col.to_pylist()) # type: ignore
|
|
else:
|
|
all_values.extend(
|
|
list(uuid_col) if hasattr(uuid_col, "__iter__") else [uuid_col]
|
|
)
|
|
|
|
assert len(all_values) == 100
|
|
for val in all_values:
|
|
assert isinstance(val, str)
|
|
uuid_module.UUID(val) # Validate UUID format
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"args,kwargs,expected_error,error_message",
|
|
[
|
|
# Too many positional arguments
|
|
(
|
|
(1,),
|
|
{},
|
|
TypeError,
|
|
"uuid\\(\\) takes 0 positional arguments but 1 was given",
|
|
),
|
|
# Unexpected keyword argument
|
|
(
|
|
(),
|
|
{"invalid_arg": 42},
|
|
TypeError,
|
|
"uuid\\(\\) got an unexpected keyword argument 'invalid_arg'",
|
|
),
|
|
],
|
|
)
|
|
def test_uuid_validation_errors(
|
|
ray_start_regular_shared, args, kwargs, expected_error, error_message
|
|
):
|
|
"""Test that uuid() validates arguments correctly."""
|
|
with pytest.raises(expected_error, match=error_message):
|
|
uuid(*args, **kwargs)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
sys.exit(pytest.main(["-v", __file__]))
|