Skip to content

Case study

Credit Risk Scorecard

Basel II scorecard for West African microfinance

  • Python
  • scikit-learn
  • Pandas
  • Next.js
  • Recharts

01

The problem

Microfinance institutions in West Africa make lending decisions on intuition because the analytical infrastructure isn't there. Loans are granted or denied based on the loan officer's read of the applicant's character, with results that range from biased to disastrous depending on the officer. I wanted to build a Basel II-compliant credit scorecard from scratch, not import a black-box library, to actually understand WoE, IV, points conversion, validation gauntlets, and stress testing the way an actuary would. It gives the loan officer a quantified second opinion rather than taking the decision off them.

02

How it fits together

A deliberately plain model with a heavy validation stage. The interesting part is on the right, where a stability metric and a stress test can disagree.Drag sideways if it runs past the edge

03

What this is built on

  • Naeem Siddiqi's 'Credit Risk Scorecards' is the methodology reference. The WoE/IV chapter is the core of the pipeline.
  • The Basel II framework (BCBS Pillar 1) sets the target. The Internal Ratings-Based Approach has three components: PD (probability of default), LGD (loss given default), EAD (exposure at default). The scorecard implements PD; the rest are placeholders for the next iteration.
  • Regulatory technical standards from BCBS, EBA and the Federal Reserve set the validation requirements. The gauntlet (Gini, KS, PSI, ROC) is non-negotiable for a regulated model. All four are implemented.
  • Population Stability Index methodology (Karakoulas 2004) sets the drift check. PSI compares score distributions between training and out-of-time samples. Below 0.1 is no shift, 0.1 to 0.25 is moderate, above 0.25 is significant. The industry threshold for 'do not deploy' is 0.25.
  • West African microfinance dynamics set the generator: group lending (joint liability), seasonal income (agricultural cycles), gender roles in finance (women's repayment rates often higher than men's). The synthetic generator reflects those patterns rather than cloning German Credit defaults.

04

What I couldn't do

  • No real loan data. Privacy, NDAs, none of it accessible to a student researcher.
  • Had to be explainable end-to-end, every step audit-able for a regulator.
  • Aspirational target: Basel II framework, the international standard.
  • Models like XGBoost or random forest, while higher-accuracy, are NOT permitted in regulated credit scoring without extensive explainability layers. Logistic regression with WoE was the only sound choice.
  • Validation: Gini, KS, PSI, all required to pass.

05

The decisions that shaped it

  1. Decision 01

    Generate 12,000 synthetic West African microfinance loans with regionally-calibrated distributions.

    I can't get real microfinance data, and toy datasets like German Credit don't reflect West African dynamics (group lending, sector-specific defaults, dependent counts that matter). I built a synthetic generator with fields specific to the region: `group_lending` (boolean), `has_collateral` (boolean), `country` (categorical), `sector` (agriculture, services, manufacturing), `dpd_history_days` (days past due in prior loans). Default rates calibrated to public microfinance default-rate stats. Not perfect, but more honest than a German Credit clone.

  2. Decision 02

    WoE/IV feature selection, then logistic regression, no fancier model.

    Basel II requires explainability. Random forests and XGBoost are forbidden in production credit scoring without extensive explainability layers because regulators can't audit them. Logistic regression with WoE-transformed features is the industry standard for a reason: the points-per-feature can be read off the model, and a score of 480 can be explained exactly.

  3. Decision 03

    WoE binning by deciles for continuous features, optimal binning for categoricals.

    Raw `monthly_income_usd` had high IV but skewed distribution and unstable bands. Binned into deciles via WoE, became more stable across the population. For categorical features (`sector`, `loan_purpose`), used optimal binning to merge low-volume categories together. The trade-off: lose some information, gain stability and explainability.

  4. Decision 04

    Validation gauntlet: Gini, KS, PSI, ROC, all required to pass.

    Three different lenses on model quality: Gini (overall discrimination, want > 0.4), KS (best cutoff separation, want > 0.3), PSI (population stability over time, want < 0.1). A model that passes one and fails another is a red flag. I rejected several feature sets that had high Gini but unstable PSI before settling on the final set. Final: Gini 0.29 and KS 0.23 on the time-based holdout, both short of those thresholds, with PSI 0.008 comfortably inside. The discrimination ceiling is set by the generator, not the modelling: no feature clears Strong information value (previous_defaults tops out at IV 0.13, total IV across the eight selected features is about 0.51), and a scorecard cannot separate better than its features do. The number reported is the one the pipeline actually produces.

  5. Decision 05

    Time-based holdout, not random-split holdout.

    The first version validated on a random split, so the holdout came from the same months as the training data and every stability measure was flattering by construction. The loan book had no origination date at all, so a time-based split was not merely unused, it was impossible. Adding a vintage column and refitting on the earlier 70 percent of the book cost about 0.02 Gini, and it produced a result I did not expect: PSI stayed at 0.002 while realised defaults rose from 12.3 to 15.9 percent. PSI compares score distributions, so it cannot see a deterioration driven by something no feature measures. Monitoring PSI alone would have reported this model as stable.

  6. Decision 06

    Multi-scenario stress tests: drought, currency crisis, pandemic.

    Real lenders need to know: what happens to default rates under shock? I implemented stress tests that multiply default probabilities by scenario-specific factors (drought: 1.5x for agriculture loans, currency crisis: 1.3x across the board, pandemic: 2.0x for services). The output shows band-by-band default-rate impact and capital-requirement implications. The stress test runs the whole book through the scenario.

  7. Decision 07

    Basel II points conversion with explicit factor + offset.

    Logistic regression coefficients are abstract. Points conversion turns them into a 300-850 score every loan officer can use. Picked factor 28.85 / offset 487.123 (industry standard) so the score doubles odds at every 20-point increment. The points card is what a loan officer actually sees.

