Kreb1907's picture
Upload folder using huggingface_hub
176e552 verified
Raw
History Blame Contribute Delete
6.31 kB
"""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},
}