chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,764 @@
|
||||
"""
|
||||
Backwards compatibility tests for Preprocessor private field renaming.
|
||||
|
||||
These tests verify that preprocessors pickled with old public field names
|
||||
(e.g., 'columns') can be deserialized correctly after fields were renamed
|
||||
to private (e.g., '_columns').
|
||||
|
||||
The __setstate__ method in each preprocessor handles migration automatically.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.data.preprocessors import (
|
||||
Categorizer,
|
||||
Chain,
|
||||
Concatenator,
|
||||
CountVectorizer,
|
||||
CustomKBinsDiscretizer,
|
||||
FeatureHasher,
|
||||
HashingVectorizer,
|
||||
LabelEncoder,
|
||||
MaxAbsScaler,
|
||||
MinMaxScaler,
|
||||
MultiHotEncoder,
|
||||
Normalizer,
|
||||
OneHotEncoder,
|
||||
OrdinalEncoder,
|
||||
PowerTransformer,
|
||||
RobustScaler,
|
||||
SimpleImputer,
|
||||
StandardScaler,
|
||||
Tokenizer,
|
||||
TorchVisionPreprocessor,
|
||||
UniformKBinsDiscretizer,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# Field Migration Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"preprocessor_class,old_state,expected_attrs",
|
||||
[
|
||||
(
|
||||
Concatenator,
|
||||
{
|
||||
"columns": ["A", "B"],
|
||||
"output_column_name": "concat",
|
||||
"dtype": np.float32,
|
||||
"raise_if_missing": True,
|
||||
"flatten": True,
|
||||
},
|
||||
{
|
||||
"columns": ["A", "B"],
|
||||
"output_column_name": "concat",
|
||||
"dtype": np.float32,
|
||||
"raise_if_missing": True,
|
||||
"flatten": True,
|
||||
},
|
||||
),
|
||||
(
|
||||
Normalizer,
|
||||
{
|
||||
"columns": ["A", "B"],
|
||||
"norm": "l1",
|
||||
"output_columns": ["A_norm", "B_norm"],
|
||||
},
|
||||
{
|
||||
"columns": ["A", "B"],
|
||||
"norm": "l1",
|
||||
"output_columns": ["A_norm", "B_norm"],
|
||||
},
|
||||
),
|
||||
(
|
||||
Tokenizer,
|
||||
{
|
||||
"columns": ["text"],
|
||||
"tokenization_fn": lambda s: s.split(),
|
||||
"output_columns": ["text_tokens"],
|
||||
},
|
||||
{
|
||||
"columns": ["text"],
|
||||
"tokenization_fn": "callable", # Special marker
|
||||
"output_columns": ["text_tokens"],
|
||||
},
|
||||
),
|
||||
(
|
||||
PowerTransformer,
|
||||
{
|
||||
"columns": ["A", "B"],
|
||||
"power": 3,
|
||||
"method": "box-cox",
|
||||
"output_columns": ["A_pow", "B_pow"],
|
||||
},
|
||||
{
|
||||
"columns": ["A", "B"],
|
||||
"power": 3,
|
||||
"method": "box-cox",
|
||||
"output_columns": ["A_pow", "B_pow"],
|
||||
},
|
||||
),
|
||||
(
|
||||
HashingVectorizer,
|
||||
{
|
||||
"columns": ["text"],
|
||||
"num_features": 200,
|
||||
"tokenization_fn": lambda s: s.split(),
|
||||
"output_columns": ["text_vec"],
|
||||
},
|
||||
{
|
||||
"columns": ["text"],
|
||||
"num_features": 200,
|
||||
"tokenization_fn": "callable",
|
||||
"output_columns": ["text_vec"],
|
||||
},
|
||||
),
|
||||
(
|
||||
CountVectorizer,
|
||||
{
|
||||
"columns": ["text"],
|
||||
"tokenization_fn": lambda s: s.split(),
|
||||
"max_features": 100,
|
||||
"output_columns": ["text_count"],
|
||||
},
|
||||
{
|
||||
"columns": ["text"],
|
||||
"tokenization_fn": "callable",
|
||||
"max_features": 100,
|
||||
"output_columns": ["text_count"],
|
||||
},
|
||||
),
|
||||
(
|
||||
FeatureHasher,
|
||||
{"columns": ["A", "B"], "num_features": 20, "output_column": "features"},
|
||||
{"columns": ["A", "B"], "num_features": 20, "output_column": "features"},
|
||||
),
|
||||
(
|
||||
OrdinalEncoder,
|
||||
{
|
||||
"columns": ["color"],
|
||||
"output_columns": ["color_encoded"],
|
||||
"encode_lists": False,
|
||||
},
|
||||
{
|
||||
"columns": ["color"],
|
||||
"output_columns": ["color_encoded"],
|
||||
"encode_lists": False,
|
||||
},
|
||||
),
|
||||
(
|
||||
OneHotEncoder,
|
||||
{
|
||||
"columns": ["color"],
|
||||
"output_columns": ["color_encoded"],
|
||||
"max_categories": {"color": 5},
|
||||
},
|
||||
{
|
||||
"columns": ["color"],
|
||||
"output_columns": ["color_encoded"],
|
||||
"max_categories": {"color": 5},
|
||||
},
|
||||
),
|
||||
(
|
||||
MultiHotEncoder,
|
||||
{
|
||||
"columns": ["tags"],
|
||||
"output_columns": ["tags_encoded"],
|
||||
"max_categories": {},
|
||||
},
|
||||
{"columns": ["tags"], "output_columns": ["tags_encoded"]},
|
||||
),
|
||||
(
|
||||
LabelEncoder,
|
||||
{"label_column": "label", "output_column": "label_id"},
|
||||
{"label_column": "label", "output_column": "label_id"},
|
||||
),
|
||||
(
|
||||
Categorizer,
|
||||
{"columns": ["sex"], "output_columns": ["sex_cat"], "dtypes": {}},
|
||||
{"columns": ["sex"], "output_columns": ["sex_cat"]},
|
||||
),
|
||||
(
|
||||
StandardScaler,
|
||||
{"columns": ["A", "B"], "output_columns": ["A_scaled", "B_scaled"]},
|
||||
{"columns": ["A", "B"], "output_columns": ["A_scaled", "B_scaled"]},
|
||||
),
|
||||
(
|
||||
MinMaxScaler,
|
||||
{"columns": ["A"], "output_columns": ["A_scaled"]},
|
||||
{"columns": ["A"], "output_columns": ["A_scaled"]},
|
||||
),
|
||||
(
|
||||
MaxAbsScaler,
|
||||
{"columns": ["A"], "output_columns": ["A_scaled"]},
|
||||
{"columns": ["A"], "output_columns": ["A_scaled"]},
|
||||
),
|
||||
(
|
||||
RobustScaler,
|
||||
{
|
||||
"columns": ["A"],
|
||||
"output_columns": ["A_scaled"],
|
||||
"quantile_range": (0.1, 0.9),
|
||||
"quantile_precision": 1000,
|
||||
},
|
||||
{
|
||||
"columns": ["A"],
|
||||
"output_columns": ["A_scaled"],
|
||||
"quantile_range": (0.1, 0.9),
|
||||
"quantile_precision": 1000,
|
||||
},
|
||||
),
|
||||
(
|
||||
SimpleImputer,
|
||||
{
|
||||
"columns": ["A", "B"],
|
||||
"output_columns": ["A_imputed", "B_imputed"],
|
||||
"strategy": "median",
|
||||
"fill_value": 99.0,
|
||||
},
|
||||
{
|
||||
"columns": ["A", "B"],
|
||||
"output_columns": ["A_imputed", "B_imputed"],
|
||||
"strategy": "median",
|
||||
"fill_value": 99.0,
|
||||
},
|
||||
),
|
||||
(
|
||||
CustomKBinsDiscretizer,
|
||||
{
|
||||
"columns": ["A", "B"],
|
||||
"bins": [0, 1, 2, 3],
|
||||
"right": False,
|
||||
"include_lowest": True,
|
||||
"duplicates": "drop",
|
||||
"dtypes": None,
|
||||
"output_columns": ["A_binned", "B_binned"],
|
||||
},
|
||||
{
|
||||
"columns": ["A", "B"],
|
||||
"bins": [0, 1, 2, 3],
|
||||
"right": False,
|
||||
"include_lowest": True,
|
||||
"duplicates": "drop",
|
||||
"dtypes": None,
|
||||
"output_columns": ["A_binned", "B_binned"],
|
||||
},
|
||||
),
|
||||
(
|
||||
UniformKBinsDiscretizer,
|
||||
{
|
||||
"columns": ["A", "B"],
|
||||
"bins": 4,
|
||||
"right": False,
|
||||
"include_lowest": True,
|
||||
"duplicates": "drop",
|
||||
"dtypes": None,
|
||||
"output_columns": ["A_binned", "B_binned"],
|
||||
},
|
||||
{
|
||||
"columns": ["A", "B"],
|
||||
"bins": 4,
|
||||
"right": False,
|
||||
"include_lowest": True,
|
||||
"duplicates": "drop",
|
||||
"dtypes": None,
|
||||
"output_columns": ["A_binned", "B_binned"],
|
||||
},
|
||||
),
|
||||
],
|
||||
ids=lambda x: x.__name__ if hasattr(x, "__name__") else str(x)[:20],
|
||||
)
|
||||
def test_field_migration_from_old_public_names(
|
||||
preprocessor_class, old_state, expected_attrs
|
||||
):
|
||||
"""Verify old public field names are migrated to new private fields."""
|
||||
preprocessor = preprocessor_class.__new__(preprocessor_class)
|
||||
preprocessor.__setstate__(old_state)
|
||||
|
||||
for attr_name, expected_value in expected_attrs.items():
|
||||
actual_value = getattr(preprocessor, attr_name)
|
||||
if expected_value == "callable":
|
||||
assert callable(actual_value), f"{attr_name} should be callable"
|
||||
else:
|
||||
assert actual_value == expected_value, f"Mismatch in {attr_name}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"preprocessor_class,minimal_state,expected_defaults",
|
||||
[
|
||||
# Callable default: tokenization_fn must be stored as the function itself,
|
||||
# not called. This would have failed with the old callable() check.
|
||||
(
|
||||
Tokenizer,
|
||||
{"columns": ["text"]},
|
||||
{"tokenization_fn": "callable", "output_columns": ["text"]},
|
||||
),
|
||||
(
|
||||
HashingVectorizer,
|
||||
{"columns": ["text"], "num_features": 100},
|
||||
{"tokenization_fn": "callable", "output_columns": ["text"]},
|
||||
),
|
||||
(
|
||||
CountVectorizer,
|
||||
{"columns": ["text"]},
|
||||
{"tokenization_fn": "callable", "output_columns": ["text"]},
|
||||
),
|
||||
# _Computed default: output_columns derives from _columns.
|
||||
(
|
||||
StandardScaler,
|
||||
{"columns": ["A", "B"]},
|
||||
{"output_columns": ["A", "B"]},
|
||||
),
|
||||
# _Computed default deriving from a different source field.
|
||||
(
|
||||
LabelEncoder,
|
||||
{"label_column": "label"},
|
||||
{"output_column": "label"},
|
||||
),
|
||||
# Plain value default alongside a _Computed default.
|
||||
(
|
||||
Normalizer,
|
||||
{"columns": ["A", "B"]},
|
||||
{"norm": "l2", "output_columns": ["A", "B"]},
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"Tokenizer",
|
||||
"HashingVectorizer",
|
||||
"CountVectorizer",
|
||||
"StandardScaler",
|
||||
"LabelEncoder",
|
||||
"Normalizer",
|
||||
],
|
||||
)
|
||||
def test_missing_optional_fields_use_defaults(
|
||||
preprocessor_class, minimal_state, expected_defaults
|
||||
):
|
||||
"""
|
||||
Verify that absent optional fields are filled with their correct defaults.
|
||||
|
||||
This exercises the default-fallback branch of migrate_private_fields. The minimal_state
|
||||
deliberately omits optional fields to force the default path.
|
||||
"""
|
||||
preprocessor = preprocessor_class.__new__(preprocessor_class)
|
||||
preprocessor.__setstate__(minimal_state)
|
||||
|
||||
for attr_name, expected_value in expected_defaults.items():
|
||||
actual_value = getattr(preprocessor, attr_name)
|
||||
if expected_value == "callable":
|
||||
assert callable(
|
||||
actual_value
|
||||
), f"{attr_name} should be a stored callable, not the result of calling it"
|
||||
else:
|
||||
assert (
|
||||
actual_value == expected_value
|
||||
), f"Mismatch in {attr_name}: {actual_value!r} != {expected_value!r}"
|
||||
|
||||
|
||||
def test_torchvision_preprocessor_field_migration():
|
||||
try:
|
||||
from torchvision import transforms
|
||||
except ImportError:
|
||||
pytest.skip("torchvision not installed")
|
||||
|
||||
transform = transforms.Lambda(lambda x: x)
|
||||
preprocessor = TorchVisionPreprocessor.__new__(TorchVisionPreprocessor)
|
||||
state = {
|
||||
"columns": ["image"],
|
||||
"output_columns": ["image_out"],
|
||||
"torchvision_transform": transform,
|
||||
"batched": True,
|
||||
}
|
||||
preprocessor.__setstate__(state)
|
||||
|
||||
assert preprocessor.columns == ["image"]
|
||||
assert preprocessor.output_columns == ["image_out"]
|
||||
assert preprocessor.torchvision_transform == transform
|
||||
assert preprocessor.batched is True
|
||||
|
||||
|
||||
def test_chain_field_migration():
|
||||
scaler1 = StandardScaler(columns=["A"])
|
||||
scaler2 = StandardScaler(columns=["B"])
|
||||
chain = Chain.__new__(Chain)
|
||||
|
||||
state = {"preprocessors": (scaler1, scaler2)}
|
||||
chain.__setstate__(state)
|
||||
|
||||
assert len(chain.preprocessors) == 2
|
||||
assert chain.preprocessors[0] == scaler1
|
||||
assert chain.preprocessors[1] == scaler2
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Functional Test Helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _simulate_old_format_deserialization(preprocessor, field_mapping):
|
||||
"""Simulate deserialization from old format by renaming private->public fields."""
|
||||
state = preprocessor.__dict__.copy()
|
||||
for public_name, private_name in field_mapping.items():
|
||||
if private_name in state:
|
||||
state[public_name] = state.pop(private_name)
|
||||
|
||||
new_preprocessor = preprocessor.__class__.__new__(preprocessor.__class__)
|
||||
new_preprocessor.__setstate__(state)
|
||||
return new_preprocessor
|
||||
|
||||
|
||||
def _test_functional_backwards_compat(preprocessor, test_ds, field_mapping):
|
||||
"""Generic functional test: verify deserialized preprocessor produces same output."""
|
||||
expected_result = preprocessor.transform(test_ds).to_pandas()
|
||||
new_preprocessor = _simulate_old_format_deserialization(preprocessor, field_mapping)
|
||||
result = new_preprocessor.transform(test_ds).to_pandas()
|
||||
pd.testing.assert_frame_equal(result, expected_result)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Functional Tests - Simple Preprocessors (No Fitting Required)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"setup_func,field_mapping",
|
||||
[
|
||||
(
|
||||
lambda: (
|
||||
Concatenator(columns=["A", "B"], output_column_name="C"),
|
||||
pd.DataFrame({"A": [1, 2], "B": [3, 4]}),
|
||||
{
|
||||
"columns": "_columns",
|
||||
"output_column_name": "_output_column_name",
|
||||
"dtype": "_dtype",
|
||||
"raise_if_missing": "_raise_if_missing",
|
||||
"flatten": "_flatten",
|
||||
},
|
||||
),
|
||||
None,
|
||||
),
|
||||
(
|
||||
lambda: (
|
||||
Normalizer(columns=["A", "B"], norm="l2"),
|
||||
pd.DataFrame({"A": [1.0, 2.0], "B": [3.0, 4.0]}),
|
||||
{
|
||||
"columns": "_columns",
|
||||
"norm": "_norm",
|
||||
"output_columns": "_output_columns",
|
||||
},
|
||||
),
|
||||
None,
|
||||
),
|
||||
(
|
||||
lambda: (
|
||||
Tokenizer(columns=["text"]),
|
||||
pd.DataFrame({"text": ["hello world", "foo bar"]}),
|
||||
{
|
||||
"columns": "_columns",
|
||||
"tokenization_fn": "_tokenization_fn",
|
||||
"output_columns": "_output_columns",
|
||||
},
|
||||
),
|
||||
None,
|
||||
),
|
||||
(
|
||||
lambda: (
|
||||
PowerTransformer(columns=["A", "B"], power=2),
|
||||
pd.DataFrame({"A": [1.0, 2.0, 3.0], "B": [4.0, 5.0, 6.0]}),
|
||||
{
|
||||
"columns": "_columns",
|
||||
"power": "_power",
|
||||
"method": "_method",
|
||||
"output_columns": "_output_columns",
|
||||
},
|
||||
),
|
||||
None,
|
||||
),
|
||||
(
|
||||
lambda: (
|
||||
HashingVectorizer(columns=["text"], num_features=10),
|
||||
pd.DataFrame({"text": ["hello world", "foo bar"]}),
|
||||
{
|
||||
"columns": "_columns",
|
||||
"num_features": "_num_features",
|
||||
"tokenization_fn": "_tokenization_fn",
|
||||
"output_columns": "_output_columns",
|
||||
},
|
||||
),
|
||||
None,
|
||||
),
|
||||
(
|
||||
lambda: (
|
||||
FeatureHasher(
|
||||
columns=["token_a", "token_b"],
|
||||
num_features=5,
|
||||
output_column="hashed",
|
||||
),
|
||||
pd.DataFrame({"token_a": [1, 2], "token_b": [3, 4]}),
|
||||
{
|
||||
"columns": "_columns",
|
||||
"num_features": "_num_features",
|
||||
"output_column": "_output_column",
|
||||
},
|
||||
),
|
||||
None,
|
||||
),
|
||||
(
|
||||
lambda: (
|
||||
CustomKBinsDiscretizer(
|
||||
columns=["A", "B"],
|
||||
bins=[0, 1, 2, 3, 4],
|
||||
output_columns=["A_binned", "B_binned"],
|
||||
),
|
||||
pd.DataFrame({"A": [0.5, 1.5, 2.5, 3.5], "B": [0.2, 1.2, 2.2, 3.2]}),
|
||||
{
|
||||
"columns": "_columns",
|
||||
"bins": "_bins",
|
||||
"right": "_right",
|
||||
"include_lowest": "_include_lowest",
|
||||
"duplicates": "_duplicates",
|
||||
"dtypes": "_dtypes",
|
||||
"output_columns": "_output_columns",
|
||||
},
|
||||
),
|
||||
None,
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"Concatenator",
|
||||
"Normalizer",
|
||||
"Tokenizer",
|
||||
"PowerTransformer",
|
||||
"HashingVectorizer",
|
||||
"FeatureHasher",
|
||||
"CustomKBinsDiscretizer",
|
||||
],
|
||||
)
|
||||
def test_simple_functional_backwards_compat(setup_func, field_mapping):
|
||||
"""Verify preprocessors that don't need fitting work after deserialization."""
|
||||
preprocessor, test_data, field_mapping = setup_func()
|
||||
test_ds = ray.data.from_pandas(test_data)
|
||||
_test_functional_backwards_compat(preprocessor, test_ds, field_mapping)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Functional Tests - Stateful Preprocessors (Require Fitting)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"setup_func",
|
||||
[
|
||||
lambda: (
|
||||
OrdinalEncoder(columns=["color"]),
|
||||
pd.DataFrame({"color": ["red", "green", "blue", "red", "green"]}),
|
||||
{
|
||||
"columns": "_columns",
|
||||
"output_columns": "_output_columns",
|
||||
"encode_lists": "_encode_lists",
|
||||
},
|
||||
),
|
||||
lambda: (
|
||||
OneHotEncoder(columns=["color"]),
|
||||
pd.DataFrame({"color": ["red", "green", "blue", "red", "green", "blue"]}),
|
||||
{
|
||||
"columns": "_columns",
|
||||
"output_columns": "_output_columns",
|
||||
"max_categories": "_max_categories",
|
||||
},
|
||||
),
|
||||
lambda: (
|
||||
LabelEncoder(label_column="label"),
|
||||
pd.DataFrame(
|
||||
{
|
||||
"feature": [1.0, 2.0, 3.0, 4.0],
|
||||
"label": ["cat", "dog", "cat", "bird"],
|
||||
}
|
||||
),
|
||||
{"label_column": "_label_column", "output_column": "_output_column"},
|
||||
),
|
||||
lambda: (
|
||||
StandardScaler(columns=["A", "B"]),
|
||||
pd.DataFrame(
|
||||
{"A": [1.0, 2.0, 3.0, 4.0, 5.0], "B": [10.0, 20.0, 30.0, 40.0, 50.0]}
|
||||
),
|
||||
{"columns": "_columns", "output_columns": "_output_columns"},
|
||||
),
|
||||
lambda: (
|
||||
MinMaxScaler(columns=["A", "B"]),
|
||||
pd.DataFrame(
|
||||
{"A": [1.0, 2.0, 3.0, 4.0, 5.0], "B": [10.0, 20.0, 30.0, 40.0, 50.0]}
|
||||
),
|
||||
{"columns": "_columns", "output_columns": "_output_columns"},
|
||||
),
|
||||
lambda: (
|
||||
RobustScaler(columns=["A"]),
|
||||
pd.DataFrame({"A": [1.0, 2.0, 3.0, 4.0, 5.0, 100.0]}),
|
||||
{
|
||||
"columns": "_columns",
|
||||
"output_columns": "_output_columns",
|
||||
"quantile_range": "_quantile_range",
|
||||
"quantile_precision": "_quantile_precision",
|
||||
},
|
||||
),
|
||||
lambda: (
|
||||
SimpleImputer(columns=["A", "B"], strategy="mean"),
|
||||
pd.DataFrame(
|
||||
{"A": [1.0, 2.0, None, 4.0, 5.0], "B": [10.0, None, 30.0, 40.0, 50.0]}
|
||||
),
|
||||
{
|
||||
"columns": "_columns",
|
||||
"output_columns": "_output_columns",
|
||||
"strategy": "_strategy",
|
||||
"fill_value": "_fill_value",
|
||||
},
|
||||
),
|
||||
lambda: (
|
||||
CountVectorizer(columns=["text"]),
|
||||
pd.DataFrame({"text": ["hello world", "foo bar", "hello foo"]}),
|
||||
{
|
||||
"columns": "_columns",
|
||||
"tokenization_fn": "_tokenization_fn",
|
||||
"max_features": "_max_features",
|
||||
"output_columns": "_output_columns",
|
||||
},
|
||||
),
|
||||
lambda: (
|
||||
UniformKBinsDiscretizer(
|
||||
columns=["A", "B"], bins=3, output_columns=["A_binned", "B_binned"]
|
||||
),
|
||||
pd.DataFrame(
|
||||
{"A": [1.0, 2.0, 3.0, 4.0, 5.0], "B": [10.0, 20.0, 30.0, 40.0, 50.0]}
|
||||
),
|
||||
{
|
||||
"columns": "_columns",
|
||||
"bins": "_bins",
|
||||
"right": "_right",
|
||||
"include_lowest": "_include_lowest",
|
||||
"duplicates": "_duplicates",
|
||||
"dtypes": "_dtypes",
|
||||
"output_columns": "_output_columns",
|
||||
},
|
||||
),
|
||||
lambda: (
|
||||
MultiHotEncoder(columns=["genre"]),
|
||||
pd.DataFrame(
|
||||
{
|
||||
"genre": [
|
||||
["comedy", "action"],
|
||||
["drama", "action"],
|
||||
["comedy", "drama"],
|
||||
]
|
||||
}
|
||||
),
|
||||
{
|
||||
"columns": "_columns",
|
||||
"output_columns": "_output_columns",
|
||||
"max_categories": "_max_categories",
|
||||
},
|
||||
),
|
||||
lambda: (
|
||||
MaxAbsScaler(columns=["A", "B"]),
|
||||
pd.DataFrame({"A": [-6.0, 3.0, -3.0], "B": [2.0, -4.0, 1.0]}),
|
||||
{"columns": "_columns", "output_columns": "_output_columns"},
|
||||
),
|
||||
lambda: (
|
||||
Categorizer(columns=["color"]),
|
||||
pd.DataFrame({"color": ["red", "green", "blue", "red", "green"]}),
|
||||
{
|
||||
"columns": "_columns",
|
||||
"output_columns": "_output_columns",
|
||||
"dtypes": "_dtypes",
|
||||
},
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"OrdinalEncoder",
|
||||
"OneHotEncoder",
|
||||
"LabelEncoder",
|
||||
"StandardScaler",
|
||||
"MinMaxScaler",
|
||||
"RobustScaler",
|
||||
"SimpleImputer",
|
||||
"CountVectorizer",
|
||||
"UniformKBinsDiscretizer",
|
||||
"MultiHotEncoder",
|
||||
"MaxAbsScaler",
|
||||
"Categorizer",
|
||||
],
|
||||
)
|
||||
def test_stateful_functional_backwards_compat(setup_func):
|
||||
"""Verify fitted preprocessors work after deserialization."""
|
||||
preprocessor, test_data, field_mapping = setup_func()
|
||||
test_ds = ray.data.from_pandas(test_data)
|
||||
preprocessor = preprocessor.fit(test_ds)
|
||||
_test_functional_backwards_compat(preprocessor, test_ds, field_mapping)
|
||||
|
||||
|
||||
def test_chain_functional_backwards_compat():
|
||||
df = pd.DataFrame({"A": [1.0, 2.0, 3.0]})
|
||||
ds = ray.data.from_pandas(df)
|
||||
|
||||
scaler = StandardScaler(columns=["A"])
|
||||
normalizer = Normalizer(columns=["A"])
|
||||
chain = Chain(scaler, normalizer)
|
||||
chain = chain.fit(ds)
|
||||
|
||||
expected_result = chain.transform(ds).to_pandas()
|
||||
|
||||
state = chain.__dict__.copy()
|
||||
state["preprocessors"] = state.pop("_preprocessors")
|
||||
|
||||
new_chain = Chain.__new__(Chain)
|
||||
new_chain.__setstate__(state)
|
||||
|
||||
result = new_chain.transform(ds).to_pandas()
|
||||
pd.testing.assert_frame_equal(result, expected_result)
|
||||
|
||||
|
||||
def test_torchvision_functional_backwards_compat():
|
||||
try:
|
||||
import torch
|
||||
from torchvision import transforms
|
||||
except ImportError:
|
||||
pytest.skip("torchvision not installed")
|
||||
|
||||
transform = transforms.Lambda(lambda x: torch.as_tensor(x, dtype=torch.float32))
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"image": [
|
||||
np.array([[1, 2], [3, 4]], dtype=np.uint8),
|
||||
np.array([[5, 6], [7, 8]], dtype=np.uint8),
|
||||
]
|
||||
}
|
||||
)
|
||||
ds = ray.data.from_pandas(df)
|
||||
|
||||
preprocessor = TorchVisionPreprocessor(
|
||||
columns=["image"], transform=transform, batched=False
|
||||
)
|
||||
expected_result = preprocessor.transform(ds).to_pandas()
|
||||
|
||||
state = preprocessor.__dict__.copy()
|
||||
state["columns"] = state.pop("_columns")
|
||||
state["output_columns"] = state.pop("_output_columns")
|
||||
state["torchvision_transform"] = state.pop("_torchvision_transform")
|
||||
state["batched"] = state.pop("_batched")
|
||||
|
||||
new_preprocessor = TorchVisionPreprocessor.__new__(TorchVisionPreprocessor)
|
||||
new_preprocessor.__setstate__(state)
|
||||
|
||||
result = new_preprocessor.transform(ds).to_pandas()
|
||||
assert len(result) == len(expected_result)
|
||||
assert "image" in result.columns
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,243 @@
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.data.preprocessor import Preprocessor
|
||||
from ray.data.preprocessors import Chain, LabelEncoder, SimpleImputer, StandardScaler
|
||||
from ray.data.util.data_batch_conversion import BatchFormat
|
||||
|
||||
|
||||
def test_chain():
|
||||
"""Tests basic Chain functionality."""
|
||||
col_a = [-1, -1, 1, 1]
|
||||
col_b = [1, 1, 1, None]
|
||||
col_c = ["sunday", "monday", "tuesday", "tuesday"]
|
||||
in_df = pd.DataFrame.from_dict({"A": col_a, "B": col_b, "C": col_c})
|
||||
ds = ray.data.from_pandas(in_df)
|
||||
|
||||
imputer = SimpleImputer(["B"])
|
||||
scaler = StandardScaler(["A", "B"])
|
||||
encoder = LabelEncoder("C")
|
||||
chain = Chain(scaler, imputer, encoder)
|
||||
|
||||
# Fit data.
|
||||
chain.fit(ds)
|
||||
# Transform data.
|
||||
transformed = chain.transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
|
||||
assert imputer.stats_ == {
|
||||
"mean(B)": 0.0,
|
||||
}
|
||||
assert scaler.stats_ == {
|
||||
"mean(A)": 0.0,
|
||||
"mean(B)": 1.0,
|
||||
"std(A)": 1.0,
|
||||
"std(B)": 0.0,
|
||||
}
|
||||
assert encoder.stats_ == {
|
||||
"unique_values(C)": {"monday": 0, "sunday": 1, "tuesday": 2}
|
||||
}
|
||||
|
||||
processed_col_a = [-1.0, -1.0, 1.0, 1.0]
|
||||
processed_col_b = [0.0, 0.0, 0.0, 0.0]
|
||||
processed_col_c = [1, 0, 2, 2]
|
||||
expected_df = pd.DataFrame.from_dict(
|
||||
{"A": processed_col_a, "B": processed_col_b, "C": processed_col_c}
|
||||
).astype(out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(out_df, expected_df, check_like=True)
|
||||
|
||||
# Transform batch.
|
||||
pred_col_a = [1, 2, None]
|
||||
pred_col_b = [0, None, 2]
|
||||
pred_col_c = ["monday", "tuesday", "wednesday"]
|
||||
pred_in_df = pd.DataFrame.from_dict(
|
||||
{"A": pred_col_a, "B": pred_col_b, "C": pred_col_c}
|
||||
)
|
||||
|
||||
pred_out_df = chain.transform_batch(pred_in_df)
|
||||
|
||||
pred_processed_col_a = [1, 2, None]
|
||||
pred_processed_col_b = [-1.0, 0.0, 1.0]
|
||||
pred_processed_col_c = [0, 2, None]
|
||||
pred_expected_df = pd.DataFrame.from_dict(
|
||||
{
|
||||
"A": pred_processed_col_a,
|
||||
"B": pred_processed_col_b,
|
||||
"C": pred_processed_col_c,
|
||||
}
|
||||
).astype(pred_out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(pred_out_df, pred_expected_df, check_like=True)
|
||||
|
||||
|
||||
def test_nested_chain_state():
|
||||
col_a = [-1, -1, 1, 1]
|
||||
col_b = [1, 1, 1, None]
|
||||
col_c = ["sunday", "monday", "tuesday", "tuesday"]
|
||||
in_df = pd.DataFrame.from_dict({"A": col_a, "B": col_b, "C": col_c})
|
||||
ds = ray.data.from_pandas(in_df)
|
||||
|
||||
def create_chain():
|
||||
imputer = SimpleImputer(["B"])
|
||||
scaler = StandardScaler(["A", "B"])
|
||||
encoder = LabelEncoder("C")
|
||||
return Chain(Chain(scaler, imputer), encoder)
|
||||
|
||||
chain = create_chain()
|
||||
assert chain.fit_status() == Preprocessor.FitStatus.NOT_FITTED
|
||||
|
||||
chain = create_chain()
|
||||
chain.preprocessors[1].fit(ds)
|
||||
assert chain.fit_status() == Preprocessor.FitStatus.PARTIALLY_FITTED
|
||||
|
||||
chain = create_chain()
|
||||
chain.preprocessors[0].fit(ds)
|
||||
assert chain.fit_status() == Preprocessor.FitStatus.PARTIALLY_FITTED
|
||||
|
||||
chain.preprocessors[1].fit(ds)
|
||||
assert chain.fit_status() == Preprocessor.FitStatus.FITTED
|
||||
|
||||
chain = create_chain()
|
||||
chain.fit(ds)
|
||||
assert chain.fit_status() == Preprocessor.FitStatus.FITTED
|
||||
|
||||
|
||||
def test_nested_chain():
|
||||
"""Tests Chain-inside-Chain functionality."""
|
||||
col_a = [-1, -1, 1, 1]
|
||||
col_b = [1, 1, 1, None]
|
||||
col_c = ["sunday", "monday", "tuesday", "tuesday"]
|
||||
in_df = pd.DataFrame.from_dict({"A": col_a, "B": col_b, "C": col_c})
|
||||
ds = ray.data.from_pandas(in_df)
|
||||
|
||||
imputer = SimpleImputer(["B"])
|
||||
scaler = StandardScaler(["A", "B"])
|
||||
encoder = LabelEncoder("C")
|
||||
chain = Chain(Chain(scaler, imputer), encoder)
|
||||
|
||||
# Fit data.
|
||||
chain.fit(ds)
|
||||
# Transform data.
|
||||
transformed = chain.transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
|
||||
assert imputer.stats_ == {
|
||||
"mean(B)": 0.0,
|
||||
}
|
||||
assert scaler.stats_ == {
|
||||
"mean(A)": 0.0,
|
||||
"mean(B)": 1.0,
|
||||
"std(A)": 1.0,
|
||||
"std(B)": 0.0,
|
||||
}
|
||||
assert encoder.stats_ == {
|
||||
"unique_values(C)": {"monday": 0, "sunday": 1, "tuesday": 2}
|
||||
}
|
||||
|
||||
processed_col_a = [-1.0, -1.0, 1.0, 1.0]
|
||||
processed_col_b = [0.0, 0.0, 0.0, 0.0]
|
||||
processed_col_c = [1, 0, 2, 2]
|
||||
expected_df = pd.DataFrame.from_dict(
|
||||
{"A": processed_col_a, "B": processed_col_b, "C": processed_col_c}
|
||||
).astype(out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(out_df, expected_df, check_like=True)
|
||||
|
||||
# Transform batch.
|
||||
pred_col_a = [1, 2, None]
|
||||
pred_col_b = [0, None, 2]
|
||||
pred_col_c = ["monday", "tuesday", "wednesday"]
|
||||
pred_in_df = pd.DataFrame.from_dict(
|
||||
{"A": pred_col_a, "B": pred_col_b, "C": pred_col_c}
|
||||
)
|
||||
|
||||
pred_out_df = chain.transform_batch(pred_in_df)
|
||||
|
||||
pred_processed_col_a = [1, 2, None]
|
||||
pred_processed_col_b = [-1.0, 0.0, 1.0]
|
||||
pred_processed_col_c = [0, 2, None]
|
||||
pred_expected_df = pd.DataFrame.from_dict(
|
||||
{
|
||||
"A": pred_processed_col_a,
|
||||
"B": pred_processed_col_b,
|
||||
"C": pred_processed_col_c,
|
||||
}
|
||||
).astype(pred_out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(pred_out_df, pred_expected_df, check_like=True)
|
||||
|
||||
|
||||
class PreprocessorWithoutTransform(Preprocessor):
|
||||
pass
|
||||
|
||||
|
||||
def test_determine_transform_to_use():
|
||||
# Test that _determine_transform_to_use doesn't throw any exceptions
|
||||
# and selects the transform function of the underlying preprocessor
|
||||
# while dealing with the nested Chain case.
|
||||
|
||||
# Check that error is propagated correctly
|
||||
with pytest.raises(NotImplementedError):
|
||||
chain = Chain(PreprocessorWithoutTransform())
|
||||
chain._determine_transform_to_use()
|
||||
|
||||
# Should have no errors from here on
|
||||
preprocessor = SimpleImputer(["A"])
|
||||
chain1 = Chain(preprocessor)
|
||||
format1 = chain1._determine_transform_to_use()
|
||||
assert format1 == BatchFormat.PANDAS
|
||||
|
||||
chain2 = Chain(chain1)
|
||||
format2 = chain2._determine_transform_to_use()
|
||||
|
||||
assert format1 == format2
|
||||
|
||||
|
||||
def test_chain_serialization():
|
||||
"""Test Chain serialization and deserialization functionality."""
|
||||
import ray
|
||||
from ray.data.preprocessor import SerializablePreprocessorBase
|
||||
from ray.data.preprocessors import Normalizer, StandardScaler
|
||||
|
||||
# Create and fit chain
|
||||
scaler = StandardScaler(columns=["A"])
|
||||
normalizer = Normalizer(columns=["A"])
|
||||
chain = Chain(scaler, normalizer)
|
||||
|
||||
df = pd.DataFrame({"A": [1.0, 2.0, 3.0]})
|
||||
ds = ray.data.from_pandas(df)
|
||||
fitted_chain = chain.fit(ds)
|
||||
|
||||
# Serialize using CloudPickle
|
||||
serialized = fitted_chain.serialize()
|
||||
|
||||
# Verify it's binary CloudPickle format
|
||||
assert isinstance(serialized, bytes)
|
||||
assert serialized.startswith(SerializablePreprocessorBase.MAGIC_CLOUDPICKLE)
|
||||
|
||||
# Deserialize
|
||||
deserialized = Chain.deserialize(serialized)
|
||||
|
||||
# Verify type and field values
|
||||
assert isinstance(deserialized, Chain)
|
||||
assert len(deserialized._preprocessors) == 2
|
||||
assert isinstance(deserialized._preprocessors[0], StandardScaler)
|
||||
assert isinstance(deserialized._preprocessors[1], Normalizer)
|
||||
# Verify the StandardScaler is fitted (Normalizer is stateless)
|
||||
assert deserialized._preprocessors[0]._fitted
|
||||
|
||||
# Verify it works correctly
|
||||
test_df = pd.DataFrame({"A": [1.5, 2.5]})
|
||||
result = deserialized.transform_batch(test_df)
|
||||
|
||||
# Result should have been transformed by both preprocessors
|
||||
assert "A" in result.columns
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,227 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from pandas.testing import assert_frame_equal
|
||||
|
||||
import ray
|
||||
from ray.data.exceptions import UserCodeException
|
||||
from ray.data.preprocessors import Concatenator, OneHotEncoder
|
||||
|
||||
|
||||
class TestConcatenator:
|
||||
def test_basic(self):
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"a": [1, 2, 3, 4],
|
||||
"b": [5, 6, 7, 8],
|
||||
}
|
||||
)
|
||||
ds = ray.data.from_pandas(df)
|
||||
prep = Concatenator(columns=["a", "b"], output_column_name="c")
|
||||
new_ds = prep.transform(ds)
|
||||
for i, row in enumerate(new_ds.take()):
|
||||
assert np.array_equal(row["c"], np.array([i + 1, i + 5]))
|
||||
|
||||
def test_raise_if_missing(self):
|
||||
df = pd.DataFrame({"a": [1, 2, 3, 4]})
|
||||
ds = ray.data.from_pandas(df)
|
||||
prep = Concatenator(
|
||||
columns=["a", "b"], output_column_name="c", raise_if_missing=True
|
||||
)
|
||||
|
||||
with pytest.raises(UserCodeException):
|
||||
with pytest.raises(ValueError, match="'b'"):
|
||||
prep.transform(ds).materialize()
|
||||
|
||||
def test_exclude_column(self):
|
||||
df = pd.DataFrame({"a": [1, 2, 3, 4], "b": [2, 3, 4, 5], "c": [3, 4, 5, 6]})
|
||||
ds = ray.data.from_pandas(df)
|
||||
prep = Concatenator(columns=["a", "c"])
|
||||
new_ds = prep.transform(ds)
|
||||
for _, row in enumerate(new_ds.take()):
|
||||
assert set(row) == {"concat_out", "b"}
|
||||
|
||||
def test_include_columns(self):
|
||||
df = pd.DataFrame({"a": [1, 2, 3, 4], "b": [2, 3, 4, 5], "c": [3, 4, 5, 6]})
|
||||
ds = ray.data.from_pandas(df)
|
||||
prep = Concatenator(columns=["a", "b"])
|
||||
new_ds = prep.transform(ds)
|
||||
for _, row in enumerate(new_ds.take()):
|
||||
assert set(row) == {"concat_out", "c"}
|
||||
|
||||
def test_change_column_order(self):
|
||||
df = pd.DataFrame({"a": [1, 2, 3, 4], "b": [2, 3, 4, 5]})
|
||||
ds = ray.data.from_pandas(df)
|
||||
prep = Concatenator(columns=["b", "a"])
|
||||
new_ds = prep.transform(ds)
|
||||
expected_df = pd.DataFrame({"concat_out": [[2, 1], [3, 2], [4, 3], [5, 4]]})
|
||||
print(new_ds.to_pandas())
|
||||
assert_frame_equal(new_ds.to_pandas(), expected_df)
|
||||
|
||||
def test_strings(self):
|
||||
df = pd.DataFrame({"a": ["string", "string2", "string3"]})
|
||||
ds = ray.data.from_pandas(df)
|
||||
prep = Concatenator(columns=["a"], output_column_name="huh")
|
||||
new_ds = prep.transform(ds)
|
||||
assert "huh" in set(new_ds.schema().names)
|
||||
|
||||
def test_preserves_order(self):
|
||||
df = pd.DataFrame({"a": [1, 2, 3, 4], "b": [2, 3, 4, 5]})
|
||||
ds = ray.data.from_pandas(df)
|
||||
prep = Concatenator(columns=["a", "b"], output_column_name="c")
|
||||
prep = prep.fit(ds)
|
||||
|
||||
df = pd.DataFrame({"a": [5, 6, 7, 8], "b": [6, 7, 8, 9]})
|
||||
concatenated_df = prep.transform_batch(df)
|
||||
expected_df = pd.DataFrame({"c": [[5, 6], [6, 7], [7, 8], [8, 9]]})
|
||||
assert_frame_equal(concatenated_df, expected_df)
|
||||
|
||||
other_df = pd.DataFrame({"a": [9, 10, 11, 12], "b": [10, 11, 12, 13]})
|
||||
concatenated_other_df = prep.transform_batch(other_df)
|
||||
expected_df = pd.DataFrame(
|
||||
{
|
||||
"c": [
|
||||
[9, 10],
|
||||
[10, 11],
|
||||
[11, 12],
|
||||
[12, 13],
|
||||
]
|
||||
}
|
||||
)
|
||||
assert_frame_equal(concatenated_other_df, expected_df)
|
||||
|
||||
@pytest.mark.parametrize("col_b", [[[2, 3], [3, 4], [4, 5], [5, 6]], [2, 3, 4, 5]])
|
||||
@pytest.mark.parametrize("flatten", [True, False])
|
||||
def test_flatten(self, col_b, flatten):
|
||||
col_a = [1, 2, 3, 4]
|
||||
col_b = [np.array(v) for v in col_b] if isinstance(col_b[0], list) else col_b
|
||||
df = pd.DataFrame({"a": col_a, "b": col_b})
|
||||
ds = ray.data.from_pandas(df)
|
||||
prep = Concatenator(columns=["a", "b"], flatten=flatten)
|
||||
new_ds = prep.transform(ds)
|
||||
|
||||
for i, row in enumerate(new_ds.take()):
|
||||
if flatten or not isinstance(col_b[i], np.ndarray):
|
||||
# When flatten=True or when col_b contains simple values
|
||||
if isinstance(col_b[i], np.ndarray):
|
||||
expected = np.concatenate([np.array([col_a[i]]), col_b[i]])
|
||||
else:
|
||||
expected = np.array([col_a[i], col_b[i]])
|
||||
assert np.array_equal(row["concat_out"], expected)
|
||||
else:
|
||||
# When flatten=False and col_b contains numpy arrays
|
||||
# The output should be a list containing the scalar and the array
|
||||
assert len(row["concat_out"]) == 2
|
||||
assert row["concat_out"][0] == col_a[i]
|
||||
assert np.array_equal(row["concat_out"][1], col_b[i])
|
||||
|
||||
@pytest.mark.parametrize("flatten", [True, False])
|
||||
def test_concatenate_with_onehotencoder(self, flatten):
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"color": ["red", "green", "blue", "red"],
|
||||
"value": [1, 2, 3, 4],
|
||||
}
|
||||
)
|
||||
ds = ray.data.from_pandas(df)
|
||||
# OneHot encode the color column
|
||||
encoder = OneHotEncoder(columns=["color"], output_columns=["color_encoded"])
|
||||
encoder = encoder.fit(ds)
|
||||
encoded_ds = encoder.transform(ds)
|
||||
# Concatenate the one-hot encoded column with the value column
|
||||
prep = Concatenator(
|
||||
columns=["color_encoded", "value"],
|
||||
output_column_name="features",
|
||||
flatten=flatten,
|
||||
)
|
||||
new_ds = prep.transform(encoded_ds)
|
||||
# Get the expected one-hot vectors
|
||||
color_map = {"blue": [1, 0, 0], "green": [0, 1, 0], "red": [0, 0, 1]}
|
||||
for i, row in enumerate(new_ds.take()):
|
||||
if flatten:
|
||||
expected = color_map[df["color"][i]] + [df["value"][i]]
|
||||
assert np.array_equal(row["features"], np.array(expected))
|
||||
else:
|
||||
expected = [np.array(color_map[df["color"][i]]), df["value"][i]]
|
||||
assert np.array_equal(row["features"][0], expected[0])
|
||||
assert row["features"][1] == expected[1]
|
||||
|
||||
@pytest.mark.parametrize("flatten", [True, False])
|
||||
def test_nested_list_with_dtype(self, flatten: bool):
|
||||
# Tests Concatenator with nested lists and dtype: flattens and coerces when flatten=True,
|
||||
# raises ValueError when flatten=False.
|
||||
output_column = "c"
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"a": [12.0],
|
||||
"b": [[1, 0, 0, 0]],
|
||||
}
|
||||
)
|
||||
prep = Concatenator(
|
||||
columns=["a", "b"],
|
||||
output_column_name=output_column,
|
||||
dtype=np.float32,
|
||||
flatten=flatten,
|
||||
)
|
||||
|
||||
if flatten:
|
||||
pd_ds = prep._transform_pandas(df)
|
||||
expected_pd = pd.DataFrame(
|
||||
{output_column: pd.Series([[12.0, 1.0, 0.0, 0.0, 0.0]])}
|
||||
)
|
||||
assert_frame_equal(pd_ds, expected_pd)
|
||||
else:
|
||||
# Only for flattened output do we expect the dtype coercion to apply
|
||||
with pytest.raises(ValueError):
|
||||
pd_ds = prep._transform_pandas(df)
|
||||
|
||||
def test_concatenator_deserialize_backward_compat(self):
|
||||
p1 = Concatenator(columns=["A"], flatten=True)
|
||||
delattr(p1, "_flatten")
|
||||
data = p1.serialize()
|
||||
p2 = Concatenator.deserialize(data)
|
||||
assert isinstance(p2, Concatenator)
|
||||
assert p2.flatten is False
|
||||
|
||||
def test_concatenator_serialization(self):
|
||||
"""Test Concatenator serialization and deserialization functionality."""
|
||||
from ray.data.preprocessor import SerializablePreprocessorBase
|
||||
|
||||
# Create concatenator
|
||||
concatenator = Concatenator(
|
||||
columns=["A", "B"],
|
||||
output_column_name="combined",
|
||||
dtype=np.float32,
|
||||
flatten=True,
|
||||
)
|
||||
|
||||
# Serialize using CloudPickle
|
||||
serialized = concatenator.serialize()
|
||||
|
||||
# Verify it's binary CloudPickle format
|
||||
assert isinstance(serialized, bytes)
|
||||
assert serialized.startswith(SerializablePreprocessorBase.MAGIC_CLOUDPICKLE)
|
||||
|
||||
# Deserialize
|
||||
deserialized = Concatenator.deserialize(serialized)
|
||||
|
||||
# Verify type and field values
|
||||
assert isinstance(deserialized, Concatenator)
|
||||
assert deserialized.columns == ["A", "B"]
|
||||
assert deserialized.output_column_name == "combined"
|
||||
assert deserialized.dtype == np.float32
|
||||
assert deserialized.flatten is True
|
||||
|
||||
# Verify it works correctly
|
||||
df = pd.DataFrame({"A": [[1, 2]], "B": [[3, 4]]})
|
||||
result = deserialized.transform_batch(df)
|
||||
|
||||
# Verify concatenation was applied correctly
|
||||
assert "combined" in result.columns
|
||||
assert len(result["combined"][0]) == 4
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,312 @@
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.data._internal.util import rows_same
|
||||
from ray.data.preprocessors import CustomKBinsDiscretizer, UniformKBinsDiscretizer
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bins", (3, {"A": 4, "B": 3}))
|
||||
@pytest.mark.parametrize(
|
||||
"dtypes",
|
||||
(
|
||||
None,
|
||||
{"A": int, "B": int},
|
||||
{"A": int, "B": pd.CategoricalDtype(["cat1", "cat2", "cat3"], ordered=True)},
|
||||
),
|
||||
)
|
||||
@pytest.mark.parametrize("right", (True, False))
|
||||
@pytest.mark.parametrize("include_lowest", (True, False))
|
||||
def test_uniform_kbins_discretizer(
|
||||
bins,
|
||||
dtypes,
|
||||
right,
|
||||
include_lowest,
|
||||
):
|
||||
"""Tests basic UniformKBinsDiscretizer functionality."""
|
||||
|
||||
col_a = [0.2, 1.4, 2.5, 6.2, 9.7, 2.1]
|
||||
col_b = col_a.copy()
|
||||
col_c = col_a.copy()
|
||||
in_df = pd.DataFrame.from_dict({"A": col_a, "B": col_b, "C": col_c})
|
||||
ds = ray.data.from_pandas(in_df).repartition(2)
|
||||
|
||||
discretizer = UniformKBinsDiscretizer(
|
||||
["A", "B"], bins=bins, dtypes=dtypes, right=right, include_lowest=include_lowest
|
||||
)
|
||||
|
||||
transformed = discretizer.fit_transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
|
||||
if isinstance(bins, dict):
|
||||
bins_A = bins["A"]
|
||||
bins_B = bins["B"]
|
||||
else:
|
||||
bins_A = bins_B = bins
|
||||
|
||||
labels_A = False
|
||||
ordered_A = True
|
||||
labels_B = False
|
||||
ordered_B = True
|
||||
if isinstance(dtypes, dict):
|
||||
if isinstance(dtypes.get("A"), pd.CategoricalDtype):
|
||||
labels_A = dtypes.get("A").categories
|
||||
ordered_A = dtypes.get("A").ordered
|
||||
if isinstance(dtypes.get("B"), pd.CategoricalDtype):
|
||||
labels_B = dtypes.get("B").categories
|
||||
ordered_B = dtypes.get("B").ordered
|
||||
|
||||
# Create expected dataframe with transformed columns
|
||||
expected_df = in_df.copy()
|
||||
expected_df["A"] = pd.cut(
|
||||
in_df["A"],
|
||||
bins_A,
|
||||
labels=labels_A,
|
||||
ordered=ordered_A,
|
||||
right=right,
|
||||
include_lowest=include_lowest,
|
||||
)
|
||||
expected_df["B"] = pd.cut(
|
||||
in_df["B"],
|
||||
bins_B,
|
||||
labels=labels_B,
|
||||
ordered=ordered_B,
|
||||
right=right,
|
||||
include_lowest=include_lowest,
|
||||
)
|
||||
|
||||
# Use rows_same to compare regardless of row ordering
|
||||
assert rows_same(out_df, expected_df)
|
||||
|
||||
# append mode
|
||||
expected_message = "The length of columns and output_columns must match."
|
||||
with pytest.raises(ValueError, match=expected_message):
|
||||
UniformKBinsDiscretizer(["A", "B"], bins=bins, output_columns=["A_discretized"])
|
||||
|
||||
discretizer = UniformKBinsDiscretizer(
|
||||
["A", "B"],
|
||||
bins=bins,
|
||||
dtypes=dtypes,
|
||||
right=right,
|
||||
include_lowest=include_lowest,
|
||||
output_columns=["A_discretized", "B_discretized"],
|
||||
)
|
||||
|
||||
transformed = discretizer.fit_transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
|
||||
# Create expected dataframe with appended columns
|
||||
expected_df = in_df.copy()
|
||||
expected_df["A_discretized"] = pd.cut(
|
||||
in_df["A"],
|
||||
bins_A,
|
||||
labels=labels_A,
|
||||
ordered=ordered_A,
|
||||
right=right,
|
||||
include_lowest=include_lowest,
|
||||
)
|
||||
expected_df["B_discretized"] = pd.cut(
|
||||
in_df["B"],
|
||||
bins_B,
|
||||
labels=labels_B,
|
||||
ordered=ordered_B,
|
||||
right=right,
|
||||
include_lowest=include_lowest,
|
||||
)
|
||||
|
||||
# Use rows_same to compare regardless of row ordering
|
||||
assert rows_same(out_df, expected_df)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bins", ([3, 4, 6, 9], {"A": [3, 4, 6, 8, 9], "B": [3, 4, 6, 9]})
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"dtypes",
|
||||
(
|
||||
None,
|
||||
{"A": int, "B": int},
|
||||
{"A": int, "B": pd.CategoricalDtype(["cat1", "cat2", "cat3"], ordered=True)},
|
||||
),
|
||||
)
|
||||
@pytest.mark.parametrize("right", (True, False))
|
||||
@pytest.mark.parametrize("include_lowest", (True, False))
|
||||
def test_custom_kbins_discretizer(
|
||||
bins,
|
||||
dtypes,
|
||||
right,
|
||||
include_lowest,
|
||||
):
|
||||
"""Tests basic CustomKBinsDiscretizer functionality."""
|
||||
|
||||
col_a = [0.2, 1.4, 2.5, 6.2, 9.7, 2.1]
|
||||
col_b = col_a.copy()
|
||||
col_c = col_a.copy()
|
||||
in_df = pd.DataFrame.from_dict({"A": col_a, "B": col_b, "C": col_c})
|
||||
ds = ray.data.from_pandas(in_df).repartition(2)
|
||||
|
||||
discretizer = CustomKBinsDiscretizer(
|
||||
["A", "B"], bins=bins, dtypes=dtypes, right=right, include_lowest=include_lowest
|
||||
)
|
||||
|
||||
transformed = discretizer.transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
|
||||
if isinstance(bins, dict):
|
||||
bins_A = bins["A"]
|
||||
bins_B = bins["B"]
|
||||
else:
|
||||
bins_A = bins_B = bins
|
||||
|
||||
labels_A = False
|
||||
ordered_A = True
|
||||
labels_B = False
|
||||
ordered_B = True
|
||||
if isinstance(dtypes, dict):
|
||||
if isinstance(dtypes.get("A"), pd.CategoricalDtype):
|
||||
labels_A = dtypes.get("A").categories
|
||||
ordered_A = dtypes.get("A").ordered
|
||||
if isinstance(dtypes.get("B"), pd.CategoricalDtype):
|
||||
labels_B = dtypes.get("B").categories
|
||||
ordered_B = dtypes.get("B").ordered
|
||||
|
||||
# Create expected dataframe with transformed columns
|
||||
expected_df = in_df.copy()
|
||||
expected_df["A"] = pd.cut(
|
||||
in_df["A"],
|
||||
bins_A,
|
||||
labels=labels_A,
|
||||
ordered=ordered_A,
|
||||
right=right,
|
||||
include_lowest=include_lowest,
|
||||
)
|
||||
expected_df["B"] = pd.cut(
|
||||
in_df["B"],
|
||||
bins_B,
|
||||
labels=labels_B,
|
||||
ordered=ordered_B,
|
||||
right=right,
|
||||
include_lowest=include_lowest,
|
||||
)
|
||||
|
||||
# Use rows_same to compare regardless of row ordering
|
||||
assert rows_same(out_df, expected_df)
|
||||
|
||||
# append mode
|
||||
expected_message = "The length of columns and output_columns must match."
|
||||
with pytest.raises(ValueError, match=expected_message):
|
||||
CustomKBinsDiscretizer(["A", "B"], bins=bins, output_columns=["A_discretized"])
|
||||
|
||||
discretizer = CustomKBinsDiscretizer(
|
||||
["A", "B"],
|
||||
bins=bins,
|
||||
dtypes=dtypes,
|
||||
right=right,
|
||||
include_lowest=include_lowest,
|
||||
output_columns=["A_discretized", "B_discretized"],
|
||||
)
|
||||
|
||||
transformed = discretizer.fit_transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
|
||||
# Create expected dataframe with appended columns
|
||||
expected_df = in_df.copy()
|
||||
expected_df["A_discretized"] = pd.cut(
|
||||
in_df["A"],
|
||||
bins_A,
|
||||
labels=labels_A,
|
||||
ordered=ordered_A,
|
||||
right=right,
|
||||
include_lowest=include_lowest,
|
||||
)
|
||||
expected_df["B_discretized"] = pd.cut(
|
||||
in_df["B"],
|
||||
bins_B,
|
||||
labels=labels_B,
|
||||
ordered=ordered_B,
|
||||
right=right,
|
||||
include_lowest=include_lowest,
|
||||
)
|
||||
|
||||
# Use rows_same to compare regardless of row ordering
|
||||
assert rows_same(out_df, expected_df)
|
||||
|
||||
|
||||
def test_custom_kbins_discretizer_serialization():
|
||||
"""Test CustomKBinsDiscretizer serialization and deserialization functionality."""
|
||||
from ray.data.preprocessor import SerializablePreprocessorBase
|
||||
|
||||
# Create discretizer
|
||||
discretizer = CustomKBinsDiscretizer(
|
||||
columns=["A"], bins={"A": [0, 1, 2, 3]}, right=True
|
||||
)
|
||||
|
||||
# Serialize using CloudPickle
|
||||
serialized = discretizer.serialize()
|
||||
|
||||
# Verify it's binary CloudPickle format
|
||||
assert isinstance(serialized, bytes)
|
||||
assert serialized.startswith(SerializablePreprocessorBase.MAGIC_CLOUDPICKLE)
|
||||
|
||||
# Deserialize
|
||||
deserialized = CustomKBinsDiscretizer.deserialize(serialized)
|
||||
|
||||
# Verify type and field values
|
||||
assert isinstance(deserialized, CustomKBinsDiscretizer)
|
||||
assert deserialized.columns == ["A"]
|
||||
assert deserialized.bins == {"A": [0, 1, 2, 3]}
|
||||
assert deserialized.right is True
|
||||
|
||||
# Verify it works correctly
|
||||
df = pd.DataFrame({"A": [0.5, 1.5, 2.5]})
|
||||
result = deserialized.transform_batch(df)
|
||||
|
||||
# Verify discretization was applied correctly
|
||||
assert "A" in result.columns
|
||||
assert len(result) == 3
|
||||
|
||||
|
||||
def test_uniform_kbins_discretizer_serialization():
|
||||
"""Test UniformKBinsDiscretizer serialization and deserialization functionality."""
|
||||
import ray
|
||||
from ray.data.preprocessor import SerializablePreprocessorBase
|
||||
|
||||
# Create and fit discretizer
|
||||
discretizer = UniformKBinsDiscretizer(columns=["A"], bins=3)
|
||||
df = pd.DataFrame({"A": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]})
|
||||
ds = ray.data.from_pandas(df)
|
||||
fitted_discretizer = discretizer.fit(ds)
|
||||
|
||||
# Serialize using CloudPickle
|
||||
serialized = fitted_discretizer.serialize()
|
||||
|
||||
# Verify it's binary CloudPickle format
|
||||
assert isinstance(serialized, bytes)
|
||||
assert serialized.startswith(SerializablePreprocessorBase.MAGIC_CLOUDPICKLE)
|
||||
|
||||
# Deserialize
|
||||
deserialized = UniformKBinsDiscretizer.deserialize(serialized)
|
||||
|
||||
# Verify type and field values
|
||||
assert isinstance(deserialized, UniformKBinsDiscretizer)
|
||||
assert deserialized._fitted
|
||||
assert deserialized.columns == ["A"]
|
||||
assert deserialized.bins == 3
|
||||
|
||||
# Verify stats are preserved: bin edges for 3 bins = 4 edge values
|
||||
assert "A" in deserialized.stats_
|
||||
assert len(deserialized.stats_["A"]) == 4
|
||||
|
||||
# Verify it works correctly
|
||||
test_df = pd.DataFrame({"A": [1.5, 3.5, 5.5]})
|
||||
result = deserialized.transform_batch(test_df)
|
||||
|
||||
# Verify discretization was applied correctly
|
||||
assert "A" in result.columns
|
||||
assert len(result) == 3
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.data.preprocessors import FeatureHasher
|
||||
|
||||
|
||||
def test_feature_hasher():
|
||||
"""Tests basic FeatureHasher functionality."""
|
||||
# This dataframe represents the counts from the documents "I like Python" and "I
|
||||
# dislike Python".
|
||||
token_counts = pd.DataFrame(
|
||||
{"I": [1, 1], "like": [1, 0], "dislike": [0, 1], "Python": [1, 1]}
|
||||
)
|
||||
|
||||
hasher = FeatureHasher(
|
||||
["I", "like", "dislike", "Python"],
|
||||
num_features=256,
|
||||
output_column="hashed_features",
|
||||
)
|
||||
document_term_matrix = hasher.fit_transform(
|
||||
ray.data.from_pandas(token_counts)
|
||||
).to_pandas()
|
||||
|
||||
hashed_features = document_term_matrix["hashed_features"]
|
||||
# Document-term matrix should have shape (# documents, # features)
|
||||
assert hashed_features.shape == (2,)
|
||||
|
||||
# The tokens tokens "I", "like", and "Python" should be hashed to distinct indices
|
||||
# for adequately large `num_features`.
|
||||
assert len(hashed_features.iloc[0]) == 256
|
||||
assert hashed_features.iloc[0].sum() == 3
|
||||
assert all(hashed_features.iloc[0] <= 1)
|
||||
|
||||
# The tokens tokens "I", "dislike", and "Python" should be hashed to distinct
|
||||
# indices for adequately large `num_features`.
|
||||
assert len(hashed_features.iloc[1]) == 256
|
||||
assert hashed_features.iloc[1].sum() == 3
|
||||
assert all(hashed_features.iloc[1] <= 1)
|
||||
|
||||
|
||||
def test_feature_hasher_serialization():
|
||||
"""Test FeatureHasher serialization and deserialization functionality."""
|
||||
from ray.data.preprocessor import SerializablePreprocessorBase
|
||||
|
||||
# Create hasher
|
||||
hasher = FeatureHasher(
|
||||
columns=["I", "like", "Python"], num_features=8, output_column="hashed"
|
||||
)
|
||||
|
||||
# Serialize using CloudPickle
|
||||
serialized = hasher.serialize()
|
||||
|
||||
# Verify it's binary CloudPickle format
|
||||
assert isinstance(serialized, bytes)
|
||||
assert serialized.startswith(SerializablePreprocessorBase.MAGIC_CLOUDPICKLE)
|
||||
|
||||
# Deserialize
|
||||
deserialized = FeatureHasher.deserialize(serialized)
|
||||
|
||||
# Verify type and field values
|
||||
assert isinstance(deserialized, FeatureHasher)
|
||||
assert deserialized.columns == ["I", "like", "Python"]
|
||||
assert deserialized.num_features == 8
|
||||
assert deserialized.output_column == "hashed"
|
||||
|
||||
# Verify it works correctly
|
||||
df = pd.DataFrame({"I": [1, 1], "like": [1, 0], "Python": [1, 1]})
|
||||
result = deserialized.transform_batch(df)
|
||||
|
||||
# Verify hashing was applied correctly
|
||||
assert "hashed" in result.columns
|
||||
assert len(result["hashed"][0]) == 8
|
||||
assert len(result["hashed"][1]) == 8
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,629 @@
|
||||
"""
|
||||
Tests for SimpleImputer functionality and serialization.
|
||||
|
||||
This file contains:
|
||||
1. Basic functional tests for SimpleImputer operations
|
||||
2. Comprehensive serialization/deserialization tests
|
||||
"""
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.data._internal.util import rows_same
|
||||
from ray.data.preprocessor import (
|
||||
PreprocessorNotFittedException,
|
||||
SerializablePreprocessorBase,
|
||||
)
|
||||
from ray.data.preprocessors import SimpleImputer
|
||||
from ray.data.preprocessors.version_support import UnknownPreprocessorError
|
||||
|
||||
|
||||
def test_simple_imputer():
|
||||
col_a = [1, 1, 1, np.nan]
|
||||
col_b = [1, 3, None, np.nan]
|
||||
col_c = [1, 1, 1, 1]
|
||||
in_df = pd.DataFrame.from_dict({"A": col_a, "B": col_b, "C": col_c})
|
||||
|
||||
ds = ray.data.from_pandas(in_df)
|
||||
|
||||
imputer = SimpleImputer(["B", "C"])
|
||||
|
||||
# Transform with unfitted preprocessor.
|
||||
with pytest.raises(PreprocessorNotFittedException):
|
||||
imputer.transform(ds)
|
||||
|
||||
# Fit data.
|
||||
imputer.fit(ds)
|
||||
assert imputer.stats_ == {"mean(B)": 2.0, "mean(C)": 1.0}
|
||||
|
||||
# Transform data.
|
||||
transformed = imputer.transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
|
||||
processed_col_a = col_a
|
||||
processed_col_b = [1.0, 3.0, 2.0, 2.0]
|
||||
processed_col_c = [1, 1, 1, 1]
|
||||
expected_df = pd.DataFrame.from_dict(
|
||||
{"A": processed_col_a, "B": processed_col_b, "C": processed_col_c}
|
||||
)
|
||||
expected_df = expected_df.astype(out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(out_df, expected_df)
|
||||
|
||||
# Transform batch.
|
||||
pred_col_a = [1, 2, np.nan]
|
||||
pred_col_b = [1, 2, np.nan]
|
||||
pred_col_c = [None, None, None]
|
||||
pred_in_df = pd.DataFrame.from_dict(
|
||||
{"A": pred_col_a, "B": pred_col_b, "C": pred_col_c}
|
||||
)
|
||||
|
||||
pred_out_df = imputer.transform_batch(pred_in_df)
|
||||
|
||||
pred_processed_col_a = pred_col_a
|
||||
pred_processed_col_b = [1.0, 2.0, 2.0]
|
||||
pred_processed_col_c = [1.0, 1.0, 1.0]
|
||||
pred_expected_df = pd.DataFrame.from_dict(
|
||||
{
|
||||
"A": pred_processed_col_a,
|
||||
"B": pred_processed_col_b,
|
||||
"C": pred_processed_col_c,
|
||||
}
|
||||
)
|
||||
|
||||
pd.testing.assert_frame_equal(pred_out_df, pred_expected_df, check_like=True)
|
||||
|
||||
# with missing column
|
||||
pred_in_df = pd.DataFrame.from_dict({"A": pred_col_a, "B": pred_col_b})
|
||||
pred_out_df = imputer.transform_batch(pred_in_df)
|
||||
pred_expected_df = pd.DataFrame.from_dict(
|
||||
{
|
||||
"A": pred_processed_col_a,
|
||||
"B": pred_processed_col_b,
|
||||
"C": pred_processed_col_c,
|
||||
}
|
||||
)
|
||||
pd.testing.assert_frame_equal(pred_out_df, pred_expected_df, check_like=True)
|
||||
|
||||
# append mode
|
||||
with pytest.raises(ValueError):
|
||||
SimpleImputer(columns=["B", "C"], output_columns=["B_encoded"])
|
||||
|
||||
imputer = SimpleImputer(
|
||||
columns=["B", "C"],
|
||||
output_columns=["B_imputed", "C_imputed"],
|
||||
)
|
||||
imputer.fit(ds)
|
||||
|
||||
pred_col_a = [1, 2, np.nan]
|
||||
pred_col_b = [1, 2, np.nan]
|
||||
pred_col_c = [None, None, None]
|
||||
pred_in_df = pd.DataFrame.from_dict(
|
||||
{"A": pred_col_a, "B": pred_col_b, "C": pred_col_c}
|
||||
)
|
||||
pred_out_df = imputer.transform_batch(pred_in_df)
|
||||
|
||||
pred_processed_col_b = [1.0, 2.0, 2.0]
|
||||
pred_processed_col_c = [1.0, 1.0, 1.0]
|
||||
pred_expected_df = pd.DataFrame.from_dict(
|
||||
{
|
||||
"A": pred_col_a,
|
||||
"B": pred_col_b,
|
||||
"C": pred_col_c,
|
||||
"B_imputed": pred_processed_col_b,
|
||||
"C_imputed": pred_processed_col_c,
|
||||
}
|
||||
)
|
||||
|
||||
pd.testing.assert_frame_equal(pred_out_df, pred_expected_df, check_like=True)
|
||||
|
||||
# Test "most_frequent" strategy.
|
||||
most_frequent_col_a = [1, 2, 2, None, None, None]
|
||||
# Use 3 "c"s to ensure it's clearly the most frequent (no tie with "b")
|
||||
most_frequent_col_b = [None, "c", "c", "c", "b", "a"]
|
||||
most_frequent_df = pd.DataFrame.from_dict(
|
||||
{"A": most_frequent_col_a, "B": most_frequent_col_b}
|
||||
)
|
||||
most_frequent_ds = ray.data.from_pandas(most_frequent_df).repartition(3)
|
||||
|
||||
most_frequent_imputer = SimpleImputer(["A", "B"], strategy="most_frequent")
|
||||
most_frequent_imputer.fit(most_frequent_ds)
|
||||
assert most_frequent_imputer.stats_ == {
|
||||
"most_frequent(A)": 2.0,
|
||||
"most_frequent(B)": "c",
|
||||
}
|
||||
|
||||
most_frequent_transformed = most_frequent_imputer.transform(most_frequent_ds)
|
||||
most_frequent_out_df = most_frequent_transformed.to_pandas()
|
||||
|
||||
most_frequent_processed_col_a = [1.0, 2.0, 2.0, 2.0, 2.0, 2.0]
|
||||
most_frequent_processed_col_b = ["c", "c", "c", "c", "b", "a"]
|
||||
most_frequent_expected_df = pd.DataFrame.from_dict(
|
||||
{"A": most_frequent_processed_col_a, "B": most_frequent_processed_col_b}
|
||||
)
|
||||
|
||||
assert rows_same(most_frequent_out_df, most_frequent_expected_df)
|
||||
|
||||
# Test "constant" strategy.
|
||||
constant_col_a = ["apple", None]
|
||||
constant_col_b = constant_col_a.copy()
|
||||
constant_df = pd.DataFrame.from_dict({"A": constant_col_a, "B": constant_col_b})
|
||||
# category dtype requires special handling
|
||||
constant_df["B"] = constant_df["B"].astype("category")
|
||||
constant_ds = ray.data.from_pandas(constant_df)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
SimpleImputer(["A", "B"], strategy="constant")
|
||||
|
||||
constant_imputer = SimpleImputer(
|
||||
["A", "B"], strategy="constant", fill_value="missing"
|
||||
)
|
||||
constant_transformed = constant_imputer.transform(constant_ds)
|
||||
constant_out_df = constant_transformed.to_pandas()
|
||||
|
||||
constant_processed_col_a = ["apple", "missing"]
|
||||
constant_processed_col_b = constant_processed_col_a.copy()
|
||||
constant_expected_df = pd.DataFrame.from_dict(
|
||||
{"A": constant_processed_col_a, "B": constant_processed_col_b}
|
||||
)
|
||||
constant_expected_df["B"] = constant_expected_df["B"].astype("category")
|
||||
constant_expected_df = constant_expected_df.astype(constant_out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(
|
||||
constant_out_df, constant_expected_df, check_like=True
|
||||
)
|
||||
|
||||
|
||||
def test_imputer_all_nan_raise_error():
|
||||
data = {
|
||||
"A": [np.nan, np.nan, np.nan, np.nan],
|
||||
}
|
||||
df = pd.DataFrame(data)
|
||||
dataset = ray.data.from_pandas(df)
|
||||
|
||||
imputer = SimpleImputer(columns=["A"], strategy="mean")
|
||||
imputer.fit(dataset)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
imputer.transform_batch(df)
|
||||
|
||||
|
||||
def test_imputer_constant_categorical():
|
||||
data = {
|
||||
"A_cat": ["one", "two", None, "four"],
|
||||
}
|
||||
df = pd.DataFrame(data)
|
||||
df["A_cat"] = df["A_cat"].astype("category")
|
||||
dataset = ray.data.from_pandas(df)
|
||||
|
||||
imputer = SimpleImputer(columns=["A_cat"], strategy="constant", fill_value="three")
|
||||
imputer.fit(dataset)
|
||||
|
||||
transformed_df = imputer.transform_batch(df)
|
||||
|
||||
expected = {
|
||||
"A_cat": ["one", "two", "three", "four"],
|
||||
}
|
||||
|
||||
for column in data.keys():
|
||||
np.testing.assert_array_equal(transformed_df[column].values, expected[column])
|
||||
|
||||
df = pd.DataFrame({"A": [1, 2, 3, 4]})
|
||||
transformed_df = imputer.transform_batch(df)
|
||||
|
||||
expected = {
|
||||
"A": [1, 2, 3, 4],
|
||||
"A_cat": ["three", "three", "three", "three"],
|
||||
}
|
||||
|
||||
for column in df:
|
||||
np.testing.assert_array_equal(transformed_df[column].values, expected[column])
|
||||
|
||||
|
||||
class TestSimpleImputerSerialization:
|
||||
"""Test CloudPickle-based serialization/deserialization functionality for SimpleImputer."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test data."""
|
||||
self.df_numeric = pd.DataFrame(
|
||||
{
|
||||
"temp": [20.0, 25.0, None, 30.0, None],
|
||||
"humidity": [60.0, None, 70.0, 80.0, 65.0],
|
||||
"other": ["a", "b", "c", "d", "e"], # Non-processed column
|
||||
}
|
||||
)
|
||||
|
||||
def test_basic_serialization(self):
|
||||
"""Test basic serialization and deserialization functionality."""
|
||||
# Create and fit a simple imputer
|
||||
imputer = SimpleImputer(columns=["temp", "humidity"], strategy="mean")
|
||||
|
||||
# Create test data
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"temp": [1.0, 2.0, None, 4.0],
|
||||
"humidity": [None, 2.0, 3.0, 4.0],
|
||||
"other": [1, 2, 3, 4],
|
||||
}
|
||||
)
|
||||
|
||||
# Fit the imputer
|
||||
dataset = ray.data.from_pandas(df)
|
||||
fitted_imputer = imputer.fit(dataset)
|
||||
|
||||
# Serialize using CloudPickle (primary format)
|
||||
serialized = fitted_imputer.serialize()
|
||||
|
||||
# Verify it's binary CloudPickle format
|
||||
assert isinstance(serialized, bytes)
|
||||
assert serialized.startswith(SerializablePreprocessorBase.MAGIC_CLOUDPICKLE)
|
||||
|
||||
# Deserialize
|
||||
deserialized = SimpleImputer.deserialize(serialized)
|
||||
|
||||
# Verify type and state
|
||||
assert isinstance(deserialized, SimpleImputer)
|
||||
assert deserialized._fitted
|
||||
assert deserialized.columns == ["temp", "humidity"]
|
||||
assert deserialized.strategy == "mean"
|
||||
|
||||
# Verify stats are preserved
|
||||
assert "mean(temp)" in deserialized.stats_
|
||||
assert "mean(humidity)" in deserialized.stats_
|
||||
assert abs(deserialized.stats_["mean(temp)"] - 2.333333) < 0.001
|
||||
assert abs(deserialized.stats_["mean(humidity)"] - 3.0) < 0.001
|
||||
|
||||
def test_serialization_formats(self):
|
||||
"""Test serialization and deserialization."""
|
||||
imputer = SimpleImputer(columns=["temp"], strategy="mean")
|
||||
dataset = ray.data.from_pandas(self.df_numeric)
|
||||
fitted_imputer = imputer.fit(dataset)
|
||||
|
||||
# Test CloudPickle format (default)
|
||||
serialized = fitted_imputer.serialize()
|
||||
assert isinstance(serialized, bytes)
|
||||
assert serialized.startswith(SerializablePreprocessorBase.MAGIC_CLOUDPICKLE)
|
||||
|
||||
# Deserialize and verify it works
|
||||
deserialized = SimpleImputer.deserialize(serialized)
|
||||
|
||||
# Verify it works correctly
|
||||
test_df = pd.DataFrame({"temp": [None, 35.0], "other": [1, 2]})
|
||||
result = deserialized.transform_batch(test_df.copy())
|
||||
|
||||
# Verify the result has the expected structure
|
||||
assert "temp" in result.columns
|
||||
assert "other" in result.columns
|
||||
|
||||
def test_functional_equivalence(self):
|
||||
"""Test that deserialized SimpleImputer works identically to original."""
|
||||
# Create and fit original
|
||||
imputer = SimpleImputer(columns=["value"], strategy="mean")
|
||||
train_df = pd.DataFrame({"value": [10, 20, None, 40], "id": [1, 2, 3, 4]})
|
||||
train_dataset = ray.data.from_pandas(train_df)
|
||||
fitted_imputer = imputer.fit(train_dataset)
|
||||
|
||||
# Test data
|
||||
test_df = pd.DataFrame({"value": [None, 50, None], "id": [5, 6, 7]})
|
||||
|
||||
# Transform with original
|
||||
original_result = fitted_imputer.transform_batch(test_df.copy())
|
||||
|
||||
# Serialize, deserialize, and transform (using CloudPickle)
|
||||
serialized = fitted_imputer.serialize()
|
||||
deserialized = SerializablePreprocessorBase.deserialize(serialized)
|
||||
deserialized_result = deserialized.transform_batch(test_df.copy())
|
||||
|
||||
# Results should be identical
|
||||
pd.testing.assert_frame_equal(original_result, deserialized_result)
|
||||
|
||||
# Verify specific values
|
||||
expected_mean = (10 + 20 + 40) / 3 # 23.333...
|
||||
assert abs(original_result.iloc[0]["value"] - expected_mean) < 1e-10
|
||||
assert abs(deserialized_result.iloc[0]["value"] - expected_mean) < 1e-10
|
||||
|
||||
def test_complex_stats_preservation(self):
|
||||
"""Test that CloudPickle perfectly preserves complex stats with various key types."""
|
||||
imputer = SimpleImputer(columns=["A"], strategy="mean")
|
||||
|
||||
# Manually set complex stats that would be problematic for other formats
|
||||
imputer.stats_ = {
|
||||
# Simple stats
|
||||
"mean(A)": 5.0,
|
||||
"count(A)": 100,
|
||||
# Complex key types that CloudPickle handles natively
|
||||
"unique_values(ints)": {1: 0, 2: 1, 3: 2, 4: 3, 5: 4}, # int keys
|
||||
"unique_values(floats)": {1.1: 0, 2.2: 1, 3.3: 2}, # float keys
|
||||
"unique_values(bools)": {True: 0, False: 1}, # bool keys
|
||||
"unique_values(none)": {None: 0}, # None keys
|
||||
"unique_values(tuples)": {
|
||||
("red", "car"): 0,
|
||||
("blue", "bike"): 1,
|
||||
(1, 2, 3): 2,
|
||||
("nested", ("inner", "tuple")): 3,
|
||||
},
|
||||
"unique_values(sets)": {
|
||||
frozenset([1, 2, 3]): 0,
|
||||
frozenset(["a", "b"]): 1,
|
||||
},
|
||||
"unique_values(mixed)": {
|
||||
"string": 0,
|
||||
42: 1,
|
||||
(1, 2): 2,
|
||||
frozenset([3, 4]): 3,
|
||||
None: 4,
|
||||
True: 5,
|
||||
},
|
||||
}
|
||||
imputer._fitted = True
|
||||
|
||||
# Serialize and deserialize (using CloudPickle)
|
||||
serialized = imputer.serialize()
|
||||
deserialized = SimpleImputer.deserialize(serialized)
|
||||
|
||||
# Verify ALL stats are perfectly preserved
|
||||
assert deserialized.stats_ == imputer.stats_
|
||||
|
||||
# Verify specific complex key preservation
|
||||
for stat_name, stat_dict in imputer.stats_.items():
|
||||
if isinstance(stat_dict, dict):
|
||||
original_keys = set(stat_dict.keys())
|
||||
restored_keys = set(deserialized.stats_[stat_name].keys())
|
||||
|
||||
# Keys should be identical (including types)
|
||||
assert original_keys == restored_keys
|
||||
|
||||
# Values should be identical
|
||||
for key in original_keys:
|
||||
assert stat_dict[key] == deserialized.stats_[stat_name][key]
|
||||
|
||||
# Key types should be preserved
|
||||
for orig_key, rest_key in zip(original_keys, restored_keys):
|
||||
if orig_key == rest_key: # Same key
|
||||
assert type(orig_key) is type(rest_key)
|
||||
|
||||
def test_performance_comparison(self):
|
||||
"""Test CloudPickle performance and simplicity."""
|
||||
# Create a large imputer with many stats
|
||||
imputer = SimpleImputer(
|
||||
columns=[f"col_{i}" for i in range(10)], strategy="mean"
|
||||
)
|
||||
|
||||
# Create large stats dictionary
|
||||
large_stats = {}
|
||||
for i in range(10):
|
||||
large_stats[f"mean(col_{i})"] = float(i)
|
||||
large_stats[f"count(col_{i})"] = 1000 + i
|
||||
|
||||
# Add complex key stats that CloudPickle handles natively
|
||||
large_stats[f"unique_values(col_{i})"] = {
|
||||
(f"key_{j}", j): j for j in range(100) # 100 tuple keys per column
|
||||
}
|
||||
|
||||
imputer.stats_ = large_stats
|
||||
imputer._fitted = True
|
||||
|
||||
# Test serialization performance and correctness (using CloudPickle)
|
||||
start_time = time.time()
|
||||
serialized = imputer.serialize()
|
||||
serialize_time = time.time() - start_time
|
||||
|
||||
start_time = time.time()
|
||||
deserialized = SimpleImputer.deserialize(serialized)
|
||||
deserialize_time = time.time() - start_time
|
||||
|
||||
# Verify correctness
|
||||
assert deserialized.stats_ == imputer.stats_
|
||||
assert len(deserialized.stats_) == len(imputer.stats_)
|
||||
|
||||
# Performance should be reasonable (less than 1 second for this size)
|
||||
assert serialize_time < 1.0
|
||||
assert deserialize_time < 1.0
|
||||
|
||||
# Verify no data loss with complex keys
|
||||
for stat_name in large_stats:
|
||||
if "unique_values" in stat_name:
|
||||
original_keys = set(large_stats[stat_name].keys())
|
||||
restored_keys = set(deserialized.stats_[stat_name].keys())
|
||||
assert original_keys == restored_keys
|
||||
|
||||
def test_cloudpickle_native_support(self):
|
||||
"""Test that CloudPickle handles all Python types natively without transformation."""
|
||||
imputer = SimpleImputer(columns=["A"], strategy="mean")
|
||||
|
||||
# Test all the key types that used to require custom transformation
|
||||
test_keys = [
|
||||
# Basic types
|
||||
"string_key",
|
||||
42, # int
|
||||
3.14, # float
|
||||
True, # bool
|
||||
False, # bool
|
||||
None, # None
|
||||
# Complex types that CloudPickle handles natively
|
||||
(1, 2, 3), # tuple
|
||||
("nested", ("inner", "tuple")), # nested tuple
|
||||
frozenset([1, 2, 3]), # frozenset
|
||||
frozenset(["a", "b"]), # frozenset with strings
|
||||
]
|
||||
|
||||
# Create stats with all these key types
|
||||
imputer.stats_ = {
|
||||
"test_dict": {key: f"value_{i}" for i, key in enumerate(test_keys)}
|
||||
}
|
||||
imputer._fitted = True
|
||||
|
||||
# Serialize and deserialize (using CloudPickle)
|
||||
serialized = imputer.serialize()
|
||||
deserialized = SimpleImputer.deserialize(serialized)
|
||||
|
||||
# Verify perfect preservation
|
||||
original_dict = imputer.stats_["test_dict"]
|
||||
restored_dict = deserialized.stats_["test_dict"]
|
||||
|
||||
assert len(original_dict) == len(restored_dict)
|
||||
|
||||
# Check each key-value pair and key type preservation
|
||||
for orig_key, orig_value in original_dict.items():
|
||||
# Key should exist and have same value
|
||||
assert orig_key in restored_dict
|
||||
assert restored_dict[orig_key] == orig_value
|
||||
|
||||
# Find the corresponding restored key to check type
|
||||
for rest_key in restored_dict.keys():
|
||||
if rest_key == orig_key:
|
||||
assert type(orig_key) is type(rest_key)
|
||||
break
|
||||
|
||||
def test_edge_case_empty_stats(self):
|
||||
"""Test serialization with empty stats."""
|
||||
imputer = SimpleImputer(columns=["A"], strategy="constant", fill_value=0)
|
||||
# Constant strategy doesn't need fitting, so stats will be empty
|
||||
|
||||
serialized = imputer.serialize()
|
||||
deserialized = SimpleImputer.deserialize(serialized)
|
||||
|
||||
assert deserialized.stats_ == {}
|
||||
assert deserialized.strategy == "constant"
|
||||
assert deserialized.fill_value == 0
|
||||
assert deserialized._is_fittable is False
|
||||
|
||||
def test_edge_case_none_values(self):
|
||||
"""Test serialization with None values in stats."""
|
||||
imputer = SimpleImputer(columns=["A"], strategy="mean")
|
||||
imputer._fitted = True
|
||||
imputer.stats_ = {
|
||||
"mean(A)": None,
|
||||
"count(A)": 0,
|
||||
"complex_dict": {
|
||||
None: "none_key",
|
||||
"none_value": None,
|
||||
(None, "tuple"): "tuple_with_none",
|
||||
},
|
||||
}
|
||||
|
||||
serialized = imputer.serialize()
|
||||
deserialized = SimpleImputer.deserialize(serialized)
|
||||
|
||||
assert deserialized.stats_ == imputer.stats_
|
||||
assert deserialized.stats_["mean(A)"] is None
|
||||
assert None in deserialized.stats_["complex_dict"]
|
||||
|
||||
def test_nested_complex_structures(self):
|
||||
"""Test deeply nested complex data structures."""
|
||||
imputer = SimpleImputer(columns=["A"], strategy="mean")
|
||||
imputer._fitted = True
|
||||
|
||||
# Create deeply nested structure with various key types
|
||||
imputer.stats_ = {
|
||||
"nested_structure": {
|
||||
("level1", "tuple"): {
|
||||
frozenset([1, 2]): "frozenset_key",
|
||||
42: {"nested_dict": "value"},
|
||||
None: [1, 2, 3],
|
||||
True: {"another": {"level": "deep"}},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
serialized = imputer.serialize()
|
||||
deserialized = SimpleImputer.deserialize(serialized)
|
||||
|
||||
assert deserialized.stats_ == imputer.stats_
|
||||
|
||||
# Verify specific nested access works
|
||||
nested = deserialized.stats_["nested_structure"]
|
||||
tuple_key = ("level1", "tuple")
|
||||
assert tuple_key in nested
|
||||
assert frozenset([1, 2]) in nested[tuple_key]
|
||||
|
||||
def test_unknown_preprocessor_type(self):
|
||||
"""Test error when trying to deserialize unknown preprocessor type."""
|
||||
import cloudpickle
|
||||
|
||||
# Create fake serialized data with unknown type
|
||||
unknown_data = {
|
||||
"type": "NonExistentPreprocessor",
|
||||
"version": 1,
|
||||
"fields": {"columns": ["test"]},
|
||||
"stats": {},
|
||||
"stats_type": "cloudpickle",
|
||||
}
|
||||
|
||||
fake_serialized = (
|
||||
SerializablePreprocessorBase.MAGIC_CLOUDPICKLE
|
||||
+ cloudpickle.dumps(unknown_data)
|
||||
)
|
||||
|
||||
with pytest.raises(UnknownPreprocessorError) as exc_info:
|
||||
SerializablePreprocessorBase.deserialize(fake_serialized)
|
||||
|
||||
# Verify the exception contains the correct preprocessor type
|
||||
assert exc_info.value.preprocessor_type == "NonExistentPreprocessor"
|
||||
assert "Unknown preprocessor type: NonExistentPreprocessor" in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
def test_file_system_integration(self):
|
||||
"""Test integration with file system operations."""
|
||||
imputer = SimpleImputer(columns=["value"], strategy="mean")
|
||||
df = pd.DataFrame({"value": [1, 2, None, 4]})
|
||||
dataset = ray.data.from_pandas(df)
|
||||
fitted = imputer.fit(dataset)
|
||||
|
||||
# Test with binary files (CloudPickle)
|
||||
with tempfile.NamedTemporaryFile(mode="wb", suffix=".cloudpickle") as f:
|
||||
# Save as CloudPickle
|
||||
serialized = fitted.serialize()
|
||||
f.write(serialized)
|
||||
f.flush()
|
||||
|
||||
# Load from file
|
||||
with open(f.name, "rb") as read_f:
|
||||
loaded_data = read_f.read()
|
||||
|
||||
deserialized = SerializablePreprocessorBase.deserialize(loaded_data)
|
||||
assert isinstance(deserialized, SimpleImputer)
|
||||
assert abs(deserialized.stats_["mean(value)"] - 2.333333333333333) < 1e-10
|
||||
|
||||
def test_special_numeric_values(self):
|
||||
"""Test serialization with inf, -inf, and NaN values."""
|
||||
# Test with inf fill_value
|
||||
imputer1 = SimpleImputer(columns=["col"], strategy="mean")
|
||||
imputer1.stats_ = {"mean(col)": float("inf")}
|
||||
imputer1._fitted = True
|
||||
|
||||
serialized = imputer1.serialize()
|
||||
deserialized = SerializablePreprocessorBase.deserialize(serialized)
|
||||
assert np.isinf(deserialized.stats_["mean(col)"])
|
||||
|
||||
# Test with -inf fill_value
|
||||
imputer2 = SimpleImputer(columns=["col"], strategy="mean")
|
||||
imputer2.stats_ = {"mean(col)": float("-inf")}
|
||||
imputer2._fitted = True
|
||||
|
||||
serialized = imputer2.serialize()
|
||||
deserialized = SerializablePreprocessorBase.deserialize(serialized)
|
||||
assert (
|
||||
np.isinf(deserialized.stats_["mean(col)"])
|
||||
and deserialized.stats_["mean(col)"] < 0
|
||||
)
|
||||
|
||||
# Test with NaN fill_value
|
||||
imputer3 = SimpleImputer(columns=["col"], strategy="mean")
|
||||
imputer3.stats_ = {"mean(col)": float("nan")}
|
||||
imputer3._fitted = True
|
||||
|
||||
serialized = imputer3.serialize()
|
||||
deserialized = SerializablePreprocessorBase.deserialize(serialized)
|
||||
assert np.isnan(deserialized.stats_["mean(col)"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,121 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.data.preprocessors import Normalizer
|
||||
|
||||
|
||||
def test_normalizer():
|
||||
"""Tests basic Normalizer functionality."""
|
||||
|
||||
col_a = [10, 10, 10]
|
||||
col_b = [1, 3, 3]
|
||||
col_c = [2, 4, -4]
|
||||
in_df = pd.DataFrame.from_dict({"A": col_a, "B": col_b, "C": col_c})
|
||||
ds = ray.data.from_pandas(in_df)
|
||||
|
||||
# l2 norm
|
||||
normalizer = Normalizer(["B", "C"])
|
||||
transformed = normalizer.transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
|
||||
processed_col_a = col_a
|
||||
processed_col_b = [1 / np.sqrt(5), 0.6, 0.6]
|
||||
processed_col_c = [2 / np.sqrt(5), 0.8, -0.8]
|
||||
expected_df = pd.DataFrame.from_dict(
|
||||
{"A": processed_col_a, "B": processed_col_b, "C": processed_col_c}
|
||||
).astype(out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(out_df, expected_df, check_like=True)
|
||||
|
||||
# l1 norm
|
||||
normalizer = Normalizer(["B", "C"], norm="l1")
|
||||
|
||||
transformed = normalizer.transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
|
||||
processed_col_a = col_a
|
||||
processed_col_b = [1 / 3, 3 / 7, 3 / 7]
|
||||
processed_col_c = [2 / 3, 4 / 7, -4 / 7]
|
||||
expected_df = pd.DataFrame.from_dict(
|
||||
{"A": processed_col_a, "B": processed_col_b, "C": processed_col_c}
|
||||
).astype(out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(out_df, expected_df, check_like=True)
|
||||
|
||||
# max norm
|
||||
normalizer = Normalizer(["B", "C"], norm="max")
|
||||
|
||||
transformed = normalizer.transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
|
||||
processed_col_a = col_a
|
||||
processed_col_b = [0.5, 0.75, 0.75]
|
||||
processed_col_c = [1.0, 1.0, -1.0]
|
||||
expected_df = pd.DataFrame.from_dict(
|
||||
{"A": processed_col_a, "B": processed_col_b, "C": processed_col_c}
|
||||
).astype(out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(out_df, expected_df, check_like=True)
|
||||
|
||||
# append mode
|
||||
with pytest.raises(ValueError):
|
||||
Normalizer(columns=["B", "C"], output_columns=["B_encoded"])
|
||||
|
||||
normalizer = Normalizer(["B", "C"], output_columns=["B_normalized", "C_normalized"])
|
||||
transformed = normalizer.transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
|
||||
processed_col_a = col_a
|
||||
processed_col_b = [1 / np.sqrt(5), 0.6, 0.6]
|
||||
processed_col_c = [2 / np.sqrt(5), 0.8, -0.8]
|
||||
expected_df = pd.DataFrame.from_dict(
|
||||
{
|
||||
"A": col_a,
|
||||
"B": col_b,
|
||||
"C": col_c,
|
||||
"B_normalized": processed_col_b,
|
||||
"C_normalized": processed_col_c,
|
||||
}
|
||||
).astype(out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(out_df, expected_df, check_like=True)
|
||||
|
||||
|
||||
def test_normalizer_serialization():
|
||||
"""Test Normalizer serialization and deserialization functionality."""
|
||||
from ray.data.preprocessor import SerializablePreprocessorBase
|
||||
|
||||
# Create normalizer with test data
|
||||
normalizer = Normalizer(columns=["A", "B"], norm="l1")
|
||||
|
||||
# Serialize using CloudPickle
|
||||
serialized = normalizer.serialize()
|
||||
|
||||
# Verify it's binary CloudPickle format
|
||||
assert isinstance(serialized, bytes)
|
||||
assert serialized.startswith(SerializablePreprocessorBase.MAGIC_CLOUDPICKLE)
|
||||
|
||||
# Deserialize
|
||||
deserialized = Normalizer.deserialize(serialized)
|
||||
|
||||
# Verify type and field values
|
||||
assert isinstance(deserialized, Normalizer)
|
||||
assert deserialized.columns == ["A", "B"]
|
||||
assert deserialized.norm == "l1"
|
||||
assert deserialized.output_columns == ["A", "B"]
|
||||
|
||||
# Verify it works correctly
|
||||
df = pd.DataFrame({"A": [3.0, 4.0], "B": [4.0, 3.0]})
|
||||
result = deserialized.transform_batch(df)
|
||||
|
||||
# For l1 norm, values should sum to 1 for each row
|
||||
assert abs(result["A"][0] + result["B"][0] - 1.0) < 1e-10
|
||||
assert abs(result["A"][1] + result["B"][1] - 1.0) < 1e-10
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,570 @@
|
||||
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__]))
|
||||
@@ -0,0 +1,891 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pyarrow as pa
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.data.preprocessor import (
|
||||
PreprocessorNotFittedException,
|
||||
SerializablePreprocessorBase,
|
||||
)
|
||||
from ray.data.preprocessors import (
|
||||
MaxAbsScaler,
|
||||
MinMaxScaler,
|
||||
RobustScaler,
|
||||
StandardScaler,
|
||||
)
|
||||
|
||||
|
||||
def test_min_max_scaler():
|
||||
"""Tests basic MinMaxScaler functionality."""
|
||||
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"])
|
||||
|
||||
# Transform with unfitted preprocessor.
|
||||
with pytest.raises(PreprocessorNotFittedException):
|
||||
scaler.transform(ds)
|
||||
|
||||
# Fit data.
|
||||
scaler.fit(ds)
|
||||
assert scaler.stats_ == {"min(B)": 1, "max(B)": 5, "min(C)": 1, "max(C)": 1}
|
||||
|
||||
transformed = scaler.transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
|
||||
processed_col_a = col_a
|
||||
processed_col_b = [0.0, 0.5, 1.0]
|
||||
processed_col_c = [0.0, 0.0, None]
|
||||
expected_df = pd.DataFrame.from_dict(
|
||||
{"A": processed_col_a, "B": processed_col_b, "C": processed_col_c}
|
||||
).astype(out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(out_df, expected_df)
|
||||
|
||||
# Transform batch.
|
||||
pred_col_a = [1, 2, 3]
|
||||
pred_col_b = [3, 5, 7]
|
||||
pred_col_c = [0, 1, 2]
|
||||
pred_in_df = pd.DataFrame.from_dict(
|
||||
{"A": pred_col_a, "B": pred_col_b, "C": pred_col_c}
|
||||
)
|
||||
|
||||
pred_out_df = scaler.transform_batch(pred_in_df)
|
||||
|
||||
pred_processed_col_a = pred_col_a
|
||||
pred_processed_col_b = [0.5, 1.0, 1.5]
|
||||
pred_processed_col_c = [-1.0, 0.0, 1.0]
|
||||
pred_expected_df = pd.DataFrame.from_dict(
|
||||
{
|
||||
"A": pred_processed_col_a,
|
||||
"B": pred_processed_col_b,
|
||||
"C": pred_processed_col_c,
|
||||
}
|
||||
).astype(pred_out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(pred_out_df, pred_expected_df)
|
||||
|
||||
# append mode
|
||||
with pytest.raises(ValueError):
|
||||
MinMaxScaler(columns=["B", "C"], output_columns=["B_mm_scaled"])
|
||||
|
||||
scaler = MinMaxScaler(
|
||||
columns=["B", "C"], output_columns=["B_mm_scaled", "C_mm_scaled"]
|
||||
)
|
||||
scaler.fit(ds)
|
||||
|
||||
pred_in_df = pd.DataFrame.from_dict(
|
||||
{"A": pred_col_a, "B": pred_col_b, "C": pred_col_c}
|
||||
)
|
||||
pred_out_df = scaler.transform_batch(pred_in_df)
|
||||
|
||||
pred_expected_df = pd.DataFrame.from_dict(
|
||||
{
|
||||
"A": pred_col_a,
|
||||
"B": pred_col_b,
|
||||
"C": pred_col_c,
|
||||
"B_mm_scaled": pred_processed_col_b,
|
||||
"C_mm_scaled": pred_processed_col_c,
|
||||
}
|
||||
).astype(pred_out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(pred_out_df, pred_expected_df, check_like=True)
|
||||
|
||||
|
||||
def test_max_abs_scaler():
|
||||
"""Tests basic MaxAbsScaler functionality."""
|
||||
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 = MaxAbsScaler(["B", "C"])
|
||||
|
||||
# Transform with unfitted preprocessor.
|
||||
with pytest.raises(PreprocessorNotFittedException):
|
||||
scaler.transform(ds)
|
||||
|
||||
# Fit data.
|
||||
scaler.fit(ds)
|
||||
assert scaler.stats_ == {"abs_max(B)": 5, "abs_max(C)": 1}
|
||||
|
||||
transformed = scaler.transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
|
||||
processed_col_a = col_a
|
||||
processed_col_b = [0.2, 0.6, -1.0]
|
||||
processed_col_c = [1.0, 1.0, None]
|
||||
expected_df = pd.DataFrame.from_dict(
|
||||
{"A": processed_col_a, "B": processed_col_b, "C": processed_col_c}
|
||||
).astype(out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(out_df, expected_df, check_like=True)
|
||||
|
||||
# Transform batch.
|
||||
pred_col_a = [1, 2, 3]
|
||||
pred_col_b = [3, 5, 7]
|
||||
pred_col_c = [0, 1, -2]
|
||||
pred_in_df = pd.DataFrame.from_dict(
|
||||
{"A": pred_col_a, "B": pred_col_b, "C": pred_col_c}
|
||||
)
|
||||
|
||||
pred_out_df = scaler.transform_batch(pred_in_df)
|
||||
|
||||
pred_processed_col_a = pred_col_a
|
||||
pred_processed_col_b = [0.6, 1.0, 1.4]
|
||||
pred_processed_col_c = [0.0, 1.0, -2.0]
|
||||
pred_expected_df = pd.DataFrame.from_dict(
|
||||
{
|
||||
"A": pred_processed_col_a,
|
||||
"B": pred_processed_col_b,
|
||||
"C": pred_processed_col_c,
|
||||
}
|
||||
).astype(pred_out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(pred_out_df, pred_expected_df, check_like=True)
|
||||
|
||||
# append mode
|
||||
with pytest.raises(ValueError):
|
||||
MaxAbsScaler(columns=["B", "C"], output_columns=["B_ma_scaled"])
|
||||
|
||||
scaler = MaxAbsScaler(
|
||||
columns=["B", "C"], output_columns=["B_ma_scaled", "C_ma_scaled"]
|
||||
)
|
||||
scaler.fit(ds)
|
||||
|
||||
pred_in_df = pd.DataFrame.from_dict(
|
||||
{"A": pred_col_a, "B": pred_col_b, "C": pred_col_c}
|
||||
)
|
||||
pred_out_df = scaler.transform_batch(pred_in_df)
|
||||
|
||||
pred_expected_df = pd.DataFrame.from_dict(
|
||||
{
|
||||
"A": pred_col_a,
|
||||
"B": pred_col_b,
|
||||
"C": pred_col_c,
|
||||
"B_ma_scaled": pred_processed_col_b,
|
||||
"C_ma_scaled": pred_processed_col_c,
|
||||
}
|
||||
).astype(pred_out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(pred_out_df, pred_expected_df, check_like=True)
|
||||
|
||||
|
||||
def test_robust_scaler():
|
||||
"""Tests basic RobustScaler functionality."""
|
||||
col_a = [-2, -1, 0, 1, 2]
|
||||
col_b = [-2, -1, 0, 1, 2]
|
||||
col_c = [-10, 1, 2, 3, 10]
|
||||
in_df = pd.DataFrame.from_dict({"A": col_a, "B": col_b, "C": col_c})
|
||||
ds = ray.data.from_pandas(in_df)
|
||||
|
||||
scaler = RobustScaler(["B", "C"])
|
||||
|
||||
# Transform with unfitted preprocessor.
|
||||
with pytest.raises(PreprocessorNotFittedException):
|
||||
scaler.transform(ds)
|
||||
|
||||
# Fit data.
|
||||
scaler.fit(ds)
|
||||
assert scaler.stats_ == {
|
||||
"low_quantile(B)": -1,
|
||||
"median(B)": 0,
|
||||
"high_quantile(B)": 1,
|
||||
"low_quantile(C)": 1,
|
||||
"median(C)": 2,
|
||||
"high_quantile(C)": 3,
|
||||
}
|
||||
|
||||
transformed = scaler.transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
|
||||
processed_col_a = col_a
|
||||
processed_col_b = [-1.0, -0.5, 0, 0.5, 1.0]
|
||||
processed_col_c = [-6, -0.5, 0, 0.5, 4]
|
||||
expected_df = pd.DataFrame.from_dict(
|
||||
{"A": processed_col_a, "B": processed_col_b, "C": processed_col_c}
|
||||
).astype(out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(out_df, expected_df, check_like=True)
|
||||
|
||||
# Transform batch.
|
||||
pred_col_a = [1, 2, 3]
|
||||
pred_col_b = [3, 5, 7]
|
||||
pred_col_c = [0, 1, 2]
|
||||
pred_in_df = pd.DataFrame.from_dict(
|
||||
{"A": pred_col_a, "B": pred_col_b, "C": pred_col_c}
|
||||
)
|
||||
|
||||
pred_out_df = scaler.transform_batch(pred_in_df)
|
||||
|
||||
pred_processed_col_a = pred_col_a
|
||||
pred_processed_col_b = [1.5, 2.5, 3.5]
|
||||
pred_processed_col_c = [-1.0, -0.5, 0.0]
|
||||
pred_expected_df = pd.DataFrame.from_dict(
|
||||
{
|
||||
"A": pred_processed_col_a,
|
||||
"B": pred_processed_col_b,
|
||||
"C": pred_processed_col_c,
|
||||
}
|
||||
).astype(pred_out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(pred_out_df, pred_expected_df, check_like=True)
|
||||
|
||||
# append mode
|
||||
with pytest.raises(ValueError):
|
||||
RobustScaler(columns=["B", "C"], output_columns=["B_r_scaled"])
|
||||
|
||||
scaler = RobustScaler(
|
||||
columns=["B", "C"], output_columns=["B_r_scaled", "C_r_scaled"]
|
||||
)
|
||||
scaler.fit(ds)
|
||||
|
||||
pred_in_df = pd.DataFrame.from_dict(
|
||||
{"A": pred_col_a, "B": pred_col_b, "C": pred_col_c}
|
||||
)
|
||||
pred_out_df = scaler.transform_batch(pred_in_df)
|
||||
|
||||
pred_expected_df = pd.DataFrame.from_dict(
|
||||
{
|
||||
"A": pred_col_a,
|
||||
"B": pred_col_b,
|
||||
"C": pred_col_c,
|
||||
"B_r_scaled": pred_processed_col_b,
|
||||
"C_r_scaled": pred_processed_col_c,
|
||||
}
|
||||
).astype(pred_out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(pred_out_df, pred_expected_df, check_like=True)
|
||||
|
||||
|
||||
def test_standard_scaler():
|
||||
"""Tests basic StandardScaler functionality."""
|
||||
col_a = [-1, 0, 1, 2]
|
||||
col_b = [1, 1, 5, 5]
|
||||
col_c = [1, 1, 1, None]
|
||||
col_d = [None, None, None, None]
|
||||
sample_df = pd.DataFrame.from_dict({"A": col_a, "B": col_b, "C": col_c, "D": col_d})
|
||||
ds = ray.data.from_pandas(sample_df)
|
||||
|
||||
scaler = StandardScaler(["B", "C", "D"])
|
||||
|
||||
# Transform with unfitted preprocessor.
|
||||
with pytest.raises(PreprocessorNotFittedException):
|
||||
scaler.transform(ds)
|
||||
|
||||
# Fit data.
|
||||
scaler = scaler.fit(ds)
|
||||
assert scaler.stats_ == {
|
||||
"mean(B)": 3.0,
|
||||
"mean(C)": 1.0,
|
||||
"mean(D)": None,
|
||||
"std(B)": 2.0,
|
||||
"std(C)": 0.0,
|
||||
"std(D)": None,
|
||||
}
|
||||
|
||||
# Transform data.
|
||||
in_col_a = [-1, 0, 1, 2]
|
||||
in_col_b = [1, 1, 5, 5]
|
||||
in_col_c = [1, 1, 1, None]
|
||||
in_col_d = [0, None, None, None]
|
||||
in_df = pd.DataFrame.from_dict(
|
||||
{"A": in_col_a, "B": in_col_b, "C": in_col_c, "D": in_col_d}
|
||||
)
|
||||
in_ds = ray.data.from_pandas(in_df)
|
||||
transformed = scaler.transform(in_ds)
|
||||
out_df = transformed.to_pandas()
|
||||
|
||||
processed_col_a = col_a
|
||||
processed_col_b = [-1.0, -1.0, 1.0, 1.0]
|
||||
processed_col_c = [0.0, 0.0, 0.0, None]
|
||||
processed_col_d = [np.nan, np.nan, np.nan, np.nan]
|
||||
expected_df = pd.DataFrame.from_dict(
|
||||
{
|
||||
"A": processed_col_a,
|
||||
"B": processed_col_b,
|
||||
"C": processed_col_c,
|
||||
"D": processed_col_d,
|
||||
}
|
||||
).astype(out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(out_df, expected_df, check_like=True)
|
||||
|
||||
# Transform batch.
|
||||
pred_col_a = [1, 2, 3]
|
||||
pred_col_b = [3, 5, 7]
|
||||
pred_col_c = [0, 1, 2]
|
||||
pred_col_d = [None, None, None]
|
||||
pred_in_df = pd.DataFrame.from_dict(
|
||||
{"A": pred_col_a, "B": pred_col_b, "C": pred_col_c, "D": pred_col_d}
|
||||
)
|
||||
|
||||
pred_out_df = scaler.transform_batch(pred_in_df)
|
||||
|
||||
pred_processed_col_a = pred_col_a
|
||||
pred_processed_col_b = [0.0, 1.0, 2.0]
|
||||
pred_processed_col_c = [-1.0, 0.0, 1.0]
|
||||
pred_processed_col_d = [None, None, None]
|
||||
pred_expected_df = pd.DataFrame.from_dict(
|
||||
{
|
||||
"A": pred_processed_col_a,
|
||||
"B": pred_processed_col_b,
|
||||
"C": pred_processed_col_c,
|
||||
"D": pred_processed_col_d,
|
||||
}
|
||||
).astype(pred_out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(pred_out_df, pred_expected_df, check_like=True)
|
||||
|
||||
# append mode
|
||||
with pytest.raises(ValueError):
|
||||
StandardScaler(columns=["B", "C"], output_columns=["B_s_scaled"])
|
||||
|
||||
scaler = StandardScaler(
|
||||
columns=["B", "C"], output_columns=["B_s_scaled", "C_s_scaled"]
|
||||
)
|
||||
scaler.fit(ds)
|
||||
|
||||
pred_in_df = pd.DataFrame.from_dict(
|
||||
{"A": pred_col_a, "B": pred_col_b, "C": pred_col_c}
|
||||
)
|
||||
pred_out_df = scaler.transform_batch(pred_in_df)
|
||||
|
||||
pred_expected_df = pd.DataFrame.from_dict(
|
||||
{
|
||||
"A": pred_col_a,
|
||||
"B": pred_col_b,
|
||||
"C": pred_col_c,
|
||||
"B_s_scaled": pred_processed_col_b,
|
||||
"C_s_scaled": pred_processed_col_c,
|
||||
}
|
||||
).astype(pred_out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(pred_out_df, pred_expected_df, check_like=True)
|
||||
|
||||
|
||||
def test_standard_scaler_arrow_transform():
|
||||
"""Test the StandardScaler _transform_arrow method directly."""
|
||||
# Create test data
|
||||
col_a = ["red", "green", "blue", "red"]
|
||||
col_b = [1.0, 3.0, 5.0, 7.0] # mean=4, std=2.236
|
||||
col_c = [10.0, 10.0, 10.0, 10.0] # constant column, std=0
|
||||
in_df = pd.DataFrame.from_dict({"A": col_a, "B": col_b, "C": col_c})
|
||||
|
||||
scaler = StandardScaler(["B", "C"])
|
||||
scaler.fit(ray.data.from_pandas(in_df))
|
||||
|
||||
# Create Arrow table for transformation
|
||||
table = pa.Table.from_pandas(in_df)
|
||||
|
||||
# Transform using Arrow
|
||||
result_table = scaler._transform_arrow(table)
|
||||
|
||||
# Verify result is an Arrow table
|
||||
assert isinstance(result_table, pa.Table)
|
||||
|
||||
# Convert to pandas for easier comparison
|
||||
result_df = result_table.to_pandas()
|
||||
|
||||
# Expected encoding:
|
||||
# B: (x - mean(B)) / std(B)
|
||||
# C: std(C)=0 -> std becomes 1 -> (x - mean(C)) / 1 = 0 for all
|
||||
b_mean = scaler.stats_["mean(B)"]
|
||||
b_std = scaler.stats_["std(B)"] or 0.0
|
||||
if b_std == 0:
|
||||
b_std = 1
|
||||
expected_col_b = [(x - b_mean) / b_std for x in col_b]
|
||||
|
||||
c_mean = scaler.stats_["mean(C)"]
|
||||
c_std = scaler.stats_["std(C)"] or 0.0
|
||||
if c_std == 0:
|
||||
c_std = 1
|
||||
expected_col_c = [(x - c_mean) / c_std for x in col_c]
|
||||
|
||||
assert result_df["A"].tolist() == col_a, "Column A should be unchanged"
|
||||
assert np.allclose(
|
||||
result_df["B"].tolist(), expected_col_b
|
||||
), f"Column B mismatch: {result_df['B'].tolist()}"
|
||||
assert np.allclose(
|
||||
result_df["C"].tolist(), expected_col_c
|
||||
), f"Column C mismatch: {result_df['C'].tolist()}"
|
||||
|
||||
|
||||
def test_standard_scaler_arrow_transform_append_mode():
|
||||
"""Test the StandardScaler _transform_arrow method in append mode."""
|
||||
col_a = ["red", "green", "blue"]
|
||||
col_b = [1.0, 3.0, 5.0]
|
||||
in_df = pd.DataFrame.from_dict({"A": col_a, "B": col_b})
|
||||
|
||||
scaler = StandardScaler(["B"], output_columns=["B_scaled"])
|
||||
scaler.fit(ray.data.from_pandas(in_df))
|
||||
|
||||
table = pa.Table.from_pandas(in_df)
|
||||
result_table = scaler._transform_arrow(table)
|
||||
result_df = result_table.to_pandas()
|
||||
|
||||
# Original columns should be unchanged
|
||||
assert result_df["A"].tolist() == col_a
|
||||
assert result_df["B"].tolist() == col_b
|
||||
|
||||
# New column should have scaled values: (x - 3) / 2
|
||||
b_mean = scaler.stats_["mean(B)"]
|
||||
b_std = scaler.stats_["std(B)"] or 0.0
|
||||
if b_std == 0:
|
||||
b_std = 1
|
||||
expected_b_scaled = [(x - b_mean) / b_std for x in col_b]
|
||||
assert np.allclose(result_df["B_scaled"].tolist(), expected_b_scaled)
|
||||
|
||||
|
||||
def test_standard_scaler_arrow_transform_null_stats():
|
||||
"""Test the StandardScaler _transform_arrow method with null mean/std."""
|
||||
# Use an all-null column to produce null mean/std during fit.
|
||||
in_df = pd.DataFrame.from_dict({"A": [None, None, None]})
|
||||
|
||||
scaler = StandardScaler(["A"])
|
||||
scaler.fit(ray.data.from_pandas(in_df))
|
||||
|
||||
table = pa.Table.from_pandas(in_df)
|
||||
result_table = scaler._transform_arrow(table)
|
||||
result_df = result_table.to_pandas()
|
||||
|
||||
# All values should be null when mean/std is None
|
||||
assert result_df["A"].isna().all(), "All values should be null when stats are None"
|
||||
|
||||
|
||||
def test_standard_scaler_arrow_transform_overlapping_columns():
|
||||
"""Test StandardScaler _transform_arrow with overlapping input/output columns.
|
||||
|
||||
This tests the case where output_columns[i] == columns[j] for i < j.
|
||||
The Arrow implementation must read all input columns before writing any output
|
||||
to avoid corrupting data that will be read later.
|
||||
"""
|
||||
# columns=['A', 'B'], output_columns=['B', 'C']
|
||||
# Without the fix, B would be overwritten before being read as input
|
||||
col_a = [2.0, 4.0, 6.0] # mean=4, std=2 -> scaled: [-1, 0, 1]
|
||||
col_b = [10.0, 20.0, 30.0] # mean=20, std=10 -> scaled: [-1, 0, 1]
|
||||
in_df = pd.DataFrame.from_dict({"A": col_a, "B": col_b})
|
||||
|
||||
scaler = StandardScaler(["A", "B"], output_columns=["B", "C"])
|
||||
scaler.fit(ray.data.from_pandas(in_df))
|
||||
|
||||
# Test Arrow transform
|
||||
table = pa.Table.from_pandas(in_df)
|
||||
result_table = scaler._transform_arrow(table)
|
||||
result_df = result_table.to_pandas()
|
||||
|
||||
# Test pandas transform for comparison
|
||||
pandas_result = scaler._transform_pandas(in_df.copy())
|
||||
|
||||
# Column A should be unchanged (not in output_columns with same index)
|
||||
assert result_df["A"].tolist() == col_a, "Column A should be unchanged"
|
||||
|
||||
# Column B should contain scaled A: (A - 4) / 2 = [-1, 0, 1]
|
||||
a_mean = scaler.stats_["mean(A)"]
|
||||
a_std = scaler.stats_["std(A)"] or 0.0
|
||||
if a_std == 0:
|
||||
a_std = 1
|
||||
expected_b = [(x - a_mean) / a_std for x in col_a]
|
||||
assert np.allclose(result_df["B"].tolist(), expected_b), (
|
||||
f"Column B should contain scaled A. Expected {expected_b}, "
|
||||
f"got {result_df['B'].tolist()}"
|
||||
)
|
||||
|
||||
# Column C should contain scaled B: (B - 20) / 10 = [-1, 0, 1]
|
||||
b_mean = scaler.stats_["mean(B)"]
|
||||
b_std = scaler.stats_["std(B)"] or 0.0
|
||||
if b_std == 0:
|
||||
b_std = 1
|
||||
expected_c = [(x - b_mean) / b_std for x in col_b]
|
||||
assert np.allclose(result_df["C"].tolist(), expected_c), (
|
||||
f"Column C should contain scaled B. Expected {expected_c}, "
|
||||
f"got {result_df['C'].tolist()}"
|
||||
)
|
||||
|
||||
# Arrow and pandas results should match
|
||||
pd.testing.assert_frame_equal(
|
||||
result_df,
|
||||
pandas_result,
|
||||
check_like=True,
|
||||
obj="Arrow vs Pandas transform results should match",
|
||||
)
|
||||
|
||||
|
||||
class TestScalerSerialization:
|
||||
"""Test serialization/deserialization functionality for scaler preprocessors."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test data."""
|
||||
self.test_df = pd.DataFrame(
|
||||
{
|
||||
"feature1": [1, 2, 3, 4, 5],
|
||||
"feature2": [10, 20, 30, 40, 50],
|
||||
"feature3": [100, 200, 300, 400, 500],
|
||||
"other": ["a", "b", "c", "d", "e"],
|
||||
}
|
||||
)
|
||||
self.test_dataset = ray.data.from_pandas(self.test_df)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scaler_class,fit_data,expected_stats,transform_data",
|
||||
[
|
||||
(
|
||||
StandardScaler,
|
||||
None, # Use default self.test_df
|
||||
{
|
||||
"mean(feature1)": 3.0,
|
||||
"mean(feature2)": 30.0,
|
||||
"std(feature1)": np.sqrt(2.0),
|
||||
"std(feature2)": np.sqrt(200.0),
|
||||
},
|
||||
pd.DataFrame(
|
||||
{
|
||||
"feature1": [6, 7, 8],
|
||||
"feature2": [60, 70, 80],
|
||||
"other": ["f", "g", "h"],
|
||||
}
|
||||
),
|
||||
),
|
||||
(
|
||||
MinMaxScaler,
|
||||
None, # Use default self.test_df
|
||||
{
|
||||
"min(feature1)": 1,
|
||||
"min(feature2)": 10,
|
||||
"max(feature1)": 5,
|
||||
"max(feature2)": 50,
|
||||
},
|
||||
pd.DataFrame(
|
||||
{
|
||||
"feature1": [6, 7, 8],
|
||||
"feature2": [60, 70, 80],
|
||||
"other": ["f", "g", "h"],
|
||||
}
|
||||
),
|
||||
),
|
||||
(
|
||||
MaxAbsScaler,
|
||||
pd.DataFrame(
|
||||
{
|
||||
"feature1": [-5, -2, 0, 2, 5],
|
||||
"feature2": [-50, -20, 0, 20, 50],
|
||||
"other": ["a", "b", "c", "d", "e"],
|
||||
}
|
||||
),
|
||||
{
|
||||
"abs_max(feature1)": 5,
|
||||
"abs_max(feature2)": 50,
|
||||
},
|
||||
pd.DataFrame(
|
||||
{
|
||||
"feature1": [-6, 0, 6],
|
||||
"feature2": [-60, 0, 60],
|
||||
"other": ["f", "g", "h"],
|
||||
}
|
||||
),
|
||||
),
|
||||
(
|
||||
RobustScaler,
|
||||
None, # Use default self.test_df
|
||||
{
|
||||
"low_quantile(feature1)": 2.0,
|
||||
"median(feature1)": 3.0,
|
||||
"high_quantile(feature1)": 4.0,
|
||||
"low_quantile(feature2)": 20.0,
|
||||
"median(feature2)": 30.0,
|
||||
"high_quantile(feature2)": 40.0,
|
||||
},
|
||||
pd.DataFrame(
|
||||
{
|
||||
"feature1": [6, 7, 8],
|
||||
"feature2": [60, 70, 80],
|
||||
"other": ["f", "g", "h"],
|
||||
}
|
||||
),
|
||||
),
|
||||
],
|
||||
ids=["StandardScaler", "MinMaxScaler", "MaxAbsScaler", "RobustScaler"],
|
||||
)
|
||||
def test_scaler_serialization(
|
||||
self, scaler_class, fit_data, expected_stats, transform_data
|
||||
):
|
||||
"""Test scaler serialization for all scaler types."""
|
||||
# Use custom fit data if provided, otherwise use default test dataset
|
||||
if fit_data is not None:
|
||||
fit_dataset = ray.data.from_pandas(fit_data)
|
||||
else:
|
||||
fit_dataset = self.test_dataset
|
||||
|
||||
# Create and fit scaler
|
||||
scaler = scaler_class(columns=["feature1", "feature2"])
|
||||
fitted_scaler = scaler.fit(fit_dataset)
|
||||
|
||||
# Verify fitted stats match expected values
|
||||
assert fitted_scaler.stats_ == expected_stats, (
|
||||
f"Stats mismatch for {scaler_class.__name__}:\n"
|
||||
f"Expected: {expected_stats}\n"
|
||||
f"Got: {fitted_scaler.stats_}"
|
||||
)
|
||||
|
||||
# Test CloudPickle serialization
|
||||
serialized = fitted_scaler.serialize()
|
||||
assert isinstance(serialized, bytes)
|
||||
assert serialized.startswith(SerializablePreprocessorBase.MAGIC_CLOUDPICKLE)
|
||||
|
||||
# Test deserialization
|
||||
deserialized = SerializablePreprocessorBase.deserialize(serialized)
|
||||
assert deserialized.__class__.__name__ == scaler_class.__name__
|
||||
assert deserialized.columns == ["feature1", "feature2"]
|
||||
assert deserialized._fitted
|
||||
|
||||
# Verify stats are preserved after deserialization
|
||||
assert deserialized.stats_ == expected_stats, (
|
||||
f"Deserialized stats mismatch for {scaler_class.__name__}:\n"
|
||||
f"Expected: {expected_stats}\n"
|
||||
f"Got: {deserialized.stats_}"
|
||||
)
|
||||
|
||||
# Verify each stat key exists and has correct value
|
||||
for stat_key, stat_value in expected_stats.items():
|
||||
assert stat_key in deserialized.stats_
|
||||
if isinstance(stat_value, float):
|
||||
assert np.isclose(deserialized.stats_[stat_key], stat_value)
|
||||
else:
|
||||
assert deserialized.stats_[stat_key] == stat_value
|
||||
|
||||
# Test functional equivalence
|
||||
original_result = fitted_scaler.transform_batch(transform_data.copy())
|
||||
deserialized_result = deserialized.transform_batch(transform_data.copy())
|
||||
|
||||
pd.testing.assert_frame_equal(original_result, deserialized_result)
|
||||
|
||||
def test_scaler_with_output_columns_serialization(self):
|
||||
"""Test scaler serialization with custom output columns."""
|
||||
# Test with StandardScaler and output columns
|
||||
scaler = StandardScaler(
|
||||
columns=["feature1", "feature2"],
|
||||
output_columns=["scaled_feature1", "scaled_feature2"],
|
||||
)
|
||||
fitted_scaler = scaler.fit(self.test_dataset)
|
||||
|
||||
# Serialize and deserialize
|
||||
serialized = fitted_scaler.serialize()
|
||||
deserialized = SerializablePreprocessorBase.deserialize(serialized)
|
||||
|
||||
# Verify output columns are preserved
|
||||
assert deserialized.output_columns == ["scaled_feature1", "scaled_feature2"]
|
||||
|
||||
# Test functional equivalence
|
||||
test_df = pd.DataFrame(
|
||||
{"feature1": [6, 7, 8], "feature2": [60, 70, 80], "other": ["f", "g", "h"]}
|
||||
)
|
||||
|
||||
original_result = fitted_scaler.transform_batch(test_df.copy())
|
||||
deserialized_result = deserialized.transform_batch(test_df.copy())
|
||||
|
||||
pd.testing.assert_frame_equal(original_result, deserialized_result)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scaler_class",
|
||||
[StandardScaler, MinMaxScaler, MaxAbsScaler, RobustScaler],
|
||||
ids=["StandardScaler", "MinMaxScaler", "MaxAbsScaler", "RobustScaler"],
|
||||
)
|
||||
def test_unfitted_scaler_serialization(self, scaler_class):
|
||||
"""Test serialization of unfitted scalers."""
|
||||
# Test unfitted scaler
|
||||
scaler = scaler_class(columns=["feature1", "feature2"])
|
||||
|
||||
# Serialize unfitted scaler
|
||||
serialized = scaler.serialize()
|
||||
deserialized = SerializablePreprocessorBase.deserialize(serialized)
|
||||
|
||||
# Verify it's still unfitted
|
||||
assert not deserialized._fitted
|
||||
assert deserialized.columns == ["feature1", "feature2"]
|
||||
assert deserialized.__class__.__name__ == scaler_class.__name__
|
||||
|
||||
# Should raise error when trying to transform
|
||||
test_df = pd.DataFrame({"feature1": [1, 2, 3], "feature2": [10, 20, 30]})
|
||||
with pytest.raises(PreprocessorNotFittedException):
|
||||
deserialized.transform_batch(test_df)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scaler_class,expected_stats",
|
||||
[
|
||||
(
|
||||
StandardScaler,
|
||||
{
|
||||
"mean(feature1)": 3.0,
|
||||
"std(feature1)": np.sqrt(2.0),
|
||||
},
|
||||
),
|
||||
(
|
||||
MinMaxScaler,
|
||||
{
|
||||
"min(feature1)": 1,
|
||||
"max(feature1)": 5,
|
||||
},
|
||||
),
|
||||
(
|
||||
MaxAbsScaler,
|
||||
{
|
||||
"abs_max(feature1)": 5,
|
||||
},
|
||||
),
|
||||
(
|
||||
RobustScaler,
|
||||
{
|
||||
"low_quantile(feature1)": 2.0,
|
||||
"median(feature1)": 3.0,
|
||||
"high_quantile(feature1)": 4.0,
|
||||
},
|
||||
),
|
||||
],
|
||||
ids=["StandardScaler", "MinMaxScaler", "MaxAbsScaler", "RobustScaler"],
|
||||
)
|
||||
def test_scaler_stats_preservation(self, scaler_class, expected_stats):
|
||||
"""Test that scaler statistics are perfectly preserved during serialization."""
|
||||
# Create scaler with known stats
|
||||
scaler = scaler_class(columns=["feature1"])
|
||||
fitted_scaler = scaler.fit(self.test_dataset)
|
||||
|
||||
# Verify fitted stats match expected values
|
||||
for stat_key, stat_value in expected_stats.items():
|
||||
assert stat_key in fitted_scaler.stats_
|
||||
if isinstance(stat_value, float):
|
||||
assert np.isclose(fitted_scaler.stats_[stat_key], stat_value)
|
||||
else:
|
||||
assert fitted_scaler.stats_[stat_key] == stat_value
|
||||
|
||||
# Get original stats
|
||||
original_stats = fitted_scaler.stats_.copy()
|
||||
|
||||
# Serialize and deserialize
|
||||
serialized = fitted_scaler.serialize()
|
||||
deserialized = SerializablePreprocessorBase.deserialize(serialized)
|
||||
|
||||
# Verify stats are identical
|
||||
assert deserialized.stats_ == original_stats
|
||||
|
||||
# Verify expected stat values are preserved
|
||||
for stat_key, stat_value in expected_stats.items():
|
||||
assert stat_key in deserialized.stats_
|
||||
if isinstance(stat_value, float):
|
||||
assert np.isclose(deserialized.stats_[stat_key], stat_value)
|
||||
else:
|
||||
assert deserialized.stats_[stat_key] == stat_value
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scaler_class",
|
||||
[StandardScaler, MinMaxScaler, MaxAbsScaler, RobustScaler],
|
||||
ids=["StandardScaler", "MinMaxScaler", "MaxAbsScaler", "RobustScaler"],
|
||||
)
|
||||
def test_scaler_version_compatibility(self, scaler_class):
|
||||
"""Test that scalers can be deserialized with version support."""
|
||||
# Create and fit scaler
|
||||
scaler = scaler_class(columns=["feature1", "feature2"])
|
||||
fitted_scaler = scaler.fit(self.test_dataset)
|
||||
|
||||
# Serialize
|
||||
serialized = fitted_scaler.serialize()
|
||||
|
||||
# Deserialize and verify version handling
|
||||
deserialized = SerializablePreprocessorBase.deserialize(serialized)
|
||||
assert deserialized.__class__.__name__ == scaler_class.__name__
|
||||
assert deserialized._fitted
|
||||
|
||||
# Test that it works correctly
|
||||
test_df = pd.DataFrame({"feature1": [6, 7, 8], "feature2": [60, 70, 80]})
|
||||
|
||||
result = deserialized.transform_batch(test_df)
|
||||
assert len(result.columns) == 2 # Should have the scaled columns
|
||||
assert "feature1" in result.columns
|
||||
assert "feature2" in result.columns
|
||||
|
||||
|
||||
def test_standard_scaler_near_zero_std():
|
||||
"""Test StandardScaler handles near-zero standard deviation correctly."""
|
||||
# Create data with very small standard deviation (near-constant values)
|
||||
col_a = [1.0, 1.0 + 1e-10, 1.0]
|
||||
col_b = [5, 10, 15] # Normal column for comparison
|
||||
in_df = pd.DataFrame.from_dict({"A": col_a, "B": col_b})
|
||||
ds = ray.data.from_pandas(in_df)
|
||||
|
||||
scaler = StandardScaler(["A", "B"])
|
||||
scaler.fit(ds)
|
||||
transformed = scaler.transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
|
||||
# Column A should be scaled to zeros (near-constant)
|
||||
# Instead of NaN or inf values
|
||||
assert np.allclose(
|
||||
out_df["A"], 0.0, atol=1e-6
|
||||
), "Near-constant column should be scaled to zeros"
|
||||
|
||||
# Column B should be normally scaled
|
||||
assert not np.allclose(out_df["B"], 0.0), "Normal column should not be all zeros"
|
||||
|
||||
# No NaN or inf values should be present
|
||||
assert not out_df["A"].isna().any(), "Should not contain NaN values"
|
||||
assert not np.isinf(out_df["A"]).any(), "Should not contain inf values"
|
||||
|
||||
|
||||
def test_min_max_scaler_near_zero_range():
|
||||
"""Test MinMaxScaler handles near-zero range correctly."""
|
||||
# Create data with very small range (near-constant values)
|
||||
col_a = [2.0, 2.0 + 1e-10, 2.0]
|
||||
col_b = [1, 5, 10] # Normal column for comparison
|
||||
in_df = pd.DataFrame.from_dict({"A": col_a, "B": col_b})
|
||||
ds = ray.data.from_pandas(in_df)
|
||||
|
||||
scaler = MinMaxScaler(["A", "B"])
|
||||
scaler.fit(ds)
|
||||
transformed = scaler.transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
|
||||
# Column A should be scaled to zeros (near-constant)
|
||||
# Instead of NaN or inf values
|
||||
assert np.allclose(
|
||||
out_df["A"], 0.0, atol=1e-6
|
||||
), "Near-constant column should be scaled to zeros"
|
||||
|
||||
# Column B should be normally scaled
|
||||
expected_b = [0.0, 4 / 9, 1.0]
|
||||
assert np.allclose(
|
||||
out_df["B"], expected_b, atol=1e-6
|
||||
), "Normal column should be scaled correctly"
|
||||
|
||||
# No NaN or inf values should be present
|
||||
assert not out_df["A"].isna().any(), "Should not contain NaN values"
|
||||
assert not np.isinf(out_df["A"]).any(), "Should not contain inf values"
|
||||
|
||||
|
||||
def test_standard_scaler_exact_zero_std():
|
||||
"""Test StandardScaler still handles exact zero standard deviation.
|
||||
|
||||
This is a regression test to ensure the epsilon-based handling
|
||||
doesn't break the existing behavior for exact zero std.
|
||||
"""
|
||||
# Create constant column (exact zero std)
|
||||
col_c = [5, 5, 5]
|
||||
in_df = pd.DataFrame.from_dict({"C": col_c})
|
||||
ds = ray.data.from_pandas(in_df)
|
||||
|
||||
scaler = StandardScaler(["C"])
|
||||
scaler.fit(ds)
|
||||
transformed = scaler.transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
|
||||
# Should be all zeros
|
||||
assert np.allclose(out_df["C"], 0.0), "Constant column should be scaled to zeros"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,118 @@
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.data.preprocessors import Tokenizer
|
||||
|
||||
|
||||
def test_tokenizer():
|
||||
"""Tests basic Tokenizer functionality."""
|
||||
|
||||
col_a = ["this is a test", "apple"]
|
||||
col_b = ["the quick brown fox jumps over the lazy dog", "banana banana"]
|
||||
in_df = pd.DataFrame.from_dict({"A": col_a, "B": col_b})
|
||||
ds = ray.data.from_pandas(in_df)
|
||||
|
||||
tokenizer = Tokenizer(["A", "B"])
|
||||
transformed = tokenizer.transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
|
||||
processed_col_a = [["this", "is", "a", "test"], ["apple"]]
|
||||
processed_col_b = [
|
||||
["the", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"],
|
||||
["banana", "banana"],
|
||||
]
|
||||
expected_df = pd.DataFrame.from_dict(
|
||||
{"A": processed_col_a, "B": processed_col_b}
|
||||
).astype(out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(out_df, expected_df, check_like=True)
|
||||
|
||||
# Test append mode
|
||||
with pytest.raises(
|
||||
ValueError, match="The length of columns and output_columns must match."
|
||||
):
|
||||
Tokenizer(columns=["A", "B"], output_columns=["A_tokenized"])
|
||||
|
||||
tokenizer = Tokenizer(
|
||||
columns=["A", "B"], output_columns=["A_tokenized", "B_tokenized"]
|
||||
)
|
||||
transformed = tokenizer.transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
print(out_df)
|
||||
expected_df = pd.DataFrame.from_dict(
|
||||
{
|
||||
"A": col_a,
|
||||
"B": col_b,
|
||||
"A_tokenized": processed_col_a,
|
||||
"B_tokenized": processed_col_b,
|
||||
}
|
||||
).astype(out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(out_df, expected_df, check_like=True)
|
||||
|
||||
# Test custom tokenization function
|
||||
def custom_tokenizer(s: str) -> list:
|
||||
return s.replace("banana", "fruit").split()
|
||||
|
||||
tokenizer = Tokenizer(
|
||||
columns=["A", "B"],
|
||||
tokenization_fn=custom_tokenizer,
|
||||
output_columns=["A_custom", "B_custom"],
|
||||
)
|
||||
transformed = tokenizer.transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
|
||||
custom_processed_col_a = [["this", "is", "a", "test"], ["apple"]]
|
||||
custom_processed_col_b = [
|
||||
["the", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"],
|
||||
["fruit", "fruit"],
|
||||
]
|
||||
expected_df = pd.DataFrame.from_dict(
|
||||
{
|
||||
"A": col_a,
|
||||
"B": col_b,
|
||||
"A_custom": custom_processed_col_a,
|
||||
"B_custom": custom_processed_col_b,
|
||||
}
|
||||
).astype(out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(out_df, expected_df, check_like=True)
|
||||
|
||||
|
||||
def test_tokenizer_serialization():
|
||||
"""Test Tokenizer serialization and deserialization functionality."""
|
||||
from ray.data.preprocessor import SerializablePreprocessorBase
|
||||
|
||||
# Create tokenizer
|
||||
tokenizer = Tokenizer(columns=["text"])
|
||||
|
||||
# Serialize using CloudPickle
|
||||
serialized = tokenizer.serialize()
|
||||
|
||||
# Verify it's binary CloudPickle format
|
||||
assert isinstance(serialized, bytes)
|
||||
assert serialized.startswith(SerializablePreprocessorBase.MAGIC_CLOUDPICKLE)
|
||||
|
||||
# Deserialize
|
||||
deserialized = Tokenizer.deserialize(serialized)
|
||||
|
||||
# Verify type and field values
|
||||
assert isinstance(deserialized, Tokenizer)
|
||||
assert deserialized.columns == ["text"]
|
||||
assert callable(deserialized.tokenization_fn)
|
||||
assert deserialized.output_columns == ["text"]
|
||||
|
||||
# Verify it works correctly
|
||||
df = pd.DataFrame({"text": ["hello world", "foo bar"]})
|
||||
result = deserialized.transform_batch(df)
|
||||
|
||||
# Verify tokenization was applied correctly
|
||||
assert result["text"][0] == ["hello", "world"]
|
||||
assert result["text"][1] == ["foo", "bar"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,161 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from torchvision import transforms
|
||||
|
||||
import ray
|
||||
from ray.data.exceptions import UserCodeException
|
||||
from ray.data.preprocessors import TorchVisionPreprocessor
|
||||
|
||||
|
||||
class TestTorchVisionPreprocessor:
|
||||
def test_repr(self):
|
||||
class StubTransform:
|
||||
def __call__(self, tensor):
|
||||
return tensor
|
||||
|
||||
def __repr__(self):
|
||||
return "StubTransform()"
|
||||
|
||||
preprocessor = TorchVisionPreprocessor(
|
||||
columns=["spam"], transform=StubTransform()
|
||||
)
|
||||
assert repr(preprocessor) == (
|
||||
"TorchVisionPreprocessor(columns=['spam'], "
|
||||
"output_columns=['spam'], transform=StubTransform())"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"transform",
|
||||
[
|
||||
transforms.ToTensor(), # `ToTensor` accepts an `np.ndarray` as input
|
||||
transforms.Lambda(lambda tensor: tensor.permute(2, 0, 1)),
|
||||
],
|
||||
)
|
||||
def test_transform_images(self, transform):
|
||||
dataset = ray.data.from_items(
|
||||
[
|
||||
{"image": np.zeros((32, 32, 3)), "label": 0},
|
||||
{"image": np.zeros((32, 32, 3)), "label": 1},
|
||||
]
|
||||
)
|
||||
preprocessor = TorchVisionPreprocessor(columns=["image"], transform=transform)
|
||||
|
||||
transformed_dataset = preprocessor.transform(dataset)
|
||||
|
||||
assert transformed_dataset.schema().names == ["image", "label"]
|
||||
transformed_images = [
|
||||
record["image"] for record in transformed_dataset.take_all()
|
||||
]
|
||||
assert all(image.shape == (3, 32, 32) for image in transformed_images)
|
||||
assert all(image.dtype == np.double for image in transformed_images)
|
||||
labels = {record["label"] for record in transformed_dataset.take_all()}
|
||||
assert labels == {0, 1}
|
||||
|
||||
def test_batch_transform_images(self):
|
||||
dataset = ray.data.from_items(
|
||||
[
|
||||
{"image": np.zeros((32, 32, 3)), "label": 0},
|
||||
{"image": np.zeros((32, 32, 3)), "label": 1},
|
||||
]
|
||||
)
|
||||
transform = transforms.Compose(
|
||||
[
|
||||
transforms.Lambda(
|
||||
lambda batch: torch.as_tensor(batch).permute(0, 3, 1, 2)
|
||||
),
|
||||
transforms.Resize(64),
|
||||
]
|
||||
)
|
||||
preprocessor = TorchVisionPreprocessor(
|
||||
columns=["image"], transform=transform, batched=True
|
||||
)
|
||||
|
||||
transformed_dataset = preprocessor.transform(dataset)
|
||||
|
||||
assert transformed_dataset.schema().names == ["image", "label"]
|
||||
transformed_images = [
|
||||
record["image"] for record in transformed_dataset.take_all()
|
||||
]
|
||||
assert all(image.shape == (3, 64, 64) for image in transformed_images)
|
||||
assert all(image.dtype == np.double for image in transformed_images)
|
||||
labels = {record["label"] for record in transformed_dataset.take_all()}
|
||||
assert labels == {0, 1}
|
||||
|
||||
def test_transform_ragged_images(self):
|
||||
dataset = ray.data.from_items(
|
||||
[
|
||||
{"image": np.zeros((16, 16, 3)), "label": 0},
|
||||
{"image": np.zeros((32, 32, 3)), "label": 1},
|
||||
]
|
||||
)
|
||||
transform = transforms.ToTensor()
|
||||
preprocessor = TorchVisionPreprocessor(columns=["image"], transform=transform)
|
||||
|
||||
transformed_dataset = preprocessor.transform(dataset)
|
||||
|
||||
assert transformed_dataset.schema().names == ["image", "label"]
|
||||
transformed_images = [
|
||||
record["image"] for record in transformed_dataset.take_all()
|
||||
]
|
||||
assert sorted(image.shape for image in transformed_images) == [
|
||||
(3, 16, 16),
|
||||
(3, 32, 32),
|
||||
]
|
||||
assert all(image.dtype == np.double for image in transformed_images)
|
||||
labels = {record["label"] for record in transformed_dataset.take_all()}
|
||||
assert labels == {0, 1}
|
||||
|
||||
def test_invalid_transform_raises_value_error(self):
|
||||
dataset = ray.data.from_items(
|
||||
[
|
||||
{"image": np.zeros((32, 32, 3)), "label": 0},
|
||||
{"image": np.zeros((32, 32, 3)), "label": 1},
|
||||
]
|
||||
)
|
||||
transform = transforms.Lambda(lambda tensor: "BLAH BLAH INVALID")
|
||||
preprocessor = TorchVisionPreprocessor(columns=["image"], transform=transform)
|
||||
|
||||
with pytest.raises((UserCodeException, ValueError)):
|
||||
preprocessor.transform(dataset).materialize()
|
||||
|
||||
|
||||
def test_torchvision_preprocessor_serialization():
|
||||
"""Test TorchVisionPreprocessor serialization and deserialization functionality."""
|
||||
from torchvision import transforms
|
||||
|
||||
from ray.data.preprocessor import SerializablePreprocessorBase
|
||||
|
||||
# Create preprocessor
|
||||
transform = transforms.Compose([transforms.ToTensor()])
|
||||
preprocessor = TorchVisionPreprocessor(columns=["image"], transform=transform)
|
||||
|
||||
# Serialize using CloudPickle
|
||||
serialized = preprocessor.serialize()
|
||||
|
||||
# Verify it's binary CloudPickle format
|
||||
assert isinstance(serialized, bytes)
|
||||
assert serialized.startswith(SerializablePreprocessorBase.MAGIC_CLOUDPICKLE)
|
||||
|
||||
# Deserialize
|
||||
deserialized = TorchVisionPreprocessor.deserialize(serialized)
|
||||
|
||||
# Verify type and field values
|
||||
assert isinstance(deserialized, TorchVisionPreprocessor)
|
||||
assert deserialized.columns == ["image"]
|
||||
assert isinstance(deserialized.torchvision_transform, type(transform))
|
||||
|
||||
# Verify it works correctly
|
||||
test_data = {"image": np.zeros((32, 32, 3), dtype=np.uint8)}
|
||||
result = deserialized.transform_batch(test_data)
|
||||
|
||||
# Verify transformation was applied - ToTensor converts uint8 [0,255] to float [0.0, 1.0]
|
||||
assert "image" in result
|
||||
assert result["image"].dtype in (np.float32, np.float64)
|
||||
assert result["image"].min() >= 0.0 and result["image"].max() <= 1.0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,146 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.data.preprocessors import PowerTransformer
|
||||
|
||||
|
||||
def test_power_transformer():
|
||||
"""Tests basic PowerTransformer functionality."""
|
||||
|
||||
# yeo-johnson
|
||||
col_a = [-1, 0]
|
||||
col_b = [0, 1]
|
||||
in_df = pd.DataFrame.from_dict({"A": col_a, "B": col_b})
|
||||
ds = ray.data.from_pandas(in_df)
|
||||
|
||||
# yeo-johnson power=0
|
||||
transformer = PowerTransformer(["A", "B"], power=0)
|
||||
transformed = transformer.transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
|
||||
processed_col_a = [-1.5, 0]
|
||||
processed_col_b = [0, np.log(2)]
|
||||
expected_df = pd.DataFrame.from_dict(
|
||||
{"A": processed_col_a, "B": processed_col_b}
|
||||
).astype(out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(out_df, expected_df, check_like=True)
|
||||
|
||||
# yeo-johnson power=2
|
||||
transformer = PowerTransformer(["A", "B"], power=2)
|
||||
transformed = transformer.transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
processed_col_a = [-np.log(2), 0]
|
||||
processed_col_b = [0, 1.5]
|
||||
expected_df = pd.DataFrame.from_dict(
|
||||
{"A": processed_col_a, "B": processed_col_b}
|
||||
).astype(out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(out_df, expected_df, check_like=True)
|
||||
|
||||
# box-cox
|
||||
col_a = [1, 2]
|
||||
col_b = [3, 4]
|
||||
in_df = pd.DataFrame.from_dict({"A": col_a, "B": col_b})
|
||||
ds = ray.data.from_pandas(in_df)
|
||||
|
||||
# box-cox power=0
|
||||
transformer = PowerTransformer(["A", "B"], power=0, method="box-cox")
|
||||
transformed = transformer.transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
|
||||
processed_col_a = [0, np.log(2)]
|
||||
processed_col_b = [np.log(3), np.log(4)]
|
||||
expected_df = pd.DataFrame.from_dict(
|
||||
{"A": processed_col_a, "B": processed_col_b}
|
||||
).astype(out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(out_df, expected_df, check_like=True)
|
||||
|
||||
# box-cox power=2
|
||||
transformer = PowerTransformer(["A", "B"], power=2, method="box-cox")
|
||||
transformed = transformer.transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
processed_col_a = [0, 1.5]
|
||||
processed_col_b = [4, 7.5]
|
||||
expected_df = pd.DataFrame.from_dict(
|
||||
{"A": processed_col_a, "B": processed_col_b}
|
||||
).astype(out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(out_df, expected_df, check_like=True)
|
||||
|
||||
# Test append mode
|
||||
# First test that providing wrong number of output columns raises error
|
||||
with pytest.raises(
|
||||
ValueError, match="The length of columns and output_columns must match."
|
||||
):
|
||||
PowerTransformer(columns=["A", "B"], power=2, output_columns=["A_transformed"])
|
||||
|
||||
# Test append mode with correct output columns
|
||||
transformer = PowerTransformer(
|
||||
columns=["A", "B"],
|
||||
power=2,
|
||||
method="box-cox",
|
||||
output_columns=["A_transformed", "B_transformed"],
|
||||
)
|
||||
transformed = transformer.transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
|
||||
# Transformed columns should have the expected values
|
||||
processed_col_a = [0, 1.5]
|
||||
processed_col_b = [4, 7.5]
|
||||
|
||||
expected_df = pd.DataFrame(
|
||||
{
|
||||
"A": col_a,
|
||||
"B": col_b,
|
||||
"A_transformed": processed_col_a,
|
||||
"B_transformed": processed_col_b,
|
||||
}
|
||||
).astype(out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(out_df, expected_df, check_like=True)
|
||||
|
||||
|
||||
def test_power_transformer_serialization():
|
||||
"""Test PowerTransformer serialization and deserialization functionality."""
|
||||
from ray.data.preprocessor import SerializablePreprocessorBase
|
||||
|
||||
# Create transformer with test data
|
||||
transformer = PowerTransformer(columns=["A", "B"], power=2.0, method="yeo-johnson")
|
||||
|
||||
# Serialize using CloudPickle
|
||||
serialized = transformer.serialize()
|
||||
|
||||
# Verify it's binary CloudPickle format
|
||||
assert isinstance(serialized, bytes)
|
||||
assert serialized.startswith(SerializablePreprocessorBase.MAGIC_CLOUDPICKLE)
|
||||
|
||||
# Deserialize
|
||||
deserialized = PowerTransformer.deserialize(serialized)
|
||||
|
||||
# Verify type and field values
|
||||
assert isinstance(deserialized, PowerTransformer)
|
||||
assert deserialized.columns == ["A", "B"]
|
||||
assert deserialized.power == 2.0
|
||||
assert deserialized.method == "yeo-johnson"
|
||||
assert deserialized.output_columns == ["A", "B"]
|
||||
|
||||
# Verify it works correctly
|
||||
df = pd.DataFrame({"A": [1.0, 2.0, 3.0], "B": [4.0, 5.0, 6.0]})
|
||||
result = deserialized.transform_batch(df.copy())
|
||||
|
||||
# Verify transformation was applied
|
||||
# For power=2, yeo-johnson on positive values: ((x+1)^2 - 1) / 2
|
||||
expected_a_0 = ((1.0 + 1) ** 2.0 - 1) / 2.0
|
||||
assert abs(result["A"][0] - expected_a_0) < 1e-10
|
||||
assert "A" in result.columns
|
||||
assert "B" in result.columns
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,29 @@
|
||||
import pytest
|
||||
|
||||
from ray.data.preprocessors.utils import simple_hash, simple_split_tokenizer
|
||||
|
||||
|
||||
def test_simple_split_tokenizer():
|
||||
# Tests simple_split_tokenizer.
|
||||
assert simple_split_tokenizer("one_word") == ["one_word"]
|
||||
assert simple_split_tokenizer("two words") == ["two", "words"]
|
||||
assert simple_split_tokenizer("One fish. Two fish.") == [
|
||||
"One",
|
||||
"fish.",
|
||||
"Two",
|
||||
"fish.",
|
||||
]
|
||||
|
||||
|
||||
def test_simple_hash():
|
||||
# Tests simple_hash determinism.
|
||||
assert simple_hash(1, 100) == 15
|
||||
assert simple_hash("a", 100) == 99
|
||||
assert simple_hash("banana", 100) == 10
|
||||
assert simple_hash([1, 2, "apple"], 100) == 58
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
@@ -0,0 +1,234 @@
|
||||
from collections import Counter
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.data.preprocessors import CountVectorizer, HashingVectorizer
|
||||
|
||||
|
||||
def test_count_vectorizer():
|
||||
"""Tests basic CountVectorizer functionality."""
|
||||
|
||||
# Increase data size & repartition to test for
|
||||
# discuss.ray.io/t/xgboost-ray-crashes-when-used-for-multiclass-text-classification
|
||||
row_multiplier = 100000
|
||||
|
||||
col_a = ["a b b c c c", "a a a a c"] * row_multiplier
|
||||
col_b = ["apple", "banana banana banana"] * row_multiplier
|
||||
in_df = pd.DataFrame.from_dict({"A": col_a, "B": col_b})
|
||||
ds = ray.data.from_pandas(in_df).repartition(10)
|
||||
|
||||
vectorizer = CountVectorizer(["A", "B"])
|
||||
vectorizer.fit(ds)
|
||||
assert vectorizer.stats_ == {
|
||||
"token_counts(A)": Counter(
|
||||
{"a": 5 * row_multiplier, "c": 4 * row_multiplier, "b": 2 * row_multiplier}
|
||||
),
|
||||
"token_counts(B)": Counter(
|
||||
{"banana": 3 * row_multiplier, "apple": 1 * row_multiplier}
|
||||
),
|
||||
}
|
||||
|
||||
transformed = vectorizer.transform(ds)
|
||||
out_df = transformed.to_pandas(limit=float("inf"))
|
||||
|
||||
processed_col_a = [[1, 3, 2], [4, 1, 0]] * row_multiplier
|
||||
processed_col_b = [[0, 1], [3, 0]] * row_multiplier
|
||||
|
||||
expected_df = pd.DataFrame.from_dict(
|
||||
{
|
||||
"A": processed_col_a,
|
||||
"B": processed_col_b,
|
||||
}
|
||||
).astype(out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(out_df, expected_df, check_like=True)
|
||||
|
||||
# max_features
|
||||
vectorizer = CountVectorizer(["A", "B"], max_features=2)
|
||||
vectorizer.fit(ds)
|
||||
|
||||
assert vectorizer.stats_ == {
|
||||
"token_counts(A)": Counter({"a": 5 * row_multiplier, "c": 4 * row_multiplier}),
|
||||
"token_counts(B)": Counter(
|
||||
{"banana": 3 * row_multiplier, "apple": 1 * row_multiplier}
|
||||
),
|
||||
}
|
||||
|
||||
transformed = vectorizer.transform(ds)
|
||||
out_df = transformed.to_pandas(limit=float("inf"))
|
||||
|
||||
processed_col_a = [[1, 3], [4, 1]] * row_multiplier
|
||||
processed_col_b = [[0, 1], [3, 0]] * row_multiplier
|
||||
|
||||
expected_df = pd.DataFrame.from_dict(
|
||||
{
|
||||
"A": processed_col_a,
|
||||
"B": processed_col_b,
|
||||
}
|
||||
).astype(out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(out_df, expected_df, check_like=True)
|
||||
|
||||
# Test append mode
|
||||
with pytest.raises(
|
||||
ValueError, match="The length of columns and output_columns must match."
|
||||
):
|
||||
CountVectorizer(
|
||||
columns=["A", "B"],
|
||||
output_columns=[
|
||||
"A_counts"
|
||||
], # Should provide same number of output columns as input
|
||||
)
|
||||
|
||||
vectorizer = CountVectorizer(["A", "B"], output_columns=["A_counts", "B_counts"])
|
||||
vectorizer.fit(ds)
|
||||
|
||||
transformed = vectorizer.transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
|
||||
processed_col_a = [[1, 3, 2], [4, 1, 0]] * row_multiplier
|
||||
processed_col_b = [[0, 1], [3, 0]] * row_multiplier
|
||||
|
||||
expected_df = pd.DataFrame.from_dict(
|
||||
{
|
||||
"A": col_a,
|
||||
"B": col_b,
|
||||
"A_counts": processed_col_a,
|
||||
"B_counts": processed_col_b,
|
||||
}
|
||||
).astype(out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(out_df, expected_df, check_like=True)
|
||||
|
||||
|
||||
def test_hashing_vectorizer():
|
||||
"""Tests basic HashingVectorizer functionality."""
|
||||
|
||||
col_a = ["a b b c c c", "a a a a c"]
|
||||
col_b = ["apple", "banana banana banana"]
|
||||
in_df = pd.DataFrame.from_dict({"A": col_a, "B": col_b})
|
||||
ds = ray.data.from_pandas(in_df)
|
||||
|
||||
vectorizer = HashingVectorizer(["A", "B"], num_features=3)
|
||||
|
||||
transformed = vectorizer.transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
|
||||
processed_col_a = [[0, 4, 2], [0, 5, 0]]
|
||||
processed_col_b = [[0, 0, 1], [3, 0, 0]]
|
||||
|
||||
expected_df = pd.DataFrame.from_dict(
|
||||
{"A": processed_col_a, "B": processed_col_b}
|
||||
).astype(out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(out_df, expected_df, check_like=True)
|
||||
|
||||
# Test append mode
|
||||
with pytest.raises(
|
||||
ValueError, match="The length of columns and output_columns must match."
|
||||
):
|
||||
HashingVectorizer(
|
||||
columns=["A", "B"],
|
||||
num_features=3,
|
||||
output_columns=[
|
||||
"A_hashed"
|
||||
], # Should provide same number of output columns as input
|
||||
)
|
||||
|
||||
vectorizer = HashingVectorizer(
|
||||
["A", "B"], num_features=3, output_columns=["A_hashed", "B_hashed"]
|
||||
)
|
||||
|
||||
transformed = vectorizer.transform(ds)
|
||||
out_df = transformed.to_pandas()
|
||||
|
||||
expected_df = pd.DataFrame.from_dict(
|
||||
{
|
||||
"A": col_a,
|
||||
"B": col_b,
|
||||
"A_hashed": processed_col_a,
|
||||
"B_hashed": processed_col_b,
|
||||
}
|
||||
).astype(out_df.dtypes.to_dict())
|
||||
|
||||
pd.testing.assert_frame_equal(out_df, expected_df, check_like=True)
|
||||
|
||||
|
||||
def test_hashing_vectorizer_serialization():
|
||||
"""Test HashingVectorizer serialization and deserialization functionality."""
|
||||
from ray.data.preprocessor import SerializablePreprocessorBase
|
||||
|
||||
# Create vectorizer
|
||||
vectorizer = HashingVectorizer(columns=["text"], num_features=16)
|
||||
|
||||
# Serialize using CloudPickle
|
||||
serialized = vectorizer.serialize()
|
||||
|
||||
# Verify it's binary CloudPickle format
|
||||
assert isinstance(serialized, bytes)
|
||||
assert serialized.startswith(SerializablePreprocessorBase.MAGIC_CLOUDPICKLE)
|
||||
|
||||
# Deserialize
|
||||
deserialized = HashingVectorizer.deserialize(serialized)
|
||||
|
||||
# Verify type and field values
|
||||
assert isinstance(deserialized, HashingVectorizer)
|
||||
assert deserialized.columns == ["text"]
|
||||
assert deserialized.num_features == 16
|
||||
assert callable(deserialized.tokenization_fn)
|
||||
assert deserialized.output_columns == ["text"]
|
||||
|
||||
# Verify it works correctly
|
||||
df = pd.DataFrame({"text": ["hello world", "foo bar"]})
|
||||
result = deserialized.transform_batch(df)
|
||||
|
||||
# Verify vectorization was applied correctly
|
||||
assert "text" in result.columns
|
||||
assert len(result["text"][0]) == 16
|
||||
assert len(result["text"][1]) == 16
|
||||
|
||||
|
||||
def test_count_vectorizer_serialization():
|
||||
"""Test CountVectorizer serialization and deserialization functionality."""
|
||||
import ray
|
||||
from ray.data.preprocessor import SerializablePreprocessorBase
|
||||
|
||||
# Create and fit vectorizer
|
||||
vectorizer = CountVectorizer(columns=["text"], max_features=5)
|
||||
df = pd.DataFrame({"text": ["hello world", "foo bar", "hello foo"]})
|
||||
ds = ray.data.from_pandas(df)
|
||||
fitted_vectorizer = vectorizer.fit(ds)
|
||||
|
||||
# Serialize using CloudPickle
|
||||
serialized = fitted_vectorizer.serialize()
|
||||
|
||||
# Verify it's binary CloudPickle format
|
||||
assert isinstance(serialized, bytes)
|
||||
assert serialized.startswith(SerializablePreprocessorBase.MAGIC_CLOUDPICKLE)
|
||||
|
||||
# Deserialize
|
||||
deserialized = CountVectorizer.deserialize(serialized)
|
||||
|
||||
# Verify type and field values
|
||||
assert isinstance(deserialized, CountVectorizer)
|
||||
assert deserialized._fitted
|
||||
assert deserialized.columns == ["text"]
|
||||
assert deserialized.max_features == 5
|
||||
|
||||
# Verify stats are preserved
|
||||
assert "token_counts(text)" in deserialized.stats_
|
||||
|
||||
# Verify it works correctly
|
||||
test_df = pd.DataFrame({"text": ["hello world"]})
|
||||
result = deserialized.transform_batch(test_df)
|
||||
|
||||
# Verify vectorization was applied correctly
|
||||
assert "text" in result.columns
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-sv", __file__]))
|
||||
Reference in New Issue
Block a user