Spaces:
Sleeping
Sleeping
| """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}, | |
| } | |