Spaces:
Sleeping
Sleeping
Deploy build-99deee4
Browse files- .dockerignore +42 -0
- .github/workflows/deploy.yml +135 -0
- .gitignore +56 -0
- ASSIGNMENT_TARGET.md +37 -0
- Dockerfile +42 -0
- POSTER_CONTENT.md +40 -0
- README.md +205 -5
- backend/main.py +675 -0
- data/.gitkeep +0 -0
- docker-compose.yml +30 -0
- frontend/app.py +235 -0
- model_utils.py +23 -0
- models/.gitkeep +0 -0
- models/chestmnist_mobilenetv3/README.md +47 -0
- models/chestmnist_mobilenetv3/baseline_stats.json +15 -0
- models/chestmnist_mobilenetv3/onnx_export_report.json +11 -0
- models/chestmnist_mobilenetv3/training_metrics.json +172 -0
- requirements.txt +19 -0
- scripts/train_chestmnist.py +384 -0
- scripts/upload_to_hf.py +143 -0
.dockerignore
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Git metadata
|
| 2 |
+
.git/
|
| 3 |
+
.gitignore
|
| 4 |
+
|
| 5 |
+
# Local environments and caches
|
| 6 |
+
.codex
|
| 7 |
+
.venv/
|
| 8 |
+
venv/
|
| 9 |
+
env/
|
| 10 |
+
.codex/
|
| 11 |
+
.cache/
|
| 12 |
+
.huggingface-spaces/
|
| 13 |
+
data/hf_cache/
|
| 14 |
+
__pycache__/
|
| 15 |
+
**/__pycache__/
|
| 16 |
+
*.py[cod]
|
| 17 |
+
|
| 18 |
+
# Local docs and screenshots not needed in the image build context
|
| 19 |
+
Screenshot_*.png
|
| 20 |
+
|
| 21 |
+
# Development-only files
|
| 22 |
+
docker-compose.yml
|
| 23 |
+
|
| 24 |
+
# Large generated artifacts that should not bloat the Docker build context
|
| 25 |
+
data/*.npz
|
| 26 |
+
models/realworld_densenet121/
|
| 27 |
+
models/realworld_efficientnet_b0/
|
| 28 |
+
models/plots/
|
| 29 |
+
models/*.csv
|
| 30 |
+
|
| 31 |
+
# Keep core project files available
|
| 32 |
+
!backend/
|
| 33 |
+
!frontend/
|
| 34 |
+
!scripts/
|
| 35 |
+
!models/
|
| 36 |
+
!data/
|
| 37 |
+
!docs/
|
| 38 |
+
!README.md
|
| 39 |
+
!requirements.txt
|
| 40 |
+
!Dockerfile
|
| 41 |
+
!.python-version
|
| 42 |
+
!.github/
|
.github/workflows/deploy.yml
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: CI / Lint + Deploy to Hugging Face Spaces
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: ["main", "master"]
|
| 6 |
+
pull_request:
|
| 7 |
+
branches: ["main", "master"]
|
| 8 |
+
workflow_dispatch:
|
| 9 |
+
|
| 10 |
+
jobs:
|
| 11 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 12 |
+
# 1. CI β syntax + import smoke test
|
| 13 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 14 |
+
ci:
|
| 15 |
+
name: Syntax & Import Check
|
| 16 |
+
runs-on: ubuntu-latest
|
| 17 |
+
steps:
|
| 18 |
+
- uses: actions/checkout@v4
|
| 19 |
+
|
| 20 |
+
- name: Set up Python 3.11
|
| 21 |
+
uses: actions/setup-python@v5
|
| 22 |
+
with:
|
| 23 |
+
python-version: "3.11"
|
| 24 |
+
|
| 25 |
+
- name: Install dependencies
|
| 26 |
+
run: |
|
| 27 |
+
python -m pip install --upgrade pip
|
| 28 |
+
# Install CPU-only torch to keep CI fast
|
| 29 |
+
pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
|
| 30 |
+
pip install -r requirements.txt
|
| 31 |
+
|
| 32 |
+
- name: Compile-check all Python modules
|
| 33 |
+
run: |
|
| 34 |
+
python -m py_compile \
|
| 35 |
+
scripts/train_chestmnist.py \
|
| 36 |
+
backend/main.py \
|
| 37 |
+
frontend/app.py \
|
| 38 |
+
model_utils.py
|
| 39 |
+
|
| 40 |
+
- name: Import smoke test (no model files needed)
|
| 41 |
+
run: |
|
| 42 |
+
python - <<'PY'
|
| 43 |
+
import ast, pathlib
|
| 44 |
+
for f in ["scripts/train_chestmnist.py", "backend/main.py", "frontend/app.py", "model_utils.py"]:
|
| 45 |
+
ast.parse(pathlib.Path(f).read_text())
|
| 46 |
+
print(f" {f} OK")
|
| 47 |
+
print("All imports parseable.")
|
| 48 |
+
PY
|
| 49 |
+
|
| 50 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 51 |
+
# 2. Deploy β push code to Hugging Face Space (on main branch only)
|
| 52 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 53 |
+
deploy:
|
| 54 |
+
name: Deploy to Hugging Face Spaces
|
| 55 |
+
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') && github.event_name == 'push'
|
| 56 |
+
needs: ci
|
| 57 |
+
runs-on: ubuntu-latest
|
| 58 |
+
steps:
|
| 59 |
+
- uses: actions/checkout@v4
|
| 60 |
+
|
| 61 |
+
- name: Set up Python 3.11
|
| 62 |
+
uses: actions/setup-python@v5
|
| 63 |
+
with:
|
| 64 |
+
python-version: "3.11"
|
| 65 |
+
|
| 66 |
+
- name: Install huggingface_hub
|
| 67 |
+
run: pip install huggingface_hub
|
| 68 |
+
|
| 69 |
+
- name: Push Space to Hugging Face Hub
|
| 70 |
+
env:
|
| 71 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 72 |
+
HF_SPACE_REPO: ${{ secrets.HF_SPACE_REPO }}
|
| 73 |
+
GITHUB_SHA: ${{ github.sha }}
|
| 74 |
+
run: |
|
| 75 |
+
python - <<'PY'
|
| 76 |
+
import os
|
| 77 |
+
from huggingface_hub import HfApi
|
| 78 |
+
|
| 79 |
+
token = os.environ["HF_TOKEN"]
|
| 80 |
+
space_repo = os.environ["HF_SPACE_REPO"] # e.g. "your-username/pneumoops"
|
| 81 |
+
sha_tag = f"build-{os.environ['GITHUB_SHA'][:7]}"
|
| 82 |
+
|
| 83 |
+
api = HfApi(token=token)
|
| 84 |
+
api.create_repo(repo_id=space_repo, repo_type="space", space_sdk="docker", exist_ok=True)
|
| 85 |
+
|
| 86 |
+
api.upload_folder(
|
| 87 |
+
repo_id=space_repo,
|
| 88 |
+
repo_type="space",
|
| 89 |
+
folder_path=".",
|
| 90 |
+
commit_message=f"Deploy {sha_tag}",
|
| 91 |
+
ignore_patterns=[
|
| 92 |
+
".git/*", "__pycache__/*", "*.pyc",
|
| 93 |
+
".venv/*", "venv/*",
|
| 94 |
+
"data/hf_cache/*",
|
| 95 |
+
],
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
try:
|
| 99 |
+
api.create_tag(repo_id=space_repo, repo_type="space", tag=sha_tag)
|
| 100 |
+
except Exception:
|
| 101 |
+
pass # tag already exists
|
| 102 |
+
|
| 103 |
+
print(f"Deployed {sha_tag} β https://huggingface.co/spaces/{space_repo}")
|
| 104 |
+
PY
|
| 105 |
+
|
| 106 |
+
- name: Upload model artifacts to HF Model Hub
|
| 107 |
+
# Only runs if HF_MODEL_REPO secret is set
|
| 108 |
+
if: ${{ env.HF_MODEL_REPO != '' }}
|
| 109 |
+
env:
|
| 110 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 111 |
+
HF_MODEL_REPO: ${{ secrets.HF_MODEL_REPO }}
|
| 112 |
+
GITHUB_SHA: ${{ github.sha }}
|
| 113 |
+
run: |
|
| 114 |
+
python - <<'PY'
|
| 115 |
+
import os
|
| 116 |
+
from huggingface_hub import HfApi
|
| 117 |
+
from pathlib import Path
|
| 118 |
+
|
| 119 |
+
token = os.environ["HF_TOKEN"]
|
| 120 |
+
model_repo = os.environ["HF_MODEL_REPO"]
|
| 121 |
+
sha_tag = f"build-{os.environ['GITHUB_SHA'][:7]}"
|
| 122 |
+
|
| 123 |
+
api = HfApi(token=token)
|
| 124 |
+
api.create_repo(repo_id=model_repo, repo_type="model", exist_ok=True)
|
| 125 |
+
|
| 126 |
+
model_dir = Path("models/chestmnist_mobilenetv3")
|
| 127 |
+
if model_dir.exists():
|
| 128 |
+
api.upload_folder(
|
| 129 |
+
repo_id=model_repo,
|
| 130 |
+
repo_type="model",
|
| 131 |
+
folder_path=str(model_dir),
|
| 132 |
+
commit_message=f"Upload model {sha_tag}",
|
| 133 |
+
)
|
| 134 |
+
print(f"Model artifacts uploaded β https://huggingface.co/models/{model_repo}")
|
| 135 |
+
PY
|
.gitignore
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*.so
|
| 5 |
+
.pytest_cache/
|
| 6 |
+
.mypy_cache/
|
| 7 |
+
.ruff_cache/
|
| 8 |
+
.coverage
|
| 9 |
+
htmlcov/
|
| 10 |
+
dist/
|
| 11 |
+
build/
|
| 12 |
+
*.egg-info/
|
| 13 |
+
|
| 14 |
+
# Virtual environments
|
| 15 |
+
.venv/
|
| 16 |
+
venv/
|
| 17 |
+
env/
|
| 18 |
+
|
| 19 |
+
# Editor / OS
|
| 20 |
+
.DS_Store
|
| 21 |
+
.idea/
|
| 22 |
+
.vscode/
|
| 23 |
+
.codex
|
| 24 |
+
.codex/
|
| 25 |
+
|
| 26 |
+
# Secrets
|
| 27 |
+
.env
|
| 28 |
+
.env.*
|
| 29 |
+
*.local
|
| 30 |
+
|
| 31 |
+
# Logs and temp
|
| 32 |
+
*.log
|
| 33 |
+
*.tmp
|
| 34 |
+
|
| 35 |
+
# Dataset cache (auto-downloaded β not committed)
|
| 36 |
+
data/hf_cache/
|
| 37 |
+
data/*.npz
|
| 38 |
+
data/*.json
|
| 39 |
+
.cache/
|
| 40 |
+
|
| 41 |
+
# Screenshots
|
| 42 |
+
Screenshot_*.png
|
| 43 |
+
|
| 44 |
+
# βββ Model artifacts (large binaries β stored on HF Hub, not in Git) βββββββββ
|
| 45 |
+
models/*.pth
|
| 46 |
+
models/*.onnx
|
| 47 |
+
models/*.json
|
| 48 |
+
models/*.csv
|
| 49 |
+
models/chestmnist_mobilenetv3/*.pth
|
| 50 |
+
models/chestmnist_mobilenetv3/*.onnx
|
| 51 |
+
models/chestmnist_mobilenetv3/*.npz
|
| 52 |
+
models/chestmnist_mobilenetv3/plots/
|
| 53 |
+
|
| 54 |
+
# Keep directory structure in Git
|
| 55 |
+
!models/.gitkeep
|
| 56 |
+
!data/.gitkeep
|
ASSIGNMENT_TARGET.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PneumoOps Assignment Target
|
| 2 |
+
|
| 3 |
+
## Title
|
| 4 |
+
PneumoOps: Continuous MLOps Pipeline with A/B Testing & Drift Monitoring for Multi-Class Thoracic Diagnostics
|
| 5 |
+
|
| 6 |
+
## Project Task
|
| 7 |
+
Build an advanced, continuous ML/DL Ops pipeline for 14-class chest X-ray classification. The system will deploy containerized models via Docker on Hugging Face Spaces, featuring live A/B testing between a standard and an optimized (ONNX) model. With a strict focus on real-world maintenance, the backend includes real-time inference latency tracking and a data drift monitoring system that triggers automated alerts for model retraining when production data shifts.
|
| 8 |
+
|
| 9 |
+
## Idea Explanation in Detail
|
| 10 |
+
This project upgrades a multi-label classification task into a complete, production-ready MLOps system, addressing real-world "Lab-to-Production" challenges and ongoing maintenance. In a real clinical setting, models degrade over time as scanner hardware changes or patient demographics shift. Instead of deploying a single static model, we upload two versions to the Hugging Face Hub: a baseline PyTorch model and an ONNX-optimized model.
|
| 11 |
+
The Dockerized FastAPI backend performs A/B testing by randomly routing user uploads to either model, allowing us to compare real-world inference latency and performance. Crucially for deployment maintenance, the backend includes a Data Drift Monitor that compares the incoming image's statistical distribution against the training dataset. If an out-of-distribution image is uploaded, the system flags it as "Drift Detected," simulating the exact trigger required for automated retraining cycles in enterprise MLOps.
|
| 12 |
+
|
| 13 |
+
## Datasets and Models to be used
|
| 14 |
+
|
| 15 |
+
- **Dataset**: ChestMNIST (14-class multi-label dataset containing Atelectasis, Cardiomegaly, Consolidation, Edema, Effusion, Emphysema, Fibrosis, Hernia, Infiltration, Mass, Nodule, Pleural Thickening, Pneumonia, Pneumothorax). It is lightweight enough for rapid CI/CD iterations without wasting hours on training.
|
| 16 |
+
- **Model A (Baseline)**: MobileNetV3-small (Standard PyTorch format adapted for 14 output nodes).
|
| 17 |
+
- **Model B (Optimized)**: MobileNetV3-small (Converted to ONNX format for faster client-level inference).
|
| 18 |
+
|
| 19 |
+
## Task 1: The Gradio Web UI
|
| 20 |
+
The UI needs to have an image upload component for a chest X-ray. When the user clicks submit, the app should send the image via a `requests.post` call to a local FastAPI backend (assume the URL is `http://127.0.0.1:8000/predict`).
|
| 21 |
+
|
| 22 |
+
The UI needs to display the following output fields clearly:
|
| 23 |
+
- **Predictions**: A bar chart or clean list showing the top 3 highest-probability conditions out of the 14 thoracic disease classes, e.g., Cardiomegaly, Effusion, Mass
|
| 24 |
+
- **Model Used**: This will show either 'Baseline PyTorch' or 'Optimized ONNX' for our A/B testing
|
| 25 |
+
- **Inference Latency**: (ms)
|
| 26 |
+
- **Data Drift Alert**: Will show 'Normal' or 'Drift Detected' with a warning color to simulate production maintenance alerts
|
| 27 |
+
|
| 28 |
+
*Please make the UI look clean, professional, and add a nice title and description at the top explaining that this is an MLOps A/B Testing Pipeline focused on real-world deployment and reliability.*
|
| 29 |
+
|
| 30 |
+
## Task 2: The Poster Text
|
| 31 |
+
Draft the text content for an academic project poster. Title: PneumoOps: Continuous MLOps Pipeline for 14-Class Thoracic Diagnostics. Generate professional, concise text for the following sections:
|
| 32 |
+
- **Project Task**
|
| 33 |
+
- **Focus on Deployment & Maintenance**
|
| 34 |
+
- **Libraries / APIs**
|
| 35 |
+
- **Deployment Architecture**
|
| 36 |
+
- **Datasets and Models**
|
| 37 |
+
*Keep the text punchy and readable for a visual poster presentation.*
|
Dockerfile
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Apply latest system cybersecurity patches and install minimal dependencies
|
| 6 |
+
RUN apt-get update && apt-get upgrade -y && \
|
| 7 |
+
apt-get install -y --no-install-recommends \
|
| 8 |
+
curl \
|
| 9 |
+
libgomp1 \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
# Security: Create an unprivileged user for executing the application
|
| 13 |
+
RUN groupadd -r pneumoops && useradd -r -g pneumoops appuser
|
| 14 |
+
|
| 15 |
+
COPY requirements.txt .
|
| 16 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 17 |
+
pip install --no-cache-dir -r requirements.txt
|
| 18 |
+
|
| 19 |
+
# Copy application code and ensure secure permissions
|
| 20 |
+
COPY --chown=appuser:pneumoops backend ./backend
|
| 21 |
+
COPY --chown=appuser:pneumoops frontend ./frontend
|
| 22 |
+
COPY --chown=appuser:pneumoops scripts ./scripts
|
| 23 |
+
COPY --chown=appuser:pneumoops models ./models
|
| 24 |
+
COPY --chown=appuser:pneumoops data ./data
|
| 25 |
+
COPY --chown=appuser:pneumoops model_utils.py ./model_utils.py
|
| 26 |
+
COPY --chown=appuser:pneumoops README.md ./README.md
|
| 27 |
+
|
| 28 |
+
ENV PYTHONPATH=/app
|
| 29 |
+
ENV PORT=7860
|
| 30 |
+
ENV MPLCONFIGDIR=/tmp/matplotlib
|
| 31 |
+
# Default to the ChestMNIST + MobileNetV3-small profile (assignment target)
|
| 32 |
+
ENV PNEUMOOPS_PROFILE=chestmnist
|
| 33 |
+
|
| 34 |
+
EXPOSE 7860
|
| 35 |
+
|
| 36 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
| 37 |
+
CMD curl -f http://127.0.0.1:7860/health || exit 1
|
| 38 |
+
|
| 39 |
+
# Security: Drop root privileges before running the application
|
| 40 |
+
USER appuser
|
| 41 |
+
|
| 42 |
+
CMD ["python", "-m", "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
POSTER_CONTENT.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# π« PneumoOps: Continuous MLOps Pipeline for 14-Class Thoracic Diagnostics
|
| 2 |
+
|
| 3 |
+
---
|
| 4 |
+
|
| 5 |
+
### π― Project Task
|
| 6 |
+
An advanced, continuous ML/DL Ops pipeline designed for multi-label chest X-ray classification. The system containerizes models via **Docker** and deploys them to **Hugging Face Spaces**. It features live **A/B testing** between standard and optimized (ONNX) deployments, paired with robust **data drift monitoring** for 14 thoracic diseases, bridging the gap between lab research and continuous production.
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
### π οΈ Focus on Deployment & Maintenance
|
| 11 |
+
Traditional clinical models natively degrade over time as scanner hardware and patient demographics shift. PneumoOps addresses this "Lab-to-Production" friction through proactive maintenance:
|
| 12 |
+
* **Real-time Latency Tracking:** Enables active comparison of model serving speeds, ensuring responsiveness on varying clinical hardware profiles in production environments.
|
| 13 |
+
* **Data Drift Detection:** Continuously compares incoming clinical image distributions against the original training baseline. Discovering out-of-distribution uploads instantly triggers a `Drift Detected` alertβproviding the automated, programmatic trigger needed for model retraining cycles in true enterprise MLOps.
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
### π» Libraries / APIs
|
| 18 |
+
The pipeline is powered by a modern, full-stack open source MLOps ecosystem:
|
| 19 |
+
* **PyTorch** (Model Training & Baseline Backend)
|
| 20 |
+
* **ONNX Runtime** (Model Optimization & Accelerated Inference)
|
| 21 |
+
* **FastAPI** (High-performance API Routing & Drift Computation)
|
| 22 |
+
* **Gradio** (Interactive Maintenance Console & Dashboard)
|
| 23 |
+
* **Docker** (Environment Standardization & Containerization)
|
| 24 |
+
* **Hugging Face Hub/Spaces** (Cloud Hosting & Artifact Registry)
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
### ποΈ Deployment Architecture
|
| 29 |
+
A streamlined, automated flow from data to deployment:
|
| 30 |
+
1. **Train** β Train Baseline MobileNetV3-small & Export to ONNX.
|
| 31 |
+
2. **Push to HF Hub** β Push version-controlled weights, training metrics, and code.
|
| 32 |
+
3. **Dockerized FastAPI Router** β Routes live traffic dynamically (50/50) for rapid A/B testing and computes drift statistics on the fly.
|
| 33 |
+
4. **Gradio UI Dashboard** β Empowers clinical data teams with an interactive maintenance dashboard showing top-3 predictions, model-arm comparisons, and system data-drift alerts.
|
| 34 |
+
|
| 35 |
+
---
|
| 36 |
+
|
| 37 |
+
### π Datasets and Models
|
| 38 |
+
* **Dataset:** **ChestMNIST** β A highly efficient, 14-class multi-label dataset. Supports rapid CI/CD iteration loops while capturing complex thoracic conditions (e.g., Cardiomegaly, Pneumonia, Consolidation, Effusion, Mass).
|
| 39 |
+
* **Model A (Baseline):** **MobileNetV3-small** in standard PyTorch format, tailored for 14 output nodes.
|
| 40 |
+
* **Model B (Optimized):** **MobileNetV3-small** converted explicitly to ONNX architecture for faster execution speeds and reduced client hardware strain.
|
README.md
CHANGED
|
@@ -1,10 +1,210 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: PneumoOps
|
| 3 |
+
emoji: π«
|
| 4 |
+
colorFrom: blue
|
| 5 |
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: true
|
| 9 |
+
license: mit
|
| 10 |
+
short_description: MLOps A/B testing & drift monitoring
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# π« PneumoOps
|
| 14 |
+
|
| 15 |
+
**Continuous MLOps Pipeline with A/B Testing & Data Drift Monitoring for 14-Class Thoracic Disease Detection**
|
| 16 |
+
|
| 17 |
+
[](https://github.com/Prakhar54-byte/PneumoOps/actions)
|
| 18 |
+
[](https://www.python.org/)
|
| 19 |
+
[](#)
|
| 20 |
+
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
## What This Is
|
| 24 |
+
|
| 25 |
+
PneumoOps is a production-style MLOps system for **multi-label chest X-ray classification**. It demonstrates real-world deployment challenges:
|
| 26 |
+
|
| 27 |
+
- **A/B Testing** β every inference request is randomly routed to either *Model A (PyTorch)* or *Model B (ONNX)*, letting you measure real-world latency differences between serving backends.
|
| 28 |
+
- **Data Drift Monitoring** β statistical pixel-distribution comparison (KS-test) against the training baseline. When distribution shifts, the system flags `DRIFT_DETECTED` β the trigger for automated retraining in production.
|
| 29 |
+
- **Prometheus Observability** β request counters, latency histograms, per-disease prediction rates, and drift alert counters are all scraped at `/metrics`.
|
| 30 |
+
- **Dockerized Deployment** β the entire stack runs in containers, deployable to Hugging Face Spaces via a single `git push`.
|
| 31 |
+
|
| 32 |
+
---
|
| 33 |
+
|
| 34 |
+
## Architecture
|
| 35 |
+
|
| 36 |
+
```
|
| 37 |
+
Train (ChestMNIST + MobileNetV3-small)
|
| 38 |
+
β
|
| 39 |
+
βββββββββββ΄βββββββββββ
|
| 40 |
+
Model A (.pth) Model B (.onnx)
|
| 41 |
+
PyTorch serving ONNX Runtime serving
|
| 42 |
+
βββββββββββ¬βββββββββββ
|
| 43 |
+
β
|
| 44 |
+
βββββββββββββ΄ββββββββββββ
|
| 45 |
+
β FastAPI Backend β
|
| 46 |
+
β ββ A/B Router (60/40) β
|
| 47 |
+
β ββ Drift Monitor (KS) β
|
| 48 |
+
β ββ Prometheus /metricsβ
|
| 49 |
+
β ββ /health /history β
|
| 50 |
+
βββββββββββββ¬ββββββββββββ
|
| 51 |
+
β
|
| 52 |
+
βββββββββββ΄βββββββββββ
|
| 53 |
+
β Gradio UI β
|
| 54 |
+
β Top-3 predictions β
|
| 55 |
+
β Model arm used β
|
| 56 |
+
β Latency (ms) β
|
| 57 |
+
β Drift alert badge β
|
| 58 |
+
βββββββββββββββββββββββ
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
---
|
| 62 |
+
|
| 63 |
+
## Dataset & Model
|
| 64 |
+
|
| 65 |
+
| Property | Value |
|
| 66 |
+
|---|---|
|
| 67 |
+
| Dataset | [ChestMNIST](https://medmnist.com/) β 14-class multi-label chest X-ray |
|
| 68 |
+
| Classes | Atelectasis, Cardiomegaly, Effusion, Infiltration, Mass, Nodule, Pneumonia, Pneumothorax, Consolidation, Edema, Emphysema, Fibrosis, Pleural Thickening, Hernia |
|
| 69 |
+
| Model A | MobileNetV3-small (PyTorch `.pth`) |
|
| 70 |
+
| Model B | MobileNetV3-small (ONNX Runtime `.onnx`) |
|
| 71 |
+
| Training | 5 epochs, AdamW, BCEWithLogitsLoss, per-class threshold tuning |
|
| 72 |
+
| Macro AUROC | 0.686 (5-epoch, 5k samples β improves with full dataset) |
|
| 73 |
+
|
| 74 |
+
---
|
| 75 |
+
|
| 76 |
+
## Project Structure
|
| 77 |
+
|
| 78 |
+
```
|
| 79 |
+
pneumo_ops/
|
| 80 |
+
βββ backend/
|
| 81 |
+
β βββ main.py # FastAPI: A/B routing, drift monitor, Prometheus
|
| 82 |
+
βββ frontend/
|
| 83 |
+
β βββ app.py # Gradio UI: top-3 chart, drift badge, latency
|
| 84 |
+
βββ scripts/
|
| 85 |
+
β βββ train_chestmnist.py # Training: ChestMNIST β MobileNetV3 β ONNX export
|
| 86 |
+
βββ models/
|
| 87 |
+
β βββ chestmnist_mobilenetv3/
|
| 88 |
+
β βββ mobilenetv3_chestmnist.pth # Model A (PyTorch)
|
| 89 |
+
β βββ mobilenetv3_chestmnist.onnx # Model B (ONNX)
|
| 90 |
+
β βββ training_metrics.json
|
| 91 |
+
β βββ baseline_stats.json # Pixel stats for drift reference
|
| 92 |
+
βββ model_utils.py # CalibratedModel + temperature scaling util
|
| 93 |
+
βββ Dockerfile # Single-container build
|
| 94 |
+
βββ docker-compose.yml # backend + frontend services
|
| 95 |
+
βββ requirements.txt
|
| 96 |
+
βββ .github/workflows/
|
| 97 |
+
βββ deploy.yml # CI (lint/import check) + HF Spaces deploy
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
---
|
| 101 |
+
|
| 102 |
+
## Quick Start
|
| 103 |
+
|
| 104 |
+
### 1. Install
|
| 105 |
+
|
| 106 |
+
```bash
|
| 107 |
+
git clone https://github.com/Prakhar54-byte/PneumoOps
|
| 108 |
+
cd pneumo_ops
|
| 109 |
+
python3 -m venv .venv && source .venv/bin/activate
|
| 110 |
+
pip install -r requirements.txt
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
### 2. Train the model
|
| 114 |
+
|
| 115 |
+
```bash
|
| 116 |
+
# Quick run (5k samples, ~1 min on GPU)
|
| 117 |
+
python3 scripts/train_chestmnist.py --epochs 5 --batch-size 32 --max-train-samples 5000
|
| 118 |
+
|
| 119 |
+
# Full dataset
|
| 120 |
+
python3 scripts/train_chestmnist.py --epochs 15 --batch-size 64
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
Outputs saved to `models/chestmnist_mobilenetv3/`:
|
| 124 |
+
- `mobilenetv3_chestmnist.pth` β PyTorch checkpoint
|
| 125 |
+
- `mobilenetv3_chestmnist.onnx` β ONNX export
|
| 126 |
+
- `training_metrics.json` β AUROC, AUPRC, F1, thresholds
|
| 127 |
+
- `baseline_stats.json` β pixel reference for drift detection
|
| 128 |
+
|
| 129 |
+
### 3. Run the backend
|
| 130 |
+
|
| 131 |
+
```bash
|
| 132 |
+
PNEUMOOPS_PROFILE=chestmnist python3 -m uvicorn backend.main:app --port 7860
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
Key endpoints:
|
| 136 |
+
|
| 137 |
+
| Endpoint | Description |
|
| 138 |
+
|---|---|
|
| 139 |
+
| `POST /predict` | Run inference (A/B routed) |
|
| 140 |
+
| `GET /health` | System status + model metadata |
|
| 141 |
+
| `GET /metrics` | Prometheus scrape endpoint |
|
| 142 |
+
| `GET /history` | Last 20 requests |
|
| 143 |
+
| `GET /metrics/class-rates` | Per-class prediction rates |
|
| 144 |
+
| `GET /metrics/calibration` | AUROC / AUPRC / Brier per class |
|
| 145 |
+
|
| 146 |
+
### 4. Run the UI
|
| 147 |
+
|
| 148 |
+
```bash
|
| 149 |
+
BACKEND_PREDICT_URL=http://127.0.0.1:7860/predict python3 frontend/app.py
|
| 150 |
+
# Open http://localhost:7861
|
| 151 |
+
```
|
| 152 |
+
|
| 153 |
+
### 5. Docker (full stack)
|
| 154 |
+
|
| 155 |
+
```bash
|
| 156 |
+
docker compose up --build
|
| 157 |
+
# Backend β http://localhost:7860
|
| 158 |
+
# Frontend β http://localhost:7861
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
---
|
| 162 |
+
|
| 163 |
+
## Deployment β Hugging Face Spaces
|
| 164 |
+
|
| 165 |
+
### Manual push
|
| 166 |
+
|
| 167 |
+
```bash
|
| 168 |
+
# Add HF remote
|
| 169 |
+
git remote add space https://huggingface.co/spaces/Prakhar54-byte/PneumoOps
|
| 170 |
+
|
| 171 |
+
# Push (Spaces will build the Docker image automatically)
|
| 172 |
+
git push space main
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
### Automated (GitHub Actions)
|
| 176 |
+
|
| 177 |
+
Set these repository secrets on GitHub:
|
| 178 |
+
|
| 179 |
+
| Secret | Description |
|
| 180 |
+
|---|---|
|
| 181 |
+
| `HF_TOKEN` | Hugging Face access token (write permission) |
|
| 182 |
+
| `HF_SPACE_REPO` | e.g. `your-username/pneumoops` |
|
| 183 |
+
| `HF_MODEL_REPO` | *(optional)* e.g. `your-username/pneumoops-models` |
|
| 184 |
+
|
| 185 |
+
Every push to `main` triggers CI checks then deploys to your Space automatically.
|
| 186 |
+
|
| 187 |
+
---
|
| 188 |
+
|
| 189 |
+
## Real-World MLOps Challenges Addressed
|
| 190 |
+
|
| 191 |
+
| Challenge | Solution |
|
| 192 |
+
|---|---|
|
| 193 |
+
| Model degradation over time | Drift Monitor (KS-test on pixel distribution) |
|
| 194 |
+
| Serving latency variance | A/B routing between PyTorch and ONNX, latency tracked per arm |
|
| 195 |
+
| Class imbalance (rare diseases) | Per-class threshold tuning on val set + AUPRC tracking |
|
| 196 |
+
| Missed diagnoses | Per-class recall monitored at `/metrics/class-rates` |
|
| 197 |
+
| Production observability | Prometheus metrics β latency histograms, per-disease counters, drift alerts |
|
| 198 |
+
| Automated retraining signals | `DRIFT_DETECTED` flag logged + exposed via Prometheus counter |
|
| 199 |
+
|
| 200 |
+
---
|
| 201 |
+
|
| 202 |
+
## Libraries
|
| 203 |
+
|
| 204 |
+
`PyTorch` Β· `ONNX Runtime` Β· `FastAPI` Β· `Gradio` Β· `Docker` Β· `Hugging Face Hub/Spaces` Β· `scikit-learn` Β· `Prometheus` Β· `MedMNIST` Β· `SciPy`
|
| 205 |
+
|
| 206 |
+
---
|
| 207 |
+
|
| 208 |
+
## License
|
| 209 |
+
|
| 210 |
+
MIT
|
backend/main.py
ADDED
|
@@ -0,0 +1,675 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import io
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
import os
|
| 6 |
+
import random
|
| 7 |
+
import sys
|
| 8 |
+
import time
|
| 9 |
+
from collections import deque
|
| 10 |
+
from datetime import datetime, timezone
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
import gradio as gr
|
| 15 |
+
import numpy as np
|
| 16 |
+
import onnxruntime as ort
|
| 17 |
+
import torch
|
| 18 |
+
from fastapi import FastAPI, File, HTTPException, Request, Response, UploadFile
|
| 19 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 20 |
+
from fastapi.responses import PlainTextResponse
|
| 21 |
+
from PIL import Image
|
| 22 |
+
from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest
|
| 23 |
+
from scipy.stats import ks_2samp
|
| 24 |
+
from torchvision import transforms
|
| 25 |
+
from torchvision.models import mobilenet_v3_small, efficientnet_b0
|
| 26 |
+
|
| 27 |
+
from model_utils import CalibratedModel
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
BASE_DIR = Path(__file__).resolve().parents[1]
|
| 31 |
+
LEGACY_MODEL_DIR = BASE_DIR / "models"
|
| 32 |
+
REALWORLD_MODEL_DIR = BASE_DIR / "models" / "realworld_efficientnet_b0"
|
| 33 |
+
CHESTMNIST_MODEL_DIR = BASE_DIR / "models" / "chestmnist_mobilenetv3"
|
| 34 |
+
|
| 35 |
+
PROFILE = os.getenv("PNEUMOOPS_PROFILE", "realworld").lower()
|
| 36 |
+
_DEFAULT_MODEL_DIR = {
|
| 37 |
+
"realworld": REALWORLD_MODEL_DIR,
|
| 38 |
+
"chestmnist": CHESTMNIST_MODEL_DIR,
|
| 39 |
+
}.get(PROFILE, LEGACY_MODEL_DIR)
|
| 40 |
+
MODEL_DIR = Path(os.getenv("PNEUMOOPS_MODEL_DIR", str(_DEFAULT_MODEL_DIR)))
|
| 41 |
+
REQUEST_LOG_HISTORY = deque(maxlen=20)
|
| 42 |
+
|
| 43 |
+
API_KEY = os.getenv("PNEUMOOPS_API_KEY")
|
| 44 |
+
ALLOWED_ORIGINS = [origin.strip() for origin in os.getenv("PNEUMOOPS_ALLOWED_ORIGINS", "*").split(",") if origin.strip()]
|
| 45 |
+
TRAFFIC_WEIGHTS = {"pytorch": 60, "onnx": 40}
|
| 46 |
+
LOW_CONFIDENCE_THRESHOLD = float(os.getenv("PNEUMOOPS_LOW_CONFIDENCE_THRESHOLD", "0.60"))
|
| 47 |
+
MIN_UPLOAD_EDGE = int(os.getenv("PNEUMOOPS_MIN_UPLOAD_EDGE", "96"))
|
| 48 |
+
MAX_CHANNEL_DELTA = float(os.getenv("PNEUMOOPS_MAX_CHANNEL_DELTA", "0.08"))
|
| 49 |
+
MIN_ASPECT_RATIO = float(os.getenv("PNEUMOOPS_MIN_ASPECT_RATIO", "0.6"))
|
| 50 |
+
MAX_ASPECT_RATIO = float(os.getenv("PNEUMOOPS_MAX_ASPECT_RATIO", "1.6"))
|
| 51 |
+
|
| 52 |
+
REQUEST_COUNTER = Counter(
|
| 53 |
+
"pneumoops_requests_total",
|
| 54 |
+
"Total inference requests served by PneumoOps.",
|
| 55 |
+
["model", "status"],
|
| 56 |
+
)
|
| 57 |
+
LATENCY_HISTOGRAM = Histogram(
|
| 58 |
+
"pneumoops_inference_latency_ms",
|
| 59 |
+
"Inference latency per model in milliseconds.",
|
| 60 |
+
["model"],
|
| 61 |
+
buckets=(5, 10, 25, 50, 100, 250, 500, 1000),
|
| 62 |
+
)
|
| 63 |
+
DRIFT_COUNTER = Counter(
|
| 64 |
+
"pneumoops_drift_alerts_total",
|
| 65 |
+
"Number of drift alerts emitted by PneumoOps.",
|
| 66 |
+
["status"],
|
| 67 |
+
)
|
| 68 |
+
DISEASE_PREDICTION_COUNTER = Counter(
|
| 69 |
+
"pneumoops_disease_predictions_total",
|
| 70 |
+
"Per-disease prediction counts for production monitoring.",
|
| 71 |
+
["disease", "model"],
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
logger = logging.getLogger("pneumoops")
|
| 75 |
+
if not logger.handlers:
|
| 76 |
+
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def resolve_path(candidates: list[Path]) -> Path | None:
|
| 80 |
+
for candidate in candidates:
|
| 81 |
+
if candidate.exists():
|
| 82 |
+
return candidate
|
| 83 |
+
return None
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def load_json(path: Path | None, fallback: dict | None = None) -> dict:
|
| 87 |
+
fallback_dict = {} if fallback is None else fallback
|
| 88 |
+
if path and path.exists():
|
| 89 |
+
try:
|
| 90 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 91 |
+
except json.JSONDecodeError:
|
| 92 |
+
logger.warning(f"Failed to parse JSON file {path}, using fallback.")
|
| 93 |
+
return fallback_dict
|
| 94 |
+
return fallback_dict
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def resolve_runtime_paths(model_dir: Path) -> dict[str, Path | None]:
|
| 98 |
+
checkpoint_path = resolve_path(
|
| 99 |
+
[
|
| 100 |
+
model_dir / "mobilenetv3_chestmnist.pth", # chestmnist profile
|
| 101 |
+
model_dir / "realworld_efficientnet_b0.pth",
|
| 102 |
+
model_dir / "pneumo_model.pth",
|
| 103 |
+
]
|
| 104 |
+
)
|
| 105 |
+
onnx_report_path = resolve_path([model_dir / "onnx_export_report.json", LEGACY_MODEL_DIR / "onnx_export_report.json"])
|
| 106 |
+
onnx_report = load_json(onnx_report_path)
|
| 107 |
+
|
| 108 |
+
base_onnx = onnx_report.get("base_onnx")
|
| 109 |
+
optimized_onnx = onnx_report.get("optimized_onnx")
|
| 110 |
+
serving_onnx = onnx_report.get("serving_onnx")
|
| 111 |
+
|
| 112 |
+
onnx_candidates = []
|
| 113 |
+
if serving_onnx:
|
| 114 |
+
onnx_candidates.append(model_dir / serving_onnx)
|
| 115 |
+
if optimized_onnx:
|
| 116 |
+
onnx_candidates.append(model_dir / optimized_onnx)
|
| 117 |
+
if base_onnx:
|
| 118 |
+
onnx_candidates.append(model_dir / base_onnx)
|
| 119 |
+
onnx_candidates.extend(
|
| 120 |
+
[
|
| 121 |
+
# chestmnist profile
|
| 122 |
+
model_dir / "mobilenetv3_chestmnist.onnx",
|
| 123 |
+
# realworld profile
|
| 124 |
+
model_dir / "realworld_efficientnet_b0_quantized.onnx",
|
| 125 |
+
model_dir / "realworld_efficientnet_b0_optimized.onnx",
|
| 126 |
+
model_dir / "realworld_efficientnet_b0.onnx",
|
| 127 |
+
model_dir / "pneumo_model_quantized.onnx",
|
| 128 |
+
model_dir / "pneumo_model_optimized.onnx",
|
| 129 |
+
model_dir / "pneumo_model.onnx",
|
| 130 |
+
]
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
return {
|
| 134 |
+
"checkpoint": checkpoint_path,
|
| 135 |
+
"onnx": resolve_path(onnx_candidates),
|
| 136 |
+
"training_metrics": resolve_path([model_dir / "training_metrics.json", LEGACY_MODEL_DIR / "training_metrics.json"]),
|
| 137 |
+
"baseline_stats": resolve_path([model_dir / "baseline_stats.json", LEGACY_MODEL_DIR / "baseline_stats.json"]),
|
| 138 |
+
"onnx_export_report": onnx_report_path,
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
RUNTIME_PATHS = resolve_runtime_paths(MODEL_DIR)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def load_checkpoint_metadata(checkpoint_path: Path | None) -> dict[str, Any]:
|
| 146 |
+
if checkpoint_path is None or not checkpoint_path.exists():
|
| 147 |
+
return {
|
| 148 |
+
"architecture": "mobilenet_v3_small",
|
| 149 |
+
"class_names": ["Normal", "Pneumonia"],
|
| 150 |
+
"image_size": 224,
|
| 151 |
+
"normalize_mean": [0.485, 0.456, 0.406],
|
| 152 |
+
"normalize_std": [0.229, 0.224, 0.225],
|
| 153 |
+
"thresholds": [0.5, 0.5],
|
| 154 |
+
"logit_temperature": 1.0,
|
| 155 |
+
"multi_label": False,
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
payload = torch.load(checkpoint_path, map_location="cpu")
|
| 159 |
+
if isinstance(payload, dict) and "model_state_dict" in payload:
|
| 160 |
+
return {
|
| 161 |
+
"state_dict": payload["model_state_dict"],
|
| 162 |
+
"architecture": payload.get("architecture", "efficientnet_b0"),
|
| 163 |
+
"class_names": payload.get("class_names", ["No Finding"]),
|
| 164 |
+
"image_size": int(payload.get("image_size", 320)),
|
| 165 |
+
"normalize_mean": payload.get("normalize_mean", [0.485, 0.456, 0.406]),
|
| 166 |
+
"normalize_std": payload.get("normalize_std", [0.229, 0.224, 0.225]),
|
| 167 |
+
"thresholds": payload.get("thresholds", [0.5] * len(payload.get("class_names", ["No Finding"]))),
|
| 168 |
+
"logit_temperature": float(payload.get("logit_temperature", 1.0)),
|
| 169 |
+
"multi_label": bool(payload.get("multi_label", True)),
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
# Plain state_dict (e.g. from train_chestmnist.py) β pull metadata from training_metrics.json
|
| 173 |
+
tm_path = MODEL_DIR / "training_metrics.json"
|
| 174 |
+
tm = load_json(tm_path)
|
| 175 |
+
class_names = tm.get("class_names", ["Normal", "Pneumonia"])
|
| 176 |
+
n = len(class_names)
|
| 177 |
+
return {
|
| 178 |
+
"state_dict": payload,
|
| 179 |
+
"architecture": tm.get("architecture", "mobilenet_v3_small"),
|
| 180 |
+
"class_names": class_names,
|
| 181 |
+
"image_size": int(tm.get("image_size", 224)),
|
| 182 |
+
"normalize_mean": [0.5, 0.5, 0.5],
|
| 183 |
+
"normalize_std": [0.5, 0.5, 0.5],
|
| 184 |
+
"thresholds": tm.get("thresholds", [0.5] * n),
|
| 185 |
+
"logit_temperature": 1.0,
|
| 186 |
+
"multi_label": bool(tm.get("multi_label", True)),
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
MODEL_METADATA = load_checkpoint_metadata(RUNTIME_PATHS["checkpoint"])
|
| 192 |
+
CLASS_NAMES = MODEL_METADATA["class_names"]
|
| 193 |
+
MULTI_LABEL = bool(MODEL_METADATA["multi_label"])
|
| 194 |
+
IMAGE_SIZE = int(MODEL_METADATA["image_size"])
|
| 195 |
+
THRESHOLDS = np.asarray(MODEL_METADATA["thresholds"], dtype=np.float32)
|
| 196 |
+
LOGIT_TEMPERATURE = float(MODEL_METADATA.get("logit_temperature", 1.0))
|
| 197 |
+
|
| 198 |
+
TRANSFORM = transforms.Compose(
|
| 199 |
+
[
|
| 200 |
+
transforms.Resize((IMAGE_SIZE, IMAGE_SIZE)),
|
| 201 |
+
transforms.Grayscale(num_output_channels=3),
|
| 202 |
+
transforms.ToTensor(),
|
| 203 |
+
transforms.Normalize(
|
| 204 |
+
mean=MODEL_METADATA["normalize_mean"],
|
| 205 |
+
std=MODEL_METADATA["normalize_std"],
|
| 206 |
+
),
|
| 207 |
+
]
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
BASELINE_STATS = load_json(
|
| 211 |
+
RUNTIME_PATHS["baseline_stats"],
|
| 212 |
+
fallback={
|
| 213 |
+
"pixel_mean_mean": 0.5,
|
| 214 |
+
"pixel_mean_std": 0.1,
|
| 215 |
+
"pixel_std_mean": 0.2,
|
| 216 |
+
"pixel_std_std": 0.05,
|
| 217 |
+
"histogram_bins": 32,
|
| 218 |
+
"histogram_mean": [1.0 / 32.0] * 32,
|
| 219 |
+
"pixel_reference_sample": [],
|
| 220 |
+
"drift_threshold": 1.2,
|
| 221 |
+
"drift_ks_pvalue_threshold": 0.05,
|
| 222 |
+
"class_names": CLASS_NAMES,
|
| 223 |
+
},
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def build_model() -> torch.nn.Module | None:
|
| 228 |
+
checkpoint_path = RUNTIME_PATHS["checkpoint"]
|
| 229 |
+
if checkpoint_path is None or not checkpoint_path.exists():
|
| 230 |
+
return None
|
| 231 |
+
|
| 232 |
+
architecture = MODEL_METADATA["architecture"]
|
| 233 |
+
num_outputs = len(CLASS_NAMES)
|
| 234 |
+
if architecture == "efficientnet_b0":
|
| 235 |
+
base_model = efficientnet_b0(weights=None)
|
| 236 |
+
base_model.classifier[1] = torch.nn.Linear(base_model.classifier[1].in_features, num_outputs)
|
| 237 |
+
else:
|
| 238 |
+
base_model = mobilenet_v3_small(weights=None)
|
| 239 |
+
base_model.classifier[3] = torch.nn.Linear(base_model.classifier[3].in_features, num_outputs)
|
| 240 |
+
|
| 241 |
+
base_model.load_state_dict(MODEL_METADATA["state_dict"])
|
| 242 |
+
model = CalibratedModel(base_model, LOGIT_TEMPERATURE)
|
| 243 |
+
model.to(torch.device("cuda" if torch.cuda.is_available() else "cpu"))
|
| 244 |
+
model.eval()
|
| 245 |
+
return model
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def build_onnx_session():
|
| 249 |
+
onnx_path = RUNTIME_PATHS["onnx"]
|
| 250 |
+
if onnx_path is None or not onnx_path.exists():
|
| 251 |
+
return None, None
|
| 252 |
+
session = ort.InferenceSession(str(onnx_path), providers=["CPUExecutionProvider"])
|
| 253 |
+
return session, onnx_path.name
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 257 |
+
PYTORCH_MODEL = build_model()
|
| 258 |
+
ONNX_SESSION, ACTIVE_ONNX_MODEL_NAME = build_onnx_session()
|
| 259 |
+
|
| 260 |
+
app = FastAPI(
|
| 261 |
+
title="PneumoOps API",
|
| 262 |
+
description="Production-style MLOps pipeline for medical image classification with A/B routing and drift monitoring.",
|
| 263 |
+
)
|
| 264 |
+
app.add_middleware(
|
| 265 |
+
CORSMiddleware,
|
| 266 |
+
allow_origins=ALLOWED_ORIGINS,
|
| 267 |
+
allow_credentials=True,
|
| 268 |
+
allow_methods=["*"],
|
| 269 |
+
allow_headers=["*"],
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
@app.middleware("http")
|
| 274 |
+
async def auth_middleware(request: Request, call_next):
|
| 275 |
+
if API_KEY and request.url.path not in {"/health", "/metrics", "/"}:
|
| 276 |
+
provided = request.headers.get("x-api-key")
|
| 277 |
+
if provided != API_KEY:
|
| 278 |
+
return Response(content="Unauthorized", status_code=401)
|
| 279 |
+
return await call_next(request)
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def load_image_from_upload(upload: UploadFile) -> Image.Image:
|
| 283 |
+
try:
|
| 284 |
+
content = upload.file.read()
|
| 285 |
+
return Image.open(io.BytesIO(content)).convert("RGB")
|
| 286 |
+
except Exception as exc:
|
| 287 |
+
raise HTTPException(status_code=400, detail="Uploaded file is not a valid image.") from exc
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def summarize_image(image: Image.Image) -> dict[str, Any]:
|
| 291 |
+
gray = np.asarray(image.convert("L"), dtype=np.float32) / 255.0
|
| 292 |
+
width, height = image.size
|
| 293 |
+
rgb = np.asarray(image.convert("RGB"), dtype=np.float32) / 255.0
|
| 294 |
+
channel_delta = float(
|
| 295 |
+
np.mean(np.abs(rgb[:, :, 0] - rgb[:, :, 1]))
|
| 296 |
+
+ np.mean(np.abs(rgb[:, :, 1] - rgb[:, :, 2]))
|
| 297 |
+
+ np.mean(np.abs(rgb[:, :, 0] - rgb[:, :, 2]))
|
| 298 |
+
) / 3.0
|
| 299 |
+
return {
|
| 300 |
+
"width": width,
|
| 301 |
+
"height": height,
|
| 302 |
+
"aspect_ratio": round(width / max(height, 1), 4),
|
| 303 |
+
"pixel_mean": round(float(np.mean(gray)), 6),
|
| 304 |
+
"pixel_std": round(float(np.std(gray)), 6),
|
| 305 |
+
"pixel_min": round(float(np.min(gray)), 6),
|
| 306 |
+
"pixel_max": round(float(np.max(gray)), 6),
|
| 307 |
+
"channel_delta": round(channel_delta, 6),
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def validate_image(image: Image.Image, summary: dict[str, Any]) -> None:
|
| 312 |
+
if min(summary["width"], summary["height"]) < MIN_UPLOAD_EDGE:
|
| 313 |
+
raise HTTPException(
|
| 314 |
+
status_code=400,
|
| 315 |
+
detail=f"Input is too small for robust X-ray screening. Minimum edge is {MIN_UPLOAD_EDGE}px.",
|
| 316 |
+
)
|
| 317 |
+
if not (MIN_ASPECT_RATIO <= summary["aspect_ratio"] <= MAX_ASPECT_RATIO):
|
| 318 |
+
raise HTTPException(
|
| 319 |
+
status_code=400,
|
| 320 |
+
detail="Input aspect ratio is outside the expected chest X-ray range.",
|
| 321 |
+
)
|
| 322 |
+
if summary["channel_delta"] > MAX_CHANNEL_DELTA:
|
| 323 |
+
raise HTTPException(
|
| 324 |
+
status_code=400,
|
| 325 |
+
detail="Input appears to be a color image instead of a grayscale-style radiograph.",
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def calculate_drift(image: Image.Image) -> dict[str, Any]:
|
| 330 |
+
gray = np.asarray(image.convert("L"), dtype=np.float32) / 255.0
|
| 331 |
+
image_mean = float(np.mean(gray))
|
| 332 |
+
image_std = float(np.std(gray))
|
| 333 |
+
hist_bins = int(BASELINE_STATS.get("histogram_bins", 32))
|
| 334 |
+
hist, _ = np.histogram(gray, bins=hist_bins, range=(0.0, 1.0), density=True)
|
| 335 |
+
baseline_hist = np.asarray(BASELINE_STATS.get("histogram_mean", [1.0 / hist_bins] * hist_bins), dtype=np.float32)
|
| 336 |
+
|
| 337 |
+
mean_z = abs(image_mean - BASELINE_STATS.get("pixel_mean_mean", 0.5)) / max(BASELINE_STATS.get("pixel_mean_std", 0.1), 1e-6)
|
| 338 |
+
std_z = abs(image_std - BASELINE_STATS.get("pixel_std_mean", 0.2)) / max(BASELINE_STATS.get("pixel_std_std", 0.05), 1e-6)
|
| 339 |
+
hist_distance = float(np.mean(np.abs(hist - baseline_hist)))
|
| 340 |
+
drift_score = round(0.35 * mean_z + 0.35 * std_z + 0.30 * hist_distance, 6)
|
| 341 |
+
|
| 342 |
+
reference_sample = np.asarray(BASELINE_STATS.get("pixel_reference_sample", []), dtype=np.float32)
|
| 343 |
+
incoming_sample = gray.reshape(-1)
|
| 344 |
+
if reference_sample.size > 0:
|
| 345 |
+
sample_size = min(len(incoming_sample), len(reference_sample), 4096)
|
| 346 |
+
incoming_idx = np.random.choice(len(incoming_sample), size=sample_size, replace=False)
|
| 347 |
+
reference_idx = np.random.choice(len(reference_sample), size=sample_size, replace=False)
|
| 348 |
+
_, ks_pvalue = ks_2samp(incoming_sample[incoming_idx], reference_sample[reference_idx])
|
| 349 |
+
ks_pvalue = float(ks_pvalue)
|
| 350 |
+
else:
|
| 351 |
+
ks_pvalue = 1.0
|
| 352 |
+
|
| 353 |
+
drift_detected = ks_pvalue < float(BASELINE_STATS.get("drift_ks_pvalue_threshold", 0.05)) or drift_score > float(
|
| 354 |
+
BASELINE_STATS.get("drift_threshold", 1.2)
|
| 355 |
+
)
|
| 356 |
+
return {
|
| 357 |
+
"drift_alert": "DRIFT_DETECTED" if drift_detected else "NORMAL",
|
| 358 |
+
"drift_score": drift_score,
|
| 359 |
+
"ks_pvalue": round(ks_pvalue, 6),
|
| 360 |
+
"mean_z": round(float(mean_z), 6),
|
| 361 |
+
"std_z": round(float(std_z), 6),
|
| 362 |
+
"histogram_distance": round(hist_distance, 6),
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def postprocess_probabilities(probabilities: np.ndarray) -> dict[str, Any]:
|
| 367 |
+
probabilities = probabilities.astype(np.float32)
|
| 368 |
+
if MULTI_LABEL:
|
| 369 |
+
predicted_indices = [index for index, value in enumerate(probabilities) if value >= THRESHOLDS[index]]
|
| 370 |
+
if not predicted_indices:
|
| 371 |
+
predicted_indices = [int(np.argmax(probabilities))]
|
| 372 |
+
predicted_labels = [CLASS_NAMES[index] for index in predicted_indices]
|
| 373 |
+
else:
|
| 374 |
+
predicted_index = int(np.argmax(probabilities))
|
| 375 |
+
predicted_indices = [predicted_index]
|
| 376 |
+
predicted_labels = [CLASS_NAMES[predicted_index]]
|
| 377 |
+
|
| 378 |
+
sorted_pairs = sorted(
|
| 379 |
+
[
|
| 380 |
+
{
|
| 381 |
+
"label": CLASS_NAMES[index],
|
| 382 |
+
"confidence": round(float(probabilities[index]) * 100, 2),
|
| 383 |
+
"threshold": round(float(THRESHOLDS[index]) * 100, 2) if index < len(THRESHOLDS) else 50.0,
|
| 384 |
+
}
|
| 385 |
+
for index in range(len(CLASS_NAMES))
|
| 386 |
+
],
|
| 387 |
+
key=lambda item: item["confidence"],
|
| 388 |
+
reverse=True,
|
| 389 |
+
)
|
| 390 |
+
top_confidence = sorted_pairs[0]["confidence"] if sorted_pairs else 0.0
|
| 391 |
+
return {
|
| 392 |
+
"predicted_labels": predicted_labels,
|
| 393 |
+
"top_predictions": sorted_pairs[: min(5, len(sorted_pairs))],
|
| 394 |
+
"max_confidence": top_confidence,
|
| 395 |
+
"low_confidence": top_confidence < (LOW_CONFIDENCE_THRESHOLD * 100.0),
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def run_pytorch_inference(image: Image.Image) -> dict[str, Any]:
|
| 400 |
+
if PYTORCH_MODEL is None:
|
| 401 |
+
raise RuntimeError("PyTorch checkpoint is missing.")
|
| 402 |
+
|
| 403 |
+
tensor = TRANSFORM(image).unsqueeze(0).to(DEVICE)
|
| 404 |
+
start = time.perf_counter()
|
| 405 |
+
with torch.no_grad():
|
| 406 |
+
logits = PYTORCH_MODEL(tensor)
|
| 407 |
+
probabilities = torch.sigmoid(logits).squeeze(0).cpu().numpy() if MULTI_LABEL else torch.softmax(logits, dim=1).squeeze(0).cpu().numpy()
|
| 408 |
+
latency_ms = round((time.perf_counter() - start) * 1000, 2)
|
| 409 |
+
return {
|
| 410 |
+
"model_key": "pytorch",
|
| 411 |
+
"model_used": "Baseline PyTorch",
|
| 412 |
+
"latency_ms": latency_ms,
|
| 413 |
+
"probabilities": probabilities.tolist(),
|
| 414 |
+
**postprocess_probabilities(probabilities),
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
def run_onnx_inference(image: Image.Image) -> dict[str, Any]:
|
| 419 |
+
if ONNX_SESSION is None:
|
| 420 |
+
raise RuntimeError("ONNX artifact is missing.")
|
| 421 |
+
|
| 422 |
+
tensor = TRANSFORM(image).unsqueeze(0).numpy().astype(np.float32)
|
| 423 |
+
input_name = ONNX_SESSION.get_inputs()[0].name
|
| 424 |
+
start = time.perf_counter()
|
| 425 |
+
outputs = ONNX_SESSION.run(None, {input_name: tensor})
|
| 426 |
+
latency_ms = round((time.perf_counter() - start) * 1000, 2)
|
| 427 |
+
logits = torch.from_numpy(outputs[0]).squeeze(0)
|
| 428 |
+
probabilities = torch.sigmoid(logits).numpy() if MULTI_LABEL else torch.softmax(logits, dim=0).numpy()
|
| 429 |
+
return {
|
| 430 |
+
"model_key": "onnx",
|
| 431 |
+
"model_used": "Optimized ONNX",
|
| 432 |
+
"latency_ms": latency_ms,
|
| 433 |
+
"probabilities": probabilities.tolist(),
|
| 434 |
+
**postprocess_probabilities(probabilities),
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
async def benchmark_both_models(image: Image.Image) -> dict[str, Any]:
|
| 439 |
+
async def safe_call(model_name: str, fn):
|
| 440 |
+
try:
|
| 441 |
+
result = await asyncio.to_thread(fn, image)
|
| 442 |
+
LATENCY_HISTOGRAM.labels(model=model_name).observe(result["latency_ms"])
|
| 443 |
+
return result
|
| 444 |
+
except Exception as exc:
|
| 445 |
+
return {"model_key": model_name, "error": str(exc)}
|
| 446 |
+
|
| 447 |
+
pytorch_result, onnx_result = await asyncio.gather(
|
| 448 |
+
safe_call("pytorch", run_pytorch_inference),
|
| 449 |
+
safe_call("onnx", run_onnx_inference),
|
| 450 |
+
)
|
| 451 |
+
return {"pytorch": pytorch_result, "onnx": onnx_result}
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
def build_recommendation(selected_result: dict[str, Any], drift_result: dict[str, Any], dual_results: dict[str, Any]) -> str:
|
| 455 |
+
if drift_result["drift_alert"] == "DRIFT_DETECTED":
|
| 456 |
+
return "Input distribution differs from the stored training baseline. Manual review is recommended before trusting this result."
|
| 457 |
+
if selected_result["low_confidence"]:
|
| 458 |
+
return "Prediction confidence is below the review threshold. Treat this as low confidence and escalate for human review."
|
| 459 |
+
|
| 460 |
+
other_key = "onnx" if selected_result["model_key"] == "pytorch" else "pytorch"
|
| 461 |
+
other_result = dual_results.get(other_key, {})
|
| 462 |
+
if other_result.get("predicted_labels") and other_result["predicted_labels"] != selected_result["predicted_labels"]:
|
| 463 |
+
return "The two serving paths disagree on the predicted findings. Use this as a monitoring alert and fall back to manual review."
|
| 464 |
+
|
| 465 |
+
return "Use this output as a triage aid only. PneumoOps monitors latency and drift, but it is not a clinical decision-maker."
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
def append_history(entry: dict[str, Any]) -> None:
|
| 469 |
+
REQUEST_LOG_HISTORY.append(entry)
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
def emit_structured_log(payload: dict[str, Any]) -> None:
|
| 473 |
+
logger.info(json.dumps(payload, ensure_ascii=True))
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
@app.get("/health")
|
| 477 |
+
def health():
|
| 478 |
+
return {
|
| 479 |
+
"status": "ok",
|
| 480 |
+
"profile": PROFILE,
|
| 481 |
+
"model_dir": str(MODEL_DIR),
|
| 482 |
+
"pytorch_model_loaded": PYTORCH_MODEL is not None,
|
| 483 |
+
"onnx_model_loaded": ONNX_SESSION is not None,
|
| 484 |
+
"active_onnx_model": ACTIVE_ONNX_MODEL_NAME,
|
| 485 |
+
"class_count": len(CLASS_NAMES),
|
| 486 |
+
"class_names": CLASS_NAMES,
|
| 487 |
+
"multi_label": MULTI_LABEL,
|
| 488 |
+
"logit_temperature": LOGIT_TEMPERATURE,
|
| 489 |
+
"training_metrics": load_json(RUNTIME_PATHS["training_metrics"]),
|
| 490 |
+
"onnx_export_report": load_json(RUNTIME_PATHS["onnx_export_report"]),
|
| 491 |
+
"recent_requests": list(REQUEST_LOG_HISTORY),
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
@app.get("/metrics")
|
| 496 |
+
def metrics():
|
| 497 |
+
return PlainTextResponse(generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
| 498 |
+
|
| 499 |
+
|
| 500 |
+
@app.get("/history")
|
| 501 |
+
def history():
|
| 502 |
+
return {"recent_requests": list(REQUEST_LOG_HISTORY)}
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
@app.get("/metrics/class-rates")
|
| 506 |
+
def class_prediction_rates():
|
| 507 |
+
"""Return per-class prediction rates from the rolling request history."""
|
| 508 |
+
history_list = list(REQUEST_LOG_HISTORY)
|
| 509 |
+
total = max(len(history_list), 1)
|
| 510 |
+
rates: dict[str, float] = {label: 0.0 for label in CLASS_NAMES}
|
| 511 |
+
avg_confidence: dict[str, list] = {label: [] for label in CLASS_NAMES}
|
| 512 |
+
|
| 513 |
+
for entry in history_list:
|
| 514 |
+
per_class = entry.get("per_class_predictions", {})
|
| 515 |
+
per_class_probs = entry.get("per_class_probabilities", {})
|
| 516 |
+
for label in CLASS_NAMES:
|
| 517 |
+
if per_class.get(label, False):
|
| 518 |
+
rates[label] = rates[label] + 1
|
| 519 |
+
if label in per_class_probs:
|
| 520 |
+
avg_confidence[label].append(per_class_probs[label])
|
| 521 |
+
|
| 522 |
+
return {
|
| 523 |
+
"window_size": total,
|
| 524 |
+
"per_class_prediction_rate": {
|
| 525 |
+
label: round(count / total, 4)
|
| 526 |
+
for label, count in rates.items()
|
| 527 |
+
},
|
| 528 |
+
"per_class_avg_confidence": {
|
| 529 |
+
label: round(float(sum(vals) / len(vals)), 4) if vals else None
|
| 530 |
+
for label, vals in avg_confidence.items()
|
| 531 |
+
},
|
| 532 |
+
"drift_rate": round(
|
| 533 |
+
sum(1 for e in history_list if e.get("drift_alert") == "DRIFT_DETECTED") / total, 4
|
| 534 |
+
),
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
|
| 538 |
+
@app.get("/metrics/calibration")
|
| 539 |
+
def calibration_summary():
|
| 540 |
+
"""Return per-class calibration (Brier scores) from training metrics."""
|
| 541 |
+
tm = load_json(RUNTIME_PATHS["training_metrics"])
|
| 542 |
+
return {
|
| 543 |
+
"test_macro_brier": tm.get("test_macro_brier"),
|
| 544 |
+
"test_macro_auprc": tm.get("test_macro_auprc"),
|
| 545 |
+
"test_macro_roc_auc": tm.get("test_macro_roc_auc"),
|
| 546 |
+
"per_class_brier": tm.get("per_class_brier", {}),
|
| 547 |
+
"per_class_auprc": tm.get("per_class_auprc", {}),
|
| 548 |
+
"per_class_roc_auc": tm.get("per_class_roc_auc", {}),
|
| 549 |
+
"per_class_recall": tm.get("per_class_recall", {}),
|
| 550 |
+
"threshold_details": tm.get("threshold_details", {}),
|
| 551 |
+
"class_names": tm.get("class_names", CLASS_NAMES),
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
|
| 555 |
+
@app.post("/predict")
|
| 556 |
+
async def predict(request: Request, file: UploadFile = File(...)):
|
| 557 |
+
image = load_image_from_upload(file)
|
| 558 |
+
input_summary = summarize_image(image)
|
| 559 |
+
validate_image(image, input_summary)
|
| 560 |
+
|
| 561 |
+
start = time.perf_counter()
|
| 562 |
+
dual_results = await benchmark_both_models(image)
|
| 563 |
+
selected_key = random.choices(["pytorch", "onnx"], weights=[TRAFFIC_WEIGHTS["pytorch"], TRAFFIC_WEIGHTS["onnx"]], k=1)[0]
|
| 564 |
+
selected_result = dual_results[selected_key]
|
| 565 |
+
warning_flags = []
|
| 566 |
+
|
| 567 |
+
if "error" in selected_result:
|
| 568 |
+
fallback_result = dual_results["pytorch"]
|
| 569 |
+
if "error" in fallback_result:
|
| 570 |
+
REQUEST_COUNTER.labels(model=selected_key, status="failure").inc()
|
| 571 |
+
raise HTTPException(status_code=503, detail=f"Both inference backends failed: {dual_results}")
|
| 572 |
+
selected_result = fallback_result
|
| 573 |
+
warning_flags.append(f"{selected_key.upper()} failed, fallback to PyTorch.")
|
| 574 |
+
|
| 575 |
+
# Increment per-class disease prediction counters
|
| 576 |
+
for label in selected_result["predicted_labels"]:
|
| 577 |
+
DISEASE_PREDICTION_COUNTER.labels(disease=label, model=selected_result["model_key"]).inc()
|
| 578 |
+
|
| 579 |
+
drift_result = calculate_drift(image)
|
| 580 |
+
DRIFT_COUNTER.labels(status=drift_result["drift_alert"]).inc()
|
| 581 |
+
REQUEST_COUNTER.labels(model=selected_result["model_key"], status="success").inc()
|
| 582 |
+
|
| 583 |
+
pytorch_latency = dual_results["pytorch"].get("latency_ms") if "error" not in dual_results["pytorch"] else None
|
| 584 |
+
onnx_latency = dual_results["onnx"].get("latency_ms") if "error" not in dual_results["onnx"] else None
|
| 585 |
+
latency_delta = None
|
| 586 |
+
if pytorch_latency is not None and onnx_latency is not None:
|
| 587 |
+
latency_delta = round(float(onnx_latency) - float(pytorch_latency), 2)
|
| 588 |
+
|
| 589 |
+
timestamp = datetime.now(timezone.utc).isoformat()
|
| 590 |
+
response_payload = {
|
| 591 |
+
"timestamp": timestamp,
|
| 592 |
+
"selected_model": selected_result["model_used"],
|
| 593 |
+
"selected_arm": "A" if selected_result["model_key"] == "pytorch" else "B",
|
| 594 |
+
"weighted_traffic_split": TRAFFIC_WEIGHTS,
|
| 595 |
+
"predicted_labels": selected_result["predicted_labels"],
|
| 596 |
+
"top_predictions": selected_result["top_predictions"],
|
| 597 |
+
"confidence": selected_result["max_confidence"],
|
| 598 |
+
"low_confidence": selected_result["low_confidence"],
|
| 599 |
+
"confidence_review_threshold": LOW_CONFIDENCE_THRESHOLD * 100.0,
|
| 600 |
+
"pytorch_latency_ms": pytorch_latency,
|
| 601 |
+
"onnx_latency_ms": onnx_latency,
|
| 602 |
+
"latency_delta_ms": latency_delta,
|
| 603 |
+
"drift": drift_result,
|
| 604 |
+
"input_summary": input_summary,
|
| 605 |
+
"warning_flags": warning_flags,
|
| 606 |
+
"recommendation": build_recommendation(selected_result, drift_result, dual_results),
|
| 607 |
+
"active_onnx_model": ACTIVE_ONNX_MODEL_NAME,
|
| 608 |
+
"recent_history": list(REQUEST_LOG_HISTORY),
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
history_entry = {
|
| 612 |
+
"timestamp": timestamp,
|
| 613 |
+
"model": selected_result["model_used"],
|
| 614 |
+
"latency_ms": selected_result["latency_ms"],
|
| 615 |
+
"drift_alert": drift_result["drift_alert"],
|
| 616 |
+
"drift_score": drift_result["drift_score"],
|
| 617 |
+
"confidence": selected_result["max_confidence"],
|
| 618 |
+
"labels": selected_result["predicted_labels"],
|
| 619 |
+
"per_class_probabilities": {
|
| 620 |
+
CLASS_NAMES[i]: round(float(selected_result["probabilities"][i]), 4)
|
| 621 |
+
for i in range(len(CLASS_NAMES))
|
| 622 |
+
if i < len(selected_result.get("probabilities", []))
|
| 623 |
+
},
|
| 624 |
+
"per_class_predictions": {
|
| 625 |
+
label: (label in selected_result["predicted_labels"])
|
| 626 |
+
for label in CLASS_NAMES
|
| 627 |
+
},
|
| 628 |
+
}
|
| 629 |
+
append_history(history_entry)
|
| 630 |
+
response_payload["recent_history"] = list(REQUEST_LOG_HISTORY)
|
| 631 |
+
|
| 632 |
+
emit_structured_log(
|
| 633 |
+
{
|
| 634 |
+
"event": "predict",
|
| 635 |
+
"timestamp": timestamp,
|
| 636 |
+
"model_used": selected_result["model_used"],
|
| 637 |
+
"latency_ms": selected_result["latency_ms"],
|
| 638 |
+
"confidence": selected_result["max_confidence"],
|
| 639 |
+
"drift_status": drift_result["drift_alert"],
|
| 640 |
+
"pathologies": selected_result["predicted_labels"],
|
| 641 |
+
"client": request.client.host if request.client else None,
|
| 642 |
+
}
|
| 643 |
+
)
|
| 644 |
+
|
| 645 |
+
response_payload["request_latency_ms"] = round((time.perf_counter() - start) * 1000, 2)
|
| 646 |
+
return response_payload
|
| 647 |
+
|
| 648 |
+
|
| 649 |
+
# βββ Mount Gradio UI into FastAPI (single-port for HF Spaces) βββββββββββββββ
|
| 650 |
+
# This allows the entire app (API + UI) to run on one port (7860).
|
| 651 |
+
# - FastAPI REST endpoints remain at /predict, /health, /metrics, etc.
|
| 652 |
+
# - Gradio UI is served at / (root)
|
| 653 |
+
|
| 654 |
+
try:
|
| 655 |
+
# Dynamically add frontend dir to path so app.py can be imported
|
| 656 |
+
_frontend_dir = str(BASE_DIR / "frontend")
|
| 657 |
+
if _frontend_dir not in sys.path:
|
| 658 |
+
sys.path.insert(0, _frontend_dir)
|
| 659 |
+
|
| 660 |
+
import gradio as gr
|
| 661 |
+
from frontend.app import demo as gradio_demo # the gr.Blocks() object
|
| 662 |
+
|
| 663 |
+
# Mount Gradio at root; FastAPI routes take priority because they're
|
| 664 |
+
# registered first via @app.get / @app.post decorators.
|
| 665 |
+
app = gr.mount_gradio_app(app, gradio_demo, path="/ui")
|
| 666 |
+
logger.info("Gradio UI mounted at /ui β full app on single port.")
|
| 667 |
+
except Exception as _e:
|
| 668 |
+
logger.warning(f"Gradio mount skipped ({_e}). API-only mode active.")
|
| 669 |
+
|
| 670 |
+
|
| 671 |
+
if __name__ == "__main__":
|
| 672 |
+
import uvicorn
|
| 673 |
+
port = int(os.getenv("PORT", "7860"))
|
| 674 |
+
uvicorn.run(app, host="0.0.0.0", port=port)
|
| 675 |
+
|
data/.gitkeep
ADDED
|
File without changes
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: "3.9"
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
# βββ Single container: FastAPI + Gradio on port 7860 βββββββββββββββββββββ
|
| 5 |
+
# This mirrors the HF Spaces deployment (single exposed port).
|
| 6 |
+
# Access:
|
| 7 |
+
# UI β http://localhost:7860/ui
|
| 8 |
+
# API β http://localhost:7860/predict
|
| 9 |
+
# Health β http://localhost:7860/health
|
| 10 |
+
app:
|
| 11 |
+
build:
|
| 12 |
+
context: .
|
| 13 |
+
dockerfile: Dockerfile
|
| 14 |
+
ports:
|
| 15 |
+
- "7860:7860"
|
| 16 |
+
volumes:
|
| 17 |
+
- ./models:/app/models # hot-swap model files without rebuilding
|
| 18 |
+
environment:
|
| 19 |
+
- PYTHONPATH=/app
|
| 20 |
+
- PNEUMOOPS_PROFILE=chestmnist
|
| 21 |
+
- PORT=7860
|
| 22 |
+
# Optional: set PNEUMOOPS_API_KEY to protect /predict in production
|
| 23 |
+
# - PNEUMOOPS_API_KEY=change-me
|
| 24 |
+
restart: unless-stopped
|
| 25 |
+
healthcheck:
|
| 26 |
+
test: ["CMD", "curl", "-f", "http://127.0.0.1:7860/health"]
|
| 27 |
+
interval: 30s
|
| 28 |
+
timeout: 5s
|
| 29 |
+
start_period: 40s
|
| 30 |
+
retries: 3
|
frontend/app.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PneumoOps β Simple Assignment Demo UI (Task 1 Spec)
|
| 3 |
+
====================================================
|
| 4 |
+
Matches the exact Gradio UI requirements from ASSIGNMENT_TARGET.md:
|
| 5 |
+
1. Image upload
|
| 6 |
+
2. Top-3 predictions β bar chart
|
| 7 |
+
3. Model Used β "Baseline PyTorch" or "Optimized ONNX"
|
| 8 |
+
4. Inference Latency (ms)
|
| 9 |
+
5. Drift Alert β "Normal" or "Drift Detected" (color badge)
|
| 10 |
+
|
| 11 |
+
Start independently of the main dashboard:
|
| 12 |
+
python3 frontend/app_simple.py
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import io
|
| 16 |
+
import os
|
| 17 |
+
|
| 18 |
+
import matplotlib
|
| 19 |
+
matplotlib.use("Agg")
|
| 20 |
+
import matplotlib.pyplot as plt
|
| 21 |
+
import matplotlib.patches as mpatches
|
| 22 |
+
import gradio as gr
|
| 23 |
+
import numpy as np
|
| 24 |
+
import requests
|
| 25 |
+
from PIL import Image
|
| 26 |
+
|
| 27 |
+
# When running embedded inside FastAPI (HF Spaces), the backend is on the same process.
|
| 28 |
+
# When running standalone via docker-compose, override with BACKEND_PREDICT_URL env var.
|
| 29 |
+
BACKEND_URL = os.getenv("BACKEND_PREDICT_URL", "http://127.0.0.1:7860/predict")
|
| 30 |
+
|
| 31 |
+
CHESTMNIST_CLASSES = [
|
| 32 |
+
"Atelectasis", "Cardiomegaly", "Effusion", "Infiltration",
|
| 33 |
+
"Mass", "Nodule", "Pneumonia", "Pneumothorax",
|
| 34 |
+
"Consolidation", "Edema", "Emphysema", "Fibrosis",
|
| 35 |
+
"Pleural_Thickening", "Hernia",
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ---------------------------------------------------------------------------
|
| 40 |
+
# Backend call
|
| 41 |
+
# ---------------------------------------------------------------------------
|
| 42 |
+
|
| 43 |
+
def call_backend(image: Image.Image, api_url: str) -> dict:
|
| 44 |
+
buf = io.BytesIO()
|
| 45 |
+
image.convert("RGB").save(buf, format="PNG")
|
| 46 |
+
buf.seek(0)
|
| 47 |
+
resp = requests.post(
|
| 48 |
+
api_url,
|
| 49 |
+
files={"file": ("xray.png", buf.getvalue(), "image/png")},
|
| 50 |
+
timeout=60,
|
| 51 |
+
)
|
| 52 |
+
resp.raise_for_status()
|
| 53 |
+
return resp.json()
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ---------------------------------------------------------------------------
|
| 57 |
+
# Helpers
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
|
| 60 |
+
_TIER_COLORS = {
|
| 61 |
+
"Pneumonia": "#dc2626",
|
| 62 |
+
"Pneumothorax": "#dc2626",
|
| 63 |
+
"Mass": "#dc2626",
|
| 64 |
+
"Nodule": "#dc2626",
|
| 65 |
+
"Effusion": "#f59e0b",
|
| 66 |
+
"Cardiomegaly": "#f59e0b",
|
| 67 |
+
"Consolidation": "#f59e0b",
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
def _bar_color(label: str) -> str:
|
| 71 |
+
return _TIER_COLORS.get(label, "#6366f1")
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def build_top3_chart(top_predictions: list[dict]) -> object:
|
| 75 |
+
"""Horizontal bar chart of top-3 predicted pathologies."""
|
| 76 |
+
top3 = sorted(top_predictions, key=lambda x: x["confidence"], reverse=True)[:3]
|
| 77 |
+
if not top3:
|
| 78 |
+
fig, ax = plt.subplots(figsize=(7, 2))
|
| 79 |
+
ax.set_title("No pathologies detected above threshold")
|
| 80 |
+
ax.axis("off")
|
| 81 |
+
return fig
|
| 82 |
+
|
| 83 |
+
labels = [p["label"] for p in top3]
|
| 84 |
+
values = [p["confidence"] / 100.0 for p in top3]
|
| 85 |
+
colors = [_bar_color(l) for l in labels]
|
| 86 |
+
|
| 87 |
+
fig, ax = plt.subplots(figsize=(8, 3.2))
|
| 88 |
+
y = np.arange(len(labels))
|
| 89 |
+
ax.barh(y, values, color=colors, height=0.55, zorder=3)
|
| 90 |
+
ax.set_yticks(y)
|
| 91 |
+
ax.set_yticklabels(labels, fontsize=13, fontweight="bold")
|
| 92 |
+
ax.set_xlim(0, 1.15)
|
| 93 |
+
ax.set_xlabel("Confidence Score", fontsize=11)
|
| 94 |
+
ax.set_title("Top-3 Predicted Pathologies", fontsize=13, fontweight="bold", pad=10)
|
| 95 |
+
ax.grid(axis="x", alpha=0.25, zorder=0)
|
| 96 |
+
|
| 97 |
+
for i, v in enumerate(values):
|
| 98 |
+
ax.text(v + 0.02, i, f"{v:.0%}", va="center", fontsize=11, fontweight="bold")
|
| 99 |
+
|
| 100 |
+
legend_patches = [
|
| 101 |
+
mpatches.Patch(color="#dc2626", label="Critical"),
|
| 102 |
+
mpatches.Patch(color="#f59e0b", label="Significant"),
|
| 103 |
+
mpatches.Patch(color="#6366f1", label="Standard"),
|
| 104 |
+
]
|
| 105 |
+
ax.legend(handles=legend_patches, loc="lower right", fontsize=9)
|
| 106 |
+
|
| 107 |
+
fig.patch.set_facecolor("#f8fafc")
|
| 108 |
+
ax.set_facecolor("#f1f5f9")
|
| 109 |
+
plt.tight_layout()
|
| 110 |
+
return fig
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def drift_badge(status: str) -> str:
|
| 114 |
+
if status == "DRIFT_DETECTED":
|
| 115 |
+
return (
|
| 116 |
+
"<div style='padding:0.8rem 1.2rem;border-radius:12px;font-size:1rem;"
|
| 117 |
+
"font-weight:700;background:#fef3f2;color:#b42318;border:2px solid #fca5a5;'>"
|
| 118 |
+
"β οΈ DRIFT DETECTED β Out-of-distribution input. Consider retraining.</div>"
|
| 119 |
+
)
|
| 120 |
+
return (
|
| 121 |
+
"<div style='padding:0.8rem 1.2rem;border-radius:12px;font-size:1rem;"
|
| 122 |
+
"font-weight:700;background:#ecfdf5;color:#065f46;border:2px solid #6ee7b7;'>"
|
| 123 |
+
"β
NORMAL β Input distribution matches training baseline.</div>"
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# ---------------------------------------------------------------------------
|
| 128 |
+
# Main predict function
|
| 129 |
+
# ---------------------------------------------------------------------------
|
| 130 |
+
|
| 131 |
+
def predict(image: Image.Image, api_url: str):
|
| 132 |
+
if image is None:
|
| 133 |
+
raise gr.Error("Please upload a chest X-ray image.")
|
| 134 |
+
|
| 135 |
+
try:
|
| 136 |
+
payload = call_backend(image, api_url)
|
| 137 |
+
except requests.HTTPError as exc:
|
| 138 |
+
detail = exc.response.text if exc.response is not None else str(exc)
|
| 139 |
+
raise gr.Error(f"Backend error: {detail}") from exc
|
| 140 |
+
except requests.RequestException as exc:
|
| 141 |
+
raise gr.Error(f"Cannot reach backend at {api_url} β is the FastAPI server running?") from exc
|
| 142 |
+
|
| 143 |
+
top_preds = payload.get("top_predictions", [])
|
| 144 |
+
model_key = payload.get("selected_arm", payload.get("selected_model", "onnx"))
|
| 145 |
+
latency_ms = payload.get("latency_delta_ms") or payload.get("request_latency_ms", "n/a")
|
| 146 |
+
drift_status = payload.get("drift", {}).get("drift_alert", "UNKNOWN")
|
| 147 |
+
|
| 148 |
+
model_label = "Optimized ONNX" if "onnx" in str(model_key).lower() else "Baseline PyTorch"
|
| 149 |
+
|
| 150 |
+
chart = build_top3_chart(top_preds)
|
| 151 |
+
findings_list = ", ".join(payload.get("predicted_labels", [])) or "No findings above threshold"
|
| 152 |
+
|
| 153 |
+
return (
|
| 154 |
+
chart,
|
| 155 |
+
findings_list,
|
| 156 |
+
model_label,
|
| 157 |
+
f"{latency_ms} ms",
|
| 158 |
+
drift_badge(drift_status),
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# ---------------------------------------------------------------------------
|
| 163 |
+
# Gradio UI
|
| 164 |
+
# ---------------------------------------------------------------------------
|
| 165 |
+
|
| 166 |
+
CSS = """
|
| 167 |
+
.hero {
|
| 168 |
+
background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 60%, #2563eb 100%);
|
| 169 |
+
padding: 1.5rem 2rem;
|
| 170 |
+
border-radius: 18px;
|
| 171 |
+
color: white;
|
| 172 |
+
margin-bottom: 1rem;
|
| 173 |
+
}
|
| 174 |
+
.hero h1 { margin: 0 0 0.4rem; font-size: 2rem; letter-spacing: -1px; }
|
| 175 |
+
.hero p { margin: 0; opacity: 0.85; font-size: 0.95rem; line-height: 1.6; }
|
| 176 |
+
"""
|
| 177 |
+
|
| 178 |
+
with gr.Blocks(
|
| 179 |
+
theme=gr.themes.Soft(),
|
| 180 |
+
css=CSS,
|
| 181 |
+
title="PneumoOps β A/B Testing MLOps Pipeline",
|
| 182 |
+
) as demo:
|
| 183 |
+
|
| 184 |
+
gr.HTML("""
|
| 185 |
+
<div class="hero">
|
| 186 |
+
<h1>π« PneumoOps</h1>
|
| 187 |
+
<p>
|
| 188 |
+
<strong>MLOps A/B Testing Pipeline</strong> for 14-class thoracic disease screening.<br/>
|
| 189 |
+
Every request is randomly routed to <strong>Model A (Baseline PyTorch)</strong> or
|
| 190 |
+
<strong>Model B (Optimized ONNX)</strong>, enabling real-world latency benchmarking.<br/>
|
| 191 |
+
A built-in <strong>Data Drift Monitor</strong> flags out-of-distribution inputs β
|
| 192 |
+
simulating automated retraining triggers in production.
|
| 193 |
+
</p>
|
| 194 |
+
</div>
|
| 195 |
+
""")
|
| 196 |
+
|
| 197 |
+
with gr.Accordion("βοΈ Backend Settings", open=False):
|
| 198 |
+
api_url = gr.Textbox(label="Backend URL", value=BACKEND_URL)
|
| 199 |
+
|
| 200 |
+
with gr.Row():
|
| 201 |
+
image_input = gr.Image(type="pil", label="Upload Chest X-Ray", height=380)
|
| 202 |
+
|
| 203 |
+
submit_btn = gr.Button("π¬ Run Screening", variant="primary", size="lg")
|
| 204 |
+
|
| 205 |
+
gr.Markdown("---")
|
| 206 |
+
gr.Markdown("## Results")
|
| 207 |
+
|
| 208 |
+
with gr.Row():
|
| 209 |
+
model_used = gr.Textbox(label="Model Used (A/B Arm)", interactive=False, scale=1)
|
| 210 |
+
latency_out = gr.Textbox(label="Inference Latency", interactive=False, scale=1)
|
| 211 |
+
|
| 212 |
+
findings_out = gr.Textbox(label="All Findings Detected", interactive=False)
|
| 213 |
+
drift_out = gr.HTML(label="Data Drift Alert")
|
| 214 |
+
top3_chart = gr.Plot(label="Top-3 Predicted Pathologies")
|
| 215 |
+
|
| 216 |
+
gr.Markdown("""
|
| 217 |
+
---
|
| 218 |
+
**14 Classes:** Atelectasis Β· Cardiomegaly Β· Effusion Β· Infiltration Β· Mass Β· Nodule Β·
|
| 219 |
+
Pneumonia Β· Pneumothorax Β· Consolidation Β· Edema Β· Emphysema Β· Fibrosis Β·
|
| 220 |
+
Pleural Thickening Β· Hernia
|
| 221 |
+
|
| 222 |
+
π΄ Critical Β· π Significant Β· π£ Standard
|
| 223 |
+
""")
|
| 224 |
+
|
| 225 |
+
submit_btn.click(
|
| 226 |
+
fn=predict,
|
| 227 |
+
inputs=[image_input, api_url],
|
| 228 |
+
outputs=[top3_chart, findings_out, model_used, latency_out, drift_out],
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
if __name__ == "__main__":
|
| 233 |
+
port = int(os.getenv("GRADIO_PORT", "7860"))
|
| 234 |
+
demo.launch(server_name="0.0.0.0", server_port=port)
|
| 235 |
+
|
model_utils.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn as nn
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def apply_temperature_to_logits(logits: torch.Tensor, temperature: float | torch.Tensor) -> torch.Tensor:
|
| 8 |
+
if isinstance(temperature, torch.Tensor):
|
| 9 |
+
temperature_tensor = temperature.to(device=logits.device, dtype=logits.dtype)
|
| 10 |
+
else:
|
| 11 |
+
temperature_tensor = torch.tensor(float(temperature), device=logits.device, dtype=logits.dtype)
|
| 12 |
+
return logits / torch.clamp(temperature_tensor, min=1e-3)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class CalibratedModel(nn.Module):
|
| 16 |
+
def __init__(self, base_model: nn.Module, temperature: float):
|
| 17 |
+
super().__init__()
|
| 18 |
+
self.base_model = base_model
|
| 19 |
+
self.register_buffer("temperature", torch.tensor(float(temperature), dtype=torch.float32))
|
| 20 |
+
|
| 21 |
+
def forward(self, inputs: torch.Tensor) -> torch.Tensor:
|
| 22 |
+
logits = self.base_model(inputs)
|
| 23 |
+
return apply_temperature_to_logits(logits, self.temperature)
|
models/.gitkeep
ADDED
|
File without changes
|
models/chestmnist_mobilenetv3/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
tags:
|
| 4 |
+
- medical
|
| 5 |
+
- image-classification
|
| 6 |
+
- chest-xray
|
| 7 |
+
- mlops
|
| 8 |
+
- onnx
|
| 9 |
+
- pytorch
|
| 10 |
+
- mobilenetv3
|
| 11 |
+
datasets:
|
| 12 |
+
- medmnist/chestmnist
|
| 13 |
+
metrics:
|
| 14 |
+
- roc_auc
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
# PneumoOps β ChestMNIST MobileNetV3-small
|
| 18 |
+
|
| 19 |
+
This repository contains **two model versions** for the PneumoOps MLOps pipeline:
|
| 20 |
+
- **Model A (Baseline):** `mobilenetv3_chestmnist.pth` β Standard PyTorch checkpoint
|
| 21 |
+
- **Model B (Optimized):** `mobilenetv3_chestmnist.onnx` β ONNX-exported for faster inference
|
| 22 |
+
|
| 23 |
+
Both models are identical in architecture (MobileNetV3-small) and weights.
|
| 24 |
+
The ONNX version is used for inference time optimization in A/B testing.
|
| 25 |
+
|
| 26 |
+
## Dataset
|
| 27 |
+
**ChestMNIST** β 14-class multi-label chest X-ray classification
|
| 28 |
+
78,468 training images, 224Γ224 pixels, grayscale (converted to 3-channel).
|
| 29 |
+
|
| 30 |
+
## Classes (14)
|
| 31 |
+
Atelectasis, Cardiomegaly, Effusion, Infiltration, Mass, Nodule, Pneumonia,
|
| 32 |
+
Pneumothorax, Consolidation, Edema, Emphysema, Fibrosis, Pleural Thickening, Hernia
|
| 33 |
+
|
| 34 |
+
## Performance (Test Set)
|
| 35 |
+
| Metric | Score |
|
| 36 |
+
|--------|-------|
|
| 37 |
+
| Macro AUROC | **0.808** |
|
| 38 |
+
| Macro AUPRC | 0.210 |
|
| 39 |
+
| Micro F1 | 0.343 |
|
| 40 |
+
|
| 41 |
+
## Usage in PneumoOps
|
| 42 |
+
These artifacts are loaded by the FastAPI backend and selected via a weighted A/B router:
|
| 43 |
+
- 60% of requests β PyTorch model
|
| 44 |
+
- 40% of requests β ONNX model
|
| 45 |
+
|
| 46 |
+
The backend also computes a drift score using `baseline_stats.json` to detect
|
| 47 |
+
out-of-distribution inputs in real time.
|
models/chestmnist_mobilenetv3/baseline_stats.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"pixel_mean": -0.010379456914961338,
|
| 3 |
+
"pixel_std": 0.5114685297012329,
|
| 4 |
+
"channel_means": [
|
| 5 |
+
-0.010379449464380741,
|
| 6 |
+
-0.010379449464380741,
|
| 7 |
+
-0.010379449464380741
|
| 8 |
+
],
|
| 9 |
+
"channel_stds": [
|
| 10 |
+
0.5114684700965881,
|
| 11 |
+
0.5114684700965881,
|
| 12 |
+
0.5114684700965881
|
| 13 |
+
],
|
| 14 |
+
"n_samples": 640
|
| 15 |
+
}
|
models/chestmnist_mobilenetv3/onnx_export_report.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"base_onnx": "mobilenetv3_chestmnist.onnx",
|
| 3 |
+
"optimized_onnx": "mobilenetv3_chestmnist.onnx",
|
| 4 |
+
"serving_onnx": "mobilenetv3_chestmnist.onnx",
|
| 5 |
+
"input_shape": [
|
| 6 |
+
1,
|
| 7 |
+
3,
|
| 8 |
+
224,
|
| 9 |
+
224
|
| 10 |
+
]
|
| 11 |
+
}
|
models/chestmnist_mobilenetv3/training_metrics.json
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"architecture": "MobileNetV3-small",
|
| 3 |
+
"dataset": "ChestMNIST",
|
| 4 |
+
"class_names": [
|
| 5 |
+
"Atelectasis",
|
| 6 |
+
"Cardiomegaly",
|
| 7 |
+
"Effusion",
|
| 8 |
+
"Infiltration",
|
| 9 |
+
"Mass",
|
| 10 |
+
"Nodule",
|
| 11 |
+
"Pneumonia",
|
| 12 |
+
"Pneumothorax",
|
| 13 |
+
"Consolidation",
|
| 14 |
+
"Edema",
|
| 15 |
+
"Emphysema",
|
| 16 |
+
"Fibrosis",
|
| 17 |
+
"Pleural_Thickening",
|
| 18 |
+
"Hernia"
|
| 19 |
+
],
|
| 20 |
+
"num_classes": 14,
|
| 21 |
+
"epochs": 15,
|
| 22 |
+
"batch_size": 32,
|
| 23 |
+
"learning_rate": 0.001,
|
| 24 |
+
"image_size": 224,
|
| 25 |
+
"thresholds": [
|
| 26 |
+
0.2,
|
| 27 |
+
0.15,
|
| 28 |
+
0.25,
|
| 29 |
+
0.2,
|
| 30 |
+
0.15,
|
| 31 |
+
0.1,
|
| 32 |
+
0.5,
|
| 33 |
+
0.15,
|
| 34 |
+
0.1,
|
| 35 |
+
0.1,
|
| 36 |
+
0.15,
|
| 37 |
+
0.1,
|
| 38 |
+
0.1,
|
| 39 |
+
0.5
|
| 40 |
+
],
|
| 41 |
+
"multi_label": true,
|
| 42 |
+
"best_val_loss": 0.1511,
|
| 43 |
+
"history": [
|
| 44 |
+
{
|
| 45 |
+
"epoch": 1,
|
| 46 |
+
"train_loss": 0.1781,
|
| 47 |
+
"val_loss": 0.1591
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
"epoch": 2,
|
| 51 |
+
"train_loss": 0.1586,
|
| 52 |
+
"val_loss": 0.1547
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
"epoch": 3,
|
| 56 |
+
"train_loss": 0.1545,
|
| 57 |
+
"val_loss": 0.153
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
"epoch": 4,
|
| 61 |
+
"train_loss": 0.1517,
|
| 62 |
+
"val_loss": 0.1519
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"epoch": 5,
|
| 66 |
+
"train_loss": 0.1496,
|
| 67 |
+
"val_loss": 0.1519
|
| 68 |
+
},
|
| 69 |
+
{
|
| 70 |
+
"epoch": 6,
|
| 71 |
+
"train_loss": 0.1478,
|
| 72 |
+
"val_loss": 0.1511
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
"epoch": 7,
|
| 76 |
+
"train_loss": 0.146,
|
| 77 |
+
"val_loss": 0.1512
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
"epoch": 8,
|
| 81 |
+
"train_loss": 0.1443,
|
| 82 |
+
"val_loss": 0.152
|
| 83 |
+
},
|
| 84 |
+
{
|
| 85 |
+
"epoch": 9,
|
| 86 |
+
"train_loss": 0.1428,
|
| 87 |
+
"val_loss": 0.152
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
"epoch": 10,
|
| 91 |
+
"train_loss": 0.1413,
|
| 92 |
+
"val_loss": 0.152
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
"epoch": 11,
|
| 96 |
+
"train_loss": 0.1399,
|
| 97 |
+
"val_loss": 0.1526
|
| 98 |
+
},
|
| 99 |
+
{
|
| 100 |
+
"epoch": 12,
|
| 101 |
+
"train_loss": 0.1386,
|
| 102 |
+
"val_loss": 0.1544
|
| 103 |
+
},
|
| 104 |
+
{
|
| 105 |
+
"epoch": 13,
|
| 106 |
+
"train_loss": 0.137,
|
| 107 |
+
"val_loss": 0.1536
|
| 108 |
+
},
|
| 109 |
+
{
|
| 110 |
+
"epoch": 14,
|
| 111 |
+
"train_loss": 0.1356,
|
| 112 |
+
"val_loss": 0.1547
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
"epoch": 15,
|
| 116 |
+
"train_loss": 0.134,
|
| 117 |
+
"val_loss": 0.1559
|
| 118 |
+
}
|
| 119 |
+
],
|
| 120 |
+
"per_class_auroc": {
|
| 121 |
+
"Atelectasis": 0.787,
|
| 122 |
+
"Cardiomegaly": 0.894,
|
| 123 |
+
"Effusion": 0.8679,
|
| 124 |
+
"Infiltration": 0.6993,
|
| 125 |
+
"Mass": 0.7819,
|
| 126 |
+
"Nodule": 0.7006,
|
| 127 |
+
"Pneumonia": 0.7558,
|
| 128 |
+
"Pneumothorax": 0.8535,
|
| 129 |
+
"Consolidation": 0.7868,
|
| 130 |
+
"Edema": 0.8848,
|
| 131 |
+
"Emphysema": 0.873,
|
| 132 |
+
"Fibrosis": 0.8002,
|
| 133 |
+
"Pleural_Thickening": 0.7577,
|
| 134 |
+
"Hernia": 0.8757
|
| 135 |
+
},
|
| 136 |
+
"per_class_auprc": {
|
| 137 |
+
"Atelectasis": 0.308,
|
| 138 |
+
"Cardiomegaly": 0.2844,
|
| 139 |
+
"Effusion": 0.5018,
|
| 140 |
+
"Infiltration": 0.3297,
|
| 141 |
+
"Mass": 0.2325,
|
| 142 |
+
"Nodule": 0.1687,
|
| 143 |
+
"Pneumonia": 0.0425,
|
| 144 |
+
"Pneumothorax": 0.2811,
|
| 145 |
+
"Consolidation": 0.1412,
|
| 146 |
+
"Edema": 0.1476,
|
| 147 |
+
"Emphysema": 0.2651,
|
| 148 |
+
"Fibrosis": 0.0952,
|
| 149 |
+
"Pleural_Thickening": 0.1069,
|
| 150 |
+
"Hernia": 0.0419
|
| 151 |
+
},
|
| 152 |
+
"per_class_f1": {
|
| 153 |
+
"Atelectasis": 0.3587,
|
| 154 |
+
"Cardiomegaly": 0.3368,
|
| 155 |
+
"Effusion": 0.5178,
|
| 156 |
+
"Infiltration": 0.3906,
|
| 157 |
+
"Mass": 0.2883,
|
| 158 |
+
"Nodule": 0.2232,
|
| 159 |
+
"Pneumonia": 0.0,
|
| 160 |
+
"Pneumothorax": 0.3755,
|
| 161 |
+
"Consolidation": 0.2189,
|
| 162 |
+
"Edema": 0.215,
|
| 163 |
+
"Emphysema": 0.317,
|
| 164 |
+
"Fibrosis": 0.1239,
|
| 165 |
+
"Pleural_Thickening": 0.1788,
|
| 166 |
+
"Hernia": 0.0
|
| 167 |
+
},
|
| 168 |
+
"test_macro_roc_auc": 0.8084,
|
| 169 |
+
"test_macro_auprc": 0.2105,
|
| 170 |
+
"test_micro_f1": 0.3425,
|
| 171 |
+
"test_macro_f1": 0.2532
|
| 172 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
datasets==3.6.0
|
| 2 |
+
fastapi==0.115.12
|
| 3 |
+
gradio==5.25.2
|
| 4 |
+
huggingface_hub==0.31.2
|
| 5 |
+
matplotlib==3.10.1
|
| 6 |
+
medmnist==3.0.2
|
| 7 |
+
numpy==1.26.4
|
| 8 |
+
onnx==1.17.0
|
| 9 |
+
onnxruntime==1.21.0
|
| 10 |
+
Pillow==11.1.0
|
| 11 |
+
prometheus-client==0.21.1
|
| 12 |
+
python-multipart==0.0.20
|
| 13 |
+
requests==2.32.3
|
| 14 |
+
scikit-learn==1.6.1
|
| 15 |
+
scipy==1.15.2
|
| 16 |
+
seaborn==0.13.2
|
| 17 |
+
torch==2.6.0
|
| 18 |
+
torchvision==0.21.0
|
| 19 |
+
uvicorn[standard]==0.34.1
|
scripts/train_chestmnist.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Train MobileNetV3-small on ChestMNIST (14-class multi-label).
|
| 3 |
+
Outputs:
|
| 4 |
+
models/chestmnist_mobilenetv3/
|
| 5 |
+
mobilenetv3_chestmnist.pth β PyTorch checkpoint (Model A)
|
| 6 |
+
mobilenetv3_chestmnist.onnx β ONNX export (Model B)
|
| 7 |
+
training_metrics.json
|
| 8 |
+
baseline_stats.json β pixel stats for drift detection
|
| 9 |
+
|
| 10 |
+
Usage (quick, shared-server-safe):
|
| 11 |
+
python3 scripts/train_chestmnist.py --epochs 5 --batch-size 32
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import argparse
|
| 15 |
+
import json
|
| 16 |
+
import logging
|
| 17 |
+
import time
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
import matplotlib
|
| 21 |
+
matplotlib.use("Agg")
|
| 22 |
+
import matplotlib.pyplot as plt
|
| 23 |
+
|
| 24 |
+
import numpy as np
|
| 25 |
+
import onnx
|
| 26 |
+
import torch
|
| 27 |
+
import torch.nn as nn
|
| 28 |
+
import torch.onnx
|
| 29 |
+
from PIL import Image
|
| 30 |
+
from sklearn.metrics import (
|
| 31 |
+
average_precision_score,
|
| 32 |
+
f1_score,
|
| 33 |
+
roc_auc_score,
|
| 34 |
+
)
|
| 35 |
+
from torch.utils.data import DataLoader
|
| 36 |
+
from torchvision import transforms
|
| 37 |
+
from torchvision.models import MobileNet_V3_Small_Weights, mobilenet_v3_small
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
import medmnist
|
| 41 |
+
from medmnist import ChestMNIST, INFO
|
| 42 |
+
except ImportError as exc:
|
| 43 |
+
raise SystemExit("medmnist not installed β run: pip install medmnist") from exc
|
| 44 |
+
|
| 45 |
+
BASE_DIR = Path(__file__).resolve().parents[1]
|
| 46 |
+
OUTPUT_DIR = BASE_DIR / "models" / "chestmnist_mobilenetv3"
|
| 47 |
+
|
| 48 |
+
CHESTMNIST_CLASSES = [
|
| 49 |
+
"Atelectasis", "Cardiomegaly", "Effusion", "Infiltration",
|
| 50 |
+
"Mass", "Nodule", "Pneumonia", "Pneumothorax",
|
| 51 |
+
"Consolidation", "Edema", "Emphysema", "Fibrosis",
|
| 52 |
+
"Pleural_Thickening", "Hernia",
|
| 53 |
+
]
|
| 54 |
+
NUM_CLASSES = 14
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def get_transforms(image_size: int = 224):
|
| 58 |
+
train_tf = transforms.Compose([
|
| 59 |
+
transforms.Resize((image_size, image_size)),
|
| 60 |
+
transforms.RandomHorizontalFlip(),
|
| 61 |
+
transforms.ColorJitter(brightness=0.2, contrast=0.2),
|
| 62 |
+
transforms.ToTensor(),
|
| 63 |
+
transforms.Normalize([0.5] * 3, [0.5] * 3),
|
| 64 |
+
])
|
| 65 |
+
val_tf = transforms.Compose([
|
| 66 |
+
transforms.Resize((image_size, image_size)),
|
| 67 |
+
transforms.ToTensor(),
|
| 68 |
+
transforms.Normalize([0.5] * 3, [0.5] * 3),
|
| 69 |
+
])
|
| 70 |
+
return train_tf, val_tf
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def build_model(num_classes: int = NUM_CLASSES) -> nn.Module:
|
| 74 |
+
model = mobilenet_v3_small(weights=MobileNet_V3_Small_Weights.IMAGENET1K_V1)
|
| 75 |
+
in_features = model.classifier[3].in_features
|
| 76 |
+
model.classifier[3] = nn.Linear(in_features, num_classes)
|
| 77 |
+
return model
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def load_chestmnist(split: str, transform, download: bool, size: int = 224,
|
| 81 |
+
max_samples: int | None = None):
|
| 82 |
+
ds = ChestMNIST(split=split, transform=transform, download=download, size=size, as_rgb=True)
|
| 83 |
+
if max_samples and len(ds) > max_samples:
|
| 84 |
+
indices = list(range(max_samples))
|
| 85 |
+
from torch.utils.data import Subset
|
| 86 |
+
ds = Subset(ds, indices)
|
| 87 |
+
return ds
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def compute_baseline_stats(loader: DataLoader) -> dict:
|
| 91 |
+
"""Compute pixel mean/std from training set for drift detection."""
|
| 92 |
+
pixels = []
|
| 93 |
+
for images, _ in loader:
|
| 94 |
+
pixels.append(images.numpy())
|
| 95 |
+
if len(pixels) >= 20: # Sample first 20 batches
|
| 96 |
+
break
|
| 97 |
+
arr = np.concatenate(pixels, axis=0) # (N, C, H, W)
|
| 98 |
+
flat = arr.reshape(arr.shape[0], -1)
|
| 99 |
+
return {
|
| 100 |
+
"pixel_mean": float(flat.mean()),
|
| 101 |
+
"pixel_std": float(flat.std()),
|
| 102 |
+
"channel_means": arr.mean(axis=(0, 2, 3)).tolist(),
|
| 103 |
+
"channel_stds": arr.std(axis=(0, 2, 3)).tolist(),
|
| 104 |
+
"n_samples": int(arr.shape[0]),
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def tune_thresholds(model: nn.Module, loader: DataLoader, device: torch.device) -> list[float]:
|
| 109 |
+
"""Best-F1 threshold per class on validation set."""
|
| 110 |
+
model.eval()
|
| 111 |
+
all_probs, all_labels = [], []
|
| 112 |
+
with torch.no_grad():
|
| 113 |
+
for images, labels in loader:
|
| 114 |
+
images = images.to(device)
|
| 115 |
+
logits = model(images)
|
| 116 |
+
probs = torch.sigmoid(logits).cpu().numpy()
|
| 117 |
+
all_probs.append(probs)
|
| 118 |
+
all_labels.append(labels.numpy().astype(float))
|
| 119 |
+
|
| 120 |
+
probs = np.vstack(all_probs)
|
| 121 |
+
labels = np.vstack(all_labels)
|
| 122 |
+
thresholds = []
|
| 123 |
+
for i in range(NUM_CLASSES):
|
| 124 |
+
best_t, best_f1 = 0.5, 0.0
|
| 125 |
+
for t in np.arange(0.10, 0.90, 0.05):
|
| 126 |
+
preds = (probs[:, i] >= t).astype(int)
|
| 127 |
+
f = f1_score(labels[:, i], preds, zero_division=0)
|
| 128 |
+
if f > best_f1:
|
| 129 |
+
best_f1, best_t = f, t
|
| 130 |
+
thresholds.append(round(float(best_t), 3))
|
| 131 |
+
return thresholds
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def evaluate(model: nn.Module, loader: DataLoader, device: torch.device,
|
| 135 |
+
thresholds: list[float]) -> dict:
|
| 136 |
+
model.eval()
|
| 137 |
+
all_probs, all_labels = [], []
|
| 138 |
+
with torch.no_grad():
|
| 139 |
+
for images, labels in loader:
|
| 140 |
+
images = images.to(device)
|
| 141 |
+
logits = model(images)
|
| 142 |
+
probs = torch.sigmoid(logits).cpu().numpy()
|
| 143 |
+
all_probs.append(probs)
|
| 144 |
+
all_labels.append(labels.numpy().astype(float))
|
| 145 |
+
|
| 146 |
+
probs = np.vstack(all_probs)
|
| 147 |
+
labels = np.vstack(all_labels)
|
| 148 |
+
|
| 149 |
+
thr_arr = np.array(thresholds)
|
| 150 |
+
preds = (probs >= thr_arr).astype(int)
|
| 151 |
+
|
| 152 |
+
per_class_auroc, per_class_auprc, per_class_f1 = {}, {}, {}
|
| 153 |
+
for i, cls in enumerate(CHESTMNIST_CLASSES):
|
| 154 |
+
if labels[:, i].sum() > 0:
|
| 155 |
+
per_class_auroc[cls] = round(float(roc_auc_score(labels[:, i], probs[:, i])), 4)
|
| 156 |
+
per_class_auprc[cls] = round(float(average_precision_score(labels[:, i], probs[:, i])), 4)
|
| 157 |
+
else:
|
| 158 |
+
per_class_auroc[cls] = None
|
| 159 |
+
per_class_auprc[cls] = None
|
| 160 |
+
per_class_f1[cls] = round(float(f1_score(labels[:, i], preds[:, i], zero_division=0)), 4)
|
| 161 |
+
|
| 162 |
+
macro_auroc_vals = [v for v in per_class_auroc.values() if v is not None]
|
| 163 |
+
macro_auprc_vals = [v for v in per_class_auprc.values() if v is not None]
|
| 164 |
+
|
| 165 |
+
return {
|
| 166 |
+
"per_class_auroc": per_class_auroc,
|
| 167 |
+
"per_class_auprc": per_class_auprc,
|
| 168 |
+
"per_class_f1": per_class_f1,
|
| 169 |
+
"test_macro_roc_auc": round(float(np.mean(macro_auroc_vals)), 4) if macro_auroc_vals else None,
|
| 170 |
+
"test_macro_auprc": round(float(np.mean(macro_auprc_vals)), 4) if macro_auprc_vals else None,
|
| 171 |
+
"test_micro_f1": round(float(f1_score(labels, preds, average="micro", zero_division=0)), 4),
|
| 172 |
+
"test_macro_f1": round(float(f1_score(labels, preds, average="macro", zero_division=0)), 4),
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def export_onnx(model: nn.Module, output_path: Path, image_size: int, device: torch.device):
|
| 177 |
+
model.eval()
|
| 178 |
+
dummy = torch.randn(1, 3, image_size, image_size).to(device)
|
| 179 |
+
torch.onnx.export(
|
| 180 |
+
model, dummy, str(output_path),
|
| 181 |
+
input_names=["input"], output_names=["logits"],
|
| 182 |
+
dynamic_axes={"input": {0: "batch_size"}, "logits": {0: "batch_size"}},
|
| 183 |
+
opset_version=17,
|
| 184 |
+
)
|
| 185 |
+
onnx.checker.check_model(str(output_path))
|
| 186 |
+
print(f" ONNX saved β {output_path}")
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def train_epoch(model: nn.Module, loader: DataLoader,
|
| 190 |
+
optimizer: torch.optim.Optimizer,
|
| 191 |
+
criterion: nn.Module, device: torch.device) -> float:
|
| 192 |
+
model.train()
|
| 193 |
+
total_loss = 0.0
|
| 194 |
+
for images, labels in loader:
|
| 195 |
+
images, labels = images.to(device), labels.float().to(device)
|
| 196 |
+
optimizer.zero_grad()
|
| 197 |
+
loss = criterion(model(images), labels)
|
| 198 |
+
loss.backward()
|
| 199 |
+
optimizer.step()
|
| 200 |
+
total_loss += loss.item()
|
| 201 |
+
return total_loss / max(len(loader), 1)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def save_training_plots(history: list[dict], test_metrics: dict, output_dir: Path):
|
| 205 |
+
plots_dir = output_dir / "plots"
|
| 206 |
+
plots_dir.mkdir(parents=True, exist_ok=True)
|
| 207 |
+
|
| 208 |
+
# 1. Learning Curve
|
| 209 |
+
epochs = [h["epoch"] for h in history]
|
| 210 |
+
train_loss = [h["train_loss"] for h in history]
|
| 211 |
+
val_loss = [h["val_loss"] for h in history]
|
| 212 |
+
|
| 213 |
+
plt.figure(figsize=(8, 5))
|
| 214 |
+
plt.plot(epochs, train_loss, label="Train Loss", marker="o", color="#2563eb")
|
| 215 |
+
plt.plot(epochs, val_loss, label="Val Loss", marker="o", color="#dc2626")
|
| 216 |
+
plt.title("Training & Validation Loss Curve")
|
| 217 |
+
plt.xlabel("Epoch")
|
| 218 |
+
plt.ylabel("BCE Loss")
|
| 219 |
+
plt.grid(True, alpha=0.3)
|
| 220 |
+
plt.legend()
|
| 221 |
+
plt.tight_layout()
|
| 222 |
+
plt.savefig(plots_dir / "loss_curve.png", dpi=150)
|
| 223 |
+
plt.close()
|
| 224 |
+
|
| 225 |
+
# 2. Per-class metrics bar chart
|
| 226 |
+
auroc = test_metrics["per_class_auroc"]
|
| 227 |
+
auprc = test_metrics["per_class_auprc"]
|
| 228 |
+
labels = [k for k in auroc.keys() if auroc[k] is not None]
|
| 229 |
+
auroc_vals = [auroc[k] for k in labels]
|
| 230 |
+
auprc_vals = [auprc[k] for k in labels]
|
| 231 |
+
|
| 232 |
+
y = np.arange(len(labels))
|
| 233 |
+
fig, ax = plt.subplots(figsize=(10, 8))
|
| 234 |
+
ax.barh(y - 0.2, auroc_vals, height=0.4, label="AUROC", color="#3b82f6")
|
| 235 |
+
ax.barh(y + 0.2, auprc_vals, height=0.4, label="AUPRC", color="#10b981")
|
| 236 |
+
ax.set_yticks(y)
|
| 237 |
+
ax.set_yticklabels(labels, fontweight="bold")
|
| 238 |
+
ax.set_xlim(0, 1.05)
|
| 239 |
+
ax.set_title("Per-Class AUROC & AUPRC", fontweight="bold")
|
| 240 |
+
ax.grid(axis="x", alpha=0.3)
|
| 241 |
+
ax.legend()
|
| 242 |
+
plt.tight_layout()
|
| 243 |
+
plt.savefig(plots_dir / "per_class_metrics.png", dpi=150)
|
| 244 |
+
plt.close()
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def main():
|
| 248 |
+
parser = argparse.ArgumentParser(description="Train MobileNetV3-small on ChestMNIST (14-class multi-label)")
|
| 249 |
+
parser.add_argument("--epochs", type=int, default=15)
|
| 250 |
+
parser.add_argument("--batch-size", type=int, default=32)
|
| 251 |
+
parser.add_argument("--lr", type=float, default=1e-3)
|
| 252 |
+
parser.add_argument("--image-size", type=int, default=224)
|
| 253 |
+
parser.add_argument("--workers", type=int, default=2)
|
| 254 |
+
parser.add_argument("--max-train-samples", type=int, default=None)
|
| 255 |
+
parser.add_argument("--max-val-samples", type=int, default=None)
|
| 256 |
+
parser.add_argument("--max-test-samples", type=int, default=None)
|
| 257 |
+
parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
|
| 258 |
+
parser.add_argument("--no-download", action="store_true")
|
| 259 |
+
args = parser.parse_args()
|
| 260 |
+
|
| 261 |
+
args.output_dir.mkdir(parents=True, exist_ok=True)
|
| 262 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 263 |
+
print(f"Device: {device}")
|
| 264 |
+
print(f"Output: {args.output_dir}")
|
| 265 |
+
|
| 266 |
+
train_tf, val_tf = get_transforms(args.image_size)
|
| 267 |
+
download = not args.no_download
|
| 268 |
+
|
| 269 |
+
print("Loading ChestMNISTβ¦")
|
| 270 |
+
train_ds = load_chestmnist("train", train_tf, download, args.image_size, args.max_train_samples)
|
| 271 |
+
val_ds = load_chestmnist("val", val_tf, download, args.image_size, args.max_val_samples)
|
| 272 |
+
test_ds = load_chestmnist("test", val_tf, download, args.image_size, args.max_test_samples)
|
| 273 |
+
print(f" Train: {len(train_ds)} Val: {len(val_ds)} Test: {len(test_ds)}")
|
| 274 |
+
|
| 275 |
+
train_loader = DataLoader(train_ds, batch_size=args.batch_size, shuffle=True,
|
| 276 |
+
num_workers=args.workers, pin_memory=True)
|
| 277 |
+
val_loader = DataLoader(val_ds, batch_size=64, shuffle=False, num_workers=args.workers)
|
| 278 |
+
test_loader = DataLoader(test_ds, batch_size=64, shuffle=False, num_workers=args.workers)
|
| 279 |
+
|
| 280 |
+
# Baseline pixel stats for drift detection
|
| 281 |
+
print("Computing baseline statsβ¦")
|
| 282 |
+
baseline_stats = compute_baseline_stats(train_loader)
|
| 283 |
+
(args.output_dir / "baseline_stats.json").write_text(
|
| 284 |
+
json.dumps(baseline_stats, indent=2), encoding="utf-8"
|
| 285 |
+
)
|
| 286 |
+
print(f" Mean={baseline_stats['pixel_mean']:.4f} Std={baseline_stats['pixel_std']:.4f}")
|
| 287 |
+
|
| 288 |
+
model = build_model(NUM_CLASSES).to(device)
|
| 289 |
+
criterion = nn.BCEWithLogitsLoss()
|
| 290 |
+
optimizer = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=1e-4)
|
| 291 |
+
scheduler = torch.optim.lr_scheduler.OneCycleLR(
|
| 292 |
+
optimizer, max_lr=args.lr,
|
| 293 |
+
steps_per_epoch=len(train_loader), epochs=args.epochs,
|
| 294 |
+
)
|
| 295 |
+
|
| 296 |
+
best_val_loss = float("inf")
|
| 297 |
+
history = []
|
| 298 |
+
|
| 299 |
+
print(f"\nTraining {args.epochs} epochsβ¦")
|
| 300 |
+
for epoch in range(1, args.epochs + 1):
|
| 301 |
+
t0 = time.time()
|
| 302 |
+
train_loss = train_epoch(model, train_loader, optimizer, criterion, device)
|
| 303 |
+
scheduler.step()
|
| 304 |
+
|
| 305 |
+
# Quick val loss
|
| 306 |
+
model.eval()
|
| 307 |
+
val_loss = 0.0
|
| 308 |
+
with torch.no_grad():
|
| 309 |
+
for imgs, lbls in val_loader:
|
| 310 |
+
imgs, lbls = imgs.to(device), lbls.float().to(device)
|
| 311 |
+
val_loss += criterion(model(imgs), lbls).item()
|
| 312 |
+
val_loss /= max(len(val_loader), 1)
|
| 313 |
+
|
| 314 |
+
elapsed = time.time() - t0
|
| 315 |
+
print(f" Epoch {epoch}/{args.epochs} β train_loss={train_loss:.4f} val_loss={val_loss:.4f} ({elapsed:.1f}s)")
|
| 316 |
+
history.append({"epoch": epoch, "train_loss": round(train_loss, 4), "val_loss": round(val_loss, 4)})
|
| 317 |
+
|
| 318 |
+
if val_loss < best_val_loss:
|
| 319 |
+
best_val_loss = val_loss
|
| 320 |
+
torch.save(model.state_dict(), args.output_dir / "mobilenetv3_chestmnist.pth")
|
| 321 |
+
print(" β checkpoint saved")
|
| 322 |
+
|
| 323 |
+
# Reload best checkpoint
|
| 324 |
+
model.load_state_dict(torch.load(args.output_dir / "mobilenetv3_chestmnist.pth", map_location=device))
|
| 325 |
+
|
| 326 |
+
# Threshold tuning on val set
|
| 327 |
+
print("\nTuning thresholds on validation setβ¦")
|
| 328 |
+
thresholds = tune_thresholds(model, val_loader, device)
|
| 329 |
+
print(f" Thresholds: {thresholds}")
|
| 330 |
+
|
| 331 |
+
# Final evaluation on test set
|
| 332 |
+
print("\nEvaluating on test setβ¦")
|
| 333 |
+
test_metrics = evaluate(model, test_loader, device, thresholds)
|
| 334 |
+
|
| 335 |
+
# Save training_metrics.json
|
| 336 |
+
training_metrics = {
|
| 337 |
+
"architecture": "MobileNetV3-small",
|
| 338 |
+
"dataset": "ChestMNIST",
|
| 339 |
+
"class_names": CHESTMNIST_CLASSES,
|
| 340 |
+
"num_classes": NUM_CLASSES,
|
| 341 |
+
"epochs": args.epochs,
|
| 342 |
+
"batch_size": args.batch_size,
|
| 343 |
+
"learning_rate": args.lr,
|
| 344 |
+
"image_size": args.image_size,
|
| 345 |
+
"thresholds": thresholds,
|
| 346 |
+
"multi_label": True,
|
| 347 |
+
"best_val_loss": round(best_val_loss, 4),
|
| 348 |
+
"history": history,
|
| 349 |
+
**test_metrics,
|
| 350 |
+
}
|
| 351 |
+
(args.output_dir / "training_metrics.json").write_text(
|
| 352 |
+
json.dumps(training_metrics, indent=2), encoding="utf-8"
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
# Save Professional Plots
|
| 356 |
+
print("\nGenerating training & evaluation plotsβ¦")
|
| 357 |
+
save_training_plots(history, test_metrics, args.output_dir)
|
| 358 |
+
print(f"\n Macro AUROC : {test_metrics['test_macro_roc_auc']}")
|
| 359 |
+
print(f" Macro AUPRC : {test_metrics['test_macro_auprc']}")
|
| 360 |
+
print(f" Micro F1 : {test_metrics['test_micro_f1']}")
|
| 361 |
+
|
| 362 |
+
# ONNX Export
|
| 363 |
+
onnx_path = args.output_dir / "mobilenetv3_chestmnist.onnx"
|
| 364 |
+
print(f"\nExporting ONNX β {onnx_path}")
|
| 365 |
+
export_onnx(model, onnx_path, args.image_size, device)
|
| 366 |
+
|
| 367 |
+
# ONNX export report (for backend resolver)
|
| 368 |
+
onnx_report = {
|
| 369 |
+
"base_onnx": str(onnx_path.name),
|
| 370 |
+
"optimized_onnx": str(onnx_path.name),
|
| 371 |
+
"serving_onnx": str(onnx_path.name),
|
| 372 |
+
"input_shape": [1, 3, args.image_size, args.image_size],
|
| 373 |
+
}
|
| 374 |
+
(args.output_dir / "onnx_export_report.json").write_text(
|
| 375 |
+
json.dumps(onnx_report, indent=2), encoding="utf-8"
|
| 376 |
+
)
|
| 377 |
+
|
| 378 |
+
print(f"\nβ
All artifacts saved to {args.output_dir}")
|
| 379 |
+
print(" Model A (PyTorch) : mobilenetv3_chestmnist.pth")
|
| 380 |
+
print(" Model B (ONNX) : mobilenetv3_chestmnist.onnx")
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
if __name__ == "__main__":
|
| 384 |
+
main()
|
scripts/upload_to_hf.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PneumoOps β Hugging Face Model Hub Upload Script
|
| 3 |
+
=================================================
|
| 4 |
+
Uploads both model artifacts to the HF Model Hub for versioning.
|
| 5 |
+
Run this once after training, and again whenever you retrain.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
huggingface-cli login # one-time login
|
| 9 |
+
python scripts/upload_to_hf.py
|
| 10 |
+
|
| 11 |
+
Environment variables (override defaults):
|
| 12 |
+
HF_MODEL_REPO your-username/pneumoops-chestmnist
|
| 13 |
+
HF_TOKEN your write token (if not logged in via CLI)
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
import json
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from huggingface_hub import HfApi, upload_file, create_repo
|
| 20 |
+
|
| 21 |
+
# βββ Config βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 22 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 23 |
+
MODEL_DIR = ROOT / "models" / "chestmnist_mobilenetv3"
|
| 24 |
+
|
| 25 |
+
HF_TOKEN = os.getenv("HF_TOKEN") # optional if already logged in via CLI
|
| 26 |
+
MODEL_REPO = os.getenv("HF_MODEL_REPO", "") # e.g. "your-username/pneumoops-chestmnist"
|
| 27 |
+
|
| 28 |
+
if not MODEL_REPO:
|
| 29 |
+
print("\nβ οΈ Please set HF_MODEL_REPO environment variable, e.g.:")
|
| 30 |
+
print(' export HF_MODEL_REPO="your-hf-username/pneumoops-chestmnist"')
|
| 31 |
+
raise SystemExit(1)
|
| 32 |
+
|
| 33 |
+
# βββ Files to upload ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 34 |
+
ARTIFACTS = [
|
| 35 |
+
("mobilenetv3_chestmnist.pth", "Model A β Baseline PyTorch checkpoint"),
|
| 36 |
+
("mobilenetv3_chestmnist.onnx", "Model B β Optimized ONNX artifact"),
|
| 37 |
+
("training_metrics.json", "Training + evaluation metrics (AUROC, AUPRC, F1)"),
|
| 38 |
+
("baseline_stats.json", "Pixel distribution stats for drift monitoring"),
|
| 39 |
+
("onnx_export_report.json", "ONNX export configuration"),
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
# βββ Model Card βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 43 |
+
MODEL_CARD = """---
|
| 44 |
+
license: mit
|
| 45 |
+
tags:
|
| 46 |
+
- medical
|
| 47 |
+
- image-classification
|
| 48 |
+
- chest-xray
|
| 49 |
+
- mlops
|
| 50 |
+
- onnx
|
| 51 |
+
- pytorch
|
| 52 |
+
- mobilenetv3
|
| 53 |
+
datasets:
|
| 54 |
+
- medmnist/chestmnist
|
| 55 |
+
metrics:
|
| 56 |
+
- roc_auc
|
| 57 |
+
---
|
| 58 |
+
|
| 59 |
+
# PneumoOps β ChestMNIST MobileNetV3-small
|
| 60 |
+
|
| 61 |
+
This repository contains **two model versions** for the PneumoOps MLOps pipeline:
|
| 62 |
+
- **Model A (Baseline):** `mobilenetv3_chestmnist.pth` β Standard PyTorch checkpoint
|
| 63 |
+
- **Model B (Optimized):** `mobilenetv3_chestmnist.onnx` β ONNX-exported for faster inference
|
| 64 |
+
|
| 65 |
+
Both models are identical in architecture (MobileNetV3-small) and weights.
|
| 66 |
+
The ONNX version is used for inference time optimization in A/B testing.
|
| 67 |
+
|
| 68 |
+
## Dataset
|
| 69 |
+
**ChestMNIST** β 14-class multi-label chest X-ray classification
|
| 70 |
+
78,468 training images, 224Γ224 pixels, grayscale (converted to 3-channel).
|
| 71 |
+
|
| 72 |
+
## Classes (14)
|
| 73 |
+
Atelectasis, Cardiomegaly, Effusion, Infiltration, Mass, Nodule, Pneumonia,
|
| 74 |
+
Pneumothorax, Consolidation, Edema, Emphysema, Fibrosis, Pleural Thickening, Hernia
|
| 75 |
+
|
| 76 |
+
## Performance (Test Set)
|
| 77 |
+
| Metric | Score |
|
| 78 |
+
|--------|-------|
|
| 79 |
+
| Macro AUROC | **0.808** |
|
| 80 |
+
| Macro AUPRC | 0.210 |
|
| 81 |
+
| Micro F1 | 0.343 |
|
| 82 |
+
|
| 83 |
+
## Usage in PneumoOps
|
| 84 |
+
These artifacts are loaded by the FastAPI backend and selected via a weighted A/B router:
|
| 85 |
+
- 60% of requests β PyTorch model
|
| 86 |
+
- 40% of requests β ONNX model
|
| 87 |
+
|
| 88 |
+
The backend also computes a drift score using `baseline_stats.json` to detect
|
| 89 |
+
out-of-distribution inputs in real time.
|
| 90 |
+
"""
|
| 91 |
+
|
| 92 |
+
# βββ Main βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 93 |
+
|
| 94 |
+
def main():
|
| 95 |
+
api = HfApi(token=HF_TOKEN)
|
| 96 |
+
|
| 97 |
+
print(f"\nπ¦ Creating/verifying model repository: {MODEL_REPO}")
|
| 98 |
+
create_repo(
|
| 99 |
+
repo_id=MODEL_REPO,
|
| 100 |
+
repo_type="model",
|
| 101 |
+
exist_ok=True,
|
| 102 |
+
token=HF_TOKEN,
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
# Write model card
|
| 106 |
+
card_path = MODEL_DIR / "README.md"
|
| 107 |
+
card_path.write_text(MODEL_CARD, encoding="utf-8")
|
| 108 |
+
print(" Model card written.")
|
| 109 |
+
|
| 110 |
+
# Upload model card first
|
| 111 |
+
print(f"\nβ¬οΈ Uploading artifacts to https://huggingface.co/{MODEL_REPO}")
|
| 112 |
+
upload_file(
|
| 113 |
+
path_or_fileobj=str(card_path),
|
| 114 |
+
path_in_repo="README.md",
|
| 115 |
+
repo_id=MODEL_REPO,
|
| 116 |
+
repo_type="model",
|
| 117 |
+
commit_message="Add model card",
|
| 118 |
+
token=HF_TOKEN,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
# Upload all artifacts
|
| 122 |
+
for filename, description in ARTIFACTS:
|
| 123 |
+
local_path = MODEL_DIR / filename
|
| 124 |
+
if not local_path.exists():
|
| 125 |
+
print(f" β οΈ Skipping {filename} β file not found")
|
| 126 |
+
continue
|
| 127 |
+
size_mb = local_path.stat().st_size / (1024 * 1024)
|
| 128 |
+
print(f" Uploading {filename} ({size_mb:.1f} MB) β {description} ...")
|
| 129 |
+
upload_file(
|
| 130 |
+
path_or_fileobj=str(local_path),
|
| 131 |
+
path_in_repo=filename,
|
| 132 |
+
repo_id=MODEL_REPO,
|
| 133 |
+
repo_type="model",
|
| 134 |
+
commit_message=f"Upload {filename}",
|
| 135 |
+
token=HF_TOKEN,
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
print(f"\nβ
All artifacts uploaded!")
|
| 139 |
+
print(f" View at: https://huggingface.co/{MODEL_REPO}")
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
if __name__ == "__main__":
|
| 143 |
+
main()
|