"""Fraud scoring service: model + SHAP explainability (EXPL-01..04). `ModelBundle` is loaded exactly once (at FastAPI startup, via `load_model_bundle`) and reused for every request -- never re-loaded per-request (EXPL-04). It wraps: - the trained `imblearn.pipeline.Pipeline` saved by `training/train.py` (its resampling step is a no-op at predict time -- imblearn pipelines only resample during `.fit()`) - a `shap.TreeExplainer` built directly against the pipeline's underlying XGBoost estimator - the training-time metadata (`model_v1.meta.json`): feature column order, model/imbalance-strategy identifiers, and the threshold chosen at MODEL-05/06 `score_transaction` is the single entry point used by every route that scores a transaction (`/predict`, `/transactions/batch`, `/test/transactions`) so real and simulated traffic always go through identical logic (EXPL-03) -- there is no hard-coded bypass for any transaction `type`. """ from __future__ import annotations import json from dataclasses import dataclass from pathlib import Path from typing import Any, Sequence import joblib import numpy as np import pandas as pd import shap from sqlalchemy import or_, select from sqlalchemy.orm import Session from app.features import FEATURE_COLUMNS, engineer_features TOP_N_FEATURES = 5 RAW_PAYSIM_COLUMNS = [ "step", "type", "amount", "nameOrig", "oldbalanceOrg", "newbalanceOrig", "nameDest", "oldbalanceDest", "newbalanceDest", ] @dataclass(frozen=True) class ModelBundle: pipeline: Any explainer: shap.TreeExplainer feature_columns: list[str] model_version: str model_name: str def load_model_bundle(model_path: str) -> ModelBundle: """Load the joblib artifact + its metadata sidecar exactly once. Raises `FileNotFoundError`/`ValueError` with a clear message if the artifact or its `.meta.json` sidecar is missing -- fail loud at startup rather than serve requests against a model that never loaded. """ artifact_path = Path(model_path) if not artifact_path.exists(): raise FileNotFoundError( f"MODEL_PATH '{model_path}' does not exist. Run " "`python -m training.train finalize ...` to produce it." ) meta_path = artifact_path.with_suffix("").with_suffix(".meta.json") if not meta_path.exists(): raise FileNotFoundError( f"Model metadata sidecar '{meta_path}' not found next to " f"'{model_path}' -- both are required (training-time versions " "and feature column order must be known before serving)." ) pipeline = joblib.load(artifact_path) meta = json.loads(meta_path.read_text()) feature_columns = meta["feature_columns"] clf = pipeline.named_steps["clf"] explainer = shap.TreeExplainer(clf) return ModelBundle( pipeline=pipeline, explainer=explainer, feature_columns=feature_columns, model_version=meta_path.stem.replace(".meta", ""), model_name=meta["model_name"], ) def risk_tier(probability: float) -> str: """Fixed low/medium/high display bands, independent of the alert threshold: MODEL-04's candidate thresholds (0.3/0.5/0.7) form a natural three-way split so this exactly matches metrics already reviewed with the user. Whether an alert fires is a *separate* decision governed by `FRAUD_ALERT_THRESHOLD` -- risk tier is purely informational. """ if probability >= 0.7: return "high" if probability >= 0.3: return "medium" return "low" def _history_rows_to_frame(rows: Sequence[Any]) -> pd.DataFrame: return pd.DataFrame( [ { "step": r.step, "type": r.type, "amount": float(r.amount), "nameOrig": r.name_orig, "oldbalanceOrg": float(r.oldbalance_org), "newbalanceOrig": float(r.newbalance_orig), "nameDest": r.name_dest, "oldbalanceDest": float(r.oldbalance_dest), "newbalanceDest": float(r.newbalance_dest), } for r in rows ], columns=RAW_PAYSIM_COLUMNS, ) def fetch_account_history( db: Session, history_model: type, name_orig: str, name_dest: str ) -> pd.DataFrame: """Prior transactions touching either account, oldest first. `app.features.engineer_features`'s velocity features are prior-only aggregates, so this history (everything *before* the new transaction) is exactly what's needed to compute them correctly for a single real-time transaction -- the same shared feature module used by training, never a re-implementation of its formulas (FEAT-01). """ stmt = ( select(history_model) .where( or_( history_model.name_orig == name_orig, history_model.name_dest == name_dest, ) ) .order_by(history_model.step) ) rows = db.execute(stmt).scalars().all() return _history_rows_to_frame(rows) _NUMERIC_COLUMNS = [ "step", "amount", "oldbalanceOrg", "newbalanceOrig", "oldbalanceDest", "newbalanceDest", ] def build_feature_row(history: pd.DataFrame, raw_transaction: dict) -> pd.DataFrame: """Append the new transaction to its account history and engineer features, returning only the new transaction's feature row. """ new_row = pd.DataFrame([raw_transaction], columns=RAW_PAYSIM_COLUMNS) combined = pd.concat([history, new_row], ignore_index=True) # An empty (no-history) frame defaults numeric columns to object dtype, # which breaks the groupby cumsum/cumcount inside engineer_features # once concatenated -- coerce explicitly rather than rely on pandas to # infer dtypes across an empty-plus-one-row concat. combined[_NUMERIC_COLUMNS] = combined[_NUMERIC_COLUMNS].apply( pd.to_numeric, errors="raise" ) engineered = engineer_features(combined) return engineered.iloc[[-1]] def score_transaction( bundle: ModelBundle, feature_row: pd.DataFrame ) -> dict[str, Any]: """Score a single already-feature-engineered transaction row. Returns probability, risk tier, and the top-N SHAP features (by descending absolute SHAP value) as a list of `{"feature": str, "shap_value": float}` dicts -- the exact shape stored in `fraud_predictions.top_features` / `test_predictions.top_features`. """ X = feature_row[bundle.feature_columns].astype("float32") probability = float(bundle.pipeline.predict_proba(X)[0, 1]) shap_values = bundle.explainer.shap_values(X) shap_row = np.asarray(shap_values)[0] order = np.argsort(-np.abs(shap_row))[:TOP_N_FEATURES] top_features = [ {"feature": bundle.feature_columns[i], "shap_value": float(shap_row[i])} for i in order ] return { "probability": probability, "risk_tier": risk_tier(probability), "top_features": top_features, "model_version": bundle.model_version, }