import itertools import random import time from typing import Iterator, Optional import numpy as np import pandas as pd import pyarrow as pa import pytest from packaging.version import parse as parse_version import ray from ray.data._internal.arrow_ops.transform_pyarrow import ( MIN_PYARROW_VERSION_TYPE_PROMOTION, combine_chunks, ) from ray.data._internal.planner.exchange.sort_task_spec import SortKey from ray.data._internal.util import is_nan from ray.data._internal.utils.arrow_utils import get_pyarrow_version from ray.data.aggregate import ( AbsMax, AggregateFn, AsList, Count, CountDistinct, Max, Mean, Min, Quantile, Std, Sum, Unique, ) from ray.data.block import BlockAccessor from ray.data.context import ShuffleStrategy from ray.data.expressions import col from ray.data.tests.conftest import * # noqa from ray.data.tests.util import named_values from ray.tests.conftest import * # noqa RANDOM_SEED = 123 def _sort_series_of_lists_elements(s: pd.Series): return s.apply( lambda l: list( # NOTE: We convert to Series to ensure the NaN elements will go last pd.Series(list(l)).sort_values() ) ) def test_grouped_dataset_repr( ray_start_regular_shared_2_cpus, disable_fallback_to_object_extension, target_max_block_size_infinite_or_default, ): ds = ray.data.from_items([{"key": "spam"}, {"key": "ham"}, {"key": "spam"}]) assert repr(ds.groupby("key")) == f"GroupedData(dataset={ds!r}, key='key')" def test_groupby_arrow( ray_start_regular_shared_2_cpus, configure_shuffle_method, disable_fallback_to_object_extension, target_max_block_size_infinite_or_default, ): # Test empty dataset. agg_ds = ray.data.range(10).filter(lambda r: r["id"] > 10).groupby("id").count() assert agg_ds.count() == 0 def test_groupby_none( ray_start_regular_shared_2_cpus, configure_shuffle_method, disable_fallback_to_object_extension, target_max_block_size_infinite_or_default, ): ds = ray.data.range(10) assert ds.groupby(None).min().take_all() == [{"min(id)": 0}] assert ds.groupby(None).max().take_all() == [{"max(id)": 9}] def test_groupby_errors( ray_start_regular_shared_2_cpus, disable_fallback_to_object_extension, target_max_block_size_infinite_or_default, ): ds = ray.data.range(100) ds.groupby(None).count().show() # OK with pytest.raises(ValueError): ds.groupby(lambda x: x % 2).count().show() with pytest.raises(ValueError): ds.groupby("foo").count().show() def test_map_groups_with_gpus( shutdown_only, configure_shuffle_method, disable_fallback_to_object_extension, target_max_block_size_infinite_or_default, ): ray.shutdown() ray.init(num_gpus=1) rows = ( ray.data.range(1, override_num_blocks=1) .groupby("id") .map_groups(lambda x: x, num_gpus=1) .take_all() ) assert rows == [{"id": 0}] def test_groupby_with_column_expression_udf( ray_start_regular_shared_2_cpus, configure_shuffle_method, disable_fallback_to_object_extension, ): import pyarrow.compute as pc from ray.data.datatype import DataType from ray.data.expressions import col, udf ds = ray.data.from_items( [ {"group": 1, "value": 1}, {"group": 1, "value": 2}, {"group": 2, "value": 3}, {"group": 2, "value": 4}, ] ) @udf(return_dtype=DataType.int32()) def min_value(values: pa.Array) -> pa.Array: scalar = pc.min(values) if isinstance(scalar, pa.Scalar): scalar = scalar.as_py() return pa.array([scalar] * len(values)) rows = ( ds.groupby("group") .with_column("min_value", min_value(col("value"))) .sort(["group", "value"]) .take_all() ) assert rows == [ {"group": 1, "value": 1, "min_value": 1}, {"group": 1, "value": 2, "min_value": 1}, {"group": 2, "value": 3, "min_value": 3}, {"group": 2, "value": 4, "min_value": 3}, ] def test_arrow_nan_element(ray_start_regular_shared_2_cpus): ds = ray.data.from_items( [ 1.0, 1.0, 2.0, np.nan, np.nan, ] ) ds = ds.groupby("item").count() ds = ds.filter(lambda v: np.isnan(v["item"])) result = ds.take_all() assert result[0]["count()"] == 2 def test_groupby_with_column_expression_arithmetic( ray_start_regular_shared_2_cpus, configure_shuffle_method, disable_fallback_to_object_extension, ): from ray.data.expressions import col ds = ray.data.from_items( [ {"group": 1, "value": 1}, {"group": 1, "value": 2}, {"group": 2, "value": 3}, {"group": 2, "value": 4}, ] ) rows = ( ds.groupby("group") .with_column("value_twice", col("value") * 2) .sort(["group", "value"]) .take_all() ) assert rows == [ {"group": 1, "value": 1, "value_twice": 2}, {"group": 1, "value": 2, "value_twice": 4}, {"group": 2, "value": 3, "value_twice": 6}, {"group": 2, "value": 4, "value_twice": 8}, ] def test_map_groups_with_actors( ray_start_regular_shared_2_cpus, configure_shuffle_method, disable_fallback_to_object_extension, target_max_block_size_infinite_or_default, ): class Identity: def __call__(self, batch): return batch rows = ( ray.data.range(1).groupby("id").map_groups(Identity, concurrency=1).take_all() ) assert rows == [{"id": 0}] def test_map_groups_with_actors_and_args( ray_start_regular_shared_2_cpus, configure_shuffle_method, disable_fallback_to_object_extension, ): class Fn: def __init__(self, x: int, y: Optional[int] = None): self.x = x self.y = y def __call__(self, batch, q: int, r: Optional[int] = None): return {"x": [self.x], "y": [self.y], "q": [q], "r": [r]} rows = ( ray.data.range(1) .groupby("id") .map_groups( Fn, concurrency=1, fn_constructor_args=[0], fn_constructor_kwargs={"y": 1}, fn_args=[2], fn_kwargs={"r": 3}, ) .take_all() ) assert rows == [{"x": 0, "y": 1, "q": 2, "r": 3}] def test_groupby_large_udf_returns( ray_start_regular_shared_2_cpus, configure_shuffle_method, disable_fallback_to_object_extension, ): # Test for https://github.com/ray-project/ray/issues/44861. # Each UDF return is 128 MiB. If Ray Data doesn't incrementally yield outputs, the # combined output size is 128 MiB * 1024 = 128 GiB and Arrow errors. def create_large_data(group): return {"item": np.zeros((1, 128 * 1024 * 1024), dtype=np.uint8)} ds = ( ray.data.range(1024, override_num_blocks=1) .groupby(key="id") .map_groups(create_large_data) ) ds.take(1) @pytest.mark.parametrize("num_parts", [1, 30]) def test_groupby_agg_name_conflict( ray_start_regular_shared_2_cpus, num_parts, configure_shuffle_method, disable_fallback_to_object_extension, ): # Test aggregation name conflict. xs = list(range(100)) grouped_ds = ( ray.data.from_items([{"A": (x % 3), "B": x} for x in xs]) .repartition(num_parts) .groupby("A") ) agg_ds = grouped_ds.aggregate( AggregateFn( init=lambda k: [0, 0], accumulate_row=lambda a, r: [a[0] + r["B"], a[1] + 1], merge=lambda a1, a2: [a1[0] + a2[0], a1[1] + a2[1]], finalize=lambda a: a[0] / a[1], name="foo", ), AggregateFn( init=lambda k: [0, 0], accumulate_row=lambda a, r: [a[0] + r["B"], a[1] + 1], merge=lambda a1, a2: [a1[0] + a2[0], a1[1] + a2[1]], finalize=lambda a: a[0] / a[1], name="foo", ), ) assert agg_ds.count() == 3 assert list(agg_ds.sort("A").iter_rows()) == [ {"A": 0, "foo": 49.5, "foo_2": 49.5}, {"A": 1, "foo": 49.0, "foo_2": 49.0}, {"A": 2, "foo": 50.0, "foo_2": 50.0}, ] @pytest.mark.parametrize("ds_format", ["pyarrow", "numpy", "pandas"]) def test_groupby_nans( ray_start_regular_shared_2_cpus, ds_format, configure_shuffle_method, disable_fallback_to_object_extension, target_max_block_size_infinite_or_default, ): ds = ray.data.from_items( [ 1.0, 1.0, 2.0, np.nan, np.nan, ] ) ds = ds.map_batches(lambda x: x, batch_format=ds_format) ds = ds.groupby("item").count() # NOTE: Hash-based shuffling will convert the block to Arrow, which # in turn convert NaNs into Nones ds = ds.filter(lambda v: v["item"] is None or is_nan(v["item"])) result = ds.take_all() assert result[0]["count()"] == 2 @pytest.mark.parametrize("num_parts", [1, 30]) @pytest.mark.parametrize("ds_format", ["pyarrow", "pandas"]) def test_groupby_tabular_count( ray_start_regular_shared_2_cpus, ds_format, num_parts, configure_shuffle_method, disable_fallback_to_object_extension, target_max_block_size_infinite_or_default, ): # Test built-in count aggregation seed = int(time.time()) print(f"Seeding RNG for test_groupby_arrow_count with: {seed}") random.seed(seed) xs = list(range(100)) random.shuffle(xs) ds = ray.data.from_items([{"A": (x % 3), "B": x} for x in xs]).repartition( num_parts ) ds = ds.map_batches(lambda x: x, batch_size=None, batch_format=ds_format) agg_ds = ds.groupby("A").count() assert agg_ds.count() == 3 assert list(agg_ds.sort("A").iter_rows()) == [ {"A": 0, "count()": 34}, {"A": 1, "count()": 33}, {"A": 2, "count()": 33}, ] @pytest.mark.parametrize("num_parts", [1, 30]) @pytest.mark.parametrize("ds_format", ["pyarrow", "pandas"]) def test_groupby_multiple_keys_tabular_count( ray_start_regular_shared_2_cpus, ds_format, num_parts, configure_shuffle_method, disable_fallback_to_object_extension, ): # Test built-in count aggregation print(f"Seeding RNG for test_groupby_arrow_count with: {RANDOM_SEED}") random.seed(RANDOM_SEED) xs = list(range(100)) random.shuffle(xs) ds = ray.data.from_items([{"A": (x % 2), "B": (x % 3)} for x in xs]).repartition( num_parts ) ds = ds.map_batches(lambda x: x, batch_size=None, batch_format=ds_format) agg_ds = ds.groupby(["A", "B"]).count() assert agg_ds.count() == 6 assert list(agg_ds.sort(["A", "B"]).iter_rows()) == [ {"A": 0, "B": 0, "count()": 17}, {"A": 0, "B": 1, "count()": 16}, {"A": 0, "B": 2, "count()": 17}, {"A": 1, "B": 0, "count()": 17}, {"A": 1, "B": 1, "count()": 17}, {"A": 1, "B": 2, "count()": 16}, ] @pytest.mark.parametrize("num_parts", [1, 30]) @pytest.mark.parametrize("ds_format", ["pyarrow", "pandas"]) def test_groupby_tabular_sum( ray_start_regular_shared_2_cpus, ds_format, num_parts, configure_shuffle_method, disable_fallback_to_object_extension, ): if ( ds_format == "pandas" and get_pyarrow_version() < MIN_PYARROW_VERSION_TYPE_PROMOTION ): pytest.skip( "PyArrow < 14 cannot unify double vs int64 schemas produced by " "pandas nullable integer columns with nulls" ) # Test built-in sum aggregation random.seed(1741752320) xs = list(range(100)) random.shuffle(xs) def _to_batch_format(ds): return ds.map_batches(lambda x: x, batch_size=None, batch_format=ds_format) ds = ray.data.from_items([{"A": (x % 3), "B": x} for x in xs]).repartition( num_parts ) ds = _to_batch_format(ds) agg_ds = ds.groupby("A").sum("B") assert agg_ds.count() == 3 assert list(agg_ds.sort("A").iter_rows()) == [ {"A": 0, "sum(B)": 1683}, {"A": 1, "sum(B)": 1617}, {"A": 2, "sum(B)": 1650}, ] # Test built-in sum aggregation with nans ds = ray.data.from_items( [{"A": (x % 3), "B": x} for x in xs] + [{"A": 0, "B": None}] ).repartition(num_parts) ds = _to_batch_format(ds) nan_grouped_ds = ds.groupby("A") nan_agg_ds = nan_grouped_ds.sum("B") assert nan_agg_ds.count() == 3 assert list(nan_agg_ds.sort("A").iter_rows()) == [ {"A": 0, "sum(B)": 1683}, {"A": 1, "sum(B)": 1617}, {"A": 2, "sum(B)": 1650}, ] # Test ignore_nulls=False nan_agg_ds = nan_grouped_ds.sum("B", ignore_nulls=False) assert nan_agg_ds.count() == 3 pd.testing.assert_frame_equal( nan_agg_ds.sort("A").to_pandas(), pd.DataFrame( { "A": [0, 1, 2], "sum(B)": [None, 1617, 1650], } ), check_dtype=False, ) # Test all nans ds = ray.data.from_items([{"A": (x % 3), "B": None} for x in xs]).repartition( num_parts ) ds = _to_batch_format(ds) nan_agg_ds = ds.groupby("A").sum("B") assert nan_agg_ds.count() == 3 result = nan_agg_ds.sort("A").to_pandas() expected = pd.DataFrame( { "A": pd.Series([0, 1, 2], dtype=result["A"].dtype), "sum(B)": pd.Series([None, None, None], dtype=result["sum(B)"].dtype), }, ) print("Result: ", result) print("Expected: ", expected) pd.testing.assert_frame_equal( expected, result, ) @pytest.mark.parametrize("num_parts", [1, 10]) @pytest.mark.parametrize("batch_format", ["pandas", "pyarrow"]) def test_as_list_e2e( ray_start_regular_shared_2_cpus, batch_format, num_parts, disable_fallback_to_object_extension, ): ds = ( ray.data.range(10) .with_column("group_key", col("id") % 3) .repartition(num_parts) .map_batches(lambda x: x, batch_format=batch_format) ) # Listing all elements per group: result = ds.groupby("group_key").aggregate(AsList(on="id")).take_all() for i in range(len(result)): result[i]["list(id)"] = sorted(result[i]["list(id)"]) assert sorted(result, key=lambda x: x["group_key"]) == [ {"group_key": 0, "list(id)": [0, 3, 6, 9]}, {"group_key": 1, "list(id)": [1, 4, 7]}, {"group_key": 2, "list(id)": [2, 5, 8]}, ] @pytest.mark.parametrize("num_parts", [1, 10]) @pytest.mark.parametrize("batch_format", ["pandas", "pyarrow"]) def test_as_list_with_nulls( ray_start_regular_shared_2_cpus, batch_format, num_parts, disable_fallback_to_object_extension, ): # Test with nulls included (default behavior: ignore_nulls=False) ds = ( ray.data.from_items( [ {"group": "A", "value": 1}, {"group": "A", "value": None}, {"group": "A", "value": 3}, {"group": "B", "value": None}, {"group": "B", "value": 5}, ] ) .repartition(num_parts) .map_batches(lambda x: x, batch_format=batch_format) ) # Default: nulls are included in the list result = ds.groupby("group").aggregate(AsList(on="value")).take_all() result_sorted = sorted(result, key=lambda x: x["group"]) # Sort the lists for comparison (None values will be at the end in sorted order) for r in result_sorted: # Separate None and non-None values for sorting non_nulls = sorted([v for v in r["list(value)"] if v is not None]) nulls = [v for v in r["list(value)"] if v is None] r["list(value)"] = non_nulls + nulls assert result_sorted == [ {"group": "A", "list(value)": [1, 3, None]}, {"group": "B", "list(value)": [5, None]}, ] # With ignore_nulls=True: nulls are excluded from the list result = ( ds.groupby("group").aggregate(AsList(on="value", ignore_nulls=True)).take_all() ) result_sorted = sorted(result, key=lambda x: x["group"]) for r in result_sorted: r["list(value)"] = sorted(r["list(value)"]) assert result_sorted == [ {"group": "A", "list(value)": [1, 3]}, {"group": "B", "list(value)": [5]}, ] @pytest.mark.parametrize("num_parts", [1, 30]) @pytest.mark.parametrize("ds_format", ["pandas", "pyarrow"]) def test_groupby_arrow_multi_agg( ray_start_regular_shared_2_cpus, num_parts, configure_shuffle_method, ds_format, disable_fallback_to_object_extension, ): using_pyarrow = ( ds_format == "pyarrow" or # NOTE: Hash-shuffle internally converts to pyarrow ( ds_format == "pandas" and configure_shuffle_method == ShuffleStrategy.HASH_SHUFFLE ) ) if using_pyarrow and get_pyarrow_version() < MIN_PYARROW_VERSION_TYPE_PROMOTION: pytest.skip( "Pyarrow < 14.0 doesn't support type promotions (hence fails " "promoting from int64 to double)" ) # NOTE: Do not change the seed random.seed(1738379113) xs = list(range(-50, 50)) random.shuffle(xs) df = pd.DataFrame({"A": [x % 3 for x in xs], "B": xs}) agg_ds = ( ray.data.from_pandas(df) .map_batches(lambda df: df, batch_size=None, batch_format=ds_format) .repartition(num_parts) .groupby("A") .aggregate( Count(), Count("B"), CountDistinct("B"), Sum("B"), Min("B"), Max("B"), AbsMax("B"), Mean("B"), Std("B"), Quantile("B"), Unique("B"), ) ) agg_df = agg_ds.to_pandas().sort_values(by="A").reset_index(drop=True) grouped_df = df.groupby("A", as_index=False).agg( { "B": [ "count", "count", "nunique", "sum", "min", "max", lambda x: x.abs().max(), "mean", "std", "quantile", "unique", ], } ) grouped_df.columns = [ "A", "count()", "count(B)", "count_distinct(B)", "sum(B)", "min(B)", "max(B)", "abs_max(B)", "mean(B)", "std(B)", "quantile(B)", "unique(B)", ] expected_df = grouped_df.sort_values(by="A").reset_index(drop=True) agg_df["unique(B)"] = _sort_series_of_lists_elements(agg_df["unique(B)"]) expected_df["unique(B)"] = _sort_series_of_lists_elements(expected_df["unique(B)"]) # to_pandas() now preserves Arrow-backed dtypes via types_mapper; coerce # the expected DataFrame's numeric columns to match. expected_df = expected_df.astype( {col: agg_df[col].dtype for col in expected_df.columns if col != "unique(B)"} ) print(f"Expected: {expected_df}") print(f"Result: {agg_df}") pd.testing.assert_frame_equal(expected_df, agg_df) # Test built-in global std aggregation df = pd.DataFrame({"A": xs}) result_row = ( ray.data.from_pandas(df) .map_batches(lambda df: df, batch_size=None, batch_format=ds_format) .repartition(num_parts) .aggregate( Sum("A"), Min("A"), Max("A"), Mean("A"), Std("A"), Quantile("A"), ) ) expected_row = { f"{agg}(A)": getattr(df["A"], agg)() for agg in ["sum", "min", "max", "mean", "std", "quantile"] } def _round_to_13_digits(row): return { # NOTE: Pandas and Arrow diverge on 14th digit (due to different formula # used with diverging FP numerical stability), hence we round it up k: round(v, 13) for k, v in row.items() } print(f"Expected: {expected_row}, (rounded: {_round_to_13_digits(expected_row)})") print(f"Result: {result_row} (rounded: {_round_to_13_digits(result_row)})") assert _round_to_13_digits(expected_row) == _round_to_13_digits(result_row) @pytest.mark.parametrize("num_parts", [1, 30]) @pytest.mark.parametrize("ds_format", ["pandas", "pyarrow"]) @pytest.mark.parametrize("ignore_nulls", [True, False]) def test_groupby_multi_agg_with_nans( ray_start_regular_shared_2_cpus, num_parts, configure_shuffle_method, ds_format, ignore_nulls, disable_fallback_to_object_extension, ): using_pyarrow = ds_format == "pyarrow" if using_pyarrow and get_pyarrow_version() < MIN_PYARROW_VERSION_TYPE_PROMOTION: pytest.skip( "Pyarrow < 14.0 doesn't support type promotions (hence fails " "promoting from int64 to double)" ) # NOTE: Do not change the seed random.seed(1738379113) xs = list(range(-50, 50)) random.shuffle(xs) df = pd.DataFrame( { "A": [x % 3 for x in xs] + [(np.nan if x % 2 == 0 else None) for x in xs], "B": xs + [(x if x % 2 == 1 else np.nan) for x in xs], } ) agg_ds = ( ray.data.from_pandas(df) .map_batches(lambda df: df, batch_size=None, batch_format=ds_format) .repartition(num_parts) .groupby("A") .aggregate( Count("B", alias_name="count_b", ignore_nulls=ignore_nulls), CountDistinct( "B", alias_name="count_distinct_b", ignore_nulls=ignore_nulls ), Sum("B", alias_name="sum_b", ignore_nulls=ignore_nulls), Min("B", alias_name="min_b", ignore_nulls=ignore_nulls), Max("B", alias_name="max_b", ignore_nulls=ignore_nulls), AbsMax("B", alias_name="abs_max_b", ignore_nulls=ignore_nulls), Mean("B", alias_name="mean_b", ignore_nulls=ignore_nulls), Std("B", alias_name="std_b", ignore_nulls=ignore_nulls), Quantile("B", alias_name="quantile_b", ignore_nulls=ignore_nulls), Unique("B", alias_name="unique_b", ignore_nulls=False), ) ) agg_df = agg_ds.to_pandas().sort_values(by="A").reset_index(drop=True) grouped_df = df.groupby("A", as_index=False, dropna=False).agg( { "B": [ ("count_b", lambda s: s.count() if ignore_nulls else len(s)), ("count_distinct_b", lambda s: s.nunique(dropna=ignore_nulls)), ("sum_b", lambda s: s.sum(skipna=ignore_nulls)), ("min_b", lambda s: s.min(skipna=ignore_nulls)), ("max_b", lambda s: s.max(skipna=ignore_nulls)), ("abs_max_b", lambda s: s.abs().max(skipna=ignore_nulls)), ("mean_b", lambda s: s.mean(skipna=ignore_nulls)), ("std_b", lambda s: s.std(skipna=ignore_nulls)), ( "quantile_b", lambda s: s.quantile() if ignore_nulls or not s.hasnans else np.nan, ), ("unique_b", "unique"), ] }, ) print(grouped_df) grouped_df.columns = [ "A", "count_b", "count_distinct_b", "sum_b", "min_b", "max_b", "abs_max_b", "mean_b", "std_b", "quantile_b", "unique_b", ] expected_df = grouped_df.sort_values(by="A").reset_index(drop=True) agg_df["unique_b"] = _sort_series_of_lists_elements(agg_df["unique_b"]) expected_df["unique_b"] = _sort_series_of_lists_elements(expected_df["unique_b"]) print(f"Expected: {expected_df}") print(f"Result: {agg_df}") pd.testing.assert_frame_equal(expected_df, agg_df, check_dtype=False) # Test built-in global std aggregation df = pd.DataFrame({"A": xs}) result_row = ( ray.data.from_pandas(df) .map_batches(lambda df: df, batch_size=None, batch_format=ds_format) .repartition(num_parts) .aggregate( Sum("A", alias_name="sum_a", ignore_nulls=ignore_nulls), Min("A", alias_name="min_a", ignore_nulls=ignore_nulls), Max("A", alias_name="max_a", ignore_nulls=ignore_nulls), Mean("A", alias_name="mean_a", ignore_nulls=ignore_nulls), Std("A", alias_name="std_a", ignore_nulls=ignore_nulls), Quantile("A", alias_name="quantile_a", ignore_nulls=ignore_nulls), ) ) expected_row = { f"{agg}_a": getattr(df["A"], agg)() for agg in ["sum", "min", "max", "mean", "std", "quantile"] } assert expected_row.keys() == result_row.keys() assert all(result_row[k] == pytest.approx(expected_row[k]) for k in expected_row) @pytest.mark.parametrize("ds_format", ["pyarrow", "pandas"]) @pytest.mark.parametrize("ignore_nulls", [True, False]) @pytest.mark.parametrize("null", [None, np.nan]) def test_groupby_aggregations_are_associative( ray_start_regular_shared_2_cpus, configure_shuffle_method, ds_format, ignore_nulls, null, disable_fallback_to_object_extension, ): # NOTE: This test verifies that combining is an properly # associative operation by combining all possible permutations # of partially aggregated blocks source = pd.DataFrame( { "A": [0, 1, 2, 3], "B": [0, 1, 2, null], } ) aggs = [ Count("B", alias_name="count_b", ignore_nulls=ignore_nulls), CountDistinct("B", alias_name="count_distinct_b", ignore_nulls=ignore_nulls), Sum("B", alias_name="sum_b", ignore_nulls=ignore_nulls), Min("B", alias_name="min_b", ignore_nulls=ignore_nulls), Max("B", alias_name="max_b", ignore_nulls=ignore_nulls), AbsMax("B", alias_name="abs_max_b", ignore_nulls=ignore_nulls), Mean("B", alias_name="mean_b", ignore_nulls=ignore_nulls), Std("B", alias_name="std_b", ignore_nulls=ignore_nulls), Quantile("B", alias_name="quantile_b", ignore_nulls=ignore_nulls), Unique("B", alias_name="unique_b", ignore_nulls=False), ] # Step 0: Prepare expected output (using Pandas) grouped_df = source.groupby("A", as_index=False, dropna=False).agg( { "B": [ ("count", lambda s: s.count() if ignore_nulls else len(s)), ("count_distinct", lambda s: s.nunique(dropna=ignore_nulls)), ("sum", lambda s: s.sum(skipna=ignore_nulls, min_count=1)), ("min", lambda s: s.min(skipna=ignore_nulls)), ("max", lambda s: s.max(skipna=ignore_nulls)), ("abs_max", lambda s: s.abs().max(skipna=ignore_nulls)), ("mean", lambda s: s.mean(skipna=ignore_nulls)), ("std", lambda s: s.std(skipna=ignore_nulls)), ( "quantile_b", lambda s: s.quantile() if ignore_nulls or not s.hasnans else np.nan, ), ("unique", "unique"), ] }, ) print(grouped_df) grouped_df.columns = [ "A", "count_b", "count_distinct_b", "sum_b", "min_b", "max_b", "abs_max_b", "mean_b", "std_b", "quantile_b", "unique_b", ] expected_df = grouped_df.sort_values(by="A").reset_index(drop=True) # Step 1: Split individual rows into standalone blocks, then apply # aggregations to it group_by_key = SortKey("A") aggregated_sub_blocks = [] for i in range(len(source)): slice_ = BlockAccessor.for_block(source).slice(i, i + 1) if ds_format == "pyarrow": b = pa.Table.from_pydict(slice_) elif ds_format == "pandas": b = pd.DataFrame(slice_) else: raise ValueError(f"Unknown format: {ds_format}") aggregated_sub_blocks.append( BlockAccessor.for_block(b)._aggregate(group_by_key, tuple(aggs)) ) # Step 2: Aggregate all possible permutations of the partially aggregated # blocks, assert against expected output for aggregated_blocks in itertools.permutations(aggregated_sub_blocks): cur = aggregated_blocks[0] for next_ in aggregated_blocks[1:]: cur, _ = BlockAccessor.for_block(cur)._combine_aggregated_blocks( [cur, next_], group_by_key, aggs, finalize=False ) finalized_block, _ = BlockAccessor.for_block(cur)._combine_aggregated_blocks( [cur], group_by_key, aggs, finalize=True ) # NOTE: _combine_aggregated_blocks could be producing # - Arrow blocks when using vectorized or full Arrow-native aggregations # - Pandas blocks if it falls back to default (OSS) impl (for ex for Arrow < 14.0) res = BlockAccessor.for_block(finalized_block).to_pandas() res = res.sort_values(by="A").reset_index(drop=True) res["unique_b"] = _sort_series_of_lists_elements(res["unique_b"]) expected_df["unique_b"] = _sort_series_of_lists_elements( expected_df["unique_b"] ) print(">>> Result: ", res) print(">>> Expected: ", expected_df) # NOTE: We currently ignore the underlying schema and assert only # based on values, due to current aggregations implementations # not handling types properly and consistently # # TODO assert on expected schema as well pd.testing.assert_frame_equal(expected_df, res, check_dtype=False) @pytest.mark.parametrize("num_parts", [1, 2, 30]) def test_groupby_map_groups_for_none_groupkey( ray_start_regular_shared_2_cpus, num_parts, configure_shuffle_method, disable_fallback_to_object_extension, ): ds = ray.data.from_items(list(range(100))) mapped = ( ds.repartition(num_parts) .groupby(None) .map_groups(lambda x: {"out": np.array([min(x["item"]) + max(x["item"])])}) ) assert mapped.count() == 1 assert mapped.take_all() == named_values("out", [99]) def test_groupby_map_groups_perf( ray_start_regular_shared_2_cpus, configure_shuffle_method, disable_fallback_to_object_extension, target_max_block_size_infinite_or_default, ): data_list = [x % 100 for x in range(5000000)] ds = ray.data.from_pandas(pd.DataFrame({"A": data_list})) start = time.perf_counter() ds.groupby("A").map_groups(lambda df: df) end = time.perf_counter() # On a t3.2xlarge instance, it ran in about 5 seconds, so expecting it has to # finish within about 10x of that time, unless something went wrong. assert end - start < 60 @pytest.mark.parametrize("num_parts", [1, 2, 30]) def test_groupby_map_groups_for_pandas( ray_start_regular_shared_2_cpus, num_parts, configure_shuffle_method, disable_fallback_to_object_extension, ): df = pd.DataFrame({"A": "a a b".split(), "B": [1, 1, 3], "C": [4, 6, 5]}) grouped = ray.data.from_pandas(df).repartition(num_parts).groupby("A") # Normalize the numeric columns (i.e. B and C) for each group. mapped = grouped.map_groups( lambda g: g.apply( lambda col: col / g[col.name].sum() if col.name in ["B", "C"] else col ) ) # The function (i.e. the normalization) performed on each group doesn't # aggregate rows, so we still have 3 rows. assert mapped.count() == 3 result = mapped.sort(["A", "C"]).to_pandas() # to_pandas() now preserves Arrow-backed dtypes via types_mapper; build the # expected DataFrame with matching dtypes. expected = pd.DataFrame( {"A": ["a", "a", "b"], "B": [0.5, 0.5, 1.000000], "C": [0.4, 0.6, 1.0]} ).astype(result.dtypes.to_dict()) pd.testing.assert_frame_equal(expected, result) @pytest.mark.parametrize("num_parts", [1, 2, 30]) def test_groupby_map_groups_for_arrow( ray_start_regular_shared_2_cpus, num_parts, configure_shuffle_method, disable_fallback_to_object_extension, ): at = pa.Table.from_pydict({"A": "a a b".split(), "B": [1, 1, 3], "C": [4, 6, 5]}) grouped = ray.data.from_arrow(at).repartition(num_parts).groupby("A") # Normalize the numeric columns (i.e. B and C) for each group. def normalize(at: pa.Table): r = at.select("A") sb = pa.compute.sum(at.column("B")).cast(pa.float64()) r = r.append_column("B", pa.compute.divide(at.column("B"), sb)) sc = pa.compute.sum(at.column("C")).cast(pa.float64()) r = r.append_column("C", pa.compute.divide(at.column("C"), sc)) return r mapped = grouped.map_groups(normalize, batch_format="pyarrow") # The function (i.e. the normalization) performed on each group doesn't # aggregate rows, so we still have 3 rows. assert mapped.count() == 3 expected = pa.Table.from_pydict( {"A": ["a", "a", "b"], "B": [0.5, 0.5, 1], "C": [0.4, 0.6, 1]} ) result = mapped.sort(["A", "C"]).take_batch(batch_format="pyarrow") assert expected == combine_chunks(result) def test_groupby_map_groups_for_numpy( ray_start_regular_shared_2_cpus, configure_shuffle_method, disable_fallback_to_object_extension, ): ds = ray.data.from_items( [ {"group": 1, "value": 1}, {"group": 1, "value": 2}, {"group": 2, "value": 3}, {"group": 2, "value": 4}, ] ) def func(group): # Test output type is NumPy format. return {"group": group["group"] + 1, "value": group["value"] + 1} ds = ds.groupby("group").map_groups(func, batch_format="numpy") expected = pa.Table.from_pydict({"group": [2, 2, 3, 3], "value": [2, 3, 4, 5]}) result = ds.sort(["group", "value"]).take_batch(batch_format="pyarrow") assert expected == result def test_groupby_map_groups_with_different_types( ray_start_regular_shared_2_cpus, configure_shuffle_method, disable_fallback_to_object_extension, ): ds = ray.data.from_items( [ {"group": 1, "value": 1}, {"group": 1, "value": 2}, {"group": 2, "value": 3}, {"group": 2, "value": 4}, ] ) def func(batch): # Test output type is Python list, different from input type. return {"group": [batch["group"][0]], "out": [min(batch["value"])]} ds = ds.groupby("group").map_groups(func) assert [x["out"] for x in ds.sort("group").take_all()] == [1, 3] @pytest.mark.parametrize("num_parts", [1, 30]) def test_groupby_map_groups_multiple_batch_formats( ray_start_regular_shared_2_cpus, num_parts, configure_shuffle_method, disable_fallback_to_object_extension, ): # Reproduces https://github.com/ray-project/ray/issues/39206 def identity(batch): return batch xs = list(range(100)) ds = ray.data.from_items([{"A": (x % 3), "B": x} for x in xs]).repartition( num_parts ) grouped_ds = ( ds.groupby("A") .map_groups(identity) .map_batches(identity, batch_format="pandas") ) agg_ds = grouped_ds.groupby("A").max("B") assert agg_ds.count() == 3 assert list(agg_ds.sort("A").iter_rows()) == [ {"A": 0, "max(B)": 99}, {"A": 1, "max(B)": 97}, {"A": 2, "max(B)": 98}, ] def test_groupby_map_groups_ray_remote_args_fn( ray_start_regular_shared_2_cpus, configure_shuffle_method, target_max_block_size_infinite_or_default, ): ds = ray.data.from_items( [ {"group": 1, "value": 1}, {"group": 1, "value": 2}, {"group": 2, "value": 3}, {"group": 2, "value": 4}, ] ) def func(df): import os df["value"] = int(os.environ["__MY_TEST__"]) return df ds = ds.groupby("group").map_groups( func, ray_remote_args_fn=lambda: {"runtime_env": {"env_vars": {"__MY_TEST__": "69"}}}, ) assert sorted([x["value"] for x in ds.take()]) == [69, 69, 69, 69] def test_groupby_map_groups_extra_args( ray_start_regular_shared_2_cpus, configure_shuffle_method, disable_fallback_to_object_extension, target_max_block_size_infinite_or_default, ): ds = ray.data.from_items( [ {"group": 1, "value": 1}, {"group": 1, "value": 2}, {"group": 2, "value": 3}, {"group": 2, "value": 4}, ] ) def func(df, a, b, c): df["value"] = df["value"] * a + b + c return df ds = ds.groupby("group").map_groups( func, fn_args=(2, 1), fn_kwargs={"c": 3}, ) assert sorted([x["value"] for x in ds.take()]) == [6, 8, 10, 12] _NEED_UNWRAP_ARROW_SCALAR = get_pyarrow_version() <= parse_version("9.0.0") @pytest.mark.parametrize("num_parts", [1, 30]) @pytest.mark.parametrize("ds_format", ["pyarrow", "pandas", "numpy"]) def test_groupby_map_groups_multicolumn( ray_start_regular_shared_2_cpus, ds_format, num_parts, configure_shuffle_method, disable_fallback_to_object_extension, ): # Test built-in count aggregation random.seed(RANDOM_SEED) xs = list(range(100)) random.shuffle(xs) ds = ray.data.from_items([{"A": (x % 2), "B": (x % 3)} for x in xs]).repartition( num_parts ) should_unwrap_pa_scalars = ds_format == "pyarrow" and _NEED_UNWRAP_ARROW_SCALAR def _map_group(df): # NOTE: Since we're grouping by A and B, these columns will be bearing # the same values. a = df["A"][0] b = df["B"][0] return { # NOTE: PA 9.0 requires explicit unwrapping into Python objects "A": [a.as_py() if should_unwrap_pa_scalars else a], "B": [b.as_py() if should_unwrap_pa_scalars else b], "count": [len(df["A"])], } agg_ds = ds.groupby(["A", "B"]).map_groups( _map_group, batch_format=ds_format, ) assert agg_ds.sort(["A", "B"]).take_all() == [ {"A": 0, "B": 0, "count": 17}, {"A": 0, "B": 1, "count": 16}, {"A": 0, "B": 2, "count": 17}, {"A": 1, "B": 0, "count": 17}, {"A": 1, "B": 1, "count": 17}, {"A": 1, "B": 2, "count": 16}, ] @pytest.mark.parametrize("num_parts", [1, 30]) @pytest.mark.parametrize("ds_format", ["pyarrow", "pandas", "numpy"]) def test_groupby_map_groups_multicolumn_with_nan( ray_start_regular_shared_2_cpus, ds_format, num_parts, configure_shuffle_method, disable_fallback_to_object_extension, ): # Test with some NaN values rng = np.random.default_rng(RANDOM_SEED) xs = np.arange(100, dtype=np.float64) xs[-5:] = np.nan rng.shuffle(xs) ds = ray.data.from_items( [ { "A": (x % 2) if np.isfinite(x) else x, "B": (x % 3) if np.isfinite(x) else x, } for x in xs ] ).repartition(num_parts) should_unwrap_pa_scalars = ds_format == "pyarrow" and _NEED_UNWRAP_ARROW_SCALAR def _map_group(df): # NOTE: Since we're grouping by A and B, these columns will be bearing # the same values a = df["A"][0] b = df["B"][0] return { # NOTE: PA 9.0 requires explicit unwrapping into Python objects "A": [a.as_py() if should_unwrap_pa_scalars else a], "B": [b.as_py() if should_unwrap_pa_scalars else b], "count": [len(df["A"])], } agg_ds = ds.groupby(["A", "B"]).map_groups( _map_group, batch_format=ds_format, ) rows = agg_ds.sort(["A", "B"]).take_all() # NOTE: Nans are not comparable directly, hence # we have to split the assertion in 2 assert rows[:-1] == [ {"A": 0.0, "B": 0.0, "count": 16}, {"A": 0.0, "B": 1.0, "count": 16}, {"A": 0.0, "B": 2.0, "count": 16}, {"A": 1.0, "B": 0.0, "count": 16}, {"A": 1.0, "B": 1.0, "count": 16}, {"A": 1.0, "B": 2.0, "count": 15}, ] assert ( np.isnan(rows[-1]["A"]) and np.isnan(rows[-1]["B"]) and rows[-1]["count"] == 5 ) def test_groupby_map_groups_with_partial(disable_fallback_to_object_extension, capsys): """ The partial function name should show up as +- Sort +- MapBatches(func) """ from functools import partial def func(x, y): return {f"x_add_{y}": [len(x["id"]) + y]} df = pd.DataFrame({"id": list(range(100))}) df["key"] = df["id"] % 5 ds = ray.data.from_pandas(df).groupby("key").map_groups(partial(func, y=5)) result = ds.take_all() assert result == [ {"x_add_5": 25}, {"x_add_5": 25}, {"x_add_5": 25}, {"x_add_5": 25}, {"x_add_5": 25}, ] ds.explain() captured = capsys.readouterr() assert "MapBatches(func)" in captured.out def test_map_groups_generator_udf(ray_start_regular_shared_2_cpus): """ Tests that map_groups supports UDFs that return generators (iterators). """ ds = ray.data.from_items( [ {"group": 1, "data": 10}, {"group": 1, "data": 20}, {"group": 2, "data": 30}, ] ) def generator_udf(df: pd.DataFrame) -> Iterator[pd.DataFrame]: # For each group, yield two DataFrames. # 1. A DataFrame where 'data' is multiplied by 2. yield df.assign(data=df["data"] * 2) # 2. A DataFrame where 'data' is multiplied by 3. yield df.assign(data=df["data"] * 3) # Apply the generator UDF to the grouped data. result_ds = ds.groupby("group").map_groups(generator_udf) # The final dataset should contain all results from all yields. # Group 1 -> data: [20, 40] and [30, 60] # Group 2 -> data: [60] and [90] expected_data = sorted([20, 40, 30, 60, 60, 90]) # Collect and sort the actual data to ensure correctness regardless of order. actual_data = sorted([row["data"] for row in result_ds.take_all()]) assert actual_data == expected_data assert result_ds.count() == 6 if __name__ == "__main__": import sys sys.exit(pytest.main(["-v", __file__]))