Skip to content
Back to work

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 is there to give the loan officer a quantified second opinion, not to take 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 I read before writing code

  • Read Naeem Siddiqi's 'Credit Risk Scorecards: Developing and Implementing Intelligent Credit Scoring' end-to-end. The WoE/IV chapter alone is the methodology bible. Read it twice.
  • Studied the Basel II framework (BCBS Pillar 1) for credit risk. Specifically the Internal Ratings-Based Approach: PD (probability of default), LGD (loss given default), EAD (exposure at default). My scorecard implements PD; the rest are placeholders for the next iteration.
  • Read regulatory technical standards from BCBS, EBA, and the Federal Reserve on model validation. The validation gauntlet (Gini, KS, PSI, ROC) is non-negotiable for a regulated model. Implemented all four.
  • Studied Population Stability Index methodology (Karakoulas 2004). PSI compares score distributions between training and out-of-time samples. PSI < 0.1 = no shift, 0.1-0.25 = moderate, > 0.25 = significant shift. Industry threshold for 'don't deploy this model' is 0.25.
  • Read about West African microfinance dynamics, group lending (joint liability), seasonal income (agricultural cycles), gender roles in finance (women's repayment rates often higher than men's). Calibrated the synthetic data generator to reflect these patterns instead of 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 had to be honest, not flattering.

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, you can read the points-per-feature off the model and explain exactly why someone scored 480.

  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. 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. I report the number the pipeline actually produces rather than the one I wanted.

  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 the whole way down.

  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. Stresses the system, not just the score.

  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. This is the layer that makes the model actually usable by humans.

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, not the one that scores best.
  • 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

  • I had not implemented WoE/IV from scratch before. Read Siddiqi's book chapter twice, then implemented `compute_all_woe_iv` and `woe_transform` myself. The implementation taught me what every formula in the book actually meant.
  • 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 it on a synthetic test where I manually shifted the distribution and watched PSI track the shift.
  • 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, then lower default, longer term, then higher default, group lending, then lower default. Then the model could actually learn.
  • Capital-requirement math (RWA, capital floor) is in the Basel II framework but I'm only at the PD layer. I know what LGD and EAD are; haven't implemented them. Honest about the scope limit.

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 can't capture interaction effects I haven't thought of. Real data always surprises you. Also need to extend to LGD and EAD for full Basel II coverage.

What I learned

In regulated domains, explainability isn't a feature, it's the constraint that picks the model. Logistic regression isn't old-fashioned, it's accountable. Validation theatre is worse than no validation: random-split metrics that look good but won't survive production are how models fail in deployment.