Files
2026-07-13 13:17:40 +08:00

571 lines
19 KiB
Python

import re
import warnings
from typing import Dict, Union
from unittest.mock import patch
import numpy as np
import pandas as pd
import pyarrow
import pytest
import ray
from ray.data.aggregate import Mean
from ray.data.constants import MAX_REPR_LENGTH
from ray.data.preprocessor import Preprocessor
from ray.data.preprocessors import (
Categorizer,
Chain,
Concatenator,
CountVectorizer,
FeatureHasher,
HashingVectorizer,
LabelEncoder,
MaxAbsScaler,
MinMaxScaler,
MultiHotEncoder,
Normalizer,
OneHotEncoder,
OrdinalEncoder,
PowerTransformer,
RobustScaler,
SimpleImputer,
StandardScaler,
Tokenizer,
TorchVisionPreprocessor,
)
from ray.data.util.data_batch_conversion import BatchFormat
@pytest.fixture
def create_dummy_preprocessors():
class DummyPreprocessorWithNothing(Preprocessor):
_is_fittable = False
class DummyPreprocessorWithPandas(DummyPreprocessorWithNothing):
def _transform_pandas(self, df: "pd.DataFrame") -> "pd.DataFrame":
return df
class DummyPreprocessorWithNumpy(DummyPreprocessorWithNothing):
batch_format = "numpy"
def _transform_numpy(
self, np_data: Union[np.ndarray, Dict[str, np.ndarray]]
) -> Union[np.ndarray, Dict[str, np.ndarray]]:
return np_data
class DummyPreprocessorWithPandasAndNumpy(DummyPreprocessorWithNothing):
def _transform_pandas(self, df: "pd.DataFrame") -> "pd.DataFrame":
return df
def _transform_numpy(
self, np_data: Union[np.ndarray, Dict[str, np.ndarray]]
) -> Union[np.ndarray, Dict[str, np.ndarray]]:
return np_data
class DummyPreprocessorWithPandasAndNumpyPreferred(DummyPreprocessorWithNothing):
def _transform_pandas(self, df: "pd.DataFrame") -> "pd.DataFrame":
return df
def _transform_numpy(
self, np_data: Union[np.ndarray, Dict[str, np.ndarray]]
) -> Union[np.ndarray, Dict[str, np.ndarray]]:
return np_data
def preferred_batch_format(cls) -> BatchFormat:
return BatchFormat.NUMPY
yield (
DummyPreprocessorWithNothing(),
DummyPreprocessorWithPandas(),
DummyPreprocessorWithNumpy(),
DummyPreprocessorWithPandasAndNumpy(),
DummyPreprocessorWithPandasAndNumpyPreferred(),
)
@pytest.mark.parametrize(
"preprocessor",
[
Categorizer(columns=["X"]),
CountVectorizer(columns=["X"]),
Chain(StandardScaler(columns=["X"]), MinMaxScaler(columns=["X"])),
FeatureHasher(columns=["X"], num_features=1, output_column="X_transformed"),
HashingVectorizer(columns=["X"], num_features=1),
LabelEncoder(label_column="X"),
MaxAbsScaler(columns=["X"]),
MinMaxScaler(columns=["X"]),
MultiHotEncoder(columns=["X"]),
Normalizer(columns=["X"]),
OneHotEncoder(columns=["X"]),
OrdinalEncoder(columns=["X"]),
PowerTransformer(columns=["X"], power=1),
RobustScaler(columns=["X"]),
SimpleImputer(columns=["X"]),
StandardScaler(columns=["X"]),
Concatenator(columns=["X"]),
Tokenizer(columns=["X"]),
],
)
def test_repr(preprocessor):
representation = repr(preprocessor)
assert len(representation) < MAX_REPR_LENGTH
pattern = re.compile(f"^{preprocessor.__class__.__name__}\\((.*)\\)$")
assert pattern.match(representation)
def test_fitted_preprocessor_without_stats():
"""Tests that Preprocessors can be fitted without needing to set self.stats_."""
class FittablePreprocessor(Preprocessor):
def _fit(self, ds):
return self
preprocessor = FittablePreprocessor()
ds = ray.data.from_items([1])
_ = preprocessor.fit(ds)
assert preprocessor.fit_status() == Preprocessor.FitStatus.FITTED
def test_fitted_preprocessor_with_stats():
"""Tests that Preprocessors can be fitted by setting an attribute that ends
with _."""
class FittablePreprocessor(Preprocessor):
...
preprocessor = FittablePreprocessor()
preprocessor.stats_ = True
assert preprocessor.fit_status() == Preprocessor.FitStatus.FITTED
@patch.object(warnings, "warn")
def test_fit_twice(mocked_warn):
"""Tests that a warning msg should be printed."""
col_a = [-1, 0, 1]
col_b = [1, 3, 5]
col_c = [1, 1, None]
in_df = pd.DataFrame.from_dict({"A": col_a, "B": col_b, "C": col_c})
ds = ray.data.from_pandas(in_df)
scaler = MinMaxScaler(["B", "C"])
# Fit data.
scaler.fit(ds)
assert scaler.stats_ == {"min(B)": 1, "max(B)": 5, "min(C)": 1, "max(C)": 1}
ds = ds.map_batches(lambda x: {k: v * 2 for k, v in x.items()})
# Fit again
scaler.fit(ds)
# Assert that the fitted state is corresponding to the second ds.
assert scaler.stats_ == {"min(B)": 2, "max(B)": 10, "min(C)": 2, "max(C)": 2}
msg = (
"`fit` has already been called on the preprocessor (or at least one "
"contained preprocessors if this is a chain). "
"All previously fitted state will be overwritten!"
)
mocked_warn.assert_called_once_with(msg)
def test_fit_twice_clears_stale_stats():
"""Tests that fit() clears stale stats when stat keys are data-dependent.
When a preprocessor's stat keys depend on the data (e.g., auto-detected columns),
calling fit() again on a different dataset should not retain stale stats from
the previous fit. This ensures that fit(A).fit(B) is equivalent to fit(B).
"""
class DataDependentPreprocessor(Preprocessor):
"""A preprocessor whose stat keys depend on the data columns present."""
_is_fittable = True
def _fit(self, ds):
# Dynamically detect columns from the dataset schema
schema = ds.schema()
column_names = list(schema.names)
self.stat_computation_plan.add_aggregator(
aggregator_fn=Mean,
columns=column_names,
)
return self
def _transform_pandas(self, df):
return df
# Dataset A has columns: "a", "b"
dataset_a = ray.data.from_items(
[
{"a": 1.0, "b": 10.0},
{"a": 2.0, "b": 20.0},
{"a": 3.0, "b": 30.0},
]
)
# Dataset B has columns: "b", "c" (note: "a" is missing, "c" is new)
dataset_b = ray.data.from_items(
[
{"b": 100.0, "c": 1000.0},
{"b": 200.0, "c": 2000.0},
{"b": 300.0, "c": 3000.0},
]
)
preprocessor = DataDependentPreprocessor()
# First fit on dataset A
preprocessor.fit(dataset_a)
assert preprocessor.stats_ == {"mean(a)": 2.0, "mean(b)": 20.0}
# Second fit on dataset B - stale stats should be cleared
preprocessor.fit(dataset_b)
# Verify stale stat "mean(a)" is NOT present
# Verify stats are correct after refit, and stale stats are cleared.
expected_stats = {"mean(b)": 200.0, "mean(c)": 2000.0}
assert preprocessor.stats_ == expected_stats, (
f"Stats after refit are incorrect. "
f"Expected: {expected_stats}, Got: {preprocessor.stats_}"
)
def test_transform_all_configs():
batch_size = 2
num_cpus = 2
concurrency = 2
memory = 1024
class DummyPreprocessor(Preprocessor):
_is_fittable = False
def _get_transform_config(self):
return {"batch_size": batch_size}
def _transform_numpy(self, data):
assert ray.get_runtime_context().get_assigned_resources()["CPU"] == num_cpus
assert (
ray.get_runtime_context().get_assigned_resources()["memory"] == memory
)
# Read(10 rows) → Limit(5) → Transform(batch_size=2)
assert (
len(data["value"]) <= batch_size
) # The last batch is size 1, and limit pushdown resulted in the transform occurring for fewer rows.
return data
def _transform_pandas(self, data):
raise RuntimeError(
"Pandas transform should not be called with numpy batch format."
)
def _determine_transform_to_use(self):
return "numpy"
prep = DummyPreprocessor()
ds = ray.data.from_pandas(pd.DataFrame({"value": list(range(10))}))
ds = prep.transform(
ds,
num_cpus=num_cpus,
memory=memory,
concurrency=concurrency,
)
assert [x["value"] for x in ds.take(5)] == [0, 1, 2, 3, 4]
@pytest.mark.parametrize("dataset_format", ["simple", "pandas", "arrow"])
def test_transform_all_formats(create_dummy_preprocessors, dataset_format):
(
with_nothing,
with_pandas,
with_numpy,
with_pandas_and_numpy,
with_pandas_and_numpy_preferred,
) = create_dummy_preprocessors
if dataset_format == "simple":
ds = ray.data.range(10)
elif dataset_format == "pandas":
df = pd.DataFrame([[1, 2, 3], [4, 5, 6]], columns=["A", "B", "C"])
ds = ray.data.from_pandas(df)
elif dataset_format == "arrow":
df = pd.DataFrame([[1, 2, 3], [4, 5, 6]], columns=["A", "B", "C"])
ds = ray.data.from_arrow(pyarrow.Table.from_pandas(df))
else:
raise ValueError(f"Untested dataset_format configuration: {dataset_format}.")
with pytest.raises(NotImplementedError):
with_nothing.transform(ds)
patcher = patch.object(ray.data.dataset.Dataset, "map_batches")
with patcher as mock_map_batches:
with_pandas.transform(ds)
mock_map_batches.assert_called_once_with(
with_pandas._transform_pandas,
batch_format=BatchFormat.PANDAS,
zero_copy_batch=True,
)
with patcher as mock_map_batches:
with_numpy.transform(ds)
mock_map_batches.assert_called_once_with(
with_numpy._transform_numpy,
batch_format=BatchFormat.NUMPY,
zero_copy_batch=True,
)
# Pandas preferred by default.
with patcher as mock_map_batches:
with_pandas_and_numpy.transform(ds)
mock_map_batches.assert_called_once_with(
with_pandas_and_numpy._transform_pandas,
batch_format=BatchFormat.PANDAS,
zero_copy_batch=True,
)
with patcher as mock_map_batches:
with_pandas_and_numpy_preferred.transform(ds)
mock_map_batches.assert_called_once_with(
with_pandas_and_numpy_preferred._transform_numpy,
batch_format=BatchFormat.NUMPY,
zero_copy_batch=True,
)
def test_numpy_pandas_support_transform_batch_wrong_format(create_dummy_preprocessors):
# Case 1: simple dataset. No support
(
with_nothing,
with_pandas,
with_numpy,
with_pandas_and_numpy,
with_pandas_and_numpy_preferred,
) = create_dummy_preprocessors
batch = [1, 2, 3]
with pytest.raises(ValueError):
with_nothing.transform_batch(batch)
with pytest.raises(ValueError):
with_pandas.transform_batch(batch)
with pytest.raises(ValueError):
with_numpy.transform_batch(batch)
with pytest.raises(ValueError):
with_pandas_and_numpy.transform_batch(batch)
with pytest.raises(ValueError):
with_pandas_and_numpy_preferred.transform_batch(batch)
def test_numpy_pandas_support_transform_batch_pandas(create_dummy_preprocessors):
# Case 2: pandas dataset
(
with_nothing,
with_pandas,
with_numpy,
with_pandas_and_numpy,
with_pandas_and_numpy_preferred,
) = create_dummy_preprocessors
df = pd.DataFrame([[1, 2, 3], [4, 5, 6]], columns=["A", "B", "C"])
df_single_column = pd.DataFrame([1, 2, 3], columns=["A"])
with pytest.raises(NotImplementedError):
with_nothing.transform_batch(df)
with pytest.raises(NotImplementedError):
with_nothing.transform_batch(df_single_column)
assert isinstance(with_pandas.transform_batch(df), pd.DataFrame)
assert isinstance(with_pandas.transform_batch(df_single_column), pd.DataFrame)
assert isinstance(with_numpy.transform_batch(df), (np.ndarray, dict))
# We can get pd.DataFrame after returning numpy data from UDF
assert isinstance(with_numpy.transform_batch(df_single_column), (np.ndarray, dict))
assert isinstance(with_pandas_and_numpy.transform_batch(df), pd.DataFrame)
assert isinstance(
with_pandas_and_numpy.transform_batch(df_single_column), pd.DataFrame
)
assert isinstance(
with_pandas_and_numpy_preferred.transform_batch(df), (np.ndarray, dict)
)
assert isinstance(
with_pandas_and_numpy_preferred.transform_batch(df_single_column),
(np.ndarray, dict),
)
def test_numpy_pandas_support_transform_batch_arrow(create_dummy_preprocessors):
# Case 3: arrow dataset
(
with_nothing,
with_pandas,
with_numpy,
with_pandas_and_numpy,
with_pandas_and_numpy_preferred,
) = create_dummy_preprocessors
df = pd.DataFrame([[1, 2, 3], [4, 5, 6]], columns=["A", "B", "C"])
df_single_column = pd.DataFrame([1, 2, 3], columns=["A"])
table = pyarrow.Table.from_pandas(df)
table_single_column = pyarrow.Table.from_pandas(df_single_column)
with pytest.raises(NotImplementedError):
with_nothing.transform_batch(table)
with pytest.raises(NotImplementedError):
with_nothing.transform_batch(table_single_column)
assert isinstance(with_pandas.transform_batch(table), pd.DataFrame)
assert isinstance(with_pandas.transform_batch(table_single_column), pd.DataFrame)
assert isinstance(with_numpy.transform_batch(table), (np.ndarray, dict))
# We can get pyarrow.Table after returning numpy data from UDF
assert isinstance(
with_numpy.transform_batch(table_single_column), (np.ndarray, dict)
)
assert isinstance(with_pandas_and_numpy.transform_batch(table), pd.DataFrame)
assert isinstance(
with_pandas_and_numpy.transform_batch(table_single_column), pd.DataFrame
)
assert isinstance(
with_pandas_and_numpy_preferred.transform_batch(table), (np.ndarray, dict)
)
assert isinstance(
with_pandas_and_numpy_preferred.transform_batch(table_single_column),
(np.ndarray, dict),
)
def test_numpy_pandas_support_transform_batch_tensor(create_dummy_preprocessors):
# Case 4: tensor dataset created by from numpy data directly
(
with_nothing,
with_pandas,
with_numpy,
with_pandas_and_numpy,
with_pandas_and_numpy_preferred,
) = create_dummy_preprocessors
np_data = np.arange(12).reshape(3, 2, 2)
np_single_column = {"A": np.arange(12).reshape(3, 2, 2)}
np_multi_column = {
"A": np.arange(12).reshape(3, 2, 2),
"B": np.arange(12, 24).reshape(3, 2, 2),
}
with pytest.raises(NotImplementedError):
with_nothing.transform_batch(np_data)
with pytest.raises(NotImplementedError):
with_nothing.transform_batch(np_single_column)
with pytest.raises(NotImplementedError):
with_nothing.transform_batch(np_multi_column)
assert isinstance(with_pandas.transform_batch(np_data), pd.DataFrame)
assert isinstance(with_pandas.transform_batch(np_single_column), pd.DataFrame)
assert isinstance(with_pandas.transform_batch(np_multi_column), pd.DataFrame)
assert isinstance(with_numpy.transform_batch(np_data), np.ndarray)
assert isinstance(with_numpy.transform_batch(np_single_column), dict)
assert isinstance(with_numpy.transform_batch(np_multi_column), dict)
assert isinstance(with_pandas_and_numpy.transform_batch(np_data), pd.DataFrame)
assert isinstance(
with_pandas_and_numpy.transform_batch(np_single_column), pd.DataFrame
)
assert isinstance(
with_pandas_and_numpy.transform_batch(np_multi_column), pd.DataFrame
)
assert isinstance(
with_pandas_and_numpy_preferred.transform_batch(np_data), np.ndarray
)
assert isinstance(
with_pandas_and_numpy_preferred.transform_batch(np_single_column), dict
)
assert isinstance(
with_pandas_and_numpy_preferred.transform_batch(np_multi_column), dict
)
def test_get_input_output_columns():
"""Tests get_input_columns() and get_output_columns() methods."""
# Test with preprocessors that have columns attribute
scaler = StandardScaler(columns=["A", "B"])
assert scaler.get_input_columns() == ["A", "B"]
assert scaler.get_output_columns() == ["A", "B"]
# Test with output_columns specified
scaler_with_output = StandardScaler(
columns=["A", "B"], output_columns=["A_scaled", "B_scaled"]
)
assert scaler_with_output.get_input_columns() == ["A", "B"]
assert scaler_with_output.get_output_columns() == ["A_scaled", "B_scaled"]
# Test with encoders
encoder = OneHotEncoder(columns=["X", "Y"])
assert encoder.get_input_columns() == ["X", "Y"]
assert encoder.get_output_columns() == ["X", "Y"]
encoder_with_output = OneHotEncoder(
columns=["X", "Y"], output_columns=["X_encoded", "Y_encoded"]
)
assert encoder_with_output.get_input_columns() == ["X", "Y"]
assert encoder_with_output.get_output_columns() == ["X_encoded", "Y_encoded"]
# Test LabelEncoder without output_column (in-place transformation)
label_encoder = LabelEncoder(label_column="target")
assert label_encoder.get_input_columns() == ["target"]
assert label_encoder.get_output_columns() == ["target"]
# Test LabelEncoder with output_column (append mode)
label_encoder = LabelEncoder(label_column="target", output_column="target_encoded")
assert label_encoder.get_input_columns() == ["target"]
assert label_encoder.get_output_columns() == ["target_encoded"]
# Test Concatenator (uses output_column_name instead of output_columns)
concatenator = Concatenator(columns=["A", "B"])
assert concatenator.get_input_columns() == ["A", "B"]
assert concatenator.get_output_columns() == ["concat_out"]
concatenator_with_output = Concatenator(
columns=["A", "B"], output_column_name="AB_concat"
)
assert concatenator_with_output.get_input_columns() == ["A", "B"]
assert concatenator_with_output.get_output_columns() == ["AB_concat"]
# Test FeatureHasher (uses output_column instead of output_columns)
feature_hasher = FeatureHasher(
columns=["token1", "token2"], num_features=8, output_column="hashed"
)
assert feature_hasher.get_input_columns() == ["token1", "token2"]
assert feature_hasher.get_output_columns() == ["hashed"]
# Test TorchVisionPreprocessor (uses _columns and _output_columns)
torch_preprocessor = TorchVisionPreprocessor(
columns=["image"], transform=lambda x: x
)
assert torch_preprocessor.get_input_columns() == ["image"]
assert torch_preprocessor.get_output_columns() == ["image"]
torch_preprocessor_with_output = TorchVisionPreprocessor(
columns=["image"], transform=lambda x: x, output_columns=["image_transformed"]
)
assert torch_preprocessor_with_output.get_input_columns() == ["image"]
assert torch_preprocessor_with_output.get_output_columns() == ["image_transformed"]
# Test with preprocessor without columns attribute
class CustomPreprocessor(Preprocessor):
_is_fittable = False
custom = CustomPreprocessor()
assert custom.get_input_columns() == []
assert custom.get_output_columns() == []
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-sv", __file__]))