Prakhar54-byte commited on
Commit
2e552e9
Β·
verified Β·
1 Parent(s): 1e3caac

Deploy build-99deee4

Browse files
.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: Pneumoops
3
- emoji: πŸ‘€
4
- colorFrom: purple
5
  colorTo: indigo
6
  sdk: docker
7
- pinned: false
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ [![CI](https://github.com/Prakhar54-byte/PneumoOps/actions/workflows/deploy.yml/badge.svg)](https://github.com/Prakhar54-byte/PneumoOps/actions)
18
+ [![Python 3.11](https://img.shields.io/badge/python-3.11-blue)](https://www.python.org/)
19
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](#)
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()