From 8af2e3a88b71897fb3c3ac2c53f582a00a0673c3 Mon Sep 17 00:00:00 2001 From: Pierre-Henry Soria Date: Sat, 12 Sep 2026 23:19:31 +1000 Subject: [PATCH 1/7] Pin runtime versions to reproduce model artifacts --- requirements.txt | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/requirements.txt b/requirements.txt index 5500a21..f71e144 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,21 @@ - -fastapi -uvicorn -joblib -scikit-learn -numpy -pydantic +# Validated together on Python 3.12; retrain artifacts after dependency updates. +annotated-doc==0.0.5 +annotated-types==0.8.0 +anyio==4.15.1 +click==8.5.0 +cloudpickle==3.1.2 +fastapi==0.141.1 +h11==0.16.0 +idna==3.19 +joblib==1.6.0 +narwhals==2.26.0 +numpy==2.5.3 +pydantic==2.13.5 +pydantic-core==2.46.5 +scikit-learn==1.9.1 +scipy==1.18.1 +starlette==1.6.0 +threadpoolctl==3.6.0 +typing-extensions==4.16.0 +typing-inspection==0.4.4 +uvicorn==0.52.4 From 6c40d6b41356c061ce2677903634db853cf4bad6 Mon Sep 17 00:00:00 2001 From: Pierre-Henry Soria Date: Sat, 12 Sep 2026 23:19:32 +1000 Subject: [PATCH 2/7] Provide reproducible training for fresh clones --- app/config.py | 4 +++ app/model.py | 26 +++++++++++++---- app/train.py | 37 +++++++++++++++++++++++ notebooks/train_model.ipynb | 58 +++++++++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 app/config.py create mode 100644 app/train.py diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..988869e --- /dev/null +++ b/app/config.py @@ -0,0 +1,4 @@ +from pathlib import Path + +MODEL_PATH = Path(__file__).resolve().parent.parent / "models" / "logistic_model.joblib" +FEATURE_COUNT = 4 diff --git a/app/model.py b/app/model.py index f641a14..e376e56 100644 --- a/app/model.py +++ b/app/model.py @@ -1,11 +1,27 @@ +"""Load only the trusted artifact generated locally by app.train.""" import joblib import numpy as np -from pathlib import Path -model_path = Path(__file__).parent.parent / "models" / "logistic_model.joblib" -model = joblib.load(model_path) +from app.config import FEATURE_COUNT, MODEL_PATH + + +def load_model(): + if not MODEL_PATH.is_file(): + raise RuntimeError( + "Model missing. Run `python -m app.train` before starting the API." + ) + loaded = joblib.load(MODEL_PATH) + if getattr(loaded, "n_features_in_", None) != FEATURE_COUNT: + raise RuntimeError( + "Incompatible model. Regenerate it with `python -m app.train`." + ) + return loaded + + +model = load_model() + def predict(features: list) -> int: - X = np.array(features).reshape(1, -1) - return int(model.predict(X)[0]) + values = np.asarray(features).reshape(1, -1) + return int(model.predict(values)[0]) diff --git a/app/train.py b/app/train.py new file mode 100644 index 0000000..ff9b198 --- /dev/null +++ b/app/train.py @@ -0,0 +1,37 @@ +"""Reproduce the small, offline Iris classification demo.""" + +import json +from pathlib import Path + +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 make_pipeline +from sklearn.preprocessing import StandardScaler + +from app.config import MODEL_PATH + + +def train_model(output_path: Path = MODEL_PATH) -> dict[str, float | int]: + features, labels = load_iris(return_X_y=True) + train_x, test_x, train_y, test_y = train_test_split( + features, labels, test_size=0.2, random_state=42, stratify=labels + ) + model = make_pipeline( + StandardScaler(), LogisticRegression(max_iter=300, random_state=42) + ) + model.fit(train_x, train_y) + accuracy = float(accuracy_score(test_y, model.predict(test_x))) + output_path.parent.mkdir(parents=True, exist_ok=True) + joblib.dump(model, output_path) + return { + "training_rows": len(train_y), + "test_rows": len(test_y), + "test_accuracy": accuracy, + } + + +if __name__ == "__main__": + print(json.dumps(train_model(), indent=2)) diff --git a/notebooks/train_model.ipynb b/notebooks/train_model.ipynb index e69de29..9a36ff2 100644 --- a/notebooks/train_model.ipynb +++ b/notebooks/train_model.ipynb @@ -0,0 +1,58 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "iris-demo", + "metadata": {}, + "source": [ + "# Offline Iris logistic regression demo\n", + "The API expects sepal length, sepal width, petal length and petal width in centimetres.\n", + "This small teaching dataset demonstrates the pipeline; held-out accuracy is not production evidence." + ] + }, + { + "cell_type": "code", + "id": "train-model", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "import sys\n", + "\n", + "project_root = Path.cwd()\n", + "if project_root.name == \"notebooks\":\n", + " project_root = project_root.parent\n", + "sys.path.insert(0, str(project_root))\n", + "\n", + "from app.train import train_model\n", + "\n", + "metrics = train_model()\n", + "metrics" + ] + }, + { + "cell_type": "markdown", + "id": "run-api", + "metadata": {}, + "source": [ + "The training split contains 120 examples; 30 held-out examples are used only for evaluation.\n", + "The exported artifact contains the scaler fitted on training data and the logistic classifier.\n", + "Run `uvicorn app.main:app --reload` from the repository root to serve it." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 738519ae98d9156b187710695cbbe39dd15fff3e Mon Sep 17 00:00:00 2001 From: Pierre-Henry Soria Date: Sat, 12 Sep 2026 23:19:32 +1000 Subject: [PATCH 3/7] Reject malformed prediction requests with JSON errors --- app/main.py | 23 +++++++-- app/model.py | 8 +-- app/schemas.py | 15 ++++-- requirements-dev.txt | 4 ++ tests/test_pipeline.py | 113 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 requirements-dev.txt create mode 100644 tests/test_pipeline.py diff --git a/app/main.py b/app/main.py index 72a09cd..b4d0e96 100644 --- a/app/main.py +++ b/app/main.py @@ -1,11 +1,24 @@ +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse -from fastapi import FastAPI from app.model import predict from app.schemas import PredictRequest, PredictResponse -app = FastAPI() +app = FastAPI(title="Iris logistic regression demo") + + +@app.exception_handler(RequestValidationError) +async def invalid_request( + _request: Request, exc: RequestValidationError +) -> JSONResponse: + # Raw invalid values can contain NaN/Infinity, which cannot be encoded as JSON. + details = [ + {key: error[key] for key in ("loc", "msg", "type")} for error in exc.errors() + ] + return JSONResponse(status_code=422, content={"detail": details}) + @app.post("/predict", response_model=PredictResponse) -def predict_endpoint(req: PredictRequest): - prediction = predict(req.features) - return PredictResponse(prediction=prediction) +def predict_endpoint(req: PredictRequest) -> PredictResponse: + return PredictResponse(prediction=predict(req.features)) diff --git a/app/model.py b/app/model.py index e376e56..52a4ce8 100644 --- a/app/model.py +++ b/app/model.py @@ -22,6 +22,8 @@ def load_model(): model = load_model() -def predict(features: list) -> int: - values = np.asarray(features).reshape(1, -1) - return int(model.predict(values)[0]) +def predict(features: list[float]) -> int: + values = np.asarray(features, dtype=float) + if values.shape != (FEATURE_COUNT,) or not np.isfinite(values).all(): + raise ValueError("Expected four finite feature values.") + return int(model.predict(values.reshape(1, -1))[0]) diff --git a/app/schemas.py b/app/schemas.py index 3a67ad2..8fca323 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -1,9 +1,18 @@ +from typing import Annotated + +from pydantic import BaseModel, ConfigDict, Field + +from app.config import FEATURE_COUNT + +FiniteFeature = Annotated[float, Field(strict=True, allow_inf_nan=False)] -from pydantic import BaseModel -from typing import List class PredictRequest(BaseModel): - features: List[float] + model_config = ConfigDict(extra="forbid") + features: list[FiniteFeature] = Field( + min_length=FEATURE_COUNT, max_length=FEATURE_COUNT + ) + class PredictResponse(BaseModel): prediction: int diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..a6f6b37 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +certifi==2026.7.22 +httpcore==1.0.9 +httpx==0.28.1 diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py new file mode 100644 index 0000000..18e5d46 --- /dev/null +++ b/tests/test_pipeline.py @@ -0,0 +1,113 @@ +"""Exercise training and real HTTP validation using an isolated model artifact.""" + +import importlib +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import joblib +from fastapi.testclient import TestClient +from pydantic import ValidationError + +from app import config +from app.schemas import PredictRequest +from app.train import train_model + + +class PipelineTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.directory = tempfile.TemporaryDirectory() + cls.addClassCleanup(cls.directory.cleanup) + cls.path = Path(cls.directory.name) / "models" / "logistic_model.joblib" + cls.metrics = train_model(cls.path) + cls.path_patch = patch.object(config, "MODEL_PATH", cls.path) + cls.path_patch.start() + cls.addClassCleanup(cls.path_patch.stop) + cls.model = importlib.import_module("app.model") + cls.api = importlib.import_module("app.main") + cls.client = TestClient(cls.api.app) + cls.addClassCleanup(cls.client.close) + + def test_training_keeps_a_separate_holdout(self): + self.assertEqual(self.metrics["training_rows"], 120) + self.assertEqual(self.metrics["test_rows"], 30) + self.assertTrue(0 <= self.metrics["test_accuracy"] <= 1) + self.assertEqual(joblib.load(self.path).n_features_in_, 4) + + def test_real_prediction_matches_saved_model(self): + features = [5.1, 3.5, 1.4, 0.2] + response = self.client.post("/predict", json={"features": features}) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + {"prediction": int(joblib.load(self.path).predict([features])[0])}, + ) + + def test_bad_request_shapes_return_422(self): + for body in [ + {}, + {"features": []}, + {"features": [1, 2, 3]}, + {"features": [1] * 5}, + {"features": [1, 2, 3, "4"]}, + {"features": [1, 2, 3, True]}, + {"features": [1, 2, 3, None]}, + {"features": [1, 2, 3, [4]]}, + {"features": [1, 2, 3, 4], "unexpected": "value"}, + ]: + with self.subTest(body=body): + self.assertEqual( + self.client.post("/predict", json=body).status_code, 422 + ) + + def test_non_finite_values_are_rejected(self): + for value in [float("inf"), float("-inf"), float("nan")]: + with self.subTest(value=value): + with self.assertRaises(ValidationError): + PredictRequest(features=[1, 2, 3, value]) + with self.assertRaises(ValueError): + self.model.predict([1, 2, 3, value]) + + def test_non_finite_http_input_returns_json_422(self): + for value in ["1e999", "NaN", "Infinity", "-Infinity"]: + with self.subTest(value=value): + response = self.client.post( + "/predict", + content='{"features":[1,2,3,' + value + "]}", + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status_code, 422) + self.assertEqual(response.json()["detail"][0]["type"], "finite_number") + self.assertNotIn("input", response.json()["detail"][0]) + + def test_direct_prediction_rejects_wrong_width(self): + with self.assertRaises(ValueError): + self.model.predict([1, 2, 3]) + + def test_missing_model_explains_training_step(self): + with ( + patch.object(self.model, "MODEL_PATH", self.path.parent / "missing.joblib"), + self.assertRaisesRegex(RuntimeError, "python -m app.train"), + ): + self.model.load_model() + + def test_incompatible_model_is_rejected(self): + with ( + patch.object(self.model.joblib, "load", return_value=object()), + self.assertRaisesRegex(RuntimeError, "Incompatible model"), + ): + self.model.load_model() + + def test_openapi_documents_exact_feature_count(self): + schema = self.client.get("/openapi.json").json() + features = schema["components"]["schemas"]["PredictRequest"]["properties"][ + "features" + ] + self.assertEqual(features["minItems"], 4) + self.assertEqual(features["maxItems"], 4) + + +if __name__ == "__main__": + unittest.main() From c277deaa46fde18c86ea2e42c5d2e1e789547ede Mon Sep 17 00:00:00 2001 From: Pierre-Henry Soria Date: Sat, 12 Sep 2026 23:19:32 +1000 Subject: [PATCH 4/7] Train the container artifact so Docker starts from a clone --- .dockerignore | 7 +++++++ Dockerfile | 11 +++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6766f8d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.env +.env.* +.venv +__pycache__ +*.pyc +models diff --git a/Dockerfile b/Dockerfile index 706c010..b65fcf7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,12 @@ +FROM python:3.12-slim -FROM python:3.11-slim - +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 WORKDIR /app -COPY . . - +COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt +COPY app/ ./app/ +RUN python -m app.train +USER 10001:10001 +EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] From 7ed121d4ad8b3b34e93463ddbc267bd0658f4f30 Mon Sep 17 00:00:00 2001 From: Pierre-Henry Soria Date: Sat, 12 Sep 2026 23:19:32 +1000 Subject: [PATCH 5/7] Run pipeline checks to detect broken examples --- .github/workflows/ci.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b833cd9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: Pipeline checks +on: [push, pull_request] +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + cache: pip + - run: python -m pip install -r requirements-dev.txt + - run: python -m pip check + - run: python -m unittest discover -s tests -v + - run: python -m app.train + - run: python -c 'from app.main import app; assert "/predict" in app.openapi()["paths"]' + - run: docker build --tag logistic-api:ci . + - run: docker run --rm --entrypoint python logistic-api:ci -c 'from app.model import predict; assert predict([5.1, 3.5, 1.4, 0.2]) == 0' From 14eb4139433e95f013a800341541bf76889ebd20 Mon Sep 17 00:00:00 2001 From: Pierre-Henry Soria Date: Sat, 12 Sep 2026 23:19:32 +1000 Subject: [PATCH 6/7] Ignore local virtual environments to keep dependencies out of Git --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index b85394f..9347a1b 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,6 @@ Thumbs.db # Environment variables .env .env.* + +# Local development environment +.venv/ From 8fb644404585e50c1506a2d2d08f3f30422ebec0 Mon Sep 17 00:00:00 2001 From: Pierre-Henry Soria Date: Sat, 12 Sep 2026 23:19:33 +1000 Subject: [PATCH 7/7] Document runnable training with explicit demo limits --- README.md | 69 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index db6aada..e3309cd 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,62 @@ # Logistic Regression ML Pipeline with FastAPI ๐Ÿš€ -A clean and modern ML microservice for logistic regression (GLM) using FastAPI. This project was built with scalability, explainability, and deployability in mind. Enjoy! +A small, reproducible Iris classification demo: train a logistic regression model, evaluate a held-out split, and serve predictions through FastAPI. The original empty notebook is now executable, and a fresh clone can generate its own model. [![From Model to Production: Logistic Regression with FastAPI and Docker](https://i1.ytimg.com/vi/2kgBbyAYDTA/sddefault.jpg)](https://youtu.be/2kgBbyAYDTA "From Model to Production: Logistic Regression with FastAPI and Docker") -[โ†’ Click here to watch on YouTube](https://youtu.be/2kgBbyAYDTA) +## Run locally +Use Python 3.12 and a virtual environment: -## ๐Ÿ”ง Project Structure - +```sh +python3.12 -m venv .venv +source .venv/bin/activate +python -m pip install -r requirements-dev.txt +python -m unittest discover -s tests -v +python -m app.train +uvicorn app.main:app --reload ``` -logistic-regression-fastapi/ -โ”‚ -โ”œโ”€โ”€ app/ -โ”‚ โ”œโ”€โ”€ main.py # FastAPI app entrypoint -โ”‚ โ”œโ”€โ”€ model.py # Model loading and prediction logic -โ”‚ โ””โ”€โ”€ schemas.py # Pydantic request/response models -โ”‚ -โ”œโ”€โ”€ models/ -โ”‚ โ””โ”€โ”€ logistic_model.joblib # Pretrained logistic regression model -โ”‚ -โ”œโ”€โ”€ notebooks/ -โ”‚ โ””โ”€โ”€ train_model.ipynb # Jupyter notebook for training and evaluation -โ”‚ -โ”œโ”€โ”€ Dockerfile # Containerisation setup -โ””โ”€โ”€ requirements.txt # Python dependencies + +Training uses the Iris dataset bundled with scikit-learn. It needs no dataset download, credentials or external service. The CLI prints the measured accuracy on 30 held-out rows after fitting on 120 rows. The scaler is fitted only on the training split. The notebook in `notebooks/train_model.ipynb` calls the same training function; use a Jupyter kernel with the project dependencies installed. + +Open [interactive API documentation](http://127.0.0.1:8000/docs), or submit: + +```sh +curl http://127.0.0.1:8000/predict \ + -H 'Content-Type: application/json' \ + -d '{"features": [5.1, 3.5, 1.4, 0.2]}' ``` -## ๐Ÿงช Training +The four values are **sepal length, sepal width, petal length, petal width**, in centimetres. Responses contain an integer `prediction`: `0` = setosa, `1` = versicolor, `2` = virginica. Missing or extra features, strings, booleans, nulls, nested values and non-finite numbers are rejected with HTTP 422. -The `notebooks/train_model.ipynb` trains a simple logistic regression classifier and exports the model. +The API deliberately fails at startup with a training instruction if the artifact is missing. It also rejects an incompatible feature count. Generate the artifact with this project's training code; joblib files can execute code when loaded, so do not substitute downloaded or untrusted model files. Retrain after changing pinned dependencies. -## โ–ถ๏ธ Run API +## Project structure -```bash -uvicorn app.main:app --reload -``` +- `app/train.py`: deterministic train/test split, scaling, training, evaluation and export +- `app/config.py`: model path and feature count shared by training/inference +- `app/model.py`: local artifact loading and prediction +- `app/schemas.py`: request/response contracts +- `app/main.py`: `POST /predict` +- `models/logistic_model.joblib`: generated artifact, intentionally ignored by Git +- `notebooks/train_model.ipynb`: executable training walkthrough +- `tests/test_pipeline.py`: actual training, HTTP validation and model-failure checks +- `requirements.txt`: runtime dependency versions validated together on Python 3.12 +- `requirements-dev.txt`: additional HTTP test dependencies -## ๐Ÿณ Docker +## Docker -```bash +```sh docker build -t logistic-api . -docker run -d -p 8000:8000 logistic-api +docker run --rm -p 127.0.0.1:8000:8000 logistic-api ``` +The image installs pinned runtime dependencies, trains its own demo artifact, and runs the API as a non-root user. Only the app and requirements are copied into the image. CI runs the Python tests and Docker build/smoke check; it does not publish an image or deploy a service. + +## Scope + +This is a teaching pipeline, not a validated production classifier. The fixed Iris split provides a reproducible experiment, not evidence of performance on other data. There is no authentication, rate limiting, production monitoring, model registry or deployment configuration. Keep the example local until those requirements are defined and implemented. + ## โœจ Author [![Pierre-Henry Soria](https://avatars0.githubusercontent.com/u/1325411?s=200)](https://ph7.me)