igor-millis~/work/credit-default-api
EN/PT
← all work · machine learning · model serving

Credit Default API

Estimate the probability that a credit-card client defaults next month, choose the decision threshold on purpose, and serve it as a validated REST API.

XGBoostscikit-learnSHAPFastAPIPydanticDocker
30,000
clients · 23 predictors
22.1%
default rate (moderate imbalance)
0.777
ROC AUC on held-out test
60.1%
of defaulters identified
dataset
Default of Credit Card Clients (UCI)
task
binary classification · next-month default
model
XGBoost (grid search) + threshold 0.24
serving
FastAPI · POST /predict · Docker
ui
Streamlit client via Docker Compose
status
runs locally · no public deployment yet
01 · problem

Accuracy is the wrong target

With 77.9% of clients paying on time, a model that predicts “no default” for everyone is already 78% accurate — and useless. The costs of the two errors are not symmetric: missing a defaulter is usually worse than flagging a good client for review.

So the project evaluates precision, recall, F1 and ROC AUC, keeps a stratified held-out test set (6,000 clients) untouched until the end, and treats the decision threshold as a modeling choice.

02 · method

One pipeline, four model families

Preprocessing lives inside a scikit-learn Pipeline so training and inference apply identical transformations: StandardScaler for amounts, OneHotEncoder for sex, education and marital status, and the ordinal payment-status history (PAY_0…PAY_6) passed through. Each family was trained as a baseline and with grid search.

modelvariantaccuracyprecisionrecallF1ROC AUC
Logistic Regressionbaseline0.80950.69660.24570.36320.7100
Logistic Regressiongrid search0.67800.36750.63230.46480.7105
Decision Treetuned0.8173———0.7477
Random Forestbaseline0.8135———0.7546
Random Forestgrid search0.7867———0.7735
XGBoostbaseline0.81830.66260.36400.46980.7731
XGBoostgrid search0.81730.66200.35570.46270.7770

Test set, threshold 0.50. Decision Tree and Random Forest report accuracy and AUC only in the source. Source: README.md, notebooks/09_model_selection.ipynb.

best hyperparameters · 72 combinations · CV ROC AUC 0.7853

colsample_bytree  = 0.8
learning_rate     = 0.05
max_depth         = 7
min_child_weight  = 5
n_estimators      = 100
reg_lambda        = 5
subsample         = 0.8
03 · threshold

Moving the threshold is the real win

At the default 0.50 cut-off, grid search barely moved XGBoost (AUC 0.773 → 0.777) and recall stayed near 36%. The probability ranking was good; the decision rule was the bottleneck. Drag the threshold:

xgboost (grid search) · test set · 0.05 gridn = 6,000
0.50
precisionrecallF1
0.000.250.500.751.000.10.30.50.70.9τ=0.24
—
precision
—
recall
—
F1
—
implied cFN/cFP
Curve: notebooks/09_model_selection.ipynb (test set, n = 6,000). The operating threshold 0.24 was selected by maximizing F1 on the validation split (F1 = 0.5405) in notebooks/010_model_validation.ipynb. Implied cost ratio = (1 − τ)/τ, valid only if probabilities are calibrated.

The final model uses τ = 0.24: it now finds 60.1% of defaulters instead of 35.6%, trading precision (0.66 → 0.49) for recall — a business decision made explicit. Why 0.24 and what it implies is discussed in the article “A threshold is a decision”.

Final confusion matrix — test set, τ = 0.24

pred 0
pred 1
actual 0
3,843true neg.
830false pos.
actual 1
530false neg.
797true pos.
metricvalue
accuracy0.7733
precision0.4899
recall0.6006
F10.5396
ROC AUC0.7770
04 · interpretability

What drives the prediction

SHAP analysis and the model's feature importance agree: recent payment history dominates. PAY_0 (status in the most recent month) alone carries about 44% of the importance.

PAY_00.436PAY_20.140PAY_30.045PAY_40.038PAY_50.030PAY_60.021SEX_20.019EDUCATION_50.018PAY_AMT20.017LIMIT_BAL0.017
XGBoost feature importance, top 10 (one-hot columns shown with their level). Source: models/xgboost_tuned_feature_importance.csv.
05 · serving

From pickle to API

The pipeline and threshold are loaded once with joblib. POST /predict receives the 23 client features, validated by a Pydantic schema with Literal domains for categorical codes — SEX = 7 is rejected with HTTP 422 before reaching the model.

POST /predict
{
  "LIMIT_BAL": 20000, "SEX": 1, "EDUCATION": 2, "MARRIAGE": 1, "AGE": 24,
  "PAY_0": 2, "PAY_2": 2, "PAY_3": 0, "PAY_4": 0, "PAY_5": 0, "PAY_6": 0,
  "BILL_AMT1": 3913, ... "PAY_AMT6": 0
}

→ 200 OK
{
  "probability": 0.3297,
  "threshold": 0.24,
  "prediction": 1,
  "classification": "inadimplente"
}
  • Tests: pytest + FastAPI TestClient — valid payload returns 200 with probability and threshold in [0, 1]; invalid category returns 422.
  • Docker: Python 3.13 image with a minimal requirements-api.txt; only the final model artifacts are copied.
  • Compose: a Streamlit client talks to the API container over the internal network.
06 · next

Limitations & next steps

  • No public deployment yet — the API runs locally or in Docker.
  • Calibration checked after the fact (article): scores are calibrated (ECE ≈ 0.008 on test), so τ = 0.24 can be read as a cost ratio of about 3×.
  • Planned: deploy, integrate the interface with the hosted API and finalize documentation.