bank-fraud / training /eda.py
root
init
942b115
Raw
History Blame Contribute Delete
13.7 kB
"""Exploratory data analysis over the PaySim dataset (EDA-01..EDA-04).
Reads `data/*.csv` (the Kaggle PaySim export fetched by fetch_data.py),
computes the class-imbalance, distribution, balance-inconsistency, and
fraud-rate findings required before feature engineering, and writes:
- reports/EDA_REPORT.md -- narrative findings + embedded chart references
- reports/eda_charts/*.png -- the charts referenced by the report
Run from the project root:
python -m training.eda
"""
from __future__ import annotations
import glob
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns
REPORTS_DIR = Path("reports")
CHARTS_DIR = REPORTS_DIR / "eda_charts"
REPORT_PATH = REPORTS_DIR / "EDA_REPORT.md"
sns.set_theme(style="whitegrid")
def _find_dataset() -> Path:
candidates = sorted(glob.glob("data/*.csv"))
if not candidates:
raise FileNotFoundError(
"No CSV found under data/. Run training/fetch_data.py first."
)
return Path(candidates[0])
def _save(fig: plt.Figure, name: str) -> str:
CHARTS_DIR.mkdir(parents=True, exist_ok=True)
path = CHARTS_DIR / name
fig.savefig(path, bbox_inches="tight", dpi=110)
plt.close(fig)
return f"eda_charts/{name}"
def load_data() -> pd.DataFrame:
path = _find_dataset()
df = pd.read_csv(path)
return df
def class_imbalance(df: pd.DataFrame) -> dict:
counts = df["isFraud"].value_counts().sort_index()
total = len(df)
fraud_count = int(counts.get(1, 0))
legit_count = int(counts.get(0, 0))
fraud_rate = fraud_count / total
fig, ax = plt.subplots(figsize=(5, 4))
sns.barplot(
x=["Legitimate", "Fraud"],
y=[legit_count, fraud_count],
hue=["Legitimate", "Fraud"],
palette=["#4C72B0", "#C44E52"],
legend=False,
ax=ax,
)
ax.set_yscale("log")
ax.set_ylabel("Transaction count (log scale)")
ax.set_title("Class imbalance: isFraud")
chart = _save(fig, "class_imbalance.png")
return {
"total": total,
"legit_count": legit_count,
"fraud_count": fraud_count,
"fraud_rate": fraud_rate,
"chart": chart,
}
def type_and_amount_distributions(df: pd.DataFrame) -> dict:
type_counts = df["type"].value_counts()
fig, ax = plt.subplots(figsize=(6, 4))
sns.barplot(
x=type_counts.index,
y=type_counts.values,
hue=type_counts.index,
palette="viridis",
legend=False,
ax=ax,
)
ax.set_ylabel("Transaction count")
ax.set_title("Transaction type distribution")
type_chart = _save(fig, "type_distribution.png")
fig, ax = plt.subplots(figsize=(6, 4))
sample = df.sample(min(len(df), 200_000), random_state=42)
sns.boxplot(
data=sample,
x="type",
y="amount",
hue="type",
palette="viridis",
legend=False,
ax=ax,
)
ax.set_yscale("log")
ax.set_ylabel("Amount (log scale)")
ax.set_title("Amount distribution by transaction type (sampled)")
amount_chart = _save(fig, "amount_by_type.png")
return {
"type_counts": type_counts.to_dict(),
"type_chart": type_chart,
"amount_chart": amount_chart,
}
def balance_inconsistencies(df: pd.DataFrame) -> dict:
orig_mismatch = (
df["oldbalanceOrg"] - df["amount"] != df["newbalanceOrig"]
)
dest_mismatch = (
df["oldbalanceDest"] + df["amount"] != df["newbalanceDest"]
)
orig_zero_after = (df["oldbalanceOrg"] > 0) & (df["newbalanceOrig"] == 0)
dest_zero_stays_zero = (
(df["oldbalanceDest"] == 0)
& (df["newbalanceDest"] == 0)
& (df["amount"] > 0)
)
total = len(df)
return {
"orig_mismatch_rate": float(orig_mismatch.mean()),
"dest_mismatch_rate": float(dest_mismatch.mean()),
"orig_zero_after_count": int(orig_zero_after.sum()),
"orig_zero_after_fraud_rate": float(
df.loc[orig_zero_after, "isFraud"].mean()
)
if orig_zero_after.any()
else 0.0,
"dest_zero_stays_zero_count": int(dest_zero_stays_zero.sum()),
"dest_zero_stays_zero_fraud_rate": float(
df.loc[dest_zero_stays_zero, "isFraud"].mean()
)
if dest_zero_stays_zero.any()
else 0.0,
"total": total,
}
def fraud_rate_by_type(df: pd.DataFrame) -> dict:
rate_by_type = df.groupby("type")["isFraud"].mean().sort_values(ascending=False)
fraud_types = rate_by_type[rate_by_type > 0].index.tolist()
fig, ax = plt.subplots(figsize=(6, 4))
sns.barplot(
x=rate_by_type.index,
y=rate_by_type.values,
hue=rate_by_type.index,
palette="rocket",
legend=False,
ax=ax,
)
ax.set_ylabel("Fraud rate")
ax.set_title("Fraud rate by transaction type")
chart = _save(fig, "fraud_rate_by_type.png")
return {
"rate_by_type": rate_by_type.to_dict(),
"fraud_confined_to": fraud_types,
"chart": chart,
}
def fraud_rate_by_amount(df: pd.DataFrame) -> dict:
fraud_df = df[df["isFraud"] == 1]
legit_df = df[df["isFraud"] == 0]
fig, ax = plt.subplots(figsize=(6, 4))
sns.histplot(
legit_df["amount"].clip(upper=legit_df["amount"].quantile(0.99)),
color="#4C72B0",
label="Legitimate",
stat="density",
kde=True,
ax=ax,
alpha=0.5,
)
sns.histplot(
fraud_df["amount"].clip(upper=fraud_df["amount"].quantile(0.99)),
color="#C44E52",
label="Fraud",
stat="density",
kde=True,
ax=ax,
alpha=0.5,
)
ax.set_title("Amount distribution: fraud vs legitimate (clipped at p99)")
ax.legend()
chart = _save(fig, "amount_fraud_vs_legit.png")
return {
"fraud_amount_median": float(fraud_df["amount"].median()),
"legit_amount_median": float(legit_df["amount"].median()),
"fraud_amount_mean": float(fraud_df["amount"].mean()),
"legit_amount_mean": float(legit_df["amount"].mean()),
"chart": chart,
}
def fraud_rate_by_time(df: pd.DataFrame) -> dict:
df = df.copy()
df["hour_of_day"] = df["step"] % 24
rate_by_hour = df.groupby("hour_of_day")["isFraud"].mean()
fig, ax = plt.subplots(figsize=(8, 4))
sns.lineplot(x=rate_by_hour.index, y=rate_by_hour.values, marker="o", ax=ax)
ax.set_xlabel("Hour of day (step mod 24)")
ax.set_ylabel("Fraud rate")
ax.set_title("Fraud rate by simulated hour of day")
chart = _save(fig, "fraud_rate_by_hour.png")
return {
"rate_by_hour": rate_by_hour.to_dict(),
"chart": chart,
}
def flagged_fraud_crosstab(df: pd.DataFrame) -> dict:
crosstab = pd.crosstab(df["isFraud"], df["isFlaggedFraud"])
total_fraud = int((df["isFraud"] == 1).sum())
flagged_and_fraud = int(((df["isFraud"] == 1) & (df["isFlaggedFraud"] == 1)).sum())
flagged_not_fraud = int(((df["isFraud"] == 0) & (df["isFlaggedFraud"] == 1)).sum())
recall_of_flag = flagged_and_fraud / total_fraud if total_fraud else 0.0
return {
"crosstab": crosstab.to_dict(),
"total_fraud": total_fraud,
"flagged_and_fraud": flagged_and_fraud,
"flagged_not_fraud": flagged_not_fraud,
"recall_of_isFlaggedFraud_against_isFraud": recall_of_flag,
}
def render_report(results: dict) -> str:
ci = results["class_imbalance"]
ty = results["type_distribution"]
bi = results["balance_inconsistencies"]
frt = results["fraud_rate_by_type"]
fra = results["fraud_rate_by_amount"]
frh = results["fraud_rate_by_time"]
flag = results["flagged_fraud_crosstab"]
type_counts_lines = "\n".join(
f"- `{t}`: {c:,}" for t, c in ty["type_counts"].items()
)
rate_by_type_lines = "\n".join(
f"- `{t}`: {r:.6f}" for t, r in frt["rate_by_type"].items()
)
lines = [
"# PaySim EDA Report",
"",
f"Dataset: `{_find_dataset()}` -- {ci['total']:,} transactions.",
"",
"## 1. Class Imbalance (EDA-01)",
"",
f"- Legitimate: {ci['legit_count']:,}",
f"- Fraud: {ci['fraud_count']:,}",
f"- Fraud rate: {ci['fraud_rate']:.6f} ({ci['fraud_rate'] * 100:.4f}%)",
"",
f"![Class imbalance]({ci['chart']})",
"",
"Fraud is an extreme minority class (~0.1%). Accuracy is not a usable "
"metric here -- a model that never predicts fraud would still score "
"~99.9% accuracy. Precision/recall/F1/PR-AUC/ROC-AUC are required "
"(see project constraints).",
"",
"## 2. Transaction Type & Amount Distributions (EDA-01)",
"",
"Transaction counts by type:",
"",
type_counts_lines,
"",
f"![Type distribution]({ty['type_chart']})",
"",
f"![Amount by type]({ty['amount_chart']})",
"",
"## 3. Balance Inconsistencies (EDA-01)",
"",
f"- Origin balance mismatch rate (`oldbalanceOrg - amount != "
f"newbalanceOrig`): {bi['orig_mismatch_rate']:.4f}",
f"- Destination balance mismatch rate (`oldbalanceDest + amount != "
f"newbalanceDest`): {bi['dest_mismatch_rate']:.4f}",
f"- Transactions where the origin balance goes to exactly zero: "
f"{bi['orig_zero_after_count']:,} (fraud rate within this group: "
f"{bi['orig_zero_after_fraud_rate']:.4f})",
f"- Transactions where destination balance is zero before and after "
f"a nonzero-amount transfer: {bi['dest_zero_stays_zero_count']:,} "
f"(fraud rate within this group: "
f"{bi['dest_zero_stays_zero_fraud_rate']:.4f})",
"",
"PaySim's balance fields are frequently inconsistent by construction "
"(destination balances of merchant accounts, `M...`, are always "
"reported as zero). These inconsistency signals -- not the raw "
"balances -- are what a leakage-safe feature module should encode "
"(ratios/flags), per the project's Phase 2 requirements.",
"",
"## 4. Fraud Rate by Type / Amount / Time (EDA-02)",
"",
"### By transaction type",
"",
rate_by_type_lines,
"",
f"![Fraud rate by type]({frt['chart']})",
"",
f"**Fraud is confined to: {', '.join(frt['fraud_confined_to'])}.** "
"All other transaction types (`PAYMENT`, `CASH_IN`, `DEBIT`) show a "
"fraud rate of exactly 0 in this dataset. A production scoring "
"service must still route all transaction types through the same "
"pipeline (per API-02) rather than hard-coding a bypass -- but this "
"finding is directly relevant to feature encoding and model "
"expectations.",
"",
"### By amount",
"",
f"- Fraud amount: median {fra['fraud_amount_median']:,.2f}, mean "
f"{fra['fraud_amount_mean']:,.2f}",
f"- Legitimate amount: median {fra['legit_amount_median']:,.2f}, mean "
f"{fra['legit_amount_mean']:,.2f}",
"",
f"![Amount: fraud vs legit]({fra['chart']})",
"",
"### By time (simulated hour of day)",
"",
f"![Fraud rate by hour]({frh['chart']})",
"",
"## 5. isFraud vs isFlaggedFraud (EDA-02 / PaySim-specific pitfall)",
"",
f"- Total actual fraud transactions: {flag['total_fraud']:,}",
f"- Of those, flagged by `isFlaggedFraud`: {flag['flagged_and_fraud']:,}",
f"- Recall of `isFlaggedFraud` against `isFraud`: "
f"{flag['recall_of_isFlaggedFraud_against_isFraud']:.4%}",
f"- Legitimate transactions incorrectly flagged: "
f"{flag['flagged_not_fraud']:,}",
"",
"**`isFlaggedFraud` is not a usable fraud signal.** It catches only "
"a vanishing fraction of true fraud and is a simulation artifact "
"(PaySim's own naive over-threshold-transfer rule), not a real "
"fraud detector. Per the project's constraints, `isFlaggedFraud` "
"must be excluded entirely from the feature/label surface -- it is "
"already excluded from the `Transaction` ORM model "
"(`app/db/models.py`).",
"",
"## Summary for feature engineering (Phase 2)",
"",
"- Treat class imbalance with SMOTE/class-weights/undersampling "
"comparison -- never rely on accuracy.",
"- Fraud only occurs in TRANSFER and CASH_OUT; type must remain an "
"encoded feature, not a filter.",
"- Balance fields should be engineered into ratios/consistency "
"flags (e.g. zero-balance-after-transfer, orig/dest mismatch), not "
"used as raw magnitudes.",
"- `isFlaggedFraud` is excluded from all feature/label surfaces.",
"",
]
return "\n".join(lines)
def main() -> None:
df = load_data()
results = {
"class_imbalance": class_imbalance(df),
"type_distribution": type_and_amount_distributions(df),
"balance_inconsistencies": balance_inconsistencies(df),
"fraud_rate_by_type": fraud_rate_by_type(df),
"fraud_rate_by_amount": fraud_rate_by_amount(df),
"fraud_rate_by_time": fraud_rate_by_time(df),
"flagged_fraud_crosstab": flagged_fraud_crosstab(df),
}
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
report = render_report(results)
REPORT_PATH.write_text(report)
print(f"EDA report written to {REPORT_PATH}")
print(f"Charts written to {CHARTS_DIR}")
if __name__ == "__main__":
main()