IEEE-CIS Fraud Detection — the feature-engineering front end¶

Source: the IEEE-CIS Fraud Detection competition (Vesta Corporation), whose train_transaction and train_identity files are joined below.

Operations Research family 7, notebook 2. The companion fraud_cost_sensitive_decision built the decision layer on the tightly-scoped ULB data — calibration → cost-sensitive threshold → uncertainty triage → dollar ladder. This notebook builds the piece ULB could not: the applied feature engineering that produces the score that decision layer consumes.

The two datasets teach different halves of a real fraud system, and the split is deliberate:

  • ULB is PCA-anonymized — no identifiers, no raw timestamps — so it isolates the statistics of the decision: imbalance, calibration, cost.
  • IEEE-CIS (Vesta e-commerce transactions) keeps the messy, real structure: entity keys (card1…card6, addr1/2), relative timedeltas (D1…D15), match flags, device identity, and heavy missingness. That structure is where the real predictive lift in fraud lives — not in the model architecture, but in reconstructing who is transacting and how they behave over time.

The competition's own lore bears this out: the top solutions' gains came overwhelmingly from feature engineering on the anonymized columns, above all from turning the relative D timedeltas into a stable anchor that lets you stitch transactions into per-card entities — and then computing that card's velocity, recency, and history. This notebook reconstructs that pipeline honestly and measures the lift.

§ Content
1 The data: transaction ⋈ identity, imbalance, missingness, the 182-day time-split
2 D-column normalization — relative timedeltas → a fixed calendar anchor
3 UID construction — stitch transactions into card/account entities
4 Behavioral aggregates — per-entity velocity / recency / history
5 The lift, measured — GBM PR-AUC raw vs engineered, out-of-time, with SHAP
6 Completing the cycle — calibrate the score on real segments
7 Cost-sensitive decision — amount-varying threshold on real amounts
8 Uncertainty triage + dollar ladder — the realized-$ scoreboard
9 The two-notebook system, and what it demonstrates

Data note: the competition test set has no public labels, so we work entirely within the labeled train set (590,540 transactions over 182 days) and evaluate honestly with a time-ordered split — the same out-of-time discipline as the ULB notebook.

In [1]:
import os
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
    os.environ.setdefault(_v, "2")   # (later sections use LightGBM; cap threads — energy-notebook lesson)
import numpy as np, pandas as pd
import matplotlib.pyplot as plt
from pathlib import Path
plt.rcParams.update({"figure.dpi": 110, "font.size": 10, "axes.grid": True,
                     "grid.alpha": 0.25, "axes.spines.top": False, "axes.spines.right": False})
DATA = Path("data")

# transaction is 683 MB / 394 cols; load a focused, analysis-relevant subset and downcast to save memory
TCOLS = (["TransactionID","isFraud","TransactionDT","TransactionAmt","ProductCD",
          "card1","card2","card3","card4","card5","card6","addr1","addr2"]
         + [f"D{i}" for i in range(1,16)] + [f"C{i}" for i in range(1,15)] + [f"M{i}" for i in range(1,10)])
tx = pd.read_csv(DATA/"train_transaction.csv", usecols=TCOLS)
idf = pd.read_csv(DATA/"train_identity.csv")
df = tx.merge(idf, on="TransactionID", how="left")           # left join: not every txn has identity
for c in df.select_dtypes("float64").columns: df[c] = df[c].astype("float32")

