← Back to list

Can Attention Beat XGBoost on Fraud Detection?

Tags: Transformer · TabTransformer · FT-Transformer · XGBoost · LSTM · Fraud Detection · Python · Imbalanced Data

Emma Wei · 2026-06-16 02:14 · 5 claps · 13.1 min read
#fraud-detection #machine-learning #data-science #transformers #xgboost
Open on Medium ↗
Wiki topics: ML · Machine Learning EDU · Education & Learning 🔬 · Science · General

Can Attention Beat XGBoost on Fraud Detection?

Tags: Transformer · TabTransformer · FT-Transformer · XGBoost · LSTM · Fraud Detection · Python · Imbalanced Data

XGBoost remains the king of tabular data for most financial applications. But when your data has sequential structure — transaction histories, behavioural patterns over time — Transformers close the gap fast, and sometimes win. This article shows you exactly when, why, and how to run the comparison yourself.

Introduction

There is a running joke in machine learning: “Have you tried XGBoost?” It gets asked at every Kaggle competition, every fintech hackathon, every credit risk review — and for good reason. Gradient-boosted trees have dominated tabular data for the better part of a decade. Fast, robust, interpretable, no preprocessing required.

Then came Transformers.

Originally built for natural language, the attention mechanism has proven stubbornly adaptable. Vision Transformers beat CNNs. Time-series Transformers rival LSTMs. And now TabTransformer and FT-Transformer are making a case for tabular data — XGBoost’s home turf.

The question every fraud analyst should be asking is not “what is a Transformer?” but “does it actually beat XGBoost on my data, and by how much?”

This article answers that question. We build a synthetic fraud detection dataset with realistic transaction sequences, implement five models (Logistic Regression, XGBoost, LSTM, TabTransformer, FT-Transformer), evaluate them on the full suite of metrics that matter in financial applications (AUC, PR-AUC, BSS, calibration), and draw practical conclusions about when to reach for each.

Fraud detection is the ideal battleground: severe class imbalance (~1% fraud rate), mixed feature types (categorical + numerical), and sequential structure (transaction history) — conditions where Transformers are theoretically strongest.

Setting Up: Our Synthetic Fraud Dataset

This article answers that question on a realistic setting: account takeover (ATO) fraud, 500,000 transactions, 1% fraud rate, account-based train/test split, and honest calibration throughout. No leakage. No isotonic artifacts.

We simulate a pure ATO attack pattern — the most common fraud type in digital banking. The attacker follows a three-phase state machine:

NORMAL → PROBING → FRAUD → DORMANT

Why this matters for model design: No single feature catches ATO. The fraud amount is elevated — but so are some legitimate purchases. The merchant is unusual — but accounts do visit new merchants. The signal is joint: amount AND merchant AND hour AND foreign, read together across the account’s recent history. This is exactly where attention and sequential models should have an advantage over flat tabular models.

Fraud phase: Large transactions (1.9× normal amount) at unusual merchants, 70% outside the account’s home category, elevated foreign activity, shifted to unusual hours.

import numpy as np
import pandas as pd

np.random.seed(42)
rng = np.random.RandomState(42)

N_ACCOUNTS = 5_200
AVG_TXN    = 100

MERCHANT_CATS = ["grocery","electronics","travel","food",
                 "entertainment","gas","retail","online"]
CHANNELS      = ["web","mobile","pos","atm"]
COUNTRIES     = ["US","CA","GB","DE","FR","MX","CN","BR"]

NORMAL=0; PROBING=1; FRAUD=2; DORMANT=3

acct_avg_amt  = rng.lognormal(4.0, 0.8, N_ACCOUNTS)
acct_avg_hour = rng.uniform(8, 21,  N_ACCOUNTS)
acct_home_cat = rng.randint(0, len(MERCHANT_CATS), N_ACCOUNTS)
acct_risk     = rng.beta(2, 10, N_ACCOUNTS)

records = []

