Build and Containerize an ML API

Train a scikit-learn pipeline and serve predictions via a containerized FastAPI.

Introduction

30 Second Summary

Machine learning models trapped in a notebook are invisible to the rest of the world. No web app, mobile app, or teammate's code can reach in and use them.

In this project, you will build a scikit-learn classifier pipeline, wrap it in a FastAPI web service, and package everything inside a Docker container. By the end, anyone with Docker installed can run your prediction service with a single command.

What You'll Build

When you finish, you can open your browser, type flower measurements into the interactive API docs, and see a species prediction with a confidence score come back instantly.

By the end of this project, you'll have:

  • A prediction API where you send feature values and instantly get back a species name with a confidence score. You can send a single sample or an entire batch at once.
  • Input validation that catches bad data before it reaches the model, plus a health endpoint that reports exactly what model is loaded and what features it expects.
  • Automated tests that prove every endpoint works correctly. A Docker container that packages your entire application so anyone can run your ML API on any machine with a single command.
  • Secret Mission: Add an explainer endpoint that shows which features influenced each prediction the most.

Are there any prerequisites?

You need Python 3.14, VS Code, and Git installed on your machine. Docker is installed as part of Step 1.

Before We Start

Before we dive in, take a moment to lock in what you are about to build and why it matters.

Set Up Your Environment and Project Structure

Before you can train a model or serve predictions, you need the right tools installed and a clean workspace to build in. This step sets up Docker, creates the project's modular directory structure, and installs the Python libraries you'll use throughout the project.

Getting the structure right now pays off in every step that follows. Each file you create will go in a specific directory with a clear responsibility, making it straightforward to extend the project later.

In this step, get ready to:
  • Install Docker and verify it can run containers.
  • Create the full project directory structure with a Python virtual environment.
  • Initialize a Git repository with a proper .gitignore file.
Install Docker on Arch Linux

Docker containers package an application with all its dependencies so it runs identically on any machine. You'll use Docker in the final step to containerize your prediction API.

  • Install Docker and its build tools by running:
sudo pacman -S docker docker-buildx

What does this install?

docker is the container runtime that runs your images. docker-buildx adds support for building images from a Dockerfile.

pacman will ask you to confirm the installation. Enter Y when prompted to proceed.

  • Start the Docker service and set it to launch on boot by running:
sudo systemctl enable --now docker.service
  • Add your user to the docker group so you can run containers without sudo:
sudo usermod -aG docker $USER
  • Apply the group change to your current terminal session by running:
newgrp docker

Why do I need to run this?

Adding your user to the docker group lets you run containers without typing sudo every time. The newgrp command applies this change immediately so you do not have to log out and back in.

  • Verify Docker is working by running:
docker run hello-world

Docker will download a small test image the first time you run this. This takes a few seconds.

✔️ I see "Hello from Docker!"

Docker is installed and running. You're ready to move on.

ⓧ I see a permission error

The newgrp docker command may not have taken effect. Try closing your terminal completely, opening a new one, and running docker run hello-world again.

If that still fails, log out of your desktop session and log back in. This forces the operating system to refresh your group membership.

ⓧ I see "Cannot connect to the Docker daemon"

The Docker service may not be running. Check its status by running:

sudo systemctl status docker.service

If it shows inactive or failed, start it manually by running:

sudo systemctl start docker.service

Then try docker run hello-world again.

Create the project structure and virtual environment

Your API will use a modular structure where routes, schemas, services, and config each live in their own directory. Setting this up now means every file you write in later steps goes straight to the right place.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running this command:
mkdir -p secureiris && cd secureiris
  • Create the full directory tree by running:
mkdir -p app/core app/services app/schemas app/routes training model tests
  • Confirm the directories exist by running:
ls app/core app/services app/schemas app/routes training model tests

You should see each directory listed, most of them empty for now.

Why this directory structure?

app/routes/ holds your API endpoint handlers. app/schemas/ defines the shape of incoming requests and outgoing responses. app/services/ contains the logic that loads and runs the model. app/core/ stores configuration.

Keeping each concern in its own directory makes it easy to find, test, and extend individual pieces without touching the rest.

  • Create the Python package marker files so Python recognizes each directory as a module:
touch app/__init__.py app/core/__init__.py app/services/__init__.py app/schemas/__init__.py app/routes/__init__.py tests/__init__.py
  • Confirm the files were created by running:
