fraud-detection-mlops-api / tests /test_preprocessing.py
github-actions[bot]
deploy: sync snapshot from github
4937cba
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from src.preprocessing import (
SCALE_COLUMNS,
build_preprocessor,
handle_imbalance,
load_preprocessor,
preprocess_for_training,
save_preprocessor,
scale_features,
split_data,
transform_features,
)
@pytest.fixture
def sample_df() -> pd.DataFrame:
rng = np.random.default_rng(42)
rows = 200
fraud_count = 20
data: dict[str, np.ndarray] = {
"Time": rng.normal(loc=5000, scale=1000, size=rows),
"Amount": rng.normal(loc=120, scale=50, size=rows),
}
for i in range(1, 29):
data[f"V{i}"] = rng.normal(size=rows)
target = np.array([0] * (rows - fraud_count) + [1] * fraud_count)
rng.shuffle(target)
data["Class"] = target
return pd.DataFrame(data)
def test_split_data_is_stratified(sample_df: pd.DataFrame) -> None:
X_train, X_test, y_train, y_test = split_data(sample_df, test_size=0.2, random_state=42)
base_ratio = sample_df["Class"].mean()
train_ratio = y_train.mean()
test_ratio = y_test.mean()
assert X_train.shape[0] == 160
assert X_test.shape[0] == 40
assert abs(train_ratio - base_ratio) < 0.02
assert abs(test_ratio - base_ratio) < 0.02
def test_scale_features_transforms_only_selected_columns(sample_df: pd.DataFrame) -> None:
features = sample_df.drop(columns=["Class"])
scaled, scaler = scale_features(features)
assert scaler is not None
for column in SCALE_COLUMNS:
assert abs(float(scaled[column].mean())) < 1e-6
assert np.allclose(features["V1"].values, scaled["V1"].values)
def test_handle_imbalance_smote_increases_minority_class(sample_df: pd.DataFrame) -> None:
X_train, _, y_train, _ = split_data(sample_df, test_size=0.2, random_state=42)
preprocessor = build_preprocessor(X_train.columns.tolist())
preprocessor.fit(X_train)
X_train_t = transform_features(preprocessor, X_train)
base_counts = y_train.value_counts().to_dict()
X_balanced, y_balanced, metadata = handle_imbalance(
X_train_t, y_train, method="smote", sampling_strategy=0.8
)
balanced_counts = y_balanced.value_counts().to_dict()
assert metadata["method"] == "smote"
assert balanced_counts[1] > base_counts[1]
assert X_balanced.shape[0] == y_balanced.shape[0]
def test_preprocessor_save_load_roundtrip(sample_df: pd.DataFrame, tmp_path) -> None:
X_train, _, _, _ = split_data(sample_df, test_size=0.2, random_state=42)
preprocessor = build_preprocessor(X_train.columns.tolist())
preprocessor.fit(X_train)
path = tmp_path / "preprocessor.pkl"
save_preprocessor(preprocessor, path)
loaded = load_preprocessor(path)
transformed = transform_features(loaded, X_train.head(5))
assert list(transformed.columns) == X_train.columns.tolist()
assert transformed.shape == (5, X_train.shape[1])
def test_preprocess_for_training_creates_artifact(sample_df: pd.DataFrame, tmp_path) -> None:
artifact = tmp_path / "preprocessor.pkl"
result = preprocess_for_training(
sample_df,
test_size=0.2,
random_state=42,
imbalance_method="class_weight",
preprocessor_path=artifact,
)
assert artifact.exists()
assert result["X_train"].shape[1] == 30
assert result["X_test"].shape[1] == 30
assert result["imbalance_metadata"]["method"] == "class_weight"
assert result["imbalance_metadata"]["class_weight"] is not None
def test_handle_imbalance_rejects_unknown_method(sample_df: pd.DataFrame) -> None:
X_train, _, y_train, _ = split_data(sample_df)
preprocessor = build_preprocessor(X_train.columns.tolist())
preprocessor.fit(X_train)
X_train_t = transform_features(preprocessor, X_train)
with pytest.raises(ValueError):
handle_imbalance(X_train_t, y_train, method="unknown")