"""Synthetic fraud data, XGBoost training and exact TreeSHAP contributions. SHAP values are computed with XGBoost's built-in TreeSHAP (``pred_contribs=True``) — the same exact algorithm ``shap.TreeExplainer`` uses for XGBoost models, without the extra dependency. """ import numpy as np import pandas as pd import xgboost as xgb from sklearn.metrics import roc_auc_score from sklearn.model_selection import train_test_split SEED = 42 # (column, UI label, unit formatter) FEATURES = [ ("amount", "Transaction amount", lambda v: f"${v:,.0f}"), ("hour", "Hour of day", lambda v: f"{int(v):02d}:00"), ("is_online", "Online (card-not-present)", lambda v: "yes" if v else "no"), ("is_foreign", "Foreign country", lambda v: "yes" if v else "no"), ("distance_km", "Distance from home", lambda v: f"{v:,.0f} km"), ("amount_over_avg", "Amount vs customer average", lambda v: f"{v:.1f}×"), ("txn_last_24h", "Transactions in last 24 h", lambda v: f"{int(v)}"), ("failed_auth_last_24h", "Failed auth attempts (24 h)", lambda v: f"{int(v)}"), ("new_merchant", "First time at this merchant", lambda v: "yes" if v else "no"), ("account_age_days", "Account age", lambda v: f"{int(v)} days"), ("merchant_risk", "Merchant category risk", lambda v: f"{v:.2f}"), ] FEATURE_COLS = [f[0] for f in FEATURES] FEATURE_LABELS = {f[0]: f[1] for f in FEATURES} FEATURE_FMT = {f[0]: f[2] for f in FEATURES} def generate_data(n: int = 80_000, seed: int = SEED) -> pd.DataFrame: """Simulate card transactions with a plausible fraud-generating process. A small "attack" subpopulation (~3.5%) has fat-tailed velocity, failed auths and risky merchants, so the model has training support in the regions the UI sliders can reach. """ rng = np.random.default_rng(seed) attack = rng.binomial(1, 0.035, size=n).astype(bool) amount = np.round(rng.lognormal(mean=3.9, sigma=1.1, size=n), 2) hour = rng.integers(0, 24, size=n) hour[attack] = np.where( rng.random(attack.sum()) < 0.5, rng.integers(0, 6, size=attack.sum()), rng.integers(0, 24, size=attack.sum()), ) is_online = rng.binomial(1, 0.45, size=n) is_online[attack] = rng.binomial(1, 0.85, size=attack.sum()) is_foreign = rng.binomial(1, 0.06, size=n) is_foreign[attack] = rng.binomial(1, 0.25, size=attack.sum()) distance_km = np.where( is_foreign == 1, rng.uniform(500, 9000, size=n), rng.exponential(scale=25, size=n), ) amount_over_avg = np.clip(rng.lognormal(mean=0.0, sigma=0.6, size=n), 0.05, 60) amount_over_avg[attack] = np.clip( rng.lognormal(mean=0.8, sigma=0.9, size=attack.sum()), 0.05, 60 ) txn_last_24h = rng.poisson(lam=2.2, size=n) txn_last_24h[attack] = rng.poisson(lam=10, size=attack.sum()) failed_auth = rng.binomial(6, 0.03, size=n) failed_auth[attack] = rng.binomial(8, 0.22, size=attack.sum()) new_merchant = rng.binomial(1, 0.25, size=n) new_merchant[attack] = rng.binomial(1, 0.7, size=attack.sum()) account_age = rng.integers(5, 3650, size=n) account_age[attack] = np.where( rng.random(attack.sum()) < 0.4, rng.integers(5, 90, size=attack.sum()), rng.integers(5, 3650, size=attack.sum()), ) merchant_risk = np.clip(rng.beta(1.6, 5.0, size=n), 0, 1) merchant_risk[attack] = np.clip(rng.beta(3.5, 2.0, size=attack.sum()), 0, 1) night = ((hour >= 0) & (hour <= 5)).astype(float) young_account = (account_age < 90).astype(float) # Ground-truth log-odds of fraud (with interactions), then Bernoulli labels. # Fraud risk is a function of the features only — the attack flag just # shapes where the feature mass sits. logit = ( -6.2 + 1.2 * is_online + 1.7 * is_foreign + 1.0 * night * is_online + 0.8 * np.log1p(np.maximum(amount_over_avg - 1, 0)) + 0.22 * np.maximum(txn_last_24h - 5, 0) + 0.85 * failed_auth + 0.6 * new_merchant + 1.3 * young_account + 2.6 * merchant_risk + 0.6 * merchant_risk * np.log1p(amount) / 5 + 0.00015 * distance_km + rng.normal(0, 0.25, size=n) ) fraud = rng.binomial(1, 1 / (1 + np.exp(-logit))) return pd.DataFrame( { "amount": amount, "hour": hour, "is_online": is_online, "is_foreign": is_foreign, "distance_km": np.round(distance_km, 1), "amount_over_avg": np.round(amount_over_avg, 2), "txn_last_24h": txn_last_24h, "failed_auth_last_24h": failed_auth, "new_merchant": new_merchant, "account_age_days": account_age, "merchant_risk": np.round(merchant_risk, 3), "fraud": fraud, } ) class FraudModel: def __init__(self): df = generate_data() X = df[FEATURE_COLS] y = df["fraud"] X_tr, X_te, y_tr, y_te = train_test_split( X, y, test_size=0.2, random_state=SEED, stratify=y ) self.clf = xgb.XGBClassifier( n_estimators=300, max_depth=4, learning_rate=0.08, subsample=0.9, colsample_bytree=0.8, eval_metric="auc", random_state=SEED, n_jobs=-1, ) self.clf.fit(X_tr, y_tr) self.auc = roc_auc_score(y_te, self.clf.predict_proba(X_te)[:, 1]) self.fraud_rate = float(y.mean()) self.n_train = len(X_tr) def score(self, tx: dict) -> dict: """Score one transaction: probability + exact TreeSHAP contributions.""" row = pd.DataFrame([{c: tx[c] for c in FEATURE_COLS}]) proba = float(self.clf.predict_proba(row)[0, 1]) dmat = xgb.DMatrix(row, feature_names=FEATURE_COLS) contribs = self.clf.get_booster().predict(dmat, pred_contribs=True)[0] shap_values = dict(zip(FEATURE_COLS, contribs[:-1].tolist())) base_value = float(contribs[-1]) # bias term, log-odds space return { "probability": proba, "margin": base_value + sum(shap_values.values()), "base_value": base_value, "shap_values": shap_values, "inputs": {c: tx[c] for c in FEATURE_COLS}, }