An end-to-end credit risk pipeline: EDA-ready data, a trained default-prediction model, SHAP-based explanations, and a live scoring API — the shape of a project a bank's risk team would actually ship, not a one-off notebook.
Why explainability matters here: credit decisions are regulated (Consumer Credit Act / FCA in the UK, ECOA in the US) — a lender generally has to be able to say why an applicant was scored the way they were, not just produce a number. This project treats the explanation as a first-class output, not an afterthought.
UCI "Default of Credit Card Clients" — 30,000 credit card clients in Taiwan (Apr–Sep 2005). Target: whether the client defaulted on their payment the following month (22.1% positive rate).
Features: credit limit, demographics (age/sex/education/marriage), 6 months of repayment status, bill amounts, and payment amounts.
src/data_prep.py Clean raw data (fix undocumented category codes, collapse
repayment-status edge cases) -> data/processed/
src/train.py Train Logistic Regression (baseline) + XGBoost, evaluate,
pick the winner by ROC-AUC -> models/, reports/
src/explain.py SHAP TreeExplainer over the winning model -> reports/
api/main.py FastAPI service: POST an applicant, get back a
probability + risk band + top SHAP drivers for that score
| Model | ROC-AUC | PR-AUC | Recall (default class) |
|---|---|---|---|
| Logistic Regression | 0.744 | 0.519 | 0.54 |
| XGBoost | 0.776 | 0.553 | 0.62 |
XGBoost is the selected model. Recall on the default class is prioritized
over raw accuracy — for a lender, missing an actual default is more costly
than a false alarm. See reports/roc_curve.png.
reports/shap_importance.png and reports/shap_summary.png show which
features drive predictions globally. Unsurprisingly, recent repayment status
(PAY_1, PAY_2 — how many months late the client was) dominates, followed
by credit limit and recent bill amounts.
Every API prediction also returns the top 5 SHAP factors for that specific applicant — a real analyst or underwriter could read the response and explain the decision to a customer.
src/fairness_audit.py checks whether the model produces disparate outcomes
across SEX, AGE band, EDUCATION, and MARRIAGE — using the "four-fifths rule"
(the standard US EEOC adverse-impact threshold: the lowest group's positive
rate should be at least 80% of the highest group's) plus fairlearn's
demographic parity and equalized odds metrics.
It also trains a second model with SEX/EDUCATION/MARRIAGE removed as features ("blinded") and re-audits it, to test a real question a UK lender has to ask: under the Equality Act 2010, using a protected characteristic as a direct model input risks a direct discrimination claim, regardless of how predictive it is — but does removing it actually fix disparate outcomes?
Findings on this dataset:
| Attribute | Full model — 4/5 rule | Blinded model — 4/5 rule |
|---|---|---|
| SEX | pass (ratio 0.85) | pass (ratio 0.88) |
| MARRIAGE | pass (ratio 0.91) | pass (ratio 0.88) |
| EDUCATION | FAIL (ratio 0.19) | FAIL (ratio 0.39) |
| AGE band | FAIL (ratio 0.66) | FAIL (ratio 0.63) |
Removing the protected attributes costs almost nothing in accuracy
(ROC-AUC 0.7758 → 0.7739) — but it doesn't fix the EDUCATION and AGE
disparities. That's the actual lesson: other features (credit limit,
repayment history, bill amounts) are correlated proxies for education and
age, so the model keeps re-deriving what it "shouldn't" know. Removing
protected fields is necessary but not sufficient — a real deployment would
need proxy analysis and likely a fairness-constrained training objective
(fairlearn's ExponentiatedGradient, for example) to actually close the
gap. Full breakdown: reports/fairness_report.json, charts:
reports/fairness_sex.png, fairness_education.png, fairness_marriage.png,
fairness_age_band.png.
Run it: python3 src/fairness_audit.py
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python3 src/data_prep.py # clean data
python3 src/train.py # train + evaluate, saves models/
python3 src/explain.py # SHAP artifacts
uvicorn api.main:app --app-dir . --reloadcurl -X POST http://127.0.0.1:8000/predict -H "Content-Type: application/json" -d '{
"LIMIT_BAL": 20000, "AGE": 24, "SEX": 2, "EDUCATION": 2, "MARRIAGE": 1,
"PAY_1": 2, "PAY_2": 2, "PAY_3": 0, "PAY_4": 0, "PAY_5": 0, "PAY_6": 0,
"BILL_AMT1": 3913, "BILL_AMT2": 3102, "BILL_AMT3": 689, "BILL_AMT4": 0, "BILL_AMT5": 0, "BILL_AMT6": 0,
"PAY_AMT1": 0, "PAY_AMT2": 689, "PAY_AMT3": 0, "PAY_AMT4": 0, "PAY_AMT5": 0, "PAY_AMT6": 0
}'{
"default_probability": 0.9275,
"risk_band": "high",
"top_factors": [
{"feature": "PAY_1", "value": 2.0, "shap_contribution": 1.57},
{"feature": "LIMIT_BAL", "value": 20000.0, "shap_contribution": 0.31},
{"feature": "PAY_2", "value": 2.0, "shap_contribution": 0.26}
]
}docker build -t credit-risk-api .
docker run -p 8000:8000 credit-risk-api- Calibrate probabilities (Platt scaling) so the score is a true probability, not just a ranking.
- Try a fairness-constrained training objective (
fairlearn.reductions.ExponentiatedGradient) to close the EDUCATION/AGE gaps found above rather than just measuring them. - Deploy to a free-tier host (Render/Fly.io) and link the live endpoint here.