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

630 lines
22 KiB
Python

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