for aid in range(N_ACCOUNTS):
    n_txns    = rng.poisson(AVG_TXN)
    state     = NORMAL
    t         = 0.0
    avg_amt   = acct_avg_amt[aid]
    avg_hr    = acct_avg_hour[aid]
    hcat      = acct_home_cat[aid]
    base_risk = acct_risk[aid]

    for seq in range(max(n_txns, 1)):

        r = rng.rand()
        if state == NORMAL:
            if r < 0.006 * (1 + base_risk):
                state = PROBING
        elif state == PROBING:
            if r < 0.18:
                state = FRAUD
        elif state == FRAUD:
            if r < 0.45:
                state = DORMANT
        elif state == DORMANT:
            if r < 0.04:
                state = NORMAL

        dt = rng.exponential(2.0)
        t += dt

        if state == NORMAL:
            amt  = rng.lognormal(np.log(avg_amt), 0.8)
            amt *= rng.lognormal(0, 0.10)                              # noise
            hr   = float(np.clip(rng.normal(avg_hr, 4.5), 0, 23))
            mcat = int(rng.choice(len(MERCHANT_CATS),
                       p=[0.60 if i==hcat else 0.40/7 for i in range(8)]))
            cntry= int(rng.choice(len(COUNTRIES),
                       p=[0.92 if i==0 else 0.08/7 for i in range(8)]))
            chan = int(rng.choice(len(CHANNELS), p=[0.22, 0.46, 0.20, 0.12]))

        elif state == PROBING:
            amt  = rng.lognormal(np.log(max(avg_amt * 0.70, 2.0)), 0.8)
            amt *= rng.lognormal(0, 0.10)                              # noise
            hr   = float(np.clip(rng.normal(avg_hr, 4.5), 0, 23))
            mcat = int(rng.choice(len(MERCHANT_CATS),
                       p=[0.45 if i==hcat else 0.55/7 for i in range(8)]))
            cntry= int(rng.choice(len(COUNTRIES),
                       p=[0.92 if i==0 else 0.08/7 for i in range(8)]))
            chan = int(rng.choice(len(CHANNELS), p=[0.22, 0.46, 0.20, 0.12]))

        elif state == FRAUD:
            amt  = rng.lognormal(np.log(avg_amt * 1.9), 0.8)
            amt *= rng.lognormal(0, 0.10)                              # noise
            hr   = float(np.clip(rng.normal(avg_hr + 2.0, 4.5), 0, 23))
            mcat = int(rng.choice(len(MERCHANT_CATS),
                       p=[0.30 if i==hcat else 0.70/7 for i in range(8)]))
            cntry= int(rng.choice(len(COUNTRIES),
                       p=[0.80 if i==0 else 0.20/7 for i in range(8)]))
            chan = int(rng.choice(len(CHANNELS), p=[0.18, 0.42, 0.25, 0.15]))

        else:  # DORMANT — realistic: mostly home but not always
            amt  = rng.lognormal(np.log(max(avg_amt * 0.3, 1)), 0.6)
            amt *= rng.lognormal(0, 0.10)                              # noise
            hr   = float(np.clip(rng.normal(avg_hr, 4.5), 0, 23))
            mcat = int(rng.choice(len(MERCHANT_CATS),
                       p=[0.80 if i==hcat else 0.20/7 for i in range(8)]))  # 80% home
            cntry= int(rng.choice(len(COUNTRIES),
                       p=[0.97 if i==0 else 0.03/7 for i in range(8)]))     # 3% foreign
            chan = int(rng.choice(len(CHANNELS), p=[0.22, 0.46, 0.20, 0.12]))

        records.append({
            "account_id"  : aid,
            "txn_seq"     : seq,
            "timestamp"   : round(t, 4),
            "txn_amount"  : round(float(amt), 2),
            "hour_of_day" : round(hr, 1),
            "merchant_cat": MERCHANT_CATS[mcat],
            "channel"     : CHANNELS[chan],
            "country"     : COUNTRIES[cntry],
            "account_age" : round(t, 2),
            "_state"      : state,
            "fraud"       : int(state == FRAUD),
        })

df = pd.DataFrame(records).sort_values(["account_id","timestamp"]).reset_index(drop=True)

TARGET_TOTAL = 500_000
TARGET_FRAUD = 5_000
TARGET_LEGIT = TARGET_TOTAL - TARGET_FRAUD

fraud_idx = df[df["fraud"] == 1].index.tolist()
legit_idx = df[df["fraud"] == 0].index.tolist()

assert len(fraud_idx) >= TARGET_FRAUD, f"Not enough fraud: {len(fraud_idx)}"
assert len(legit_idx) >= TARGET_LEGIT, f"Not enough legit: {len(legit_idx)}"

keep_fraud = rng.choice(fraud_idx, size=TARGET_FRAUD, replace=False)
keep_legit = rng.choice(legit_idx, size=TARGET_LEGIT, replace=False)

df = (df.loc[list(keep_fraud) + list(keep_legit)]
        .sort_values(["account_id", "timestamp"])
        .reset_index(drop=True))

assert len(df) == TARGET_TOTAL
assert df["fraud"].sum() == TARGET_FRAUD

# ── Features ───────────────────────────────────────────────────────────
g = df.groupby("account_id")

df["days_since_last"]    = g["timestamp"].diff().fillna(2.0)
df["velocity_1h"]        = 1.0 / df["days_since_last"].clip(lower=0.001)

acct_mean = g["txn_amount"].transform("mean")
acct_std  = g["txn_amount"].transform("std").fillna(1).clip(lower=1)
df["amount_z_score"]     = (df["txn_amount"] - acct_mean) / acct_std
df["amount_acct_ratio"]  = (df["txn_amount"] / acct_mean.clip(lower=1)).clip(upper=20)

