The decision is made: alongside the weekly batch for marketing, CineClick needs an online service that, the moment a customer clicks "cancel subscription", returns their churn probability in under 300 ms so product can decide whether to show an offer. In this lesson we build that service with FastAPI: we'll define input and output contracts with Pydantic, load the champion from the registry at startup, expose the /predict, /health, and /version endpoints, and — honoring the mandate from lesson 03-03 — import build_features from the installable package instead of rewriting it. By the end you'll have the service running on your machine with uvicorn, tested from the interactive documentation and from curl, and leaving in the logs the seed of the monitoring that arrives in module 6.
Contents
- Why FastAPI for serving models
- The service's structure in the repo
- Pydantic schemas: the input and output contract
- Loading the model at startup: lifespan
- The POST /predict endpoint
- Healthcheck and version: GET /health and GET /version
- Trying the service: uvicorn, /docs, and curl
- Structured logging of every prediction
Why FastAPI for serving models
There are many ways to expose a model over HTTP (Flask, Django, a hand-rolled server). FastAPI has become the de facto standard for serving ML in Python for four concrete reasons:
- Typing with Pydantic: you define the input and output as typed Python classes, and FastAPI automatically validates every request against that schema. In an ML service this is gold: the number one cause of absurd predictions in production is a malformed input the model swallows without complaint (a nonexistent
plan, a negative tenure). With Pydantic, the invalid request dies at the door with an explanatory 422 error, before ever touching the model. - Automatic OpenAPI documentation: the service publishes at
/docsan interactive interface generated from the schemas. CineClick's product team can see and try the contract without reading a line of Python. - Native async support: FastAPI runs on an ASGI server (uvicorn) and can handle many concurrent connections. An important nuance for ML: scikit-learn's
predictis synchronous CPU-bound code, so we'll declare it in a plaindefendpoint (notasync def) and FastAPI will run it in a threadpool without blocking the event loop. - Performance and ergonomics: it's among the fastest in the Python ecosystem and the resulting code is short and readable — you'll see the whole service fits in two files.
We add the dependencies to the project (and freeze them in the lock, as module 2 mandates):
# add to pyproject.toml under [project.dependencies]: # fastapi==0.115.5 # uvicorn[standard]==0.32.1 pip-compile pyproject.toml -o requirements.lock # regenerate the lock pip install -e . # reinstall the package
The service's structure in the repo
The service lives inside the cineclick_churn package, not in a separate repo. This decision follows directly from 03-03: the service must import build_features from the same package training uses, and keeping them together guarantees both get installed and versioned at once.
cineclick-churn/ ├── src/cineclick_churn/ │ ├── data.py │ ├── features.py # single source of features (03-03) │ ├── train.py │ ├── evaluate.py │ └── api/ # NEW: the prediction service │ ├── __init__.py │ ├── main.py # the FastAPI app: lifespan + endpoints │ └── schemas.py # Pydantic input/output contracts ├── tests/ ├── configs/ └── ...
Two files: schemas.py defines what goes in and out; main.py defines what gets done with it. Separating them keeps the contract readable on its own.
Pydantic schemas: the input and output contract
The input schema mirrors the raw columns of churn_customers.csv (minus customer_id, which travels separately for traceability, and churned, which is what we predict). Notice that the validations encode domain rules, not just types:
# src/cineclick_churn/api/schemas.py
from typing import Literal
from pydantic import BaseModel, Field
class CustomerInput(BaseModel):
"""Raw data of one customer, as the frontend has it."""
customer_id: str = Field(..., min_length=1, description="Customer identifier")
tenure_months: int = Field(..., ge=0, le=600,
description="Months since signup (>= 0)")
weekly_hours: float = Field(..., ge=0, le=168,
description="Viewing hours this week")
support_tickets: int = Field(..., ge=0,
description="Open support tickets")
plan: Literal["basic", "standard", "premium"]
payment_method: Literal["card", "direct_debit", "paypal"]
active_discount: bool
class PredictionOutput(BaseModel):
"""The service's response: probability, decision, and traceability."""
customer_id: str
churn_probability: float = Field(..., ge=0, le=1)
prediction: bool # True = churn is predicted
threshold: float # threshold applied in this response
model_version: str # e.g. "2" — the registry versionBreaking down the decisions:
ge=0ontenure_monthsandsupport_ticketscuts off negative inputs cold — which, remember, would silently break theratio_ticketsfeature (theclip(lower=1)guards against odd divisions, but a tenure of -3 is corrupt data that should never get that far).le=168onweekly_hours: a week has 168 hours; more than that is a bug in the sender, not a very devoted customer.Literal[...]onplanandpayment_methodrestricts them to the values the model was trained on. If the business launches the"family"plan tomorrow, the API will return a 422 instead of letting the model hallucinate over a category it never saw — and that 422 is the early alarm that it's time to retrain and update the contract.PredictionOutputreturns not just the probability but the applied threshold and the model version: every response is self-explanatory and auditable. When we investigate drift in module 6, knowing which version answered each request will be indispensable.
Loading the model at startup: lifespan
The golden rule of serving: the model is loaded once, when the process starts — never inside the endpoint. Loading from the registry takes seconds; doing it per request would destroy any latency budget. FastAPI offers the lifespan pattern for running code at startup and shutdown:
# src/cineclick_churn/api/main.py (part 1: startup)
import logging
import os
import time
from contextlib import asynccontextmanager
import mlflow
import pandas as pd
from fastapi import FastAPI, HTTPException
from cineclick_churn.features import build_features # THE single source
from cineclick_churn.api.schemas import CustomerInput, PredictionOutput
logger = logging.getLogger("cineclick_churn.api")
MODEL_URI = "models:/churn-cineclick@champion" # the contract fixed in 03-02
DEFAULT_THRESHOLD = float(os.getenv("CHURN_THRESHOLD", "0.5"))
state = {} # the model and its metadata will live here for the process's whole life
@asynccontextmanager
async def lifespan(app: FastAPI):
# --- STARTUP ---
# MLFLOW_TRACKING_URI must be in the environment; if it's missing, better to know NOW.
tracking_uri = os.environ["MLFLOW_TRACKING_URI"] # KeyError = a clear failure
mlflow.set_tracking_uri(tracking_uri)
logger.info("Loading model from %s ...", MODEL_URI)
try:
state["model"] = mlflow.sklearn.load_model(MODEL_URI)
client = mlflow.MlflowClient()
mv = client.get_model_version_by_alias("churn-cineclick", "champion")
state["model_version"] = mv.version # "2" as of today
except Exception:
logger.exception("Could not load the model from the registry. Aborting.")
raise # fail fast: better a dead process than a zombie without a model
logger.info("Model churn-cineclick v%s loaded.", state["model_version"])
yield # ---- here the service handles requests ----
# --- SHUTDOWN ---
state.clear()
app = FastAPI(title="CineClick Churn API", version="0.1.0", lifespan=lifespan)The resilience note deserves a pause: if the registry doesn't respond at startup, there are two philosophies. The wrong one is to catch the exception, start up "anyway", and return errors on every /predict — a service that looks healthy but can't do its job (a zombie). The right one, and the one we implement, is to fail fast and loud: the raise kills the process with an unambiguous message in the log, and the orchestrator (Docker in 04-03, Kubernetes in 04-04) will detect it and retry or alert. A process that dies noisily gets diagnosed in minutes; a silent zombie, in days.
The POST /predict endpoint
# src/cineclick_churn/api/main.py (part 2: prediction)
@app.post("/predict", response_model=PredictionOutput)
def predict(customer: CustomerInput, threshold: float | None = None) -> PredictionOutput:
"""Returns the churn probability of ONE customer."""
t0 = time.perf_counter()
applied_threshold = threshold if threshold is not None else DEFAULT_THRESHOLD
# 1. From validated JSON to a one-row DataFrame with the raw columns
df = pd.DataFrame([customer.model_dump()])
# 2. Features: the SAME function training used. Not one line copied.
X = build_features(df.drop(columns=["customer_id"]))
# 3. Probability of the positive class (churn)
prob = float(state["model"].predict_proba(X)[0, 1])
# 4. Binary decision according to the threshold
output = PredictionOutput(
customer_id=customer.customer_id,
churn_probability=round(prob, 4),
prediction=prob >= applied_threshold,
threshold=applied_threshold,
model_version=state["model_version"],
)
latency_ms = (time.perf_counter() - t0) * 1000
logger.info("prediction", extra={"event_data": {
"customer_id": customer.customer_id,
"probability": output.churn_probability,
"prediction": output.prediction,
"model_version": output.model_version,
"latency_ms": round(latency_ms, 1),
}})
return outputStep by step:
customer.model_dump()turns the (already validated) Pydantic object into a dictionary, andpd.DataFrame([...])wraps it in a one-row DataFrame — the formatbuild_featuresexpects, identical to training's.build_featuresimported from the package. This is the moment the 03-03 decision pays off:ratio_tickets = support_tickets / tenure_months.clip(lower=1)is computed with the canonical implementation. If someone improves it tomorrow infeatures.py, training and serving change together — training/serving skew becomes structurally impossible.predict_proba(X)[0, 1]: row 0 (there's only one), column 1 (probability of the positive class). We return the probability, not just the boolean, because the consumer may want to grade the offer.- The threshold is configurable (the
CHURN_THRESHOLDenvironment variable, overridable per request with thethresholdquery param). The default 0.5 is just the mathematical starting point: the real threshold is a business decision. With the current champion (precision 0.55, recall 0.68), lowering the threshold captures more future churners at the cost of offering discounts to customers who weren't going to leave; raising it does the opposite. Product and finance must pick that point with a cost calculation — the API only needs to allow changing it without redeploying.
Healthcheck and version: GET /health and GET /version
# src/cineclick_churn/api/main.py (part 3: operations)
@app.get("/health")
def health() -> dict:
"""Healthcheck: is the process alive AND holding a loaded model?"""
if "model" not in state:
raise HTTPException(status_code=503, detail="Model not loaded")
return {"status": "ok", "model": "churn-cineclick",
"model_version": state["model_version"]}
@app.get("/version")
def version() -> dict:
"""Exactly what's running here: service version and model version."""
return {"service_version": app.version, # 0.1.0
"model_uri": MODEL_URI,
"model_version": state.get("model_version", "unknown")}/healthdoesn't just answer "I'm alive": it checks that the model is in memory. This nuance matters because it's the endpoint Docker (04-03) and Kubernetes probes (04-04) will call to decide whether the container receives traffic or gets restarted. A healthcheck that lies is worse than no healthcheck./versionanswers the quintessential operational question: "which model is this serving RIGHT NOW?". When we moved the@championalias in 03-02, we said rollback was "moving an alias";/versionis how you verify, after restarting the service, that the change took effect.
Trying the service: uvicorn, /docs, and curl
With the MLflow server running (the sqlite one from module 3), we start up:
# terminal 1: the MLflow server from module 3 (if it's not already running) mlflow server --backend-store-uri sqlite:///mlflow.db --host 127.0.0.1 --port 5000 # terminal 2: the prediction service export MLFLOW_TRACKING_URI=http://127.0.0.1:5000 uvicorn cineclick_churn.api.main:app --host 0.0.0.0 --port 8000
In the log you'll see Loading model from models:/churn-cineclick@champion ... followed by Model churn-cineclick v2 loaded. — the lifespan in action. Now open http://localhost:8000/docs: FastAPI has generated the interactive documentation with the three endpoints, the schemas with their constraints (tenure_months >= 0, the allowed plan values), and a Try it out button to fire requests from the browser. This /docs is the contract you hand to the product team.
Let's try curl with a fictional customer of risky profile (short tenure, low usage, tickets):
curl -s -X POST http://localhost:8000/predict \
-H "Content-Type: application/json" \
-d '{
"customer_id": "CLI-04217",
"tenure_months": 3,
"weekly_hours": 1.5,
"support_tickets": 4,
"plan": "basic",
"payment_method": "card",
"active_discount": false
}'Response:
{
"customer_id": "CLI-04217",
"churn_probability": 0.8412,
"prediction": true,
"threshold": 0.5,
"model_version": "2"
}And the validation in action — a plan that doesn't exist:
curl -s -X POST http://localhost:8000/predict \
-H "Content-Type: application/json" \
-d '{"customer_id": "CLI-1", "tenure_months": 3, "weekly_hours": 1.5,
"support_tickets": 4, "plan": "family", "payment_method": "card",
"active_discount": false}'
# → HTTP 422: "Input should be 'basic', 'standard' or 'premium'"The model never even noticed. That's Pydantic working the door.
A note on security, without straying: this service will run on CineClick's internal network, but even so the bare minimum is a simple API key (an X-API-Key header verified by a FastAPI dependency against a secret held in an environment variable). Serious enterprise authentication — OAuth2, mTLS, gateway — is the company platform's territory, not this lesson's; what matters is that the endpoint isn't left open to anyone, not even "temporarily".
Structured logging of every prediction
You've already seen it in the endpoint: every prediction emits a log line with structured fields, not a free-form sentence. We configure the output as JSON so machines can process it:
# src/cineclick_churn/api/logging_config.py — minimal JSON formatter
import json, logging, hashlib
class JSONFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
base = {"ts": self.formatTime(record), "level": record.levelname,
"logger": record.name, "message": record.getMessage()}
base.update(getattr(record, "event_data", {}))
return json.dumps(base, ensure_ascii=False)
def input_hash(payload: dict) -> str:
"""Stable hash of the input: detects duplicates and enables auditing without storing PII."""
return hashlib.sha256(
json.dumps(payload, sort_keys=True).encode()
).hexdigest()[:16]Each prediction log line contains: the input's hash (via input_hash over the payload — traceable without dumping customer data in plain text), probability, prediction, model version, and latency in ms. It looks like little, but it's exactly the seed of module 6's monitoring: with these logs accumulated we'll be able to plot the probability distribution over time (drift, 06-02), the p95 latency (06-01), and the request volume. The rule: if you don't log it from day one, it won't exist when you need it.
{"ts": "2026-07-07 11:32:04", "level": "INFO", "logger": "cineclick_churn.api",
"message": "prediction", "customer_id": "CLI-04217", "probability": 0.8412,
"prediction": true, "model_version": "2", "latency_ms": 14.3}Common Mistakes and Tips
- Mistake: loading the model inside the endpoint. Every request would pay seconds of registry loading. The model is loaded once in the
lifespanand lives in memory. - Mistake: reimplementing the features in the API. "It's just a division" — until someone changes the
clipinfeatures.pyand serving keeps the old version: silent training/serving skew, the most expensive bug to detect. At CineClick it's forbidden by the 03-03 decision: alwaysfrom cineclick_churn.features import build_features. - Mistake: starting without a model and returning errors per request. The zombie service. Fail fast at startup (the
raisein the lifespan) and let the orchestrator do its job. - Mistake: using
async defon the prediction endpoint.predict_probais synchronous CPU work: insideasync defit would block the event loop and sink concurrency. Withdef, FastAPI moves it to the threadpool automatically. - Mistake: accepting any string in the categorical fields. Without
Literal, aplan="Basic"(with a capital letter) would reach the model as an unknown category. The schema must reflect the exact vocabulary of training. - Tip: always return the model version in the response. It costs one field and turns every response into auditable evidence.
- Tip: treat the threshold as business configuration, not a code constant. Going from 0.5 to 0.4 shouldn't require a deployment.
Exercises
Exercise 1
Product asks for a POST /predict-batch endpoint that receives a list of customers (100 at most) and returns a list of predictions, to score all the attendees of a retention webinar in one go. Implement it reusing the existing schemas. Hint: predict_proba accepts an N-row DataFrame.
Exercise 2
Write a test with FastAPI's TestClient (in tests/test_api.py) verifying that a request with tenure_months: -5 receives a 422 without ever invoking the model. Hint: you can monkeypatch state so the tests don't depend on the registry.
Exercise 3
Right now, if MLflow is down when the service starts, the process dies (correct). But what happens if MLflow goes down afterwards, with the service already up? Reason through it: (a) does /predict keep working?, (b) what does /health return?, (c) is this behavior acceptable?
Solutions
Solution 1
# in schemas.py
class BatchInput(BaseModel):
customers: list[CustomerInput] = Field(..., min_length=1, max_length=100)
# in main.py
@app.post("/predict-batch", response_model=list[PredictionOutput])
def predict_batch(batch: BatchInput) -> list[PredictionOutput]:
df = pd.DataFrame([c.model_dump() for c in batch.customers])
X = build_features(df.drop(columns=["customer_id"]))
probs = state["model"].predict_proba(X)[:, 1] # ONE single predict for N rows
return [
PredictionOutput(
customer_id=c.customer_id,
churn_probability=round(float(p), 4),
prediction=float(p) >= DEFAULT_THRESHOLD,
threshold=DEFAULT_THRESHOLD,
model_version=state["model_version"],
)
for c, p in zip(batch.customers, probs)
]The essential part: one single call to build_features and to predict_proba for the N rows, not a loop of N predictions — we'll quantify the performance difference in 04-05. The max_length=100 protects the service from someone sending it the entire customer base over HTTP (that's what the batch from 04-01 is for).
Solution 2
# tests/test_api.py
from fastapi.testclient import TestClient
from cineclick_churn.api import main
def test_negative_tenure_returns_422(monkeypatch):
# A fake model in the state: if the endpoint called it, the test would blow up
class ForbiddenModel:
def predict_proba(self, X):
raise AssertionError("The model must not be invoked with invalid input")
monkeypatch.setitem(main.state, "model", ForbiddenModel())
monkeypatch.setitem(main.state, "model_version", "test")
http_client = TestClient(main.app) # without running the real lifespan
response = http_client.post("/predict", json={
"customer_id": "CLI-X", "tenure_months": -5, "weekly_hours": 2,
"support_tickets": 0, "plan": "basic", "payment_method": "card",
"active_discount": False,
})
assert response.status_code == 422If Pydantic let the input through, ForbiddenModel.predict_proba would raise the AssertionError and the test would fail with a clear message. This test will join the CI in lesson 05-01.
Solution 3
(a) Yes. The model is already in memory; /predict never touches MLflow on the request path. The registry being down doesn't affect in-flight traffic — a virtue of the "load at startup" pattern. (b) /health returns 200 ok, because it only checks that the model is in memory, not connectivity to MLflow — and that's correct: the healthcheck must measure the ability to do the job (predict), not the health of dependencies it no longer needs. (c) Acceptable, with one caveat: new startups (a restart, a fresh replica when scaling out) will fail while MLflow is down. It's a known risk we note down for 04-03: there we'll discuss the alternative of baking the model into the image precisely to remove this startup dependency, and why CineClick still chooses the registry.
Conclusion
Laura's model now takes requests. The service has an explicit contract (Pydantic validates domain, not just types), loads the champion exactly once from models:/churn-cineclick@champion with a fail-fast policy, predicts by importing build_features from the single source — zero duplicated logic —, exposes /health and /version so machines and humans know what's running, and logs every prediction as JSON that module 6 will turn into monitoring. All of this works... on your machine, with your Python 3.11.9, your installed requirements.lock, and your environment variable pointing at your MLflow. The production server has none of that, and "install it by hand and cross your fingers" is exactly the kind of process this course came to eliminate. The next lesson freezes the whole service — operating system, Python, dependencies, and code — into a Docker image that runs identically anywhere: "works on my machine" is about to stop being an excuse.
MLOps Course
Module 1: MLOps Fundamentals
- What MLOps is and why models die in the notebook
- The lifecycle of an ML model in production
- MLOps maturity levels and team roles
- The course project: from notebook to production
Module 2: From Notebook to Reproducible Code
- Structuring an ML project: from notebook to package
- Reproducible environments and dependency management
- Data versioning with DVC
- Reproducible training pipelines
Module 3: Experiments and Model Registry
- Experiment tracking with MLflow
- Model registry: versioning and promoting models
- Feature stores: when and what for
Module 4: Serving Models in Production
- Deployment patterns: batch, online and streaming
- A prediction service with FastAPI
- Packaging with Docker
- Scaling and deployment: Kubernetes and serverless
- Inference optimization: latency and cost
Module 5: Automation: CI/CD and Orchestration
- CI for ML: testing code, data and models
- CD: automating model deployment
- Orchestrating ML pipelines
- Release strategies: shadow, canary and A/B
