credit-risk-explain

Explainable credit risk ranking

Work the riskiest accounts first, and say why for each one.

Ranks a capacity-limited collections or outreach queue by calibrated default risk, attaches plain-language reason codes to every account, sizes the queue from an explicit cost/benefit matrix, and publishes a fairness audit and model card generated from the same run.

View on GitHub Model card Quickstart

Cumulative capture of defaulters by share of accounts worked, LightGBM versus logistic regression
LightGBM captures 50.7% of next-month defaulters in the riskiest 20% of the holdout queue (logistic regression: 48.2%).
Defaulters captured in top 20%
50.7%

baseline 48.2%

ROC-AUC
0.778

baseline 0.738

Calibration error (ECE)
0.010

0.016 before calibration

Largest group TPR gap
18.0 pts

age_band 18-24 60.6% vs 55+ 42.6%

Holdout of 6,000 accounts from UCI Default of Credit Card Clients (30,000 rows), stratified split. Every figure on this page is read from the committed results of that run.

Features

Quickstart

Requires Python 3.11+, uv and make. One command trains, ranks and explains on the bundled synthetic sample:

git clone https://github.com/seanmcrae/credit-risk-explain.git && cd credit-risk-explain && make install demo

Then:

make app            # Streamlit queue viewer on http://localhost:8501
make train-uci      # download UCI data (about 5 MB, checksum-verified, never committed),
                    # train, and regenerate docs/MODEL_CARD.md, docs/img/ and docs/results/
make site           # build the docs site into site/ (offline)
make check          # lint, format check, mypy, tests: the same gates as CI

The CLI covers the rest of the workflow:

uv run credit-rank train    --data data/raw/uci_credit_default.csv --out artifacts/uci
uv run credit-rank evaluate --artifacts artifacts/uci                    # saved holdout metrics
uv run credit-rank evaluate --artifacts artifacts/uci --data new.csv     # score a labeled file, PSI vs holdout
uv run credit-rank rank     --artifacts artifacts/uci --capacity 1200    # writes queue.csv
uv run credit-rank explain 16555 --artifacts artifacts/uci --plot waterfall.png
uv run credit-rank model-card --artifacts artifacts/uci --out docs/MODEL_CARD.md --img-dir img

docker build -t credit-risk-explain . && docker run -p 8501:8501 credit-risk-explain trains on the synthetic sample at build time and serves the app.

Results

UCI Default of Credit Card Clients (30,000 rows): 19,500 training, 4,500 calibration and 6,000 holdout accounts. LightGBM with monotone constraints is the champion; logistic regression is the baseline.

Holdout metricLightGBMLogistic regression
ROC-AUC0.7780.738
PR-AUC0.5570.504
KS0.4290.387
Top-decile lift3.132.93
Precision in top 1% of queue86.7%75.0%
Precision in top 5% of queue77.0%70.7%
Precision in top 10% of queue69.2%64.8%
Precision in top 20% of queue56.1%53.3%
Defaulters captured in top 20%50.7%48.2%
Calibrated ECE0.0100.009
Brier score0.1350.143
Expected value at 20% capacity (illustrative)197,200184,000
Reliability curve
Isotonic calibration fitted on a separate split: ECE 0.010 after, 0.016 before.
Expected value by queue size
Under the illustrative economics in configs/default.yaml, value peaks at 3,102 accounts (51.7% of the holdout).

Lift by decile (LightGBM)

DecileAccountsDefaultsDefault rateLiftCum. capture
160041569.2%3.1331.3%
260025843.0%1.9450.7%
360015826.3%1.1962.6%
460010918.2%0.8270.8%
560010116.8%0.7678.4%
66009115.2%0.6985.3%
76006711.2%0.5090.4%
86006010.0%0.4594.9%
9600447.3%0.3398.2%
10600244.0%0.18100.0%
SHAP summary of feature contributions on the holdout
Exact TreeSHAP contributions in log-odds. Reason codes group these features and cite only the groups that raise an account's risk.
Static queue dashboard: top of the queue, one account's waterfall, capture and reliability
The top of the holdout queue with the first account's SHAP waterfall, as the CLI and Streamlit viewer present it.

Score PSI between training and holdout is 0.002. Full definitions and every table are in the model card.

How evaluation works

Fairness findings

