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/.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' 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/ 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"] 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) 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/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 f641a14..52a4ce8 100644 --- a/app/model.py +++ b/app/model.py @@ -1,11 +1,29 @@ +"""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 predict(features: list) -> int: - X = np.array(features).reshape(1, -1) - return int(model.predict(X)[0]) + +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[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/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 +} 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/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 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()