loanguard / src /data /loader.py
adwitiyashukla's picture
update repo
44cd54e
Raw
History Blame Contribute Delete
10.8 kB
"""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",
]
@dataclass
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
@staticmethod
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
# ------------------------------------------------------------------ #
@staticmethod
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()