Back to projects
ML / Risk Analytics

Credit Risk Scoring Engine

Engineered 176 features from 307K loan records, tuned XGBoost with Optuna and MLflow, and deployed a FastAPI service that returns a risk score with per-decision SHAP explanations.

  • XGBoost
  • SHAP
  • FastAPI
  • MLflow
  • Docker
  • Google Cloud Run
  • Streamlit
Test ROC-AUC
0.78
API Latency
<35ms
Records
307K
Features
176

Measured in testing, not in live production.

The real problem was in the other tables

The Home Credit Default Risk dataset is 307,511 loan applications spread across 8 CSV files. There is one main applications table, plus tables for credit bureau history, previous applications, installment payments, point-of-sale cash loans, and credit card balances. Some of those have more than 10 million rows.

The modeling problem is not really in the main table. It is in deciding how to squeeze each of the other tables down to a handful of numbers per applicant: active credit ratio and max overdue days from the bureau data, approval rate from previous applications, payment ratio and days late from installments. After joining everything and adding a few domain ratios, the feature matrix was 176 columns.

The single most important feature turned out to be one I built: the product of two external credit scores. Someone who scores poorly on both is much riskier than someone who scores poorly on just one, and multiplying them captures that.

Accuracy would have lied

Only 8.1% of the applications are defaults. A model that predicts "no default" for everyone gets about 92% accuracy and is completely useless. So I measured ROC-AUC instead, which does not reward ignoring the minority class.

I weighted the default class about 11 times more heavily to match the imbalance, split the data 60/20/20 with the default rate preserved in every split, and ran 30 rounds of hyperparameter search with Optuna, logging each run in MLflow.

The final model scored 0.7743 on validation and 0.7795 on the held-out test set. The fact that those two numbers are close mattered more to me than either number on its own.

The small things that would have broken it

The employment column uses 365243 days, about 1,000 years, to mean "never employed." Left alone, it looks like the most experienced workers in history. I replaced it with a missing value and added a separate flag so the model still knows it happened.

The harder problem was keeping preprocessing identical between training and the live API. The API accepts whatever fields a caller has, so a request might be missing most of the 176 columns. The pipeline remembers the exact column order it was trained on and realigns every request to it, filling gaps the same way it did during training.

Every decision comes with a reason

In real lending you cannot just say "denied." Regulators require a reason. So every prediction comes back with the features that pushed the risk up or down, computed in the same request.

I used XGBoost's built-in SHAP calculation instead of the separate shap library. It runs the same algorithm without the extra dependencies, so explanations add almost nothing to response time. A warm request takes under 35ms. The API runs in Docker on Google Cloud Run, a Streamlit dashboard calls it live, and 41 tests cover the pipeline, training, explanations, and API.

Where it falls short

The test score of 0.7795 is a little below the roughly 0.79 that strong public Kaggle entries reach. The likely gap is the monthly bureau balance table, which I did not aggregate, plus the hand-crafted interaction features top entries use.

Gender shows up among the model's top 8 features. In many places, including the US, using gender in lending decisions is restricted or illegal. This model is a portfolio project and should never make real credit decisions, but if it were going anywhere near production it would need a proper fairness audit first.

It also leans hard on the external credit scores, which new applicants often do not have, and it was trained on one Central and Eastern European lender's data. It would not transfer cleanly to other markets, and it would drift as borrower behavior changes.

Where I would take it next

Aggregating the monthly bureau balance data is the most direct way to close the gap with the public benchmark. After that, a fairness audit across gender and age, comparing approval rates and error rates between groups, to see how much the model is relying on characteristics it should not.

I wrote more about this in Building a Credit Scoring Model From Scratch.