Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.git
.env
.env.*
.venv
__pycache__
*.pyc
models
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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'
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,6 @@ Thumbs.db
# Environment variables
.env
.env.*

# Local development environment
.venv/
11 changes: 7 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
69 changes: 41 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
4 changes: 4 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from pathlib import Path

MODEL_PATH = Path(__file__).resolve().parent.parent / "models" / "logistic_model.joblib"
FEATURE_COUNT = 4
23 changes: 18 additions & 5 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -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))
30 changes: 24 additions & 6 deletions app/model.py
Original file line number Diff line number Diff line change
@@ -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])
15 changes: 12 additions & 3 deletions app/schemas.py
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions app/train.py
Original file line number Diff line number Diff line change
@@ -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))
58 changes: 58 additions & 0 deletions notebooks/train_model.ipynb
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 4 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-r requirements.txt
certifi==2026.7.22
httpcore==1.0.9
httpx==0.28.1
28 changes: 21 additions & 7 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
Loading