Spaces:
Configuration error
Configuration error
File size: 6,664 Bytes
942b115 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 | """Shared, leakage-safe feature engineering (FEAT-01..04).
This module is the single source of truth for turning a raw PaySim-shaped
transaction (or batch of transactions) into the model's feature vector. It
is imported by both `training/train.py` (batch, DataFrame-based) and the
future Phase 3 scoring service (single-transaction), so feature logic never
drifts between train and serve.
Design notes:
- Raw balance columns (`oldbalanceOrg`, `newbalanceOrig`, `oldbalanceDest`,
`newbalanceDest`) are never fed to the model directly -- only ratios and
consistency flags derived from them (FEAT-03), because fraudulent
TRANSFER/CASH_OUT rows trivially zero `newbalanceOrig`, which would let a
model "cheat" on a feature that is actually just restating the label.
- `isFlaggedFraud` is never referenced here -- EDA showed it has ~0.19%
recall against `isFraud` and is a simulation artifact, not a fraud
signal (see reports/EDA_REPORT.md section 5).
- Per-account velocity features are computed as *prior-only* aggregates
(expanding count/sum strictly before the current row for that account),
so a transaction never sees its own future -- this is what keeps
`engineer_features` safe to use directly ahead of a step-based
train/test split (MODEL-03).
"""
from __future__ import annotations
import pandas as pd
EPSILON = 1.0 # avoids divide-by-zero without materially distorting ratios
TRANSACTION_TYPES = ["CASH_IN", "CASH_OUT", "DEBIT", "PAYMENT", "TRANSFER"]
# Final feature columns handed to every model. Keeping this list explicit
# (rather than "everything numeric in the frame") means adding a new
# intermediate column to `engineer_features` never silently changes the
# model's input shape.
FEATURE_COLUMNS = [
"amount",
"amount_to_oldbalanceOrg_ratio",
"amount_to_oldbalanceDest_ratio",
"orig_balance_delta_ratio",
"dest_balance_delta_ratio",
"orig_balance_consistent",
"dest_balance_consistent",
"orig_zero_after_flag",
"dest_zero_stays_zero_flag",
"dest_is_merchant",
"orig_equals_dest",
"orig_prior_txn_count",
"orig_prior_txn_amount_mean",
"dest_prior_txn_count",
] + [f"type_{t}" for t in TRANSACTION_TYPES]
# PaySim generates fraud rows such that amount == oldbalanceOrg (and thus
# newbalanceOrig == 0) in ~98% of fraud cases, but *never* exactly for
# legitimate TRANSFER/CASH_OUT rows -- a simulation artifact, not a
# real-world fraud behavior. `amount_to_oldbalanceOrg_ratio` (~1.0 for
# fraud) and `orig_balance_delta_ratio` (mathematically the same relationship
# restated) let a tree model memorize this exact-equality quirk rather than
# learn a generalizable pattern, which is what drove PR-AUC to a suspicious
# 1.0000 in the full feature set. FEATURE_COLUMNS_CONSERVATIVE excludes both
# so the comparison can show how much of the full set's performance is real
# signal versus this artifact.
LEAKAGE_PRONE_COLUMNS = ["amount_to_oldbalanceOrg_ratio", "orig_balance_delta_ratio"]
FEATURE_COLUMNS_CONSERVATIVE = [
c for c in FEATURE_COLUMNS if c not in LEAKAGE_PRONE_COLUMNS
]
def _encode_type(df: pd.DataFrame) -> pd.DataFrame:
"""FEAT-02: one-hot encode transaction type against a fixed vocabulary.
A fixed vocabulary (rather than `pd.get_dummies` alone) guarantees the
same columns exist at scoring time even if a single transaction can
only ever be one type.
"""
for t in TRANSACTION_TYPES:
df[f"type_{t}"] = (df["type"] == t).astype("int8")
return df
def _balance_ratio_features(df: pd.DataFrame) -> pd.DataFrame:
"""FEAT-01 / FEAT-03: ratios and consistency flags, never raw balances."""
old_orig = df["oldbalanceOrg"]
new_orig = df["newbalanceOrig"]
old_dest = df["oldbalanceDest"]
new_dest = df["newbalanceDest"]
amount = df["amount"]
df["amount_to_oldbalanceOrg_ratio"] = amount / (old_orig + EPSILON)
df["amount_to_oldbalanceDest_ratio"] = amount / (old_dest + EPSILON)
df["orig_balance_delta_ratio"] = (old_orig - new_orig) / (old_orig + EPSILON)
df["dest_balance_delta_ratio"] = (new_dest - old_dest) / (old_dest + EPSILON)
df["orig_balance_consistent"] = (
(old_orig - amount - new_orig).abs() < 0.01
).astype("int8")
df["dest_balance_consistent"] = (
(old_dest + amount - new_dest).abs() < 0.01
).astype("int8")
df["orig_zero_after_flag"] = ((old_orig > 0) & (new_orig == 0)).astype("int8")
df["dest_zero_stays_zero_flag"] = (
(old_dest == 0) & (new_dest == 0) & (amount > 0)
).astype("int8")
return df
def _origin_dest_mismatch_features(df: pd.DataFrame) -> pd.DataFrame:
"""FEAT-01: origin/destination mismatch patterns."""
df["dest_is_merchant"] = df["nameDest"].str.startswith("M").astype("int8")
df["orig_equals_dest"] = (df["nameOrig"] == df["nameDest"]).astype("int8")
return df
def _velocity_features(df: pd.DataFrame) -> pd.DataFrame:
"""FEAT-01: per-account transaction velocity, computed leakage-safe.
`df` must already be sorted ascending by `step` (the caller's
responsibility -- `engineer_features` enforces it). Each account's
velocity aggregates use `groupby(...).cumcount()` / `.cumsum().shift()`,
which by construction only ever reflect that account's *earlier* rows
in step order, never the current or future ones.
"""
orig_group = df.groupby("nameOrig")["amount"]
df["orig_prior_txn_count"] = orig_group.cumcount()
prior_amount_sum = orig_group.cumsum() - df["amount"]
df["orig_prior_txn_amount_mean"] = (
prior_amount_sum / df["orig_prior_txn_count"].replace(0, pd.NA)
).fillna(0.0)
df["dest_prior_txn_count"] = df.groupby("nameDest").cumcount()
return df
def engineer_features(df: pd.DataFrame) -> pd.DataFrame:
"""Add all model features to a copy of `df`.
`df` must contain the raw PaySim columns: step, type, amount, nameOrig,
oldbalanceOrg, newbalanceOrig, nameDest, oldbalanceDest, newbalanceDest.
Returns a new DataFrame sorted by `step` (stable) with every column in
`FEATURE_COLUMNS` added, plus all original columns preserved.
Velocity features require the *full* history for an account to be
present in `df` at once (e.g. the whole training set, or -- at scoring
time -- a query result of that account's own transaction history from
`transactions`) since they aggregate strictly prior same-account rows.
"""
df = df.sort_values("step", kind="mergesort").reset_index(drop=True)
df = _encode_type(df)
df = _balance_ratio_features(df)
df = _origin_dest_mismatch_features(df)
df = _velocity_features(df)
return df
|