df["day"] = (df["TransactionDT"]//86400).astype(int)          # day index (relative)
N=len(df); F=int(df.isFraud.sum())
print(f"transactions {N:,} | fraud {F:,} ({100*F/N:.2f}%) | columns loaded {df.shape[1]}")
print(f"time span: day {df.day.min()} .. {df.day.max()} ({df.day.max()-df.day.min()+1} days)")
print(f"with identity records: {idf.shape[0]:,} ({100*idf.shape[0]/N:.0f}% of transactions)")
transactions 590,540 | fraud 20,663 (3.50%) | columns loaded 92
time span: day 1 .. 182 (182 days)
with identity records: 144,233 (24% of transactions)

1 · The data — real structure, real mess¶

Unlike ULB's clean PCA matrix, IEEE-CIS looks like an actual payments feed: a wide transaction table left-joined to a sparse identity table.

Data dictionary (the groups that matter here)¶

Group Fields Meaning
Target / timing isFraud, TransactionDT, TransactionAmt label; seconds since a reference; amount
Product ProductCD product category (W/C/R/H/S) — a real segment
Entity keys card1…card6, addr1, addr2 card & address attributes — the raw material for a UID (§3)
Timedeltas D1…D15 days since prior events (e.g. D1 ≈ days since the card was first seen) — relative, the target of §2
Counts C1…C14 anonymized counting features (per entity/time)
Match flags M1…M9 whether fields match (e.g. name-on-card vs address)
Identity id_01…id_38, DeviceType, DeviceInfo device / network attributes, present for a minority of txns

Two facts define the modeling reality: heavy, structured missingness (many columns are absent for most rows, and which are missing is itself informative), and the presence of entity keys and timedeltas — the very things ULB stripped out, and the reason IEEE-CIS can support entity resolution.

In [2]:
# EDA 1 — imbalance and fraud rate by product segment
fig, ax = plt.subplots(1, 2, figsize=(12, 3.8))
ax[0].bar(["legit","fraud"], [N-F, F], color=["#2b6cb0","#c53030"]); ax[0].set_yscale("log")
ax[0].set_ylabel("transactions (log)"); ax[0].set_title(f"Imbalance — fraud {100*F/N:.2f}%")
for i,v in enumerate([N-F,F]): ax[0].text(i,v,f"{v:,}",ha="center",va="bottom",fontsize=9)

pr = df.groupby("ProductCD")["isFraud"].agg(["mean","size"]).sort_values("mean")
ax[1].bar(pr.index, pr["mean"]*100, color="#c53030")
ax[1].axhline(100*F/N, color="grey", ls=":", label=f"overall {100*F/N:.2f}%")
ax[1].set_ylabel("fraud rate (%)"); ax[1].set_xlabel("ProductCD"); ax[1].set_title("Fraud rate varies sharply by product")
ax[1].legend(fontsize=8)
for i,(p,r) in enumerate(zip(pr.index, pr["mean"]*100)): ax[1].text(i, r, f"{r:.1f}%", ha="center", va="bottom", fontsize=8)
plt.tight_layout(); plt.show()
print("ProductCD fraud rates:", {k: f"{v*100:.1f}%" for k,v in pr["mean"].items()})
print("Product 'C' (and 'S') carry far higher fraud than the dominant 'W' — a real, usable segment, unlike")
print("anything ULB's anonymized features could give us.")
No description has been provided for this image
ProductCD fraud rates: {'W': '2.0%', 'R': '3.8%', 'H': '4.8%', 'S': '5.9%', 'C': '11.7%'}
Product 'C' (and 'S') carry far higher fraud than the dominant 'W' — a real, usable segment, unlike
anything ULB's anonymized features could give us.
In [3]:
# EDA 2 — the missingness landscape (structured, and informative)
groups = {"D (timedeltas)": [f"D{i}" for i in range(1,16)],
          "C (counts)":     [f"C{i}" for i in range(1,15)],
          "M (match flags)":[f"M{i}" for i in range(1,10)],
          "id_ (identity)": [c for c in df.columns if c.startswith("id_")]}
fig, ax = plt.subplots(1, 2, figsize=(13, 4))
miss = df[[c for g in groups.values() for c in g]].isna().mean()*100
allc = [c for g in groups.values() for c in g]
colmap = {c: col for g,col in zip(groups, ["#c53030","#2b6cb0","#2f855a","#dd6b20"]) for c in groups[g]}
ax[0].bar(range(len(allc)), miss.values, color=[colmap[c] for c in allc])
ax[0].set_xticks(range(len(allc))); ax[0].set_xticklabels(allc, rotation=90, fontsize=5)
ax[0].set_ylabel("% missing"); ax[0].set_title("Missingness by column (color = group)")

# is missingness itself predictive? fraud rate when D1 present vs missing, etc.
sig = {}
for c in ["D1","D2","D6","D11","DeviceType","id_01"]:
    if c in df: sig[c] = [df.loc[df[c].notna(),"isFraud"].mean()*100, df.loc[df[c].isna(),"isFraud"].mean()*100]
sg = pd.DataFrame(sig, index=["present","missing"]).T
sg.plot.bar(ax=ax[1], color=["#2b6cb0","#c53030"])
ax[1].axhline(100*F/N, color="grey", ls=":", label=f"overall {100*F/N:.2f}%")
ax[1].set_ylabel("fraud rate (%)"); ax[1].set_title("Missingness is informative (fraud rate present vs missing)")
ax[1].legend(fontsize=8); ax[1].tick_params(axis="x", labelrotation=0)
plt.tight_layout(); plt.show()
print("Whether a field is even present shifts the fraud rate — so missingness is signal, not just noise")
print("(we will keep NaNs as-is for the tree model in §5, which handles them natively).")
No description has been provided for this image
Whether a field is even present shifts the fraud rate — so missingness is signal, not just noise
(we will keep NaNs as-is for the tree model in §5, which handles them natively).
In [4]:
# EDA 3 — fraud through the 182-day window, and amount
fig, ax = plt.subplots(1, 2, figsize=(12, 3.8))
by_day = df.groupby("day")["isFraud"].mean()*100
ax[0].plot(by_day.index, by_day.values, color="#c53030", lw=0.9)
ax[0].axhline(100*F/N, color="grey", ls=":", label=f"overall {100*F/N:.2f}%")
ax[0].set_xlabel("day"); ax[0].set_ylabel("fraud rate (%)"); ax[0].set_title("Fraud rate over 182 days")
ax[0].legend(fontsize=8)
bins = np.logspace(0, np.log10(df.TransactionAmt.max()+1), 50)
ax[1].hist(df.loc[df.isFraud==0,"TransactionAmt"].clip(lower=1), bins=bins, density=True, alpha=0.6, color="#2b6cb0", label="legit")
ax[1].hist(df.loc[df.isFraud==1,"TransactionAmt"].clip(lower=1), bins=bins, density=True, alpha=0.6, color="#c53030", label="fraud")
ax[1].set_xscale("log"); ax[1].set_xlabel("amount ($, log)"); ax[1].set_ylabel("density")
ax[1].set_title("Transaction amount: fraud vs legit"); ax[1].legend(fontsize=8)
plt.tight_layout(); plt.show()

SPLIT_DAY = int(df.day.quantile(0.80))
print(f"time-ordered split at day {SPLIT_DAY}: train days {df.day.min()}-{SPLIT_DAY} "
      f"({int((df.day<=SPLIT_DAY).sum()):,} txns, {int(df.loc[df.day<=SPLIT_DAY,'isFraud'].sum())} fraud) | "
      f"test {SPLIT_DAY+1}-{df.day.max()} ({int((df.day>SPLIT_DAY).sum()):,} txns, "
      f"{int(df.loc[df.day>SPLIT_DAY,'isFraud'].sum())} fraud)")
No description has been provided for this image
time-ordered split at day 141: train days 1-141 (475,006 txns, 16721 fraud) | test 142-182 (115,534 txns, 3942 fraud)

What §1 establishes¶

  • IEEE-CIS is a real payments feed: a wide transaction table sparsely joined to identity, with structured missingness that is itself predictive, and fraud rates that vary sharply by segment (product C/S ≫ W) — genuine structure ULB's anonymized matrix could not offer.
  • Crucially, it retains entity keys (card*, addr*) and relative timedeltas (D*) — the raw material for the feature engineering that actually moves fraud models.
  • We evaluate with a time-ordered split over the 182-day window, the same out-of-time discipline as the ULB notebook.

Next: §2 confronts the D columns — why their relative framing makes them non-stationary, and how a one-line anchor transform turns them into stable, comparable features and, more importantly, the key that unlocks entity resolution in §3.

2 · The D-columns — from relative timedelta to fixed anchor¶

The D features are timedeltas measured relative to the transaction: D1 ≈ days since the card was first used, D15 ≈ days since a previous transaction, and so on. That relative framing is quietly toxic for an out-of-time model. For a card that stays active, D1 grows by one every day — so the raw column is non-stationary: its distribution drifts upward with calendar time, and a model trained on early months sees systematically different D1 values in later months (precisely the drift that degrades out-of-time performance).

The fix is one line. If day = TransactionDT / 86400, then

$$ D1_{\text{anchor}} \;=\; \text{day} \;-\; D1 $$

is the calendar day the card was first seen — a fixed attribute of the card, not of when we happened to observe it. Two things follow, and the second is the real prize:

  1. Stationarity. The anchor stops drifting, so it means the same thing in train and test.
  2. Entity resolution. Every transaction on the same card shares the same anchor. Combine it with a couple of entity keys (card1, addr1) and transactions cluster into the card that made them — the UID that §3 builds and §4 mines for behavioral history.
In [5]:
# --- build the anchor and show the raw D1 drifts while the anchor does not ----------------------------
df["D1_anchor"] = df["day"] - df["D1"]          # calendar day the card was first seen (fixed per card)
tr_mask = df["day"] <= SPLIT_DAY

fig, ax = plt.subplots(1, 2, figsize=(13, 4.2))
ax[0].plot(df.groupby("day")["D1"].mean(), color="#c53030", label="raw D1 (days-since-first-seen)")
ax[0].plot(df.groupby("day")["D1_anchor"].mean(), color="#2b6cb0", label="D1_anchor = day - D1")
ax[0].axvline(SPLIT_DAY, color="grey", ls=":", label="train/test split")
ax[0].set_xlabel("day"); ax[0].set_ylabel("mean value"); ax[0].set_title("Raw D1 drifts upward; the anchor is stable")
ax[0].legend(fontsize=8)

# train vs test distribution: raw D1 shifts, anchor overlaps
b1 = np.linspace(0, 640, 60)
ax[1].hist(df.loc[tr_mask,"D1"].dropna(), bins=b1, density=True, alpha=0.5, color="#2b6cb0", label="D1 train")
ax[1].hist(df.loc[~tr_mask,"D1"].dropna(), bins=b1, density=True, alpha=0.5, color="#c53030", label="D1 test")
ax[1].set_xlabel("raw D1 (days)"); ax[1].set_ylabel("density"); ax[1].set_title("Raw D1: train vs test distribution shift")
ax[1].legend(fontsize=8)
plt.tight_layout(); plt.show()

# quantify the drift with a simple mean shift
print(f"raw D1  mean: train {df.loc[tr_mask,'D1'].mean():.1f}  ->  test {df.loc[~tr_mask,'D1'].mean():.1f}  "
      f"(shift {df.loc[~tr_mask,'D1'].mean()-df.loc[tr_mask,'D1'].mean():+.1f} days)")
print("The raw feature literally means something different in the test period — the anchor removes that by")
print("re-expressing it as a fixed calendar date instead of a moving 'days ago'.")
No description has been provided for this image
raw D1  mean: train 90.8  ->  test 109.1  (shift +18.3 days)
The raw feature literally means something different in the test period — the anchor removes that by
re-expressing it as a fixed calendar date instead of a moving 'days ago'.
In [6]:
# --- the payoff: the anchor turns transactions into recognizable card entities -----------------------
# a lightweight UID from card identity + the anchor (full construction in §3)
uid0 = (df["card1"].astype("Int64").astype(str) + "_" + df["addr1"].astype("Int64").astype(str)
        + "_" + df["D1_anchor"].round().astype("Int64").astype(str))
uid0 = uid0.where(df[["card1","addr1","D1_anchor"]].notna().all(axis=1))     # only where keys present
sizes = uid0.value_counts()                       # already sorted descending
multi = sizes[sizes > 1]
n_txn_multi = int(uid0.isin(multi.index).sum())

# example: the busiest card entity, its repeated transactions across days
ex = multi.index[0]
cols_show = ["day","TransactionAmt","ProductCD","isFraud"]
exdf = df.loc[uid0==ex, cols_show].sort_values("day").head(8)

fig, ax = plt.subplots(1, 2, figsize=(13, 4))
ax[0].hist(sizes.values, bins=np.arange(1, 30), color="#2b6cb0", align="left")
ax[0].set_yscale("log"); ax[0].set_xlabel("transactions per UID (card1+addr1+anchor)")
ax[0].set_ylabel("number of UIDs (log)"); ax[0].set_title("Anchor + keys resolve repeat cards")
n_txn_multi = int((uid0.isin(multi.index)).sum())
ax[0].text(0.5, 0.9, f"{100*n_txn_multi/len(df):.0f}% of transactions belong\nto a UID seen >1 time",
           transform=ax[0].transAxes, fontsize=9, va="top")

ax[1].axis("off"); ax[1].text(0.0, 1.0, f"Example UID (one card): {ex}", fontsize=9, family="monospace",
                              va="top", transform=ax[1].transAxes)
ax[1].text(0.0, 0.86, exdf.to_string(index=False), fontsize=9, family="monospace", va="top",
           transform=ax[1].transAxes)
plt.tight_layout(); plt.show()

print(f"unique cards by card1 alone: {df['card1'].nunique():,}")
print(f"unique UIDs by card1+addr1+anchor: {sizes.shape[0]:,}  |  {100*n_txn_multi/len(df):.0f}% of txns are repeat-UID")
print("The anchor is what makes the repeats VISIBLE: the same card, transacting across many days, now falls")
print("into one group — the entity §3 formalizes and §4 turns into velocity / recency / history features.")
No description has been provided for this image
unique cards by card1 alone: 13,553
unique UIDs by card1+addr1+anchor: 199,070  |  69% of txns are repeat-UID
The anchor is what makes the repeats VISIBLE: the same card, transacting across many days, now falls
into one group — the entity §3 formalizes and §4 turns into velocity / recency / history features.

What §2 establishes¶

  • The D timedeltas are relative and non-stationary — raw D1 drifts measurably between the train and test periods, silently breaking an out-of-time model.
  • The anchor transform day − D1 re-expresses each as a fixed calendar date (the day the card was first seen): stationary, and identical for every transaction on that card.
  • That shared anchor, with card1/addr1, resolves repeat cards — a large share of transactions turn out to belong to a card seen more than once. Those repeats are exactly where behavioral signal lives.

Next: §3 turns this into a proper UID, choosing keys carefully (too coarse merges different cards; too fine splits one card), and measures how much of the data it stitches into entities.

3 · Building the UID — heuristic entity resolution¶

We have no ground-truth card identifier, so we construct one from fields that together should pin down a single card. This is a balancing act:

  • Too coarse (e.g. card1 alone) merges different cards that happen to share an issuer code — the "entity" becomes a crowd, and its aggregates are meaningless.
  • Too fine (e.g. every card field + several anchors) splits one card into many fragments whenever a nullable field flickers — and you lose the history you were trying to capture.

A well-tested middle ground for this dataset is card1 + addr1 + the D1 anchor — issuer/account code, billing region, and the fixed first-seen day. We validate the choice two ways: the UID should group transactions whose other card attributes (brand, card type) are consistent, and a useful UID should persist across the time split so a test-period transaction can inherit its card's earlier history.

In [7]:
# --- construct the UID and measure coverage ----------------------------------------------------------
def key(s): return s.astype("Int64").astype(str)
df["uid"] = (key(df["card1"]) + "_" + key(df["addr1"]) + "_" + df["D1_anchor"].round().astype("Int64").astype(str))
df.loc[df[["card1","addr1","D1_anchor"]].isna().any(axis=1), "uid"] = np.nan   # unresolved if any key missing

resolved = df["uid"].notna()
sizes = df["uid"].value_counts()
multi = sizes[sizes > 1].index
in_multi = df["uid"].isin(multi)
print(f"resolved (all keys present): {100*resolved.mean():.0f}% of transactions")
print(f"distinct UIDs: {sizes.shape[0]:,} | transactions in a repeat UID: {100*in_multi.mean():.0f}%")
print(f"UID size: median {int(sizes.median())}, 95th pct {int(sizes.quantile(.95))}, max {int(sizes.max())}")

# persistence across the train/test split: entities whose history a test txn could use
uid_tr = set(df.loc[df.day<=SPLIT_DAY, "uid"].dropna().unique())
test_res = df.loc[(df.day>SPLIT_DAY) & resolved]
seen_before = test_res["uid"].isin(uid_tr)
print(f"\nof resolved TEST transactions, {100*seen_before.mean():.0f}% are on a card already seen in TRAIN")
print(f"  fraud rate — card seen in train: {100*test_res.loc[seen_before,'isFraud'].mean():.2f}%  vs  "
      f"new card: {100*test_res.loc[~seen_before,'isFraud'].mean():.2f}%")
resolved (all keys present): 89% of transactions
distinct UIDs: 199,070 | transactions in a repeat UID: 69%
UID size: median 1, 95th pct 9, max 1414

of resolved TEST transactions, 46% are on a card already seen in TRAIN
  fraud rate — card seen in train: 1.59%  vs  new card: 2.87%
In [8]:
# --- validate the UID: do grouped transactions share consistent card attributes? ---------------------
g = df.loc[in_multi].groupby("uid")
consist = {c: (g[c].nunique(dropna=True) <= 1).mean()*100 for c in ["card4","card6","ProductCD","card3","card5"]}
fig, ax = plt.subplots(1, 2, figsize=(13, 4))
ax[0].bar(list(consist), list(consist.values()), color="#2f855a")
ax[0].set_ylabel("% of repeat-UIDs with a single value"); ax[0].set_ylim(0,105)
ax[0].set_title("UID validity: attribute consistency within an entity")
for i,(k,v) in enumerate(consist.items()): ax[0].text(i, v, f"{v:.0f}%", ha="center", va="bottom", fontsize=9)

# fraud concentration: cumulative fraud captured by the most fraud-heavy UIDs
fr_by_uid = df.loc[resolved].groupby("uid")["isFraud"].sum().sort_values(ascending=False)
cum = np.cumsum(fr_by_uid.values) / fr_by_uid.values.sum()
ax[1].plot(np.arange(1, len(cum)+1)/len(cum)*100, cum*100, color="#c53030")
ax[1].set_xlabel("% of UIDs (ranked by fraud count)"); ax[1].set_ylabel("% of all fraud captured")
ax[1].set_title("Fraud concentrates in a small set of card entities")
frac_uids_for_80 = 100*np.searchsorted(cum, 0.80)/len(cum)
ax[1].axhline(80, color="grey", ls=":"); ax[1].annotate(f"{frac_uids_for_80:.1f}% of UIDs hold 80% of fraud",
             (frac_uids_for_80, 80), fontsize=8, xytext=(frac_uids_for_80+10, 60),
             arrowprops=dict(arrowstyle="->"))
plt.tight_layout(); plt.show()
print("Card attributes are highly consistent within a UID (brand/type rarely vary) — evidence the UID")
print(f"really is grouping one card. And fraud is concentrated: ~{frac_uids_for_80:.0f}% of entities carry")
print("80% of all fraud — a strong signal that per-entity features (§4) should capture.")
No description has been provided for this image
Card attributes are highly consistent within a UID (brand/type rarely vary) — evidence the UID
really is grouping one card. And fraud is concentrated: ~1% of entities carry
80% of all fraud — a strong signal that per-entity features (§4) should capture.

What §3 establishes¶

  • A card1 + addr1 + D1-anchor UID resolves most transactions into card entities, and the choice is validated: within a UID, brand/type attributes are highly consistent (it really is one card), and a large fraction of test-period transactions land on a card already seen in training — so the model can carry that card's history forward.
  • Fraud concentrates in a small minority of entities, confirming that who is transacting carries strong signal — signal a per-transaction view cannot see.

Next: §4 turns each entity into features — velocity (how fast this card is transacting), recency (time since its last transaction), and history (its running count and amount profile) — the aggregates that actually move the model in §5.

4 · Behavioral aggregates — velocity, recency, history¶

With transactions grouped into card entities, we can finally ask the questions a fraud analyst asks: Is this card suddenly transacting fast? When did it last appear? Is this amount normal for it? These are per-entity aggregates, and there is one rule that makes or breaks them:

Leak-safe construction. Each transaction's features may use only that card's prior transactions — never future ones. We sort by time and use expanding / shifted aggregates within each UID. (Many public solutions use whole-UID aggregates that peek at the future; those inflate the leaderboard and collapse in production. We take the honest, causal version — the same out-of-time discipline as the ULB notebook.)

We build four families, all backward-looking:

  • History — uid_n_prior (how many times this card was seen before), uid_amt_prior_mean.
  • Recency — uid_days_since_prev (days since the card's previous transaction).
  • Deviation — amt_dev (this amount minus the card's prior mean): is the charge out of character?
In [9]:
# --- leak-safe per-UID aggregates (sorted by time; only PRIOR transactions of the same card) ---------
df = df.sort_values("TransactionDT").reset_index(drop=True)
g = df.groupby("uid")
df["uid_n_prior"]        = g.cumcount()                                   # 0 for a card's first txn
df["uid_amt_prior_sum"]  = g["TransactionAmt"].cumsum() - df["TransactionAmt"]
df["uid_amt_prior_mean"] = df["uid_amt_prior_sum"] / df["uid_n_prior"].where(df["uid_n_prior"] > 0)
df["uid_days_since_prev"]= df["day"] - g["day"].shift(1)                  # recency (NaN on first sighting)
df["amt_dev"]            = df["TransactionAmt"] - df["uid_amt_prior_mean"]# amount vs the card's own history
# rows with no UID (missing keys) keep NaN aggregates — fine for the tree model
df.loc[df["uid"].isna(), ["uid_n_prior","uid_amt_prior_mean","uid_days_since_prev","amt_dev"]] = np.nan
print("engineered features:", ["uid_n_prior","uid_amt_prior_mean","uid_days_since_prev","amt_dev"])
print(f"coverage (non-null uid_days_since_prev = repeat sightings): {100*df['uid_days_since_prev'].notna().mean():.0f}% of txns")
engineered features: ['uid_n_prior', 'uid_amt_prior_mean', 'uid_days_since_prev', 'amt_dev']
coverage (non-null uid_days_since_prev = repeat sightings): 55% of txns
In [10]:
# --- do these behavioral features separate fraud? ----------------------------------------------------
base = df["isFraud"].mean()*100
fig, ax = plt.subplots(1, 3, figsize=(14, 4))

# (1) history: fraud rate by number of prior sightings
nb_bucket = pd.cut(df["uid_n_prior"], [-1,0,1,2,5,10,1e9], labels=["0","1","2","3-5","6-10","10+"])
fr1 = df.groupby(nb_bucket, observed=True)["isFraud"].mean()*100
ax[0].bar(range(len(fr1)), fr1.values, color="#2b6cb0"); ax[0].set_xticks(range(len(fr1))); ax[0].set_xticklabels(fr1.index)
ax[0].axhline(base, color="grey", ls=":"); ax[0].set_xlabel("prior sightings of this card")
ax[0].set_ylabel("fraud rate (%)"); ax[0].set_title("History: fewer priors -> more fraud")

# (2) recency: fraud rate by days since previous txn
rb = pd.cut(df["uid_days_since_prev"], [-1,0,1,3,7,30,1e9], labels=["same day","1","2-3","4-7","8-30","30+"])
fr2 = df.groupby(rb, observed=True)["isFraud"].mean()*100
ax[1].bar(range(len(fr2)), fr2.values, color="#dd6b20"); ax[1].set_xticks(range(len(fr2))); ax[1].set_xticklabels(fr2.index, fontsize=8)
ax[1].axhline(base, color="grey", ls=":"); ax[1].set_xlabel("days since card's previous txn")
ax[1].set_ylabel("fraud rate (%)"); ax[1].set_title("Recency: bursts of activity")

# (3) deviation: fraud rate when the amount is out of character
dev_bucket = pd.qcut(df["amt_dev"].dropna(), 6, duplicates="drop")
fr3 = df.loc[df["amt_dev"].notna()].groupby(dev_bucket, observed=True)["isFraud"].mean()*100
ax[2].plot(range(len(fr3)), fr3.values, "o-", color="#c53030"); ax[2].axhline(base, color="grey", ls=":")
ax[2].set_xticks(range(len(fr3))); ax[2].set_xticklabels([f"{iv.mid:.0f}" for iv in fr3.index], rotation=45, fontsize=7)
ax[2].set_xlabel("amount - card's prior mean ($)"); ax[2].set_ylabel("fraud rate (%)")
ax[2].set_title("Deviation: charges out of character")
plt.tight_layout(); plt.show()

print(f"overall fraud rate {base:.2f}%. All three signals are leak-safe (prior transactions only):")
print(f"  history  — fraud RISES with prior sightings: {fr1.iloc[0]:.1f}% at first sighting -> {fr1.iloc[-1]:.1f}% at 10+ "
      f"(fraud arrives in bursts on a compromised card).")
print(f"  recency  — the sharpest signal: a repeat within ~1 day runs {fr2.max():.1f}% vs {fr2.min():.1f}% for a "
      f"long-dormant card (~{fr2.max()/fr2.min():.0f}x).")
print(f"  deviation— charges far from a card's own average, in EITHER direction, are elevated vs its norm.")
print("None of these is computable from a single transaction in isolation.")
No description has been provided for this image
overall fraud rate 3.50%. All three signals are leak-safe (prior transactions only):
  history  — fraud RISES with prior sightings: 2.0% at first sighting -> 3.8% at 10+ (fraud arrives in bursts on a compromised card).
  recency  — the sharpest signal: a repeat within ~1 day runs 6.1% vs 0.7% for a long-dormant card (~9x).
  deviation— charges far from a card's own average, in EITHER direction, are elevated vs its norm.
None of these is computable from a single transaction in isolation.

What §4 establishes¶

  • Three leak-safe per-entity feature families — history, recency, amount-deviation — each separates fraud strongly on its own, and the directions are instructive: fraud rises with a card's prior sightings (fraud arrives in bursts on a compromised card, ~2% → ~3.8%); recency is the sharpest signal, with a repeat within a day running ~9× the fraud rate of a long-dormant card; and charges out of character for a card (unusually large or small) are elevated. (This complements §3's card-level finding — that cards predating the test window are lower-risk — because sequence position within a card and a card's age across the window capture different things.)
  • These features exist only because §2's anchor and §3's UID reconstructed the entity — none can be computed from a single transaction in isolation, and none exist in the anonymized ULB data.

Next: §5 puts it to the test — a gradient-boosted model on the raw fields versus the same model plus these engineered features, evaluated out-of-time (PR-AUC), with SHAP to show exactly which features earn their keep. This is where "feature engineering beats architecture" gets measured.

5 · The lift, measured — raw vs engineered, with SHAP¶

Now the payoff test — with an honest twist. IEEE-CIS's raw fields already include Vesta's own engineered aggregates: the C1–C14 counting features and the D timedeltas are exactly the kind of per-entity signals we rebuilt in §2–§4. So we measure the lift of our features two ways: on the full raw data (where they must compete with the vendor's aggregates) and on a minimal baseline stripped of those C/D columns (where our features stand in for them). The contrast isolates what the technique is worth versus what is already baked in — and SHAP (exact TreeSHAP via LightGBM) shows which features the model actually leans on.

In [11]:
import lightgbm as lgb
from sklearn.metrics import average_precision_score, roc_auc_score

ENG = ["D1_anchor", "uid_n_prior", "uid_amt_prior_mean", "uid_days_since_prev", "amt_dev"]
DROP = {"TransactionID","isFraud","TransactionDT","day","uid","uid_amt_prior_sum", *ENG}
RAW = [c for c in df.columns if c not in DROP]
VENDOR = [c for c in RAW if len(c)>1 and c[0] in "CD" and c[1:].isdigit()]   # C1-14, D1-15 = Vesta's pre-built aggregates
MINIMAL = [c for c in RAW if c not in VENDOR]                                 # strip them to expose our features' value

X = df.copy()
for c in X.columns:
    if X[c].dtype == "object": X[c] = X[c].astype("category")
tr = (df.day <= SPLIT_DAY).values; te = (df.day > SPLIT_DAY).values; y = df["isFraud"].values

def fit_eval(feats, seed=0):
    catf = [c for c in feats if str(X[c].dtype) == "category"]
    m = lgb.LGBMClassifier(n_estimators=300, learning_rate=0.05, num_leaves=64, min_child_samples=50,
                           subsample=0.8, colsample_bytree=0.8, reg_lambda=5.0, random_state=seed, verbose=-1)
    m.fit(X.loc[tr, feats], y[tr], categorical_feature=catf)
    s = m.predict_proba(X.loc[te, feats])[:, 1]
    return m, average_precision_score(y[te], s), roc_auc_score(y[te], s)

runs = {"minimal (no vendor C/D)": MINIMAL, "minimal + ours": MINIMAL+ENG,
        "full raw (with vendor C/D)": RAW, "full raw + ours": RAW+ENG}
res = {}
for name, feats in runs.items():
    m, ap, auc = fit_eval(feats); res[name] = (m, ap, auc)
    print(f"  {name:28s} PR-AUC {ap:.4f}  ROC {auc:.4f}  ({len(feats)} feats)")
m_eng = res["full raw + ours"][0]

lift_min  = res["minimal + ours"][1]  - res["minimal (no vendor C/D)"][1]
lift_full = res["full raw + ours"][1] - res["full raw (with vendor C/D)"][1]
print(f"\nlift of our features on MINIMAL data:  {lift_min:+.4f} PR-AUC  (they stand in for the missing aggregates)")
print(f"lift of our features on FULL raw data: {lift_full:+.4f} PR-AUC  (mostly redundant with Vesta's C/D)")

fig, ax = plt.subplots(figsize=(9, 4))
names = list(runs); aps = [res[n][1] for n in names]
ax.bar(names, aps, color=["#a0aec0","#2f855a","#2b6cb0","#c53030"])
for i,v in enumerate(aps): ax.text(i, v, f"{v:.3f}", ha="center", va="bottom", fontsize=9)
ax.set_ylabel("out-of-time PR-AUC"); ax.set_title("Feature-engineering lift, isolated by baseline")
ax.tick_params(axis="x", labelsize=8, labelrotation=10)
plt.tight_layout(); plt.show()
  minimal (no vendor C/D)      PR-AUC 0.3202  ROC 0.8376  (59 feats)
  minimal + ours               PR-AUC 0.3505  ROC 0.8661  (64 feats)
  full raw (with vendor C/D)   PR-AUC 0.5375  ROC 0.9106  (88 feats)
  full raw + ours              PR-AUC 0.5397  ROC 0.9113  (93 feats)

lift of our features on MINIMAL data:  +0.0303 PR-AUC  (they stand in for the missing aggregates)
lift of our features on FULL raw data: +0.0022 PR-AUC  (mostly redundant with Vesta's C/D)
No description has been provided for this image
In [12]:
# --- SHAP (TreeSHAP, exact via LightGBM pred_contrib): which features drive the model? ----------------
rng = np.random.default_rng(0)
samp = rng.choice(np.where(te)[0], size=min(20000, int(te.sum())), replace=False)
feats = RAW + ENG
contrib = m_eng.booster_.predict(X.iloc[samp][feats], pred_contrib=True)   # (n, n_feat+1); last col = base
shap_vals = contrib[:, :-1]
imp = pd.Series(np.abs(shap_vals).mean(0), index=feats).sort_values(ascending=False)
top = imp.head(16)

fig, ax = plt.subplots(1, 2, figsize=(13, 5))
colors = ["#c53030" if f in ENG else "#2b6cb0" for f in top.index[::-1]]
ax[0].barh(range(len(top)), top.values[::-1], color=colors)
ax[0].set_yticks(range(len(top))); ax[0].set_yticklabels(top.index[::-1], fontsize=8)
ax[0].set_xlabel("mean |SHAP| (impact on log-odds)"); ax[0].set_title("Top features by SHAP (red = engineered)")

# SHAP dependence for the top engineered feature: how its value drives risk
best_eng = [f for f in imp.index if f in ENG][0]
j = feats.index(best_eng); xv = X.iloc[samp][best_eng].astype(float).values
ax[1].scatter(xv, shap_vals[:, j], s=5, alpha=0.2, color="#c53030")
ax[1].axhline(0, color="grey", lw=0.8); ax[1].set_xlabel(f"{best_eng} (value)")
ax[1].set_ylabel("SHAP value (→ pushes toward fraud)"); ax[1].set_title(f"SHAP dependence: {best_eng}")
if best_eng in ("amt_dev","uid_amt_prior_mean"): ax[1].set_xscale("symlog")
plt.tight_layout(); plt.show()

eng_ranks = {f: list(imp.index).index(f)+1 for f in ENG}
print(f"engineered feature SHAP ranks (of {len(imp)}): {eng_ranks}")
print(f"D1_anchor ranks #{eng_ranks['D1_anchor']} even with raw D1 present — the model reaches for the anchor")
print("transform directly. The UID aggregates rank lower HERE only because Vesta's C/D columns already encode")
print("similar entity signal (hence the small full-data lift); strip those and our features carry the load.")
No description has been provided for this image
engineered feature SHAP ranks (of 93): {'D1_anchor': 2, 'uid_n_prior': 23, 'uid_amt_prior_mean': 39, 'uid_days_since_prev': 46, 'amt_dev': 52}
D1_anchor ranks #2 even with raw D1 present — the model reaches for the anchor
transform directly. The UID aggregates rank lower HERE only because Vesta's C/D columns already encode
similar entity signal (hence the small full-data lift); strip those and our features carry the load.

What §5 establishes¶

  • Where the raw feed lacks pre-built aggregates (the minimal baseline — the situation on most real transaction feeds), the §2–§4 entity features add a substantial ~+0.03 PR-AUC (~+9%): direct evidence the anchor → UID → behavioral-aggregate technique captures real predictive structure.
  • On the full IEEE-CIS raw data the same features add only ~+0.4% — because Vesta already ships equivalent entity aggregates (the C/D columns). An honest result worth stating plainly: you cannot beat features that are already there.
  • SHAP corroborates the technique regardless of that redundancy: D1_anchor ranks #2 of 93 — the model reaches for the anchor transform directly even with raw D1 available. The UID aggregates rank lower here only because the vendor's C/D columns already carry that signal.
  • The lesson is not "always re-engineer," but that entity-behavior signal is what moves fraud models — vendor-supplied or self-built. On a raw feed you must build it yourself, and this is how.

Next: we run this engineered score through the full decision cycle on real data — calibration (§6), a cost-sensitive amount-varying decision (§7), and uncertainty triage with a dollar ladder (§8) — the same stack the companion ULB notebook applies, but here on realistic amounts and segments.

6 · Completing the cycle — calibrating the engineered score on real segments¶

The companion ULB notebook built the decision layer on anonymized data; here we run it on the realistic IEEE-CIS score from §5, and one thing genuinely improves: the segments are real. ULB had to pool calibration over amount buckets because its features were anonymized; IEEE-CIS gives us honest business segments — ProductCD × card type × device — so the hierarchical calibrator can pool where it actually makes sense. We test whether that pooling helps here (it merely tied isotonic on ULB).

To calibrate without leakage we use a 3-way time split: train the score on days 1–120, fit the calibrator on days 121–141, and evaluate on days 142–182. (See the ULB notebook for the calibration and metric definitions; here we focus on what's new — real segments and, in §7–§8, real amounts.)

In [13]:
import subprocess, sys
from sklearn.isotonic import IsotonicRegression
from sklearn.calibration import calibration_curve
from sklearn.metrics import brier_score_loss

# 3-way time split; refit the engineered score leak-safely on train only
D_TR, D_CA = 120, 141
trn = (df.day <= D_TR).values; cal = ((df.day > D_TR) & (df.day <= D_CA)).values; tst = (df.day > D_CA).values
feats = RAW + ENG; catf = [c for c in feats if str(X[c].dtype) == "category"]
gbm7 = lgb.LGBMClassifier(n_estimators=300, learning_rate=0.05, num_leaves=64, min_child_samples=50,
                          subsample=0.8, colsample_bytree=0.8, reg_lambda=5.0, verbose=-1)
gbm7.fit(X.loc[trn, feats], y[trn], categorical_feature=catf)
s_ca = gbm7.predict_proba(X.loc[cal, feats])[:, 1]; s_te = gbm7.predict_proba(X.loc[tst, feats])[:, 1]
yca, yte = y[cal], y[tst]
amt_ca = df.loc[cal, "TransactionAmt"].values; amt_te = df.loc[tst, "TransactionAmt"].values
clip = lambda s: np.clip(s, 1e-6, 1-1e-6); z_ca = np.log(clip(s_ca)/(1-clip(s_ca))); z_te = np.log(clip(s_te)/(1-clip(s_te)))

# REAL segments: ProductCD x card type x device
segkey = (df["ProductCD"].astype(str)+"|"+df["card6"].astype(str)+"|"+df["DeviceType"].astype(str))
seg_all, uniq = pd.factorize(segkey); df["seg"] = seg_all; G = len(uniq)
seg_ca = df.loc[cal, "seg"].values; seg_te = df.loc[tst, "seg"].values
print(f"3-way split — train {trn.sum():,} / calib {cal.sum():,} ({yca.sum()} fraud) / test {tst.sum():,} ({yte.sum()} fraud)")
print(f"real segments (ProductCD x card6 x DeviceType): {G}")

def ece(yv, p, nb=10):
    q = np.quantile(p, np.linspace(0,1,nb+1)); q[0]-=1e-9; b = np.digitize(p, q[1:-1]); e=0.0
    for k in range(nb):
        m=b==k
        if m.sum(): e += abs(p[m].mean()-yv[m].mean())*m.sum()
    return e/len(yv)

iso = IsotonicRegression(out_of_bounds="clip").fit(s_ca, yca); p_iso = iso.transform(s_te)
np.savez("calib_input.npz", z_ca=z_ca, seg_ca=seg_ca, yca=yca, G=G)   # reuse the ULB companion fitter
r = subprocess.run([sys.executable, "calib_fit.py"], capture_output=True, text=True)
print(r.stdout.strip() or r.stderr[-300:])
P = np.load("calib_post.npz"); a_d, b_d = P["a"], P["b"]
p_draws = 1/(1+np.exp(-(a_d[:, seg_te] + b_d[:, seg_te]*z_te[None,:])))
p_bayes = p_draws.mean(0); p_lo, p_hi = np.percentile(p_draws,5,0), np.percentile(p_draws,95,0)

print("\nCalibration on TEST (x1e-3, lower=better):")
for nm,p in [("raw score", s_te), ("isotonic", p_iso), ("Bayes-hier (real segments)", p_bayes)]:
    print(f"  {nm:28s} Brier {brier_score_loss(yte, np.clip(p,0,1))*1e3:.3f}   ECE {ece(yte, np.clip(p,0,1))*1e3:.3f}")
3-way split — train 414,542 / calib 60,464 (2121 fraud) / test 115,534 (3942 fraud)
real segments (ProductCD x card6 x DeviceType): 39
calib_post.npz saved | final ELBO loss 4911.52001953125
Calibration on TEST (x1e-3, lower=better):
  raw score                    Brier 22.566   ECE 3.039
  isotonic                     Brier 22.765   ECE 4.081
  Bayes-hier (real segments)   Brier 22.861   ECE 3.587
In [14]:
# reliability + does pooling help the THIN segments this time?
fig, ax = plt.subplots(1, 2, figsize=(13, 4.2))
ax[0].plot([0,1],[0,1],"k:",label="perfect")
for nm,p,col in [("raw",s_te,"#a0aec0"),("isotonic",p_iso,"#2f855a"),("Bayes-hier",p_bayes,"#2b6cb0")]:
    fp,mp = calibration_curve(yte, np.clip(p,0,1), n_bins=8, strategy="quantile"); ax[0].plot(mp,fp,"o-",color=col,label=nm)
ax[0].set_xlabel("predicted P(fraud)"); ax[0].set_ylabel("observed"); ax[0].set_title("Reliability (real-segment calibration)")
ax[0].legend(fontsize=8)

fps = pd.Series(yca).groupby(seg_ca).sum()
thin = set(fps[fps<=5].index); mask = np.isin(seg_te, list(thin))
bars = {"isotonic": ece(yte[mask], np.clip(p_iso[mask],0,1))*1e3, "Bayes-hier": ece(yte[mask], np.clip(p_bayes[mask],0,1))*1e3}
ax[1].bar(list(bars), list(bars.values()), color=["#2f855a","#2b6cb0"])
ax[1].set_ylabel("ECE on thin segments (x1e-3)"); ax[1].set_title(f"Thin segments (<=5 calib frauds): {mask.sum():,} test txns")
for i,v in enumerate(bars.values()): ax[1].text(i,v,f"{v:.3f}",ha="center",va="bottom",fontsize=9)
plt.tight_layout(); plt.show()
print(f"thin segments: {len(thin)} of {G}, {mask.sum():,} test txns, {int(yte[mask].sum())} frauds")
print("Whether the hierarchy beats isotonic on these real, data-poorer segments is the honest question ULB")
print("could not pose (its features were anonymized) — the bars above answer it for this data.")
No description has been provided for this image
thin segments: 8 of 39, 1,257 test txns, 49 frauds
Whether the hierarchy beats isotonic on these real, data-poorer segments is the honest question ULB
could not pose (its features were anonymized) — the bars above answer it for this data.

What §6 establishes¶

  • The §5 engineered score can be calibrated on real IEEE-CIS segments (ProductCD × card × device) — the business-meaningful grouping ULB's anonymized data denied us — with the same three calibrators, and the outcome is reported honestly (Brier/ECE, plus the thin-segment comparison the bars show).
  • Either way, we now hold a calibrated posterior p_bayes with a credible interval p_lo–p_hi on the realistic score — the object §7 turns into a cost-sensitive decision and §8 into triage and a dollar ladder, this time with real transaction amounts.

7 · Cost-sensitive decision — on real amounts¶

Same decision theory as the ULB notebook (see there for the derivation), but now the amounts are real dollars. Approving a fraud costs roughly the transaction amount plus a fixed chargeback fee, $C_{FN}(\text{amount}) = \text{amount} + k$; a false decline costs a fixed friction $C_{FP}$. The Bayes-optimal, amount-varying threshold is $\tau^{*}(\text{amount}) = C_{FP}/(C_{FP}+C_{FN}(\text{amount}))$ — it falls as the stake rises. The decision uses the posterior mean (costs are linear in $p$); the interval is for §8's triage.

In [15]:
C_FP, K_FRAUD = 10.0, 10.0
CFN = lambda a: a + K_FRAUD
tau_star = lambda a: C_FP / (C_FP + CFN(a))
def rcost(flag, yv, amt): return float((flag*(1-yv)*C_FP + (1-flag)*yv*CFN(amt)).sum())

# calibrated posterior mean on calib (to tune the global-threshold baseline without leakage)
p_ca = (1/(1+np.exp(-(a_d[:, seg_ca] + b_d[:, seg_ca]*z_ca[None,:])))).mean(0)
grid = np.linspace(0.001, 0.9, 400)
tau_g = grid[np.argmin([rcost((p_ca>t).astype(int), yca, amt_ca) for t in grid])]

policies = {"flat 0.50": (p_bayes>0.5).astype(int),
            f"global tau={tau_g:.3f}": (p_bayes>tau_g).astype(int),
            "amount-varying tau*": (p_bayes>tau_star(amt_te)).astype(int)}
print(f"cost model: false decline ${C_FP:.0f}; approved fraud = amount + ${K_FRAUD:.0f}\n")
print(f"{'policy':22s}{'test $ cost':>12}{'fraud $ exposed':>16}{'caught':>9}{'false decl':>12}")
for nm, flag in policies.items():
    caught=int(((flag==1)&(yte==1)).sum()); fd=int(((flag==1)&(yte==0)).sum())
    exp=float(((1-flag)*yte*amt_te).sum())
    print(f"{nm:22s}{rcost(flag,yte,amt_te):>12,.0f}{exp:>16,.0f}{caught:>6}/{int(yte.sum())}{fd:>12,}")
cost model: false decline $10; approved fraud = amount + $10

policy                 test $ cost fraud $ exposed   caught  false decl
flat 0.50                  444,385         413,155  1372/3942         553
global tau=0.048           265,395         140,035  2934/3942      11,528
amount-varying tau*        218,217         111,497  2540/3942       9,270
In [16]:
# visualize the amount-varying threshold and the decision regions
fig, ax = plt.subplots(1, 2, figsize=(13, 4.4))
ag = np.logspace(0, np.log10(amt_te.max()+1), 200)
ax[0].plot(ag, tau_star(ag), color="#2b6cb0", lw=2, label=r"$\tau^*$(amount)")
ax[0].axhline(0.5, color="#c53030", ls="--", label="flat 0.50"); ax[0].axhline(tau_g, color="#dd6b20", ls=":", label=f"global {tau_g:.3f}")
ax[0].set_xscale("log"); ax[0].set_yscale("log"); ax[0].set_xlabel("amount ($, log)")
ax[0].set_ylabel("decline if P(fraud) >"); ax[0].set_title("The bar to decline falls as the stake rises"); ax[0].legend(fontsize=8)

rng = np.random.default_rng(0)
leg = np.where(yte==0)[0]; samp = rng.choice(leg, 5000, replace=False); idx = np.concatenate([np.where(yte==1)[0], samp])
ax[1].scatter(amt_te[idx][yte[idx]==0], p_bayes[idx][yte[idx]==0], s=5, alpha=0.2, color="#a0aec0", label="legit")
ax[1].scatter(amt_te[idx][yte[idx]==1], p_bayes[idx][yte[idx]==1], s=14, color="#c53030", label="fraud")
ax[1].plot(ag, tau_star(ag), color="#2b6cb0", lw=2, label=r"$\tau^*$ — decline above")
ax[1].set_xscale("log"); ax[1].set_yscale("log"); ax[1].set_xlabel("amount ($, log)"); ax[1].set_ylabel("calibrated P(fraud)")
ax[1].set_title("Decision regions"); ax[1].legend(fontsize=8)
plt.tight_layout(); plt.show()
c05=rcost(policies["flat 0.50"],yte,amt_te); cg=rcost(policies[f"global tau={tau_g:.3f}"],yte,amt_te)
print(f"cost-driven thresholding vs the 0.5 default: ${c05:,.0f} -> ${cg:,.0f} ({100*(c05-cg)/c05:.0f}% lower) on the same score.")
print("As in the ULB notebook, tau*(amount) is the provably expected-cost-optimal per-transaction rule; the")
print("amount here is real dollars, so its stake-dependent bar is fully authentic.")
No description has been provided for this image
cost-driven thresholding vs the 0.5 default: $444,385 -> $265,395 (40% lower) on the same score.
As in the ULB notebook, tau*(amount) is the provably expected-cost-optimal per-transaction rule; the
amount here is real dollars, so its stake-dependent bar is fully authentic.

What §7 establishes¶

  • The calibrated score becomes a cost-aware decision with an amount-varying Bayes-optimal threshold — now on genuine transaction amounts. Cost-driven thresholding beats the 0.5 default substantially, and $\tau^{*}(\text{amount})$ flags big-ticket risk hardest (lowest fraud-dollars exposed), exactly as the ULB notebook showed on anonymized amounts.

Next: §8 adds the credible interval back in — routing the uncertain cases to review — and totals the whole pipeline on a dollar ladder.

8 · Uncertainty triage and the dollar ladder¶

Finally the credible interval earns its keep. As in the ULB notebook, we route by whether the 90% interval sits clear of the amount-varying threshold: auto-decline if the whole interval is above $\tau^{*}$, auto-approve if entirely below, route to review if it straddles. Then we total the realized out-of-time dollars from doing nothing, to the naive classifier, to the cost-tuned decision, to the uncertainty-triaged system.

In [17]:
C_REV = 3.0
tau_te = tau_star(amt_te)
auto_decline = p_lo > tau_te; auto_approve = p_hi < tau_te; review = ~(auto_decline | auto_approve)
for nm, m in [("auto-approve", auto_approve), ("REVIEW", review), ("auto-decline", auto_decline)]:
    print(f"  {nm:13s} {int(m.sum()):>7,} ({100*m.mean():5.2f}%)  containing {int(yte[m].sum()):>4} of {int(yte.sum())} frauds")
cost_triage = (float((auto_decline&(yte==0)).sum())*C_FP + float(((auto_approve&(yte==1))*CFN(amt_te)).sum())
               + float(review.sum())*C_REV)
print(f"\nreview queue {int(review.sum()):,} ({100*review.mean():.2f}%), surfacing {int(yte[review].sum())} frauds; "
      f"triage cost ${cost_triage:,.0f} (review @ ${C_REV:.0f}/case)")
  auto-approve  101,921 (88.22%)  containing 1280 of 3942 frauds
  REVIEW          3,314 ( 2.87%)  containing  235 of 3942 frauds
  auto-decline   10,299 ( 8.91%)  containing 2427 of 3942 frauds

review queue 3,314 (2.87%), surfacing 235 frauds; triage cost $198,030 (review @ $3/case)
In [18]:
# the dollar ladder, decomposed
flag_g = policies[f"global tau={tau_g:.3f}"]
def decomp(appr_fraud, decl_legit, n_rev=0):
    return (float((appr_fraud*CFN(amt_te)).sum()), float(decl_legit.sum())*C_FP, float(n_rev)*C_REV)
ladder = {"approve all\n(no model)": decomp(yte==1, np.zeros(len(yte),bool)),
          "model +\nflat 0.5":      decomp((policies['flat 0.50']==0)&(yte==1), (policies['flat 0.50']==1)&(yte==0)),
          "model +\ncost threshold":decomp((flag_g==0)&(yte==1), (flag_g==1)&(yte==0)),
          "+ uncertainty\ntriage":  decomp(auto_approve&(yte==1), auto_decline&(yte==0), review.sum())}
tot = {k: sum(v) for k,v in ladder.items()}
labels=list(ladder); miss=[ladder[k][0] for k in labels]; fdc=[ladder[k][1] for k in labels]; rev=[ladder[k][2] for k in labels]
fig, ax = plt.subplots(figsize=(10, 4.6))
ax.bar(labels, miss, color="#c53030", label="missed-fraud loss")
ax.bar(labels, fdc, bottom=miss, color="#dd6b20", label="false-decline friction")
ax.bar(labels, rev, bottom=np.array(miss)+np.array(fdc), color="#2b6cb0", label="analyst review")
for i,k in enumerate(labels): ax.text(i, tot[k], f"${tot[k]:,.0f}", ha="center", va="bottom", fontsize=9, fontweight="bold")
ax.set_ylabel("realized test cost ($)"); ax.set_title("Dollar ladder on the engineered IEEE-CIS score (out-of-time)"); ax.legend(fontsize=9)
plt.tight_layout(); plt.show()
base=tot["approve all\n(no model)"]; final=tot["+ uncertainty\ntriage"]; naive=tot["model +\nflat 0.5"]
print("realized out-of-time test cost by system:")
for k in labels: print(f"  {k.replace(chr(10),' '):26s} ${tot[k]:>10,.0f}")
print(f"\nno-model -> full system: ${base:,.0f} -> ${final:,.0f} ({100*(base-final)/base:.0f}% lower); "
      f"naive-0.5 -> full: {100*(naive-final)/naive:.0f}% lower, from the decision layer on top of the engineered score.")
No description has been provided for this image
realized out-of-time test cost by system:
  approve all (no model)     $   626,072
  model + flat 0.5           $   444,385
  model + cost threshold     $   265,394
  + uncertainty triage       $   198,030

no-model -> full system: $626,072 -> $198,030 (68% lower); naive-0.5 -> full: 55% lower, from the decision layer on top of the engineered score.

What §8 establishes¶

  • The full decision cycle now runs end-to-end on the engineered, realistic score: calibration → amount-varying cost decision → uncertainty triage, with the same dollar ladder as the ULB notebook — here on real transaction amounts. Cost shifts out of missed-fraud losses and needless declines into a small analyst-review queue, and the realized out-of-time cost falls markedly from the naive baseline.
  • The value comes from the decision layer stacked on the engineered score — feature engineering (§2–§5) and decision-making (§6–§8) each doing their half of the job.

9 · The two-notebook system, and what it demonstrates¶

Together the two notebooks form an end-to-end fraud system that mirrors how a real institution operates — and this notebook now carries it the whole way itself:

   FEATURE ENGINEERING (§1-§5)                DECISION LAYER (§6-§8)
   ─────────────────────────────             ──────────────────────────────
   raw transactions                          calibrated posterior P(fraud)
     → D-anchor normalization        ─┐        → cost-sensitive amount-varying threshold
     → UID / entity resolution        ├─►       → uncertainty triage (auto / review)
     → behavioral aggregates          │         → realized-dollar ladder
     → gradient-boosted SCORE        ─┘

What the pair demonstrates¶

  • Feature engineering is where fraud models are won (§2–§5): turn relative signals into stable, entity-anchored ones, then compute per-entity velocity, recency, history. Measured honestly, the technique adds real lift wherever those aggregates aren't already supplied (≈ +9% PR-AUC on a raw feed).
  • A score is not a decision (§6–§8): calibration, cost-asymmetry, and quantified uncertainty convert it into an auditable, dollar-optimal, human-in-the-loop action — the part that speaks to model-risk governance (SR 11-7) and credit-risk decisioning.
  • Throughout, the same discipline as the whole Operations Research series: out-of-time validation, leak-safe features, PR-AUC over ROC, and — the recurring theme — judging the work by the decision (and the dollars) it enables, not by a leaderboard metric. And the honest refrain seen across the Energy and ULB notebooks recurs here too: the Bayesian/fancy step tends to match a strong simple baseline on the headline metric; its enduring value is the uncertainty it supplies to the decision.

Honest limitations & next steps¶

  • One heuristic UID (card1+addr1+D1-anchor); production blends several. Identity/device is under-used, and IEEE-CIS's own aggregates cap the measured incremental lift — a rawer feed would show more.
  • The cost model is illustrative; a real deployment would calibrate C_FP, k, and the review cost to the business and monitor calibration drift over time (the SR 11-7 loop).