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