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.
- 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
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.
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.
| model | variant | accuracy | precision | recall | F1 | ROC AUC |
|---|---|---|---|---|---|---|
| Logistic Regression | baseline | 0.8095 | 0.6966 | 0.2457 | 0.3632 | 0.7100 |
| Logistic Regression | grid search | 0.6780 | 0.3675 | 0.6323 | 0.4648 | 0.7105 |
| Decision Tree | tuned | 0.8173 | — | — | — | 0.7477 |
| Random Forest | baseline | 0.8135 | — | — | — | 0.7546 |
| Random Forest | grid search | 0.7867 | — | — | — | 0.7735 |
| XGBoost | baseline | 0.8183 | 0.6626 | 0.3640 | 0.4698 | 0.7731 |
| XGBoost | grid search | 0.8173 | 0.6620 | 0.3557 | 0.4627 | 0.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
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:
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
| metric | value |
|---|---|
| accuracy | 0.7733 |
| precision | 0.4899 |
| recall | 0.6006 |
| F1 | 0.5396 |
| ROC AUC | 0.7770 |
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.
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.
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.