Sex, age, education and marital status never enter the model; they are joined back only for the audit. "Priority" means falling inside the top 20% of the holdout queue. The audit reports selection parity and error-rate parity side by side, because being worked can be a burden (collections contact) or a benefit (early hardship outreach) depending on the program.

AttributePriority-rate ratioTPR gapFPR gapMax abs calibration gap
age_band0.650.1800.0770.029
education0.670.0850.0530.008
marriage0.950.0150.0020.023
sex0.940.0360.0160.011

The largest gap is on age_band: 60.6% of defaulters in 18-24 are prioritized versus 42.6% for 55+. The repository measures and publishes this gap; it does not apply a mitigation.

Where the gap comes from

Mean SHAP contribution (log-odds) among each group's defaulters, for the features that differ most between the two groups. A positive difference moves 18-24 defaulters up the queue. This is a proxy screen, not a causal decomposition.

Feature18-2455+Difference
utilization_mean-0.100+0.073-0.173
log_limit+0.261+0.098+0.164
payment_to_limit_mean-0.040+0.060-0.100
pay_status_recent+0.439+0.351+0.088
TPR at capacity by group, and feature contributions behind the largest gap

Slices under 100 accounts are shown in grey and left out of the summary: education: other/unknown (82), marriage: other/unknown (75).

Per-group default rates, priority rates, TPR, calibration gaps and AUC are in the model card's fairness audit.

Example output

make demo on the bundled synthetic sample, captured when this site was built. Synthetic numbers say nothing about real-world performance.

$ make demo
Data: bundled synthetic sample (5,000 rows)  rows=5,000  default rate=21.9%
Holdout: 1,000 accounts. Champion: LightGBM
LightGBM             AUC=0.778  PR-AUC=0.550  KS=0.418  lift@D1=3.20  ECE=0.029
                     P@1%=0.700  P@5%=0.760  P@10%=0.700  P@20%=0.555
Logistic regression  AUC=0.786  PR-AUC=0.569  KS=0.460  lift@D1=3.29  ECE=0.028
                     P@1%=0.800  P@5%=0.780  P@10%=0.720  P@20%=0.550
Expected value @ 200 accounts: 32,400   value-maximizing capacity: 600 (60.0%) -> 40,800
Score PSI train vs holdout: 0.0049
Artifacts in artifacts/demo
Queue of 50 of 1,000 accounts -> artifacts/demo/queue.csv
 rank   id  probability  decile    reason_codes                                  primary_reason
    1 2185        0.800       1 R05;R01;R03;R02 Payments are small relative to the balance owed
    2 2165        0.800       1 R05;R01;R03;R02 Payments are small relative to the balance owed
    3 3081        0.800       1 R05;R01;R03;R02 Payments are small relative to the balance owed
    4 3813        0.800       1 R05;R01;R03;R02 Payments are small relative to the balance owed
    5 3153        0.765       1 R05;R01;R03;R02 Payments are small relative to the balance owed
    6 1855        0.750       1 R03;R05;R01;R02        Balance is high relative to credit limit
    7 2646        0.750       1 R03;R01;R05;R02        Balance is high relative to credit limit
    8 3465        0.750       1 R05;R01;R03;R02 Payments are small relative to the balance owed
    9 2341        0.750       1 R01;R05;R03;R02                 Most recent payment is past due
   10 3334        0.750       1 R05;R01;R03;R02 Payments are small relative to the balance owed
Account 2185: P(default)=0.800  decile=1
Reasons:
  R05 Payments are small relative to the balance owed  [payment_to_limit_mean=0.00, +1.66]
  R01 Most recent payment is past due  [pay_status_recent=4.00, +1.01]
  R03 Balance is high relative to credit limit  [utilization_recent=0.90, +0.75]
  R02 Repeated or ongoing delinquency in the last six months  [delinquency_streak=6.00, +0.42]
Largest contributions (log-odds):
  +0.958  Repayment status last month (k = months late) = 4.00
  +0.907  Last statement balance divided by credit limit = 0.90
  +0.724  Average payment divided by credit limit = 0.00
  +0.527  Average payment as a share of the prior statement balance = 0.00
  +0.405  Last payment as a share of the prior statement balance = 0.00
  -0.396  Average six-month balance-to-limit ratio = 0.92

Architecture

