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

892 lines
30 KiB
Python

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__]))