Skip to content

Commit 7f62f2d

Browse files
committed
Add auto finance credit loss forecasting
1 parent 0cade3c commit 7f62f2d

11 files changed

Lines changed: 697 additions & 0 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,5 @@ venv/
99
.DS_Store
1010
data/warehouse/*.duckdb
1111
data/warehouse/*.wal
12+
data/sample/credit_risk/
13+
reports/credit_loss_scored_holdout.csv

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ python3 forge.py audit-experiment --workspace .
4646
python3 forge.py analyze --workspace .
4747
python3 forge.py report --workspace .
4848
python3 forge.py demo --workspace .
49+
python3 forge.py credit-risk-demo --workspace . --loans 6000 --seed 42
4950
```
5051

5152
## Platform Layers
@@ -56,6 +57,7 @@ python3 forge.py demo --workspace .
5657
| `warehouse/` | DuckDB raw, staging, intermediate, and mart models |
5758
| `quality/` | Assignment, source, temporal, mart, and guardrail checks |
5859
| `analysis/` | Statistical readout and decision recommendation |
60+
| `credit_risk/` | Auto-finance PD, LGD, EAD, expected credit loss, and stress-scenario modeling |
5961
| `reporting/` | Markdown reports and Plotly HTML dashboard |
6062
| `config/` | Experiment and metric registry |
6163

@@ -99,6 +101,24 @@ The original statistics toolkit is still included:
99101
- Bayesian A/B testing
100102
- Multi-armed bandit simulations
101103

104+
## Credit Loss Forecasting
105+
106+
Experiment Forge includes an auto-finance credit-risk workflow for portfolio loss forecasting:
107+
108+
- synthetic auto-loan origination and monthly performance data
109+
- borrower risk, loan term, LTV, APR, collateral, delinquency, and macroeconomic drivers
110+
- Probability of Default (PD), Loss Given Default (LGD), and Exposure at Default (EAD) models
111+
- Expected Credit Loss (ECL) scoring using PD x LGD x EAD
112+
- holdout validation with PD AUC, Brier score, LGD MAE, and EAD MAPE
113+
- stress scenario with unemployment, used-vehicle collateral, and rate shocks
114+
- model governance readout with assumptions, validation results, and high-loss segments
115+
116+
Generated artifacts:
117+
118+
- `reports/credit_loss_forecast.json`
119+
- `reports/credit_loss_forecast_readout.md`
120+
- `reports/credit_loss_scored_holdout.csv`
121+
102122
## Reports
103123

104124
Sample generated artifacts:

credit_risk/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Credit loss forecasting utilities for auto-finance portfolios."""
2+
3+
from .modeling import run_credit_loss_forecast
4+
from .synthetic_auto import generate_auto_loan_portfolio
5+
6+
__all__ = ["generate_auto_loan_portfolio", "run_credit_loss_forecast"]

credit_risk/modeling.py

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
"""PD/LGD/EAD modeling and expected credit loss reporting."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from pathlib import Path
7+
8+
import numpy as np
9+
import pandas as pd
10+
import statsmodels.api as sm
11+
12+
13+
FEATURES = [
14+
"fico",
15+
"debt_to_income",
16+
"loan_to_value",
17+
"apr",
18+
"term_months",
19+
"used_vehicle",
20+
"loan_age_months",
21+
"scheduled_balance",
22+
"unemployment_rate",
23+
"used_vehicle_price_index",
24+
"interest_rate_index",
25+
]
26+
27+
28+
@dataclass(frozen=True)
29+
class CreditLossForecast:
30+
"""Summary outputs from a credit loss model run."""
31+
32+
pd_auc: float
33+
pd_brier: float
34+
lgd_mae: float
35+
ead_mape: float
36+
baseline_expected_loss: float
37+
stress_expected_loss: float
38+
stress_lift: float
39+
high_risk_segments: list[dict[str, float | str]]
40+
records_scored: int
41+
42+
def to_dict(self) -> dict[str, object]:
43+
return {
44+
"pd_auc": round(self.pd_auc, 4),
45+
"pd_brier": round(self.pd_brier, 4),
46+
"lgd_mae": round(self.lgd_mae, 4),
47+
"ead_mape": round(self.ead_mape, 4),
48+
"baseline_expected_loss": round(self.baseline_expected_loss, 2),
49+
"stress_expected_loss": round(self.stress_expected_loss, 2),
50+
"stress_lift": round(self.stress_lift, 4),
51+
"high_risk_segments": self.high_risk_segments,
52+
"records_scored": self.records_scored,
53+
}
54+
55+
56+
def _load_model_frame(raw_dir: Path) -> pd.DataFrame:
57+
loans = pd.read_csv(raw_dir / "auto_loans.csv")
58+
performance = pd.read_csv(raw_dir / "monthly_performance.csv")
59+
frame = performance.merge(loans, on="loan_id", how="left", validate="many_to_one")
60+
frame["defaulted"] = frame["defaulted"].astype(int)
61+
frame["loss_given_default_observed"] = frame["loss_given_default"].fillna(0)
62+
frame["exposure_at_default_observed"] = frame["exposure_at_default"].fillna(frame["scheduled_balance"])
63+
frame["fico_band"] = pd.cut(
64+
frame["fico"],
65+
bins=[0, 620, 680, 720, 850],
66+
labels=["subprime", "near_prime", "prime", "super_prime"],
67+
include_lowest=True,
68+
).astype(str)
69+
return frame
70+
71+
72+
def _design_matrix(frame: pd.DataFrame) -> pd.DataFrame:
73+
X = frame[FEATURES].copy()
74+
return sm.add_constant(X, has_constant="add")
75+
76+
77+
def _roc_auc(y_true: np.ndarray, y_score: np.ndarray) -> float:
78+
positives = y_score[y_true == 1]
79+
negatives = y_score[y_true == 0]
80+
if len(positives) == 0 or len(negatives) == 0:
81+
return 0.5
82+
sample_limit = 250000
83+
pair_count = len(positives) * len(negatives)
84+
if pair_count > sample_limit:
85+
rng = np.random.default_rng(17)
86+
pos = rng.choice(positives, size=int(np.sqrt(sample_limit)), replace=True)
87+
neg = rng.choice(negatives, size=int(np.sqrt(sample_limit)), replace=True)
88+
else:
89+
pos = positives
90+
neg = negatives
91+
comparisons = (pos[:, None] > neg[None, :]).mean()
92+
ties = (pos[:, None] == neg[None, :]).mean()
93+
return float(comparisons + 0.5 * ties)
94+
95+
96+
def _fit_pd_model(train: pd.DataFrame):
97+
X_train = _design_matrix(train)
98+
y_train = train["defaulted"]
99+
return sm.Logit(y_train, X_train).fit(disp=False, maxiter=250)
100+
101+
102+
def _fit_lgd_model(train: pd.DataFrame):
103+
defaults = train[train["defaulted"] == 1].copy()
104+
X_train = _design_matrix(defaults)
105+
y_train = defaults["loss_given_default_observed"]
106+
return sm.OLS(y_train, X_train).fit()
107+
108+
109+
def _fit_ead_model(train: pd.DataFrame):
110+
defaults = train[train["defaulted"] == 1].copy()
111+
X_train = _design_matrix(defaults)
112+
y_train = defaults["exposure_at_default_observed"]
113+
return sm.OLS(y_train, X_train).fit()
114+
115+
116+
def _score_frame(frame: pd.DataFrame, pd_model, lgd_model, ead_model) -> pd.DataFrame:
117+
scored = frame.copy()
118+
X = _design_matrix(scored)
119+
scored["pd_forecast"] = np.clip(pd_model.predict(X), 0.001, 0.75)
120+
scored["lgd_forecast"] = np.clip(lgd_model.predict(X), 0.04, 0.95)
121+
scored["ead_forecast"] = np.clip(ead_model.predict(X), 500, scored["original_balance"])
122+
scored["expected_credit_loss"] = scored["pd_forecast"] * scored["lgd_forecast"] * scored["ead_forecast"]
123+
return scored
124+
125+
126+
def _stress_scenario(frame: pd.DataFrame) -> pd.DataFrame:
127+
stressed = frame.copy()
128+
stressed["unemployment_rate"] = np.clip(stressed["unemployment_rate"] + 0.025, 0, 0.16)
129+
stressed["used_vehicle_price_index"] = np.clip(stressed["used_vehicle_price_index"] - 0.09, 0.65, 1.2)
130+
stressed["interest_rate_index"] = np.clip(stressed["interest_rate_index"] + 0.012, 0, 0.18)
131+
return stressed
132+
133+
134+
def _segment_table(scored: pd.DataFrame) -> list[dict[str, float | str]]:
135+
grouped = (
136+
scored.groupby(["fico_band", "used_vehicle"], observed=True)
137+
.agg(
138+
loans=("loan_id", "nunique"),
139+
default_rate=("defaulted", "mean"),
140+
avg_pd=("pd_forecast", "mean"),
141+
avg_lgd=("lgd_forecast", "mean"),
142+
expected_loss=("expected_credit_loss", "sum"),
143+
)
144+
.reset_index()
145+
.sort_values("expected_loss", ascending=False)
146+
.head(5)
147+
)
148+
grouped["vehicle_type"] = np.where(grouped["used_vehicle"] == 1, "used", "new")
149+
return [
150+
{
151+
"fico_band": row["fico_band"],
152+
"vehicle_type": row["vehicle_type"],
153+
"loans": int(row["loans"]),
154+
"default_rate": round(float(row["default_rate"]), 4),
155+
"avg_pd": round(float(row["avg_pd"]), 4),
156+
"avg_lgd": round(float(row["avg_lgd"]), 4),
157+
"expected_loss": round(float(row["expected_loss"]), 2),
158+
}
159+
for _, row in grouped.iterrows()
160+
]
161+
162+
163+
def run_credit_loss_forecast(raw_dir: str | Path, reports_dir: str | Path) -> CreditLossForecast:
164+
"""Train PD, LGD, and EAD models and write forecast artifacts."""
165+
166+
raw_path = Path(raw_dir)
167+
report_path = Path(reports_dir)
168+
report_path.mkdir(parents=True, exist_ok=True)
169+
170+
frame = _load_model_frame(raw_path)
171+
train = frame[frame["month"] <= 27].copy()
172+
holdout = frame[frame["month"] > 27].copy()
173+
174+
pd_model = _fit_pd_model(train)
175+
lgd_model = _fit_lgd_model(train)
176+
ead_model = _fit_ead_model(train)
177+
178+
scored = _score_frame(holdout, pd_model, lgd_model, ead_model)
179+
stressed = _score_frame(_stress_scenario(holdout), pd_model, lgd_model, ead_model)
180+
181+
y_true = scored["defaulted"].to_numpy()
182+
y_score = scored["pd_forecast"].to_numpy()
183+
pd_auc = _roc_auc(y_true, y_score)
184+
pd_brier = float(np.mean((y_true - y_score) ** 2))
185+
186+
default_holdout = scored[scored["defaulted"] == 1]
187+
lgd_mae = float(np.mean(np.abs(default_holdout["loss_given_default_observed"] - default_holdout["lgd_forecast"])))
188+
ead_mape = float(
189+
np.mean(
190+
np.abs(default_holdout["exposure_at_default_observed"] - default_holdout["ead_forecast"])
191+
/ np.maximum(default_holdout["exposure_at_default_observed"], 1)
192+
)
193+
)
194+
baseline_expected_loss = float(scored["expected_credit_loss"].sum())
195+
stress_expected_loss = float(stressed["expected_credit_loss"].sum())
196+
stress_lift = float((stress_expected_loss / baseline_expected_loss) - 1)
197+
198+
forecast = CreditLossForecast(
199+
pd_auc=pd_auc,
200+
pd_brier=pd_brier,
201+
lgd_mae=lgd_mae,
202+
ead_mape=ead_mape,
203+
baseline_expected_loss=baseline_expected_loss,
204+
stress_expected_loss=stress_expected_loss,
205+
stress_lift=stress_lift,
206+
high_risk_segments=_segment_table(scored),
207+
records_scored=len(scored),
208+
)
209+
210+
scored[
211+
[
212+
"loan_id",
213+
"month",
214+
"fico_band",
215+
"used_vehicle",
216+
"scheduled_balance",
217+
"pd_forecast",
218+
"lgd_forecast",
219+
"ead_forecast",
220+
"expected_credit_loss",
221+
"defaulted",
222+
]
223+
].to_csv(report_path / "credit_loss_scored_holdout.csv", index=False)
224+
return forecast

credit_risk/reports.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Reporting artifacts for credit loss forecasts."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from pathlib import Path
7+
8+
from .modeling import CreditLossForecast
9+
10+
11+
def write_credit_loss_report(forecast: CreditLossForecast, output_dir: str | Path) -> None:
12+
"""Write model governance and business-readout artifacts."""
13+
14+
output_path = Path(output_dir)
15+
output_path.mkdir(parents=True, exist_ok=True)
16+
payload = forecast.to_dict()
17+
(output_path / "credit_loss_forecast.json").write_text(json.dumps(payload, indent=2))
18+
19+
segment_rows = "\n".join(
20+
"| {fico_band} | {vehicle_type} | {loans} | {avg_pd:.2%} | {avg_lgd:.2%} | ${expected_loss:,.0f} |".format(
21+
**segment
22+
)
23+
for segment in forecast.high_risk_segments
24+
)
25+
report = f"""# Credit Loss Forecast Readout
26+
27+
## Model Scope
28+
29+
Auto-finance credit loss forecast using portfolio-level borrower attributes, loan terms, monthly performance, delinquency behavior, collateral price pressure, and macroeconomic drivers.
30+
31+
## Model Components
32+
33+
- Probability of Default (PD): logistic regression with borrower, loan, performance, and macroeconomic drivers.
34+
- Loss Given Default (LGD): regression model trained on observed default outcomes.
35+
- Exposure at Default (EAD): regression model trained on defaulted-account balances.
36+
- Expected Credit Loss (ECL): PD x LGD x EAD for each active loan-month record.
37+
- Stress scenario: unemployment +250 bps, used-vehicle collateral index -9%, interest-rate index +120 bps.
38+
39+
## Holdout Validation
40+
41+
| Metric | Value |
42+
|---|---:|
43+
| Holdout records scored | {forecast.records_scored:,} |
44+
| PD AUC | {forecast.pd_auc:.3f} |
45+
| PD Brier score | {forecast.pd_brier:.4f} |
46+
| LGD MAE | {forecast.lgd_mae:.3f} |
47+
| EAD MAPE | {forecast.ead_mape:.3f} |
48+
49+
## Forecast Summary
50+
51+
| Scenario | Expected credit loss |
52+
|---|---:|
53+
| Baseline | ${forecast.baseline_expected_loss:,.0f} |
54+
| Stressed macro | ${forecast.stress_expected_loss:,.0f} |
55+
| Stress lift | {forecast.stress_lift:.1%} |
56+
57+
## Highest-Loss Segments
58+
59+
| FICO band | Vehicle type | Loans | Avg PD | Avg LGD | Expected loss |
60+
|---|---:|---:|---:|---:|---:|
61+
{segment_rows}
62+
63+
## Governance Notes
64+
65+
- Uses a deterministic train/holdout split by observation month to avoid look-ahead leakage.
66+
- Separates PD, LGD, and EAD so model performance and business assumptions can be reviewed independently.
67+
- Writes scored holdout records for challenger-model comparison, audit review, and portfolio monitoring.
68+
- Keeps stress assumptions explicit so Risk, Finance, and business partners can challenge or replace them.
69+
"""
70+
(output_path / "credit_loss_forecast_readout.md").write_text(report)

0 commit comments

Comments
 (0)