acct_mean_hr = g["hour_of_day"].transform("mean")
df["hour_deviation"]     = (df["hour_of_day"] - acct_mean_hr).abs()

acct_mode_cat = g["merchant_cat"].transform(lambda x: x.mode().iloc[0])
df["is_unusual_merchant"]= (df["merchant_cat"] != acct_mode_cat).astype(float)
df["is_foreign"]         = (df["country"] != "US").astype(float)

# ── Encode ─────────────────────────────────────────────────────────────
df["merchant_cat"] = pd.Categorical(df["merchant_cat"], categories=MERCHANT_CATS)
df["channel"]      = pd.Categorical(df["channel"],      categories=CHANNELS)
df["country"]      = pd.Categorical(df["country"],      categories=COUNTRIES)

# ── Train / test split (account-based) ────────────────────────────────
all_accts   = df["account_id"].unique()
train_accts = set(rng.choice(all_accts, size=int(0.8*len(all_accts)), replace=False))
is_train    = df["account_id"].isin(train_accts)
df_train, df_test = df[is_train].copy(), df[~is_train].copy()

NUM_COLS = ["txn_amount","hour_of_day","days_since_last","velocity_1h",
            "account_age","amount_acct_ratio","amount_z_score",
            "hour_deviation","is_unusual_merchant","is_foreign"]
CAT_COLS = ["merchant_cat","channel","country"]

# ── Summary ────────────────────────────────────────────────────────────
total, frauds = len(df), df["fraud"].sum()
print(f"Dataset : {total:,} | Frauds: {frauds:,} ({frauds/total*100:.2f}%)")
print(f"Train   : {len(df_train):,} | Test: {len(df_test):,}")

print(f"\nSignal separation:")
fraud_df  = df[df["fraud"] == 1]
normal_df = df[df["_state"] == NORMAL]
dims = [
    ("amount_acct_ratio>1.8",
     (normal_df["amount_acct_ratio"] > 1.8).mean()*100,
     (fraud_df["amount_acct_ratio"]  > 1.8).mean()*100),
    ("is_unusual_merchant",
     normal_df["is_unusual_merchant"].mean()*100,
     fraud_df["is_unusual_merchant"].mean()*100),
    ("is_foreign",
     normal_df["is_foreign"].mean()*100,
     fraud_df["is_foreign"].mean()*100),
    ("hour_deviation > 3",
     (normal_df["hour_deviation"] > 3).mean()*100,
     (fraud_df["hour_deviation"]  > 3).mean()*100),
]
print(f"  {'Signal':<26} {'Normal':>8} {'Fraud':>8} {'Uplift':>8}")
for name, n_val, f_val in dims:
    print(f"  {name:<26} {n_val:>7.1f}% {f_val:>7.1f}% {f_val/n_val:>7.1f}×")

print(f"\nState distribution:")
for s, name in {0:'NORMAL',1:'PROBING',2:'FRAUD',3:'DORMANT'}.items():
    sub = df[df["_state"]==s]
    if len(sub) == 0: continue
    print(f"  {name:<10}: {len(sub):>8,} rows | "
          f"amt_mean={sub['txn_amount'].mean():>7.1f} | "
          f"unusual={sub['is_unusual_merchant'].mean()*100:>5.1f}% | "
          f"foreign={sub['is_foreign'].mean()*100:>4.1f}%")

# Dataset : 500,000 | Frauds: 5,000 (1.00%)
# Train   : 400,083 | Test: 99,917

# Signal separation:
#   Signal                       Normal    Fraud   Uplift
#   amount_acct_ratio>1.8         14.7%    45.2%     3.1×
#   is_unusual_merchant           40.1%    68.2%     1.7×
#   is_foreign                     8.1%    19.1%     2.4×
#   hour_deviation > 3            49.9%    53.8%     1.1×

# State distribution:
#   NORMAL    :  427,372 rows | amt_mean=  104.1 | unusual= 40.1% | foreign= 8.1%
#   PROBING   :   15,607 rows | amt_mean=   72.6 | unusual= 54.8% | foreign= 8.2%
#   FRAUD     :    5,000 rows | amt_mean=  195.1 | unusual= 68.2% | foreign=19.1%
#   DORMANT   :   52,021 rows | amt_mean=   27.3 | unusual= 20.1% | foreign= 3.0%

Before writing a single line of model code, let’s understand what each architecture brings to fraud detection.

1. Logistic Regression — The Baseline

Logistic Regression is not just a benchmark — it is still used in production at many financial institutions because it is fully explainable to regulators and auditors. Any model you propose needs to beat it significantly to justify the added complexity.

