Read the story

Architecture

Scores loan default risk.Ran on Chameleon Cloud.

Containers pull the public LendingClub loan book, fit one transform on the training rows and save it beside the data. XGBoost trains on those rows, and a FastAPI service rebuilds each uploaded loan with the same saved transform before it scores it.

0.79 ms

median to score one loan row, model in process, over 100 calls

22,607 loans

held out for the stress tests, never seen in training

7 days

on the calendar from the first Dockerfile to the last commit, May 5 to 11, 2025

0.71 AUC

ROC AUC of the served model in 2 fold cross validation

New York --:-- simulated

A loan file
Loan Radar Containers pull the public LendingClub loan book, fit one transform on the training rows and save it beside the data. XGBoost trains on those rows, and a FastAPI service rebuilds each uploaded loan with the same saved transform before it scores it. Offline tests stress sets, pytest MLflow Postgres and MinIO Prometheus Grafana, every 15 s LendingClub book public, 2007 to 2018 ETL containers extract, transform, load Object store loaded with rclone XGBoost training 2 fold CV, 1.8M rows FastAPI scorer one loan per request Credit officer the imagined user Flask page upload a .txt loan CSV rows + pkl train split model.pth template sets runs, metrics metrics upload .txt POST file Chameleon Cloud KVM, Terraform

Hover a part to trace what it touches. Click it for its notes.

  • Built by me
  • Managed by Google
  • Outside system
  • Main data path
  • On demand
  • Credentials cross

When it breaks

What each failure does, and what it does not do.

  1. A loan field is blank

    The API fills it with the training median or mode saved in the pickle, so one empty field does not sink the request.

    fastapi_pt/app.py

  2. A category never seen before

    One hot columns are rebuilt from the training list and missing ones are added as zeros, so the model always gets the columns it expects.

    fastapi_pt/app.py

  3. An expected column is gone

    The transform stops and names the missing columns before it writes anything.

    etl/docker/docker-compose-etl.yaml

What I tuned

Small choices that keep it fast and cheap.

  1. Fit once, load everywhere

    Medians, modes, encoders and the scaler are fit on training rows only. Validation and the API load the same pickle.

    etl/docker/docker-compose-etl.yaml

  2. Class weight from the counts

    Training held 1,564,054 low risk and 244,480 high risk loans. A scale_pos_weight of 6.4 matches that ratio.

    train/train_xgboost_onnx.ipynb

  3. Run history on block storage

    MLflow keeps runs in Postgres and artifacts in MinIO on mounted block storage, so the history outlives any container.

    train/docker/docker-compose-block.yaml

Guards

Rules the code enforces, not rules people remember.

  1. Training statistics only

    Validation rows are filled, encoded and scaled with values learned from training rows, never from themselves.

    etl/docker/docker-compose-etl.yaml

  2. Outcome columns dropped

    Payment, recovery and settlement fields only exist after a loan ends, so the split drops them before anything trains.

    etl/data-split/data-split.py

  3. Every score is counted

    Each prediction feeds a confidence histogram and a per class counter that Prometheus scrapes.

    fastapi_pt/app.py