Model Deployment and APIs
Applied ML in Production · Session 9
Eight sessions of work exist only inside a notebook. A credit officer cannot open a notebook, and neither can the bank's loan origination system.
This session closes that gap. We save the pipeline to a file, wrap it in a small web service that validates what it is sent, measure how fast it answers, and put a simple interface in front of it. By the end this project is something another program can call.
The engineering here is deliberately modest — a hundred lines in total. The ideas that matter are the inference contract (what the service promises), input validation (what it refuses), and the discipline that the code serving the model must be the code that trained it.
How to work through this
This notebook writes real files into the repository: app/api/main.py,
app/ui/app.py, and the saved model in app/models/. Run it and they appear,
ready to launch from a terminal.
Run each cell, read the output, then read the commentary. If a cell errors, run from the top.
Learning objectives
After this session you will be able to:
- Persist a fitted pipeline with
joblib, and say what else must be saved with it. - Write down an inference contract: inputs, outputs, errors, and version.
- Build a FastAPI prediction service with Pydantic validation.
- Test the service end to end and read a
422validation error. - Measure prediction latency and compare it to the charter's budget.
- Build a Streamlit interface for the people who will not call an API.
- List what a prediction service must log for session 8's monitoring to work.
Setup
We train the final pipeline on the whole dataset — at deployment time you use everything you have — and record the context that has to travel with it.
import hashlib
import json
import time
from pathlib import Path
import joblib
import numpy as np
import pandas as pd
import sklearn
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
def find_repository_root():
for folder in [Path.cwd(), *Path.cwd().parents]:
if (folder / "data").is_dir():
return folder
raise FileNotFoundError("could not find the repository root")
ROOT = find_repository_root()
loans = pd.read_csv(ROOT / "data" / "loan_default.csv", parse_dates=["application_date"])
NUMERIC = ["loan_amount", "tenure_months", "interest_rate", "previous_loans",
"days_past_due_history", "annual_income", "credit_score"]
CATEGORICAL = ["sector", "employment_type", "has_collateral"]
THRESHOLD = 0.20 # the operating point agreed in session 5
model = Pipeline([
("prepare", ColumnTransformer([
("numeric", Pipeline([("impute", SimpleImputer(strategy="median")),
("scale", StandardScaler())]), NUMERIC),
("categorical", Pipeline([("impute", SimpleImputer(strategy="most_frequent")),
("encode", OneHotEncoder(handle_unknown="ignore", drop="first"))]),
CATEGORICAL),
])),
("classify", LogisticRegression(max_iter=1000)),
])
model.fit(loans[NUMERIC + CATEGORICAL], loans["defaulted"])
print("trained on", f"{len(loans):,}", "applications")
trained on 12,000 applications
1. Saving the model — and everything around it
joblib.dump writes the fitted pipeline to disk. Because it is a pipeline, the
file contains the imputers, the scaler, the encoder and the model together, so the
serving code cannot preprocess differently from the training code. That is session
7's payoff arriving.
But a model file alone is not deployable. Three questions it cannot answer:
- Which threshold turns this probability into a decision?
- What fields does it expect, and of what type?
- Which version of scikit-learn wrote it, and from which data?
So we save a small metadata file beside it. This is the cheapest habit in production machine learning and it prevents the most confusing incidents.
MODEL_DIR = ROOT / "app" / "models"
MODEL_DIR.mkdir(parents=True, exist_ok=True)
MODEL_PATH = MODEL_DIR / "loan_default_pipeline.joblib"
METADATA_PATH = MODEL_DIR / "loan_default_pipeline.json"
joblib.dump(model, MODEL_PATH)
data_file = ROOT / "data" / "loan_default.csv"
metadata = {
"name": "loan-default",
"version": "1.0.0",
"trained_at": pd.Timestamp.now().strftime("%Y-%m-%d %H:%M"),
"training_rows": len(loans),
"training_data_md5": hashlib.md5(data_file.read_bytes()).hexdigest()[:12],
"numeric_features": NUMERIC,
"categorical_features": CATEGORICAL,
"threshold": THRESHOLD,
"sklearn_version": sklearn.__version__,
"notes": "Probability of default within 12 months, scored at application time.",
}
METADATA_PATH.write_text(json.dumps(metadata, indent=2))
print(f"model {MODEL_PATH.name} ({MODEL_PATH.stat().st_size / 1024:.1f} KB)")
print(f"metadata {METADATA_PATH.name}")
print(json.dumps(metadata, indent=2)[:400], "...")
model loan_default_pipeline.joblib (5.5 KB)
metadata loan_default_pipeline.json
{
"name": "loan-default",
"version": "1.0.0",
"trained_at": "2026-09-16 07:15",
"training_rows": 12000,
"training_data_md5": "18b4b7c4918f",
"numeric_features": [
"loan_amount",
"tenure_months",
"interest_rate",
"previous_loans",
"days_past_due_history",
"annual_income",
"credit_score"
],
"categorical_features": [
"sector",
"employment_type",
...
# The round trip that must always be checked: does the reloaded model agree?
reloaded = joblib.load(MODEL_PATH)
sample = loans[NUMERIC + CATEGORICAL].head(5)
original_predictions = model.predict_proba(sample)[:, 1]
reloaded_predictions = reloaded.predict_proba(sample)[:, 1]
print("original:", original_predictions.round(4))
print("reloaded:", reloaded_predictions.round(4))
print("identical:", np.allclose(original_predictions, reloaded_predictions))
original: [0.0856 0.0229 0.1419 0.3178 0.0383]
reloaded: [0.0856 0.0229 0.1419 0.3178 0.0383]
identical: True
The version trap. A
joblibfile is a pickled Python object graph. Load it with a different scikit-learn version and you may get a warning, a subtly different model, or an exception. That is whysklearn_versionis in the metadata and why session 8's Dockerfile pins the environment. If you remember one deployment rule: the model file and its environment travel together.
2. The inference contract
Before writing the service, write down what it promises. This is the API equivalent of session 1's specification, and it is what the team integrating with you actually needs.
| Endpoint | POST /predict |
| Input | One application: seven numeric fields, three categorical. annual_income and credit_score may be null |
| Output | probability_of_default (0–1), decision (approve / review), threshold, model_version |
| Errors | 422 if a field is missing, the wrong type, or out of range |
| Latency budget | Under 5 seconds (session 1's charter); we should be far under |
| Not promised | An explanation, a batch mode, or a guarantee across model versions |
Two decisions worth noticing.
We return the probability and the decision. The probability lets the consumer do something smarter later; the decision means they do not have to know what a threshold is. Returning only one of them is a common design mistake.
We return the threshold and the model version. When somebody asks in March why an application was flagged in January, those two fields are the answer.
api_code = '''"""Loan default prediction service.
Run from the repository root:
uvicorn app.api.main:app --reload
Then open http://127.0.0.1:8000/docs for the generated documentation.
"""
import json
from pathlib import Path
from typing import Literal, Optional
import joblib
import pandas as pd
from fastapi import FastAPI
from pydantic import BaseModel, Field
MODEL_DIR = Path(__file__).resolve().parents[1] / "models"
# Loaded ONCE, at startup — never inside the request handler.
model = joblib.load(MODEL_DIR / "loan_default_pipeline.joblib")
metadata = json.loads((MODEL_DIR / "loan_default_pipeline.json").read_text())
app = FastAPI(title="Loan default prediction", version=metadata["version"])
class Application(BaseModel):
"""One loan application, as it exists at the moment of submission."""
loan_amount: float = Field(gt=0, le=50_000_000, examples=[800_000])
tenure_months: int = Field(ge=1, le=120, examples=[24])
interest_rate: float = Field(ge=0, le=50, examples=[14.5])
previous_loans: int = Field(ge=0, le=50, examples=[0])
days_past_due_history: int = Field(ge=0, le=3650, examples=[30])
annual_income: Optional[float] = Field(default=None, ge=0)
credit_score: Optional[float] = Field(default=None, ge=300, le=850)
sector: str
employment_type: str
has_collateral: Literal["Yes", "No"]
class Prediction(BaseModel):
probability_of_default: float
decision: Literal["approve", "review"]
threshold: float
model_version: str
@app.get("/health")
def health():
"""Liveness check — what a load balancer calls every few seconds."""
return {"status": "ok", "model_version": metadata["version"]}
@app.get("/metadata")
def model_metadata():
"""What is deployed right now. The first thing to check in an incident."""
return metadata
@app.post("/predict", response_model=Prediction)
def predict(application: Application) -> Prediction:
frame = pd.DataFrame([application.model_dump()])
probability = float(model.predict_proba(frame)[0, 1])
return Prediction(
probability_of_default=round(probability, 4),
decision="review" if probability >= metadata["threshold"] else "approve",
threshold=metadata["threshold"],
model_version=metadata["version"],
)
'''
API_PATH = ROOT / "app" / "api" / "main.py"
API_PATH.parent.mkdir(parents=True, exist_ok=True)
API_PATH.write_text(api_code)
print(f"wrote {API_PATH.relative_to(ROOT)} ({len(api_code.splitlines())} lines)")
wrote app/api/main.py (71 lines)
Read that file rather than skimming it. Four details carry the lesson.
The model is loaded once, at import. Loading it inside predict would reread
the file on every request and turn a 2 millisecond answer into a 200 millisecond
one.
Application is the schema, and the validation. Pydantic enforces types and
ranges before your code sees anything. loan_amount must be positive,
credit_score must lie between 300 and 850, has_collateral must be exactly
"Yes" or "No". A bad request is rejected with a readable error rather than
scored.
Optional fields are explicit. annual_income and credit_score may be
None, because sessions 2 and 3 established that missing values are normal here
and the pipeline's imputer handles them. Fields that may not be missing are
required, and that distinction is now enforced by the service.
/metadata exists. When a prediction is questioned, the first thing anyone
needs is what was deployed at the time.
3. Running it, and calling it
In a terminal you would start the service with:
uvicorn app.api.main:app --reload
Inside this notebook we start the same application on a background thread so we can call it and shut it down cleanly.
import threading
import requests
import uvicorn
# Import the app we just wrote, as a module, exactly as uvicorn would.
import sys
sys.path.insert(0, str(ROOT))
from app.api.main import app # noqa: E402
PORT = 8123
server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=PORT, log_level="error"))
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
for _ in range(100): # wait for startup, at most 10 seconds
if server.started:
break
time.sleep(0.1)
BASE = f"http://127.0.0.1:{PORT}"
print("server started:", server.started)
print("health:", requests.get(f"{BASE}/health", timeout=5).json())
server started: True
health: {'status': 'ok', 'model_version': '1.0.0'}
application = {
"loan_amount": 800_000,
"tenure_months": 24,
"interest_rate": 14.5,
"previous_loans": 0,
"days_past_due_history": 30,
"annual_income": 600_000,
"credit_score": 545,
"sector": "Construction",
"employment_type": "Self-Employed",
"has_collateral": "No",
}
response = requests.post(f"{BASE}/predict", json=application, timeout=5)
print(response.status_code)
print(json.dumps(response.json(), indent=2))
200
{
"probability_of_default": 0.5311,
"decision": "review",
"threshold": 0.2,
"model_version": "1.0.0"
}
A real HTTP request, a real model, a real decision. That JSON is what the loan origination system would receive.
Now the more interesting half: what happens when the request is wrong.
broken = dict(application, loan_amount=-5000, credit_score=1200)
response = requests.post(f"{BASE}/predict", json=broken, timeout=5)
print("status:", response.status_code)
for error in response.json()["detail"]:
print(f" {error['loc'][-1]}: {error['msg']}")
missing_field = {k: v for k, v in application.items() if k != "sector"}
response = requests.post(f"{BASE}/predict", json=missing_field, timeout=5)
print("\nmissing a required field ->", response.status_code,
response.json()["detail"][0]["msg"])
status: 422
loan_amount: Input should be greater than 0
credit_score: Input should be less than or equal to 850
missing a required field -> 422 Field required
422 Unprocessable Entity, with a message naming the offending field.
Compare that with what happens without validation: a negative loan amount is
perfectly acceptable arithmetic, the pipeline scales it, the model returns a
confident probability, and a nonsense decision travels downstream with no trace of
anything having gone wrong. Session 4's reindex warning was the notebook version
of this problem; Pydantic is the production answer.
The rule: a service should refuse what it cannot score, loudly and early.
4. Latency
Session 1's charter gave a budget of five seconds. Measure against it rather than assuming.
timings = []
for _ in range(50):
start = time.perf_counter()
requests.post(f"{BASE}/predict", json=application, timeout=5)
timings.append((time.perf_counter() - start) * 1000)
timings = np.array(timings)
print(f"median {np.median(timings):.1f} ms")
print(f"p95 {np.percentile(timings, 95):.1f} ms")
print(f"slowest {timings.max():.1f} ms")
print(f"\nbudget 5000 ms — headroom: {5000 / np.median(timings):.0f}x")
median 2.3 ms
p95 3.4 ms
slowest 7.3 ms
budget 5000 ms — headroom: 2202x
A couple of milliseconds per request, against a five-second budget. Three observations that generalise beyond this project.
Most of that time is HTTP, not the model. The pipeline itself takes tens of microseconds; the rest is request parsing, validation and serialisation. Which is why "make the model faster" is usually the wrong first optimisation.
Report the p95, not the mean. Users experience the slow requests. A service with a 2 ms median and a 4 second p95 is a broken service with a flattering average.
Latency is a design constraint, discovered early. If the charter had demanded predictions in 5 milliseconds, that would have ruled out the KNN model in session 4 before anyone trained it.
# Shut the background server down so the notebook exits cleanly.
server.should_exit = True
thread.join(timeout=5)
print("server stopped:", not thread.is_alive())
server stopped: True
5. An interface for humans
An API serves systems. A credit officer needs a screen. Streamlit turns a Python script into a web interface without any front-end work, which makes it ideal for internal tools and demonstrations.
The interface calls the API rather than loading the model itself. That separation matters: one model, one place where the decision is made, and the UI can be redeployed without touching the service.
ui_code = '''"""Streamlit interface for the loan default service.
Start the API first:
uvicorn app.api.main:app
Then, in a second terminal:
streamlit run app/ui/app.py
"""
import requests
import streamlit as st
API = "http://127.0.0.1:8000"
st.title("Loan default risk")
st.caption("Scores an application at submission time. Decision support, not a decision.")
with st.form("application"):
column_left, column_right = st.columns(2)
with column_left:
loan_amount = st.number_input("Loan amount (NPR)", min_value=50_000, value=800_000, step=50_000)
tenure_months = st.selectbox("Tenure (months)", [6, 12, 18, 24, 36, 48, 60], index=3)
interest_rate = st.slider("Interest rate (%)", 8.0, 24.0, 14.5, 0.1)
previous_loans = st.number_input("Previous loans", min_value=0, max_value=20, value=0)
days_past_due_history = st.number_input("Days past due (history)", min_value=0, value=30)
with column_right:
sector = st.selectbox("Sector", ["Trade", "Service", "Manufacturing",
"Agriculture", "Construction", "Tourism"])
employment_type = st.selectbox("Employment", ["Salaried", "Self-Employed",
"Business Owner", "Informal"])
has_collateral = st.radio("Collateral", ["Yes", "No"], horizontal=True)
has_income = st.checkbox("Income documented", value=True)
annual_income = st.number_input("Annual income (NPR)", min_value=0, value=600_000,
step=50_000, disabled=not has_income)
has_score = st.checkbox("Credit score available", value=True)
credit_score = st.number_input("Credit score", min_value=300, max_value=850,
value=545, disabled=not has_score)
submitted = st.form_submit_button("Score application")
if submitted:
payload = {
"loan_amount": loan_amount,
"tenure_months": tenure_months,
"interest_rate": interest_rate,
"previous_loans": previous_loans,
"days_past_due_history": days_past_due_history,
"annual_income": annual_income if has_income else None,
"credit_score": credit_score if has_score else None,
"sector": sector,
"employment_type": employment_type,
"has_collateral": has_collateral,
}
try:
response = requests.post(f"{API}/predict", json=payload, timeout=5)
except requests.exceptions.ConnectionError:
st.error(f"Cannot reach the API at {API}. Is uvicorn running?")
st.stop()
if response.status_code != 200:
st.error(f"The service rejected this application: {response.json()}")
st.stop()
result = response.json()
probability = result["probability_of_default"]
st.metric("Probability of default", f"{probability:.1%}")
st.progress(min(probability / 0.5, 1.0))
if result["decision"] == "review":
st.warning("Send to senior review")
else:
st.success("Within auto-approval range")
st.caption(f"threshold {result['threshold']:.0%} · model {result['model_version']}")
'''
UI_PATH = ROOT / "app" / "ui" / "app.py"
UI_PATH.parent.mkdir(parents=True, exist_ok=True)
UI_PATH.write_text(ui_code)
print(f"wrote {UI_PATH.relative_to(ROOT)} ({len(ui_code.splitlines())} lines)")
print("\nTo use it, in two terminals from the repository root:")
print(" 1) uvicorn app.api.main:app")
print(" 2) streamlit run app/ui/app.py")
wrote app/ui/app.py (78 lines)
To use it, in two terminals from the repository root:
1) uvicorn app.api.main:app
2) streamlit run app/ui/app.py
Notice three deliberate choices in that interface.
Missing values are a checkbox, not a blank box. "Income documented" reflects
the reality sessions 2 and 3 uncovered — an applicant with no documented income is
a normal case, not a form error. The UI sends null and the pipeline imputes.
The probability and the recommendation are both shown. The officer sees the number and the suggested action, and remains the decision-maker. The caption naming the threshold and model version is what makes a decision auditable months later.
Failures are handled. If the API is down the interface says so. Demos that crash on a disconnected backend lose more credibility than models that score 0.05 lower.
6. What a real service also needs
Our hundred lines are honest but incomplete. Before this served real applications, the following would not be optional.
| Concern | What to add | Why |
|---|---|---|
| Prediction logging | Write every request and response to a store | Session 8's monitoring has nothing to measure otherwise |
| Authentication | An API key or service token | A scoring endpoint open to the internet is a data leak |
| Rate limiting | Requests per client per minute | One broken loop should not take the service down |
| Timeouts and retries | On both sides of the call | Networks fail; hanging requests are worse than failed ones |
| Structured logging | Request id on every line | Tracing one application through the system |
| Containerisation | Session 8's Dockerfile | The environment stops drifting |
| Batch endpoint | POST /predict/batch |
Overnight scoring of the whole book |
The first row is the one that connects this session to the last one. A prediction that is not logged cannot be monitored, and a service that is not monitored will drift exactly as session 8's did — silently, for months.
The minimum log record: timestamp, request id, model version, the input fields, the probability, the decision, and the response time. With those, every chart in session 8 can be rebuilt from production traffic instead of from a CSV.
Your turn
1. Add prediction logging. Append every request and response to a JSON Lines
file in app/logs/. Then load that file with pandas and reproduce session 8's
predicted-rate chart from it.
2. Add a batch endpoint. POST /predict/batch taking a list of applications
and returning a list of predictions. Measure its latency for 1, 10 and 100
applications — how much of the cost is per request rather than per application?
3. Serve the size-aware thresholds. Session 6 saved NPR 8.6m using a lower threshold for large loans. Move that rule into the service (it belongs in the metadata file, not in the code) and return which band was applied.
4. Break it on purpose. Send a sector value the model has never seen, such
as "Fintech". Does the service reject it, or score it? Should the schema use a
Literal of known sectors, and what breaks when the lender opens a new sector
next year?
5. Version endpoint discipline. Retrain the model, bump version to 1.1.0,
restart the API, and confirm /metadata reports the change. Write the two
sentences you would send the integrating team about the new version.
6. Health check that means something. /health currently returns ok
unconditionally. Make it score a fixed known application and check the result is
within tolerance, so a corrupted model file fails the check instead of passing it.
If you remember nothing else
Save the pipeline, not the model. Preprocessing and estimator in one file is what makes training and serving the same computation.
A model file is not deployable on its own. Threshold, schema, versions and training data hash travel with it, or nobody can explain a decision six months later.
Write the inference contract before the code. Inputs, outputs, errors, latency, and what you are not promising.
Validate at the boundary. A service that scores a negative loan amount has
produced a confident, wrong, untraceable decision. 422 is a feature.
Load the model once, at startup. Not per request.
Measure latency, report the p95. We had a 5-second budget and a 2-millisecond median; most of that was HTTP, not the model.
Log every prediction. Monitoring, incident analysis, and next year's retrain all depend on the log you either wrote or did not.
Glossary
| Term | Meaning |
|---|---|
| Serialisation | Writing a fitted object to a file — joblib, pickle, skops |
| Model artifact | The saved model file, plus the metadata it needs to be used |
| Inference contract | The documented promise a prediction service makes |
| Endpoint | One callable URL on a service, e.g. POST /predict |
| Pydantic model | A typed schema that validates request bodies |
| 422 | HTTP status for a well-formed request whose contents fail validation |
| Health check | An endpoint a load balancer polls to see if the service is alive |
| p95 latency | The response time 95% of requests come in under |
| Cold start | The first request after startup, before caches are warm |
| Prediction log | The record of every input and output, the raw material of monitoring |
Further reading
- FastAPI documentation, Tutorial — User Guide, first ten pages; then Response Model and Handling Errors.
- Pydantic documentation, Fields and Validators — everything the schema in section 2 could enforce.
- Streamlit documentation, Get started and API reference — Input widgets.
- scikit-learn User Guide, Model persistence — the security and version
caveats around pickle and joblib, and where
skopsfits.
Next session: End-to-End ML Project — the whole module in one pass on a fresh problem, plus the project brief and grading rubric for your own submission.