Spaces:
Running
Running
File size: 10,799 Bytes
42029e4 44cd54e 42029e4 44cd54e 42029e4 44cd54e 42029e4 44cd54e 42029e4 44cd54e 42029e4 | 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 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 | """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()
|