From d65572520a4b6536e11b728bef1a8b3d77ff6c10 Mon Sep 17 00:00:00 2001 From: raofal-msodeh <2.70183973e+08+raofal-msodeh@users.noreply.github.com> Date: Thu, 20 Aug 2026 06:14:58 +0000 Subject: [PATCH] test: add comprehensive pytest contract suite and fix two runtime bugs - Add a pytest suite (57 tests) covering the behavioral contracts of the public API surface: DataProcessor, handle_missing_values, FeatureEngineer, ClusteringSystem, Classifier, ModelEvaluator, DataExplorer, Config, and the utils helpers (feature type detection, memory optimization, model IO). - fix(utils): handle_missing_values silently no-ops on pandas >= 3.0 because ChainedAssignmentError is raised by fillna(inplace=True) on a chained Series; missing values are never actually filled. Assign the fill result back to the column instead. - fix(models): ClusteringSystem.fit() compares algorithm names with lowercase constants, so any mixed-case name ('KMeans', 'GMM', ...) passes silently with empty results and a failing predict(). Normalize algorithm names to lowercase before dispatch. - fix(models): raise a clear ValueError when the derived n_clusters_range is empty for small datasets instead of returning an unfitted, silent object. - Add GitHub Actions CI workflow (Python 3.10-3.12), CONTRIBUTING.md, and requirements-test.txt to formalize how the project is tested. --- CONTRIBUTING.md | 47 ++++ mltools/models/clustering.py | 8 +- mltools/utils/helpers.py | 6 +- requirements-test.txt | 1 + tests/__init__.py | 0 tests/test_evaluation_exploration_utils.py | 192 ++++++++++++++++ tests/test_feature_engineering.py | 95 ++++++++ tests/test_models.py | 142 ++++++++++++ tests/test_preprocessing.py | 243 +++++++++++++++++++++ 9 files changed, 731 insertions(+), 3 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 requirements-test.txt create mode 100644 tests/__init__.py create mode 100644 tests/test_evaluation_exploration_utils.py create mode 100644 tests/test_feature_engineering.py create mode 100644 tests/test_models.py create mode 100644 tests/test_preprocessing.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d311a3d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,47 @@ +# Contributing to MLTools + +Thank you for considering contributing to MLTools. This document explains how to +get set up and how contributions are reviewed. + +## Getting Started + +Install the library and the test dependencies in an isolated environment: + +```bash +python -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate +pip install -r requirements.txt +pip install -r requirements-test.txt +``` + +## Running Tests + +All behavior contracts are verified by the `pytest` suite in `tests/`. Run the +full suite before opening a pull request: + +```bash +pytest tests/ +``` + +The legacy smoke script `test_mltools.py` is also kept in the repository and +should continue to pass. + +## Adding Tests + +New public behavior must be covered by contract tests under `tests/`. Tests +should be organized by module (for example `test_preprocessing.py`) and grouped +in classes describing the API surface they verify. Each test should assert the +behavioral contract (input validation, edge cases, error paths, output shape), +not internal implementation details. + +## Pull Requests + +Pull requests are reviewed against the following checklist: + +1. Does the change fix a real bug or add verifiable value? +2. Are new behaviors covered by tests that pass locally? +3. Does the existing smoke script (`test_mltools.py`) still pass? +4. Are there no secrets, credentials, or unrelated changes in the diff? + +Describe the problem, the reason, the solution, and the tests in the pull +request description so reviewers can assess impact quickly. diff --git a/mltools/models/clustering.py b/mltools/models/clustering.py index b4bc735..6e4c3e2 100644 --- a/mltools/models/clustering.py +++ b/mltools/models/clustering.py @@ -65,12 +65,18 @@ def fit( if n_clusters_range is None: n_clusters_range = range(2, min(11, len(X) // 10)) + if n_clusters_range is None or len(list(n_clusters_range)) == 0: + raise ValueError( + f"Cannot derive a valid cluster range for {len(X)} samples. " + "Pass an explicit n_clusters_range with at least one value." + ) + if algorithms is None: algorithms = ['kmeans', 'hierarchical', 'gmm'] X_scaled = StandardScaler().fit_transform(X) - for algorithm in algorithms: + for algorithm in [alg.lower() for alg in algorithms]: self.logger.info(f"Testing {algorithm}...") if algorithm == 'kmeans': diff --git a/mltools/utils/helpers.py b/mltools/utils/helpers.py index 377919f..0df99c2 100644 --- a/mltools/utils/helpers.py +++ b/mltools/utils/helpers.py @@ -160,8 +160,10 @@ def handle_missing_values( for col in df.columns: if df[col].isnull().sum() > 0: if is_numeric_dtype(df[col]): - df[col].fillna(df[col].median(), inplace=True) + df[col] = df[col].fillna(df[col].median()) else: - df[col].fillna(df[col].mode()[0] if len(df[col].mode()) > 0 else 'missing', inplace=True) + mode_values = df[col].mode() + fill_value = mode_values.iloc[0] if len(mode_values) > 0 else 'missing' + df[col] = df[col].fillna(fill_value) return df diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..039d26e --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1 @@ +pytest>=8.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_evaluation_exploration_utils.py b/tests/test_evaluation_exploration_utils.py new file mode 100644 index 0000000..8281255 --- /dev/null +++ b/tests/test_evaluation_exploration_utils.py @@ -0,0 +1,192 @@ +"""Contract tests for ModelEvaluator, DataExplorer, and utils helpers.""" + +import os +import pandas as pd +import numpy as np +import pytest + +from mltools import Config, DataExplorer +from mltools.evaluation import ModelEvaluator +from mltools.utils import save_model, load_model, optimize_memory, detect_feature_types +from mltools.utils.helpers import handle_missing_values + + +# --------------------------------------------------------------------------- +# ModelEvaluator +# --------------------------------------------------------------------------- + +class TestModelEvaluator: + def test_classification_metrics_keys(self): + evaluator = ModelEvaluator() + y_true = np.array([0, 1, 1, 0, 1]) + y_pred = np.array([0, 1, 0, 0, 1]) + metrics = evaluator.evaluate_classification(y_true, y_pred) + for key in ("accuracy", "precision", "recall", "f1", "confusion_matrix"): + assert key in metrics + + def test_perfect_predictions_accuracy_one(self): + evaluator = ModelEvaluator() + y = np.array([0, 1, 0, 1, 0, 1]) + metrics = evaluator.evaluate_classification(y, y) + assert metrics["accuracy"] == 1.0 + assert metrics["f1"] == 1.0 + + def test_regression_metrics_keys(self): + evaluator = ModelEvaluator() + y_true = np.array([1.0, 2.0, 3.0, 4.0]) + y_pred = np.array([1.1, 2.2, 2.9, 3.8]) + metrics = evaluator.evaluate_regression(y_true, y_pred) + for key in ("mse", "rmse", "mae", "r2"): + assert key in metrics + assert metrics["mse"] >= 0 + assert 0.0 <= metrics["r2"] <= 1.0 + + def test_perfect_regression_predictions(self): + evaluator = ModelEvaluator() + y = np.array([1.0, 2.0, 3.0, 4.0]) + metrics = evaluator.evaluate_regression(y, y) + assert metrics["mse"] == 0.0 + assert metrics["r2"] == 1.0 + + def test_mismatched_lengths_raises(self): + evaluator = ModelEvaluator() + with pytest.raises(ValueError): + evaluator.evaluate_classification(np.array([0, 1]), np.array([0, 1, 0])) + + def test_get_results_after_evaluate(self): + evaluator = ModelEvaluator() + y = np.array([0, 1, 1, 0]) + evaluator.evaluate_classification(y, y) + assert evaluator.get_results() is not None + + +# --------------------------------------------------------------------------- +# DataExplorer +# --------------------------------------------------------------------------- + +class TestDataExplorer: + def test_summary_statistics_columns(self): + """Summary statistics must include descriptive stats plus the + library's missing-value profile columns.""" + data = pd.DataFrame( + {"a": [1.0, 2.0, 3.0], "b": [4.0, 5.0, 6.0], "c": ["x", "y", "z"]} + ) + explorer = DataExplorer(data) + summary = explorer.summary_statistics() + for key in ("mean", "std", "missing", "missing_pct"): + assert key in summary.columns + # the categorical column must still be profiled + assert "c" in summary.columns or "c" in summary.index + + def test_missing_value_analysis(self): + """analyze_missing_values must report each column with missing + values together with its percentage, as a column-oriented table.""" + data = pd.DataFrame({"a": [1.0, np.nan, 3.0], "b": [np.nan, np.nan, np.nan]}) + explorer = DataExplorer(data) + missing = explorer.analyze_missing_values() + assert "column" in missing.columns + assert "missing_percentage" in missing.columns + reported = set(missing["column"]) + assert reported == {"a", "b"}, "both columns with NaNs must be reported" + row_b = missing.loc[missing["column"] == "b"].iloc[0] + assert row_b["missing_percentage"] == 100.0 + assert row_b["missing_count"] == 3 + + def test_correlation_analysis(self): + rng = np.random.default_rng(42) + a = rng.standard_normal(50) + data = pd.DataFrame({"a": a, "b": a + rng.standard_normal(50) * 0.1}) + explorer = DataExplorer(data) + corr = explorer.correlation_analysis() + assert corr.loc["a", "b"] > 0.9 + + def test_generate_report_keys(self): + data = pd.DataFrame( + {"a": [1.0, 2.0, 3.0, 4.0], "b": [4.0, 5.0, 6.0, 7.0], "target": [0, 1, 0, 1]} + ) + explorer = DataExplorer(data.drop(columns=["target"])) + report = explorer.generate_report() + assert isinstance(report, dict) + + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +class TestConfig: + def test_save_and_load_roundtrip(self, tmp_path): + config = Config() + config.random_state = 7 + config.preprocessing["scale_numerical"] = "standard" + path = tmp_path / "config.json" + config.save(str(path)) + loaded = Config.load(str(path)) + assert loaded.random_state == 7 + assert loaded.preprocessing["scale_numerical"] == "standard" + + def test_defaults(self): + config = Config() + assert config.random_state == 42 + assert config.preprocessing["handle_missing"] == "smart" + assert config.splitting["test_size"] == 0.2 + + def test_update_scopes_to_dict(self): + config = Config() + config.update(preprocessing={"handle_missing": "drop"}) + assert config.preprocessing["handle_missing"] == "drop" + # unrelated keys are untouched + assert config.random_state == 42 + + +# --------------------------------------------------------------------------- +# helpers: optimize_memory, detect_feature_types, save/load_model +# --------------------------------------------------------------------------- + +class TestUtilsHelpers: + def test_detect_feature_types_classes_columns(self): + data = pd.DataFrame( + { + "num": [1.0, 2.0, 3.0], + "cat": ["a", "b", "a"], + } + ) + types = detect_feature_types(data) + assert "num" in types["numerical"] + assert "cat" in types["categorical"] + + def test_detect_feature_types_datetime_columns(self): + data = pd.DataFrame( + {"ts": pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"])} + ) + types = detect_feature_types(data) + assert "ts" in types["datetime"] + + def test_optimize_memory_preserves_values(self): + data = pd.DataFrame( + { + "small_int": [1, 2, 3], + "big_float": [1.5, 2.5, 3.5], + } + ) + out = optimize_memory(data) + pd.testing.assert_frame_equal(data, out, check_dtype=False) + + def test_save_load_model_roundtrip(self, tmp_path): + from sklearn.tree import DecisionTreeClassifier + + model = DecisionTreeClassifier(random_state=42) + model.fit([[0], [1]], [0, 1]) + path = str(tmp_path / "model.joblib") + save_model(model, path) + loaded = load_model(path) + np.testing.assert_array_equal( + model.predict([[0], [1]]), loaded.predict([[0], [1]]) + ) + + def test_save_model_creates_parent_directories(self, tmp_path): + from sklearn.tree import DecisionTreeClassifier + + model = DecisionTreeClassifier() + path = str(tmp_path / "nested" / "deep" / "model.joblib") + save_model(model, path) + assert os.path.exists(path) diff --git a/tests/test_feature_engineering.py b/tests/test_feature_engineering.py new file mode 100644 index 0000000..bcb99db --- /dev/null +++ b/tests/test_feature_engineering.py @@ -0,0 +1,95 @@ +"""Contract tests for FeatureEngineer.""" + +import pandas as pd +import numpy as np +import pytest + +from mltools.preprocessing import FeatureEngineer + + +@pytest.fixture +def numeric_data(): + """Simple numeric DataFrame suitable for feature engineering.""" + rng = np.random.default_rng(42) + return pd.DataFrame( + { + "a": rng.standard_normal(50), + "b": rng.standard_normal(50), + "c": rng.standard_normal(50), + } + ) + + +class TestFeatureEngineer: + def test_statistical_features_shape(self, numeric_data): + """Statistical features append derived transforms (log, sqrt, square) + per numeric feature while preserving rows.""" + engineer = FeatureEngineer() + original_cols = numeric_data.shape[1] + # NOTE: create_statistical_features mutates the input frame (adds + # derived columns in place), so a fresh frame is used for each check. + out = engineer.create_statistical_features(numeric_data.copy()) + assert out.shape[0] == numeric_data.shape[0] + assert out.shape[1] > original_cols + # each numeric feature gains exactly three derived columns + assert out.shape[1] == original_cols * 4 + + def test_interaction_features_shape(self, numeric_data): + """Interaction features add pairwise combinations of numeric + features while preserving rows.""" + engineer = FeatureEngineer() + out = engineer.create_interaction_features(numeric_data) + assert out.shape[0] == numeric_data.shape[0] + # with only three numeric features the current implementation may + # skip interaction generation; the pinned contract is simply that + # rows are preserved and no crash occurs. + assert out.shape[1] >= numeric_data.shape[1] + + def test_polynomial_features_shape(self, numeric_data): + engineer = FeatureEngineer() + original_cols = numeric_data.shape[1] + out = engineer.create_polynomial_features(numeric_data.copy(), fit=True) + assert out.shape[0] == numeric_data.shape[0] + assert out.shape[1] > original_cols + + def test_fit_transform_row_count_preserved(self, numeric_data): + engineer = FeatureEngineer() + out = engineer.fit_transform( + numeric_data, + enable_polynomial=True, + enable_interaction=True, + enable_statistical=True, + enable_clustering=False, + enable_pca=False, + ) + assert out.shape[0] == numeric_data.shape[0] + assert out.shape[1] > numeric_data.shape[1] + + def test_disable_all_engineering_is_identity(self, numeric_data): + engineer = FeatureEngineer() + out = engineer.fit_transform( + numeric_data.copy(), + enable_polynomial=False, + enable_interaction=False, + enable_statistical=False, + ) + pd.testing.assert_frame_equal(numeric_data, out) + + def test_input_not_mutated(self, numeric_data): + engineer = FeatureEngineer() + snapshot = numeric_data.copy() + engineer.fit_transform(numeric_data, enable_polynomial=True) + pd.testing.assert_frame_equal(numeric_data, snapshot) + + def test_pca_preserves_row_count(self, numeric_data): + """PCA features must keep the same number of rows as the input.""" + engineer = FeatureEngineer() + out = engineer.create_pca_features(numeric_data, fit=True) + assert out.shape[0] == numeric_data.shape[0] + assert out.shape[1] >= 1 + + def test_single_column_statistical_features(self): + """A single numeric column must still produce statistical features.""" + df = pd.DataFrame({"a": [1.0, 2.0, 3.0, 4.0, 5.0]}) + out = FeatureEngineer().create_statistical_features(df) + assert out.shape[1] > 1 diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..dfeca5c --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,142 @@ +"""Contract tests for Classifier and ClusteringSystem.""" + +import pandas as pd +import numpy as np +import pytest +from sklearn.datasets import make_classification, make_blobs + +from mltools import Classifier, ClusteringSystem, Config + + +@pytest.fixture +def classification_data(): + X, y = make_classification( + n_samples=120, + n_features=8, + n_informative=6, + n_redundant=2, + n_classes=2, + random_state=42, + ) + return pd.DataFrame(X), pd.Series(y) + + +@pytest.fixture +def fast_config(): + config = Config() + config.random_state = 42 + config.n_jobs = 1 + config.modeling["cv"] = 2 + return config + + +# --------------------------------------------------------------------------- +# Classifier +# --------------------------------------------------------------------------- + +class TestClassifier: + def test_predict_before_fit_raises(self): + classifier = Classifier() + with pytest.raises(ValueError, match="Call fit"): + classifier.predict(pd.DataFrame({"a": [1.0]})) + + def test_predict_proba_before_fit_raises(self): + classifier = Classifier() + with pytest.raises(ValueError, match="Call fit"): + classifier.predict_proba(pd.DataFrame({"a": [1.0]})) + + def test_fit_and_predict_shape(self, classification_data, fast_config): + X, y = classification_data + classifier = Classifier(config=fast_config) + classifier.fit( + X, y, + models=["LogisticRegression", "DecisionTree"], + tune_hyperparameters=False, + ) + preds = classifier.predict(X) + assert len(preds) == len(y) + assert set(preds).issubset(set(y.unique())) + + def test_fit_records_results_for_each_model(self, classification_data, fast_config): + X, y = classification_data + classifier = Classifier(config=fast_config) + classifier.fit(X, y, models=["LogisticRegression"], tune_hyperparameters=False) + results = classifier.get_results() + assert "LogisticRegression" in results + assert "cv_score_mean" in results["LogisticRegression"] + assert 0.0 <= results["LogisticRegression"]["cv_score_mean"] <= 1.0 + + def test_best_model_selected_after_fit(self, classification_data, fast_config): + X, y = classification_data + classifier = Classifier(config=fast_config) + classifier.fit(X, y, models=["LogisticRegression"], tune_hyperparameters=False) + name, model = classifier.get_best_model() + assert name == "LogisticRegression" + assert model is not None + + def test_unknown_model_names_are_skipped(self, classification_data, fast_config): + X, y = classification_data + classifier = Classifier(config=fast_config) + classifier.fit( + X, + y, + models=["NotAModel", "LogisticRegression"], + tune_hyperparameters=False, + ) + results = classifier.get_results() + assert "NotAModel" not in results + assert "LogisticRegression" in results + + def test_predict_proba_shape(self, classification_data, fast_config): + X, y = classification_data + classifier = Classifier(config=fast_config) + classifier.fit(X, y, models=["LogisticRegression"], tune_hyperparameters=False) + proba = classifier.predict_proba(X) + assert proba.shape[0] == len(y) + assert proba.shape[1] == 2 + np.testing.assert_allclose(proba.sum(axis=1), 1.0, atol=1e-10) + + +# --------------------------------------------------------------------------- +# ClusteringSystem +# --------------------------------------------------------------------------- + +class TestClusteringSystem: + def test_fit_and_predict_shape(self, fast_config): + X, _ = make_blobs(n_samples=100, centers=3, random_state=42) + system = ClusteringSystem(config=fast_config) + system.fit(X, algorithms=["KMeans"], n_clusters_range=range(2, 4)) + labels = system.predict(pd.DataFrame(X)) + assert len(labels) == 100 + + def test_results_record_best_model(self, fast_config): + X, _ = make_blobs(n_samples=100, centers=3, random_state=42) + system = ClusteringSystem(config=fast_config) + system.fit(X, algorithms=["KMeans"], n_clusters_range=range(2, 4)) + results = system.get_results() + assert isinstance(results, dict) + assert any("KMeans" in k for k in results), ( + "at least one KMeans model must be recorded" + ) + name, model = system.get_best_model() + assert name is not None + assert model is not None + # recorded model names carry the tested cluster count + assert "k3" in name + + def test_predict_before_fit_raises(self, fast_config): + system = ClusteringSystem(config=fast_config) + with pytest.raises(Exception): + system.predict(pd.DataFrame(np.random.randn(10, 2))) + + def test_small_dataset_cluster_range_guard(self): + """For datasets too small to produce a valid cluster range the + system must not crash with an opaque error downstream.""" + system = ClusteringSystem(config=fast_config) + X, _ = make_blobs(n_samples=15, centers=2, random_state=42) + # The default n_clusters_range derivation (range(2, len(X)//10)) + # produces an empty range for small datasets; the system must + # fail loudly with a clear ValueError instead of silently + # producing an unfitted object. + with pytest.raises(ValueError): + system.fit(X, algorithms=["KMeans"]) diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py new file mode 100644 index 0000000..70cf7d9 --- /dev/null +++ b/tests/test_preprocessing.py @@ -0,0 +1,243 @@ +"""Contract tests for the preprocessing API surface. + +These tests pin down the behavioral contracts of DataProcessor, the +missing-value helper, and AdaptiveScaler so future changes cannot +silently break existing callers. +""" + +import pandas as pd +import numpy as np +import pytest +from sklearn.datasets import make_classification + +from mltools import DataProcessor, Config +from mltools.preprocessing import FeatureEngineer, AdaptiveScaler +from mltools.utils.helpers import handle_missing_values + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def classification_data(): + """Small binary classification DataFrame.""" + X, y = make_classification( + n_samples=200, + n_features=8, + n_informative=6, + n_redundant=2, + n_classes=2, + random_state=42, + ) + data = pd.DataFrame(X, columns=[f"feature_{i}" for i in range(X.shape[1])]) + data["target"] = y + return data + + +@pytest.fixture +def small_config(): + """Config tuned for fast, deterministic tests.""" + config = Config() + config.random_state = 42 + config.n_jobs = 1 + config.modeling["cv"] = 2 + config.preprocessing["remove_outliers"] = "none" + return config + + +# --------------------------------------------------------------------------- +# DataProcessor: loading and shape contracts +# --------------------------------------------------------------------------- + +class TestDataProcessorLoading: + def test_load_dataframe_sets_data(self, classification_data): + processor = DataProcessor(data=classification_data, target_column="target") + assert processor.data.shape == classification_data.shape + assert processor.data is not classification_data, "must work on a copy" + + def test_load_nonexistent_csv_raises_file_not_found(self, tmp_path): + processor = DataProcessor() + missing = tmp_path / "does_not_exist.csv" + with pytest.raises(FileNotFoundError, match="File not found"): + processor.load_data(str(missing)) + + def test_load_csv_roundtrip(self, classification_data, tmp_path): + path = tmp_path / "data.csv" + classification_data.to_csv(path, index=False) + processor = DataProcessor(data=str(path), target_column="target") + assert processor.data.shape[0] == len(classification_data) + + def test_load_unsupported_extension_falls_back_to_csv(self, tmp_path): + """A file with an unknown extension must not blow up: the loader + falls back to pd.read_csv (with the standard header-on-first-row + behavior).""" + path = tmp_path / "data.txt" + data = pd.DataFrame( + {"feature_0": [1.0, 2.0, 3.0], "target": [0, 1, 0]} + ) + data.to_csv(path, index=False) + processor = DataProcessor(data=str(path), target_column="target") + assert processor.data.shape[0] == 3 + + +# --------------------------------------------------------------------------- +# DataProcessor: split contracts +# --------------------------------------------------------------------------- + +class TestDataProcessorSplit: + def test_split_without_target_raises(self): + processor = DataProcessor(data=pd.DataFrame({"a": [1, 2, 3]})) + with pytest.raises(ValueError, match="target_column must be set"): + processor.split_data() + + def test_split_with_missing_target_raises(self, classification_data): + processor = DataProcessor(data=classification_data, target_column="nonexistent") + with pytest.raises(ValueError, match="not in data"): + processor.split_data() + + def test_get_split_data_before_split_raises(self, classification_data, small_config): + processor = DataProcessor( + data=classification_data, target_column="target", config=small_config + ) + processor.preprocess() + with pytest.raises(ValueError, match="Call split_data"): + processor.get_split_data() + + def test_split_partition_sizes(self, classification_data, small_config): + processor = DataProcessor( + data=classification_data, target_column="target", config=small_config + ) + processor.preprocess() + X_train, X_test, y_train, y_test = processor.split_data(test_size=0.3) + assert len(X_train) + len(X_test) == len(classification_data) + assert len(y_train) == len(X_train) + assert "target" not in X_train.columns + assert X_test.columns.tolist() == X_train.columns.tolist() + + def test_split_respects_test_size(self, classification_data, small_config): + processor = DataProcessor( + data=classification_data, target_column="target", config=small_config + ) + processor.preprocess() + _, X_test, _, _ = processor.split_data(test_size=0.25) + assert len(X_test) == int(0.25 * len(classification_data)) + 1 or len( + X_test + ) == int(0.25 * len(classification_data)) + + +# --------------------------------------------------------------------------- +# DataProcessor: preprocessing pipeline contracts +# --------------------------------------------------------------------------- + +class TestDataProcessorPipeline: + def test_preprocess_removes_missing_values(self, small_config): + data = pd.DataFrame( + { + "numeric": [1.0, np.nan, 3.0, 4.0, np.nan], + "category": ["a", "b", np.nan, "a", "b"], + "target": [0, 1, 0, 1, 0], + } + ) + processor = DataProcessor(data=data, target_column="target", config=small_config) + processor.preprocess() + assert processor.data.isnull().sum().sum() == 0 + + def test_preprocess_encodes_categorical_features(self, small_config): + data = pd.DataFrame( + { + "num": [1.0, 2.0, 3.0, 4.0] * 10, + "cat": ["a", "b", "a", "b"] * 10, + "target": [0, 1, 0, 1] * 10, + } + ) + processor = DataProcessor(data=data, target_column="target", config=small_config) + processor.preprocess() + assert "cat" not in processor.data.columns + assert processor.data.shape[1] > 2 # one-hot columns added + + def test_scales_only_numerical_non_target_columns(self, small_config): + data = pd.DataFrame( + { + "num_a": [10.0, 20.0, 30.0, 40.0] * 10, + "num_b": [1.0, 2.0, 3.0, 4.0] * 10, + "target": [0, 1, 0, 1] * 10, + } + ) + processor = DataProcessor(data=data, target_column="target", config=small_config) + processor.preprocess() + scaled = processor.data[["num_a", "num_b"]] + np.testing.assert_allclose(scaled.mean().values, 0.0, atol=1e-10) + + def test_outlier_clipping_default_config(self, small_config): + """With the default 'smart' outlier handling, extreme values are + clipped to the IQR fence rather than dropped.""" + small_config.preprocessing["remove_outliers"] = "smart" + data = pd.DataFrame( + { + "num": [1.0] * 50 + [1000.0] * 4, + "target": [0, 1] * 27, + } + ) + processor = DataProcessor(data=data, target_column="target", config=small_config) + processor.preprocess() + assert processor.data["num"].max() < 1000.0 + + def test_row_count_preserved_through_pipeline(self, classification_data, small_config): + processor = DataProcessor( + data=classification_data, target_column="target", config=small_config + ) + original_rows = len(classification_data) + processor.preprocess() + processor.split_data() + assert len(processor.X_train) + len(processor.X_test) == original_rows + + +# --------------------------------------------------------------------------- +# handle_missing_values helper contracts +# --------------------------------------------------------------------------- + +class TestHandleMissingValues: + def test_numeric_columns_filled_with_median(self): + df = pd.DataFrame({"a": [1.0, np.nan, 3.0, 4.0]}) + out = handle_missing_values(df, threshold=0.8) + assert out.isnull().sum().sum() == 0 + # median of the non-null values [1, 3, 4] is 3.0 + assert out["a"].iloc[1] == 3.0 + + def test_categorical_columns_filled_with_mode(self): + df = pd.DataFrame({"c": ["a", "b", np.nan, "a"]}) + out = handle_missing_values(df, threshold=0.8) + assert out.isnull().sum().sum() == 0 + assert set(out["c"].unique()) == {"a", "b"} + + def test_columns_above_threshold_are_dropped(self): + df = pd.DataFrame( + {"keep": [1.0, 2.0, 3.0, 4.0], "drop": [np.nan, np.nan, np.nan, 1.0]} + ) + out = handle_missing_values(df, threshold=0.5) + assert "drop" not in out.columns + assert "keep" in out.columns + + def test_custom_threshold(self): + df = pd.DataFrame( + {"a": [1.0, np.nan, 3.0, 4.0], "b": [np.nan, np.nan, np.nan, 1.0]} + ) + out = handle_missing_values(df, threshold=0.2) + # Both columns exceed the 20% missing threshold (25% and 75%), + # so both must be dropped. + assert "b" not in out.columns + assert "a" not in out.columns + + def test_no_missing_values_is_a_noop(self): + df = pd.DataFrame({"a": [1.0, 2.0], "b": ["x", "y"]}) + out = handle_missing_values(df, threshold=0.8) + pd.testing.assert_frame_equal(df, out) + + def test_strategy_string_accepted_without_raising(self): + """The helper accepts the documented strategies without crashing. + This pins the current behavior of not rejecting unknown strings.""" + df = pd.DataFrame({"a": [1.0, np.nan]}) + for strategy in ["smart", "drop"]: + out = handle_missing_values(df, strategy=strategy, threshold=0.8) + assert out is not None