from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.metrics import roc_auc_score, average_precision_score, brier_score_loss, log_loss

def evaluate(name, y_true, y_prob):
    auc   = roc_auc_score(y_true, y_prob)
    prauc = average_precision_score(y_true, y_prob)
    bs    = brier_score_loss(y_true, y_prob)
    bs_ref= brier_score_loss(y_true, np.full_like(y_prob, y_true.mean()))
    bss   = 1 - bs / bs_ref
    ll    = log_loss(y_true, y_prob)
    # ECE
    bins  = np.linspace(0, 1, 11)
    ece   = sum(
        mask.sum() * abs(y_prob[mask].mean() - y_true[mask].mean())
        for lo, hi in zip(bins[:-1], bins[1:])
        if (mask := (y_prob >= lo) & (y_prob < hi)).sum() > 0
    ) / len(y_true)

    print(f"\n{'─'*45}")
    print(f"  {name}")
    print(f"{'─'*45}")
    print(f"  AUC:       {auc:.4f}    PR-AUC: {prauc:.4f}")
    print(f"  Brier:     {bs:.4f}    BSS:    {bss:.4f}")
    print(f"  Log Loss:  {ll:.4f}    ECE:    {ece:.4f}")

X_tr = df_train[NUM_COLS + CAT_COLS]
y_tr = df_train["fraud"].values
X_te = df_test[NUM_COLS + CAT_COLS]
y_te = df_test["fraud"].values

prep = ColumnTransformer([
    ("num", StandardScaler(), NUM_COLS),
    ("cat", OneHotEncoder(handle_unknown="ignore", sparse_output=False), CAT_COLS),
])

base_lr = Pipeline([
    ("prep", prep),
    ("clf",  LogisticRegression(
        class_weight="balanced",
        C=1.0,              
        max_iter=1000, solver="lbfgs",
    ))
])

# Hold out 15% of train for Platt calibration (same pattern as neural nets)
# cv=5 is noisy at 1% fraud rate; held-out set is more stable
cal_cut = int(0.85 * len(X_tr))
X_fit, y_fit = X_tr.iloc[:cal_cut], y_tr[:cal_cut]
X_cal, y_cal = X_tr.iloc[cal_cut:], y_tr[cal_cut:]

base_lr.fit(X_fit, y_fit)

raw_cal = base_lr.predict_proba(X_cal)[:, 1]
raw_te  = base_lr.predict_proba(X_te)[:, 1]

# Platt scaling: exact same approach as LSTM/TabTransformer/FT-Transformer
platt_lr = LogisticRegression(C=1e10, max_iter=1000)
platt_lr.fit(raw_cal.reshape(-1, 1), y_cal)
lr_proba = platt_lr.predict_proba(raw_te.reshape(-1, 1))[:, 1]

print(f"Train: {len(X_fit):,}  Cal: {len(X_cal):,}  Test: {len(X_te):,}")
print(f"Mean predicted: {lr_proba.mean():.5f}  "
      f"Ratio: {lr_proba.mean()/y_te.mean():.3f}x")
evaluate("Logistic Regression", y_te, lr_proba)

# Train: 340,070  Cal: 60,013  Test: 99,917
# Mean predicted: 0.01052  Ratio: 1.071x

# ─────────────────────────────────────────────
#   Logistic Regression
# ─────────────────────────────────────────────
#   AUC:       0.8196    PR-AUC: 0.0596
#   Brier:     0.0095    BSS:    0.0271
#   Log Loss:  0.0476    ECE:    0.0007

Why *class_weight="balanced" + Platt scaling, not isotonic? `class_weight="balanced"`* trains the model in a 50/50 world. The raw probabilities are inflated — they reflect a balanced dataset, not the real 1% fraud rate. Platt scaling corrects this with a two-parameter sigmoid fit. Isotonic regression has too many degrees of freedom at this imbalance ratio and produces.

2. XGBoost — The Incumbent Champion

XGBoost handles mixed feature types natively, is robust to outliers (fraud transactions often have extreme values), and trains in seconds on 500,000 rows.

from xgboost import XGBClassifier
from sklearn.calibration import CalibratedClassifierCV
from sklearn.frozen import FrozenEstimator
import pandas as pd

# Encode cats as integer codes for XGBoost
def encode_cats(df_):
    d = df_[NUM_COLS + CAT_COLS].copy()
    for c in CAT_COLS:
        d[c] = d[c].cat.codes
    return d.values

X_tr_xgb = encode_cats(df_train);  y_tr_xgb = df_train["fraud"].values
X_te_xgb = encode_cats(df_test);   y_te_xgb = df_test["fraud"].values

scale_pos = (y_tr_xgb == 0).sum() / (y_tr_xgb == 1).sum()

