DEV Community

Marcos Alvarez
Marcos Alvarez

Posted on

Deploying a Frozen ML Model with FastAPI and Docker

Module 5 of ML Zoomcamp hands you a frozen model and asks you to serve it. Sounds trivial until your probabilities are slightly off and nothing errors out.

Here is the setup that worked for me, with the actual code.

  1. Verify the artifact first Before touching anything, check you have the right model: sha256sum pipeline.bin

expected: 1646bbdcd38d4f044da6b630c5b332c93a314245a8c21929011c42de51f629f1

I learned this the hard way. Debugging numbers on the wrong artifact wastes an afternoon.

  1. Pin the environment
    uv sync --locked
    That --locked flag is the whole story behind my probability mismatch. Without it, uv resolved a different sklearn build and everything shifted slightly. The reference env pins scikit-learn==1.7.2. Don't regenerate the lockfile for the graded run.

  2. Load the model the same way the API does

import pickle
from model import normalize_record

with open("pipeline.bin", "rb") as f:
pipeline = pickle.load(f) # DictVectorizer + LogisticRegression

lead = {
"lead_source": "paid_ads",
"industry": "technology",
"employment_status": "employed",
"location": "north_america",
"number_of_courses_viewed": 2,
"annual_income": 79276.0,
"interaction_count": 4,
"lead_score": 0.41,
}
record = normalize_record(lead)
prob = float(pipeline.predict_proba([record])[0, 1])
print(round(prob, 3)) # 0.533
The key line is normalize_record. It applies the training-set medians and "NA" for missing categoricals. Hand-rolling imputation gives you a different vector and a different answer.

  1. Serve it @app.post("/predict") def predict(lead: Lead): record = normalize_record(lead.model_dump()) prob = float(pipeline.predict_proba([record])[0, 1]) return {"conversion_probability": round(prob, 6), "conversion": prob >= 0.5} uv run uvicorn predict:app --host 0.0.0.0 --port 9696 curl -s http://localhost:9696/health # {"status":"ok","model_sha256":"1646...","feature_defaults_sha256":"1694..."} Note the API loads the pipeline once at startup. Swap the artifact, restart uvicorn.
  2. Containerize and confirm parity FROM python:3.11.15-slim-bookworm

COPY --from=ghcr.io/astral-sh/uv:0.10.11 /uv /uvx /bin/

ENV PATH="/code/.venv/bin:$PATH" \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy

WORKDIR /code
COPY pyproject.toml uv.lock .python-version ./
RUN uv sync --locked --no-dev

COPY model.py predict.py feature_defaults.json model_metadata.json pipeline.bin ./

EXPOSE 9696
ENTRYPOINT ["uvicorn", "predict:app", "--host", "0.0.0.0", "--port", "9696"]
docker build -t zoomcamp-model:2026-hw5 .
docker run --rm -p 9696:9696 zoomcamp-model:2026-hw5
python q6_test.py

{'conversion_probability': 0.769799, 'conversion': True}

Same inference locally and in the container. If yours differ, the build context has a different artifact or lockfile. Free port 9696 before running (stop local uvicorn first).

What stuck with me
Serialize the whole pipeline, not just the model, so preprocessing travels with it. And verify before you debug. Most of my "model bugs" were environment bugs.

Full code, notebook and FAQ are here: https://github.com/marcos-Nieves-24/machine-learning-zoomcamp

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev •

A deployment checklist for frozen models should include the model artifact hash, preprocessing version, and a single known input/output fixture. Those three checks catch many cases where the container is healthy but serving a different model contract.