Download backend/tests/unit/test_split_isolation.py from yusufcalisir/Collaborative-Fraud-Intelligence-Simulator: direct link, hf CLI and curl.
- Browser
- Download file 7.82 kB
-
https://huggingface.co/spaces/yusufcalisir/Collaborative-Fraud-Intelligence-Simulator/resolve/main/backend/tests/unit/test_split_isolation.py
- Command line
-
hf download hf://spaces/yusufcalisir/Collaborative-Fraud-Intelligence-Simulator/backend/tests/unit/test_split_isolation.py
-
curl -L -o test_split_isolation.py https://huggingface.co/spaces/yusufcalisir/Collaborative-Fraud-Intelligence-Simulator/resolve/main/backend/tests/unit/test_split_isolation.py
7.82 kB
| """Unit tests for Data Preprocessor and Temporal Split Isolation. | |
| Verifies: | |
| - Strict train-split isolation (parameters learned only from training partition). | |
| - Handling of unseen test categorical tokens (mapped to zero-vector without crashing). | |
| - Zero data snooping (outliers/distributions in test have 0% effect on train statistics). | |
| - Chronological temporal ordering (t_train_max <= t_val_min <= t_test_min). | |
| - Full state serialization and deserialization fidelity. | |
| """ | |
| from __future__ import annotations | |
| import numpy as np | |
| import pandas as pd | |
| import pytest | |
| from app.application.services.dataloader import temporal_split_dataset | |
| from app.application.services.feature_service import FeatureService | |
| from app.application.services.preprocessor import DataPreprocessor, PreprocessingStateError | |
| class TestDataPreprocessorIsolation: | |
| """Test suite ensuring zero-leakage preprocessing and split isolation.""" | |
| def test_preprocessor_not_fitted_raises_error(self) -> None: | |
| """Calling transform before fit must raise PreprocessingStateError.""" | |
| prep = DataPreprocessor() | |
| df = pd.DataFrame({"col1": [1.0, 2.0, 3.0]}) | |
| with pytest.raises(PreprocessingStateError, match="must be fitted before transform"): | |
| prep.transform(df) | |
| def test_preprocessor_fit_transform_strict_isolation(self) -> None: | |
| """Preprocessor statistics must derive strictly from training set.""" | |
| train_data = pd.DataFrame({ | |
| "amount": [100.0, 200.0, 300.0], | |
| "balance": [1000.0, 2000.0, 3000.0], | |
| }) | |
| test_data = pd.DataFrame({ | |
| "amount": [999999.0, 888888.0], # Extreme outliers in test set | |
| "balance": [5000.0, 6000.0], | |
| }) | |
| prep = DataPreprocessor(numeric_strategy="standardize", impute_strategy="mean") | |
| X_train_transformed = prep.fit_transform(train_data) | |
| # Means and stds must reflect ONLY train_data | |
| assert prep.means_["amount"] == pytest.approx(200.0) | |
| assert prep.means_["balance"] == pytest.approx(2000.0) | |
| assert prep.stds_["amount"] > 0 | |
| assert prep.stds_["balance"] > 0 | |
| # Transforming test data must use train statistics without mutating them | |
| X_test_transformed = prep.transform(test_data) | |
| # Verify mean of amount has NOT changed | |
| assert prep.means_["amount"] == pytest.approx(200.0) | |
| assert X_train_transformed.shape == (3, 2) | |
| assert X_test_transformed.shape == (2, 2) | |
| def test_unseen_categorical_vocabulary_isolation(self) -> None: | |
| """Categories present only in test set must be mapped to zero-vector without leaking.""" | |
| train_df = pd.DataFrame({ | |
| "amount": [10.0, 20.0, 30.0], | |
| "channel": ["MOBILE", "WEB", "MOBILE"], | |
| }) | |
| test_df = pd.DataFrame({ | |
| "amount": [40.0, 50.0], | |
| "channel": ["ATM", "BRANCH"], # Completely unseen in train | |
| }) | |
| prep = DataPreprocessor() | |
| X_train = prep.fit_transform(train_df) | |
| X_test = prep.transform(test_df) | |
| assert "MOBILE" in prep.categories_["channel"] | |
| assert "WEB" in prep.categories_["channel"] | |
| assert "ATM" not in prep.categories_["channel"] | |
| assert "BRANCH" not in prep.categories_["channel"] | |
| # Number of output features: 1 numeric ("amount") + 2 categories ("MOBILE", "WEB") | |
| assert prep.feature_names_out_ == ["amount", "channel_MOBILE", "channel_WEB"] | |
| assert X_train.shape[1] == 3 | |
| assert X_test.shape[1] == 3 | |
| # Test rows for ATM and BRANCH must have zeros for all train category indicators | |
| assert np.all(X_test[:, 1:] == 0.0) | |
| def test_preprocessor_outlier_clipping(self) -> None: | |
| """Outlier clipping bounds transformed values to clip_std_factor.""" | |
| train_df = pd.DataFrame({"feat": [0.0, 1.0, 2.0, 3.0, 4.0]}) | |
| test_df = pd.DataFrame({"feat": [10000.0, -10000.0]}) | |
| prep = DataPreprocessor(numeric_strategy="standardize", clip_outliers=True, clip_std_factor=5.0) | |
| prep.fit(train_df) | |
| X_test = prep.transform(test_df) | |
| assert np.all(X_test <= 5.0) | |
| assert np.all(X_test >= -5.0) | |
| def test_serialization_fidelity(self) -> None: | |
| """Serialized preprocessor state must reconstitute identical transformation behavior.""" | |
| df = pd.DataFrame({ | |
| "amount": [100.0, 200.0, np.nan, 400.0], | |
| "category": ["TRANSFER", "CASH_OUT", "TRANSFER", "PAYMENT"], | |
| }) | |
| prep = DataPreprocessor(numeric_strategy="minmax", impute_strategy="median") | |
| X_transformed_orig = prep.fit_transform(df) | |
| state_dict = prep.to_dict() | |
| restored_prep = DataPreprocessor.from_dict(state_dict) | |
| X_transformed_restored = restored_prep.transform(df) | |
| np.testing.assert_allclose(X_transformed_orig, X_transformed_restored, rtol=1e-5) | |
| class TestTemporalSplitIsolation: | |
| """Test suite ensuring chronological arrow of time is enforced without lookahead.""" | |
| def test_temporal_split_monotonic_order(self) -> None: | |
| """Verifies t_train_max <= t_val_min <= t_test_min.""" | |
| n_samples = 100 | |
| df = pd.DataFrame({ | |
| "step": np.random.default_rng(42).permutation(np.arange(n_samples)), | |
| "amount": np.random.default_rng(42).uniform(10, 500, size=n_samples), | |
| "isFraud": (np.random.default_rng(42).random(n_samples) < 0.1).astype(int), | |
| }) | |
| train_df, val_df, test_df, meta = FeatureService.temporal_split( | |
| df, | |
| time_col="step", | |
| train_ratio=0.70, | |
| val_ratio=0.15, | |
| test_ratio=0.15, | |
| label_col="isFraud", | |
| ) | |
| assert meta.is_strictly_chronological is True | |
| assert len(train_df) == 70 | |
| assert len(val_df) == 15 | |
| assert len(test_df) == 15 | |
| # Check temporal order boundaries | |
| assert meta.train_time_max is not None and meta.val_time_min is not None | |
| assert meta.val_time_max is not None and meta.test_time_min is not None | |
| assert float(meta.train_time_max) <= float(meta.val_time_min) | |
| assert float(meta.val_time_max) <= float(meta.test_time_min) | |
| # Check internal ascending order | |
| assert train_df["step"].is_monotonic_increasing | |
| assert val_df["step"].is_monotonic_increasing | |
| assert test_df["step"].is_monotonic_increasing | |
| def test_temporal_split_boundary_overlap_detection(self) -> None: | |
| """Boundary overlap is flagged when boundary timestamps match across partitions.""" | |
| # 10 records all at timestamp 5.0 | |
| df = pd.DataFrame({ | |
| "step": [5.0] * 10, | |
| "amount": list(range(10)), | |
| }) | |
| _, _, _, meta = FeatureService.temporal_split( | |
| df, | |
| time_col="step", | |
| train_ratio=0.6, | |
| val_ratio=0.2, | |
| test_ratio=0.2, | |
| ) | |
| assert meta.boundary_overlap is True | |
| def test_dataloader_temporal_split_integration(self) -> None: | |
| """Verifies dataloader temporal split helper on PaySim mock dataset.""" | |
| from app.application.services.synthetic_dataset_generators import generate_synthetic_paysim | |
| paysim_data = generate_synthetic_paysim(n_mock_txns=200) | |
| split_result = temporal_split_dataset( | |
| paysim_data, | |
| train_ratio=0.70, | |
| val_ratio=0.15, | |
| test_ratio=0.15, | |
| preprocess=True, | |
| ) | |
| assert split_result["is_strictly_chronological"] is True | |
| assert split_result["X_train"].shape[0] == 140 | |
| assert split_result["X_val"].shape[0] == 30 | |
| assert split_result["X_test"].shape[0] == 30 | |
| assert split_result["preprocessor"] is not None | |
| assert split_result["preprocessor"].is_fitted is True | |