Pipeline architecture UCI download or synthetic generator pandera schema validation Row-wise feature engineering Audit groups: sex, age band, education, marriage Stratified split: train / calibration / test Logistic regression baseline LightGBM with monotone constraints Calibrator fitted on calibration split SHAP attributions and reason codes Holdout evaluation: AUC, KS, lift, precision at k, ECE, PSI Queue economics: value by capacity Fairness slice audit metrics.json and charts MODEL_CARD.md CLI: rank and explain Streamlit queue viewer
Mermaid source
flowchart TB
    A["UCI download or synthetic generator"] --> B["pandera schema validation"]
    B --> C["Row-wise feature engineering"]
    B --> G["Audit groups: sex, age band, education, marriage"]
    C --> D["Stratified split: train / calibration / test"]
    D --> E1["Logistic regression baseline"]
    D --> E2["LightGBM with monotone constraints"]
    E1 --> F["Calibrator fitted on calibration split"]
    E2 --> F
    F --> H["Holdout evaluation: AUC, KS, lift, precision at k, ECE, PSI"]
    F --> I["Queue economics: value by capacity"]
    E2 --> J["SHAP attributions and reason codes"]
    G --> K["Fairness slice audit"]
    F --> K
    H --> L["metrics.json and charts"]
    I --> L
    J --> L
    K --> L
    L --> M["MODEL_CARD.md"]
    L --> N["CLI: rank and explain"]
    L --> O["Streamlit queue viewer"]
Module Responsibility
schema.py, data.py, download.py Canonical columns, pandera validation, UCI download with SHA-256 check, audit-group decoding
synthetic.py Seeded synthetic accounts with the same schema
features.py 14 leakage-safe features; protected attributes excluded by construction
models.py Stratified split, both models, Laplace-smoothed isotonic or Platt calibration
metrics.py, evaluation.py KS, lift and capture, precision@k, expected value curve, ECE, PSI
explain.py TreeSHAP / linear SHAP and grouped reason codes
fairness.py Per-group priority rate, TPR/FPR at capacity, calibration gap, slice AUC
queue.py Saved model bundle, queue building, single-account explanations
pipeline.py, model_card.py, plots.py, cli.py Orchestration, generated model card, charts, typer CLI
docsite/ Offline static site: README sections, rendered docs, results snapshot, SVG diagram

Design decisions

Data

Where it fails

Read from the UCI holdout snapshot at 20% capacity. The fairness audit drops slices under 100 accounts from its summary; they are listed here because that is where the model is weakest.

Model and data limits

Slice or failure mode Holdout evidence What it means
Age 18-24 vs 55+ TPR 0.606 vs 0.426 (gap 0.180, target < 0.10); 18-24 calibration gap 0.029 against a 0.03 limit Young defaulters are worked far more often; traced mainly to credit limit acting as a partial proxy for age
Age 55+ 227 accounts Smallest audited age slice; no confidence intervals are reported, so its TPR is a noisy point estimate
Education: high school AUC 0.734 (1,014 accounts) vs 0.78-0.79 for university and graduate school Weakest ranking among the audited slices
Education: other/unknown AUC 0.551, calibration gap 0.099 (82 accounts, excluded from the summary) Ranking is close to random and probabilities run high for this group
Marriage: other/unknown AUC 0.679, calibration gap 0.072 (75 accounts, excluded from the summary) Same pattern, smaller
Top of the queue Precision 86.7% in the top 1%, 69.2% in the top 10%, 56.1% in the top 20% Most of a 20% queue's contacts are still non-defaulters

Design and scaffolding limits

Limit Consequence
Stratified split of one six-month 2005 snapshot No out-of-time test; the PSI of 0.002 compares train with a holdout from the same period, so it cannot show drift
Baselines are LightGBM vs logistic regression only No rule-based queue ("2+ months late", "highest balance first") is scored, so the gain over current practice is not measured
Champion is fixed in config On the bundled synthetic sample, logistic regression scores higher (AUC 0.786 vs 0.778) and LightGBM is still the champion
Economics are placeholders Expected value and the value-maximizing capacity move with the contact cost, loss given default and cure rate in config

Considered and rejected. Ranking the queue on the calibrated probability. Isotonic calibration is a step function, so on the UCI holdout its top level (0.861) ties 61 accounts and throws away ordering inside the block. The queue ranks on the raw score and uses the calibrated probability for display, economics and audits (see Design decisions and PRODUCT.md).

Known limitations

The model card covers intended use, out-of-scope uses and limitations in full; the product brief covers users, success metrics, risks and the roadmap.