loanguard / scripts /smoke_test.py
adwitiyashukla's picture
update repo
44cd54e
Raw
History Blame Contribute Delete
7.95 kB
"""End-to-end smoke test.
Runs the entire pipeline on a small synthetic dataset and verifies:
1. Data loads (synthetic fallback works)
2. Labels build
3. Split works
4. FeatureBuilder fits and transforms consistently
5. Each model fits and scores
6. Ensemble stacks and calibrates
7. Metrics compute
8. Artifacts save & load
9. ScoringService produces a decision
If this exits 0, you are safe to commit. Run with:
python scripts/smoke_test.py
"""
from __future__ import annotations
import sys
import tempfile
import traceback
from pathlib import Path
# Add repo root to path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
def step(name: str):
print(f"\n=== {name} ===", flush=True)
def fail(msg: str, exc: Exception | None = None) -> None:
print(f"\n❌ FAIL: {msg}")
if exc is not None:
traceback.print_exc()
sys.exit(1)
def main() -> None:
from src.data import LendingClubLoader, build_fraud_labels, LabelConfig
from src.data.splitter import time_based_split, stratified_split
from src.features import FeatureBuilder
from src.models import (
XGBFraudModel,
LGBMFraudModel,
IsolationForestFraudModel,
StackingFraudEnsemble,
)
from src.evaluation import binary_classification_metrics
from src.evaluation.business import cost_sensitive_evaluation, CostMatrix
from src.utils.io import save_joblib, load_joblib
from src.api.service import ScoringService
from src.api.schemas import LoanApplication
from datetime import date
# 1. Load synthetic data
step("1. Load synthetic data")
try:
df = LendingClubLoader._synthetic(n=3000, seed=42)
assert len(df) == 3000
print(f" loaded {len(df):,} rows, {df.shape[1]} cols")
except Exception as e:
fail("Synthetic loader broke", e)
# 2. Build fraud labels
step("2. Build fraud labels")
try:
df = build_fraud_labels(df, LabelConfig())
rate = df["is_fraud"].mean()
print(f" fraud rate: {rate:.2%}")
assert 0.01 < rate < 0.6, f"Fraud rate {rate:.2%} outside expected range"
except Exception as e:
fail("Label builder broke", e)
# 3. Split
step("3. Time-based split")
try:
train, val, test = stratified_split(df, "is_fraud", 0.15, 0.15, random_seed=1)
print(f" train={len(train)} val={len(val)} test={len(test)}")
except Exception as e:
fail("Splitter broke", e)
drop = ["is_fraud", "rule_fpd", "rule_income_anomaly", "rule_debt_inconsist",
"rule_address_ring", "n_anomalies", "loan_status", "last_pymnt_d", "id"]
y_train, y_val, y_test = train["is_fraud"], val["is_fraud"], test["is_fraud"]
X_train_raw = train.drop(columns=drop)
X_val_raw = val.drop(columns=drop)
X_test_raw = test.drop(columns=drop)
# 4. Feature builder
step("4. FeatureBuilder fit+transform")
try:
builder = FeatureBuilder(use_velocity=True, use_graph=True)
X_train = builder.fit_transform(X_train_raw, y_train)
X_val = builder.transform(X_val_raw)
X_test = builder.transform(X_test_raw)
print(f" produced {X_train.shape[1]} features")
assert X_train.shape[1] == X_val.shape[1] == X_test.shape[1]
assert not X_train.isna().any().any(), "NaNs in transformed training set"
except Exception as e:
fail("FeatureBuilder broke", e)
# 5. Train each model
step("5. Train base models")
try:
xgb = XGBFraudModel(params={"n_estimators": 50, "max_depth": 4}).fit(
X_train, y_train, eval_set=[(X_val, y_val)]
)
lgb = LGBMFraudModel(params={"n_estimators": 50, "num_leaves": 15}).fit(
X_train, y_train, eval_set=[(X_val, y_val)]
)
iso = IsolationForestFraudModel(params={"n_estimators": 50}).fit(X_train)
print(f" XGB AUC = {binary_classification_metrics(y_test, xgb.predict_proba(X_test))['roc_auc']:.3f}")
print(f" LGB AUC = {binary_classification_metrics(y_test, lgb.predict_proba(X_test))['roc_auc']:.3f}")
print(f" IF AUC = {binary_classification_metrics(y_test, iso.predict_proba(X_test))['roc_auc']:.3f}")
except Exception as e:
fail("Model training broke", e)
# 6. Stacking ensemble
step("6. Stacking ensemble")
try:
ens = StackingFraudEnsemble(
base_models=[xgb, lgb, iso],
n_folds=3,
calibration="isotonic",
).fit(X_train, y_train, X_val=X_val, y_val=y_val)
proba = ens.predict_proba(X_test)
metrics = binary_classification_metrics(y_test, proba)
print(f" ensemble: AUC={metrics['roc_auc']:.3f}, "
f"PR-AUC={metrics['pr_auc']:.3f}, KS={metrics['ks']:.3f}")
assert 0 <= proba.min() and proba.max() <= 1
except Exception as e:
fail("Ensemble broke", e)
# 7. Cost sweep
step("7. Cost-sensitive evaluation")
try:
cost_df = cost_sensitive_evaluation(y_test, proba, CostMatrix())
best = cost_df.loc[cost_df["total_cost"].idxmin()]
print(f" optimal threshold = {best['threshold']:.2f}, "
f"cost/applicant = ${best['cost_per_applicant']:.2f}")
except Exception as e:
fail("Cost evaluation broke", e)
# 8. Save + reload artifacts
step("8. Save & load artifacts")
try:
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
save_joblib(builder, tmp_path / "feature_builder.joblib")
save_joblib(ens, tmp_path / "model_ensemble.joblib")
builder2 = load_joblib(tmp_path / "feature_builder.joblib")
ens2 = load_joblib(tmp_path / "model_ensemble.joblib")
import numpy as np
# (a) Builder roundtrip - re-loaded builder transforms the SAME
# full raw frame to identical features (velocity/graph are
# batch-context dependent, so we must transform the same input).
Xb1 = builder.transform(X_test_raw)
Xb2 = builder2.transform(X_test_raw)
assert np.allclose(Xb1.values, Xb2.values, atol=1e-6), (
"Re-loaded FeatureBuilder produces different features"
)
# (b) Model roundtrip - re-loaded ensemble scores identically on
# identical features.
p1 = ens.predict_proba(X_test)
p2 = ens2.predict_proba(X_test)
assert np.allclose(p1, p2, atol=1e-6), "Re-loaded model produces different scores"
print(" serialization roundtrip OK")
# 9. Scoring service
step("9. ScoringService end-to-end")
svc = ScoringService(artifacts_dir=tmp_path)
svc.load()
assert svc.is_ready
app = LoanApplication(
loan_amnt=15000,
term=36,
int_rate=13.5,
installment=509.0,
grade="B",
sub_grade="B3",
emp_length=4.0,
home_ownership="RENT",
annual_inc=60000,
verification_status="Verified",
purpose="debt_consolidation",
zip_code="941xx",
addr_state="CA",
dti=18.0,
revol_util=45.0,
revol_bal=5000,
open_acc=8,
total_acc=15,
issue_d=date.today(),
)
result = svc.score_one(app)
print(f" score = {result.fraud_score:.4f}, decision = {result.decision}")
assert 0 <= result.fraud_score <= 1
assert result.decision in ("APPROVE", "REVIEW", "DECLINE")
except Exception as e:
fail("Serialization or scoring service broke", e)
print("\n✅ ALL SMOKE CHECKS PASSED - safe to commit.\n")
if __name__ == "__main__":
main()