Loan Default Prediction API
Train, register, and serve a loan default ML model with Docker.
Introduction
30 Second Summary
Every time a bank approves or rejects a loan application, there's a prediction model running behind the scenes. It estimates the risk that someone won't pay back what they borrowed.
In this project, you will build a complete machine learning pipeline that predicts loan defaults, track your experiments, and serve real-time predictions through a REST API packaged inside a Docker container.
What You'll Build
When it's finished, you can send a JSON loan application to your API and get back a default probability, prediction, and risk level in milliseconds, all running inside a Docker container you can hand to anyone.
By the end of this project, you'll have:
- A synthetic data generator that produces realistic loan records with credit scores, income, debt ratios, and a class-imbalanced default target you can observe and explore.
- Three trained models compared side-by-side in MLflow's experiment tracking UI, with the best one promoted to champion so your API always serves the current winner.
- A FastAPI prediction endpoint where you POST a loan application and instantly receive a risk assessment with probability, prediction, and risk level.
- The entire service running inside a Docker container that works identically on any machine, ready for production deployment.
- Secret Mission: Add a GitHub Actions CI/CD pipeline that automatically builds and tests your Docker image on every push.
Are there any prerequisites?
You need basic Python knowledge (variables, functions, loops). No machine learning or Docker experience is needed.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters to you.
A clear picture of the end goal keeps you focused through each stage of the pipeline.
Generate Financial Data and Train Your First Model
Your environment is set up. Now you need a working model before you can productionize it.
In this step, you will generate synthetic loan data and train multiple models. A naive first attempt will reveal a critical flaw: high accuracy can mask terrible performance at actually catching defaults, which is the whole point of a credit risk model.
In this step, get ready to:
- Create a synthetic data generator that produces realistic loan records.
- Build a training script that trains three models and logs experiments to MLflow.
- Observe how class imbalance fools a naive model into high accuracy but low usefulness.
Create the synthetic data generator
Real loan datasets require lengthy downloads and cleanup. Instead, you will generate synthetic data with realistic financial relationships between features like credit score, income, and default probability.
- In VS Code, create a new file called generate_data.py in your loan-default-predictor folder.
- Add the imports and the first half of the data generation function by pasting this code:
import numpy as np
import pandas as pd
import os
def generate_loan_data(n_samples=10000, random_state=42):
"""Generate synthetic loan data for credit risk modeling."""
rng = np.random.default_rng(random_state)
# Core financial features with realistic distributions
credit_score = rng.normal(680, 80, n_samples).clip(300, 850).astype(int)
annual_income = rng.lognormal(10.8, 0.5, n_samples).clip(20000, 500000)
loan_amount = rng.lognormal(9.5, 0.7, n_samples).clip(1000, 100000)
debt_to_income = rng.uniform(0, 50, n_samples)
employment_length = rng.exponential(5, n_samples).clip(0, 40).astype(int)
home_ownership = rng.choice(
["RENT", "OWN", "MORTGAGE"], n_samples, p=[0.4, 0.1, 0.5]
)
loan_purpose = rng.choice(
["debt_consolidation", "credit_card", "home_improvement", "major_purchase", "other"],
n_samples,
p=[0.4, 0.25, 0.15, 0.1, 0.1],
)
interest_rate = rng.uniform(5, 25, n_samples)
open_accounts = rng.poisson(10, n_samples)
delinquencies_2yr = rng.poisson(0.3, n_samples)
What does this code do?
Each feature uses a statistical distribution that matches real-world patterns. Credit scores follow a normal distribution centred around 680. Income is log-normal (most people earn moderate amounts, a few earn a lot). Loan purposes have weighted probabilities matching actual lending data.
The .clip() calls enforce realistic bounds. Nobody has a credit score of 1500 or negative income.
- Directly below the last line you just pasted, add the default probability calculation, DataFrame assembly, and the main block:
# Calculate default probability based on feature relationships
default_prob = (
-0.005 * credit_score
+ 0.02 * debt_to_income
+ 0.03 * interest_rate
- 0.000005 * annual_income
+ 0.1 * delinquencies_2yr
- 0.02 * employment_length
+ 2.5
)
default_prob = 1 / (1 + np.exp(-default_prob))
default_prob = (default_prob + rng.uniform(0, 0.1, n_samples)).clip(0, 1)
loan_default = (rng.random(n_samples) < default_prob).astype(int)
df = pd.DataFrame({
"credit_score": credit_score,
"annual_income": np.round(annual_income, 2),
"loan_amount": np.round(loan_amount, 2),
"debt_to_income": np.round(debt_to_income, 2),
"employment_length": employment_length,
"home_ownership": home_ownership,
"loan_purpose": loan_purpose,
"interest_rate": np.round(interest_rate, 2),
"open_accounts": open_accounts,
"delinquencies_2yr": delinquencies_2yr,
"loan_default": loan_default,
})
return df
How does the default probability work?
The formula combines features into a linear score, then squashes it through a sigmoid function to produce a probability between 0 and 1. Higher credit scores reduce default risk. Higher debt-to-income and delinquencies increase it.
The result is approximately 20% of records defaulting. This intentional class imbalance mirrors real lending data and will expose a critical problem with naive models later.
- At the very bottom of the file, after the return df line, add the entry point that runs the generator and saves the output:
if __name__ == "__main__":
os.makedirs("data", exist_ok=True)
df = generate_loan_data()
df.to_csv("data/loans.csv", index=False)
print(f"Generated {len(df)} loan records")
print(f"Default rate: {df['loan_default'].mean():.1%}")
print(f"\nSample records:")
print(df.head())
What does the main block do?
The if __name__ == "__main__" block runs only when you execute the file directly. It creates the data directory, generates 10,000 loan records, saves them to data/loans.csv, and prints summary statistics so you can confirm it worked.
- Save generate_data.py.
- Run the data generator by running this command in your terminal:
python generate_data.py
You should see output showing 10,000 generated records with a default rate around 20%, followed by a preview of the first five rows.
Seeing a ModuleNotFoundError?
Make sure your virtual environment is active. You should see (.venv) at the start of your terminal prompt. If not, activate it by running .venv\Scripts\activate (Windows) or source .venv/bin/activate (macOS/Linux).
help me fix a ModuleNotFoundError when running generate_data.py
- Confirm the CSV file was created by running:
ls data/
You should see loans.csv listed in the output.
✔️ Awesome, I've got everything!
Great. Make sure you have saved generate_data.py and confirmed the data file exists.
ⓧ I'd like to double check the full code
Scroll up and confirm your generate_data.py contains all three sections in this order:
- Imports (numpy, pandas, os) and the generate_loan_data function definition with all 10 feature generators.
- The default probability calculation, DataFrame assembly, and return df statement.
- The if __name__ == "__main__" block that saves to data/loans.csv and prints statistics.
Create the training script
Now you have data. The training script will build three models, each with a different approach to handling the class imbalance in your dataset. All experiments get logged to MLflow so you can compare them later.
- Create a new file called train.py in your loan-default-predictor folder.
- Add the imports, MLflow tracking configuration, and the data loading function:
import pandas as pd
import mlflow
from mlflow.models import infer_signature
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import (
accuracy_score,
precision_score,
recall_score,
f1_score,
roc_auc_score,
classification_report,
)
# Store experiment data in a local SQLite database
TRACKING_URI = "sqlite:///mlflow.db"
mlflow.set_tracking_uri(TRACKING_URI)
def load_data():
"""Load and split the loan dataset."""
df = pd.read_csv("data/loans.csv")
X = df.drop("loan_default", axis=1)
y = df["loan_default"]
return train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
What does this code do?
The imports bring in scikit-learn for ML modelling and MLflow for experiment tracking. The tracking URI tells MLflow to store everything in a local SQLite database file rather than needing a separate server.
The load_data function splits the data into 80% training and 20% testing. The stratify=y parameter ensures both sets have the same proportion of defaults, which is important for imbalanced data.
- Below the load_data function, add the preprocessing function that handles mixed feature types:
def build_preprocessor():
"""Build a ColumnTransformer for mixed feature types."""
numeric_features = [
"credit_score",
"annual_income",
"loan_amount",
"debt_to_income",
"employment_length",
"interest_rate",
"open_accounts",
"delinquencies_2yr",
]
categorical_features = ["home_ownership", "loan_purpose"]
# Scale numbers to same range, one-hot encode categories
preprocessor = ColumnTransformer(
transformers=[
("num", StandardScaler(), numeric_features),
("cat", OneHotEncoder(handle_unknown="ignore"), categorical_features),
]
)
return preprocessor
What is a ColumnTransformer?
Your data has two types of features: numbers (credit score, income) and categories (home ownership, loan purpose). ML models need them processed differently. ColumnTransformer applies the right transformation to each column type in a single step.
Numeric features get scaled so no single feature dominates by magnitude. Categorical features get one-hot encoded into binary columns the model can understand.
- Save train.py and verify the imports load correctly by running:
python -c "import train; print('Functions defined successfully')"
You should see Functions defined successfully printed with no errors.
Seeing an ImportError or SyntaxError?
Check that your indentation is consistent (all spaces, no tabs). Make sure the ColumnTransformer import matches from sklearn.compose import ColumnTransformer exactly.
help me fix an import or syntax error in my train.py file
- Return to train.py and add the training function below build_preprocessor. This first part builds the pipeline and computes metrics:
def train_model(model, model_name, X_train, X_test, y_train, y_test):
"""Train a model and log to MLflow."""
preprocessor = build_preprocessor()
pipeline = Pipeline([
("preprocessor", preprocessor),
("classifier", model),
])
with mlflow.start_run(run_name=model_name):
pipeline.fit(X_train, y_train)
# Generate predictions and probabilities
y_pred = pipeline.predict(X_test)
y_proba = pipeline.predict_proba(X_test)[:, 1]
metrics = {
"accuracy": accuracy_score(y_test, y_pred),
"precision": precision_score(y_test, y_pred),
"recall": recall_score(y_test, y_pred),
"f1_score": f1_score(y_test, y_pred),
"roc_auc": roc_auc_score(y_test, y_proba),
}
What does this code do?
The Pipeline chains preprocessing and classification into one object. When you call .fit(), it first transforms the data, then trains the classifier.
The mlflow.start_run context manager creates a tracked experiment run. Everything inside it (metrics, parameters, models) gets logged automatically to your MLflow database.
- Directly below the metrics dictionary (still inside the with mlflow.start_run block), add the logging and reporting code:
# Log everything to MLflow for comparison later
mlflow.log_params({"model_type": model_name})
mlflow.log_metrics(metrics)
signature = infer_signature(X_test, y_pred)
mlflow.sklearn.log_model(pipeline, "model", signature=signature)
print(f"\n{'='*50}")
print(f"Model: {model_name}")
print(f"{'='*50}")
print(f"ROC-AUC: {metrics['roc_auc']:.4f}")
print(f"F1 Score: {metrics['f1_score']:.4f}")
print(f"Precision: {metrics['precision']:.4f}")
print(f"Recall: {metrics['recall']:.4f}")
print(f"Accuracy: {metrics['accuracy']:.4f}")
print(classification_report(y_test, y_pred, target_names=["No Default", "Default"]))
return metrics
What does this code do?
This logs the model type, all metrics, and the full trained pipeline to MLflow. The infer_signature call records the input/output schema so MLflow knows what shape of data the model expects.
The print statements give you immediate terminal feedback showing each model's performance. The classification_report breaks down precision and recall for each class (Default vs No Default), which is where the real story shows up.
- Finally, add the main function and entry point at the bottom of train.py. This trains three models with different strategies:
def main():
mlflow.set_experiment("loan-default-prediction")
X_train, X_test, y_train, y_test = load_data()
print(f"Training set size: {len(X_train)}")
print(f"Test set size: {len(X_test)}")
print(f"Default rate (train): {y_train.mean():.1%}")
print(f"Default rate (test): {y_test.mean():.1%}")
# Model 1: Naive baseline with no class weight adjustment
train_model(
LogisticRegression(max_iter=1000),
"logistic_regression_baseline",
X_train, X_test, y_train, y_test,
)
# Model 2: Balanced weights to penalize missing defaults
train_model(
LogisticRegression(max_iter=1000, class_weight="balanced"),
"logistic_regression_balanced",
X_train, X_test, y_train, y_test,
)
# Model 3: Random forest with balanced weights
train_model(
RandomForestClassifier(
n_estimators=100, class_weight="balanced", random_state=42
),
"random_forest_balanced",
X_train, X_test, y_train, y_test,
)
if __name__ == "__main__":
main()
Why three different models?
The baseline LogisticRegression treats both classes equally. With 80% of records being non-defaults, it can get 80% accuracy by just predicting "no default" for everyone.
The balanced models use class_weight="balanced", which tells the algorithm to penalize misclassifying defaults more heavily. This forces the model to actually learn what defaults look like, even though they are the minority class.
- Save train.py.
✔️ Awesome, I've got everything!
Great. Make sure you have saved train.py and that all five sections are present in order.
ⓧ I'd like to double check the full code
Scroll up and confirm your train.py contains these sections in order:
- Imports (pandas, mlflow, sklearn) plus the TRACKING_URI setup and load_data() function.
- The build_preprocessor() function with numeric and categorical feature lists.
- The train_model() function (pipeline creation, metric computation, MLflow logging, and printing).
- The main() function training all three models, plus the if __name__ == "__main__" entry point.
Observe the class imbalance problem
Your training script is ready. Before you run it, think about what you expect: if 80% of loans do NOT default, what accuracy could a model achieve by simply predicting "no default" for everyone?
- Run the training script:
python train.py
Look at the printed output. The baseline model shows approximately 80% accuracy but recall for the Default class below 50%. The balanced models show dramatically better recall.
What is the class imbalance problem?
A model that says "nobody will default" is 80% accurate because only 20% of borrowers actually default. But it catches zero defaults, making it completely useless for credit risk.
Recall measures how many actual defaults the model catches. The baseline has high accuracy but terrible recall. The balanced models sacrifice some accuracy but catch far more defaults. In credit risk, missing a default costs the bank the entire loan amount. Missing a non-default just means a lost lending opportunity. The costs are asymmetric, so recall matters more than accuracy.
What should I see in the output?
The logistic_regression_baseline model shows approximately 80% accuracy but recall for Default around 30-50%. The logistic_regression_balanced and random_forest_balanced models show lower accuracy but recall jumping to 60-80%.
You will also see new files appear in your project: mlflow.db (the tracking database) and an mlartifacts/ directory (containing saved model files).
Script runs but all metrics are identical?
Check that the class_weight="balanced" parameter is included in the second and third model calls in your main() function. Without it, all three models train identically.
help me debug why all three models in train.py show the same metrics
- Confirm the MLflow artifacts were created by running:
ls mlflow.db mlartifacts/
You should see mlflow.db listed along with contents inside mlartifacts/.
Your models are trained and tracked. Next up, you will explore the MLflow UI to compare these experiments visually and register the best model as your production champion.
Compare Experiments and Register Your Champion
In the last step, you trained three models and saw the class imbalance problem in action. Now you have metrics printed in a terminal, but no systematic way to compare models or tell your serving layer which one to use.
MLflow's tracking UI lets you compare all three experiments side-by-side. Its model registry with the champion alias solves the promotion problem: your API can always load the champion model without hard-coding a specific version.
In this step, get ready to:
- Compare model experiments side-by-side in MLflow's tracking UI.
- Register the best model with the champion alias for production serving.
Explore the MLflow UI
Your three training runs are stored in mlflow.db, but a database file is hard to browse. MLflow ships with a web-based tracking UI that visualizes experiments and lets you compare metrics across runs.
- Start the MLflow tracking UI by running this command:
mlflow ui --backend-store-uri sqlite:///mlflow.db
What does this command do?
This launches a local web server on port 5000 that reads your mlflow.db file. It renders all your logged experiments, metrics, and model artifacts in a browser-friendly dashboard.
- Open http://127.0.0.1:5000 in your browser.
- Click into the loan-default-prediction experiment in the left sidebar.
- You should see three runs listed: logistic_regression_baseline, logistic_regression_balanced, and random_forest_balanced.
- Select all three runs using the checkboxes on the left.
- Click Compare to view their metrics side-by-side.
Look at the ROC-AUC and recall columns. The baseline has high accuracy but low recall, while the balanced models catch far more defaults at the cost of some precision.
Why ROC-AUC over accuracy?
Accuracy tells you what percentage of all predictions were correct. For imbalanced data (20% defaults), a model that always predicts "no default" gets 80% accuracy while catching zero actual defaults.
ROC-AUC measures how well the model separates the two classes across all thresholds. A higher ROC-AUC means the model is better at ranking defaulters above non-defaulters, which is what a lender actually needs.
Keep this terminal running
The MLflow UI runs as a foreground process. Open a new terminal tab or window for the next commands, and keep this one running so you can return to the UI later.
Register the best model with the champion alias
Comparing models visually is useful, but your API needs a programmatic way to know which model to load. MLflow's model registry stores named models with version numbers, and the champion alias is a mutable pointer that always resolves to your current best version.
What is the champion alias pattern?
An alias is a named pointer to a specific model version. Your API loads models:/loan-default-classifier@champion and always gets the current best model.
When you train a better model later, you just re-point the alias to the new version. No code changes needed in your serving layer.
- Create a new file called register_model.py in your loan-default-predictor folder.
- Add the imports and the first part of the registration function that finds the best run:
import mlflow
from mlflow import MlflowClient
TRACKING_URI = "sqlite:///mlflow.db"
mlflow.set_tracking_uri(TRACKING_URI)
def register_best_model():
"""Find the best model and register it with the champion alias."""
client = MlflowClient()
experiment = client.get_experiment_by_name("loan-default-prediction")
if experiment is None:
print("No experiment found! Run train.py first.")
return
runs = client.search_runs(
experiment_ids=[experiment.experiment_id],
order_by=["metrics.roc_auc DESC"],
max_results=1,
)
if not runs:
print("No runs found!")
return
What does this code do?
- MlflowClient() creates a client that can query the tracking database and manage the model registry.
- get_experiment_by_name looks up the experiment you created in train.py.
- search_runs queries all runs, sorted by ROC-AUC descending, and returns only the top one.
- Add the rest of the function below the return statement you just pasted, still inside register_best_model():
best_run = runs[0]
model_type = best_run.data.params.get("model_type", "unknown")
roc_auc = best_run.data.metrics["roc_auc"]
print(f"Best model: {model_type}")
print(f"ROC-AUC: {roc_auc:.4f}")
model_uri = f"runs:/{best_run.info.run_id}/model"
model_name = "loan-default-classifier"
result = mlflow.register_model(model_uri, model_name)
print(f"Registered model version: {result.version}")
client.set_registered_model_alias(
name=model_name,
alias="champion",
version=result.version,
)
print(f"Set 'champion' alias to version {result.version}")
if __name__ == "__main__":
register_best_model()
What does this code do?
- best_run.data.params and best_run.data.metrics pull the parameters and metrics you logged during training.
- mlflow.register_model copies the model artifact into the registry under the name loan-default-classifier.
- set_registered_model_alias points the champion alias at this version. Any code loading models:/loan-default-classifier@champion now gets this exact model.
- Save register_model.py.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before running it.
ⓧ I'd like to double check the full code
Scroll up and confirm your register_model.py contains both chunks in order:
- Chunk 1: imports, TRACKING_URI setup, and the register_best_model() function through the if not runs: return guard.
- Chunk 2: best_run extraction, register_model call, set_registered_model_alias call, and the if __name__ == "__main__" block at the bottom.
Before you run this, what do you think the script will print? Which model do you expect had the highest ROC-AUC?
- Run the registration script:
python register_model.py
You should see output naming the best model, its ROC-AUC score, the registered version number, and confirmation that the champion alias was set to version 1.
Seeing "No experiment found"?
- Make sure you ran train.py successfully in the previous step. The script creates the experiment and runs that register_model.py queries.
- Check that mlflow.db exists in your loan-default-predictor directory by running ls.
Still stuck? Help me debug why register_model.py can't find my MLflow experiment.
Why the champion alias instead of version numbers?
Hard-coding version=1 in your API means you have to redeploy every time you train a better model. The alias decouples your serving code from specific versions.
In production, teams retrain models weekly or monthly. When a new version beats the current champion on your chosen metric, you re-point the alias and the API automatically starts serving the new model on the next restart.
Your best model is now registered and tagged as the champion. Next up, you will build a FastAPI REST API that loads this champion model and serves real-time loan default predictions.
Serve Predictions with FastAPI
You have a champion model registered in MLflow, but it's stuck in a database file on your laptop. Nobody can send it a loan application and get a prediction back.
In this step, you'll wrap the champion model in a FastAPI REST API that loads the model once at startup and serves real-time predictions. You'll send a loan application as JSON and get back a risk assessment in milliseconds.
In this step, get ready to:
- Create a prediction API that loads the champion model from MLflow.
- Test the API with interactive docs and a curl command.
Create the prediction API
Your API needs to do three things: load the champion model at startup, validate incoming loan applications, and return a prediction with a risk level.
Why FastAPI over Flask?
FastAPI automatically validates request bodies using Pydantic models. If someone sends a loan application with a missing field or wrong type, FastAPI rejects it with a clear error before your code even runs.
It also auto-generates interactive API documentation at /docs where you can test endpoints in your browser. Flask requires separate libraries for both of these features.
- Create a new file called serve.py in your loan-default-predictor folder.
- Add the imports, app setup, and model loading by pasting the following code:
import mlflow
import pandas as pd
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
# Point MLflow to the same tracking database used during training
TRACKING_URI = "sqlite:///mlflow.db"
mlflow.set_tracking_uri(TRACKING_URI)
# Create the FastAPI application
app = FastAPI(
title="Loan Default Prediction API",
description="Predict whether a loan applicant is likely to default.",
version="1.0.0",
)
# Load the champion model once at startup
MODEL_NAME = "loan-default-classifier"
MODEL_URI = f"models:/{MODEL_NAME}@champion"
model = mlflow.pyfunc.load_model(MODEL_URI)
What does this code do?
- mlflow.set_tracking_uri(TRACKING_URI) tells MLflow where to find the model registry (the same mlflow.db from training).
- models:/loan-default-classifier@champion resolves the champion alias to whichever model version it points to. If you promote a new model later, the API picks it up automatically on restart.
- The model loads once at the module level, not per request. Loading from MLflow takes seconds, so doing it per-request would make every prediction unusably slow.
- Save serve.py and verify the model loads by running this command:
python -c "from serve import model; print('Champion model loaded successfully')"
You should see Champion model loaded successfully printed to your terminal.
Seeing a ModuleNotFoundError?
- Make sure your virtual environment is activated. You should see (.venv) at the start of your terminal prompt.
- Confirm you're in the loan-default-predictor directory where serve.py and mlflow.db both exist.
- If you see an error about the model not being found, run python register_model.py again to re-register the champion.
Why can't my serve.py find the champion model?
- Now add the Pydantic request and response models below the existing code in serve.py:
# Define the expected shape of a loan application request
class LoanApplication(BaseModel):
credit_score: int
annual_income: float
loan_amount: float
debt_to_income: float
employment_length: int
home_ownership: str
loan_purpose: str
interest_rate: float
open_accounts: int
delinquencies_2yr: int
# Define the shape of the prediction response
class PredictionResponse(BaseModel):
default_probability: float
prediction: str
risk_level: str
What do Pydantic models do here?
LoanApplication defines exactly which fields the API expects and their types. If a request is missing credit_score or sends it as a string instead of an integer, FastAPI returns a 422 error with a clear message. No manual validation code needed.
PredictionResponse defines the shape of the JSON the API sends back. FastAPI uses this to generate accurate documentation automatically.
- Finally, add the two API endpoints below the Pydantic models in serve.py:
@app.get("/health")
def health_check():
return {"status": "healthy", "model": MODEL_URI}
@app.post("/predict", response_model=PredictionResponse)
def predict(application: LoanApplication):
try:
# Convert request to DataFrame and get prediction probability
input_df = pd.DataFrame([application.model_dump()])
proba = model.predict(input_df)
probability = float(proba[0])
# Map probability to a binary prediction
if probability >= 0.5:
prediction = "DEFAULT"
else:
prediction = "NO DEFAULT"
# Assign risk level based on probability bands
if probability < 0.3:
risk_level = "LOW"
elif probability < 0.6:
risk_level = "MEDIUM"
else:
risk_level = "HIGH"
return PredictionResponse(
default_probability=round(probability, 4),
prediction=prediction,
risk_level=risk_level,
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
What do these endpoints do?
- /health is a simple GET endpoint that confirms the API is running and which model it loaded. This is the endpoint you'll use later for automated health checks.
- /predict accepts a POST request with a JSON loan application. It converts the input to a DataFrame (what the sklearn pipeline expects), runs the model, then maps the raw probability to a human-readable prediction and risk level.
- The 0.5 threshold for DEFAULT/NO DEFAULT and the 0.3/0.6 bands for risk levels are business decisions. In production, these thresholds would be tuned based on the bank's risk appetite.
- Save serve.py.
✔️ Awesome, I've got everything!
Great. Double check you've saved serve.py with all three sections: imports/setup, Pydantic models, and endpoints.
ⓧ I'd like to double check the full code
Your serve.py should have three sections in order. Scroll up and confirm each chunk is present:
- Imports, TRACKING_URI, app creation, and model loading (MODEL_NAME, MODEL_URI, model).
- LoanApplication class with 10 fields and PredictionResponse class with 3 fields.
- health_check function at /health and predict function at /predict.
Test the API
Your API code is complete. Time to start the server and send it a loan application.
- Start the FastAPI server by running this command:
uvicorn serve:app --reload
What does this command do?
- serve:app tells Uvicorn to import the app object from serve.py.
- --reload makes the server restart automatically when you change your code. This is for development only.
You should see Uvicorn running on http://127.0.0.1:8000 and Application startup complete in your terminal. The server is now running and waiting for requests.
Server not starting?
- If you see a syntax error, check that all parentheses and brackets in serve.py are properly closed.
- If the port is already in use, make sure you stopped the MLflow UI from the previous step with Ctrl+C first. The MLflow UI uses port 5000, but another process might be using 8000.
Why won't my FastAPI server start?
- Open your browser and navigate to http://127.0.0.1:8000/docs.
You should see FastAPI's interactive documentation page listing your two endpoints: /health and /predict.
- Click on the POST /predict endpoint to expand it.
- Click Try it out.
- Replace the example request body with this JSON:
{
"credit_score": 650,
"annual_income": 55000,
"loan_amount": 15000,
"debt_to_income": 22.5,
"employment_length": 3,
"home_ownership": "RENT",
"loan_purpose": "debt_consolidation",
"interest_rate": 14.5,
"open_accounts": 8,
"delinquencies_2yr": 1
}
- Click Execute.
Before you look at the response, think about it: with a credit score of 650, a past delinquency, and a 14.5% interest rate, do you think this applicant is high risk or low risk?
You should see a JSON response with default_probability, prediction, and risk_level fields. The model returns its assessment based on the patterns it learned from 10,000 training records.
- Now test from the command line. Open a second terminal in VS Code (click the + icon in the terminal panel).
- Activate your virtual environment in the new terminal:
.venv\Scripts\activate
- Send a prediction request using curl:
curl -X POST http://127.0.0.1:8000/predict -H "Content-Type: application/json" -d "{\"credit_score\": 650, \"annual_income\": 55000, \"loan_amount\": 15000, \"debt_to_income\": 22.5, \"employment_length\": 3, \"home_ownership\": \"RENT\", \"loan_purpose\": \"debt_consolidation\", \"interest_rate\": 14.5, \"open_accounts\": 8, \"delinquencies_2yr\": 1}"
You should see the same JSON prediction response printed in your terminal.
Getting a connection refused error?
- Make sure the Uvicorn server is still running in your first terminal. You should see the Uvicorn running on http://127.0.0.1:8000 message there.
- On Windows, if curl is not recognized, use the PowerShell equivalent Invoke-WebRequest or test through the browser docs at /docs instead.
Why can't I connect to my FastAPI prediction endpoint?
- Switch back to your first terminal (where Uvicorn is running) and press Ctrl+C to stop the server.
Your model is now serving real-time predictions through a production-grade API. Next up, you'll package this entire service into a Docker container so it runs identically anywhere.
Containerize with Docker
Your prediction API works perfectly on your machine. But if a colleague wants to run it, they need to install Python, the exact package versions, MLflow, and the model files. One wrong version and it breaks.
Docker solves this by packaging your code, model, dependencies, and runtime into a single container image that runs identically anywhere. In this step, you'll create a Dockerfile and build a container that serves your loan default predictions.
In this step, get ready to:
- Create a Dockerfile that packages your API and model.
- Build and run the Docker container.
- Verify predictions work inside the container.
Create the Dockerfile
A Dockerfile is a recipe that tells Docker exactly how to build your container image. It specifies the base operating system, installs dependencies, copies your files, and defines how to start the application.
- Create a new file called Dockerfile in the root of your loan-default-predictor folder (no file extension) with the following content:
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY serve.py .
COPY mlflow.db .
COPY mlartifacts/ ./mlartifacts/
EXPOSE 8000
CMD ["uvicorn", "serve:app", "--host", "0.0.0.0", "--port", "8000"]
What does this Dockerfile do?
- FROM python:3.13-slim starts from a lightweight Python image, keeping the final container small.
- COPY requirements.txt and RUN pip install install dependencies first. Docker caches this layer, so rebuilds are fast unless dependencies change.
- COPY serve.py ., COPY mlflow.db ., and COPY mlartifacts/ bundle your API code, model registry database, and actual model files into the image.
- CMD tells Docker to start uvicorn when the container launches, listening on all interfaces (0.0.0.0) so traffic from outside the container can reach it.
- Save Dockerfile.
- Confirm the file exists by running:
ls Dockerfile
You should see Dockerfile listed in the output.
Don't see the Dockerfile?
- Make sure the file is named exactly Dockerfile with a capital D and no file extension. Not Dockerfile.txt or dockerfile.
- Confirm you created it inside the loan-default-predictor folder, not inside a subfolder like data/.
Why isn't my Dockerfile being recognized?
✔️ Awesome, I've got everything!
Great. Make sure you saved the file before moving on.
ⓧ I'd like to double check the full code
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY serve.py .
COPY mlflow.db .
COPY mlartifacts/ ./mlartifacts/
EXPOSE 8000
CMD ["uvicorn", "serve:app", "--host", "0.0.0.0", "--port", "8000"]
Build and run the container
With your Dockerfile ready, you can now build an image and run it. Building downloads the base image, installs packages, and bundles your files into a portable artifact.
- Build the Docker image by running this command:
docker build -t loan-default-api .
What does this command do?
- docker build reads your Dockerfile and executes each instruction in order.
- -t loan-default-api tags (names) the resulting image so you can reference it later.
- . tells Docker to use the current directory as the build context, so COPY commands can find your files.
The first build takes a minute or two while Docker downloads the python:3.13-slim base image and installs your packages. Subsequent builds are much faster thanks to layer caching.
You'll see output as Docker processes each layer. The build is complete when output stops and your terminal prompt returns.
Build failed?
- Make sure Docker Desktop is running. You can check by looking for the Docker whale icon in your system tray.
- If you see a file not found error, confirm you are running the command from inside your loan-default-predictor folder where the Dockerfile lives.
- If pip install fails inside the build, check that requirements.txt exists and has no typos in the package names.
Why is my Docker build failing?
- Run the container by running this command:
docker run -p 8000:8000 loan-default-api
What does this command do?
-p 8000:8000 maps port 8000 inside the container to port 8000 on your machine. Without this, the API runs but you cannot reach it from your browser.
This command keeps the terminal attached. You'll see uvicorn startup logs appear, indicating the server is ready to accept requests.
You'll see log messages in the terminal indicating the server has started inside the container.
Before you test the API, do you think the predictions from the containerized version will match what you saw when running locally?
- Open a new browser tab and go to http://127.0.0.1:8000/docs.
- Click the /predict endpoint to expand it.
- Click Try it out.
- Replace the example JSON with this test application:
{
"credit_score": 650,
"annual_income": 55000,
"loan_amount": 15000,
"debt_to_income": 22.5,
"employment_length": 3,
"home_ownership": "RENT",
"loan_purpose": "debt_consolidation",
"interest_rate": 14.5,
"open_accounts": 8,
"delinquencies_2yr": 1
}
- Click Execute.
You should see a JSON response with default_probability, prediction, and risk_level fields. The predictions match exactly what you saw when running locally. The same model, same logic, now running inside a portable container.
Can't reach the API at localhost:8000?
- Make sure you don't have another process still using port 8000. If you left the local uvicorn server running from earlier, stop it with Ctrl+C in that terminal first, then re-run the Docker container.
- Check the terminal where you ran docker run. If you see error messages instead of startup logs, the container crashed. Read the error for clues.
- If the browser shows a connection refused error, wait a few seconds. The model takes a moment to load on startup.
Why can't I reach my Docker container's API?
Verify the full pipeline
- Return to the terminal where the container is running.
- Press Ctrl+C to stop the container.
You now have a complete end-to-end ML pipeline.
- A trained model tracked in MLflow with full experiment history.
- A registered champion model with alias-based promotion (no code changes needed to swap models).
- A FastAPI REST API with automatic input validation and interactive docs.
- A Docker container packaging everything for deployment anywhere.
Why does this pipeline matter?
This is the exact pattern used in production ML systems at banks and fintechs. A model that lives in a notebook helps nobody. A containerized API with experiment tracking and a model registry is what hiring managers look for in ML engineers.
The champion alias pattern means you can retrain with new data, register a new version, and re-point the alias. Your API starts serving the improved model without a single code change or redeployment.
Secret mission
Add GitHub Actions CI/CD
In production, nobody builds Docker images by hand. Add a GitHub Actions workflow that automatically builds your Docker image, spins up the container, and validates the /health and /predict endpoints on every push. One YAML file turns your project into a real CI/CD pipeline.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine. There are no cloud resources or ongoing costs to worry about.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- loan-default-predictor project directory (all Python files, data, model artifacts, Dockerfile).
- Docker image loan-default-api and any stopped containers.
- Docker Desktop application.
- GitHub repository with your project code and CI/CD workflow.
Keep everything running
No action needed. Choose this if you plan to keep iterating on your model, retraining with new data, or extending the API.
- Your Docker image stays cached locally for fast container restarts.
- Your MLflow database retains all experiment history for future comparison.
- Docker Desktop remains installed for other container projects.
- Your GitHub repository stays live with the CI/CD workflow ready for new pushes.
Pause - I'll come back to this later
Shut down running processes to free up memory while keeping all your files intact.
- Stop any running Docker containers by pressing Ctrl+C in the terminal where the container is running.
- Quit Docker Desktop to free up system memory. Right-click the Docker icon in the system tray (Windows) or menu bar (macOS) and select Quit Docker Desktop.
- Deactivate your Python virtual environment by running:
deactivate
Your project files, Docker image, and GitHub repository all remain ready for when you come back.
Delete - I don't want to use this again
Remove all project resources and start fresh. This cannot be undone.
- Stop any running containers and remove the Docker image by running these commands in your terminal:
docker rm -f ml-api
docker rmi loan-default-api
- Delete the project directory. On macOS/Linux:
cd ~/Desktop
rm -rf loan-default-predictor
- On Windows, run this in PowerShell:
cd ~\Desktop
Remove-Item -Recurse -Force loan-default-predictor
- Delete the GitHub repository. Open your repository page on GitHub, click Settings, scroll down to the Danger Zone section, and click Delete this repository. Confirm by typing the repository name.
- Uninstall Docker Desktop. On Windows, open Settings, then select Apps, then Apps & features. Find Docker Desktop in the list and click Uninstall. On macOS, open Docker Desktop, click the Troubleshoot icon in the top-right corner, select Uninstall, then move the Docker application to the Trash.
Uninstalling Docker Desktop removes all data
Uninstalling Docker Desktop destroys all Docker containers, images, and volumes on your machine. Only uninstall if you do not need Docker for other projects.
Nice Work!
Nice Work!
You just built a complete ML pipeline from training to production-ready deployment. You can now send a loan application to your API and get back a risk assessment in milliseconds, all running inside a portable Docker container.
You've learned how to:
- Build a production ML preprocessing pipeline with ColumnTransformer handling mixed feature types, and fix misleading accuracy on imbalanced data using class_weight="balanced".
- Track experiments in MLflow and register your best model with the industry-standard champion alias pattern for zero-downtime model promotion.
- Serve real-time predictions through a FastAPI REST API with automatic input validation, then containerize the entire service with Docker for portable deployment anywhere.
- Secret Mission: Automated the build-and-test cycle with a GitHub Actions CI/CD pipeline that builds your Docker image and validates the health endpoint on every push.
Ready to quiz yourself?