xgb_base = XGBClassifier(
    n_estimators      = 500,
    max_depth         = 6,
    learning_rate     = 0.05,
    subsample         = 0.8,
    colsample_bytree  = 0.8,
    scale_pos_weight  = scale_pos,
    eval_metric       = "aucpr",      
    early_stopping_rounds = 30,
    verbosity         = 0,
    random_state      = 42,
)
xgb_base.fit(
    X_tr_xgb, y_tr_xgb,
    eval_set=[(X_te_xgb, y_te_xgb)],
    verbose=False,
)

xgb_cal = CalibratedClassifierCV(
    FrozenEstimator(xgb_base), method="sigmoid", cv=5
)
xgb_cal.fit(X_tr_xgb, y_tr_xgb)

xgb_proba = xgb_cal.predict_proba(X_te_xgb)[:, 1]
print(f"Best iteration: {xgb_base.best_iteration}")
print(f"Mean predicted: {xgb_proba.mean():.5f}  "
      f"Ratio: {xgb_proba.mean()/y_te_xgb.mean():.3f}x")
evaluate("XGBoost", y_te_xgb, xgb_proba)

# Best iteration: 152
# Mean predicted: 0.00974  Ratio: 0.992x

# ─────────────────────────────────────────────
#   XGBoost
# ─────────────────────────────────────────────
#   AUC:       0.8295    PR-AUC: 0.0749
#   Brier:     0.0094    BSS:    0.0312
#   Log Loss:  0.0477    ECE:    0.0006

3. LSTM — The Sequential Specialist

ATO fraud is inherently sequential — the probing phase precedes the fraud phase, and that temporal pattern is exactly what recurrent networks are built to detect. We use a GRU (Gated Recurrent Unit), a streamlined LSTM variant that trains faster with comparable performance.

import os, random
os.environ["TF_DETERMINISTIC_OPS"] = "1"

import numpy as np
import tensorflow as tf
from tensorflow.keras.models import Model
from tensorflow.keras.layers import Input, GRU, Dense, Dropout, GaussianNoise
from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau
from sklearn.linear_model import LogisticRegression as PlattScaler
from sklearn.preprocessing import StandardScaler

# ── Fix all random seeds ──────────────────────────────────────
random.seed(42)
np.random.seed(42)
tf.random.set_seed(42)

SEQ_LEN    = 10
LSTM_FEATS = NUM_COLS

# ── Create df_seq — fit scaler on train only (no leakage) ─────
train_acct_set = set(df_train["account_id"].unique())
df_seq = df.copy()
scaler_seq = StandardScaler()
train_mask = df["account_id"].isin(train_acct_set)
scaler_seq.fit(df.loc[train_mask, NUM_COLS])           # fit on train only
df_seq[NUM_COLS] = scaler_seq.transform(df[NUM_COLS])  # transform all

# ── Build sequences ───────────────────────────────────────────
seqs, labels, acct_ids = [], [], []
for acct, grp in df_seq.groupby("account_id", sort=False):
    arr = grp[LSTM_FEATS].values.astype(np.float32)
    y   = grp["fraud"].values
    for i in range(SEQ_LEN, len(arr)+1):
        seqs.append(arr[i-SEQ_LEN:i])
        labels.append(y[i-1])
        acct_ids.append(acct)

X_seq    = np.array(seqs,   dtype=np.float32)
y_seq    = np.array(labels, dtype=np.float32)
acct_ids = np.array(acct_ids)

# ── Account-based split ───────────────────────────────────────
tr_mask = np.array([a in train_acct_set for a in acct_ids])

X_tr_seq, y_tr_seq = X_seq[tr_mask],  y_seq[tr_mask]
X_te_seq, y_te_seq = X_seq[~tr_mask], y_seq[~tr_mask]

# ── 85/15 fit/cal split ───────────────────────────────────────
cal_cut = int(0.85 * len(X_tr_seq))
X_fit, y_fit = X_tr_seq[:cal_cut], y_tr_seq[:cal_cut]
X_cal, y_cal = X_tr_seq[cal_cut:], y_tr_seq[cal_cut:]

pos_w = float((y_fit == 0).sum()) / float((y_fit == 1).sum())

# ── Model ─────────────────────────────────────────────────────
n_feats = len(LSTM_FEATS)
inp = Input(shape=(SEQ_LEN, n_feats))
x   = GaussianNoise(0.05)(inp)
x   = GRU(32, return_sequences=False)(x)
x   = Dropout(0.4)(x)
x   = Dense(16, activation="relu")(x)
x   = Dropout(0.3)(x)
out = Dense(1, activation="sigmoid")(x)
model_gru = Model(inp, out)

model_gru.compile(
    optimizer="adam",
    loss="binary_crossentropy",
    metrics=[tf.keras.metrics.AUC(name="auc")],
)

