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

730 lines
24 KiB
Python

import pandas as pd
import pyarrow as pa
import pytest
from packaging.version import parse as parse_version
import ray
from ray.data._internal.util import rows_same
from ray.data._internal.utils.arrow_utils import get_pyarrow_version
from ray.data.aggregate import (
ApproximateQuantile,
ApproximateTopK,
Count,
Max,
Mean,
Min,
MissingValuePercentage,
Std,
ZeroPercentage,
)
from ray.data.datatype import DataType
from ray.data.stats import (
DatasetSummary,
_basic_aggregators,
_default_dtype_aggregators,
_dtype_aggregators_for_dataset,
_numerical_aggregators,
_temporal_aggregators,
)
class TestDtypeAggregatorsForDataset:
"""Test suite for _dtype_aggregators_for_dataset function."""
@pytest.mark.parametrize(
"data,expected_dtypes,expected_agg_count",
[
# Numerical columns only
(
[{"int_col": 1, "float_col": 1.5}],
{
"int_col": "DataType(arrow:int64)",
"float_col": "DataType(arrow:double)",
},
16, # 2 columns * 8 aggregators each
),
# Mixed numerical and string
(
[{"num": 1, "str": "a"}],
{"num": "DataType(arrow:int64)", "str": "DataType(arrow:string)"},
11, # 1 numerical * 8 + 1 string * 3
),
# Boolean treated as numerical
(
[{"bool_col": True, "int_col": 1}],
{
"bool_col": "DataType(arrow:bool)",
"int_col": "DataType(arrow:int64)",
},
16, # 2 columns * 8 aggregators each
),
],
)
def test_column_type_detection(self, data, expected_dtypes, expected_agg_count):
"""Test that column types are correctly detected and mapped."""
ds = ray.data.from_items(data)
result = _dtype_aggregators_for_dataset(ds.schema())
assert (result.column_to_dtype, len(result.aggregators)) == (
expected_dtypes,
expected_agg_count,
)
def test_column_filtering(self):
"""Test that only specified columns are included."""
data = [{"col1": 1, "col2": "a", "col3": 1.5}]
ds = ray.data.from_items(data)
result = _dtype_aggregators_for_dataset(ds.schema(), columns=["col1", "col3"])
assert (set(result.column_to_dtype.keys()), len(result.aggregators)) == (
{"col1", "col3"},
16,
)
def test_empty_columns_list(self):
"""Test behavior with empty columns list."""
data = [{"col1": 1, "col2": "a"}]
ds = ray.data.from_items(data)
result = _dtype_aggregators_for_dataset(ds.schema(), columns=[])
assert (len(result.column_to_dtype), len(result.aggregators)) == (0, 0)
def test_invalid_columns_raises_error(self):
"""Test error handling when columns parameter contains non-existent columns."""
data = [{"col1": 1}]
ds = ray.data.from_items(data)
with pytest.raises(ValueError, match="not found in dataset schema"):
_dtype_aggregators_for_dataset(ds.schema(), columns=["nonexistent"])
def test_none_schema_raises_error(self):
"""Test that None schema raises appropriate error."""
with pytest.raises(ValueError, match="must have a schema"):
_dtype_aggregators_for_dataset(None)
def test_custom_dtype_mapping(self):
"""Test that custom dtype mappings override defaults."""
data = [{"int_col": 1}]
ds = ray.data.from_items(data)
# Override int64 to only use Count and Mean
custom_mapping = {DataType.int64(): lambda col: [Count(on=col), Mean(on=col)]}
result = _dtype_aggregators_for_dataset(
ds.schema(), dtype_agg_mapping=custom_mapping
)
assert [type(agg) for agg in result.aggregators] == [Count, Mean]
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("14.0.0"),
reason="Requires pyarrow >= 14.0.0",
)
def test_custom_dtype_mapping_pattern_precedence(self):
"""Test that specific custom mappings take precedence over default patterns."""
import datetime
# Use from_arrow to ensure we get exactly timestamp[us]
t = pa.table(
{"ts": pa.array([datetime.datetime(2024, 1, 1)], type=pa.timestamp("us"))}
)
ds = ray.data.from_arrow(t)
# Override specific timestamp type to only use Count
# Default for temporal is [Count, Min, Max, MissingValuePercentage]
ts_dtype = DataType.from_arrow(pa.timestamp("us"))
custom_mapping = {ts_dtype: lambda col: [Count(on=col)]}
result = _dtype_aggregators_for_dataset(
ds.schema(), dtype_agg_mapping=custom_mapping
)
# Should only have 1 aggregator if our specific override was used.
# If the default DataType.temporal() pattern matched first, we'd get 4 aggregators.
assert len(result.aggregators) == 1
assert isinstance(result.aggregators[0], Count)
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("14.0.0"),
reason="Requires pyarrow >= 14.0.0",
)
@pytest.mark.parametrize(
"pa_type",
[
pa.timestamp("us"), # Temporal: count, min, max, missing%
pa.date32(), # Temporal
pa.time64("us"), # Temporal
],
)
def test_temporal_types(self, pa_type):
"""Test that temporal types get appropriate aggregators."""
table = pa.table({"temporal_col": pa.array([1, 2, 3], type=pa_type)})
ds = ray.data.from_arrow(table)
result = _dtype_aggregators_for_dataset(ds.schema())
assert "temporal_col" in result.column_to_dtype
assert [type(agg) for agg in result.aggregators] == [
Count,
Min,
Max,
MissingValuePercentage,
]
class TestIndividualAggregatorFunctions:
"""Test suite for individual aggregator generator functions."""
def test_numerical_aggregators(self):
"""Test _numerical_aggregators function."""
aggs = _numerical_aggregators("test_col")
assert len(aggs) == 8
assert all(agg.get_target_column() == "test_col" for agg in aggs)
assert [type(agg) for agg in aggs] == [
Count,
Mean,
Min,
Max,
Std,
ApproximateQuantile,
MissingValuePercentage,
ZeroPercentage,
]
def test_temporal_aggregators(self):
"""Test _temporal_aggregators function."""
aggs = _temporal_aggregators("test_col")
assert len(aggs) == 4
assert all(agg.get_target_column() == "test_col" for agg in aggs)
assert [type(agg) for agg in aggs] == [Count, Min, Max, MissingValuePercentage]
def test_basic_aggregators(self):
"""Test _basic_aggregators function."""
aggs = _basic_aggregators("test_col")
assert len(aggs) == 3
assert all(agg.get_target_column() == "test_col" for agg in aggs)
assert [type(agg) for agg in aggs] == [
Count,
MissingValuePercentage,
ApproximateTopK,
]
class TestDefaultDtypeAggregators:
"""Test suite for _default_dtype_aggregators function."""
@pytest.mark.skipif(
get_pyarrow_version() < parse_version("14.0.0"),
reason="Requires pyarrow >= 14.0.0",
)
@pytest.mark.parametrize(
"dtype_factory,expected_agg_types,uses_pattern_matching",
[
(
DataType.int32,
[
Count,
Mean,
Min,
Max,
Std,
ApproximateQuantile,
MissingValuePercentage,
ZeroPercentage,
],
False,
), # Numerical
(
DataType.float64,
[
Count,
Mean,
Min,
Max,
Std,
ApproximateQuantile,
MissingValuePercentage,
ZeroPercentage,
],
False,
), # Numerical
(
DataType.bool,
[
Count,
Mean,
Min,
Max,
Std,
ApproximateQuantile,
MissingValuePercentage,
ZeroPercentage,
],
False,
), # Numerical
(
DataType.string,
[Count, MissingValuePercentage, ApproximateTopK],
False,
), # Basic
(
DataType.binary,
[Count, MissingValuePercentage, ApproximateTopK],
False,
), # Basic
(
lambda: DataType.temporal("timestamp", unit="us"),
[Count, Min, Max, MissingValuePercentage],
True,
), # Temporal (pattern matched)
(
lambda: DataType.temporal("date32"),
[Count, Min, Max, MissingValuePercentage],
True,
), # Temporal (pattern matched)
(
lambda: DataType.temporal("time64", unit="us"),
[Count, Min, Max, MissingValuePercentage],
True,
), # Temporal (pattern matched)
],
)
def test_default_mappings(
self, dtype_factory, expected_agg_types, uses_pattern_matching
):
"""Test that default mappings return correct aggregators."""
from ray.data.datatype import TypeCategory
mapping = _default_dtype_aggregators()
dtype = dtype_factory()
if uses_pattern_matching:
# For pattern-matched types (like temporal), find the matching factory
factory = None
for mapping_key, mapping_factory in mapping.items():
if isinstance(mapping_key, (TypeCategory, str)) and dtype.is_of(
mapping_key
):
factory = mapping_factory
break
assert (
factory is not None
), f"Type {dtype} should match a pattern in the mapping"
else:
# For exact matches, directly access the mapping
assert dtype in mapping
factory = mapping[dtype]
# Call the factory with a test column to get aggregators
aggs = factory("test_col")
assert [type(agg) for agg in aggs] == expected_agg_types
class TestDatasetSummary:
"""Test suite for Dataset.summary() method."""
def test_basic_summary(self):
"""Test basic summary computation."""
ds = ray.data.from_items(
[
{"age": 25, "name": "Alice"},
{"age": 30, "name": "Bob"},
]
)
summary = ds.summary()
actual = summary.to_pandas()
# Verify columns are present
assert "age" in actual.columns
assert "name" in actual.columns
# Check key statistics using rows_same
actual_subset = actual[
actual["statistic"].isin(["count", "mean", "min", "max"])
].copy()
actual_subset["age"] = actual_subset["age"].astype(float)
actual_subset["name"] = actual_subset["name"].astype(float)
expected = pd.DataFrame(
{
"statistic": ["count", "mean", "min", "max"],
"age": [2.0, 27.5, 25.0, 30.0],
"name": [2.0, None, None, None],
}
)
assert rows_same(actual_subset, expected)
def test_summary_with_column_filter(self):
"""Test summary with specific columns."""
ds = ray.data.from_items(
[
{"col1": 1, "col2": "a", "col3": 3.5},
]
)
summary = ds.summary(columns=["col1"])
actual = summary.to_pandas()
# Check count and mean with rows_same
actual_subset = actual[actual["statistic"].isin(["count", "mean"])][
["statistic", "col1"]
].copy()
actual_subset["col1"] = actual_subset["col1"].astype(float)
expected = pd.DataFrame(
{
"statistic": ["count", "mean"],
"col1": [1.0, 1.0],
}
)
assert rows_same(actual_subset, expected)
def test_summary_custom_mapping(self):
"""Test summary with custom dtype aggregation mapping."""
ds = ray.data.from_items([{"value": 10, "other": 20}])
# Only Count and Mean for int64 columns
custom_mapping = {DataType.int64(): lambda col: [Count(on=col), Mean(on=col)]}
summary = ds.summary(override_dtype_agg_mapping=custom_mapping)
actual = summary.to_pandas()
# Convert to float for comparison
actual["value"] = actual["value"].astype(float)
actual["other"] = actual["other"].astype(float)
# Columns are sorted alphabetically, so order is: statistic, other, value
expected = pd.DataFrame(
{
"statistic": ["count", "mean"],
"other": [1.0, 20.0],
"value": [1.0, 10.0],
}
)
assert rows_same(actual, expected)
def test_get_column_stats(self):
"""Test get_column_stats method."""
ds = ray.data.from_items(
[
{"x": 1, "y": 2},
{"x": 3, "y": 4},
]
)
summary = ds.summary()
actual = summary.get_column_stats("x")
# Verify key statistics with rows_same (checking subset due to mixed types)
expected_stats = ["count", "mean", "min", "max"]
actual_subset = actual[actual["statistic"].isin(expected_stats)].copy()
actual_subset["value"] = actual_subset["value"].astype(float)
expected = pd.DataFrame(
{
"statistic": ["count", "mean", "min", "max"],
"value": [2.0, 2.0, 1.0, 3.0],
}
)
assert rows_same(actual_subset, expected)
@pytest.mark.parametrize(
"data,column,expected_df",
[
(
[{"x": 1}, {"x": 2}, {"x": 3}],
"x",
pd.DataFrame(
{
"statistic": ["count", "mean", "min", "max"],
"x": [3.0, 2.0, 1.0, 3.0],
}
),
),
(
[{"y": 10}, {"y": 20}],
"y",
pd.DataFrame(
{
"statistic": ["count", "mean", "min", "max"],
"y": [2.0, 15.0, 10.0, 20.0],
}
),
),
(
[{"z": 0}, {"z": 0}, {"z": 1}],
"z",
pd.DataFrame(
{
"statistic": ["count", "mean", "min", "max"],
"z": [3.0, 1.0 / 3, 0.0, 1.0],
}
),
),
],
)
def test_summary_statistics_values(self, data, column, expected_df):
"""Test that computed statistics have correct values."""
ds = ray.data.from_items(data)
summary = ds.summary(columns=[column])
actual = summary.to_pandas()
# Filter to key statistics and convert to float
actual_subset = actual[
actual["statistic"].isin(["count", "mean", "min", "max"])
][["statistic", column]].copy()
actual_subset[column] = actual_subset[column].astype(float)
assert rows_same(actual_subset, expected_df)
@pytest.mark.parametrize(
"data,columns,expected_df",
[
# Single numerical column with two values
(
[{"x": 10}, {"x": 20}],
["x"],
pd.DataFrame(
{
DatasetSummary.STATISTIC_COLUMN: [
"approx_quantile[0]",
"count",
"max",
"mean",
"min",
"missing_pct",
"std",
"zero_pct",
],
"x": [20.0, 2.0, 20.0, 15.0, 10.0, 0.0, 5.0, 0.0],
}
),
),
# Single numerical column with all same values
(
[{"y": 5}, {"y": 5}, {"y": 5}],
["y"],
pd.DataFrame(
{
DatasetSummary.STATISTIC_COLUMN: [
"approx_quantile[0]",
"count",
"max",
"mean",
"min",
"missing_pct",
"std",
"zero_pct",
],
"y": [5.0, 3.0, 5.0, 5.0, 5.0, 0.0, 0.0, 0.0],
}
),
),
# Multiple numerical columns
(
[{"a": 1, "b": 10}, {"a": 3, "b": 30}],
["a", "b"],
pd.DataFrame(
{
DatasetSummary.STATISTIC_COLUMN: [
"approx_quantile[0]",
"count",
"max",
"mean",
"min",
"missing_pct",
"std",
"zero_pct",
],
"a": [3.0, 2.0, 3.0, 2.0, 1.0, 0.0, 1.0, 0.0],
"b": [30.0, 2.0, 30.0, 20.0, 10.0, 0.0, 10.0, 0.0],
}
),
),
# Column with zeros and missing values
(
[{"z": 0}, {"z": 10}, {"z": None}],
["z"],
pd.DataFrame(
{
DatasetSummary.STATISTIC_COLUMN: [
"approx_quantile[0]",
"count",
"max",
"mean",
"min",
"missing_pct",
"std",
"zero_pct",
],
"z": [10.0, 3.0, 10.0, 5.0, 0.0, 100.0 / 3, 5.0, 50.0],
}
),
),
# Column with all zeros
(
[{"w": 0}, {"w": 0}],
["w"],
pd.DataFrame(
{
DatasetSummary.STATISTIC_COLUMN: [
"approx_quantile[0]",
"count",
"max",
"mean",
"min",
"missing_pct",
"std",
"zero_pct",
],
"w": [0.0, 2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 100.0],
}
),
),
],
)
def test_summary_full_dataframe(self, data, columns, expected_df):
"""Test summary with full DataFrame comparison."""
ds = ray.data.from_items(data)
summary = ds.summary(columns=columns)
actual = summary.to_pandas()
# Convert to float for comparison
for col in expected_df.columns:
if col != DatasetSummary.STATISTIC_COLUMN:
expected_df[col] = expected_df[col].astype(float)
actual[col] = actual[col].astype(float)
assert rows_same(actual, expected_df)
def test_summary_multiple_quantiles(self):
"""Test summary with multiple quantiles."""
ds = ray.data.from_items(
[
{"x": 1},
{"x": 2},
{"x": 3},
{"x": 4},
{"x": 5},
]
)
# Create custom mapping with multiple quantiles
custom_mapping = {
DataType.int64(): lambda col: [
Count(on=col, ignore_nulls=False),
Min(on=col, ignore_nulls=True),
Max(on=col, ignore_nulls=True),
ApproximateQuantile(on=col, quantiles=[0.25, 0.5, 0.75]),
]
}
summary = ds.summary(columns=["x"], override_dtype_agg_mapping=custom_mapping)
actual = summary.to_pandas()
# Should have separate rows for each quantile with index-based labels [0], [1], [2]
expected = pd.DataFrame(
{
DatasetSummary.STATISTIC_COLUMN: [
"approx_quantile[0]",
"approx_quantile[1]",
"approx_quantile[2]",
"count",
"max",
"min",
],
"x": [2.0, 3.0, 4.0, 5.0, 5.0, 1.0],
}
)
# Convert to float for comparison
for col in expected.columns:
if col != DatasetSummary.STATISTIC_COLUMN:
expected[col] = expected[col].astype(float)
actual[col] = actual[col].astype(float)
assert rows_same(actual, expected)
def test_summary_custom_quantiles_and_topk(self):
"""Test summary with custom ApproximateQuantile and ApproximateTopK values."""
# Create data with numerical and string columns
ds = ray.data.from_items(
[
{"value": 10, "category": "apple"},
{"value": 20, "category": "banana"},
{"value": 30, "category": "apple"},
{"value": 40, "category": "cherry"},
{"value": 50, "category": "banana"},
{"value": 60, "category": "apple"},
{"value": 70, "category": "date"},
]
)
# Custom mapping with different quantile values and top-k value
custom_mapping = {
DataType.int64(): lambda col: [
Count(on=col, ignore_nulls=False),
ApproximateQuantile(on=col, quantiles=[0.1, 0.5, 0.9]),
],
DataType.string(): lambda col: [
Count(on=col, ignore_nulls=False),
ApproximateTopK(on=col, k=3), # Top 3 instead of default 10
],
}
summary = ds.summary(override_dtype_agg_mapping=custom_mapping)
actual = summary.to_pandas()
expected_stats = [
"approx_quantile[0]",
"approx_quantile[1]",
"approx_quantile[2]",
"approx_topk[0]",
"approx_topk[1]",
"approx_topk[2]",
"count",
]
# Verify all expected statistics are present
actual_stats = set(actual[DatasetSummary.STATISTIC_COLUMN].tolist())
assert all(stat in actual_stats for stat in expected_stats)
# Helper to get statistic value for a column
def get_stat(stat_name, col_name):
return actual[actual[DatasetSummary.STATISTIC_COLUMN] == stat_name][
col_name
].iloc[0]
# Verify all expected values
expected_values = {
("approx_quantile[0]", "value"): 10.0,
("approx_quantile[1]", "value"): 40.0,
("approx_quantile[2]", "value"): 70.0,
("approx_topk[0]", "category"): {"category": "apple", "count": 3},
("approx_topk[1]", "category"): {"category": "banana", "count": 2},
("approx_topk[2]", "category"): {"category": "date", "count": 1},
("count", "value"): 7.0,
("count", "category"): 7,
}
for (stat, col), expected in expected_values.items():
actual_value = get_stat(stat, col)
assert (
actual_value == expected
), f"{stat}[{col}] = {actual_value} != {expected}"
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))