06

What broke and how I changed course

  • First feature set had monthly_income as raw, high IV but skewed. WoE-binning by deciles became more stable across population shifts. Standard scorecard practice but I had to discover it via the validation failing.
  • I assumed a time-based holdout would make PSI reveal the drift. It did not. The split was still the right change, because a same-period holdout measures nothing, but the number I expected to move stayed flat and the damage showed up in the outcome rate instead. That taught me more than a confirming result would have: a stability metric can only see the thing it is computed on.
  • Originally tried XGBoost first because the accuracy was higher. Realised the explainability requirement made it disqualifying. Killed the XGBoost branch and went back to logistic regression. The right model is the one regulators allow.
  • Stress tests originally hardcoded scenario multipliers in the script. Refactored to a `SCENARIOS` config so new shocks (climate, geopolitical) could be added without rewriting validation code.

07

What I didn't know, and how I learned

  • WoE/IV was new to implement from scratch. The Siddiqi chapter is the reference, and `compute_all_woe_iv` and `woe_transform` are the implementation. Writing the code made the formulas concrete.
  • Population Stability Index was new. The intuition, comparing two distributions via a sum of `(actual - expected) * ln(actual/expected)`, wasn't obvious at first. Built a synthetic test where I manually shifted the distribution and watched PSI track it.
  • Synthetic data generation is more art than science. My first generator produced loans where every feature was uniformly distributed; the model learned nothing because there was no real signal. Re-built the generator with realistic correlations: higher income, lower default; longer term, higher default; group lending, lower default.
  • Capital-requirement math (RWA, capital floor) is in the Basel II framework but I'm only at the PD layer. I have not implemented LGD or EAD.

08

What shipped

  • 12,000 synthetic West African microfinance loans, regionally-calibrated
  • WoE/IV feature selection pipeline, optimal binning for categoricals
  • Basel II points conversion (factor 28.85, offset 487.123)
  • Validation: Gini 0.27, KS 0.21 on a later-vintage holdout, below industry thresholds and reported as such, capped by the synthetic generator's weak feature signal rather than by the fitting
  • PSI 0.002 across vintages: the score distribution held while realised defaults rose from 12.3 to 15.9 percent, because the deterioration came from a macro shock no feature observes. PSI alone would have called the model stable
  • Multi-scenario stress testing (drought, currency crisis, pandemic)
  • Live Next.js + Recharts dashboard
  • Explainable end-to-end: every score traceable back to feature contributions

09

What's next

I'd swap synthetic data for real anonymised data the moment I have access. The synthetic generator is calibrated to public stats, but it cannot capture interaction effects I have not thought of. I also need to extend to LGD and EAD for full Basel II coverage.

What I learned

In regulated domains, explainability is the constraint that picks the model. Logistic regression with WoE is the industry standard because a regulator can read the points off it. Random-split metrics that look good but will not survive production are how models fail after deployment.