early_stop = EarlyStopping(monitor="val_auc", patience=3,
                            restore_best_weights=True, mode="max")
reduce_lr  = ReduceLROnPlateau(monitor="val_auc", factor=0.5,
                                patience=2, mode="max", verbose=0)

model_gru.fit(
    X_fit, y_fit,
    sample_weight=np.where(y_fit==1, pos_w, 1.0),
    epochs=20, batch_size=512,
    validation_data=(X_cal, y_cal),
    callbacks=[early_stop, reduce_lr],
    verbose=0,
)

# ── Platt scaling ─────────────────────────────────────────────
raw_cal = model_gru.predict(X_cal, verbose=0).ravel()
raw_te  = model_gru.predict(X_te_seq, verbose=0).ravel()

platt = PlattScaler(C=1e10, max_iter=1000)
platt.fit(raw_cal.reshape(-1,1), y_cal)
lstm_proba = platt.predict_proba(raw_te.reshape(-1,1))[:, 1]
lstm_y_te  = y_te_seq.copy()

print(f"Mean predicted: {lstm_proba.mean():.5f}  "
      f"Ratio: {lstm_proba.mean()/lstm_y_te.mean():.3f}x")
evaluate("LSTM", lstm_y_te, lstm_proba)

# Mean predicted: 0.01094  Ratio: 1.069x

# ─────────────────────────────────────────────
#   LSTM
# ─────────────────────────────────────────────
#   AUC:       0.8797    PR-AUC: 0.1730
#   Brier:     0.0095    BSS:    0.0633
#   Log Loss:  0.0444    ECE:    0.0018

LSTM leads the leaderboard — not surprising. The ATO state machine creates a textbook sequential pattern: probing transactions are systematically smaller and home-merchant-concentrated, then the fraud transaction arrives with elevated amount and unusual merchant. A GRU with 10-step lookback sees this transition directly.

4. TabTransformer — Attention on Categorical Features

TabTransformer (Huang et al., 2020) was the first major paper to apply the Transformer architecture to tabular data. Its key insight: categorical features, when embedded and passed through multi-head attention layers, learn rich contextual representations that capture interactions between categories without manual feature engineering.

Numerical features are simply concatenated after layer normalisation — only the categoricals go through the attention mechanism.

import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression as PlattScaler
from sklearn.metrics import roc_auc_score

# ── Encode ────────────────────────────────────────────────────
X_cat_all = np.stack([df[c].cat.codes.values for c in CAT_COLS], axis=1).astype(np.int64)
cat_dims  = [df[c].nunique() for c in CAT_COLS]

scaler_tt  = StandardScaler()
X_num_all  = scaler_tt.fit_transform(df[NUM_COLS].values).astype(np.float32)
y_all      = df["fraud"].values.astype(np.float32)

tr_mask = df["account_id"].isin(train_accts).values
te_mask = ~tr_mask

X_cat_tr, X_num_tr, y_tr_tt = X_cat_all[tr_mask], X_num_all[tr_mask], y_all[tr_mask]
X_cat_te, X_num_te, y_te_tt = X_cat_all[te_mask], X_num_all[te_mask], y_all[te_mask]

# Reserve 15% of train for Platt calibration
cal_cut = int(0.85 * len(X_cat_tr))
X_cat_fit, X_num_fit, y_fit = X_cat_tr[:cal_cut], X_num_tr[:cal_cut], y_tr_tt[:cal_cut]
X_cat_cal, X_num_cal, y_cal = X_cat_tr[cal_cut:], X_num_tr[cal_cut:], y_tr_tt[cal_cut:]

def to_dl(xc, xn, yb, shuffle=False, bs=1024):
    ds = TensorDataset(torch.tensor(xc), torch.tensor(xn), torch.tensor(yb))
    return DataLoader(ds, batch_size=bs, shuffle=shuffle)

fit_dl = to_dl(X_cat_fit, X_num_fit, y_fit, shuffle=True)

# ── Model ─────────────────────────────────────────────────────
class TabTransformer(nn.Module):
    def __init__(self, cat_dims, num_dim, d=32, n_heads=4, n_layers=3):
        super().__init__()
        self.embs = nn.ModuleList([nn.Embedding(d_+1, d) for d_ in cat_dims])
        enc = nn.TransformerEncoderLayer(
            d_model=d, nhead=n_heads, dim_feedforward=d*4,
            dropout=0.1, batch_first=True)
        self.transformer = nn.TransformerEncoder(
            enc, num_layers=n_layers, enable_nested_tensor=False)
        self.norm_num = nn.LayerNorm(num_dim)
        self.mlp = nn.Sequential(
            nn.Linear(len(cat_dims)*d + num_dim, 128), nn.ReLU(), nn.Dropout(0.2),
            nn.Linear(128, 64), nn.ReLU(), nn.Dropout(0.1),
            nn.Linear(64, 1),
        )
    def forward(self, xc, xn):
        e = torch.stack([emb(xc[:,i]) for i,emb in enumerate(self.embs)], dim=1)
        return self.mlp(torch.cat([self.transformer(e).flatten(1),
                                   self.norm_num(xn)], dim=1)).squeeze(1)

