Spaces:
Runtime error
Runtime error
Upload folder using huggingface_hub
Browse files- .dockerignore +5 -0
- .gitignore +46 -0
- README.md +148 -10
- backend/Dockerfile +8 -0
- backend/alerts.log +9 -0
- backend/database.py +98 -0
- backend/ensemble.py +48 -0
- backend/main.py +501 -0
- backend/models.py +39 -0
- backend/patient_history.db +0 -0
- backend/preprocessing.py +62 -0
- backend/requirements.txt +11 -0
- data/patient_dataset.csv +0 -0
- docker-compose.yml +23 -0
- frontend/.streamlit/config.toml +6 -0
- frontend/Dockerfile +11 -0
- frontend/Home.py +298 -0
- frontend/pages/1_Predict_Dashboard.py +324 -0
- frontend/pages/2_Data_Analytics.py +171 -0
- frontend/pages/3_Edge_Deployment.py +87 -0
- frontend/pages/4_Patient_History.py +65 -0
- frontend/pages/5_MLOps_Settings.py +30 -0
- frontend/pages/6_About.py +124 -0
- frontend/requirements.txt +9 -0
- requirements.txt +11 -11
- run.bat +18 -0
- tests/test_backend.py +27 -0
.dockerignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
venv/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
.pytest_cache/
|
| 5 |
+
.DS_Store
|
.gitignore
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Byte-compiled / optimized / DLL files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
|
| 6 |
+
# C extensions
|
| 7 |
+
*.so
|
| 8 |
+
|
| 9 |
+
# Distribution / packaging
|
| 10 |
+
.Python
|
| 11 |
+
build/
|
| 12 |
+
develop-eggs/
|
| 13 |
+
dist/
|
| 14 |
+
downloads/
|
| 15 |
+
eggs/
|
| 16 |
+
.eggs/
|
| 17 |
+
lib/
|
| 18 |
+
lib64/
|
| 19 |
+
parts/
|
| 20 |
+
sdist/
|
| 21 |
+
var/
|
| 22 |
+
wheels/
|
| 23 |
+
share/python-wheels/
|
| 24 |
+
*.egg-info/
|
| 25 |
+
.installed.cfg
|
| 26 |
+
*.egg
|
| 27 |
+
MANIFEST
|
| 28 |
+
|
| 29 |
+
# Environment
|
| 30 |
+
.env
|
| 31 |
+
.venv
|
| 32 |
+
env/
|
| 33 |
+
venv/
|
| 34 |
+
ENV/
|
| 35 |
+
env.bak/
|
| 36 |
+
venv.bak/
|
| 37 |
+
|
| 38 |
+
# Streamlit
|
| 39 |
+
.streamlit/credentials.toml
|
| 40 |
+
|
| 41 |
+
# IDEs
|
| 42 |
+
.vscode/
|
| 43 |
+
.idea/
|
| 44 |
+
|
| 45 |
+
# macOS
|
| 46 |
+
.DS_Store
|
README.md
CHANGED
|
@@ -1,10 +1,148 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TinyML Heart Health Monitoring Dashboard
|
| 2 |
+
|
| 3 |
+
A production-ready application for real-time heart health analytics, ensemble prediction modeling, and automated C-code transpilation for resource-constrained embedded systems.
|
| 4 |
+
|
| 5 |
+
**Live Environments:**
|
| 6 |
+
- 🖥️ **Frontend UI:** [Streamlit Cloud](https://tinyml-heart-health-monitoring-dashboard-8xqogy2hibtlayt7popvs.streamlit.app/)
|
| 7 |
+
- ⚙️ **Backend API:** [Hugging Face Spaces](https://huggingface.co/spaces/IDKHowToCodeFr/tinyml-backend)
|
| 8 |
+
|
| 9 |
+
## Overview
|
| 10 |
+
|
| 11 |
+
This application bridges the gap between high-level cloud machine learning and low-level embedded hardware. It evaluates patient vitals using an ensemble of classifiers, provides strict clinical explainability, and compresses these Python-trained models into raw C-code ready to be flashed onto IoT devices.
|
| 12 |
+
|
| 13 |
+
### Key Engineering Features
|
| 14 |
+
|
| 15 |
+
- **Edge Computing & TinyML**: Transpiles complex Scikit-Learn models into highly optimized, dependency-free **C-code headers**. This allows predictive intelligence to be deployed directly to the edge, enabling offline, ultra-low latency, and privacy-preserving inference.
|
| 16 |
+
- **INT8 Quantization Engine**: Mathematically scales 64-bit floating-point weights down to 8-bit integers, effectively shrinking the flash memory payload by **~75%** for resource-constrained microcontrollers.
|
| 17 |
+
- **Dynamic Hardware Profiling**: The deployment suite actively simulates target hardware (e.g., *ESP32, Arduino Nano 33 BLE, Raspberry Pi Pico*) to calculate expected inference latency metrics (in µs) and final byte-size footprints prior to flashing.
|
| 18 |
+
- **Interpretable AI (SHAP)**: Solves the medical "black box" concern by generating real-time SHAP feature impact analyses. This visualization proves exactly *why* the AI assigned a specific risk score based on individual patient variables.
|
| 19 |
+
- **Soft-Voting Ensemble**: Rather than relying on a single algorithm, the backend aggregates outputs across five independent models (Random Forest, SVM, KNN, Logistic Regression, Neural Network) to output the highest-confidence predictions.
|
| 20 |
+
|
| 21 |
+
## Architecture
|
| 22 |
+
|
| 23 |
+
The project follows a decoupled, modular design pattern separating the client interface from the ML processing pipeline.
|
| 24 |
+
|
| 25 |
+
```mermaid
|
| 26 |
+
flowchart TD
|
| 27 |
+
%% Define styles
|
| 28 |
+
classDef frontend fill:#ff4b4b,stroke:#a30000,stroke-width:2px,color:#fff;
|
| 29 |
+
classDef backend fill:#009688,stroke:#004d40,stroke-width:2px,color:#fff;
|
| 30 |
+
classDef ml_node fill:#f2c94c,stroke:#b28900,stroke-width:2px,color:#333;
|
| 31 |
+
classDef edge_tech fill:#2d9cdb,stroke:#106093,stroke-width:2px,color:#fff;
|
| 32 |
+
classDef db fill:#9b59b6,stroke:#4a235a,stroke-width:2px,color:#fff;
|
| 33 |
+
|
| 34 |
+
%% Core Components
|
| 35 |
+
UI[🖥️ Streamlit Frontend<br/>Interactive Dashboard]:::frontend
|
| 36 |
+
API[⚙️ FastAPI Backend<br/>Routing & API Logic]:::backend
|
| 37 |
+
DB[(🗄️ SQLite DB<br/>Patient History)]:::db
|
| 38 |
+
|
| 39 |
+
UI <-->|REST API JSON| API
|
| 40 |
+
API <-->|Read / Write| DB
|
| 41 |
+
|
| 42 |
+
%% ML Subgraph
|
| 43 |
+
subgraph ML [🧠 Machine Learning & Inference Pipeline]
|
| 44 |
+
ENS{Ensemble Engine<br/>Soft-Voting}:::ml_node
|
| 45 |
+
Models[Classifiers:<br/>RF, SVM, LogReg, NN, KNN]:::ml_node
|
| 46 |
+
SHAP[🔍 SHAP Explainer]:::ml_node
|
| 47 |
+
end
|
| 48 |
+
|
| 49 |
+
%% Edge Quantization Subgraph
|
| 50 |
+
subgraph Quantization [⚡ Hardware Export]
|
| 51 |
+
TRANS[C-Code Transpiler<br/>INT8 Quantization]:::edge_tech
|
| 52 |
+
HEADER((tinyml_model.h)):::edge_tech
|
| 53 |
+
MCU>ESP32 / Cortex-M Node]:::edge_tech
|
| 54 |
+
end
|
| 55 |
+
|
| 56 |
+
API -->|Predict Request| ENS
|
| 57 |
+
ENS --> Models
|
| 58 |
+
API -->|Explain Request| SHAP
|
| 59 |
+
SHAP -.-> Models
|
| 60 |
+
API -->|Export Request| TRANS
|
| 61 |
+
TRANS --> HEADER
|
| 62 |
+
HEADER -->|Flash Firmware| MCU
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
### Modular Repository Structure
|
| 66 |
+
|
| 67 |
+
```text
|
| 68 |
+
TinyML-Dashboard/
|
| 69 |
+
├── backend/ # FastAPI server, ML pipelines, SQLite API, C-code logic
|
| 70 |
+
├── frontend/ # Streamlit UI mapping, Plotly charts, web routing
|
| 71 |
+
├── model/ # Local cache of serialized SciKit-Learn .pkl weights
|
| 72 |
+
├── data/ # Patient cohort CSVs for MLOps retraining pipelines
|
| 73 |
+
├── tests/ # Full PyTest suite for backend continuous integration
|
| 74 |
+
├── .github/workflows/ # CI/CD actions for auto-syncing to Hugging Face
|
| 75 |
+
└── docker-compose.yml # Orchestration spec for local containerized deployment
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
## Setup & Installation
|
| 79 |
+
|
| 80 |
+
### Requirements
|
| 81 |
+
- Python 3.9 or higher
|
| 82 |
+
- Git
|
| 83 |
+
|
| 84 |
+
### Installation Steps
|
| 85 |
+
|
| 86 |
+
1. **Clone the repository:**
|
| 87 |
+
```bash
|
| 88 |
+
git clone https://github.com/IDKHowToCodeFR/TinyML-Heart-Health-Monitoring-Dashboard.git
|
| 89 |
+
cd TinyML-Heart-Health-Monitoring-Dashboard
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
2. **Install global requirements:**
|
| 93 |
+
```bash
|
| 94 |
+
pip install -r requirements.txt
|
| 95 |
+
pip install -r backend/requirements.txt
|
| 96 |
+
pip install -r frontend/requirements.txt
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
3. **Train the baseline local `.pkl` models (Required on first run):**
|
| 100 |
+
```bash
|
| 101 |
+
cd backend
|
| 102 |
+
python models.py
|
| 103 |
+
cd ..
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
## Usage
|
| 107 |
+
|
| 108 |
+
### 🚀 Quick Start (Windows)
|
| 109 |
+
We've bundled an automated startup script that boots both environments natively:
|
| 110 |
+
```bash
|
| 111 |
+
run.bat
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
### 🐳 Docker Deployment
|
| 115 |
+
To avoid local python pathing issues, you can spin up the application via Docker Compose:
|
| 116 |
+
```bash
|
| 117 |
+
docker-compose up --build
|
| 118 |
+
```
|
| 119 |
+
*The Streamlit interface will bind automatically to `http://localhost:8501`, connecting directly to the internal API.*
|
| 120 |
+
|
| 121 |
+
### 🛠️ Manual Startup
|
| 122 |
+
If you prefer running the stack manually in separate terminal instances:
|
| 123 |
+
|
| 124 |
+
**Start the Backend:**
|
| 125 |
+
```bash
|
| 126 |
+
cd backend
|
| 127 |
+
uvicorn main:app --host 0.0.0.0 --port 8000
|
| 128 |
+
```
|
| 129 |
+
**Start the Frontend:**
|
| 130 |
+
```bash
|
| 131 |
+
cd frontend
|
| 132 |
+
streamlit run Home.py
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
## Development & Automation
|
| 136 |
+
|
| 137 |
+
### API Documentation
|
| 138 |
+
FastAPI serves auto-generated interactive documentation. While the backend is running, you can hit the endpoints directly at:
|
| 139 |
+
- **Swagger UI:** `http://localhost:8000/docs`
|
| 140 |
+
- **ReDoc:** `http://localhost:8000/redoc`
|
| 141 |
+
|
| 142 |
+
### CI/CD Pipelines
|
| 143 |
+
The project utilizes GitHub actions natively located in `.github/workflows/`:
|
| 144 |
+
1. **PyTest Validation**: Verifies endpoint data sanitization and schema parameters immediately on Push/Pull Requests.
|
| 145 |
+
2. **Hugging Face Sync**: Auto-deploys `main` branch updates precisely to the Hugging Face Space Docker container ensuring CI/CD alignment.
|
| 146 |
+
|
| 147 |
+
## Support & License
|
| 148 |
+
This project is licensed under the MIT License. Designed as a scalable, educational, and fully interpretable Edge-based Machine Learning architecture.
|
backend/Dockerfile
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
COPY backend/requirements.txt .
|
| 4 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 5 |
+
COPY . .
|
| 6 |
+
WORKDIR /app/backend
|
| 7 |
+
EXPOSE 8000
|
| 8 |
+
CMD sh -c "uvicorn main:app --host 0.0.0.0 --port ${PORT:-8000}"
|
backend/alerts.log
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ALERT: Patient at risk! HR: 171.28, SpO2: 98.0, Confidence: 0.82
|
| 2 |
+
ALERT: Patient at risk! HR: 68.0, SpO2: 99.0, Confidence: 0.95
|
| 3 |
+
ALERT: Patient at risk! HR: 97.0, SpO2: 88.0, Confidence: 0.87
|
| 4 |
+
ALERT: Patient at risk! HR: 93.0, SpO2: 88.0, Confidence: 0.99
|
| 5 |
+
ALERT: Patient at risk! HR: 93.0, SpO2: 88.0, Confidence: 0.99
|
| 6 |
+
ALERT: Patient at risk! HR: 93.0, SpO2: 85.0, Confidence: 0.98
|
| 7 |
+
ALERT: Patient at risk! HR: 65.0, SpO2: 93.0, Confidence: 0.92
|
| 8 |
+
ALERT: Patient at risk! HR: 81.0, SpO2: 96.0, Confidence: 0.97
|
| 9 |
+
ALERT: Patient at risk! HR: 91.0, SpO2: 87.0, Confidence: 0.92
|
backend/database.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sqlite3
|
| 2 |
+
import os
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from huggingface_hub import HfApi, hf_hub_download
|
| 5 |
+
|
| 6 |
+
REPO_ID = "IDKHowToCodeFr/tinyml-logs"
|
| 7 |
+
DB_NAME = 'patient_history.db'
|
| 8 |
+
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), DB_NAME)
|
| 9 |
+
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 10 |
+
|
| 11 |
+
api = HfApi()
|
| 12 |
+
|
| 13 |
+
def sync_from_hub():
|
| 14 |
+
if not HF_TOKEN:
|
| 15 |
+
print("No HF_TOKEN found. Skipping sync.")
|
| 16 |
+
return
|
| 17 |
+
try:
|
| 18 |
+
print(f"Downloading {DB_NAME} from Hub...")
|
| 19 |
+
path = hf_hub_download(repo_id=REPO_ID, filename=DB_NAME, repo_type="dataset", token=HF_TOKEN)
|
| 20 |
+
import shutil
|
| 21 |
+
shutil.copy(path, DB_PATH)
|
| 22 |
+
print("Sync from Hub complete.")
|
| 23 |
+
except Exception as e:
|
| 24 |
+
print(f"Sync from Hub failed (maybe first run?): {e}")
|
| 25 |
+
|
| 26 |
+
def sync_to_hub():
|
| 27 |
+
if not HF_TOKEN:
|
| 28 |
+
return
|
| 29 |
+
try:
|
| 30 |
+
api.upload_file(
|
| 31 |
+
path_or_fileobj=DB_PATH,
|
| 32 |
+
path_in_repo=DB_NAME,
|
| 33 |
+
repo_id=REPO_ID,
|
| 34 |
+
repo_type="dataset",
|
| 35 |
+
token=HF_TOKEN
|
| 36 |
+
)
|
| 37 |
+
except Exception as e:
|
| 38 |
+
print(f"Sync to Hub failed: {e}")
|
| 39 |
+
|
| 40 |
+
def init_db():
|
| 41 |
+
sync_from_hub()
|
| 42 |
+
conn = sqlite3.connect(DB_PATH)
|
| 43 |
+
cursor = conn.cursor()
|
| 44 |
+
cursor.execute('''
|
| 45 |
+
CREATE TABLE IF NOT EXISTS predictions (
|
| 46 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 47 |
+
timestamp DATETIME,
|
| 48 |
+
heart_rate REAL,
|
| 49 |
+
spo2 REAL,
|
| 50 |
+
sys_bp REAL,
|
| 51 |
+
dia_bp REAL,
|
| 52 |
+
temp REAL,
|
| 53 |
+
fall_detection TEXT,
|
| 54 |
+
prediction_label TEXT,
|
| 55 |
+
confidence REAL
|
| 56 |
+
)
|
| 57 |
+
''')
|
| 58 |
+
conn.commit()
|
| 59 |
+
conn.close()
|
| 60 |
+
|
| 61 |
+
def log_prediction(data, prediction_label, confidence):
|
| 62 |
+
conn = sqlite3.connect(DB_PATH)
|
| 63 |
+
cursor = conn.cursor()
|
| 64 |
+
cursor.execute('''
|
| 65 |
+
INSERT INTO predictions (timestamp, heart_rate, spo2, sys_bp, dia_bp, temp, fall_detection, prediction_label, confidence)
|
| 66 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 67 |
+
''', (
|
| 68 |
+
datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
| 69 |
+
data.Heart_Rate,
|
| 70 |
+
data.SpO2_Level,
|
| 71 |
+
data.Systolic_BP,
|
| 72 |
+
data.Diastolic_BP,
|
| 73 |
+
data.Body_Temp,
|
| 74 |
+
data.Fall_Detection,
|
| 75 |
+
prediction_label,
|
| 76 |
+
confidence
|
| 77 |
+
))
|
| 78 |
+
conn.commit()
|
| 79 |
+
conn.close()
|
| 80 |
+
sync_to_hub()
|
| 81 |
+
|
| 82 |
+
def get_history():
|
| 83 |
+
if not os.path.exists(DB_PATH):
|
| 84 |
+
return []
|
| 85 |
+
conn = sqlite3.connect(DB_PATH)
|
| 86 |
+
cursor = conn.cursor()
|
| 87 |
+
cursor.execute('SELECT * FROM predictions ORDER BY timestamp DESC LIMIT 100')
|
| 88 |
+
rows = cursor.fetchall()
|
| 89 |
+
col_names = [description[0] for description in cursor.description]
|
| 90 |
+
conn.close()
|
| 91 |
+
|
| 92 |
+
history = []
|
| 93 |
+
for row in rows:
|
| 94 |
+
history.append(dict(zip(col_names, row)))
|
| 95 |
+
return history
|
| 96 |
+
|
| 97 |
+
# Initialize db when this module is loaded
|
| 98 |
+
init_db()
|
backend/ensemble.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import joblib
|
| 2 |
+
import numpy as np
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
|
| 6 |
+
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
| 7 |
+
from preprocessing import resolve_model_dir
|
| 8 |
+
|
| 9 |
+
class EnsembleModel:
|
| 10 |
+
def __init__(self):
|
| 11 |
+
self.model_dir = resolve_model_dir()
|
| 12 |
+
self.model_names = ['knn', 'svm', 'logreg', 'rf', 'small_nn']
|
| 13 |
+
self.models = {}
|
| 14 |
+
self.load_models()
|
| 15 |
+
self.label_encoder = joblib.load(f'{self.model_dir}/label_encoder.pkl')
|
| 16 |
+
self.weights = {'knn': 0.15, 'svm': 0.15, 'logreg': 0.20, 'rf': 0.25, 'small_nn': 0.25}
|
| 17 |
+
|
| 18 |
+
def load_models(self):
|
| 19 |
+
for name in self.model_names:
|
| 20 |
+
path = f'{self.model_dir}/{name}.pkl'
|
| 21 |
+
if os.path.exists(path):
|
| 22 |
+
self.models[name] = joblib.load(path)
|
| 23 |
+
|
| 24 |
+
def predict(self, X):
|
| 25 |
+
individual_preds = {}
|
| 26 |
+
individual_probs = {}
|
| 27 |
+
|
| 28 |
+
for name, model in self.models.items():
|
| 29 |
+
probs = model.predict_proba(X)
|
| 30 |
+
individual_probs[name] = probs[0]
|
| 31 |
+
pred_indices = np.argmax(probs, axis=1)
|
| 32 |
+
individual_preds[name] = str(self.label_encoder.inverse_transform(pred_indices)[0])
|
| 33 |
+
|
| 34 |
+
num_classes = list(individual_probs.values())[0].shape[0]
|
| 35 |
+
weighted_probs = np.zeros(num_classes)
|
| 36 |
+
|
| 37 |
+
total_weight = 0
|
| 38 |
+
for name in self.models.keys():
|
| 39 |
+
w = self.weights.get(name, 1.0)
|
| 40 |
+
weighted_probs += individual_probs[name] * w
|
| 41 |
+
total_weight += w
|
| 42 |
+
|
| 43 |
+
weighted_probs /= total_weight
|
| 44 |
+
final_pred_idx = np.argmax(weighted_probs)
|
| 45 |
+
final_pred = str(self.label_encoder.inverse_transform([final_pred_idx])[0])
|
| 46 |
+
confidence = float(np.max(weighted_probs))
|
| 47 |
+
|
| 48 |
+
return final_pred, confidence, individual_preds, individual_probs, self.weights
|
backend/main.py
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, UploadFile, File
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
import pandas as pd
|
| 5 |
+
import asyncio
|
| 6 |
+
import sys
|
| 7 |
+
import os
|
| 8 |
+
import m2cgen as m2c
|
| 9 |
+
import shap
|
| 10 |
+
from database import log_prediction, get_history
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
| 14 |
+
from preprocessing import preprocess_data
|
| 15 |
+
|
| 16 |
+
app = FastAPI(title="TinyML Healthcare API")
|
| 17 |
+
|
| 18 |
+
app.add_middleware(
|
| 19 |
+
CORSMiddleware,
|
| 20 |
+
allow_origins=["*"],
|
| 21 |
+
allow_credentials=True,
|
| 22 |
+
allow_methods=["*"],
|
| 23 |
+
allow_headers=["*"],
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
ensemble_system = None
|
| 27 |
+
def get_ensemble():
|
| 28 |
+
global ensemble_system
|
| 29 |
+
if ensemble_system is None:
|
| 30 |
+
try:
|
| 31 |
+
from ensemble import EnsembleModel
|
| 32 |
+
ensemble_system = EnsembleModel()
|
| 33 |
+
except Exception:
|
| 34 |
+
return None
|
| 35 |
+
return ensemble_system
|
| 36 |
+
|
| 37 |
+
class PatientData(BaseModel):
|
| 38 |
+
Heart_Rate: float
|
| 39 |
+
SpO2_Level: float
|
| 40 |
+
Systolic_BP: float
|
| 41 |
+
Diastolic_BP: float
|
| 42 |
+
Body_Temp: float
|
| 43 |
+
Fall_Detection: str
|
| 44 |
+
|
| 45 |
+
@app.get("/health")
|
| 46 |
+
def health_check():
|
| 47 |
+
return {"status": "Healthy" if get_ensemble() else "Warning - Models Offline"}
|
| 48 |
+
|
| 49 |
+
@app.post("/predict")
|
| 50 |
+
async def predict(data: PatientData):
|
| 51 |
+
try:
|
| 52 |
+
eng = get_ensemble()
|
| 53 |
+
if not eng:
|
| 54 |
+
return {"error": "Models untrained. Ensure python backend/models.py executes."}
|
| 55 |
+
|
| 56 |
+
df = pd.DataFrame([{
|
| 57 |
+
'Heart Rate (bpm)': data.Heart_Rate,
|
| 58 |
+
'SpO2 Level (%)': data.SpO2_Level,
|
| 59 |
+
'Systolic Blood Pressure (mmHg)': data.Systolic_BP,
|
| 60 |
+
'Diastolic Blood Pressure (mmHg)': data.Diastolic_BP,
|
| 61 |
+
'Body Temperature (°C)': data.Body_Temp,
|
| 62 |
+
'Fall Detection': data.Fall_Detection
|
| 63 |
+
}])
|
| 64 |
+
|
| 65 |
+
X_proc, _ = await asyncio.to_thread(preprocess_data, df, False)
|
| 66 |
+
final_pred, conf, ind_preds, ind_probs, weights = await asyncio.to_thread(eng.predict, X_proc)
|
| 67 |
+
|
| 68 |
+
is_at_risk = 0 if final_pred == "Healthy" else 1
|
| 69 |
+
|
| 70 |
+
# Critical Alert System — fault-tolerant
|
| 71 |
+
if is_at_risk == 1 and float(conf) > 0.80:
|
| 72 |
+
try:
|
| 73 |
+
with open("alerts.log", "a") as f:
|
| 74 |
+
f.write(f"ALERT: Patient at risk! HR: {data.Heart_Rate}, SpO2: {data.SpO2_Level}, Confidence: {conf:.2f}\n")
|
| 75 |
+
except Exception:
|
| 76 |
+
pass
|
| 77 |
+
|
| 78 |
+
# Log to SQLite History — fault-tolerant
|
| 79 |
+
try:
|
| 80 |
+
await asyncio.to_thread(log_prediction, data, final_pred, float(conf))
|
| 81 |
+
except Exception as db_e:
|
| 82 |
+
print(f"DB logging skipped: {db_e}")
|
| 83 |
+
|
| 84 |
+
return {
|
| 85 |
+
"prediction": is_at_risk,
|
| 86 |
+
"prediction_label": final_pred,
|
| 87 |
+
"probability": float(conf),
|
| 88 |
+
"ensemble_prediction": is_at_risk,
|
| 89 |
+
"model_outputs": ind_preds,
|
| 90 |
+
"model_probs": {k: float(np.max(v)) for k, v in ind_probs.items()},
|
| 91 |
+
"weights": weights
|
| 92 |
+
}
|
| 93 |
+
except Exception as e:
|
| 94 |
+
import traceback
|
| 95 |
+
from fastapi import HTTPException
|
| 96 |
+
raise HTTPException(status_code=500, detail=f"Backend Error: {str(e)}\n\nTraceback:\n{traceback.format_exc()}")
|
| 97 |
+
|
| 98 |
+
@app.get("/history")
|
| 99 |
+
def history():
|
| 100 |
+
return get_history()
|
| 101 |
+
|
| 102 |
+
@app.get("/dataset")
|
| 103 |
+
async def get_dataset():
|
| 104 |
+
data_path = '/app/data/patient_dataset.csv' if os.path.exists('/app/data') else '../data/patient_dataset.csv' if os.path.exists('../data') else 'data/patient_dataset.csv'
|
| 105 |
+
if os.path.exists(data_path):
|
| 106 |
+
df = pd.read_csv(data_path)
|
| 107 |
+
# Return summary or partial data to avoid huge payloads
|
| 108 |
+
return df.to_dict(orient="records")
|
| 109 |
+
return {"error": "Dataset not found"}
|
| 110 |
+
|
| 111 |
+
@app.get("/sync")
|
| 112 |
+
def force_sync():
|
| 113 |
+
from database import sync_from_hub, sync_to_hub
|
| 114 |
+
sync_from_hub()
|
| 115 |
+
return {"status": "Sync attempted"}
|
| 116 |
+
|
| 117 |
+
@app.post("/explain")
|
| 118 |
+
async def explain(data: PatientData):
|
| 119 |
+
eng = get_ensemble()
|
| 120 |
+
if not eng or 'rf' not in eng.models:
|
| 121 |
+
return {"error": "RF Model unavailable for explanation."}
|
| 122 |
+
|
| 123 |
+
df = pd.DataFrame([{
|
| 124 |
+
'Heart Rate (bpm)': data.Heart_Rate,
|
| 125 |
+
'SpO2 Level (%)': data.SpO2_Level,
|
| 126 |
+
'Systolic Blood Pressure (mmHg)': data.Systolic_BP,
|
| 127 |
+
'Diastolic Blood Pressure (mmHg)': data.Diastolic_BP,
|
| 128 |
+
'Body Temperature (°C)': data.Body_Temp,
|
| 129 |
+
'Fall Detection': data.Fall_Detection
|
| 130 |
+
}])
|
| 131 |
+
|
| 132 |
+
X_proc, _ = await asyncio.to_thread(preprocess_data, df, False)
|
| 133 |
+
|
| 134 |
+
def compute_shap():
|
| 135 |
+
import numpy as np
|
| 136 |
+
rf_model = eng.models['rf']
|
| 137 |
+
explainer = shap.TreeExplainer(rf_model)
|
| 138 |
+
shap_values = explainer.shap_values(X_proc)
|
| 139 |
+
pred_idx = int(rf_model.predict(X_proc)[0])
|
| 140 |
+
if isinstance(shap_values, list):
|
| 141 |
+
vals = shap_values[pred_idx][0]
|
| 142 |
+
elif isinstance(shap_values, np.ndarray) and len(shap_values.shape) == 3:
|
| 143 |
+
vals = shap_values[0, :, pred_idx]
|
| 144 |
+
else:
|
| 145 |
+
vals = shap_values[0]
|
| 146 |
+
return vals.tolist(), X_proc.columns.tolist()
|
| 147 |
+
|
| 148 |
+
try:
|
| 149 |
+
shap_vals, features = await asyncio.to_thread(compute_shap)
|
| 150 |
+
return {"shap_values": shap_vals, "feature_names": features}
|
| 151 |
+
except Exception as e:
|
| 152 |
+
return {"error": str(e)}
|
| 153 |
+
|
| 154 |
+
@app.get("/export_tinyml")
|
| 155 |
+
def export_tinyml(model_name: str = "rf", quantize: bool = False):
|
| 156 |
+
import numpy as np
|
| 157 |
+
eng = get_ensemble()
|
| 158 |
+
if not eng or model_name not in eng.models:
|
| 159 |
+
return {"error": f"Model {model_name} not found"}
|
| 160 |
+
|
| 161 |
+
model = eng.models[model_name]
|
| 162 |
+
|
| 163 |
+
# m2cgen handles Random Forest beautifully, but outputs FP32/double rules
|
| 164 |
+
if model_name == "rf":
|
| 165 |
+
try:
|
| 166 |
+
code = m2c.export_to_c(model)
|
| 167 |
+
if quantize:
|
| 168 |
+
code = "/* WARNING: M2CGen generated FP32 output. INT8 Quantization is not supported directly for Random Forest trees. */\n" + code
|
| 169 |
+
return {"code": code}
|
| 170 |
+
except Exception:
|
| 171 |
+
pass
|
| 172 |
+
|
| 173 |
+
# For LogReg, use m2cgen if FP32, otherwise manual generation for INT8
|
| 174 |
+
if not quantize and model_name == "logreg":
|
| 175 |
+
try:
|
| 176 |
+
code = m2c.export_to_c(model)
|
| 177 |
+
return {"code": code}
|
| 178 |
+
except Exception:
|
| 179 |
+
pass
|
| 180 |
+
|
| 181 |
+
# Manual C-code generation for all model types
|
| 182 |
+
try:
|
| 183 |
+
L = []
|
| 184 |
+
L.append("/* ====================================================== */")
|
| 185 |
+
L.append(f"/* TinyML C Export: {model_name} */")
|
| 186 |
+
q_text = "INT8 Quantized" if quantize else "FP32 Double"
|
| 187 |
+
L.append(f"/* Auto-generated for ARM Cortex-M / ESP32 ({q_text}) */")
|
| 188 |
+
L.append("/* ====================================================== */")
|
| 189 |
+
L.append("")
|
| 190 |
+
L.append("#include <math.h>")
|
| 191 |
+
L.append("#include <stdint.h>")
|
| 192 |
+
L.append("#include <string.h>")
|
| 193 |
+
L.append("")
|
| 194 |
+
|
| 195 |
+
if model_name == "svm" and hasattr(model, 'coef_'):
|
| 196 |
+
coefs = model.coef_
|
| 197 |
+
intercepts = model.intercept_
|
| 198 |
+
n_classes = len(model.classes_)
|
| 199 |
+
n_features = coefs.shape[1]
|
| 200 |
+
L.append(f"/* Linear SVM with {n_classes} classes, {n_features} features */")
|
| 201 |
+
L.append(f"#define N_FEATURES {n_features}")
|
| 202 |
+
L.append(f"#define N_CLASSES {n_classes}")
|
| 203 |
+
L.append(f"#define N_HYPERPLANES {coefs.shape[0]}")
|
| 204 |
+
L.append("")
|
| 205 |
+
|
| 206 |
+
if quantize:
|
| 207 |
+
scale_factor = 127.0 / max(np.max(np.abs(coefs)), np.max(np.abs(intercepts)), 1e-6)
|
| 208 |
+
L.append(f"/* Quantization Scale: {scale_factor:.4f} */")
|
| 209 |
+
L.append("static const int8_t SVM_COEF[N_HYPERPLANES][N_FEATURES] = {")
|
| 210 |
+
for row in coefs:
|
| 211 |
+
vals = ", ".join([str(int(round(v * scale_factor))) for v in row])
|
| 212 |
+
L.append(f" {{{vals}}},")
|
| 213 |
+
L.append("};")
|
| 214 |
+
L.append("")
|
| 215 |
+
vals = ", ".join([str(int(round(v * scale_factor))) for v in intercepts])
|
| 216 |
+
L.append(f"static const int8_t SVM_INTERCEPT[N_HYPERPLANES] = {{{vals}}};")
|
| 217 |
+
L.append("")
|
| 218 |
+
L.append("int predict(int8_t *features) {")
|
| 219 |
+
L.append(" int32_t scores[N_CLASSES] = {0};")
|
| 220 |
+
L.append(" int h = 0;")
|
| 221 |
+
L.append(" for (int i = 0; i < N_CLASSES; i++) {")
|
| 222 |
+
L.append(" for (int j = i + 1; j < N_CLASSES; j++) {")
|
| 223 |
+
L.append(" int32_t decision = SVM_INTERCEPT[h];")
|
| 224 |
+
L.append(" for (int f = 0; f < N_FEATURES; f++) {")
|
| 225 |
+
L.append(" decision += (int32_t)SVM_COEF[h][f] * features[f];")
|
| 226 |
+
L.append(" }")
|
| 227 |
+
L.append(" if (decision > 0) scores[i] += 1;")
|
| 228 |
+
L.append(" else scores[j] += 1;")
|
| 229 |
+
L.append(" h++;")
|
| 230 |
+
L.append(" }")
|
| 231 |
+
L.append(" }")
|
| 232 |
+
L.append(" int best = 0;")
|
| 233 |
+
L.append(" for (int c = 1; c < N_CLASSES; c++) {")
|
| 234 |
+
L.append(" if (scores[c] > scores[best]) best = c;")
|
| 235 |
+
L.append(" }")
|
| 236 |
+
L.append(" return best;")
|
| 237 |
+
L.append("}")
|
| 238 |
+
else:
|
| 239 |
+
L.append("static const double SVM_COEF[N_HYPERPLANES][N_FEATURES] = {")
|
| 240 |
+
for row in coefs:
|
| 241 |
+
vals = ", ".join([f"{v:.6f}" for v in row])
|
| 242 |
+
L.append(f" {{{vals}}},")
|
| 243 |
+
L.append("};")
|
| 244 |
+
L.append("")
|
| 245 |
+
vals = ", ".join([f"{v:.6f}" for v in intercepts])
|
| 246 |
+
L.append(f"static const double SVM_INTERCEPT[N_HYPERPLANES] = {{{vals}}};")
|
| 247 |
+
L.append("")
|
| 248 |
+
L.append("int predict(double *features) {")
|
| 249 |
+
L.append(" double scores[N_CLASSES] = {0};")
|
| 250 |
+
L.append(" int h = 0;")
|
| 251 |
+
L.append(" for (int i = 0; i < N_CLASSES; i++) {")
|
| 252 |
+
L.append(" for (int j = i + 1; j < N_CLASSES; j++) {")
|
| 253 |
+
L.append(" double decision = SVM_INTERCEPT[h];")
|
| 254 |
+
L.append(" for (int f = 0; f < N_FEATURES; f++) {")
|
| 255 |
+
L.append(" decision += SVM_COEF[h][f] * features[f];")
|
| 256 |
+
L.append(" }")
|
| 257 |
+
L.append(" if (decision > 0) scores[i] += 1.0;")
|
| 258 |
+
L.append(" else scores[j] += 1.0;")
|
| 259 |
+
L.append(" h++;")
|
| 260 |
+
L.append(" }")
|
| 261 |
+
L.append(" }")
|
| 262 |
+
L.append(" int best = 0;")
|
| 263 |
+
L.append(" for (int c = 1; c < N_CLASSES; c++) {")
|
| 264 |
+
L.append(" if (scores[c] > scores[best]) best = c;")
|
| 265 |
+
L.append(" }")
|
| 266 |
+
L.append(" return best;")
|
| 267 |
+
L.append("}")
|
| 268 |
+
|
| 269 |
+
elif model_name == "logreg" and hasattr(model, 'coef_'):
|
| 270 |
+
coefs = model.coef_
|
| 271 |
+
intercepts = model.intercept_
|
| 272 |
+
n_classes = coefs.shape[0] if len(model.classes_) > 2 else 2
|
| 273 |
+
n_features = coefs.shape[1]
|
| 274 |
+
L.append(f"/* Logistic Regression with {n_classes} classes, {n_features} features */")
|
| 275 |
+
L.append(f"#define N_FEATURES {n_features}")
|
| 276 |
+
L.append(f"#define N_CLASSES {coefs.shape[0]}")
|
| 277 |
+
L.append("")
|
| 278 |
+
|
| 279 |
+
if quantize:
|
| 280 |
+
scale_factor = 127.0 / max(np.max(np.abs(coefs)), np.max(np.abs(intercepts)), 1e-6)
|
| 281 |
+
L.append(f"/* Quantization Scale: {scale_factor:.4f} */")
|
| 282 |
+
L.append("static const int8_t LOGREG_COEF[N_CLASSES][N_FEATURES] = {")
|
| 283 |
+
for row in coefs:
|
| 284 |
+
vals = ", ".join([str(int(round(v * scale_factor))) for v in row])
|
| 285 |
+
L.append(f" {{{vals}}},")
|
| 286 |
+
L.append("};")
|
| 287 |
+
L.append("")
|
| 288 |
+
vals = ", ".join([str(int(round(v * scale_factor))) for v in intercepts])
|
| 289 |
+
L.append(f"static const int8_t LOGREG_INTERCEPT[N_CLASSES] = {{{vals}}};")
|
| 290 |
+
L.append("")
|
| 291 |
+
L.append("int predict(int8_t *features) {")
|
| 292 |
+
L.append(" int32_t scores[N_CLASSES];")
|
| 293 |
+
L.append(" for (int c = 0; c < N_CLASSES; c++) {")
|
| 294 |
+
L.append(f" scores[c] = LOGREG_INTERCEPT[c] * {int(scale_factor)};")
|
| 295 |
+
L.append(" for (int f = 0; f < N_FEATURES; f++) {")
|
| 296 |
+
L.append(" scores[c] += (int32_t)LOGREG_COEF[c][f] * features[f];")
|
| 297 |
+
L.append(" }")
|
| 298 |
+
L.append(" }")
|
| 299 |
+
L.append(" int best = 0;")
|
| 300 |
+
L.append(" for (int c = 1; c < N_CLASSES; c++) {")
|
| 301 |
+
L.append(" if (scores[c] > scores[best]) best = c;")
|
| 302 |
+
L.append(" }")
|
| 303 |
+
L.append(" return best;")
|
| 304 |
+
L.append("}")
|
| 305 |
+
else:
|
| 306 |
+
L.append("static const double LOGREG_COEF[N_CLASSES][N_FEATURES] = {")
|
| 307 |
+
for row in coefs:
|
| 308 |
+
vals = ", ".join([f"{v:.6f}" for v in row])
|
| 309 |
+
L.append(f" {{{vals}}},")
|
| 310 |
+
L.append("};")
|
| 311 |
+
L.append("")
|
| 312 |
+
vals = ", ".join([f"{v:.6f}" for v in intercepts])
|
| 313 |
+
L.append(f"static const double LOGREG_INTERCEPT[N_CLASSES] = {{{vals}}};")
|
| 314 |
+
L.append("")
|
| 315 |
+
L.append("int predict(double *features) {")
|
| 316 |
+
L.append(" double scores[N_CLASSES];")
|
| 317 |
+
L.append(" for (int c = 0; c < N_CLASSES; c++) {")
|
| 318 |
+
L.append(" scores[c] = LOGREG_INTERCEPT[c];")
|
| 319 |
+
L.append(" for (int f = 0; f < N_FEATURES; f++) {")
|
| 320 |
+
L.append(" scores[c] += LOGREG_COEF[c][f] * features[f];")
|
| 321 |
+
L.append(" }")
|
| 322 |
+
L.append(" }")
|
| 323 |
+
L.append(" int best = 0;")
|
| 324 |
+
L.append(" for (int c = 1; c < N_CLASSES; c++) {")
|
| 325 |
+
L.append(" if (scores[c] > scores[best]) best = c;")
|
| 326 |
+
L.append(" }")
|
| 327 |
+
L.append(" return best;")
|
| 328 |
+
L.append("}")
|
| 329 |
+
|
| 330 |
+
elif model_name == "small_nn" and hasattr(model, 'coefs_'):
|
| 331 |
+
layers = model.coefs_
|
| 332 |
+
biases = model.intercepts_
|
| 333 |
+
arch = " -> ".join([str(l.shape[0]) for l in layers] + [str(layers[-1].shape[1])])
|
| 334 |
+
L.append(f"/* MLP Neural Network: {len(layers)} layers */")
|
| 335 |
+
L.append(f"/* Architecture: {arch} */")
|
| 336 |
+
L.append("")
|
| 337 |
+
|
| 338 |
+
for idx, (W, b) in enumerate(zip(layers, biases)):
|
| 339 |
+
n_in, n_out = W.shape
|
| 340 |
+
L.append(f"#define L{idx}_IN {n_in}")
|
| 341 |
+
L.append(f"#define L{idx}_OUT {n_out}")
|
| 342 |
+
L.append(f"static const double W{idx}[{n_in}][{n_out}] = {{")
|
| 343 |
+
for row in W:
|
| 344 |
+
vals = ", ".join([f"{v:.6f}" for v in row])
|
| 345 |
+
L.append(f" {{{vals}}},")
|
| 346 |
+
L.append("};")
|
| 347 |
+
bvals = ", ".join([f"{v:.6f}" for v in b])
|
| 348 |
+
L.append(f"static const double B{idx}[{n_out}] = {{{bvals}}};")
|
| 349 |
+
L.append("")
|
| 350 |
+
|
| 351 |
+
L.append("static inline double relu(double x) { return x > 0 ? x : 0; }")
|
| 352 |
+
L.append("")
|
| 353 |
+
|
| 354 |
+
if quantize:
|
| 355 |
+
# Calculate global max for int8 scaling
|
| 356 |
+
max_val = max([np.max(np.abs(w)) for w in layers] + [np.max(np.abs(b)) for b in biases] + [1e-6])
|
| 357 |
+
scale_factor = 127.0 / max_val
|
| 358 |
+
L.append(f"/* INT8 Quantization Scale Factor: {scale_factor:.4f} */")
|
| 359 |
+
for idx, (W, b) in enumerate(zip(layers, biases)):
|
| 360 |
+
n_in, n_out = W.shape
|
| 361 |
+
L.append(f"static const int8_t W{idx}[{n_in}][{n_out}] = {{")
|
| 362 |
+
for row in W:
|
| 363 |
+
vals = ", ".join([str(int(round(v * scale_factor))) for v in row])
|
| 364 |
+
L.append(f" {{{vals}}},")
|
| 365 |
+
L.append("};")
|
| 366 |
+
bvals = ", ".join([str(int(round(v * scale_factor))) for v in b])
|
| 367 |
+
L.append(f"static const int8_t B{idx}[{n_out}] = {{{bvals}}};")
|
| 368 |
+
L.append("")
|
| 369 |
+
L.append("static inline int32_t relu_int(int32_t x) { return x > 0 ? x : 0; }")
|
| 370 |
+
L.append("")
|
| 371 |
+
L.append("int predict(int8_t *input) {")
|
| 372 |
+
for idx in range(len(layers)):
|
| 373 |
+
n_in = layers[idx].shape[0]
|
| 374 |
+
n_out = layers[idx].shape[1]
|
| 375 |
+
is_last = (idx == len(layers) - 1)
|
| 376 |
+
src = "input" if idx == 0 else f"a{idx-1}"
|
| 377 |
+
L.append(f" int32_t a{idx}[{n_out}];")
|
| 378 |
+
L.append(f" for (int j = 0; j < {n_out}; j++) {{")
|
| 379 |
+
L.append(f" a{idx}[j] = B{idx}[j] * {int(scale_factor)}; /* scale bias */")
|
| 380 |
+
L.append(f" for (int i = 0; i < {n_in}; i++) {{")
|
| 381 |
+
L.append(f" a{idx}[j] += (int32_t){src}[i] * W{idx}[i][j];")
|
| 382 |
+
L.append(f" }}")
|
| 383 |
+
if not is_last:
|
| 384 |
+
L.append(f" a{idx}[j] = relu_int(a{idx}[j]) / {int(scale_factor)}; /* rescale */")
|
| 385 |
+
L.append(f" }}")
|
| 386 |
+
last_idx = len(layers) - 1
|
| 387 |
+
last_out = layers[-1].shape[1]
|
| 388 |
+
L.append(f" int best = 0;")
|
| 389 |
+
L.append(f" for (int c = 1; c < {last_out}; c++) {{")
|
| 390 |
+
L.append(f" if (a{last_idx}[c] > a{last_idx}[best]) best = c;")
|
| 391 |
+
L.append(f" }}")
|
| 392 |
+
L.append(f" return best;")
|
| 393 |
+
L.append("}")
|
| 394 |
+
else:
|
| 395 |
+
for idx, (W, b) in enumerate(zip(layers, biases)):
|
| 396 |
+
n_in, n_out = W.shape
|
| 397 |
+
L.append(f"#define L{idx}_IN {n_in}")
|
| 398 |
+
L.append(f"#define L{idx}_OUT {n_out}")
|
| 399 |
+
L.append(f"static const double W{idx}[{n_in}][{n_out}] = {{")
|
| 400 |
+
for row in W:
|
| 401 |
+
vals = ", ".join([f"{v:.6f}" for v in row])
|
| 402 |
+
L.append(f" {{{vals}}},")
|
| 403 |
+
L.append("};")
|
| 404 |
+
bvals = ", ".join([f"{v:.6f}" for v in b])
|
| 405 |
+
L.append(f"static const double B{idx}[{n_out}] = {{{bvals}}};")
|
| 406 |
+
L.append("")
|
| 407 |
+
|
| 408 |
+
L.append("static inline double relu(double x) { return x > 0 ? x : 0; }")
|
| 409 |
+
L.append("")
|
| 410 |
+
L.append("int predict(double *input) {")
|
| 411 |
+
for idx in range(len(layers)):
|
| 412 |
+
n_in = layers[idx].shape[0]
|
| 413 |
+
n_out = layers[idx].shape[1]
|
| 414 |
+
is_last = (idx == len(layers) - 1)
|
| 415 |
+
src = "input" if idx == 0 else f"a{idx-1}"
|
| 416 |
+
L.append(f" double a{idx}[{n_out}];")
|
| 417 |
+
L.append(f" for (int j = 0; j < {n_out}; j++) {{")
|
| 418 |
+
L.append(f" a{idx}[j] = B{idx}[j];")
|
| 419 |
+
L.append(f" for (int i = 0; i < {n_in}; i++) {{")
|
| 420 |
+
L.append(f" a{idx}[j] += {src}[i] * W{idx}[i][j];")
|
| 421 |
+
L.append(f" }}")
|
| 422 |
+
if not is_last:
|
| 423 |
+
L.append(f" a{idx}[j] = relu(a{idx}[j]);")
|
| 424 |
+
L.append(f" }}")
|
| 425 |
+
|
| 426 |
+
last_idx = len(layers) - 1
|
| 427 |
+
last_out = layers[-1].shape[1]
|
| 428 |
+
L.append(f" int best = 0;")
|
| 429 |
+
L.append(f" for (int c = 1; c < {last_out}; c++) {{")
|
| 430 |
+
L.append(f" if (a{last_idx}[c] > a{last_idx}[best]) best = c;")
|
| 431 |
+
L.append(f" }}")
|
| 432 |
+
L.append(f" return best;")
|
| 433 |
+
L.append("}")
|
| 434 |
+
|
| 435 |
+
elif model_name == "knn" and hasattr(model, '_fit_X'):
|
| 436 |
+
n_samples = min(model._fit_X.shape[0], 100)
|
| 437 |
+
n_feats = model._fit_X.shape[1]
|
| 438 |
+
L.append(f"/* KNN Lookup Table: {n_samples} reference samples */")
|
| 439 |
+
L.append(f"#define N_NEIGHBORS {model.n_neighbors}")
|
| 440 |
+
L.append(f"#define N_SAMPLES {n_samples}")
|
| 441 |
+
L.append(f"#define N_FEATURES {n_feats}")
|
| 442 |
+
L.append("")
|
| 443 |
+
L.append("static const double REF[N_SAMPLES][N_FEATURES] = {")
|
| 444 |
+
for row in model._fit_X[:n_samples]:
|
| 445 |
+
vals = ", ".join([f"{v:.4f}" for v in row])
|
| 446 |
+
L.append(f" {{{vals}}},")
|
| 447 |
+
L.append("};")
|
| 448 |
+
L.append("")
|
| 449 |
+
labels_str = ", ".join([str(int(l)) for l in model._y[:n_samples]])
|
| 450 |
+
L.append(f"static const int LABELS[N_SAMPLES] = {{{labels_str}}};")
|
| 451 |
+
L.append("")
|
| 452 |
+
L.append("int predict(double *features) {")
|
| 453 |
+
L.append(" double dists[N_SAMPLES];")
|
| 454 |
+
L.append(" for (int i = 0; i < N_SAMPLES; i++) {")
|
| 455 |
+
L.append(" dists[i] = 0.0;")
|
| 456 |
+
L.append(" for (int f = 0; f < N_FEATURES; f++) {")
|
| 457 |
+
L.append(" double d = features[f] - REF[i][f];")
|
| 458 |
+
L.append(" dists[i] += d * d;")
|
| 459 |
+
L.append(" }")
|
| 460 |
+
L.append(" }")
|
| 461 |
+
L.append(" int votes[10] = {0};")
|
| 462 |
+
L.append(" for (int k = 0; k < N_NEIGHBORS; k++) {")
|
| 463 |
+
L.append(" int mi = 0;")
|
| 464 |
+
L.append(" for (int i = 1; i < N_SAMPLES; i++) {")
|
| 465 |
+
L.append(" if (dists[i] < dists[mi]) mi = i;")
|
| 466 |
+
L.append(" }")
|
| 467 |
+
L.append(" votes[LABELS[mi]]++;")
|
| 468 |
+
L.append(" dists[mi] = 1e18;")
|
| 469 |
+
L.append(" }")
|
| 470 |
+
L.append(" int best = 0;")
|
| 471 |
+
L.append(" for (int i = 1; i < 10; i++) {")
|
| 472 |
+
L.append(" if (votes[i] > votes[best]) best = i;")
|
| 473 |
+
L.append(" }")
|
| 474 |
+
L.append(" return best;")
|
| 475 |
+
L.append("}")
|
| 476 |
+
else:
|
| 477 |
+
return {"error": f"Model {model_name} cannot be exported to C."}
|
| 478 |
+
|
| 479 |
+
return {"code": "\n".join(L)}
|
| 480 |
+
except Exception as e:
|
| 481 |
+
return {"error": f"Export failed: {str(e)}"}
|
| 482 |
+
|
| 483 |
+
@app.post("/retrain")
|
| 484 |
+
async def retrain(file: UploadFile = File(...)):
|
| 485 |
+
data_path = '/app/data/patient_dataset.csv' if os.path.exists('/app/data') else '../data/patient_dataset.csv' if os.path.exists('../data') else 'data/patient_dataset.csv'
|
| 486 |
+
os.makedirs(os.path.dirname(data_path), exist_ok=True)
|
| 487 |
+
|
| 488 |
+
content = await file.read()
|
| 489 |
+
with open(data_path, "wb") as f:
|
| 490 |
+
f.write(content)
|
| 491 |
+
|
| 492 |
+
try:
|
| 493 |
+
from models import train_models
|
| 494 |
+
await asyncio.to_thread(train_models)
|
| 495 |
+
|
| 496 |
+
global ensemble_system
|
| 497 |
+
ensemble_system = None
|
| 498 |
+
|
| 499 |
+
return {"status": "success", "message": "Ensemble retrained successfully!"}
|
| 500 |
+
except Exception as e:
|
| 501 |
+
return {"error": str(e)}
|
backend/models.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import joblib
|
| 3 |
+
from sklearn.neighbors import KNeighborsClassifier
|
| 4 |
+
from sklearn.svm import SVC
|
| 5 |
+
from sklearn.linear_model import LogisticRegression
|
| 6 |
+
from sklearn.ensemble import RandomForestClassifier
|
| 7 |
+
from sklearn.neural_network import MLPClassifier
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import sys
|
| 10 |
+
|
| 11 |
+
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
| 12 |
+
from preprocessing import get_train_test_split, resolve_model_dir
|
| 13 |
+
|
| 14 |
+
def train_models():
|
| 15 |
+
data_path = '/app/data/patient_dataset.csv' if os.path.exists('/app/data') else '../data/patient_dataset.csv' if os.path.exists('../data') else 'data/patient_dataset.csv'
|
| 16 |
+
model_dir = resolve_model_dir()
|
| 17 |
+
os.makedirs(model_dir, exist_ok=True)
|
| 18 |
+
|
| 19 |
+
print("Loading data...")
|
| 20 |
+
df = pd.read_csv(data_path)
|
| 21 |
+
X_train, X_test, y_train, y_test = get_train_test_split(df)
|
| 22 |
+
|
| 23 |
+
models = {
|
| 24 |
+
'knn': KNeighborsClassifier(n_neighbors=5),
|
| 25 |
+
'svm': SVC(kernel='linear', probability=True, max_iter=2000),
|
| 26 |
+
'logreg': LogisticRegression(max_iter=1000),
|
| 27 |
+
'rf': RandomForestClassifier(n_estimators=30, max_depth=5, random_state=42),
|
| 28 |
+
'small_nn': MLPClassifier(hidden_layer_sizes=(16, 8), max_iter=500, random_state=42)
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
for name, model in models.items():
|
| 32 |
+
print(f"Training {name}...")
|
| 33 |
+
model.fit(X_train, y_train)
|
| 34 |
+
joblib.dump(model, f'{model_dir}/{name}.pkl')
|
| 35 |
+
|
| 36 |
+
print("Training Complete.")
|
| 37 |
+
|
| 38 |
+
if __name__ == '__main__':
|
| 39 |
+
train_models()
|
backend/patient_history.db
ADDED
|
Binary file (12.3 kB). View file
|
|
|
backend/preprocessing.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import numpy as np
|
| 3 |
+
import os
|
| 4 |
+
import joblib
|
| 5 |
+
from sklearn.model_selection import train_test_split
|
| 6 |
+
from sklearn.preprocessing import StandardScaler, LabelEncoder
|
| 7 |
+
|
| 8 |
+
def resolve_model_dir():
|
| 9 |
+
# Resolve relative paths universally whether Docker or local execution
|
| 10 |
+
return '/app/model' if os.path.exists('/app/model') else '../model' if os.path.exists('../model') else 'model'
|
| 11 |
+
|
| 12 |
+
def preprocess_data(df, is_training=True):
|
| 13 |
+
model_dir = resolve_model_dir()
|
| 14 |
+
scaler_path = f'{model_dir}/scaler.pkl'
|
| 15 |
+
label_encoder_path = f'{model_dir}/label_encoder.pkl'
|
| 16 |
+
|
| 17 |
+
columns_to_drop = ['Patient Number', 'Data Accuracy (%)', 'Heart Rate Alert', 'SpO2 Level Alert', 'Blood Pressure Alert', 'Temperature Alert']
|
| 18 |
+
cols_drop = [c for c in columns_to_drop if c in df.columns]
|
| 19 |
+
X_raw = df.drop(columns=cols_drop)
|
| 20 |
+
|
| 21 |
+
y = None
|
| 22 |
+
if 'Predicted Disease' in X_raw.columns:
|
| 23 |
+
y_raw = X_raw['Predicted Disease']
|
| 24 |
+
X_raw = X_raw.drop(columns=['Predicted Disease'])
|
| 25 |
+
|
| 26 |
+
if is_training:
|
| 27 |
+
label_encoder = LabelEncoder()
|
| 28 |
+
y = label_encoder.fit_transform(y_raw)
|
| 29 |
+
os.makedirs(os.path.dirname(label_encoder_path), exist_ok=True)
|
| 30 |
+
joblib.dump(label_encoder, label_encoder_path)
|
| 31 |
+
joblib.dump(label_encoder.classes_, f'{model_dir}/classes.pkl')
|
| 32 |
+
else:
|
| 33 |
+
if os.path.exists(label_encoder_path):
|
| 34 |
+
label_encoder = joblib.load(label_encoder_path)
|
| 35 |
+
y = label_encoder.transform(y_raw)
|
| 36 |
+
else:
|
| 37 |
+
y = y_raw
|
| 38 |
+
|
| 39 |
+
if 'Fall Detection' in X_raw.columns:
|
| 40 |
+
X_raw['Fall Detection'] = X_raw['Fall Detection'].map({'Yes': 1, 'No': 0}).fillna(0)
|
| 41 |
+
|
| 42 |
+
X_raw.fillna(X_raw.mean(), inplace=True)
|
| 43 |
+
|
| 44 |
+
# Engineered Feature
|
| 45 |
+
X_raw['Risk_Severity'] = (X_raw['Heart Rate (bpm)'] > 105).astype(int) + (X_raw['SpO2 Level (%)'] < 94).astype(int)
|
| 46 |
+
|
| 47 |
+
continuous_features = ['Heart Rate (bpm)', 'SpO2 Level (%)', 'Systolic Blood Pressure (mmHg)', 'Diastolic Blood Pressure (mmHg)', 'Body Temperature (°C)', 'Risk_Severity']
|
| 48 |
+
|
| 49 |
+
if is_training:
|
| 50 |
+
scaler = StandardScaler()
|
| 51 |
+
X_raw[continuous_features] = scaler.fit_transform(X_raw[continuous_features])
|
| 52 |
+
joblib.dump(scaler, scaler_path)
|
| 53 |
+
else:
|
| 54 |
+
if os.path.exists(scaler_path):
|
| 55 |
+
scaler = joblib.load(scaler_path)
|
| 56 |
+
X_raw[continuous_features] = scaler.transform(X_raw[continuous_features])
|
| 57 |
+
|
| 58 |
+
return X_raw, y
|
| 59 |
+
|
| 60 |
+
def get_train_test_split(df):
|
| 61 |
+
X, y = preprocess_data(df, is_training=True)
|
| 62 |
+
return train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
scikit-learn==1.8.0
|
| 4 |
+
pandas
|
| 5 |
+
numpy
|
| 6 |
+
joblib
|
| 7 |
+
shap
|
| 8 |
+
m2cgen
|
| 9 |
+
matplotlib
|
| 10 |
+
python-multipart
|
| 11 |
+
huggingface_hub
|
data/patient_dataset.csv
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
backend:
|
| 5 |
+
build:
|
| 6 |
+
context: .
|
| 7 |
+
dockerfile: backend/Dockerfile
|
| 8 |
+
ports:
|
| 9 |
+
- "8000:8000"
|
| 10 |
+
volumes:
|
| 11 |
+
- ./model:/app/model
|
| 12 |
+
- ./data:/app/data
|
| 13 |
+
|
| 14 |
+
frontend:
|
| 15 |
+
build:
|
| 16 |
+
context: .
|
| 17 |
+
dockerfile: frontend/Dockerfile
|
| 18 |
+
ports:
|
| 19 |
+
- "8501:8501"
|
| 20 |
+
environment:
|
| 21 |
+
- API_URL=http://backend:8000
|
| 22 |
+
depends_on:
|
| 23 |
+
- backend
|
frontend/.streamlit/config.toml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[theme]
|
| 2 |
+
primaryColor="#00f2fe"
|
| 3 |
+
backgroundColor="#0e1117"
|
| 4 |
+
secondaryBackgroundColor="#262730"
|
| 5 |
+
textColor="#fafafa"
|
| 6 |
+
font="sans serif"
|
frontend/Dockerfile
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
|
| 4 |
+
RUN apt-get update && apt-get install -y graphviz && rm -rf /var/lib/apt/lists/*
|
| 5 |
+
|
| 6 |
+
COPY frontend/requirements.txt .
|
| 7 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 8 |
+
COPY . .
|
| 9 |
+
WORKDIR /app/frontend
|
| 10 |
+
EXPOSE 8501
|
| 11 |
+
CMD sh -c "streamlit run Home.py --server.port=${PORT:-8501} --server.address=0.0.0.0"
|
frontend/Home.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import requests
|
| 3 |
+
import os
|
| 4 |
+
import time
|
| 5 |
+
import pandas as pd
|
| 6 |
+
import plotly.graph_objects as go
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
API_URL = os.getenv("API_URL", "http://localhost:8000")
|
| 10 |
+
|
| 11 |
+
st.set_page_config(page_title="TinyML Heart Health Monitoring", page_icon="🫀", layout="wide")
|
| 12 |
+
|
| 13 |
+
st.markdown("""
|
| 14 |
+
<style>
|
| 15 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
|
| 16 |
+
html, body, [class*="css"] { font-family: 'Inter', sans-serif; }
|
| 17 |
+
|
| 18 |
+
.hero-sub {
|
| 19 |
+
font-size: 1.15rem; color: rgba(255,255,255,0.55);
|
| 20 |
+
margin-top: 8px; font-weight: 300; letter-spacing: 0.3px;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
.metrics-grid {
|
| 24 |
+
display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 16px;
|
| 25 |
+
}
|
| 26 |
+
.glass-metric {
|
| 27 |
+
background: rgba(255,255,255,0.04);
|
| 28 |
+
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
|
| 29 |
+
border: 1px solid rgba(255,255,255,0.08); border-radius: 16px;
|
| 30 |
+
padding: 24px 20px; text-align: center;
|
| 31 |
+
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
| 32 |
+
min-height: 160px; display: flex; flex-direction: column;
|
| 33 |
+
justify-content: center; align-items: center;
|
| 34 |
+
}
|
| 35 |
+
.glass-metric:hover {
|
| 36 |
+
transform: translateY(-3px);
|
| 37 |
+
box-shadow: 0 12px 40px rgba(0,242,254,0.12);
|
| 38 |
+
border-color: rgba(255,255,255,0.15);
|
| 39 |
+
}
|
| 40 |
+
.metric-value {
|
| 41 |
+
font-size: 2.4rem; font-weight: 800; margin: 8px 0 6px 0; letter-spacing: -0.5px;
|
| 42 |
+
}
|
| 43 |
+
.metric-label {
|
| 44 |
+
font-size: 0.78rem; color: rgba(255,255,255,0.45);
|
| 45 |
+
text-transform: uppercase; letter-spacing: 2px; font-weight: 600;
|
| 46 |
+
}
|
| 47 |
+
.metric-delta {
|
| 48 |
+
font-size: 0.8rem; font-weight: 500; margin-top: 6px;
|
| 49 |
+
padding: 3px 10px; border-radius: 20px; display: inline-block;
|
| 50 |
+
}
|
| 51 |
+
.delta-good { color: #00cc96; background: rgba(0,204,150,0.12); }
|
| 52 |
+
.delta-bad { color: #ef553b; background: rgba(239,85,59,0.12); }
|
| 53 |
+
|
| 54 |
+
.status-badge {
|
| 55 |
+
display: inline-block; padding: 6px 18px; border-radius: 50px;
|
| 56 |
+
font-size: 0.85rem; font-weight: 600; letter-spacing: 0.5px;
|
| 57 |
+
}
|
| 58 |
+
.status-online { background: rgba(0,204,150,0.15); color: #00cc96; border: 1px solid rgba(0,204,150,0.3); }
|
| 59 |
+
.status-fallback { background: rgba(99,110,250,0.15); color: #636efa; border: 1px solid rgba(99,110,250,0.3); }
|
| 60 |
+
|
| 61 |
+
.feature-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
|
| 62 |
+
.feature-card {
|
| 63 |
+
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06);
|
| 64 |
+
border-radius: 14px; padding: 24px 20px; min-height: 170px;
|
| 65 |
+
display: flex; flex-direction: column;
|
| 66 |
+
transition: border-color 0.2s ease, transform 0.2s ease;
|
| 67 |
+
}
|
| 68 |
+
.feature-card:hover { border-color: rgba(0,242,254,0.25); transform: translateY(-2px); }
|
| 69 |
+
.feature-card .fc-icon { font-size: 1.6rem; margin-bottom: 10px; }
|
| 70 |
+
.feature-card .fc-title { color: #00f2fe; font-size: 0.95rem; font-weight: 700; margin-bottom: 8px; }
|
| 71 |
+
.feature-card .fc-desc { color: rgba(255,255,255,0.45); font-size: 0.82rem; line-height: 1.55; flex-grow: 1; }
|
| 72 |
+
</style>
|
| 73 |
+
""", unsafe_allow_html=True)
|
| 74 |
+
|
| 75 |
+
# ──────── Hero ────────
|
| 76 |
+
st.title("🫀 TinyML Heart Health Monitoring Dashboard")
|
| 77 |
+
st.markdown('<p class="hero-sub">Edge Intelligence for Real-Time Patient Monitoring • 5-Model Ensemble • SHAP Explainability • IoT Edge Deployment</p>', unsafe_allow_html=True)
|
| 78 |
+
st.markdown("<br>", unsafe_allow_html=True)
|
| 79 |
+
|
| 80 |
+
# ──────── System Status ────────
|
| 81 |
+
try:
|
| 82 |
+
resp = requests.get(f"{API_URL}/health", timeout=5)
|
| 83 |
+
if resp.status_code == 200:
|
| 84 |
+
status = resp.json().get("status", "Unknown")
|
| 85 |
+
is_online = "Healthy" in status
|
| 86 |
+
else:
|
| 87 |
+
status = f"Backend Error ({resp.status_code})"
|
| 88 |
+
is_online = False
|
| 89 |
+
except Exception as e:
|
| 90 |
+
status = f"Offline / Monolith Fallback ({str(e)[:50]})"
|
| 91 |
+
is_online = False
|
| 92 |
+
|
| 93 |
+
badge_class = "status-online" if is_online else "status-fallback"
|
| 94 |
+
badge_text = "● ONLINE" if is_online else "● CLOUD MODE"
|
| 95 |
+
st.markdown(f'<span class="status-badge {badge_class}">{badge_text}</span> <span style="color:rgba(255,255,255,0.35);font-size:0.85rem;">{status}</span>', unsafe_allow_html=True)
|
| 96 |
+
st.markdown("---")
|
| 97 |
+
|
| 98 |
+
# ──────── Dataset constants ────────
|
| 99 |
+
HR_MIN, HR_MAX, HR_AVG = 50.0, 139.0, 94.6
|
| 100 |
+
SPO2_MIN, SPO2_MAX, SPO2_AVG = 84.0, 100.0, 94.6
|
| 101 |
+
ALERT_MAX = 100
|
| 102 |
+
HR_NORMAL_LO, HR_NORMAL_HI = 60.0, 100.0
|
| 103 |
+
SPO2_NORMAL_LO = 95.0
|
| 104 |
+
CANDLE_COUNT = 60
|
| 105 |
+
TICK_INTERVAL = 1.5 # seconds
|
| 106 |
+
|
| 107 |
+
# ──────── Deterministic candle from timestamp ────────
|
| 108 |
+
def _alert_at(ts_epoch):
|
| 109 |
+
"""Deterministic alert count for a given epoch timestamp."""
|
| 110 |
+
rng = np.random.RandomState(int(ts_epoch * 10) % (2**31))
|
| 111 |
+
return int(rng.randint(0, ALERT_MAX + 1))
|
| 112 |
+
|
| 113 |
+
def _candle_at(t_epoch, prev_alerts, curr_alerts):
|
| 114 |
+
"""Build an OHLC candle from alert transitions."""
|
| 115 |
+
o = float(prev_alerts)
|
| 116 |
+
c = float(curr_alerts)
|
| 117 |
+
rng = np.random.RandomState(int(t_epoch * 7) % (2**31))
|
| 118 |
+
spread = abs(c - o) * 0.3 + rng.uniform(1, 4)
|
| 119 |
+
h = max(o, c) + spread
|
| 120 |
+
l = min(o, c) - rng.uniform(0.5, spread * 0.6)
|
| 121 |
+
h = min(h, ALERT_MAX + 5)
|
| 122 |
+
l = max(l, 0)
|
| 123 |
+
return {"time": pd.Timestamp.fromtimestamp(t_epoch), "open": round(o, 1), "high": round(h, 1), "low": round(l, 1), "close": round(c, 1)}
|
| 124 |
+
|
| 125 |
+
# ──────── Initialize: pre-fill chart as if running since startup ────────
|
| 126 |
+
if "chart_candles" not in st.session_state:
|
| 127 |
+
now_ep = time.time()
|
| 128 |
+
candles = []
|
| 129 |
+
prev = 50 # initial alert count
|
| 130 |
+
for i in range(CANDLE_COUNT):
|
| 131 |
+
t = now_ep - (CANDLE_COUNT - i) * TICK_INTERVAL
|
| 132 |
+
curr = _alert_at(t)
|
| 133 |
+
candles.append(_candle_at(t, prev, curr))
|
| 134 |
+
prev = curr
|
| 135 |
+
st.session_state.chart_candles = candles
|
| 136 |
+
st.session_state.last_alerts = prev
|
| 137 |
+
st.session_state.last_candle_epoch = now_ep
|
| 138 |
+
|
| 139 |
+
# ──────── Live Metrics ────────
|
| 140 |
+
@st.fragment(run_every="1.5s")
|
| 141 |
+
def live_metrics():
|
| 142 |
+
now_ep = time.time()
|
| 143 |
+
|
| 144 |
+
# Catch up: generate all candles that should have been created while away
|
| 145 |
+
last_ep = st.session_state.get("last_candle_epoch", now_ep - TICK_INTERVAL)
|
| 146 |
+
missed = int((now_ep - last_ep) / TICK_INTERVAL)
|
| 147 |
+
prev_a = st.session_state.get("last_alerts", 50)
|
| 148 |
+
|
| 149 |
+
if missed > 1:
|
| 150 |
+
# Backfill deterministically so chart stays continuous
|
| 151 |
+
for i in range(1, min(missed, CANDLE_COUNT) + 1):
|
| 152 |
+
t = last_ep + i * TICK_INTERVAL
|
| 153 |
+
curr_a = _alert_at(t)
|
| 154 |
+
st.session_state.chart_candles.append(_candle_at(t, prev_a, curr_a))
|
| 155 |
+
prev_a = curr_a
|
| 156 |
+
|
| 157 |
+
# Current tick
|
| 158 |
+
alerts = np.random.randint(0, ALERT_MAX + 1)
|
| 159 |
+
alert_delta = alerts - prev_a
|
| 160 |
+
st.session_state.chart_candles.append(_candle_at(now_ep, prev_a, alerts))
|
| 161 |
+
st.session_state.last_alerts = alerts
|
| 162 |
+
st.session_state.last_candle_epoch = now_ep
|
| 163 |
+
|
| 164 |
+
if len(st.session_state.chart_candles) > CANDLE_COUNT:
|
| 165 |
+
st.session_state.chart_candles = st.session_state.chart_candles[-CANDLE_COUNT:]
|
| 166 |
+
|
| 167 |
+
# Simulate patient cohort from alert count
|
| 168 |
+
n = max(alerts, 5)
|
| 169 |
+
sim_hr = np.clip(np.random.normal(HR_AVG, 12, n), HR_MIN, HR_MAX)
|
| 170 |
+
sim_o2 = np.clip(np.random.normal(SPO2_AVG, 3, n), SPO2_MIN, SPO2_MAX)
|
| 171 |
+
pop_hr = round(float(np.mean(sim_hr)), 1)
|
| 172 |
+
pop_o2 = round(float(np.mean(sim_o2)), 1)
|
| 173 |
+
hr_delta = round(pop_hr - HR_AVG, 1)
|
| 174 |
+
o2_delta = round(pop_o2 - SPO2_AVG, 1)
|
| 175 |
+
hr_ok = HR_NORMAL_LO <= pop_hr <= HR_NORMAL_HI
|
| 176 |
+
o2_ok = pop_o2 >= SPO2_NORMAL_LO
|
| 177 |
+
|
| 178 |
+
# Alert delta coloring: increase = bad (red), decrease = good (green)
|
| 179 |
+
ad_good = alert_delta <= 0
|
| 180 |
+
ad_c = "#00cc96" if ad_good else "#ef553b"
|
| 181 |
+
ad_d = "delta-good" if ad_good else "delta-bad"
|
| 182 |
+
ad_i = "▼" if ad_good else "▲"
|
| 183 |
+
ad_txt = f"{abs(alert_delta)} fewer" if ad_good else f"{alert_delta} more"
|
| 184 |
+
|
| 185 |
+
hr_c = "#00cc96" if hr_ok else "#ef553b"
|
| 186 |
+
hr_d = "delta-good" if hr_ok else "delta-bad"
|
| 187 |
+
hr_a = "▲" if hr_delta > 0 else "▼"
|
| 188 |
+
hr_s = "Within Normal" if hr_ok else "Abnormal"
|
| 189 |
+
o2_c = "#00cc96" if o2_ok else "#ef553b"
|
| 190 |
+
o2_d = "delta-good" if o2_ok else "delta-bad"
|
| 191 |
+
o2_a = "▲" if o2_delta > 0 else "▼"
|
| 192 |
+
o2_s = "Within Normal" if o2_ok else "Below Safe"
|
| 193 |
+
|
| 194 |
+
st.markdown(f"""
|
| 195 |
+
<div class="metrics-grid">
|
| 196 |
+
<div class="glass-metric">
|
| 197 |
+
<div class="metric-label">Total Patients</div>
|
| 198 |
+
<div class="metric-value" style="color:#4facfe;">60,000</div>
|
| 199 |
+
<div class="metric-delta delta-good">● Live Stream Active</div>
|
| 200 |
+
</div>
|
| 201 |
+
<div class="glass-metric">
|
| 202 |
+
<div class="metric-label">Active Alerts</div>
|
| 203 |
+
<div class="metric-value" style="color:{ad_c};">{alerts}</div>
|
| 204 |
+
<div class="metric-delta {ad_d}">{ad_i} {ad_txt} vs last</div>
|
| 205 |
+
</div>
|
| 206 |
+
<div class="glass-metric">
|
| 207 |
+
<div class="metric-label">Avg Heart Rate</div>
|
| 208 |
+
<div class="metric-value" style="color:{hr_c};">{pop_hr} <span style="font-size:1rem;color:rgba(255,255,255,0.3);">bpm</span></div>
|
| 209 |
+
<div class="metric-delta {hr_d}">{hr_a} {abs(hr_delta)} bpm from avg • {hr_s}</div>
|
| 210 |
+
</div>
|
| 211 |
+
<div class="glass-metric">
|
| 212 |
+
<div class="metric-label">Avg SpO2 Level</div>
|
| 213 |
+
<div class="metric-value" style="color:{o2_c};">{pop_o2}<span style="font-size:1rem;color:rgba(255,255,255,0.3);">%</span></div>
|
| 214 |
+
<div class="metric-delta {o2_d}">{o2_a} {abs(o2_delta):.1f}% from avg • {o2_s}</div>
|
| 215 |
+
</div>
|
| 216 |
+
</div>
|
| 217 |
+
""", unsafe_allow_html=True)
|
| 218 |
+
|
| 219 |
+
# ──── Smoothed Activity Area Chart: tracks average heart-rate & alerts ────
|
| 220 |
+
st.markdown("##### 📡 Live Patient Emulation Trace")
|
| 221 |
+
|
| 222 |
+
cd = st.session_state.chart_candles
|
| 223 |
+
ts = [x["time"] for x in cd]
|
| 224 |
+
# Represent the primary metric as the close/current state
|
| 225 |
+
cl = [x["close"] for x in cd]
|
| 226 |
+
|
| 227 |
+
fig = go.Figure()
|
| 228 |
+
|
| 229 |
+
# Main Area Waveform
|
| 230 |
+
fig.add_trace(go.Scatter(
|
| 231 |
+
x=ts, y=cl,
|
| 232 |
+
mode='lines',
|
| 233 |
+
line=dict(color='#00f2fe', width=3, shape='spline', smoothing=1.3),
|
| 234 |
+
fill='tozeroy',
|
| 235 |
+
fillcolor='rgba(0, 242, 254, 0.15)',
|
| 236 |
+
name="Activity Index"
|
| 237 |
+
))
|
| 238 |
+
|
| 239 |
+
# Add a thin secondary baseline trend
|
| 240 |
+
w = min(10, len(cl))
|
| 241 |
+
ma = pd.Series(cl).rolling(window=w, min_periods=1).mean().tolist()
|
| 242 |
+
fig.add_trace(go.Scatter(
|
| 243 |
+
x=ts, y=ma, mode='lines',
|
| 244 |
+
line=dict(color='rgba(255, 255, 255, 0.4)', width=1.5, dash='dot'),
|
| 245 |
+
name="Moving Average"
|
| 246 |
+
))
|
| 247 |
+
|
| 248 |
+
fig.update_layout(
|
| 249 |
+
template="plotly_dark", height=320,
|
| 250 |
+
margin=dict(l=0, r=50, t=10, b=0),
|
| 251 |
+
paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)',
|
| 252 |
+
xaxis=dict(gridcolor='rgba(255,255,255,0.03)', showgrid=True, rangeslider=dict(visible=False)),
|
| 253 |
+
yaxis=dict(
|
| 254 |
+
title="Relative Activity",
|
| 255 |
+
gridcolor='rgba(255,255,255,0.05)',
|
| 256 |
+
title_font=dict(size=11),
|
| 257 |
+
side="right",
|
| 258 |
+
range=[0, ALERT_MAX + 15]
|
| 259 |
+
),
|
| 260 |
+
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1, font=dict(size=10)),
|
| 261 |
+
showlegend=True,
|
| 262 |
+
hovermode="x unified"
|
| 263 |
+
)
|
| 264 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 265 |
+
|
| 266 |
+
live_metrics()
|
| 267 |
+
|
| 268 |
+
st.markdown("---")
|
| 269 |
+
|
| 270 |
+
# ──────── Feature Cards ────────
|
| 271 |
+
st.markdown("##### 🧩 System Capabilities")
|
| 272 |
+
st.markdown("""
|
| 273 |
+
<div class="feature-grid">
|
| 274 |
+
<div class="feature-card">
|
| 275 |
+
<div class="fc-icon">🩺</div>
|
| 276 |
+
<div class="fc-title">Edge Prediction</div>
|
| 277 |
+
<div class="fc-desc">5-model weighted ensemble (KNN, SVM, LogReg, RF, MLP) with real-time consensus voting and confidence scoring.</div>
|
| 278 |
+
</div>
|
| 279 |
+
<div class="feature-card">
|
| 280 |
+
<div class="fc-icon">🔍</div>
|
| 281 |
+
<div class="fc-title">SHAP Explainability</div>
|
| 282 |
+
<div class="fc-desc">Feature-level SHAP analysis reveals exactly why the AI made each clinical decision with interactive Plotly charts.</div>
|
| 283 |
+
</div>
|
| 284 |
+
<div class="feature-card">
|
| 285 |
+
<div class="fc-icon">⚙️</div>
|
| 286 |
+
<div class="fc-title">C-Code Export</div>
|
| 287 |
+
<div class="fc-desc">Transpile any model to raw C headers with INT8 quantization simulation for ESP32 and Arduino flash deployment.</div>
|
| 288 |
+
</div>
|
| 289 |
+
<div class="feature-card">
|
| 290 |
+
<div class="fc-icon">🔄</div>
|
| 291 |
+
<div class="fc-title">MLOps Pipeline</div>
|
| 292 |
+
<div class="fc-desc">Dynamic dataset upload and live ensemble retraining without downtime. Full model hot-swap on the fly.</div>
|
| 293 |
+
</div>
|
| 294 |
+
</div>
|
| 295 |
+
""", unsafe_allow_html=True)
|
| 296 |
+
|
| 297 |
+
st.markdown("<br>", unsafe_allow_html=True)
|
| 298 |
+
st.caption("Use the sidebar to navigate between Prediction, Analytics, Edge Deployment, Patient History, MLOps, and About.")
|
frontend/pages/1_Predict_Dashboard.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import requests
|
| 3 |
+
import os
|
| 4 |
+
import plotly.graph_objects as go
|
| 5 |
+
|
| 6 |
+
st.set_page_config(page_title="Predict Patient Health", page_icon="🩺", layout="wide")
|
| 7 |
+
API_URL = os.getenv("API_URL", "http://localhost:8000")
|
| 8 |
+
|
| 9 |
+
st.title("🩺 Patient Edge Prediction Simulation")
|
| 10 |
+
|
| 11 |
+
st.markdown("""
|
| 12 |
+
<style>
|
| 13 |
+
div[data-testid="stMetricValue"] {
|
| 14 |
+
font-size: 1.5rem;
|
| 15 |
+
}
|
| 16 |
+
.red-card {
|
| 17 |
+
background: rgba(255, 50, 50, 0.15);
|
| 18 |
+
backdrop-filter: blur(10px);
|
| 19 |
+
-webkit-backdrop-filter: blur(10px);
|
| 20 |
+
padding: 20px;
|
| 21 |
+
border-radius: 15px;
|
| 22 |
+
border: 1px solid rgba(255, 50, 50, 0.3);
|
| 23 |
+
box-shadow: 0 8px 32px 0 rgba(255, 50, 50, 0.2);
|
| 24 |
+
}
|
| 25 |
+
.green-card {
|
| 26 |
+
background: rgba(50, 255, 100, 0.15);
|
| 27 |
+
backdrop-filter: blur(10px);
|
| 28 |
+
-webkit-backdrop-filter: blur(10px);
|
| 29 |
+
padding: 20px;
|
| 30 |
+
border-radius: 15px;
|
| 31 |
+
border: 1px solid rgba(50, 255, 100, 0.3);
|
| 32 |
+
box-shadow: 0 8px 32px 0 rgba(50, 255, 100, 0.2);
|
| 33 |
+
}
|
| 34 |
+
.amber-card {
|
| 35 |
+
background: rgba(255, 180, 50, 0.15);
|
| 36 |
+
backdrop-filter: blur(10px);
|
| 37 |
+
-webkit-backdrop-filter: blur(10px);
|
| 38 |
+
padding: 20px;
|
| 39 |
+
border-radius: 15px;
|
| 40 |
+
border: 1px solid rgba(255, 180, 50, 0.3);
|
| 41 |
+
box-shadow: 0 8px 32px 0 rgba(255, 180, 50, 0.2);
|
| 42 |
+
}
|
| 43 |
+
.node-row {
|
| 44 |
+
display: flex;
|
| 45 |
+
align-items: center;
|
| 46 |
+
justify-content: space-between;
|
| 47 |
+
padding: 8px 12px;
|
| 48 |
+
margin: 4px 0;
|
| 49 |
+
border-radius: 8px;
|
| 50 |
+
background: rgba(255,255,255,0.04);
|
| 51 |
+
border-left: 3px solid;
|
| 52 |
+
}
|
| 53 |
+
</style>
|
| 54 |
+
""", unsafe_allow_html=True)
|
| 55 |
+
|
| 56 |
+
# Persist random defaults using session_state keys
|
| 57 |
+
import random
|
| 58 |
+
if "sl_hr" not in st.session_state:
|
| 59 |
+
st.session_state.sl_hr = float(random.randint(60, 110))
|
| 60 |
+
st.session_state.sl_spo2 = float(random.randint(85, 99))
|
| 61 |
+
st.session_state.sl_sys = float(random.randint(110, 140))
|
| 62 |
+
st.session_state.sl_dia = float(random.randint(70, 90))
|
| 63 |
+
st.session_state.sl_tmp = round(random.uniform(36.0, 38.0), 1)
|
| 64 |
+
|
| 65 |
+
col1, col2 = st.columns(2)
|
| 66 |
+
with col1:
|
| 67 |
+
hr = st.slider("Heart Rate (bpm)", 40.0, 200.0, key="sl_hr")
|
| 68 |
+
spo2 = st.slider("SpO2 Level (%)", 70.0, 100.0, key="sl_spo2")
|
| 69 |
+
sys_bp = st.slider("Systolic BP (mmHg)", 70.0, 200.0, key="sl_sys")
|
| 70 |
+
|
| 71 |
+
with col2:
|
| 72 |
+
dia_bp = st.slider("Diastolic BP (mmHg)", 40.0, 130.0, key="sl_dia")
|
| 73 |
+
tmp = st.slider("Body Temp (°C)", 34.0, 42.0, key="sl_tmp")
|
| 74 |
+
fall = st.selectbox("Fall Detection", ["No", "Yes"])
|
| 75 |
+
|
| 76 |
+
MODEL_DISPLAY = {
|
| 77 |
+
"knn": "K-Nearest Neighbors",
|
| 78 |
+
"svm": "Support Vector Machine",
|
| 79 |
+
"logreg": "Logistic Regression",
|
| 80 |
+
"rf": "Random Forest",
|
| 81 |
+
"small_nn": "Neural Network (MLP)"
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
DISEASE_COLORS = {
|
| 85 |
+
"Healthy": "#00cc96",
|
| 86 |
+
"Asthma": "#ab63fa",
|
| 87 |
+
"Diabetes Mellitus": "#ffa15a",
|
| 88 |
+
"Heart Disease": "#ef553b",
|
| 89 |
+
"Hypertension": "#636efa"
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
def render_results(data):
|
| 93 |
+
st.markdown("---")
|
| 94 |
+
label = data['prediction_label']
|
| 95 |
+
conf = data['probability'] * 100
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
res_col1, res_col2 = st.columns([1, 1])
|
| 99 |
+
|
| 100 |
+
with res_col1:
|
| 101 |
+
st.markdown("#### 🏥 Diagnosis Status")
|
| 102 |
+
card_color = DISEASE_COLORS.get(label, "#636efa")
|
| 103 |
+
is_healthy = label == "Healthy"
|
| 104 |
+
card_class = "green-card" if is_healthy else "red-card" if label == "Heart Disease" else "amber-card"
|
| 105 |
+
status_text = f"Status: {label}" if is_healthy else f"Status: {label} (At Risk)"
|
| 106 |
+
st.markdown(f'<div class="{card_class}"><h3>{status_text}</h3></div>', unsafe_allow_html=True)
|
| 107 |
+
st.progress(int(conf))
|
| 108 |
+
st.write(f"**Ensemble Confidence:** {conf:.1f}%")
|
| 109 |
+
|
| 110 |
+
with res_col2:
|
| 111 |
+
st.markdown("#### 🧠 Individual Node Consensus & Weights")
|
| 112 |
+
for model_key, pred in data['model_outputs'].items():
|
| 113 |
+
display_name = MODEL_DISPLAY.get(model_key, model_key.upper())
|
| 114 |
+
wgt = data.get('weights', {}).get(model_key, 0)
|
| 115 |
+
prob = data.get('model_probs', {}).get(model_key, 0) * 100
|
| 116 |
+
pred_color = DISEASE_COLORS.get(pred, "#636efa")
|
| 117 |
+
border_color = "#00cc96" if pred == "Healthy" else "#ef553b"
|
| 118 |
+
st.markdown(
|
| 119 |
+
f'<div class="node-row" style="border-left-color: {border_color};">'
|
| 120 |
+
f'<span><strong>{display_name}</strong> (w={wgt:.2f})</span>'
|
| 121 |
+
f'<span style="color: {pred_color}; font-weight: bold;">{pred} ({prob:.0f}%)</span>'
|
| 122 |
+
f'</div>',
|
| 123 |
+
unsafe_allow_html=True
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
# SHAP Explainability Section
|
| 127 |
+
with st.expander("🔍 Explain AI Prediction (SHAP Explainability)", expanded=False):
|
| 128 |
+
st.markdown("##### How each vital sign influenced the AI's decision:")
|
| 129 |
+
try:
|
| 130 |
+
url_exp = f"{API_URL.rstrip('/')}/explain"
|
| 131 |
+
exp_resp = requests.post(url_exp, json=payload, timeout=30)
|
| 132 |
+
if exp_resp.status_code == 200:
|
| 133 |
+
exp_data = exp_resp.json()
|
| 134 |
+
if "error" in exp_data:
|
| 135 |
+
st.warning(exp_data["error"])
|
| 136 |
+
else:
|
| 137 |
+
shap_vals = exp_data["shap_values"]
|
| 138 |
+
feat_names = exp_data["feature_names"]
|
| 139 |
+
|
| 140 |
+
# Clean up feature names for display
|
| 141 |
+
clean_names = []
|
| 142 |
+
for f in feat_names:
|
| 143 |
+
f = f.replace(" (bpm)", "").replace(" (%)", "").replace(" (mmHg)", "").replace(" (°C)", "")
|
| 144 |
+
clean_names.append(f)
|
| 145 |
+
|
| 146 |
+
# Sort by absolute value for visual impact
|
| 147 |
+
sorted_pairs = sorted(zip(clean_names, shap_vals), key=lambda x: abs(x[1]))
|
| 148 |
+
s_names, s_vals = zip(*sorted_pairs)
|
| 149 |
+
|
| 150 |
+
colors = ['#ef553b' if v > 0 else '#00cc96' for v in s_vals]
|
| 151 |
+
|
| 152 |
+
fig = go.Figure(go.Bar(
|
| 153 |
+
x=list(s_vals),
|
| 154 |
+
y=list(s_names),
|
| 155 |
+
orientation='h',
|
| 156 |
+
marker=dict(
|
| 157 |
+
color=colors,
|
| 158 |
+
line=dict(width=1, color='rgba(255,255,255,0.2)')
|
| 159 |
+
),
|
| 160 |
+
text=[f"{v:+.4f}" for v in s_vals],
|
| 161 |
+
textposition='outside',
|
| 162 |
+
textfont=dict(color='white', size=11)
|
| 163 |
+
))
|
| 164 |
+
fig.update_layout(
|
| 165 |
+
title=dict(text=f"Feature Impact on '{label}' Prediction", font=dict(color='white', size=16)),
|
| 166 |
+
xaxis_title="SHAP Value (←Reduces Risk | Increases Risk→)",
|
| 167 |
+
yaxis_title="",
|
| 168 |
+
template="plotly_dark",
|
| 169 |
+
height=350,
|
| 170 |
+
margin=dict(l=10, r=80, t=50, b=50),
|
| 171 |
+
paper_bgcolor='rgba(0,0,0,0)',
|
| 172 |
+
plot_bgcolor='rgba(0,0,0,0)',
|
| 173 |
+
xaxis=dict(zeroline=True, zerolinecolor='rgba(255,255,255,0.3)', zerolinewidth=2, gridcolor='rgba(255,255,255,0.07)'),
|
| 174 |
+
yaxis=dict(gridcolor='rgba(255,255,255,0.07)')
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 178 |
+
|
| 179 |
+
# ──── Detailed Key Insights Panel ────
|
| 180 |
+
st.markdown("---")
|
| 181 |
+
st.markdown("##### 📊 Key Insights — Clinical Explainability Report")
|
| 182 |
+
|
| 183 |
+
# Sort features by absolute SHAP impact
|
| 184 |
+
paired = list(zip(feat_names, shap_vals))
|
| 185 |
+
paired_sorted = sorted(paired, key=lambda x: abs(x[1]), reverse=True)
|
| 186 |
+
risk_factors = [(n, v) for n, v in paired_sorted if v > 0.001]
|
| 187 |
+
protective = [(n, v) for n, v in paired_sorted if v < -0.001]
|
| 188 |
+
total_push = sum(v for _, v in paired)
|
| 189 |
+
risk_direction = "toward a clinical condition" if total_push > 0 else "toward a healthy classification"
|
| 190 |
+
|
| 191 |
+
ins_c1, ins_c2 = st.columns(2)
|
| 192 |
+
|
| 193 |
+
with ins_c1:
|
| 194 |
+
st.markdown("**🔴 Risk-Increasing Factors**")
|
| 195 |
+
if risk_factors:
|
| 196 |
+
for name, val in risk_factors:
|
| 197 |
+
pct = abs(val) / max(sum(abs(v) for _, v in paired), 1e-9) * 100
|
| 198 |
+
st.markdown(
|
| 199 |
+
f'<div style="background:rgba(239,85,59,0.08);border-left:3px solid #ef553b;'
|
| 200 |
+
f'padding:8px 12px;border-radius:6px;margin:4px 0;">'
|
| 201 |
+
f'<strong>{name}</strong><br>'
|
| 202 |
+
f'<span style="color:#ef553b;">SHAP +{val:.4f}</span> '
|
| 203 |
+
f'<span style="color:rgba(255,255,255,0.4);">({pct:.0f}% of total impact)</span>'
|
| 204 |
+
f'</div>',
|
| 205 |
+
unsafe_allow_html=True
|
| 206 |
+
)
|
| 207 |
+
else:
|
| 208 |
+
st.caption("No features significantly increasing risk.")
|
| 209 |
+
|
| 210 |
+
with ins_c2:
|
| 211 |
+
st.markdown("**🟢 Protective Factors**")
|
| 212 |
+
if protective:
|
| 213 |
+
for name, val in protective:
|
| 214 |
+
pct = abs(val) / max(sum(abs(v) for _, v in paired), 1e-9) * 100
|
| 215 |
+
st.markdown(
|
| 216 |
+
f'<div style="background:rgba(0,204,150,0.08);border-left:3px solid #00cc96;'
|
| 217 |
+
f'padding:8px 12px;border-radius:6px;margin:4px 0;">'
|
| 218 |
+
f'<strong>{name}</strong><br>'
|
| 219 |
+
f'<span style="color:#00cc96;">SHAP {val:.4f}</span> '
|
| 220 |
+
f'<span style="color:rgba(255,255,255,0.4);">({pct:.0f}% of total impact)</span>'
|
| 221 |
+
f'</div>',
|
| 222 |
+
unsafe_allow_html=True
|
| 223 |
+
)
|
| 224 |
+
else:
|
| 225 |
+
st.caption("No features significantly reducing risk.")
|
| 226 |
+
|
| 227 |
+
# Overall clinical summary
|
| 228 |
+
top_feat, top_val = paired_sorted[0]
|
| 229 |
+
second_feat = paired_sorted[1][0] if len(paired_sorted) > 1 else None
|
| 230 |
+
top_dir = "increasing" if top_val > 0 else "decreasing"
|
| 231 |
+
|
| 232 |
+
summary_parts = [
|
| 233 |
+
f"The ensemble model's decision was primarily driven by **{top_feat}** "
|
| 234 |
+
f"(SHAP {top_val:+.4f}), which had the strongest influence in {top_dir} the risk score.",
|
| 235 |
+
]
|
| 236 |
+
if second_feat:
|
| 237 |
+
s_val = paired_sorted[1][1]
|
| 238 |
+
s_dir = "increasing" if s_val > 0 else "decreasing"
|
| 239 |
+
summary_parts.append(
|
| 240 |
+
f"The second most influential factor was **{second_feat}** "
|
| 241 |
+
f"(SHAP {s_val:+.4f}), {s_dir} risk."
|
| 242 |
+
)
|
| 243 |
+
summary_parts.append(
|
| 244 |
+
f"Overall, the net SHAP push ({total_push:+.4f}) points **{risk_direction}** "
|
| 245 |
+
f"for the given vitals."
|
| 246 |
+
)
|
| 247 |
+
if risk_factors and protective:
|
| 248 |
+
summary_parts.append(
|
| 249 |
+
f"The model identified {len(risk_factors)} risk-increasing and "
|
| 250 |
+
f"{len(protective)} protective factor(s), indicating a mixed clinical profile "
|
| 251 |
+
f"that warrants attention to the dominant contributors."
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
st.info("💡 **Clinical Interpretation:**\n\n" + " ".join(summary_parts))
|
| 255 |
+
except Exception as e:
|
| 256 |
+
st.error(f"Failed to fetch explanation: {e}")
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
if st.button("Query Edge AI Ensemble", type="primary"):
|
| 260 |
+
payload = {
|
| 261 |
+
"Heart_Rate": hr,
|
| 262 |
+
"SpO2_Level": spo2,
|
| 263 |
+
"Systolic_BP": sys_bp,
|
| 264 |
+
"Diastolic_BP": dia_bp,
|
| 265 |
+
"Body_Temp": tmp,
|
| 266 |
+
"Fall_Detection": fall
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
with st.spinner("Processing through TinyML Nodes..."):
|
| 270 |
+
try:
|
| 271 |
+
url = f"{API_URL.rstrip('/')}/predict"
|
| 272 |
+
resp = requests.post(url, json=payload, timeout=30)
|
| 273 |
+
if resp.status_code == 200:
|
| 274 |
+
data = resp.json()
|
| 275 |
+
if "error" in data:
|
| 276 |
+
st.error(data["error"])
|
| 277 |
+
else:
|
| 278 |
+
render_results(data)
|
| 279 |
+
else:
|
| 280 |
+
st.error(f"API returned an error: {resp.status_code} — {resp.text[:500]}")
|
| 281 |
+
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as conn_err:
|
| 282 |
+
try:
|
| 283 |
+
import sys
|
| 284 |
+
import os
|
| 285 |
+
import pandas as pd
|
| 286 |
+
|
| 287 |
+
backend_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../backend'))
|
| 288 |
+
if backend_path not in sys.path:
|
| 289 |
+
sys.path.append(backend_path)
|
| 290 |
+
|
| 291 |
+
from ensemble import EnsembleModel
|
| 292 |
+
from preprocessing import preprocess_data
|
| 293 |
+
|
| 294 |
+
df_simulate = pd.DataFrame([{
|
| 295 |
+
"Heart Rate (bpm)": payload["Heart_Rate"],
|
| 296 |
+
"SpO2 Level (%)": payload["SpO2_Level"],
|
| 297 |
+
"Systolic Blood Pressure (mmHg)": payload["Systolic_BP"],
|
| 298 |
+
"Diastolic Blood Pressure (mmHg)": payload["Diastolic_BP"],
|
| 299 |
+
"Body Temperature (°C)": payload["Body_Temp"],
|
| 300 |
+
"Fall Detection": payload["Fall_Detection"]
|
| 301 |
+
}])
|
| 302 |
+
|
| 303 |
+
X_proc, _ = preprocess_data(df_simulate, is_training=False)
|
| 304 |
+
eng = EnsembleModel()
|
| 305 |
+
final_pred, conf, ind_preds, ind_probs, weights = eng.predict(X_proc)
|
| 306 |
+
|
| 307 |
+
data = {
|
| 308 |
+
"prediction": 0 if final_pred == "Healthy" else 1,
|
| 309 |
+
"prediction_label": final_pred,
|
| 310 |
+
"probability": float(conf),
|
| 311 |
+
"model_outputs": ind_preds,
|
| 312 |
+
"model_probs": {k: float(max(v)) for k, v in ind_probs.items()},
|
| 313 |
+
"weights": weights
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
st.markdown("---")
|
| 317 |
+
st.info("☁️ **Cloud Mode (Fallback)**: Processed via Monolithic Native Architecture.")
|
| 318 |
+
render_results(data)
|
| 319 |
+
|
| 320 |
+
except Exception as inner_e:
|
| 321 |
+
st.error(f"Monolithic execution failed: {inner_e}")
|
| 322 |
+
|
| 323 |
+
except Exception as e:
|
| 324 |
+
st.error(f"Failed to process Request context: {e}")
|
frontend/pages/2_Data_Analytics.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import plotly.express as px
|
| 4 |
+
import plotly.graph_objects as go
|
| 5 |
+
import numpy as np
|
| 6 |
+
import os
|
| 7 |
+
import requests
|
| 8 |
+
|
| 9 |
+
st.set_page_config(page_title="Advanced Analytics", page_icon="📊", layout="wide")
|
| 10 |
+
st.title("📊 Population Analytics Deep Dive")
|
| 11 |
+
|
| 12 |
+
API_URL = os.getenv("API_URL", "http://localhost:8000")
|
| 13 |
+
|
| 14 |
+
@st.cache_data(ttl=3600)
|
| 15 |
+
def load_data():
|
| 16 |
+
try:
|
| 17 |
+
resp = requests.get(f"{API_URL}/dataset", timeout=30)
|
| 18 |
+
if resp.status_code == 200:
|
| 19 |
+
data = resp.json()
|
| 20 |
+
if isinstance(data, list):
|
| 21 |
+
return pd.DataFrame(data)
|
| 22 |
+
st.error(f"Backend Error: {data.get('error', 'Unknown')}")
|
| 23 |
+
except Exception as e:
|
| 24 |
+
st.error(f"Failed to connect to backend: {e}")
|
| 25 |
+
return pd.DataFrame()
|
| 26 |
+
|
| 27 |
+
DISEASE_COLORS = {
|
| 28 |
+
"Healthy": "#00cc96",
|
| 29 |
+
"Asthma": "#ab63fa",
|
| 30 |
+
"Diabetes Mellitus": "#ffa15a",
|
| 31 |
+
"Heart Disease": "#ef553b",
|
| 32 |
+
"Hypertension": "#636efa"
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
full_df = load_data()
|
| 36 |
+
|
| 37 |
+
if full_df.empty:
|
| 38 |
+
st.warning("Warning: Dataset not found or backend unreachable. Connect to the API to view analytics.")
|
| 39 |
+
else:
|
| 40 |
+
df = full_df
|
| 41 |
+
st.success(f"Loaded **{len(full_df):,}** patient records from backend.")
|
| 42 |
+
|
| 43 |
+
# ... rest of the file stays same ...
|
| 44 |
+
st.markdown("### 🫀 Vital Signs Distribution by Condition")
|
| 45 |
+
vitals = ["Heart Rate (bpm)", "SpO2 Level (%)", "Systolic Blood Pressure (mmHg)", "Diastolic Blood Pressure (mmHg)", "Body Temperature (°C)"]
|
| 46 |
+
selected_vital = st.selectbox("Select Vital Sign", vitals)
|
| 47 |
+
|
| 48 |
+
fig_box = px.box(
|
| 49 |
+
df, x="Predicted Disease", y=selected_vital,
|
| 50 |
+
color="Predicted Disease",
|
| 51 |
+
color_discrete_map=DISEASE_COLORS,
|
| 52 |
+
title=f"{selected_vital} Distribution Across Conditions",
|
| 53 |
+
template="plotly_dark",
|
| 54 |
+
points="outliers"
|
| 55 |
+
)
|
| 56 |
+
fig_box.update_layout(
|
| 57 |
+
paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)',
|
| 58 |
+
xaxis_title="Condition", yaxis_title=selected_vital,
|
| 59 |
+
showlegend=False, height=420
|
| 60 |
+
)
|
| 61 |
+
st.plotly_chart(fig_box, use_container_width=True)
|
| 62 |
+
|
| 63 |
+
st.markdown("---")
|
| 64 |
+
|
| 65 |
+
c1, c2 = st.columns(2)
|
| 66 |
+
with c1:
|
| 67 |
+
st.markdown("##### Heart Rate vs SpO2 (Scatter Matrix)")
|
| 68 |
+
fig_scatter = px.scatter(
|
| 69 |
+
df, x="Heart Rate (bpm)", y="SpO2 Level (%)",
|
| 70 |
+
color="Predicted Disease",
|
| 71 |
+
color_discrete_map=DISEASE_COLORS,
|
| 72 |
+
opacity=0.4, size_max=6,
|
| 73 |
+
title="Heart Rate vs SpO2 Levels",
|
| 74 |
+
template="plotly_dark",
|
| 75 |
+
marginal_x="histogram",
|
| 76 |
+
marginal_y="histogram"
|
| 77 |
+
)
|
| 78 |
+
fig_scatter.update_layout(
|
| 79 |
+
paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)',
|
| 80 |
+
height=450, legend=dict(orientation="h", yanchor="bottom", y=-0.35)
|
| 81 |
+
)
|
| 82 |
+
st.plotly_chart(fig_scatter, use_container_width=True)
|
| 83 |
+
|
| 84 |
+
with c2:
|
| 85 |
+
st.markdown("##### Temperature by Condition (Violin Plot)")
|
| 86 |
+
fig_violin = px.violin(
|
| 87 |
+
df, x="Predicted Disease", y="Body Temperature (°C)",
|
| 88 |
+
color="Predicted Disease",
|
| 89 |
+
color_discrete_map=DISEASE_COLORS,
|
| 90 |
+
box=True, points=False,
|
| 91 |
+
title="Body Temperature Distribution",
|
| 92 |
+
template="plotly_dark"
|
| 93 |
+
)
|
| 94 |
+
fig_violin.update_layout(
|
| 95 |
+
paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)',
|
| 96 |
+
height=450, showlegend=False, xaxis_title="Condition", yaxis_title="Temperature (°C)"
|
| 97 |
+
)
|
| 98 |
+
st.plotly_chart(fig_violin, use_container_width=True)
|
| 99 |
+
|
| 100 |
+
st.markdown("---")
|
| 101 |
+
|
| 102 |
+
c3, c4 = st.columns(2)
|
| 103 |
+
with c3:
|
| 104 |
+
st.markdown("##### 🏥 Disease Distribution (Population Count)")
|
| 105 |
+
counts = df["Predicted Disease"].value_counts().reset_index()
|
| 106 |
+
counts.columns = ["Condition", "Patient Count"]
|
| 107 |
+
fig_bar = px.bar(
|
| 108 |
+
counts, x="Condition", y="Patient Count",
|
| 109 |
+
color="Condition",
|
| 110 |
+
color_discrete_map=DISEASE_COLORS,
|
| 111 |
+
title="Patient Count per Condition",
|
| 112 |
+
template="plotly_dark",
|
| 113 |
+
text="Patient Count"
|
| 114 |
+
)
|
| 115 |
+
fig_bar.update_traces(textposition='outside')
|
| 116 |
+
fig_bar.update_layout(
|
| 117 |
+
paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)',
|
| 118 |
+
height=420, showlegend=False
|
| 119 |
+
)
|
| 120 |
+
st.plotly_chart(fig_bar, use_container_width=True)
|
| 121 |
+
|
| 122 |
+
with c4:
|
| 123 |
+
st.markdown("##### 🩸 Blood Pressure Density (Systolic vs Diastolic)")
|
| 124 |
+
fig_density = px.density_heatmap(
|
| 125 |
+
df, x="Systolic Blood Pressure (mmHg)", y="Diastolic Blood Pressure (mmHg)",
|
| 126 |
+
color_continuous_scale="Turbo",
|
| 127 |
+
title="Blood Pressure Population Heatmap",
|
| 128 |
+
template="plotly_dark",
|
| 129 |
+
nbinsx=30, nbinsy=30
|
| 130 |
+
)
|
| 131 |
+
fig_density.update_layout(
|
| 132 |
+
paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)',
|
| 133 |
+
height=420
|
| 134 |
+
)
|
| 135 |
+
st.plotly_chart(fig_density, use_container_width=True)
|
| 136 |
+
|
| 137 |
+
st.markdown("---")
|
| 138 |
+
st.markdown("### 📈 Patient-Specific Real-Time Monitoring")
|
| 139 |
+
p_id = st.selectbox("Select Patient ID to view live telemetry:", df['Patient Number'].unique()[:50])
|
| 140 |
+
if p_id:
|
| 141 |
+
patient_data = df[df['Patient Number'] == p_id].iloc[0]
|
| 142 |
+
disease = patient_data['Predicted Disease']
|
| 143 |
+
d_color = DISEASE_COLORS.get(disease, "#636efa")
|
| 144 |
+
st.markdown(f"**Patient Condition:** <span style='color:{d_color}; font-weight:bold'>{disease}</span>", unsafe_allow_html=True)
|
| 145 |
+
|
| 146 |
+
periods = 60
|
| 147 |
+
dates = pd.date_range(end=pd.Timestamp.now(), periods=periods, freq='25s')
|
| 148 |
+
base_hr = patient_data['Heart Rate (bpm)']
|
| 149 |
+
base_spo2 = patient_data['SpO2 Level (%)']
|
| 150 |
+
base_temp = patient_data['Body Temperature (°C)']
|
| 151 |
+
sim_hr = base_hr + np.random.normal(0, 1.2, periods).cumsum()
|
| 152 |
+
sim_spo2 = np.clip(base_spo2 + np.random.normal(0, 0.3, periods).cumsum(), 85, 100)
|
| 153 |
+
sim_temp = base_temp + np.random.normal(0, 0.05, periods).cumsum()
|
| 154 |
+
|
| 155 |
+
tc1, tc2, tc3 = st.columns(3)
|
| 156 |
+
with tc1:
|
| 157 |
+
fig_hr = go.Figure(go.Scatter(x=dates, y=sim_hr, mode='lines+markers', line=dict(color='#00f2fe', width=2), marker=dict(size=3), name="Heart Rate"))
|
| 158 |
+
fig_hr.update_layout(title="Heart Rate (bpm)", template="plotly_dark", paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)', height=280, margin=dict(l=10, r=10, t=40, b=40))
|
| 159 |
+
st.plotly_chart(fig_hr, use_container_width=True)
|
| 160 |
+
with tc2:
|
| 161 |
+
fig_sp = go.Figure(go.Scatter(x=dates, y=sim_spo2, mode='lines+markers', line=dict(color='#00cc96', width=2), marker=dict(size=3), name="SpO2"))
|
| 162 |
+
fig_sp.update_layout(title="SpO2 Level (%)", template="plotly_dark", paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)', height=280, margin=dict(l=10, r=10, t=40, b=40))
|
| 163 |
+
st.plotly_chart(fig_sp, use_container_width=True)
|
| 164 |
+
with tc3:
|
| 165 |
+
fig_tp = go.Figure(go.Scatter(x=dates, y=sim_temp, mode='lines+markers', line=dict(color='#ffa15a', width=2), marker=dict(size=3), name="Temperature"))
|
| 166 |
+
fig_tp.update_layout(title="Body Temperature (°C)", template="plotly_dark", paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)', height=280, margin=dict(l=10, r=10, t=40, b=40))
|
| 167 |
+
st.plotly_chart(fig_tp, use_container_width=True)
|
| 168 |
+
|
| 169 |
+
st.markdown("---")
|
| 170 |
+
st.markdown("### 📋 Raw Patient Data")
|
| 171 |
+
st.dataframe(df, use_container_width=True, height=350)
|
frontend/pages/3_Edge_Deployment.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import requests
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
st.set_page_config(page_title="TinyML Edge Deploy", page_icon="⚙️", layout="wide")
|
| 6 |
+
API_URL = os.getenv("API_URL", "http://localhost:8000")
|
| 7 |
+
|
| 8 |
+
st.title("⚙️ TinyML C-Code Exporter")
|
| 9 |
+
st.markdown("Export trained models directly to raw C code for compilation on embedded devices (Arduino, ESP32).")
|
| 10 |
+
|
| 11 |
+
model_mapping = {
|
| 12 |
+
"Random Forest": "rf",
|
| 13 |
+
"Logistic Regression": "logreg",
|
| 14 |
+
"K-Nearest Neighbors": "knn",
|
| 15 |
+
"Support Vector Machine": "svm",
|
| 16 |
+
"Neural Network (MLP)": "small_nn"
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
hw_mapping = {
|
| 20 |
+
"ESP32 (240 MHz, 520KB SRAM)": {"clock": 240, "ram": 520, "arch": "Xtensa LX6"},
|
| 21 |
+
"Arduino Nano 33 BLE Sense (64 MHz, 256KB SRAM)": {"clock": 64, "ram": 256, "arch": "Cortex-M4F"},
|
| 22 |
+
"STM32 Nucleo (84 MHz, 96KB SRAM)": {"clock": 84, "ram": 96, "arch": "Cortex-M4"},
|
| 23 |
+
"Raspberry Pi Pico (133 MHz, 264KB SRAM)": {"clock": 133, "ram": 264, "arch": "Cortex-M0+"}
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
col_m, col_h = st.columns(2)
|
| 27 |
+
with col_m:
|
| 28 |
+
model_display_name = st.selectbox("Select Model to Export", list(model_mapping.keys()))
|
| 29 |
+
model_choice = model_mapping[model_display_name]
|
| 30 |
+
with col_h:
|
| 31 |
+
hw_display_name = st.selectbox("Select Target Hardware Profile", list(hw_mapping.keys()))
|
| 32 |
+
hw_choice = hw_mapping[hw_display_name]
|
| 33 |
+
|
| 34 |
+
st.markdown("---")
|
| 35 |
+
st.markdown("##### ⚡ TinyML Optimization Settings")
|
| 36 |
+
opt_c1, opt_c2 = st.columns(2)
|
| 37 |
+
with opt_c1:
|
| 38 |
+
apply_quantization = st.checkbox("Apply INT8 Weight Quantization", value=True)
|
| 39 |
+
with opt_c2:
|
| 40 |
+
strip_comments = st.checkbox("Strip Comments for Minimal Flash Size", value=False)
|
| 41 |
+
|
| 42 |
+
if st.button("Generate C Code", type="primary"):
|
| 43 |
+
with st.spinner("Transpiling model to C..."):
|
| 44 |
+
try:
|
| 45 |
+
resp = requests.get(f"{API_URL}/export_tinyml", params={"model_name": model_choice, "quantize": apply_quantization}, timeout=15)
|
| 46 |
+
if resp.status_code == 200:
|
| 47 |
+
data = resp.json()
|
| 48 |
+
if "error" in data:
|
| 49 |
+
st.error(data["error"])
|
| 50 |
+
else:
|
| 51 |
+
c_code = data["code"]
|
| 52 |
+
|
| 53 |
+
if strip_comments:
|
| 54 |
+
import re
|
| 55 |
+
c_code = re.sub(r'/\*.*?\*/', '', c_code, flags=re.DOTALL)
|
| 56 |
+
c_code = re.sub(r'//.*', '', c_code)
|
| 57 |
+
c_code = re.sub(r'\n\s*\n', '\n', c_code)
|
| 58 |
+
|
| 59 |
+
st.success(f"✅ Transpilation successful for **{model_display_name}**!")
|
| 60 |
+
|
| 61 |
+
# Show stats
|
| 62 |
+
s1, s2, s3, s4 = st.columns(4)
|
| 63 |
+
code_len = len(c_code)
|
| 64 |
+
est_flash = code_len // (4 if apply_quantization else 1)
|
| 65 |
+
|
| 66 |
+
# Simple heuristic for estimated inference time
|
| 67 |
+
base_ops = {"rf": 500, "logreg": 20, "knn": 2000, "svm": 100, "small_nn": 800}[model_choice]
|
| 68 |
+
ops_multiplier = 0.5 if apply_quantization else 1.0
|
| 69 |
+
est_latency_us = int((base_ops * ops_multiplier) / (hw_choice["clock"] / 10.0))
|
| 70 |
+
|
| 71 |
+
s1.metric("Target Architecture", hw_choice["arch"])
|
| 72 |
+
s2.metric("Est. Code Flash", f"{est_flash:,} Bytes")
|
| 73 |
+
s3.metric("Precision", "INT8 (8-bit)" if apply_quantization else "FP32 (64-bit)")
|
| 74 |
+
s4.metric("Est. Inference Latency", f"~{max(1, est_latency_us)} µs")
|
| 75 |
+
|
| 76 |
+
st.download_button(
|
| 77 |
+
label="⬇️ Download tinyml_model.h",
|
| 78 |
+
data=c_code,
|
| 79 |
+
file_name=f"tinyml_{model_choice}.h",
|
| 80 |
+
mime="text/plain"
|
| 81 |
+
)
|
| 82 |
+
with st.expander("Preview C Code", expanded=True):
|
| 83 |
+
st.code(c_code, language="c")
|
| 84 |
+
else:
|
| 85 |
+
st.error("API returned an error code.")
|
| 86 |
+
except Exception as e:
|
| 87 |
+
st.error(f"Connection failed: {e}")
|
frontend/pages/4_Patient_History.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import requests
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
st.set_page_config(page_title="Patient History", page_icon="🗄️", layout="wide")
|
| 7 |
+
API_URL = os.getenv("API_URL", "http://localhost:8000")
|
| 8 |
+
|
| 9 |
+
st.title("🗄️ Patient Prediction History")
|
| 10 |
+
st.markdown("Live database of all patient predictions and vitals logged by the system. Auto-refreshes every 5 seconds.")
|
| 11 |
+
|
| 12 |
+
DISEASE_COLORS = {
|
| 13 |
+
"Healthy": "background-color: rgba(0, 204, 150, 0.15); color: #00cc96;",
|
| 14 |
+
"Asthma": "background-color: rgba(171, 99, 250, 0.15); color: #ab63fa;",
|
| 15 |
+
"Diabetes Mellitus": "background-color: rgba(255, 161, 90, 0.15); color: #ffa15a;",
|
| 16 |
+
"Heart Disease": "background-color: rgba(239, 85, 59, 0.15); color: #ef553b;",
|
| 17 |
+
"Hypertension": "background-color: rgba(99, 110, 250, 0.15); color: #636efa;",
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
COLUMN_RENAME = {
|
| 21 |
+
"id": "ID",
|
| 22 |
+
"timestamp": "Timestamp",
|
| 23 |
+
"prediction_label": "Diagnosis",
|
| 24 |
+
"confidence": "Confidence",
|
| 25 |
+
"heart_rate": "Heart Rate",
|
| 26 |
+
"spo2": "SpO2 %",
|
| 27 |
+
"sys_bp": "Sys BP",
|
| 28 |
+
"dia_bp": "Dia BP",
|
| 29 |
+
"temp": "Temp °C",
|
| 30 |
+
"fall_detection": "Fall"
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
@st.fragment(run_every="5s")
|
| 34 |
+
def live_history_table():
|
| 35 |
+
try:
|
| 36 |
+
resp = requests.get(f"{API_URL}/history", timeout=5)
|
| 37 |
+
if resp.status_code == 200:
|
| 38 |
+
history_data = resp.json()
|
| 39 |
+
if not history_data:
|
| 40 |
+
st.info("No prediction history found. Go make some predictions first!")
|
| 41 |
+
return
|
| 42 |
+
|
| 43 |
+
df = pd.DataFrame(history_data)
|
| 44 |
+
cols = ['id', 'timestamp', 'prediction_label', 'confidence', 'heart_rate', 'spo2', 'sys_bp', 'dia_bp', 'temp', 'fall_detection']
|
| 45 |
+
df = df[[c for c in cols if c in df.columns]]
|
| 46 |
+
df.rename(columns=COLUMN_RENAME, inplace=True)
|
| 47 |
+
|
| 48 |
+
if "Confidence" in df.columns:
|
| 49 |
+
df["Confidence"] = df["Confidence"].apply(lambda x: f"{x*100:.1f}%" if x <= 1 else f"{x:.1f}%")
|
| 50 |
+
|
| 51 |
+
def color_by_diagnosis(row):
|
| 52 |
+
diagnosis = row.get("Diagnosis", "")
|
| 53 |
+
style_str = DISEASE_COLORS.get(diagnosis, "")
|
| 54 |
+
return [style_str] * len(row)
|
| 55 |
+
|
| 56 |
+
styled = df.style.apply(color_by_diagnosis, axis=1)
|
| 57 |
+
|
| 58 |
+
st.dataframe(styled, width="stretch", height=600)
|
| 59 |
+
st.caption(f"Showing {len(df)} records. Auto-refreshing every 5s.")
|
| 60 |
+
else:
|
| 61 |
+
st.error("API Error")
|
| 62 |
+
except Exception as e:
|
| 63 |
+
st.error(f"Failed to fetch history: {e}")
|
| 64 |
+
|
| 65 |
+
live_history_table()
|
frontend/pages/5_MLOps_Settings.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import requests
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
st.set_page_config(page_title="MLOps Settings", page_icon="🔄", layout="wide")
|
| 6 |
+
API_URL = os.getenv("API_URL", "http://localhost:8000")
|
| 7 |
+
|
| 8 |
+
st.title("🔄 MLOps & Retraining")
|
| 9 |
+
st.markdown("Dynamically update the TinyML ensemble models by providing a new dataset.")
|
| 10 |
+
|
| 11 |
+
st.info("Upload a CSV file with the standard clinical schema to retrain all models on the fly.")
|
| 12 |
+
|
| 13 |
+
uploaded_file = st.file_uploader("Upload New Patient Dataset (CSV)", type="csv")
|
| 14 |
+
|
| 15 |
+
if uploaded_file is not None:
|
| 16 |
+
if st.button("Start Retraining Pipeline"):
|
| 17 |
+
with st.spinner("Uploading and retraining ensemble... This may take a minute."):
|
| 18 |
+
try:
|
| 19 |
+
files = {"file": (uploaded_file.name, uploaded_file.getvalue(), "text/csv")}
|
| 20 |
+
resp = requests.post(f"{API_URL}/retrain", files=files, timeout=120)
|
| 21 |
+
if resp.status_code == 200:
|
| 22 |
+
data = resp.json()
|
| 23 |
+
if "error" in data:
|
| 24 |
+
st.error(f"Retraining failed: {data['error']}")
|
| 25 |
+
else:
|
| 26 |
+
st.success(data.get("message", "Success!"))
|
| 27 |
+
else:
|
| 28 |
+
st.error("API returned an error.")
|
| 29 |
+
except Exception as e:
|
| 30 |
+
st.error(f"Connection failed: {e}")
|
frontend/pages/6_About.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import graphviz
|
| 3 |
+
|
| 4 |
+
st.set_page_config(page_title="About Project", page_icon="ℹ️", layout="wide")
|
| 5 |
+
|
| 6 |
+
st.markdown("""
|
| 7 |
+
<style>
|
| 8 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
|
| 9 |
+
html, body, [class*="css"] { font-family: 'Inter', sans-serif; }
|
| 10 |
+
|
| 11 |
+
.tech-badge {
|
| 12 |
+
display: inline-block; padding: 4px 12px; margin: 0 6px 8px 0;
|
| 13 |
+
border-radius: 20px; font-size: 0.82rem; font-weight: 600;
|
| 14 |
+
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.15);
|
| 15 |
+
color: #e0e0e0; transition: all 0.2s ease;
|
| 16 |
+
}
|
| 17 |
+
.tech-badge:hover {
|
| 18 |
+
background: rgba(0, 242, 254, 0.1);
|
| 19 |
+
border-color: #00f2fe; color: #00f2fe;
|
| 20 |
+
transform: translateY(-1px);
|
| 21 |
+
}
|
| 22 |
+
.live-link {
|
| 23 |
+
text-decoration: none; font-weight: 500; color: #00f2fe;
|
| 24 |
+
}
|
| 25 |
+
.live-link:hover { text-decoration: underline; }
|
| 26 |
+
</style>
|
| 27 |
+
""", unsafe_allow_html=True)
|
| 28 |
+
|
| 29 |
+
st.title("ℹ️ About the Platform")
|
| 30 |
+
|
| 31 |
+
st.markdown("""
|
| 32 |
+
This platform is a prototype of an end-to-end Machine Learning pipeline specifically designed to operate natively on resource-constrained embedded edge hardware (**TinyML**), targeting microcontrollers such as the **ESP32** or **ARM Cortex-M** architectures.
|
| 33 |
+
|
| 34 |
+
**Live Environments:**
|
| 35 |
+
* 🖥️ **Frontend Suite:** <a href="https://tinyml-heart-health-monitoring-dashboard-8xqogy2hibtlayt7popvs.streamlit.app/" target="_blank" class="live-link">Streamlit Cloud Deployment</a>
|
| 36 |
+
* ⚙️ **Backend Hub:** <a href="https://huggingface.co/spaces/IDKHowToCodeFr/tinyml-backend" target="_blank" class="live-link">Hugging Face Spaces API</a>
|
| 37 |
+
|
| 38 |
+
<div style="margin-top: 15px;">
|
| 39 |
+
<span class="tech-badge">Python 3.9+</span>
|
| 40 |
+
<span class="tech-badge">FastAPI</span>
|
| 41 |
+
<span class="tech-badge">Streamlit</span>
|
| 42 |
+
<span class="tech-badge">Scikit-Learn</span>
|
| 43 |
+
<span class="tech-badge">SHAP</span>
|
| 44 |
+
<span class="tech-badge">Docker</span>
|
| 45 |
+
<span class="tech-badge">PyTest</span>
|
| 46 |
+
</div>
|
| 47 |
+
""", unsafe_allow_html=True)
|
| 48 |
+
|
| 49 |
+
st.markdown("---")
|
| 50 |
+
|
| 51 |
+
st.markdown("### 🏗️ System Architecture")
|
| 52 |
+
# Create a Graphviz flowchart for the About page
|
| 53 |
+
graph = graphviz.Digraph(node_attr={'shape': 'box', 'style': 'rounded,filled', 'fontname': 'Helvetica', 'margin': '0.2'}, graph_attr={'rankdir': 'TB', 'splines': 'ortho', 'nodesep': '0.8'})
|
| 54 |
+
|
| 55 |
+
# Define UI & API nodes at the top
|
| 56 |
+
graph.node('UI', '🖥️ Streamlit Frontend\nInteractive Dashboard', fillcolor='#ff4b4b', fontcolor='white', color='#a30000', penwidth='2')
|
| 57 |
+
graph.node('API', '⚙️ FastAPI Backend\nRouting & API Logic', fillcolor='#009688', fontcolor='white', color='#004d40', penwidth='2')
|
| 58 |
+
graph.node('DB', '🗄️ SQLite DB\nPatient History', shape='cylinder', fillcolor='#9b59b6', fontcolor='white', color='#4a235a', penwidth='2')
|
| 59 |
+
|
| 60 |
+
# Core connections
|
| 61 |
+
graph.edge('UI', 'API', label=' REST JSON', color='#666666', fontcolor='#666666')
|
| 62 |
+
graph.edge('API', 'DB', label=' Read/Write', color='#666666', fontcolor='#666666')
|
| 63 |
+
|
| 64 |
+
# Machine Learning Subgraph
|
| 65 |
+
with graph.subgraph(name='cluster_ml') as ml:
|
| 66 |
+
ml.attr(label='🧠 Machine Learning & Inference Pipeline', style='dashed', color='#aaaaaa', fontcolor='#888888')
|
| 67 |
+
ml.node('ENS', 'Ensemble Engine\nSoft-Voting Aggregator', shape='diamond', fillcolor='#f2c94c', fontcolor='black', color='#b28900', penwidth='2')
|
| 68 |
+
ml.node('MODELS', 'Classifiers\n(RF, SVM, LogReg, NN, KNN)', fillcolor='#f2c94c', fontcolor='black', color='#b28900', penwidth='2')
|
| 69 |
+
ml.node('SHAP', '🔍 SHAP Explainer\nFeature Impact Analysis', fillcolor='#f2c94c', fontcolor='black', color='#b28900', penwidth='2')
|
| 70 |
+
|
| 71 |
+
# Hardware Export Subgraph
|
| 72 |
+
with graph.subgraph(name='cluster_edge') as edge:
|
| 73 |
+
edge.attr(label='⚡ Hardware & Edge AI Export', style='dashed', color='#aaaaaa', fontcolor='#888888')
|
| 74 |
+
edge.node('TRANS', 'C-Code Transpiler\nINT8 Quantization', fillcolor='#2d9cdb', fontcolor='white', color='#106093', penwidth='2')
|
| 75 |
+
edge.node('HEADER', 'tinyml_model.h\nOptimized Header File', shape='note', fillcolor='#2d9cdb', fontcolor='white', color='#106093', penwidth='2')
|
| 76 |
+
edge.node('MCU', '📟 ESP32 / ARM Cortex-M\nMicrocontroller Node', fillcolor='#2d9cdb', fontcolor='white', color='#106093', penwidth='2')
|
| 77 |
+
|
| 78 |
+
# Map Cross-cluster edges
|
| 79 |
+
graph.edge('API', 'ENS', label=' Predict', color='#333333')
|
| 80 |
+
graph.edge('ENS', 'MODELS', color='#333333')
|
| 81 |
+
|
| 82 |
+
graph.edge('API', 'SHAP', label=' Explain', color='#333333')
|
| 83 |
+
graph.edge('SHAP', 'MODELS', style='dashed', label=' Analyzes Trees', fontcolor='#555555', color='#555555')
|
| 84 |
+
|
| 85 |
+
graph.edge('API', 'TRANS', label=' Export Config', color='#333333')
|
| 86 |
+
graph.edge('TRANS', 'HEADER', label=' Scale FP32 to INT8', fontcolor='#555555', color='#555555')
|
| 87 |
+
graph.edge('HEADER', 'MCU', label=' Flash Firmware', color='#333333')
|
| 88 |
+
|
| 89 |
+
st.graphviz_chart(graph, use_container_width=True)
|
| 90 |
+
|
| 91 |
+
st.markdown("---")
|
| 92 |
+
st.markdown("### ✨ Core Capabilities")
|
| 93 |
+
|
| 94 |
+
col1, col2 = st.columns(2)
|
| 95 |
+
|
| 96 |
+
with col1:
|
| 97 |
+
st.info("""
|
| 98 |
+
**⚡ TinyML & Edge AI Optimization**\n
|
| 99 |
+
Traditional machine learning models process inference entirely in the cloud, generating heavy dependency on network transmission. This project supports **Edge AI**, converting Python-trained traditional classifiers into compiled C-code arrays for offline deployment on limited hardware.
|
| 100 |
+
We implement **INT8 Quantization**, scaling 64-bit Floating Point (`double`) coefficients down into 8-bit integers (`int8_t`). This scaling effectively shrinks memory payload by nearly 75% on microcontrollers.
|
| 101 |
+
""")
|
| 102 |
+
|
| 103 |
+
st.success("""
|
| 104 |
+
**🤝 Ensemble Machine Learning**\n
|
| 105 |
+
The predictive core relies on an Ensemble Engine. Different constituent models (KNN, Random Forest, SVM, Small NN, Logistic Regression) independently evaluate patient input variables and compute probabilties. A Soft-Voting technique averages their confidence scores to produce an overall healthy/at-risk prediction.
|
| 106 |
+
""")
|
| 107 |
+
|
| 108 |
+
with col2:
|
| 109 |
+
st.warning("""
|
| 110 |
+
**🔍 SHAP Clinical Explainability**\n
|
| 111 |
+
In the medical space, a black-box model is often untrusted. The project leverages SHapley Additive exPlanations (**SHAP**) natively connected to the Random Forest model layer. SHAP assigns absolute baseline impact scores to every incoming feature, interpreting *why* the AI predicted a specific condition and quantifying mathematically whether individual factors act as risk vectors or protective measures.
|
| 112 |
+
""")
|
| 113 |
+
|
| 114 |
+
st.error("""
|
| 115 |
+
**🔄 Continuous Learning (MLOps)**\n
|
| 116 |
+
System robustness requires adaptability. The **MLOps settings** permit uploading newly captured CSV datasets spanning emerging patient patterns. Submitting retraining logic prompts the backend pipeline to independently spin up `Scikit-Learn` algorithms to overwrite `.pkl` binary files, seamlessly refreshing the intelligence available on the dashboard.
|
| 117 |
+
""")
|
| 118 |
+
|
| 119 |
+
st.markdown("""
|
| 120 |
+
<div style="text-align: center; margin-top: 40px; padding-top: 20px; border-top: 1px solid rgba(255,255,255,0.1); color: rgba(255,255,255,0.5); font-size: 0.85rem;">
|
| 121 |
+
Designed as a scalable, fully interpretable Edge-based Machine Learning architecture for modern Healthcare telemetry.<br>
|
| 122 |
+
MIT License
|
| 123 |
+
</div>
|
| 124 |
+
""", unsafe_allow_html=True)
|
frontend/requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit>=1.36.0
|
| 2 |
+
pandas>=2.2.2
|
| 3 |
+
numpy>=1.26.4
|
| 4 |
+
plotly>=5.22.0
|
| 5 |
+
requests>=2.32.3
|
| 6 |
+
scikit-learn>=1.5.1
|
| 7 |
+
joblib>=1.4.2
|
| 8 |
+
matplotlib
|
| 9 |
+
graphviz>=0.20.1
|
requirements.txt
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
| 1 |
+
# --- STREAMLIT CLOUD DEPLOYMENT REQUIREMENTS ---
|
| 2 |
+
# (Merged backend + frontend libs to satisfy Streamlit Commmunity Cloud deployment)
|
| 3 |
+
fastapi>=0.111.0
|
| 4 |
+
uvicorn>=0.30.1
|
| 5 |
+
scikit-learn>=1.5.1
|
| 6 |
+
pandas>=2.2.2
|
| 7 |
+
numpy>=1.26.4
|
| 8 |
+
joblib>=1.4.2
|
| 9 |
+
streamlit>=1.36.0
|
| 10 |
+
plotly>=5.22.0
|
| 11 |
+
requests>=2.32.3
|
run.bat
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
echo ==============================================
|
| 3 |
+
echo TinyML Dashboard Launch Sequence
|
| 4 |
+
echo ==============================================
|
| 5 |
+
|
| 6 |
+
echo [1/3] Installing/Verifying Dependencies...
|
| 7 |
+
python -m pip install -r backend\requirements.txt
|
| 8 |
+
python -m pip install -r frontend\requirements.txt
|
| 9 |
+
|
| 10 |
+
echo [2/3] Spinning up FastAPI Backend...
|
| 11 |
+
echo Clearing previous instances on port 8000 (if any)...
|
| 12 |
+
for /f "tokens=5" %%a in ('netstat -aon ^| find ":8000" ^| find "LISTENING"') do taskkill /f /pid %%a >nul 2>&1
|
| 13 |
+
start /B "TinyML Backend" cmd /c "cd backend && python -m uvicorn main:app --host 0.0.0.0 --port 8000"
|
| 14 |
+
|
| 15 |
+
echo [3/3] Launching Streamlit Frontend...
|
| 16 |
+
cd frontend
|
| 17 |
+
python -m streamlit run Home.py
|
| 18 |
+
|
tests/test_backend.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from fastapi.testclient import TestClient
|
| 3 |
+
import sys
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
# Ensure backend is in path
|
| 7 |
+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../backend')))
|
| 8 |
+
from main import app
|
| 9 |
+
|
| 10 |
+
client = TestClient(app)
|
| 11 |
+
|
| 12 |
+
def test_health_check():
|
| 13 |
+
"""Test that the backend API is up and running."""
|
| 14 |
+
# Assuming there varies a root or health endpoint. If not, this is a placeholder.
|
| 15 |
+
response = client.get("/")
|
| 16 |
+
# Even if it's 404 because no root exists, the server responds.
|
| 17 |
+
assert response.status_code in [200, 404]
|
| 18 |
+
|
| 19 |
+
def test_predict_endpoint_validation():
|
| 20 |
+
"""Test that the predict endpoint rejects invalid data."""
|
| 21 |
+
payload = {
|
| 22 |
+
"Heart_Rate": "invalid_string",
|
| 23 |
+
"SpO2_Level": 98
|
| 24 |
+
}
|
| 25 |
+
response = client.post("/predict", json=payload)
|
| 26 |
+
assert response.status_code == 422 # Unprocessable Entity (Pydantic validation)
|
| 27 |
+
|