Spaces:
Configuration error
Configuration error
| """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"", | |
| "", | |
| "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"", | |
| "", | |
| f"", | |
| "", | |
| "## 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"", | |
| "", | |
| 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"", | |
| "", | |
| "### By time (simulated hour of day)", | |
| "", | |
| f"", | |
| "", | |
| "## 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() | |