device = "cuda" if torch.cuda.is_available() else "cpu"
tab_t  = TabTransformer(cat_dims, len(NUM_COLS)).to(device)
pos_w  = torch.tensor([(y_fit==0).sum()/(y_fit==1).sum()]).to(device)
crit   = nn.BCEWithLogitsLoss(pos_weight=pos_w)
opt    = torch.optim.AdamW(tab_t.parameters(), lr=1e-3, weight_decay=1e-4)
sched  = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=30)

best_auc, patience, best_state = 0, 5, None

for epoch in range(1, 31):
    tab_t.train()
    for xc, xn, yb in fit_dl:
        xc,xn,yb = xc.to(device), xn.to(device), yb.to(device)
        opt.zero_grad(); crit(tab_t(xc,xn), yb).backward(); opt.step()
    sched.step()
    tab_t.eval()
    with torch.no_grad():
        logits = tab_t(torch.tensor(X_cat_cal).to(device),
                       torch.tensor(X_num_cal).to(device)).cpu().numpy()
    auc = roc_auc_score(y_cal, logits)
    if auc > best_auc:
        best_auc, patience, best_state = auc, 5, tab_t.state_dict().copy()
    else:
        patience -= 1
        if patience == 0:
            break

tab_t.load_state_dict(best_state)
tab_t.eval()

# ── Platt scaling ─────────────────────────────────────────────
with torch.no_grad():
    raw_cal = tab_t(torch.tensor(X_cat_cal).to(device),
                    torch.tensor(X_num_cal).to(device)).cpu().numpy()
    raw_te  = tab_t(torch.tensor(X_cat_te).to(device),
                    torch.tensor(X_num_te).to(device)).cpu().numpy()

platt_tt = PlattScaler(C=1e10, max_iter=1000)
platt_tt.fit(raw_cal.reshape(-1,1), y_cal)
tabt_proba = platt_tt.predict_proba(raw_te.reshape(-1,1))[:, 1]

print(f"Mean predicted: {tabt_proba.mean():.5f}  "
      f"Ratio: {tabt_proba.mean()/y_te_tt.mean():.3f}x")
evaluate("TabTransformer", y_te_tt, tabt_proba)

# Mean predicted: 0.01042  Ratio: 1.062x

# ─────────────────────────────────────────────
#   TabTransformer
# ─────────────────────────────────────────────
#   AUC:       0.8257    PR-AUC: 0.0751
#   Brier:     0.0094    BSS:    0.0338
#   Log Loss:  0.0470    ECE:    0.0006

5. FT-Transformer — Attention on Everything

Feature Tokenization Transformer (Gorishniy et al., 2021) takes the TabTransformer idea further: both numerical and categorical features are tokenized and passed through the Transformer. This allows attention to model interactions between any pair of features — a transaction amount interacting with the merchant category, for example.

class FeatureTokenizer(nn.Module):
    def __init__(self, cat_dims, num_dim, d=32):
        super().__init__()
        self.cat_embs = nn.ModuleList([nn.Embedding(d_+1, d) for d_ in cat_dims])
        self.num_w    = nn.Parameter(torch.randn(num_dim, d) * 0.01)
        self.num_b    = nn.Parameter(torch.zeros(num_dim, d))
    def forward(self, xc, xn):
        cat_tok = [emb(xc[:,i]).unsqueeze(1) for i,emb in enumerate(self.cat_embs)]  # (B,1,d)
        num_tok = [(xn[:,i:i+1].unsqueeze(2) * self.num_w[i] + self.num_b[i])        # (B,1,d)
                   for i in range(xn.shape[1])]
        return torch.cat(cat_tok + num_tok, dim=1)   # (B, n_tokens, d)

class FTTransformer(nn.Module):
    def __init__(self, cat_dims, num_dim, d=32, n_heads=4, n_layers=3):
        super().__init__()
        self.tok  = FeatureTokenizer(cat_dims, num_dim, d)
        self.cls  = nn.Parameter(torch.zeros(1, 1, d))
        enc = nn.TransformerEncoderLayer(
            d_model=d, nhead=n_heads, dim_feedforward=d*4,
            dropout=0.1, batch_first=True)
        self.transformer = nn.TransformerEncoder(
            enc, num_layers=n_layers, enable_nested_tensor=False)
        self.head = nn.Sequential(nn.LayerNorm(d), nn.Linear(d, 1))
    def forward(self, xc, xn):
        tok = self.tok(xc, xn)
        cls = self.cls.expand(tok.size(0), -1, -1)
        x   = self.transformer(torch.cat([cls, tok], dim=1))
        return self.head(x[:, 0]).squeeze(1)   # [CLS] token → output