ls app/*.py app/core/*.py app/services/*.py app/schemas/*.py app/routes/*.py tests/*.py

You should see six __init__.py files listed across the directories.

What are __init__.py files?

An empty __init__.py file tells Python that the folder it lives in is a package. Without it, Python cannot import code from that directory.

  • Create a Python virtual environment and activate it by running:
python3 -m venv venv
source venv/bin/activate

Your terminal prompt now shows (venv) at the beginning, confirming the virtual environment is active.

What is a virtual environment?

A virtual environment is an isolated Python installation that keeps your project's packages separate from your system Python. Packages you install inside (venv) only exist here, so they cannot conflict with anything else on your machine.

  • Install the project dependencies by running:
pip install scikit-learn==1.9.0 "fastapi[standard]==0.141.1" pytest==9.1.1 httpx==0.28.1

What are these packages?

scikit-learn is the machine learning library you'll use to build and train your model pipeline. FastAPI is the web framework that turns your model into a REST API.

pytest runs your automated tests. httpx is required by FastAPI's test client to simulate HTTP requests during testing.

  • Verify scikit-learn installed correctly by running:
pip show scikit-learn

You should see Version: 1.9.0 in the output.

Seeing an installation error?

Make sure your virtual environment is active. Your terminal prompt should start with (venv). If it does not, run source venv/bin/activate and try the install command again.

If packages fail to download, check your internet connection and try again. help me troubleshoot a pip install error in my virtual environment

Initialize Git and create a .gitignore

Version control tracks every change you make so you can always roll back if something breaks. A .gitignore file tells Git which files to leave out of the repository.

  • Initialize a Git repository in your project by running:
git init
  • Create a .gitignore file by running:
cat > .gitignore << 'EOF'
venv/
__pycache__/
model/*.joblib
EOF

What does this .gitignore do?

venv/ excludes the virtual environment directory. It is large and every developer recreates it from scratch.

__pycache__/ excludes Python's compiled bytecode files. model/*.joblib excludes the trained model artifact, which is too large for version control and will be regenerated by the training script.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the .gitignore file.

ⓧ I'd like to double check the full code

Your .gitignore should contain exactly this:

venv/
__pycache__/
model/*.joblib
  • Stage all files and create your first commit by running:
git add . && git commit -m "Initial project structure"

Before you check the log, what do you expect to see?

  • Verify the commit was created by running:
git log --oneline

You should see a single line with a short commit hash followed by Initial project structure.

Seeing "Author identity unknown"?

Git needs your name and email before it can create a commit. Set them by running:

git config --global user.name "Your Name"
git config --global user.email "your@email.com"

Then run the git add and git commit commands again. help me configure Git for my first commit

Your environment is locked in. Next up, you'll write a training script that builds a machine learning pipeline and saves it as a reusable artifact.

Train a Model Pipeline and Save It

Your project structure is ready and all dependencies are installed. In this step, you will train a machine learning model and save it so your API can serve predictions.

One of the most common ML deployment bugs is applying different preprocessing during training and inference. If you scale your features during training but forget to scale them when predicting, your model silently produces wrong results. A scikit-learn Pipeline solves this by bundling preprocessing and the model into a single object.

In this step, get ready to:
  • Write a training script that builds a Pipeline with preprocessing and a classifier.
  • Train the pipeline on sample data and save it with metadata as a reusable artifact.
Write the training script with a Pipeline

The Iris dataset is a classic flower classification dataset built into scikit-learn. It has 150 samples, 4 features per sample, and 3 target species. You will build a Pipeline that chains a StandardScaler (normalizes feature values) with a Random Forest classifier.

Why a Pipeline instead of a raw model?

Without a Pipeline, you would train a scaler and a classifier separately. At prediction time, you would have to remember to apply the scaler first, then pass the result to the classifier. If you forget or apply it differently, predictions are silently wrong.

A Pipeline chains both steps into one object. Calling .predict() always scales first, then classifies. One object, zero room for mismatch.

  • Open training/train.py in VS Code and paste the imports and data loading code:
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score
from joblib import dump
from pathlib import Path

# Load dataset
iris = load_iris()
X, y = iris.data, iris.target

# Split into training and test sets
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

What does this code do?

  • load_iris() loads a built-in dataset with 150 flower samples. Each sample has 4 measurements (sepal length, sepal width, petal length, petal width) and belongs to one of 3 species.
  • train_test_split randomly divides the data into 80% training and 20% test sets. Setting random_state=42 makes the split reproducible every time you run the script.
  • joblib and Path are imported now but used later when saving the trained pipeline to disk.
  • Save training/train.py. The next chunk adds the pipeline and training logic that produces visible results.
  • Add the pipeline creation, training, and evaluation code below the train_test_split section in training/train.py:
# Build pipeline: preprocessing + model
pipeline = Pipeline([
    ("scaler", StandardScaler()),
    ("classifier", RandomForestClassifier(n_estimators=100, random_state=42)),
])

# Train
pipeline.fit(X_train, y_train)

# Evaluate
predictions = pipeline.predict(X_test)
accuracy = accuracy_score(y_test, predictions)
print(f"Model accuracy: {accuracy:.2f}")

# Sample prediction
sample = X_test[0:1]
prediction = pipeline.predict(sample)
print(f"Sample prediction: {iris.target_names[prediction[0]]}")
print(f"Actual label: {iris.target_names[y_test[0]]}")

What does this code do?

  • Pipeline chains two steps. First, StandardScaler normalizes each feature to have zero mean and unit variance. Then RandomForestClassifier trains 100 decision trees and combines their votes for a prediction.
  • pipeline.fit(X_train, y_train) trains both the scaler and the classifier in one call. The scaler learns the feature statistics, and the classifier learns the decision boundaries.
  • The evaluation section runs the pipeline on the held-out test set and prints the accuracy. The sample prediction shows a single flower's predicted species vs. its actual label.
  • Save training/train.py.

The script is runnable now. What accuracy do you think a Random Forest classifier will achieve on a clean dataset like Iris?

  • Run the training script to see your first results:
python3 training/train.py

You should see accuracy above 0.90 printed in the terminal, followed by a sample prediction and the actual label for one flower.

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 source venv/bin/activate from inside the secureiris directory.
  • Make sure you are running the command from the secureiris project directory, not from inside the training/ subdirectory.

Why isn't my training script finding scikit-learn?

  • Now add the artifact saving code at the bottom of training/train.py, below the sample prediction section:
# Save artifact: pipeline + metadata
model_dir = Path("model")
model_dir.mkdir(exist_ok=True)

artifact = {
    "pipeline": pipeline,
    "target_names": list(iris.target_names),
    "feature_names": list(iris.feature_names),
}
artifact_path = model_dir / "model_pipeline.joblib"
dump(artifact, artifact_path)
print(f"Pipeline saved to {artifact_path}")

Why save metadata alongside the pipeline?

The artifact dictionary stores three things: the trained pipeline, target_names (the species labels), and feature_names (what each input measurement represents).

This means the API never needs to hardcode anything about the Iris dataset. To swap to a different dataset later, you only change training/train.py. The API loads whatever artifact is at the configured path.

  • Save training/train.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved training/train.py.

ⓧ I'd like to double check the full code

Scroll up and confirm your training/train.py has these three sections, in this order:

  • Imports and data loading (8 import lines, load_iris(), and train_test_split).
  • Pipeline creation, training, evaluation, and sample prediction (Pipeline with StandardScaler and RandomForestClassifier, .fit(), accuracy, and a sample prediction).
  • Artifact saving (a dictionary with pipeline, target_names, and feature_names, saved with dump()).
Run the training and verify the artifact

The script now loads data, trains the pipeline, prints results, and saves the artifact. Time to run the full thing and confirm everything landed on disk.

  • Run the training script by running:
python3 training/train.py

You see the model accuracy (above 0.90), a sample prediction, the actual label, and a new line: Pipeline saved to model/model_pipeline.joblib.

  • Confirm the artifact file exists on disk by running:
ls -la model/model_pipeline.joblib

You should see model_pipeline.joblib listed with its file size.

Before you inspect the artifact, what three keys do you think are stored inside it?

  • Inspect the artifact contents by running:
python3 -c "from joblib import load; a = load('model/model_pipeline.joblib'); print(a.keys()); print(type(a['pipeline']))"

You see dict_keys(['pipeline', 'target_names', 'feature_names']) followed by <class 'sklearn.pipeline.Pipeline'>. The artifact holds your trained pipeline and all the metadata the API needs to serve predictions.

Seeing a FileNotFoundError?

  • Make sure you are running the commands from the secureiris project directory. Run pwd to check.
  • Make sure the third chunk (artifact saving) is present at the bottom of training/train.py and that you saved the file before running.

My training script runs but the joblib artifact is not being saved. Help me debug.

Your trained pipeline and its metadata are saved as a single artifact. Next up, you will wrap this artifact in a FastAPI web service so any application can send HTTP requests and get predictions back.

Build the Prediction API with FastAPI

Your trained pipeline sits on disk as a model_pipeline.joblib file. That file is useless to anyone who cannot run Python and load it themselves.

Wrapping the pipeline in a web API means any application, in any language, on any device, can send HTTP requests and get predictions back. In this step, you will build a modular FastAPI application with separate config, services, schemas, and route files. The API reads feature names, target names, and expected feature count from the saved artifact, so it never hardcodes anything dataset-specific.

In this step, get ready to:
  • Create the config, model service, and schema modules that load and serve predictions from your saved pipeline.
  • Build route handlers for health checks, single predictions, and batch predictions.
  • Run the API and test every endpoint through auto-generated interactive docs.
Create the config, model service, and schemas

Three foundation files power the API. A config module defines the path to the model artifact. A service module loads the artifact and exposes prediction functions. A schemas module defines the shape of every request and response using Pydantic.

  • Open app/core/config.py in VS Code and paste the following code:
from pathlib import Path

# Resolve paths relative to the project root
BASE_DIR = Path(__file__).resolve().parent.parent.parent
MODEL_PATH = BASE_DIR / "model" / "model_pipeline.joblib"

What does this code do?

Path(__file__).resolve().parent.parent.parent climbs three directories up from app/core/config.py to reach the project root (secureiris/). From there it builds the full path to model/model_pipeline.joblib. Centralizing the path here means every other module imports it from one place.

  • Save config.py.
  • Open app/services/model_service.py and paste this first section, which loads the artifact and extracts its contents:
from joblib import load
from app.core.config import MODEL_PATH
import numpy as np

# Load the saved artifact once at startup
_artifact = load(MODEL_PATH)

# Extract the pipeline and metadata
pipeline = _artifact["pipeline"]
target_names = _artifact["target_names"]
feature_names = _artifact["feature_names"]
n_features = pipeline.n_features_in_

What does this code do?

This code runs once when Python first imports the module. It loads the joblib artifact you saved in Step 2 and unpacks the pipeline, target names, feature names, and expected feature count into module-level variables.

Because loading happens at import time, the model is ready in memory before any request arrives. The API never needs to know which dataset was used for training.

  • Save model_service.py.
  • Verify that the artifact loads correctly by running this command in your terminal:
python3 -c "from app.services.model_service import pipeline; print(type(pipeline))"

You should see <class 'sklearn.pipeline.Pipeline'> printed.

Seeing a ModuleNotFoundError?

  • Make sure you are running the command from inside the secureiris/ directory, not from a parent folder.
  • Confirm your virtual environment is active. You should see (venv) at the start of your terminal prompt.
  • Check that config.py is saved inside app/core/, not in the project root.

Still stuck? Why can't Python find my model_service module?

  • Return to app/services/model_service.py and add the three functions below the existing code:
def predict(features: list[float]) -> tuple[str, float]:
    # Run a single sample through the pipeline
    data = np.array([features])
    prediction = pipeline.predict(data)[0]
    probabilities = pipeline.predict_proba(data)[0]
    confidence = float(probabilities[prediction])
    return target_names[prediction], round(confidence, 4)

def predict_batch(samples: list[list[float]]) -> list[tuple[str, float]]:
    # Run multiple samples through the pipeline at once
    data = np.array(samples)
    predictions = pipeline.predict(data)
    probabilities = pipeline.predict_proba(data)
    results = []
    for i, pred in enumerate(predictions):
        confidence = float(probabilities[i][pred])
        results.append((target_names[pred], round(confidence, 4)))
    return results

def get_health_info() -> dict:
    # Return metadata about the loaded model
    return {
        "status": "healthy",
        "model_type": type(pipeline).__name__,
        "n_features": n_features,
        "feature_names": list(feature_names),
        "target_names": list(target_names),
    }

What do these functions do?

  • predict() takes a list of feature values, passes them through the pipeline, and returns the predicted class name plus the model's confidence score.
  • predict_batch() does the same for multiple samples at once, which is more efficient than calling predict() in a loop.
  • get_health_info() returns the model's metadata so callers can discover what features the model expects and what classes it predicts.
  • Save model_service.py.
  • Verify that the prediction function works by running:
python3 -c "from app.services.model_service import predict; print(predict([5.1, 3.5, 1.4, 0.2]))"

You should see a tuple like ('setosa', 1.0) with the predicted species name and a confidence score.

Seeing a NameError or ImportError?

  • Make sure you added the three functions below the existing loading code, not in a separate file.
  • Check that numpy is imported at the top of model_service.py. It was installed as a dependency of scikit-learn.

Still stuck? Why is my predict function not working?

✔️ Awesome, I've got everything!

Great. Make sure you have saved model_service.py.

ⓧ I'd like to double check the full code

Scroll up and confirm both chunks are present in app/services/model_service.py, in this order:

  • Imports (joblib, config, numpy) and module-level loading (_artifact, pipeline, target_names, feature_names, n_features).
  • Three functions: predict(), predict_batch(), and get_health_info().
  • Open app/schemas/prediction.py and paste the following schema definitions:
from pydantic import BaseModel, Field

# Single prediction request: a list of feature values
class PredictionRequest(BaseModel):
    features: list[float] = Field(
        ...,
        min_length=1,
        description="Feature values in the order the model expects",
    )

# Single prediction result
class PredictionResponse(BaseModel):
    prediction: str
    confidence: float

# Multiple samples in one request
class BatchPredictionRequest(BaseModel):
    samples: list[list[float]]

# Batch result wrapping multiple PredictionResponses
class BatchPredictionResponse(BaseModel):
    predictions: list[PredictionResponse]

# Health check response with model metadata
class HealthResponse(BaseModel):
    status: str
    model_type: str
    n_features: int
    feature_names: list[str]
    target_names: list[str]

What do these schemas do?

Each class defines the exact shape of a request or response. Pydantic validates incoming data automatically. If a caller sends a request missing the features field or passes a string where a float is expected, FastAPI returns a 422 error before your code ever runs.

PredictionRequest accepts a plain list of floats instead of named fields like sepal_length. This keeps the API model-agnostic. The /health endpoint (using HealthResponse) tells callers which features the model expects.

  • Save prediction.py.
  • Verify the schemas load correctly by running:
python3 -c "from app.schemas.prediction import PredictionRequest; print(PredictionRequest(features=[1.0, 2.0]))"

You should see features=[1.0, 2.0] printed, confirming Pydantic parsed the input.

✔️ Awesome, I've got everything!

Great. Make sure you have saved prediction.py inside app/schemas/.

ⓧ I'd like to double check the full code

from pydantic import BaseModel, Field

# Single prediction request: a list of feature values
class PredictionRequest(BaseModel):
    features: list[float] = Field(
        ...,
        min_length=1,
        description="Feature values in the order the model expects",
    )

# Single prediction result
class PredictionResponse(BaseModel):
    prediction: str
    confidence: float

# Multiple samples in one request
class BatchPredictionRequest(BaseModel):
    samples: list[list[float]]

# Batch result wrapping multiple PredictionResponses
class BatchPredictionResponse(BaseModel):
    predictions: list[PredictionResponse]

# Health check response with model metadata
class HealthResponse(BaseModel):
    status: str
    model_type: str
    n_features: int
    feature_names: list[str]
    target_names: list[str]

Why does the API use a list of floats instead of named fields?

If the schema defined fields like sepal_length and petal_width, the API would be permanently tied to the Iris dataset. Using a generic list of floats means you can swap the model for a completely different dataset without changing any API code.

Callers discover the expected feature names and count by hitting the /health endpoint.

Create the routes and main application

The foundation modules are ready. Now you need route handlers to expose them as HTTP endpoints and a main application file to wire everything together.

  • Open app/routes/health.py and paste the following code:
from fastapi import APIRouter
from app.schemas.prediction import HealthResponse
from app.services import model_service

router = APIRouter()

# Returns model metadata and status
@router.get("/health", response_model=HealthResponse)
def health():
    return model_service.get_health_info()

What does this code do?

APIRouter groups related endpoints into a module that the main app can include. The /health endpoint calls get_health_info() from your model service and returns the result shaped as a HealthResponse.

  • Save health.py.
  • Open app/main.py and paste the following to create the application and register the health route:
from fastapi import FastAPI
from app.routes import health

app = FastAPI(title="SecureIris Prediction API", version="1.0.0")

# Register the health router
app.include_router(health.router)

# Root endpoint
@app.get("/")
def root():
    return {"message": "SecureIris Prediction API is running"}

What does this code do?

This creates the FastAPI application instance and registers the health router using app.include_router(). The root endpoint at / returns a simple JSON message confirming the API is running. You will add the prediction router shortly.

  • Save main.py.
  • Start the development server by running:
uvicorn app.main:app --reload
  • Open http://127.0.0.1:8000/docs in your browser.

You should see the Swagger UI interactive docs page listing two endpoints: GET / and GET /health.

  • Click the GET /health row to expand it.
  • Click Try it out, then click Execute.

The response body shows the model's status, feature names, target names, and expected feature count. This metadata comes directly from the artifact you saved in Step 2.

Server not starting?

  • Make sure you are running uvicorn from inside the secureiris/ directory.
  • Check that every __init__.py file exists inside app/, app/core/, app/services/, app/schemas/, and app/routes/. These files can be empty but must be present.
  • If you see a FileNotFoundError about the model artifact, confirm that model/model_pipeline.joblib exists by running ls model/.

Still stuck? Why won't my FastAPI server start?

Keep the server running. The --reload flag tells uvicorn to watch for file changes and restart automatically, so everything you save next takes effect without restarting.

  • Open a second terminal tab. Make sure you cd into the secureiris/ directory and activate your venv in the new tab:
cd ~/Desktop/secureiris
source venv/bin/activate
  • Open app/routes/prediction.py and paste the imports, router, and the /predict endpoint:
from fastapi import APIRouter, HTTPException
from app.schemas.prediction import (
    PredictionRequest,
    PredictionResponse,
    BatchPredictionRequest,
    BatchPredictionResponse,
)
from app.services import model_service

router = APIRouter()

# Validate feature count and return a single prediction
@router.post("/predict", response_model=PredictionResponse)
def predict(request: PredictionRequest):
    if len(request.features) != model_service.n_features:
        raise HTTPException(
            status_code=422,
            detail=f"Expected {model_service.n_features} features, got {len(request.features)}",
        )
    prediction, confidence = model_service.predict(request.features)
    return PredictionResponse(prediction=prediction, confidence=confidence)

What does this code do?

Before calling the model, the route checks that the number of features matches what the pipeline expects. If a caller sends 2 features instead of 4, the route raises a 422 error with a clear message. This validation catches bad input before it reaches the model, where it would cause a confusing array shape error.

  • Add the batch prediction endpoint below the existing code in the same file:
# Validate each sample's feature count and return batch predictions
@router.post("/predict/batch", response_model=BatchPredictionResponse)
def predict_batch(request: BatchPredictionRequest):
    for i, sample in enumerate(request.samples):
        if len(sample) != model_service.n_features:
            raise HTTPException(
                status_code=422,
                detail=f"Sample {i}: expected {model_service.n_features} features, got {len(sample)}",
            )
    results = model_service.predict_batch(request.samples)
    return BatchPredictionResponse(
        predictions=[
            PredictionResponse(prediction=pred, confidence=conf)
            for pred, conf in results
        ]
    )

What does this code do?

The batch endpoint validates every sample individually before passing them to predict_batch(). If sample 3 out of 5 has the wrong number of features, the error message names exactly which sample is invalid. This is much more helpful than a generic failure from numpy.

  • Save prediction.py.

✔️ Awesome, I've got everything!

Great. Make sure you have saved app/routes/prediction.py.

ⓧ I'd like to double check the full code

Scroll up and confirm both chunks are present in app/routes/prediction.py, in this order:

  • Imports (APIRouter, HTTPException, all schema classes, model_service), router = APIRouter(), and the POST /predict route with feature count validation.
  • The POST /predict/batch route with per-sample validation.

Now you need to register the prediction router in main.py.

  • In app/main.py, find the import line at the top:
from app.routes import health
  • Replace it with this line to also import the prediction router:
from app.routes import health, prediction
  • Find this line:
app.include_router(health.router)
  • Add this line directly below it:
app.include_router(prediction.router)
  • Save main.py.

The running server auto-reloads with your changes. Switch back to your browser and refresh http://127.0.0.1:8000/docs. You should now see five endpoints listed: GET /, GET /health, POST /predict, and POST /predict/batch.

Not seeing the new endpoints?

  • Check the terminal running uvicorn. If you see an error, the auto-reload caught a syntax issue. Read the traceback to find the file and line number.
  • Make sure main.py has both app.include_router(health.router) and app.include_router(prediction.router).
  • Hard-refresh your browser with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows).

Still stuck? Why aren't my prediction endpoints showing in /docs?

✔️ Awesome, I've got everything!

Great. Make sure main.py is saved.

ⓧ I'd like to double check the full code

from fastapi import FastAPI
from app.routes import health, prediction

app = FastAPI(title="SecureIris Prediction API", version="1.0.0")

# Register route modules
app.include_router(health.router)
app.include_router(prediction.router)

# Root endpoint
@app.get("/")
def root():
    return {"message": "SecureIris Prediction API is running"}
  • Create requirements.txt in the project root by running:
cat > requirements.txt << 'EOF'
scikit-learn==1.9.0
fastapi[standard]==0.141.1
httpx==0.28.1
pytest==9.1.1
EOF

Why pin dependency versions?

Pinning exact versions guarantees that everyone who builds this project installs the same packages. When you containerize the API with Docker in the next step, pip install -r requirements.txt inside the container will produce an identical environment.

  • Verify the file was created correctly by running:
cat requirements.txt

You should see all four dependencies with their pinned versions.

Test all endpoints and verify validation

Your API is running and all four endpoints are wired up. Time to test each one through the Swagger UI interactive docs and confirm that invalid input is properly rejected.

  • In your browser at http://127.0.0.1:8000/docs, click the POST /predict row to expand it.
  • Click Try it out.
  • Replace the example request body with this JSON:
{
  "features": [5.1, 3.5, 1.4, 0.2]
}
  • Click Execute.

The response body shows a prediction (a species name like setosa) and a confidence score between 0 and 1.

  • Test the POST /predict/batch endpoint. Click Try it out and use this request body:
{
  "samples": [
    [5.1, 3.5, 1.4, 0.2],
    [6.7, 3.0, 5.2, 2.3]
  ]
}
  • Click Execute.

The response contains two predictions, one for each sample.

Now for the real test. What happens when someone sends the wrong number of features?

  • Go back to the POST /predict endpoint.
  • Click Try it out and enter this request body with only 2 features:
{
  "features": [5.1, 3.5]
}

Before you click Execute, predict what will happen. Will the API return a prediction, or will it reject the request?

  • Click Execute.

You get a 422 status code with a message like Expected 4 features, got 2. Your feature count validation caught the bad input before it reached the model.

  • Switch back to the terminal running uvicorn and press Ctrl+C to stop the server.

Your API is fully wired up with health checks, predictions, batch predictions, and input validation. Next up, you will write automated tests that verify all of this works without a browser.

Write and Run Unit Tests

In the last step, you built a full prediction API and tested it manually through the Swagger docs. Clicking through endpoints by hand works for a quick check, but it does not scale.

If you change the model service or add a route in a future project, you need to know existing functionality still works. Automated tests catch regressions in seconds and are required by the CI pipeline you will build in Project 3.

In this step, get ready to:
  • Write health and root endpoint tests with FastAPI TestClient.
  • Write prediction endpoint tests covering valid input, invalid input, and batch requests.
  • Run the full test suite and commit your code.
Write health and root endpoint tests

FastAPI provides a TestClient that lets you call your routes without starting a live server. It sends requests directly to your application in-process, so tests run in milliseconds.

  • Create a new file called tests/test_health.py in VS Code and paste the following code:
from fastapi.testclient import TestClient
from app.main import app

# Create a test client that calls routes without a live server
client = TestClient(app)


def test_root():
    # GET / should return a 200 with a message key
    response = client.get("/")
    assert response.status_code == 200
    assert "message" in response.json()


def test_health():
    # GET /health should return model metadata
    response = client.get("/health")
    assert response.status_code == 200
    data = response.json()
    assert data["status"] == "healthy"
    assert data["n_features"] > 0
    assert len(data["target_names"]) > 0
    assert len(data["feature_names"]) > 0

What does this code do?

  • TestClient(app) wraps your FastAPI app so you can call routes like client.get("/") without running uvicorn.
  • test_root checks that GET / returns a 200 status and a JSON body containing a message key.
  • test_health checks that GET /health returns a healthy status plus the feature and target metadata that the model service loaded from the artifact.
  • Save tests/test_health.py.
  • Run the tests by running this command from the secureiris directory:
pytest tests/test_health.py

You should see 2 tests pass with green output.

Tests failing or not found?

  • Make sure your virtual environment is active. You should see (venv) in your terminal prompt.
  • Check that tests/__init__.py exists. pytest needs this file to discover tests in the tests/ directory.
  • Confirm you are running the command from inside the secureiris directory, not from inside tests/.
  • help me debug my failing test_health tests

Why does TestClient need httpx?

FastAPI's TestClient uses httpx under the hood to send requests. You installed it in Step 1 as part of your project dependencies, so it is already available in your virtual environment.

Write prediction endpoint tests

The prediction endpoints need more thorough testing. You need to verify that valid requests return correct responses, that requests with the wrong number of features are rejected, and that batch predictions work as expected.

  • Create a new file called tests/test_prediction.py and paste the following code:
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)


# Test that a valid prediction returns a species and confidence
def test_predict_valid():
    response = client.post("/predict", json={
        "features": [5.1, 3.5, 1.4, 0.2]
    })
    assert response.status_code == 200
    data = response.json()
    assert "prediction" in data
    assert "confidence" in data
    assert 0 <= data["confidence"] <= 1

What does this test check?

test_predict_valid sends 4 feature values (matching the Iris model's expected input) to POST /predict. It checks that the response has a 200 status, a prediction string, and a confidence float between 0 and 1.

  • Save tests/test_prediction.py.
  • Run the full test suite so far by running:
pytest

You should see 3 tests pass: the 2 health tests from before, plus your new prediction test.

  • Add the error case and batch tests to tests/test_prediction.py below the test_predict_valid function:
# Test that sending too few features returns 422
def test_predict_wrong_feature_count():
    response = client.post("/predict", json={
        "features": [5.1, 3.5]
    })
    assert response.status_code == 422

# Test that an empty feature list is rejected
def test_predict_empty_features():
    response = client.post("/predict", json={
        "features": []
    })
    assert response.status_code == 422

# Test that batch predictions return one result per sample
def test_predict_batch():
    response = client.post("/predict/batch", json={
        "samples": [
            [5.1, 3.5, 1.4, 0.2],
            [6.7, 3.0, 5.2, 2.3],
        ]
    })
    assert response.status_code == 200
    data = response.json()
    assert len(data["predictions"]) == 2

# Test that batch validation catches per-sample feature count errors
def test_predict_batch_wrong_feature_count():
    response = client.post("/predict/batch", json={
        "samples": [
            [5.1, 3.5, 1.4, 0.2],
            [6.7, 3.0],
        ]
    })
    assert response.status_code == 422

What do these tests cover?

  • test_predict_wrong_feature_count sends only 2 features instead of 4. Your route's validation catches this mismatch and returns a 422 error.
  • test_predict_empty_features sends an empty list. Pydantic's min_length=1 constraint on PredictionRequest rejects this before your route code even runs.
  • test_predict_batch sends two valid samples and checks that both get predictions back.
  • test_predict_batch_wrong_feature_count mixes one valid sample with one invalid sample to confirm batch validation catches per-sample errors.
  • Save tests/test_prediction.py.
  • Run the tests again by running:
pytest

You should see 7 tests pass: 2 from test_health.py and 5 from test_prediction.py.

Seeing test failures?

  • If a 422 test is failing, double check that the feature list in your test matches the expected error condition. test_predict_wrong_feature_count should send exactly 2 features (not 4).
  • If you see an import error, confirm that all __init__.py files exist in app/, app/routes/, app/schemas/, app/services/, and tests/.
  • help me debug my failing prediction tests

✔️ Awesome, I've got everything!

Great. Double check you have saved tests/test_prediction.py with all 5 test functions.

ⓧ I'd like to double check the full code

Scroll up and confirm your tests/test_prediction.py contains these sections in order:

  • The imports and client = TestClient(app) line at the top.
  • test_predict_valid (sends 4 features, checks 200 and confidence range).
  • test_predict_wrong_feature_count (sends 2 features, expects 422).
  • test_predict_empty_features (sends empty list, expects 422).
  • test_predict_batch (sends 2 valid samples, checks 2 predictions returned).
  • test_predict_batch_wrong_feature_count (mixes valid and invalid samples, expects 422).
Run the test suite

Time for the final check. Before you run the next command, think about what you expect to see. How many tests should pass, and from which files?

  • Run the full test suite with verbose output by running:
pytest -v

You should see all 7 tests listed by name, each followed by PASSED in green. The verbose flag shows each test function's full path so you can see exactly what ran.

  • Commit your work by running:
git add . && git commit -m "Add API, tests, and model pipeline"

Why commit everything together?

This commit captures the API code, the model artifact reference (via .gitignore), and the tests that prove it all works. Anyone who clones this repo can run pytest -v and immediately see whether the API is in a working state.

Your API is tested and committed. Next up, you will package the entire application into a Docker container so it runs identically on any machine.

Containerize Your API with Docker

Your tests all pass and every endpoint works. But right now, your API depends entirely on your machine: your Python version, your installed packages, your file paths.

Docker solves this by packaging your application, its dependencies, and its runtime into a single image that runs identically on any machine. In this step, you'll write a Dockerfile, build an image, and run your API inside a container.

In this step, get ready to:
  • Write a Dockerfile that packages your API into a Docker image.
  • Build and run a Docker container from your image.
  • Verify predictions work inside the container.
Write the Dockerfile

A Dockerfile is a recipe that tells Docker exactly how to build your image. Each line is an instruction: start from a base image, install dependencies, copy files, and set the startup command.

  • Create the Dockerfile in the root of your secureiris folder by running this command:
touch Dockerfile
  • Confirm the file exists by running:
ls Dockerfile

You should see Dockerfile printed in the output.

  • Open Dockerfile in VS Code and paste the following code:
FROM python:3.14-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app/ app/

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

What does this Dockerfile do?

  • FROM python:3.14-slim starts from a minimal Python image with only the essentials installed.
  • WORKDIR /app sets the working directory inside the container. All subsequent commands run from this path.
  • COPY requirements.txt and RUN pip install copy your dependency list into the container and install everything. The --no-cache-dir flag keeps the image smaller by not caching downloaded packages.
  • COPY app/ app/ copies your application code into the container.
  • CMD starts uvicorn when the container launches, binding to 0.0.0.0 so connections from outside the container can reach it.
  • Save Dockerfile.

File not recognized later?

  • Make sure the file is named exactly Dockerfile with a capital D and no file extension. Names like dockerfile or Dockerfile.txt will not be found by Docker.
  • Confirm the file is in the root of your secureiris folder, not inside app/ or another subdirectory.
  • Help me fix my Dockerfile setup.
Build and run the container

Your Dockerfile is ready. Time to build a Docker image from it and start a container.

  • Build your Docker image by running this command:
docker build -t secureiris .

What does this command do?

docker build reads your Dockerfile and executes each instruction to create an image. The -t secureiris flag tags the image with the name secureiris so you can refer to it later.

The first build takes a minute or two because Docker downloads the base image and installs all your packages. Future builds reuse cached layers and finish much faster.

You should see each step complete, ending with a line that says the image was successfully tagged.

Before you run the container, what do you think will happen? Will the API serve predictions just like it did locally?

  • Start the container by running this command:
docker run -p 8000:8000 secureiris

The container crashes immediately. You see a FileNotFoundError in the output. The container tried to load model/model_pipeline.joblib, but that file does not exist inside the container.

Why did the container crash?

This is Docker's isolation in action. A Docker container has its own file system that is completely separate from your host machine. Files on your machine do not automatically appear inside the container.

Your model/model_pipeline.joblib exists locally, but the Dockerfile never copied it into the image. Only files that appear in a COPY instruction end up inside the container.

Fix the Dockerfile and verify

The fix is a single line. You need to tell Docker to copy the model/ directory into the image.

  • In Dockerfile, find this line:
COPY app/ app/
  • Add this line directly below it:
COPY model/ model/
  • Save Dockerfile.
  • Rebuild the image by running this command:
docker build -t secureiris .

The rebuild finishes much faster this time. Docker caches the layers that did not change (the base image and dependency install), so only the new COPY layer is rebuilt.

What do you expect to see this time when you run the container?

  • Start the container by running this command:
docker run -p 8000:8000 secureiris

What does -p 8000:8000 do?

The -p 8000:8000 flag maps port 8000 on your machine to port 8000 inside the container. Without this mapping, the container's port is unreachable from your browser.

This time the container starts successfully. You should see uvicorn's startup log indicating the server is running on 0.0.0.0:8000.

  • Open your browser and go to http://localhost:8000/docs.
  • Click on POST /predict to expand it.
  • Click Try it out.
  • Replace the request body with {"features": [5.1, 3.5, 1.4, 0.2]}.
  • Click Execute.

You should see a prediction response with a species name and a confidence score. Your containerized API serves predictions identically to the local version.

  • Return to the terminal and press Ctrl+C to stop the container.
  • Check the image size by running this command:
docker images secureiris

You should see the secureiris image listed with its size. This is everything needed to run your API on any machine with Docker installed.

✔️ Awesome, I've got everything!

Great. Double check you have saved your Dockerfile.

ⓧ I'd like to double check the full code

Here is the complete Dockerfile. Make sure your version matches this exactly.

FROM python:3.14-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app/ app/

COPY model/ model/

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Container not starting or predictions failing?

  • If you still see a FileNotFoundError, make sure you added COPY model/ model/ to your Dockerfile and rebuilt the image with docker build -t secureiris . after saving.
  • If your browser cannot connect to http://localhost:8000/docs, check that you included the -p 8000:8000 flag in your docker run command. Without the port mapping, the container is unreachable.
  • If port 8000 is already in use, make sure you stopped any locally running uvicorn process from earlier steps.
  • Help me troubleshoot my Docker container.
  • Commit the Dockerfile by running this command:
git add . && git commit -m "Add Dockerfile"

Your API is now fully containerized. Anyone with Docker installed can pull your image and run your ML prediction service with a single command. Next up is the Secret Mission, where you can teach your model to explain its own predictions.

Secret mission

Add a Prediction Explainer Endpoint

Your API tells users what it predicted, but not why. In real ML deployments, stakeholders need to understand which features drove a prediction. Can you add a /predict/explain endpoint that returns feature importances alongside the prediction?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The secureiris project folder on your Desktop (source code, virtual environment, trained model artifact, Git repository).
  • The secureiris Docker image.
  • Docker containers created from the secureiris image.
  • Docker and Docker Buildx system packages (installed via pacman).

Keep everything running

No action needed. Choose this if you are still actively building or plan to continue with the next SecureIris project soon.

  • Your project files stay in the secureiris folder on your Desktop.
  • Your Docker image remains available locally. You can start the API any time with docker run -p 8000:8000 secureiris.
  • Docker stays installed and ready to use for future projects.

Pause - I'll come back to this later

Stop any running containers to free up system resources. Your project files and Docker image stay on disk so you can pick up where you left off.

  • If you have a running container, press Ctrl+C in the terminal where it is running to stop it.
  • If your virtual environment is active, deactivate it by running:
deactivate
  • When you come back, navigate to your secureiris folder, activate the virtual environment with source venv/bin/activate, and pick up from where you left off.

Delete - I don't want to use this again

Remove all project resources and start fresh. This removes the project files, Docker image, containers, and optionally Docker itself.

  • If you have a running container, press Ctrl+C in its terminal to stop it.
  • Remove all containers created from the secureiris image by running:
docker rm -f $(docker ps -a -q --filter ancestor=secureiris)
  • Remove the Docker image by running:
docker rmi secureiris
  • If your virtual environment is still active, deactivate it first by running:
deactivate
  • Delete the entire project folder by running:
rm -rf ~/Desktop/secureiris
  • If you want to uninstall Docker entirely, disable the service and remove the packages by running:
sudo systemctl disable --now docker.service
sudo pacman -Rs docker docker-buildx

Nice Work!

Nice Work!

You just built a fully containerized machine learning prediction API from scratch. Anyone with Docker installed can run your image and start getting predictions without installing a single dependency.

You learned how to:

  • Build a scikit-learn Pipeline that bundles StandardScaler preprocessing with a RandomForestClassifier into a single artifact, preventing the training/inference preprocessing mismatch bug. Swapping datasets means changing only training/train.py.
  • Build a modular FastAPI application with separate config, services, schemas, and routes serving production endpoints like /predict, /predict/batch, and /health, with input validation and automated unit tests using pytest.
  • Containerize the full application into a Docker image by hitting a real FileNotFoundError that proved Docker's isolation, then fixing it so the API runs anywhere with a single command.
  • Secret Mission: Added a /predict/explain endpoint that returns feature importances, proving the model can explain its decisions.

Ready to quiz yourself?