MLOps basics: from notebook to deployed model
Take a model out of the notebook: save it with its preprocessing, serve it with FastAPI, test it, package it with Docker and learn what to monitor once it is live.
What you will build: a trained scikit-learn model wrapped in a small web API (FastAPI) that returns predictions as JSON, tested locally, containerised with Docker, and with simple logging so you can monitor it.
Prerequisites: Python, scikit-learn basics (see *Classification: predicting categories*), and a laptop with Python 3.10 or newer. Docker is optional: the Docker step needs Docker Desktop installed. FastAPI is covered in the Backend Development track.
We use the classic iris flower dataset so the focus stays on deployment, not modelling.
What is MLOps?
A model in a notebook helps nobody. MLOps is the set of practices that take a model from notebook to something that runs reliably for real users, and keeps it healthy afterwards. Think of it as DevOps plus the extra problems that models bring: data changes, models go stale, and results are hard to reproduce.
The MLOps loop
| Stage | Question it answers | Typical tools |
|---|---|---|
| Track experiments | Which settings and data produced this result? | MLflow, Weights & Biases, or even a CSV |
| Version data and code | Can I reproduce last month's model? | Git, DVC |
| Package the model | How do I save it with its preprocessing? | joblib, ONNX |
| Serve it | How do other programs ask for predictions? | FastAPI, Flask, cloud endpoints |
| Containerise | Will it run the same on the server? | Docker |
| Monitor | Is it still working? Has the data changed? | Logs, dashboards, alerts |
| Retrain | When and how do we update it? | Scheduled jobs, pipelines |
Step 1: train and save a model with its preprocessing
The most common deployment bug is saving the model but not the preprocessing. Use a scikit-learn Pipeline so the scaler and the model are saved together as one object.
Setup
- 1Create a folder
iris-serviceand open a terminal in it. - 2Create and activate a virtual environment:
python -m venv .venv, then on Windows.venv\Scripts\activate, or on macOS and Linuxsource .venv/bin/activate. - 3Create
requirements.txtwith the content shown below and runpip install -r requirements.txt. - 4Create
train.pyand run it withpython train.py.
fastapi
uvicorn
scikit-learn
joblib
pydanticFor a real project, pin exact versions (for example scikit-learn==1.5.2, using the versions you tested with) so the server matches your laptop.
import joblib
from sklearn.datasets import load_iris
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
iris = load_iris()
X_train, X_test, y_train, y_test = train_test_split(
iris.data, iris.target, test_size=0.2, random_state=42, stratify=iris.target
)
pipeline = Pipeline([
("scale", StandardScaler()),
("model", LogisticRegression(max_iter=200)),
])
pipeline.fit(X_train, y_train)
acc = accuracy_score(y_test, pipeline.predict(X_test))
print(f"test accuracy: {acc:.3f}")
joblib.dump({"pipeline": pipeline, "classes": list(iris.target_names)}, "model.joblib")
print("saved model.joblib")Trains, prints the test accuracy (high on this easy dataset) and saves the pipeline and the class names into one file.
Security
Only load .joblib or pickle files that you created yourself or fully trust. Loading a file from an unknown source can run malicious code on your machine.
Step 2: serve it with FastAPI
The API has two endpoints: /health to check the service is alive, and /predict to get a prediction. Pydantic validates the input, so bad requests are rejected with a clear error instead of crashing the model. We log each prediction so that we can monitor it later.
import logging
import time
import joblib
from fastapi import FastAPI
from pydantic import BaseModel, Field
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
logger = logging.getLogger("iris-service")
artifact = joblib.load("model.joblib")
pipeline = artifact["pipeline"]
classes = artifact["classes"]
app = FastAPI(title="Iris classifier")
class Flower(BaseModel):
sepal_length: float = Field(gt=0, lt=20)
sepal_width: float = Field(gt=0, lt=20)
petal_length: float = Field(gt=0, lt=20)
petal_width: float = Field(gt=0, lt=20)
@app.get("/health")
def health():
return {"status": "ok"}
@app.post("/predict")
def predict(flower: Flower):
start = time.perf_counter()
features = [[flower.sepal_length, flower.sepal_width,
flower.petal_length, flower.petal_width]]
probs = pipeline.predict_proba(features)[0]
best = int(probs.argmax())
elapsed_ms = (time.perf_counter() - start) * 1000
logger.info("input=%s prediction=%s confidence=%.3f latency_ms=%.1f",
features[0], classes[best], probs[best], elapsed_ms)
return {"prediction": classes[best], "confidence": round(float(probs[best]), 3)}The model is loaded once when the server starts, not on every request. That is important for speed.
Run it
- 1Start the server:
uvicorn app:app --reload. - 2Open http://127.0.0.1:8000/docs in a browser. FastAPI builds interactive documentation where you can try the endpoint.
- 3Or call it from a second terminal with the Python script below.
import requests
r = requests.post(
"http://127.0.0.1:8000/predict",
json={"sepal_length": 5.1, "sepal_width": 3.5, "petal_length": 1.4, "petal_width": 0.2},
)
print(r.status_code, r.json())
bad = requests.post("http://127.0.0.1:8000/predict", json={"sepal_length": -1})
print(bad.status_code)Needs pip install requests. The first call should return a 200 with a prediction (setosa for these measurements). The second returns 422 because fields are missing or invalid.
200 {'prediction': 'setosa', 'confidence': 0.97}
422The confidence value will differ a little on your machine. The 422 shows that input validation works: the model never sees garbage.
Step 3: write a test
Automated tests catch breakages when you retrain or change code. FastAPI ships a test client. (If you get an error about httpx, run pip install httpx pytest.)
from fastapi.testclient import TestClient
from app import app
client = TestClient(app)
def test_health():
assert client.get("/health").json() == {"status": "ok"}
def test_predict_setosa():
r = client.post("/predict", json={
"sepal_length": 5.1, "sepal_width": 3.5,
"petal_length": 1.4, "petal_width": 0.2,
})
assert r.status_code == 200
assert r.json()["prediction"] == "setosa"
def test_rejects_bad_input():
r = client.post("/predict", json={"sepal_length": -1})
assert r.status_code == 422Run with pytest. These tests check the behaviour you promised users, not the model's accuracy; test accuracy separately in training.
Step 4: containerise with Docker
A Docker image packages your code, Python and libraries together, so it runs the same on your laptop, a teammate's machine or a cloud server. This removes "it works on my machine".
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py model.joblib ./
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]Copying requirements first lets Docker reuse the cached install layer when only your code changes. Use the same scikit-learn version for training and serving, or loading the model may fail or behave differently.
docker build -t iris-service .
docker run -p 8000:8000 iris-serviceThen call http://127.0.0.1:8000/predict as before. From here the same image can be deployed to a cloud service that runs containers.
Step 5: monitor it
Deploying is the start, not the end. Models fail quietly: the server stays up but predictions get worse. Watch three things.
What to monitor
| What | Example | Why it matters |
|---|---|---|
| Service health | Errors, latency, uptime | The API is available and fast |
| Data drift | Average petal length of requests changes a lot from training data | The world changed, so the model sees inputs it was not trained on |
| Model quality | Accuracy on recent examples once true answers are known | The real test; often comes with delay (for example, did the customer actually churn?) |
Our log lines already capture inputs, predictions and latency. Real systems send these to a log or dashboard tool, compare input statistics against the training data, and alert when they shift. Retrain when quality drops or on a schedule, and test the new model against the old one before switching.
Experiment tracking in a nutshell
When you try many models, a notebook full of overwritten cells is not reproducible. Tools such as MLflow (open source) record the parameters, metrics and model file of every run so that you can compare runs and go back to any of them. At minimum, even without a tool: keep code in Git, set random seeds, write down the data version and save metrics from each run in a file.
Pre-deployment checklist
- Model and preprocessing saved together (Pipeline)
- Library versions pinned in requirements.txt
- Inputs validated before reaching the model
- Health endpoint and logging in place
- Tests for the API
- Random seed and data version recorded
- A plan for monitoring and retraining
- No secrets or personal data in the repository or in logs
You are an experienced ML engineer. Review my model-serving code for production problems: input validation, error handling, model loading, logging, security, versions and reproducibility.
[paste app.py and Dockerfile]
List the top 5 risks in order of importance with a concrete fix for each. Do not rewrite everything.What to expect: A good review mentions things like validation, pinned versions, handling a missing model file, and not logging sensitive data. Decide yourself which fixes to apply.
Try it: Extend the service
Add a /predict-batch endpoint that accepts a list of flowers and returns a list of predictions. Then add a /metrics endpoint that returns how many predictions were served and the average petal length of the requests so far, and use it to explain how you would notice data drift.
Key takeaways
- MLOps is how a model goes from notebook to reliable service and stays healthy.
- Save preprocessing and model together with a Pipeline, and pin library versions.
- Serve with an API such as FastAPI, load the model once at startup and validate input with Pydantic.
- Docker packages everything so the service runs identically anywhere.
- Models degrade silently: monitor service health, data drift and model quality, and plan retraining.
- Never load model files from untrusted sources, and keep secrets and personal data out of code and logs.
Check yourself
Finishing the quiz marks the lesson as done.
1. Why save a scikit-learn Pipeline instead of only the trained model?
2. Why load the model at server start rather than inside the predict function?
3. Your API is up and fast, but customers say the recommendations got worse over the last two months. What is the most likely issue?
4. What is the main benefit of Docker for model deployment?
5. Why is it risky to load a pickle or joblib file you downloaded from the internet?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.