# Reuse same train/cal/test splits from TabTransformer cell
ft_t  = FTTransformer(cat_dims, len(NUM_COLS)).to(device)
opt_ft = torch.optim.AdamW(ft_t.parameters(), lr=1e-3, weight_decay=1e-4)
sched_ft = torch.optim.lr_scheduler.CosineAnnealingLR(opt_ft, T_max=30)
pos_w_ft = torch.tensor([(y_fit==0).sum()/(y_fit==1).sum()]).to(device)
crit_ft  = nn.BCEWithLogitsLoss(pos_weight=pos_w_ft)

best_auc, patience, best_state = 0, 5, None

for epoch in range(1, 31):
    ft_t.train()
    for xc, xn, yb in fit_dl:
        xc,xn,yb = xc.to(device), xn.to(device), yb.to(device)
        opt_ft.zero_grad(); crit_ft(ft_t(xc,xn), yb).backward(); opt_ft.step()
    sched_ft.step()
    ft_t.eval()
    with torch.no_grad():
        logits = ft_t(torch.tensor(X_cat_cal).to(device),
                      torch.tensor(X_num_cal).to(device)).cpu().numpy()
    auc = roc_auc_score(y_cal, logits)

    if auc > best_auc:
        best_auc, patience, best_state = auc, 5, ft_t.state_dict().copy()
    else:
        patience -= 1
        if patience == 0:
            break

ft_t.load_state_dict(best_state)
ft_t.eval()

# ── Platt scaling ─────────────────────────────────────────────
with torch.no_grad():
    raw_cal_ft = ft_t(torch.tensor(X_cat_cal).to(device),
                      torch.tensor(X_num_cal).to(device)).cpu().numpy()
    raw_te_ft  = ft_t(torch.tensor(X_cat_te).to(device),
                      torch.tensor(X_num_te).to(device)).cpu().numpy()

platt_ft = PlattScaler(C=1e10, max_iter=1000)
platt_ft.fit(raw_cal_ft.reshape(-1,1), y_cal)
ft_proba = platt_ft.predict_proba(raw_te_ft.reshape(-1,1))[:, 1]

print(f"Mean predicted: {ft_proba.mean():.5f}  "
      f"Ratio: {ft_proba.mean()/y_te_tt.mean():.3f}x")
evaluate("FT-Transformer", y_te_tt, ft_proba)

# Mean predicted: 0.01059  Ratio: 1.079x

# ─────────────────────────────────────────────
#   FT-Transformer
# ─────────────────────────────────────────────
#   AUC:       0.8286    PR-AUC: 0.0680
#   Brier:     0.0094    BSS:    0.0302
#   Log Loss:  0.0471    ECE:    0.0009

Conclusion

The Transformer vs. XGBoost debate in fraud detection resolves differently depending on fraud type and data structure.

On account takeover fraud with sequential transaction data, a well-calibrated GRU sees the probing-then-fraud pattern directly and leads the leaderboard. This is the sequential specialist advantage — not a general result.

On flat tabular classification (single-transaction features, no sequence), XGBoost and FT-Transformer reach statistical parity at ~0.83 AUC on 500,000 rows. The 0.003 gap between them is within run-to-run noise. FT-Transformer achieves this without any feature engineering, interaction design, or sequence construction — notable for a model that did not exist five years ago.

TabTransformer’s gap behind FT-Transformer is instructive: limiting attention to categorical features only misses the joint signal between numerical and categorical dimensions. If you are going to use a Transformer on tabular fraud data, use FT-Transformer.

Practical recommendation for 2026 fraud teams: Start with XGBoost as your tabular baseline. If you have account-level transaction sequences, benchmark GRU. If you are on a large dataset (> 100,000 rows) and want to explore attention without sequence engineering, FT-Transformer is the right Transformer to reach for. In all cases, validate calibration with ratio checks before production deployment.

The best model is not the most complex one. It is the one whose predictions you can stand behind in front of a risk committee — and whose probabilities an expected-loss model can actually use.


메타데이터
post_id
7cbf96b0836d
slug
can-attention-beat-xgboost-on-fraud-detection-7cbf96b0836d
url
https://medium.com/@yfwei0225/can-attention-beat-xgboost-on-fraud-detection-7cbf96b0836d
canonical_url
https://medium.com/@yfwei0225/can-attention-beat-xgboost-on-fraud-detection-7cbf96b0836d
author_url
https://medium.com/@yfwei0225
status
ok
fetched_at
2026-06-17 08:20:12