Spaces:
Running
Running
| """LendingClub loan data loader. | |
| The LendingClub dataset is the only public dataset of comparable size | |
| and richness to a real lender's book. We treat its 'accepted loans' CSV | |
| (2007–2018, ~2.2M rows) as a proxy for an SME / unsecured retail book. | |
| If the raw CSV isn't available locally, the loader falls back to a | |
| deterministic synthetic generator that produces statistically similar | |
| data - used in CI and for quickstart demos. | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Iterable | |
| import numpy as np | |
| import pandas as pd | |
| from ..utils.logging import get_logger | |
| log = get_logger(__name__) | |
| # Columns we actually use downstream. The full CSV has 150+ columns, | |
| # most of which are post-funding (and therefore data-leakage if used | |
| # for application-time fraud detection). | |
| APPLICATION_TIME_COLUMNS: list[str] = [ | |
| "id", | |
| "issue_d", | |
| "loan_amnt", | |
| "term", | |
| "int_rate", | |
| "installment", | |
| "grade", | |
| "sub_grade", | |
| "emp_title", | |
| "emp_length", | |
| "home_ownership", | |
| "annual_inc", | |
| "verification_status", | |
| "purpose", | |
| "title", | |
| "zip_code", | |
| "addr_state", | |
| "dti", | |
| "delinq_2yrs", | |
| "earliest_cr_line", | |
| "inq_last_6mths", | |
| "open_acc", | |
| "pub_rec", | |
| "revol_bal", | |
| "revol_util", | |
| "total_acc", | |
| "mort_acc", | |
| "pub_rec_bankruptcies", | |
| # Outcome columns - used only for label construction, then dropped: | |
| "loan_status", | |
| "last_pymnt_d", | |
| ] | |
| class LendingClubLoader: | |
| """Loader for LendingClub accepted-loans CSV.""" | |
| raw_path: str | Path | |
| sample_size: int | None = None | |
| random_seed: int = 42 | |
| def load(self) -> pd.DataFrame: | |
| raw_path = Path(self.raw_path) | |
| if not raw_path.exists(): | |
| log.warning( | |
| f"Raw file not found at {raw_path} - falling back to synthetic generator. " | |
| "Run scripts/download_data.py for real data." | |
| ) | |
| return self._synthetic(n=self.sample_size or 50_000, seed=self.random_seed) | |
| log.info(f"Loading LendingClub data from {raw_path}") | |
| df = pd.read_csv( | |
| raw_path, | |
| usecols=lambda c: c in APPLICATION_TIME_COLUMNS, | |
| low_memory=False, | |
| ) | |
| log.info(f"Loaded {len(df):,} rows, {df.shape[1]} columns") | |
| if self.sample_size is not None and self.sample_size < len(df): | |
| df = df.sample(n=self.sample_size, random_state=self.random_seed).reset_index(drop=True) | |
| log.info(f"Sampled down to {len(df):,} rows") | |
| df = self._normalise(df) | |
| return df | |
| # ------------------------------------------------------------------ # | |
| # Cleaning | |
| # ------------------------------------------------------------------ # | |
| def _normalise(self, df: pd.DataFrame) -> pd.DataFrame: | |
| """Cast types, strip percent signs, parse dates.""" | |
| df = df.copy() | |
| if "int_rate" in df.columns and df["int_rate"].dtype == object: | |
| df["int_rate"] = ( | |
| df["int_rate"].astype(str).str.rstrip("%").replace("nan", np.nan).astype(float) | |
| ) | |
| if "revol_util" in df.columns and df["revol_util"].dtype == object: | |
| df["revol_util"] = ( | |
| df["revol_util"].astype(str).str.rstrip("%").replace("nan", np.nan).astype(float) | |
| ) | |
| if "term" in df.columns and df["term"].dtype == object: | |
| df["term"] = ( | |
| df["term"].astype(str).str.extract(r"(\d+)")[0].astype(float) | |
| ) | |
| # Date parsing | |
| for date_col in ("issue_d", "last_pymnt_d", "earliest_cr_line"): | |
| if date_col in df.columns: | |
| df[date_col] = pd.to_datetime(df[date_col], format="%b-%Y", errors="coerce") | |
| # emp_length: '< 1 year' -> 0, '10+ years' -> 10, '4 years' -> 4 | |
| if "emp_length" in df.columns: | |
| df["emp_length"] = df["emp_length"].map(self._parse_emp_length) | |
| # Trim whitespace on string cols | |
| for c in df.select_dtypes(include="object").columns: | |
| df[c] = df[c].astype(str).str.strip() | |
| return df | |
| def _parse_emp_length(val: str | float) -> float: | |
| if pd.isna(val): | |
| return np.nan | |
| s = str(val).strip().lower() | |
| if "<" in s: | |
| return 0.0 | |
| if "10" in s: | |
| return 10.0 | |
| digits = "".join(c for c in s if c.isdigit()) | |
| return float(digits) if digits else np.nan | |
| # ------------------------------------------------------------------ # | |
| # Synthetic fallback - used for CI and quick demos | |
| # ------------------------------------------------------------------ # | |
| def _synthetic(n: int = 50_000, seed: int = 42) -> pd.DataFrame: | |
| """Generate a realistic-looking synthetic LendingClub dataset. | |
| Distributions are calibrated to roughly match the real data so | |
| that EDA / unit tests behave the same way. | |
| """ | |
| rng = np.random.default_rng(seed) | |
| grades = ["A", "B", "C", "D", "E", "F", "G"] | |
| grade_probs = [0.15, 0.27, 0.27, 0.18, 0.08, 0.04, 0.01] | |
| grade = rng.choice(grades, size=n, p=grade_probs) | |
| sub_grade = np.array([f"{g}{rng.integers(1, 6)}" for g in grade]) | |
| # Risk increases with grade index | |
| grade_idx = np.array([grades.index(g) for g in grade]) | |
| loan_amnt = np.clip(rng.normal(15000, 9000, n), 1000, 40000).round(-2) | |
| term = rng.choice([36, 60], size=n, p=[0.72, 0.28]).astype(float) | |
| int_rate = np.clip(5 + grade_idx * 2.5 + rng.normal(0, 1.5, n), 5, 30) | |
| installment = (loan_amnt * (int_rate / 1200)) / (1 - (1 + int_rate / 1200) ** -term) | |
| annual_inc = np.clip(rng.lognormal(mean=11.0, sigma=0.6, size=n), 8000, 1_500_000).round(-2) | |
| dti = np.clip(rng.normal(18, 9, n), 0, 60) | |
| emp_length = rng.choice( | |
| [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], | |
| size=n, | |
| p=[0.07, 0.06, 0.07, 0.06, 0.06, 0.06, 0.05, 0.04, 0.04, 0.03, 0.46], | |
| ).astype(float) | |
| home_ownership = rng.choice( | |
| ["RENT", "MORTGAGE", "OWN", "OTHER"], size=n, p=[0.40, 0.50, 0.09, 0.01] | |
| ) | |
| verification_status = rng.choice( | |
| ["Verified", "Source Verified", "Not Verified"], size=n, p=[0.34, 0.34, 0.32] | |
| ) | |
| purpose = rng.choice( | |
| [ | |
| "debt_consolidation", "credit_card", "home_improvement", | |
| "other", "major_purchase", "small_business", "car", | |
| "medical", "moving", "vacation", "house", | |
| ], | |
| size=n, | |
| p=[0.50, 0.23, 0.06, 0.06, 0.04, 0.03, 0.02, 0.02, 0.02, 0.01, 0.01], | |
| ) | |
| delinq_2yrs = rng.poisson(0.3 + grade_idx * 0.15, n) | |
| inq_last_6mths = rng.poisson(0.6 + grade_idx * 0.1, n) | |
| open_acc = rng.poisson(10, n) | |
| pub_rec = rng.binomial(1, 0.05, n) | |
| revol_bal = np.clip(rng.lognormal(8.5, 1.2, n), 0, 1_500_000).round(-1) | |
| revol_util = np.clip(rng.normal(55, 25, n), 0, 150) | |
| total_acc = open_acc + rng.poisson(15, n) | |
| mort_acc = rng.poisson(1.5, n) | |
| pub_rec_bankruptcies = rng.binomial(1, 0.03, n) | |
| states = [ | |
| "CA", "NY", "TX", "FL", "IL", "PA", "OH", "GA", "NC", "MI", | |
| "NJ", "VA", "WA", "MA", "AZ", "TN", "IN", "MO", "MD", "WI", | |
| ] | |
| addr_state = rng.choice(states, size=n) | |
| zip_code = np.array([f"{rng.integers(100, 999):03d}xx" for _ in range(n)]) | |
| # Issue date over 4 years | |
| days_offset = rng.integers(0, 365 * 4, size=n) | |
| issue_d = pd.to_datetime("2015-01-01") + pd.to_timedelta(days_offset, unit="D") | |
| # Make it month-resolution like the real data | |
| issue_d = issue_d.to_period("M").to_timestamp() | |
| earliest_cr_line = issue_d - pd.to_timedelta( | |
| rng.integers(365 * 3, 365 * 25, size=n), unit="D" | |
| ) | |
| # Loan status: charge-off rate increases with grade | |
| co_prob = 0.05 + grade_idx * 0.045 | |
| is_co = rng.random(n) < co_prob | |
| loan_status = np.where(is_co, "Charged Off", "Fully Paid") | |
| # last_pymnt_d: a few months after issue for charged-off | |
| months_to_co = np.where(is_co, rng.integers(1, 20, n), rng.integers(20, 60, n)) | |
| last_pymnt_d = issue_d + pd.to_timedelta(months_to_co * 30, unit="D") | |
| emp_title = rng.choice( | |
| ["Manager", "Engineer", "Teacher", "Sales", "Driver", "Nurse", "Analyst", | |
| "Owner", "Director", "Consultant", "Clerk", "Technician"], | |
| size=n, | |
| ) | |
| title = rng.choice( | |
| ["Debt consolidation", "Credit card refinancing", "Home improvement", | |
| "Major purchase", "Business", "Medical expenses"], | |
| size=n, | |
| ) | |
| df = pd.DataFrame({ | |
| "id": np.arange(1_000_000, 1_000_000 + n), | |
| "issue_d": issue_d, | |
| "loan_amnt": loan_amnt, | |
| "term": term, | |
| "int_rate": int_rate.round(2), | |
| "installment": installment.round(2), | |
| "grade": grade, | |
| "sub_grade": sub_grade, | |
| "emp_title": emp_title, | |
| "emp_length": emp_length, | |
| "home_ownership": home_ownership, | |
| "annual_inc": annual_inc, | |
| "verification_status": verification_status, | |
| "purpose": purpose, | |
| "title": title, | |
| "zip_code": zip_code, | |
| "addr_state": addr_state, | |
| "dti": dti.round(2), | |
| "delinq_2yrs": delinq_2yrs, | |
| "earliest_cr_line": earliest_cr_line, | |
| "inq_last_6mths": inq_last_6mths, | |
| "open_acc": open_acc, | |
| "pub_rec": pub_rec, | |
| "revol_bal": revol_bal, | |
| "revol_util": revol_util.round(1), | |
| "total_acc": total_acc, | |
| "mort_acc": mort_acc, | |
| "pub_rec_bankruptcies": pub_rec_bankruptcies, | |
| "loan_status": loan_status, | |
| "last_pymnt_d": last_pymnt_d, | |
| }) | |
| # Inject some realistic NaNs | |
| for col, frac in [ | |
| ("emp_length", 0.05), | |
| ("emp_title", 0.06), | |
| ("title", 0.10), | |
| ("revol_util", 0.005), | |
| ("dti", 0.001), | |
| ]: | |
| mask = rng.random(n) < frac | |
| df.loc[mask, col] = np.nan | |
| log.info(f"Generated synthetic LendingClub-like dataset: {len(df):,} rows") | |
| return df | |
| def load_raw( | |
| raw_path: str | Path = "data/raw/accepted_2007_to_2018Q4.csv", | |
| sample_size: int | None = None, | |
| random_seed: int = 42, | |
| ) -> pd.DataFrame: | |
| """Convenience function - instantiate loader and return DataFrame.""" | |
| return LendingClubLoader( | |
| raw_path=raw_path, sample_size=sample_size, random_seed=random_seed | |
| ).load() | |